diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 82ab912f8..8a83b57a8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,6 +31,41 @@ jobs: # match every accumulated change and defeat the filter. base: ${{ github.event_name == 'push' && github.event.before || '' }} filters: | + # A change to a root build file (the solution, restore config, or THIS workflow) can + # affect every product, so it forces a full build/test/publish. + root: + - 'PerformanceMonitor.sln' + - 'global.json' + - 'nuget.config' + - 'NuGet.config' + - '.github/workflows/build.yml' + # The shared PerformanceMonitor.* core libraries feed Lite, the Full Dashboard, AND + # Darling (verified via ProjectReference), so a change here fans out to all three. + # NOT the CLI Installer — it references only Installer.Core. + core: + - 'PerformanceMonitor.Alerting/**' + - 'PerformanceMonitor.Analysis/**' + - 'PerformanceMonitor.Collectors/**' + - 'PerformanceMonitor.Common/**' + - 'PerformanceMonitor.Notifications/**' + - 'PerformanceMonitor.PlanAnalysis/**' + - 'PerformanceMonitor.Ui/**' + - '!**/*.md' + # Installer.Core is shared by the CLI Installer AND the Full Dashboard's integrated + # installer — a change rebuilds both, and nothing else. + installer_core: + - 'Installer.Core/**' + - '!**/*.md' + dashboard: + - 'Dashboard/**' + - 'Dashboard.Tests/**' + - '!**/*.md' + lite: + - 'Lite/**' + - 'Lite.Tests/**' + - '!**/*.md' + # Lite's analysis-heavy test subset — the narrower gate the workflow already used, so + # those slow tests run only when Lite analysis code (or shared/root code) changed. lite_analysis: - 'Lite/Analysis/**' - 'Lite/Services/**' @@ -38,12 +73,16 @@ jobs: - 'Lite/Models/**' - 'Lite.Tests/**' - 'Lite/PerformanceMonitorLite.csproj' + - '!**/*.md' installer: - 'Installer/**' - - 'Installer.Core/**' - 'Installer.Tests/**' - 'install/**' - 'upgrades/**' + - '!**/*.md' + darling: + - 'Darling/**' + - '!**/*.md' # True when any non-documentation file changed. Documentation-only # changes (e.g. a CHANGELOG or README edit) skip the build/test/ # publish steps below — there is nothing to compile. The job still @@ -68,27 +107,39 @@ jobs: dotnet restore Installer/PerformanceMonitorInstaller.csproj --locked-mode dotnet restore Lite.Tests/Lite.Tests.csproj --locked-mode dotnet restore Installer.Tests/Installer.Tests.csproj --locked-mode + dotnet restore Darling/Darling.Tests/Darling.Tests.csproj --locked-mode + dotnet restore Darling/PerformanceMonitor.Darling.Viewer/PerformanceMonitor.Darling.Viewer.csproj --locked-mode - name: Build Lite.Tests - if: steps.filter.outputs.code != 'false' + if: steps.filter.outputs.lite == 'true' || steps.filter.outputs.core == 'true' || steps.filter.outputs.root == 'true' || github.event_name == 'release' run: dotnet build Lite.Tests/Lite.Tests.csproj -c Release --no-restore - name: Build Installer.Tests - if: steps.filter.outputs.code != 'false' + if: steps.filter.outputs.installer == 'true' || steps.filter.outputs.installer_core == 'true' || steps.filter.outputs.root == 'true' || github.event_name == 'release' run: dotnet build Installer.Tests/Installer.Tests.csproj -c Release --no-restore + - name: Build Darling + if: steps.filter.outputs.darling == 'true' || steps.filter.outputs.core == 'true' || steps.filter.outputs.root == 'true' || github.event_name == 'release' + run: | + dotnet build Darling/Darling.Tests/Darling.Tests.csproj -c Release --no-restore + dotnet build Darling/PerformanceMonitor.Darling.Viewer/PerformanceMonitor.Darling.Viewer.csproj -c Release --no-restore + - name: Run Lite fast tests - if: steps.filter.outputs.code != 'false' + if: steps.filter.outputs.lite == 'true' || steps.filter.outputs.core == 'true' || steps.filter.outputs.root == 'true' || github.event_name == 'release' run: dotnet test Lite.Tests/Lite.Tests.csproj -c Release --no-build --verbosity normal --filter "FullyQualifiedName!~AnomalyDetectorTests&FullyQualifiedName!~FactCollectorTests&FullyQualifiedName!~FactCollectorMiseryTests&FullyQualifiedName!~BaselineProviderTests&FullyQualifiedName!~InferenceEngineTests&FullyQualifiedName!~ScenarioTests&FullyQualifiedName!~AnalysisServiceTests" - name: Run Lite analysis-heavy tests - if: steps.filter.outputs.lite_analysis == 'true' || github.event_name == 'release' + if: steps.filter.outputs.lite_analysis == 'true' || steps.filter.outputs.core == 'true' || steps.filter.outputs.root == 'true' || github.event_name == 'release' run: dotnet test Lite.Tests/Lite.Tests.csproj -c Release --no-build --verbosity normal --filter "FullyQualifiedName~AnomalyDetectorTests|FullyQualifiedName~FactCollectorTests|FullyQualifiedName~FactCollectorMiseryTests|FullyQualifiedName~BaselineProviderTests|FullyQualifiedName~InferenceEngineTests|FullyQualifiedName~ScenarioTests|FullyQualifiedName~AnalysisServiceTests" - name: Run Installer tests - if: steps.filter.outputs.installer == 'true' || github.event_name == 'release' + if: steps.filter.outputs.installer == 'true' || steps.filter.outputs.installer_core == 'true' || steps.filter.outputs.root == 'true' || github.event_name == 'release' run: dotnet test Installer.Tests/Installer.Tests.csproj -c Release --no-build --verbosity normal --filter "FullyQualifiedName!~VersionDetectionTests&FullyQualifiedName!~IdempotencyTests&FullyQualifiedName!~AdversarialTests" + - name: Run Darling tests + if: steps.filter.outputs.darling == 'true' || steps.filter.outputs.core == 'true' || steps.filter.outputs.root == 'true' || github.event_name == 'release' + run: dotnet test Darling/Darling.Tests/Darling.Tests.csproj -c Release --no-build --verbosity normal + - name: Get version if: steps.filter.outputs.code != 'false' id: version @@ -98,7 +149,7 @@ jobs: echo "VERSION=$version" >> $env:GITHUB_OUTPUT - name: Publish Dashboard - if: steps.filter.outputs.code != 'false' + if: steps.filter.outputs.dashboard == 'true' || steps.filter.outputs.installer_core == 'true' || steps.filter.outputs.core == 'true' || steps.filter.outputs.root == 'true' || github.event_name == 'release' run: dotnet publish Dashboard/Dashboard.csproj -c Release -o publish/Dashboard - name: Publish Dashboard (self-contained for Velopack) @@ -106,7 +157,7 @@ jobs: run: dotnet publish Dashboard/Dashboard.csproj -c Release -r win-x64 --self-contained -o publish/Dashboard-velopack - name: Publish Lite - if: steps.filter.outputs.code != 'false' + if: steps.filter.outputs.lite == 'true' || steps.filter.outputs.core == 'true' || steps.filter.outputs.root == 'true' || github.event_name == 'release' run: dotnet publish Lite/PerformanceMonitorLite.csproj -c Release -o publish/Lite - name: Publish Lite (self-contained for Velopack) @@ -114,9 +165,45 @@ jobs: run: dotnet publish Lite/PerformanceMonitorLite.csproj -c Release -r win-x64 --self-contained -o publish/Lite-velopack - name: Publish CLI Installer - if: steps.filter.outputs.code != 'false' + if: steps.filter.outputs.installer == 'true' || steps.filter.outputs.installer_core == 'true' || steps.filter.outputs.root == 'true' || github.event_name == 'release' run: dotnet publish Installer/PerformanceMonitorInstaller.csproj -c Release + # Darling (headless edition): the Windows-service collector and the WPF viewer, published + # framework-dependent for the plain zip (both apps). The SERVICE stays zip-only — a Windows + # service has a different update story, deliberately deferred for v1. The VIEWER additionally + # ships as a remote-seat Velopack Setup.exe (#1555) via the self-contained publish below, + # exactly like Dashboard/Lite. + - name: Publish Darling Service + if: steps.filter.outputs.darling == 'true' || steps.filter.outputs.core == 'true' || steps.filter.outputs.root == 'true' || github.event_name == 'release' + run: dotnet publish Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj -c Release -o publish/DarlingService + + - name: Publish Darling Viewer + if: steps.filter.outputs.darling == 'true' || steps.filter.outputs.core == 'true' || steps.filter.outputs.root == 'true' || github.event_name == 'release' + run: dotnet publish Darling/PerformanceMonitor.Darling.Viewer/PerformanceMonitor.Darling.Viewer.csproj -c Release -o publish/DarlingViewer + + - name: Publish Darling Viewer (self-contained for Velopack) + if: github.event_name == 'release' + run: dotnet publish Darling/PerformanceMonitor.Darling.Viewer/PerformanceMonitor.Darling.Viewer.csproj -c Release -r win-x64 --self-contained -o publish/DarlingViewer-velopack + + # Darling bundles a PostgreSQL 18 + TimescaleDB runtime (pg-runtime.zip) that ships beside + # the service exe; DarlingManagedPostgres extracts it on first run. The fetch script pulls + # ~340MB of pinned EDB/TimescaleDB archives, so this is release-only and cached. The key is + # the fetch script's own content hash (the SHA256 pins live inside it): a re-release with + # unchanged pins restores the assembled zip and skips both the download and the assembly, + # and any pin/version bump edits the script and invalidates the cache automatically. + - name: Cache Darling pg-runtime.zip + if: github.event_name == 'release' + id: cache-pg-runtime + uses: actions/cache@v4 + with: + path: Darling/artifacts/pg-runtime.zip + key: pg-runtime-${{ runner.os }}-${{ hashFiles('Darling/tools/fetch-pg-runtime.ps1') }} + + - name: Build Darling pg-runtime.zip + if: github.event_name == 'release' && steps.cache-pg-runtime.outputs.cache-hit != 'true' + shell: pwsh + run: ./Darling/tools/fetch-pg-runtime.ps1 + - name: Package release artifacts if: github.event_name == 'release' shell: pwsh @@ -171,6 +258,29 @@ jobs: name: Installer-unsigned path: publish/Installer/ + # Only our own .NET output (the service + viewer apphost exes + PerformanceMonitor.* dlls) is + # uploaded to SignPath, staged in the exact layout the single 'Darling' artifact-configuration + # signs: the service at the root, the viewer under viewer/. pg-runtime.zip is deliberately NOT + # here — the bundled EDB PostgreSQL binaries inside are EDB's and ship as opaque data, so the + # zip is placed into the package AFTER signing (see "Package Darling (signed)" below). + - name: Stage Darling for signing + if: github.event_name == 'release' + shell: pwsh + run: | + $stage = 'publish/Darling-signing' + if (Test-Path $stage) { Remove-Item -Recurse -Force $stage } + New-Item -ItemType Directory -Force -Path "$stage/viewer" | Out-Null + Copy-Item 'publish/DarlingService/*' $stage -Recurse + Copy-Item 'publish/DarlingViewer/*' "$stage/viewer" -Recurse + + - name: Upload Darling for signing + if: github.event_name == 'release' + id: upload-darling + uses: actions/upload-artifact@v6 + with: + name: Darling-unsigned + path: publish/Darling-signing/ + - name: Sign Dashboard if: github.event_name == 'release' uses: signpath/github-action-submit-signing-request@v2 @@ -210,6 +320,24 @@ jobs: wait-for-completion: true output-artifact-directory: 'signed/Installer' + # The single 'Darling' artifact-configuration slug on signpath.io signs the two apphost exes + # at their staged paths (PerformanceMonitor.Darling.Service.exe at the root and + # viewer/PerformanceMonitor.Darling.Viewer.exe) and leaves the PerformanceMonitor.* and + # third-party dlls alone. That config (which files get signed) lives outside this repo; until + # the 'Darling' slug exists this step fails the release. + - name: Sign Darling + if: github.event_name == 'release' + uses: signpath/github-action-submit-signing-request@v2 + with: + api-token: '${{ secrets.SIGNPATH_API_TOKEN }}' + organization-id: '7969f8b6-d946-4a74-9bac-a55856d8b8e0' + project-slug: 'PerformanceMonitor' + signing-policy-slug: 'release-signing' + artifact-configuration-slug: 'Darling' + github-artifact-id: '${{ steps.upload-darling.outputs.artifact-id }}' + wait-for-completion: true + output-artifact-directory: 'signed/Darling' + - name: Replace with signed artifacts if: github.event_name == 'release' shell: pwsh @@ -223,6 +351,98 @@ jobs: Remove-Item "releases/PerformanceMonitorInstaller-$version.zip" -ErrorAction SilentlyContinue Compress-Archive -Path 'signed/Installer/*' -DestinationPath "releases/PerformanceMonitorInstaller-$version.zip" -Force + - name: Package Darling (signed) + if: github.event_name == 'release' + shell: pwsh + run: | + $version = "${{ steps.version.outputs.VERSION }}" + # One product, one zip (mirrors the one-zip-per-product convention above). signed/Darling + # already holds the signed tree in its final layout — the service at the archive root (its + # darling.sample.json alongside), the viewer in a viewer\ subfolder. Drop pg-runtime.zip + # beside the service exe, exactly where DarlingManagedPostgres looks (AppContext.BaseDirectory) + # and extracts it on first run. The EDB PostgreSQL binaries inside pg-runtime.zip are shipped + # as opaque data and were never signed. + Copy-Item 'Darling/artifacts/pg-runtime.zip' 'signed/Darling' + + Remove-Item "releases/PerformanceMonitorDarling-$version.zip" -ErrorAction SilentlyContinue + Compress-Archive -Path 'signed/Darling/*' -DestinationPath "releases/PerformanceMonitorDarling-$version.zip" -Force + + # The Velopack (Setup.exe) path publishes a SEPARATE self-contained build + # (publish/Dashboard-velopack, publish/Lite-velopack -- see the "self-contained + # for Velopack" steps above) that previously went straight into `vpk pack` + # without ever being uploaded to SignPath. Only the framework-dependent trees + # used for the legacy ZIPs were signed, so every Setup.exe shipped unsigned + # since Velopack packaging was introduced. These steps close that gap by + # mirroring the exact upload/sign pattern used for Dashboard/Lite/Installer + # above, and vpk pack below now reads from the signed output. + - name: Upload Dashboard (Velopack) for signing + if: github.event_name == 'release' + id: upload-dashboard-velopack + uses: actions/upload-artifact@v6 + with: + name: Dashboard-Velopack-unsigned + path: publish/Dashboard-velopack/ + + - name: Upload Lite (Velopack) for signing + if: github.event_name == 'release' + id: upload-lite-velopack + uses: actions/upload-artifact@v6 + with: + name: Lite-Velopack-unsigned + path: publish/Lite-velopack/ + + - name: Upload Darling Viewer (Velopack) for signing + if: github.event_name == 'release' + id: upload-darlingviewer-velopack + uses: actions/upload-artifact@v6 + with: + name: DarlingViewer-Velopack-unsigned + path: publish/DarlingViewer-velopack/ + + - name: Sign Dashboard (Velopack) + if: github.event_name == 'release' + uses: signpath/github-action-submit-signing-request@v2 + with: + api-token: '${{ secrets.SIGNPATH_API_TOKEN }}' + organization-id: '7969f8b6-d946-4a74-9bac-a55856d8b8e0' + project-slug: 'PerformanceMonitor' + signing-policy-slug: 'release-signing' + artifact-configuration-slug: 'Dashboard' + github-artifact-id: '${{ steps.upload-dashboard-velopack.outputs.artifact-id }}' + wait-for-completion: true + output-artifact-directory: 'signed/Dashboard-Velopack' + + - name: Sign Lite (Velopack) + if: github.event_name == 'release' + uses: signpath/github-action-submit-signing-request@v2 + with: + api-token: '${{ secrets.SIGNPATH_API_TOKEN }}' + organization-id: '7969f8b6-d946-4a74-9bac-a55856d8b8e0' + project-slug: 'PerformanceMonitor' + signing-policy-slug: 'release-signing' + artifact-configuration-slug: 'Lite' + github-artifact-id: '${{ steps.upload-lite-velopack.outputs.artifact-id }}' + wait-for-completion: true + output-artifact-directory: 'signed/Lite-Velopack' + + # The remote-seat viewer Setup.exe (#1555) is signed with its OWN 'DarlingViewer' artifact + # configuration — the co-located-zip 'Darling' slug signs a service+viewer\ tree layout, which + # does not match this self-contained viewer-at-root publish. Like the 'Darling' slug, the + # 'DarlingViewer' config (which files get signed) lives outside this repo on signpath.io; until + # Erik creates the slug this step fails the release — the standing #1340 SignPath prerequisite. + - name: Sign Darling Viewer (Velopack) + if: github.event_name == 'release' + uses: signpath/github-action-submit-signing-request@v2 + with: + api-token: '${{ secrets.SIGNPATH_API_TOKEN }}' + organization-id: '7969f8b6-d946-4a74-9bac-a55856d8b8e0' + project-slug: 'PerformanceMonitor' + signing-policy-slug: 'release-signing' + artifact-configuration-slug: 'DarlingViewer' + github-artifact-id: '${{ steps.upload-darlingviewer-velopack.outputs.artifact-id }}' + wait-for-completion: true + output-artifact-directory: 'signed/DarlingViewer-Velopack' + - name: Create Velopack release (Dashboard) if: github.event_name == 'release' shell: pwsh @@ -235,14 +455,21 @@ jobs: dotnet tool install -g vpk --version 1.2.0 New-Item -ItemType Directory -Force -Path releases/velopack-dashboard New-Item -ItemType Directory -Force -Path releases/velopack-lite + New-Item -ItemType Directory -Force -Path releases/velopack-darlingviewer - # Dashboard: download previous + pack + # Dashboard: download previous + pack (from the SIGNED velopack output, not the raw publish dir) vpk download github --repoUrl https://github.com/${{ github.repository }} --channel dashboard -o releases/velopack-dashboard --token $env:GH_TOKEN - vpk pack -u PerformanceMonitorDashboard -v $env:VERSION -p publish/Dashboard-velopack -e PerformanceMonitorDashboard.exe -o releases/velopack-dashboard --channel dashboard + vpk pack -u PerformanceMonitorDashboard -v $env:VERSION -p signed/Dashboard-Velopack -e PerformanceMonitorDashboard.exe -o releases/velopack-dashboard --channel dashboard - # Lite: download previous + pack + # Lite: download previous + pack (from the SIGNED velopack output, not the raw publish dir) vpk download github --repoUrl https://github.com/${{ github.repository }} --channel lite -o releases/velopack-lite --token $env:GH_TOKEN - vpk pack -u PerformanceMonitorLite -v $env:VERSION -p publish/Lite-velopack -e PerformanceMonitorLite.exe -o releases/velopack-lite --channel lite + vpk pack -u PerformanceMonitorLite -v $env:VERSION -p signed/Lite-Velopack -e PerformanceMonitorLite.exe -o releases/velopack-lite --channel lite + + # Darling Viewer remote-seat installer (#1555): download previous + pack (from the SIGNED + # velopack output). Its own 'darlingviewer' channel/delta feed, separate from the co-located + # viewer inside PerformanceMonitorDarling-*.zip (which stays plain-zip only). + vpk download github --repoUrl https://github.com/${{ github.repository }} --channel darlingviewer -o releases/velopack-darlingviewer --token $env:GH_TOKEN + vpk pack -u PerformanceMonitorDarlingViewer -v $env:VERSION -p signed/DarlingViewer-Velopack -e PerformanceMonitor.Darling.Viewer.exe -o releases/velopack-darlingviewer --channel darlingviewer - name: Generate checksums if: github.event_name == 'release' @@ -280,3 +507,147 @@ jobs: VERSION: ${{ steps.version.outputs.VERSION }} run: | vpk upload github --repoUrl https://github.com/${{ github.repository }} --channel lite -o releases/velopack-lite --releaseName "v$env:VERSION" --tag "v$env:VERSION" --merge --token $env:GH_TOKEN + + - name: Upload Darling Viewer Velopack artifacts + if: github.event_name == 'release' + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.VERSION }} + run: | + vpk upload github --repoUrl https://github.com/${{ github.repository }} --channel darlingviewer -o releases/velopack-darlingviewer --releaseName "v$env:VERSION" --tag "v$env:VERSION" --merge --token $env:GH_TOKEN + + # #1587: gated-live Darling coverage BEFORE merge, not only in the nightly. Darling.Tests has + # live-PostgreSQL tests (the *_AgainstDevPostgres classes) gated on DARLING_TEST_PG, which the + # build job above never sets — so those tests only ever ran post-merge in nightly.yml. That is + # exactly how #1586's alter_job(bigint) bug merged AND deployed clean: the test that catches it is + # gated-live, and PR CI did not run it. This job stands up a throwaway PostgreSQL + TimescaleDB + # from the bundled pg-runtime and runs the FULL Darling suite against it — but ONLY when Darling + # code (or this workflow) changed, so a Lite/Dashboard-only PR pays nothing. On a non-Darling + # change every step below no-ops via the path filter and the job still reports SUCCESS, so it can + # be made a required check without blocking unrelated PRs (the same always-runs-reports-a-result + # shape the build job uses for doc-only changes). Mirrors the nightly darling-pg job step-for-step; + # the one live-SQL-Server E2E stays skipped (DARLING_TEST_SQL unset — no SQL Server on the runner). + darling-pg: + name: Darling PostgreSQL tests + runs-on: windows-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v5 + + # Only do the expensive TimescaleDB work when Darling code changed — or when THIS workflow + # changed, so a change to the gate itself is exercised by the gate (this is what makes the PR + # that introduces this job validate itself end-to-end). Doc-only Darling edits don't trigger + # it. Skipped entirely on release: the dev push that produced the release commit already ran + # it, so the filter step doesn't run and every step below no-ops. + - name: Detect changed paths + id: filter + if: github.event_name != 'release' + uses: dorny/paths-filter@v3 + with: + # On push, compare against the previous commit on this branch (mirrors the build job); + # on pull_request, an empty base makes the action diff against the PR base branch. + base: ${{ github.event_name == 'push' && github.event.before || '' }} + filters: | + darling: + - 'Darling/**' + - '!Darling/**/*.md' + - '.github/workflows/build.yml' + + - name: Setup .NET 10.0 + if: steps.filter.outputs.darling == 'true' + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + cache: true + cache-dependency-path: '**/packages.lock.json' + + # Same cache key the release job and nightly.yml use (the fetch script's own content hash), so + # a warm cache from any of the three means no ~340MB EDB/TimescaleDB download here. + - name: Cache Darling pg-runtime.zip + if: steps.filter.outputs.darling == 'true' + id: cache-pg-runtime + uses: actions/cache@v4 + with: + path: Darling/artifacts/pg-runtime.zip + key: pg-runtime-${{ runner.os }}-${{ hashFiles('Darling/tools/fetch-pg-runtime.ps1') }} + + - name: Build Darling pg-runtime.zip (cache miss only) + if: steps.filter.outputs.darling == 'true' && steps.cache-pg-runtime.outputs.cache-hit != 'true' + shell: pwsh + run: ./Darling/tools/fetch-pg-runtime.ps1 + + - name: Extract pg-runtime + if: steps.filter.outputs.darling == 'true' + shell: pwsh + run: | + $zip = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime.zip" + $dest = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime" + if (Test-Path $dest) { Remove-Item -Recurse -Force $dest } + Add-Type -AssemblyName System.IO.Compression.FileSystem + [System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $dest) + if (-not (Test-Path "$dest\pgsql\bin\pg_ctl.exe")) { throw "pg-runtime missing pgsql\bin\pg_ctl.exe" } + + - name: Restore Darling.Tests + if: steps.filter.outputs.darling == 'true' + run: dotnet restore Darling/Darling.Tests/Darling.Tests.csproj --locked-mode + + - name: Build Darling.Tests + if: steps.filter.outputs.darling == 'true' + run: dotnet build Darling/Darling.Tests/Darling.Tests.csproj -c Release --no-restore + + # Throwaway cluster from the bundled runtime. initdb TRUST auth is acceptable ONLY here: an + # ephemeral CI runner, loopback-only, throwaway data (the product default is scram-sha-256 + + # a generated credential). Superuser is "darling" (not "postgres") so CREATE SCHEMA ... + # AUTHORIZATION darling in the V8 schema-split migration resolves. Settings mirror + # DarlingManagedPostgres.BuildConfAppend (timescaledb preload, port 5541, loopback bind). + - name: Initialize and start throwaway PostgreSQL + if: steps.filter.outputs.darling == 'true' + shell: pwsh + run: | + $bin = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime\pgsql\bin" + $dataDir = "$env:RUNNER_TEMP\darling-pgdata" + $logFile = "$env:RUNNER_TEMP\darling-pg.log" + & "$bin\initdb.exe" -D $dataDir -U darling -A trust --encoding=UTF8 + if ($LASTEXITCODE -ne 0) { throw "initdb failed ($LASTEXITCODE)" } + Add-Content -Path "$dataDir\postgresql.conf" -Value "shared_preload_libraries = 'timescaledb'" + Add-Content -Path "$dataDir\postgresql.conf" -Value "port = 5541" + Add-Content -Path "$dataDir\postgresql.conf" -Value "listen_addresses = '127.0.0.1'" + & "$bin\pg_ctl.exe" -D $dataDir -l $logFile -w start + if ($LASTEXITCODE -ne 0) { if (Test-Path $logFile) { Get-Content $logFile -Tail 50 }; throw "pg_ctl start failed ($LASTEXITCODE)" } + & "$bin\createdb.exe" -h 127.0.0.1 -p 5541 -U darling darling + if ($LASTEXITCODE -ne 0) { throw "createdb failed ($LASTEXITCODE)" } + + # DARLING_TEST_PG lights up the [Collection("live-postgres")] classes; DARLING_TEST_PGRUNTIME + # lights up the managed-bootstrap E2E. DARLING_TEST_SQL is intentionally unset — the one + # live-SQL-Server E2E stays skipped (no SQL Server on the runner). Full suite (not a filtered + # subset): the ungated ~3s overlap with the build job is negligible and a filter could hide a + # test the way narrow filters have bitten before. + - name: Run Darling PG tests + if: steps.filter.outputs.darling == 'true' + shell: pwsh + env: + DARLING_TEST_PG: "Host=127.0.0.1;Port=5541;Username=darling;Database=darling" + DARLING_TEST_PGRUNTIME: ${{ github.workspace }}\Darling\artifacts\pg-runtime + run: dotnet test Darling/Darling.Tests/Darling.Tests.csproj -c Release --no-build --verbosity normal --logger "trx;LogFileName=darling-pr.trx" --results-directory TestResults + + - name: Stop PostgreSQL + if: always() && steps.filter.outputs.darling == 'true' + shell: pwsh + run: | + $bin = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime\pgsql\bin" + $dataDir = "$env:RUNNER_TEMP\darling-pgdata" + if (Test-Path "$bin\pg_ctl.exe") { & "$bin\pg_ctl.exe" -D $dataDir -m fast -w stop } + exit 0 + + - name: Upload PG log and test results on failure + if: failure() && steps.filter.outputs.darling == 'true' + uses: actions/upload-artifact@v6 + with: + name: darling-pg-failure + path: | + ${{ runner.temp }}/darling-pg.log + TestResults/ + if-no-files-found: ignore diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index f886a01a5..3f526b037 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -65,6 +65,7 @@ jobs: dotnet restore Lite/PerformanceMonitorLite.csproj --locked-mode dotnet restore Installer/PerformanceMonitorInstaller.csproj --locked-mode dotnet restore Lite.Tests/Lite.Tests.csproj --locked-mode + dotnet restore Darling/PerformanceMonitor.Darling.Viewer/PerformanceMonitor.Darling.Viewer.csproj --locked-mode - name: Run tests run: dotnet test Lite.Tests/Lite.Tests.csproj -c Release --verbosity normal @@ -78,6 +79,34 @@ jobs: - name: Publish CLI Installer run: dotnet publish Installer/PerformanceMonitorInstaller.csproj -c Release + - name: Publish Darling Service + run: dotnet publish Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj -c Release -o publish/DarlingService + + - name: Publish Darling Viewer + run: dotnet publish Darling/PerformanceMonitor.Darling.Viewer/PerformanceMonitor.Darling.Viewer.csproj -c Release -o publish/DarlingViewer + + # Self-contained viewer publish that feeds the remote-seat Velopack Setup.exe (#1555), the same + # publish shape build.yml uses for the Dashboard/Lite Velopack packs. This is IN ADDITION to the + # framework-dependent "Publish Darling Viewer" above, which still feeds the co-located viewer\ + # folder inside PerformanceMonitorDarling-*.zip — that zip is unchanged. + - name: Publish Darling Viewer (self-contained for Velopack) + run: dotnet publish Darling/PerformanceMonitor.Darling.Viewer/PerformanceMonitor.Darling.Viewer.csproj -c Release -r win-x64 --self-contained -o publish/DarlingViewer-velopack + + # Same cache key as build.yml's release path: the fetch script's content hash (the SHA256 + # pins live inside it). The ~340MB EDB/TimescaleDB fetch runs at most once per pin-set per + # branch; nightly and release runs share the assembled zip whenever the cache is visible. + - name: Cache Darling pg-runtime.zip + id: cache-pg-runtime + uses: actions/cache@v4 + with: + path: Darling/artifacts/pg-runtime.zip + key: pg-runtime-${{ runner.os }}-${{ hashFiles('Darling/tools/fetch-pg-runtime.ps1') }} + + - name: Build Darling pg-runtime.zip + if: steps.cache-pg-runtime.outputs.cache-hit != 'true' + shell: pwsh + run: ./Darling/tools/fetch-pg-runtime.ps1 + - name: Package artifacts shell: pwsh run: | @@ -102,10 +131,39 @@ jobs: Compress-Archive -Path 'publish/Installer/*' -DestinationPath "releases/PerformanceMonitorInstaller-$version.zip" -Force + # Same layout as the release zip (build.yml "Package Darling (signed)"): service at the + # archive root with darling.sample.json alongside, viewer under viewer\, pg-runtime.zip + # beside the service exe where DarlingManagedPostgres extracts it on first run. Nightly + # zips are unsigned across the board, so this stages from publish/ instead of signed/. + $darlingDir = 'publish/Darling' + New-Item -ItemType Directory -Force -Path "$darlingDir/viewer" | Out-Null + Copy-Item 'publish/DarlingService/*' $darlingDir -Recurse + Copy-Item 'publish/DarlingViewer/*' "$darlingDir/viewer" -Recurse + Copy-Item 'Darling/artifacts/pg-runtime.zip' $darlingDir + + Compress-Archive -Path 'publish/Darling/*' -DestinationPath "releases/PerformanceMonitorDarling-$version.zip" -Force + + # Darling viewer remote-seat installer (#1555). Nightly ships it UNSIGNED like every other nightly + # artifact. Mirrors build.yml's release vpk pack (same pack id / exe / channel) but packs from the + # raw self-contained publish (no SignPath), and deliberately does NOT touch the Velopack update + # feed: the nightly GitHub release is deleted + recreated each night, so there is no persistent + # delta chain to `vpk download`/`vpk upload` from — we ship a standalone full Setup.exe as a plain + # release asset. Copied to a deterministic name so the checksum + upload steps below pick it up. + # Purely additive: the co-located viewer inside PerformanceMonitorDarling-*.zip is untouched. + - name: Create Darling Viewer Setup.exe (Velopack, unsigned) + shell: pwsh + run: | + $version = "${{ steps.version.outputs.VERSION }}" + dotnet tool install -g vpk --version 1.2.0 + New-Item -ItemType Directory -Force -Path releases/velopack-darlingviewer + vpk pack -u PerformanceMonitorDarlingViewer -v $version -p publish/DarlingViewer-velopack -e PerformanceMonitor.Darling.Viewer.exe -o releases/velopack-darlingviewer --channel darlingviewer + $setup = Get-ChildItem releases/velopack-darlingviewer/*Setup.exe | Select-Object -First 1 + Copy-Item $setup.FullName "releases/PerformanceMonitorDarlingViewer-$version-Setup.exe" + - name: Generate checksums shell: pwsh run: | - $checksums = Get-ChildItem releases/*.zip | ForEach-Object { + $checksums = Get-ChildItem releases/*.zip, releases/*.exe | ForEach-Object { $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower() "$hash $($_.Name)" } @@ -142,4 +200,123 @@ jobs: --title "Nightly Build ($version)" ` --notes $body ` --prerelease ` - releases/*.zip releases/SHA256SUMS.txt + releases/*.zip releases/*.exe releases/SHA256SUMS.txt + + # Darling.Tests has live-PostgreSQL tests gated on env vars that the per-PR/push build never + # sets, so regular CI runs only the ungated subset. This job stands up a throwaway PostgreSQL + # from the bundled pg-runtime and runs the full Darling suite against it nightly, so the + # Postgres + managed-bootstrap paths get real coverage. The one live-SQL-Server E2E stays + # skipped (no SQL Server on the runner) — expected. Gated like the build job so it only runs + # when dev actually changed (or on manual dispatch). + darling-pg: + name: Darling PostgreSQL tests + needs: check + if: needs.check.outputs.has_changes == 'true' || github.event_name == 'workflow_dispatch' + runs-on: windows-latest + + steps: + # Scheduled runs always test dev (schedules execute from the default branch, so ref_name + # would be main). A manual dispatch tests the DISPATCHED ref — the only way to validate a + # branch's gated-pg test changes before merge; the artifact-publishing build job stays + # pinned to dev either way, so a branch dispatch can never ship branch binaries. + - uses: actions/checkout@v5 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && github.ref_name || 'dev' }} + + - name: Setup .NET 10.0 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + cache: true + cache-dependency-path: '**/packages.lock.json' + + # Darling bundles a PostgreSQL 18 + TimescaleDB runtime (pg-runtime.zip). The fetch script + # pulls ~340MB of pinned EDB/TimescaleDB archives, so cache the assembled zip on the SAME + # key build.yml's release job uses (the fetch script's own content hash) — nightly and + # release share one cache entry, so a warm cache means no download here. + - name: Cache Darling pg-runtime.zip + id: cache-pg-runtime + uses: actions/cache@v4 + with: + path: Darling/artifacts/pg-runtime.zip + key: pg-runtime-${{ runner.os }}-${{ hashFiles('Darling/tools/fetch-pg-runtime.ps1') }} + + - name: Build Darling pg-runtime.zip (cache miss only) + if: steps.cache-pg-runtime.outputs.cache-hit != 'true' + shell: pwsh + run: ./Darling/tools/fetch-pg-runtime.ps1 + + # One uniform path for hit and miss: we always have the zip (restored or freshly built), + # so always extract it — simpler than branching on the script's -KeepWork assembled tree. + # ExtractToDirectory mirrors the fetch script's own API and reads any zip it writes. + - name: Extract pg-runtime + shell: pwsh + run: | + $zip = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime.zip" + $dest = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime" + if (Test-Path $dest) { Remove-Item -Recurse -Force $dest } + Add-Type -AssemblyName System.IO.Compression.FileSystem + [System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $dest) + if (-not (Test-Path "$dest\pgsql\bin\pg_ctl.exe")) { throw "pg-runtime missing pgsql\bin\pg_ctl.exe" } + + - name: Restore Darling.Tests + run: dotnet restore Darling/Darling.Tests/Darling.Tests.csproj --locked-mode + + - name: Build Darling.Tests + run: dotnet build Darling/Darling.Tests/Darling.Tests.csproj -c Release --no-restore + + # Stand up a throwaway cluster from the bundled runtime. initdb TRUST auth is acceptable + # ONLY here: an ephemeral CI runner, loopback-only, throwaway data. The PRODUCT default is + # the opposite (scram-sha-256 + a generated credential; see DarlingManagedPostgres). The + # appended settings mirror DarlingManagedPostgres.BuildConfAppend (timescaledb preload — + # the Timescale-gated tests detect and use it — plus the port and loopback bind). Port 5541 + # is fixed and distinct; the managed-bootstrap E2E starts its OWN postgres on a random free + # port, so there is no collision. + - name: Initialize and start throwaway PostgreSQL + shell: pwsh + run: | + $bin = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime\pgsql\bin" + $dataDir = "$env:RUNNER_TEMP\darling-pgdata" + $logFile = "$env:RUNNER_TEMP\darling-pg.log" + # The bootstrap superuser is named "darling", not "postgres": the V8 schema-split + # migration runs CREATE SCHEMA ... AUTHORIZATION darling (PgSchemaGenerator.OwnerRole), + # so a cluster without that role fails every fresh-store migration. Matching managed + # mode's shape (DarlingManagedPostgres also initdbs its owner as "darling"). + & "$bin\initdb.exe" -D $dataDir -U darling -A trust --encoding=UTF8 + if ($LASTEXITCODE -ne 0) { throw "initdb failed ($LASTEXITCODE)" } + Add-Content -Path "$dataDir\postgresql.conf" -Value "shared_preload_libraries = 'timescaledb'" + Add-Content -Path "$dataDir\postgresql.conf" -Value "port = 5541" + Add-Content -Path "$dataDir\postgresql.conf" -Value "listen_addresses = '127.0.0.1'" + & "$bin\pg_ctl.exe" -D $dataDir -l $logFile -w start + if ($LASTEXITCODE -ne 0) { if (Test-Path $logFile) { Get-Content $logFile -Tail 50 }; throw "pg_ctl start failed ($LASTEXITCODE)" } + & "$bin\createdb.exe" -h 127.0.0.1 -p 5541 -U darling darling + if ($LASTEXITCODE -ne 0) { throw "createdb failed ($LASTEXITCODE)" } + + # DARLING_TEST_PG lights up the [Collection("live-postgres")] classes; DARLING_TEST_PGRUNTIME + # lights up the managed-bootstrap E2E. DARLING_TEST_SQL is intentionally unset — the one + # live-SQL-Server E2E stays skipped (no SQL Server on the runner). + - name: Run Darling PG tests + shell: pwsh + env: + DARLING_TEST_PG: "Host=127.0.0.1;Port=5541;Username=darling;Database=darling" + DARLING_TEST_PGRUNTIME: ${{ github.workspace }}\Darling\artifacts\pg-runtime + run: dotnet test Darling/Darling.Tests/Darling.Tests.csproj -c Release --no-build --verbosity normal --logger "trx;LogFileName=darling-nightly.trx" --results-directory TestResults + + - name: Stop PostgreSQL + if: always() + shell: pwsh + run: | + $bin = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime\pgsql\bin" + $dataDir = "$env:RUNNER_TEMP\darling-pgdata" + if (Test-Path "$bin\pg_ctl.exe") { & "$bin\pg_ctl.exe" -D $dataDir -m fast -w stop } + exit 0 + + - name: Upload PG log and test results on failure + if: failure() + uses: actions/upload-artifact@v6 + with: + name: darling-pg-failure + path: | + ${{ runner.temp }}/darling-pg.log + TestResults/ + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 1937a3d4f..876ee9b5a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ obj/ publish/ releases/ +# Darling bundled-Postgres runtime (built by Darling/tools/fetch-pg-runtime.ps1, never committed) +Darling/artifacts/ + # Visual Studio *.suo *.user diff --git a/CHANGELOG.md b/CHANGELOG.md index 4663fd62b..ae7fb5f0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,114 +7,361 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.2.0] - 2026-07-21 + +### Added + +- **Darling MCP: bulk add/remove monitored servers — an MCP client can now stand up FLEET monitoring conversationally** ([#1609]) - the Darling MCP server exposed read tools plus the [#1600] Custom Views and [#1608] alert-tuning writes, but no way for an MCP client (or Claude, over MCP) to add or remove the MONITORED SERVERS themselves - onboarding was WPF-Viewer-only (the Add / Manage Servers dialogs). Two new tools close that gap, the direct sibling of the alert-tuning slice: `add_servers` (BULK) takes a JSON ARRAY of server objects and, IN ORDER (sequential, mirroring [#1549]'s bulk-probe, to avoid a probe storm), validates each, connection-tests it IN the service, and saves the new+reachable ones - so "monitor these twenty servers with this login" stands up fleet monitoring in one call; `remove_server` removes one by name. **No divergent second implementation:** the probe is `DarlingServerConnector.ProbeAsync` run IN-PROCESS (the MCP host lives inside the service, which holds the network path + credentials - unlike the Viewer's dialogs, which enqueue a `test_connect` command for the service to run), the case-folded dedupe gate is the shared `ServerIdHelper.BuildStorageName` identity in a `HashSet(OrdinalIgnoreCase)` exactly as the [#1549] bulk dialog uses, the SQL password is DPAPI-encrypted through the SAME `DarlingSecrets.Protect` the service decrypts with at collection time (so it round-trips), and the INSERT mirrors `StoreConfigProvider.SeedMonitoredServersAsync`'s exact column set + `server_id` identity (so a tool-written row JOINs the collected data and the service's reconcile matches it, picking it up within one sweep - no restart). Per server, `add_servers` returns `status` `added` / `duplicate` (a case-variant or exact dup of an existing or earlier-in-batch server, skipped WITHOUT a probe) / `connection_failed` (recorded, the batch CONTINUES) / `invalid` (a bad field, or Entra/MFA/Service-Principal/Managed-Identity auth - interactive MFA is nonsensical headless, the same belt [#1549] applies), and the whole call returns `{added, skipped, failed, results:[...]}`; per Erik, the TLS options `encrypt_mode` (Optional/Mandatory/Strict) and `trust_server_certificate` are EXPOSED so a headless caller sets the connection posture explicitly. **Security (deliberate, scoped):** the tools connect as the least-privilege `mcp` role, now granted - via role PROVISIONING, not a migration - INSERT/UPDATE/DELETE on `config.config_monitored_servers` (mirroring how [#1600]/[#1608] granted their single tables), a single non-secret-KEY table: the `encrypted_password` column stays in the fail-closed secret carve, so `mcp` can WRITE a credential blob (onboarding) but can never READ one back, and it still cannot reach the `config_command` service-credential pivot or a schema-wide config write. **The #1608 beacon grant already covers this:** a `config_monitored_servers` write fires the existing `trg_bump_monitored_servers → config_bump_version` trigger (SECURITY INVOKER, UPDATEs `config_service.config_version` AS `mcp`), and [#1608] already granted `mcp` UPDATE on the two `config_service` beacon columns - verified, no new `config_service` grant is added. **Credential on the wire:** the SQL password travels to the MCP endpoint inside `add_servers`' request JSON (DPAPI-encrypted at rest, never returned by any read tool), which is one more reason a LAN deployment should front the endpoint with the documented TLS reverse proxy - the README's MCP blast-radius section now states this and recommends Windows/integrated auth for onboarded servers where possible. The MCP server instructions (now eighty-seven tools), the cross-app tool-inventory ratchet (Lite is a single-instance app with no central monitored-server store, so these are Darling-only), the `/api/read` write-exclusion set, and the Darling README are updated. Verified: the Darling service + `Darling.Tests` build clean (0 errors, no new warnings in the changed source), full `Darling.Tests` **2847 passed / 0 failed / 146 gated-live skipped** (with `DARLING_TEST_PG` cleared), and `Lite.Tests` `CrossAppMcpToolInventoryPinTests` **2 passed** - the ungated pins cover the two-tool surface, the Gemini-clean schema + required-params, validate-before-write (a malformed payload or a bad/MFA entry returns `invalid` WITHOUT probing or opening a connection), and the pure case-folded dedupe partition; one gated-live test (`DARLING_TEST_PG`, own-scoped + cleaned up, the SQL probe stubbed to success - no live SQL Server touched in CI) proves the store INSERT (the SQL secret is DPAPI-encrypted at rest and round-trips, the Windows-auth server is secret-free), the duplicate skip, the `config_version` self-bump, and `remove_server` (removed, then not_found). The REAL end-to-end probe is what the human dogfoods (remove sql2016, re-add via MCP). + +- **Darling MCP: alert-tuning write tools — an MCP client can now tune thresholds and manage mute rules conversationally** ([#1608]) - the Darling MCP server exposed alert READS (`get_alert_history` / `get_alert_settings` / `get_mute_rules`) but no way for an MCP client (or Claude, over MCP) to CHANGE the alerting - thresholds and mute rules could only be edited in the WPF Viewer's Settings window. Three new tools close that gap, the direct sibling of the [#1600] Custom Views MCP write tools: `update_alert_settings` (a PARTIAL update of the single global alert-settings row - the agent reads via `get_alert_settings`, changes fields, and sends only those back in the SAME nested shape, e.g. `{"cpu":{"threshold_percent":90},"cooldown_minutes":10}`), and `create_mute_rule` / `delete_mute_rule` (add/remove the mute rules the delivery paths honor). **No divergent second implementation:** `update_alert_settings` validates EVERY provided field against the SAME ranges/enums the Viewer's Settings window enforces (`SettingsWindow.BuildAlertRowFromControls` - thresholds in range, `cpu.mode` `sql`/`total`, `delivery.mode` `Summary`/`PerEvent`, counts within bounds) BEFORE any write - an out-of-range value or an unknown field (top-level or nested) returns `{status:"invalid"}` and writes nothing - then applies ONLY the provided columns via a targeted parameterized `UPDATE ... WHERE id = 1` and re-reads the merged state; `create_mute_rule` / `delete_mute_rule` reuse the SAME `PgMuteRuleStore` `get_mute_rules` reads through, with the same GUID id-generation the Viewer's mute-create path uses. SMTP/webhook delivery credentials are out of scope (the `mcp` role cannot read or write the secret columns). A `config_alert_settings` write self-bumps `config_version` via the existing config-table trigger, so the running service HOT-RELOADS the change within one collection sweep (the tool never writes `config_version` itself). **Security (deliberate, scoped):** the tools connect as the least-privilege `mcp` role, now granted - via role PROVISIONING, not a migration - INSERT/UPDATE/DELETE on `config.config_mute_rules` and UPDATE on the singleton `config.config_alert_settings` (mirroring how [#1600] granted `config.custom_views`), so a token-holder can tune alerting but still cannot reach the `config_command` service-credential pivot or the carved secret columns. **One non-obvious grant, called out for review:** the `config_alert_settings` bump trigger (`config_bump_version`) is SECURITY INVOKER and UPDATEs `config_service` AS the writing role, so the `mcp` role ALSO needs a COLUMN-level UPDATE on just the two `config_service` beacon columns (`config_version`, `updated_at`) or every `update_alert_settings` write would fail 42501 in production - and the superuser-run gated-live tests would never catch it. The column grant lets `mcp` bump the reload beacon but NOT flip `paused` / `capture_plans` / `mcp_enabled` / `mcp_port`; the live security test now proves this end-to-end as the real `mcp` role (a `config_alert_settings` UPDATE succeeds and fires the beacon; a `paused` UPDATE still 42501s). The MCP server instructions, the cross-app tool-inventory ratchet (Lite has no central alert store, so these are Darling-only), and the Darling README's MCP blast-radius section are updated. Verified: the Darling service + `Darling.Tests` build clean (0 errors, 0 warnings in the changed source), full `Darling.Tests` **2821 passed / 0 failed / 145 gated-live skipped** (with `DARLING_TEST_PG` cleared), and `Lite.Tests` `CrossAppMcpToolInventoryPinTests` **2 passed** - the ungated pins cover the six-tool surface, the Gemini-clean schema + required-params, and validate-before-write (a bad/unknown partial update returns `invalid` WITHOUT opening a connection); one gated-live test (`DARLING_TEST_PG`, own-scoped + restored) proves `update_alert_settings` flips a threshold AND self-bumps `config_version`, and `create_mute_rule`→`get_mute_rules`→`delete_mute_rule` round-trips, and it skips in the normal unit run like the others. + +- **Darling: headless `--enable-mcp` / `--disable-mcp` / `--enable-web` / `--disable-web` CLI verbs — bring an endpoint up (store + firewall) without the Viewer** ([#1601]) - a headless Darling box had no supported way to (a) turn the MCP or web-dashboard endpoint on/off or (b) open its firewall. Two structural reasons: `mcp.enabled`/`web.enabled` in `darling.json` are only a FIRST-RUN seed - after the first run the store (`config.config_service.mcp_enabled`/`web_enabled`) is authoritative and is normally toggled only by the WPF Viewer's Settings, which a headless deployment does not have; and the service runs as a virtual service account (`NT SERVICE\PerformanceMonitor Darling`) that CANNOT modify Windows Firewall, so its best-effort self-reconcile silently fails. Each verb closes both gaps in one elevated action. **(store)** a TARGETED `UPDATE config.config_service SET = ..., updated_by = 'cli' WHERE id = 1` flips ONLY that endpoint's flag; the existing BEFORE-UPDATE self-bump trigger increments `config_version`, so the worker HOT-RELOADS within one collection sweep - no restart - and the write deliberately never touches `config_version` itself, `paused`, or the other endpoint's flag (0 rows affected ⇒ the store isn't seeded yet, reported as such; the owner credential missing ⇒ the service has never initialized the store, reported as such). **(firewall)** only when the endpoint's `darling.json` network block opts into LAN exposure (a non-loopback `listen`, decided via the shared `DarlingNetwork.IsExposedListenAddress`): run ELEVATED, it opens/removes the SAME scoped, idempotent-by-DisplayName rule the host self-reconciles (the two `McpFirewallRuleName`/`WebFirewallRuleName` builders are now `internal` so the CLI and host act on the EXACT same rule, through the shared `BuildFirewallEnableCommand`/`BuildFirewallDisableCommand` builders); run NON-elevated, the store toggle still succeeds and the exact elevated command is printed as a HANDOFF (never a failure); a loopback-only endpoint gets a note pointing at `--configure-network` and takes no firewall action. A firewall failure is non-fatal. Managed-mode only (BYO governs its own `config_service` + exposure) and Windows-only (DPAPI credential decrypt + `WindowsPrincipal` + firewall), the same guard shape as `--print-viewer-connection`; wired into `IsKnownVerb`, `UsageText`, and the `Program` dispatch (allow-list and dispatch kept in sync per the existing anti-drift comments). The output states the store change, that the running service applies it live within one sweep, the firewall outcome/handoff, and a reminder that `darling.json`'s `enabled` is only the seed - the store is the live switch. No store schema change. Verified: `Darling.Tests` **2789 passed / 0 failed / 144 gated-live skipped** (with `DARLING_TEST_PG` cleared) and 0 new build warnings - the pure tests pin the four store-write SQL strings (right flag, `updated_by='cli'`, `WHERE id=1`, never `config_version`/`paused`/the other endpoint's flag), the verb recognition + classify wiring, the pure firewall-step classifier (exposed × elevated), and the shared rule names; one gated-live test (`DARLING_TEST_PG`, transaction-rolled-back) proves enable then disable flip the flag AND self-bump `config_version` against a throwaway Postgres, and it skips in the normal unit run exactly like the other `*_AgainstDevPostgres` tests (CI's `darling-pg` job runs it live). +- **Darling MCP: `describe_custom_view_catalog` — the compose vocabulary an LLM needs to author a view without guessing** ([#1602]) - [#1600] gave the Darling MCP server the seven Custom Views authoring tools, but an MCP client had no way to DISCOVER what a composed panel may contain. The tool descriptions give the panel SHAPE (a `source` + `measure`/`ratio` + `aggregate` + `unit` + `timeBucket`/`topN` + `groupBy`/`filters` + `viz`) and say "iterate until valid", but no tool enumerated the legal identifiers, and `validate_custom_view`'s errors name the first problem without listing valid values (`"unknown source 'X'"` never says what sources exist). So an assistant asked to "make me a top-waits view" had to GUESS `source`/`measure` names blind and flail on validation errors — the exact wall hit while dogfooding the [#1600] tools, cleared only by reading `MeasureCatalog.cs` source, which a customer's assistant cannot do. A human in the web composer gets dropdowns; the LLM got nothing. **Fix:** a new read-only `describe_custom_view_catalog` tool returns the SAME compose catalog the web composer's picker binds to (`DarlingWebEndpoints.BuildComposeCatalogNode`, already served at `/api/catalog`) — `{measures, dimensions, annotationSources, universalDimensions, unitFamilies, aggregates, timeBuckets, filterOps, viz}`, each measure carrying its `source`, `kind` (scalar/ratio), `validAggregates`, `allowedDimensions`, unit family + default/native unit, and per-server-type `appliesTo`. An LLM calls it ONCE and composes a valid panel on the first try instead of guessing. The MCP server instructions now direct clients to call it FIRST before authoring, and it joins the read-only `/api/read` exclusion set (like the other Custom Views tools it is served by its own richer endpoint, not the `/api/read/{tool}` 1:1 mirror). **No divergent second implementation:** the tool returns the one `BuildComposeCatalogNode` the web UI already serves — no parallel catalog. Static reference data: it reads no monitored server and no collected data, needs no store connection, and is not a write, so it does not widen the MCP token's blast radius. Verified: full `Darling.Tests` green (2768 passed / 0 failed / 143 gated-live skipped locally; the one local failure is the pre-existing timezone-sensitive viewer display test, green on UTC CI), including the updated tool-surface / Gemini-schema / required-param pins (now eight Custom Views tools), the `/api/read`-parity reflection test, and a new pin that the tool surfaces a known measure with its composable fields. + +- **Darling MCP: create and manage Custom Views (CV2) over MCP** ([#1600]) - the Darling MCP server previously exposed ~72 READ tools but no way for an MCP client (or Claude, over MCP) to build a Custom View programmatically - views could only be authored in the web viewer's editor. Seven new tools close that gap and add the server's ONE write surface: `list_custom_views` / `get_custom_view` (read the saved dashboards + notebooks), `validate_custom_view` (dry-run a definition against the measure catalog + composer rules WITHOUT persisting), `create_custom_view` / `update_custom_view` (validate THEN persist; optimistic-concurrency on update), `delete_custom_view`, and `run_custom_view_panel` (compile + run a single composed panel and return `{sql, rows, annotations}` - the composer's live preview, so a generated view can be checked end-to-end). Two payoffs: ask Claude to "make me a view of X" and it builds it, and Claude authoring views exercises the CV2 catalog / spec validation / render path end-to-end - exactly where CV2 gaps hide. **No divergent second implementation:** every tool routes through the EXISTING authority - persistence is the same `CustomViewStore`, validation is the same `DarlingWebEndpoints.ValidateDefinition` (through `ComposeSpec.TryParsePanel` and the hand-authored measure catalog), and the panel run is the SAME compile-and-run extracted from the web `/api/compose/run` into a shared `RunComposedPanelAsync` that now backs both surfaces; create/update ALWAYS validate before persisting, so a stored view can always compile and render. **Security (deliberate, scoped):** the tools connect as the least-privilege `mcp` role, now granted INSERT/UPDATE/DELETE on ONLY `config.custom_views` (mirroring the `viewer` role's single-table grant for the web composer) via role PROVISIONING, not a migration - a token-holder can author custom views but still cannot reach the `config_command` service-credential pivot or the carved secret columns, and a composed query names only `collect.*` collector tables so a stored view structurally cannot read a config control-plane table. The README's MCP blast-radius section is updated to state the widened write. Verified: full `Darling.Tests` green (see the PR body for the exact pass/fail counts), including the provisioning-grant pins, the tool-validation envelopes, and a gated-live round-trip through the tools against a real PostgreSQL. + + +- **Darling Web: Custom Views v2 - QS execution-weighted average + per-server measure availability greying** ([#1584]) - closes the two remaining #1563 deferrals so nothing's parked. **QS execution-weighted average:** a new `Weighted` ratio mode compiles `SUM(value * weight) / NULLIF(SUM(weight), 0)` - the correct execution-weighted mean across Query Store's pre-aggregated per-interval averages, NOT an avg-of-avgs - shipping `qs_avg_duration_us` and `qs_avg_cpu_us` (the QS avg column x `execution_count`), with both operand columns pinned to the collector's `PayloadColumns`. **Per-server measure auto-greying (D4):** `/api/fleet` cards now carry the reliable per-server platform (`engine_edition` + `is_azure_sql_db` / `is_azure_mi`, from the probed servers registry), and the composer GREYS (disables + a reason) measures a SINGLE selected `$server` can't collect (Azure SQL DB / MI / on-prem, matched to the measure's `appliesTo`); an already-chosen measure stays selectable (re-scoping never silently drops a panel's metric), and All / multi-select or an unprobed server keeps the Wave-1 badges. AWS RDS + msdb deliberately stay badge-only (no reliable per-server signal without a new store column). Wired into both the dashboard and notebook composers. All additive; full `Darling.Tests` green (2677 passed / 0 failed / 141 gated-live skipped). + +- **Darling Web: Custom Views v2 - notebook mode (investigation documents)** ([#1583]) - the final #1563 follow-up: an investigation / runbook document that interleaves prose with LIVE composed-panel cells, read top-to-bottom (vs the dashboard's grid for watching) - the "Datadog notebooks" half of the original ask. A notebook is a view `kind` in the SAME `config.custom_views` store: `{ kind:"notebook", cells:[ {type:"markdown",text} | {type:"panel", } ], variables, range }` - reusing the v2 panel spec, `/api/compose/run`, the render path, the store + CRUD, and the view-level `$server`/`$database`/time scope. Each panel cell is validated by the EXACT `TryParsePanel` authority (all the v2 safety); markdown cells cap at 32 KB; `ValidateDefinition` dispatches on `kind` (the dashboard `{panels}` path byte-for-byte unchanged); and the `/api/views` list summary now carries `kind` (a `definition->>'kind'` scalar, never the body) so the sidebar badges + routes notebooks with no extra fetch. **The one genuinely-new component is a minimal, air-gapped, XSS-safe Markdown -> DOM renderer** (`markdown.js`): ATX headings, paragraphs, bold/italic, inline + fenced code, ordered/unordered lists, and links - EVERY node built via `textContent` (NO innerHTML anywhere, no library), so raw markup in the source is inert BY CONSTRUCTION (a `", true)] // absolute off-origin + [InlineData("@import url(https://fonts.example.com/f.css);", true)] + [InlineData("", true)] // protocol-relative + [InlineData("background: url('//cdn.example.com/bg.png');", true)] + public void FindOffOrigin_FlagsExactlyTheOffOriginLines(string line, bool shouldFlag) + { + Assert.Equal(shouldFlag, FindOffOrigin(line) is not null); + } +} diff --git a/Darling/Darling.Tests/DarlingWebSupervisorTests.cs b/Darling/Darling.Tests/DarlingWebSupervisorTests.cs new file mode 100644 index 000000000..3721e40f5 --- /dev/null +++ b/Darling/Darling.Tests/DarlingWebSupervisorTests.cs @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using PerformanceMonitor.Darling.Service.Mcp; +using Xunit; + +namespace Darling.Tests; + +/// +/// #1562 (live web dashboard toggle): the web host's supervisor decision table and the worker→host state +/// seam — the exact twin of . The viewer's Settings toggle writes +/// config_service.web_enabled / web_port; the worker publishes the live values to +/// on every reload; the host's supervisor polls and starts/stops/rebinds WITHOUT a service restart. These pins +/// hold the decision table and the seam's coherence; the Kestrel lifecycle itself is exercised by field use. +/// +public sealed class DarlingWebSupervisorTests +{ + [Theory] + /* not running: start only when enabled */ + [InlineData(false, 0, false, 5153, DarlingWebHostService.WebSupervisorAction.None)] + [InlineData(false, 0, true, 5153, DarlingWebHostService.WebSupervisorAction.Start)] + /* running: stop on disable — the no-restart kill switch, network-exposed included */ + [InlineData(true, 5153, false, 5153, DarlingWebHostService.WebSupervisorAction.Stop)] + /* running + enabled: rebind only on a port change */ + [InlineData(true, 5153, true, 5153, DarlingWebHostService.WebSupervisorAction.None)] + [InlineData(true, 5153, true, 5199, DarlingWebHostService.WebSupervisorAction.Restart)] + public void DecideWebAction_CoversTheWholeTable( + bool running, int runningPort, bool enabled, int desiredPort, DarlingWebHostService.WebSupervisorAction expected) + { + Assert.Equal(expected, DarlingWebHostService.DecideWebAction(running, runningPort, enabled, desiredPort)); + } + + [Fact] + public void WebRuntimeState_UnpublishedReadsNull_ThenLatestCoherentSnapshot() + { + var state = new WebRuntimeState(); + + /* Pre-publish: the host falls back to the FILE values. */ + Assert.Null(state.Read()); + + state.Publish(enabled: true, port: 5153); + var first = state.Read(); + Assert.NotNull(first); + Assert.True(first!.Enabled); + Assert.Equal(5153, first.Port); + + /* A re-publish swaps ONE immutable snapshot reference — a reader can never see a torn + (old Enabled, new Port) mix. */ + state.Publish(enabled: false, port: 5199); + var second = state.Read(); + Assert.NotNull(second); + Assert.False(second!.Enabled); + Assert.Equal(5199, second.Port); + Assert.NotSame(first, second); + } + + /// Cadence tripwires: the supervisor reacts within ~one reload beacon (5s poll) and a persistent + /// start failure retries on a calm 30s backoff, not every tick — pinned equal to the MCP host's cadences. + [Fact] + public void SupervisorCadences_ArePinned() + { + Assert.Equal(TimeSpan.FromSeconds(5), DarlingWebHostService.SupervisorPollInterval); + Assert.Equal(TimeSpan.FromSeconds(30), DarlingWebHostService.FailedStartBackoff); + } +} diff --git a/Darling/Darling.Tests/DarlingWorkerTests.cs b/Darling/Darling.Tests/DarlingWorkerTests.cs new file mode 100644 index 000000000..66be36bc4 --- /dev/null +++ b/Darling/Darling.Tests/DarlingWorkerTests.cs @@ -0,0 +1,251 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Notifications; +using Xunit; + +namespace Darling.Tests; + +/// +/// M2 slice D: the collection loop's dispatch must cover every collector in the shared catalog +/// and every scheduled default — a new definition that isn't wired into the worker fails here, +/// not silently in production. +/// +public sealed class DarlingWorkerTests +{ + [Fact] + public void Dispatch_CoversEveryCatalogCollector_AndEveryScheduleDefault() + { + var dispatched = DarlingWorker.DispatchedCollectorNames.ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var definition in CollectorCatalog.All) + { + Assert.True(dispatched.Contains(definition.Name), $"worker dispatch missing catalog collector '{definition.Name}'"); + } + + foreach (var name in CollectorScheduleDefaults.All.Keys) + { + Assert.True(dispatched.Contains(name), $"worker dispatch missing scheduled collector '{name}'"); + } + + Assert.Equal(CollectorCatalog.All.Count, dispatched.Count); + } + + /// + /// A bring-your-own store connection string that omits the search path must get the canonical + /// collect/config/public one injected BEFORE the data source is created — otherwise the Npgsql + /// pool's first physical connections keep a pre-migration session search_path and a fresh BYO + /// store silently collects nothing (42P01) until the service restarts. The injected path pins + /// against the managed constant so both modes carry the same schemas in the same order. + /// + [Fact] + public void EnsureStoreSearchPath_InjectsCanonicalPath_WhenByoStringOmitsIt() + { + const string byo = "Host=localhost;Port=5432;Username=darling;Database=darling"; + Assert.Empty(new NpgsqlConnectionStringBuilder(byo).SearchPath ?? string.Empty); + + var result = DarlingWorker.EnsureStoreSearchPath(byo); + + var parsed = new NpgsqlConnectionStringBuilder(result); + Assert.Equal("collect,config,public", parsed.SearchPath); + Assert.Equal(DarlingManagedPostgres.SearchPath, parsed.SearchPath); + + /* The rest of the connection string is preserved. */ + Assert.Equal("localhost", parsed.Host); + Assert.Equal(5432, parsed.Port); + Assert.Equal("darling", parsed.Username); + Assert.Equal("darling", parsed.Database); + } + + /// + /// The Stage 2 pause gate: the collection sweep runs only when NOT paused. This pins the gate the loop + /// keys off (config_service.paused -> _paused -> skip collection/alert/analysis/purge) so a future edit + /// can't silently invert or drop it; the command loop keeps running while paused so a resume is honored. + /// + [Fact] + public void ShouldRunCollection_SkipsOnlyWhenPaused() + { + Assert.True(DarlingWorker.ShouldRunCollection(paused: false)); + Assert.False(DarlingWorker.ShouldRunCollection(paused: true)); + } + + /// + /// A connection string that ALREADY specifies a Search Path (managed mode carries it, and a BYO + /// operator may set their own) is returned untouched — no double-set, and a non-default choice + /// is respected. + /// + [Fact] + public void EnsureStoreSearchPath_LeavesExistingSearchPathUnchanged() + { + /* Managed-mode shape: already carries collect,config,public. */ + var managed = DarlingManagedPostgres.BuildConnectionString(5641, "pw123"); + Assert.Equal(managed, DarlingWorker.EnsureStoreSearchPath(managed)); + + /* A BYO operator's own non-default choice is respected, not overwritten. */ + const string custom = "Host=pg.example.com;Database=metrics;Search Path=collect,config,reporting,public"; + var result = DarlingWorker.EnsureStoreSearchPath(custom); + Assert.Equal("collect,config,reporting,public", new NpgsqlConnectionStringBuilder(result).SearchPath); + } + + /// + /// Win 1: a cost-only edit must NOT count as a server-definition change, so ReconcileServers does not tear + /// down and re-establish the connection for it. The reload's + /// mirrors the new MonthlyCostUsd straight + /// onto collect.servers (which the FinOps display reads) with no connection churn. + /// + [Fact] + public void ServerDefinitionEquals_CostOnlyDelta_IsEqual_SoNoReconnect() + { + var original = SampleServer(); + var costChanged = SampleServer(); + costChanged.MonthlyCostUsd = original.MonthlyCostUsd + 250m; + + Assert.True(DarlingWorker.ServerDefinitionEquals(original, costChanged), + "a cost-only change must compare equal (no reconnect) — cost is synced onto collect.servers without connection churn"); + } + + /// + /// #1236: a per-server alert-delivery override edit is likewise NON-connection — it must compare equal so + /// ReconcileServers does not tear down the connection. The deliverer reads the override live off the + /// refreshed state.Config, so the change still takes effect on the reload without connection churn. + /// + [Fact] + public void ServerDefinitionEquals_DeliveryOverrideDelta_IsEqual_SoNoReconnect() + { + var original = SampleServer(); + original.AlertDeliveryModeOverride = null; + + var overrideSet = SampleServer(); + overrideSet.AlertDeliveryModeOverride = AlertNotificationMode.PerEvent; + + Assert.True(DarlingWorker.ServerDefinitionEquals(original, overrideSet), + "an alert-delivery-override change must compare equal (no reconnect) — it is read live off state.Config, not a connection field"); + } + + /// + /// The flip side: a genuine connection- or collection-affecting change (host, excluded databases) still + /// compares NOT equal, so ReconcileServers reconnects the server with the new definition. + /// + [Fact] + public void ServerDefinitionEquals_ConnectionOrDbDelta_IsNotEqual_SoReconnects() + { + var original = SampleServer(); + + var hostChanged = SampleServer(); + hostChanged.Host = "other-host"; + Assert.False(DarlingWorker.ServerDefinitionEquals(original, hostChanged), "a host change must reconnect"); + + var excludedChanged = SampleServer(); + excludedChanged.ExcludedDatabases = new List { "tempdb", "reporting" }; + Assert.False(DarlingWorker.ServerDefinitionEquals(original, excludedChanged), "an excluded-databases change must reconnect"); + + /* Sanity: two identical definitions compare equal. */ + Assert.True(DarlingWorker.ServerDefinitionEquals(original, SampleServer())); + } + + /// + /// D-BYO: managed=false with any network.* set warns per section that the fields are ignored (the + /// operator's own PostgreSQL governs exposure). Asserted against the pure function's returned strings, + /// NOT a live logger and NOT Validate(). + /// + [Fact] + public void GetNetworkStartupWarnings_ByoWithNetwork_WarnsBothSectionsIgnored() + { + var config = new DarlingConfig + { + Postgres = new PostgresConfig + { + Managed = false, + ConnectionString = "Host=pg;Database=darling", + Network = new PostgresNetworkConfig { Listen = "192.168.1.205" }, + }, + Mcp = new McpConfig { Network = new McpNetworkConfig { Listen = "192.168.1.205" } }, + }; + + var warnings = DarlingWorker.GetNetworkStartupWarnings(config); + Assert.Contains(warnings, w => w.Contains("postgres.network", StringComparison.Ordinal) && w.Contains("bring-your-own", StringComparison.Ordinal)); + Assert.Contains(warnings, w => w.Contains("mcp.network", StringComparison.Ordinal) && w.Contains("managed-mode only", StringComparison.Ordinal)); + /* BYO never emits the admin-pivot warning — the network config is ignored anyway. */ + Assert.DoesNotContain(warnings, w => w.Contains("pivot", StringComparison.OrdinalIgnoreCase) || w.Contains("config_command", StringComparison.Ordinal)); + } + + /// + /// D7: managed mode + an EXPOSED store whose network role is admin warns about the config-write / + /// service-credential pivot a remote admin connection can reach. + /// + [Fact] + public void GetNetworkStartupWarnings_ManagedExposedAdminRole_WarnsPivot() + { + var config = new DarlingConfig + { + Postgres = new PostgresConfig + { + Managed = true, + Network = new PostgresNetworkConfig { Listen = "192.168.1.205", AllowFrom = "192.168.1.0/24", Role = "admin" }, + }, + }; + + var warnings = DarlingWorker.GetNetworkStartupWarnings(config); + Assert.Contains(warnings, w => w.Contains("network.role is 'admin'", StringComparison.Ordinal) && w.Contains("config_command", StringComparison.Ordinal)); + /* Managed mode does not emit the BYO "ignored" notices. */ + Assert.DoesNotContain(warnings, w => w.Contains("IGNORED", StringComparison.Ordinal)); + } + + /// The secure defaults are silent: managed + viewer role, managed + no network, and managed + + /// admin role that is NOT exposed (loopback listen) all produce zero warnings. + [Fact] + public void GetNetworkStartupWarnings_ManagedViewerOrLoopback_IsSilent() + { + var viewerExposed = new DarlingConfig + { + Postgres = new PostgresConfig + { + Managed = true, + Network = new PostgresNetworkConfig { Listen = "192.168.1.205", AllowFrom = "192.168.1.0/24", Role = "viewer" }, + }, + }; + Assert.Empty(DarlingWorker.GetNetworkStartupWarnings(viewerExposed)); + + Assert.Empty(DarlingWorker.GetNetworkStartupWarnings(new DarlingConfig { Postgres = new PostgresConfig { Managed = true } })); + + /* admin role but bound to loopback = not exposed => no pivot warning (nothing is reachable). */ + var adminButLoopback = new DarlingConfig + { + Postgres = new PostgresConfig + { + Managed = true, + Network = new PostgresNetworkConfig { Listen = "127.0.0.1", AllowFrom = "192.168.1.0/24", Role = "admin" }, + }, + }; + Assert.Empty(DarlingWorker.GetNetworkStartupWarnings(adminButLoopback)); + } + + /// A fully-populated server definition the equality tests perturb a single field of. + private static MonitoredServer SampleServer() => new() + { + Name = "prod-01", + Host = "prod-host", + Database = "AppDb", + Auth = "sql", + Username = "monitor", + EncryptedPassword = "AQAAblob==", + Password = null, + EncryptMode = "Mandatory", + TrustServerCertificate = true, + ReadOnlyIntent = false, + MultiSubnetFailover = false, + MonthlyCostUsd = 500m, + ExcludedDatabases = new List { "tempdb" }, + }; +} diff --git a/Darling/Darling.Tests/DatabaseFilterTests.cs b/Darling/Darling.Tests/DatabaseFilterTests.cs new file mode 100644 index 000000000..6da20e86c --- /dev/null +++ b/Darling/Darling.Tests/DatabaseFilterTests.cs @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using NpgsqlTypes; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// #1319 global database filter (Darling). Pins that every database-scoped reader's SQL carries the +/// always-present, PARAMETERIZED = ANY($n) predicate (guarded by $n::text[] IS NULL so an +/// empty selection short-circuits to unfiltered) at the expected positional index, and that the shared +/// binds a Postgres text[] (SQL NULL for +/// "All", the array otherwise). No live Postgres needed — the SQL is a compile-time contract. +/// +public sealed class DatabaseFilterTests +{ + [Fact] + public void DatabaseFilterParameter_NullSelection_BindsSqlNullTypedAsTextArray() + { + var p = ViewerDataService.DatabaseFilterParameter(null); + + Assert.Equal(NpgsqlDbType.Array | NpgsqlDbType.Text, p.NpgsqlDbType); + Assert.IsType(p.Value); + } + + [Fact] + public void DatabaseFilterParameter_EmptySelection_BindsSqlNullTypedAsTextArray() + { + var p = ViewerDataService.DatabaseFilterParameter(new List()); + + Assert.Equal(NpgsqlDbType.Array | NpgsqlDbType.Text, p.NpgsqlDbType); + Assert.IsType(p.Value); + } + + [Fact] + public void DatabaseFilterParameter_NonEmptySelection_BindsTheDatabasesAsTextArray() + { + var p = ViewerDataService.DatabaseFilterParameter(new[] { "AppDB1", "AppDB2" }); + + Assert.Equal(NpgsqlDbType.Array | NpgsqlDbType.Text, p.NpgsqlDbType); + var arr = Assert.IsType(p.Value); + Assert.Equal(new[] { "AppDB1", "AppDB2" }, arr); + } + + [Fact] + public void DatabaseFilterClause_EmitsGuardedParameterizedAnyPredicate() + { + Assert.Equal( + " AND ($4::text[] IS NULL OR database_name = ANY($4))", + ViewerDataService.DatabaseFilterClause("database_name", 4)); + } + + // Each touched reader's SQL const must carry the guarded, parameterized ANY predicate at the index + // one past its previous highest positional parameter. (data, columnQualifier, index) + [Theory] + // Query stats: window ($1-$3) + top ($4) → $5; comparison ($1-$5) → $6; slicer ($1-$3) → $4. + [InlineData(nameof(ViewerDataService.TopQueriesSql), "database_name", 5)] + [InlineData(nameof(ViewerDataService.QueryStatsComparisonSql), "database_name", 6)] + [InlineData(nameof(ViewerDataService.QueryStatsSlicerSql), "database_name", 4)] + // Procedure stats. + [InlineData(nameof(ViewerDataService.TopProceduresSql), "database_name", 5)] + [InlineData(nameof(ViewerDataService.ProcedureStatsComparisonSql), "database_name", 6)] + [InlineData(nameof(ViewerDataService.ProcStatsSlicerSql), "database_name", 4)] + // Query Store. + [InlineData(nameof(ViewerDataService.QueryStoreTopSql), "database_name", 5)] + [InlineData(nameof(ViewerDataService.QueryStoreComparisonSql), "database_name", 6)] + [InlineData(nameof(ViewerDataService.QueryStoreSlicerSql), "database_name", 4)] + // Active-query snapshots. + [InlineData(nameof(ViewerDataService.LatestQuerySnapshotsSql), "database_name", 4)] + [InlineData(nameof(ViewerDataService.ActiveQuerySlicerSql), "database_name", 4)] + // Blocking. + [InlineData(nameof(ViewerDataService.BlockedProcessReportsSql), "database_name", 4)] + [InlineData(nameof(ViewerDataService.DmvBlockingSnapshotsSql), "database_name", 4)] + [InlineData(nameof(ViewerDataService.BlockingSlicerSql), "database_name", 4)] + [InlineData(nameof(ViewerDataService.BlockingTrendSql), "database_name", 4)] + [InlineData(nameof(ViewerDataService.BlockedSessionTrendSql), "database_name", 4)] + // Config + config changes: server_id only ($1) → $2; config changes ($1-$2) → $3. + [InlineData(nameof(ViewerDataService.DatabaseConfigSql), "database_name", 2)] + [InlineData(nameof(ViewerDataService.DatabaseScopedConfigSql), "database_name", 2)] + [InlineData(nameof(ViewerDataService.DatabaseConfigChangesSnapshotsSql), "database_name", 3)] + // Query trends (all window-only → $4). + [InlineData(nameof(ViewerDataService.QueryDurationTrendSql), "database_name", 4)] + [InlineData(nameof(ViewerDataService.ProcedureDurationTrendSql), "database_name", 4)] + [InlineData(nameof(ViewerDataService.QueryStoreDurationTrendSql), "database_name", 4)] + [InlineData(nameof(ViewerDataService.ExecutionCountTrendSql), "database_name", 4)] + // Default Trace (real column, qualified alias). + [InlineData(nameof(ViewerDataService.DefaultTraceEventsByWindowSql), "dte.database_name", 4)] + public void TouchedReaderSql_CarriesGuardedParameterizedAnyPredicate(string sqlFieldName, string column, int index) + { + var sql = GetPublicSqlField(sqlFieldName); + + Assert.Contains($"{column} = ANY(${index})", sql, StringComparison.Ordinal); + Assert.Contains($"${index}::text[] IS NULL", sql, StringComparison.Ordinal); + } + + private static string GetPublicSqlField(string name) + { + var field = typeof(ViewerDataService).GetField( + name, + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); + Assert.NotNull(field); + return (string)field!.GetValue(null)!; + } +} diff --git a/Dashboard.Tests/DeadlockGraphLayoutTests.cs b/Darling/Darling.Tests/DeadlockGraphLayoutTests.cs similarity index 99% rename from Dashboard.Tests/DeadlockGraphLayoutTests.cs rename to Darling/Darling.Tests/DeadlockGraphLayoutTests.cs index d052bd846..86a24d652 100644 --- a/Dashboard.Tests/DeadlockGraphLayoutTests.cs +++ b/Darling/Darling.Tests/DeadlockGraphLayoutTests.cs @@ -11,7 +11,7 @@ using PerformanceMonitor.Common; using Xunit; -namespace PerformanceMonitorDashboard.Tests; +namespace Darling.Tests; /// /// Pure unit tests for the shared — determinism, no-overlap, and full diff --git a/Dashboard.Tests/DeadlockGraphParserTests.cs b/Darling/Darling.Tests/DeadlockGraphParserTests.cs similarity index 99% rename from Dashboard.Tests/DeadlockGraphParserTests.cs rename to Darling/Darling.Tests/DeadlockGraphParserTests.cs index 1ae1dcb45..da89bdf29 100644 --- a/Dashboard.Tests/DeadlockGraphParserTests.cs +++ b/Darling/Darling.Tests/DeadlockGraphParserTests.cs @@ -13,7 +13,7 @@ using PerformanceMonitor.Common; using Xunit; -namespace PerformanceMonitorDashboard.Tests; +namespace Darling.Tests; /// /// Tests for the shared , driven primarily by REAL deadlock graphs diff --git a/Darling/Darling.Tests/ExcludedDatabasesTests.cs b/Darling/Darling.Tests/ExcludedDatabasesTests.cs new file mode 100644 index 000000000..c2c3cbc0e --- /dev/null +++ b/Darling/Darling.Tests/ExcludedDatabasesTests.cs @@ -0,0 +1,219 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the Excluded Databases picker's population from the store's collected database list (the fix for +/// the PR #1403 "manual text editor" downscope). Two halves: the pure merge (collected ∪ existing exclusions, checked +/// state, not-collected preservation, dedup/order — no store) and the load-bearing store SQL pinned +/// against the generated schema (no live Postgres, matching the ViewerDailySummarySqlTests convention). +/// +public sealed class ExcludedDatabasesPickerTests +{ + [Fact] + public void BuildDatabaseItems_CollectedAreCheckboxes_CheckedWhenExcluded_SortedByName() + { + var collected = new[] { "AppB", "AppA", "AppC" }; // deliberately unsorted + var existing = new[] { "AppB" }; + + var items = ExcludedDatabasesDialog.BuildDatabaseItems(collected, existing); + + Assert.Equal(new[] { "AppA", "AppB", "AppC" }, items.Select(i => i.Name)); // sorted + Assert.All(items, i => Assert.True(i.IsCollected)); + Assert.All(items, i => Assert.True(i.IsEnabled)); + Assert.False(items.Single(i => i.Name == "AppA").IsExcluded); + Assert.True(items.Single(i => i.Name == "AppB").IsExcluded); // already excluded → checked + Assert.False(items.Single(i => i.Name == "AppC").IsExcluded); + } + + [Fact] + public void BuildDatabaseItems_PreservesExclusionsTheStoreHasNotCollected_AsCheckedNotCollectedRows() + { + var collected = new[] { "AppA" }; + var existing = new[] { "AppA", "ZombieDb" }; // ZombieDb no longer collected + + var items = ExcludedDatabasesDialog.BuildDatabaseItems(collected, existing); + + var zombie = items.Single(i => i.Name == "ZombieDb"); + Assert.True(zombie.IsExcluded); // preserved, checked — a save must not drop it + Assert.False(zombie.IsCollected); + Assert.True(zombie.IsEnabled); // still removable (viewer can't verify liveness) + Assert.Contains("not collected", zombie.DisplayName, StringComparison.OrdinalIgnoreCase); + + /* Collected rows come first, the not-collected exclusion last. */ + Assert.Equal(new[] { "AppA", "ZombieDb" }, items.Select(i => i.Name)); + } + + [Fact] + public void BuildDatabaseItems_IsCaseInsensitive_NoDuplicateForDifferentlyCasedExclusion() + { + var items = ExcludedDatabasesDialog.BuildDatabaseItems(new[] { "MyDb" }, new[] { "mydb" }); + + var item = Assert.Single(items); + Assert.Equal("MyDb", item.Name); // collected casing wins, no separate "mydb" row + Assert.True(item.IsExcluded); + Assert.True(item.IsCollected); + } + + [Fact] + public void BuildDatabaseItems_NoCollectedList_SeedsExistingExclusionsAsManualRows() + { + var items = ExcludedDatabasesDialog.BuildDatabaseItems(Array.Empty(), new[] { "X", "Y" }); + + Assert.Equal(2, items.Count); + Assert.All(items, i => Assert.True(i.IsExcluded)); + Assert.All(items, i => Assert.False(i.IsCollected)); + } + + [Fact] + public void CollectedDatabaseNamesSql_UnionsBothStores_ExcludesSystemDatabases_PgDialect() + { + var sql = ViewerDataService.CollectedDatabaseNamesSql; + + Assert.Contains("FROM v_database_config", sql, StringComparison.Ordinal); + Assert.Contains("FROM v_database_size_stats", sql, StringComparison.Ordinal); + Assert.Contains("UNION", sql, StringComparison.Ordinal); + Assert.Contains("NOT IN ('master', 'model', 'msdb', 'tempdb')", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY database_name", sql, StringComparison.Ordinal); + + /* Positional param, no bare now(), no N'' literals, no @-params. */ + Assert.Contains("$1", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + Assert.DoesNotContain("N'", sql, StringComparison.Ordinal); + Assert.DoesNotContain("now(", sql.ToLowerInvariant()); + } + + [Fact] + public void ServerIdByNameSql_MatchesServerOrDisplayName_SingleRow() + { + var sql = ViewerDataService.ServerIdByNameSql; + + Assert.Contains("FROM servers", sql, StringComparison.Ordinal); + Assert.Contains("server_name", sql, StringComparison.Ordinal); + Assert.Contains("display_name", sql, StringComparison.Ordinal); + Assert.Contains("$1", sql, StringComparison.Ordinal); + Assert.Contains("LIMIT 1", sql, StringComparison.Ordinal); + } + + [Fact] + public void CollectedDatabaseNamesSql_ReadsDatabaseNameColumnThatExistsInBothSourceTables() + { + Assert.Contains("database_name", PgSchemaGenerator.CreateTable(DatabaseConfigCollector.Instance), StringComparison.Ordinal); + Assert.Contains("database_name", PgSchemaGenerator.CreateTable(DatabaseSizeStatsCollector.Instance), StringComparison.Ordinal); + } +} + +/// +/// Live round-trip for the collected-database reads against a dev Postgres (skipped unless DARLING_TEST_PG +/// is set): the picker's list is the DISTINCT user databases across v_database_config and +/// v_database_size_stats, system databases removed, resolved from the registry server name. +/// +public sealed class ExcludedDatabasesStoreLiveTests +{ + private const int ServerId = 990_101; + private const string ServerName = "ExclDbTestServer"; + + [Fact] + public async Task GetCollectedDatabaseNames_ReturnsDistinctUserDatabases_AcrossBothStores_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live collected-databases test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await CleanupAsync(connection); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + await InsertServerAsync(connection); + var now = DateTime.UtcNow; + + /* database_config: two user DBs + a system DB (master, must be filtered). */ + await InsertDatabaseConfigAsync(connection, now, "AppA"); + await InsertDatabaseConfigAsync(connection, now, "AppB"); + await InsertDatabaseConfigAsync(connection, now, "master"); + /* database_size_stats: a DUP (AppB) + a new user DB + a system DB (tempdb). */ + await InsertDatabaseSizeAsync(connection, now, "AppB"); + await InsertDatabaseSizeAsync(connection, now, "AppC"); + await InsertDatabaseSizeAsync(connection, now, "tempdb"); + + var resolvedId = await viewer.GetServerIdByNameAsync(ServerName); + Assert.Equal(ServerId, resolvedId); + + var names = await viewer.GetCollectedDatabaseNamesAsync(ServerId); + + /* DISTINCT across both stores, system DBs removed, sorted. */ + Assert.Equal(new[] { "AppA", "AppB", "AppC" }, names); + } + finally + { + await CleanupAsync(connection); + } + } + + private static async Task InsertServerAsync(NpgsqlConnection connection) + { + using var command = new NpgsqlCommand( + "INSERT INTO servers (server_id, server_name, display_name) VALUES ($1, $2, $3)", connection); + command.Parameters.AddWithValue(ServerId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(ServerName); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertDatabaseConfigAsync(NpgsqlConnection connection, DateTime captureTimeUtc, string databaseName) + { + using var command = new NpgsqlCommand( + "INSERT INTO database_config (config_id, capture_time, server_id, server_name, database_name) VALUES ($1, $2, $3, $4, $5)", + connection); + command.Parameters.AddWithValue(CollectionIdGenerator.Next()); + command.Parameters.AddWithValue(DateTime.SpecifyKind(captureTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(ServerId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(databaseName); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertDatabaseSizeAsync(NpgsqlConnection connection, DateTime collectionTimeUtc, string databaseName) + { + using var command = new NpgsqlCommand( + "INSERT INTO database_size_stats (collection_id, collection_time, server_id, server_name, database_name) VALUES ($1, $2, $3, $4, $5)", + connection); + command.Parameters.AddWithValue(CollectionIdGenerator.Next()); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(ServerId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(databaseName); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task CleanupAsync(NpgsqlConnection connection) + { + foreach (var table in new[] { "database_config", "database_size_stats", "servers" }) + { + using var command = new NpgsqlCommand($"DELETE FROM {table} WHERE server_id = $1", connection); + command.Parameters.AddWithValue(ServerId); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + } +} diff --git a/Dashboard.Tests/Fixtures/Deadlocks/README.md b/Darling/Darling.Tests/Fixtures/Deadlocks/README.md similarity index 100% rename from Dashboard.Tests/Fixtures/Deadlocks/README.md rename to Darling/Darling.Tests/Fixtures/Deadlocks/README.md diff --git a/Dashboard.Tests/Fixtures/Deadlocks/deadlock_2proc_real_sql2025.xml b/Darling/Darling.Tests/Fixtures/Deadlocks/deadlock_2proc_real_sql2025.xml similarity index 100% rename from Dashboard.Tests/Fixtures/Deadlocks/deadlock_2proc_real_sql2025.xml rename to Darling/Darling.Tests/Fixtures/Deadlocks/deadlock_2proc_real_sql2025.xml diff --git a/Dashboard.Tests/Fixtures/Deadlocks/deadlock_5proc_real_sql2025.xml b/Darling/Darling.Tests/Fixtures/Deadlocks/deadlock_5proc_real_sql2025.xml similarity index 100% rename from Dashboard.Tests/Fixtures/Deadlocks/deadlock_5proc_real_sql2025.xml rename to Darling/Darling.Tests/Fixtures/Deadlocks/deadlock_5proc_real_sql2025.xml diff --git a/Dashboard.Tests/Fixtures/Deadlocks/deadlock_8proc_multivictim_real_sql2025.xml b/Darling/Darling.Tests/Fixtures/Deadlocks/deadlock_8proc_multivictim_real_sql2025.xml similarity index 100% rename from Dashboard.Tests/Fixtures/Deadlocks/deadlock_8proc_multivictim_real_sql2025.xml rename to Darling/Darling.Tests/Fixtures/Deadlocks/deadlock_8proc_multivictim_real_sql2025.xml diff --git a/Dashboard.Tests/Fixtures/Deadlocks/deadlock_azure_database_report_synthetic.xml b/Darling/Darling.Tests/Fixtures/Deadlocks/deadlock_azure_database_report_synthetic.xml similarity index 100% rename from Dashboard.Tests/Fixtures/Deadlocks/deadlock_azure_database_report_synthetic.xml rename to Darling/Darling.Tests/Fixtures/Deadlocks/deadlock_azure_database_report_synthetic.xml diff --git a/Dashboard.Tests/Fixtures/Deadlocks/deadlock_crossproduct_synthetic.xml b/Darling/Darling.Tests/Fixtures/Deadlocks/deadlock_crossproduct_synthetic.xml similarity index 100% rename from Dashboard.Tests/Fixtures/Deadlocks/deadlock_crossproduct_synthetic.xml rename to Darling/Darling.Tests/Fixtures/Deadlocks/deadlock_crossproduct_synthetic.xml diff --git a/Dashboard.Tests/Fixtures/Deadlocks/deadlock_parallel_selfedge_synthetic.xml b/Darling/Darling.Tests/Fixtures/Deadlocks/deadlock_parallel_selfedge_synthetic.xml similarity index 100% rename from Dashboard.Tests/Fixtures/Deadlocks/deadlock_parallel_selfedge_synthetic.xml rename to Darling/Darling.Tests/Fixtures/Deadlocks/deadlock_parallel_selfedge_synthetic.xml diff --git a/Dashboard.Tests/Fixtures/Deadlocks/deadlock_single5cycle_synthetic.xml b/Darling/Darling.Tests/Fixtures/Deadlocks/deadlock_single5cycle_synthetic.xml similarity index 100% rename from Dashboard.Tests/Fixtures/Deadlocks/deadlock_single5cycle_synthetic.xml rename to Darling/Darling.Tests/Fixtures/Deadlocks/deadlock_single5cycle_synthetic.xml diff --git a/Darling/Darling.Tests/Fixtures/SystemHealth/error_reported.xml b/Darling/Darling.Tests/Fixtures/SystemHealth/error_reported.xml new file mode 100644 index 000000000..0e31a927c --- /dev/null +++ b/Darling/Darling.Tests/Fixtures/SystemHealth/error_reported.xml @@ -0,0 +1,11 @@ + + 823 + 24 + 2 + false + 2SERVER + 0x00000002ERRORLOG + false + 6 + The operating system returned error 21 to SQL Server during a read at offset 0x00000c60000 in file 'D:\data\prod.mdf'. + diff --git a/Darling/Darling.Tests/Fixtures/SystemHealth/memory_broker.xml b/Darling/Darling.Tests/Fixtures/SystemHealth/memory_broker.xml new file mode 100644 index 000000000..354927535 --- /dev/null +++ b/Darling/Darling.Tests/Fixtures/SystemHealth/memory_broker.xml @@ -0,0 +1,14 @@ + + 1 + 0 + 30000 + 85 + 1048576 + 2097152 + 512 + 900000 + 1000000 + 1100000 + MEMORYBROKER_FOR_CACHE + RESOURCE_MEMPHYSICAL_HIGH + diff --git a/Darling/Darling.Tests/Fixtures/SystemHealth/memory_node_oom.xml b/Darling/Darling.Tests/Fixtures/SystemHealth/memory_node_oom.xml new file mode 100644 index 000000000..7e7797f4b --- /dev/null +++ b/Darling/Darling.Tests/Fixtures/SystemHealth/memory_node_oom.xml @@ -0,0 +1,27 @@ + + 0 + 0 + 72 + 16777216 + 4194304 + 25165824 + 8388608 + 137438953472 + 137000000000 + 12582912 + 1048576 + 10485760 + 2048 + 0 + 9437184 + 2NUMA_NODE_UNAVAILABLE + 1 + 3FACTORY_PROCESS + 0 + 0 + true + false + true + false + false + diff --git a/Darling/Darling.Tests/Fixtures/SystemHealth/scheduler_monitor.xml b/Darling/Darling.Tests/Fixtures/SystemHealth/scheduler_monitor.xml new file mode 100644 index 000000000..bae3a49de --- /dev/null +++ b/Darling/Darling.Tests/Fixtures/SystemHealth/scheduler_monitor.xml @@ -0,0 +1,10 @@ + + 10 + 3 + 2WARNING + 1true + 0false + 1true + 4500 + 4 + diff --git a/Darling/Darling.Tests/Fixtures/SystemHealth/sp_server_diagnostics_io_subsystem.xml b/Darling/Darling.Tests/Fixtures/SystemHealth/sp_server_diagnostics_io_subsystem.xml new file mode 100644 index 000000000..5f782644b --- /dev/null +++ b/Darling/Darling.Tests/Fixtures/SystemHealth/sp_server_diagnostics_io_subsystem.xml @@ -0,0 +1,16 @@ + + 4IO_SUBSYSTEM + 2WARNING + + + + + + + + + + + + + diff --git a/Darling/Darling.Tests/Fixtures/SystemHealth/sp_server_diagnostics_query_processing.xml b/Darling/Darling.Tests/Fixtures/SystemHealth/sp_server_diagnostics_query_processing.xml new file mode 100644 index 000000000..9b5c82d16 --- /dev/null +++ b/Darling/Darling.Tests/Fixtures/SystemHealth/sp_server_diagnostics_query_processing.xml @@ -0,0 +1,16 @@ + + 1QUERY_PROCESSING + 1CLEAN + + + + + + + + + + + + + diff --git a/Darling/Darling.Tests/Fixtures/SystemHealth/sp_server_diagnostics_query_processing_warning.xml b/Darling/Darling.Tests/Fixtures/SystemHealth/sp_server_diagnostics_query_processing_warning.xml new file mode 100644 index 000000000..a798d27ca --- /dev/null +++ b/Darling/Darling.Tests/Fixtures/SystemHealth/sp_server_diagnostics_query_processing_warning.xml @@ -0,0 +1,28 @@ + + 1QUERY_PROCESSING + 2WARNING + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Darling/Darling.Tests/Fixtures/SystemHealth/sp_server_diagnostics_resource.xml b/Darling/Darling.Tests/Fixtures/SystemHealth/sp_server_diagnostics_resource.xml new file mode 100644 index 000000000..801f55880 --- /dev/null +++ b/Darling/Darling.Tests/Fixtures/SystemHealth/sp_server_diagnostics_resource.xml @@ -0,0 +1,41 @@ + + 2RESOURCE + 1CLEAN + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Darling/Darling.Tests/Fixtures/SystemHealth/sp_server_diagnostics_system.xml b/Darling/Darling.Tests/Fixtures/SystemHealth/sp_server_diagnostics_system.xml new file mode 100644 index 000000000..9b8337a09 --- /dev/null +++ b/Darling/Darling.Tests/Fixtures/SystemHealth/sp_server_diagnostics_system.xml @@ -0,0 +1,10 @@ + + 0SYSTEM + 1CLEAN + + + + + + + \ No newline at end of file diff --git a/Darling/Darling.Tests/Fixtures/SystemHealth/wait_info.xml b/Darling/Darling.Tests/Fixtures/SystemHealth/wait_info.xml new file mode 100644 index 000000000..b63364b97 --- /dev/null +++ b/Darling/Darling.Tests/Fixtures/SystemHealth/wait_info.xml @@ -0,0 +1,9 @@ + + 66PAGEIOLATCH_SH + 1End + 1500 + 12 + 7:1:12345 + 57 + SELECT * FROM dbo.big_table WHERE id = @p1; + diff --git a/Darling/Darling.Tests/IndexCleanupAnalyzerTests.cs b/Darling/Darling.Tests/IndexCleanupAnalyzerTests.cs new file mode 100644 index 000000000..b9f20a4bf --- /dev/null +++ b/Darling/Darling.Tests/IndexCleanupAnalyzerTests.cs @@ -0,0 +1,1026 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using PerformanceMonitor.Common; +using Xunit; + +namespace Darling.Tests; + +/// +/// DB-free tests for the shared — the monitor-side reproduction of +/// sp_IndexCleanup's analysis over the captured index_object_stats snapshot. Every rule +/// (Unused + uptime caveat, Exact/Reverse/Equal-Except-Filter, Key Duplicate, Key Subset/Superset, subset +/// chains), every disable protection (PK, unique constraint, FK-reference), compression candidacy + the +/// 0.20–0.60 savings band, the reclaimable roll-up, and the reconstructed T-SQL are asserted against +/// synthetic index sets. No database. +/// +public class IndexCleanupAnalyzerTests +{ + // ── input builder ────────────────────────────────────────────────────────────────────────────── + + private static IndexCleanupIndexInput Idx( + string name, + string? keys, + int indexId = 2, + string? includes = null, + string? filter = null, + string type = "NONCLUSTERED", + bool unique = false, + bool primaryKey = false, + bool uniqueConstraint = false, + bool foreignKeyReference = false, + bool foreignKey = false, + long seeks = 0, + long scans = 0, + long lookups = 0, + long updates = 0, + decimal reservedMb = 0m, + string? compression = "PAGE", // default already-compressed so dedupe tests aren't cluttered with COMPRESS rows + long rows = 1000, + int partitionCount = 1, + bool optimizeSequentialKey = false, + int objectId = 1, + string database = "TestDb", + string schema = "dbo", + string table = "T") => + new() + { + DatabaseName = database, + DatabaseId = 5, + SchemaName = schema, + ObjectId = objectId, + TableName = table, + IndexId = indexId, + IndexName = name, + IndexTypeDesc = type, + KeyColumns = keys, + IncludedColumns = includes, + FilterDefinition = filter, + IsUnique = unique || primaryKey || uniqueConstraint, + IsUniqueConstraint = uniqueConstraint, + IsPrimaryKey = primaryKey, + IsForeignKey = foreignKey, + IsForeignKeyReference = foreignKeyReference, + IsDisabled = false, + DataCompressionDesc = compression, + OptimizeForSequentialKey = optimizeSequentialKey, + UserSeeks = seeks, + UserScans = scans, + UserLookups = lookups, + UserUpdates = updates, + ReservedMb = reservedMb, + TotalRows = rows, + PartitionCount = partitionCount, + SqlServerStartTime = null, + }; + + private static IndexCleanupOptions Opts( + bool dedupeOnly = false, + double? uptimeDays = 30, + decimal minSizeGb = 0m, + long minRows = 0, + long minReads = 0, + long minWrites = 0, + bool supportsCompression = true, + bool supportsOnline = false) => + new() + { + DedupeOnly = dedupeOnly, + ServerUptimeDays = uptimeDays, + MinSizeGb = minSizeGb, + MinRows = minRows, + MinReads = minReads, + MinWrites = minWrites, + ServerSupportsCompression = supportsCompression, + ServerSupportsOnline = supportsOnline, + }; + + private static IndexCleanupRecommendation? Rec(IndexCleanupAnalysisResult r, string indexName) => + r.Recommendations.FirstOrDefault(x => x.IndexName == indexName); + + // ── Rule 1: Unused ───────────────────────────────────────────────────────────────────────────── + + [Fact] + public void Unused_NonclusteredWithNoReads_IsDisabled() + { + var r = IndexCleanupAnalyzer.Analyze(new[] { Idx("IX_Unused", "[a]") }, Opts()); + + var rec = Assert.Single(r.Recommendations); + Assert.Equal("IX_Unused", rec.IndexName); + Assert.Equal(IndexCleanupAction.Disable, rec.Action); + Assert.Equal(IndexCleanupRules.UnusedIndex, rec.ConsolidationRule); + Assert.Equal("ALTER INDEX [IX_Unused] ON [TestDb].[dbo].[T] DISABLE;", rec.Script); + Assert.False(r.UptimeWarning); + } + + [Fact] + public void Unused_UptimeUnder14Days_CarriesWarningLabel() + { + var r = IndexCleanupAnalyzer.Analyze(new[] { Idx("IX_Unused", "[a]") }, Opts(uptimeDays: 10)); + + var rec = Assert.Single(r.Recommendations); + Assert.Equal(IndexCleanupRules.UnusedIndexUptimeWarning, rec.ConsolidationRule); + Assert.Contains("uptime < 14 days", rec.ConsolidationRule); + Assert.True(r.UptimeWarning); + Assert.False(r.DedupeOnlyApplied); + } + + [Fact] + public void Unused_UptimeUnder7Days_AutoDedupeSkipsUnused() + { + var r = IndexCleanupAnalyzer.Analyze(new[] { Idx("IX_Unused", "[a]") }, Opts(uptimeDays: 5)); + + Assert.Empty(r.Recommendations); // unused not flagged under auto-dedupe + Assert.True(r.DedupeOnlyApplied); + } + + [Fact] + public void Unused_DedupeOnlyOption_SkipsUnused() + { + var r = IndexCleanupAnalyzer.Analyze(new[] { Idx("IX_Unused", "[a]") }, Opts(dedupeOnly: true)); + Assert.Empty(r.Recommendations); + } + + [Fact] + public void Unused_ClusteredIndex_NeverFlagged() + { + // A clustered index with no reads must never be "unused". + var r = IndexCleanupAnalyzer.Analyze(new[] { Idx("PK_T", "[a]", indexId: 1, type: "CLUSTERED", primaryKey: true) }, Opts()); + Assert.DoesNotContain(r.Recommendations, x => x.Action == IndexCleanupAction.Disable); + } + + // ── Rule 2: Exact Duplicate ──────────────────────────────────────────────────────────────────── + + [Fact] + public void ExactDuplicate_DisablesLowerPriorityKeepsHigher() + { + var keeper = Idx("IX_Keep", "[a], [b]", indexId: 2, includes: "[c]", seeks: 10, scans: 5); + var loser = Idx("IX_Drop", "[a], [b]", indexId: 3, includes: "[c]", seeks: 5); + + var r = IndexCleanupAnalyzer.Analyze(new[] { keeper, loser }, Opts()); + + var rec = Assert.Single(r.Recommendations); + Assert.Equal("IX_Drop", rec.IndexName); + Assert.Equal(IndexCleanupAction.Disable, rec.Action); + Assert.Equal(IndexCleanupRules.ExactDuplicate, rec.ConsolidationRule); + Assert.Equal("IX_Keep", rec.TargetIndexName); + Assert.Contains("exact duplicate", rec.AdditionalInfo); + } + + [Fact] + public void ExactDuplicate_TieBreaksOnIndexName() + { + // Identical priority (both seeks + includes) → the alphabetically-first name is the keeper. + var a = Idx("IX_AAA", "[a]", indexId: 2, includes: "[c]", seeks: 10); + var b = Idx("IX_BBB", "[a]", indexId: 3, includes: "[c]", seeks: 10); + + var r = IndexCleanupAnalyzer.Analyze(new[] { b, a }, Opts()); + + var rec = Assert.Single(r.Recommendations); + Assert.Equal("IX_BBB", rec.IndexName); // BBB disabled, AAA kept + Assert.Equal("IX_AAA", rec.TargetIndexName); + } + + // ── Reverse Duplicate (review only — never auto-disabled; mirrors adversarial test 9a) ────────── + + [Fact] + public void ReverseDuplicate_ReversedColumnOrder_IsReviewNotDisabled() + { + // (a, b) vs (b, a): same column set, different leading column → serve different queries. + var ab = Idx("IX_ab", "[a], [b]", indexId: 2, seeks: 10); + var ba = Idx("IX_ba", "[b], [a]", indexId: 3, seeks: 10); + + var r = IndexCleanupAnalyzer.Analyze(new[] { ab, ba }, Opts()); + + Assert.DoesNotContain(r.Recommendations, x => x.Action == IndexCleanupAction.Disable); + + var review = r.Recommendations.First(x => x.IndexName == "IX_ab"); + Assert.Equal(IndexCleanupResultKind.Review, review.ResultKind); + Assert.Equal(IndexCleanupRules.ReverseDuplicate, review.ConsolidationRule); + Assert.Equal(IndexCleanupAction.Keep, review.Action); + Assert.Equal("", review.Script); // informational — no destructive script + } + + [Fact] + public void ReverseDuplicate_InvertedDirection_IsReviewNotDisabled() + { + // (a ASC) vs (a DESC): functionally scannable both ways, but sp_IndexCleanup does not auto-drop them. + var asc = Idx("IX_asc", "[a]", indexId: 2, seeks: 10); + var desc = Idx("IX_desc", "[a] DESC", indexId: 3, seeks: 10); + + var r = IndexCleanupAnalyzer.Analyze(new[] { asc, desc }, Opts()); + + Assert.DoesNotContain(r.Recommendations, x => x.Action == IndexCleanupAction.Disable); + Assert.Contains(r.Recommendations, x => x.ResultKind == IndexCleanupResultKind.Review && x.ConsolidationRule == IndexCleanupRules.ReverseDuplicate); + } + + [Fact] + public void ExactDuplicate_SameDescendingKeys_IsDisabled() + { + // Two byte-identical DESC indexes ARE exact duplicates (mirrors adversarial test 2a). + var d1 = Idx("IX_d1", "[a] DESC", indexId: 2, seeks: 10, scans: 5); + var d2 = Idx("IX_d2", "[a] DESC", indexId: 3, seeks: 10); + + var r = IndexCleanupAnalyzer.Analyze(new[] { d1, d2 }, Opts()); + Assert.Contains(r.Recommendations, x => x.Action == IndexCleanupAction.Disable && x.ConsolidationRule == IndexCleanupRules.ExactDuplicate); + } + + // ── Equal Except For Filter (review only — never auto-disabled; mirrors adversarial test 10a) ─── + + [Fact] + public void EqualExceptForFilter_FilteredVsUnfiltered_IsReviewNotDisabled() + { + var unfiltered = Idx("IX_a", "[a]", indexId: 2, seeks: 10); + var filtered = Idx("IX_a_filt", "[a]", indexId: 3, filter: "([status_code]=(1))", seeks: 10); + + var r = IndexCleanupAnalyzer.Analyze(new[] { unfiltered, filtered }, Opts()); + + Assert.DoesNotContain(r.Recommendations, x => x.Action == IndexCleanupAction.Disable); + var review = r.Recommendations.First(x => x.IndexName == "IX_a_filt" && x.ResultKind == IndexCleanupResultKind.Review); + Assert.Equal(IndexCleanupRules.EqualExceptForFilter, review.ConsolidationRule); + Assert.Contains("filter", review.AdditionalInfo, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void EqualExceptForFilter_DifferentFilters_AreReviewNotDisabled() + { + var a = Idx("IX_A", "[a]", indexId: 2, includes: "[b]", filter: "([a]>(0))", seeks: 10); + var b = Idx("IX_B", "[a]", indexId: 3, includes: "[b]", filter: "([a]>(100))", seeks: 10); + + var r = IndexCleanupAnalyzer.Analyze(new[] { a, b }, Opts()); + + Assert.DoesNotContain(r.Recommendations, x => x.Action == IndexCleanupAction.Disable); + Assert.Equal(2, r.Recommendations.Count(x => x.ResultKind == IndexCleanupResultKind.Review)); + } + + // ── Rule 5: Key Duplicate ────────────────────────────────────────────────────────────────────── + + [Fact] + public void KeyDuplicate_SameKeysDifferentIncludes_MergesIntoKeeper() + { + var keeper = Idx("IX_Keep", "[a]", indexId: 2, includes: "[b]", seeks: 10, scans: 5); + var loser = Idx("IX_Drop", "[a]", indexId: 3, includes: "[c]", seeks: 10); + + var r = IndexCleanupAnalyzer.Analyze(new[] { keeper, loser }, Opts()); + + var keep = Rec(r, "IX_Keep"); + var drop = Rec(r, "IX_Drop"); + Assert.NotNull(keep); + Assert.NotNull(drop); + + Assert.Equal(IndexCleanupAction.MergeIncludes, keep!.Action); + Assert.Equal(IndexCleanupRules.KeyDuplicate, keep.ConsolidationRule); + Assert.Contains("INCLUDE ([b], [c])", keep.Script); // absorbed the loser's include + Assert.Contains("DROP_EXISTING = ON", keep.Script); + Assert.Equal("Supersedes IX_Drop", keep.SupersededBy); + + Assert.Equal(IndexCleanupAction.Disable, drop!.Action); + Assert.Equal("IX_Keep", drop.TargetIndexName); + } + + [Fact] + public void KeyDuplicate_PrefersUniqueAsKeeper() + { + // Non-unique has higher usage priority, but the unique index must win. + var uniqueIx = Idx("IX_Unique", "[a]", indexId: 2, includes: "[b]", unique: true, seeks: 1); + var busyIx = Idx("IX_Busy", "[a]", indexId: 3, includes: "[c]", seeks: 99, scans: 99); + + var r = IndexCleanupAnalyzer.Analyze(new[] { busyIx, uniqueIx }, Opts()); + + Assert.Equal(IndexCleanupAction.MergeIncludes, Rec(r, "IX_Unique")!.Action); + Assert.Equal(IndexCleanupAction.Disable, Rec(r, "IX_Busy")!.Action); + } + + // ── Rules 3/4/6: Key Subset / Superset ───────────────────────────────────────────────────────── + + [Fact] + public void KeySubset_NarrowerPrefix_DisabledInFavorOfSuperset() + { + var narrow = Idx("IX_Narrow", "[a]", indexId: 2, includes: "[z]", seeks: 10); + var wide = Idx("IX_Wide", "[a], [b]", indexId: 3, seeks: 10); + + var r = IndexCleanupAnalyzer.Analyze(new[] { narrow, wide }, Opts()); + + var sub = Rec(r, "IX_Narrow"); + var sup = Rec(r, "IX_Wide"); + Assert.NotNull(sub); + Assert.NotNull(sup); + + Assert.Equal(IndexCleanupAction.Disable, sub!.Action); + Assert.Equal(IndexCleanupRules.KeySubset, sub.ConsolidationRule); + Assert.Equal("IX_Wide", sub.TargetIndexName); + + Assert.Equal(IndexCleanupAction.MergeIncludes, sup!.Action); + Assert.Equal(IndexCleanupRules.KeySuperset, sup.ConsolidationRule); + Assert.Equal("[z]", sup.MissingIncludedColumns); // subset's include merged into the superset + Assert.Contains("INCLUDE ([z])", sup.Script); + Assert.Equal("Supersedes IX_Narrow", sup.SupersededBy); + } + + [Fact] + public void KeySubset_UniqueNarrower_IsNotDisabled() + { + // A unique index on (A) enforces uniqueness a wider (A,B) index does not — never disable it. + var uniqueNarrow = Idx("IX_UQ", "[a]", indexId: 2, unique: true, seeks: 10); + var wide = Idx("IX_Wide", "[a], [b]", indexId: 3, seeks: 10); + + var r = IndexCleanupAnalyzer.Analyze(new[] { uniqueNarrow, wide }, Opts()); + + Assert.DoesNotContain(r.Recommendations, x => x.IndexName == "IX_UQ" && x.Action == IndexCleanupAction.Disable); + } + + [Fact] + public void KeySubset_ChooseClosestSuperset() + { + // Two supersets that are NOT nested in each other (different 2nd key), so neither is itself + // disabled — the narrow index must defer to the one with the fewest extra key columns. + var narrow = Idx("IX_N", "[a]", indexId: 2, seeks: 10); + var near = Idx("IX_Near", "[a], [b]", indexId: 3, seeks: 10); + var far = Idx("IX_Far", "[a], [c], [d]", indexId: 4, seeks: 10); + + var r = IndexCleanupAnalyzer.Analyze(new[] { narrow, near, far }, Opts()); + + Assert.Equal("IX_Near", Rec(r, "IX_N")!.TargetIndexName); // fewest extra key columns + } + + [Fact] + public void KeySubset_ChainIsFlattenedToFinalSuperset() + { + var a = Idx("IX_A", "[a]", indexId: 2, seeks: 10); + var b = Idx("IX_B", "[a], [b]", indexId: 3, seeks: 10); + var c = Idx("IX_C", "[a], [b], [c]", indexId: 4, seeks: 10); + + var r = IndexCleanupAnalyzer.Analyze(new[] { a, b, c }, Opts()); + + Assert.Equal("IX_C", Rec(r, "IX_A")!.TargetIndexName); // A → B → C flattened to A → C + Assert.Equal("IX_C", Rec(r, "IX_B")!.TargetIndexName); + Assert.Equal(IndexCleanupAction.MergeIncludes, Rec(r, "IX_C")!.Action); + Assert.Equal(IndexCleanupRules.KeySuperset, Rec(r, "IX_C")!.ConsolidationRule); + } + + [Fact] + public void KeySubset_SameFilter_IsDisabled() + { + // Filtered subset with a MATCHING filter → disabled (mirrors adversarial test 3c). + var narrow = Idx("IX_n", "[a]", indexId: 2, filter: "([status_code]=(3))", seeks: 10); + var wide = Idx("IX_w", "[a], [b]", indexId: 3, filter: "([status_code]=(3))", seeks: 10); + + var r = IndexCleanupAnalyzer.Analyze(new[] { narrow, wide }, Opts()); + + Assert.Equal(IndexCleanupAction.Disable, Rec(r, "IX_n")!.Action); + Assert.Equal(IndexCleanupRules.KeySubset, Rec(r, "IX_n")!.ConsolidationRule); + } + + [Fact] + public void KeySubset_DifferentFilter_IsNotSubset() + { + // Prefix key but a DIFFERENT filter → NOT a subset (mirrors adversarial test 3d). + var narrow = Idx("IX_n", "[a]", indexId: 2, filter: "([status_code]=(1))", seeks: 10); + var wide = Idx("IX_w", "[a], [b]", indexId: 3, filter: "([status_code]=(2))", seeks: 10); + + var r = IndexCleanupAnalyzer.Analyze(new[] { narrow, wide }, Opts()); + + Assert.DoesNotContain(r.Recommendations, x => x.Action == IndexCleanupAction.Disable); + } + + // ── Rules 7 / 7.5 / 7.5b: Unique Constraint Replacement ────────────────────────────────────────── + // Behavior below was verified against a live sp_IndexCleanup @debug=1 run (see PR notes). + + [Fact] + public void UniqueConstraintReplacement_NcMatchesConstraint_NcMadeUniqueConstraintDropped() + { + // Rule 7 + 7.5: a nonclustered index whose keys EXACTLY match a unique constraint → the index is made + // unique and the redundant constraint is dropped. + var nc = Idx("nc_ab", "[a], [b]", indexId: 2, seeks: 10); + var uc = Idx("UC_ab", "[a], [b]", indexId: 3, uniqueConstraint: true); + + var r = IndexCleanupAnalyzer.Analyze(new[] { nc, uc }, Opts()); + + var ncRec = Rec(r, "nc_ab"); + Assert.NotNull(ncRec); + Assert.Equal(IndexCleanupAction.MakeUnique, ncRec!.Action); + Assert.Equal(IndexCleanupRules.UniqueConstraintReplacement, ncRec.ConsolidationRule); + Assert.Equal(IndexCleanupResultKind.Merge, ncRec.ResultKind); + Assert.Equal("MERGE SCRIPT", ncRec.ScriptType); + Assert.Equal("Will replace constraint UC_ab", ncRec.SupersededBy); + Assert.Contains("CREATE UNIQUE INDEX [nc_ab]", ncRec.Script); + Assert.Contains("DROP_EXISTING = ON", ncRec.Script); + + var ucRec = Rec(r, "UC_ab"); + Assert.NotNull(ucRec); + Assert.Equal(IndexCleanupAction.Disable, ucRec!.Action); + Assert.Equal(IndexCleanupRules.UniqueConstraintReplacement, ucRec.ConsolidationRule); + Assert.Equal(IndexCleanupResultKind.DisableConstraint, ucRec.ResultKind); + Assert.Equal("DISABLE CONSTRAINT SCRIPT", ucRec.ScriptType); + Assert.Equal("nc_ab", ucRec.TargetIndexName); + Assert.Equal("ALTER TABLE [TestDb].[dbo].[T] DROP CONSTRAINT [UC_ab];", ucRec.Script); + Assert.Contains("replaced by: nc_ab", ucRec.AdditionalInfo); + } + + [Fact] + public void UniqueConstraintReplacement_MakeUniqueScript_HasCanonicalUniqueDropExistingForm() + { + var nc = Idx("nc_ab", "[a], [b]", indexId: 2, seeks: 10); + var uc = Idx("UC_ab", "[a], [b]", indexId: 3, uniqueConstraint: true); + + var r = IndexCleanupAnalyzer.Analyze(new[] { nc, uc }, Opts()); + + Assert.Equal( + "CREATE UNIQUE INDEX [nc_ab] ON [TestDb].[dbo].[T] ([a], [b]) WITH (DROP_EXISTING = ON, FILLFACTOR = 100, SORT_IN_TEMPDB = ON, ONLINE = OFF);", + Rec(r, "nc_ab")!.Script); + } + + [Fact] + public void UniqueConstraintReplacement_MakeUniqueKeepsOwnIncludes() + { + // Key match is on key_columns only — the made-unique index keeps its own includes (Rule 7.6 include-merge is out of scope). + var nc = Idx("nc_ab", "[a], [b]", indexId: 2, includes: "[c]", seeks: 10); + var uc = Idx("UC_ab", "[a], [b]", indexId: 3, uniqueConstraint: true); + + var r = IndexCleanupAnalyzer.Analyze(new[] { nc, uc }, Opts()); + + var ncRec = Rec(r, "nc_ab"); + Assert.Equal(IndexCleanupAction.MakeUnique, ncRec!.Action); + Assert.Contains("CREATE UNIQUE INDEX [nc_ab] ON [TestDb].[dbo].[T] ([a], [b]) INCLUDE ([c])", ncRec.Script); + } + + [Fact] + public void UniqueConstraintReplacement_ConstraintBacksForeignKeyReference_NotDropped() + { + // PROTECTION: a constraint backing an inbound FK reference is never dropped (disabling silently breaks + // referential integrity). Its would-be replacement index is then reverted to no action (proc-faithful). + var nc = Idx("nc_ab", "[a], [b]", indexId: 2, seeks: 10); + var uc = Idx("UC_ab", "[a], [b]", indexId: 3, uniqueConstraint: true, foreignKeyReference: true); + + var r = IndexCleanupAnalyzer.Analyze(new[] { nc, uc }, Opts()); + + Assert.DoesNotContain(r.Recommendations, x => x.IndexName == "UC_ab" && x.Action == IndexCleanupAction.Disable); + Assert.DoesNotContain(r.Recommendations, x => x.ResultKind == IndexCleanupResultKind.DisableConstraint); + Assert.DoesNotContain(r.Recommendations, x => x.Action == IndexCleanupAction.MakeUnique); + } + + [Fact] + public void UniqueConstraintReplacement_NcKeySuperset_NotPromoted() + { + // Both-direction set-equality guard: an index with an EXTRA key column is NOT equivalent to the + // constraint, so it is not promoted (and the constraint is not dropped). + var nc = Idx("nc_abc", "[a], [b], [c]", indexId: 2, seeks: 10); + var uc = Idx("UC_ab", "[a], [b]", indexId: 3, uniqueConstraint: true); + + var r = IndexCleanupAnalyzer.Analyze(new[] { nc, uc }, Opts()); + + Assert.DoesNotContain(r.Recommendations, x => x.Action == IndexCleanupAction.MakeUnique); + Assert.DoesNotContain(r.Recommendations, x => x.ResultKind == IndexCleanupResultKind.DisableConstraint); + } + + [Fact] + public void UniqueConstraintReplacement_AlreadyUniqueNcExactMatch_IsMadeUniquePerLiteralProc() + { + // AMBIGUITY (verified live): an already-unique index that EXACTLY matches a constraint is still marked + // MAKE UNIQUE, NOT KEEP — the proc's Rule 7.5 step 2 has no is_unique guard, so it overrides Rule 7's + // KEEP. The rebuild is a redundant-but-harmless UNIQUE rebuild that still drops the constraint. This + // reproduces the proc's LITERAL behavior (the task text's "KEEP" expectation does not survive Rule 7.5). + var nc = Idx("nc_ab", "[a], [b]", indexId: 2, unique: true, seeks: 10); + var uc = Idx("UC_ab", "[a], [b]", indexId: 3, uniqueConstraint: true); + + var r = IndexCleanupAnalyzer.Analyze(new[] { nc, uc }, Opts()); + + Assert.Equal(IndexCleanupAction.MakeUnique, Rec(r, "nc_ab")!.Action); + Assert.Equal(IndexCleanupAction.Disable, Rec(r, "UC_ab")!.Action); + Assert.Equal(IndexCleanupResultKind.DisableConstraint, Rec(r, "UC_ab")!.ResultKind); + } + + [Fact] + public void UniqueConstraintReplacement_AlreadyUniqueNcDifferentKeyOrder_IsKeptNotMadeUnique() + { + // The KEEP case the task describes: an already-unique index whose keys match the constraint only by SET + // (reversed order) survives as KEEP — Rule 7's set-match assigns KEEP and Rule 7.5's EXACT-key match + // does not fire, so no redundant MAKE UNIQUE and no constraint drop. + var nc = Idx("nc_ba", "[b], [a]", indexId: 2, unique: true, seeks: 10); + var uc = Idx("UC_ab", "[a], [b]", indexId: 3, uniqueConstraint: true); + + var r = IndexCleanupAnalyzer.Analyze(new[] { nc, uc }, Opts()); + + Assert.DoesNotContain(r.Recommendations, x => x.Action == IndexCleanupAction.MakeUnique); + Assert.DoesNotContain(r.Recommendations, x => x.Action == IndexCleanupAction.Disable); + Assert.DoesNotContain(r.Recommendations, x => x.ResultKind == IndexCleanupResultKind.DisableConstraint); + } + + [Fact] + public void UniqueConstraintReplacement_ExactMatch_OverridesUnusedMarking() + { + // Rule 7.5 step 2 is unconditional on prior state: a nonclustered index with no reads is first marked + // Unused/DISABLE, then FORCED to MAKE UNIQUE by the exact-key constraint match (verified live). + var nc = Idx("nc_ab", "[a], [b]", indexId: 2); // no reads → would be "Unused" + var uc = Idx("UC_ab", "[a], [b]", indexId: 3, uniqueConstraint: true); + + var r = IndexCleanupAnalyzer.Analyze(new[] { nc, uc }, Opts(uptimeDays: 30)); + + var ncRec = Rec(r, "nc_ab"); + Assert.Equal(IndexCleanupAction.MakeUnique, ncRec!.Action); + Assert.Equal(IndexCleanupRules.UniqueConstraintReplacement, ncRec.ConsolidationRule); + Assert.Equal(IndexCleanupAction.Disable, Rec(r, "UC_ab")!.Action); + } + + [Fact] + public void UniqueConstraintReplacement_DuplicateConstraints_LoserDroppedKeeperSurvives() + { + // Rule 7.5b: two identical unique constraints, no nonclustered index to promote → drop all but one. + // The alphabetically-later name loses at equal priority (mirrors the Rule 2 tiebreak). + var uc1 = Idx("UC_1", "[a], [b]", indexId: 2, uniqueConstraint: true); + var uc2 = Idx("UC_2", "[a], [b]", indexId: 3, uniqueConstraint: true); + + var r = IndexCleanupAnalyzer.Analyze(new[] { uc1, uc2 }, Opts()); + + var loser = Rec(r, "UC_2"); + Assert.NotNull(loser); + Assert.Equal(IndexCleanupAction.Disable, loser!.Action); + Assert.Equal(IndexCleanupResultKind.DisableConstraint, loser.ResultKind); + Assert.Equal(IndexCleanupRules.UniqueConstraintReplacement, loser.ConsolidationRule); + Assert.Equal("UC_1", loser.TargetIndexName); + Assert.Equal("ALTER TABLE [TestDb].[dbo].[T] DROP CONSTRAINT [UC_2];", loser.Script); + + // The keeper survives with no destructive recommendation. + Assert.DoesNotContain(r.Recommendations, x => x.IndexName == "UC_1" && x.Action == IndexCleanupAction.Disable); + } + + [Fact] + public void UniqueConstraintReplacement_ThreeDuplicateConstraints_AllLosersPointAtSingleWinner() + { + // Determinism guard: with 3+ identical constraints, every loser targets the single overall winner. + var uc1 = Idx("UC_a", "[a], [b]", indexId: 2, uniqueConstraint: true); + var uc2 = Idx("UC_b", "[a], [b]", indexId: 3, uniqueConstraint: true); + var uc3 = Idx("UC_c", "[a], [b]", indexId: 4, uniqueConstraint: true); + + var r = IndexCleanupAnalyzer.Analyze(new[] { uc1, uc2, uc3 }, Opts()); + + Assert.Equal(2, r.Recommendations.Count(x => x.ResultKind == IndexCleanupResultKind.DisableConstraint)); + Assert.Equal("UC_a", Rec(r, "UC_b")!.TargetIndexName); + Assert.Equal("UC_a", Rec(r, "UC_c")!.TargetIndexName); + Assert.DoesNotContain(r.Recommendations, x => x.IndexName == "UC_a" && x.Action == IndexCleanupAction.Disable); + } + + [Fact] + public void UniqueConstraintReplacement_DuplicateConstraints_FkReferencedLoserProtected() + { + // Rule 7.5b FK guard: the natural loser (alphabetically later) backs an inbound FK reference → it is + // NOT demoted/dropped, so neither constraint is dropped. + var keep = Idx("UC_aaa", "[a], [b]", indexId: 2, uniqueConstraint: true); + var fkLoser = Idx("UC_zzz", "[a], [b]", indexId: 3, uniqueConstraint: true, foreignKeyReference: true); + + var r = IndexCleanupAnalyzer.Analyze(new[] { keep, fkLoser }, Opts()); + + Assert.DoesNotContain(r.Recommendations, x => x.ResultKind == IndexCleanupResultKind.DisableConstraint); + Assert.DoesNotContain(r.Recommendations, x => x.Action == IndexCleanupAction.Disable); + } + + [Fact] + public void UniqueConstraintReplacement_RollupCountsMakeUniqueAsMergeAndDroppedConstraintAsDisable() + { + var nc = Idx("nc_ab", "[a], [b]", indexId: 2, seeks: 10); + var uc = Idx("UC_ab", "[a], [b]", indexId: 3, uniqueConstraint: true); + + var r = IndexCleanupAnalyzer.Analyze(new[] { nc, uc }, Opts()); + + Assert.Equal(1, r.OverallRollup.IndexesToMerge); // the MAKE UNIQUE index + Assert.Equal(1, r.OverallRollup.IndexesToDisable); // the dropped constraint + Assert.Equal(0, r.OverallRollup.UnusedIndexes); // neither is "unused" + } + + [Fact] + public void UniqueConstraintReplacement_KeySupersetMadeUnique_RetainsAbsorbedSubsetIncludes() + { + // A Key-Superset index that absorbed a disabled subset's includes and THEN exactly matches a + // constraint is made unique WITHOUT losing the absorbed include (verified live: + // CREATE UNIQUE INDEX [IX_wide] … INCLUDE ([c], [d])). + var uc = Idx("UC_ab", "[a], [b]", indexId: 2, uniqueConstraint: true); + var wide = Idx("IX_wide", "[a], [b]", indexId: 3, includes: "[c]", seeks: 10); + var narrow = Idx("IX_narrow", "[a]", indexId: 4, includes: "[d]", seeks: 10); + + var r = IndexCleanupAnalyzer.Analyze(new[] { uc, wide, narrow }, Opts()); + + var wideRec = Rec(r, "IX_wide"); + Assert.Equal(IndexCleanupAction.MakeUnique, wideRec!.Action); + Assert.Contains("INCLUDE ([c], [d])", wideRec.Script); // absorbed subset include [d] survives step 2 + Assert.Equal(IndexCleanupRules.KeySubset, Rec(r, "IX_narrow")!.ConsolidationRule); + Assert.Equal(IndexCleanupAction.Disable, Rec(r, "IX_narrow")!.Action); + Assert.Equal(IndexCleanupResultKind.DisableConstraint, Rec(r, "UC_ab")!.ResultKind); + } + + [Fact] + public void UniqueConstraintReplacement_Rule76_KeyDuplicateOfMadeUniqueIndex_ReDisabledAndIncludesFolded() + { + // Rule 7.6: after Rule 7.5 makes nc_a unique, the other same-key index (nc_b) — which step 2 briefly + // promoted then the cleanup reverted — is re-disabled as a Key Duplicate and its include folded into + // the winner (verified live). Without Rule 7.6 nc_b would be silently left in place (a dropped DISABLE). + var uc = Idx("UC_ab", "[a], [b]", indexId: 2, uniqueConstraint: true); + var ncA = Idx("nc_a", "[a], [b]", indexId: 3, includes: "[c]", seeks: 10); + var ncB = Idx("nc_b", "[a], [b]", indexId: 4, includes: "[d]", seeks: 10); + + var r = IndexCleanupAnalyzer.Analyze(new[] { uc, ncA, ncB }, Opts()); + + var winner = Rec(r, "nc_a"); + Assert.Equal(IndexCleanupAction.MakeUnique, winner!.Action); + Assert.Contains("INCLUDE ([c], [d])", winner.Script); // nc_b's include folded in + + var loser = Rec(r, "nc_b"); + Assert.NotNull(loser); + Assert.Equal(IndexCleanupAction.Disable, loser!.Action); + Assert.Equal(IndexCleanupRules.KeyDuplicate, loser.ConsolidationRule); // NOT Unique Constraint Replacement + Assert.Equal(IndexCleanupResultKind.Disable, loser.ResultKind); // ALTER INDEX DISABLE (nc_b is not a constraint) + Assert.Equal("nc_a", loser.TargetIndexName); + + Assert.Equal(IndexCleanupResultKind.DisableConstraint, Rec(r, "UC_ab")!.ResultKind); + } + + // ── Protections ──────────────────────────────────────────────────────────────────────────────── + + [Fact] + public void Protection_PrimaryKey_IsKeptNotDisabled() + { + var pk = Idx("PK_T", "[a]", indexId: 2, primaryKey: true, unique: true, seeks: 1); + var dup = Idx("IX_Dup", "[a]", indexId: 3, seeks: 99, scans: 99); // higher usage, but must not win + + var r = IndexCleanupAnalyzer.Analyze(new[] { pk, dup }, Opts()); + + Assert.DoesNotContain(r.Recommendations, x => x.IndexName == "PK_T" && x.Action == IndexCleanupAction.Disable); + Assert.Equal(IndexCleanupAction.Disable, Rec(r, "IX_Dup")!.Action); + } + + [Fact] + public void Protection_ForeignKeyReference_IsKeptNotDisabled() + { + var fkRef = Idx("UQ_Ref", "[a]", indexId: 2, unique: true, foreignKeyReference: true, seeks: 1); + var dup = Idx("IX_Dup", "[a]", indexId: 3, seeks: 99, scans: 99); + + var r = IndexCleanupAnalyzer.Analyze(new[] { fkRef, dup }, Opts()); + + Assert.DoesNotContain(r.Recommendations, x => x.IndexName == "UQ_Ref" && x.Action == IndexCleanupAction.Disable); + Assert.Equal(IndexCleanupAction.Disable, Rec(r, "IX_Dup")!.Action); + } + + [Fact] + public void Protection_UniqueConstraint_NotNaivelyDeduped_ReplacedViaRule75() + { + // A unique constraint is excluded from the Exact/Key duplicate candidate set, so it is never marked an + // "Exact Duplicate"/"Key Duplicate" loser. With an identical-key nonclustered index it is instead + // handled by Rule 7.5 (replaced by the made-unique index → a constraint DROP), never by naive dedupe. + var uc = Idx("UQ_C", "[a]", indexId: 2, uniqueConstraint: true, unique: true, seeks: 10); + var dup = Idx("IX_Dup", "[a]", indexId: 3, seeks: 10); + + var r = IndexCleanupAnalyzer.Analyze(new[] { uc, dup }, Opts()); + + var ucRec = Rec(r, "UQ_C"); + Assert.NotNull(ucRec); + // Never swept into the naive Exact/Key duplicate consolidation. + Assert.NotEqual(IndexCleanupRules.ExactDuplicate, ucRec!.ConsolidationRule); + Assert.NotEqual(IndexCleanupRules.KeyDuplicate, ucRec.ConsolidationRule); + // Instead: Rule 7.5 replaces it with the made-unique index and drops the constraint. + Assert.Equal(IndexCleanupRules.UniqueConstraintReplacement, ucRec.ConsolidationRule); + Assert.Equal(IndexCleanupResultKind.DisableConstraint, ucRec.ResultKind); + Assert.Equal("IX_Dup", ucRec.TargetIndexName); + Assert.Equal(IndexCleanupAction.MakeUnique, Rec(r, "IX_Dup")!.Action); + } + + [Fact] + public void Protection_ForeignKeyReferencedUnused_IsNotDisabled() + { + // FK-referenced index with zero reads: must not be flagged unused (it backs referential integrity). + var fkRef = Idx("UQ_Ref", "[a]", indexId: 2, unique: true, foreignKeyReference: true); + + var r = IndexCleanupAnalyzer.Analyze(new[] { fkRef }, Opts()); + Assert.Empty(r.Recommendations); + } + + // ── Compression ──────────────────────────────────────────────────────────────────────────────── + + [Fact] + public void Compression_UncompressedLargeIndex_IsCandidateWithSavingsBand() + { + var clustered = Idx("CX_T", "[a]", indexId: 1, type: "CLUSTERED", compression: "NONE", reservedMb: 10240m, seeks: 10); + + var r = IndexCleanupAnalyzer.Analyze(new[] { clustered }, Opts()); + + var rec = Assert.Single(r.Recommendations); + Assert.Equal(IndexCleanupResultKind.Compress, rec.ResultKind); + Assert.True(rec.CanCompress); + Assert.Equal("ALTER INDEX [CX_T] ON [TestDb].[dbo].[T] REBUILD WITH (FILLFACTOR = 100, SORT_IN_TEMPDB = ON, ONLINE = OFF, DATA_COMPRESSION = PAGE);", rec.Script); + + Assert.Equal(1, r.OverallRollup.CompressableIndexes); + Assert.Equal(2m, Math.Round(r.OverallRollup.CompressionMinSavingsGb, 4)); // 10 GB * 0.20 + Assert.Equal(6m, Math.Round(r.OverallRollup.CompressionMaxSavingsGb, 4)); // 10 GB * 0.60 + } + + [Fact] + public void Compression_AlreadyPageCompressed_NotCandidate() + { + var ix = Idx("CX_T", "[a]", indexId: 1, type: "CLUSTERED", compression: "PAGE", reservedMb: 10240m, seeks: 10); + var r = IndexCleanupAnalyzer.Analyze(new[] { ix }, Opts()); + Assert.Empty(r.Recommendations); + Assert.Equal(0, r.OverallRollup.CompressableIndexes); + } + + [Fact] + public void Compression_BelowMinSize_Excluded() + { + var ix = Idx("CX_T", "[a]", indexId: 1, type: "CLUSTERED", compression: "NONE", reservedMb: 512m, seeks: 10); + var r = IndexCleanupAnalyzer.Analyze(new[] { ix }, Opts(minSizeGb: 1m)); // 0.5 GB < 1 GB + Assert.Empty(r.Recommendations); + } + + [Fact] + public void Compression_EditionUnsupported_Excluded() + { + var ix = Idx("CX_T", "[a]", indexId: 1, type: "CLUSTERED", compression: "NONE", reservedMb: 10240m, seeks: 10); + var r = IndexCleanupAnalyzer.Analyze(new[] { ix }, Opts(supportsCompression: false)); + Assert.Empty(r.Recommendations); + } + + [Fact] + public void Compression_PartitionedIndex_UsesPartitionAll() + { + var ix = Idx("CX_T", "[a]", indexId: 1, type: "CLUSTERED", compression: "NONE", reservedMb: 10240m, partitionCount: 4, seeks: 10); + var r = IndexCleanupAnalyzer.Analyze(new[] { ix }, Opts()); + Assert.Contains("REBUILD PARTITION = ALL", Assert.Single(r.Recommendations).Script); + } + + [Fact] + public void Compression_OnlineSupported_EmitsOnlineOn() + { + var ix = Idx("CX_T", "[a]", indexId: 1, type: "CLUSTERED", compression: "NONE", reservedMb: 10240m, optimizeSequentialKey: true, seeks: 10); + var r = IndexCleanupAnalyzer.Analyze(new[] { ix }, Opts(supportsOnline: true)); + var rec = Assert.Single(r.Recommendations); + Assert.Contains("ONLINE = ON", rec.Script); + Assert.Contains("OPTIMIZE_FOR_SEQUENTIAL_KEY = ON", rec.Script); + } + + // ── Reclaimable roll-up ──────────────────────────────────────────────────────────────────────── + + [Fact] + public void Rollup_CombinesDisabledFullSizeAndCompressionBand() + { + var unused = Idx("IX_Unused", "[b]", indexId: 2, compression: "PAGE", reservedMb: 5120m); // 5 GB, disabled → full reclaim + var keptCompressible = Idx("CX_T", "[a]", indexId: 1, type: "CLUSTERED", compression: "NONE", reservedMb: 10240m, seeks: 10); // 10 GB kept → 2-6 GB + + var r = IndexCleanupAnalyzer.Analyze(new[] { unused, keptCompressible }, Opts()); + + var roll = r.OverallRollup; + Assert.Equal(1, roll.UnusedIndexes); + Assert.Equal(1, roll.IndexesToDisable); + Assert.Equal(5m, Math.Round(roll.UnusedSizeGb, 4)); + Assert.Equal(1, roll.CompressableIndexes); + Assert.Equal(2m, Math.Round(roll.CompressionMinSavingsGb, 4)); + Assert.Equal(6m, Math.Round(roll.CompressionMaxSavingsGb, 4)); + Assert.Equal(7m, Math.Round(roll.TotalMinSavingsGb, 4)); // 5 + 2 + Assert.Equal(11m, Math.Round(roll.TotalMaxSavingsGb, 4)); // 5 + 6 + } + + // ── Reconstructed T-SQL ──────────────────────────────────────────────────────────────────────── + + [Fact] + public void OriginalDefinition_ReconstructsCreateFromCapturedStrings() + { + var ix = Idx("IX_x", "[a], [b] DESC", indexId: 2, includes: "[c], [d]", filter: "([a]>(0))"); + + var r = IndexCleanupAnalyzer.Analyze(new[] { ix }, Opts()); + var rec = Assert.Single(r.Recommendations); + + Assert.Equal( + "CREATE NONCLUSTERED INDEX [IX_x] ON [TestDb].[dbo].[T] ([a], [b] DESC) INCLUDE ([c], [d]) WHERE ([a]>(0));", + rec.OriginalIndexDefinition); + } + + [Fact] + public void MergeScript_HasCanonicalDropExistingForm() + { + var keeper = Idx("IX_Keep", "[a]", indexId: 2, includes: "[b]", seeks: 10, scans: 5); + var loser = Idx("IX_Drop", "[a]", indexId: 3, includes: "[c]", seeks: 10); + + var r = IndexCleanupAnalyzer.Analyze(new[] { keeper, loser }, Opts()); + + Assert.Equal( + "CREATE INDEX [IX_Keep] ON [TestDb].[dbo].[T] ([a]) INCLUDE ([b], [c]) WITH (DROP_EXISTING = ON, FILLFACTOR = 100, SORT_IN_TEMPDB = ON, ONLINE = OFF);", + Rec(r, "IX_Keep")!.Script); + } + + [Fact] + public void QuoteName_EscapesClosingBracketsInObjectNames() + { + var ix = Idx("IX]evil", "[a]", indexId: 2, table: "Weird]Table"); + var r = IndexCleanupAnalyzer.Analyze(new[] { ix }, Opts()); + var rec = Assert.Single(r.Recommendations); + Assert.Equal("ALTER INDEX [IX]]evil] ON [TestDb].[dbo].[Weird]]Table] DISABLE;", rec.Script); + } + + // ── Input shaping ────────────────────────────────────────────────────────────────────────────── + + [Fact] + public void NonRowstoreAndDisabled_AreExcludedFromAnalysis() + { + var heap = Idx("Heap", null, indexId: 0, type: "HEAP"); + var columnstore = Idx("CS", "[a]", indexId: 2, type: "NONCLUSTERED COLUMNSTORE", compression: "COLUMNSTORE"); + var disabled = new IndexCleanupIndexInput + { + DatabaseName = "TestDb", SchemaName = "dbo", TableName = "T", ObjectId = 1, IndexId = 4, + IndexName = "IX_Disabled", IndexTypeDesc = "NONCLUSTERED", KeyColumns = "[a]", IsDisabled = true, + }; + + var r = IndexCleanupAnalyzer.Analyze(new[] { heap, columnstore, disabled }, Opts()); + + Assert.Empty(r.Recommendations); + Assert.Equal(0, r.OverallRollup.IndexCount); + } + + [Fact] + public void MultipleDatabases_ProduceSeparateRollups() + { + var db1 = Idx("IX_1", "[a]", indexId: 2, database: "DbOne", objectId: 1); + var db2 = Idx("IX_2", "[a]", indexId: 2, database: "DbTwo", objectId: 2); + + var r = IndexCleanupAnalyzer.Analyze(new[] { db1 with { DatabaseId = 1 }, db2 with { DatabaseId = 2 } }, Opts()); + + Assert.Equal(2, r.DatabaseRollups.Count); + Assert.Contains(r.DatabaseRollups, x => x.DatabaseName == "DbOne"); + Assert.Contains(r.DatabaseRollups, x => x.DatabaseName == "DbTwo"); + Assert.Equal(2, r.OverallRollup.IndexesToDisable); + } + + [Fact] + public void CrossTableIsolation_IdenticalIndexesOnDifferentObjects_NotFlagged() + { + // Identical indexes on DIFFERENT tables (objects) must never dedupe each other (mirrors test 7a). + var t1 = Idx("IX_same", "[a]", indexId: 2, objectId: 10, table: "T1", seeks: 10); + var t2 = Idx("IX_same", "[a]", indexId: 2, objectId: 20, table: "T2", seeks: 10); + + var r = IndexCleanupAnalyzer.Analyze(new[] { t1, t2 }, Opts()); + Assert.DoesNotContain(r.Recommendations, x => x.Action == IndexCleanupAction.Disable); + } + + [Fact] + public void MinRows_SmallTableIsSkipped() + { + var ix = Idx("IX_Small", "[a]", indexId: 2, rows: 50); + var r = IndexCleanupAnalyzer.Analyze(new[] { ix }, Opts(minRows: 1000)); + Assert.Empty(r.Recommendations); + } + + [Fact] + public void Notes_SurfaceOutOfScopeRules() + { + var r = IndexCleanupAnalyzer.Analyze(new[] { Idx("IX_Unused", "[a]") }, Opts()); + // Same Keys Different Order remains out of scope (Unique Constraint Replacement, incl. Rules 7.5b/7.6, is implemented). + Assert.Contains(r.Notes, n => n.Contains("Same Keys Different Order")); + } + + // ── Review-parity guards (sp_IndexCleanup fix waves ae32a4c / 0abb3ef) ──────────────────────── + // A constraint-backed index cannot be rebuilt with CREATE INDEX ... DROP_EXISTING (Msg 1907), so it + // must never be the surviving side of a merge; a filtered or partitioned index must never be promoted + // to replace a unique constraint (uniqueness hole / unverifiable Msg-1908 alignment); the usage floors + // must each bite on their own. + + [Fact] + public void KeySubset_SupersetTarget_SkipsConstraintBackedIndex() + { + // Closest superset by key length is the PK-backed index — but its DROP_EXISTING merge rebuild + // would fail (Msg 1907) while the subset's DISABLE succeeded. The plain wider index must win. + var narrow = Idx("IX_Narrow", "[a]", indexId: 2, includes: "[z]", seeks: 10); + var pkBacked = Idx("PK_AB", "[a], [b]", indexId: 3, primaryKey: true, seeks: 10); + var wide = Idx("IX_Wide", "[a], [b], [c]", indexId: 4, seeks: 10); + + var r = IndexCleanupAnalyzer.Analyze(new[] { narrow, pkBacked, wide }, Opts()); + + var narrowRec = Rec(r, "IX_Narrow"); + Assert.NotNull(narrowRec); + Assert.Equal(IndexCleanupAction.Disable, narrowRec!.Action); + Assert.Equal("IX_Wide", narrowRec.TargetIndexName); + Assert.DoesNotContain(r.Recommendations, x => + x.IndexName == "PK_AB" + && (x.Action == IndexCleanupAction.MergeIncludes || x.Action == IndexCleanupAction.Disable)); + } + + [Fact] + public void KeySubset_OnlyConstraintBackedSupersetExists_LeavesNarrowAlone() + { + // The ONLY wider index is PK-backed: better to keep the redundant narrow index than disable it + // deferring to a rebuild that cannot execute. + var narrow = Idx("IX_Narrow", "[a]", indexId: 2, seeks: 10); + var pkBacked = Idx("PK_AB", "[a], [b]", indexId: 3, primaryKey: true, seeks: 10); + + var r = IndexCleanupAnalyzer.Analyze(new[] { narrow, pkBacked }, Opts()); + + Assert.DoesNotContain(r.Recommendations, x => x.Action == IndexCleanupAction.Disable); + Assert.DoesNotContain(r.Recommendations, x => x.Action == IndexCleanupAction.MergeIncludes); + } + + [Fact] + public void KeyDuplicate_PkBackedMember_IsNeverTheMergeKeeper() + { + // Same keys, different includes. The PK-backed member is Protected (sorts first in the old keeper + // pick) — but as keeper it would draw the Msg-1907 rebuild. The plain index must keep and absorb; + // the PK-backed member is protected and must not be disabled. + var pkBacked = Idx("PK_A", "[a]", indexId: 3, primaryKey: true, includes: "[p]", seeks: 100); + var plain = Idx("IX_A", "[a]", indexId: 2, includes: "[q]", seeks: 10); + + var r = IndexCleanupAnalyzer.Analyze(new[] { pkBacked, plain }, Opts()); + + var keeperRec = Rec(r, "IX_A"); + Assert.NotNull(keeperRec); + Assert.Equal(IndexCleanupAction.MergeIncludes, keeperRec!.Action); + Assert.Contains("[p]", keeperRec.Script); // the PK member's include was absorbed + Assert.DoesNotContain(r.Recommendations, x => + x.IndexName == "PK_A" + && (x.Action == IndexCleanupAction.Disable || x.Action == IndexCleanupAction.MergeIncludes)); + } + + [Fact] + public void UniqueConstraintReplacement_FilteredIndex_IsNeverPromoted() + { + // A filtered unique index enforces uniqueness only over rows matching its predicate — promoting it + // to replace the constraint opens a uniqueness hole outside the filter (ae32a4c). + var constraint = Idx("UQ_A", "[a]", indexId: 3, uniqueConstraint: true, seeks: 5); + var filtered = Idx("IX_F", "[a]", indexId: 2, filter: "([x]>(0))", seeks: 5); + + var r = IndexCleanupAnalyzer.Analyze(new[] { constraint, filtered }, Opts()); + + Assert.DoesNotContain(r.Recommendations, x => x.Action == IndexCleanupAction.MakeUnique); + Assert.DoesNotContain(r.Recommendations, x => x.ResultKind == IndexCleanupResultKind.DisableConstraint); + } + + [Fact] + public void UniqueConstraintReplacement_PartitionedIndex_IsNeverPromoted() + { + // Stage 1 does not capture the partitioning column, so the Msg-1908 alignment requirement (a + // unique index's partitioning column must be part of its key) cannot be verified monitor-side. + var constraint = Idx("UQ_A", "[a]", indexId: 3, uniqueConstraint: true, seeks: 5); + var partitioned = Idx("IX_P", "[a]", indexId: 2, partitionCount: 4, seeks: 5); + + var r = IndexCleanupAnalyzer.Analyze(new[] { constraint, partitioned }, Opts()); + + Assert.DoesNotContain(r.Recommendations, x => x.Action == IndexCleanupAction.MakeUnique); + Assert.DoesNotContain(r.Recommendations, x => x.ResultKind == IndexCleanupResultKind.DisableConstraint); + } + + [Fact] + public void UniqueConstraintReplacement_PkBackedIndex_IsNeverPromoted() + { + // Rule 7.5 step 2's old gate skipped only unique CONSTRAINTS — a PK-backed nonclustered index with + // the exact key drew a MAKE UNIQUE (a DROP_EXISTING rebuild of a PK: Msg 1907), pairing a failing + // script with a live DROP CONSTRAINT. + var constraint = Idx("UQ_A", "[a]", indexId: 3, uniqueConstraint: true, seeks: 5); + var pkBacked = Idx("PK_A", "[a]", indexId: 4, primaryKey: true, seeks: 5); + + var r = IndexCleanupAnalyzer.Analyze(new[] { constraint, pkBacked }, Opts()); + + Assert.DoesNotContain(r.Recommendations, x => x.Action == IndexCleanupAction.MakeUnique); + Assert.DoesNotContain(r.Recommendations, x => x.ResultKind == IndexCleanupResultKind.DisableConstraint); + } + + [Fact] + public void UniqueConstraintReplacement_ClusteredIndex_IsNeverPromoted() + { + // The step-2 comment always promised "every TRUE nonclustered index"; the code now enforces it — + // a clustered index with the exact key must not be re-marked MAKE UNIQUE. + var constraint = Idx("UQ_A", "[a]", indexId: 3, uniqueConstraint: true, seeks: 5); + var clustered = Idx("CX_A", "[a]", indexId: 1, type: "CLUSTERED", seeks: 5); + + var r = IndexCleanupAnalyzer.Analyze(new[] { constraint, clustered }, Opts()); + + Assert.DoesNotContain(r.Recommendations, x => x.Action == IndexCleanupAction.MakeUnique); + Assert.DoesNotContain(r.Recommendations, x => x.ResultKind == IndexCleanupResultKind.DisableConstraint); + } + + [Fact] + public void ObjectUsageFloor_SingleFloorAlone_IsEffective() + { + // ae32a4c: with only MinReads set, the old keep-condition's other half was `writes >= 0` — always + // true — so the filter did nothing. The exact-duplicate pair below is only analyzed if the object + // passes the floor; reads across the object = 2, floor = 100 → the object must be filtered out. + var d1 = Idx("IX_D1", "[a]", indexId: 2, includes: "[c]", seeks: 1); + var d2 = Idx("IX_D2", "[a]", indexId: 3, includes: "[c]", seeks: 1); + + var r = IndexCleanupAnalyzer.Analyze(new[] { d1, d2 }, Opts(minReads: 100)); + + Assert.Empty(r.Recommendations); + } + + [Fact] + public void ObjectUsageFloor_EitherSetFloorPasses() + { + // The OR is intended (the proc's own comment): an object counts as used if it clears EITHER set + // floor. Reads fail the floor, writes clear it → the object is analyzed and the duplicate surfaces. + var d1 = Idx("IX_D1", "[a]", indexId: 2, includes: "[c]", seeks: 1, updates: 200000); + var d2 = Idx("IX_D2", "[a]", indexId: 3, includes: "[c]", seeks: 1); + + var r = IndexCleanupAnalyzer.Analyze(new[] { d1, d2 }, Opts(minReads: 100, minWrites: 100)); + + Assert.Contains(r.Recommendations, x => x.Action == IndexCleanupAction.Disable); + } +} diff --git a/Darling/Darling.Tests/PgCopyWriterTests.cs b/Darling/Darling.Tests/PgCopyWriterTests.cs new file mode 100644 index 000000000..a3f7fe008 --- /dev/null +++ b/Darling/Darling.Tests/PgCopyWriterTests.cs @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Storage; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins Darling's COPY plumbing: the binary-COPY type map, the per-collector COPY command +/// (prefix columns first, id omitted for running_jobs), and the migration script list. The +/// end-to-end migrate + COPY + read-back test runs only when DARLING_TEST_PG points at a dev +/// Postgres (connection string), so CI stays green without one and the test lights up the moment +/// a live instance exists. +/// +/* Live-fixture tests share one Postgres store; the collection serializes them so + cross-test row churn (inserts/purges/deletes) cannot race another class's assertions. */ +[Collection("live-postgres")] +public sealed class PgCopyWriterTests +{ + [Fact] + public void DbTypeFor_MapsEveryColumnType() + { + var mapped = Enum.GetValues() + .Select(PgCollectorRowWriter.DbTypeFor) + .ToArray(); + + Assert.Equal(Enum.GetValues().Length, mapped.Length); + Assert.Equal(NpgsqlTypes.NpgsqlDbType.Bigint, PgCollectorRowWriter.DbTypeFor(CollectorColumnType.BigInt)); + Assert.Equal(NpgsqlTypes.NpgsqlDbType.Text, PgCollectorRowWriter.DbTypeFor(CollectorColumnType.Varchar)); + Assert.Equal(NpgsqlTypes.NpgsqlDbType.Timestamp, PgCollectorRowWriter.DbTypeFor(CollectorColumnType.Timestamp)); + Assert.Equal(NpgsqlTypes.NpgsqlDbType.Numeric, PgCollectorRowWriter.DbTypeFor(CollectorColumnType.Decimal)); + } + + [Fact] + public void CopyCommandFor_PrefixColumnsFirst_MirroringLiteNames() + { + Assert.StartsWith( + "COPY wait_stats (collection_id, collection_time, server_id, server_name, wait_type,", + PgCollectorRowWriter.CopyCommandFor(WaitStatsCollector.Instance), + StringComparison.Ordinal); + Assert.StartsWith( + "COPY deadlocks (deadlock_id, collection_time, server_id, server_name, deadlock_time,", + PgCollectorRowWriter.CopyCommandFor(DeadlocksCollector.Instance), + StringComparison.Ordinal); + Assert.StartsWith( + "COPY running_jobs (collection_time, server_id, server_name, job_name,", + PgCollectorRowWriter.CopyCommandFor(RunningJobsCollector.Instance), + StringComparison.Ordinal); + Assert.StartsWith( + "COPY server_config (config_id, capture_time, server_id, server_name,", + PgCollectorRowWriter.CopyCommandFor(ServerConfigCollector.Instance), + StringComparison.Ordinal); + Assert.EndsWith(") FROM STDIN (FORMAT BINARY)", + PgCollectorRowWriter.CopyCommandFor(WaitStatsCollector.Instance), + StringComparison.Ordinal); + } + + [Fact] + public void MigrationScripts_StartAtV1_StrictlyIncreasing_V1CreatesEveryTable() + { + Assert.Equal(1, PgMigrations.Scripts[0].Version); + for (int i = 1; i < PgMigrations.Scripts.Count; i++) + { + Assert.True(PgMigrations.Scripts[i].Version > PgMigrations.Scripts[i - 1].Version); + } + + var v1 = PgMigrations.Scripts[0].Sql; + foreach (var schema in CollectorCatalog.All) + { + Assert.Contains($"CREATE TABLE IF NOT EXISTS {schema.TargetTable} (", v1, StringComparison.Ordinal); + } + } + + [Fact] + public async Task EndToEnd_MigrateCopyReadBack_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live COPY test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + + /* Migrations are idempotent — a second run applies nothing. */ + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + Assert.Equal(0, await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken)); + + /* COPY one deadlocks row through the definition's own WritePayload. */ + var collectionTime = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified); + var row = new DeadlocksCollector.Row + { + DeadlockTime = collectionTime, + VictimProcessId = "process-e2e", + VictimSqlText = "UPDATE t SET x = 1;", + GraphXml = "", + }; + + var writer = new PgCollectorRowWriter(); + using (var importer = await connection.BeginBinaryImportAsync( + PgCollectorRowWriter.CopyCommandFor(DeadlocksCollector.Instance), TestContext.Current.CancellationToken)) + { + writer.Importer = importer; + await importer.StartRowAsync(TestContext.Current.CancellationToken); + writer.Value(1L) /* deadlock_id */ + .Value(collectionTime) /* collection_time */ + .Value(42) /* server_id */ + .Value("e2e-server"); /* server_name */ + DeadlocksCollector.Instance.WritePayload(row, writer, null!); + await importer.CompleteAsync(TestContext.Current.CancellationToken); + } + + using (var read = new NpgsqlCommand( + "SELECT victim_sql_text FROM deadlocks WHERE server_name = 'e2e-server' ORDER BY collection_time DESC LIMIT 1", connection)) + { + Assert.Equal("UPDATE t SET x = 1;", await read.ExecuteScalarAsync(TestContext.Current.CancellationToken)); + } + + using (var cleanup = new NpgsqlCommand("DELETE FROM deadlocks WHERE server_name = 'e2e-server'", connection)) + { + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + } +} diff --git a/Darling/Darling.Tests/PgFactCollectorTests.cs b/Darling/Darling.Tests/PgFactCollectorTests.cs new file mode 100644 index 000000000..abb66392c --- /dev/null +++ b/Darling/Darling.Tests/PgFactCollectorTests.cs @@ -0,0 +1,335 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Analysis; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Analysis; +using PerformanceMonitor.Darling.Storage; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the Phase-5 analysis slice AN2a fact-collector port. Ungated: PgFactCollector exposes +/// EXACTLY the collect-method surface of Lite's DuckDbFactCollector (the literal name list below +/// — a method ported without updating the list, or renamed, fails loudly; when Lite grows a new +/// collect method the port PR must extend both); every query passes the PG-dialect hygiene pins +/// (no QUALIFY, no bare NOW()/CURRENT_TIMESTAMP, no read_parquet, no DuckDB UNION ALL BY NAME, +/// $N positional parameters only, no N'' literals); every FROM/JOIN target resolves to a V4 +/// passthrough view or a shared-catalog collector table; and the single any_value() use (PG 16+, +/// below the supported floor: BYO PG 16+, bundled PG 18) stays confined to the plan-regression query. Gated on DARLING_TEST_PG: +/// migrate, plant rows for two representative collectors (wait-stats and CPU facts), run the full +/// CollectFactsAsync, assert the expected fact keys/values and Lite's emission order, and assert +/// an EMPTY store yields zero facts with NO exception (the swallow posture). +/// +/* Live-fixture tests share one Postgres store; the collection serializes them so + cross-test row churn (inserts/purges/deletes) cannot race another class's assertions. */ +[Collection("live-postgres")] +public sealed class PgFactCollectorTests +{ + /// Distinctive fake ids — a real server_id is a storage-name hash, never these. + private const int TestServerId = -626262; + private const int EmptyServerId = -626263; + private const string TestServerName = "fact-collector-e2e"; + + /// + /// Lite's DuckDbFactCollector collect-method surface (ordinal-sorted), enumerated literally: + /// the port must carry every one of these — same names — and nothing extra. A future Lite + /// addition must be ported AND added here, so drift fails this test rather than silently + /// narrowing Darling's analysis. + /// + private static readonly string[] LiteCollectMethodSurface = + { + "CollectActiveQueryFactsAsync", + "CollectBadActorFactsAsync", + "CollectBlockingChainFactsAsync", + "CollectBlockingFactsAsync", + "CollectCpuUtilizationFactsAsync", + "CollectDatabaseConfigFactsAsync", + "CollectDatabaseSizeFactAsync", + "CollectDeadlockFactsAsync", + "CollectDiskSpaceFactsAsync", + "CollectFileAutogrowthFactsAsync", + "CollectIoLatencyFactsAsync", + "CollectMemoryClerkFactsAsync", + "CollectMemoryFactsAsync", + "CollectMemoryGrantFactsAsync", + "CollectMemoryPressureEventFactsAsync", + "CollectParameterSensitivityFactsAsync", + "CollectPerfmonFactsAsync", + "CollectPlanAdvisoryFactsAsync", + "CollectPlanCacheFactsAsync", + "CollectPlanRegressionFactsAsync", + "CollectProcedureStatsFactsAsync", + "CollectQueryStatsFactsAsync", + "CollectRunnableTaskFactsAsync", + "CollectRunningJobFactsAsync", + "CollectServerConfigFactsAsync", + "CollectServerMetadataFactsAsync", + "CollectServerPropertiesFactsAsync", + "CollectSessionFactsAsync", + "CollectTempDbFactsAsync", + "CollectTraceFlagFactsAsync", + "CollectWaitStatsFactsAsync" + }; + + /* ---------------- ungated: surface + dialect pins ---------------- */ + + [Fact] + public void ImplementsTheSharedSeam_WithLitesCollectMethodSurface() + { + Assert.True(typeof(IFactCollector).IsAssignableFrom(typeof(PgFactCollector))); + + var ported = typeof(PgFactCollector) + .GetMethods(BindingFlags.Instance | BindingFlags.NonPublic) + .Where(m => m.Name.StartsWith("Collect", StringComparison.Ordinal) + && m.Name.EndsWith("Async", StringComparison.Ordinal)) + .Select(m => m.Name) + .OrderBy(n => n, StringComparer.Ordinal) + .ToArray(); + + Assert.Equal(LiteCollectMethodSurface, ported); + } + + [Fact] + public void AllSql_CoversEveryQuery_OnePerCollectMethodPlusTheDmvFallback() + { + /* 31 collect methods, one query each, plus the DMV-snapshot fallback the blocking-chain + method appends through PgBlockingPairRowQuery. */ + Assert.Equal(LiteCollectMethodSurface.Length + 1, PgFactCollector.AllSql.Count); + Assert.Contains(PgBlockingPairRowQuery.DmvSnapshotSql, PgFactCollector.AllSql); + } + + [Fact] + public void AllSql_PgDialect_NoDuckDbOnlyConstructs_NoBareNow_PositionalParams() + { + foreach (var sql in PgFactCollector.AllSql) + { + var upper = sql.ToUpperInvariant(); + + /* QUALIFY is DuckDB-only (Lite keeps it in BaselineProvider, a different slice — + it must never appear here). */ + Assert.DoesNotContain("QUALIFY", upper, StringComparison.Ordinal); + + /* Bare now()/CURRENT_TIMESTAMP is timestamptz — it would compare the naive-UTC + columns in the PG server's time zone. Every "now"/window bound is a bound + Kind-Unspecified parameter. */ + Assert.DoesNotContain("NOW(", upper, StringComparison.Ordinal); + Assert.DoesNotContain("CURRENT_TIMESTAMP", upper, StringComparison.Ordinal); + + /* Lite's v_* views union the parquet archive; Darling has no parquet tier. */ + Assert.DoesNotContain("READ_PARQUET", upper, StringComparison.Ordinal); + Assert.DoesNotContain("UNION ALL BY NAME", upper, StringComparison.Ordinal); + + /* Postgres has no N'' literals and no @named parameters — $N positional only. */ + Assert.DoesNotContain("N'", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + Assert.Contains("$1", sql, StringComparison.Ordinal); + } + } + + [Fact] + public void AllSql_AnyValue_OnlyInThePlanRegressionQuery() + { + /* any_value() is standard SQL:2023, in Postgres since 16 (product minimum PG is 17). + It is deliberate in the plan-regression aggregation and nowhere else. */ + Assert.Contains("any_value(query_plan_hash)", PgFactCollector.PlanRegressionSql, StringComparison.Ordinal); + foreach (var sql in PgFactCollector.AllSql) + { + if (sql.Contains("any_value", StringComparison.OrdinalIgnoreCase)) + { + Assert.Equal(PgFactCollector.PlanRegressionSql, sql); + } + } + } + + [Fact] + public void AllSql_EveryFromJoinTarget_ResolvesToAV4ViewOrACollectorTable() + { + /* The V4 passthrough views, parsed from the migration itself so this can't drift. */ + var v4 = PgMigrations.Scripts.Single(m => m.Version == 4); + var views = Regex.Matches(v4.Sql, @"CREATE OR REPLACE VIEW (v_\w+)") + .Select(m => m.Groups[1].Value) + .ToHashSet(StringComparer.Ordinal); + Assert.Equal(17, views.Count); + + var tables = CollectorCatalog.All.Select(s => s.TargetTable).ToHashSet(StringComparer.Ordinal); + + foreach (var sql in PgFactCollector.AllSql) + { + /* CTE names defined by this query are legal FROM targets too (WITH x AS ( / , y AS (). */ + var ctes = Regex.Matches(sql, @"(?:WITH|,)\s*(\w+)\s+AS\s*\(", RegexOptions.Singleline) + .Select(m => m.Groups[1].Value) + .ToHashSet(StringComparer.Ordinal); + + foreach (Match m in Regex.Matches(sql, @"\b(?:FROM|JOIN)\s+(\w+)", RegexOptions.IgnoreCase)) + { + var target = m.Groups[1].Value; + Assert.True( + views.Contains(target) || tables.Contains(target) || ctes.Contains(target), + $"FROM/JOIN target '{target}' resolves to no V4 view, collector table, or CTE in:\n{sql}"); + } + } + } + + [Fact] + public void BlockingChainSql_KeepsThePairRowShape_AndThePhantomSpidFilter() + { + /* The SPID-0 phantom-root filter and the pair cap are behavior, not style — see + PgBlockingPairRowQuery (Lite's BlockingPairRowQuery carried verbatim). */ + foreach (var sql in new[] { PgFactCollector.BlockingChainSql, PgBlockingPairRowQuery.DmvSnapshotSql }) + { + Assert.Contains("blocking_spid IS NOT NULL", sql, StringComparison.Ordinal); + Assert.Contains("blocking_spid <> 0", sql, StringComparison.Ordinal); + Assert.Contains("LIMIT 5000", sql, StringComparison.Ordinal); + Assert.Contains("monitor_loop", sql, StringComparison.Ordinal); + Assert.Contains("contentious_object", sql, StringComparison.Ordinal); + } + } + + /* ---------------- gated: live end-to-end collection ---------------- */ + + [Fact] + public async Task EndToEnd_CollectFacts_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live fact-collector test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + + /* Migrations are idempotent — an older store comes up to current, a current store no-ops. */ + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + + /* Clear leftovers from an earlier aborted run so the assertions below are deterministic. */ + await DeleteTestRowsAsync(connection); + + await using var postgres = NpgsqlDataSource.Create(connectionString!); + var collector = new PgFactCollector(postgres); + + try + { + /* Whole-second window bounds so the PG microsecond timestamp comparisons are exact. + Host-UTC window semantics — AnalysisContext times are used as-is. */ + var windowEnd = TruncateToSeconds(DateTime.UtcNow); + var windowStart = windowEnd.AddHours(-1); + var sampleTime = windowStart.AddMinutes(30); + + /* Plant the two representative collectors' inputs: + 1. wait_stats — a wait with delta_wait_time_ms > 0 (the WaitStatsSql emission + gate) that is neither LCK_M_* nor CX* so the shared grouping helpers no-op: + 900,000 ms over a 3,600,000 ms window = 0.25 fraction, 3,000 tasks = 300 ms avg. */ + using (var plant = new NpgsqlCommand(@" +INSERT INTO wait_stats + (collection_id, collection_time, server_id, server_name, + wait_type, delta_waiting_tasks, delta_wait_time_ms, delta_signal_wait_time_ms) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", connection)) + { + plant.Parameters.AddWithValue(CollectionIdGenerator.Next()); + plant.Parameters.AddWithValue(sampleTime); + plant.Parameters.AddWithValue(TestServerId); + plant.Parameters.AddWithValue(TestServerName); + plant.Parameters.AddWithValue("SOS_SCHEDULER_YIELD"); + plant.Parameters.AddWithValue(3000L); + plant.Parameters.AddWithValue(900000L); + plant.Parameters.AddWithValue(100000L); + await plant.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + /* 2. cpu_utilization_stats — two samples (80%, 90%): avg 85 → CPU_SQL_PERCENT + Value 85; max 90 stays under the CPU_SPIKE gate (max >= 80 AND (avg < 20 OR + max/avg >= 3) — 90/85 < 3), so exactly one CPU fact. */ + foreach (var cpuPercent in new[] { 80, 90 }) + { + using var plant = new NpgsqlCommand(@" +INSERT INTO cpu_utilization_stats + (collection_id, collection_time, server_id, server_name, + sqlserver_cpu_utilization, other_process_cpu_utilization) +VALUES ($1, $2, $3, $4, $5, $6)", connection); + plant.Parameters.AddWithValue(CollectionIdGenerator.Next()); + plant.Parameters.AddWithValue(sampleTime); + plant.Parameters.AddWithValue(TestServerId); + plant.Parameters.AddWithValue(TestServerName); + plant.Parameters.AddWithValue(cpuPercent); + plant.Parameters.AddWithValue(5); + await plant.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + var context = new AnalysisContext + { + ServerId = TestServerId, + ServerName = TestServerName, + TimeRangeStart = windowStart, + TimeRangeEnd = windowEnd, + ServerUtcOffset = TimeSpan.Zero + }; + + var facts = await collector.CollectFactsAsync(context); + + /* The wait fact: Key = the wait type, Value = fraction of the examined period. */ + var wait = Assert.Single(facts, f => f.Source == "waits"); + Assert.Equal("SOS_SCHEDULER_YIELD", wait.Key); + Assert.Equal(0.25, wait.Value, precision: 10); + Assert.Equal(900000, wait.Metadata["wait_time_ms"]); + Assert.Equal(3000, wait.Metadata["waiting_tasks_count"]); + Assert.Equal(100000, wait.Metadata["signal_wait_time_ms"]); + Assert.Equal(800000, wait.Metadata["resource_wait_time_ms"]); + Assert.Equal(300.0, wait.Metadata["avg_ms_per_wait"], precision: 10); + + /* The CPU fact: Value = average SQL CPU %, and no spurious CPU_SPIKE. */ + var cpu = Assert.Single(facts, f => f.Source == "cpu"); + Assert.Equal("CPU_SQL_PERCENT", cpu.Key); + Assert.Equal(85.0, cpu.Value, precision: 10); + Assert.Equal(90.0, cpu.Metadata["max_sql_cpu"], precision: 10); + Assert.Equal(5.0, cpu.Metadata["avg_other_cpu"], precision: 10); + Assert.Equal(2, (int)cpu.Metadata["sample_count"]); + Assert.DoesNotContain(facts, f => f.Key == "CPU_SPIKE"); + + /* Lite's emission order: wait facts before CPU facts, and nothing else invented + from the otherwise-empty store. */ + Assert.True(facts.IndexOf(wait) < facts.IndexOf(cpu), + "wait facts must be emitted before cpu facts (Lite's collection order)"); + Assert.Equal(2, facts.Count); + + /* An EMPTY (but migrated) store: zero facts, NO exception — the collectors' + swallow/degrade posture. */ + var emptyContext = new AnalysisContext + { + ServerId = EmptyServerId, + ServerName = "fact-collector-empty", + TimeRangeStart = windowStart, + TimeRangeEnd = windowEnd, + ServerUtcOffset = TimeSpan.Zero + }; + Assert.Empty(await collector.CollectFactsAsync(emptyContext)); + } + finally + { + await DeleteTestRowsAsync(connection); + } + } + + private static DateTime TruncateToSeconds(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified); + + private static async Task DeleteTestRowsAsync(NpgsqlConnection connection) + { + using var cleanup = new NpgsqlCommand( + $"DELETE FROM wait_stats WHERE server_id IN ({TestServerId}, {EmptyServerId}); " + + $"DELETE FROM cpu_utilization_stats WHERE server_id IN ({TestServerId}, {EmptyServerId});", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } +} diff --git a/Darling/Darling.Tests/PgSchemaGeneratorTests.cs b/Darling/Darling.Tests/PgSchemaGeneratorTests.cs new file mode 100644 index 000000000..06cc0d724 --- /dev/null +++ b/Darling/Darling.Tests/PgSchemaGeneratorTests.cs @@ -0,0 +1,505 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Storage; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins Darling's generated Postgres schema against the collector definitions: the catalog covers +/// every collector, the type map mirrors Lite's DuckDB types (per-column numeric(p,s) included), +/// the prefix names vary exactly where Lite's schema varies (deadlock_id / blocked_report_id / +/// system_health_event_id / config_id+capture_time / running_jobs' no-id), and the index shapes +/// mirror Lite's columns. +/// +public sealed class PgSchemaGeneratorTests +{ + [Fact] + public void Catalog_CoversAllCollectors_WithUniqueTablesAndNames() + { + /* 35 through agent_status + long_query_completions (#1496 long-query trace) = 36. */ + Assert.Equal(36, CollectorCatalog.All.Count); + Assert.Equal(36, CollectorCatalog.All.Select(s => s.TargetTable).Distinct().Count()); + Assert.Equal(36, CollectorCatalog.All.Select(s => s.Name).Distinct().Count()); + } + + [Fact] + public void Catalog_PrefixNames_MirrorLiteSchemaExceptions() + { + var byTable = CollectorCatalog.All.ToDictionary(s => s.TargetTable); + + Assert.Equal("deadlock_id", byTable["deadlocks"].PrefixIdColumnName); + Assert.Equal("blocked_report_id", byTable["blocked_process_reports"].PrefixIdColumnName); + Assert.Equal("system_health_event_id", byTable["system_health_events"].PrefixIdColumnName); + foreach (var config in new[] { "server_config", "database_config", "database_scoped_config", "trace_flags" }) + { + Assert.Equal("config_id", byTable[config].PrefixIdColumnName); + Assert.Equal("capture_time", byTable[config].PrefixTimeColumnName); + } + Assert.False(byTable["running_jobs"].IncludesCollectionId); + Assert.Equal("collection_id", byTable["wait_stats"].PrefixIdColumnName); + Assert.Equal("collection_time", byTable["wait_stats"].PrefixTimeColumnName); + } + + [Fact] + public void Catalog_EveryDecimalColumn_DeclaresPrecision() + { + var undeclared = CollectorCatalog.All + .SelectMany(s => s.PayloadColumns.Select(c => (s.TargetTable, Column: c))) + .Where(x => x.Column.Type == CollectorColumnType.Decimal && x.Column.Precision <= 0) + .Select(x => $"{x.TargetTable}.{x.Column.Name}") + .ToArray(); + + Assert.Empty(undeclared); + } + + [Fact] + public void TypeFor_MapsEveryColumnType() + { + Assert.Equal("bigint", PgSchemaGenerator.TypeFor(new CollectorColumn("c", CollectorColumnType.BigInt))); + Assert.Equal("integer", PgSchemaGenerator.TypeFor(new CollectorColumn("c", CollectorColumnType.Integer))); + Assert.Equal("smallint", PgSchemaGenerator.TypeFor(new CollectorColumn("c", CollectorColumnType.SmallInt))); + Assert.Equal("text", PgSchemaGenerator.TypeFor(new CollectorColumn("c", CollectorColumnType.Varchar))); + Assert.Equal("timestamp", PgSchemaGenerator.TypeFor(new CollectorColumn("c", CollectorColumnType.Timestamp))); + Assert.Equal("double precision", PgSchemaGenerator.TypeFor(new CollectorColumn("c", CollectorColumnType.Double))); + Assert.Equal("numeric(18,2)", PgSchemaGenerator.TypeFor(new CollectorColumn("c", CollectorColumnType.Decimal, 18, 2))); + Assert.Equal("numeric(5,2)", PgSchemaGenerator.TypeFor(new CollectorColumn("c", CollectorColumnType.Decimal, 5, 2))); + Assert.Equal("boolean", PgSchemaGenerator.TypeFor(new CollectorColumn("c", CollectorColumnType.Boolean))); + Assert.Throws( + () => PgSchemaGenerator.TypeFor(new CollectorColumn("c", CollectorColumnType.Decimal))); + } + + [Fact] + public void CreateTable_Deadlocks_FullDdlPinned() + { + var ddl = PgSchemaGenerator.CreateTable(DeadlocksCollector.Instance); + + Assert.Equal( + "CREATE TABLE IF NOT EXISTS deadlocks (\n" + + " deadlock_id bigint NOT NULL,\n" + + " collection_time timestamp NOT NULL,\n" + + " server_id integer NOT NULL,\n" + + " server_name text NOT NULL,\n" + + " deadlock_time timestamp,\n" + + " victim_process_id text,\n" + + " victim_sql_text text,\n" + + " deadlock_graph_xml text,\n" + + " victim_query_plan_xml text,\n" + + " database_name text\n" + + ");", + ddl); + } + + [Fact] + public void CreateTable_RunningJobs_HasNoIdColumn() + { + var ddl = PgSchemaGenerator.CreateTable(RunningJobsCollector.Instance); + + Assert.StartsWith("CREATE TABLE IF NOT EXISTS running_jobs (\n collection_time timestamp NOT NULL,", ddl, StringComparison.Ordinal); + Assert.DoesNotContain("collection_id", ddl, StringComparison.Ordinal); + Assert.Contains("percent_of_average numeric(10,1)", ddl, StringComparison.Ordinal); + } + + [Fact] + public void CreateTable_ServerConfig_UsesConfigIdAndCaptureTime() + { + var ddl = PgSchemaGenerator.CreateTable(ServerConfigCollector.Instance); + + Assert.StartsWith( + "CREATE TABLE IF NOT EXISTS server_config (\n" + + " config_id bigint NOT NULL,\n" + + " capture_time timestamp NOT NULL,", + ddl, StringComparison.Ordinal); + } + + [Fact] + public void CreateTable_QuerySnapshots_MirrorsPerColumnNumericPrecision() + { + var ddl = PgSchemaGenerator.CreateTable(QuerySnapshotsCollector.Instance); + + Assert.Contains("granted_query_memory_gb numeric(18,2)", ddl, StringComparison.Ordinal); + Assert.Contains("percent_complete numeric(5,2)", ddl, StringComparison.Ordinal); + Assert.Contains("is_cdc_capture boolean", ddl, StringComparison.Ordinal); + Assert.Contains("requested_memory_mb double precision", ddl, StringComparison.Ordinal); + } + + [Fact] + public void CreateTable_LatchStats_MirrorsDeltaColumns() + { + var ddl = PgSchemaGenerator.CreateTable(LatchStatsCollector.Instance); + + Assert.Equal( + "CREATE TABLE IF NOT EXISTS latch_stats (\n" + + " collection_id bigint NOT NULL,\n" + + " collection_time timestamp NOT NULL,\n" + + " server_id integer NOT NULL,\n" + + " server_name text NOT NULL,\n" + + " latch_class text,\n" + + " waiting_requests_count bigint,\n" + + " wait_time_ms bigint,\n" + + " max_wait_time_ms bigint,\n" + + " delta_waiting_requests_count bigint,\n" + + " delta_wait_time_ms bigint,\n" + + " delta_max_wait_time_ms bigint\n" + + ");", + ddl); + } + + [Fact] + public void CreateTable_SpinlockStats_MapsSpinsPerCollisionToDoublePrecision() + { + var ddl = PgSchemaGenerator.CreateTable(SpinlockStatsCollector.Instance); + + Assert.Equal( + "CREATE TABLE IF NOT EXISTS spinlock_stats (\n" + + " collection_id bigint NOT NULL,\n" + + " collection_time timestamp NOT NULL,\n" + + " server_id integer NOT NULL,\n" + + " server_name text NOT NULL,\n" + + " spinlock_name text,\n" + + " collisions bigint,\n" + + " spins bigint,\n" + + " spins_per_collision double precision,\n" + + " sleep_time bigint,\n" + + " backoffs bigint,\n" + + " delta_collisions bigint,\n" + + " delta_spins bigint,\n" + + " delta_sleep_time bigint,\n" + + " delta_backoffs bigint\n" + + ");", + ddl); + } + + [Fact] + public void CreateTable_CpuSchedulerStats_MapsWarningsToBoolean_AndAveragesToNumeric() + { + var ddl = PgSchemaGenerator.CreateTable(CpuSchedulerStatsCollector.Instance); + + Assert.Equal( + "CREATE TABLE IF NOT EXISTS cpu_scheduler_stats (\n" + + " collection_id bigint NOT NULL,\n" + + " collection_time timestamp NOT NULL,\n" + + " server_id integer NOT NULL,\n" + + " server_name text NOT NULL,\n" + + " max_workers_count integer,\n" + + " scheduler_count integer,\n" + + " cpu_count integer,\n" + + " total_runnable_tasks_count integer,\n" + + " total_work_queue_count bigint,\n" + + " total_current_workers_count integer,\n" + + " avg_runnable_tasks_count numeric(38,2),\n" + + " total_active_request_count integer,\n" + + " total_queued_request_count integer,\n" + + " total_blocked_task_count integer,\n" + + " total_active_parallel_thread_count bigint,\n" + + " runnable_request_count integer,\n" + + " total_request_count integer,\n" + + " runnable_percent numeric(38,2),\n" + + " worker_thread_exhaustion_warning boolean,\n" + + " runnable_tasks_warning boolean,\n" + + " blocked_tasks_warning boolean,\n" + + " queued_requests_warning boolean,\n" + + " total_physical_memory_kb bigint,\n" + + " available_physical_memory_kb bigint,\n" + + " system_memory_state_desc text,\n" + + " physical_memory_pressure_warning boolean,\n" + + " total_node_count integer,\n" + + " nodes_online_count integer,\n" + + " offline_cpu_count integer,\n" + + " offline_cpu_warning boolean\n" + + ");", + ddl); + } + + [Fact] + public void CreateTable_PlanCacheStats_MapsAvgUseCountToNumeric_AndOldestPlanToTimestamp() + { + var ddl = PgSchemaGenerator.CreateTable(PlanCacheStatsCollector.Instance); + + Assert.Equal( + "CREATE TABLE IF NOT EXISTS plan_cache_stats (\n" + + " collection_id bigint NOT NULL,\n" + + " collection_time timestamp NOT NULL,\n" + + " server_id integer NOT NULL,\n" + + " server_name text NOT NULL,\n" + + " cacheobjtype text,\n" + + " objtype text,\n" + + " total_plans integer,\n" + + " total_size_mb integer,\n" + + " single_use_plans integer,\n" + + " single_use_size_mb integer,\n" + + " multi_use_plans integer,\n" + + " multi_use_size_mb integer,\n" + + " avg_use_count numeric(38,2),\n" + + " avg_size_kb integer,\n" + + " oldest_plan_create_time timestamp\n" + + ");", + ddl); + } + + [Fact] + public void CreateTable_SessionSummaryStats_MapsCountsToInteger_AndTopColumns() + { + var ddl = PgSchemaGenerator.CreateTable(SessionSummaryStatsCollector.Instance); + + Assert.Equal( + "CREATE TABLE IF NOT EXISTS session_summary_stats (\n" + + " collection_id bigint NOT NULL,\n" + + " collection_time timestamp NOT NULL,\n" + + " server_id integer NOT NULL,\n" + + " server_name text NOT NULL,\n" + + " total_sessions integer,\n" + + " running_sessions integer,\n" + + " sleeping_sessions integer,\n" + + " background_sessions integer,\n" + + " dormant_sessions integer,\n" + + " idle_sessions_over_30min integer,\n" + + " sessions_waiting_for_memory integer,\n" + + " databases_with_connections integer,\n" + + " top_application_name text,\n" + + " top_application_connections integer,\n" + + " top_host_name text,\n" + + " top_host_connections integer\n" + + ");", + ddl); + } + + [Fact] + public void CreateTable_SystemHealthEvents_UsesSystemHealthEventIdPrefix_AndTextXml() + { + var ddl = PgSchemaGenerator.CreateTable(SystemHealthEventsCollector.Instance); + + /* Stage 1 raw-capture shape: the system_health_event_id bigint prefix (mirroring Lite's + DuckDB PRIMARY KEY column), then event_time / event_type / event_xml — the XML lands as + text (no shredding here; Stage 2 owns the parse). */ + Assert.Equal( + "CREATE TABLE IF NOT EXISTS system_health_events (\n" + + " system_health_event_id bigint NOT NULL,\n" + + " collection_time timestamp NOT NULL,\n" + + " server_id integer NOT NULL,\n" + + " server_name text NOT NULL,\n" + + " event_time timestamp,\n" + + " event_type text,\n" + + " event_xml text\n" + + ");", + ddl); + } + + [Fact] + public void CreateTable_DefaultTraceEvents_UsesDefaultTraceEventIdPrefix_AndMirrorsPayloadOrder() + { + var ddl = PgSchemaGenerator.CreateTable(DefaultTraceEventsCollector.Instance); + + /* The default_trace_event_id bigint prefix (mirroring Lite's DuckDB PRIMARY KEY column), then the + curated Default Trace payload in emission order. This generated V1 shape MUST equal the hardcoded + V21 upgrade migration body (minus the collect. qualification) so a fresh store (V1) and an upgraded + store (V21) land an identical table for the positional binary COPY. */ + Assert.Equal( + "CREATE TABLE IF NOT EXISTS default_trace_events (\n" + + " default_trace_event_id bigint NOT NULL,\n" + + " collection_time timestamp NOT NULL,\n" + + " server_id integer NOT NULL,\n" + + " server_name text NOT NULL,\n" + + " event_time timestamp,\n" + + " event_name text,\n" + + " event_class integer,\n" + + " spid integer,\n" + + " database_name text,\n" + + " database_id integer,\n" + + " login_name text,\n" + + " host_name text,\n" + + " application_name text,\n" + + " object_name text,\n" + + " filename text,\n" + + " integer_data bigint,\n" + + " integer_data_2 bigint,\n" + + " text_data text,\n" + + " session_login_name text,\n" + + " error_number integer,\n" + + " severity integer,\n" + + " state integer,\n" + + " event_sequence bigint,\n" + + " duration_us bigint,\n" + + " end_time timestamp\n" + + ");", + ddl); + } + + [Fact] + public void CreateTable_JobHistory_UsesJobHistoryIdPrefix_AndMirrorsPayloadOrder() + { + var ddl = PgSchemaGenerator.CreateTable(JobHistoryCollector.Instance); + + /* The job_history_id bigint prefix (mirroring Lite's DuckDB PRIMARY KEY column), then the retained + sysjobhistory payload in emission order (instance_id dedup key first). This generated V1 shape MUST + equal the hardcoded V24 upgrade migration body (minus the collect. qualification) so a fresh store + (V1) and an upgraded store (V24) land an identical table for the positional binary COPY (#1433). */ + Assert.Equal( + "CREATE TABLE IF NOT EXISTS job_history (\n" + + " job_history_id bigint NOT NULL,\n" + + " collection_time timestamp NOT NULL,\n" + + " server_id integer NOT NULL,\n" + + " server_name text NOT NULL,\n" + + " instance_id bigint,\n" + + " job_id text,\n" + + " job_name text,\n" + + " job_enabled boolean,\n" + + " category_name text,\n" + + " step_id integer,\n" + + " step_name text,\n" + + " run_status integer,\n" + + " run_status_desc text,\n" + + " run_datetime timestamp,\n" + + " run_duration_seconds bigint,\n" + + " retries_attempted integer,\n" + + " message text\n" + + ");", + ddl); + } + + [Fact] + public void CreateTable_AgentStatus_MirrorsPayloadOrder() + { + var ddl = PgSchemaGenerator.CreateTable(AgentStatusCollector.Instance); + + /* Agent status snapshot (collection_id prefix), matching the V25 upgrade migration body (#1433 Phase 2). */ + Assert.Equal( + "CREATE TABLE IF NOT EXISTS agent_status (\n" + + " collection_id bigint NOT NULL,\n" + + " collection_time timestamp NOT NULL,\n" + + " server_id integer NOT NULL,\n" + + " server_name text NOT NULL,\n" + + " agent_running boolean,\n" + + " agent_status_desc text,\n" + + " agent_startup_desc text,\n" + + " next_scheduled_run timestamp\n" + + ");", + ddl); + } + + [Fact] + public void Migrations_JobHistoryAndAgentStatus_MatchGeneratedFreshShape() + { + /* The additive V24/V25 upgrade bodies MUST be the collect.-qualified twin of the fresh (V1 + GenerateFullSchema) table, or a fresh store and an upgraded store would drift and the positional + binary COPY would mis-bind. Pin each migration's CREATE TABLE against the generator's output. */ + /* Normalize line endings: CreateTable emits '\n'; the verbatim-string V##Sql consts carry the + source file's CRLF, so compare on a common newline. */ + static string Lf(string s) => s.Replace("\r\n", "\n", StringComparison.Ordinal); + + static string CollectQualified(ICollectorSchemaInfo schema) + { + var fresh = Lf(PgSchemaGenerator.CreateTable(schema)); + return fresh.Replace( + $"CREATE TABLE IF NOT EXISTS {schema.TargetTable} (", + $"CREATE TABLE IF NOT EXISTS collect.{schema.TargetTable} (", + StringComparison.Ordinal); + } + + var v24 = Lf(PgMigrations.Scripts.Single(m => m.Version == 24).Sql); + var v25 = Lf(PgMigrations.Scripts.Single(m => m.Version == 25).Sql); + + Assert.Contains(CollectQualified(JobHistoryCollector.Instance), v24, StringComparison.Ordinal); + Assert.Contains("CREATE INDEX IF NOT EXISTS idx_job_history_time ON collect.job_history(server_id, collection_time);", v24, StringComparison.Ordinal); + Assert.Contains(CollectQualified(AgentStatusCollector.Instance), v25, StringComparison.Ordinal); + Assert.Contains("CREATE INDEX IF NOT EXISTS idx_agent_status_time ON collect.agent_status(server_id, collection_time);", v25, StringComparison.Ordinal); + } + + [Fact] + public void CreateIndex_MirrorsLiteIndexColumns() + { + Assert.Equal( + "CREATE INDEX IF NOT EXISTS idx_wait_stats_time ON wait_stats(server_id, collection_time);", + PgSchemaGenerator.CreateIndex(WaitStatsCollector.Instance)); + Assert.Equal( + "CREATE INDEX IF NOT EXISTS idx_trace_flags_time ON trace_flags(server_id, capture_time);", + PgSchemaGenerator.CreateIndex(TraceFlagsCollector.Instance)); + Assert.Equal( + "CREATE INDEX IF NOT EXISTS idx_memory_pressure_events_time ON memory_pressure_events(server_id, sample_time);", + PgSchemaGenerator.CreateIndex(MemoryPressureEventsCollector.Instance)); + Assert.Equal( + "CREATE INDEX IF NOT EXISTS idx_index_object_stats_object ON index_object_stats(server_id, database_name, object_id, index_id, collection_time);", + PgSchemaGenerator.CreateIndex(IndexObjectStatsCollector.Instance)); + Assert.Null(PgSchemaGenerator.CreateIndex(ServerConfigCollector.Instance)); + Assert.Null(PgSchemaGenerator.CreateIndex(DatabaseConfigCollector.Instance)); + } + + [Fact] + public void GenerateFullSchema_EmitsEveryTableAndIndex() + { + var script = PgSchemaGenerator.GenerateFullSchema(); + + var tableCount = CollectorCatalog.All.Count(s => script.Contains($"CREATE TABLE IF NOT EXISTS {s.TargetTable} (", StringComparison.Ordinal)); + Assert.Equal(36, tableCount); + + /* 36 tables minus the two index-less config tables (server_config, database_config) = 34 indexes. */ + var indexCount = script.Split("CREATE INDEX IF NOT EXISTS").Length - 1; + Assert.Equal(34, indexCount); + + /* The precision guard can never regress silently. */ + Assert.DoesNotContain("numeric(0,0)", script, StringComparison.Ordinal); + } + + [Fact] + public void GenerateV8Move_CreatesSchemas_AndMovesEveryCatalogTableToCollect() + { + var v8 = PgSchemaGenerator.GenerateV8Move(); + + Assert.Contains("CREATE SCHEMA IF NOT EXISTS collect AUTHORIZATION darling;", v8, StringComparison.Ordinal); + Assert.Contains("CREATE SCHEMA IF NOT EXISTS config AUTHORIZATION darling;", v8, StringComparison.Ordinal); + + /* Every collector table moves to collect — catalog-driven, so a new collector moves for free. */ + foreach (var schema in CollectorCatalog.All) + { + Assert.Contains($"ALTER TABLE IF EXISTS public.{schema.TargetTable} SET SCHEMA collect;", v8, StringComparison.Ordinal); + } + } + + [Fact] + public void GenerateV8Move_MovesMetadataAndViewsToCollect_AndConfigTablesToConfig() + { + var v8 = PgSchemaGenerator.GenerateV8Move(); + + foreach (var table in PgSchemaGenerator.CollectMetadataTables) + { + Assert.Contains($"ALTER TABLE IF EXISTS public.{table} SET SCHEMA collect;", v8, StringComparison.Ordinal); + } + + Assert.Equal(24, PgSchemaGenerator.CollectViews.Count); + foreach (var view in PgSchemaGenerator.CollectViews) + { + Assert.Contains($"ALTER VIEW IF EXISTS public.{view} SET SCHEMA collect;", v8, StringComparison.Ordinal); + } + + /* Exactly the operator-writable coordination tables go to config — the admin write surface. */ + Assert.Equal( + new[] { "config_alert_log", "config_edge_trigger_watermarks", "config_mute_rules", "analysis_muted" }, + PgSchemaGenerator.ConfigTables); + foreach (var table in PgSchemaGenerator.ConfigTables) + { + Assert.Contains($"ALTER TABLE IF EXISTS public.{table} SET SCHEMA config;", v8, StringComparison.Ordinal); + } + + /* analysis_findings is service-written / viewer-read-only -> collect, not config (fork #1). */ + Assert.Contains("ALTER TABLE IF EXISTS public.analysis_findings SET SCHEMA collect;", v8, StringComparison.Ordinal); + Assert.DoesNotContain("public.analysis_findings SET SCHEMA config;", v8, StringComparison.Ordinal); + + /* The ALTER DATABASE search_path default is NOT baked into V8 — it is best-effort in MigrateAsync. */ + Assert.DoesNotContain("ALTER DATABASE", v8, StringComparison.Ordinal); + } + + [Fact] + public void SearchPath_ListsCollectConfigPublicInOrder() + { + Assert.Equal("collect, config, public", PgSchemaGenerator.SearchPath); + } +} diff --git a/Darling/Darling.Tests/PlanViewerCapabilityPinTests.cs b/Darling/Darling.Tests/PlanViewerCapabilityPinTests.cs new file mode 100644 index 000000000..ff1310167 --- /dev/null +++ b/Darling/Darling.Tests/PlanViewerCapabilityPinTests.cs @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using Xunit; + +namespace Darling.Tests; + +/// +/// CAPABILITY PIN for the STANDALONE Plan Viewer (front-end SHELL COLLAPSE, Darling-viewer side) -- the twin of +/// Lite.Tests' PlanViewerCapabilityPinTests. The collapse hoisted ~300 near-verbatim lines out of the +/// viewer's MainWindow.PlanViewer.cs into the shared +/// ; this file keeps only the app-specific +/// outer-container reveal/close (MainTabs vs the no-servers empty state), the store->viewer entry +/// (OpenStoredPlanInPlanViewer, fed by FinOpsTab.PlanRequested), and the XAML-wired drag/drop/key +/// forwarders. +/// +/// MECHANISM = RATCHET (see the Lite twin for the full rationale): a fixed probe table is matched against the +/// COMBINED plan-viewer surface (this app's MainWindow.xaml + MainWindow.PlanViewer.cs + the shared controller), +/// and the committed BASELINE of matched probe names must remain a SUBSET of what currently matches. A capability +/// may never disappear; new matching probes are free. Intentional removal is an explicit baseline-line delete in +/// the same PR; regenerate ONLY via PM_PIN_REGEN=1. Text-scans SOURCE, located from this file's +/// compile-time path -- NO WPF / assembly load. +/// +public sealed class PlanViewerCapabilityPinTests +{ + /* Probe = (stable capability name, a substring that proves it is still present). Wire probes use the + ="Handler" form so they match ONLY the XAML attribute, not the code-behind method definition. */ + private static readonly (string Name, string Pattern)[] Probes = + { + // Core plan-viewer behavior (now in the shared controller) + ("add-tab-sentinel", "__PLAN_ADD_TAB__"), + ("open-file-button", "Open .sqlplan File"), + ("paste-button", "Paste XML"), + ("empty-state-label", "New Plan"), + ("file-dialog-filter", "SQL Plan Files (*.sqlplan)"), + ("multi-file-open", "Multiselect = true"), + ("drag-filedrop", "DataFormats.FileDrop"), + ("drag-copy-effect", "DragDropEffects.Copy"), + ("clipboard-paste", "Clipboard.GetText"), + ("render-loadplan", ".LoadPlan("), + ("open-file-dialog", "OpenFileDialog"), + ("ctrl-v-paste", "Key.V"), + ("viewer-cleanup", ".Cleanup()"), + ("empty-state-icon", "Segoe MDL2 Assets"), + ("unique-sub-tab-label", "GetUniqueSubTabLabel"), + ("shared-plan-viewer-control", "new PlanViewerControl"), + // Per-app XAML shell wires (removal compiles clean) + ("wire-open", "=\"OpenPlanViewerButton_Click\""), + ("wire-close", "=\"MainWindowPlanViewerClose_Click\""), + ("wire-dragover", "=\"MainWindowPlanViewer_DragOver\""), + ("wire-drop", "=\"MainWindowPlanViewer_Drop\""), + ("wire-keydown", "=\"MainWindowPlanViewer_KeyDown\""), + // Per-app thin entry-method definitions + ("entry-open", "OpenPlanViewerButton_Click(object sender"), + ("entry-close", "MainWindowPlanViewerClose_Click(object sender"), + // Darling-only: the FinOps "View Plan" store->viewer entry + ("entry-stored-plan", "OpenStoredPlanInPlanViewer("), + }; + + [Fact] + public void PlanViewerCapabilities_NoneDisappeared() + { + var matched = ScanCapabilities(); + AssertRatchet("planviewer", matched, floor: 21); + } + + /* ---------------- scan ---------------- */ + + private static ISet ScanCapabilities() + { + var combined = CombinedSource(); + return Probes.Where(p => combined.Contains(p.Pattern, StringComparison.Ordinal)) + .Select(p => p.Name) + .ToHashSet(StringComparer.Ordinal); + } + + private static string CombinedSource() + { + var parts = new List + { + File.ReadAllText(AppXaml()), + File.ReadAllText(AppPlanViewerCs()), + }; + var controller = ControllerCs(); + // Tolerate the controller being absent (pre-hoist baselining): the union still finds the app-side tokens. + if (File.Exists(controller)) + { + parts.Add(File.ReadAllText(controller)); + } + return string.Join("\n", parts); + } + + /* ---------------- ratchet + source location ---------------- */ + + private static void AssertRatchet(string section, ISet current, int floor) + { + Assert.True(current.Count >= floor, + $"plan-viewer capability scan matched only {current.Count} probes (< floor {floor}) -- the scan is " + + "likely broken (source moved / renamed). Fix the scan; do not lower the floor."); + + var file = Path.Combine(BaselineDir(), $"{section}.baseline.txt"); + + if (Regen()) + { + Directory.CreateDirectory(BaselineDir()); + File.WriteAllLines(file, current.OrderBy(x => x, StringComparer.Ordinal)); + return; + } + + Assert.True(File.Exists(file), + $"baseline '{Path.GetFileName(file)}' is missing -- run the pin once with environment variable " + + "PM_PIN_REGEN=1 to generate it, then commit it."); + + var baseline = File.ReadAllLines(file) + .Select(l => l.Trim()) + .Where(l => l.Length > 0 && !l.StartsWith("#", StringComparison.Ordinal)) + .ToHashSet(StringComparer.Ordinal); + + var missing = baseline.Where(b => !current.Contains(b)) + .OrderBy(x => x, StringComparer.Ordinal) + .ToList(); + + Assert.True(missing.Count == 0, + "plan-viewer capability(ies) in the baseline are NO LONGER present in the source -- a capability " + + "silently went missing during the collapse:\n " + + string.Join("\n ", missing) + + "\nIf this removal is INTENTIONAL, delete those lines from Darling.Tests/CapabilityPins/" + + $"{section}.baseline.txt in THIS PR (never regenerate wholesale -- that masks accidental drops)."); + } + + private static bool Regen() => + string.Equals(Environment.GetEnvironmentVariable("PM_PIN_REGEN"), "1", StringComparison.Ordinal); + + private static string AppXaml([CallerFilePath] string thisFile = "") => + Path.GetFullPath(Path.Combine(Path.GetDirectoryName(thisFile)!, "..", "PerformanceMonitor.Darling.Viewer", "MainWindow.xaml")); + + private static string AppPlanViewerCs([CallerFilePath] string thisFile = "") => + Path.GetFullPath(Path.Combine(Path.GetDirectoryName(thisFile)!, "..", "PerformanceMonitor.Darling.Viewer", "MainWindow.PlanViewer.cs")); + + private static string ControllerCs([CallerFilePath] string thisFile = "") => + Path.GetFullPath(Path.Combine(Path.GetDirectoryName(thisFile)!, "..", "..", "PerformanceMonitor.Ui", "StandalonePlanViewerController.cs")); + + private static string BaselineDir([CallerFilePath] string thisFile = "") => + Path.Combine(Path.GetDirectoryName(thisFile)!, "CapabilityPins"); +} diff --git a/Darling/Darling.Tests/ScaffoldTests.cs b/Darling/Darling.Tests/ScaffoldTests.cs new file mode 100644 index 000000000..2cedf3d4a --- /dev/null +++ b/Darling/Darling.Tests/ScaffoldTests.cs @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using Microsoft.Extensions.Hosting; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Storage; +using Xunit; + +namespace Darling.Tests; + +/// +/// M0 scaffold smoke tests: prove the Darling projects build, reference the shared spine, +/// and wire the expected hosting shapes. Real suites (schema migrations, COPY-writer parity, +/// cross-SKU golden tests) replace these as milestones land. +/// +public sealed class ScaffoldTests +{ + [Fact] + public void StorageSchemaVersion_TracksLatestMigrationScript() + { + Assert.Equal(StorageVersion.SchemaVersion, PgMigrations.Scripts[^1].Version); + } + + [Fact] + public void DarlingWorker_IsAHostedBackgroundService() + { + Assert.True(typeof(BackgroundService).IsAssignableFrom(typeof(DarlingWorker))); + } +} diff --git a/Darling/Darling.Tests/ScratchPostgres.cs b/Darling/Darling.Tests/ScratchPostgres.cs new file mode 100644 index 000000000..66a570270 --- /dev/null +++ b/Darling/Darling.Tests/ScratchPostgres.cs @@ -0,0 +1,69 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace Darling.Tests; + +/// +/// Mints an isolated scratch DATABASE on the DARLING_TEST_PG server for tests that seed the +/// singleton config rows. StoreConfigProvider.SeedIfEmptyAsync deliberately no-ops on a +/// store any earlier test already seeded, so running these tests against the one shared CI +/// database made them order-dependent (caught live: the V20 round-trip read the V3 defaults an +/// earlier test seeded). Each such test gets its own database instead — created here, dropped on +/// dispose with WITH (FORCE) so a lingering connection can never wedge the drop. +/// Pooling=false on the scratch connection string keeps in-process pooled connections from +/// pinning the database in the first place. +/// +internal sealed class ScratchPostgres : IAsyncDisposable +{ + private readonly string _adminConnectionString; + + public string DatabaseName { get; } + + public string ConnectionString { get; } + + private ScratchPostgres(string adminConnectionString, string databaseName, string connectionString) + { + _adminConnectionString = adminConnectionString; + DatabaseName = databaseName; + ConnectionString = connectionString; + } + + public static async Task CreateAsync(string baseConnectionString, CancellationToken cancellationToken) + { + /* Hex-only generated name: safe to interpolate as a quoted identifier. */ + var databaseName = "darling_scratch_" + Guid.NewGuid().ToString("N")[..12]; + + await using (var admin = new NpgsqlConnection(baseConnectionString)) + { + await admin.OpenAsync(cancellationToken); + await using var create = new NpgsqlCommand($"CREATE DATABASE \"{databaseName}\"", admin); + await create.ExecuteNonQueryAsync(cancellationToken); + } + + var builder = new NpgsqlConnectionStringBuilder(baseConnectionString) + { + Database = databaseName, + Pooling = false, + }; + + return new ScratchPostgres(baseConnectionString, databaseName, builder.ConnectionString); + } + + public async ValueTask DisposeAsync() + { + try + { + await using var admin = new NpgsqlConnection(_adminConnectionString); + await admin.OpenAsync(); + await using var drop = new NpgsqlCommand($"DROP DATABASE IF EXISTS \"{DatabaseName}\" WITH (FORCE)", admin); + await drop.ExecuteNonQueryAsync(); + } + catch + { + /* Best-effort: a leaked scratch database on a throwaway test cluster is harmless, + and failing a passing test in its cleanup would invert the signal. */ + } + } +} diff --git a/Darling/Darling.Tests/ServerAttentionDeriverTests.cs b/Darling/Darling.Tests/ServerAttentionDeriverTests.cs new file mode 100644 index 000000000..6e99fdd51 --- /dev/null +++ b/Darling/Darling.Tests/ServerAttentionDeriverTests.cs @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the pure per-server "needs attention" badge derivation (): the +/// grouping + count, the muted/resolved exclusions (so a silenced server and an all-clear notice never light a +/// badge), the critical-severity flag that drives the badge colour, the newest-event instant that feeds the +/// ack-until-worse clear, and the tooltip breakdown. No WPF, no Postgres, no clock. +/// +public sealed class ServerAttentionDeriverTests +{ + private static readonly DateTime T0 = new(2026, 7, 8, 12, 0, 0, DateTimeKind.Utc); + + private static ViewerAlertRow Row(int serverId, string metric, DateTime alertTime, bool muted = false) => new() + { + AlertTime = alertTime, + ServerId = serverId, + ServerName = $"Server{serverId}", + MetricName = metric, + CurrentValue = 90, + ThresholdValue = 80, + AlertSent = true, + NotificationType = "tray", + Muted = muted, + }; + + [Fact] + public void EmptyInput_YieldsEmptyResult() + { + Assert.Empty(ServerAttentionDeriver.Derive(Array.Empty())); + } + + [Fact] + public void GroupsByServer_AndCountsActionableRows() + { + var rows = new[] + { + Row(1, "High CPU", T0), + Row(1, "Blocking Detected", T0.AddMinutes(1)), + Row(2, "High CPU", T0), + }; + + var result = ServerAttentionDeriver.Derive(rows); + + Assert.Equal(2, result.Count); + Assert.Equal(2, result[1].AlertCount); + Assert.Equal(1, result[2].AlertCount); + } + + [Fact] + public void ExcludesMutedRows_SoASilencedServerShowsNoBadge() + { + var rows = new[] + { + Row(1, "High CPU", T0, muted: true), + Row(1, "Blocking Detected", T0.AddMinutes(1), muted: true), + }; + + /* Every row for server 1 is muted -> the server is absent from the result (quiet). */ + Assert.False(ServerAttentionDeriver.Derive(rows).ContainsKey(1)); + } + + [Fact] + public void ExcludesResolutionNotices() + { + var rows = new[] + { + Row(1, "Blocking Cleared", T0), + Row(1, "Server Restored", T0.AddMinutes(1)), + Row(1, "High CPU", T0.AddMinutes(2)), + }; + + var result = ServerAttentionDeriver.Derive(rows); + + /* Only the actionable High CPU row counts; the two resolution notices are good news, not attention. */ + Assert.Equal(1, result[1].AlertCount); + } + + [Fact] + public void IsCritical_WhenAnyRowIsCritical() + { + var rows = new[] + { + Row(1, "High CPU", T0), + Row(1, "Deadlocks Detected", T0.AddMinutes(1)), + }; + + Assert.True(ServerAttentionDeriver.Derive(rows)[1].IsCritical); + } + + [Fact] + public void IsCritical_False_WhenOnlyWarnings() + { + var rows = new[] + { + Row(1, "High CPU", T0), + Row(1, "Blocking Detected", T0.AddMinutes(1)), + }; + + Assert.False(ServerAttentionDeriver.Derive(rows)[1].IsCritical); + } + + [Fact] + public void LatestEventUtc_IsTheNewestAlertTime() + { + var rows = new[] + { + Row(1, "High CPU", T0.AddMinutes(3)), + Row(1, "Blocking Detected", T0.AddMinutes(1)), + Row(1, "High CPU", T0.AddMinutes(7)), + }; + + Assert.Equal(T0.AddMinutes(7), ServerAttentionDeriver.Derive(rows)[1].LatestEventUtc); + } + + [Fact] + public void Summary_ListsMetrics_MostFrequentFirst_WithCounts() + { + var rows = new[] + { + Row(1, "High CPU", T0), + Row(1, "High CPU", T0.AddMinutes(1)), + Row(1, "Blocking Detected", T0.AddMinutes(2)), + }; + + var summary = ServerAttentionDeriver.Derive(rows)[1].Summary; + + Assert.Equal("High CPU ×2, Blocking Detected", summary); + } +} diff --git a/Darling/Darling.Tests/ServerHealthClassifierTests.cs b/Darling/Darling.Tests/ServerHealthClassifierTests.cs new file mode 100644 index 000000000..cac3f36e5 --- /dev/null +++ b/Darling/Darling.Tests/ServerHealthClassifierTests.cs @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using PerformanceMonitor.Common; +using Xunit; + +namespace Darling.Tests; + +/// +/// The shared brush-free server-health classifier (#1562) — the ONE place the freshness thresholds and the six +/// per-metric severity cutoffs live, consumed by the web dashboard, the get_fleet_overview MCP tool, and the WPF +/// viewer's Overview cards. This table-driven pin reproduces the exact cutoffs the WPF ServerSummaryItem +/// used to own (see ViewerW2aTests) so the unification cannot silently drift a threshold, plus the fleet band + +/// worst-first score derivations. +/// +public sealed class ServerHealthClassifierTests +{ + private static readonly DateTime Now = new(2026, 7, 18, 12, 0, 0, DateTimeKind.Utc); + + /* ── freshness (2x 1-min cadence stale / 15-min offline / null never-collected) ── */ + + [Fact] + public void ClassifyFreshness_NoCollection_IsNeverCollected() => + Assert.Equal(ServerFreshness.NeverCollected, ServerHealthClassifier.ClassifyFreshness(null, Now)); + + [Theory] + [InlineData(0, ServerFreshness.Fresh)] + [InlineData(120, ServerFreshness.Fresh)] // exactly 2x cadence — still fresh + [InlineData(121, ServerFreshness.Stale)] // just past 2x cadence + [InlineData(900, ServerFreshness.Stale)] // exactly 15 min — still stale + [InlineData(901, ServerFreshness.Offline)] // just past 15 min + [InlineData(1200, ServerFreshness.Offline)] + public void ClassifyFreshness_BandsByAge(int ageSeconds, ServerFreshness expected) => + Assert.Equal(expected, ServerHealthClassifier.ClassifyFreshness(Now.AddSeconds(-ageSeconds), Now)); + + /* ── per-metric bands ── */ + + [Theory] + [InlineData(40.0, HealthSeverity.Healthy)] + [InlineData(79.0, HealthSeverity.Healthy)] + [InlineData(80.0, HealthSeverity.Warning)] + [InlineData(94.0, HealthSeverity.Warning)] + [InlineData(95.0, HealthSeverity.Critical)] + [InlineData(100.0, HealthSeverity.Critical)] + public void CpuSeverity_BandsOnTotalCpu(double cpu, HealthSeverity expected) => + Assert.Equal(expected, ServerHealthClassifier.CpuSeverity(cpu)); + + [Fact] + public void CpuSeverity_NoData_IsUnknown() => + Assert.Equal(HealthSeverity.Unknown, ServerHealthClassifier.CpuSeverity(null)); + + [Theory] + [InlineData(false, HealthSeverity.Healthy)] + [InlineData(true, HealthSeverity.Critical)] + public void MemorySeverity_CriticalOnPressure(bool pressure, HealthSeverity expected) => + Assert.Equal(expected, ServerHealthClassifier.MemorySeverity(pressure)); + + [Theory] + [InlineData(0, 0.0, HealthSeverity.Healthy)] + [InlineData(1, 0.0, HealthSeverity.Warning)] // any blocking is Warning + [InlineData(2, 0.0, HealthSeverity.Warning)] + [InlineData(5, 0.0, HealthSeverity.Critical)] // >= 5 events Critical + [InlineData(0, 10.0, HealthSeverity.Warning)] // >= 10s max wait Warning + [InlineData(0, 60.0, HealthSeverity.Critical)] // >= 60s max wait Critical + public void BlockingSeverity_BandsOnCountAndWait(int count, double maxSeconds, HealthSeverity expected) => + Assert.Equal(expected, ServerHealthClassifier.BlockingSeverity(count, maxSeconds)); + + [Theory] + [InlineData(0, HealthSeverity.Healthy)] + [InlineData(1, HealthSeverity.Critical)] + public void DeadlockSeverity_AnyDeadlockCritical(int count, HealthSeverity expected) => + Assert.Equal(expected, ServerHealthClassifier.DeadlockSeverity(count)); + + [Fact] + public void ThreadsSeverity_NoSnapshot_IsUnknown() => + Assert.Equal(HealthSeverity.Unknown, ServerHealthClassifier.ThreadsSeverity(null, null, 0, 0)); + + [Fact] + public void ThreadsSeverity_WorkQueueStarvation_IsCritical() => + Assert.Equal(HealthSeverity.Critical, ServerHealthClassifier.ThreadsSeverity(512, 412, 0, 3)); + + [Fact] + public void ThreadsSeverity_ManyRunnable_IsWarning() => + Assert.Equal(HealthSeverity.Warning, ServerHealthClassifier.ThreadsSeverity(512, 412, 20, 0)); + + [Fact] + public void ThreadsSeverity_LowAvailable_IsWarning() => + // available 42 < 10% of 512 (51.2) + Assert.Equal(HealthSeverity.Warning, ServerHealthClassifier.ThreadsSeverity(512, 42, 0, 0)); + + [Fact] + public void ThreadsSeverity_Ample_IsHealthy() => + Assert.Equal(HealthSeverity.Healthy, ServerHealthClassifier.ThreadsSeverity(512, 400, 0, 0)); + + [Theory] + [InlineData(0, HealthSeverity.Healthy)] + [InlineData(1, HealthSeverity.Warning)] + public void CollectorSeverity_AnyFailingWarning(int failing, HealthSeverity expected) => + Assert.Equal(expected, ServerHealthClassifier.CollectorSeverity(failing)); + + /* ── overall reduce ── */ + + [Fact] + public void OverallMetricSeverity_CriticalWins() + { + var m = new ServerHealthMetrics { CpuPercentForAlert = 82, DeadlockCount = 1 }; // Warning + Critical + Assert.Equal(HealthSeverity.Critical, ServerHealthClassifier.OverallMetricSeverity(m)); + } + + [Fact] + public void OverallMetricSeverity_WarningWhenNoCritical() + { + var m = new ServerHealthMetrics { CpuPercentForAlert = 82, FailedCollectorCount = 1 }; + Assert.Equal(HealthSeverity.Warning, ServerHealthClassifier.OverallMetricSeverity(m)); + } + + [Fact] + public void OverallMetricSeverity_AllCalm_IsHealthy_UnknownNeverEscalates() + { + // No CPU snapshot (Unknown) and no threads snapshot (Unknown) must not escalate the card. + var m = new ServerHealthMetrics { CpuPercentForAlert = null, TotalThreads = null }; + Assert.Equal(HealthSeverity.Healthy, ServerHealthClassifier.OverallMetricSeverity(m)); + } + + /* ── fleet band (collapse) ── */ + + [Fact] + public void ClassifyBand_Offline_WhenNotOnline() => + Assert.Equal(FleetHealthBand.Offline, + ServerHealthClassifier.ClassifyBand(isOnline: false, awaitingFirstCollection: false, hasCollectorErrors: false, HealthSeverity.Healthy)); + + [Fact] + public void ClassifyBand_AwaitingFirstCollection_IsWarning_NotOffline() => + Assert.Equal(FleetHealthBand.Warning, + ServerHealthClassifier.ClassifyBand(isOnline: null, awaitingFirstCollection: true, hasCollectorErrors: false, HealthSeverity.Healthy)); + + [Fact] + public void ClassifyBand_CriticalMetric_IsCritical() => + Assert.Equal(FleetHealthBand.Critical, + ServerHealthClassifier.ClassifyBand(isOnline: true, awaitingFirstCollection: false, hasCollectorErrors: false, HealthSeverity.Critical)); + + [Fact] + public void ClassifyBand_StaleCollectionCalmMetrics_IsWarning() => + Assert.Equal(FleetHealthBand.Warning, + ServerHealthClassifier.ClassifyBand(isOnline: true, awaitingFirstCollection: false, hasCollectorErrors: true, HealthSeverity.Healthy)); + + [Fact] + public void ClassifyBand_OnlineCalm_IsHealthy() => + Assert.Equal(FleetHealthBand.Healthy, + ServerHealthClassifier.ClassifyBand(isOnline: true, awaitingFirstCollection: false, hasCollectorErrors: false, HealthSeverity.Healthy)); + + /* ── worst-first score ── */ + + [Fact] + public void FleetHealthScore_BandRankDominates() + { + var m = new ServerHealthMetrics(); + var offline = ServerHealthClassifier.FleetHealthScore(FleetHealthBand.Offline, m); + var critical = ServerHealthClassifier.FleetHealthScore(FleetHealthBand.Critical, m); + var warning = ServerHealthClassifier.FleetHealthScore(FleetHealthBand.Warning, m); + var healthy = ServerHealthClassifier.FleetHealthScore(FleetHealthBand.Healthy, m); + Assert.True(offline > critical); + Assert.True(critical > warning); + Assert.True(warning > healthy); + } + + [Fact] + public void FleetHealthScore_WithinBand_MoreBadMetricsRanksHigher() + { + var worse = new ServerHealthMetrics { CpuPercentForAlert = 96, DeadlockCount = 1 }; // two Critical metrics + var milder = new ServerHealthMetrics { DeadlockCount = 1 }; // one Critical metric + Assert.True( + ServerHealthClassifier.FleetHealthScore(FleetHealthBand.Critical, worse) > + ServerHealthClassifier.FleetHealthScore(FleetHealthBand.Critical, milder)); + } + + [Fact] + public void FleetHealthScore_WithinBandTermsNeverCrossABandBoundary() + { + // A Warning server maxed out on within-band magnitude (every metric bad, capped incidents) must still + // rank below the LOWEST possible Critical (no bad metrics) — the within-band total stays under 1000. + var maxed = new ServerHealthMetrics + { + CpuPercentForAlert = 96, + HasMemoryPressure = true, + BlockingCount = 99, + MaxBlockedSeconds = 120, + DeadlockCount = 99, + TotalThreads = 512, + AvailableThreads = 1, + FailedCollectorCount = 1, + }; + var maxedWarning = ServerHealthClassifier.FleetHealthScore(FleetHealthBand.Warning, maxed); + var bareCritical = ServerHealthClassifier.FleetHealthScore(FleetHealthBand.Critical, new ServerHealthMetrics()); + Assert.True(bareCritical > maxedWarning); + } + + [Fact] + public void BandLabel_MapsEachBand() + { + Assert.Equal("Healthy", ServerHealthClassifier.BandLabel(FleetHealthBand.Healthy)); + Assert.Equal("Warning", ServerHealthClassifier.BandLabel(FleetHealthBand.Warning)); + Assert.Equal("Critical", ServerHealthClassifier.BandLabel(FleetHealthBand.Critical)); + Assert.Equal("Offline", ServerHealthClassifier.BandLabel(FleetHealthBand.Offline)); + } +} diff --git a/Darling/Darling.Tests/ServerOverviewSortTests.cs b/Darling/Darling.Tests/ServerOverviewSortTests.cs new file mode 100644 index 000000000..781ccc766 --- /dev/null +++ b/Darling/Darling.Tests/ServerOverviewSortTests.cs @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using PerformanceMonitor.Ui; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins — the pure, total-order comparer that orders the NOC Overview tiles +/// in BOTH apps (Lite and the Darling viewer). Mirror of Lite.Tests' copy (the ChartStyle precedent: the +/// shared pure function is tested identically in each suite). Uses a local Row test-double because +/// each app binds its own unrelated ServerSummaryItem type; only the three sorted members matter. +/// +public class ServerOverviewSortTests +{ + /// Stand-in for each app's ServerSummaryItem — CPU% (the alert-band value), display name, id. + private sealed record Row(double? Cpu, string Name, int Id); + + private static List Order(IEnumerable rows, ServerOverviewSortMode mode) => + ServerOverviewSort.Order(rows, mode, r => r.Cpu, r => r.Name, r => r.Id); + + [Fact] + public void Cpu_OrdersByCpuDescending() + { + var rows = new[] + { + new Row(12.0, "b", 1), + new Row(90.0, "a", 2), + new Row(45.0, "c", 3), + }; + + var ordered = Order(rows, ServerOverviewSortMode.Cpu); + + Assert.Equal(new[] { 2, 3, 1 }, ordered.Select(r => r.Id)); + } + + [Fact] + public void Cpu_NoSampleServersSinkLast_ThenOrderedByName() + { + var rows = new[] + { + new Row(null, "zeta", 1), + new Row(5.0, "busy", 2), + new Row(null, "alpha", 3), + }; + + var ordered = Order(rows, ServerOverviewSortMode.Cpu); + + // Has-sample first (busy), then the two null-CPU servers by name (alpha before zeta). + Assert.Equal(new[] { 2, 3, 1 }, ordered.Select(r => r.Id)); + } + + [Fact] + public void Cpu_RealZeroPercent_OutranksNoSample() + { + // A genuine 0.0% has a sample and must sort ABOVE a null (no-sample) server, proving the `?? 0.0` + // fallback only ever bites inside the already-sunk no-sample partition — a real 0.0% is unaffected. + var rows = new[] + { + new Row(null, "nosample", 1), + new Row(0.0, "idle", 2), + }; + + var ordered = Order(rows, ServerOverviewSortMode.Cpu); + + Assert.Equal(new[] { 2, 1 }, ordered.Select(r => r.Id)); + } + + [Fact] + public void Cpu_TiesBreakByName_CaseInsensitive() + { + var rows = new[] + { + new Row(50.0, "Bravo", 1), + new Row(50.0, "alpha", 2), + new Row(50.0, "Charlie", 3), + }; + + var ordered = Order(rows, ServerOverviewSortMode.Cpu); + + // Equal CPU -> case-insensitive name asc: alpha, Bravo, Charlie. + Assert.Equal(new[] { 2, 1, 3 }, ordered.Select(r => r.Id)); + } + + [Fact] + public void Cpu_SameCpuAndName_BreaksByStableId() + { + var rows = new[] + { + new Row(50.0, "same", 30), + new Row(50.0, "same", 10), + new Row(50.0, "SAME", 20), + }; + + var ordered = Order(rows, ServerOverviewSortMode.Cpu); + + // Equal CPU, name equal case-insensitively -> id ascending. + Assert.Equal(new[] { 10, 20, 30 }, ordered.Select(r => r.Id)); + } + + [Fact] + public void Cpu_IsATotalOrder_IndependentOfInputEnumeration() + { + var rows = new[] + { + new Row(50.0, "same", 10), + new Row(50.0, "same", 20), + new Row(null, "x", 30), + new Row(70.0, "hot", 40), + }; + + var forward = Order(rows, ServerOverviewSortMode.Cpu).Select(r => r.Id).ToList(); + var reversed = Order(rows.Reverse(), ServerOverviewSortMode.Cpu).Select(r => r.Id).ToList(); + + Assert.Equal(forward, reversed); + } + + [Fact] + public void Name_OrdersByNameAscending_IgnoringCpu() + { + var rows = new[] + { + new Row(99.0, "charlie", 1), + new Row(1.0, "Alpha", 2), + new Row(null, "bravo", 3), + }; + + var ordered = Order(rows, ServerOverviewSortMode.Name); + + // Name mode: case-insensitive name asc, regardless of CPU value or a null CPU sample. + Assert.Equal(new[] { 2, 3, 1 }, ordered.Select(r => r.Id)); + } + + [Fact] + public void Name_TiesBreakByStableId() + { + var rows = new[] + { + new Row(10.0, "dup", 3), + new Row(20.0, "dup", 1), + new Row(30.0, "dup", 2), + }; + + var ordered = Order(rows, ServerOverviewSortMode.Name); + + Assert.Equal(new[] { 1, 2, 3 }, ordered.Select(r => r.Id)); + } + + [Fact] + public void Order_EmptyInput_ReturnsEmpty() + { + Assert.Empty(Order(Array.Empty(), ServerOverviewSortMode.Cpu)); + Assert.Empty(Order(Array.Empty(), ServerOverviewSortMode.Name)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("bogus")] + [InlineData("5")] + public void ParseMode_UnrecognisedOrEmpty_FallsBackToCpu(string? token) + { + Assert.Equal(ServerOverviewSortMode.Cpu, ServerOverviewSort.ParseMode(token)); + } + + [Theory] + [InlineData("Cpu", ServerOverviewSortMode.Cpu)] + [InlineData("cpu", ServerOverviewSortMode.Cpu)] + [InlineData("Name", ServerOverviewSortMode.Name)] + [InlineData("name", ServerOverviewSortMode.Name)] + [InlineData("NAME", ServerOverviewSortMode.Name)] + public void ParseMode_RecognisedTokens_AreCaseInsensitive(string token, ServerOverviewSortMode expected) + { + Assert.Equal(expected, ServerOverviewSort.ParseMode(token)); + } + + [Theory] + [InlineData(ServerOverviewSortMode.Cpu)] + [InlineData(ServerOverviewSortMode.Name)] + public void ToToken_RoundTripsThroughParseMode(ServerOverviewSortMode mode) + { + Assert.Equal(mode, ServerOverviewSort.ParseMode(ServerOverviewSort.ToToken(mode))); + } + + [Fact] + public void Default_IsCpu() + { + Assert.Equal(ServerOverviewSortMode.Cpu, ServerOverviewSort.Default); + } +} diff --git a/Darling/Darling.Tests/SharedBaselineModelPinTests.cs b/Darling/Darling.Tests/SharedBaselineModelPinTests.cs new file mode 100644 index 000000000..f67bd09e6 --- /dev/null +++ b/Darling/Darling.Tests/SharedBaselineModelPinTests.cs @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using PerformanceMonitor.Analysis.Baselines; +using PerformanceMonitor.Darling.Analysis; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the Tier-0 baseline-model collapse (Darling half of the Lite<->Darling lockstep): +/// Darling's baseline/anomaly brain consumes the SHARED model from the PerformanceMonitor.Analysis +/// assembly (namespace PerformanceMonitor.Analysis.Baselines) and keeps NO per-app copy of it, +/// so Darling and Lite can't silently re-fork the once-triplicated model + constants. The deprecated +/// Dashboard keeps its OWN copy on purpose (PerformanceMonitorDashboard.Analysis) — this pin is +/// Lite<->Darling only. Lite.Tests carries the mirror-image pin for the Lite half. +/// +public sealed class SharedBaselineModelPinTests +{ + private const string SharedAssembly = "PerformanceMonitor.Analysis"; + private const string SharedNamespace = "PerformanceMonitor.Analysis.Baselines"; + + [Fact] + public void BaselineModelTypes_LiveInSharedAssemblyAndNamespace() + { + Assert.Equal(SharedAssembly, typeof(BaselineBucket).Assembly.GetName().Name); + Assert.Equal(SharedAssembly, typeof(BaselineTier).Assembly.GetName().Name); + Assert.Equal(SharedAssembly, typeof(MetricNames).Assembly.GetName().Name); + Assert.Equal(SharedAssembly, typeof(BaselineMath).Assembly.GetName().Name); + Assert.Equal(SharedAssembly, typeof(AnomalyThresholds).Assembly.GetName().Name); + + Assert.Equal(SharedNamespace, typeof(BaselineBucket).Namespace); + Assert.Equal(SharedNamespace, typeof(BaselineTier).Namespace); + Assert.Equal(SharedNamespace, typeof(MetricNames).Namespace); + Assert.Equal(SharedNamespace, typeof(BaselineMath).Namespace); + Assert.Equal(SharedNamespace, typeof(AnomalyThresholds).Namespace); + } + + [Fact] + public void Darling_KeepsNoPrivateCopyOfTheBaselineModel() + { + // If someone re-declares any of these in Darling's own Analysis namespace, this fails — + // the whole point of the collapse is that the model has exactly ONE home. + var darlingAssembly = typeof(PgBaselineProvider).Assembly; + foreach (var forkedType in new[] + { + "PerformanceMonitor.Darling.Analysis.BaselineBucket", + "PerformanceMonitor.Darling.Analysis.BaselineTier", + "PerformanceMonitor.Darling.Analysis.MetricNames", + "PerformanceMonitor.Darling.Analysis.BaselineMath", + "PerformanceMonitor.Darling.Analysis.AnomalyThresholds", + }) + { + Assert.Null(darlingAssembly.GetType(forkedType)); + } + } + + [Fact] + public void PgBaselineProvider_BindsToTheSharedBaselineBucket() + { + // GetBaselineAsync returns Task; pin the element type to the shared type — + // the SAME PerformanceMonitor.Analysis.Baselines.BaselineBucket Lite binds to. + var method = typeof(PgBaselineProvider).GetMethod(nameof(PgBaselineProvider.GetBaselineAsync)); + Assert.NotNull(method); + var bucketType = method!.ReturnType.GetGenericArguments()[0]; + Assert.Same(typeof(BaselineBucket), bucketType); + Assert.Equal(SharedAssembly, bucketType.Assembly.GetName().Name); + } +} diff --git a/Darling/Darling.Tests/SingleInstanceGuardTests.cs b/Darling/Darling.Tests/SingleInstanceGuardTests.cs new file mode 100644 index 000000000..453e70cf2 --- /dev/null +++ b/Darling/Darling.Tests/SingleInstanceGuardTests.cs @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading; +using PerformanceMonitor.Darling.Service; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the #1581 single-instance guard (Fix B): the mutex-based "am I the only running instance" decision that +/// stops a stray second invocation (the incident's Service.exe --version that fell into a real startup) +/// from spawning a second service that fights the first over the bundled PostgreSQL and ports. The behavioral +/// cases drive real named mutexes across threads in-process (deterministic — a full cross-PROCESS test is not +/// worth the flakiness), using unique Local\ names so they need no SeCreateGlobalPrivilege; they +/// are Windows-gated because named-mutex namespace semantics are Windows-specific. The production name and the +/// release-on-dispose (SCM stop→start) contract are pinned too. +/// +public sealed class SingleInstanceGuardTests +{ + private static string UniqueName() => @"Local\Darling-Test-" + Guid.NewGuid().ToString("N"); + + [Fact] + public void ServiceMutexName_IsTheGlobalProductName() + { + /* Global\ so the lock spans terminal-services sessions: the service runs in session 0 and an interactive + invocation runs in the user session — a Local\ mutex would not collide, which is the whole incident. */ + Assert.Equal(@"Global\PerformanceMonitorDarlingService", SingleInstanceGuard.ServiceMutexName); + } + + [Fact] + public void TryAcquire_FreshName_OwnsAndIsNotAbandoned() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "Named-mutex namespace semantics are Windows-specific."); + + var name = UniqueName(); + using var guard = SingleInstanceGuard.TryAcquire(name); + + Assert.True(guard.Owns); + Assert.False(guard.WasAbandoned); + } + + [Fact] + public void TryAcquire_AfterDispose_ReAcquiresCleanly_ScmStopStart() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "Named-mutex namespace semantics are Windows-specific."); + + var name = UniqueName(); + + /* First "instance" acquires then disposes (a graceful stop releases the mutex). */ + var first = SingleInstanceGuard.TryAcquire(name); + Assert.True(first.Owns); + first.Dispose(); + + /* A subsequent "start" (fresh process, here a fresh guard) re-acquires cleanly, not as abandoned. */ + using var second = SingleInstanceGuard.TryAcquire(name); + Assert.True(second.Owns); + Assert.False(second.WasAbandoned); + } + + [Fact] + public void TryAcquire_WhenHeldByAnotherThread_DoesNotOwn() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "Named-mutex namespace semantics are Windows-specific."); + + var name = UniqueName(); + + /* A background thread acquires and HOLDS the named mutex; the main thread must then fail to own it. A + named mutex is one kernel object across all handles in the process, but ownership is per-thread, so the + main thread's WaitOne(0) returns false while the other thread holds it — exactly the second-instance + case. keepAlive keeps a handle open so the object persists for the whole test. */ + using var keepAlive = new Mutex(false, name); + using var acquired = new ManualResetEventSlim(false); + using var release = new ManualResetEventSlim(false); + + var holder = new Thread(() => + { + keepAlive.WaitOne(); + acquired.Set(); + release.Wait(); + keepAlive.ReleaseMutex(); + }) + { IsBackground = true }; + holder.Start(); + + try + { + Assert.True(acquired.Wait(TimeSpan.FromSeconds(5)), "the holder thread did not acquire the mutex in time"); + + using var guard = SingleInstanceGuard.TryAcquire(name); + Assert.False(guard.Owns); + Assert.False(guard.WasAbandoned); + } + finally + { + release.Set(); + holder.Join(TimeSpan.FromSeconds(5)); + } + } + + [Fact] + public void TryAcquire_AbandonedByCrashedInstance_OwnsWithAbandonedWarning() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "Named-mutex namespace semantics are Windows-specific."); + + var name = UniqueName(); + + /* A background thread acquires the mutex then EXITS without releasing it — exactly a crashed/force-killed + previous instance. keepAlive (on this thread) keeps the kernel object alive after the holder exits, so + it is genuinely ABANDONED rather than destroyed. The next acquire must then succeed WITH the abandoned + flag set (a stale mutex must never wedge every future start). */ + using var keepAlive = new Mutex(false, name); + using var acquired = new ManualResetEventSlim(false); + + var holder = new Thread(() => + { + keepAlive.WaitOne(); + acquired.Set(); + /* Return without ReleaseMutex -> the mutex is abandoned when this thread exits. */ + }) + { IsBackground = true }; + holder.Start(); + + Assert.True(acquired.Wait(TimeSpan.FromSeconds(5)), "the holder thread did not acquire the mutex in time"); + holder.Join(TimeSpan.FromSeconds(5)); + + /* The kernel marks the mutex abandoned as the holder's OS thread is torn down, which completes shortly + AFTER Thread.Join returns (Join synchronizes on the MANAGED thread; the native teardown that abandons + the mutex can lag by a few milliseconds). The production TryAcquire is a non-blocking WaitOne(0), so a + poll landing in that window sees the mutex still owned by the dying thread and returns false — a race + that exists ONLY for this in-process crash SIMULATION, never in production, where the crashed PROCESS + is fully dead (all handles closed by OS process teardown) before the SCM starts the replacement. Retry + the non-blocking acquire until the abandonment is observable, then assert the real contract: an + abandoned mutex is acquired WITH the abandoned warning. The guard itself must NOT retry — a false there + means a LIVE second instance holds the lock and exiting is correct — so the retry lives here, not in + the production code. */ + var guard = SingleInstanceGuard.TryAcquire(name); + for (var attempt = 0; attempt < 200 && !guard.Owns; attempt++) + { + guard.Dispose(); + Thread.Sleep(10); + guard = SingleInstanceGuard.TryAcquire(name); + } + + using var acquiredGuard = guard; + Assert.True(guard.Owns, "an abandoned mutex must become acquirable once the holder thread is fully torn down"); + Assert.True(guard.WasAbandoned); + } +} diff --git a/Darling/Darling.Tests/StoreConfigProviderTests.cs b/Darling/Darling.Tests/StoreConfigProviderTests.cs new file mode 100644 index 000000000..d3f2e82c1 --- /dev/null +++ b/Darling/Darling.Tests/StoreConfigProviderTests.cs @@ -0,0 +1,240 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Storage; +using Xunit; + +namespace Darling.Tests; + +/// +/// The control-plane Stage 1 store provider. The pure tests pin the reload/reconcile merge logic with +/// no database: swaps the held config's sections in place +/// (so the by-reference seam reflects a store reload immediately), and +/// the schedule resolvers layer config_collector_schedules overrides on +/// (per-server > fleet > default, per column). The live tests +/// (gated on DARLING_TEST_PG) prove the V17 bump trigger fires and are transaction-rolled-back so they never +/// clobber a shared dev store's singleton config rows. +/// +public sealed class StoreConfigProviderTests +{ + /* ---------------- pure: apply (view -> held config, by-reference seam) ---------------- */ + + [Fact] + public void ApplyToConfig_SwapsEverySection_ByReferenceSeamReflectsItWithoutReconstruct() + { + var config = new DarlingConfig(); + var settings = new DarlingAlertSettings(config); // holds config by reference + + /* Sanity — the defaults before the swap. */ + Assert.Equal(80, settings.CpuThresholdPercent); + Assert.True(config.CapturePlans); + Assert.Equal(1.5, settings.AnalysisNotifySeverity); + Assert.False(settings.SmtpEnabled); + Assert.False(settings.TeamsWebhookEnabled); + + var view = new StoreConfigView + { + CapturePlans = false, + McpEnabled = true, + McpPort = 6000, + WebEnabled = true, + WebPort = 6001, + Alerts = new AlertsConfig { CpuThresholdPercent = 42, ExcludedDatabases = { "tempdb" } }, + Analysis = new AnalysisConfig { Enabled = false, IntervalMinutes = 90, NotificationsEnabled = false, NotifySeverity = 0.75 }, + Smtp = new SmtpConfig { Host = "smtp.example.com", From = "a@b.com", To = "c@d.com" }, + Webhooks = new WebhooksConfig { TeamsUrl = "https://teams.example.com/hook" }, + }; + + StoreConfigProvider.ApplyToConfig(config, view); + + /* The held config is mutated in place. */ + Assert.False(config.CapturePlans); + Assert.Equal(42, config.Alerts.CpuThresholdPercent); + Assert.True(config.Mcp.Enabled); + Assert.Equal(6000, config.Mcp.Port); + Assert.True(config.Web.Enabled); + Assert.Equal(6001, config.Web.Port); + Assert.Equal(90, config.Analysis.IntervalMinutes); + Assert.False(config.Analysis.Enabled); + + /* The SAME settings adapter instance reflects the swap — the "future config-reload" seam. */ + Assert.Equal(42, settings.CpuThresholdPercent); + Assert.Equal(0.75, settings.AnalysisNotifySeverity); + Assert.True(settings.SmtpEnabled); + Assert.Equal("smtp.example.com", settings.SmtpServer); + Assert.Equal("c@d.com", settings.SmtpRecipients); + Assert.True(settings.TeamsWebhookEnabled); + Assert.Contains("tempdb", settings.ExcludedDatabases); + } + + [Fact] + public void ApplyToConfig_NotifySeverity_ClampsThroughTheSettingsSeam() + { + var config = new DarlingConfig(); + var settings = new DarlingAlertSettings(config); + + StoreConfigProvider.ApplyToConfig(config, new StoreConfigView { Analysis = new AnalysisConfig { NotifySeverity = 9.9 } }); + Assert.Equal(2.0, settings.AnalysisNotifySeverity); // above max -> 2.0 + + StoreConfigProvider.ApplyToConfig(config, new StoreConfigView { Analysis = new AnalysisConfig { NotifySeverity = -1.0 } }); + Assert.Equal(0.0, settings.AnalysisNotifySeverity); // below min -> 0.0 + } + + /* ---------------- pure: schedule resolution (override layering) ---------------- */ + + [Fact] + public void ResolveSchedule_EmptyOverrides_ReturnsCollectorScheduleDefaults() + { + var def = CollectorScheduleDefaults.All["wait_stats"]; + var eff = StoreConfigProvider.ResolveSchedule("wait_stats", 123, Array.Empty()); + + Assert.Equal(def.FrequencyMinutes, eff.FrequencyMinutes); + Assert.Equal(def.RetentionDays, eff.RetentionDays); + Assert.True(eff.Enabled); + } + + [Fact] + public void ResolveSchedule_FleetOverride_AppliesWhenNoPerServerRow() + { + var overrides = new[] { new ScheduleOverride(null, "wait_stats", 15, 60, true) }; + var eff = StoreConfigProvider.ResolveSchedule("wait_stats", 123, overrides); + + Assert.Equal(15, eff.FrequencyMinutes); + Assert.Equal(60, eff.RetentionDays); + Assert.True(eff.Enabled); + } + + [Fact] + public void ResolveSchedule_PerServerWinsOverFleet_PerColumnNullFallsThrough() + { + var overrides = new[] + { + new ScheduleOverride(null, "wait_stats", 15, 60, true), // fleet + new ScheduleOverride(123, "wait_stats", 2, null, true), // per-server: freq 2, retention NULL + }; + var eff = StoreConfigProvider.ResolveSchedule("wait_stats", 123, overrides); + + Assert.Equal(2, eff.FrequencyMinutes); // per-server frequency wins + Assert.Equal(60, eff.RetentionDays); // per-server retention NULL -> fleet 60 + } + + [Fact] + public void ResolveSchedule_DisabledOverride_ReportsDisabled_FrequencyFallsToDefault() + { + var overrides = new[] { new ScheduleOverride(123, "wait_stats", null, null, false) }; + var eff = StoreConfigProvider.ResolveSchedule("wait_stats", 123, overrides); + + Assert.False(eff.Enabled); + Assert.Equal(CollectorScheduleDefaults.All["wait_stats"].FrequencyMinutes, eff.FrequencyMinutes); + } + + [Fact] + public void ResolveSchedule_OtherServersRow_DoesNotLeakToThisServer() + { + var overrides = new[] { new ScheduleOverride(999, "wait_stats", 99, 99, false) }; // a different server + var eff = StoreConfigProvider.ResolveSchedule("wait_stats", 123, overrides); + + var def = CollectorScheduleDefaults.All["wait_stats"]; + Assert.Equal(def.FrequencyMinutes, eff.FrequencyMinutes); + Assert.True(eff.Enabled); + } + + [Fact] + public void ResolveFleetRetentionDays_FleetOverrideWins_PerServerIgnored_ElseDefault() + { + var def = CollectorScheduleDefaults.All["query_stats"].RetentionDays; + + Assert.Equal(def, StoreConfigProvider.ResolveFleetRetentionDays("query_stats", Array.Empty())); + + /* A per-server retention override cannot apply to a shared-table purge — ignored. */ + var perServerOnly = new[] { new ScheduleOverride(9, "query_stats", null, 5, true) }; + Assert.Equal(def, StoreConfigProvider.ResolveFleetRetentionDays("query_stats", perServerOnly)); + + var fleet = new[] { new ScheduleOverride(null, "query_stats", null, 45, true) }; + Assert.Equal(45, StoreConfigProvider.ResolveFleetRetentionDays("query_stats", fleet)); + } + + [Fact] + public void Resolve_RejectsDestructiveRetention_AndNegativeFrequency_FallingBackToDefaults() + { + var def = CollectorScheduleDefaults.All["query_stats"]; + + /* Retention 0/negative would invert the purge cutoff and delete everything — it must NOT be + honored; the resolvers fall through to the safe code default. */ + foreach (var bad in new[] { 0, -1, -3650 }) + { + var fleet = new[] { new ScheduleOverride(null, "query_stats", null, bad, true) }; + Assert.Equal(def.RetentionDays, StoreConfigProvider.ResolveFleetRetentionDays("query_stats", fleet)); + Assert.Equal(def.RetentionDays, StoreConfigProvider.ResolveSchedule("query_stats", 1, fleet).RetentionDays); + } + + /* A negative frequency must not be honored (would make the collector run every sweep). */ + var badFreq = new[] { new ScheduleOverride(null, "query_stats", -5, null, true) }; + Assert.Equal(def.FrequencyMinutes, StoreConfigProvider.ResolveSchedule("query_stats", 1, badFreq).FrequencyMinutes); + + /* A valid retention (>= 1) and frequency 0 (on-load-only) are still honored. */ + var ok = new[] { new ScheduleOverride(null, "query_stats", 0, 1, true) }; + var eff = StoreConfigProvider.ResolveSchedule("query_stats", 1, ok); + Assert.Equal(0, eff.FrequencyMinutes); + Assert.Equal(1, eff.RetentionDays); + } + + /* ---------------- live (DARLING_TEST_PG): the V17 bump trigger, rolled back ---------------- */ + + [Fact] + public async Task ConfigVersion_BumpTriggers_FireOnConfigWrites_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the config_version bump-trigger test."); + + var ct = TestContext.Current.CancellationToken; + await using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + + /* Everything inside a transaction that ROLLS BACK — the shared dev store's real singleton config + rows are never mutated. Names are schema-qualified so resolution is search_path-independent. */ + await using var tx = await connection.BeginTransactionAsync(ct); + + await ExecAsync(connection, tx, + "INSERT INTO config.config_service (id, updated_at) VALUES (1, now() AT TIME ZONE 'UTC') ON CONFLICT (id) DO NOTHING", ct); + var before = Convert.ToInt64(await ScalarAsync(connection, tx, "SELECT config_version FROM config.config_service WHERE id = 1", ct)); + + /* A desired-state write bumps the beacon via the statement-level trigger. */ + await ExecAsync(connection, tx, + "INSERT INTO config.config_collector_schedules (server_id, collector_name, frequency_minutes, enabled) VALUES (424242, 'wait_stats', 7, true)", ct); + var afterSchedule = Convert.ToInt64(await ScalarAsync(connection, tx, "SELECT config_version FROM config.config_service WHERE id = 1", ct)); + Assert.True(afterSchedule > before, "config_version should bump on a config_collector_schedules write"); + + /* A DIRECT config_service write self-bumps without the writer touching config_version. */ + await ExecAsync(connection, tx, "UPDATE config.config_service SET paused = NOT paused WHERE id = 1", ct); + var afterService = Convert.ToInt64(await ScalarAsync(connection, tx, "SELECT config_version FROM config.config_service WHERE id = 1", ct)); + Assert.True(afterService > afterSchedule, "config_version should self-bump on a direct config_service write"); + + await tx.RollbackAsync(ct); + } + + private static async Task ExecAsync(NpgsqlConnection connection, NpgsqlTransaction tx, string sql, CancellationToken ct) + { + using var command = new NpgsqlCommand(sql, connection, tx); + await command.ExecuteNonQueryAsync(ct); + } + + private static async Task ScalarAsync(NpgsqlConnection connection, NpgsqlTransaction tx, string sql, CancellationToken ct) + { + using var command = new NpgsqlCommand(sql, connection, tx); + return await command.ExecuteScalarAsync(ct); + } +} diff --git a/Darling/Darling.Tests/SystemHealthParserTests.cs b/Darling/Darling.Tests/SystemHealthParserTests.cs new file mode 100644 index 000000000..a120c137c --- /dev/null +++ b/Darling/Darling.Tests/SystemHealthParserTests.cs @@ -0,0 +1,492 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.IO; +using System.Linq; +using PerformanceMonitor.Common; +using Xunit; + +namespace Darling.Tests; + +/// +/// Tests for the shared — the monitor-side C# port of Erik's +/// sp_HealthParser per-category xpath shred. Each of the eight warning categories is asserted +/// column-by-column against a representative captured event (Fixtures/SystemHealth/*.xml, shaped to +/// sp_HealthParser's xpath + the MS event schema), plus dispatch, robustness, the per-component +/// sp_server_diagnostics routing (RESOURCE / QUERY_PROCESSING / IO_SUBSYSTEM) and no-op on the others, and +/// the sql_text control-character cleanup. Tested once in Common (not duplicated per app). No database. +/// +public class SystemHealthParserTests +{ + private static string LoadFixture(string name) => + File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "SystemHealth", name)); + + private static readonly DateTime SchedulerTime = new(2026, 7, 5, 11, 58, 0, 123, DateTimeKind.Utc); + private static readonly DateTime ErrorTime = new(2026, 7, 5, 12, 0, 5, 500, DateTimeKind.Utc); + private static readonly DateTime ResourceTime = new(2026, 7, 5, 12, 1, 0, 0, DateTimeKind.Utc); + private static readonly DateTime BrokerTime = new(2026, 7, 5, 12, 2, 10, 250, DateTimeKind.Utc); + private static readonly DateTime OomTime = new(2026, 7, 5, 12, 3, 20, 750, DateTimeKind.Utc); + private static readonly DateTime WaitTime = new(2026, 7, 5, 12, 4, 30, 900, DateTimeKind.Utc); + private static readonly DateTime IoTime = new(2026, 7, 5, 12, 5, 0, 0, DateTimeKind.Utc); + private static readonly DateTime CpuWarningTime = new(2026, 7, 5, 12, 6, 0, 0, DateTimeKind.Utc); + private static readonly DateTime CpuCleanTime = new(2026, 7, 5, 12, 1, 0, 0, DateTimeKind.Utc); + private static readonly DateTime SystemTime = new(2026, 7, 5, 12, 7, 0, 0, DateTimeKind.Utc); + + // ── SchedulerIssues (scheduler_monitor_system_health_ring_buffer_recorded) ── + + [Fact] + public void SchedulerIssue_ShredsEveryColumn() + { + var r = SystemHealthParser.ParseSchedulerIssue(LoadFixture("scheduler_monitor.xml")); + + Assert.NotNull(r); + Assert.Equal(SchedulerTime, r!.EventTime); + Assert.Equal(10, r.SchedulerId); + Assert.Equal(3, r.CpuId); + Assert.Equal("WARNING", r.Status); // data[@name="status"]/text, NOT the numeric value + Assert.True(r.IsOnline); + Assert.False(r.IsRunnable); + Assert.True(r.IsRunning); + Assert.Equal(4500L, r.NonYieldingTimeMs); // data[@name="non_yielding_time"] (no _ms suffix in XML) + Assert.Equal(4L, r.ThreadQuantumMs); // data[@name="thread_quantum"] + } + + // ── SevereErrors (error_reported) ── + + [Fact] + public void SevereError_ShredsEveryColumn() + { + var r = SystemHealthParser.ParseSevereError(LoadFixture("error_reported.xml")); + + Assert.NotNull(r); + Assert.Equal(ErrorTime, r!.EventTime); + Assert.Equal(823, r.ErrorNumber); + Assert.Equal(24, r.Severity); + Assert.Equal(2, r.State); + Assert.Equal( + "The operating system returned error 21 to SQL Server during a read at offset 0x00000c60000 in file 'D:\\data\\prod.mdf'.", + r.Message); + Assert.Equal(6, r.DatabaseId); + // DB_NAME(database_id) is a server-side resolution a DB-free shred cannot do — always null here. + Assert.Null(r.DatabaseName); + } + + // ── MemoryConditions (sp_server_diagnostics_component_result / RESOURCE) ── + + [Fact] + public void MemoryConditions_ShredsResourceAttributesAndReportName() + { + var r = SystemHealthParser.ParseMemoryConditions(LoadFixture("sp_server_diagnostics_resource.xml")); + + Assert.NotNull(r); + Assert.Equal(ResourceTime, r!.EventTime); + Assert.Equal("RESOURCE_MEMPHYSICAL_HIGH", r.LastNotification); + Assert.Equal(0L, r.OutOfMemoryExceptions); + Assert.False(r.IsAnyPoolOutOfMemory); + Assert.Equal(0L, r.ProcessOutOfMemoryPeriod); + Assert.Equal("Process/System Counts", r.Name); // first memoryReport/@name + } + + [Fact] + public void MemoryConditions_ByteEntries_DivideBy1024Cubed() + { + var r = SystemHealthParser.ParseMemoryConditions(LoadFixture("sp_server_diagnostics_resource.xml"))!; + + Assert.Equal(8L, r.AvailablePhysicalMemoryGb); // 8589934592 B / 1024^3 + Assert.Equal(16L, r.AvailableVirtualMemoryGb); // 17179869184 B / 1024^3 + Assert.Equal(4L, r.AvailablePagingFileGb); // 4294967296 B / 1024^3 + Assert.Equal(2L, r.WorkingSetGb); // 2147483648 B / 1024^3 + } + + [Fact] + public void MemoryConditions_KbEntries_DivideBy1024Squared_Truncating() + { + var r = SystemHealthParser.ParseMemoryConditions(LoadFixture("sp_server_diagnostics_resource.xml"))!; + + Assert.Equal(8L, r.VmReservedGb); // 8388608 KB / 1024^2 + Assert.Equal(4L, r.VmCommittedGb); // 4194304 KB / 1024^2 + Assert.Equal(6L, r.TargetCommittedGb); // 6291456 KB / 1024^2 + Assert.Equal(4L, r.CurrentCommittedGb); // 4194304 KB / 1024^2 + // Sub-GB KB values truncate to 0, exactly like sp_HealthParser's bigint integer division. + Assert.Equal(0L, r.EmergencyMemoryGb); // 1024 KB / 1024^2 -> 0 + Assert.Equal(0L, r.EmergencyMemoryInUseGb); // 16 KB / 1024^2 -> 0 + } + + [Fact] + public void MemoryConditions_RawEntries_NotConverted() + { + var r = SystemHealthParser.ParseMemoryConditions(LoadFixture("sp_server_diagnostics_resource.xml"))!; + + Assert.Equal(99L, r.PercentOfCommittedMemoryInWs); + Assert.Equal(123456L, r.PageFaults); + Assert.Equal(1L, r.SystemPhysicalMemoryHigh); + Assert.Equal(0L, r.SystemPhysicalMemoryLow); + Assert.Equal(0L, r.ProcessPhysicalMemoryLow); + Assert.Equal(0L, r.ProcessVirtualMemoryLow); + Assert.Equal(0L, r.LockedPagesAllocated); + Assert.Equal(0L, r.LargePagesAllocated); + Assert.Equal(524288L, r.PagesAllocated); + Assert.Equal(0L, r.PagesReserved); + Assert.Equal(1024L, r.PagesFree); + Assert.Equal(200000L, r.PagesInUse); + Assert.Equal(700000L, r.PageAllocPotential); + Assert.Equal(0L, r.NumaGrowthPhase); + Assert.Equal(0L, r.LastOomFactor); + Assert.Equal(0L, r.LastOsError); + } + + [Fact] + public void MemoryConditions_NonResourceComponent_YieldsNoRecord() + { + // A QUERY_PROCESSING sp_server_diagnostics component carries no node — no memory + // conditions to report (sp_HealthParser's CROSS APPLY /event/data/value/resource returns nothing). + var r = SystemHealthParser.ParseMemoryConditions(LoadFixture("sp_server_diagnostics_query_processing.xml")); + + Assert.Null(r); + } + + // ── MemoryBroker (memory_broker_ring_buffer_recorded) ── + + [Fact] + public void MemoryBroker_ShredsEveryColumn() + { + var r = SystemHealthParser.ParseMemoryBroker(LoadFixture("memory_broker.xml")); + + Assert.NotNull(r); + Assert.Equal(BrokerTime, r!.EventTime); + Assert.Equal(1L, r.BrokerId); // data[@name="id"] + Assert.Equal(0L, r.PoolMetadataId); + Assert.Equal(30000L, r.DeltaTime); + Assert.Equal(85L, r.MemoryRatio); + Assert.Equal(1048576L, r.NewTarget); + Assert.Equal(2097152L, r.Overall); + Assert.Equal(512L, r.Rate); + Assert.Equal(900000L, r.CurrentlyPredicated); + Assert.Equal(1000000L, r.CurrentlyAllocated); + Assert.Equal(1100000L, r.PreviouslyAllocated); + Assert.Equal("MEMORYBROKER_FOR_CACHE", r.Broker); + Assert.Equal("RESOURCE_MEMPHYSICAL_HIGH", r.Notification); + } + + // ── MemoryNodeOOM (memory_node_oom_ring_buffer_recorded) ── + + [Fact] + public void MemoryNodeOom_ShredsEveryColumn() + { + var r = SystemHealthParser.ParseMemoryNodeOom(LoadFixture("memory_node_oom.xml")); + + Assert.NotNull(r); + Assert.Equal(OomTime, r!.EventTime); + Assert.Equal(0L, r.NodeId); // data[@name="id"] + Assert.Equal(0L, r.MemoryNodeId); + Assert.Equal(72L, r.MemoryUtilizationPct); + Assert.Equal(16777216L, r.TotalPhysicalMemoryKb); + Assert.Equal(4194304L, r.AvailablePhysicalMemoryKb); + Assert.Equal(25165824L, r.TotalPageFileKb); + Assert.Equal(8388608L, r.AvailablePageFileKb); + Assert.Equal(137438953472L, r.TotalVirtualAddressSpaceKb); // > int32 — proves long handling + Assert.Equal(137000000000L, r.AvailableVirtualAddressSpaceKb); + Assert.Equal(12582912L, r.TargetKb); + Assert.Equal(1048576L, r.ReservedKb); + Assert.Equal(10485760L, r.CommittedKb); + Assert.Equal(2048m, r.SharedCommittedKb); // numeric(38,0) -> decimal + Assert.Equal(0L, r.AweKb); + Assert.Equal(9437184L, r.PagesKb); + Assert.Equal("NUMA_NODE_UNAVAILABLE", r.FailureType); // data[@name="failure"]/text + Assert.Equal(2L, r.FailureValue); // data[@name="failure"]/value + Assert.Equal(1L, r.Resources); + Assert.Equal("FACTORY_PROCESS", r.FactorText); // data[@name="factor"]/text + Assert.Equal(3L, r.FactorValue); // data[@name="factor"]/value + Assert.Equal(0L, r.LastError); + Assert.Equal(0L, r.PoolMetadataId); + // is_* flags kept as raw text (nvarchar(10)), exactly as sp_HealthParser stores them. + Assert.Equal("true", r.IsProcessInJob); + Assert.Equal("false", r.IsSystemPhysicalMemoryHigh); + Assert.Equal("true", r.IsSystemPhysicalMemoryLow); + Assert.Equal("false", r.IsProcessPhysicalMemoryLow); + Assert.Equal("false", r.IsProcessVirtualMemoryLow); + } + + // ── SignificantWaits (wait_info) ── + + [Fact] + public void SignificantWait_ShredsEveryColumn() + { + var r = SystemHealthParser.ParseSignificantWait(LoadFixture("wait_info.xml")); + + Assert.NotNull(r); + Assert.Equal(WaitTime, r!.EventTime); + Assert.Equal("PAGEIOLATCH_SH", r.WaitType); // data[@name="wait_type"]/text + Assert.Equal(1500L, r.DurationMs); + Assert.Equal(12L, r.SignalDurationMs); + Assert.Equal("7:1:12345", r.WaitResource); + Assert.Equal(57, r.SessionId); // action[@name="session_id"] + Assert.Equal("SELECT * FROM dbo.big_table WHERE id = @p1;", r.QueryText); // action[@name="sql_text"] + } + + [Fact] + public void SignificantWait_CleansControlCharactersButKeepsTabCrLf() + { + // Real sql_text carries control chars (SQL Server serializes them as &#xNN; references). The parser + // tolerates them (CheckCharacters=false) and CleanSqlText replaces every control char EXCEPT tab + // (9), LF (10) and CR (13) with '?', exactly as sp_HealthParser's query_text REPLACE chain does. + const string xml = + "" + + "800" + + "SELECT1FROM t GO" + + ""; + + var r = SystemHealthParser.ParseSignificantWait(xml); + + Assert.NotNull(r); + Assert.Equal("SELECT?1?FROM\tt\r\nGO", r!.QueryText); + } + + // ── CpuTasks (sp_server_diagnostics_component_result / QUERY_PROCESSING) ── + + [Fact] + public void CpuTasks_WarningEvent_ShredsEveryColumn() + { + var r = SystemHealthParser.ParseCpuTasks(LoadFixture("sp_server_diagnostics_query_processing_warning.xml")); + + Assert.NotNull(r); + Assert.Equal(CpuWarningTime, r!.EventTime); + Assert.Equal("WARNING", r.State); // data[@name="state"]/text + Assert.Equal(960L, r.MaxWorkers); // attributes + Assert.Equal(500L, r.WorkersCreated); + Assert.Equal(20L, r.WorkersIdle); + Assert.Equal(1234L, r.TasksCompletedWithinInterval); + Assert.Equal(15L, r.PendingTasks); + Assert.Equal(8000L, r.OldestPendingTaskWaitingTime); + Assert.True(r.HasUnresolvableDeadlockOccurred); + Assert.True(r.HasDeadlockedSchedulersOccurred); + Assert.True(r.DidBlockingOccur); // blockingTasks/blocked-process-report present + } + + [Fact] + public void CpuTasks_CleanEvent_ShredsColumns_NoBlocking() + { + // The shared clean QUERY_PROCESSING fixture: CLEAN state, zero pending tasks, no blocking node. + var r = SystemHealthParser.ParseCpuTasks(LoadFixture("sp_server_diagnostics_query_processing.xml")); + + Assert.NotNull(r); + Assert.Equal(CpuCleanTime, r!.EventTime); + Assert.Equal("CLEAN", r.State); + Assert.Equal(512L, r.MaxWorkers); + Assert.Equal(40L, r.WorkersCreated); + Assert.Equal(12L, r.WorkersIdle); + Assert.Equal(100L, r.TasksCompletedWithinInterval); + Assert.Equal(0L, r.PendingTasks); + Assert.Equal(0L, r.OldestPendingTaskWaitingTime); + Assert.False(r.HasUnresolvableDeadlockOccurred); + Assert.False(r.HasDeadlockedSchedulersOccurred); + Assert.False(r.DidBlockingOccur); // .exist() over absent blocked-process-report -> false + } + + [Fact] + public void CpuTasks_NonQueryProcessingComponent_YieldsNoRecord() + { + // A RESOURCE sp_server_diagnostics component carries no node. + Assert.Null(SystemHealthParser.ParseCpuTasks(LoadFixture("sp_server_diagnostics_resource.xml"))); + } + + // ── IoIssues (sp_server_diagnostics_component_result / IO_SUBSYSTEM) ── + + [Fact] + public void IoIssues_ShredsEventAttributes_AndGroupsPendingRequestsByFileSummingDuration() + { + var rows = SystemHealthParser.ParseIoIssues(LoadFixture("sp_server_diagnostics_io_subsystem.xml")); + + // Three pendingRequests over two files -> two rows (the same-file pair is summed, per #io -> #i). + Assert.Equal(2, rows.Count); + Assert.All(rows, r => + { + Assert.Equal(IoTime, r.EventTime); + Assert.Equal("WARNING", r.State); // data[@name="state"]/text + Assert.Equal(2L, r.IoLatchTimeouts); // attributes, repeated per file row + Assert.Equal(5L, r.IntervalLongIos); + Assert.Equal(42L, r.TotalLongIos); + }); + + var mdf = rows.Single(r => r.LongestPendingRequestsFilePath == @"D:\data\prod.mdf"); + Assert.Equal(1500L, mdf.LongestPendingRequestsDurationMs); // 1000 + 500 summed + var ldf = rows.Single(r => r.LongestPendingRequestsFilePath == @"E:\log\prod.ldf"); + Assert.Equal(750L, ldf.LongestPendingRequestsDurationMs); + } + + [Fact] + public void IoIssues_NonIoSubsystemComponent_YieldsNoRows() + { + // RESOURCE / QUERY_PROCESSING components carry no node. + Assert.Empty(SystemHealthParser.ParseIoIssues(LoadFixture("sp_server_diagnostics_resource.xml"))); + Assert.Empty(SystemHealthParser.ParseIoIssues(LoadFixture("sp_server_diagnostics_query_processing.xml"))); + } + + [Fact] + public void IoIssues_IoSubsystemWithNoPendingRequest_YieldsNoRows() + { + // sp_HealthParser's WHERE duration IS NOT NULL: an IO_SUBSYSTEM event with no pending request is dropped. + const string xml = + "" + + "1CLEAN" + + "" + + "" + + ""; + + Assert.Empty(SystemHealthParser.ParseIoIssues(xml)); + } + + // ── SystemHealth (sp_server_diagnostics_component_result / SYSTEM) ── + + [Fact] + public void SystemHealth_ShredsEveryColumn() + { + var r = SystemHealthParser.ParseSystemHealth(LoadFixture("sp_server_diagnostics_system.xml")); + + Assert.NotNull(r); + Assert.Equal(SystemTime, r!.EventTime); + Assert.Equal("CLEAN", r.State); // data[@name="state"]/text + Assert.Equal(12345L, r.SpinlockBackoffs); // attributes + Assert.Equal("SOS_CACHESTORE", r.SickSpinlockType); + Assert.Equal("SOS_SUSPEND_QUEUE", r.SickSpinlockTypeAfterAv); + Assert.Equal(7L, r.LatchWarnings); + Assert.Equal(1L, r.IsAccessViolationOccurred); // bigint, not bit — matches the *_SystemHealth column + Assert.Equal(3L, r.WriteAccessViolationCount); + Assert.Equal(9L, r.TotalDumpRequests); + Assert.Equal(2L, r.IntervalDumpRequests); + Assert.Equal(4L, r.NonYieldingTasksReported); + Assert.Equal(654321L, r.PageFaults); + Assert.Equal(65L, r.SystemCpuUtilization); + Assert.Equal(40L, r.SqlCpuUtilization); + Assert.Equal(6L, r.BadPagesDetected); + Assert.Equal(5L, r.BadPagesFixed); + } + + [Fact] + public void SystemHealth_NonSystemComponent_YieldsNoRecord() + { + // RESOURCE / QUERY_PROCESSING / IO_SUBSYSTEM components carry no node — no system-health + // counters to report (parity with the CROSS APPLY over /event/data/value/system returning nothing). + Assert.Null(SystemHealthParser.ParseSystemHealth(LoadFixture("sp_server_diagnostics_resource.xml"))); + Assert.Null(SystemHealthParser.ParseSystemHealth(LoadFixture("sp_server_diagnostics_query_processing.xml"))); + Assert.Null(SystemHealthParser.ParseSystemHealth(LoadFixture("sp_server_diagnostics_io_subsystem.xml"))); + } + + // ── dispatch (ParseEvents / ParseInto) ── + + [Fact] + public void ParseEvents_DispatchesEachEventToItsCategory() + { + var events = new (string?, string?)[] + { + (SystemHealthParser.SchedulerMonitorEvent, LoadFixture("scheduler_monitor.xml")), + (SystemHealthParser.ErrorReportedEvent, LoadFixture("error_reported.xml")), + (SystemHealthParser.SpServerDiagnosticsEvent, LoadFixture("sp_server_diagnostics_system.xml")), + (SystemHealthParser.SpServerDiagnosticsEvent, LoadFixture("sp_server_diagnostics_resource.xml")), + (SystemHealthParser.SpServerDiagnosticsEvent, LoadFixture("sp_server_diagnostics_query_processing.xml")), + (SystemHealthParser.SpServerDiagnosticsEvent, LoadFixture("sp_server_diagnostics_io_subsystem.xml")), + (SystemHealthParser.MemoryBrokerEvent, LoadFixture("memory_broker.xml")), + (SystemHealthParser.MemoryNodeOomEvent, LoadFixture("memory_node_oom.xml")), + (SystemHealthParser.WaitInfoEvent, LoadFixture("wait_info.xml")), + }; + + var result = SystemHealthParser.ParseEvents(events); + + Assert.Single(result.SchedulerIssues); + Assert.Single(result.SevereErrors); + Assert.Single(result.MemoryBroker); + Assert.Single(result.MemoryNodeOom); + Assert.Single(result.SignificantWaits); + // Four sp_server_diagnostics events route by component: SYSTEM -> system health, + // RESOURCE -> memory conditions, QUERY_PROCESSING -> CPU tasks, IO_SUBSYSTEM -> I/O issues + // (two files -> two rows). Each component parser is a no-op on the other three components. + Assert.Single(result.SystemHealth); + Assert.Single(result.MemoryConditions); + Assert.Single(result.CpuTasks); + Assert.Equal(2, result.IoIssues.Count); + Assert.Equal(10, result.SchedulerIssues[0].SchedulerId); + Assert.Equal(823, result.SevereErrors[0].ErrorNumber); + Assert.Equal(6, result.SystemHealth[0].BadPagesDetected); + } + + [Fact] + public void ParseEvents_IgnoresUnknownEventTypes() + { + var events = new (string?, string?)[] + { + ("xml_deadlock_report", "1"), + ("connectivity_ring_buffer_recorded", ""), + }; + + var result = SystemHealthParser.ParseEvents(events); + + Assert.Empty(result.SchedulerIssues); + Assert.Empty(result.SevereErrors); + Assert.Empty(result.MemoryConditions); + Assert.Empty(result.MemoryBroker); + Assert.Empty(result.MemoryNodeOom); + Assert.Empty(result.SignificantWaits); + } + + [Fact] + public void ParseInto_FallsBackToEventNameWhenEventTypeMissing() + { + // event_type null -> dispatch on the XML's own @name attribute. + var result = new SystemHealthParseResult(); + + SystemHealthParser.ParseInto(null, LoadFixture("wait_info.xml"), result); + + Assert.Single(result.SignificantWaits); + Assert.Equal("PAGEIOLATCH_SH", result.SignificantWaits[0].WaitType); + } + + // ── robustness (mirrors DeadlockGraphParser's never-throw contract) ── + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData(" with none of the expected data nodes still shreds (pure port of the CROSS APPLY + // //event, which emits one row per event before any WHERE filter) — every field simply comes back null. + var r = SystemHealthParser.ParseMemoryBroker(""); + + Assert.NotNull(r); + Assert.Null(r!.EventTime); + Assert.Null(r.BrokerId); + Assert.Null(r.Broker); + Assert.Null(r.Notification); + } + + [Fact] + public void ParseEvents_NullSequence_ReturnsEmptyResult() + { + var result = SystemHealthParser.ParseEvents(null!); + + Assert.Empty(result.SchedulerIssues); + Assert.Empty(result.SignificantWaits); + } +} diff --git a/Darling/Darling.Tests/ThemeCompletenessTests.cs b/Darling/Darling.Tests/ThemeCompletenessTests.cs new file mode 100644 index 000000000..fd24f89ec --- /dev/null +++ b/Darling/Darling.Tests/ThemeCompletenessTests.cs @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using Xunit; + +namespace Darling.Tests; + +/// +/// Guards that the viewer is fully theme-able: the Dark, Light, and CoolBreeze dictionaries define the +/// SAME set of resource keys, and every theme token the viewer XAML actually references resolves in ALL +/// three. This is the automated backstop for "Light / CoolBreeze mode has no missing resources" — a token +/// that exists only in Dark (the historical failure mode: the app looked right in Dark only) fails here +/// instead of silently rendering a control unstyled after a theme switch. +/// +/// Text-parses the XAML (no WPF apartment / pack-URI loading needed) and locates the Viewer project via +/// — the tests read source that sits next to this file in the repo. +/// +public sealed class ThemeCompletenessTests +{ + private static readonly string[] ThemeRelativePaths = + { + "Themes/DarkTheme.xaml", + "Themes/LightTheme.xaml", + "Themes/CoolBreezeTheme.xaml", + }; + + /// Matches a named resource definition: x:Key="Name" where Name is a plain identifier + /// (skips system keys like x:Key="{x:Static SystemColors.HighlightBrushKey}"). + private static readonly Regex KeyDefinition = + new("x:Key=\"([A-Za-z_][A-Za-z0-9_]*)\"", RegexOptions.Compiled); + + /// Matches a simple resource reference: {DynamicResource Name} / {StaticResource Name}. + /// The identifier-only capture skips nested markup like {StaticResource {x:Type Button}}. + private static readonly Regex ResourceReference = + new(@"\{(?:Dynamic|Static)Resource\s+([A-Za-z_][A-Za-z0-9_]*)\s*\}", RegexOptions.Compiled); + + [Fact] + public void Light_and_CoolBreeze_define_the_same_keys_as_Dark() + { + var dark = KeysIn(ThemeRelativePaths[0]); + var light = KeysIn(ThemeRelativePaths[1]); + var coolBreeze = KeysIn(ThemeRelativePaths[2]); + + Assert.False(dark.Count == 0, "Parsed zero keys from DarkTheme.xaml — the test cannot find the theme files."); + + var missingFromLight = dark.Except(light).OrderBy(k => k, StringComparer.Ordinal).ToList(); + var extraInLight = light.Except(dark).OrderBy(k => k, StringComparer.Ordinal).ToList(); + Assert.True( + missingFromLight.Count == 0 && extraInLight.Count == 0, + $"LightTheme.xaml key set differs from DarkTheme.xaml.\n" + + $" Missing from Light (would render unstyled in Light): {Join(missingFromLight)}\n" + + $" Extra in Light (not in Dark): {Join(extraInLight)}"); + + var missingFromCool = dark.Except(coolBreeze).OrderBy(k => k, StringComparer.Ordinal).ToList(); + var extraInCool = coolBreeze.Except(dark).OrderBy(k => k, StringComparer.Ordinal).ToList(); + Assert.True( + missingFromCool.Count == 0 && extraInCool.Count == 0, + $"CoolBreezeTheme.xaml key set differs from DarkTheme.xaml.\n" + + $" Missing from CoolBreeze: {Join(missingFromCool)}\n" + + $" Extra in CoolBreeze: {Join(extraInCool)}"); + } + + [Fact] + public void Every_theme_token_referenced_in_viewer_xaml_resolves_in_all_three_themes() + { + var viewerDir = ViewerDirectory(); + var dark = KeysIn(ThemeRelativePaths[0]); + var light = KeysIn(ThemeRelativePaths[1]); + var coolBreeze = KeysIn(ThemeRelativePaths[2]); + + /* Only consider references that ARE theme tokens (defined in Dark) — this ignores locally-defined + window styles, ViewerDarkTheme aliases, and WPF system keys, so a real "token in Dark but not + Light/CoolBreeze" is the only thing that can fail here. */ + var offenders = new List(); + foreach (var xaml in Directory.EnumerateFiles(viewerDir, "*.xaml", SearchOption.AllDirectories)) + { + // Skip build output by whole path SEGMENT (obj/bin), not a substring: Path.Combine("obj", "") + // yields "obj" with no separator, so a Contains("obj") would also skip a legitimately-named + // XAML file such as ObjectBrowser.xaml. + if (HasBuildOutputSegment(xaml)) + { + continue; + } + + var text = File.ReadAllText(xaml); + foreach (Match m in ResourceReference.Matches(text)) + { + var key = m.Groups[1].Value; + if (!dark.Contains(key)) + { + continue; // not a theme token (local style / system / cross-assembly) + } + + if (!light.Contains(key)) + { + offenders.Add($"{Path.GetFileName(xaml)} references '{key}', which is missing from LightTheme.xaml"); + } + if (!coolBreeze.Contains(key)) + { + offenders.Add($"{Path.GetFileName(xaml)} references '{key}', which is missing from CoolBreezeTheme.xaml"); + } + } + } + + Assert.True(offenders.Count == 0, + "Theme tokens referenced by the viewer XAML do not resolve in every theme:\n " + + string.Join("\n ", offenders.Distinct())); + } + + private static HashSet KeysIn(string relativePath) + { + var path = Path.Combine(ViewerDirectory(), relativePath.Replace('/', Path.DirectorySeparatorChar)); + var text = File.ReadAllText(path); + return KeyDefinition.Matches(text).Select(m => m.Groups[1].Value).ToHashSet(StringComparer.Ordinal); + } + + /// True if any whole path segment is a build-output directory (obj/bin). Segment-based, not a + /// substring test — a plain Contains("obj") would false-positive on a file such as ObjectBrowser.xaml. + private static bool HasBuildOutputSegment(string path) => + path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + .Any(seg => seg.Equals("obj", StringComparison.OrdinalIgnoreCase) || + seg.Equals("bin", StringComparison.OrdinalIgnoreCase)); + + /// The Viewer project directory, resolved from this test file's compile-time path (it lives at + /// Darling/Darling.Tests/; the Viewer is the sibling Darling/PerformanceMonitor.Darling.Viewer/). + private static string ViewerDirectory([CallerFilePath] string thisFile = "") + { + var testDir = Path.GetDirectoryName(thisFile)!; + return Path.GetFullPath(Path.Combine(testDir, "..", "PerformanceMonitor.Darling.Viewer")); + } + + private static string Join(IReadOnlyCollection keys) => + keys.Count == 0 ? "(none)" : string.Join(", ", keys); +} diff --git a/Darling/Darling.Tests/TimescaleSupportTests.cs b/Darling/Darling.Tests/TimescaleSupportTests.cs new file mode 100644 index 000000000..20bf61834 --- /dev/null +++ b/Darling/Darling.Tests/TimescaleSupportTests.cs @@ -0,0 +1,524 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Storage; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the optional-TimescaleDB contract. Ungated: the hypertable scope is EXACTLY the shared +/// collector catalog (the registry/config/analysis tables can never sneak in), every +/// create_hypertable partitions by_range on the definition's own prefix time column +/// (collection_time almost everywhere; the config snapshots' capture_time) with if_not_exists + +/// migrate_data, compression segments by server_id, and the policy is the hardcoded 1-day +/// if_not_exists shape. Gated on DARLING_TEST_PG (the dev fixture has the extension): detect → +/// convert (idempotent) → a 40-day-old wait_stats row and a 70-day-old collection_log row are removed +/// by the drop_chunks-based purge (collection_log is a hypertable since V23) while a fresh row holds → +/// the compression policy applies idempotently and lands in timescaledb_information.jobs. +/// +/* Live-fixture tests share one Postgres store; the collection serializes them so + cross-test row churn (inserts/purges/deletes/chunk drops) cannot race another class. */ +[Collection("live-postgres")] +public sealed class TimescaleSupportTests +{ + /// Distinctive fake id — a real server_id is a storage-name hash, never this. + private const int TestServerId = -717171; + + [Fact] + public void HypertableScope_IsExactlyTheCollectorCatalog() + { + /* Scope = the catalog, table-for-table: 26 collector tables, nothing else. */ + Assert.Equal( + CollectorCatalog.All.Select(s => s.TargetTable).ToArray(), + TimescaleSupport.HypertableTables.Select(s => s.TargetTable).ToArray()); + + /* The registry/config/analysis tables stay plain: registries keep their PRIMARY KEYs + (which hypertables reject unless they include the partition column), and + analysis_findings — designed keyless so it COULD convert later — is a deliberate + not-yet. Widening the scope must consciously break this pin. */ + var hypertables = TimescaleSupport.HypertableTables.Select(s => s.TargetTable).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var excluded in new[] + { + "servers", + "config_alert_log", "config_edge_trigger_watermarks", "config_mute_rules", + "analysis_findings", "analysis_muted", "darling_schema_version", + }) + { + Assert.False(hypertables.Contains(excluded), $"'{excluded}' must never be converted to a hypertable"); + } + + /* collection_log IS a hypertable (since V23) but is deliberately NOT in the catalog: it is converted + + compressed DIRECTLY — authoritatively by EnsureCollectionLogHypertableAsync at runtime, plus a + best-effort V23-migration fast-path — and purged directly by DarlingRetention, so the catalog-driven + runtime loops (ConvertToHypertables / ApplyCompressionPolicy) must never touch it. Its +1 IS reflected + in the worker-sizing count, though (HypertableCount). */ + Assert.False(hypertables.Contains("collection_log"), + "collection_log must stay OUT of the collector catalog — it is converted directly, not via the catalog loop"); + Assert.Equal(TimescaleSupport.HypertableTables.Count + 1, TimescaleSupport.HypertableCount); + } + + [Fact] + public void CreateHypertableSql_PartitionsByEachDefinitionsOwnTimeColumn() + { + var byName = CollectorCatalog.All.ToDictionary(d => d.Name, StringComparer.OrdinalIgnoreCase); + + Assert.Equal( + "SELECT create_hypertable('wait_stats', by_range('collection_time', INTERVAL '1 days'), if_not_exists => true, migrate_data => true)", + TimescaleSupport.CreateHypertableSql(byName["wait_stats"])); + + /* The config snapshots partition on their capture_time, not collection_time. */ + Assert.Equal( + "SELECT create_hypertable('server_config', by_range('capture_time', INTERVAL '1 days'), if_not_exists => true, migrate_data => true)", + TimescaleSupport.CreateHypertableSql(byName["server_config"])); + Assert.Equal( + "SELECT create_hypertable('trace_flags', by_range('capture_time', INTERVAL '1 days'), if_not_exists => true, migrate_data => true)", + TimescaleSupport.CreateHypertableSql(byName["trace_flags"])); + + /* Every table: its own prefix time column, 1-day chunk interval, idempotent, and existing + plain-PG data migrates into chunks. */ + foreach (var schema in CollectorCatalog.All) + { + var sql = TimescaleSupport.CreateHypertableSql(schema); + Assert.Contains($"create_hypertable('{schema.TargetTable}', by_range('{schema.PrefixTimeColumnName}', INTERVAL '1 days')", sql, StringComparison.Ordinal); + Assert.Contains("if_not_exists => true", sql, StringComparison.Ordinal); + Assert.Contains("migrate_data => true", sql, StringComparison.Ordinal); + } + + /* collection_log's runtime conversion (the raw-name overload, since it has no ICollectorSchemaInfo) — + the AUTHORITATIVE path EnsureCollectionLogHypertableAsync runs, identical shape to the collectors. */ + Assert.Equal( + "SELECT create_hypertable('collection_log', by_range('collection_time', INTERVAL '1 days'), if_not_exists => true, migrate_data => true)", + TimescaleSupport.CreateHypertableSql(TimescaleSupport.CollectionLogTable, TimescaleSupport.CollectionLogTimeColumn)); + } + + [Fact] + public void CompressionSql_SegmentsByServerId_OneDayPolicy_IfNotExists() + { + var byName = CollectorCatalog.All.ToDictionary(d => d.Name, StringComparer.OrdinalIgnoreCase); + + Assert.Equal( + "ALTER TABLE wait_stats SET (timescaledb.compress, timescaledb.compress_segmentby = 'server_id')", + TimescaleSupport.EnableCompressionSql(byName["wait_stats"])); + Assert.Equal( + "SELECT add_compression_policy('wait_stats', compress_after => INTERVAL '1 days', if_not_exists => true)", + TimescaleSupport.AddCompressionPolicySql(byName["wait_stats"])); + + /* 1 day matches the 1-day chunk interval so chunks become compressible quickly, keeping the + managed store compact (#1458). */ + Assert.Equal(1, TimescaleSupport.CompressAfterDays); + + foreach (var schema in CollectorCatalog.All) + { + Assert.Contains("timescaledb.compress_segmentby = 'server_id'", + TimescaleSupport.EnableCompressionSql(schema), StringComparison.Ordinal); + Assert.Contains("if_not_exists => true", + TimescaleSupport.AddCompressionPolicySql(schema), StringComparison.Ordinal); + } + + /* collection_log gets the identical compression via the raw-name overloads (the runtime path). */ + Assert.Equal( + "ALTER TABLE collection_log SET (timescaledb.compress, timescaledb.compress_segmentby = 'server_id')", + TimescaleSupport.EnableCompressionSql(TimescaleSupport.CollectionLogTable)); + Assert.Equal( + "SELECT add_compression_policy('collection_log', compress_after => INTERVAL '1 days', if_not_exists => true)", + TimescaleSupport.AddCompressionPolicySql(TimescaleSupport.CollectionLogTable)); + } + + /* ---------------- compression-job self-heal (#1581) — pure predicate ---------------- */ + + private static readonly DateTime s_now = new(2026, 7, 19, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public void IsCompressionJobStuck_NextStartNegativeInfinity_IsStuck() + { + /* The dominant failure mode: next_start = -infinity, so the scheduler never re-fires it. Stuck + regardless of status/last-run — it will never run again. */ + Assert.True(TimescaleSupport.IsCompressionJobStuck( + nextStartIsNegativeInfinity: true, jobStatus: "Scheduled", lastRunStartedAtUtc: null, + scheduleInterval: TimeSpan.FromHours(12), nowUtc: s_now, out var reason)); + Assert.Contains("-infinity", reason, StringComparison.Ordinal); + } + + [Fact] + public void IsCompressionJobStuck_HealthyScheduled_IsNotStuck() + { + /* A normally scheduled job (finite next_start, not running) is healthy. */ + Assert.False(TimescaleSupport.IsCompressionJobStuck( + nextStartIsNegativeInfinity: false, jobStatus: "Scheduled", lastRunStartedAtUtc: s_now.AddMinutes(-5), + scheduleInterval: TimeSpan.FromHours(12), nowUtc: s_now, out var reason)); + Assert.Equal("", reason); + } + + [Fact] + public void IsCompressionJobStuck_RunningPastBound_IsStuck() + { + /* Running since well past max(2x interval, floor): 2x 1h = 2h == floor, and 5h elapsed > 2h -> hung. */ + Assert.True(TimescaleSupport.IsCompressionJobStuck( + nextStartIsNegativeInfinity: false, jobStatus: "Running", lastRunStartedAtUtc: s_now.AddHours(-5), + scheduleInterval: TimeSpan.FromHours(1), nowUtc: s_now, out var reason)); + Assert.Contains("Running", reason, StringComparison.Ordinal); + } + + [Fact] + public void IsCompressionJobStuck_RunningWithinBound_IsNotStuck() + { + /* Running for 10 minutes with a 12h interval (bound = 24h) — legitimately in progress, not stuck. */ + Assert.False(TimescaleSupport.IsCompressionJobStuck( + nextStartIsNegativeInfinity: false, jobStatus: "Running", lastRunStartedAtUtc: s_now.AddMinutes(-10), + scheduleInterval: TimeSpan.FromHours(12), nowUtc: s_now, out _)); + } + + [Fact] + public void IsCompressionJobStuck_RunningButNoStartTime_IsNotStuck() + { + /* Running with an unknown last_run_started_at cannot be judged as hung — do not false-flag. */ + Assert.False(TimescaleSupport.IsCompressionJobStuck( + nextStartIsNegativeInfinity: false, jobStatus: "Running", lastRunStartedAtUtc: null, + scheduleInterval: TimeSpan.FromHours(1), nowUtc: s_now, out _)); + } + + [Fact] + public void StuckRunningBound_UsesMaxOfTwiceIntervalAndFloor() + { + /* 2x a large interval wins over the floor. */ + Assert.Equal(TimeSpan.FromHours(24), TimescaleSupport.StuckRunningBound(TimeSpan.FromHours(12))); + /* The 2-hour floor wins over 2x a tiny interval. */ + Assert.Equal(TimeSpan.FromHours(2), TimescaleSupport.StuckRunningBound(TimeSpan.FromMinutes(1))); + /* A missing/zero interval falls back to the floor. */ + Assert.Equal(TimeSpan.FromHours(2), TimescaleSupport.StuckRunningBound(null)); + Assert.Equal(TimeSpan.FromHours(2), TimescaleSupport.StuckRunningBound(TimeSpan.Zero)); + } + + [Fact] + public void RearmJobSql_IsParameterized_NotInterpolated() + { + /* The job_id is ALWAYS bound as $1, never interpolated; next_start is SQL now(), not a value. */ + Assert.Equal("SELECT alter_job($1::integer, next_start => now())", TimescaleSupport.RearmJobSql); + Assert.Contains("$1", TimescaleSupport.RearmJobSql, StringComparison.Ordinal); + Assert.Contains("next_start => now()", TimescaleSupport.RearmJobSql, StringComparison.Ordinal); + } + + [Fact] + public async Task ReadStuckCompressionJobs_StoreUnavailable_ReturnsEmpty_DoesNotThrow() + { + /* Failure-isolated: when the timescaledb_information views are absent (a plain-PostgreSQL store — the + belt-and-suspenders behind the worker's _timescaleAvailable gate) or the store is unreachable, the + read returns an empty list and logs at Debug, NEVER throwing into the sweep loop. An unopened + connection exercises that catch deterministically without a live store. */ + using var connection = new NpgsqlConnection("Host=localhost;Port=1;Database=darling-does-not-exist"); + var result = await TimescaleSupport.ReadStuckCompressionJobsAsync( + connection, DateTime.UtcNow, logger: null, TestContext.Current.CancellationToken); + Assert.Empty(result); + } + + [Fact] + public async Task TryRearmJob_StoreUnavailable_ReturnsFalse_DoesNotThrow() + { + /* An alter_job failure (permission denied on a least-privilege BYO store, or a store hiccup) degrades to + false + a single log line — never a crash. An unopened connection stands in for any such failure. */ + using var connection = new NpgsqlConnection("Host=localhost;Port=1;Database=darling-does-not-exist"); + Assert.False(await TimescaleSupport.TryRearmJobAsync( + connection, jobId: 1234, logger: null, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task EndToEnd_DetectConvertAndDropChunksPurge_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string (with TimescaleDB installed) to run the live Timescale test."); + + var ct = TestContext.Current.CancellationToken; + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + + /* The dev fixture has the extension (validated live on 2.28.1): enable must succeed and + detection must agree. */ + Assert.True(await TimescaleSupport.TryEnableAsync(connection, null, ct), + "the dev fixture is expected to have TimescaleDB installed"); + Assert.True(await TimescaleSupport.DetectAsync(connection, ct)); + + /* Conversion covers every collector table and is idempotent (if_not_exists no-ops). */ + Assert.Equal(CollectorCatalog.All.Count, await TimescaleSupport.ConvertToHypertablesAsync(connection, null, ct)); + Assert.Equal(CollectorCatalog.All.Count, await TimescaleSupport.ConvertToHypertablesAsync(connection, null, ct)); + + /* wait_stats really is a hypertable now — so the purge below genuinely exercises + drop_chunks, not the per-table DELETE fallback. */ + using (var isHypertable = new NpgsqlCommand( + "SELECT COUNT(*) FROM timescaledb_information.hypertables WHERE hypertable_name = 'wait_stats'", connection)) + { + Assert.Equal(1L, await isHypertable.ExecuteScalarAsync(ct)); + } + + /* collection_log is ALSO a hypertable now — but NOT via ConvertToHypertablesAsync (it is outside the + collector catalog). The V23 migration converts it only on an upgrade where the extension already + exists; on a store whose migrations ran BEFORE CREATE EXTENSION (this shared test database, and any + fresh managed store) V23's guard skips and the AUTHORITATIVE runtime path is + EnsureCollectionLogHypertableAsync — the same call the service makes right after TryEnableAsync on + every start. Exercise it exactly like the service does, then the purge below genuinely hits + drop_chunks, not the DELETE fallback. */ + Assert.True(await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, null, ct), + "EnsureCollectionLogHypertableAsync is expected to convert (or no-op on) collection_log once the extension is enabled"); + + using (var logIsHypertable = new NpgsqlCommand( + "SELECT COUNT(*) FROM timescaledb_information.hypertables WHERE hypertable_name = 'collection_log'", connection)) + { + Assert.Equal(1L, await logIsHypertable.ExecuteScalarAsync(ct)); + } + + /* Clear leftovers from an earlier aborted run so the assertions below are deterministic. */ + await DeleteTestRowsAsync(connection); + + await using var postgres = NpgsqlDataSource.Create(connectionString!); + + try + { + /* All timestamps Kind-Unspecified — naive-UTC storage, see PgCollectorRowWriter. */ + var utcNow = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified); + + /* wait_stats retention is 30 days. The old row is 40 days back so its WHOLE chunk + (7-day default width → spanning at most now-43d..now-36d) is past the horizon — + drop_chunks only drops fully-expired chunks. The fresh row lives in the current + chunk, which can never be fully expired. */ + using (var insert = new NpgsqlCommand( + "INSERT INTO wait_stats (collection_id, collection_time, server_id, server_name) VALUES ($1, $2, $3, $4)", connection)) + { + insert.Parameters.AddWithValue(1L); + insert.Parameters.AddWithValue(utcNow.AddDays(-40)); + insert.Parameters.AddWithValue(TestServerId); + insert.Parameters.AddWithValue("timescale-e2e"); + await insert.ExecuteNonQueryAsync(ct); + } + + using (var insert = new NpgsqlCommand( + "INSERT INTO wait_stats (collection_id, collection_time, server_id, server_name) VALUES ($1, $2, $3, $4)", connection)) + { + insert.Parameters.AddWithValue(2L); + insert.Parameters.AddWithValue(utcNow.AddHours(-1)); + insert.Parameters.AddWithValue(TestServerId); + insert.Parameters.AddWithValue("timescale-e2e"); + await insert.ExecuteNonQueryAsync(ct); + } + + /* collection_log is a hypertable since V23, so in Timescale mode it purges via drop_chunks too. + drop_chunks only drops WHOLE expired chunks, so this row must be past collection_log's own 2x + horizon (60 days) for its 1-day chunk to be fully expired: 70 days back. (A row inside the 60-day + window would survive — exercised on the plain-PG DELETE path in DarlingRetentionTests.) */ + using (var insert = new NpgsqlCommand( + "INSERT INTO collection_log (log_id, server_id, server_name, collector_name, collection_time, status) VALUES ($1, $2, $3, $4, $5, $6)", connection)) + { + insert.Parameters.AddWithValue(1L); + insert.Parameters.AddWithValue(TestServerId); + insert.Parameters.AddWithValue("timescale-e2e"); + insert.Parameters.AddWithValue("wait_stats"); + insert.Parameters.AddWithValue(utcNow.AddDays(-70)); + insert.Parameters.AddWithValue("SUCCESS"); + await insert.ExecuteNonQueryAsync(ct); + } + + /* The Timescale purge. Deliberately NO assertion on the returned global activity count + (#1564): chunk drops are per-table + time-window across the WHOLE shared store, so sibling + collection classes' rows make the global number order-dependent. The contract is the + OWN-SCOPED evidence below: this server's fresh row survives, its old rows are gone — plus + the is-hypertable assertions above proving the drop_chunks branch was in play. If + drop_chunks transiently fails (e.g. a lock clash with the shared fixture's compression + policy jobs, which run mid-suite), the time-sliced DELETE fallback now clears the rows even + inside a compressed chunk — the capturing logger surfaces any such fallback in the failure + text instead of silencing it (a silent skip was #1564's whole failure mode). */ + var purgeLog = new CapturingTestLogger(); + await DarlingRetention.PurgeAsync(postgres, timescaleAvailable: true, purgeLog, ct); + + using (var read = new NpgsqlCommand( + "SELECT collection_time FROM wait_stats WHERE server_id = $1", connection)) + { + read.Parameters.AddWithValue(TestServerId); + using var reader = await read.ExecuteReaderAsync(ct); + Assert.True(await reader.ReadAsync(ct), $"the fresh wait_stats row did not survive the drop_chunks purge; {purgeLog.Joined}"); + var survivor = reader.GetDateTime(0); + Assert.True(survivor > utcNow.AddDays(-1), $"the surviving row should be the 1-hour one, got {survivor:O}; {purgeLog.Joined}"); + Assert.False(await reader.ReadAsync(ct), $"the 40-day wait_stats row survived the drop_chunks purge; {purgeLog.Joined}"); + } + + /* The 70-day collection_log row went — via drop_chunks (past the 60-day horizon), or via the + DELETE fallback if drop_chunks transiently failed. */ + using (var read = new NpgsqlCommand( + "SELECT COUNT(*) FROM collection_log WHERE server_id = $1", connection)) + { + read.Parameters.AddWithValue(TestServerId); + var remaining = (long)(await read.ExecuteScalarAsync(ct))!; + Assert.True(remaining == 0L, $"the 70-day collection_log row survived the purge ({remaining} row(s)); {purgeLog.Joined}"); + } + } + finally + { + await DeleteTestRowsAsync(connection); + } + } + + [Fact] + public async Task EndToEnd_CompressionPolicyApplies_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string (with TimescaleDB installed) to run the live compression test."); + + var ct = TestContext.Current.CancellationToken; + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + + Assert.True(await TimescaleSupport.TryEnableAsync(connection, null, ct), + "the dev fixture is expected to have TimescaleDB installed"); + + /* Compression needs hypertables first — idempotent, so safe regardless of test order. */ + Assert.Equal(CollectorCatalog.All.Count, await TimescaleSupport.ConvertToHypertablesAsync(connection, null, ct)); + + /* Applies cleanly and idempotently (the second pass re-runs ALTER SET and the policy + no-ops on if_not_exists). */ + Assert.Equal(CollectorCatalog.All.Count, await TimescaleSupport.ApplyCompressionPolicyAsync(connection, null, ct)); + Assert.Equal(CollectorCatalog.All.Count, await TimescaleSupport.ApplyCompressionPolicyAsync(connection, null, ct)); + + /* The background job really exists. proc_name is 'policy_compression' on the long-stable + API; the LIKE also tolerates the 2.18+ columnstore rebrand's naming. */ + using (var job = new NpgsqlCommand(@" +SELECT COUNT(*) +FROM timescaledb_information.jobs +WHERE hypertable_name = 'wait_stats' + AND (proc_name LIKE '%compression%' OR proc_name LIKE '%columnstore%')", connection)) + { + var jobs = (long)(await job.ExecuteScalarAsync(ct))!; + Assert.True(jobs >= 1, "expected a compression policy job on wait_stats in timescaledb_information.jobs"); + } + + /* Deliberately NO policy removal on cleanup: the applied policies are the service's + real end state on this fixture, and if_not_exists keeps every rerun a no-op. */ + } + + [Fact] + public async Task CompressionJobSelfHeal_DetectionQueryValid_AndRearmSucceeds_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string (with TimescaleDB installed) to run the live compression self-heal test."); + + var ct = TestContext.Current.CancellationToken; + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + + Assert.True(await TimescaleSupport.TryEnableAsync(connection, null, ct), + "the dev fixture is expected to have TimescaleDB installed"); + Assert.Equal(CollectorCatalog.All.Count, await TimescaleSupport.ConvertToHypertablesAsync(connection, null, ct)); + Assert.Equal(CollectorCatalog.All.Count, await TimescaleSupport.ApplyCompressionPolicyAsync(connection, null, ct)); + + /* Pick one real compression policy job (on wait_stats). */ + long jobId; + using (var find = new NpgsqlCommand(@" +SELECT j.job_id +FROM timescaledb_information.jobs AS j +WHERE j.hypertable_name = 'wait_stats' + AND (j.proc_name LIKE '%compression%' OR j.proc_name LIKE '%columnstore%') +ORDER BY j.job_id +LIMIT 1", connection)) + { + var result = await find.ExecuteScalarAsync(ct); + Assert.NotNull(result); + jobId = Convert.ToInt64(result, System.Globalization.CultureInfo.InvariantCulture); + } + + /* (1) The detection query is valid SQL against the REAL timescaledb_information job_stats/jobs views + (including the `next_start = '-infinity'::timestamptz` comparison), and a healthy compression job is + NOT flagged — no false alarm. This is the full ReadStuckCompressionJobsAsync path against the live + schema. + + A just-added compression policy job can momentarily read next_start = '-infinity' in job_stats + BEFORE TimescaleDB's background scheduler assigns its first real next run — and the detector is + CORRECT to flag -infinity — so asserting immediately after ApplyCompressionPolicy raced that window + and intermittently false-failed on a slow CI runner. Deterministically settle the job into the + healthy state the assertion is actually about: give it a real FUTURE next_start (via the same + alter_job the self-heal uses), then wait for the catalog to reflect a non-(-infinity) next_start. */ + using (var arm = new NpgsqlCommand("SELECT alter_job($1::integer, next_start => now() + interval '1 hour')", connection)) + { + arm.Parameters.Add(new NpgsqlParameter { Value = jobId }); + await arm.ExecuteNonQueryAsync(ct); + } + await WaitForSettledNextStartAsync(connection, jobId, ct); + + var healthy = await TimescaleSupport.ReadStuckCompressionJobsAsync(connection, DateTime.UtcNow, null, ct); + Assert.DoesNotContain(healthy, s => s.JobId == jobId); + + /* (2) The #1586 REGRESSION GUARD: the production re-arm runs the real alter_job against TimescaleDB and + SUCCEEDS. The job_id MUST be sent as `integer`, not `bigint` — an un-cast bound long fails with + `42883: function alter_job(bigint, ...) does not exist`, which shipped in #1585 and made every + self-heal re-arm silently throw. This drives the exact production path (TryRearmJobAsync -> + RearmJobSql `alter_job($1::integer, next_start => now())`), which the unit tests — using a fake re-arm + delegate — cannot reach. + + We deliberately do NOT simulate the stuck state by forcing next_start to -infinity: TimescaleDB + REJECTS `alter_job(..., next_start => '-infinity')` with `22023: cannot set next start to -infinity` + (the dead-scheduler -infinity arises from TimescaleDB's own background scheduler on a failed run, not + from a user call, so it cannot be injected through the public API). The -infinity / Running-past-bound + DETECTION logic is covered by the pure IsCompressionJobStuck unit tests. */ + Assert.True(await TimescaleSupport.TryRearmJobAsync(connection, jobId, null, ct)); + + /* (3) After a real re-arm (next_start => now()) the job is scheduled/running within bound, not stuck. */ + var afterRearm = await TimescaleSupport.ReadStuckCompressionJobsAsync(connection, DateTime.UtcNow, null, ct); + Assert.DoesNotContain(afterRearm, s => s.JobId == jobId); + } + + /// + /// Wait until a compression policy job's next_start in timescaledb_information.job_stats has + /// settled to a real value (not -infinity, and a row exists). A just-added or just-altered + /// TimescaleDB job can momentarily read next_start = '-infinity' before its background scheduler + /// assigns the real next run; the stuck-job detector is CORRECT to flag -infinity, so a live test + /// asserting "healthy, not flagged" must first let that transient window close — else it races the + /// scheduler and false-fails intermittently on a slow runner. Bounded; fails loudly if it never settles + /// (a job that stays -infinity IS genuinely stuck and the assertion should not silently pass). + /// + private static async Task WaitForSettledNextStartAsync(NpgsqlConnection connection, long jobId, System.Threading.CancellationToken ct) + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30); + while (true) + { + using var probe = new NpgsqlCommand( + "SELECT next_start = '-infinity'::timestamptz FROM timescaledb_information.job_stats WHERE job_id = $1::integer", + connection); + probe.Parameters.Add(new NpgsqlParameter { Value = jobId }); + var negInfinity = await probe.ExecuteScalarAsync(ct); + + /* A present, non-(-infinity) next_start means the scheduler settled the job into its scheduled + state. A null result (no job_stats row yet) is also "not settled yet" — keep waiting. */ + if (negInfinity is bool isNegInfinity && !isNegInfinity) + { + return; + } + + Assert.True(DateTime.UtcNow < deadline, + $"compression job {jobId} never settled to a real next_start within 30s (stayed at -infinity)."); + await Task.Delay(250, ct); + } + } + + private static async Task DeleteTestRowsAsync(NpgsqlConnection connection) + { + using var cleanup = new NpgsqlCommand( + $"DELETE FROM wait_stats WHERE server_id = {TestServerId}; DELETE FROM collection_log WHERE server_id = {TestServerId};", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } +} diff --git a/Darling/Darling.Tests/ViewerAlertStateServiceTests.cs b/Darling/Darling.Tests/ViewerAlertStateServiceTests.cs new file mode 100644 index 000000000..b0e423274 --- /dev/null +++ b/Darling/Darling.Tests/ViewerAlertStateServiceTests.cs @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.IO; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the viewer's per-server alert-badge acknowledgement state machine () — +/// the ported "ack-until-worse" half of Lite's AlertStateService (mirrors Lite.Tests/AlertBadgeAckTests): +/// an acknowledgement suppresses the badge until a genuinely NEWER alert arrives, at which point it clears and the +/// badge re-lights. The clock and the state file are injected so the timestamp seam is deterministic and no test +/// leaks state to another (each uses a unique temp file). +/// +public sealed class ViewerAlertStateServiceTests : IDisposable +{ + private static readonly DateTime AckAt = new(2026, 7, 8, 12, 0, 0, DateTimeKind.Utc); + private readonly string _statePath = Path.Combine(Path.GetTempPath(), $"darling-ack-{Guid.NewGuid():N}.json"); + + private ViewerAlertStateService NewService(DateTime? ackClock = null) => + new(_statePath, () => ackClock ?? AckAt); + + public void Dispose() + { + try { File.Delete(_statePath); } catch { /* best effort */ } + } + + [Fact] + public void FreshServer_WithAlerts_ShowsBadge() + { + var svc = NewService(); + + Assert.True(svc.ShouldShowAlerts(1)); + Assert.True(svc.UpdateAlertState(1, alertCount: 2, latestEventTimeUtc: AckAt)); + } + + [Fact] + public void NoAlerts_NeverShowsBadge() + { + var svc = NewService(); + + Assert.False(svc.UpdateAlertState(1, alertCount: 0, latestEventTimeUtc: null)); + } + + [Fact] + public void Acknowledge_SuppressesBadge_ForTheSameAndOlderEvents() + { + var svc = NewService(); + + /* The badge is lit... */ + Assert.True(svc.UpdateAlertState(1, alertCount: 3, latestEventTimeUtc: AckAt)); + + /* ...the operator acknowledges it. */ + svc.AcknowledgeAlert(1); + Assert.False(svc.ShouldShowAlerts(1)); + Assert.True(svc.IsAcknowledged(1)); + + /* A re-read of the SAME rows (latest event == the ack instant) stays suppressed... */ + Assert.False(svc.UpdateAlertState(1, alertCount: 3, latestEventTimeUtc: AckAt)); + + /* ...and so does an OLDER event still lingering in the rolling window. */ + Assert.False(svc.UpdateAlertState(1, alertCount: 3, latestEventTimeUtc: AckAt.AddMinutes(-5))); + } + + [Fact] + public void Acknowledge_ThenNewerAlert_ClearsAck_AndRelights() + { + var svc = NewService(); + + svc.AcknowledgeAlert(1); + Assert.False(svc.ShouldShowAlerts(1)); + + /* A genuinely newer alert (alert_time after the ack) worsens the condition -> ack clears, badge shows. */ + Assert.True(svc.UpdateAlertState(1, alertCount: 1, latestEventTimeUtc: AckAt.AddMinutes(1))); + Assert.True(svc.ShouldShowAlerts(1)); + Assert.False(svc.IsAcknowledged(1)); + } + + [Fact] + public void Acknowledge_IsPerServer_AndDoesNotSuppressOthers() + { + var svc = NewService(); + + svc.AcknowledgeAlert(1); + + Assert.False(svc.UpdateAlertState(1, alertCount: 2, latestEventTimeUtc: AckAt)); + Assert.True(svc.UpdateAlertState(2, alertCount: 2, latestEventTimeUtc: AckAt)); + } + + [Fact] + public void AcknowledgeAlert_RaisesSuppressionStateChanged() + { + var svc = NewService(); + var fired = false; + svc.SuppressionStateChanged += (_, _) => fired = true; + + svc.AcknowledgeAlert(1); + + Assert.True(fired); + } + + [Fact] + public void ClearAcknowledgement_WhenAcknowledged_Fires_AndRelights() + { + var svc = NewService(); + svc.AcknowledgeAlert(1); + + var fired = false; + svc.SuppressionStateChanged += (_, _) => fired = true; + + svc.ClearAcknowledgement(1); + + Assert.True(fired); + Assert.True(svc.ShouldShowAlerts(1)); + Assert.True(svc.UpdateAlertState(1, alertCount: 1, latestEventTimeUtc: AckAt)); + } + + [Fact] + public void ClearAcknowledgement_WhenNothingAcknowledged_DoesNotFire() + { + var svc = NewService(); + var fired = false; + svc.SuppressionStateChanged += (_, _) => fired = true; + + svc.ClearAcknowledgement(1); + + Assert.False(fired); + } + + [Fact] + public void RemoveServerState_ClearsTheAcknowledgement() + { + var svc = NewService(); + svc.AcknowledgeAlert(1); + Assert.True(svc.IsAcknowledged(1)); + + svc.RemoveServerState(1); + + Assert.False(svc.IsAcknowledged(1)); + Assert.True(svc.UpdateAlertState(1, alertCount: 1, latestEventTimeUtc: AckAt)); + } + + [Fact] + public void Acknowledgement_SurvivesRestart_ViaTheStateFile() + { + /* First instance acknowledges and persists. */ + var first = new ViewerAlertStateService(_statePath, () => AckAt); + first.AcknowledgeAlert(1); + + /* A fresh instance over the SAME file loads the ack, so a restart doesn't re-light an acknowledged badge + for events still in the rolling window. */ + var second = new ViewerAlertStateService(_statePath, () => AckAt); + Assert.True(second.IsAcknowledged(1)); + Assert.False(second.UpdateAlertState(1, alertCount: 2, latestEventTimeUtc: AckAt)); + + /* ...but a newer event after the restart still clears it. */ + Assert.True(second.UpdateAlertState(1, alertCount: 2, latestEventTimeUtc: AckAt.AddMinutes(10))); + } +} diff --git a/Darling/Darling.Tests/ViewerAlertToastCoordinatorTests.cs b/Darling/Darling.Tests/ViewerAlertToastCoordinatorTests.cs new file mode 100644 index 000000000..618724075 --- /dev/null +++ b/Darling/Darling.Tests/ViewerAlertToastCoordinatorTests.cs @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the two load-bearing seams of the viewer's headless in-app alert toasts — the "new since last-seen" +/// watermark and the per-condition cooldown gate in — without WPF, +/// Postgres, or a real clock. The tray rendering itself (Hardcodet balloons) is not unit-testable; this +/// covers all the decision logic that decides IF and WHICH polled alert rows become toasts. +/// +public sealed class ViewerAlertToastCoordinatorTests +{ + private static readonly DateTime T0 = new(2026, 7, 7, 12, 0, 0, DateTimeKind.Utc); + private static readonly TimeSpan Retention = TimeSpan.FromMinutes(20); + private static readonly TimeSpan Cooldown = TimeSpan.FromMinutes(5); + + private static ViewerAlertRow Row( + DateTime alertTime, int serverId, string metric, bool muted = false, string? detail = null) => new() + { + AlertTime = alertTime, + ServerId = serverId, + ServerName = $"Server{serverId}", + MetricName = metric, + CurrentValue = 90, + ThresholdValue = 80, + AlertSent = true, + NotificationType = "tray", + Muted = muted, + DetailText = detail, + }; + + [Fact] + public void Ctor_RejectsNonPositiveRetention() + { + Assert.Throws(() => new AlertToastCoordinator(TimeSpan.Zero)); + Assert.Throws(() => new AlertToastCoordinator(TimeSpan.FromMinutes(-1))); + } + + [Fact] + public void Prime_SuppressesRowsThatExistedAtStartup() + { + var coordinator = new AlertToastCoordinator(Retention); + var existing = new[] { Row(T0, 1, "High CPU"), Row(T0.AddMinutes(1), 2, "Blocking Detected") }; + coordinator.Prime(existing); + + /* Re-polling the exact rows that were present at startup must produce no toasts. */ + var toasts = coordinator.SelectToasts(existing, T0.AddMinutes(2), Cooldown); + + Assert.Empty(toasts); + } + + [Fact] + public void SelectToasts_NewRowAfterPrime_IsToasted() + { + var coordinator = new AlertToastCoordinator(Retention); + coordinator.Prime(new[] { Row(T0, 1, "High CPU") }); + + var fresh = Row(T0.AddMinutes(1), 2, "Deadlocks Detected"); + var toasts = coordinator.SelectToasts( + new[] { Row(T0, 1, "High CPU"), fresh }, T0.AddMinutes(1), Cooldown); + + Assert.Single(toasts); + Assert.Same(fresh, toasts[0]); + } + + [Fact] + public void SelectToasts_SameRowAcrossTwoPolls_ToastsExactlyOnce() + { + var coordinator = new AlertToastCoordinator(Retention); + var row = Row(T0, 1, "High CPU"); + + var first = coordinator.SelectToasts(new[] { row }, T0, Cooldown); + var second = coordinator.SelectToasts(new[] { row }, T0.AddMinutes(1), Cooldown); + + Assert.Single(first); + Assert.Empty(second); + } + + [Fact] + public void SelectToasts_MutedRow_IsNeverToasted() + { + var coordinator = new AlertToastCoordinator(Retention); + var muted = Row(T0, 1, "High CPU", muted: true); + + var toasts = coordinator.SelectToasts(new[] { muted }, T0, Cooldown); + + Assert.Empty(toasts); + } + + [Fact] + public void SelectToasts_MutedRow_IsMarkedSeen_SoAnUnmuteInTheStoreDoesNotReplay() + { + var coordinator = new AlertToastCoordinator(Retention); + var muted = Row(T0, 1, "High CPU", muted: true); + coordinator.SelectToasts(new[] { muted }, T0, Cooldown); + + /* Same identity re-read later (even if it came back unmuted) stays suppressed — the row was + already considered. */ + var unmutedSameIdentity = Row(T0, 1, "High CPU"); + var toasts = coordinator.SelectToasts(new[] { unmutedSameIdentity }, T0.AddMinutes(1), Cooldown); + + Assert.Empty(toasts); + } + + [Fact] + public void SelectToasts_TwoDistinctRowsOfSameCondition_WithinCooldown_ToastsOnlyTheFirst() + { + var coordinator = new AlertToastCoordinator(Retention); + var earlier = Row(T0, 1, "High CPU"); + var later = Row(T0.AddMinutes(1), 1, "High CPU"); /* distinct row, same server+metric */ + + var toasts = coordinator.SelectToasts(new[] { earlier, later }, T0.AddMinutes(1), Cooldown); + + Assert.Single(toasts); + Assert.Same(earlier, toasts[0]); /* oldest-first: the earliest wins the cooldown slot */ + } + + [Fact] + public void SelectToasts_ProcessesOldestFirst_RegardlessOfInputOrder() + { + var coordinator = new AlertToastCoordinator(Retention); + var earlier = Row(T0, 1, "High CPU"); + var later = Row(T0.AddMinutes(1), 1, "High CPU"); + + /* Newest-first input (the read's ORDER BY alert_time DESC) must still admit the OLDEST. */ + var toasts = coordinator.SelectToasts(new[] { later, earlier }, T0.AddMinutes(1), Cooldown); + + Assert.Single(toasts); + Assert.Same(earlier, toasts[0]); + } + + [Fact] + public void SelectToasts_DifferentConditions_BothToastWithinCooldown() + { + var coordinator = new AlertToastCoordinator(Retention); + var cpuOnA = Row(T0, 1, "High CPU"); + var cpuOnB = Row(T0, 2, "High CPU"); /* different server */ + var blockingOnA = Row(T0, 1, "Blocking Detected"); /* different metric */ + + var toasts = coordinator.SelectToasts(new[] { cpuOnA, cpuOnB, blockingOnA }, T0, Cooldown); + + Assert.Equal(3, toasts.Count); + } + + [Fact] + public void SelectToasts_CooldownElapsed_ReToastsSameCondition() + { + var coordinator = new AlertToastCoordinator(Retention); + var first = coordinator.SelectToasts(new[] { Row(T0, 1, "High CPU") }, T0, Cooldown); + + /* A distinct later row of the same condition, polled after the cooldown window, toasts again. */ + var later = Row(T0.AddMinutes(6), 1, "High CPU"); + var second = coordinator.SelectToasts(new[] { later }, T0.AddMinutes(6), Cooldown); + + Assert.Single(first); + Assert.Single(second); + Assert.Same(later, second[0]); + } + + [Fact] + public void SelectToasts_ZeroCooldown_AdmitsEveryNewRowOfAConditionInOnePoll() + { + var coordinator = new AlertToastCoordinator(Retention); + var a = Row(T0, 1, "High CPU"); + var b = Row(T0.AddMinutes(1), 1, "High CPU"); + var c = Row(T0.AddMinutes(2), 1, "High CPU"); + + var toasts = coordinator.SelectToasts(new[] { a, b, c }, T0.AddMinutes(2), TimeSpan.Zero); + + Assert.Equal(3, toasts.Count); + } + + [Fact] + public void SelectToasts_ResolvedRow_IsSeverityAgnostic_AndStillSelected() + { + /* The coordinator does not filter by severity — the MainWindow renders a resolved row as a green + styled card. A "Restored"/"Cleared"/"Resolved" row (unmuted) must be admitted. */ + var coordinator = new AlertToastCoordinator(Retention); + var resolved = Row(T0, 1, "Server Restored"); + + var toasts = coordinator.SelectToasts(new[] { resolved }, T0, Cooldown); + + Assert.Single(toasts); + Assert.True(toasts[0].IsResolved); + } + + [Fact] + public void SelectToasts_PrimedRowWithinRetention_StaysSuppressed() + { + var coordinator = new AlertToastCoordinator(Retention); + coordinator.Prime(new[] { Row(T0, 1, "High CPU") }); + + /* Well inside the retention window: the primed row is still remembered, so it never toasts. */ + var toasts = coordinator.SelectToasts(new[] { Row(T0, 1, "High CPU") }, T0.AddMinutes(10), Cooldown); + + Assert.Empty(toasts); + } + + [Fact] + public void SelectToasts_SeenRowBeyondRetention_IsPruned_ThenReToasts() + { + var coordinator = new AlertToastCoordinator(Retention); + coordinator.Prime(new[] { Row(T0, 1, "High CPU") }); + + /* A poll past the retention window prunes the stale seen key (an out-of-window row can never be + re-read, so remembering it forever would leak). */ + coordinator.SelectToasts(Array.Empty(), T0.AddMinutes(21), Cooldown); + + /* If that identity somehow re-appears it is treated as new again (bounded: retention exceeds the + poll fetch window, so an in-window row is never pruned early). */ + var toasts = coordinator.SelectToasts(new[] { Row(T0, 1, "High CPU") }, T0.AddMinutes(21), Cooldown); + + Assert.Single(toasts); + } +} diff --git a/Darling/Darling.Tests/ViewerAppSettingsStoreTests.cs b/Darling/Darling.Tests/ViewerAppSettingsStoreTests.cs new file mode 100644 index 000000000..72f2f52ac --- /dev/null +++ b/Darling/Darling.Tests/ViewerAppSettingsStoreTests.cs @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the viewer's application-settings store — the Darling equivalent of Lite's settings.json that the +/// full Settings window (the port of Lite's SettingsWindow) reads and writes: MCP, every alert threshold and +/// toggle, notification options, SMTP/webhook (non-secret) config, and the automated-analysis knobs. Covers +/// the four behaviors the store promises: Lite-matching defaults when the file is missing, a lossless +/// save/load round-trip across sections, clamping out-of-range values back into range (a corrupt / hand-edited +/// / forward-version file must never seed an out-of-range control), and defaults on a corrupt file. Each store +/// points at a unique temp file so nothing touches the real profile. Secrets (SMTP password, webhook URLs) are +/// deliberately NOT covered here — they live in Windows Credential Manager, not this file. +/// +public sealed class ViewerAppSettingsStoreTests : IDisposable +{ + private readonly string _tempFile = + Path.Combine(Path.GetTempPath(), $"viewer-settings-test-{Guid.NewGuid():N}.json"); + + public void Dispose() + { + if (File.Exists(_tempFile)) + { + File.Delete(_tempFile); + } + } + + [Fact] + public void Load_WhenFileMissing_ReturnsLiteMatchingDefaults() + { + var settings = new ViewerAppSettingsStore(_tempFile).Load(); + + /* Spot-check one value per section — every default mirrors Lite's App.* exactly, except the MCP + port, which is Darling's 5152 (avoids Lite 5151 / Dashboard 5150). */ + Assert.False(settings.McpEnabled); + Assert.Equal(5152, settings.McpPort); + Assert.Equal(5, settings.ConnectionTimeoutSeconds); + Assert.Equal(30, settings.NocRefreshIntervalSeconds); + Assert.Equal("ServerTime", settings.TimeDisplayMode); + Assert.True(settings.AlertsEnabled); + Assert.Equal(80, settings.AlertCpuThreshold); + Assert.Equal("Total", settings.AlertCpuMode); + Assert.Equal(500, settings.AlertPoisonWaitThresholdMs); + Assert.Equal("Summary", settings.AlertDeliveryMode); + Assert.Equal("24 hours", settings.MuteRuleDefaultExpiration); + Assert.Equal(1.5, settings.AnalysisNotifySeverity); + Assert.False(settings.SmtpEnabled); + Assert.Equal(587, settings.SmtpPort); + Assert.True(settings.SmtpUseSsl); + Assert.Empty(settings.AlertExcludedDatabases); + } + + [Fact] + public void SaveThenLoad_RoundTripsValuesAcrossEverySection() + { + var store = new ViewerAppSettingsStore(_tempFile); + store.Save(new ViewerAppSettings + { + McpEnabled = true, + McpPort = 5200, + ConnectionTimeoutSeconds = 30, + NocRefreshIntervalSeconds = 120, + CsvSeparator = ";", + TimeDisplayMode = "UTC", + OverviewSortMode = "Name", + AlertsEnabled = false, + AlertCpuThreshold = 65, + AlertCpuMode = "SqlOnly", + AlertBlockingThreshold = 3, + AlertLongRunningQueryMaxResults = 12, + AlertLongRunningQueryExcludeCdc = false, + AlertExcludedDatabases = new List { "msdb", "tempdb", "DBAtools" }, + AlertLowDiskThresholdGb = 20, + AlertDeliveryMode = "PerEvent", + AlertPerEventMaxPerCycle = 25, + MuteRuleDefaultExpiration = "7 days", + LogAlertDismissals = false, + AnalysisEnabled = false, + AnalysisIntervalMinutes = 120, + AnalysisNotifySeverity = 0.5, + SmtpEnabled = true, + SmtpServer = "smtp.example.com", + SmtpPort = 2525, + SmtpUseSsl = false, + SmtpUsername = "monitor", + SmtpFromAddress = "alerts@example.com", + SmtpRecipients = "dba@example.com", + TeamsWebhookEnabled = true, + TeamsProxyAddress = "http://proxy.corp:8080", + SlackWebhookEnabled = true, + SlackProxyAddress = "http://proxy.corp:3128", + }); + + /* A fresh store on the same path proves the values survived a real serialize -> file -> deserialize. */ + var reloaded = new ViewerAppSettingsStore(_tempFile).Load(); + + Assert.True(reloaded.McpEnabled); + Assert.Equal(5200, reloaded.McpPort); + Assert.Equal(30, reloaded.ConnectionTimeoutSeconds); + Assert.Equal(120, reloaded.NocRefreshIntervalSeconds); + Assert.Equal(";", reloaded.CsvSeparator); + Assert.Equal("UTC", reloaded.TimeDisplayMode); + Assert.Equal("Name", reloaded.OverviewSortMode); + Assert.False(reloaded.AlertsEnabled); + Assert.Equal(65, reloaded.AlertCpuThreshold); + Assert.Equal("SqlOnly", reloaded.AlertCpuMode); + Assert.Equal(3, reloaded.AlertBlockingThreshold); + Assert.Equal(12, reloaded.AlertLongRunningQueryMaxResults); + Assert.False(reloaded.AlertLongRunningQueryExcludeCdc); + Assert.Equal(new[] { "msdb", "tempdb", "DBAtools" }, reloaded.AlertExcludedDatabases); + Assert.Equal(20, reloaded.AlertLowDiskThresholdGb); + Assert.Equal("PerEvent", reloaded.AlertDeliveryMode); + Assert.Equal(25, reloaded.AlertPerEventMaxPerCycle); + Assert.Equal("7 days", reloaded.MuteRuleDefaultExpiration); + Assert.False(reloaded.LogAlertDismissals); + Assert.False(reloaded.AnalysisEnabled); + Assert.Equal(120, reloaded.AnalysisIntervalMinutes); + Assert.Equal(0.5, reloaded.AnalysisNotifySeverity); + Assert.True(reloaded.SmtpEnabled); + Assert.Equal("smtp.example.com", reloaded.SmtpServer); + Assert.Equal(2525, reloaded.SmtpPort); + Assert.False(reloaded.SmtpUseSsl); + Assert.Equal("monitor", reloaded.SmtpUsername); + Assert.Equal("alerts@example.com", reloaded.SmtpFromAddress); + Assert.Equal("dba@example.com", reloaded.SmtpRecipients); + Assert.True(reloaded.TeamsWebhookEnabled); + Assert.Equal("http://proxy.corp:8080", reloaded.TeamsProxyAddress); + Assert.True(reloaded.SlackWebhookEnabled); + Assert.Equal("http://proxy.corp:3128", reloaded.SlackProxyAddress); + } + + [Fact] + public void Load_ClampsOutOfRangeValues_BackIntoRange() + { + /* Persist deliberately out-of-range numerics and unknown enum strings by writing the JSON directly, + then confirm Load normalizes each one so no control is ever seeded with a bad value. */ + File.WriteAllText( + _tempFile, + """ + { + "McpPort": 0, + "ConnectionTimeoutSeconds": 1, + "NocRefreshIntervalSeconds": 9999, + "AlertCpuThreshold": 999, + "AlertCpuMode": "Bogus", + "AlertLongRunningQueryMaxResults": 99999, + "AlertCooldownMinutes": 500, + "AnalysisIntervalMinutes": 2, + "AnalysisNotifySeverity": 5.0, + "AlertDeliveryMode": "Whenever", + "MuteRuleDefaultExpiration": "Whenever", + "TimeDisplayMode": "Martian", + "OverviewSortMode": "Bogus", + "CsvSeparator": "|" + } + """); + + var settings = new ViewerAppSettingsStore(_tempFile).Load(); + + Assert.Equal(5152, settings.McpPort); // 0 (unset) -> Darling default + Assert.Equal(5, settings.ConnectionTimeoutSeconds); // below min 5 -> 5 + Assert.Equal(600, settings.NocRefreshIntervalSeconds); // above max 600 -> 600 + Assert.Equal(100, settings.AlertCpuThreshold); // above max 100 -> 100 + Assert.Equal("Total", settings.AlertCpuMode); // unknown -> Total + Assert.Equal(1000, settings.AlertLongRunningQueryMaxResults); // above max 1000 -> 1000 + Assert.Equal(120, settings.AlertCooldownMinutes); // above max 120 -> 120 + Assert.Equal(5, settings.AnalysisIntervalMinutes); // below min 5 -> 5 + Assert.Equal(2.0, settings.AnalysisNotifySeverity); // above max 2.0 -> 2.0 + Assert.Equal("Summary", settings.AlertDeliveryMode); // unknown -> Summary + Assert.Equal("24 hours", settings.MuteRuleDefaultExpiration); // unknown -> 24 hours + Assert.Equal("ServerTime", settings.TimeDisplayMode); // unknown -> ServerTime + Assert.Equal("Cpu", settings.OverviewSortMode); // unknown -> Cpu + Assert.Contains(settings.CsvSeparator, new[] { ",", ";", "\t" }); // unknown -> a valid locale default + } + + [Fact] + public void Load_WhenFileCorrupt_ReturnsDefaults() + { + File.WriteAllText(_tempFile, "{ not valid json"); + + var settings = new ViewerAppSettingsStore(_tempFile).Load(); + + Assert.Equal(5152, settings.McpPort); + Assert.True(settings.AlertsEnabled); + Assert.Equal(80, settings.AlertCpuThreshold); + } + + [Fact] + public void ViewerExportSettings_Apply_SeedsTheRuntimeCsvSeparator() + { + /* The one persisted setting the viewer honors at runtime today: grid CSV export reads this static. */ + ViewerExportSettings.Apply(new ViewerAppSettings { CsvSeparator = ";" }); + Assert.Equal(";", ViewerExportSettings.CsvSeparator); + + ViewerExportSettings.Apply(new ViewerAppSettings { CsvSeparator = "\t" }); + Assert.Equal("\t", ViewerExportSettings.CsvSeparator); + } + + [Fact] + public void Save_CreatesTheAppDataDirectory_WhenItDoesNotExist() + { + var nestedDir = Path.Combine(Path.GetTempPath(), $"viewer-settings-dir-{Guid.NewGuid():N}"); + var nestedFile = Path.Combine(nestedDir, "viewer-settings.json"); + try + { + new ViewerAppSettingsStore(nestedFile).Save(new ViewerAppSettings()); + + Assert.True(File.Exists(nestedFile)); + } + finally + { + if (Directory.Exists(nestedDir)) + { + Directory.Delete(nestedDir, recursive: true); + } + } + } +} diff --git a/Darling/Darling.Tests/ViewerBlockingDepthTests.cs b/Darling/Darling.Tests/ViewerBlockingDepthTests.cs new file mode 100644 index 000000000..20a512cd5 --- /dev/null +++ b/Darling/Darling.Tests/ViewerBlockingDepthTests.cs @@ -0,0 +1,779 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Alerting; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the W1e Blocking-depth reads (widened Blocked Process Reports grid, the block-chain pair-row fetch, +/// the Deadlocks grid, and the two slicer bucket reads) against the Darling store contract, no live Postgres. +/// All mirror Lite's LocalDataService.Blocking.cs queries: the BPR grid read (including +/// blocked_process_report_xml + the best-effort plan columns) with the 19-column DMV fallback; +/// the pair-row read built from the shared PgBlockingPairRowQuery fragments (so the apex + column +/// order can't drift); the deadlock grid read (including the victim plan); and the hourly slicer buckets +/// (blocking XE-preferred with the DMV fallback exclusion, deadlocks by collection_time). The two +/// plan-carrying grid reads source the BASE tables, not the v_ views: the #1368 / V7 plan columns are +/// projected reliably only by the base table (Postgres pins a view's SELECT * at creation — V4, +/// before V7 — and V8 only ALTER VIEW … SET SCHEMAs it, never re-creating it), so a view read would +/// fail on an upgraded store. The plan columns are Darling-only, so those reads were never Lite-twinnable +/// once they carry a plan; every OTHER blocking/deadlock read (pair-row, slicers, trends, summaries) still +/// twins over the v_ views. +/// +public sealed class ViewerBlockingDepthSqlTests +{ + [Fact] + public void BlockedProcessReportsSql_FullColumnSet_IncludesReportXml_AndBestEffortPlans_OverBaseTable() + { + /* Base table, not v_blocked_process_reports: the V7 best-effort plan columns are projected reliably + only by the base table (pinned SELECT * view; see the class remarks). */ + Assert.Contains("FROM blocked_process_reports", ViewerDataService.BlockedProcessReportsSql, StringComparison.Ordinal); + Assert.DoesNotContain("FROM v_blocked_process_reports", ViewerDataService.BlockedProcessReportsSql, StringComparison.Ordinal); + Assert.Contains("blocked_process_report_xml", ViewerDataService.BlockedProcessReportsSql, StringComparison.Ordinal); + /* The two best-effort plan columns the grid's "View Plan" items open in-row (#1368 / V7). */ + Assert.Contains("blocked_query_plan_xml", ViewerDataService.BlockedProcessReportsSql, StringComparison.Ordinal); + Assert.Contains("blocking_query_plan_xml", ViewerDataService.BlockedProcessReportsSql, StringComparison.Ordinal); + Assert.Contains("ORDER BY event_time DESC", ViewerDataService.BlockedProcessReportsSql, StringComparison.Ordinal); + Assert.Contains("LIMIT 200", ViewerDataService.BlockedProcessReportsSql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $2", ViewerDataService.BlockedProcessReportsSql, StringComparison.Ordinal); + + /* The widened grid columns Lite's row carries that the old 9-column viewer read dropped. */ + foreach (var column in new[] + { + "blocked_ecid", "blocking_ecid", "wait_resource", "blocked_status", "blocking_status", + "blocked_isolation_level", "blocking_isolation_level", "blocked_log_used", + "blocked_transaction_count", "blocked_priority", "blocking_priority", + "blocked_transaction_name", "blocking_transaction_name", "monitor_loop", + }) + { + Assert.Contains(column, ViewerDataService.BlockedProcessReportsSql, StringComparison.Ordinal); + } + } + + [Fact] + public void DmvBlockingSnapshotsSql_FallbackColumns_NoReportXml_OverDmvView() + { + Assert.Contains("FROM v_dmv_blocking_snapshots", ViewerDataService.DmvBlockingSnapshotsSql, StringComparison.Ordinal); + /* dmv_blocking_snapshots has no report XML column — it must not be selected. */ + Assert.DoesNotContain("blocked_process_report_xml", ViewerDataService.DmvBlockingSnapshotsSql, StringComparison.Ordinal); + Assert.Contains("blocking_status", ViewerDataService.DmvBlockingSnapshotsSql, StringComparison.Ordinal); + Assert.Contains("monitor_loop", ViewerDataService.DmvBlockingSnapshotsSql, StringComparison.Ordinal); + Assert.Contains("ORDER BY event_time DESC", ViewerDataService.DmvBlockingSnapshotsSql, StringComparison.Ordinal); + Assert.Contains("LIMIT 200", ViewerDataService.DmvBlockingSnapshotsSql, StringComparison.Ordinal); + } + + [Fact] + public void BlockingPairRowsSql_BuiltFromSharedFragments_SpidFilter_EventTimeWindow() + { + Assert.Contains("FROM v_blocked_process_reports", ViewerDataService.BlockingPairRowsSql, StringComparison.Ordinal); + /* The shared PgBlockingPairRowQuery fragments — spid:ecid + monitor_loop are the reconstruction identity. */ + Assert.Contains("blocked_ecid", ViewerDataService.BlockingPairRowsSql, StringComparison.Ordinal); + Assert.Contains("blocking_ecid", ViewerDataService.BlockingPairRowsSql, StringComparison.Ordinal); + Assert.Contains("monitor_loop", ViewerDataService.BlockingPairRowsSql, StringComparison.Ordinal); + /* SpidFilter drops the missing-blocker sentinel so no consumer invents a SPID-0 apex. */ + Assert.Contains("blocking_spid IS NOT NULL", ViewerDataService.BlockingPairRowsSql, StringComparison.Ordinal); + Assert.Contains("blocking_spid <> 0", ViewerDataService.BlockingPairRowsSql, StringComparison.Ordinal); + Assert.Contains("event_time >= $2 AND event_time <= $3", ViewerDataService.BlockingPairRowsSql, StringComparison.Ordinal); + Assert.Contains("LIMIT 5000", ViewerDataService.BlockingPairRowsSql, StringComparison.Ordinal); + } + + [Fact] + public void RecentDeadlocksSql_CarriesGraphXml_AndVictimPlan_OverBaseTable_OrdersByDeadlockTime() + { + /* Base deadlocks table, not v_deadlocks: the V7 victim_query_plan_xml is projected reliably only by + the base table (pinned SELECT * view; see the class remarks). */ + Assert.Contains("FROM deadlocks", ViewerDataService.RecentDeadlocksSql, StringComparison.Ordinal); + Assert.DoesNotContain("FROM v_deadlocks", ViewerDataService.RecentDeadlocksSql, StringComparison.Ordinal); + Assert.Contains("deadlock_graph_xml", ViewerDataService.RecentDeadlocksSql, StringComparison.Ordinal); + Assert.Contains("victim_process_id", ViewerDataService.RecentDeadlocksSql, StringComparison.Ordinal); + /* The best-effort victim plan the grid's "View Victim Plan" item opens in-row (#1368 / V7). */ + Assert.Contains("victim_query_plan_xml", ViewerDataService.RecentDeadlocksSql, StringComparison.Ordinal); + Assert.Contains("ORDER BY deadlock_time DESC", ViewerDataService.RecentDeadlocksSql, StringComparison.Ordinal); + Assert.Contains("LIMIT 50", ViewerDataService.RecentDeadlocksSql, StringComparison.Ordinal); + } + + [Fact] + public void BlockingSlicerSql_XePreferred_DmvFallbackOnlyWhenNoXe_HourlyBuckets() + { + Assert.Contains("FROM v_blocked_process_reports", ViewerDataService.BlockingSlicerSql, StringComparison.Ordinal); + Assert.Contains("FROM v_dmv_blocking_snapshots", ViewerDataService.BlockingSlicerSql, StringComparison.Ordinal); + Assert.Contains("WHERE NOT EXISTS (SELECT 1 FROM bpr)", ViewerDataService.BlockingSlicerSql, StringComparison.Ordinal); + Assert.Contains("date_trunc('hour', collection_time)", ViewerDataService.BlockingSlicerSql, StringComparison.Ordinal); + Assert.Contains("COUNT(DISTINCT blocking_spid)", ViewerDataService.BlockingSlicerSql, StringComparison.Ordinal); + } + + [Fact] + public void DeadlockSlicerSql_HourlyBuckets_OverDeadlocksView() + { + Assert.Contains("FROM v_deadlocks", ViewerDataService.DeadlockSlicerSql, StringComparison.Ordinal); + Assert.Contains("date_trunc('hour', collection_time)", ViewerDataService.DeadlockSlicerSql, StringComparison.Ordinal); + Assert.Contains("COUNT(*) AS deadlock_count", ViewerDataService.DeadlockSlicerSql, StringComparison.Ordinal); + } + + [Theory] + [InlineData("bpr")] + [InlineData("dmv")] + [InlineData("pairs")] + [InlineData("deadlocks")] + [InlineData("blocking-slicer")] + [InlineData("deadlock-slicer")] + public void W1eReads_PgDialect_PositionalParams_NoBareNow_NoNLiterals(string which) + { + var sql = which switch + { + "bpr" => ViewerDataService.BlockedProcessReportsSql, + "dmv" => ViewerDataService.DmvBlockingSnapshotsSql, + "pairs" => ViewerDataService.BlockingPairRowsSql, + "deadlocks" => ViewerDataService.RecentDeadlocksSql, + "blocking-slicer" => ViewerDataService.BlockingSlicerSql, + _ => ViewerDataService.DeadlockSlicerSql, + }; + + Assert.DoesNotContain("now(", sql.ToLowerInvariant()); + Assert.DoesNotContain("N'", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + Assert.Contains("$1", sql, StringComparison.Ordinal); + Assert.Contains("$2", sql, StringComparison.Ordinal); + Assert.Contains("$3", sql, StringComparison.Ordinal); + } + + [Fact] + public void BprReads_ReadColumnsThatExistInTheGeneratedBlockedProcessReportsTable() + { + Assert.Equal("blocked_process_reports", BlockedProcessReportCollector.Instance.TargetTable); + + var ddl = PgSchemaGenerator.CreateTable(BlockedProcessReportCollector.Instance); + foreach (var column in new[] + { + "event_time", "database_name", "blocked_spid", "blocked_ecid", "blocking_spid", "blocking_ecid", + "wait_time_ms", "wait_resource", "lock_mode", "blocked_status", "blocked_isolation_level", + "blocked_log_used", "blocked_transaction_count", "blocked_client_app", "blocked_host_name", + "blocked_login_name", "blocked_sql_text", "blocking_status", "blocking_isolation_level", + "blocking_client_app", "blocking_host_name", "blocking_login_name", "blocking_sql_text", + "blocked_process_report_xml", "blocked_transaction_name", "blocking_transaction_name", + "blocked_priority", "blocking_priority", "contentious_object", "monitor_loop", + /* the best-effort plan columns the grid read carries in-row (#1368 / V7) */ + "blocked_query_plan_xml", "blocking_query_plan_xml", + }) + { + Assert.Contains(column, ddl, StringComparison.Ordinal); + } + } + + [Fact] + public void DeadlockRead_ReadsColumnsThatExistInTheGeneratedDeadlocksTable() + { + Assert.Equal("deadlocks", DeadlocksCollector.Instance.TargetTable); + + var ddl = PgSchemaGenerator.CreateTable(DeadlocksCollector.Instance); + foreach (var column in new[] + { + "collection_time", "deadlock_time", "victim_process_id", "victim_sql_text", "deadlock_graph_xml", + /* the best-effort victim plan the grid read carries in-row (#1368 / V7) */ + "victim_query_plan_xml", + }) + { + Assert.Contains(column, ddl, StringComparison.Ordinal); + } + } +} + +/// +/// Pins the copied — the sp_BlitzLock-style graph walk +/// (victim detection, regular-vs-parallel classification, per-process owner/waiter modes, object names, +/// proc-name resolution) and the malformed-XML fallback. Pure (no Postgres); the parse is byte-identical +/// to Lite's, so a drift here is a real regression. +/// +public sealed class DeadlockProcessDetailParseTests +{ + private const string TwoProcessDeadlockXml = """ + + + + + + + + UPDATE dbo.t SET c = 1 + + UPDATE dbo.t SET c = 1 + + + UPDATE dbo.t SET c = 2 + + + + + + + + + + + + + + """; + + [Fact] + public void ParseFromRows_TwoProcessGraph_ResolvesVictimOwnerWaiterAndObject() + { + var rows = new List + { + new() { DeadlockTime = new DateTime(2026, 7, 1, 10, 0, 5, DateTimeKind.Unspecified), VictimProcessId = "process1", DeadlockGraphXml = TwoProcessDeadlockXml }, + }; + + var details = DeadlockProcessDetail.ParseFromRows(rows); + + Assert.Equal(2, details.Count); + Assert.All(details, d => Assert.Equal("Regular", d.DeadlockType)); + Assert.All(details, d => Assert.True(d.HasDeadlockXml)); + + var victim = details.Single(d => d.Spid == 55); + Assert.True(victim.IsVictim); + Assert.Equal("Victim", victim.VictimDisplay); + Assert.Equal("AppDb", victim.DatabaseName); + Assert.Equal("X", victim.LockMode); + Assert.Equal("X", victim.WaiterMode); // process1 is the waiter on the keylock + Assert.Equal("AppDb.dbo.t", victim.ObjectNames); + Assert.Equal("AppDb.dbo.usp_Update", victim.ProcName); + Assert.Equal("UPDATE dbo.t SET c = 1", victim.SqlText); + Assert.Equal(1500, victim.WaitTime); + Assert.Equal("1,500 ms", victim.WaitTimeFormatted); + + var blocker = details.Single(d => d.Spid == 60); + Assert.False(blocker.IsVictim); + Assert.Equal("", blocker.VictimDisplay); + Assert.Equal("X", blocker.OwnerMode); // process2 owns the keylock + Assert.Equal("AppDb.dbo.t", blocker.ObjectNames); + } + + [Fact] + public void ParseFromRows_ThreadsVictimPlanOntoEveryProcessRow_GatingFlagFollows() + { + /* One deadlock, one best-effort victim plan (#1368 / V7) — it must ride on EVERY process row so the + per-row-gated "View Victim Plan" reaches the same plan from any process. */ + var rows = new List + { + new() + { + DeadlockTime = new DateTime(2026, 7, 1, 10, 0, 5, DateTimeKind.Unspecified), + VictimProcessId = "process1", + DeadlockGraphXml = TwoProcessDeadlockXml, + VictimQueryPlanXml = "victim", + }, + }; + + var details = DeadlockProcessDetail.ParseFromRows(rows); + + Assert.Equal(2, details.Count); + Assert.All(details, d => Assert.True(d.HasVictimQueryPlan)); + Assert.All(details, d => Assert.Equal("victim", d.VictimQueryPlanXml)); + } + + [Fact] + public void ParseFromRows_NullVictimPlan_LeavesGatingFlagFalse() + { + /* The common best-effort case: no victim plan captured → every row's flag is false → the item disables. */ + var rows = new List + { + new() { DeadlockTime = DateTime.UnixEpoch, VictimProcessId = "process1", DeadlockGraphXml = TwoProcessDeadlockXml }, + }; + + var details = DeadlockProcessDetail.ParseFromRows(rows); + + Assert.NotEmpty(details); + Assert.All(details, d => Assert.False(d.HasVictimQueryPlan)); + Assert.All(details, d => Assert.Null(d.VictimQueryPlanXml)); + } + + [Fact] + public void ParseFromRows_MalformedXml_YieldsSingleVictimFallbackRow() + { + var rows = new List + { + new() { DeadlockTime = DateTime.UnixEpoch, VictimSqlText = "UPDATE t SET c = 9", DeadlockGraphXml = " + { + new() { DeadlockTime = DateTime.UnixEpoch, DeadlockGraphXml = "" }, + }; + + Assert.Empty(DeadlockProcessDetail.ParseFromRows(rows)); + } +} + +/// +/// Gated (DARLING_TEST_PG) live round-trips for the W1e Blocking-depth reads: the widened 37-column BPR +/// grid read (including the report XML → HasReportXml + the long-block tint + the Source badge), the DMV +/// fallback surfacing when the XE source is empty, the deadlock read feeding the copied parser, and the two +/// hourly slicer bucket reads (blocking XE-preferred with the DMV exclusion; deadlocks). Shares the +/// serialized "live-postgres" collection; uses negative sentinel server_ids and cleans up in finally. +/// +[Collection("live-postgres")] +public sealed class ViewerBlockingDepthLivePostgresTests +{ + private const int BprServerId = -972001; + private const int DmvFallbackServerId = -972002; + private const int DeadlockServerId = -972003; + private const int BlockingSlicerServerId = -972004; + private const int DeadlockSlicerServerId = -972005; + private const int BprPlanServerId = -972006; + private const int DeadlockPlanServerId = -972007; + + private const string ServerName = "viewer-w1e-e2e"; + + [Fact] + public async Task BprRead_WidenedColumns_ReportXml_LongBlock_SourceBadge_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live BPR-depth test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "blocked_process_reports", BprServerId); + await DeleteRowsAsync(connection, "dmv_blocking_snapshots", BprServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var collectionTime = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + /* One long block (45s → IsLongBlock + "45.0 sec") carrying a report XML, one short (500 ms). */ + await InsertBlockedProcessReportAsync(connection, BprServerId, collectionTime, collectionTime.AddSeconds(1), + waitTimeMs: 45000, reportXml: "", monitorLoop: 7); + await InsertBlockedProcessReportAsync(connection, BprServerId, collectionTime, collectionTime.AddSeconds(2), + waitTimeMs: 500, reportXml: "", monitorLoop: 7); + + var rows = await viewer.GetRecentBlockedProcessReportsAsync(BprServerId, collectionTime.AddMinutes(-1), collectionTime.AddMinutes(1)); + + Assert.Equal(2, rows.Count); + Assert.All(rows, r => Assert.Equal(BlockedProcessAlertRow.XeReportSource, r.Source)); + + var longBlock = rows.Single(r => r.WaitTimeMs == 45000); + Assert.True(longBlock.IsLongBlock); + Assert.Equal("45.0 sec", longBlock.WaitTimeFormatted); + Assert.True(longBlock.HasReportXml); + Assert.Equal("", longBlock.BlockedProcessReportXml); + /* Widened columns the old 9-column read dropped. */ + Assert.Equal("KEY: 5:72", longBlock.WaitResource); + Assert.Equal("suspended", longBlock.BlockedStatus); + Assert.Equal("sleeping", longBlock.BlockingStatus); + Assert.Equal("read committed (2)", longBlock.BlockedIsolationLevel); + Assert.Equal("sa", longBlock.BlockedLoginName); + Assert.Equal(1, longBlock.BlockedTransactionCount); + Assert.Equal(7, longBlock.MonitorLoop); + Assert.Equal("dbo.t", longBlock.ContentiousObject); + + var shortBlock = rows.Single(r => r.WaitTimeMs == 500); + Assert.False(shortBlock.IsLongBlock); + Assert.Equal("500 ms", shortBlock.WaitTimeFormatted); + Assert.False(shortBlock.HasReportXml); + } + finally + { + await DeleteRowsAsync(connection, "blocked_process_reports", BprServerId); + await DeleteRowsAsync(connection, "dmv_blocking_snapshots", BprServerId); + } + } + + [Fact] + public async Task BprRead_DmvFallback_SurfacesWhenNoXe_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live DMV-fallback test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "blocked_process_reports", DmvFallbackServerId); + await DeleteRowsAsync(connection, "dmv_blocking_snapshots", DmvFallbackServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var collectionTime = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + /* No XE report; a DMV snapshot for a different SPID pair must surface with the DMV Source badge. */ + await InsertDmvBlockingSnapshotAsync(connection, DmvFallbackServerId, collectionTime, collectionTime.AddSeconds(3), + blockedSpid: 155, blockingSpid: 160, waitTimeMs: 900); + + var rows = await viewer.GetRecentBlockedProcessReportsAsync(DmvFallbackServerId, collectionTime.AddMinutes(-1), collectionTime.AddMinutes(1)); + + var only = Assert.Single(rows); + Assert.Equal(BlockedProcessAlertRow.DmvSnapshotSource, only.Source); + Assert.Equal(155, only.BlockedSpid); + Assert.Equal(160, only.BlockingSpid); + Assert.False(only.HasReportXml); // the DMV read never selects the report XML column + } + finally + { + await DeleteRowsAsync(connection, "blocked_process_reports", DmvFallbackServerId); + await DeleteRowsAsync(connection, "dmv_blocking_snapshots", DmvFallbackServerId); + } + } + + [Fact] + public async Task DeadlockRead_CarriesGraphXml_ParsesToProcessRows_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live deadlock-read test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "deadlocks", DeadlockServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var collectionTime = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + const string graph = """ + + + + UPDATE dbo.t SET c = 1 + UPDATE dbo.t SET c = 2 + + + + """; + await InsertDeadlockAsync(connection, DeadlockServerId, collectionTime, collectionTime.AddSeconds(4), "p1", graph); + + var rows = await viewer.GetRecentDeadlocksAsync(DeadlockServerId, collectionTime.AddMinutes(-1), collectionTime.AddMinutes(1)); + + var row = Assert.Single(rows); + Assert.Equal("p1", row.VictimProcessId); + Assert.True(row.HasDeadlockXml); + + var details = DeadlockProcessDetail.ParseFromRows(rows); + Assert.Equal(2, details.Count); + Assert.True(details.Single(d => d.Spid == 55).IsVictim); + Assert.False(details.Single(d => d.Spid == 60).IsVictim); + } + finally + { + await DeleteRowsAsync(connection, "deadlocks", DeadlockServerId); + } + } + + [Fact] + public async Task BprRead_CarriesBestEffortPlans_InRow_GatesPerRow_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live BPR-plan test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "blocked_process_reports", BprPlanServerId); + await DeleteRowsAsync(connection, "dmv_blocking_snapshots", BprPlanServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var t = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + /* Both plans captured (1000 ms), only the blocked plan (2000), neither (3000) — best-effort, so + each side is independently NULL-able and the Has* flags gate the two "View Plan" items per row. */ + await InsertBlockedProcessReportAsync(connection, BprPlanServerId, t, t.AddSeconds(1), + waitTimeMs: 1000, reportXml: "", monitorLoop: 1, + blockedPlanXml: "blocked", blockingPlanXml: "blocking"); + await InsertBlockedProcessReportAsync(connection, BprPlanServerId, t, t.AddSeconds(2), + waitTimeMs: 2000, reportXml: "", monitorLoop: 1, + blockedPlanXml: "blocked-only", blockingPlanXml: null); + await InsertBlockedProcessReportAsync(connection, BprPlanServerId, t, t.AddSeconds(3), + waitTimeMs: 3000, reportXml: "", monitorLoop: 1); + + var rows = await viewer.GetRecentBlockedProcessReportsAsync(BprPlanServerId, t.AddMinutes(-1), t.AddMinutes(1)); + + var both = rows.Single(r => r.WaitTimeMs == 1000); + Assert.True(both.HasBlockedQueryPlan); + Assert.True(both.HasBlockingQueryPlan); + Assert.Equal("blocked", both.BlockedQueryPlanXml); + Assert.Equal("blocking", both.BlockingQueryPlanXml); + + var blockedOnly = rows.Single(r => r.WaitTimeMs == 2000); + Assert.True(blockedOnly.HasBlockedQueryPlan); + Assert.False(blockedOnly.HasBlockingQueryPlan); // best-effort: the blocking side stayed NULL + + var neither = rows.Single(r => r.WaitTimeMs == 3000); + Assert.False(neither.HasBlockedQueryPlan); + Assert.False(neither.HasBlockingQueryPlan); + } + finally + { + await DeleteRowsAsync(connection, "blocked_process_reports", BprPlanServerId); + await DeleteRowsAsync(connection, "dmv_blocking_snapshots", BprPlanServerId); + } + } + + [Fact] + public async Task DeadlockRead_CarriesVictimPlan_ThreadedOntoEveryProcessRow_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live victim-plan test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "deadlocks", DeadlockPlanServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var t = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + const string graph = """ + + + + UPDATE dbo.t SET c = 1 + UPDATE dbo.t SET c = 2 + + + + """; + await InsertDeadlockAsync(connection, DeadlockPlanServerId, t, t.AddSeconds(1), "p1", graph, + victimPlanXml: "victim"); + + var rows = await viewer.GetRecentDeadlocksAsync(DeadlockPlanServerId, t.AddMinutes(-1), t.AddMinutes(1)); + var row = Assert.Single(rows); + Assert.Equal("victim", row.VictimQueryPlanXml); + + /* The single victim plan rides on EVERY parsed process row so "View Victim Plan" is reachable from + any of them (gated on presence, not IsVictim). */ + var details = DeadlockProcessDetail.ParseFromRows(rows); + Assert.Equal(2, details.Count); + Assert.All(details, d => Assert.True(d.HasVictimQueryPlan)); + Assert.All(details, d => Assert.Equal("victim", d.VictimQueryPlanXml)); + } + finally + { + await DeleteRowsAsync(connection, "deadlocks", DeadlockPlanServerId); + } + } + + [Fact] + public async Task BlockingSlicer_HourlyBuckets_XePreferredDmvExcluded_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live blocking-slicer test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "blocked_process_reports", BlockingSlicerServerId); + await DeleteRowsAsync(connection, "dmv_blocking_snapshots", BlockingSlicerServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var hourA = TruncateToHour(DateTime.UtcNow.AddHours(-5)); + var hourB = hourA.AddHours(1); + /* Two XE events in hour A, one in hour B, plus a DMV row that must be excluded while XE exists. */ + await InsertBlockedProcessReportAsync(connection, BlockingSlicerServerId, hourA.AddMinutes(5), hourA.AddMinutes(5), waitTimeMs: 1000, reportXml: "", monitorLoop: 1); + await InsertBlockedProcessReportAsync(connection, BlockingSlicerServerId, hourA.AddMinutes(20), hourA.AddMinutes(20), waitTimeMs: 2000, reportXml: "", monitorLoop: 1); + await InsertBlockedProcessReportAsync(connection, BlockingSlicerServerId, hourB.AddMinutes(10), hourB.AddMinutes(10), waitTimeMs: 3000, reportXml: "", monitorLoop: 2); + await InsertDmvBlockingSnapshotAsync(connection, BlockingSlicerServerId, hourA.AddMinutes(30), hourA.AddMinutes(30), blockedSpid: 200, blockingSpid: 201, waitTimeMs: 9000); + + var buckets = await viewer.GetBlockingSlicerDataAsync(BlockingSlicerServerId, hourA.AddMinutes(-1), hourB.AddHours(1)); + + Assert.Equal(2, buckets.Count); + Assert.Equal(2, buckets[0].SessionCount); // hour A = 2 XE events (DMV excluded) + Assert.Equal(1, buckets[1].SessionCount); // hour B = 1 XE event + Assert.True(buckets[0].BucketTime < buckets[1].BucketTime); + } + finally + { + await DeleteRowsAsync(connection, "blocked_process_reports", BlockingSlicerServerId); + await DeleteRowsAsync(connection, "dmv_blocking_snapshots", BlockingSlicerServerId); + } + } + + [Fact] + public async Task DeadlockSlicer_HourlyBuckets_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live deadlock-slicer test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "deadlocks", DeadlockSlicerServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var hourA = TruncateToHour(DateTime.UtcNow.AddHours(-5)); + var hourB = hourA.AddHours(1); + await InsertDeadlockAsync(connection, DeadlockSlicerServerId, hourA.AddMinutes(5), hourA.AddMinutes(5), "p1", ""); + await InsertDeadlockAsync(connection, DeadlockSlicerServerId, hourA.AddMinutes(25), hourA.AddMinutes(25), "p1", ""); + await InsertDeadlockAsync(connection, DeadlockSlicerServerId, hourB.AddMinutes(15), hourB.AddMinutes(15), "p1", ""); + + var buckets = await viewer.GetDeadlockSlicerDataAsync(DeadlockSlicerServerId, hourA.AddMinutes(-1), hourB.AddHours(1)); + + Assert.Equal(2, buckets.Count); + Assert.Equal(2, buckets[0].SessionCount); + Assert.Equal(1, buckets[1].SessionCount); + } + finally + { + await DeleteRowsAsync(connection, "deadlocks", DeadlockSlicerServerId); + } + } + + private static async Task InsertBlockedProcessReportAsync( + NpgsqlConnection connection, int serverId, DateTime collectionTimeUtc, DateTime eventTimeUtc, + long waitTimeMs, string reportXml, int monitorLoop, + string? blockedPlanXml = null, string? blockingPlanXml = null) + { + using var command = new NpgsqlCommand(@" +INSERT INTO blocked_process_reports + (blocked_report_id, collection_time, server_id, server_name, event_time, database_name, + blocked_spid, blocked_ecid, blocking_spid, blocking_ecid, wait_time_ms, wait_resource, lock_mode, + blocked_status, blocked_isolation_level, blocked_log_used, blocked_transaction_count, + blocked_client_app, blocked_host_name, blocked_login_name, blocked_sql_text, + blocking_status, blocking_isolation_level, blocking_client_app, blocking_host_name, blocking_login_name, + blocking_sql_text, blocked_process_report_xml, blocked_transaction_name, blocking_transaction_name, + blocked_priority, blocking_priority, contentious_object, monitor_loop, + blocked_query_plan_xml, blocking_query_plan_xml) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, + $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(DateTime.SpecifyKind(eventTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue("AppDb"); + command.Parameters.AddWithValue(55); + command.Parameters.AddWithValue(1); + command.Parameters.AddWithValue(60); + command.Parameters.AddWithValue(0); + command.Parameters.AddWithValue(waitTimeMs); + command.Parameters.AddWithValue("KEY: 5:72"); + command.Parameters.AddWithValue("X"); + command.Parameters.AddWithValue("suspended"); + command.Parameters.AddWithValue("read committed (2)"); + command.Parameters.AddWithValue(200L); + command.Parameters.AddWithValue(1); + command.Parameters.AddWithValue("TestApp"); + command.Parameters.AddWithValue("HOST1"); + command.Parameters.AddWithValue("sa"); + command.Parameters.AddWithValue("SELECT 1"); + command.Parameters.AddWithValue("sleeping"); + command.Parameters.AddWithValue("read committed (2)"); + command.Parameters.AddWithValue("TestApp2"); + command.Parameters.AddWithValue("HOST2"); + command.Parameters.AddWithValue("sa"); + command.Parameters.AddWithValue("UPDATE t SET c = 1"); + command.Parameters.AddWithValue(reportXml); + command.Parameters.AddWithValue("user_transaction"); + command.Parameters.AddWithValue("user_transaction"); + command.Parameters.AddWithValue(0); + command.Parameters.AddWithValue(0); + command.Parameters.AddWithValue("dbo.t"); + command.Parameters.AddWithValue(monitorLoop); + command.Parameters.AddWithValue((object?)blockedPlanXml ?? DBNull.Value); + command.Parameters.AddWithValue((object?)blockingPlanXml ?? DBNull.Value); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertDmvBlockingSnapshotAsync( + NpgsqlConnection connection, int serverId, DateTime collectionTimeUtc, DateTime eventTimeUtc, + int blockedSpid, int blockingSpid, long waitTimeMs) + { + using var command = new NpgsqlCommand(@" +INSERT INTO dmv_blocking_snapshots + (collection_id, collection_time, server_id, server_name, event_time, database_name, + blocked_spid, blocking_spid, wait_time_ms, lock_mode, blocking_status, + blocked_sql_text, blocking_sql_text, contentious_object) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(DateTime.SpecifyKind(eventTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue("AppDb"); + command.Parameters.AddWithValue(blockedSpid); + command.Parameters.AddWithValue(blockingSpid); + command.Parameters.AddWithValue(waitTimeMs); + command.Parameters.AddWithValue("S"); + command.Parameters.AddWithValue("running"); + command.Parameters.AddWithValue("SELECT 2"); + command.Parameters.AddWithValue("UPDATE t SET c = 2"); + command.Parameters.AddWithValue("dbo.t"); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertDeadlockAsync( + NpgsqlConnection connection, int serverId, DateTime collectionTimeUtc, DateTime deadlockTimeUtc, + string victimProcessId, string graphXml, string? victimPlanXml = null) + { + using var command = new NpgsqlCommand(@" +INSERT INTO deadlocks + (deadlock_id, collection_time, server_id, server_name, deadlock_time, + victim_process_id, victim_sql_text, deadlock_graph_xml, victim_query_plan_xml) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(DateTime.SpecifyKind(deadlockTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(victimProcessId); + command.Parameters.AddWithValue("UPDATE dbo.t SET c = 1"); + command.Parameters.AddWithValue(graphXml); + command.Parameters.AddWithValue((object?)victimPlanXml ?? DBNull.Value); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static DateTime TruncateToSeconds(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified); + + /* The slicer buckets by date_trunc('hour', ...), so anchor on an hour boundary. */ + private static DateTime TruncateToHour(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerHour)), DateTimeKind.Unspecified); + + private static async Task DeleteRowsAsync(NpgsqlConnection connection, string table, int serverId) + { + using var cleanup = new NpgsqlCommand($"DELETE FROM {table} WHERE server_id = {serverId};", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } +} diff --git a/Darling/Darling.Tests/ViewerBlockingStatsTests.cs b/Darling/Darling.Tests/ViewerBlockingStatsTests.cs new file mode 100644 index 000000000..1c8a2fbe3 --- /dev/null +++ b/Darling/Darling.Tests/ViewerBlockingStatsTests.cs @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the Blocking Stats sub-tab's blocking-duration aggregate read against the Darling store contract +/// (no live Postgres). This is the SEVERITY companion to the blocking-incident COUNT trend: it buckets the +/// raw blocked-process rows by minute and aggregates each block's wait_time_ms into total / max / avg +/// duration. Critically it reuses the count trend's EXACT XE-preferred / DMV-fallback source selection +/// (WHERE NOT EXISTS (SELECT 1 FROM bpr)) so the severity and count surfaces reconcile. Postgres +/// dialect adaptations mirror the sibling reads: COUNT(*) / SUM are read as bigint (SUM CAST back from +/// numeric), AVG CAST to double precision. +/// +public sealed class ViewerBlockingStatsSqlTests +{ + [Fact] + public void BlockingDurationStatsSql_XePreferred_DmvFallbackOnlyWhenNoXe() + { + Assert.Contains("FROM v_blocked_process_reports", ViewerDataService.BlockingDurationStatsSql, StringComparison.Ordinal); + Assert.Contains("FROM v_dmv_blocking_snapshots", ViewerDataService.BlockingDurationStatsSql, StringComparison.Ordinal); + /* The DMV branch contributes only when the XE source has no rows in the window — the SAME exclusion + as the blocking-incident count trend, so the two surfaces reconcile row-for-row. */ + Assert.Contains("WHERE NOT EXISTS (SELECT 1 FROM bpr)", ViewerDataService.BlockingDurationStatsSql, StringComparison.Ordinal); + } + + [Fact] + public void BlockingDurationStatsSql_AggregatesWaitTimeMs_TotalMaxAvg() + { + /* The duration source is each blocked process's wait_time_ms (the verified block-duration column). */ + Assert.Contains("SUM(wait_time_ms)", ViewerDataService.BlockingDurationStatsSql, StringComparison.Ordinal); + Assert.Contains("MAX(wait_time_ms)", ViewerDataService.BlockingDurationStatsSql, StringComparison.Ordinal); + Assert.Contains("AVG(wait_time_ms)", ViewerDataService.BlockingDurationStatsSql, StringComparison.Ordinal); + Assert.Contains("COUNT(*) AS event_count", ViewerDataService.BlockingDurationStatsSql, StringComparison.Ordinal); + /* SUM of a bigint column is numeric in Postgres → CAST back to bigint for the typed GetInt64 reader; + AVG is numeric → CAST to double precision for GetDouble (the sibling reads' exact adaptations). */ + Assert.Contains("CAST(SUM(wait_time_ms) AS bigint)", ViewerDataService.BlockingDurationStatsSql, StringComparison.Ordinal); + Assert.Contains("CAST(AVG(wait_time_ms) AS double precision)", ViewerDataService.BlockingDurationStatsSql, StringComparison.Ordinal); + } + + [Fact] + public void BlockingDurationStatsSql_BucketsPerMinute_BothWindowBoundsOnEventTime() + { + /* Bucketed on event_time (when blocking happened), not collection_time — matching the count trend so + the buckets line up 1:1 across the severity and count charts. */ + Assert.Contains("DATE_TRUNC('minute', event_time)", ViewerDataService.BlockingDurationStatsSql, StringComparison.Ordinal); + Assert.Contains("GROUP BY DATE_TRUNC('minute', event_time)", ViewerDataService.BlockingDurationStatsSql, StringComparison.Ordinal); + /* Both window bounds are applied (the XE and DMV CTEs each carry them). */ + Assert.Contains("event_time >= $2 AND event_time <= $3", ViewerDataService.BlockingDurationStatsSql, StringComparison.Ordinal); + } + + [Fact] + public void BlockingDurationStatsSql_PgDialect_PositionalParams_NoBareNow_NoNLiterals() + { + var sql = ViewerDataService.BlockingDurationStatsSql; + Assert.DoesNotContain("now(", sql.ToLowerInvariant()); + Assert.DoesNotContain("N'", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + Assert.Contains("$1", sql, StringComparison.Ordinal); + Assert.Contains("$2", sql, StringComparison.Ordinal); + Assert.Contains("$3", sql, StringComparison.Ordinal); + } + + [Fact] + public void BlockingDurationStats_ReadsWaitTimeMsColumnThatExistsInBothGeneratedTables() + { + /* The verified duration column — wait_time_ms — must exist in BOTH the XE-report table and the + DMV-snapshot fallback table (the read UNION-ALLs the two aggregates). */ + Assert.Equal("blocked_process_reports", BlockedProcessReportCollector.Instance.TargetTable); + Assert.Equal("dmv_blocking_snapshots", DmvBlockingSnapshotCollector.Instance.TargetTable); + + var bprDdl = PgSchemaGenerator.CreateTable(BlockedProcessReportCollector.Instance); + var dmvDdl = PgSchemaGenerator.CreateTable(DmvBlockingSnapshotCollector.Instance); + foreach (var column in new[] { "event_time", "wait_time_ms" }) + { + Assert.Contains(column, bprDdl, StringComparison.Ordinal); + Assert.Contains(column, dmvDdl, StringComparison.Ordinal); + } + } +} + +/// +/// Gated (DARLING_TEST_PG) live round-trips for the blocking-duration aggregate: proves the per-minute +/// bucketing, the total / max / avg wait_time_ms math, and the XE-preferred / DMV-fallback reconciliation +/// (DMV excluded while any XE row exists in the window; contributes once the XE rows are gone). Shares the +/// serialized "live-postgres" collection so the row churn can't race another class; uses a negative sentinel +/// server_id and cleans up in finally. +/// +[Collection("live-postgres")] +public sealed class ViewerBlockingStatsLivePostgresTests +{ + private const int StatsServerId = -972001; + private const string ServerName = "viewer-blocking-stats-e2e"; + + [Fact] + public async Task BlockingDurationStats_BucketsPerMinute_TotalMaxAvg_XePreferred_DmvFallback_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live blocking-duration-stats test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "blocked_process_reports", StatsServerId); + await DeleteRowsAsync(connection, "dmv_blocking_snapshots", StatsServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var baseTime = TruncateToMinute(DateTime.UtcNow.AddMinutes(-10)); + var minuteA = baseTime; + var minuteB = baseTime.AddMinutes(3); + + /* minute A: two XE blocks (1000 ms + 3000 ms); minute B: one XE block (500 ms). Plus two DMV + rows in minute A (9999 ms) that must be EXCLUDED while any XE row exists in the window. */ + await InsertBlockedProcessReportAsync(connection, StatsServerId, minuteA, minuteA.AddSeconds(5), 1000); + await InsertBlockedProcessReportAsync(connection, StatsServerId, minuteA, minuteA.AddSeconds(40), 3000); + await InsertBlockedProcessReportAsync(connection, StatsServerId, minuteB, minuteB.AddSeconds(10), 500); + await InsertDmvBlockingSnapshotAsync(connection, StatsServerId, minuteA, minuteA.AddSeconds(20), 9999); + await InsertDmvBlockingSnapshotAsync(connection, StatsServerId, minuteA, minuteA.AddSeconds(50), 9999); + + var withXe = await viewer.GetBlockingDurationStatsAsync(StatsServerId, baseTime.AddMinutes(-1), baseTime.AddMinutes(10)); + + /* Only the XE buckets appear (DMV excluded by WHERE NOT EXISTS): A then B, ordered by bucket. */ + Assert.Equal(2, withXe.Count); + Assert.True(withXe[0].Time < withXe[1].Time); + + /* minute A: 2 events, total 4000, max 3000, avg 2000. */ + Assert.Equal(2, withXe[0].EventCount); + Assert.Equal(4000, withXe[0].TotalDurationMs); + Assert.Equal(3000, withXe[0].MaxDurationMs); + Assert.Equal(2000.0, withXe[0].AvgDurationMs, precision: 3); + + /* minute B: 1 event, total 500, max 500, avg 500. */ + Assert.Equal(1, withXe[1].EventCount); + Assert.Equal(500, withXe[1].TotalDurationMs); + Assert.Equal(500, withXe[1].MaxDurationMs); + Assert.Equal(500.0, withXe[1].AvgDurationMs, precision: 3); + + /* Remove the XE rows: now the DMV fallback contributes its two minute-A rows (9999 ms each). */ + await DeleteRowsAsync(connection, "blocked_process_reports", StatsServerId); + var dmvOnly = await viewer.GetBlockingDurationStatsAsync(StatsServerId, baseTime.AddMinutes(-1), baseTime.AddMinutes(10)); + + Assert.Single(dmvOnly); + Assert.Equal(2, dmvOnly[0].EventCount); + Assert.Equal(19998, dmvOnly[0].TotalDurationMs); + Assert.Equal(9999, dmvOnly[0].MaxDurationMs); + Assert.Equal(9999.0, dmvOnly[0].AvgDurationMs, precision: 3); + } + finally + { + await DeleteRowsAsync(connection, "blocked_process_reports", StatsServerId); + await DeleteRowsAsync(connection, "dmv_blocking_snapshots", StatsServerId); + } + } + + private static async Task InsertBlockedProcessReportAsync( + NpgsqlConnection connection, int serverId, DateTime collectionTimeUtc, DateTime eventTimeUtc, long waitTimeMs) + { + using var command = new NpgsqlCommand(@" +INSERT INTO blocked_process_reports + (blocked_report_id, collection_time, server_id, server_name, event_time, database_name, + blocked_spid, blocking_spid, wait_time_ms, lock_mode, blocked_sql_text, blocking_sql_text, + blocked_process_report_xml, contentious_object) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(DateTime.SpecifyKind(eventTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue("AppDb"); + command.Parameters.AddWithValue(55); + command.Parameters.AddWithValue(60); + command.Parameters.AddWithValue(waitTimeMs); + command.Parameters.AddWithValue("X"); + command.Parameters.AddWithValue("SELECT 1"); + command.Parameters.AddWithValue("UPDATE t SET c = 1"); + command.Parameters.AddWithValue(""); + command.Parameters.AddWithValue("dbo.t"); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertDmvBlockingSnapshotAsync( + NpgsqlConnection connection, int serverId, DateTime collectionTimeUtc, DateTime eventTimeUtc, long waitTimeMs) + { + using var command = new NpgsqlCommand(@" +INSERT INTO dmv_blocking_snapshots + (collection_id, collection_time, server_id, server_name, event_time, database_name, + blocked_spid, blocking_spid, wait_time_ms, lock_mode, blocked_sql_text, blocking_sql_text, contentious_object) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(DateTime.SpecifyKind(eventTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue("AppDb"); + command.Parameters.AddWithValue(155); + command.Parameters.AddWithValue(160); + command.Parameters.AddWithValue(waitTimeMs); + command.Parameters.AddWithValue("S"); + command.Parameters.AddWithValue("SELECT 2"); + command.Parameters.AddWithValue("UPDATE t SET c = 2"); + command.Parameters.AddWithValue("dbo.t"); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + /* The duration aggregate buckets by DATE_TRUNC('minute', event_time), so the test anchors on a minute + boundary — otherwise added seconds could spill into the next minute and split a bucket. */ + private static DateTime TruncateToMinute(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerMinute)), DateTimeKind.Unspecified); + + private static async Task DeleteRowsAsync(NpgsqlConnection connection, string table, int serverId) + { + using var cleanup = new NpgsqlCommand($"DELETE FROM {table} WHERE server_id = {serverId};", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } +} diff --git a/Darling/Darling.Tests/ViewerCollectorCoverageTests.cs b/Darling/Darling.Tests/ViewerCollectorCoverageTests.cs new file mode 100644 index 000000000..a89281ce7 --- /dev/null +++ b/Darling/Darling.Tests/ViewerCollectorCoverageTests.cs @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Darling.Tests; + +/// +/// THE COVERAGE PIN (parity board Tier 0), Darling side — the mirror of Lite's +/// CollectorViewerCoverageTests. Every collector table in must have +/// a consumer in the Darling viewer's reader layer +/// (PerformanceMonitor.Darling.Viewer/ViewerDataService*.cs) — referenced directly by table name or +/// via its v_ view — OR be listed in . +/// +/// Darling currently reads EVERY catalog table, so the allow-list is empty and this pin is fully green. +/// Its job is forward protection: if a new collector is added to the catalog (both apps generate/consume +/// from it) and the Darling viewer never wires a reader for it, this goes red instead of shipping a +/// silently-invisible collector. +/// +/// RATCHET: same contract as the Lite mirror — the allow-list can only shrink, +/// deletes stale entries the moment a reader appears, +/// and rejects a name that is not a real catalog table. +/// +/// Text-scans the reader source (no Postgres / WPF needed) and locates the viewer via +/// , exactly like ThemeCompletenessTests. +/// +public sealed class ViewerCollectorCoverageTests +{ + /// + /// Collector tables the Darling service stores but whose data no viewer reader surfaces yet. Empty + /// today — the viewer has a reader for all 35 catalog tables. A genuinely store-only/headless table + /// (one deliberately never shown) would go here with a comment; an unbuilt-UI table would go here with + /// a // UNBUILT UI (parity board Tier 1) -- remove when the tab ships comment. + /// + private static readonly HashSet KnownStoreOnlyOrUnbuiltTables = new(StringComparer.OrdinalIgnoreCase) + { + // (empty) -- the Darling viewer reads every collector table. + }; + + [Fact] + public void EveryCollectorTable_HasAViewerReader_OrIsAllowListed() + { + var readerText = ReaderLayerText(); + + var uncovered = new List(); + foreach (var definition in CollectorCatalog.All) + { + var table = definition.TargetTable; + if (ReferencedIn(readerText, table)) + { + continue; // read directly or via its v_ view + } + if (KnownStoreOnlyOrUnbuiltTables.Contains(table)) + { + continue; // tracked debt — the ratchet keeps this list shrinking + } + uncovered.Add(table); + } + + Assert.True(uncovered.Count == 0, + "Collector table(s) have no Darling viewer reader and are not allow-listed. Add a reader in " + + "PerformanceMonitor.Darling.Viewer/ViewerDataService*.cs, or — if intentionally store-only/" + + "unbuilt — add the table to KnownStoreOnlyOrUnbuiltTables with a comment:\n " + + string.Join("\n ", uncovered.OrderBy(t => t, StringComparer.Ordinal))); + } + + [Fact] + public void AllowList_NamesOnlyRealCatalogTables() + { + var catalog = CollectorCatalog.All + .Select(d => d.TargetTable) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var unknown = KnownStoreOnlyOrUnbuiltTables + .Where(t => !catalog.Contains(t)) + .OrderBy(t => t, StringComparer.Ordinal) + .ToList(); + + Assert.True(unknown.Count == 0, + "Allow-list names table(s) that are not in CollectorCatalog (typo, or a removed/renamed " + + "collector): " + string.Join(", ", unknown)); + } + + [Fact] + public void AllowList_HasNoEntryThatIsActuallyRead() + { + // The ratchet enforcement: once a table gains a reader, its allow-list entry is stale debt and + // must be deleted. This fails the build until it is, so the list can only shrink. + var readerText = ReaderLayerText(); + + var stale = KnownStoreOnlyOrUnbuiltTables + .Where(t => ReferencedIn(readerText, t)) + .OrderBy(t => t, StringComparer.Ordinal) + .ToList(); + + Assert.True(stale.Count == 0, + "Allow-list entr(y/ies) now HAVE a Darling viewer reader — delete them from " + + "KnownStoreOnlyOrUnbuiltTables: " + string.Join(", ", stale)); + } + + /// A table is "referenced" if its name appears anywhere in the reader source — this matches + /// both a direct FROM table and the usual FROM v_table view form (the view name contains + /// the table name as a substring). No catalog table name is a substring of another, so this is + /// collision-free across the catalog. + private static bool ReferencedIn(string readerText, string table) => + readerText.Contains(table, StringComparison.OrdinalIgnoreCase); + + /// Concatenated text of the Darling viewer's reader layer + /// (PerformanceMonitor.Darling.Viewer/ViewerDataService*.cs), located from this test file's + /// compile-time path (Darling.Tests sits at Darling/Darling.Tests/; the viewer is the sibling + /// Darling/PerformanceMonitor.Darling.Viewer/). + private static string ReaderLayerText([CallerFilePath] string thisFile = "") + { + var testDir = Path.GetDirectoryName(thisFile)!; + var readerDir = Path.GetFullPath(Path.Combine(testDir, "..", "PerformanceMonitor.Darling.Viewer")); + + // Enumerate all *.cs and filter by filename prefix rather than passing "ViewerDataService*.cs" as the + // search pattern: Directory.EnumerateFiles matches the pattern against BOTH the long name and the 8.3 + // short name, which makes multi-dot names (e.g. ViewerDataService.FinOps.IndexAnalysis.cs) match + // unreliably. A plain StartsWith is unambiguous. The obj/bin exclusion checks whole path SEGMENTS, + // not a substring — a substring "obj"/"bin" would wrongly match a source file whose name contains it. + var files = Directory + .EnumerateFiles(readerDir, "*.cs", SearchOption.AllDirectories) + .Where(f => Path.GetFileName(f).StartsWith("ViewerDataService", StringComparison.OrdinalIgnoreCase)) + .Where(f => !HasBuildOutputSegment(f)) + .ToList(); + + Assert.False(files.Count == 0, + $"No ViewerDataService*.cs files found under {readerDir} — the coverage pin cannot read the " + + "Darling viewer's reader layer (did the reader layer move?)."); + + return string.Join("\n", files.Select(File.ReadAllText)); + } + + /// True if any whole path segment is a build-output directory (obj/bin). Segment-based, not a + /// substring test — a plain Contains("obj") would false-positive on a source file whose name embeds it. + private static bool HasBuildOutputSegment(string path) => + path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + .Any(seg => seg.Equals("obj", StringComparison.OrdinalIgnoreCase) || + seg.Equals("bin", StringComparison.OrdinalIgnoreCase)); +} diff --git a/Darling/Darling.Tests/ViewerConfigChangesTests.cs b/Darling/Darling.Tests/ViewerConfigChangesTests.cs new file mode 100644 index 000000000..9cb8f6cd7 --- /dev/null +++ b/Darling/Darling.Tests/ViewerConfigChangesTests.cs @@ -0,0 +1,558 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +using Snap = PerformanceMonitor.Common.ConfigChangeDiff; + +namespace Darling.Tests; + +/// +/// Pins the Configuration Changes tab's snapshot-read SQL (the Dashboard's ConfigChangesContent ported to the +/// viewer). Unlike the latest-snapshot Configuration reads, the change reads are WINDOWED — but only the UPPER +/// bound (capture_time <= $2, the window end) is applied in SQL; the window START is applied to the +/// computed change_time in C# (see ), so the snapshot immediately +/// BEFORE the window is kept as the diff baseline. That mirrors the Dashboard, whose report.*_changes views LAG +/// over the full history and then filter change_time to [from,to]. Reads take server_id ($1) and window-end ($2) +/// only — never a lower $3 (which would drop the baseline). +/// +public sealed class ViewerConfigChangesSqlTests +{ + private static string StripWhitespace(string sql) => + new string(sql.Where(c => !char.IsWhiteSpace(c)).ToArray()); + + [Fact] + public void ServerConfigChangesSnapshotsSql_ReadsView_UpperBounded_OrderedForPerNameDiff() + { + var sql = ViewerDataService.ServerConfigChangesSnapshotsSql; + Assert.Contains("FROM v_server_config", sql, StringComparison.Ordinal); + Assert.Contains("capture_time", sql, StringComparison.Ordinal); + Assert.Contains("value_configured", sql, StringComparison.Ordinal); + Assert.Contains("value_in_use", sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("capture_time <= $2", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY configuration_name, capture_time", sql, StringComparison.Ordinal); + } + + [Fact] + public void DatabaseConfigChangesSnapshotsSql_ReadsView_CastsSettingsToText_UpperBounded() + { + var sql = ViewerDataService.DatabaseConfigChangesSnapshotsSql; + Assert.Contains("FROM v_database_config", sql, StringComparison.Ordinal); + Assert.Contains("is_read_committed_snapshot_on::text", sql, StringComparison.Ordinal); + Assert.Contains("compatibility_level::text", sql, StringComparison.Ordinal); + Assert.Contains("is_optimized_locking_on::text", sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("capture_time <= $2", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY database_name, capture_time", sql, StringComparison.Ordinal); + } + + [Fact] + public void TraceFlagChangesSnapshotsSql_ReadsView_UpperBounded_OrderedForSetDiff() + { + var sql = ViewerDataService.TraceFlagChangesSnapshotsSql; + Assert.Contains("FROM v_trace_flags", sql, StringComparison.Ordinal); + Assert.Contains("trace_flag", sql, StringComparison.Ordinal); + Assert.Contains("is_global", sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("capture_time <= $2", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY capture_time, trace_flag", sql, StringComparison.Ordinal); + } + + [Theory] + [InlineData("server")] + [InlineData("database")] + [InlineData("traceflags")] + public void ChangeReads_PgDialect_ServerIdAndWindowEnd_NoLowerBound_NoBareNow_NoNLiterals(string which) + { + var sql = which switch + { + "server" => ViewerDataService.ServerConfigChangesSnapshotsSql, + "database" => ViewerDataService.DatabaseConfigChangesSnapshotsSql, + _ => ViewerDataService.TraceFlagChangesSnapshotsSql, + }; + + var lower = sql.ToLowerInvariant(); + Assert.DoesNotContain("getdate", lower); + Assert.DoesNotContain("now(", lower); + Assert.DoesNotContain("convert(", lower); /* ::text casts, not T-SQL CONVERT */ + Assert.DoesNotContain("top (", lower); + Assert.DoesNotContain("isnull(", lower); + Assert.DoesNotContain("N'", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + Assert.Contains("$1", sql, StringComparison.Ordinal); + Assert.Contains("$2", sql, StringComparison.Ordinal); + /* Upper bound only — the window START is applied to change_time in C# so the pre-window baseline is + kept for correct left-edge change detection. A lower $3 read bound would under-report. */ + if (which == "database") + { + /* #1319: the database-config change read gains the global database filter ($3, a nullable + text[] applied as database_name = ANY($3)) — a database-name predicate, not a lower time bound. */ + Assert.Contains("database_name = ANY($3)", sql, StringComparison.Ordinal); + } + else + { + Assert.DoesNotContain("$3", sql, StringComparison.Ordinal); + } + } + + [Fact] + public void DatabaseConfigChangeSettingNames_Match27CollectorColumns_InOrder() + { + var table = PgSchemaGenerator.CreateTable(DatabaseConfigCollector.Instance); + Assert.Equal(27, ConfigChangeDiff.DatabaseConfigChangeSettingNames.Count); + foreach (var name in ConfigChangeDiff.DatabaseConfigChangeSettingNames) + Assert.Contains(name, table, StringComparison.Ordinal); + + /* The wide-row diff walks these positionally, so the order must match the SELECT's column order. */ + Assert.Equal("state_desc", ConfigChangeDiff.DatabaseConfigChangeSettingNames[0]); + Assert.Equal("is_optimized_locking_on", ConfigChangeDiff.DatabaseConfigChangeSettingNames[^1]); + } + + [Fact] + public void ServerConfigChangesSnapshotsSql_ReadsColumnsThatExistInTheGeneratedTable() + { + Assert.Equal("server_config", ServerConfigCollector.Instance.TargetTable); + var ddl = PgSchemaGenerator.CreateTable(ServerConfigCollector.Instance); + foreach (var column in new[] { "capture_time", "configuration_name", "value_configured", "value_in_use", "is_dynamic", "is_advanced" }) + Assert.Contains(column, ddl, StringComparison.Ordinal); + } + + [Fact] + public void TraceFlagChangesSnapshotsSql_ReadsColumnsThatExistInTheGeneratedTable() + { + Assert.Equal("trace_flags", TraceFlagsCollector.Instance.TargetTable); + var ddl = PgSchemaGenerator.CreateTable(TraceFlagsCollector.Instance); + foreach (var column in new[] { "capture_time", "trace_flag", "status", "is_global", "is_session" }) + Assert.Contains(column, ddl, StringComparison.Ordinal); + } + + [Fact] + public void DatabaseConfigChangesSnapshotsSql_ReadsColumnsThatExistInTheGeneratedTable() + { + Assert.Equal("database_config", DatabaseConfigCollector.Instance.TargetTable); + var ddl = PgSchemaGenerator.CreateTable(DatabaseConfigCollector.Instance); + Assert.Contains("capture_time", ddl, StringComparison.Ordinal); + Assert.Contains("database_name", ddl, StringComparison.Ordinal); + foreach (var column in ConfigChangeDiff.DatabaseConfigChangeSettingNames) + Assert.Contains(column, ddl, StringComparison.Ordinal); + } +} + +/// +/// The C# snapshot-diff logic (pure, no live PG) — mirrors DarlingConfigHistoryReader's diff (PR #1421) plus the +/// window's UPPER bound and the derived display columns the Dashboard grids show. Exercises value moves, the +/// >= 2-snapshot rule, BOTH window edges on change_time, the crucial pre-window baseline (a change landing on +/// the first in-window snapshot is still detected), the wide-row UNPIVOT, trace-flag enable/disable/modify, and +/// the derived requires_restart / change_description / scope columns. +/// +public sealed class ViewerConfigChangesDiffTests +{ + private static readonly DateTime T0 = new(2026, 7, 1, 0, 0, 0, DateTimeKind.Unspecified); + private static readonly DateTime T1 = new(2026, 7, 2, 0, 0, 0, DateTimeKind.Unspecified); + private static readonly DateTime T2 = new(2026, 7, 3, 0, 0, 0, DateTimeKind.Unspecified); + private static readonly DateTime T3 = new(2026, 7, 4, 0, 0, 0, DateTimeKind.Unspecified); + + /* ---------------- server config ---------------- */ + + [Fact] + public void ServerConfigChanges_DetectsValueMove_NewestFirst() + { + var snapshots = new List + { + new(T0, "max degree of parallelism", 0, 0, true, true), + new(T1, "max degree of parallelism", 4, 4, true, true), /* changed */ + new(T0, "cost threshold for parallelism", 5, 5, true, true), + new(T1, "cost threshold for parallelism", 5, 5, true, true), /* unchanged */ + }; + + var changes = ConfigChangeDiff.DiffServerConfigChanges(snapshots, T0, T3); + var change = Assert.Single(changes); + Assert.Equal("max degree of parallelism", change.ConfigurationName); + Assert.Equal(0L, change.OldValueConfigured); + Assert.Equal(4L, change.NewValueConfigured); + Assert.Equal(T1, change.ChangeTime); + } + + [Fact] + public void ServerConfigChanges_SingleSnapshot_YieldsNothing() + { + var single = new List { new(T0, "max server memory (MB)", 8000, 8000, true, true) }; + Assert.Empty(ConfigChangeDiff.DiffServerConfigChanges(single, T0, T3)); + } + + [Fact] + public void ServerConfigChanges_WindowBoundsBothSides_OnChangeTime() + { + var two = new List + { + new(T1, "max server memory (MB)", 8000, 8000, true, true), + new(T2, "max server memory (MB)", 16000, 16000, true, true), /* change at T2 */ + }; + + /* Change at T2 is inside [T0, T3]. */ + Assert.Single(ConfigChangeDiff.DiffServerConfigChanges(two, T0, T3)); + /* Window START after the change (start T3) excludes it. */ + Assert.Empty(ConfigChangeDiff.DiffServerConfigChanges(two, T3, T3)); + /* Window END before the change (end T1) excludes it. */ + Assert.Empty(ConfigChangeDiff.DiffServerConfigChanges(two, T0, T1)); + } + + [Fact] + public void ServerConfigChanges_PreWindowBaseline_ChangeOnFirstInWindowSnapshot_IsDetected() + { + /* T0 is BEFORE the window [T1, T3]; the value moves T0 -> T1. The change at T1 must still be detected, + using the pre-window T0 as the diff baseline — this is why the read is not lower-bounded. */ + var snapshots = new List + { + new(T0, "max degree of parallelism", 0, 0, true, true), + new(T1, "max degree of parallelism", 8, 8, true, true), + }; + + var change = Assert.Single(ConfigChangeDiff.DiffServerConfigChanges(snapshots, T1, T3)); + Assert.Equal(0L, change.OldValueConfigured); + Assert.Equal(8L, change.NewValueConfigured); + Assert.Equal(T1, change.ChangeTime); + } + + [Fact] + public void ServerConfigChanges_DerivedColumns_RestartAndDescription() + { + /* Non-dynamic, new configured != new in-use => requires restart; configured value moved. */ + var restartNeeded = new List + { + new(T0, "max server memory (MB)", 8000, 8000, false, false), + new(T1, "max server memory (MB)", 16000, 8000, false, false), + }; + var change = Assert.Single(ConfigChangeDiff.DiffServerConfigChanges(restartNeeded, T0, T3)); + Assert.True(change.RequiresRestart); + Assert.Equal("Yes", change.RequiresRestartDisplay); + Assert.Equal("No", change.DynamicDisplay); + Assert.Equal("Configured value changed from 8000 to 16000", change.ChangeDescription); + + /* Dynamic setting whose in-use caught up => no restart; in-use narrative when configured is stable. */ + var inUseMove = new List + { + new(T0, "cost threshold for parallelism", 50, 5, true, true), + new(T1, "cost threshold for parallelism", 50, 50, true, true), + }; + var c2 = Assert.Single(ConfigChangeDiff.DiffServerConfigChanges(inUseMove, T0, T3)); + Assert.False(c2.RequiresRestart); + Assert.Equal("In-use value changed from 5 to 50", c2.ChangeDescription); + } + + /* ---------------- database config (wide-row unpivot) ---------------- */ + + [Fact] + public void DatabaseConfigChanges_UnpivotsWideRow_NamesTheChangedColumn_WithDescription() + { + const int rcsiIndex = 10; /* is_read_committed_snapshot_on */ + Assert.Equal("is_read_committed_snapshot_on", ConfigChangeDiff.DatabaseConfigChangeSettingNames[rcsiIndex]); + + var snapshots = new List + { + new(T0, "StackOverflow", DbValues()), + new(T1, "StackOverflow", DbValues((rcsiIndex, "true"))), /* RCSI flipped on */ + }; + + var change = Assert.Single(ConfigChangeDiff.DiffDatabaseConfigChanges(snapshots, T0, T3)); + Assert.Equal("StackOverflow", change.DatabaseName); + Assert.Equal("is_read_committed_snapshot_on", change.SettingName); + Assert.Equal("false", change.OldValue); + Assert.Equal("true", change.NewValue); + Assert.Equal(T1, change.ChangeTime); + Assert.Equal("Changed from false to true", change.ChangeDescription); + } + + [Fact] + public void DatabaseConfigChanges_PerDatabase_UnchangedYieldsNothing() + { + var snapshots = new List + { + new(T0, "DbA", DbValues()), + new(T1, "DbA", DbValues()), /* no change */ + new(T0, "DbB", DbValues((3, "FULL"))), + new(T1, "DbB", DbValues((3, "SIMPLE"))), /* recovery_model changed */ + }; + + var change = Assert.Single(ConfigChangeDiff.DiffDatabaseConfigChanges(snapshots, T0, T3)); + Assert.Equal("DbB", change.DatabaseName); + Assert.Equal("recovery_model", change.SettingName); + Assert.Equal("FULL", change.OldValue); + Assert.Equal("SIMPLE", change.NewValue); + } + + [Fact] + public void DatabaseConfigChanges_NullTransitions_SetAndCleared() + { + var setValue = new List + { + new(T0, "Db", DbValues((3, null))), + new(T1, "Db", DbValues((3, "SIMPLE"))), + }; + Assert.Equal("Set to: SIMPLE", Assert.Single(ConfigChangeDiff.DiffDatabaseConfigChanges(setValue, T0, T3)).ChangeDescription); + + var cleared = new List + { + new(T0, "Db", DbValues((3, "SIMPLE"))), + new(T1, "Db", DbValues((3, null))), + }; + Assert.Equal("Cleared (was: SIMPLE)", Assert.Single(ConfigChangeDiff.DiffDatabaseConfigChanges(cleared, T0, T3)).ChangeDescription); + } + + /* ---------------- trace flags (set-diff) ---------------- */ + + [Fact] + public void TraceFlagChanges_DetectsEnableDisableModify() + { + var snapshots = new List + { + /* T0: 3226 global on. */ + new(T0, 3226, true, true, false), + /* T1: 3226 still on, 1117 appears (enabled). */ + new(T1, 3226, true, true, false), + new(T1, 1117, true, true, false), + /* T2: 1117 disappears (disabled), 3226 becomes session-scoped (modified). */ + new(T2, 3226, true, false, true), + }; + + var changes = ConfigChangeDiff.DiffTraceFlagChanges(snapshots, T0, T3); + Assert.Equal(3, changes.Count); + + var enabled = Assert.Single(changes, c => c.TraceFlag == 1117 && c.ChangeType == "enabled"); + Assert.Equal(T1, enabled.ChangeTime); + Assert.Equal("Trace flag 1117 ENABLED", enabled.ChangeDescription); + Assert.Equal("", enabled.PreviousStatusDisplay); + Assert.Equal("ON", enabled.NewStatusDisplay); + Assert.Equal("GLOBAL", enabled.Scope); + + var disabled = Assert.Single(changes, c => c.TraceFlag == 1117 && c.ChangeType == "disabled"); + Assert.Equal(T2, disabled.ChangeTime); + Assert.Equal("Trace flag 1117 DISABLED", disabled.ChangeDescription); + Assert.Equal("", disabled.NewStatusDisplay); + + var modified = Assert.Single(changes, c => c.TraceFlag == 3226 && c.ChangeType == "modified"); + Assert.Equal("SESSION", modified.Scope); + Assert.Equal("No", modified.GlobalDisplay); + Assert.Equal("Yes", modified.SessionDisplay); + Assert.Equal("Trace flag 3226 scope changed", modified.ChangeDescription); + } + + [Fact] + public void TraceFlagChanges_StableYieldsNothing_AndWindowBoundsBothSides() + { + var stable = new List + { + new(T0, 3226, true, true, false), + new(T1, 3226, true, true, false), + }; + Assert.Empty(ConfigChangeDiff.DiffTraceFlagChanges(stable, T0, T3)); + + var enableAtT2 = new List + { + new(T1, 3226, true, true, false), + new(T1, 1117, true, true, false), + new(T2, 3226, true, true, false), /* 1117 gone at T2 => disabled at T2 */ + }; + Assert.Single(ConfigChangeDiff.DiffTraceFlagChanges(enableAtT2, T0, T3)); /* T2 in window */ + Assert.Empty(ConfigChangeDiff.DiffTraceFlagChanges(enableAtT2, T0, T1)); /* window ends at T1 */ + Assert.Empty(ConfigChangeDiff.DiffTraceFlagChanges(enableAtT2, T3, T3)); /* window starts at T3 */ + } + + private static string?[] DbValues(params (int Index, string? Value)[] overrides) + { + var values = new string?[ConfigChangeDiff.DatabaseConfigChangeSettingNames.Count]; + for (var i = 0; i < values.Length; i++) + values[i] = "false"; + foreach (var (index, value) in overrides) + values[index] = value; + return values; + } +} + +/// +/// Pins the reworked inner-tab order: the resource run (Overview … Configuration Changes) followed by a +/// diagnostics tail — Daily Summary -> System Events -> Latches & Spinlocks -> Collection Health. These +/// constants are the source of truth for LoadInnerTabAsync's switch + the drill-down SelectedIndex navigation, +/// so a stale constant would route to the wrong loader / tab. Must match the <TabItem> order in +/// ViewerServerTab.xaml. +/// +public sealed class ViewerConfigChangesTabTests +{ + [Fact] + public void InnerTabIndices_MatchTheReworkedDiagnosticsTailOrder() + { + /* Resource run. */ + Assert.Equal(0, ViewerServerTab.OverviewInnerTabIndex); + Assert.Equal(1, ViewerServerTab.WaitStatsInnerTabIndex); + Assert.Equal(2, ViewerServerTab.QueriesInnerTabIndex); + Assert.Equal(3, ViewerServerTab.PlanViewerInnerTabIndex); + Assert.Equal(4, ViewerServerTab.CpuInnerTabIndex); + Assert.Equal(5, ViewerServerTab.MemoryInnerTabIndex); + Assert.Equal(6, ViewerServerTab.FileIoInnerTabIndex); + Assert.Equal(7, ViewerServerTab.TempDbInnerTabIndex); + Assert.Equal(8, ViewerServerTab.BlockingInnerTabIndex); + Assert.Equal(9, ViewerServerTab.PerfmonInnerTabIndex); + Assert.Equal(10, ViewerServerTab.SessionStatsInnerTabIndex); + Assert.Equal(11, ViewerServerTab.RunningJobsInnerTabIndex); + Assert.Equal(12, ViewerServerTab.ConfigurationInnerTabIndex); + Assert.Equal(13, ViewerServerTab.ConfigChangesInnerTabIndex); + /* Diagnostics tail: Daily Summary -> System Events -> Latches & Spinlocks -> Collection Health. */ + Assert.Equal(14, ViewerServerTab.DailySummaryInnerTabIndex); + Assert.Equal(15, ViewerServerTab.SystemEventsInnerTabIndex); + Assert.Equal(16, ViewerServerTab.LatchSpinlockInnerTabIndex); + Assert.Equal(17, ViewerServerTab.HealthInnerTabIndex); + } +} + +/// +/// Gated (DARLING_TEST_PG) live round-trips for the change reads — validates the snapshot-read SQL end-to-end +/// (columns exist, params bind, the capture_time <= $2 upper bound trims post-window captures while the +/// pre-window baseline is kept). Plants two server-config captures (one changed) and two trace-flag captures (a +/// flag disappears), then asserts the change reads surface the drift and that a window ending before the change +/// hides it. Shares the serialized "live-postgres" collection and cleans up in finally. +/// +[Collection("live-postgres")] +public sealed class ViewerConfigChangesLivePostgresTests +{ + private const int ServerConfigServerId = -970801; + private const int TraceFlagsServerId = -970802; + + [Fact] + public async Task ServerConfigChanges_SurfaceInWindow_ExcludedWhenWindowEndsBeforeChange_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live server-config-changes test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "server_config", ServerConfigServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var older = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + var newer = older.AddMinutes(5); + + await InsertServerConfigAsync(connection, older, "max degree of parallelism", 0, 0, true, true); + await InsertServerConfigAsync(connection, newer, "max degree of parallelism", 4, 4, true, true); + + /* Window covers both captures: the 0 -> 4 change at `newer` surfaces. */ + var inWindow = await viewer.GetServerConfigChangesAsync(ServerConfigServerId, older.AddMinutes(-1), newer.AddMinutes(1)); + var change = Assert.Single(inWindow); + Assert.Equal("max degree of parallelism", change.ConfigurationName); + Assert.Equal(0L, change.OldValueConfigured); + Assert.Equal(4L, change.NewValueConfigured); + + /* Window ends before `newer` (upper bound trims the newer capture) => no change. */ + var trimmed = await viewer.GetServerConfigChangesAsync(ServerConfigServerId, older.AddMinutes(-1), older.AddMinutes(1)); + Assert.Empty(trimmed); + } + finally + { + await DeleteRowsAsync(connection, "server_config", ServerConfigServerId); + } + } + + [Fact] + public async Task TraceFlagChanges_SetDiffDisable_SurfacesInWindow_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live trace-flag-changes test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "trace_flags", TraceFlagsServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var older = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + var newer = older.AddMinutes(5); + + /* 1117 present at the older capture, absent at the newer => 'disabled' at `newer`. 3226 anchors the + newer capture so it exists as a distinct set-diff snapshot. */ + await InsertTraceFlagAsync(connection, older, 1117, status: true, isGlobal: true, isSession: false); + await InsertTraceFlagAsync(connection, newer, 3226, status: true, isGlobal: true, isSession: false); + + var changes = await viewer.GetTraceFlagChangesAsync(TraceFlagsServerId, older.AddMinutes(-1), newer.AddMinutes(1)); + var disabled = Assert.Single(changes, c => c.TraceFlag == 1117); + Assert.Equal("disabled", disabled.ChangeType); + Assert.Equal("Trace flag 1117 DISABLED", disabled.ChangeDescription); + } + finally + { + await DeleteRowsAsync(connection, "trace_flags", TraceFlagsServerId); + } + } + + private static async Task InsertServerConfigAsync( + NpgsqlConnection connection, DateTime captureTimeUtc, + string configurationName, long valueConfigured, long valueInUse, bool isDynamic, bool isAdvanced) + { + using var command = new NpgsqlCommand(@" +INSERT INTO server_config + (config_id, capture_time, server_id, server_name, + configuration_name, value_configured, value_in_use, is_dynamic, is_advanced) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(captureTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(ServerConfigServerId); + command.Parameters.AddWithValue("viewer-server-config-changes-e2e"); + command.Parameters.AddWithValue(configurationName); + command.Parameters.AddWithValue(valueConfigured); + command.Parameters.AddWithValue(valueInUse); + command.Parameters.AddWithValue(isDynamic); + command.Parameters.AddWithValue(isAdvanced); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertTraceFlagAsync( + NpgsqlConnection connection, DateTime captureTimeUtc, + int traceFlag, bool status, bool isGlobal, bool isSession) + { + using var command = new NpgsqlCommand(@" +INSERT INTO trace_flags + (config_id, capture_time, server_id, server_name, + trace_flag, status, is_global, is_session) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(captureTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(TraceFlagsServerId); + command.Parameters.AddWithValue("viewer-trace-flag-changes-e2e"); + command.Parameters.AddWithValue(traceFlag); + command.Parameters.AddWithValue(status); + command.Parameters.AddWithValue(isGlobal); + command.Parameters.AddWithValue(isSession); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static DateTime TruncateToSeconds(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified); + + private static async Task DeleteRowsAsync(NpgsqlConnection connection, string table, int serverId) + { + using var cleanup = new NpgsqlCommand($"DELETE FROM {table} WHERE server_id = {serverId};", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } +} diff --git a/Darling/Darling.Tests/ViewerConfigurationTests.cs b/Darling/Darling.Tests/ViewerConfigurationTests.cs new file mode 100644 index 000000000..f09152223 --- /dev/null +++ b/Darling/Darling.Tests/ViewerConfigurationTests.cs @@ -0,0 +1,527 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the Configuration-tab reads (W1g viewer copy-parity) against the Darling store contract, no +/// live Postgres. Each of the four reads is a latest-snapshot query — the newest capture is chosen in +/// SQL via capture_time = (SELECT MAX(capture_time) ...) against a config passthrough view — so +/// the tests pin the SELECT column lists (the 28-column database-config order is load-bearing: the +/// reader maps by incrementing ordinal), the MAX(capture_time) shape, and that every selected column +/// exists in the collector-generated table. Config reads take only server_id ($1); unlike the +/// windowed reads there is no $2 time bound. +/// +public sealed class ViewerConfigurationSqlTests +{ + /* The 28 database-config columns in Lite's exact SELECT order — the database-config reader maps + them by incrementing ordinal, so this contiguous order is the load-bearing contract. */ + private static readonly string[] DatabaseConfigColumns = + { + "database_name", "state_desc", "compatibility_level", "collation_name", "recovery_model", + "is_read_only", "is_auto_close_on", "is_auto_shrink_on", + "is_auto_create_stats_on", "is_auto_update_stats_on", "is_auto_update_stats_async_on", + "is_read_committed_snapshot_on", "snapshot_isolation_state", "is_parameterization_forced", + "is_query_store_on", "is_encrypted", "is_trustworthy_on", "is_db_chaining_on", + "is_broker_enabled", "is_cdc_enabled", "is_mixed_page_allocation_on", + "log_reuse_wait_desc", "page_verify_option", "target_recovery_time_seconds", "delayed_durability", + "is_accelerated_database_recovery_on", "is_memory_optimized_enabled", "is_optimized_locking_on", + }; + + private static readonly string[] ServerConfigColumns = + { "configuration_name", "value_configured", "value_in_use", "is_dynamic", "is_advanced" }; + + private static readonly string[] ScopedConfigColumns = + { "database_name", "configuration_name", "value", "value_for_secondary" }; + + private static readonly string[] TraceFlagColumns = + { "trace_flag", "status", "is_global", "is_session" }; + + private static string StripWhitespace(string sql) => + new string(sql.Where(c => !char.IsWhiteSpace(c)).ToArray()); + + [Fact] + public void ServerConfigSql_SelectsSnapshotColumns_FromView_LatestCapture() + { + var sql = ViewerDataService.ServerConfigSql; + Assert.Contains("FROM v_server_config", sql, StringComparison.Ordinal); + Assert.Contains("configuration_name,value_configured,value_in_use,is_dynamic,is_advanced", + StripWhitespace(sql), StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("capture_time = (SELECT MAX(capture_time) FROM v_server_config WHERE server_id = $1)", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY configuration_name", sql, StringComparison.Ordinal); + } + + [Fact] + public void DatabaseConfigSql_Pins28ColumnOrdinalOrder_FromView_LatestCapture() + { + var sql = ViewerDataService.DatabaseConfigSql; + Assert.Contains("FROM v_database_config", sql, StringComparison.Ordinal); + + /* Whitespace-stripped contiguous order: a reordered or dropped column would shift the + incrementing-ordinal reader onto the wrong property. */ + Assert.Contains(string.Join(",", DatabaseConfigColumns), StripWhitespace(sql), StringComparison.Ordinal); + + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("capture_time = (SELECT MAX(capture_time) FROM v_database_config WHERE server_id = $1)", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY database_name", sql, StringComparison.Ordinal); + } + + [Fact] + public void DatabaseScopedConfigSql_SelectsSnapshotColumns_FromView_LatestCapture() + { + var sql = ViewerDataService.DatabaseScopedConfigSql; + Assert.Contains("FROM v_database_scoped_config", sql, StringComparison.Ordinal); + Assert.Contains(string.Join(",", ScopedConfigColumns), StripWhitespace(sql), StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("capture_time = (SELECT MAX(capture_time) FROM v_database_scoped_config WHERE server_id = $1)", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY database_name, configuration_name", sql, StringComparison.Ordinal); + } + + [Fact] + public void TraceFlagsSql_SelectsSnapshotColumns_FromView_LatestCapture() + { + var sql = ViewerDataService.TraceFlagsSql; + Assert.Contains("FROM v_trace_flags", sql, StringComparison.Ordinal); + Assert.Contains(string.Join(",", TraceFlagColumns), StripWhitespace(sql), StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("capture_time = (SELECT MAX(capture_time) FROM v_trace_flags WHERE server_id = $1)", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY trace_flag", sql, StringComparison.Ordinal); + } + + [Fact] + public void ServerConfigSql_ReadsColumnsThatExistInTheGeneratedTable() + { + Assert.Equal("server_config", ServerConfigCollector.Instance.TargetTable); + var ddl = PgSchemaGenerator.CreateTable(ServerConfigCollector.Instance); + Assert.Contains("capture_time", ddl, StringComparison.Ordinal); + foreach (var column in ServerConfigColumns) + { + Assert.Contains(column, ddl, StringComparison.Ordinal); + } + } + + [Fact] + public void DatabaseConfigSql_ReadsColumnsThatExistInTheGeneratedTable() + { + Assert.Equal("database_config", DatabaseConfigCollector.Instance.TargetTable); + var ddl = PgSchemaGenerator.CreateTable(DatabaseConfigCollector.Instance); + Assert.Contains("capture_time", ddl, StringComparison.Ordinal); + foreach (var column in DatabaseConfigColumns) + { + Assert.Contains(column, ddl, StringComparison.Ordinal); + } + } + + [Fact] + public void DatabaseScopedConfigSql_ReadsColumnsThatExistInTheGeneratedTable() + { + Assert.Equal("database_scoped_config", DatabaseScopedConfigCollector.Instance.TargetTable); + var ddl = PgSchemaGenerator.CreateTable(DatabaseScopedConfigCollector.Instance); + Assert.Contains("capture_time", ddl, StringComparison.Ordinal); + foreach (var column in ScopedConfigColumns) + { + Assert.Contains(column, ddl, StringComparison.Ordinal); + } + } + + [Fact] + public void TraceFlagsSql_ReadsColumnsThatExistInTheGeneratedTable() + { + Assert.Equal("trace_flags", TraceFlagsCollector.Instance.TargetTable); + var ddl = PgSchemaGenerator.CreateTable(TraceFlagsCollector.Instance); + Assert.Contains("capture_time", ddl, StringComparison.Ordinal); + foreach (var column in TraceFlagColumns) + { + Assert.Contains(column, ddl, StringComparison.Ordinal); + } + } + + [Theory] + [InlineData("server")] + [InlineData("database")] + [InlineData("scoped")] + [InlineData("traceflags")] + public void ConfigReads_PgDialect_ServerIdOnly_NoWindowParam_NoBareNow_NoNLiterals(string which) + { + var sql = which switch + { + "server" => ViewerDataService.ServerConfigSql, + "database" => ViewerDataService.DatabaseConfigSql, + "scoped" => ViewerDataService.DatabaseScopedConfigSql, + _ => ViewerDataService.TraceFlagsSql, + }; + + Assert.DoesNotContain("now(", sql.ToLowerInvariant()); + Assert.DoesNotContain("N'", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + Assert.Contains("$1", sql, StringComparison.Ordinal); + if (which is "database" or "scoped") + { + /* #1319: the database-scoped config reads gain the global database filter — a single nullable + text[] param ($2) applied as database_name = ANY($2). It is NOT a time window (the + latest-snapshot capture_time subquery still reuses $1). */ + Assert.Contains("database_name = ANY($2)", sql, StringComparison.Ordinal); + } + else + { + /* Server-config + trace-flag reads are server-wide (not database-scoped) — server_id ($1) only. */ + Assert.DoesNotContain("$2", sql, StringComparison.Ordinal); + } + } +} + +/// +/// Gated (DARLING_TEST_PG) live round-trips for the four Configuration reads. Each plants two +/// capture_time snapshots (an older and a newer) for a negative sentinel server and asserts the read +/// returns only the newest snapshot's rows, in the read's ORDER BY. The database-config test sets all +/// 28 payload columns to distinct alternating values so the incrementing-ordinal mapping is verified +/// end-to-end (an off-by-one would flip an asserted value). Shares the serialized "live-postgres" +/// collection and cleans up in finally. +/// +[Collection("live-postgres")] +public sealed class ViewerConfigurationLivePostgresTests +{ + private const int ServerConfigServerId = -970701; + private const int DatabaseConfigServerId = -970702; + private const int ScopedConfigServerId = -970703; + private const int TraceFlagsServerId = -970704; + + [Fact] + public async Task ServerConfig_LatestCaptureWins_OrderedByName_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live server-config test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "server_config", ServerConfigServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var older = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + var newer = older.AddMinutes(5); + + /* Older capture — must be shadowed by the newer one. */ + await InsertServerConfigAsync(connection, older, "max degree of parallelism", 0, 0, true, true); + /* Newer capture — the snapshot the read returns. */ + await InsertServerConfigAsync(connection, newer, "max degree of parallelism", 8, 4, true, false); + await InsertServerConfigAsync(connection, newer, "cost threshold for parallelism", 50, 50, true, false); + + var rows = await viewer.GetLatestServerConfigAsync(ServerConfigServerId); + + /* Only the newer capture's two rows, ordered by configuration_name. */ + Assert.Equal(2, rows.Count); + Assert.Equal(new[] { "cost threshold for parallelism", "max degree of parallelism" }, + rows.Select(r => r.ConfigurationName)); + + var maxdop = rows.Single(r => r.ConfigurationName == "max degree of parallelism"); + Assert.Equal(8, maxdop.ValueConfigured); + Assert.Equal(4, maxdop.ValueInUse); + Assert.False(maxdop.ValuesMatch); + Assert.Equal("No", maxdop.AdvancedDisplay); + Assert.Equal("Yes", maxdop.DynamicDisplay); + } + finally + { + await DeleteRowsAsync(connection, "server_config", ServerConfigServerId); + } + } + + [Fact] + public async Task DatabaseConfig_LatestCaptureWins_All28ColumnsMapByOrdinal_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live database-config test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "database_config", DatabaseConfigServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var older = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + var newer = older.AddMinutes(5); + + /* Older capture with a different database name — must be shadowed. */ + await InsertDatabaseConfigOldAsync(connection, older, "ShadowedDb"); + /* Newer capture: all 28 payload columns set to distinct, alternating values. */ + await InsertDatabaseConfigNewAsync(connection, newer, "TestDb"); + + var rows = await viewer.GetLatestDatabaseConfigAsync(DatabaseConfigServerId); + + Assert.Single(rows); + var r = rows[0]; + + /* Spot-checks spanning the ordinal range (0,1,2,3,5,6,11,12,14,18,21,22,23,24,25,27) — an + off-by-one in the incrementing-ordinal reader would flip one of the alternating values. */ + Assert.Equal("TestDb", r.DatabaseName); // 0 + Assert.Equal("ONLINE", r.StateDesc); // 1 + Assert.Equal(160, r.CompatibilityLevel); // 2 + Assert.Equal("SQL_Latin1_General_CP1_CI_AS", r.CollationName); // 3 + Assert.False(r.IsReadOnly); // 5 + Assert.True(r.IsAutoCloseOn); // 6 (catches the 5/6 boundary) + Assert.True(r.IsRcsiOn); // 11 + Assert.Equal("ON", r.SnapshotIsolationState); // 12 + Assert.True(r.IsQueryStoreOn); // 14 + Assert.True(r.IsBrokerEnabled); // 18 + Assert.Equal("NOTHING", r.LogReuseWaitDesc); // 21 + Assert.Equal("CHECKSUM", r.PageVerifyOption); // 22 + Assert.Equal(60, r.TargetRecoveryTimeSeconds); // 23 + Assert.Equal("DISABLED", r.DelayedDurability); // 24 + Assert.True(r.IsAcceleratedDatabaseRecoveryOn); // 25 + Assert.True(r.IsOptimizedLockingOn); // 27 + + /* Display projections (pure C#) resolve as Lite's XAML binds them. */ + Assert.Equal("Yes", r.RcsiDisplay); + Assert.Equal("Yes", r.AutoCloseDisplay); + Assert.Equal("No", r.AutoShrinkDisplay); + Assert.Equal("Yes", r.OptimizedLockingDisplay); + } + finally + { + await DeleteRowsAsync(connection, "database_config", DatabaseConfigServerId); + } + } + + [Fact] + public async Task ScopedConfig_LatestCaptureWins_OrderedByDbThenName_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live scoped-config test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "database_scoped_config", ScopedConfigServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var older = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + var newer = older.AddMinutes(5); + + await InsertScopedConfigAsync(connection, older, "ShadowedDb", "MAXDOP", "0", "0"); + /* Newer capture inserted out of order to prove ORDER BY database_name, configuration_name. */ + await InsertScopedConfigAsync(connection, newer, "db2", "MAXDOP", "8", "8"); + await InsertScopedConfigAsync(connection, newer, "db1", "MAXDOP", "4", "2"); + await InsertScopedConfigAsync(connection, newer, "db1", "LEGACY_CARDINALITY_ESTIMATION", "1", "0"); + + var rows = await viewer.GetLatestDatabaseScopedConfigAsync(ScopedConfigServerId); + + Assert.Equal(3, rows.Count); + Assert.Equal( + new[] { ("db1", "LEGACY_CARDINALITY_ESTIMATION"), ("db1", "MAXDOP"), ("db2", "MAXDOP") }, + rows.Select(r => (r.DatabaseName, r.ConfigurationName))); + + var db1Maxdop = rows.Single(r => r.DatabaseName == "db1" && r.ConfigurationName == "MAXDOP"); + Assert.Equal("4", db1Maxdop.Value); + Assert.Equal("2", db1Maxdop.ValueForSecondary); + } + finally + { + await DeleteRowsAsync(connection, "database_scoped_config", ScopedConfigServerId); + } + } + + [Fact] + public async Task TraceFlags_LatestCaptureWins_OrderedByFlag_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live trace-flags test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "trace_flags", TraceFlagsServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var older = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + var newer = older.AddMinutes(5); + + await InsertTraceFlagAsync(connection, older, 1117, status: true, isGlobal: true, isSession: false); + /* Newer capture inserted high-then-low to prove ORDER BY trace_flag. */ + await InsertTraceFlagAsync(connection, newer, 3226, status: true, isGlobal: true, isSession: false); + await InsertTraceFlagAsync(connection, newer, 1118, status: false, isGlobal: true, isSession: false); + + var rows = await viewer.GetLatestTraceFlagsAsync(TraceFlagsServerId); + + Assert.Equal(2, rows.Count); + Assert.Equal(new[] { 1118, 3226 }, rows.Select(r => r.TraceFlag)); + + Assert.Equal("Disabled", rows[0].StatusDisplay); // 1118, status false + Assert.Equal("Enabled", rows[1].StatusDisplay); // 3226, status true + Assert.Equal("Yes", rows[1].GlobalDisplay); + Assert.Equal("No", rows[1].SessionDisplay); + } + finally + { + await DeleteRowsAsync(connection, "trace_flags", TraceFlagsServerId); + } + } + + private static async Task InsertServerConfigAsync( + NpgsqlConnection connection, DateTime captureTimeUtc, + string configurationName, long valueConfigured, long valueInUse, bool isDynamic, bool isAdvanced) + { + using var command = new NpgsqlCommand(@" +INSERT INTO server_config + (config_id, capture_time, server_id, server_name, + configuration_name, value_configured, value_in_use, is_dynamic, is_advanced) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(captureTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(ServerConfigServerId); + command.Parameters.AddWithValue("viewer-server-config-e2e"); + command.Parameters.AddWithValue(configurationName); + command.Parameters.AddWithValue(valueConfigured); + command.Parameters.AddWithValue(valueInUse); + command.Parameters.AddWithValue(isDynamic); + command.Parameters.AddWithValue(isAdvanced); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertDatabaseConfigOldAsync(NpgsqlConnection connection, DateTime captureTimeUtc, string databaseName) + { + using var command = new NpgsqlCommand(@" +INSERT INTO database_config + (config_id, capture_time, server_id, server_name, database_name, state_desc, compatibility_level) +VALUES ($1, $2, $3, $4, $5, $6, $7)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(captureTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(DatabaseConfigServerId); + command.Parameters.AddWithValue("viewer-database-config-e2e"); + command.Parameters.AddWithValue(databaseName); + command.Parameters.AddWithValue("RESTORING"); + command.Parameters.AddWithValue(150); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + /* Sets all 28 payload columns to distinct, alternating values — the newer capture the read returns. */ + private static async Task InsertDatabaseConfigNewAsync(NpgsqlConnection connection, DateTime captureTimeUtc, string databaseName) + { + using var command = new NpgsqlCommand(@" +INSERT INTO database_config + (config_id, capture_time, server_id, server_name, + database_name, state_desc, compatibility_level, collation_name, recovery_model, + is_read_only, is_auto_close_on, is_auto_shrink_on, + is_auto_create_stats_on, is_auto_update_stats_on, is_auto_update_stats_async_on, + is_read_committed_snapshot_on, snapshot_isolation_state, is_parameterization_forced, + is_query_store_on, is_encrypted, is_trustworthy_on, is_db_chaining_on, + is_broker_enabled, is_cdc_enabled, is_mixed_page_allocation_on, + log_reuse_wait_desc, page_verify_option, target_recovery_time_seconds, delayed_durability, + is_accelerated_database_recovery_on, is_memory_optimized_enabled, is_optimized_locking_on) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, + $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32)", connection); + command.Parameters.AddWithValue(2L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(captureTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(DatabaseConfigServerId); + command.Parameters.AddWithValue("viewer-database-config-e2e"); + command.Parameters.AddWithValue(databaseName); // database_name (0) + command.Parameters.AddWithValue("ONLINE"); // state_desc (1) + command.Parameters.AddWithValue(160); // compatibility_level (2) + command.Parameters.AddWithValue("SQL_Latin1_General_CP1_CI_AS"); // collation_name (3) + command.Parameters.AddWithValue("FULL"); // recovery_model (4) + command.Parameters.AddWithValue(false); // is_read_only (5) + command.Parameters.AddWithValue(true); // is_auto_close_on (6) + command.Parameters.AddWithValue(false); // is_auto_shrink_on (7) + command.Parameters.AddWithValue(true); // is_auto_create_stats_on (8) + command.Parameters.AddWithValue(true); // is_auto_update_stats_on (9) + command.Parameters.AddWithValue(false); // is_auto_update_stats_async_on (10) + command.Parameters.AddWithValue(true); // is_read_committed_snapshot_on (11) + command.Parameters.AddWithValue("ON"); // snapshot_isolation_state (12) + command.Parameters.AddWithValue(false); // is_parameterization_forced (13) + command.Parameters.AddWithValue(true); // is_query_store_on (14) + command.Parameters.AddWithValue(false); // is_encrypted (15) + command.Parameters.AddWithValue(false); // is_trustworthy_on (16) + command.Parameters.AddWithValue(false); // is_db_chaining_on (17) + command.Parameters.AddWithValue(true); // is_broker_enabled (18) + command.Parameters.AddWithValue(false); // is_cdc_enabled (19) + command.Parameters.AddWithValue(false); // is_mixed_page_allocation_on (20) + command.Parameters.AddWithValue("NOTHING"); // log_reuse_wait_desc (21) + command.Parameters.AddWithValue("CHECKSUM"); // page_verify_option (22) + command.Parameters.AddWithValue(60); // target_recovery_time_seconds (23) + command.Parameters.AddWithValue("DISABLED"); // delayed_durability (24) + command.Parameters.AddWithValue(true); // is_accelerated_database_recovery_on (25) + command.Parameters.AddWithValue(false); // is_memory_optimized_enabled (26) + command.Parameters.AddWithValue(true); // is_optimized_locking_on (27) + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertScopedConfigAsync( + NpgsqlConnection connection, DateTime captureTimeUtc, + string databaseName, string configurationName, string value, string valueForSecondary) + { + using var command = new NpgsqlCommand(@" +INSERT INTO database_scoped_config + (config_id, capture_time, server_id, server_name, + database_name, configuration_name, value, value_for_secondary) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(captureTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(ScopedConfigServerId); + command.Parameters.AddWithValue("viewer-scoped-config-e2e"); + command.Parameters.AddWithValue(databaseName); + command.Parameters.AddWithValue(configurationName); + command.Parameters.AddWithValue(value); + command.Parameters.AddWithValue(valueForSecondary); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertTraceFlagAsync( + NpgsqlConnection connection, DateTime captureTimeUtc, + int traceFlag, bool status, bool isGlobal, bool isSession) + { + using var command = new NpgsqlCommand(@" +INSERT INTO trace_flags + (config_id, capture_time, server_id, server_name, + trace_flag, status, is_global, is_session) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(captureTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(TraceFlagsServerId); + command.Parameters.AddWithValue("viewer-trace-flags-e2e"); + command.Parameters.AddWithValue(traceFlag); + command.Parameters.AddWithValue(status); + command.Parameters.AddWithValue(isGlobal); + command.Parameters.AddWithValue(isSession); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static DateTime TruncateToSeconds(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified); + + private static async Task DeleteRowsAsync(NpgsqlConnection connection, string table, int serverId) + { + using var cleanup = new NpgsqlCommand($"DELETE FROM {table} WHERE server_id = {serverId};", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } +} diff --git a/Darling/Darling.Tests/ViewerContextMenuTests.cs b/Darling/Darling.Tests/ViewerContextMenuTests.cs new file mode 100644 index 000000000..0e857df85 --- /dev/null +++ b/Darling/Darling.Tests/ViewerContextMenuTests.cs @@ -0,0 +1,265 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the chart + grid right-click menus restored from Lite (the last Phase B parity cluster): the composed +/// per-chart menu order (drill-down item + copy/save/export coexisting in ONE menu, so #1409's drill-downs +/// still resolve), the chart-data CSV shape, the "Copy Repro Script" STORED-fields mapping (no live +/// connection), and the read-only-seat server-list SQL (no encrypted_password). Pure logic + SQL pins only — +/// the same no-WPF discipline as the rest of Darling.Tests. +/// +public sealed class ViewerChartContextMenuTests +{ + private const string DrillLabel = "Show Active Queries at This Time"; + + [Fact] + public void ChartMenuHeaderOrder_DrillDownChart_CarriesBothTheDrillDownAndTheCopyExportItems() + { + /* The coexistence pin: a drill-down chart's menu = [drill-down] [separator] [Copy/Save/Export]. This is + what keeps the B1 (#1409) drill-downs working AFTER the copy/export menu was merged into the same + ContextMenu instance. */ + var order = ViewerServerTab.ChartMenuHeaderOrder(DrillLabel, includeDataSource: false); + + /* Drill-down is FIRST, then a separator, so the drill-down still shows and resolves. */ + Assert.Equal(DrillLabel, order[0]); + Assert.Equal(ViewerServerTab.MenuSeparatorMarker, order[1]); + + /* ...AND every copy/save/export item is present in the SAME menu. */ + Assert.Contains(ViewerServerTab.CopyImageHeader, order); + Assert.Contains(ViewerServerTab.SaveImageHeader, order); + Assert.Contains(ViewerServerTab.OpenInNewWindowHeader, order); + Assert.Contains(ViewerServerTab.RevertHeader, order); + Assert.Contains(ViewerServerTab.ExportDataToCsvHeader, order); + } + + [Fact] + public void ChartMenuHeaderOrder_NoDrillDownChart_IsJustTheCopyExportBlock() + { + var order = ViewerServerTab.ChartMenuHeaderOrder(drillDownLabel: null, includeDataSource: false); + + /* No drill-down → the menu opens straight into Copy Image (no leading item / separator). */ + Assert.Equal(ViewerServerTab.CopyImageHeader, order[0]); + Assert.DoesNotContain(DrillLabel, order); + Assert.Contains(ViewerServerTab.ExportDataToCsvHeader, order); + Assert.DoesNotContain(ViewerServerTab.ShowDataSourceHeader, order); + } + + [Fact] + public void ChartMenuHeaderOrder_WithDataSource_AppendsShowDataSource() + { + var order = ViewerServerTab.ChartMenuHeaderOrder(DrillLabel, includeDataSource: true); + + Assert.Equal(ViewerServerTab.ShowDataSourceHeader, order[^1]); + Assert.Contains(DrillLabel, order); + } + + [Theory] + [InlineData(",", "DateTime,Series,Value")] + [InlineData(";", "DateTime;Series;Value")] + [InlineData("\t", "DateTime\tSeries\tValue")] + public void ChartCsvHeaderLine_IsDateTimeSeriesValue_InTheChosenSeparator(string separator, string expected) + { + Assert.Equal(expected, ViewerServerTab.ChartCsvHeaderLine(separator)); + } + + [Fact] + public void FormatChartCsvLine_EmitsInvariantTimestampSeriesAndValue() + { + var line = ViewerServerTab.FormatChartCsvLine(new DateTime(2026, 7, 6, 14, 20, 5), "CPU %", 42.5, ","); + Assert.Equal("2026-07-06 14:20:05,CPU %,42.5", line); + } + + [Fact] + public void FormatChartCsvLine_QuotesASeriesNameContainingTheSeparator() + { + /* A series label with the separator must be RFC-4180 quoted so the CSV column count stays right. */ + var line = ViewerServerTab.FormatChartCsvLine(new DateTime(2026, 7, 6, 0, 0, 0), "reads, writes", 3, ","); + Assert.Equal("2026-07-06 00:00:00,\"reads, writes\",3", line); + } + + [Theory] + [InlineData("plain", ",", "plain")] + [InlineData("a,b", ",", "\"a,b\"")] + [InlineData("a;b", ";", "\"a;b\"")] + [InlineData("has\"quote", ",", "\"has\"\"quote\"")] + public void CsvEscape_QuotesOnlyWhenNeeded_AndDoublesEmbeddedQuotes(string value, string separator, string expected) + { + Assert.Equal(expected, ViewerServerTab.CsvEscape(value, separator)); + } +} + +/// +/// "Copy Repro Script" builds from STORED row fields with NO live connection (the inventory found the +/// "needs a live connection" rationale OVERSTATED): Active Queries carries its plan + isolation in-row; Top +/// Queries / Query Store take a best-effort STORED plan the caller already read from Postgres. Pure mapping, +/// so the pin never opens a connection. +/// +public sealed class ViewerReproScriptTests +{ + private const string Product = "SQL Server Performance Monitor Test"; + + [Fact] + public void ActiveQueriesRow_BuildsFromInRowStoredFields_NoConnection() + { + var row = new ViewerQuerySnapshotRow + { + QueryText = "SELECT * FROM dbo.Users WHERE Id = 1", + DatabaseName = "tpcc", + /* The real value the snapshot collector records (CASE ... WHEN 2 THEN 'Read Committed'), which + uppercases to the valid T-SQL 'READ COMMITTED' — the repro builder only emits a SET for a + recognized isolation level. */ + TransactionIsolationLevel = "Read Committed", + QueryPlan = null, + }; + + var script = ViewerServerTab.BuildReproScriptForRow(row, enrichedPlanXml: null, Product); + + Assert.NotNull(script); + Assert.Contains("SELECT * FROM dbo.Users WHERE Id = 1", script!, StringComparison.Ordinal); + Assert.Contains("USE [tpcc];", script, StringComparison.Ordinal); + Assert.Contains("Source: Active Queries", script, StringComparison.Ordinal); + Assert.Contains(Product, script, StringComparison.Ordinal); + /* The stored isolation level rides through to the SET statement as VALID T-SQL. */ + Assert.Contains("SET TRANSACTION ISOLATION LEVEL READ COMMITTED;", script, StringComparison.Ordinal); + } + + [Fact] + public void ActiveQueriesRow_UsesItsInRowPlan_NotTheCallersEnrichedPlan() + { + /* The snapshot carries QueryPlan in-row; enrichedPlanXml (which the caller supplies only for the + aggregate grids) must be ignored here. With a null in-row plan the script reports plan-unavailable + even though a non-null enrichedPlanXml was passed — proving the snapshot branch reads in-row. */ + var row = new ViewerQuerySnapshotRow { QueryText = "SELECT 1", DatabaseName = "db", QueryPlan = null }; + + var script = ViewerServerTab.BuildReproScriptForRow(row, enrichedPlanXml: "", Product); + + Assert.NotNull(script); + Assert.Contains("plan XML not available", script!, StringComparison.Ordinal); + } + + [Fact] + public void TopQueriesRow_BuildsFromStoredFields_WithBestEffortStoredPlan() + { + var row = new ViewerQueryStatsRow + { + QueryText = "SELECT COUNT(*) FROM dbo.Orders", + DatabaseName = "sales", + QueryHash = "0xABCD", + }; + + /* enrichedPlanXml is the STORED plan the caller read from Postgres (a read, not a live exec). Passing + null still produces a plan-less repro. */ + var script = ViewerServerTab.BuildReproScriptForRow(row, enrichedPlanXml: null, Product); + + Assert.NotNull(script); + Assert.Contains("SELECT COUNT(*) FROM dbo.Orders", script!, StringComparison.Ordinal); + Assert.Contains("Source: Top Queries (dm_exec_query_stats)", script, StringComparison.Ordinal); + Assert.Contains("USE [sales];", script, StringComparison.Ordinal); + } + + [Fact] + public void QueryStoreRow_BuildsFromStoredFields() + { + var row = new ViewerQueryStoreRow + { + QueryText = "UPDATE dbo.Inventory SET Qty = Qty - 1", + DatabaseName = "wh", + QueryId = 42, + PlanId = 7, + }; + + var script = ViewerServerTab.BuildReproScriptForRow(row, enrichedPlanXml: null, Product); + + Assert.NotNull(script); + Assert.Contains("UPDATE dbo.Inventory SET Qty = Qty - 1", script!, StringComparison.Ordinal); + Assert.Contains("Source: Query Store", script, StringComparison.Ordinal); + } + + [Fact] + public void QueryStoreRegressionsRow_BuildsPlanLessReproFromTheQueryTextSample() + { + /* The regression row carries the query-text sample but no plan (it aggregates over plans), so the + repro is plan-less even though the caller could pass an enriched plan — the branch ignores it. */ + var row = new ViewerQueryStoreRegressionRow + { + QueryTextSample = "SELECT SUM(Amount) FROM dbo.Ledger", + DatabaseName = "fin", + QueryId = 99, + }; + + var script = ViewerServerTab.BuildReproScriptForRow(row, enrichedPlanXml: "", Product); + + Assert.NotNull(script); + Assert.Contains("SELECT SUM(Amount) FROM dbo.Ledger", script!, StringComparison.Ordinal); + Assert.Contains("Source: Query Store Regressions", script, StringComparison.Ordinal); + Assert.Contains("USE [fin];", script, StringComparison.Ordinal); + Assert.Contains("plan XML not available", script, StringComparison.Ordinal); + } + + [Fact] + public void QueryStoreRegressionsRow_WithNoQueryText_ReturnsNull_SoTheHandlerNoOps() + => Assert.Null(ViewerServerTab.BuildReproScriptForRow( + new ViewerQueryStoreRegressionRow { QueryTextSample = "", DatabaseName = "db" }, enrichedPlanXml: null, Product)); + + [Fact] + public void RowWithNoQueryText_ReturnsNull_SoTheHandlerNoOps() + { + var empty = new ViewerQuerySnapshotRow { QueryText = "", DatabaseName = "db" }; + Assert.Null(ViewerServerTab.BuildReproScriptForRow(empty, enrichedPlanXml: null, Product)); + } + + [Fact] + public void UnsupportedRowType_ReturnsNull() + { + /* Comparison-aggregate rows / config rows / anything else: no repro, the handler no-ops (Lite's + default branch). */ + Assert.Null(ViewerServerTab.BuildReproScriptForRow(new object(), enrichedPlanXml: null, Product)); + } +} + +/// +/// The read-only-seat fix (#1416 revoked the viewer role's SELECT on encrypted_password): the server +/// LIST read must not reference the secret column, or a read-only connectAs: viewer seat 42501s on the +/// Manage Servers grid + sidebar. The secret stays only in the by-id edit-load read (an admin action). +/// +public sealed class ViewerServerListReadOnlyTests +{ + [Fact] + public void MonitoredServersSelectSql_DoesNotSelectTheSecret_SoAReadOnlySeatCanListServers() + { + var sql = ViewerDataService.MonitoredServersSelectSql; + Assert.DoesNotContain("encrypted_password", sql, StringComparison.Ordinal); + /* Still selects everything the grid displays. */ + Assert.Contains("server_id", sql, StringComparison.Ordinal); + Assert.Contains("name", sql, StringComparison.Ordinal); + Assert.Contains("host", sql, StringComparison.Ordinal); + Assert.Contains("created_at", sql, StringComparison.Ordinal); + } + + [Fact] + public void ManagedServersSql_DoesNotSelectTheSecret() + { + /* The sidebar's config-authoritative read never carried the secret; pin it stays that way. */ + Assert.DoesNotContain("encrypted_password", ViewerDataService.ManagedServersSql, StringComparison.Ordinal); + } + + [Fact] + public void MonitoredServerByIdSql_KeepsTheSecret_ForTheEditDialogPrefill() + { + /* Editing (an admin-role action) reloads the row by id and needs the DPAPI blob to keep the password + when the user doesn't retype it. */ + Assert.Contains("encrypted_password", ViewerDataService.MonitoredServerByIdSql, StringComparison.Ordinal); + } +} diff --git a/Darling/Darling.Tests/ViewerControlPlaneStage3bTests.cs b/Darling/Darling.Tests/ViewerControlPlaneStage3bTests.cs new file mode 100644 index 000000000..59d3571ad --- /dev/null +++ b/Darling/Darling.Tests/ViewerControlPlaneStage3bTests.cs @@ -0,0 +1,577 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Stage 3b — the viewer's control-plane writes for the OPERATOR config sections: alert settings, notification +/// (SMTP + webhooks), the service flags, and the collector-schedule overrides. Pins the SQL shape +/// (parameterization, single-row id=1 upserts, the sparse-schedule ON CONFLICT arbiters) + the column parity +/// with the service's , plus the pure overlay/preset/migration logic — all +/// with no live Postgres, exactly like the Stage-3a server + mute pins. +/// +public sealed class ViewerAlertSettingsSqlTests +{ + /* The 36 AlertsConfig + AnalysisConfig columns the service reads (StoreConfigProvider.ReadAlertSettingsAsync) + — delivery_mode/per_event_max appended in V18 (#1141/#1236), the six long-running-query read knobs + the + connection-change notify toggle in V20. */ + private static readonly string[] Columns = + { + "enabled", "cpu_enabled", "cpu_threshold_percent", "cpu_mode", "blocking_enabled", "blocking_count_threshold", + "deadlock_enabled", "deadlock_count_threshold", "poison_wait_enabled", "poison_wait_threshold_ms", + "long_running_query_enabled", "long_running_query_threshold_minutes", "tempdb_space_enabled", + "tempdb_space_threshold_percent", "low_disk_enabled", "low_disk_threshold_percent", "low_disk_threshold_gb", + "long_running_job_enabled", "long_running_job_multiplier", "failed_job_enabled", "failed_job_lookback_minutes", + "cooldown_minutes", "excluded_databases", "analysis_enabled", "analysis_interval_minutes", + "analysis_notifications_enabled", "analysis_notify_severity", "delivery_mode", "per_event_max", + "long_running_query_max_results", "long_running_query_exclude_sp_server_diagnostics", + "long_running_query_exclude_wait_for", "long_running_query_exclude_backups", + "long_running_query_exclude_misc_waits", "long_running_query_exclude_cdc", "notify_connection_changes", + }; + + [Fact] + public void UpsertSql_WritesEveryColumn_AsBoundParameters_SingleRowOnConflictUpdate() + { + var sql = ViewerDataService.AlertSettingsUpsertSql; + + Assert.Contains("INSERT INTO config_alert_settings", sql, StringComparison.Ordinal); + Assert.Contains("VALUES (1,", sql, StringComparison.Ordinal); /* pinned to the single global row */ + foreach (var column in Columns) + { + Assert.Contains(column, sql, StringComparison.Ordinal); + } + + for (var i = 1; i <= 36; i++) + { + Assert.Contains("$" + i.ToString(System.Globalization.CultureInfo.InvariantCulture), sql, StringComparison.Ordinal); + } + + Assert.Contains("ON CONFLICT (id) DO UPDATE", sql, StringComparison.Ordinal); + Assert.Contains("modified_at = (now() AT TIME ZONE 'UTC')", sql, StringComparison.Ordinal); + } + + [Fact] + public void SelectSql_ReadsEveryColumn_FromTheSingleRow() + { + var sql = ViewerDataService.AlertSettingsSelectSql; + Assert.Contains("FROM config_alert_settings WHERE id = 1", sql, StringComparison.Ordinal); + foreach (var column in Columns) + { + Assert.Contains(column, sql, StringComparison.Ordinal); + } + } + + [Theory] + [InlineData("Total", "total")] + [InlineData("SqlOnly", "sql")] + public void CpuMode_MapsToTheServiceVocabulary_RoundTrips(string viewer, string store) + { + /* The service compares cpu_mode case-insensitively against "sql"; the viewer must emit that word. */ + Assert.Equal(store, ViewerDataService.MapCpuModeToStore(viewer)); + Assert.Equal(viewer, ViewerDataService.MapCpuModeFromStore(store)); + } + + [Fact] + public void DefaultsRow_EqualsItself_AndDiffersOnAnyChange() + { + var a = AlertSettingsRow.Defaults(); + Assert.True(a.ValueEquals(AlertSettingsRow.Defaults())); + + var b = AlertSettingsRow.Defaults(); + b.CpuThresholdPercent = 55; + Assert.False(a.ValueEquals(b)); + + var c = AlertSettingsRow.Defaults(); + c.ExcludedDatabases = new List { "msdb" }; + Assert.False(a.ValueEquals(c)); + } +} + +/// The notification write partial: SQL shape + the DPAPI SMTP blob (never plaintext into Postgres). +public sealed class ViewerNotificationSqlTests +{ + private static readonly string[] Columns = + { + "smtp_host", "smtp_port", "smtp_use_ssl", "smtp_username", "smtp_encrypted_password", "smtp_from_address", + "smtp_recipients", "email_cooldown_minutes", "teams_url", "teams_proxy", "slack_url", "slack_proxy", + "generic_url", "generic_headers", "generic_body_template", "generic_proxy", + }; + + [Fact] + public void UpsertSql_WritesEveryColumn_AsBoundParameters_SingleRowOnConflictUpdate() + { + var sql = ViewerDataService.NotificationUpsertSql; + Assert.Contains("INSERT INTO config_notification", sql, StringComparison.Ordinal); + Assert.Contains("VALUES (1,", sql, StringComparison.Ordinal); + foreach (var column in Columns) + { + Assert.Contains(column, sql, StringComparison.Ordinal); + } + + for (var i = 1; i <= 16; i++) + { + Assert.Contains("$" + i.ToString(System.Globalization.CultureInfo.InvariantCulture), sql, StringComparison.Ordinal); + } + + Assert.Contains("ON CONFLICT (id) DO UPDATE", sql, StringComparison.Ordinal); + } + + [Fact] + public void SelectSql_ReadsEveryColumn_FromTheSingleRow() + { + var sql = ViewerDataService.NotificationSelectSql; + Assert.Contains("FROM config_notification WHERE id = 1", sql, StringComparison.Ordinal); + foreach (var column in Columns) + { + Assert.Contains(column, sql, StringComparison.Ordinal); + } + } + + [Fact] + public void SmtpPassword_IsSealedAsAServiceReadableBlob_NeverPlaintext() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "DPAPI requires Windows."); + + var settings = new ViewerAppSettings + { + SmtpEnabled = true, + SmtpServer = "smtp.contoso.com", + SmtpFromAddress = "alerts@contoso.com", + SmtpRecipients = "dba@contoso.com", + }; + + var row = ViewerControlPlaneMigration.BuildNotificationRow(settings, "hunter2", "", ""); + + Assert.NotNull(row.SmtpEncryptedPassword); + Assert.NotEqual("hunter2", row.SmtpEncryptedPassword); /* not plaintext */ + /* The SERVICE must be able to read what the viewer sealed (identical DPAPI entropy + scope). */ + Assert.Equal("hunter2", DarlingSecrets.Unprotect(row.SmtpEncryptedPassword!)); + } + + [Fact] + public void DisabledChannels_WriteEmptyFields_SoTheServiceTreatsThemOff() + { + var settings = new ViewerAppSettings + { + SmtpEnabled = false, + SmtpServer = "smtp.contoso.com", /* typed but disabled */ + TeamsWebhookEnabled = false, + SlackWebhookEnabled = false, + }; + + var row = ViewerControlPlaneMigration.BuildNotificationRow(settings, "pw", "https://teams", "https://slack"); + + Assert.Equal("", row.SmtpHost); + Assert.Null(row.SmtpEncryptedPassword); + Assert.Equal("", row.TeamsUrl); + Assert.Equal("", row.SlackUrl); + } +} + +/// The service-config write partial: the flag update touches only the viewer-owned columns (never +/// paused), and the reload-signal touch bumps the beacon (F16). +public sealed class ViewerServiceConfigSqlTests +{ + [Fact] + public void UpdateFlagsSql_SetsOnlyCapturePlansMcp_OnTheSingleRow_NeverPaused() + { + var sql = ViewerDataService.ServiceConfigUpdateFlagsSql; + Assert.Contains("UPDATE config_service", sql, StringComparison.Ordinal); + Assert.Contains("capture_plans = $1", sql, StringComparison.Ordinal); + Assert.Contains("mcp_enabled = $2", sql, StringComparison.Ordinal); + Assert.Contains("mcp_port = $3", sql, StringComparison.Ordinal); + /* #1562: the web dashboard toggle/port ride the same viewer-owned flag update. */ + Assert.Contains("web_enabled = $4", sql, StringComparison.Ordinal); + Assert.Contains("web_port = $5", sql, StringComparison.Ordinal); + Assert.Contains("WHERE id = 1", sql, StringComparison.Ordinal); + /* paused is a command, not a settings write — the flag update must never touch it. */ + Assert.DoesNotContain("paused", sql, StringComparison.Ordinal); + } + + [Fact] + public void SelectSql_ReadsPausedAndTheViewerOwnedFlags() + { + Assert.Contains("SELECT paused, capture_plans, mcp_enabled, mcp_port, web_enabled, web_port FROM config_service WHERE id = 1", + ViewerDataService.ServiceConfigSelectSql, StringComparison.Ordinal); + } + + [Fact] + public void ReloadSignalSql_TouchesConfigService_ToBumpTheBeacon_WithoutChangingAFlag() + { + /* F16: config_mute_rules has no bump trigger, so a viewer mute write must touch config_service to fire + the self-bump (config_version++) and make the service re-LoadAsync() its mute cache next sweep. */ + var sql = ViewerDataService.ConfigReloadSignalSql; + Assert.Contains("UPDATE config_service SET updated_at = (now() AT TIME ZONE 'UTC') WHERE id = 1", sql, StringComparison.Ordinal); + Assert.DoesNotContain("paused", sql, StringComparison.Ordinal); + Assert.DoesNotContain("capture_plans", sql, StringComparison.Ordinal); + } +} + +/// The collector-schedule write partial: the sparse-table ON CONFLICT arbiters must match V17's partial +/// unique indexes AND the service's own enable/disable_collector upsert shape. +public sealed class ViewerCollectorSchedulesSqlTests +{ + [Fact] + public void FleetUpsert_ArbitratesOnCollectorName_WhereServerIdIsNull() + { + var sql = ViewerDataService.CollectorScheduleFleetUpsertSql; + Assert.Contains("INSERT INTO config_collector_schedules", sql, StringComparison.Ordinal); + Assert.Contains("VALUES (NULL, $1, $2, $3, $4)", sql, StringComparison.Ordinal); + Assert.Contains("ON CONFLICT (collector_name) WHERE server_id IS NULL DO UPDATE", sql, StringComparison.Ordinal); + Assert.Contains("frequency_minutes = EXCLUDED.frequency_minutes", sql, StringComparison.Ordinal); + Assert.Contains("retention_days = EXCLUDED.retention_days", sql, StringComparison.Ordinal); + Assert.Contains("enabled = EXCLUDED.enabled", sql, StringComparison.Ordinal); + } + + [Fact] + public void ServerUpsert_ArbitratesOnServerIdCollectorName_WhereServerIdIsNotNull() + { + var sql = ViewerDataService.CollectorScheduleServerUpsertSql; + Assert.Contains("VALUES ($1, $2, $3, $4, $5)", sql, StringComparison.Ordinal); + Assert.Contains("ON CONFLICT (server_id, collector_name) WHERE server_id IS NOT NULL DO UPDATE", sql, StringComparison.Ordinal); + } + + [Fact] + public void DeleteScopeSql_KeyOnTheScope() + { + Assert.Contains("DELETE FROM config_collector_schedules WHERE server_id IS NULL", + ViewerDataService.CollectorScheduleDeleteFleetScopeSql, StringComparison.Ordinal); + Assert.Contains("DELETE FROM config_collector_schedules WHERE server_id = $1", + ViewerDataService.CollectorScheduleDeleteServerScopeSql, StringComparison.Ordinal); + } + + [Fact] + public void DeleteAllServerScopesSql_ClearsEveryPerServerOverride_ButNotTheFleetDefault() + { + /* The "Apply Default to All" bulk reset deletes every per-server row (server_id IS NOT NULL) in one + statement and must leave the fleet-wide default (server_id IS NULL) untouched. */ + var sql = ViewerDataService.CollectorScheduleDeleteAllServerScopesSql; + Assert.Contains("DELETE FROM config_collector_schedules WHERE server_id IS NOT NULL", sql, StringComparison.Ordinal); + Assert.DoesNotContain("server_id IS NULL", sql, StringComparison.Ordinal); + } +} + +/// The pure store↔editor overlay + the preset table (ported from Lite, frequency-only). +public sealed class ViewerCollectorScheduleLogicTests +{ + [Fact] + public void BuildDefaultSchedule_CoversEveryKnownCollector_AtItsCodeDefault() + { + var schedule = CollectorSchedulePresets.BuildDefaultSchedule(); + Assert.Equal(CollectorScheduleDefaults.All.Count, schedule.Count); + + foreach (var item in schedule) + { + var def = CollectorScheduleDefaults.All[item.Name]; + Assert.Equal(def.FrequencyMinutes, item.FrequencyMinutes); + Assert.Equal(def.RetentionDays, item.RetentionDays); + Assert.True(item.Enabled); + } + } + + [Fact] + public void Presets_OnlyReferenceKnownCollectors() + { + foreach (var (_, intervals) in CollectorSchedulePresets.Presets) + { + foreach (var collector in intervals.Keys) + { + Assert.True(CollectorScheduleDefaults.All.ContainsKey(collector), $"preset references unknown collector '{collector}'"); + } + } + } + + [Fact] + public void Presets_CoverTheSamePinnedCollectorSet() + { + /* The three preset tables are duplicated byte-for-byte from Lite's ScheduleManager.s_presets (the + projects don't share a schedule library). Mirror Lite's own SchedulePresets_CoverTheSamePinnedCollectorSet + guard HERE so a collector added to Lite's presets (or to one Darling preset but not the others) without + being mirrored into all three fails this test instead of silently shipping a preset that skips it — the + anti-drift pin the class docstring promises. (job_history/agent_status/default_trace_events fell behind + exactly this way; see the #1494 review.) */ + var expected = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "wait_stats", "latch_stats", "spinlock_stats", "cpu_scheduler_stats", "plan_cache_stats", + "query_stats", "procedure_stats", "query_store", "query_snapshots", "cpu_utilization", + "file_io_stats", "memory_stats", "memory_clerks", "memory_pressure_events", "tempdb_stats", + "perfmon_stats", "deadlocks", "memory_grant_stats", "waiting_tasks", "dmv_blocking_snapshot", + "blocked_process_report", "running_jobs", "session_summary_stats", "system_health_events", + "default_trace_events", "job_history", "agent_status" + }; + + Assert.Equal(3, CollectorSchedulePresets.Presets.Count); + foreach (var (presetName, intervals) in CollectorSchedulePresets.Presets) + { + Assert.True(expected.SetEquals(intervals.Keys), + $"preset '{presetName}' collector set drifted from the pinned set"); + } + } + + [Fact] + public void ApplyPreset_ChangesFrequenciesOnly_AndDetectRoundTrips() + { + var schedule = CollectorSchedulePresets.BuildDefaultSchedule(); + var retentionBefore = schedule.ToDictionary(s => s.Name, s => s.RetentionDays); + + CollectorSchedulePresets.ApplyPreset(schedule, "Low-Impact"); + + /* Frequencies now match the Low-Impact table; retention untouched. */ + Assert.Equal(30, schedule.First(s => s.Name == "query_store").FrequencyMinutes); + foreach (var item in schedule) + { + Assert.Equal(retentionBefore[item.Name], item.RetentionDays); + } + + Assert.Equal("Low-Impact", CollectorSchedulePresets.DetectPreset(schedule)); + } + + [Fact] + public void Overlay_FleetRowWins_OverCodeDefault_AndPerServerWinsOverFleet() + { + var overrides = new List + { + new(null, "wait_stats", 9, null, true), /* fleet: freq 9 (retention falls through) */ + new(1234, "wait_stats", 3, 45, true), /* server 1234: freq 3, retention 45 */ + }; + + var fleet = CollectorScheduleOverlay.BuildEffectiveSchedule(overrides, serverId: null) + .First(s => s.Name == "wait_stats"); + Assert.Equal(9, fleet.FrequencyMinutes); + Assert.Equal(CollectorScheduleDefaults.All["wait_stats"].RetentionDays, fleet.RetentionDays); /* NULL fell through */ + + var server = CollectorScheduleOverlay.BuildEffectiveSchedule(overrides, serverId: 1234) + .First(s => s.Name == "wait_stats"); + Assert.Equal(3, server.FrequencyMinutes); + Assert.Equal(45, server.RetentionDays); + } + + [Fact] + public void FleetOverrideRows_AreSparse_OnlyForCollectorsDifferingFromDefault() + { + var edited = CollectorSchedulePresets.BuildDefaultSchedule(); + edited.First(s => s.Name == "wait_stats").FrequencyMinutes = 15; /* the only change */ + + var rows = CollectorScheduleOverlay.ToFleetOverrideRows(edited); + + var row = Assert.Single(rows); + Assert.Null(row.ServerId); + Assert.Equal("wait_stats", row.CollectorName); + Assert.Equal(15, row.FrequencyMinutes); + } + + [Fact] + public void ServerOverrideRows_AreAFullSnapshot_ForWysiwyg() + { + var edited = CollectorSchedulePresets.BuildDefaultSchedule(); + var rows = CollectorScheduleOverlay.ToServerOverrideRows(edited, serverId: 7); + + Assert.Equal(CollectorScheduleDefaults.All.Count, rows.Count); + Assert.All(rows, r => Assert.Equal(7, r.ServerId)); + } + + [Fact] + public void ServerHasOverride_ReflectsTheServersRows() + { + var overrides = new List { new(7, "wait_stats", 5, null, true) }; + Assert.True(CollectorScheduleOverlay.ServerHasOverride(overrides, 7)); + Assert.False(CollectorScheduleOverlay.ServerHasOverride(overrides, 8)); + } +} + +/// The viewer's control commands agree with the service executor's dispatch (the two ends must use the +/// same command_type words). +public sealed class ViewerControlCommandParityTests +{ + private static ClaimedCommand Command(string type, int? target = null) => new(1, type, target, null, "viewer"); + + [Fact] + public void PauseResume_AreRecognizedStoreWrites() + { + Assert.Equal("pause", ViewerDataService.CommandPause); + Assert.Equal("resume", ViewerDataService.CommandResume); + Assert.Equal(CommandKind.StoreWrite, DarlingCommandExecutor.ResolvePlan(Command(ViewerDataService.CommandPause)).Kind); + Assert.Equal(CommandKind.StoreWrite, DarlingCommandExecutor.ResolvePlan(Command(ViewerDataService.CommandResume)).Kind); + } + + [Fact] + public void SnapshotAnalyze_AreRecognizedImperatives_RequiringATarget() + { + Assert.Equal("snapshot_now", ViewerDataService.CommandSnapshotNow); + Assert.Equal("analyze_now", ViewerDataService.CommandAnalyzeNow); + + Assert.Equal(CommandKind.Snapshot, DarlingCommandExecutor.ResolvePlan(Command(ViewerDataService.CommandSnapshotNow, target: 1)).Kind); + Assert.Equal(CommandKind.Analyze, DarlingCommandExecutor.ResolvePlan(Command(ViewerDataService.CommandAnalyzeNow, target: 1)).Kind); + + /* Without a target they fail — matching the viewer only ever sending them with a server id. */ + Assert.Equal(CommandKind.Fail, DarlingCommandExecutor.ResolvePlan(Command(ViewerDataService.CommandSnapshotNow)).Kind); + Assert.Equal(CommandKind.Fail, DarlingCommandExecutor.ResolvePlan(Command(ViewerDataService.CommandAnalyzeNow)).Kind); + } + + [Fact] + public void PurgeNow_IsRecognizedFleetWide_WithNoTargetRequired() + { + Assert.Equal("purge_now", ViewerDataService.CommandPurgeNow); + + /* Fleet-wide over the shared tables — resolves to Purge with NO target (unlike snapshot_now/analyze_now, + which the viewer only ever sends with a server id). */ + Assert.Equal(CommandKind.Purge, DarlingCommandExecutor.ResolvePlan(Command(ViewerDataService.CommandPurgeNow)).Kind); + Assert.Equal(CommandKind.Purge, DarlingCommandExecutor.ResolvePlan(Command(ViewerDataService.CommandPurgeNow, target: 1)).Kind); + } + + [Fact] + public void FetchPlan_IsRecognized_WithATargetAndAViewerBuiltHandleArgs() + { + Assert.Equal("fetch_plan", ViewerDataService.CommandFetchPlan); + + /* The viewer builds the args; the executor must recognize them as a live-plan fetch. */ + var args = ViewerDataService.BuildPlanFetchArgsByPlanHandle("0x0600AA", "master"); + var command = new ClaimedCommand(1, ViewerDataService.CommandFetchPlan, 5, args, "viewer"); + Assert.Equal(CommandKind.FetchPlan, DarlingCommandExecutor.ResolvePlan(command).Kind); + + /* Without a target it fails — the viewer only ever sends it with a server id. */ + Assert.Equal(CommandKind.Fail, + DarlingCommandExecutor.ResolvePlan(new ClaimedCommand(1, ViewerDataService.CommandFetchPlan, null, args, "viewer")).Kind); + } +} + +/// The one-time operational migrate-in: the defaults-only import decision + the projection, with no +/// live store or vault. +public sealed class ViewerControlPlaneMigrationTests +{ + [Fact] + public void ShouldImportAlerts_OnlyWhenStoreIsDefault_AndViewerDiffers() + { + var defaults = AlertSettingsRow.Defaults(); + + var viewerCustom = AlertSettingsRow.Defaults(); + viewerCustom.CpuThresholdPercent = 60; + + Assert.True(ViewerControlPlaneMigration.ShouldImportAlerts(AlertSettingsRow.Defaults(), viewerCustom)); /* store default, viewer differs */ + Assert.False(ViewerControlPlaneMigration.ShouldImportAlerts(AlertSettingsRow.Defaults(), defaults)); /* viewer also default -> nothing to carry */ + + var storeCustom = AlertSettingsRow.Defaults(); + storeCustom.CpuThresholdPercent = 95; + Assert.False(ViewerControlPlaneMigration.ShouldImportAlerts(storeCustom, viewerCustom)); /* store already tuned -> never clobber */ + } + + [Fact] + public void ShouldImportMcp_OnlyWhenStoreMcpIsDefault_AndViewerDiffers() + { + var storeDefault = new ServiceConfigRow(); /* mcp off, 5152 */ + var storeTuned = new ServiceConfigRow { McpEnabled = true, McpPort = 6000 }; + + Assert.True(ViewerControlPlaneMigration.ShouldImportMcp(storeDefault, new ViewerAppSettings { McpEnabled = true })); + Assert.False(ViewerControlPlaneMigration.ShouldImportMcp(storeDefault, new ViewerAppSettings())); /* viewer also default */ + Assert.False(ViewerControlPlaneMigration.ShouldImportMcp(storeTuned, new ViewerAppSettings { McpEnabled = true })); + } + + [Fact] + public void BuildAlertRow_ProjectsEveryFieldAndMapsCpuMode() + { + var settings = new ViewerAppSettings + { + AlertsEnabled = false, + NotifyConnectionChanges = false, + AlertCpuThreshold = 66, + AlertCpuMode = "SqlOnly", + AlertExcludedDatabases = new List { "msdb", "tempdb" }, + AnalysisIntervalMinutes = 45, + AnalysisNotifySeverity = 1.2, + AlertDeliveryMode = "PerEvent", + AlertPerEventMaxPerCycle = 7, + AlertLongRunningQueryMaxResults = 20, + AlertLongRunningQueryExcludeSpServerDiagnostics = false, + AlertLongRunningQueryExcludeCdc = false, + }; + + var row = ViewerControlPlaneMigration.BuildAlertRow(settings); + + Assert.False(row.Enabled); + Assert.False(row.NotifyConnectionChanges); + Assert.Equal(66, row.CpuThresholdPercent); + Assert.Equal("sql", row.CpuMode); + Assert.Equal(new[] { "msdb", "tempdb" }, row.ExcludedDatabases); + Assert.Equal(45, row.AnalysisIntervalMinutes); + Assert.Equal(1.2, row.AnalysisNotifySeverity, 3); + /* #1141/#1236: the delivery customization carries into the store row. */ + Assert.Equal("PerEvent", row.DeliveryMode); + Assert.Equal(7, row.PerEventMax); + /* V20: the long-running-query read-shape customization carries into the store row too. */ + Assert.Equal(20, row.LongRunningQueryMaxResults); + Assert.False(row.LongRunningQueryExcludeSpServerDiagnostics); + Assert.False(row.LongRunningQueryExcludeCdc); + Assert.True(row.LongRunningQueryExcludeWaitFor); /* untouched viewer default carries through */ + } + + [Fact] + public async Task MigrateAsync_Disconnected_IsANoOp_AndLeavesTheMarkerUnwritten() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "The migrate-in path is Windows-only (DPAPI secret re-seal)."); + + using var dir = new TempDir(); + var marker = Path.Combine(dir.Path, "cp.marker"); + var migration = new ViewerControlPlaneMigration(new ViewerAppSettings(), marker); + + var imported = await migration.MigrateAsync(dataService: null); + + Assert.Equal(0, imported); + Assert.False(migration.AlreadyMigrated); + Assert.False(File.Exists(marker)); + } + + [Fact] + public void Marker_GatesTheOnceGuard() + { + using var dir = new TempDir(); + var marker = Path.Combine(dir.Path, "cp.marker"); + Assert.False(new ViewerControlPlaneMigration(new ViewerAppSettings(), marker).AlreadyMigrated); + + File.WriteAllText(marker, "done"); + Assert.True(new ViewerControlPlaneMigration(new ViewerAppSettings(), marker).AlreadyMigrated); + } + + [Fact] + public async Task MigrateAsync_UnseededOrUnreachableStore_IsANoOp_AndLeavesTheMarkerUnwritten() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "The migrate-in path is Windows-only (DPAPI secret re-seal)."); + + using var dir = new TempDir(); + var marker = Path.Combine(dir.Path, "cp.marker"); + + /* An unreachable store: IsConfigSeededAsync fails safe to false, so the migrate must import nothing AND + NOT burn the once-marker (a later, seeded run still carries the operator's settings across). This + pins the seeded-guard that prevents a partially-seeded store from stranding the import. Port 1 has no + listener -> a fast connection-refused; Timeout=1 caps it. */ + await using var dataService = new ViewerDataService( + "Host=127.0.0.1;Port=1;Username=x;Password=x;Timeout=1;Command Timeout=1"); + var migration = new ViewerControlPlaneMigration(new ViewerAppSettings(), marker); + + var imported = await migration.MigrateAsync(dataService); + + Assert.Equal(0, imported); + Assert.False(migration.AlreadyMigrated); + Assert.False(File.Exists(marker)); + } + + private sealed class TempDir : IDisposable + { + public string Path { get; } = Directory.CreateTempSubdirectory("darling-cp3b-").FullName; + public void Dispose() { try { Directory.Delete(Path, recursive: true); } catch { /* best-effort */ } } + } +} diff --git a/Darling/Darling.Tests/ViewerCpuSchedulerTests.cs b/Darling/Darling.Tests/ViewerCpuSchedulerTests.cs new file mode 100644 index 000000000..4049c2b60 --- /dev/null +++ b/Darling/Darling.Tests/ViewerCpuSchedulerTests.cs @@ -0,0 +1,381 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the CPU Scheduler sub-tab's two reads against the Darling store contract (no live Postgres): the +/// pressure trend (runnable / blocked / queued task counts per collection, a point-in-time collector so +/// no delta math) and the latest-snapshot read (the single most recent cpu_scheduler_stats row in the +/// window). Both run on the v_cpu_scheduler_stats passthrough view. +/// +public sealed class ViewerCpuSchedulerSqlTests +{ + [Fact] + public void CpuSchedulerTrendSql_SelectsPressureCounts_OverTheWindow_OrderedByTime() + { + var sql = ViewerDataService.CpuSchedulerTrendSql; + + Assert.Contains("FROM v_cpu_scheduler_stats", sql, StringComparison.Ordinal); + Assert.Contains("total_runnable_tasks_count", sql, StringComparison.Ordinal); + Assert.Contains("total_blocked_task_count", sql, StringComparison.Ordinal); + Assert.Contains("total_queued_request_count", sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $2", sql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $3", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY collection_time", sql, StringComparison.Ordinal); + + /* A point-in-time snapshot collector: the counts plot directly — no averaging, no delta. */ + Assert.DoesNotContain("AVG(", sql, StringComparison.Ordinal); + Assert.DoesNotContain("LAG(", sql, StringComparison.Ordinal); + } + + [Fact] + public void CpuSchedulerSnapshotSql_LatestRowInWindow_AllPressureColumns() + { + var sql = ViewerDataService.CpuSchedulerSnapshotSql; + + Assert.Contains("FROM v_cpu_scheduler_stats", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY collection_time DESC", sql, StringComparison.Ordinal); + Assert.Contains("LIMIT 1", sql, StringComparison.Ordinal); + + /* The warning flags + NUMA / OS-memory columns the metric grid renders. */ + foreach (var column in new[] + { + "worker_thread_exhaustion_warning", "runnable_tasks_warning", "blocked_tasks_warning", + "queued_requests_warning", "physical_memory_pressure_warning", "offline_cpu_warning", + "runnable_percent", "system_memory_state_desc", "total_node_count", + }) + { + Assert.Contains(column, sql, StringComparison.Ordinal); + } + } + + [Theory] + [InlineData("trend")] + [InlineData("snapshot")] + public void CpuSchedulerSql_PgDialect_PositionalParams_NoBareNow_NoNLiterals(string which) + { + var sql = which == "trend" ? ViewerDataService.CpuSchedulerTrendSql : ViewerDataService.CpuSchedulerSnapshotSql; + + Assert.DoesNotContain("now(", sql.ToLowerInvariant()); + Assert.DoesNotContain("N'", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + Assert.Contains("$1", sql, StringComparison.Ordinal); + Assert.Contains("$2", sql, StringComparison.Ordinal); + Assert.Contains("$3", sql, StringComparison.Ordinal); + } + + [Fact] + public void CpuSchedulerSql_ReadsColumnsThatExistInTheGeneratedTable() + { + Assert.Equal("cpu_scheduler_stats", CpuSchedulerStatsCollector.Instance.TargetTable); + + var ddl = PgSchemaGenerator.CreateTable(CpuSchedulerStatsCollector.Instance); + foreach (var column in new[] + { + "collection_time", "total_runnable_tasks_count", "total_blocked_task_count", + "total_queued_request_count", "max_workers_count", "total_current_workers_count", + "runnable_percent", "worker_thread_exhaustion_warning", "system_memory_state_desc", + "total_physical_memory_kb", "nodes_online_count", + }) + { + Assert.Contains(column, ddl, StringComparison.Ordinal); + } + } + + [Fact] + public void CpuSchedulerView_IsPinnedInTheAuthoritativeViewList() + => Assert.Contains("v_cpu_scheduler_stats", PgSchemaGenerator.AllPassthroughViews); +} + +/// +/// Pins the CPU Scheduler latest-snapshot metric-grid projection (): the collector's pressure flags land on the right +/// labelled rows (driving the grid highlight), worker-utilization is current/max*100, nullable request +/// columns degrade to "N/A", and a null snapshot yields an empty grid. +/// +public sealed class ViewerCpuSchedulerProjectionTests +{ + private static CpuSchedulerSnapshot Snapshot( + bool workerExhaustion = false, bool runnableWarn = false, bool blockedWarn = false, + bool queuedWarn = false, bool memPressure = false, bool offlineWarn = false, + int maxWorkers = 512, int currentWorkers = 128, + int? runnableRequests = 3, decimal? runnablePercent = 12.5m) + => new( + CollectionTime: new DateTime(2026, 6, 1, 12, 0, 0, DateTimeKind.Unspecified), + MaxWorkersCount: maxWorkers, + SchedulerCount: 8, + CpuCount: 8, + TotalRunnableTasksCount: 4, + TotalWorkQueueCount: 0, + TotalCurrentWorkersCount: currentWorkers, + AvgRunnableTasksCount: 0.5m, + TotalActiveRequestCount: 2, + TotalQueuedRequestCount: 0, + TotalBlockedTaskCount: 0, + TotalActiveParallelThreadCount: 0, + RunnableRequestCount: runnableRequests, + TotalRequestCount: runnableRequests.HasValue ? 20 : (int?)null, + RunnablePercent: runnablePercent, + WorkerThreadExhaustionWarning: workerExhaustion, + RunnableTasksWarning: runnableWarn, + BlockedTasksWarning: blockedWarn, + QueuedRequestsWarning: queuedWarn, + TotalPhysicalMemoryKb: 67_108_864, // 64 GB + AvailablePhysicalMemoryKb: 8_388_608, // 8 GB + SystemMemoryStateDesc: "Available physical memory is high", + PhysicalMemoryPressureWarning: memPressure, + TotalNodeCount: 2, + NodesOnlineCount: 2, + OfflineCpuCount: 0, + OfflineCpuWarning: offlineWarn); + + [Fact] + public void BuildMetrics_NullSnapshot_YieldsEmptyGrid() + => Assert.Empty(CpuSchedulerMetrics.BuildMetrics(null)); + + [Fact] + public void BuildMetrics_WarningFlags_HighlightTheirOwnRows() + { + var rows = CpuSchedulerMetrics.BuildMetrics( + Snapshot(runnableWarn: true, blockedWarn: true, memPressure: true)); + + Assert.True(rows.Single(r => r.Metric == "Runnable Tasks").IsWarning); + Assert.True(rows.Single(r => r.Metric == "Blocked Tasks").IsWarning); + Assert.True(rows.Single(r => r.Metric == "Physical Memory Pressure").IsWarning); + Assert.True(rows.Single(r => r.Metric == "System Memory State").IsWarning); + + /* A metric whose flag is off must NOT highlight. */ + Assert.False(rows.Single(r => r.Metric == "Queued Requests").IsWarning); + Assert.False(rows.Single(r => r.Metric == "Offline CPUs").IsWarning); + } + + [Fact] + public void BuildMetrics_WorkerUtilization_IsCurrentOverMaxTimes100() + { + var rows = CpuSchedulerMetrics.BuildMetrics(Snapshot(maxWorkers: 400, currentWorkers: 100)); + + /* 100 / 400 * 100 = 25.0 %. */ + Assert.Equal("25.0", rows.Single(r => r.Metric == "Worker Utilization %").Value); + } + + [Fact] + public void BuildMetrics_WorkerUtilization_ZeroMaxWorkers_DoesNotDivideByZero() + { + var rows = CpuSchedulerMetrics.BuildMetrics(Snapshot(maxWorkers: 0, currentWorkers: 0)); + + Assert.Equal("0.0", rows.Single(r => r.Metric == "Worker Utilization %").Value); + } + + [Fact] + public void BuildMetrics_NullableRequestColumns_DegradeToNa() + { + var rows = CpuSchedulerMetrics.BuildMetrics(Snapshot(runnableRequests: null, runnablePercent: null)); + + Assert.Equal("N/A", rows.Single(r => r.Metric == "Runnable Requests").Value); + Assert.Equal("N/A", rows.Single(r => r.Metric == "Total Requests").Value); + Assert.Equal("N/A", rows.Single(r => r.Metric == "Runnable %").Value); + } + + [Fact] + public void BuildMetrics_LeadsWithThePressureLevelAndRecommendationRows() + { + /* Item 4: the rolled-up pressure badge + recommendation are the first two headline rows, ahead of + the raw metrics. A calm snapshot is NORMAL and does not highlight. */ + var rows = CpuSchedulerMetrics.BuildMetrics(Snapshot()); + + Assert.Equal("Pressure Level", rows[0].Metric); + Assert.Equal("Recommendation", rows[1].Metric); + Assert.Equal("NORMAL", rows[0].Value); + Assert.False(rows[0].IsWarning); + } + + [Fact] + public void BuildMetrics_PressureLevelRow_HighlightsWhenNotNormal() + { + /* worker_thread_exhaustion_warning drives a CRITICAL pressure level, which must highlight. */ + var rows = CpuSchedulerMetrics.BuildMetrics(Snapshot(workerExhaustion: true)); + + var pressure = rows.Single(r => r.Metric == "Pressure Level"); + Assert.StartsWith("CRITICAL", pressure.Value, StringComparison.Ordinal); + Assert.True(pressure.IsWarning); + } +} + +/// +/// Pins the rolled-up CPU-scheduler pressure classification +/// () against install/47's report.cpu_scheduler_pressure +/// CASE ordering: runnable task queue > 50 / > 20 / > 10, then worker-utilization > 90%, then the +/// collector's warning flags; plus the paired recommendation. +/// +public sealed class ViewerCpuPressureTests +{ + private static CpuSchedulerSnapshot Snapshot( + int runnableTasks = 0, int maxWorkers = 512, int currentWorkers = 100, + int queuedRequests = 0, bool workerExhaustion = false, bool runnableWarn = false, + bool queuedWarn = false) + => new( + CollectionTime: new DateTime(2026, 6, 1, 12, 0, 0, DateTimeKind.Unspecified), + MaxWorkersCount: maxWorkers, SchedulerCount: 8, CpuCount: 8, + TotalRunnableTasksCount: runnableTasks, TotalWorkQueueCount: 0, + TotalCurrentWorkersCount: currentWorkers, AvgRunnableTasksCount: 0m, + TotalActiveRequestCount: 0, TotalQueuedRequestCount: queuedRequests, TotalBlockedTaskCount: 0, + TotalActiveParallelThreadCount: 0, RunnableRequestCount: 0, TotalRequestCount: 0, + RunnablePercent: 0m, WorkerThreadExhaustionWarning: workerExhaustion, + RunnableTasksWarning: runnableWarn, BlockedTasksWarning: false, QueuedRequestsWarning: queuedWarn, + TotalPhysicalMemoryKb: 0, AvailablePhysicalMemoryKb: 0, SystemMemoryStateDesc: null, + PhysicalMemoryPressureWarning: false, TotalNodeCount: 1, NodesOnlineCount: 1, + OfflineCpuCount: 0, OfflineCpuWarning: false); + + [Theory] + [InlineData(60, "CRITICAL - High runnable task queue")] + [InlineData(51, "CRITICAL - High runnable task queue")] + [InlineData(50, "HIGH - Moderate runnable task queue")] // 50 is not > 50, but > 20 + [InlineData(25, "HIGH - Moderate runnable task queue")] + [InlineData(21, "HIGH - Moderate runnable task queue")] + [InlineData(15, "MEDIUM - Some runnable tasks queued")] + [InlineData(11, "MEDIUM - Some runnable tasks queued")] + [InlineData(5, "NORMAL")] + public void ClassifyCpuPressure_BandsOnRunnableTaskQueue(int runnable, string expectedLevel) + => Assert.Equal(expectedLevel, CpuSchedulerMetrics.ClassifyCpuPressure(Snapshot(runnableTasks: runnable)).Level); + + [Fact] + public void ClassifyCpuPressure_WorkerUtilizationOver90_IsHigh_WhenRunnableIsLow() + { + /* 95 / 100 = 95% > 90, runnable low → the worker-utilization branch. */ + var pressure = CpuSchedulerMetrics.ClassifyCpuPressure(Snapshot(maxWorkers: 100, currentWorkers: 95)); + Assert.Equal("HIGH - Worker thread exhaustion", pressure.Level); + } + + [Fact] + public void ClassifyCpuPressure_FallsThroughToWarningFlags_InOrder() + { + Assert.Equal("CRITICAL - Worker thread exhaustion warning", + CpuSchedulerMetrics.ClassifyCpuPressure(Snapshot(workerExhaustion: true)).Level); + Assert.Equal("HIGH - Runnable tasks warning", + CpuSchedulerMetrics.ClassifyCpuPressure(Snapshot(runnableWarn: true)).Level); + Assert.Equal("MEDIUM - Queued requests warning", + CpuSchedulerMetrics.ClassifyCpuPressure(Snapshot(queuedWarn: true)).Level); + } + + [Fact] + public void ClassifyCpuPressure_ZeroMaxWorkers_DoesNotDivideByZero_StaysNormal() + => Assert.Equal("NORMAL", CpuSchedulerMetrics.ClassifyCpuPressure(Snapshot(maxWorkers: 0, currentWorkers: 0)).Level); + + [Theory] + [InlineData(25, false, 0, "CPU pressure detected - check for CPU-intensive queries, consider adding CPU cores")] + [InlineData(0, true, 0, "Worker thread exhaustion - check max worker threads setting")] + [InlineData(0, false, 5, "Requests queued for execution - CPU or worker thread pressure")] + [InlineData(0, false, 0, "No CPU scheduler pressure detected")] + public void ClassifyCpuPressure_Recommendation_MirrorsTheProcCase( + int runnable, bool workerExhaustion, int queuedRequests, string expected) + => Assert.Equal(expected, CpuSchedulerMetrics.ClassifyCpuPressure( + Snapshot(runnableTasks: runnable, workerExhaustion: workerExhaustion, queuedRequests: queuedRequests)).Recommendation); +} + +/// +/// Gated (DARLING_TEST_PG) live round-trips for the CPU Scheduler reads: the pressure trend order and the +/// latest-snapshot selection (newest row in the window, all pressure columns round-tripped). Shares the +/// serialized "live-postgres" collection; uses a negative sentinel server_id and cleans up in finally. +/// +[Collection("live-postgres")] +public sealed class ViewerCpuSchedulerLivePostgresTests +{ + private const int ServerId = -940003; + private const string ServerName = "viewer-cpusched-e2e"; + + [Fact] + public async Task Trend_OrdersByCollectionTime_AndSnapshot_TakesNewestRow_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live cpu-scheduler test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteAsync(connection, ServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var t1 = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + var t2 = t1.AddMinutes(5); + + await InsertAsync(connection, 1, t1, runnable: 2, blocked: 0, queued: 0, workerWarn: false); + await InsertAsync(connection, 2, t2, runnable: 9, blocked: 4, queued: 1, workerWarn: true); + + var trend = await viewer.GetCpuSchedulerTrendAsync(ServerId, t1.AddMinutes(-1), t2.AddMinutes(1)); + Assert.Equal(2, trend.Count); + Assert.Equal(new[] { t1.Ticks, t2.Ticks }, trend.Select(p => p.CollectionTime.Ticks)); + Assert.Equal(new[] { 2, 9 }, trend.Select(p => p.RunnableTasks)); + Assert.Equal(new[] { 0, 4 }, trend.Select(p => p.BlockedTasks)); + + /* Snapshot = the newest row (t2), warning flag round-trips. */ + var snapshot = await viewer.GetCpuSchedulerSnapshotAsync(ServerId, t1.AddMinutes(-1), t2.AddMinutes(1)); + Assert.NotNull(snapshot); + Assert.Equal(t2.Ticks, snapshot!.CollectionTime.Ticks); + Assert.Equal(9, snapshot.TotalRunnableTasksCount); + Assert.True(snapshot.WorkerThreadExhaustionWarning); + } + finally + { + await DeleteAsync(connection, ServerId); + } + } + + private static async Task InsertAsync( + NpgsqlConnection connection, long collectionId, DateTime collectionTimeUtc, + int runnable, int blocked, int queued, bool workerWarn) + { + using var command = new NpgsqlCommand(@" +INSERT INTO cpu_scheduler_stats + (collection_id, collection_time, server_id, server_name, + max_workers_count, scheduler_count, cpu_count, + total_runnable_tasks_count, total_work_queue_count, total_current_workers_count, + avg_runnable_tasks_count, total_active_request_count, total_queued_request_count, + total_blocked_task_count, total_active_parallel_thread_count, + runnable_request_count, total_request_count, runnable_percent, + worker_thread_exhaustion_warning, runnable_tasks_warning, blocked_tasks_warning, + queued_requests_warning, total_physical_memory_kb, available_physical_memory_kb, + system_memory_state_desc, physical_memory_pressure_warning, + total_node_count, nodes_online_count, offline_cpu_count, offline_cpu_warning) +VALUES ($1, $2, $3, $4, 512, 8, 8, $5, 0, 128, 0.5, 2, $6, $7, 0, 3, 20, 12.5, + $8, false, false, false, 67108864, 8388608, 'ok', false, 2, 2, 0, false)", connection); + command.Parameters.AddWithValue(collectionId); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(ServerId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(runnable); + command.Parameters.AddWithValue(queued); + command.Parameters.AddWithValue(blocked); + command.Parameters.AddWithValue(workerWarn); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static DateTime TruncateToSeconds(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified); + + private static async Task DeleteAsync(NpgsqlConnection connection, int serverId) + { + using var cleanup = new NpgsqlCommand($"DELETE FROM cpu_scheduler_stats WHERE server_id = {serverId};", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } +} diff --git a/Darling/Darling.Tests/ViewerCpuTempDbTests.cs b/Darling/Darling.Tests/ViewerCpuTempDbTests.cs new file mode 100644 index 000000000..79faa91d5 --- /dev/null +++ b/Darling/Darling.Tests/ViewerCpuTempDbTests.cs @@ -0,0 +1,537 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading.Tasks; +using Npgsql; +using NpgsqlTypes; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the CPU-tab and tempdb-tab reads (W1a viewer copy-parity) against the Darling store contract, +/// no live Postgres. The CPU tab (and, since W1d, the Overview's CPU lane) plots RAW per-sample rows +/// (windowed on the naive-UTC collection_time, ordered by the server-local sample_time) — deliberately +/// not an average-per-collection roll-up. The tempdb reads mirror Lite's view-based queries +/// (v_tempdb_stats / v_file_io_stats): the numeric(18,2) MB columns are CAST to double precision for +/// the typed reader, total_sessions_using_tempdb stays bigint (GetInt64), and the file-I/O read filters +/// to tempdb and averages stall/op per file. +/// +public sealed class ViewerCpuTempDbSqlTests +{ + [Fact] + public void CpuUtilizationSql_SelectsRawSamples_WindowedOnCollectionTime_OrderedBySampleTime() + { + Assert.Contains("FROM cpu_utilization_stats", ViewerDataService.CpuUtilizationSql, StringComparison.Ordinal); + Assert.Contains("sample_time", ViewerDataService.CpuUtilizationSql, StringComparison.Ordinal); + Assert.Contains("sqlserver_cpu_utilization", ViewerDataService.CpuUtilizationSql, StringComparison.Ordinal); + Assert.Contains("other_process_cpu_utilization", ViewerDataService.CpuUtilizationSql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", ViewerDataService.CpuUtilizationSql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $2", ViewerDataService.CpuUtilizationSql, StringComparison.Ordinal); + Assert.Contains("ORDER BY sample_time", ViewerDataService.CpuUtilizationSql, StringComparison.Ordinal); + } + + [Fact] + public void CpuUtilizationSql_IsRawNotAveraged_NoAvgOrGroupBy() + { + /* The CPU tab (and the Overview's CPU lane) plots every ring-buffer sample; it must NOT + average per collection. The de-skew uses a window MAX (one value per row, no roll-up) and a + PARTITION BY, never AVG or GROUP BY. */ + Assert.DoesNotContain("AVG(", ViewerDataService.CpuUtilizationSql, StringComparison.Ordinal); + Assert.DoesNotContain("GROUP BY", ViewerDataService.CpuUtilizationSql, StringComparison.Ordinal); + } + + [Fact] + public void CpuUtilizationSql_DeSkewsSampleTime_PerBatchOffsetRoundedTo15Minutes() + { + /* #1262: sample_time is the monitored server's LOCAL wall clock, not naive UTC. The read + de-skews it to naive UTC by subtracting the per-batch UTC offset + = round(MAX(sample_time) over the batch - collection_time) to the nearest 15 minutes, so the + naive-UTC-assuming ToLocalTime aligns the CPU series with every collection_time-based lane. */ + var sql = ViewerDataService.CpuUtilizationSql; + + /* Window MAX over the collection batch — collection_time is the batch key (in the Darling PG + store collection_id is a plain non-unique bigint, NOT a batch id), so the partition is + (server_id, collection_time). One value per row: no roll-up of the raw samples. */ + Assert.Contains("MAX(sample_time) OVER (PARTITION BY server_id, collection_time)", sql, StringComparison.Ordinal); + + /* Offset = anchor - collection_time in epoch seconds, rounded to the nearest 900s (15 min), + then subtracted as an interval. */ + Assert.Contains("EXTRACT(EPOCH FROM (", sql, StringComparison.Ordinal); + Assert.Contains("- collection_time", sql, StringComparison.Ordinal); + Assert.Contains("/ 900.0", sql, StringComparison.Ordinal); + Assert.Contains("ROUND(", sql, StringComparison.Ordinal); + Assert.Contains("INTERVAL '15 minutes'", sql, StringComparison.Ordinal); + + /* The de-skewed value is still projected as sample_time (both consumers read column 0). */ + Assert.Contains("AS sample_time", sql, StringComparison.Ordinal); + } + + [Fact] + public void CpuUtilizationSql_ReadsColumnsThatExistInTheGeneratedCpuTable() + { + Assert.Equal("cpu_utilization_stats", CpuUtilizationCollector.Instance.TargetTable); + + var ddl = PgSchemaGenerator.CreateTable(CpuUtilizationCollector.Instance); + foreach (var column in new[] { "sample_time", "sqlserver_cpu_utilization", "other_process_cpu_utilization" }) + { + Assert.Contains(column, ddl, StringComparison.Ordinal); + } + } + + [Fact] + public void TempDbTrendSql_SelectsUsageColumns_CastsMbToDouble_OverTheWindow() + { + Assert.Contains("FROM v_tempdb_stats", ViewerDataService.TempDbTrendSql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", ViewerDataService.TempDbTrendSql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $2", ViewerDataService.TempDbTrendSql, StringComparison.Ordinal); + Assert.Contains("ORDER BY collection_time", ViewerDataService.TempDbTrendSql, StringComparison.Ordinal); + + /* Every numeric(18,2) MB column is CAST to double precision for the typed GetDouble reader. */ + foreach (var col in new[] + { + "user_object_reserved_mb", "internal_object_reserved_mb", "version_store_reserved_mb", + "total_reserved_mb", "unallocated_mb", "top_session_tempdb_mb", + }) + { + Assert.Contains($"CAST({col} AS double precision)", ViewerDataService.TempDbTrendSql, StringComparison.Ordinal); + } + + /* bigint session count + integer session id are selected raw (read with their own getters). */ + Assert.Contains("total_sessions_using_tempdb", ViewerDataService.TempDbTrendSql, StringComparison.Ordinal); + Assert.Contains("top_session_id", ViewerDataService.TempDbTrendSql, StringComparison.Ordinal); + /* The bigint session count must NOT be cast to double (it's read via GetInt64). */ + Assert.DoesNotContain("CAST(total_sessions_using_tempdb", ViewerDataService.TempDbTrendSql, StringComparison.Ordinal); + } + + [Fact] + public void TempDbTrendSql_ReadsColumnsThatExistInTheGeneratedTempDbTable() + { + Assert.Equal("tempdb_stats", TempDbStatsCollector.Instance.TargetTable); + + var ddl = PgSchemaGenerator.CreateTable(TempDbStatsCollector.Instance); + foreach (var column in new[] + { + "collection_time", "user_object_reserved_mb", "internal_object_reserved_mb", + "version_store_reserved_mb", "total_reserved_mb", "unallocated_mb", + "total_sessions_using_tempdb", "top_session_id", "top_session_tempdb_mb", + }) + { + Assert.Contains(column, ddl, StringComparison.Ordinal); + } + } + + [Fact] + public void TempDbFileIoTrendSql_FiltersToTempDb_GroupsPerFile_CastsStallToDouble() + { + Assert.Contains("FROM v_file_io_stats", ViewerDataService.TempDbFileIoTrendSql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", ViewerDataService.TempDbFileIoTrendSql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $2", ViewerDataService.TempDbFileIoTrendSql, StringComparison.Ordinal); + Assert.Contains("database_name = 'tempdb'", ViewerDataService.TempDbFileIoTrendSql, StringComparison.Ordinal); + Assert.Contains("GROUP BY collection_time, file_name", ViewerDataService.TempDbFileIoTrendSql, StringComparison.Ordinal); + Assert.Contains("ORDER BY collection_time, file_name", ViewerDataService.TempDbFileIoTrendSql, StringComparison.Ordinal); + Assert.Contains("CAST(delta_stall_read_ms AS double precision)", ViewerDataService.TempDbFileIoTrendSql, StringComparison.Ordinal); + Assert.Contains("CAST(delta_stall_write_ms AS double precision)", ViewerDataService.TempDbFileIoTrendSql, StringComparison.Ordinal); + } + + [Fact] + public void TempDbFileIoTrendSql_ReadsColumnsThatExistInTheGeneratedFileIoTable() + { + Assert.Equal("file_io_stats", FileIoStatsCollector.Instance.TargetTable); + + var ddl = PgSchemaGenerator.CreateTable(FileIoStatsCollector.Instance); + foreach (var column in new[] + { + "collection_time", "database_name", "file_name", + "delta_reads", "delta_writes", "delta_stall_read_ms", "delta_stall_write_ms", + }) + { + Assert.Contains(column, ddl, StringComparison.Ordinal); + } + } + + [Theory] + [InlineData("cpu")] + [InlineData("tempdb")] + [InlineData("fileio")] + public void NewViewerReads_PgDialect_PositionalParams_NoBareNow_NoNLiterals(string which) + { + var sql = which switch + { + "cpu" => ViewerDataService.CpuUtilizationSql, + "tempdb" => ViewerDataService.TempDbTrendSql, + _ => ViewerDataService.TempDbFileIoTrendSql, + }; + + Assert.DoesNotContain("now(", sql.ToLowerInvariant()); + Assert.DoesNotContain("N'", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + Assert.Contains("$1", sql, StringComparison.Ordinal); + Assert.Contains("$2", sql, StringComparison.Ordinal); + } +} + +/// +/// Gated (DARLING_TEST_PG) live round-trips for the W1a reads plus the #1262 CPU de-skew: raw CPU +/// samples (order + NULL-other-as-0); the CPU sample_time de-skew (a server-local batch shifted back to +/// naive UTC by the per-batch offset, and a UTC batch left untouched, both landing on collection_time); +/// the tempdb usage read (MB-as-double and — the load-bearing check — the bigint +/// total_sessions_using_tempdb read via GetInt64, which GetInt32 would throw on); and the tempdb +/// file-I/O read (tempdb-only filtering + per-file average-latency computation). Shares the serialized +/// "live-postgres" collection so the row churn can't race another class; uses negative sentinel +/// server_ids and cleans up in finally. +/// +[Collection("live-postgres")] +public sealed class ViewerCpuTempDbLivePostgresTests +{ + private const int CpuServerId = -949494; + private const string CpuServerName = "viewer-cpu-tab-e2e"; + + private const int SkewServerId = -979797; + private const string SkewServerName = "viewer-cpu-skew-e2e"; + + private const int TempDbServerId = -959595; + private const string TempDbServerName = "viewer-tempdb-tab-e2e"; + + private const int FileIoServerId = -969696; + private const string FileIoServerName = "viewer-tempdb-fileio-e2e"; + + [Fact] + public async Task Cpu_ReadsRawSamplesInOrder_NullOtherAsZero_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live CPU-tab test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "cpu_utilization_stats", CpuServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var collUtc = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + var s1 = collUtc; + var s2 = collUtc.AddMinutes(1); + var s3 = collUtc.AddMinutes(2); + + /* Three raw samples in one collection; the middle one has NULL other-process CPU (SQL on + Linux). Inserted out of sample_time order to prove the read's ORDER BY sample_time. This + batch is a UTC server (sample_time == UTC), so the #1262 de-skew is a no-op here (the + newest sample is +2min from collection_time, which rounds to a 0 offset). */ + await InsertCpuAsync(connection, CpuServerId, CpuServerName, collUtc, s3, sqlCpu: 30, otherCpu: 15); + await InsertCpuAsync(connection, CpuServerId, CpuServerName, collUtc, s1, sqlCpu: 10, otherCpu: 5); + await InsertCpuAsync(connection, CpuServerId, CpuServerName, collUtc, s2, sqlCpu: 20, otherCpu: null); + + var samples = await viewer.GetCpuUtilizationAsync(CpuServerId, collUtc.AddMinutes(-1)); + + Assert.Equal(3, samples.Count); + /* Raw (not averaged), ordered by sample_time. */ + Assert.Equal(new[] { s1.Ticks, s2.Ticks, s3.Ticks }, samples.Select(s => s.SampleTime.Ticks)); + Assert.Equal(new[] { 10, 20, 30 }, samples.Select(s => s.SqlServerCpu)); + /* Middle sample's NULL other-process CPU reads as 0. */ + Assert.Equal(new[] { 5, 0, 15 }, samples.Select(s => s.OtherProcessCpu)); + } + finally + { + await DeleteRowsAsync(connection, "cpu_utilization_stats", CpuServerId); + } + } + + [Fact] + public async Task Cpu_DeSkewsServerLocalSampleTimeToUtc_AlignsWithCollectionTime_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live CPU de-skew test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "cpu_utilization_stats", SkewServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + /* #1262 round-trip. cpu_utilization_stats stores sample_time as the monitored server's LOCAL + wall clock while collection_time is naive UTC. GetCpuUtilizationAsync must de-skew each + sample back to true naive UTC by the per-batch offset (round(MAX(sample_time) over the + batch - collection_time) to 15 min), so every batch below lands back on its collection_time + axis regardless of the server's timezone. In each batch the newest sample is 30s before the + collection instant (the anchor the de-skew keys on) and an older sample sits 5 min back; + each batch has its own collection_time (an hour apart) so the (server_id, collection_time) + partitions stay separate. Four zones exercise the derivation: + - Pacific PDT (UTC-7): Erik's fleet in summer. + - Pacific PST (UTC-8): the same fleet across its DST boundary. + - India IST (UTC+5:30): a POSITIVE, HALF-hour offset — proves the 15-minute rounding + generalizes past whole hours (14:00 - 30s -> round(+5:30 -30s) = +5:30 exactly). + - UTC (offset 0): the de-skew must be a no-op (stored local already equals true UTC). */ + var pacificSummer = TimeSpan.FromHours(-7); + var pacificWinter = TimeSpan.FromHours(-8); + var indiaStandard = new TimeSpan(5, 30, 0); + var utc = TimeSpan.Zero; + + var collPdt = new DateTime(2026, 6, 1, 12, 0, 0, DateTimeKind.Unspecified); + var collPst = new DateTime(2026, 6, 1, 13, 0, 0, DateTimeKind.Unspecified); + var collInd = new DateTime(2026, 6, 1, 14, 0, 0, DateTimeKind.Unspecified); + var collUtc = new DateTime(2026, 6, 1, 15, 0, 0, DateTimeKind.Unspecified); + + /* trueX = the intended true naive-UTC sample time; the row stores trueX + zone offset (the + server-local wall clock the collector would record). */ + var truePdtOld = collPdt.AddMinutes(-5); var truePdtNew = collPdt.AddSeconds(-30); + var truePstOld = collPst.AddMinutes(-5); var truePstNew = collPst.AddSeconds(-30); + var trueIndOld = collInd.AddMinutes(-5); var trueIndNew = collInd.AddSeconds(-30); + var trueUtcOld = collUtc.AddMinutes(-5); var trueUtcNew = collUtc.AddSeconds(-30); + + await InsertCpuAsync(connection, SkewServerId, SkewServerName, collPdt, truePdtOld + pacificSummer, sqlCpu: 11, otherCpu: 3); + await InsertCpuAsync(connection, SkewServerId, SkewServerName, collPdt, truePdtNew + pacificSummer, sqlCpu: 12, otherCpu: 4); + await InsertCpuAsync(connection, SkewServerId, SkewServerName, collPst, truePstOld + pacificWinter, sqlCpu: 21, otherCpu: 5); + await InsertCpuAsync(connection, SkewServerId, SkewServerName, collPst, truePstNew + pacificWinter, sqlCpu: 22, otherCpu: 6); + await InsertCpuAsync(connection, SkewServerId, SkewServerName, collInd, trueIndOld + indiaStandard, sqlCpu: 31, otherCpu: 7); + await InsertCpuAsync(connection, SkewServerId, SkewServerName, collInd, trueIndNew + indiaStandard, sqlCpu: 32, otherCpu: 8); + await InsertCpuAsync(connection, SkewServerId, SkewServerName, collUtc, trueUtcOld + utc, sqlCpu: 41, otherCpu: 9); + await InsertCpuAsync(connection, SkewServerId, SkewServerName, collUtc, trueUtcNew + utc, sqlCpu: 42, otherCpu: 10); + + var samples = await viewer.GetCpuUtilizationAsync(SkewServerId, collPdt.AddMinutes(-10)); + + Assert.Equal(8, samples.Count); + + /* Every sample_time comes back as its intended true naive UTC — each skewed batch shifted by + the negative of its zone offset, the UTC batch untouched — ordered by that de-skewed time. + Paired with its (unique) CPU value so the assertion pins which row de-skewed to which + instant. */ + Assert.Equal( + new[] + { + (truePdtOld.Ticks, 11), + (truePdtNew.Ticks, 12), + (truePstOld.Ticks, 21), + (truePstNew.Ticks, 22), + (trueIndOld.Ticks, 31), + (trueIndNew.Ticks, 32), + (trueUtcOld.Ticks, 41), + (trueUtcNew.Ticks, 42), + }, + samples.Select(s => (s.SampleTime.Ticks, s.SqlServerCpu))); + + /* The load-bearing outcome: each batch's newest de-skewed sample now sits within the 15-min + rounding tolerance of its own collection_time, on every zone — i.e. the CPU series is back + on the collection_time axis every other lane plots on. */ + Assert.True((samples[1].SampleTime - collPdt).Duration() <= TimeSpan.FromMinutes(15), + "The Pacific PDT (UTC-7) batch's newest sample should align with its collection_time after de-skew."); + Assert.True((samples[3].SampleTime - collPst).Duration() <= TimeSpan.FromMinutes(15), + "The Pacific PST (UTC-8) batch's newest sample should align with its collection_time after de-skew."); + Assert.True((samples[5].SampleTime - collInd).Duration() <= TimeSpan.FromMinutes(15), + "The India IST (UTC+5:30) batch's newest sample should align with its collection_time after de-skew."); + Assert.True((samples[7].SampleTime - collUtc).Duration() <= TimeSpan.FromMinutes(15), + "The UTC batch's newest sample should align with its collection_time (unchanged)."); + + /* Each skewed batch was genuinely shifted (stored local != returned UTC); the UTC batch was + left untouched (stored local == returned UTC). */ + Assert.NotEqual((truePdtNew + pacificSummer).Ticks, samples[1].SampleTime.Ticks); + Assert.NotEqual((truePstNew + pacificWinter).Ticks, samples[3].SampleTime.Ticks); + Assert.NotEqual((trueIndNew + indiaStandard).Ticks, samples[5].SampleTime.Ticks); + Assert.Equal(trueUtcNew.Ticks, samples[7].SampleTime.Ticks); + } + finally + { + await DeleteRowsAsync(connection, "cpu_utilization_stats", SkewServerId); + } + } + + [Fact] + public async Task TempDb_ReadsMbAsDouble_AndBigintSessionCount_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live tempdb-usage test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "tempdb_stats", TempDbServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var t1 = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + var t2 = t1.AddMinutes(5); + + /* total_sessions deliberately exceeds int.MaxValue so a GetInt32 reader would throw — + this is the read's load-bearing bigint check. */ + const long bigSessionCount = 5_000_000_000L; + + await InsertTempDbAsync(connection, t1, + userMb: 100.25m, internalMb: 50.50m, versionMb: 10.75m, + totalMb: 161.50m, unallocMb: 38.50m, totalSessions: 3, topSessionId: 55, topSessionMb: 12.00m); + await InsertTempDbAsync(connection, t2, + userMb: 200.00m, internalMb: 60.00m, versionMb: 20.00m, + totalMb: 280.00m, unallocMb: 120.00m, totalSessions: bigSessionCount, topSessionId: 77, topSessionMb: 25.50m); + + var samples = await viewer.GetTempDbTrendAsync(TempDbServerId, t1.AddMinutes(-1)); + + Assert.Equal(2, samples.Count); + + /* Ordered by collection_time; MB columns read as double. */ + Assert.Equal(t1.Ticks, samples[0].CollectionTime.Ticks); + Assert.Equal(100.25, samples[0].UserObjectReservedMb, precision: 3); + Assert.Equal(50.50, samples[0].InternalObjectReservedMb, precision: 3); + Assert.Equal(10.75, samples[0].VersionStoreReservedMb, precision: 3); + Assert.Equal(161.50, samples[0].TotalReservedMb, precision: 3); + Assert.Equal(38.50, samples[0].UnallocatedMb, precision: 3); + Assert.Equal(3L, samples[0].TotalSessionsUsingTempDb); + + /* The bigint session count round-trips through GetInt64. */ + Assert.Equal(t2.Ticks, samples[1].CollectionTime.Ticks); + Assert.Equal(bigSessionCount, samples[1].TotalSessionsUsingTempDb); + Assert.Equal(77, samples[1].TopSessionId); + Assert.Equal(25.50, samples[1].TopSessionTempDbMb, precision: 3); + } + finally + { + await DeleteRowsAsync(connection, "tempdb_stats", TempDbServerId); + } + } + + [Fact] + public async Task TempDbFileIo_TempDbOnly_PerFileAverageLatency_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live tempdb-file-I/O test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "file_io_stats", FileIoServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var t1 = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + + /* Two tempdb files plus a user-database file that must be excluded by the WHERE clause. */ + await InsertFileIoAsync(connection, t1, "tempdb", "tempdb_data", + deltaReads: 10, deltaWrites: 4, deltaStallReadMs: 100, deltaStallWriteMs: 20); + await InsertFileIoAsync(connection, t1, "tempdb", "tempdb_log", + deltaReads: 0, deltaWrites: 8, deltaStallReadMs: 0, deltaStallWriteMs: 80); + await InsertFileIoAsync(connection, t1, "StackOverflow2010", "so_data", + deltaReads: 1000, deltaWrites: 1000, deltaStallReadMs: 999999, deltaStallWriteMs: 999999); + + var rows = await viewer.GetTempDbFileIoTrendAsync(FileIoServerId, t1.AddMinutes(-1)); + + /* Only the two tempdb files survive the database_name = 'tempdb' filter. */ + Assert.Equal(2, rows.Count); + Assert.All(rows, r => Assert.Equal(t1.Ticks, r.CollectionTime.Ticks)); + /* Ordered by file_name. */ + Assert.Equal(new[] { "tempdb_data", "tempdb_log" }, rows.Select(r => r.FileName)); + + /* tempdb_data: 100 stall / 10 reads = 10 ms read; 20 stall / 4 writes = 5 ms write. */ + Assert.Equal(10.0, rows[0].AvgReadLatencyMs, precision: 3); + Assert.Equal(5.0, rows[0].AvgWriteLatencyMs, precision: 3); + /* tempdb_log: 0 reads → CASE ELSE 0 read latency; 80 stall / 8 writes = 10 ms write. */ + Assert.Equal(0.0, rows[1].AvgReadLatencyMs, precision: 3); + Assert.Equal(10.0, rows[1].AvgWriteLatencyMs, precision: 3); + } + finally + { + await DeleteRowsAsync(connection, "file_io_stats", FileIoServerId); + } + } + + private static async Task InsertCpuAsync( + NpgsqlConnection connection, int serverId, string serverName, + DateTime collectionTimeUtc, DateTime sampleTimeLocal, int sqlCpu, int? otherCpu) + { + using var command = new NpgsqlCommand(@" +INSERT INTO cpu_utilization_stats + (collection_id, collection_time, server_id, server_name, sample_time, + sqlserver_cpu_utilization, other_process_cpu_utilization) +VALUES ($1, $2, $3, $4, $5, $6, $7)", connection); + /* collection_id is a plain non-unique bigint in the Darling PG store (PgSchemaGenerator drops + DuckDB's PK), so every row of a batch can share it — collection_time is the real batch key. */ + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(serverName); + /* sample_time is the monitored server's LOCAL wall clock as the collector stores it. */ + command.Parameters.AddWithValue(DateTime.SpecifyKind(sampleTimeLocal, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(sqlCpu); + command.Parameters.Add(new NpgsqlParameter { Value = (object?)otherCpu ?? DBNull.Value, NpgsqlDbType = NpgsqlDbType.Integer }); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertTempDbAsync( + NpgsqlConnection connection, DateTime collectionTimeUtc, + decimal userMb, decimal internalMb, decimal versionMb, decimal totalMb, decimal unallocMb, + long totalSessions, int topSessionId, decimal topSessionMb) + { + using var command = new NpgsqlCommand(@" +INSERT INTO tempdb_stats + (collection_id, collection_time, server_id, server_name, + user_object_reserved_mb, internal_object_reserved_mb, version_store_reserved_mb, + total_reserved_mb, unallocated_mb, total_sessions_using_tempdb, + top_session_id, top_session_tempdb_mb) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(TempDbServerId); + command.Parameters.AddWithValue(TempDbServerName); + command.Parameters.AddWithValue(userMb); + command.Parameters.AddWithValue(internalMb); + command.Parameters.AddWithValue(versionMb); + command.Parameters.AddWithValue(totalMb); + command.Parameters.AddWithValue(unallocMb); + command.Parameters.AddWithValue(totalSessions); + command.Parameters.AddWithValue(topSessionId); + command.Parameters.AddWithValue(topSessionMb); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertFileIoAsync( + NpgsqlConnection connection, DateTime collectionTimeUtc, string databaseName, string fileName, + long deltaReads, long deltaWrites, long deltaStallReadMs, long deltaStallWriteMs) + { + using var command = new NpgsqlCommand(@" +INSERT INTO file_io_stats + (collection_id, collection_time, server_id, server_name, + database_name, file_name, delta_reads, delta_writes, + delta_stall_read_ms, delta_stall_write_ms) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(FileIoServerId); + command.Parameters.AddWithValue(FileIoServerName); + command.Parameters.AddWithValue(databaseName); + command.Parameters.AddWithValue(fileName); + command.Parameters.AddWithValue(deltaReads); + command.Parameters.AddWithValue(deltaWrites); + command.Parameters.AddWithValue(deltaStallReadMs); + command.Parameters.AddWithValue(deltaStallWriteMs); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static DateTime TruncateToSeconds(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified); + + private static async Task DeleteRowsAsync(NpgsqlConnection connection, string table, int serverId) + { + using var cleanup = new NpgsqlCommand($"DELETE FROM {table} WHERE server_id = {serverId};", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } +} diff --git a/Darling/Darling.Tests/ViewerDailyHealthTests.cs b/Darling/Darling.Tests/ViewerDailyHealthTests.cs new file mode 100644 index 000000000..716435d77 --- /dev/null +++ b/Darling/Darling.Tests/ViewerDailyHealthTests.cs @@ -0,0 +1,768 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the Performance Calendar / Daily Summary per-day-range aggregate (DailySummaryRangeSql) +/// against the Darling store contract — no live Postgres. The pins are the load-bearing clauses: per-day +/// bucketing, the day spine, the per-source CTEs, the top-wait ranking, the blocking XE→DMV fallback, the +/// total-host-CPU ≥ 80 rule, the memory tiers, and the actionable-alert filter. +/// +public sealed class ViewerDailySummarySqlTests +{ + [Fact] + public void DailySummaryRangeSql_RollsUpEverySource_GroupedPerDay() + { + var sql = ViewerDataService.DailySummaryRangeSql; + + /* Per-day bucketing + a day spine so a quiet-but-collected day still appears. */ + Assert.Contains("date_trunc('day', collection_time)", sql, StringComparison.Ordinal); + Assert.Contains("day_spine AS (", sql, StringComparison.Ordinal); + Assert.Contains("UNION SELECT d FROM", sql, StringComparison.Ordinal); + + /* Total wait seconds + per-day top wait type off v_wait_stats, positive deltas only. */ + Assert.Contains("FROM v_wait_stats", sql, StringComparison.Ordinal); + Assert.Contains("SUM(ms) / 1000.0", sql, StringComparison.Ordinal); + Assert.Contains("delta_wait_time_ms > 0", sql, StringComparison.Ordinal); + Assert.Contains("DISTINCT ON (d)", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY d, ms DESC", sql, StringComparison.Ordinal); + + /* Distinct query count, deadlock count. */ + Assert.Contains("COUNT(DISTINCT query_hash)", sql, StringComparison.Ordinal); + Assert.Contains("FROM v_query_stats", sql, StringComparison.Ordinal); + Assert.Contains("FROM v_deadlocks", sql, StringComparison.Ordinal); + + /* Blocking events: XE blocked-process reports, falling back to the DMV snapshot count when the + XE count is zero (NULLIF → COALESCE). */ + Assert.Contains("COALESCE(NULLIF(b.c, 0), dm.c, 0)", sql, StringComparison.Ordinal); + Assert.Contains("FROM v_blocked_process_reports", sql, StringComparison.Ordinal); + Assert.Contains("FROM v_dmv_blocking_snapshots", sql, StringComparison.Ordinal); + + /* Peak block wait (ms) for the day-detail blocking reason: MAX(wait_time_ms) per source, taken from + the SAME source the count came from (BPR preferred, DMV fallback) so it reconciles with the count. */ + Assert.Contains("MAX(wait_time_ms) AS max_wait_ms", sql, StringComparison.Ordinal); + Assert.Contains("CASE WHEN COALESCE(b.c, 0) > 0 THEN b.max_wait_ms ELSE dm.max_wait_ms END", sql, StringComparison.Ordinal); + + /* High-CPU count uses total host CPU = SQL + other-process (Linux NULL → 0), threshold 80, via FILTER. */ + Assert.Contains("(sqlserver_cpu_utilization + COALESCE(other_process_cpu_utilization, 0)) >= 80", sql, StringComparison.Ordinal); + Assert.Contains("FROM v_cpu_utilization_stats", sql, StringComparison.Ordinal); + Assert.Contains("FILTER (WHERE", sql, StringComparison.Ordinal); + + /* Collect errors off the collection_log ERROR rows; all-status runs mark the day collected. */ + Assert.Contains("status = 'ERROR'", sql, StringComparison.Ordinal); + Assert.Contains("FROM v_collection_log", sql, StringComparison.Ordinal); + + /* Memory pressure (process OR system indicator >= 2) and the severe escalation (process >= 3). */ + Assert.Contains("FROM v_memory_pressure_events", sql, StringComparison.Ordinal); + Assert.Contains("memory_indicators_process >= 2 OR memory_indicators_system >= 2", sql, StringComparison.Ordinal); + Assert.Contains("memory_indicators_process >= 3", sql, StringComparison.Ordinal); + + /* Actionable alerts: non-dismissed, excluding resolution notices, off config_alert_log. */ + Assert.Contains("FROM config_alert_log", sql, StringComparison.Ordinal); + Assert.Contains("dismissed = FALSE", sql, StringComparison.Ordinal); + Assert.Contains("NOT LIKE '%Cleared%'", sql, StringComparison.Ordinal); + + /* Every subquery windows on the half-open [rangeStart, rangeEnd) range. */ + Assert.Contains("collection_time >= $2 AND collection_time < $3", sql, StringComparison.Ordinal); + Assert.Contains("alert_time >= $2 AND alert_time < $3", sql, StringComparison.Ordinal); + } + + [Fact] + public void DailySummaryRangeSql_MemoryPressureColumns_ExistInTheGeneratedSourceTable() + { + var ddl = PgSchemaGenerator.CreateTable(MemoryPressureEventsCollector.Instance); + Assert.Contains("memory_indicators_process", ddl, StringComparison.Ordinal); + Assert.Contains("memory_indicators_system", ddl, StringComparison.Ordinal); + Assert.Contains("v_memory_pressure_events", PgSchemaGenerator.AllPassthroughViews); + } + + [Fact] + public void DailySummaryRangeSql_IsPgDialect_PositionalParams_NoBareNow_NoNLiterals() + { + var sql = ViewerDataService.DailySummaryRangeSql; + Assert.DoesNotContain("now(", sql.ToLowerInvariant()); + Assert.DoesNotContain("N'", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + Assert.Contains("$1", sql, StringComparison.Ordinal); + Assert.Contains("$2", sql, StringComparison.Ordinal); + Assert.Contains("$3", sql, StringComparison.Ordinal); + } + + [Fact] + public void DailySummaryRangeSql_ReadsColumnsThatExistInTheGeneratedSourceTables() + { + Assert.Contains("delta_wait_time_ms", PgSchemaGenerator.CreateTable(WaitStatsCollector.Instance), StringComparison.Ordinal); + Assert.Contains("wait_type", PgSchemaGenerator.CreateTable(WaitStatsCollector.Instance), StringComparison.Ordinal); + Assert.Contains("query_hash", PgSchemaGenerator.CreateTable(QueryStatsCollector.Instance), StringComparison.Ordinal); + + var cpuDdl = PgSchemaGenerator.CreateTable(CpuUtilizationCollector.Instance); + Assert.Contains("sqlserver_cpu_utilization", cpuDdl, StringComparison.Ordinal); + Assert.Contains("other_process_cpu_utilization", cpuDdl, StringComparison.Ordinal); + } + + [Fact] + public void PassthroughViews_ForEverySource_ExistInTheMigrations() + { + /* The daily-summary sources read the v_* passthrough views; they are created across the + migration set. The alert signal reads the config_alert_log base table directly. */ + var allMigrationSql = string.Concat(PgMigrations.Scripts.Select(m => m.Sql)); + foreach (var view in new[] + { + "v_wait_stats", "v_query_stats", "v_deadlocks", "v_blocked_process_reports", + "v_dmv_blocking_snapshots", "v_cpu_utilization_stats", "v_collection_log", + "v_memory_pressure_events", "config_alert_log", + }) + { + Assert.Contains(view, allMigrationSql, StringComparison.Ordinal); + } + } +} + +/// +/// Pins the Collection Health tab's three ported reads (W1i, copied from Lite's +/// LocalDataService.CollectionHealth.cs) — the 7-day per-collector aggregate, the recent-log read, and +/// the per-collector drill read — against the Darling store contract, no live Postgres. +/// +public sealed class ViewerCollectionHealthSqlTests +{ + [Fact] + public void CollectionHealthSql_AggregatesPerCollectorOverTheWindow_WithTheBandingBuckets() + { + var sql = ViewerDataService.CollectionHealthSql; + + Assert.Contains("FROM v_collection_log", sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $2", sql, StringComparison.Ordinal); + Assert.Contains("GROUP BY collector_name", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY collector_name", sql, StringComparison.Ordinal); + + /* Success / error / permission buckets feed the row's HealthStatus banding. */ + Assert.Contains("SUM(CASE WHEN status = 'SUCCESS' THEN 1 ELSE 0 END)", sql, StringComparison.Ordinal); + Assert.Contains("SUM(CASE WHEN status = 'ERROR' THEN 1 ELSE 0 END)", sql, StringComparison.Ordinal); + Assert.Contains("SUM(CASE WHEN status = 'PERMISSIONS' THEN 1 ELSE 0 END)", sql, StringComparison.Ordinal); + + /* SKIPPED counts as a healthy run for the last-success watermark. */ + Assert.Contains("MAX(CASE WHEN status IN ('SUCCESS', 'SKIPPED') THEN collection_time END)", sql, StringComparison.Ordinal); + Assert.Contains("AVG(duration_ms)", sql, StringComparison.Ordinal); + } + + [Fact] + public void RecentCollectionLogSql_BoundsTheWindowOnBothSides_NewestFirst_Capped() + { + var sql = ViewerDataService.RecentCollectionLogSql; + Assert.Contains("FROM v_collection_log", sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + /* Both-sided window bound so the toolbar's custom From/To is honored EXACTLY (not rounded to a + now-relative hours-back span). $2 = window start, $3 = window end, $4 = row cap. */ + Assert.Contains("collection_time >= $2", sql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $3", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY collection_time DESC", sql, StringComparison.Ordinal); + Assert.Contains("LIMIT $4", sql, StringComparison.Ordinal); + /* The two phase-timing columns the grid surfaces (Store (ms) = duckdb_duration_ms). */ + Assert.Contains("sql_duration_ms", sql, StringComparison.Ordinal); + Assert.Contains("duckdb_duration_ms", sql, StringComparison.Ordinal); + } + + [Fact] + public void CollectionLogByCollectorSql_FiltersToOneCollector_NewestFirst() + { + var sql = ViewerDataService.CollectionLogByCollectorSql; + Assert.Contains("FROM v_collection_log", sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("collector_name = $2", sql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $3", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY collection_time DESC", sql, StringComparison.Ordinal); + } + + [Fact] + public void CollectionHealthSql_AllThree_ArePgDialect_PositionalParams_NoBareNow_NoNLiterals() + { + foreach (var sql in new[] + { + ViewerDataService.CollectionHealthSql, + ViewerDataService.RecentCollectionLogSql, + ViewerDataService.CollectionLogByCollectorSql, + }) + { + Assert.DoesNotContain("now(", sql.ToLowerInvariant()); + Assert.DoesNotContain("N'", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + Assert.Contains("$1", sql, StringComparison.Ordinal); + } + } + + [Fact] + public void CollectionLog_ColumnsTheReadsUse_ExistInTheGeneratedTable() + { + var v2 = PgMigrations.Scripts.First(m => m.Name == "server-registry-and-collection-log").Sql; + foreach (var column in new[] + { + "collector_name", "collection_time", "duration_ms", "sql_duration_ms", + "duckdb_duration_ms", "rows_collected", "status", "error_message", "server_name", + }) + { + Assert.Contains(column, v2, StringComparison.Ordinal); + } + } +} + +/// +/// Unit-tests the pure display helpers copied verbatim from Lite: the Daily Summary row's wait +/// formatting and the rich Collection Health row's HealthStatus banding + duration/time formatting. +/// These are the load-bearing bits computed in C# (not SQL), so they run without any Postgres. +/// +public sealed class ViewerDailyHealthRowTests +{ + [Theory] + [InlineData(0, "0.0 s")] + [InlineData(12.5, "12.5 s")] + [InlineData(999.9, "999.9 s")] + [InlineData(1000, "16.7 min")] // >= 1000 s flips to minutes + [InlineData(3600, "60.0 min")] + public void DailySummaryRow_TotalWaitFormatted_FlipsToMinutesAtAThousandSeconds(double seconds, string expected) + { + var row = new DailySummaryRow { TotalWaitTimeSec = (decimal)seconds }; + Assert.Equal(expected, row.TotalWaitFormatted); + } + + [Fact] + public void DailySummaryRow_SummaryDateFormatted_IsIsoDate() + { + var row = new DailySummaryRow { SummaryDate = new DateTime(2026, 7, 3, 14, 0, 0, DateTimeKind.Utc) }; + Assert.Equal("2026-07-03", row.SummaryDateFormatted); + } + + // The Daily Summary health banding moved to the shared, app-agnostic DailyHealthBandCalculator + // (composite No-Data / Healthy / Warning / Critical for the Performance Calendar) and is pinned by + // DailyHealthBandTests here in Darling.Tests and, identically, in Lite.Tests. + + [Fact] + public void CollectorHealthRow_NeverRun_WhenNoRuns() + { + Assert.Equal("NEVER_RUN", new CollectorHealthRow { CollectorName = "wait_stats", TotalRuns = 0 }.HealthStatus); + } + + [Fact] + public void CollectorHealthRow_NoPermissions_WhenOnlyPermissionDenials() + { + var row = new CollectorHealthRow + { + CollectorName = "wait_stats", + TotalRuns = 5, + PermissionDeniedCount = 5, + SuccessCount = 0, + ErrorCount = 0, + }; + Assert.Equal("NO_PERMISSIONS", row.HealthStatus); + } + + [Fact] + public void CollectorHealthRow_Failing_WhenLastSuccessOlderThan24Hours() + { + var row = new CollectorHealthRow + { + CollectorName = "wait_stats", + TotalRuns = 10, + SuccessCount = 10, + LastSuccessTime = DateTime.UtcNow.AddHours(-30), + }; + Assert.Equal("FAILING", row.HealthStatus); + } + + [Fact] + public void CollectorHealthRow_Stale_WhenLastSuccessBetween4And24Hours() + { + var row = new CollectorHealthRow + { + CollectorName = "wait_stats", + TotalRuns = 10, + SuccessCount = 10, + LastSuccessTime = DateTime.UtcNow.AddHours(-10), + }; + Assert.Equal("STALE", row.HealthStatus); + } + + [Fact] + public void CollectorHealthRow_Warning_WhenRecentButFailureRateOver20Percent() + { + var row = new CollectorHealthRow + { + CollectorName = "wait_stats", + TotalRuns = 10, + SuccessCount = 7, + ErrorCount = 3, // 30% > 20% + LastSuccessTime = DateTime.UtcNow.AddMinutes(-1), + }; + Assert.Equal("WARNING", row.HealthStatus); + } + + [Fact] + public void CollectorHealthRow_Healthy_WhenRecentAndLowFailureRate() + { + var row = new CollectorHealthRow + { + CollectorName = "wait_stats", + TotalRuns = 10, + SuccessCount = 10, + ErrorCount = 0, + LastSuccessTime = DateTime.UtcNow.AddMinutes(-1), + }; + Assert.Equal("HEALTHY", row.HealthStatus); + } + + [Fact] + public void CollectorHealthRow_OnLoadCollector_IsExemptFromStaleness() + { + /* server_config runs once per tab open, not on the loop — a 100-hour-old last success is NOT + stale/failing for it (would be FAILING for a scheduled collector). */ + var row = new CollectorHealthRow + { + CollectorName = "server_config", + TotalRuns = 3, + SuccessCount = 3, + LastSuccessTime = DateTime.UtcNow.AddHours(-100), + }; + Assert.Equal("HEALTHY", row.HealthStatus); + } + + [Fact] + public void CollectorHealthRow_OnLoadCollector_StillWarnsOnHighFailureRate() + { + var row = new CollectorHealthRow + { + CollectorName = "server_config", + TotalRuns = 10, + SuccessCount = 7, + ErrorCount = 3, // 30% > 20% + LastSuccessTime = DateTime.UtcNow.AddHours(-100), + }; + Assert.Equal("WARNING", row.HealthStatus); + } + + [Theory] + [InlineData(0, 0, 0.0)] // no runs → 0, no divide-by-zero + [InlineData(10, 2, 20.0)] + public void CollectorHealthRow_FailureRatePercent(long totalRuns, long errorCount, double expected) + { + var row = new CollectorHealthRow { TotalRuns = totalRuns, ErrorCount = errorCount }; + Assert.Equal(expected, row.FailureRatePercent, 3); + } + + [Theory] + [InlineData(500, "500 ms")] + [InlineData(1500, "1.5 s")] + public void CollectorHealthRow_AvgDurationFormatted_FlipsToSecondsAtAThousandMs(double avgMs, string expected) + { + Assert.Equal(expected, new CollectorHealthRow { AvgDurationMs = avgMs }.AvgDurationFormatted); + } + + [Fact] + public void CollectorHealthRow_NeverFormatting_WhenNoTimestamps() + { + var row = new CollectorHealthRow(); + Assert.Equal("Never", row.LastSuccessFormatted); + Assert.Equal("Never", row.LastRunFormatted); + Assert.Equal("", row.LastErrorFormatted); + } + + [Theory] + [InlineData(null, "")] + [InlineData(500, "500 ms")] + [InlineData(1500, "1.5 s")] + public void CollectionLogRow_DurationFormatted_FlipsToSecondsAtAThousandMs(int? durationMs, string expected) + { + Assert.Equal(expected, new CollectionLogRow { DurationMs = durationMs }.DurationFormatted); + } + + [Fact] + public void CollectionLogRow_PhaseDurations_FormatAsMilliseconds() + { + var row = new CollectionLogRow { SqlDurationMs = 42, DuckDbDurationMs = 7 }; + Assert.Equal("42 ms", row.SqlDurationFormatted); + Assert.Equal("7 ms", row.DuckDbDurationFormatted); // surfaced as the "Store (ms)" column + } + + [Fact] + public void CollectionLogRow_CollectionTimeFormatted_TreatsTheStoredValueAsUtc() + { + /* Npgsql reads a naive timestamp as Unspecified; ToLocalTime treats Unspecified as UTC, so the + formatted local time round-trips back to the stored instant on any machine. */ + var storedUtc = new DateTime(2026, 7, 3, 3, 30, 0, DateTimeKind.Unspecified); + var row = new CollectionLogRow { CollectionTime = storedUtc }; + var expected = storedUtc.ToLocalTime().ToString("g"); + Assert.Equal(expected, row.CollectionTimeFormatted); + } +} + +/// +/// Gated (DARLING_TEST_PG) live round-trips for the Daily Summary aggregate and the Collection Health +/// reads. Shares the serialized "live-postgres" collection; uses negative sentinel server_ids and cleans +/// up in finally. These prove the copied SQL runs value-identically on real Postgres (the aggregate's +/// COALESCE/NULLIF fallback, the total-CPU ≥ 80 rule, the 7-day banding). +/// +[Collection("live-postgres")] +public sealed class ViewerDailyHealthLivePostgresTests +{ + private const int SummaryServerId = -949601; + private const int HealthServerId = -949602; + private const string ServerName = "viewer-w1i-e2e"; + + [Fact] + public async Task DailySummary_RollsUpEverySource_ForTheSelectedDay_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live daily-summary test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteSummaryRowsAsync(connection); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var day = DateTime.UtcNow.Date.AddDays(-2); + var inDay = day.AddHours(12); + var priorDay = day.AddDays(-1).AddHours(12); // outside the window — must be excluded + + /* Waits: CXPACKET (100s) beats PAGEIOLATCH_SH (50s); a prior-day row must not leak in. */ + await InsertWaitAsync(connection, SummaryServerId, inDay, "CXPACKET", 100_000); + await InsertWaitAsync(connection, SummaryServerId, inDay, "PAGEIOLATCH_SH", 50_000); + await InsertWaitAsync(connection, SummaryServerId, priorDay, "CXPACKET", 999_000); + + /* Queries: two distinct hashes + one dup → COUNT(DISTINCT) = 2. */ + await InsertQueryAsync(connection, SummaryServerId, inDay, "0xAAA"); + await InsertQueryAsync(connection, SummaryServerId, inDay, "0xBBB"); + await InsertQueryAsync(connection, SummaryServerId, inDay, "0xAAA"); + + /* CPU: 70+20 = 90 ≥ 80 counts; 40+10 = 50 does not. */ + await InsertCpuAsync(connection, SummaryServerId, inDay, sqlCpu: 70, otherCpu: 20); + await InsertCpuAsync(connection, SummaryServerId, inDay, sqlCpu: 40, otherCpu: 10); + + await InsertDeadlockAsync(connection, SummaryServerId, inDay); + await InsertBlockedProcessAsync(connection, SummaryServerId, inDay); + await InsertCollectionLogAsync(connection, SummaryServerId, "wait_stats", inDay, "ERROR"); + + var summary = await viewer.GetDailySummaryAsync(SummaryServerId, day); + + Assert.NotNull(summary); + Assert.Equal(150m, summary!.TotalWaitTimeSec); // (100000 + 50000) / 1000 + Assert.Equal("CXPACKET", summary.TopWaitType); + Assert.Equal(2, summary.UniqueQueries); + Assert.Equal(1, summary.DeadlockCount); + Assert.Equal(1, summary.BlockingEvents); // XE report count (fallback not used) + Assert.Equal(1, summary.HighCpuEvents); // only the 90% sample + Assert.Equal(1, summary.CollectionErrors); + Assert.Equal("Critical", summary.OverallHealth); // deadlocks -> Critical composite band + } + finally + { + await DeleteSummaryRowsAsync(connection); + } + } + + [Fact] + public async Task DailySummary_BlockingEvents_FallBackToTheDmvSnapshotCount_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live blocking-fallback test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteSummaryRowsAsync(connection); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var day = DateTime.UtcNow.Date.AddDays(-3); + var inDay = day.AddHours(9); + + /* No XE blocked-process reports; two DMV snapshots → the COALESCE(NULLIF(...)) falls back to + the DMV count. No deadlocks / sustained CPU / heavy blocking, but 2 blocking events is + "some blocking" → the composite band is Warning. */ + await InsertDmvBlockingAsync(connection, SummaryServerId, inDay); + await InsertDmvBlockingAsync(connection, SummaryServerId, inDay); + + var summary = await viewer.GetDailySummaryAsync(SummaryServerId, day); + + Assert.NotNull(summary); + Assert.Equal(2, summary!.BlockingEvents); + Assert.Equal(0, summary.DeadlockCount); + Assert.Equal("Warning", summary.OverallHealth); + } + finally + { + await DeleteSummaryRowsAsync(connection); + } + } + + [Fact] + public async Task CollectionHealth_SevenDayAggregate_BandsEachCollector_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live collection-health test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteHealthLogRowsAsync(connection); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var recent = DateTime.UtcNow.AddMinutes(-5); + var old = DateTime.UtcNow.AddHours(-30); + + /* healthy_collector: recent SUCCESS only → HEALTHY. */ + await InsertCollectionLogAsync(connection, HealthServerId, "healthy_collector", recent, "SUCCESS"); + /* skipped_collector: recent SKIPPED counts as a healthy run → HEALTHY (not STALE). */ + await InsertCollectionLogAsync(connection, HealthServerId, "skipped_collector", recent, "SKIPPED"); + /* failing_collector: only an old SUCCESS (>24h) → FAILING. */ + await InsertCollectionLogAsync(connection, HealthServerId, "failing_collector", old, "SUCCESS"); + + var rows = await viewer.GetCollectionHealthAsync(HealthServerId); + + var healthy = rows.Single(r => r.CollectorName == "healthy_collector"); + var skipped = rows.Single(r => r.CollectorName == "skipped_collector"); + var failing = rows.Single(r => r.CollectorName == "failing_collector"); + + Assert.Equal("HEALTHY", healthy.HealthStatus); + Assert.Equal("HEALTHY", skipped.HealthStatus); // SKIPPED is a healthy run + Assert.Equal("FAILING", failing.HealthStatus); + Assert.Equal(1, healthy.TotalRuns); + Assert.Equal(1, healthy.SuccessCount); + } + finally + { + await DeleteHealthLogRowsAsync(connection); + } + } + + [Fact] + public async Task CollectionLogByCollector_ReturnsOneCollectorNewestFirst_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live per-collector log test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteHealthLogRowsAsync(connection); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var t1 = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-20)); + var t2 = t1.AddMinutes(5); + + /* Two runs of wait_stats + one of a different collector that must not leak in. */ + await InsertCollectionLogAsync(connection, HealthServerId, "wait_stats", t1, "SUCCESS", durationMs: 120, sqlMs: 100, storeMs: 20, rows: 42); + await InsertCollectionLogAsync(connection, HealthServerId, "wait_stats", t2, "ERROR", durationMs: 5, sqlMs: 5, storeMs: 0, rows: 0); + await InsertCollectionLogAsync(connection, HealthServerId, "cpu_utilization", t2, "SUCCESS"); + + var logs = await viewer.GetCollectionLogByCollectorAsync(HealthServerId, "wait_stats"); + + Assert.Equal(2, logs.Count); + Assert.All(logs, l => Assert.Equal("wait_stats", l.CollectorName)); + Assert.Equal(t2.Ticks, logs[0].CollectionTime.Ticks); // newest first + Assert.Equal(t1.Ticks, logs[1].CollectionTime.Ticks); + Assert.Equal(120, logs[1].DurationMs); + Assert.Equal(20, logs[1].DuckDbDurationMs); // the store-phase column + } + finally + { + await DeleteHealthLogRowsAsync(connection); + } + } + + [Fact] + public async Task RecentCollectionLog_HonorsTheExactWindow_ExcludingRowsOutsideBothBounds_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live windowed-log test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteHealthLogRowsAsync(connection); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + /* A three-hour custom window [start, end] set entirely in the PAST (end is an hour ago). A row 30 + min BEFORE start and a row 30 min AFTER end must BOTH be excluded. The after-end exclusion is + the behavior the old hours-back read (a single now-relative lower bound) could not express — it + would have swept everything up to "now", pulling in the after-end row. */ + var end = TruncateToSeconds(DateTime.UtcNow.AddHours(-1)); + var start = end.AddHours(-3); + var beforeStart = start.AddMinutes(-30); + var inWindowEarly = start.AddMinutes(15); + var inWindowLate = end.AddMinutes(-15); + var afterEnd = end.AddMinutes(30); + + await InsertCollectionLogAsync(connection, HealthServerId, "wait_stats", beforeStart, "SUCCESS"); + await InsertCollectionLogAsync(connection, HealthServerId, "wait_stats", inWindowEarly, "SUCCESS"); + await InsertCollectionLogAsync(connection, HealthServerId, "cpu_utilization", inWindowLate, "SUCCESS"); + await InsertCollectionLogAsync(connection, HealthServerId, "wait_stats", afterEnd, "SUCCESS"); + + var logs = await viewer.GetRecentCollectionLogAsync(HealthServerId, start, end); + + Assert.Equal(2, logs.Count); + Assert.All(logs, l => Assert.InRange(l.CollectionTime.Ticks, start.Ticks, end.Ticks)); + Assert.Equal(inWindowLate.Ticks, logs[0].CollectionTime.Ticks); // newest first + Assert.Equal(inWindowEarly.Ticks, logs[1].CollectionTime.Ticks); + } + finally + { + await DeleteHealthLogRowsAsync(connection); + } + } + + private static async Task InsertWaitAsync(NpgsqlConnection connection, int serverId, DateTime collectionTimeUtc, string waitType, long deltaWaitMs) + { + using var command = new NpgsqlCommand( + "INSERT INTO wait_stats (collection_id, collection_time, server_id, server_name, wait_type, delta_waiting_tasks, delta_wait_time_ms) VALUES ($1, $2, $3, $4, $5, $6, $7)", + connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(waitType); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(deltaWaitMs); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertQueryAsync(NpgsqlConnection connection, int serverId, DateTime collectionTimeUtc, string queryHash) + { + using var command = new NpgsqlCommand( + "INSERT INTO query_stats (collection_id, collection_time, server_id, server_name, query_hash) VALUES ($1, $2, $3, $4, $5)", + connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(queryHash); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertCpuAsync(NpgsqlConnection connection, int serverId, DateTime collectionTimeUtc, int sqlCpu, int otherCpu) + { + using var command = new NpgsqlCommand( + "INSERT INTO cpu_utilization_stats (collection_id, collection_time, server_id, server_name, sample_time, sqlserver_cpu_utilization, other_process_cpu_utilization) VALUES ($1, $2, $3, $4, $5, $6, $7)", + connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(sqlCpu); + command.Parameters.AddWithValue(otherCpu); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertDeadlockAsync(NpgsqlConnection connection, int serverId, DateTime collectionTimeUtc) + { + using var command = new NpgsqlCommand( + "INSERT INTO deadlocks (deadlock_id, collection_time, server_id, server_name) VALUES ($1, $2, $3, $4)", + connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertBlockedProcessAsync(NpgsqlConnection connection, int serverId, DateTime collectionTimeUtc) + { + using var command = new NpgsqlCommand( + "INSERT INTO blocked_process_reports (blocked_report_id, collection_time, server_id, server_name) VALUES ($1, $2, $3, $4)", + connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertDmvBlockingAsync(NpgsqlConnection connection, int serverId, DateTime collectionTimeUtc) + { + using var command = new NpgsqlCommand( + "INSERT INTO dmv_blocking_snapshots (collection_id, collection_time, server_id, server_name) VALUES ($1, $2, $3, $4)", + connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertCollectionLogAsync( + NpgsqlConnection connection, int serverId, string collectorName, DateTime collectionTimeUtc, string status, + int? durationMs = null, int? sqlMs = null, int? storeMs = null, int? rows = null) + { + using var command = new NpgsqlCommand(@" +INSERT INTO collection_log + (log_id, server_id, server_name, collector_name, collection_time, status, duration_ms, sql_duration_ms, duckdb_duration_ms, rows_collected) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", connection); + command.Parameters.AddWithValue(CollectionIdGenerator.Next()); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(collectorName); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(status); + command.Parameters.AddWithValue((object?)durationMs ?? DBNull.Value); + command.Parameters.AddWithValue((object?)sqlMs ?? DBNull.Value); + command.Parameters.AddWithValue((object?)storeMs ?? DBNull.Value); + command.Parameters.AddWithValue((object?)rows ?? DBNull.Value); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static DateTime TruncateToSeconds(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified); + + private static async Task DeleteSummaryRowsAsync(NpgsqlConnection connection) + { + foreach (var table in new[] + { + "wait_stats", "query_stats", "cpu_utilization_stats", "deadlocks", + "blocked_process_reports", "dmv_blocking_snapshots", "collection_log", + }) + { + using var cleanup = new NpgsqlCommand($"DELETE FROM {table} WHERE server_id = {SummaryServerId};", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + } + + private static async Task DeleteHealthLogRowsAsync(NpgsqlConnection connection) + { + using var cleanup = new NpgsqlCommand($"DELETE FROM collection_log WHERE server_id = {HealthServerId};", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } +} diff --git a/Darling/Darling.Tests/ViewerDataServiceTests.cs b/Darling/Darling.Tests/ViewerDataServiceTests.cs new file mode 100644 index 000000000..51465d580 --- /dev/null +++ b/Darling/Darling.Tests/ViewerDataServiceTests.cs @@ -0,0 +1,670 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Globalization; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using System.Windows.Media; +using Npgsql; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the viewer's SQL against the Darling store contract (no live Postgres needed) and +/// unit-tests the pure display helpers: the version label. (The naive-UTC → display conversion moved to +/// the mode-aware ViewerTimeHelper, pinned in ViewerTimeHelperTests; the Overview trend +/// reads + wait-category roll-up are pinned in ViewerTrendsTests.) +/// +public sealed class ViewerDataServiceTests +{ + [Fact] + public void ServersSql_ReadsTheServerListOrderedByDisplayName() + { + Assert.Contains("FROM servers", ViewerDataService.ServersSql, StringComparison.Ordinal); + Assert.Contains("ORDER BY display_name", ViewerDataService.ServersSql, StringComparison.Ordinal); + Assert.Contains("server_id, server_name, display_name, is_enabled, sql_major_version", ViewerDataService.ServersSql, StringComparison.Ordinal); + } + + /* The Collection Health SQL is no longer the shell's DISTINCT-ON latest-run placeholder; W1i + replaced it with Lite's 7-day aggregate, pinned in ViewerDailyHealthTests along with the Daily + Summary reads. */ + + [Theory] + [InlineData(13, "SQL Server 2016")] + [InlineData(14, "SQL Server 2017")] + [InlineData(15, "SQL Server 2019")] + [InlineData(16, "SQL Server 2022")] + [InlineData(17, "SQL Server 2025")] + [InlineData(99, "SQL Server v99")] + [InlineData(null, "")] + public void SqlVersionLabel_MapsKnownMajors_AndFallsBack(int? major, string expected) + { + Assert.Equal(expected, ViewerDataService.SqlVersionLabel(major)); + } + + /* The naive-UTC → display conversion moved from ViewerDataService.ToLocalTime to the mode-aware + ViewerTimeHelper (Server/Local/UTC); it is pinned in ViewerTimeHelperTests. */ + + [Fact] + public void DarlingServer_VersionLabel_ComesFromTheMajorVersion() + { + var server = new DarlingServer(1, "SQL2022", "SQL2022", true, 16); + Assert.Equal("SQL Server 2022", server.VersionLabel); + } + + /* The old placeholder CollectorHealthRow record (a single latest-run snapshot with + CollectionTimeLocal) was replaced by Lite's rich 7-day aggregate class in W1i; its HealthStatus + banding + formatting are pinned in ViewerDailyHealthTests. */ +} + +/// +/// The viewer's sliver of darling.json: the service's resolution order and lenient JSON +/// (comments, trailing commas, case-insensitive names), but only the postgres section — +/// including the managed bundled-Postgres mode, whose derivation (credential path convention + +/// DPAPI entropy) is pinned against the SERVICE's DarlingSecrets/DarlingManagedPostgres here, +/// because the viewer deliberately duplicates those constants instead of referencing the +/// service project. +/// +public sealed class ViewerSettingsTests +{ + [Fact] + public void Parse_ManagedMode_DefaultsToAdminRole_DerivesFromTheAdminCredential() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "DPAPI requires Windows."); + + var root = Directory.CreateTempSubdirectory("darling-viewer-managed-"); + try + { + /* The SERVICE provisions the least-privilege roles and writes their credentials; the + VIEWER (default connectAs = admin) must derive the connection string for the admin + role from the admin credential (V8 security split — no longer the darling superuser). */ + var dataDirectory = Path.Combine(root.FullName, "pg"); + var adminCredential = PerformanceMonitor.Darling.Service.DarlingManagedPostgres.AdminCredentialPathFor(dataDirectory); + File.WriteAllText(adminCredential, PerformanceMonitor.Darling.Service.DarlingSecrets.Protect("admin-pw")); + + var json = $$""" + { + "postgres": { + "managed": true, + "port": 5991, + "dataDirectory": {{JsonSerializer.Serialize(dataDirectory)}} + } + } + """; + + var settings = ViewerSettings.Parse(json); + var parsed = new NpgsqlConnectionStringBuilder(settings.ConnectionString); + /* 127.0.0.1, not "localhost" — the viewer half of the managed-Host parity pair with the + service's DarlingManagedPostgres.BuildConnectionString (darling-network-endpoints). */ + Assert.Equal("127.0.0.1", parsed.Host); + Assert.Equal(5991, parsed.Port); + Assert.Equal("admin", parsed.Username); + Assert.Equal("admin-pw", parsed.Password); + Assert.Equal("darling", parsed.Database); + /* The bare table names resolve to the V8 collect/config schemas on every connection. */ + Assert.Equal("collect,config,public", parsed.SearchPath); + } + finally + { + root.Delete(recursive: true); + } + } + + [Fact] + public void Parse_ManagedMode_ConnectAsViewer_DerivesFromTheViewerCredential() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "DPAPI requires Windows."); + + var root = Directory.CreateTempSubdirectory("darling-viewer-ro-"); + try + { + /* A locked-down deployment (connectAs = "viewer") reads the read-only viewer role's + credential and connects as that role — the write surfaces then degrade gracefully. */ + var dataDirectory = Path.Combine(root.FullName, "pg"); + var viewerCredential = PerformanceMonitor.Darling.Service.DarlingManagedPostgres.ViewerCredentialPathFor(dataDirectory); + File.WriteAllText(viewerCredential, PerformanceMonitor.Darling.Service.DarlingSecrets.Protect("viewer-pw")); + + var json = $$""" + { + "postgres": { + "managed": true, + "port": 5991, + "connectAs": "viewer", + "dataDirectory": {{JsonSerializer.Serialize(dataDirectory)}} + } + } + """; + + var settings = ViewerSettings.Parse(json); + var parsed = new NpgsqlConnectionStringBuilder(settings.ConnectionString); + Assert.Equal("viewer", parsed.Username); + Assert.Equal("viewer-pw", parsed.Password); + Assert.Equal("collect,config,public", parsed.SearchPath); + } + finally + { + root.Delete(recursive: true); + } + } + + [Fact] + public void Parse_ManagedMode_UnknownConnectAs_FallsBackToAdmin() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "DPAPI requires Windows."); + + var root = Directory.CreateTempSubdirectory("darling-viewer-unknown-"); + try + { + /* A typo'd connectAs degrades to the admin role (the DarlingConfig default), not a crash. */ + var dataDirectory = Path.Combine(root.FullName, "pg"); + var adminCredential = PerformanceMonitor.Darling.Service.DarlingManagedPostgres.AdminCredentialPathFor(dataDirectory); + File.WriteAllText(adminCredential, PerformanceMonitor.Darling.Service.DarlingSecrets.Protect("admin-pw")); + + var json = $$""" + { + "postgres": { + "managed": true, + "connectAs": "superadmin", + "dataDirectory": {{JsonSerializer.Serialize(dataDirectory)}} + } + } + """; + + var parsed = new NpgsqlConnectionStringBuilder(ViewerSettings.Parse(json).ConnectionString); + Assert.Equal("admin", parsed.Username); + } + finally + { + root.Delete(recursive: true); + } + } + + [Fact] + public void Parse_ManagedMode_MissingCredential_ThrowsAReadableFirstRunHint() + { + var missing = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName(), "pg"); + var json = $$"""{ "postgres": { "managed": true, "dataDirectory": {{JsonSerializer.Serialize(missing)}} } }"""; + + var ex = Assert.Throws(() => ViewerSettings.Parse(json)); + Assert.Contains("service", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Parse_ReadsTheConnectionString_WithCommentsAndTrailingCommas() + { + var json = """ + { + // the same file the service reads + "Postgres": { + "ConnectionString": "Host=localhost;Database=darling", + }, + "servers": [ { "name": "SQL2022", "host": "SQL2022" } ], + } + """; + + var settings = ViewerSettings.Parse(json); + + Assert.Equal("Host=localhost;Database=darling", settings.ConnectionString); + } + + [Fact] + public void Parse_MissingConnectionString_Throws() + { + Assert.Throws(() => ViewerSettings.Parse("{}")); + Assert.Throws(() => ViewerSettings.Parse("""{ "postgres": { "connectionString": "" } }""")); + } + + [Fact] + public void ResolveConfigPath_ExplicitPathWins() + { + Assert.Equal(@"C:\somewhere\darling.json", ViewerSettings.ResolveConfigPath(@"C:\somewhere\darling.json")); + } + + /// + /// The release-zip layout: viewer\ under the service root, darling.json beside the SERVICE + /// exe. Beside-binary wins when present; the parent probe covers the shipped layout; when + /// neither exists the beside-binary path comes back so the not-found hint names the + /// viewer's own directory. + /// + [Fact] + public void ResolveConfigPath_PackagedLayout_FallsBackToParentDirectory() + { + /* DARLING_CONFIG is resolution step 2 — ahead of BOTH probed locations — so a machine with it set + (any developer pointing a viewer at a remote store) fails this test on the environment rather + than on the code. Clear it for the duration and restore. The sibling explicit-path test needs no + such guard: an explicit path is step 1 and outranks the variable. */ + var savedConfigEnvironment = Environment.GetEnvironmentVariable("DARLING_CONFIG"); + Environment.SetEnvironmentVariable("DARLING_CONFIG", null); + var root = Directory.CreateTempSubdirectory("darling-viewer-resolve-"); + try + { + var viewerDirectory = Path.Combine(root.FullName, "viewer"); + Directory.CreateDirectory(viewerDirectory); + var missing = Path.Combine(viewerDirectory, "darling.json"); + + /* Neither location has a config: report the viewer's own (missing) path. */ + Assert.Equal(missing, ViewerSettings.ResolveConfigPath(baseDirectory: viewerDirectory)); + + /* Only the service root has one — the shipped-zip case. */ + var atServiceRoot = Path.Combine(root.FullName, "darling.json"); + File.WriteAllText(atServiceRoot, "{}"); + Assert.Equal(atServiceRoot, ViewerSettings.ResolveConfigPath(baseDirectory: viewerDirectory)); + + /* Beside the viewer binary still wins over the parent when both exist. */ + File.WriteAllText(missing, "{}"); + Assert.Equal(missing, ViewerSettings.ResolveConfigPath(baseDirectory: viewerDirectory)); + } + finally + { + Environment.SetEnvironmentVariable("DARLING_CONFIG", savedConfigEnvironment); + root.Delete(recursive: true); + } + } + + [Fact] + public void TryLoad_MissingFile_ReturnsNull() + { + Assert.Null(ViewerSettings.TryLoad(Path.Combine(Path.GetTempPath(), Path.GetRandomFileName(), "darling.json"))); + } +} + +/// Status colors: SUCCESS green, PERMISSIONS orange, ERROR red, anything else default. +public sealed class StatusToBrushConverterTests +{ + private static Color ColorFor(string? status) + { + var converter = new StatusToBrushConverter(); + var brush = Assert.IsType(converter.Convert(status, typeof(Brush), null, CultureInfo.InvariantCulture)); + return brush.Color; + } + + [Fact] + public void Convert_MapsTheThreeStatuses_ToDistinctColors_CaseInsensitively() + { + var success = ColorFor("SUCCESS"); + var permissions = ColorFor("PERMISSIONS"); + var error = ColorFor("ERROR"); + var other = ColorFor("SKIPPED"); + + Assert.NotEqual(success, permissions); + Assert.NotEqual(success, error); + Assert.NotEqual(permissions, error); + Assert.NotEqual(success, other); + + Assert.Equal(success, ColorFor("success")); + Assert.Equal(error, ColorFor("Error")); + Assert.Equal(other, ColorFor(null)); + } +} + +/// +/// The viewer's read-only degradation (V8 security hardening): the authoritative capability probe +/// (has_table_privilege on a config table, resolved through search_path) and the friendly +/// read-only exception the write paths translate a Postgres 42501 into. The live admin-writes / +/// viewer-denied round-trip is in the gated DarlingSecuritySplitLiveTests. +/// +public sealed class ViewerReadOnlyTests +{ + [Fact] + public void ReadOnlyProbeSql_TestsConfigInsertPrivilege() + { + /* has_table_privilege on config_mute_rules INSERT: true => writable (admin/owner), false => + read-only viewer. The bare name resolves to config.config_mute_rules via search_path. */ + Assert.Equal("SELECT has_table_privilege('config_mute_rules', 'INSERT')", ViewerDataService.ReadOnlyProbeSql); + } + + [Fact] + public async System.Threading.Tasks.Task IsReadOnly_DefaultsFalse_BeforeProbing() + { + /* Defaults writable until DetectReadOnlyAsync runs (which fails safe to read-only). The + constructor only creates the pooled data source; no connection is opened here. */ + await using var service = new ViewerDataService("Host=localhost;Port=1;Database=darling;Timeout=1"); + Assert.False(service.IsReadOnly); + } + + [Fact] + public void ViewerReadOnlyException_ExplainsHowToEnableWrites() + { + var ex = new ViewerReadOnlyException(new InvalidOperationException("42501")); + + Assert.Contains("read-only", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("connectAs", ex.Message, StringComparison.Ordinal); + Assert.Equal("42501", ex.InnerException?.Message); + } +} + +/// +/// The connect-time schema-skew gate (finding B1): the viewer probes the store's effective version via +/// information_schema (the owner-only darling_schema_version table can't be read) and blocks a +/// store behind the version this build needs, instead of letting a control-plane write throw a raw 42703. +/// +public sealed class ViewerSchemaVersionGateTests +{ + [Fact] + public void StoreSchemaProbeSql_ProbesInformationSchema_ForTheV17ToV25Sentinels() + { + var sql = ViewerDataService.StoreSchemaProbeSql; + + /* Reads information_schema (visible to every role) — not the owner-only darling_schema_version. */ + Assert.Contains("information_schema.tables", sql, StringComparison.Ordinal); + Assert.Contains("information_schema.columns", sql, StringComparison.Ordinal); + Assert.DoesNotContain("darling_schema_version", sql, StringComparison.Ordinal); + + /* The version sentinels: V17 config control plane, V18 delivery-override column, V19 marker, + V20 tuning knobs, V21 default_trace_events, V22 idx_index_object_stats_latest. */ + Assert.Contains("config_monitored_servers", sql, StringComparison.Ordinal); + Assert.Contains("alert_delivery_mode_override", sql, StringComparison.Ordinal); + Assert.Contains("analysis_state", sql, StringComparison.Ordinal); + Assert.Contains("config_alert_settings", sql, StringComparison.Ordinal); + Assert.Contains("notify_connection_changes", sql, StringComparison.Ordinal); + Assert.Contains("default_trace_events", sql, StringComparison.Ordinal); + /* V22's sentinel is an INDEX — Postgres has no index listing in information_schema, so the probe reads + the world-readable pg_indexes catalog for it. */ + Assert.Contains("pg_indexes", sql, StringComparison.Ordinal); + Assert.Contains("idx_index_object_stats_latest", sql, StringComparison.Ordinal); + + /* V23's sentinel is collection_log-IS-a-hypertable OR no-timescaledb. It MUST stay plain-PostgreSQL-safe: + it reads the core pg_inherits catalog for collection_log's chunk children (TimescaleDB 2.x links chunks + to the hypertable root via inheritance; NOT timescaledb_information.hypertables, which does not exist + without the extension and would make the whole probe throw on plain PG — and NOT the ts_insert_blocker + trigger, which TimescaleDB 2.x no longer creates), OR'd with a pg_extension check so a plain-PG store at + V23 (an object-invisible no-op there) isn't gated. */ + Assert.Contains("pg_inherits", sql, StringComparison.Ordinal); + Assert.Contains("inhparent", sql, StringComparison.Ordinal); + Assert.Contains("collection_log", sql, StringComparison.Ordinal); + Assert.Contains("pg_extension", sql, StringComparison.Ordinal); + Assert.Contains("timescaledb", sql, StringComparison.Ordinal); + /* Never the extension-only view — that would throw on plain PG and take the whole probe (and gate) down. */ + Assert.DoesNotContain("timescaledb_information", sql, StringComparison.Ordinal); + + /* V24/V25 (#1433): job_history + agent_status table-existence sentinels (engine-agnostic). */ + Assert.Contains("job_history", sql, StringComparison.Ordinal); + Assert.Contains("agent_status", sql, StringComparison.Ordinal); + + /* V26 (#1506): the generic webhook's generic_url column on config_notification. */ + Assert.Contains("generic_url", sql, StringComparison.Ordinal); + + /* V27 (#1535): the Azure per-database watermark key on deadlocks. */ + Assert.Contains("database_name", sql, StringComparison.Ordinal); + Assert.Contains("deadlocks", sql, StringComparison.Ordinal); + + /* V30 (#1562) web dashboard toggle + V31 (#1563) custom_views — the two newest sentinels. */ + Assert.Contains("web_enabled", sql, StringComparison.Ordinal); + Assert.Contains("custom_views", sql, StringComparison.Ordinal); + } + + [Theory] + [InlineData(true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, 31)] // fully migrated V31 (config.custom_views, #1563) + [InlineData(true, true, true, true, true, true, true, true, true, true, true, true, true, true, false, 30)] // V30: config_service web_enabled present, custom_views not yet (#1562) + [InlineData(true, true, true, true, true, true, true, true, true, true, true, true, true, false, false, 29)] // V29: long_query_completions present, web config not yet (#1496) + [InlineData(true, true, true, true, true, true, true, true, true, true, true, true, false, false, false, 28)] // V28: query_store_stats replica_role present, long_query_completions not yet (#1546) + [InlineData(true, true, true, true, true, true, true, true, true, true, true, false, false, false, false, 27)] // V27: deadlocks database_name present, replica_role not yet (#1535) + [InlineData(true, true, true, true, true, true, true, true, true, true, false, false, false, false, false, 26)] // V26: generic webhook present, deadlocks column not yet + [InlineData(true, true, true, true, true, true, true, true, true, false, false, false, false, false, false, 25)] // V25: agent_status present, generic webhook not yet + [InlineData(true, true, true, true, true, true, true, true, false, false, false, false, false, false, false, 24)] // V24: job_history present, agent_status not yet + [InlineData(true, true, true, true, true, true, true, false, false, false, false, false, false, false, false, 23)] // V23 (collection_log hypertable, or plain-PG at V23) + [InlineData(true, true, true, true, true, true, false, false, false, false, false, false, false, false, false, 22)] // Timescale store still at V22 (index present, collection_log not yet a hypertable) + [InlineData(true, true, true, true, true, false, false, false, false, false, false, false, false, false, false, 21)] // pre-V22: has default_trace_events, no V22 index + [InlineData(true, true, true, true, false, false, false, false, false, false, false, false, false, false, false, 20)] // pre-V21: no default_trace_events + [InlineData(true, true, true, false, false, false, false, false, false, false, false, false, false, false, false, 19)]// pre-V20: no alert-tuning knobs + [InlineData(true, true, false, false, false, false, false, false, false, false, false, false, false, false, false, 18)]// pre-V19: no analysis_state + [InlineData(true, false, false, false, false, false, false, false, false, false, false, false, false, false, false, 17)]// pre-V18: no delivery-override column + [InlineData(false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, 16)]// pre-V17: no config control plane at all + public void MapProbedSchemaVersion_TakesTheHighestSatisfiedSentinel( + bool hasConfigControlPlane, bool hasAlertDeliveryOverride, bool hasAnalysisState, bool hasAlertTuningKnobs, bool hasDefaultTraceEvents, bool hasIndexObjectStatsLatestIndex, bool hasCollectionLogHypertableOrPlainPg, bool hasJobHistory, bool hasAgentStatus, bool hasGenericWebhook, bool hasDeadlocksDatabaseName, bool hasQueryStoreReplicaRole, bool hasLongQueryCompletions, bool hasWebDashboardConfig, bool hasCustomViews, int expected) + { + Assert.Equal(expected, ViewerDataService.MapProbedSchemaVersion( + hasConfigControlPlane, hasAlertDeliveryOverride, hasAnalysisState, hasAlertTuningKnobs, hasDefaultTraceEvents, hasIndexObjectStatsLatestIndex, hasCollectionLogHypertableOrPlainPg, hasJobHistory, hasAgentStatus, hasGenericWebhook, hasDeadlocksDatabaseName, hasQueryStoreReplicaRole, hasLongQueryCompletions, hasWebDashboardConfig, hasCustomViews)); + } + + [Fact] + public void MapProbedSchemaVersion_V23CompositeIsGatedBehindV22_NotAStandaloneTopArm() + { + /* A plain-PG store BELOW V22 has the no-extension arm TRUE (so hasCollectionLogHypertableOrPlainPg=true) + but must NOT be reported as 23 — the composite only counts once the engine-agnostic V22 index is + present. Here: pre-V22 (no index) with the composite true still maps to 21 (its real V21 sentinel), + proving the composite is gated behind V22 rather than treated as a newest-first arm. */ + Assert.Equal(21, ViewerDataService.MapProbedSchemaVersion(true, true, true, true, true, false, true, false, false, false, false, false, false, false, false)); + } + + [Fact] + public void RequiredStoreSchemaVersion_TracksTheBuildSchemaVersion_AndTheProbeCoversIt() + { + /* The gate compares against the build's known schema version. */ + Assert.Equal(StorageVersion.SchemaVersion, ViewerDataService.RequiredStoreSchemaVersion); + + /* Pin: a fully-migrated store (all sentinels present) must map to exactly the required version. If a + future migration bumps StorageVersion past 30, this fails until a matching sentinel + map arm is + added — the guard against the probe silently under-reporting a newer store as skewed. */ + Assert.Equal( + ViewerDataService.RequiredStoreSchemaVersion, + ViewerDataService.MapProbedSchemaVersion(true, true, true, true, true, true, true, true, true, true, true, true, true, true, true)); + } +} + +/// +/// The write-executor schema-skew translation (finding B2): a control-plane write against a lagging store +/// throws Postgres 42703 (undefined_column) / 42P01 (undefined_table); the executors translate both into a +/// the callers surface like the read-only exception, instead of a raw +/// "column ... does not exist" error. +/// +public sealed class ViewerSchemaSkewTests +{ + [Fact] + public void SchemaSkewSqlStates_AreTheUndefinedColumnAndTableStates() + { + Assert.Equal("42703", ViewerDataService.UndefinedColumnSqlState); + Assert.Equal("42P01", ViewerDataService.UndefinedTableSqlState); + } + + [Fact] + public void ViewerSchemaSkewException_NamesTheRequiredVersion_AndTheServiceRemedy() + { + var ex = new ViewerSchemaSkewException(new InvalidOperationException("42703")); + + Assert.Contains($"v{ViewerDataService.RequiredStoreSchemaVersion}", ex.Message, StringComparison.Ordinal); + Assert.Contains("Darling service", ex.Message, StringComparison.Ordinal); + Assert.Contains("migrate", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal("42703", ex.InnerException?.Message); + } +} + +/// +/// Distinguishing an unreachable store from a read-only seat (finding B3): a connection-level failure +/// (service down, wrong host/port) is a with its own message, +/// NOT a false read-only verdict; a server error RESPONSE (a PostgresException) means the store is reachable. +/// +public sealed class ViewerStoreUnreachableTests +{ + [Fact] + public void ViewerStoreUnreachableException_AsksWhetherTheServiceIsRunning_AndNamesDarlingJson() + { + var ex = new ViewerStoreUnreachableException(new InvalidOperationException("connection refused")); + + Assert.Contains("Darling service", ex.Message, StringComparison.Ordinal); + Assert.Contains("darling.json", ex.Message, StringComparison.Ordinal); + Assert.Equal("connection refused", ex.InnerException?.Message); + } + + [Fact] + public void IsConnectionFailure_TrueForConnectLevelFailures_FalseForServerResponses() + { + /* Connection-level failures — the server was never reached. */ + Assert.True(ViewerDataService.IsConnectionFailure(new NpgsqlException("could not connect"))); + Assert.True(ViewerDataService.IsConnectionFailure(new System.Net.Sockets.SocketException())); + Assert.True(ViewerDataService.IsConnectionFailure(new TimeoutException())); + + /* A PostgresException is a server RESPONSE (carries a SQLSTATE) — the store IS reachable. */ + var serverResponse = new PostgresException("permission denied", "ERROR", "ERROR", "42501"); + Assert.False(ViewerDataService.IsConnectionFailure(serverResponse)); + + /* An unrelated exception is not a connection failure either. */ + Assert.False(ViewerDataService.IsConnectionFailure(new InvalidOperationException())); + } +} + +/// +/// The read-only viewer's per-section Settings degradation (darling-network-endpoints D7): a read-only +/// viewer role is column-denied the secret columns of config_notification and +/// config_monitored_servers, so those reads fall back to secret-free projections, and the Settings load +/// reads each section independently so one 42501 never blanks the rest. +/// +public sealed class ViewerReadOnlyDegradationTests +{ + [Fact] + public void NotificationSelectNoSecretSql_OmitsTheCarvedSecrets_KeepsTheNonSecretColumns() + { + var noSecret = ViewerDataService.NotificationSelectNoSecretSql; + + /* The four carved secret columns the viewer role loses SELECT on (#1262). */ + Assert.DoesNotContain("smtp_encrypted_password", noSecret, StringComparison.Ordinal); + Assert.DoesNotContain("smtp_username", noSecret, StringComparison.Ordinal); + Assert.DoesNotContain("teams_url", noSecret, StringComparison.Ordinal); + Assert.DoesNotContain("slack_url", noSecret, StringComparison.Ordinal); + + /* The non-secret fields the read-only Settings window still prefills. */ + Assert.Contains("smtp_host", noSecret, StringComparison.Ordinal); + Assert.Contains("smtp_from_address", noSecret, StringComparison.Ordinal); + Assert.Contains("teams_proxy", noSecret, StringComparison.Ordinal); + Assert.Contains("slack_proxy", noSecret, StringComparison.Ordinal); + Assert.Contains("FROM config_notification WHERE id = 1", noSecret, StringComparison.Ordinal); + } + + [Fact] + public void NotificationSelectSql_FullProjection_StillCarriesTheSecrets_ForAWritableSeat() + { + /* The admin/owner seat still reads the secrets to prefill the SMTP password + webhook URLs. */ + var full = ViewerDataService.NotificationSelectSql; + Assert.Contains("smtp_encrypted_password", full, StringComparison.Ordinal); + Assert.Contains("smtp_username", full, StringComparison.Ordinal); + Assert.Contains("teams_url", full, StringComparison.Ordinal); + Assert.Contains("slack_url", full, StringComparison.Ordinal); + } + + [Fact] + public void MonitoredServerByIdNoSecretSql_OmitsEncryptedPassword_ButKeepsTheByIdFilter() + { + var noSecret = ViewerDataService.MonitoredServerByIdNoSecretSql; + Assert.DoesNotContain("encrypted_password", noSecret, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", noSecret, StringComparison.Ordinal); + + /* The full by-id read (an admin action) still selects the DPAPI blob for the password box. */ + Assert.Contains("encrypted_password", ViewerDataService.MonitoredServerByIdSql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", ViewerDataService.MonitoredServerByIdSql, StringComparison.Ordinal); + } + + [Fact] + public async Task ReadStoreSectionsAsync_NotificationThrows42501_DoesNotBlankTheOtherSections() + { + var alert = AlertSettingsRow.Defaults(); + var service = new ServiceConfigRow(); + + var sections = await SettingsWindow.ReadStoreSectionsAsync( + () => Task.FromResult(alert), + () => Task.FromException( + new PostgresException("permission denied for table config_notification", "ERROR", "ERROR", "42501")), + () => Task.FromResult(service)); + + /* The notification section degrades to null (its defaults stand); the OTHER two survive intact. */ + Assert.Same(alert, sections.Alert); + Assert.Null(sections.Notification); + Assert.Same(service, sections.Service); + } + + [Fact] + public async Task ReadStoreSectionsAsync_AllSucceed_ReturnsAllThreeSections() + { + var alert = AlertSettingsRow.Defaults(); + var notify = NotificationRow.Defaults(); + var service = new ServiceConfigRow(); + + var sections = await SettingsWindow.ReadStoreSectionsAsync( + () => Task.FromResult(alert), + () => Task.FromResult(notify), + () => Task.FromResult(service)); + + Assert.Same(alert, sections.Alert); + Assert.Same(notify, sections.Notification); + Assert.Same(service, sections.Service); + } + + [Fact] + public async Task ReadStoreSectionsAsync_Cancellation_Propagates() + { + /* A genuine cancellation is NOT swallowed as a degraded section — it propagates. */ + await Assert.ThrowsAsync(() => + SettingsWindow.ReadStoreSectionsAsync( + () => Task.FromResult(AlertSettingsRow.Defaults()), + () => Task.FromException(new OperationCanceledException()), + () => Task.FromResult(new ServiceConfigRow()))); + } +} + +/// +/// Pins the connection-timeout application (the formerly-dead ConnectionTimeoutSeconds knob): the viewer's +/// preference is applied to the store connection string as Npgsql's connect Timeout — set on the +/// managed-derived string (which carries none) and appended to a BYO string only when the operator did not +/// already specify one. Pure string logic (no live Postgres), the same discipline as the SQL-const pins. +/// +public sealed class ViewerConnectionTimeoutTests +{ + [Fact] + public void ApplyConnectionTimeout_ManagedStyleStringWithoutTimeout_SetsThePreference() + { + /* The managed-derived shape (DarlingManagedPostgres): no Timeout key, so the preference is applied. */ + const string managed = + "Host=127.0.0.1;Port=5641;Username=admin;Password=secret;Database=darling;Search Path=collect,config,public"; + + var result = ViewerDataService.ApplyConnectionTimeout(managed, 42); + + var builder = new NpgsqlConnectionStringBuilder(result); + Assert.Equal(42, builder.Timeout); + /* Every other setting is preserved. */ + Assert.Equal("127.0.0.1", builder.Host); + Assert.Equal(5641, builder.Port); + Assert.Equal("darling", builder.Database); + /* Search Path is the load-bearing one: it resolves the bare table names to the collect/config schemas + on every connection, so if the base DbConnectionStringBuilder round-trip ever mangled it, every + managed-path query would silently break. Pin that it survives verbatim (the reason we detect + emit + via the base builder rather than NpgsqlConnectionStringBuilder). */ + Assert.Equal("collect,config,public", builder.SearchPath); + } + + [Fact] + public void ApplyConnectionTimeout_ByoStringWithoutTimeout_AppendsThePreference() + { + var result = ViewerDataService.ApplyConnectionTimeout("Host=db.corp;Port=5432;Database=darling", 30); + + var builder = new NpgsqlConnectionStringBuilder(result); + Assert.Equal(30, builder.Timeout); + Assert.Equal("db.corp", builder.Host); + } + + [Fact] + public void ApplyConnectionTimeout_ByoStringWithExplicitTimeout_LeavesTheOperatorsValue() + { + /* The operator's explicit Timeout in a BYO connection string wins — the preference must not override it. */ + var result = ViewerDataService.ApplyConnectionTimeout("Host=db.corp;Database=darling;Timeout=25", 5); + + Assert.Equal(25, new NpgsqlConnectionStringBuilder(result).Timeout); + } + + [Fact] + public void ApplyConnectionTimeout_DetectsAnExistingTimeoutCaseInsensitively() + { + /* A lowercase keyword still counts as "already specified" (connection-string keys are case-insensitive). */ + var result = ViewerDataService.ApplyConnectionTimeout("Host=db.corp;Database=darling;timeout=25", 5); + + Assert.Equal(25, new NpgsqlConnectionStringBuilder(result).Timeout); + } +} diff --git a/Darling/Darling.Tests/ViewerDeadlockSeverityStatsTests.cs b/Darling/Darling.Tests/ViewerDeadlockSeverityStatsTests.cs new file mode 100644 index 000000000..ca025d970 --- /dev/null +++ b/Darling/Darling.Tests/ViewerDeadlockSeverityStatsTests.cs @@ -0,0 +1,357 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the Blocking Stats sub-tab's deadlock-SEVERITY aggregate — the on-the-fly Darling equivalent of the +/// Dashboard's collect.blocking_deadlock_stats victim_count / total_deadlock_wait_time_ms, computed by +/// parsing the stored deadlock_graph_xml (no collector, no migration). Two halves: (1) the read SQL, +/// which hands back raw (deadlock_time, xml) rows over the SAME v_deadlocks / collection_time +/// window as the deadlock COUNT trend so the two reconcile in period; (2) the pure parse→aggregate logic that +/// buckets by minute and sums the shared 's per-process IsVictim / WaitTimeMs. +/// No live Postgres (the live round-trip is the gated sibling class below). +/// +public sealed class ViewerDeadlockSeverityStatsSqlTests +{ + [Fact] + public void DeadlockSeverityGraphsSql_ReadsGraphAndTime_FromDeadlocksView() + { + /* The read pulls only what the C# aggregate needs: the bucket key (deadlock_time) and the payload + the shared parser reads (deadlock_graph_xml) — nothing is aggregated in SQL. */ + Assert.Contains("FROM v_deadlocks", ViewerDataService.DeadlockSeverityGraphsSql, StringComparison.Ordinal); + Assert.Contains("deadlock_time", ViewerDataService.DeadlockSeverityGraphsSql, StringComparison.Ordinal); + Assert.Contains("deadlock_graph_xml", ViewerDataService.DeadlockSeverityGraphsSql, StringComparison.Ordinal); + } + + [Fact] + public void DeadlockSeverityGraphsSql_WindowsOnCollectionTime_ReconcilesWithCountTrend() + { + /* Reconciliation: the deadlock COUNT trend windows on collection_time (NOT deadlock_time). To draw from + the identical row set — so the count on the summary strip and the victim/wait aggregate agree in + period — this read must window on collection_time too. */ + Assert.Contains("collection_time >= $2", ViewerDataService.DeadlockSeverityGraphsSql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $3", ViewerDataService.DeadlockSeverityGraphsSql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $2", ViewerDataService.DeadlockTrendSql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $3", ViewerDataService.DeadlockTrendSql, StringComparison.Ordinal); + /* The window is NOT applied on deadlock_time (that column is only the SELECT'd bucket key + ORDER BY). */ + Assert.DoesNotContain("deadlock_time >=", ViewerDataService.DeadlockSeverityGraphsSql, StringComparison.Ordinal); + Assert.DoesNotContain("deadlock_time <=", ViewerDataService.DeadlockSeverityGraphsSql, StringComparison.Ordinal); + } + + [Fact] + public void DeadlockSeverityGraphsSql_HasNoCap_SoItCannotDropRowsTheCountTrendKeeps() + { + /* No LIMIT: deadlocks are rare (low volume), and any cap would silently drop rows the un-capped count + trend keeps, breaking reconciliation. If a cap is ever needed it must be surfaced, not silent. */ + Assert.DoesNotContain("LIMIT", ViewerDataService.DeadlockSeverityGraphsSql, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void DeadlockSeverityGraphsSql_PgDialect_PositionalParams_NoBareNow_NoNLiterals() + { + var sql = ViewerDataService.DeadlockSeverityGraphsSql; + Assert.DoesNotContain("now(", sql.ToLowerInvariant()); + Assert.DoesNotContain("N'", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + Assert.Contains("$1", sql, StringComparison.Ordinal); + Assert.Contains("$2", sql, StringComparison.Ordinal); + Assert.Contains("$3", sql, StringComparison.Ordinal); + } + + [Fact] + public void DeadlockSeverityGraphXml_IsAnOriginalDeadlocksColumn_SoTheViewExposesIt() + { + /* Unlike victim_query_plan_xml (V7, why RecentDeadlocksSql reads the base table), deadlock_graph_xml is + an original deadlocks column, so the SELECT * view (v_deadlocks) projects it. The generated DDL pins + that the column exists; the gated live test proves the view actually returns it. */ + var ddl = PgSchemaGenerator.CreateTable(DeadlocksCollector.Instance); + Assert.Contains("deadlock_graph_xml", ddl, StringComparison.Ordinal); + Assert.Contains("deadlock_time", ddl, StringComparison.Ordinal); + } +} + +/// +/// Pins the pure parse→aggregate core (): buckets the +/// deadlock graphs by minute and sums the shared parser's per-process IsVictim / WaitTimeMs into victim_count +/// and total / max / avg deadlock wait — the Dashboard analyzer's exact semantics +/// (26_blocking_deadlock_analyzer.sql: victim_count = SUM(IsVictim); total = SUM(every process's wait)). +/// Driven by the REAL sql2025 deadlock fixtures (documented victim counts) plus synthetic graphs with known +/// wait times for exact total/max/avg. No database, no WPF. +/// +public sealed class DeadlockSeverityAggregationTests +{ + /// Builds a bare <deadlock> graph (what the store keeps) from known processes, so a test can + /// assert exact victim_count / total / max / avg without depending on a captured fixture's wait times. + private static string BuildGraph(params (string Id, int Spid, long WaitTime, bool IsVictim)[] processes) + { + var sb = new StringBuilder(); + sb.Append(""); + foreach (var p in processes.Where(p => p.IsVictim)) + sb.Append($""); + sb.Append(""); + foreach (var p in processes) + sb.Append($"x"); + sb.Append(""); + return sb.ToString(); + } + + private static readonly DateTime MinuteA = new(2026, 6, 21, 6, 51, 0, DateTimeKind.Unspecified); + private static readonly DateTime MinuteB = new(2026, 6, 21, 6, 53, 0, DateTimeKind.Unspecified); + + [Fact] + public void SingleGraph_SumsVictimsAndWaits_AvgIsProcessWeighted() + { + var graph = BuildGraph( + ("p1", 55, 1000, true), + ("p2", 66, 3000, false)); + + var points = ViewerDataService.AggregateDeadlockSeverity( + new (DateTime?, string?)[] { (MinuteA.AddSeconds(30), graph) }); + + var point = Assert.Single(points); + Assert.Equal(MinuteA, point.Time); // bucketed to the minute + Assert.Equal(1, point.VictimCount); // p1 only + Assert.Equal(4000, point.TotalWaitMs); // 1000 + 3000 + Assert.Equal(3000, point.MaxWaitMs); // max process wait + Assert.Equal(2000.0, point.AvgWaitMs, precision: 3); // 4000 / 2 processes + } + + [Fact] + public void MultipleGraphs_SameMinute_CollapseIntoOneBucket() + { + /* Two deadlocks in the same minute → one bucket summing victims + waits across BOTH graphs. */ + var graphs = new (DateTime?, string?)[] + { + (MinuteA.AddSeconds(10), BuildGraph(("a1", 55, 1000, true), ("a2", 66, 3000, false))), + (MinuteA.AddSeconds(50), BuildGraph(("b1", 77, 2000, true))), + }; + + var point = Assert.Single(ViewerDataService.AggregateDeadlockSeverity(graphs)); + Assert.Equal(MinuteA, point.Time); + Assert.Equal(2, point.VictimCount); // one victim per graph + Assert.Equal(6000, point.TotalWaitMs); // 1000 + 3000 + 2000 + Assert.Equal(3000, point.MaxWaitMs); + Assert.Equal(2000.0, point.AvgWaitMs, precision: 3); // 6000 / 3 processes + } + + [Fact] + public void MultipleGraphs_DifferentMinutes_ProduceOrderedBuckets() + { + /* Fed out of order; the aggregate must return buckets ordered by time (parity with the SQL ORDER BY). */ + var graphs = new (DateTime?, string?)[] + { + (MinuteB.AddSeconds(5), BuildGraph(("b1", 77, 500, true), ("b2", 88, 700, false))), + (MinuteA.AddSeconds(5), BuildGraph(("a1", 55, 1000, true))), + }; + + var points = ViewerDataService.AggregateDeadlockSeverity(graphs); + Assert.Equal(2, points.Count); + Assert.Equal(MinuteA, points[0].Time); + Assert.Equal(MinuteB, points[1].Time); + Assert.Equal(1, points[0].VictimCount); + Assert.Equal(1200, points[1].TotalWaitMs); // 500 + 700 + Assert.Equal(700, points[1].MaxWaitMs); + } + + [Fact] + public void NullDeadlockTime_IsSkipped_CannotBeBucketed() + { + var graphs = new (DateTime?, string?)[] + { + (null, BuildGraph(("p1", 55, 1000, true))), + (MinuteA.AddSeconds(5), BuildGraph(("p2", 66, 2000, true))), + }; + + var point = Assert.Single(ViewerDataService.AggregateDeadlockSeverity(graphs)); + Assert.Equal(MinuteA, point.Time); + Assert.Equal(2000, point.TotalWaitMs); // only the placeable graph + } + + [Fact] + public void EmptyOrMalformedXml_ContributesNothing() + { + var graphs = new (DateTime?, string?)[] + { + (MinuteA.AddSeconds(1), null), + (MinuteA.AddSeconds(2), ""), + (MinuteA.AddSeconds(3), ""), + (MinuteA.AddSeconds(4), " p.WaitTimeMs); + long expectedMax = model.Processes.Max(p => p.WaitTimeMs); + + var point = Assert.Single(ViewerDataService.AggregateDeadlockSeverity( + new (DateTime?, string?)[] { (MinuteA.AddSeconds(15), xml) })); + + Assert.Equal(expectedVictims, point.VictimCount); + Assert.Equal(expectedTotal, point.TotalWaitMs); + Assert.Equal(expectedMax, point.MaxWaitMs); + } + + [Fact] + public void BucketVictimCount_NeverExceedsProcesses_ButAtLeastOnePerRealDeadlock() + { + /* Reconciliation sanity: each real deadlock contributes >= 1 victim to its bucket, so the summed + victim_count is a coherent companion to the deadlock COUNT drawn from the same window. */ + var xml = DeadlockGraphParserTests.LoadFixture("deadlock_5proc_real_sql2025.xml"); + var point = Assert.Single(ViewerDataService.AggregateDeadlockSeverity( + new (DateTime?, string?)[] { (MinuteA.AddSeconds(15), xml) })); + + Assert.True(point.VictimCount >= 1); + Assert.True(point.VictimCount <= DeadlockGraphParser.Parse(xml).Processes.Count); + } +} + +/// +/// Gated (DARLING_TEST_PG) live round-trip for the deadlock-severity aggregate: proves the read executes +/// against the real migrated schema (in particular that v_deadlocks exposes deadlock_graph_xml), +/// windows on collection_time, and that the C# parse→bucket produces the right per-minute victim / total / +/// max / avg. Shares the serialized "live-postgres" collection; uses a negative sentinel server_id + finally +/// cleanup so it can't race or leak. +/// +[Collection("live-postgres")] +public sealed class ViewerDeadlockSeverityLivePostgresTests +{ + private const int SeverityServerId = -972101; + private const string ServerName = "viewer-deadlock-severity-e2e"; + + [Fact] + public async Task DeadlockSeverity_BucketsPerMinute_VictimTotalMaxAvg_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live deadlock-severity-stats test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, SeverityServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var baseTime = TruncateToMinute(DateTime.UtcNow.AddMinutes(-10)); + var minuteA = baseTime; + var minuteB = baseTime.AddMinutes(3); + + /* minute A: two deadlocks (2 victims total, waits 1000+3000 then 2000 → total 6000, max 3000, + 3 processes → avg 2000); minute B: one deadlock (1 victim, waits 500+700 → total 1200, max 700, + 2 processes → avg 600). */ + await InsertDeadlockAsync(connection, minuteA, minuteA.AddSeconds(10), + BuildGraph(("a1", 55, 1000, true), ("a2", 66, 3000, false))); + await InsertDeadlockAsync(connection, minuteA, minuteA.AddSeconds(40), + BuildGraph(("b1", 77, 2000, true))); + await InsertDeadlockAsync(connection, minuteB, minuteB.AddSeconds(10), + BuildGraph(("c1", 88, 500, true), ("c2", 99, 700, false))); + + var points = await viewer.GetDeadlockSeverityStatsAsync( + SeverityServerId, baseTime.AddMinutes(-1), baseTime.AddMinutes(10)); + + Assert.Equal(2, points.Count); + Assert.True(points[0].Time < points[1].Time); + + Assert.Equal(2, points[0].VictimCount); + Assert.Equal(6000, points[0].TotalWaitMs); + Assert.Equal(3000, points[0].MaxWaitMs); + Assert.Equal(2000.0, points[0].AvgWaitMs, precision: 3); + + Assert.Equal(1, points[1].VictimCount); + Assert.Equal(1200, points[1].TotalWaitMs); + Assert.Equal(700, points[1].MaxWaitMs); + Assert.Equal(600.0, points[1].AvgWaitMs, precision: 3); + } + finally + { + await DeleteRowsAsync(connection, SeverityServerId); + } + } + + private static string BuildGraph(params (string Id, int Spid, long WaitTime, bool IsVictim)[] processes) + { + var sb = new StringBuilder(); + sb.Append(""); + foreach (var p in processes.Where(p => p.IsVictim)) + sb.Append($""); + sb.Append(""); + foreach (var p in processes) + sb.Append($"x"); + sb.Append(""); + return sb.ToString(); + } + + private static async Task InsertDeadlockAsync( + NpgsqlConnection connection, DateTime collectionTimeUtc, DateTime deadlockTimeUtc, string graphXml) + { + using var command = new NpgsqlCommand(@" +INSERT INTO deadlocks + (deadlock_id, collection_time, server_id, server_name, deadlock_time, + victim_process_id, victim_sql_text, deadlock_graph_xml) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(SeverityServerId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(DateTime.SpecifyKind(deadlockTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue("a1"); + command.Parameters.AddWithValue("UPDATE t SET c = 1"); + command.Parameters.AddWithValue(graphXml); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static DateTime TruncateToMinute(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerMinute)), DateTimeKind.Unspecified); + + private static async Task DeleteRowsAsync(NpgsqlConnection connection, int serverId) + { + using var cleanup = new NpgsqlCommand($"DELETE FROM deadlocks WHERE server_id = {serverId};", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } +} diff --git a/Darling/Darling.Tests/ViewerDrillDownTests.cs b/Darling/Darling.Tests/ViewerDrillDownTests.cs new file mode 100644 index 000000000..ed59a3901 --- /dev/null +++ b/Darling/Darling.Tests/ViewerDrillDownTests.cs @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using PerformanceMonitor.Darling.Viewer; +using PerformanceMonitor.Ui; +using Xunit; +using static PerformanceMonitor.Ui.WaitDrillDownHelper; + +namespace Darling.Tests; + +/// +/// Pins the drill-down / chart-navigation parity restored from Lite (Items 1-5): the +/-30-minute window +/// computation, the slicer-overlay metric selection, the Wait Stats "Show Queries With <wait>" +/// classification + sort, and the new store reads' SQL shape. Pure logic + SQL pins only (no live Postgres). +/// +public sealed class ViewerDrillDownTests +{ + // ── +/-30-minute drill window (Items 1-3) ── + + [Fact] + public void DrillWindowUtc_IsA60MinuteSpanCenteredOnTheConvertedTime() + { + /* Robust to whatever display mode/offset the process statics currently hold: recompute the expected + centre through the SAME conversion the method uses, then assert the +/-30 span around it. */ + var clicked = new DateTime(2026, 7, 6, 14, 20, 0); + var (fromUtc, toUtc) = ViewerServerTab.DrillWindowUtc(clicked); + + var centre = ViewerTimeHelper.DisplayToNaiveUtc(clicked); + Assert.Equal(centre.AddMinutes(-30), fromUtc); + Assert.Equal(centre.AddMinutes(30), toUtc); + Assert.Equal(TimeSpan.FromMinutes(60), toUtc - fromUtc); + } + + [Fact] + public void DrillWindowUtc_InUtcMode_IsExactlyPlusMinus30OfTheClickedTime() + { + var savedMode = ViewerTimeHelper.CurrentDisplayMode; + try + { + ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.UTC; /* identity conversion */ + var clicked = new DateTime(2026, 7, 6, 9, 0, 0); + var (fromUtc, toUtc) = ViewerServerTab.DrillWindowUtc(clicked); + + Assert.Equal(new DateTime(2026, 7, 6, 8, 30, 0), fromUtc); + Assert.Equal(new DateTime(2026, 7, 6, 9, 30, 0), toUtc); + } + finally + { + ViewerTimeHelper.CurrentDisplayMode = savedMode; + } + } + + [Theory] + [InlineData(TimeDisplayMode.UTC, 0)] + [InlineData(TimeDisplayMode.ServerTime, 330)] // e.g. IST (+5:30) — the chart X is display-time + [InlineData(TimeDisplayMode.ServerTime, -480)] // e.g. PST (-8:00) + public void DisplayToNaiveUtc_UndoesTheChartsForDisplayShift(TimeDisplayMode mode, int offsetMinutes) + { + /* The chart plots ForDisplay(naiveUtc); a drill must recover the original naive UTC so the read + windows the right rows. Round-trips through the pure core (no process statics). */ + var storedUtc = new DateTime(2026, 7, 6, 3, 15, 0); + var display = ViewerTimeHelper.ConvertToDisplay(storedUtc, mode, offsetMinutes); + var recovered = ViewerTimeHelper.ConvertFromDisplay(display, mode, offsetMinutes); + + Assert.Equal(storedUtc, recovered); + } + + // ── Slicer overlay metric selection (Item 5) ── + + private static readonly List Timeline = new() + { + new(new DateTime(2026, 7, 6, 10, 0, 0), CpuMs: 10, ElapsedMs: 100, Reads: 5, Writes: 2, PhysicalReads: 1), + new(new DateTime(2026, 7, 6, 10, 1, 0), CpuMs: 0, ElapsedMs: 0, Reads: 0, Writes: 0, PhysicalReads: 0), + new(new DateTime(2026, 7, 6, 10, 2, 0), CpuMs: 20, ElapsedMs: 200, Reads: 7, Writes: 3, PhysicalReads: 4), + }; + + [Theory] + [InlineData("TotalCpu", 10.0, 20.0)] + [InlineData("AvgCpu", 10.0, 20.0)] + [InlineData("TotalElapsed", 100.0, 200.0)] + [InlineData("TotalReads", 5.0, 7.0)] + [InlineData("TotalWrites", 2.0, 3.0)] + [InlineData("TotalPhysReads", 1.0, 4.0)] + public void ComputeOverlayPoints_PicksTheFieldMatchingTheSlicerMetric_AndDropsZeroCycles( + string metric, double firstValue, double secondValue) + { + var points = ViewerServerTab.ComputeOverlayPoints(Timeline, metric); + + /* The middle (all-zero) cycle is dropped so an idle stretch draws no flat baseline. */ + Assert.Equal(2, points.Count); + Assert.Equal(firstValue, points[0].Value, precision: 3); + Assert.Equal(secondValue, points[1].Value, precision: 3); + Assert.Equal(new DateTime(2026, 7, 6, 10, 0, 0), points[0].TimeUtc); + Assert.Equal(new DateTime(2026, 7, 6, 10, 2, 0), points[1].TimeUtc); + } + + [Fact] + public void ComputeOverlayPoints_UnknownMetric_FallsBackToElapsed() + { + /* Query Store's "Sessions" (executions) sort has no dedicated column — it falls to elapsed, like Lite. */ + var points = ViewerServerTab.ComputeOverlayPoints(Timeline, "Sessions"); + Assert.Equal(new[] { 100.0, 200.0 }, points.Select(p => p.Value)); + } + + // ── Wait Stats classification + sort (Item 4) ── + + [Theory] + [InlineData("SOS_SCHEDULER_YIELD", WaitCategory.Correlated, "CpuTimeMs")] + [InlineData("WRITELOG", WaitCategory.Correlated, "Writes")] + [InlineData("RESOURCE_SEMAPHORE", WaitCategory.Correlated, "GrantedQueryMemoryGb")] + [InlineData("PAGEIOLATCH_SH", WaitCategory.Correlated, "Reads")] + [InlineData("THREADPOOL", WaitCategory.Uncapturable, "CpuTimeMs")] + [InlineData("LCK_M_X", WaitCategory.Chain, "")] + [InlineData("SOME_RANDOM_WAIT", WaitCategory.Filtered, "WaitTimeMs")] + public void Classify_SelectsTheBranchTheWaitDrillDownWindowRoutesOn( + string waitType, WaitCategory expectedCategory, string expectedSort) + { + var classification = Classify(waitType); + Assert.Equal(expectedCategory, classification.Category); + Assert.Equal(expectedSort, classification.SortProperty); + } + + [Fact] + public void SortByProperty_OrdersDescendingByTheClassifiedMetric() + { + var rows = new List + { + new() { SessionId = 1, CpuTimeMs = 10, WaitTimeMs = 300 }, + new() { SessionId = 2, CpuTimeMs = 90, WaitTimeMs = 100 }, + new() { SessionId = 3, CpuTimeMs = 50, WaitTimeMs = 200 }, + }; + + var byCpu = WaitDrillDownWindow.SortByProperty(rows, "CpuTimeMs"); + Assert.Equal(new[] { 2, 3, 1 }, byCpu.Select(r => r.SessionId)); + + var byWait = WaitDrillDownWindow.SortByProperty(rows, "WaitTimeMs"); + Assert.Equal(new[] { 1, 3, 2 }, byWait.Select(r => r.SessionId)); + } + + [Fact] + public void SortByProperty_UnknownProperty_LeavesOrderUnchanged() + { + var rows = new List + { + new() { SessionId = 7 }, + new() { SessionId = 3 }, + }; + Assert.Same(rows, WaitDrillDownWindow.SortByProperty(rows, "")); /* the "" (chain) case: untouched */ + } + + // ── New store reads' SQL shape (Items 4-5) ── + + [Fact] + public void QuerySnapshotsByWaitTypeSql_FiltersByWaitTypeOverTheWindow() + { + var sql = ViewerDataService.QuerySnapshotsByWaitTypeSql; + Assert.Contains("FROM query_snapshots", sql, StringComparison.Ordinal); + Assert.Contains("server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $2", sql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $3", sql, StringComparison.Ordinal); + Assert.Contains("wait_type = $4", sql, StringComparison.Ordinal); + AssertPgPositionalDialect(sql); + } + + [Fact] + public void QueryStatsItemTimelineSql_FiltersOneQueryHashOverTheWindow() + { + var sql = ViewerDataService.QueryStatsItemTimelineSql; + Assert.Contains("FROM query_stats", sql, StringComparison.Ordinal); + Assert.Contains("server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("database_name = $2", sql, StringComparison.Ordinal); + Assert.Contains("query_hash = $3", sql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $4", sql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $5", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY collection_time", sql, StringComparison.Ordinal); + AssertPgPositionalDialect(sql); + } + + [Fact] + public void ProcStatsItemTimelineSql_FiltersOneSchemaObjectOverTheWindow() + { + var sql = ViewerDataService.ProcStatsItemTimelineSql; + Assert.Contains("FROM procedure_stats", sql, StringComparison.Ordinal); + Assert.Contains("database_name = $2", sql, StringComparison.Ordinal); + Assert.Contains("schema_name = $3", sql, StringComparison.Ordinal); + Assert.Contains("object_name = $4", sql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $5", sql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $6", sql, StringComparison.Ordinal); + AssertPgPositionalDialect(sql); + } + + [Fact] + public void QueryStoreItemTimelineSql_FiltersOneQueryPlanAndScalesByExecutionCount() + { + var sql = ViewerDataService.QueryStoreItemTimelineSql; + Assert.Contains("FROM query_store_stats", sql, StringComparison.Ordinal); + Assert.Contains("query_id = $3", sql, StringComparison.Ordinal); + Assert.Contains("plan_id = $4", sql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $5", sql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $6", sql, StringComparison.Ordinal); + /* Query Store stores per-execution averages; the per-interval total scales by execution_count. */ + Assert.Contains("execution_count", sql, StringComparison.Ordinal); + AssertPgPositionalDialect(sql); + } + + private static void AssertPgPositionalDialect(string sql) + { + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); // no T-SQL named params + Assert.DoesNotContain("N'", sql, StringComparison.Ordinal); // no T-SQL unicode literals + Assert.Contains("$1", sql, StringComparison.Ordinal); + } +} diff --git a/Darling/Darling.Tests/ViewerFileIoBlockingTests.cs b/Darling/Darling.Tests/ViewerFileIoBlockingTests.cs new file mode 100644 index 000000000..628417742 --- /dev/null +++ b/Darling/Darling.Tests/ViewerFileIoBlockingTests.cs @@ -0,0 +1,688 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading.Tasks; +using Npgsql; +using NpgsqlTypes; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the File I/O-tab and Blocking-trend reads (W1c viewer copy-parity) against the Darling store +/// contract, no live Postgres. Both File I/O reads mirror Lite's view-based queries over +/// v_file_io_stats (a top-10 CTE by delta activity; the latency read averages stall/op with the +/// queued-stall COALESCE'd; the throughput read derives MB/s from the LAG-based collection interval). +/// The blocking-trend reads mirror Lite's: blocking incidents prefer v_blocked_process_reports +/// and fall back to v_dmv_blocking_snapshots only when the XE source is empty; deadlocks bucket on +/// deadlock_time; lock waits filter v_wait_stats to LCK%. The two Current-Waits reads hit +/// the base waiting_tasks table directly — the Darling store has no v_waiting_tasks view — +/// with the bigint-duration SUM CAST back to bigint for the typed reader. +/// +public sealed class ViewerFileIoBlockingSqlTests +{ + [Fact] + public void FileIoLatencyTrendSql_TopFilesCte_PerFileAverageLatency_WithQueuedCoalesce() + { + Assert.Contains("FROM v_file_io_stats", ViewerDataService.FileIoLatencyTrendSql, StringComparison.Ordinal); + Assert.Contains("WITH top_files AS", ViewerDataService.FileIoLatencyTrendSql, StringComparison.Ordinal); + Assert.Contains("ORDER BY SUM(delta_reads + delta_writes) DESC", ViewerDataService.FileIoLatencyTrendSql, StringComparison.Ordinal); + Assert.Contains("LIMIT 10", ViewerDataService.FileIoLatencyTrendSql, StringComparison.Ordinal); + + /* Read/write average latency casts the stall SUM to double before dividing by ops. */ + Assert.Contains("SUM(CAST(f.delta_stall_read_ms AS double precision)) / SUM(f.delta_reads)", ViewerDataService.FileIoLatencyTrendSql, StringComparison.Ordinal); + Assert.Contains("SUM(CAST(f.delta_stall_write_ms AS double precision)) / SUM(f.delta_writes)", ViewerDataService.FileIoLatencyTrendSql, StringComparison.Ordinal); + + /* Queued latency COALESCEs the queued-stall column to 0 (a pre-queued-column build reads 0). */ + Assert.Contains("COALESCE(f.delta_stall_queued_read_ms, 0)", ViewerDataService.FileIoLatencyTrendSql, StringComparison.Ordinal); + Assert.Contains("COALESCE(f.delta_stall_queued_write_ms, 0)", ViewerDataService.FileIoLatencyTrendSql, StringComparison.Ordinal); + + Assert.Contains("GROUP BY f.collection_time, f.database_name, f.file_name", ViewerDataService.FileIoLatencyTrendSql, StringComparison.Ordinal); + Assert.Contains("ORDER BY f.collection_time, f.database_name, f.file_name", ViewerDataService.FileIoLatencyTrendSql, StringComparison.Ordinal); + + /* Both window bounds are applied (top_files CTE + main query). */ + Assert.Contains("collection_time >= $2", ViewerDataService.FileIoLatencyTrendSql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $3", ViewerDataService.FileIoLatencyTrendSql, StringComparison.Ordinal); + } + + [Fact] + public void FileIoThroughputTrendSql_LagInterval_PerSecondMbRate() + { + Assert.Contains("FROM v_file_io_stats", ViewerDataService.FileIoThroughputTrendSql, StringComparison.Ordinal); + Assert.Contains("WITH top_files AS", ViewerDataService.FileIoThroughputTrendSql, StringComparison.Ordinal); + Assert.Contains("ORDER BY SUM(delta_read_bytes + delta_write_bytes) DESC", ViewerDataService.FileIoThroughputTrendSql, StringComparison.Ordinal); + + /* The per-file label is database.file, and the interval comes from LAG over collection_time. */ + Assert.Contains("f.database_name || '.' || f.file_name AS file_label", ViewerDataService.FileIoThroughputTrendSql, StringComparison.Ordinal); + Assert.Contains("LAG(f.collection_time)", ViewerDataService.FileIoThroughputTrendSql, StringComparison.Ordinal); + Assert.Contains("EXTRACT(EPOCH FROM", ViewerDataService.FileIoThroughputTrendSql, StringComparison.Ordinal); + + /* Bytes / interval-seconds / 1 MiB, and the NULL-interval first row per file is dropped. */ + Assert.Contains("/ interval_seconds / 1048576.0", ViewerDataService.FileIoThroughputTrendSql, StringComparison.Ordinal); + Assert.Contains("WHERE interval_seconds IS NOT NULL AND interval_seconds > 0", ViewerDataService.FileIoThroughputTrendSql, StringComparison.Ordinal); + } + + [Fact] + public void FileIoReads_ReadColumnsThatExistInTheGeneratedFileIoTable() + { + Assert.Equal("file_io_stats", FileIoStatsCollector.Instance.TargetTable); + + var ddl = PgSchemaGenerator.CreateTable(FileIoStatsCollector.Instance); + foreach (var column in new[] + { + "collection_time", "database_name", "file_name", + "delta_reads", "delta_writes", "delta_read_bytes", "delta_write_bytes", + "delta_stall_read_ms", "delta_stall_write_ms", + "delta_stall_queued_read_ms", "delta_stall_queued_write_ms", + }) + { + Assert.Contains(column, ddl, StringComparison.Ordinal); + } + } + + [Fact] + public void BlockingTrendSql_XePreferred_DmvFallbackOnlyWhenNoXe() + { + Assert.Contains("FROM v_blocked_process_reports", ViewerDataService.BlockingTrendSql, StringComparison.Ordinal); + Assert.Contains("FROM v_dmv_blocking_snapshots", ViewerDataService.BlockingTrendSql, StringComparison.Ordinal); + /* The DMV branch contributes only when the XE source has no rows in the window. */ + Assert.Contains("WHERE NOT EXISTS (SELECT 1 FROM bpr)", ViewerDataService.BlockingTrendSql, StringComparison.Ordinal); + /* Bucketed on event_time (when blocking happened), not collection_time. */ + Assert.Contains("DATE_TRUNC('minute', event_time)", ViewerDataService.BlockingTrendSql, StringComparison.Ordinal); + Assert.Contains("event_time >= $2 AND event_time <= $3", ViewerDataService.BlockingTrendSql, StringComparison.Ordinal); + } + + [Fact] + public void DeadlockTrendSql_BucketsOnDeadlockTime_WindowsOnCollection() + { + Assert.Contains("FROM v_deadlocks", ViewerDataService.DeadlockTrendSql, StringComparison.Ordinal); + Assert.Contains("DATE_TRUNC('minute', deadlock_time)", ViewerDataService.DeadlockTrendSql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $2", ViewerDataService.DeadlockTrendSql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $3", ViewerDataService.DeadlockTrendSql, StringComparison.Ordinal); + } + + [Fact] + public void LockWaitTrendSql_FiltersLckPrefix_PerSecondRateViaLag() + { + Assert.Contains("FROM v_wait_stats", ViewerDataService.LockWaitTrendSql, StringComparison.Ordinal); + Assert.Contains("wait_type LIKE 'LCK%'", ViewerDataService.LockWaitTrendSql, StringComparison.Ordinal); + Assert.Contains("LAG(collection_time)", ViewerDataService.LockWaitTrendSql, StringComparison.Ordinal); + Assert.Contains("CAST(delta_wait_time_ms AS double precision) / interval_seconds", ViewerDataService.LockWaitTrendSql, StringComparison.Ordinal); + } + + [Fact] + public void WaitingTaskTrendSql_ReadsBaseTable_CastsBigintSumToBigint() + { + /* No v_waiting_tasks view exists in the Darling store — the read hits the base table. */ + Assert.Contains("FROM waiting_tasks", ViewerDataService.WaitingTaskTrendSql, StringComparison.Ordinal); + Assert.DoesNotContain("v_waiting_tasks", ViewerDataService.WaitingTaskTrendSql, StringComparison.Ordinal); + /* SUM of a bigint column is numeric in Postgres, CAST back to bigint for the typed GetInt64 reader. */ + Assert.Contains("CAST(SUM(wait_duration_ms) AS bigint)", ViewerDataService.WaitingTaskTrendSql, StringComparison.Ordinal); + Assert.Contains("wait_type IS NOT NULL", ViewerDataService.WaitingTaskTrendSql, StringComparison.Ordinal); + } + + [Fact] + public void BlockedSessionTrendSql_ReadsBaseTable_OnlyBlockedRows_CountByDatabase() + { + Assert.Contains("FROM waiting_tasks", ViewerDataService.BlockedSessionTrendSql, StringComparison.Ordinal); + Assert.DoesNotContain("v_waiting_tasks", ViewerDataService.BlockedSessionTrendSql, StringComparison.Ordinal); + Assert.Contains("blocking_session_id > 0", ViewerDataService.BlockedSessionTrendSql, StringComparison.Ordinal); + Assert.Contains("database_name IS NOT NULL", ViewerDataService.BlockedSessionTrendSql, StringComparison.Ordinal); + Assert.Contains("COUNT(*) AS blocked_count", ViewerDataService.BlockedSessionTrendSql, StringComparison.Ordinal); + } + + [Fact] + public void CurrentWaitsReads_ReadColumnsThatExistInTheGeneratedWaitingTasksTable() + { + Assert.Equal("waiting_tasks", WaitingTasksCollector.Instance.TargetTable); + + var ddl = PgSchemaGenerator.CreateTable(WaitingTasksCollector.Instance); + foreach (var column in new[] + { + "collection_time", "session_id", "wait_type", "wait_duration_ms", + "blocking_session_id", "database_name", + }) + { + Assert.Contains(column, ddl, StringComparison.Ordinal); + } + } + + [Fact] + public void DeadlockTrend_ReadsColumnThatExistsInTheGeneratedDeadlocksTable() + { + Assert.Equal("deadlocks", DeadlocksCollector.Instance.TargetTable); + + var ddl = PgSchemaGenerator.CreateTable(DeadlocksCollector.Instance); + foreach (var column in new[] { "collection_time", "deadlock_time" }) + { + Assert.Contains(column, ddl, StringComparison.Ordinal); + } + } + + [Theory] + [InlineData("fileio-latency")] + [InlineData("fileio-throughput")] + [InlineData("blocking")] + [InlineData("deadlock")] + [InlineData("lockwait")] + [InlineData("waiting-task")] + [InlineData("blocked-session")] + public void NewViewerReads_PgDialect_PositionalParams_NoBareNow_NoNLiterals(string which) + { + var sql = which switch + { + "fileio-latency" => ViewerDataService.FileIoLatencyTrendSql, + "fileio-throughput" => ViewerDataService.FileIoThroughputTrendSql, + "blocking" => ViewerDataService.BlockingTrendSql, + "deadlock" => ViewerDataService.DeadlockTrendSql, + "lockwait" => ViewerDataService.LockWaitTrendSql, + "waiting-task" => ViewerDataService.WaitingTaskTrendSql, + _ => ViewerDataService.BlockedSessionTrendSql, + }; + + Assert.DoesNotContain("now(", sql.ToLowerInvariant()); + Assert.DoesNotContain("N'", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + Assert.Contains("$1", sql, StringComparison.Ordinal); + Assert.Contains("$2", sql, StringComparison.Ordinal); + Assert.Contains("$3", sql, StringComparison.Ordinal); + } +} + +/// +/// Gated (DARLING_TEST_PG) live round-trips for the seven new W1c reads: the two File I/O reads +/// (per-file latency with the queued overlay; LAG-derived MB/s dropping the first NULL-interval row), +/// the three Blocking-Trends reads (XE-preferred blocking with the DMV fallback exclusion; deadlock +/// bucketing on deadlock_time; LCK%-only lock waits), and the two Current-Waits reads (the bigint-SUM +/// duration read via GetInt64; the blocked-session count filtered to blocking rows). Shares the +/// serialized "live-postgres" collection so the row churn can't race another class; uses negative +/// sentinel server_ids and cleans up in finally. +/// +[Collection("live-postgres")] +public sealed class ViewerFileIoBlockingLivePostgresTests +{ + private const int FileIoServerId = -971001; + private const int ThroughputServerId = -971002; + private const int BlockingServerId = -971003; + private const int DeadlockServerId = -971004; + private const int LockWaitServerId = -971005; + private const int WaitingTaskServerId = -971006; + private const int BlockedSessionServerId = -971007; + + private const string ServerName = "viewer-w1c-e2e"; + + [Fact] + public async Task FileIoLatency_PerFileAverage_WithQueuedOverlay_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live File I/O latency test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "file_io_stats", FileIoServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var t1 = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + + /* Two files in one collection. tempdb_data has queued stall; a zero-activity file is excluded + from top_files by the (delta_reads > 0 OR delta_writes > 0) guard. */ + await InsertFileIoAsync(connection, FileIoServerId, t1, "tempdb", "tempdb_data", + deltaReads: 10, deltaWrites: 4, deltaReadBytes: 0, deltaWriteBytes: 0, + deltaStallReadMs: 100, deltaStallWriteMs: 20, deltaStallQueuedReadMs: 30, deltaStallQueuedWriteMs: 8); + await InsertFileIoAsync(connection, FileIoServerId, t1, "StackOverflow", "so_data", + deltaReads: 5, deltaWrites: 0, deltaReadBytes: 0, deltaWriteBytes: 0, + deltaStallReadMs: 50, deltaStallWriteMs: 0, deltaStallQueuedReadMs: 0, deltaStallQueuedWriteMs: 0); + await InsertFileIoAsync(connection, FileIoServerId, t1, "Idle", "idle_data", + deltaReads: 0, deltaWrites: 0, deltaReadBytes: 0, deltaWriteBytes: 0, + deltaStallReadMs: 0, deltaStallWriteMs: 0, deltaStallQueuedReadMs: 0, deltaStallQueuedWriteMs: 0); + + var rows = await viewer.GetFileIoLatencyTrendAsync(FileIoServerId, t1.AddMinutes(-1), t1.AddMinutes(1)); + + /* Idle file excluded (no ops); the two active files survive, ordered by database.file. */ + Assert.Equal(2, rows.Count); + Assert.Equal(new[] { "StackOverflow", "tempdb" }, rows.Select(r => r.DatabaseName)); + + var tempdb = rows.Single(r => r.DatabaseName == "tempdb"); + /* 100 stall / 10 reads = 10 ms read; 20 stall / 4 writes = 5 ms write. */ + Assert.Equal(10.0, tempdb.AvgReadLatencyMs, precision: 3); + Assert.Equal(5.0, tempdb.AvgWriteLatencyMs, precision: 3); + /* Queued: 30 / 10 = 3 ms read; 8 / 4 = 2 ms write. */ + Assert.Equal(3.0, tempdb.AvgQueuedReadLatencyMs, precision: 3); + Assert.Equal(2.0, tempdb.AvgQueuedWriteLatencyMs, precision: 3); + + var so = rows.Single(r => r.DatabaseName == "StackOverflow"); + /* 50 stall / 5 reads = 10 ms read; 0 writes → CASE ELSE 0 write latency. */ + Assert.Equal(10.0, so.AvgReadLatencyMs, precision: 3); + Assert.Equal(0.0, so.AvgWriteLatencyMs, precision: 3); + } + finally + { + await DeleteRowsAsync(connection, "file_io_stats", FileIoServerId); + } + } + + [Fact] + public async Task FileIoThroughput_LagInterval_DropsFirstNullInterval_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live File I/O throughput test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "file_io_stats", ThroughputServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var t1 = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + var t2 = t1.AddSeconds(60); + var t3 = t2.AddSeconds(60); + + /* One file across three collections 60s apart. 62914560 bytes / 60s / 1 MiB = 1.0 MB/s. */ + const long oneMbPerSecOverMinute = 1048576L * 60L; + await InsertFileIoAsync(connection, ThroughputServerId, t1, "db1", "f1", + deltaReads: 1, deltaWrites: 1, deltaReadBytes: oneMbPerSecOverMinute, deltaWriteBytes: oneMbPerSecOverMinute * 2, + deltaStallReadMs: 0, deltaStallWriteMs: 0, deltaStallQueuedReadMs: 0, deltaStallQueuedWriteMs: 0); + await InsertFileIoAsync(connection, ThroughputServerId, t2, "db1", "f1", + deltaReads: 1, deltaWrites: 1, deltaReadBytes: oneMbPerSecOverMinute, deltaWriteBytes: oneMbPerSecOverMinute * 2, + deltaStallReadMs: 0, deltaStallWriteMs: 0, deltaStallQueuedReadMs: 0, deltaStallQueuedWriteMs: 0); + await InsertFileIoAsync(connection, ThroughputServerId, t3, "db1", "f1", + deltaReads: 1, deltaWrites: 1, deltaReadBytes: oneMbPerSecOverMinute, deltaWriteBytes: oneMbPerSecOverMinute * 2, + deltaStallReadMs: 0, deltaStallWriteMs: 0, deltaStallQueuedReadMs: 0, deltaStallQueuedWriteMs: 0); + + var rows = await viewer.GetFileIoThroughputTrendAsync(ThroughputServerId, t1.AddMinutes(-1), t3.AddMinutes(1)); + + /* First collection has no prior sample (NULL interval) and is dropped; two rows remain. */ + Assert.Equal(2, rows.Count); + Assert.All(rows, r => Assert.Equal("db1.f1", r.FileLabel)); + Assert.All(rows, r => Assert.Equal(1.0, r.ReadMbPerSec, precision: 3)); + /* Write bytes are double the read bytes → 2.0 MB/s. */ + Assert.All(rows, r => Assert.Equal(2.0, r.WriteMbPerSec, precision: 3)); + } + finally + { + await DeleteRowsAsync(connection, "file_io_stats", ThroughputServerId); + } + } + + [Fact] + public async Task BlockingTrend_XePreferred_DmvFallbackWhenNoXe_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live blocking-trend test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "blocked_process_reports", BlockingServerId); + await DeleteRowsAsync(connection, "dmv_blocking_snapshots", BlockingServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var baseTime = TruncateToMinute(DateTime.UtcNow.AddMinutes(-10)); + var minuteA = baseTime; + var minuteB = baseTime.AddMinutes(3); + + /* Two XE reports in minute A + one in minute B, plus DMV rows that must be excluded while XE exists. */ + await InsertBlockedProcessReportAsync(connection, BlockingServerId, minuteA, minuteA.AddSeconds(5)); + await InsertBlockedProcessReportAsync(connection, BlockingServerId, minuteA, minuteA.AddSeconds(40)); + await InsertBlockedProcessReportAsync(connection, BlockingServerId, minuteB, minuteB.AddSeconds(10)); + await InsertDmvBlockingSnapshotAsync(connection, BlockingServerId, minuteA, minuteA.AddSeconds(20)); + await InsertDmvBlockingSnapshotAsync(connection, BlockingServerId, minuteA, minuteA.AddSeconds(50)); + + var withXe = await viewer.GetBlockingTrendAsync(BlockingServerId, baseTime.AddMinutes(-1), baseTime.AddMinutes(10)); + + /* Only the XE buckets appear (DMV excluded by WHERE NOT EXISTS): minute A = 2, minute B = 1. */ + Assert.Equal(2, withXe.Count); + Assert.Equal(2, withXe[0].Count); + Assert.Equal(1, withXe[1].Count); + Assert.True(withXe[0].Time < withXe[1].Time); + + /* Remove the XE rows: now the DMV fallback contributes its two minute-A rows. */ + await DeleteRowsAsync(connection, "blocked_process_reports", BlockingServerId); + var dmvOnly = await viewer.GetBlockingTrendAsync(BlockingServerId, baseTime.AddMinutes(-1), baseTime.AddMinutes(10)); + Assert.Single(dmvOnly); + Assert.Equal(2, dmvOnly[0].Count); + } + finally + { + await DeleteRowsAsync(connection, "blocked_process_reports", BlockingServerId); + await DeleteRowsAsync(connection, "dmv_blocking_snapshots", BlockingServerId); + } + } + + [Fact] + public async Task DeadlockTrend_BucketsOnDeadlockTime_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live deadlock-trend test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "deadlocks", DeadlockServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var collectionTime = TruncateToMinute(DateTime.UtcNow.AddMinutes(-10)); + var minuteA = collectionTime; + var minuteB = collectionTime.AddMinutes(2); + + /* Two deadlocks in minute A, one in minute B — all collected at the same collection_time. */ + await InsertDeadlockAsync(connection, DeadlockServerId, collectionTime, minuteA.AddSeconds(3)); + await InsertDeadlockAsync(connection, DeadlockServerId, collectionTime, minuteA.AddSeconds(45)); + await InsertDeadlockAsync(connection, DeadlockServerId, collectionTime, minuteB.AddSeconds(15)); + + var rows = await viewer.GetDeadlockTrendAsync(DeadlockServerId, collectionTime.AddMinutes(-1), collectionTime.AddMinutes(1)); + + Assert.Equal(2, rows.Count); + Assert.Equal(2, rows[0].Count); + Assert.Equal(1, rows[1].Count); + Assert.True(rows[0].Time < rows[1].Time); + } + finally + { + await DeleteRowsAsync(connection, "deadlocks", DeadlockServerId); + } + } + + [Fact] + public async Task LockWaitTrend_FiltersLckPrefix_PerSecondRate_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live lock-wait-trend test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "wait_stats", LockWaitServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var t1 = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + var t2 = t1.AddSeconds(60); + + /* Two collections of LCK_M_S 60s apart + a non-LCK wait that must be filtered out. */ + await InsertWaitStatAsync(connection, LockWaitServerId, t1, "LCK_M_S", deltaWaitTimeMs: 3000); + await InsertWaitStatAsync(connection, LockWaitServerId, t2, "LCK_M_S", deltaWaitTimeMs: 6000); + await InsertWaitStatAsync(connection, LockWaitServerId, t2, "SOS_SCHEDULER_YIELD", deltaWaitTimeMs: 99999); + + var rows = await viewer.GetLockWaitTrendAsync(LockWaitServerId, t1.AddMinutes(-1), t2.AddMinutes(1)); + + /* Only the two LCK_M_S rows survive the LIKE 'LCK%' filter. */ + Assert.Equal(2, rows.Count); + Assert.All(rows, r => Assert.Equal("LCK_M_S", r.WaitType)); + /* First collection has no prior sample → interval NULL → CASE ELSE 0. */ + Assert.Equal(0.0, rows[0].WaitTimeMsPerSecond, precision: 3); + /* Second: 6000 ms / 60 s = 100 ms/sec. */ + Assert.Equal(100.0, rows[1].WaitTimeMsPerSecond, precision: 3); + } + finally + { + await DeleteRowsAsync(connection, "wait_stats", LockWaitServerId); + } + } + + [Fact] + public async Task WaitingTaskTrend_SumBigintCastToBigint_GroupedByWaitType_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live waiting-task-trend test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "waiting_tasks", WaitingTaskServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var collectionTime = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + + /* Two LCK_M_X waiters summing past int.MaxValue (proves the numeric SUM → bigint read), + plus a NULL-wait_type row that must be excluded. */ + await InsertWaitingTaskAsync(connection, WaitingTaskServerId, collectionTime, + sessionId: 55, waitType: "LCK_M_X", waitDurationMs: 3_000_000_000L, blockingSessionId: 60, databaseName: "AppDb"); + await InsertWaitingTaskAsync(connection, WaitingTaskServerId, collectionTime, + sessionId: 56, waitType: "LCK_M_X", waitDurationMs: 3_000_000_000L, blockingSessionId: 60, databaseName: "AppDb"); + await InsertWaitingTaskAsync(connection, WaitingTaskServerId, collectionTime, + sessionId: 57, waitType: null, waitDurationMs: 123, blockingSessionId: 0, databaseName: "AppDb"); + + var rows = await viewer.GetWaitingTaskTrendAsync(WaitingTaskServerId, collectionTime.AddMinutes(-1), collectionTime.AddMinutes(1)); + + /* One group (LCK_M_X); the NULL-wait_type row is excluded. */ + Assert.Single(rows); + Assert.Equal("LCK_M_X", rows[0].WaitType); + Assert.Equal(6_000_000_000L, rows[0].TotalWaitMs); + } + finally + { + await DeleteRowsAsync(connection, "waiting_tasks", WaitingTaskServerId); + } + } + + [Fact] + public async Task BlockedSessionTrend_OnlyBlockedRows_CountByDatabase_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live blocked-session-trend test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "waiting_tasks", BlockedSessionServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var collectionTime = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + + /* Two blocked sessions in AppDb, one in ReportDb; an unblocked row (blocking_session_id 0) + and a NULL-database row must both be excluded. */ + await InsertWaitingTaskAsync(connection, BlockedSessionServerId, collectionTime, + sessionId: 71, waitType: "LCK_M_X", waitDurationMs: 100, blockingSessionId: 90, databaseName: "AppDb"); + await InsertWaitingTaskAsync(connection, BlockedSessionServerId, collectionTime, + sessionId: 72, waitType: "LCK_M_X", waitDurationMs: 100, blockingSessionId: 90, databaseName: "AppDb"); + await InsertWaitingTaskAsync(connection, BlockedSessionServerId, collectionTime, + sessionId: 73, waitType: "LCK_M_S", waitDurationMs: 100, blockingSessionId: 91, databaseName: "ReportDb"); + await InsertWaitingTaskAsync(connection, BlockedSessionServerId, collectionTime, + sessionId: 74, waitType: "CXPACKET", waitDurationMs: 100, blockingSessionId: 0, databaseName: "AppDb"); + await InsertWaitingTaskAsync(connection, BlockedSessionServerId, collectionTime, + sessionId: 75, waitType: "LCK_M_X", waitDurationMs: 100, blockingSessionId: 92, databaseName: null); + + var rows = await viewer.GetBlockedSessionTrendAsync(BlockedSessionServerId, collectionTime.AddMinutes(-1), collectionTime.AddMinutes(1)); + + /* Ordered by database_name: AppDb = 2, ReportDb = 1. */ + Assert.Equal(2, rows.Count); + Assert.Equal("AppDb", rows[0].DatabaseName); + Assert.Equal(2, rows[0].BlockedCount); + Assert.Equal("ReportDb", rows[1].DatabaseName); + Assert.Equal(1, rows[1].BlockedCount); + } + finally + { + await DeleteRowsAsync(connection, "waiting_tasks", BlockedSessionServerId); + } + } + + private static async Task InsertFileIoAsync( + NpgsqlConnection connection, int serverId, DateTime collectionTimeUtc, string databaseName, string fileName, + long deltaReads, long deltaWrites, long deltaReadBytes, long deltaWriteBytes, + long deltaStallReadMs, long deltaStallWriteMs, long deltaStallQueuedReadMs, long deltaStallQueuedWriteMs) + { + using var command = new NpgsqlCommand(@" +INSERT INTO file_io_stats + (collection_id, collection_time, server_id, server_name, + database_name, file_name, delta_reads, delta_writes, delta_read_bytes, delta_write_bytes, + delta_stall_read_ms, delta_stall_write_ms, delta_stall_queued_read_ms, delta_stall_queued_write_ms) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(databaseName); + command.Parameters.AddWithValue(fileName); + command.Parameters.AddWithValue(deltaReads); + command.Parameters.AddWithValue(deltaWrites); + command.Parameters.AddWithValue(deltaReadBytes); + command.Parameters.AddWithValue(deltaWriteBytes); + command.Parameters.AddWithValue(deltaStallReadMs); + command.Parameters.AddWithValue(deltaStallWriteMs); + command.Parameters.AddWithValue(deltaStallQueuedReadMs); + command.Parameters.AddWithValue(deltaStallQueuedWriteMs); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertBlockedProcessReportAsync( + NpgsqlConnection connection, int serverId, DateTime collectionTimeUtc, DateTime eventTimeUtc) + { + using var command = new NpgsqlCommand(@" +INSERT INTO blocked_process_reports + (blocked_report_id, collection_time, server_id, server_name, event_time, database_name, + blocked_spid, blocking_spid, wait_time_ms, lock_mode, blocked_sql_text, blocking_sql_text, + blocked_process_report_xml, contentious_object) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(DateTime.SpecifyKind(eventTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue("AppDb"); + command.Parameters.AddWithValue(55); + command.Parameters.AddWithValue(60); + command.Parameters.AddWithValue(1200L); + command.Parameters.AddWithValue("X"); + command.Parameters.AddWithValue("SELECT 1"); + command.Parameters.AddWithValue("UPDATE t SET c = 1"); + command.Parameters.AddWithValue(""); + command.Parameters.AddWithValue("dbo.t"); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertDmvBlockingSnapshotAsync( + NpgsqlConnection connection, int serverId, DateTime collectionTimeUtc, DateTime eventTimeUtc) + { + using var command = new NpgsqlCommand(@" +INSERT INTO dmv_blocking_snapshots + (collection_id, collection_time, server_id, server_name, event_time, database_name, + blocked_spid, blocking_spid, wait_time_ms, lock_mode, blocked_sql_text, blocking_sql_text, contentious_object) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(DateTime.SpecifyKind(eventTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue("AppDb"); + command.Parameters.AddWithValue(155); + command.Parameters.AddWithValue(160); + command.Parameters.AddWithValue(900L); + command.Parameters.AddWithValue("S"); + command.Parameters.AddWithValue("SELECT 2"); + command.Parameters.AddWithValue("UPDATE t SET c = 2"); + command.Parameters.AddWithValue("dbo.t"); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertDeadlockAsync( + NpgsqlConnection connection, int serverId, DateTime collectionTimeUtc, DateTime deadlockTimeUtc) + { + using var command = new NpgsqlCommand(@" +INSERT INTO deadlocks + (deadlock_id, collection_time, server_id, server_name, deadlock_time, + victim_process_id, victim_sql_text, deadlock_graph_xml) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(DateTime.SpecifyKind(deadlockTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue("process123"); + command.Parameters.AddWithValue("UPDATE t SET c = 1"); + command.Parameters.AddWithValue(""); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertWaitStatAsync( + NpgsqlConnection connection, int serverId, DateTime collectionTimeUtc, string waitType, long deltaWaitTimeMs) + { + using var command = new NpgsqlCommand(@" +INSERT INTO wait_stats + (collection_id, collection_time, server_id, server_name, wait_type, delta_waiting_tasks, delta_wait_time_ms) +VALUES ($1, $2, $3, $4, $5, $6, $7)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(waitType); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(deltaWaitTimeMs); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertWaitingTaskAsync( + NpgsqlConnection connection, int serverId, DateTime collectionTimeUtc, + int sessionId, string? waitType, long waitDurationMs, int blockingSessionId, string? databaseName) + { + using var command = new NpgsqlCommand(@" +INSERT INTO waiting_tasks + (collection_id, collection_time, server_id, server_name, + session_id, wait_type, wait_duration_ms, blocking_session_id, resource_description, database_name) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(sessionId); + command.Parameters.Add(new NpgsqlParameter { Value = (object?)waitType ?? DBNull.Value, NpgsqlDbType = NpgsqlDbType.Text }); + command.Parameters.AddWithValue(waitDurationMs); + command.Parameters.AddWithValue(blockingSessionId); + command.Parameters.AddWithValue("keylock"); + command.Parameters.Add(new NpgsqlParameter { Value = (object?)databaseName ?? DBNull.Value, NpgsqlDbType = NpgsqlDbType.Text }); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static DateTime TruncateToSeconds(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified); + + /* The blocking/deadlock trends bucket by DATE_TRUNC('minute', ...), so the tests anchor on a minute + boundary — otherwise added seconds could spill into the next minute and split a bucket. */ + private static DateTime TruncateToMinute(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerMinute)), DateTimeKind.Unspecified); + + private static async Task DeleteRowsAsync(NpgsqlConnection connection, string table, int serverId) + { + using var cleanup = new NpgsqlCommand($"DELETE FROM {table} WHERE server_id = {serverId};", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } +} diff --git a/Darling/Darling.Tests/ViewerFinOpsRecommendationsTests.cs b/Darling/Darling.Tests/ViewerFinOpsRecommendationsTests.cs new file mode 100644 index 000000000..d2f31eeb4 --- /dev/null +++ b/Darling/Darling.Tests/ViewerFinOpsRecommendationsTests.cs @@ -0,0 +1,436 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the FinOps Recommendations sub-tab — the last deferred sub-tab, reproduced MONITOR-SIDE. Lite mixed +/// collected reads with LIVE target queries; every check here runs over the collected Postgres store. These tests +/// cover the three checks Lite ran "live" and now reproduces from collected data (edition/license audit, TDE from +/// database_config.is_encrypted, compression from index_object_stats, dev/test name match), the +/// dropped check (sp_IndexCleanup existence — superseded by native Index Analysis), the AG-nuance deferral, and the +/// collected time-series reads' SQL contract (a representative case, per the port brief — not the ported arithmetic). +/// +public sealed class ViewerFinOpsRecommendationsTests +{ + // ── Check 1: Edition / license audit branching (reproduced from collected server_properties) ── + + [Fact] + public void EditionAudit_NonEnterprise_ReturnsNoRecommendations() + { + var recs = ViewerDataService.BuildEditionAuditRecommendations( + "Standard Edition (64-bit)", majorVersion: 16, cpuCount: 8, Array.Empty(), monthlyCost: 1000m, + agReplicaRole: "Standalone", isHadrEnabled: false); + + Assert.Empty(recs); + } + + [Fact] + public void EditionAudit_Enterprise2019Plus_Standalone_SuggestsDowngrade_WithBudgetSavings_NoAgCaveat() + { + var recs = ViewerDataService.BuildEditionAuditRecommendations( + "Enterprise Edition (64-bit)", majorVersion: 16, cpuCount: 16, Array.Empty(), monthlyCost: 1000m, + agReplicaRole: "Standalone", isHadrEnabled: false); + + var rec = Assert.Single(recs); + Assert.Equal("Licensing", rec.Category); + Assert.Equal("High", rec.Severity); + /* Standalone (no AG) => the AG-driven confidence downgrade does not apply; standalone confidence stands. */ + Assert.Equal("Medium", rec.Confidence); + Assert.Equal("Enterprise Edition may not be required", rec.Finding); + Assert.Equal(400m, rec.EstMonthlySavings); // 1000 * 0.40 + /* No AG present => no secondary branch, no Basic-AG caveat. */ + Assert.DoesNotContain("secondary replica", rec.Detail, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Basic Availability Groups", rec.Detail, StringComparison.Ordinal); + } + + [Fact] + public void EditionAudit_EnterprisePre2019_NoTde_Standalone_SuggestsDowngrade_HighConfidence() + { + var recs = ViewerDataService.BuildEditionAuditRecommendations( + "Enterprise Edition (64-bit)", majorVersion: 13, cpuCount: 8, Array.Empty(), monthlyCost: 1000m, + agReplicaRole: "Standalone", isHadrEnabled: false); + + var rec = Assert.Single(recs); + Assert.Equal("High", rec.Severity); + Assert.Equal("High", rec.Confidence); + Assert.Equal("Enterprise Edition with no Enterprise-only features detected", rec.Finding); + Assert.Equal(400m, rec.EstMonthlySavings); + Assert.Contains("Transparent Data Encryption", rec.Detail, StringComparison.Ordinal); + Assert.DoesNotContain("Basic Availability Groups", rec.Detail, StringComparison.Ordinal); + } + + [Fact] + public void EditionAudit_EnterprisePre2019_WithTde_BlocksDowngrade_AndAddsLicenseCoreMath() + { + var tdeDbs = new[] { "Sales", "Payroll" }; + var recs = ViewerDataService.BuildEditionAuditRecommendations( + "Enterprise Edition (64-bit)", majorVersion: 14, cpuCount: 8, tdeDbs, monthlyCost: 1000m, + agReplicaRole: "Standalone", isHadrEnabled: false); + + Assert.Equal(2, recs.Count); + + var blocker = recs[0]; + Assert.Equal("TDE in use — Enterprise Edition downgrade blocker", blocker.Finding); + Assert.Equal("Low", blocker.Severity); + Assert.Equal("High", blocker.Confidence); + Assert.Null(blocker.EstMonthlySavings); // a blocker carries no savings estimate + Assert.Contains("Sales", blocker.Detail, StringComparison.Ordinal); + Assert.Contains("Payroll", blocker.Detail, StringComparison.Ordinal); + + // Check 10: core-count list-price math (8 cores * $5,000/yr / 12 = $3,333/mo). + var licenseMath = recs[1]; + Assert.Equal("Low", licenseMath.Confidence); + Assert.Contains("8 cores", licenseMath.Finding, StringComparison.Ordinal); + Assert.Contains("$3,333/mo", licenseMath.Finding, StringComparison.Ordinal); + Assert.NotNull(licenseMath.EstMonthlySavings); + Assert.Equal(8 * 5000m / 12m, licenseMath.EstMonthlySavings!.Value); + } + + [Fact] + public void EditionAudit_ZeroBudget_OmitsSavingsEstimate() + { + var recs = ViewerDataService.BuildEditionAuditRecommendations( + "Enterprise Edition (64-bit)", majorVersion: 15, cpuCount: 8, Array.Empty(), monthlyCost: 0m, + agReplicaRole: "Standalone", isHadrEnabled: false); + + var rec = Assert.Single(recs); + Assert.Null(rec.EstMonthlySavings); + Assert.Equal("", rec.EstMonthlySavingsDisplay); + } + + // ── Check 1: the AG-aware branches restored from the collected ag_replica_role + is_hadr_enabled ── + // (Lite's LocalDataService.FinOps.Recommendations.cs GetAgReplicaRoleAsync / GetAdvancedAgCountAsync branches.) + + [Fact] + public void EditionAudit_Enterprise2019Plus_PrimaryReplica_DowngradesConfidenceToLow_AndAddsBasicAgCaveat() + { + // AG primary on Enterprise => Lite's advancedAgCount > 0 stand-in: confidence Low + Basic-AG caveat. + var recs = ViewerDataService.BuildEditionAuditRecommendations( + "Enterprise Edition (64-bit)", majorVersion: 16, cpuCount: 16, Array.Empty(), monthlyCost: 1000m, + agReplicaRole: "Primary", isHadrEnabled: true); + + var rec = Assert.Single(recs); + Assert.Equal("Enterprise Edition may not be required", rec.Finding); + Assert.Equal("High", rec.Severity); + Assert.Equal("Low", rec.Confidence); // downgraded from Medium by the AG signal + Assert.Equal(400m, rec.EstMonthlySavings); // savings still emitted for a primary/standalone + Assert.Contains("Basic Availability Groups", rec.Detail, StringComparison.Ordinal); + Assert.Contains("primary replica of an Always On Availability Group", rec.Detail, StringComparison.Ordinal); + Assert.Contains("basic-availability-groups", rec.Detail, StringComparison.Ordinal); // the MS Learn link + } + + [Fact] + public void EditionAudit_EnterprisePre2019_NoTde_PrimaryReplica_ConfidenceMedium_AndReviewFinding() + { + var recs = ViewerDataService.BuildEditionAuditRecommendations( + "Enterprise Edition (64-bit)", majorVersion: 13, cpuCount: 8, Array.Empty(), monthlyCost: 1000m, + agReplicaRole: "Primary", isHadrEnabled: true); + + var rec = Assert.Single(recs); + Assert.Equal("High", rec.Severity); + Assert.Equal("Medium", rec.Confidence); // downgraded from High by the AG signal + Assert.Equal("Enterprise Edition — review Availability Group requirements before downgrading", rec.Finding); + Assert.Equal(400m, rec.EstMonthlySavings); + Assert.Contains("Basic Availability Groups", rec.Detail, StringComparison.Ordinal); + } + + [Theory] + [InlineData(16)] // 2019+ + [InlineData(13)] // pre-2019 + public void EditionAudit_SecondaryReplica_EmitsSecondaryNote_NoSavings_RegardlessOfVersion(int majorVersion) + { + // A secondary replica short-circuits every version/TDE branch: one informational note, no savings. + var recs = ViewerDataService.BuildEditionAuditRecommendations( + "Enterprise Edition (64-bit)", majorVersion, cpuCount: 16, new[] { "Sales" }, monthlyCost: 1000m, + agReplicaRole: "Secondary", isHadrEnabled: true); + + var rec = Assert.Single(recs); + Assert.Equal("Licensing", rec.Category); + Assert.Equal("Low", rec.Severity); + Assert.Equal("High", rec.Confidence); + Assert.Equal("Enterprise Edition — Availability Group secondary replica", rec.Finding); + Assert.Null(rec.EstMonthlySavings); // decision belongs to the AG on the primary + Assert.Contains("secondary replica", rec.Detail, StringComparison.OrdinalIgnoreCase); + Assert.Contains("same SQL Server edition", rec.Detail, StringComparison.Ordinal); + // The secondary branch never carries the primary Basic-AG caveat. + Assert.DoesNotContain("primary replica of an Always On", rec.Detail, StringComparison.Ordinal); + } + + [Fact] + public void EditionAudit_PrimaryRole_ButHadrDisabled_NoCaveat_ConfidenceStaysStandalone() + { + // is_hadr_enabled is load-bearing: a "Primary" role with the Always On master switch off does not + // trigger the AG caveat/confidence downgrade (defensive — the two collected columns should agree). + var recs = ViewerDataService.BuildEditionAuditRecommendations( + "Enterprise Edition (64-bit)", majorVersion: 16, cpuCount: 16, Array.Empty(), monthlyCost: 1000m, + agReplicaRole: "Primary", isHadrEnabled: false); + + var rec = Assert.Single(recs); + Assert.Equal("Medium", rec.Confidence); // standalone confidence, not the AG-downgraded Low + Assert.DoesNotContain("Basic Availability Groups", rec.Detail, StringComparison.Ordinal); + } + + [Fact] + public void EditionAudit_NullOrBlankAgRole_TreatedAsStandalone() + { + // A missing collected role (older store / non-AG platform) falls back to Standalone — no caveat, no throw. + foreach (var role in new[] { "", " " }) + { + var recs = ViewerDataService.BuildEditionAuditRecommendations( + "Enterprise Edition (64-bit)", majorVersion: 16, cpuCount: 16, Array.Empty(), monthlyCost: 1000m, + agReplicaRole: role, isHadrEnabled: false); + + var rec = Assert.Single(recs); + Assert.Equal("Medium", rec.Confidence); + Assert.DoesNotContain("Basic Availability Groups", rec.Detail, StringComparison.Ordinal); + } + } + + // ── Check 1 data: TDE detection from collected database_config.is_encrypted ── + + [Fact] + public void SelectTdeDatabaseNames_PicksEncryptedOnlineUserDbs_ExcludesSystemOfflineAndUnencrypted() + { + var rows = new List + { + new() { DatabaseName = "Sales", IsEncrypted = true, StateDesc = "ONLINE" }, // TDE user db -> in + new() { DatabaseName = "Reports", IsEncrypted = false, StateDesc = "ONLINE" }, // not encrypted -> out + new() { DatabaseName = "Archive", IsEncrypted = true, StateDesc = "OFFLINE" }, // offline -> out + new() { DatabaseName = "tempdb", IsEncrypted = true, StateDesc = "ONLINE" }, // system db -> out + new() { DatabaseName = "Payroll", IsEncrypted = true, StateDesc = "ONLINE" }, // TDE user db -> in + }; + + var tde = ViewerDataService.SelectTdeDatabaseNames(rows); + + Assert.Equal(new[] { "Payroll", "Sales" }, tde); // ordered by name + } + + // ── Check 7: dev/test workload name match (reproduced from the collected database list) ── + + [Fact] + public void MatchDevTestDatabases_MatchesPatternsCaseInsensitive_ExcludesSystemDbs() + { + var names = new[] + { + "AdventureWorks_DEV", "SalesTest", "app_staging", "MyQaDb", "master", "tempdb", "Production", + }; + + var matches = ViewerDataService.MatchDevTestDatabases(names); + + Assert.Equal(new[] { "AdventureWorks_DEV", "SalesTest", "app_staging", "MyQaDb" }, matches); + Assert.DoesNotContain("Production", matches); + Assert.DoesNotContain("master", matches); + } + + // ── Check 5: compression candidates (reproduced from collected index_object_stats) ── + + [Fact] + public void BuildCompressionRecommendation_FlagsUncompressedRowstoreOver1Gb_WithStorageFraming() + { + var indexes = new[] + { + Index("Sales", "dbo", "Orders", "NONE", 30720m), // 30 GB uncompressed -> candidate + Index("Sales", "dbo", "OrderLines", "NONE", 30720m), // 30 GB uncompressed -> candidate + Index("Sales", "dbo", "Small", "NONE", 512m), // < 1 GB -> out + Index("Sales", "dbo", "Compressed", "PAGE", 40960m), // already compressed -> out + Index("Sales", "dbo", "Cci", "COLUMNSTORE", 40960m), // columnstore -> out + }; + + var rec = ViewerDataService.BuildCompressionRecommendation(indexes); + + Assert.NotNull(rec); + Assert.Equal("Storage", rec!.Category); + Assert.Equal("High", rec.Severity); // 60 GB total > 50 + Assert.Equal("High", rec.Confidence); + Assert.Equal("2 uncompressed object(s) >= 1GB (60.0GB total)", rec.Finding); + Assert.Contains("dbo.Orders", rec.Detail, StringComparison.Ordinal); + Assert.Null(rec.EstMonthlySavings); // compression candidates carry no dollar estimate (mirrors Lite) + } + + [Theory] + [InlineData(2048.0, "Low")] // 2 GB total + [InlineData(20480.0, "Medium")] // 20 GB total + [InlineData(61440.0, "High")] // 60 GB total + public void BuildCompressionRecommendation_SeverityBandsOnTotalGb(double reservedMb, string expectedSeverity) + { + var rec = ViewerDataService.BuildCompressionRecommendation(new[] + { + Index("Db", "dbo", "T", "NONE", (decimal)reservedMb), + }); + + Assert.NotNull(rec); + Assert.Equal(expectedSeverity, rec!.Severity); + } + + [Fact] + public void BuildCompressionRecommendation_ReturnsNull_WhenNothingUncompressedAndLarge() + { + var rec = ViewerDataService.BuildCompressionRecommendation(new[] + { + Index("Db", "dbo", "Small", "NONE", 100m), + Index("Db", "dbo", "Compressed", "PAGE", 99999m), + }); + + Assert.Null(rec); + } + + // ── RecommendationRow projection ── + + [Fact] + public void RecommendationRow_SeveritySortAndSavingsDisplay() + { + Assert.Equal(1, new RecommendationRow { Severity = "High" }.SeveritySort); + Assert.Equal(2, new RecommendationRow { Severity = "Medium" }.SeveritySort); + Assert.Equal(3, new RecommendationRow { Severity = "Low" }.SeveritySort); + Assert.Equal(4, new RecommendationRow { Severity = "Whatever" }.SeveritySort); + + Assert.Equal("$1,234", new RecommendationRow { EstMonthlySavings = 1234m }.EstMonthlySavingsDisplay); + Assert.Equal("", new RecommendationRow { EstMonthlySavings = null }.EstMonthlySavingsDisplay); + } + + // ── Collected time-series reads: SQL contract (representative case; not the ported arithmetic) ── + + [Fact] + public void EditionFactsSql_ReadsServerPropertiesBaseTable_WithCpuCount() + { + var sql = ViewerDataService.RecommendationsEditionFactsSql; + Assert.Contains("FROM server_properties", sql, StringComparison.Ordinal); + Assert.DoesNotContain("v_server_properties", sql, StringComparison.Ordinal); + Assert.Contains("edition", sql, StringComparison.Ordinal); + Assert.Contains("product_version", sql, StringComparison.Ordinal); + Assert.Contains("cpu_count", sql, StringComparison.Ordinal); + } + + [Fact] + public void EditionFactsSql_SelectsCollectedAgRoleAndHadrFlag() + { + // Read-path pin: the AG-aware edition branches need the collected ag_replica_role + is_hadr_enabled, so the + // edition-facts projection must SELECT them (ServerPropertiesCollector writes both; the base table exposes + // them — the FinOps Server Inventory read already reads the same two columns). + var sql = ViewerDataService.RecommendationsEditionFactsSql; + Assert.Contains("ag_replica_role", sql, StringComparison.Ordinal); + Assert.Contains("is_hadr_enabled", sql, StringComparison.Ordinal); + } + + [Fact] + public void ServerPropertiesCollector_WritesAgRoleAndHadrColumns() + { + // The collected columns the AG branches depend on exist in the generated server_properties DDL — proving the + // "Darling collects no AG state" premise false (ServerPropertiesCollector resolves ag_replica_role live from + // sys.dm_hadr_availability_replica_states and writes is_hadr_enabled from SERVERPROPERTY). + var serverProps = PgSchemaGenerator.CreateTable(ServerPropertiesCollector.Instance); + Assert.Contains("ag_replica_role", serverProps, StringComparison.Ordinal); + Assert.Contains("is_hadr_enabled", serverProps, StringComparison.Ordinal); + } + + [Fact] + public void MemoryP95Sql_PercentileOverMemoryStats() + { + var sql = ViewerDataService.RecommendationsMemoryP95Sql; + Assert.Contains("FROM v_memory_stats", sql, StringComparison.Ordinal); + Assert.Contains("PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY total_server_memory_mb)", sql, StringComparison.Ordinal); + Assert.Contains("COUNT(*)", sql, StringComparison.Ordinal); + } + + [Fact] + public void MaintenanceWindowSql_RunningJobsThatRanLongAtLeastThreeTimes() + { + var sql = ViewerDataService.RecommendationsMaintenanceWindowSql; + Assert.Contains("FROM v_running_jobs", sql, StringComparison.Ordinal); + Assert.Contains("SUM(CASE WHEN is_running_long THEN 1 ELSE 0 END)", sql, StringComparison.Ordinal); + Assert.Contains(">= 3", sql, StringComparison.Ordinal); + } + + [Fact] + public void StorageTierSql_PerDatabaseFileIoStall() + { + var sql = ViewerDataService.RecommendationsStorageTierSql; + Assert.Contains("FROM v_file_io_stats", sql, StringComparison.Ordinal); + Assert.Contains("SUM(delta_stall_read_ms)", sql, StringComparison.Ordinal); + Assert.Contains("SUM(delta_stall_write_ms)", sql, StringComparison.Ordinal); + Assert.Contains("HAVING SUM(delta_reads) > 1000", sql, StringComparison.Ordinal); + } + + [Fact] + public void ReservedCapacitySql_CpuMeanAndStddevStability() + { + var sql = ViewerDataService.RecommendationsReservedCapacitySql; + Assert.Contains("FROM v_cpu_utilization_stats", sql, StringComparison.Ordinal); + Assert.Contains("STDDEV(sqlserver_cpu_utilization)", sql, StringComparison.Ordinal); + Assert.Contains("HAVING COUNT(*) >= 24", sql, StringComparison.Ordinal); + } + + [Theory] + [InlineData(nameof(ViewerDataService.RecommendationsEditionFactsSql))] + [InlineData(nameof(ViewerDataService.RecommendationsMemoryP95Sql))] + [InlineData(nameof(ViewerDataService.RecommendationsCpuP95Sql))] + [InlineData(nameof(ViewerDataService.RecommendationsMaintenanceWindowSql))] + [InlineData(nameof(ViewerDataService.RecommendationsStorageTierSql))] + [InlineData(nameof(ViewerDataService.RecommendationsReservedCapacitySql))] + public void RecommendationReads_PgDialect_PositionalParams_NoTSqlNamedParams(string sqlName) + { + var sql = (string)typeof(ViewerDataService).GetField(sqlName)!.GetValue(null)!; + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + Assert.DoesNotContain("now(", sql.ToLowerInvariant()); + Assert.Contains("$1", sql, StringComparison.Ordinal); + } + + // ── Column parity: every column the reproduced checks reference exists in the generated collector DDL ── + + [Fact] + public void RecommendationChecks_ReadColumnsThatExistInTheGeneratedTables() + { + var serverProps = PgSchemaGenerator.CreateTable(ServerPropertiesCollector.Instance); + Assert.Equal("server_properties", ServerPropertiesCollector.Instance.TargetTable); + foreach (var col in new[] { "edition", "product_version", "cpu_count" }) + Assert.Contains(col, serverProps, StringComparison.Ordinal); + + var databaseConfig = PgSchemaGenerator.CreateTable(DatabaseConfigCollector.Instance); + Assert.Equal("database_config", DatabaseConfigCollector.Instance.TargetTable); + foreach (var col in new[] { "database_name", "is_encrypted", "state_desc" }) + Assert.Contains(col, databaseConfig, StringComparison.Ordinal); + + var indexStats = PgSchemaGenerator.CreateTable(IndexObjectStatsCollector.Instance); + foreach (var col in new[] { "data_compression_desc", "reserved_mb", "schema_name", "table_name" }) + Assert.Contains(col, indexStats, StringComparison.Ordinal); + + var memory = PgSchemaGenerator.CreateTable(MemoryStatsCollector.Instance); + Assert.Contains("total_server_memory_mb", memory, StringComparison.Ordinal); + + var cpu = PgSchemaGenerator.CreateTable(CpuUtilizationCollector.Instance); + Assert.Contains("sqlserver_cpu_utilization", cpu, StringComparison.Ordinal); + + var runningJobs = PgSchemaGenerator.CreateTable(RunningJobsCollector.Instance); + foreach (var col in new[] { "job_name", "current_duration_seconds", "avg_duration_seconds", "is_running_long" }) + Assert.Contains(col, runningJobs, StringComparison.Ordinal); + + var fileIo = PgSchemaGenerator.CreateTable(FileIoStatsCollector.Instance); + foreach (var col in new[] { "delta_reads", "delta_stall_read_ms", "delta_writes", "delta_stall_write_ms" }) + Assert.Contains(col, fileIo, StringComparison.Ordinal); + } + + private static IndexCleanupIndexInput Index(string db, string schema, string table, string compression, decimal reservedMb) => + new() + { + DatabaseName = db, + SchemaName = schema, + TableName = table, + DataCompressionDesc = compression, + ReservedMb = reservedMb, + }; +} diff --git a/Darling/Darling.Tests/ViewerFinOpsTests.cs b/Darling/Darling.Tests/ViewerFinOpsTests.cs new file mode 100644 index 000000000..ce36bfa86 --- /dev/null +++ b/Darling/Darling.Tests/ViewerFinOpsTests.cs @@ -0,0 +1,353 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the FinOps-tab reads (copy-parity program) against the Darling store contract, no live Postgres. +/// The nine ported sub-tabs' SQL is Lite's DuckDB SQL rewritten to Postgres; these tests guard the two +/// things a silent DuckDB→PG rewrite can break: (1) the load-bearing clauses/params survive the port, and +/// (2) every column the reads reference actually exists in the generated collector DDL (column parity). +/// The one substantive rewrite — Utilization/Server-Inventory reading the collected server_properties +/// base table instead of Lite's non-existent v_server_properties view — is pinned explicitly. +/// +public sealed class ViewerFinOpsSqlTests +{ + // ── Utilization ── + + [Fact] + public void UtilizationEfficiencySql_ReadsServerPropertiesBaseTable_NotTheMissingView() + { + var sql = ViewerDataService.UtilizationEfficiencySql; + + Assert.Contains("FROM v_cpu_utilization_stats", sql, StringComparison.Ordinal); + Assert.Contains("FROM v_memory_stats", sql, StringComparison.Ordinal); + /* Darling has no v_server_properties view (V4/V5 never created one); the core count comes from the + collected server_properties base table, bare-name resolved via the connection's Search Path. */ + Assert.Contains("FROM server_properties", sql, StringComparison.Ordinal); + Assert.DoesNotContain("v_server_properties", sql, StringComparison.Ordinal); + Assert.Contains("COALESCE(vcore_count, cpu_count)", sql, StringComparison.Ordinal); + Assert.Contains("PERCENTILE_CONT(0.95) WITHIN GROUP", sql, StringComparison.Ordinal); + Assert.Contains("LEFT JOIN server_info s ON true", sql, StringComparison.Ordinal); + } + + [Fact] + public void ProvisioningTrendSql_DailyCpuAndMemoryRatio() + { + var sql = ViewerDataService.ProvisioningTrendSql; + Assert.Contains("CAST(collection_time AS DATE)", sql, StringComparison.Ordinal); + Assert.Contains("FROM v_cpu_utilization_stats", sql, StringComparison.Ordinal); + Assert.Contains("FROM v_memory_stats", sql, StringComparison.Ordinal); + Assert.Contains("GROUP BY CAST(collection_time AS DATE)", sql, StringComparison.Ordinal); + } + + [Fact] + public void MemoryGrantEfficiencySql_ReadsColumnsThatExistInTheGeneratedTable() + { + var sql = ViewerDataService.MemoryGrantEfficiencySql; + Assert.Contains("FROM v_memory_grant_stats", sql, StringComparison.Ordinal); + + var ddl = PgSchemaGenerator.CreateTable(MemoryGrantsCollector.Instance); + Assert.Equal("memory_grant_stats", MemoryGrantsCollector.Instance.TargetTable); + foreach (var col in new[] + { + "granted_memory_mb", "used_memory_mb", "grantee_count", "waiter_count", + "timeout_error_count_delta", "forced_grant_count_delta", + }) + { + Assert.Contains(col, ddl, StringComparison.Ordinal); + } + } + + // ── Database Resources / Workload ── + + [Fact] + public void DatabaseResourceUsageSql_FullJoinsWorkloadAndIo_OverTheWindow() + { + var sql = ViewerDataService.DatabaseResourceUsageSql; + Assert.Contains("FROM v_query_stats", sql, StringComparison.Ordinal); + Assert.Contains("FROM v_file_io_stats", sql, StringComparison.Ordinal); + Assert.Contains("FULL JOIN io i", sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $2", sql, StringComparison.Ordinal); + } + + [Fact] + public void ApplicationConnectionsSql_ReadsColumnsThatExistInTheGeneratedSessionTable() + { + var sql = ViewerDataService.ApplicationConnectionsSql; + Assert.Contains("FROM v_session_stats", sql, StringComparison.Ordinal); + Assert.Contains("GROUP BY program_name", sql, StringComparison.Ordinal); + + /* The read surfaces the collected per-app resource + session-status metrics (DC-F1), not just + connection counts. Every source column it aggregates must exist in the generated session table. */ + var sourceColumns = new[] + { + "program_name", "connection_count", "running_count", "sleeping_count", "dormant_count", + "total_cpu_time_ms", "total_reads", "total_writes", "total_logical_reads" + }; + + var ddl = PgSchemaGenerator.CreateTable(SessionStatsCollector.Instance); + Assert.Equal("session_stats", SessionStatsCollector.Instance.TargetTable); + foreach (var col in sourceColumns) + { + Assert.Contains(col, sql, StringComparison.Ordinal); + Assert.Contains(col, ddl, StringComparison.Ordinal); + } + + /* AVG + MAX aggregates for each metric family are all present. */ + foreach (var agg in new[] + { + "AVG(running_count)", "MAX(running_count)", + "AVG(sleeping_count)", "MAX(sleeping_count)", + "AVG(dormant_count)", "MAX(dormant_count)", + "AVG(total_cpu_time_ms)", "MAX(total_cpu_time_ms)", + "AVG(total_reads)", "MAX(total_reads)", + "AVG(total_writes)", "MAX(total_writes)", + "AVG(total_logical_reads)", "MAX(total_logical_reads)" + }) + { + Assert.Contains(agg, sql, StringComparison.Ordinal); + } + } + + [Fact] + public void TopResourceConsumersSql_ParameterizeTheLimit() + { + Assert.Contains("LIMIT $3", ViewerDataService.TopResourceConsumersByTotalSql, StringComparison.Ordinal); + Assert.Contains("LIMIT $3", ViewerDataService.TopResourceConsumersByAvgSql, StringComparison.Ordinal); + Assert.Contains("HAVING SUM(delta_execution_count) > 0", ViewerDataService.TopResourceConsumersByAvgSql, StringComparison.Ordinal); + } + + [Fact] + public void WaitCategorySummarySql_UsesIlikeCategoryCase() + { + var sql = ViewerDataService.WaitCategorySummarySql; + Assert.Contains("FROM v_wait_stats", sql, StringComparison.Ordinal); + Assert.Contains("wait_type ILIKE 'PAGEIOLATCH%'", sql, StringComparison.Ordinal); + Assert.Contains("wait_type ILIKE 'LCK_M_%'", sql, StringComparison.Ordinal); + Assert.Contains("ROW_NUMBER() OVER (PARTITION BY category ORDER BY wait_time_ms DESC)", sql, StringComparison.Ordinal); + } + + [Fact] + public void ExpensiveQueriesSql_GroupsByHandleAndText_LimitParam() + { + var sql = ViewerDataService.ExpensiveQueriesSql; + Assert.Contains("FROM v_query_stats", sql, StringComparison.Ordinal); + Assert.Contains("LEFT(query_text, 200)", sql, StringComparison.Ordinal); + Assert.Contains("GROUP BY", sql, StringComparison.Ordinal); + Assert.Contains("sql_handle", sql, StringComparison.Ordinal); + Assert.Contains("LIMIT $3", sql, StringComparison.Ordinal); + /* The stored statement-level plan is aggregated per group so "View Plan" opens it (Darling stores + plans) — restores the plan action the port had wrongly dropped as "no live SQL". */ + Assert.Contains("MAX(query_plan_xml)", sql, StringComparison.Ordinal); + } + + [Fact] + public void HighImpactQueriesSql_ReadsBaseTableForCorrelatedSampleText() + { + var sql = ViewerDataService.HighImpactQueriesSql; + /* Aggregates to query_hash level; the correlated sample-text subqueries read the query_stats base + table (as Lite does), not the view. */ + Assert.Contains("FROM query_stats AS qs", sql, StringComparison.Ordinal); + Assert.Contains("FROM query_stats qs2", sql, StringComparison.Ordinal); + Assert.Contains("GROUP BY query_hash", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY qs2.delta_execution_count DESC NULLS LAST", sql, StringComparison.Ordinal); + /* The stored statement-level plan is fetched by the correlated subquery so "View Plan" opens it. */ + Assert.Contains("qs2.query_plan_xml", sql, StringComparison.Ordinal); + + var ddl = PgSchemaGenerator.CreateTable(QueryStatsCollector.Instance); + foreach (var col in new[] + { + "query_hash", "query_text", "sql_handle", "database_name", "max_grant_kb", "query_plan_xml", + "delta_execution_count", "delta_worker_time", "delta_elapsed_time", + "delta_logical_reads", "delta_logical_writes", "delta_physical_reads", + }) + { + Assert.Contains(col, ddl, StringComparison.Ordinal); + } + } + + // ── Storage (Database Sizes / Storage Growth / object drill / tempdb / idle) ── + + [Fact] + public void DatabaseSizeReads_ReadColumnsThatExistInTheGeneratedTable() + { + Assert.Contains("FROM v_database_size_stats", ViewerDataService.DatabaseSizeLatestSql, StringComparison.Ordinal); + Assert.Contains("LIMIT $2", ViewerDataService.DatabaseSizeSummarySql, StringComparison.Ordinal); + + var ddl = PgSchemaGenerator.CreateTable(DatabaseSizeStatsCollector.Instance); + Assert.Equal("database_size_stats", DatabaseSizeStatsCollector.Instance.TargetTable); + foreach (var col in new[] + { + "file_type_desc", "file_name", "total_size_mb", "used_size_mb", "volume_mount_point", + "volume_total_mb", "volume_free_mb", "recovery_model_desc", "auto_growth_mb", + "is_percent_growth", "growth_pct", "vlf_count", + }) + { + Assert.Contains(col, ddl, StringComparison.Ordinal); + } + } + + [Fact] + public void StorageGrowthSql_ComparesLatestTo7dAnd30dAgo() + { + var sql = ViewerDataService.StorageGrowthSql; + Assert.Contains("collection_time <= $2", sql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $3", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY growth_30d_mb DESC", sql, StringComparison.Ordinal); + } + + [Fact] + public void ObjectGrowthReads_RankTopN_AndBuildDailySeries() + { + Assert.Contains("FROM v_index_object_stats", ViewerDataService.ObjectGrowthSummarySql, StringComparison.Ordinal); + Assert.Contains("LIMIT $4", ViewerDataService.ObjectGrowthSummarySql, StringComparison.Ordinal); + Assert.Contains("date_trunc('day', ios.collection_time)", ViewerDataService.ObjectGrowthSeriesSql, StringComparison.Ordinal); + Assert.Contains("LIMIT $4", ViewerDataService.ObjectGrowthSeriesSql, StringComparison.Ordinal); + Assert.Contains("GREATEST(last_user_seek, last_user_scan, last_user_lookup, last_user_update)", + ViewerDataService.ObjectIndexDetailSql, StringComparison.Ordinal); + } + + [Fact] + public void IdleDatabasesSql_UsesExceptToFindZeroActivityDbs() + { + var sql = ViewerDataService.IdleDatabasesSql; + Assert.Contains("FROM v_database_size_stats", sql, StringComparison.Ordinal); + Assert.Contains("FROM v_query_stats", sql, StringComparison.Ordinal); + Assert.Contains("COALESCE(a.total_executions, 0) = 0", sql, StringComparison.Ordinal); + } + + [Fact] + public void TempdbSummarySql_LatestVsPeak_ReadsColumnsThatExist() + { + Assert.Contains("FROM v_tempdb_stats", ViewerDataService.TempdbSummarySql, StringComparison.Ordinal); + + var ddl = PgSchemaGenerator.CreateTable(TempDbStatsCollector.Instance); + foreach (var col in new[] + { + "user_object_reserved_mb", "internal_object_reserved_mb", "version_store_reserved_mb", "total_reserved_mb", + }) + { + Assert.Contains(col, ddl, StringComparison.Ordinal); + } + } + + // ── Locking ── + + [Fact] + public void IndexLockingReads_AllAndByDbVariants_ReadColumnsThatExist() + { + Assert.Contains("LIMIT $2", ViewerDataService.IndexLockingAllSql, StringComparison.Ordinal); + Assert.Contains("AND database_name = $2", ViewerDataService.IndexLockingByDbSql, StringComparison.Ordinal); + Assert.Contains("LIMIT $3", ViewerDataService.IndexLockingByDbSql, StringComparison.Ordinal); + Assert.Contains("FROM v_index_object_stats", ViewerDataService.IndexLockingDatabasesSql, StringComparison.Ordinal); + + var ddl = PgSchemaGenerator.CreateTable(IndexObjectStatsCollector.Instance); + Assert.Equal("index_object_stats", IndexObjectStatsCollector.Instance.TargetTable); + foreach (var col in new[] + { + "reserved_mb", "used_mb", "total_rows", "row_lock_wait_in_ms", "page_lock_wait_in_ms", + "page_latch_wait_in_ms", "page_io_latch_wait_in_ms", "index_lock_promotion_count", + "user_seeks", "user_updates", "last_user_seek", + }) + { + Assert.Contains(col, ddl, StringComparison.Ordinal); + } + } + + // ── Server Inventory ── + + [Fact] + public void ServerInventorySql_JoinsLatestServerPropertiesToRegistry_WithInventoryFieldsAndCost() + { + var sql = ViewerDataService.ServerInventorySql; + Assert.Contains("DISTINCT ON (server_id)", sql, StringComparison.Ordinal); + Assert.Contains("FROM server_properties", sql, StringComparison.Ordinal); + Assert.Contains("JOIN servers s ON s.server_id = sp.server_id", sql, StringComparison.Ordinal); + Assert.DoesNotContain("v_server_properties", sql, StringComparison.Ordinal); + + /* The restored inventory fields (start time / host OS / AG role) + the per-server budget from the + registry are all selected (fix for the earlier drop — they are collected now, not live-only). */ + Assert.Contains("sqlserver_start_time", sql, StringComparison.Ordinal); + Assert.Contains("host_os_version", sql, StringComparison.Ordinal); + Assert.Contains("ag_replica_role", sql, StringComparison.Ordinal); + Assert.Contains("monthly_cost_usd", sql, StringComparison.Ordinal); + + /* Every server_properties column the inventory read surfaces exists in the generated table — the + three restored ones included, so the shared collector + Darling schema actually carry them. */ + var ddl = PgSchemaGenerator.CreateTable(ServerPropertiesCollector.Instance); + Assert.Equal("server_properties", ServerPropertiesCollector.Instance.TargetTable); + foreach (var col in new[] + { + "edition", "product_version", "product_level", "product_update_level", "engine_edition", + "cpu_count", "vcore_count", "physical_memory_mb", "socket_count", "cores_per_socket", + "is_hadr_enabled", "is_clustered", + "sqlserver_start_time", "host_os_version", "ag_replica_role", + }) + { + Assert.Contains(col, ddl, StringComparison.Ordinal); + } + } + + [Fact] + public void ServerMetricsSql_ReadsCpuStorageIdleProvisioning() + { + var sql = ViewerDataService.ServerMetricsSql; + Assert.Contains("FROM v_cpu_utilization_stats", sql, StringComparison.Ordinal); + Assert.Contains("FROM v_database_size_stats", sql, StringComparison.Ordinal); + Assert.Contains("FROM v_query_stats", sql, StringComparison.Ordinal); + Assert.Contains("EXCEPT", sql, StringComparison.Ordinal); + Assert.Contains("OVER_PROVISIONED", sql, StringComparison.Ordinal); + Assert.Contains("UNDER_PROVISIONED", sql, StringComparison.Ordinal); + } + + // ── PG dialect guard across every FinOps read ── + + [Theory] + [InlineData(nameof(ViewerDataService.UtilizationEfficiencySql))] + [InlineData(nameof(ViewerDataService.ProvisioningTrendSql))] + [InlineData(nameof(ViewerDataService.MemoryGrantEfficiencySql))] + [InlineData(nameof(ViewerDataService.DatabaseResourceUsageSql))] + [InlineData(nameof(ViewerDataService.ApplicationConnectionsSql))] + [InlineData(nameof(ViewerDataService.TopResourceConsumersByTotalSql))] + [InlineData(nameof(ViewerDataService.TopResourceConsumersByAvgSql))] + [InlineData(nameof(ViewerDataService.WaitCategorySummarySql))] + [InlineData(nameof(ViewerDataService.ExpensiveQueriesSql))] + [InlineData(nameof(ViewerDataService.HighImpactQueriesSql))] + [InlineData(nameof(ViewerDataService.DatabaseSizeLatestSql))] + [InlineData(nameof(ViewerDataService.DatabaseSizeSummarySql))] + [InlineData(nameof(ViewerDataService.StorageGrowthSql))] + [InlineData(nameof(ViewerDataService.ObjectGrowthSummarySql))] + [InlineData(nameof(ViewerDataService.ObjectGrowthSeriesSql))] + [InlineData(nameof(ViewerDataService.ObjectIndexDetailSql))] + [InlineData(nameof(ViewerDataService.IdleDatabasesSql))] + [InlineData(nameof(ViewerDataService.TempdbSummarySql))] + [InlineData(nameof(ViewerDataService.IndexLockingAllSql))] + [InlineData(nameof(ViewerDataService.IndexLockingByDbSql))] + [InlineData(nameof(ViewerDataService.IndexLockingDatabasesSql))] + [InlineData(nameof(ViewerDataService.ServerMetricsSql))] + public void FinOpsReads_PgDialect_PositionalParams_NoNamedParams_NoTSqlNLiterals(string sqlName) + { + var sql = (string)typeof(ViewerDataService).GetField(sqlName)!.GetValue(null)!; + + /* Postgres positional params only; no T-SQL @named params leaking from the SQL-Server origin, and + no bare now() (the reads window on a C#-supplied cutoff). (A DuckDB->PG port never carries N'' + unicode literals — Lite's source uses plain '' — and a naive "N'" scan false-matches wait-type + names ending in N like ASYNC_IO_COMPLETION, so that check is deliberately omitted here.) */ + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + Assert.DoesNotContain("now(", sql.ToLowerInvariant()); + Assert.Contains("$1", sql, StringComparison.Ordinal); + } +} diff --git a/Darling/Darling.Tests/ViewerFleetRollupTests.cs b/Darling/Darling.Tests/ViewerFleetRollupTests.cs new file mode 100644 index 000000000..a813a2d0e --- /dev/null +++ b/Darling/Darling.Tests/ViewerFleetRollupTests.cs @@ -0,0 +1,538 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the Fleet-wide NOC roll-up's cross-server totals read () +/// against the Darling store contract — no live Postgres. This is the signature capability Darling's single +/// central store enables and the Dashboard/Lite physically cannot do: one aggregate over EVERY server's rows, +/// keyed by server_id. The pins are the load-bearing clauses — the two blocking sources counted per +/// server (a GROUP BY), the FULL OUTER JOIN lining them up, the per-server XE→DMV fallback CASE, the +/// cross-server deadlock COUNT, the two-sided window on every source, and the Pg dialect (no per-server +/// filter, positional params). +/// +public sealed class ViewerFleetRollupSqlTests +{ + [Fact] + public void FleetTotalsSql_BlockingCountsBothSourcesPerServer_ThenAppliesXeDmvFallback() + { + var sql = ViewerDataService.FleetTotalsSql; + + /* Each source counted per server (a cross-server GROUP BY), FULL OUTER JOIN so a server present in + only one source still appears, then Lite's per-server XE-preferred / DMV-fallback rule before the + fleet SUM — so an RDS server with only DMV snapshots still counts and an XE server is never + double-counted. */ + Assert.Contains("FROM v_blocked_process_reports", sql, StringComparison.Ordinal); + Assert.Contains("FROM v_dmv_blocking_snapshots", sql, StringComparison.Ordinal); + Assert.Contains("GROUP BY server_id", sql, StringComparison.Ordinal); + Assert.Contains("FULL OUTER JOIN", sql, StringComparison.Ordinal); + Assert.Contains("COALESCE(xe.server_id, dmv.server_id)", sql, StringComparison.Ordinal); + Assert.Contains("SUM(CASE WHEN per_server.xe_count > 0 THEN per_server.xe_count ELSE per_server.dmv_count END)", sql, StringComparison.Ordinal); + } + + [Fact] + public void FleetTotalsSql_DeadlocksAreACrossServerCount_OverTheWindow() + { + var sql = ViewerDataService.FleetTotalsSql; + Assert.Contains("FROM v_deadlocks", sql, StringComparison.Ordinal); + Assert.Contains("COUNT(*)", sql, StringComparison.Ordinal); + Assert.Contains("deadlock_time >= $1", sql, StringComparison.Ordinal); + Assert.Contains("deadlock_time <= $2", sql, StringComparison.Ordinal); + } + + [Fact] + public void FleetTotalsSql_WindowsEverySource_OnBothBounds() + { + var sql = ViewerDataService.FleetTotalsSql; + /* Both blocking sources and the deadlock read are bounded on BOTH sides so the totals honor a + settable window exactly (the caller passes the same one-hour window the cards use). */ + Assert.Contains("event_time >= $1", sql, StringComparison.Ordinal); + Assert.Contains("event_time <= $2", sql, StringComparison.Ordinal); + Assert.Equal(2, CountOccurrences(sql, "event_time >= $1")); // one per blocking source + Assert.Equal(2, CountOccurrences(sql, "event_time <= $2")); + } + + [Fact] + public void FleetTotalsSql_IsPgDialect_PositionalWindowParams_NoBareNow_NoNLiterals_NoPerServerFilter() + { + var sql = ViewerDataService.FleetTotalsSql; + Assert.DoesNotContain("now(", sql.ToLowerInvariant()); + Assert.DoesNotContain("N'", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + Assert.Contains("$1", sql, StringComparison.Ordinal); + Assert.Contains("$2", sql, StringComparison.Ordinal); + /* The fleet read spans ALL servers — no per-server scoping predicate (unlike the per-server card + reads, whose signature is "WHERE server_id = $1"). */ + Assert.DoesNotContain("server_id = $", sql, StringComparison.Ordinal); + } + + [Fact] + public void FleetTotals_ReferenceColumnsThatExistInTheGeneratedSourceTables() + { + /* The window is applied on event_time (both blocking sources) and deadlock_time (deadlocks); the + per-server GROUP BY keys on server_id, the universal store key on every collect table. */ + Assert.Contains("event_time", PgSchemaGenerator.CreateTable(BlockedProcessReportCollector.Instance), StringComparison.Ordinal); + Assert.Contains("event_time", PgSchemaGenerator.CreateTable(DmvBlockingSnapshotCollector.Instance), StringComparison.Ordinal); + Assert.Contains("deadlock_time", PgSchemaGenerator.CreateTable(DeadlocksCollector.Instance), StringComparison.Ordinal); + } + + [Fact] + public void PassthroughViews_ForEveryFleetSource_ExistInTheMigrations() + { + var allMigrationSql = string.Concat(PgMigrations.Scripts.Select(m => m.Sql)); + foreach (var view in new[] { "v_blocked_process_reports", "v_dmv_blocking_snapshots", "v_deadlocks" }) + { + Assert.Contains(view, allMigrationSql, StringComparison.Ordinal); + } + } + + private static int CountOccurrences(string haystack, string needle) + { + var count = 0; + var index = 0; + while ((index = haystack.IndexOf(needle, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += needle.Length; + } + return count; + } +} + +/// +/// The fleet roll-up's pure C# reduction () — no Postgres. Every band +/// decision REUSES #1426's card banding ( + freshness), +/// so these tests prove the fleet counts / ranking never invent a new band: they classify the same cards the +/// Overview renders. Covers the band counts, the servers-with-collection-failures roll-up, the worst-first +/// ranking order (offline > critical > warning, then by how many metrics are bad), the cap + overflow, +/// the all-clear state, and the totals passthrough. +/// +public sealed class ViewerFleetRollupBuilderTests +{ + private static ServerSummaryItem Healthy(string name, int id) => + new() { DisplayName = name, ServerId = id, IsOnline = true }; + + private static ServerSummaryItem Critical(string name, int id, int deadlocks = 1) => + new() { DisplayName = name, ServerId = id, IsOnline = true, DeadlockCount = deadlocks }; + + private static ServerSummaryItem WarningCollectors(string name, int id, int failed = 1) => + new() { DisplayName = name, ServerId = id, IsOnline = true, FailedCollectorCount = failed }; + + private static ServerSummaryItem Stale(string name, int id) => + new() { DisplayName = name, ServerId = id, IsOnline = true, HasCollectorErrors = true }; + + private static ServerSummaryItem Offline(string name, int id) => + new() { DisplayName = name, ServerId = id, IsOnline = false }; + + private static ServerSummaryItem Awaiting(string name, int id) => + new() { DisplayName = name, ServerId = id, IsOnline = null, AwaitingFirstCollection = true }; + + private static readonly FleetTotals NoTotals = new(); + + // ── ClassifyBand: the fleet band is the card's banding, collapsed to one label ───────────────── + + [Fact] + public void ClassifyBand_OfflineCollection_IsOffline() + { + Assert.Equal(FleetHealthBand.Offline, FleetRollup.ClassifyBand(Offline("s", 1))); + } + + [Fact] + public void ClassifyBand_AwaitingFirstCollection_IsWarning_NeverOfflineOrHealthy() + { + /* A queued-during-bootstrap server is attention-worthy (never Healthy) but truthfully + amber, never the red Offline overlay (24-server field incident, 2026-07-17). */ + Assert.Equal(FleetHealthBand.Warning, FleetRollup.ClassifyBand(Awaiting("s", 1))); + } + + [Fact] + public void ClassifyBand_CriticalMetric_IsCritical() + { + // A deadlock in the window is the card's Critical (DeadlockSeverity) → fleet Critical. + Assert.Equal(FleetHealthBand.Critical, FleetRollup.ClassifyBand(Critical("s", 1))); + // CPU >= 95 is also the card's Critical. + Assert.Equal(FleetHealthBand.Critical, + FleetRollup.ClassifyBand(new ServerSummaryItem { IsOnline = true, CpuPercent = 96 })); + } + + [Fact] + public void ClassifyBand_WarningMetric_IsWarning() + { + // A FAILING collector is the card's Warning (CollectorSeverity) — this is the reuse of + // CollectorHealthRow.HealthStatus that flows through the card's FailedCollectorCount. + Assert.Equal(FleetHealthBand.Warning, FleetRollup.ClassifyBand(WarningCollectors("s", 1))); + } + + [Fact] + public void ClassifyBand_StaleCollection_IsWarning() + { + // Online but collection has gone stale (HasCollectorErrors) → the card's amber → fleet Warning, + // even with every metric calm. + Assert.Equal(FleetHealthBand.Warning, FleetRollup.ClassifyBand(Stale("s", 1))); + } + + [Fact] + public void ClassifyBand_OnlineAndCalm_IsHealthy() + { + Assert.Equal(FleetHealthBand.Healthy, FleetRollup.ClassifyBand(Healthy("s", 1))); + } + + // ── Band counts + servers-with-failures ──────────────────────────────────────────────────────── + + [Fact] + public void Build_CountsServersByBand() + { + var summaries = new[] + { + Healthy("h1", 1), Healthy("h2", 2), Healthy("h3", 3), + WarningCollectors("w1", 4), Stale("w2", 5), + Critical("c1", 6), + Offline("o1", 7), + }; + + var rollup = FleetRollup.Build(summaries, NoTotals); + + Assert.Equal(7, rollup.TotalServers); + Assert.Equal(3, rollup.HealthyCount); + Assert.Equal(2, rollup.WarningCount); + Assert.Equal(1, rollup.CriticalCount); + Assert.Equal(1, rollup.OfflineCount); + } + + [Fact] + public void Build_CountsServersWithCollectionFailures_FromTheCardsFailingCount() + { + var summaries = new[] + { + WarningCollectors("w1", 1, failed: 2), // 2 failing collectors → counts once + Critical("c1", 2, deadlocks: 1), // critical but no failing collector → not counted + new ServerSummaryItem { DisplayName = "c2", ServerId = 3, IsOnline = true, DeadlockCount = 1, FailedCollectorCount = 3 }, // both + Healthy("h1", 4), + }; + + var rollup = FleetRollup.Build(summaries, NoTotals); + + // Servers whose card FailedCollectorCount > 0: w1 and c2 → 2. + Assert.Equal(2, rollup.ServersWithCollectionFailures); + } + + // ── Worst-first ranking ───────────────────────────────────────────────────────────────────────── + + [Fact] + public void Build_RanksProblemServersWorstFirst_OfflineThenCriticalThenWarning() + { + var summaries = new[] + { + Healthy("h1", 1), + WarningCollectors("w1", 2), + Critical("c1", 3), + Offline("o1", 4), + }; + + var rollup = FleetRollup.Build(summaries, NoTotals); + + // Healthy is excluded from the "needs attention" ranking; problems ordered worst-first. + Assert.Equal(3, rollup.WorstServers.Count); + Assert.Equal(new[] { "o1", "c1", "w1" }, rollup.WorstServers.Select(w => w.DisplayName).ToArray()); + Assert.Equal(FleetHealthBand.Offline, rollup.WorstServers[0].Band); + Assert.Equal(FleetHealthBand.Critical, rollup.WorstServers[1].Band); + Assert.Equal(FleetHealthBand.Warning, rollup.WorstServers[2].Band); + Assert.True(rollup.HasProblems); + } + + [Fact] + public void Build_WithinTheSameBand_RanksByHowManyMetricsAreBad() + { + // Two Critical servers: one with two critical metrics (CPU + deadlock), one with a single deadlock. + var twoCritical = new ServerSummaryItem { DisplayName = "worse", ServerId = 1, IsOnline = true, CpuPercent = 96, DeadlockCount = 1 }; + var oneCritical = new ServerSummaryItem { DisplayName = "milder", ServerId = 2, IsOnline = true, DeadlockCount = 1 }; + + var rollup = FleetRollup.Build(new[] { oneCritical, twoCritical }, NoTotals); + + Assert.Equal(new[] { "worse", "milder" }, rollup.WorstServers.Select(w => w.DisplayName).ToArray()); + Assert.True(FleetRollup.FleetHealthScore(twoCritical) > FleetRollup.FleetHealthScore(oneCritical)); + } + + [Fact] + public void Build_CapsTheRankingAtWorstCount_AndReportsTheOverflow() + { + var summaries = Enumerable.Range(1, 8).Select(i => Critical($"c{i}", i, deadlocks: 1)).ToArray(); + + var rollup = FleetRollup.Build(summaries, NoTotals, worstCount: 3); + + Assert.Equal(3, rollup.WorstServers.Count); + Assert.Equal(5, rollup.AdditionalProblemCount); // 8 problems − 3 shown + Assert.Equal("+5 more need attention", rollup.AdditionalProblemText); + } + + [Fact] + public void Build_AllHealthy_HasNoProblems_AndAnAllClearLine() + { + var summaries = new[] { Healthy("h1", 1), Healthy("h2", 2) }; + + var rollup = FleetRollup.Build(summaries, NoTotals); + + Assert.False(rollup.HasProblems); + Assert.Empty(rollup.WorstServers); + Assert.Equal(0, rollup.AdditionalProblemCount); + Assert.Equal("", rollup.AdditionalProblemText); + Assert.Equal("All 2 servers healthy", rollup.AllHealthyText); + } + + [Fact] + public void FleetHealthScore_BandRankDominates_AcrossBands() + { + var offline = FleetRollup.FleetHealthScore(Offline("o", 1)); + var critical = FleetRollup.FleetHealthScore(Critical("c", 2)); + var warning = FleetRollup.FleetHealthScore(WarningCollectors("w", 3)); + var healthy = FleetRollup.FleetHealthScore(Healthy("h", 4)); + + Assert.True(offline > critical); + Assert.True(critical > warning); + Assert.True(warning > healthy); + } + + // ── Totals passthrough + empty fleet ──────────────────────────────────────────────────────────── + + [Fact] + public void Build_PassesTheCrossServerTotalsThrough() + { + var totals = new FleetTotals { TotalBlockingEvents = 42, TotalDeadlocks = 7 }; + + var rollup = FleetRollup.Build(new[] { Healthy("h1", 1) }, totals); + + Assert.Equal(42, rollup.TotalBlockingEvents); + Assert.Equal(7, rollup.TotalDeadlocks); + } + + [Fact] + public void Build_NoServers_StillCarriesTotals_ButHasNoRanking() + { + var totals = new FleetTotals { TotalBlockingEvents = 5, TotalDeadlocks = 2 }; + + var rollup = FleetRollup.Build(Array.Empty(), totals); + + Assert.Equal(0, rollup.TotalServers); + Assert.Empty(rollup.WorstServers); + Assert.False(rollup.HasProblems); + Assert.Equal(5, rollup.TotalBlockingEvents); + Assert.Equal(2, rollup.TotalDeadlocks); + } + + // ── Reason text + ranked-row display ──────────────────────────────────────────────────────────── + + [Fact] + public void BuildReason_Offline_SaysSo() + { + Assert.Equal("Offline — no recent collection", FleetRollup.BuildReason(Offline("s", 1))); + } + + [Fact] + public void BuildReason_AwaitingFirstCollection_SaysSo() + { + Assert.Equal("Awaiting first collection", FleetRollup.BuildReason(Awaiting("s", 1))); + } + + [Fact] + public void BuildReason_NamesTheBadMetrics_FromTheCardsOwnDisplays() + { + var s = new ServerSummaryItem + { + IsOnline = true, + CpuPercent = 96, // CPU 96% + BlockingCount = 6, MaxBlockingWaitMs = 70000, + DeadlockCount = 2, + FailedCollectorCount = 1, + }; + + var reason = FleetRollup.BuildReason(s); + + Assert.Contains("CPU 96%", reason, StringComparison.Ordinal); + Assert.Contains("Blocking 6", reason, StringComparison.Ordinal); + Assert.Contains("Deadlocks 2", reason, StringComparison.Ordinal); + Assert.Contains("1 collector failing", reason, StringComparison.Ordinal); + } + + [Fact] + public void BuildReason_StaleOnly_SaysCollectionStale() + { + Assert.Equal("collection stale", FleetRollup.BuildReason(Stale("s", 1))); + } + + [Fact] + public void RankedServer_LabelAndBrush_MatchTheBand() + { + var rollup = FleetRollup.Build(new[] { Critical("c1", 1), Offline("o1", 2), WarningCollectors("w1", 3) }, NoTotals); + + var offline = rollup.WorstServers.Single(w => w.DisplayName == "o1"); + var critical = rollup.WorstServers.Single(w => w.DisplayName == "c1"); + var warning = rollup.WorstServers.Single(w => w.DisplayName == "w1"); + + Assert.Equal("Offline", offline.BandLabel); + Assert.Equal("#FF888888", offline.BandBrush.Color.ToString()); // grey (the card's Unknown palette) + Assert.Equal("Critical", critical.BandLabel); + Assert.Equal("#FFE57373", critical.BandBrush.Color.ToString()); // red + Assert.Equal("Warning", warning.BandLabel); + Assert.Equal("#FFFFD54F", warning.BandBrush.Color.ToString()); // amber + } + + // ── Header / total display helpers (singular / plural) ────────────────────────────────────────── + + [Fact] + public void DisplayHelpers_Pluralize() + { + var one = FleetRollup.Build(new[] { Healthy("h1", 1) }, NoTotals); + Assert.Equal("Monitoring 1 server", one.MonitoringText); + Assert.Equal("All 1 server healthy", one.AllHealthyText); + + var many = FleetRollup.Build(new[] + { + Healthy("h1", 1), + new ServerSummaryItem { DisplayName = "w1", ServerId = 2, IsOnline = true, FailedCollectorCount = 1 }, + }, NoTotals); + Assert.Equal("Monitoring 2 servers", many.MonitoringText); + Assert.Equal("1 server", many.CollectionFailuresText); + + var twoFailing = FleetRollup.Build(new[] + { + new ServerSummaryItem { DisplayName = "w1", ServerId = 1, IsOnline = true, FailedCollectorCount = 1 }, + new ServerSummaryItem { DisplayName = "w2", ServerId = 2, IsOnline = true, FailedCollectorCount = 2 }, + }, NoTotals); + Assert.Equal("2 servers", twoFailing.CollectionFailuresText); + } +} + +/// +/// Gated (DARLING_TEST_PG) live round-trip for the Fleet-wide totals read against planted rows across +/// MULTIPLE servers — the cross-server aggregate the Dashboard/Lite cannot run. Verifies the per-server +/// XE→DMV blocking fallback is applied PER server before the fleet SUM (an XE server counts its XE rows; a +/// DMV-only / RDS server counts its DMV rows) and that deadlocks total across servers. Uses a fixed +/// historical sentinel window so only the planted rows fall inside it, making the fleet-wide (un-scoped) +/// read deterministic regardless of any real data in the store; negative sentinel server_ids, cleaned up in +/// finally. Shares the serialized "live-postgres" collection. +/// +[Collection("live-postgres")] +public sealed class ViewerFleetRollupLivePostgresTests +{ + private const int XeServerId = -939301; // has XE reports (XE preferred) + private const int DmvServerId = -939302; // DMV snapshots only (fallback) + private const string ServerName = "viewer-fleet-e2e"; + + /* A fixed historical window no real collected row occupies, so the fleet-wide read sees only the + planted rows. */ + private static readonly DateTime WindowAnchor = new(2001, 3, 14, 9, 0, 0, DateTimeKind.Unspecified); + + [Fact] + public async Task FleetTotals_SumAcrossServers_WithPerServerXeDmvFallback_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live fleet-totals test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteFleetRowsAsync(connection); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var at = WindowAnchor; + + /* Server XE: two XE blocked-process reports + one DMV snapshot in the window → the fallback + prefers XE, so this server contributes 2 (the DMV row is ignored). One deadlock. */ + await InsertBlockedProcessAsync(connection, XeServerId, at); + await InsertBlockedProcessAsync(connection, XeServerId, at.AddMinutes(1)); + await InsertDmvBlockingAsync(connection, XeServerId, at); + await InsertDeadlockAsync(connection, XeServerId, at); + + /* Server DMV: no XE reports, three DMV snapshots in the window → the fallback uses DMV, so this + server contributes 3. Two deadlocks. */ + await InsertDmvBlockingAsync(connection, DmvServerId, at); + await InsertDmvBlockingAsync(connection, DmvServerId, at.AddMinutes(1)); + await InsertDmvBlockingAsync(connection, DmvServerId, at.AddMinutes(2)); + await InsertDeadlockAsync(connection, DmvServerId, at); + await InsertDeadlockAsync(connection, DmvServerId, at.AddMinutes(1)); + + var totals = await viewer.GetFleetTotalsAsync(at.AddMinutes(-5), at.AddMinutes(30)); + + /* Fleet blocking = 2 (XE server, XE-preferred) + 3 (DMV server, fallback) = 5. */ + Assert.Equal(5, totals.TotalBlockingEvents); + /* Fleet deadlocks = 1 + 2 = 3. */ + Assert.Equal(3, totals.TotalDeadlocks); + } + finally + { + await DeleteFleetRowsAsync(connection); + } + } + + // ── planting helpers ───────────────────────────────────────────────────────────── + + private static async Task InsertBlockedProcessAsync(NpgsqlConnection connection, int serverId, DateTime eventTime) + { + using var command = new NpgsqlCommand( + "INSERT INTO blocked_process_reports (blocked_report_id, collection_time, server_id, server_name, event_time) VALUES ($1, $2, $3, $4, $5)", + connection); + command.Parameters.AddWithValue(CollectionIdGenerator.Next()); + command.Parameters.AddWithValue(DateTime.SpecifyKind(eventTime, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(DateTime.SpecifyKind(eventTime, DateTimeKind.Unspecified)); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertDmvBlockingAsync(NpgsqlConnection connection, int serverId, DateTime eventTime) + { + using var command = new NpgsqlCommand( + "INSERT INTO dmv_blocking_snapshots (collection_id, collection_time, server_id, server_name, event_time) VALUES ($1, $2, $3, $4, $5)", + connection); + command.Parameters.AddWithValue(CollectionIdGenerator.Next()); + command.Parameters.AddWithValue(DateTime.SpecifyKind(eventTime, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(DateTime.SpecifyKind(eventTime, DateTimeKind.Unspecified)); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertDeadlockAsync(NpgsqlConnection connection, int serverId, DateTime deadlockTime) + { + using var command = new NpgsqlCommand( + "INSERT INTO deadlocks (deadlock_id, collection_time, server_id, server_name, deadlock_time) VALUES ($1, $2, $3, $4, $5)", + connection); + command.Parameters.AddWithValue(CollectionIdGenerator.Next()); + command.Parameters.AddWithValue(DateTime.SpecifyKind(deadlockTime, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(DateTime.SpecifyKind(deadlockTime, DateTimeKind.Unspecified)); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task DeleteFleetRowsAsync(NpgsqlConnection connection) + { + foreach (var table in new[] { "blocked_process_reports", "dmv_blocking_snapshots", "deadlocks" }) + { + using var cleanup = new NpgsqlCommand( + $"DELETE FROM {table} WHERE server_id IN ({XeServerId}, {DmvServerId});", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + } +} diff --git a/Darling/Darling.Tests/ViewerHistoryWindowTests.cs b/Darling/Darling.Tests/ViewerHistoryWindowTests.cs new file mode 100644 index 000000000..c00c83d78 --- /dev/null +++ b/Darling/Darling.Tests/ViewerHistoryWindowTests.cs @@ -0,0 +1,266 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the per-row double-click execution-history drill-down windows (parity-gap "Per-row double-click history +/// windows missing on all 3 query grids" + port-candidate "Per-object execution-history drill-downs"): the +/// three fuller history SQL reads' shape + parameterization, the row models' delta / average / total +/// derivations, the metric-selector chart projection, and the row → window-parameter mapping (which fields +/// identify the double-clicked item, and when a row can't open a window). Pure logic + SQL pins only — no live +/// Postgres, and nothing here touches 's process-wide statics. +/// +public sealed class ViewerHistoryWindowTests +{ + // ── History reads' SQL shape (both-sides window-bound, positional PG params) ── + + [Fact] + public void QueryStatsHistorySql_FiltersOneQueryHashOverTheWindow_OrderedByTime() + { + var sql = ViewerDataService.QueryStatsHistorySql; + Assert.Contains("FROM query_stats", sql, StringComparison.Ordinal); + Assert.Contains("server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("database_name = $2", sql, StringComparison.Ordinal); + Assert.Contains("query_hash = $3", sql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $4", sql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $5", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY collection_time", sql, StringComparison.Ordinal); + /* The window needs the delta, cumulative, and min/max columns the narrow overlay read lacks. */ + Assert.Contains("delta_worker_time", sql, StringComparison.Ordinal); + Assert.Contains("total_worker_time", sql, StringComparison.Ordinal); + Assert.Contains("min_grant_kb", sql, StringComparison.Ordinal); + Assert.Contains("sample_interval_seconds", sql, StringComparison.Ordinal); + AssertPgPositionalDialect(sql); + } + + [Fact] + public void ProcStatsHistorySql_FiltersOneSchemaObjectOverTheWindow_AndDerivesTheInterval() + { + var sql = ViewerDataService.ProcStatsHistorySql; + Assert.Contains("FROM procedure_stats", sql, StringComparison.Ordinal); + Assert.Contains("server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("database_name = $2", sql, StringComparison.Ordinal); + Assert.Contains("schema_name = $3", sql, StringComparison.Ordinal); + Assert.Contains("object_name = $4", sql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $5", sql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $6", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY collection_time", sql, StringComparison.Ordinal); + /* procedure_stats has no sample_interval_seconds column — it's derived from the previous row's gap. */ + Assert.Contains("LAG(collection_time)", sql, StringComparison.Ordinal); + Assert.Contains("total_spills", sql, StringComparison.Ordinal); + AssertPgPositionalDialect(sql); + } + + [Fact] + public void QueryStoreHistorySql_IsQueryScoped_AcrossEveryPlan_OverTheWindow() + { + var sql = ViewerDataService.QueryStoreHistorySql; + Assert.Contains("FROM query_store_stats", sql, StringComparison.Ordinal); + Assert.Contains("server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("database_name = $2", sql, StringComparison.Ordinal); + Assert.Contains("query_id = $3", sql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $4", sql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $5", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY collection_time", sql, StringComparison.Ordinal); + /* Query-scoped, NOT plan-filtered — plan_id rides back so the chart draws one series per plan. */ + Assert.DoesNotContain("plan_id = $", sql, StringComparison.Ordinal); + Assert.Contains("plan_id", sql, StringComparison.Ordinal); + Assert.Contains("is_forced_plan", sql, StringComparison.Ordinal); + AssertPgPositionalDialect(sql); + } + + // ── Row model derivations (delta µs → ms, per-execution averages, cumulative totals) ── + + [Fact] + public void QueryStatsHistoryRow_ConvertsMicrosecondsAndDividesByExecutionDelta() + { + var row = new ViewerQueryStatsHistoryRow + { + DeltaExecutions = 10, + DeltaCpuUs = 5_000, // 5 ms across 10 execs → 0.5 ms avg + DeltaElapsedUs = 20_000, // 20 ms across 10 execs → 2 ms avg + DeltaLogicalReads = 500, // 50 avg + MinCpuUs = 2_000, // 2 ms + MaxCpuUs = 9_000, // 9 ms + TotalCpuUs = 100_000, // 100 ms + TotalClrTimeUs = 3_000, // 3 ms + }; + + Assert.Equal(5.0, row.DeltaCpuMs, precision: 6); + Assert.Equal(20.0, row.DeltaElapsedMs, precision: 6); + Assert.Equal(0.5, row.AvgCpuMs, precision: 6); + Assert.Equal(2.0, row.AvgElapsedMs, precision: 6); + Assert.Equal(50.0, row.AvgReads, precision: 6); + Assert.Equal(2.0, row.MinCpuMs, precision: 6); + Assert.Equal(9.0, row.MaxCpuMs, precision: 6); + Assert.Equal(100.0, row.TotalCpuMs, precision: 6); + Assert.Equal(3.0, row.TotalClrMs, precision: 6); + } + + [Fact] + public void QueryStatsHistoryRow_ZeroExecutionDelta_YieldsZeroAverages_NoDivideByZero() + { + var row = new ViewerQueryStatsHistoryRow { DeltaExecutions = 0, DeltaCpuUs = 5_000, DeltaLogicalReads = 99 }; + Assert.Equal(0.0, row.AvgCpuMs); + Assert.Equal(0.0, row.AvgReads); + Assert.Equal(5.0, row.DeltaCpuMs, precision: 6); // the delta itself is still surfaced + } + + [Fact] + public void ProcedureHistoryRow_DerivesMinCpuFromWorkerTime_AndAvgSpills() + { + var row = new ViewerProcedureStatsHistoryRow + { + DeltaExecutions = 4, + DeltaSpills = 8, // 2 avg + MinWorkerTimeUs = 1_500, // 1.5 ms + MaxWorkerTimeUs = 6_000, // 6 ms + TotalCpuUs = 40_000, // 40 ms + }; + + Assert.Equal(2.0, row.AvgSpills, precision: 6); + Assert.Equal(1.5, row.MinCpuMs, precision: 6); + Assert.Equal(6.0, row.MaxCpuMs, precision: 6); + Assert.Equal(40.0, row.TotalCpuMs, precision: 6); + } + + [Fact] + public void QueryStoreHistoryRow_TotalsScalePerExecutionAveragesByExecutionCount() + { + var row = new ViewerQueryStoreHistoryRow { ExecutionCount = 25, AvgDurationMs = 4, AvgCpuTimeMs = 2 }; + Assert.Equal(100.0, row.TotalDurationMs, precision: 6); + Assert.Equal(50.0, row.TotalCpuMs, precision: 6); + } + + // ── Metric-selector chart projection (each window's GetMetricValue switch) ── + + [Fact] + public void QueryStatsGetMetricValue_MapsEachSelectorTag_UnknownFallsBackToAvgCpu() + { + var row = new ViewerQueryStatsHistoryRow + { + DeltaExecutions = 2, DeltaCpuUs = 4_000, DeltaLogicalReads = 6, DeltaSpills = 3, + }; + Assert.Equal(row.AvgCpuMs, QueryStatsHistoryWindow.GetMetricValue(row, "AvgCpuMs"), precision: 6); + Assert.Equal(row.AvgReads, QueryStatsHistoryWindow.GetMetricValue(row, "AvgReads"), precision: 6); + Assert.Equal(row.DeltaExecutions, QueryStatsHistoryWindow.GetMetricValue(row, "DeltaExecutions"), precision: 6); + Assert.Equal(row.DeltaCpuMs, QueryStatsHistoryWindow.GetMetricValue(row, "DeltaCpuMs"), precision: 6); + Assert.Equal(row.DeltaSpills, QueryStatsHistoryWindow.GetMetricValue(row, "DeltaSpills"), precision: 6); + Assert.Equal(row.AvgCpuMs, QueryStatsHistoryWindow.GetMetricValue(row, "not-a-metric"), precision: 6); + } + + [Fact] + public void QueryStoreGetMetricValue_MapsTotalsAndExecutionCount() + { + var row = new ViewerQueryStoreHistoryRow { ExecutionCount = 7, AvgDurationMs = 3, AvgCpuTimeMs = 1.5 }; + Assert.Equal(row.TotalDurationMs, QueryStoreHistoryWindow.GetMetricValue(row, "TotalDurationMs"), precision: 6); + Assert.Equal(row.TotalCpuMs, QueryStoreHistoryWindow.GetMetricValue(row, "TotalCpuMs"), precision: 6); + Assert.Equal(row.ExecutionCount, QueryStoreHistoryWindow.GetMetricValue(row, "ExecutionCount"), precision: 6); + Assert.Equal(row.AvgCpuTimeMs, QueryStoreHistoryWindow.GetMetricValue(row, "unknown"), precision: 6); + } + + // ── Row → window-parameter mapping (the fields that identify the double-clicked item) ── + + [Fact] + public void MapQueryStatsHistoryKey_CarriesDbHashAndText_WhenBothKeyFieldsPresent() + { + var key = ViewerServerTab.MapQueryStatsHistoryKey(new ViewerQueryStatsRow + { + DatabaseName = "Sales", QueryHash = "0xABCD", QueryText = "SELECT 1", + }); + Assert.NotNull(key); + Assert.Equal("Sales", key!.DatabaseName); + Assert.Equal("0xABCD", key.QueryHash); + Assert.Equal("SELECT 1", key.QueryText); + } + + [Theory] + [InlineData("", "0xABCD")] // no database + [InlineData("Sales", "")] // no query hash + public void MapQueryStatsHistoryKey_ReturnsNull_WhenAKeyFieldIsMissing(string db, string hash) + => Assert.Null(ViewerServerTab.MapQueryStatsHistoryKey( + new ViewerQueryStatsRow { DatabaseName = db, QueryHash = hash })); + + [Fact] + public void MapProcedureHistoryKey_CarriesDbSchemaObject_WhenDbAndObjectPresent() + { + var key = ViewerServerTab.MapProcedureHistoryKey(new ViewerProcedureStatsRow + { + DatabaseName = "Sales", SchemaName = "dbo", ObjectName = "usp_Get", + }); + Assert.NotNull(key); + Assert.Equal("Sales", key!.DatabaseName); + Assert.Equal("dbo", key.SchemaName); + Assert.Equal("usp_Get", key.ObjectName); + } + + [Theory] + [InlineData("", "usp_Get")] // no database + [InlineData("Sales", "")] // no object name + public void MapProcedureHistoryKey_ReturnsNull_WhenAKeyFieldIsMissing(string db, string obj) + => Assert.Null(ViewerServerTab.MapProcedureHistoryKey( + new ViewerProcedureStatsRow { DatabaseName = db, ObjectName = obj })); + + [Fact] + public void MapQueryStoreHistoryKey_CarriesDbQueryIdPlanIdText_WhenDbAndQueryIdPresent() + { + var key = ViewerServerTab.MapQueryStoreHistoryKey(new ViewerQueryStoreRow + { + DatabaseName = "Sales", QueryId = 42, PlanId = 7, QueryText = "SELECT 2", + }); + Assert.NotNull(key); + Assert.Equal("Sales", key!.DatabaseName); + Assert.Equal(42, key.QueryId); + Assert.Equal(7, key.PlanId); + Assert.Equal("SELECT 2", key.QueryText); + } + + [Theory] + [InlineData("", 42)] // no database + [InlineData("Sales", 0)] // no query_id + public void MapQueryStoreHistoryKey_ReturnsNull_WhenAKeyFieldIsMissing(string db, long queryId) + => Assert.Null(ViewerServerTab.MapQueryStoreHistoryKey( + new ViewerQueryStoreRow { DatabaseName = db, QueryId = queryId })); + + [Fact] + public void MapQueryStoreRegressionHistoryKey_CarriesDbQueryIdText_WithZeroPlanId_WhenDbAndQueryIdPresent() + { + /* The regression row aggregates over plans (no single plan_id), so the query-scoped history window + opens with plan_id 0 — the same "no specific plan" value the Dashboard passes. */ + var key = ViewerServerTab.MapQueryStoreRegressionHistoryKey(new ViewerQueryStoreRegressionRow + { + DatabaseName = "Sales", QueryId = 42, QueryTextSample = "SELECT 3", + }); + Assert.NotNull(key); + Assert.Equal("Sales", key!.DatabaseName); + Assert.Equal(42, key.QueryId); + Assert.Equal(0, key.PlanId); + Assert.Equal("SELECT 3", key.QueryText); + } + + [Theory] + [InlineData("", 42)] // no database + [InlineData("Sales", 0)] // no query_id + public void MapQueryStoreRegressionHistoryKey_ReturnsNull_WhenAKeyFieldIsMissing(string db, long queryId) + => Assert.Null(ViewerServerTab.MapQueryStoreRegressionHistoryKey( + new ViewerQueryStoreRegressionRow { DatabaseName = db, QueryId = queryId })); + + // ── Shared PG positional-dialect assertion (mirrors ViewerDrillDownTests) ── + + private static void AssertPgPositionalDialect(string sql) + { + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); // no T-SQL named params + Assert.DoesNotContain("N'", sql, StringComparison.Ordinal); // no T-SQL unicode literals + Assert.Contains("$1", sql, StringComparison.Ordinal); + } +} diff --git a/Darling/Darling.Tests/ViewerIndexAnalysisTests.cs b/Darling/Darling.Tests/ViewerIndexAnalysisTests.cs new file mode 100644 index 000000000..7adb4199c --- /dev/null +++ b/Darling/Darling.Tests/ViewerIndexAnalysisTests.cs @@ -0,0 +1,564 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the FinOps Index Analysis sub-tab's Darling-specific read/map/derive/project layer (Stage 3) against +/// the store contract, no live Postgres. The Common itself is already +/// covered (53 tests); these cover ONLY the viewer glue Stage 3 adds: (1) the two reads — the load-bearing +/// SQL clauses + column parity against the generated collector DDL; (2) the v_index_object_stats row → +/// mapping (incl. NULL flag → false); (3) the +/// derivation from the collected server_properties facts +/// (edition → compression/online, start-time → uptime); and (4) the analyzer-result → grid-row projections +/// (the six-value action label, the "ALL DATABASES"-first rollup). +/// +public sealed class ViewerIndexAnalysisTests +{ + // ── Reads: SQL clause pins ── + + [Fact] + public void IndexObjectStatsLatestSql_LatestPerIndex_ViaDistinctOn() + { + var sql = ViewerDataService.IndexObjectStatsLatestSql; + + Assert.Contains("FROM v_index_object_stats", sql, StringComparison.Ordinal); + /* Latest snapshot per index identity — never the whole history, and keyed on the index (not a single + server-wide MAX(collection_time)) so per-database cycles with different times still dedupe right. */ + Assert.Contains("DISTINCT ON (database_id, object_id, index_id)", sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY database_id, object_id, index_id, collection_time DESC", sql, StringComparison.Ordinal); + } + + [Fact] + public void ServerCompressionInfoSql_ReadsServerPropertiesBaseTable_LatestRow() + { + var sql = ViewerDataService.ServerCompressionInfoSql; + + /* Darling has no v_server_properties view — the base table, bare-name resolved via the Search Path. */ + Assert.Contains("FROM server_properties", sql, StringComparison.Ordinal); + Assert.DoesNotContain("v_server_properties", sql, StringComparison.Ordinal); + Assert.Contains("engine_edition", sql, StringComparison.Ordinal); + Assert.Contains("product_version", sql, StringComparison.Ordinal); + Assert.Contains("sqlserver_start_time", sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY collection_time DESC", sql, StringComparison.Ordinal); + Assert.Contains("LIMIT 1", sql, StringComparison.Ordinal); + } + + [Theory] + [InlineData(nameof(ViewerDataService.IndexObjectStatsLatestSql))] + [InlineData(nameof(ViewerDataService.ServerCompressionInfoSql))] + public void IndexAnalysisReads_PgDialect_PositionalParams_NoTSqlNamedParams(string sqlName) + { + var sql = (string)typeof(ViewerDataService).GetField(sqlName)!.GetValue(null)!; + + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + Assert.DoesNotContain("now(", sql.ToLowerInvariant()); + Assert.Contains("$1", sql, StringComparison.Ordinal); + } + + // ── Reads: column parity against the generated collector DDL ── + + [Fact] + public void IndexObjectStatsLatestSql_ReferencesColumnsThatExistInTheGeneratedTable() + { + var ddl = PgSchemaGenerator.CreateTable(IndexObjectStatsCollector.Instance); + Assert.Equal("index_object_stats", IndexObjectStatsCollector.Instance.TargetTable); + + foreach (var col in new[] + { + "database_name", "database_id", "schema_name", "object_id", "table_name", "index_id", "index_name", + "index_type_desc", "key_columns", "included_columns", "filter_definition", "is_unique", + "is_unique_constraint", "is_primary_key", "is_foreign_key", "is_foreign_key_reference", "is_disabled", + "is_indexed_view", "data_compression_desc", "optimize_for_sequential_key", "fill_factor", "is_padded", + "allow_page_locks", "allow_row_locks", "user_seeks", "user_scans", "user_lookups", "user_updates", + "reserved_mb", "total_rows", "partition_count", "sqlserver_start_time", "collection_time", "server_id", + "row_lock_wait_count", "row_lock_wait_in_ms", "page_lock_wait_count", "page_lock_wait_in_ms", + "page_latch_wait_count", "page_latch_wait_in_ms", "page_io_latch_wait_count", "page_io_latch_wait_in_ms", + }) + { + Assert.Contains(col, ddl, StringComparison.Ordinal); + } + } + + [Fact] + public void ServerCompressionInfoRead_ReferencesColumnsThatExistInTheGeneratedTable() + { + var ddl = PgSchemaGenerator.CreateTable(ServerPropertiesCollector.Instance); + Assert.Equal("server_properties", ServerPropertiesCollector.Instance.TargetTable); + + foreach (var col in new[] { "engine_edition", "product_version", "sqlserver_start_time", "collection_time", "server_id" }) + { + Assert.Contains(col, ddl, StringComparison.Ordinal); + } + } + + // ── Row → IndexCleanupIndexInput mapping ── + + private static ViewerDataService.IndexObjectStatsRow FullRow() => new() + { + DatabaseName = "AdventureWorks", + DatabaseId = 7, + SchemaName = "Sales", + ObjectId = 12345, + TableName = "Orders", + IndexId = 3, + IndexName = "IX_Orders_CustomerId", + IndexTypeDesc = "NONCLUSTERED", + KeyColumns = "[CustomerId], [OrderDate] DESC", + IncludedColumns = "[Total]", + FilterDefinition = "([Status]=(1))", + IsUnique = true, + IsUniqueConstraint = true, + IsPrimaryKey = true, + IsForeignKey = true, + IsForeignKeyReference = true, + IsDisabled = false, + IsIndexedView = true, + DataCompressionDesc = "NONE", + OptimizeForSequentialKey = true, + FillFactor = 90, + IsPadded = true, + AllowPageLocks = true, + AllowRowLocks = true, + UserSeeks = 10, + UserScans = 20, + UserLookups = 30, + UserUpdates = 40, + ReservedMb = 512.50m, + TotalRows = 1_000_000, + PartitionCount = 4, + SqlServerStartTime = new DateTime(2026, 7, 1, 8, 0, 0), + RowLockWaitCount = 100, + RowLockWaitInMs = 500, + PageLockWaitCount = 50, + PageLockWaitInMs = 250, + PageLatchWaitCount = 8, + PageLatchWaitInMs = 40, + PageIoLatchWaitCount = 2, + PageIoLatchWaitInMs = 10, + }; + + [Fact] + public void MapToIndexInput_CarriesEveryField() + { + var input = ViewerDataService.MapToIndexInput(FullRow()); + + Assert.Equal("AdventureWorks", input.DatabaseName); + Assert.Equal(7, input.DatabaseId); + Assert.Equal("Sales", input.SchemaName); + Assert.Equal(12345, input.ObjectId); + Assert.Equal("Orders", input.TableName); + Assert.Equal(3, input.IndexId); + Assert.Equal("IX_Orders_CustomerId", input.IndexName); + Assert.Equal("NONCLUSTERED", input.IndexTypeDesc); + Assert.Equal("[CustomerId], [OrderDate] DESC", input.KeyColumns); + Assert.Equal("[Total]", input.IncludedColumns); + Assert.Equal("([Status]=(1))", input.FilterDefinition); + Assert.True(input.IsUnique); + Assert.True(input.IsUniqueConstraint); + Assert.True(input.IsPrimaryKey); + Assert.True(input.IsForeignKey); + Assert.True(input.IsForeignKeyReference); + Assert.False(input.IsDisabled); + Assert.True(input.IsIndexedView); + Assert.Equal("NONE", input.DataCompressionDesc); + Assert.True(input.OptimizeForSequentialKey); + Assert.Equal((short)90, input.FillFactor); + Assert.True(input.IsPadded); + Assert.True(input.AllowPageLocks); + Assert.True(input.AllowRowLocks); + Assert.Equal(10, input.UserSeeks); + Assert.Equal(20, input.UserScans); + Assert.Equal(30, input.UserLookups); + Assert.Equal(40, input.UserUpdates); + Assert.Equal(512.50m, input.ReservedMb); + Assert.Equal(1_000_000, input.TotalRows); + Assert.Equal(4, input.PartitionCount); + Assert.Equal(new DateTime(2026, 7, 1, 8, 0, 0), input.SqlServerStartTime); + Assert.Equal(100, input.RowLockWaitCount); + Assert.Equal(500, input.RowLockWaitInMs); + Assert.Equal(50, input.PageLockWaitCount); + Assert.Equal(250, input.PageLockWaitInMs); + Assert.Equal(8, input.PageLatchWaitCount); + Assert.Equal(40, input.PageLatchWaitInMs); + Assert.Equal(2, input.PageIoLatchWaitCount); + Assert.Equal(10, input.PageIoLatchWaitInMs); + } + + [Fact] + public void MapToIndexInput_NullFlags_CollapseToFalse_AbsentGuardIsNotAGuard() + { + // A minimal row: every boolean flag NULL (the Postgres columns are nullable), counters NULL, sizes NULL. + var input = ViewerDataService.MapToIndexInput(new ViewerDataService.IndexObjectStatsRow + { + DatabaseName = "db", + SchemaName = "dbo", + TableName = "t", + IndexId = 2, + }); + + Assert.False(input.IsUnique); + Assert.False(input.IsUniqueConstraint); + Assert.False(input.IsPrimaryKey); + Assert.False(input.IsForeignKey); + Assert.False(input.IsForeignKeyReference); + Assert.False(input.IsDisabled); + Assert.False(input.IsIndexedView); + Assert.False(input.OptimizeForSequentialKey); + Assert.False(input.IsPadded); + Assert.False(input.AllowPageLocks); + Assert.False(input.AllowRowLocks); + Assert.Equal(0, input.UserSeeks); + Assert.Equal(0, input.UserScans); + Assert.Equal(0, input.UserLookups); + Assert.Equal(0, input.UserUpdates); + // Lock/latch counters collapse NULL → 0 (an absent counter is a zero contribution to the rollup sum). + Assert.Equal(0, input.RowLockWaitCount); + Assert.Equal(0, input.RowLockWaitInMs); + Assert.Equal(0, input.PageLockWaitCount); + Assert.Equal(0, input.PageLockWaitInMs); + Assert.Equal(0, input.PageLatchWaitCount); + Assert.Equal(0, input.PageLatchWaitInMs); + Assert.Equal(0, input.PageIoLatchWaitCount); + Assert.Equal(0, input.PageIoLatchWaitInMs); + // The genuinely-nullable sizing fields stay null (distinct from "collected as 0"). + Assert.Null(input.ReservedMb); + Assert.Null(input.TotalRows); + Assert.Null(input.PartitionCount); + Assert.Null(input.FillFactor); + } + + // ── Options derivation: compression / online by edition ── + + private static readonly DateTime Ref = new(2026, 7, 5, 12, 0, 0); + + [Theory] + [InlineData(3, "16.0.1000.6", true, true)] // Enterprise (also Developer/Evaluation) — both + [InlineData(5, "12.0.2000.8", true, true)] // Azure SQL Database — both (version irrelevant) + [InlineData(8, "12.0.2000.8", true, true)] // Azure SQL Managed Instance — both + [InlineData(2, "16.0.1000.6", true, false)] // Standard 2016+ — compression yes, online no + [InlineData(2, "13.0.5026.0", true, false)] // Standard 2016 RTM (major 13) — compression yes + [InlineData(4, "15.0.4300.1", true, false)] // Express 2019 — compression yes, online no + [InlineData(2, "12.0.6024.0", false, false)] // Standard 2014 (pre-2016) — no compression, no online + [InlineData(4, "11.0.7001.0", false, false)] // Express 2012 — neither + public void DeriveOptions_CompressionAndOnline_ByEditionAndVersion( + int engineEdition, string productVersion, bool expectCompression, bool expectOnline) + { + var options = ViewerDataService.DeriveIndexCleanupOptions(engineEdition, productVersion, null, Ref); + + Assert.Equal(expectCompression, options.ServerSupportsCompression); + Assert.Equal(expectOnline, options.ServerSupportsOnline); + } + + [Fact] + public void DeriveOptions_UnknownEdition_NoCollectedRow_SafeDefaults() + { + // No server_properties row collected yet: assume compression available (2016+ product minimum), online off. + var options = ViewerDataService.DeriveIndexCleanupOptions(null, null, null, Ref); + + Assert.True(options.ServerSupportsCompression); + Assert.False(options.ServerSupportsOnline); + } + + // ── Options derivation: uptime days from start time ── + + [Fact] + public void DeriveOptions_UptimeDays_FromServerLocalStartVsReferenceNow() + { + var start = Ref.AddDays(-30); + var options = ViewerDataService.DeriveIndexCleanupOptions(3, "16.0.1000.6", start, Ref); + + Assert.NotNull(options.ServerUptimeDays); + Assert.Equal(30.0, options.ServerUptimeDays!.Value, precision: 3); + } + + [Fact] + public void DeriveOptions_UptimeUnderSevenDays_DrivesAnalyzerDedupeAndWarning() + { + // Uptime derivation feeds the analyzer's <=7-day auto-dedupe and <14-day caveat: prove the wiring end to end. + var options = ViewerDataService.DeriveIndexCleanupOptions(3, "16.0.1000.6", Ref.AddDays(-3), Ref); + Assert.Equal(3.0, options.ServerUptimeDays!.Value, precision: 3); + + var result = IndexCleanupAnalyzer.Analyze(Array.Empty(), options); + Assert.True(result.DedupeOnlyApplied); + Assert.True(result.UptimeWarning); + } + + [Fact] + public void DeriveOptions_NullStart_NoUptime_AndFutureStartIgnored() + { + Assert.Null(ViewerDataService.DeriveIndexCleanupOptions(3, "16.0", null, Ref).ServerUptimeDays); + // A start clock ahead of the viewer's (timezone skew) yields a negative age — discarded, not applied. + Assert.Null(ViewerDataService.DeriveIndexCleanupOptions(3, "16.0", Ref.AddDays(2), Ref).ServerUptimeDays); + } + + [Fact] + public void DeriveOptions_MinimumsAndFactors_MatchSpIndexCleanupParameterDefaults() + { + var options = ViewerDataService.DeriveIndexCleanupOptions(3, "16.0.1000.6", Ref.AddDays(-30), Ref); + + // sp_IndexCleanup @min_reads / @min_writes / @min_size_gb / @min_rows / @dedupe_only defaults (0 / off). + Assert.False(options.DedupeOnly); + Assert.Equal(0m, options.MinSizeGb); + Assert.Equal(0, options.MinReads); + Assert.Equal(0, options.MinWrites); + Assert.Equal(0, options.MinRows); + // The 0.20–0.60 general compression savings band (record defaults, unchanged). + Assert.Equal(0.20m, options.CompressionMinSavingsFactor); + Assert.Equal(0.60m, options.CompressionMaxSavingsFactor); + } + + // ── ParseMajorVersion ── + + [Theory] + [InlineData("16.0.1000.6", 16)] + [InlineData("13.0.5026.0", 13)] + [InlineData("9.00.5000.00", 9)] + [InlineData("11.0.7001.0", 11)] + [InlineData(null, 0)] + [InlineData("", 0)] + [InlineData(" ", 0)] + [InlineData("garbage", 0)] + public void ParseMajorVersion_LeadingInteger(string? productVersion, int expected) => + Assert.Equal(expected, ViewerDataService.ParseMajorVersion(productVersion)); + + // ── Recommendation-row action label (the six-value filter set) ── + + [Theory] + [InlineData(IndexCleanupAction.MakeUnique, IndexCleanupResultKind.Merge, "MAKE UNIQUE")] + [InlineData(IndexCleanupAction.MakeUnique, IndexCleanupResultKind.Disable, "MAKE UNIQUE")] // Action wins over its bucket + [InlineData(IndexCleanupAction.Disable, IndexCleanupResultKind.Disable, "DISABLE")] + [InlineData(IndexCleanupAction.MergeIncludes, IndexCleanupResultKind.Merge, "MERGE")] + [InlineData(IndexCleanupAction.Keep, IndexCleanupResultKind.Compress, "COMPRESS")] + [InlineData(IndexCleanupAction.Keep, IndexCleanupResultKind.Review, "REVIEW")] + [InlineData(IndexCleanupAction.Disable, IndexCleanupResultKind.DisableConstraint, "DROP CONSTRAINT")] + public void ActionLabelFor_MapsToTheSixOperationLabels( + IndexCleanupAction action, IndexCleanupResultKind resultKind, string expected) => + Assert.Equal(expected, IndexCleanupRecommendationRow.ActionLabelFor(action, resultKind)); + + [Fact] + public void RecommendationRow_ReviewRow_CarriesNoDestructiveScript() + { + var row = new IndexCleanupRecommendationRow(new IndexCleanupRecommendation + { + DatabaseName = "db", + SchemaName = "dbo", + TableName = "t", + IndexName = "IX_review", + Action = IndexCleanupAction.Keep, + ResultKind = IndexCleanupResultKind.Review, + ConsolidationRule = IndexCleanupRules.ReverseDuplicate, + SupersededBy = "Related to IX_other", + Script = "", + }); + + Assert.Equal("REVIEW", row.ActionDisplay); + Assert.Equal("REVIEW", row.ResultKindDisplay); + Assert.Equal(IndexCleanupRules.ReverseDuplicate, row.ConsolidationRule); + Assert.Equal("", row.Script); + Assert.Equal("Related to IX_other", row.SupersededBy); + } + + // ── Rollup projection ── + + [Fact] + public void ProjectRollups_OverallFirst_ThenPerDatabase() + { + var result = new IndexCleanupAnalysisResult + { + OverallRollup = new IndexCleanupRollup { DatabaseName = null, IndexCount = 42, TotalMaxSavingsGb = 9.5m }, + DatabaseRollups = new[] + { + new IndexCleanupRollup { DatabaseName = "Alpha", IndexCount = 20 }, + new IndexCleanupRollup { DatabaseName = "Beta", IndexCount = 22 }, + }, + }; + + var rows = ViewerDataService.ProjectRollups(result); + + Assert.Equal(3, rows.Count); + Assert.Equal("ALL DATABASES", rows[0].Scope); + Assert.Equal(42, rows[0].IndexCount); + Assert.Equal(9.5m, rows[0].TotalMaxSavingsGb); + Assert.Equal("Alpha", rows[1].Scope); + Assert.Equal("Beta", rows[2].Scope); + } + + // ── End-to-end glue: map → analyze → project (NOT re-testing analyzer rules; just the Stage-3 wiring) ── + + [Fact] + public void EndToEnd_MapAnalyzeProject_ExactDuplicate_YieldsADisableRecommendation() + { + // Two identical nonclustered indexes on one table = an Exact Duplicate; the analyzer disables the loser. + ViewerDataService.IndexObjectStatsRow Idx(int indexId, string name) => new() + { + DatabaseName = "AdventureWorks", + DatabaseId = 7, + SchemaName = "Sales", + ObjectId = 100, + TableName = "Orders", + IndexId = indexId, + IndexName = name, + IndexTypeDesc = "NONCLUSTERED", + KeyColumns = "[CustomerId]", + IncludedColumns = "[Total]", + IsUnique = false, + ReservedMb = 100m, + TotalRows = 500_000, + UserSeeks = 5, + }; + + var clustered = new ViewerDataService.IndexObjectStatsRow + { + DatabaseName = "AdventureWorks", + DatabaseId = 7, + SchemaName = "Sales", + ObjectId = 100, + TableName = "Orders", + IndexId = 1, + IndexName = "PK_Orders", + IndexTypeDesc = "CLUSTERED", + KeyColumns = "[OrderId]", + IsUnique = true, + IsPrimaryKey = true, + ReservedMb = 400m, + TotalRows = 500_000, + }; + + var inputs = new[] { clustered, Idx(2, "IX_A"), Idx(3, "IX_B") } + .Select(ViewerDataService.MapToIndexInput) + .ToList(); + + var options = ViewerDataService.DeriveIndexCleanupOptions(3, "16.0.1000.6", Ref.AddDays(-60), Ref); + var result = IndexCleanupAnalyzer.Analyze(inputs, options); + + var recRows = ViewerDataService.ProjectRecommendations(result); + var rollupRows = ViewerDataService.ProjectRollups(result); + + // The Stage-3 glue produced rows the grids can bind: at least one DISABLE recommendation for the duplicate. + Assert.Contains(recRows, r => r.ActionDisplay == "DISABLE" && r.ConsolidationRule == IndexCleanupRules.ExactDuplicate); + // The rollup carries the overall row and reflects the disable. + Assert.Equal("ALL DATABASES", rollupRows[0].Scope); + Assert.True(rollupRows[0].IndexesToDisable >= 1); + } + + // ── Workload-impact rollup columns (sp_IndexCleanup #index_reporting_stats parity) ── + + [Fact] + public void IndexObjectStatsLatestSql_SelectsTheOperationalLockLatchCounters() + { + // The reclaimable-space rollup's workload columns are backed by the operational counters the collector + // already captures — the read must pull them (the same columns the FinOps Locking sub-tab reads). + var sql = ViewerDataService.IndexObjectStatsLatestSql; + + foreach (var col in new[] + { + "row_lock_wait_count", "row_lock_wait_in_ms", "page_lock_wait_count", "page_lock_wait_in_ms", + "page_latch_wait_count", "page_latch_wait_in_ms", "page_io_latch_wait_count", "page_io_latch_wait_in_ms", + }) + { + Assert.Contains(col, sql, StringComparison.Ordinal); + } + } + + [Fact] + public void WorkloadRollup_DatabaseRowAggregatesCountersAcrossIndexes_OverallRowIsNA() + { + // One database, three analyzed indexes on one table. The DATABASE rollup SUMs the workload counters over + // every analyzed index (reads/writes + lock/latch); the overall ("ALL DATABASES") row mirrors + // sp_IndexCleanup's SUMMARY level, which emits these columns as 'N/A'. + var clustered = new ViewerDataService.IndexObjectStatsRow + { + DatabaseName = "AdventureWorks", DatabaseId = 7, SchemaName = "Sales", ObjectId = 100, + TableName = "Orders", IndexId = 1, IndexName = "PK_Orders", IndexTypeDesc = "CLUSTERED", + KeyColumns = "[OrderId]", IsUnique = true, IsPrimaryKey = true, ReservedMb = 400m, TotalRows = 500_000, + // Clustered index carries no workload here so the expected sums stay clean (it is still counted). + }; + var idxA = new ViewerDataService.IndexObjectStatsRow + { + DatabaseName = "AdventureWorks", DatabaseId = 7, SchemaName = "Sales", ObjectId = 100, + TableName = "Orders", IndexId = 2, IndexName = "IX_A", IndexTypeDesc = "NONCLUSTERED", + KeyColumns = "[CustomerId]", ReservedMb = 100m, TotalRows = 500_000, + UserSeeks = 10, UserScans = 20, UserLookups = 30, UserUpdates = 40, + RowLockWaitCount = 100, RowLockWaitInMs = 500, PageLockWaitCount = 50, PageLockWaitInMs = 250, + PageLatchWaitCount = 8, PageLatchWaitInMs = 40, PageIoLatchWaitCount = 2, PageIoLatchWaitInMs = 10, + }; + var idxB = new ViewerDataService.IndexObjectStatsRow + { + DatabaseName = "AdventureWorks", DatabaseId = 7, SchemaName = "Sales", ObjectId = 100, + TableName = "Orders", IndexId = 3, IndexName = "IX_B", IndexTypeDesc = "NONCLUSTERED", + KeyColumns = "[OrderDate]", ReservedMb = 100m, TotalRows = 500_000, + UserSeeks = 5, UserScans = 5, UserLookups = 5, UserUpdates = 10, + // No lock/latch waits — proves the SUM includes zero-wait indexes without skewing the average. + }; + + var inputs = new[] { clustered, idxA, idxB }.Select(ViewerDataService.MapToIndexInput).ToList(); + var options = ViewerDataService.DeriveIndexCleanupOptions(3, "16.0.1000.6", Ref.AddDays(-60), Ref); + var result = IndexCleanupAnalyzer.Analyze(inputs, options); + var rows = ViewerDataService.ProjectRollups(result); + + var overall = rows[0]; + var db = rows.Single(r => r.Scope == "AdventureWorks"); + + // Database row: SUMs — reads 15/25/35 (=75), writes 50, lock waits 150 (avg 750/150 = 5), latch waits 10 (avg 50/10 = 5). + Assert.Equal(50m, db.WritesSort); + Assert.Equal(150m, db.LockWaitCountSort); + Assert.Equal(5m, db.AvgLockWaitMsSort); + Assert.Equal(10m, db.LatchWaitCountSort); + Assert.Equal(5m, db.AvgLatchWaitMsSort); + Assert.NotEqual("N/A", db.Writes); + Assert.Contains("seeks", db.ReadsBreakdown, StringComparison.Ordinal); + Assert.Contains("scans", db.ReadsBreakdown, StringComparison.Ordinal); + Assert.Contains("lookups", db.ReadsBreakdown, StringComparison.Ordinal); + + // Overall ("ALL DATABASES") row: workload columns are 'N/A' (matches sp_IndexCleanup's SUMMARY level), + // and their numeric sort keys sink below every real value. + Assert.Equal("ALL DATABASES", overall.Scope); + Assert.Equal("N/A", overall.ReadsBreakdown); + Assert.Equal("N/A", overall.Writes); + Assert.Equal("N/A", overall.LockWaitCount); + Assert.Equal("N/A", overall.AvgLockWaitMs); + Assert.Equal("N/A", overall.LatchWaitCount); + Assert.Equal("N/A", overall.AvgLatchWaitMs); + Assert.Equal(-1m, overall.WritesSort); + Assert.Equal(-1m, overall.LockWaitCountSort); + Assert.Equal(-1m, overall.AvgLatchWaitMsSort); + } + + [Fact] + public void WorkloadRollup_DatabaseRowWithNoWaits_ShowsZeroNotNA() + { + // A database whose indexes have zero lock/latch waits shows "0" (not 'N/A') on the database row — + // 'N/A' is reserved for the overall row, exactly like sp_IndexCleanup's DATABASE-vs-SUMMARY split. + var idx = new ViewerDataService.IndexObjectStatsRow + { + DatabaseName = "Db", DatabaseId = 9, SchemaName = "dbo", ObjectId = 1, TableName = "t", + IndexId = 2, IndexName = "IX_quiet", IndexTypeDesc = "NONCLUSTERED", KeyColumns = "[c]", + ReservedMb = 1m, TotalRows = 10, UserSeeks = 1, + }; + + var result = IndexCleanupAnalyzer.Analyze( + new[] { idx }.Select(ViewerDataService.MapToIndexInput), + ViewerDataService.DeriveIndexCleanupOptions(3, "16.0.1000.6", Ref.AddDays(-60), Ref)); + var db = ViewerDataService.ProjectRollups(result).Single(r => r.Scope == "Db"); + + Assert.Equal("0", db.AvgLockWaitMs); // no waits → "0", not a divide-by-zero or 'N/A' + Assert.Equal("0", db.AvgLatchWaitMs); + Assert.Equal(0m, db.LockWaitCountSort); + } +} diff --git a/Darling/Darling.Tests/ViewerLatchSpinlockTests.cs b/Darling/Darling.Tests/ViewerLatchSpinlockTests.cs new file mode 100644 index 000000000..a26043477 --- /dev/null +++ b/Darling/Darling.Tests/ViewerLatchSpinlockTests.cs @@ -0,0 +1,294 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the Latches & Spinlocks tab's four reads against the Darling store contract (no live +/// Postgres): the two per-second trend reads (top-5 by delta, normalized to ms/sec and collisions/sec via +/// the per-contender LAG interval — Darling's cumulative-delta tables carry no stored +/// sample_interval_seconds, so the seconds come from the same truncate-then-diff epoch idiom the wait +/// trend uses) and the two latest-snapshot grid reads (most recent collection in the window, ordered by +/// recent delta). All four run on the v_latch_stats / v_spinlock_stats passthrough views. +/// +public sealed class ViewerLatchSpinlockSqlTests +{ + [Fact] + public void LatchTrendSql_TopFiveByDeltaWait_PerClassLagPerSecond_OverTheWindow() + { + var sql = ViewerDataService.LatchTrendSql; + + Assert.Contains("FROM v_latch_stats", sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $2", sql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $3", sql, StringComparison.Ordinal); + + /* Top-5 latch classes by total delta wait time (mirrors the Dashboard's GetLatchStatsTopNAsync). */ + Assert.Contains("WITH top_latches AS", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY SUM(delta_wait_time_ms) DESC", sql, StringComparison.Ordinal); + Assert.Contains("LIMIT 5", sql, StringComparison.Ordinal); + Assert.Contains("latch_class IN (SELECT latch_class FROM top_latches)", sql, StringComparison.Ordinal); + + /* Per-class LAG interval → ms/sec, the wait-stats truncate-then-diff epoch idiom. */ + Assert.Contains("LAG(collection_time) OVER (PARTITION BY latch_class ORDER BY collection_time)", sql, StringComparison.Ordinal); + Assert.Contains("extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time)", sql, StringComparison.Ordinal); + Assert.Contains("CAST(delta_wait_time_ms AS DOUBLE PRECISION) / interval_seconds", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY latch_class, collection_time", sql, StringComparison.Ordinal); + } + + [Fact] + public void LatchSnapshotSql_LatestCollectionInWindow_OrderedByRecentDelta_CapAt20() + { + var sql = ViewerDataService.LatchSnapshotSql; + + Assert.Contains("FROM v_latch_stats", sql, StringComparison.Ordinal); + Assert.Contains("SELECT MAX(collection_time) AS mx", sql, StringComparison.Ordinal); + Assert.Contains("collection_time = (SELECT mx FROM latest)", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY delta_wait_time_ms DESC, wait_time_ms DESC", sql, StringComparison.Ordinal); + Assert.Contains("LIMIT 20", sql, StringComparison.Ordinal); + } + + [Fact] + public void SpinlockTrendSql_TopFiveByDeltaCollisions_PerNameLagPerSecond_OverTheWindow() + { + var sql = ViewerDataService.SpinlockTrendSql; + + Assert.Contains("FROM v_spinlock_stats", sql, StringComparison.Ordinal); + Assert.Contains("WITH top_spinlocks AS", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY SUM(delta_collisions) DESC", sql, StringComparison.Ordinal); + Assert.Contains("LIMIT 5", sql, StringComparison.Ordinal); + Assert.Contains("spinlock_name IN (SELECT spinlock_name FROM top_spinlocks)", sql, StringComparison.Ordinal); + Assert.Contains("LAG(collection_time) OVER (PARTITION BY spinlock_name ORDER BY collection_time)", sql, StringComparison.Ordinal); + Assert.Contains("CAST(delta_collisions AS DOUBLE PRECISION) / interval_seconds", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY spinlock_name, collection_time", sql, StringComparison.Ordinal); + } + + [Fact] + public void SpinlockSnapshotSql_LatestCollectionInWindow_OrderedByRecentDelta_CapAt20() + { + var sql = ViewerDataService.SpinlockSnapshotSql; + + Assert.Contains("FROM v_spinlock_stats", sql, StringComparison.Ordinal); + Assert.Contains("SELECT MAX(collection_time) AS mx", sql, StringComparison.Ordinal); + Assert.Contains("collection_time = (SELECT mx FROM latest)", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY delta_collisions DESC, collisions DESC", sql, StringComparison.Ordinal); + Assert.Contains("LIMIT 20", sql, StringComparison.Ordinal); + } + + [Theory] + [InlineData("latch-trend")] + [InlineData("latch-snapshot")] + [InlineData("spinlock-trend")] + [InlineData("spinlock-snapshot")] + public void LatchSpinlockSql_PgDialect_PositionalParams_NoBareNow_NoNLiterals(string which) + { + var sql = which switch + { + "latch-trend" => ViewerDataService.LatchTrendSql, + "latch-snapshot" => ViewerDataService.LatchSnapshotSql, + "spinlock-trend" => ViewerDataService.SpinlockTrendSql, + _ => ViewerDataService.SpinlockSnapshotSql, + }; + + Assert.DoesNotContain("now(", sql.ToLowerInvariant()); + Assert.DoesNotContain("N'", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + Assert.Contains("$1", sql, StringComparison.Ordinal); + Assert.Contains("$2", sql, StringComparison.Ordinal); + Assert.Contains("$3", sql, StringComparison.Ordinal); + } + + [Fact] + public void LatchSql_ReadsColumnsThatExistInTheGeneratedLatchTable() + { + Assert.Equal("latch_stats", LatchStatsCollector.Instance.TargetTable); + + var ddl = PgSchemaGenerator.CreateTable(LatchStatsCollector.Instance); + foreach (var column in new[] + { + "collection_time", "latch_class", "waiting_requests_count", "wait_time_ms", + "max_wait_time_ms", "delta_waiting_requests_count", "delta_wait_time_ms", + }) + { + Assert.Contains(column, ddl, StringComparison.Ordinal); + } + } + + [Fact] + public void SpinlockSql_ReadsColumnsThatExistInTheGeneratedSpinlockTable() + { + Assert.Equal("spinlock_stats", SpinlockStatsCollector.Instance.TargetTable); + + var ddl = PgSchemaGenerator.CreateTable(SpinlockStatsCollector.Instance); + foreach (var column in new[] + { + "collection_time", "spinlock_name", "collisions", "spins", "spins_per_collision", + "sleep_time", "backoffs", "delta_collisions", "delta_spins", + }) + { + Assert.Contains(column, ddl, StringComparison.Ordinal); + } + } + + [Fact] + public void LatchAndSpinlockViews_ArePinnedInTheAuthoritativeViewList() + { + /* The tab reads the v_ passthrough views; both must be in the store's authoritative view set + (so V14 refreshes them and the migration test cross-checks them). */ + Assert.Contains("v_latch_stats", PgSchemaGenerator.AllPassthroughViews); + Assert.Contains("v_spinlock_stats", PgSchemaGenerator.AllPassthroughViews); + } +} + +/// +/// Gated (DARLING_TEST_PG) live round-trips for the Latches & Spinlocks reads: the latch trend's +/// top-5 selection + per-class ms/sec math, and the spinlock snapshot's latest-collection + recent-delta +/// ordering. Shares the serialized "live-postgres" collection; uses negative sentinel server_ids and +/// cleans up in finally. +/// +[Collection("live-postgres")] +public sealed class ViewerLatchSpinlockLivePostgresTests +{ + private const int LatchServerId = -940001; + private const string LatchServerName = "viewer-latch-e2e"; + private const int SpinlockServerId = -940002; + private const string SpinlockServerName = "viewer-spinlock-e2e"; + + [Fact] + public async Task LatchTrend_TopFiveByDelta_PerSecond_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live latch-trend test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteAsync(connection, "latch_stats", LatchServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var t1 = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + var t2 = t1.AddMinutes(5); // 300 seconds later + + /* BUFFER across two collections: 300 ms delta over 300 s = 1.0 ms/sec at t2; 0 at t1 (no LAG). */ + await InsertLatchAsync(connection, 1, t1, "BUFFER", deltaWait: 100, deltaReqs: 5); + await InsertLatchAsync(connection, 2, t2, "BUFFER", deltaWait: 300, deltaReqs: 3); + + var trend = await viewer.GetLatchStatsTrendAsync(LatchServerId, t1.AddMinutes(-1), t2.AddMinutes(1)); + + var buffer = trend.Where(p => p.LatchClass == "BUFFER").OrderBy(p => p.CollectionTime).ToList(); + Assert.Equal(2, buffer.Count); + Assert.Equal(0.0, buffer[0].WaitTimeMsPerSecond, precision: 3); + Assert.Equal(1.0, buffer[1].WaitTimeMsPerSecond, precision: 3); + } + finally + { + await DeleteAsync(connection, "latch_stats", LatchServerId); + } + } + + [Fact] + public async Task SpinlockSnapshot_LatestCollection_OrderedByRecentDelta_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live spinlock-snapshot test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteAsync(connection, "spinlock_stats", SpinlockServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var t1 = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + var t2 = t1.AddMinutes(5); + + /* Older collection (t1) must be ignored; the snapshot is the latest (t2). At t2, LOCK_HASH + has the larger delta so it sorts above SOS_CACHESTORE. */ + await InsertSpinlockAsync(connection, 1, t1, "LOCK_HASH", collisions: 10, deltaCollisions: 10); + await InsertSpinlockAsync(connection, 2, t2, "SOS_CACHESTORE", collisions: 500, deltaCollisions: 50); + await InsertSpinlockAsync(connection, 2, t2, "LOCK_HASH", collisions: 900, deltaCollisions: 400); + + var snapshot = await viewer.GetSpinlockStatsSnapshotAsync(SpinlockServerId, t1.AddMinutes(-1), t2.AddMinutes(1)); + + Assert.Equal(2, snapshot.Count); + Assert.Equal("LOCK_HASH", snapshot[0].SpinlockName); // larger delta first + Assert.Equal(400, snapshot[0].DeltaCollisions); + Assert.Equal("SOS_CACHESTORE", snapshot[1].SpinlockName); + } + finally + { + await DeleteAsync(connection, "spinlock_stats", SpinlockServerId); + } + } + + private static async Task InsertLatchAsync( + NpgsqlConnection connection, long collectionId, DateTime collectionTimeUtc, + string latchClass, long deltaWait, long deltaReqs) + { + using var command = new NpgsqlCommand(@" +INSERT INTO latch_stats + (collection_id, collection_time, server_id, server_name, latch_class, + waiting_requests_count, wait_time_ms, max_wait_time_ms, + delta_waiting_requests_count, delta_wait_time_ms, delta_max_wait_time_ms) +VALUES ($1, $2, $3, $4, $5, 0, 0, 0, $6, $7, 0)", connection); + command.Parameters.AddWithValue(collectionId); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(LatchServerId); + command.Parameters.AddWithValue(LatchServerName); + command.Parameters.AddWithValue(latchClass); + command.Parameters.AddWithValue(deltaReqs); + command.Parameters.AddWithValue(deltaWait); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertSpinlockAsync( + NpgsqlConnection connection, long collectionId, DateTime collectionTimeUtc, + string spinlockName, long collisions, long deltaCollisions) + { + using var command = new NpgsqlCommand(@" +INSERT INTO spinlock_stats + (collection_id, collection_time, server_id, server_name, spinlock_name, + collisions, spins, spins_per_collision, sleep_time, backoffs, + delta_collisions, delta_spins, delta_sleep_time, delta_backoffs) +VALUES ($1, $2, $3, $4, $5, $6, 0, 0, 0, 0, $7, 0, 0, 0)", connection); + command.Parameters.AddWithValue(collectionId); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(SpinlockServerId); + command.Parameters.AddWithValue(SpinlockServerName); + command.Parameters.AddWithValue(spinlockName); + command.Parameters.AddWithValue(collisions); + command.Parameters.AddWithValue(deltaCollisions); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static DateTime TruncateToSeconds(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified); + + private static async Task DeleteAsync(NpgsqlConnection connection, string table, int serverId) + { + using var cleanup = new NpgsqlCommand($"DELETE FROM {table} WHERE server_id = {serverId};", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } +} diff --git a/Darling/Darling.Tests/ViewerManageServersTests.cs b/Darling/Darling.Tests/ViewerManageServersTests.cs new file mode 100644 index 000000000..bf42ed32b --- /dev/null +++ b/Darling/Darling.Tests/ViewerManageServersTests.cs @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using PerformanceMonitor.Darling.Viewer; +using PerformanceMonitor.Ui; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the Manage Servers grid's new "Last Collected" column projection (): +/// the SERVICE's newest collection time (the MAX(collection_time) freshness signal the sidebar dots reuse) is +/// rendered in the viewer's timestamp-display mode, and a server the service has not collected yet reads +/// "Never" rather than a bogus epoch. The read itself () +/// is a single documented query already exercised by the sidebar; the CELL logic is what this change adds, and +/// it is pure (given the display mode), so it is unit-testable without a live Postgres. +/// +public sealed class ViewerManageServersColumnTests +{ + private static ManagedServerListItem Item(DateTime? lastCollectedUtc) => + new(new MonitoredServerRow { ServerId = 1, Host = "SQL2022", Name = "SQL2022" }, isFavorite: false) + { + LastCollectedUtc = lastCollectedUtc, + }; + + [Fact] + public void LastCollectedDisplay_ReadsNever_WhenTheServiceHasNotCollectedYet() + { + Assert.Equal("Never", Item(lastCollectedUtc: null).LastCollectedDisplay); + } + + [Fact] + public void LastCollectedDisplay_FormatsTheNewestCollectionTime_InTheDisplayMode() + { + var savedMode = ViewerTimeHelper.CurrentDisplayMode; + try + { + // UTC mode is the identity conversion, so the naive-UTC collection time renders verbatim. + ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.UTC; + var lastCollected = new DateTime(2026, 7, 1, 14, 30, 0, DateTimeKind.Unspecified); + + Assert.Equal("2026-07-01 14:30", Item(lastCollected).LastCollectedDisplay); + } + finally + { + ViewerTimeHelper.CurrentDisplayMode = savedMode; + } + } +} diff --git a/Darling/Darling.Tests/ViewerMemoryTests.cs b/Darling/Darling.Tests/ViewerMemoryTests.cs new file mode 100644 index 000000000..376eb804b --- /dev/null +++ b/Darling/Darling.Tests/ViewerMemoryTests.cs @@ -0,0 +1,782 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Npgsql; +using NpgsqlTypes; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +using PerformanceMonitor.Ui; + +namespace Darling.Tests; + +/// +/// Pins the Memory-tab reads (W1j viewer copy-parity) against the Darling store contract, no live +/// Postgres. Every read runs against a v_* passthrough view (v_memory_stats / v_memory_grant_stats / +/// v_memory_clerks / v_memory_pressure_events) mirroring Lite. The numeric(18,2) MB columns are CAST to +/// double precision for the typed GetDouble reader; the grant-chart count SUMs are CAST to bigint (a +/// Postgres SUM widens integer→bigint and bigint→numeric, so the CAST keeps GetInt64 valid either way); +/// the Overview summary read is a latest-snapshot (ORDER BY collection_time DESC LIMIT 1, no window); the +/// clerk trend batches its selected types into a dynamic IN list from $4; and the pressure read windows +/// on the payload's own sample_time (its dedicated index), not collection_time. +/// +public sealed class ViewerMemorySqlTests +{ + [Fact] + public void LatestMemoryStatsSql_IsLatestSnapshot_CastsMbToDouble_SelectsStateStrings() + { + var sql = ViewerDataService.LatestMemoryStatsSql; + + Assert.Contains("FROM v_memory_stats", sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY collection_time DESC", sql, StringComparison.Ordinal); + Assert.Contains("LIMIT 1", sql, StringComparison.Ordinal); + + /* The snapshot takes no window — only $1 (server_id). */ + Assert.DoesNotContain("$2", sql, StringComparison.Ordinal); + + /* Every numeric(18,2) MB metric is CAST to double precision for the typed GetDouble reader. */ + foreach (var col in new[] + { + "total_physical_memory_mb", "available_physical_memory_mb", "total_page_file_mb", + "available_page_file_mb", "target_server_memory_mb", "total_server_memory_mb", + "buffer_pool_mb", "plan_cache_mb", + }) + { + Assert.Contains($"CAST({col} AS double precision)", sql, StringComparison.Ordinal); + } + + /* The two state strings are text — selected raw (GetString), never cast. */ + Assert.Contains("system_memory_state", sql, StringComparison.Ordinal); + Assert.Contains("sql_memory_model", sql, StringComparison.Ordinal); + Assert.DoesNotContain("CAST(system_memory_state", sql, StringComparison.Ordinal); + } + + [Fact] + public void MemoryGrantTrendSql_SumsGrantedMb_PerCollection_OverTheWindow() + { + var sql = ViewerDataService.MemoryGrantTrendSql; + + Assert.Contains("FROM v_memory_grant_stats", sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $2", sql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $3", sql, StringComparison.Ordinal); + Assert.Contains("CAST(SUM(granted_memory_mb) AS double precision)", sql, StringComparison.Ordinal); + Assert.Contains("GROUP BY collection_time", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY collection_time", sql, StringComparison.Ordinal); + } + + [Fact] + public void DistinctMemoryClerkTypesSql_RanksEveryTypeByTotalMemory_OverTheWindow() + { + var sql = ViewerDataService.DistinctMemoryClerkTypesSql; + + Assert.Contains("FROM v_memory_clerks", sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $2", sql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $3", sql, StringComparison.Ordinal); + Assert.Contains("GROUP BY clerk_type", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY SUM(memory_mb) DESC", sql, StringComparison.Ordinal); + } + + [Theory] + [InlineData(1, "$4", "clerk_type IN ($4)")] + [InlineData(2, "$5", "clerk_type IN ($4, $5)")] + [InlineData(4, "$7", "clerk_type IN ($4, $5, $6, $7)")] + public void MemoryClerkTrendsSql_BuildsDynamicInListFromFour_CastsMemoryToDouble(int count, string highestParam, string inClause) + { + var sql = ViewerDataService.MemoryClerkTrendsSql(count); + + /* server_id/start/end take $1/$2/$3; the clerk-type IN list starts at $4 (Lite's numbering). */ + Assert.Contains(inClause, sql, StringComparison.Ordinal); + Assert.Contains(highestParam, sql, StringComparison.Ordinal); + Assert.Contains("FROM v_memory_clerks", sql, StringComparison.Ordinal); + Assert.Contains("CAST(memory_mb AS double precision)", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY clerk_type, collection_time", sql, StringComparison.Ordinal); + } + + [Fact] + public void MemoryGrantChartDataSql_GroupsPerPool_CastsMbToDouble_CountsToBigint() + { + var sql = ViewerDataService.MemoryGrantChartDataSql; + + Assert.Contains("FROM v_memory_grant_stats", sql, StringComparison.Ordinal); + Assert.Contains("GROUP BY collection_time, pool_id", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY collection_time, pool_id", sql, StringComparison.Ordinal); + + /* pool_id is the integer group key, selected raw (GetInt32). */ + Assert.Contains("pool_id", sql, StringComparison.Ordinal); + + /* The three sizing MB SUMs plus the workspace-memory ceiling (target / max target — item 2) all + CAST to double precision. */ + foreach (var col in new[] { "available_memory_mb", "granted_memory_mb", "used_memory_mb", "target_memory_mb", "max_target_memory_mb" }) + { + Assert.Contains($"CAST(SUM({col}) AS double precision)", sql, StringComparison.Ordinal); + } + + /* The four activity count SUMs CAST to bigint (integer→bigint / bigint→numeric both land on + GetInt64). */ + foreach (var col in new[] + { + "grantee_count", "waiter_count", "timeout_error_count_delta", "forced_grant_count_delta", + }) + { + Assert.Contains($"CAST(SUM({col}) AS bigint)", sql, StringComparison.Ordinal); + } + } + + [Fact] + public void MemoryPressureEventsSql_WindowsOnSampleTime_SelectsIndicators() + { + var sql = ViewerDataService.MemoryPressureEventsSql; + + Assert.Contains("FROM v_memory_pressure_events", sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + /* Windowed on sample_time (the payload's own clock + its dedicated index), NOT collection_time. */ + Assert.Contains("sample_time >= $2", sql, StringComparison.Ordinal); + Assert.Contains("sample_time <= $3", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY sample_time", sql, StringComparison.Ordinal); + Assert.DoesNotContain("collection_time", sql, StringComparison.Ordinal); + + foreach (var col in new[] + { + "sample_time", "memory_notification", "memory_indicators_process", "memory_indicators_system", + }) + { + Assert.Contains(col, sql, StringComparison.Ordinal); + } + } + + [Theory] + [InlineData("latest")] + [InlineData("granttrend")] + [InlineData("distinctclerks")] + [InlineData("grantchart")] + [InlineData("pressure")] + public void MemoryReads_PgDialect_PositionalParams_NoBareNow_NoNLiterals(string which) + { + var sql = which switch + { + "latest" => ViewerDataService.LatestMemoryStatsSql, + "granttrend" => ViewerDataService.MemoryGrantTrendSql, + "distinctclerks" => ViewerDataService.DistinctMemoryClerkTypesSql, + "grantchart" => ViewerDataService.MemoryGrantChartDataSql, + _ => ViewerDataService.MemoryPressureEventsSql, + }; + + Assert.DoesNotContain("now(", sql.ToLowerInvariant()); + Assert.DoesNotContain("N'", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + Assert.Contains("$1", sql, StringComparison.Ordinal); + } + + [Fact] + public void MemoryReads_ReadColumnsThatExistInTheGeneratedTables() + { + Assert.Equal("memory_stats", MemoryStatsCollector.Instance.TargetTable); + Assert.Equal("memory_clerks", MemoryClerksCollector.Instance.TargetTable); + Assert.Equal("memory_grant_stats", MemoryGrantsCollector.Instance.TargetTable); + Assert.Equal("memory_pressure_events", MemoryPressureEventsCollector.Instance.TargetTable); + + var statsDdl = PgSchemaGenerator.CreateTable(MemoryStatsCollector.Instance); + foreach (var col in new[] + { + "total_physical_memory_mb", "available_physical_memory_mb", "total_page_file_mb", + "available_page_file_mb", "system_memory_state", "sql_memory_model", + "target_server_memory_mb", "total_server_memory_mb", "buffer_pool_mb", "plan_cache_mb", + }) + { + Assert.Contains(col, statsDdl, StringComparison.Ordinal); + } + + var clerksDdl = PgSchemaGenerator.CreateTable(MemoryClerksCollector.Instance); + foreach (var col in new[] { "clerk_type", "memory_mb" }) + { + Assert.Contains(col, clerksDdl, StringComparison.Ordinal); + } + + var grantDdl = PgSchemaGenerator.CreateTable(MemoryGrantsCollector.Instance); + foreach (var col in new[] + { + "pool_id", "available_memory_mb", "granted_memory_mb", "used_memory_mb", + "grantee_count", "waiter_count", "timeout_error_count_delta", "forced_grant_count_delta", + /* Item 2 verification: the workspace-memory ceiling columns the viewer now surfaces are collected. */ + "target_memory_mb", "max_target_memory_mb", + }) + { + Assert.Contains(col, grantDdl, StringComparison.Ordinal); + } + + var pressureDdl = PgSchemaGenerator.CreateTable(MemoryPressureEventsCollector.Instance); + foreach (var col in new[] + { + "sample_time", "memory_notification", "memory_indicators_process", "memory_indicators_system", + }) + { + Assert.Contains(col, pressureDdl, StringComparison.Ordinal); + } + } + + [Fact] + public void MemoryClerksAndPressureViews_AreCreatedByAMigration() + { + /* The clerk + pressure reads hit v_memory_clerks / v_memory_pressure_events, which V4/V5 left + out — a migration must create them (V6 here; the concat is robust to a V6→V7 renumber if the + plan-capture migration merges first). The other two views (v_memory_stats / + v_memory_grant_stats) already ship in V4. */ + var allMigrationSql = string.Concat(PgMigrations.Scripts.Select(m => m.Sql)); + Assert.Contains("CREATE OR REPLACE VIEW v_memory_clerks AS SELECT * FROM memory_clerks;", allMigrationSql, StringComparison.Ordinal); + Assert.Contains("CREATE OR REPLACE VIEW v_memory_pressure_events AS SELECT * FROM memory_pressure_events;", allMigrationSql, StringComparison.Ordinal); + } +} + +/// +/// Pins the Memory-tab picker + pressure-bucketing logic copied from Lite (ServerTab.Pickers.cs / +/// ServerTab.Charts.cs). The clerk picker's Top-5 / default composition and its clear-only-the-filtered +/// semantics, and the pressure chart's medium/severe classification, are all inline in the WPF-bound +/// control methods (not headlessly invokable), so these are behavior specs mirroring that logic — the +/// same approach ViewerWaitPickerLogicTests uses for the wait picker's filter/clear. +/// +public sealed class ViewerMemoryPickerLogicTests +{ + [Fact] + public void MemoryClerkDefault_SelectsTopFive_WhenNoPriorSelection() + { + /* PopulateMemoryClerkPicker: with no previously-selected clerks, the default is the first 5 of + the store-ranked (total-memory-descending) list. */ + var ranked = new List + { + "MEMORYCLERK_SQLBUFFERPOOL", "CACHESTORE_SQLCP", "MEMORYCLERK_SQLGENERAL", + "OBJECTSTORE_LOCK_MANAGER", "MEMORYCLERK_SQLLOGPOOL", "CACHESTORE_OBJCP", "MEMORYCLERK_XTP", + }; + + var previouslySelected = new HashSet(); + var topClerks = previouslySelected.Count == 0 ? new HashSet(ranked.Take(5)) : null; + var selected = ranked + .Where(c => previouslySelected.Contains(c) || (topClerks != null && topClerks.Contains(c))) + .ToList(); + + Assert.Equal(ranked.Take(5), selected); + Assert.DoesNotContain("CACHESTORE_OBJCP", selected); + Assert.DoesNotContain("MEMORYCLERK_XTP", selected); + } + + [Fact] + public void MemoryClerkTop_SelectsExactlyTheFirstFive_ByRankedOrder() + { + /* MemoryClerkSelectTop_Click: select the first 5 of the current (ranked) list, deselect the rest. */ + var items = new List + { + new() { DisplayName = "MEMORYCLERK_SQLBUFFERPOOL", IsSelected = false }, + new() { DisplayName = "CACHESTORE_SQLCP", IsSelected = true }, + new() { DisplayName = "MEMORYCLERK_SQLGENERAL", IsSelected = false }, + new() { DisplayName = "OBJECTSTORE_LOCK_MANAGER", IsSelected = false }, + new() { DisplayName = "MEMORYCLERK_SQLLOGPOOL", IsSelected = false }, + new() { DisplayName = "CACHESTORE_OBJCP", IsSelected = true }, + }; + + var topClerks = new HashSet(items.Take(5).Select(x => x.DisplayName)); + foreach (var item in items) + { + item.IsSelected = topClerks.Contains(item.DisplayName); + } + + Assert.Equal(5, items.Count(i => i.IsSelected)); + Assert.True(items.Single(i => i.DisplayName == "MEMORYCLERK_SQLBUFFERPOOL").IsSelected); + Assert.True(items.Single(i => i.DisplayName == "MEMORYCLERK_SQLLOGPOOL").IsSelected); + /* The sixth clerk (outside the top 5) is deselected even though it started selected. */ + Assert.False(items.Single(i => i.DisplayName == "CACHESTORE_OBJCP").IsSelected); + } + + [Fact] + public void MemoryClerkClearAll_ClearsOnlyTheFilteredVisibleSet_LeavingHiddenSelectionsIntact() + { + /* Behavior spec mirroring ApplyMemoryClerkFilter's case-insensitive Contains filter + + MemoryClerkClearAll_Click clearing only the currently-visible (filtered) items. */ + var items = new List + { + new() { DisplayName = "MEMORYCLERK_SQLBUFFERPOOL", IsSelected = true }, + new() { DisplayName = "CACHESTORE_SQLCP", IsSelected = true }, + new() { DisplayName = "CACHESTORE_OBJCP", IsSelected = true }, + new() { DisplayName = "MEMORYCLERK_XTP", IsSelected = true }, + }; + + const string search = "cachestore"; + var visible = items.Where(i => i.DisplayName.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList(); + foreach (var item in visible) + { + item.IsSelected = false; + } + + /* Only the two CACHESTORE_* (the filtered set) are cleared; the buffer pool + XTP persist. */ + Assert.True(items.Single(i => i.DisplayName == "MEMORYCLERK_SQLBUFFERPOOL").IsSelected); + Assert.True(items.Single(i => i.DisplayName == "MEMORYCLERK_XTP").IsSelected); + Assert.False(items.Single(i => i.DisplayName == "CACHESTORE_SQLCP").IsSelected); + Assert.False(items.Single(i => i.DisplayName == "CACHESTORE_OBJCP").IsSelected); + } + + [Fact] + public void MemoryClerkChart_TakesAtMostTwentySelected() + { + /* UpdateMemoryClerksChartFromPickerAsync caps the plotted series at 20 selected clerks. */ + var selectedAll = Enumerable.Range(0, 30) + .Select(i => new SelectableItem { DisplayName = $"CLERK_{i:D2}", IsSelected = true }) + .ToList(); + + var plotted = selectedAll.Where(i => i.IsSelected).Take(20).ToList(); + + Assert.Equal(20, plotted.Count); + } + + [Fact] + public void PressureBucketing_FiltersBelowMedium_ClassifiesMediumSevere_PerHour_ProcessVsSystem() + { + /* RenderMemoryPressureEventsChart: keep rows where process>=2 OR system>=2, bucket by the hour of + sample_time, and per hour count SQL Server (process) medium (==2) vs severe (>=3) and OS + (system) medium vs severe. */ + var h9 = new DateTime(2026, 6, 1, 9, 0, 0, DateTimeKind.Unspecified); + var h10 = new DateTime(2026, 6, 1, 10, 0, 0, DateTimeKind.Unspecified); + + var data = new List + { + /* 09:xx — SQL: one medium (2) + one severe (4); OS: one medium (2). */ + new(h9.AddMinutes(5), "RESOURCE_MEMPHYSICAL_LOW", 2, 0), + new(h9.AddMinutes(20), "RESOURCE_MEMPHYSICAL_LOW", 4, 2), + /* below-threshold on both axes — must be filtered out entirely. */ + new(h9.AddMinutes(40), "RESOURCE_MEMPHYSICAL_HIGH", 1, 1), + /* 10:xx — SQL: one severe (3); OS: one severe (5). */ + new(h10.AddMinutes(15), "RESOURCE_MEMPHYSICAL_LOW", 3, 5), + }; + + var pressureRows = data + .Where(d => d.MemoryIndicatorsProcess >= 2 || d.MemoryIndicatorsSystem >= 2) + .ToList(); + Assert.Equal(3, pressureRows.Count); // the 1/1 row dropped + + var buckets = pressureRows + .GroupBy(d => new DateTime(d.SampleTime.Year, d.SampleTime.Month, d.SampleTime.Day, d.SampleTime.Hour, 0, 0)) + .OrderBy(g => g.Key) + .Select(g => new + { + g.Key, + SqlMedium = g.Count(d => d.MemoryIndicatorsProcess == 2), + SqlSevere = g.Count(d => d.MemoryIndicatorsProcess >= 3), + OsMedium = g.Count(d => d.MemoryIndicatorsSystem == 2), + OsSevere = g.Count(d => d.MemoryIndicatorsSystem >= 3), + }) + .ToList(); + + Assert.Equal(2, buckets.Count); + + var nine = buckets[0]; + Assert.Equal(h9, nine.Key); + Assert.Equal(1, nine.SqlMedium); // the process==2 row + Assert.Equal(1, nine.SqlSevere); // the process==4 row + Assert.Equal(1, nine.OsMedium); // the system==2 row + Assert.Equal(0, nine.OsSevere); + + var ten = buckets[1]; + Assert.Equal(h10, ten.Key); + Assert.Equal(0, ten.SqlMedium); + Assert.Equal(1, ten.SqlSevere); // process==3 + Assert.Equal(0, ten.OsMedium); + Assert.Equal(1, ten.OsSevere); // system==5 + } +} + +/// +/// Gated (DARLING_TEST_PG) live round-trips for the six NEW Memory-tab reads: the Overview latest-snapshot +/// (MB-as-double + the two state strings, newest by collection_time), the grant overlay (granted-MB SUM +/// per collection), the clerk ranking (by total memory descending), the batched clerk trend (dynamic IN + +/// per-type grouping + MB-as-double), the grant chart aggregation (per-pool sizing MB-as-double + the +/// load-bearing bigint count SUMs), and the pressure samples (windowed on sample_time). The reused +/// GetMemoryTrendAsync is already covered by ViewerOverviewLanesTests. Shares the serialized +/// "live-postgres" collection; uses negative sentinel server_ids and cleans up in finally. +/// +[Collection("live-postgres")] +public sealed class ViewerMemoryLivePostgresTests +{ + private const int StatsServerId = -930001; + private const string StatsServerName = "viewer-memory-stats-e2e"; + + private const int GrantTrendServerId = -930002; + private const string GrantTrendServerName = "viewer-memory-granttrend-e2e"; + + private const int ClerkServerId = -930003; + private const string ClerkServerName = "viewer-memory-clerks-e2e"; + + private const int GrantChartServerId = -930004; + private const string GrantChartServerName = "viewer-memory-grantchart-e2e"; + + private const int PressureServerId = -930005; + private const string PressureServerName = "viewer-memory-pressure-e2e"; + + [Fact] + public async Task LatestMemoryStats_ReturnsNewestSnapshot_MbAsDouble_StateStrings_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live memory-stats test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "memory_stats", StatsServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var older = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-20)); + var newer = older.AddMinutes(10); + + await InsertMemoryStatsAsync(connection, older, + totalPhys: 111.00m, availPhys: 22.00m, totalPage: 200.00m, availPage: 40.00m, + state: "Steady", model: "CONVENTIONAL", target: 90.00m, total: 88.00m, buffer: 70.00m, plan: 5.00m); + await InsertMemoryStatsAsync(connection, newer, + totalPhys: 8192.00m, availPhys: 1024.50m, totalPage: 16384.00m, availPage: 4096.25m, + state: "Available physical memory is high", model: "LOCK_PAGES", target: 6000.00m, total: 5800.75m, buffer: 5000.00m, plan: 250.50m); + + var stats = await viewer.GetLatestMemoryStatsAsync(StatsServerId); + + Assert.NotNull(stats); + /* Newest snapshot wins (ORDER BY collection_time DESC LIMIT 1). */ + Assert.Equal(newer.Ticks, stats!.CollectionTime.Ticks); + Assert.Equal(8192.00, stats.TotalPhysicalMemoryMb, precision: 2); + Assert.Equal(1024.50, stats.AvailablePhysicalMemoryMb, precision: 2); + Assert.Equal(16384.00, stats.TotalPageFileMb, precision: 2); + Assert.Equal(4096.25, stats.AvailablePageFileMb, precision: 2); + Assert.Equal(6000.00, stats.TargetServerMemoryMb, precision: 2); + Assert.Equal(5800.75, stats.TotalServerMemoryMb, precision: 2); + Assert.Equal(5000.00, stats.BufferPoolMb, precision: 2); + Assert.Equal(250.50, stats.PlanCacheMb, precision: 2); + Assert.Equal("Available physical memory is high", stats.SystemMemoryState); + Assert.Equal("LOCK_PAGES", stats.SqlMemoryModel); + } + finally + { + await DeleteRowsAsync(connection, "memory_stats", StatsServerId); + } + } + + [Fact] + public async Task MemoryGrantTrend_SumsGrantedMbPerCollection_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live grant-trend test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "memory_grant_stats", GrantTrendServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var t1 = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + var t2 = t1.AddMinutes(5); + + /* t1 has two pools (100 + 25 = 125 granted); t2 has one pool (300). */ + await InsertMemoryGrantAsync(connection, GrantTrendServerId, GrantTrendServerName, t1, poolId: 1, + availMb: 900m, grantedMb: 100m, usedMb: 80m, grantee: 2, waiter: 0, timeoutDelta: 0, forcedDelta: 0); + await InsertMemoryGrantAsync(connection, GrantTrendServerId, GrantTrendServerName, t1, poolId: 2, + availMb: 400m, grantedMb: 25m, usedMb: 20m, grantee: 1, waiter: 0, timeoutDelta: 0, forcedDelta: 0); + await InsertMemoryGrantAsync(connection, GrantTrendServerId, GrantTrendServerName, t2, poolId: 1, + availMb: 700m, grantedMb: 300m, usedMb: 250m, grantee: 5, waiter: 1, timeoutDelta: 0, forcedDelta: 0); + + var trend = await viewer.GetMemoryGrantTrendAsync(GrantTrendServerId, t1.AddMinutes(-1), t2.AddMinutes(1)); + + Assert.Equal(2, trend.Count); + Assert.Equal(t1.Ticks, trend[0].CollectionTime.Ticks); + Assert.Equal(125.0, trend[0].TotalGrantedMb, precision: 2); + Assert.Equal(t2.Ticks, trend[1].CollectionTime.Ticks); + Assert.Equal(300.0, trend[1].TotalGrantedMb, precision: 2); + } + finally + { + await DeleteRowsAsync(connection, "memory_grant_stats", GrantTrendServerId); + } + } + + [Fact] + public async Task MemoryClerks_RankByTotalMemory_AndBatchedTrendByType_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live memory-clerks test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "memory_clerks", ClerkServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var t1 = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + var t2 = t1.AddMinutes(5); + + /* BUFFERPOOL total = 500 + 520 = 1020; GENERAL total = 30 + 40 = 70 → BUFFERPOOL ranks first. + THIRD is a type NOT requested in the batched trend, to prove the IN filter excludes it. */ + await InsertMemoryClerkAsync(connection, t1, "MEMORYCLERK_SQLBUFFERPOOL", 500.00m); + await InsertMemoryClerkAsync(connection, t2, "MEMORYCLERK_SQLBUFFERPOOL", 520.00m); + await InsertMemoryClerkAsync(connection, t1, "MEMORYCLERK_SQLGENERAL", 30.00m); + await InsertMemoryClerkAsync(connection, t2, "MEMORYCLERK_SQLGENERAL", 40.00m); + await InsertMemoryClerkAsync(connection, t2, "MEMORYCLERK_XTP", 999.00m); + + var window = (t1.AddMinutes(-1), t2.AddMinutes(1)); + + var ranked = await viewer.GetDistinctMemoryClerkTypesAsync(ClerkServerId, window.Item1, window.Item2); + /* Ranked by SUM(memory_mb) DESC: BUFFERPOOL's two rows total 1020, XTP's single row is 999, + GENERAL totals 70 — so BUFFERPOOL leads despite XTP's larger single value (proves the + ranking sums across collections, not per-row). */ + Assert.Equal(new[] { "MEMORYCLERK_SQLBUFFERPOOL", "MEMORYCLERK_XTP", "MEMORYCLERK_SQLGENERAL" }, ranked); + + var trends = await viewer.GetMemoryClerkTrendsByTypesAsync( + ClerkServerId, new List { "MEMORYCLERK_SQLBUFFERPOOL", "MEMORYCLERK_SQLGENERAL" }, + window.Item1, window.Item2); + + Assert.True(trends.ContainsKey("MEMORYCLERK_SQLBUFFERPOOL")); + Assert.True(trends.ContainsKey("MEMORYCLERK_SQLGENERAL")); + Assert.False(trends.ContainsKey("MEMORYCLERK_XTP")); // excluded by the IN filter + + var bp = trends["MEMORYCLERK_SQLBUFFERPOOL"]; + Assert.Equal(2, bp.Count); + Assert.Equal(t1.Ticks, bp[0].CollectionTime.Ticks); + Assert.Equal(500.00, bp[0].MemoryMb, precision: 2); + Assert.Equal(520.00, bp[1].MemoryMb, precision: 2); + } + finally + { + await DeleteRowsAsync(connection, "memory_clerks", ClerkServerId); + } + } + + [Fact] + public async Task MemoryClerkTrends_EmptySelection_ReturnsEmpty_WithoutTouchingStore() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live empty-clerk-selection test."); + + await using var viewer = new ViewerDataService(connectionString!); + + var trends = await viewer.GetMemoryClerkTrendsByTypesAsync( + ClerkServerId, new List(), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow); + + Assert.Empty(trends); + } + + [Fact] + public async Task MemoryGrantChart_AggregatesPerPool_MbAsDouble_BigintCounts_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live grant-chart test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "memory_grant_stats", GrantChartServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var t1 = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + + /* Pool 1 at t1 has TWO resource semaphores that must SUM together; pool 2 is a second group. + forcedDelta on pool 1 deliberately exceeds int.MaxValue so a GetInt32 reader would throw — + the load-bearing bigint check for the delta counts. */ + const long bigForced = 5_000_000_000L; + await InsertMemoryGrantAsync(connection, GrantChartServerId, GrantChartServerName, t1, poolId: 1, + availMb: 600.00m, grantedMb: 100.00m, usedMb: 90.00m, grantee: 2, waiter: 1, timeoutDelta: 3, forcedDelta: bigForced); + await InsertMemoryGrantAsync(connection, GrantChartServerId, GrantChartServerName, t1, poolId: 1, + availMb: 400.00m, grantedMb: 50.00m, usedMb: 40.00m, grantee: 1, waiter: 0, timeoutDelta: 4, forcedDelta: 1); + await InsertMemoryGrantAsync(connection, GrantChartServerId, GrantChartServerName, t1, poolId: 2, + availMb: 200.00m, grantedMb: 20.00m, usedMb: 10.00m, grantee: 3, waiter: 2, timeoutDelta: 0, forcedDelta: 0); + + var rows = await viewer.GetMemoryGrantChartDataAsync(GrantChartServerId, t1.AddMinutes(-1), t1.AddMinutes(1)); + + /* Two groups: (t1, pool 1) and (t1, pool 2), ordered by collection_time, pool_id. */ + Assert.Equal(2, rows.Count); + + var pool1 = rows[0]; + Assert.Equal(1, pool1.PoolId); + Assert.Equal(1000.00, pool1.AvailableMemoryMb, precision: 2); // 600 + 400 + Assert.Equal(150.00, pool1.GrantedMemoryMb, precision: 2); // 100 + 50 + Assert.Equal(130.00, pool1.UsedMemoryMb, precision: 2); // 90 + 40 + Assert.Equal(3, pool1.GranteeCount); // 2 + 1 + Assert.Equal(1, pool1.WaiterCount); // 1 + 0 + Assert.Equal(7L, pool1.TimeoutErrorCountDelta); // 3 + 4 + Assert.Equal(bigForced + 1L, pool1.ForcedGrantCountDelta); // bigint SUM + + var pool2 = rows[1]; + Assert.Equal(2, pool2.PoolId); + Assert.Equal(20.00, pool2.GrantedMemoryMb, precision: 2); + Assert.Equal(3, pool2.GranteeCount); + Assert.Equal(2, pool2.WaiterCount); + } + finally + { + await DeleteRowsAsync(connection, "memory_grant_stats", GrantChartServerId); + } + } + + [Fact] + public async Task MemoryPressureEvents_WindowedOnSampleTime_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live pressure-events test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "memory_pressure_events", PressureServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var collUtc = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-30)); + var inWindowOld = collUtc.AddMinutes(1); + var inWindowNew = collUtc.AddMinutes(9); + var beforeWindow = collUtc.AddMinutes(-120); // outside the [start, end] sample_time filter + + await InsertMemoryPressureAsync(connection, collUtc, inWindowNew, "RESOURCE_MEMPHYSICAL_LOW", process: 3, system: 2); + await InsertMemoryPressureAsync(connection, collUtc, inWindowOld, "RESOURCE_MEMPHYSICAL_LOW", process: 2, system: 0); + await InsertMemoryPressureAsync(connection, collUtc, beforeWindow, "RESOURCE_MEMPHYSICAL_HIGH", process: 0, system: 0); + + var events = await viewer.GetMemoryPressureEventsAsync( + PressureServerId, collUtc, collUtc.AddMinutes(10)); + + /* Only the two in-window samples survive the sample_time window; ordered by sample_time. */ + Assert.Equal(2, events.Count); + Assert.Equal(inWindowOld.Ticks, events[0].SampleTime.Ticks); + Assert.Equal(2, events[0].MemoryIndicatorsProcess); + Assert.Equal(0, events[0].MemoryIndicatorsSystem); + Assert.Equal(inWindowNew.Ticks, events[1].SampleTime.Ticks); + Assert.Equal(3, events[1].MemoryIndicatorsProcess); + Assert.Equal(2, events[1].MemoryIndicatorsSystem); + Assert.Equal("RESOURCE_MEMPHYSICAL_LOW", events[1].MemoryNotification); + } + finally + { + await DeleteRowsAsync(connection, "memory_pressure_events", PressureServerId); + } + } + + private static async Task InsertMemoryStatsAsync( + NpgsqlConnection connection, DateTime collectionTimeUtc, + decimal totalPhys, decimal availPhys, decimal totalPage, decimal availPage, + string state, string model, decimal target, decimal total, decimal buffer, decimal plan) + { + using var command = new NpgsqlCommand(@" +INSERT INTO memory_stats + (collection_id, collection_time, server_id, server_name, + total_physical_memory_mb, available_physical_memory_mb, total_page_file_mb, available_page_file_mb, + system_memory_state, sql_memory_model, + target_server_memory_mb, total_server_memory_mb, buffer_pool_mb, plan_cache_mb) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(StatsServerId); + command.Parameters.AddWithValue(StatsServerName); + command.Parameters.AddWithValue(totalPhys); + command.Parameters.AddWithValue(availPhys); + command.Parameters.AddWithValue(totalPage); + command.Parameters.AddWithValue(availPage); + command.Parameters.AddWithValue(state); + command.Parameters.AddWithValue(model); + command.Parameters.AddWithValue(target); + command.Parameters.AddWithValue(total); + command.Parameters.AddWithValue(buffer); + command.Parameters.AddWithValue(plan); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertMemoryClerkAsync( + NpgsqlConnection connection, DateTime collectionTimeUtc, string clerkType, decimal memoryMb) + { + using var command = new NpgsqlCommand(@" +INSERT INTO memory_clerks + (collection_id, collection_time, server_id, server_name, clerk_type, memory_mb) +VALUES ($1, $2, $3, $4, $5, $6)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(ClerkServerId); + command.Parameters.AddWithValue(ClerkServerName); + command.Parameters.AddWithValue(clerkType); + command.Parameters.AddWithValue(memoryMb); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertMemoryGrantAsync( + NpgsqlConnection connection, int serverId, string serverName, DateTime collectionTimeUtc, int poolId, + decimal availMb, decimal grantedMb, decimal usedMb, int grantee, int waiter, long timeoutDelta, long forcedDelta) + { + using var command = new NpgsqlCommand(@" +INSERT INTO memory_grant_stats + (collection_id, collection_time, server_id, server_name, pool_id, + available_memory_mb, granted_memory_mb, used_memory_mb, + grantee_count, waiter_count, timeout_error_count_delta, forced_grant_count_delta) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(serverName); + command.Parameters.AddWithValue(poolId); + command.Parameters.AddWithValue(availMb); + command.Parameters.AddWithValue(grantedMb); + command.Parameters.AddWithValue(usedMb); + command.Parameters.AddWithValue(grantee); + command.Parameters.AddWithValue(waiter); + command.Parameters.AddWithValue(timeoutDelta); + command.Parameters.AddWithValue(forcedDelta); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertMemoryPressureAsync( + NpgsqlConnection connection, DateTime collectionTimeUtc, DateTime sampleTimeUtc, + string notification, int process, int system) + { + using var command = new NpgsqlCommand(@" +INSERT INTO memory_pressure_events + (collection_id, collection_time, server_id, server_name, + sample_time, memory_notification, memory_indicators_process, memory_indicators_system) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(PressureServerId); + command.Parameters.AddWithValue(PressureServerName); + command.Parameters.AddWithValue(DateTime.SpecifyKind(sampleTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(notification); + command.Parameters.AddWithValue(process); + command.Parameters.AddWithValue(system); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static DateTime TruncateToSeconds(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified); + + private static async Task DeleteRowsAsync(NpgsqlConnection connection, string table, int serverId) + { + using var cleanup = new NpgsqlCommand($"DELETE FROM {table} WHERE server_id = {serverId};", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } +} diff --git a/Darling/Darling.Tests/ViewerOverviewLanesTests.cs b/Darling/Darling.Tests/ViewerOverviewLanesTests.cs new file mode 100644 index 000000000..b092b4d6f --- /dev/null +++ b/Darling/Darling.Tests/ViewerOverviewLanesTests.cs @@ -0,0 +1,299 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading.Tasks; +using Npgsql; +using NpgsqlTypes; +using PerformanceMonitor.Analysis.Baselines; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Analysis; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the two W1d Overview-lanes reads that don't already exist from earlier waves — the single-line +/// TOTAL wait trend and the memory trend — against the Darling store contract (no live Postgres). The +/// lanes' other four feeds (raw CPU from W1a; blocking/deadlock/file-IO from W1c) keep their own wave's +/// pins. This wave replaces wave-4's Overview trend reads (CpuTrendSql / WaitCategoryTrendSql / +/// RollUpWaitCategories, whose ViewerTrendsTests were removed): the lanes plot raw CPU (W1a's +/// read) and a single total-wait line rather than an averaged CPU trend + per-category wait roll-up, so +/// those reads and their unit tests no longer exist. The shared ChartPalette.WaitCategory +/// classifier the roll-up exercised is still covered by the plan-viewer wait list and its own tests. +/// +public sealed class ViewerOverviewLanesSqlTests +{ + [Fact] + public void TotalWaitTrendSql_SumsAllTypesPerCollection_PerSecondFromLagInterval() + { + Assert.Contains("FROM v_wait_stats", ViewerDataService.TotalWaitTrendSql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", ViewerDataService.TotalWaitTrendSql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $2", ViewerDataService.TotalWaitTrendSql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $3", ViewerDataService.TotalWaitTrendSql, StringComparison.Ordinal); + + /* SUM across ALL wait types per collection (the single-line total), the LAG-derived collection + interval, and the per-second division with the delta CAST to double for the typed reader. */ + Assert.Contains("SUM(delta_wait_time_ms)", ViewerDataService.TotalWaitTrendSql, StringComparison.Ordinal); + Assert.Contains("LAG(collection_time)", ViewerDataService.TotalWaitTrendSql, StringComparison.Ordinal); + Assert.Contains("CAST(total_delta_ms AS double precision) / interval_seconds", ViewerDataService.TotalWaitTrendSql, StringComparison.Ordinal); + Assert.Contains("GROUP BY collection_time", ViewerDataService.TotalWaitTrendSql, StringComparison.Ordinal); + Assert.Contains("ORDER BY collection_time", ViewerDataService.TotalWaitTrendSql, StringComparison.Ordinal); + } + + [Fact] + public void TotalWaitTrendSql_DropsLitesIgnoredWaitExclusionClause() + { + /* Lite splices an "AND wait_type NOT IN (...)" per-user ignored-wait exclusion; the viewer has + no per-user ignore config, so the ported read carries no NOT IN clause (matches W1b). */ + Assert.DoesNotContain("NOT IN", ViewerDataService.TotalWaitTrendSql, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void MemoryTrendSql_CastsAllFourMbMetricsToDouble_OverTheWindow() + { + Assert.Contains("FROM v_memory_stats", ViewerDataService.MemoryTrendSql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", ViewerDataService.MemoryTrendSql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $2", ViewerDataService.MemoryTrendSql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $3", ViewerDataService.MemoryTrendSql, StringComparison.Ordinal); + Assert.Contains("ORDER BY collection_time", ViewerDataService.MemoryTrendSql, StringComparison.Ordinal); + + /* Every MB column is numeric(18,2) in the store; without the cast the typed GetDouble reader + throws (the same numeric→double reason the CPU/File-IO reads cast). */ + foreach (var column in new[] { "total_server_memory_mb", "target_server_memory_mb", "buffer_pool_mb", "plan_cache_mb" }) + { + Assert.Contains($"CAST({column} AS double precision)", ViewerDataService.MemoryTrendSql, StringComparison.Ordinal); + } + } + + [Theory] + [InlineData(nameof(ViewerDataService.TotalWaitTrendSql))] + [InlineData(nameof(ViewerDataService.MemoryTrendSql))] + public void TrendSql_PgDialect_PositionalParams_NoBareNow_NoNLiterals(string which) + { + var sql = which == nameof(ViewerDataService.TotalWaitTrendSql) + ? ViewerDataService.TotalWaitTrendSql + : ViewerDataService.MemoryTrendSql; + + Assert.DoesNotContain("now(", sql.ToLowerInvariant()); + Assert.DoesNotContain("N'", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + Assert.Contains("$1", sql, StringComparison.Ordinal); + Assert.Contains("$2", sql, StringComparison.Ordinal); + Assert.Contains("$3", sql, StringComparison.Ordinal); + } + + [Fact] + public void TotalWaitTrendSql_ReadsColumnsThatExistInTheGeneratedWaitTable() + { + Assert.Equal("wait_stats", WaitStatsCollector.Instance.TargetTable); + + var ddl = PgSchemaGenerator.CreateTable(WaitStatsCollector.Instance); + foreach (var column in new[] { "collection_time", "delta_wait_time_ms" }) + { + Assert.Contains(column, ddl, StringComparison.Ordinal); + } + } + + [Fact] + public void MemoryTrendSql_ReadsColumnsThatExistInTheGeneratedMemoryTable() + { + Assert.Equal("memory_stats", MemoryStatsCollector.Instance.TargetTable); + + var ddl = PgSchemaGenerator.CreateTable(MemoryStatsCollector.Instance); + foreach (var column in new[] { "collection_time", "total_server_memory_mb", "target_server_memory_mb", "buffer_pool_mb", "plan_cache_mb" }) + { + Assert.Contains(column, ddl, StringComparison.Ordinal); + } + } +} + +/// +/// Gated (DARLING_TEST_PG) live round-trips for the W1d Overview-lanes reads: the total-wait per-second +/// rate (SUM across all types divided by the LAG-derived collection interval, first collection reading 0 +/// on a NULL interval), the four-MB memory read (numeric→double), and the per-lane baseline lookup +/// (graceful on no history — the baseline COMPUTATION itself is +/// covered by the Analysis suite; this pins the viewer's delegation). Shares the serialized +/// "live-postgres" collection; uses negative sentinel server_ids and cleans up in finally. +/// +[Collection("live-postgres")] +public sealed class ViewerOverviewLanesLivePostgresTests +{ + private const int WaitServerId = -949494; + private const string WaitServerName = "viewer-lanes-wait-e2e"; + + private const int MemoryServerId = -959595; + private const string MemoryServerName = "viewer-lanes-memory-e2e"; + + private const int BaselineServerId = -969696; + + [Fact] + public async Task TotalWaitTrend_SumsAcrossTypes_PerSecondFromLagInterval_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live total-wait-trend test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteWaitRowsAsync(connection); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var t1 = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + var t2 = t1.AddSeconds(60); + + /* Two collections, two wait types each. The first collection has no prior LAG (interval + NULL → per-second rate 0); the second is 60 seconds later, so its total delta divides by + 60. Totals sum ACROSS types: t2 = (60 + 120) / 60 = 3.0 ms/sec. */ + await InsertWaitRowAsync(connection, 1, t1, "WAIT_A", 100); + await InsertWaitRowAsync(connection, 1, t1, "WAIT_B", 200); + await InsertWaitRowAsync(connection, 2, t2, "WAIT_A", 60); + await InsertWaitRowAsync(connection, 2, t2, "WAIT_B", 120); + + var points = await viewer.GetTotalWaitTrendAsync(WaitServerId, t1.AddMinutes(-1), t2.AddMinutes(1)); + + Assert.Equal(2, points.Count); + + /* Ordered by collection_time; first collection's NULL interval reads as 0. */ + Assert.Equal(t1.Ticks, points[0].CollectionTime.Ticks); + Assert.Equal(0.0, points[0].WaitTimeMsPerSecond, precision: 3); + + Assert.Equal(t2.Ticks, points[1].CollectionTime.Ticks); + Assert.Equal(3.0, points[1].WaitTimeMsPerSecond, precision: 3); + } + finally + { + await DeleteWaitRowsAsync(connection); + } + } + + [Fact] + public async Task MemoryTrend_ReadsFourMbMetricsAsDouble_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live memory-trend test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteMemoryRowsAsync(connection); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var t1 = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + var t2 = t1.AddMinutes(5); + + /* Fractional buffer-pool value proves numeric(18,2) → double preserves the cents. */ + await InsertMemoryRowAsync(connection, 1, t1, total: 1000.00m, target: 2000.00m, buffer: 800.50m, planCache: 150.00m); + await InsertMemoryRowAsync(connection, 2, t2, total: 1100.00m, target: 2000.00m, buffer: 850.25m, planCache: 160.00m); + + var points = await viewer.GetMemoryTrendAsync(MemoryServerId, t1.AddMinutes(-1), t2.AddMinutes(1)); + + Assert.Equal(2, points.Count); + + /* Ordered by collection_time; every metric reads as a double via the SQL CAST. */ + Assert.Equal(t1.Ticks, points[0].CollectionTime.Ticks); + Assert.Equal(1000.0, points[0].TotalServerMemoryMb, precision: 2); + Assert.Equal(2000.0, points[0].TargetServerMemoryMb, precision: 2); + Assert.Equal(800.50, points[0].BufferPoolMb, precision: 2); + Assert.Equal(150.0, points[0].PlanCacheMb, precision: 2); + + Assert.Equal(t2.Ticks, points[1].CollectionTime.Ticks); + Assert.Equal(850.25, points[1].BufferPoolMb, precision: 2); + } + finally + { + await DeleteMemoryRowsAsync(connection); + } + } + + [Fact] + public async Task BaselineForLane_NoHistory_ReturnsEmptyBucket_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live baseline-lane test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + + await using var viewer = new ViewerDataService(connectionString!); + + /* A sentinel server with no CPU history: the CPU baseline query runs against the real schema + (v_cpu_utilization_stats), returns no rows, and the lane read hands back Empty (SampleCount 0) + so the lane renderer's guard skips the band. Proves the viewer's PgBaselineProvider wiring. */ + var baseline = await viewer.GetBaselineForLaneAsync(BaselineServerId, MetricNames.Cpu, DateTime.UtcNow); + + Assert.Equal(0, baseline.SampleCount); + } + + private static async Task InsertWaitRowAsync( + NpgsqlConnection connection, long collectionId, DateTime collectionTimeUtc, string waitType, long deltaWaitTimeMs) + { + using var command = new NpgsqlCommand(@" +INSERT INTO wait_stats + (collection_id, collection_time, server_id, server_name, wait_type, + waiting_tasks_count, wait_time_ms, signal_wait_time_ms, + delta_waiting_tasks, delta_wait_time_ms, delta_signal_wait_time_ms) +VALUES ($1, $2, $3, $4, $5, 0, 0, 0, 0, $6, 0)", connection); + command.Parameters.AddWithValue(collectionId); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(WaitServerId); + command.Parameters.AddWithValue(WaitServerName); + command.Parameters.AddWithValue(waitType); + command.Parameters.AddWithValue(deltaWaitTimeMs); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertMemoryRowAsync( + NpgsqlConnection connection, long collectionId, DateTime collectionTimeUtc, + decimal total, decimal target, decimal buffer, decimal planCache) + { + using var command = new NpgsqlCommand(@" +INSERT INTO memory_stats + (collection_id, collection_time, server_id, server_name, + target_server_memory_mb, total_server_memory_mb, buffer_pool_mb, plan_cache_mb) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", connection); + command.Parameters.AddWithValue(collectionId); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(MemoryServerId); + command.Parameters.AddWithValue(MemoryServerName); + command.Parameters.Add(new NpgsqlParameter { Value = target, NpgsqlDbType = NpgsqlDbType.Numeric }); + command.Parameters.Add(new NpgsqlParameter { Value = total, NpgsqlDbType = NpgsqlDbType.Numeric }); + command.Parameters.Add(new NpgsqlParameter { Value = buffer, NpgsqlDbType = NpgsqlDbType.Numeric }); + command.Parameters.Add(new NpgsqlParameter { Value = planCache, NpgsqlDbType = NpgsqlDbType.Numeric }); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static DateTime TruncateToSeconds(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified); + + private static async Task DeleteWaitRowsAsync(NpgsqlConnection connection) + { + using var cleanup = new NpgsqlCommand( + $"DELETE FROM wait_stats WHERE server_id = {WaitServerId};", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task DeleteMemoryRowsAsync(NpgsqlConnection connection) + { + using var cleanup = new NpgsqlCommand( + $"DELETE FROM memory_stats WHERE server_id = {MemoryServerId};", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } +} diff --git a/Darling/Darling.Tests/ViewerPerfmonRunningJobsTests.cs b/Darling/Darling.Tests/ViewerPerfmonRunningJobsTests.cs new file mode 100644 index 000000000..3648a19fd --- /dev/null +++ b/Darling/Darling.Tests/ViewerPerfmonRunningJobsTests.cs @@ -0,0 +1,511 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using PerformanceMonitor.Ui; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the Perfmon tab's two ported reads against the Darling store contract (no live Postgres): the +/// distinct-counter population read and the batched per-counter trend read, both copied from Lite's +/// LocalDataService.Perfmon.cs and run on the v_perfmon_stats passthrough view. The one +/// deviation from Lite's SQL text is the CAST(SUM(...) AS bigint) wrap, because Postgres' +/// SUM(bigint) returns numeric while the typed GetInt64 reader (and DuckDB) want an +/// integral type. +/// +public sealed class ViewerPerfmonSqlTests +{ + [Fact] + public void DistinctPerfmonCountersSql_ListsEveryCounter_OverTheWindow() + { + Assert.Contains("SELECT DISTINCT counter_name", ViewerDataService.DistinctPerfmonCountersSql, StringComparison.Ordinal); + Assert.Contains("FROM v_perfmon_stats", ViewerDataService.DistinctPerfmonCountersSql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", ViewerDataService.DistinctPerfmonCountersSql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $2", ViewerDataService.DistinctPerfmonCountersSql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $3", ViewerDataService.DistinctPerfmonCountersSql, StringComparison.Ordinal); + Assert.Contains("ORDER BY counter_name", ViewerDataService.DistinctPerfmonCountersSql, StringComparison.Ordinal); + } + + [Fact] + public void PerfmonTrendsSql_SumsValueAndDelta_GroupedByCounterAndTime() + { + var sql = ViewerDataService.PerfmonTrendsSql(3); + + Assert.Contains("FROM v_perfmon_stats", sql, StringComparison.Ordinal); + /* The two aggregates are CAST to bigint for the typed GetInt64 reader (Postgres SUM(bigint) + returns numeric). */ + Assert.Contains("CAST(SUM(cntr_value) AS bigint)", sql, StringComparison.Ordinal); + Assert.Contains("CAST(SUM(delta_cntr_value) AS bigint)", sql, StringComparison.Ordinal); + Assert.Contains("GROUP BY counter_name, collection_time", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY counter_name, collection_time", sql, StringComparison.Ordinal); + } + + [Theory] + [InlineData(1, "$4", "IN ($4)")] + [InlineData(3, "$6", "IN ($4, $5, $6)")] + public void PerfmonTrendsSql_BuildsDynamicInListFromFour(int count, string highestParam, string inClause) + { + var sql = ViewerDataService.PerfmonTrendsSql(count); + /* server_id/start/end take $1/$2/$3; the counter-name IN list starts at $4 (Lite's numbering). */ + Assert.Contains("server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $2", sql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $3", sql, StringComparison.Ordinal); + Assert.Contains(inClause, sql, StringComparison.Ordinal); + Assert.Contains(highestParam, sql, StringComparison.Ordinal); + } + + [Fact] + public void PerfmonSql_PgDialect_PositionalParams_NoBareNow_NoNLiterals() + { + foreach (var sql in new[] { ViewerDataService.DistinctPerfmonCountersSql, ViewerDataService.PerfmonTrendsSql(2) }) + { + Assert.DoesNotContain("now(", sql.ToLowerInvariant()); + Assert.DoesNotContain("N'", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + Assert.Contains("$1", sql, StringComparison.Ordinal); + Assert.Contains("$2", sql, StringComparison.Ordinal); + Assert.Contains("$3", sql, StringComparison.Ordinal); + } + } + + [Fact] + public void PerfmonSql_ReadsColumnsThatExistInTheGeneratedPerfmonTable() + { + Assert.Equal("perfmon_stats", PerfmonStatsCollector.Instance.TargetTable); + + var ddl = PgSchemaGenerator.CreateTable(PerfmonStatsCollector.Instance); + foreach (var column in new[] { "collection_time", "counter_name", "cntr_value", "delta_cntr_value" }) + { + Assert.Contains(column, ddl, StringComparison.Ordinal); + } + } +} + +/// +/// Pins the Running Jobs tab's two ported reads (no live Postgres): the latest-snapshot grid read +/// (copied from Lite's GetRunningJobsAsync, run on v_running_jobs) and the msdb-warning +/// derivation read that stands in for Lite's live _hasMsdbAccess probe — the running_jobs +/// collector's most recent collection_log status. +/// +public sealed class ViewerRunningJobsSqlTests +{ + [Fact] + public void RunningJobsSql_ReturnsLatestSnapshotOnly_OrderedByCurrentDurationDesc() + { + Assert.Contains("FROM v_running_jobs", ViewerDataService.RunningJobsSql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", ViewerDataService.RunningJobsSql, StringComparison.Ordinal); + /* Latest snapshot: collection_time = MAX(collection_time) self-subquery for the same server. */ + Assert.Contains("collection_time = (", ViewerDataService.RunningJobsSql, StringComparison.Ordinal); + Assert.Contains("SELECT MAX(collection_time)", ViewerDataService.RunningJobsSql, StringComparison.Ordinal); + Assert.Contains("ORDER BY current_duration_seconds DESC", ViewerDataService.RunningJobsSql, StringComparison.Ordinal); + } + + [Fact] + public void RunningJobsCollectorStatusSql_ReadsLatestRunningJobsStatus_FromCollectionLog() + { + Assert.Contains("FROM collection_log", ViewerDataService.RunningJobsCollectorStatusSql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", ViewerDataService.RunningJobsCollectorStatusSql, StringComparison.Ordinal); + Assert.Contains("collector_name = 'running_jobs'", ViewerDataService.RunningJobsCollectorStatusSql, StringComparison.Ordinal); + Assert.Contains("ORDER BY collection_time DESC", ViewerDataService.RunningJobsCollectorStatusSql, StringComparison.Ordinal); + Assert.Contains("LIMIT 1", ViewerDataService.RunningJobsCollectorStatusSql, StringComparison.Ordinal); + } + + [Fact] + public void RunningJobsSql_PgDialect_PositionalParams_NoBareNow_NoNLiterals() + { + foreach (var sql in new[] { ViewerDataService.RunningJobsSql, ViewerDataService.RunningJobsCollectorStatusSql }) + { + Assert.DoesNotContain("now(", sql.ToLowerInvariant()); + Assert.DoesNotContain("N'", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + Assert.Contains("$1", sql, StringComparison.Ordinal); + } + } + + [Fact] + public void RunningJobsSql_ReadsColumnsThatExistInTheGeneratedRunningJobsTable() + { + Assert.Equal("running_jobs", RunningJobsCollector.Instance.TargetTable); + + var ddl = PgSchemaGenerator.CreateTable(RunningJobsCollector.Instance); + foreach (var column in new[] + { + "collection_time", "job_name", "job_id", "job_enabled", "start_time", + "current_duration_seconds", "avg_duration_seconds", "p95_duration_seconds", + "successful_run_count", "is_running_long", "percent_of_average", + }) + { + Assert.Contains(column, ddl, StringComparison.Ordinal); + } + } + + [Theory] + [InlineData("PERMISSIONS", true)] + [InlineData("permissions", true)] // case-insensitive + [InlineData("Permissions", true)] + [InlineData("ERROR", false)] // a transient ERROR is NOT a permission problem — banner must stay hidden + [InlineData("error", false)] + [InlineData("SUCCESS", false)] + [InlineData("SKIPPED", false)] + [InlineData("", false)] + [InlineData(null, false)] // collector never ran + public void MsdbBanner_ShowsOnlyForPermissionsStatus_NotTransientError(string? status, bool expectVisible) + { + /* Item 6: the banner is restricted to the running_jobs collector's PERMISSIONS outcome (the store + stand-in for Lite's real _hasMsdbAccess probe). A generic ERROR (timeout/blip) must no longer + raise the misleading "grant msdb access" guidance. Calls the real production decision. */ + Assert.Equal(expectVisible, ViewerServerTab.ShouldShowMsdbBanner(status)); + } + + [Theory] + [InlineData(30, "30s")] + [InlineData(90, "1m 30s")] + [InlineData(3720, "1h 2m")] + public void RunningJobRow_FormatsDuration_LikeLite(long seconds, string expected) + { + var row = new RunningJobRow { CurrentDurationSeconds = seconds }; + Assert.Equal(expected, row.CurrentDurationFormatted); + } + + [Fact] + public void RunningJobRow_PercentOfAverage_NullShowsNa() + { + Assert.Equal("N/A", new RunningJobRow { PercentOfAverage = null }.PercentOfAverageFormatted); + Assert.Equal("150%", new RunningJobRow { PercentOfAverage = 150m }.PercentOfAverageFormatted); + } +} + +/// +/// Pins the perfmon picker logic copied from Lite's ServerTab.Pickers.cs. The picker methods are +/// WPF-bound (they touch the ComboBox / search box / ListBox) so they aren't headlessly invokable; +/// these are behavior specs mirroring the load-bearing rules — the General-Throughput default set, the +/// 12-counter cap on pack fills / "Select All", the "All Counters" → General-Throughput mapping, and +/// the clear-only-the-filtered-visible-set semantics — pinned against the SHARED +/// parity data the real picker reads. +/// +public sealed class ViewerPerfmonPickerLogicTests +{ + private static readonly HashSet GeneralThroughput = + new(PerfmonPacks.Packs["General Throughput"], StringComparer.OrdinalIgnoreCase); + + [Fact] + public void GeneralThroughputPack_IsTheDefaultCounterSet_AndNonEmpty() + { + Assert.NotEmpty(GeneralThroughput); + /* The curated default counters Lite seeds the picker with. */ + Assert.Contains("Batch Requests/sec", GeneralThroughput); + Assert.Contains("SQL Compilations/sec", GeneralThroughput); + } + + [Fact] + public void DefaultSelection_SelectsGeneralThroughputCounters_WhenNoPriorSelection() + { + /* Mirrors PopulatePerfmonPicker: with no previously-selected counters, an item is checked iff + it is in the General Throughput default set. */ + var available = new List { "Batch Requests/sec", "SQL Compilations/sec", "Page reads/sec", "Lock Waits/sec" }; + var previouslySelected = new HashSet(); + + var selected = available + .Where(c => previouslySelected.Contains(c) || (previouslySelected.Count == 0 && GeneralThroughput.Contains(c))) + .ToList(); + + Assert.Contains("Batch Requests/sec", selected); + Assert.Contains("SQL Compilations/sec", selected); + Assert.DoesNotContain("Page reads/sec", selected); // not in General Throughput + } + + [Fact] + public void PackFill_SelectsAtMostTwelveMatchingCounters() + { + /* Mirrors PerfmonPack_SelectionChanged's 12-cap. "I/O Pressure" ships 15 counters — a full-fill + against an available list that contains them all must stop at 12. */ + var packCounters = PerfmonPacks.Packs["I/O Pressure"]; + Assert.True(packCounters.Length > 12, "the I/O Pressure pack should exceed the 12-counter cap to exercise it"); + + var available = packCounters.ToList(); // every pack counter is collected + var packSet = new HashSet(packCounters, StringComparer.OrdinalIgnoreCase); + + int count = 0; + var selected = new List(); + foreach (var item in available) + { + if (count >= 12) break; + if (packSet.Contains(item)) { selected.Add(item); count++; } + } + + Assert.Equal(12, selected.Count); + } + + [Fact] + public void AllCounters_SelectsTheGeneralThroughputDefaults() + { + /* Mirrors the AllCounters branch: "All Counters" checks exactly the General Throughput defaults + present in the available list (not literally every counter). */ + var available = new List { "Batch Requests/sec", "Page reads/sec", "SQL Compilations/sec", "Lock Waits/sec" }; + + var selected = available.Where(c => GeneralThroughput.Contains(c)).ToList(); + + Assert.Equal(new[] { "Batch Requests/sec", "SQL Compilations/sec" }, selected); + } + + [Fact] + public void ClearAll_ClearsOnlyTheFilteredVisibleSet_LeavingHiddenSelectionsIntact() + { + /* Behavior spec mirroring ApplyPerfmonFilter's case-insensitive Contains filter + + PerfmonClearAll_Click clearing only the currently-visible (filtered) items. */ + var items = new List + { + new() { DisplayName = "Batch Requests/sec", IsSelected = true }, + new() { DisplayName = "Page reads/sec", IsSelected = true }, + new() { DisplayName = "Page writes/sec", IsSelected = true }, + new() { DisplayName = "Lock Waits/sec", IsSelected = true }, + }; + + const string search = "page"; + var visible = items.Where(i => i.DisplayName.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList(); + foreach (var item in visible) + { + item.IsSelected = false; + } + + /* Only the two "Page*" (the filtered set) are cleared; the others persist. */ + Assert.True(items.Single(i => i.DisplayName == "Batch Requests/sec").IsSelected); + Assert.True(items.Single(i => i.DisplayName == "Lock Waits/sec").IsSelected); + Assert.False(items.Single(i => i.DisplayName == "Page reads/sec").IsSelected); + Assert.False(items.Single(i => i.DisplayName == "Page writes/sec").IsSelected); + } +} + +/// +/// Gated (DARLING_TEST_PG) live round-trips for the Perfmon + Running Jobs reads. Shares the serialized +/// "live-postgres" collection; uses negative sentinel server_ids and cleans up in finally. +/// +[Collection("live-postgres")] +public sealed class ViewerPerfmonRunningJobsLivePostgresTests +{ + private const int PerfmonServerId = -949501; + private const int JobsServerId = -949502; + private const string ServerName = "viewer-w1h-e2e"; + + [Fact] + public async Task Perfmon_DistinctCounters_AndBatchedTrends_SumAcrossInstances_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live perfmon test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeletePerfmonRowsAsync(connection); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var t1 = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + var t2 = t1.AddMinutes(5); + + /* AAA_Counter at t1 across two instances (delta 10 + 5 = 15, cntr 100 + 50 = 150) and once + at t2 (delta 20). ZZZ_Counter must NOT appear when only AAA is requested (the IN filter). */ + await InsertPerfmonRowAsync(connection, 1, t1, "AAA_Counter", "", cntr: 100, delta: 10); + await InsertPerfmonRowAsync(connection, 2, t1, "AAA_Counter", "db2", cntr: 50, delta: 5); + await InsertPerfmonRowAsync(connection, 3, t2, "AAA_Counter", "", cntr: 200, delta: 20); + await InsertPerfmonRowAsync(connection, 4, t2, "ZZZ_Counter", "", cntr: 999, delta: 99); + + var counters = await viewer.GetDistinctPerfmonCountersAsync(PerfmonServerId, t1.AddMinutes(-1), t2.AddMinutes(1)); + Assert.Equal(new[] { "AAA_Counter", "ZZZ_Counter" }, counters); + + var trends = await viewer.GetPerfmonTrendsByCountersAsync( + PerfmonServerId, new List { "AAA_Counter" }, t1.AddMinutes(-1), t2.AddMinutes(1)); + + Assert.True(trends.ContainsKey("AAA_Counter")); + Assert.False(trends.ContainsKey("ZZZ_Counter")); // the IN filter excluded it + + var aaa = trends["AAA_Counter"]; + Assert.Equal(2, aaa.Count); + + /* t1: summed across the two instances → delta 15, value 150. */ + Assert.Equal(t1.Ticks, aaa[0].CollectionTime.Ticks); + Assert.Equal(15, aaa[0].DeltaValue); + Assert.Equal(150, aaa[0].Value); + + /* t2: single instance → delta 20, value 200. */ + Assert.Equal(t2.Ticks, aaa[1].CollectionTime.Ticks); + Assert.Equal(20, aaa[1].DeltaValue); + Assert.Equal(200, aaa[1].Value); + } + finally + { + await DeletePerfmonRowsAsync(connection); + } + } + + [Fact] + public async Task RunningJobs_ReturnsLatestSnapshotOnly_OrderedByDuration_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live running-jobs test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteJobRowsAsync(connection); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var older = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-20)); + var latest = older.AddMinutes(10); + + /* An older snapshot that must NOT appear (only the latest collection_time is returned). */ + await InsertJobRowAsync(connection, older, "OldSnapshotJob", currentDuration: 999, isRunningLong: false, percentOfAverage: 50m); + /* Latest snapshot: two jobs — the read orders by current duration descending. */ + await InsertJobRowAsync(connection, latest, "JobA", currentDuration: 300, isRunningLong: false, percentOfAverage: 120m); + await InsertJobRowAsync(connection, latest, "JobB", currentDuration: 600, isRunningLong: true, percentOfAverage: 250m); + + var jobs = await viewer.GetRunningJobsAsync(JobsServerId); + + Assert.Equal(2, jobs.Count); + Assert.DoesNotContain(jobs, j => j.JobName == "OldSnapshotJob"); + Assert.Equal("JobB", jobs[0].JobName); // 600s first + Assert.Equal("JobA", jobs[1].JobName); // 300s second + Assert.True(jobs[0].IsRunningLong); + Assert.Equal(250m, jobs[0].PercentOfAverage); + } + finally + { + await DeleteJobRowsAsync(connection); + } + } + + [Fact] + public async Task RunningJobsCollectorStatus_ReturnsLatestStatus_ForMsdbBanner_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live collector-status test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteJobLogRowsAsync(connection); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var t1 = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + var t2 = t1.AddMinutes(5); + + /* Latest running_jobs run wins: an earlier SUCCESS then a newer PERMISSIONS returns + PERMISSIONS (the banner shows). A stray SUCCESS for a DIFFERENT collector at t2 must not + leak in (collector_name filter). */ + await InsertCollectionLogAsync(connection, 1, "running_jobs", t1, "SUCCESS"); + await InsertCollectionLogAsync(connection, 2, "running_jobs", t2, "PERMISSIONS"); + await InsertCollectionLogAsync(connection, 3, "wait_stats", t2.AddMinutes(1), "SUCCESS"); + + var status = await viewer.GetLatestRunningJobsCollectorStatusAsync(JobsServerId); + + Assert.Equal("PERMISSIONS", status); + } + finally + { + await DeleteJobLogRowsAsync(connection); + } + } + + private static async Task InsertPerfmonRowAsync( + NpgsqlConnection connection, long collectionId, DateTime collectionTimeUtc, + string counterName, string instanceName, long cntr, long delta) + { + using var command = new NpgsqlCommand(@" +INSERT INTO perfmon_stats + (collection_id, collection_time, server_id, server_name, object_name, counter_name, + instance_name, cntr_value, delta_cntr_value, sample_interval_seconds) +VALUES ($1, $2, $3, $4, 'SQLServer:General', $5, $6, $7, $8, 60)", connection); + command.Parameters.AddWithValue(collectionId); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(PerfmonServerId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(counterName); + command.Parameters.AddWithValue(instanceName); + command.Parameters.AddWithValue(cntr); + command.Parameters.AddWithValue(delta); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertJobRowAsync( + NpgsqlConnection connection, DateTime collectionTimeUtc, string jobName, + long currentDuration, bool isRunningLong, decimal percentOfAverage) + { + using var command = new NpgsqlCommand(@" +INSERT INTO running_jobs + (collection_time, server_id, server_name, job_name, job_id, job_enabled, start_time, + current_duration_seconds, avg_duration_seconds, p95_duration_seconds, successful_run_count, + is_running_long, percent_of_average) +VALUES ($1, $2, $3, $4, $5, true, $6, $7, 100, 200, 42, $8, $9)", connection); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(JobsServerId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(jobName); + command.Parameters.AddWithValue(Guid.NewGuid().ToString()); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(currentDuration); + command.Parameters.AddWithValue(isRunningLong); + command.Parameters.AddWithValue(percentOfAverage); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertCollectionLogAsync( + NpgsqlConnection connection, long logId, string collectorName, DateTime collectionTimeUtc, string status) + { + using var command = new NpgsqlCommand(@" +INSERT INTO collection_log (log_id, server_id, server_name, collector_name, collection_time, status) +VALUES ($1, $2, $3, $4, $5, $6)", connection); + command.Parameters.AddWithValue(logId); + command.Parameters.AddWithValue(JobsServerId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(collectorName); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(status); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static DateTime TruncateToSeconds(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified); + + private static async Task DeletePerfmonRowsAsync(NpgsqlConnection connection) + { + using var cleanup = new NpgsqlCommand($"DELETE FROM perfmon_stats WHERE server_id = {PerfmonServerId};", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task DeleteJobRowsAsync(NpgsqlConnection connection) + { + using var cleanup = new NpgsqlCommand($"DELETE FROM running_jobs WHERE server_id = {JobsServerId};", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task DeleteJobLogRowsAsync(NpgsqlConnection connection) + { + using var cleanup = new NpgsqlCommand($"DELETE FROM collection_log WHERE server_id = {JobsServerId};", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } +} diff --git a/Darling/Darling.Tests/ViewerPlanCacheTests.cs b/Darling/Darling.Tests/ViewerPlanCacheTests.cs new file mode 100644 index 000000000..7a856553e --- /dev/null +++ b/Darling/Darling.Tests/ViewerPlanCacheTests.cs @@ -0,0 +1,263 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the Plan Cache sub-tab's two reads against the Darling store contract (no live Postgres): the +/// size trend (single-use vs multi-use MB, summed across every cacheobjtype/objtype group per collection, +/// the Dashboard's single-use bloat shape) and the latest-snapshot composition read (the most recent +/// collection in the window, per group, top by size). Both run on the v_plan_cache_stats +/// passthrough view. +/// +public sealed class ViewerPlanCacheSqlTests +{ + [Fact] + public void PlanCacheTrendSql_SumsSingleVsMultiUseMb_PerCollection_OverTheWindow() + { + var sql = ViewerDataService.PlanCacheTrendSql; + + Assert.Contains("FROM v_plan_cache_stats", sql, StringComparison.Ordinal); + Assert.Contains("CAST(SUM(single_use_size_mb) AS double precision)", sql, StringComparison.Ordinal); + Assert.Contains("CAST(SUM(multi_use_size_mb) AS double precision)", sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $2", sql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $3", sql, StringComparison.Ordinal); + Assert.Contains("GROUP BY collection_time", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY collection_time", sql, StringComparison.Ordinal); + } + + [Fact] + public void PlanCacheSnapshotSql_LatestCollection_Composition_OrderedBySize_CapAt30() + { + var sql = ViewerDataService.PlanCacheSnapshotSql; + + Assert.Contains("FROM v_plan_cache_stats", sql, StringComparison.Ordinal); + Assert.Contains("SELECT MAX(collection_time) AS mx", sql, StringComparison.Ordinal); + Assert.Contains("collection_time = (SELECT mx FROM latest)", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY total_size_mb DESC, total_plans DESC", sql, StringComparison.Ordinal); + Assert.Contains("LIMIT 30", sql, StringComparison.Ordinal); + + /* The composition + stability columns the grid + summary render. */ + foreach (var column in new[] + { + "cacheobjtype", "objtype", "total_plans", "single_use_plans", "single_use_size_mb", + "multi_use_plans", "multi_use_size_mb", "avg_use_count", "avg_size_kb", "oldest_plan_create_time", + }) + { + Assert.Contains(column, sql, StringComparison.Ordinal); + } + } + + [Fact] + public void PlanCacheSummarySql_SumsAllGroups_LatestCollection_Uncapped() + { + var sql = ViewerDataService.PlanCacheSummarySql; + + Assert.Contains("FROM v_plan_cache_stats", sql, StringComparison.Ordinal); + Assert.Contains("SELECT MAX(collection_time) AS mx", sql, StringComparison.Ordinal); + Assert.Contains("collection_time = (SELECT mx FROM latest)", sql, StringComparison.Ordinal); + /* TRUE total across every group + single-use total (for the bloat badge) + oldest plan; COALESCE so + an empty window yields 0 not NULL. */ + Assert.Contains("COALESCE(SUM(total_plans), 0)", sql, StringComparison.Ordinal); + Assert.Contains("COALESCE(SUM(single_use_plans), 0)", sql, StringComparison.Ordinal); + Assert.Contains("MIN(oldest_plan_create_time)", sql, StringComparison.Ordinal); + /* Must NOT be capped — the summary is the exact total, independent of the grid's LIMIT 30. */ + Assert.DoesNotContain("LIMIT", sql, StringComparison.Ordinal); + } + + [Theory] + [InlineData("trend")] + [InlineData("snapshot")] + [InlineData("summary")] + public void PlanCacheSql_PgDialect_PositionalParams_NoBareNow_NoNLiterals(string which) + { + var sql = which switch + { + "trend" => ViewerDataService.PlanCacheTrendSql, + "snapshot" => ViewerDataService.PlanCacheSnapshotSql, + _ => ViewerDataService.PlanCacheSummarySql, + }; + + Assert.DoesNotContain("now(", sql.ToLowerInvariant()); + Assert.DoesNotContain("N'", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + Assert.Contains("$1", sql, StringComparison.Ordinal); + Assert.Contains("$2", sql, StringComparison.Ordinal); + Assert.Contains("$3", sql, StringComparison.Ordinal); + } + + [Fact] + public void PlanCacheSql_ReadsColumnsThatExistInTheGeneratedTable() + { + Assert.Equal("plan_cache_stats", PlanCacheStatsCollector.Instance.TargetTable); + + var ddl = PgSchemaGenerator.CreateTable(PlanCacheStatsCollector.Instance); + foreach (var column in new[] + { + "collection_time", "cacheobjtype", "objtype", "total_plans", "total_size_mb", + "single_use_plans", "single_use_size_mb", "multi_use_plans", "multi_use_size_mb", + "avg_use_count", "avg_size_kb", "oldest_plan_create_time", + }) + { + Assert.Contains(column, ddl, StringComparison.Ordinal); + } + } + + [Fact] + public void PlanCacheView_IsPinnedInTheAuthoritativeViewList() + => Assert.Contains("v_plan_cache_stats", PgSchemaGenerator.AllPassthroughViews); +} + +/// +/// Pins the derived single-use plan-cache bloat badge (), +/// a pure client-side CASE mirroring install/47's report.plan_cache_bloat: bloat_level banding on the +/// single-use / total plan ratio (> 50 CRITICAL / > 30 HIGH / > 20 MEDIUM / else NORMAL) and the +/// paired forced-parameterization recommendation flipping at the same > 20% threshold. +/// +public sealed class ViewerPlanCacheBloatTests +{ + [Theory] + [InlineData(100, 60, "CRITICAL")] // 60% > 50 + [InlineData(100, 51, "CRITICAL")] + [InlineData(100, 50, "HIGH")] // exactly 50 is NOT > 50 → HIGH + [InlineData(100, 40, "HIGH")] // 40% > 30 + [InlineData(100, 31, "HIGH")] + [InlineData(100, 30, "MEDIUM")] // exactly 30 → MEDIUM + [InlineData(100, 25, "MEDIUM")] // 25% > 20 + [InlineData(100, 21, "MEDIUM")] + [InlineData(100, 20, "NORMAL")] // exactly 20 → NORMAL + [InlineData(100, 5, "NORMAL")] + [InlineData(0, 0, "NORMAL")] // empty cache → no divide-by-zero, NORMAL + public void ClassifyPlanCacheBloat_BandsOnSingleUsePercent(long total, long singleUse, string expectedLevel) + => Assert.Equal(expectedLevel, ViewerDataService.ClassifyPlanCacheBloat(total, singleUse).Level); + + [Theory] + [InlineData(100, 25, true)] // > 20% → forced-parameterization hint + [InlineData(100, 21, true)] + [InlineData(100, 20, false)] // <= 20% → healthy + [InlineData(100, 0, false)] + [InlineData(0, 0, false)] + public void ClassifyPlanCacheBloat_RecommendationFlipsAtTwentyPercent(long total, long singleUse, bool expectHint) + { + var rec = ViewerDataService.ClassifyPlanCacheBloat(total, singleUse).Recommendation; + if (expectHint) + { + Assert.Contains("Forced Parameterization", rec, StringComparison.Ordinal); + } + else + { + Assert.Equal("Plan cache composition is healthy", rec); + } + } +} + +/// +/// Gated (DARLING_TEST_PG) live round-trips for the Plan Cache reads: the trend SUMs single/multi-use MB +/// across the per-collection groups, and the snapshot takes the latest collection's composition ordered by +/// size (with oldest_plan_create_time round-tripped for the summary). Shares the serialized "live-postgres" +/// collection; uses a negative sentinel server_id and cleans up in finally. +/// +[Collection("live-postgres")] +public sealed class ViewerPlanCacheLivePostgresTests +{ + private const int ServerId = -940004; + private const string ServerName = "viewer-plancache-e2e"; + + [Fact] + public async Task Trend_SumsAcrossGroups_AndSnapshot_TakesLatestCompositionBySize_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live plan-cache test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteAsync(connection, ServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var t1 = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + var oldest = t1.AddDays(-2); + + /* Two groups at the same collection: single-use MB 40 + 10 = 50; multi-use 100 + 200 = 300. */ + await InsertAsync(connection, t1, "Compiled Plan", "Proc", + totalPlans: 5, totalSizeMb: 140, singlePlans: 1, singleMb: 40, multiPlans: 4, multiMb: 100, oldest: oldest); + await InsertAsync(connection, t1, "Compiled Plan", "Adhoc", + totalPlans: 30, totalSizeMb: 210, singlePlans: 25, singleMb: 10, multiPlans: 5, multiMb: 200, oldest: oldest); + + var trend = await viewer.GetPlanCacheTrendAsync(ServerId, t1.AddMinutes(-1), t1.AddMinutes(1)); + Assert.Single(trend); + Assert.Equal(50.0, trend[0].SingleUseSizeMb, precision: 3); + Assert.Equal(300.0, trend[0].MultiUseSizeMb, precision: 3); + + var snapshot = await viewer.GetPlanCacheSnapshotAsync(ServerId, t1.AddMinutes(-1), t1.AddMinutes(1)); + Assert.Equal(2, snapshot.Count); + /* Ordered by total_size_mb DESC → the Adhoc group (210) first. */ + Assert.Equal("Adhoc", snapshot[0].Objtype); + Assert.Equal(210, snapshot[0].TotalSizeMb); + Assert.Equal(oldest.Ticks, snapshot[0].OldestPlanCreateTime!.Value.Ticks); + + /* Summary sums total_plans across BOTH groups (5 + 30 = 35, uncapped) and takes MIN(oldest). */ + var summary = await viewer.GetPlanCacheSummaryAsync(ServerId, t1.AddMinutes(-1), t1.AddMinutes(1)); + Assert.Equal(35L, summary.TotalPlans); + Assert.Equal(oldest.Ticks, summary.OldestPlanCreateTime!.Value.Ticks); + } + finally + { + await DeleteAsync(connection, ServerId); + } + } + + private static async Task InsertAsync( + NpgsqlConnection connection, DateTime collectionTimeUtc, string cacheobjtype, string objtype, + int totalPlans, int totalSizeMb, int singlePlans, int singleMb, int multiPlans, int multiMb, DateTime oldest) + { + using var command = new NpgsqlCommand(@" +INSERT INTO plan_cache_stats + (collection_id, collection_time, server_id, server_name, cacheobjtype, objtype, + total_plans, total_size_mb, single_use_plans, single_use_size_mb, + multi_use_plans, multi_use_size_mb, avg_use_count, avg_size_kb, oldest_plan_create_time) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 1.5, 64, $13)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(ServerId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(cacheobjtype); + command.Parameters.AddWithValue(objtype); + command.Parameters.AddWithValue(totalPlans); + command.Parameters.AddWithValue(totalSizeMb); + command.Parameters.AddWithValue(singlePlans); + command.Parameters.AddWithValue(singleMb); + command.Parameters.AddWithValue(multiPlans); + command.Parameters.AddWithValue(multiMb); + command.Parameters.AddWithValue(DateTime.SpecifyKind(oldest, DateTimeKind.Unspecified)); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static DateTime TruncateToSeconds(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified); + + private static async Task DeleteAsync(NpgsqlConnection connection, int serverId) + { + using var cleanup = new NpgsqlCommand($"DELETE FROM plan_cache_stats WHERE server_id = {serverId};", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } +} diff --git a/Darling/Darling.Tests/ViewerPlanHostTests.cs b/Darling/Darling.Tests/ViewerPlanHostTests.cs new file mode 100644 index 000000000..d8ed57a61 --- /dev/null +++ b/Darling/Darling.Tests/ViewerPlanHostTests.cs @@ -0,0 +1,391 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the Plan Viewer host's stored-plan reads against the Darling store contract (no live Postgres): +/// the two on-demand plan lookups (query_stats.query_plan_xml by db+query_hash; +/// query_store_stats.query_plan_text by db+query_id+plan_id) and the has_query_plan presence flag the Top +/// Queries grid read now carries to gate its Query Plan column. Ordinal correctness + PG execution are +/// covered by the gated live round-trips below. +/// +public sealed class ViewerPlanHostSqlTests +{ + [Fact] + public void QueryStatsPlanXmlSql_ReadsStoredXml_KeyedByDatabaseAndHash_LatestNonNull() + { + var sql = ViewerDataService.QueryStatsPlanXmlSql; + Assert.Contains("SELECT query_plan_xml", sql, StringComparison.Ordinal); + Assert.Contains("FROM query_stats", sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("database_name = $2", sql, StringComparison.Ordinal); + Assert.Contains("query_hash = $3", sql, StringComparison.Ordinal); + Assert.Contains("query_plan_xml IS NOT NULL", sql, StringComparison.Ordinal); /* skip rows the collector left NULL */ + Assert.Contains("ORDER BY collection_time DESC", sql, StringComparison.Ordinal); /* most-recent plan for the key */ + Assert.Contains("LIMIT 1", sql, StringComparison.Ordinal); + } + + [Fact] + public void QueryStorePlanTextSql_ReadsStoredText_KeyedByQueryIdAndPlanId_LatestNonNull() + { + var sql = ViewerDataService.QueryStorePlanTextSql; + Assert.Contains("SELECT query_plan_text", sql, StringComparison.Ordinal); + Assert.Contains("FROM query_store_stats", sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("database_name = $2", sql, StringComparison.Ordinal); + Assert.Contains("query_id = $3", sql, StringComparison.Ordinal); + Assert.Contains("plan_id = $4", sql, StringComparison.Ordinal); /* (query_id, plan_id) names one specific compiled plan */ + Assert.Contains("query_plan_text IS NOT NULL", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY collection_time DESC", sql, StringComparison.Ordinal); + Assert.Contains("LIMIT 1", sql, StringComparison.Ordinal); + } + + [Fact] + public void ProcedureStatsPlanXmlSql_ReadsStoredXml_KeyedByDatabaseSchemaObject_LatestNonNull() + { + var sql = ViewerDataService.ProcedureStatsPlanXmlSql; + Assert.Contains("SELECT query_plan_xml", sql, StringComparison.Ordinal); + /* The base procedure_stats table (there is no v_procedure_stats view; the plan column is on the table). */ + Assert.Contains("FROM procedure_stats", sql, StringComparison.Ordinal); + Assert.DoesNotContain("v_procedure_stats", sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("database_name = $2", sql, StringComparison.Ordinal); + Assert.Contains("schema_name = $3", sql, StringComparison.Ordinal); + Assert.Contains("object_name = $4", sql, StringComparison.Ordinal); /* (database, schema, object) = the grid's group key */ + Assert.Contains("query_plan_xml IS NOT NULL", sql, StringComparison.Ordinal); /* skip rows the collector left NULL */ + Assert.Contains("ORDER BY collection_time DESC", sql, StringComparison.Ordinal); /* most-recent plan for the key */ + Assert.Contains("LIMIT 1", sql, StringComparison.Ordinal); + } + + [Fact] + public void ProcedureStatsPlanColumn_ExistsInTheGeneratedProcedureStatsTable() + { + /* The keyed fetch reads procedure_stats.query_plan_xml — pin that the collector-generated table has it. */ + Assert.Equal("procedure_stats", ProcedureStatsCollector.Instance.TargetTable); + Assert.Contains("query_plan_xml", PgSchemaGenerator.CreateTable(ProcedureStatsCollector.Instance), StringComparison.Ordinal); + } + + [Fact] + public void TopQueriesSql_CarriesCheapHasPlanFlag_NotThePlanItself() + { + /* The grid gates its Query Plan column on a group-level presence flag; the multi-KB plan XML is + never pulled into the grid row (it is fetched on demand by GetQueryStatsPlanXmlAsync). */ + var sql = ViewerDataService.TopQueriesSql; + Assert.Contains("bool_or(query_plan_xml IS NOT NULL) AS has_query_plan", sql, StringComparison.Ordinal); + Assert.Contains("r.has_query_plan", sql, StringComparison.Ordinal); + } + + [Theory] + [InlineData(nameof(ViewerDataService.QueryStatsPlanXmlSql))] + [InlineData(nameof(ViewerDataService.QueryStorePlanTextSql))] + [InlineData(nameof(ViewerDataService.ProcedureStatsPlanXmlSql))] + public void PlanReads_ArePostgresDialect_PositionalParams_NoTsqlIsms(string sqlName) + { + var sql = sqlName switch + { + nameof(ViewerDataService.QueryStatsPlanXmlSql) => ViewerDataService.QueryStatsPlanXmlSql, + nameof(ViewerDataService.QueryStorePlanTextSql) => ViewerDataService.QueryStorePlanTextSql, + _ => ViewerDataService.ProcedureStatsPlanXmlSql, + }; + Assert.DoesNotContain("getdate", sql.ToLowerInvariant()); + Assert.DoesNotContain("N'", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + Assert.Contains("$1", sql, StringComparison.Ordinal); + } +} + +/// The plan-gating property on the Top Queries row model (the flag the Query Plan column binds). +public sealed class ViewerPlanHostGatingTests +{ + [Fact] + public void HasQueryPlan_DefaultsFalse_SoTheDownloadColumnIsDisabledUntilTheReadSaysOtherwise() + { + Assert.False(new ViewerQueryStatsRow().HasQueryPlan); + Assert.True(new ViewerQueryStatsRow { HasQueryPlan = true }.HasQueryPlan); + } + + [Fact] + public void BlockedProcessRow_PlanFlags_DefaultFalse_GateBlockedAndBlockingIndependently() + { + var empty = new ViewerBlockedProcessRow(); + Assert.False(empty.HasBlockedQueryPlan); + Assert.False(empty.HasBlockingQueryPlan); + + var both = new ViewerBlockedProcessRow + { + BlockedQueryPlanXml = "", + BlockingQueryPlanXml = "", + }; + Assert.True(both.HasBlockedQueryPlan); + Assert.True(both.HasBlockingQueryPlan); + + /* Best-effort: one side can be captured while the other is NULL — the two "View Plan" items gate apart. */ + var blockedOnly = new ViewerBlockedProcessRow { BlockedQueryPlanXml = "" }; + Assert.True(blockedOnly.HasBlockedQueryPlan); + Assert.False(blockedOnly.HasBlockingQueryPlan); + } + + [Fact] + public void DeadlockProcessDetail_VictimPlanFlag_DefaultsFalse_ReflectsPresence() + { + Assert.False(new DeadlockProcessDetail().HasVictimQueryPlan); + Assert.True(new DeadlockProcessDetail { VictimQueryPlanXml = "" }.HasVictimQueryPlan); + } +} + +/// +/// Gated (DARLING_TEST_PG) live round-trips for the Plan Viewer host reads. Each plants query/query-store +/// rows — with and without a stored plan — for a negative sentinel server, then asserts the on-demand plan +/// fetch returns the exact stored text (newest wins) and the grid's has_query_plan gate reflects presence. +/// Shares the serialized "live-postgres" collection and cleans up in finally. +/// +[Collection("live-postgres")] +public sealed class ViewerPlanHostLivePostgresTests +{ + private const int QueryStatsPlanServerId = -970811; + private const int QueryStatsNoPlanServerId = -970812; + private const int QueryStorePlanServerId = -970813; + + /* A small but well-formed ShowPlanXML — the reads only round-trip the text (the WPF host parses it), + so any stable string proves the fetch; a realistic plan keeps the fixture honest. */ + private const string SamplePlanXml = + "" + + "" + + "" + + "" + + ""; + + private const string NewerPlanXml = + "" + + "" + + "" + + "" + + ""; + + private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + + [Fact] + public async Task QueryStatsPlan_FetchesNewestStoredXml_AndGridFlagsHasPlan_AgainstDevPostgres() + { + var cs = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live query-stats plan test."); + + using var connection = new NpgsqlConnection(cs); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "query_stats", QueryStatsPlanServerId); + + await using var viewer = new ViewerDataService(cs!); + var end = TruncateToSeconds(DateTime.UtcNow); + var start = end.AddHours(-24); + var older = start.AddHours(1); + var newer = start.AddHours(2); + + try + { + /* Same (database, query_hash) captured twice with different plans — the newer collection wins. */ + await InsertQueryStatsWithPlanAsync(connection, QueryStatsPlanServerId, older, "StackOverflow", "0xPLAN", + deltaExec: 1, deltaWorker: 10_000, deltaElapsed: 20_000, queryText: "SELECT plan", planXml: SamplePlanXml); + await InsertQueryStatsWithPlanAsync(connection, QueryStatsPlanServerId, newer, "StackOverflow", "0xPLAN", + deltaExec: 1, deltaWorker: 10_000, deltaElapsed: 20_000, queryText: "SELECT plan", planXml: NewerPlanXml); + + var plan = await viewer.GetQueryStatsPlanXmlAsync(QueryStatsPlanServerId, "StackOverflow", "0xPLAN"); + Assert.Equal(NewerPlanXml, plan); /* newest non-null plan for the key */ + + var rows = await viewer.GetTopQueriesByCpuAsync(QueryStatsPlanServerId, start, end); + var row = Assert.Single(rows); + Assert.Equal("0xPLAN", row.QueryHash); + Assert.True(row.HasQueryPlan); /* bool_or over the group: a plan exists -> column enabled */ + } + finally + { + await DeleteRowsAsync(connection, "query_stats", QueryStatsPlanServerId); + } + } + + [Fact] + public async Task QueryStatsPlan_NullPlan_FetchReturnsNull_AndGridFlagIsFalse_AgainstDevPostgres() + { + var cs = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live no-plan test."); + + using var connection = new NpgsqlConnection(cs); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "query_stats", QueryStatsNoPlanServerId); + + await using var viewer = new ViewerDataService(cs!); + var end = TruncateToSeconds(DateTime.UtcNow); + var start = end.AddHours(-24); + + try + { + /* A query with activity but no captured plan (query_plan_xml NULL). */ + await InsertQueryStatsWithPlanAsync(connection, QueryStatsNoPlanServerId, start.AddHours(1), "StackOverflow", "0xNOPLAN", + deltaExec: 1, deltaWorker: 10_000, deltaElapsed: 20_000, queryText: "SELECT noplan", planXml: null); + + var plan = await viewer.GetQueryStatsPlanXmlAsync(QueryStatsNoPlanServerId, "StackOverflow", "0xNOPLAN"); + Assert.Null(plan); + + var rows = await viewer.GetTopQueriesByCpuAsync(QueryStatsNoPlanServerId, start, end); + var row = Assert.Single(rows); + Assert.False(row.HasQueryPlan); /* no plan captured -> column disabled */ + } + finally + { + await DeleteRowsAsync(connection, "query_stats", QueryStatsNoPlanServerId); + } + } + + [Fact] + public async Task QueryStorePlan_RoundTripsStoredText_ByQueryIdAndPlanId_NullElsewhere_AgainstDevPostgres() + { + var cs = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live query-store plan test."); + + using var connection = new NpgsqlConnection(cs); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "query_store_stats", QueryStorePlanServerId); + + await using var viewer = new ViewerDataService(cs!); + var t = TruncateToSeconds(DateTime.UtcNow).AddHours(-1); + + try + { + /* plan_id 7 has a stored plan; plan_id 9 (same query) has none. */ + await InsertQueryStoreWithPlanAsync(connection, QueryStorePlanServerId, t, "StackOverflow", + queryId: 42, planId: 7, execCount: 3, queryText: "SELECT qs", planText: SamplePlanXml); + await InsertQueryStoreWithPlanAsync(connection, QueryStorePlanServerId, t, "StackOverflow", + queryId: 42, planId: 9, execCount: 3, queryText: "SELECT qs", planText: null); + + var plan = await viewer.GetQueryStorePlanTextAsync(QueryStorePlanServerId, "StackOverflow", 42, 7); + Assert.Equal(SamplePlanXml, plan); + + /* The NULL-plan plan_id and an absent plan_id both fetch null. */ + Assert.Null(await viewer.GetQueryStorePlanTextAsync(QueryStorePlanServerId, "StackOverflow", 42, 9)); + Assert.Null(await viewer.GetQueryStorePlanTextAsync(QueryStorePlanServerId, "StackOverflow", 42, 999)); + } + finally + { + await DeleteRowsAsync(connection, "query_store_stats", QueryStorePlanServerId); + } + } + + // ── Insert helpers (mirror ViewerQueriesTests' column lists + the trailing plan column) ── + + private static async Task InsertQueryStatsWithPlanAsync( + NpgsqlConnection connection, int serverId, DateTime collectionTimeUtc, string databaseName, string queryHash, + long deltaExec, long deltaWorker, long deltaElapsed, string queryText, string? planXml) + { + using var command = new NpgsqlCommand(@" +INSERT INTO query_stats + (collection_id, collection_time, server_id, server_name, + database_name, query_hash, query_plan_hash, sql_handle, plan_handle, + last_execution_time, creation_time, sample_interval_seconds, + delta_execution_count, delta_worker_time, delta_elapsed_time, delta_logical_reads, + delta_rows, delta_logical_writes, delta_physical_reads, delta_spills, + total_clr_time, plan_generation_num, query_text, query_plan_xml) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue("viewer-plan-e2e"); + command.Parameters.AddWithValue(databaseName); + command.Parameters.AddWithValue(queryHash); + command.Parameters.AddWithValue("0xPLANHASH"); + command.Parameters.AddWithValue("0xSQLHANDLE"); + command.Parameters.AddWithValue("0xPLANHANDLE"); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc.AddHours(-1), DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(60); + command.Parameters.AddWithValue(deltaExec); + command.Parameters.AddWithValue(deltaWorker); + command.Parameters.AddWithValue(deltaElapsed); + command.Parameters.AddWithValue(100L); + command.Parameters.AddWithValue(0L); + command.Parameters.AddWithValue(0L); + command.Parameters.AddWithValue(0L); + command.Parameters.AddWithValue(0L); + command.Parameters.AddWithValue(0L); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(queryText); + command.Parameters.AddWithValue((object?)planXml ?? DBNull.Value); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertQueryStoreWithPlanAsync( + NpgsqlConnection connection, int serverId, DateTime collectionTimeUtc, string databaseName, + long queryId, long planId, long execCount, string queryText, string? planText) + { + using var command = new NpgsqlCommand(@" +INSERT INTO query_store_stats + (collection_id, collection_time, server_id, server_name, + database_name, query_id, plan_id, query_hash, query_plan_hash, query_text, module_name, + execution_type_desc, first_execution_time, last_execution_time, + execution_count, avg_duration_us, avg_cpu_time_us, avg_logical_io_reads, avg_logical_io_writes, + avg_physical_io_reads, avg_rowcount, avg_clr_time_us, avg_log_bytes_used, avg_tempdb_space_used, + avg_num_physical_io_reads, avg_query_max_used_memory, min_dop, max_dop, + plan_type, plan_forcing_type, is_forced_plan, force_failure_count, compatibility_level, query_plan_text) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, + $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue("viewer-qs-plan-e2e"); + command.Parameters.AddWithValue(databaseName); + command.Parameters.AddWithValue(queryId); + command.Parameters.AddWithValue(planId); + command.Parameters.AddWithValue("0xQSHASH"); + command.Parameters.AddWithValue("0xQSPLANHASH"); + command.Parameters.AddWithValue(queryText); + command.Parameters.AddWithValue("dbo.usp_Qs"); + command.Parameters.AddWithValue("Regular"); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc.AddHours(-1), DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(execCount); + command.Parameters.AddWithValue(2000L); + command.Parameters.AddWithValue(1000L); + command.Parameters.AddWithValue(100L); + command.Parameters.AddWithValue(0L); + command.Parameters.AddWithValue(10L); + command.Parameters.AddWithValue(50L); + command.Parameters.AddWithValue(0L); + command.Parameters.AddWithValue(0L); + command.Parameters.AddWithValue(0L); + command.Parameters.AddWithValue(5L); + command.Parameters.AddWithValue(1024L); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue("Compiled"); + command.Parameters.AddWithValue("None"); + command.Parameters.AddWithValue(false); + command.Parameters.AddWithValue(0L); + command.Parameters.AddWithValue(160); + command.Parameters.AddWithValue((object?)planText ?? DBNull.Value); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static DateTime TruncateToSeconds(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified); + + private static async Task DeleteRowsAsync(NpgsqlConnection connection, string table, int serverId) + { + using var cleanup = new NpgsqlCommand($"DELETE FROM {table} WHERE server_id = {serverId};", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } +} diff --git a/Darling/Darling.Tests/ViewerPreferencesStoreTests.cs b/Darling/Darling.Tests/ViewerPreferencesStoreTests.cs new file mode 100644 index 000000000..4088fd7fc --- /dev/null +++ b/Darling/Darling.Tests/ViewerPreferencesStoreTests.cs @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.IO; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the viewer's per-user preferences store — the persisted DEFAULTS (default time range + auto-refresh +/// on/off + interval) that seed a newly-opened server tab's toolbar. Covers the three behaviors the store +/// promises: defaults when the file is missing, a lossless save/load round-trip, and clamping an +/// out-of-range value back to its default (a corrupt / hand-edited / forward-version file must never leave +/// a toolbar combo unselected). Each store points at a unique temp file so nothing touches the real profile. +/// +public sealed class ViewerPreferencesStoreTests : IDisposable +{ + private readonly string _tempFile = + Path.Combine(Path.GetTempPath(), $"viewer-preferences-test-{Guid.NewGuid():N}.json"); + + public void Dispose() + { + if (File.Exists(_tempFile)) + { + File.Delete(_tempFile); + } + } + + [Fact] + public void Load_WhenFileMissing_ReturnsHistoricalToolbarDefaults() + { + var store = new ViewerPreferencesStore(_tempFile); + + var prefs = store.Load(); + + /* The default-constructed values reproduce the toolbar's old hardcoded XAML defaults exactly: + index 3 = Last 24 hours, auto-refresh ON, interval index 1 = 1 minute. */ + Assert.Equal(3, prefs.DefaultTimeRangeIndex); + Assert.True(prefs.AutoRefreshEnabled); + Assert.Equal(1, prefs.AutoRefreshIntervalIndex); + } + + [Fact] + public void SaveThenLoad_RoundTripsAllThreeValues() + { + var store = new ViewerPreferencesStore(_tempFile); + store.Save(new ViewerPreferences + { + DefaultTimeRangeIndex = 0, // Last 1 hour + AutoRefreshEnabled = false, + AutoRefreshIntervalIndex = 2, // 5 minutes + }); + + /* A fresh store on the same path proves the values survived a real serialize → file → deserialize. */ + var reloaded = new ViewerPreferencesStore(_tempFile).Load(); + + Assert.Equal(0, reloaded.DefaultTimeRangeIndex); + Assert.False(reloaded.AutoRefreshEnabled); + Assert.Equal(2, reloaded.AutoRefreshIntervalIndex); + } + + [Fact] + public void Load_ClampsOutOfRangeIndices_BackToDefaults() + { + /* Persist deliberately invalid indices (Custom=5 is not a valid default; 99 is off the interval combo) + by writing the JSON directly, then confirm Load normalizes both back to their defaults while + leaving the valid auto-refresh flag untouched. */ + File.WriteAllText( + _tempFile, + """ + { + "DefaultTimeRangeIndex": 5, + "AutoRefreshEnabled": false, + "AutoRefreshIntervalIndex": 99 + } + """); + + var prefs = new ViewerPreferencesStore(_tempFile).Load(); + + Assert.Equal(3, prefs.DefaultTimeRangeIndex); // clamped 5 -> 24h default + Assert.Equal(1, prefs.AutoRefreshIntervalIndex); // clamped 99 -> 1m default + Assert.False(prefs.AutoRefreshEnabled); // in-range value preserved + } + + [Fact] + public void Load_WhenFileCorrupt_ReturnsDefaults() + { + File.WriteAllText(_tempFile, "{ not valid json"); + + var prefs = new ViewerPreferencesStore(_tempFile).Load(); + + Assert.Equal(3, prefs.DefaultTimeRangeIndex); + Assert.True(prefs.AutoRefreshEnabled); + Assert.Equal(1, prefs.AutoRefreshIntervalIndex); + } + + [Fact] + public void Save_CreatesTheAppDataDirectory_WhenItDoesNotExist() + { + /* Point at a nested path whose directory does not exist yet — Save must create it (first-run). */ + var nestedDir = Path.Combine(Path.GetTempPath(), $"viewer-prefs-dir-{Guid.NewGuid():N}"); + var nestedFile = Path.Combine(nestedDir, "viewer-preferences.json"); + try + { + new ViewerPreferencesStore(nestedFile).Save(new ViewerPreferences()); + + Assert.True(File.Exists(nestedFile)); + } + finally + { + if (Directory.Exists(nestedDir)) + { + Directory.Delete(nestedDir, recursive: true); + } + } + } +} diff --git a/Darling/Darling.Tests/ViewerProfileStoreTests.cs b/Darling/Darling.Tests/ViewerProfileStoreTests.cs new file mode 100644 index 000000000..ad2d5443c --- /dev/null +++ b/Darling/Darling.Tests/ViewerProfileStoreTests.cs @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the viewer's credential-profile registry (, the port of Lite's +/// ProfileManager): JSON round-trip, secret keying under profile_{id} (never the JSON, never the +/// bare server key), ManagedIdentity's no-secret rule, CRUD, per-process name uniqueness, and the +/// referential-integrity delete-constraint (blocked while a server references it, allowed after reassign). +/// Every test uses a temp file and an in-memory secret fake, so nothing touches the real registry or +/// Windows Credential Manager. +/// +public sealed class ViewerProfileStoreTests : IDisposable +{ + private readonly string _profilePath = Path.Combine(Path.GetTempPath(), $"viewer-profiles-{Guid.NewGuid():N}.json"); + private readonly string _serverPath = Path.Combine(Path.GetTempPath(), $"viewer-servers-{Guid.NewGuid():N}.json"); + private readonly FakeSecretStore _profileSecrets = new(); + private readonly FakeSecretStore _serverSecrets = new(); + + private ViewerServerStore NewServerStore() => new(_serverPath, _serverSecrets); + private ViewerProfileStore NewProfileStore(ViewerServerStore serverStore) => new(serverStore, _profilePath, _profileSecrets); + + public void Dispose() + { + foreach (var path in new[] { _profilePath, _serverPath }) + { + try { if (File.Exists(path)) File.Delete(path); } + catch { /* best-effort temp cleanup */ } + } + } + + [Fact] + public void AddProfile_RoundTripsThroughDisk_AndStoresSecretUnderProfileKey_NotJson() + { + var store = NewProfileStore(NewServerStore()); + var profile = new ViewerCredentialProfile + { + Name = "sql-fleet", + AuthType = AuthenticationTypes.SqlServer, + Username = "sa" + }; + + store.AddProfile(profile, "sa", "the-password"); + + /* Secret under profile_{id}, NOT a bare id key. */ + Assert.NotNull(_profileSecrets.Get(ViewerProfileStore.ProfileCredentialId(profile.Id))); + Assert.Null(_profileSecrets.Get(profile.Id)); + + /* viewer-profiles.json holds the profile, never the secret. */ + var json = File.ReadAllText(_profilePath); + Assert.Contains("sql-fleet", json); + Assert.DoesNotContain("the-password", json); + + /* A fresh store over the same file reloads it. */ + var reloaded = NewProfileStore(NewServerStore()).GetProfile(profile.Id); + Assert.NotNull(reloaded); + Assert.Equal("sql-fleet", reloaded!.Name); + Assert.Equal(AuthenticationTypes.SqlServer, reloaded.AuthType); + Assert.Equal("sa", reloaded.Username); + } + + [Fact] + public void ManagedIdentityProfile_StoresNoSecret_ButHasStoredSecretIsTrue() + { + var store = NewProfileStore(NewServerStore()); + var profile = new ViewerCredentialProfile { Name = "mi", AuthType = AuthenticationTypes.ManagedIdentity }; + + store.AddProfile(profile, username: null, secret: null); + + Assert.Null(_profileSecrets.Get(ViewerProfileStore.ProfileCredentialId(profile.Id))); + Assert.True(store.HasStoredSecret(profile)); // MI needs none + } + + [Fact] + public void GetAll_SortsByName() + { + var store = NewProfileStore(NewServerStore()); + store.AddProfile(new ViewerCredentialProfile { Name = "Zeta", AuthType = AuthenticationTypes.ManagedIdentity }); + store.AddProfile(new ViewerCredentialProfile { Name = "alpha", AuthType = AuthenticationTypes.ManagedIdentity }); + + Assert.Equal(new[] { "alpha", "Zeta" }, store.GetAll().Select(p => p.Name)); + } + + [Fact] + public void AddProfile_RejectsDuplicateName() + { + var store = NewProfileStore(NewServerStore()); + store.AddProfile(new ViewerCredentialProfile { Name = "dup", AuthType = AuthenticationTypes.ManagedIdentity }); + + Assert.Throws(() => + store.AddProfile(new ViewerCredentialProfile { Name = "dup", AuthType = AuthenticationTypes.ManagedIdentity })); + } + + [Fact] + public void UpdateProfile_BlankSecret_LeavesStoredSecretUnchanged_FreshSecretReplacesIt() + { + var store = NewProfileStore(NewServerStore()); + var profile = new ViewerCredentialProfile { Name = "p", AuthType = AuthenticationTypes.SqlServer, Username = "sa" }; + store.AddProfile(profile, "sa", "pw1"); + + /* Rename only (blank secret) → the stored secret is untouched. */ + profile.Name = "renamed"; + store.UpdateProfile(profile); + Assert.Equal("pw1", _profileSecrets.Get(ViewerProfileStore.ProfileCredentialId(profile.Id))!.Value.Password); + + /* Fresh secret → replaces it. */ + store.UpdateProfile(profile, "sa", "pw2"); + Assert.Equal("pw2", _profileSecrets.Get(ViewerProfileStore.ProfileCredentialId(profile.Id))!.Value.Password); + Assert.Equal("renamed", NewProfileStore(NewServerStore()).GetProfile(profile.Id)!.Name); + } + + [Fact] + public void DeleteProfile_RemovesProfileAndSecret() + { + var store = NewProfileStore(NewServerStore()); + var profile = new ViewerCredentialProfile { Name = "to-delete", AuthType = AuthenticationTypes.ServicePrincipal, AzureClientId = "cid" }; + store.AddProfile(profile, "cid", "sec"); + + store.DeleteProfile(profile.Id); + + Assert.Null(store.GetProfile(profile.Id)); + Assert.Null(_profileSecrets.Get(ViewerProfileStore.ProfileCredentialId(profile.Id))); + Assert.Empty(NewProfileStore(NewServerStore()).GetAll()); + } + + [Fact] + public void DeleteProfile_BlockedWhileServerReferencesIt_AllowedAfterReassign() + { + var serverStore = NewServerStore(); + var profileStore = NewProfileStore(serverStore); + + var profile = new ViewerCredentialProfile { Name = "shared", AuthType = AuthenticationTypes.SqlServer, Username = "sa" }; + profileStore.AddProfile(profile, "sa", "pw"); + + var server = new ViewerServerEntry { ServerName = "s", DisplayName = "s", CredentialProfileId = profile.Id }; + serverStore.AddServer(server, null, null); + + Assert.Equal(1, profileStore.CountServersUsingProfile(profile.Id)); + + /* Blocked while referenced. */ + var ex = Assert.Throws(() => profileStore.DeleteProfile(profile.Id)); + Assert.Contains("used by", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.NotNull(profileStore.GetProfile(profile.Id)); + + /* Reassign (clear the reference) then delete succeeds. */ + server.CredentialProfileId = null; + serverStore.UpdateServer(server); + profileStore.DeleteProfile(profile.Id); + Assert.Null(profileStore.GetProfile(profile.Id)); + } + + /// In-memory so tests never touch Windows Credential Manager. + private sealed class FakeSecretStore : IViewerServerSecretStore + { + private readonly Dictionary _map = new(); + + public void Save(string id, string username, string password) => _map[id] = (username, password); + + public (string Username, string Password)? Get(string id) => + _map.TryGetValue(id, out var v) ? v : null; + + public void Delete(string id) => _map.Remove(id); + } +} diff --git a/Darling/Darling.Tests/ViewerQueriesRestTests.cs b/Darling/Darling.Tests/ViewerQueriesRestTests.cs new file mode 100644 index 000000000..24584c9d3 --- /dev/null +++ b/Darling/Darling.Tests/ViewerQueriesRestTests.cs @@ -0,0 +1,524 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the W1f-2 Queries sub-tab SQL against the Darling store contract (no live Postgres): the four +/// Performance-Trends reads, the Query-Heatmap DuckDB→PG rewrite, and the Active-Queries snapshot + latest +/// batch + slicer reads. String pins guard the load-bearing clauses — the LAG per-interval rate, the +/// base-table names, and (the wave's real work) the heatmap's time_bucket → date_bin and +/// ARG_MAX → ROW_NUMBER rewrites. Ordinal correctness + PG execution are covered by the gated live +/// round-trips below. +/// +public sealed class ViewerQueryTrendsSqlTests +{ + [Theory] + [InlineData(nameof(ViewerDataService.QueryDurationTrendSql), "query_stats")] + [InlineData(nameof(ViewerDataService.ProcedureDurationTrendSql), "procedure_stats")] + [InlineData(nameof(ViewerDataService.QueryStoreDurationTrendSql), "query_store_stats")] + [InlineData(nameof(ViewerDataService.ExecutionCountTrendSql), "query_stats")] + public void TrendSql_ComputesPerSecondRate_ViaLagInterval_BaseTable(string sqlName, string table) + { + var sql = SqlByName(sqlName); + Assert.Contains($"FROM {table}", sql, StringComparison.Ordinal); + Assert.DoesNotContain($"v_{table}", sql, StringComparison.Ordinal); /* viewer reads base tables */ + /* The seconds-since-previous-snapshot interval (per-second rate denominator). */ + Assert.Contains("LAG(collection_time) OVER (ORDER BY collection_time)", sql, StringComparison.Ordinal); + Assert.Contains("extract(epoch FROM", sql, StringComparison.Ordinal); + Assert.Contains("date_trunc('second', collection_time)", sql, StringComparison.Ordinal); + /* interval_seconds > 0 guards the first row (LAG NULL -> rate 0). */ + Assert.Contains("CASE WHEN interval_seconds > 0 THEN", sql, StringComparison.Ordinal); + Assert.Contains("GROUP BY collection_time", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY collection_time", sql, StringComparison.Ordinal); + } + + [Fact] + public void DurationTrendSql_SumsElapsedMs_ExecutionTrendSql_OnlyExecutions() + { + Assert.Contains("SUM(delta_elapsed_time) / 1000.0", ViewerDataService.QueryDurationTrendSql, StringComparison.Ordinal); + Assert.Contains("SUM(delta_elapsed_time) / 1000.0", ViewerDataService.ProcedureDurationTrendSql, StringComparison.Ordinal); + /* Query Store has no delta_elapsed_time: duration = execution_count * avg_duration_us. */ + Assert.Contains("SUM(execution_count * avg_duration_us / 1000.0)", ViewerDataService.QueryStoreDurationTrendSql, StringComparison.Ordinal); + /* The execution-count trend projects only the executions/sec column (no elapsed). */ + Assert.DoesNotContain("elapsed", ViewerDataService.ExecutionCountTrendSql, StringComparison.Ordinal); + Assert.Contains("SUM(delta_execution_count)", ViewerDataService.ExecutionCountTrendSql, StringComparison.Ordinal); + } + + [Theory] + [InlineData(nameof(ViewerDataService.QueryDurationTrendSql))] + [InlineData(nameof(ViewerDataService.ProcedureDurationTrendSql))] + [InlineData(nameof(ViewerDataService.QueryStoreDurationTrendSql))] + [InlineData(nameof(ViewerDataService.ExecutionCountTrendSql))] + public void TrendSql_IsPostgresDialect_PositionalParams(string sqlName) + { + var sql = SqlByName(sqlName); + Assert.Contains("$1", sql, StringComparison.Ordinal); + Assert.Contains("$2", sql, StringComparison.Ordinal); + Assert.Contains("$3", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + Assert.DoesNotContain("getdate", sql.ToLowerInvariant()); + } + + private static string SqlByName(string name) => name switch + { + nameof(ViewerDataService.QueryDurationTrendSql) => ViewerDataService.QueryDurationTrendSql, + nameof(ViewerDataService.ProcedureDurationTrendSql) => ViewerDataService.ProcedureDurationTrendSql, + nameof(ViewerDataService.QueryStoreDurationTrendSql) => ViewerDataService.QueryStoreDurationTrendSql, + _ => ViewerDataService.ExecutionCountTrendSql, + }; +} + +/// +/// Pins the Query-Heatmap DuckDB→PG rewrite — the wave's load-bearing SQL work. Guards that both +/// dialect swaps landed for EVERY metric: time_bucket is gone (replaced by date_bin with an +/// explicit epoch origin) and ARG_MAX is gone (replaced by a ROW_NUMBER … rn = 1 top-1 +/// window plus COUNT(*) OVER for the per-cell count). +/// +public sealed class ViewerQueryHeatmapSqlTests +{ + private static readonly HeatmapMetric[] AllMetrics = + { + HeatmapMetric.Duration, HeatmapMetric.Cpu, HeatmapMetric.LogicalReads, + HeatmapMetric.LogicalWrites, HeatmapMetric.ExecutionCount, + }; + + [Fact] + public void HeatmapSql_RewritesTimeBucketToDateBin_WithEpochOrigin_ForEveryMetric() + { + foreach (var metric in AllMetrics) + { + var sql = ViewerDataService.BuildQueryHeatmapSql(metric); + Assert.DoesNotContain("time_bucket", sql, StringComparison.Ordinal); /* DuckDB-only */ + Assert.Contains("date_bin(INTERVAL '5 minutes', collection_time, TIMESTAMP '1970-01-01 00:00:00')", sql, StringComparison.Ordinal); + } + } + + [Fact] + public void HeatmapSql_RewritesArgMaxToTop1Window_ForEveryMetric() + { + foreach (var metric in AllMetrics) + { + var sql = ViewerDataService.BuildQueryHeatmapSql(metric); + Assert.DoesNotContain("ARG_MAX", sql, StringComparison.Ordinal); /* DuckDB-only */ + /* Hottest query per cell = the max-delta_execution_count row, taken by a top-1 window. */ + Assert.Contains("ROW_NUMBER() OVER (PARTITION BY time_bin, bucket_index ORDER BY delta_execution_count DESC) AS rn", sql, StringComparison.Ordinal); + Assert.Contains("COUNT(*) OVER (PARTITION BY time_bin, bucket_index) AS query_count", sql, StringComparison.Ordinal); + Assert.Contains("WHERE rn = 1", sql, StringComparison.Ordinal); + } + } + + [Fact] + public void HeatmapSql_KeepsLitesMagnitudeBuckets_Filters_And_Preview() + { + var sql = ViewerDataService.BuildQueryHeatmapSql(HeatmapMetric.Duration); + Assert.Contains("FROM query_stats", sql, StringComparison.Ordinal); + Assert.Contains("delta_execution_count > 0", sql, StringComparison.Ordinal); + Assert.Contains("LEFT(query_text, 120) AS query_preview", sql, StringComparison.Ordinal); + /* The 7-way log-magnitude CASE (only the boundaries are pinned). */ + Assert.Contains("WHEN metric_value < 1 THEN 0", sql, StringComparison.Ordinal); + Assert.Contains("WHEN metric_value < 100000 THEN 5", sql, StringComparison.Ordinal); + Assert.Contains("ELSE 6", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY time_bin, bucket_index", sql, StringComparison.Ordinal); + } + + [Theory] + [InlineData(HeatmapMetric.Duration, "(delta_elapsed_time / 1000.0) / NULLIF(delta_execution_count, 0)")] + [InlineData(HeatmapMetric.Cpu, "(delta_worker_time / 1000.0) / NULLIF(delta_execution_count, 0)")] + [InlineData(HeatmapMetric.LogicalReads, "CAST(delta_logical_reads AS DOUBLE PRECISION) / NULLIF(delta_execution_count, 0)")] + [InlineData(HeatmapMetric.LogicalWrites, "CAST(delta_logical_writes AS DOUBLE PRECISION) / NULLIF(delta_execution_count, 0)")] + [InlineData(HeatmapMetric.ExecutionCount, "CAST(delta_execution_count AS DOUBLE PRECISION)")] + public void HeatmapSql_UsesLitesPerMetricExpression(HeatmapMetric metric, string expr) + { + var sql = ViewerDataService.BuildQueryHeatmapSql(metric); + Assert.Contains(expr, sql, StringComparison.Ordinal); + /* $1/$2/$3 positional params, no T-SQL-isms. */ + Assert.Contains("$1", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + } +} + +/// Pins the Active-Queries reads (snapshot window, latest batch, slicer) against the store contract. +public sealed class ViewerQuerySnapshotsSqlTests +{ + [Fact] + public void LatestQuerySnapshotsSql_SelectsTheWindow_TrimsWaitfor_OrdersNewestThenCpu_BaseTable() + { + var sql = ViewerDataService.LatestQuerySnapshotsSql; + Assert.Contains("FROM query_snapshots", sql, StringComparison.Ordinal); + Assert.DoesNotContain("v_query_snapshots", sql, StringComparison.Ordinal); /* viewer reads base tables */ + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $2", sql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $3", sql, StringComparison.Ordinal); + Assert.Contains("query_text NOT LIKE 'WAITFOR%'", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY collection_time DESC, cpu_time_ms DESC", sql, StringComparison.Ordinal); + /* The plan columns the Estimated / Actual buttons bind are selected. */ + Assert.Contains("query_plan", sql, StringComparison.Ordinal); + Assert.Contains("live_query_plan", sql, StringComparison.Ordinal); + } + + [Fact] + public void LatestQuerySnapshotBatchSql_TakesOnlyTheNewestCaptureForTheServer() + { + var sql = ViewerDataService.LatestQuerySnapshotBatchSql; + Assert.Contains("FROM query_snapshots", sql, StringComparison.Ordinal); + Assert.Contains("collection_time = (SELECT MAX(collection_time) FROM query_snapshots WHERE server_id = $1)", sql, StringComparison.Ordinal); + Assert.Contains("query_text NOT LIKE 'WAITFOR%'", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY cpu_time_ms DESC", sql, StringComparison.Ordinal); + /* #1319: the batch stays server-scoped (the MAX(collection_time) subquery reuses $1, not a window), + plus the global database filter's single nullable text[] param ($2 = ANY). */ + Assert.Contains("database_name = ANY($2)", sql, StringComparison.Ordinal); + } + + [Fact] + public void ActiveQuerySlicerSql_BucketsByHour_SevenColumnShape() + { + var sql = ViewerDataService.ActiveQuerySlicerSql; + Assert.Contains("date_trunc('hour', collection_time)", sql, StringComparison.Ordinal); + Assert.Contains("FROM query_snapshots", sql, StringComparison.Ordinal); + Assert.Contains("COUNT(*) AS session_count", sql, StringComparison.Ordinal); + Assert.Contains("COALESCE(SUM(cpu_time_ms), 0)", sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY bucket", sql, StringComparison.Ordinal); + } + + [Fact] + public void QuerySnapshotColumns_SelectTheHash286TriageColumns_ForTheWaitDrillDownGrid() + { + /* Item 8: the #1286 memory-grant / tempdb / transaction triage columns Lite's WaitDrillDownWindow + binds are now selected (they were dropped from the viewer's ~26-column read). Pinned via the + shared LatestQuerySnapshotsSql, which is built from QuerySnapshotColumns. */ + var sql = ViewerDataService.LatestQuerySnapshotsSql; + foreach (var column in new[] + { + "requested_memory_mb", "used_memory_mb", "max_used_memory_mb", + "tempdb_current_mb", "tempdb_allocations_mb", "tran_log_used_mb", "tran_start_time", "request_id", + }) + { + Assert.Contains(column, sql, StringComparison.Ordinal); + } + } + + [Fact] + public void QuerySnapshotTriageColumns_ExistInTheGeneratedStoreTable() + { + /* Verification gate for item 8: every triage column the viewer now surfaces IS collected into + query_snapshots (no collector gap). */ + Assert.Equal("query_snapshots", QuerySnapshotsCollector.Instance.TargetTable); + var ddl = PgSchemaGenerator.CreateTable(QuerySnapshotsCollector.Instance); + foreach (var column in new[] + { + "requested_memory_mb", "used_memory_mb", "max_used_memory_mb", + "tempdb_current_mb", "tempdb_allocations_mb", "tran_log_used_mb", "tran_start_time", "request_id", + }) + { + Assert.Contains(column, ddl, StringComparison.Ordinal); + } + } +} + +/// The W1f-2 row models' pure helpers: snapshot plan gating + naive-UTC collection-time display. +public sealed class ViewerActiveQueriesDisplayTests +{ + [Fact] + public void QuerySnapshotRow_HasPlanFlags_GateOnNonEmptyPlanXml() + { + Assert.False(new ViewerQuerySnapshotRow().HasQueryPlan); + Assert.False(new ViewerQuerySnapshotRow().HasLiveQueryPlan); + Assert.True(new ViewerQuerySnapshotRow { QueryPlan = "" }.HasQueryPlan); + Assert.True(new ViewerQuerySnapshotRow { LiveQueryPlan = "" }.HasLiveQueryPlan); + } + + [Fact] + public void QuerySnapshotRow_CollectionTimeLocal_ConvertsNaiveUtc_EmptyForMinValue() + { + /* collection_time is naive UTC in the store; the display converts to local (unlike the raw + server-clock last_execution_time). MinValue (no row) renders empty. */ + var row = new ViewerQuerySnapshotRow { CollectionTime = new DateTime(2026, 7, 1, 12, 0, 0, DateTimeKind.Unspecified) }; + var expected = ViewerTimeHelper.ForDisplay(row.CollectionTime).ToString("yyyy-MM-dd HH:mm:ss"); + Assert.Equal(expected, row.CollectionTimeLocal); + Assert.Equal("", new ViewerQuerySnapshotRow().CollectionTimeLocal); + } + + [Fact] + public void QueryTrendPoint_CarriesRateAndExecutionCount() + { + var p = new QueryTrendPoint { CollectionTime = new DateTime(2026, 7, 1, 9, 0, 0), Value = 2.5, ExecutionCount = 7 }; + Assert.Equal(2.5, p.Value); + Assert.Equal(7, p.ExecutionCount); + } + + [Fact] + public void QuerySnapshotRow_TranStartTimeLocal_RendersRawServerClock_EmptyWhenNoOpenTransaction() + { + /* Item 8: the WaitDrillDownWindow's "Tran Start" column. transaction_begin_time is a server-local + wall-clock time (NOT naive UTC), so it renders raw via FormatServerClock like the other dm_exec_* + times — no UTC→display conversion. A null TranStartTime (no open transaction) renders empty. */ + Assert.Equal("", new ViewerQuerySnapshotRow().TranStartTimeLocal); + var row = new ViewerQuerySnapshotRow { TranStartTime = new DateTime(2026, 7, 1, 12, 0, 0, DateTimeKind.Unspecified) }; + Assert.Equal("2026-07-01 12:00:00", row.TranStartTimeLocal); + } +} + +/// +/// Gated (DARLING_TEST_PG) live round-trips for the W1f-2 reads: a duration trend, the heatmap rewrite, +/// the snapshot window + latest batch, and the slicer. Each plants rows for a negative sentinel server and +/// asserts the read executes on real Postgres and returns the expected shape end-to-end (the string pins +/// can't catch a date_bin / window-function dialect error or an ordinal slip). Serialized on the +/// "live-postgres" collection; cleans up in finally. +/// +[Collection("live-postgres")] +public sealed class ViewerQueriesRestLivePostgresTests +{ + private const int TrendServerId = -970811; + private const int HeatmapServerId = -970812; + private const int SnapshotServerId = -970813; + private const int SlicerServerId = -970814; + + private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + + [Fact] + public async Task QueryDurationTrend_ComputesPerSecondRate_AcrossTwoCollections_AgainstDevPostgres() + { + var cs = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live trend test."); + + using var connection = new NpgsqlConnection(cs); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "query_stats", TrendServerId); + + await using var viewer = new ViewerDataService(cs!); + var end = TruncateToSeconds(DateTime.UtcNow); + var start = end.AddHours(-24); + var t1 = start.AddHours(1); + var t2 = t1.AddSeconds(60); /* one 60-second interval later */ + + try + { + await InsertQueryStatsAsync(connection, TrendServerId, t1, "0xA", deltaExec: 10, deltaWorker: 30_000, deltaElapsed: 60_000, deltaReads: 100, deltaWrites: 0, queryText: "SELECT a"); + await InsertQueryStatsAsync(connection, TrendServerId, t2, "0xA", deltaExec: 120, deltaWorker: 60_000, deltaElapsed: 120_000, deltaReads: 200, deltaWrites: 0, queryText: "SELECT a"); + + var points = await viewer.GetQueryDurationTrendAsync(TrendServerId, start, end); + + Assert.Equal(2, points.Count); + Assert.Equal(t1, points[0].CollectionTime); + Assert.Equal(0.0, points[0].Value); /* first row: LAG NULL -> rate 0 */ + Assert.Equal(t2, points[1].CollectionTime); + Assert.Equal(2.0, points[1].Value, 3); /* 120_000 us = 120 ms over 60 s -> 2 ms/sec */ + Assert.Equal(2, points[1].ExecutionCount); /* 120 execs over 60 s -> 2/sec (truncated) */ + } + finally + { + await DeleteRowsAsync(connection, "query_stats", TrendServerId); + } + } + + [Fact] + public async Task QueryHeatmap_BinsByTime_BucketsByMagnitude_TopQueryByExecutions_AgainstDevPostgres() + { + var cs = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live heatmap test."); + + using var connection = new NpgsqlConnection(cs); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "query_stats", HeatmapServerId); + + await using var viewer = new ViewerDataService(cs!); + var end = TruncateToSeconds(DateTime.UtcNow); + var start = end.AddHours(-24); + var t1 = start.AddHours(1); /* all rows share one collection_time -> one 5-min bin */ + + try + { + /* Duration metric = (delta_elapsed_time / 1000) / delta_exec (ms/exec). + Two rows at ~50ms -> bucket 2; the higher-exec one is the cell's top query. */ + await InsertQueryStatsAsync(connection, HeatmapServerId, t1, "0xHOT", deltaExec: 5, deltaWorker: 0, deltaElapsed: 250_000, deltaReads: 0, deltaWrites: 0, queryText: "SELECT hot"); + await InsertQueryStatsAsync(connection, HeatmapServerId, t1, "0xCOLD", deltaExec: 1, deltaWorker: 0, deltaElapsed: 50_000, deltaReads: 0, deltaWrites: 0, queryText: "SELECT cold"); + /* 0.5ms -> bucket 0. */ + await InsertQueryStatsAsync(connection, HeatmapServerId, t1, "0xLOW", deltaExec: 2, deltaWorker: 0, deltaElapsed: 1_000, deltaReads: 0, deltaWrites: 0, queryText: "SELECT low"); + /* delta_exec = 0 -> excluded by the > 0 filter (must not inflate any cell). */ + await InsertQueryStatsAsync(connection, HeatmapServerId, t1, "0xZERO", deltaExec: 0, deltaWorker: 0, deltaElapsed: 999_000, deltaReads: 0, deltaWrites: 0, queryText: "SELECT zero"); + + var result = await viewer.GetQueryHeatmapAsync(HeatmapServerId, HeatmapMetric.Duration, start, end); + + Assert.Single(result.TimeBuckets); /* one 5-min bin */ + Assert.Equal(7, result.Intensities.GetLength(0)); /* seven magnitude buckets */ + Assert.Equal(2.0, result.Intensities[2, 0]); /* bucket 2: 0xHOT + 0xCOLD */ + Assert.Equal(1.0, result.Intensities[0, 0]); /* bucket 0: 0xLOW */ + Assert.Equal("0xHOT", result.CellDetails[2, 0].TopQueryHash); /* ARG_MAX replacement: higher delta_exec wins */ + Assert.Equal("0xLOW", result.CellDetails[0, 0].TopQueryHash); + /* 0xZERO (delta_exec = 0) contributed to no cell. */ + Assert.Equal(0.0, result.Intensities[6, 0]); + } + finally + { + await DeleteRowsAsync(connection, "query_stats", HeatmapServerId); + } + } + + [Fact] + public async Task QuerySnapshots_TrimsWaitfor_OrdersNewestThenCpu_AndLatestBatch_AgainstDevPostgres() + { + var cs = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live snapshots test."); + + using var connection = new NpgsqlConnection(cs); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "query_snapshots", SnapshotServerId); + + await using var viewer = new ViewerDataService(cs!); + var end = TruncateToSeconds(DateTime.UtcNow); + var start = end.AddHours(-24); + var older = start.AddHours(1); + var newer = start.AddHours(2); + + try + { + await InsertQuerySnapshotAsync(connection, SnapshotServerId, older, spid: 51, cpu: 100, hash: "0xB1", queryText: "SELECT older"); + await InsertQuerySnapshotAsync(connection, SnapshotServerId, older, spid: 99, cpu: 999, hash: "0xWAIT", queryText: "WAITFOR DELAY '00:00:05'"); + await InsertQuerySnapshotAsync(connection, SnapshotServerId, newer, spid: 52, cpu: 500, hash: "0xB2", queryText: "SELECT mid"); + await InsertQuerySnapshotAsync(connection, SnapshotServerId, newer, spid: 53, cpu: 900, hash: "0xB3", queryText: "SELECT hot"); + + var rows = await viewer.GetLatestQuerySnapshotsAsync(SnapshotServerId, start, end); + + Assert.Equal(3, rows.Count); /* WAITFOR excluded */ + Assert.DoesNotContain(rows, r => r.QueryHash == "0xWAIT"); + Assert.Equal(53, rows[0].SessionId); /* newest batch, highest cpu first */ + Assert.Equal(52, rows[1].SessionId); + Assert.Equal(51, rows[2].SessionId); /* older batch last */ + + var (batchTime, batch) = await viewer.GetLatestQuerySnapshotBatchAsync(SnapshotServerId); + + Assert.Equal(newer, batchTime); /* only the newest capture */ + Assert.Equal(2, batch.Count); + Assert.Equal(53, batch[0].SessionId); /* cpu DESC within the batch */ + Assert.Equal(52, batch[1].SessionId); + } + finally + { + await DeleteRowsAsync(connection, "query_snapshots", SnapshotServerId); + } + } + + [Fact] + public async Task ActiveQuerySlicer_BucketsByHour_CountsSessions_AgainstDevPostgres() + { + var cs = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live active-queries slicer test."); + + using var connection = new NpgsqlConnection(cs); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "query_snapshots", SlicerServerId); + + await using var viewer = new ViewerDataService(cs!); + var end = TruncateToSeconds(DateTime.UtcNow); + var start = end.AddHours(-24); + var hour1 = TruncateToHour(start.AddHours(3)); + var hour2 = TruncateToHour(start.AddHours(5)); + + try + { + await InsertQuerySnapshotAsync(connection, SlicerServerId, hour1.AddMinutes(5), spid: 60, cpu: 100, hash: "0xA", queryText: "a"); + await InsertQuerySnapshotAsync(connection, SlicerServerId, hour1.AddMinutes(35), spid: 61, cpu: 200, hash: "0xB", queryText: "b"); + await InsertQuerySnapshotAsync(connection, SlicerServerId, hour2.AddMinutes(5), spid: 62, cpu: 300, hash: "0xC", queryText: "c"); + + var buckets = await viewer.GetActiveQuerySlicerDataAsync(SlicerServerId, start, end); + + Assert.Equal(2, buckets.Count); /* two distinct hours */ + Assert.Equal(hour1, buckets[0].BucketTime); + Assert.Equal(2, buckets[0].SessionCount); /* two snapshots in hour1 */ + Assert.Equal(300.0, buckets[0].TotalCpu, 3); /* 100 + 200 */ + } + finally + { + await DeleteRowsAsync(connection, "query_snapshots", SlicerServerId); + } + } + + // ── Insert helpers (only the columns each read touches; the rest default to NULL) ── + + private static async Task InsertQueryStatsAsync( + NpgsqlConnection connection, int serverId, DateTime collectionTimeUtc, string queryHash, + long deltaExec, long deltaWorker, long deltaElapsed, long deltaReads, long deltaWrites, string queryText) + { + using var command = new NpgsqlCommand(@" +INSERT INTO query_stats + (collection_id, collection_time, server_id, server_name, + database_name, query_hash, sample_interval_seconds, + delta_execution_count, delta_worker_time, delta_elapsed_time, delta_logical_reads, delta_logical_writes, + query_text) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue("viewer-queries-rest-e2e"); + command.Parameters.AddWithValue("StackOverflow"); + command.Parameters.AddWithValue(queryHash); + command.Parameters.AddWithValue(60); + command.Parameters.AddWithValue(deltaExec); + command.Parameters.AddWithValue(deltaWorker); + command.Parameters.AddWithValue(deltaElapsed); + command.Parameters.AddWithValue(deltaReads); + command.Parameters.AddWithValue(deltaWrites); + command.Parameters.AddWithValue(queryText); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertQuerySnapshotAsync( + NpgsqlConnection connection, int serverId, DateTime collectionTimeUtc, int spid, long cpu, string hash, string queryText) + { + using var command = new NpgsqlCommand(@" +INSERT INTO query_snapshots + (collection_id, collection_time, server_id, server_name, + session_id, database_name, query_text, status, + cpu_time_ms, total_elapsed_time_ms, reads, writes, logical_reads, + wait_type, query_hash) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue("viewer-snapshots-e2e"); + command.Parameters.AddWithValue(spid); + command.Parameters.AddWithValue("StackOverflow"); + command.Parameters.AddWithValue(queryText); + command.Parameters.AddWithValue("running"); + command.Parameters.AddWithValue(cpu); + command.Parameters.AddWithValue(cpu * 2); + command.Parameters.AddWithValue(10L); + command.Parameters.AddWithValue(0L); + command.Parameters.AddWithValue(20L); + command.Parameters.AddWithValue("CXPACKET"); + command.Parameters.AddWithValue(hash); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static DateTime TruncateToSeconds(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified); + + private static DateTime TruncateToHour(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Year, value.Month, value.Day, value.Hour, 0, 0), DateTimeKind.Unspecified); + + private static async Task DeleteRowsAsync(NpgsqlConnection connection, string table, int serverId) + { + using var cleanup = new NpgsqlCommand($"DELETE FROM {table} WHERE server_id = {serverId};", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } +} diff --git a/Darling/Darling.Tests/ViewerQueriesTests.cs b/Darling/Darling.Tests/ViewerQueriesTests.cs new file mode 100644 index 000000000..93c020edb --- /dev/null +++ b/Darling/Darling.Tests/ViewerQueriesTests.cs @@ -0,0 +1,896 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using PerformanceMonitor.Ui; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the W1f-1 Queries-tab SQL against the Darling store contract (no live Postgres): the three grid +/// reads (Top Queries / Top Procedures / Query Store), their comparison reads, and their slicer bucket +/// reads. The pins guard the load-bearing clauses the string-only tests can catch — the ranking clause, +/// the LATERAL latest-text shape, the WAITFOR trim, the over-fetch + cap, the base-table names, the +/// CAST-back-to-bigint on summed aggregates, and the FULL OUTER JOIN / IS NOT DISTINCT FROM comparison +/// shape. Ordinal correctness + PG execution are covered by the gated live round-trips below. +/// +public sealed class ViewerQueriesSqlTests +{ + // ── Top Queries ── + + [Fact] + public void TopQueriesSql_GroupsQueryStatsByDatabaseAndHash_OverTheWindow_BaseTable() + { + var sql = ViewerDataService.TopQueriesSql; + Assert.Contains("FROM query_stats", sql, StringComparison.Ordinal); + Assert.DoesNotContain("v_query_stats", sql, StringComparison.Ordinal); /* viewer reads base tables */ + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $2", sql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $3", sql, StringComparison.Ordinal); /* end bound for the slicer */ + Assert.Contains("GROUP BY database_name, query_hash", sql, StringComparison.Ordinal); + Assert.Contains("HAVING SUM(delta_execution_count) > 0 OR SUM(delta_elapsed_time) > 0", sql, StringComparison.Ordinal); + } + + [Fact] + public void TopQueriesSql_RanksByTotalElapsedDelta_OverFetchesFive_CapsAtTop() + { + var sql = ViewerDataService.TopQueriesSql; + Assert.Contains("ORDER BY SUM(delta_elapsed_time) DESC", sql, StringComparison.Ordinal); + Assert.Contains("LIMIT $4 + 5", sql, StringComparison.Ordinal); /* over-fetch so the WAITFOR trim can't shrink below top */ + Assert.Contains("ORDER BY r.total_elapsed_us DESC", sql, StringComparison.Ordinal); + Assert.Contains("LIMIT $4", sql, StringComparison.Ordinal); + Assert.Contains("NOT LIKE 'WAITFOR%'", sql, StringComparison.Ordinal); + } + + [Fact] + public void TopQueriesSql_FetchesTheLatestNonNullQueryText_ViaLateralJoin_WithPlanPresenceFlag() + { + var sql = ViewerDataService.TopQueriesSql; + Assert.Contains("LEFT JOIN LATERAL", sql, StringComparison.Ordinal); + Assert.Contains("query_text IS NOT NULL", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY collection_time DESC", sql, StringComparison.Ordinal); + Assert.Contains("LIMIT 1", sql, StringComparison.Ordinal); + /* The LATERAL still fetches only query_text (never the multi-KB plan). The plan is surfaced by a + cheap group-level presence flag — bool_or(query_plan_xml IS NOT NULL) — that gates the grid's + Query Plan column; the plan XML itself is read on demand (GetQueryStatsPlanXmlAsync). */ + Assert.Contains("bool_or(query_plan_xml IS NOT NULL) AS has_query_plan", sql, StringComparison.Ordinal); + Assert.Contains("r.has_query_plan", sql, StringComparison.Ordinal); /* the flag rides from the ranked CTE, not the LATERAL */ + } + + [Fact] + public void TopQueriesSql_CastsEverySummedAggregateBackToBigint() + { + /* Postgres SUM(bigint) returns numeric; without the CAST the typed GetInt64 readers throw. */ + var sql = ViewerDataService.TopQueriesSql; + Assert.Contains("CAST(SUM(delta_execution_count) AS bigint)", sql, StringComparison.Ordinal); + Assert.Contains("CAST(SUM(delta_worker_time) AS bigint)", sql, StringComparison.Ordinal); + Assert.Contains("CAST(SUM(delta_elapsed_time) AS bigint)", sql, StringComparison.Ordinal); + Assert.Contains("CAST(SUM(delta_logical_reads) AS bigint)", sql, StringComparison.Ordinal); + Assert.Contains("CAST(SUM(delta_rows) AS bigint)", sql, StringComparison.Ordinal); + Assert.Contains("CAST(SUM(delta_logical_writes) AS bigint)", sql, StringComparison.Ordinal); + Assert.Contains("CAST(SUM(delta_physical_reads) AS bigint)", sql, StringComparison.Ordinal); + Assert.Contains("CAST(SUM(delta_spills) AS bigint)", sql, StringComparison.Ordinal); + /* Peak CPU/sec keeps Lite's per-sample expression (double precision). */ + Assert.Contains("NULLIF(sample_interval_seconds, 0)", sql, StringComparison.Ordinal); + } + + [Fact] + public void TopQueriesSql_DropsLitesStalenessFilterAndUtcOffset() + { + /* The viewer has no per-server UTC offset; the delta HAVING already excludes never-ran plans. */ + var sql = ViewerDataService.TopQueriesSql; + Assert.DoesNotContain("INTERVAL", sql, StringComparison.Ordinal); + /* #1319: $5 is now the global database filter (database_name = ANY($5)), NOT Lite's utc-offset + staleness param — the viewer still drops Lite's INTERVAL staleness filter. */ + Assert.Contains("database_name = ANY($5)", sql, StringComparison.Ordinal); + Assert.DoesNotContain("$6", sql, StringComparison.Ordinal); + } + + [Fact] + public void TopQueriesSql_StitchesModuleByHandle_DedupsToLatest_LeftJoinsForAdHocFallback() + { + /* #1568: read-time attribution. A dedicated CTE picks ONE procedure_stats identity per sql_handle + (latest collection_time, via ROW_NUMBER + WHERE rn = 1 — Postgres has no QUALIFY), then a LEFT + JOIN on the shared normalized handle surfaces db.schema.object; unmatched rows stay ad hoc. */ + var sql = ViewerDataService.TopQueriesSql; + Assert.Contains("module AS (", sql, StringComparison.Ordinal); + Assert.Contains("FROM procedure_stats", sql, StringComparison.Ordinal); + Assert.Contains("ROW_NUMBER() OVER (PARTITION BY sql_handle ORDER BY collection_time DESC) AS rn", sql, StringComparison.Ordinal); + Assert.Contains("WHERE rn = 1", sql, StringComparison.Ordinal); + Assert.Contains("LEFT JOIN module AS m ON m.sql_handle = r.sql_handle", sql, StringComparison.Ordinal); + Assert.Contains("m.object_name AS module_object_name", sql, StringComparison.Ordinal); + Assert.Contains("m.schema_name AS module_schema_name", sql, StringComparison.Ordinal); + Assert.Contains("m.database_name AS module_database_name", sql, StringComparison.Ordinal); + } + + // ── Top Procedures ── + + [Fact] + public void TopProceduresSql_GroupsProcedureStatsByObject_RanksByTotalElapsed_BaseTable() + { + var sql = ViewerDataService.TopProceduresSql; + Assert.Contains("FROM procedure_stats", sql, StringComparison.Ordinal); + Assert.DoesNotContain("v_procedure_stats", sql, StringComparison.Ordinal); /* no such view exists in Darling */ + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $2", sql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $3", sql, StringComparison.Ordinal); + Assert.Contains("GROUP BY database_name, schema_name, object_name, object_type", sql, StringComparison.Ordinal); + Assert.Contains("HAVING SUM(delta_execution_count) > 0 OR SUM(delta_elapsed_time) > 0", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY SUM(delta_elapsed_time) DESC", sql, StringComparison.Ordinal); + Assert.Contains("LIMIT $4", sql, StringComparison.Ordinal); + Assert.Contains("CAST(SUM(delta_execution_count) AS bigint)", sql, StringComparison.Ordinal); + /* avg_spills is the one derived double in the proc read. */ + Assert.Contains("CAST(SUM(delta_spills) AS double precision) / NULLIF(SUM(delta_execution_count), 0)", sql, StringComparison.Ordinal); + /* Item 7: whether the collector stored a plan for the object — gates the grid's Download button on the + same query_plan_xml IS NOT NULL filter GetProcedureStatsPlanXmlAsync fetches on. */ + Assert.Contains("bool_or(query_plan_xml IS NOT NULL) AS has_query_plan", sql, StringComparison.Ordinal); + } + + // ── Query Store ── + + [Fact] + public void QueryStoreTopSql_GroupsByQueryAndPlan_RanksByTotalDuration_BaseTable() + { + var sql = ViewerDataService.QueryStoreTopSql; + Assert.Contains("FROM query_store_stats", sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + /* replica_role is a grouping key: an AG's shared Query Store (2022+) would otherwise report + primary and secondary workload blended into one row. */ + Assert.Contains("GROUP BY database_name, query_id, plan_id, query_hash, replica_role", sql, StringComparison.Ordinal); + /* Rank by total duration = executions * avg duration, over-fetch 5, cap at top (Lite's shape). */ + Assert.Contains("ORDER BY SUM(execution_count) * AVG(CAST(avg_duration_us AS double precision)) DESC", sql, StringComparison.Ordinal); + Assert.Contains("LIMIT $4 + 5", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY r.total_executions * r.avg_duration_ms DESC", sql, StringComparison.Ordinal); + Assert.Contains("LIMIT $4", sql, StringComparison.Ordinal); + Assert.Contains("NOT LIKE 'WAITFOR%'", sql, StringComparison.Ordinal); + } + + [Fact] + public void QueryStoreTopSql_ConvertsMetrics_ForcedPlanBoolean_MemoryPagesToMb_NoPlanText() + { + var sql = ViewerDataService.QueryStoreTopSql; + Assert.Contains("CAST(SUM(execution_count) AS bigint)", sql, StringComparison.Ordinal); + Assert.Contains("AVG(CAST(avg_duration_us AS double precision)) / 1000.0", sql, StringComparison.Ordinal); + /* Postgres has no MAX(boolean); the forced-plan flag folds through bool_or. */ + Assert.Contains("bool_or(is_forced_plan)", sql, StringComparison.Ordinal); + Assert.DoesNotContain("MAX(CASE WHEN is_forced_plan", sql, StringComparison.Ordinal); + Assert.Contains("AVG(CAST(avg_query_max_used_memory AS double precision)) * 8.0 / 1024.0", sql, StringComparison.Ordinal); + /* View-Plan deferred: the collected query_plan_text column is not read this wave. */ + Assert.DoesNotContain("query_plan_text", sql, StringComparison.Ordinal); + } + + // ── Comparisons ── + + [Theory] + [InlineData(nameof(ViewerDataService.QueryStatsComparisonSql), "query_stats", "GROUP BY th.database_name, th.query_hash")] + [InlineData(nameof(ViewerDataService.QueryStoreComparisonSql), "query_store_stats", "GROUP BY th.database_name, th.query_hash")] + [InlineData(nameof(ViewerDataService.ProcedureStatsComparisonSql), "procedure_stats", "GROUP BY tp.database_name, tp.schema_name, tp.object_name")] + public void ComparisonSql_UnionsTop100_FullOuterJoins_NullSafe(string sqlName, string table, string finalGroupBy) + { + var sql = SqlByName(sqlName); + Assert.Contains($"FROM {table}", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY SUM(", sql, StringComparison.Ordinal); + Assert.Contains("LIMIT 100", sql, StringComparison.Ordinal); + Assert.Contains("UNION ALL", sql, StringComparison.Ordinal); + Assert.Contains("FULL OUTER JOIN baseline_period b", sql, StringComparison.Ordinal); + /* The CTE INNER JOINs keep Lite's null-safe IS NOT DISTINCT FROM (legal in a PG inner join)... */ + Assert.Contains("IS NOT DISTINCT FROM", sql, StringComparison.Ordinal); + /* ...but the FULL JOIN must be COALESCE-equality — PG can't FULL-JOIN on IS NOT DISTINCT FROM. */ + Assert.Contains("COALESCE(c.database_name, '') = COALESCE(b.database_name, '')", sql, StringComparison.Ordinal); + Assert.Contains(finalGroupBy, sql, StringComparison.Ordinal); + } + + [Fact] + public void QueryStoreComparisonSql_UsesExecutionCountWeightedAverages() + { + /* Query Store rows are per-interval averages; the comparison weights each by execution_count. */ + var sql = ViewerDataService.QueryStoreComparisonSql; + Assert.Contains("SUM(qs.execution_count * qs.avg_duration_us::double precision) / NULLIF(SUM(qs.execution_count), 0)", sql, StringComparison.Ordinal); + } + + // ── Slicers ── + + [Theory] + [InlineData(nameof(ViewerDataService.QueryStatsSlicerSql), "query_stats", "COUNT(DISTINCT query_hash)")] + [InlineData(nameof(ViewerDataService.ProcStatsSlicerSql), "procedure_stats", "COUNT(DISTINCT object_name)")] + [InlineData(nameof(ViewerDataService.QueryStoreSlicerSql), "query_store_stats", "COUNT(DISTINCT query_id)")] + public void SlicerSql_BucketsByHour_SevenColumnShape(string sqlName, string table, string distinctCount) + { + var sql = SqlByName(sqlName); + Assert.Contains("date_trunc('hour', collection_time)", sql, StringComparison.Ordinal); + Assert.Contains($"FROM {table}", sql, StringComparison.Ordinal); + Assert.Contains(distinctCount, sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $2", sql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $3", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY bucket", sql, StringComparison.Ordinal); + } + + // ── PG dialect (all Queries reads) ── + + [Theory] + [InlineData(nameof(ViewerDataService.TopQueriesSql))] + [InlineData(nameof(ViewerDataService.TopProceduresSql))] + [InlineData(nameof(ViewerDataService.QueryStoreTopSql))] + [InlineData(nameof(ViewerDataService.QueryStoreRegressionsSql))] + [InlineData(nameof(ViewerDataService.QueryStatsComparisonSql))] + [InlineData(nameof(ViewerDataService.ProcedureStatsComparisonSql))] + [InlineData(nameof(ViewerDataService.QueryStoreComparisonSql))] + [InlineData(nameof(ViewerDataService.QueryStatsSlicerSql))] + [InlineData(nameof(ViewerDataService.ProcStatsSlicerSql))] + [InlineData(nameof(ViewerDataService.QueryStoreSlicerSql))] + public void QueriesReads_ArePostgresDialect_PositionalParams_NoTsqlIsms(string sqlName) + { + var sql = SqlByName(sqlName); + Assert.DoesNotContain("now(", sql.ToLowerInvariant()); + Assert.DoesNotContain("getdate", sql.ToLowerInvariant()); + Assert.DoesNotContain("N'", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + Assert.Contains("$1", sql, StringComparison.Ordinal); + } + + private static string SqlByName(string name) => name switch + { + nameof(ViewerDataService.TopQueriesSql) => ViewerDataService.TopQueriesSql, + nameof(ViewerDataService.TopProceduresSql) => ViewerDataService.TopProceduresSql, + nameof(ViewerDataService.QueryStoreTopSql) => ViewerDataService.QueryStoreTopSql, + nameof(ViewerDataService.QueryStoreRegressionsSql) => ViewerDataService.QueryStoreRegressionsSql, + nameof(ViewerDataService.QueryStatsComparisonSql) => ViewerDataService.QueryStatsComparisonSql, + nameof(ViewerDataService.ProcedureStatsComparisonSql) => ViewerDataService.ProcedureStatsComparisonSql, + nameof(ViewerDataService.QueryStoreComparisonSql) => ViewerDataService.QueryStoreComparisonSql, + nameof(ViewerDataService.QueryStatsSlicerSql) => ViewerDataService.QueryStatsSlicerSql, + nameof(ViewerDataService.ProcStatsSlicerSql) => ViewerDataService.ProcStatsSlicerSql, + _ => ViewerDataService.QueryStoreSlicerSql, + }; +} + +/// The Queries row models' pure display helpers: raw server-clock formatting, FullName, totals. +public sealed class ViewerQueriesDisplayTests +{ + [Fact] + public void FormatServerClock_ShowsRawServerWallClock_EmptyForNull() + { + /* last_execution_time / creation_time are the SQL server's local wall clock — shown raw, NOT run + through the naive-UTC-to-local conversion the collection_time columns get. */ + var t = new DateTime(2026, 7, 1, 13, 45, 7); + Assert.Equal("2026-07-01 13:45:07", ViewerDataService.FormatServerClock(t)); + Assert.Equal("", ViewerDataService.FormatServerClock(null)); + } + + [Fact] + public void QueryStatsRow_ConvertsMicrosecondsToMs_AndAveragesByExecutions() + { + var row = new ViewerQueryStatsRow { TotalExecutions = 4, TotalCpuUs = 8000, TotalElapsedUs = 20000, TotalLogicalReads = 400 }; + Assert.Equal(8.0, row.TotalCpuMs); + Assert.Equal(20.0, row.TotalElapsedMs); + Assert.Equal(2.0, row.AvgCpuMs); + Assert.Equal(5.0, row.AvgElapsedMs); + Assert.Equal(100.0, row.AvgReads); + } + + [Fact] + public void ProcedureStatsRow_FullName_ComposesSchemaAndObject() + { + Assert.Equal("dbo.usp_Get", new ViewerProcedureStatsRow { SchemaName = "dbo", ObjectName = "usp_Get" }.FullName); + Assert.Equal("usp_Get", new ViewerProcedureStatsRow { SchemaName = "", ObjectName = "usp_Get" }.FullName); + } + + [Fact] + public void QueryStatsRow_ModuleName_ComposesDbSchemaObject_OrAdHocWhenUnmatched() + { + /* #1568: attributed statement shows database.schema.object; an unmatched statement is ad hoc. */ + var attributed = new ViewerQueryStatsRow { ModuleDatabaseName = "StackOverflow", ModuleSchemaName = "dbo", ModuleObjectName = "usp_Get" }; + Assert.Equal("StackOverflow.dbo.usp_Get", attributed.ModuleName); + Assert.Equal("ad hoc", new ViewerQueryStatsRow().ModuleName); + Assert.Equal("ad hoc", new ViewerQueryStatsRow { ModuleDatabaseName = "StackOverflow", ModuleSchemaName = "dbo" }.ModuleName); + } + + [Fact] + public void QueryStoreRow_TotalsAreExecutionsTimesAverage() + { + var row = new ViewerQueryStoreRow { TotalExecutions = 10, AvgDurationMs = 3.5, AvgCpuTimeMs = 1.25 }; + Assert.Equal(35.0, row.TotalDurationMs); + Assert.Equal(12.5, row.TotalCpuMs); + } + + [Fact] + public void ComparisonItems_AreTheSharedUiTypes() + { + /* The three comparison reads return the shared .Ui derivatives the collapsed grids bind. */ + Assert.IsAssignableFrom(new QueryStatsComparisonItem()); + Assert.IsAssignableFrom(new ProcedureStatsComparisonItem()); + Assert.Equal("NEW", new QueryStatsComparisonItem { ExecutionCount = 5, BaselineExecutionCount = 0 }.StatusBadge); + Assert.Equal("GONE", new ProcedureStatsComparisonItem { ExecutionCount = 0, BaselineExecutionCount = 5 }.StatusBadge); + } +} + +/// +/// Pins the Query Store Regressions read (the Dashboard's report.query_store_regressions TVF ported +/// to Postgres): the baseline-before-window vs. recent-in-window split ON collection_time (not the +/// TVF's server_last_execution_time), the CPU-regression > 25% gate, the added-duration ranking + +/// TOP (50) cap, the duration-driven severity bands, the summed/counted CASTs, and the #1319 database +/// filter — plus the row model's raw-server-clock display. String + pure-logic pins only (no live Postgres). +/// +public sealed class ViewerQueryStoreRegressionsTests +{ + [Fact] + public void RegressionsSql_SplitsBaselineBeforeWindow_RecentInWindow_OverTheBaseTable() + { + var sql = ViewerDataService.QueryStoreRegressionsSql; + Assert.Contains("FROM query_store_stats", sql, StringComparison.Ordinal); + Assert.DoesNotContain("v_query_store_stats", sql, StringComparison.Ordinal); /* viewer reads base tables */ + Assert.Contains("collection_time < $2", sql, StringComparison.Ordinal); /* baseline: everything before the window */ + Assert.Contains("collection_time >= $2", sql, StringComparison.Ordinal); /* recent: window start */ + Assert.Contains("collection_time <= $3", sql, StringComparison.Ordinal); /* recent: window end */ + Assert.Contains("GROUP BY database_name, query_id", sql, StringComparison.Ordinal); + /* Darling windows the split on collection_time — the Dashboard TVF's server_last_execution_time is + the server's LOCAL wall clock in Darling's store and must not be windowed against UTC bounds. */ + Assert.DoesNotContain("server_last_execution_time", sql, StringComparison.Ordinal); + } + + [Fact] + public void RegressionsSql_GatesOnCpuRegressionOver25_RanksByAddedDuration_CapsAt50_InnerJoinsBaseline() + { + var sql = ViewerDataService.QueryStoreRegressionsSql; + /* The TVF's single-metric gate: CPU regression > 25% (recent vs baseline), NULLIF-guarded. */ + Assert.Contains("(r.avg_cpu_time_ms - b.avg_cpu_time_ms) * 100.0 / NULLIF(b.avg_cpu_time_ms, 0) > 25", sql, StringComparison.Ordinal); + /* Extra total time = per-exec duration delta × recent exec count, and the read's ranking. */ + Assert.Contains("(r.avg_duration_ms - b.avg_duration_ms) * r.exec_count AS additional_duration_ms", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY additional_duration_ms DESC", sql, StringComparison.Ordinal); + Assert.Contains("LIMIT 50", sql, StringComparison.Ordinal); + /* INNER JOIN — a query with no baseline (NEW) can't regress, exactly like the Dashboard TVF. */ + Assert.Contains("JOIN baseline_performance", sql, StringComparison.Ordinal); + } + + [Fact] + public void RegressionsSql_DurationDrivenSeverityBands_MatchTheDashboardThresholds() + { + var sql = ViewerDataService.QueryStoreRegressionsSql; + Assert.Contains("> 100 THEN 'CRITICAL'", sql, StringComparison.Ordinal); + Assert.Contains("> 50 THEN 'HIGH'", sql, StringComparison.Ordinal); + Assert.Contains("> 25 THEN 'MEDIUM'", sql, StringComparison.Ordinal); + Assert.Contains("ELSE 'LOW'", sql, StringComparison.Ordinal); + } + + [Fact] + public void RegressionsSql_CastsSummedExecCountToBigint_PlanCountToInteger_ConvertsUnits_HonorsDatabaseFilter() + { + var sql = ViewerDataService.QueryStoreRegressionsSql; + Assert.Contains("CAST(SUM(execution_count) AS bigint)", sql, StringComparison.Ordinal); + Assert.Contains("CAST(COUNT(DISTINCT plan_id) AS integer)", sql, StringComparison.Ordinal); + /* µs → ms on duration + CPU; reads stay raw pages (matching the Dashboard TVF units). */ + Assert.Contains("AVG(CAST(avg_duration_us AS double precision)) / 1000.0", sql, StringComparison.Ordinal); + Assert.Contains("AVG(CAST(avg_cpu_time_us AS double precision)) / 1000.0", sql, StringComparison.Ordinal); + /* #1319 global database filter, the same guarded ANY() idiom as the sibling reads ($4 here). */ + Assert.Contains("$4::text[] IS NULL OR database_name = ANY($4)", sql, StringComparison.Ordinal); + } + + [Fact] + public void RegressionRow_LastExecutionLocal_ShowsRawServerClock_EmptyForNull() + { + var row = new ViewerQueryStoreRegressionRow { LastExecutionTime = new DateTime(2026, 7, 1, 9, 30, 15) }; + Assert.Equal("2026-07-01 09:30:15", row.LastExecutionTimeLocal); + Assert.Equal("", new ViewerQueryStoreRegressionRow { LastExecutionTime = null }.LastExecutionTimeLocal); + } +} + +/// +/// Gated (DARLING_TEST_PG) live round-trips for the three Queries reads + a comparison + a slicer. Each +/// plants query/procedure/query-store rows for a negative sentinel server across two collections, then +/// asserts the read executes on real Postgres and returns the expected grouping / ordering / unit +/// conversion / WAITFOR trim end-to-end (the string pins can't catch a dialect error or an ordinal +/// slip). Shares the serialized "live-postgres" collection and cleans up in finally. +/// +[Collection("live-postgres")] +public sealed class ViewerQueriesLivePostgresTests +{ + private const int QueryStatsServerId = -970801; + private const int ProcedureStatsServerId = -970802; + private const int QueryStoreServerId = -970803; + private const int ComparisonServerId = -970804; + private const int SlicerServerId = -970805; + private const int ProcedureStatsPlanServerId = -970806; + private const int RegressionsServerId = -970807; + private const int ModuleAttributionServerId = -970808; + + private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + + [Fact] + public async Task TopQueries_GroupsSums_ExcludesWaitfor_OrdersByTotalDuration_AgainstDevPostgres() + { + var cs = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live Top Queries test."); + + using var connection = new NpgsqlConnection(cs); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "query_stats", QueryStatsServerId); + + await using var viewer = new ViewerDataService(cs!); + var end = TruncateToSeconds(DateTime.UtcNow); + var start = end.AddHours(-24); + var t1 = start.AddHours(1); + var t2 = start.AddHours(2); + + try + { + /* "slow" query: two collections summed. total_elapsed = 300_000 us across the two rows. */ + await InsertQueryStatsAsync(connection, QueryStatsServerId, t1, "StackOverflow", "0xSLOW", + deltaExec: 2, deltaWorker: 40_000, deltaElapsed: 120_000, deltaReads: 200, queryText: "SELECT slow"); + await InsertQueryStatsAsync(connection, QueryStatsServerId, t2, "StackOverflow", "0xSLOW", + deltaExec: 3, deltaWorker: 60_000, deltaElapsed: 180_000, deltaReads: 300, queryText: "SELECT slow"); + /* "fast" query: smaller total elapsed → ranks second. */ + await InsertQueryStatsAsync(connection, QueryStatsServerId, t1, "StackOverflow", "0xFAST", + deltaExec: 5, deltaWorker: 5_000, deltaElapsed: 10_000, deltaReads: 50, queryText: "SELECT fast"); + /* WAITFOR shell: must be trimmed. */ + await InsertQueryStatsAsync(connection, QueryStatsServerId, t1, "StackOverflow", "0xWAIT", + deltaExec: 1, deltaWorker: 1, deltaElapsed: 999_999, deltaReads: 1, queryText: "WAITFOR DELAY '00:00:05'"); + + var rows = await viewer.GetTopQueriesByCpuAsync(QueryStatsServerId, start, end); + + Assert.Equal(2, rows.Count); /* WAITFOR excluded despite its huge elapsed */ + Assert.DoesNotContain(rows, r => r.QueryHash == "0xWAIT"); + + /* Ordered by total elapsed descending: slow first. */ + Assert.Equal("0xSLOW", rows[0].QueryHash); + Assert.Equal(5, rows[0].TotalExecutions); /* 2 + 3 summed across collections */ + Assert.Equal(300.0, rows[0].TotalElapsedMs); /* (120000 + 180000) us -> ms */ + Assert.Equal(100.0, rows[0].TotalCpuMs); /* (40000 + 60000) us -> ms */ + Assert.Equal("SELECT slow", rows[0].QueryText); + Assert.Equal("0xFAST", rows[1].QueryHash); + } + finally + { + await DeleteRowsAsync(connection, "query_stats", QueryStatsServerId); + } + } + + [Fact] + public async Task TopQueries_AttributesModuleByHandle_DedupsToLatest_AdHocWhenUnmatched_AgainstDevPostgres() + { + var cs = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live module attribution test."); + + using var connection = new NpgsqlConnection(cs); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "query_stats", ModuleAttributionServerId); + await DeleteRowsAsync(connection, "procedure_stats", ModuleAttributionServerId); + + await using var viewer = new ViewerDataService(cs!); + var end = TruncateToSeconds(DateTime.UtcNow); + var start = end.AddHours(-24); + var t1 = start.AddHours(1); + + try + { + /* Attributed: query whose sql_handle matches a cached module; bigger elapsed → ranks first. */ + await InsertQueryStatsAsync(connection, ModuleAttributionServerId, t1, "StackOverflow", "0xATTRIB", + deltaExec: 5, deltaWorker: 60_000, deltaElapsed: 300_000, deltaReads: 300, queryText: "SELECT attributed", sqlHandle: "0xMOD"); + /* Same handle captured twice — the newer identity (usp_New) wins, and the query row must not fan + out across the two procedure_stats rows. */ + await InsertProcedureStatsAsync(connection, ModuleAttributionServerId, t1, "StackOverflow", "dbo", "usp_Old", "PROCEDURE", + deltaExec: 1, deltaWorker: 10_000, deltaElapsed: 20_000, deltaReads: 100, sqlHandle: "0xMOD"); + await InsertProcedureStatsAsync(connection, ModuleAttributionServerId, t1.AddMinutes(30), "StackOverflow", "dbo", "usp_New", "PROCEDURE", + deltaExec: 1, deltaWorker: 10_000, deltaElapsed: 20_000, deltaReads: 100, sqlHandle: "0xMOD"); + /* Ad hoc: query whose sql_handle matches no cached module. */ + await InsertQueryStatsAsync(connection, ModuleAttributionServerId, t1, "StackOverflow", "0xADHOC", + deltaExec: 3, deltaWorker: 20_000, deltaElapsed: 100_000, deltaReads: 100, queryText: "SELECT adhoc", sqlHandle: "0xLOOSE"); + + var rows = await viewer.GetTopQueriesByCpuAsync(ModuleAttributionServerId, start, end); + + Assert.Equal(2, rows.Count); /* the two module rows did not fan out the one query */ + var attributed = rows.Single(r => r.QueryHash == "0xATTRIB"); + Assert.Equal("StackOverflow.dbo.usp_New", attributed.ModuleName); /* latest collection_time wins */ + Assert.Equal("ad hoc", rows.Single(r => r.QueryHash == "0xADHOC").ModuleName); + } + finally + { + await DeleteRowsAsync(connection, "query_stats", ModuleAttributionServerId); + await DeleteRowsAsync(connection, "procedure_stats", ModuleAttributionServerId); + } + } + + [Fact] + public async Task TopProcedures_GroupsByObject_ComposesFullName_OrdersByTotalDuration_AgainstDevPostgres() + { + var cs = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live Top Procedures test."); + + using var connection = new NpgsqlConnection(cs); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "procedure_stats", ProcedureStatsServerId); + + await using var viewer = new ViewerDataService(cs!); + var end = TruncateToSeconds(DateTime.UtcNow); + var start = end.AddHours(-24); + + try + { + await InsertProcedureStatsAsync(connection, ProcedureStatsServerId, start.AddHours(1), "StackOverflow", "dbo", "usp_Slow", "PROC", + deltaExec: 4, deltaWorker: 40_000, deltaElapsed: 200_000, deltaReads: 400); + await InsertProcedureStatsAsync(connection, ProcedureStatsServerId, start.AddHours(1), "StackOverflow", "dbo", "usp_Fast", "PROC", + deltaExec: 10, deltaWorker: 5_000, deltaElapsed: 20_000, deltaReads: 100); + + var rows = await viewer.GetTopProceduresByCpuAsync(ProcedureStatsServerId, start, end); + + Assert.Equal(2, rows.Count); + Assert.Equal("dbo.usp_Slow", rows[0].FullName); + Assert.Equal(4, rows[0].TotalExecutions); + Assert.Equal(200.0, rows[0].TotalElapsedMs); + Assert.Equal(50.0, rows[0].AvgElapsedMs); /* 200000 us / 4 execs -> ms */ + Assert.Equal("dbo.usp_Fast", rows[1].FullName); + } + finally + { + await DeleteRowsAsync(connection, "procedure_stats", ProcedureStatsServerId); + } + } + + [Fact] + public async Task ProcedureStatsPlan_FetchesNewestStoredXml_KeyedByObject_NullWhenUncaptured_AgainstDevPostgres() + { + var cs = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live procedure-plan test."); + + using var connection = new NpgsqlConnection(cs); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "procedure_stats", ProcedureStatsPlanServerId); + + await using var viewer = new ViewerDataService(cs!); + var t = TruncateToSeconds(DateTime.UtcNow).AddHours(-2); + + try + { + /* Same (db, schema, object) captured twice — the newer plan wins (ORDER BY collection_time DESC). */ + await InsertProcedureStatsAsync(connection, ProcedureStatsPlanServerId, t, "StackOverflow", "dbo", "usp_WithPlan", "PROC", + deltaExec: 1, deltaWorker: 10_000, deltaElapsed: 20_000, deltaReads: 100, planXml: "older"); + await InsertProcedureStatsAsync(connection, ProcedureStatsPlanServerId, t.AddMinutes(30), "StackOverflow", "dbo", "usp_WithPlan", "PROC", + deltaExec: 1, deltaWorker: 10_000, deltaElapsed: 20_000, deltaReads: 100, planXml: "newer"); + /* A procedure with activity but no captured plan (query_plan_xml NULL). */ + await InsertProcedureStatsAsync(connection, ProcedureStatsPlanServerId, t, "StackOverflow", "dbo", "usp_NoPlan", "PROC", + deltaExec: 1, deltaWorker: 10_000, deltaElapsed: 20_000, deltaReads: 100); + + Assert.Equal("newer", + await viewer.GetProcedureStatsPlanXmlAsync(ProcedureStatsPlanServerId, "StackOverflow", "dbo", "usp_WithPlan")); + /* No plan captured, and an absent object, both fetch null. */ + Assert.Null(await viewer.GetProcedureStatsPlanXmlAsync(ProcedureStatsPlanServerId, "StackOverflow", "dbo", "usp_NoPlan")); + Assert.Null(await viewer.GetProcedureStatsPlanXmlAsync(ProcedureStatsPlanServerId, "StackOverflow", "dbo", "usp_Absent")); + } + finally + { + await DeleteRowsAsync(connection, "procedure_stats", ProcedureStatsPlanServerId); + } + } + + [Fact] + public async Task QueryStore_AveragesIntervals_ForcedPlanBoolean_MemoryToMb_AgainstDevPostgres() + { + var cs = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live Query Store test."); + + using var connection = new NpgsqlConnection(cs); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "query_store_stats", QueryStoreServerId); + + await using var viewer = new ViewerDataService(cs!); + var end = TruncateToSeconds(DateTime.UtcNow); + var start = end.AddHours(-24); + + try + { + /* One query, one plan, two intervals averaged: avg_duration_us 2000 & 4000 -> AVG 3000us = 3ms. */ + await InsertQueryStoreAsync(connection, QueryStoreServerId, start.AddHours(1), "StackOverflow", queryId: 42, planId: 7, + execCount: 3, avgDurationUs: 2000, avgCpuUs: 1000, forced: true, maxMemPages: 1024, queryText: "SELECT qs"); + await InsertQueryStoreAsync(connection, QueryStoreServerId, start.AddHours(2), "StackOverflow", queryId: 42, planId: 7, + execCount: 3, avgDurationUs: 4000, avgCpuUs: 2000, forced: true, maxMemPages: 1024, queryText: "SELECT qs"); + + var rows = await viewer.GetQueryStoreTopQueriesAsync(QueryStoreServerId, start, end); + + var r = Assert.Single(rows); + Assert.Equal(42, r.QueryId); + Assert.Equal(7, r.PlanId); + Assert.Equal(6, r.TotalExecutions); /* 3 + 3 summed */ + Assert.Equal(3.0, r.AvgDurationMs, 3); /* AVG(2000, 4000) us -> ms */ + Assert.True(r.IsForcedPlan); + Assert.Equal(8.0, r.AvgMemoryMb, 3); /* 1024 pages * 8 KB / 1024 -> 8 MB */ + Assert.Equal("SELECT qs", r.QueryText); + } + finally + { + await DeleteRowsAsync(connection, "query_store_stats", QueryStoreServerId); + } + } + + [Fact] + public async Task QueryStoreRegressions_ContrastsBaselineVsRecent_GatesOnCpu_RanksByAddedDuration_AgainstDevPostgres() + { + var cs = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live Query Store regressions test."); + + using var connection = new NpgsqlConnection(cs); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "query_store_stats", RegressionsServerId); + + await using var viewer = new ViewerDataService(cs!); + var end = TruncateToSeconds(DateTime.UtcNow); + var start = end.AddHours(-24); + var baseline = start.AddHours(-2); /* before the window → the baseline arm */ + var recent = start.AddHours(1); /* inside the window → the recent arm */ + + try + { + /* Query 100 REGRESSED: baseline 2ms dur / 1ms cpu → recent 6ms dur / 4ms cpu. + CPU regression = (4-1)/1 = 300% (clears the > 25% gate); duration regression = (6-2)/2 = 200% + (CRITICAL band); additional = (6-2) * recent exec 5 = 20 ms. */ + await InsertQueryStoreAsync(connection, RegressionsServerId, baseline, "StackOverflow", queryId: 100, planId: 1, + execCount: 4, avgDurationUs: 2000, avgCpuUs: 1000, forced: false, maxMemPages: 0, queryText: "SELECT regressed"); + await InsertQueryStoreAsync(connection, RegressionsServerId, recent, "StackOverflow", queryId: 100, planId: 2, + execCount: 5, avgDurationUs: 6000, avgCpuUs: 4000, forced: false, maxMemPages: 0, queryText: "SELECT regressed"); + + /* Query 200 STABLE: cpu 1ms → 1.1ms = 10% (below the gate) → excluded. */ + await InsertQueryStoreAsync(connection, RegressionsServerId, baseline, "StackOverflow", queryId: 200, planId: 1, + execCount: 5, avgDurationUs: 3000, avgCpuUs: 1000, forced: false, maxMemPages: 0, queryText: "SELECT stable"); + await InsertQueryStoreAsync(connection, RegressionsServerId, recent, "StackOverflow", queryId: 200, planId: 1, + execCount: 5, avgDurationUs: 3000, avgCpuUs: 1100, forced: false, maxMemPages: 0, queryText: "SELECT stable"); + + /* Query 300 NEW (recent only, no baseline) → the INNER JOIN drops it. */ + await InsertQueryStoreAsync(connection, RegressionsServerId, recent, "StackOverflow", queryId: 300, planId: 1, + execCount: 9, avgDurationUs: 9000, avgCpuUs: 9000, forced: false, maxMemPages: 0, queryText: "SELECT new"); + + var rows = await viewer.GetQueryStoreRegressionsAsync(RegressionsServerId, start, end); + + var r = Assert.Single(rows); /* only query 100 clears the CPU gate + INNER JOIN */ + Assert.Equal(100, r.QueryId); + Assert.Equal("CRITICAL", r.Severity); + Assert.Equal(2.0, r.BaselineDurationMs, 3); + Assert.Equal(6.0, r.RecentDurationMs, 3); + Assert.Equal(200.0, r.DurationRegressionPercent, 1); + Assert.Equal(300.0, r.CpuRegressionPercent, 1); + Assert.Equal(20.0, r.AdditionalDurationMs, 1); /* (6-2)ms * 5 recent execs */ + Assert.Equal(4, r.BaselineExecCount); + Assert.Equal(5, r.RecentExecCount); + Assert.Equal("SELECT regressed", r.QueryTextSample); + } + finally + { + await DeleteRowsAsync(connection, "query_store_stats", RegressionsServerId); + } + } + + [Fact] + public async Task QueryStatsComparison_FlagsNewAndGone_AcrossWindows_AgainstDevPostgres() + { + var cs = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live comparison test."); + + using var connection = new NpgsqlConnection(cs); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "query_stats", ComparisonServerId); + + await using var viewer = new ViewerDataService(cs!); + var currentEnd = TruncateToSeconds(DateTime.UtcNow); + var currentStart = currentEnd.AddHours(-1); + var baselineEnd = currentStart; + var baselineStart = baselineEnd.AddHours(-1); + + try + { + /* Present in both -> normal; present only current -> NEW; only baseline -> GONE. */ + await InsertQueryStatsAsync(connection, ComparisonServerId, currentStart.AddMinutes(10), "DB", "0xBOTH", + deltaExec: 5, deltaWorker: 5000, deltaElapsed: 10000, deltaReads: 50, queryText: "both"); + await InsertQueryStatsAsync(connection, ComparisonServerId, baselineStart.AddMinutes(10), "DB", "0xBOTH", + deltaExec: 5, deltaWorker: 5000, deltaElapsed: 8000, deltaReads: 50, queryText: "both"); + await InsertQueryStatsAsync(connection, ComparisonServerId, currentStart.AddMinutes(10), "DB", "0xNEW", + deltaExec: 3, deltaWorker: 3000, deltaElapsed: 6000, deltaReads: 30, queryText: "new"); + await InsertQueryStatsAsync(connection, ComparisonServerId, baselineStart.AddMinutes(10), "DB", "0xGONE", + deltaExec: 7, deltaWorker: 7000, deltaElapsed: 14000, deltaReads: 70, queryText: "gone"); + + var items = await viewer.GetQueryStatsComparisonAsync(ComparisonServerId, currentStart, currentEnd, baselineStart, baselineEnd); + + Assert.Equal("NEW", items.Single(i => i.QueryHash == "0xNEW").StatusBadge); + Assert.Equal("GONE", items.Single(i => i.QueryHash == "0xGONE").StatusBadge); + Assert.Equal("", items.Single(i => i.QueryHash == "0xBOTH").StatusBadge); + } + finally + { + await DeleteRowsAsync(connection, "query_stats", ComparisonServerId); + } + } + + [Fact] + public async Task QueryStatsSlicer_BucketsByHour_AgainstDevPostgres() + { + var cs = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to a Postgres connection string to run the live slicer test."); + + using var connection = new NpgsqlConnection(cs); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteRowsAsync(connection, "query_stats", SlicerServerId); + + await using var viewer = new ViewerDataService(cs!); + var end = TruncateToSeconds(DateTime.UtcNow); + var start = end.AddHours(-24); + var hour1 = TruncateToHour(start.AddHours(3)); + var hour2 = TruncateToHour(start.AddHours(5)); + + try + { + await InsertQueryStatsAsync(connection, SlicerServerId, hour1.AddMinutes(5), "DB", "0xA", + deltaExec: 1, deltaWorker: 60_000, deltaElapsed: 120_000, deltaReads: 10, queryText: "a"); + await InsertQueryStatsAsync(connection, SlicerServerId, hour1.AddMinutes(35), "DB", "0xB", + deltaExec: 1, deltaWorker: 60_000, deltaElapsed: 120_000, deltaReads: 10, queryText: "b"); + await InsertQueryStatsAsync(connection, SlicerServerId, hour2.AddMinutes(5), "DB", "0xA", + deltaExec: 1, deltaWorker: 30_000, deltaElapsed: 60_000, deltaReads: 10, queryText: "a"); + + var buckets = await viewer.GetQueryStatsSlicerDataAsync(SlicerServerId, start, end); + + Assert.Equal(2, buckets.Count); /* two distinct hours */ + var first = buckets[0]; + Assert.Equal(hour1, first.BucketTime); + Assert.Equal(2, first.SessionCount); /* two distinct query hashes in hour1 */ + Assert.Equal(120.0, first.TotalCpu, 3); /* (60000 + 60000) us / 1000 -> ms */ + } + finally + { + await DeleteRowsAsync(connection, "query_stats", SlicerServerId); + } + } + + // ── Insert helpers (only the columns each read touches; the rest default to NULL) ── + + private static async Task InsertQueryStatsAsync( + NpgsqlConnection connection, int serverId, DateTime collectionTimeUtc, string databaseName, string queryHash, + long deltaExec, long deltaWorker, long deltaElapsed, long deltaReads, string queryText, string sqlHandle = "0xSQLHANDLE") + { + using var command = new NpgsqlCommand(@" +INSERT INTO query_stats + (collection_id, collection_time, server_id, server_name, + database_name, query_hash, query_plan_hash, sql_handle, plan_handle, + last_execution_time, creation_time, sample_interval_seconds, + delta_execution_count, delta_worker_time, delta_elapsed_time, delta_logical_reads, + delta_rows, delta_logical_writes, delta_physical_reads, delta_spills, + total_clr_time, plan_generation_num, query_text) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue("viewer-queries-e2e"); + command.Parameters.AddWithValue(databaseName); + command.Parameters.AddWithValue(queryHash); + command.Parameters.AddWithValue("0xPLANHASH"); + command.Parameters.AddWithValue(sqlHandle); + command.Parameters.AddWithValue("0xPLANHANDLE"); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc.AddHours(-1), DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(60); + command.Parameters.AddWithValue(deltaExec); + command.Parameters.AddWithValue(deltaWorker); + command.Parameters.AddWithValue(deltaElapsed); + command.Parameters.AddWithValue(deltaReads); + command.Parameters.AddWithValue(0L); + command.Parameters.AddWithValue(0L); + command.Parameters.AddWithValue(0L); + command.Parameters.AddWithValue(0L); + command.Parameters.AddWithValue(0L); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(queryText); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertProcedureStatsAsync( + NpgsqlConnection connection, int serverId, DateTime collectionTimeUtc, + string databaseName, string schemaName, string objectName, string objectType, + long deltaExec, long deltaWorker, long deltaElapsed, long deltaReads, string? planXml = null, string sqlHandle = "0xSQLHANDLE") + { + using var command = new NpgsqlCommand(@" +INSERT INTO procedure_stats + (collection_id, collection_time, server_id, server_name, + database_name, schema_name, object_name, object_type, + cached_time, last_execution_time, sql_handle, plan_handle, + delta_execution_count, delta_worker_time, delta_elapsed_time, delta_logical_reads, + delta_logical_writes, delta_physical_reads, delta_spills, query_plan_xml) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue("viewer-procs-e2e"); + command.Parameters.AddWithValue(databaseName); + command.Parameters.AddWithValue(schemaName); + command.Parameters.AddWithValue(objectName); + command.Parameters.AddWithValue(objectType); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc.AddHours(-1), DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(sqlHandle); + command.Parameters.AddWithValue("0xPLANHANDLE"); + command.Parameters.AddWithValue(deltaExec); + command.Parameters.AddWithValue(deltaWorker); + command.Parameters.AddWithValue(deltaElapsed); + command.Parameters.AddWithValue(deltaReads); + command.Parameters.AddWithValue(0L); + command.Parameters.AddWithValue(0L); + command.Parameters.AddWithValue(0L); + command.Parameters.AddWithValue((object?)planXml ?? DBNull.Value); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertQueryStoreAsync( + NpgsqlConnection connection, int serverId, DateTime collectionTimeUtc, string databaseName, + long queryId, long planId, long execCount, long avgDurationUs, long avgCpuUs, bool forced, long maxMemPages, string queryText) + { + using var command = new NpgsqlCommand(@" +INSERT INTO query_store_stats + (collection_id, collection_time, server_id, server_name, + database_name, query_id, plan_id, query_hash, query_plan_hash, query_text, module_name, + execution_type_desc, first_execution_time, last_execution_time, + execution_count, avg_duration_us, avg_cpu_time_us, avg_logical_io_reads, avg_logical_io_writes, + avg_physical_io_reads, avg_rowcount, avg_clr_time_us, avg_log_bytes_used, avg_tempdb_space_used, + avg_num_physical_io_reads, avg_query_max_used_memory, min_dop, max_dop, + plan_type, plan_forcing_type, is_forced_plan, force_failure_count, compatibility_level) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, + $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33)", connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue("viewer-qs-e2e"); + command.Parameters.AddWithValue(databaseName); + command.Parameters.AddWithValue(queryId); + command.Parameters.AddWithValue(planId); + command.Parameters.AddWithValue("0xQSHASH"); + command.Parameters.AddWithValue("0xQSPLANHASH"); + command.Parameters.AddWithValue(queryText); + command.Parameters.AddWithValue("dbo.usp_Qs"); + command.Parameters.AddWithValue("Regular"); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc.AddHours(-1), DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(execCount); + command.Parameters.AddWithValue(avgDurationUs); + command.Parameters.AddWithValue(avgCpuUs); + command.Parameters.AddWithValue(100L); + command.Parameters.AddWithValue(0L); + command.Parameters.AddWithValue(10L); + command.Parameters.AddWithValue(50L); + command.Parameters.AddWithValue(0L); + command.Parameters.AddWithValue(0L); + command.Parameters.AddWithValue(0L); + command.Parameters.AddWithValue(5L); + command.Parameters.AddWithValue(maxMemPages); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue("Compiled"); + command.Parameters.AddWithValue("None"); + command.Parameters.AddWithValue(forced); + command.Parameters.AddWithValue(0L); + command.Parameters.AddWithValue(160); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static DateTime TruncateToSeconds(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified); + + private static DateTime TruncateToHour(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Year, value.Month, value.Day, value.Hour, 0, 0), DateTimeKind.Unspecified); + + private static async Task DeleteRowsAsync(NpgsqlConnection connection, string table, int serverId) + { + using var cleanup = new NpgsqlCommand($"DELETE FROM {table} WHERE server_id = {serverId};", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } +} diff --git a/Darling/Darling.Tests/ViewerRecommendationsTests.cs b/Darling/Darling.Tests/ViewerRecommendationsTests.cs new file mode 100644 index 000000000..8ea98c167 --- /dev/null +++ b/Darling/Darling.Tests/ViewerRecommendationsTests.cs @@ -0,0 +1,815 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Analysis; +using PerformanceMonitor.Darling.Analysis; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the W2b Recommendations re-skin's pure shaping: the finding-row -> advise-only card mapping, +/// the Copy-fix payload rebuilt from the PERSISTED remediation action (the viewer's read-back source, +/// mirroring the Dashboard reader), the Ask-AI MCP prompt text (ported verbatim from Lite), and the +/// affordance model. WPF-free — and its card/section DTOs carry +/// no WPF dependency, so the mapping + grouping are unit-testable directly. +/// +public sealed class ViewerRecommendationCardTests +{ + private static AnalysisFinding Finding( + double severity = 1.0, + string rootFactKey = "SOME_UNKNOWN_FACT_KEY", + string storyText = "", + string? database = null, + string incidentId = "", + RemediationAction? remediation = null, + DateTime? windowStart = null, + DateTime? windowEnd = null) + => new() + { + FindingId = 1, + AnalysisTime = new DateTime(2026, 7, 1, 12, 0, 0, DateTimeKind.Utc), + ServerId = 1, + ServerName = "SQL2022", + DatabaseName = database, + TimeRangeStart = windowStart, + TimeRangeEnd = windowEnd, + Severity = severity, + Confidence = 0.9, + Category = "cpu", + StoryPath = "a -> b", + StoryPathHash = "hash-" + rootFactKey + severity, + StoryText = storyText, + RootFactKey = rootFactKey, + IncidentId = incidentId, + FactCount = 1, + Remediation = remediation, + }; + + private static ViewerFindingRow Row(AnalysisFinding finding, string? title = null) + => new() + { + SeverityBand = ViewerDataService.SeverityBand(finding.Severity), + RawSeverity = finding.Severity, + Title = title ?? ViewerDataService.FindingTitle(finding), + Category = finding.Category, + DatabaseName = finding.DatabaseName ?? "", + Finding = finding, + }; + + [Theory] + [InlineData(2.0, RecommendationSeverity.Critical)] + [InlineData(1.5, RecommendationSeverity.Critical)] + [InlineData(1.49, RecommendationSeverity.Warning)] + [InlineData(0.75, RecommendationSeverity.Warning)] + [InlineData(0.74, RecommendationSeverity.Info)] + [InlineData(0.0, RecommendationSeverity.Info)] + public void ToSeverity_UsesTheSharedThreeBandCutoffs(double severity, RecommendationSeverity expected) + { + Assert.Equal(expected, RecommendationsViewModel.ToSeverity(severity)); + } + + [Fact] + public void ComposeAdvice_JoinsRemediationThenInvestigation_NullWhenNeither() + { + Assert.Null(RecommendationsViewModel.ComposeAdvice(null)); + Assert.Null(RecommendationsViewModel.ComposeAdvice(new AdviceBlock("head", "", ""))); + + Assert.Equal("do this", + RecommendationsViewModel.ComposeAdvice(new AdviceBlock("head", "", "do this"))); + Assert.Equal("do this look here", + RecommendationsViewModel.ComposeAdvice(new AdviceBlock("head", "look here", "do this"))); + } + + [Fact] + public void BuildAskAiPrompt_PortsLitesExactText_ReferencingTheMcpTools() + { + var from = new DateTime(2026, 7, 1, 14, 0, 0, DateTimeKind.Utc); + var to = new DateTime(2026, 7, 1, 15, 30, 0, DateTimeKind.Utc); + + var prompt = RecommendationsViewModel.BuildAskAiPrompt("SQL2022", "CPU is on fire", from, to); + + Assert.Equal( + "Using the PerformanceMonitor MCP tools, investigate this finding on server \"SQL2022\": " + + "\"CPU is on fire\". It was flagged around 2026-07-01 14:00–15:30. Call analyze_server / " + + "get_analysis_findings and the relevant wait/blocking/memory tools, then tell me the " + + "likely cause and what to do.", + prompt); + } + + [Fact] + public void BuildCopyPasteSql_NullOrEmptyAction_ReturnsNull() + { + Assert.Null(RecommendationsViewModel.BuildCopyPasteSql(null)); + + // An action that carries no renderable target (an empty force-plan list) yields null — there is + // nothing to run. (A force-plan action WITH targets now renders; see the force-plan test below.) + var empty = new RemediationAction("PLAN_REGRESSION", "force", Array.Empty()); + Assert.Null(RecommendationsViewModel.BuildCopyPasteSql(empty)); + } + + // ── The four shapes that were previously copy-paste dead ends (viewer has no executor) ────────── + + [Fact] + public void BuildCopyPasteSql_ForcePlanTargets_RenderUseThenForcePlanPerDatabase() + { + // sp_query_store_force_plan is DATABASE-scoped, so each target is a standalone USE + EXEC block + // (the copy-paste must run without an ambient database). Blocks are separated by a blank line. + var action = new RemediationAction( + "PLAN_REGRESSION", "force", + new[] + { + new ForcePlanTarget("StackOverflow", QueryId: 42, PlanId: 7), + new ForcePlanTarget("Crazy]Name", QueryId: 100, PlanId: 200), + }); + + var sql = RecommendationsViewModel.BuildCopyPasteSql(action); + + Assert.Equal( + "USE [StackOverflow];" + Environment.NewLine + + "EXEC sys.sp_query_store_force_plan @query_id = 42, @plan_id = 7;" + Environment.NewLine + + Environment.NewLine + + "USE [Crazy]]Name];" + Environment.NewLine + + "EXEC sys.sp_query_store_force_plan @query_id = 100, @plan_id = 200;", + sql); + } + + [Fact] + public void BuildCopyPasteSql_ServerConfigTargets_RenderSpConfigureViaTheSharedBuilder() + { + // Every server-config setting renders sp_configure + RECONFIGURE via the shared builder, + // INCLUDING the advise-only memory settings (the operator wants the scaffold regardless). + var action = new RemediationAction( + "SERVER_CONFIG", "set", Array.Empty(), + ServerConfigTargets: new[] + { + new ServerConfigTarget(ServerConfigSetting.Maxdop, 0, 8), + new ServerConfigTarget(ServerConfigSetting.MaxServerMemory, 2147483647, 2147483647), + }); + + var sql = RecommendationsViewModel.BuildCopyPasteSql(action); + + Assert.Equal( + FactRemediation.BuildSpConfigureStatement(ServerConfigSetting.Maxdop, 8) + + Environment.NewLine + Environment.NewLine + + FactRemediation.BuildSpConfigureStatement(ServerConfigSetting.MaxServerMemory, 2147483647), + sql); + } + + [Fact] + public void BuildCopyPasteSql_RcsiTargets_RenderAlterWithTwoSidedDisclosureHeader() + { + // RCSI rides a DB_CONFIG action as a per-database RcsiTarget (with real inaction figures). It is + // DESTRUCTIVE, so the copy-paste prepends the two-sided risk disclosure as a comment header. + var action = new RemediationAction( + "DB_CONFIG", "set", Array.Empty(), + RcsiTargets: new[] { new RcsiTarget("StackOverflow", new RcsiInactionFigures(50, 3, 70)) }); + + var sql = RecommendationsViewModel.BuildCopyPasteSql(action); + + Assert.NotNull(sql); + // Two-sided disclosure header, rendered as a T-SQL block comment ABOVE the runnable statement. + Assert.StartsWith("/*", sql, StringComparison.Ordinal); + Assert.Contains("Risks of MAKING this change:", sql!, StringComparison.Ordinal); + Assert.Contains("Risks of NOT making this change:", sql!, StringComparison.Ordinal); + // The real carried figures reach the inaction side (50 blocked-process events, reader/writer). + Assert.Contains("50 blocked-process events", sql!, StringComparison.Ordinal); + Assert.Contains("readers blocked by writers", sql!, StringComparison.Ordinal); + // The runnable statement, after the closing comment. + Assert.Contains("ALTER DATABASE [StackOverflow] SET READ_COMMITTED_SNAPSHOT ON;", sql!, StringComparison.Ordinal); + Assert.True( + sql!.IndexOf("*/", StringComparison.Ordinal) < + sql.IndexOf("ALTER DATABASE", StringComparison.Ordinal), + "the disclosure comment header must precede the ALTER statement"); + } + + [Fact] + public void BuildCopyPasteSql_ClearPlanTargets_RenderResolveAndFreeScriptWithDisclosureHeader() + { + // Clear-plan is DESTRUCTIVE. The honest runnable form live-resolves the currently-cached plan + // handle(s) for the abnormal query hash and frees each — with the two-sided disclosure header. + var action = new RemediationAction( + "CLEAR_PLAN", "clear", Array.Empty(), + ClearPlanTargets: new[] + { + new ClearPlanTarget( + "StackOverflow", "0xABCDEF0123456789", + CurrentCpuPerExecMs: 45.0, BaselineCpuPerExecMs: 9.0, AnomalyRatio: 5.0), + }, + ClearPlanFigures: new ClearPlanFigures(45.0, 9.0, 5.0, 62, false, false)); + + var sql = RecommendationsViewModel.BuildCopyPasteSql(action); + + Assert.NotNull(sql); + Assert.StartsWith("/*", sql, StringComparison.Ordinal); + Assert.Contains("Risks of MAKING this change:", sql!, StringComparison.Ordinal); + Assert.Contains("Risks of NOT making this change:", sql!, StringComparison.Ordinal); + // The real carried anomaly figure (62% of CPU over the window) reaches the inaction side. + Assert.Contains("62% of CPU over the window", sql!, StringComparison.Ordinal); + // The honest runnable form: resolve cached handles for the hash, then free each one. + Assert.Contains("sys.dm_exec_query_stats", sql!, StringComparison.Ordinal); + Assert.Contains("0xABCDEF0123456789", sql!, StringComparison.Ordinal); + Assert.Contains("DBCC FREEPROCCACHE(@plan_handle);", sql!, StringComparison.Ordinal); + } + + [Fact] + public void BuildCopyPasteSql_DbConfigWithRcsi_RendersSafeBareThenRcsiWithHeader() + { + // A DB_CONFIG action can carry BOTH always-safe settings AND per-db RCSI. The safe ALTER renders + // bare (non-destructive, no header); the RCSI ALTER follows with its two-sided disclosure header. + var action = new RemediationAction( + "DB_CONFIG", "set", Array.Empty(), + DbConfigTargets: new[] { new DbConfigTarget("StackOverflow", DbConfigSetting.AutoShrinkOff) }, + RcsiTargets: new[] { new RcsiTarget("StackOverflow", new RcsiInactionFigures(50, 3, 70)) }); + + var sql = RecommendationsViewModel.BuildCopyPasteSql(action); + + Assert.NotNull(sql); + // Safe fix comes first, bare (no disclosure header above it). + Assert.StartsWith("ALTER DATABASE [StackOverflow] SET AUTO_SHRINK OFF;", sql, StringComparison.Ordinal); + // Then the destructive RCSI block, with its header. + Assert.Contains("Risks of MAKING this change:", sql!, StringComparison.Ordinal); + Assert.Contains("ALTER DATABASE [StackOverflow] SET READ_COMMITTED_SNAPSHOT ON;", sql!, StringComparison.Ordinal); + Assert.True( + sql!.IndexOf("AUTO_SHRINK OFF", StringComparison.Ordinal) < + sql.IndexOf("/*", StringComparison.Ordinal), + "the bare safe fix must precede the RCSI disclosure header"); + } + + [Fact] + public void BuildCopyPasteSql_DbConfigTargets_RenderAlterDatabaseSetPerTarget() + { + var action = new RemediationAction( + "DB_CONFIG", "set", Array.Empty(), + DbConfigTargets: new[] + { + new DbConfigTarget("StackOverflow", DbConfigSetting.AutoShrinkOff), + new DbConfigTarget("Crazy]Name", DbConfigSetting.PageVerifyChecksum), + }); + + var sql = RecommendationsViewModel.BuildCopyPasteSql(action); + + Assert.Equal( + "ALTER DATABASE [StackOverflow] SET AUTO_SHRINK OFF;" + Environment.NewLine + + "ALTER DATABASE [Crazy]]Name] SET PAGE_VERIFY CHECKSUM;", + sql); + } + + [Fact] + public void BuildCopyPasteSql_MissingIndexTargets_RenderTheSuggestedCreateVerbatim() + { + var action = new RemediationAction( + "MISSING_INDEX", "advise", Array.Empty(), + MissingIndexTargets: new[] + { + new MissingIndexTarget("dbo.Posts", 92.5, "CREATE INDEX IX_Posts_1 ON dbo.Posts (OwnerUserId);"), + new MissingIndexTarget("dbo.Users", 80.0, "CREATE INDEX IX_Users_1 ON dbo.Users (Reputation);"), + }); + + var sql = RecommendationsViewModel.BuildCopyPasteSql(action); + + Assert.Equal( + "CREATE INDEX IX_Posts_1 ON dbo.Posts (OwnerUserId);" + Environment.NewLine + + "CREATE INDEX IX_Users_1 ON dbo.Users (Reputation);", + sql); + } + + [Fact] + public void BuildCopyPasteSql_FileGrowthTargets_RenderModifyFileViaTheSharedBuilder() + { + var target = new FileGrowthTarget("StackOverflow", "SO_data", CurrentSizeMb: 90000, CurrentGrowthPercent: 10, RecommendedGrowthMb: 1024); + var action = new RemediationAction( + "FILE_AUTOGROWTH_PERCENT", "set", Array.Empty(), + FileGrowthTargets: new[] { target }); + + var sql = RecommendationsViewModel.BuildCopyPasteSql(action); + + // Byte-identical to the shared renderer the drill-down/Dashboard reader use. + Assert.Equal(FactRemediation.BuildModifyFileStatement("StackOverflow", "SO_data", 1024), sql); + } + + [Fact] + public void MapItem_ShapesTheCardFromTheRowAndFinding() + { + var frozen = FactAdvice.SerializeForStoryText( + new AdviceBlock("CPU is on fire", "check the top queries", "tune or cap them")); + var action = new RemediationAction( + "DB_CONFIG", "set", Array.Empty(), + DbConfigTargets: new[] { new DbConfigTarget("StackOverflow", DbConfigSetting.AutoShrinkOff) }); + var finding = Finding( + severity: 1.6, storyText: frozen, database: "StackOverflow", incidentId: "inc-1", + remediation: action, + windowStart: new DateTime(2026, 7, 1, 10, 0, 0, DateTimeKind.Utc), + windowEnd: new DateTime(2026, 7, 1, 12, 0, 0, DateTimeKind.Utc)); + + var item = RecommendationsViewModel.MapItem(Row(finding), "SQL2022"); + + Assert.Equal(RecommendationSeverity.Critical, item.Severity); + Assert.Equal(1.6, item.RawSeverity); + Assert.Equal("StackOverflow", item.Database); + Assert.Equal("CPU is on fire", item.Title); // frozen advice headline + Assert.Equal("tune or cap them check the top queries", item.AdviceText); + Assert.Equal("ALTER DATABASE [StackOverflow] SET AUTO_SHRINK OFF;", item.CopyPasteSql); + Assert.Equal("inc-1", item.IncidentId); + Assert.Equal("SQL2022", item.ServerName); + Assert.Equal(1, item.ServerId); // carried from the finding (drives the deep-link tab) + Assert.Equal(DateTimeKind.Utc, item.WindowStartUtc!.Value.Kind); + Assert.Equal(new DateTime(2026, 7, 1, 10, 0, 0), item.WindowStartUtc!.Value); + } + + [Fact] + public void MapItem_ServerScopedFinding_HasNullDatabase() + { + var item = RecommendationsViewModel.MapItem(Row(Finding(database: null)), "SQL2022"); + Assert.Null(item.Database); + } + + [Fact] + public void Card_AffordanceModel_CopyFixOnlyWhenSqlPresent_AskAiAlways() + { + var withSql = new RecommendationCardViewModel(new RecommendationItem + { + Severity = RecommendationSeverity.Warning, + Title = "t", + CopyPasteSql = "ALTER DATABASE [x] SET AUTO_SHRINK OFF;", + }); + Assert.True(withSql.ShowCopyFix); + Assert.True(withSql.ShowAskAi); + + var noSql = new RecommendationCardViewModel(new RecommendationItem + { + Severity = RecommendationSeverity.Warning, + Title = "t", + CopyPasteSql = null, + }); + Assert.False(noSql.ShowCopyFix); + Assert.True(noSql.ShowAskAi); + } + + // ── "Open in Active Queries" deep-link affordance + window (Dashboard-parity port) ────── + + [Fact] + public void Card_ShowOpenInActiveQueries_TrueOnlyWithBothATimeWindowAndAServerId() + { + var window = (new DateTime(2026, 7, 1, 10, 0, 0, DateTimeKind.Utc), new DateTime(2026, 7, 1, 12, 0, 0, DateTimeKind.Utc)); + + // Incident: has a time window AND a server id -> shown. + var incident = Card(serverId: 7, windowStart: window.Item1, windowEnd: window.Item2); + Assert.True(incident.ShowOpenInActiveQueries); + Assert.Equal(7, incident.ServerId); + + // No time anchor (config-only finding read with no window) -> hidden. + var noWindow = Card(serverId: 7, windowStart: null, windowEnd: null); + Assert.False(noWindow.ShowOpenInActiveQueries); + + // Half a window is not enough (defensive; the reader always maps both or neither) -> hidden. + Assert.False(Card(serverId: 7, windowStart: window.Item1, windowEnd: null).ShowOpenInActiveQueries); + + // No server anchor (a real server id is never 0) -> hidden even with a window. + var noServer = Card(serverId: 0, windowStart: window.Item1, windowEnd: window.Item2); + Assert.False(noServer.ShowOpenInActiveQueries); + } + + [Fact] + public void Card_DeepLinkWindowUtc_UsesTheFindingsOwnRangeWhenItCarriesOne() + { + var start = new DateTime(2026, 7, 1, 10, 0, 0, DateTimeKind.Utc); + var end = new DateTime(2026, 7, 1, 12, 0, 0, DateTimeKind.Utc); + + var (from, to) = Card(serverId: 7, windowStart: start, windowEnd: end).DeepLinkWindowUtc(); + + Assert.Equal(start, from); + Assert.Equal(end, to); + } + + [Fact] + public void Card_DeepLinkWindowUtc_WidensADegeneratePointByTheDrillHalfWindow() + { + // start == end (a point) -> widen to +/- #1409's DrillDownHalfWindowMinutes so the read isn't empty. + var point = new DateTime(2026, 7, 1, 11, 0, 0, DateTimeKind.Utc); + + var (from, to) = Card(serverId: 7, windowStart: point, windowEnd: point).DeepLinkWindowUtc(); + + Assert.Equal(point.AddMinutes(-ViewerServerTab.DrillDownHalfWindowMinutes), from); + Assert.Equal(point.AddMinutes(ViewerServerTab.DrillDownHalfWindowMinutes), to); + } + + private static RecommendationCardViewModel Card( + int serverId, DateTime? windowStart, DateTime? windowEnd, RemediationAction? remediation = null) + => new(new RecommendationItem + { + Severity = RecommendationSeverity.Warning, + Title = "CPU is on fire", + ServerId = serverId, + WindowStartUtc = windowStart, + WindowEndUtc = windowEnd, + Remediation = remediation, + }); + + // ── Incident-vs-config-fix gate on the deep-link (fixes the #1427 "shows on every finding" bug) ── + + [Fact] + public void Card_ShowOpenInActiveQueries_HiddenForConfigFixFindings_EvenWithAWindowAndServer() + { + var start = new DateTime(2026, 7, 1, 10, 0, 0, DateTimeKind.Utc); + var end = new DateTime(2026, 7, 1, 12, 0, 0, DateTimeKind.Utc); + + // A safe DB_CONFIG standing fix (AUTO_SHRINK) is a config fix, not an incident -> deep-link hidden + // despite carrying a full window + a real server id. + var dbConfig = new RemediationAction( + "DB_CONFIG", "set", Array.Empty(), + DbConfigTargets: new[] { new DbConfigTarget("StackOverflow", DbConfigSetting.AutoShrinkOff) }); + var dbConfigCard = Card(serverId: 7, windowStart: start, windowEnd: end, remediation: dbConfig); + Assert.True(dbConfigCard.IsConfigFix); + Assert.False(dbConfigCard.IsIncident); + Assert.False(dbConfigCard.ShowOpenInActiveQueries); + + // A DB_CONFIG action can carry ONLY per-db RCSI targets (no safe targets) — still a config fix. + var rcsiOnly = new RemediationAction( + "DB_CONFIG", "set", Array.Empty(), + RcsiTargets: new[] { new RcsiTarget("StackOverflow", new RcsiInactionFigures(50, 2, 60)) }); + Assert.False(Card(serverId: 7, windowStart: start, windowEnd: end, remediation: rcsiOnly).ShowOpenInActiveQueries); + + // Percent-autogrowth (FILE_AUTOGROWTH_PERCENT) is a config fix. + var autogrowth = new RemediationAction( + "FILE_AUTOGROWTH_PERCENT", "set", Array.Empty(), + FileGrowthTargets: new[] { new FileGrowthTarget("StackOverflow", "SO_data", 90000, 10, 1024) }); + Assert.False(Card(serverId: 7, windowStart: start, windowEnd: end, remediation: autogrowth).ShowOpenInActiveQueries); + + // Server-level config (MAXDOP) is a config fix. + var serverConfig = new RemediationAction( + "SERVER_CONFIG", "set", Array.Empty(), + ServerConfigTargets: new[] { new ServerConfigTarget(ServerConfigSetting.Maxdop, 0, 8) }); + Assert.False(Card(serverId: 7, windowStart: start, windowEnd: end, remediation: serverConfig).ShowOpenInActiveQueries); + } + + [Fact] + public void Card_ShowOpenInActiveQueries_ShownForIncidentFindings_WithAWindowAndServer() + { + var start = new DateTime(2026, 7, 1, 10, 0, 0, DateTimeKind.Utc); + var end = new DateTime(2026, 7, 1, 12, 0, 0, DateTimeKind.Utc); + + // A pure incident (CPU/blocking/waits) reads back with no remediation action -> shown. + var bare = Card(serverId: 7, windowStart: start, windowEnd: end, remediation: null); + Assert.True(bare.IsIncident); + Assert.True(bare.ShowOpenInActiveQueries); + + // Plan-regression (force-plan) is Apply-able but stays an INCIDENT (no structured config targets) -> shown. + var forcePlan = new RemediationAction( + "PLAN_REGRESSION", "force", new[] { new ForcePlanTarget("StackOverflow", QueryId: 42, PlanId: 7) }); + Assert.True(Card(serverId: 7, windowStart: start, windowEnd: end, remediation: forcePlan).ShowOpenInActiveQueries); + + // MISSING_INDEX is copy-paste-only but deliberately stays an incident (Dashboard parity) -> shown. + var missingIndex = new RemediationAction( + "MISSING_INDEX", "advise", Array.Empty(), + MissingIndexTargets: new[] { new MissingIndexTarget("dbo.Posts", 92.5, "CREATE INDEX IX ON dbo.Posts (OwnerUserId);") }); + Assert.True(Card(serverId: 7, windowStart: start, windowEnd: end, remediation: missingIndex).ShowOpenInActiveQueries); + } + + [Theory] + [InlineData(RecommendationSeverity.Critical, "CRITICAL", "")] + [InlineData(RecommendationSeverity.Warning, "WARNING", "")] + [InlineData(RecommendationSeverity.Info, "INFO", "")] + public void Card_SeverityLabelAndGlyph_MatchLite(RecommendationSeverity severity, string label, string glyph) + { + var card = new RecommendationCardViewModel(new RecommendationItem { Severity = severity, Title = "t" }); + Assert.Equal(label, card.SeverityLabel); + Assert.Equal(glyph, card.SeverityGlyph); + } + + [Fact] + public void Card_DatabaseAndAdviceVisibilityFlags() + { + var withDb = new RecommendationCardViewModel(new RecommendationItem + { + Severity = RecommendationSeverity.Info, Title = "t", Database = "StackOverflow", AdviceText = "advice", + }); + Assert.True(withDb.HasDatabase); + Assert.Equal("[StackOverflow]", withDb.DatabaseBracketed); + Assert.True(withDb.HasAdvice); + + var bare = new RecommendationCardViewModel(new RecommendationItem + { + Severity = RecommendationSeverity.Info, Title = "t", Database = null, AdviceText = null, + }); + Assert.False(bare.HasDatabase); + Assert.Equal("", bare.DatabaseBracketed); + Assert.False(bare.HasAdvice); + } + + [Fact] + public void Card_AskAiPrompt_RendersTheWindowWithTheGivenOffset() + { + var item = new RecommendationItem + { + Severity = RecommendationSeverity.Warning, + Title = "CPU is on fire", + ServerName = "SQL2022", + WindowStartUtc = new DateTime(2026, 7, 1, 14, 0, 0, DateTimeKind.Utc), + WindowEndUtc = new DateTime(2026, 7, 1, 15, 30, 0, DateTimeKind.Utc), + }; + + // Offset 0 -> the UTC window verbatim (deterministic; the tab passes the machine-local offset). + var utc = new RecommendationCardViewModel(item, utcOffsetMinutes: 0).AskAiPrompt; + Assert.Contains("14:00–15:30", utc, StringComparison.Ordinal); + + // +60 shifts the rendered window an hour forward. + var plusOne = new RecommendationCardViewModel(item, utcOffsetMinutes: 60).AskAiPrompt; + Assert.Contains("15:00–16:30", plusOne, StringComparison.Ordinal); + } +} + +/// +/// Pins the W2b incident grouping + state selection: rows sharing an incident collapse into one +/// section headed by the primary finding, solo findings each get their own section, sections keep the +/// read's severity-desc order and expand unless Info-only, and the co-fired cross-reference is +/// appended across a multi-finding window. Mirrors Lite's GroupByIncident. +/// +public sealed class ViewerRecommendationGroupingTests +{ + private const string MidDot = "·"; + + private static ViewerFindingRow Row(double severity, string title, string incidentId = "", string? database = null) + { + var finding = new AnalysisFinding + { + FindingId = 1, + AnalysisTime = new DateTime(2026, 7, 1, 12, 0, 0, DateTimeKind.Utc), + ServerId = 1, + ServerName = "SQL2022", + DatabaseName = database, + Severity = severity, + Confidence = 0.9, + Category = "cpu", + StoryPath = "a -> b", + StoryPathHash = "hash-" + title, + StoryText = "", + RootFactKey = "FACT", + IncidentId = incidentId, + FactCount = 1, + }; + return new ViewerFindingRow + { + SeverityBand = ViewerDataService.SeverityBand(severity), + RawSeverity = severity, + Title = title, + Category = "cpu", + DatabaseName = database ?? "", + Finding = finding, + }; + } + + [Fact] + public void FromFindings_NoRows_YieldsEmptyState() + { + var vm = RecommendationsViewModel.FromFindings(Array.Empty(), "SQL2022"); + Assert.Equal(RecommendationsState.Empty, vm.State); + Assert.Empty(vm.Sections); + Assert.Equal(0, vm.TotalCount); + } + + // ── Insufficient-data vs all-clear state selection (V19 marker) ───────────────────────────── + + [Fact] + public void FromFindings_ZeroFindings_NoInsufficientMarker_IsTheGenuineAllClear() + { + // data + zero findings -> Empty (the genuine all-clear); the message is empty. + var vm = RecommendationsViewModel.FromFindings( + Array.Empty(), "SQL2022", utcOffsetMinutes: 0, + insufficientData: false, insufficientDataMessage: null); + + Assert.Equal(RecommendationsState.Empty, vm.State); + Assert.Empty(vm.Sections); + Assert.Equal(string.Empty, vm.InsufficientDataMessage); + } + + [Fact] + public void FromFindings_ZeroFindings_InsufficientMarker_ShowsStillCollecting_NotAllClear() + { + // insufficient + zero findings -> InsufficientData ("still collecting"), NOT a false all-clear. + var vm = RecommendationsViewModel.FromFindings( + Array.Empty(), "SQL2022", utcOffsetMinutes: 0, + insufficientData: true, + insufficientDataMessage: "Not enough data for reliable analysis. Need 1.0 days, have 3.0 hours."); + + Assert.Equal(RecommendationsState.InsufficientData, vm.State); + Assert.Empty(vm.Sections); + Assert.Equal("Not enough data for reliable analysis. Need 1.0 days, have 3.0 hours.", vm.InsufficientDataMessage); + } + + [Fact] + public void FromFindings_InsufficientMarker_ButFindingsPresent_FindingsWin_Loaded() + { + // A server with findings shows them regardless of the marker (the marker only decides zero-finding). + var rows = new List { Row(1.6, "CPU is on fire", incidentId: "a") }; + + var vm = RecommendationsViewModel.FromFindings( + rows, "SQL2022", utcOffsetMinutes: 0, insufficientData: true, insufficientDataMessage: "collecting"); + + Assert.Equal(RecommendationsState.Loaded, vm.State); + Assert.Single(vm.Sections); + Assert.Equal(string.Empty, vm.InsufficientDataMessage); + } + + [Fact] + public void InsufficientData_UsesEngineMessageWhenPresent_ElseTheDefault() + { + Assert.Equal( + RecommendationsViewModel.DefaultInsufficientDataMessage, + RecommendationsViewModel.InsufficientData(null).InsufficientDataMessage); + Assert.Equal( + RecommendationsViewModel.DefaultInsufficientDataMessage, + RecommendationsViewModel.InsufficientData(" ").InsufficientDataMessage); + Assert.Equal( + "engine says 3.0 hours", + RecommendationsViewModel.InsufficientData("engine says 3.0 hours").InsufficientDataMessage); + Assert.Equal(RecommendationsState.InsufficientData, RecommendationsViewModel.InsufficientData(null).State); + } + + [Fact] + public void FromFindings_GroupsByIncident_HeaderNamesPrimaryPlusCount() + { + // Input arrives severity-desc (as the read returns it): incident A's primary first. + var rows = new List + { + Row(1.8, "Blocking chain", incidentId: "inc-A"), + Row(1.2, "RCSI is off", incidentId: "inc-A"), + Row(1.6, "CPU is on fire", incidentId: "inc-B"), + }; + + var vm = RecommendationsViewModel.FromFindings(rows, "SQL2022"); + + Assert.Equal(RecommendationsState.Loaded, vm.State); + Assert.Equal(2, vm.Sections.Count); + Assert.Equal(3, vm.TotalCount); + + // inc-A: two cards, header names the primary + count + severity. + var a = vm.Sections[0]; + Assert.Equal(2, a.Count); + Assert.Equal($"Blocking chain {MidDot} 2 findings {MidDot} CRITICAL", a.Header); + Assert.Equal("Blocking chain", a.Cards[0].Title); + Assert.Equal("RCSI is off", a.Cards[1].Title); + + // inc-B: single card, header has no count. + var b = vm.Sections[1]; + Assert.Equal(1, b.Count); + Assert.Equal($"CPU is on fire {MidDot} CRITICAL", b.Header); + } + + [Fact] + public void FromFindings_SoloFindings_EachBecomeTheirOwnSection() + { + var rows = new List + { + Row(1.6, "Finding one"), // no incident id + Row(1.4, "Finding two"), // no incident id + }; + + var vm = RecommendationsViewModel.FromFindings(rows, "SQL2022"); + + Assert.Equal(2, vm.Sections.Count); + Assert.All(vm.Sections, s => Assert.Equal(1, s.Count)); + Assert.Equal($"Finding one {MidDot} CRITICAL", vm.Sections[0].Header); + Assert.Equal($"Finding two {MidDot} WARNING", vm.Sections[1].Header); + } + + [Fact] + public void FromFindings_ExpandsCriticalAndWarning_CollapsesInfo() + { + var rows = new List + { + Row(1.6, "Critical thing", incidentId: "c"), + Row(1.0, "Warning thing", incidentId: "w"), + Row(0.3, "Info thing", incidentId: "i"), + }; + + var vm = RecommendationsViewModel.FromFindings(rows, "SQL2022"); + + Assert.True(vm.Sections[0].IsExpanded); // Critical + Assert.True(vm.Sections[1].IsExpanded); // Warning + Assert.False(vm.Sections[2].IsExpanded); // Info-only -> collapsed + } + + [Fact] + public void FromFindings_AppendsCoFiredCrossReference_AcrossAMultiFindingWindow() + { + var rows = new List + { + Row(1.6, "CPU is on fire", incidentId: "a"), + Row(1.2, "Blocking chain", incidentId: "b"), + }; + + var vm = RecommendationsViewModel.FromFindings(rows, "SQL2022"); + + // Each card's advice carries the "what else fired" line naming the OTHER finding. + var cpu = vm.Sections[0].Cards[0]; + Assert.Contains("Also surfaced in this analysis window:", cpu.AdviceText, StringComparison.Ordinal); + Assert.Contains("Blocking chain", cpu.AdviceText!, StringComparison.Ordinal); + } +} + +/// +/// Gated (DARLING_TEST_PG) live round-trip proving the W2b Copy-fix path end to end: a persisted +/// finding whose remediation_action_json carries a missing-index action deserializes through the +/// viewer read and yields a card whose Copy-fix payload is the suggested CREATE. Shares the serialized +/// "live-postgres" collection so the row churn can't race another class. +/// +[Collection("live-postgres")] +public sealed class ViewerRecommendationsLivePostgresTests +{ + private const int ServerId = -929292; + private const string ServerName = "viewer-w2b-reco-e2e"; + private const string Hash = "v2b-reco-e2e-hash"; + + [Fact] + public async Task LatestFindings_WithPersistedMissingIndexAction_YieldACopyFixCard() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live Recommendations card test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteFindingRowsAsync(connection); + + await using var postgres = NpgsqlDataSource.Create(connectionString!); + var store = new PgFindingStore(postgres); + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var windowEnd = TruncateToSeconds(DateTime.UtcNow); + var context = new AnalysisContext + { + ServerId = ServerId, + ServerName = ServerName, + TimeRangeStart = windowEnd.AddHours(-4), + TimeRangeEnd = windowEnd, + ServerUtcOffset = TimeSpan.Zero, + }; + var stories = new List + { + new AnalysisStory + { + Severity = 1.2, + Confidence = 0.8, + Category = "indexes", + StoryPath = "MISSING_INDEX", + StoryPathHash = Hash, + StoryText = "seed finding for the viewer Copy-fix round-trip", + RootFactKey = "MISSING_INDEX", + FactCount = 1, + DatabaseName = "StackOverflow", + }, + }; + + /* Two-phase write (the pipeline's shape): attach the BUILT action between the phases so it + persists as remediation_action_json, exactly as the Darling analysis service does. */ + var survivors = await store.FilterMutedFindingsAsync(stories, context); + var survivor = Assert.Single(survivors); + survivor.Remediation = new RemediationAction( + "MISSING_INDEX", "advise", Array.Empty(), + MissingIndexTargets: new[] + { + new MissingIndexTarget("dbo.Posts", 92.5, "CREATE INDEX IX_Posts_1 ON dbo.Posts (OwnerUserId);"), + }); + await store.InsertFindingsAsync(survivors, context); + + /* The viewer read hydrates the action; the pure shaping turns it into the card's Copy-fix. */ + var rows = await viewer.GetLatestFindingsAsync(ServerId); + var vm = RecommendationsViewModel.FromFindings(rows, ServerName); + + var card = Assert.Single(vm.Sections).Cards.Single(); + Assert.True(card.ShowCopyFix); + Assert.Equal("CREATE INDEX IX_Posts_1 ON dbo.Posts (OwnerUserId);", card.CopyPasteSql); + Assert.True(card.ShowAskAi); + } + finally + { + await DeleteFindingRowsAsync(connection); + } + } + + private static DateTime TruncateToSeconds(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified); + + private static async Task DeleteFindingRowsAsync(NpgsqlConnection connection) + { + using var cleanup = new NpgsqlCommand( + $"DELETE FROM analysis_findings WHERE server_id = {ServerId}; DELETE FROM analysis_muted WHERE server_id = {ServerId};", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } +} diff --git a/Darling/Darling.Tests/ViewerServerChromeTests.cs b/Darling/Darling.Tests/ViewerServerChromeTests.cs new file mode 100644 index 000000000..8f1acbb4f --- /dev/null +++ b/Darling/Darling.Tests/ViewerServerChromeTests.cs @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the ported MainWindow sidebar chrome: the server-row status-dot derivation (from collection +/// freshness, the same signal the Overview cards use), the favorite flag, the +/// display helpers the Manage Servers grid binds, and the load-bearing clauses of the new status reads. +/// +public sealed class ViewerServerChromeTests +{ + private static DarlingServer Server() => new(1, "SQL2022", "Prod", true, 16); + + [Fact] + public void DotStatus_IsUnknown_UntilFreshnessIsApplied() + { + Assert.Equal("Unknown", Server().DotStatus); + } + + [Fact] + public void ApplyFreshness_FreshCollection_IsOnline() + { + var now = DateTime.UtcNow; + var server = Server(); + + server.ApplyFreshness(now.AddSeconds(-30), now); + + Assert.True(server.IsOnline); + Assert.False(server.HasCollectorErrors); + Assert.Equal("Online", server.DotStatus); + } + + [Fact] + public void ApplyFreshness_StaleCollection_IsWarning() + { + var now = DateTime.UtcNow; + var server = Server(); + + /* Older than 2x the 1-minute collector cadence but not yet offline (< 15 min). */ + server.ApplyFreshness(now.AddMinutes(-5), now); + + Assert.True(server.IsOnline); + Assert.True(server.HasCollectorErrors); + Assert.Equal("Warning", server.DotStatus); + } + + [Fact] + public void ApplyFreshness_OldCollection_IsOffline() + { + var now = DateTime.UtcNow; + var server = Server(); + + server.ApplyFreshness(now.AddMinutes(-30), now); + + Assert.False(server.IsOnline); + Assert.Equal("Offline", server.DotStatus); + } + + [Fact] + public void ApplyFreshness_NeverCollected_IsUnknownDot_NotOffline() + { + /* Never-collected = the service hasn't reached the server yet (bootstrap). The sidebar dot + goes grey Unknown, never the red Offline — a queued server is not a dead one (24-server + field incident, 2026-07-17). */ + var server = Server(); + + server.ApplyFreshness(null, DateTime.UtcNow); + + Assert.Null(server.IsOnline); + Assert.Equal("Unknown", server.DotStatus); + } + + [Fact] + public void DotStatus_RaisesChangeNotification_ForLiveDotUpdates() + { + var server = Server(); + var changed = 0; + server.PropertyChanged += (_, e) => + { + if (e.PropertyName is nameof(DarlingServer.DotStatus)) changed++; + }; + + server.ApplyFreshness(DateTime.UtcNow, DateTime.UtcNow); + + Assert.True(changed > 0); + } + + [Fact] + public void IsFavorite_RaisesChangeNotification_ForLiveStarUpdates() + { + var server = Server(); + var raised = false; + server.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(DarlingServer.IsFavorite)) raised = true; + }; + + server.IsFavorite = true; + + Assert.True(raised); + } + + [Theory] + [InlineData(false, "Prod", "SQL2022")] + [InlineData(true, "Prod (Read-Only)", "SQL2022 (Read-Only)")] + public void ViewerServerEntry_DisplayHelpers_ReflectReadOnlyIntent(bool readOnly, string expectedDisplay, string expectedServer) + { + var entry = new ViewerServerEntry + { + ServerName = "SQL2022", + DisplayName = "Prod", + ReadOnlyIntent = readOnly + }; + + Assert.Equal(expectedDisplay, entry.DisplayNameWithIntent); + Assert.Equal(expectedServer, entry.ServerNameDisplay); + } + + [Fact] + public void ViewerServerEntry_AuthenticationDisplay_MapsEachMode() + { + Assert.Equal("Windows", new ViewerServerEntry { AuthenticationType = AuthenticationTypes.Windows }.AuthenticationDisplay); + Assert.Equal("SQL Server", new ViewerServerEntry { AuthenticationType = AuthenticationTypes.SqlServer }.AuthenticationDisplay); + Assert.Equal("Microsoft Entra MFA", new ViewerServerEntry { AuthenticationType = AuthenticationTypes.EntraMFA }.AuthenticationDisplay); + Assert.Equal("Azure — Service Principal", new ViewerServerEntry { AuthenticationType = AuthenticationTypes.ServicePrincipal }.AuthenticationDisplay); + Assert.Equal("Azure — Managed Identity", new ViewerServerEntry { AuthenticationType = AuthenticationTypes.ManagedIdentity }.AuthenticationDisplay); + } + + [Fact] + public void ServerFreshnessSql_ReadsNewestCollectionPerServer() + { + Assert.Contains("MAX(collection_time)", ViewerDataService.ServerFreshnessSql, StringComparison.Ordinal); + Assert.Contains("FROM v_collection_log", ViewerDataService.ServerFreshnessSql, StringComparison.Ordinal); + Assert.Contains("GROUP BY server_id", ViewerDataService.ServerFreshnessSql, StringComparison.Ordinal); + /* Excludes the fleet retention run-record sentinel (server_id 0) so it never appears as a phantom + server in the freshness dictionary. */ + Assert.Contains("server_id <> 0", ViewerDataService.ServerFreshnessSql, StringComparison.Ordinal); + } + + [Fact] + public void StoreSizeSql_UsesPgDatabaseSize() + { + Assert.Contains("pg_database_size", ViewerDataService.StoreSizeSql, StringComparison.Ordinal); + } +} diff --git a/Darling/Darling.Tests/ViewerServerManagementTests.cs b/Darling/Darling.Tests/ViewerServerManagementTests.cs new file mode 100644 index 000000000..e6af794e5 --- /dev/null +++ b/Darling/Darling.Tests/ViewerServerManagementTests.cs @@ -0,0 +1,646 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// The control-plane Stage-3a viewer writes to config.config_monitored_servers — the write partial's +/// SQL shape (parameterization, ON CONFLICT semantics, the V17 column parity with the service's +/// ) and the shared server_id identity, pinned with no live Postgres +/// exactly like the mute-rule + command pins. +/// +public sealed class ViewerMonitoredServerSqlTests +{ + private static readonly string[] StoreColumns = + { + "server_id", "name", "host", "database", "auth", "username", "encrypted_password", "encrypt_mode", + "trust_server_certificate", "read_only_intent", "multi_subnet_failover", "excluded_databases", + "monthly_cost_usd", "capture_plans", "is_enabled", "alert_delivery_mode_override", + }; + + [Fact] + public void UpsertSql_WritesEveryStoreColumn_AsBoundParameters_OnConflictUpdate() + { + var sql = ViewerDataService.MonitoredServerUpsertSql; + + Assert.Contains("INSERT INTO config_monitored_servers", sql, StringComparison.Ordinal); + foreach (var column in StoreColumns) + { + Assert.Contains(column, sql, StringComparison.Ordinal); + } + + /* Every value is a $N parameter (16 columns incl. the V18 alert_delivery_mode_override), never inlined. */ + for (var i = 1; i <= 16; i++) + { + Assert.Contains("$" + i.ToString(System.Globalization.CultureInfo.InvariantCulture), sql, StringComparison.Ordinal); + } + + /* Upsert on the shared identity; created_at is server-side, never clobbered on update. */ + Assert.Contains("ON CONFLICT (server_id) DO UPDATE", sql, StringComparison.Ordinal); + Assert.Contains("modified_at = (now() AT TIME ZONE 'UTC')", sql, StringComparison.Ordinal); + } + + [Fact] + public void InsertIfAbsentSql_DoesNothingOnConflict_SoTheMigrateNeverDoubleSeeds() + { + var sql = ViewerDataService.MonitoredServerInsertIfAbsentSql; + Assert.Contains("INSERT INTO config_monitored_servers", sql, StringComparison.Ordinal); + Assert.Contains("ON CONFLICT (server_id) DO NOTHING", sql, StringComparison.Ordinal); + } + + [Fact] + public void DeleteAndTargetedUpdates_KeyOnServerId() + { + Assert.Contains("DELETE FROM config_monitored_servers WHERE server_id = $1", ViewerDataService.MonitoredServerDeleteSql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", ViewerDataService.MonitoredServerSetEnabledSql, StringComparison.Ordinal); + Assert.Contains("is_enabled = $2", ViewerDataService.MonitoredServerSetEnabledSql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", ViewerDataService.MonitoredServerSetExcludedDatabasesSql, StringComparison.Ordinal); + Assert.Contains("excluded_databases = $2", ViewerDataService.MonitoredServerSetExcludedDatabasesSql, StringComparison.Ordinal); + } + + [Fact] + public void ManagedServersSql_IsConfigAuthoritative_EnrichedByCollectServers() + { + var sql = ViewerDataService.ManagedServersSql; + /* Membership from the DESIRED-state config table, facts LEFT JOINed from the observed registry. */ + Assert.Contains("FROM config_monitored_servers c", sql, StringComparison.Ordinal); + Assert.Contains("LEFT JOIN servers s ON s.server_id = c.server_id", sql, StringComparison.Ordinal); + Assert.Contains("c.is_enabled", sql, StringComparison.Ordinal); + Assert.Contains("c.monthly_cost_usd", sql, StringComparison.Ordinal); + } + + [Fact] + public void SelectSql_ReadsCreatedAt_ForTheManageServersGrid() + { + Assert.Contains("created_at", ViewerDataService.MonitoredServersSelectSql, StringComparison.Ordinal); + Assert.Contains("created_at", ViewerDataService.MonitoredServerByIdSql, StringComparison.Ordinal); + } + + [Theory] + [InlineData("SQL2022", null, false)] + [InlineData("myazure.database.windows.net", "AdventureWorks", false)] + [InlineData("ag-listener", null, true)] + [InlineData("host", "db", true)] + public void ComputeServerId_MatchesTheSharedHelper(string host, string? database, bool readOnlyIntent) + { + var expected = ServerIdHelper.GetDeterministicHashCode(ServerIdHelper.BuildStorageName(host, database, readOnlyIntent)); + Assert.Equal(expected, ViewerDataService.ComputeServerId(host, database, readOnlyIntent)); + } + + [Fact] + public void ComputeServerId_MatchesTheServiceMonitoredServerIdentity() + { + /* The service seeds server_id from its MonitoredServer.StorageName; the viewer must derive the SAME id + for the same host/database/read-only-intent so a viewer-written row JOINs collected data and the + service's reconcile matches it. */ + var svc = new MonitoredServer { Host = "SQL2022", Database = "tpcc", ReadOnlyIntent = true }; + Assert.Equal( + ServerIdHelper.GetDeterministicHashCode(svc.StorageName), + ViewerDataService.ComputeServerId("SQL2022", "tpcc", true)); + } +} + +/// +/// The viewer half of the command plane — the enqueue/poll SQL shape, the terminal-state helper, and the +/// test_connect args_json that the service's deserializes as a +/// (proving the two ends agree, and that the password rides as the DPAPI blob, +/// never plaintext, into Postgres). +/// +public sealed class ViewerCommandSqlTests +{ + private static readonly JsonSerializerOptions s_caseInsensitive = new() { PropertyNameCaseInsensitive = true }; + + [Fact] + public void EnqueueSql_InsertsPendingAndReturnsCommandId() + { + var sql = ViewerDataService.CommandEnqueueSql; + Assert.Contains("INSERT INTO config_command", sql, StringComparison.Ordinal); + Assert.Contains("'pending'", sql, StringComparison.Ordinal); + Assert.Contains("RETURNING command_id", sql, StringComparison.Ordinal); + /* requested_by, command_type, target_server_id, args_json bound as $1..$4. */ + Assert.Contains("$1, $2, $3, $4", sql, StringComparison.Ordinal); + } + + [Fact] + public void PollSql_ReadsStatusAndResult_ByCommandId() + { + var sql = ViewerDataService.CommandPollSql; + Assert.Contains("SELECT status, result_status, result_json FROM config_command", sql, StringComparison.Ordinal); + Assert.Contains("WHERE command_id = $1", sql, StringComparison.Ordinal); + } + + [Fact] + public void DeleteSql_RemovesTheCredentialBearingRow_ByCommandId() + { + /* The viewer cleans up its own terminal test_connect command so the DPAPI blob in args_json does not + linger in the store. */ + Assert.Equal("DELETE FROM config_command WHERE command_id = $1", ViewerDataService.CommandDeleteSql); + } + + [Theory] + [InlineData("succeeded", true)] + [InlineData("failed", true)] + [InlineData("pending", false)] + [InlineData("in_progress", false)] + [InlineData(null, false)] + public void IsTerminal_OnlyForSucceededOrFailed(string? status, bool expected) + { + Assert.Equal(expected, ViewerDataService.IsTerminal(status)); + } + + [Fact] + public void BuildTestConnectArgs_DeserializesIntoTheServiceMonitoredServer_WithNoPlaintextPassword() + { + var args = new TestConnectServer + { + Name = "Prod", + Host = "SQL2022", + Database = "tpcc", + Auth = "sql", + Username = "monitor", + EncryptedPassword = "DPAPI-BLOB==", + ReadOnlyIntent = true, + TrustServerCertificate = true, + EncryptMode = "Strict", + MultiSubnetFailover = true, + }; + + var json = ViewerDataService.BuildTestConnectArgs(args); + + /* The service uses PropertyNameCaseInsensitive; the viewer emits the MonitoredServer's [JsonPropertyName] + (camelCase) names, so the round-trip reconstructs the server the probe connects with. */ + var server = JsonSerializer.Deserialize(json, s_caseInsensitive); + Assert.NotNull(server); + Assert.Equal("SQL2022", server!.Host); + Assert.Equal("tpcc", server.Database); + Assert.Equal("sql", server.Auth); + Assert.Equal("monitor", server.Username); + Assert.Equal("DPAPI-BLOB==", server.EncryptedPassword); + Assert.True(server.ReadOnlyIntent); + Assert.True(server.TrustServerCertificate); + Assert.Equal("Strict", server.EncryptMode); + Assert.True(server.MultiSubnetFailover); + + /* The plaintext password field is NEVER emitted — the secret only ever travels as the DPAPI blob. */ + Assert.Null(server.Password); + Assert.DoesNotContain("\"password\"", json, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void BuildTestConnectArgs_IntegratedAuth_OmitsUsernameAndSecret() + { + var json = ViewerDataService.BuildTestConnectArgs(new TestConnectServer { Host = "SQL2022", Auth = "integrated" }); + var server = JsonSerializer.Deserialize(json, s_caseInsensitive); + Assert.NotNull(server); + Assert.Equal("integrated", server!.Auth); + Assert.Null(server.Username); + Assert.Null(server.EncryptedPassword); + } +} + +/// The pure auth mapping — the service honors integrated + SQL only; the three Azure/Entra modes block. +public sealed class ServerStoreCredentialTests +{ + [Theory] + [InlineData(AuthenticationTypes.Windows, "integrated")] + [InlineData(AuthenticationTypes.SqlServer, "sql")] + public void MapAuth_SupportedModes_MapToStoreAuth(string authType, string expected) + { + Assert.Equal(expected, ServerStoreCredential.MapAuth(authType)); + Assert.True(ServerStoreCredential.IsSupported(authType)); + } + + [Theory] + [InlineData(AuthenticationTypes.EntraMFA)] + [InlineData(AuthenticationTypes.ServicePrincipal)] + [InlineData(AuthenticationTypes.ManagedIdentity)] + public void MapAuth_AzureModes_AreUnsupported(string authType) + { + Assert.Null(ServerStoreCredential.MapAuth(authType)); + Assert.False(ServerStoreCredential.IsSupported(authType)); + } + + [Fact] + public void RequiresSecret_OnlyForSql() + { + Assert.True(ServerStoreCredential.RequiresSecret(AuthenticationTypes.SqlServer)); + Assert.False(ServerStoreCredential.RequiresSecret(AuthenticationTypes.Windows)); + Assert.False(ServerStoreCredential.RequiresSecret(AuthenticationTypes.ManagedIdentity)); + } +} + +/// +/// The DPAPI blob the viewer writes into encrypted_password must be readable by the SERVICE, and vice +/// versa — a cross-project round trip against proving the entropy + scope match +/// (Windows-only; DPAPI is unavailable elsewhere). +/// +public sealed class ViewerServerSecretTests +{ + [Fact] + public void ViewerProtect_IsReadableByTheService() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "DPAPI requires Windows."); + var blob = ViewerServerSecret.Protect("s3cr3t!"); + Assert.Equal("s3cr3t!", DarlingSecrets.Unprotect(blob)); + } + + [Fact] + public void ServiceProtect_IsReadableByTheViewer() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "DPAPI requires Windows."); + var blob = DarlingSecrets.Protect("another-pw"); + Assert.Equal("another-pw", ViewerServerSecret.TryUnprotect(blob)); + } + + [Fact] + public void TryUnprotect_ReturnsNullOnGarbage_NeverThrows() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "DPAPI requires Windows."); + Assert.Null(ViewerServerSecret.TryUnprotect(null)); + Assert.Null(ViewerServerSecret.TryUnprotect("")); + Assert.Null(ViewerServerSecret.TryUnprotect("not-base64!!")); + Assert.Null(ViewerServerSecret.TryUnprotect(Convert.ToBase64String(new byte[] { 1, 2, 3, 4 }))); + } +} + +/// +/// The one-time viewer-servers.json → config migrate-in: the per-entry projection (auth mapping + secret +/// resolution + the shared server_id) and the once-marker guard. Uses an in-memory secret fake + temp files, +/// never the real vault or a live store. +/// +public sealed class ViewerServerMigrationTests +{ + [Fact] + public void Projection_IntegratedEntry_ImportsWithNoSecret_OnTheSharedIdentity() + { + using var fixture = new Fixture(); + fixture.ServerStore.AddServer( + new ViewerServerEntry { ServerName = "SQL2022", DisplayName = "Prod", AuthenticationType = AuthenticationTypes.Windows }, + null, null); + + var (row, reason) = fixture.Migration.TryProjectEntry(fixture.ServerStore.GetAllServers()[0]); + + Assert.Null(reason); + Assert.NotNull(row); + Assert.Equal("integrated", row!.Auth); + Assert.Null(row.EncryptedPassword); + Assert.Equal("SQL2022", row.Host); + Assert.Equal("Prod", row.Name); + Assert.Equal(ViewerDataService.ComputeServerId("SQL2022", null, false), row.ServerId); + } + + [Fact] + public void Projection_SqlEntry_SealsTheSecretAsAServiceReadableBlob() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "DPAPI requires Windows."); + + using var fixture = new Fixture(); + var entry = new ViewerServerEntry { ServerName = "SQL2019", DisplayName = "Dev", AuthenticationType = AuthenticationTypes.SqlServer }; + fixture.ServerStore.AddServer(entry, "monitor", "p@ss"); + + var (row, reason) = fixture.Migration.TryProjectEntry(fixture.ServerStore.GetByServerName("SQL2019")!); + + Assert.Null(reason); + Assert.NotNull(row); + Assert.Equal("sql", row!.Auth); + Assert.Equal("monitor", row.Username); + Assert.NotNull(row.EncryptedPassword); + /* The service must be able to read what the migrate wrote. */ + Assert.Equal("p@ss", DarlingSecrets.Unprotect(row.EncryptedPassword!)); + } + + [Fact] + public void Projection_SqlEntry_WithNoStoredSecret_IsSkipped() + { + using var fixture = new Fixture(); + /* SQL auth but no secret saved (username/password null) — an un-connectable row; skip, don't import broken. */ + fixture.ServerStore.AddServer( + new ViewerServerEntry { ServerName = "SQL2016", DisplayName = "Old", AuthenticationType = AuthenticationTypes.SqlServer }, + null, null); + + var (row, reason) = fixture.Migration.TryProjectEntry(fixture.ServerStore.GetAllServers()[0]); + + Assert.Null(row); + Assert.Contains("no stored password", reason, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData(AuthenticationTypes.EntraMFA)] + [InlineData(AuthenticationTypes.ServicePrincipal)] + [InlineData(AuthenticationTypes.ManagedIdentity)] + public void Projection_AzureAuth_IsSkipped(string authType) + { + using var fixture = new Fixture(); + fixture.ServerStore.AddServer( + new ViewerServerEntry { ServerName = "azure", DisplayName = "Azure", AuthenticationType = authType }, + null, null); + + var (row, reason) = fixture.Migration.TryProjectEntry(fixture.ServerStore.GetAllServers()[0]); + + Assert.Null(row); + Assert.Contains("not supported", reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Projection_CarriesConnectionOptionsAndExcludedDatabases() + { + using var fixture = new Fixture(); + fixture.ServerStore.AddServer(new ViewerServerEntry + { + ServerName = "ag", + DisplayName = "AG", + AuthenticationType = AuthenticationTypes.Windows, + ReadOnlyIntent = true, + MultiSubnetFailover = true, + TrustServerCertificate = true, + EncryptMode = "Strict", + MonthlyCostUsd = 250m, + IsEnabled = false, + ExcludedDatabases = new List { "ReportServer", "ReportServerTempDB" }, + }, null, null); + + var (row, _) = fixture.Migration.TryProjectEntry(fixture.ServerStore.GetAllServers()[0]); + + Assert.NotNull(row); + Assert.True(row!.ReadOnlyIntent); + Assert.True(row.MultiSubnetFailover); + Assert.True(row.TrustServerCertificate); + Assert.Equal("Strict", row.EncryptMode); + Assert.Equal(250m, row.MonthlyCostUsd); + Assert.False(row.IsEnabled); + Assert.Equal(new[] { "ReportServer", "ReportServerTempDB" }, row.ExcludedDatabases); + /* Read-only intent participates in the identity. */ + Assert.Equal(ViewerDataService.ComputeServerId("ag", null, true), row.ServerId); + } + + [Fact] + public void Marker_GatesTheOnceGuard() + { + using var fixture = new Fixture(); + Assert.False(fixture.Migration.AlreadyMigrated); + + File.WriteAllText(fixture.MarkerPath, "done"); + Assert.True(new ViewerServerMigration(fixture.ServerStore, fixture.ProfileStore, fixture.MarkerPath).AlreadyMigrated); + } + + /// Temp files + an in-memory secret fake so the migration never touches the real vault or store. + private sealed class Fixture : IDisposable + { + private readonly string _serversPath = Path.Combine(Path.GetTempPath(), $"vs-{Guid.NewGuid():N}.json"); + private readonly string _profilesPath = Path.Combine(Path.GetTempPath(), $"vp-{Guid.NewGuid():N}.json"); + + public string MarkerPath { get; } = Path.Combine(Path.GetTempPath(), $"vm-{Guid.NewGuid():N}.marker"); + public ViewerServerStore ServerStore { get; } + public ViewerProfileStore ProfileStore { get; } + public ViewerServerMigration Migration { get; } + + public Fixture() + { + var secrets = new FakeSecretStore(); + ServerStore = new ViewerServerStore(_serversPath, secrets); + ProfileStore = new ViewerProfileStore(ServerStore, _profilesPath, secrets); + Migration = new ViewerServerMigration(ServerStore, ProfileStore, MarkerPath); + } + + public void Dispose() + { + foreach (var path in new[] { _serversPath, _profilesPath, MarkerPath }) + { + try { File.Delete(path); } catch { /* best-effort */ } + } + } + } + + private sealed class FakeSecretStore : IViewerServerSecretStore + { + private readonly Dictionary _map = new(); + public void Save(string id, string username, string password) => _map[id] = (username, password); + public (string Username, string Password)? Get(string id) => _map.TryGetValue(id, out var v) ? v : null; + public void Delete(string id) => _map.Remove(id); + } +} + +/// +/// The read-only degradation: a viewer connected read-only cannot write server config or enqueue commands. +/// The proactive gating hides the affordances; these pin that the write paths still fail safe (the reactive +/// 42501 → backstop is shared with the mute-rule path, tested there). +/// +public sealed class ViewerServerManagementReadOnlyTests +{ + [Fact] + public async System.Threading.Tasks.Task MigrateAsync_Disconnected_IsANoOp() + { + using var serverDir = new TempDir(); + var secrets = new NoopSecretStore(); + var serverStore = new ViewerServerStore(Path.Combine(serverDir.Path, "s.json"), secrets); + var profileStore = new ViewerProfileStore(serverStore, Path.Combine(serverDir.Path, "p.json"), secrets); + var migration = new ViewerServerMigration(serverStore, profileStore, Path.Combine(serverDir.Path, "m.marker")); + + /* No store → nothing imported, and the once-marker is NOT written (so a later connected run still runs). */ + var imported = await migration.MigrateAsync(dataService: null); + Assert.Equal(0, imported); + Assert.False(migration.AlreadyMigrated); + } + + private sealed class TempDir : IDisposable + { + public string Path { get; } = Directory.CreateTempSubdirectory("darling-cp-").FullName; + public void Dispose() { try { Directory.Delete(Path, recursive: true); } catch { /* best-effort */ } } + } + + private sealed class NoopSecretStore : IViewerServerSecretStore + { + public void Save(string id, string username, string password) { } + public (string Username, string Password)? Get(string id) => null; + public void Delete(string id) { } + } +} + +/// +/// The Darling "Add Multiple Servers" pure mapping + gate helpers (WPF-free statics on +/// ): the row → / → +/// projection, the service-honors-only-Windows/SQL belt (mirroring the single dialog's MapAuth block), the +/// blank-db → NULL trap, the single-Protect blob reused per row, and the ratified OrdinalIgnoreCase dedupe gate +/// over the shared storage name (mirrors Lite's B7 pins). +/// +public sealed class BulkServerOnboardingMappingTests +{ + private static BulkServerParseLine Line(string server, string? display = null, string? database = null) => + new(1, server, server, display, database); + + [Fact] + public void BuildMonitoredServerRow_WindowsAuth_MapsToIntegrated_NoSecret() + { + var (row, error) = AddMultipleServersDialog.BuildMonitoredServerRow( + Line("SQL2022"), + new BulkSharedSettings { AuthType = AuthenticationTypes.Windows }); + + Assert.Null(error); + Assert.NotNull(row); + Assert.Equal("integrated", row!.Auth); + Assert.Null(row.EncryptedPassword); + Assert.True(row.IsEnabled); + Assert.False(row.ReadOnlyIntent); + } + + [Fact] + public void BuildMonitoredServerRow_SqlAuth_MapsToSql_CarriesBlobAndUsername() + { + var (row, _) = AddMultipleServersDialog.BuildMonitoredServerRow( + Line("SQL2022"), + new BulkSharedSettings { AuthType = AuthenticationTypes.SqlServer, Username = "monitor", EncryptedPassword = "DPAPI-BLOB==" }); + + Assert.Equal("sql", row!.Auth); + Assert.Equal("monitor", row.Username); + Assert.Equal("DPAPI-BLOB==", row.EncryptedPassword); + } + + [Theory] + [InlineData(AuthenticationTypes.EntraMFA)] + [InlineData(AuthenticationTypes.ServicePrincipal)] + [InlineData(AuthenticationTypes.ManagedIdentity)] + public void BuildMonitoredServerRow_AzureAuth_IsRejected_TheBelt(string authType) + { + // The trimmed radios never offer these, but a picked profile could resolve to one — the mapping helper + // still rejects (mirrors the single dialog's MapAuth-null block). + var (row, error) = AddMultipleServersDialog.BuildMonitoredServerRow( + Line("azure.database.windows.net"), + new BulkSharedSettings { AuthType = authType }); + + Assert.Null(row); + Assert.False(string.IsNullOrEmpty(error)); + } + + [Fact] + public void BuildMonitoredServerRow_ServerId_MatchesComputeServerId() + { + var (row, _) = AddMultipleServersDialog.BuildMonitoredServerRow( + Line("myazure.database.windows.net", null, "AdventureWorks"), + new BulkSharedSettings { AuthType = AuthenticationTypes.Windows }); + + Assert.Equal(ViewerDataService.ComputeServerId("myazure.database.windows.net", "AdventureWorks", false), row!.ServerId); + } + + [Fact] + public void BuildMonitoredServerRow_BlankDatabase_IsNull_NeverMaster() + { + // The builder trap: a blank/whitespace database must map to NULL, not "master". Coercing would derive a + // MISMATCHED server_id (master's), breaking dedupe against single-added rows and splitting collected data. + var (row, _) = AddMultipleServersDialog.BuildMonitoredServerRow( + Line("SQL2022", null, " "), + new BulkSharedSettings { AuthType = AuthenticationTypes.Windows }); + + Assert.Null(row!.Database); + Assert.Equal(ViewerDataService.ComputeServerId("SQL2022", null, false), row.ServerId); + } + + [Fact] + public void BuildMonitoredServerRow_CarriesEncryptTrustDatabase_AndDisplayFallback() + { + var (row, _) = AddMultipleServersDialog.BuildMonitoredServerRow( + Line("SQL2022", null, " tpcc "), + new BulkSharedSettings { AuthType = AuthenticationTypes.Windows, EncryptMode = "Strict", TrustServerCertificate = true }); + + Assert.Equal("Strict", row!.EncryptMode); + Assert.True(row.TrustServerCertificate); + Assert.Equal("tpcc", row.Database); + Assert.Equal("SQL2022", row.Name); // display name falls back to host + } + + [Fact] + public void SharedBlob_IsReusedAcrossRows_ProtectNotCalledPerRow() + { + // The shared settings carry ONE pre-Protected blob (Protect is called once at resolve time); the mapping + // helper reuses it verbatim on every row rather than re-encrypting per server. + var shared = new BulkSharedSettings { AuthType = AuthenticationTypes.SqlServer, Username = "u", EncryptedPassword = "ONE-BLOB==" }; + var (a, _) = AddMultipleServersDialog.BuildMonitoredServerRow(Line("SQL2022"), shared); + var (b, _) = AddMultipleServersDialog.BuildMonitoredServerRow(Line("SQL2019"), shared); + + Assert.Equal("ONE-BLOB==", a!.EncryptedPassword); + Assert.Same(a.EncryptedPassword, b!.EncryptedPassword); + } + + [Fact] + public void BuildTestConnectServer_ProjectsTheSameFields() + { + var (test, error) = AddMultipleServersDialog.BuildTestConnectServer( + Line("SQL2022", "Prod", "tpcc"), + new BulkSharedSettings { AuthType = AuthenticationTypes.SqlServer, Username = "monitor", EncryptedPassword = "BLOB==", EncryptMode = "Strict", TrustServerCertificate = true }); + + Assert.Null(error); + Assert.NotNull(test); + Assert.Equal("SQL2022", test!.Host); + Assert.Equal("Prod", test.Name); + Assert.Equal("tpcc", test.Database); + Assert.Equal("sql", test.Auth); + Assert.Equal("monitor", test.Username); + Assert.Equal("BLOB==", test.EncryptedPassword); + Assert.Equal("Strict", test.EncryptMode); + Assert.True(test.TrustServerCertificate); + } + + [Fact] + public void BuildTestConnectServer_AzureAuth_IsRejected() + { + var (test, error) = AddMultipleServersDialog.BuildTestConnectServer( + Line("azure"), + new BulkSharedSettings { AuthType = AuthenticationTypes.ServicePrincipal }); + + Assert.Null(test); + Assert.False(string.IsNullOrEmpty(error)); + } + + [Fact] + public void DedupeGate_CaseVariant_IsSkipped_EvenThoughStoredHashesDiffer() + { + // Hostnames are case-insensitive in reality, so an existing SQL01 must block a bulk 'sql01' even though + // the stored server_id differs by case. Pins against a "simplify back to the raw hash" regression. + Assert.NotEqual( + ViewerDataService.ComputeServerId("SQL01", null, false), + ViewerDataService.ComputeServerId("sql01", null, false)); + + var seen = AddMultipleServersDialog.SeedGate(new[] { Row("SQL01", null, false) }); + Assert.False(seen.Add(AddMultipleServersDialog.GateKey(Row("sql01", null, false)))); + } + + [Fact] + public void DedupeGate_ReadOnlyIntent_SeedDoesNotBlockReadWrite_ButReadWriteDoes() + { + // An existing read-only SQL01:RO does NOT block a bulk read-write SQL01 (bulk rows are read-write)… + var seenRo = AddMultipleServersDialog.SeedGate(new[] { Row("SQL01", null, true) }); + Assert.True(seenRo.Add(AddMultipleServersDialog.GateKey(Row("SQL01", null, false)))); + + // …but an existing read-write SQL01 DOES. + var seenRw = AddMultipleServersDialog.SeedGate(new[] { Row("SQL01", null, false) }); + Assert.False(seenRw.Add(AddMultipleServersDialog.GateKey(Row("SQL01", null, false)))); + } + + [Fact] + public void DedupeGate_DifferentDatabases_StayDistinct() + { + var seen = AddMultipleServersDialog.SeedGate(new[] { Row("host", "db1", false) }); + Assert.True(seen.Add(AddMultipleServersDialog.GateKey(Row("host", "db2", false)))); + } + + private static MonitoredServerRow Row(string host, string? database, bool readOnlyIntent) => new() + { + ServerId = ViewerDataService.ComputeServerId(host, database, readOnlyIntent), + Host = host, + Database = database, + ReadOnlyIntent = readOnlyIntent, + }; +} diff --git a/Darling/Darling.Tests/ViewerServerSilenceTests.cs b/Darling/Darling.Tests/ViewerServerSilenceTests.cs new file mode 100644 index 000000000..591febe60 --- /dev/null +++ b/Darling/Darling.Tests/ViewerServerSilenceTests.cs @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using PerformanceMonitor.Darling.Viewer; +using PerformanceMonitor.Notifications; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the one-click "Silence This Server" / "Unsilence" logic (the sidebar shortcut over the multi-step +/// Manage Mute Rules dialog): the whole-server silence rule it writes and the predicate "Unsilence" uses to find +/// the rule(s) to remove. Pure domain logic (no WPF, no live Postgres) — the same discipline as the SQL pins. +/// The rule is keyed on the server's DISPLAY name because that's what the alert engine's mute context and the +/// alert rows carry (so the rule actually suppresses the alerts, matching the existing "Mute This Server"). +/// +public sealed class ViewerServerSilenceTests +{ + [Fact] + public void BuildServerSilenceRule_ScopesToServer_WithNoNarrowingPatterns_AndNoExpiry() + { + var rule = ViewerDataService.BuildServerSilenceRule("Prod SQL 1"); + + Assert.Equal("Prod SQL 1", rule.ServerName); + Assert.True(rule.Enabled); + Assert.Null(rule.ExpiresAtUtc); /* indefinite — stays until Unsilence, mirroring Lite's per-server silence */ + Assert.Equal(ViewerDataService.ServerSilenceReason, rule.Reason); + + /* Every other pattern is null so the rule matches EVERY alert for the server. */ + Assert.Null(rule.MetricName); + Assert.Null(rule.DatabasePattern); + Assert.Null(rule.QueryTextPattern); + Assert.Null(rule.WaitTypePattern); + Assert.Null(rule.JobNamePattern); + } + + [Fact] + public void BuildServerSilenceRule_MatchesEveryAlertForThatServer_ButNotOtherServers() + { + var rule = ViewerDataService.BuildServerSilenceRule("Prod SQL 1"); + + Assert.True(rule.Matches(new AlertMuteContext { ServerName = "Prod SQL 1", MetricName = "High CPU" })); + Assert.True(rule.Matches(new AlertMuteContext { ServerName = "Prod SQL 1", MetricName = "Deadlocks Detected" })); + Assert.False(rule.Matches(new AlertMuteContext { ServerName = "Prod SQL 2", MetricName = "High CPU" })); + } + + [Fact] + public void IsWholeServerSilence_TrueForABlanketServerRule_CaseInsensitiveOnTheName() + { + var rule = ViewerDataService.BuildServerSilenceRule("Prod SQL 1"); + + Assert.True(ViewerDataService.IsWholeServerSilence(rule, "Prod SQL 1")); + Assert.True(ViewerDataService.IsWholeServerSilence(rule, "prod sql 1")); /* name compare is case-insensitive */ + } + + [Fact] + public void IsWholeServerSilence_FalseForADifferentServer() + { + var rule = ViewerDataService.BuildServerSilenceRule("Prod SQL 1"); + + Assert.False(ViewerDataService.IsWholeServerSilence(rule, "Prod SQL 2")); + } + + [Fact] + public void IsWholeServerSilence_FalseForANarrowedRule_SoUnsilenceLeavesSpecificMutesAlone() + { + /* A rule the operator authored to mute only ONE metric on the server is NOT a whole-server silence, so + "Unsilence" must not remove it. */ + var narrowed = new MuteRule { ServerName = "Prod SQL 1", MetricName = "High CPU" }; + + Assert.False(ViewerDataService.IsWholeServerSilence(narrowed, "Prod SQL 1")); + } + + [Fact] + public void IsWholeServerSilence_FalseForAMetricOnlyRule_WithNoServerScope() + { + /* A metric-only (all-servers) mute has no ServerName, so it's never a per-server silence. */ + var metricOnly = new MuteRule { MetricName = "High CPU" }; + + Assert.False(ViewerDataService.IsWholeServerSilence(metricOnly, "Prod SQL 1")); + } +} diff --git a/Darling/Darling.Tests/ViewerServerStoreTests.cs b/Darling/Darling.Tests/ViewerServerStoreTests.cs new file mode 100644 index 000000000..8c2461721 --- /dev/null +++ b/Darling/Darling.Tests/ViewerServerStoreTests.cs @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the viewer's server-definition registry () — JSON round-trip, +/// Lite's favorites-first ordering, ToggleFavorite (including the adopt-on-first-favorite path), CRUD, +/// and Import. Every test uses a temp file and an in-memory secret fake, so nothing touches the real +/// per-user profile or Windows Credential Manager. +/// +public sealed class ViewerServerStoreTests : IDisposable +{ + private readonly string _path = Path.Combine(Path.GetTempPath(), $"viewer-servers-{Guid.NewGuid():N}.json"); + private readonly FakeSecretStore _secrets = new(); + + private ViewerServerStore NewStore() => new(_path, _secrets); + + public void Dispose() + { + try + { + if (File.Exists(_path)) + { + File.Delete(_path); + } + } + catch + { + /* best-effort temp cleanup */ + } + } + + [Fact] + public void AddServer_RoundTripsThroughDisk() + { + var store = NewStore(); + store.AddServer(new ViewerServerEntry + { + ServerName = "SQL2022", + DisplayName = "Prod", + AuthenticationType = AuthenticationTypes.SqlServer, + MonthlyCostUsd = 250m, + ReadOnlyIntent = true, + ExcludedDatabases = new List { "junk" } + }, "sa", "secret"); + + /* A fresh store over the same file must see the persisted definition. */ + var reloaded = NewStore().GetAllServers(); + var entry = Assert.Single(reloaded); + Assert.Equal("SQL2022", entry.ServerName); + Assert.Equal("Prod", entry.DisplayName); + Assert.Equal(AuthenticationTypes.SqlServer, entry.AuthenticationType); + Assert.Equal(250m, entry.MonthlyCostUsd); + Assert.True(entry.ReadOnlyIntent); + Assert.Equal("Prod (Read-Only)", entry.DisplayNameWithIntent); + Assert.Contains("junk", entry.ExcludedDatabases); + } + + [Fact] + public void AddServer_StoresSecret_AndGetCredentialReturnsIt() + { + var store = NewStore(); + var entry = new ViewerServerEntry { ServerName = "A", DisplayName = "A", AuthenticationType = AuthenticationTypes.SqlServer }; + store.AddServer(entry, "sa", "pw"); + + var cred = store.GetCredential(entry.Id); + Assert.True(cred.HasValue); + Assert.Equal("sa", cred!.Value.Username); + Assert.Equal("pw", cred.Value.Password); + } + + [Fact] + public void AddServer_WithoutCredentials_ClearsAnyStaleSecret() + { + var store = NewStore(); + var entry = new ViewerServerEntry { ServerName = "A", DisplayName = "A" }; + _secrets.Save(entry.Id, "old", "old"); + + store.AddServer(entry, null, null); + + Assert.Null(store.GetCredential(entry.Id)); + } + + [Fact] + public void GetAllServers_SortsFavoritesFirst_ThenByDisplayName() + { + var store = NewStore(); + store.AddServer(new ViewerServerEntry { ServerName = "z", DisplayName = "Zeta" }, null, null); + store.AddServer(new ViewerServerEntry { ServerName = "a", DisplayName = "Alpha" }, null, null); + store.AddServer(new ViewerServerEntry { ServerName = "m", DisplayName = "Mu", IsFavorite = true }, null, null); + + var order = store.GetAllServers().Select(s => s.DisplayName).ToList(); + + Assert.Equal(new[] { "Mu", "Alpha", "Zeta" }, order); + } + + [Fact] + public void ToggleFavorite_AdoptsUnknownServer_ThenFlips() + { + var store = NewStore(); + + Assert.True(store.ToggleFavorite("SQL2019")); // adopt + pin + Assert.True(store.IsFavorite("SQL2019")); + Assert.Single(store.GetAllServers()); + + Assert.False(store.ToggleFavorite("SQL2019")); // unpin the same entry (not a second one) + Assert.False(store.IsFavorite("SQL2019")); + Assert.Single(store.GetAllServers()); + } + + [Fact] + public void ToggleFavorite_IsCaseInsensitiveOnServerName() + { + var store = NewStore(); + store.AddServer(new ViewerServerEntry { ServerName = "SQL2022", DisplayName = "Prod" }, null, null); + + Assert.True(store.ToggleFavorite("sql2022")); + + Assert.True(store.IsFavorite("SQL2022")); + Assert.Single(store.GetAllServers()); // flipped the existing entry, didn't add a duplicate + } + + [Fact] + public void UpdateServer_ReplacesById_AndPersists() + { + var store = NewStore(); + var entry = new ViewerServerEntry { ServerName = "A", DisplayName = "A" }; + store.AddServer(entry, null, null); + + entry.DisplayName = "Renamed"; + entry.MonthlyCostUsd = 99m; + store.UpdateServer(entry); + + var reloaded = Assert.Single(NewStore().GetAllServers()); + Assert.Equal("Renamed", reloaded.DisplayName); + Assert.Equal(99m, reloaded.MonthlyCostUsd); + } + + [Fact] + public void DeleteServer_RemovesEntry_AndDeletesSecret() + { + var store = NewStore(); + var entry = new ViewerServerEntry { ServerName = "A", DisplayName = "A", AuthenticationType = AuthenticationTypes.SqlServer }; + store.AddServer(entry, "sa", "pw"); + + store.DeleteServer(entry.Id); + + Assert.Empty(NewStore().GetAllServers()); + Assert.Null(_secrets.Get(entry.Id)); + } + + [Fact] + public void ImportServersFromFile_UpsertsByName_SkippingDuplicates() + { + /* Source registry written by another install. */ + var source = Path.Combine(Path.GetTempPath(), $"viewer-servers-src-{Guid.NewGuid():N}.json"); + var sourceStore = new ViewerServerStore(source, new FakeSecretStore()); + sourceStore.AddServer(new ViewerServerEntry { ServerName = "shared", DisplayName = "Shared" }, null, null); + sourceStore.AddServer(new ViewerServerEntry { ServerName = "extra", DisplayName = "Extra" }, null, null); + + try + { + var store = NewStore(); + store.AddServer(new ViewerServerEntry { ServerName = "shared", DisplayName = "Existing" }, null, null); + + var (imported, skipped) = store.ImportServersFromFile(source); + + Assert.Equal(1, imported); // "extra" + Assert.Equal(1, skipped); // "shared" already present + Assert.Equal(2, store.GetAllServers().Count); + Assert.NotNull(store.GetByServerName("extra")); + } + finally + { + try { File.Delete(source); } catch { /* best-effort */ } + } + } + + /// In-memory so tests never touch Windows Credential Manager. + private sealed class FakeSecretStore : IViewerServerSecretStore + { + private readonly Dictionary _map = new(); + + public void Save(string id, string username, string password) => _map[id] = (username, password); + + public (string Username, string Password)? Get(string id) => + _map.TryGetValue(id, out var v) ? v : null; + + public void Delete(string id) => _map.Remove(id); + } +} diff --git a/Darling/Darling.Tests/ViewerServerTabCapabilityPinTests.cs b/Darling/Darling.Tests/ViewerServerTabCapabilityPinTests.cs new file mode 100644 index 000000000..140e6f7ab --- /dev/null +++ b/Darling/Darling.Tests/ViewerServerTabCapabilityPinTests.cs @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using Xunit; + +namespace Darling.Tests; + +/// +/// THE CAPABILITY-INVENTORY PIN (parity board — front-end SHELL COLLAPSE, Darling side; twin of Lite's +/// ServerTabCapabilityPinTests). The upcoming collapse hoists duplicated code-behind out of +/// PerformanceMonitor.Darling.Viewer/ViewerServerTab.* (and the history/drill windows it opens) into +/// shared Ui/Common. WPF's generated Connect() only errors on a DANGLING handler +/// reference; it does NOT catch REMOVING the XAML wire, nor a dropped code-behind +/// new DataGridFilterManager<T>() / chart-menu wiring / a grid's RowStyle (how its whole +/// context menu attaches). Those are the "went missing" failure modes. +/// +/// MECHANISM = RATCHET, not golden: each section captures a capability SET from the CURRENT source and +/// asserts the committed BASELINE is a SUBSET of it (a capability may never disappear; NEW ones are free — +/// no regen as later waves add grids/tabs). Deliberate removal is an explicit baseline edit in the same PR, +/// never a silent wholesale regenerate. Regenerate baselines ONLY on a real intended surface change via +/// PM_PIN_REGEN=1. Text-scans SOURCE (XAML + .cs) located from this file's compile-time path — NO WPF +/// / Npgsql / assembly load, like the other Darling parity pins (see ThemeCompletenessTests). +/// +public sealed class ViewerServerTabCapabilityPinTests +{ + [Fact] + public void Tabs_NoneDisappeared() + { + AssertRatchet("tabs", ScanTabs(), floor: 15); + } + + [Fact] + public void FilterManagers_NoneDisappeared() + { + var (mgrs, primitives, matches) = ScanFilterManagers(); + Assert.True(matches == primitives, + $"filter-manager scan under-matched: {primitives} `new DataGridFilterManager<` but {matches} carried a " + + "parseable grid arg. A ctor takes a non-identifier first arg — tighten the scan before trusting this pin."); + AssertRatchet("filtermgrs", mgrs, floor: 20); + } + + [Fact] + public void ChartWiring_NoneDisappeared() + { + AssertRatchet("chartwiring", ScanChartWiring(), floor: 15); + } + + [Fact] + public void ContextMenuCommands_NoneDisappeared() + { + AssertRatchet("menucmds", ScanMenuCommands(), floor: 15); + } + + [Fact] + public void GridStyleMenuMap_NoneDisappeared() + { + // Section E: which grid POINTS at which RowStyle. Section D sees the menu resource; only THIS sees the + // grid->menu binding. Drop a grid's RowStyle and its `Grid=>StyleKey` pair flips to `Grid=>(none)`. + AssertRatchet("gridstyle", ScanGridStyleMap(), floor: 20); + } + + [Fact] + public void GridEventWires_NoneDisappeared() + { + // Section F: XAML MouseDoubleClick/SelectionChanged wires (grid double-click opens plan/drill; the + // inner-TabControl SelectionChanged wires are the routing). Removing the wire compiles clean. + AssertRatchet("eventwires", ScanEventWires(), floor: 6); + } + + /* ---------------- scans ---------------- */ + + private static ISet ScanTabs() + { + return Regex.Matches(ViewerXaml(), @"]*?\bHeader=""([^""]+)""") + .Select(m => m.Groups[1].Value.Trim()) + .Where(s => s.Length > 0) + .ToHashSet(StringComparer.Ordinal); + } + + private static (ISet names, int primitives, int matches) ScanFilterManagers() + { + var names = new HashSet(StringComparer.Ordinal); + int primitives = 0, matches = 0; + foreach (var file in ViewerServerTabCsFiles().Concat(WindowCsFiles())) + { + var src = File.ReadAllText(file); + primitives += Regex.Matches(src, @"new\s+DataGridFilterManager<").Count; + foreach (Match m in Regex.Matches(src, @"new\s+DataGridFilterManager<[^>]+>\s*\(\s*(\w+)")) + { + matches++; + names.Add($"{Path.GetFileName(file)}:{m.Groups[1].Value}"); + } + } + return (names, primitives, matches); + } + + private static ISet ScanChartWiring() + { + // Chart FIELD NAME as first arg to ANY wiring primitive — Darling routes every wired chart through + // BuildChartContextMenu(chart, ...) (directly for the menu-only CollectorDurationChart, or nested in + // AddChart/WaitDrillDownMenuItem). Anchored on the chart, not the builder, so a rename does not trip it. + var src = string.Join("\n", ViewerServerTabCsFiles().Select(File.ReadAllText)); + return Regex.Matches(src, + @"(?:SetupChartContextMenu|BuildChartContextMenu|AddChartDrillDownMenuItem|AddWaitDrillDownMenuItem|WireChartContextMenus|WireChartDrillDowns)\s*\(\s*(\w+)") + .Select(m => m.Groups[1].Value) + .Where(n => n.EndsWith("Chart", StringComparison.Ordinal)) + .ToHashSet(StringComparer.Ordinal); + } + + private static ISet ScanMenuCommands() + { + // Menu-QUALIFIED (key:Header=>Click) so shared Copy/Export commands across Darling's five grid menus + // (DataGridContextMenu / QueryGridContextMenu / QueryPlanGridContextMenu / QuerySnapshotGridContextMenu / + // Blocked / Deadlock) do not dedup, and a command dropped from one specific menu is caught. + var xaml = ViewerXaml(); + var cmds = new HashSet(StringComparer.Ordinal); + foreach (Match cm in Regex.Matches(xaml, @"]*?\bx:Key=""(\w+)""(.*?)", RegexOptions.Singleline)) + { + var key = cm.Groups[1].Value; + foreach (Match mi in Regex.Matches(cm.Groups[2].Value, @"]*?>", RegexOptions.Singleline)) + { + var tag = mi.Value; + var h = Regex.Match(tag, @"\bHeader=""([^""]+)"""); + var c = Regex.Match(tag, @"\bClick=""(\w+)"""); + if (h.Success && c.Success) + { + cmds.Add($"{key}:{h.Groups[1].Value}=>{c.Groups[1].Value}"); + } + } + } + return cmds; + } + + private static ISet ScanGridStyleMap() + { + var xaml = ViewerXaml(); + var map = new HashSet(StringComparer.Ordinal); + foreach (Match dg in Regex.Matches(xaml, @"]*?>", RegexOptions.Singleline)) + { + var tag = dg.Value; + var name = Regex.Match(tag, @"\bx:Name=""(\w+)"""); + if (!name.Success) + { + continue; + } + var style = Regex.Match(tag, @"\bRowStyle=""\{(?:StaticResource|DynamicResource)\s+(\w+)\}"""); + map.Add($"{name.Groups[1].Value}=>{(style.Success ? style.Groups[1].Value : "(none)")}"); + } + return map; + } + + private static ISet ScanEventWires() + { + // BOTH wiring styles: XAML `Event="Handler"` attributes AND code-behind `element.Event += Handler` + // (Darling wires its history-grid double-clicks in code — WireHistoryDrillDowns — so the XAML scan + // alone would miss them). + var wires = new HashSet(StringComparer.Ordinal); + foreach (Match m in Regex.Matches(ViewerXaml(), @"\b(MouseDoubleClick|SelectionChanged)=""(\w+)""")) + { + wires.Add($"{m.Groups[1].Value}=>{m.Groups[2].Value}"); + } + foreach (var file in ViewerServerTabCsFiles()) + { + foreach (Match m in Regex.Matches(File.ReadAllText(file), @"\.(MouseDoubleClick|SelectionChanged)\s*\+=\s*(\w+)")) + { + wires.Add($"{m.Groups[1].Value}=>{m.Groups[2].Value}"); + } + } + return wires; + } + + /* ---------------- ratchet + source location ---------------- */ + + private static void AssertRatchet(string section, ISet current, int floor) + { + Assert.True(current.Count >= floor, + $"capability section '{section}' scanned only {current.Count} items (< floor {floor}) — the scan is " + + "likely broken (source moved / pattern drift). Fix the scan; do not lower the floor."); + + var file = Path.Combine(BaselineDir(), $"viewerservertab-{section}.baseline.txt"); + + if (Regen()) + { + Directory.CreateDirectory(BaselineDir()); + File.WriteAllLines(file, current.OrderBy(x => x, StringComparer.Ordinal)); + return; + } + + Assert.True(File.Exists(file), + $"baseline '{Path.GetFileName(file)}' is missing — run the pin once with environment variable " + + "PM_PIN_REGEN=1 to generate it, then commit it."); + + var baseline = File.ReadAllLines(file) + .Select(l => l.Trim()) + .Where(l => l.Length > 0 && !l.StartsWith("#", StringComparison.Ordinal)) + .ToHashSet(StringComparer.Ordinal); + + var missing = baseline.Where(b => !current.Contains(b)) + .OrderBy(x => x, StringComparer.Ordinal) + .ToList(); + + Assert.True(missing.Count == 0, + $"capability section '{section}': item(s) in the baseline are NO LONGER present in the source — a " + + "capability silently went missing during the collapse:\n " + + string.Join("\n ", missing) + + $"\nIf this removal is INTENTIONAL, delete those lines from Darling.Tests/CapabilityPins/" + + $"viewerservertab-{section}.baseline.txt in THIS PR (never regenerate wholesale — that masks accidental drops)."); + } + + private static bool Regen() => + string.Equals(Environment.GetEnvironmentVariable("PM_PIN_REGEN"), "1", StringComparison.Ordinal); + + private static string ViewerXaml() => + File.ReadAllText(Path.Combine(ViewerDir(), "ViewerServerTab.xaml")); + + private static IEnumerable ViewerServerTabCsFiles() => + Directory.EnumerateFiles(ViewerDir(), "*.cs", SearchOption.TopDirectoryOnly) + .Where(f => Path.GetFileName(f).StartsWith("ViewerServerTab", StringComparison.OrdinalIgnoreCase)); + + private static IEnumerable WindowCsFiles() => + Directory.EnumerateFiles(ViewerDir(), "*.xaml.cs", SearchOption.TopDirectoryOnly) + .Where(f => + { + var n = Path.GetFileName(f); + return n.EndsWith("HistoryWindow.xaml.cs", StringComparison.OrdinalIgnoreCase) || + n.Equals("WaitDrillDownWindow.xaml.cs", StringComparison.OrdinalIgnoreCase); + }); + + private static string ViewerDir([CallerFilePath] string thisFile = "") => + Path.GetFullPath(Path.Combine(Path.GetDirectoryName(thisFile)!, "..", "PerformanceMonitor.Darling.Viewer")); + + private static string BaselineDir([CallerFilePath] string thisFile = "") => + Path.Combine(Path.GetDirectoryName(thisFile)!, "CapabilityPins"); +} diff --git a/Darling/Darling.Tests/ViewerServerTabRegistryTests.cs b/Darling/Darling.Tests/ViewerServerTabRegistryTests.cs new file mode 100644 index 000000000..5a375417d --- /dev/null +++ b/Darling/Darling.Tests/ViewerServerTabRegistryTests.cs @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the per-server tab dedupe/close bookkeeping (headless-plan W0 IA inversion). The registry is +/// the WPF-free core of MainWindow's tab strip: a server gets at most one open tab, keyed by server id, +/// so double-clicking an already-open server focuses its tab instead of duplicating it — and closing a +/// tab drops it so the same server can be re-opened. The string payload here stands in for the WPF +/// TabItem MainWindow parameterizes the registry with. +/// +public sealed class ViewerServerTabRegistryTests +{ + /// Models MainWindow.OpenServerTab: TryGet first (focus existing), else create + Add. + private static bool OpenTab(OpenServerTabRegistry registry, int serverId, string tab) + { + if (registry.TryGet(serverId, out _)) + { + return false; // focused an existing tab — no new tab created + } + + registry.Add(serverId, tab); + return true; // opened a new tab + } + + [Fact] + public void ReopeningTheSameServer_FocusesTheExistingTab_NoDuplicate() + { + var registry = new OpenServerTabRegistry(); + + Assert.True(OpenTab(registry, 5, "tab-A")); // first open creates a tab + Assert.False(OpenTab(registry, 5, "tab-A2")); // second open focuses the existing one + + Assert.Equal(1, registry.Count); + Assert.True(registry.TryGet(5, out var tab)); + Assert.Equal("tab-A", tab); // the original tab is kept, not the would-be duplicate + } + + [Fact] + public void DistinctServers_GetDistinctTabs() + { + var registry = new OpenServerTabRegistry(); + + Assert.True(OpenTab(registry, 5, "tab-A")); + Assert.True(OpenTab(registry, 7, "tab-B")); + + Assert.Equal(2, registry.Count); + Assert.True(registry.TryGet(5, out var five)); + Assert.True(registry.TryGet(7, out var seven)); + Assert.Equal("tab-A", five); + Assert.Equal("tab-B", seven); + } + + [Fact] + public void TryGet_ReturnsFalse_ForAnUnopenedServer() + { + var registry = new OpenServerTabRegistry(); + + Assert.False(registry.TryGet(42, out var tab)); + Assert.Null(tab); + } + + [Fact] + public void Closing_DropsTheTab_AndAllowsReopen() + { + var registry = new OpenServerTabRegistry(); + OpenTab(registry, 5, "tab-A"); + + Assert.True(registry.Remove(5)); // close succeeds + Assert.False(registry.Remove(5)); // closing again is a no-op + Assert.Equal(0, registry.Count); + Assert.False(registry.TryGet(5, out _)); + + // The same server can be opened afresh after close. + Assert.True(OpenTab(registry, 5, "tab-A-reopened")); + Assert.True(registry.TryGet(5, out var tab)); + Assert.Equal("tab-A-reopened", tab); + } +} diff --git a/Darling/Darling.Tests/ViewerServerTabTimeRangeTests.cs b/Darling/Darling.Tests/ViewerServerTabTimeRangeTests.cs new file mode 100644 index 000000000..e704b0b46 --- /dev/null +++ b/Darling/Darling.Tests/ViewerServerTabTimeRangeTests.cs @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the per-server toolbar's time-range dropdown → hours-back mapping (the pure core that replaced +/// the viewer's old hardcoded 24-hour s_dataWindow). The combo lists 1h / 4h / 12h / 24h / 7d / +/// Custom; a preset index resolves to its hours, and any non-preset slot (Custom, or a stray value) +/// falls back to the viewer's historical 24-hour default so no surface is ever left window-less. +/// +public sealed class ViewerServerTabTimeRangeTests +{ + [Theory] + [InlineData(0, 1)] // Last 1 hour + [InlineData(1, 4)] // Last 4 hours + [InlineData(2, 12)] // Last 12 hours + [InlineData(3, 24)] // Last 24 hours + [InlineData(4, 168)] // Last 7 days + public void PresetIndex_MapsToItsHours(int index, int expectedHours) + { + Assert.Equal(expectedHours, ViewerServerTab.TimeRangeIndexToHours(index)); + } + + [Theory] + [InlineData(5)] // "Custom Range" — the absolute From/To drives the window, not hours-back + [InlineData(6)] // out of range + [InlineData(-1)] // out of range + public void NonPresetIndex_FallsBackTo24Hours(int index) + { + Assert.Equal(24, ViewerServerTab.TimeRangeIndexToHours(index)); + } +} diff --git a/Darling/Darling.Tests/ViewerSessionStatsTests.cs b/Darling/Darling.Tests/ViewerSessionStatsTests.cs new file mode 100644 index 000000000..e49a0f2f7 --- /dev/null +++ b/Darling/Darling.Tests/ViewerSessionStatsTests.cs @@ -0,0 +1,350 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using PerformanceMonitor.Ui; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the Session Stats tab's read + chart mapping against the Darling store contract (no live Postgres). +/// The tab is the Dashboard-parity port of ResourceMetricsContent's Session Stats sub-tab; its feed is the +/// server-wide v_session_summary_stats view (the that is +/// the 1:1 port of the Dashboard's session_stats table), NOT the per-application +/// v_session_stats FinOps' Application Connections read uses. These tests verify that every one of the +/// Dashboard's seven chart series + three summary fields maps to a column that actually exists in that +/// table, that the read is both-sides window-bound, and that the pure series/summary helpers shape the data +/// exactly as the Dashboard does. +/// +public sealed class ViewerSessionStatsSqlTests +{ + [Fact] + public void SessionStatsSql_ReadsSessionSummaryView_BothSidesWindow_OrderedOldestFirst() + { + var sql = ViewerDataService.SessionStatsSql; + + /* The server-wide SUMMARY view, not the per-application v_session_stats. */ + Assert.Contains("FROM v_session_summary_stats", sql, StringComparison.Ordinal); + Assert.DoesNotContain("FROM v_session_stats", sql, StringComparison.Ordinal); + + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $2", sql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $3", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY collection_time", sql, StringComparison.Ordinal); + } + + [Fact] + public void SessionStatsSql_SelectsEveryDashboardSeriesAndSummaryColumn() + { + var sql = ViewerDataService.SessionStatsSql; + + /* The seven chart series columns + the three summary columns (+ the two connection counts) — every + field the Dashboard's SessionStatsItem carries, so no series is silently dropped. */ + foreach (var column in new[] + { + "collection_time", + "total_sessions", + "running_sessions", + "sleeping_sessions", + "background_sessions", + "dormant_sessions", + "idle_sessions_over_30min", + "sessions_waiting_for_memory", + "databases_with_connections", + "top_application_name", + "top_application_connections", + "top_host_name", + "top_host_connections", + }) + { + Assert.Contains(column, sql, StringComparison.Ordinal); + } + } + + [Fact] + public void SessionStatsSql_PgDialect_PositionalParams_NoBareNow_NoNLiterals() + { + var sql = ViewerDataService.SessionStatsSql; + + Assert.DoesNotContain("now(", sql.ToLowerInvariant()); + Assert.DoesNotContain("N'", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + Assert.Contains("$1", sql, StringComparison.Ordinal); + Assert.Contains("$2", sql, StringComparison.Ordinal); + Assert.Contains("$3", sql, StringComparison.Ordinal); + } + + [Fact] + public void SessionStatsSql_ReadsColumnsThatExistInTheGeneratedSessionSummaryTable() + { + /* The verified-columns pin: every column the read selects is a real column of the collector's + generated table, so a series can never reference a column the store does not have. */ + Assert.Equal("session_summary_stats", SessionSummaryStatsCollector.Instance.TargetTable); + + var ddl = PgSchemaGenerator.CreateTable(SessionSummaryStatsCollector.Instance); + foreach (var column in new[] + { + "collection_time", "total_sessions", "running_sessions", "sleeping_sessions", + "background_sessions", "dormant_sessions", "idle_sessions_over_30min", + "sessions_waiting_for_memory", "databases_with_connections", "top_application_name", + "top_application_connections", "top_host_name", "top_host_connections", + }) + { + Assert.Contains(column, ddl, StringComparison.Ordinal); + } + } + + [Fact] + public void SessionSummaryView_IsPinnedInTheAuthoritativeViewList() + { + /* The tab reads the v_ passthrough view; it must be in the store's authoritative view set (so V14 + refreshes it and the migration test cross-checks it). */ + Assert.Contains("v_session_summary_stats", PgSchemaGenerator.AllPassthroughViews); + } +} + +/// +/// Pins the Session Stats chart's pure series mapping + summary formatting (no WpfPlot): the seven series +/// and their shared-palette colors, and the "name (count)" / N/A summary shaping — the same no-WPF +/// discipline as the rest of Darling.Tests. The render loop and the summary setter both consume these, so +/// the pins guard the exact Dashboard-parity mapping. +/// +public sealed class ViewerSessionStatsMappingTests +{ + [Fact] + public void SessionSeriesSpecs_AreTheSevenDashboardSeries_InOrder() + { + var legends = SessionStatsChartRenderer.SessionSeriesSpecs.Select(s => s.Legend).ToList(); + + Assert.Equal( + new[] { "Total", "Running", "Sleeping", "Background", "Dormant", "Idle >30m", "Waiting for Memory" }, + legends); + } + + [Fact] + public void SessionSeriesSpecs_EachSeriesResolvesToADistinctSharedPaletteColor() + { + var keys = SessionStatsChartRenderer.SessionSeriesSpecs.Select(s => s.PaletteKey).ToList(); + + Assert.Equal( + new[] { "SessionTotal", "SessionRunning", "SessionSleeping", "SessionBackground", "SessionDormant", "SessionIdle", "SessionWaiting" }, + keys); + + /* Every key resolves through the shared palette to a valid #RRGGBB (so a typo'd key that fell back + to a cycling color would still be a hex, but the exact-key list above pins the intended mapping). */ + var colors = keys.Select(ChartPalette.SeriesColor).ToList(); + Assert.All(colors, c => Assert.Matches("^#[0-9A-Fa-f]{6}$", c)); + + /* The seven status series must be visually distinguishable — distinct colors. */ + Assert.Equal(keys.Count, colors.Distinct(StringComparer.OrdinalIgnoreCase).Count()); + } + + [Fact] + public void SessionSeriesSpecs_Selectors_ReadTheMatchingStatusColumn() + { + /* Distinct value per column so a mis-wired selector (e.g. Running reading Sleeping) fails. */ + var point = new SessionStatsPoint( + CollectionTime: DateTime.UtcNow, + TotalSessions: 100, + RunningSessions: 11, + SleepingSessions: 22, + BackgroundSessions: 33, + DormantSessions: 44, + IdleSessionsOver30Min: 55, + SessionsWaitingForMemory: 66, + DatabasesWithConnections: 7, + TopApplicationName: "AppA", + TopApplicationConnections: 40, + TopHostName: "Host1", + TopHostConnections: 30); + + var byLegend = SessionStatsChartRenderer.SessionSeriesSpecs.ToDictionary(s => s.Legend, s => s.Value(point)); + + Assert.Equal(100, byLegend["Total"]); + Assert.Equal(11, byLegend["Running"]); + Assert.Equal(22, byLegend["Sleeping"]); + Assert.Equal(33, byLegend["Background"]); + Assert.Equal(44, byLegend["Dormant"]); + Assert.Equal(55, byLegend["Idle >30m"]); + Assert.Equal(66, byLegend["Waiting for Memory"]); + } + + [Fact] + public void FormatSessionSummary_Null_YieldsNAForAllThree() + { + var (topApp, topHost, databases) = SessionStatsSummary.Format(null); + + Assert.Equal("N/A", topApp); + Assert.Equal("N/A", topHost); + Assert.Equal("N/A", databases); + } + + [Fact] + public void FormatSessionSummary_FullPoint_RendersNameAndConnectionCount() + { + var point = Point(topApp: "SQLAgent", topAppConns: 42, topHost: "APPSRV01", topHostConns: 18, dbs: 9); + + var (topApp, topHost, databases) = SessionStatsSummary.Format(point); + + Assert.Equal("SQLAgent (42)", topApp); + Assert.Equal("APPSRV01 (18)", topHost); + Assert.Equal("9", databases); + } + + [Fact] + public void FormatSessionSummary_MissingApplicationOrHost_YieldsNA_ButKeepsDatabaseCount() + { + var point = Point(topApp: null, topAppConns: null, topHost: "", topHostConns: null, dbs: 3); + + var (topApp, topHost, databases) = SessionStatsSummary.Format(point); + + Assert.Equal("N/A", topApp); + Assert.Equal("N/A", topHost); + Assert.Equal("3", databases); + } + + private static SessionStatsPoint Point(string? topApp, int? topAppConns, string? topHost, int? topHostConns, int dbs) => + new( + CollectionTime: DateTime.UtcNow, + TotalSessions: 0, + RunningSessions: 0, + SleepingSessions: 0, + BackgroundSessions: 0, + DormantSessions: 0, + IdleSessionsOver30Min: 0, + SessionsWaitingForMemory: 0, + DatabasesWithConnections: dbs, + TopApplicationName: topApp, + TopApplicationConnections: topAppConns, + TopHostName: topHost, + TopHostConnections: topHostConns); +} + +/// +/// Gated (DARLING_TEST_PG) live round-trip for the Session Stats read: two in-window snapshots plus one +/// out-of-window snapshot prove the oldest-first ordering, the inclusive both-sides window bound, and the +/// latest-snapshot summary attribution. Shares the serialized "live-postgres" collection; uses a negative +/// sentinel server_id and cleans up in finally. +/// +[Collection("live-postgres")] +public sealed class ViewerSessionStatsLivePostgresTests +{ + private const int SessionServerId = -940003; + private const string SessionServerName = "viewer-session-summary-e2e"; + + [Fact] + public async Task SessionStats_OrderedInWindow_ExcludesOutOfWindow_LatestSummary_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live session-stats test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteAsync(connection, SessionServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var t1 = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + var t2 = t1.AddMinutes(5); + var tOut = t2.AddMinutes(10); // AFTER the window end below — must be excluded + + await InsertAsync(connection, 1, t1, total: 100, running: 5, sleeping: 90, background: 3, dormant: 2, + idle: 10, waiting: 1, dbs: 7, topApp: "AppA", topAppConns: 40, topHost: "Host1", topHostConns: 30); + await InsertAsync(connection, 2, t2, total: 120, running: 8, sleeping: 105, background: 4, dormant: 3, + idle: 15, waiting: 2, dbs: 9, topApp: "AppB", topAppConns: 55, topHost: "Host2", topHostConns: 45); + await InsertAsync(connection, 3, tOut, total: 999, running: 0, sleeping: 0, background: 0, dormant: 0, + idle: 0, waiting: 0, dbs: 0, topApp: "OutOfWindow", topAppConns: 1, topHost: "OutHost", topHostConns: 1); + + var window = await viewer.GetSessionStatsAsync(SessionServerId, t1.AddMinutes(-1), t2.AddMinutes(1)); + + /* Both-sides bound: only the two in-window rows, oldest-first. */ + Assert.Equal(2, window.Count); + Assert.Equal(t1, window[0].CollectionTime); + Assert.Equal(t2, window[1].CollectionTime); + Assert.DoesNotContain(window, p => p.TotalSessions == 999); + + /* Column mapping on the latest snapshot. */ + var latest = window[^1]; + Assert.Equal(120, latest.TotalSessions); + Assert.Equal(8, latest.RunningSessions); + Assert.Equal(105, latest.SleepingSessions); + Assert.Equal(4, latest.BackgroundSessions); + Assert.Equal(3, latest.DormantSessions); + Assert.Equal(15, latest.IdleSessionsOver30Min); + Assert.Equal(2, latest.SessionsWaitingForMemory); + Assert.Equal(9, latest.DatabasesWithConnections); + Assert.Equal("AppB", latest.TopApplicationName); + Assert.Equal(55, latest.TopApplicationConnections); + Assert.Equal("Host2", latest.TopHostName); + Assert.Equal(45, latest.TopHostConnections); + + /* The summary strip reads that latest snapshot. */ + var (topApp, topHost, databases) = SessionStatsSummary.Format(latest); + Assert.Equal("AppB (55)", topApp); + Assert.Equal("Host2 (45)", topHost); + Assert.Equal("9", databases); + } + finally + { + await DeleteAsync(connection, SessionServerId); + } + } + + private static async Task InsertAsync( + NpgsqlConnection connection, long collectionId, DateTime collectionTimeUtc, + int total, int running, int sleeping, int background, int dormant, int idle, int waiting, int dbs, + string topApp, int topAppConns, string topHost, int topHostConns) + { + using var command = new NpgsqlCommand(@" +INSERT INTO session_summary_stats + (collection_id, collection_time, server_id, server_name, + total_sessions, running_sessions, sleeping_sessions, background_sessions, dormant_sessions, + idle_sessions_over_30min, sessions_waiting_for_memory, databases_with_connections, + top_application_name, top_application_connections, top_host_name, top_host_connections) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)", connection); + command.Parameters.AddWithValue(collectionId); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(SessionServerId); + command.Parameters.AddWithValue(SessionServerName); + command.Parameters.AddWithValue(total); + command.Parameters.AddWithValue(running); + command.Parameters.AddWithValue(sleeping); + command.Parameters.AddWithValue(background); + command.Parameters.AddWithValue(dormant); + command.Parameters.AddWithValue(idle); + command.Parameters.AddWithValue(waiting); + command.Parameters.AddWithValue(dbs); + command.Parameters.AddWithValue(topApp); + command.Parameters.AddWithValue(topAppConns); + command.Parameters.AddWithValue(topHost); + command.Parameters.AddWithValue(topHostConns); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static DateTime TruncateToSeconds(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified); + + private static async Task DeleteAsync(NpgsqlConnection connection, int serverId) + { + using var cleanup = new NpgsqlCommand($"DELETE FROM session_summary_stats WHERE server_id = {serverId};", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } +} diff --git a/Darling/Darling.Tests/ViewerSystemEventsTests.cs b/Darling/Darling.Tests/ViewerSystemEventsTests.cs new file mode 100644 index 000000000..18a1945eb --- /dev/null +++ b/Darling/Darling.Tests/ViewerSystemEventsTests.cs @@ -0,0 +1,565 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the System Events tab (system_health parity, Stage 2b) against the Darling store contract, no live +/// Postgres. The Common shred is already tested; these cover the two things +/// Stage 2b adds: (1) the data-service reads — the load-bearing SQL clauses and column parity against the +/// generated collector DDL — and (2) , the per-category +/// "significant / warning" filter that re-applies sp_HealthParser's WHERE predicates at the product's +/// canonical parameters (@warnings_only = 1, @wait_duration_ms = 500), plus the Severe-Errors database_id +/// resolution and the machine-local event-time projection. +/// +public sealed class ViewerSystemEventsTests +{ + private static string LoadFixture(string name) => + File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "SystemHealth", name)); + + // ── Reads: SQL clause pins + column parity ── + + [Fact] + public void SystemHealthEventsByTypeSql_ReadsViewByServerTypeAndEventTimeWindow() + { + var sql = ViewerDataService.SystemHealthEventsByTypeSql; + + Assert.Contains("FROM v_system_health_events", sql, StringComparison.Ordinal); + Assert.Contains("event_xml", sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + /* Windows on event_time (the XE @timestamp — the event's real time), NOT collection_time, and on the + category's own event_type ($4), newest first. */ + Assert.Contains("event_time >= $2", sql, StringComparison.Ordinal); + Assert.Contains("event_time <= $3", sql, StringComparison.Ordinal); + Assert.Contains("event_type = $4", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY event_time DESC", sql, StringComparison.Ordinal); + } + + [Fact] + public void DatabaseNameMapSql_LatestNamePerId_FromSizeStatsView() + { + var sql = ViewerDataService.DatabaseNameMapSql; + + Assert.Contains("DISTINCT ON (database_id)", sql, StringComparison.Ordinal); + Assert.Contains("FROM v_database_size_stats", sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY database_id, collection_time DESC", sql, StringComparison.Ordinal); + } + + [Theory] + [InlineData(nameof(ViewerDataService.SystemHealthEventsByTypeSql))] + [InlineData(nameof(ViewerDataService.DatabaseNameMapSql))] + public void SystemEventsReads_PgDialect_PositionalParams_NoTSqlNamedParams(string sqlName) + { + var sql = (string)typeof(ViewerDataService).GetField(sqlName)!.GetValue(null)!; + + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + Assert.DoesNotContain("now(", sql.ToLowerInvariant()); + Assert.Contains("$1", sql, StringComparison.Ordinal); + } + + [Fact] + public void SystemHealthEventsRead_ReferencesColumnsThatExistInTheGeneratedTable() + { + var ddl = PgSchemaGenerator.CreateTable(SystemHealthEventsCollector.Instance); + Assert.Equal("system_health_events", SystemHealthEventsCollector.Instance.TargetTable); + + foreach (var col in new[] { "event_xml", "event_type", "event_time", "collection_time", "server_id" }) + { + Assert.Contains(col, ddl, StringComparison.Ordinal); + } + } + + [Fact] + public void DatabaseNameMapRead_ReferencesColumnsThatExistInTheGeneratedTable() + { + var ddl = PgSchemaGenerator.CreateTable(DatabaseSizeStatsCollector.Instance); + Assert.Equal("database_size_stats", DatabaseSizeStatsCollector.Instance.TargetTable); + + /* database_size_stats is the mapping source precisely because it carries BOTH id and name. */ + foreach (var col in new[] { "database_id", "database_name", "collection_time", "server_id" }) + { + Assert.Contains(col, ddl, StringComparison.Ordinal); + } + } + + // ── Significance: Scheduler Issues (status = WARNING) ── + + [Theory] + [InlineData("WARNING", true)] + [InlineData("OK", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void SchedulerIssue_SignificantOnlyWhenWarning(string? status, bool expected) => + Assert.Equal(expected, SystemEventSignificance.IsSignificant(new SchedulerIssueRecord { Status = status })); + + [Fact] + public void SchedulerIssue_Fixture_IsSignificant() + { + var record = SystemHealthParser.ParseSchedulerIssue(LoadFixture("scheduler_monitor.xml"))!; + Assert.Equal("WARNING", record.Status); + Assert.True(SystemEventSignificance.IsSignificant(record)); + } + + // ── Significance: Severe Errors (severity >= 19, excluding 17830 / 18056) ── + + [Theory] + [InlineData(24, 823, true)] // fixture case + [InlineData(19, 50000, true)] // boundary — 19 is kept + [InlineData(18, 50000, false)] // below the warnings_only cutoff + [InlineData(16, 50000, false)] // the always-on floor, but not "warning" + [InlineData(25, 17830, false)] // severe but on the ignore list + [InlineData(20, 18056, false)] // severe but on the ignore list + public void SevereError_SignificantByCutoffAndIgnoreList(int severity, int errorNumber, bool expected) => + Assert.Equal(expected, SystemEventSignificance.IsSignificant( + new SevereErrorRecord { Severity = severity, ErrorNumber = errorNumber })); + + [Fact] + public void SevereError_NullSeverity_IsNotSignificant() => + Assert.False(SystemEventSignificance.IsSignificant(new SevereErrorRecord { Severity = null, ErrorNumber = 823 })); + + [Fact] + public void SevereError_HighSeverity_NullErrorNumber_IsSignificant() => + Assert.True(SystemEventSignificance.IsSignificant(new SevereErrorRecord { Severity = 20, ErrorNumber = null })); + + [Fact] + public void SevereError_Fixture_IsSignificant() + { + var record = SystemHealthParser.ParseSevereError(LoadFixture("error_reported.xml"))!; + Assert.True(SystemEventSignificance.IsSignificant(record)); + } + + // ── Significance: Memory Conditions / Memory Broker (RESOURCE_MEMPHYSICAL_LOW) ── + + [Theory] + [InlineData("RESOURCE_MEMPHYSICAL_LOW", true)] + [InlineData("RESOURCE_MEMPHYSICAL_HIGH", false)] + [InlineData(null, false)] + public void MemoryConditions_SignificantOnlyWhenLow(string? lastNotification, bool expected) => + Assert.Equal(expected, SystemEventSignificance.IsSignificant( + new MemoryConditionsRecord { LastNotification = lastNotification })); + + [Theory] + [InlineData("RESOURCE_MEMPHYSICAL_LOW", true)] + [InlineData("RESOURCE_MEMPHYSICAL_HIGH", false)] + [InlineData(null, false)] + public void MemoryBroker_SignificantOnlyWhenLow(string? notification, bool expected) => + Assert.Equal(expected, SystemEventSignificance.IsSignificant( + new MemoryBrokerRecord { Notification = notification })); + + [Fact] + public void MemoryConditions_HighFixture_IsNotSignificant() + { + // The representative fixture is RESOURCE_MEMPHYSICAL_HIGH, so the warnings_only filter drops it. + var record = SystemHealthParser.ParseMemoryConditions(LoadFixture("sp_server_diagnostics_resource.xml"))!; + Assert.Equal("RESOURCE_MEMPHYSICAL_HIGH", record.LastNotification); + Assert.False(SystemEventSignificance.IsSignificant(record)); + } + + [Fact] + public void MemoryBroker_HighFixture_IsNotSignificant() + { + var record = SystemHealthParser.ParseMemoryBroker(LoadFixture("memory_broker.xml"))!; + Assert.Equal("RESOURCE_MEMPHYSICAL_HIGH", record.Notification); + Assert.False(SystemEventSignificance.IsSignificant(record)); + } + + // ── Significance: Memory Node OOM (never filtered) ── + + [Fact] + public void MemoryNodeOom_AlwaysSignificant_EvenEmpty() => + Assert.True(SystemEventSignificance.IsSignificant(new MemoryNodeOomRecord())); + + [Fact] + public void MemoryNodeOom_Fixture_IsSignificant() + { + var record = SystemHealthParser.ParseMemoryNodeOom(LoadFixture("memory_node_oom.xml"))!; + Assert.True(SystemEventSignificance.IsSignificant(record)); + } + + // ── Significance: Significant Waits (session, non-BACKUP, >= 500 ms, not on ignore list) ── + + [Fact] + public void SignificantWait_Fixture_IsSignificant() + { + var record = SystemHealthParser.ParseSignificantWait(LoadFixture("wait_info.xml"))!; + Assert.True(SystemEventSignificance.IsSignificant(record)); + } + + private static SignificantWaitRecord Wait( + string? waitType = "PAGEIOLATCH_SH", long? duration = 1500, int? session = 57, string? sql = "SELECT 1") => + new() { WaitType = waitType, DurationMs = duration, SessionId = session, QueryText = sql }; + + [Fact] + public void SignificantWait_DurationBoundary_500Kept_499Dropped() + { + Assert.True(SystemEventSignificance.IsSignificant(Wait(duration: 500))); + Assert.False(SystemEventSignificance.IsSignificant(Wait(duration: 499))); + Assert.False(SystemEventSignificance.IsSignificant(Wait(duration: null))); + } + + [Fact] + public void SignificantWait_SystemSession0_IsDropped() => + Assert.False(SystemEventSignificance.IsSignificant(Wait(session: 0))); + + [Fact] + public void SignificantWait_NoStatementOrBackup_IsDropped() + { + Assert.False(SystemEventSignificance.IsSignificant(Wait(sql: null))); + Assert.False(SystemEventSignificance.IsSignificant(Wait(sql: ""))); + Assert.False(SystemEventSignificance.IsSignificant(Wait(sql: "BACKUP DATABASE foo TO DISK = 'x'"))); + Assert.False(SystemEventSignificance.IsSignificant(Wait(sql: "backup log bar"))); // case-insensitive + } + + [Fact] + public void SignificantWait_IgnoredWaitType_IsDropped() + { + // A long wait on an idle/background type on the ignore list is not significant, even at 1000 ms. + Assert.False(SystemEventSignificance.IsSignificant(Wait(waitType: "CHECKPOINT_QUEUE", duration: 1000))); + Assert.False(SystemEventSignificance.IsSignificant(Wait(waitType: "checkpoint_queue", duration: 1000))); // case-insensitive + } + + [Fact] + public void IgnoredWaitTypes_ContainsKnownIdleWaits_NotRealWaits() + { + Assert.Contains("CHECKPOINT_QUEUE", SystemEventSignificance.IgnoredWaitTypes); + Assert.Contains("LAZYWRITER_SLEEP", SystemEventSignificance.IgnoredWaitTypes); + Assert.Contains("XE_TIMER_EVENT", SystemEventSignificance.IgnoredWaitTypes); + /* Real, actionable waits must NOT be on the ignore list. */ + Assert.DoesNotContain("PAGEIOLATCH_SH", SystemEventSignificance.IgnoredWaitTypes); + Assert.DoesNotContain("LCK_M_X", SystemEventSignificance.IgnoredWaitTypes); + Assert.DoesNotContain("CXPACKET", SystemEventSignificance.IgnoredWaitTypes); + } + + // ── Significance: CPU Tasks (state = WARNING and pendingTasks >= 10) ── + + [Theory] + [InlineData("WARNING", 10, true)] // boundary — 10 is kept + [InlineData("WARNING", 15, true)] // fixture case + [InlineData("WARNING", 9, false)] // below the pending-task threshold + [InlineData("WARNING", null, false)] // no pending tasks -> dropped (exist-predicate fails) + [InlineData("CLEAN", 100, false)] // not a warning, even with many pending tasks + [InlineData("HAS_ISSUES", 100, false)] + [InlineData(null, 100, false)] + public void CpuTasks_SignificantOnlyWhenWarningAndPendingAtThreshold(string? state, int? pendingTasks, bool expected) => + Assert.Equal(expected, SystemEventSignificance.IsSignificant( + new CpuTasksRecord { State = state, PendingTasks = pendingTasks })); + + [Fact] + public void CpuTasks_WarningFixture_IsSignificant() + { + var record = SystemHealthParser.ParseCpuTasks(LoadFixture("sp_server_diagnostics_query_processing_warning.xml"))!; + Assert.True(SystemEventSignificance.IsSignificant(record)); + } + + [Fact] + public void CpuTasks_CleanFixture_IsNotSignificant() + { + // The shared clean QUERY_PROCESSING fixture is CLEAN with zero pending tasks — the warnings filter drops it. + var record = SystemHealthParser.ParseCpuTasks(LoadFixture("sp_server_diagnostics_query_processing.xml"))!; + Assert.Equal("CLEAN", record.State); + Assert.False(SystemEventSignificance.IsSignificant(record)); + } + + // ── Significance: I/O Issues (state = WARNING) ── + + [Theory] + [InlineData("WARNING", true)] + [InlineData("CLEAN", false)] + [InlineData("HAS_ISSUES", false)] + [InlineData(null, false)] + public void IoIssues_SignificantOnlyWhenWarning(string? state, bool expected) => + Assert.Equal(expected, SystemEventSignificance.IsSignificant(new IoIssuesRecord { State = state })); + + [Fact] + public void IoIssues_WarningFixture_AllRowsSignificant() + { + var rows = SystemHealthParser.ParseIoIssues(LoadFixture("sp_server_diagnostics_io_subsystem.xml")); + Assert.NotEmpty(rows); + Assert.All(rows, r => Assert.True(SystemEventSignificance.IsSignificant(r))); + } + + // ── System Health (Corruption + Contention counter charts — NO significance filter) ── + + [Fact] + public void SystemHealth_Fixture_ShredsBothCorruptionAndContentionCounters() + { + // The ONE SYSTEM-component record feeds BOTH chart sub-tabs: the Corruption Events charts read the + // bad-page / dump / access-violation columns; the Contention Events charts read the non-yielding / + // latch / spinlock / CPU columns. Pin that every column both tabs plot is present off one shred. + var r = SystemHealthParser.ParseSystemHealth(LoadFixture("sp_server_diagnostics_system.xml"))!; + + // Corruption Events columns. + Assert.Equal(6L, r.BadPagesDetected); + Assert.Equal(2L, r.IntervalDumpRequests); + Assert.Equal(1L, r.IsAccessViolationOccurred); + Assert.Equal(3L, r.WriteAccessViolationCount); + // Contention Events columns. + Assert.Equal(4L, r.NonYieldingTasksReported); + Assert.Equal(7L, r.LatchWarnings); + Assert.Equal("SOS_CACHESTORE", r.SickSpinlockType); + Assert.Equal(12345L, r.SpinlockBackoffs); + Assert.Equal(65L, r.SystemCpuUtilization); + Assert.Equal(40L, r.SqlCpuUtilization); + } + + [Fact] + public void SystemHealth_CleanSnapshot_IsStillKept_NoWarningsOnlyGate() + { + // Unlike the grid categories, the Corruption / Contention charts plot EVERY collected snapshot over + // time — there is no significance / warnings-only filter (GetSystemHealthAsync keeps every SYSTEM + // record with a timestamp). The representative fixture is a CLEAN snapshot, and it is a real record + // (not dropped), so the counter charts render it. This is the design contrast with the filtered grids. + var r = SystemHealthParser.ParseSystemHealth(LoadFixture("sp_server_diagnostics_system.xml")); + + Assert.NotNull(r); + Assert.Equal("CLEAN", r!.State); + Assert.True(r.EventTime.HasValue); // GetSystemHealthAsync requires a timestamp to sit on the time axis + } + + [Fact] + public void SystemHealth_NonSystemComponent_YieldsNothing() + { + // The SYSTEM read shares the sp_server_diagnostics feed with Memory Conditions / CPU Tasks / I/O + // Issues; only the SYSTEM component contributes a record (the others parse to null, so the charts + // never plot a RESOURCE / QUERY_PROCESSING / IO_SUBSYSTEM snapshot). + Assert.Null(SystemHealthParser.ParseSystemHealth(LoadFixture("sp_server_diagnostics_resource.xml"))); + Assert.Null(SystemHealthParser.ParseSystemHealth(LoadFixture("sp_server_diagnostics_query_processing.xml"))); + Assert.Null(SystemHealthParser.ParseSystemHealth(LoadFixture("sp_server_diagnostics_io_subsystem.xml"))); + } + + // ── End-to-end: shred the fixtures then apply the filters (the data-service path's logic) ── + + [Fact] + public void ShredThenFilter_KeepsOnlySignificantRows_AcrossAllCategories() + { + var events = new (string?, string?)[] + { + (SystemHealthParser.SchedulerMonitorEvent, LoadFixture("scheduler_monitor.xml")), + (SystemHealthParser.ErrorReportedEvent, LoadFixture("error_reported.xml")), + (SystemHealthParser.SpServerDiagnosticsEvent, LoadFixture("sp_server_diagnostics_system.xml")), + (SystemHealthParser.SpServerDiagnosticsEvent, LoadFixture("sp_server_diagnostics_resource.xml")), + (SystemHealthParser.SpServerDiagnosticsEvent, LoadFixture("sp_server_diagnostics_query_processing.xml")), + (SystemHealthParser.SpServerDiagnosticsEvent, LoadFixture("sp_server_diagnostics_query_processing_warning.xml")), + (SystemHealthParser.SpServerDiagnosticsEvent, LoadFixture("sp_server_diagnostics_io_subsystem.xml")), + (SystemHealthParser.MemoryBrokerEvent, LoadFixture("memory_broker.xml")), + (SystemHealthParser.MemoryNodeOomEvent, LoadFixture("memory_node_oom.xml")), + (SystemHealthParser.WaitInfoEvent, LoadFixture("wait_info.xml")), + }; + + var result = SystemHealthParser.ParseEvents(events); + + // Scheduler (WARNING), Severe Error (sev 24), Memory Node OOM (ungated), and the wait all pass. + Assert.Single(result.SchedulerIssues.Where(SystemEventSignificance.IsSignificant)); + Assert.Single(result.SevereErrors.Where(SystemEventSignificance.IsSignificant)); + Assert.Single(result.MemoryNodeOom.Where(SystemEventSignificance.IsSignificant)); + Assert.Single(result.SignificantWaits.Where(SystemEventSignificance.IsSignificant)); + // The memory-conditions / broker fixtures are RESOURCE_MEMPHYSICAL_HIGH — the warnings filter drops them. + Assert.Empty(result.MemoryConditions.Where(SystemEventSignificance.IsSignificant)); + Assert.Empty(result.MemoryBroker.Where(SystemEventSignificance.IsSignificant)); + // CPU: the WARNING+15-pending event passes; the CLEAN one is dropped. I/O: both WARNING file rows pass. + Assert.Single(result.CpuTasks.Where(SystemEventSignificance.IsSignificant)); + Assert.Equal(2, result.IoIssues.Count(SystemEventSignificance.IsSignificant)); + // System Health (Corruption + Contention charts) has NO significance filter — the CLEAN SYSTEM + // snapshot is kept, so the counter charts plot it (contrast with the dropped HIGH memory rows above). + Assert.Single(result.SystemHealth); + } + + // ── Severe-Errors database_id resolution ── + + [Fact] + public void ResolveDatabaseName_MappedId_ReturnsName() + { + var map = new Dictionary { [5] = "AdventureWorks", [1] = "master" }; + Assert.Equal("AdventureWorks", ViewerDataService.ResolveDatabaseName(5, map)); + Assert.Equal("master", ViewerDataService.ResolveDatabaseName(1, map)); + } + + [Fact] + public void ResolveDatabaseName_NoContext_ZeroOrNull_IsBlank() + { + var map = new Dictionary { [5] = "AdventureWorks" }; + Assert.Equal(string.Empty, ViewerDataService.ResolveDatabaseName(0, map)); + Assert.Equal(string.Empty, ViewerDataService.ResolveDatabaseName(null, map)); + } + + [Fact] + public void ResolveDatabaseName_RealButUnmappedId_SurfacesTheRawId_NotBlank() + { + var map = new Dictionary { [5] = "AdventureWorks" }; + // A database dropped before the latest size-stats snapshot: surface the id rather than silently blank. + Assert.Equal("database_id 7", ViewerDataService.ResolveDatabaseName(7, map)); + } + + // ── Row projection: machine-local event time + resolved DB passthrough ── + + [Fact] + public void Rows_EventTimeLocal_RendersTheUtcTimestampAsMachineLocal() + { + var utc = new DateTime(2026, 7, 5, 12, 0, 5, DateTimeKind.Utc); + var expected = ViewerTimeHelper.ForDisplay(utc).ToString("yyyy-MM-dd HH:mm:ss"); + + Assert.Equal(expected, new SchedulerIssueRow(new SchedulerIssueRecord { EventTime = utc }).EventTimeLocal); + Assert.Equal(expected, new SignificantWaitRow(new SignificantWaitRecord { EventTime = utc }).EventTimeLocal); + } + + [Fact] + public void Rows_NullEventTime_RendersEmpty() => + Assert.Equal(string.Empty, new MemoryBrokerRow(new MemoryBrokerRecord { EventTime = null }).EventTimeLocal); + + [Fact] + public void SevereErrorRow_CarriesResolvedDatabaseName() + { + var record = new SevereErrorRecord { ErrorNumber = 823, Severity = 24, DatabaseId = 5 }; + var row = new SevereErrorRow(record, "AdventureWorks"); + + Assert.Equal("AdventureWorks", row.DatabaseName); + Assert.Equal(5, row.DatabaseId); + Assert.Equal(823, row.ErrorNumber); + } + + [Fact] + public void CpuTasksRow_And_IoIssuesRow_ProjectRecordFields() + { + var utc = new DateTime(2026, 7, 5, 12, 6, 0, DateTimeKind.Utc); + var expected = ViewerTimeHelper.ForDisplay(utc).ToString("yyyy-MM-dd HH:mm:ss"); + + var cpu = new CpuTasksRow(new CpuTasksRecord + { EventTime = utc, State = "WARNING", PendingTasks = 15, DidBlockingOccur = true }); + Assert.Equal(expected, cpu.EventTimeLocal); + Assert.Equal("WARNING", cpu.State); + Assert.Equal(15L, cpu.PendingTasks); + Assert.True(cpu.DidBlockingOccur); + + var io = new IoIssuesRow(new IoIssuesRecord + { EventTime = utc, State = "WARNING", LongestPendingRequestsDurationMs = 1500, LongestPendingRequestsFilePath = @"D:\data\prod.mdf" }); + Assert.Equal(expected, io.EventTimeLocal); + Assert.Equal("WARNING", io.State); + Assert.Equal(1500L, io.LongestPendingRequestsDurationMs); + Assert.Equal(@"D:\data\prod.mdf", io.LongestPendingRequestsFilePath); + } + + // ── Default Trace (always-on server events; the Default Trace sub-tab) ── + + [Fact] + public void DefaultTraceEventsByWindowSql_ReadsBaseTable_DeSkewsLocalEventTimeToUtc_AndWindows() + { + var sql = ViewerDataService.DefaultTraceEventsByWindowSql; + + /* Base default_trace_events (no v_ view, like server_properties). */ + Assert.Contains("FROM default_trace_events", sql, StringComparison.Ordinal); + Assert.DoesNotContain("v_default_trace_events", sql, StringComparison.Ordinal); + Assert.Contains("WHERE dte.server_id = $1", sql, StringComparison.Ordinal); + + /* The Default Trace StartTime is server-LOCAL, so de-skew to naive-UTC via the collected offset + BEFORE windowing + returning — so the row shares the system_health rows' UTC frame (the cross-frame + caveat: system_health.event_time is UTC, default_trace.event_time is local). */ + Assert.Contains("server_properties", sql, StringComparison.Ordinal); + Assert.Contains("utc_offset_minutes", sql, StringComparison.Ordinal); + Assert.Contains("event_time - make_interval(mins => svr.offset_minutes) AS event_time_utc", sql, StringComparison.Ordinal); + Assert.Contains("event_time - make_interval(mins => svr.offset_minutes) >= $2", sql, StringComparison.Ordinal); + Assert.Contains("event_time - make_interval(mins => svr.offset_minutes) <= $3", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY event_time_utc DESC", sql, StringComparison.Ordinal); + + /* Postgres dialect, positional params. */ + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + Assert.DoesNotContain("getdate", sql.ToLowerInvariant()); + Assert.Contains("$1", sql, StringComparison.Ordinal); + } + + [Fact] + public void DefaultTraceEventsRead_ReferencesColumnsThatExistInTheGeneratedTable() + { + var ddl = PgSchemaGenerator.CreateTable(DefaultTraceEventsCollector.Instance); + Assert.Equal("default_trace_events", DefaultTraceEventsCollector.Instance.TargetTable); + + foreach (var col in new[] + { + "event_time", "event_name", "database_name", "object_name", "login_name", "host_name", + "application_name", "spid", "duration_us", "integer_data", "severity", "error_number", + "text_data", "server_id", + }) + { + Assert.Contains(col, ddl, StringComparison.Ordinal); + } + } + + [Fact] + public void DefaultTraceEventRow_EventTimeLocal_SharesTheSystemHealthUtcFrame() + { + /* A default-trace row and a system_health row built from the SAME UTC instant must render the SAME + machine-local time — the loader de-skews the local StartTime to UTC, so both are in the UTC frame + and a merged System Events timeline sorts them correctly (the coordinator's cross-frame caveat). */ + var utc = new DateTime(2026, 7, 9, 12, 0, 5, DateTimeKind.Utc); + var expected = ViewerTimeHelper.ForDisplay(utc).ToString("yyyy-MM-dd HH:mm:ss"); + + var dt = new DefaultTraceEventRow( + utc, DefaultTraceEventCategory.AutoGrowShrink, "Data File Auto Grow", "SalesDB", null, + "app", "APPHOST", "SqlClient", 55, 1_500_000, 256, null, null, null); + var sh = new SchedulerIssueRow(new SchedulerIssueRecord { EventTime = utc }); + + Assert.Equal(expected, dt.EventTimeLocal); + Assert.Equal(sh.EventTimeLocal, dt.EventTimeLocal); + } + + [Fact] + public void DefaultTraceEventRow_NullEventTime_RendersEmpty() => + Assert.Equal(string.Empty, new DefaultTraceEventRow( + null, DefaultTraceEventCategory.ErrorLog, "ErrorLog", null, null, null, null, null, null, null, null, 20, 823, "x").EventTimeLocal); + + [Fact] + public void DefaultTraceEventRow_ProjectsCategory_DurationMs_AndGrowthMbForAutogrowOnly() + { + var grow = new DefaultTraceEventRow( + null, DefaultTraceEventCategory.AutoGrowShrink, "Data File Auto Grow", "db", null, null, null, null, + null, 1_500_000, 256, null, null, null); + Assert.Equal("AutoGrowShrink", grow.Category); + Assert.Equal(1500m, grow.DurationMs); /* 1_500_000 us / 1000 */ + Assert.Equal(2.0m, grow.GrowthMb); /* 256 pages * 8 KB / 1024 */ + + /* Growth is null for non-autogrow categories even when integer_data is present. */ + var err = new DefaultTraceEventRow( + null, DefaultTraceEventCategory.ErrorLog, "ErrorLog", null, null, null, null, null, + null, null, 256, 20, 823, "boom"); + Assert.Equal("ErrorLog", err.Category); + Assert.Null(err.GrowthMb); + } + + [Fact] + public void DefaultTrace_SignificantSet_GatesErrorLogBySeverity_KeepsEveryOtherCategory() + { + /* The significant set the loader keeps (the default-trace feed for the tab): every curated category + is significant as collected, except ErrorLog which must clear the severity floor. */ + var events = new (string EventName, int? Severity)[] + { + ("Data File Auto Grow", null), // stall -> significant + ("Object:Altered", null), // schema DDL -> significant + ("Audit DBCC Event", null), // security audit -> significant + ("Server Memory Change", null), // memory change -> significant + ("ErrorLog", 20), // severe ErrorLog -> significant + ("ErrorLog", 10), // routine ErrorLog -> DROPPED + }; + + var significant = events + .Where(e => SystemEventSignificance.IsSignificantDefaultTraceEvent(e.EventName, e.Severity)) + .ToList(); + + Assert.Equal(5, significant.Count); + Assert.DoesNotContain(significant, e => e.EventName == "ErrorLog" && e.Severity == 10); + } +} diff --git a/Darling/Darling.Tests/ViewerTimeHelperTests.cs b/Darling/Darling.Tests/ViewerTimeHelperTests.cs new file mode 100644 index 000000000..b4ba50457 --- /dev/null +++ b/Darling/Darling.Tests/ViewerTimeHelperTests.cs @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using PerformanceMonitor.Darling.Viewer; +using PerformanceMonitor.Ui; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins — the viewer's port of Lite's ServerTimeHelper and the single +/// chokepoint every rendered timestamp routes through. The store is naive-UTC, so: UTC = raw, Local = +/// machine-local (SpecifyKind(Utc).ToLocalTime()), Server = UTC + the server's collected offset. Also pins +/// the inverse used by the custom-range pickers and the per-server offset read SQL. The conversion core is +/// exercised through the pure ConvertToDisplay/ConvertFromDisplay overloads so the process- +/// wide statics are not mutated; two focused tests confirm the static ForDisplay/DisplayToNaiveUtc +/// delegate to them (saving/restoring the statics). +/// +public sealed class ViewerTimeHelperTests +{ + private static readonly DateTime NaiveUtc = new(2026, 7, 1, 12, 0, 0, DateTimeKind.Unspecified); + + [Fact] + public void ConvertToDisplay_Utc_ReturnsRawStoredValue() + { + Assert.Equal(NaiveUtc, ViewerTimeHelper.ConvertToDisplay(NaiveUtc, TimeDisplayMode.UTC, utcOffsetMinutes: 330)); + } + + [Fact] + public void ConvertToDisplay_Local_MatchesMachineLocalConvention() + { + /* The viewer's historical convention (the former ViewerDataService.ToLocalTime): treat the + Unspecified store value as UTC, then convert to the viewer machine's local time. Offset is ignored. */ + var expected = DateTime.SpecifyKind(NaiveUtc, DateTimeKind.Utc).ToLocalTime(); + var actual = ViewerTimeHelper.ConvertToDisplay(NaiveUtc, TimeDisplayMode.LocalTime, utcOffsetMinutes: 999); + Assert.Equal(expected, actual); + Assert.Equal(DateTimeKind.Local, actual.Kind); + } + + [Theory] + [InlineData(0, 0)] // a UTC server: Server == UTC + [InlineData(330, 330)] // India +05:30 + [InlineData(-480, -480)] // US Pacific standard -08:00 + [InlineData(-300, -300)] // US Eastern standard -05:00 + public void ConvertToDisplay_Server_AddsCollectedOffset(int offsetMinutes, int expectedShiftMinutes) + { + Assert.Equal( + NaiveUtc.AddMinutes(expectedShiftMinutes), + ViewerTimeHelper.ConvertToDisplay(NaiveUtc, TimeDisplayMode.ServerTime, offsetMinutes)); + } + + [Theory] + [InlineData(TimeDisplayMode.UTC)] + [InlineData(TimeDisplayMode.LocalTime)] + [InlineData(TimeDisplayMode.ServerTime)] + public void ConvertFromDisplay_InvertsConvertToDisplay(TimeDisplayMode mode) + { + const int offset = -300; + var display = ViewerTimeHelper.ConvertToDisplay(NaiveUtc, mode, offset); + var backToStore = ViewerTimeHelper.ConvertFromDisplay(display, mode, offset); + + /* Round-trips to the original naive-UTC store value, re-stamped Unspecified (the kind the reads send). */ + Assert.Equal(NaiveUtc, backToStore); + Assert.Equal(DateTimeKind.Unspecified, backToStore.Kind); + } + + [Fact] + public void ConvertFromDisplay_Server_SubtractsOffset() + { + /* A picker value the user typed in Server time maps back to the naive-UTC window bound. */ + var serverWallClock = new DateTime(2026, 7, 1, 7, 0, 0); // 07:00 on a -05:00 server + var expectedUtc = DateTime.SpecifyKind(new DateTime(2026, 7, 1, 12, 0, 0), DateTimeKind.Unspecified); + Assert.Equal(expectedUtc, ViewerTimeHelper.ConvertFromDisplay(serverWallClock, TimeDisplayMode.ServerTime, -300)); + } + + [Fact] + public void ForDisplay_ReadsProcessWideStatics() + { + var savedMode = ViewerTimeHelper.CurrentDisplayMode; + var savedOffset = ViewerTimeHelper.UtcOffsetMinutes; + try + { + ViewerTimeHelper.UtcOffsetMinutes = 90; + + ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime; + Assert.Equal(NaiveUtc.AddMinutes(90), ViewerTimeHelper.ForDisplay(NaiveUtc)); + + ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.UTC; + Assert.Equal(NaiveUtc, ViewerTimeHelper.ForDisplay(NaiveUtc)); + + ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.LocalTime; + Assert.Equal(DateTime.SpecifyKind(NaiveUtc, DateTimeKind.Utc).ToLocalTime(), ViewerTimeHelper.ForDisplay(NaiveUtc)); + } + finally + { + ViewerTimeHelper.CurrentDisplayMode = savedMode; + ViewerTimeHelper.UtcOffsetMinutes = savedOffset; + } + } + + [Fact] + public void DisplayToNaiveUtc_ReadsProcessWideStatics_AndRoundTripsForDisplay() + { + var savedMode = ViewerTimeHelper.CurrentDisplayMode; + var savedOffset = ViewerTimeHelper.UtcOffsetMinutes; + try + { + ViewerTimeHelper.UtcOffsetMinutes = -300; + ViewerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime; + + var display = ViewerTimeHelper.ForDisplay(NaiveUtc); + Assert.Equal(NaiveUtc, ViewerTimeHelper.DisplayToNaiveUtc(display)); + } + finally + { + ViewerTimeHelper.CurrentDisplayMode = savedMode; + ViewerTimeHelper.UtcOffsetMinutes = savedOffset; + } + } + + [Fact] + public void ServerUtcOffsetSql_ReadsLatestNonNullOffsetForServer() + { + var sql = ViewerDataService.ServerUtcOffsetSql; + Assert.Contains("utc_offset_minutes", sql, StringComparison.Ordinal); + Assert.Contains("FROM server_properties", sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("utc_offset_minutes IS NOT NULL", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY collection_time DESC", sql, StringComparison.Ordinal); + Assert.Contains("LIMIT 1", sql, StringComparison.Ordinal); + /* Darling has no v_server_properties view — the base table, bare-name resolved via the Search Path. */ + Assert.DoesNotContain("v_server_properties", sql, StringComparison.Ordinal); + } +} diff --git a/Darling/Darling.Tests/ViewerW2aTests.cs b/Darling/Darling.Tests/ViewerW2aTests.cs new file mode 100644 index 000000000..3d519949b --- /dev/null +++ b/Darling/Darling.Tests/ViewerW2aTests.cs @@ -0,0 +1,1079 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Npgsql; +using NpgsqlTypes; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the Overview server-cards reads (W2a viewer copy-parity, copied from Lite's +/// LocalDataService.Overview.cs) against the Darling store contract — no live Postgres. The five +/// per-server summary reads run over the same v_* passthrough views the other tabs read; these pins are +/// the load-bearing clauses (source view, per-server filter, one-hour window on the event/deadlock time, +/// XE→DMV blocking fallback, newest-first LIMIT 1) plus the Pg dialect. +/// +public sealed class ViewerOverviewSqlTests +{ + [Fact] + public void SummaryCpuSql_ReadsLatestSample_FromCpuView() + { + var sql = ViewerDataService.ServerSummaryCpuSql; + Assert.Contains("FROM v_cpu_utilization_stats", sql, StringComparison.Ordinal); + Assert.Contains("sqlserver_cpu_utilization", sql, StringComparison.Ordinal); + Assert.Contains("other_process_cpu_utilization", sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY sample_time DESC", sql, StringComparison.Ordinal); + Assert.Contains("LIMIT 1", sql, StringComparison.Ordinal); + } + + [Fact] + public void SummaryMemorySql_ReadsLatestTotalServerMemoryAndBufferPool_CastToDouble() + { + var sql = ViewerDataService.ServerSummaryMemorySql; + Assert.Contains("FROM v_memory_stats", sql, StringComparison.Ordinal); + Assert.Contains("CAST(total_server_memory_mb AS double precision)", sql, StringComparison.Ordinal); + Assert.Contains("CAST(buffer_pool_mb AS double precision)", sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY collection_time DESC", sql, StringComparison.Ordinal); + Assert.Contains("LIMIT 1", sql, StringComparison.Ordinal); + } + + [Fact] + public void SummaryMemoryPressureSql_SumsSemaphoreColumns_AtTheLatestCollection() + { + var sql = ViewerDataService.ServerSummaryMemoryPressureSql; + Assert.Contains("FROM v_memory_grant_stats", sql, StringComparison.Ordinal); + Assert.Contains("SUM(waiter_count)", sql, StringComparison.Ordinal); + Assert.Contains("SUM(timeout_error_count_delta)", sql, StringComparison.Ordinal); + Assert.Contains("SUM(forced_grant_count_delta)", sql, StringComparison.Ordinal); + Assert.Contains("SUM(granted_memory_mb)", sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + /* Latest collection instant = MAX(collection_time), summed across every pool at that instant. */ + Assert.Contains("collection_time = (SELECT MAX(collection_time) FROM v_memory_grant_stats WHERE server_id = $1)", sql, StringComparison.Ordinal); + } + + [Fact] + public void SummaryThreadsSql_ReadsLatestSchedulerSnapshot_WorkerPressureColumns() + { + var sql = ViewerDataService.ServerSummaryThreadsSql; + Assert.Contains("FROM v_cpu_scheduler_stats", sql, StringComparison.Ordinal); + Assert.Contains("max_workers_count", sql, StringComparison.Ordinal); + Assert.Contains("total_current_workers_count", sql, StringComparison.Ordinal); + Assert.Contains("total_runnable_tasks_count", sql, StringComparison.Ordinal); + Assert.Contains("total_work_queue_count", sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY collection_time DESC", sql, StringComparison.Ordinal); + Assert.Contains("LIMIT 1", sql, StringComparison.Ordinal); + } + + [Fact] + public void SummaryBlockingSql_CountAndMaxWait_FromBothSources_OverTheWindow() + { + var sql = ViewerDataService.ServerSummaryBlockingSql; + /* Count + worst wait from each source; the caller applies Lite's XE-preferred, DMV-fallback rule. */ + Assert.Contains("COUNT(*)", sql, StringComparison.Ordinal); + Assert.Contains("MAX(wait_time_ms)", sql, StringComparison.Ordinal); + /* Newest event ever (unbounded) for the "Last: N ago" detail when the window is clear. */ + Assert.Contains("MAX(event_time)", sql, StringComparison.Ordinal); + Assert.Contains("FROM v_blocked_process_reports", sql, StringComparison.Ordinal); + Assert.Contains("FROM v_dmv_blocking_snapshots", sql, StringComparison.Ordinal); + Assert.Contains("event_time >= $2", sql, StringComparison.Ordinal); + Assert.Contains("server_id = $1", sql, StringComparison.Ordinal); + } + + [Fact] + public void SummaryDeadlockSql_CountsOverTheWindow_AndNewestEver() + { + var sql = ViewerDataService.ServerSummaryDeadlockSql; + Assert.Contains("FROM v_deadlocks", sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("COUNT(*)", sql, StringComparison.Ordinal); + Assert.Contains("deadlock_time >= $2", sql, StringComparison.Ordinal); + /* Newest deadlock ever (unbounded) for the "Last: N ago" detail. */ + Assert.Contains("MAX(deadlock_time)", sql, StringComparison.Ordinal); + } + + [Fact] + public void SummaryLastCollectionSql_TakesTheNewestCollectionTime() + { + var sql = ViewerDataService.ServerSummaryLastCollectionSql; + Assert.Contains("MAX(collection_time)", sql, StringComparison.Ordinal); + Assert.Contains("FROM v_collection_log", sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + } + + [Fact] + public void SummaryReads_ArePgDialect_PositionalParams_NoBareNow_NoNLiterals() + { + foreach (var sql in new[] + { + ViewerDataService.ServerSummaryCpuSql, + ViewerDataService.ServerSummaryMemorySql, + ViewerDataService.ServerSummaryMemoryPressureSql, + ViewerDataService.ServerSummaryThreadsSql, + ViewerDataService.ServerSummaryBlockingSql, + ViewerDataService.ServerSummaryDeadlockSql, + ViewerDataService.ServerSummaryLastCollectionSql, + }) + { + Assert.DoesNotContain("now(", sql.ToLowerInvariant()); + Assert.DoesNotContain("N'", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + Assert.Contains("$1", sql, StringComparison.Ordinal); + } + } + + [Fact] + public void SummaryReads_ReferenceColumnsThatExistInTheGeneratedSourceTables() + { + var cpu = PgSchemaGenerator.CreateTable(CpuUtilizationCollector.Instance); + Assert.Contains("sqlserver_cpu_utilization", cpu, StringComparison.Ordinal); + Assert.Contains("other_process_cpu_utilization", cpu, StringComparison.Ordinal); + Assert.Contains("sample_time", cpu, StringComparison.Ordinal); + + var memory = PgSchemaGenerator.CreateTable(MemoryStatsCollector.Instance); + Assert.Contains("total_server_memory_mb", memory, StringComparison.Ordinal); + Assert.Contains("buffer_pool_mb", memory, StringComparison.Ordinal); + + /* Enrichment: the Threads row's four scheduler columns + the Memory row's semaphore columns. */ + var scheduler = PgSchemaGenerator.CreateTable(CpuSchedulerStatsCollector.Instance); + Assert.Contains("max_workers_count", scheduler, StringComparison.Ordinal); + Assert.Contains("total_current_workers_count", scheduler, StringComparison.Ordinal); + Assert.Contains("total_runnable_tasks_count", scheduler, StringComparison.Ordinal); + Assert.Contains("total_work_queue_count", scheduler, StringComparison.Ordinal); + + var grants = PgSchemaGenerator.CreateTable(MemoryGrantsCollector.Instance); + Assert.Contains("waiter_count", grants, StringComparison.Ordinal); + Assert.Contains("timeout_error_count_delta", grants, StringComparison.Ordinal); + Assert.Contains("forced_grant_count_delta", grants, StringComparison.Ordinal); + Assert.Contains("granted_memory_mb", grants, StringComparison.Ordinal); + + /* Blocking duration comes from wait_time_ms on both blocking sources. */ + Assert.Contains("wait_time_ms", PgSchemaGenerator.CreateTable(BlockedProcessReportCollector.Instance), StringComparison.Ordinal); + Assert.Contains("wait_time_ms", PgSchemaGenerator.CreateTable(DmvBlockingSnapshotCollector.Instance), StringComparison.Ordinal); + + Assert.Contains("event_time", PgSchemaGenerator.CreateTable(BlockedProcessReportCollector.Instance), StringComparison.Ordinal); + Assert.Contains("event_time", PgSchemaGenerator.CreateTable(DmvBlockingSnapshotCollector.Instance), StringComparison.Ordinal); + Assert.Contains("deadlock_time", PgSchemaGenerator.CreateTable(DeadlocksCollector.Instance), StringComparison.Ordinal); + } + + [Fact] + public void PassthroughViews_ForEverySummarySource_ExistInTheMigrations() + { + var allMigrationSql = string.Concat(PgMigrations.Scripts.Select(m => m.Sql)); + foreach (var view in new[] + { + "v_cpu_utilization_stats", "v_memory_stats", "v_memory_grant_stats", + "v_cpu_scheduler_stats", "v_blocked_process_reports", + "v_dmv_blocking_snapshots", "v_deadlocks", "v_collection_log", + }) + { + Assert.Contains(view, allMigrationSql, StringComparison.Ordinal); + } + } +} + +/// +/// The Overview card view-model's pure display + the viewer's freshness-derived status (#1262). No +/// Postgres: is pure over (last-collection, now), and +/// every display property is a pure format. Verifies the one semantic change — status from collection +/// freshness, not a live ping — maps onto Lite's Online / Warning / Offline card states. +/// +public sealed class ViewerServerSummaryDisplayTests +{ + private static readonly DateTime Now = new(2026, 7, 3, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public void ClassifyFreshness_NoCollection_IsNeverCollected() + { + /* Never-collected is DISTINCT from Offline: during a slow fleet bootstrap a queued server + must not render the red "data stopped" overlay (24-server field incident, 2026-07-17). */ + Assert.Equal(ServerFreshness.NeverCollected, ServerSummaryItem.ClassifyFreshness(null, Now)); + } + + [Theory] + [InlineData(0, ServerFreshness.Fresh)] // just collected + [InlineData(30, ServerFreshness.Fresh)] // within 2x cadence + [InlineData(120, ServerFreshness.Fresh)] // exactly 2x cadence — still fresh + [InlineData(121, ServerFreshness.Stale)] // just past 2x cadence + [InlineData(300, ServerFreshness.Stale)] // 5 min — stale + [InlineData(900, ServerFreshness.Stale)] // exactly 15 min — still stale + [InlineData(901, ServerFreshness.Offline)] // just past 15 min + [InlineData(1200, ServerFreshness.Offline)] // 20 min — offline + public void ClassifyFreshness_BandsByAge(int ageSeconds, ServerFreshness expected) + { + var lastCollection = Now.AddSeconds(-ageSeconds); + Assert.Equal(expected, ServerSummaryItem.ClassifyFreshness(lastCollection, Now)); + } + + [Fact] + public void ApplyFreshness_Fresh_IsOnline() + { + var item = new ServerSummaryItem { LastCollectionTime = Now.AddSeconds(-30) }; + item.ApplyFreshness(Now); + Assert.True(item.IsOnline); + Assert.False(item.HasCollectorErrors); + Assert.False(item.IsOffline); + Assert.Equal("Online", item.StatusDisplay); + } + + [Fact] + public void ApplyFreshness_Stale_IsWarning() + { + var item = new ServerSummaryItem { LastCollectionTime = Now.AddMinutes(-5) }; + item.ApplyFreshness(Now); + Assert.True(item.IsOnline); + Assert.True(item.HasCollectorErrors); + Assert.False(item.IsOffline); + Assert.Equal("Warning", item.StatusDisplay); + } + + [Fact] + public void ApplyFreshness_Offline_ShowsOverlay() + { + var offlineByAge = new ServerSummaryItem { LastCollectionTime = Now.AddMinutes(-20) }; + offlineByAge.ApplyFreshness(Now); + Assert.False(offlineByAge.IsOnline); + Assert.True(offlineByAge.IsOffline); + Assert.Equal("Offline", offlineByAge.StatusDisplay); + + /* No collection EVER is not an outage — it renders as the amber awaiting state. */ + var neverCollected = new ServerSummaryItem { LastCollectionTime = null }; + neverCollected.ApplyFreshness(Now); + Assert.Null(neverCollected.IsOnline); + Assert.False(neverCollected.IsOffline); + Assert.True(neverCollected.AwaitingFirstCollection); + Assert.False(neverCollected.HasCollectorErrors); + Assert.Equal("Awaiting first collection", neverCollected.StatusDisplay); + + /* And a later real collection clears the awaiting state through the same path. */ + neverCollected.LastCollectionTime = Now.AddSeconds(-30); + neverCollected.ApplyFreshness(Now); + Assert.False(neverCollected.AwaitingFirstCollection); + Assert.True(neverCollected.IsOnline); + Assert.Equal("Online", neverCollected.StatusDisplay); + } + + [Theory] + [InlineData(60.0, null, "60%")] // SQL-only when other-process is unavailable + [InlineData(60.0, 15.0, "75% (SQL 60%)")] // total prominently, SQL alongside + public void CpuDisplay_ShowsTotalWithSqlAlongside(double sqlCpu, double? otherCpu, string expected) + { + var item = new ServerSummaryItem { CpuPercent = sqlCpu, OtherProcessCpuPercent = otherCpu }; + Assert.Equal(expected, item.CpuDisplay); + } + + [Fact] + public void CpuDisplay_NoData_IsDashes() + { + Assert.Equal("--", new ServerSummaryItem().CpuDisplay); + } + + [Fact] + public void CpuPercentForAlert_IsAlwaysTotal_TheViewerHasNoCpuAlertMode() + { + var item = new ServerSummaryItem { CpuPercent = 60, OtherProcessCpuPercent = 25 }; + Assert.Equal(85, item.CpuPercentForAlert); + } + + [Theory] + [InlineData(2048.0, "2.0 GB")] + [InlineData(1536.0, "1.5 GB")] + public void MemoryDisplay_FormatsGb(double memoryMb, string expected) + { + Assert.Equal(expected, new ServerSummaryItem { MemoryMb = memoryMb }.MemoryDisplay); + } + + [Fact] + public void MemoryDisplay_NoData_IsDashes() + { + Assert.Equal("--", new ServerSummaryItem().MemoryDisplay); + } + + [Fact] + public void LastCollectionDisplay_TreatsStoredValueAsUtc_ShownLocal() + { + var storedUtc = new DateTime(2026, 7, 3, 3, 30, 45, DateTimeKind.Unspecified); + var expected = DateTime.SpecifyKind(storedUtc, DateTimeKind.Utc).ToLocalTime().ToString("HH:mm:ss"); + Assert.Equal(expected, new ServerSummaryItem { LastCollectionTime = storedUtc }.LastCollectionDisplay); + } + + [Fact] + public void LastCollectionDisplay_NoData_IsNever() + { + Assert.Equal("Never", new ServerSummaryItem().LastCollectionDisplay); + } + + [Fact] + public void CountDisplays_MirrorLite() + { + Assert.Equal("0", new ServerSummaryItem { BlockingCount = 0 }.BlockingDisplay); + Assert.Equal("3", new ServerSummaryItem { BlockingCount = 3 }.BlockingDisplay); + Assert.Equal("0", new ServerSummaryItem { DeadlockCount = 0 }.DeadlockDisplay); + Assert.Equal("2", new ServerSummaryItem { DeadlockCount = 2 }.DeadlockDisplay); + Assert.True(new ServerSummaryItem { DeadlockCount = 1 }.HasAlerts); + Assert.False(new ServerSummaryItem().HasAlerts); + } + + [Fact] + public void CardBorderBrush_RedForDeadlock_DefaultOtherwise() + { + var deadlocked = new ServerSummaryItem { DeadlockCount = 1, LastCollectionTime = Now }; + deadlocked.ApplyFreshness(Now); + Assert.Equal("#FFE57373", deadlocked.CardBorderBrush.Color.ToString()); + + var calm = new ServerSummaryItem { LastCollectionTime = Now }; + calm.ApplyFreshness(Now); + Assert.Equal("#FF2A2D35", calm.CardBorderBrush.Color.ToString()); + } + + // ── Enrichment: per-metric severity bands (verbatim mirrors of ServerHealthStatus) ────────────── + + [Theory] + [InlineData(40.0, HealthSeverity.Healthy)] + [InlineData(79.0, HealthSeverity.Healthy)] + [InlineData(80.0, HealthSeverity.Warning)] // ServerHealthStatus.CpuSeverity: >=80 Warning + [InlineData(94.0, HealthSeverity.Warning)] + [InlineData(95.0, HealthSeverity.Critical)] // >=95 Critical + [InlineData(100.0, HealthSeverity.Critical)] + public void CpuSeverity_BandsOnTotalCpu(double sqlCpu, HealthSeverity expected) + { + Assert.Equal(expected, new ServerSummaryItem { CpuPercent = sqlCpu }.CpuSeverity); + } + + [Fact] + public void CpuSeverity_NoData_IsUnknown() + { + Assert.Equal(HealthSeverity.Unknown, new ServerSummaryItem().CpuSeverity); + } + + [Fact] + public void CpuSeverity_UsesTotalNotSqlOnly() + { + // SQL 60 + other 40 = 100 total → Critical, though SQL alone is only Warning. + Assert.Equal(HealthSeverity.Critical, + new ServerSummaryItem { CpuPercent = 60, OtherProcessCpuPercent = 40 }.CpuSeverity); + } + + [Fact] + public void ThreadsSeverity_NoSnapshot_IsUnknown_DisplayDashes() + { + var item = new ServerSummaryItem(); // TotalThreads null (e.g. Azure SQL DB — collector n/a) + Assert.Equal(HealthSeverity.Unknown, item.ThreadsSeverity); + Assert.Equal("--", item.ThreadsDisplay); + Assert.Equal("", item.ThreadsDetail); + Assert.Null(item.AvailableThreads); + } + + [Fact] + public void ThreadsSeverity_WorkQueueStarvation_IsCritical() + { + // ServerHealthStatus.ThreadsSeverity: requests_waiting_for_threads (work queue) > 0 → Critical + var item = new ServerSummaryItem { TotalThreads = 512, CurrentWorkers = 100, RequestsWaitingForThreads = 3 }; + Assert.Equal(HealthSeverity.Critical, item.ThreadsSeverity); + Assert.Equal("3 starved", item.ThreadsDisplay); + } + + [Fact] + public void ThreadsSeverity_ManyRunnableWaitingForCpu_IsWarning() + { + // >=20 runnable tasks waiting for CPU → Warning + var item = new ServerSummaryItem { TotalThreads = 512, CurrentWorkers = 100, ThreadsWaitingForCpu = 20 }; + Assert.Equal(HealthSeverity.Warning, item.ThreadsSeverity); + Assert.Equal("20 runnable", item.ThreadsDisplay); + } + + [Fact] + public void ThreadsSeverity_LessThanTenPercentAvailable_IsWarning() + { + // available = 512 - 470 = 42 < 51.2 (10% of 512) → Warning, "Low" + var item = new ServerSummaryItem { TotalThreads = 512, CurrentWorkers = 470 }; + Assert.Equal(HealthSeverity.Warning, item.ThreadsSeverity); + Assert.Equal("Low", item.ThreadsDisplay); + Assert.Equal(42, item.AvailableThreads); + } + + [Fact] + public void ThreadsSeverity_HealthyShowsAvailableDetail() + { + var item = new ServerSummaryItem { TotalThreads = 512, CurrentWorkers = 100 }; + Assert.Equal(HealthSeverity.Healthy, item.ThreadsSeverity); + Assert.Equal("OK", item.ThreadsDisplay); + Assert.Equal(412, item.AvailableThreads); + Assert.Equal("Available: 412/512", item.ThreadsDetail); + } + + [Fact] + public void MemorySeverity_NoPressure_IsHealthy_DetailShowsSizes() + { + var item = new ServerSummaryItem { MemoryMb = 8192, BufferPoolMb = 6144, GrantedMemoryMb = 1024 }; + Assert.Equal(HealthSeverity.Healthy, item.MemorySeverity); + Assert.False(item.HasMemoryPressure); + Assert.Equal("BP 6.0, QMG 1.0 GB", item.MemoryDetail); + } + + [Theory] + [InlineData(3, 0, 0, "3 grant waiters")] // ServerHealthStatus: waiter_count > 0 → Critical + [InlineData(1, 0, 0, "1 grant waiter")] + [InlineData(0, 2, 0, "2 timeouts")] // viewer extends to timeouts (collected delta) + [InlineData(0, 0, 1, "1 forced")] // and forced grants + [InlineData(2, 1, 1, "2 grant waiters, 1 timeout, 1 forced")] + public void MemorySeverity_AnyPressure_IsCritical_DetailNamesIt(long waiters, long timeouts, long forced, string expectedDetail) + { + var item = new ServerSummaryItem { MemoryWaiterCount = waiters, MemoryTimeoutCount = timeouts, MemoryForcedCount = forced }; + Assert.Equal(HealthSeverity.Critical, item.MemorySeverity); + Assert.True(item.HasMemoryPressure); + Assert.Equal(expectedDetail, item.MemoryDetail); + } + + [Theory] + [InlineData(0, 0, HealthSeverity.Healthy)] + [InlineData(1, 0, HealthSeverity.Warning)] // any blocking at all → Warning + [InlineData(2, 0, HealthSeverity.Warning)] // >=2 events → Warning + [InlineData(5, 0, HealthSeverity.Critical)] // >=5 events → Critical + [InlineData(1, 10000, HealthSeverity.Warning)] // 10s max wait → Warning + [InlineData(1, 59000, HealthSeverity.Warning)] + [InlineData(1, 60000, HealthSeverity.Critical)] // 60s max wait → Critical + public void BlockingSeverity_BandsOnCountAndDuration(int count, long maxWaitMs, HealthSeverity expected) + { + Assert.Equal(expected, new ServerSummaryItem { BlockingCount = count, MaxBlockingWaitMs = maxWaitMs }.BlockingSeverity); + } + + [Fact] + public void BlockingDetail_MaxWhenBlocked_LastAgoWhenClear_BlankWhenNever() + { + Assert.Equal("max: 42s", new ServerSummaryItem { BlockingCount = 3, MaxBlockingWaitMs = 42000 }.BlockingDetail); + /* Window clear but blocking happened earlier → the Dashboard's "Last: N ago". */ + Assert.Equal("Last: 3h ago", new ServerSummaryItem { BlockingCount = 0, LastBlockingMinutesAgo = 180 }.BlockingDetail); + Assert.Equal("Last: 2d ago", new ServerSummaryItem { BlockingCount = 0, LastBlockingMinutesAgo = 2880 }.BlockingDetail); + /* Never any blocking → blank. */ + Assert.Equal("", new ServerSummaryItem { BlockingCount = 0 }.BlockingDetail); + } + + [Fact] + public void DeadlockDetail_ShowsLastAgo_WhenKnown_BlankWhenNever() + { + Assert.Equal("Last: 5m ago", new ServerSummaryItem { LastDeadlockMinutesAgo = 5 }.DeadlockDetail); + Assert.Equal("Last: just now", new ServerSummaryItem { LastDeadlockMinutesAgo = 0 }.DeadlockDetail); + Assert.Equal("", new ServerSummaryItem().DeadlockDetail); + } + + [Fact] + public void CollectorSeverity_FailingIsWarning_HealthyOtherwise() + { + Assert.Equal(HealthSeverity.Healthy, new ServerSummaryItem { HealthyCollectorCount = 30 }.CollectorSeverity); + var failing = new ServerSummaryItem { HealthyCollectorCount = 28, FailedCollectorCount = 2 }; + Assert.Equal(HealthSeverity.Warning, failing.CollectorSeverity); + Assert.Equal("2 failed", failing.CollectorDisplay); + Assert.Equal("Healthy: 28, Failing: 2", failing.CollectorDetail); + Assert.Equal("OK", new ServerSummaryItem { HealthyCollectorCount = 30 }.CollectorDisplay); + } + + [Fact] + public void DeadlockSeverity_AnyInWindow_IsCritical() + { + Assert.Equal(HealthSeverity.Healthy, new ServerSummaryItem { DeadlockCount = 0 }.DeadlockSeverity); + Assert.Equal(HealthSeverity.Critical, new ServerSummaryItem { DeadlockCount = 1 }.DeadlockSeverity); + } + + [Fact] + public void OverallMetricSeverity_TakesTheWorstBand_UnknownDoesNotEscalate() + { + // Threads Critical dominates even when every other metric is calm/unknown. + var item = new ServerSummaryItem { TotalThreads = 512, CurrentWorkers = 100, RequestsWaitingForThreads = 1 }; + Assert.Equal(HealthSeverity.Critical, item.OverallMetricSeverity); + + // A pure Warning (collectors) with no Critical → Warning. + Assert.Equal(HealthSeverity.Warning, new ServerSummaryItem { FailedCollectorCount = 1 }.OverallMetricSeverity); + + // No data anywhere (CPU Unknown, rest Healthy) → Healthy baseline (Unknown never escalates). + Assert.Equal(HealthSeverity.Healthy, new ServerSummaryItem().OverallMetricSeverity); + } + + [Fact] + public void CardBorderBrush_EnrichedBands_ThreadsCriticalRed_CollectorsWarningOrange() + { + var threadsCritical = new ServerSummaryItem + { + TotalThreads = 512, CurrentWorkers = 100, RequestsWaitingForThreads = 1, LastCollectionTime = Now, + }; + threadsCritical.ApplyFreshness(Now); + Assert.Equal("#FFE57373", threadsCritical.CardBorderBrush.Color.ToString()); + + var collectorsWarning = new ServerSummaryItem { FailedCollectorCount = 1, LastCollectionTime = Now }; + collectorsWarning.ApplyFreshness(Now); + Assert.Equal("#FFFFD54F", collectorsWarning.CardBorderBrush.Color.ToString()); + } + + [Fact] + public void SeverityBrushes_MapBandsToDarkPalette() + { + Assert.Equal("#FF81C784", new ServerSummaryItem { CpuPercent = 10 }.CpuSeverityBrush.Color.ToString()); // Healthy green + Assert.Equal("#FFFFD54F", new ServerSummaryItem { CpuPercent = 85 }.CpuSeverityBrush.Color.ToString()); // Warning amber + Assert.Equal("#FFE57373", new ServerSummaryItem { CpuPercent = 96 }.CpuSeverityBrush.Color.ToString()); // Critical red + Assert.Equal("#FF888888", new ServerSummaryItem().ThreadsSeverityBrush.Color.ToString()); // Unknown gray + } +} + +/// +/// Pins the Alert History parity SQL (W2a): the all-servers read shape, the per-server read carrying the +/// Server column's columns, and the dismiss write path (set-based UPDATE keyed on +/// (alert_time, server_id, metric_name)). No live Postgres. +/// +public sealed class ViewerAlertHistoryW2aSqlTests +{ + [Fact] + public void AllServersSql_ReadsConfigAlertLog_NoServerFilter_NewestFirst_ExcludesDismissed() + { + var sql = ViewerDataService.AlertHistoryAllServersSql; + Assert.Contains("FROM config_alert_log", sql, StringComparison.Ordinal); + Assert.Contains("WHERE alert_time >= $1", sql, StringComparison.Ordinal); + Assert.Contains("dismissed = FALSE", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY alert_time DESC", sql, StringComparison.Ordinal); + Assert.Contains("LIMIT $2", sql, StringComparison.Ordinal); + /* No per-server predicate in the all-servers read (that's the per-server read's $2). */ + Assert.DoesNotContain("server_id = $", sql, StringComparison.Ordinal); + } + + [Fact] + public void BothReads_CarryServerColumns_ForTheServerColumnAndDismissKey() + { + foreach (var sql in new[] { ViewerDataService.AlertHistorySql, ViewerDataService.AlertHistoryAllServersSql }) + { + Assert.Contains("server_id", sql, StringComparison.Ordinal); + Assert.Contains("server_name", sql, StringComparison.Ordinal); + Assert.Contains("metric_name", sql, StringComparison.Ordinal); + Assert.Contains("context_json", sql, StringComparison.Ordinal); + } + + /* Per-server read keeps its scoping predicate + limit. */ + Assert.Contains("server_id = $2", ViewerDataService.AlertHistorySql, StringComparison.Ordinal); + Assert.Contains("LIMIT $3", ViewerDataService.AlertHistorySql, StringComparison.Ordinal); + } + + [Fact] + public void DismissAlertsSql_SetBasedUpdate_KeyedOnTheAlertTuple_GuardsAlreadyDismissed() + { + var sql = ViewerDataService.DismissAlertsSql; + Assert.Contains("UPDATE config_alert_log", sql, StringComparison.Ordinal); + Assert.Contains("SET dismissed = TRUE", sql, StringComparison.Ordinal); + Assert.Contains("dismissed = FALSE", sql, StringComparison.Ordinal); + Assert.Contains("(alert_time, server_id, metric_name) IN (", sql, StringComparison.Ordinal); + /* Batch dismiss via unnested arrays — one round-trip for the whole selection. */ + Assert.Contains("unnest($1::timestamp[], $2::integer[], $3::text[])", sql, StringComparison.Ordinal); + } + + [Fact] + public void DismissAllSql_WindowsThenScopes_GuardsAlreadyDismissed() + { + Assert.Contains("UPDATE config_alert_log", ViewerDataService.DismissAllAlertsSql, StringComparison.Ordinal); + Assert.Contains("alert_time >= $1", ViewerDataService.DismissAllAlertsSql, StringComparison.Ordinal); + Assert.Contains("dismissed = FALSE", ViewerDataService.DismissAllAlertsSql, StringComparison.Ordinal); + Assert.DoesNotContain("server_id", ViewerDataService.DismissAllAlertsSql, StringComparison.Ordinal); + + Assert.Contains("alert_time >= $1", ViewerDataService.DismissAllAlertsForServerSql, StringComparison.Ordinal); + Assert.Contains("server_id = $2", ViewerDataService.DismissAllAlertsForServerSql, StringComparison.Ordinal); + Assert.Contains("dismissed = FALSE", ViewerDataService.DismissAllAlertsForServerSql, StringComparison.Ordinal); + } + + [Fact] + public void AllW2aAlertSql_ArePgDialect_NoBareNow_NoNLiterals() + { + foreach (var sql in new[] + { + ViewerDataService.AlertHistoryAllServersSql, + ViewerDataService.DismissAlertsSql, + ViewerDataService.DismissAllAlertsSql, + ViewerDataService.DismissAllAlertsForServerSql, + }) + { + Assert.DoesNotContain("now(", sql.ToLowerInvariant()); + Assert.DoesNotContain("N'", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + Assert.Contains("$1", sql, StringComparison.Ordinal); + } + } +} + +/// +/// Gated (DARLING_TEST_PG) live round-trips for W2a: the Overview server-summary read against planted +/// metric rows (including the XE→DMV blocking fallback and the freshness-derived status), and the alert +/// dismiss write path (dismissed rows drop out of the default read; the all-servers read spans servers). +/// Shares the serialized "live-postgres" collection; negative sentinel server_ids, cleaned up in finally. +/// +[Collection("live-postgres")] +public sealed class ViewerW2aLivePostgresTests +{ + private const int SummaryServerId = -929201; + private const int FallbackServerId = -929202; + private const int DismissServerA = -929203; + private const int DismissServerB = -929204; + private const int EnrichServerId = -929205; + private const string ServerName = "viewer-w2a-e2e"; + + [Fact] + public async Task ServerSummary_ReadsPlantedMetrics_AndDerivesOnlineFromFreshCollection_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live server-summary test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteSummaryRowsAsync(connection, SummaryServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var now = TruncateToSeconds(DateTime.UtcNow); + var withinHour = now.AddMinutes(-20); + + /* Two CPU samples; the newest (sample_time later) wins. */ + await InsertCpuAsync(connection, SummaryServerId, now.AddMinutes(-2), sampleTime: now.AddMinutes(-2), sqlCpu: 10, otherCpu: 2); + await InsertCpuAsync(connection, SummaryServerId, now.AddMinutes(-1), sampleTime: now.AddMinutes(-1), sqlCpu: 42, otherCpu: 8); + + /* Two memory rows; the newest collection_time wins. */ + await InsertMemoryAsync(connection, SummaryServerId, now.AddMinutes(-2), totalServerMemoryMb: 4096.00m); + await InsertMemoryAsync(connection, SummaryServerId, now.AddMinutes(-1), totalServerMemoryMb: 8192.00m); + + /* One blocked-process report + one deadlock inside the one-hour window. */ + await InsertBlockedProcessAsync(connection, SummaryServerId, collectionTime: withinHour, eventTime: withinHour); + await InsertDeadlockAsync(connection, SummaryServerId, collectionTime: withinHour, deadlockTime: withinHour); + + /* A fresh collection_log row → freshness = Online. */ + await InsertCollectionLogAsync(connection, SummaryServerId, "cpu_utilization", now, "SUCCESS"); + + var summary = await viewer.GetServerSummaryAsync(SummaryServerId, "Summary E2E"); + summary.ServerName = ServerName; + summary.ApplyFreshness(DateTime.UtcNow); + + Assert.Equal(42.0, summary.CpuPercent); + Assert.Equal(8.0, summary.OtherProcessCpuPercent); + Assert.Equal(50.0, summary.TotalCpuPercent); + Assert.Equal(8192.0, summary.MemoryMb!.Value, precision: 1); + Assert.Equal(1, summary.BlockingCount); // XE report count + Assert.Equal(1, summary.DeadlockCount); + Assert.NotNull(summary.LastCollectionTime); + Assert.True(summary.IsOnline); // fresh collection + Assert.Equal("Online", summary.StatusDisplay); + } + finally + { + await DeleteSummaryRowsAsync(connection, SummaryServerId); + } + } + + [Fact] + public async Task ServerSummary_BlockingCount_FallsBackToDmvSnapshot_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live blocking-fallback test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteSummaryRowsAsync(connection, FallbackServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var now = TruncateToSeconds(DateTime.UtcNow); + var withinHour = now.AddMinutes(-15); + + /* No XE blocked-process reports; two DMV snapshots → COALESCE(NULLIF(0,0), dmv) = 2. */ + await InsertDmvBlockingAsync(connection, FallbackServerId, collectionTime: withinHour, eventTime: withinHour); + await InsertDmvBlockingAsync(connection, FallbackServerId, collectionTime: withinHour, eventTime: withinHour); + + var summary = await viewer.GetServerSummaryAsync(FallbackServerId, "Fallback E2E"); + + Assert.Equal(2, summary.BlockingCount); + Assert.Equal(0, summary.DeadlockCount); + } + finally + { + await DeleteSummaryRowsAsync(connection, FallbackServerId); + } + } + + [Fact] + public async Task ServerSummary_ReadsEnrichedThreadsMemoryBlockingCollectors_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live enrichment test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteEnrichmentRowsAsync(connection, EnrichServerId); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var now = TruncateToSeconds(DateTime.UtcNow); + var latest = now.AddMinutes(-1); + var older = now.AddMinutes(-2); + var withinHour = now.AddMinutes(-20); + + /* Memory: total + buffer pool. */ + await InsertMemoryWithBufferPoolAsync(connection, EnrichServerId, now, totalServerMemoryMb: 8192.00m, bufferPoolMb: 6144.00m); + + /* Resource semaphore: an older heavy sample (must be IGNORED) + the latest two-pool sample (summed). */ + await InsertGrantAsync(connection, EnrichServerId, older, poolId: 1, grantedMb: 4096m, waiter: 99, timeoutDelta: 0, forcedDelta: 0); + await InsertGrantAsync(connection, EnrichServerId, latest, poolId: 1, grantedMb: 512m, waiter: 1, timeoutDelta: 0, forcedDelta: 0); + await InsertGrantAsync(connection, EnrichServerId, latest, poolId: 2, grantedMb: 512m, waiter: 2, timeoutDelta: 0, forcedDelta: 0); + + /* Scheduler snapshot: 512 ceiling, 128 in use → 384 available; 5 runnable, no work-queue starvation. */ + await InsertSchedulerAsync(connection, EnrichServerId, now, runnable: 5); + + /* Two XE blocked-process reports in the window; the worst wait is 42s. */ + await InsertBlockedProcessWithWaitAsync(connection, EnrichServerId, withinHour, waitTimeMs: 5000); + await InsertBlockedProcessWithWaitAsync(connection, EnrichServerId, withinHour, waitTimeMs: 42000); + + /* Collection health: one HEALTHY collector (recent success) + one FAILING (error, never succeeded). */ + await InsertCollectionLogAsync(connection, EnrichServerId, "cpu_utilization", now, "SUCCESS"); + await InsertCollectionLogAsync(connection, EnrichServerId, "memory_stats", now, "ERROR"); + + var summary = await viewer.GetServerSummaryAsync(EnrichServerId, "Enrich E2E"); + + /* Threads — the latest scheduler snapshot (available = ceiling − in-use). */ + Assert.Equal(512, summary.TotalThreads); + Assert.Equal(384, summary.AvailableThreads); + Assert.Equal(5, summary.ThreadsWaitingForCpu); + Assert.Equal(0, summary.RequestsWaitingForThreads); + Assert.Equal(HealthSeverity.Healthy, summary.ThreadsSeverity); + Assert.Equal("Available: 384/512", summary.ThreadsDetail); + + /* Memory — total + buffer pool + resource-semaphore pressure summed at the LATEST collection only. */ + Assert.Equal(8192.0, summary.MemoryMb!.Value, precision: 1); + Assert.Equal(6144.0, summary.BufferPoolMb!.Value, precision: 1); + Assert.Equal(3, summary.MemoryWaiterCount); // 1 + 2 at latest; the older 99 is ignored + Assert.Equal(1024.0, summary.GrantedMemoryMb!.Value, precision: 1); + Assert.True(summary.HasMemoryPressure); + Assert.Equal(HealthSeverity.Critical, summary.MemorySeverity); + + /* Blocking — count + worst wait, both from the XE source; last-event read populated. */ + Assert.Equal(2, summary.BlockingCount); + Assert.Equal(42000, summary.MaxBlockingWaitMs); + Assert.Equal("max: 42s", summary.BlockingDetail); + Assert.Equal(HealthSeverity.Warning, summary.BlockingSeverity); + Assert.NotNull(summary.LastBlockingMinutesAgo); + + /* Collectors — REUSE of the 7-day banding (one HEALTHY, one FAILING). */ + Assert.Equal(1, summary.HealthyCollectorCount); + Assert.Equal(1, summary.FailedCollectorCount); + Assert.Equal(HealthSeverity.Warning, summary.CollectorSeverity); + } + finally + { + await DeleteEnrichmentRowsAsync(connection, EnrichServerId); + } + } + + [Fact] + public async Task Dismiss_HidesRowsFromTheDefaultRead_AllServersSpansServers_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live dismiss test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteAlertRowsAsync(connection); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var now = TruncateToSeconds(DateTime.UtcNow); + var since = now.AddHours(-1); + + /* Three visible rows on server A (distinct alert_times so a single dismiss targets one), + one on server B. */ + await InsertAlertAsync(connection, now, DismissServerA, "server-a", "High CPU"); + await InsertAlertAsync(connection, now.AddMinutes(-1), DismissServerA, "server-a", "Deadlocks Detected"); + await InsertAlertAsync(connection, now.AddMinutes(-2), DismissServerA, "server-a", "Blocking Detected"); + await InsertAlertAsync(connection, now.AddMinutes(-1), DismissServerB, "server-b", "High CPU"); + + /* All-servers read spans both servers. */ + var all = await viewer.GetAlertHistoryAsync(since); + Assert.Equal(4, all.Count(r => r.ServerId is DismissServerA or DismissServerB)); + Assert.Contains(all, r => r.ServerId == DismissServerB); + + /* Per-server read scopes to A. */ + var aRows = await viewer.GetAlertHistoryAsync(since, DismissServerA); + Assert.Equal(3, aRows.Count); + + /* Dismiss one A row → it drops out; the other two remain. */ + var target = aRows.Single(r => r.MetricName == "Deadlocks Detected"); + var dismissedOne = await viewer.DismissAlertsAsync(new[] { target }); + Assert.Equal(1, dismissedOne); + + var aAfterOne = await viewer.GetAlertHistoryAsync(since, DismissServerA); + Assert.Equal(2, aAfterOne.Count); + Assert.DoesNotContain(aAfterOne, r => r.MetricName == "Deadlocks Detected"); + + /* Dismissing the same row again is a no-op (the dismissed = FALSE guard). */ + Assert.Equal(0, await viewer.DismissAlertsAsync(new[] { target })); + + /* Dismiss all remaining A rows → A empties, B untouched. */ + var dismissedAll = await viewer.DismissAllVisibleAlertsAsync(since, DismissServerA); + Assert.Equal(2, dismissedAll); + Assert.Empty(await viewer.GetAlertHistoryAsync(since, DismissServerA)); + Assert.Single(await viewer.GetAlertHistoryAsync(since, DismissServerB)); + } + finally + { + await DeleteAlertRowsAsync(connection); + } + } + + // ── planting helpers ───────────────────────────────────────────────────────────── + + private static async Task InsertCpuAsync( + NpgsqlConnection connection, int serverId, DateTime collectionTime, DateTime sampleTime, int sqlCpu, int otherCpu) + { + using var command = new NpgsqlCommand( + "INSERT INTO cpu_utilization_stats (collection_id, collection_time, server_id, server_name, sample_time, sqlserver_cpu_utilization, other_process_cpu_utilization) VALUES ($1, $2, $3, $4, $5, $6, $7)", + connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTime, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(DateTime.SpecifyKind(sampleTime, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(sqlCpu); + command.Parameters.AddWithValue(otherCpu); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertMemoryAsync( + NpgsqlConnection connection, int serverId, DateTime collectionTime, decimal totalServerMemoryMb) + { + using var command = new NpgsqlCommand( + "INSERT INTO memory_stats (collection_id, collection_time, server_id, server_name, total_server_memory_mb) VALUES ($1, $2, $3, $4, $5)", + connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTime, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(totalServerMemoryMb); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertBlockedProcessAsync( + NpgsqlConnection connection, int serverId, DateTime collectionTime, DateTime eventTime) + { + using var command = new NpgsqlCommand( + "INSERT INTO blocked_process_reports (blocked_report_id, collection_time, server_id, server_name, event_time) VALUES ($1, $2, $3, $4, $5)", + connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTime, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(DateTime.SpecifyKind(eventTime, DateTimeKind.Unspecified)); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertDmvBlockingAsync( + NpgsqlConnection connection, int serverId, DateTime collectionTime, DateTime eventTime) + { + using var command = new NpgsqlCommand( + "INSERT INTO dmv_blocking_snapshots (collection_id, collection_time, server_id, server_name, event_time) VALUES ($1, $2, $3, $4, $5)", + connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTime, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(DateTime.SpecifyKind(eventTime, DateTimeKind.Unspecified)); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertDeadlockAsync( + NpgsqlConnection connection, int serverId, DateTime collectionTime, DateTime deadlockTime) + { + using var command = new NpgsqlCommand( + "INSERT INTO deadlocks (deadlock_id, collection_time, server_id, server_name, deadlock_time) VALUES ($1, $2, $3, $4, $5)", + connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTime, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(DateTime.SpecifyKind(deadlockTime, DateTimeKind.Unspecified)); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertCollectionLogAsync( + NpgsqlConnection connection, int serverId, string collectorName, DateTime collectionTime, string status) + { + using var command = new NpgsqlCommand( + "INSERT INTO collection_log (log_id, server_id, server_name, collector_name, collection_time, status) VALUES ($1, $2, $3, $4, $5, $6)", + connection); + command.Parameters.AddWithValue(CollectionIdGenerator.Next()); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(collectorName); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTime, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(status); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertMemoryWithBufferPoolAsync( + NpgsqlConnection connection, int serverId, DateTime collectionTime, decimal totalServerMemoryMb, decimal bufferPoolMb) + { + using var command = new NpgsqlCommand( + "INSERT INTO memory_stats (collection_id, collection_time, server_id, server_name, total_server_memory_mb, buffer_pool_mb) VALUES ($1, $2, $3, $4, $5, $6)", + connection); + command.Parameters.AddWithValue(1L); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTime, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(totalServerMemoryMb); + command.Parameters.AddWithValue(bufferPoolMb); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertGrantAsync( + NpgsqlConnection connection, int serverId, DateTime collectionTime, int poolId, decimal grantedMb, int waiter, long timeoutDelta, long forcedDelta) + { + using var command = new NpgsqlCommand(@" +INSERT INTO memory_grant_stats + (collection_id, collection_time, server_id, server_name, pool_id, + available_memory_mb, granted_memory_mb, used_memory_mb, + grantee_count, waiter_count, timeout_error_count_delta, forced_grant_count_delta) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", connection); + command.Parameters.AddWithValue(CollectionIdGenerator.Next()); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTime, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(poolId); + command.Parameters.AddWithValue(1000m); // available_memory_mb + command.Parameters.AddWithValue(grantedMb); + command.Parameters.AddWithValue(500m); // used_memory_mb + command.Parameters.AddWithValue(1); // grantee_count + command.Parameters.AddWithValue(waiter); + command.Parameters.AddWithValue(timeoutDelta); + command.Parameters.AddWithValue(forcedDelta); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertSchedulerAsync( + NpgsqlConnection connection, int serverId, DateTime collectionTime, int runnable) + { + using var command = new NpgsqlCommand(@" +INSERT INTO cpu_scheduler_stats + (collection_id, collection_time, server_id, server_name, + max_workers_count, scheduler_count, cpu_count, + total_runnable_tasks_count, total_work_queue_count, total_current_workers_count, + avg_runnable_tasks_count, total_active_request_count, total_queued_request_count, + total_blocked_task_count, total_active_parallel_thread_count, + runnable_request_count, total_request_count, runnable_percent, + worker_thread_exhaustion_warning, runnable_tasks_warning, blocked_tasks_warning, + queued_requests_warning, total_physical_memory_kb, available_physical_memory_kb, + system_memory_state_desc, physical_memory_pressure_warning, + total_node_count, nodes_online_count, offline_cpu_count, offline_cpu_warning) +VALUES ($1, $2, $3, $4, 512, 8, 8, $5, 0, 128, 0.5, 2, 0, 0, 0, 3, 20, 12.5, + false, false, false, false, 67108864, 8388608, 'ok', false, 2, 2, 0, false)", connection); + command.Parameters.AddWithValue(CollectionIdGenerator.Next()); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTime, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(runnable); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertBlockedProcessWithWaitAsync( + NpgsqlConnection connection, int serverId, DateTime eventTime, long waitTimeMs) + { + using var command = new NpgsqlCommand( + "INSERT INTO blocked_process_reports (blocked_report_id, collection_time, server_id, server_name, event_time, wait_time_ms) VALUES ($1, $2, $3, $4, $5, $6)", + connection); + command.Parameters.AddWithValue(CollectionIdGenerator.Next()); + command.Parameters.AddWithValue(DateTime.SpecifyKind(eventTime, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(DateTime.SpecifyKind(eventTime, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(waitTimeMs); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task InsertAlertAsync( + NpgsqlConnection connection, DateTime alertTimeUtc, int serverId, string serverName, string metric) + { + using var command = new NpgsqlCommand(@" +INSERT INTO config_alert_log + (alert_time, server_id, server_name, metric_name, current_value, threshold_value, + alert_sent, notification_type, send_error, dismissed, muted, detail_text, context_json) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)", connection); + command.Parameters.AddWithValue(DateTime.SpecifyKind(alertTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(serverName); + command.Parameters.AddWithValue(metric); + command.Parameters.AddWithValue(1.0); + command.Parameters.AddWithValue(1.0); + command.Parameters.AddWithValue(false); + command.Parameters.AddWithValue("tray"); + command.Parameters.Add(new NpgsqlParameter { Value = DBNull.Value, NpgsqlDbType = NpgsqlDbType.Text }); + command.Parameters.AddWithValue(false); + command.Parameters.AddWithValue(false); + command.Parameters.Add(new NpgsqlParameter { Value = DBNull.Value, NpgsqlDbType = NpgsqlDbType.Text }); + command.Parameters.Add(new NpgsqlParameter { Value = DBNull.Value, NpgsqlDbType = NpgsqlDbType.Text }); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static DateTime TruncateToSeconds(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified); + + private static async Task DeleteSummaryRowsAsync(NpgsqlConnection connection, int serverId) + { + foreach (var table in new[] + { + "cpu_utilization_stats", "memory_stats", "blocked_process_reports", + "dmv_blocking_snapshots", "deadlocks", "collection_log", + }) + { + using var cleanup = new NpgsqlCommand($"DELETE FROM {table} WHERE server_id = {serverId};", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + } + + private static async Task DeleteEnrichmentRowsAsync(NpgsqlConnection connection, int serverId) + { + foreach (var table in new[] + { + "memory_stats", "memory_grant_stats", "cpu_scheduler_stats", + "blocked_process_reports", "collection_log", + }) + { + using var cleanup = new NpgsqlCommand($"DELETE FROM {table} WHERE server_id = {serverId};", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + } + + private static async Task DeleteAlertRowsAsync(NpgsqlConnection connection) + { + using var cleanup = new NpgsqlCommand( + $"DELETE FROM config_alert_log WHERE server_id IN ({DismissServerA}, {DismissServerB});", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } +} diff --git a/Darling/Darling.Tests/ViewerWaitStatsTests.cs b/Darling/Darling.Tests/ViewerWaitStatsTests.cs new file mode 100644 index 000000000..4a1b12842 --- /dev/null +++ b/Darling/Darling.Tests/ViewerWaitStatsTests.cs @@ -0,0 +1,341 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +using PerformanceMonitor.Ui; + +namespace Darling.Tests; + +/// +/// Pins the Wait Stats tab's two ported reads against the Darling store contract (no live Postgres): +/// the distinct-wait-types population read and the batched per-type trend read, both copied from +/// Lite's LocalDataService.WaitStats.cs and run VERBATIM on the v_wait_stats passthrough view. +/// The trend keeps Lite's per-type LAG per-second math and the dynamic IN ($4, $5, ...) +/// list; the population read drops Lite's per-user ignored-wait exclusion clause (no viewer equivalent). +/// +public sealed class ViewerWaitStatsSqlTests +{ + [Fact] + public void DistinctWaitTypesSql_RanksEveryTypeByTotalDelta_OverTheWindow() + { + Assert.Contains("FROM v_wait_stats", ViewerDataService.DistinctWaitTypesSql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", ViewerDataService.DistinctWaitTypesSql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $2", ViewerDataService.DistinctWaitTypesSql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $3", ViewerDataService.DistinctWaitTypesSql, StringComparison.Ordinal); + Assert.Contains("GROUP BY wait_type", ViewerDataService.DistinctWaitTypesSql, StringComparison.Ordinal); + Assert.Contains("ORDER BY SUM(delta_wait_time_ms) DESC", ViewerDataService.DistinctWaitTypesSql, StringComparison.Ordinal); + } + + [Fact] + public void DistinctWaitTypesSql_DropsLitesIgnoredWaitExclusionClause() + { + /* Lite splices an "AND wait_type NOT IN (...)" ignored-wait exclusion; the viewer has no + per-user ignore config, so the ported read must carry no NOT IN clause. */ + Assert.DoesNotContain("NOT IN", ViewerDataService.DistinctWaitTypesSql, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void WaitTrendsSql_KeepsLitesPerTypeLagPerSecondMath() + { + var sql = ViewerDataService.WaitTrendsSql(3); + + Assert.Contains("WITH raw AS", sql, StringComparison.Ordinal); + Assert.Contains("FROM v_wait_stats", sql, StringComparison.Ordinal); + /* The LAG window is partitioned per wait_type so each type's per-second rate is independent. */ + Assert.Contains("LAG(collection_time) OVER (PARTITION BY wait_type ORDER BY collection_time)", sql, StringComparison.Ordinal); + /* The truncate-then-diff epoch idiom proven value-identical between DuckDB and Postgres. */ + Assert.Contains("extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time)", sql, StringComparison.Ordinal); + /* The three metric expressions: per-second wait, per-second signal, avg ms per wait. */ + Assert.Contains("CAST(delta_wait_time_ms AS DOUBLE PRECISION) / interval_seconds", sql, StringComparison.Ordinal); + Assert.Contains("CAST(delta_signal_wait_time_ms AS DOUBLE PRECISION) / interval_seconds", sql, StringComparison.Ordinal); + Assert.Contains("CAST(delta_wait_time_ms AS DOUBLE PRECISION) / delta_waiting_tasks", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY wait_type, collection_time", sql, StringComparison.Ordinal); + } + + [Theory] + [InlineData(1, "$4", "IN ($4)")] + [InlineData(3, "$6", "IN ($4, $5, $6)")] + public void WaitTrendsSql_BuildsDynamicInListFromFour(int count, string highestParam, string inClause) + { + var sql = ViewerDataService.WaitTrendsSql(count); + /* server_id/start/end take $1/$2/$3; the wait-type IN list starts at $4 (Lite's numbering). */ + Assert.Contains("server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $2", sql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $3", sql, StringComparison.Ordinal); + Assert.Contains(inClause, sql, StringComparison.Ordinal); + Assert.Contains(highestParam, sql, StringComparison.Ordinal); + } + + [Fact] + public void WaitStatsSql_PgDialect_PositionalParams_NoBareNow_NoNLiterals() + { + foreach (var sql in new[] { ViewerDataService.DistinctWaitTypesSql, ViewerDataService.WaitTrendsSql(2) }) + { + Assert.DoesNotContain("now(", sql.ToLowerInvariant()); + Assert.DoesNotContain("N'", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@", sql, StringComparison.Ordinal); + Assert.Contains("$1", sql, StringComparison.Ordinal); + Assert.Contains("$2", sql, StringComparison.Ordinal); + Assert.Contains("$3", sql, StringComparison.Ordinal); + } + } + + [Fact] + public void WaitStatsSql_ReadsColumnsThatExistInTheGeneratedWaitTable() + { + Assert.Equal("wait_stats", WaitStatsCollector.Instance.TargetTable); + + var ddl = PgSchemaGenerator.CreateTable(WaitStatsCollector.Instance); + foreach (var column in new[] { "collection_time", "wait_type", "delta_wait_time_ms", "delta_signal_wait_time_ms", "delta_waiting_tasks" }) + { + Assert.Contains(column, ddl, StringComparison.Ordinal); + } + } +} + +/// +/// Pins the picker logic copied from Lite's ServerTab.Pickers.cs: the default-selection composition +/// (poison + usual-suspect + PAGELATCH_ prefix, presence-gated), the fill cap, and the +/// clear-only-the-filtered-visible-set semantics. +/// is the real (internal) method; the filter/clear pin is a behavior spec mirroring the inline +/// (WPF-bound, not headlessly invokable) ApplyWaitTypeFilter + WaitTypeClearAll_Click. +/// +public sealed class ViewerWaitPickerLogicTests +{ + private static readonly string[] Fixed = + { + "THREADPOOL", "RESOURCE_SEMAPHORE", "RESOURCE_SEMAPHORE_QUERY_COMPILE", // poison + "SOS_SCHEDULER_YIELD", "CXPACKET", "CXCONSUMER", "PAGEIOLATCH_SH", "PAGEIOLATCH_EX", "WRITELOG", // usual suspects + }; + + [Fact] + public void GetDefaultWaitTypes_IncludesPoisonUsualSuspectsAndPagelatchPrefix_WhenPresent() + { + var available = new List(Fixed) { "PAGELATCH_UP", "PAGELATCH_EX", "SLEEP_TASK", "BROKER_RECEIVE_WAITFOR" }; + + var defaults = ViewerServerTab.GetDefaultWaitTypes(available); + + foreach (var w in Fixed) + { + Assert.Contains(w, defaults); + } + Assert.Contains("PAGELATCH_UP", defaults); + Assert.Contains("PAGELATCH_EX", defaults); + } + + [Fact] + public void GetDefaultWaitTypes_DoesNotAddAbsentPoisonOrUsualSuspects() + { + /* Only THREADPOOL + CXPACKET are actually collected; the rest of the fixed set must not appear. */ + var available = new List { "THREADPOOL", "CXPACKET", "SLEEP_TASK" }; + + var defaults = ViewerServerTab.GetDefaultWaitTypes(available); + + Assert.Contains("THREADPOOL", defaults); + Assert.Contains("CXPACKET", defaults); + Assert.DoesNotContain("RESOURCE_SEMAPHORE", defaults); + Assert.DoesNotContain("WRITELOG", defaults); + /* SLEEP_TASK rides in only as fill (there is room), which is fine — the point is absent + fixed types stay out. */ + } + + [Fact] + public void GetDefaultWaitTypes_FillAddsAtMostTenNonFixedTypes() + { + /* 9 fixed present, no PAGELATCH_ prefix, plus 30 "other" types — the fill loop stops at 10 + added, so the result is 9 + 10 = 19 and never sweeps in all 30 others. */ + var available = new List(Fixed); + available.AddRange(Enumerable.Range(0, 30).Select(i => $"OTHER_{i:D2}")); + + var defaults = ViewerServerTab.GetDefaultWaitTypes(available); + + Assert.Equal(19, defaults.Count); + var others = defaults.Where(d => d.StartsWith("OTHER_", StringComparison.Ordinal)).ToList(); + Assert.Equal(10, others.Count); + } + + [Fact] + public void GetDefaultWaitTypes_FillRespectsThirtyCap_WhenPrefixSetAlreadyLarge() + { + /* 3 poison + 6 usual + 20 PAGELATCH_ = 29 before fill; the fill's ">= 30" guard lets exactly + one more in, then stops — so a huge PAGELATCH_ family can't run the default set away. */ + var available = new List(Fixed); + available.AddRange(Enumerable.Range(0, 20).Select(i => $"PAGELATCH_{i:D2}")); + available.AddRange(Enumerable.Range(0, 5).Select(i => $"OTHER_{i:D2}")); + + var defaults = ViewerServerTab.GetDefaultWaitTypes(available); + + Assert.Equal(30, defaults.Count); + Assert.Equal(1, defaults.Count(d => d.StartsWith("OTHER_", StringComparison.Ordinal))); + } + + [Fact] + public void ClearAll_ClearsOnlyTheFilteredVisibleSet_LeavingHiddenSelectionsIntact() + { + /* Behavior spec mirroring ApplyWaitTypeFilter's case-insensitive Contains filter + + WaitTypeClearAll_Click clearing only the currently-visible (filtered) items. */ + var items = new List + { + new() { DisplayName = "CXPACKET", IsSelected = true }, + new() { DisplayName = "PAGEIOLATCH_SH", IsSelected = true }, + new() { DisplayName = "PAGEIOLATCH_EX", IsSelected = true }, + new() { DisplayName = "WRITELOG", IsSelected = true }, + }; + + const string search = "pageio"; + var visible = items.Where(i => i.DisplayName.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList(); + foreach (var item in visible) + { + item.IsSelected = false; + } + + /* Only the two PAGEIOLATCH_* (the filtered set) are cleared; CXPACKET and WRITELOG persist. */ + Assert.True(items.Single(i => i.DisplayName == "CXPACKET").IsSelected); + Assert.True(items.Single(i => i.DisplayName == "WRITELOG").IsSelected); + Assert.False(items.Single(i => i.DisplayName == "PAGEIOLATCH_SH").IsSelected); + Assert.False(items.Single(i => i.DisplayName == "PAGEIOLATCH_EX").IsSelected); + } +} + +/// +/// Gated (DARLING_TEST_PG) live round-trips for the two Wait Stats reads: the distinct-types ranking +/// and the batched per-type trend (per-second wait rate + avg ms per wait computed in SQL via the +/// per-type LAG window). Shares the serialized "live-postgres" collection; uses a negative sentinel +/// server_id and cleans up in finally. +/// +[Collection("live-postgres")] +public sealed class ViewerWaitStatsLivePostgresTests +{ + private const int WaitServerId = -949494; + private const string WaitServerName = "viewer-waitstats-e2e"; + + [Fact] + public async Task DistinctWaitTypes_RanksByTotalDeltaDescending_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live distinct-wait-types test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteWaitRowsAsync(connection); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var t1 = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + var t2 = t1.AddMinutes(5); + + /* CXPACKET total delta = 100 + 600 = 700; WRITELOG total = 50 → CXPACKET ranks first. */ + await InsertWaitRowAsync(connection, 1, t1, "CXPACKET", deltaWait: 100, deltaSignal: 10, deltaTasks: 5); + await InsertWaitRowAsync(connection, 2, t2, "CXPACKET", deltaWait: 600, deltaSignal: 60, deltaTasks: 3); + await InsertWaitRowAsync(connection, 1, t1, "WRITELOG", deltaWait: 50, deltaSignal: 5, deltaTasks: 2); + + var types = await viewer.GetDistinctWaitTypesAsync(WaitServerId, t1.AddMinutes(-1), t2.AddMinutes(1)); + + Assert.Equal(new[] { "CXPACKET", "WRITELOG" }, types); + } + finally + { + await DeleteWaitRowsAsync(connection); + } + } + + [Fact] + public async Task WaitTrends_ComputePerSecondAndAvgPerWait_ByType_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live wait-trend test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteWaitRowsAsync(connection); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var t1 = TruncateToSeconds(DateTime.UtcNow.AddMinutes(-10)); + var t2 = t1.AddMinutes(5); // 300 seconds later + + /* CXPACKET across two collections; WRITELOG is a second type that must NOT appear when + only CXPACKET is requested (the IN filter). */ + await InsertWaitRowAsync(connection, 1, t1, "CXPACKET", deltaWait: 100, deltaSignal: 10, deltaTasks: 5); + await InsertWaitRowAsync(connection, 2, t2, "CXPACKET", deltaWait: 600, deltaSignal: 60, deltaTasks: 3); + await InsertWaitRowAsync(connection, 2, t2, "WRITELOG", deltaWait: 900, deltaSignal: 90, deltaTasks: 9); + + var trends = await viewer.GetWaitStatsTrendsByTypesAsync( + WaitServerId, new List { "CXPACKET" }, t1.AddMinutes(-1), t2.AddMinutes(1)); + + Assert.True(trends.ContainsKey("CXPACKET")); + Assert.False(trends.ContainsKey("WRITELOG")); // the IN filter excluded it + + var cx = trends["CXPACKET"]; + Assert.Equal(2, cx.Count); + + /* First point: no prior collection → per-second 0; avg = 100/5 = 20. */ + Assert.Equal(t1.Ticks, cx[0].CollectionTime.Ticks); + Assert.Equal(0.0, cx[0].WaitTimeMsPerSecond, precision: 3); + Assert.Equal(20.0, cx[0].AvgMsPerWait, precision: 3); + + /* Second point: 600 ms over 300 s = 2.0 ms/sec; signal 60/300 = 0.2; avg 600/3 = 200. */ + Assert.Equal(t2.Ticks, cx[1].CollectionTime.Ticks); + Assert.Equal(2.0, cx[1].WaitTimeMsPerSecond, precision: 3); + Assert.Equal(0.2, cx[1].SignalWaitTimeMsPerSecond, precision: 3); + Assert.Equal(200.0, cx[1].AvgMsPerWait, precision: 3); + } + finally + { + await DeleteWaitRowsAsync(connection); + } + } + + private static async Task InsertWaitRowAsync( + NpgsqlConnection connection, long collectionId, DateTime collectionTimeUtc, + string waitType, long deltaWait, long deltaSignal, long deltaTasks) + { + using var command = new NpgsqlCommand(@" +INSERT INTO wait_stats + (collection_id, collection_time, server_id, server_name, wait_type, + waiting_tasks_count, wait_time_ms, signal_wait_time_ms, + delta_waiting_tasks, delta_wait_time_ms, delta_signal_wait_time_ms) +VALUES ($1, $2, $3, $4, $5, 0, 0, 0, $6, $7, $8)", connection); + command.Parameters.AddWithValue(collectionId); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(WaitServerId); + command.Parameters.AddWithValue(WaitServerName); + command.Parameters.AddWithValue(waitType); + command.Parameters.AddWithValue(deltaTasks); + command.Parameters.AddWithValue(deltaWait); + command.Parameters.AddWithValue(deltaSignal); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static DateTime TruncateToSeconds(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified); + + private static async Task DeleteWaitRowsAsync(NpgsqlConnection connection) + { + using var cleanup = new NpgsqlCommand( + $"DELETE FROM wait_stats WHERE server_id = {WaitServerId};", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } +} diff --git a/Darling/Darling.Tests/ViewerWave2Tests.cs b/Darling/Darling.Tests/ViewerWave2Tests.cs new file mode 100644 index 000000000..f0e0068b6 --- /dev/null +++ b/Darling/Darling.Tests/ViewerWave2Tests.cs @@ -0,0 +1,345 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text.Json; +using System.Windows.Media; +using PerformanceMonitor.Alerting; +using PerformanceMonitor.Analysis; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the viewer wave-2 SQL against the Darling store contract (no live Postgres needed): +/// the two blocking reads whose XE-preferred/DMV-fallback merge runs through the SHARED +/// on the shared row shape. The Queries read the absorbed +/// top-50 grid used is now the full W1f-1 TopQueriesSql — its pins live in ViewerQueriesTests. +/// +public sealed class ViewerWave2SqlTests +{ + [Theory] + /* The BPR read sources the BASE table (not v_blocked_process_reports) so its #1368 / V7 best-effort plan + columns are projected on every store — the pinned SELECT * view would omit them on an upgraded store. + This also matches DarlingAlertReadAdapter, which already reads the base blocked_process_reports. */ + [InlineData(ViewerDataService.BlockedProcessReportsSql, "FROM blocked_process_reports")] + [InlineData(ViewerDataService.DmvBlockingSnapshotsSql, "FROM v_dmv_blocking_snapshots")] + public void BlockingSql_WindowsOnCollectionTime_NewestEventsFirst_Cap200(string sql, string fromClause) + { + /* The alert path's read semantics (DarlingAlertReadAdapter), verbatim. */ + Assert.Contains(fromClause, sql, StringComparison.Ordinal); + Assert.Contains("WHERE server_id = $1", sql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $2", sql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $3", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY event_time DESC", sql, StringComparison.Ordinal); + Assert.Contains("LIMIT 200", sql, StringComparison.Ordinal); + } + + [Theory] + [InlineData(ViewerDataService.BlockedProcessReportsSql)] + [InlineData(ViewerDataService.DmvBlockingSnapshotsSql)] + public void BlockingSql_SelectsTheSharedRowColumns(string sql) + { + foreach (var column in new[] + { + "event_time", "database_name", "blocked_spid", "blocking_spid", + "wait_time_ms", "lock_mode", "blocked_sql_text", "blocking_sql_text", + "contentious_object", + }) + { + Assert.Contains(column, sql, StringComparison.Ordinal); + } + } + + /* The W1c "deliberately skips the report XML" pin was removed in W1e: the widened Blocked Process + Reports grid needs blocked_process_report_xml (HasReportXml gates the per-row XML Save, and the + block-chain viewer reads it). ViewerBlockingDepthSqlTests now pins that the XML IS selected. */ +} + +/// +/// The viewer's blocking rows ARE the shared alert row shape, so the XE-preferred/DMV-fallback +/// dedup + re-cap semantics come from the one shared — +/// exercised here through lists exactly as the read path +/// calls it. +/// +public sealed class ViewerBlockingMergeTests +{ + private static ViewerBlockedProcessRow Row(string source, DateTime eventTime, int blocked, int blocking) + => new() + { + Source = source, + EventTime = eventTime, + BlockedSpid = blocked, + BlockingSpid = blocking, + }; + + [Fact] + public void ViewerRow_DerivesFromTheSharedAlertRow() + { + Assert.IsAssignableFrom(new ViewerBlockedProcessRow()); + } + + [Fact] + public void Merge_KeepsXeRows_AndDropsDmvRowsCoveredWithinTheSameMinute() + { + var t = new DateTime(2026, 7, 1, 12, 0, 5); + var items = new List + { + Row(BlockedProcessAlertRow.XeReportSource, t, blocked: 51, blocking: 60), + }; + var dmvItems = new List + { + /* Same SPID pair, same minute — covered by the XE row, so dropped. */ + Row(BlockedProcessAlertRow.DmvSnapshotSource, t.AddSeconds(20), blocked: 51, blocking: 60), + /* Different pair — appended. */ + Row(BlockedProcessAlertRow.DmvSnapshotSource, t.AddSeconds(30), blocked: 52, blocking: 61), + }; + + BlockedProcessReportMerge.AppendDmvFallbackRows(items, dmvItems); + + Assert.Equal(2, items.Count); + Assert.Single(items, r => r.Source == BlockedProcessAlertRow.XeReportSource); + var dmvSurvivor = Assert.Single(items, r => r.Source == BlockedProcessAlertRow.DmvSnapshotSource); + Assert.Equal(52, dmvSurvivor.BlockedSpid); + } + + [Fact] + public void Merge_ReordersNewestFirst_AndReCapsToTheGridLimit() + { + var t = new DateTime(2026, 7, 1, 12, 0, 0); + var items = new List + { + Row(BlockedProcessAlertRow.XeReportSource, t, blocked: 51, blocking: 60), + }; + var dmvItems = new List + { + Row(BlockedProcessAlertRow.DmvSnapshotSource, t.AddMinutes(5), blocked: 70, blocking: 71), + }; + + BlockedProcessReportMerge.AppendDmvFallbackRows(items, dmvItems, cap: 1); + + /* Newest wins the cap — the later DMV row displaces the older XE row. */ + var survivor = Assert.Single(items); + Assert.Equal(BlockedProcessAlertRow.DmvSnapshotSource, survivor.Source); + } +} + +/// The wave-2 pure display helpers: wait-time text, local times, and text truncation. +public sealed class ViewerWave2DisplayTests +{ + [Theory] + [InlineData(0, "0 ms")] + [InlineData(999, "999 ms")] + [InlineData(1000, "1.0 sec")] + [InlineData(1500, "1.5 sec")] + [InlineData(30000, "30.0 sec")] + public void FormatWaitTime_MirrorsLitesRendering(long waitMs, string expected) + { + Assert.Equal(expected, ViewerDataService.FormatWaitTime(waitMs)); + } + + [Fact] + public void BlockedRow_EventTimeLocal_FormatsTheStoredUtcInLocalTime() + { + /* W1e: EventTimeLocal is now the Lite-grid display STRING (the widened grid binds it directly), + the stored naive-UTC rendered in the viewer's machine-local time; empty when there is no event. */ + var storedUtc = new DateTime(2026, 7, 1, 3, 30, 0, DateTimeKind.Unspecified); + var row = new ViewerBlockedProcessRow { EventTime = storedUtc }; + + Assert.Equal(ViewerTimeHelper.ForDisplay(storedUtc).ToString("yyyy-MM-dd HH:mm:ss"), row.EventTimeLocal); + Assert.Equal("", new ViewerBlockedProcessRow().EventTimeLocal); + } + + [Fact] + public void TruncateForCell_CollapsesWhitespace_AndCapsWithEllipsis() + { + Assert.Equal("SELECT * FROM t", ViewerDataService.TruncateForCell(" SELECT *\r\n FROM\tt ")); + Assert.Equal("", ViewerDataService.TruncateForCell(null)); + + var longText = new string('a', 400); + var preview = ViewerDataService.TruncateForCell(longText); + Assert.Equal(ViewerDataService.CellTextCap + 1, preview.Length); /* cap + ellipsis */ + Assert.EndsWith("…", preview, StringComparison.Ordinal); + } + + [Fact] + public void TruncateForTooltip_KeepsLineBreaks_AndCaps() + { + Assert.Equal("SELECT 1\r\nFROM t", ViewerDataService.TruncateForTooltip("SELECT 1\r\nFROM t")); + Assert.Equal("", ViewerDataService.TruncateForTooltip(null)); + + var longText = new string('b', ViewerDataService.TooltipTextCap + 50); + var tooltip = ViewerDataService.TruncateForTooltip(longText); + Assert.Equal(ViewerDataService.TooltipTextCap + 1, tooltip.Length); + Assert.EndsWith("…", tooltip, StringComparison.Ordinal); + } +} + +/// +/// The Recommendations tab's finding mapping: Lite's severity cutoffs and display order, the +/// headline-or-fact-key title, the composed detail text (advice via +/// ), and the read-only pretty-print of the persisted +/// remediation action. +/// +public sealed class ViewerFindingTests +{ + private static AnalysisFinding Finding( + double severity = 1.0, + string rootFactKey = "SOME_UNKNOWN_FACT_KEY", + string storyText = "", + string? database = null, + RemediationAction? remediation = null) + => new() + { + FindingId = 1, + AnalysisTime = new DateTime(2026, 7, 1, 12, 0, 0, DateTimeKind.Utc), + ServerId = 1, + ServerName = "SQL2022", + DatabaseName = database, + Severity = severity, + Confidence = 0.9, + Category = "cpu", + StoryPath = "a -> b", + StoryPathHash = "hash-" + rootFactKey + severity.ToString(CultureInfo.InvariantCulture), + StoryText = storyText, + RootFactKey = rootFactKey, + FactCount = 2, + Remediation = remediation, + }; + + [Theory] + [InlineData(2.0, "CRITICAL")] + [InlineData(1.5, "CRITICAL")] + [InlineData(1.49, "WARNING")] + [InlineData(0.75, "WARNING")] + [InlineData(0.74, "INFO")] + [InlineData(0.0, "INFO")] + public void SeverityBand_UsesBothAppsSharedCutoffs(double severity, string expected) + { + Assert.Equal(expected, ViewerDataService.SeverityBand(severity)); + } + + [Fact] + public void FindingTitle_PrefersTheFrozenAdviceHeadline_FallsBackToTheRootFactKey() + { + var frozen = FactAdvice.SerializeForStoryText(new AdviceBlock("Frozen headline", "look here", "do this")); + + Assert.Equal("Frozen headline", ViewerDataService.FindingTitle(Finding(storyText: frozen))); + Assert.Equal("SOME_UNKNOWN_FACT_KEY", ViewerDataService.FindingTitle(Finding())); + } + + [Fact] + public void MapFindings_SortsBandDesc_ThenRawDesc_ThenDatabase() + { + var findings = new List + { + Finding(severity: 0.5), + Finding(severity: 1.0, database: "beta"), + Finding(severity: 1.0, database: "alpha"), + Finding(severity: 1.9), + Finding(severity: 1.2), + }; + + var rows = ViewerDataService.MapFindings(findings); + + Assert.Equal(new[] { "CRITICAL", "WARNING", "WARNING", "WARNING", "INFO" }, + rows.ConvertAll(r => r.SeverityBand)); + Assert.Equal(1.9, rows[0].RawSeverity); + Assert.Equal(1.2, rows[1].RawSeverity); + /* Equal band + raw: database ascending, case-insensitive. */ + Assert.Equal("alpha", rows[2].DatabaseName); + Assert.Equal("beta", rows[3].DatabaseName); + } + + [Fact] + public void ComposeFindingDetailText_RendersTheComposedStory() + { + var frozen = FactAdvice.SerializeForStoryText( + new AdviceBlock("CPU is on fire", "check the top queries", "tune or cap them")); + + var text = ViewerDataService.ComposeFindingDetailText(Finding(storyText: frozen)); + + Assert.StartsWith("CPU is on fire", text, StringComparison.Ordinal); + Assert.Contains("Investigation:", text, StringComparison.Ordinal); + Assert.Contains("check the top queries", text, StringComparison.Ordinal); + Assert.Contains("Remediation:", text, StringComparison.Ordinal); + Assert.Contains("tune or cap them", text, StringComparison.Ordinal); + } + + [Fact] + public void ComposeFindingDetailText_UnknownFactKey_SaysSo() + { + var text = ViewerDataService.ComposeFindingDetailText(Finding()); + + Assert.StartsWith("SOME_UNKNOWN_FACT_KEY", text, StringComparison.Ordinal); + Assert.Contains("No advice text is available", text, StringComparison.Ordinal); + } + + [Fact] + public void ComposeFindingDetailText_AppendsThePersistedAction_AsReadOnlyJson() + { + var action = new RemediationAction( + "PLAN_REGRESSION", "force", + new[] { new ForcePlanTarget("StackOverflow", QueryId: 42, PlanId: 7) }); + + var text = ViewerDataService.ComposeFindingDetailText(Finding(remediation: action)); + + Assert.Contains("Stored remediation action (read-only):", text, StringComparison.Ordinal); + Assert.Contains("\"FactKey\": \"PLAN_REGRESSION\"", text, StringComparison.Ordinal); + /* No Apply affordance in the viewer — the action renders as text only. */ + Assert.DoesNotContain("Apply", text, StringComparison.Ordinal); + } + + [Fact] + public void PrettyPrintRemediation_IndentsTheSharedSerializersJson_NullForNoAction() + { + Assert.Null(ViewerDataService.PrettyPrintRemediation(null)); + + var action = new RemediationAction( + "PLAN_REGRESSION", "force", + new[] { new ForcePlanTarget("StackOverflow", QueryId: 42, PlanId: 7) }); + + var json = ViewerDataService.PrettyPrintRemediation(action); + + Assert.NotNull(json); + Assert.Contains(Environment.NewLine, json, StringComparison.Ordinal); /* indented, not compact */ + using var document = JsonDocument.Parse(json!); /* still valid JSON */ + Assert.Equal("PLAN_REGRESSION", document.RootElement.GetProperty("FactKey").GetString()); + Assert.Equal(42, document.RootElement.GetProperty("Targets")[0].GetProperty("QueryId").GetInt64()); + } +} + +/// Wave 2 extends the status converter to the severity bands. +public sealed class SeverityBandBrushTests +{ + private static Color ColorFor(string? status) + { + var converter = new StatusToBrushConverter(); + var brush = Assert.IsType(converter.Convert(status, typeof(Brush), null, CultureInfo.InvariantCulture)); + return brush.Color; + } + + [Fact] + public void Convert_MapsSeverityBands_SharingTheHealthStatusColors() + { + /* CRITICAL reads like ERROR, WARNING like PERMISSIONS — one visual language. */ + Assert.Equal(ColorFor("ERROR"), ColorFor("CRITICAL")); + Assert.Equal(ColorFor("PERMISSIONS"), ColorFor("WARNING")); + + var info = ColorFor("INFO"); + Assert.NotEqual(ColorFor("CRITICAL"), info); + Assert.NotEqual(ColorFor("WARNING"), info); + Assert.NotEqual(ColorFor("SUCCESS"), info); + Assert.NotEqual(ColorFor("SKIPPED"), info); /* default foreground */ + + Assert.Equal(info, ColorFor("info")); /* case-insensitive like the statuses */ + } +} diff --git a/Darling/Darling.Tests/ViewerWave3Tests.cs b/Darling/Darling.Tests/ViewerWave3Tests.cs new file mode 100644 index 000000000..276bec857 --- /dev/null +++ b/Darling/Darling.Tests/ViewerWave3Tests.cs @@ -0,0 +1,510 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Analysis; +using PerformanceMonitor.Darling.Analysis; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using PerformanceMonitor.Notifications; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the viewer wave-3 write-back SQL against the Darling store contract (no live Postgres): +/// the alert-history read over config_alert_log, the mute-rule CRUD's column parity with the V3 +/// schema and with the service's PgMuteRuleStore shape, and the muted-story read the finding-mute +/// surface uses. +/// +public sealed class ViewerWave3SqlTests +{ + [Fact] + public void AlertHistorySql_ReadsConfigAlertLog_PerServer_NewestFirst_ExcludesDismissed() + { + Assert.Contains("FROM config_alert_log", ViewerDataService.AlertHistorySql, StringComparison.Ordinal); + Assert.Contains("WHERE alert_time >= $1", ViewerDataService.AlertHistorySql, StringComparison.Ordinal); + Assert.Contains("server_id = $2", ViewerDataService.AlertHistorySql, StringComparison.Ordinal); + /* Mirrors Lite's GetAlertHistoryAsync — dismissed rows are hidden from the view. */ + Assert.Contains("dismissed = FALSE", ViewerDataService.AlertHistorySql, StringComparison.Ordinal); + Assert.Contains("ORDER BY alert_time DESC", ViewerDataService.AlertHistorySql, StringComparison.Ordinal); + Assert.Contains("LIMIT $3", ViewerDataService.AlertHistorySql, StringComparison.Ordinal); + } + + [Fact] + public void AlertHistorySql_SelectsTheHumanColumns_AndNoArchiveView() + { + foreach (var column in new[] + { + "alert_time", "metric_name", "current_value", "threshold_value", + "alert_sent", "notification_type", "send_error", "muted", "detail_text", "context_json", + }) + { + Assert.Contains(column, ViewerDataService.AlertHistorySql, StringComparison.Ordinal); + } + + /* Darling has no parquet archive tier, so no v_config_alert_log union / source column. */ + Assert.DoesNotContain("v_config_alert_log", ViewerDataService.AlertHistorySql, StringComparison.Ordinal); + } + + [Fact] + public void AlertHistorySql_PgDialect_NoBareNow_NoNLiterals_PositionalParams() + { + Assert.DoesNotContain("now(", ViewerDataService.AlertHistorySql.ToLowerInvariant()); + Assert.DoesNotContain("N'", ViewerDataService.AlertHistorySql); + Assert.DoesNotContain("@", ViewerDataService.AlertHistorySql); + Assert.Contains("$1", ViewerDataService.AlertHistorySql); + } + + private static readonly string[] AllMuteRuleWriteSql = + { + ViewerDataService.MuteRulesSelectSql, + ViewerDataService.MuteRuleInsertSql, + ViewerDataService.MuteRuleUpdateSql, + ViewerDataService.MuteRuleSetEnabledSql, + ViewerDataService.MuteRuleDeleteSql, + }; + + /// The 11 config_mute_rules columns the store round-trips (PgMuteRuleStore's shape). + private static readonly string[] MuteRuleColumns = + { + "id", "enabled", "created_at_utc", "expires_at_utc", "reason", + "server_name", "metric_name", "database_pattern", + "query_text_pattern", "wait_type_pattern", "job_name_pattern", + }; + + [Fact] + public void MuteRuleSql_SelectAndInsert_CarryEveryConfigMuteRulesColumn() + { + foreach (var column in MuteRuleColumns) + { + Assert.Contains(column, ViewerDataService.MuteRulesSelectSql, StringComparison.Ordinal); + Assert.Contains(column, ViewerDataService.MuteRuleInsertSql, StringComparison.Ordinal); + } + + /* All 11 columns are bound in the insert; newest-first read like PgMuteRuleStore/Lite. */ + Assert.Contains("$11", ViewerDataService.MuteRuleInsertSql, StringComparison.Ordinal); + Assert.Contains("ORDER BY created_at_utc DESC", ViewerDataService.MuteRulesSelectSql, StringComparison.Ordinal); + } + + [Fact] + public void MuteRuleSql_MatchesTheV3SchemaColumns() + { + /* Every column the viewer writes must exist in the migration-owned table. */ + var v3 = PgMigrations.Scripts.Single(m => m.Version == 3).Sql; + Assert.Contains("CREATE TABLE IF NOT EXISTS config_mute_rules", v3, StringComparison.Ordinal); + foreach (var column in MuteRuleColumns) + { + Assert.Contains(column, v3, StringComparison.Ordinal); + } + } + + [Fact] + public void MuteRuleSql_KeyedById_PgDialect() + { + Assert.Contains("WHERE id = $1", ViewerDataService.MuteRuleUpdateSql, StringComparison.Ordinal); + Assert.Contains("WHERE id = $1", ViewerDataService.MuteRuleDeleteSql, StringComparison.Ordinal); + Assert.Contains("WHERE id = $1", ViewerDataService.MuteRuleSetEnabledSql, StringComparison.Ordinal); + + /* Dialect check on every statement; the parameterless SELECT has no $N to assert. */ + foreach (var sql in AllMuteRuleWriteSql) + { + Assert.DoesNotContain("now(", sql.ToLowerInvariant()); + Assert.DoesNotContain("N'", sql); + Assert.DoesNotContain("@", sql); + } + + foreach (var sql in new[] + { + ViewerDataService.MuteRuleInsertSql, + ViewerDataService.MuteRuleUpdateSql, + ViewerDataService.MuteRuleSetEnabledSql, + ViewerDataService.MuteRuleDeleteSql, + }) + { + Assert.Contains("$1", sql); + } + } + + [Fact] + public void MutedStoriesSql_CarriesMuteId_AndSpansPerServerPlusGlobal() + { + /* The finding-mute surface needs mute_id (GetMutedHashesSql omits it) and the same per-server + + global span the engine's mute filter uses — NULL (the canonical all-servers marker) plus + legacy server_id = 0 rows written before the NULL fix. */ + Assert.Contains("mute_id", PgFindingStore.GetMutedStoriesSql, StringComparison.Ordinal); + Assert.Contains("story_path_hash", PgFindingStore.GetMutedStoriesSql, StringComparison.Ordinal); + Assert.Contains("story_path", PgFindingStore.GetMutedStoriesSql, StringComparison.Ordinal); + Assert.Contains("FROM analysis_muted", PgFindingStore.GetMutedStoriesSql, StringComparison.Ordinal); + Assert.Contains("server_id = $1 OR server_id IS NULL", PgFindingStore.GetMutedStoriesSql, StringComparison.Ordinal); + Assert.Contains("OR server_id = 0", PgFindingStore.GetMutedStoriesSql, StringComparison.Ordinal); + } +} + +/// +/// The wave-3 pure display helpers: the alert row's metric-keyed value formatting and status +/// mapping (Lite's AlertHistoryRow), the shared severity classification, the alert-detail +/// composition (fingerprint disclosure), and the finding row's muted label. +/// +public sealed class ViewerWave3DisplayTests +{ + private static ViewerAlertRow AlertRow( + string metric = "High CPU", + double current = 95.5, + double threshold = 90, + bool alertSent = false, + string notificationType = "tray", + string? sendError = null, + bool muted = false, + string? detailText = null, + string? contextJson = null) + => new() + { + AlertTime = new DateTime(2026, 7, 1, 3, 30, 0, DateTimeKind.Unspecified), + MetricName = metric, + CurrentValue = current, + ThresholdValue = threshold, + AlertSent = alertSent, + NotificationType = notificationType, + SendError = sendError, + Muted = muted, + DetailText = detailText, + ContextJson = contextJson, + }; + + [Theory] + [InlineData("High CPU", 95.5, "95.5%")] + [InlineData("Volume Free Space", 12.34, "12.3%")] + [InlineData("Poison Wait", 1234.0, "1234 ms")] + [InlineData("Long-Running Query", 12.0, "12 m")] + [InlineData("Deadlocks Detected", 3.0, "3")] + [InlineData("Blocking Detected", 7.0, "7")] + [InlineData("Some Unmapped Metric", 0.97460, "0.97")] + public void CurrentValueDisplay_MirrorsLitesMetricKeyedFormatting(string metric, double value, string expected) + { + Assert.Equal(expected, AlertRow(metric: metric, current: value).CurrentValueDisplay); + } + + [Theory] + [InlineData("email", true, null, "Sent")] + [InlineData("email", false, "smtp exploded", "Failed")] + [InlineData("email", false, null, "Not sent")] + [InlineData("tray", true, null, "Delivered")] + [InlineData("tray", false, null, "Shown")] + public void StatusDisplay_MirrorsLitesChannelMapping(string notif, bool sent, string? error, string expected) + { + Assert.Equal(expected, AlertRow(notificationType: notif, alertSent: sent, sendError: error).StatusDisplay); + } + + [Fact] + public void Classification_UsesTheSharedAlertMetricClassifier() + { + Assert.True(AlertRow(metric: "Deadlocks Detected").IsCritical); + Assert.True(AlertRow(metric: "Poison Wait").IsCritical); + Assert.True(AlertRow(metric: "Blocking Cleared").IsResolved); + Assert.True(AlertRow(metric: "High CPU").IsWarning); + Assert.False(AlertRow(metric: "High CPU").IsCritical); + } + + [Fact] + public void TimeLocal_TreatsTheStoredValueAsUtc() + { + var row = AlertRow(); + var expected = DateTime.SpecifyKind(row.AlertTime, DateTimeKind.Utc).ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"); + Assert.Equal(expected, row.TimeLocal); + } + + [Fact] + public void MutedLabel_ReflectsTheMutedFlag() + { + var finding = new AnalysisFinding + { + FindingId = 1, + AnalysisTime = new DateTime(2026, 7, 1, 12, 0, 0, DateTimeKind.Utc), + ServerId = 1, + ServerName = "SQL2022", + Severity = 1.0, + Confidence = 0.9, + Category = "cpu", + StoryPath = "a -> b", + StoryPathHash = "hash", + StoryText = "", + RootFactKey = "CPU", + FactCount = 1, + }; + var row = new ViewerFindingRow + { + SeverityBand = "WARNING", + RawSeverity = 1.0, + Title = "t", + Category = "cpu", + DatabaseName = "", + Finding = finding, + }; + + Assert.Equal("", row.MutedLabel); + Assert.False(row.IsMuted); + + row.IsMuted = true; + Assert.Equal("Muted", row.MutedLabel); + } +} + +/// +/// Gated (DARLING_TEST_PG) live round-trips for every wave-3 write path: the finding mute/unmute +/// visible to a fresh viewer read, the mute-rule CRUD round-trip, and the alert-history read shape +/// (including the dismissed-row exclusion). Shares the serialized "live-postgres" collection so the +/// row churn can't race another class. +/// +[Collection("live-postgres")] +public sealed class ViewerWave3LivePostgresTests +{ + private const int FindingServerId = -717171; + private const string FindingServerName = "viewer-wave3-finding-e2e"; + private const string FindingHash = "v3-mute-e2e-hash"; + + private const string MuteRuleId = "viewer-wave3-mute-rule-e2e"; + private const string MuteRuleServer = "viewer-wave3-rule-server"; + + private const int AlertServerId = -818181; + private const string AlertServerName = "viewer-wave3-alert-e2e"; + + [Fact] + public async Task FindingMuteUnmute_RoundTrips_VisibleToAFreshViewerRead() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live finding-mute test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteFindingRowsAsync(connection); + + await using var postgres = NpgsqlDataSource.Create(connectionString!); + var store = new PgFindingStore(postgres); + await using var viewer = new ViewerDataService(connectionString!); + + try + { + /* Seed one persisted finding for the server. */ + var windowEnd = TruncateToSeconds(DateTime.UtcNow); + var context = new AnalysisContext + { + ServerId = FindingServerId, + ServerName = FindingServerName, + TimeRangeStart = windowEnd.AddHours(-4), + TimeRangeEnd = windowEnd, + ServerUtcOffset = TimeSpan.Zero, + }; + var stories = new List + { + new AnalysisStory + { + Severity = 1.2, + Confidence = 0.8, + Category = "cpu", + StoryPath = "CPU_PRESSURE -> THREADPOOL", + StoryPathHash = FindingHash, + StoryText = "seed finding for the viewer mute round-trip", + RootFactKey = "CPU_PRESSURE", + FactCount = 2, + }, + }; + var survivors = await store.FilterMutedFindingsAsync(stories, context); + await store.InsertFindingsAsync(survivors, context); + + /* Before mute: the viewer read shows it, unmuted. */ + var before = Assert.Single(await viewer.GetLatestFindingsAsync(FindingServerId)); + Assert.False(before.IsMuted); + Assert.Null(before.MuteId); + + /* Mute via the viewer's write-back — a fresh read flags it muted with a mute id (the + persisted batch is unchanged; the engine would drop it on the next analysis run). */ + await viewer.MuteFindingAsync(FindingServerId, before.Finding); + var afterMute = Assert.Single(await viewer.GetLatestFindingsAsync(FindingServerId)); + Assert.True(afterMute.IsMuted); + Assert.NotNull(afterMute.MuteId); + + /* Unmute via the viewer — the flag clears on the next read. */ + await viewer.UnmuteFindingAsync(afterMute.MuteId!.Value); + Assert.False(Assert.Single(await viewer.GetLatestFindingsAsync(FindingServerId)).IsMuted); + } + finally + { + await DeleteFindingRowsAsync(connection); + } + } + + [Fact] + public async Task MuteRuleCrud_RoundTrips_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live mute-rule CRUD test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteMuteRuleRowsAsync(connection); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var rule = new MuteRule + { + Id = MuteRuleId, + Enabled = true, + CreatedAtUtc = DateTime.UtcNow, + Reason = "e2e", + ServerName = MuteRuleServer, + MetricName = "High CPU", + DatabasePattern = "StackOverflow", + }; + await viewer.InsertMuteRuleAsync(rule); + + var inserted = Assert.Single((await viewer.GetMuteRulesAsync()).Where(r => r.Id == MuteRuleId)); + Assert.True(inserted.Enabled); + Assert.Equal(MuteRuleServer, inserted.ServerName); + Assert.Equal("High CPU", inserted.MetricName); + Assert.Equal("StackOverflow", inserted.DatabasePattern); + Assert.Equal(DateTimeKind.Utc, inserted.CreatedAtUtc.Kind); + + /* Update swaps metric + reason. */ + rule.MetricName = "Poison Wait"; + rule.Reason = "updated"; + await viewer.UpdateMuteRuleAsync(rule); + var updated = Assert.Single((await viewer.GetMuteRulesAsync()).Where(r => r.Id == MuteRuleId)); + Assert.Equal("Poison Wait", updated.MetricName); + Assert.Equal("updated", updated.Reason); + + /* Toggle enabled off. */ + await viewer.SetMuteRuleEnabledAsync(MuteRuleId, false); + Assert.False(Assert.Single((await viewer.GetMuteRulesAsync()).Where(r => r.Id == MuteRuleId)).Enabled); + + /* Delete removes it. */ + await viewer.DeleteMuteRuleAsync(MuteRuleId); + Assert.Empty((await viewer.GetMuteRulesAsync()).Where(r => r.Id == MuteRuleId)); + + /* Purge-expired removes an already-expired rule. */ + await viewer.InsertMuteRuleAsync(new MuteRule + { + Id = MuteRuleId, + Enabled = true, + CreatedAtUtc = DateTime.UtcNow.AddDays(-2), + ExpiresAtUtc = DateTime.UtcNow.AddHours(-1), + ServerName = MuteRuleServer, + }); + var removed = await viewer.PurgeExpiredMuteRulesAsync(); + Assert.True(removed >= 1); + Assert.Empty((await viewer.GetMuteRulesAsync()).Where(r => r.Id == MuteRuleId)); + } + finally + { + await DeleteMuteRuleRowsAsync(connection); + } + } + + [Fact] + public async Task AlertHistory_ReadsRecent_ExcludesDismissed_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live alert-history test."); + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await PgMigrations.MigrateAsync(connection, TestContext.Current.CancellationToken); + await DeleteAlertRowsAsync(connection); + + await using var viewer = new ViewerDataService(connectionString!); + + try + { + var now = TruncateToSeconds(DateTime.UtcNow); + /* One visible row (with a dedup fingerprint) and one dismissed row that must be hidden. */ + await InsertAlertRowAsync(connection, now, "High CPU", 95.5, 90, alertSent: false, + notificationType: "tray", dismissed: false, + detailText: " Database: StackOverflow", contextJson: "{\"DedupKey\":\"abc123\"}"); + await InsertAlertRowAsync(connection, now.AddMinutes(-5), "Deadlocks Detected", 3, 1, alertSent: true, + notificationType: "email", dismissed: true, detailText: null, contextJson: null); + + var rows = await viewer.GetAlertHistoryAsync(now.AddHours(-1), AlertServerId); + + /* Dismissed row excluded — only the live one comes back. */ + var row = Assert.Single(rows); + Assert.Equal("High CPU", row.MetricName); + Assert.Equal("95.5%", row.CurrentValueDisplay); + Assert.Equal("tray", row.NotificationType); + Assert.Equal("Shown", row.StatusDisplay); + /* The read carries the raw context_json (the #1140 dedup fingerprint) through unchanged. */ + Assert.NotNull(row.ContextJson); + Assert.Contains("DedupKey", row.ContextJson, StringComparison.Ordinal); + } + finally + { + await DeleteAlertRowsAsync(connection); + } + } + + private static async Task InsertAlertRowAsync( + NpgsqlConnection connection, DateTime alertTimeUtc, string metric, + double current, double threshold, bool alertSent, string notificationType, + bool dismissed, string? detailText, string? contextJson) + { + using var command = new NpgsqlCommand(@" +INSERT INTO config_alert_log + (alert_time, server_id, server_name, metric_name, current_value, threshold_value, + alert_sent, notification_type, send_error, dismissed, muted, detail_text, context_json) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)", connection); + command.Parameters.AddWithValue(DateTime.SpecifyKind(alertTimeUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(AlertServerId); + command.Parameters.AddWithValue(AlertServerName); + command.Parameters.AddWithValue(metric); + command.Parameters.AddWithValue(current); + command.Parameters.AddWithValue(threshold); + command.Parameters.AddWithValue(alertSent); + command.Parameters.AddWithValue(notificationType); + command.Parameters.Add(new NpgsqlParameter { Value = (object?)null ?? DBNull.Value, NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Text }); + command.Parameters.AddWithValue(dismissed); + command.Parameters.AddWithValue(false); + command.Parameters.Add(new NpgsqlParameter { Value = (object?)detailText ?? DBNull.Value, NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Text }); + command.Parameters.Add(new NpgsqlParameter { Value = (object?)contextJson ?? DBNull.Value, NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Text }); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static DateTime TruncateToSeconds(DateTime value) => + DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified); + + private static async Task DeleteFindingRowsAsync(NpgsqlConnection connection) + { + using var cleanup = new NpgsqlCommand( + $"DELETE FROM analysis_findings WHERE server_id = {FindingServerId}; DELETE FROM analysis_muted WHERE server_id = {FindingServerId};", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task DeleteMuteRuleRowsAsync(NpgsqlConnection connection) + { + using var cleanup = new NpgsqlCommand("DELETE FROM config_mute_rules WHERE id = $1 OR server_name = $2", connection); + cleanup.Parameters.AddWithValue(MuteRuleId); + cleanup.Parameters.AddWithValue(MuteRuleServer); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task DeleteAlertRowsAsync(NpgsqlConnection connection) + { + using var cleanup = new NpgsqlCommand( + $"DELETE FROM config_alert_log WHERE server_id = {AlertServerId};", connection); + await cleanup.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } +} diff --git a/Darling/Darling.Tests/packages.lock.json b/Darling/Darling.Tests/packages.lock.json new file mode 100644 index 000000000..f790c1cf4 --- /dev/null +++ b/Darling/Darling.Tests/packages.lock.json @@ -0,0 +1,927 @@ +{ + "version": 1, + "dependencies": { + "net10.0-windows7.0": { + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.6.0, )", + "resolved": "18.6.0", + "contentHash": "kAIBt0MsYR0o2RULmlW5BhQ1ha50aGEgLKG4f1p0kePBGLJCprqs3S+NxRrYN8UH7mSQRPKpeiH9mwPMEKUObQ==", + "dependencies": { + "Microsoft.CodeCoverage": "18.6.0", + "Microsoft.TestPlatform.TestHost": "18.6.0" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.5, )", + "resolved": "3.1.5", + "contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA==" + }, + "xunit.v3": { + "type": "Direct", + "requested": "[3.2.2, )", + "resolved": "3.2.2", + "contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==", + "dependencies": { + "xunit.v3.mtp-v1": "[3.2.2]" + } + }, + "CredentialManagement": { + "type": "Transitive", + "resolved": "1.0.2", + "contentHash": "VkP04/jFXaxT3TkcRhzETYtOrznQxRmQ2J1XJdbXz47Bir7hIzPR7mFZk4GJQ4An4gozW+vonpf+iqTHomAkQw==" + }, + "Hardcodet.NotifyIcon.Wpf": { + "type": "Transitive", + "resolved": "2.0.1", + "contentHash": "dtxmeZXzV2GzSm91aZ3hqzgoeVoARSkDPVCYfhVUNyyKBWYxMgNC0EcLiSYxD4Uc4alq/2qb3SmV8DgAENLRLQ==" + }, + "HarfBuzzSharp": { + "type": "Transitive", + "resolved": "8.3.1.1", + "contentHash": "tLZN66oe/uiRPTZfrCU4i8ScVGwqHNh5MHrXj0yVf4l7Mz0FhTGnQ71RGySROTmdognAs0JtluHkL41pIabWuQ==", + "dependencies": { + "HarfBuzzSharp.NativeAssets.Win32": "8.3.1.1", + "HarfBuzzSharp.NativeAssets.macOS": "8.3.1.1" + } + }, + "HarfBuzzSharp.NativeAssets.Linux": { + "type": "Transitive", + "resolved": "8.3.1.1", + "contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg==" + }, + "HarfBuzzSharp.NativeAssets.macOS": { + "type": "Transitive", + "resolved": "8.3.1.1", + "contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ==" + }, + "HarfBuzzSharp.NativeAssets.Win32": { + "type": "Transitive", + "resolved": "8.3.1.1", + "contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA==" + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.Bcl.Cryptography": { + "type": "Transitive", + "resolved": "9.0.13", + "contentHash": "5T+bH3Lb1nEe8Hf/ixMxLmhlrx5wRi53wv7OhVwG2F1ZviW1ejFRS1NHur3uqPpJRGtkQwUchtY6zhVK2R+v+w==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.6.0", + "contentHash": "bkmCXn/65Cd0LdO2zTb/ValGAJ1H8y/CgYOiBb3jsDyHI3Y1ljKx6RBvhvn3e5D/4R4I00RRwLf+Bd2Sn6bJjA==" + }, + "Microsoft.Data.SqlClient": { + "type": "Transitive", + "resolved": "7.0.1", + "contentHash": "9jZFXAJ2ThNYK7lhj2RhH7klXVNaWSvZpQncq3bPIOjmHBrdjwgeO4c8wucUVxQwFT8rAA13Z2F2jzoYR7ICDw==", + "dependencies": { + "Microsoft.Bcl.Cryptography": "9.0.13", + "Microsoft.Data.SqlClient.Extensions.Abstractions": "1.0.0", + "Microsoft.Data.SqlClient.Internal.Logging": "1.0.0", + "Microsoft.Data.SqlClient.SNI.runtime": "6.0.2", + "Microsoft.Extensions.Caching.Memory": "9.0.13", + "Microsoft.IdentityModel.JsonWebTokens": "8.16.0", + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.16.0", + "Microsoft.SqlServer.Server": "1.0.0" + } + }, + "Microsoft.Data.SqlClient.Extensions.Abstractions": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "rlnxc0KfwDSbE8ZHntFnl8SCgOa9QtJZblMv2zXLhRwl1Je7fsdsVzxSjzzC4JMsfAK+jXJWyezRB8SxUY4BdA==", + "dependencies": { + "Microsoft.Data.SqlClient.Internal.Logging": "1.0.0" + } + }, + "Microsoft.Data.SqlClient.Internal.Logging": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "Kue/7CF8KNT9zozfr30C94dMZVZml3atqWZvQemSXvTau76tRdypzeKiBKXadqgbOME0UiQIyVTNo5WxCRNVNg==" + }, + "Microsoft.Data.SqlClient.SNI.runtime": { + "type": "Transitive", + "resolved": "6.0.2", + "contentHash": "f+pRODTWX7Y67jXO3T5S2dIPZ9qMJNySjlZT/TKmWVNWe19N8jcWmHaqHnnchaq3gxEKv1SWVY5EFzOD06l41w==" + }, + "Microsoft.Extensions.AI.Abstractions": { + "type": "Transitive", + "resolved": "10.5.2", + "contentHash": "Ei+YWV9Ybnps7pR1dgjlG29gelXEwZkhLVAcWmKe6HvXS6LNBYgSdWiY3Hk9OZXYtK34rv/NtLWBQYQGOBQYPQ==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "pUDgQKEqNUFlerDIFRg7zzoDVRPEWIG7nR40h8Gzg8RXza4Ry0lWZ7u91bmwu3iUDCxw3Dv6TLHVFoAgY0gy7Q==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.7" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "9.0.13", + "contentHash": "OdQmN8LYcUEu20Fxii9mk68nHJGL+JPXF3w0+hxenf0oDDdDBA+ZV/S92FmIgAWAElowIiFA/g0x+8YB1g80Hg==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "9.0.13", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.13", + "Microsoft.Extensions.Logging.Abstractions": "9.0.13", + "Microsoft.Extensions.Options": "9.0.13", + "Microsoft.Extensions.Primitives": "9.0.13" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "woZsWLhOQsASuxbmgiZJqiGUBNo3IjRdXC92xt8rRokza+P6/nIsnzq7sm9Or6ZYcRl2kL1ufj8HVzp1QlPTXw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "qGhRPd3VxfLV9UqatVOiD9mAeUbj2KiMwGFYC5uXlzExiZQoe4X/hdmzGIU7BQjNLTqCnnbTHVyBglG3668/HA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.9" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "Tp/+LPb70RyjjtLg9m5C959eP4KrUpJHThZfAegZVpsfmGvzfuNkuYbI/ft+LvXhMSyUcAeOPaN6rzTccwnZAg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9" + } + }, + "Microsoft.Extensions.Configuration.CommandLine": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "8D4HaqxWdm5M/nuhQffjPoR1ekhlpyKTXjFMAT5KlP0dvxkJe5JLAP6MAsuUEUxKWG09Bi5aAUaYMFKrMqWHqA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9" + } + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "JhKySWIL8+N4yFt4HPm1rGKCHooze+MBdTdpXc0bd/PGm31TrSUi2m0Nek1y441Wlv/RE6VH0W/DCv2xnmy8FA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "NgLB9cYnIb0/djSDcnqo4GIGGWooxGmr/gCUe3/CRXcKqLizOFui8MyW4EVkTB/KNJL+oXdMXnD6ZRm3Y+qkrQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9", + "Microsoft.Extensions.FileProviders.Physical": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "LiFKJgc9jZEW+7RhcSfsvCwoikt1lDdOqOn+whZC5zVHyg/gExftHl2QPtmfiHsEdDNg+Y+BDr6835tOfj8Y7A==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.9", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9" + } + }, + "Microsoft.Extensions.Configuration.UserSecrets": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "ockJRreRW/HbGwoyHzYOxMucFBimvAZ8lKNwQLMHrS6mwkDUaCJMWzzeE+Rm9vgFlv2o/xqk8fm+FpqrDCnkTA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Configuration.Json": "10.0.9", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9", + "Microsoft.Extensions.FileProviders.Physical": "10.0.9" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "NijozhERJDIaJ4k5TSMy1jOi0cSC2HfkvRD/Sl+kGSSKgVbFnF4GxgtMN/MrzHB8D1JxIrD4xSer9Blh9v3axQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "g41l/30G3K4B/d/L8kjux0+30e27c8D0FVQ/PFCpbekgfDpj9mnDhieP67EqXWvl1EWNeZh2rpR4F5B/jcDOHA==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "NLXI3PbTe39q6/sgs7JYhmfPf7bMzReUoAJ0q9Po6yhfM+0anZa7PrEva4W2SdiLWGyB9eKZS9THGt2BP40xJg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.9", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.9" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "86RgyFsmVslW4Nu28IXgt8tLglynGQrwjk/xhGZaTe8j6YIeR1Ywoc42hSHsBSl920CQdfqq2dBohZiGm3AkUA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "Oxn4vqDk+EwceTMpZxVm7L/UZEAM1qIQlNP1+7tBZckD+P4SKrm/5X4gMTPCTdpnau/xY8Sb4/0d6onomSg4ZA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.9" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "zm8WVod4swgprGrkxkuSILlbXqdDRqF+3y6U0I7jlmj4PMyKN6d8pzXZHUn5lr/gZVULzk/+FeTYlTupt6akpg==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "mvRf9qOH/LslWIee/h+lsElnoUyKotEwoPL31soqScmO/eoxObaTCLCdx2DdqPdRi9LnB+7qKZ49jfyrLZuc+w==" + }, + "Microsoft.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "HTgnvmK0ubesUFO16pLC+i9+RS8lEGd6TmDouuy75FsAgIFrSwUVhYCqG2IENzBJwgxGc/6Rsulfsvd9ZG/XkA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Configuration.Binder": "10.0.9", + "Microsoft.Extensions.Configuration.CommandLine": "10.0.9", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.9", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.9", + "Microsoft.Extensions.Configuration.Json": "10.0.9", + "Microsoft.Extensions.Configuration.UserSecrets": "10.0.9", + "Microsoft.Extensions.DependencyInjection": "10.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Diagnostics": "10.0.9", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9", + "Microsoft.Extensions.FileProviders.Physical": "10.0.9", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging.Configuration": "10.0.9", + "Microsoft.Extensions.Logging.Console": "10.0.9", + "Microsoft.Extensions.Logging.Debug": "10.0.9", + "Microsoft.Extensions.Logging.EventLog": "10.0.9", + "Microsoft.Extensions.Logging.EventSource": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "Xd/2F+uWblTiUp+ssaDZN2ea4vmnHmW6PXugmqBHumyhqVkyeh6RJ3S2Zo/F+1bXIL/KuGqe2pKv6UiGOc1KeQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.9", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9" + } + }, + "Microsoft.Extensions.Hosting.WindowsServices": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "o4pwquIOdvTwj/VBq/KXmE4/JBVuuemmdy80PqAsnzeBmkWiukQ4YrtaTtqhP+BkdHsIYtmYyTrM6Q6RedfbQw==", + "dependencies": { + "Microsoft.Extensions.Hosting": "10.0.9", + "Microsoft.Extensions.Logging.EventLog": "10.0.9", + "System.ServiceProcess.ServiceController": "10.0.9" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "N7Gm9SjugYjmmnhwbBKC9DFqGqjfJvh6YfOJgtwh0AW0Xpok3dIVors1ik050XmUxKAgAc7nNngDIJyFb06K2g==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "9S/DFt4cohlMPpzIxjG6kk0L8MuN2vDm9pbMCulxtJzzk82oJHVLBd8vuQxaPskaYQwKqmFmbannf5eoChgjYg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "bUth5ip7YsZMXWZS42IRTI0zDrPEqdE+xnsmcL0Pk784grWKApDvc5UoMi2tP2qYJ5ylFzeVDuDu08sFATq1bg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Configuration.Binder": "10.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.9" + } + }, + "Microsoft.Extensions.Logging.Console": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "WyZEG/O8jKqBOBF6/M6IJqiEyWFBUv6PDyzNoXDA0mBZwKtkuf7GiZ/0/8eU8OpLKKQL0O95oPOY1szrWIKofQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging.Configuration": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9" + } + }, + "Microsoft.Extensions.Logging.Debug": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "r/A0ahpXmZH/8ltPjrFFWp12BIizK9cCVJXPcHyOad8e4eIX7P/geW+uBYdczkeCAaMebT4jEU7snOm4GnmKfA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9" + } + }, + "Microsoft.Extensions.Logging.EventLog": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "goAl30/WwmdnWDPRwATaDPIK0iuDBnQSMTH2XYGVB1SwReg7hglhvDNjjpNhT25US3GF4I5q6BhTNs6nFYzEfg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9" + } + }, + "Microsoft.Extensions.Logging.EventSource": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "tHynPVHbTicuaDpS2JVTxX0qA5VTg15CXgVKTwWvvudb5BvW5aVew8MMyek6LrDGAom7UbON1jf1T5GhpTilFA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "hyNdX4c2UwkRkzb9byw0H2DQkRzwBM3mzY2sCM9egwzTyg8dvQJmp5noQHGEaaCORQrNK3DD2gREBsc2DlXS4A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "Y4E24zffF/aPS0igNvY6ZzAQfbxd6AYdC9L4brnH+uK0yYYHIR6FeGVQVVjAOo8wub1EQDl2B90lCcpqoTF7Yw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Configuration.Binder": "10.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "fmEbAUFsaIKirgLt/lYhuFRBwhcSJN31jjHgCdbQxJiWOum6EdLjkbgGuukSP9z/a+9LibaxII/kF+GwOXgC4g==" + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "gSxKLWRZzBpIsEoeUPkxfywNCCvRvl7hkq146XHPk5vOQc9izSf1I+uL1vh4y2U19QPxd9Z8K/8AdWyxYz2lSg==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "prBU72cIP4V8E9fhN+o/YdskTsLeIcnKPbhZf0X6mD7fdxoZqnS/NdEkSr+9Zp+2q7OZBOMfNBKGbTbhXODO4w==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.16.0" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "MTzXmETkNQPACR7/XCXM1OGM6oU9RkyibqeJRtO9Ndew2LnGjMf9Atqj2VSf4XC27X0FQycUAlzxxEgQMWn2xQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.16.0" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "UFrU7d46UTsPQTa2HIEIpB9H1uJe1BW9FLw5uhEJ2ZuKdur8bcUA/bO5caq5dlBt5gNJeRIB3QQXYNs5fCQCZA==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.16.0" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "h4yVXyJsEBBX5lg2G5ftMsi5JzcNEGAzrNphA6DQ6eOd8P0s+cDCOyPwVTYLePZvJL5unbPvYIvzrbTXzFjXnQ==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "8.16.0", + "System.IdentityModel.Tokens.Jwt": "8.16.0" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "rtViGJcGsN7WcfUNErwNeQgjuU5cJNl6FDQsfi9TncwO+Epzn0FTfBsg3YuFW1Q0Ch/KPxaVdjLw3/+5Z5ceFQ==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.IdentityModel.Logging": "8.16.0" + } + }, + "Microsoft.SqlServer.Server": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "N4KeF3cpcm1PUHym1RmakkzfkEv3GRMyofVv40uXsQhCQeglr2OHNcUk2WOG51AKpGO8ynGpo9M/kFXSzghwug==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.6.0", + "contentHash": "gQTW4BIfM2ZLxixo9ITXoulLKjn20FiiHtqTsx9PENqTrX7368ZeJ5L0QZJyReXDWORPRV8jXwZR6Aar8JOyaA==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.6.0", + "contentHash": "em1eLz5Q46+hsCtAXdXggWAPd9gQyT4ngdsQ7k1eWvQgpsjtS/wAOJ/5TteieFdiAvrEq1iVn00LtusAxRaVmQ==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.6.0", + "Newtonsoft.Json": "13.0.3" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "ModelContextProtocol": { + "type": "Transitive", + "resolved": "1.4.0", + "contentHash": "CAmnAMQIMax2t9naUgyDAkVBj329EhQCiHcbfirMFNgP6ShQ8hJdJb0OLoaBpeGjkunH3IOjRXLdwXGKKwMlLA==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.7", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.7", + "ModelContextProtocol.Core": "1.4.0" + } + }, + "ModelContextProtocol.AspNetCore": { + "type": "Transitive", + "resolved": "1.4.0", + "contentHash": "Q/xGhfhbCfZsoeEsEqBeiyzr6AJw2cBUTKN3Giz3QRmWyRzn62ISsgJCTnk2/VPMNj2/Spb2jYpPahnMZWvaPg==", + "dependencies": { + "ModelContextProtocol": "1.4.0" + } + }, + "ModelContextProtocol.Core": { + "type": "Transitive", + "resolved": "1.4.0", + "contentHash": "6ZJFQTgYwdu0IacUUTNExAY2Z+JFuTd10CPeORalQtuGPY4hdUqq0BXxTpNEP0n7ajruNsGXyCKQLP0erIZmog==", + "dependencies": { + "Microsoft.Extensions.AI.Abstractions": "10.5.2", + "Microsoft.Extensions.Logging.Abstractions": "10.0.7" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "Npgsql": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "OpenTK": { + "type": "Transitive", + "resolved": "4.9.4", + "contentHash": "fp9ZI6akO4ZuIGzuUE0QRU5zRJhXbSXa5racehFoTdvodhRDecXCOcH68mRsZaMwnxLO9ECnf2LGM9BmiJKvhw==", + "dependencies": { + "OpenTK.Audio.OpenAL": "4.9.4", + "OpenTK.Compute": "4.9.4", + "OpenTK.Core": "4.9.4", + "OpenTK.Graphics": "4.9.4", + "OpenTK.Input": "4.9.4", + "OpenTK.Mathematics": "4.9.4", + "OpenTK.Windowing.Common": "4.9.4", + "OpenTK.Windowing.Desktop": "4.9.4", + "OpenTK.Windowing.GraphicsLibraryFramework": "4.9.4" + } + }, + "OpenTK.Audio.OpenAL": { + "type": "Transitive", + "resolved": "4.9.4", + "contentHash": "N/SCeFrLJ3ckUbshyfsonbtjmDxh55urGiR+Fe1iPkTWyEW5OKfQRoIOZyjEva/4eRqOXdt3bV2ka4IJ2+JvZw==", + "dependencies": { + "OpenTK.Core": "[4.9.4, 4.10.0)", + "OpenTK.Mathematics": "[4.9.4, 4.10.0)" + } + }, + "OpenTK.Compute": { + "type": "Transitive", + "resolved": "4.9.4", + "contentHash": "30G+/9mtrUJwX5xcRyz2qcujQ2s+wDm+C7qg6jDsLyGWUdND3j/vD+nlQ6tuKuAow51zWduFVujPkaS8VrBinQ==" + }, + "OpenTK.Core": { + "type": "Transitive", + "resolved": "4.9.4", + "contentHash": "/4Mn3ABf1xNfHMzDFUapVY2K4PG3oP/oWTQJlK0JacT20HwsNe/HPIAhnAJWz27EM3ukOJDtqyQRukvQKqkcLw==" + }, + "OpenTK.GLWpfControl": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "mbKt+ZApqYk/Ym4Ph0dnI+hfSnyXn/gWW6Ys6l3C+fMlgXLYaWpbQ+/3yNm6G1lJ3vteXGxtoMJPFYlaXi/tQw==", + "dependencies": { + "OpenTK": "[4.8.2, 5.0.0)" + } + }, + "OpenTK.Graphics": { + "type": "Transitive", + "resolved": "4.9.4", + "contentHash": "yZeq5swIsRnNzifVA9fLtl+qMAQvmYiF1b7U/XQLARmmU4PYQ23lPNahqAdD22XzA21ew7eG3yDyPLkYw+hDBA==", + "dependencies": { + "OpenTK.Core": "[4.9.4, 4.10.0)", + "OpenTK.Mathematics": "[4.9.4, 4.10.0)" + } + }, + "OpenTK.Input": { + "type": "Transitive", + "resolved": "4.9.4", + "contentHash": "njbC7fySQEK30rjOQ1uRjlycEkLw+UVoPT3nRQ8uBPNLotDgr47NEcPEjmJyX1ll+pm/wmtcvJIYHBRi1HbOqg==" + }, + "OpenTK.Mathematics": { + "type": "Transitive", + "resolved": "4.9.4", + "contentHash": "2ucF25RVJzdSLXUHgjAgB058gVfSgerrR5pLCn9J+Bnyqi45NrBfPu9wyTT35ZCjYwLJCu/HJMnzKFLjq+uFIA==" + }, + "OpenTK.redist.glfw": { + "type": "Transitive", + "resolved": "3.4.0.44", + "contentHash": "HEjbdk0wWxSRrXHl3DSmrA8trecndgJpAHHloJJ1vPseNfeu/ynmrH/LturU1KguRn4r3IzZ81UIp5xRjnyahg==" + }, + "OpenTK.Windowing.Common": { + "type": "Transitive", + "resolved": "4.9.4", + "contentHash": "xNjvKjoptAh6w5/YYIx/9sNv3/A2VVlTahlekTlLP+qfNyig+0jBuLHO4bWELWZ7Ta3Y1m/2Pvk0Z1KCG8VZOA==", + "dependencies": { + "OpenTK.Core": "[4.9.4, 4.10.0)", + "OpenTK.Mathematics": "[4.9.4, 4.10.0)" + } + }, + "OpenTK.Windowing.Desktop": { + "type": "Transitive", + "resolved": "4.9.4", + "contentHash": "LBX4jwCneISIBST2vWYLI8cdR8tLwHNcswK1decElbTAXMB+H6uFIoF41ld+af4q3TsHpjR5fyP2ilbTK3T0rA==", + "dependencies": { + "OpenTK.Core": "[4.9.4, 4.10.0)", + "OpenTK.Mathematics": "[4.9.4, 4.10.0)", + "OpenTK.Windowing.Common": "[4.9.4, 4.10.0)", + "OpenTK.Windowing.GraphicsLibraryFramework": "[4.9.4, 4.10.0)" + } + }, + "OpenTK.Windowing.GraphicsLibraryFramework": { + "type": "Transitive", + "resolved": "4.9.4", + "contentHash": "wNlxnPrD7veMV8EGTTi/sCDeg5V8w/18vC0aw0Ao69eNneD7YSq9Ad+C389rVA957eHNR6Xieu/5qzHQeEOyxQ==", + "dependencies": { + "OpenTK.Core": "[4.9.4, 4.10.0)", + "OpenTK.redist.glfw": "3.4.0.44" + } + }, + "ScottPlot": { + "type": "Transitive", + "resolved": "5.1.58", + "contentHash": "prikNmFbV+z2ruXYmE06yYlnHQfuYzhPrrj30SvcUkRXfdiC07A+2LBSYhuSMaRFCHvgRKo6gXjfwHzhOW8ASg==", + "dependencies": { + "HarfBuzzSharp.NativeAssets.Linux": "8.3.1.1", + "SkiaSharp": "3.119.0", + "SkiaSharp.HarfBuzz": "3.119.0", + "SkiaSharp.NativeAssets.Linux.NoDependencies": "3.119.0" + } + }, + "ScottPlot.WPF": { + "type": "Transitive", + "resolved": "5.1.58", + "contentHash": "BL6MecJPyhllqSYsM5N9SfrrVEdgkRSyDu3lYSeiFq4dGgS0obuoGEbRiXgJq5PAZWixo+6Hcxaz5OEwElC8aQ==", + "dependencies": { + "OpenTK": "4.9.4", + "OpenTK.GLWpfControl": "4.3.3", + "ScottPlot": "5.1.58", + "SkiaSharp.Views.WPF": "3.119.0" + } + }, + "SkiaSharp": { + "type": "Transitive", + "resolved": "3.119.0", + "contentHash": "gR9yVoOta2Mc1Rxt15LD65AckfHMfwjIs/3kkD59C9bT2nYYISsE6uz3t4aMPNHA6CgsIL0Ssn+jE5OVilZ1yw==", + "dependencies": { + "SkiaSharp.NativeAssets.Win32": "3.119.0", + "SkiaSharp.NativeAssets.macOS": "3.119.0" + } + }, + "SkiaSharp.HarfBuzz": { + "type": "Transitive", + "resolved": "3.119.0", + "contentHash": "5ocGzl+RYEZFnUPUbModshnEFvKTku4hTtOBsLvy5MCK2KAHjq5Nw2+uAVUx79Rjh0z8fTojT6ZVav8MJgW0ug==", + "dependencies": { + "HarfBuzzSharp": "8.3.1.1", + "SkiaSharp": "3.119.0" + } + }, + "SkiaSharp.NativeAssets.Linux.NoDependencies": { + "type": "Transitive", + "resolved": "3.119.0", + "contentHash": "e92vdqf1VOETPjy1T67Fs1zPxfGMM1nbrpt69GM5foXSI/iIbq6L9avPz/bl/DbWtb81D0yF/NKjRmXuOZoLcg==" + }, + "SkiaSharp.NativeAssets.macOS": { + "type": "Transitive", + "resolved": "3.119.0", + "contentHash": "YE1vNn0Nyw2PWtv7hw1PYkKJO0itFiQp9vSqGppZUKzQJqwp28a2jgdCMPfYtOiR8KCnDgZqQoynqJRRaE2ZVg==" + }, + "SkiaSharp.NativeAssets.Win32": { + "type": "Transitive", + "resolved": "3.119.0", + "contentHash": "IwC9yx36lOdXVT2DjgmWHl1qkVspfj8ctd4+li8CNnvqdfaTolXCOh6TLznURcPAvzatx9K/tLOB7zT6T8EA9w==" + }, + "SkiaSharp.Views.Desktop.Common": { + "type": "Transitive", + "resolved": "3.119.0", + "contentHash": "KST7Q3n+X5BhDj1aKTJXk9tITWWznveka9n+pBjOtE53Omc+wlZR0s4fQStybWCVVaWG9eaeOMaw93J3zEMklA==", + "dependencies": { + "SkiaSharp": "3.119.0" + } + }, + "SkiaSharp.Views.WPF": { + "type": "Transitive", + "resolved": "3.119.0", + "contentHash": "1Dmj9RlBW/gvmJwSUER+N9plA/kcWOgiL5g3q2DB+USVQav8rQtfOZIQZ1Wt/AqhwepOacv1M6pZKDWtXmwDAg==", + "dependencies": { + "OpenTK": "3.3.1", + "OpenTK.GLWpfControl": "3.3.0", + "SkiaSharp": "3.119.0", + "SkiaSharp.Views.Desktop.Common": "3.119.0" + } + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "rrs2u7DRMXQG2yh0oVyF/vLwosfRv20Ld2iEpYcKwQWXHjfV+gFXNQsQ9p008kR9Ou4pxBs68Q6/9zC8Gi1wjg==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "8.16.0", + "Microsoft.IdentityModel.Tokens": "8.16.0" + } + }, + "System.IO.FileSystem.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "SxHB3nuNrpptVk+vZ/F+7OHEpoHUIKKMl02bUmYHQr1r+glbZQxs7pRtsf4ENO29TVm2TH3AEeep2fJcy92oYw==" + }, + "System.ServiceProcess.ServiceController": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "VaYklIT689DthcRzbqt3S8z3GkymiD+US+RLzMFfoPxNIyUxMpXT116cO6rVrKHSuwgdvb35HRKN/PmDmbL+3g==" + }, + "Velopack": { + "type": "Transitive", + "resolved": "1.2.0", + "contentHash": "Rz67gJL619fSBS6omaSINUxyDuwhIxkm5mmubf7uLd5Qgi6LLKaKCha+QFP6n+Bw/UjA0vutnH4JQfYzn6ANtw==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.27.0", + "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "1.9.1", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1", + "Microsoft.Testing.Platform": "1.9.1", + "Microsoft.Testing.Platform.MSBuild": "1.9.1", + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.inproc.console": "[3.2.2]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==", + "dependencies": { + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==", + "dependencies": { + "xunit.analyzers": "1.27.0", + "xunit.v3.assert": "[3.2.2]", + "xunit.v3.core.mtp-v1": "[3.2.2]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==", + "dependencies": { + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.common": "[3.2.2]" + } + }, + "performancemonitor.alerting": { + "type": "Project", + "dependencies": { + "PerformanceMonitor.Notifications": "[1.0.0, )" + } + }, + "performancemonitor.analysis": { + "type": "Project" + }, + "performancemonitor.collectors": { + "type": "Project" + }, + "performancemonitor.common": { + "type": "Project", + "dependencies": { + "CredentialManagement": "[1.0.2, )", + "Microsoft.Extensions.Logging.Abstractions": "[10.0.9, )", + "ModelContextProtocol": "[1.4.0, )" + } + }, + "performancemonitor.darling.analysis": { + "type": "Project", + "dependencies": { + "Microsoft.Data.SqlClient": "[7.0.1, )", + "PerformanceMonitor.Analysis": "[1.0.0, )", + "PerformanceMonitor.Collectors": "[1.0.0, )", + "PerformanceMonitor.Darling.Storage": "[1.0.0, )", + "PerformanceMonitor.Notifications": "[1.0.0, )", + "PerformanceMonitor.PlanAnalysis": "[1.0.0, )" + } + }, + "performancemonitor.darling.service": { + "type": "Project", + "dependencies": { + "Microsoft.Data.SqlClient": "[7.0.1, )", + "Microsoft.Extensions.Hosting": "[10.0.9, )", + "Microsoft.Extensions.Hosting.WindowsServices": "[10.0.9, )", + "ModelContextProtocol": "[1.4.0, )", + "ModelContextProtocol.AspNetCore": "[1.4.0, )", + "PerformanceMonitor.Alerting": "[1.0.0, )", + "PerformanceMonitor.Collectors": "[1.0.0, )", + "PerformanceMonitor.Common": "[1.0.0, )", + "PerformanceMonitor.Darling.Analysis": "[1.0.0, )", + "PerformanceMonitor.Darling.Storage": "[1.0.0, )", + "PerformanceMonitor.PlanAnalysis": "[1.0.0, )", + "System.IO.FileSystem.AccessControl": "[5.0.0, )" + } + }, + "performancemonitor.darling.storage": { + "type": "Project", + "dependencies": { + "Npgsql": "[10.0.3, )", + "PerformanceMonitor.Collectors": "[1.0.0, )" + } + }, + "performancemonitor.darling.viewer": { + "type": "Project", + "dependencies": { + "Hardcodet.NotifyIcon.Wpf": "[2.0.1, )", + "PerformanceMonitor.Alerting": "[1.0.0, )", + "PerformanceMonitor.Analysis": "[1.0.0, )", + "PerformanceMonitor.Common": "[1.0.0, )", + "PerformanceMonitor.Darling.Analysis": "[1.0.0, )", + "PerformanceMonitor.Darling.Storage": "[1.0.0, )", + "PerformanceMonitor.Notifications": "[1.0.0, )", + "PerformanceMonitor.PlanAnalysis": "[1.0.0, )", + "PerformanceMonitor.Ui": "[1.0.0, )", + "ScottPlot.WPF": "[5.1.58, )", + "Velopack": "[1.2.0, )" + } + }, + "performancemonitor.notifications": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "[10.0.9, )", + "PerformanceMonitor.Analysis": "[1.0.0, )" + } + }, + "performancemonitor.plananalysis": { + "type": "Project", + "dependencies": { + "Microsoft.Data.SqlClient": "[7.0.1, )", + "PerformanceMonitor.Common": "[1.0.0, )" + } + }, + "performancemonitor.ui": { + "type": "Project", + "dependencies": { + "PerformanceMonitor.Common": "[1.0.0, )", + "PerformanceMonitor.PlanAnalysis": "[1.0.0, )", + "ScottPlot.WPF": "[5.1.58, )" + } + } + } + } +} \ No newline at end of file diff --git a/Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs b/Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs new file mode 100644 index 000000000..2a05c5fac --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs @@ -0,0 +1,427 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Npgsql; +using PerformanceMonitor.Analysis; + +namespace PerformanceMonitor.Darling.Analysis; + +/// +/// Orchestrates the full analysis pipeline: collect → score → traverse → persist. +/// Can be run on-demand or on a timer. Each run analyzes a single server's data +/// for a given time window and persists the findings. +/// +/// +/// Port of Lite's AnalysisService (Phase-5 analysis slice AN3) over the PG pieces — Lite's +/// pipeline ORDER (24h-data-span gate → CollectFacts → DetectAnomalies → ScoreAll → +/// BuildStories → PopulateStoryText → ClusterIntoIncidents + StampClusters → persist → +/// enrich) with the DASHBOARD twin's richer SAVE phase adopted (recommendations rebuild +/// D2/P2): mute-filter WITHOUT inserting → enrich the survivors → build + attach each +/// finding's RemediationAction from the drill-down → insert with +/// remediation_action_json persisted. The V4 schema carries that column and +/// implements the two-phase surface precisely for this. +/// +/// +/// +/// Clock semantics are LITE's, not the Dashboard's: the analysis window is built from host +/// UTC and the context times are used as-is (no server-clock probe, no offset math) because +/// Darling's collectors stamp collection_time with the service host's +/// DateTime.UtcNow exactly like Lite's — the windowed reads and the collected rows +/// share one clock already. ServerUtcOffset stays , which +/// treats as an identity conversion. +/// +/// +/// +/// /* PG port deviations from the twins, the PgFactCollector conventions: the DuckDB +/// initializer / SQL connection string becomes an ; Lite's +/// static AppLogger / Dashboard's static Logger become an injected optional +/// ; the data-span SQL moves to a public const for the ungated +/// dialect pins (text is Lite's verbatim — EXTRACT(EPOCH FROM ...) is shared-dialect +/// and runs unchanged on Postgres, reading the raw wait_stats table with Lite's +/// multi-server server_id = $1 filter, which the single-server Dashboard twin +/// drops). Dashboard's GetServerClockAsync/GetServerLocalNowAsync are +/// deliberately NOT ported — they exist only for its server-local collection clock. */ +/// +/// +public sealed class DarlingAnalysisService +{ + private readonly NpgsqlDataSource _postgres; + private readonly PgFindingStore _findingStore; + private readonly PgFactCollector _collector; + private readonly FactScorer _scorer; + private readonly RelationshipGraph _graph; + private readonly InferenceEngine _engine; + private readonly PgDrillDownCollector _drillDown; + private readonly PgAnomalyDetector _anomalyDetector; + private readonly PgBaselineProvider _baselineProvider; + private readonly ILogger? _logger; + + /// + /// Minimum hours of collected data required before analysis will run. + /// Short collection windows distort fraction-of-period calculations — + /// 5 seconds of THREADPOOL looks alarming in a 16-minute window. + /// 24 hours has been validated empirically as sufficient. + /// + internal double MinimumDataHours { get; set; } = 24; + + /// + /// Raised after each analysis run completes, providing the findings — the twins' UI + /// hook, kept so the surface stays twin-shaped (the worker awaits AnalyzeAsync's return + /// directly, like Lite's scheduler does). + /// + public event EventHandler? AnalysisCompleted; + + /// + /// Whether an analysis is currently running. + /// + public bool IsAnalyzing { get; private set; } + + /// + /// Time of the last completed analysis run. + /// + public DateTime? LastAnalysisTime { get; private set; } + + /// + /// Set after AnalyzeAsync if insufficient data was found. Null if enough data exists. + /// + public string? InsufficientDataMessage { get; private set; } + + public DarlingAnalysisService(NpgsqlDataSource postgres, IPlanFetcher? planFetcher = null, ILogger? logger = null) + { + _postgres = postgres ?? throw new ArgumentNullException(nameof(postgres)); + _logger = logger; + _findingStore = new PgFindingStore(postgres, logger); + _collector = new PgFactCollector(postgres); + _scorer = new FactScorer(); + _graph = new RelationshipGraph(); + _engine = new InferenceEngine(_graph); + _drillDown = new PgDrillDownCollector(postgres, planFetcher, logger); + _baselineProvider = new PgBaselineProvider(postgres, logger); + _anomalyDetector = new PgAnomalyDetector(postgres, _baselineProvider, logger); + } + + /// + /// Runs the full analysis pipeline for a server. + /// Default time range is the last 4 hours. Host-UTC window (Lite's clock semantics — + /// Darling's collectors stamp rows with the service host's UTC clock). + /// + public async Task> AnalyzeAsync(int serverId, string serverName, int hoursBack = 4) + { + var timeRangeEnd = DateTime.UtcNow; + var timeRangeStart = timeRangeEnd.AddHours(-hoursBack); + + var context = new AnalysisContext + { + ServerId = serverId, + ServerName = serverName, + TimeRangeStart = timeRangeStart, + TimeRangeEnd = timeRangeEnd + }; + + return await AnalyzeAsync(context); + } + + /// + /// Runs the full analysis pipeline with a specific context. + /// + public async Task> AnalyzeAsync(AnalysisContext context) + { + if (IsAnalyzing) + return []; + + IsAnalyzing = true; + InsufficientDataMessage = null; + + try + { + // 0. Check minimum data span — total history, not the analysis window. + // A server with 100h of total history can be analyzed over a 4h window. + var dataSpanHours = await GetTotalDataSpanHoursAsync(context.ServerId); + if (dataSpanHours < MinimumDataHours) + { + var needed = MinimumDataHours >= 24 + ? $"{MinimumDataHours / 24:F1} days" + : $"{MinimumDataHours:F0} hours"; + var have = dataSpanHours >= 24 + ? $"{dataSpanHours / 24:F1} days" + : $"{dataSpanHours:F1} hours"; + + InsufficientDataMessage = + $"Not enough data for reliable analysis. Need {needed} of collected data, " + + $"have {have}. Keep the collector running and try again later."; + + _logger?.LogInformation( + "[DarlingAnalysisService] Skipping analysis for {Server}: {Have:F1}h data, need {Need}h", + context.ServerName, dataSpanHours, MinimumDataHours); + + LastAnalysisTime = DateTime.UtcNow; + return []; + } + + // 1. Collect facts from the Postgres store + var facts = await _collector.CollectFactsAsync(context); + + if (facts.Count == 0) + { + LastAnalysisTime = DateTime.UtcNow; + return []; + } + + // 1.5. Detect anomalies (compare analysis window against baseline) + var anomalies = await _anomalyDetector.DetectAnomaliesAsync(context); + facts.AddRange(anomalies); + + // 2. Score facts (base severity + amplifiers) + _scorer.ScoreAll(facts); + + // 3. Build stories via graph traversal + var stories = _engine.BuildStories(facts); + + // 3.5. Freeze value-stated advice (current MAXDOP/CTFP/etc.) into each story's StoryText + // from the FULL fact set, BEFORE the store copies StoryText onto the finding. This is the + // only place the raw fact VALUES are in scope; read-back cards then state the numbers + // (FactAdvice.GetComposedForFinding) instead of generic folklore. No schema change. + FactAdvice.PopulateStoryText(stories, facts); + + // 3.6. Cluster the run's stories into causally-related incidents (graph-connectivity) and + // stamp each with its own trackable id, BEFORE the store copies it onto the finding. The + // grouped surface renders one report per incident; the id fingerprints the incident's + // primary so the same recurring incident is trackable across runs. + var incidents = _engine.ClusterIntoIncidents(stories, facts); + IncidentId.StampClusters(context.ServerName, incidents); + + // 3.7. Fold each ANOMALY_* story into the REGULAR finding that describes the same symptom + // (same run, same database) by rewriting its stamped incident id onto that parent's — so + // the anomaly stops rendering as its own card / its own email. No-parent anomalies stay + // solo; db-scoped object anomalies never cross databases. Presentation-only: nothing is + // dropped, only the incident tag is reconciled. + AnomalyIncidentReconciler.Reconcile(stories); + + // 4. Mute-filter the stories into the surviving findings (the Dashboard twin's D2/P2 + // reorder) — WITHOUT inserting yet, so enrichment + action-build happen on the + // survivors first and the BUILT RemediationAction is persisted on each row. Muted/ + // absolution findings are dropped here and never enriched. + var findings = await _findingStore.FilterMutedFindingsAsync(stories, context); + + // 5. Enrich the survivors with drill-down data (ephemeral except through the built + // action; the cheap config drill-downs run below the 0.5 gate inside the collector). + await _drillDown.EnrichFindingsAsync(findings, context); + + // 6. Build + attach each finding's RemediationAction from the now drill-down- + // populated finding (D2). The builders REQUIRE finding.DrillDown, which the + // store read-back does not return — so the BUILT action is persisted, exactly + // the artifact the alert path serializes into ContextJson. Try the always-safe/ + // db-config force action first, then the destructive entry points (each gates + // internally on RootFactKey + drill-down and returns null when N/A); attach the + // first non-null. + foreach (var finding in findings) + { + finding.Remediation = + FactRemediation.BuildAction(finding) + ?? FactRemediation.BuildRcsiAction(finding) + ?? FactRemediation.BuildClearPlanAction(finding) + ?? FactRemediation.BuildFileAutogrowthAction(finding) // WS3: advisory only (no handler -> no Apply); carried for the read-time copy-paste + ?? FactRemediation.BuildServerConfigAction(finding) // WS3: server-level config — MAXDOP/CTFP/memory + ?? FactRemediation.BuildMissingIndexAction(finding); // WS4: missing-index CREATE — copy-paste only + } + + // 7. Insert the survivors in one batched pass, persisting remediation_action_json. + await _findingStore.InsertFindingsAsync(findings, context); + + LastAnalysisTime = DateTime.UtcNow; + + // 8. Notify listeners — the returned/enriched findings (now action-bearing) also + // flow back to the caller (the worker), which routes them to the shared + // AnalysisNotificationService. + AnalysisCompleted?.Invoke(this, new AnalysisCompletedEventArgs + { + ServerId = context.ServerId, + ServerName = context.ServerName, + Findings = findings, + AnalysisTime = LastAnalysisTime.Value + }); + + _logger?.LogInformation( + "[DarlingAnalysisService] Analysis complete for {Server}: {Count} finding(s), highest severity {Severity:F2}", + context.ServerName, findings.Count, findings.Count > 0 ? findings.Max(f => f.Severity) : 0); + + return findings; + } + catch (Exception ex) + { + _logger?.LogError("[DarlingAnalysisService] Analysis failed for {Server}: {Message}", + context.ServerName, ex.Message); + return []; + } + finally + { + IsAnalyzing = false; + } + } + + /// + /// Runs the collect + score pipeline without graph traversal. + /// Returns raw scored facts with amplifier details for direct inspection. + /// + public async Task> CollectAndScoreFactsAsync(int serverId, string serverName, int hoursBack = 4) + { + var timeRangeEnd = DateTime.UtcNow; + var timeRangeStart = timeRangeEnd.AddHours(-hoursBack); + + var context = new AnalysisContext + { + ServerId = serverId, + ServerName = serverName, + TimeRangeStart = timeRangeStart, + TimeRangeEnd = timeRangeEnd + }; + + try + { + var facts = await _collector.CollectFactsAsync(context); + if (facts.Count == 0) return facts; + _scorer.ScoreAll(facts); + return facts; + } + catch (Exception ex) + { + _logger?.LogError("[DarlingAnalysisService] Fact collection failed for {Server}: {Message}", + serverName, ex.Message); + return []; + } + } + + /// + /// Compares analysis of two time periods, returning facts from both for comparison. + /// + public async Task<(List BaselineFacts, List ComparisonFacts)> ComparePeriodsAsync( + int serverId, string serverName, + DateTime baselineStart, DateTime baselineEnd, + DateTime comparisonStart, DateTime comparisonEnd) + { + var baselineContext = new AnalysisContext + { + ServerId = serverId, + ServerName = serverName, + TimeRangeStart = baselineStart, + TimeRangeEnd = baselineEnd + }; + + var comparisonContext = new AnalysisContext + { + ServerId = serverId, + ServerName = serverName, + TimeRangeStart = comparisonStart, + TimeRangeEnd = comparisonEnd + }; + + try + { + var baselineFacts = await _collector.CollectFactsAsync(baselineContext); + var comparisonFacts = await _collector.CollectFactsAsync(comparisonContext); + + _scorer.ScoreAll(baselineFacts); + _scorer.ScoreAll(comparisonFacts); + + return (baselineFacts, comparisonFacts); + } + catch (Exception ex) + { + _logger?.LogError("[DarlingAnalysisService] Period comparison failed for {Server}: {Message}", + serverName, ex.Message); + return ([], []); + } + } + + /// + /// Gets the latest findings for a server without running a new analysis. + /// + public async Task> GetLatestFindingsAsync(int serverId) + { + return await _findingStore.GetLatestFindingsAsync(serverId); + } + + /// + /// Gets recent findings for a server within the given time range. + /// + public async Task> GetRecentFindingsAsync(int serverId, int hoursBack = 24) + { + return await _findingStore.GetRecentFindingsAsync(serverId, hoursBack); + } + + /// + /// Mutes a finding pattern so it won't appear in future runs. + /// + public async Task MuteFindingAsync(AnalysisFinding finding, string? reason = null) + { + await _findingStore.MuteStoryAsync( + finding.ServerId, finding.StoryPathHash, finding.StoryPath, reason); + } + + /// + /// Cleans up old findings beyond the retention period. + /// + public async Task CleanupAsync(int retentionDays = 30) + { + await _findingStore.CleanupOldFindingsAsync(retentionDays); + } + + /// + /// Lite's data-span query VERBATIM — EXTRACT(EPOCH FROM ...) is shared dialect and runs + /// unchanged on Postgres. Reads the raw wait_stats table (not the view) like Lite, with + /// Lite's multi-server server_id filter (the Dashboard twin is single-server and drops it). + /// Exposed const so Darling.Tests can pin the dialect ungated. + /// + public const string TotalDataSpanSql = @" +SELECT EXTRACT(EPOCH FROM (MAX(collection_time) - MIN(collection_time))) / 3600.0 +FROM wait_stats +WHERE server_id = $1"; + + /// + /// Returns the total span of collected data for a server (no time range filter). + /// This answers "has this server been monitored long enough?" — separate from + /// the analysis window. A server with 100 hours of total history can safely + /// be analyzed over a 4-hour window without dilution. + /// + private async Task GetTotalDataSpanHoursAsync(int serverId) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(TotalDataSpanSql, connection); + cmd.Parameters.AddWithValue(serverId); + + var result = await cmd.ExecuteScalarAsync(); + if (result == null || result is DBNull) + return 0; + + return Convert.ToDouble(result); + } + catch + { + return 0; + } + } +} + +/// +/// Event args for when an analysis run completes. +/// +public class AnalysisCompletedEventArgs : EventArgs +{ + public int ServerId { get; set; } + public string ServerName { get; set; } = string.Empty; + public List Findings { get; set; } = []; + public DateTime AnalysisTime { get; set; } +} diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PerformanceMonitor.Darling.Analysis.csproj b/Darling/PerformanceMonitor.Darling.Analysis/PerformanceMonitor.Darling.Analysis.csproj new file mode 100644 index 000000000..4a448e574 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Analysis/PerformanceMonitor.Darling.Analysis.csproj @@ -0,0 +1,41 @@ + + + net10.0 + enable + disable + PerformanceMonitor.Darling.Analysis + PerformanceMonitor.Darling.Analysis + Darling Data, LLC + Copyright © 2026 Darling Data, LLC + true + latest-recommended + CA1849;CA2007;CA1508;CA1822;CA1805;CA1510;CA1816;CA1861;CA1845;CA2201;CA1848;CA1852;CA1305;CA1860;CA1707;CA1507;CA1806;CA2254 + CS4014 + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgAnomalyDetector.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgAnomalyDetector.cs new file mode 100644 index 000000000..d4ad77ff0 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Analysis/PgAnomalyDetector.cs @@ -0,0 +1,998 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Npgsql; +using PerformanceMonitor.Analysis; +using PerformanceMonitor.Analysis.Baselines; +using static PerformanceMonitor.Analysis.Baselines.AnomalyThresholds; + +namespace PerformanceMonitor.Darling.Analysis; + +/// +/// Detects anomalies by comparing the analysis window's metrics against +/// time-bucketed baselines (hour-of-day x day-of-week, 30-day rolling window) — +/// Lite's AnomalyDetector (Lite/Analysis/AnomalyDetector.cs) ported for the +/// analysis slice AN2b onto Darling's Postgres store: the same nine detector +/// methods plus the HasBaselineDataAsync gate, the same fact keys, metadata +/// keys, thresholds, and severity inputs, and the same per-detector try/catch +/// tolerance (a failing detector logs and contributes nothing; it never fails +/// the run). +/// +/// Two detection patterns: +/// - Z-score: (observed - mean) / stddev — used for continuous metrics +/// (CPU, batch requests, I/O latency, session counts, query duration, memory) +/// - Ratio: currentRate / baselineRate — used for rate/event metrics +/// (wait stats, blocking, deadlocks) +/// +/// Baseline computation and caching are handled by . +/// +/// +/// Postgres discipline (see PgFindingStore): the SQL is Lite-verbatim against the +/// V4 passthrough views (already dialect-shared — no QUALIFY in the detector +/// queries), with every window bound a naive-UTC Kind-Unspecified parameter. +/// Lite's SQL contains no bare NOW()/CURRENT_TIMESTAMP; the one C#-side "now" +/// (the 30-day baseline-data gate in 's $2) is +/// bound as a naive-UTC parameter exactly like Lite binds DateTime.UtcNow. +/// SQL is exposed const so Darling.Tests can pin the dialect ungated +/// ($N positional parameters, no QUALIFY, no bare now(), no N'' literals) — +/// the PgFindingStore/DarlingAlertReadAdapter pattern. +/// +/// +public class PgAnomalyDetector +{ + private readonly NpgsqlDataSource _postgres; + private readonly PgBaselineProvider _baselineProvider; + private readonly ILogger? _logger; + + /// + /// Per-metric deviation thresholds. Metrics not listed use DefaultDeviationThreshold. + /// + private readonly Dictionary _deviationThresholds = new(); + + public PgAnomalyDetector(NpgsqlDataSource postgres, PgBaselineProvider baselineProvider, ILogger? logger = null) + { + _postgres = postgres ?? throw new ArgumentNullException(nameof(postgres)); + _baselineProvider = baselineProvider ?? throw new ArgumentNullException(nameof(baselineProvider)); + _logger = logger; + } + + /// + /// Sets a custom deviation threshold for a specific metric. + /// + public void SetDeviationThreshold(string metricName, double threshold) + { + _deviationThresholds[metricName] = threshold; + } + + private double GetDeviationThreshold(string metricName) + { + return _deviationThresholds.TryGetValue(metricName, out var threshold) + ? threshold + : DefaultDeviationThreshold; + } + + /// + /// Adds baseline context metadata to an anomaly fact's metadata dictionary. + /// + private static void AddBaselineContext(Dictionary metadata, BaselineBucket baseline) + { + metadata["baseline_hour"] = baseline.HourOfDay; + metadata["baseline_dow"] = baseline.DayOfWeek; + metadata["baseline_tier"] = (double)baseline.Tier; + } + + /// + /// Detects anomalies by comparing the analysis window against time-bucketed baselines. + /// Returns anomaly facts to be merged into the main fact list. + /// + public async Task> DetectAnomaliesAsync(AnalysisContext context) + { + var anomalies = new List(); + + // Check if baseline period has any data at all — if not, skip all anomaly detection. + if (!await HasBaselineDataAsync(context.ServerId)) + return anomalies; + + // Existing detection methods (upgraded to time-bucketed baselines) + await DetectCpuAnomalies(context, anomalies); + await DetectWaitAnomalies(context, anomalies); + await DetectBlockingAnomalies(context, anomalies); + await DetectIoAnomalies(context, anomalies); + + // New detection methods + await DetectBatchRequestAnomalies(context, anomalies); + await DetectSessionAnomalies(context, anomalies); + await DetectQueryDurationAnomalies(context, anomalies); + await DetectMemoryAnomalies(context, anomalies); + await DetectObjectStatsAnomalies(context, anomalies); + + return anomalies; + } + + /* ---------------- SQL (Lite-verbatim, PG dialect only where forced) ---------------- */ + + /// + /// Baseline-data gate: wait_stats as canary — if waits are collected, other data is too. + /// $2 is the ONLY "now"-derived bound in this class (DateTime.UtcNow.AddDays(-30), bound + /// naive-UTC Kind-Unspecified — never a bare now(), which would be timestamptz). + /// + public const string HasBaselineDataSql = @" +SELECT (SELECT COUNT(*) FROM v_wait_stats + WHERE server_id = $1 AND collection_time >= $2) + + (SELECT COUNT(*) FROM v_cpu_utilization_stats + WHERE server_id = $1 AND collection_time >= $2)"; + + public const string CpuWindowSql = @" +SELECT MAX(sqlserver_cpu_utilization) AS peak_cpu, + AVG(sqlserver_cpu_utilization) AS avg_cpu, + COUNT(*) AS sample_count, + (SELECT collection_time FROM v_cpu_utilization_stats + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + ORDER BY sqlserver_cpu_utilization DESC LIMIT 1) AS peak_time +FROM v_cpu_utilization_stats +WHERE server_id = $1 +AND collection_time >= $2 AND collection_time < $3"; + + /* Wait-profile current window: all-types wait ms/sec per collection (interval via LAG — never an + assumed cadence, mirrors the WaitMsPerSec baseline), then PEAK across collections. Window bound + aligned to the baseline (>= $2 AND < $3). */ + public const string WaitRateWindowSql = @" +WITH per_collection AS ( + SELECT collection_time, + SUM(delta_wait_time_ms)::DOUBLE PRECISION AS total_wait_ms, + extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (ORDER BY collection_time)))) AS interval_sec + FROM v_wait_stats + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + AND delta_wait_time_ms >= 0 + GROUP BY collection_time +) +SELECT MAX(CASE WHEN interval_sec > 0 THEN total_wait_ms / interval_sec ELSE 0 END) AS peak_ms_per_sec, + SUM(total_wait_ms) AS total_wait_ms, + COUNT(*) FILTER (WHERE interval_sec IS NOT NULL) AS sample_count +FROM per_collection"; + + /* Top 6 wait-type contributors in the window (named in the metadata KEY). */ + public const string WaitContribWindowSql = @" +SELECT wait_type, + SUM(delta_wait_time_ms)::BIGINT AS total_ms +FROM v_wait_stats +WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 +AND delta_wait_time_ms > 0 +GROUP BY wait_type +ORDER BY total_ms DESC +LIMIT 6"; + + /* current_blocking: prefer the blocked-process-report; fall back to the always-on DMV + snapshot so RDS (where the BPR session is empty) still counts blocking. Mirrors the + overview/alert path (Lite LocalDataService.Overview.cs / LocalDataService.Blocking.cs). */ + public const string BlockingWindowSql = @" +SELECT + COALESCE(NULLIF( + (SELECT COUNT(*) FROM v_blocked_process_reports + WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3), 0), + (SELECT COUNT(*) FROM v_dmv_blocking_snapshots + WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3)) AS current_blocking, + (SELECT COUNT(*) FROM v_deadlocks + WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3) AS current_deadlocks"; + + public const string IoWindowSql = @" +SELECT AVG(delta_stall_read_ms * 1.0 / NULLIF(delta_reads, 0)) AS avg_read_lat, + AVG(delta_stall_write_ms * 1.0 / NULLIF(delta_writes, 0)) AS avg_write_lat +FROM v_file_io_stats +WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 +AND (delta_reads > 0 OR delta_writes > 0)"; + + public const string BatchRequestWindowSql = @" +SELECT AVG(delta_cntr_value) AS avg_batch, + MAX(delta_cntr_value) AS peak_batch, + COUNT(*) AS sample_count +FROM v_perfmon_stats +WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 +AND counter_name = 'Batch Requests/sec' +AND delta_cntr_value >= 0"; + + public const string SessionWindowSql = @" +WITH per_collection AS ( + SELECT collection_time, + SUM(connection_count)::DOUBLE PRECISION AS total_connections + FROM v_session_stats + WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 + GROUP BY collection_time +) +SELECT AVG(total_connections) AS avg_connections, + MAX(total_connections) AS peak_connections, + COUNT(*) AS sample_count +FROM per_collection"; + + public const string QueryDurationWindowSql = @" +WITH per_collection AS ( + SELECT collection_time, + SUM(delta_elapsed_time)::DOUBLE PRECISION AS total_elapsed + FROM v_query_stats + WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 + AND delta_execution_count > 0 + AND delta_elapsed_time >= 0 + GROUP BY collection_time +) +SELECT AVG(total_elapsed) AS avg_elapsed, + MAX(total_elapsed) AS peak_elapsed, + COUNT(*) AS sample_count +FROM per_collection"; + + public const string MemoryWindowSql = @" +SELECT AVG(total_server_memory_mb::DOUBLE PRECISION / NULLIF(target_server_memory_mb::DOUBLE PRECISION, 0) * 100) AS avg_pressure, + MAX(total_server_memory_mb::DOUBLE PRECISION / NULLIF(target_server_memory_mb::DOUBLE PRECISION, 0) * 100) AS peak_pressure, + COUNT(*) AS sample_count +FROM v_memory_stats +WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 +AND target_server_memory_mb > 0"; + + public const string ObjectGrowthSql = @" +WITH snaps AS (SELECT DISTINCT collection_time FROM v_index_object_stats WHERE server_id = $1 ORDER BY collection_time DESC LIMIT 2), +latest AS (SELECT MAX(collection_time) t FROM snaps), +prior AS (SELECT MIN(collection_time) t FROM snaps), +cur AS (SELECT database_name, object_id, MAX(schema_name) schema_name, MAX(table_name) table_name, SUM(reserved_mb) mb + FROM v_index_object_stats WHERE server_id = $1 AND collection_time = (SELECT t FROM latest) GROUP BY database_name, object_id), +prv AS (SELECT database_name, object_id, SUM(reserved_mb) mb + FROM v_index_object_stats WHERE server_id = $1 AND collection_time = (SELECT t FROM prior) GROUP BY database_name, object_id) +SELECT cur.database_name, cur.schema_name, cur.table_name, prv.mb AS prior_mb, cur.mb AS current_mb, + cur.mb - prv.mb AS growth_mb, + CASE WHEN prv.mb > 0 THEN (cur.mb - prv.mb) * 100.0 / prv.mb ELSE 0 END AS growth_pct +FROM cur JOIN prv ON cur.database_name = prv.database_name AND cur.object_id = prv.object_id +WHERE (SELECT t FROM latest) <> (SELECT t FROM prior) +AND cur.mb - prv.mb >= $2 +AND (CASE WHEN prv.mb > 0 THEN (cur.mb - prv.mb) * 100.0 / prv.mb ELSE 0 END) >= $3 +ORDER BY growth_mb DESC LIMIT 1"; + + public const string ObjectContentionSql = @" +WITH snaps AS (SELECT DISTINCT collection_time FROM v_index_object_stats WHERE server_id = $1 ORDER BY collection_time DESC LIMIT 2), +latest AS (SELECT MAX(collection_time) t FROM snaps), +prior AS (SELECT MIN(collection_time) t FROM snaps), +cur AS (SELECT database_name, object_id, index_id, schema_name, table_name, index_name, + COALESCE(row_lock_wait_in_ms,0) ms, COALESCE(index_lock_promotion_count,0) esc + FROM v_index_object_stats WHERE server_id = $1 AND collection_time = (SELECT t FROM latest)), +prv AS (SELECT database_name, object_id, index_id, COALESCE(row_lock_wait_in_ms,0) ms, COALESCE(index_lock_promotion_count,0) esc + FROM v_index_object_stats WHERE server_id = $1 AND collection_time = (SELECT t FROM prior)) +SELECT cur.database_name, cur.schema_name, cur.table_name, cur.index_name, + cur.ms - prv.ms AS ms_delta, cur.esc - prv.esc AS esc_delta +FROM cur JOIN prv ON cur.database_name = prv.database_name AND cur.object_id = prv.object_id AND cur.index_id = prv.index_id +WHERE (SELECT t FROM latest) <> (SELECT t FROM prior) +AND cur.ms >= prv.ms +AND cur.ms - prv.ms >= $2 +ORDER BY ms_delta DESC LIMIT 1"; + + /* ---------------- detectors (Lite-verbatim logic) ---------------- */ + + /// + /// Day-over-day object/index detection (delta-based, not stddev-baseline) since the + /// index_object_stats collector runs daily and its counters are cumulative. + /// Emits ANOMALY_OBJECT_GROWTH for the biggest table grower over threshold and + /// ANOMALY_OBJECT_CONTENTION for the index with the largest new lock-wait time. + /// + private async Task DetectObjectStatsAnomalies(AnalysisContext context, List anomalies) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + // Growth: biggest day-over-day table grower (indexes rolled up) over threshold. + using (var cmd = new NpgsqlCommand(ObjectGrowthSql, connection)) + { + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(ObjectGrowthMbThreshold); + cmd.Parameters.AddWithValue(ObjectGrowthPctThreshold); + + using var reader = await cmd.ExecuteReaderAsync(); + if (await reader.ReadAsync()) + { + var db = reader.GetString(0); + var gSchema = reader.IsDBNull(1) ? null : reader.GetValue(1)?.ToString(); + var gTable = reader.IsDBNull(2) ? null : reader.GetValue(2)?.ToString(); + var growthMb = Convert.ToDouble(reader.GetValue(5)); + var growthPct = Convert.ToDouble(reader.GetValue(6)); + anomalies.Add(new Fact + { + Source = "anomaly", + Key = "ANOMALY_OBJECT_GROWTH", + Value = growthMb, + ServerId = context.ServerId, + DatabaseName = db, + ObjectName = string.IsNullOrEmpty(gTable) ? null : string.IsNullOrEmpty(gSchema) ? gTable : $"{gSchema}.{gTable}", + Metadata = new Dictionary + { + ["prior_mb"] = Convert.ToDouble(reader.GetValue(3)), + ["current_mb"] = Convert.ToDouble(reader.GetValue(4)), + ["growth_mb"] = growthMb, + ["growth_pct"] = growthPct, + ["growth_ratio"] = growthPct / ObjectGrowthPctThreshold + } + }); + } + } + + // Contention: index with the largest new row-lock wait time (no reset). + using (var cmd = new NpgsqlCommand(ObjectContentionSql, connection)) + { + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(ObjectLockWaitMsDeltaThreshold); + + using var reader = await cmd.ExecuteReaderAsync(); + if (await reader.ReadAsync()) + { + var db = reader.GetString(0); + var cSchema = reader.IsDBNull(1) ? null : reader.GetValue(1)?.ToString(); + var cTable = reader.IsDBNull(2) ? null : reader.GetValue(2)?.ToString(); + var cIndex = reader.IsDBNull(3) ? null : reader.GetValue(3)?.ToString(); + var msDelta = Convert.ToDouble(reader.GetValue(4)); + string? contendedObject = null; + if (!string.IsNullOrEmpty(cTable)) + { + contendedObject = string.IsNullOrEmpty(cSchema) ? cTable : $"{cSchema}.{cTable}"; + if (!string.IsNullOrEmpty(cIndex)) + contendedObject += $", index {cIndex}"; + } + anomalies.Add(new Fact + { + Source = "anomaly", + Key = "ANOMALY_OBJECT_CONTENTION", + Value = msDelta, + ServerId = context.ServerId, + DatabaseName = db, + ObjectName = contendedObject, + Metadata = new Dictionary + { + ["lock_wait_ms_delta"] = msDelta, + ["escalation_delta"] = Convert.ToDouble(reader.GetValue(5)), + ["contention_ratio"] = msDelta / ObjectLockWaitMsDeltaThreshold + } + }); + } + } + } + catch (Exception ex) + { + _logger?.LogError("[PgAnomalyDetector] Object stats anomaly detection failed: {Message}", ex.Message); + } + } + + /// + /// Checks if the server has enough historical data for meaningful baselines. + /// Uses wait_stats as canary — if waits are collected, other data is too. + /// + private async Task HasBaselineDataAsync(int serverId) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(HasBaselineDataSql, connection); + cmd.Parameters.AddWithValue(serverId); + /* Lite binds DateTime.UtcNow.AddDays(-30) here too — the parameterized "now", + made Kind-Unspecified for the naive-UTC timestamp columns. */ + cmd.Parameters.AddWithValue(AsNaive(DateTime.UtcNow.AddDays(-30))); + + var count = Convert.ToInt64(await cmd.ExecuteScalarAsync() ?? 0); + return count > 0; + } + catch { return false; } + } + + /// + /// Detects CPU utilization anomalies using z-score against time-bucketed baseline. + /// + private async Task DetectCpuAnomalies(AnalysisContext context, List anomalies) + { + try + { + var baseline = await _baselineProvider.GetBaselineAsync( + context.ServerId, MetricNames.Cpu, context.TimeRangeStart); + + if (baseline.SampleCount == 0) return; + // No effectiveStdDev<=0 early return — an untrustworthy/zero-dispersion baseline falls + // back to the absolute bar (below) rather than going silent. + var effectiveStdDev = baseline.EffectiveStdDev; + + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(CpuWindowSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + var peakCpu = reader.IsDBNull(0) ? 0.0 : Convert.ToDouble(reader.GetValue(0)); + var avgCpu = reader.IsDBNull(1) ? 0.0 : Convert.ToDouble(reader.GetValue(1)); + var windowSamples = reader.IsDBNull(2) ? 0L : Convert.ToInt64(reader.GetValue(2)); + var peakTime = reader.IsDBNull(3) ? (DateTime?)null : reader.GetDateTime(3); + + if (windowSamples == 0) return; + + var decision = AnomalyGate.EvaluateZScore( + baseline.Mean, effectiveStdDev, baseline.IsTrustworthy, peakCpu, + GetDeviationThreshold(MetricNames.Cpu), CpuFloorPct, CpuFallbackPct, SigmaDisplayCap); + if (!decision.Fire) return; + + var metadata = new Dictionary + { + ["peak_cpu"] = peakCpu, + ["avg_cpu_in_window"] = avgCpu, + ["baseline_mean"] = baseline.Mean, + ["baseline_stddev"] = effectiveStdDev, + ["deviation_sigma"] = decision.Sigma, + ["baseline_low_quality"] = decision.LowQualityBaseline ? 1 : 0, + ["fallback_exceedance"] = decision.FallbackExceedance, + ["baseline_samples"] = baseline.SampleCount, + ["window_samples"] = windowSamples, + ["confidence"] = 1.0, + ["peak_time_ticks"] = peakTime?.Ticks ?? 0 + }; + AddBaselineContext(metadata, baseline); + + anomalies.Add(new Fact + { + Source = "anomaly", + Key = "ANOMALY_CPU_SPIKE", + Value = peakCpu, + ServerId = context.ServerId, + Metadata = metadata + }); + } + catch (Exception ex) + { + _logger?.LogError("[PgAnomalyDetector] CPU anomaly detection failed: {Message}", ex.Message); + } + } + + /// + /// Detects a shift in the wait PROFILE — the whole-server all-types wait rate (ms/sec) running + /// significantly above its time-bucketed baseline — and emits ONE ANOMALY_WAIT_PROFILE fact with + /// the top wait types as contrib_<TYPE> metadata. Replaces the old per-type + /// ANOMALY_WAIT_<type> facts, which compared a per-hour per-type value to a per-interval + /// all-types baseline (a ~240x unit inflation) and missed a minority-but-real wait. Comparing + /// all-types-vs-all-types on the honest per-second scale fixes units, aggregation, and the + /// per-type cascade together. + /// + private async Task DetectWaitAnomalies(AnalysisContext context, List anomalies) + { + try + { + var baseline = await _baselineProvider.GetBaselineAsync( + context.ServerId, MetricNames.WaitMsPerSec, context.TimeRangeStart); + + await using var connection = await _postgres.OpenConnectionAsync(); + + // Current window: all-types wait ms/sec per collection (interval via LAG), then PEAK. + double peakRate; + double totalWaitMs; + long collectionCount; + using (var rateCmd = new NpgsqlCommand(WaitRateWindowSql, connection)) + { + rateCmd.Parameters.AddWithValue(context.ServerId); + rateCmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + rateCmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var rateReader = await rateCmd.ExecuteReaderAsync(); + if (!await rateReader.ReadAsync()) return; + peakRate = rateReader.IsDBNull(0) ? 0.0 : Convert.ToDouble(rateReader.GetValue(0)); + totalWaitMs = rateReader.IsDBNull(1) ? 0.0 : Convert.ToDouble(rateReader.GetValue(1)); + collectionCount = rateReader.IsDBNull(2) ? 0L : Convert.ToInt64(rateReader.GetValue(2)); + } + + if (collectionCount == 0) return; // no rated collection in the window + + // Trustworthy baseline → honest per-second ratio; else fall back to the absolute peak-rate + // bar (NOT silence) so a genuinely heavy profile still surfaces on a young store (is_new). + bool isNew; + double ratio; + if (baseline.IsTrustworthy && baseline.Mean > 0) + { + isNew = false; + ratio = peakRate / baseline.Mean; + } + else + { + isNew = true; + ratio = peakRate >= WaitProfileFallbackMsPerSec ? NoBaselineRatio : 0; + } + + if (ratio < DefaultRatioThreshold) return; + + var metadata = new Dictionary + { + ["current_ms_per_sec"] = peakRate, + ["baseline_mean"] = baseline.Mean, + ["total_wait_ms"] = totalWaitMs, + ["ratio"] = ratio, + ["is_new"] = isNew ? 1 : 0 + }; + AddBaselineContext(metadata, baseline); + + // Top 6 contributors — named in the metadata KEY (a Dictionary can't hold + // the type name in the value), value = the type's total wait ms in the window. + using (var contribCmd = new NpgsqlCommand(WaitContribWindowSql, connection)) + { + contribCmd.Parameters.AddWithValue(context.ServerId); + contribCmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + contribCmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var contribReader = await contribCmd.ExecuteReaderAsync(); + while (await contribReader.ReadAsync()) + { + var waitType = contribReader.GetString(0); + metadata[$"contrib_{waitType}"] = Convert.ToDouble(contribReader.GetValue(1)); + } + } + + anomalies.Add(new Fact + { + Source = "anomaly", + Key = "ANOMALY_WAIT_PROFILE", + Value = totalWaitMs, + ServerId = context.ServerId, + Metadata = metadata + }); + } + catch (Exception ex) + { + _logger?.LogError("[PgAnomalyDetector] Wait anomaly detection failed: {Message}", ex.Message); + } + } + + /// + /// Detects blocking/deadlock anomalies — event rates significantly above + /// baseline for this time bucket. Uses ratio-based scoring. + /// + private async Task DetectBlockingAnomalies(AnalysisContext context, List anomalies) + { + try + { + var blockingBaseline = await _baselineProvider.GetBaselineAsync( + context.ServerId, MetricNames.Blocking, context.TimeRangeStart); + var deadlockBaseline = await _baselineProvider.GetBaselineAsync( + context.ServerId, MetricNames.Deadlock, context.TimeRangeStart); + + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(BlockingWindowSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + var currentBlocking = Convert.ToInt64(reader.GetValue(0)); + var currentDeadlocks = Convert.ToInt64(reader.GetValue(1)); + + /* Baseline mean is events per hour-of-day/dow bucket (≈ events per hour at this time of + day). current_* are raw counts over the whole analysis window (hoursBack, default 4), + so normalize them to per-hour before the ratio — otherwise the ratio scales with the + window length, not the workload, and a steady event rate trips the spike threshold. */ + var windowHours = (context.TimeRangeEnd - context.TimeRangeStart).TotalHours; + if (windowHours <= 0) windowHours = 1; + var currentBlockingPerHour = currentBlocking / windowHours; + var currentDeadlocksPerHour = currentDeadlocks / windowHours; + + // Baseline mean = events per hour for this hour+dow bucket. Gate on IsTrustworthy (not just + // SampleCount>0): a thin/zero-history baseline falls back to the absolute event count rather + // than an inflated ratio. is_new marks that fallback so the composer renders it honestly as + // a first occurrence — never the dishonest "spiked to 100×" the sentinel used to render. + var blockingTrust = blockingBaseline.IsTrustworthy; + var deadlockTrust = deadlockBaseline.IsTrustworthy; + var baselineBlockingRate = blockingBaseline.SampleCount > 0 ? blockingBaseline.Mean : 0; + var baselineDeadlockRate = deadlockBaseline.SampleCount > 0 ? deadlockBaseline.Mean : 0; + + // Blocking spike: at least 5 events in the window AND (trustworthy → per-hour rate >= 3x + // baseline; untrustworthy → fire on the count alone). + if (currentBlocking >= 5 && (!blockingTrust || currentBlockingPerHour / Math.Max(baselineBlockingRate, 1) >= DefaultEventRatioThreshold)) + { + var isNew = !blockingTrust; + var metadata = new Dictionary + { + ["current_count"] = currentBlocking, + ["baseline_rate"] = baselineBlockingRate, + ["ratio"] = isNew ? NoBaselineRatio : currentBlockingPerHour / baselineBlockingRate, + ["is_new"] = isNew ? 1 : 0 + }; + AddBaselineContext(metadata, blockingBaseline); + + anomalies.Add(new Fact + { + Source = "anomaly", + Key = "ANOMALY_BLOCKING_SPIKE", + Value = currentBlocking, + ServerId = context.ServerId, + Metadata = metadata + }); + } + + // Deadlock spike: at least 3 events in the window AND (trustworthy → per-hour rate >= 3x + // baseline; untrustworthy → fire on the count alone). + if (currentDeadlocks >= 3 && (!deadlockTrust || currentDeadlocksPerHour / Math.Max(baselineDeadlockRate, 1) >= DefaultEventRatioThreshold)) + { + var isNew = !deadlockTrust; + var metadata = new Dictionary + { + ["current_count"] = currentDeadlocks, + ["baseline_rate"] = baselineDeadlockRate, + ["ratio"] = isNew ? NoBaselineRatio : currentDeadlocksPerHour / baselineDeadlockRate, + ["is_new"] = isNew ? 1 : 0 + }; + AddBaselineContext(metadata, deadlockBaseline); + + anomalies.Add(new Fact + { + Source = "anomaly", + Key = "ANOMALY_DEADLOCK_SPIKE", + Value = currentDeadlocks, + ServerId = context.ServerId, + Metadata = metadata + }); + } + } + catch (Exception ex) + { + _logger?.LogError("[PgAnomalyDetector] Blocking anomaly detection failed: {Message}", ex.Message); + } + } + + /// + /// Detects I/O latency anomalies using z-score against time-bucketed baseline. + /// + private async Task DetectIoAnomalies(AnalysisContext context, List anomalies) + { + try + { + var baseline = await _baselineProvider.GetBaselineAsync( + context.ServerId, MetricNames.IoLatency, context.TimeRangeStart); + + if (baseline.SampleCount == 0) return; + var effectiveStdDev = baseline.EffectiveStdDev; + + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(IoWindowSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + var currentReadLat = reader.IsDBNull(0) ? 0.0 : Convert.ToDouble(reader.GetValue(0)); + var currentWriteLat = reader.IsDBNull(1) ? 0.0 : Convert.ToDouble(reader.GetValue(1)); + + var ioThreshold = GetDeviationThreshold(MetricNames.IoLatency); + + // Read latency anomaly + var readDecision = AnomalyGate.EvaluateZScore( + baseline.Mean, effectiveStdDev, baseline.IsTrustworthy, currentReadLat, + ioThreshold, ReadLatencyFloorMs, IoLatencyFallbackMs, SigmaDisplayCap); + if (readDecision.Fire) + { + var metadata = new Dictionary + { + ["current_latency_ms"] = currentReadLat, + ["baseline_mean_ms"] = baseline.Mean, + ["baseline_stddev_ms"] = effectiveStdDev, + ["deviation_sigma"] = readDecision.Sigma, + ["baseline_low_quality"] = readDecision.LowQualityBaseline ? 1 : 0, + ["fallback_exceedance"] = readDecision.FallbackExceedance, + ["baseline_samples"] = baseline.SampleCount + }; + AddBaselineContext(metadata, baseline); + + anomalies.Add(new Fact + { + Source = "anomaly", + Key = "ANOMALY_READ_LATENCY", + Value = currentReadLat, + ServerId = context.ServerId, + Metadata = metadata + }); + } + + // Write latency anomaly + var writeDecision = AnomalyGate.EvaluateZScore( + baseline.Mean, effectiveStdDev, baseline.IsTrustworthy, currentWriteLat, + ioThreshold, WriteLatencyFloorMs, IoLatencyFallbackMs, SigmaDisplayCap); + if (writeDecision.Fire) + { + var metadata = new Dictionary + { + ["current_latency_ms"] = currentWriteLat, + ["baseline_mean_ms"] = baseline.Mean, + ["baseline_stddev_ms"] = effectiveStdDev, + ["deviation_sigma"] = writeDecision.Sigma, + ["baseline_low_quality"] = writeDecision.LowQualityBaseline ? 1 : 0, + ["fallback_exceedance"] = writeDecision.FallbackExceedance, + ["baseline_samples"] = baseline.SampleCount + }; + AddBaselineContext(metadata, baseline); + + anomalies.Add(new Fact + { + Source = "anomaly", + Key = "ANOMALY_WRITE_LATENCY", + Value = currentWriteLat, + ServerId = context.ServerId, + Metadata = metadata + }); + } + } + catch (Exception ex) + { + _logger?.LogError("[PgAnomalyDetector] I/O anomaly detection failed: {Message}", ex.Message); + } + } + + /// + /// Detects batch requests/sec anomalies using z-score against time-bucketed baseline. + /// + private async Task DetectBatchRequestAnomalies(AnalysisContext context, List anomalies) + { + try + { + var baseline = await _baselineProvider.GetBaselineAsync( + context.ServerId, MetricNames.BatchRequests, context.TimeRangeStart); + + if (baseline.SampleCount == 0) return; + var effectiveStdDev = baseline.EffectiveStdDev; + + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(BatchRequestWindowSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + var avgBatch = reader.IsDBNull(0) ? 0.0 : Convert.ToDouble(reader.GetValue(0)); + var peakBatch = reader.IsDBNull(1) ? 0.0 : Convert.ToDouble(reader.GetValue(1)); + var windowSamples = reader.IsDBNull(2) ? 0L : Convert.ToInt64(reader.GetValue(2)); + + if (windowSamples == 0) return; + + var decision = AnomalyGate.EvaluateZScore( + baseline.Mean, effectiveStdDev, baseline.IsTrustworthy, peakBatch, + GetDeviationThreshold(MetricNames.BatchRequests), BatchRequestFloor, BatchRequestFallback, SigmaDisplayCap); + if (!decision.Fire) return; + + var metadata = new Dictionary + { + ["peak_batch_requests"] = peakBatch, + ["avg_batch_requests"] = avgBatch, + ["baseline_mean"] = baseline.Mean, + ["baseline_stddev"] = effectiveStdDev, + ["deviation_sigma"] = decision.Sigma, + ["baseline_low_quality"] = decision.LowQualityBaseline ? 1 : 0, + ["fallback_exceedance"] = decision.FallbackExceedance, + ["baseline_samples"] = baseline.SampleCount, + ["window_samples"] = windowSamples + }; + AddBaselineContext(metadata, baseline); + + anomalies.Add(new Fact + { + Source = "anomaly", + Key = "ANOMALY_BATCH_REQUESTS", + Value = peakBatch, + ServerId = context.ServerId, + Metadata = metadata + }); + } + catch (Exception ex) + { + _logger?.LogError("[PgAnomalyDetector] Batch request anomaly detection failed: {Message}", ex.Message); + } + } + + /// + /// Detects session/connection count anomalies using z-score against time-bucketed baseline. + /// + private async Task DetectSessionAnomalies(AnalysisContext context, List anomalies) + { + try + { + var baseline = await _baselineProvider.GetBaselineAsync( + context.ServerId, MetricNames.SessionCount, context.TimeRangeStart); + + if (baseline.SampleCount == 0) return; + var effectiveStdDev = baseline.EffectiveStdDev; + + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(SessionWindowSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + var avgConnections = reader.IsDBNull(0) ? 0.0 : Convert.ToDouble(reader.GetValue(0)); + var peakConnections = reader.IsDBNull(1) ? 0.0 : Convert.ToDouble(reader.GetValue(1)); + var windowSamples = reader.IsDBNull(2) ? 0L : Convert.ToInt64(reader.GetValue(2)); + + if (windowSamples == 0) return; + + var decision = AnomalyGate.EvaluateZScore( + baseline.Mean, effectiveStdDev, baseline.IsTrustworthy, peakConnections, + GetDeviationThreshold(MetricNames.SessionCount), SessionCountFloor, SessionCountFallback, SigmaDisplayCap); + if (!decision.Fire) return; + + var metadata = new Dictionary + { + ["peak_connections"] = peakConnections, + ["avg_connections"] = avgConnections, + ["baseline_mean"] = baseline.Mean, + ["baseline_stddev"] = effectiveStdDev, + ["deviation_sigma"] = decision.Sigma, + ["baseline_low_quality"] = decision.LowQualityBaseline ? 1 : 0, + ["fallback_exceedance"] = decision.FallbackExceedance, + ["baseline_samples"] = baseline.SampleCount, + ["window_samples"] = windowSamples + }; + AddBaselineContext(metadata, baseline); + + anomalies.Add(new Fact + { + Source = "anomaly", + Key = "ANOMALY_SESSION_SPIKE", + Value = peakConnections, + ServerId = context.ServerId, + Metadata = metadata + }); + } + catch (Exception ex) + { + _logger?.LogError("[PgAnomalyDetector] Session anomaly detection failed: {Message}", ex.Message); + } + } + + /// + /// Detects query duration aggregate anomalies using z-score against time-bucketed baseline. + /// Measures total elapsed time across all queries per collection interval. + /// + private async Task DetectQueryDurationAnomalies(AnalysisContext context, List anomalies) + { + try + { + var baseline = await _baselineProvider.GetBaselineAsync( + context.ServerId, MetricNames.QueryDuration, context.TimeRangeStart); + + if (baseline.SampleCount == 0) return; + var effectiveStdDev = baseline.EffectiveStdDev; + + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(QueryDurationWindowSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + var avgElapsed = reader.IsDBNull(0) ? 0.0 : Convert.ToDouble(reader.GetValue(0)); + var peakElapsed = reader.IsDBNull(1) ? 0.0 : Convert.ToDouble(reader.GetValue(1)); + var windowSamples = reader.IsDBNull(2) ? 0L : Convert.ToInt64(reader.GetValue(2)); + + if (windowSamples == 0) return; + + var decision = AnomalyGate.EvaluateZScore( + baseline.Mean, effectiveStdDev, baseline.IsTrustworthy, peakElapsed, + GetDeviationThreshold(MetricNames.QueryDuration), QueryDurationFloorUs, QueryDurationFallbackUs, SigmaDisplayCap); + if (!decision.Fire) return; + + var metadata = new Dictionary + { + ["peak_total_elapsed_us"] = peakElapsed, + ["avg_total_elapsed_us"] = avgElapsed, + ["baseline_mean"] = baseline.Mean, + ["baseline_stddev"] = effectiveStdDev, + ["deviation_sigma"] = decision.Sigma, + ["baseline_low_quality"] = decision.LowQualityBaseline ? 1 : 0, + ["fallback_exceedance"] = decision.FallbackExceedance, + ["baseline_samples"] = baseline.SampleCount, + ["window_samples"] = windowSamples + }; + AddBaselineContext(metadata, baseline); + + anomalies.Add(new Fact + { + Source = "anomaly", + Key = "ANOMALY_QUERY_DURATION", + Value = peakElapsed, + ServerId = context.ServerId, + Metadata = metadata + }); + } + catch (Exception ex) + { + _logger?.LogError("[PgAnomalyDetector] Query duration anomaly detection failed: {Message}", ex.Message); + } + } + + /// + /// Detects memory utilization anomalies using z-score against time-bucketed baseline. + /// Ported from Lite (Darling, Lite, and Dashboard all collect memory metrics). + /// Measures total_server_memory_mb / target_server_memory_mb as memory pressure %. + /// + private async Task DetectMemoryAnomalies(AnalysisContext context, List anomalies) + { + try + { + var baseline = await _baselineProvider.GetBaselineAsync( + context.ServerId, MetricNames.Memory, context.TimeRangeStart); + + if (baseline.SampleCount == 0) return; + var effectiveStdDev = baseline.EffectiveStdDev; + + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(MemoryWindowSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + var avgPressure = reader.IsDBNull(0) ? 0.0 : Convert.ToDouble(reader.GetValue(0)); + var peakPressure = reader.IsDBNull(1) ? 0.0 : Convert.ToDouble(reader.GetValue(1)); + var windowSamples = reader.IsDBNull(2) ? 0L : Convert.ToInt64(reader.GetValue(2)); + + if (windowSamples == 0) return; + + var decision = AnomalyGate.EvaluateZScore( + baseline.Mean, effectiveStdDev, baseline.IsTrustworthy, peakPressure, + GetDeviationThreshold(MetricNames.Memory), MemoryPressureFloorPct, MemoryPressureFallbackPct, SigmaDisplayCap); + if (!decision.Fire) return; + + var metadata = new Dictionary + { + ["peak_memory_pressure_pct"] = peakPressure, + ["avg_memory_pressure_pct"] = avgPressure, + ["baseline_mean"] = baseline.Mean, + ["baseline_stddev"] = effectiveStdDev, + ["deviation_sigma"] = decision.Sigma, + ["baseline_low_quality"] = decision.LowQualityBaseline ? 1 : 0, + ["fallback_exceedance"] = decision.FallbackExceedance, + ["baseline_samples"] = baseline.SampleCount, + ["window_samples"] = windowSamples + }; + AddBaselineContext(metadata, baseline); + + anomalies.Add(new Fact + { + Source = "anomaly", + Key = "ANOMALY_MEMORY_PRESSURE", + Value = peakPressure, + ServerId = context.ServerId, + Metadata = metadata + }); + } + catch (Exception ex) + { + _logger?.LogError("[PgAnomalyDetector] Memory anomaly detection failed: {Message}", ex.Message); + } + } + + /// Kind-Unspecified for query bounds — Npgsql 6+ rejects Kind-Utc against timestamp. + private static DateTime AsNaive(DateTime value) => + DateTime.SpecifyKind(value, DateTimeKind.Unspecified); +} diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgBaselineProvider.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgBaselineProvider.cs new file mode 100644 index 000000000..404e1b0cf --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Analysis/PgBaselineProvider.cs @@ -0,0 +1,518 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Npgsql; +using PerformanceMonitor.Analysis.Baselines; + +namespace PerformanceMonitor.Darling.Analysis; + +/// +/// Provides time-bucketed baselines (hour-of-day x day-of-week) computed from +/// 30-day rolling history in Darling's Postgres store — Lite's BaselineProvider +/// (Lite/Analysis/BaselineProvider.cs) ported for the analysis slice AN2b, reading +/// the V4 passthrough views so the metric SQL stays Lite-verbatim wherever the +/// dialects agree. +/// +/// +/// Each baseline bucket contains mean, stddev, and sample count for a metric +/// at a specific (hour, day-of-week) combination. When a bucket has insufficient +/// samples, the provider collapses to less-specific tiers: +/// Full (hour+dow) -> Hour-only -> Flat (global mean/stddev) +/// Baselines are cached in memory with a 1-hour TTL to avoid redundant +/// recomputation during rapid re-analysis. Collapse math, cache keys, and the +/// public surface are Lite's, line-for-line. +/// +/// +/// +/// Postgres discipline (see PgFindingStore): the window bounds are bound +/// naive-UTC Kind-Unspecified parameters ($1 server_id, $2 window start, +/// $3 analysis time) — never a bare now()/CURRENT_TIMESTAMP, which +/// would be timestamptz and compare in the PG server's time zone. Lite's SQL +/// already parameterized every bound, so no "now" replacement was needed here. +/// +/// +/// +/// The arc's only genuine dialect work lives in : +/// DuckDB's QUALIFY clause (used at four sites for restart-poisoning / rate +/// exclusion) does not exist in Postgres. Each site is rewritten as +/// window-function-in-a-CTE + the identical predicate in an OUTER where — the +/// same idiom the Dashboard twin (SqlServerBaselineProvider) uses for T-SQL — +/// with the original DuckDB form preserved in a comment block and the +/// row-selection equivalence argued site-by-site. +/// +/// +public class PgBaselineProvider +{ + private readonly NpgsqlDataSource _postgres; + private readonly ILogger? _logger; + + /// Cache TTL — baselines are recomputed after this interval. + public static TimeSpan CacheTtl { get; set; } = TimeSpan.FromHours(1); + + private readonly ConcurrentDictionary _cache = new(); + + public PgBaselineProvider(NpgsqlDataSource postgres, ILogger? logger = null) + { + _postgres = postgres ?? throw new ArgumentNullException(nameof(postgres)); + _logger = logger; + } + + /// + /// Gets the baseline for a specific metric, server, and time bucket. + /// Returns the most specific bucket available, collapsing as needed. + /// + public async Task GetBaselineAsync( + int serverId, string metricName, DateTime analysisTime) + { + var hourOfDay = analysisTime.Hour; + var dayOfWeek = (int)analysisTime.DayOfWeek; // Sunday=0 — matches EXTRACT(DOW) in both engines + + var baselines = await GetOrComputeBaselinesAsync(serverId, metricName, analysisTime); + if (baselines == null || baselines.Count == 0) + return BaselineBucket.Empty; + + return BaselineMath.SelectBucket(baselines, hourOfDay, dayOfWeek); + } + + /// Forces cache eviction for a server — used during testing. + public void InvalidateCache(int serverId) + { + var keysToRemove = _cache.Keys.Where(k => k.StartsWith($"{serverId}:", StringComparison.Ordinal)).ToList(); + foreach (var key in keysToRemove) + _cache.TryRemove(key, out _); + } + + /// Forces full cache clear — used during testing. + public void ClearCache() => _cache.Clear(); + + private async Task?> GetOrComputeBaselinesAsync( + int serverId, string metricName, DateTime analysisTime) + { + var cacheKey = $"{serverId}:{metricName}"; + var roundedHour = new DateTime(analysisTime.Year, analysisTime.Month, analysisTime.Day, analysisTime.Hour, 0, 0); + + if (_cache.TryGetValue(cacheKey, out var cached) && + cached.ComputedAt == roundedHour && + (DateTime.UtcNow - cached.RealTime) < CacheTtl) + { + return cached.Buckets; + } + + var buckets = await ComputeBaselinesAsync(serverId, metricName, analysisTime); + + _cache[cacheKey] = new CachedBaseline + { + ComputedAt = roundedHour, + RealTime = DateTime.UtcNow, + Buckets = buckets + }; + + return buckets; + } + + private async Task?> ComputeBaselinesAsync( + int serverId, string metricName, DateTime analysisTime) + { + var query = GetBaselineQuery(metricName); + if (query == null) return null; + + var absStdDevFloor = BaselineMath.AbsStdDevFloorFor(metricName); + var windowStart = analysisTime.AddDays(-BaselineMath.BaselineWindowDays); + + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(query, connection); + cmd.Parameters.AddWithValue(serverId); + /* Window bounds arrive as bound naive-UTC parameters (Kind-Unspecified so Npgsql + maps them to `timestamp`, matching the naive-UTC columns) — never bare now(). */ + cmd.Parameters.AddWithValue(AsNaive(windowStart)); + cmd.Parameters.AddWithValue(AsNaive(analysisTime)); + + var buckets = new Dictionary<(int, int), BaselineBucket>(); + + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var hour = Convert.ToInt32(reader.GetValue(0)); + var dow = Convert.ToInt32(reader.GetValue(1)); + var mean = reader.IsDBNull(2) ? 0.0 : Convert.ToDouble(reader.GetValue(2)); + var stddev = reader.IsDBNull(3) ? 0.0 : Convert.ToDouble(reader.GetValue(3)); + var count = reader.IsDBNull(4) ? 0L : Convert.ToInt64(reader.GetValue(4)); + var distinctDays = reader.IsDBNull(5) ? 0L : Convert.ToInt64(reader.GetValue(5)); + + buckets[(hour, dow)] = new BaselineBucket + { + HourOfDay = hour, + DayOfWeek = dow, + Mean = mean, + StdDev = stddev, + SampleCount = count, + DistinctDays = distinctDays, + AbsStdDevFloor = absStdDevFloor, + // Every bucket here is a full (hour, day-of-week) bucket; the HourOnly/Flat + // tiers are assigned only on the collapse/flat paths below. A sparse full + // bucket is still Full, just low-sample. + Tier = BaselineTier.Full + }; + } + + return buckets; + } + catch (Exception ex) + { + _logger?.LogError("[PgBaselineProvider] Failed to compute baselines for {MetricName}: {Message}", metricName, ex.Message); + return null; + } + } + + /// + /// The eleven per-metric baseline queries — Lite's, verbatim, except the four QUALIFY + /// sites rewritten for Postgres (no QUALIFY support). Internal (not private like Lite's) + /// so Darling.Tests can pin every query's dialect and the rewrites' structure ungated. + /// + internal static string? GetBaselineQuery(string metricName) + { + // All queries return: hour_of_day, day_of_week, mean_val, stddev_val, sample_count + // Cumulative metrics (batch requests, wait stats, query duration) use CTEs for + // restart poisoning exclusion — exclude samples where value drops to near-zero + // when the prior sample was significantly higher. + // Multi-row-per-collection metrics (waits, sessions, queries) aggregate per + // collection_time first, then bucket by hour+dow. + return metricName switch + { + // Point-in-time metric — no restart exclusion needed + MetricNames.Cpu => @" +SELECT EXTRACT(HOUR FROM collection_time)::INT AS hour_of_day, + EXTRACT(DOW FROM collection_time)::INT AS day_of_week, + AVG(sqlserver_cpu_utilization) AS mean_val, + STDDEV_SAMP(sqlserver_cpu_utilization) AS stddev_val, + COUNT(*) AS sample_count, + COUNT(DISTINCT collection_time::DATE) AS distinct_days +FROM v_cpu_utilization_stats +WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 +GROUP BY hour_of_day, day_of_week", + + /* QUALIFY rewrite 1 of 4 — cumulative counter, restart exclusion. + Excludes samples where the delta drops to 0 when the prior sample was > 1000 + (restart signature for cumulative counters). Lite's DuckDB original: + + SELECT EXTRACT(HOUR FROM collection_time)::INT AS hour_of_day, + EXTRACT(DOW FROM collection_time)::INT AS day_of_week, + AVG(delta_cntr_value) AS mean_val, + STDDEV_SAMP(delta_cntr_value) AS stddev_val, + COUNT(*) AS sample_count + FROM ( + SELECT collection_time, delta_cntr_value + FROM v_perfmon_stats + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + AND counter_name = 'Batch Requests/sec' + AND delta_cntr_value >= 0 + QUALIFY NOT (delta_cntr_value = 0 + AND COALESCE(LAG(delta_cntr_value) OVER (ORDER BY collection_time), 0) > 1000) + ) + GROUP BY hour_of_day, day_of_week + + QUALIFY evaluates AFTER window computation: LAG runs over every WHERE-surviving + row (including rows QUALIFY itself is about to drop), THEN the predicate prunes. + The rewrite computes the SAME LAG over the SAME WHERE-filtered rowset inside a + CTE and applies the IDENTICAL predicate in the outer WHERE — window-before-filter + is preserved, so only the FIRST zero after a >1000 sample is dropped, and a zero + following another zero keeps LAG = 0 and SURVIVES (genuine idle, not a restart). + Row selection is exactly the original's. */ + MetricNames.BatchRequests => @" +WITH windowed AS ( + SELECT collection_time, delta_cntr_value, + COALESCE(LAG(delta_cntr_value) OVER (ORDER BY collection_time), 0) AS prior_delta + FROM v_perfmon_stats + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + AND counter_name = 'Batch Requests/sec' + AND delta_cntr_value >= 0 +) +SELECT EXTRACT(HOUR FROM collection_time)::INT AS hour_of_day, + EXTRACT(DOW FROM collection_time)::INT AS day_of_week, + AVG(delta_cntr_value) AS mean_val, + STDDEV_SAMP(delta_cntr_value) AS stddev_val, + COUNT(*) AS sample_count, + COUNT(DISTINCT collection_time::DATE) AS distinct_days +FROM windowed +WHERE NOT (delta_cntr_value = 0 AND prior_delta > 1000) +GROUP BY hour_of_day, day_of_week", + + /* QUALIFY rewrite 2 of 4 — cumulative counter, multiple rows per collection (per + wait type): aggregate to total wait ms per collection FIRST, then restart + exclusion. Lite's DuckDB original applied QUALIFY inside the grouped CTE: + + WITH per_collection AS ( + SELECT collection_time, + SUM(delta_wait_time_ms) AS total_wait_ms + FROM v_wait_stats + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + AND delta_wait_time_ms >= 0 + GROUP BY collection_time + QUALIFY NOT (total_wait_ms = 0 + AND COALESCE(LAG(total_wait_ms) OVER (ORDER BY collection_time), 0) > 10000) + ) + SELECT ... FROM per_collection GROUP BY hour_of_day, day_of_week + + In DuckDB that QUALIFY's LAG runs over the GROUPED rows (one per collection_time) + before any exclusion. The rewrite splits grouping (per_collection) from windowing + (with_lag) so LAG still sees EVERY grouped row — a row the filter drops still + serves as its successor's LAG value — and the outer WHERE applies the identical + predicate. Only the first 0-total immediately after a >10000ms collection (the + restart signature) is excluded; consecutive zeros (genuine idle) survive because + their LAG is 0, not >10000. Row selection is exactly the original's. */ + MetricNames.WaitStats => @" +WITH per_collection AS ( + SELECT collection_time, + SUM(delta_wait_time_ms) AS total_wait_ms + FROM v_wait_stats + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + AND delta_wait_time_ms >= 0 + GROUP BY collection_time +), +with_lag AS ( + SELECT collection_time, total_wait_ms, + COALESCE(LAG(total_wait_ms) OVER (ORDER BY collection_time), 0) AS prior_total_wait_ms + FROM per_collection +) +SELECT EXTRACT(HOUR FROM collection_time)::INT AS hour_of_day, + EXTRACT(DOW FROM collection_time)::INT AS day_of_week, + AVG(total_wait_ms) AS mean_val, + STDDEV_SAMP(total_wait_ms) AS stddev_val, + COUNT(*) AS sample_count, + COUNT(DISTINCT collection_time::DATE) AS distinct_days +FROM with_lag +WHERE NOT (total_wait_ms = 0 AND prior_total_wait_ms > 10000) +GROUP BY hour_of_day, day_of_week", + + // Point-in-time, multiple rows per collection (per program_name) — + // aggregate to total connections per collection first + MetricNames.SessionCount => @" +WITH per_collection AS ( + SELECT collection_time, + SUM(connection_count) AS total_connections + FROM v_session_stats + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + GROUP BY collection_time +) +SELECT EXTRACT(HOUR FROM collection_time)::INT AS hour_of_day, + EXTRACT(DOW FROM collection_time)::INT AS day_of_week, + AVG(total_connections) AS mean_val, + STDDEV_SAMP(total_connections) AS stddev_val, + COUNT(*) AS sample_count, + COUNT(DISTINCT collection_time::DATE) AS distinct_days +FROM per_collection +GROUP BY hour_of_day, day_of_week", + + /* QUALIFY rewrite 3 of 4 — cumulative (plan cache), multiple rows per collection + (per query): delta columns aggregated to total elapsed per collection, then + restart exclusion. Lite's DuckDB original: + + WITH per_collection AS ( + SELECT collection_time, + SUM(delta_elapsed_time) AS total_elapsed + FROM v_query_stats + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + AND delta_execution_count > 0 + AND delta_elapsed_time >= 0 + GROUP BY collection_time + QUALIFY NOT (total_elapsed = 0 + AND COALESCE(LAG(total_elapsed) OVER (ORDER BY collection_time), 0) > 100000) + ) + SELECT ... FROM per_collection GROUP BY hour_of_day, day_of_week + + Same shape as rewrite 2: group first, window over ALL grouped rows in a separate + CTE, apply the identical exclusion predicate in the outer WHERE — a 0-total right + after a >100000us collection is the restart signature and is dropped; zeros after + zeros survive. Row selection is exactly the original's. */ + MetricNames.QueryDuration => @" +WITH per_collection AS ( + SELECT collection_time, + SUM(delta_elapsed_time) AS total_elapsed + FROM v_query_stats + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + AND delta_execution_count > 0 + AND delta_elapsed_time >= 0 + GROUP BY collection_time +), +with_lag AS ( + SELECT collection_time, total_elapsed, + COALESCE(LAG(total_elapsed) OVER (ORDER BY collection_time), 0) AS prior_total_elapsed + FROM per_collection +) +SELECT EXTRACT(HOUR FROM collection_time)::INT AS hour_of_day, + EXTRACT(DOW FROM collection_time)::INT AS day_of_week, + AVG(total_elapsed) AS mean_val, + STDDEV_SAMP(total_elapsed) AS stddev_val, + COUNT(*) AS sample_count, + COUNT(DISTINCT collection_time::DATE) AS distinct_days +FROM with_lag +WHERE NOT (total_elapsed = 0 AND prior_total_elapsed > 100000) +GROUP BY hour_of_day, day_of_week", + + // Point-in-time metric — no restart exclusion needed. The stall/reads ratio is cast to + // DOUBLE PRECISION (as the memory / wait-rate metrics are) so a spurious large delta can't + // make STDDEV_SAMP produce a numeric that overflows System.Decimal when Npgsql materializes + // the aggregate (it does with `* 1.0`, which yields numeric, not float8). + MetricNames.IoLatency => @" +SELECT EXTRACT(HOUR FROM collection_time)::INT AS hour_of_day, + EXTRACT(DOW FROM collection_time)::INT AS day_of_week, + AVG(delta_stall_read_ms::DOUBLE PRECISION / NULLIF(delta_reads, 0)) AS mean_val, + STDDEV_SAMP(delta_stall_read_ms::DOUBLE PRECISION / NULLIF(delta_reads, 0)) AS stddev_val, + COUNT(*) AS sample_count, + COUNT(DISTINCT collection_time::DATE) AS distinct_days +FROM v_file_io_stats +WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 +AND (delta_reads > 0 OR delta_writes > 0) +GROUP BY hour_of_day, day_of_week", + + // Event-based — mean = events per day for this bucket, sample_count = distinct days observed. + // No restart exclusion needed (event counts, not cumulative). + MetricNames.Blocking => @" +SELECT EXTRACT(HOUR FROM collection_time)::INT AS hour_of_day, + EXTRACT(DOW FROM collection_time)::INT AS day_of_week, + COUNT(*)::DOUBLE PRECISION / GREATEST(COUNT(DISTINCT collection_time::DATE), 1) AS mean_val, + 0::DOUBLE PRECISION AS stddev_val, + COUNT(DISTINCT collection_time::DATE) AS sample_count, + COUNT(DISTINCT collection_time::DATE) AS distinct_days +FROM v_blocked_process_reports +WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 +GROUP BY hour_of_day, day_of_week", + + // Event-based — same approach as blocking + MetricNames.Deadlock => @" +SELECT EXTRACT(HOUR FROM collection_time)::INT AS hour_of_day, + EXTRACT(DOW FROM collection_time)::INT AS day_of_week, + COUNT(*)::DOUBLE PRECISION / GREATEST(COUNT(DISTINCT collection_time::DATE), 1) AS mean_val, + 0::DOUBLE PRECISION AS stddev_val, + COUNT(DISTINCT collection_time::DATE) AS sample_count, + COUNT(DISTINCT collection_time::DATE) AS distinct_days +FROM v_deadlocks +WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 +GROUP BY hour_of_day, day_of_week", + + // Point-in-time metric (memory pressure %) — no restart exclusion needed + MetricNames.Memory => @" +SELECT EXTRACT(HOUR FROM collection_time)::INT AS hour_of_day, + EXTRACT(DOW FROM collection_time)::INT AS day_of_week, + AVG(total_server_memory_mb::DOUBLE PRECISION / NULLIF(target_server_memory_mb::DOUBLE PRECISION, 0) * 100) AS mean_val, + STDDEV_SAMP(total_server_memory_mb::DOUBLE PRECISION / NULLIF(target_server_memory_mb::DOUBLE PRECISION, 0) * 100) AS stddev_val, + COUNT(*) AS sample_count, + COUNT(DISTINCT collection_time::DATE) AS distinct_days +FROM v_memory_stats +WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 +AND target_server_memory_mb > 0 +GROUP BY hour_of_day, day_of_week", + + // ── Chart-unit baselines (for UI bands — units match what the chart displays) ── + + /* QUALIFY rewrite 4 of 4 — wait ms per second (chart unit). Lite's DuckDB original: + + WITH per_collection AS ( + SELECT collection_time, + SUM(delta_wait_time_ms)::DOUBLE PRECISION AS total_wait_ms, + extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (ORDER BY collection_time)))) AS interval_sec + FROM v_wait_stats + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + AND delta_wait_time_ms >= 0 + GROUP BY collection_time + ), + with_rate AS ( + SELECT collection_time, + CASE WHEN interval_sec > 0 THEN total_wait_ms / interval_sec ELSE 0 END AS ms_per_sec + FROM per_collection + WHERE interval_sec IS NOT NULL + QUALIFY NOT (ms_per_sec = 0 + AND COALESCE(LAG(ms_per_sec) OVER (ORDER BY collection_time), 0) > 100) + ) + SELECT ... FROM with_rate GROUP BY hour_of_day, day_of_week + + Two things to preserve exactly: + (a) per_collection's LAG(collection_time) alongside GROUP BY collection_time is a + window over the GROUPED rows — standard SQL both engines share; it carries + over verbatim (no QUALIFY there). + (b) In DuckDB, with_rate's WHERE runs BEFORE its QUALIFY window: the window's + first row (interval_sec NULL — no prior collection) is removed FIRST, so the + QUALIFY LAG is computed over only the rated rows. The rewrite keeps that + WHERE in with_rate, then windows in a LATER CTE (with_lag) over exactly the + post-WHERE rowset, then applies the identical predicate in the outer WHERE. + As in rewrites 1-3, LAG sees rows the filter drops, so only the first 0-rate + after a >100 ms/sec sample (restart signature) is excluded and idle zeros + after zeros survive. Row selection is exactly the original's. */ + MetricNames.WaitMsPerSec => @" +WITH per_collection AS ( + SELECT collection_time, + SUM(delta_wait_time_ms)::DOUBLE PRECISION AS total_wait_ms, + extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (ORDER BY collection_time)))) AS interval_sec + FROM v_wait_stats + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + AND delta_wait_time_ms >= 0 + GROUP BY collection_time +), +with_rate AS ( + SELECT collection_time, + CASE WHEN interval_sec > 0 THEN total_wait_ms / interval_sec ELSE 0 END AS ms_per_sec + FROM per_collection + WHERE interval_sec IS NOT NULL +), +with_lag AS ( + SELECT collection_time, ms_per_sec, + COALESCE(LAG(ms_per_sec) OVER (ORDER BY collection_time), 0) AS prior_ms_per_sec + FROM with_rate +) +SELECT EXTRACT(HOUR FROM collection_time)::INT AS hour_of_day, + EXTRACT(DOW FROM collection_time)::INT AS day_of_week, + AVG(ms_per_sec) AS mean_val, + STDDEV_SAMP(ms_per_sec) AS stddev_val, + COUNT(*) AS sample_count, + COUNT(DISTINCT collection_time::DATE) AS distinct_days +FROM with_lag +WHERE NOT (ms_per_sec = 0 AND prior_ms_per_sec > 100) +GROUP BY hour_of_day, day_of_week", + + // Blocking events per minute (chart shows event bars bucketed by minute) + MetricNames.BlockingPerMinute => @" +WITH per_minute AS ( + SELECT DATE_TRUNC('minute', collection_time) AS minute_bucket, + COUNT(*)::DOUBLE PRECISION AS event_count + FROM v_blocked_process_reports + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + GROUP BY minute_bucket +) +SELECT EXTRACT(HOUR FROM minute_bucket)::INT AS hour_of_day, + EXTRACT(DOW FROM minute_bucket)::INT AS day_of_week, + AVG(event_count) AS mean_val, + STDDEV_SAMP(event_count) AS stddev_val, + COUNT(*) AS sample_count, + COUNT(DISTINCT minute_bucket::DATE) AS distinct_days +FROM per_minute +GROUP BY hour_of_day, day_of_week", + + _ => null + }; + } + + /// Kind-Unspecified for reads/writes — Npgsql 6+ rejects Kind-Utc against timestamp. + private static DateTime AsNaive(DateTime value) => + DateTime.SpecifyKind(value, DateTimeKind.Unspecified); + + private class CachedBaseline + { + public DateTime ComputedAt { get; init; } + public DateTime RealTime { get; init; } + public Dictionary<(int HourOfDay, int DayOfWeek), BaselineBucket>? Buckets { get; init; } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgBlockingPairRowQuery.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgBlockingPairRowQuery.cs new file mode 100644 index 000000000..6eb37e236 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Analysis/PgBlockingPairRowQuery.cs @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Analysis; + +namespace PerformanceMonitor.Darling.Analysis; + +/// +/// Shared pieces of the blocked-process-report pair-row query used to reconstruct blocking chains — +/// Lite's BlockingPairRowQuery ported onto Npgsql, byte-identical SQL fragments and reader +/// mapping. In Lite three consumers share it (drill-down collector, BLOCKING_CHAIN fact collector, +/// viewer fetch); in Darling the fact collector is the first, and the future drill-down/viewer +/// slices reuse it so the apex and the column order can't drift here either. +/// +/// is the behavioral fix carried over: Lite maps a missing blocker to +/// spid 0 (a phantom root); without filtering it out, every consumer would invent a SPID-0 apex. +/// +/// is the single source of truth for ordinals 0-8 so the queries +/// can't drift and silently feed mismapped columns. Only the two SQL-text +/// expressions (ordinals 9-10) stay per-site — the fact collector selects the full text. +/// +/// /* PG port: Lite's command factory is Func<DuckDBCommand> and its parameters are +/// DuckDBParameter; here they are the Npgsql equivalents, DateTimes bound naive-UTC +/// Kind-Unspecified (the PgCollectorRowWriter discipline). The SQL text is unchanged. */ +/// +internal static class PgBlockingPairRowQuery +{ + /// Ordinals 0-8 of the pair-row SELECT. Each query appends its own SQL-text expressions (9-10). + public const string LeadingColumns = @"event_time, + database_name, + blocked_spid, + blocked_last_tran_started, + blocking_spid, + blocking_last_tran_started, + wait_time_ms, + lock_mode, + blocking_status"; + + /// Ordinals 11-16: session identity for each side. Appended after the per-site SQL-text + /// expressions so all consumers share one column order for . The store + /// denormalizes both sides (Lite's shape), so the apex's identity is available too. + public const string IdentityColumns = @"blocked_login_name, + blocked_host_name, + blocked_client_app, + blocking_login_name, + blocking_host_name, + blocking_client_app"; + + /// Ordinals 18-20: spid:ecid + monitor_loop, the reconstruction's session identity. Every + /// pair-row site appends this after contentious_object (ordinal 17) so the shared sees + /// one column order across all call sites. + public const string TrailingIdentityColumns = @"blocked_ecid, + blocking_ecid, + monitor_loop"; + + /// Append to the WHERE clause of every pair-row query (covers NULL and the 0 sentinel). + public const string SpidFilter = @"AND blocking_spid IS NOT NULL +AND blocking_spid <> 0"; + + /// The DMV-snapshot fallback fetch (see ) — + /// Lite's query text verbatim, exposed const for the ungated dialect pins. + public const string DmvSnapshotSql = $@" +SELECT + {LeadingColumns}, + blocked_sql_text, blocking_sql_text, + {IdentityColumns}, + contentious_object, + {TrailingIdentityColumns} +FROM v_dmv_blocking_snapshots +WHERE server_id = $1 AND event_time >= $2 AND event_time <= $3 +{SpidFilter} +ORDER BY event_time DESC +LIMIT 5000"; + + public static BlockingPairRow Read(DbDataReader reader) => new() + { + EventTime = reader.IsDBNull(0) ? default : reader.GetDateTime(0), + DatabaseName = reader.IsDBNull(1) ? string.Empty : reader.GetString(1), + BlockedSpid = reader.IsDBNull(2) ? 0 : Convert.ToInt32(reader.GetValue(2)), + BlockedTranStarted = reader.IsDBNull(3) ? null : reader.GetDateTime(3), + BlockingSpid = reader.IsDBNull(4) ? 0 : Convert.ToInt32(reader.GetValue(4)), + BlockingTranStarted = reader.IsDBNull(5) ? null : reader.GetDateTime(5), + WaitTimeMs = reader.IsDBNull(6) ? 0L : ToInt64(reader.GetValue(6)), + LockMode = reader.IsDBNull(7) ? string.Empty : reader.GetString(7), + BlockingStatus = reader.IsDBNull(8) ? string.Empty : reader.GetString(8), + BlockedSqlText = reader.IsDBNull(9) ? string.Empty : reader.GetString(9), + BlockingSqlText = reader.IsDBNull(10) ? string.Empty : reader.GetString(10), + BlockedLoginName = reader.IsDBNull(11) ? string.Empty : reader.GetString(11), + BlockedHostName = reader.IsDBNull(12) ? string.Empty : reader.GetString(12), + BlockedClientApp = reader.IsDBNull(13) ? string.Empty : reader.GetString(13), + BlockingLoginName = reader.IsDBNull(14) ? string.Empty : reader.GetString(14), + BlockingHostName = reader.IsDBNull(15) ? string.Empty : reader.GetString(15), + BlockingClientApp = reader.IsDBNull(16) ? string.Empty : reader.GetString(16), + // Ordinal 17: the contended object (every pair-row site appends contentious_object after IdentityColumns). + ContentiousObject = reader.IsDBNull(17) ? string.Empty : reader.GetString(17), + // 18-20: spid:ecid + monitor_loop (every site appends TrailingIdentityColumns after contentious_object). + BlockedEcid = reader.IsDBNull(18) ? 0 : Convert.ToInt32(reader.GetValue(18)), + BlockingEcid = reader.IsDBNull(19) ? 0 : Convert.ToInt32(reader.GetValue(19)), + MonitorLoop = reader.IsDBNull(20) ? (int?)null : Convert.ToInt32(reader.GetValue(20)) + }; + + /// + /// Long conversion for aggregate reads — the single home for the idiom every Darling numeric + /// reader uses (PgFactCollector delegates here), mirroring Lite's + /// BlockingPairRowQuery.ToInt64. /* PG port: Lite's BigInteger branch is dropped — + /// DuckDB boxes wide aggregates as System.Numerics.BigInteger (not IConvertible); Npgsql + /// never does. Postgres returns SUM(bigint)/AVG-style aggregates as numeric, which arrives + /// as decimal — Convert.ToInt64 handles that (and every integral type) directly. */ + /// + public static long ToInt64(object value) => Convert.ToInt64(value); + + /// + /// Fetches DMV-snapshot pair-rows (the always-on blocking fallback) for the window and merges them into + /// (BPR-preferred — see ). Uses the same column + /// fragments as the blocked-process-report queries, against v_dmv_blocking_snapshots, so + /// maps it unchanged. Takes a command factory so the caller's connection runs it — the Lite shape, kept so + /// the future drill-down/viewer slices call it identically. + /// + internal static async Task AppendDmvSnapshotRowsAsync(Func createCommand, List rows, int serverId, DateTime start, DateTime end) + { + var dmv = new List(); + using (var cmd = createCommand()) + { + cmd.CommandText = DmvSnapshotSql; + cmd.Parameters.AddWithValue(serverId); + cmd.Parameters.AddWithValue(DateTime.SpecifyKind(start, DateTimeKind.Unspecified)); + cmd.Parameters.AddWithValue(DateTime.SpecifyKind(end, DateTimeKind.Unspecified)); + + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + dmv.Add(Read(reader)); + } + + BlockingPairRowMerge.MergeInto(rows, dmv); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Blocking.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Blocking.cs new file mode 100644 index 000000000..f32437d8d --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Blocking.cs @@ -0,0 +1,232 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Analysis; +using PerformanceMonitor.Notifications; + +namespace PerformanceMonitor.Darling.Analysis; + +public sealed partial class PgDrillDownCollector +{ + public const string TopDeadlocksSql = @" +SELECT collection_time, deadlock_time, victim_process_id, + LEFT(victim_sql_text, 500) AS victim_sql, + deadlock_graph_xml +FROM v_deadlocks +WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 +ORDER BY collection_time DESC +LIMIT 3"; + + private async Task CollectTopDeadlocks(AnalysisFinding finding, AnalysisContext context) + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(TopDeadlocksSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + var items = new List(); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + /* #1140: parse the involved objects from the graph for the dedup fingerprint + a readable + Objects field. The raw graph XML is NOT surfaced (it would bloat the alert detail). */ + var objects = DeadlockObjectExtractor.FromGraphXml(reader.IsDBNull(4) ? null : reader.GetString(4)); + items.Add(new + { + time = reader.IsDBNull(0) ? "" : reader.GetDateTime(0).ToString("o"), + deadlock_time = reader.IsDBNull(1) ? "" : reader.GetDateTime(1).ToString("o"), + victim = reader.IsDBNull(2) ? "" : reader.GetString(2), + victim_sql = reader.IsDBNull(3) ? "" : reader.GetString(3), + objects = string.Join(", ", objects) + }); + } + + if (items.Count > 0) + finding.DrillDown!["top_deadlocks"] = items; + } + + /* BPR + always-on DMV blocking snapshot, so the flat top-blocking list isn't empty when the + blocked-process-report XE captured nothing (AWS RDS). Worst-by-wait surfaces regardless of + source; on a box with both, each may contribute (this is a top-5 list, not a count). */ + public const string TopBlockingChainsSql = @" +SELECT collection_time, database_name, blocked_spid, blocking_spid, + wait_time_ms, lock_mode, blocked_sql, blocking_sql, contentious_object +FROM +( + SELECT collection_time, database_name, blocked_spid, blocking_spid, + wait_time_ms, lock_mode, + LEFT(blocked_sql_text, 500) AS blocked_sql, + LEFT(blocking_sql_text, 500) AS blocking_sql, + contentious_object + FROM v_blocked_process_reports + WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 + + UNION ALL + + SELECT collection_time, database_name, blocked_spid, blocking_spid, + wait_time_ms, lock_mode, + LEFT(blocked_sql_text, 500) AS blocked_sql, + LEFT(blocking_sql_text, 500) AS blocking_sql, + contentious_object + FROM v_dmv_blocking_snapshots + WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 +) AS combined +ORDER BY wait_time_ms DESC +LIMIT 5"; + + private async Task CollectTopBlockingChains(AnalysisFinding finding, AnalysisContext context) + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(TopBlockingChainsSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + var items = new List(); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new + { + time = reader.IsDBNull(0) ? "" : reader.GetDateTime(0).ToString("o"), + database = reader.IsDBNull(1) ? "" : reader.GetString(1), + blocked_spid = reader.IsDBNull(2) ? 0 : Convert.ToInt32(reader.GetValue(2)), + blocking_spid = reader.IsDBNull(3) ? 0 : Convert.ToInt32(reader.GetValue(3)), + wait_time_ms = reader.IsDBNull(4) ? 0L : Convert.ToInt64(reader.GetValue(4)), + lock_mode = reader.IsDBNull(5) ? "" : reader.GetString(5), + blocked_sql = reader.IsDBNull(6) ? "" : reader.GetString(6), + blocking_sql = reader.IsDBNull(7) ? "" : reader.GetString(7), + contentious_object = reader.IsDBNull(8) ? "" : reader.GetString(8) + }); + } + + if (items.Count > 0) + finding.DrillDown!["top_blocking_chains"] = items; + } + + // SpidFilter keeps the drill-down, fact collector, and viewer fetch in lockstep on the apex + // (a missing blocker maps to spid 0 — see PgBlockingPairRowQuery). SQL text is truncated here + // for the drill-down payload; the shared reader mapping is unaffected (same column order). + public const string ReconstructedChainsSql = $@" +SELECT + {PgBlockingPairRowQuery.LeadingColumns}, + LEFT(blocked_sql_text, 500) AS blocked_sql, + LEFT(blocking_sql_text, 500) AS blocking_sql, + {PgBlockingPairRowQuery.IdentityColumns}, + contentious_object, + {PgBlockingPairRowQuery.TrailingIdentityColumns} +FROM v_blocked_process_reports +WHERE server_id = $1 AND event_time >= $2 AND event_time <= $3 +{PgBlockingPairRowQuery.SpidFilter} +ORDER BY event_time DESC +LIMIT 5000"; + + /// + /// Reconstructs blocking chains (same logic as the collector) and surfaces the top 3 + /// by magnitude — apex, depth, victim count, and the level-by-level structure that the + /// flat top_blocking_chains list cannot show. + /// + private async Task CollectReconstructedBlockingChains(AnalysisFinding finding, AnalysisContext context) + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(ReconstructedChainsSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + var rows = new List(); + using (var reader = await cmd.ExecuteReaderAsync()) + { + while (await reader.ReadAsync()) + rows.Add(PgBlockingPairRowQuery.Read(reader)); + } + + // Always-on DMV blocking snapshot fallback. Merge BEFORE the empty check so DMV-only blocking + // (blocked-process-report unavailable, e.g. AWS RDS) still reconstructs. + await PgBlockingPairRowQuery.AppendDmvSnapshotRowsAsync( + connection.CreateCommand, rows, context.ServerId, context.TimeRangeStart, context.TimeRangeEnd); + + if (rows.Count == 0) return; + + var reconstruction = BlockingChainReconstructor.Reconstruct( + rows, maxDepth: 50, maxPairs: 5000, stepBudget: 100_000, scopeByMonitorLoop: false); + + var items = new List(); + foreach (var chain in reconstruction.Chains.Take(3)) + { + items.Add(new + { + apex_spid = chain.ApexSpid, + apex_sleeping = chain.ApexSleeping, + depth = chain.Depth, + // Distinct sessions blocked under this apex over the window — cumulative, not peak-concurrent. + victim_count = chain.VictimCount, + max_wait_ms = chain.MaxWaitMs, + levels = chain.Levels.Select(l => new + { + level = l.Level, + blocking_spid = l.BlockingSpid, + blocked_spid = l.BlockedSpid, + lock_mode = l.LockMode, + wait_time_ms = l.WaitTimeMs, + blocking_sql = l.BlockingSqlText, + blocked_sql = l.BlockedSqlText + }).ToList() + }); + } + + if (items.Count > 0) + finding.DrillDown!["reconstructed_blocking_chains"] = items; + } + + public const string LockModeBreakdownSql = @" +SELECT wait_type, + SUM(delta_wait_time_ms)::BIGINT AS total_wait_ms, + SUM(delta_waiting_tasks)::BIGINT AS total_count +FROM v_wait_stats +WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 +AND wait_type ILIKE 'LCK%' +AND delta_wait_time_ms > 0 +GROUP BY wait_type +ORDER BY total_wait_ms DESC +LIMIT 10"; + + private async Task CollectLockModeBreakdown(AnalysisFinding finding, AnalysisContext context) + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(LockModeBreakdownSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + var items = new List(); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new + { + lock_type = reader.IsDBNull(0) ? "" : reader.GetString(0), + total_wait_ms = reader.IsDBNull(1) ? 0.0 : Convert.ToDouble(reader.GetValue(1)), + waiting_tasks = reader.IsDBNull(2) ? 0.0 : Convert.ToDouble(reader.GetValue(2)) + }); + } + + if (items.Count > 0) + finding.DrillDown!["lock_mode_breakdown"] = items; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Config.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Config.cs new file mode 100644 index 000000000..cab40b020 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Config.cs @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Analysis; + +namespace PerformanceMonitor.Darling.Analysis; + +public sealed partial class PgDrillDownCollector +{ + public const string ConfigIssuesSql = @" +SELECT database_name, recovery_model, is_auto_shrink_on, is_auto_close_on, + is_read_committed_snapshot_on, page_verify_option, is_query_store_on +FROM v_database_config +WHERE server_id = $1 +AND capture_time = (SELECT MAX(capture_time) FROM v_database_config WHERE server_id = $1) +AND (is_auto_shrink_on = true OR is_auto_close_on = true + OR is_read_committed_snapshot_on = false OR page_verify_option != 'CHECKSUM') +ORDER BY database_name"; + + private async Task CollectConfigIssues(AnalysisFinding finding, AnalysisContext context) + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(ConfigIssuesSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + + var items = new List(); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var issues = new List(); + if (!reader.IsDBNull(2) && reader.GetBoolean(2)) issues.Add("auto_shrink ON"); + if (!reader.IsDBNull(3) && reader.GetBoolean(3)) issues.Add("auto_close ON"); + var rcsiOn = !reader.IsDBNull(4) && reader.GetBoolean(4); + if (!rcsiOn) issues.Add("RCSI OFF"); + var pageVerify = reader.IsDBNull(5) ? "" : reader.GetString(5); + if (!string.IsNullOrEmpty(pageVerify) && pageVerify != "CHECKSUM") issues.Add($"page_verify={pageVerify}"); + + // RCSI-off rows carry the three structured inaction-risk fields with the + // SAME JSON names + types as the Dashboard collector (M-2): like Lite, Darling + // emits them null/0 because it has no collect.blocking_deadlock_stats aggregate + // and the per-DB counts would not be parity-clean. Darling is headless — the + // disclosure simply shows the weak-case baseline (Lite's posture verbatim). + if (!rcsiOn) + { + items.Add(new + { + database = reader.IsDBNull(0) ? "" : reader.GetString(0), + recovery_model = reader.IsDBNull(1) ? "" : reader.GetString(1), + rcsi = rcsiOn, + query_store = !reader.IsDBNull(6) && reader.GetBoolean(6), + issues, + auto_shrink = !reader.IsDBNull(2) && reader.GetBoolean(2), + auto_close = !reader.IsDBNull(3) && reader.GetBoolean(3), + page_verify = pageVerify, + rcsi_blocking_events = 0, + rcsi_deadlocks = 0, + rcsi_reader_writer_pct = (int?)null + }); + } + else + { + items.Add(new + { + database = reader.IsDBNull(0) ? "" : reader.GetString(0), + recovery_model = reader.IsDBNull(1) ? "" : reader.GetString(1), + rcsi = rcsiOn, + query_store = !reader.IsDBNull(6) && reader.GetBoolean(6), + issues, + // §4.1: structured, wording-independent fields the shared extractor + // (FactRemediation.ExtractDbConfigTargets) reads. Identical JSON names + // and types to the Lite/Dashboard collectors (bool / bool / string). + auto_shrink = !reader.IsDBNull(2) && reader.GetBoolean(2), + auto_close = !reader.IsDBNull(3) && reader.GetBoolean(3), + page_verify = pageVerify + }); + } + } + + if (items.Count > 0) + finding.DrillDown!["config_issues"] = items; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Plans.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Plans.cs new file mode 100644 index 000000000..80286bd6e --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Plans.cs @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Analysis; +using PerformanceMonitor.Common; +using PerformanceMonitor.PlanAnalysis; + +namespace PerformanceMonitor.Darling.Analysis; + +public sealed partial class PgDrillDownCollector +{ + public const string PlanHandleLookupSql = @" +SELECT plan_handle +FROM v_query_stats +WHERE server_id = $1 +AND query_hash = $2 +AND plan_handle IS NOT NULL AND plan_handle != '' +ORDER BY collection_time DESC +LIMIT 1"; + + /// + /// For findings that have query hashes (bad actors), fetch the execution plan + /// live from SQL Server via IPlanFetcher, then run PlanAnalyzer to surface + /// warnings and missing indexes. No plan storage needed — fetch on demand + /// only for queries that make it into high-impact findings. + /// + private async Task CollectPlanAnalysis(AnalysisFinding finding, AnalysisContext context) + { + if (finding.DrillDown == null || _planFetcher == null) return; + + // Only analyze plans for bad actor findings (1 plan each). + // Skip top_cpu_queries (5 plans would be too heavy). + if (!finding.RootFactKey.StartsWith("BAD_ACTOR_", StringComparison.OrdinalIgnoreCase)) return; + + var queryHash = finding.RootFactKey.Replace("BAD_ACTOR_", ""); + if (string.IsNullOrEmpty(queryHash)) return; + + // Look up plan_handle from the store for this query_hash + string? planHandle = null; + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(PlanHandleLookupSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(queryHash); + + using var reader = await cmd.ExecuteReaderAsync(); + if (await reader.ReadAsync() && !reader.IsDBNull(0)) + planHandle = reader.GetString(0); + } + catch { return; } + + if (string.IsNullOrEmpty(planHandle)) return; + + // Fetch plan XML live from SQL Server + var planXml = await _planFetcher.FetchPlanXmlAsync(context.ServerId, planHandle); + if (string.IsNullOrEmpty(planXml)) return; + + try + { + var plan = ShowPlanParser.Parse(planXml); + PlanAnalyzer.Analyze(plan); + + var allWarnings = plan.Batches + .SelectMany(b => b.Statements) + .Where(s => s.RootNode != null) + .SelectMany(s => + { + var nodeWarnings = new List(); + CollectPlanNodes(s.RootNode!, nodeWarnings); + return s.PlanWarnings + .Concat(nodeWarnings.SelectMany(n => n.Warnings)); + }) + .ToList(); + + var missingIndexes = plan.AllMissingIndexes; + + if (allWarnings.Count == 0 && missingIndexes.Count == 0) return; + + finding.DrillDown["plan_analysis"] = new + { + query_hash = queryHash, + warning_count = allWarnings.Count, + critical_count = allWarnings.Count(w => w.Severity == PlanWarningSeverity.Critical), + warnings = allWarnings + .OrderByDescending(w => w.Severity) + .Take(10) + .Select(w => new + { + severity = w.Severity.ToString(), + type = w.WarningType, + message = McpHelpers.Truncate(w.Message, 300) + }), + missing_indexes = missingIndexes.Take(5).Select(idx => new + { + table = $"{idx.Schema}.{idx.Table}", + impact = idx.Impact, + create_statement = idx.CreateStatement + }) + }; + } + catch + { + // Plan parsing can fail on malformed XML — skip silently + } + } + + public const string PlanAdvisoryXmlSql = @" +SELECT query_plan_xml +FROM query_stats +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3 +AND query_plan_xml IS NOT NULL +ORDER BY delta_worker_time DESC +LIMIT 10"; + + /// + /// WS4: re-parses the top collected query plans (the same top-10-by-cost set the fact collector + /// summarized) and attaches the specific missing indexes / plan warnings to a MISSING_INDEX or + /// PLAN_WARNING finding's drill-down. The fact carries only counts (Fact.Metadata is numeric), + /// so the strings — CREATE statements, warning messages — are rendered here. Mirrors the + /// Lite/Dashboard drill-down collectors. Best-effort: a read/parse failure leaves no detail. + /// + private async Task CollectPlanAdvisoryDetail(AnalysisFinding finding, AnalysisContext context, HashSet pathKeys) + { + try + { + var planXmls = new List(); + + /* PG port: Lite scopes the connection in a block to release its DuckDB read lock + before the CPU-only parse; the scoping is kept so the connection closes before + the parse, even though PG holds no lock. */ + await using (var connection = await _postgres.OpenConnectionAsync()) + { + using var cmd = new NpgsqlCommand(PlanAdvisoryXmlSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + if (!reader.IsDBNull(0)) + planXmls.Add(reader.GetString(0)); + } + } + + if (planXmls.Count == 0) + return; + + var details = PlanAdvisoryAggregator.Extract(planXmls); + + if (pathKeys.Contains("MISSING_INDEX") && details.MissingIndexes.Count > 0) + { + finding.DrillDown!["missing_indexes"] = details.MissingIndexes + .OrderByDescending(i => i.Impact) + .Take(5) + .Select(i => new + { + table = $"{i.Schema}.{i.Table}", + impact = Math.Round(i.Impact, 1), + create_statement = i.CreateStatement + }) + .ToList(); + } + + if (pathKeys.Contains("PLAN_WARNING") && details.Warnings.Count > 0) + { + finding.DrillDown!["plan_warnings"] = details.Warnings + .OrderByDescending(w => w.Severity) + .Take(5) + .Select(w => new + { + type = w.WarningType, + severity = w.Severity.ToString(), + message = McpHelpers.Truncate(w.Message, 300) + }) + .ToList(); + } + } + catch + { + // Plan read/parse can fail on malformed XML — skip, the detail is best-effort. + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Queries.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Queries.cs new file mode 100644 index 000000000..36e4e56ac --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Queries.cs @@ -0,0 +1,522 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Analysis; + +namespace PerformanceMonitor.Darling.Analysis; + +public sealed partial class PgDrillDownCollector +{ + /// + /// Per-command timeout for every drill-down query. These run inside the shared DarlingWorker sweep + /// loop whose watchdog warns at 60s ("collection body has not completed after 60s" -> stale-status + /// flapping), so no single drill-down query may run unbounded: even an optimized one must fail fast on + /// a cold cache / index bloat / a much busier server rather than eat the whole budget. Npgsql defaults + /// to 30s already, but pinning it here makes the bound explicit and independent of any global default. + /// + private const int DrillDownCommandTimeoutSeconds = 30; + + public const string SpikePeakSql = @" +SELECT collection_time, sqlserver_cpu_utilization +FROM v_cpu_utilization_stats +WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 +ORDER BY sqlserver_cpu_utilization DESC +LIMIT 1"; + + public const string QueriesAtSpikeSql = @" +SELECT collection_time, session_id, database_name, status, + cpu_time_ms, total_elapsed_time_ms, logical_reads, + wait_type, dop, parallel_worker_count, + LEFT(query_text, 500) AS query_text +FROM v_query_snapshots +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3 +AND query_text NOT LIKE 'WAITFOR%' +ORDER BY cpu_time_ms DESC +LIMIT 5"; + + private async Task CollectQueriesAtSpike(AnalysisFinding finding, AnalysisContext context) + { + // Find the peak CPU time, then get queries active within 2 minutes of it + await using var connection = await _postgres.OpenConnectionAsync(); + + // Step 1: Find when the spike occurred + using var peakCmd = new NpgsqlCommand(SpikePeakSql, connection); + peakCmd.CommandTimeout = DrillDownCommandTimeoutSeconds; + peakCmd.Parameters.AddWithValue(context.ServerId); + peakCmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + peakCmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + DateTime? peakTime = null; + int peakCpu = 0; + using (var peakReader = await peakCmd.ExecuteReaderAsync()) + { + if (await peakReader.ReadAsync()) + { + peakTime = peakReader.GetDateTime(0); + peakCpu = peakReader.GetInt32(1); + } + } + + if (peakTime == null) return; + + // Step 2: Get queries active within 2 minutes of peak + using var queryCmd = new NpgsqlCommand(QueriesAtSpikeSql, connection); + queryCmd.CommandTimeout = DrillDownCommandTimeoutSeconds; + queryCmd.Parameters.AddWithValue(context.ServerId); + queryCmd.Parameters.AddWithValue(AsNaive(peakTime.Value.AddMinutes(-2))); + queryCmd.Parameters.AddWithValue(AsNaive(peakTime.Value.AddMinutes(2))); + + var items = new List(); + using (var reader = await queryCmd.ExecuteReaderAsync()) + { + while (await reader.ReadAsync()) + { + items.Add(new + { + time = reader.IsDBNull(0) ? "" : reader.GetDateTime(0).ToString("o"), + session_id = reader.IsDBNull(1) ? 0 : reader.GetInt32(1), + database = reader.IsDBNull(2) ? "" : reader.GetString(2), + status = reader.IsDBNull(3) ? "" : reader.GetString(3), + cpu_time_ms = reader.IsDBNull(4) ? 0L : Convert.ToInt64(reader.GetValue(4)), + elapsed_time_ms = reader.IsDBNull(5) ? 0L : Convert.ToInt64(reader.GetValue(5)), + logical_reads = reader.IsDBNull(6) ? 0L : Convert.ToInt64(reader.GetValue(6)), + wait_type = reader.IsDBNull(7) ? "" : reader.GetString(7), + dop = reader.IsDBNull(8) ? 0 : reader.GetInt32(8), + parallel_workers = reader.IsDBNull(9) ? 0 : reader.GetInt32(9), + query_text = reader.IsDBNull(10) ? "" : reader.GetString(10) + }); + } + } + + if (items.Count > 0) + { + finding.DrillDown!["spike_peak"] = new + { + time = peakTime.Value.ToString("o"), + cpu_percent = peakCpu + }; + finding.DrillDown!["queries_at_spike"] = items; + } + } + + public const string TopCpuQueriesSql = @" +SELECT database_name, query_hash, + SUM(delta_worker_time)::BIGINT AS total_cpu_us, + SUM(delta_execution_count)::BIGINT AS exec_count, + MAX(max_dop) AS max_dop, + SUM(delta_spills)::BIGINT AS spills, + LEFT(MAX(query_text), 500) AS query_text +FROM v_query_stats +WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 +AND delta_worker_time > 0 +GROUP BY database_name, query_hash +ORDER BY total_cpu_us DESC +LIMIT 5"; + + private async Task CollectTopCpuQueries(AnalysisFinding finding, AnalysisContext context) + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(TopCpuQueriesSql, connection); + cmd.CommandTimeout = DrillDownCommandTimeoutSeconds; + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + var items = new List(); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new + { + database = reader.IsDBNull(0) ? "" : reader.GetString(0), + query_hash = reader.IsDBNull(1) ? "" : reader.GetString(1), + total_cpu_ms = reader.IsDBNull(2) ? 0.0 : Convert.ToDouble(reader.GetValue(2)) / 1000.0, + execution_count = reader.IsDBNull(3) ? 0L : Convert.ToInt64(reader.GetValue(3)), + max_dop = reader.IsDBNull(4) ? 0 : Convert.ToInt32(reader.GetValue(4)), + spills = reader.IsDBNull(5) ? 0L : Convert.ToInt64(reader.GetValue(5)), + query_text = reader.IsDBNull(6) ? "" : reader.GetString(6) + }); + } + + if (items.Count > 0 && !finding.DrillDown!.ContainsKey("top_cpu_queries")) + finding.DrillDown!["top_cpu_queries"] = items; + } + + public const string TopSpillingQueriesSql = @" +SELECT database_name, query_hash, + SUM(delta_spills)::BIGINT AS total_spills, + SUM(delta_execution_count)::BIGINT AS exec_count, + LEFT(MAX(query_text), 500) AS query_text +FROM v_query_stats +WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 +AND delta_spills > 0 +GROUP BY database_name, query_hash +ORDER BY total_spills DESC +LIMIT 5"; + + private async Task CollectTopSpillingQueries(AnalysisFinding finding, AnalysisContext context) + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(TopSpillingQueriesSql, connection); + cmd.CommandTimeout = DrillDownCommandTimeoutSeconds; + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + var items = new List(); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new + { + database = reader.IsDBNull(0) ? "" : reader.GetString(0), + query_hash = reader.IsDBNull(1) ? "" : reader.GetString(1), + total_spills = reader.IsDBNull(2) ? 0L : Convert.ToInt64(reader.GetValue(2)), + execution_count = reader.IsDBNull(3) ? 0L : Convert.ToInt64(reader.GetValue(3)), + query_text = reader.IsDBNull(4) ? "" : reader.GetString(4) + }); + } + + if (items.Count > 0) + finding.DrillDown!["top_spilling_queries"] = items; + } + + public const string ParameterSensitiveSql = @" +WITH latest AS +( + SELECT + database_name, + query_hash, + query_plan_hash, + execution_count, + creation_time, + min_worker_time, + max_worker_time, + min_grant_kb, + max_grant_kb, + min_spills, + max_spills, + query_text, + ROW_NUMBER() OVER + ( + PARTITION BY database_name, query_hash, query_plan_hash + ORDER BY collection_time DESC + ) AS rn + FROM v_query_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND delta_execution_count > 0 +) +SELECT + database_name, + query_hash, + query_plan_hash, + execution_count, + min_worker_time, + max_worker_time, + max_worker_time::DOUBLE PRECISION / NULLIF(min_worker_time, 0) AS worker_ratio, + max_grant_kb::DOUBLE PRECISION / NULLIF(min_grant_kb, 0) AS grant_ratio, + CASE WHEN max_spills > 0 AND min_spills = 0 THEN 1 ELSE 0 END AS spill_divergence, + LEFT(query_text, 500) AS query_text +FROM latest +WHERE rn = 1 +AND min_worker_time >= 10000 +AND max_worker_time >= 250000 +AND execution_count >= 20 +AND creation_time <= $2 +AND max_worker_time::DOUBLE PRECISION / NULLIF(min_worker_time, 0) >= 10 +ORDER BY worker_ratio DESC +LIMIT 5"; + + /// + /// Top parameter-sensitive plans behind a PARAMETER_SENSITIVITY finding. + /// Re-runs Detector A's detection (standard analysis window) for the top 5 offenders. + /// + private async Task CollectParameterSensitiveQueries(AnalysisFinding finding, AnalysisContext context) + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(ParameterSensitiveSql, connection); + cmd.CommandTimeout = DrillDownCommandTimeoutSeconds; + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + var items = new List(); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new + { + database = reader.IsDBNull(0) ? "" : reader.GetString(0), + query_hash = reader.IsDBNull(1) ? "" : reader.GetString(1), + query_plan_hash = reader.IsDBNull(2) ? "" : reader.GetString(2), + execution_count = reader.IsDBNull(3) ? 0L : Convert.ToInt64(reader.GetValue(3)), + min_worker_time_us = reader.IsDBNull(4) ? 0L : Convert.ToInt64(reader.GetValue(4)), + max_worker_time_us = reader.IsDBNull(5) ? 0L : Convert.ToInt64(reader.GetValue(5)), + worker_ratio = reader.IsDBNull(6) ? 0.0 : Convert.ToDouble(reader.GetValue(6)), + grant_ratio = reader.IsDBNull(7) ? 0.0 : Convert.ToDouble(reader.GetValue(7)), + spills_on_some_inputs = !reader.IsDBNull(8) && Convert.ToInt32(reader.GetValue(8)) == 1, + query_text = reader.IsDBNull(9) ? "" : reader.GetString(9) + }); + } + + if (items.Count > 0) + finding.DrillDown!["parameter_sensitive_queries"] = items; + } + + public const string RegressedQueriesSql = @" +WITH deduped AS +( + -- LOAD-BEARING (correctness, not just perf): query_store_stats rows are CUMULATIVE per-Query-Store- + -- interval snapshots. The QueryStoreCollector is incremental and re-fetches the OPEN interval every + -- cycle as its last_execution_time advances (WHERE last_execution_time > watermark), so the SAME + -- interval (same first_execution_time) is collected repeatedly with a GROWING execution_count. Keep + -- only the LATEST snapshot per interval; summing the accumulating snapshots would multiply-count + -- executions and corrupt the execution_count-weighted cpu/duration averages. + SELECT + database_name, + query_id, + plan_id, + query_plan_hash, + execution_count, + avg_cpu_time_us, + avg_duration_us, + last_execution_time, + query_text, + ROW_NUMBER() OVER + ( + PARTITION BY database_name, query_id, plan_id, first_execution_time + ORDER BY collection_time DESC + ) AS rn + FROM v_query_store_stats + WHERE server_id = $1 + AND execution_type_desc = 'Regular' + AND last_execution_time >= $2 + -- Chunk-exclusion bound. collection_time >= last_execution_time ALWAYS (a row is collected AFTER the + -- interval's last execution), so last_execution_time >= $2 provably implies this; the 1-day slack is + -- safety. query_store_stats is a hypertable partitioned on collection_time, so this lets TimescaleDB + -- exclude whole chunks instead of decompress-then-filter as retained history grows. + AND collection_time >= $2 - interval '1 day' +), +plan_dedup AS +( + -- Aggregate the DISTINCT intervals (rn = 1) per (query_id, plan hash). Collapses the old + -- plan_agg(per plan_id) + plan_dedup(per hash) two-stage into one: a weighted-avg of weighted-avgs + -- equals the direct execution_count-weighted avg, and MAX(plan_id) is unchanged. MAX(plan_id) carries + -- the most recently observed plan_id in the hash forward (newer plans are less likely evicted by + -- Query Store retention; any plan_id sharing the hash forces the same execution shape). + SELECT + database_name, + query_id, + query_plan_hash, + MAX(plan_id) AS plan_id, + any_value(query_text) AS query_text, + SUM(execution_count) AS execs, + SUM(avg_cpu_time_us * execution_count)::DOUBLE PRECISION / NULLIF(SUM(execution_count), 0) AS cpu_per_exec, + SUM(avg_duration_us * execution_count)::DOUBLE PRECISION / NULLIF(SUM(execution_count), 0) AS dur_per_exec, + MAX(last_execution_time) AS last_exec + FROM deduped + WHERE rn = 1 + GROUP BY database_name, query_id, query_plan_hash + HAVING SUM(execution_count) >= 25 +), +latest AS +( + -- The most recently executed plan per query. DISTINCT ON instead of the old self-referential rank, + -- so plan_dedup is materialized ONCE rather than the whole pipeline running twice (once per side). + SELECT DISTINCT ON (database_name, query_id) * + FROM plan_dedup + ORDER BY database_name, query_id, last_exec DESC +), +cheapest AS +( + -- The cheapest (best) plan per query by cpu-per-exec. + SELECT DISTINCT ON (database_name, query_id) * + FROM plan_dedup + ORDER BY database_name, query_id, cpu_per_exec ASC +) +SELECT + l.database_name, + l.query_id, + l.query_plan_hash AS latest_plan_hash, + l.cpu_per_exec AS latest_cpu, + l.dur_per_exec AS latest_dur, + b.query_plan_hash AS best_plan_hash, + b.plan_id AS best_plan_id, + b.cpu_per_exec AS best_cpu, + b.dur_per_exec AS best_dur, + GREATEST + ( + l.cpu_per_exec / NULLIF(b.cpu_per_exec, 0), + l.dur_per_exec / NULLIF(b.dur_per_exec, 0) + ) AS regression_factor, + LEFT(l.query_text, 500) AS query_text +FROM latest AS l +JOIN cheapest AS b USING (database_name, query_id) +WHERE l.query_plan_hash <> b.query_plan_hash +AND GREATEST + ( + l.cpu_per_exec / NULLIF(b.cpu_per_exec, 0), + l.dur_per_exec / NULLIF(b.dur_per_exec, 0) + ) >= 2 +ORDER BY regression_factor DESC +LIMIT 5"; + + /// + /// Top regressed queries behind a PLAN_REGRESSION finding. + /// Re-runs Detector B's detection for the top 5 offenders. Uses the same 14-day + /// last_execution_time comparison window as the detector — NOT the standard analysis + /// window — so the days-old "best plan" baseline is present. + /// + private async Task CollectRegressedQueries(AnalysisFinding finding, AnalysisContext context) + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(RegressedQueriesSql, connection); + cmd.CommandTimeout = DrillDownCommandTimeoutSeconds; + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart.AddDays(-14))); + + var items = new List(); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new + { + database = reader.IsDBNull(0) ? "" : reader.GetString(0), + query_id = reader.IsDBNull(1) ? 0L : Convert.ToInt64(reader.GetValue(1)), + latest_plan_hash = reader.IsDBNull(2) ? "" : reader.GetString(2), + latest_cpu_per_exec_us = reader.IsDBNull(3) ? 0.0 : Convert.ToDouble(reader.GetValue(3)), + latest_duration_per_exec_us = reader.IsDBNull(4) ? 0.0 : Convert.ToDouble(reader.GetValue(4)), + best_plan_hash = reader.IsDBNull(5) ? "" : reader.GetString(5), + best_plan_id = reader.IsDBNull(6) ? 0L : Convert.ToInt64(reader.GetValue(6)), + best_cpu_per_exec_us = reader.IsDBNull(7) ? 0.0 : Convert.ToDouble(reader.GetValue(7)), + best_duration_per_exec_us = reader.IsDBNull(8) ? 0.0 : Convert.ToDouble(reader.GetValue(8)), + regression_factor = reader.IsDBNull(9) ? 0.0 : Convert.ToDouble(reader.GetValue(9)), + query_text = reader.IsDBNull(10) ? "" : reader.GetString(10) + }); + } + + if (items.Count > 0) + finding.DrillDown!["regressed_queries"] = items; + } + + public const string BadActorDetailSql = @" +SELECT database_name, query_hash, + LEFT(MAX(query_text), 500) AS query_text, + SUM(delta_execution_count)::BIGINT AS exec_count, + CASE WHEN SUM(delta_execution_count) > 0 + THEN SUM(delta_worker_time)::DOUBLE PRECISION / SUM(delta_execution_count) / 1000.0 + ELSE 0 END AS avg_cpu_ms, + CASE WHEN SUM(delta_execution_count) > 0 + THEN SUM(delta_elapsed_time)::DOUBLE PRECISION / SUM(delta_execution_count) / 1000.0 + ELSE 0 END AS avg_elapsed_ms, + CASE WHEN SUM(delta_execution_count) > 0 + THEN SUM(delta_logical_reads)::DOUBLE PRECISION / SUM(delta_execution_count) + ELSE 0 END AS avg_reads, + SUM(delta_worker_time)::BIGINT AS total_cpu_us, + SUM(delta_logical_reads)::BIGINT AS total_reads, + SUM(delta_spills)::BIGINT AS total_spills, + MAX(max_dop) AS max_dop +FROM v_query_stats +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3 +AND query_hash = $4 +GROUP BY database_name, query_hash"; + + private async Task CollectBadActorDetail(AnalysisFinding finding, AnalysisContext context) + { + // Extract query_hash from the fact key (BAD_ACTOR_0x...) + var queryHash = finding.RootFactKey.Replace("BAD_ACTOR_", ""); + if (string.IsNullOrEmpty(queryHash)) return; + + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(BadActorDetailSql, connection); + cmd.CommandTimeout = DrillDownCommandTimeoutSeconds; + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + cmd.Parameters.AddWithValue(queryHash); + + using var reader = await cmd.ExecuteReaderAsync(); + if (await reader.ReadAsync()) + { + finding.DrillDown!["bad_actor_query"] = new + { + database = reader.IsDBNull(0) ? "" : reader.GetString(0), + query_hash = reader.IsDBNull(1) ? "" : reader.GetString(1), + query_text = reader.IsDBNull(2) ? "" : reader.GetString(2), + execution_count = reader.IsDBNull(3) ? 0L : Convert.ToInt64(reader.GetValue(3)), + avg_cpu_ms = reader.IsDBNull(4) ? 0.0 : Math.Round(Convert.ToDouble(reader.GetValue(4)), 2), + avg_elapsed_ms = reader.IsDBNull(5) ? 0.0 : Math.Round(Convert.ToDouble(reader.GetValue(5)), 2), + avg_reads = reader.IsDBNull(6) ? 0.0 : Math.Round(Convert.ToDouble(reader.GetValue(6)), 0), + total_cpu_ms = reader.IsDBNull(7) ? 0.0 : Convert.ToDouble(reader.GetValue(7)) / 1000.0, + total_reads = reader.IsDBNull(8) ? 0L : Convert.ToInt64(reader.GetValue(8)), + total_spills = reader.IsDBNull(9) ? 0L : Convert.ToInt64(reader.GetValue(9)), + max_dop = reader.IsDBNull(10) ? 0 : Convert.ToInt32(reader.GetValue(10)) + }; + } + } + + public const string PendingGrantsSql = @" +SELECT collection_time, + target_memory_mb, total_memory_mb, available_memory_mb, + granted_memory_mb, used_memory_mb, + grantee_count, waiter_count, + timeout_error_count_delta, forced_grant_count_delta +FROM v_memory_grant_stats +WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 +AND waiter_count > 0 +ORDER BY waiter_count DESC +LIMIT 5"; + + private async Task CollectPendingGrants(AnalysisFinding finding, AnalysisContext context) + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(PendingGrantsSql, connection); + cmd.CommandTimeout = DrillDownCommandTimeoutSeconds; + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + var items = new List(); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new + { + time = reader.IsDBNull(0) ? "" : reader.GetDateTime(0).ToString("o"), + target_memory_mb = reader.IsDBNull(1) ? 0.0 : Convert.ToDouble(reader.GetValue(1)), + total_memory_mb = reader.IsDBNull(2) ? 0.0 : Convert.ToDouble(reader.GetValue(2)), + available_memory_mb = reader.IsDBNull(3) ? 0.0 : Convert.ToDouble(reader.GetValue(3)), + granted_memory_mb = reader.IsDBNull(4) ? 0.0 : Convert.ToDouble(reader.GetValue(4)), + used_memory_mb = reader.IsDBNull(5) ? 0.0 : Convert.ToDouble(reader.GetValue(5)), + grantee_count = reader.IsDBNull(6) ? 0 : reader.GetInt32(6), + waiter_count = reader.IsDBNull(7) ? 0 : reader.GetInt32(7), + timeout_errors = reader.IsDBNull(8) ? 0L : Convert.ToInt64(reader.GetValue(8)), + forced_grants = reader.IsDBNull(9) ? 0L : Convert.ToInt64(reader.GetValue(9)) + }); + } + + if (items.Count > 0) + finding.DrillDown!["pending_grants"] = items; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Storage.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Storage.cs new file mode 100644 index 000000000..5315d70df --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Storage.cs @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Analysis; + +namespace PerformanceMonitor.Darling.Analysis; + +public sealed partial class PgDrillDownCollector +{ + public const string FileLatencyBreakdownSql = @" +SELECT database_name, file_type, + AVG(delta_stall_read_ms * 1.0 / NULLIF(delta_reads, 0)) AS avg_read_ms, + AVG(delta_stall_write_ms * 1.0 / NULLIF(delta_writes, 0)) AS avg_write_ms, + SUM(delta_reads)::BIGINT AS total_reads, + SUM(delta_writes)::BIGINT AS total_writes +FROM v_file_io_stats +WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 +AND (delta_reads > 0 OR delta_writes > 0) +GROUP BY database_name, file_type +ORDER BY avg_read_ms DESC NULLS LAST +LIMIT 10"; + + private async Task CollectFileLatencyBreakdown(AnalysisFinding finding, AnalysisContext context) + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(FileLatencyBreakdownSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + var items = new List(); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new + { + database = reader.IsDBNull(0) ? "" : reader.GetString(0), + file_type = reader.IsDBNull(1) ? "" : reader.GetString(1), + avg_read_latency_ms = reader.IsDBNull(2) ? 0.0 : Math.Round(Convert.ToDouble(reader.GetValue(2)), 2), + avg_write_latency_ms = reader.IsDBNull(3) ? 0.0 : Math.Round(Convert.ToDouble(reader.GetValue(3)), 2), + total_reads = reader.IsDBNull(4) ? 0L : Convert.ToInt64(reader.GetValue(4)), + total_writes = reader.IsDBNull(5) ? 0L : Convert.ToInt64(reader.GetValue(5)) + }); + } + + if (items.Count > 0) + finding.DrillDown!["file_latency_breakdown"] = items; + } + + public const string AutogrowthPercentFilesSql = @" +WITH latest AS ( + SELECT database_name, file_id, file_type_desc, file_name, total_size_mb, is_percent_growth, growth_pct, + ROW_NUMBER() OVER (PARTITION BY database_name, file_id ORDER BY collection_time DESC) AS rn + FROM v_database_size_stats + WHERE server_id = $1 +) +SELECT database_name, file_type_desc, file_name, total_size_mb, growth_pct +FROM latest +WHERE rn = 1 +AND is_percent_growth = true +AND total_size_mb >= 10240 +AND database_name NOT IN ('master', 'msdb', 'model', 'tempdb') +ORDER BY total_size_mb DESC +LIMIT 50"; + + /// + /// Lists the large (>= 10 GB) data/log files on PERCENTAGE autogrowth (WS3), latest + /// snapshot per file, excluding system databases — and attaches a copy-paste + /// ALTER DATABASE ... MODIFY FILE fix per file (FILEGROWTH set to a size-tiered fixed MB). + /// Same structured fields + SHARED renderer as the Lite/Dashboard collectors so the + /// copy-paste is byte-identical across apps. + /// + private async Task CollectAutogrowthPercentFiles(AnalysisFinding finding, AnalysisContext context) + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(AutogrowthPercentFilesSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + + var items = new List(); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var database = reader.IsDBNull(0) ? "" : reader.GetString(0); + var fileType = reader.IsDBNull(1) ? "" : reader.GetString(1); + var logical = reader.IsDBNull(2) ? "" : reader.GetString(2); + var sizeMb = reader.IsDBNull(3) ? 0.0 : Convert.ToDouble(reader.GetValue(3)); + var growthPct = reader.IsDBNull(4) ? 0 : Convert.ToInt32(reader.GetValue(4)); + if (string.IsNullOrEmpty(database) || string.IsNullOrEmpty(logical)) continue; + + var growthMb = FactRemediation.RecommendedGrowthMbFor(fileType); + items.Add(new + { + database, + logical_file_name = logical, + file_type = fileType, + total_size_mb = sizeMb, + growth_pct = growthPct, + issue = $"{growthPct}% autogrowth on {sizeMb / 1024.0:N1} GB {fileType} file", + alter_statement = FactRemediation.BuildModifyFileStatement(database, logical, growthMb) + }); + } + + if (items.Count > 0) + finding.DrillDown!["autogrowth_percent_files"] = items; + } + + public const string TempDbBreakdownSql = @" +SELECT collection_time, user_object_reserved_mb, internal_object_reserved_mb, + version_store_reserved_mb, unallocated_mb +FROM v_tempdb_stats +WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 +ORDER BY (user_object_reserved_mb + internal_object_reserved_mb + version_store_reserved_mb) DESC +LIMIT 5"; + + private async Task CollectTempDbBreakdown(AnalysisFinding finding, AnalysisContext context) + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(TempDbBreakdownSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + var items = new List(); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new + { + time = reader.GetDateTime(0).ToString("o"), + user_objects_mb = reader.IsDBNull(1) ? 0.0 : Convert.ToDouble(reader.GetValue(1)), + internal_objects_mb = reader.IsDBNull(2) ? 0.0 : Convert.ToDouble(reader.GetValue(2)), + version_store_mb = reader.IsDBNull(3) ? 0.0 : Convert.ToDouble(reader.GetValue(3)), + unallocated_mb = reader.IsDBNull(4) ? 0.0 : Convert.ToDouble(reader.GetValue(4)) + }); + } + + if (items.Count > 0) + finding.DrillDown!["tempdb_breakdown"] = items; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.cs new file mode 100644 index 000000000..4af698cc8 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.cs @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Npgsql; +using PerformanceMonitor.Analysis; +using PerformanceMonitor.PlanAnalysis; + +namespace PerformanceMonitor.Darling.Analysis; + +/// +/// Enriches findings with drill-down data from Darling's Postgres store — Lite's +/// DrillDownCollector ported method-for-method (Phase-5 analysis slice AN3): the same +/// enrichment dispatch (story-path keys → collectors, the D7/WS3/WS4 cheap-config drill-downs +/// running below the 0.5 display gate, the expensive collectors behind it), the same drill-down +/// dictionary keys and row shapes, the same per-finding catch-and-keep error posture, and the +/// same partial-file split (Blocking / Config / Plans / Queries / Storage). Lite's analysis SQL +/// was deliberately written in the PG-shared dialect and the V4 passthrough v_<table> +/// views exist precisely so these queries run VERBATIM — every query string here is +/// byte-identical to Lite's. +/// +/// +/// The documented PG-port deviations, none of which touch a query's text (the PgFactCollector +/// AN2a conventions): +/// +/// /* PG port: Lite wraps every read in _duckDb.AcquireReadLock() — +/// DuckDB is a single-writer embedded file and Lite serializes readers against the writer. +/// Postgres is MVCC; no read lock exists or is needed, so the lock line is dropped from every +/// method. */ +/// /* PG port: parameters bind positionally as $N exactly like Lite's +/// DuckDB SQL already did, via Npgsql's positional AddWithValue; every +/// is bound naive-UTC Kind-Unspecified — the PgCollectorRowWriter +/// discipline, because Npgsql 6+ maps Kind-Utc to timestamptz and rejects it against the +/// store's naive timestamp columns. AnalysisContext times are used as-is (host-UTC window +/// semantics — no offset math). */ +/// /* PG port: Lite logs enrichment failures through its static +/// AppLogger; Darling injects an optional (the PgFindingStore +/// convention) — same message, same don't-null-out posture. */ +/// /* PG port: the SQL moves from inline literals to public const +/// fields (text unchanged) so Darling.Tests can pin the dialect ungated — the +/// PgFindingStore/PgFactCollector convention. */ +/// +/// No query uses NOW()/CURRENT_TIMESTAMP (every window bound is a parameter), no +/// QUALIFY appears, and the one any_value() use (the plan-regression drill-down, +/// same as the fact collector's plan-regression detector) is standard SQL:2023, in Postgres +/// since 16 (the product's minimum PG is 17) — all pinned by the AN3 tests. +/// +/// +public sealed partial class PgDrillDownCollector +{ + private readonly NpgsqlDataSource _postgres; + private readonly IPlanFetcher? _planFetcher; + private readonly ILogger? _logger; + private const int TextLimit = 500; + + public PgDrillDownCollector(NpgsqlDataSource postgres, IPlanFetcher? planFetcher = null, ILogger? logger = null) + { + _postgres = postgres ?? throw new ArgumentNullException(nameof(postgres)); + _planFetcher = planFetcher; + _logger = logger; + } + + /// + /// Every query this collector executes, for the ungated dialect/hygiene pins in + /// Darling.Tests (no QUALIFY, no bare NOW()/CURRENT_TIMESTAMP, no read_parquet, $N + /// positional parameters only, and every FROM/JOIN target resolves to a V4 passthrough + /// view or a V1/V2 table). The reconstructed-chain query shares the + /// fragments with the fact collector, so the pair-row + /// column order and the SPID-0 phantom filter cannot drift between the two consumers. + /// + public static IReadOnlyList AllSql { get; } = new[] + { + TopDeadlocksSql, + TopBlockingChainsSql, + ReconstructedChainsSql, + LockModeBreakdownSql, + ConfigIssuesSql, + PlanHandleLookupSql, + PlanAdvisoryXmlSql, + SpikePeakSql, + QueriesAtSpikeSql, + TopCpuQueriesSql, + TopSpillingQueriesSql, + ParameterSensitiveSql, + RegressedQueriesSql, + BadActorDetailSql, + PendingGrantsSql, + FileLatencyBreakdownSql, + AutogrowthPercentFilesSql, + TempDbBreakdownSql + }; + + /// + /// Enriches each finding's DrillDown dictionary based on its story path. + /// + public async Task EnrichFindingsAsync(List findings, AnalysisContext context) + { + foreach (var finding in findings) + { + try + { + finding.DrillDown = new Dictionary(); + var pathKeys = finding.StoryPath.Split(" → ", StringSplitOptions.RemoveEmptyEntries).ToHashSet(); + + /* D7: the config drill-down is a single cheap config-table read and is + required to build config/RCSI/db-config advice, which legitimately scores + below 0.5 (RCSI-off base severity is 0.3). Collect it regardless of the + 0.5 display gate. */ + if (pathKeys.Contains("DB_CONFIG")) + await CollectConfigIssues(finding, context); + + /* WS3: the percent-autogrowth drill-down is a single cheap config-table read + and is required to render the copy-paste MODIFY FILE fix for a + FILE_AUTOGROWTH_PERCENT finding, which scores 0.3 (advisory). Collect it + regardless of the 0.5 display gate, like the config drill-down above. */ + if (pathKeys.Contains("FILE_AUTOGROWTH_PERCENT")) + await CollectAutogrowthPercentFiles(finding, context); + + /* WS4: re-parse the top collected query plans to render the specific missing + indexes / warnings for a MISSING_INDEX or PLAN_WARNING finding, which scores 0.4 + (advisory). Run regardless of the 0.5 display gate, like the config drill-downs + above — the fact carries only counts, so the strings live here. */ + if (pathKeys.Contains("MISSING_INDEX") || pathKeys.Contains("PLAN_WARNING")) + await CollectPlanAdvisoryDetail(finding, context, pathKeys); + + // Below the 0.5 display gate, only the cheap config drill-down above runs; + // the expensive collectors (plan fetches, multi-row reads) are skipped. + if (finding.Severity < 0.5) + { + if (finding.DrillDown.Count == 0) + finding.DrillDown = null; + continue; + } + + if (pathKeys.Contains("DEADLOCKS")) + await CollectTopDeadlocks(finding, context); + + if (pathKeys.Contains("BLOCKING_EVENTS")) + await CollectTopBlockingChains(finding, context); + + if (pathKeys.Contains("BLOCKING_CHAIN")) + await CollectReconstructedBlockingChains(finding, context); + + if (pathKeys.Contains("CPU_SPIKE")) + await CollectQueriesAtSpike(finding, context); + + if (pathKeys.Contains("CPU_SQL_PERCENT") || pathKeys.Contains("CPU_SPIKE")) + await CollectTopCpuQueries(finding, context); + + if (pathKeys.Contains("QUERY_SPILLS")) + await CollectTopSpillingQueries(finding, context); + + if (pathKeys.Contains("IO_READ_LATENCY_MS") || pathKeys.Contains("IO_WRITE_LATENCY_MS")) + await CollectFileLatencyBreakdown(finding, context); + + if (pathKeys.Contains("LCK") || pathKeys.Contains("LCK_M_S") || pathKeys.Contains("LCK_M_IS")) + await CollectLockModeBreakdown(finding, context); + + if (pathKeys.Contains("TEMPDB_USAGE")) + await CollectTempDbBreakdown(finding, context); + + if (pathKeys.Contains("MEMORY_GRANT_PENDING")) + await CollectPendingGrants(finding, context); + + if (pathKeys.Any(k => k.StartsWith("BAD_ACTOR_", StringComparison.OrdinalIgnoreCase))) + await CollectBadActorDetail(finding, context); + + if (pathKeys.Contains("PARAMETER_SENSITIVITY")) + await CollectParameterSensitiveQueries(finding, context); + + if (pathKeys.Contains("PLAN_REGRESSION")) + await CollectRegressedQueries(finding, context); + + // Plan analysis: for findings with top queries, analyze their cached plans + await CollectPlanAnalysis(finding, context); + + // Remove empty drill-down dictionaries + if (finding.DrillDown.Count == 0) + finding.DrillDown = null; + } + catch (Exception ex) + { + _logger?.LogError("[PgDrillDownCollector] Drill-down failed for {StoryPath}: {ExceptionType}: {Message}", + finding.StoryPath, ex.GetType().Name, ex.Message); + // Don't null out — keep whatever was collected before the error + } + } + } + + private static void CollectPlanNodes(PlanNode node, List nodes) + { + nodes.Add(node); + foreach (var child in node.Children) + CollectPlanNodes(child, nodes); + } + + /// Kind-Unspecified for parameter binds — Npgsql 6+ rejects Kind-Utc against + /// timestamp (the PgCollectorRowWriter / PgFactCollector discipline). + private static DateTime AsNaive(DateTime value) => + DateTime.SpecifyKind(value, DateTimeKind.Unspecified); +} diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Activity.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Activity.cs new file mode 100644 index 000000000..1b9d7a180 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Activity.cs @@ -0,0 +1,287 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Analysis; + +namespace PerformanceMonitor.Darling.Analysis; + +public sealed partial class PgFactCollector +{ + public const string BadActorSql = @" +SELECT + database_name, + query_hash, + SUM(delta_execution_count)::BIGINT AS exec_count, + CASE WHEN SUM(delta_execution_count) > 0 + THEN SUM(delta_worker_time)::DOUBLE PRECISION / SUM(delta_execution_count) / 1000.0 + ELSE 0 END AS avg_cpu_ms, + CASE WHEN SUM(delta_execution_count) > 0 + THEN SUM(delta_elapsed_time)::DOUBLE PRECISION / SUM(delta_execution_count) / 1000.0 + ELSE 0 END AS avg_elapsed_ms, + CASE WHEN SUM(delta_execution_count) > 0 + THEN SUM(delta_logical_reads)::DOUBLE PRECISION / SUM(delta_execution_count) + ELSE 0 END AS avg_reads, + SUM(delta_worker_time)::BIGINT AS total_cpu_us, + SUM(delta_logical_reads)::BIGINT AS total_reads, + SUM(delta_spills)::BIGINT AS total_spills, + MAX(max_dop) AS max_dop, + LEFT(MAX(query_text), 200) AS query_text +FROM v_query_stats +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3 +AND delta_execution_count > 0 +GROUP BY database_name, query_hash +HAVING SUM(delta_execution_count) >= 100 +ORDER BY SUM(delta_worker_time)::DOUBLE PRECISION / GREATEST(SUM(delta_execution_count), 1) * + LN(GREATEST(SUM(delta_execution_count), 1)) DESC +LIMIT 5"; + + /// + /// Identifies individual queries that are consistently terrible ("bad actors"). + /// These queries don't necessarily cause server-level symptoms but waste resources + /// on every execution. Detection uses execution count tiers x per-execution impact. + /// Top 5 worst offenders become individual BAD_ACTOR facts. + /// + private async Task CollectBadActorFactsAsync(AnalysisContext context, List facts) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(BadActorSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var dbName = reader.IsDBNull(0) ? "" : reader.GetString(0); + var queryHash = reader.IsDBNull(1) ? "" : reader.GetString(1); + var execCount = reader.IsDBNull(2) ? 0L : Convert.ToInt64(reader.GetValue(2)); + var avgCpuMs = reader.IsDBNull(3) ? 0.0 : Convert.ToDouble(reader.GetValue(3)); + var avgElapsedMs = reader.IsDBNull(4) ? 0.0 : Convert.ToDouble(reader.GetValue(4)); + var avgReads = reader.IsDBNull(5) ? 0.0 : Convert.ToDouble(reader.GetValue(5)); + var totalCpuUs = reader.IsDBNull(6) ? 0L : Convert.ToInt64(reader.GetValue(6)); + var totalReads = reader.IsDBNull(7) ? 0L : Convert.ToInt64(reader.GetValue(7)); + var totalSpills = reader.IsDBNull(8) ? 0L : Convert.ToInt64(reader.GetValue(8)); + var maxDop = reader.IsDBNull(9) ? 0 : Convert.ToInt32(reader.GetValue(9)); + var queryText = reader.IsDBNull(10) ? "" : reader.GetString(10); + + // Skip low-impact queries — need meaningful per-execution cost + if (avgCpuMs < 10 && avgReads < 1000) continue; + + facts.Add(new Fact + { + Source = "bad_actor", + Key = $"BAD_ACTOR_{queryHash}", + Value = avgCpuMs, // Primary scoring dimension + ServerId = context.ServerId, + DatabaseName = dbName, + Metadata = new Dictionary + { + ["execution_count"] = execCount, + ["avg_cpu_ms"] = avgCpuMs, + ["avg_elapsed_ms"] = avgElapsedMs, + ["avg_reads"] = avgReads, + ["total_cpu_us"] = totalCpuUs, + ["total_reads"] = totalReads, + ["total_spills"] = totalSpills, + ["max_dop"] = maxDop + } + }); + } + } + catch { /* Table may not exist or have no data */ } + } + + public const string ActiveQuerySql = @" +SELECT + COUNT(*) AS total_snapshots, + COUNT(CASE WHEN total_elapsed_time_ms > 30000 THEN 1 END) AS long_running_count, + COUNT(CASE WHEN blocking_session_id > 0 THEN 1 END) AS blocked_count, + MAX(total_elapsed_time_ms) AS max_elapsed_ms, + COUNT(CASE WHEN dop > 1 THEN 1 END) AS parallel_count, + MAX(dop) AS max_dop, + COUNT(DISTINCT session_id) AS distinct_sessions +FROM query_snapshots +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3"; + + /// + /// Collects active query snapshot facts: long-running queries, blocked sessions, high DOP. + /// + private async Task CollectActiveQueryFactsAsync(AnalysisContext context, List facts) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(ActiveQuerySql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + var totalSnapshots = reader.IsDBNull(0) ? 0L : ToInt64(reader.GetValue(0)); + if (totalSnapshots == 0) return; + + var longRunning = reader.IsDBNull(1) ? 0L : ToInt64(reader.GetValue(1)); + var blocked = reader.IsDBNull(2) ? 0L : ToInt64(reader.GetValue(2)); + var maxElapsed = reader.IsDBNull(3) ? 0L : ToInt64(reader.GetValue(3)); + var parallel = reader.IsDBNull(4) ? 0L : ToInt64(reader.GetValue(4)); + var maxDop = reader.IsDBNull(5) ? 0L : ToInt64(reader.GetValue(5)); + var distinctSessions = reader.IsDBNull(6) ? 0L : ToInt64(reader.GetValue(6)); + + facts.Add(new Fact + { + Source = "queries", + Key = "ACTIVE_QUERIES", + Value = longRunning, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["total_snapshots"] = totalSnapshots, + ["long_running_count"] = longRunning, + ["blocked_count"] = blocked, + ["max_elapsed_ms"] = maxElapsed, + ["parallel_count"] = parallel, + ["max_dop"] = maxDop, + ["distinct_sessions"] = distinctSessions + } + }); + } + catch { /* Table may not exist or have no data */ } + } + + public const string RunningJobsSql = @" +SELECT + COUNT(*) AS running_count, + COUNT(CASE WHEN is_running_long THEN 1 END) AS running_long_count, + MAX(percent_of_average) AS max_percent_of_avg, + MAX(current_duration_seconds) AS max_duration_seconds +FROM running_jobs +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3"; + + /// + /// Collects running job facts: jobs currently running long vs historical averages. + /// + private async Task CollectRunningJobFactsAsync(AnalysisContext context, List facts) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(RunningJobsSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + var runningCount = reader.IsDBNull(0) ? 0L : ToInt64(reader.GetValue(0)); + if (runningCount == 0) return; + + var runningLong = reader.IsDBNull(1) ? 0L : ToInt64(reader.GetValue(1)); + var maxPctAvg = reader.IsDBNull(2) ? 0.0 : Convert.ToDouble(reader.GetValue(2)); + var maxDuration = reader.IsDBNull(3) ? 0L : ToInt64(reader.GetValue(3)); + + facts.Add(new Fact + { + Source = "jobs", + Key = "RUNNING_JOBS", + Value = runningLong, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["running_count"] = runningCount, + ["running_long_count"] = runningLong, + ["max_percent_of_average"] = maxPctAvg, + ["max_duration_seconds"] = maxDuration + } + }); + } + catch { /* Table may not exist or have no data */ } + } + + public const string SessionStatsSql = @" +WITH latest AS ( + SELECT program_name, connection_count, running_count, sleeping_count, dormant_count, + ROW_NUMBER() OVER (PARTITION BY program_name ORDER BY collection_time DESC) AS rn + FROM session_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 +) +SELECT + SUM(connection_count) AS total_connections, + SUM(running_count) AS total_running, + SUM(sleeping_count) AS total_sleeping, + SUM(dormant_count) AS total_dormant, + COUNT(*) AS distinct_apps, + MAX(connection_count) AS max_app_connections +FROM latest WHERE rn = 1"; + + /// + /// Collects session stats: connection counts per application, total connections. + /// + private async Task CollectSessionFactsAsync(AnalysisContext context, List facts) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(SessionStatsSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + var totalConns = reader.IsDBNull(0) ? 0L : ToInt64(reader.GetValue(0)); + if (totalConns == 0) return; + + var totalRunning = reader.IsDBNull(1) ? 0L : ToInt64(reader.GetValue(1)); + var totalSleeping = reader.IsDBNull(2) ? 0L : ToInt64(reader.GetValue(2)); + var totalDormant = reader.IsDBNull(3) ? 0L : ToInt64(reader.GetValue(3)); + var distinctApps = reader.IsDBNull(4) ? 0L : ToInt64(reader.GetValue(4)); + var maxAppConns = reader.IsDBNull(5) ? 0L : ToInt64(reader.GetValue(5)); + + facts.Add(new Fact + { + Source = "sessions", + Key = "SESSION_STATS", + Value = totalConns, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["total_connections"] = totalConns, + ["total_running"] = totalRunning, + ["total_sleeping"] = totalSleeping, + ["total_dormant"] = totalDormant, + ["distinct_applications"] = distinctApps, + ["max_app_connections"] = maxAppConns + } + }); + } + catch { /* Table may not exist or have no data */ } + } + +} diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Config.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Config.cs new file mode 100644 index 000000000..23147d86b --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Config.cs @@ -0,0 +1,333 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Analysis; + +namespace PerformanceMonitor.Darling.Analysis; + +public sealed partial class PgFactCollector +{ + // Latest value PER configuration_name (ROW_NUMBER, not LIMIT N): server_config accumulates + // a row per capture, so LIMIT-N-ORDER-BY-time returns the newest N ROWS — which collapses to + // one config when captures are frequent, silently dropping settings. Partition by name and + // take rn = 1 so each requested setting is its latest value. + public const string ServerConfigSql = @" +WITH latest AS ( + SELECT + configuration_name, + value_in_use, + ROW_NUMBER() OVER (PARTITION BY configuration_name ORDER BY capture_time DESC) AS rn + FROM server_config + WHERE server_id = $1 + AND configuration_name IN ( + 'cost threshold for parallelism', + 'max degree of parallelism', + 'max server memory (MB)', + 'min server memory (MB)', + 'max worker threads', + 'priority boost', + 'lightweight pooling' + ) +) +SELECT configuration_name, value_in_use +FROM latest +WHERE rn = 1"; + + /// + /// Collects server configuration settings relevant to analysis. + /// These become facts that amplifiers and the config audit tool can reference + /// to make recommendations specific (e.g., "your CTFP is 50" vs "check CTFP"). + /// + private async Task CollectServerConfigFactsAsync(AnalysisContext context, List facts) + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(ServerConfigSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + + // max/min server memory are read alongside the rooted CONFIG_* facts so the + // narrow-memory derivation below can compare them without a second query. + double? maxMemoryMb = null; + double? minMemoryMb = null; + + using (var reader = await cmd.ExecuteReaderAsync()) + { + while (await reader.ReadAsync()) + { + var configName = reader.GetString(0); + var value = Convert.ToDouble(reader.GetValue(1)); + + if (configName == "max server memory (MB)") maxMemoryMb = value; + if (configName == "min server memory (MB)") minMemoryMb = value; + + var factKey = configName switch + { + "cost threshold for parallelism" => "CONFIG_CTFP", + "max degree of parallelism" => "CONFIG_MAXDOP", + "max server memory (MB)" => "CONFIG_MAX_MEMORY_MB", + "min server memory (MB)" => "CONFIG_MIN_MEMORY_MB", + "max worker threads" => "CONFIG_MAX_WORKER_THREADS", + "priority boost" => "CONFIG_PRIORITY_BOOST", + "lightweight pooling" => "CONFIG_LIGHTWEIGHT_POOLING", + _ => null + }; + + if (factKey == null) continue; + + facts.Add(new Fact + { + Source = "config", + Key = factKey, + Value = value, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["value_in_use"] = value + } + }); + } + } + + // CONFIG_MIN_MAX_MEMORY_NARROW: emitted only when max is configured AND min is pinned + // near it (shared rule so Dashboard/Lite/Darling agree). + var narrow = FactRemediation.BuildNarrowMemoryFact(context.ServerId, maxMemoryMb, minMemoryMb); + if (narrow is not null) + facts.Add(narrow); + } + + public const string ServerMetadataSql = @" +SELECT engine_edition, + CAST(SPLIT_PART(product_version, '.', 1) AS INTEGER) AS major_version +FROM server_properties +WHERE server_id = $1 +ORDER BY collection_time DESC +LIMIT 1"; + + /// + /// Collects SQL Server edition and major version from the server_properties table. + /// These are persisted by the collection service after connection check. + /// + private async Task CollectServerMetadataFactsAsync(AnalysisContext context, List facts) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(ServerMetadataSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + var edition = reader.IsDBNull(0) ? 0 : Convert.ToInt32(reader.GetValue(0)); + var majorVersion = reader.IsDBNull(1) ? 0 : Convert.ToInt32(reader.GetValue(1)); + + if (edition > 0) + facts.Add(new Fact { Source = "config", Key = "SERVER_EDITION", Value = edition, ServerId = context.ServerId }); + if (majorVersion > 0) + facts.Add(new Fact { Source = "config", Key = "SERVER_MAJOR_VERSION", Value = majorVersion, ServerId = context.ServerId }); + } + catch { /* Columns may not exist yet (pre-migration) */ } + } + + public const string DatabaseConfigSql = @" +WITH latest AS ( + SELECT database_name, recovery_model, is_auto_shrink_on, is_auto_close_on, + is_read_committed_snapshot_on, is_auto_create_stats_on, is_auto_update_stats_on, + is_query_store_on, compatibility_level, page_verify_option, + is_accelerated_database_recovery_on, + ROW_NUMBER() OVER (PARTITION BY database_name ORDER BY capture_time DESC) AS rn + FROM database_config + WHERE server_id = $1 +) +SELECT + COUNT(*) AS database_count, + COUNT(CASE WHEN is_auto_shrink_on THEN 1 END) AS auto_shrink_count, + COUNT(CASE WHEN is_auto_close_on THEN 1 END) AS auto_close_count, + COUNT(CASE WHEN NOT is_read_committed_snapshot_on THEN 1 END) AS rcsi_off_count, + COUNT(CASE WHEN NOT is_auto_create_stats_on THEN 1 END) AS auto_create_stats_off_count, + COUNT(CASE WHEN NOT is_auto_update_stats_on THEN 1 END) AS auto_update_stats_off_count, + COUNT(CASE WHEN page_verify_option != 'CHECKSUM' THEN 1 END) AS page_verify_not_checksum_count, + COUNT(CASE WHEN recovery_model = 'FULL' THEN 1 END) AS full_recovery_count, + COUNT(CASE WHEN recovery_model = 'SIMPLE' THEN 1 END) AS simple_recovery_count, + COUNT(CASE WHEN is_query_store_on THEN 1 END) AS query_store_on_count +FROM latest WHERE rn = 1 +AND database_name NOT IN ('master', 'msdb', 'model', 'tempdb')"; + + /// + /// Collects database configuration facts: RCSI status, auto_shrink, auto_close, + /// recovery model. Aggregates counts across databases. + /// + private async Task CollectDatabaseConfigFactsAsync(AnalysisContext context, List facts) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(DatabaseConfigSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + var dbCount = reader.IsDBNull(0) ? 0L : ToInt64(reader.GetValue(0)); + if (dbCount == 0) return; + + var autoShrink = reader.IsDBNull(1) ? 0L : ToInt64(reader.GetValue(1)); + var autoClose = reader.IsDBNull(2) ? 0L : ToInt64(reader.GetValue(2)); + var rcsiOff = reader.IsDBNull(3) ? 0L : ToInt64(reader.GetValue(3)); + var autoCreateOff = reader.IsDBNull(4) ? 0L : ToInt64(reader.GetValue(4)); + var autoUpdateOff = reader.IsDBNull(5) ? 0L : ToInt64(reader.GetValue(5)); + var pageVerifyBad = reader.IsDBNull(6) ? 0L : ToInt64(reader.GetValue(6)); + var fullRecovery = reader.IsDBNull(7) ? 0L : ToInt64(reader.GetValue(7)); + var simpleRecovery = reader.IsDBNull(8) ? 0L : ToInt64(reader.GetValue(8)); + var queryStoreOn = reader.IsDBNull(9) ? 0L : ToInt64(reader.GetValue(9)); + + facts.Add(new Fact + { + Source = "database_config", + Key = "DB_CONFIG", + Value = dbCount, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["database_count"] = dbCount, + ["auto_shrink_on_count"] = autoShrink, + ["auto_close_on_count"] = autoClose, + ["rcsi_off_count"] = rcsiOff, + ["auto_create_stats_off_count"] = autoCreateOff, + ["auto_update_stats_off_count"] = autoUpdateOff, + ["page_verify_not_checksum_count"] = pageVerifyBad, + ["full_recovery_count"] = fullRecovery, + ["simple_recovery_count"] = simpleRecovery, + ["query_store_on_count"] = queryStoreOn + } + }); + } + catch { /* Table may not exist or have no data */ } + } + + public const string TraceFlagsSql = @" +WITH latest AS ( + SELECT trace_flag, status, + ROW_NUMBER() OVER (PARTITION BY trace_flag ORDER BY capture_time DESC) AS rn + FROM trace_flags + WHERE server_id = $1 + AND is_global = true +) +SELECT trace_flag +FROM latest WHERE rn = 1 AND status = true +ORDER BY trace_flag"; + + /// + /// Collects active global trace flags. Context for the AI to factor into recommendations. + /// + private async Task CollectTraceFlagFactsAsync(AnalysisContext context, List facts) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(TraceFlagsSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + + using var reader = await cmd.ExecuteReaderAsync(); + var metadata = new Dictionary(); + var flagCount = 0; + + while (await reader.ReadAsync()) + { + var flag = Convert.ToInt32(reader.GetValue(0)); + metadata[$"TF_{flag}"] = 1; + flagCount++; + } + + if (flagCount == 0) return; + + metadata["flag_count"] = flagCount; + + facts.Add(new Fact + { + Source = "config", + Key = "TRACE_FLAGS", + Value = flagCount, + ServerId = context.ServerId, + Metadata = metadata + }); + } + catch { /* Table may not exist or have no data */ } + } + + public const string ServerPropertiesSql = @" +SELECT COALESCE(vcore_count, cpu_count) AS cpu_count, hyperthread_ratio, physical_memory_mb, + socket_count, cores_per_socket, is_hadr_enabled, edition, product_version, + lock_pages_in_memory, instant_file_initialization_enabled, memory_dump_count +FROM server_properties +WHERE server_id = $1 +ORDER BY collection_time DESC +LIMIT 1"; + + /// + /// Collects server hardware properties: CPU count, cores, sockets, memory. + /// Critical context for MAXDOP and memory recommendations. + /// + private async Task CollectServerPropertiesFactsAsync(AnalysisContext context, List facts) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(ServerPropertiesSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + var cpuCount = reader.IsDBNull(0) ? 0 : Convert.ToInt32(reader.GetValue(0)); + var htRatio = reader.IsDBNull(1) ? 0 : Convert.ToInt32(reader.GetValue(1)); + var physicalMemMb = reader.IsDBNull(2) ? 0L : ToInt64(reader.GetValue(2)); + var socketCount = reader.IsDBNull(3) ? 0 : Convert.ToInt32(reader.GetValue(3)); + var coresPerSocket = reader.IsDBNull(4) ? 0 : Convert.ToInt32(reader.GetValue(4)); + var hadrEnabled = !reader.IsDBNull(5) && Convert.ToBoolean(reader.GetValue(5)); + var edition = reader.IsDBNull(6) ? string.Empty : reader.GetString(6); + bool? lpim = reader.IsDBNull(8) ? (bool?)null : Convert.ToBoolean(reader.GetValue(8)); + bool? ifi = reader.IsDBNull(9) ? (bool?)null : Convert.ToBoolean(reader.GetValue(9)); + int? dumpCount = reader.IsDBNull(10) ? (int?)null : Convert.ToInt32(reader.GetValue(10)); + + if (cpuCount == 0) return; + + facts.Add(new Fact + { + Source = "config", + Key = "SERVER_HARDWARE", + Value = cpuCount, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["cpu_count"] = cpuCount, + ["hyperthread_ratio"] = htRatio, + ["physical_memory_mb"] = physicalMemMb, + ["socket_count"] = socketCount, + ["cores_per_socket"] = coresPerSocket, + ["hadr_enabled"] = hadrEnabled ? 1 : 0 + } + }); + + // WS5 server-health advisories (advise-only). Gating mirrors the Lite/Dashboard + // collectors so all consumers agree on what is worth flagging; a fact that would + // score 0 is simply never emitted (noise control). + FactCollectorHelpers.EmitServerHealthFacts(context, facts, edition, physicalMemMb, lpim, ifi, dumpCount); + } + catch { /* Table may not exist or have no data */ } + } + +} diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.QueryPerf.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.QueryPerf.cs new file mode 100644 index 000000000..5d6d24b5d --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.QueryPerf.cs @@ -0,0 +1,528 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Analysis; +using PerformanceMonitor.PlanAnalysis; + +namespace PerformanceMonitor.Darling.Analysis; + +public sealed partial class PgFactCollector +{ + public const string QueryStatsSql = @" +SELECT + SUM(delta_spills) AS total_spills, + COUNT(CASE WHEN max_dop > 8 THEN 1 END) AS high_dop_queries, + COUNT(CASE WHEN delta_spills > 0 THEN 1 END) AS spilling_queries, + SUM(delta_execution_count) AS total_executions, + SUM(delta_worker_time) AS total_cpu_time_us +FROM v_query_stats +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3 +AND delta_execution_count > 0"; + + /// + /// Collects query-level aggregate facts from query_stats. + /// Focuses on spills (memory grant misestimates) and high-parallelism queries. + /// + private async Task CollectQueryStatsFactsAsync(AnalysisContext context, List facts) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(QueryStatsSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + var totalSpills = reader.IsDBNull(0) ? 0L : ToInt64(reader.GetValue(0)); + var highDopQueries = reader.IsDBNull(1) ? 0L : ToInt64(reader.GetValue(1)); + var spillingQueries = reader.IsDBNull(2) ? 0L : ToInt64(reader.GetValue(2)); + var totalExecutions = reader.IsDBNull(3) ? 0L : ToInt64(reader.GetValue(3)); + var totalCpuTimeUs = reader.IsDBNull(4) ? 0L : ToInt64(reader.GetValue(4)); + + if (totalSpills > 0) + { + facts.Add(new Fact + { + Source = "queries", + Key = "QUERY_SPILLS", + Value = totalSpills, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["total_spills"] = totalSpills, + ["spilling_query_count"] = spillingQueries, + ["total_executions"] = totalExecutions + } + }); + } + + if (highDopQueries > 0) + { + facts.Add(new Fact + { + Source = "queries", + Key = "QUERY_HIGH_DOP", + Value = highDopQueries, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["high_dop_query_count"] = highDopQueries, + ["total_cpu_time_us"] = totalCpuTimeUs, + ["total_executions"] = totalExecutions + } + }); + } + } + catch { /* Table may not exist or have no data */ } + } + + public const string ParameterSensitivitySql = @" +WITH latest AS +( + SELECT + query_hash, + query_plan_hash, + database_name, + execution_count, + creation_time, + min_worker_time, + max_worker_time, + min_grant_kb, + max_grant_kb, + min_spills, + max_spills, + ROW_NUMBER() OVER + ( + PARTITION BY database_name, query_hash, query_plan_hash + ORDER BY collection_time DESC + ) AS rn + FROM v_query_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND delta_execution_count > 0 +) +SELECT + min_worker_time, + max_worker_time, + max_worker_time::DOUBLE PRECISION / NULLIF(min_worker_time, 0) AS worker_ratio, + max_grant_kb::DOUBLE PRECISION / NULLIF(min_grant_kb, 0) AS grant_ratio, + CASE WHEN max_spills > 0 AND min_spills = 0 THEN 1 ELSE 0 END AS spill_divergence +FROM latest +WHERE rn = 1 +AND min_worker_time >= 10000 +AND max_worker_time >= 250000 +AND execution_count >= 20 +AND creation_time <= $2 +AND max_worker_time::DOUBLE PRECISION / NULLIF(min_worker_time, 0) >= 10 +ORDER BY worker_ratio DESC +LIMIT 20"; + + /// + /// Detects parameter-sensitive cached plans: a single query_plan_hash whose + /// per-execution worker time varies wildly — one plan serving very different + /// parameter values. Emits one aggregate PARAMETER_SENSITIVITY fact. + /// Note min_*/max_* are cumulative over the plan's cached lifetime, so the + /// finding means "this plan, active now, has a history of widely varying cost". + /// + private async Task CollectParameterSensitivityFactsAsync(AnalysisContext context, List facts) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(ParameterSensitivitySql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + var offenderCount = 0; + var worstRatio = 0.0; + var worstMinWorker = 0L; + var worstMaxWorker = 0L; + var worstGrantRatio = 0.0; + var worstSpillDivergence = 0; + + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + // Rows arrive ordered by worker_ratio DESC — the first row is the worst offender. + if (offenderCount == 0) + { + worstMinWorker = reader.IsDBNull(0) ? 0L : ToInt64(reader.GetValue(0)); + worstMaxWorker = reader.IsDBNull(1) ? 0L : ToInt64(reader.GetValue(1)); + worstRatio = reader.IsDBNull(2) ? 0.0 : Convert.ToDouble(reader.GetValue(2)); + worstGrantRatio = reader.IsDBNull(3) ? 0.0 : Convert.ToDouble(reader.GetValue(3)); + worstSpillDivergence = reader.IsDBNull(4) ? 0 : Convert.ToInt32(reader.GetValue(4)); + } + offenderCount++; + } + + if (offenderCount == 0) return; + + facts.Add(new Fact + { + Source = "queries", + Key = "PARAMETER_SENSITIVITY", + Value = worstRatio, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["offender_count"] = offenderCount, + ["worst_ratio"] = worstRatio, + ["worst_min_worker_us"] = worstMinWorker, + ["worst_max_worker_us"] = worstMaxWorker, + ["worst_grant_ratio"] = worstGrantRatio, + ["grant_divergence"] = worstGrantRatio >= 5 ? 1 : 0, + ["spill_divergence"] = worstSpillDivergence + } + }); + } + catch { /* Table may not exist or have no data */ } + } + + /* PG port: any_value() below is standard SQL:2023, in Postgres since 16 — the product's + minimum supported PG is 17, so it stays verbatim (DuckDB and PG agree on its semantics: + an arbitrary non-null value from the group). */ + public const string PlanRegressionSql = @" +WITH deduped AS +( + -- Collapse incremental re-collections of the same open runtime-stats interval: + -- keep only the latest collection_time row per logical interval. + SELECT + database_name, + query_id, + plan_id, + query_plan_hash, + execution_count, + avg_cpu_time_us, + avg_duration_us, + last_execution_time, + is_forced_plan, + force_failure_count, + ROW_NUMBER() OVER + ( + PARTITION BY database_name, query_id, plan_id, first_execution_time + ORDER BY collection_time DESC + ) AS rn + FROM v_query_store_stats + WHERE server_id = $1 + AND execution_type_desc = 'Regular' + AND last_execution_time >= $2 +), +plan_agg AS +( + -- Execution-weighted per-exec cost per plan_id. + SELECT + database_name, + query_id, + plan_id, + any_value(query_plan_hash) AS query_plan_hash, + SUM(execution_count) AS execs, + SUM(avg_cpu_time_us * execution_count)::DOUBLE PRECISION / NULLIF(SUM(execution_count), 0) AS cpu_per_exec, + SUM(avg_duration_us * execution_count)::DOUBLE PRECISION / NULLIF(SUM(execution_count), 0) AS dur_per_exec, + MAX(last_execution_time) AS last_exec, + bool_or(is_forced_plan) AS is_forced_plan, + MAX(force_failure_count) AS force_failure_count + FROM deduped + WHERE rn = 1 + GROUP BY database_name, query_id, plan_id +), +plan_dedup AS +( + -- Collapse plan_ids that share a query_plan_hash (a recompile can produce an + -- identical plan under a new plan_id); keep only plans with enough executions. + SELECT + database_name, + query_id, + query_plan_hash, + SUM(execs) AS execs, + SUM(cpu_per_exec * execs) / NULLIF(SUM(execs), 0) AS cpu_per_exec, + SUM(dur_per_exec * execs) / NULLIF(SUM(execs), 0) AS dur_per_exec, + MAX(last_exec) AS last_exec, + bool_or(is_forced_plan) AS is_forced_plan, + MAX(force_failure_count) AS force_failure_count + FROM plan_agg + GROUP BY database_name, query_id, query_plan_hash + HAVING SUM(execs) >= 25 +), +ranked AS +( + SELECT + *, + ROW_NUMBER() OVER (PARTITION BY database_name, query_id ORDER BY last_exec DESC) AS recency, + ROW_NUMBER() OVER (PARTITION BY database_name, query_id ORDER BY cpu_per_exec ASC) AS cheapness + FROM plan_dedup +), +compared AS +( + -- Latest active plan vs the best-performing plan for the same query. + SELECT + l.query_id, + l.cpu_per_exec AS latest_cpu, + l.dur_per_exec AS latest_dur, + l.is_forced_plan AS latest_is_forced, + l.force_failure_count AS force_failure_count, + b.cpu_per_exec AS best_cpu, + b.dur_per_exec AS best_dur, + GREATEST + ( + l.cpu_per_exec / NULLIF(b.cpu_per_exec, 0), + l.dur_per_exec / NULLIF(b.dur_per_exec, 0) + ) AS regression_factor + FROM ranked AS l + JOIN ranked AS b + ON b.database_name = l.database_name + AND b.query_id = l.query_id + AND b.cheapness = 1 + WHERE l.recency = 1 + AND l.query_plan_hash <> b.query_plan_hash +) +SELECT + query_id, + latest_cpu, + latest_dur, + latest_is_forced, + force_failure_count, + best_cpu, + best_dur, + regression_factor +FROM compared +WHERE regression_factor >= 2 +ORDER BY regression_factor DESC +LIMIT 20"; + + /// + /// Detects plan regressions: a query whose currently-active plan has per-execution + /// cost >= 2x the best plan that query is known to perform well with. Emits one + /// aggregate PLAN_REGRESSION fact. Sourced from Query Store (v_query_store_stats); + /// no fact when Query Store is not enabled on the monitored databases. + /// Unlike other collectors this windows on last_execution_time (14-day comparison + /// window), NOT collection_time — see plan note. + /// + private async Task CollectPlanRegressionFactsAsync(AnalysisContext context, List facts) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(PlanRegressionSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart.AddDays(-14))); + + var offenderCount = 0; + var worstFactor = 0.0; + var worstQueryId = 0L; + var worstLatestCpu = 0.0; + var worstBestCpu = 0.0; + var worstDimension = 1; + var worstLatestForced = 0; + var worstForceFailures = 0L; + + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + // Rows arrive ordered by regression_factor DESC — the first row is the worst offender. + if (offenderCount == 0) + { + worstQueryId = reader.IsDBNull(0) ? 0L : ToInt64(reader.GetValue(0)); + var latestCpu = reader.IsDBNull(1) ? 0.0 : Convert.ToDouble(reader.GetValue(1)); + var latestDur = reader.IsDBNull(2) ? 0.0 : Convert.ToDouble(reader.GetValue(2)); + worstLatestForced = (!reader.IsDBNull(3) && Convert.ToBoolean(reader.GetValue(3))) ? 1 : 0; + worstForceFailures = reader.IsDBNull(4) ? 0L : ToInt64(reader.GetValue(4)); + var bestCpu = reader.IsDBNull(5) ? 0.0 : Convert.ToDouble(reader.GetValue(5)); + var bestDur = reader.IsDBNull(6) ? 0.0 : Convert.ToDouble(reader.GetValue(6)); + worstFactor = reader.IsDBNull(7) ? 0.0 : Convert.ToDouble(reader.GetValue(7)); + + worstLatestCpu = latestCpu; + worstBestCpu = bestCpu; + var cpuRatio = bestCpu > 0 ? latestCpu / bestCpu : 0.0; + var durRatio = bestDur > 0 ? latestDur / bestDur : 0.0; + worstDimension = cpuRatio >= durRatio ? 1 : 2; // 1 = cpu, 2 = duration + } + offenderCount++; + } + + if (offenderCount == 0) return; + + facts.Add(new Fact + { + Source = "queries", + Key = "PLAN_REGRESSION", + Value = worstFactor, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["offender_count"] = offenderCount, + ["worst_regression_factor"] = worstFactor, + ["worst_query_id"] = worstQueryId, + ["latest_cpu_per_exec_us"] = worstLatestCpu, + ["best_cpu_per_exec_us"] = worstBestCpu, + ["regressed_dimension"] = worstDimension, + ["latest_is_forced"] = worstLatestForced, + ["force_failure_count"] = worstForceFailures + } + }); + } + catch { /* Table may not exist or have no data */ } + } + + public const string ProcedureStatsSql = @" +SELECT + COUNT(DISTINCT object_name) AS distinct_procs, + SUM(delta_execution_count) AS total_executions, + SUM(delta_worker_time) AS total_cpu_time_us, + SUM(delta_elapsed_time) AS total_elapsed_time_us, + SUM(delta_logical_reads) AS total_logical_reads +FROM procedure_stats +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3 +AND delta_execution_count > 0"; + + /// + /// Collects procedure stats: top procedure by delta CPU time in the period. + /// + private async Task CollectProcedureStatsFactsAsync(AnalysisContext context, List facts) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(ProcedureStatsSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + var distinctProcs = reader.IsDBNull(0) ? 0L : ToInt64(reader.GetValue(0)); + var totalExecs = reader.IsDBNull(1) ? 0L : ToInt64(reader.GetValue(1)); + var totalCpuUs = reader.IsDBNull(2) ? 0L : ToInt64(reader.GetValue(2)); + var totalElapsedUs = reader.IsDBNull(3) ? 0L : ToInt64(reader.GetValue(3)); + var totalReads = reader.IsDBNull(4) ? 0L : ToInt64(reader.GetValue(4)); + + if (totalExecs == 0) return; + + facts.Add(new Fact + { + Source = "queries", + Key = "PROCEDURE_STATS", + Value = totalCpuUs, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["distinct_procedures"] = distinctProcs, + ["total_executions"] = totalExecs, + ["total_cpu_time_us"] = totalCpuUs, + ["total_elapsed_time_us"] = totalElapsedUs, + ["total_logical_reads"] = totalReads, + ["avg_cpu_per_exec_us"] = totalExecs > 0 ? (double)totalCpuUs / totalExecs : 0 + } + }); + } + catch { /* Table may not exist or have no data */ } + } + + public const string PlanAdvisorySql = @" +SELECT query_plan_xml +FROM query_stats +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3 +AND query_plan_xml IS NOT NULL +ORDER BY delta_worker_time DESC +LIMIT 10"; + + /// + /// WS4: plan-XML advisories. Parses the already-collected query plans of the top queries by + /// cost with the shared ShowPlanParser/PlanAnalyzer and emits two advise-only facts — + /// MISSING_INDEX (Value = distinct suggested indexes) and PLAN_WARNING (Value = actionable + /// warnings). The specifics ride in the finding drill-down (DrillDownCollector); Fact.Metadata + /// is numeric only. Mirrors the Lite/Dashboard fact collectors against query_stats.query_plan_xml. + /// + private async Task CollectPlanAdvisoryFactsAsync(AnalysisContext context, List facts) + { + try + { + var planXmls = new List(); + + /* PG port: Lite scopes the connection in a block to release its DuckDB read lock + before the CPU-only parse; the scoping is kept so the connection closes before + the parse, even though PG holds no lock. */ + await using (var connection = await _postgres.OpenConnectionAsync()) + { + using var command = new NpgsqlCommand(PlanAdvisorySql, connection); + command.Parameters.AddWithValue(context.ServerId); + command.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + command.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + if (!reader.IsDBNull(0)) + planXmls.Add(reader.GetString(0)); + } + } + + // Connection released above; parse off the store (CPU-only, no DB). + if (planXmls.Count == 0) + return; + + var summary = PlanAdvisoryAggregator.Summarize(planXmls); + + if (summary.MissingIndexCount > 0) + { + facts.Add(new Fact + { + Source = "queries", + Key = "MISSING_INDEX", + Value = summary.MissingIndexCount, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["index_count"] = summary.MissingIndexCount, + ["max_impact"] = summary.MaxImpact + } + }); + } + + if (summary.WarningCount > 0) + { + facts.Add(new Fact + { + Source = "queries", + Key = "PLAN_WARNING", + Value = summary.WarningCount, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["warning_count"] = summary.WarningCount, + ["critical_count"] = summary.CriticalCount + } + }); + } + } + catch + { + // query_stats / plan parse may be unavailable — skip, the advisory is best-effort. + } + } + +} diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Resources.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Resources.cs new file mode 100644 index 000000000..72acc1f13 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Resources.cs @@ -0,0 +1,498 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Analysis; + +namespace PerformanceMonitor.Darling.Analysis; + +public sealed partial class PgFactCollector +{ + public const string MemoryStatsSql = @" +SELECT total_physical_memory_mb, buffer_pool_mb, target_server_memory_mb +FROM memory_stats +WHERE server_id = $1 +AND collection_time <= $2 +ORDER BY collection_time DESC +LIMIT 1"; + + /// + /// Collects memory stats: total physical RAM, buffer pool size, target memory. + /// These facts enable edition-aware memory recommendations in the config audit. + /// + private async Task CollectMemoryFactsAsync(AnalysisContext context, List facts) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(MemoryStatsSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + var totalPhysical = reader.IsDBNull(0) ? 0.0 : Convert.ToDouble(reader.GetValue(0)); + var bufferPool = reader.IsDBNull(1) ? 0.0 : Convert.ToDouble(reader.GetValue(1)); + var targetMemory = reader.IsDBNull(2) ? 0.0 : Convert.ToDouble(reader.GetValue(2)); + + if (totalPhysical > 0) + facts.Add(new Fact { Source = "memory", Key = "MEMORY_TOTAL_PHYSICAL_MB", Value = totalPhysical, ServerId = context.ServerId }); + if (bufferPool > 0) + facts.Add(new Fact { Source = "memory", Key = "MEMORY_BUFFER_POOL_MB", Value = bufferPool, ServerId = context.ServerId }); + if (targetMemory > 0) + facts.Add(new Fact { Source = "memory", Key = "MEMORY_TARGET_MB", Value = targetMemory, ServerId = context.ServerId }); + } + catch { /* Table may not exist or have no data */ } + } + + public const string RunnableTaskStatsSql = @" +SELECT total_runnable_tasks_count, runnable_tasks_warning +FROM cpu_scheduler_stats +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3 +ORDER BY collection_time DESC +LIMIT 1"; + + /// + /// Collects the runnable-task-queue pressure signal from the EXISTING cpu_scheduler_stats snapshot + /// (its total_runnable_tasks_count = SUM(runnable_tasks_count) and its runnable_tasks_warning = + /// SUM(runnable_tasks_count) >= cpu_count heuristic) as one RUNNABLE_TASKS context fact (Source + /// "cpu" — carried into the FactScorer amplifier lookup but never scored/rooted, like every + /// Source="cpu" key besides CPU_SQL_PERCENT/CPU_SPIKE). The THREADPOOL runnable-queue amplifier + /// reads the warning flag to confirm real scheduler CPU pressure behind a thread exhaustion. No new + /// collector — reuses the data cpu_scheduler_stats already stores (not collected on Azure SQL DB, + /// where the fact is simply absent and the amplifier no-ops). The read is window-bounded to + /// [TimeRangeStart, TimeRangeEnd] (a lower bound, not just <= end, matching CpuUtilizationSql) so a + /// lapsed collection surfaces no stale snapshot from outside the window — the fact is then absent + /// and the amplifier no-ops. Ported byte-identically from Lite's + /// DuckDbFactCollector.CollectRunnableTaskFactsAsync. + /// + private async Task CollectRunnableTaskFactsAsync(AnalysisContext context, List facts) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(RunnableTaskStatsSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + var totalRunnable = reader.IsDBNull(0) ? 0.0 : Convert.ToDouble(reader.GetValue(0)); + var runnableWarning = reader.IsDBNull(1) ? 0.0 : Convert.ToDouble(reader.GetValue(1)); + + facts.Add(new Fact + { + Source = "cpu", + Key = "RUNNABLE_TASKS", + Value = totalRunnable, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["total_runnable_tasks"] = totalRunnable, + ["runnable_tasks_warning"] = runnableWarning + } + }); + } + catch { /* Table may not exist or have no data */ } + } + + public const string MemoryGrantSql = @" +SELECT + MAX(waiter_count) AS max_waiters, + AVG(waiter_count) AS avg_waiters, + MAX(grantee_count) AS max_grantees, + SUM(timeout_error_count_delta) AS total_timeout_errors, + SUM(forced_grant_count_delta) AS total_forced_grants +FROM v_memory_grant_stats +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3"; + + /// + /// Collects memory grant facts from the resource semaphore view. + /// Detects grant waiters (sessions waiting for memory) and grant pressure. + /// + private async Task CollectMemoryGrantFactsAsync(AnalysisContext context, List facts) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(MemoryGrantSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + var maxWaiters = reader.IsDBNull(0) ? 0L : ToInt64(reader.GetValue(0)); + var avgWaiters = reader.IsDBNull(1) ? 0.0 : Convert.ToDouble(reader.GetValue(1)); + var maxGrantees = reader.IsDBNull(2) ? 0L : ToInt64(reader.GetValue(2)); + var totalTimeouts = reader.IsDBNull(3) ? 0L : ToInt64(reader.GetValue(3)); + var totalForcedGrants = reader.IsDBNull(4) ? 0L : ToInt64(reader.GetValue(4)); + + // Only create a fact if there's evidence of grant pressure + if (maxWaiters <= 0 && totalTimeouts <= 0 && totalForcedGrants <= 0) return; + + facts.Add(new Fact + { + Source = "memory", + Key = "MEMORY_GRANT_PENDING", + Value = maxWaiters, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["max_waiters"] = maxWaiters, + ["avg_waiters"] = avgWaiters, + ["max_grantees"] = maxGrantees, + ["total_timeout_errors"] = totalTimeouts, + ["total_forced_grants"] = totalForcedGrants + } + }); + } + catch { /* Table may not exist or have no data */ } + } + + public const string MemoryClerkSql = @" +WITH latest AS ( + SELECT clerk_type, memory_mb, + ROW_NUMBER() OVER (PARTITION BY clerk_type ORDER BY collection_time DESC) AS rn + FROM memory_clerks + WHERE server_id = $1 + AND collection_time <= $2 +) +SELECT clerk_type, memory_mb +FROM latest WHERE rn = 1 AND memory_mb > 0 +ORDER BY memory_mb DESC +LIMIT 10"; + + /// + /// Collects top memory clerks by size. Context for understanding where memory is allocated. + /// + private async Task CollectMemoryClerkFactsAsync(AnalysisContext context, List facts) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(MemoryClerkSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var reader = await cmd.ExecuteReaderAsync(); + var metadata = new Dictionary(); + var totalMb = 0.0; + var clerkCount = 0; + + while (await reader.ReadAsync()) + { + var clerkType = reader.GetString(0); + var memoryMb = Convert.ToDouble(reader.GetValue(1)); + metadata[clerkType] = memoryMb; + totalMb += memoryMb; + clerkCount++; + } + + if (clerkCount == 0) return; + + metadata["total_top_clerks_mb"] = totalMb; + metadata["clerk_count"] = clerkCount; + + facts.Add(new Fact + { + Source = "memory", + Key = "MEMORY_CLERKS", + Value = totalMb, + ServerId = context.ServerId, + Metadata = metadata + }); + } + catch { /* Table may not exist or have no data */ } + } + + public const string CpuUtilizationSql = @" +SELECT + AVG(sqlserver_cpu_utilization) AS avg_sql_cpu, + MAX(sqlserver_cpu_utilization) AS max_sql_cpu, + AVG(other_process_cpu_utilization) AS avg_other_cpu, + MAX(other_process_cpu_utilization) AS max_other_cpu, + COUNT(*) AS sample_count +FROM v_cpu_utilization_stats +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3"; + + /// + /// Collects CPU utilization: average and max SQL Server CPU % over the period. + /// Value is average SQL CPU %. Corroborates SOS_SCHEDULER_YIELD. + /// + private async Task CollectCpuUtilizationFactsAsync(AnalysisContext context, List facts) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(CpuUtilizationSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + var avgSqlCpu = reader.IsDBNull(0) ? 0.0 : Convert.ToDouble(reader.GetValue(0)); + var maxSqlCpu = reader.IsDBNull(1) ? 0.0 : Convert.ToDouble(reader.GetValue(1)); + var avgOtherCpu = reader.IsDBNull(2) ? 0.0 : Convert.ToDouble(reader.GetValue(2)); + var maxOtherCpu = reader.IsDBNull(3) ? 0.0 : Convert.ToDouble(reader.GetValue(3)); + var sampleCount = reader.IsDBNull(4) ? 0L : ToInt64(reader.GetValue(4)); + + if (sampleCount == 0) return; + + var cpuMetadata = new Dictionary + { + ["avg_sql_cpu"] = avgSqlCpu, + ["max_sql_cpu"] = maxSqlCpu, + ["avg_other_cpu"] = avgOtherCpu, + ["max_other_cpu"] = maxOtherCpu, + ["avg_total_cpu"] = avgSqlCpu + avgOtherCpu, + ["sample_count"] = sampleCount + }; + + facts.Add(new Fact + { + Source = "cpu", + Key = "CPU_SQL_PERCENT", + Value = avgSqlCpu, + ServerId = context.ServerId, + Metadata = cpuMetadata + }); + + // Emit a CPU_SPIKE fact when max is high and significantly above average. + // This catches bursty CPU events that average-based scoring misses entirely. + // Requires max >= 80% AND at least 3x the average (or avg < 20% with max >= 80%). + if (maxSqlCpu >= 80 && (avgSqlCpu < 20 || maxSqlCpu / Math.Max(avgSqlCpu, 1) >= 3)) + { + facts.Add(new Fact + { + Source = "cpu", + Key = "CPU_SPIKE", + Value = maxSqlCpu, + ServerId = context.ServerId, + Metadata = cpuMetadata + }); + } + } + catch { /* Table may not exist or have no data */ } + } + + public const string PerfmonSql = @" +WITH latest AS ( + SELECT counter_name, cntr_value, delta_cntr_value, + ROW_NUMBER() OVER (PARTITION BY counter_name ORDER BY collection_time DESC) AS rn + FROM perfmon_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND counter_name IN ('Batch Requests/sec', 'SQL Compilations/sec', 'SQL Re-Compilations/sec') +) +SELECT counter_name, cntr_value, delta_cntr_value +FROM latest WHERE rn = 1"; + + /// + /// Collects key perfmon throughput counters: Batch Requests/sec, compilations, recompilations. + /// Unscored context that distinguishes a busy server from a sick one (used by the AI surfaces). + /// + private async Task CollectPerfmonFactsAsync(AnalysisContext context, List facts) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(PerfmonSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var counterName = reader.GetString(0); + var cntrValue = reader.IsDBNull(1) ? 0L : ToInt64(reader.GetValue(1)); + var deltaValue = reader.IsDBNull(2) ? 0L : ToInt64(reader.GetValue(2)); + + var (factKey, source) = counterName switch + { + "Batch Requests/sec" => ("PERFMON_BATCH_REQ_SEC", "perfmon"), + "SQL Compilations/sec" => ("PERFMON_COMPILATIONS_SEC", "perfmon"), + "SQL Re-Compilations/sec" => ("PERFMON_RECOMPILATIONS_SEC", "perfmon"), + _ => (null, null) + }; + + if (factKey == null) continue; + + // All remaining counters are per-second rates — use the delta. + var value = (double)deltaValue; + + facts.Add(new Fact + { + Source = source!, + Key = factKey, + Value = value, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["cntr_value"] = cntrValue, + ["delta_cntr_value"] = deltaValue + } + }); + } + } + catch { /* Table may not exist or have no data */ } + } + + public const string PlanCacheStatsSql = @" +WITH ranked AS ( + SELECT total_plans, single_use_plans, total_size_mb, single_use_size_mb, + DENSE_RANK() OVER (ORDER BY collection_time DESC) AS rnk + FROM plan_cache_stats + WHERE server_id = $1 + AND collection_time <= $2 +) +SELECT + COALESCE(SUM(total_plans), 0) AS total_plans, + COALESCE(SUM(single_use_plans), 0) AS single_use_plans, + COALESCE(SUM(total_size_mb), 0) AS total_size_mb, + COALESCE(SUM(single_use_size_mb), 0) AS single_use_size_mb +FROM ranked +WHERE rnk = 1"; + + /// + /// Collects the plan-cache single-use bloat signal from the LATEST plan_cache_stats snapshot. Mirrors + /// the Dashboard's report.plan_cache_bloat (install/47_create_reporting_views.sql:1456-1496): SUM the + /// plan/size counts over the newest collection_time, derive single_use_percent. Point-in-time read + /// (no lower bound, just <= TimeRangeEnd) like CollectMemoryFactsAsync; DENSE_RANK() selects every + /// row of the newest collection (QUALIFY is DuckDB-only and banned in the shared PG dialect). The + /// FactScorer applies the > 50/30/20% tiers AND the single_use_size_mb >= 100 noise guard. + /// Ported byte-identically from Lite's DuckDbFactCollector.CollectPlanCacheFactsAsync. + /// + private async Task CollectPlanCacheFactsAsync(AnalysisContext context, List facts) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(PlanCacheStatsSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + // SUM(integer) is bigint in Postgres — read every aggregate through ToInt64 (the Lite-parity + // shape), then widen the MB sizes to double for the metadata. + var totalPlans = reader.IsDBNull(0) ? 0L : ToInt64(reader.GetValue(0)); + var singleUsePlans = reader.IsDBNull(1) ? 0L : ToInt64(reader.GetValue(1)); + var totalSizeMb = reader.IsDBNull(2) ? 0.0 : (double)ToInt64(reader.GetValue(2)); + var singleUseSizeMb = reader.IsDBNull(3) ? 0.0 : (double)ToInt64(reader.GetValue(3)); + + // No cache collected (or an empty cache) — nothing to classify. + if (totalPlans <= 0) return; + + var singleUsePercent = singleUsePlans * 100.0 / totalPlans; + + facts.Add(new Fact + { + Source = "memory", + Key = "PLAN_CACHE_BLOAT", + Value = singleUsePercent, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["single_use_plans"] = singleUsePlans, + ["total_plans"] = totalPlans, + ["single_use_size_mb"] = singleUseSizeMb, + ["total_size_mb"] = totalSizeMb, + ["single_use_percent"] = singleUsePercent + } + }); + } + catch { /* Table may not exist or have no data */ } + } + + public const string MemoryPressureEventsSql = @" +SELECT + SUM(CASE WHEN memory_indicators_process >= 2 OR memory_indicators_system >= 2 THEN 1 ELSE 0 END) AS pressure_event_count, + MAX(memory_indicators_process) AS max_process, + MAX(memory_indicators_system) AS max_system +FROM memory_pressure_events +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3"; + + /// + /// Collects ring-buffer physical-memory-pressure notifications from memory_pressure_events. Mirrors + /// the Dashboard's report.memory_pressure_events (install/47_create_reporting_views.sql:220-238), + /// whose severity keys off memory_indicators_process / memory_indicators_system (>= 3 HIGH, >= 2 + /// MEDIUM, else LOW). Window-bounded to [TimeRangeStart, TimeRangeEnd]. NOISE GATE: emit only when a + /// genuine MEDIUM+ indicator occurred (max >= 2) — the steady 0-1 samples on every healthy server + /// stay silent (install/47:232-233). Value = the max overall indicator; event_count = the MEDIUM+ + /// sample count. Ported byte-identically from Lite's + /// DuckDbFactCollector.CollectMemoryPressureEventFactsAsync. + /// + private async Task CollectMemoryPressureEventFactsAsync(AnalysisContext context, List facts) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(MemoryPressureEventsSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + var pressureEventCount = reader.IsDBNull(0) ? 0L : ToInt64(reader.GetValue(0)); + var maxProcess = reader.IsDBNull(1) ? 0.0 : Convert.ToDouble(reader.GetValue(1)); + var maxSystem = reader.IsDBNull(2) ? 0.0 : Convert.ToDouble(reader.GetValue(2)); + var maxIndicator = Math.Max(maxProcess, maxSystem); + + // Steady ring buffer (indicators 0-1) is the healthy norm on every server — only a genuine + // MEDIUM+ pressure indicator is worth a fact (install/47:232-233 MEDIUM floor). + if (maxIndicator < 2) return; + + facts.Add(new Fact + { + Source = "memory", + Key = "MEMORY_PRESSURE_EVENTS", + Value = maxIndicator, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["event_count"] = pressureEventCount, + ["max_process_indicator"] = maxProcess, + ["max_system_indicator"] = maxSystem + } + }); + } + catch { /* Table may not exist or have no data */ } + } + +} diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Storage.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Storage.cs new file mode 100644 index 000000000..4102104ef --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Storage.cs @@ -0,0 +1,311 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Analysis; + +namespace PerformanceMonitor.Darling.Analysis; + +public sealed partial class PgFactCollector +{ + public const string DatabaseSizeSql = @" +WITH latest AS ( + SELECT database_name, file_name, size_mb, + ROW_NUMBER() OVER (PARTITION BY database_name, file_name ORDER BY collection_time DESC) AS rn + FROM file_io_stats + WHERE server_id = $1 + AND collection_time <= $2 + AND size_mb > 0 +) +SELECT SUM(size_mb) AS total_size_mb +FROM latest +WHERE rn = 1"; + + /// + /// Collects total database data size from file_io_stats. + /// Sums the latest size_mb across all database files for the server. + /// + private async Task CollectDatabaseSizeFactAsync(AnalysisContext context, List facts) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(DatabaseSizeSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + var totalSize = reader.IsDBNull(0) ? 0.0 : Convert.ToDouble(reader.GetValue(0)); + if (totalSize > 0) + facts.Add(new Fact { Source = "config", Key = "DATABASE_TOTAL_SIZE_MB", Value = totalSize, ServerId = context.ServerId }); + } + catch { /* Table may not exist or have no data */ } + } + + public const string IoLatencySql = @" +SELECT + SUM(delta_stall_read_ms) AS total_stall_read_ms, + SUM(delta_reads) AS total_reads, + SUM(delta_stall_write_ms) AS total_stall_write_ms, + SUM(delta_writes) AS total_writes +FROM v_file_io_stats +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3 +AND (delta_reads > 0 OR delta_writes > 0)"; + + /// + /// Collects I/O latency from file_io_stats delta columns. + /// Computes average read and write latency across all database files. + /// + private async Task CollectIoLatencyFactsAsync(AnalysisContext context, List facts) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(IoLatencySql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + var totalStallReadMs = reader.IsDBNull(0) ? 0L : ToInt64(reader.GetValue(0)); + var totalReads = reader.IsDBNull(1) ? 0L : ToInt64(reader.GetValue(1)); + var totalStallWriteMs = reader.IsDBNull(2) ? 0L : ToInt64(reader.GetValue(2)); + var totalWrites = reader.IsDBNull(3) ? 0L : ToInt64(reader.GetValue(3)); + + if (totalReads > 0) + { + var avgReadLatency = (double)totalStallReadMs / totalReads; + facts.Add(new Fact + { + Source = "io", + Key = "IO_READ_LATENCY_MS", + Value = avgReadLatency, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["avg_read_latency_ms"] = avgReadLatency, + ["total_stall_read_ms"] = totalStallReadMs, + ["total_reads"] = totalReads + } + }); + } + + if (totalWrites > 0) + { + var avgWriteLatency = (double)totalStallWriteMs / totalWrites; + facts.Add(new Fact + { + Source = "io", + Key = "IO_WRITE_LATENCY_MS", + Value = avgWriteLatency, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["avg_write_latency_ms"] = avgWriteLatency, + ["total_stall_write_ms"] = totalStallWriteMs, + ["total_writes"] = totalWrites + } + }); + } + } + catch { /* Table may not exist or have no data */ } + } + + public const string TempDbSql = @" +SELECT + MAX(total_reserved_mb) AS max_total_reserved_mb, + MAX(user_object_reserved_mb) AS max_user_object_mb, + MAX(internal_object_reserved_mb) AS max_internal_object_mb, + MAX(version_store_reserved_mb) AS max_version_store_mb, + MIN(unallocated_mb) AS min_unallocated_mb, + AVG(total_reserved_mb) AS avg_total_reserved_mb +FROM v_tempdb_stats +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3"; + + /// + /// Collects TempDB usage facts: max usage, version store size, and unallocated space. + /// Value is max total_reserved_mb over the period. + /// + private async Task CollectTempDbFactsAsync(AnalysisContext context, List facts) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(TempDbSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + var maxReserved = reader.IsDBNull(0) ? 0.0 : Convert.ToDouble(reader.GetValue(0)); + var maxUserObj = reader.IsDBNull(1) ? 0.0 : Convert.ToDouble(reader.GetValue(1)); + var maxInternalObj = reader.IsDBNull(2) ? 0.0 : Convert.ToDouble(reader.GetValue(2)); + var maxVersionStore = reader.IsDBNull(3) ? 0.0 : Convert.ToDouble(reader.GetValue(3)); + var minUnallocated = reader.IsDBNull(4) ? 0.0 : Convert.ToDouble(reader.GetValue(4)); + var avgReserved = reader.IsDBNull(5) ? 0.0 : Convert.ToDouble(reader.GetValue(5)); + + if (maxReserved <= 0) return; + + // TempDB usage as fraction of total space (reserved + unallocated) + var totalSpace = maxReserved + minUnallocated; + var usageFraction = totalSpace > 0 ? maxReserved / totalSpace : 0; + + facts.Add(new Fact + { + Source = "tempdb", + Key = "TEMPDB_USAGE", + Value = usageFraction, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["max_reserved_mb"] = maxReserved, + ["avg_reserved_mb"] = avgReserved, + ["max_user_object_mb"] = maxUserObj, + ["max_internal_object_mb"] = maxInternalObj, + ["max_version_store_mb"] = maxVersionStore, + ["min_unallocated_mb"] = minUnallocated, + ["usage_fraction"] = usageFraction + } + }); + } + catch { /* Table may not exist or have no data */ } + } + + public const string FileAutogrowthSql = @" +WITH latest AS ( + SELECT database_name, file_id, total_size_mb, is_percent_growth, + ROW_NUMBER() OVER (PARTITION BY database_name, file_id ORDER BY collection_time DESC) AS rn + FROM database_size_stats + WHERE server_id = $1 +) +SELECT + COUNT(*) AS file_count, + COUNT(DISTINCT database_name) AS database_count +FROM latest +WHERE rn = 1 +AND is_percent_growth = true +AND total_size_mb >= 10240 +AND database_name NOT IN ('master', 'msdb', 'model', 'tempdb')"; + + /// + /// Collects the percent-autogrowth-on-large-files config fact (WS3): data/log files set + /// to grow in PERCENTAGE steps that are also large (>= 10 GB), where a single growth is a + /// huge, stalling allocation. Reads the latest snapshot per file from database_size_stats, + /// excludes system databases, and emits ONE aggregate FILE_AUTOGROWTH_PERCENT fact carrying + /// the offending-file/database counts (the per-file detail + copy-paste fix is attached + /// later by the drill-down collector). + /// + private async Task CollectFileAutogrowthFactsAsync(AnalysisContext context, List facts) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(FileAutogrowthSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + var fileCount = reader.IsDBNull(0) ? 0L : ToInt64(reader.GetValue(0)); + if (fileCount == 0) return; + + var databaseCount = reader.IsDBNull(1) ? 0L : ToInt64(reader.GetValue(1)); + + facts.Add(new Fact + { + Source = "config", + Key = "FILE_AUTOGROWTH_PERCENT", + Value = fileCount, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["file_count"] = fileCount, + ["database_count"] = databaseCount + } + }); + } + catch { /* Table may not exist or have no data */ } + } + + public const string DiskSpaceSql = @" +WITH latest AS ( + SELECT volume_mount_point, volume_total_mb, volume_free_mb, + ROW_NUMBER() OVER (PARTITION BY volume_mount_point ORDER BY collection_time DESC) AS rn + FROM database_size_stats + WHERE server_id = $1 + AND collection_time <= $2 + AND volume_total_mb > 0 +) +SELECT + MIN(volume_free_mb * 1.0 / volume_total_mb) AS min_free_pct, + MIN(volume_free_mb) AS min_free_mb, + COUNT(DISTINCT volume_mount_point) AS volume_count, + SUM(volume_total_mb) AS total_volume_mb, + SUM(volume_free_mb) AS total_free_mb +FROM latest WHERE rn = 1"; + + /// + /// Collects disk space facts from database_size_stats: volume free space, file sizes. + /// + private async Task CollectDiskSpaceFactsAsync(AnalysisContext context, List facts) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var cmd = new NpgsqlCommand(DiskSpaceSql, connection); + cmd.Parameters.AddWithValue(context.ServerId); + cmd.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + var minFreePct = reader.IsDBNull(0) ? 1.0 : Convert.ToDouble(reader.GetValue(0)); + var minFreeMb = reader.IsDBNull(1) ? 0.0 : Convert.ToDouble(reader.GetValue(1)); + var volumeCount = reader.IsDBNull(2) ? 0L : ToInt64(reader.GetValue(2)); + var totalVolumeMb = reader.IsDBNull(3) ? 0.0 : Convert.ToDouble(reader.GetValue(3)); + var totalFreeMb = reader.IsDBNull(4) ? 0.0 : Convert.ToDouble(reader.GetValue(4)); + + if (volumeCount == 0) return; + + facts.Add(new Fact + { + Source = "disk", + Key = "DISK_SPACE", + Value = minFreePct, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["min_free_pct"] = minFreePct, + ["min_free_mb"] = minFreeMb, + ["volume_count"] = volumeCount, + ["total_volume_mb"] = totalVolumeMb, + ["total_free_mb"] = totalFreeMb + } + }); + } + catch { /* Table may not exist or have no data */ } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Waits.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Waits.cs new file mode 100644 index 000000000..22c2fd712 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Waits.cs @@ -0,0 +1,265 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Analysis; + +namespace PerformanceMonitor.Darling.Analysis; + +public sealed partial class PgFactCollector +{ + public const string WaitStatsSql = @" +SELECT + wait_type, + SUM(delta_waiting_tasks) AS total_waiting_tasks, + SUM(delta_wait_time_ms) AS total_wait_time_ms, + SUM(delta_signal_wait_time_ms) AS total_signal_wait_time_ms +FROM v_wait_stats +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3 +AND delta_wait_time_ms > 0 +GROUP BY wait_type +ORDER BY SUM(delta_wait_time_ms) DESC"; + + /// + /// Collects wait stats facts — one Fact per significant wait type. + /// Value is wait_time_ms / period_duration_ms (fraction of examined period). + /// + private async Task CollectWaitStatsFactsAsync(AnalysisContext context, List facts) + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var command = new NpgsqlCommand(WaitStatsSql, connection); + command.Parameters.AddWithValue(context.ServerId); + command.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + command.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var waitType = reader.GetString(0); + var waitingTasks = reader.IsDBNull(1) ? 0L : ToInt64(reader.GetValue(1)); + var waitTimeMs = reader.IsDBNull(2) ? 0L : ToInt64(reader.GetValue(2)); + var signalWaitTimeMs = reader.IsDBNull(3) ? 0L : ToInt64(reader.GetValue(3)); + + if (waitTimeMs <= 0) continue; + + var fractionOfPeriod = waitTimeMs / context.PeriodDurationMs; + var avgMsPerWait = waitingTasks > 0 ? (double)waitTimeMs / waitingTasks : 0; + + facts.Add(new Fact + { + Source = "waits", + Key = waitType, + Value = fractionOfPeriod, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["wait_time_ms"] = waitTimeMs, + ["waiting_tasks_count"] = waitingTasks, + ["signal_wait_time_ms"] = signalWaitTimeMs, + ["resource_wait_time_ms"] = waitTimeMs - signalWaitTimeMs, + ["avg_ms_per_wait"] = avgMsPerWait, + ["period_duration_ms"] = context.PeriodDurationMs + } + }); + } + } + + public const string BlockingSql = @" +SELECT + COUNT(*) AS event_count, + AVG(wait_time_ms) AS avg_wait_time_ms, + MAX(wait_time_ms) AS max_wait_time_ms, + COUNT(DISTINCT blocking_spid) AS distinct_head_blockers, + COUNT(CASE WHEN blocking_status = 'sleeping' THEN 1 END) AS sleeping_blocker_count +FROM blocked_process_reports +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3"; + + /// + /// Collects blocking facts from blocked_process_reports. + /// Produces a single BLOCKING_EVENTS fact with event count, rate, and details. + /// Value is events per hour for threshold comparison. + /// + private async Task CollectBlockingFactsAsync(AnalysisContext context, List facts) + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var command = new NpgsqlCommand(BlockingSql, connection); + command.Parameters.AddWithValue(context.ServerId); + command.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + command.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var reader = await command.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + var eventCount = reader.IsDBNull(0) ? 0L : ToInt64(reader.GetValue(0)); + if (eventCount <= 0) return; + + var avgWaitTimeMs = reader.IsDBNull(1) ? 0.0 : Convert.ToDouble(reader.GetValue(1)); + var maxWaitTimeMs = reader.IsDBNull(2) ? 0L : ToInt64(reader.GetValue(2)); + var distinctHeadBlockers = reader.IsDBNull(3) ? 0L : ToInt64(reader.GetValue(3)); + var sleepingBlockerCount = reader.IsDBNull(4) ? 0L : ToInt64(reader.GetValue(4)); + + var periodHours = context.PeriodDurationMs / 3_600_000.0; + var eventsPerHour = periodHours > 0 ? eventCount / periodHours : 0; + + facts.Add(new Fact + { + Source = "blocking", + Key = "BLOCKING_EVENTS", + Value = eventsPerHour, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["event_count"] = eventCount, + ["events_per_hour"] = eventsPerHour, + ["avg_wait_time_ms"] = avgWaitTimeMs, + ["max_wait_time_ms"] = maxWaitTimeMs, + ["distinct_head_blockers"] = distinctHeadBlockers, + ["sleeping_blocker_count"] = sleepingBlockerCount, + ["period_hours"] = periodHours + } + }); + } + + public const string DeadlocksSql = @" +SELECT COUNT(*) AS deadlock_count +FROM deadlocks +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3"; + + /// + /// Collects deadlock facts from the deadlocks table. + /// Produces a single DEADLOCKS fact with count and rate. + /// Value is deadlocks per hour for threshold comparison. + /// + private async Task CollectDeadlockFactsAsync(AnalysisContext context, List facts) + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var command = new NpgsqlCommand(DeadlocksSql, connection); + command.Parameters.AddWithValue(context.ServerId); + command.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + command.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + using var reader = await command.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + var deadlockCount = reader.IsDBNull(0) ? 0L : ToInt64(reader.GetValue(0)); + if (deadlockCount <= 0) return; + + var periodHours = context.PeriodDurationMs / 3_600_000.0; + var deadlocksPerHour = periodHours > 0 ? deadlockCount / periodHours : 0; + + facts.Add(new Fact + { + Source = "blocking", + Key = "DEADLOCKS", + Value = deadlocksPerHour, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["deadlock_count"] = deadlockCount, + ["deadlocks_per_hour"] = deadlocksPerHour, + ["period_hours"] = periodHours + } + }); + } + + // SpidFilter keeps this in lockstep with the drill-down + viewer fetch on the apex + // (Lite maps a missing blocker to spid 0 — see PgBlockingPairRowQuery). + public const string BlockingChainSql = $@" +SELECT + {PgBlockingPairRowQuery.LeadingColumns}, + blocked_sql_text, + blocking_sql_text, + {PgBlockingPairRowQuery.IdentityColumns}, + contentious_object, + {PgBlockingPairRowQuery.TrailingIdentityColumns} +FROM v_blocked_process_reports +WHERE server_id = $1 +AND event_time >= $2 +AND event_time <= $3 +{PgBlockingPairRowQuery.SpidFilter} +ORDER BY event_time DESC +LIMIT 5000"; + + /// + /// Reconstructs blocking chains from blocked_process_reports (per-pair rows) and emits + /// one aggregate BLOCKING_CHAIN fact describing the worst chain — apex head blocker, + /// depth, and transitive victim count — structure the BLOCKING_EVENTS rate is blind to. + /// + private async Task CollectBlockingChainFactsAsync(AnalysisContext context, List facts) + { + const int maxPairs = 5000; + const int maxDepth = 50; + const int stepBudget = 100_000; + + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + using var command = new NpgsqlCommand(BlockingChainSql, connection); + command.Parameters.AddWithValue(context.ServerId); + command.Parameters.AddWithValue(AsNaive(context.TimeRangeStart)); + command.Parameters.AddWithValue(AsNaive(context.TimeRangeEnd)); + + var rows = new List(); + using (var reader = await command.ExecuteReaderAsync()) + { + while (await reader.ReadAsync()) + rows.Add(PgBlockingPairRowQuery.Read(reader)); + } + + // Always-on DMV blocking snapshot fallback (works when the blocked-process-report XE is empty, + // e.g. AWS RDS). Merge BEFORE the empty check so DMV-only blocking still produces facts. + await PgBlockingPairRowQuery.AppendDmvSnapshotRowsAsync( + connection.CreateCommand, rows, context.ServerId, context.TimeRangeStart, context.TimeRangeEnd); + + if (rows.Count == 0) return; + + var reconstruction = BlockingChainReconstructor.Reconstruct(rows, maxDepth, maxPairs, stepBudget, scopeByMonitorLoop: false); + if (reconstruction.Chains.Count == 0) return; + + var worst = reconstruction.Chains[0]; + + facts.Add(new Fact + { + Source = "blocking", + Key = "BLOCKING_CHAIN", + Value = worst.Depth, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["worst_chain_depth"] = worst.Depth, + ["worst_chain_victim_count"] = worst.VictimCount, + ["worst_apex_spid"] = worst.ApexSpid, + ["worst_apex_sleeping"] = worst.ApexSleeping ? 1 : 0, + ["worst_chain_max_wait_ms"] = worst.MaxWaitMs, + ["total_reconstructed_chains"] = reconstruction.Chains.Count, + ["deepest_chain_overall"] = reconstruction.Chains.Max(c => c.Depth), + ["max_victim_count_overall"] = reconstruction.Chains.Max(c => c.VictimCount), + ["depth_capped"] = reconstruction.DepthCapped ? 1 : 0, + ["traversal_truncated"] = reconstruction.TraversalTruncated ? 1 : 0, + ["cycle_detected"] = reconstruction.CycleDetected ? 1 : 0 + } + }); + } + catch { /* Table may not exist or have no data */ } + } + +} diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.cs new file mode 100644 index 000000000..6a65b1b42 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.cs @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Analysis; + +namespace PerformanceMonitor.Darling.Analysis; + +/// +/// Collects facts from Darling's Postgres store for the analysis engine — Lite's +/// DuckDbFactCollector ported method-for-method (Phase-5 analysis slice AN2a): the same +/// 28 collect methods across the same seven partial files, the same fact keys / values / +/// metadata shapes, the same emission order, and the same per-method swallow-everything error +/// posture (Lite's collector logs nothing in those catches; neither does this port — a missing +/// table or empty store degrades to "no facts", never an exception). Lite's analysis SQL was +/// deliberately written in the PG-shared dialect and the V4 passthrough v_<table> +/// views exist precisely so these queries run VERBATIM — every query string here is +/// byte-identical to Lite's. +/// +/// +/// The documented PG-port deviations, none of which touch a query's text: +/// +/// /* PG port: Lite wraps every read in _duckDb.AcquireReadLock() — +/// DuckDB is a single-writer embedded file and Lite serializes readers against the writer. +/// Postgres is MVCC; no read lock exists or is needed, so the lock line is dropped from every +/// method. */ +/// /* PG port: parameters bind positionally as $N exactly like Lite's +/// DuckDB SQL already did, via Npgsql's positional AddWithValue (the PgFindingStore +/// pattern); every is bound naive-UTC Kind-Unspecified — the +/// PgCollectorRowWriter discipline, because Npgsql 6+ maps Kind-Utc to timestamptz and +/// rejects it against the store's naive timestamp columns. AnalysisContext times are +/// used as-is (host-UTC window semantics — no offset math). */ +/// /* PG port: Lite's BigInteger-tolerant ToInt64 exists because +/// DuckDB hands wide aggregates back boxed as ; Npgsql +/// never does — Postgres returns numeric aggregates as , which +/// already handles — so the BigInteger branch is dropped +/// (see ). */ +/// /* PG port: the SQL moves from inline literals to public const +/// fields (text unchanged) so Darling.Tests can pin the dialect ungated — the +/// PgFindingStore/DarlingAlertReadAdapter convention. */ +/// +/// No query used NOW()/CURRENT_TIMESTAMP (every window bound is a parameter), no +/// QUALIFY appears (that lives in BaselineProvider, a different slice), and the one +/// any_value() use is standard SQL:2023, in Postgres since 16 (the product's minimum PG +/// is 17) — all pinned by PgFactCollectorTests. +/// +/// +public sealed partial class PgFactCollector : IFactCollector +{ + private readonly NpgsqlDataSource _postgres; + + public PgFactCollector(NpgsqlDataSource postgres) + { + _postgres = postgres ?? throw new ArgumentNullException(nameof(postgres)); + } + + public async Task> CollectFactsAsync(AnalysisContext context) + { + var facts = new List(); + + await CollectWaitStatsFactsAsync(context, facts); + FactCollectorHelpers.GroupGeneralLockWaits(facts, context); + FactCollectorHelpers.GroupParallelismWaits(facts, context); + await CollectBlockingFactsAsync(context, facts); + await CollectBlockingChainFactsAsync(context, facts); + await CollectDeadlockFactsAsync(context, facts); + await CollectServerConfigFactsAsync(context, facts); + await CollectMemoryFactsAsync(context, facts); + await CollectDatabaseSizeFactAsync(context, facts); + await CollectServerMetadataFactsAsync(context, facts); + await CollectCpuUtilizationFactsAsync(context, facts); + await CollectRunnableTaskFactsAsync(context, facts); + await CollectIoLatencyFactsAsync(context, facts); + await CollectTempDbFactsAsync(context, facts); + await CollectMemoryGrantFactsAsync(context, facts); + await CollectQueryStatsFactsAsync(context, facts); + await CollectParameterSensitivityFactsAsync(context, facts); + await CollectPlanRegressionFactsAsync(context, facts); + await CollectBadActorFactsAsync(context, facts); + await CollectPerfmonFactsAsync(context, facts); + await CollectMemoryClerkFactsAsync(context, facts); + await CollectPlanCacheFactsAsync(context, facts); + await CollectMemoryPressureEventFactsAsync(context, facts); + await CollectDatabaseConfigFactsAsync(context, facts); + await CollectFileAutogrowthFactsAsync(context, facts); + await CollectProcedureStatsFactsAsync(context, facts); + await CollectActiveQueryFactsAsync(context, facts); + await CollectRunningJobFactsAsync(context, facts); + await CollectSessionFactsAsync(context, facts); + await CollectTraceFlagFactsAsync(context, facts); + await CollectServerPropertiesFactsAsync(context, facts); + await CollectDiskSpaceFactsAsync(context, facts); + await CollectPlanAdvisoryFactsAsync(context, facts); + + return facts; + } + + /// + /// Every query this collector executes, for the ungated dialect/hygiene pins in + /// Darling.Tests (no QUALIFY, no bare NOW()/CURRENT_TIMESTAMP, no read_parquet, $N + /// positional parameters only, and every FROM/JOIN target resolves to a V4 passthrough + /// view or a V1/V2 table). + /// + public static IReadOnlyList AllSql { get; } = new[] + { + WaitStatsSql, + BlockingSql, + BlockingChainSql, + PgBlockingPairRowQuery.DmvSnapshotSql, + DeadlocksSql, + ServerConfigSql, + MemoryStatsSql, + DatabaseSizeSql, + ServerMetadataSql, + CpuUtilizationSql, + RunnableTaskStatsSql, + IoLatencySql, + TempDbSql, + MemoryGrantSql, + QueryStatsSql, + ParameterSensitivitySql, + PlanRegressionSql, + BadActorSql, + PerfmonSql, + MemoryClerkSql, + PlanCacheStatsSql, + MemoryPressureEventsSql, + DatabaseConfigSql, + FileAutogrowthSql, + ProcedureStatsSql, + ActiveQuerySql, + RunningJobsSql, + SessionStatsSql, + TraceFlagsSql, + ServerPropertiesSql, + DiskSpaceSql, + PlanAdvisorySql + }; + + // Single impl lives in PgBlockingPairRowQuery (shared with the pair-row reader); + // delegate so the check isn't duplicated — the Lite shape, minus DuckDB's BigInteger case. + private static long ToInt64(object value) => PgBlockingPairRowQuery.ToInt64(value); + + /// Kind-Unspecified for parameter binds — Npgsql 6+ rejects Kind-Utc against + /// timestamp (the PgCollectorRowWriter / PgFindingStore discipline). + private static DateTime AsNaive(DateTime value) => + DateTime.SpecifyKind(value, DateTimeKind.Unspecified); +} diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgFindingStore.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgFindingStore.cs new file mode 100644 index 000000000..ed99aa2a1 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Analysis/PgFindingStore.cs @@ -0,0 +1,560 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Npgsql; +using NpgsqlTypes; +using PerformanceMonitor.Analysis; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Notifications; + +namespace PerformanceMonitor.Darling.Analysis; + +/// +/// One row of the analysis_muted registry, as the viewer's Recommendations tab needs it +/// to mark a displayed finding muted and to offer the unmute (it needs the mute_id, which +/// deliberately omits). ServerId is nullable +/// because a mute can be global (server_id IS NULL). +/// +public sealed record MutedStory( + long MuteId, + int? ServerId, + string StoryPathHash, + string StoryPath, + DateTime MutedDate, + string? Reason); + +/// +/// Persists analysis findings to Darling's Postgres store (the V4 analysis_findings / +/// analysis_muted tables) and checks for muted story hashes — the write side of the +/// analysis pipeline, ported with the DASHBOARD twin's method surface and semantics +/// (SqlServerFindingStore): the two-phase → +/// write (recommendations rebuild D2/P2 — survivors are +/// enriched and get their RemediationAction BUILT between the phases, so the action is +/// persisted as remediation_action_json via the shared ), +/// plus Lite's single-pass (its AnalysisService's surface), +/// implemented as the composition of the two phases. +/// +/// +/// Postgres discipline (see PgCollectorRowWriter/PgAlertHistoryStore): every timestamp is +/// written naive-UTC Kind-Unspecified (Npgsql 6+ rejects Kind-Utc against timestamp) +/// and tagged Utc on read; "now" is always a bound parameter, never a bare now() +/// (timestamptz — it would compare in the PG server's time zone). Finding/mute ids come from +/// the shared instead of the twins' per-instance tick +/// counters. Schema is migration-owned (V4) — unlike the Dashboard twin there is no +/// EnsureTablesExist: the service migrates on startup, so the store carries no DDL. +/// +/// +/// +/// Deliberate reconciliations with the twins, both toward the richer behavior: +/// copies story.DatabaseName onto the finding +/// (Lite does; the Dashboard twin drops it and always persists NULL database_name), and +/// selects remediation_action_json too (the +/// Dashboard twin omits it from that one read and guards its reader by field count) — both +/// reads here share one uniform column list. +/// +/// +/// +/// Error discipline mirrors the Dashboard twin: no public method throws — writes log and +/// degrade, reads log and return empty. The mute-hash read fails OPEN (an unreadable mute +/// registry lets findings through rather than suppressing them), exactly like both twins. +/// +/// +public sealed class PgFindingStore +{ + private readonly NpgsqlDataSource _postgres; + private readonly ILogger? _logger; + + public PgFindingStore(NpgsqlDataSource postgres, ILogger? logger = null) + { + _postgres = postgres ?? throw new ArgumentNullException(nameof(postgres)); + _logger = logger; + } + + /* SQL is exposed const so Darling.Tests can pin the dialect ungated ($N positional + parameters, no bare now(), no N'' literals) — the DarlingAlertReadAdapter pattern. */ + + public const string InsertFindingSql = @" +INSERT INTO analysis_findings + (finding_id, analysis_time, server_id, server_name, database_name, + time_range_start, time_range_end, severity, confidence, category, + story_path, story_path_hash, story_text, + root_fact_key, root_fact_value, leaf_fact_key, leaf_fact_value, fact_count, + incident_id, remediation_action_json) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)"; + + public const string GetRecentFindingsSql = @" +SELECT finding_id, analysis_time, server_id, server_name, database_name, + time_range_start, time_range_end, severity, confidence, category, + story_path, story_path_hash, story_text, + root_fact_key, root_fact_value, leaf_fact_key, leaf_fact_value, fact_count, + incident_id, remediation_action_json +FROM analysis_findings +WHERE server_id = $1 +AND analysis_time >= $2 +ORDER BY analysis_time DESC, severity DESC +LIMIT $3"; + + public const string GetLatestFindingsSql = @" +SELECT finding_id, analysis_time, server_id, server_name, database_name, + time_range_start, time_range_end, severity, confidence, category, + story_path, story_path_hash, story_text, + root_fact_key, root_fact_value, leaf_fact_key, leaf_fact_value, fact_count, + incident_id, remediation_action_json +FROM analysis_findings +WHERE server_id = $1 +AND analysis_time = ( + SELECT MAX(analysis_time) FROM analysis_findings WHERE server_id = $1 +) +ORDER BY severity DESC"; + + /* server_id = 0 rows are legacy all-servers mutes written by the pre-fix MCP tool path + (no real server has id 0); honor them as global, alongside the canonical NULL. */ + public const string GetMutedHashesSql = @" +SELECT story_path_hash FROM analysis_muted +WHERE server_id = $1 OR server_id IS NULL OR server_id = 0"; + + /* Same per-server + global span as GetMutedHashesSql (NULL, plus legacy server_id = 0 all-servers + rows), but carries mute_id/story_path so the viewer can mark a finding muted and delete the exact + registry row on unmute. */ + public const string GetMutedStoriesSql = @" +SELECT mute_id, server_id, story_path_hash, story_path, muted_date, reason +FROM analysis_muted +WHERE server_id = $1 OR server_id IS NULL OR server_id = 0"; + + public const string MuteStorySql = @" +INSERT INTO analysis_muted (mute_id, server_id, story_path_hash, story_path, muted_date, reason) +VALUES ($1, $2, $3, $4, $5, $6)"; + + public const string UnmuteStorySql = "DELETE FROM analysis_muted WHERE mute_id = $1"; + + public const string CleanupOldFindingsSql = "DELETE FROM analysis_findings WHERE analysis_time < $1"; + + /// + /// Mute-filters the stories and materializes the SURVIVING findings, WITHOUT inserting + /// them (the Dashboard twin's D2/P2 reorder). The orchestrator then enriches these + /// survivors and builds + attaches each finding's RemediationAction before calling + /// , so the BUILT action is persisted on the row. + /// Absolution stories (severity 0) and muted hashes are dropped here and never enriched. + /// The context window arrives in the SERVER's local clock (Dashboard semantics — windowed + /// reads match the collectors' SYSDATETIME rows); ServerUtcOffset converts it back to UTC + /// for persistence. A Lite-shaped caller that leaves ServerUtcOffset at Zero (host-UTC + /// windows) gets an identity conversion, so both twins' callers are served. + /// + public async Task> FilterMutedFindingsAsync( + List stories, AnalysisContext context) + { + if (stories is null) + { + throw new ArgumentNullException(nameof(stories)); + } + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + var analysisTime = NaiveUtcNow(); + var survivors = new List(); + + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + var mutedHashes = await GetMutedHashesAsync(connection, context.ServerId); + + foreach (var story in stories) + { + /* Skip absolution stories (severity 0) — they confirm health, not problems. */ + if (story.Severity <= 0) + { + continue; + } + + if (mutedHashes.Contains(story.StoryPathHash)) + { + continue; + } + + survivors.Add(new AnalysisFinding + { + FindingId = CollectionIdGenerator.Next(), + AnalysisTime = analysisTime, + ServerId = context.ServerId, + ServerName = context.ServerName, + DatabaseName = story.DatabaseName, + TimeRangeStart = context.TimeRangeStart - context.ServerUtcOffset, + TimeRangeEnd = context.TimeRangeEnd - context.ServerUtcOffset, + Severity = story.Severity, + Confidence = story.Confidence, + Category = story.Category, + StoryPath = story.StoryPath, + StoryPathHash = story.StoryPathHash, + IncidentId = story.IncidentId, + StoryText = story.StoryText, + RootFactKey = story.RootFactKey, + RootFactValue = story.RootFactValue, + LeafFactKey = story.LeafFactKey, + LeafFactValue = story.LeafFactValue, + FactCount = story.FactCount, + /* Carried in-memory only; no analysis_findings column for it. */ + RootFactMetadata = story.RootFactMetadata + }); + } + } + catch (Exception ex) + { + _logger?.LogError("[PgFindingStore] FilterMutedFindingsAsync failed: {Message}", ex.Message); + } + + return survivors; + } + + /// + /// Inserts the (already mute-filtered, enriched, and action-attached) findings in one + /// batched pass on a single connection. Each row persists its BUILT + /// as remediation_action_json via the + /// shared , so a Darling finding's persisted action + /// round-trips byte-identically to a Dashboard one. Returns the same list for caller + /// convenience; the in-memory findings are unchanged. + /// + public async Task> InsertFindingsAsync( + List findings, AnalysisContext context) + { + if (findings is null) + { + throw new ArgumentNullException(nameof(findings)); + } + + if (findings.Count == 0) + { + return findings; + } + + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + + foreach (var finding in findings) + { + await InsertFindingAsync(connection, finding); + } + } + catch (Exception ex) + { + _logger?.LogError("[PgFindingStore] InsertFindingsAsync failed: {Message}", ex.Message); + } + + return findings; + } + + /// + /// Saves analysis stories as findings in one pass — Lite's FindingStore surface (its + /// AnalysisService calls this shape). Implemented as + /// + , so a + /// Lite-shaped pipeline port and a Dashboard-shaped one persist through the same code. + /// NOTE: a caller that wants remediation_action_json persisted must use the two-phase + /// shape and attach actions between the phases — this single pass inserts the findings + /// exactly as filtered (Remediation is null unless the caller pre-set it on the stories' + /// findings, which Lite's pipeline does not). + /// + public async Task> SaveFindingsAsync( + List stories, AnalysisContext context) + { + var survivors = await FilterMutedFindingsAsync(stories, context); + return await InsertFindingsAsync(survivors, context); + } + + /// + /// Returns the most recent findings for a server within the given time range, newest and + /// most severe first, including each finding's persisted remediation action. + /// + public async Task> GetRecentFindingsAsync( + int serverId, int hoursBack = 24, int limit = 100) + { + var findings = new List(); + + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + using var command = new NpgsqlCommand(GetRecentFindingsSql, connection); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(NaiveUtcNow().AddHours(-hoursBack)); + command.Parameters.AddWithValue(limit); + + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + findings.Add(ReadFinding(reader)); + } + } + catch (Exception ex) + { + _logger?.LogError("[PgFindingStore] GetRecentFindingsAsync failed: {Message}", ex.Message); + } + + return findings; + } + + /// + /// Returns the latest analysis run's findings for a server (most recent analysis_time), + /// most severe first. Unlike the Dashboard twin this read also returns + /// remediation_action_json — both reads share one column list and one reader. + /// + public async Task> GetLatestFindingsAsync(int serverId) + { + var findings = new List(); + + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + using var command = new NpgsqlCommand(GetLatestFindingsSql, connection); + command.Parameters.AddWithValue(serverId); + + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + findings.Add(ReadFinding(reader)); + } + } + catch (Exception ex) + { + _logger?.LogError("[PgFindingStore] GetLatestFindingsAsync failed: {Message}", ex.Message); + } + + return findings; + } + + /// + /// Mutes a story pattern so it won't appear in future analysis runs. + /// + public async Task MuteStoryAsync(int serverId, string storyPathHash, string storyPath, string? reason = null) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + using var command = new NpgsqlCommand(MuteStorySql, connection); + command.Parameters.AddWithValue(CollectionIdGenerator.Next()); + // serverId 0 is the MCP "mute across all servers" sentinel; persist it as NULL, the + // canonical global marker every reader filters on (legacy 0 rows are still honored). + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Integer, Value = serverId == 0 ? (object)DBNull.Value : serverId }); + command.Parameters.AddWithValue(storyPathHash); + command.Parameters.AddWithValue(storyPath); + command.Parameters.AddWithValue(NaiveUtcNow()); + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Text, Value = (object?)reason ?? DBNull.Value }); + + await command.ExecuteNonQueryAsync(); + } + catch (Exception ex) + { + _logger?.LogError("[PgFindingStore] MuteStoryAsync failed: {Message}", ex.Message); + } + } + + /// + /// Unmutes a story pattern (Dashboard-twin surface). + /// + public async Task UnmuteStoryAsync(long muteId) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + using var command = new NpgsqlCommand(UnmuteStorySql, connection); + command.Parameters.AddWithValue(muteId); + await command.ExecuteNonQueryAsync(); + } + catch (Exception ex) + { + _logger?.LogError("[PgFindingStore] UnmuteStoryAsync failed: {Message}", ex.Message); + } + } + + /// + /// Reads the muted-story registry rows visible to a server (its own plus the global + /// server_id IS NULL rows — the same span filters), + /// carrying each row's mute_id so the viewer can offer an unmute. Reads log and + /// degrade to an empty list like the store's other reads. + /// The MCP "mute across all servers" path now persists server_id = NULL (the canonical + /// global marker); legacy server_id = 0 rows written before that fix are honored as global + /// too ( filters both), so an all-servers mute is visible to every + /// server. A NULL/0 (global) row here is flagged muted but left un-unmutable from the per-server + /// viewer, since deleting it would unmute the pattern everywhere. + /// + public async Task> GetMutedStoriesAsync(int serverId) + { + var stories = new List(); + + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + using var command = new NpgsqlCommand(GetMutedStoriesSql, connection); + command.Parameters.AddWithValue(serverId); + + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + stories.Add(new MutedStory( + reader.GetInt64(0), + reader.IsDBNull(1) ? null : reader.GetInt32(1), + reader.GetString(2), + reader.GetString(3), + AsUtc(reader.GetDateTime(4)), + reader.IsDBNull(5) ? null : reader.GetString(5))); + } + } + catch (Exception ex) + { + _logger?.LogError("[PgFindingStore] GetMutedStoriesAsync failed: {Message}", ex.Message); + } + + return stories; + } + + /// + /// Cleans up old findings beyond the retention period. + /// + public async Task CleanupOldFindingsAsync(int retentionDays = 30) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + using var command = new NpgsqlCommand(CleanupOldFindingsSql, connection); + command.Parameters.AddWithValue(NaiveUtcNow().AddDays(-retentionDays)); + await command.ExecuteNonQueryAsync(); + } + catch (Exception ex) + { + _logger?.LogError("[PgFindingStore] CleanupOldFindingsAsync failed: {Message}", ex.Message); + } + } + + /// + /// Reads muted story hashes for a server on an already-open connection (the caller owns + /// it, so the mute-filter read reuses the filter call's connection). Fails OPEN like both + /// twins: an unreadable mute registry returns the hashes read so far (usually empty) and + /// findings flow through unfiltered rather than being suppressed. + /// + private async Task> GetMutedHashesAsync(NpgsqlConnection connection, int serverId) + { + var hashes = new HashSet(); + + try + { + using var command = new NpgsqlCommand(GetMutedHashesSql, connection); + command.Parameters.AddWithValue(serverId); + + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + hashes.Add(reader.GetString(0)); + } + } + catch (Exception ex) + { + _logger?.LogError("[PgFindingStore] GetMutedHashesAsync failed: {Message}", ex.Message); + } + + return hashes; + } + + /// + /// Inserts one finding on an already-open connection (the caller owns it, so a batch + /// shares a single connection). Per-row failure isolation like the Dashboard twin: one + /// bad row logs and the batch continues. + /// + private async Task InsertFindingAsync(NpgsqlConnection connection, AnalysisFinding finding) + { + try + { + using var command = new NpgsqlCommand(InsertFindingSql, connection); + command.Parameters.AddWithValue(finding.FindingId); + command.Parameters.AddWithValue(AsNaive(finding.AnalysisTime)); + command.Parameters.AddWithValue(finding.ServerId); + command.Parameters.AddWithValue(finding.ServerName); + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Text, Value = (object?)finding.DatabaseName ?? DBNull.Value }); + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Timestamp, Value = finding.TimeRangeStart is { } rangeStart ? AsNaive(rangeStart) : (object)DBNull.Value }); + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Timestamp, Value = finding.TimeRangeEnd is { } rangeEnd ? AsNaive(rangeEnd) : (object)DBNull.Value }); + command.Parameters.AddWithValue(finding.Severity); + command.Parameters.AddWithValue(finding.Confidence); + command.Parameters.AddWithValue(finding.Category); + command.Parameters.AddWithValue(finding.StoryPath); + command.Parameters.AddWithValue(finding.StoryPathHash); + command.Parameters.AddWithValue(finding.StoryText); + command.Parameters.AddWithValue(finding.RootFactKey); + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Double, Value = (object?)finding.RootFactValue ?? DBNull.Value }); + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Text, Value = (object?)finding.LeafFactKey ?? DBNull.Value }); + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Double, Value = (object?)finding.LeafFactValue ?? DBNull.Value }); + command.Parameters.AddWithValue(finding.FactCount); + command.Parameters.Add(new NpgsqlParameter + { + NpgsqlDbType = NpgsqlDbType.Text, + Value = string.IsNullOrEmpty(finding.IncidentId) ? (object)DBNull.Value : finding.IncidentId + }); + /* D2: persist the BUILT action (mirrors the alert path's ContextJson) so the + Recommendations reader can drive Apply + consent from a stored finding. */ + command.Parameters.Add(new NpgsqlParameter + { + NpgsqlDbType = NpgsqlDbType.Text, + Value = (object?)AlertContextSerializer.SerializeAction(finding.Remediation) ?? DBNull.Value + }); + + await command.ExecuteNonQueryAsync(); + } + catch (Exception ex) + { + _logger?.LogError("[PgFindingStore] InsertFindingAsync failed: {Message}", ex.Message); + } + } + + /// + /// Reads a single AnalysisFinding from a data reader row. Ordinals match the shared + /// column list of both SELECTs; timestamps are stored naive-UTC and tagged Utc here so + /// the kind is explicit (the PgAlertHistoryStore read discipline). + /// + private static AnalysisFinding ReadFinding(NpgsqlDataReader reader) + { + return new AnalysisFinding + { + FindingId = reader.GetInt64(0), + AnalysisTime = AsUtc(reader.GetDateTime(1)), + ServerId = reader.GetInt32(2), + ServerName = reader.GetString(3), + DatabaseName = reader.IsDBNull(4) ? null : reader.GetString(4), + TimeRangeStart = reader.IsDBNull(5) ? null : AsUtc(reader.GetDateTime(5)), + TimeRangeEnd = reader.IsDBNull(6) ? null : AsUtc(reader.GetDateTime(6)), + Severity = reader.GetDouble(7), + Confidence = reader.GetDouble(8), + Category = reader.GetString(9), + StoryPath = reader.GetString(10), + StoryPathHash = reader.GetString(11), + StoryText = reader.GetString(12), + RootFactKey = reader.GetString(13), + RootFactValue = reader.IsDBNull(14) ? null : reader.GetDouble(14), + LeafFactKey = reader.IsDBNull(15) ? null : reader.GetString(15), + LeafFactValue = reader.IsDBNull(16) ? null : reader.GetDouble(16), + FactCount = reader.GetInt32(17), + IncidentId = reader.IsDBNull(18) ? string.Empty : reader.GetString(18), + /* D2: the BUILT action deserialized via the SAME serializer the alert path uses; + null/garbage degrades to "no Apply affordance" inside DeserializeAction. */ + Remediation = reader.IsDBNull(19) ? null : AlertContextSerializer.DeserializeAction(reader.GetString(19)) + }; + } + + /// Naive-UTC now, Kind-Unspecified — the product's PG timestamp discipline. + private static DateTime NaiveUtcNow() => + DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified); + + /// Kind-Unspecified for writes — Npgsql 6+ rejects Kind-Utc against timestamp. + private static DateTime AsNaive(DateTime value) => + DateTime.SpecifyKind(value, DateTimeKind.Unspecified); + + /// Columns are written as naive UTC; tag them Utc on read so the kind is explicit. + private static DateTime AsUtc(DateTime value) => + DateTime.SpecifyKind(value, DateTimeKind.Utc); +} diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgPlanFetcher.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgPlanFetcher.cs new file mode 100644 index 000000000..8818d1d5d --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Analysis/PgPlanFetcher.cs @@ -0,0 +1,244 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Data; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; +using PerformanceMonitor.Analysis; + +namespace PerformanceMonitor.Darling.Analysis; + +/// +/// Darling's — fetches execution plan XML live from the MONITORED +/// SQL Server (the plan cache, on demand; plan XML is never stored in Postgres), the +/// Dashboard's SqlServerPlanFetcher ported with one seam change: where the Dashboard has a +/// single connection string and Lite looks servers up in its ServerManager, Darling monitors +/// many servers from one service, so the constructor takes a +/// Func<int serverId, string?> resolver. +/// +/// +/// THE SEAM: Darling's worker supplies the resolver from its per-server runtimes (each +/// connected ServerRuntime carries the built connection string, keyed by the +/// storage-name-hash ServerId the findings persist) — this project deliberately does NOT +/// reference PerformanceMonitor.Darling.Service, so the dependency points service → analysis +/// and the resolver is the only bridge. Returning null for an unknown/disconnected serverId +/// degrades the fetch to null exactly like Lite's ServerManager miss. +/// +/// +/// +/// Same query, timeouts, and degrade semantics as the Dashboard implementation: 10-second +/// connect / 15-second command budgets clamped onto the resolved connection string, null for +/// a plan no longer in cache, and any failure logs and returns null — a plan fetch can never +/// fail an analysis run. +/// +/// +public sealed class PgPlanFetcher : IPlanFetcher +{ + /// The Dashboard twin's query verbatim — plan_handle arrives as the '0x...' hex + /// string the collectors persist and converts server-side (CONVERT style 1). + public const string PlanQuery = @" +SET NOCOUNT ON; +SELECT query_plan +FROM sys.dm_exec_query_plan(CONVERT(varbinary(64), @plan_handle, 1));"; + + private readonly Func _connectionStringResolver; + private readonly ILogger? _logger; + + public PgPlanFetcher(Func connectionStringResolver, ILogger? logger = null) + { + _connectionStringResolver = connectionStringResolver ?? throw new ArgumentNullException(nameof(connectionStringResolver)); + _logger = logger; + } + + public async Task FetchPlanXmlAsync(int serverId, string planHandle) + { + if (string.IsNullOrEmpty(planHandle)) + { + return null; + } + + try + { + var connectionString = _connectionStringResolver(serverId); + if (string.IsNullOrEmpty(connectionString)) + { + /* Unknown or not-currently-connected server — degrade like Lite's lookup miss. */ + return null; + } + + var builder = new SqlConnectionStringBuilder(connectionString) + { + ConnectTimeout = 10, + CommandTimeout = 15 + }; + + await using var connection = new SqlConnection(builder.ConnectionString); + await connection.OpenAsync(); + + await using var command = new SqlCommand(PlanQuery, connection); + command.CommandTimeout = 15; + command.Parameters.AddWithValue("@plan_handle", planHandle); + + var result = await command.ExecuteScalarAsync(); + if (result == null || result is DBNull) + { + return null; + } + + return result.ToString(); + } + catch (Exception ex) + { + _logger?.LogError("[PgPlanFetcher] Failed to fetch plan for handle {PlanHandle}: {Message}", planHandle, ex.Message); + return null; + } + } + + /// + /// The database-name → QUOTENAME validation (Lite's GetValidatedDatabaseNameAsync): resolves the + /// caller-supplied database name to its safely-quoted form via sys.databases, so the by-sql_handle + /// fetch's three-part [db].sys.sp_executesql can never be a SQL-injection vector. Public const so a + /// test can pin the shape. + /// + public const string ValidateDatabaseSql = @" +SELECT + quoted_name = QUOTENAME(d.name) +FROM sys.databases AS d +WHERE d.name = @database_name;"; + + /// + /// The by-sql_handle plan fetch's inner statement (Lite's FetchPlanBySqlHandleAsync body): the stored + /// statement's plan by sql_handle + the exact statement offsets, newest execution first. This is the key a + /// deadlock-graph process / blocked-process-report frame carries (no plan_handle), so it is what closes the + /// "fetch ANY process's live plan" gap. Public const so a test can pin the DMV shape + read-only-ness. + /// + public const string SqlHandlePlanInnerSql = @" +SELECT TOP (1) + query_plan_text = tqp.query_plan +FROM sys.dm_exec_query_stats AS qs +OUTER APPLY sys.dm_exec_text_query_plan(qs.plan_handle, qs.statement_start_offset, qs.statement_end_offset) AS tqp +WHERE qs.sql_handle = @h +AND qs.statement_start_offset = @stmt_start +AND qs.statement_end_offset = @stmt_end +AND tqp.query_plan IS NOT NULL +ORDER BY + qs.last_execution_time DESC +OPTION(RECOMPILE);"; + + /// + /// Fetches a cached plan BY SQL_HANDLE + statement offsets — the second live-cache key + /// 's plan_handle path cannot serve, needed for a deadlock-graph process or a + /// blocked-process-report frame (those XE payloads carry only a sql_handle). Same seam + degrade semantics + /// as : resolves to the connected runtime's + /// connection string (null for unknown/disconnected → null), validates the database name to QUOTENAME + /// (falls back to [master] like Lite), and returns null (never throws) for a plan no longer in cache + /// or any error — a plan fetch can never fail its caller. + /// + public async Task FetchPlanBySqlHandleAsync( + int serverId, string? databaseName, string sqlHandleHex, + int statementStartOffset, int statementEndOffset, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(sqlHandleHex)) + { + return null; + } + + var handleBytes = HexStringToBytes(sqlHandleHex); + if (handleBytes is null || handleBytes.Length == 0) + { + return null; + } + + try + { + var connectionString = _connectionStringResolver(serverId); + if (string.IsNullOrEmpty(connectionString)) + { + return null; + } + + var builder = new SqlConnectionStringBuilder(connectionString) + { + ConnectTimeout = 10, + CommandTimeout = 30, + }; + + await using var connection = new SqlConnection(builder.ConnectionString); + await connection.OpenAsync(cancellationToken); + + var quotedDbName = await GetValidatedDatabaseNameAsync(connection, databaseName, cancellationToken) ?? "[master]"; + + var query = $@" +EXECUTE {quotedDbName}.sys.sp_executesql + N'{SqlHandlePlanInnerSql}', + N'@h varbinary(64), @stmt_start int, @stmt_end int', + @h, @stmt_start, @stmt_end;"; + + await using var command = new SqlCommand(query, connection) { CommandTimeout = 30 }; + command.Parameters.Add(new SqlParameter("@h", SqlDbType.VarBinary, 64) { Value = handleBytes }); + command.Parameters.Add(new SqlParameter("@stmt_start", SqlDbType.Int) { Value = statementStartOffset }); + command.Parameters.Add(new SqlParameter("@stmt_end", SqlDbType.Int) { Value = statementEndOffset }); + + var result = await command.ExecuteScalarAsync(cancellationToken); + return result is null or DBNull ? null : result.ToString(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger?.LogError("[PgPlanFetcher] Failed to fetch plan for sql_handle {SqlHandle}: {Message}", sqlHandleHex, ex.Message); + return null; + } + } + + /// Resolves a database name to its QUOTENAME'd form (null when unknown) — the injection guard for + /// the three-part sp_executesql. A null/blank name short-circuits to null (caller uses [master]). + private static async Task GetValidatedDatabaseNameAsync( + SqlConnection connection, string? databaseName, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(databaseName)) + { + return null; + } + + await using var command = new SqlCommand(ValidateDatabaseSql, connection) { CommandTimeout = 30 }; + command.Parameters.Add(new SqlParameter("@database_name", SqlDbType.NVarChar, 128) { Value = databaseName }); + var result = await command.ExecuteScalarAsync(cancellationToken); + return result as string; + } + + /// Parses a '0x…' (or bare) hex handle to bytes; null for a malformed / odd-length string (Lite's + /// HexStringToBytes verbatim). + internal static byte[]? HexStringToBytes(string hex) + { + if (string.IsNullOrEmpty(hex)) + { + return null; + } + + var start = hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? 2 : 0; + var len = hex.Length - start; + if (len <= 0 || (len % 2) != 0) + { + return null; + } + + var bytes = new byte[len / 2]; + for (int i = 0; i < bytes.Length; i++) + { + if (!byte.TryParse(hex.AsSpan(start + i * 2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out bytes[i])) + { + return null; + } + } + + return bytes; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/ActiveQueriesLivePayload.cs b/Darling/PerformanceMonitor.Darling.Service/ActiveQueriesLivePayload.cs new file mode 100644 index 000000000..74853b9b6 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/ActiveQueriesLivePayload.cs @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Text.Json; +using PerformanceMonitor.Collectors; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// Builds the result_json a fetch_active_queries command reports back to the viewer: a live DMV +/// snapshot of the requests running on a monitored server right now, shredded by the shared +/// into its shape (the SAME +/// shape the collector stores), so the viewer renders the live rows in the same grid as the stored ones. +/// +/// Pure and side-effect-free so a unit test can pin the wire contract end-to-end WITHOUT a live SQL +/// Server: build s, serialize here, and assert the viewer's +/// ViewerDataService.ParseActiveQueriesLiveResult reconstructs them. The row objects are serialized +/// whole (all columns), and the viewer parses them case-insensitively — the round-trip test guards the two +/// ends against drift. The payload carries a fetchedAtUtc so the viewer can stamp each row's +/// "collected" time (the DMV row itself has no capture timestamp — the collector stamps it at store time). +/// +public static class ActiveQueriesLivePayload +{ + private static readonly JsonSerializerOptions s_options = new() + { + /* No naming policy — the row properties serialize as PascalCase and the viewer parses + case-insensitively, so the two ends never depend on a shared policy setting. */ + }; + + /// + /// Serializes the live snapshot rows into the command's result_json: + /// {"success":true,"fetchedAtUtc":"…","rows":[ …one object per running request… ]}. + /// + public static string Serialize(IReadOnlyList rows, DateTime fetchedAtUtc) + { + ArgumentNullException.ThrowIfNull(rows); + return JsonSerializer.Serialize(new + { + success = true, + fetchedAtUtc, + rows, + }, s_options); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/ActualPlanRequest.cs b/Darling/PerformanceMonitor.Darling.Service/ActualPlanRequest.cs new file mode 100644 index 000000000..7d7c2e211 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/ActualPlanRequest.cs @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Text.Json; + +namespace PerformanceMonitor.Darling.Service; + +/// Which store table the actual-plan request's IDENTIFIER resolves the re-executable query text from. +public enum ActualPlanSource +{ + /// No usable identifier — the dispatch fails the request. + None, + + /// Top Queries / Query-Stats history / FinOps High Impact — resolves query_stats by query_hash + database. + QueryStats, + + /// Query Store history — resolves query_store_stats by query_id + database. + QueryStore, + + /// Wait drill-down — resolves query_snapshots by collection_time + session_id. + QuerySnapshot, +} + +/// +/// The parsed execute_actual_plan command payload (the viewer builds it, the service parses it). It +/// carries an IDENTIFIER ONLY — a stored-row key — and deliberately NO SQL text. Exactly one identifier kind is +/// populated (see ), because "Get Actual Plan" lives on several viewer surfaces whose rows +/// are keyed differently: Top Queries / Query-Stats history / FinOps High Impact by ; +/// Query Store history by ; Wait drill-down by + +/// . The service resolves the query text and estimated plan from its OWN store by +/// the identifier before re-executing. +/// +/// Why identifier-only is a hard security requirement: the target server id rides on the command +/// row and the query is RE-EXECUTED as the service's stored monitoring credential. If this payload could carry +/// SQL text, any writer of config.config_command could make the service run arbitrary SQL against every +/// monitored target — a privilege escalation through the command plane. Carrying only a key means the worst a +/// command writer can do is ask the service to re-run a query the collector ALREADY captured (and only a +/// read-write seat can enqueue at all). Property names are camelCase to match the viewer's serialization +/// (parsed case-insensitively). Pure args model + validation (); never throws. +/// +public sealed record ActualPlanRequest( + string? QueryHash, + long? QueryId, + DateTime? SnapshotCollectionTime, + int? SnapshotSessionId, + string? DatabaseName) +{ + /// Which store table the identifier resolves from (the first populated kind wins — deterministic). + public ActualPlanSource Source => + !string.IsNullOrWhiteSpace(QueryHash) ? ActualPlanSource.QueryStats + : QueryId is not null ? ActualPlanSource.QueryStore + : SnapshotCollectionTime is not null && SnapshotSessionId is not null ? ActualPlanSource.QuerySnapshot + : ActualPlanSource.None; + + /// True when a usable stored-row key is present — the dispatch fails a request without one. + public bool HasIdentifier => Source != ActualPlanSource.None; + + private static readonly JsonSerializerOptions s_options = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + /// + /// Parses args_json into a request, returning false for null/blank/malformed JSON or a request that + /// carries no usable identifier. Never throws. + /// + public static bool TryParse(string? argsJson, out ActualPlanRequest request) + { + request = new ActualPlanRequest(null, null, null, null, null); + if (string.IsNullOrWhiteSpace(argsJson)) + { + return false; + } + + Dto? dto; + try + { + dto = JsonSerializer.Deserialize(argsJson, s_options); + } + catch (JsonException) + { + return false; + } + + if (dto is null) + { + return false; + } + + var parsed = new ActualPlanRequest( + NullIfBlank(dto.QueryHash), + dto.QueryId, + dto.SnapshotCollectionTime, + dto.SnapshotSessionId, + NullIfBlank(dto.DatabaseName)); + + if (!parsed.HasIdentifier) + { + return false; + } + + request = parsed; + return true; + } + + private static string? NullIfBlank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value; + + /// The wire shape of args_json — identifiers only; NO query-text field exists by design. + private sealed class Dto + { + public string? QueryHash { get; set; } + public long? QueryId { get; set; } + public DateTime? SnapshotCollectionTime { get; set; } + public int? SnapshotSessionId { get; set; } + public string? DatabaseName { get; set; } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCompiler.cs b/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCompiler.cs new file mode 100644 index 000000000..34c622179 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCompiler.cs @@ -0,0 +1,441 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using Npgsql; +using NpgsqlTypes; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Storage; + +namespace PerformanceMonitor.Darling.Service; + +/// The server scope + window + variable bindings a compiles against. The +/// window is naive UTC (Kind=Unspecified is stamped when bound, matching every other Darling read). +/// is the resolved $server scope (Erik's Decision 1 fleet axis): null/empty = +/// the WHOLE FLEET (no server predicate); one or many server names = a bound server_name = ANY($n) +/// filter, never interpolated (the compile-run endpoint resolves the $server variable to this). Group-by-server +/// and a per-panel server filter are separate and flow through the normal dimension path. +public sealed record ComposeRunContext( + IReadOnlyList? Servers, + DateTime StartUtc, + DateTime EndUtc, + IReadOnlyDictionary Variables) +{ + public static readonly IReadOnlyDictionary NoVariables = + new Dictionary(StringComparer.Ordinal); +} + +/// The compiled SQL + its bound parameters (in $1..$n order). +public sealed record ComposeCompiled(string Sql, IReadOnlyList Parameters); + +/// +/// Compiles a validated into a parameterized Postgres query. IRON RULES, all +/// upheld by construction because every identifier comes from (never the +/// caller): +/// +/// Table/column/aggregate/time-column identifiers are catalog constants, emitted schema-qualified +/// collect.<table> — the composed query can NEVER name a config table or an +/// off-catalog column, and never relies on search_path. +/// Every VALUE is a bound parameter: $1/$2 the naive-UTC window, then (when the run is +/// scoped to specific servers) a bound server_name = ANY($n), then filter values as = ANY($n) +/// (with the array bound), LIKE $n, threshold $n, and LIMIT $n for topN. +/// Aggregation is archetype-gated (SUM on the delta of a cumulative, on the column of a delta; AVG/ +/// MIN/MAX on the gauge/per-event column; percentile_cont only on per-event); a ratio is +/// SUM(a)::float / NULLIF(SUM(b), 0). +/// The #1568 object_name module join is bounded by the same window (the DoS fix over the +/// viewer's currently-unbounded stitch). +/// +/// The compiler assumes its input is a that +/// already validated; the only failure it can still surface is the window×resolution ceiling (which needs +/// the run window, so it cannot be checked at write time). +/// +public static class ComposeCompiler +{ + /// The prefix time column per source (the collector's PrefixTimeColumnName — usually + /// collection_time), resolved from the catalog so the compiler buckets/windows on the SAME + /// column the pin test asserts each measure's time column is. + private static readonly Dictionary s_timeColumnByTable = + CollectorCatalog.All.ToDictionary(c => c.TargetTable, c => c.PrefixTimeColumnName, StringComparer.Ordinal); + + /// The fact-table alias every composed query uses, so fact columns qualify unambiguously + /// against the m module-join alias. + private const string FactAlias = "f"; + + private const string ModuleAlias = "m"; + + /// + /// Compiles against . Returns the parameterized SQL, + /// or a caller-facing error for the one runtime-only check (the window×resolution bucket ceiling). + /// + public static (ComposeCompiled? Compiled, string? Error) Compile(PanelPlan plan, ComposeRunContext context) + { + if (plan is null) + { + throw new ArgumentNullException(nameof(plan)); + } + + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (plan.Mode == PanelMode.TimeSeries) + { + var windowSeconds = (context.EndUtc - context.StartUtc).TotalSeconds; + var bucketSeconds = MeasureCatalog.BucketSeconds(plan.TimeBucket); + if (bucketSeconds > 0) + { + var buckets = Math.Ceiling(windowSeconds / bucketSeconds); + if (buckets > ComposeLimits.MaxBuckets) + { + return (null, + $"the window and '{MeasureCatalog.WireName(plan.TimeBucket)}' bucket would produce {buckets:0} points " + + $"(max {ComposeLimits.MaxBuckets}); choose a coarser bucket or a shorter window."); + } + } + } + + var p = new ParamList(); + /* $1 start, $2 end — the naive-UTC window prelude. The server scope is OPTIONAL/multi: null/empty + $server => the whole fleet (no predicate); one/many servers => a bound server_name = ANY($n), never + interpolated (the compile-run endpoint resolves the $server variable to this). */ + var startParam = p.AddTimestamp(context.StartUtc); + var endParam = p.AddTimestamp(context.EndUtc); + var hasServerScope = context.Servers is { Count: > 0 }; + var serverScopeParam = hasServerScope ? p.AddTextArray(context.Servers!) : null; + + var timeColumn = s_timeColumnByTable[plan.Measure.SourceTable]; + var sql = new StringBuilder(); + + /* The #1568 module CTE (window-bounded) — only when a dimension is stitched from it. */ + if (plan.UsesModuleJoin) + { + /* Window-bounded AND scoped to the same server set — partitioned by (server_name, sql_handle) so a + handle reused across servers attributes per server, not globally. */ + sql.Append("WITH ").Append(ModuleAlias).Append(" AS (\n"); + sql.Append(" SELECT server_name, sql_handle, object_name, schema_name, database_name\n"); + sql.Append(" FROM (\n"); + sql.Append(" SELECT server_name, sql_handle, object_name, schema_name, database_name,\n"); + sql.Append(" ROW_NUMBER() OVER (PARTITION BY server_name, sql_handle ORDER BY collection_time DESC) AS rn\n"); + sql.Append(" FROM ").Append(PgSchemaGenerator.CollectSchema).Append(".procedure_stats\n"); + sql.Append(" WHERE collection_time >= ").Append(startParam).Append('\n'); + sql.Append(" AND collection_time <= ").Append(endParam).Append('\n'); + if (hasServerScope) + { + sql.Append(" AND server_name = ANY(").Append(serverScopeParam).Append(")\n"); + } + + sql.Append(" AND sql_handle IS NOT NULL\n"); + sql.Append(" AND sql_handle <> ''\n"); + sql.Append(" ) ranked_modules\n"); + sql.Append(" WHERE rn = 1\n"); + sql.Append(")\n"); + } + + /* SELECT list + the matching GROUP BY expressions. */ + var selectExprs = new List(); + var groupExprs = new List(); + + if (plan.Mode == PanelMode.TimeSeries) + { + var bucketExpr = $"date_trunc('{MeasureCatalog.DateTruncField(plan.TimeBucket)}', {FactAlias}.{timeColumn})"; + selectExprs.Add(bucketExpr + " AS bucket"); + groupExprs.Add(bucketExpr); + } + + foreach (var dim in plan.GroupBy) + { + var expr = ColumnRef(dim); + selectExprs.Add(expr + " AS " + dim.Name); + groupExprs.Add(expr); + } + + selectExprs.Add(BuildValueExpr(plan) + " AS value"); + + sql.Append("SELECT ").Append(string.Join(", ", selectExprs)).Append('\n'); + sql.Append("FROM ").Append(PgSchemaGenerator.CollectSchema).Append('.').Append(plan.Measure.SourceTable) + .Append(" AS ").Append(FactAlias).Append('\n'); + + if (plan.UsesModuleJoin) + { + sql.Append("LEFT JOIN ").Append(ModuleAlias).Append(" ON ").Append(ModuleAlias) + .Append(".sql_handle = ").Append(FactAlias).Append(".sql_handle AND ").Append(ModuleAlias) + .Append(".server_name = ").Append(FactAlias).Append(".server_name\n"); + } + + sql.Append("WHERE ").Append(FactAlias).Append('.').Append(timeColumn).Append(" >= ").Append(startParam).Append('\n'); + sql.Append(" AND ").Append(FactAlias).Append('.').Append(timeColumn).Append(" <= ").Append(endParam).Append('\n'); + if (hasServerScope) + { + sql.Append(" AND ").Append(FactAlias).Append(".server_name = ANY(").Append(serverScopeParam).Append(")\n"); + } + + foreach (var filter in plan.Filters) + { + sql.Append(" AND ").Append(BuildFilterClause(filter, context, p)).Append('\n'); + } + + if (groupExprs.Count > 0) + { + sql.Append("GROUP BY ").Append(string.Join(", ", groupExprs)).Append('\n'); + } + + switch (plan.Mode) + { + case PanelMode.TimeSeries: + sql.Append("ORDER BY bucket\n"); + sql.Append("LIMIT ").Append(ComposeLimits.HardRowCap); + break; + case PanelMode.Ranked: + sql.Append("ORDER BY value DESC\n"); + sql.Append("LIMIT ").Append(p.AddInt(plan.TopN)); + break; + default: /* Scalar — a single aggregate row. */ + sql.Append("LIMIT 1"); + break; + } + + return (new ComposeCompiled(sql.ToString(), p.Parameters), null); + } + + /// + /// Compiles the panel's event-annotation overlays (design D5) into one bounded parameterized query per + /// requested source — the SAME discipline as : identifiers come ONLY from the annotation + /// catalog (schema-qualified collect.<table>, never a caller string); the window and the (optional) + /// server scope are the SAME bound parameters the measure query uses (never interpolated); and each query is + /// capped at and ordered by event time. Returns one + /// (sourceKey, compiled) pair per requested source (empty when the panel requests none). Annotations + /// never change the measure query — the endpoint runs these separately and returns their events alongside it. + /// + public static IReadOnlyList<(string Source, ComposeCompiled Compiled)> CompileAnnotations(PanelPlan plan, ComposeRunContext context) + { + if (plan is null) + { + throw new ArgumentNullException(nameof(plan)); + } + + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (plan.Annotations.Count == 0) + { + return Array.Empty<(string, ComposeCompiled)>(); + } + + var results = new List<(string, ComposeCompiled)>(plan.Annotations.Count); + foreach (var source in plan.Annotations) + { + results.Add((source.Key, CompileAnnotation(source, context))); + } + + return results; + } + + /// Compiles one annotation source into its bounded, catalog-only, schema-qualified event query: + /// SELECT f.<timeCol> AS ts, f.<labelCol> AS label FROM collect.<table> AS f WHERE + /// f.<timeCol> BETWEEN $1 AND $2 [AND f.server_name = ANY($3)] ORDER BY ts LIMIT + /// <MaxAnnotationEvents>. Every identifier is a catalog constant; every value is bound. + private static ComposeCompiled CompileAnnotation(ComposeAnnotationSource source, ComposeRunContext context) + { + var p = new ParamList(); + var startParam = p.AddTimestamp(context.StartUtc); + var endParam = p.AddTimestamp(context.EndUtc); + var hasServerScope = context.Servers is { Count: > 0 }; + var serverScopeParam = hasServerScope ? p.AddTextArray(context.Servers!) : null; + + var sql = new StringBuilder(); + sql.Append("SELECT ").Append(FactAlias).Append('.').Append(source.TimeColumn).Append(" AS ts, ") + .Append(FactAlias).Append('.').Append(source.LabelColumn).Append(" AS label\n"); + sql.Append("FROM ").Append(PgSchemaGenerator.CollectSchema).Append('.').Append(source.SourceTable) + .Append(" AS ").Append(FactAlias).Append('\n'); + sql.Append("WHERE ").Append(FactAlias).Append('.').Append(source.TimeColumn).Append(" >= ").Append(startParam).Append('\n'); + sql.Append(" AND ").Append(FactAlias).Append('.').Append(source.TimeColumn).Append(" <= ").Append(endParam).Append('\n'); + if (hasServerScope) + { + sql.Append(" AND ").Append(FactAlias).Append(".server_name = ANY(").Append(serverScopeParam).Append(")\n"); + } + + sql.Append("ORDER BY ts\n"); + sql.Append("LIMIT ").Append(ComposeLimits.MaxAnnotationEvents); + + return new ComposeCompiled(sql.ToString(), p.Parameters); + } + + /// The qualified reference for a dimension column: m. for a module-join dimension, + /// f. for a fact column. + private static string ColumnRef(ComposeDimension dimension) => + (dimension.ViaModuleJoin ? ModuleAlias : FactAlias) + "." + dimension.Column; + + /// Builds the value expression: the archetype/kind-gated aggregate, cast to double, then + /// scaled by the requested unit's conversion factor (a compile-time family constant). + private static string BuildValueExpr(PanelPlan plan) + { + var measure = plan.Measure; + + if (measure.Kind == MeasureKind.Ratio) + { + string native; + if (measure.RatioMode == MeasureRatioMode.Weighted) + { + /* Weighted mode (a pre-aggregated per-interval average weighted by its execution count): the + execution-weighted mean SUM(value * weight) / NULLIF(SUM(weight), 0) across intervals — the + correct average of averages (design §2), not a plain avg-of-avgs. Both operands are raw source + columns, not numerator/denominator measures. */ + native = $"(CAST(SUM({FactAlias}.{measure.WeightedValueColumn} * {FactAlias}.{measure.WeightColumn}) AS double precision) " + + $"/ NULLIF(SUM({FactAlias}.{measure.WeightColumn}), 0))"; + } + else + { + var numerator = MeasureCatalog.Measure(measure.NumeratorKey)!; + var denominator = MeasureCatalog.Measure(measure.DenominatorKey)!; + /* Sum mode (cumulative/delta operands): SUM(delta) / NULLIF(SUM(delta), 0). Avg mode (gauge operands, + whose AggregationColumn is null — a gauge is never summable): AVG(col) / NULLIF(AVG(col), 0). */ + native = measure.RatioMode == MeasureRatioMode.Avg + ? $"(CAST(AVG({FactAlias}.{numerator.Column}) AS double precision) " + + $"/ NULLIF(AVG({FactAlias}.{denominator.Column}), 0))" + : $"(CAST(SUM({FactAlias}.{numerator.AggregationColumn}) AS double precision) " + + $"/ NULLIF(SUM({FactAlias}.{denominator.AggregationColumn}), 0))"; + } + + return ApplyUnitConversion(native, measure.UnitFamily, measure.NativeUnit, plan.Unit); + } + + /* COUNT is a plain, unitless row count. */ + if (plan.Aggregate == ComposeAggregate.Count) + { + return "CAST(COUNT(*) AS double precision)"; + } + + /* The aggregated column: the delta for a cumulative counter, the column itself otherwise. */ + var aggColumn = measure.Archetype == MeasureArchetype.Cumulative ? measure.DeltaColumn! : measure.Column!; + var qualified = FactAlias + "." + aggColumn; + + var nativeExpr = plan.Aggregate switch + { + ComposeAggregate.Sum => $"CAST(SUM({qualified}) AS double precision)", + ComposeAggregate.Avg => $"CAST(AVG({qualified}) AS double precision)", + ComposeAggregate.Min => $"CAST(MIN({qualified}) AS double precision)", + ComposeAggregate.Max => $"CAST(MAX({qualified}) AS double precision)", + ComposeAggregate.PercentileCont => + $"percentile_cont({FormatDouble(ComposeLimits.DefaultPercentile)}) WITHIN GROUP (ORDER BY {qualified})", + _ => throw new InvalidOperationException($"Unhandled aggregate {plan.Aggregate}"), + }; + + return ApplyUnitConversion(nativeExpr, measure.UnitFamily, measure.NativeUnit, plan.Unit); + } + + /// Scales (already a double) from to + /// within : value * factorNative / + /// factorRequested. A no-op when the units match. Factors are compile-time family constants. + private static string ApplyUnitConversion(string expr, string familyName, string nativeUnit, string requestedUnit) + { + var family = MeasureCatalog.Family(familyName); + var from = family?.Unit(nativeUnit); + var to = family?.Unit(requestedUnit); + if (from is null || to is null || from.BaseFactor == to.BaseFactor) + { + return expr; + } + + return $"({expr}) * {FormatDouble(from.BaseFactor)} / {FormatDouble(to.BaseFactor)}"; + } + + /// Builds one filter's SQL predicate, binding every value as a parameter. + private static string BuildFilterClause(ComposeFilter filter, ComposeRunContext context, ParamList p) + { + var column = ColumnRef(filter.Dimension); + var values = ResolveValues(filter.Value, context); + + switch (filter.Op) + { + case ComposeFilterOp.Eq: + return $"{column} = ANY({p.AddTextArray(values)})"; + case ComposeFilterOp.Neq: + return $"{column} <> ALL({p.AddTextArray(values)})"; + case ComposeFilterOp.Like: + return $"{column} LIKE {p.AddText(First(values))}"; + case ComposeFilterOp.Gt: + return $"{column} > {p.AddText(First(values))}"; + case ComposeFilterOp.Gte: + return $"{column} >= {p.AddText(First(values))}"; + case ComposeFilterOp.Lt: + return $"{column} < {p.AddText(First(values))}"; + case ComposeFilterOp.Lte: + return $"{column} <= {p.AddText(First(values))}"; + default: + throw new InvalidOperationException($"Unhandled filter op {filter.Op}"); + } + } + + /// Resolves a filter value to concrete strings: a literal set as-is, or a declared variable's + /// run value (its request binding or default; a missing binding resolves to an empty string). + private static IReadOnlyList ResolveValues(ComposeFilterValue value, ComposeRunContext context) + { + if (value.VariableRef is not null) + { + var resolved = context.Variables.TryGetValue(value.VariableRef, out var v) ? v : null; + return new[] { resolved ?? "" }; + } + + return value.Literals ?? Array.Empty(); + } + + private static string First(IReadOnlyList values) => values.Count > 0 ? values[0] : ""; + + /// Formats an (always integral) factor / the percentile as a double literal so Postgres never + /// does integer division (e.g. 1048576.0, 0.95). + private static string FormatDouble(double value) + { + if (value == Math.Floor(value) && !double.IsInfinity(value)) + { + return ((long)value).ToString(CultureInfo.InvariantCulture) + ".0"; + } + + return value.ToString("0.0###############", CultureInfo.InvariantCulture); + } + + /// Accumulates bound parameters and hands back their $n placeholders in order. + private sealed class ParamList + { + private readonly List _parameters = new(); + + public IReadOnlyList Parameters => _parameters; + + public string AddInt(int value) + { + _parameters.Add(new NpgsqlParameter { TypedValue = value }); + return Placeholder(); + } + + public string AddTimestamp(DateTime value) + { + _parameters.Add(new NpgsqlParameter { TypedValue = DateTime.SpecifyKind(value, DateTimeKind.Unspecified) }); + return Placeholder(); + } + + public string AddText(string value) + { + _parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Text, Value = value ?? "" }); + return Placeholder(); + } + + public string AddTextArray(IReadOnlyList values) + { + _parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Array | NpgsqlDbType.Text, Value = values.ToArray() }); + return Placeholder(); + } + + private string Placeholder() => "$" + _parameters.Count.ToString(CultureInfo.InvariantCulture); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSpec.cs b/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSpec.cs new file mode 100644 index 000000000..61adec14a --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSpec.cs @@ -0,0 +1,757 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Nodes; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// The abuse bounds a composed panel must stay within — the compile-time half of the DoS controls +/// (the DB-side half is the statement_timeout on the viewer/mcp roles). Because the compiler only +/// ever emits aggregated queries (GROUP BY bucket/dims, or a single scalar) — never raw rows — output is +/// bounded by construction; these caps bound the WORK: the window, the bucket resolution, and the number +/// of filter/group-by terms. +/// +public static class ComposeLimits +{ + /// The widest window a panel may query (90 days) — a hard ceiling on the scan. + public const int MaxWindowHours = 24 * 90; + + /// The most buckets a time series may resolve to (window ÷ bucket) — bounds a 1-minute + /// bucket over a long window from fanning out to hundreds of thousands of buckets. + public const int MaxBuckets = 5_000; + + /// The ceiling on a ranked panel's topN (mirrors the read surface's row clamp). + public const int MaxTopN = 1_000; + + /// The final safety LIMIT on a time-series query (a grouped series × buckets backstop). + public const int HardRowCap = 10_000; + + public const int MaxFilters = 12; + public const int MaxGroupBy = 4; + + /// The most render-only reference lines (thresholds) a panel may carry — a handful of guide lines, + /// never a data series. Render-only: thresholds are validated at write time but never enter the compiler. + public const int MaxThresholds = 4; + + /// The most event-annotation sources a time-series panel may overlay (design D5) — a handful of + /// marker layers, never a data series. Each is a separate bounded event query alongside the measure query. + public const int MaxAnnotations = 4; + + /// The most annotation events one source returns for a panel (design D5) — the hard LIMIT on + /// each overlay query, so a dense window draws a capped set of markers rather than fanning out. Bounds OUTPUT; + /// the viewer role's bounds the WORK. + public const int MaxAnnotationEvents = 200; + + /// + /// The per-session statement_timeout applied to the viewer/mcp roles (the compose + /// surface's DB identities) — the hard backstop a composed query can never exceed, whatever the compile-time + /// window/bucket caps reason around (a LIMIT bounds OUTPUT, a group-by scans+sorts before it). Applied + /// in the role PROVISIONING DDL ( + tools/provision-roles.sql), NOT a + /// versioned migration: a role's statement_timeout has no probeable schema footprint, so tying it to + /// would break the viewer's connect-time version gate. A Postgres + /// interval literal. + /// + public const string StatementTimeout = "15s"; + + /// The fixed percentile for percentile_cont (p95) — a hardcoded default per the + /// defaults-over-speculative-config rule; a per-panel percentile knob is a clean later add. + public const double DefaultPercentile = 0.95; +} + +/// A view-level variable: a named, dimension-typed slot a panel filter can reference as +/// "$name". A variable whose dimension is scopes the run's server +/// (the compile+run endpoint resolves it to the serverId); the rest resolve to a bound filter value. +public sealed record ComposeVariable(string Name, string Dimension, string? Default) +{ + /// The reserved dimension marking the ambient server scope (resolved to serverId, not a filter). + public const string ServerDimension = "server"; +} + +/// A panel filter's value — exactly one of a literal set (one value, or many for a multi-select +/// eq/neq) or a reference to a declared . Either way it is +/// resolved to bound parameters at compile time; a value is NEVER interpolated into SQL. +public sealed record ComposeFilterValue(IReadOnlyList? Literals, string? VariableRef) +{ + public bool IsVariable => VariableRef is not null; +} + +/// One parsed, validated filter: a real dimension for the source, a legal operator, and a value +/// that resolves to bound parameters. +public sealed record ComposeFilter(ComposeDimension Dimension, ComposeFilterOp Op, ComposeFilterValue Value); + +/// Which shape the compiler emits — chosen by the presence of a time bucket vs. a topN. +public enum PanelMode +{ + /// Bucketed over time: date_trunc + GROUP BY bucket [+ dims], ordered by bucket. + TimeSeries, + + /// Ranked: GROUP BY dims, ordered by the aggregate descending, LIMIT topN. + Ranked, + + /// A single aggregate over the whole window (one row) — the stat tile. + Scalar, +} + +/// +/// A fully-parsed, fully-validated panel — everything the compiler needs, with every identifier already +/// resolved to a catalog object (never a raw string). Produced by +/// (the write-time authority and the compiler's input alike), so a in hand is by +/// construction safe to compile. +/// +public sealed record PanelPlan +{ + public required ComposeMeasure Measure { get; init; } + public ComposeAggregate Aggregate { get; init; } + public required string Unit { get; init; } + public PanelMode Mode { get; init; } + public ComposeTimeBucket TimeBucket { get; init; } + public int TopN { get; init; } + public required IReadOnlyList Filters { get; init; } + public required IReadOnlyList GroupBy { get; init; } + public required string Viz { get; init; } + + /// Optional render-only reference-line values, in the panel's unit (0-4 finite numbers, design D3). + /// Validated at parse time but NEVER compiled — the frontend draws them, so they never enter the SQL. + public IReadOnlyList Thresholds { get; init; } = Array.Empty(); + + /// Optional event-annotation sources to overlay as markers on a TIME-SERIES panel (design D5): + /// 0- catalog-resolved sources. Only ever non-empty for a + /// panel (a marker overlay needs a time axis — parse rejects them + /// otherwise). They do NOT change the measure query: each is compiled to its own bounded event query by + /// and returned alongside the panel's rows. + public IReadOnlyList Annotations { get; init; } = Array.Empty(); + + /// True when any filter/groupBy dimension is stitched from the #1568 module join, so the + /// compiler must emit (and window-bound) the module CTE. + public bool UsesModuleJoin => + Filters.Any(f => f.Dimension.ViaModuleJoin) || GroupBy.Any(d => d.ViaModuleJoin); +} + +/// +/// Parses + validates the Custom Views v2 spec JSON into typed, catalog-resolved plans — the SINGLE +/// authority both the write path (DarlingWebEndpoints.ValidateDefinition) and the run path +/// (/api/compose/run) route through, so a definition that stored can always compile and vice +/// versa. Cross-checks EVERY identifier-bearing field against and rejects +/// anything off-catalog; the compiler then trusts the plan completely. +/// +public static class ComposeSpec +{ + /// Whether a panel object is a v2 (composed) panel — it names a source. A v1 panel + /// names a read instead; the two coexist in one definition, dispatched per panel. + public static bool IsComposedPanel(JsonObject panel) => panel["source"] is not null; + + /// The v2 composed-panel viz vocabulary (design §4) — distinct from v1 read panels' KnownViz + /// (table/line/stat/bandlist). The composer's viz picker serves this; every stored composed panel's viz + /// must be in it AND coherent with the panel's mode (). + public static readonly IReadOnlyList ComposeVizList = new[] { "line", "area", "bar", "stacked", "stacked-bar", "pie", "table", "stat" }; + + /// Set form of for O(1) membership. + public static readonly IReadOnlySet KnownComposeViz = new HashSet(ComposeVizList, StringComparer.Ordinal); + + /* ─────────────────────────── variables ─────────────────────────── */ + + /// The set of dimension names a variable may be typed as: every catalog dimension name plus + /// the reserved server scope. + private static readonly HashSet s_variableDimensionNames = BuildVariableDimensionNames(); + + private static HashSet BuildVariableDimensionNames() + { + var names = MeasureCatalog.Dimensions.Select(d => d.Name).ToHashSet(StringComparer.Ordinal); + names.Add(ComposeVariable.ServerDimension); + return names; + } + + /// Parses + validates a definition's variables array (absent = none). Each entry must be + /// an object with a non-empty name (unique) and a dimension that is a known dimension or + /// server; default is optional text. + public static (IReadOnlyList? Variables, string? Error) ParseVariables(JsonNode? node) + { + if (node is null) + { + return (Array.Empty(), null); + } + + if (node is not JsonArray array) + { + return (null, "definition.variables must be an array."); + } + + var variables = new List(); + var seen = new HashSet(StringComparer.Ordinal); + for (var i = 0; i < array.Count; i++) + { + if (array[i] is not JsonObject obj) + { + return (null, $"variable {i} must be an object."); + } + + var name = Str(obj, "name"); + if (string.IsNullOrWhiteSpace(name)) + { + return (null, $"variable {i} is missing 'name'."); + } + + if (!seen.Add(name)) + { + return (null, $"variable '{name}' is declared more than once."); + } + + var dimension = Str(obj, "dimension"); + if (string.IsNullOrEmpty(dimension) || !s_variableDimensionNames.Contains(dimension)) + { + return (null, $"variable '{name}' has an unknown dimension '{dimension}'."); + } + + variables.Add(new ComposeVariable(name, dimension, Str(obj, "default"))); + } + + return (variables, null); + } + + /* ─────────────────────────── range ─────────────────────────── */ + + /// Validates a definition/run range object (absent = the caller's default). Only + /// {hours} is understood today; hours must be a positive int within the window ceiling. + public static (int? Hours, string? Error) ParseRange(JsonNode? node) + { + if (node is null) + { + return (null, null); + } + + if (node is not JsonObject obj) + { + return (null, "range must be an object."); + } + + if (obj["hours"] is not JsonValue hoursValue || !hoursValue.TryGetValue(out var hours)) + { + return (null, "range.hours must be an integer."); + } + + if (hours < 1 || hours > ComposeLimits.MaxWindowHours) + { + return (null, $"range.hours must be between 1 and {ComposeLimits.MaxWindowHours}."); + } + + return (hours, null); + } + + /* ─────────────────────────── panels ─────────────────────────── */ + + /// + /// Parses + validates one v2 (source) panel against the catalog and the view's declared + /// , returning a compiler-ready or a + /// caller-facing error. The full identifier cross-check: source ∈ catalog; measure|ratio ∈ catalog and + /// on that source; aggregate ∈ the measure's ValidAggs ∧ legal-for-archetype; unit ∈ the measure's + /// family; each filter/groupBy dimension ∈ the measure's AllowedDimensions; LIKE only on a LIKE-able + /// dimension; each variable-ref ∈ the declared variables; and the per-panel caps. + /// + public static (PanelPlan? Plan, string? Error) TryParsePanel(JsonObject panel, IReadOnlyCollection declaredVariables) + { + var source = Str(panel, "source"); + if (string.IsNullOrEmpty(source) || !MeasureCatalog.IsKnownSource(source)) + { + return (null, $"panel references unknown source '{source}'."); + } + + /* measure XOR ratio. */ + var measureKey = Str(panel, "measure"); + var ratioKey = Str(panel, "ratio"); + if (measureKey is not null && ratioKey is not null) + { + return (null, "panel must set exactly one of 'measure' or 'ratio', not both."); + } + + var key = measureKey ?? ratioKey; + var measure = MeasureCatalog.Measure(key); + if (measure is null) + { + return (null, $"panel references unknown measure '{key}'."); + } + + if (!string.Equals(measure.SourceTable, source, StringComparison.Ordinal)) + { + return (null, $"measure '{measure.Key}' is not on source '{source}' (it is on '{measure.SourceTable}')."); + } + + var isRatio = measure.Kind == MeasureKind.Ratio; + if (isRatio && measureKey is not null) + { + return (null, $"'{measure.Key}' is a ratio; reference it as 'ratio', not 'measure'."); + } + + if (!isRatio && ratioKey is not null) + { + return (null, $"'{measure.Key}' is not a ratio; reference it as 'measure', not 'ratio'."); + } + + /* aggregate (scalar only; a ratio's aggregation is fixed). */ + var aggregate = measure.DefaultTimeAgg; + if (!isRatio) + { + var aggWire = Str(panel, "aggregate"); + if (aggWire is null) + { + return (null, $"panel is missing 'aggregate' (one of {string.Join(", ", MeasureCatalog.AggregateWireNames)})."); + } + + if (!MeasureCatalog.TryParseAggregate(aggWire, out aggregate)) + { + return (null, $"panel has unknown aggregate '{aggWire}'."); + } + + if (!measure.ValidAggs.Contains(aggregate)) + { + return (null, $"aggregate '{aggWire}' is not valid for measure '{measure.Key}'."); + } + + /* Defense in depth over ValidAggs: percentile_cont is legal ONLY on a per-event measure. */ + if (aggregate == ComposeAggregate.PercentileCont && measure.Archetype != MeasureArchetype.PerEvent) + { + return (null, "percentile_cont is only valid on per-event measures."); + } + } + + /* unit ∈ the measure's family — except COUNT, which is a plain row count (unitless) regardless + of the measure's family, so its only legal unit is 'count'. */ + string unit; + if (!isRatio && aggregate == ComposeAggregate.Count) + { + unit = Str(panel, "unit") ?? MeasureCatalog.FamilyCount; + if (!string.Equals(unit, MeasureCatalog.FamilyCount, StringComparison.Ordinal)) + { + return (null, "a count aggregate is unitless; its unit must be 'count'."); + } + } + else + { + unit = Str(panel, "unit") ?? measure.DefaultUnit; + var family = MeasureCatalog.Family(measure.UnitFamily); + if (family is null || !family.Has(unit)) + { + return (null, $"unit '{unit}' is not valid for measure '{measure.Key}' (family '{measure.UnitFamily}')."); + } + } + + /* mode: a real timeBucket => time series; else topN => ranked; else scalar. */ + var bucket = ComposeTimeBucket.None; + var bucketWire = Str(panel, "timeBucket"); + if (bucketWire is not null && !MeasureCatalog.TryParseTimeBucket(bucketWire, out bucket)) + { + return (null, $"panel has unknown timeBucket '{bucketWire}'."); + } + + var hasBucket = bucket != ComposeTimeBucket.None; + var hasTopN = panel["topN"] is JsonValue; + var topN = 0; + if (hasTopN) + { + if (panel["topN"] is not JsonValue topNValue || !topNValue.TryGetValue(out topN) || topN < 1) + { + return (null, "panel.topN must be a positive integer."); + } + + topN = Math.Min(topN, ComposeLimits.MaxTopN); + } + + if (hasBucket && hasTopN) + { + return (null, "panel cannot set both 'timeBucket' and 'topN' (a ranked panel is not a time series)."); + } + + var mode = hasBucket ? PanelMode.TimeSeries : hasTopN ? PanelMode.Ranked : PanelMode.Scalar; + + /* viz — the v2 composed-panel vocabulary (distinct from v1 read panels' KnownViz); coherence with the + panel's mode is checked below once groupBy is known (design §4). */ + var viz = ParseViz(panel["viz"]); + if (viz is null || !KnownComposeViz.Contains(viz)) + { + return (null, $"panel has an unknown or missing viz type (one of {string.Join(", ", ComposeVizList)})."); + } + + /* filters. */ + var (filters, filterError) = ParseFilters(panel["filters"], source, measure, declaredVariables); + if (filterError is not null) + { + return (null, filterError); + } + + /* groupBy. */ + var (groupBy, groupError) = ParseGroupBy(panel["groupBy"], source, measure); + if (groupError is not null) + { + return (null, groupError); + } + + /* viz ↔ mode coherence, so a stored def can never be un-renderable (design §4): line/area/stacked are + time series, bar/pie are ranked, stat is a single scalar (table works in any mode). */ + var vizModeError = ValidateVizMode(viz, mode, groupBy!.Count); + if (vizModeError is not null) + { + return (null, vizModeError); + } + + /* thresholds — optional render-only reference lines (design D3). Validated here so a stored def can't + carry a non-number/NaN/over-long list, but NOT compiled: the frontend draws them, so they never enter + the compiler/SQL. */ + var (thresholds, thresholdError) = ParseThresholds(panel["thresholds"]); + if (thresholdError is not null) + { + return (null, thresholdError); + } + + /* annotations — optional event-marker overlays (design D5), each a known annotation-source KEY, capped, + and only meaningful on a time-series panel (a marker overlay needs a time axis — rejected on a + ranked/scalar panel so a stored def is never un-renderable). They do NOT change the measure query: the + compiler emits a separate bounded event query per source. */ + var (annotations, annotationError) = ParseAnnotations(panel["annotations"], mode); + if (annotationError is not null) + { + return (null, annotationError); + } + + var plan = new PanelPlan + { + Measure = measure, + Aggregate = aggregate, + Unit = unit, + Mode = mode, + TimeBucket = bucket, + TopN = topN, + Filters = filters!, + GroupBy = groupBy!, + Viz = viz, + Thresholds = thresholds!, + Annotations = annotations!, + }; + + return (plan, null); + } + + private static (IReadOnlyList? Filters, string? Error) ParseFilters( + JsonNode? node, string source, ComposeMeasure measure, IReadOnlyCollection declaredVariables) + { + if (node is null) + { + return (Array.Empty(), null); + } + + if (node is not JsonArray array) + { + return (null, "panel.filters must be an array."); + } + + if (array.Count > ComposeLimits.MaxFilters) + { + return (null, $"panel has {array.Count} filters; the maximum is {ComposeLimits.MaxFilters}."); + } + + var filters = new List(); + for (var i = 0; i < array.Count; i++) + { + if (array[i] is not JsonObject obj) + { + return (null, $"filter {i} must be an object."); + } + + var dimName = Str(obj, "dimension"); + var dimension = dimName is null ? null : MeasureCatalog.Dimension(source, dimName); + if (dimension is null || !IsDimensionAllowed(dimName!, measure)) + { + return (null, $"filter {i} references dimension '{dimName}', which is not allowed for measure '{measure.Key}'."); + } + + var opWire = Str(obj, "op"); + if (opWire is null || !MeasureCatalog.TryParseFilterOp(opWire, out var op)) + { + return (null, $"filter {i} has unknown op '{opWire}'."); + } + + if (op == ComposeFilterOp.Like && !dimension.Likeable) + { + return (null, $"filter {i}: op 'like' is not allowed on dimension '{dimName}'."); + } + + var (value, valueError) = ParseFilterValue(obj["value"], op, i, declaredVariables); + if (valueError is not null) + { + return (null, valueError); + } + + filters.Add(new ComposeFilter(dimension, op, value!)); + } + + return (filters, null); + } + + private static (ComposeFilterValue? Value, string? Error) ParseFilterValue( + JsonNode? node, ComposeFilterOp op, int index, IReadOnlyCollection declaredVariables) + { + if (node is null) + { + return (null, $"filter {index} is missing 'value'."); + } + + var allowsMany = op is ComposeFilterOp.Eq or ComposeFilterOp.Neq; + + if (node is JsonValue value) + { + if (!value.TryGetValue(out var text) || text is null) + { + /* A numeric/boolean literal is coerced to its text form (the columns are text). */ + text = value.ToString(); + } + + if (text.StartsWith("$", StringComparison.Ordinal)) + { + var varName = text.Substring(1); + if (varName.Length == 0 || !declaredVariables.Contains(varName)) + { + return (null, $"filter {index} references undeclared variable '{text}'."); + } + + return (new ComposeFilterValue(null, varName), null); + } + + return (new ComposeFilterValue(new[] { text }, null), null); + } + + if (node is JsonArray array) + { + if (!allowsMany) + { + return (null, $"filter {index}: op '{MeasureCatalog.WireName(op)}' takes a single value, not a list."); + } + + if (array.Count == 0) + { + return (null, $"filter {index} has an empty value list."); + } + + var literals = new List(array.Count); + foreach (var item in array) + { + if (item is not JsonValue itemValue) + { + return (null, $"filter {index} value list must contain only strings."); + } + + literals.Add(itemValue.TryGetValue(out var s) && s is not null ? s : itemValue.ToString()); + } + + return (new ComposeFilterValue(literals, null), null); + } + + return (null, $"filter {index} has an invalid 'value'."); + } + + private static (IReadOnlyList? GroupBy, string? Error) ParseGroupBy( + JsonNode? node, string source, ComposeMeasure measure) + { + if (node is null) + { + return (Array.Empty(), null); + } + + if (node is not JsonArray array) + { + return (null, "panel.groupBy must be an array."); + } + + if (array.Count > ComposeLimits.MaxGroupBy) + { + return (null, $"panel has {array.Count} groupBy dimensions; the maximum is {ComposeLimits.MaxGroupBy}."); + } + + var dims = new List(); + var seen = new HashSet(StringComparer.Ordinal); + for (var i = 0; i < array.Count; i++) + { + var name = (array[i] as JsonValue)?.GetValue(); + var dimension = name is null ? null : MeasureCatalog.Dimension(source, name); + if (dimension is null || !IsDimensionAllowed(name!, measure)) + { + return (null, $"groupBy references dimension '{name}', which is not allowed for measure '{measure.Key}'."); + } + + if (!seen.Add(name!)) + { + return (null, $"groupBy lists dimension '{name}' more than once."); + } + + dims.Add(dimension); + } + + return (dims, null); + } + + /// Parses + validates a panel's optional thresholds array (design D3): 0-4 finite numbers, + /// in the panel's chosen unit, for render-only reference lines. Rejects a non-array, a non-number, + /// NaN/Infinity, or an over-long list. The values are validated but never compiled — the frontend draws + /// them — so a stored definition can never carry an un-renderable threshold. + private static (IReadOnlyList? Thresholds, string? Error) ParseThresholds(JsonNode? node) + { + if (node is null) + { + return (Array.Empty(), null); + } + + if (node is not JsonArray array) + { + return (null, "panel.thresholds must be an array."); + } + + if (array.Count > ComposeLimits.MaxThresholds) + { + return (null, $"panel has {array.Count} thresholds; the maximum is {ComposeLimits.MaxThresholds}."); + } + + var values = new List(array.Count); + for (var i = 0; i < array.Count; i++) + { + if (array[i] is not JsonValue value || !value.TryGetValue(out var number) || !double.IsFinite(number)) + { + return (null, $"threshold {i} must be a finite number."); + } + + values.Add(number); + } + + return (values, null); + } + + /// Parses + validates a panel's optional annotations array (design D5): 0- known annotation-source KEYS (deduped) to overlay as event markers. + /// Rejects a non-array, a non-string/unknown key, a duplicate, or an over-long list — and rejects ANY + /// annotation on a non-time-series panel (a marker overlay needs a time axis, so a stored def is never + /// un-renderable). Returns the catalog-resolved sources, which the compiler emits schema-qualified; the + /// caller supplies only a key, never a table/column. + private static (IReadOnlyList? Annotations, string? Error) ParseAnnotations(JsonNode? node, PanelMode mode) + { + if (node is null) + { + return (Array.Empty(), null); + } + + if (node is not JsonArray array) + { + return (null, "panel.annotations must be an array."); + } + + if (array.Count == 0) + { + return (Array.Empty(), null); + } + + if (mode != PanelMode.TimeSeries) + { + return (null, "annotations are only valid on a time-series panel (add a timeBucket)."); + } + + if (array.Count > ComposeLimits.MaxAnnotations) + { + return (null, $"panel has {array.Count} annotations; the maximum is {ComposeLimits.MaxAnnotations}."); + } + + var sources = new List(array.Count); + var seen = new HashSet(StringComparer.Ordinal); + for (var i = 0; i < array.Count; i++) + { + var key = array[i] is JsonValue value && value.TryGetValue(out var s) ? s : null; + var source = MeasureCatalog.AnnotationSource(key); + if (source is null) + { + return (null, $"annotation {i} references unknown source '{key}'."); + } + + if (!seen.Add(key!)) + { + return (null, $"annotation source '{key}' is listed more than once."); + } + + sources.Add(source); + } + + return (sources, null); + } + + private static string? ParseViz(JsonNode? node) => node switch + { + JsonValue value when value.TryGetValue(out var s) => s, + JsonObject obj => Str(obj, "type"), + _ => null, + }; + + /// The universal server axis (fleet / multi-server) is usable by EVERY measure; any other + /// dimension must be in the measure's . + private static bool IsDimensionAllowed(string dimensionName, ComposeMeasure measure) => + string.Equals(dimensionName, MeasureCatalog.ServerDimensionName, StringComparison.Ordinal) + || measure.AllowedDimensions.Contains(dimensionName); + + /// Rejects a viz that cannot render the panel's shape (design §4 shape-steering, enforced so a + /// stored def is never un-renderable): line/area/stacked/stacked-bar are time series; bar/pie are ranked + /// (topN) with a categorical group; stat is a single scalar; table renders any shape. A stacked (or + /// stacked-bar) chart also needs a group-by (the parts that stack), and a single-value panel cannot group. + private static string? ValidateVizMode(string viz, PanelMode mode, int groupByCount) + { + if (string.Equals(viz, "table", StringComparison.Ordinal)) + { + return null; + } + + switch (mode) + { + case PanelMode.TimeSeries: + if (viz is not ("line" or "area" or "stacked" or "stacked-bar")) + { + return $"a '{viz}' chart is not a time series; use line/area/stacked/stacked-bar (or drop the timeBucket)."; + } + + if (viz is ("stacked" or "stacked-bar") && groupByCount == 0) + { + return "a stacked chart needs a group-by dimension (the parts that stack)."; + } + + return null; + + case PanelMode.Ranked: + if (viz is not ("bar" or "pie")) + { + return $"a '{viz}' chart cannot render a ranked (topN) panel; use bar or pie."; + } + + if (groupByCount == 0) + { + return $"a '{viz}' chart needs a group-by dimension (the categories to rank)."; + } + + return null; + + case PanelMode.Scalar: + if (groupByCount > 0) + { + return "a single-value panel cannot group by a dimension; add a timeBucket (time series) or a topN (ranked)."; + } + + return string.Equals(viz, "stat", StringComparison.Ordinal) + ? null + : $"a '{viz}' chart needs a group or time bucket; a single value uses stat (or table)."; + + default: + return null; + } + } + + private static string? Str(JsonObject obj, string key) => + obj[key] is JsonValue value && value.TryGetValue(out var s) ? s : null; +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Compose/MeasureCatalog.cs b/Darling/PerformanceMonitor.Darling.Service/Compose/MeasureCatalog.cs new file mode 100644 index 000000000..1affbe04f --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Compose/MeasureCatalog.cs @@ -0,0 +1,1212 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// How a measure's underlying column behaves over time — the single fact that decides which +/// aggregations are legal and which physical column the compiler aggregates. The collector schema +/// () carries ONLY {name, type, +/// precision, scale}; the archetype (and unit, dimension roles) are this HAND-AUTHORED layer on top, +/// pinned to the collector's real columns by DarlingComposeTests. +/// +public enum MeasureArchetype +{ + /// An ever-increasing counter (e.g. wait_time_ms) that ships with a paired + /// delta_* column. Aggregation operates on the DELTA (summing a raw counter is meaningless); + /// SUM(delta) over a bucket is throughput, AVG/MIN/MAX(delta) the per-sample spread. + Cumulative, + + /// A column that is ALREADY a per-interval delta (e.g. delta_wait_time_ms read as a + /// first-class measure). Aggregation operates on the column itself. + Delta, + + /// A point-in-time reading (e.g. sqlserver_cpu_utilization percent). AVG/MIN/MAX only — + /// summing a gauge over time is a category error (the grain-trap the cpu measure exists to prove). + Gauge, + + /// One row per event (e.g. long_query_completions). COUNT(*) for the event rate, plus + /// AVG/MIN/MAX and percentile_cont over an event column. The only archetype percentile is legal on. + PerEvent, +} + +/// Whether a measure is a single column-backed metric or a numerator/denominator ratio. +public enum MeasureKind +{ + /// A single column (or its delta) aggregated by one function. + Scalar, + + /// A ratio of two scalar measures on the SAME source — compiled as + /// SUM(num)::float / NULLIF(SUM(den), 0). + Ratio, +} + +/// How a combines its operands over the window. +public enum MeasureRatioMode +{ + /// SUM(num) / NULLIF(SUM(den), 0) — the cumulative/delta form (operands have a non-null + /// ): a throughput or execution-weighted ratio. + Sum, + + /// AVG(num) / NULLIF(AVG(den), 0) — the gauge form: a ratio of two point-in-time readings + /// (both operands must be , whose AggregationColumn is null). + Avg, + + /// SUM(value * weight) / NULLIF(SUM(weight), 0) — the execution-WEIGHTED average of a + /// column that is ITSELF a pre-aggregated per-interval average (e.g. Query Store avg_duration_us, + /// weighted by execution_count). This is the correct weighted mean across pre-aggregated intervals, + /// NOT a plain avg-of-avgs (which over-weights sparse intervals — design §2). Its two operands are RAW + /// source columns — (the per-interval average) and + /// (the interval's execution weight) — not numerator/denominator + /// measures, because the pre-aggregated averages are deliberately NOT exposed as summable scalar measures. + Weighted, +} + +/// The fixed aggregation vocabulary. percentile_cont is legal ONLY on +/// measures (the compiler enforces it regardless of the +/// measure's declared ). +public enum ComposeAggregate +{ + Sum, + Avg, + Min, + Max, + Count, + PercentileCont, +} + +/// The fixed time-bucket vocabulary. is the ranked / scalar mode (no +/// bucketing); the rest map 1:1 to a Postgres date_trunc unit. +public enum ComposeTimeBucket +{ + None, + Minute, + Hour, + Day, +} + +/// The fixed filter-operator vocabulary — each maps to a compile-time SQL operator string +/// (never a user string), and the value is ALWAYS a bound parameter. +public enum ComposeFilterOp +{ + /// col = ANY($n) — value is one literal or a list (multi-select). + Eq, + + /// col <> ALL($n). + Neq, + + /// col LIKE $n — only on a dimension. + Like, + + Gt, + Gte, + Lt, + Lte, +} + +/// One unit inside a . is how many of +/// the family's smallest unit this unit equals, so converting a value from unit A to unit B is +/// value * A.BaseFactor / B.BaseFactor (both in the same family). +public sealed record ComposeUnit(string Name, double BaseFactor); + +/// A convertible unit family — the compiler validates a spec's unit is one of these and +/// scales the aggregate by the from→to factor; the catalog serves the list so the composer's unit +/// picker matches. +public sealed record ComposeUnitFamily(string Name, IReadOnlyList Units) +{ + public ComposeUnit? Unit(string name) => + Units.FirstOrDefault(u => string.Equals(u.Name, name, StringComparison.Ordinal)); + + public bool Has(string name) => Unit(name) is not null; +} + +/// +/// One dimension a source can be filtered or grouped by. is the physical column +/// (usually == ); gates the LIKE operator; +/// marks the query_stats object_name, which is not a query_stats +/// column at all but is stitched read-time from procedure_stats via the #1568 sql_handle join +/// (window-bounded by the compiler). +/// +public sealed record ComposeDimension(string SourceTable, string Name, string Column, bool Likeable, bool ViaModuleJoin = false); + +/// +/// One event-annotation SOURCE (design D5): a collector event table whose rows can be overlaid as point +/// markers on a time-series panel. is the event's OWN time column (when the event +/// happened — e.g. deadlock_time, not the collector's collection_time) and +/// a short text column for the marker tooltip. Both — like a measure's columns — +/// are pinned to the owning collector's PayloadColumns by DarlingComposeTests, so the annotation +/// compiler emits them schema-qualified (collect.<table>) and never touches a caller string. +/// +public sealed record ComposeAnnotationSource( + string Key, + string DisplayName, + string Category, + string SourceTable, + string TimeColumn, + string LabelColumn); + +/// +/// One measure — a named, composable metric. Everything identifier-bearing (, +/// , , ) is validated against +/// the collector catalog by test, so the compiler can trust every field is a real column and emit it +/// schema-qualified without ever touching a user string. +/// +public sealed record ComposeMeasure +{ + public required string Key { get; init; } + public required string DisplayName { get; init; } + public required string Category { get; init; } + public required string SourceTable { get; init; } + public MeasureKind Kind { get; init; } = MeasureKind.Scalar; + + /// Scalar only — decides the summable column + legal aggregations. + public MeasureArchetype Archetype { get; init; } + + /// Scalar base column (the raw counter / gauge / delta / event column). Null for a ratio. + public string? Column { get; init; } + + /// For a measure, its paired delta_* column — + /// the column aggregation actually operates on. Null otherwise. + public string? DeltaColumn { get; init; } + + /// Ratio numerator/denominator — both must be Scalar measures on the SAME source. + public string? NumeratorKey { get; init; } + public string? DenominatorKey { get; init; } + + /// How a ratio combines its operands ( only): the default + /// for cumulative/delta operands, + /// for gauge operands, or for the execution-weighted average of a + /// pre-aggregated per-interval average. + public MeasureRatioMode RatioMode { get; init; } = MeasureRatioMode.Sum; + + /// For a ratio: the raw source column that is itself a + /// pre-aggregated per-interval AVERAGE (e.g. Query Store avg_duration_us), whose execution-weighted + /// mean is computed as SUM(value * weight) / SUM(weight). A real payload column of the source + /// (pinned by test); null for every other kind/mode. + public string? WeightedValueColumn { get; init; } + + /// For a ratio: the raw source column each interval's + /// is weighted by (the interval's execution count, e.g. + /// execution_count). A real payload column of the source (pinned by test); null otherwise. + public string? WeightColumn { get; init; } + + public required string NativeUnit { get; init; } + public required string DefaultUnit { get; init; } + public required string UnitFamily { get; init; } + + public ComposeAggregate DefaultTimeAgg { get; init; } + public required IReadOnlyList ValidAggs { get; init; } + public required IReadOnlyList AllowedDimensions { get; init; } + + /// + /// The column a SUM/AVG/MIN/MAX operates on for a scalar measure: the delta for a Cumulative counter, + /// the column itself for a Delta/PerEvent measure, and null for a Gauge (never summable — the + /// grain-trap) or a Ratio (handled through its numerator/denominator). + /// + public string? AggregationColumn => Archetype switch + { + MeasureArchetype.Cumulative => DeltaColumn, + MeasureArchetype.Delta => Column, + MeasureArchetype.PerEvent => Column, + _ => null, + }; +} + +/// +/// The hand-authored catalog of named measures + dimensions + unit families that Custom Views v2 composes +/// from — the v2 twin of DarlingWebEndpoints.CatalogDescriptors (the v1 read allowlist). It is the +/// SOLE source of identifiers the compiler will emit: a measure names a real collector table + column, so +/// the compiler never accepts a table/column from the caller. DarlingComposeTests pins every +/// (table, column) against the owning collector's PayloadColumns, every measure's time column == +/// the collector's PrefixTimeColumnName, gauges as non-summable, and ratios as referencing real +/// same-source scalars — so the catalog can never drift from the collectors it reads. +/// +/// B1 authors a vertical SLICE (six tables) proving every archetype end-to-end; B3 fills the +/// remaining ~30 collector tables against this same model. +/// +public static class MeasureCatalog +{ + /* ─────────────────────────── unit families ─────────────────────────── */ + + public const string FamilyDuration = "duration"; + public const string FamilyBytes = "bytes"; + public const string FamilyPercent = "percent"; + public const string FamilyCount = "count"; + public const string FamilyFraction = "fraction"; + + /// The convertible unit families. Duration base = microsecond, bytes base = byte, fraction + /// base = percent (so ratio = 100 percent). percent/count are single-unit (no conversion). + public static readonly IReadOnlyList UnitFamilies = new[] + { + new ComposeUnitFamily(FamilyDuration, new[] + { + new ComposeUnit("us", 1), + new ComposeUnit("ms", 1_000), + new ComposeUnit("s", 1_000_000), + new ComposeUnit("min", 60_000_000), + }), + new ComposeUnitFamily(FamilyBytes, new[] + { + new ComposeUnit("bytes", 1), + new ComposeUnit("kb", 1024), + new ComposeUnit("mb", 1024L * 1024), + new ComposeUnit("gb", 1024L * 1024 * 1024), + /* 8-KB data pages, for page-count columns (none in the B1 slice; here for B3). */ + new ComposeUnit("pages", 8192), + }), + new ComposeUnitFamily(FamilyPercent, new[] { new ComposeUnit("percent", 1) }), + new ComposeUnitFamily(FamilyCount, new[] { new ComposeUnit("count", 1) }), + new ComposeUnitFamily(FamilyFraction, new[] + { + new ComposeUnit("percent", 1), + new ComposeUnit("ratio", 100), + }), + }; + + private static readonly Dictionary s_familyByName = + UnitFamilies.ToDictionary(f => f.Name, StringComparer.Ordinal); + + public static ComposeUnitFamily? Family(string name) => + s_familyByName.TryGetValue(name, out var f) ? f : null; + + /* ─────────────────────────── dimensions ─────────────────────────── */ + + /// Every dimension a slice source can be filtered / grouped by. Keyed by (source, name). + /// object_name on query_stats is the ONLY entry — + /// it is not a query_stats column; the compiler stitches it from procedure_stats (#1568). + public static readonly IReadOnlyList Dimensions = new[] + { + new ComposeDimension("wait_stats", "wait_type", "wait_type", Likeable: true), + + new ComposeDimension("procedure_stats", "database_name", "database_name", Likeable: true), + new ComposeDimension("procedure_stats", "schema_name", "schema_name", Likeable: true), + new ComposeDimension("procedure_stats", "object_name", "object_name", Likeable: true), + + new ComposeDimension("query_stats", "database_name", "database_name", Likeable: true), + new ComposeDimension("query_stats", "query_hash", "query_hash", Likeable: false), + /* #1568: not a query_stats column — stitched from procedure_stats.object_name via sql_handle. */ + new ComposeDimension("query_stats", "object_name", "object_name", Likeable: true, ViaModuleJoin: true), + + new ComposeDimension("file_io_stats", "database_name", "database_name", Likeable: true), + new ComposeDimension("file_io_stats", "file_name", "file_name", Likeable: true), + + new ComposeDimension("long_query_completions", "database_name", "database_name", Likeable: true), + new ComposeDimension("long_query_completions", "object_name", "object_name", Likeable: true), + new ComposeDimension("long_query_completions", "result", "result", Likeable: false), + + /* ── B3 dimensions (every column pinned to the collector's PayloadColumns by test) ── */ + new ComposeDimension("latch_stats", "latch_class", "latch_class", Likeable: true), + new ComposeDimension("spinlock_stats", "spinlock_name", "spinlock_name", Likeable: true), + new ComposeDimension("memory_clerks", "clerk_type", "clerk_type", Likeable: true), + new ComposeDimension("plan_cache_stats", "cacheobjtype", "cacheobjtype", Likeable: true), + new ComposeDimension("plan_cache_stats", "objtype", "objtype", Likeable: true), + + new ComposeDimension("database_size_stats", "database_name", "database_name", Likeable: true), + new ComposeDimension("database_size_stats", "file_name", "file_name", Likeable: true), + new ComposeDimension("database_size_stats", "file_type_desc", "file_type_desc", Likeable: true), + new ComposeDimension("database_size_stats", "recovery_model_desc", "recovery_model_desc", Likeable: true), + + new ComposeDimension("index_object_stats", "database_name", "database_name", Likeable: true), + new ComposeDimension("index_object_stats", "schema_name", "schema_name", Likeable: true), + new ComposeDimension("index_object_stats", "table_name", "table_name", Likeable: true), + new ComposeDimension("index_object_stats", "index_name", "index_name", Likeable: true), + new ComposeDimension("index_object_stats", "index_type_desc", "index_type_desc", Likeable: true), + + new ComposeDimension("session_stats", "program_name", "program_name", Likeable: true), + + new ComposeDimension("waiting_tasks", "wait_type", "wait_type", Likeable: true), + new ComposeDimension("waiting_tasks", "database_name", "database_name", Likeable: true), + + new ComposeDimension("query_snapshots", "database_name", "database_name", Likeable: true), + new ComposeDimension("query_snapshots", "status", "status", Likeable: true), + new ComposeDimension("query_snapshots", "wait_type", "wait_type", Likeable: true), + new ComposeDimension("query_snapshots", "program_name", "program_name", Likeable: true), + new ComposeDimension("query_snapshots", "login_name", "login_name", Likeable: true), + new ComposeDimension("query_snapshots", "host_name", "host_name", Likeable: true), + + new ComposeDimension("blocked_process_reports", "database_name", "database_name", Likeable: true), + new ComposeDimension("blocked_process_reports", "contentious_object", "contentious_object", Likeable: true), + new ComposeDimension("blocked_process_reports", "lock_mode", "lock_mode", Likeable: true), + + new ComposeDimension("dmv_blocking_snapshots", "database_name", "database_name", Likeable: true), + new ComposeDimension("dmv_blocking_snapshots", "contentious_object", "contentious_object", Likeable: true), + new ComposeDimension("dmv_blocking_snapshots", "lock_mode", "lock_mode", Likeable: true), + new ComposeDimension("dmv_blocking_snapshots", "blocking_status", "blocking_status", Likeable: true), + + new ComposeDimension("deadlocks", "database_name", "database_name", Likeable: true), + + new ComposeDimension("system_health_events", "event_type", "event_type", Likeable: true), + + new ComposeDimension("default_trace_events", "event_name", "event_name", Likeable: true), + new ComposeDimension("default_trace_events", "database_name", "database_name", Likeable: true), + new ComposeDimension("default_trace_events", "object_name", "object_name", Likeable: true), + new ComposeDimension("default_trace_events", "login_name", "login_name", Likeable: true), + + new ComposeDimension("running_jobs", "job_name", "job_name", Likeable: true), + + new ComposeDimension("job_history", "job_name", "job_name", Likeable: true), + new ComposeDimension("job_history", "step_name", "step_name", Likeable: true), + new ComposeDimension("job_history", "run_status_desc", "run_status_desc", Likeable: true), + new ComposeDimension("job_history", "category_name", "category_name", Likeable: true), + + new ComposeDimension("perfmon_stats", "object_name", "object_name", Likeable: true), + new ComposeDimension("perfmon_stats", "counter_name", "counter_name", Likeable: true), + new ComposeDimension("perfmon_stats", "instance_name", "instance_name", Likeable: true), + + new ComposeDimension("query_store_stats", "database_name", "database_name", Likeable: true), + new ComposeDimension("query_store_stats", "module_name", "module_name", Likeable: true), + new ComposeDimension("query_store_stats", "query_hash", "query_hash", Likeable: false), + }; + + private static readonly Dictionary<(string Source, string Name), ComposeDimension> s_dimByKey = + Dimensions.ToDictionary(d => (d.SourceTable, d.Name)); + + /// + /// The universal server dimension name. server_name is a prefix column on EVERY collector + /// table, so server is filterable + group-by-able on every measure (the fleet / multi-server axis, Erik's + /// Decision 1 + acceptance Flow B). It is a virtual dimension — allowed for every measure without appearing + /// in each one's — resolved by for + /// any source and validated as always-allowed by . + /// + public const string ServerDimensionName = "server"; + + /// The virtual server dimension for a source (server_name; exact-match only, not LIKE-able). + public static ComposeDimension ServerDimension(string source) => + new(source, ServerDimensionName, "server_name", Likeable: false); + + /// The dimension for (, ), or null. The universal + /// server dimension resolves for any known source. + public static ComposeDimension? Dimension(string source, string name) + { + if (string.Equals(name, ServerDimensionName, StringComparison.Ordinal)) + { + return IsKnownSource(source) ? ServerDimension(source) : null; + } + + return s_dimByKey.TryGetValue((source, name), out var d) ? d : null; + } + + /* ─────────────────────────── measures ─────────────────────────── */ + + private static readonly ComposeAggregate[] CumulativeAggs = { ComposeAggregate.Sum, ComposeAggregate.Avg, ComposeAggregate.Min, ComposeAggregate.Max }; + private static readonly ComposeAggregate[] GaugeAggs = { ComposeAggregate.Avg, ComposeAggregate.Min, ComposeAggregate.Max }; + private static readonly ComposeAggregate[] PerEventAggs = { ComposeAggregate.Count, ComposeAggregate.Sum, ComposeAggregate.Avg, ComposeAggregate.Min, ComposeAggregate.Max, ComposeAggregate.PercentileCont }; + private static readonly ComposeAggregate[] NoAggs = Array.Empty(); + /* Pure event-count tables (deadlocks, system_health) carry no numeric per-event column worth aggregating — + only COUNT(*) is meaningful. Their measure names a real (non-numeric) column to satisfy the schema pin; + the compiler's COUNT path never reads it. */ + private static readonly ComposeAggregate[] CountOnlyAggs = { ComposeAggregate.Count }; + + private const string CatWaits = "Waits"; + private const string CatCpu = "CPU"; + private const string CatProcedures = "Procedures"; + private const string CatQueries = "Queries"; + private const string CatFileIo = "File I/O"; + private const string CatLongQueries = "Long Queries"; + /* ── B3 categories ── */ + private const string CatLatch = "Latches & Spinlocks"; + private const string CatScheduler = "CPU Scheduler"; + private const string CatPlanCache = "Plan Cache"; + private const string CatMemory = "Memory"; + private const string CatTempdb = "TempDB"; + private const string CatDatabaseSize = "Database Size"; + private const string CatIndexes = "Indexes & Objects"; + private const string CatSessions = "Sessions"; + private const string CatBlocking = "Blocking & Deadlocks"; + private const string CatSystemHealth = "System Health"; + private const string CatDefaultTrace = "Default Trace"; + private const string CatJobs = "Agent Jobs"; + private const string CatPerfmon = "Perfmon Counters"; + private const string CatQueryStore = "Query Store"; + + private static readonly string[] WaitDims = { "wait_type" }; + private static readonly string[] ProcDims = { "database_name", "schema_name", "object_name" }; + private static readonly string[] QueryDims = { "database_name", "query_hash", "object_name" }; + private static readonly string[] FileIoDims = { "database_name", "file_name" }; + private static readonly string[] LqcDims = { "database_name", "object_name", "result" }; + private static readonly string[] NoDims = Array.Empty(); + /* ── B3 per-table dimension allow-lists (server is universal, added separately) ── */ + private static readonly string[] LatchDims = { "latch_class" }; + private static readonly string[] SpinlockDims = { "spinlock_name" }; + private static readonly string[] ClerkDims = { "clerk_type" }; + private static readonly string[] PlanCacheDims = { "cacheobjtype", "objtype" }; + private static readonly string[] DbSizeDims = { "database_name", "file_name", "file_type_desc", "recovery_model_desc" }; + private static readonly string[] IndexDims = { "database_name", "schema_name", "table_name", "index_name", "index_type_desc" }; + private static readonly string[] SessionStatsDims = { "program_name" }; + private static readonly string[] WaitingTaskDims = { "wait_type", "database_name" }; + private static readonly string[] QuerySnapshotDims = { "database_name", "status", "wait_type", "program_name", "login_name", "host_name" }; + private static readonly string[] BlockingDims = { "database_name", "contentious_object", "lock_mode" }; + private static readonly string[] DmvBlockingDims = { "database_name", "contentious_object", "lock_mode", "blocking_status" }; + private static readonly string[] DeadlockDims = { "database_name" }; + private static readonly string[] SysHealthDims = { "event_type" }; + private static readonly string[] DefaultTraceDims = { "event_name", "database_name", "object_name", "login_name" }; + private static readonly string[] RunningJobDims = { "job_name" }; + private static readonly string[] JobHistoryDims = { "job_name", "step_name", "run_status_desc", "category_name" }; + private static readonly string[] PerfmonDims = { "object_name", "counter_name", "instance_name" }; + private static readonly string[] QueryStoreDims = { "database_name", "module_name", "query_hash" }; + + /// The catalog. Every measure's SourceTable is a real collector; every Column/DeltaColumn is a + /// real payload column of that collector (pinned by test). + public static readonly IReadOnlyList Measures = new[] + { + /* ── wait_stats (server-grain; dimension wait_type) — Cumulative + Delta + Ratio ── */ + new ComposeMeasure + { + Key = "wait_time_ms", DisplayName = "Wait time", Category = CatWaits, SourceTable = "wait_stats", + Archetype = MeasureArchetype.Cumulative, Column = "wait_time_ms", DeltaColumn = "delta_wait_time_ms", + NativeUnit = "ms", DefaultUnit = "ms", UnitFamily = FamilyDuration, + DefaultTimeAgg = ComposeAggregate.Sum, ValidAggs = CumulativeAggs, AllowedDimensions = WaitDims, + }, + new ComposeMeasure + { + Key = "wait_time_delta_ms", DisplayName = "Wait time (per-sample delta)", Category = CatWaits, SourceTable = "wait_stats", + Archetype = MeasureArchetype.Delta, Column = "delta_wait_time_ms", + NativeUnit = "ms", DefaultUnit = "ms", UnitFamily = FamilyDuration, + DefaultTimeAgg = ComposeAggregate.Sum, ValidAggs = CumulativeAggs, AllowedDimensions = WaitDims, + }, + new ComposeMeasure + { + Key = "signal_wait_time_ms", DisplayName = "Signal wait time", Category = CatWaits, SourceTable = "wait_stats", + Archetype = MeasureArchetype.Cumulative, Column = "signal_wait_time_ms", DeltaColumn = "delta_signal_wait_time_ms", + NativeUnit = "ms", DefaultUnit = "ms", UnitFamily = FamilyDuration, + DefaultTimeAgg = ComposeAggregate.Sum, ValidAggs = CumulativeAggs, AllowedDimensions = WaitDims, + }, + new ComposeMeasure + { + Key = "waiting_tasks", DisplayName = "Waiting tasks", Category = CatWaits, SourceTable = "wait_stats", + Archetype = MeasureArchetype.Cumulative, Column = "waiting_tasks_count", DeltaColumn = "delta_waiting_tasks", + NativeUnit = "count", DefaultUnit = "count", UnitFamily = FamilyCount, + DefaultTimeAgg = ComposeAggregate.Sum, ValidAggs = CumulativeAggs, AllowedDimensions = WaitDims, + }, + new ComposeMeasure + { + Key = "signal_wait_pct", DisplayName = "Signal wait %", Category = CatWaits, SourceTable = "wait_stats", + Kind = MeasureKind.Ratio, NumeratorKey = "signal_wait_time_ms", DenominatorKey = "wait_time_ms", + NativeUnit = "ratio", DefaultUnit = "percent", UnitFamily = FamilyFraction, + ValidAggs = NoAggs, AllowedDimensions = WaitDims, + }, + + /* ── cpu_utilization_stats (server-grain; NO dimensions) — Gauge (the grain-trap) ── */ + new ComposeMeasure + { + Key = "sqlserver_cpu_utilization", DisplayName = "SQL Server CPU %", Category = CatCpu, SourceTable = "cpu_utilization_stats", + Archetype = MeasureArchetype.Gauge, Column = "sqlserver_cpu_utilization", + NativeUnit = "percent", DefaultUnit = "percent", UnitFamily = FamilyPercent, + DefaultTimeAgg = ComposeAggregate.Avg, ValidAggs = GaugeAggs, AllowedDimensions = NoDims, + }, + new ComposeMeasure + { + Key = "other_process_cpu_utilization", DisplayName = "Other-process CPU %", Category = CatCpu, SourceTable = "cpu_utilization_stats", + Archetype = MeasureArchetype.Gauge, Column = "other_process_cpu_utilization", + NativeUnit = "percent", DefaultUnit = "percent", UnitFamily = FamilyPercent, + DefaultTimeAgg = ComposeAggregate.Avg, ValidAggs = GaugeAggs, AllowedDimensions = NoDims, + }, + + /* ── procedure_stats (database + object grain) — Cumulative (µs) ── */ + new ComposeMeasure + { + Key = "proc_elapsed_us", DisplayName = "Procedure elapsed time", Category = CatProcedures, SourceTable = "procedure_stats", + Archetype = MeasureArchetype.Cumulative, Column = "total_elapsed_time", DeltaColumn = "delta_elapsed_time", + NativeUnit = "us", DefaultUnit = "ms", UnitFamily = FamilyDuration, + DefaultTimeAgg = ComposeAggregate.Sum, ValidAggs = CumulativeAggs, AllowedDimensions = ProcDims, + }, + new ComposeMeasure + { + Key = "proc_worker_us", DisplayName = "Procedure CPU (worker) time", Category = CatProcedures, SourceTable = "procedure_stats", + Archetype = MeasureArchetype.Cumulative, Column = "total_worker_time", DeltaColumn = "delta_worker_time", + NativeUnit = "us", DefaultUnit = "ms", UnitFamily = FamilyDuration, + DefaultTimeAgg = ComposeAggregate.Sum, ValidAggs = CumulativeAggs, AllowedDimensions = ProcDims, + }, + new ComposeMeasure + { + Key = "proc_executions", DisplayName = "Procedure executions", Category = CatProcedures, SourceTable = "procedure_stats", + Archetype = MeasureArchetype.Cumulative, Column = "execution_count", DeltaColumn = "delta_execution_count", + NativeUnit = "count", DefaultUnit = "count", UnitFamily = FamilyCount, + DefaultTimeAgg = ComposeAggregate.Sum, ValidAggs = CumulativeAggs, AllowedDimensions = ProcDims, + }, + + /* ── query_stats (database + query_hash grain; object_name via #1568 join) — Cumulative (µs) ── */ + new ComposeMeasure + { + Key = "query_elapsed_us", DisplayName = "Query elapsed time", Category = CatQueries, SourceTable = "query_stats", + Archetype = MeasureArchetype.Cumulative, Column = "total_elapsed_time", DeltaColumn = "delta_elapsed_time", + NativeUnit = "us", DefaultUnit = "ms", UnitFamily = FamilyDuration, + DefaultTimeAgg = ComposeAggregate.Sum, ValidAggs = CumulativeAggs, AllowedDimensions = QueryDims, + }, + new ComposeMeasure + { + Key = "query_worker_us", DisplayName = "Query CPU (worker) time", Category = CatQueries, SourceTable = "query_stats", + Archetype = MeasureArchetype.Cumulative, Column = "total_worker_time", DeltaColumn = "delta_worker_time", + NativeUnit = "us", DefaultUnit = "ms", UnitFamily = FamilyDuration, + DefaultTimeAgg = ComposeAggregate.Sum, ValidAggs = CumulativeAggs, AllowedDimensions = QueryDims, + }, + + /* ── file_io_stats (database + file grain) — Cumulative (bytes + ms) ── */ + new ComposeMeasure + { + Key = "file_read_bytes", DisplayName = "File bytes read", Category = CatFileIo, SourceTable = "file_io_stats", + Archetype = MeasureArchetype.Cumulative, Column = "read_bytes", DeltaColumn = "delta_read_bytes", + NativeUnit = "bytes", DefaultUnit = "mb", UnitFamily = FamilyBytes, + DefaultTimeAgg = ComposeAggregate.Sum, ValidAggs = CumulativeAggs, AllowedDimensions = FileIoDims, + }, + new ComposeMeasure + { + Key = "file_write_bytes", DisplayName = "File bytes written", Category = CatFileIo, SourceTable = "file_io_stats", + Archetype = MeasureArchetype.Cumulative, Column = "write_bytes", DeltaColumn = "delta_write_bytes", + NativeUnit = "bytes", DefaultUnit = "mb", UnitFamily = FamilyBytes, + DefaultTimeAgg = ComposeAggregate.Sum, ValidAggs = CumulativeAggs, AllowedDimensions = FileIoDims, + }, + new ComposeMeasure + { + Key = "file_io_stall_read_ms", DisplayName = "File read stall", Category = CatFileIo, SourceTable = "file_io_stats", + Archetype = MeasureArchetype.Cumulative, Column = "io_stall_read_ms", DeltaColumn = "delta_stall_read_ms", + NativeUnit = "ms", DefaultUnit = "ms", UnitFamily = FamilyDuration, + DefaultTimeAgg = ComposeAggregate.Sum, ValidAggs = CumulativeAggs, AllowedDimensions = FileIoDims, + }, + new ComposeMeasure + { + Key = "file_io_stall_write_ms", DisplayName = "File write stall", Category = CatFileIo, SourceTable = "file_io_stats", + Archetype = MeasureArchetype.Cumulative, Column = "io_stall_write_ms", DeltaColumn = "delta_stall_write_ms", + NativeUnit = "ms", DefaultUnit = "ms", UnitFamily = FamilyDuration, + DefaultTimeAgg = ComposeAggregate.Sum, ValidAggs = CumulativeAggs, AllowedDimensions = FileIoDims, + }, + + /* ── long_query_completions (per-event) — PerEvent (proves count + percentile_cont) ── */ + new ComposeMeasure + { + Key = "lqc_duration_us", DisplayName = "Completion duration", Category = CatLongQueries, SourceTable = "long_query_completions", + Archetype = MeasureArchetype.PerEvent, Column = "duration_microseconds", + NativeUnit = "us", DefaultUnit = "ms", UnitFamily = FamilyDuration, + DefaultTimeAgg = ComposeAggregate.Avg, ValidAggs = PerEventAggs, AllowedDimensions = LqcDims, + }, + new ComposeMeasure + { + Key = "lqc_cpu_time_us", DisplayName = "Completion CPU time", Category = CatLongQueries, SourceTable = "long_query_completions", + Archetype = MeasureArchetype.PerEvent, Column = "cpu_time_microseconds", + NativeUnit = "us", DefaultUnit = "ms", UnitFamily = FamilyDuration, + DefaultTimeAgg = ComposeAggregate.Avg, ValidAggs = PerEventAggs, AllowedDimensions = LqcDims, + }, + + /* ═══════════════════ B3 full catalog fill (remaining collector tables) ═══════════════════ */ + + /* ── latch_stats (Cumulative + delta; dim latch_class) ── */ + new ComposeMeasure + { + Key = "latch_wait_time_ms", DisplayName = "Latch wait time", Category = CatLatch, SourceTable = "latch_stats", + Archetype = MeasureArchetype.Cumulative, Column = "wait_time_ms", DeltaColumn = "delta_wait_time_ms", + NativeUnit = "ms", DefaultUnit = "ms", UnitFamily = FamilyDuration, + DefaultTimeAgg = ComposeAggregate.Sum, ValidAggs = CumulativeAggs, AllowedDimensions = LatchDims, + }, + new ComposeMeasure + { + Key = "latch_waiting_requests", DisplayName = "Latch waiting requests", Category = CatLatch, SourceTable = "latch_stats", + Archetype = MeasureArchetype.Cumulative, Column = "waiting_requests_count", DeltaColumn = "delta_waiting_requests_count", + NativeUnit = "count", DefaultUnit = "count", UnitFamily = FamilyCount, + DefaultTimeAgg = ComposeAggregate.Sum, ValidAggs = CumulativeAggs, AllowedDimensions = LatchDims, + }, + + /* ── spinlock_stats (Cumulative + delta; dim spinlock_name) ── */ + new ComposeMeasure + { + Key = "spinlock_collisions", DisplayName = "Spinlock collisions", Category = CatLatch, SourceTable = "spinlock_stats", + Archetype = MeasureArchetype.Cumulative, Column = "collisions", DeltaColumn = "delta_collisions", + NativeUnit = "count", DefaultUnit = "count", UnitFamily = FamilyCount, + DefaultTimeAgg = ComposeAggregate.Sum, ValidAggs = CumulativeAggs, AllowedDimensions = SpinlockDims, + }, + new ComposeMeasure + { + Key = "spinlock_spins", DisplayName = "Spinlock spins", Category = CatLatch, SourceTable = "spinlock_stats", + Archetype = MeasureArchetype.Cumulative, Column = "spins", DeltaColumn = "delta_spins", + NativeUnit = "count", DefaultUnit = "count", UnitFamily = FamilyCount, + DefaultTimeAgg = ComposeAggregate.Sum, ValidAggs = CumulativeAggs, AllowedDimensions = SpinlockDims, + }, + new ComposeMeasure + { + Key = "spinlock_backoffs", DisplayName = "Spinlock backoffs", Category = CatLatch, SourceTable = "spinlock_stats", + Archetype = MeasureArchetype.Cumulative, Column = "backoffs", DeltaColumn = "delta_backoffs", + NativeUnit = "count", DefaultUnit = "count", UnitFamily = FamilyCount, + DefaultTimeAgg = ComposeAggregate.Sum, ValidAggs = CumulativeAggs, AllowedDimensions = SpinlockDims, + }, + + /* ── cpu_scheduler_stats (Gauge, server-grain) ── */ + new ComposeMeasure + { + Key = "scheduler_runnable_pct", DisplayName = "Runnable-tasks %", Category = CatScheduler, SourceTable = "cpu_scheduler_stats", + Archetype = MeasureArchetype.Gauge, Column = "runnable_percent", + NativeUnit = "percent", DefaultUnit = "percent", UnitFamily = FamilyPercent, + DefaultTimeAgg = ComposeAggregate.Avg, ValidAggs = GaugeAggs, AllowedDimensions = NoDims, + }, + new ComposeMeasure + { + Key = "scheduler_runnable_tasks", DisplayName = "Runnable tasks", Category = CatScheduler, SourceTable = "cpu_scheduler_stats", + Archetype = MeasureArchetype.Gauge, Column = "total_runnable_tasks_count", + NativeUnit = "count", DefaultUnit = "count", UnitFamily = FamilyCount, + DefaultTimeAgg = ComposeAggregate.Avg, ValidAggs = GaugeAggs, AllowedDimensions = NoDims, + }, + + /* ── plan_cache_stats (Gauge; dims cacheobjtype, objtype) ── */ + new ComposeMeasure + { + Key = "plan_cache_total_plans", DisplayName = "Cached plans", Category = CatPlanCache, SourceTable = "plan_cache_stats", + Archetype = MeasureArchetype.Gauge, Column = "total_plans", + NativeUnit = "count", DefaultUnit = "count", UnitFamily = FamilyCount, + DefaultTimeAgg = ComposeAggregate.Avg, ValidAggs = GaugeAggs, AllowedDimensions = PlanCacheDims, + }, + new ComposeMeasure + { + Key = "plan_cache_single_use_plans", DisplayName = "Single-use plans", Category = CatPlanCache, SourceTable = "plan_cache_stats", + Archetype = MeasureArchetype.Gauge, Column = "single_use_plans", + NativeUnit = "count", DefaultUnit = "count", UnitFamily = FamilyCount, + DefaultTimeAgg = ComposeAggregate.Avg, ValidAggs = GaugeAggs, AllowedDimensions = PlanCacheDims, + }, + new ComposeMeasure + { + Key = "plan_cache_size_mb", DisplayName = "Plan-cache size", Category = CatPlanCache, SourceTable = "plan_cache_stats", + Archetype = MeasureArchetype.Gauge, Column = "total_size_mb", + NativeUnit = "mb", DefaultUnit = "mb", UnitFamily = FamilyBytes, + DefaultTimeAgg = ComposeAggregate.Avg, ValidAggs = GaugeAggs, AllowedDimensions = PlanCacheDims, + }, + + /* ── memory_stats (Gauge MB, server-grain) ── */ + new ComposeMeasure + { + Key = "mem_total_server_mb", DisplayName = "Total server memory", Category = CatMemory, SourceTable = "memory_stats", + Archetype = MeasureArchetype.Gauge, Column = "total_server_memory_mb", + NativeUnit = "mb", DefaultUnit = "mb", UnitFamily = FamilyBytes, + DefaultTimeAgg = ComposeAggregate.Avg, ValidAggs = GaugeAggs, AllowedDimensions = NoDims, + }, + new ComposeMeasure + { + Key = "mem_target_server_mb", DisplayName = "Target server memory", Category = CatMemory, SourceTable = "memory_stats", + Archetype = MeasureArchetype.Gauge, Column = "target_server_memory_mb", + NativeUnit = "mb", DefaultUnit = "mb", UnitFamily = FamilyBytes, + DefaultTimeAgg = ComposeAggregate.Avg, ValidAggs = GaugeAggs, AllowedDimensions = NoDims, + }, + new ComposeMeasure + { + Key = "mem_buffer_pool_mb", DisplayName = "Buffer pool", Category = CatMemory, SourceTable = "memory_stats", + Archetype = MeasureArchetype.Gauge, Column = "buffer_pool_mb", + NativeUnit = "mb", DefaultUnit = "mb", UnitFamily = FamilyBytes, + DefaultTimeAgg = ComposeAggregate.Avg, ValidAggs = GaugeAggs, AllowedDimensions = NoDims, + }, + new ComposeMeasure + { + Key = "mem_available_physical_mb", DisplayName = "Available physical memory", Category = CatMemory, SourceTable = "memory_stats", + Archetype = MeasureArchetype.Gauge, Column = "available_physical_memory_mb", + NativeUnit = "mb", DefaultUnit = "mb", UnitFamily = FamilyBytes, + DefaultTimeAgg = ComposeAggregate.Avg, ValidAggs = GaugeAggs, AllowedDimensions = NoDims, + }, + + /* ── memory_clerks (Gauge; dim clerk_type) ── */ + new ComposeMeasure + { + Key = "clerk_memory_mb", DisplayName = "Memory-clerk allocation", Category = CatMemory, SourceTable = "memory_clerks", + Archetype = MeasureArchetype.Gauge, Column = "memory_mb", + NativeUnit = "mb", DefaultUnit = "mb", UnitFamily = FamilyBytes, + DefaultTimeAgg = ComposeAggregate.Avg, ValidAggs = GaugeAggs, AllowedDimensions = ClerkDims, + }, + + /* ── memory_grant_stats (Gauge MB + cumulative counts, server-grain) ── */ + new ComposeMeasure + { + Key = "grant_granted_mb", DisplayName = "Granted query memory", Category = CatMemory, SourceTable = "memory_grant_stats", + Archetype = MeasureArchetype.Gauge, Column = "granted_memory_mb", + NativeUnit = "mb", DefaultUnit = "mb", UnitFamily = FamilyBytes, + DefaultTimeAgg = ComposeAggregate.Avg, ValidAggs = GaugeAggs, AllowedDimensions = NoDims, + }, + new ComposeMeasure + { + Key = "grant_used_mb", DisplayName = "Used query memory", Category = CatMemory, SourceTable = "memory_grant_stats", + Archetype = MeasureArchetype.Gauge, Column = "used_memory_mb", + NativeUnit = "mb", DefaultUnit = "mb", UnitFamily = FamilyBytes, + DefaultTimeAgg = ComposeAggregate.Avg, ValidAggs = GaugeAggs, AllowedDimensions = NoDims, + }, + new ComposeMeasure + { + Key = "grant_waiters", DisplayName = "Memory-grant waiters", Category = CatMemory, SourceTable = "memory_grant_stats", + Archetype = MeasureArchetype.Gauge, Column = "waiter_count", + NativeUnit = "count", DefaultUnit = "count", UnitFamily = FamilyCount, + DefaultTimeAgg = ComposeAggregate.Max, ValidAggs = GaugeAggs, AllowedDimensions = NoDims, + }, + new ComposeMeasure + { + Key = "grant_timeouts", DisplayName = "Memory-grant timeouts", Category = CatMemory, SourceTable = "memory_grant_stats", + Archetype = MeasureArchetype.Cumulative, Column = "timeout_error_count", DeltaColumn = "timeout_error_count_delta", + NativeUnit = "count", DefaultUnit = "count", UnitFamily = FamilyCount, + DefaultTimeAgg = ComposeAggregate.Sum, ValidAggs = CumulativeAggs, AllowedDimensions = NoDims, + }, + + /* ── tempdb_stats (Gauge MB, server-grain) ── */ + new ComposeMeasure + { + Key = "tempdb_total_reserved_mb", DisplayName = "TempDB reserved", Category = CatTempdb, SourceTable = "tempdb_stats", + Archetype = MeasureArchetype.Gauge, Column = "total_reserved_mb", + NativeUnit = "mb", DefaultUnit = "mb", UnitFamily = FamilyBytes, + DefaultTimeAgg = ComposeAggregate.Avg, ValidAggs = GaugeAggs, AllowedDimensions = NoDims, + }, + new ComposeMeasure + { + Key = "tempdb_version_store_mb", DisplayName = "TempDB version store", Category = CatTempdb, SourceTable = "tempdb_stats", + Archetype = MeasureArchetype.Gauge, Column = "version_store_reserved_mb", + NativeUnit = "mb", DefaultUnit = "mb", UnitFamily = FamilyBytes, + DefaultTimeAgg = ComposeAggregate.Avg, ValidAggs = GaugeAggs, AllowedDimensions = NoDims, + }, + new ComposeMeasure + { + Key = "tempdb_user_object_mb", DisplayName = "TempDB user objects", Category = CatTempdb, SourceTable = "tempdb_stats", + Archetype = MeasureArchetype.Gauge, Column = "user_object_reserved_mb", + NativeUnit = "mb", DefaultUnit = "mb", UnitFamily = FamilyBytes, + DefaultTimeAgg = ComposeAggregate.Avg, ValidAggs = GaugeAggs, AllowedDimensions = NoDims, + }, + + /* ── database_size_stats (Gauge MB; per database/file) ── */ + new ComposeMeasure + { + Key = "dbsize_total_mb", DisplayName = "Database file size", Category = CatDatabaseSize, SourceTable = "database_size_stats", + Archetype = MeasureArchetype.Gauge, Column = "total_size_mb", + NativeUnit = "mb", DefaultUnit = "mb", UnitFamily = FamilyBytes, + DefaultTimeAgg = ComposeAggregate.Max, ValidAggs = GaugeAggs, AllowedDimensions = DbSizeDims, + }, + new ComposeMeasure + { + Key = "dbsize_used_mb", DisplayName = "Database space used", Category = CatDatabaseSize, SourceTable = "database_size_stats", + Archetype = MeasureArchetype.Gauge, Column = "used_size_mb", + NativeUnit = "mb", DefaultUnit = "mb", UnitFamily = FamilyBytes, + DefaultTimeAgg = ComposeAggregate.Max, ValidAggs = GaugeAggs, AllowedDimensions = DbSizeDims, + }, + new ComposeMeasure + { + Key = "dbsize_volume_free_mb", DisplayName = "Volume free space", Category = CatDatabaseSize, SourceTable = "database_size_stats", + Archetype = MeasureArchetype.Gauge, Column = "volume_free_mb", + NativeUnit = "mb", DefaultUnit = "mb", UnitFamily = FamilyBytes, + DefaultTimeAgg = ComposeAggregate.Min, ValidAggs = GaugeAggs, AllowedDimensions = DbSizeDims, + }, + new ComposeMeasure + { + Key = "dbsize_vlf_count", DisplayName = "VLF count", Category = CatDatabaseSize, SourceTable = "database_size_stats", + Archetype = MeasureArchetype.Gauge, Column = "vlf_count", + NativeUnit = "count", DefaultUnit = "count", UnitFamily = FamilyCount, + DefaultTimeAgg = ComposeAggregate.Max, ValidAggs = GaugeAggs, AllowedDimensions = DbSizeDims, + }, + + /* ── index_object_stats (daily; sizes + usage as a current-snapshot value — no delta column exists, + so the cumulative usage counters are exposed as gauges over the daily snapshots) ── */ + new ComposeMeasure + { + Key = "index_reserved_mb", DisplayName = "Index reserved size", Category = CatIndexes, SourceTable = "index_object_stats", + Archetype = MeasureArchetype.Gauge, Column = "reserved_mb", + NativeUnit = "mb", DefaultUnit = "mb", UnitFamily = FamilyBytes, + DefaultTimeAgg = ComposeAggregate.Max, ValidAggs = GaugeAggs, AllowedDimensions = IndexDims, + }, + new ComposeMeasure + { + Key = "index_total_rows", DisplayName = "Index/table rows", Category = CatIndexes, SourceTable = "index_object_stats", + Archetype = MeasureArchetype.Gauge, Column = "total_rows", + NativeUnit = "count", DefaultUnit = "count", UnitFamily = FamilyCount, + DefaultTimeAgg = ComposeAggregate.Max, ValidAggs = GaugeAggs, AllowedDimensions = IndexDims, + }, + new ComposeMeasure + { + Key = "index_user_seeks", DisplayName = "Index seeks (cumulative)", Category = CatIndexes, SourceTable = "index_object_stats", + Archetype = MeasureArchetype.Gauge, Column = "user_seeks", + NativeUnit = "count", DefaultUnit = "count", UnitFamily = FamilyCount, + DefaultTimeAgg = ComposeAggregate.Max, ValidAggs = GaugeAggs, AllowedDimensions = IndexDims, + }, + new ComposeMeasure + { + Key = "index_user_updates", DisplayName = "Index updates (cumulative)", Category = CatIndexes, SourceTable = "index_object_stats", + Archetype = MeasureArchetype.Gauge, Column = "user_updates", + NativeUnit = "count", DefaultUnit = "count", UnitFamily = FamilyCount, + DefaultTimeAgg = ComposeAggregate.Max, ValidAggs = GaugeAggs, AllowedDimensions = IndexDims, + }, + + /* ── session_stats (per application; point-in-time connection counts) ── */ + new ComposeMeasure + { + Key = "session_connection_count", DisplayName = "Connections", Category = CatSessions, SourceTable = "session_stats", + Archetype = MeasureArchetype.Gauge, Column = "connection_count", + NativeUnit = "count", DefaultUnit = "count", UnitFamily = FamilyCount, + DefaultTimeAgg = ComposeAggregate.Avg, ValidAggs = GaugeAggs, AllowedDimensions = SessionStatsDims, + }, + new ComposeMeasure + { + Key = "session_running_count", DisplayName = "Running sessions", Category = CatSessions, SourceTable = "session_stats", + Archetype = MeasureArchetype.Gauge, Column = "running_count", + NativeUnit = "count", DefaultUnit = "count", UnitFamily = FamilyCount, + DefaultTimeAgg = ComposeAggregate.Avg, ValidAggs = GaugeAggs, AllowedDimensions = SessionStatsDims, + }, + + /* ── session_summary_stats (server-wide session gauges) ── */ + new ComposeMeasure + { + Key = "sessum_total_sessions", DisplayName = "Total sessions", Category = CatSessions, SourceTable = "session_summary_stats", + Archetype = MeasureArchetype.Gauge, Column = "total_sessions", + NativeUnit = "count", DefaultUnit = "count", UnitFamily = FamilyCount, + DefaultTimeAgg = ComposeAggregate.Avg, ValidAggs = GaugeAggs, AllowedDimensions = NoDims, + }, + new ComposeMeasure + { + Key = "sessum_idle_over_30min", DisplayName = "Idle sessions (>30 min)", Category = CatSessions, SourceTable = "session_summary_stats", + Archetype = MeasureArchetype.Gauge, Column = "idle_sessions_over_30min", + NativeUnit = "count", DefaultUnit = "count", UnitFamily = FamilyCount, + DefaultTimeAgg = ComposeAggregate.Max, ValidAggs = GaugeAggs, AllowedDimensions = NoDims, + }, + + /* ── waiting_tasks (point-in-time current waits) ── */ + new ComposeMeasure + { + Key = "waiting_task_duration_ms", DisplayName = "Current wait duration", Category = CatWaits, SourceTable = "waiting_tasks", + Archetype = MeasureArchetype.Gauge, Column = "wait_duration_ms", + NativeUnit = "ms", DefaultUnit = "ms", UnitFamily = FamilyDuration, + DefaultTimeAgg = ComposeAggregate.Max, ValidAggs = GaugeAggs, AllowedDimensions = WaitingTaskDims, + }, + + /* ── query_snapshots (point-in-time running requests) ── */ + new ComposeMeasure + { + Key = "snapshot_cpu_time_ms", DisplayName = "Active query CPU time", Category = CatQueries, SourceTable = "query_snapshots", + Archetype = MeasureArchetype.Gauge, Column = "cpu_time_ms", + NativeUnit = "ms", DefaultUnit = "ms", UnitFamily = FamilyDuration, + DefaultTimeAgg = ComposeAggregate.Max, ValidAggs = GaugeAggs, AllowedDimensions = QuerySnapshotDims, + }, + new ComposeMeasure + { + Key = "snapshot_elapsed_ms", DisplayName = "Active query elapsed time", Category = CatQueries, SourceTable = "query_snapshots", + Archetype = MeasureArchetype.Gauge, Column = "total_elapsed_time_ms", + NativeUnit = "ms", DefaultUnit = "ms", UnitFamily = FamilyDuration, + DefaultTimeAgg = ComposeAggregate.Max, ValidAggs = GaugeAggs, AllowedDimensions = QuerySnapshotDims, + }, + new ComposeMeasure + { + Key = "snapshot_granted_memory_gb", DisplayName = "Active query granted memory", Category = CatQueries, SourceTable = "query_snapshots", + Archetype = MeasureArchetype.Gauge, Column = "granted_query_memory_gb", + NativeUnit = "gb", DefaultUnit = "gb", UnitFamily = FamilyBytes, + DefaultTimeAgg = ComposeAggregate.Max, ValidAggs = GaugeAggs, AllowedDimensions = QuerySnapshotDims, + }, + + /* ── blocked_process_reports (per-event; wait_time_ms is the per-event value) ── */ + new ComposeMeasure + { + Key = "bpr_wait_time_ms", DisplayName = "Blocked-process report", Category = CatBlocking, SourceTable = "blocked_process_reports", + Archetype = MeasureArchetype.PerEvent, Column = "wait_time_ms", + NativeUnit = "ms", DefaultUnit = "ms", UnitFamily = FamilyDuration, + DefaultTimeAgg = ComposeAggregate.Count, ValidAggs = PerEventAggs, AllowedDimensions = BlockingDims, + }, + + /* ── dmv_blocking_snapshots (per-event; wait_time_ms) ── */ + new ComposeMeasure + { + Key = "dmvblock_wait_time_ms", DisplayName = "DMV blocking snapshot", Category = CatBlocking, SourceTable = "dmv_blocking_snapshots", + Archetype = MeasureArchetype.PerEvent, Column = "wait_time_ms", + NativeUnit = "ms", DefaultUnit = "ms", UnitFamily = FamilyDuration, + DefaultTimeAgg = ComposeAggregate.Count, ValidAggs = PerEventAggs, AllowedDimensions = DmvBlockingDims, + }, + + /* ── deadlocks (per-event, count-only — no numeric per-event column) ── */ + new ComposeMeasure + { + Key = "deadlock_count", DisplayName = "Deadlocks", Category = CatBlocking, SourceTable = "deadlocks", + Archetype = MeasureArchetype.PerEvent, Column = "deadlock_time", + NativeUnit = "count", DefaultUnit = "count", UnitFamily = FamilyCount, + DefaultTimeAgg = ComposeAggregate.Count, ValidAggs = CountOnlyAggs, AllowedDimensions = DeadlockDims, + }, + + /* ── system_health_events (per-event, count-only) ── */ + new ComposeMeasure + { + Key = "syshealth_event_count", DisplayName = "system_health events", Category = CatSystemHealth, SourceTable = "system_health_events", + Archetype = MeasureArchetype.PerEvent, Column = "event_time", + NativeUnit = "count", DefaultUnit = "count", UnitFamily = FamilyCount, + DefaultTimeAgg = ComposeAggregate.Count, ValidAggs = CountOnlyAggs, AllowedDimensions = SysHealthDims, + }, + + /* ── default_trace_events (per-event; duration_us is the per-event value) ── */ + new ComposeMeasure + { + Key = "deftrace_duration_us", DisplayName = "Default-trace event", Category = CatDefaultTrace, SourceTable = "default_trace_events", + Archetype = MeasureArchetype.PerEvent, Column = "duration_us", + NativeUnit = "us", DefaultUnit = "ms", UnitFamily = FamilyDuration, + DefaultTimeAgg = ComposeAggregate.Count, ValidAggs = PerEventAggs, AllowedDimensions = DefaultTraceDims, + }, + + /* ── running_jobs (point-in-time current run duration; dim job_name) ── */ + new ComposeMeasure + { + Key = "runningjob_current_duration_s", DisplayName = "Running-job duration", Category = CatJobs, SourceTable = "running_jobs", + Archetype = MeasureArchetype.Gauge, Column = "current_duration_seconds", + NativeUnit = "s", DefaultUnit = "s", UnitFamily = FamilyDuration, + DefaultTimeAgg = ComposeAggregate.Max, ValidAggs = GaugeAggs, AllowedDimensions = RunningJobDims, + }, + + /* ── job_history (per job-run; run_duration_seconds is the per-event value) ── */ + new ComposeMeasure + { + Key = "jobhistory_run_duration_s", DisplayName = "Job run", Category = CatJobs, SourceTable = "job_history", + Archetype = MeasureArchetype.PerEvent, Column = "run_duration_seconds", + NativeUnit = "s", DefaultUnit = "s", UnitFamily = FamilyDuration, + DefaultTimeAgg = ComposeAggregate.Count, ValidAggs = PerEventAggs, AllowedDimensions = JobHistoryDims, + }, + + /* ── perfmon_stats (EAV: the counter value; the unit VARIES per counter — cntr_type is not stored — so + it is tagged 'count' as an honest placeholder, and the user picks the counter via counter_name) ── */ + new ComposeMeasure + { + Key = "perfmon_value", DisplayName = "Perfmon counter value", Category = CatPerfmon, SourceTable = "perfmon_stats", + Archetype = MeasureArchetype.Gauge, Column = "cntr_value", + NativeUnit = "count", DefaultUnit = "count", UnitFamily = FamilyCount, + DefaultTimeAgg = ComposeAggregate.Avg, ValidAggs = GaugeAggs, AllowedDimensions = PerfmonDims, + }, + new ComposeMeasure + { + Key = "perfmon_value_delta", DisplayName = "Perfmon counter (per-interval delta)", Category = CatPerfmon, SourceTable = "perfmon_stats", + Archetype = MeasureArchetype.Delta, Column = "delta_cntr_value", + NativeUnit = "count", DefaultUnit = "count", UnitFamily = FamilyCount, + DefaultTimeAgg = ComposeAggregate.Sum, ValidAggs = CumulativeAggs, AllowedDimensions = PerfmonDims, + }, + + /* ── query_store_stats. execution_count is a per-interval count (SUM over the window = total executions); + max_duration/max_cpu are per-interval maxima (MAX over the window = the window peak). The + pre-aggregated AVG columns (avg_duration_us / avg_cpu_time_us) are NOT summable — a plain avg-of-avgs + over-weights sparse intervals (design §2) — but their correct execution-WEIGHTED average across + intervals now ships as a Weighted ratio (SUM(avg * execution_count) / SUM(execution_count)), the + product-sum form MeasureRatioMode.Weighted was added for. ── */ + new ComposeMeasure + { + Key = "qs_executions", DisplayName = "Query Store executions", Category = CatQueryStore, SourceTable = "query_store_stats", + Archetype = MeasureArchetype.Delta, Column = "execution_count", + NativeUnit = "count", DefaultUnit = "count", UnitFamily = FamilyCount, + DefaultTimeAgg = ComposeAggregate.Sum, ValidAggs = CumulativeAggs, AllowedDimensions = QueryStoreDims, + }, + new ComposeMeasure + { + Key = "qs_max_duration_us", DisplayName = "Query Store peak duration", Category = CatQueryStore, SourceTable = "query_store_stats", + Archetype = MeasureArchetype.Gauge, Column = "max_duration_us", + NativeUnit = "us", DefaultUnit = "ms", UnitFamily = FamilyDuration, + DefaultTimeAgg = ComposeAggregate.Max, ValidAggs = GaugeAggs, AllowedDimensions = QueryStoreDims, + }, + new ComposeMeasure + { + Key = "qs_max_cpu_us", DisplayName = "Query Store peak CPU time", Category = CatQueryStore, SourceTable = "query_store_stats", + Archetype = MeasureArchetype.Gauge, Column = "max_cpu_time_us", + NativeUnit = "us", DefaultUnit = "ms", UnitFamily = FamilyDuration, + DefaultTimeAgg = ComposeAggregate.Max, ValidAggs = GaugeAggs, AllowedDimensions = QueryStoreDims, + }, + /* The execution-weighted averages of the pre-aggregated per-interval averages (see the Weighted note + above): SUM(avg_duration_us * execution_count) / SUM(execution_count), the correct mean per execution + across the window — not an avg-of-avgs. Ratio measures, so ValidAggs is empty (a ratio's aggregation + is fixed). */ + new ComposeMeasure + { + Key = "qs_avg_duration_us", DisplayName = "Query Store avg duration / execution", Category = CatQueryStore, SourceTable = "query_store_stats", + Kind = MeasureKind.Ratio, RatioMode = MeasureRatioMode.Weighted, + WeightedValueColumn = "avg_duration_us", WeightColumn = "execution_count", + NativeUnit = "us", DefaultUnit = "ms", UnitFamily = FamilyDuration, + ValidAggs = NoAggs, AllowedDimensions = QueryStoreDims, + }, + new ComposeMeasure + { + Key = "qs_avg_cpu_us", DisplayName = "Query Store avg CPU / execution", Category = CatQueryStore, SourceTable = "query_store_stats", + Kind = MeasureKind.Ratio, RatioMode = MeasureRatioMode.Weighted, + WeightedValueColumn = "avg_cpu_time_us", WeightColumn = "execution_count", + NativeUnit = "us", DefaultUnit = "ms", UnitFamily = FamilyDuration, + ValidAggs = NoAggs, AllowedDimensions = QueryStoreDims, + }, + + /* ═══════════ Same-source ratios (design §2c) — the bread-and-butter derived metrics ═══════════ */ + /* Supporting execution-count scalars the "average per execution" ratios divide by (query_stats had no + executions measure; procedure_stats already exposes proc_executions). */ + new ComposeMeasure + { + Key = "query_executions", DisplayName = "Query executions", Category = CatQueries, SourceTable = "query_stats", + Archetype = MeasureArchetype.Cumulative, Column = "execution_count", DeltaColumn = "delta_execution_count", + NativeUnit = "count", DefaultUnit = "count", UnitFamily = FamilyCount, + DefaultTimeAgg = ComposeAggregate.Sum, ValidAggs = CumulativeAggs, AllowedDimensions = QueryDims, + }, + new ComposeMeasure + { + Key = "file_reads", DisplayName = "File reads", Category = CatFileIo, SourceTable = "file_io_stats", + Archetype = MeasureArchetype.Cumulative, Column = "num_of_reads", DeltaColumn = "delta_reads", + NativeUnit = "count", DefaultUnit = "count", UnitFamily = FamilyCount, + DefaultTimeAgg = ComposeAggregate.Sum, ValidAggs = CumulativeAggs, AllowedDimensions = FileIoDims, + }, + + /* Average duration PER EXECUTION — the classic query/proc metric, and the correct (execution-weighted) + form of "avg elapsed": SUM(delta_elapsed) / SUM(delta_executions), NOT an avg-of-per-sample-avgs. */ + new ComposeMeasure + { + Key = "query_avg_elapsed_us", DisplayName = "Query avg duration / execution", Category = CatQueries, SourceTable = "query_stats", + Kind = MeasureKind.Ratio, NumeratorKey = "query_elapsed_us", DenominatorKey = "query_executions", + NativeUnit = "us", DefaultUnit = "ms", UnitFamily = FamilyDuration, + ValidAggs = NoAggs, AllowedDimensions = QueryDims, + }, + new ComposeMeasure + { + Key = "query_avg_cpu_us", DisplayName = "Query avg CPU / execution", Category = CatQueries, SourceTable = "query_stats", + Kind = MeasureKind.Ratio, NumeratorKey = "query_worker_us", DenominatorKey = "query_executions", + NativeUnit = "us", DefaultUnit = "ms", UnitFamily = FamilyDuration, + ValidAggs = NoAggs, AllowedDimensions = QueryDims, + }, + new ComposeMeasure + { + Key = "proc_avg_elapsed_us", DisplayName = "Procedure avg duration / execution", Category = CatProcedures, SourceTable = "procedure_stats", + Kind = MeasureKind.Ratio, NumeratorKey = "proc_elapsed_us", DenominatorKey = "proc_executions", + NativeUnit = "us", DefaultUnit = "ms", UnitFamily = FamilyDuration, + ValidAggs = NoAggs, AllowedDimensions = ProcDims, + }, + new ComposeMeasure + { + Key = "proc_avg_cpu_us", DisplayName = "Procedure avg CPU / execution", Category = CatProcedures, SourceTable = "procedure_stats", + Kind = MeasureKind.Ratio, NumeratorKey = "proc_worker_us", DenominatorKey = "proc_executions", + NativeUnit = "us", DefaultUnit = "ms", UnitFamily = FamilyDuration, + ValidAggs = NoAggs, AllowedDimensions = ProcDims, + }, + + /* Latch average wait per waiting request (ms/request) and file average read latency (ms/read). */ + new ComposeMeasure + { + Key = "latch_avg_wait_ms", DisplayName = "Latch avg wait / request", Category = CatLatch, SourceTable = "latch_stats", + Kind = MeasureKind.Ratio, NumeratorKey = "latch_wait_time_ms", DenominatorKey = "latch_waiting_requests", + NativeUnit = "ms", DefaultUnit = "ms", UnitFamily = FamilyDuration, + ValidAggs = NoAggs, AllowedDimensions = LatchDims, + }, + new ComposeMeasure + { + Key = "file_avg_read_latency_ms", DisplayName = "File avg read latency", Category = CatFileIo, SourceTable = "file_io_stats", + Kind = MeasureKind.Ratio, NumeratorKey = "file_io_stall_read_ms", DenominatorKey = "file_reads", + NativeUnit = "ms", DefaultUnit = "ms", UnitFamily = FamilyDuration, + ValidAggs = NoAggs, AllowedDimensions = FileIoDims, + }, + + /* ══════════ Gauge-operand ratios (AVG/NULLIF(AVG)) — a ratio of two point-in-time readings, shown as a + percent (native 'ratio' × 100). Both operands are gauges on the same source (RatioMode = Avg). ══════════ */ + new ComposeMeasure + { + Key = "grant_used_pct", DisplayName = "Query-memory grant used %", Category = CatMemory, SourceTable = "memory_grant_stats", + Kind = MeasureKind.Ratio, RatioMode = MeasureRatioMode.Avg, NumeratorKey = "grant_used_mb", DenominatorKey = "grant_granted_mb", + NativeUnit = "ratio", DefaultUnit = "percent", UnitFamily = FamilyFraction, + ValidAggs = NoAggs, AllowedDimensions = NoDims, + }, + new ComposeMeasure + { + Key = "single_use_plan_pct", DisplayName = "Single-use plans %", Category = CatPlanCache, SourceTable = "plan_cache_stats", + Kind = MeasureKind.Ratio, RatioMode = MeasureRatioMode.Avg, NumeratorKey = "plan_cache_single_use_plans", DenominatorKey = "plan_cache_total_plans", + NativeUnit = "ratio", DefaultUnit = "percent", UnitFamily = FamilyFraction, + ValidAggs = NoAggs, AllowedDimensions = PlanCacheDims, + }, + }; + + private static readonly Dictionary s_measureByKey = + Measures.ToDictionary(m => m.Key, StringComparer.Ordinal); + + /// The measure with this key, or null. + public static ComposeMeasure? Measure(string? key) => + key is not null && s_measureByKey.TryGetValue(key, out var m) ? m : null; + + /// Whether is a real measure source (a collector table the catalog serves). + public static bool IsKnownSource(string? source) => + source is not null && Measures.Any(m => string.Equals(m.SourceTable, source, StringComparison.Ordinal)); + + /* ─────────────────────────── annotation sources (design D5) ─────────────────────────── */ + + /// The event sources a composed time-series panel may overlay as annotation markers (design D5). + /// Each names a real collector event table plus its event-time and label columns — every (table, timeColumn, + /// labelColumn) pinned to the owning collector's PayloadColumns by DarlingComposeTests, exactly + /// like a measure's columns. These are the SOLE identifiers the annotation compiler emits; a caller supplies + /// only a KEY from this list, never a table/column. + public static readonly IReadOnlyList AnnotationSources = new[] + { + new ComposeAnnotationSource("deadlocks", "Deadlocks", CatBlocking, "deadlocks", "deadlock_time", "database_name"), + new ComposeAnnotationSource("blocked_process_reports", "Blocked-process reports", CatBlocking, "blocked_process_reports", "event_time", "contentious_object"), + new ComposeAnnotationSource("long_query_completions", "Long-query completions", CatLongQueries, "long_query_completions", "event_time", "object_name"), + new ComposeAnnotationSource("default_trace_events", "Default-trace events", CatDefaultTrace, "default_trace_events", "event_time", "event_name"), + new ComposeAnnotationSource("system_health_events", "system_health events", CatSystemHealth, "system_health_events", "event_time", "event_type"), + }; + + private static readonly Dictionary s_annotationByKey = + AnnotationSources.ToDictionary(a => a.Key, StringComparer.Ordinal); + + /// The annotation source with this key, or null. + public static ComposeAnnotationSource? AnnotationSource(string? key) => + key is not null && s_annotationByKey.TryGetValue(key, out var a) ? a : null; + + /* ─────────────────────────── wire-name maps for the enums ─────────────────────────── */ + + private static readonly (ComposeAggregate Value, string Wire)[] s_aggWire = + { + (ComposeAggregate.Sum, "sum"), (ComposeAggregate.Avg, "avg"), (ComposeAggregate.Min, "min"), + (ComposeAggregate.Max, "max"), (ComposeAggregate.Count, "count"), (ComposeAggregate.PercentileCont, "percentile_cont"), + }; + + private static readonly (ComposeTimeBucket Value, string Wire, int Seconds)[] s_bucketWire = + { + (ComposeTimeBucket.None, "none", 0), (ComposeTimeBucket.Minute, "minute", 60), + (ComposeTimeBucket.Hour, "hour", 3600), (ComposeTimeBucket.Day, "day", 86_400), + }; + + private static readonly (ComposeFilterOp Value, string Wire)[] s_opWire = + { + (ComposeFilterOp.Eq, "eq"), (ComposeFilterOp.Neq, "neq"), (ComposeFilterOp.Like, "like"), + (ComposeFilterOp.Gt, "gt"), (ComposeFilterOp.Gte, "gte"), (ComposeFilterOp.Lt, "lt"), (ComposeFilterOp.Lte, "lte"), + }; + + public static IReadOnlyList AggregateWireNames { get; } = s_aggWire.Select(x => x.Wire).ToArray(); + public static IReadOnlyList TimeBucketWireNames { get; } = s_bucketWire.Select(x => x.Wire).ToArray(); + public static IReadOnlyList FilterOpWireNames { get; } = s_opWire.Select(x => x.Wire).ToArray(); + + public static bool TryParseAggregate(string? wire, out ComposeAggregate value) + { + foreach (var (v, w) in s_aggWire) + { + if (string.Equals(w, wire, StringComparison.Ordinal)) { value = v; return true; } + } + value = default; + return false; + } + + public static string WireName(ComposeAggregate value) => s_aggWire.First(x => x.Value == value).Wire; + + public static bool TryParseTimeBucket(string? wire, out ComposeTimeBucket value) + { + foreach (var (v, w, _) in s_bucketWire) + { + if (string.Equals(w, wire, StringComparison.Ordinal)) { value = v; return true; } + } + value = default; + return false; + } + + public static string WireName(ComposeTimeBucket value) => s_bucketWire.First(x => x.Value == value).Wire; + + /// Seconds per bucket (0 for ) — drives the window×resolution ceiling. + public static int BucketSeconds(ComposeTimeBucket value) => s_bucketWire.First(x => x.Value == value).Seconds; + + /// The Postgres date_trunc field for a real bucket (a compile-time constant, never a user string). + public static string DateTruncField(ComposeTimeBucket value) => value switch + { + ComposeTimeBucket.Minute => "minute", + ComposeTimeBucket.Hour => "hour", + ComposeTimeBucket.Day => "day", + _ => throw new ArgumentOutOfRangeException(nameof(value), value, "None has no date_trunc field"), + }; + + public static bool TryParseFilterOp(string? wire, out ComposeFilterOp value) + { + foreach (var (v, w) in s_opWire) + { + if (string.Equals(w, wire, StringComparison.Ordinal)) { value = v; return true; } + } + value = default; + return false; + } + + public static string WireName(ComposeFilterOp value) => s_opWire.First(x => x.Value == value).Wire; +} diff --git a/Darling/PerformanceMonitor.Darling.Service/CustomViewStore.cs b/Darling/PerformanceMonitor.Darling.Service/CustomViewStore.cs new file mode 100644 index 000000000..95e13bfed --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/CustomViewStore.cs @@ -0,0 +1,311 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using NpgsqlTypes; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// Typed CRUD over config.custom_views (#1563) — the web dashboard's user-authored view/notebook +/// definitions. Built from the web host's least-privilege VIEWER-role pool exactly like +/// DarlingAnalysisService is built from it (new CustomViewStore(postgres)); the viewer role +/// carries the single narrow INSERT/UPDATE/DELETE ON config.custom_views grant (see +/// ). Same store discipline as ViewerDataService.Commands: public-const +/// SQL, bound $N parameters, definition bound as jsonb. Bare table name — the viewer pool's +/// search_path = collect, config, public resolves custom_views to config.custom_views +/// (there is no such table in collect), mirroring config_command in ViewerDataService.Commands. +/// +/// Optimistic concurrency: carries WHERE id = $1 AND version = $6 +/// and bumps version in place; a 0-rowcount is disambiguated by a follow-up probe into +/// (the id is gone) vs (a stale +/// version — someone else wrote first). A duplicate name (unique violation, 23505) is also a +/// . The route layer validates the definition STRUCTURE first +/// (DarlingWebEndpoints.ValidateDefinition, the authority); this store adds only light argument guards +/// () so it is safe to call independently. +/// +public sealed class CustomViewStore +{ + private readonly NpgsqlDataSource _dataSource; + + public CustomViewStore(NpgsqlDataSource dataSource) + { + _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); + } + + /// The bare-array list read: the (potentially large) definition BODY is never selected — only + /// the view kind is extracted as a scalar (definition->>'kind') so the list can badge/route + /// a notebook vs a dashboard without fetching each definition. + public const string ListSql = @" +SELECT id, name, description, version, updated_at, updated_by, (definition->>'kind') AS kind +FROM custom_views +ORDER BY name"; + + /// The full single-view read (includes definition). $1 id. + public const string GetSql = @" +SELECT id, name, definition, description, version, created_at, updated_at, updated_by +FROM custom_views +WHERE id = $1"; + + /// Inserts a new view at version 1. $1 name, $2 definition (jsonb), $3 description, $4 updated_by. + public const string InsertSql = @" +INSERT INTO custom_views (name, definition, description, version, created_at, updated_at, updated_by) +VALUES ($1, $2, $3, 1, (now() AT TIME ZONE 'UTC'), (now() AT TIME ZONE 'UTC'), $4) +RETURNING id, name, definition, description, version, created_at, updated_at, updated_by"; + + /// Optimistic-concurrency update. $1 id, $2 name, $3 definition (jsonb), $4 description, + /// $5 updated_by, $6 expected_version. Returns the row only when the expected version matched. + public const string UpdateSql = @" +UPDATE custom_views +SET name = $2, + definition = $3, + description = $4, + version = version + 1, + updated_at = (now() AT TIME ZONE 'UTC'), + updated_by = $5 +WHERE id = $1 AND version = $6 +RETURNING id, name, definition, description, version, created_at, updated_at, updated_by"; + + /// Disambiguates a 0-rowcount update: the current version, or no row when the id is gone. $1 id. + public const string VersionProbeSql = "SELECT version FROM custom_views WHERE id = $1"; + + /// Deletes a view by id. $1 id. + public const string DeleteSql = "DELETE FROM custom_views WHERE id = $1"; + + /// Postgres unique_violation — a duplicate name insert/rename. + private const string UniqueViolation = "23505"; + + /// An abuse bound on the view name (the column itself is unbounded text). + public const int MaxNameLength = 200; + + /// Lists every saved view as a lightweight summary (no definition), ordered by name. + public async Task> ListAsync(CancellationToken cancellationToken = default) + { + var results = new List(); + await using var command = _dataSource.CreateCommand(ListSql); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + results.Add(new CustomViewSummary( + reader.GetInt64(0), + reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetString(2), + reader.GetInt32(3), + reader.GetDateTime(4), + reader.IsDBNull(5) ? null : reader.GetString(5), + reader.IsDBNull(6) ? null : reader.GetString(6))); + } + + return results; + } + + /// Reads one view in full (with definition), or . + public async Task GetAsync(long id, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(GetSql); + command.Parameters.Add(new NpgsqlParameter { TypedValue = id }); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + { + return new CustomViewResult.NotFound(); + } + + return new CustomViewResult.Ok(ReadFullView(reader)); + } + + /// + /// Inserts a new view. Returns with the stored row (version 1), + /// on a duplicate name (23505), or + /// on a failed argument guard. The caller is expected to have + /// validated 's structure first. + /// + public async Task CreateAsync( + string name, string? description, string definitionJson, string? updatedBy, CancellationToken cancellationToken = default) + { + var invalid = ValidateArgs(name, definitionJson); + if (invalid is not null) + { + return invalid; + } + + await using var command = _dataSource.CreateCommand(InsertSql); + command.Parameters.Add(new NpgsqlParameter { TypedValue = name }); // $1 + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Jsonb, Value = definitionJson }); // $2 + AddNullableText(command, description); // $3 + AddNullableText(command, updatedBy); // $4 + + try + { + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + await reader.ReadAsync(cancellationToken); + return new CustomViewResult.Ok(ReadFullView(reader)); + } + catch (PostgresException ex) when (ex.SqlState == UniqueViolation) + { + return new CustomViewResult.Conflict($"A view named '{name}' already exists."); + } + } + + /// + /// Updates a view under optimistic concurrency. Returns with the + /// new row (version bumped) on success; on a stale + /// OR a duplicate name; when + /// the id no longer exists; on a failed argument guard. + /// + public async Task UpdateAsync( + long id, string name, string? description, string definitionJson, int expectedVersion, string? updatedBy, + CancellationToken cancellationToken = default) + { + var invalid = ValidateArgs(name, definitionJson); + if (invalid is not null) + { + return invalid; + } + + await using var command = _dataSource.CreateCommand(UpdateSql); + command.Parameters.Add(new NpgsqlParameter { TypedValue = id }); // $1 + command.Parameters.Add(new NpgsqlParameter { TypedValue = name }); // $2 + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Jsonb, Value = definitionJson }); // $3 + AddNullableText(command, description); // $4 + AddNullableText(command, updatedBy); // $5 + command.Parameters.Add(new NpgsqlParameter { TypedValue = expectedVersion }); // $6 + + try + { + await using (var reader = await command.ExecuteReaderAsync(cancellationToken)) + { + if (await reader.ReadAsync(cancellationToken)) + { + return new CustomViewResult.Ok(ReadFullView(reader)); + } + } + } + catch (PostgresException ex) when (ex.SqlState == UniqueViolation) + { + return new CustomViewResult.Conflict($"A view named '{name}' already exists."); + } + + /* 0 rows updated: the id is gone (NotFound) or the version moved under us (stale Conflict). */ + return await ClassifyMissedUpdateAsync(id, cancellationToken); + } + + /// Deletes a view. Returns (no body) when a row was deleted, + /// else . + public async Task DeleteAsync(long id, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(DeleteSql); + command.Parameters.Add(new NpgsqlParameter { TypedValue = id }); + var affected = await command.ExecuteNonQueryAsync(cancellationToken); + return affected > 0 ? new CustomViewResult.Ok(null) : new CustomViewResult.NotFound(); + } + + private async Task ClassifyMissedUpdateAsync(long id, CancellationToken cancellationToken) + { + await using var command = _dataSource.CreateCommand(VersionProbeSql); + command.Parameters.Add(new NpgsqlParameter { TypedValue = id }); + var current = await command.ExecuteScalarAsync(cancellationToken); + if (current is null or DBNull) + { + return new CustomViewResult.NotFound(); + } + + var currentVersion = Convert.ToInt32(current, CultureInfo.InvariantCulture); + return new CustomViewResult.Conflict( + $"This view was changed by someone else (current version {currentVersion}). Reload it and re-apply your edit."); + } + + /// + /// PURE argument guard the store applies before any write (the route's ValidateDefinition owns the + /// definition STRUCTURE; this only guards presence/length so the store is safe to call on its own). + /// Returns on a bad argument, else null. + /// + internal static CustomViewResult? ValidateArgs(string? name, string? definitionJson) + { + if (string.IsNullOrWhiteSpace(name)) + { + return new CustomViewResult.Invalid("'name' is required."); + } + + if (name.Length > MaxNameLength) + { + return new CustomViewResult.Invalid($"'name' exceeds the maximum length of {MaxNameLength} characters."); + } + + if (string.IsNullOrWhiteSpace(definitionJson)) + { + return new CustomViewResult.Invalid("'definition' is required."); + } + + return null; + } + + /// Reads a full-view row (column order: id, name, definition, description, version, created_at, + /// updated_at, updated_by — shared by //'s + /// RETURNING). definition is jsonb, read as its text representation. + private static CustomView ReadFullView(NpgsqlDataReader reader) => new( + reader.GetInt64(0), + reader.GetString(1), + reader.GetFieldValue(2), + reader.IsDBNull(3) ? null : reader.GetString(3), + reader.GetInt32(4), + reader.GetDateTime(5), + reader.GetDateTime(6), + reader.IsDBNull(7) ? null : reader.GetString(7)); + + private static void AddNullableText(NpgsqlCommand command, string? value) => + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Text, Value = (object?)value ?? DBNull.Value }); +} + +/// A saved custom view in full — the definition is the raw view JSON text (jsonb). +public sealed record CustomView( + long Id, + string Name, + string DefinitionJson, + string? Description, + int Version, + DateTime CreatedAt, + DateTime UpdatedAt, + string? UpdatedBy); + +/// The lightweight list projection — every field except the definition body, plus the view +/// Kind extracted as a scalar (null when the stored definition declares none — the wire layer +/// defaults that to "dashboard") so the list can badge/route a notebook vs a dashboard cheaply. +public sealed record CustomViewSummary( + long Id, + string Name, + string? Description, + int Version, + DateTime UpdatedAt, + string? UpdatedBy, + string? Kind); + +/// +/// The discriminated outcome of a store operation. Closed (the private base constructor blocks external +/// subclassing): (success — carries the row for read/create/update, null body for delete), +/// , (duplicate name OR stale optimistic-concurrency version), +/// (a failed argument guard). The route layer maps these to 200/201/204, 404, 409, 400. +/// +public abstract record CustomViewResult +{ + private CustomViewResult() + { + } + + public sealed record Ok(CustomView? View) : CustomViewResult; + + public sealed record NotFound : CustomViewResult; + + public sealed record Conflict(string Message) : CustomViewResult; + + public sealed record Invalid(string Message) : CustomViewResult; +} diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingAlertDeliverer.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingAlertDeliverer.cs new file mode 100644 index 000000000..4ff2be634 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingAlertDeliverer.cs @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using PerformanceMonitor.Alerting; +using PerformanceMonitor.Notifications; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// Darling's — Lite's EmailAlertService.TrySendAlertEmailAsync +/// record-and-send cadence transplanted: the shared attempts email +/// (when SMTP is configured and outside the per-fingerprint cooldown) and fans out to the shared +/// (Teams/Slack), then ONE combined config_alert_log row +/// is written per fired alert regardless of channel outcome — including muted alerts (flagged +/// muted, channels skipped) and alerts with no channel configured at all (recorded as 'tray', +/// Lite's taxonomy for delivered-without-email; the headless smoke asserts this row exists). +/// Never throws — a dead SMTP server or Postgres store must not abort the engine's sweep. +/// +/// Delivery-mode fan-out (Lite/Dashboard parity): the effective mode is the shared +/// of a per-server override (#1236, +/// via the injected resolver) against the global +/// . In Per-event mode an alert carrying incidents is split by +/// the shared into one send+row per distinct incident (capped at +/// with a trailing "+N more"); Summary mode — or any alert +/// without incidents (CPU, low-disk, jobs) — takes the single combined send unchanged. +/// +public sealed class DarlingAlertDeliverer : IAlertDeliverer +{ + private static readonly AlertBranding s_branding = new( + "Performance Monitor Darling", + /* Headless: no snooze-hint footer (Lite's points at its Settings window). */ + null); + + /// The branding this service feeds the shared email/template/webhook renderers. + public static AlertBranding Branding => s_branding; + + private readonly IAlertHistoryStore _historyStore; + private readonly EmailSendCore _core; + private readonly ILogger _logger; + private readonly DarlingAlertSettings _settings; + private readonly Func _resolveServerOverride; + + /// + /// Maps a fired alert's to that server's + /// (or null to inherit the global mode). Optional — + /// null (the default) means every server inherits the global , + /// which is also correct for incident-free self-alerts. The service passes a resolver over its live server set. + /// + public DarlingAlertDeliverer( + DarlingAlertSettings settings, + IAlertHistoryStore historyStore, + WebhookAlertService webhookAlertService, + ILogger logger, + Func? resolveServerOverride = null) + { + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _historyStore = historyStore ?? throw new ArgumentNullException(nameof(historyStore)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _resolveServerOverride = resolveServerOverride ?? (_ => null); + _core = new EmailSendCore(settings, historyStore, webhookAlertService, s_branding, logger); + } + + public async Task DeliverAsync(AlertOutcome outcome, CancellationToken cancellationToken = default) + { + if (outcome is null) + { + throw new ArgumentNullException(nameof(outcome)); + } + + try + { + /* #1236/#1141: a per-server override wins over the global mode; Per-event splits an + incident-carrying alert into one send+row per distinct incident (capped, "+N more"). */ + var mode = AlertDeliveryModeResolver.Resolve(_resolveServerOverride(outcome.ServerKey), _settings.DeliveryMode); + if (mode == AlertNotificationMode.PerEvent && outcome.Context?.Incidents is { Count: > 0 }) + { + foreach (var message in PerEventNotification.Split(outcome.Context, _settings.PerEventMax)) + { + /* Per-incident card: msg.CurrentValue is the incident's occurrence count; no server-level + numerics (matching Lite/Dashboard's per-event sends), detail text rebuilt from the split context. */ + await SendAndRecordAsync( + outcome, message.CurrentValue, message.Context, + AlertContextBuilders.ContextToDetailText(message.Context), + numericCurrentValue: null, numericThresholdValue: null); + } + + return; + } + + /* Summary mode, or an alert with no incidents (CPU/low-disk/jobs): one combined send+row, unchanged. */ + await SendAndRecordAsync( + outcome, outcome.CurrentValue, outcome.Context, outcome.DetailText, + outcome.NumericCurrentValue, outcome.NumericThresholdValue); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogError("Alert delivery failed for {Metric} on {Server}: {Message}", + outcome.MetricName, outcome.ServerName, ex.Message); + } + } + + /// + /// Sends one alert message (email + webhook fan-out via the shared core) and writes exactly one combined + /// config_alert_log row for it — Lite's record-and-send cadence. Called once for a Summary alert and + /// once per split incident in Per-event mode, so every send is paired with its own history row (the + /// structured context persists as JSON alongside the flat detail_text). Muted alerts skip both channels but + /// still record (flagged muted). + /// + private async Task SendAndRecordAsync( + AlertOutcome outcome, string currentValue, AlertContext? context, string? detailText, + double? numericCurrentValue, double? numericThresholdValue) + { + /* Lite's EmailAlertService.cs:65-66 — muted alerts skip both channels but still record below. */ + var result = await _core.TrySendAsync( + outcome.MetricName, outcome.ServerName, currentValue, outcome.ThresholdValue, + outcome.ServerKey, context, attemptChannels: !outcome.Muted); + + /* Lite's single-row notification_type taxonomy (EmailAlertService.cs:68-80): + muted → "muted"; email attempted → "email"; webhook delivery upgrades to + "email+webhook"/"webhook"; otherwise "tray". */ + var notificationType = outcome.Muted ? "muted" : "tray"; + if (result.EmailAttempted) + { + notificationType = "email"; + } + + var sent = result.EmailSent; + if (result.WebhookSent) + { + notificationType = notificationType == "email" ? "email+webhook" : "webhook"; + sent = true; + } + + /* Always log the alert, regardless of channel status (EmailAlertService.cs:82-94). */ + string? contextJson = context is not null ? AlertContextSerializer.Serialize(context) : null; + await _historyStore.RecordAlertAsync(new AlertHistoryRecord( + outcome.ServerKey, outcome.ServerName, outcome.MetricName, + currentValue, outcome.ThresholdValue, + numericCurrentValue, numericThresholdValue, + sent, notificationType, result.SendError, + outcome.Muted, detailText, contextJson)); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingAlertReadAdapter.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingAlertReadAdapter.cs new file mode 100644 index 000000000..6dfca234d --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingAlertReadAdapter.cs @@ -0,0 +1,529 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Alerting; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// Darling's (Phase-5 slice B): the seven collected alert feeds +/// read from Postgres — each query is Lite's DuckDB read ported dialect-for-dialect against the +/// same table names and columns (the generated PG schema mirrors Lite's — see PgSchemaGenerator), +/// with three deliberate adjustments: +/// (1) no v_ views exist here — the raw tables are queried directly; +/// (2) Lite's long-running-query read uses DuckDB's bare NOW(); the PG twin binds a +/// parameterized naive-UTC now instead (the poison-wait read's parameterization style), +/// because a bare now() is timestamptz and comparing it against the naive-UTC +/// timestamp columns would resolve in the server's time zone; +/// (3) DuckDB's N'...' literals become plain '...' (Postgres has no N-prefix). +/// All bound timestamps are naive-UTC Kind-Unspecified, matching the COPY writer's storage +/// discipline. The blocking read reproduces Lite's XE-preferred + DMV-fallback merge via the +/// shared . +/// +/// serverKey is the deterministic storage-name hash rendered as a string (the engine's +/// identity across the Phase-5 seams), parsed back to the server_id int here. +/// +/// +public sealed class DarlingAlertReadAdapter : IAlertReadAdapter +{ + private readonly NpgsqlDataSource _postgres; + + public DarlingAlertReadAdapter(NpgsqlDataSource postgres) + { + _postgres = postgres ?? throw new ArgumentNullException(nameof(postgres)); + } + + /* ---------------- blocking (XE-preferred + DMV fallback) ---------------- */ + + /// + /// The XE blocked-process-report read — Lite's query with the column list trimmed to the + /// shared alert row's fields (same WHERE / ORDER BY event_time DESC / LIMIT 200 semantics). + /// $1 server_id, $2 window start, $3 window end (naive UTC). + /// + public const string BlockedProcessReportsSql = @" +SELECT + event_time, + database_name, + blocked_spid, + blocking_spid, + wait_time_ms, + lock_mode, + blocked_sql_text, + blocking_sql_text, + blocked_process_report_xml, + contentious_object +FROM blocked_process_reports +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3 +ORDER BY event_time DESC +LIMIT 200"; + + /// + /// The always-on DMV blocking-snapshot fallback read — Lite's query trimmed the same way + /// (dmv_blocking_snapshots has no report XML column). Same parameters as + /// . + /// + public const string DmvBlockingSnapshotsSql = @" +SELECT + event_time, + database_name, + blocked_spid, + blocking_spid, + wait_time_ms, + lock_mode, + blocked_sql_text, + blocking_sql_text, + contentious_object +FROM dmv_blocking_snapshots +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3 +ORDER BY event_time DESC +LIMIT 200"; + + public async Task> GetRecentBlockedProcessReportsAsync( + string serverKey, int hoursBack, CancellationToken cancellationToken = default) + { + var serverId = ParseServerKey(serverKey); + var (startTime, endTime) = Window(hoursBack); + + var items = new List(); + var dmvItems = new List(); + + await using (var connection = await _postgres.OpenConnectionAsync(cancellationToken)) + { + using (var command = new NpgsqlCommand(BlockedProcessReportsSql, connection)) + { + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(startTime); + command.Parameters.AddWithValue(endTime); + + using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new BlockedProcessAlertRow + { + EventTime = reader.IsDBNull(0) ? null : reader.GetDateTime(0), + DatabaseName = reader.IsDBNull(1) ? "" : reader.GetString(1), + BlockedSpid = reader.IsDBNull(2) ? 0 : reader.GetInt32(2), + BlockingSpid = reader.IsDBNull(3) ? 0 : reader.GetInt32(3), + WaitTimeMs = reader.IsDBNull(4) ? 0 : reader.GetInt64(4), + LockMode = reader.IsDBNull(5) ? "" : reader.GetString(5), + BlockedSqlText = reader.IsDBNull(6) ? "" : reader.GetString(6), + BlockingSqlText = reader.IsDBNull(7) ? "" : reader.GetString(7), + BlockedProcessReportXml = reader.IsDBNull(8) ? "" : reader.GetString(8), + ContentiousObject = reader.IsDBNull(9) ? "" : reader.GetString(9) + }); + } + } + + using (var command = new NpgsqlCommand(DmvBlockingSnapshotsSql, connection)) + { + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(startTime); + command.Parameters.AddWithValue(endTime); + + using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + dmvItems.Add(new BlockedProcessAlertRow + { + EventTime = reader.IsDBNull(0) ? null : reader.GetDateTime(0), + DatabaseName = reader.IsDBNull(1) ? "" : reader.GetString(1), + BlockedSpid = reader.IsDBNull(2) ? 0 : reader.GetInt32(2), + BlockingSpid = reader.IsDBNull(3) ? 0 : reader.GetInt32(3), + WaitTimeMs = reader.IsDBNull(4) ? 0 : reader.GetInt64(4), + LockMode = reader.IsDBNull(5) ? "" : reader.GetString(5), + BlockedSqlText = reader.IsDBNull(6) ? "" : reader.GetString(6), + BlockingSqlText = reader.IsDBNull(7) ? "" : reader.GetString(7), + ContentiousObject = reader.IsDBNull(8) ? "" : reader.GetString(8), + Source = BlockedProcessAlertRow.DmvSnapshotSource + }); + } + } + } + + /* Lite's XE-preferred fallback semantics, verbatim via the shared merge: keep all BPR + rows; append a DMV row only where no BPR covers the same SPID pair in the same minute; + re-cap to the 200 newest. */ + BlockedProcessReportMerge.AppendDmvFallbackRows(items, dmvItems); + + return items; + } + + /* ---------------- deadlocks ---------------- */ + + /// Lite's deadlock read (column list trimmed to the shared alert row's fields). + public const string DeadlocksSql = @" +SELECT + victim_process_id, + victim_sql_text, + deadlock_graph_xml +FROM deadlocks +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3 +ORDER BY deadlock_time DESC +LIMIT 50"; + + public async Task> GetRecentDeadlocksAsync( + string serverKey, int hoursBack, CancellationToken cancellationToken = default) + { + var serverId = ParseServerKey(serverKey); + var (startTime, endTime) = Window(hoursBack); + + var items = new List(); + await using var connection = await _postgres.OpenConnectionAsync(cancellationToken); + using var command = new NpgsqlCommand(DeadlocksSql, connection); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(startTime); + command.Parameters.AddWithValue(endTime); + + using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new DeadlockAlertRow + { + VictimProcessId = reader.IsDBNull(0) ? "" : reader.GetString(0), + VictimSqlText = reader.IsDBNull(1) ? "" : reader.GetString(1), + DeadlockGraphXml = reader.IsDBNull(2) ? "" : reader.GetString(2) + }); + } + + return items; + } + + /* ---------------- poison waits ---------------- */ + + /// + /// Lite's poison-wait read verbatim (wait_stats table instead of the v_ view). $2 is the + /// parameterized naive-UTC "now minus 10 minutes" — the parameterization style the + /// long-running-query twin copies. + /// + public const string PoisonWaitsSql = @" +SELECT + wait_type, + delta_wait_time_ms AS delta_ms, + delta_waiting_tasks AS delta_tasks, + CASE WHEN delta_waiting_tasks > 0 + THEN CAST(delta_wait_time_ms AS DOUBLE PRECISION) / delta_waiting_tasks + ELSE 0 END AS avg_ms_per_wait +FROM wait_stats +WHERE server_id = $1 +AND wait_type IN ('THREADPOOL', 'RESOURCE_SEMAPHORE', 'RESOURCE_SEMAPHORE_QUERY_COMPILE') +AND delta_waiting_tasks > 0 +AND collection_time >= $2 +ORDER BY collection_time DESC +LIMIT 3"; + + public async Task> GetPoisonWaitDeltasAsync( + string serverKey, double thresholdMs, CancellationToken cancellationToken = default) + { + var serverId = ParseServerKey(serverKey); + + var items = new List(); + await using var connection = await _postgres.OpenConnectionAsync(cancellationToken); + using var command = new NpgsqlCommand(PoisonWaitsSql, connection); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(NaiveUtcNow().AddMinutes(-10)); + + using (var reader = await command.ExecuteReaderAsync(cancellationToken)) + { + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new PoisonWaitDelta + { + WaitType = reader.GetString(0), + DeltaMs = reader.IsDBNull(1) ? 0 : reader.GetInt64(1), + DeltaTasks = reader.IsDBNull(2) ? 0 : reader.GetInt64(2), + AvgMsPerWait = reader.IsDBNull(3) ? 0 : reader.GetDouble(3) + }); + } + } + + /* Fetch-then-filter, exactly like Lite's loop: the 3-row window is selected before + thresholding (see the IAlertReadAdapter contract). */ + return items.FindAll(w => w.AvgMsPerWait >= thresholdMs); + } + + /* ---------------- long-running queries ---------------- */ + + /// + /// Lite's long-running-query read with the two PG dialect adjustments: DuckDB's bare + /// NOW() - INTERVAL '10 MINUTES' becomes the parameterized naive-UTC $4 (a bare + /// now() is timestamptz — wrong basis against naive-UTC timestamp columns), and the + /// N'' literals in the opt-out filters ({0} placeholder) lose their N prefix. + /// $1 server_id, $2 elapsed-ms threshold, $3 max results, $4 staleness floor. + /// + public const string LongRunningQueriesSqlTemplate = @" +SELECT + r.session_id, + r.database_name, + SUBSTRING(r.query_text, 1, 300) AS query_text, + r.total_elapsed_time_ms / 1000 AS elapsed_seconds, + r.cpu_time_ms, + r.reads, + r.writes, + r.wait_type, + r.blocking_session_id, + r.query_hash, + r.program_name +FROM query_snapshots AS r +WHERE r.server_id = $1 + AND r.collection_time = (SELECT MAX(vqs.collection_time) FROM query_snapshots AS vqs WHERE vqs.server_id = $1) + AND r.collection_time >= $4 + AND r.session_id > 50 + {0} + AND r.total_elapsed_time_ms >= $2 +ORDER BY r.total_elapsed_time_ms DESC +LIMIT $3"; + + /// The five opt-out noise filters — Lite's clauses with the N'' prefixes dropped. + public const string SpServerDiagnosticsFilter = "AND r.wait_type NOT LIKE '%SP_SERVER_DIAGNOSTICS%'"; + public const string WaitForFilter = "AND r.wait_type NOT IN ('WAITFOR', 'BROKER_RECEIVE_WAITFOR')"; + public const string BackupsFilter = "AND r.wait_type NOT IN ('BACKUPTHREAD', 'BACKUPIO')"; + public const string MiscWaitsFilter = "AND r.wait_type NOT IN ('XE_LIVE_TARGET_TVF')"; + public const string CdcFilter = "AND COALESCE(r.is_cdc_capture, FALSE) = FALSE"; + + public async Task> GetLongRunningQueriesAsync( + string serverKey, + int thresholdMinutes, + int maxResults, + bool excludeSpServerDiagnostics, + bool excludeWaitFor, + bool excludeBackups, + bool excludeMiscWaits, + bool excludeCdc, + IReadOnlyList excludedDatabases, + CancellationToken cancellationToken = default) + { + var serverId = ParseServerKey(serverKey); + var thresholdMs = (long)thresholdMinutes * 60 * 1000; + maxResults = Math.Clamp(maxResults, 1, 1000); + + var filters = string.Join("\n ", new[] + { + excludeSpServerDiagnostics ? SpServerDiagnosticsFilter : "", + excludeWaitFor ? WaitForFilter : "", + excludeBackups ? BackupsFilter : "", + excludeMiscWaits ? MiscWaitsFilter : "", + excludeCdc ? CdcFilter : "" + }.Where(f => f.Length > 0)); + + var sql = LongRunningQueriesSqlTemplate.Replace("{0}", filters); + + var items = new List(); + await using var connection = await _postgres.OpenConnectionAsync(cancellationToken); + using var command = new NpgsqlCommand(sql, connection); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(thresholdMs); + command.Parameters.AddWithValue(maxResults); + command.Parameters.AddWithValue(NaiveUtcNow().AddMinutes(-10)); + + using (var reader = await command.ExecuteReaderAsync(cancellationToken)) + { + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new LongRunningQueryInfo + { + SessionId = reader.IsDBNull(0) ? 0 : reader.GetInt32(0), + DatabaseName = reader.IsDBNull(1) ? "" : reader.GetString(1), + QueryText = reader.IsDBNull(2) ? "" : reader.GetString(2), + ElapsedSeconds = reader.IsDBNull(3) ? 0 : reader.GetInt64(3), + CpuTimeMs = reader.IsDBNull(4) ? 0 : reader.GetInt64(4), + Reads = reader.IsDBNull(5) ? 0 : reader.GetInt64(5), + Writes = reader.IsDBNull(6) ? 0 : reader.GetInt64(6), + WaitType = reader.IsDBNull(7) ? null : reader.GetString(7), + BlockingSessionId = reader.IsDBNull(8) ? null : (int?)reader.GetInt32(8), + QueryHash = reader.IsDBNull(9) ? null : reader.GetString(9), + ProgramName = reader.IsDBNull(10) ? "" : reader.GetString(10) + }); + } + } + + if (excludedDatabases is { Count: > 0 }) + { + items = items + .Where(q => string.IsNullOrEmpty(q.DatabaseName) || + !excludedDatabases.Any(e => + string.Equals(e, q.DatabaseName, StringComparison.OrdinalIgnoreCase))) + .ToList(); + } + + return items; + } + + /* ---------------- volume free space ---------------- */ + + /// Lite's per-volume free-space read verbatim (database_size_stats table). + public const string VolumeFreeSpaceSql = @" +SELECT + volume_mount_point, + MAX(volume_total_mb) AS volume_total_mb, + MIN(volume_free_mb) AS volume_free_mb +FROM database_size_stats +WHERE server_id = $1 +AND collection_time = ( + SELECT MAX(collection_time) + FROM database_size_stats + WHERE server_id = $1 +) +AND volume_mount_point IS NOT NULL +AND volume_total_mb > 0 +GROUP BY volume_mount_point +ORDER BY MIN(volume_free_mb) / MAX(volume_total_mb)"; + + public async Task> GetVolumeFreeSpaceAsync( + string serverKey, CancellationToken cancellationToken = default) + { + var serverId = ParseServerKey(serverKey); + + var items = new List(); + await using var connection = await _postgres.OpenConnectionAsync(cancellationToken); + using var command = new NpgsqlCommand(VolumeFreeSpaceSql, connection); + command.Parameters.AddWithValue(serverId); + + using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new VolumeFreeSpaceInfo + { + MountPoint = reader.IsDBNull(0) ? "" : reader.GetString(0), + TotalMb = reader.IsDBNull(1) ? 0 : ToDouble(reader.GetValue(1)), + FreeMb = reader.IsDBNull(2) ? 0 : ToDouble(reader.GetValue(2)) + }); + } + + return items; + } + + /* ---------------- tempdb ---------------- */ + + /// Lite's latest-tempdb-snapshot read verbatim (tempdb_stats table). + public const string TempDbSpaceSql = @" +SELECT + total_reserved_mb, + unallocated_mb, + user_object_reserved_mb, + internal_object_reserved_mb, + version_store_reserved_mb, + top_session_tempdb_mb, + top_session_id +FROM tempdb_stats +WHERE server_id = $1 +ORDER BY collection_time DESC +LIMIT 1"; + + public async Task GetTempDbSpaceAsync( + string serverKey, CancellationToken cancellationToken = default) + { + var serverId = ParseServerKey(serverKey); + + await using var connection = await _postgres.OpenConnectionAsync(cancellationToken); + using var command = new NpgsqlCommand(TempDbSpaceSql, connection); + command.Parameters.AddWithValue(serverId); + + using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (await reader.ReadAsync(cancellationToken)) + { + return new TempDbSpaceInfo + { + TotalReservedMb = reader.IsDBNull(0) ? 0 : ToDouble(reader.GetValue(0)), + UnallocatedMb = reader.IsDBNull(1) ? 0 : ToDouble(reader.GetValue(1)), + UserObjectReservedMb = reader.IsDBNull(2) ? 0 : ToDouble(reader.GetValue(2)), + InternalObjectReservedMb = reader.IsDBNull(3) ? 0 : ToDouble(reader.GetValue(3)), + VersionStoreReservedMb = reader.IsDBNull(4) ? 0 : ToDouble(reader.GetValue(4)), + TopConsumerMb = reader.IsDBNull(5) ? 0 : ToDouble(reader.GetValue(5)), + TopConsumerSessionId = reader.IsDBNull(6) ? 0 : reader.GetInt32(6) + }; + } + + return null; + } + + /* ---------------- anomalous jobs ---------------- */ + + /// + /// Lite's anomalous-jobs read verbatim (running_jobs table). $2 is the threshold percent + /// (multiplier x 100, as numeric — percent_of_average is numeric(10,1)). + /// + public const string AnomalousJobsSql = @" +SELECT + job_name, + job_id, + current_duration_seconds, + avg_duration_seconds, + p95_duration_seconds, + percent_of_average, + start_time +FROM running_jobs +WHERE server_id = $1 +AND collection_time = (SELECT MAX(collection_time) FROM running_jobs WHERE server_id = $1) +AND avg_duration_seconds >= 60 +AND percent_of_average >= $2 +ORDER BY percent_of_average DESC +LIMIT 5"; + + public async Task> GetAnomalousJobsAsync( + string serverKey, int multiplier, CancellationToken cancellationToken = default) + { + var serverId = ParseServerKey(serverKey); + var thresholdPercent = (decimal)(multiplier * 100); + + var items = new List(); + await using var connection = await _postgres.OpenConnectionAsync(cancellationToken); + using var command = new NpgsqlCommand(AnomalousJobsSql, connection); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(thresholdPercent); + + using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new AnomalousJobInfo + { + JobName = reader.GetString(0), + JobId = reader.IsDBNull(1) ? "" : reader.GetString(1), + CurrentDurationSeconds = reader.IsDBNull(2) ? 0 : reader.GetInt64(2), + AvgDurationSeconds = reader.IsDBNull(3) ? 0 : reader.GetInt64(3), + P95DurationSeconds = reader.IsDBNull(4) ? 0 : reader.GetInt64(4), + PercentOfAverage = reader.IsDBNull(5) ? null : reader.GetDecimal(5), + StartTime = reader.IsDBNull(6) ? DateTime.MinValue : reader.GetDateTime(6) + }); + } + + return items; + } + + /* ---------------- helpers ---------------- */ + + /// Naive-UTC now, Kind-Unspecified — the product's PG timestamp discipline. + private static DateTime NaiveUtcNow() => + DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified); + + /// The (start, end) collection_time window Lite's GetTimeRange produces for hoursBack. + private static (DateTime StartTime, DateTime EndTime) Window(int hoursBack) + { + var endTime = NaiveUtcNow(); + return (endTime.AddHours(-hoursBack), endTime); + } + + private static int ParseServerKey(string serverKey) => + int.Parse(serverKey, CultureInfo.InvariantCulture); + + /// numeric(p,s) columns read back as decimal — coerce like Lite's ToDouble. + private static double ToDouble(object value) => + Convert.ToDouble(value, CultureInfo.InvariantCulture); +} diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingAlertSettings.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingAlertSettings.cs new file mode 100644 index 000000000..04b9d5be9 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingAlertSettings.cs @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using PerformanceMonitor.Alerting; +using PerformanceMonitor.Notifications; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// Darling's settings adapter for the Phase-5 shared alert engine: implements BOTH the engine +/// threshold surface () and the delivery surface +/// (, consumed by the shared EmailSendCore/ +/// WebhookAlertService) over one — the Darling twin of Lite's +/// AppAlertSettings. Pass-through reads (the config object is held by reference, so a +/// future config-reload feature is reflected immediately); the few clamps mirror the ones Lite +/// applies at settings-load time (cooldowns 1–120, failed-job lookback 1–1440, low-disk % 0–100, +/// low-disk GB ≥ 0). SMTP is "enabled" when host + from + to are all configured and a webhook +/// channel when its URL is set — no speculative enable flags. +/// +public sealed class DarlingAlertSettings : IAlertEngineSettings, IAlertSettings +{ + private readonly DarlingConfig _config; + + public DarlingAlertSettings(DarlingConfig config) + { + _config = config ?? throw new ArgumentNullException(nameof(config)); + } + + /* ---------------- IAlertEngineSettings (thresholds) ---------------- */ + + public bool AlertsEnabled => _config.Alerts.Enabled; + + public bool CpuEnabled => _config.Alerts.CpuEnabled; + public bool BlockingEnabled => _config.Alerts.BlockingEnabled; + public bool DeadlockEnabled => _config.Alerts.DeadlockEnabled; + public bool PoisonWaitEnabled => _config.Alerts.PoisonWaitEnabled; + public bool LongRunningQueryEnabled => _config.Alerts.LongRunningQueryEnabled; + public bool TempDbSpaceEnabled => _config.Alerts.TempDbSpaceEnabled; + public bool LowDiskEnabled => _config.Alerts.LowDiskEnabled; + public bool LongRunningJobEnabled => _config.Alerts.LongRunningJobEnabled; + public bool FailedJobEnabled => _config.Alerts.FailedJobEnabled; + + public int CpuThresholdPercent => _config.Alerts.CpuThresholdPercent; + public int BlockingCountThreshold => _config.Alerts.BlockingCountThreshold; + public int DeadlockCountThreshold => _config.Alerts.DeadlockCountThreshold; + public int PoisonWaitThresholdMs => _config.Alerts.PoisonWaitThresholdMs; + public int LongRunningQueryThresholdMinutes => _config.Alerts.LongRunningQueryThresholdMinutes; + public int TempDbSpaceThresholdPercent => _config.Alerts.TempDbSpaceThresholdPercent; + public int LowDiskThresholdPercent => Math.Clamp(_config.Alerts.LowDiskThresholdPercent, 0, 100); + public int LowDiskThresholdGb => Math.Max(0, _config.Alerts.LowDiskThresholdGb); + public int LongRunningJobMultiplier => _config.Alerts.LongRunningJobMultiplier; + public int FailedJobLookbackMinutes => Math.Clamp(_config.Alerts.FailedJobLookbackMinutes, 1, 1440); + public int CooldownMinutes => Math.Clamp(_config.Alerts.CooldownMinutes, 1, 120); + + public IReadOnlyList ExcludedDatabases => _config.Alerts.ExcludedDatabases; + + /// + /// The GLOBAL deadlock/blocking delivery mode (#1141), read live through the by-reference config seam so a + /// store reload reflects immediately. The deliverer resolves a per-server override against this via the + /// shared AlertDeliveryModeResolver. Not on the shared IAlertSettings surface — it is Darling's + /// delivery concern, consumed by off the concrete type. + /// + public AlertNotificationMode DeliveryMode => _config.Alerts.DeliveryMode; + + /// Per-event mode's per-cycle incident cap before the "+N more" batch (#1141); clamped 1–100 like + /// Lite/the viewer so a hand-edited store value can't drive an unbounded fan-out. + public int PerEventMax => Math.Clamp(_config.Alerts.PerEventMax, 1, 100); + + /// + /// Whether the Server-Unreachable / Server-Restored connect-edge alerts are delivered (V20), read live + /// through the by-reference config seam. Not on the shared surface — the + /// connect edge is not a sweep condition; it is Darling's own service-health concern, consumed by + /// off this concrete type (the DeliveryMode precedent). Default true. + /// + public bool NotifyConnectionChanges => _config.Alerts.NotifyConnectionChanges; + + /// "sql" → SqlProcess; anything else (incl. Lite's default "total") → TotalServer. + public CpuAlertMode CpuAlertMode => + string.Equals(_config.Alerts.CpuMode, "sql", StringComparison.OrdinalIgnoreCase) + ? CpuAlertMode.SqlProcess + : CpuAlertMode.TotalServer; + + /* The long-running-query read shape — control-plane knobs since V20, read live through the by-reference + config seam so a store reload reflects immediately (the read adapter re-clamps max results 1–1000). The + shipped defaults still match Lite's App.* (5 rows, every filter on), so an un-customized store is + unchanged from the previously-hardcoded behavior. */ + public int LongRunningQueryMaxResults => _config.Alerts.LongRunningQueryMaxResults; + public bool LongRunningQueryExcludeSpServerDiagnostics => _config.Alerts.LongRunningQueryExcludeSpServerDiagnostics; + public bool LongRunningQueryExcludeWaitFor => _config.Alerts.LongRunningQueryExcludeWaitFor; + public bool LongRunningQueryExcludeBackups => _config.Alerts.LongRunningQueryExcludeBackups; + public bool LongRunningQueryExcludeMiscWaits => _config.Alerts.LongRunningQueryExcludeMiscWaits; + public bool LongRunningQueryExcludeCdc => _config.Alerts.LongRunningQueryExcludeCdc; + + /* ---------------- IAlertSettings (delivery) ---------------- */ + + public bool SmtpEnabled => + !string.IsNullOrWhiteSpace(_config.Smtp.Host) + && !string.IsNullOrWhiteSpace(_config.Smtp.From) + && !string.IsNullOrWhiteSpace(_config.Smtp.To); + + public string SmtpServer => _config.Smtp.Host; + public int SmtpPort => _config.Smtp.Port; + public bool SmtpUseSsl => _config.Smtp.UseSsl; + public string SmtpUsername => _config.Smtp.Username ?? ""; + public string SmtpFromAddress => _config.Smtp.From; + public string SmtpRecipients => _config.Smtp.To; + + /// + /// DPAPI-decrypts smtp.encryptedPassword (the --encrypt-password pattern); null when unset. + /// Called inside EmailSendCore's send try/catch, so a decrypt failure surfaces as that + /// alert's send_error rather than killing the sweep. + /// + public string? GetSmtpPassword() + { + var blob = _config.Smtp.EncryptedPassword; + if (string.IsNullOrWhiteSpace(blob)) + { + return null; + } + + if (!OperatingSystem.IsWindows()) + { + throw new PlatformNotSupportedException("smtp.encryptedPassword requires Windows (DPAPI)."); + } + + return DarlingSecrets.Unprotect(blob); + } + + public int EmailCooldownMinutes => Math.Clamp(_config.Smtp.EmailCooldownMinutes, 1, 120); + + public bool TeamsWebhookEnabled => !string.IsNullOrWhiteSpace(_config.Webhooks.TeamsUrl); + public string TeamsWebhookUrl => _config.Webhooks.TeamsUrl; + public string TeamsProxyAddress => _config.Webhooks.TeamsProxy; + + public bool SlackWebhookEnabled => !string.IsNullOrWhiteSpace(_config.Webhooks.SlackUrl); + public string SlackWebhookUrl => _config.Webhooks.SlackUrl; + public string SlackProxyAddress => _config.Webhooks.SlackProxy; + + /* Generic webhook (#1506) — enabled by a non-empty URL, the same no-speculative-enable-flag derivation + the sibling channels use. */ + public bool GenericWebhookEnabled => !string.IsNullOrWhiteSpace(_config.Webhooks.GenericUrl); + public string GenericWebhookUrl => _config.Webhooks.GenericUrl; + public string GenericWebhookHeadersJson => _config.Webhooks.GenericHeaders; + public string GenericWebhookBodyTemplate => _config.Webhooks.GenericBodyTemplate; + public string GenericWebhookProxyAddress => _config.Webhooks.GenericProxy; + + /* Scheduled-analysis notifications (AN3): the shared AnalysisNotificationService's severity floor + + per-finding re-notify cooldown. The severity floor is now a control-plane knob (config Stage + 1) read through the by-reference config seam — a store reload reflects it immediately; clamped + 0–2 like Lite/Dashboard. The re-notify cooldown stays Lite's hardcoded default (not a knob). */ + public double AnalysisNotifySeverity => Math.Clamp(_config.Analysis.NotifySeverity, 0.0, 2.0); + public int AnalysisNotifyCooldownMinutes => 360; +} diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs new file mode 100644 index 000000000..6000a6ade --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs @@ -0,0 +1,1470 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Reflection; +using System.Runtime.Versioning; +using System.Security.Principal; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Darling.Service.Mcp; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// What the exe should do for its command-line, decided from the FIRST argument by +/// (#1581). Before this, an UNRECOGNIZED flag +/// (the incident's Service.exe --version) fell through into a real service startup, spawning a +/// second instance — the outage. Now only (no args) or a recognized verb reaches +/// the host; anything else prints and exits. +/// +public enum StartupAction +{ + /// No arguments — run the service host (also how the SCM starts it). + StartHost, + + /// --version/-v — print the product version and exit 0. + PrintVersion, + + /// --help/-h — print usage and exit 0. + PrintHelp, + + /// A recognized one-shot verb (encrypt-password, test-connection, …) — dispatched by Program. + RunKnownVerb, + + /// An unrecognized argument — print "unknown option" + usage to stderr and exit non-zero. + UnknownOption, +} + +/// +/// One-shot CLI verbs the service exe supports alongside the Windows-service host — currently the +/// --test-connection / --validate-config pre-flight (Stage 2). It loads darling.json, +/// validates its shape, and probes EVERY configured server for reachability + permissions, reusing the SAME +/// path the test_connect command runs — so a config +/// that validates from the CLI connects identically under the running service. Pure output formatting +/// () is split out so it is unit-testable without live SQL. +/// +public static class DarlingCliCommands +{ + /// The verb that encrypts a SQL-auth password for darling.json (reads stdin). + public static bool IsEncryptPasswordVerb(string arg) => + string.Equals(arg, "--encrypt-password", StringComparison.OrdinalIgnoreCase); + + /// The verb aliases handled by . + public static bool IsValidateConfigVerb(string arg) => + string.Equals(arg, "--test-connection", StringComparison.OrdinalIgnoreCase) + || string.Equals(arg, "--validate-config", StringComparison.OrdinalIgnoreCase); + + /// The verb handles (darling-network-endpoints D8). + public static bool IsPrintViewerConnectionVerb(string arg) => + string.Equals(arg, "--print-viewer-connection", StringComparison.OrdinalIgnoreCase); + + /// The verb handles — the interactive exposure wizard (#1561). + public static bool IsConfigureNetworkVerb(string arg) => + string.Equals(arg, "--configure-network", StringComparison.OrdinalIgnoreCase); + + /// The verb handles — enable the MCP endpoint in the store (+ firewall). + public static bool IsEnableMcpVerb(string arg) => + string.Equals(arg, "--enable-mcp", StringComparison.OrdinalIgnoreCase); + + /// The verb handles — disable the MCP endpoint in the store (+ firewall). + public static bool IsDisableMcpVerb(string arg) => + string.Equals(arg, "--disable-mcp", StringComparison.OrdinalIgnoreCase); + + /// The verb handles — enable the web-dashboard endpoint in the store (+ firewall). + public static bool IsEnableWebVerb(string arg) => + string.Equals(arg, "--enable-web", StringComparison.OrdinalIgnoreCase); + + /// The verb handles — disable the web-dashboard endpoint in the store (+ firewall). + public static bool IsDisableWebVerb(string arg) => + string.Equals(arg, "--disable-web", StringComparison.OrdinalIgnoreCase); + + /// --version/-v — print the product version and exit. + public static bool IsVersionVerb(string arg) => + string.Equals(arg, "--version", StringComparison.OrdinalIgnoreCase) + || string.Equals(arg, "-v", StringComparison.OrdinalIgnoreCase); + + /// --help/-h/-?//? — print usage and exit. + public static bool IsHelpVerb(string arg) => + string.Equals(arg, "--help", StringComparison.OrdinalIgnoreCase) + || string.Equals(arg, "-h", StringComparison.OrdinalIgnoreCase) + || string.Equals(arg, "-?", StringComparison.Ordinal) + || string.Equals(arg, "/?", StringComparison.Ordinal); + + /// + /// Every one-shot CLI verb the exe recognizes as its FIRST argument — the allow-list + /// uses and the single source of truth Program's dispatch mirrors, so the + /// two can never drift. Excludes --version/--help (those are their own classifications). + /// + public static bool IsKnownVerb(string arg) => + IsEncryptPasswordVerb(arg) + || IsValidateConfigVerb(arg) + || IsPrintViewerConnectionVerb(arg) + || IsConfigureNetworkVerb(arg) + || IsEnableMcpVerb(arg) + || IsDisableMcpVerb(arg) + || IsEnableWebVerb(arg) + || IsDisableWebVerb(arg); + + /// + /// Classifies the exe's command line from its FIRST argument (#1581): no args → run the host; a recognized + /// verb → dispatch it; --version/--help → print + exit; ANYTHING else → an unknown option that + /// must NOT start the host (the incident: Service.exe --version used to fall through into a real + /// startup and spawn a second instance). Pure so it pins directly. + /// + public static StartupAction ClassifyStartupArgs(string[]? args) + { + if (args is null || args.Length == 0) + { + return StartupAction.StartHost; + } + + var first = args[0]; + if (IsVersionVerb(first)) + { + return StartupAction.PrintVersion; + } + + if (IsHelpVerb(first)) + { + return StartupAction.PrintHelp; + } + + if (IsKnownVerb(first)) + { + return StartupAction.RunKnownVerb; + } + + return StartupAction.UnknownOption; + } + + /// + /// The product version string for --version — the assembly's informational version (the csproj + /// <Version>), with any SemVer +build metadata suffix stripped, falling back to the + /// assembly version. Pure (reads this assembly's own attributes), so it pins directly. + /// + public static string ProductVersion() + { + var assembly = typeof(DarlingCliCommands).Assembly; + var informational = assembly.GetCustomAttribute()?.InformationalVersion; + if (!string.IsNullOrWhiteSpace(informational)) + { + var plus = informational.IndexOf('+', StringComparison.Ordinal); + return plus >= 0 ? informational[..plus] : informational; + } + + return assembly.GetName().Version?.ToString() ?? "unknown"; + } + + /// The usage text for --help and the unknown-option error. Pure ASCII, one verb per line. + public static string UsageText() => + "PerformanceMonitor Darling service." + Environment.NewLine + + Environment.NewLine + + "Usage:" + Environment.NewLine + + " PerformanceMonitor.Darling.Service.exe Run the service (also how the Windows Service Control Manager starts it)." + Environment.NewLine + + " PerformanceMonitor.Darling.Service.exe --version, -v Print the product version and exit." + Environment.NewLine + + " PerformanceMonitor.Darling.Service.exe --help, -h Print this help and exit." + Environment.NewLine + + " PerformanceMonitor.Darling.Service.exe --test-connection Validate darling.json and probe every configured server." + Environment.NewLine + + " PerformanceMonitor.Darling.Service.exe --encrypt-password Encrypt a SQL-auth password for darling.json (reads stdin)." + Environment.NewLine + + " PerformanceMonitor.Darling.Service.exe --print-viewer-connection Print a remote-viewer connection string (managed store)." + Environment.NewLine + + " PerformanceMonitor.Darling.Service.exe --configure-network Interactive LAN-exposure wizard." + Environment.NewLine + + " PerformanceMonitor.Darling.Service.exe --enable-mcp Enable the MCP endpoint in the store and open its firewall (run elevated)." + Environment.NewLine + + " PerformanceMonitor.Darling.Service.exe --disable-mcp Disable the MCP endpoint in the store and remove its firewall rule (run elevated)." + Environment.NewLine + + " PerformanceMonitor.Darling.Service.exe --enable-web Enable the web dashboard in the store and open its firewall (run elevated)." + Environment.NewLine + + " PerformanceMonitor.Darling.Service.exe --disable-web Disable the web dashboard in the store and remove its firewall rule (run elevated)."; + + /// + /// Loads + validates darling.json, then probes every server. Prints one PASS/FAIL line per server and a + /// summary. Returns 0 only when the config is valid AND every server is reachable; 1 otherwise (so it is + /// usable as a deployment gate). Store/collection are never touched — this is a pure config pre-flight. + /// + public static async Task ValidateConfigAsync( + string? configPath, TextWriter output, TextWriter error, CancellationToken cancellationToken) + { + DarlingConfig config; + try + { + config = DarlingConfig.Load(configPath); + } + catch (Exception ex) + { + error.WriteLine($"Could not load configuration: {ex.Message}"); + return 1; + } + + var problems = config.Validate(); + if (problems.Count > 0) + { + error.WriteLine("Configuration is invalid:"); + foreach (var problem in problems) + { + error.WriteLine(" - " + problem); + } + + return 1; + } + + output.WriteLine($"Validating connectivity to {config.Servers.Count} server(s)..."); + + var allReachable = true; + foreach (var server in config.Servers) + { + cancellationToken.ThrowIfCancellationRequested(); + var probe = await DarlingServerConnector.ProbeAsync(server, null, cancellationToken); + output.WriteLine(FormatProbeLine(server.DisplayName, probe)); + if (!probe.Success) + { + allReachable = false; + } + } + + output.WriteLine(allReachable + ? "All servers reachable." + : "One or more servers failed the connection pre-flight (see above)."); + return allReachable ? 0 : 1; + } + + /// Formats one server's probe outcome as a PASS/FAIL line (pure — unit-testable). + public static string FormatProbeLine(string serverName, ConnectionProbeResult probe) + { + if (!probe.Success) + { + return $" [FAIL] {serverName}: {probe.Error}"; + } + + var edition = string.IsNullOrEmpty(probe.EngineEditionDescription) + ? DarlingServerConnector.DescribeEngineEdition(probe.EngineEdition) + : probe.EngineEditionDescription; + var msdb = probe.HasMsdbAccess ? "msdb access: yes" : "msdb access: NO (failed-job alerts unavailable)"; + return $" [PASS] {serverName}: SQL major version {probe.MajorVersion}, {edition}, {msdb}"; + } + + /// + /// Prints a paste-ready remote-viewer connection string and the server TLS certificate for the opt-in store + /// network endpoint (darling-network-endpoints D8). It DPAPI-decrypts the credential of the role + /// postgres.network.role names (default viewer, read-only) and reads the generated + /// server.crt, so it must run ON the managed store's host under an account that can decrypt them — + /// hence Windows-only (the caller is OperatingSystem.IsWindows()-guarded, mirroring + /// --encrypt-password). The operator pastes the string into the VIEWER machine's darling.json + /// (postgres.managed = false, into postgres.connectionString, consumed verbatim — no viewer + /// code change) and saves the emitted PEM where Root Certificate points. Returns 0 on success; 1 on a + /// mode/role/credential error. Managed-mode only (BYO governs its own exposure, D-BYO); network config lives + /// out of the all-fatal , so this verb never calls it. + /// STDOUT carries a LIVE SECRET (the role password) — the verb warns (on STDERR) to redirect it + /// to an ACL'd file or the clipboard, never scrollback / CI / a screenshare. + /// + [SupportedOSPlatform("windows")] + public static async Task PrintViewerConnectionAsync( + string? configPath, TextWriter output, TextWriter error, CancellationToken cancellationToken) + { + DarlingConfig config; + try + { + config = DarlingConfig.Load(configPath); + } + catch (Exception ex) + { + error.WriteLine($"Could not load configuration: {ex.Message}"); + return 1; + } + + var postgres = config.Postgres; + if (postgres is null) + { + error.WriteLine("postgres section is required."); + return 1; + } + + /* Managed-mode only: the DPAPI credential files + the generated TLS cert this verb reads exist only in + managed mode. In BYO the operator's own PostgreSQL governs exposure + credentials (D-BYO). */ + if (!postgres.Managed) + { + error.WriteLine( + "--print-viewer-connection is for the managed store only. In bring-your-own mode " + + "(postgres.connectionString), your own PostgreSQL governs network exposure and credentials — " + + "build the remote viewer's connection string from your own role + TLS setup."); + return 1; + } + + /* The pg_hba login role the network exposure names — default viewer (read-only, the secure default). + An explicitly-invalid value is a hard error: the store degrades to loopback for it, so no remote + connection exists to print. */ + var network = postgres.Network; + var role = DarlingNetwork.NormalizeNetworkRole(network?.Role); + if (role is null) + { + error.WriteLine( + $"postgres.network.role '{network?.Role}' is invalid — it must be \"viewer\" (default, read-only) " + + "or \"admin\". The store degrades to loopback for an unknown role, so there is no remote connection to print."); + return 1; + } + + /* Warn (not fail) when the store is not actually network-exposed: the operator still gets a template, + but the endpoint will not accept it until postgres.network.listen is set and the service restarted. */ + if (!DarlingNetwork.IsExposedListenAddress(network?.Listen)) + { + error.WriteLine( + "WARNING: postgres.network.listen is not a network address, so the managed store is loopback-only " + + "right now. Set postgres.network (listen + allowFrom) and restart the service to expose it (which " + + "also generates the TLS cert), then re-run this command."); + } + + var host = ResolveViewerHost(network?.Listen); + + /* Decrypt the role's DPAPI-LocalMachine credential (Windows-only; the caller is IsWindows-guarded). + The cert lives in the same directory as the credential (ParentOf(dataDirectory)). */ + var dataDirectory = DarlingManagedPostgres.ResolveDataDirectory(postgres); + var credentialPath = string.Equals(role, "admin", StringComparison.Ordinal) + ? DarlingManagedPostgres.AdminCredentialPathFor(dataDirectory) + : DarlingManagedPostgres.ViewerCredentialPathFor(dataDirectory); + + if (!File.Exists(credentialPath)) + { + error.WriteLine( + $"The '{role}' role credential ({credentialPath}) does not exist yet. Start the PerformanceMonitor " + + "Darling service once so its first run provisions the least-privilege roles and their credentials, " + + "then re-run this command."); + return 1; + } + + string password; + try + { + password = DarlingSecrets.Unprotect((await File.ReadAllTextAsync(credentialPath, cancellationToken)).Trim()); + } + catch (Exception ex) + { + error.WriteLine( + $"Could not decrypt the '{role}' credential at {credentialPath}: {ex.Message} (DPAPI-LocalMachine — " + + "run this on the same machine as the service, under an account that can read the credential)."); + return 1; + } + + /* The client-side Root Certificate placeholder: the operator saves the PEM below at this path on the + VIEWER machine (a bare filename resolves beside the viewer's working directory; an absolute path + also works). Kept as a literal so the printed string is paste-ready. */ + const string clientCertificatePath = "server.crt"; + var connectionString = BuildViewerConnectionString(host, postgres.Port, role, password, clientCertificatePath); + + /* Guidance + the live-secret warning go to STDERR, so redirecting STDOUT to a file or the clipboard + captures the connection string + cert WITHOUT swallowing the warning (D8). */ + error.WriteLine(); + error.WriteLine( + $"WARNING: the connection string below contains a LIVE database password (the '{role}' role), written " + + "to STDOUT. Redirect it to an ACL'd file or pipe it to the clipboard; do not leave it in shell " + + "scrollback, CI logs, or a screenshare."); + error.WriteLine(" Example (file): PerformanceMonitor.Darling.Service.exe --print-viewer-connection > viewer-connection.txt"); + error.WriteLine(" Example (clipboard): PerformanceMonitor.Darling.Service.exe --print-viewer-connection | clip"); + if (string.Equals(role, "admin", StringComparison.Ordinal)) + { + error.WriteLine( + " NOTE: 'admin' is a WRITE credential holding the config-table pivot surface. Prefer the default " + + "'viewer' (read-only) for a remote seat; if you must use 'admin', NTFS-ACL the laptop file too."); + } + + error.WriteLine( + $"Save the certificate block below as '{clientCertificatePath}' on the viewer machine (beside its " + + "darling.json) and point \"Root Certificate\" at it — the store uses SSL Mode=VerifyFull, so the cert must match."); + error.WriteLine(); + + output.WriteLine( + "# Paste into the viewer machine's darling.json -> postgres.connectionString (with postgres.managed = false):"); + output.WriteLine(connectionString); + output.WriteLine(); + + /* Emit the server cert PEM so the operator can copy it to the viewer machine. */ + var certificatePath = Path.Combine( + Path.GetDirectoryName(credentialPath)!, DarlingManagedPostgres.ServerCertFileName); + if (File.Exists(certificatePath)) + { + output.WriteLine($"# Server TLS certificate ({DarlingManagedPostgres.ServerCertFileName}) — save as '{clientCertificatePath}' on the viewer machine:"); + output.WriteLine((await File.ReadAllTextAsync(certificatePath, cancellationToken)).Trim()); + } + else + { + error.WriteLine( + $"NOTE: the server TLS certificate ({certificatePath}) does not exist yet — the service generates it " + + "on its first managed start with postgres.network exposed. Enable postgres.network, restart the " + + "service, then re-run this command to emit the cert for verify-full."); + } + + return 0; + } + + /// + /// The Host= value for the remote viewer connection (D8 / Round 4 #12): the bind IP itself when + /// is a concrete IP (not IPv4 loopback, not a 0.0.0.0/:: wildcard) — + /// verify-full then validates it against the cert's iPAddress SAN — otherwise the machine's hostname, which + /// the cert also carries as a dnsName SAN (the fallback for a wildcard bind, a hostname listen, or an unset + /// listen). Pure — unit-testable. + /// + public static string ResolveViewerHost(string? listen) + { + var trimmed = listen?.Trim(); + if (!string.IsNullOrEmpty(trimmed) + && IPAddress.TryParse(trimmed, out var ip) + && !(ip.AddressFamily == AddressFamily.InterNetwork && ip.GetAddressBytes()[0] == 127) + && !ip.Equals(IPAddress.Any) + && !ip.Equals(IPAddress.IPv6Any)) + { + return trimmed; + } + + return Environment.MachineName; + } + + /// + /// The remote viewer's paste-ready Npgsql connection string (D8): the resolved host, the network role, + /// verify-full TLS against the pinned server cert, the darling database, and the collect/config + /// search path — the exact string the operator drops into the viewer machine's darling.json + /// postgres.connectionString (managed = false, consumed verbatim). The managed role password + /// is service-generated alphanumeric (no connection-string metacharacters), so a hand-built string is safe + /// and yields the exact documented shape. Pure — unit-testable. + /// + public static string BuildViewerConnectionString( + string host, int port, string role, string password, string rootCertificatePath) => + $"Host={host};Port={port};Username={role};Password={password};Database=darling;" + + $"Search Path=collect,config,public;SSL Mode=VerifyFull;Root Certificate={rootCertificatePath}"; + + /* ================================================================================================ + --configure-network: the interactive opt-in exposure wizard (#1561). + + Design invariants (the whole reason this is safe): + - Validation is DELEGATED. Every candidate value is checked by building the SAME config object + the service reads and running the SAME resolver it fail-closes on (the store's + DarlingManagedPostgres.ResolveNetworkExposure, the MCP host's DarlingMcpHostService.ResolveMcpBind). + The wizard never re-implements CIDR / family / role / token rules — it re-prompts with the + resolver's own degrade reason, so the wizard can never write what the service would reject. + - The edit is comment-preserving TEXT SURGERY (DarlingNetworkConfigEditor) — the sample's + heavily-commented documentation survives verbatim. + - Nothing is written until the new text passes DarlingConfig.Parse AND the resolver re-check on + the REPARSED result, and only then behind a timestamped backup. An edit never leaves an + unparseable or fail-closed darling.json. + - The MCP bearer token is generated + DPAPI-protected; the plaintext is printed to STDOUT exactly + once with the save-this warning on STDERR (the --print-viewer-connection secret-split posture). + - mcp.enabled / mcp.port are control-plane after the first run (the Viewer's Settings toggle owns + them live), so the wizard WARNS and points at Settings — it never edits them. The network block + it writes is deliberately file-defined + restart-only. + ================================================================================================ */ + + private const string ServiceName = "PerformanceMonitor Darling"; + + /// + /// Interactive wizard that guides the operator through the opt-in store / MCP LAN exposure and writes + /// a comment-preserving, resolver-validated edit to darling.json behind a timestamped backup. Managed + /// mode only (BYO exposure is governed by the operator's own PostgreSQL). Windows-only (it generates a + /// DPAPI-protected token and controls the Windows service). is the scripted- + /// input testability lever — the tests drive the whole flow with a . Returns + /// 0 on a completed run (including a clean quit) and 1 on a load/parse/write error or a BYO refusal. + /// + [SupportedOSPlatform("windows")] + public static async Task ConfigureNetworkAsync( + string? configPath, TextReader input, TextWriter output, TextWriter error, CancellationToken cancellationToken) + { + var resolvedPath = DarlingConfig.ResolveConfigPath(configPath); + + DarlingConfig config; + try + { + config = DarlingConfig.Load(configPath); + } + catch (Exception ex) + { + error.WriteLine($"Could not load configuration: {ex.Message}"); + return 1; + } + + var postgres = config.Postgres; + if (postgres is null) + { + error.WriteLine("postgres section is required."); + return 1; + } + + string originalText; + try + { + originalText = await File.ReadAllTextAsync(resolvedPath, cancellationToken); + } + catch (Exception ex) + { + error.WriteLine($"Could not read {resolvedPath}: {ex.Message}"); + return 1; + } + + output.WriteLine(); + output.WriteLine("PerformanceMonitor Darling — opt-in network exposure wizard (--configure-network)"); + output.WriteLine($"Config: {resolvedPath}"); + output.WriteLine(); + + /* Current verdicts, straight from the resolvers the running service uses — so the operator sees the + service's own truth (including any fail-closed degrade), not the wizard's guess. */ + var (storeCertPath, storeKeyPath) = ResolveStoreCertPaths(postgres); + var storeNow = DarlingManagedPostgres.ResolveNetworkExposure(postgres.Network, storeCertPath, storeKeyPath); + var mcpNow = DarlingMcpHostService.ResolveMcpBind(config.Mcp, postgres.Managed); + var mcpNowExposed = mcpNow.Mode == DarlingMcpHostService.McpBindMode.NetworkAndLoopback; + var mcpNowDegrade = + mcpNow.Reason is DarlingMcpHostService.McpBindReason.NetworkExposed or DarlingMcpHostService.McpBindReason.LoopbackByDefault + ? null + : McpDegradeText(mcpNow.Reason, config.Mcp); + + output.WriteLine("Current exposure:"); + output.WriteLine(DarlingNetworkConfigEditor.FormatExposureState( + "Store", storeNow.Exposed, storeNow.ListenIp, storeNow.Cidr, storeNow.Role, storeNow.DegradeReason)); + output.WriteLine(DarlingNetworkConfigEditor.FormatExposureState( + "MCP ", mcpNowExposed, config.Mcp.Network?.Listen, config.Mcp.Network?.AllowFrom, null, mcpNowDegrade)); + output.WriteLine($" Service: {await DescribeServiceStateAsync(cancellationToken)}"); + output.WriteLine(); + + /* BYO guard — exposure is managed-mode only (same refusal shape as PrintViewerConnectionAsync). */ + if (!postgres.Managed) + { + output.WriteLine("Network exposure is MANAGED-MODE ONLY."); + output.WriteLine("This darling.json uses bring-your-own PostgreSQL (postgres.connectionString), so your own"); + output.WriteLine("PostgreSQL / reverse proxy governs network exposure — the wizard cannot open the endpoints here."); + + var hasBlocks = (postgres.Network?.IsConfigured ?? false) || (config.Mcp.Network?.IsConfigured ?? false); + if (hasBlocks && AskYesNo(input, output, "A network block is present but IGNORED in this mode. Remove it from darling.json?", defaultYes: false)) + { + return await DisableExposureAsync(resolvedPath, originalText, input, output, error, cancellationToken); + } + + return 1; + } + + /* Surface selection. */ + output.WriteLine("What would you like to configure?"); + output.WriteLine(" [1] Store — a remote Viewer over TLS (verify-full)"); + output.WriteLine(" [2] MCP — a LAN assistant/client behind a bearer token"); + output.WriteLine(" [3] Both"); + output.WriteLine(" [4] Disable — remove all exposure (back to loopback-only)"); + output.WriteLine(" [q] Quit without changes"); + var choice = Prompt(input, output, "Choice", "q"); + if (choice is null || choice.Length == 0 || string.Equals(choice, "q", StringComparison.OrdinalIgnoreCase)) + { + output.WriteLine("No changes made."); + return 0; + } + + if (choice == "4") + { + return await DisableExposureAsync(resolvedPath, originalText, input, output, error, cancellationToken); + } + + var doStore = choice is "1" or "3"; + var doMcp = choice is "2" or "3"; + if (!doStore && !doMcp) + { + output.WriteLine("Unrecognized choice; no changes made."); + return 0; + } + + /* Gather all inputs (delegated validation) BEFORE writing anything, so a cancel leaves the file + untouched and a "both" run is all-or-nothing. */ + (string Listen, string AllowFrom, string Role)? store = null; + if (doStore) + { + output.WriteLine(); + output.WriteLine("== Store exposure =="); + store = GatherStoreInputs(input, output, error, storeCertPath, storeKeyPath); + if (store is null) + { + return 1; + } + } + + (string Listen, string AllowFrom, string? EncryptedToken, string? PlainToken, string? GeneratedPlain)? mcp = null; + if (doMcp) + { + output.WriteLine(); + output.WriteLine("== MCP exposure =="); + if (!config.Mcp.Enabled) + { + output.WriteLine("NOTE: mcp.enabled is currently false. The wizard writes the network block, but the endpoint"); + output.WriteLine(" stays down until you enable MCP in the Viewer's Settings (enabled/port are control-plane"); + output.WriteLine(" after first run; the wizard never edits them)."); + } + + mcp = GatherMcpInputs(input, output, error, config.Mcp); + if (mcp is null) + { + return 1; + } + } + + /* Build the edit through the comment-preserving surgeon. */ + var newText = originalText; + if (store is not null) + { + newText = DarlingNetworkConfigEditor.UpsertNetworkBlock( + newText, "postgres", + DarlingNetworkConfigEditor.BuildStoreNetworkBlock(store.Value.Listen, store.Value.AllowFrom, store.Value.Role)); + } + + if (mcp is not null) + { + newText = DarlingNetworkConfigEditor.UpsertNetworkBlock( + newText, "mcp", + DarlingNetworkConfigEditor.BuildMcpNetworkBlock(mcp.Value.Listen, mcp.Value.AllowFrom, mcp.Value.EncryptedToken, mcp.Value.PlainToken)); + } + + /* Guard 1: the edited text must PARSE (comments/trailing-commas tolerated). */ + DarlingConfig reparsed; + try + { + reparsed = DarlingConfig.Parse(newText); + } + catch (Exception ex) + { + error.WriteLine($"Internal error: the edited darling.json did not parse ({ex.Message}). No changes were written."); + return 1; + } + + /* Guard 2: the resolvers must ACCEPT the reparsed result — never write a file the service would + fail-close on. This is the same delegation the input loop used, re-run on the FINAL text. */ + if (store is not null) + { + var check = DarlingManagedPostgres.ResolveNetworkExposure(reparsed.Postgres.Network, storeCertPath, storeKeyPath); + if (!check.Exposed) + { + error.WriteLine($"Internal error: the store block would fail-close ({check.DegradeReason}). No changes were written."); + return 1; + } + } + + if (mcp is not null) + { + var check = DarlingMcpHostService.ResolveMcpBind(reparsed.Mcp, reparsed.Postgres.Managed); + if (check.Mode != DarlingMcpHostService.McpBindMode.NetworkAndLoopback) + { + error.WriteLine($"Internal error: the MCP block would fail-close ({McpDegradeText(check.Reason, reparsed.Mcp)}). No changes were written."); + return 1; + } + } + + /* Only now: timestamped backup + write. */ + if (!await WriteWithBackupAsync(resolvedPath, newText, output, error, cancellationToken)) + { + return 1; + } + + /* The generated MCP token plaintext — STDOUT exactly once; the save-this warning on STDERR so a + STDOUT redirect keeps the token without swallowing the warning. */ + if (mcp is not null && mcp.Value.GeneratedPlain is not null) + { + error.WriteLine(); + error.WriteLine("SAVE THIS NOW — your new MCP bearer token is shown ONCE (darling.json stores only its DPAPI blob)."); + error.WriteLine("Remote MCP clients send it as the header: Authorization: Bearer "); + output.WriteLine(mcp.Value.GeneratedPlain); + } + + PrintNextSteps( + output, + store is not null, postgres.Port, store?.AllowFrom, + mcp is not null, config.Mcp.Port, mcp?.AllowFrom, config.Mcp.Enabled); + + await OfferRestartAsync(input, output, error, cancellationToken); + return 0; + } + + /// + /// The store's generated TLS cert/key paths (beside the data directory), passed to + /// ResolveNetworkExposure so the whitespace-in-path degrade (a spaced path the pg_ctl -o + /// override cannot pass to postgres) is caught pre-write, exactly as the service would. Mirrors the + /// path idiom in . + /// + [SupportedOSPlatform("windows")] + private static (string CertPath, string KeyPath) ResolveStoreCertPaths(PostgresConfig postgres) + { + var dataDirectory = DarlingManagedPostgres.ResolveDataDirectory(postgres); + var credentialDirectory = Path.GetDirectoryName(DarlingManagedPostgres.ViewerCredentialPathFor(dataDirectory))!; + return ( + Path.Combine(credentialDirectory, DarlingManagedPostgres.ServerCertFileName), + Path.Combine(credentialDirectory, DarlingManagedPostgres.ServerKeyFileName)); + } + + /// Best-effort Windows-service status line via Get-Service; never throws (falls back to a plain note). + [SupportedOSPlatform("windows")] + private static async Task DescribeServiceStateAsync(CancellationToken cancellationToken) + { + try + { + var (exitCode, psOutput) = await DarlingManagedPostgres.RunPowerShellAsync( + $"(Get-Service -Name '{ServiceName}' -ErrorAction SilentlyContinue).Status", cancellationToken); + var status = psOutput.Trim(); + return exitCode == 0 && status.Length > 0 ? status : "not installed (or status unavailable)"; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception) + { + return "status unavailable"; + } + } + + /// The machine's non-loopback IPv4 unicast addresses (interface name + address). Impure (queries the OS); the pure menu formatter takes its output. + private static IReadOnlyList<(string Name, string Address)> EnumerateLocalIPv4() + { + var addresses = new List<(string Name, string Address)>(); + foreach (var nic in NetworkInterface.GetAllNetworkInterfaces()) + { + if (nic.OperationalStatus != OperationalStatus.Up || nic.NetworkInterfaceType == NetworkInterfaceType.Loopback) + { + continue; + } + + foreach (var unicast in nic.GetIPProperties().UnicastAddresses) + { + if (unicast.Address.AddressFamily != AddressFamily.InterNetwork) + { + continue; + } + + var ip = unicast.Address.ToString(); + if (!ip.StartsWith("127.", StringComparison.Ordinal)) + { + addresses.Add((nic.Name, ip)); + } + } + } + + return addresses; + } + + /// Prompts for the bind IP: pick a listed adapter, choose 0.0.0.0, or type any IP (the resolver validates it). Returns null on EOF/cancel. + private static string? SelectListenAddress(TextReader input, TextWriter output, string surface) + { + var adapters = EnumerateLocalIPv4(); + output.WriteLine($"Select the {surface} bind IP (the address remote clients connect to):"); + output.WriteLine(DarlingNetworkConfigEditor.FormatAdapterMenu(adapters)); + var allInterfacesChoice = adapters.Count + 1; + output.WriteLine($" [{allInterfacesChoice}] 0.0.0.0 (all interfaces — connect by a cert SAN name)"); + output.WriteLine(" [c] type a custom IP"); + + while (true) + { + var pick = Prompt(input, output, "Bind IP"); + if (pick is null) + { + return null; + } + + if (pick.Length == 0) + { + output.WriteLine(" A bind IP is required."); + continue; + } + + if (int.TryParse(pick, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)) + { + if (n >= 1 && n <= adapters.Count) + { + return adapters[n - 1].Address; + } + + if (n == allInterfacesChoice) + { + return "0.0.0.0"; + } + + output.WriteLine(" Not a listed number — enter a menu number or an IP."); + continue; + } + + if (string.Equals(pick, "c", StringComparison.OrdinalIgnoreCase)) + { + var custom = Prompt(input, output, "Enter the bind IP"); + if (custom is null) + { + return null; + } + + if (custom.Length == 0) + { + output.WriteLine(" A bind IP is required."); + continue; + } + + return custom; + } + + /* Anything else is treated as a directly-typed IP; the resolver is the arbiter of validity. */ + return pick; + } + } + + /// + /// Gathers listen / allowFrom / role for the store, RE-PROMPTING with the store resolver's own degrade + /// reason until it accepts them (or the operator cancels). The whitespace-in-path degrade is a config + /// problem the loop cannot fix, so it is reported and the surface is abandoned. Returns null on cancel. + /// + [SupportedOSPlatform("windows")] + private static (string Listen, string AllowFrom, string Role)? GatherStoreInputs( + TextReader input, TextWriter output, TextWriter error, string certPath, string keyPath) + { + while (true) + { + var listen = SelectListenAddress(input, output, "store"); + if (listen is null) + { + output.WriteLine("Cancelled — no changes made."); + return null; + } + + var allowFrom = Prompt(input, output, "Allowed remote CIDR (e.g. 192.168.1.0/24)"); + if (allowFrom is null) + { + output.WriteLine("Cancelled — no changes made."); + return null; + } + + output.WriteLine("Remote pg_hba role: 'viewer' (read-only, the secure default) or 'admin' (remote WRITES)."); + var role = Prompt(input, output, "Role", "viewer"); + if (role is null) + { + output.WriteLine("Cancelled — no changes made."); + return null; + } + + if (string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase)) + { + output.WriteLine(" WARNING: 'admin' is a remote WRITE credential holding the config-table service-credential pivot."); + output.WriteLine(" Prefer 'viewer' unless you specifically need remote writes."); + } + + var candidate = new PostgresNetworkConfig { Listen = listen, AllowFrom = allowFrom, Role = role }; + var decision = DarlingManagedPostgres.ResolveNetworkExposure(candidate, certPath, keyPath); + if (decision.Exposed) + { + /* Write the resolver's canonical values (parsed IP, host-bits-zeroed CIDR, normalized role) + so the file matches what the service would compute. */ + return (decision.ListenIp!, decision.Cidr!, decision.Role!); + } + + var reason = decision.DegradeReason ?? "the store resolver rejected these values"; + output.WriteLine($" Not accepted: {reason}"); + if (reason.Contains("whitespace", StringComparison.OrdinalIgnoreCase)) + { + error.WriteLine(" This is a path problem, not an input problem — move postgres.dataDirectory to a space-free path, then re-run."); + return null; + } + + output.WriteLine(" Let us try again."); + } + } + + /// + /// Gathers the MCP token (default KEEP an existing one; else generate a fresh 32-char token and + /// DPAPI-protect it) plus listen / allowFrom, RE-PROMPTING with the MCP resolver's degrade reason + /// until it accepts them. Returns the fields to write plus the generated plaintext (non-null only when + /// a token was generated, so the caller prints it once). Returns null on cancel. + /// + [SupportedOSPlatform("windows")] + private static (string Listen, string AllowFrom, string? EncryptedToken, string? PlainToken, string? GeneratedPlain)? GatherMcpInputs( + TextReader input, TextWriter output, TextWriter error, McpConfig currentMcp) + { + string? encryptedToken = null; + string? plainToken = null; + string? generatedPlain = null; + + var existing = currentMcp.Network; + var hasExistingToken = existing is not null + && (!string.IsNullOrWhiteSpace(existing.EncryptedToken) || !string.IsNullOrWhiteSpace(existing.Token)); + + if (hasExistingToken && AskYesNo(input, output, "An MCP bearer token already exists. Keep it?", defaultYes: true)) + { + if (!string.IsNullOrWhiteSpace(existing!.EncryptedToken)) + { + encryptedToken = existing.EncryptedToken; + } + else + { + plainToken = existing!.Token; + output.WriteLine(" Keeping the existing PLAINTEXT token (consider regenerating to store it DPAPI-encrypted instead)."); + } + } + else + { + generatedPlain = DarlingManagedPostgres.GeneratePassword(); + encryptedToken = DarlingSecrets.Protect(generatedPlain); + } + + while (true) + { + var listen = SelectListenAddress(input, output, "MCP"); + if (listen is null) + { + output.WriteLine("Cancelled — no changes made."); + return null; + } + + var allowFrom = Prompt(input, output, "Allowed remote CIDR (e.g. 192.168.1.0/24)"); + if (allowFrom is null) + { + output.WriteLine("Cancelled — no changes made."); + return null; + } + + var candidate = new McpConfig + { + Enabled = currentMcp.Enabled, + Port = currentMcp.Port, + Network = new McpNetworkConfig + { + Listen = listen, + AllowFrom = allowFrom, + EncryptedToken = encryptedToken, + Token = plainToken, + }, + }; + + var decision = DarlingMcpHostService.ResolveMcpBind(candidate, managed: true); + if (decision.Mode == DarlingMcpHostService.McpBindMode.NetworkAndLoopback) + { + return (listen, allowFrom, encryptedToken, plainToken, generatedPlain); + } + + output.WriteLine($" Not accepted: {McpDegradeText(decision.Reason, candidate)}"); + output.WriteLine(" Let us try again."); + } + } + + /// Human text for an MCP bind degrade reason (presentation only — the resolver decides; this narrates). + private static string McpDegradeText(DarlingMcpHostService.McpBindReason reason, McpConfig mcp) => reason switch + { + DarlingMcpHostService.McpBindReason.ListenInvalid => + $"mcp.network.listen '{mcp.Network?.Listen}' is not a valid IP address (use a specific IP, or 0.0.0.0 for all interfaces).", + DarlingMcpHostService.McpBindReason.TokenMissing => + "no bearer token is set (the wizard should have supplied one — this is unexpected).", + DarlingMcpHostService.McpBindReason.AllowFromInvalid => + $"mcp.network.allowFrom '{mcp.Network?.AllowFrom}' is not a valid CIDR or its address family does not match listen (e.g. 192.168.1.0/24, host bits zeroed).", + DarlingMcpHostService.McpBindReason.ManagedModeRequired => + "network exposure is managed-mode only.", + _ => "the MCP resolver rejected these values.", + }; + + /// Removes BOTH network blocks (symmetric with the reconcilers), validating parse + loopback-only before the timestamped write, then offers a restart. Shared by the managed Disable choice and the BYO cleanup. + [SupportedOSPlatform("windows")] + private static async Task DisableExposureAsync( + string resolvedPath, string originalText, TextReader input, TextWriter output, TextWriter error, CancellationToken cancellationToken) + { + var newText = DarlingNetworkConfigEditor.RemoveNetworkBlock(originalText, "postgres"); + newText = DarlingNetworkConfigEditor.RemoveNetworkBlock(newText, "mcp"); + + if (string.Equals(newText, originalText, StringComparison.Ordinal)) + { + output.WriteLine("No live network block found — already loopback-only. Nothing to change."); + return 0; + } + + DarlingConfig reparsed; + try + { + reparsed = DarlingConfig.Parse(newText); + } + catch (Exception ex) + { + error.WriteLine($"Internal error: the disabled darling.json did not parse ({ex.Message}). No changes were written."); + return 1; + } + + var (certPath, keyPath) = ResolveStoreCertPaths(reparsed.Postgres); + var storeStillExposed = DarlingManagedPostgres.ResolveNetworkExposure(reparsed.Postgres.Network, certPath, keyPath).Exposed; + var mcpStillExposed = DarlingMcpHostService.ResolveMcpBind(reparsed.Mcp, reparsed.Postgres.Managed).Mode + == DarlingMcpHostService.McpBindMode.NetworkAndLoopback; + if (storeStillExposed || mcpStillExposed) + { + error.WriteLine("Internal error: exposure is still present after removal. No changes were written."); + return 1; + } + + if (!await WriteWithBackupAsync(resolvedPath, newText, output, error, cancellationToken)) + { + return 1; + } + + output.WriteLine("Network exposure removed. The service reconciles the endpoints OFF (pg_hba rule, firewall, ssl) on its next start."); + await OfferRestartAsync(input, output, error, cancellationToken); + return 0; + } + + /// Offers to restart the service so the edit applies. Elevated: does it via a 90s-budget Restart-Service + WaitForStatus; otherwise prints the exact manual commands (non-fatal, guidance-first). + [SupportedOSPlatform("windows")] + private static async Task OfferRestartAsync(TextReader input, TextWriter output, TextWriter error, CancellationToken cancellationToken) + { + output.WriteLine(); + if (!AskYesNo(input, output, $"Restart the '{ServiceName}' service now to apply?", defaultYes: false)) + { + output.WriteLine("Apply later by restarting the service:"); + PrintManualRestartCommands(output); + return; + } + + if (!IsElevated()) + { + output.WriteLine("This shell is not elevated, so it cannot control the service. Run these in an ELEVATED PowerShell:"); + PrintManualRestartCommands(output); + return; + } + + output.WriteLine($"Restarting '{ServiceName}' (this can take up to ~90 seconds)..."); + var command = + $"Restart-Service -Name '{ServiceName}' -Force; " + + $"(Get-Service -Name '{ServiceName}').WaitForStatus('Running', [TimeSpan]::FromSeconds(75))"; + try + { + var (exitCode, psOutput) = await DarlingManagedPostgres.RunPowerShellAsync(command, cancellationToken, TimeSpan.FromSeconds(90)); + if (exitCode == 0) + { + output.WriteLine($"Service '{ServiceName}' restarted and is Running."); + } + else + { + error.WriteLine($"Restart did not confirm Running (exit {exitCode}): {psOutput}"); + output.WriteLine("Restart it manually:"); + PrintManualRestartCommands(output); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + error.WriteLine($"Restart attempt failed ({ex.Message})."); + output.WriteLine("Restart it manually:"); + PrintManualRestartCommands(output); + } + } + + private static void PrintManualRestartCommands(TextWriter output) + { + output.WriteLine($" Restart-Service -Name '{ServiceName}' -Force"); + output.WriteLine($" (or: sc.exe stop \"{ServiceName}\" then sc.exe start \"{ServiceName}\")"); + } + + /// Prints the handoff reminders: the scoped firewall command(s) and, for the store, the --print-viewer-connection step. + [SupportedOSPlatform("windows")] + private static void PrintNextSteps( + TextWriter output, + bool storeConfigured, int storePort, string? storeCidr, + bool mcpConfigured, int mcpPort, string? mcpCidr, bool mcpEnabled) + { + output.WriteLine(); + output.WriteLine("Next steps:"); + if (storeConfigured) + { + output.WriteLine(" Store firewall rule (run ELEVATED; scoped to the port + CIDR):"); + output.WriteLine(" " + DarlingManagedPostgres.BuildFirewallEnableCommand( + $"PerformanceMonitor Darling store (port {storePort})", storePort, storeCidr!)); + output.WriteLine(" After the service restarts (which generates the TLS cert), get the remote viewer's"); + output.WriteLine(" paste-ready connection string + certificate with:"); + output.WriteLine(" PerformanceMonitor.Darling.Service.exe --print-viewer-connection"); + } + + if (mcpConfigured) + { + output.WriteLine(" MCP firewall rule (run ELEVATED; scoped to the port + CIDR):"); + output.WriteLine(" " + DarlingManagedPostgres.BuildFirewallEnableCommand( + $"PerformanceMonitor Darling MCP (port {mcpPort})", mcpPort, mcpCidr!)); + if (!mcpEnabled) + { + output.WriteLine(" NOTE: mcp.enabled is false, so the MCP endpoint stays down until you enable MCP in the"); + output.WriteLine(" Viewer's Settings. The network block you just wrote applies once MCP is enabled."); + } + } + } + + /// Backs up darling.json to a timestamped sibling, then writes the new text. Returns false (with a message) on any I/O failure. + private static async Task WriteWithBackupAsync( + string resolvedPath, string newText, TextWriter output, TextWriter error, CancellationToken cancellationToken) + { + try + { + var stamp = DateTime.Now.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture); + var backupPath = resolvedPath + ".bak-" + stamp; + for (var suffix = 2; File.Exists(backupPath); suffix++) + { + backupPath = $"{resolvedPath}.bak-{stamp}-{suffix}"; + } + + File.Copy(resolvedPath, backupPath, overwrite: false); + await File.WriteAllTextAsync(resolvedPath, newText, cancellationToken); + output.WriteLine($"Wrote {resolvedPath}"); + output.WriteLine($"Backup saved: {backupPath}"); + return true; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + error.WriteLine($"Could not write the configuration: {ex.Message}"); + return false; + } + } + + /// True when the current process is running elevated (Administrators role) — required to control the service. + [SupportedOSPlatform("windows")] + private static bool IsElevated() + { + using var identity = WindowsIdentity.GetCurrent(); + return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator); + } + + /// Writes a prompt and reads a trimmed line. Returns null on EOF (input exhausted); an empty line yields (or ""). + private static string? Prompt(TextReader input, TextWriter output, string label, string? defaultValue = null) + { + output.Write(defaultValue is null ? $"{label}: " : $"{label} [{defaultValue}]: "); + var line = input.ReadLine(); + if (line is null) + { + return null; + } + + line = line.Trim(); + return line.Length == 0 ? defaultValue ?? "" : line; + } + + /// Yes/no prompt; EOF or an empty line yields . A leading 'y' (any case) is yes. + private static bool AskYesNo(TextReader input, TextWriter output, string label, bool defaultYes) + { + output.Write($"{label} [{(defaultYes ? "Y/n" : "y/N")}]: "); + var line = input.ReadLine(); + if (line is null) + { + return defaultYes; + } + + line = line.Trim(); + return line.Length == 0 ? defaultYes : line.StartsWith("y", StringComparison.OrdinalIgnoreCase); + } + + /* ================================================================================================ + --enable-mcp / --disable-mcp / --enable-web / --disable-web: headless endpoint bring-up. + + Two gaps these close on a headless box (no WPF Viewer, and the service runs as a virtual service + account that CANNOT modify Windows Firewall, so its best-effort self-reconcile silently fails): + (a) ENABLE/DISABLE an endpoint. After the first run mcp.enabled/web.enabled in darling.json are only + a SEED; the store (config.config_service.mcp_enabled/web_enabled) is authoritative and is normally + toggled only by the Viewer's Settings. These verbs write the store directly — a TARGETED UPDATE + whose BEFORE-UPDATE self-bump trigger increments config_version, so the worker hot-reloads within + one sweep (no restart). We NEVER set config_version ourselves (the trigger owns it), and never + touch paused or the OTHER endpoint's flag. + (b) OPEN/CLOSE the endpoint's firewall, but only when its darling.json network block opts into LAN + exposure. Elevated -> run the SAME scoped, idempotent-by-DisplayName rule the host reconciles; + not elevated -> print the exact elevated command as a handoff (the store toggle already + succeeded, so a non-elevated shell is never a failure). + + Managed-mode only (the owner credential + firewall are managed concerns; BYO governs its own + config_service + exposure). Windows-only: DPAPI credential decrypt + WindowsPrincipal + firewall — the + Program dispatch is OperatingSystem.IsWindows()-guarded, mirroring --print-viewer-connection. + ================================================================================================ */ + + /// The CLI's targeted store write that ENABLES the MCP endpoint on the single config_service row + /// (id=1). Sets only mcp_enabled + the audit columns; the BEFORE-UPDATE self-bump trigger fires + /// config_version (deliberately NOT set here) so the worker hot-reloads. Pure — Darling.Tests pin the shape. + public const string EnableMcpStoreSql = + "UPDATE config.config_service SET mcp_enabled = TRUE, updated_at = (now() AT TIME ZONE 'UTC'), updated_by = 'cli' WHERE id = 1"; + + /// The CLI's targeted store write that DISABLES the MCP endpoint (twin of ). + public const string DisableMcpStoreSql = + "UPDATE config.config_service SET mcp_enabled = FALSE, updated_at = (now() AT TIME ZONE 'UTC'), updated_by = 'cli' WHERE id = 1"; + + /// The CLI's targeted store write that ENABLES the read-only web dashboard endpoint (twin of ). + public const string EnableWebStoreSql = + "UPDATE config.config_service SET web_enabled = TRUE, updated_at = (now() AT TIME ZONE 'UTC'), updated_by = 'cli' WHERE id = 1"; + + /// The CLI's targeted store write that DISABLES the web dashboard endpoint (twin of ). + public const string DisableWebStoreSql = + "UPDATE config.config_service SET web_enabled = FALSE, updated_at = (now() AT TIME ZONE 'UTC'), updated_by = 'cli' WHERE id = 1"; + + /// Which optional endpoint a toggle verb targets — selects the store column, firewall rule name, + /// darling.json network block, and seed-key note. + private enum EndpointKind + { + Mcp, + Web, + } + + /// The firewall step a toggle verb takes, from the pure (exposed, elevated) inputs (pin-tested via + /// ). + public enum EndpointFirewallPlan + { + /// Loopback-only (no LAN-exposure block) — no firewall change is needed. + LoopbackNoAction, + + /// Exposed + elevated — run the scoped enable/disable command directly. + RunElevated, + + /// Exposed + NOT elevated — print the exact elevated command for the operator to run by hand. + Handoff, + } + + /// PURE firewall-step decision for a toggle verb (unit-tested): a loopback-only endpoint needs no + /// rule; an exposed endpoint runs the rule when elevated, otherwise hands the exact command off. Shared by + /// enable and disable alike (disable just runs/hands-off the removal command instead of the open command). + public static EndpointFirewallPlan ClassifyFirewallPlan(bool exposed, bool elevated) => + !exposed ? EndpointFirewallPlan.LoopbackNoAction + : elevated ? EndpointFirewallPlan.RunElevated + : EndpointFirewallPlan.Handoff; + + /// + /// Enables the embedded MCP endpoint on a headless managed deployment: flips + /// config.config_service.mcp_enabled TRUE (the live switch — the worker hot-reloads within one sweep + /// via the self-bump trigger, no restart) and, when mcp.network opts into LAN exposure, opens the scoped + /// firewall rule if elevated (else prints it as an elevated handoff — the toggle still succeeded). Managed- + /// mode only; Windows-only (the caller is OperatingSystem.IsWindows()-guarded, mirroring + /// ). Returns 0 on a successful toggle; 1 on a load/mode/credential/unseeded error. + /// + [SupportedOSPlatform("windows")] + public static Task EnableMcpAsync( + string? configPath, TextWriter output, TextWriter error, CancellationToken cancellationToken) => + ToggleEndpointAsync(EndpointKind.Mcp, enable: true, configPath, output, error, cancellationToken); + + /// Disables the embedded MCP endpoint (twin of ): flips + /// mcp_enabled FALSE live and, when exposed, best-effort removes the scoped firewall rule (elevated) or + /// prints the removal as a handoff. A firewall-removal failure is non-fatal. + [SupportedOSPlatform("windows")] + public static Task DisableMcpAsync( + string? configPath, TextWriter output, TextWriter error, CancellationToken cancellationToken) => + ToggleEndpointAsync(EndpointKind.Mcp, enable: false, configPath, output, error, cancellationToken); + + /// Enables the embedded read-only web dashboard endpoint (twin of for + /// web_enabled + the web firewall rule). + [SupportedOSPlatform("windows")] + public static Task EnableWebAsync( + string? configPath, TextWriter output, TextWriter error, CancellationToken cancellationToken) => + ToggleEndpointAsync(EndpointKind.Web, enable: true, configPath, output, error, cancellationToken); + + /// Disables the embedded web dashboard endpoint (twin of for + /// web_enabled + the web firewall rule). + [SupportedOSPlatform("windows")] + public static Task DisableWebAsync( + string? configPath, TextWriter output, TextWriter error, CancellationToken cancellationToken) => + ToggleEndpointAsync(EndpointKind.Web, enable: false, configPath, output, error, cancellationToken); + + /// The shared body of the four endpoint-toggle verbs: load + managed-mode guard + owner-credential + /// build (mirroring ), a targeted config_service UPDATE (0 + /// rows = an unseeded store), the live-apply note, the firewall step, and the "darling.json enabled is only + /// the seed" UX note. Never touches config_version (the self-bump trigger owns it) or the OTHER endpoint's flag. + [SupportedOSPlatform("windows")] + private static async Task ToggleEndpointAsync( + EndpointKind endpoint, bool enable, string? configPath, TextWriter output, TextWriter error, CancellationToken cancellationToken) + { + DarlingConfig config; + try + { + config = DarlingConfig.Load(configPath); + } + catch (Exception ex) + { + error.WriteLine($"Could not load configuration: {ex.Message}"); + return 1; + } + + var verb = VerbName(endpoint, enable); + var endpointLabel = endpoint == EndpointKind.Mcp ? "MCP" : "web dashboard"; + var column = endpoint == EndpointKind.Mcp ? "mcp_enabled" : "web_enabled"; + var seedKey = endpoint == EndpointKind.Mcp ? "mcp.enabled" : "web.enabled"; + + /* Managed-mode guard (mirrors PrintViewerConnectionAsync): the owner credential + firewall reconcile are + managed concerns. In BYO the operator's own PostgreSQL holds config_service — toggle it there. */ + var postgres = config.Postgres; + if (postgres is null) + { + error.WriteLine("postgres section is required."); + return 1; + } + + if (!postgres.Managed) + { + error.WriteLine( + $"{verb} applies to the managed store only. In bring-your-own mode (postgres.connectionString), the " + + $"endpoint enable flags live in YOUR PostgreSQL's config.config_service ({column}) — toggle them there."); + return 1; + } + + /* The OWNER connection (the service's own superuser credential) — null until the worker's first run has + written the DPAPI-protected credential (i.e. the service has never initialized the store). */ + var connectionString = DarlingManagedPostgres.TryBuildConnectionStringFromStoredCredential(postgres); + if (connectionString is null) + { + error.WriteLine( + "The managed store credential does not exist yet — start the PerformanceMonitor Darling service once " + + "so its first run initializes the store, then re-run this command."); + return 1; + } + + /* The TARGETED store write. A DIRECT config_service UPDATE self-bumps config_version via the BEFORE-UPDATE + trigger, so the worker hot-reloads within one sweep — we never touch config_version ourselves. */ + var sql = EndpointToggleSql(endpoint, enable); + int rows; + try + { + await using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(cancellationToken); + await using var command = new NpgsqlCommand(sql, connection); + rows = await command.ExecuteNonQueryAsync(cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + error.WriteLine($"Could not update the control-plane store: {ex.Message}"); + return 1; + } + + if (rows == 0) + { + error.WriteLine( + "The control-plane store is not seeded yet (config.config_service has no id=1 row) — start the " + + "PerformanceMonitor Darling service once so it seeds the store, then re-run this command."); + return 1; + } + + output.WriteLine( + $"{endpointLabel} endpoint {(enable ? "ENABLED" : "DISABLED")} in the control-plane store " + + $"(config.config_service.{column} = {(enable ? "true" : "false")})."); + output.WriteLine( + "The running service applies this LIVE within one collection sweep (the write self-bumps the reload " + + "beacon) — no restart needed."); + + await ReconcileEndpointFirewallAsync(endpoint, enable, config, output, error, cancellationToken); + + output.WriteLine(); + output.WriteLine( + $"NOTE: '{seedKey}' in darling.json is only the FIRST-RUN seed, not the live switch. After the first run " + + $"the store (config.config_service.{column}) is authoritative — which is exactly what this command changed."); + + return 0; + } + + /// The four endpoint-toggle SQL strings, selected by (endpoint, enable) — the routing the public verbs share. + private static string EndpointToggleSql(EndpointKind endpoint, bool enable) => (endpoint, enable) switch + { + (EndpointKind.Mcp, true) => EnableMcpStoreSql, + (EndpointKind.Mcp, false) => DisableMcpStoreSql, + (EndpointKind.Web, true) => EnableWebStoreSql, + (EndpointKind.Web, false) => DisableWebStoreSql, + _ => throw new ArgumentOutOfRangeException(nameof(endpoint)), + }; + + /// The verb spelling for a toggle (for error + handoff text). + private static string VerbName(EndpointKind endpoint, bool enable) => (endpoint, enable) switch + { + (EndpointKind.Mcp, true) => "--enable-mcp", + (EndpointKind.Mcp, false) => "--disable-mcp", + (EndpointKind.Web, true) => "--enable-web", + (EndpointKind.Web, false) => "--disable-web", + _ => throw new ArgumentOutOfRangeException(nameof(endpoint)), + }; + + /// + /// The firewall half of a toggle (defense-in-depth, never the boundary — pg_hba/token + the in-app CIDR + /// check are). Only acts when the endpoint's darling.json network block opts into LAN exposure (a non-loopback + /// listen, via the shared ). Uses the SAME scoped, + /// idempotent-by-DisplayName rule name the host's self-reconcile uses + /// ( / ) + /// and the SAME pure command builders. Elevated -> runs the rule; otherwise prints the exact elevated command + /// — the store toggle already succeeded, so a non-elevated shell is a handoff, never a failure. A firewall + /// failure is likewise non-fatal. + /// + [SupportedOSPlatform("windows")] + private static async Task ReconcileEndpointFirewallAsync( + EndpointKind endpoint, bool enable, DarlingConfig config, TextWriter output, TextWriter error, CancellationToken cancellationToken) + { + var (port, listen, allowFrom, ruleName) = endpoint == EndpointKind.Mcp + ? (config.Mcp.Port, config.Mcp.Network?.Listen, config.Mcp.Network?.AllowFrom, DarlingMcpHostService.McpFirewallRuleName(config.Mcp.Port)) + : (config.Web.Port, config.Web.Network?.Listen, config.Web.Network?.AllowFrom, DarlingWebHostService.WebFirewallRuleName(config.Web.Port)); + + var exposed = DarlingNetwork.IsExposedListenAddress(listen); + var plan = ClassifyFirewallPlan(exposed, IsElevated()); + + output.WriteLine(); + if (plan == EndpointFirewallPlan.LoopbackNoAction) + { + output.WriteLine(enable + ? "Firewall: this endpoint has no LAN-exposure block, so it binds LOOPBACK ONLY — no firewall change is " + + "needed. To expose it on the LAN, run --configure-network (which writes the listen/allowFrom/token block)." + : "Firewall: this endpoint is loopback-only — there is no scoped firewall rule to remove."); + return; + } + + if (enable && string.IsNullOrWhiteSpace(allowFrom)) + { + /* Non-loopback listen but no allowFrom CIDR: the service itself would fail-close this to loopback, so + there is nothing to open. Point at the wizard rather than emit a malformed New-NetFirewallRule. */ + output.WriteLine( + $"Firewall: the network block sets listen '{listen}' but no allowFrom CIDR, so the service will bind " + + "loopback-only until it is completed. Run --configure-network to finish the block; not opening the firewall."); + return; + } + + var command = enable + ? DarlingManagedPostgres.BuildFirewallEnableCommand(ruleName, port, allowFrom!) + : DarlingManagedPostgres.BuildFirewallDisableCommand(ruleName); + + if (plan == EndpointFirewallPlan.RunElevated) + { + await RunFirewallCommandAsync(command, ruleName, enable, output, error, cancellationToken); + return; + } + + /* Handoff (not elevated) — the store toggle already succeeded; print the exact command to run elevated. */ + output.WriteLine(enable + ? "Firewall: this shell is not elevated, so the endpoint was enabled but its firewall rule was NOT opened. " + + "Run this in an ELEVATED PowerShell to open the port (scoped to the port + CIDR):" + : "Firewall: this shell is not elevated, so the firewall rule was NOT removed. Run this in an ELEVATED PowerShell to close the port:"); + output.WriteLine(" " + command); + } + + /// Runs a scoped firewall command via the shared PowerShell runner and reports the outcome. NEVER + /// throws (except on cancellation) — the store toggle already succeeded, so a firewall failure degrades to a + /// printed elevated hand-off, never a non-zero exit. + [SupportedOSPlatform("windows")] + private static async Task RunFirewallCommandAsync( + string command, string ruleName, bool enable, TextWriter output, TextWriter error, CancellationToken cancellationToken) + { + try + { + var (exitCode, psOutput) = await DarlingManagedPostgres.RunPowerShellAsync(command, cancellationToken); + if (exitCode == 0) + { + output.WriteLine($"Firewall rule '{ruleName}' {(enable ? "opened" : "removed")}."); + return; + } + + error.WriteLine( + $"Firewall rule {(enable ? "open" : "removal")} did not confirm (exit {exitCode}: {psOutput}). " + + "Run this in an elevated PowerShell:"); + error.WriteLine(" " + command); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + error.WriteLine($"Firewall rule {(enable ? "open" : "removal")} failed ({ex.Message}). Run this in an elevated PowerShell:"); + error.WriteLine(" " + command); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs new file mode 100644 index 000000000..f7d471471 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs @@ -0,0 +1,805 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Data; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Storage; + +namespace PerformanceMonitor.Darling.Service; + +/// Per-run outcome the worker logs (mirrors Lite's fetch/store phase split, #1180). +public sealed record CollectorRunResult(int Rows, long SqlMs, long StorageMs); + +/// +/// Runs a shared collector definition against one monitored server and binary-COPYs the rows +/// into Postgres — the Darling counterpart of Lite's RemoteCollectorService.DefinitionRunner, +/// ported semantics-for-semantics: AppliesTo skip, host-store watermarks, the three execution +/// paths (per-database Azure connections; enumeration with the optional scalar probe; plain +/// single query with best-effort supplemental), cancellation-aware per-item catches, and the +/// separated SQL/storage phase timing. The definitions and the delta/ignored-wait/schedule +/// defaults are the shared brain; only the storage engine differs. +/// +public sealed class DarlingCollectorRunner +{ + private readonly NpgsqlDataSource _postgres; + private readonly CollectorDeltaCalculator _deltas; + private readonly ILogger? _logger; + + /* Feeds CollectorContext.CapturePlanXml on every cycle — the query_stats / query_store + collectors capture the execution plan when true (darling.json "capturePlans", default true). + Lite never sets the context flag; this is what makes Darling the plan-capturing SKU. Read + through a provider (not a captured bool) so a control-plane store reload of config_service's + capture_plans is honored on the NEXT cycle without reconstructing the runner. */ + private readonly Func _capturePlans; + + /* Feeds CollectorContext.CollectSchemaChangeEvents on every cycle — the default_trace_events + collector drops its Object:Created/Altered/Deleted (schema DDL) slice when false (darling.json + "collectSchemaChangeEvents", default true). Lite never sets the context flag, so it keeps + collecting Object DDL. Read through a provider (not a captured bool) for symmetry with + _capturePlans, so a future live reload is honored on the NEXT cycle without rebuilding. */ + private readonly Func _collectSchemaChanges; + + /* Azure SQL DB logins without master access fall back to single-database mode, throttled per + server so master isn't retried every cycle (#857 — mirrors Lite). + + Stores WHEN the verdict was formed, not just that it was: it expires after + AzureMasterRecheckInterval, and OnServerReconnected drops it outright. Both escape hatches + exist because this used to latch until the process was restarted, so a transient Azure error + could permanently demote a healthy server to single-database collection (#1506). */ + private readonly ConcurrentDictionary _azureMasterInaccessibleSince = new(); + + private static readonly TimeSpan AzureMasterRecheckInterval = TimeSpan.FromMinutes(15); + + public const int CommandTimeoutSeconds = 60; + + /// + /// Live provider for the plan-capture flag; null defaults to always-on (Darling's SKU default). + /// The worker passes () => config.CapturePlans so a store reload takes effect next cycle; + /// tests pass a constant lambda. + /// + /// + /// Live provider for the schema-change (Object DDL) collection flag; null defaults to on (today's + /// behavior). The worker passes () => config.CollectSchemaChangeEvents so a noisy/benchmark box + /// can suppress the default-trace Object:Created/Deleted flood; tests pass a constant lambda. + /// + public DarlingCollectorRunner(NpgsqlDataSource postgres, CollectorDeltaCalculator deltas, ILogger? logger = null, Func? capturePlans = null, Func? collectSchemaChanges = null) + { + _postgres = postgres ?? throw new ArgumentNullException(nameof(postgres)); + _deltas = deltas ?? throw new ArgumentNullException(nameof(deltas)); + _logger = logger; + _capturePlans = capturePlans ?? (() => true); + _collectSchemaChanges = collectSchemaChanges ?? (() => true); + } + + public async Task RunAsync( + ICollectorDefinition definition, + ServerRuntime server, + CancellationToken cancellationToken) + { + var collectionTime = DateTime.UtcNow; + + /* Some collectors don't exist on some targets (e.g. ring buffers on Azure SQL DB) — + skip the cycle entirely, matching Lite. */ + if (!definition.AppliesTo(server.Target)) + { + return new CollectorRunResult(0, 0, 0); + } + + /* Watermark = the newest already-collected value of the definition's time column, + read from Postgres (Lite reads DuckDB here). */ + DateTime? watermark = definition.WatermarkColumn is null + ? null + : await GetLastCollectedTimeAsync(server.ServerId, definition.TargetTable, definition.WatermarkColumn, cancellationToken); + + /* Numeric (bigint) watermark = the newest already-collected value of the definition's monotonic + identity column (job_history's instance_id), read from Postgres — the bigint twin of the timestamp + watermark above. Null for every collector that declares no numeric watermark (the common case), + so no extra query runs for them. */ + long? numericWatermark = definition.NumericWatermarkColumn is null + ? null + : await GetLastCollectedInstanceIdAsync(server.ServerId, definition.TargetTable, definition.NumericWatermarkColumn, cancellationToken); + + /* Only when the watermark came back null: tell a TRUE first run from a store merely emptied by + retention, so default_trace_events uses a bounded window instead of re-scanning all .trc history + (CollectorContext.HasCollectedBefore). Skipped in the common (non-null watermark) path. */ + bool hasCollectedBefore = definition.WatermarkColumn is not null + && watermark is null + && await HasPriorCollectorSuccessAsync(server.ServerId, definition.Name, cancellationToken); + + var context = new CollectorContext + { + ServerId = server.ServerId, + ServerName = server.StorageName, + CollectionTime = collectionTime, + Deltas = _deltas, + Target = server.Target, + Watermark = watermark, + NumericWatermark = numericWatermark, + HasCollectedBefore = hasCollectedBefore, + IgnoredWaitTypes = IgnoredWaitDefaults.All, + ExcludedDatabases = server.Config.ExcludedDatabases?.ToArray() ?? Array.Empty(), + PerfmonCounterOverride = null, + CapturePlanXml = _capturePlans(), + CollectSchemaChangeEvents = _collectSchemaChanges(), + }; + + /* Two accumulators, not one contiguous read-then-write pair: the enumeration and Azure paths now + FLUSH each database's rows before reading the next (#1556), so SQL and storage slices interleave. + Wall-clock (sqlMs + storageMs) and rows_collected totals stay coherent — collection_log is + unchanged; only the split is now a sum of interleaved slices. */ + long sqlMs = 0; + long storageMs = 0; + var rowsWritten = 0; + + if (definition.RunsPerDatabase(context.Target)) + { + /* Azure SQL DB scopes some DMVs to the connected database — run the query once per + database, skipping (and debug-logging) databases that error, matching Lite. + + Definitions with a database-scoped watermark (the XE ring-buffer collectors, whose + per-database sessions dispatch independently) get the query rebuilt per database + against that database's own newest already-collected value — the single server-wide + watermark would let one busy database's newer event silence another database's older + event still sitting in its ring buffer (#1535). Everything else keeps the + build-once plan. + + Honor CommandTimeoutSecondsOverride here (#1556): this path previously passed the constant + 60s cap where Lite's twin already honored the override, a latent bug — index_object_stats + needs 300s per database on Azure, so on a large Azure database its per-database read would + have timed out at 60s. */ + var plan = definition.PerDatabaseWatermarkColumn is null || definition.WatermarkColumn is null + ? definition.BuildQuery(context) + : null; + var perDbTimeout = definition.CommandTimeoutSecondsOverride ?? CommandTimeoutSeconds; + var databases = await GetAzureDatabaseListAsync(server, cancellationToken); + + var attempted = 0; + var failed = 0; + Exception? firstFailure = null; + + /* One pooled store connection for the whole body; one binary COPY per database on it + (completing an importer commits that database — commit-1..N-1 semantics on abort). */ + await using var pgConnection = await _postgres.OpenConnectionAsync(cancellationToken); + + foreach (var databaseName in databases) + { + cancellationToken.ThrowIfCancellationRequested(); + attempted++; + try + { + /* The authoritative database_name for XE rows read on this path — see + CollectorContext.CurrentDatabaseName. */ + context.CurrentDatabaseName = databaseName; + + var dbPlan = plan; + if (dbPlan is null) + { + /* Null (no rows for this database yet) falls back to the definition's + documented first-run window, per database. This is the XE ring-buffer path + (deadlocks / BPR), NOT query_store — no 24h clamp here (those sources roll + past 24h on their own; the clamp is scoped to query_store's enumeration path). */ + context.Watermark = await GetLastCollectedTimeForDatabaseAsync( + server.ServerId, definition.TargetTable, definition.WatermarkColumn!, + definition.PerDatabaseWatermarkColumn!, databaseName, cancellationToken); + dbPlan = definition.BuildQuery(context); + } + + var sqlSlice = Stopwatch.StartNew(); + List batch; + using (var dbConnection = await OpenAzureDatabaseConnectionAsync(server, databaseName, cancellationToken)) + using (var dbCommand = CreateCollectorCommand(dbPlan, dbConnection, perDbTimeout)) + using (var dbReader = await dbCommand.ExecuteReaderAsync(cancellationToken)) + { + batch = await definition.ReadAsync(dbReader, context, cancellationToken); + } + sqlMs += sqlSlice.ElapsedMilliseconds; + + /* Flush this database before reading the next — peak memory is one database's rows. */ + if (batch.Count > 0) + { + var storageSlice = Stopwatch.StartNew(); + rowsWritten += await WriteBatchAsync(pgConnection, definition, batch, server, collectionTime, context, cancellationToken); + storageMs += storageSlice.ElapsedMilliseconds; + } + } + catch (Exception ex) when (ex is not OperationCanceledException and not OutOfMemoryException) + { + /* OOM is filtered OUT of this per-database skip and propagates: it is fatal, not a + routine one-database miss. */ + failed++; + firstFailure ??= ex; + _logger?.LogDebug("Skipping database '{Database}' for {Collector}: {Error}", databaseName, definition.Name, ex.Message); + } + } + + context.CurrentDatabaseName = null; + + /* One database failing is routine (offline, mid-restore, a permissions oddity) and stays a + debug-logged skip. EVERY database failing is a systemic fault — before this check the run + recorded SUCCESS with zero rows, which on the XE collectors also made the SESSION_MISSING + classification (RunXeTolerantAsync → the Capture Down self-alert) unreachable on Azure. + Rethrow the first failure so RunOneAsync classifies it (SESSION_MISSING / PERMISSIONS / + ERROR) instead. Mirrors Lite's definition runner. */ + if (attempted > 0 && failed == attempted && firstFailure is not null) + { + _logger?.LogWarning("{Collector} failed in all {Count} database(s) on '{Server}'; surfacing the first failure", + definition.Name, attempted, server.Config.DisplayName); + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(firstFailure).Throw(); + } + } + else + { + using var sqlConnection = new SqlConnection(server.ConnectionString); + await sqlConnection.OpenAsync(cancellationToken); + + var enumerationPlan = definition.BuildEnumerationQuery(context); + if (enumerationPlan is not null) + { + /* Enumeration shape (the [db].sys.sp_executesql idiom): list items first, then + run one query per item ON THE SAME CONNECTION; an item that fails is skipped + with a warning, matching Lite. */ + var listSlice = Stopwatch.StartNew(); + var items = new List(); + using (var enumerationCommand = CreateCollectorCommand(enumerationPlan, sqlConnection, CommandTimeoutSeconds)) + using (var enumerationReader = await enumerationCommand.ExecuteReaderAsync(cancellationToken)) + { + while (await enumerationReader.ReadAsync(cancellationToken)) + { + items.Add(enumerationReader.GetString(0)); + } + } + sqlMs += listSlice.ElapsedMilliseconds; + + if (items.Count == 0) + { + return new CollectorRunResult(0, sqlMs, 0); + } + + /* Optional quick scalar probe (query_store's live PRODUCTVERSION check) — + best-effort on a 10-second budget; failure leaves the documented default. */ + var probeSlice = Stopwatch.StartNew(); + var probePlan = definition.BuildEnumerationProbe(context); + if (probePlan is not null) + { + try + { + using var probeCommand = CreateCollectorCommand(probePlan, sqlConnection, 10); + var probeResult = await probeCommand.ExecuteScalarAsync(cancellationToken); + if (probeResult is not null && probeResult != DBNull.Value) + { + context.EnumerationProbeResult = probeResult; + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger?.LogDebug("Enumeration probe for {Collector} failed; using defaults: {Error}", + definition.Name, ex.Message); + } + } + sqlMs += probeSlice.ElapsedMilliseconds; + + var itemTimeout = definition.CommandTimeoutSecondsOverride ?? CommandTimeoutSeconds; + + /* One pooled store connection for the whole body; the driver writes one binary COPY per + database on it, flushing each before reading the next. */ + await using var pgConnection = await _postgres.OpenConnectionAsync(cancellationToken); + + var driverResult = await EnumeratedCollectorDriver.RunAsync( + items, + /* Per-database watermark refresh + the 24h catch-up clamp, computed INSIDE the loop — + this is the per-item cutoff site the plan's LOUD FLAG requires the clamp to live at. + Only query_store (the sole enumeration collector with a per-database timestamp + watermark) reaches this; the two snapshot collectors are watermark-less. */ + perItemWatermark: definition.PerDatabaseWatermarkColumn is null || definition.WatermarkColumn is null + ? null + : async (item, ct) => + { + var raw = await GetLastCollectedTimeForDatabaseAsync( + server.ServerId, definition.TargetTable, definition.WatermarkColumn!, + definition.PerDatabaseWatermarkColumn!, item, ct); + var clamped = WatermarkPolicy.ClampCatchup(raw, collectionTime); + if (raw.HasValue && clamped != raw) + { + _logger?.LogWarning( + "{Collector} on '{Server}' database [{Database}] catch-up clamped to {Hours}h (stored watermark {Raw:o} is older) — a bounded, logged history hole.", + definition.Name, server.Config.DisplayName, item, WatermarkPolicy.MaxCatchup.TotalHours, raw.Value); + } + context.Watermark = clamped; + }, + readItem: async (item, ct) => + { + var batch = new List(); + using var itemCommand = CreateCollectorCommand(definition.BuildPerItemQuery(item, context), sqlConnection, itemTimeout); + using var itemReader = await itemCommand.ExecuteReaderAsync(ct); + await definition.ReadItemAsync(item, itemReader, batch, context, ct); + return batch; + }, + writeBatch: (batch, ct) => WriteBatchAsync(pgConnection, definition, batch, server, collectionTime, context, ct), + onItemComplete: (item, batchCount, itemSqlMs, itemStorageMs) => + { + /* Per-DATABASE line for non-empty batches (#1565): the per-server summary blends + every database into one number, which hid a single busy database's 50s burst + behind four quiet siblings. Quiet databases (0 rows — the 2-of-3 cycles between + Query Store's 900s flushes) stay silent. */ + if (batchCount > 0) + { + _logger?.LogInformation(" [{Server}] {Collector} [{Database}] => {Rows} rows (sql:{SqlMs}ms, pg:{PgMs}ms)", + server.Config.DisplayName, definition.Name, item, batchCount, itemSqlMs, itemStorageMs); + } + + var capHit = definition.PerItemRowCountWarnThreshold is int cap && batchCount >= cap; + if (capHit || context.PerItemTextBudgetExceeded) + { + _logger?.LogWarning( + "{Collector} on '{Server}' database [{Database}] hit its per-database collection bound ({Reason}) — oldest rows dropped this cycle.", + definition.Name, server.Config.DisplayName, item, + capHit ? $"row cap {definition.PerItemRowCountWarnThreshold}" : "256MB text budget"); + } + }, + onItemError: (item, ex) => + _logger?.LogWarning("Failed to collect {Collector} from [{Database}] on '{Server}': {Message}", + definition.Name, item, server.Config.DisplayName, ex.Message), + cancellationToken); + + rowsWritten = driverResult.Rows; + sqlMs += driverResult.SqlMs; + storageMs += driverResult.StorageMs; + } + else + { + /* Plain single-query path — unchanged: read all rows, then write them in one batch + (supplemental never runs for per-database collectors). Routed through WriteBatchAsync + so all three paths share one writer. */ + var sqlSlice = Stopwatch.StartNew(); + var plan = definition.BuildQuery(context); + List rows; + using (var command = CreateCollectorCommand(plan, sqlConnection, definition.CommandTimeoutSecondsOverride ?? CommandTimeoutSeconds)) + using (var reader = await command.ExecuteReaderAsync(cancellationToken)) + { + rows = await definition.ReadAsync(reader, context, cancellationToken); + } + + /* Optional best-effort second query on the same connection (server_properties' + health probe). Failure-isolated; skipped on an empty primary, matching Lite. */ + var supplementalPlan = definition.BuildSupplementalQuery(context); + if (supplementalPlan is not null && rows.Count > 0) + { + try + { + using var supplementalCommand = CreateCollectorCommand(supplementalPlan, sqlConnection, CommandTimeoutSeconds); + using var supplementalReader = await supplementalCommand.ExecuteReaderAsync(cancellationToken); + await definition.ApplySupplementalAsync(rows, supplementalReader, context, cancellationToken); + } + catch (Exception ex) + { + _logger?.LogDebug(ex, "Supplemental query for {Collector} failed; continuing without it", definition.Name); + } + } + sqlMs += sqlSlice.ElapsedMilliseconds; + + var storageSlice = Stopwatch.StartNew(); + await using var pgConnection = await _postgres.OpenConnectionAsync(cancellationToken); + rowsWritten = await WriteBatchAsync(pgConnection, definition, rows, server, collectionTime, context, cancellationToken); + storageMs += storageSlice.ElapsedMilliseconds; + } + } + + _logger?.LogDebug("Collected {RowCount} {Collector} rows for server '{Server}'", + rowsWritten, definition.Name, server.Config.DisplayName); + return new CollectorRunResult(rowsWritten, sqlMs, storageMs); + } + + /// + /// Writes ONE batch (one enumerated item / one database, or the whole result set for a plain + /// collector) to Postgres as a single binary COPY on the caller's already-open connection (#1556). + /// The three collection paths route through here so the storage logic — the prefix columns, the + /// naive-UTC stamp, the positional payload — lives once. Completing the importer COMMITS the batch, + /// so on a mid-run abort the batches already written stay committed (commit-1..N-1). An empty batch + /// opens no COPY and returns 0 (rows_collected = Σ non-empty batch counts). + /// + private async Task WriteBatchAsync( + NpgsqlConnection pgConnection, + ICollectorDefinition definition, + List rows, + ServerRuntime server, + DateTime collectionTime, + CollectorContext context, + CancellationToken cancellationToken) + { + if (rows.Count == 0) + { + return 0; + } + + var rowsWritten = 0; + var writer = new PgCollectorRowWriter(); + using var importer = await pgConnection.BeginBinaryImportAsync( + PgCollectorRowWriter.CopyCommandFor(definition), cancellationToken); + writer.Importer = importer; + + /* Naive-UTC storage — see PgCollectorRowWriter. */ + var storedCollectionTime = DateTime.SpecifyKind(collectionTime, DateTimeKind.Unspecified); + + foreach (var row in rows) + { + await importer.StartRowAsync(cancellationToken); + + if (definition.IncludesCollectionId) + { + writer.Value(CollectionIdGenerator.Next()); + } + + writer.Value(storedCollectionTime) + .Value(server.ServerId) + .Value(server.StorageName); + + definition.WritePayload(row, writer, context); + rowsWritten++; + } + + await importer.CompleteAsync(cancellationToken); + return rowsWritten; + } + + /// + /// Runs a collector definition against one monitored server's LIVE connection and RETURNS the shredded + /// rows WITHOUT writing them to the store — the read-only "fetch phase only" twin of , + /// for an on-demand read (the live Current Active Queries snapshot the fetch_active_queries command + /// serves). It builds the SAME the scheduled sweep builds (the shared delta + /// calculator, the live capture-plan / schema-change providers, the server's excluded databases, the + /// ignored-wait defaults), so the live query is byte-identical to the collector's, then opens ONE SqlClient + /// connection and runs the definition's single query and shredder. It deliberately supports ONLY the + /// single-statement path (no per-database enumeration, no per-item enumeration, no supplemental query): it + /// exists for , whose Azure variant already reads what it needs from one + /// connection. A collector that does not apply to the target yields an empty list (mirrors + /// ). Cancellation is honored; a SqlException propagates to the caller, which + /// maps it to a legible command outcome (timeout / permission / error) exactly as the actual-plan handler does. + /// + public async Task> FetchRowsAsync( + ICollectorDefinition definition, + ServerRuntime server, + int commandTimeoutSeconds, + CancellationToken cancellationToken) + { + if (!definition.AppliesTo(server.Target)) + { + return new List(); + } + + var context = new CollectorContext + { + ServerId = server.ServerId, + ServerName = server.StorageName, + CollectionTime = DateTime.UtcNow, + Deltas = _deltas, + Target = server.Target, + Watermark = null, + NumericWatermark = null, + HasCollectedBefore = false, + IgnoredWaitTypes = IgnoredWaitDefaults.All, + ExcludedDatabases = server.Config.ExcludedDatabases?.ToArray() ?? Array.Empty(), + PerfmonCounterOverride = null, + CapturePlanXml = _capturePlans(), + CollectSchemaChangeEvents = _collectSchemaChanges(), + }; + + var plan = definition.BuildQuery(context); + + using var connection = new SqlConnection(server.ConnectionString); + await connection.OpenAsync(cancellationToken); + using var command = CreateCollectorCommand(plan, connection, commandTimeoutSeconds); + using var reader = await command.ExecuteReaderAsync(cancellationToken); + return await definition.ReadAsync(reader, context, cancellationToken); + } + + /// + /// Gets the most recent value of a timestamp column from Postgres for incremental collection. + /// Returns null on first run or if the query fails (caller uses a fallback window) — the + /// Postgres twin of Lite's GetLastCollectedTimeAsync. + /// + public async Task GetLastCollectedTimeAsync( + int serverId, string tableName, string columnName, CancellationToken cancellationToken) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(cancellationToken); + using var command = new NpgsqlCommand( + $"SELECT MAX({columnName}) FROM {tableName} WHERE server_id = $1", connection); + command.Parameters.AddWithValue(serverId); + var result = await command.ExecuteScalarAsync(cancellationToken); + if (result is DateTime dt) + { + return dt; + } + } + catch + { + /* If the Postgres query fails, caller uses fallback window */ + } + return null; + } + + /// + /// Postgres twin of Lite's GetLastCollectedTimeForDatabaseAsync: the newest already-collected + /// value for ONE database, for definitions with a PerDatabaseWatermarkColumn (Azure SQL DB + /// per-database XE capture, #1535). Null on first run for that database or on failure — the + /// caller falls back to the definition's documented window. + /// + public async Task GetLastCollectedTimeForDatabaseAsync( + int serverId, string tableName, string columnName, string databaseColumnName, string databaseName, CancellationToken cancellationToken) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(cancellationToken); + using var command = new NpgsqlCommand( + $"SELECT MAX({columnName}) FROM {tableName} WHERE server_id = $1 AND {databaseColumnName} = $2", connection); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(databaseName); + var result = await command.ExecuteScalarAsync(cancellationToken); + if (result is DateTime dt) + { + return dt; + } + } + catch + { + /* If the Postgres query fails, caller uses fallback window */ + } + return null; + } + + /// + /// Gets the most recent value of a monotonic bigint identity column from Postgres for incremental + /// collection — the numeric twin of (job_history dedups on + /// instance_id, sysjobhistory's IDENTITY bigint). Returns null on first run or if the query + /// fails (caller uses its documented first-run/fallback path). + /// + public async Task GetLastCollectedInstanceIdAsync( + int serverId, string tableName, string columnName, CancellationToken cancellationToken) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(cancellationToken); + using var command = new NpgsqlCommand( + $"SELECT MAX({columnName}) FROM {tableName} WHERE server_id = $1", connection); + command.Parameters.AddWithValue(serverId); + var result = await command.ExecuteScalarAsync(cancellationToken); + if (result is not null && result != DBNull.Value) + { + return Convert.ToInt64(result); + } + } + catch + { + /* If the Postgres query fails, caller uses fallback window */ + } + return null; + } + + /// + /// Whether a prior SUCCESS row exists in collection_log for this collector+server — the "has collected + /// before" signal (), consulted only when the watermark + /// is null. Returns false on any failure, which errs toward the all-history first run (correct for a + /// genuinely fresh store). + /// + public async Task HasPriorCollectorSuccessAsync(int serverId, string collectorName, CancellationToken cancellationToken) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(cancellationToken); + using var command = new NpgsqlCommand( + "SELECT EXISTS(SELECT 1 FROM collection_log WHERE server_id = $1 AND collector_name = $2 AND status = 'SUCCESS')", connection); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(collectorName); + var result = await command.ExecuteScalarAsync(cancellationToken); + return result is bool b && b; + } + catch + { + /* Fail toward first-run (all-history) — matches a fresh store with no log yet. */ + return false; + } + } + + /// + /// Drops any cached master-inaccessible verdict for a server that has just reconnected. + /// + /// Azure SQL DB reports "this login may not read master" and "you cannot reach this server right + /// now" with overlapping error numbers, so a verdict formed while a server was failing is not + /// trustworthy. The moment it answers again is the moment to discard that verdict and re-probe. + /// Without this, a transient outage permanently misfiles a login that CAN read master, and + /// database-scoped collection stays degraded until the service restarts (#1506). + /// + public void OnServerReconnected(int serverId) + { + if (_azureMasterInaccessibleSince.TryRemove(serverId, out _)) + { + _logger?.LogInformation("[server_id {ServerId}] reconnected — re-probing master for database-scoped collectors.", serverId); + } + } + + /// + /// Lists databases on an Azure SQL DB logical server, mirroring Lite's #857 behavior: try + /// master enumeration first (with the per-server exclusion filter), and on a master-access + /// error fall back to the connection's own database, throttling re-probes per server. + /// + internal async Task> GetAzureDatabaseListAsync(ServerRuntime server, CancellationToken cancellationToken) + { + var targetDb = new SqlConnectionStringBuilder(server.ConnectionString).InitialCatalog; + + /* Skip the throttle when there is nothing to fall back TO — see Lite's twin. With no target + database the fallback can only throw, so honouring the throttle would guarantee 15 minutes + of failure without ever attempting to recover. */ + var hasFallback = SingleDbOrEmpty(targetDb).Count > 0; + + if (hasFallback && IsMasterProbeThrottled(server.ServerId)) + { + return FallbackDatabaseList(server, targetDb, reason: "master previously inaccessible", quiet: true); + } + + var masterConnectionString = new SqlConnectionStringBuilder(server.ConnectionString) + { + InitialCatalog = "master", + }.ConnectionString; + + var (exclusionClause, exclusionParameters) = DatabaseExclusionFilter.Build( + server.Config.ExcludedDatabases, "name"); + + var databases = new List(); + try + { + using var connection = new SqlConnection(masterConnectionString); + await connection.OpenAsync(cancellationToken); + using var command = new SqlCommand( + $"SELECT name FROM sys.databases WHERE state_desc = N'ONLINE' AND database_id > 0 {exclusionClause} ORDER BY name;", + connection) + { CommandTimeout = CommandTimeoutSeconds }; + foreach (var parameter in exclusionParameters) + { + command.Parameters.Add(ToSqlParameter(parameter)); + } + using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + databases.Add(reader.GetString(0)); + } + + _azureMasterInaccessibleSince.TryRemove(server.ServerId, out _); + return databases; + } + catch (SqlException ex) when (IsMasterAccessDeniedError(ex.Number)) + { + _azureMasterInaccessibleSince[server.ServerId] = DateTime.UtcNow; + + return FallbackDatabaseList(server, targetDb, reason: $"master DB inaccessible (SQL error {ex.Number})"); + } + } + + /// + /// True while a recent master-inaccessible verdict still stands. It expires so a server whose + /// access was restored recovers on its own rather than staying degraded until restart (#1506). + /// + private bool IsMasterProbeThrottled(int serverId) + { + if (!_azureMasterInaccessibleSince.TryGetValue(serverId, out var deniedAt)) + { + return false; + } + + if (DateTime.UtcNow - deniedAt < AzureMasterRecheckInterval) + { + return true; + } + + _azureMasterInaccessibleSince.TryRemove(serverId, out _); + return false; + } + + /// + /// The database list to use when master cannot be enumerated: the connection's own catalog. + /// + /// When there isn't one, database-scoped collectors have nowhere to read from. That used to be a + /// warning and an empty list, which made every one of them report success having collected zero + /// rows. Throwing puts the failure where it can actually be seen (#1506). + /// + /// + /// Set on the throttled path, which runs for every database-scoped collector on every cycle. Only + /// forming the verdict is worth an Information line; re-reading it is not. + /// + private List FallbackDatabaseList(ServerRuntime server, string? targetDb, string reason, bool quiet = false) + { + var fallback = SingleDbOrEmpty(targetDb); + + if (fallback.Count == 0) + { + throw new InvalidOperationException( + $"{reason}, and this connection has no target database to fall back to (it resolves to " + + $"master). Set a database for '{server.Config.DisplayName}' so database-scoped collectors " + + $"have something to read."); + } + + if (quiet) + { + _logger?.LogDebug("[{Server}] {Reason} — collecting from '{Database}' only.", + server.Config.DisplayName, reason, targetDb); + } + else + { + _logger?.LogInformation("[{Server}] {Reason} — collecting from '{Database}' only.", + server.Config.DisplayName, reason, targetDb); + } + + return fallback; + } + + internal async Task OpenAzureDatabaseConnectionAsync(ServerRuntime server, string databaseName, CancellationToken cancellationToken) + { + var connectionString = new SqlConnectionStringBuilder(server.ConnectionString) + { + InitialCatalog = databaseName, + }.ConnectionString; + + var connection = new SqlConnection(connectionString); + await connection.OpenAsync(cancellationToken); + return connection; + } + + private static List SingleDbOrEmpty(string? targetDb) + { + if (string.IsNullOrEmpty(targetDb) || string.Equals(targetDb, "master", StringComparison.OrdinalIgnoreCase)) + { + return new List(); + } + return new List { targetDb }; + } + + /// + /// Error numbers indicating the login cannot open or read from master on Azure SQL DB — + /// trigger the single-database fallback (verbatim from Lite). + /// + /// + /// Whether this error means the login cannot read master, in which case database-scoped collectors + /// fall back to the connection's own catalog (#857). The list — and the reason a reachability error + /// must never be on it (#1506) — is owned by , shared with Lite + /// so the two cannot drift. This bug reached Darling because the list was duplicated here. + /// + internal static bool IsMasterAccessDeniedError(int errorNumber) => + SqlErrorClassification.IsMasterAccessDenied(errorNumber); + + private static SqlCommand CreateCollectorCommand(CollectorQuery plan, SqlConnection connection, int commandTimeoutSeconds) + { + var command = new SqlCommand(plan.Text, connection) { CommandTimeout = commandTimeoutSeconds }; + + foreach (var parameter in plan.Parameters) + { + command.Parameters.Add(ToSqlParameter(parameter)); + } + + return command; + } + + private static SqlParameter ToSqlParameter(CollectorParameter parameter) => parameter.Type switch + { + CollectorParameterType.DateTime2 => new SqlParameter(parameter.Name, SqlDbType.DateTime2) { Value = parameter.Value ?? DBNull.Value }, + CollectorParameterType.NVarChar128 => new SqlParameter(parameter.Name, SqlDbType.NVarChar, 128) { Value = parameter.Value ?? DBNull.Value }, + CollectorParameterType.Int32 => new SqlParameter(parameter.Name, SqlDbType.Int) { Value = parameter.Value ?? DBNull.Value }, + CollectorParameterType.BigInt => new SqlParameter(parameter.Name, SqlDbType.BigInt) { Value = parameter.Value ?? DBNull.Value }, + _ => throw new ArgumentOutOfRangeException(nameof(parameter), parameter.Type, "Unmapped collector parameter type"), + }; +} diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingCommandExecutor.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingCommandExecutor.cs new file mode 100644 index 000000000..ea14113b5 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingCommandExecutor.cs @@ -0,0 +1,680 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Npgsql; +using NpgsqlTypes; +using PerformanceMonitor.Collectors; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// The service side of the store<->service COMMAND plane (Stage 2): the Viewer (Stage 3) enqueues rows +/// into config.config_command; this executor CLAIMS one at a time, EXECUTES it, and REPORTS the +/// terminal outcome back on the same row so the Viewer can poll for the result (esp. test_connect's +/// probe facts). Mirrors 's style: pure, testable dispatch +/// () separated from the store I/O, failure-isolated (a bad command is reported +/// failed, never thrown out of the poller). +/// +/// The claim is safe against multiple service instances: selects the +/// oldest pending row FOR UPDATE SKIP LOCKED, so two services polling the same store each +/// grab a DIFFERENT row (or none) instead of racing the same one. The desired-state commands +/// (pause/resume/enable/disable/reload) only WRITE the config.* tables — the bump trigger fires the +/// config_version reload beacon and the worker's existing reconcile applies them; the executor never +/// mutates the live collection loop's state directly. The imperative commands that DO need the running loop +/// (snapshot_now/analyze_now) are delegated to the worker through +/// . +/// +public sealed class DarlingCommandExecutor +{ + /// + /// The atomic claim (the correctness-critical piece): mark the OLDEST pending row + /// in_progress and RETURN it, guarded by FOR UPDATE SKIP LOCKED in the sub-select so + /// concurrent service instances never claim the same row. Public const so a test can pin the shape. + /// + public const string ClaimCommandSql = @" +UPDATE config.config_command +SET status = 'in_progress', + claimed_at = now() AT TIME ZONE 'UTC', + service_instance = $1 +WHERE command_id = ( + SELECT command_id + FROM config.config_command + WHERE status = 'pending' + ORDER BY command_id + LIMIT 1 + FOR UPDATE SKIP LOCKED +) +RETURNING command_id, command_type, target_server_id, args_json, requested_by"; + + /// + /// Writes the terminal outcome back on the claimed row (succeeded/failed + result_status/json). Internal + /// so the round-trip test can pin + exercise it; $1 command_id, $2 status, $3 result_status, $4 result_json. + /// + /// Secret retention (#1262 Low follow-up): nulls args_json as the row reaches a + /// terminal state — a test_connect command's args_json carries an inline credential blob, + /// and there is no reason to retain it once the probe has run. The claim already captured args_json into + /// the in-memory before execution, and the Viewer's terminal-result poll + /// reads result_status/result_json (never args), so scrubbing it here is transparent. The + /// stale-command reaper scrubs it the same way () for a row that + /// never reported. result_json holds only probe facts / an error string, never a secret, so it stays. + /// + internal const string ReportCommandSql = @" +UPDATE config.config_command +SET status = $2, + completed_at = now() AT TIME ZONE 'UTC', + result_status = $3, + result_json = $4, + args_json = NULL +WHERE command_id = $1"; + + /// + /// The stale-command reaper (#1262 Stage 2 robustness follow-up): reclaims a command abandoned + /// in_progress because the claiming service crashed or restarted between the claim and the report. + /// Without this the row stays in_progress forever and a Viewer polling it for a terminal result + /// waits indefinitely. A row whose claimed_at is older than the stale timeout ($1, a PG interval) + /// is marked terminal failed with a clear result_status so the poll completes, and its + /// args_json is nulled at the same time (the same terminal secret scrub the report path does). + /// Public const so a test can pin the shape. + /// + /// Why fail-terminal, not re-queue to pending: re-running a reclaimed command is the + /// UNSAFE choice — the imperative commands are not all idempotent (a half-run snapshot_now already + /// wrote its collector rows, so a re-run double-writes them and races the shared delta baselines), and a + /// command that CRASHED the service would crash it again on every reclaim (a poison-message loop that + /// never drains). Marking it failed always unblocks the Viewer (the actual bug) and leaves the + /// idempotent commands (pause/resume/test_connect/toggles) for an operator to re-issue by hand. The + /// is wide enough that a merely-slow live command is never reaped. + /// + public const string ReclaimStaleCommandsSql = @" +UPDATE config.config_command +SET status = 'failed', + completed_at = now() AT TIME ZONE 'UTC', + result_status = 'reclaimed (service restart or timeout)', + result_json = '{""success"": false, ""error"": ""command reclaimed: the claiming service did not report a result within the stale-command timeout (a service restart or a crash mid-command)""}'::jsonb, + args_json = NULL +WHERE status = 'in_progress' + AND claimed_at < (now() AT TIME ZONE 'UTC') - $1"; + + /// + /// The stale-command reaper timeout: an in_progress command whose claimed_at is older than + /// this is reclaimed as failed. Five minutes is a wide margin over the slowest command — a + /// test_connect is bounded by the SQL connect timeout (~15-30s) and analyze_now self-caps + /// at 120s — so the reaper can never catch a command that is merely slow, only one genuinely abandoned. + /// A hardcoded default (not a config knob) per the "defaults over speculative config" rule. + /// + public static readonly TimeSpan StaleCommandTimeout = TimeSpan.FromMinutes(5); + + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + private readonly NpgsqlDataSource _postgres; + private readonly IDarlingCommandHost _host; + private readonly string _serviceInstance; + private readonly ILogger? _logger; + + public DarlingCommandExecutor(NpgsqlDataSource postgres, IDarlingCommandHost host, string serviceInstance, ILogger? logger = null) + { + _postgres = postgres ?? throw new ArgumentNullException(nameof(postgres)); + _host = host ?? throw new ArgumentNullException(nameof(host)); + _serviceInstance = serviceInstance ?? throw new ArgumentNullException(nameof(serviceInstance)); + _logger = logger; + } + + /// + /// Claims and executes at most one pending command, reporting its outcome. Returns true if a command was + /// claimed (so the caller can drain a backlog by looping until it returns false). Store-unreachable and + /// any per-command failure are swallowed (logged) — the poller must never die for one bad tick. + /// + public async Task PollOnceAsync(CancellationToken cancellationToken) + { + ClaimedCommand? claimed; + try + { + claimed = await ClaimNextAsync(cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger?.LogWarning("Could not poll the command queue — will retry next tick: {Message}", ex.Message); + return false; + } + + if (claimed is null) + { + return false; + } + + await ExecuteAndReportAsync(claimed, cancellationToken); + return true; + } + + /// + /// Runs one stale-command reaper sweep () and returns the number of + /// commands reclaimed. Failure-isolated like the rest of the poller — a store blip logs and returns 0 + /// rather than killing the command loop. Cheap enough to run every tick: the WHERE is served by the + /// status index and matches nothing in the normal case (commands are claimed and reported within + /// seconds), and an UPDATE that changes no rows writes no WAL. + /// + public async Task ReclaimStaleCommandsAsync(CancellationToken cancellationToken) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(cancellationToken); + using var command = new NpgsqlCommand(ReclaimStaleCommandsSql, connection); + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Interval, Value = StaleCommandTimeout }); + + var reclaimed = await command.ExecuteNonQueryAsync(cancellationToken); + if (reclaimed > 0) + { + _logger?.LogWarning( + "Reclaimed {Count} stale in_progress command(s) claimed over {Timeout} ago with no result reported (a service restart or a crash mid-command) — marked failed so the Viewer's poll completes", + reclaimed, StaleCommandTimeout); + } + + return reclaimed; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger?.LogWarning("Could not run the stale-command reaper — will retry next tick: {Message}", ex.Message); + return 0; + } + } + + /// Atomically claims the oldest pending command (or null when the queue is empty). + public async Task ClaimNextAsync(CancellationToken cancellationToken) + { + await using var connection = await _postgres.OpenConnectionAsync(cancellationToken); + using var command = new NpgsqlCommand(ClaimCommandSql, connection); + command.Parameters.AddWithValue(_serviceInstance); + + using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + { + return null; + } + + return new ClaimedCommand( + CommandId: reader.GetInt64(0), + CommandType: reader.GetString(1), + TargetServerId: reader.IsDBNull(2) ? null : reader.GetInt32(2), + ArgsJson: reader.IsDBNull(3) ? null : reader.GetFieldValue(3), + RequestedBy: reader.IsDBNull(4) ? null : reader.GetString(4)); + } + + /// + /// Executes one claimed command and writes its terminal result. Never throws out (an unexpected + /// exception is caught and reported as failed/error), so the poller keeps running. + /// + public async Task ExecuteAndReportAsync(ClaimedCommand command, CancellationToken cancellationToken) + { + CommandOutcome outcome; + try + { + outcome = await RunAsync(command, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger?.LogError("Command {Id} ({Type}) failed: {Message}", command.CommandId, command.CommandType, ex.Message); + outcome = new CommandOutcome(false, "error", ErrorJson(ex.Message)); + } + + await ReportAsync(command.CommandId, outcome, cancellationToken); + } + + private async Task RunAsync(ClaimedCommand command, CancellationToken cancellationToken) + { + var plan = ResolvePlan(command); + switch (plan.Kind) + { + case CommandKind.Fail: + _logger?.LogWarning("Command {Id}: {Reason} (type '{Type}')", command.CommandId, plan.FailReason, command.CommandType); + return new CommandOutcome(false, plan.FailReason!, ErrorJson(plan.FailReason!)); + + case CommandKind.StoreWrite: + await ExecuteStoreWriteAsync(plan, cancellationToken); + _logger?.LogInformation("Command {Id} ({Type}) => {Result}", command.CommandId, command.CommandType, plan.SuccessStatus); + return new CommandOutcome(true, plan.SuccessStatus, DetailJson(plan.SuccessStatus)); + + case CommandKind.Probe: + { + var server = DeserializeServer(command.ArgsJson!); + if (server is null) + { + return new CommandOutcome(false, "invalid args_json", ErrorJson("test_connect args_json did not deserialize to a server definition")); + } + + var probe = await DarlingServerConnector.ProbeAsync(server, _logger, cancellationToken); + var (resultStatus, resultJson) = MapProbeResult(probe); + _logger?.LogInformation("Command {Id} (test_connect '{Server}') => {Result}", command.CommandId, server.DisplayName, resultStatus); + return new CommandOutcome(probe.Success, resultStatus, resultJson); + } + + case CommandKind.Snapshot: + return await _host.SnapshotNowAsync(command.TargetServerId!.Value, cancellationToken); + + case CommandKind.Analyze: + return await _host.AnalyzeNowAsync(command.TargetServerId!.Value, cancellationToken); + + case CommandKind.Purge: + { + /* Fleet-wide over the shared tables (no target server). The optional custom retention comes + from args_json (default = the configured fleet horizons); parse it here like the other + host-delegated branches re-derive their args. */ + var customRetentionDays = TryReadInt(command.ArgsJson, "retention_days", "retentionDays"); + return await _host.PurgeNowAsync(customRetentionDays, cancellationToken); + } + + case CommandKind.FetchPlan: + { + /* Re-parse args_json here (the pure dispatch already validated it parses) so the host receives + the structured request — mirrors the Probe branch re-deriving its MonitoredServer. */ + if (!PlanFetchRequest.TryParse(command.ArgsJson, out var fetchRequest)) + { + return new CommandOutcome(false, "invalid args_json", + ErrorJson("fetch_plan args_json did not carry a plan_handle or sql_handle")); + } + + return await _host.FetchPlanAsync(command.TargetServerId!.Value, fetchRequest, cancellationToken); + } + + case CommandKind.ExecuteActualPlan: + { + /* Re-parse args_json here (the pure dispatch already validated it parses) so the host receives + the structured request — mirrors the FetchPlan branch. The payload is an IDENTIFIER ONLY + (query_hash + database_name); the host resolves the text/plan from the store and re-executes. */ + if (!ActualPlanRequest.TryParse(command.ArgsJson, out var actualPlanRequest)) + { + return new CommandOutcome(false, "invalid args_json", + ErrorJson("execute_actual_plan args_json did not carry a query_hash")); + } + + return await _host.ExecuteActualPlanAsync(command.TargetServerId!.Value, actualPlanRequest, cancellationToken); + } + + case CommandKind.FetchActiveQueries: + /* Worker-delegated like fetch_plan (needs the target's LIVE connection to read the running-request + DMVs). Takes NO args — the target_server_id on the command row is the whole request; the host + runs the shared Active Queries collector's query on demand and returns the rows in result_json. */ + return await _host.FetchActiveQueriesLiveAsync(command.TargetServerId!.Value, cancellationToken); + + default: + return new CommandOutcome(false, "unknown command_type", ErrorJson("unknown command_type")); + } + } + + /// + /// PURE dispatch: maps a claimed command to a plan without touching the store, so every branch — incl. + /// the argument-validation failures and the unknown-type fallback — is unit-testable. Store-write + /// commands carry their parameterized SQL (the toggled boolean is a compile-time literal, never user + /// input; every value is a bound parameter); the imperative/probe commands carry only a kind. + /// + internal static CommandPlan ResolvePlan(ClaimedCommand command) + { + switch ((command.CommandType ?? string.Empty).Trim().ToLowerInvariant()) + { + case "pause": + return StoreWrite("UPDATE config.config_service SET paused = TRUE WHERE id = 1", null, "paused"); + + case "resume": + return StoreWrite("UPDATE config.config_service SET paused = FALSE WHERE id = 1", null, "resumed"); + + case "reload": + /* No state change — an UPDATE bumps config_version via the self-bump trigger, forcing the + worker's next-sweep reload (an explicit resync). */ + return StoreWrite("UPDATE config.config_service SET updated_at = now() AT TIME ZONE 'UTC' WHERE id = 1", null, "reload signaled"); + + case "enable_server": + return command.TargetServerId is int enableId + ? StoreWrite( + "UPDATE config.config_monitored_servers SET is_enabled = TRUE, modified_at = now() AT TIME ZONE 'UTC' WHERE server_id = $1", + new object?[] { enableId }, "server enabled") + : Fail("enable_server requires target_server_id"); + + case "disable_server": + return command.TargetServerId is int disableId + ? StoreWrite( + "UPDATE config.config_monitored_servers SET is_enabled = FALSE, modified_at = now() AT TIME ZONE 'UTC' WHERE server_id = $1", + new object?[] { disableId }, "server disabled") + : Fail("disable_server requires target_server_id"); + + case "enable_collector": + return ResolveCollectorToggle(command, enabled: true); + + case "disable_collector": + return ResolveCollectorToggle(command, enabled: false); + + case "test_connect": + return string.IsNullOrWhiteSpace(command.ArgsJson) + ? Fail("test_connect requires args_json with the server definition") + : new CommandPlan(CommandKind.Probe, null, null, "connected", null); + + case "snapshot_now": + return command.TargetServerId is int + ? new CommandPlan(CommandKind.Snapshot, null, null, "snapshot complete", null) + : Fail("snapshot_now requires target_server_id"); + + case "analyze_now": + return command.TargetServerId is int + ? new CommandPlan(CommandKind.Analyze, null, null, "analysis complete", null) + : Fail("analyze_now requires target_server_id"); + + case "purge_now": + /* The daily retention purge on demand. Fleet-wide over the SHARED store tables, so — unlike + snapshot_now/analyze_now — it needs NO target_server_id. An optional custom retention + (args_json.retention_days) is read at execution time; absent = the configured fleet horizons. + Always resolvable: there are no required arguments. */ + return new CommandPlan(CommandKind.Purge, null, null, "purge complete", null); + + case "fetch_plan": + /* Worker-delegated like snapshot_now (needs the target's LIVE runtime connection to read its + plan cache). Requires a target server AND args_json carrying a plan_handle or sql_handle. */ + if (command.TargetServerId is null) + { + return Fail("fetch_plan requires target_server_id"); + } + + return PlanFetchRequest.TryParse(command.ArgsJson, out _) + ? new CommandPlan(CommandKind.FetchPlan, null, null, "plan fetched", null) + : Fail("fetch_plan requires args_json with a plan_handle or sql_handle"); + + case "execute_actual_plan": + /* Worker-delegated (needs the target's LIVE runtime connection to RE-EXECUTE the query) and the + store (to resolve the query text/plan from the identifier). Requires a target server AND + args_json carrying the stored-row key. This is the one command that WRITES to a target (it + re-applies a modifying query's changes), so it rides the same read-write-seat-only enqueue as + every other command — a read-only viewer cannot reach it. */ + if (command.TargetServerId is null) + { + return Fail("execute_actual_plan requires target_server_id"); + } + + return ActualPlanRequest.TryParse(command.ArgsJson, out _) + ? new CommandPlan(CommandKind.ExecuteActualPlan, null, null, "actual plan captured", null) + : Fail("execute_actual_plan requires args_json with a query_hash"); + + case "fetch_active_queries": + /* Worker-delegated like fetch_plan (needs the target's LIVE runtime connection to read what is + running now). Requires a target server; takes NO args_json (the whole request is "read this + server's active queries"). Read-only DMV read — NOT consent-class like execute_actual_plan — + but it rides the same read-write-seat-only enqueue as every other command. */ + return command.TargetServerId is int + ? new CommandPlan(CommandKind.FetchActiveQueries, null, null, "active queries fetched", null) + : Fail("fetch_active_queries requires target_server_id"); + + default: + return Fail("unknown command_type"); + } + } + + /// + /// enable/disable_collector -> upsert config_collector_schedules.enabled for the collector named in + /// args_json, scoped to target_server_id (else args_json.server_id, else fleet-wide when both are + /// absent). Touches ONLY the enabled column (ON CONFLICT DO UPDATE), so an existing frequency/retention + /// override is preserved. The two ON CONFLICT arbiters match V17's partial unique indexes (a PK cannot + /// span the nullable server_id). + /// + private static CommandPlan ResolveCollectorToggle(ClaimedCommand command, bool enabled) + { + var verb = enabled ? "enable_collector" : "disable_collector"; + var collectorName = TryReadString(command.ArgsJson, "collector_name", "collectorName"); + if (string.IsNullOrWhiteSpace(collectorName)) + { + return Fail($"{verb} requires args_json.collector_name"); + } + + if (!CollectorScheduleDefaults.All.ContainsKey(collectorName)) + { + return Fail($"{verb}: unknown collector '{collectorName}'"); + } + + var flag = enabled ? "TRUE" : "FALSE"; + var successStatus = enabled ? "collector enabled" : "collector disabled"; + var serverId = command.TargetServerId ?? TryReadInt(command.ArgsJson, "server_id", "serverId"); + + if (serverId is int scopedId) + { + return StoreWrite( + "INSERT INTO config.config_collector_schedules (server_id, collector_name, enabled) VALUES ($1, $2, " + flag + ") " + + "ON CONFLICT (server_id, collector_name) WHERE server_id IS NOT NULL DO UPDATE SET enabled = EXCLUDED.enabled", + new object?[] { scopedId, collectorName }, successStatus); + } + + return StoreWrite( + "INSERT INTO config.config_collector_schedules (server_id, collector_name, enabled) VALUES (NULL, $1, " + flag + ") " + + "ON CONFLICT (collector_name) WHERE server_id IS NULL DO UPDATE SET enabled = EXCLUDED.enabled", + new object?[] { collectorName }, successStatus + " (fleet-wide)"); + } + + /// test_connect result mapping (pure): success carries the probe facts, failure the error. + internal static (string ResultStatus, string ResultJson) MapProbeResult(ConnectionProbeResult probe) + { + if (probe.Success) + { + var json = JsonSerializer.Serialize(new + { + success = true, + majorVersion = probe.MajorVersion, + engineEdition = probe.EngineEdition, + engineEditionDescription = probe.EngineEditionDescription, + isAzureSqlDb = probe.IsAzureSqlDb, + isAzureManagedInstance = probe.IsAzureManagedInstance, + isAwsRds = probe.IsAwsRds, + hasMsdbAccess = probe.HasMsdbAccess, + }); + return ("connected", json); + } + + return ("connection_failed", ErrorJson(probe.Error ?? "connection failed")); + } + + private async Task ExecuteStoreWriteAsync(CommandPlan plan, CancellationToken cancellationToken) + { + await using var connection = await _postgres.OpenConnectionAsync(cancellationToken); + using var command = new NpgsqlCommand(plan.Sql, connection); + if (plan.Parameters is not null) + { + foreach (var parameter in plan.Parameters) + { + command.Parameters.AddWithValue(parameter ?? (object)DBNull.Value); + } + } + + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private async Task ReportAsync(long commandId, CommandOutcome outcome, CancellationToken cancellationToken) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(cancellationToken); + using var command = new NpgsqlCommand(ReportCommandSql, connection); + command.Parameters.AddWithValue(commandId); + command.Parameters.AddWithValue(outcome.Success ? "succeeded" : "failed"); + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Text, Value = (object?)outcome.ResultStatus ?? DBNull.Value }); + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Jsonb, Value = (object?)outcome.ResultJson ?? DBNull.Value }); + await command.ExecuteNonQueryAsync(cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + /* Failure-isolated: a store blip writing the result must not kill the poller; the row stays + in_progress and a later reader can see it never terminated. */ + _logger?.LogWarning("Could not write result for command {Id}: {Message}", commandId, ex.Message); + } + } + + private static MonitoredServer? DeserializeServer(string argsJson) + { + try + { + return JsonSerializer.Deserialize(argsJson, s_jsonOptions); + } + catch (JsonException) + { + return null; + } + } + + private static string? TryReadString(string? json, params string[] names) + { + var value = TryReadProperty(json, names); + return value.HasValue && value.Value.ValueKind == JsonValueKind.String ? value.Value.GetString() : null; + } + + private static int? TryReadInt(string? json, params string[] names) + { + var value = TryReadProperty(json, names); + return value.HasValue && value.Value.ValueKind == JsonValueKind.Number && value.Value.TryGetInt32(out var i) ? i : null; + } + + private static JsonElement? TryReadProperty(string? json, string[] names) + { + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + try + { + using var document = JsonDocument.Parse(json); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + return null; + } + + foreach (var name in names) + { + if (document.RootElement.TryGetProperty(name, out var element)) + { + return element.Clone(); + } + } + } + catch (JsonException) + { + /* Malformed args_json — the caller degrades to "missing". */ + } + + return null; + } + + private static CommandPlan StoreWrite(string sql, object?[]? parameters, string successStatus) => + new(CommandKind.StoreWrite, sql, parameters, successStatus, null); + + private static CommandPlan Fail(string reason) => + new(CommandKind.Fail, null, null, string.Empty, reason); + + private static string ErrorJson(string error) => JsonSerializer.Serialize(new { success = false, error }); + + private static string DetailJson(string detail) => JsonSerializer.Serialize(new { success = true, detail }); +} + +/// A row claimed from config.config_command. +public sealed record ClaimedCommand(long CommandId, string CommandType, int? TargetServerId, string? ArgsJson, string? RequestedBy); + +/// The terminal outcome written back on a command row. +public sealed record CommandOutcome(bool Success, string ResultStatus, string? ResultJson); + +/// How a command is carried out — the four execution shapes plus the fail-fast fallback. +public enum CommandKind +{ + /// A parameterized write to the config.* tables (the bump trigger drives the reload). + StoreWrite, + + /// test_connect: deserialize args_json, probe the server, report the facts/error. + Probe, + + /// snapshot_now: run the running loop's collectors for a server now (via the host). + Snapshot, + + /// analyze_now: force an immediate analysis pass for a server now (via the host). + Analyze, + + /// purge_now: run the retention purge across the shared store now (fleet-wide, via the host). + Purge, + + /// fetch_plan: read a plan from a server's LIVE plan cache by plan_handle or sql_handle (via the host). + FetchPlan, + + /// execute_actual_plan: RE-EXECUTE a stored query (resolved from the store by the identifier) to + /// capture its ACTUAL plan with runtime stats (via the host). The one command that writes to a target. + ExecuteActualPlan, + + /// fetch_active_queries: read the LIVE running-request DMV snapshot from a server on demand (via + /// the host, running the shared Active Queries collector's query) and return the rows in result_json. Read-only. + FetchActiveQueries, + + /// Bad arguments or an unknown command_type — report failed without touching the store. + Fail, +} + +/// The resolved (pure) plan for one command — see . +public sealed record CommandPlan(CommandKind Kind, string? Sql, object?[]? Parameters, string SuccessStatus, string? FailReason); + +/// +/// The worker's callback surface for the two imperative commands that need the LIVE collection loop: +/// snapshot_now (run a server's collectors now) and analyze_now (force an analysis pass now). +/// Kept an interface so the executor's dispatch is testable with a fake host, and so the executor never +/// reaches into the worker's mutable loop state directly. +/// +public interface IDarlingCommandHost +{ + Task SnapshotNowAsync(int serverId, CancellationToken cancellationToken); + + Task AnalyzeNowAsync(int serverId, CancellationToken cancellationToken); + + /// + /// purge_now: run the retention purge over the shared store immediately (the daily + /// on demand). Fleet-wide over shared tables, so no target + /// server; (from args_json) purges every collector to that horizon + /// when set, else the configured fleet horizons apply. + /// + Task PurgeNowAsync(int? customRetentionDays, CancellationToken cancellationToken); + + /// + /// fetch_plan: read an execution plan from the target server's LIVE plan cache (by plan_handle or by + /// sql_handle + offsets — see ) on the server's runtime connection, and return + /// the plan XML (or a "not in cache" / "not connected" outcome). Read-only, like the other worker-delegated + /// commands but reaching the target SQL Server rather than the store. + /// + Task FetchPlanAsync(int serverId, PlanFetchRequest request, CancellationToken cancellationToken); + + /// + /// execute_actual_plan: RE-EXECUTE a stored query against the target server (SET STATISTICS XML) to + /// capture its ACTUAL plan with runtime stats, and return the plan XML. The request is an IDENTIFIER ONLY + /// (, the stored-row key) — the host resolves the query text + database + + /// estimated plan XML from the SERVICE'S OWN store (never from the payload, so no SQL text can ride the + /// command plane) and re-executes as the server's stored monitoring credential. Unlike every other + /// worker-delegated command this WRITES to the target (it re-applies any INSERT/UPDATE/DELETE/MERGE), so + /// the viewer gates it behind informed consent; the permission-denied / timeout cases return a legible + /// outcome rather than a raw SQL error. + /// + Task ExecuteActualPlanAsync(int serverId, ActualPlanRequest request, CancellationToken cancellationToken); + + /// + /// fetch_active_queries: read the LIVE running-request DMV snapshot from the target server on demand + /// (the shared Active Queries collector's query, run on the server's runtime connection) and return the rows + /// in result_json — the on-demand, live counterpart of the collector's stored hourly snapshot, for the + /// viewer's Current Active Queries tab. Read-only (sys.dm_exec_requests/sessions + the sql_text / + /// query_plan DMFs), so — unlike — it is NOT consent-class; it still + /// reaches the target SQL Server like the other worker-delegated commands, and the not-connected / timeout / + /// permission cases return a legible outcome rather than a raw SQL error. + /// + Task FetchActiveQueriesLiveAsync(int serverId, CancellationToken cancellationToken); +} diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs new file mode 100644 index 000000000..e874d22e2 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs @@ -0,0 +1,942 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text.Json; +using System.Text.Json.Serialization; +using PerformanceMonitor.Notifications; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// The service's configuration file (darling.json): the Postgres store and the monitored +/// servers. Headless plan D-M2-1 — deliberately minimal: no schedule knobs (the shared +/// CollectorScheduleDefaults apply; defaults over speculative config), integrated auth +/// recommended, SQL-auth passwords DPAPI-protected via the service's --encrypt-password verb. +/// Resolution order: explicit path → DARLING_CONFIG environment variable → darling.json next +/// to the service binary. +/// +public sealed class DarlingConfig +{ + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + [JsonPropertyName("postgres")] + public PostgresConfig Postgres { get; set; } = new(); + + [JsonPropertyName("servers")] + public List Servers { get; set; } = new(); + + /// + /// Capture execution-plan text into query_stats.query_plan_xml and + /// query_store_stats.query_plan_text. Default TRUE for Darling: PostgreSQL TOAST compresses the + /// plan text transparently (pglz), and TimescaleDB chunk compression squeezes it further, so + /// plans are cheap to keep — unlike Lite, which stores to DuckDB/parquet and deliberately never + /// captures them. Set false to skip plan capture (e.g. to shave storage across a very large + /// fleet). Feeds in the shared query_stats / + /// query_store collectors. + /// + [JsonPropertyName("capturePlans")] + public bool CapturePlans { get; set; } = true; + + /// + /// Whether the default_trace_events collector records Object:Created/Altered/Deleted schema-change + /// (DDL) events. Default TRUE (today's behavior). Set false on a noisy or benchmark box where a + /// create/drop-happy workload floods the viewer's System Events > Default Trace tab — e.g. HammerDB's + /// TPC-H Query 15 creates and drops a revenue view thousands of times, and the collector + /// faithfully records every create/delete. Only the Object DDL slice is suppressed; the file-growth, + /// ErrorLog, and security-audit categories are still collected. Feeds + /// — the shared collector's equivalent of the + /// full Dashboard proc's @include_object_events. A file-only knob (not seeded into the + /// control-plane store), so an edit takes effect on the next restart. + /// + [JsonPropertyName("collectSchemaChangeEvents")] + public bool CollectSchemaChangeEvents { get; set; } = true; + + /// + /// The shared alert engine's enabled flags and thresholds (Phase-5 slice D). Every default + /// mirrors Lite's App.* alert defaults exactly, so an empty section alerts like a + /// fresh Lite install. Optional — omit it entirely for the defaults. + /// + [JsonPropertyName("alerts")] + public AlertsConfig Alerts { get; set; } = new(); + + /// + /// SMTP delivery for fired alerts. Delivery is enabled when host + from + to are all set + /// (no separate flag — defaults over speculative config); the password uses the same DPAPI + /// --encrypt-password pattern as SQL auth. Optional. + /// + [JsonPropertyName("smtp")] + public SmtpConfig Smtp { get; set; } = new(); + + /// + /// Teams/Slack incoming-webhook delivery for fired alerts. A channel is enabled when its + /// URL is set. Optional. + /// + [JsonPropertyName("webhooks")] + public WebhooksConfig Webhooks { get; set; } = new(); + + /// + /// The scheduled-analysis cadence + delivery knobs (Phase-5 AN3 / control-plane Stage 1). Every + /// default mirrors Lite's App.* analysis defaults (enabled, 30-minute interval, + /// notify-severity 1.5) EXCEPT , which defaults + /// TRUE to preserve Darling's shipped behavior (the service gated analysis-finding delivery on the + /// master alerts.enabled switch, default on) rather than Lite's App default of false. Seeded + /// into config_alert_settings' analysis columns and read back live so the store is + /// authoritative after the first run. Optional — omit the section entirely for the defaults. + /// + [JsonPropertyName("analysis")] + public AnalysisConfig Analysis { get; set; } = new(); + + /// + /// The embedded MCP server (analysis slice AN4): the same analysis + data-read tool surface Lite + /// and the Dashboard expose (the analysis class plus the plan-analysis and ~60 STORED data-read + /// tools), over Streamable HTTP. Default OFF: a headless service should not open a port unless the + /// operator asks for it (both apps default their MCP servers off too). Loopback-only and tokenless + /// by default; an opt-in block exposes it on the LAN behind a + /// required bearer token + an in-app CIDR check (managed-mode only). Optional — omit the section entirely. + /// + [JsonPropertyName("mcp")] + public McpConfig Mcp { get; set; } = new(); + + /// + /// The embedded web dashboard (#1562): a second, independent Kestrel host serving a read-only, + /// browser-based view of the monitoring store over HTTP — a SEPARATE port + kill switch + exposure block + /// from the MCP server (they gate different blast radii). Default OFF: a headless service should not open a + /// port unless the operator asks. Loopback-only by default; an opt-in block + /// exposes it on the LAN behind a required token (once) → an HMAC session cookie + an in-app CIDR check + /// (managed-mode only). Loopback stays tokenless even while LAN-exposed (the web surface is read-only). + /// Optional — omit the section entirely. + /// + [JsonPropertyName("web")] + public WebConfig Web { get; set; } = new(); + + public static string ResolveConfigPath(string? explicitPath = null) + { + if (!string.IsNullOrWhiteSpace(explicitPath)) + { + return explicitPath; + } + + var fromEnvironment = Environment.GetEnvironmentVariable("DARLING_CONFIG"); + if (!string.IsNullOrWhiteSpace(fromEnvironment)) + { + return fromEnvironment; + } + + return Path.Combine(AppContext.BaseDirectory, "darling.json"); + } + + public static DarlingConfig Load(string? explicitPath = null) + { + var path = ResolveConfigPath(explicitPath); + if (!File.Exists(path)) + { + throw new FileNotFoundException( + $"Configuration file not found: {path}. Copy darling.sample.json to darling.json and edit it.", path); + } + + return Parse(File.ReadAllText(path)); + } + + public static DarlingConfig Parse(string json) + { + var config = JsonSerializer.Deserialize(json, s_jsonOptions); + return config ?? throw new InvalidDataException("Configuration file parsed to null."); + } + + /// + /// Validates the configuration; returns human-readable problems (empty = valid). + /// Plaintext passwords are accepted (dev convenience) but reported as warnings by the caller. + /// + public IReadOnlyList Validate() + { + var problems = new List(); + + if (Postgres is null) + { + problems.Add("postgres section is required."); + } + else if (Postgres.Managed) + { + /* Managed mode DERIVES the connection string (localhost + port + the generated + DPAPI-protected credential — see DarlingManagedPostgres); a hand-set string + would silently win or silently lose depending on code order, so both together + is a hard config error, not a precedence rule. */ + if (!string.IsNullOrWhiteSpace(Postgres.ConnectionString)) + { + problems.Add("postgres.managed is true AND postgres.connectionString is set — pick one: " + + "managed mode derives the connection string itself (remove connectionString), or remove " + + "\"managed\" to use your own PostgreSQL via connectionString."); + } + + if (Postgres.Port is < 1 or > 65535) + { + problems.Add($"postgres.port must be between 1 and 65535 (got {Postgres.Port})."); + } + } + else if (string.IsNullOrWhiteSpace(Postgres.ConnectionString)) + { + problems.Add("postgres.connectionString is required (or set postgres.managed = true to run the bundled server)."); + } + + if (Servers is null || Servers.Count == 0) + { + problems.Add("servers must contain at least one entry."); + return problems; + } + + for (int i = 0; i < Servers.Count; i++) + { + var server = Servers[i]; + var label = string.IsNullOrWhiteSpace(server.Name) ? $"servers[{i}]" : $"server '{server.Name}'"; + + if (string.IsNullOrWhiteSpace(server.Host)) + { + problems.Add($"{label}: host is required."); + } + + if (server.UsesSqlAuth) + { + if (string.IsNullOrWhiteSpace(server.Username)) + { + problems.Add($"{label}: sql auth requires username."); + } + + if (string.IsNullOrWhiteSpace(server.EncryptedPassword) && string.IsNullOrWhiteSpace(server.Password)) + { + problems.Add($"{label}: sql auth requires encryptedPassword (preferred; see --encrypt-password) or password."); + } + } + else if (!string.Equals(server.Auth, "integrated", StringComparison.OrdinalIgnoreCase)) + { + problems.Add($"{label}: auth must be 'integrated' or 'sql' (got '{server.Auth}')."); + } + } + + return problems; + } +} + +/// +/// The Postgres store — two modes. Unmanaged (default): points at +/// an existing PostgreSQL and the service never touches its lifecycle. Managed (the shipped +/// zero-admin default in darling.sample.json): = true and the service +/// unpacks, initializes, starts, and stops its own bundled PostgreSQL + TimescaleDB via +/// ; the connection string is DERIVED +/// (localhost + + the generated DPAPI-protected credential), so setting +/// too is a validation error. +/// +public sealed class PostgresConfig +{ + [JsonPropertyName("connectionString")] + public string ConnectionString { get; set; } = ""; + + /// Run the bundled, service-managed PostgreSQL instead of pointing at an existing one. + [JsonPropertyName("managed")] + public bool Managed { get; set; } + + /// + /// The managed server's loopback port. 5641 deliberately avoids PostgreSQL's default 5432 so + /// the bundled instance can coexist with a PostgreSQL the machine already runs. + /// + [JsonPropertyName("port")] + public int Port { get; set; } = 5641; + + /// + /// The managed server's data directory; null (the default) means + /// %ProgramData%\PerformanceMonitorDarling\pg — a machine-wide, service-account-writable + /// convention created with inherited ACLs. + /// + [JsonPropertyName("dataDirectory")] + public string? DataDirectory { get; set; } + + /// + /// Which least-privilege role the interactive Viewer connects as in managed mode (V8 security + /// hardening): "admin" (default — reads both schemas + writes the config tables the mute / + /// alert-dismiss / analysis-mute surfaces need) or "viewer" (read-only, for a locked-down + /// deployment where the Viewer's write actions degrade gracefully). Selects the role + credential + /// file the Viewer's managed derivation uses; ignored by the service (which always connects as the + /// owner) and in BYO mode (the operator's connectionString picks the role). Unknown values fall + /// back to admin. + /// + [JsonPropertyName("connectAs")] + public string ConnectAs { get; set; } = "admin"; + + /// + /// Opt-in network exposure for the managed store (darling-network-endpoints). Omit for the secure + /// default = loopback-only (byte-for-byte today's behavior). Managed-mode only; ignored in BYO with + /// a caller warning (the operator's own PostgreSQL governs BYO exposure). Any invalid/incomplete + /// field degrades the store to loopback + LogCritical rather than failing validation — the exposure + /// rules live at the point of use, NEVER in the all-fatal + /// (D-validate). See . + /// + [JsonPropertyName("network")] + public PostgresNetworkConfig? Network { get; set; } +} + +/// +/// The alert engine's config section. Defaults mirror Lite's App.xaml.cs alert defaults +/// member-for-member: cpu 80% (Total mode), blocking 1, deadlock 1, poison 500 ms, long-running +/// query 30 min, tempdb 80%, low disk 10% / 5 GB, job multiplier 3x, failed-job lookback 60 min, +/// cooldown 5 min. The long-running-query read shape (max results + the five noise filters) is +/// control-plane-honored since V20 (was hardcoded); its defaults (5 / all on) still match Lite's. +/// +public sealed class AlertsConfig +{ + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } = true; + + /// + /// Whether the service delivers the Server-Unreachable / Server-Restored connect-edge alerts (the headless + /// twin of Lite's connection-change tray toasts, fired by ). Default + /// true (matches Lite's App.NotifyConnectionChanges). Independent of the per-alert toggles — the connection + /// edge is a service-health signal, gated together with the master alerts.enabled switch. Stored as + /// config_alert_settings.notify_connection_changes (V20). + /// + [JsonPropertyName("notifyConnectionChanges")] + public bool NotifyConnectionChanges { get; set; } = true; + + [JsonPropertyName("cpuEnabled")] + public bool CpuEnabled { get; set; } = true; + + [JsonPropertyName("cpuThresholdPercent")] + public int CpuThresholdPercent { get; set; } = 80; + + /// "total" (Lite's default: SQL + other-process CPU) or "sql" (SQL process only). + [JsonPropertyName("cpuMode")] + public string CpuMode { get; set; } = "total"; + + [JsonPropertyName("blockingEnabled")] + public bool BlockingEnabled { get; set; } = true; + + [JsonPropertyName("blockingCountThreshold")] + public int BlockingCountThreshold { get; set; } = 1; + + [JsonPropertyName("deadlockEnabled")] + public bool DeadlockEnabled { get; set; } = true; + + [JsonPropertyName("deadlockCountThreshold")] + public int DeadlockCountThreshold { get; set; } = 1; + + [JsonPropertyName("poisonWaitEnabled")] + public bool PoisonWaitEnabled { get; set; } = true; + + [JsonPropertyName("poisonWaitThresholdMs")] + public int PoisonWaitThresholdMs { get; set; } = 500; + + [JsonPropertyName("longRunningQueryEnabled")] + public bool LongRunningQueryEnabled { get; set; } = true; + + [JsonPropertyName("longRunningQueryThresholdMinutes")] + public int LongRunningQueryThresholdMinutes { get; set; } = 30; + + [JsonPropertyName("tempDbSpaceEnabled")] + public bool TempDbSpaceEnabled { get; set; } = true; + + [JsonPropertyName("tempDbSpaceThresholdPercent")] + public int TempDbSpaceThresholdPercent { get; set; } = 80; + + [JsonPropertyName("lowDiskEnabled")] + public bool LowDiskEnabled { get; set; } = true; + + /// Alert when a volume's free space < X% (0 disables this dimension). + [JsonPropertyName("lowDiskThresholdPercent")] + public int LowDiskThresholdPercent { get; set; } = 10; + + /// Alert when a volume's free space < X GB (0 disables this dimension). + [JsonPropertyName("lowDiskThresholdGb")] + public int LowDiskThresholdGb { get; set; } = 5; + + [JsonPropertyName("longRunningJobEnabled")] + public bool LongRunningJobEnabled { get; set; } = true; + + [JsonPropertyName("longRunningJobMultiplier")] + public int LongRunningJobMultiplier { get; set; } = 3; + + [JsonPropertyName("failedJobEnabled")] + public bool FailedJobEnabled { get; set; } = true; + + [JsonPropertyName("failedJobLookbackMinutes")] + public int FailedJobLookbackMinutes { get; set; } = 60; + + /// Minimum minutes between repeated notifications for the same alert condition. + [JsonPropertyName("cooldownMinutes")] + public int CooldownMinutes { get; set; } = 5; + + /// Databases excluded from blocking/deadlock/long-running-query alert evaluation. + [JsonPropertyName("excludedDatabases")] + public List ExcludedDatabases { get; set; } = new(); + + /// + /// How deadlock/blocking alerts are delivered (#1141): + /// (one batched card per cycle — the default, matching Lite) or + /// (one notification per distinct incident, capped at with a trailing "+N more"). + /// A per-server override () wins over this global + /// default via the shared AlertDeliveryModeResolver. Serialized as its name ("Summary"/"PerEvent") + /// in darling.json and stored as config_alert_settings.delivery_mode. + /// + [JsonPropertyName("deliveryMode")] + [JsonConverter(typeof(JsonStringEnumConverter))] + public AlertNotificationMode DeliveryMode { get; set; } = AlertNotificationMode.Summary; + + /// Per-event mode's cap on incidents delivered individually per cycle before the trailing + /// "+N more" batch (#1141); clamped 1–100 when applied. Stored as config_alert_settings.per_event_max. + [JsonPropertyName("perEventMax")] + public int PerEventMax { get; set; } = 5; + + /* The long-running-query read shape (V20 control-plane knobs) — the row cap + the five noise-filter + opt-outs the shared AlertEngine forwards to GetLongRunningQueriesAsync. Defaults match Lite's App.* + (5 rows, every filter on) so an un-customized store suppresses the same noise it always did; stored as + config_alert_settings.long_running_query_*. */ + + /// Row cap for the long-running-query read (clamped 1–1000 when applied; default 5). + [JsonPropertyName("longRunningQueryMaxResults")] + public int LongRunningQueryMaxResults { get; set; } = 5; + + /// Exclude sessions waiting on SP_SERVER_DIAGNOSTICS (default true). + [JsonPropertyName("longRunningQueryExcludeSpServerDiagnostics")] + public bool LongRunningQueryExcludeSpServerDiagnostics { get; set; } = true; + + /// Exclude WAITFOR / BROKER_RECEIVE_WAITFOR sessions (default true). + [JsonPropertyName("longRunningQueryExcludeWaitFor")] + public bool LongRunningQueryExcludeWaitFor { get; set; } = true; + + /// Exclude BACKUPTHREAD / BACKUPIO sessions (default true). + [JsonPropertyName("longRunningQueryExcludeBackups")] + public bool LongRunningQueryExcludeBackups { get; set; } = true; + + /// Exclude XE_LIVE_TARGET_TVF sessions (default true). + [JsonPropertyName("longRunningQueryExcludeMiscWaits")] + public bool LongRunningQueryExcludeMiscWaits { get; set; } = true; + + /// Exclude CDC capture sessions (default true). + [JsonPropertyName("longRunningQueryExcludeCdc")] + public bool LongRunningQueryExcludeCdc { get; set; } = true; +} + +/// +/// The scheduled-analysis cadence + delivery knobs (control-plane Stage 1). These live in +/// config_alert_settings' analysis columns in the store; before this section existed the +/// service hardcoded the interval (30 min) and gated finding delivery on alerts.enabled. +/// Defaults mirror Lite's App.* analysis defaults (enabled, 30-minute interval clamped to +/// 5–360, notify-severity 1.5 clamped to 0–2) except , which is +/// TRUE to keep Darling's shipped notify-on default (Lite's App default is false). +/// +public sealed class AnalysisConfig +{ + /// Whether the scheduled analysis pipeline runs (findings still require notify gating). + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } = true; + + /// How often scheduled analysis runs, per server (clamped 5–360 when applied). + [JsonPropertyName("intervalMinutes")] + public int IntervalMinutes { get; set; } = 30; + + /// Whether findings are delivered to the notification channels (analysis still runs + persists). + [JsonPropertyName("notificationsEnabled")] + public bool NotificationsEnabled { get; set; } = true; + + /// Minimum finding severity (0.0–2.0) to notify on — the shared AnalysisNotificationService floor. + [JsonPropertyName("notifySeverity")] + public double NotifySeverity { get; set; } = 1.5; +} + +/// +/// SMTP alert delivery. Port/SSL/cooldown defaults mirror Lite's (587 / SSL on / 15 minutes). +/// +public sealed class SmtpConfig +{ + [JsonPropertyName("host")] + public string Host { get; set; } = ""; + + [JsonPropertyName("port")] + public int Port { get; set; } = 587; + + [JsonPropertyName("useSsl")] + public bool UseSsl { get; set; } = true; + + [JsonPropertyName("username")] + public string? Username { get; set; } + + /// DPAPI-LocalMachine-protected SMTP password, base64 — produced by --encrypt-password. + [JsonPropertyName("encryptedPassword")] + public string? EncryptedPassword { get; set; } + + [JsonPropertyName("from")] + public string From { get; set; } = ""; + + /// Comma-separated recipient list. + [JsonPropertyName("to")] + public string To { get; set; } = ""; + + /// Email/webhook channel cooldown between repeated alerts (Lite's default 15). + [JsonPropertyName("emailCooldownMinutes")] + public int EmailCooldownMinutes { get; set; } = 15; +} + +/// +/// The embedded MCP server's config. Port 5152 keeps the product's local MCP family +/// non-colliding on one machine (Dashboard 5150, Lite 5151, Darling 5152). Loopback-only and +/// tokenless by default; the optional block opts into LAN exposure behind a +/// required bearer token + an in-app CIDR check (darling-network-endpoints, managed-mode only). +/// +public sealed class McpConfig +{ + /// Default OFF — the headless twin of both apps' mcp_enabled=false default. + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } + + [JsonPropertyName("port")] + public int Port { get; set; } = 5152; + + /// + /// Opt-in network exposure for the MCP server (darling-network-endpoints). Omit for the secure + /// default = loopback-only, tokenless HTTP (today's behavior). Managed-mode only; ignored in BYO + /// with a caller warning. Any missing precondition (token / valid allowFrom / managed) keeps MCP + /// loopback-only + LogCritical — enforced in the MCP host, NEVER in the all-fatal + /// (D-validate). See . + /// + [JsonPropertyName("network")] + public McpNetworkConfig? Network { get; set; } +} + +/// +/// Opt-in NETWORK exposure for the managed store (darling-network-endpoints). Omit the whole +/// network object for the secure default = today's loopback-only behavior (no pg_hba network +/// rule, no firewall rule, ssl=off). When present with a non-loopback , the +/// service reconciles it on every start (managed mode only): listen_addresses gains the bind IP, a +/// self-signed TLS cert is generated for verify-full, a marked +/// hostssl darling <role> <allowFrom> scram-sha-256 pg_hba block is written + +/// reloaded, and a best-effort firewall rule is added. Reconciliation is symmetric (removing the +/// block closes the box) and fail-closed: any invalid/incomplete field degrades the store to +/// loopback + LogCritical, never exposed. These rules are enforced at the point of use, NOT in the +/// all-fatal (D-validate). +/// +public sealed class PostgresNetworkConfig +{ + /// + /// Bind IP for the store's network listener and the generated cert's iPAddress SAN. A specific IP + /// (e.g. 192.168.1.205) is preferred; 0.0.0.0 = all interfaces (connect by a cert SAN + /// name under verify-full). Absent or an IPv4 loopback (127.0.0.0/8) = the default + /// loopback-only store. + /// + [JsonPropertyName("listen")] + public string? Listen { get; set; } + + /// + /// The CIDR the pg_hba rule and firewall rule allow (e.g. 192.168.1.0/24); its address family + /// must match . Required when exposed — a missing/invalid CIDR degrades the store + /// to loopback. + /// + [JsonPropertyName("allowFrom")] + public string? AllowFrom { get; set; } + + /// + /// The least-privilege login role the network pg_hba rule names: viewer (default, read-only + /// remote — the secure default) or admin (full remote writes; the caller warns because admin + /// holds the config_command/config_monitored_servers/config_notification + /// service-credential pivot). Never the superuser darling. Distinct from the local-loopback + /// (default admin) — the sample/README document the difference. + /// An unknown value degrades the store to loopback. + /// + [JsonPropertyName("role")] + public string? Role { get; set; } + + /// + /// True when any field is set — used only for the BYO "network.* is ignored" caller warning (D-BYO). + /// It does NOT mean "exposed"; use for that. + /// + [JsonIgnore] + public bool IsConfigured => + !string.IsNullOrWhiteSpace(Listen) + || !string.IsNullOrWhiteSpace(AllowFrom) + || !string.IsNullOrWhiteSpace(Role); +} + +/// +/// Opt-in NETWORK exposure for the embedded MCP server (darling-network-endpoints). Omit the whole +/// network object for the secure default = today's loopback-only, tokenless HTTP. When present +/// with a non-loopback AND managed mode AND a token AND a valid +/// , the MCP host binds the network interface behind a required bearer token + +/// an in-app CIDR check (D3); any missing precondition keeps MCP loopback-only + LogCritical +/// (fail-closed, enforced in the MCP host). No TLS on MCP (a self-signed cert breaks real clients; the +/// named MITM control is a TLS reverse proxy in front of the endpoint). +/// +public sealed class McpNetworkConfig +{ + /// + /// Bind IP for the MCP network listener (a specific IP preferred; 0.0.0.0 = all interfaces). + /// Absent or an IPv4 loopback = the default loopback-only MCP server. + /// + [JsonPropertyName("listen")] + public string? Listen { get; set; } + + /// + /// The CIDR the in-app RemoteIpAddress check and the firewall rule allow (e.g. + /// 192.168.1.0/24); loopback is always allowed regardless. Required when exposed. + /// + [JsonPropertyName("allowFrom")] + public string? AllowFrom { get; set; } + + /// + /// DPAPI-LocalMachine-protected bearer token, base64 — produced by --encrypt-password + /// (preferred over the plaintext ). Read via . + /// + [JsonPropertyName("encryptedToken")] + public string? EncryptedToken { get; set; } + + /// + /// Plaintext bearer token — dev convenience only; the caller warns when it is used. Prefer + /// . + /// + [JsonPropertyName("token")] + public string? Token { get; set; } + + /// + /// True when any field is set — used only for the BYO "network.* is ignored" caller warning (D-BYO); + /// NOT the same as "exposed". + /// + [JsonIgnore] + public bool IsConfigured => + !string.IsNullOrWhiteSpace(Listen) + || !string.IsNullOrWhiteSpace(AllowFrom) + || !string.IsNullOrWhiteSpace(EncryptedToken) + || !string.IsNullOrWhiteSpace(Token); + + /// + /// The bearer token, preferring (DPAPI-decrypted; Windows-only) over + /// the plaintext . is true when the plaintext + /// fallback is used, so the caller can warn — the same shape as + /// . Returns null when neither is set. + /// + public string? ResolveToken(out bool usedPlaintext) + { + usedPlaintext = false; + + if (!string.IsNullOrWhiteSpace(EncryptedToken)) + { + /* DarlingSecrets.Unprotect is DPAPI (Windows-only). The network path is managed-mode-only, + which is itself Windows-gated, so this only runs on Windows; the guard keeps the analyzer + (CA1416) honest and gives a clear failure if someone points a non-Windows BYO client here. */ + if (!OperatingSystem.IsWindows()) + { + throw new PlatformNotSupportedException( + "mcp.network.encryptedToken requires Windows (DPAPI); use the plaintext \"token\" on other platforms."); + } + + return DarlingSecrets.Unprotect(EncryptedToken); + } + + if (!string.IsNullOrWhiteSpace(Token)) + { + usedPlaintext = true; + return Token; + } + + return null; + } +} + +/// +/// The embedded web dashboard's config (#1562). Port 5153 keeps the product's local port family +/// non-colliding on one machine (Dashboard MCP 5150, Lite MCP 5151, Darling MCP 5152, Darling Web 5153). +/// Loopback-only by default; the optional block opts into LAN exposure behind a +/// required token → HMAC session cookie + an in-app CIDR check (darling-network-endpoints, managed-mode only). +/// A SEPARATE surface from — its own enable flag, port, token, and exposure block — +/// because the two gate different blast radii (MCP's token guards analyze_server's live outbound SQL +/// connections; the web dashboard is read-only over the store). +/// +public sealed class WebConfig +{ + /// Default OFF — a headless service should not open the dashboard port unless asked. + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } + + [JsonPropertyName("port")] + public int Port { get; set; } = 5153; + + /// + /// Opt-in network exposure for the web dashboard (darling-network-endpoints). Omit for the secure + /// default = loopback-only HTTP. Managed-mode only; ignored in BYO with a caller warning. Any missing + /// precondition (token / valid allowFrom / managed) keeps the dashboard loopback-only + LogCritical — + /// enforced in the web host, NEVER in the all-fatal (D-validate). + /// See . + /// + [JsonPropertyName("network")] + public WebNetworkConfig? Network { get; set; } +} + +/// +/// Opt-in NETWORK exposure for the embedded web dashboard (#1562), the twin of . +/// Omit the whole network object for the secure default = loopback-only HTTP. When present with a +/// non-loopback AND managed mode AND a token AND a valid , the web +/// host binds the network interface behind an in-app CIDR check and a token gate: a browser presents the token +/// once via ?token=, which is exchanged for an HMAC-signed session cookie. Loopback requests stay +/// TOKENLESS even while exposed (the dashboard is read-only). Any missing precondition keeps the dashboard +/// loopback-only + LogCritical (fail-closed, enforced in the web host). No TLS on the dashboard (the same +/// reverse-proxy story as MCP); the token/cookie travels cleartext on-segment. +/// +public sealed class WebNetworkConfig +{ + /// + /// Bind IP for the web network listener (a specific IP preferred; 0.0.0.0 = all interfaces). + /// Absent or an IPv4 loopback = the default loopback-only dashboard. + /// + [JsonPropertyName("listen")] + public string? Listen { get; set; } + + /// + /// The CIDR the in-app RemoteIpAddress check and the firewall rule allow (e.g. + /// 192.168.1.0/24); loopback is always allowed regardless. Required when exposed. + /// + [JsonPropertyName("allowFrom")] + public string? AllowFrom { get; set; } + + /// + /// DPAPI-LocalMachine-protected access token, base64 — produced by --encrypt-password + /// (preferred over the plaintext ). Read via . + /// + [JsonPropertyName("encryptedToken")] + public string? EncryptedToken { get; set; } + + /// + /// Plaintext access token — dev convenience only; the caller warns when it is used. Prefer + /// . + /// + [JsonPropertyName("token")] + public string? Token { get; set; } + + /// + /// True when any field is set — used only for the BYO "network.* is ignored" caller warning (D-BYO); + /// NOT the same as "exposed". + /// + [JsonIgnore] + public bool IsConfigured => + !string.IsNullOrWhiteSpace(Listen) + || !string.IsNullOrWhiteSpace(AllowFrom) + || !string.IsNullOrWhiteSpace(EncryptedToken) + || !string.IsNullOrWhiteSpace(Token); + + /// + /// The access token, preferring (DPAPI-decrypted; Windows-only) over the + /// plaintext . is true when the plaintext fallback is + /// used, so the caller can warn — the same shape as . Returns + /// null when neither is set. + /// + public string? ResolveToken(out bool usedPlaintext) + { + usedPlaintext = false; + + if (!string.IsNullOrWhiteSpace(EncryptedToken)) + { + /* DarlingSecrets.Unprotect is DPAPI (Windows-only). The network path is managed-mode-only, which is + itself Windows-gated, so this only runs on Windows; the guard keeps the analyzer (CA1416) honest. */ + if (!OperatingSystem.IsWindows()) + { + throw new PlatformNotSupportedException( + "web.network.encryptedToken requires Windows (DPAPI); use the plaintext \"token\" on other platforms."); + } + + return DarlingSecrets.Unprotect(EncryptedToken); + } + + if (!string.IsNullOrWhiteSpace(Token)) + { + usedPlaintext = true; + return Token; + } + + return null; + } +} + +/// +/// Shared network-exposure helpers for the opt-in store/MCP endpoints (darling-network-endpoints). +/// These pure functions are the single source of truth for "is this listen value a network bind?" +/// and "what pg_hba role?" — used by the store bootstrap (), the +/// worker's startup warnings (), and the MCP host's bind resolution. Kept +/// deliberately OUT of the all-fatal (one entry there kills +/// collection): a typo in an optional, default-off endpoint must degrade to loopback + LogCritical +/// and keep collecting (D-validate). +/// +public static class DarlingNetwork +{ + /// + /// Is a NETWORK (non-loopback) bind — i.e. anything other than the safe + /// IPv4 loopback range 127.0.0.0/8? Absent/blank is NOT exposed (the default = today's + /// loopback-only behavior). Everything else is treated as exposed, and therefore subject to the full + /// exposure validation: a real IP; 0.0.0.0/:: (all interfaces); the IPv6 loopback + /// ::1 (the store's loopback handling is IPv4-127 only); and any value that is not even + /// an IP — localhost, *, a hostname — which the store bind (it needs + /// ) then rejects by degrading to loopback. Fail-safe: unknown ⇒ + /// exposed ⇒ validated, never silently bound. + /// + public static bool IsExposedListenAddress(string? listen) + { + if (string.IsNullOrWhiteSpace(listen)) + { + return false; + } + + if (IPAddress.TryParse(listen.Trim(), out var ip)) + { + /* The ONLY non-exposed value is an IPv4 address in 127.0.0.0/8 (canonical loopback). */ + return !(ip.AddressFamily == AddressFamily.InterNetwork && ip.GetAddressBytes()[0] == 127); + } + + /* Not an IP at all (localhost / hostname / "*") — treat as exposed; the store bind degrades to + loopback because it cannot IPAddress.Parse it. */ + return true; + } + + /// + /// Normalizes postgres.network.role to the pg_hba login role: absent/blank ⇒ the secure + /// default viewer (read-only remote, D7); viewer/admin pass through + /// (case-insensitively); anything else ⇒ null (invalid — the store degrades to loopback). NEVER + /// returns darling (the superuser/owner is service-only). + /// + public static string? NormalizeNetworkRole(string? role) + { + /* Literals rather than DarlingManagedPostgres.ViewerRoleName/AdminRoleName: that type is + [SupportedOSPlatform("windows")] and this classifier is platform-neutral, so referencing its + consts would raise CA1416 here. The names mirror those consts (pinned equal by test). */ + if (string.IsNullOrWhiteSpace(role)) + { + return "viewer"; + } + + var normalized = role.Trim().ToLowerInvariant(); + return normalized is "viewer" or "admin" ? normalized : null; + } +} + +/// Teams/Slack incoming-webhook alert delivery; a channel is enabled by a non-empty URL. +public sealed class WebhooksConfig +{ + [JsonPropertyName("teamsUrl")] + public string TeamsUrl { get; set; } = ""; + + [JsonPropertyName("teamsProxy")] + public string TeamsProxy { get; set; } = ""; + + [JsonPropertyName("slackUrl")] + public string SlackUrl { get; set; } = ""; + + [JsonPropertyName("slackProxy")] + public string SlackProxy { get; set; } = ""; + + /* Generic webhook (#1506): POSTs an operator-authored JSON body to any endpoint, so an alert can drive + automation we ship no adapter for (PagerDuty, Opsgenie, n8n, a GitHub repository_dispatch that re-runs + a workflow). Enabled by a non-empty URL, like the sibling channels. */ + + [JsonPropertyName("genericUrl")] + public string GenericUrl { get; set; } = ""; + + /// + /// A JSON object of request headers, e.g. {"Authorization":"Bearer ghp_..."}. A bearer secret — + /// carved out of the read-only viewer role's column grants alongside generic_url + /// (DarlingManagedRoles.ViewerRestrictedConfigTables). + /// + [JsonPropertyName("genericHeaders")] + public string GenericHeaders { get; set; } = ""; + + /// The JSON body, with {{metric}}/{{server}}/… placeholders; empty uses the shared default. + [JsonPropertyName("genericBodyTemplate")] + public string GenericBodyTemplate { get; set; } = ""; + + [JsonPropertyName("genericProxy")] + public string GenericProxy { get; set; } = ""; +} + +public sealed class MonitoredServer +{ + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("host")] + public string Host { get; set; } = ""; + + /// Azure SQL Database: the one database this entry monitors (feeds the storage-name identity). + [JsonPropertyName("database")] + public string? Database { get; set; } + + /// "integrated" (default, recommended for a service account) or "sql". + [JsonPropertyName("auth")] + public string Auth { get; set; } = "integrated"; + + [JsonPropertyName("username")] + public string? Username { get; set; } + + /// DPAPI-LocalMachine-protected password, base64 — produced by --encrypt-password. + [JsonPropertyName("encryptedPassword")] + public string? EncryptedPassword { get; set; } + + /// Plaintext password — dev convenience only; the service logs a warning when used. + [JsonPropertyName("password")] + public string? Password { get; set; } + + [JsonPropertyName("readOnlyIntent")] + public bool ReadOnlyIntent { get; set; } + + [JsonPropertyName("trustServerCertificate")] + public bool TrustServerCertificate { get; set; } + + /// Mandatory (default) | Strict | Optional — unknown values fail closed to Mandatory. + [JsonPropertyName("encryptMode")] + public string EncryptMode { get; set; } = "Mandatory"; + + [JsonPropertyName("multiSubnetFailover")] + public bool MultiSubnetFailover { get; set; } + + [JsonPropertyName("excludedDatabases")] + public List ExcludedDatabases { get; set; } = new(); + + /// + /// The server's monthly cost/budget in USD — the twin of Lite's ServerConnection.MonthlyCostUsd. + /// Pure user-input config (not collected data): every FinOps cost figure is proportional math on this + /// single number (e.g. a database's share = its size fraction × this budget). 0 (the default) hides the + /// cost columns/cards in the viewer's FinOps tab, exactly like Lite. Upserted into the servers registry + /// so the headless viewer can read it. + /// + [JsonPropertyName("monthlyCostUsd")] + public decimal MonthlyCostUsd { get; set; } + + /// + /// Per-server override of the deadlock/blocking alert delivery mode (#1236); null (the default) inherits + /// the global . Forces Summary or Per-event for THIS server only + /// (e.g. Per-event for one noisy prod box while the fleet default stays Summary). Populated from + /// config_monitored_servers.alert_delivery_mode_override at read time so the deliverer can resolve + /// it per fired alert via the shared AlertDeliveryModeResolver; serialized as its name in darling.json. + /// + [JsonPropertyName("alertDeliveryModeOverride")] + [JsonConverter(typeof(JsonStringEnumConverter))] + public AlertNotificationMode? AlertDeliveryModeOverride { get; set; } + + [JsonIgnore] + public bool UsesSqlAuth => string.Equals(Auth, "sql", StringComparison.OrdinalIgnoreCase); + + /// Display name falls back to the host. + [JsonIgnore] + public string DisplayName => string.IsNullOrWhiteSpace(Name) ? Host : Name; + + /// + /// The canonical storage identity (host[:database][:RO]) — hashed to server_id via the shared + /// ServerIdHelper, so this Darling entry derives the same id Lite would for the same server. + /// + [JsonIgnore] + public string StorageName => PerformanceMonitor.Common.ServerIdHelper.BuildStorageName(Host, Database, ReadOnlyIntent); +} diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingDeltaCalculator.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingDeltaCalculator.cs new file mode 100644 index 000000000..e980878b7 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingDeltaCalculator.cs @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Npgsql; +using PerformanceMonitor.Collectors; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// Darling's delta calculator: the shared core (baseline / +/// counter-reset / gap-policy semantics live there, identical across SKUs) plus the Postgres +/// seeding that lets the first collection after a service restart produce accurate deltas instead +/// of returning 0 for everything — the twin of Lite's DuckDB-seeding DeltaCalculator, with the +/// seed queries, delta-group names, and key formats mirrored verbatim so the two hosts can never +/// restore different baselines. +/// +public sealed class DarlingDeltaCalculator : CollectorDeltaCalculator +{ + /* The four seed queries are verbatim from Lite's DeltaCalculator — deliberately written in + the PG-shared dialect (the (server_id, collection_time) row-value latest-row form runs on + both engines as-is). Pinned by DarlingDeltaSeederTests. */ + + public const string WaitStatsSeedSql = @" +SELECT server_id, wait_type, waiting_tasks_count, wait_time_ms, signal_wait_time_ms, collection_time +FROM wait_stats +WHERE (server_id, collection_time) IN ( + SELECT server_id, MAX(collection_time) FROM wait_stats GROUP BY server_id +)"; + + public const string FileIoStatsSeedSql = @" +SELECT server_id, database_name, file_name, + num_of_reads, num_of_writes, read_bytes, write_bytes, + io_stall_read_ms, io_stall_write_ms, + io_stall_queued_read_ms, io_stall_queued_write_ms, + collection_time +FROM file_io_stats +WHERE (server_id, collection_time) IN ( + SELECT server_id, MAX(collection_time) FROM file_io_stats GROUP BY server_id +)"; + + public const string PerfmonStatsSeedSql = @" +SELECT server_id, object_name, counter_name, instance_name, cntr_value, collection_time +FROM perfmon_stats +WHERE (server_id, collection_time) IN ( + SELECT server_id, MAX(collection_time) FROM perfmon_stats GROUP BY server_id +)"; + + public const string MemoryGrantStatsSeedSql = @" +SELECT server_id, pool_id, resource_semaphore_id, timeout_error_count, forced_grant_count +FROM memory_grant_stats +WHERE (server_id, collection_time) IN ( + SELECT server_id, MAX(collection_time) FROM memory_grant_stats GROUP BY server_id +)"; + + /// + /// Seeds the delta cache from the Postgres store so that the first collection after a service + /// restart can produce accurate deltas instead of returning 0 for everything. A seed failure + /// logs a warning and the service proceeds with first-cycle-zero deltas — restart continuity + /// must never block collection. + /// + public async Task SeedFromStoreAsync(NpgsqlDataSource postgres, ILogger? logger, CancellationToken cancellationToken) + { + try + { + await using var connection = await postgres.OpenConnectionAsync(cancellationToken); + + await SeedWaitStatsAsync(connection, logger, cancellationToken); + await SeedFileIoStatsAsync(connection, logger, cancellationToken); + await SeedPerfmonStatsAsync(connection, logger, cancellationToken); + await SeedMemoryGrantStatsAsync(connection, logger, cancellationToken); + + logger?.LogInformation("Delta calculator seeded from Postgres store"); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogWarning(ex, "Failed to seed delta calculator from Postgres store, first collection will return 0 deltas"); + } + } + + private async Task SeedWaitStatsAsync(NpgsqlConnection connection, ILogger? logger, CancellationToken cancellationToken) + { + using var cmd = new NpgsqlCommand(WaitStatsSeedSql, connection); + using var reader = await cmd.ExecuteReaderAsync(cancellationToken); + var count = 0; + while (await reader.ReadAsync(cancellationToken)) + { + var serverId = reader.GetInt32(0); + var waitType = reader.GetString(1); + /* Naive-UTC storage by convention across the product — read as-is, never convert. */ + var ts = reader.IsDBNull(5) ? (DateTime?)null : reader.GetDateTime(5); + Seed(serverId, "wait_stats_tasks", waitType, reader.GetInt64(2), ts); + Seed(serverId, "wait_stats_time", waitType, reader.GetInt64(3), ts); + Seed(serverId, "wait_stats_signal", waitType, reader.GetInt64(4), ts); + count++; + } + if (count > 0) logger?.LogDebug("Seeded {Count} wait_stats baseline rows", count); + } + + private async Task SeedFileIoStatsAsync(NpgsqlConnection connection, ILogger? logger, CancellationToken cancellationToken) + { + using var cmd = new NpgsqlCommand(FileIoStatsSeedSql, connection); + using var reader = await cmd.ExecuteReaderAsync(cancellationToken); + var count = 0; + while (await reader.ReadAsync(cancellationToken)) + { + var serverId = reader.GetInt32(0); + var dbName = reader.IsDBNull(1) ? "" : reader.GetString(1); + var fileName = reader.IsDBNull(2) ? "" : reader.GetString(2); + var deltaKey = $"{dbName}|{fileName}"; + var ts = reader.IsDBNull(11) ? (DateTime?)null : reader.GetDateTime(11); + Seed(serverId, "file_io_reads", deltaKey, reader.IsDBNull(3) ? 0 : reader.GetInt64(3), ts); + Seed(serverId, "file_io_writes", deltaKey, reader.IsDBNull(4) ? 0 : reader.GetInt64(4), ts); + Seed(serverId, "file_io_read_bytes", deltaKey, reader.IsDBNull(5) ? 0 : reader.GetInt64(5), ts); + Seed(serverId, "file_io_write_bytes", deltaKey, reader.IsDBNull(6) ? 0 : reader.GetInt64(6), ts); + Seed(serverId, "file_io_stall_read", deltaKey, reader.IsDBNull(7) ? 0 : reader.GetInt64(7), ts); + Seed(serverId, "file_io_stall_write", deltaKey, reader.IsDBNull(8) ? 0 : reader.GetInt64(8), ts); + Seed(serverId, "file_io_stall_queued_read", deltaKey, reader.IsDBNull(9) ? 0 : reader.GetInt64(9), ts); + Seed(serverId, "file_io_stall_queued_write", deltaKey, reader.IsDBNull(10) ? 0 : reader.GetInt64(10), ts); + count++; + } + if (count > 0) logger?.LogDebug("Seeded {Count} file_io_stats baseline rows", count); + } + + private async Task SeedPerfmonStatsAsync(NpgsqlConnection connection, ILogger? logger, CancellationToken cancellationToken) + { + using var cmd = new NpgsqlCommand(PerfmonStatsSeedSql, connection); + using var reader = await cmd.ExecuteReaderAsync(cancellationToken); + var count = 0; + while (await reader.ReadAsync(cancellationToken)) + { + var serverId = reader.GetInt32(0); + var objectName = reader.IsDBNull(1) ? "" : reader.GetString(1); + var counter = reader.IsDBNull(2) ? "" : reader.GetString(2); + var instance = reader.IsDBNull(3) ? "" : reader.GetString(3); + var ts = reader.IsDBNull(5) ? (DateTime?)null : reader.GetDateTime(5); + Seed(serverId, "perfmon", $"{objectName}|{counter}|{instance}", reader.GetInt64(4), ts); + count++; + } + if (count > 0) logger?.LogDebug("Seeded {Count} perfmon_stats baseline rows", count); + } + + private async Task SeedMemoryGrantStatsAsync(NpgsqlConnection connection, ILogger? logger, CancellationToken cancellationToken) + { + try + { + using var cmd = new NpgsqlCommand(MemoryGrantStatsSeedSql, connection); + using var reader = await cmd.ExecuteReaderAsync(cancellationToken); + var count = 0; + while (await reader.ReadAsync(cancellationToken)) + { + var serverId = reader.GetInt32(0); + var poolId = reader.IsDBNull(1) ? 0 : reader.GetInt32(1); + var semaphoreId = reader.IsDBNull(2) ? (short)0 : reader.GetInt16(2); + var deltaKey = $"{poolId}_{semaphoreId}"; + Seed(serverId, "memory_grants_timeouts", deltaKey, reader.IsDBNull(3) ? 0 : reader.GetInt64(3), null); + Seed(serverId, "memory_grants_forced", deltaKey, reader.IsDBNull(4) ? 0 : reader.GetInt64(4), null); + count++; + } + if (count > 0) logger?.LogDebug("Seeded {Count} memory_grant_stats baseline rows", count); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + /* Table may not exist on first run after schema migration — mirrors Lite's tolerance. */ + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingFileLoggerProvider.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingFileLoggerProvider.cs new file mode 100644 index 000000000..624213609 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingFileLoggerProvider.cs @@ -0,0 +1,285 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using Microsoft.Extensions.Logging; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// The service's rolling file log — an so every existing +/// ILogger call site (collector run lines, connect edges, reload notices, errors) lands in a +/// greppable file with zero changes at the call sites. This is the service's PRIMARY diagnostic +/// surface: the Windows Event Log provider is also wired, but its event source can only be created +/// by an elevated principal — the recommended NT SERVICE virtual account cannot, so on a +/// by-the-book install the Event Log silently receives nothing until the installer pre-creates the +/// source (see the README's install step). A headless service must not depend on that. +/// +/// Disciplines ported from the viewer's ViewerLogger (itself a port of Lite's +/// AppLogger): buffered writes on a 5-second flush timer (a log line never blocks a collection +/// sweep on disk I/O), one file per day, and never-throw — a full disk or an ACL surprise turns +/// logging into a no-op, never a service crash. Files older than are +/// swept at startup so an unattended service never grows an unbounded log directory. +/// +/// Directory: %ProgramData%\PerformanceMonitorDarling\logs — the machine-level Darling +/// folder the managed store already lives under (...\pg), created by the service account on +/// first use exactly like the data directory. File: darling-service_yyyyMMdd.log. +/// +public sealed class DarlingFileLoggerProvider : ILoggerProvider +{ + internal const int RetentionDays = 14; + + private readonly ConcurrentQueue _buffer = new(); + private readonly Timer _flushTimer; + private readonly object _flushLock = new(); + private readonly string _logDirectory; + private readonly bool _enabled; + + /// + /// The always-available fallback channel used to surface a file-logging FAILURE (#1581) — the log directory + /// could not be created/enabled at construction, or a flush write threw. Defaults to a best-effort single + /// Windows Event Log Warning () under the source Program.cs registers; + /// injectable so a unit test can drive the once-only latch without a real Event Log. + /// + private readonly Action _reportFailure; + + /// + /// Interlocked once-only latch (0 = not yet surfaced, 1 = surfaced): a persistently-broken log — a flush that + /// throws on every 5s tick — emits a SINGLE fallback event for the provider's lifetime, not one per flush. + /// Shared by the constructor and flush failure paths: whichever fails first reports, and no later failure + /// re-reports (the field box's unwritable log dir went silent precisely because nothing was ever surfaced). + /// + private int _failureReported; + + public DarlingFileLoggerProvider() + : this(DefaultLogDirectory()) + { + } + + /// Test seam: point the provider at any directory. + internal DarlingFileLoggerProvider(string logDirectory) + : this(logDirectory, DefaultReportFailure) + { + } + + /// + /// Full test seam: also inject the failure-report sink (the caller latches it to at most one call) so a unit + /// test can drive the once-only fallback without a real Event Log. Production uses the parameterless / single + /// -directory constructor, whose sink is . + /// + internal DarlingFileLoggerProvider(string logDirectory, Action reportFailure) + { + _logDirectory = logDirectory; + _reportFailure = reportFailure; + try + { + Directory.CreateDirectory(_logDirectory); + SweepOldFiles(); + _enabled = true; + } + catch (Exception ex) + { + /* A log directory we cannot create must never take down the service — but it must not go SILENT + either (#1581): surface it ONCE so an operator learns file logging is disabled instead of + discovering it only when they tail an empty log directory. */ + _enabled = false; + ReportFailureOnce( + $"File logging is disabled - cannot create log directory '{_logDirectory}': {ex.GetType().Name}: {ex.Message}"); + } + + _flushTimer = new Timer(_ => Flush(), null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5)); + } + + /// %ProgramData%\PerformanceMonitorDarling\logs — the service's machine-level log directory. + public static string DefaultLogDirectory() => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "PerformanceMonitorDarling", + "logs"); + + /// Today's log file path (darling-service_yyyyMMdd.log under the log directory). + public string CurrentLogFile() => + Path.Combine(_logDirectory, $"darling-service_{DateTime.Now:yyyyMMdd}.log"); + + public ILogger CreateLogger(string categoryName) => new FileLogger(this, categoryName); + + public void Dispose() + { + _flushTimer.Change(Timeout.Infinite, Timeout.Infinite); + Flush(); + _flushTimer.Dispose(); + } + + private void Enqueue(string line) + { + if (_enabled) + { + _buffer.Enqueue(line); + } + } + + /// Internal (not private) so a unit test can drive a flush deterministically instead of waiting on + /// the 5-second timer — the once-only failure latch (#1581) is asserted by flushing a broken directory + /// repeatedly and checking the fallback fired exactly once. + internal void Flush() + { + if (!_enabled || _buffer.IsEmpty) + { + return; + } + + lock (_flushLock) + { + try + { + var sb = new StringBuilder(); + while (_buffer.TryDequeue(out var line)) + { + sb.AppendLine(line); + } + + if (sb.Length > 0) + { + File.AppendAllText(CurrentLogFile(), sb.ToString()); + } + } + catch (Exception ex) + { + /* Never let a logging failure crash the service — but surface it ONCE (#1581) so a log + directory that turned unwritable mid-run (the field box's ACL artifact) does not silently + swallow every flush until someone notices the log stopped growing. The dequeued lines are + already gone; the fallback event is the only remaining signal. */ + ReportFailureOnce( + $"File logging is disabled - cannot write '{CurrentLogFile()}': {ex.GetType().Name}: {ex.Message}"); + } + } + } + + /// + /// Surface a file-logging failure through exactly ONCE for the provider's + /// lifetime (#1581). The Interlocked latch means a persistently-broken log — a flush that throws on every + /// 5s tick — emits a SINGLE event, not one per flush. Best-effort and never-throw: the sink is wrapped so a + /// failure to report (a missing Event Log source, say) can never re-enter and crash the very logging path it + /// is reporting on. + /// + private void ReportFailureOnce(string message) + { + if (Interlocked.Exchange(ref _failureReported, 1) != 0) + { + return; + } + + try + { + _reportFailure(message); + } + catch + { + /* Logging-about-logging must never crash the service — a sink that throws is swallowed. */ + } + } + + /// + /// The default failure sink: a best-effort single Windows Event Log Warning under the source Program.cs + /// registers ("PerformanceMonitor Darling"). Windows-guarded and fully swallowed — a missing/unregistered + /// source (a by-the-book NT SERVICE install cannot create it) or a non-Windows host must never let + /// surfacing a file-logging failure itself throw. The caller owns the latch. + /// + private static void DefaultReportFailure(string message) + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + try + { + System.Diagnostics.EventLog.WriteEntry( + "PerformanceMonitor Darling", message, System.Diagnostics.EventLogEntryType.Warning); + } + catch + { + /* Best-effort: an unregistered source or an Event Log write failure must never crash the service + just because file logging already failed. */ + } + } + + private void SweepOldFiles() + { + try + { + var cutoff = DateTime.Now.AddDays(-RetentionDays); + foreach (var file in Directory.EnumerateFiles(_logDirectory, "darling-service_*.log") + .Where(f => File.GetLastWriteTime(f) < cutoff)) + { + File.Delete(file); + } + } + catch + { + /* Retention is best-effort; a locked or undeletable file is tomorrow's problem. */ + } + } + + private sealed class FileLogger : ILogger + { + private readonly DarlingFileLoggerProvider _provider; + private readonly string _category; + + public FileLogger(DarlingFileLoggerProvider provider, string category) + { + _provider = provider; + /* The short category name — "DarlingWorker", not the full namespace — keeps lines scannable. */ + var dot = category.LastIndexOf('.'); + _category = dot >= 0 && dot < category.Length - 1 ? category[(dot + 1)..] : category; + } + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None; + + public void Log( + LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (!IsEnabled(logLevel)) + { + return; + } + + try + { + var line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{Abbreviate(logLevel)}] [{_category}] {formatter(state, exception)}"; + if (exception is not null) + { + line += $" | {exception.GetType().Name}: {exception.Message}"; + } + + _provider.Enqueue(line); + } + catch + { + /* A formatter that throws must never take down the caller. */ + } + } + + private static string Abbreviate(LogLevel level) => level switch + { + LogLevel.Trace => "TRACE", + LogLevel.Debug => "DEBUG", + LogLevel.Information => "INFO ", + LogLevel.Warning => "WARN ", + LogLevel.Error => "ERROR", + LogLevel.Critical => "CRIT ", + _ => "?????", + }; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingFileSecurity.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingFileSecurity.cs new file mode 100644 index 000000000..e494f4bd5 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingFileSecurity.cs @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.IO; +using System.Runtime.Versioning; +using System.Security.AccessControl; +using System.Security.Principal; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// Restrictive Windows ACLs for the DPAPI credential files (V8 security hardening, #1262). DPAPI +/// LocalMachine scope is deliberate — the service (a service account) writes the credential and a +/// DIFFERENT interactive user's Viewer reads it — so the machine-bound blob is decryptable by any +/// local code that can READ the file, and the in-source entropy is no secret. The file ACL is +/// therefore the real access boundary: without it, any local user could read +/// pg-credential.dpapi, unprotect it, and connect as the darling SUPERUSER +/// (DROP / exfil / COPY … TO PROGRAM = RCE as the service account). This class strips the +/// inherited world-readable access and re-grants it narrowly. +/// +/// Principal model (the assumption to review): +/// +/// The superuser credential (pg-credential.dpapi) and the transient init password +/// file are readable ONLY by SYSTEM, Administrators, and the service account — never an +/// interactive user. Post-split the Viewer no longer needs the superuser credential (it connects as +/// admin/viewer), so the RCE vector is locked hardest. +/// The admin/viewer credentials additionally grant read to NT AUTHORITY\INTERACTIVE. +/// The Viewer runs as the interactive operator, whose identity is unknown at provisioning time; +/// INTERACTIVE is the concrete principal that lets that Viewer read the credential with zero config +/// while excluding exactly the non-interactive local attack primitives the design's threat model +/// calls out (services, SSRF/sandboxed socket code, scheduled tasks — none hold an interactive token). +/// +/// On the design's default single-operator VM, INTERACTIVE == the operator, so this is tight. On a +/// shared machine where untrusted users can log on interactively they could read the admin/viewer +/// credentials (never the superuser one); the tightening path there is a configured operator account +/// ACL'd specifically — a future knob, not built for the Phase 1 single-operator default. +/// +[SupportedOSPlatform("windows")] +public static class DarlingFileSecurity +{ + private static SecurityIdentifier LocalSystem => new(WellKnownSidType.LocalSystemSid, null); + private static SecurityIdentifier Administrators => new(WellKnownSidType.BuiltinAdministratorsSid, null); + private static SecurityIdentifier Interactive => new(WellKnownSidType.InteractiveSid, null); + + /// The account this process runs as — the service account when hosted as a service. + private static SecurityIdentifier ServiceAccount => + WindowsIdentity.GetCurrent().User + ?? throw new InvalidOperationException("Cannot resolve the current Windows identity for ACL hardening."); + + /// + /// Locks a directory to SYSTEM + Administrators + the service account (full control, inherited by + /// children), removing ALL inherited access so no Users / Authenticated Users read survives. When + /// is set, INTERACTIVE gets traverse (not list) on THIS + /// folder only — so the operator's Viewer can reach the admin/viewer credential files beside the + /// data directory without that access flowing into the data-directory subtree. + /// + public static void HardenDirectory(string path, bool allowInteractiveTraverse) + { + var info = new DirectoryInfo(path); + var security = new DirectorySecurity(); + + /* Protect the DACL (isProtected: true) and drop inherited ACEs (preserveInheritance: false): + the directory's access is now EXACTLY the rules added below, nothing world-readable. */ + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + + const InheritanceFlags subtree = InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit; + AddFull(security, LocalSystem, subtree); + AddFull(security, Administrators, subtree); + AddFull(security, ServiceAccount, subtree); + + if (allowInteractiveTraverse) + { + /* Traverse + synchronize, THIS FOLDER ONLY (no inheritance) — the operator can open a + known credential path here but cannot enumerate the folder or descend into the PG data + directory. */ + security.AddAccessRule(new FileSystemAccessRule( + Interactive, + FileSystemRights.Traverse | FileSystemRights.Synchronize, + InheritanceFlags.None, + PropagationFlags.None, + AccessControlType.Allow)); + } + + info.SetAccessControl(security); + } + + /// + /// Locks a file to SYSTEM + Administrators + the service account, dropping inherited access. When + /// is set (the admin/viewer credentials the Viewer reads), + /// INTERACTIVE gets read. The superuser credential and the transient init pwfile pass false. + /// + public static void HardenFile(string path, bool allowInteractiveRead) + { + var info = new FileInfo(path); + var security = new FileSecurity(); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + + AddFile(security, LocalSystem, FileSystemRights.FullControl); + AddFile(security, Administrators, FileSystemRights.FullControl); + AddFile(security, ServiceAccount, FileSystemRights.FullControl); + + if (allowInteractiveRead) + { + AddFile(security, Interactive, FileSystemRights.Read | FileSystemRights.Synchronize); + } + + info.SetAccessControl(security); + } + + /// + /// Is an existing credential file owned by a trusted principal (SYSTEM, Administrators, or the + /// service account)? A file owned by anyone else may have been PRE-PLANTED — for a role credential, + /// by an attacker who wrote a password they know so the service's ALTER ROLE … PASSWORD + /// re-assert would hand them that login; for the owner credential, a tamper signal. Callers must not + /// trust an untrusted-owned file. Returns false (untrusted) on any error reading the owner. + /// + public static bool IsTrustedOwner(string path) + { + try + { + var owner = new FileInfo(path).GetAccessControl().GetOwner(typeof(SecurityIdentifier)) as SecurityIdentifier; + return owner is not null + && (owner.Equals(LocalSystem) || owner.Equals(Administrators) || owner.Equals(ServiceAccount)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) + { + return false; + } + } + + private static void AddFull(DirectorySecurity security, SecurityIdentifier sid, InheritanceFlags inheritance) => + security.AddAccessRule(new FileSystemAccessRule( + sid, FileSystemRights.FullControl, inheritance, PropagationFlags.None, AccessControlType.Allow)); + + private static void AddFile(FileSecurity security, SecurityIdentifier sid, FileSystemRights rights) => + security.AddAccessRule(new FileSystemAccessRule(sid, rights, AccessControlType.Allow)); +} diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingFindingAlertSender.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingFindingAlertSender.cs new file mode 100644 index 000000000..5e8791302 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingFindingAlertSender.cs @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using PerformanceMonitor.Notifications; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// Darling's — the seam the shared +/// dispatches composed analysis-finding alerts +/// through. Lite's EmailAlertService.SendFindingAlertAsync cadence transplanted onto +/// the same /webhook flow +/// already uses (same branding, same settings, same PG history store): the core attempts +/// email + fans out to Teams/Slack, then ONE combined config_alert_log row is written +/// per finding alert regardless of channel outcome (no channel configured → recorded as +/// 'tray', Lite's delivered-without-email taxonomy), with the finding's numeric +/// severity/threshold and detail text persisted like Lite's row. Never throws. +/// +/// +/// A separate class from (where Lite folds both surfaces +/// into one EmailAlertService) because the two seams belong to different engines — the alert +/// engine's IAlertDeliverer and the analysis pipeline's IFindingAlertSender. +/// Each holds its own ; the per-fingerprint email cooldown state +/// is keyed by metric name and the two namespaces are disjoint ("Analysis: …" vs the engine +/// metrics), and both cores seed cooldowns from the SAME shared history store across +/// restarts (#1145), so the split changes no delivery behavior. +/// +/// +public sealed class DarlingFindingAlertSender : IFindingAlertSender +{ + private readonly IAlertHistoryStore _historyStore; + private readonly EmailSendCore _core; + private readonly ILogger _logger; + + public DarlingFindingAlertSender( + IAlertSettings settings, + IAlertHistoryStore historyStore, + WebhookAlertService webhookAlertService, + ILogger logger) + { + _historyStore = historyStore ?? throw new ArgumentNullException(nameof(historyStore)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _core = new EmailSendCore(settings, historyStore, webhookAlertService, DarlingAlertDeliverer.Branding, logger); + } + + /// + /// : latest alert_log time for (serverId, metricName), + /// any channel/result — seeds the shared AnalysisNotificationService cooldown across + /// restarts (the analysis cooldown is stamped unconditionally, so the persisted + /// equivalent is the latest row for that metric_name). Delegates to the PG store. + /// + public Task GetLastAlertTimeAsync(string serverId, string metricName) + => _historyStore.GetLastAlertTimeAsync(serverId, metricName); + + /// + /// : dispatches a composed analysis-finding alert. + /// Lite's cadence — one combined alert-history row written unconditionally, carrying the + /// numeric severity/threshold and the detail text — so no separate fallback row is needed. + /// + public async Task SendFindingAlertAsync(FindingAlert alert) + { + if (alert is null) + { + return; + } + + try + { + /* Findings are never muted here — the pipeline's mute filter dropped muted + stories before they became findings (Lite passes muted: false identically). */ + var result = await _core.TrySendAsync( + alert.MetricName, alert.ServerName, alert.CurrentValue, alert.ThresholdValue, + alert.ServerId, alert.Context, attemptChannels: true); + + /* Lite's single-row notification_type taxonomy (EmailAlertService.cs): email + attempted → "email"; webhook delivery upgrades to "email+webhook"/"webhook"; + otherwise "tray". */ + var notificationType = "tray"; + if (result.EmailAttempted) + { + notificationType = "email"; + } + + var sent = result.EmailSent; + if (result.WebhookSent) + { + notificationType = notificationType == "email" ? "email+webhook" : "webhook"; + sent = true; + } + + /* Always log the alert, regardless of channel status — the structured context + persists as JSON alongside the flat detail_text, the numeric severity/threshold + in the double columns (Lite's shape). */ + string? contextJson = alert.Context is not null ? AlertContextSerializer.Serialize(alert.Context) : null; + await _historyStore.RecordAlertAsync(new AlertHistoryRecord( + alert.ServerId, alert.ServerName, alert.MetricName, + alert.CurrentValue, alert.ThresholdValue, + alert.Severity, alert.NotifyThreshold, + sent, notificationType, result.SendError, + false, alert.DetailText, contextJson)); + } + catch (Exception ex) + { + _logger.LogError("Finding alert delivery failed for {Metric} on {Server}: {Message}", + alert.MetricName, alert.ServerName, ex.Message); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs new file mode 100644 index 000000000..6c357b915 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs @@ -0,0 +1,1941 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Net; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Npgsql; +using PerformanceMonitor.Darling.Storage; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// The bundled-Postgres bootstrap — the shipped zero-admin default (headless plan Phase 8 / +/// v4.3 "bundled-managed"): when darling.json says postgres.managed = true, the worker +/// calls BEFORE touching the store, and this class unpacks the +/// runtime shipped beside the service (pg-runtime\pgsql\, self-healing from +/// pg-runtime.zip), initializes a cluster on first run (initdb), starts the server when +/// it is not already running (pg_ctl, loopback by default; an opt-in postgres.network block +/// exposes it on the LAN behind TLS + pg_hba — see the posture note below), creates the +/// darling database, and returns the derived connection string. On service shutdown the worker calls +/// — the flag matters: a server this process did +/// NOT start (an operator's own pg_ctl, a previous service crash's surviving postmaster) is +/// adopted for connections but never stopped, because stopping someone else's server is not +/// this service's call to make. +/// +/// Security posture — why not trust auth, even on localhost: initdb runs with +/// -A scram-sha-256 and a generated 32-character random password, never -A trust. +/// Trust would hand superuser DDL/DML to ANY local code that can open a loopback socket — +/// including network-capable-but-not-filesystem-capable attack primitives (SSRF from a co-hosted +/// web app, sandboxed code with socket access) and every other local user — silently and +/// unauditably. A shipped default must also survive an enterprise security scan, where a +/// trust-auth listener is an automatic finding. With scram the credential is required on the +/// wire, failed attempts are auditable, and access is confined to what can read the +/// DPAPI-LocalMachine-protected credential file (pg-credential.dpapi beside the data +/// directory — the same machine-bound posture as darling.json's encryptedPassword, so +/// the interactive Viewer on the same machine can derive the connection string too). +/// +/// Network exposure — off by default, secure by default (darling-network-endpoints): +/// with no postgres.network block the store binds listen_addresses = '127.0.0.1' +/// (forced every start via the -o runtime override, so it holds even against a hand-edited +/// ALTER SYSTEM), has no pg_hba network rule, no firewall rule, and ssl=off — byte-for-byte +/// today's loopback-only behavior. An opt-in network block (managed mode only) adds the bind IP to +/// listen_addresses, generates a self-signed cert for TLS verify-full, writes a marked +/// hostssl darling <role> <allowFrom> scram-sha-256 pg_hba block (loopback rules stay +/// plain host), reloads, and best-effort adds a scoped firewall rule. Every layer is reconciled +/// each start, symmetric (removing the block closes the box), and fail-closed: any invalid/incomplete +/// exposure config degrades the store to loopback (the full disable-reconcile) + LogCritical — never +/// ssl=off while exposed, never a stale pg_hba rule left open. +/// +/// Every step throws with an actionable message; the worker turns a bootstrap failure +/// into LogCritical + clean service exit (the existing no-store behavior). All idempotent: +/// a second against an initialized, running cluster does no +/// initdb, no restart, no credential rewrite. The conf append is marker-guarded and re-checked +/// every start, so a crash between initdb and the append self-heals on the next run instead of +/// silently degrading TimescaleDB to plain-PG mode. +/// +[SupportedOSPlatform("windows")] +public sealed class DarlingManagedPostgres +{ + /// The cluster's bootstrap superuser AND the store's database name — the managed twin of the sample's unmanaged string. + public const string UserName = "darling"; + public const string DatabaseName = "darling"; + + /// DPAPI-LocalMachine blob holding the generated password, beside (not inside) the data directory. + public const string CredentialFileName = "pg-credential.dpapi"; + + /// + /// DPAPI-LocalMachine blobs holding the generated passwords for the least-privilege login roles + /// the V8 security hardening provisions (): admin reads + /// both schemas + writes config (the Viewer's default identity), viewer reads only. + /// Same location/posture as — beside the data directory, machine + /// bound. Generated idempotently and self-healing (a deleted file regenerates and the superuser + /// re-asserts the role's password, unlike the owner's unrecoverable password). + /// + public const string AdminCredentialFileName = "pg-admin-credential.dpapi"; + public const string ViewerCredentialFileName = "pg-viewer-credential.dpapi"; + + /// + /// DPAPI-LocalMachine blob holding the generated password for the dedicated least-privilege + /// mcp login role (darling-network-endpoints, D3-role) — the store pool identity the + /// (optionally network-exposed) MCP host connects as. Same location/posture as the admin/viewer + /// credentials, but hardened NON-interactive ( with + /// allowInteractiveRead: false): it is consumed only by the in-service MCP host, never an + /// interactive Viewer, so it mirrors the superuser credential's ACL, not the admin/viewer one. + /// + public const string McpCredentialFileName = "pg-mcp-credential.dpapi"; + + /// The least-privilege login role names provisioned into the managed cluster (V8 hardening; + /// mcp added for the network endpoints, D3-role). + public const string AdminRoleName = "admin"; + public const string ViewerRoleName = "viewer"; + public const string McpRoleName = "mcp"; + + /// + /// The search path (schemas in resolution order) the managed connection strings carry, so pooled + /// connections resolve the bare table names to collect/config even if the database default was + /// not (or could not be) set. Same schemas, same order as the SQL-side + /// PgSchemaGenerator.SearchPath the V8 split writes as the database default — the + /// connection-string form omits spaces; a test pins the order against the SQL form. + /// + public const string SearchPath = "collect,config,public"; + + /// The server log pg_ctl appends to, beside (not inside) the data directory. + public const string ServerLogFileName = "pg.log"; + + /// Marker line guarding the idempotent postgresql.conf append. + public const string ConfMarker = "# Managed by PerformanceMonitor Darling -- do not remove this block"; + + /// + /// Marker for the v2 worker-sizing conf block. A separate versioned block rather than an edit + /// of the v1 block, so clusters initialized before the sizing existed self-heal on their next + /// start — checks each marker independently, and a + /// marker-present-but-different-block conf is never rewritten in place. + /// + public const string ConfMarkerV2 = "# Managed by PerformanceMonitor Darling (v2 worker sizing) -- do not remove this block"; + + /// + /// Marker for the v3 memory-sizing conf block (derived from host RAM). A THIRD independently + /// versioned block, separate for the same reason v2 is separate from v1: an already-provisioned + /// cluster (v1 + v2 present, v3 absent) heals by GAINING this block on its next service-owned + /// start rather than by an in-place rewrite, which never does. + /// Each marker is checked independently. + /// + public const string ConfMarkerV3 = "# Managed by PerformanceMonitor Darling (v3 memory sizing) -- do not remove this block"; + + /// + /// Marker for the v4 write-throughput block (connection headroom + WAL ceiling). A FOURTH + /// independently versioned block, same heal discipline as v2/v3. Born from a 24-server field + /// incident: the PG defaults of max_connections = 100 and max_wal_size = 1GB are + /// sized for a toy, and a fleet's first-bootstrap write burst (every collector's initial + /// snapshot landing at once) forced back-to-back spread checkpoints while backend spawn + /// failures (Windows error 487 under churn) surfaced as transient store write errors. + /// + public const string ConfMarkerV4 = "# Managed by PerformanceMonitor Darling (v4 write throughput) -- do not remove this block"; + + /// + /// Marker for the v5 co-located-sizing override (#1559). A FIFTH independently versioned block, same + /// heal discipline: clusters whose v3 block wrote the old min(25%, 8 GB) shared_buffers gain this + /// override on their next service-owned start — postgresql.conf takes the LAST occurrence of a + /// setting, so appending wins without rewriting the v3 block. Carries the capped derivation + /// (min(25% RAM, 1 GB)); on a box already at or under the cap it re-states the same figure, harmlessly. + /// + public const string ConfMarkerV5 = "# Managed by PerformanceMonitor Darling (v5 co-located sizing) -- do not remove this block"; + + /// + /// Markers delimiting the Darling-managed network access block in pg_hba.conf + /// (darling-network-endpoints, D5). replaces exactly the lines + /// between them and preserves every non-marked line, so the opt-in hostssl rule is + /// symmetric — appended when exposed, removed when disabled — without disturbing the operator's + /// own entries or initdb's loopback defaults. + /// + public const string PgHbaBeginMarker = "# BEGIN PerformanceMonitor Darling network access -- managed block, do not edit"; + public const string PgHbaEndMarker = "# END PerformanceMonitor Darling network access"; + + /// The self-signed store TLS cert + key (PEM), generated beside the data directory for + /// verify-full when exposed (darling-network-endpoints, D6). Delete both to rotate. + public const string ServerCertFileName = "server.crt"; + public const string ServerKeyFileName = "server.key"; + + /// ~10-year self-signed validity (D6): a home-lab cert the operator pins; delete-to-rotate. + private const int ServerCertValidityYears = 10; + + private const string PasswordAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + private const int PasswordLength = 32; + + /* Process budgets. pg_ctl start/stop get -w -t 60 of their own, so the outer budget only + has to outlive them; initdb on a cold disk can take tens of seconds. */ + private static readonly TimeSpan s_initDbTimeout = TimeSpan.FromSeconds(180); + private static readonly TimeSpan s_pgCtlTimeout = TimeSpan.FromSeconds(90); + private static readonly TimeSpan s_statusTimeout = TimeSpan.FromSeconds(30); + private const int PgCtlWaitSeconds = 60; + + private readonly PostgresConfig _config; + private readonly ILogger _logger; + private readonly string _runtimeRoot; + private readonly string _runtimeZipPath; + private readonly string _dataDirectory; + private readonly string _credentialPath; + private readonly string _serverLogPath; + + private bool _startedByThisProcess; + + public DarlingManagedPostgres(PostgresConfig config, ILogger logger, string? runtimeRootOverride = null) + { + _config = config ?? throw new ArgumentNullException(nameof(config)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _runtimeRoot = runtimeRootOverride ?? Path.Combine(AppContext.BaseDirectory, "pg-runtime"); + _runtimeZipPath = Path.Combine(AppContext.BaseDirectory, "pg-runtime.zip"); + _dataDirectory = ResolveDataDirectory(config); + _credentialPath = CredentialPathFor(_dataDirectory); + _serverLogPath = Path.Combine(ParentOf(_dataDirectory), ServerLogFileName); + } + + /// True when THIS process started the server — the only case shutdown may stop it. + public bool StartedByThisProcess => _startedByThisProcess; + + public string DataDirectory => _dataDirectory; + + /// null/empty dataDirectory means %ProgramData%\PerformanceMonitorDarling\pg (created with inherited ACLs). + public static string ResolveDataDirectory(PostgresConfig config) + { + if (config is null) + { + throw new ArgumentNullException(nameof(config)); + } + + return string.IsNullOrWhiteSpace(config.DataDirectory) + ? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "PerformanceMonitorDarling", "pg") + : Path.GetFullPath(config.DataDirectory); + } + + public static string CredentialPathFor(string dataDirectory) + => Path.Combine(ParentOf(dataDirectory), CredentialFileName); + + /// Path to the admin role's DPAPI credential, beside the data directory. + public static string AdminCredentialPathFor(string dataDirectory) + => Path.Combine(ParentOf(dataDirectory), AdminCredentialFileName); + + /// Path to the viewer role's DPAPI credential, beside the data directory. + public static string ViewerCredentialPathFor(string dataDirectory) + => Path.Combine(ParentOf(dataDirectory), ViewerCredentialFileName); + + /// Path to the mcp role's DPAPI credential, beside the data directory (darling-network-endpoints). + public static string McpCredentialPathFor(string dataDirectory) + => Path.Combine(ParentOf(dataDirectory), McpCredentialFileName); + + /// + /// 32 characters from [A-Za-z0-9] via the crypto RNG (~190 bits) — deliberately alphanumeric + /// only, so the password survives initdb's --pwfile line, the connection string, and any + /// future conf/pgpass surface without escaping bugs; the length carries the strength. + /// + public static string GeneratePassword() + => RandomNumberGenerator.GetString(PasswordAlphabet, PasswordLength); + + /// + /// The block appended once (marker-guarded) to the fresh cluster's postgresql.conf: + /// TimescaleDB preloaded (the extension refuses to CREATE without it), the configured port, + /// and loopback-only listening. The port line is a default for anyone starting the cluster + /// by hand; the service itself passes -o "-p <port>" so a darling.json port change + /// wins on the next start without editing the conf. + /// + public static string BuildConfAppend(int port) + { + var builder = new StringBuilder(); + builder.Append('\n'); + builder.Append(ConfMarker).Append('\n'); + builder.Append("shared_preload_libraries = 'timescaledb'\n"); + builder.Append("port = ").Append(port).Append('\n'); + builder.Append("listen_addresses = '127.0.0.1'\n"); + /* LZ4 TOAST (PG14+; the bundled runtime is PG18): large text/XML values — query text, plan + XML, deadlock/blocked-process XML — auto-compress on write faster than the pglz default + and about as small, shrinking the ~1-day hot window before TimescaleDB's columnar + compression takes over. This is PostgreSQL's automatic equivalent of the SQL-Server + Dashboard's manual COMPRESS()/DECOMPRESS() on those columns — applied to every large + value, not hand-picked ones. Managed mode only; a BYO store uses its own server default. */ + builder.Append("default_toast_compression = lz4\n"); + return builder.ToString(); + } + + /// + /// The v2 worker-sizing block. PostgreSQL's default max_worker_processes = 8 cannot + /// launch TimescaleDB's 26 per-hypertable compression policy jobs — the postmaster logs + /// "failed to launch job ... failed to start a background worker" storms and most policy runs + /// fail (caught live: 21 failures vs 7 successes in timescaledb_information.job_stats on a + /// fresh managed instance). Sizing follows the TimescaleDB guidance + /// (max_worker_processes = 3 + timescaledb.max_background_workers + max_parallel_workers, + /// background workers sized to hypertables + 2): DERIVED from the live hypertable count + /// ( = the collector catalog PLUS collection_log, the V23 + /// non-catalog hypertable) so it never goes stale as collectors are added and is not under-sized by the + /// collection_log compression policy (27 hypertables -> 29/40, 33 -> 35/46). Idle background workers + /// cost a few MB each and no CPU. Both settings need a PostgreSQL restart, so an existing + /// cluster picks this up on its next service-owned start — an adopted (not-started-by-us) + /// server heals the conf now and applies it whenever its operator next restarts it. + /// + public static string BuildWorkerSizingConfAppend() + { + /* Derived from the TRUE hypertable count (never stale when a collector is added; includes + collection_log, the V23 hypertable outside the collector catalog): one background worker per + per-hypertable compression policy that can run concurrently + the scheduler + slack; + max_worker_processes = 3 (other bg workers) + bg workers + 8 (default max_parallel_workers). */ + var maxBackgroundWorkers = TimescaleSupport.HypertableCount + 2; + var maxWorkerProcesses = 3 + maxBackgroundWorkers + 8; + var builder = new StringBuilder(); + builder.Append('\n'); + builder.Append(ConfMarkerV2).Append('\n'); + builder.Append("timescaledb.max_background_workers = ").Append(maxBackgroundWorkers).Append('\n'); + builder.Append("max_worker_processes = ").Append(maxWorkerProcesses).Append('\n'); + return builder.ToString(); + } + + /// + /// The v4 write-throughput block. Two fixed settings, deliberately not derived: + /// max_connections = 200 doubles the PG default — headroom for the service pool, the + /// co-located viewer's admin/viewer seats, MCP, and psql without approaching the ceiling + /// (idle PG connections cost a few MB each; the real concurrency is bounded by the service's + /// own pool). max_wal_size = 4GB quadruples the default 1GB, which a fleet's + /// first-bootstrap burst (every collector's initial snapshot at once) blows through in + /// seconds, forcing continuous spread checkpoints that stall every write behind them + /// (observed live: back-to-back 4-minute checkpoints on a 24-server bootstrap). WAL space is + /// a CEILING, not an allocation — a quiet store never uses it. Both are restart-only, healed + /// on the next service-owned start like v2/v3. + /// + public static string BuildWriteThroughputConfAppend() + { + var builder = new StringBuilder(); + builder.Append('\n'); + builder.Append(ConfMarkerV4).Append('\n'); + builder.Append("max_connections = 200\n"); + builder.Append("max_wal_size = 4GB\n"); + return builder.ToString(); + } + + /// + /// The v5 co-located-sizing override block (#1559): re-states shared_buffers at the CAPPED + /// derivation (min(25% RAM, 1 GB) — see for the dedicated-server + /// and Windows-487 rationale) so an EXISTING cluster provisioned under the old min(25%, 8 GB) rule + /// heals down on its next service-owned start. postgresql.conf semantics: the LAST occurrence of a + /// setting wins, so this override never edits the v3 block in place. + /// + public static string BuildColocatedSizingConfAppend(long totalPhysicalMemoryBytes) + { + var settings = DeriveMemorySettings(totalPhysicalMemoryBytes); + var builder = new StringBuilder(); + builder.Append('\n'); + builder.Append(ConfMarkerV5).Append('\n'); + builder.Append("shared_buffers = ").Append(settings.SharedBuffersMb).Append("MB\n"); + return builder.ToString(); + } + + /* ===================== v3 memory sizing (derived from host RAM) ===================== */ + + /// 4 GB — the conservative fallback used only when the runtime RAM query fails, so sizing + /// never divides a zero/garbage reading (yields shared_buffers 1 GB, work_mem 16 MB). + private const long MemoryFallbackRamBytes = 4L * 1024 * 1024 * 1024; + + /// The four PostgreSQL memory settings derived from total physical RAM, all in whole MB. + internal readonly record struct MemorySettings( + int SharedBuffersMb, + int EffectiveCacheSizeMb, + int MaintenanceWorkMemMb, + int WorkMemMb); + + /// + /// Derives the four memory settings from total physical RAM — PURE and testable via an injected byte + /// count, exactly the way derives from the hypertable count. + /// This is SCALE-READINESS, not a fix for observed pressure: the stock PostgreSQL defaults + /// (shared_buffers 128 MB, work_mem 4 MB, maintenance_work_mem 64 MB, effective_cache_size 4 GB) are + /// fine for a handful of monitored servers on an 8 GB box, but would bottleneck the "up to 500 servers" + /// store. Formulas: + /// + /// shared_buffers = min(25% RAM, 1 GB) — the docs' 25% starting point is explicitly + /// conditioned on a DEDICATED database server, which the managed store is NOT: it is co-located + /// with the service, the viewer, and often Lite, so the OS cache is shared and a large PG buffer + /// pool double-caches against it. The 1 GB CAP is also the Windows 487 mitigation (#1559): every + /// backend process must re-reserve the shared memory region at the postmaster's base address, and + /// the pgsql-bugs history (BUG #14050 / #18954) documents larger shared_buffers exacerbating + /// could-not-reserve-shared-memory (error code 487) spawn failures under ASLR/DLL address + /// pressure — observed live on a 16 GB field box running the prior 4 GB segment. A smaller + /// segment also shrinks every backend's reattach surface and the checkpointer's sweep. + /// RESTART-ONLY, like max_worker_processes; it applies on the next server start. + /// effective_cache_size = 75% RAM — a PLANNER HINT (no allocation) telling the planner how + /// much data is likely cached (PG + OS cache), biasing it toward index scans. + /// maintenance_work_mem = min(5% RAM, 1 GB) — headroom for VACUUM / CREATE INDEX; capped at + /// 1 GB because several autovacuum workers can each take up to this. + /// work_mem = clamp(RAM/512, 16 MB, 64 MB) — the ONE with real downside (per-sort, + /// per-connection: worst-case ≈ max_connections × sorts × work_mem), so it is deliberately modest. + /// At the PG default max_connections = 100 with ~3 concurrent sort/hash nodes, the pathological + /// all-connections-busy case is 100 × 3 × (RAM/512) ≈ 58% of RAM until the 64 MB cap tightens it — + /// and each sort/hash SPILLS to a temp file rather than OOMing when it exceeds work_mem (PG13+ spills + /// hash aggregates too), while the real Darling store runs a handful of pooled connections, not 100 + /// concurrent analytical queries. + /// + /// + internal static MemorySettings DeriveMemorySettings(long totalPhysicalMemoryBytes) + { + var ram = totalPhysicalMemoryBytes > 0 ? totalPhysicalMemoryBytes : MemoryFallbackRamBytes; + + const long oneMb = 1024L * 1024L; + const long oneGb = 1024L * oneMb; + + var sharedBuffers = Math.Min(ram / 4, oneGb); /* 25% RAM, capped at 1 GB (co-located store + Windows 487 mitigation, #1559) — restart-only */ + var effectiveCache = ram / 4 * 3; /* 75% RAM — planner hint, not an allocation */ + var maintenanceWorkMem = Math.Min(ram / 20, oneGb); /* 5% RAM, capped at 1 GB */ + var workMem = Math.Clamp(ram / 512, 16 * oneMb, 64 * oneMb); + + return new MemorySettings( + (int)(sharedBuffers / oneMb), + (int)(effectiveCache / oneMb), + (int)(maintenanceWorkMem / oneMb), + (int)(workMem / oneMb)); + } + + /// + /// The v3 memory-sizing block, built the SAME marker-guarded way as + /// and appended by on every + /// start (so an already-provisioned cluster gains it on restart, not just a fresh initdb). Takes the + /// RAM byte count so it is unit-testable; supplies the live + /// value at runtime. Values are emitted in whole MB. shared_buffers needs a PostgreSQL restart — the + /// service applies the whole block on its next server-owned start, exactly as it does the restart-only + /// worker sizing. + /// + public static string BuildMemorySizingConfAppend(long totalPhysicalMemoryBytes) + { + var settings = DeriveMemorySettings(totalPhysicalMemoryBytes); + var builder = new StringBuilder(); + builder.Append('\n'); + builder.Append(ConfMarkerV3).Append('\n'); + builder.Append("shared_buffers = ").Append(settings.SharedBuffersMb).Append("MB\n"); + builder.Append("effective_cache_size = ").Append(settings.EffectiveCacheSizeMb).Append("MB\n"); + builder.Append("maintenance_work_mem = ").Append(settings.MaintenanceWorkMemMb).Append("MB\n"); + builder.Append("work_mem = ").Append(settings.WorkMemMb).Append("MB\n"); + return builder.ToString(); + } + + /// + /// The derived managed-mode connection string: 127.0.0.1 + port + darling/darling + the + /// generated password, carrying the collect/config so every pooled connection + /// resolves the bare table names to the V8 schemas regardless of the database default. Uses the explicit + /// IPv4 loopback rather than the name "localhost": listen_addresses binds IPv4 127.0.0.1 (plus the + /// optional network IP when exposed), NOT ::1, so a host that resolves "localhost" to IPv6 first + /// could otherwise miss the listener. (darling-network-endpoints) + /// + public static string BuildConnectionString(int port, string password) + => BuildRoleConnectionString(port, UserName, password); + + /// + /// Builds a managed loopback connection string for a specific login role — shared by + /// (the owner) and + /// (the mcp role). Same + /// 127.0.0.1 + port + shape; only the username/password differ. + /// + private static string BuildRoleConnectionString(int port, string username, string password) + { + var builder = new NpgsqlConnectionStringBuilder + { + Host = "127.0.0.1", + Port = port, + Username = username, + Password = password, + Database = DatabaseName, + SearchPath = SearchPath, + /* #1559: bound the service's backend count. Every pooled Npgsql connection is a live + postgres.exe PROCESS on Windows, and each spawn must re-reserve the shared memory + region (the 487 surface) — a field box showed 43 backends during a 24-server sweep. + 24 comfortably covers the 4-wide sweep + command/beacon/alert/analysis seams; Npgsql's + default idle pruning shrinks the pool between bursts. */ + MaxPoolSize = 24, + }; + return builder.ConnectionString; + } + + /// + /// Derives the managed connection string from the stored credential WITHOUT touching the + /// server — for secondary consumers (the MCP host) that must never bootstrap; the worker + /// owns the lifecycle. Null until the worker's first initdb has written the credential. + /// + public static string? TryBuildConnectionStringFromStoredCredential(PostgresConfig config) + { + var credentialPath = CredentialPathFor(ResolveDataDirectory(config)); + if (!File.Exists(credentialPath)) + { + return null; + } + + return BuildConnectionString(config.Port, DarlingSecrets.Unprotect(File.ReadAllText(credentialPath).Trim())); + } + + /// + /// Derives the MCP store connection string from the stored mcp-role credential WITHOUT touching + /// the server (darling-network-endpoints, D3-role) — the least-privilege pool the MCP host connects as + /// instead of the owner. Same 127.0.0.1 + port + shape as the owner + /// string, but Username = mcp. Null until + /// has written the credential — which happens AFTER migration, later than the owner credential, so the + /// MCP host's first-boot poll budget must tolerate the delay. + /// + public static string? TryBuildMcpConnectionStringFromStoredCredential(PostgresConfig config) + { + var credentialPath = McpCredentialPathFor(ResolveDataDirectory(config)); + if (!File.Exists(credentialPath)) + { + return null; + } + + return BuildRoleConnectionString(config.Port, McpRoleName, DarlingSecrets.Unprotect(File.ReadAllText(credentialPath).Trim())); + } + + /// + /// Derives the store connection string from the stored viewer-role credential WITHOUT touching the + /// server (#1562) — the least-privilege READ-ONLY pool the web dashboard host connects as (not the owner, + /// not mcp). Same 127.0.0.1 + port + shape as the owner string, but + /// Username = viewer. Null until has written + /// the credential (AFTER migration), so the web host's first-boot poll budget must tolerate the delay — the + /// twin of . + /// + public static string? TryBuildViewerConnectionStringFromStoredCredential(PostgresConfig config) + { + var credentialPath = ViewerCredentialPathFor(ResolveDataDirectory(config)); + if (!File.Exists(credentialPath)) + { + return null; + } + + return BuildRoleConnectionString(config.Port, ViewerRoleName, DarlingSecrets.Unprotect(File.ReadAllText(credentialPath).Trim())); + } + + /// + /// The whole first-run story, idempotent: locate/unpack the runtime, initdb if the data + /// directory has no cluster, self-heal the conf append, start the server if nothing is + /// listening on the data directory, create the darling database if missing, and return the + /// ready-to-use connection string. Throws (actionably) on any failure — the worker logs it + /// critical and exits cleanly. + /// + public async Task EnsureRunningAsync(CancellationToken cancellationToken) + { + var binDirectory = await EnsureRuntimeAsync(cancellationToken); + + /* Create the directory that holds the data dir + the DPAPI credential files, then LOCK it + down (V8 hardening): strip the inherited world-readable ACLs %ProgramData% would otherwise + give it, leaving SYSTEM + Administrators + the service account, plus INTERACTIVE traverse so + the operator's Viewer can reach the admin/viewer credential files beside the data directory. + Re-applied every start (self-healing, like the conf append) so an existing loose install is + tightened too. Done BEFORE initdb so the data-dir subtree inherits the locked-down ACL. */ + Directory.CreateDirectory(ParentOf(_dataDirectory)); + TryHardenDirectory(ParentOf(_dataDirectory)); + + if (!File.Exists(Path.Combine(_dataDirectory, "PG_VERSION"))) + { + await InitializeClusterAsync(binDirectory, cancellationToken); + } + + EnsureConfAppended(); + + var password = ReadStoredPassword(); + + /* Resolve the opt-in network exposure (darling-network-endpoints) BEFORE start so listen_addresses + and the ssl trio can ride the -o runtime override. Fail-closed: an invalid/incomplete exposure + config (or a cert-gen failure) returns a LOOPBACK plan carrying the degrade reason, logged + critical here; the reconcile below then runs the FULL disable-reconcile (loopback listen + pg_hba + block removed + reload + verify), so a previously-exposed cluster actually closes. The whole + network path is caught internally (BuildNetworkPlan swallows cert-gen failure into a degrade; + ReconcileNetworkAsync never throws) because EnsureRunningAsync's contract is throw => service-exit, + and a typo in an optional, default-off endpoint must NEVER take collection down (Round 4 #3). */ + var networkPlan = BuildNetworkPlan(); + if (networkPlan.DegradeReason is not null) + { + _logger.LogCritical( + "Store network exposure DISABLED (degraded to loopback-only): {Reason}. Fix postgres.network and restart to expose the store.", + networkPlan.DegradeReason); + } + + if (await IsRunningAsync(binDirectory, cancellationToken)) + { + /* Already running — a previous service crash's surviving postmaster, or an operator + started it by hand. Use it, never stop it (the flag stays false). */ + _logger.LogInformation( + "Managed Postgres is already running for {DataDirectory} — connecting to it (this service did not start it and will not stop it)", + _dataDirectory); + } + else + { + await StartServerAsync(binDirectory, networkPlan, cancellationToken); + _startedByThisProcess = true; + } + + var connectionString = BuildConnectionString(_config.Port, password); + await EnsureDatabaseAsync(connectionString, cancellationToken); + + /* Reconcile pg_hba + reload + verify, the adopted-listener guard, and the firewall against the LIVE + server — symmetric (present when exposed, absent when loopback/degraded). Never throws (Round 4 #3): + a network reconcile failure logs + degrades, it does not abort the bootstrap. */ + await ReconcileNetworkAsync(binDirectory, networkPlan, connectionString, cancellationToken); + + return connectionString; + } + + /// + /// pg_ctl stop -m fast, ONLY when this process started the server. Called on worker + /// shutdown; never throws (a failed stop at shutdown is a warning, not a crash) and never + /// takes the (already-cancelled) stopping token. + /// + public async Task StopIfStartedByThisProcessAsync() + { + if (!_startedByThisProcess) + { + return; + } + + _startedByThisProcess = false; + try + { + var binDirectory = Path.Combine(_runtimeRoot, "pgsql", "bin"); + var (exitCode, output) = await RunToolAsync( + Path.Combine(binDirectory, "pg_ctl.exe"), + $"stop -D \"{_dataDirectory}\" -m fast -w -t {PgCtlWaitSeconds}", + s_pgCtlTimeout, + CancellationToken.None); + + if (exitCode == 0) + { + _logger.LogInformation("Managed Postgres stopped (fast shutdown)"); + } + else + { + _logger.LogWarning("Managed Postgres stop reported exit code {ExitCode}: {Output}", exitCode, output); + } + } + catch (Exception ex) + { + _logger.LogWarning("Managed Postgres stop failed: {Message}", ex.Message); + } + } + + /// + /// Locates pg-runtime\pgsql\bin beside the service; when only pg-runtime.zip is present + /// (fresh install, or someone deleted the extracted copy) it self-heals by extracting. + /// Neither present is a packaging problem with a packaging answer, not a retry loop. + /// + private async Task EnsureRuntimeAsync(CancellationToken cancellationToken) + { + var pgsqlDirectory = Path.Combine(_runtimeRoot, "pgsql"); + var binDirectory = Path.Combine(pgsqlDirectory, "bin"); + var pgCtl = Path.Combine(binDirectory, "pg_ctl.exe"); + if (File.Exists(pgCtl)) + { + return binDirectory; + } + + if (File.Exists(_runtimeZipPath)) + { + _logger.LogInformation("Extracting the bundled Postgres runtime from {Zip} (first run)", _runtimeZipPath); + await Task.Run( + () => ZipFile.ExtractToDirectory(_runtimeZipPath, _runtimeRoot, overwriteFiles: true), + cancellationToken); + + if (File.Exists(pgCtl)) + { + return binDirectory; + } + + throw new InvalidOperationException( + $"Extracted {_runtimeZipPath} but {pgCtl} is still missing — the archive does not contain pgsql\\bin. " + + "Rebuild it with Darling\\tools\\fetch-pg-runtime.ps1 and redeploy."); + } + + throw new InvalidOperationException( + $"Managed Postgres runtime not found: neither {pgsqlDirectory} nor {_runtimeZipPath} exists. " + + "Packaging builds pg-runtime.zip with Darling\\tools\\fetch-pg-runtime.ps1 and ships it beside the service binary; " + + "put it there, or set postgres.managed = false and point postgres.connectionString at your own PostgreSQL."); + } + + /// + /// First-run initdb. The credential is generated and DPAPI-persisted BEFORE initdb runs: + /// if the order were reversed, a crash after a successful initdb would leave a cluster whose + /// password nobody knows. A failed initdb leaves the credential file behind harmlessly — + /// the next attempt regenerates and overwrites it (initdb itself cleans up its partial data + /// directory on failure). + /// + private async Task InitializeClusterAsync(string binDirectory, CancellationToken cancellationToken) + { + _logger.LogInformation("Initializing managed Postgres cluster in {DataDirectory} (first run)", _dataDirectory); + + var password = GeneratePassword(); + File.WriteAllText(_credentialPath, DarlingSecrets.Protect(password)); + /* The SUPERUSER credential — locked to SYSTEM + Administrators + the service account, NEVER an + interactive user (post-split the Viewer connects as admin/viewer, never darling). */ + TryHardenCredentialFile(_credentialPath, allowInteractiveRead: false); + + /* --pwfile is the non-interactive way to hand initdb the superuser password; the file + lives next to the (equally sensitive, DPAPI-protected) credential for its few seconds + of life and is deleted in finally. Alphanumeric-only password, UTF8 no BOM — a BOM + would corrupt the first (and only) line initdb reads. Same restrictive ACL: it briefly + holds the superuser password in the clear. */ + var passwordFile = Path.Combine(ParentOf(_dataDirectory), "pg-pwfile.tmp"); + File.WriteAllText(passwordFile, password + "\n"); + TryHardenCredentialFile(passwordFile, allowInteractiveRead: false); + try + { + var (exitCode, output) = await RunToolAsync( + Path.Combine(binDirectory, "initdb.exe"), + $"-D \"{_dataDirectory}\" -U {UserName} -A scram-sha-256 --pwfile=\"{passwordFile}\" -E UTF8 --locale=C --data-checksums", + s_initDbTimeout, + cancellationToken); + + if (exitCode != 0) + { + throw new InvalidOperationException( + $"initdb failed (exit code {exitCode}) for {_dataDirectory}. Output:\n{output}"); + } + } + finally + { + try + { + File.Delete(passwordFile); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _logger.LogWarning("Could not delete the temporary password file {Path} — delete it by hand", passwordFile); + } + } + + _logger.LogInformation("Managed Postgres cluster initialized (scram-sha-256, data checksums, UTF8/C locale)"); + } + + /// + /// Marker-guarded conf append, re-checked on EVERY start — heals the crash window between + /// initdb and the first append, which would otherwise silently cost TimescaleDB + /// (shared_preload_libraries missing = CREATE EXTENSION fails = plain-PG degradation). + /// + private void EnsureConfAppended() + { + var confPath = Path.Combine(_dataDirectory, "postgresql.conf"); + if (!File.Exists(confPath)) + { + throw new InvalidOperationException( + $"{confPath} is missing although PG_VERSION exists — the data directory looks damaged. " + + "Stop the service, move/delete the data directory to re-initialize (destroys collected history), " + + "or restore it from backup."); + } + + var conf = File.ReadAllText(confPath); + if (!conf.Contains(ConfMarker, StringComparison.Ordinal)) + { + File.AppendAllText(confPath, BuildConfAppend(_config.Port)); + _logger.LogInformation("Appended managed settings to postgresql.conf (timescaledb preload, port {Port}, loopback only)", _config.Port); + } + + /* Checked independently of the v1 marker: clusters initialized before the worker sizing + existed have v1 but not v2, and heal here on their next start. */ + if (!conf.Contains(ConfMarkerV2, StringComparison.Ordinal)) + { + File.AppendAllText(confPath, BuildWorkerSizingConfAppend()); + _logger.LogInformation("Appended v2 worker sizing to postgresql.conf (derived from {Hypertables} hypertables; effective from the next PostgreSQL restart)", TimescaleSupport.HypertableTables.Count); + } + + /* Checked independently of v1/v2: an already-provisioned cluster has v1 + v2 but not v3, and heals + by GAINING the memory block here on its next start. shared_buffers is restart-only, so it applies + whenever the service next owns the start — exactly the worker-sizing story. Derived from the host's + physical RAM at runtime (SCALE-READINESS for the up-to-500-servers store, not a fix for pressure). */ + if (!conf.Contains(ConfMarkerV3, StringComparison.Ordinal)) + { + var ramBytes = GetTotalPhysicalMemoryBytes(); + File.AppendAllText(confPath, BuildMemorySizingConfAppend(ramBytes)); + var derived = DeriveMemorySettings(ramBytes); + _logger.LogInformation( + "Appended v3 memory sizing to postgresql.conf (host RAM {RamMb} MB -> shared_buffers {SharedBuffers}MB, effective_cache_size {EffectiveCache}MB, maintenance_work_mem {Maintenance}MB, work_mem {WorkMem}MB; effective from the next PostgreSQL restart)", + ramBytes / (1024L * 1024L), derived.SharedBuffersMb, derived.EffectiveCacheSizeMb, derived.MaintenanceWorkMemMb, derived.WorkMemMb); + } + + /* Checked independently of v1/v2/v3: an already-provisioned cluster heals by GAINING the + write-throughput block (connection headroom + WAL ceiling) on its next start. */ + if (!conf.Contains(ConfMarkerV4, StringComparison.Ordinal)) + { + File.AppendAllText(confPath, BuildWriteThroughputConfAppend()); + _logger.LogInformation( + "Appended v4 write throughput to postgresql.conf (max_connections 200, max_wal_size 4GB; effective from the next PostgreSQL restart)"); + } + + /* Checked independently of v1-v4: a cluster provisioned under the old min(25%, 8 GB) + shared_buffers rule heals DOWN to the co-located cap (last-occurrence-wins override). */ + if (!conf.Contains(ConfMarkerV5, StringComparison.Ordinal)) + { + var v5RamBytes = GetTotalPhysicalMemoryBytes(); + File.AppendAllText(confPath, BuildColocatedSizingConfAppend(v5RamBytes)); + _logger.LogInformation( + "Appended v5 co-located sizing to postgresql.conf (shared_buffers = {SharedBuffers}MB, capped at min(25% RAM, 1GB); effective from the next PostgreSQL restart)", + DeriveMemorySettings(v5RamBytes).SharedBuffersMb); + } + } + + /// + /// Total physical RAM in bytes, read once per bootstrap to size the v3 memory block — the runtime + /// analogue of the worker sizing's hypertable count. Uses the Win32 GlobalMemoryStatusEx + /// (ullTotalPhys = the machine's installed physical memory); on the rare failure it falls back to + /// the GC's view of total available memory, then to a conservative 4 GB, so sizing never runs on a + /// zero/garbage reading. Windows-only, like the rest of this managed-mode class. + /// + private long GetTotalPhysicalMemoryBytes() + { + try + { + var status = new MemoryStatusEx(); + if (GlobalMemoryStatusEx(status) && status.ullTotalPhys > 0) + { + return (long)status.ullTotalPhys; + } + + _logger.LogWarning( + "GlobalMemoryStatusEx did not return total physical memory (Win32 error {Error}); sizing Postgres memory from a fallback.", + Marshal.GetLastWin32Error()); + } + catch (Exception ex) + { + _logger.LogWarning("Could not query total physical memory ({Message}); sizing Postgres memory from a fallback.", ex.Message); + } + + var gcTotal = GC.GetGCMemoryInfo().TotalAvailableMemoryBytes; + return gcTotal > 0 ? gcTotal : MemoryFallbackRamBytes; + } + +#pragma warning disable CS0649 // fields are populated by the native GlobalMemoryStatusEx call, not in managed code + [StructLayout(LayoutKind.Sequential)] + private sealed class MemoryStatusEx + { + public uint dwLength = (uint)Marshal.SizeOf(); + public uint dwMemoryLoad; + public ulong ullTotalPhys; + public ulong ullAvailPhys; + public ulong ullTotalPageFile; + public ulong ullAvailPageFile; + public ulong ullTotalVirtual; + public ulong ullAvailVirtual; + public ulong ullAvailExtendedVirtual; + } +#pragma warning restore CS0649 + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GlobalMemoryStatusEx([In, Out] MemoryStatusEx lpBuffer); + + /// pg_ctl status: 0 = a postmaster is running on this data directory, 3 = not running, 4 = bad/inaccessible data directory. + private async Task IsRunningAsync(string binDirectory, CancellationToken cancellationToken) + { + var (exitCode, output) = await RunToolAsync( + Path.Combine(binDirectory, "pg_ctl.exe"), + $"status -D \"{_dataDirectory}\"", + s_statusTimeout, + cancellationToken); + + return exitCode switch + { + 0 => true, + 3 => false, + _ => throw new InvalidOperationException( + $"pg_ctl status reported exit code {exitCode} for {_dataDirectory} — the data directory is not usable. Output:\n{output}"), + }; + } + + /// + /// pg_ctl start, windowed (-w): returns only when the server accepts connections. The -o + /// runtime override carries the port (authoritative over the conf line), listen_addresses (always + /// 127.0.0.1, plus the network IP when exposed — -c outranks postgresql.auto.conf, so + /// this forces loopback even against a hand-edited ALTER SYSTEM), and the ssl trio when exposed + /// (darling-network-endpoints, D2/D6). pg_ctl itself handles a stale postmaster.pid from a crash (the + /// new postmaster validates and replaces it); when start still fails, the server log tail is surfaced + /// in the error because that is where Postgres explains itself. + /// + private async Task StartServerAsync(string binDirectory, NetworkPlan networkPlan, CancellationToken cancellationToken) + { + var exposed = networkPlan.Mode == NetworkMode.Exposed; + var runtimeOptions = BuildServerRuntimeOptions( + _config.Port, + exposed ? networkPlan.ListenIp : null, + exposed ? networkPlan.CertPath : null, + exposed ? networkPlan.KeyPath : null); + + _logger.LogInformation( + "Starting managed Postgres (listen_addresses={Listen}, ssl={Ssl}, port {Port}, log: {Log})", + BuildListenAddresses(exposed ? networkPlan.ListenIp : null), + exposed ? "on" : "off", + _config.Port, _serverLogPath); + + var exitCode = await RunDetachingToolAsync( + Path.Combine(binDirectory, "pg_ctl.exe"), + $"-D \"{_dataDirectory}\" -o \"{runtimeOptions}\" -l \"{_serverLogPath}\" -w -t {PgCtlWaitSeconds} start", + s_pgCtlTimeout, + cancellationToken); + + if (exitCode != 0) + { + throw new InvalidOperationException( + $"pg_ctl start failed (exit code {exitCode}) for {_dataDirectory}.\n" + + $"Server log tail ({_serverLogPath}):\n{ReadServerLogTail()}"); + } + + _logger.LogInformation("Managed Postgres started"); + } + + private string ReadServerLogTail() + { + try + { + if (!File.Exists(_serverLogPath)) + { + return "(no server log written)"; + } + + var lines = File.ReadAllLines(_serverLogPath); + var take = Math.Min(40, lines.Length); + return string.Join('\n', lines[^take..]); + } + catch (IOException ex) + { + return $"(could not read server log: {ex.Message})"; + } + } + + /// + /// CREATE DATABASE darling if missing, via the maintenance database. Doubles as the + /// bootstrap's end-to-end auth check: this is the first real connection with the derived + /// credential, so a wrong/stale credential fails HERE with a clear Postgres auth error + /// instead of somewhere inside the migration path. + /// + private async Task EnsureDatabaseAsync(string connectionString, CancellationToken cancellationToken) + { + var builder = new NpgsqlConnectionStringBuilder(connectionString) { Database = "postgres" }; + await using var connection = new NpgsqlConnection(builder.ConnectionString); + await connection.OpenAsync(cancellationToken); + + using (var exists = new NpgsqlCommand($"SELECT 1 FROM pg_database WHERE datname = '{DatabaseName}'", connection)) + { + if (await exists.ExecuteScalarAsync(cancellationToken) is not null) + { + return; + } + } + + _logger.LogInformation("Creating the '{Database}' database", DatabaseName); + + /* Identifier from the class constant, never from input — same interpolation reasoning + as TimescaleSupport/DarlingRetention. CREATE DATABASE cannot run in a transaction; + plain ExecuteNonQuery is the correct shape. */ + using var create = new NpgsqlCommand($"CREATE DATABASE {DatabaseName}", connection); + await create.ExecuteNonQueryAsync(cancellationToken); + } + + private string ReadStoredPassword() + { + if (!File.Exists(_credentialPath)) + { + throw new InvalidOperationException( + $"The managed Postgres data directory {_dataDirectory} is initialized but its credential file " + + $"{_credentialPath} is missing, so the service cannot authenticate. Restore the file from backup, " + + "or stop the service and move/delete the data directory to re-initialize (destroys collected history), " + + "or switch to unmanaged mode (postgres.connectionString) against a server you manage."); + } + + /* Pre-plant guard: never trust a superuser credential file owned by an arbitrary local user + (SYSTEM / Administrators / the service account only). A file someone else owns may have been + planted to feed the service a password they know — refuse rather than authenticate with it. */ + if (!DarlingFileSecurity.IsTrustedOwner(_credentialPath)) + { + throw new InvalidOperationException( + $"The managed Postgres credential file {_credentialPath} is not owned by SYSTEM, Administrators, or the " + + "service account — it may have been tampered with or pre-planted. Refusing to trust it. Investigate, then " + + "restore it from backup or re-initialize the data directory (destroys collected history)."); + } + + return DarlingSecrets.Unprotect(File.ReadAllText(_credentialPath).Trim()); + } + + /// + /// Best-effort restrictive ACL on the credential directory (V8 hardening). A failure is logged + /// loud but never bricks the service — the fresh-install path (the service account owns the + /// just-created directory) succeeds, and the trusted-owner read guard is the complementary defense. + /// + private void TryHardenDirectory(string path) + { + try + { + DarlingFileSecurity.HardenDirectory(path, allowInteractiveTraverse: true); + } + catch (Exception ex) + { + _logger.LogError( + "Could not restrict the ACL on {Path} ({Message}). The DPAPI credential files it holds may be readable by " + + "other local users — fix the directory permissions by hand (SYSTEM/Administrators/the service account only).", + path, ex.Message); + } + } + + /// Best-effort restrictive ACL on one credential file — same posture as . + private void TryHardenCredentialFile(string path, bool allowInteractiveRead) + { + try + { + DarlingFileSecurity.HardenFile(path, allowInteractiveRead); + } + catch (Exception ex) + { + _logger.LogError( + "Could not restrict the ACL on {Path} ({Message}). This DPAPI credential may be readable by other local " + + "users — fix the file permissions by hand.", path, ex.Message); + } + } + + private static string ParentOf(string dataDirectory) + { + var parent = Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(Path.GetFullPath(dataDirectory))); + if (string.IsNullOrEmpty(parent)) + { + throw new InvalidOperationException( + $"postgres.dataDirectory '{dataDirectory}' has no parent directory — use a subdirectory " + + "(the credential and server log live beside the data directory), not a drive root."); + } + + return parent; + } + + /* ================= Opt-in network exposure (darling-network-endpoints) ================= */ + + /// The store's effective bind: loopback-only (the default / a fail-closed degrade) or network-exposed. + private enum NetworkMode + { + Loopback, + Exposed, + } + + /// + /// The resolved network state for one start. = Exposed carries the validated bind + /// IP, canonical CIDR, pg_hba role, and generated cert/key paths; Loopback carries a non-null + /// only when it is a FAIL-CLOSED degrade from an INTENDED exposure (null + /// when the store is simply loopback-by-default). Resolved before start so listen/ssl ride the -o + /// override and the reconcile can run the disable path for a degrade. + /// + private sealed record NetworkPlan( + NetworkMode Mode, + string? ListenIp, + string? Cidr, + string? Role, + string? CertPath, + string? KeyPath, + string? DegradeReason) + { + public static NetworkPlan Loopback(string? degradeReason = null) + => new(NetworkMode.Loopback, null, null, null, null, null, degradeReason); + + public static NetworkPlan Exposed(string listenIp, string cidr, string role, string certPath, string keyPath) + => new(NetworkMode.Exposed, listenIp, cidr, role, certPath, keyPath, null); + } + + /// + /// The store's exposure decision after PURE validation (no I/O, no cert gen). =false + /// with a non-null is a FAIL-CLOSED degrade from an intended exposure; + /// Exposed=false with a null reason is loopback-by-default; Exposed=true carries the validated + /// ListenIp/Cidr/Role. + /// + internal sealed record NetworkExposureDecision( + bool Exposed, string? ListenIp, string? Cidr, string? Role, string? DegradeReason); + + /// + /// PURE validation of postgres.network into a — NO I/O and NO cert + /// generation (the caller does that on a valid decision), so it is unit-testable without a live server. + /// Degrades to loopback WITH a reason on: a listen value that is not a parseable IP; a missing/invalid + /// allowFrom CIDR or an address-family mismatch; a role outside {viewer, admin}; or a cert path + /// (/) that contains whitespace (the -o string + /// cannot nest-quote a space -> PG fail-DEAD). Never a fatal Validate() (D-validate). + /// + internal static NetworkExposureDecision ResolveNetworkExposure(PostgresNetworkConfig? network, string certPath, string keyPath) + { + if (network is null || !DarlingNetwork.IsExposedListenAddress(network.Listen)) + { + /* Loopback by default (no reason) — the byte-for-byte-today path. */ + return new NetworkExposureDecision(false, null, null, null, null); + } + + var listenRaw = network.Listen!.Trim(); + if (!IPAddress.TryParse(listenRaw, out var listenIp)) + { + return Degrade( + $"postgres.network.listen '{network.Listen}' is not a valid IP address (use a specific IP, e.g. 192.168.1.205, or 0.0.0.0 for all interfaces)"); + } + + if (string.IsNullOrWhiteSpace(network.AllowFrom) || !IPNetwork.TryParse(network.AllowFrom.Trim(), out var cidr)) + { + return Degrade( + $"postgres.network.allowFrom '{network.AllowFrom}' is not a valid CIDR (e.g. 192.168.1.0/24, with host bits zeroed)"); + } + + if (cidr.BaseAddress.AddressFamily != listenIp.AddressFamily) + { + return Degrade( + $"postgres.network.allowFrom '{network.AllowFrom}' address family does not match listen '{listenRaw}'"); + } + + var role = DarlingNetwork.NormalizeNetworkRole(network.Role); + if (role is null) + { + return Degrade( + $"postgres.network.role '{network.Role}' must be 'viewer' or 'admin' (never the superuser)"); + } + + /* The cert path rides the -o string, which cannot nest-quote a space -> a spaced path fail-DEADS + the postmaster. Gate on it (D6) and degrade rather than ever start ssl=on with a broken path. */ + if (ContainsWhitespace(certPath) || ContainsWhitespace(keyPath)) + { + return Degrade( + $"the TLS cert path '{certPath}' contains whitespace, which the pg_ctl -o override cannot pass to postgres; " + + "move postgres.dataDirectory to a space-free path to expose the store over TLS"); + } + + /* Canonical base/prefix form (IPNetwork requires zeroed host bits) for the pg_hba line + firewall. */ + return new NetworkExposureDecision(true, listenIp.ToString(), $"{cidr.BaseAddress}/{cidr.PrefixLength}", role, null); + + static NetworkExposureDecision Degrade(string reason) => new(false, null, null, null, reason); + } + + /// + /// Validates postgres.network (via ) and, on a valid exposure, + /// generates the TLS cert — returning the effective . NEVER throws: a cert-gen + /// failure degrades to loopback with a reason (D6/D-validate). + /// + private NetworkPlan BuildNetworkPlan() + { + var certPath = Path.Combine(ParentOf(_dataDirectory), ServerCertFileName); + var keyPath = Path.Combine(ParentOf(_dataDirectory), ServerKeyFileName); + + var decision = ResolveNetworkExposure(_config.Network, certPath, keyPath); + if (!decision.Exposed) + { + return NetworkPlan.Loopback(decision.DegradeReason); + } + + try + { + EnsureServerCertificate(IPAddress.Parse(decision.ListenIp!), certPath, keyPath); + } + catch (Exception ex) + { + /* Any cert-gen/write failure degrades to loopback — the network path must never throw out of + EnsureRunningAsync (its contract is throw => service-exit), Round 4 #3. */ + return NetworkPlan.Loopback($"could not generate/read the store TLS cert ({ex.Message})"); + } + + return NetworkPlan.Exposed(decision.ListenIp!, decision.Cidr!, decision.Role!, certPath, keyPath); + } + + private static bool ContainsWhitespace(string value) + { + foreach (var c in value) + { + if (char.IsWhiteSpace(c)) + { + return true; + } + } + + return false; + } + + /// + /// Idempotent self-signed server cert for store TLS verify-full (D6): reuse the existing PEM pair ONLY if + /// it loads AND its SAN still covers the current (delete both to rotate), + /// else generate a ~10-year cert with BOTH an iPAddress SAN (the listen IP, for verify-full by IP) AND a + /// dnsName SAN (the machine hostname, so the operator can connect by name if IP-SAN validation ever + /// disappoints, or when listen=0.0.0.0). Regenerating on an UNREADABLE cert avoids fail-DEADing an + /// ssl=on start (exposure-adjacent, Round 4 #3); regenerating on a SAN/IP change keeps verify-full + /// working after a bind-IP change. The private key is written unencrypted PKCS#8 PEM (what postgres reads) + /// and hardened NON-interactive (SYSTEM + Administrators + service account only) — the postmaster reads + /// it, never an interactive user. + /// + internal void EnsureServerCertificate(IPAddress listenIp, string certPath, string keyPath) + { + if (File.Exists(certPath) && File.Exists(keyPath)) + { + try + { + using var existing = X509Certificate2.CreateFromPem(File.ReadAllText(certPath)); + if (CertificateSanCoversIp(existing, listenIp)) + { + /* Present + loads + the SAN covers this listen IP -> reuse (delete-to-rotate). Re-harden + the key every start (self-healing), same discipline as the credential files. */ + TryHardenCredentialFile(keyPath, allowInteractiveRead: false); + return; + } + + _logger.LogInformation( + "Regenerating the store TLS cert: the existing cert's SAN does not cover the current listen IP {Ip} (a listen change needs a fresh cert for verify-full).", + listenIp); + } + catch (Exception ex) + { + _logger.LogWarning( + "Regenerating the store TLS cert: the existing {Cert} could not be read ({Message}) — an unreadable cert would fail-dead an ssl=on start.", + certPath, ex.Message); + } + + /* Fall through to regenerate — overwrites both files (the service account owns them). */ + } + + using var rsa = RSA.Create(2048); + var request = new CertificateRequest( + $"CN={Environment.MachineName}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + var sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddIpAddress(listenIp); + sanBuilder.AddDnsName(Environment.MachineName); + request.CertificateExtensions.Add(sanBuilder.Build()); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); + request.CertificateExtensions.Add( + new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, true)); + request.CertificateExtensions.Add( + new X509EnhancedKeyUsageExtension(new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") /* serverAuth */ }, false)); + + var notBefore = DateTimeOffset.UtcNow.AddDays(-1); + var notAfter = notBefore.AddYears(ServerCertValidityYears); + using var certificate = request.CreateSelfSigned(notBefore, notAfter); + + File.WriteAllText(certPath, certificate.ExportCertificatePem()); + File.WriteAllText(keyPath, rsa.ExportPkcs8PrivateKeyPem()); + TryHardenCredentialFile(keyPath, allowInteractiveRead: false); + + _logger.LogInformation( + "Generated a self-signed store TLS cert (CN/DNS SAN {Host}, IP SAN {Ip}, ~{Years}yr) at {Cert}", + Environment.MachineName, listenIp, ServerCertValidityYears, certPath); + } + + /// + /// Whether carries an iPAddress SAN equal to + /// — the reuse gate for the store TLS cert (verify-full pins the IP SAN). Reads the SAN extension + /// (OID 2.5.29.17) via . Pure. + /// + internal static bool CertificateSanCoversIp(X509Certificate2 certificate, IPAddress listenIp) + { + X509SubjectAlternativeNameExtension? san = null; + foreach (var extension in certificate.Extensions) + { + if (!string.Equals(extension.Oid?.Value, "2.5.29.17", StringComparison.Ordinal)) + { + continue; + } + + /* cert.Extensions may hand back a generic X509Extension for the SAN; re-materialize the typed + view from its raw DER when so, so EnumerateIPAddresses is always available. */ + san = extension as X509SubjectAlternativeNameExtension + ?? new X509SubjectAlternativeNameExtension(extension.RawData); + break; + } + + if (san is null) + { + return false; + } + + foreach (var ip in san.EnumerateIPAddresses()) + { + if (ip.Equals(listenIp)) + { + return true; + } + } + + return false; + } + + /// + /// Builds the pg_ctl -o runtime-override payload (the INNER string, no wrapping quotes): + /// always -p {port} -c listen_addresses=127.0.0.1 (loopback FIRST, forced every start — -c + /// outranks postgresql.auto.conf), the network IP appended when exposed, and the ssl trio + /// (ssl=on + cert/key) when a cert is present. NO single quotes and NO spaces inside any value + /// (Windows CRT arg parsing treats only double quotes as metacharacters, and postgres re-splits the + /// -o payload on interior spaces); cert paths are forward-slashed here and space-free-gated by the + /// caller. Pure + testable (D2/D6). + /// + internal static string BuildServerRuntimeOptions(int port, string? networkListenIp, string? sslCertFile, string? sslKeyFile) + { + var builder = new StringBuilder(); + builder.Append("-p ").Append(port); + builder.Append(" -c listen_addresses=").Append(BuildListenAddresses(networkListenIp)); + + if (!string.IsNullOrWhiteSpace(sslCertFile) && !string.IsNullOrWhiteSpace(sslKeyFile)) + { + builder.Append(" -c ssl=on"); + builder.Append(" -c ssl_cert_file=").Append(ToForwardSlashes(sslCertFile)); + builder.Append(" -c ssl_key_file=").Append(ToForwardSlashes(sslKeyFile)); + } + + return builder.ToString(); + } + + /// + /// The effective listen_addresses value for a given network bind — the SINGLE source of truth so + /// the -o override and the start-log can never drift. Null/empty ⇒ 127.0.0.1 (loopback + /// only). Exactly 0.0.0.0 ⇒ 0.0.0.0 ALONE: the IPv4 wildcard already covers 127.0.0.1 for + /// the service's own owner connection, and Windows PG cannot ALSO bind an explicit 127.0.0.1 on the same + /// port (overlapping IPv4 -> WSAEADDRINUSE). Everything else — a specific IPv4, a specific IPv6, OR the + /// IPv6 wildcard :: — keeps the 127.0.0.1, prefix so the hardcoded-IPv4 owner ALWAYS + /// connects (different families / non-overlapping IPv4 = no WSAEADDRINUSE; :: binds IPv6-only on + /// Windows and would otherwise strand the owner). Pure + testable. + /// + internal static string BuildListenAddresses(string? networkListenIp) + { + var listen = networkListenIp?.Trim(); + if (string.IsNullOrEmpty(listen)) + { + return "127.0.0.1"; + } + + if (string.Equals(listen, "0.0.0.0", StringComparison.Ordinal)) + { + return "0.0.0.0"; + } + + return $"127.0.0.1,{listen}"; + } + + private static string ToForwardSlashes(string path) => path.Replace('\\', '/'); + + /// + /// The Darling-managed pg_hba network rule: hostssl darling <role> <cidr> scram-sha-256 + /// — TLS-required (hostssl rejects a non-TLS network client), scoped to the darling database and exactly + /// the given login role and CIDR; never all, never the superuser (D5/D6). Pure + testable. + /// + internal static string BuildNetworkPgHbaLine(string role, string cidr) + => $"hostssl {DatabaseName} {role} {cidr} scram-sha-256"; + + /// + /// Whether the live pg_hba.conf needs reconciling (darling-network-endpoints): true when there is a rule + /// to apply ( non-null = exposing) OR the file still carries a Darling + /// managed block to remove ( present = a disable edge). FALSE for a store + /// that was never exposed and is not being exposed — so the reconcile skips reading-normalizing-rewriting + /// an untouched operator pg_hba ( normalizes CRLF and trims trailing blanks, + /// which would otherwise be a spurious write + reload + firewall spawn on a store never opted in). Pure. + /// + public static bool NeedsPgHbaReconcile(string current, string? desiredLine) + => desiredLine is not null + || (current is not null && current.Contains(PgHbaBeginMarker, StringComparison.Ordinal)); + + /// + /// Pure marked-block reconcile for pg_hba.conf (D5): returns with the + /// Darling-managed block (between and ) set + /// to a single when non-empty, or REMOVED when null/empty (disable). + /// Every non-marked line is preserved verbatim; a narrowing CIDR REPLACES the block (old removed, new + /// appended); idempotent (re-running with the same desired yields identical text). + /// + public static string ReconcilePgHba(string existing, string? desiredRuleLine) + { + existing ??= string.Empty; + var normalized = existing.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n'); + var lines = normalized.Split('\n'); + + var kept = new List(lines.Length); + var insideBlock = false; + foreach (var line in lines) + { + var trimmed = line.Trim(); + if (string.Equals(trimmed, PgHbaBeginMarker, StringComparison.Ordinal)) + { + insideBlock = true; + continue; + } + + if (string.Equals(trimmed, PgHbaEndMarker, StringComparison.Ordinal)) + { + insideBlock = false; + continue; + } + + if (!insideBlock) + { + kept.Add(line); + } + } + + /* Drop trailing blank lines so re-runs don't accumulate whitespace (idempotency). */ + while (kept.Count > 0 && string.IsNullOrWhiteSpace(kept[^1])) + { + kept.RemoveAt(kept.Count - 1); + } + + var result = new StringBuilder(); + foreach (var line in kept) + { + result.Append(line).Append('\n'); + } + + if (!string.IsNullOrWhiteSpace(desiredRuleLine)) + { + result.Append('\n'); + result.Append(PgHbaBeginMarker).Append('\n'); + result.Append(desiredRuleLine!.Trim()).Append('\n'); + result.Append(PgHbaEndMarker).Append('\n'); + } + + return result.ToString(); + } + + /// + /// Reconciles the LIVE server's network access to (D5), symmetric and + /// fail-closed. Rewrites the pg_hba marked block (add the hostssl rule when exposed, remove it when + /// loopback/degraded), reloads (exit-checked), verifies the intended rule really took via + /// pg_hba_file_rules (a reload returns 0 for mere SIGHUP delivery even if a malformed file is + /// rejected), guards an ADOPTED server whose live listen_addresses cannot be changed without a restart + /// the service will not perform, and best-effort (dis)installs the firewall rule. NEVER throws + /// (Round 4 #3): its whole body is caught so a reconcile failure logs + degrades, it does not abort the + /// bootstrap (whose contract is throw => service-exit). + /// + private async Task ReconcileNetworkAsync( + string binDirectory, NetworkPlan plan, string ownerConnectionString, CancellationToken cancellationToken) + { + try + { + var exposed = plan.Mode == NetworkMode.Exposed; + var desiredLine = exposed ? BuildNetworkPgHbaLine(plan.Role!, plan.Cidr!) : null; + + var hbaPath = Path.Combine(_dataDirectory, "pg_hba.conf"); + if (!File.Exists(hbaPath)) + { + if (exposed) + { + _logger.LogWarning("pg_hba.conf not found at {Path} — cannot apply the network access rule", hbaPath); + } + + return; + } + + var current = await File.ReadAllTextAsync(hbaPath, cancellationToken); + + /* Byte-for-byte today's behavior: a never-exposed store (no managed block) that is not being + exposed needs NO reconcile — do not even normalize/rewrite pg_hba (ReconcilePgHba would trim + CRLF + trailing blanks and trigger a spurious write + reload + verify + firewall spawn on a + store the operator never opted into). A disable EDGE (marker still present) or an exposure to + apply (desiredLine non-null) both return true and run the full reconcile below. */ + if (!NeedsPgHbaReconcile(current, desiredLine)) + { + return; + } + + var updated = ReconcilePgHba(current, desiredLine); + var changed = !string.Equals(current, updated, StringComparison.Ordinal); + if (changed) + { + await File.WriteAllTextAsync(hbaPath, updated, cancellationToken); + var (reloadCode, reloadOutput) = await RunToolAsync( + Path.Combine(binDirectory, "pg_ctl.exe"), + $"reload -D \"{_dataDirectory}\"", + s_statusTimeout, + cancellationToken); + if (reloadCode != 0) + { + _logger.LogCritical( + "pg_ctl reload failed (exit {ExitCode}) after updating pg_hba.conf — the network access change may not be live: {Output}", + reloadCode, reloadOutput); + } + } + + /* Verify the live rules match intent (reload can report success on a rejected malformed file). */ + await VerifyPgHbaAsync(ownerConnectionString, plan, changed, cancellationToken); + + /* Adopted server: we did not start it, so the -o listen_addresses/ssl override never applied. + If its live listener cannot satisfy the desired exposure, say so loud and do not claim exposed. */ + if (!_startedByThisProcess) + { + await GuardAdoptedListenAsync(ownerConnectionString, plan, cancellationToken); + } + + /* Defense-in-depth firewall rule (the boundary is pg_hba + TLS). Best-effort, never fatal. Enable + when exposed; on a disable EDGE (we just removed the block) also remove the rule. A store that + was never exposed touches the firewall not at all (the early return above). */ + if (exposed) + { + await TryConfigureStoreFirewallAsync(enable: true, plan.Cidr!, cancellationToken); + } + else + { + await TryConfigureStoreFirewallAsync(enable: false, cidr: null, cancellationToken); + } + } + catch (OperationCanceledException) + { + /* Shutdown mid-reconcile — the store still runs loopback-safe; nothing to clean up. */ + } + catch (Exception ex) + { + /* Round 4 #3: never let a network reconcile failure abort the bootstrap. */ + _logger.LogCritical( + "Store network reconcile failed ({Message}) — the store keeps running; verify pg_hba.conf / listen_addresses by hand.", + ex.Message); + } + } + + /// + /// Confirms the live pg_hba rules match intent via pg_hba_file_rules: no rule has a parse error, + /// and the Darling hostssl rule for the network role is PRESENT when exposed / ABSENT when + /// loopback. A mismatch is logged critical (a reload delivers a SIGHUP that Postgres may then reject). + /// Best-effort — a query failure degrades to a warning, not a throw. + /// + private async Task VerifyPgHbaAsync(string ownerConnectionString, NetworkPlan plan, bool reloaded, CancellationToken cancellationToken) + { + var exposed = plan.Mode == NetworkMode.Exposed; + try + { + await using var connection = new NpgsqlConnection(ownerConnectionString); + await connection.OpenAsync(cancellationToken); + + await using (var errors = new NpgsqlCommand( + "SELECT count(*) FROM pg_hba_file_rules WHERE error IS NOT NULL", connection)) + { + var errorCount = Convert.ToInt64(await errors.ExecuteScalarAsync(cancellationToken) ?? 0L); + if (errorCount > 0) + { + _logger.LogCritical( + "pg_hba.conf has {Count} rule(s) Postgres could not parse (pg_hba_file_rules.error) — the live network access may not match intent; check {Path}", + errorCount, Path.Combine(_dataDirectory, "pg_hba.conf")); + return; + } + } + + long present; + if (exposed) + { + await using var command = new NpgsqlCommand( + "SELECT count(*) FROM pg_hba_file_rules WHERE type = 'hostssl' AND $1 = ANY(database) AND $2 = ANY(user_name)", + connection); + command.Parameters.AddWithValue(DatabaseName); + command.Parameters.AddWithValue(plan.Role!); + present = Convert.ToInt64(await command.ExecuteScalarAsync(cancellationToken) ?? 0L); + + if (present == 0) + { + _logger.LogCritical( + "pg_hba verification: the expected 'hostssl {Db} {Role} {Cidr} scram-sha-256' rule is NOT live after reload — the store is not accepting network clients as intended", + DatabaseName, plan.Role, plan.Cidr); + return; + } + + _logger.LogInformation( + "Store network access reconciled: exposed to {Cidr} as '{Role}' over TLS (pg_hba {Verb}).", + plan.Cidr, plan.Role, reloaded ? "reloaded + verified" : "already current"); + } + else + { + /* Disable: no Darling-managed hostssl rule (darling database, viewer/admin) should remain. */ + await using var command = new NpgsqlCommand( + "SELECT count(*) FROM pg_hba_file_rules WHERE type = 'hostssl' AND $1 = ANY(database) AND (user_name && ARRAY['viewer','admin'])", + connection); + command.Parameters.AddWithValue(DatabaseName); + present = Convert.ToInt64(await command.ExecuteScalarAsync(cancellationToken) ?? 0L); + + if (present > 0) + { + _logger.LogCritical( + "pg_hba verification: a Darling hostssl rule is STILL live after the disable reconcile — network auth may not be closed; check {Path}", + Path.Combine(_dataDirectory, "pg_hba.conf")); + return; + } + + _logger.LogInformation( + "Store network access reconciled: loopback-only (pg_hba {Verb}).", + reloaded ? "reloaded + verified" : "already current"); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning("Could not verify pg_hba via pg_hba_file_rules ({Message}) — assuming the file write took", ex.Message); + } + } + + /// + /// For an ADOPTED (not-started-by-us) server the -o listen_addresses override never applied, so its live + /// binding is whatever its operator started it with. Compares SHOW listen_addresses to intent: an + /// exposed plan needs the network IP live (else LogCritical — the pg_hba rule is in place but the port is + /// not bound; the operator must restart their postmaster); a disable on a still-wide adopted listener is + /// a warning (auth closed, but the port stays bound until the operator restarts — the documented residual). + /// + private async Task GuardAdoptedListenAsync(string ownerConnectionString, NetworkPlan plan, CancellationToken cancellationToken) + { + try + { + await using var connection = new NpgsqlConnection(ownerConnectionString); + await connection.OpenAsync(cancellationToken); + await using var command = new NpgsqlCommand("SHOW listen_addresses", connection); + var liveListen = await command.ExecuteScalarAsync(cancellationToken) as string ?? string.Empty; + + if (plan.Mode == NetworkMode.Exposed) + { + if (!LiveListenIncludes(liveListen, plan.ListenIp!)) + { + _logger.LogCritical( + "Adopted Postgres listen_addresses is '{Live}', which does not include the requested {Ip}. This service will not restart a server it did not start, so the network listener is NOT active — the pg_hba rule is in place but the port is not bound. Restart your postmaster to apply, or let the service own the cluster.", + liveListen, plan.ListenIp); + } + } + else if (LiveListenHasNonLoopback(liveListen)) + { + _logger.LogWarning( + "Adopted Postgres listen_addresses is '{Live}' (wider than loopback). The network AUTH path is closed (pg_hba rule removed), but the TCP port stays bound until you restart your postmaster.", + liveListen); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning("Could not read the adopted server's listen_addresses ({Message})", ex.Message); + } + } + + private static bool LiveListenIncludes(string liveListenAddresses, string ip) + { + foreach (var token in liveListenAddresses.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (string.Equals(token, ip, StringComparison.Ordinal) + || string.Equals(token, "*", StringComparison.Ordinal) + || string.Equals(token, "0.0.0.0", StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + private static bool LiveListenHasNonLoopback(string liveListenAddresses) + { + foreach (var token in liveListenAddresses.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var isLoopback = + string.Equals(token, "localhost", StringComparison.OrdinalIgnoreCase) + || (IPAddress.TryParse(token, out var ip) + && ip.AddressFamily == AddressFamily.InterNetwork + && ip.GetAddressBytes()[0] == 127); + if (!isLoopback) + { + return true; + } + } + + return false; + } + + /// The scoped store firewall rule name (idempotent by DisplayName), port-specific. + private string StoreFirewallRuleName => $"PerformanceMonitor Darling store (port {_config.Port})"; + + /// Idempotent-named enable command (remove-by-name then add) — the exact scoped command the docs + /// lead with (D1). Pure + testable. + internal static string BuildFirewallEnableCommand(string ruleName, int port, string remoteCidr) + => $"Remove-NetFirewallRule -DisplayName '{ruleName}' -ErrorAction SilentlyContinue; " + + $"New-NetFirewallRule -DisplayName '{ruleName}' -Direction Inbound -Action Allow -Protocol TCP -LocalPort {port} -RemoteAddress {remoteCidr} | Out-Null"; + + /// + /// Idempotent-named disable command (remove-by-name). Pure + testable. + /// Deliberately NOT `-ErrorAction SilentlyContinue`: that suppresses the error OUTPUT but the cmdlet + /// failure still makes powershell.exe exit 1, so removing an already-absent rule — the COMMON no-op + /// (the rule was never created, or a prior shutdown already removed it) — logged + /// "Could not remove the firewall rule automatically (exit 1: )" with an EMPTY message on every + /// shutdown, on every host. "Rule absent" IS this command's desired end state, so ObjectNotFound is + /// swallowed and we exit 0; any OTHER failure (e.g. access denied, which leaves a stale allow rule + /// behind and is worth knowing about) rethrows so the caller still warns — with a real message. + /// The catch alone is not enough: a caught error leaves the exit state non-zero, hence the exit 0. + /// + internal static string BuildFirewallDisableCommand(string ruleName) + => $"try {{ Remove-NetFirewallRule -DisplayName '{ruleName}' -ErrorAction Stop }} " + + $"catch {{ if ($_.CategoryInfo.Category -ne 'ObjectNotFound') {{ throw }} }}; exit 0"; + + /// + /// Best-effort firewall reconcile (D1): add/remove the scoped, idempotent-named inbound rule via + /// PowerShell. The firewall is defense-in-depth, NOT the boundary (pg_hba + TLS are), so a failure (no + /// elevation, PowerShell missing) logs the exact scoped command for the operator to run by hand and + /// NEVER fails startup. + /// + private async Task TryConfigureStoreFirewallAsync(bool enable, string? cidr, CancellationToken cancellationToken) + { + var ruleName = StoreFirewallRuleName; + var command = enable + ? BuildFirewallEnableCommand(ruleName, _config.Port, cidr!) + : BuildFirewallDisableCommand(ruleName); + + try + { + var (exitCode, output) = await RunPowerShellAsync(command, cancellationToken); + if (exitCode == 0) + { + _logger.LogInformation("Firewall rule '{Rule}' {Verb}.", ruleName, enable ? "ensured" : "removed"); + } + else if (enable) + { + _logger.LogWarning( + "Could not configure the firewall automatically (exit {ExitCode}: {Output}). Run this in an elevated PowerShell:\n{Command}", + exitCode, output, command); + } + else + { + _logger.LogWarning("Could not remove the firewall rule automatically (exit {ExitCode}: {Output}).", exitCode, output); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + if (enable) + { + _logger.LogWarning( + "Could not configure the firewall automatically ({Message}). Run this in an elevated PowerShell:\n{Command}", + ex.Message, command); + } + else + { + _logger.LogWarning("Could not remove the firewall rule automatically ({Message}).", ex.Message); + } + } + } + + /// + /// Runs a PowerShell command with captured, interleaved stdout+stderr and a timeout. internal + /// so the (separate) MCP host can reuse it for its own best-effort firewall reconcile + /// (darling-network-endpoints) instead of duplicating it — the firewall command shape is shared via the + /// pure / builders. + /// is optional and defaults to the shared status timeout + /// (); the --configure-network wizard passes a longer one for a + /// service restart, which routinely exceeds the status budget. Existing callers are unaffected. + /// + internal static async Task<(int ExitCode, string Output)> RunPowerShellAsync( + string command, CancellationToken cancellationToken, TimeSpan? timeout = null) + { + /* Full path (not the bare name) — avoid a PATH/CWD hijack of "powershell.exe", matching the house + style of full-pathing every PG tool. */ + var powershellPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe"); + + using var process = new Process(); + process.StartInfo = new ProcessStartInfo + { + FileName = powershellPath, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + process.StartInfo.ArgumentList.Add("-NoProfile"); + process.StartInfo.ArgumentList.Add("-NonInteractive"); + process.StartInfo.ArgumentList.Add("-Command"); + process.StartInfo.ArgumentList.Add(command); + + var output = new StringBuilder(); + var outputLock = new object(); + process.OutputDataReceived += (_, e) => { if (e.Data is not null) { lock (outputLock) { output.AppendLine(e.Data); } } }; + process.ErrorDataReceived += (_, e) => { if (e.Data is not null) { lock (outputLock) { output.AppendLine(e.Data); } } }; + + if (!process.Start()) + { + return (-1, "could not start powershell.exe"); + } + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource.CancelAfter(timeout ?? s_statusTimeout); + try + { + await process.WaitForExitAsync(timeoutSource.Token); + } + catch (OperationCanceledException) + { + try + { + process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { + /* Exited between the timeout and the kill. */ + } + + cancellationToken.ThrowIfCancellationRequested(); + string soFar; + lock (outputLock) { soFar = output.ToString().Trim(); } + return (-1, $"timed out: {soFar}"); + } + + lock (outputLock) + { + return (process.ExitCode, output.ToString().Trim()); + } + } + + /// + /// Runs one PG tool with captured, interleaved stdout+stderr and a hard timeout. The + /// service's stopping token cancels a bootstrap mid-flight; the shutdown stop path passes + /// CancellationToken.None because its token is by definition already cancelled. + /// + private static async Task<(int ExitCode, string Output)> RunToolAsync( + string exePath, string arguments, TimeSpan timeout, CancellationToken cancellationToken) + { + if (!File.Exists(exePath)) + { + throw new InvalidOperationException( + $"{exePath} is missing — the pg-runtime directory is incomplete. Rebuild pg-runtime.zip with " + + "Darling\\tools\\fetch-pg-runtime.ps1 and redeploy (deleting the pg-runtime directory makes the " + + "service re-extract the zip on its next start)."); + } + + using var process = new Process(); + process.StartInfo = new ProcessStartInfo + { + FileName = exePath, + Arguments = arguments, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + var output = new StringBuilder(); + var outputLock = new object(); + process.OutputDataReceived += (_, e) => + { + if (e.Data is not null) + { + lock (outputLock) { output.AppendLine(e.Data); } + } + }; + process.ErrorDataReceived += (_, e) => + { + if (e.Data is not null) + { + lock (outputLock) { output.AppendLine(e.Data); } + } + }; + + if (!process.Start()) + { + throw new InvalidOperationException($"Could not start {exePath}."); + } + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource.CancelAfter(timeout); + try + { + await process.WaitForExitAsync(timeoutSource.Token); + } + catch (OperationCanceledException) + { + try + { + process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { + /* Exited between the timeout and the kill. */ + } + + cancellationToken.ThrowIfCancellationRequested(); + + string capturedSoFar; + lock (outputLock) { capturedSoFar = output.ToString().Trim(); } + throw new TimeoutException( + $"{Path.GetFileName(exePath)} {arguments} did not finish within {timeout.TotalSeconds:0}s. Output so far:\n{capturedSoFar}"); + } + + lock (outputLock) + { + return (process.ExitCode, output.ToString().Trim()); + } + } + + /// + /// Runs the ONE tool whose spawned server outlives it — pg_ctl start — with NO output + /// redirection, waiting on process exit alone. Redirecting here is a guaranteed hang on + /// SUCCESS: pg_ctl launches postgres.exe with handle inheritance, the postmaster keeps the + /// pipe write-ends open for its whole lifetime, so the pipes never reach EOF — and + /// waits for redirected output to + /// drain (dotnet/runtime#42556), so a healthy start times out after pg_ctl itself has long + /// exited. Caught live by the gated bootstrap E2E. The lost pg_ctl console text is the + /// throwaway "waiting for server to start..." narration; the real failure story is the -l + /// server log, which the caller surfaces via . initdb and + /// pg_ctl stop/status stay on — nothing they spawn survives them, + /// so their pipes close and their captured output is worth having. + /// + private static async Task RunDetachingToolAsync( + string exePath, string arguments, TimeSpan timeout, CancellationToken cancellationToken) + { + if (!File.Exists(exePath)) + { + throw new InvalidOperationException( + $"{exePath} is missing — the pg-runtime directory is incomplete. Rebuild pg-runtime.zip with " + + "Darling\\tools\\fetch-pg-runtime.ps1 and redeploy (deleting the pg-runtime directory makes the " + + "service re-extract the zip on its next start)."); + } + + using var process = new Process(); + process.StartInfo = new ProcessStartInfo + { + FileName = exePath, + Arguments = arguments, + UseShellExecute = false, + CreateNoWindow = true, + }; + + if (!process.Start()) + { + throw new InvalidOperationException($"Could not start {exePath}."); + } + + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource.CancelAfter(timeout); + try + { + await process.WaitForExitAsync(timeoutSource.Token); + } + catch (OperationCanceledException) + { + /* A start stuck past pg_ctl's own -w -t budget is a real failure; the tree kill + reaps the half-started postmaster while it is still pg_ctl's child. */ + try + { + process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { + /* Exited between the timeout and the kill. */ + } + + cancellationToken.ThrowIfCancellationRequested(); + throw new TimeoutException( + $"{Path.GetFileName(exePath)} {arguments} did not finish within {timeout.TotalSeconds:0}s."); + } + + return process.ExitCode; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs new file mode 100644 index 000000000..9e6259bff --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs @@ -0,0 +1,501 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.Versioning; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Npgsql; +using PerformanceMonitor.Darling.Storage; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// Least-privilege role provisioning for the managed store (V8 security hardening, #1262) — the +/// conf-append discipline of applied to roles and credentials. +/// The service connects as the bootstrap superuser darling (it does DDL: migrations, +/// hypertable conversion, retention), and it provisions three least-privilege LOGIN roles that +/// connect instead of the superuser: +/// +/// admin — SELECT on both schemas + INSERT/UPDATE/DELETE on config only. +/// The Viewer's default identity: it owns the alert-dismiss, mute-rule, and analysis-mute writes but +/// can never DROP, alter schema, touch collect data, or create objects. +/// viewer — SELECT on both schemas, and (the single write exception, #1563) +/// INSERT/UPDATE/DELETE on ONLY config.custom_views (the web dashboard's user-authored view +/// definitions — non-secret JSON; editing is any AUTHENTICATED seat, the web surface's normal networked +/// mode gated server-side by the host's token+CIDR auth — NOT loopback-only). No other writes anywhere. A +/// locked-down deployment points the Viewer at this ("look but don't touch" — plus its own saved custom +/// views). +/// mcp — the (optionally network-exposed) MCP host's store identity +/// (darling-network-endpoints, D3-role): the SAME read surface as viewer (SELECT on +/// collect + config-minus-the-secret-columns) PLUS a NARROW, enumerated set of writes — +/// INSERT on collect.analysis_findings and config.analysis_muted (what analyze_server +/// persists + the mute tool need), INSERT/UPDATE/DELETE on config.custom_views (the +/// custom-view tools, #1599), the alert-tuning writes (INSERT/UPDATE/DELETE on +/// config.config_mute_rules + UPDATE on the singleton config.config_alert_settings, plus the +/// two beacon columns of config.config_service so the settings write's self-bump trigger can fire), +/// and the server-onboarding writes (INSERT/UPDATE/DELETE on config.config_monitored_servers for the +/// add_servers/remove_server tools — a single non-secret-KEY table; the credential column stays +/// SELECT-carved, so mcp can WRITE a password blob but never READ one back). +/// Deliberately NOT admin: a token-holder reachable over the network must never get the +/// config_command service-credential pivot or the secret columns. Every write grant is an EXPLICIT +/// single-table (or single-column) statement with NO ALTER DEFAULT PRIVILEGES (ADP has no per-table +/// form -> it would broaden mcp to all of a schema); a dropped/recreated table re-grants because +/// provisioning re-runs every start. +/// +/// +/// On every managed startup (after migration, before TimescaleDB conversion), for each role: +/// read its DPAPI-LocalMachine credential file beside the data directory, or GENERATE one if missing +/// (self-heal — a superuser can always ALTER ROLE … PASSWORD, so a deleted file just +/// regenerates, a nicer property than the owner's unrecoverable password). Then run the idempotent +/// provisioning DDL with the passwords injected: DO-guarded CREATE ROLE, an +/// ALTER ROLE … PASSWORD re-assert so role and file never drift, and the +/// GRANT/ALTER DEFAULT PRIVILEGES that make new collector tables auto-inherit SELECT. +/// Every statement is idempotent, so re-running each start converges — no version stamp, existence +/// checks drive it exactly as uses its conf +/// marker. +/// +/// Windows-only (the DPAPI credential files), like every DPAPI surface here. Managed mode provisions +/// all three roles (admin/viewer/mcp); bring-your-own Postgres provisions admin + viewer out-of-band via +/// Darling/tools/provision-roles.sql and correctly has NO mcp role — the network MCP endpoint +/// is managed-mode-only, so a BYO operator's own PostgreSQL governs any MCP-role exposure it wants. +/// +[SupportedOSPlatform("windows")] +public static class DarlingManagedRoles +{ + /// + /// The comment stamped on every Darling-created login role (COMMENT ON ROLE … IS, read back + /// via shobj_description(oid, 'pg_authid')). Because the role names are the bare, un-prefixed + /// admin/viewer (Erik's decision — no darling_ namespace), provisioning must not + /// silently repurpose a same-named role someone else created: an existing role WITHOUT this marker + /// makes provisioning fail loud rather than reset its password/privileges. + /// + public const string RoleMarker = "darling-managed"; + + /// + /// The config tables that carry SECRET columns the read-only viewer role must never read, + /// each paired with the EXACT non-secret columns it may (#1262 Medium credential-column follow-up). The + /// admin role — which writes and therefore owns these secrets, and which the Settings window + /// connects as — keeps its full table-wide SELECT; only viewer is column-restricted. + /// + /// config_monitored_servers.encrypted_password — a DPAPI-LocalMachine password blob. + /// config_command.args_json — carries the inline test_connect credential blob (also nulled + /// on terminal state; see ). + /// config_notification — the SMTP password blob + username, and the Teams/Slack webhook + /// URLs (a webhook URL is a bearer secret). + /// + /// + /// Fail-CLOSED by construction: grants viewer + /// column-level SELECT on ONLY the enumerated , so a + /// column added to one of these tables in a future migration is INVISIBLE to viewer until it is + /// deliberately added here — the opposite of GRANT-ALL-then-REVOKE-each-secret, which would silently + /// expose a new secret column. The live DarlingSecuritySplitLiveTests asserts the union of the + /// two column sets equals the table's actual columns, so a migration that adds a column without updating + /// this list FAILS the build's live gate. A NEW secret-bearing config table added later must also + /// be added here (its ALTER DEFAULT PRIVILEGES table-grant to viewer would otherwise + /// expose every column). + /// + public static readonly IReadOnlyList ViewerRestrictedConfigTables = new[] + { + new ViewerSecretTableAcl( + "config_monitored_servers", + NonSecretColumns: new[] + { + "server_id", "name", "host", "database", "auth", "username", "encrypt_mode", + "trust_server_certificate", "read_only_intent", "multi_subnet_failover", + "excluded_databases", "monthly_cost_usd", "capture_plans", "is_enabled", + "created_at", "modified_at", "alert_delivery_mode_override", + }, + SecretColumns: new[] { "encrypted_password" }), + + new ViewerSecretTableAcl( + "config_command", + NonSecretColumns: new[] + { + "command_id", "created_at", "requested_by", "command_type", "target_server_id", + "status", "claimed_at", "completed_at", "result_status", "result_json", "service_instance", + }, + SecretColumns: new[] { "args_json" }), + + new ViewerSecretTableAcl( + "config_notification", + NonSecretColumns: new[] + { + "id", "smtp_host", "smtp_port", "smtp_use_ssl", "smtp_from_address", "smtp_recipients", + "email_cooldown_minutes", "teams_proxy", "slack_proxy", "modified_at", + "generic_body_template", "generic_proxy", + }, + /* generic_headers carries the Authorization bearer token itself, and generic_url is a bearer + secret like the sibling webhook URLs (#1506 / V26). */ + SecretColumns: new[] + { + "smtp_encrypted_password", "smtp_username", "teams_url", "slack_url", + "generic_url", "generic_headers", + }), + }; + + /// + /// The fail-closed viewer column-ACL block for one config schema + viewer role: for each + /// entry, REVOKE the table-wide SELECT (undoing the blanket + /// GRANT SELECT ON ALL TABLES and the V17 ALTER DEFAULT PRIVILEGES grant that already + /// landed on it) and re-GRANT SELECT on ONLY the non-secret columns. Shared verbatim by + /// and the live security test so the two can never drift. The table + /// and column names are compile-time constants from this file (never user input), so interpolation is + /// safe — the same reasoning the password-injection guard relies on. + /// + public static string BuildViewerColumnAclSql(string configSchema, string viewerRole) => + string.Join("\n", ViewerRestrictedConfigTables.Select(acl => + $"REVOKE SELECT ON {configSchema}.{acl.Table} FROM {viewerRole};\n" + + $"GRANT SELECT ({string.Join(", ", acl.NonSecretColumns)}) ON {configSchema}.{acl.Table} TO {viewerRole};")); + + /// + /// Ensures the admin/viewer/mcp roles, their DPAPI credentials, and the + /// collect/config grants exist and match — idempotent and self-healing. Opens one connection from the + /// owner-darling data source (ALTER DEFAULT PRIVILEGES FOR ROLE darling only governs objects + /// darling creates, which is all of them). MUST run AFTER migration: the mcp role's per-table + /// INSERT grants name collect.analysis_findings / config.analysis_muted by qualified + /// name, which the one-shot batch requires to already exist (they do — the worker migrates before it + /// calls this). Throws on a hard failure; the caller degrades (the Viewer/MCP cannot connect as their + /// roles until a later start succeeds) but keeps collecting. + /// + public static async Task EnsureProvisionedAsync( + NpgsqlDataSource dataSource, string dataDirectory, ILogger logger, CancellationToken cancellationToken = default) + { + if (dataSource is null) + { + throw new ArgumentNullException(nameof(dataSource)); + } + + if (string.IsNullOrWhiteSpace(dataDirectory)) + { + throw new ArgumentException("Data directory is required.", nameof(dataDirectory)); + } + + /* admin/viewer credentials are read by the interactive Viewer -> INTERACTIVE-readable ACL. */ + var adminPassword = EnsureRoleCredential( + DarlingManagedPostgres.AdminCredentialPathFor(dataDirectory), DarlingManagedPostgres.AdminRoleName, + allowInteractiveRead: true, logger); + var viewerPassword = EnsureRoleCredential( + DarlingManagedPostgres.ViewerCredentialPathFor(dataDirectory), DarlingManagedPostgres.ViewerRoleName, + allowInteractiveRead: true, logger); + /* The mcp credential is consumed only by the in-service MCP host, never an interactive Viewer, so it + is hardened NON-interactive (mirrors the superuser posture, not admin/viewer's) — Round 4 #4. */ + var mcpPassword = EnsureRoleCredential( + DarlingManagedPostgres.McpCredentialPathFor(dataDirectory), DarlingManagedPostgres.McpRoleName, + allowInteractiveRead: false, logger); + + await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); + await using var command = new NpgsqlCommand(BuildProvisioningSql(adminPassword, viewerPassword, mcpPassword), connection); + await command.ExecuteNonQueryAsync(cancellationToken); + + logger.LogInformation( + "Least-privilege roles ready (admin: read both schemas + write config; viewer: read-only + write config.custom_views; mcp: viewer's reads + INSERT on analysis_findings/analysis_muted + write config.custom_views + tune alerting (config_mute_rules, config_alert_settings, config_service reload beacon) + onboard servers (config_monitored_servers)) — the Viewer and MCP host no longer connect as the superuser"); + } + + /// + /// Reads a TRUSTED existing role credential, or generates + DPAPI-persists a fresh one (self-heal), + /// then restricts its ACL. Same 32-char alnum + /// and DPAPI-LocalMachine posture as the owner credential; unlike the owner's, a role password can + /// always be re-asserted (ALTER ROLE … PASSWORD), so an untrusted-owned (possibly pre-planted) + /// file is discarded and regenerated rather than trusted. + /// + private static string EnsureRoleCredential(string credentialPath, string roleName, bool allowInteractiveRead, ILogger logger) + { + string password; + if (File.Exists(credentialPath) && DarlingFileSecurity.IsTrustedOwner(credentialPath)) + { + password = DarlingSecrets.Unprotect(File.ReadAllText(credentialPath).Trim()); + } + else + { + if (File.Exists(credentialPath)) + { + /* Pre-plant defense: a role credential owned by an arbitrary local user would feed the + caller's ALTER ROLE … PASSWORD re-assert a password the attacker chose. Discard it. */ + logger.LogWarning( + "The managed '{Role}' credential {File} is not owned by a trusted principal — discarding and regenerating it (possible pre-plant).", + roleName, Path.GetFileName(credentialPath)); + TryDelete(credentialPath, logger); + } + + password = DarlingManagedPostgres.GeneratePassword(); + File.WriteAllText(credentialPath, DarlingSecrets.Protect(password)); + logger.LogInformation( + "Generated the managed '{Role}' role credential ({File})", roleName, Path.GetFileName(credentialPath)); + } + + /* Re-harden every start (self-healing): admin/viewer are additionally readable by the interactive + operator (whose Viewer reads them); mcp is NOT (SYSTEM + Administrators + service account only — + the in-service MCP host reads it, never an interactive user). */ + TryHardenRoleCredential(credentialPath, allowInteractiveRead, logger); + return password; + } + + /// Best-effort restrictive ACL on a role credential; a failure is logged loud, not fatal. + private static void TryHardenRoleCredential(string path, bool allowInteractiveRead, ILogger logger) + { + try + { + DarlingFileSecurity.HardenFile(path, allowInteractiveRead); + } + catch (Exception ex) + { + logger.LogError( + "Could not restrict the ACL on {Path} ({Message}) — the role credential may be readable by other local users; fix the file permissions by hand.", + path, ex.Message); + } + } + + private static void TryDelete(string path, ILogger logger) + { + try + { + File.Delete(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + logger.LogError( + "Could not delete the untrusted credential file {Path} ({Message}) — remove it by hand so a fresh one can be generated.", + path, ex.Message); + } + } + + /// + /// The idempotent, self-healing provisioning DDL with the role passwords injected. Passwords are + /// alnum-only (), verified here before the + /// interpolation, so string-building the PASSWORD '…' literals is escaping-safe — the same + /// reasoning relies on for --pwfile. Public + shape-pinnable + /// so a test can assert it without a live Postgres. The mcp-role INSERT grants reference + /// collect.analysis_findings / config.analysis_muted by qualified name, so the one-shot + /// batch requires those tables to already exist — safe because provisioning runs AFTER migration (see + /// ); a dropped/recreated table re-grants on the next start. + /// + public static string BuildProvisioningSql(string adminPassword, string viewerPassword, string mcpPassword) + { + RequireAlphanumeric(adminPassword, nameof(adminPassword)); + RequireAlphanumeric(viewerPassword, nameof(viewerPassword)); + RequireAlphanumeric(mcpPassword, nameof(mcpPassword)); + + const string owner = DarlingManagedPostgres.UserName; // darling (owner/superuser) + const string database = DarlingManagedPostgres.DatabaseName; // darling + const string admin = DarlingManagedPostgres.AdminRoleName; + const string viewer = DarlingManagedPostgres.ViewerRoleName; + const string mcp = DarlingManagedPostgres.McpRoleName; + const string collect = PgSchemaGenerator.CollectSchema; + const string config = PgSchemaGenerator.ConfigSchema; + const string marker = RoleMarker; + const string statementTimeout = ComposeLimits.StatementTimeout; + + /* The fail-closed viewer column-ACL carve for the secret-bearing config tables (see + ViewerRestrictedConfigTables). Runs AFTER the blanket config GRANT below, so it strips + viewer's table-wide SELECT on those tables and re-grants only the non-secret columns. */ + var viewerColumnAcl = BuildViewerColumnAclSql(config, viewer); + + /* The SAME fail-closed carve for the mcp role — it gets viewer's read surface, so it must be + denied the identical secret columns. Reusing BuildViewerColumnAclSql(config, mcp) means the + mcp carve can never drift from viewer's (Round 4 #5 guards this with a live denial test). */ + var mcpColumnAcl = BuildViewerColumnAclSql(config, mcp); + + return $@" +/* Least-privilege roles for the Darling security split (#1262). Idempotent + self-healing: + re-run every managed start, converging role state to the DPAPI credential files. */ + +-- 1. Roles (CREATE ROLE has no IF NOT EXISTS -> guard with a DO block). The names are bare +-- admin/viewer, so a fresh role is STAMPED with a marker comment and an existing SAME-NAMED role +-- is trusted only if it carries that marker; an unmarked collision fails loud (never repurposed). +DO $do$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{admin}') THEN + CREATE ROLE {admin} LOGIN NOSUPERUSER PASSWORD '{adminPassword}'; + COMMENT ON ROLE {admin} IS '{marker}'; + ELSIF shobj_description((SELECT oid FROM pg_roles WHERE rolname = '{admin}'), 'pg_authid') IS DISTINCT FROM '{marker}' THEN + RAISE EXCEPTION 'Role ""{admin}"" already exists and was not created by Darling (missing the ''{marker}'' marker comment). Rename or drop it before provisioning so Darling does not repurpose an unrelated login.'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{viewer}') THEN + CREATE ROLE {viewer} LOGIN NOSUPERUSER PASSWORD '{viewerPassword}'; + COMMENT ON ROLE {viewer} IS '{marker}'; + ELSIF shobj_description((SELECT oid FROM pg_roles WHERE rolname = '{viewer}'), 'pg_authid') IS DISTINCT FROM '{marker}' THEN + RAISE EXCEPTION 'Role ""{viewer}"" already exists and was not created by Darling (missing the ''{marker}'' marker comment). Rename or drop it before provisioning so Darling does not repurpose an unrelated login.'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{mcp}') THEN + CREATE ROLE {mcp} LOGIN NOSUPERUSER PASSWORD '{mcpPassword}'; + COMMENT ON ROLE {mcp} IS '{marker}'; + ELSIF shobj_description((SELECT oid FROM pg_roles WHERE rolname = '{mcp}'), 'pg_authid') IS DISTINCT FROM '{marker}' THEN + RAISE EXCEPTION 'Role ""{mcp}"" already exists and was not created by Darling (missing the ''{marker}'' marker comment). Rename or drop it before provisioning so Darling does not repurpose an unrelated login.'; + END IF; +END $do$; + +-- 1b. Re-assert password + attributes every start (the credential file is the source of truth). +-- Only reached when the guard above passed (fresh + marked, or already Darling-marked). +ALTER ROLE {admin} LOGIN NOSUPERUSER PASSWORD '{adminPassword}'; +ALTER ROLE {viewer} LOGIN NOSUPERUSER PASSWORD '{viewerPassword}'; +ALTER ROLE {mcp} LOGIN NOSUPERUSER PASSWORD '{mcpPassword}'; + +-- 1c. statement_timeout backstop on the composed-query identities (Custom Views v2, #1563). viewer is the web +-- dashboard's DB identity and mcp the optional network MCP identity; both serve the network-reachable +-- compose surface over a raw, no-rollup store, so a runaway aggregation can NEVER pin the store beyond this +-- (a LIMIT bounds output, not work). A role SET applies to every future session and re-asserts each start. +-- admin (the Settings writer, small config writes) is deliberately NOT bounded. NOT a versioned migration: +-- a role statement_timeout has no probeable schema footprint, so tying it to StorageVersion would break the +-- viewer's connect-time version gate. +ALTER ROLE {viewer} SET statement_timeout = '{statementTimeout}'; +ALTER ROLE {mcp} SET statement_timeout = '{statementTimeout}'; + +-- 2. Schema usage + SELECT everywhere (ALL TABLES covers tables AND views). collect holds no secrets, +-- so admin+viewer read all of it. config: admin (the writer, and the Settings window's identity) reads +-- every column; viewer reads all config tables too — MINUS the secret columns carved below. +GRANT USAGE ON SCHEMA {collect}, {config} TO {admin}, {viewer}; +GRANT SELECT ON ALL TABLES IN SCHEMA {collect} TO {admin}, {viewer}; +GRANT SELECT ON ALL TABLES IN SCHEMA {config} TO {admin}, {viewer}; + +-- 2b. Credential-column fail-closed ACLs (#1262 Medium follow-up). The read-only viewer must NOT read the +-- secret columns in config_monitored_servers / config_command / config_notification (a DPAPI password +-- blob, the test_connect credential args, the SMTP password + username, the Teams/Slack webhook URLs). +-- Instead of GRANT-ALL-then-REVOKE-each-secret (fail-OPEN — a future secret column leaks until someone +-- remembers to revoke it), DROP viewer's table-wide SELECT on each and re-grant ONLY the non-secret +-- columns (fail-CLOSED — any column added later is invisible to viewer until explicitly listed in +-- DarlingManagedRoles.ViewerRestrictedConfigTables). admin keeps its table-wide SELECT (granted above), +-- so the Settings window is unaffected. This also undoes the table-level grant the V17 ALTER DEFAULT +-- PRIVILEGES already landed on these tables when they were created. +{viewerColumnAcl} + +-- 3. config writes -- admin gets the whole schema. (mcp gets ONLY a narrow config.analysis_muted INSERT +-- in section 6, never the config_command/monitored_servers/notification pivot tables; viewer: none.) +GRANT INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA {config} TO {admin}; + +-- 4. Default privileges so NEW tables/views auto-inherit (no per-table-grant foot-gun). +ALTER DEFAULT PRIVILEGES FOR ROLE {owner} IN SCHEMA {collect} + GRANT SELECT ON TABLES TO {admin}, {viewer}; +ALTER DEFAULT PRIVILEGES FOR ROLE {owner} IN SCHEMA {config} + GRANT SELECT ON TABLES TO {admin}, {viewer}; +ALTER DEFAULT PRIVILEGES FOR ROLE {owner} IN SCHEMA {config} + GRANT INSERT, UPDATE, DELETE ON TABLES TO {admin}; +-- Fail-closed: today no config table has a sequence (ids are app-generated / text), but a future +-- serial/identity column would give admin INSERT with no sequence USAGE -> the write breaks. Grant it now. +ALTER DEFAULT PRIVILEGES FOR ROLE {owner} IN SCHEMA {config} + GRANT USAGE, SELECT ON SEQUENCES TO {admin}; + +-- 5. Public hardening: no world-writable public schema, no anonymous connect. The REVOKE ALL drops +-- PUBLIC's implicit CONNECT, so admin/viewer are re-granted CONNECT explicitly (darling is +-- superuser + owner and never needs it). +REVOKE CREATE ON SCHEMA public FROM PUBLIC; +REVOKE ALL ON DATABASE {database} FROM PUBLIC; +GRANT CONNECT ON DATABASE {database} TO {admin}, {viewer}; + +-- 6. The mcp role (darling-network-endpoints, D3-role): the ONLY credential reachable (via the bearer +-- token) from the optional network MCP surface, so it is deliberately NOT admin. It gets viewer's exact +-- READ surface (SELECT on collect + config-minus-secret-columns) as SEPARATE 'TO mcp' statements -- the +-- 'TO admin, viewer' grant lines above are pinned verbatim by tests, so mcp is never appended to them -- +-- PLUS exactly two narrow analysis INSERTs HERE: what analyze_server persists (collect.analysis_findings) +-- and what the mute tool writes (config.analysis_muted) -- no UPDATE/DELETE on EITHER analysis target (no +-- MCP unmute tool exists; DELETE/unmute is viewer/admin only). The mcp role ALSO gets the narrow +-- config.custom_views write in section 7 (for the MCP custom-view tools) -- likewise a single non-secret +-- config table, never the config_command pivot or the carved secret columns. EXPLICIT single-table grants +-- with NO ALTER DEFAULT PRIVILEGES: ADP has no per-table +-- form, so an ADP INSERT would broaden mcp to ALL of collect -- provisioning re-runs every start, so a +-- recreated table re-grants (self-heal) without ADP. The two INSERT targets must already exist here, so +-- provisioning runs AFTER migration (EnsureProvisionedAsync). +GRANT USAGE ON SCHEMA {collect}, {config} TO {mcp}; +GRANT SELECT ON ALL TABLES IN SCHEMA {collect} TO {mcp}; +GRANT SELECT ON ALL TABLES IN SCHEMA {config} TO {mcp}; +{mcpColumnAcl} +GRANT INSERT ON {collect}.analysis_findings TO {mcp}; +GRANT INSERT ON {config}.analysis_muted TO {mcp}; +-- NOT granted (Round 4 #9): collect.analysis_state. MCP analyze_server calls AnalyzeAsync directly and never +-- writes the analysis_state marker -- only the worker's RunAnalysisPassAsync wrapper does, as the owner +-- (the Analysis project cannot reference the Service-project observability writer), so mcp needs no grant on it. +GRANT CONNECT ON DATABASE {database} TO {mcp}; + +-- 7. Custom views (#1563): the SINGLE exception to viewer-writes-nothing, and the mcp role's ONLY write outside +-- the two section-6 analysis INSERTs. Both the web dashboard (as the viewer role) and the optional network MCP +-- surface (as the mcp role) CRUD rows in exactly this one config table (non-secret dashboard/notebook JSON -- +-- no ViewerRestrictedConfigTables carve). The web composer and the MCP custom-view tools share ONE store +-- (CustomViewStore) + ONE validator (DarlingWebEndpoints.ValidateDefinition), so the two write identities need +-- the identical narrow grant. Editing is any AUTHENTICATED seat -- the surfaces' normal networked mode, gated +-- server-side by the host's token+CIDR auth (web token/cookie; MCP bearer token) NOT loopback-only; this DB +-- grant is only the narrow floor beneath that gate. EXPLICIT single-table statements (one per identity) with +-- NO ALTER DEFAULT PRIVILEGES, mirroring the narrow analysis-write grants above (ADP has no per-table form -> +-- an ADP write would broaden the role to ALL of config); provisioning re-runs every start, so a recreated +-- table re-grants (self-heal). The target must already exist here, so provisioning runs AFTER migration (V31 +-- created config.custom_views). id is GENERATED ALWAYS AS IDENTITY (like config_command), so the INSERT needs +-- no sequence USAGE grant. +GRANT INSERT, UPDATE, DELETE ON {config}.custom_views TO {viewer}; +GRANT INSERT, UPDATE, DELETE ON {config}.custom_views TO {mcp}; + +-- 8. Alert tuning (the MCP alert-tuning write tools): the mcp role's alert-config writes, mirroring section 7's +-- custom_views grant model (EXPLICIT single-table statements, NO ALTER DEFAULT PRIVILEGES). update_alert_settings +-- / create_mute_rule / delete_mute_rule let a token-holder tune the SAME alert engine the Viewer's Settings +-- window drives: INSERT/UPDATE/DELETE on config_mute_rules (the mute rules the delivery paths honor) and UPDATE +-- on the SINGLETON config_alert_settings row (id=1 -- UPDATE only, never INSERT/DELETE: the row is a fixed +-- singleton the service seeds). Still NARROW -- never the config_command service-credential pivot, the +-- monitored-servers/notification secret tables, or a schema-wide config write. +-- The beacon caveat: a config_alert_settings write fires the existing statement-level bump trigger +-- (trg_bump_alert_settings -> config_bump_version), which UPDATEs config_service.config_version AS THE CURRENT +-- ROLE (the trigger function is SECURITY INVOKER). So mcp ALSO needs UPDATE on JUST the two beacon columns of +-- config_service, or every update_alert_settings write would fail 42501 in production -- and the superuser-run +-- gated-live tests would never catch it (they connect as the owner). A COLUMN-level grant lets mcp bump the +-- reload beacon but NOT flip paused / capture_plans / mcp_enabled / mcp_port. The targets exist here because +-- provisioning runs AFTER migration; a recreated table re-grants on the next start (self-heal). +GRANT INSERT, UPDATE, DELETE ON {config}.config_mute_rules TO {mcp}; +GRANT UPDATE ON {config}.config_alert_settings TO {mcp}; +GRANT UPDATE (config_version, updated_at) ON {config}.config_service TO {mcp}; + +-- 9. Server onboarding (the MCP server-admin write tools): the mcp role's monitored-server writes, mirroring +-- sections 7/8's model (an EXPLICIT single-table statement, NO ALTER DEFAULT PRIVILEGES). add_servers / +-- remove_server let a token-holder add or remove monitored servers in the SAME central store the Viewer's +-- Add / Manage-Servers dialogs write: INSERT/UPDATE/DELETE on config_monitored_servers. Still NARROW -- a +-- single non-secret-KEY table (the encrypted_password column is SELECT-carved from mcp by the section-6 +-- secret-column ACL above, so mcp can WRITE a credential blob but never READ one back), never the +-- config_command service-credential pivot or a schema-wide config write. The BEACON is already covered: a +-- config_monitored_servers write fires trg_bump_monitored_servers -> config_bump_version (SECURITY INVOKER), +-- which UPDATEs config_service.config_version AS mcp, and section 8 already granted mcp +-- UPDATE (config_version, updated_at) ON config_service -- so no additional config_service grant is needed here. +GRANT INSERT, UPDATE, DELETE ON {config}.config_monitored_servers TO {mcp}; +"; + } + + /// + /// The generated passwords are alnum by construction; this fails closed if that ever changes, + /// because the passwords are string-interpolated into DDL literals (belt: quote_literal if + /// the alphabet is ever widened). + /// + private static void RequireAlphanumeric(string password, string parameterName) + { + if (string.IsNullOrEmpty(password)) + { + throw new ArgumentException("Role password must not be empty.", parameterName); + } + + foreach (var c in password) + { + if (!char.IsLetterOrDigit(c) || c > 127) + { + throw new ArgumentException( + "Role password must be ASCII alphanumeric (it is interpolated into DDL); use DarlingManagedPostgres.GeneratePassword.", + parameterName); + } + } + } +} + +/// +/// One secret-bearing config table's viewer ACL split: the the +/// read-only viewer role is granted column-level SELECT on, and the +/// it is denied (credential blobs / bearer secrets). The two sets must PARTITION the table's real columns — +/// the live security test asserts their union equals the table's actual column set, so a migration that adds +/// a column without classifying it here fails that gate. See +/// . +/// +public sealed record ViewerSecretTableAcl( + string Table, IReadOnlyList NonSecretColumns, IReadOnlyList SecretColumns); diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingNetworkConfigEditor.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingNetworkConfigEditor.cs new file mode 100644 index 000000000..7ba997b45 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingNetworkConfigEditor.cs @@ -0,0 +1,647 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Text.Json; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// PURE, comment-preserving TEXT SURGERY on darling.json for the --configure-network wizard +/// (issue #1561). This type has NO I/O, NO DPAPI, and NO Windows dependency by design: it is the +/// single correctness-critical piece, so it is fully unit-testable against the REAL +/// darling.sample.json as a fixture. +/// +/// Why text surgery and not a re-serialize. darling.json / darling.sample.json are +/// comment-rich BY DESIGN — the comments ARE the documentation (see the sample header). Round-tripping +/// through would silently delete every comment, so instead we splice the +/// two network blocks (postgres.network, mcp.network) in/out while leaving every +/// other byte — including the heavily-commented template blocks the sample ships COMMENTED OUT — +/// untouched. +/// +/// Why a real scanner and not IndexOf. The sample ships both network blocks commented out +/// (the // "network": { ... } templates), so a naive IndexOf("\"network\"") would match +/// the TEMPLATE and corrupt the file. walks the text once and labels every +/// character as code / string / line-comment / block-comment; every structural scan below +/// (brace matching, key finding) then counts ONLY characters in a code region, so a commented-out +/// network key is invisible to the surgery. That "scanner ignores the commented template" +/// property is the core correctness proof and is pinned by the fixture tests. +/// +/// The parser is comment-tolerant (ReadCommentHandling.Skip + AllowTrailingCommas, +/// see ), so a spliced-in LIVE block that sits AFTER the surviving commented +/// template parses cleanly, and a removed block can leave a trailing comma behind without breaking the +/// parse. The wizard still re-parses () AND re-runs the resolvers on +/// the result before it ever writes, so an edit can never leave an unparseable or fail-closed file. +/// +internal static class DarlingNetworkConfigEditor +{ + /// + /// The direct children of the postgres / mcp objects are indented four spaces in the + /// sample (a two-space step: root fields at two, their object children at four). The spliced-in + /// network block mirrors that: the "network": line rides the four-space child indent + /// the caller prepends, its fields ride six, and its closing brace rides four. Indentation is purely + /// cosmetic to the parser — a reformatted darling.json still parses, it just would not match its own + /// two-space style — so this is a faithful-to-the-sample default, not a correctness requirement. + /// + private const string ChildIndent = " "; + private const string FieldIndent = " "; + + /// How labels each character of the JSON text. + internal enum RegionKind + { + /// Structural JSON (braces, commas, colons, whitespace, primitive tokens). + Code, + + /// Inside a "..." string token, including its opening/closing quotes and escapes. + StringLiteral, + + /// Inside a // ... line comment (the two slashes through the char before the newline). + LineComment, + + /// Inside a /* ... */ block comment, including the opening and closing delimiters. + BlockComment, + } + + /// + /// Single-pass classifier: labels every character of as + /// code / string / line-comment / block-comment. This is the foundation every structural scan + /// stands on — a { inside a string or a comment is NOT a real brace, and a + /// "network" inside a comment is NOT a real key. String escapes (\", \\) are + /// honored so an embedded quote never falsely closes a string. Pure. + /// + internal static RegionKind[] Classify(string json) + { + if (json is null) + { + throw new ArgumentNullException(nameof(json)); + } + + var regions = new RegionKind[json.Length]; + var state = RegionKind.Code; + var i = 0; + while (i < json.Length) + { + var c = json[i]; + switch (state) + { + case RegionKind.Code: + if (c == '"') + { + regions[i] = RegionKind.StringLiteral; + state = RegionKind.StringLiteral; + i++; + } + else if (c == '/' && i + 1 < json.Length && json[i + 1] == '/') + { + regions[i] = RegionKind.LineComment; + regions[i + 1] = RegionKind.LineComment; + state = RegionKind.LineComment; + i += 2; + } + else if (c == '/' && i + 1 < json.Length && json[i + 1] == '*') + { + regions[i] = RegionKind.BlockComment; + regions[i + 1] = RegionKind.BlockComment; + state = RegionKind.BlockComment; + i += 2; + } + else + { + regions[i] = RegionKind.Code; + i++; + } + + break; + + case RegionKind.StringLiteral: + regions[i] = RegionKind.StringLiteral; + if (c == '\\' && i + 1 < json.Length) + { + /* An escaped char (\" or \\ etc.) — consume it as part of the string so a \" never + reads as the closing quote. */ + regions[i + 1] = RegionKind.StringLiteral; + i += 2; + } + else if (c == '"') + { + state = RegionKind.Code; + i++; + } + else + { + i++; + } + + break; + + case RegionKind.LineComment: + if (c == '\n') + { + /* The newline TERMINATES the line comment; label it Code so line-start scans and + "last code char" scans see a clean structural boundary. */ + regions[i] = RegionKind.Code; + state = RegionKind.Code; + } + else + { + regions[i] = RegionKind.LineComment; + } + + i++; + break; + + case RegionKind.BlockComment: + regions[i] = RegionKind.BlockComment; + if (c == '*' && i + 1 < json.Length && json[i + 1] == '/') + { + regions[i + 1] = RegionKind.BlockComment; + state = RegionKind.Code; + i += 2; + } + else + { + i++; + } + + break; + } + } + + return regions; + } + + /// Index of the first code { (the root object open), or -1 if there is none. + internal static int FindRootObjectOpen(string json, RegionKind[] regions) + { + for (var i = 0; i < json.Length; i++) + { + if (regions[i] == RegionKind.Code && json[i] == '{') + { + return i; + } + } + + return -1; + } + + /// + /// Index of the code brace/bracket that closes the one at , or -1 if the + /// text is unbalanced. Combined {[/}] depth counting is correct for well-formed JSON + /// (which nests properly), and only code-region braces are counted, so braces inside strings/comments + /// are ignored. Pure. + /// + internal static int FindMatchingClose(string json, RegionKind[] regions, int openIndex) + { + var depth = 0; + for (var i = openIndex; i < json.Length; i++) + { + if (regions[i] != RegionKind.Code) + { + continue; + } + + var c = json[i]; + if (c == '{' || c == '[') + { + depth++; + } + else if (c == '}' || c == ']') + { + depth--; + if (depth == 0) + { + return i; + } + } + } + + return -1; + } + + /// The span of a direct-child key inside an object. + /// Index of the opening quote of the key's name. + /// Index of the first character of the value (after the colon). + /// Index one past the last character of the value. + internal readonly record struct ObjectMemberSpan(int KeyStart, int ValueStart, int ValueEnd); + + /// + /// Finds a DIRECT-CHILD member named inside the object whose braces are + /// /, returning the key's start and its + /// value's extent, or null if there is no such LIVE (code-region) key. A that + /// appears only inside a comment (the sample's commented template) or nested deeper is not matched. + /// Pure. + /// + internal static ObjectMemberSpan? FindObjectValueSpan( + string json, RegionKind[] regions, int objectOpen, int objectClose, string key) + { + var depth = 0; + var i = objectOpen; + while (i < objectClose) + { + if (regions[i] != RegionKind.Code) + { + /* A string/comment token — skip its whole span. For a string, that also skips any braces + or colons it contains so they cannot perturb the depth or be read as a key delimiter. */ + if (regions[i] == RegionKind.StringLiteral) + { + var token = ReadStringToken(json, regions, i); + if (depth == 1 && string.Equals(token.Content, key, StringComparison.Ordinal) + && NextCodeChar(json, regions, token.End, objectClose) == ':') + { + var colon = NextCodeIndex(json, regions, token.End, objectClose); + var valueStart = NextCodeIndex(json, regions, colon + 1, objectClose); + if (valueStart < 0) + { + return null; + } + + var valueEnd = ValueEndFrom(json, regions, valueStart, objectClose); + return new ObjectMemberSpan(token.Start, valueStart, valueEnd); + } + + i = token.End; + continue; + } + + i++; + continue; + } + + var c = json[i]; + if (c == '{' || c == '[') + { + depth++; + } + else if (c == '}' || c == ']') + { + depth--; + } + + i++; + } + + return null; + } + + /// + /// Insert or replace parentKey.network with (the block text + /// starting at "network":, with no leading indent on its first line — this method supplies the + /// child indent). If a LIVE network key already exists it is replaced in place; otherwise the + /// block is appended as the parent's last member (after any surviving commented template), with a + /// separating comma synthesized only when the previous member lacks a trailing one. If the parent + /// section itself is absent it is created at the root (the mcp-omitted case). Pure. + /// + internal static string UpsertNetworkBlock(string json, string parentKey, string networkBlock) + { + var regions = Classify(json); + var rootOpen = FindRootObjectOpen(json, regions); + if (rootOpen < 0) + { + throw new InvalidOperationException("darling.json has no root JSON object."); + } + + var rootClose = FindMatchingClose(json, regions, rootOpen); + if (rootClose < 0) + { + throw new InvalidOperationException("darling.json root object is not brace-balanced."); + } + + var parent = FindObjectValueSpan(json, regions, rootOpen, rootClose, parentKey); + if (parent is null) + { + /* No parent section at all (e.g. a minimal darling.json with no "mcp"): create it at root, + wrapping the block one level deeper so its four-space child indent stays correct. */ + var section = + $"\"{parentKey}\": {{\n" + + ChildIndent + networkBlock + "\n" + + " }"; + return InsertAsLastMember(json, regions, rootOpen, rootClose, section, " "); + } + + if (json[parent.Value.ValueStart] != '{') + { + throw new InvalidOperationException($"darling.json '{parentKey}' is not a JSON object."); + } + + var parentOpen = parent.Value.ValueStart; + var parentClose = parent.Value.ValueEnd - 1; + var existing = FindObjectValueSpan(json, regions, parentOpen, parentClose, "network"); + if (existing is not null) + { + /* Replace the existing LIVE block in place. KeyStart already sits after the four-space indent, + so the block goes in without a leading indent. */ + return json[..existing.Value.KeyStart] + networkBlock + json[existing.Value.ValueEnd..]; + } + + return InsertAsLastMember(json, regions, parentOpen, parentClose, networkBlock, ChildIndent); + } + + /// + /// Remove a LIVE parentKey.network block, taking one adjacent separating comma with it so the + /// result stays valid JSON (trailing comma preferred; else the leading comma; else the block is the + /// sole member). A missing parent or a missing LIVE network key is a no-op. The surviving commented + /// template is untouched. Pure. + /// + internal static string RemoveNetworkBlock(string json, string parentKey) + { + var regions = Classify(json); + var rootOpen = FindRootObjectOpen(json, regions); + if (rootOpen < 0) + { + return json; + } + + var rootClose = FindMatchingClose(json, regions, rootOpen); + if (rootClose < 0) + { + return json; + } + + var parent = FindObjectValueSpan(json, regions, rootOpen, rootClose, parentKey); + if (parent is null || json[parent.Value.ValueStart] != '{') + { + return json; + } + + var parentOpen = parent.Value.ValueStart; + var parentClose = parent.Value.ValueEnd - 1; + var existing = FindObjectValueSpan(json, regions, parentOpen, parentClose, "network"); + if (existing is null) + { + return json; + } + + var removeStart = existing.Value.KeyStart; + var removeEnd = existing.Value.ValueEnd; + + /* Take a trailing comma if there is one (the block was not last), else a leading comma (the block + was last). Comments between the block and the comma are whitespace to the parser, so removing + the comma keeps the sibling separation correct either way. */ + var afterIdx = NextCodeIndex(json, regions, removeEnd, parentClose); + if (afterIdx >= 0 && json[afterIdx] == ',') + { + removeEnd = afterIdx + 1; + } + else + { + var beforeIdx = PreviousCodeIndex(json, regions, removeStart, parentOpen); + if (beforeIdx >= 0 && json[beforeIdx] == ',') + { + removeStart = beforeIdx; + } + } + + /* Also swallow the run of trailing horizontal whitespace + the block's line break so a removed + block does not leave a widening blank gap behind (cosmetic; the parse is fine either way). */ + var trimmedTail = removeEnd; + while (trimmedTail < json.Length && (json[trimmedTail] == ' ' || json[trimmedTail] == '\t' || json[trimmedTail] == '\r')) + { + trimmedTail++; + } + + if (trimmedTail < json.Length && json[trimmedTail] == '\n') + { + removeEnd = trimmedTail + 1; + } + + return json[..removeStart] + json[removeEnd..]; + } + + /// + /// The active (uncommented) postgres.network block the wizard writes — a sample-styled block + /// (per-field trailing comments, no fragile column alignment) whose VALUES are live so the store + /// resolver picks it up. The values are pre-validated by the wizard through the real resolver; they + /// are JSON-encoded here so any character is emitted safely. Pure. + /// + internal static string BuildStoreNetworkBlock(string listen, string allowFrom, string role) => + "\"network\": {\n" + + FieldIndent + $"\"listen\": {JsonString(listen)}, // bind IP; 0.0.0.0 = all interfaces (connect by a cert SAN name).\n" + + FieldIndent + $"\"allowFrom\": {JsonString(allowFrom)}, // pg_hba + firewall CIDR (address family must match listen).\n" + + FieldIndent + $"\"role\": {JsonString(role)} // remote pg_hba role: viewer (read-only, default) or admin (remote writes).\n" + + ChildIndent + "}"; + + /// + /// The active (uncommented) mcp.network block the wizard writes. Exactly one of + /// / is non-null: the wizard + /// prefers encryptedToken (a DPAPI blob) and only emits a plaintext token when + /// preserving an existing plaintext value the operator chose to keep. Pure. + /// + internal static string BuildMcpNetworkBlock( + string listen, string allowFrom, string? encryptedToken, string? plaintextToken) + { + var tokenLine = encryptedToken is not null + ? FieldIndent + $"\"encryptedToken\": {JsonString(encryptedToken)} // DPAPI bearer token (from --encrypt-password); required to expose.\n" + : FieldIndent + $"\"token\": {JsonString(plaintextToken)} // plaintext bearer token (dev only; prefer encryptedToken).\n"; + + return + "\"network\": {\n" + + FieldIndent + $"\"listen\": {JsonString(listen)}, // bind IP; 0.0.0.0 = all interfaces.\n" + + FieldIndent + $"\"allowFrom\": {JsonString(allowFrom)}, // in-app RemoteIpAddress check + firewall CIDR (loopback always allowed).\n" + + tokenLine + + ChildIndent + "}"; + } + + /// + /// A numbered menu of the local IPv4 addresses (the caller appends the two always-available choices + /// outside this list). Pure — the caller does the (impure) NIC enumeration and passes the result in. + /// + internal static string FormatAdapterMenu(IReadOnlyList<(string Name, string Address)> adapters) + { + if (adapters.Count == 0) + { + return " (no non-loopback IPv4 addresses were found on this machine)"; + } + + var lines = new List(adapters.Count); + for (var i = 0; i < adapters.Count; i++) + { + lines.Add($" [{i + 1}] {adapters[i].Address} ({adapters[i].Name})"); + } + + return string.Join("\n", lines); + } + + /// + /// One surface's exposure verdict, formatted for the wizard's status display. The caller derives + /// these fields from the REAL resolvers (ResolveNetworkExposure / ResolveMcpBind) so + /// this only formats; it never decides. is null for MCP (no remote role). + /// Pure. + /// + internal static string FormatExposureState( + string surfaceLabel, bool exposed, string? listen, string? cidr, string? role, string? degradeReason) + { + if (exposed) + { + var roleSuffix = string.IsNullOrEmpty(role) ? "" : $", role {role}"; + return $" {surfaceLabel}: EXPOSED — listen {listen}, allowFrom {cidr}{roleSuffix}"; + } + + if (!string.IsNullOrEmpty(degradeReason)) + { + return $" {surfaceLabel}: loopback-only (DEGRADED — {degradeReason})"; + } + + return $" {surfaceLabel}: loopback-only (secure default)"; + } + + /* ------------------------------------------------------------------------------------------------ + Internal scan helpers — all pure, all code-region-aware via the Classify labels. + ------------------------------------------------------------------------------------------------ */ + + private readonly record struct StringToken(int Start, int End, string Content); + + /// + /// Reads the string token that starts at (a code-adjacent opening + /// quote), returning its extent (End is one past the closing quote) and its raw inner content. Our + /// target keys have no escapes, so the raw slice is the name. + /// + private static StringToken ReadStringToken(string json, RegionKind[] regions, int quoteIndex) + { + var start = quoteIndex; + var i = quoteIndex + 1; + while (i < json.Length && regions[i] == RegionKind.StringLiteral) + { + i++; + } + + /* i now points one past the closing quote (the first non-string index). */ + var innerStart = start + 1; + var innerEnd = i - 1; + var content = innerEnd > innerStart ? json[innerStart..innerEnd] : ""; + return new StringToken(start, i, content); + } + + /// Index of the next code character in [, ), skipping whitespace + comments; -1 if none. + private static int NextCodeIndex(string json, RegionKind[] regions, int from, int limit) + { + for (var i = from; i < limit && i < json.Length; i++) + { + if (regions[i] == RegionKind.Code && !char.IsWhiteSpace(json[i])) + { + return i; + } + } + + return -1; + } + + /// The next code character (skipping ws + comments), or '\0' if none. + private static char NextCodeChar(string json, RegionKind[] regions, int from, int limit) + { + var idx = NextCodeIndex(json, regions, from, limit); + return idx < 0 ? '\0' : json[idx]; + } + + /// Index of the previous code character before (down to ), skipping ws + comments; -1 if none. + private static int PreviousCodeIndex(string json, RegionKind[] regions, int before, int floor) + { + for (var i = before - 1; i >= floor && i >= 0; i--) + { + if (regions[i] == RegionKind.Code && !char.IsWhiteSpace(json[i])) + { + return i; + } + } + + return -1; + } + + /// + /// End (exclusive) of the value that starts at : the matching brace for + /// an object/array, the closing quote for a string, or the char after the last non-whitespace token + /// for a primitive (up to the next code comma or closing brace/bracket). + /// + private static int ValueEndFrom(string json, RegionKind[] regions, int valueStart, int limit) + { + var c = json[valueStart]; + if (c == '{' || c == '[') + { + var close = FindMatchingClose(json, regions, valueStart); + return close < 0 ? limit : close + 1; + } + + if (c == '"') + { + return ReadStringToken(json, regions, valueStart).End; + } + + /* Primitive (true/false/null/number): runs to the next code comma or closing brace/bracket. */ + var lastNonWs = valueStart; + for (var i = valueStart; i < limit && i < json.Length; i++) + { + if (regions[i] != RegionKind.Code) + { + continue; + } + + var ch = json[i]; + if (ch == ',' || ch == '}' || ch == ']') + { + break; + } + + if (!char.IsWhiteSpace(ch)) + { + lastNonWs = i; + } + } + + return lastNonWs + 1; + } + + /// + /// Insert as the last member of the object whose braces are + /// /, on its own line(s) just before the + /// closing brace's line, prepending . A separating comma is synthesized + /// right after the previous member's value only when it lacks a trailing comma (comments in between + /// are irrelevant — they are whitespace to the parser). Pure. + /// + private static string InsertAsLastMember( + string json, RegionKind[] regions, int objectOpen, int objectClose, string member, string childIndent) + { + var lineStart = LineStartOf(json, objectClose); + var lastCodeIdx = PreviousCodeIndex(json, regions, objectClose, objectOpen); + + /* Empty object (only the open brace precedes the close): drop the member in on its own line. */ + if (lastCodeIdx < 0 || lastCodeIdx == objectOpen) + { + return json[..lineStart] + childIndent + member + "\n" + json[lineStart..]; + } + + var lastCode = json[lastCodeIdx]; + var needComma = lastCode != ','; + + if (needComma) + { + return json[..(lastCodeIdx + 1)] + "," + + json[(lastCodeIdx + 1)..lineStart] + + childIndent + member + "\n" + + json[lineStart..]; + } + + return json[..lineStart] + childIndent + member + "\n" + json[lineStart..]; + } + + /// Index of the start of the line containing (just after the previous newline, or 0). + private static int LineStartOf(string json, int index) + { + for (var i = index - 1; i >= 0; i--) + { + if (json[i] == '\n') + { + return i + 1; + } + } + + return 0; + } + + /// JSON-encodes a value as a quoted string (safe for any character). Pure. + private static string JsonString(string? value) => JsonSerializer.Serialize(value ?? ""); +} diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingObservability.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingObservability.cs new file mode 100644 index 000000000..c2739b851 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingObservability.cs @@ -0,0 +1,294 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Npgsql; +using NpgsqlTypes; +using PerformanceMonitor.Collectors; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// The service's writes into the V2 observability store: the servers registry (upserted on every +/// successful connect) and the per-run collection_log (one row per collector cycle, Lite's status +/// vocabulary: SUCCESS / PERMISSIONS / ERROR). Column names mirror Lite's DuckDB tables so +/// viewer/analysis SQL can twin — duckdb_duration_ms records the Postgres storage phase here. +/// Both writes are failure-isolated: they log at Debug and never throw, because an observability +/// write must never break the collection loop. +/// +public static class DarlingObservability +{ + /* is_enabled is written ONLY on first insert (a server reaching a connect is enabled by definition — + only enabled servers are in the loop). The ON CONFLICT re-connect deliberately does NOT touch + is_enabled, so a control-plane disable (config_monitored_servers.is_enabled = FALSE, mirrored onto + this observed row by SyncServerEnabledStatesAsync) is never clobbered back to TRUE on the next + connect. Before Stage 2 this forced is_enabled = TRUE on every connect and nothing ever read it. */ + private const string UpsertServerSql = @" +INSERT INTO servers (server_id, server_name, display_name, is_enabled, sql_engine_edition, sql_major_version, created_date, modified_date, monthly_cost_usd) +VALUES ($1, $2, $3, TRUE, $4, $5, $6, $6, $7) +ON CONFLICT (server_id) DO UPDATE SET + server_name = EXCLUDED.server_name, + display_name = EXCLUDED.display_name, + sql_engine_edition = EXCLUDED.sql_engine_edition, + sql_major_version = EXCLUDED.sql_major_version, + modified_date = EXCLUDED.modified_date, + monthly_cost_usd = EXCLUDED.monthly_cost_usd;"; + + /* Mirror the DESIRED config (config.config_monitored_servers) onto the OBSERVED registry + (collect.servers) for the two fields the viewer/FinOps read straight off collect.servers: is_enabled + and monthly_cost_usd. A disabled server drops out of the collection loop and stops upserting, so without + this its observed row would keep its last (TRUE) value forever; likewise a cost-only edit reaches the + FinOps display (which reads collect.servers) through THIS sync, so a cost change needs no + disconnect+reconnect (see DarlingWorker.ServerDefinitionEquals, which deliberately no longer compares + cost). Fires when EITHER field drifts. Internal so a pure test can pin the SHAPE. Runs on every reload. */ + internal const string SyncEnabledStatesSql = @" +UPDATE collect.servers s +SET is_enabled = c.is_enabled, + monthly_cost_usd = c.monthly_cost_usd, + modified_date = (now() AT TIME ZONE 'UTC') +FROM config.config_monitored_servers c +WHERE s.server_id = c.server_id + AND (s.is_enabled IS DISTINCT FROM c.is_enabled + OR s.monthly_cost_usd IS DISTINCT FROM c.monthly_cost_usd);"; + + private const string InsertCollectionLogSql = @" +INSERT INTO collection_log (log_id, server_id, server_name, collector_name, collection_time, duration_ms, status, error_message, rows_collected, sql_duration_ms, duckdb_duration_ms) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);"; + + /* The fleet-sentinel server_id the daily retention purge writes its run-record under. collection_log's + server_id is NOT NULL and Collection Health reads per real server_id, but the purge is fleet-wide (per + shared table, not per server), so it has no real server to attribute to. This reserved id is never a + monitored server (server_ids are FNV-1a hashes of a storage name; it is also never registered in + config_monitored_servers / collect.servers), so the sentinel row is: (a) EXCLUDED from the fleet + Collection-Health aggregate — that read is scoped to server_id IN config_monitored_servers WHERE + is_enabled; (b) never matched by any per-server read (all WHERE server_id = $realId) or the self-alert + collection-signal reads; yet (c) fully auditable via v_collection_log (WHERE collector_name = + 'data_retention'). Deliberately NOT a new fleet UI surface — just an auditable log row. */ + internal const int FleetServerId = 0; + private const string FleetServerName = "(fleet)"; + + /* Matches the Dashboard's config.data_retention collector_name so the audit vocabulary twins across SKUs. */ + private const string RetentionCollectorName = "data_retention"; + + /* The per-server analysis-state marker (V19): the analysis pass's insufficient-data determination, + persisted so the Viewer's Recommendations tab can tell "still collecting" (a young deployment under + the 24h data-span gate) apart from a genuine all-clear. One row per server, upserted after each + completed pass on the natural key (server_id). The bare name resolves through the collect/config + search path to collect.analysis_state — the observed-output schema, like servers/collection_log. + Internal so a pure test can pin the UPSERT shape. */ + internal const string WriteAnalysisStateSql = @" +INSERT INTO analysis_state (server_id, insufficient_data, message, analysis_time) +VALUES ($1, $2, $3, $4) +ON CONFLICT (server_id) DO UPDATE SET + insufficient_data = EXCLUDED.insufficient_data, + message = EXCLUDED.message, + analysis_time = EXCLUDED.analysis_time;"; + + /// + /// Registers (or refreshes) a connected server in the registry: created_date is written only on first + /// insert, modified_date on every connect. is_enabled is set TRUE on first insert only and left + /// UNTOUCHED on re-connect (Stage 2) so a control-plane disable is not resurrected — the desired enable + /// state is mirrored onto this observed row by on each reload. + /// + public static async Task UpsertServerAsync(NpgsqlDataSource postgres, ServerRuntime server, ILogger? logger, CancellationToken cancellationToken) + { + try + { + await using var connection = await postgres.OpenConnectionAsync(cancellationToken); + using var command = new NpgsqlCommand(UpsertServerSql, connection); + command.Parameters.AddWithValue(server.ServerId); + command.Parameters.AddWithValue(server.StorageName); + command.Parameters.AddWithValue(server.Config.DisplayName); + /* The raw probed SERVERPROPERTY('EngineEdition') — real box editions (2/3/4...), not + just the 5/8 Azure classifications. */ + command.Parameters.AddWithValue(server.EngineEdition); + command.Parameters.AddWithValue(server.Target.SqlMajorVersion); + /* Naive-UTC storage: Npgsql 6+ rejects Kind=Utc against `timestamp` — see PgCollectorRowWriter. */ + command.Parameters.AddWithValue(DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified)); + /* Per-server FinOps budget from darling.json (0 = hide cost in the viewer, like Lite). */ + command.Parameters.AddWithValue(server.Config.MonthlyCostUsd); + await command.ExecuteNonQueryAsync(cancellationToken); + } + catch (Exception ex) + { + /* Failure-isolated by design — an observability write must never break the collection loop. */ + logger?.LogDebug("Observability: servers upsert for '{Server}' failed: {Message}", + server.Config.DisplayName, ex.Message); + } + } + + /// + /// Mirrors the DESIRED config (config.config_monitored_servers) onto the OBSERVED registry + /// (collect.servers) for the two fields the viewer/FinOps read straight off collect.servers: + /// is_enabled and monthly_cost_usd. So a control-plane disable_server flips the + /// observed row to FALSE even though the disabled server stops upserting (a later enable_server + /// flips it back), and a cost-only edit reaches the FinOps display through this reload sync with NO + /// disconnect+reconnect. Runs on each control-plane reload. Failure-isolated (Debug + no-op) like the + /// other observability writes. + /// + public static async Task SyncServerEnabledStatesAsync(NpgsqlDataSource postgres, ILogger? logger, CancellationToken cancellationToken) + { + try + { + await using var connection = await postgres.OpenConnectionAsync(cancellationToken); + using var command = new NpgsqlCommand(SyncEnabledStatesSql, connection); + var changed = await command.ExecuteNonQueryAsync(cancellationToken); + if (changed > 0) + { + logger?.LogInformation("Synced observed enable-state/cost for {Count} server(s) from the desired config", changed); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + /* Failure-isolated by design — an observability write must never break the collection loop. */ + logger?.LogDebug("Observability: server enable-state sync failed: {Message}", ex.Message); + } + } + + /// + /// Records one collector run's outcome in collection_log. duration_ms is the SQL fetch plus + /// the storage phase; duckdb_duration_ms carries the storage (Postgres) milliseconds under + /// Lite's column name so analysis SQL can twin. + /// + public static async Task LogCollectionAsync( + NpgsqlDataSource postgres, + ServerRuntime server, + string collectorName, + string status, + int rowsCollected, + long sqlMs, + long storageMs, + string? errorMessage, + ILogger? logger, + CancellationToken cancellationToken) + { + try + { + var message = errorMessage; + if (message is not null && message.Length > 4000) + { + message = message.Substring(0, 4000); + } + + await using var connection = await postgres.OpenConnectionAsync(cancellationToken); + using var command = new NpgsqlCommand(InsertCollectionLogSql, connection); + command.Parameters.AddWithValue(CollectionIdGenerator.Next()); + command.Parameters.AddWithValue(server.ServerId); + command.Parameters.AddWithValue(server.StorageName); + command.Parameters.AddWithValue(collectorName); + /* Naive-UTC storage: Npgsql 6+ rejects Kind=Utc against `timestamp` — see PgCollectorRowWriter. */ + command.Parameters.AddWithValue(DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue((int)(sqlMs + storageMs)); + command.Parameters.AddWithValue(status); + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Text, Value = (object?)message ?? DBNull.Value }); + command.Parameters.AddWithValue(rowsCollected); + command.Parameters.AddWithValue((int)sqlMs); + command.Parameters.AddWithValue((int)storageMs); + await command.ExecuteNonQueryAsync(cancellationToken); + } + catch (Exception ex) + { + /* Failure-isolated by design — an observability write must never break the collection loop. */ + logger?.LogDebug("Observability: collection_log write for '{Server}' / {Collector} failed: {Message}", + server.Config.DisplayName, collectorName, ex.Message); + } + } + + /// + /// Records one daily retention-purge sweep in collection_log as an auditable data_retention + /// run-record (the Darling twin of the Dashboard's config.data_retention log rows). Fleet-wide, so it is + /// written under the reserved sentinel — never a real monitored server, and + /// excluded from every per-server and fleet Collection-Health read (see the constant's remarks). is SUCCESS / WARNING (some tables failed) / ERROR; is the + /// coarse rows-deleted-plus-chunks-dropped activity count; is the whole-sweep + /// elapsed (recorded as both the total and the storage phase — a purge is all store-side work, no SQL-target + /// phase). Failure-isolated (Debug + no-op) like the other observability writes — an audit write must never + /// break the collection loop. + /// + public static async Task LogRetentionRunAsync( + NpgsqlDataSource postgres, + string status, + int rowsPurged, + long durationMs, + string? message, + ILogger? logger, + CancellationToken cancellationToken) + { + try + { + var text = message; + if (text is not null && text.Length > 4000) + { + text = text.Substring(0, 4000); + } + + var elapsed = durationMs > int.MaxValue ? int.MaxValue : (int)durationMs; + + await using var connection = await postgres.OpenConnectionAsync(cancellationToken); + using var command = new NpgsqlCommand(InsertCollectionLogSql, connection); + command.Parameters.AddWithValue(CollectionIdGenerator.Next()); // log_id + command.Parameters.AddWithValue(FleetServerId); // server_id (sentinel) + command.Parameters.AddWithValue(FleetServerName); // server_name + command.Parameters.AddWithValue(RetentionCollectorName); // collector_name + /* Naive-UTC storage: Npgsql 6+ rejects Kind=Utc against `timestamp` — see PgCollectorRowWriter. */ + command.Parameters.AddWithValue(DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified)); // collection_time + command.Parameters.AddWithValue(elapsed); // duration_ms + command.Parameters.AddWithValue(status); // status + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Text, Value = (object?)text ?? DBNull.Value }); // error_message + command.Parameters.AddWithValue(rowsPurged); // rows_collected + command.Parameters.AddWithValue(0); // sql_duration_ms (no SQL-target phase) + command.Parameters.AddWithValue(elapsed); // duckdb_duration_ms (storage phase = whole sweep) + await command.ExecuteNonQueryAsync(cancellationToken); + } + catch (Exception ex) + { + /* Failure-isolated by design — an observability write must never break the collection loop. */ + logger?.LogDebug("Observability: data_retention run-record write failed: {Message}", ex.Message); + } + } + + /// + /// Upserts the per-server analysis-state marker (V19) after an analysis pass: insufficient_data = + /// true plus the engine's message when the pass hit the 24h data-span gate, or false + null + /// when a real pass completed on enough data. The engine ALREADY makes this determination + /// (DarlingAnalysisService.InsufficientDataMessage); this persists it so the Viewer's + /// Recommendations tab — which never calls the engine — shows "still collecting" instead of a false + /// all-clear on a young deployment's zero-finding read. One row per server, upserted on + /// server_id. Failure-isolated (Debug + no-op) like the other observability writes — an + /// analysis-state write must never break the collection loop. + /// + public static async Task WriteAnalysisStateAsync( + NpgsqlDataSource postgres, + int serverId, + bool insufficientData, + string? message, + ILogger? logger, + CancellationToken cancellationToken) + { + try + { + await using var connection = await postgres.OpenConnectionAsync(cancellationToken); + using var command = new NpgsqlCommand(WriteAnalysisStateSql, connection); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(insufficientData); + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Text, Value = (object?)message ?? DBNull.Value }); + /* Naive-UTC storage: Npgsql 6+ rejects Kind=Utc against `timestamp` — see PgCollectorRowWriter. */ + command.Parameters.AddWithValue(DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified)); + await command.ExecuteNonQueryAsync(cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + /* Failure-isolated by design — an observability write must never break the collection loop. */ + logger?.LogDebug("Observability: analysis-state write for server {ServerId} failed: {Message}", + serverId, ex.Message); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingRetention.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingRetention.cs new file mode 100644 index 000000000..5bbea5f07 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingRetention.cs @@ -0,0 +1,453 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Diagnostics; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Storage; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// Daily retention purge for the Darling Postgres store. The extension-free baseline is +/// DELETE-based and works on any Postgres; when the worker detected TimescaleDB +/// (timescaleAvailable — see TimescaleSupport in Darling.Storage) the collector tables +/// purge via hypertable drop_chunks instead, which detaches whole expired chunks in O(1) +/// instead of scanning rows. collection_log — a hypertable since V23, though converted directly by the +/// V23 migration rather than the catalog loop — purges the SAME way (drop_chunks with a DELETE fallback for +/// a plain-PostgreSQL store). config_alert_log stays DELETE-based either way (never converted — a plain +/// config-side registry table), as do the analysis tables (PgFindingStore.CleanupOldFindingsAsync owns +/// those). Retention horizons are the shared +/// per-collector (identity-pinned to Lite's +/// ScheduleManager table), so both SKUs keep the same data horizons out of the box. NOTE: Lite +/// archives expired rows to parquet before deleting (ArchiveService); Darling deliberately +/// purges without archiving — with Timescale, the compression policy on old chunks IS the +/// archival tier (compressed chunks stay queryable), and the plain-PG story remains +/// purge-without-archive for now. +/// Every sweep writes one AUDITABLE run-record to collection_log under a fleet-sentinel server_id +/// (, never a real server) — SUCCESS, WARNING (some +/// tables failed their statement, already logged + isolated), or ERROR — so a stalled or partial purge is +/// visible in the collection log, not just the service log. The DELETE path drains each table in +/// one-day time slices ( — the compressed-chunk-safe successor to the +/// Dashboard's DELETE TOP idiom); collection_log is kept 2x the base data-retention window so a +/// run-record outlives the metric rows it explains. +/// +public static class DarlingRetention +{ + /// + /// The base data-retention window the collection_log horizon is a multiple of. 30 days matches the + /// dominant collector horizon and the Dashboard's + /// @effective_retention_days default (config.data_retention). + /// + internal const int DataRetentionBaseDays = 30; + + /// + /// collection_log isn't a collector, so it has no entry to carry + /// its horizon. It is kept at 2x the base window (mirrors the Dashboard's retention_date x2 rule) + /// so a collector run-record survives long enough to diagnose WHY a collector failed AFTER its metric + /// rows have aged out — a 30-day metric row and its failure log would otherwise expire together, erasing + /// the evidence. Effectively 60 days. + /// + internal const int CollectionLogRetentionDays = DataRetentionBaseDays * 2; + + /// + /// config_alert_log (the fired-alert history: what alerted + delivery status, read by the viewer Alert + /// History tab and the get_alert_history MCP tool) is a plain config-schema registry table — NOT a + /// collector (so it has no horizon) and NOT a hypertable (so it + /// purges via batched DELETE, never drop_chunks). It is INSERT-only (PgAlertHistoryStore) with no other + /// purge path, so without a horizon it grows unbounded — the same class as the #1471 findings-cleanup gap. + /// Kept 90 days (a quarter): alert history is low-volume and a valuable audit trail, so the horizon is + /// generous, but it is BOUNDED. No operator setting governs this today (config_alert_settings carries the + /// cooldown / lookback knobs, not an alert-history horizon), so this constant is the single source of truth. + /// + internal const int AlertHistoryRetentionDays = 90; + + /* Each one-day slice is bounded work (see TimeSlicedDeleteSql); the generous 300s per-slice command + timeout (well above Npgsql's 30s default) is belt-and-suspenders for a slow disk. Slicing is also what + keeps a large first purge from ever hitting a timeout at all — a single unbounded DELETE could roll + back on a long backlog and never catch up (retried tomorrow with a day MORE to delete). */ + private const int DeleteTimeoutSeconds = 300; + + /// + /// Purges every collector table past its shared + /// RetentionDays, plus collection_log past and + /// config_alert_log past . + /// When (the worker's startup detection), the + /// collector tables purge via drop_chunks () with a + /// per-table DELETE fallback so a table that failed hypertable conversion still honors its + /// horizon; when false, the extension-free DELETE path runs unchanged. collection_log is + /// DELETE-based either way. Failure-isolated per table: one failed statement is logged as a + /// warning and the sweep continues (that table is retried on the next purge). Safe on a + /// fresh/empty store — a purge that matches nothing removes nothing. Returns a coarse + /// activity count: rows deleted by the DELETE paths plus whole chunks dropped by + /// drop_chunks (Timescale doesn't report per-row counts for dropped chunks). + /// + /// + /// Optional resolver for a collector's effective retention horizon (control-plane fleet-wide overrides + /// layered on ). Null (or a value it does not override) uses the + /// shared default. A per-server override cannot apply here — the purge is per shared table, not per server. + /// The on-demand purge_now command passes a _ => customDays resolver for its custom-N mode. + /// + /// + /// A : how many tables were touched and the coarse activity count (DELETE rows + /// plus dropped chunks). The daily caller discards it; the on-demand purge_now command reports it. + /// + public static async Task PurgeAsync( + NpgsqlDataSource postgres, bool timescaleAvailable, ILogger? logger, CancellationToken cancellationToken, + Func? retentionDaysFor = null) + { + var sw = Stopwatch.StartNew(); + var tablesPurged = 0; + var totalRowsDeleted = 0; + var totalChunksDropped = 0; + var tablesFailed = 0; + + /* Naive-UTC storage: Npgsql 6+ rejects Kind=Utc against `timestamp` — see PgCollectorRowWriter. */ + var utcNow = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified); + + try + { + foreach (var definition in CollectorCatalog.All) + { + if (!CollectorScheduleDefaults.All.TryGetValue(definition.Name, out var schedule)) + { + /* Impossible while the retention coverage test holds — belt-and-suspenders so schedule + drift degrades to a loud warning (and a WARNING run-record) instead of killing the sweep. */ + logger?.LogWarning("Retention purge: no schedule entry for '{Collector}' — {Table} was not purged", + definition.Name, definition.TargetTable); + tablesFailed++; + continue; + } + + /* Clamp at the destructive sink (belt-and-suspenders with the resolver + the V17 CHECK): a + retention of 0/negative would flip the cutoff into the present/future and drop_chunks / + DELETE the entire table. Never purge with a horizon under 1 day. */ + var retentionDays = Math.Max(1, retentionDaysFor?.Invoke(definition.Name) ?? schedule.RetentionDays); + + if (timescaleAvailable) + { + var dropped = await DropChunksOneAsync( + postgres, definition.TargetTable, DropChunksSqlFor(definition, retentionDays), + logger, cancellationToken); + if (dropped is not null) + { + tablesPurged++; + totalChunksDropped += dropped.Value; + continue; + } + + /* drop_chunks failed (warned) — most likely this one table failed hypertable + conversion and is still plain. Fall back to the extension-free DELETE so the + table still honors its horizon instead of growing unbounded. */ + } + + var deleted = await PurgeOneAsync( + postgres, definition.TargetTable, DeleteSqlFor(definition), + utcNow.AddDays(-retentionDays), logger, cancellationToken); + if (deleted is not null) + { + tablesPurged++; + totalRowsDeleted += deleted.Value; + } + else + { + tablesFailed++; + } + } + + /* collection_log retention. Since V23 it is a TimescaleDB hypertable (converted DIRECTLY by the V23 + migration — it is NOT in CollectorCatalog.All, so the loop above skips it), so with Timescale it + purges via drop_chunks in O(1) — no DELETE churn — at its own 2x horizon (CollectionLogRetentionDays). + On plain PostgreSQL, or if its hypertable conversion failed, it falls back to the batched DELETE so + the horizon is ALWAYS honored. Failure-isolated like every sibling. The FleetServerId=0 retention + run-record sentinel is a genuine collection_log row and lives in a chunk normally. */ + var logPurged = false; + if (timescaleAvailable) + { + var droppedLog = await DropChunksOneAsync( + postgres, "collection_log", DropChunksSqlFor("collection_log", CollectionLogRetentionDays), + logger, cancellationToken); + if (droppedLog is not null) + { + tablesPurged++; + totalChunksDropped += droppedLog.Value; + logPurged = true; + } + + /* drop_chunks failed (warned) — most likely collection_log's conversion failed and it is still + plain. Fall through to the extension-free DELETE so it still honors its horizon. */ + } + + if (!logPurged) + { + var logDeleted = await PurgeOneAsync( + postgres, "collection_log", TimeSlicedDeleteSql("collection_log", "collection_time"), + utcNow.AddDays(-CollectionLogRetentionDays), logger, cancellationToken); + if (logDeleted is not null) + { + tablesPurged++; + totalRowsDeleted += logDeleted.Value; + } + else + { + tablesFailed++; + } + } + + /* config_alert_log (the fired-alert history) is a plain config-schema registry table, never a + hypertable, so it purges via the same batched DELETE as collection_log — on its alert_time + column, at the AlertHistoryRetentionDays horizon. INSERT-only (PgAlertHistoryStore) with no + other purge path, so without this it grows unbounded (the #1471 findings-cleanup class of bug). + Failure-isolated like every sibling: a failed statement is warned + counted, the sweep goes on. */ + var alertLogDeleted = await PurgeOneAsync( + postgres, "config_alert_log", TimeSlicedDeleteSql("config_alert_log", "alert_time"), + utcNow.AddDays(-AlertHistoryRetentionDays), logger, cancellationToken); + if (alertLogDeleted is not null) + { + tablesPurged++; + totalRowsDeleted += alertLogDeleted.Value; + } + else + { + tablesFailed++; + } + + var summary = new PurgeSummary(tablesPurged, totalRowsDeleted, totalChunksDropped); + logger?.LogInformation( + "Retention purge: {Tables} table(s) purged, {Rows} row(s) deleted, {Chunks} chunk(s) dropped, {Failed} failed, {ElapsedMs}ms", + tablesPurged, totalRowsDeleted, totalChunksDropped, tablesFailed, sw.ElapsedMilliseconds); + + /* Auditable run-record: a clean sweep writes SUCCESS, a sweep where one or more tables failed + their statement writes WARNING (the per-table failures were already logged + isolated above). + Fleet-wide, so it lands under the sentinel server_id (DarlingObservability.LogRetentionRunAsync), + which is failure-isolated and never breaks the loop. */ + var (status, message) = BuildRunRecordSummary(tablesPurged, totalRowsDeleted, totalChunksDropped, tablesFailed); + await DarlingObservability.LogRetentionRunAsync( + postgres, status, summary.TotalPurged, sw.ElapsedMilliseconds, message, logger, cancellationToken); + + return summary; + } + catch (OperationCanceledException) + { + /* Shutdown/cancellation — propagate exactly like the per-table helpers (no ERROR run-record; the + purge simply didn't finish and retries on the next daily tick). */ + throw; + } + catch (Exception ex) + { + /* The per-table helpers isolate their own failures, so reaching here means something unexpected + escaped the loop. Record an ERROR run-record for the audit trail and return what we managed to + purge — PurgeAsync must never throw at the daily caller (it is not wrapped there), so a broken + purge surfaces as an auditable ERROR row, not a crashed collection loop. */ + logger?.LogError("Retention purge failed: {Message}", ex.Message); + await DarlingObservability.LogRetentionRunAsync( + postgres, "ERROR", totalRowsDeleted + totalChunksDropped, sw.ElapsedMilliseconds, ex.Message, logger, cancellationToken); + return new PurgeSummary(tablesPurged, totalRowsDeleted, totalChunksDropped); + } + } + + /// + /// The status + human message for the auditable run-record of a completed sweep (not the exception path, + /// which writes a literal ERROR): SUCCESS when every table purged cleanly, WARNING when + /// > 0 (some table's statement failed — already logged + isolated). + /// Pure so the SUCCESS/WARNING branch and the message text are unit-testable without a live store. + /// + internal static (string Status, string Message) BuildRunRecordSummary( + int tablesPurged, int totalRowsDeleted, int totalChunksDropped, int tablesFailed) + { + var status = tablesFailed == 0 ? "SUCCESS" : "WARNING"; + var message = tablesFailed == 0 + ? $"Purged {tablesPurged.ToString(CultureInfo.InvariantCulture)} table(s): {totalRowsDeleted.ToString(CultureInfo.InvariantCulture)} row(s) deleted, {totalChunksDropped.ToString(CultureInfo.InvariantCulture)} chunk(s) dropped" + : $"Purged {tablesPurged.ToString(CultureInfo.InvariantCulture)} table(s), {tablesFailed.ToString(CultureInfo.InvariantCulture)} failed (see prior warnings): {totalRowsDeleted.ToString(CultureInfo.InvariantCulture)} row(s) deleted, {totalChunksDropped.ToString(CultureInfo.InvariantCulture)} chunk(s) dropped"; + return (status, message); + } + + /// + /// The batched purge statement for one collector table — deletes expired rows one time slice at a time + /// on the definition's own prefix time column ("collection_time" almost everywhere; the config snapshots + /// purge on "capture_time"), executed in a loop until the table is drained (). + /// Table and column names come from the shared catalog constants, never from user input, so + /// interpolation is safe here — the same reasoning as the runner's watermark read + /// (DarlingCollectorRunner.GetLastCollectedTimeAsync). + /// + internal static string DeleteSqlFor(ICollectorSchemaInfo schema) + => TimeSlicedDeleteSql(schema.TargetTable, schema.PrefixTimeColumnName); + + /// + /// The batched purge statement: delete the OLDEST one-day slice of expired rows per execution — + /// WHERE {col} < $1 AND {col} in [min expired, min expired + 1 day) — repeated until a slice + /// deletes nothing. This replaced a ctid IN (SELECT ctid … LIMIT 10000) row-cap idiom (#1564): + /// reading the ctid system column through TimescaleDB's transparent decompression is unsupported + /// ("transparent decompression only supports tableoid system column" — reproduced on the pinned 2.28.1), + /// so the moment ANY in-range chunk was compressed the whole statement errored and the table silently + /// kept its expired rows. A plain time-range predicate instead rides TimescaleDB's DML decompression, + /// which IS supported on compressed chunks — and is a no-op cost on plain tables. + /// The slice width doubles as the work bound: one day of arrival volume per statement — exactly + /// what a steady-state daily purge deletes in total, so a long backlog is drained in day-sized units the + /// store already sustains daily (the old 10k row cap served the same goal; a day is also precisely one + /// chunk on a hypertable, ). Both min({col}) + /// subqueries evaluate against the same statement snapshot, so they are always equal; when no expired + /// rows remain, min is NULL, every comparison is unknown, zero rows delete, and the drain loop + /// stops. In production the hypertables purge via drop_chunks, so this path runs on plain tables + /// (a plain-PostgreSQL store, config_alert_log) or as the fallback when a hypertable's + /// drop_chunks failed — where compressed chunks are LIKELY, which is what makes the + /// compressed-safe shape load-bearing. $1 is bound once and referenced by all three positions. + /// Table/column come from catalog constants (never user input), so interpolation is safe. + /// + internal static string TimeSlicedDeleteSql(string table, string timeColumn) + => $"DELETE FROM {table} WHERE {timeColumn} < $1" + + $" AND {timeColumn} >= (SELECT min({timeColumn}) FROM {table} WHERE {timeColumn} < $1)" + + $" AND {timeColumn} < (SELECT min({timeColumn}) FROM {table} WHERE {timeColumn} < $1) + INTERVAL '{TimescaleSupport.ChunkIntervalDays} days'"; + + /// + /// The Timescale purge statement for one collector table — drop_chunks detaches every + /// chunk wholly older than the horizon (validated live on TimescaleDB 2.28.1; the partition + /// column is implicit in the hypertable's dimension, so no time column appears here). An + /// accepted coarseness: drop_chunks only drops WHOLE chunks, so rows inside a + /// partially-expired chunk survive until the entire chunk ages past the horizon (with Darling's + /// 1-day chunk interval — — up to ~1 day of grace) — + /// the trade for a metadata-only purge that never scans or rewrites rows. RetentionDays comes from the shared + /// constants, never from user input, so + /// interpolation is safe here — the same reasoning as . + /// + internal static string DropChunksSqlFor(ICollectorSchemaInfo schema, int retentionDays) + => DropChunksSqlFor(schema.TargetTable, retentionDays); + + /// + /// The drop_chunks statement for a hypertable by raw table name — the collection_log path (a + /// hypertable since V23 but outside the collector catalog, so it has no ). + /// Same shape as the schema overload; the table name comes from a compile-time constant, never user input, + /// so interpolation is safe. + /// + internal static string DropChunksSqlFor(string table, int retentionDays) + => $"SELECT drop_chunks('{table}', older_than => make_interval(days => {retentionDays}))"; + + /// + /// One table's drop_chunks; returns the number of chunks dropped, or null when it failed + /// (warned; the caller falls back to DELETE for that table). drop_chunks returns one row per + /// dropped chunk, so the count comes from reading the result set. + /// + private static async Task DropChunksOneAsync( + NpgsqlDataSource postgres, + string tableName, + string dropChunksSql, + ILogger? logger, + CancellationToken cancellationToken) + { + try + { + await using var connection = await postgres.OpenConnectionAsync(cancellationToken); + using var command = new NpgsqlCommand(dropChunksSql, connection) { CommandTimeout = DeleteTimeoutSeconds }; + + var chunksDropped = 0; + using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + chunksDropped++; + } + + return chunksDropped; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + /* Failure-isolated per table — warned here, then the caller's DELETE fallback runs. */ + logger?.LogWarning("Retention purge (drop_chunks) failed for {Table} — falling back to DELETE: {Message}", + tableName, ex.Message); + return null; + } + } + + /// + /// One table's batched DELETE: re-executes (a + /// statement clearing the oldest one-day slice of expired rows) until a slice deletes nothing — i.e. the + /// table is drained. Returns the total rows deleted across all slices, or null when it failed (warned, + /// sweep continues). Slicing bounds lock/WAL/dead-tuple growth on a large first purge; a small + /// steady-state purge finishes in one slice. The connection and command (with its single bound cutoff + /// parameter) are reused across the loop. + /// + private static async Task PurgeOneAsync( + NpgsqlDataSource postgres, + string tableName, + string deleteSql, + DateTime cutoff, + ILogger? logger, + CancellationToken cancellationToken) + { + try + { + await using var connection = await postgres.OpenConnectionAsync(cancellationToken); + + /* Deleting a whole expired slice from a COMPRESSED chunk (the drop_chunks-failed fallback) + decompresses every affected segment, and TimescaleDB caps that at 100k tuples per DML + transaction by default — a rail against accidental bulk decompression, which a retention + purge deliberately is. Lift it for this connection only. On a store without the extension + the qualified name is accepted as a placeholder GUC, so this is safe everywhere. */ + using (var lift = new NpgsqlCommand( + "SET timescaledb.max_tuples_decompressed_per_dml_transaction = 0", connection)) + { + await lift.ExecuteNonQueryAsync(cancellationToken); + } + + using var command = new NpgsqlCommand(deleteSql, connection) { CommandTimeout = DeleteTimeoutSeconds }; + command.Parameters.AddWithValue(cutoff); + + /* batchSize 1: the time-sliced statement has no row cap, so "fewer than the cap" degenerates + to "deleted zero rows" — a slice that clears anything means older slices may remain. */ + return await DrainBatchesAsync(ct => command.ExecuteNonQueryAsync(ct), batchSize: 1, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + /* Failure-isolated per table — one stuck DELETE must not stop the sweep. */ + logger?.LogWarning("Retention purge failed for {Table}: {Message}", tableName, ex.Message); + return null; + } + } + + /// + /// Repeatedly runs (one capped batched-DELETE execution, returning its + /// rows-affected) and sums the total, stopping when a batch clears fewer than + /// rows — i.e. no expired rows remain and the table is drained. A full-cap batch means there may be more, + /// so it goes again; an exact multiple of the cap terminates on the following empty batch. Pure over the + /// injected executor so the loop-again + termination is unit-testable without a live store. + /// + internal static async Task DrainBatchesAsync( + Func> executeBatch, int batchSize, CancellationToken cancellationToken) + { + var totalDeleted = 0; + while (true) + { + var deleted = await executeBatch(cancellationToken); + totalDeleted += deleted; + + if (deleted < batchSize) + { + break; + } + } + + return totalDeleted; + } +} + +/// +/// The outcome of one sweep: how many tables were touched +/// () and the coarse activity count split into DELETE rows +/// () and dropped Timescale chunks ( — +/// drop_chunks doesn't report per-row counts). is the single headline number the +/// daily log and the on-demand purge_now result report. +/// +public readonly record struct PurgeSummary(int TablesPurged, int RowsDeleted, int ChunksDropped) +{ + /// Rows deleted plus whole chunks dropped — the coarse "how much did this purge remove" count. + public int TotalPurged => RowsDeleted + ChunksDropped; +} diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingSecrets.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingSecrets.cs new file mode 100644 index 000000000..146bba098 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingSecrets.cs @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Text; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// DPAPI protection for SQL-auth passwords in darling.json. LocalMachine scope deliberately — +/// the service runs under a service account, and machine scope lets an administrator encrypt a +/// password interactively (--encrypt-password) that the service account can later decrypt on +/// the same machine. (Lite uses the Windows Credential Manager instead, which is user-profile +/// scoped — right for an interactive app, wrong for a service.) The blob is machine-bound: +/// moving darling.json to another machine requires re-encrypting. +/// +[SupportedOSPlatform("windows")] +public static class DarlingSecrets +{ + private static readonly byte[] s_entropy = Encoding.UTF8.GetBytes("PerformanceMonitor.Darling.v1"); + + public static string Protect(string plaintext) + { + if (plaintext is null) + { + throw new ArgumentNullException(nameof(plaintext)); + } + + var protectedBytes = ProtectedData.Protect( + Encoding.UTF8.GetBytes(plaintext), s_entropy, DataProtectionScope.LocalMachine); + return Convert.ToBase64String(protectedBytes); + } + + public static string Unprotect(string base64Blob) + { + if (string.IsNullOrWhiteSpace(base64Blob)) + { + throw new ArgumentException("Encrypted password blob is empty.", nameof(base64Blob)); + } + + var plainBytes = ProtectedData.Unprotect( + Convert.FromBase64String(base64Blob), s_entropy, DataProtectionScope.LocalMachine); + return Encoding.UTF8.GetString(plainBytes); + } + + /// + /// Resolves a monitored server's SQL-auth password: DPAPI blob preferred, plaintext fallback + /// (dev convenience — callers should warn when is true). + /// + public static string ResolvePassword(MonitoredServer server, out bool usedPlaintext) + { + if (server is null) + { + throw new ArgumentNullException(nameof(server)); + } + + if (!string.IsNullOrWhiteSpace(server.EncryptedPassword)) + { + usedPlaintext = false; + return Unprotect(server.EncryptedPassword); + } + + if (!string.IsNullOrWhiteSpace(server.Password)) + { + usedPlaintext = true; + return server.Password; + } + + throw new InvalidOperationException( + $"Server '{server.DisplayName}' uses sql auth but has neither encryptedPassword nor password."); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs new file mode 100644 index 000000000..c33bea529 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs @@ -0,0 +1,942 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Npgsql; +using PerformanceMonitor.Alerting; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Notifications; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// Stage 4 of the Darling control plane — the SERVICE's self-alerts: the "is my collection actually +/// working" conditions that matter most for an unattended 24/7 headless service where nobody is +/// watching a dashboard. Four conditions — the first three reframe a Dashboard health check onto Darling's +/// own signals; the fourth (Store Disk Pressure) is net-new and guards the service's OWN store — all routed +/// through the SAME the shared alert engine uses (so they inherit its +/// email/webhook delivery, per-fingerprint delivery cooldown, and restart replay) and the SAME +/// config_alert_log history store: +/// +/// Collection Stopped / collector failure — the server's collection_log shows no +/// SUCCESS within a staleness window OR the last N runs all failed (reframes the Dashboard's +/// "Collection Stopped", MainWindow.AlertEngine.cs + NocHealth.GetCollectionStoppedAsync, +/// onto Darling's collection_log instead of msdb Agent-job state). +/// Server Unreachable / Restored — fired on the connect edge in +/// 's loop (online→offline and back), the headless twin of the +/// Dashboard's NotifyOnConnectionLost/Restored (MainWindow.xaml.cs). Uses the +/// Dashboard's exact metric names ("Server Unreachable" / "Server Restored") so the alert history +/// vocabulary and the shared AlertSeverity map (Critical / green RESOLVED) match cross-app. +/// Capture Down — a missing/denied blocking-deadlock XE session, surfaced from the +/// SESSION_MISSING collection_log status the tolerant XE readers now write (reframes the +/// Dashboard's #1086 "Capture Down", NocHealth.GetMissingCaptureSessionsAsync). +/// Store Disk Pressure — the volume hosting the Darling store is nearly full. Unlike the +/// other three (per monitored server), this is a FLEET-level condition polled once per sweep from the +/// store itself (pg_database_size for context) and the store volume's free space: when a headless +/// service's disk fills, collection and every write stop for the WHOLE fleet, and nobody is watching. The +/// flagship-appropriate maintenance backstop the daily time-based purge otherwise lacks — deliberately +/// NOT Lite's 512MB archive-then-reset (Postgres has no single-file INSERT cliff, and a blanket reset +/// would nuke every tenant of the shared store). +/// +/// Each condition is EDGE-TRIGGERED (in-memory active flag + the shared alert cooldown for the polled +/// conditions; a per-server connection state machine for the connect edge) so it fires once on the +/// transition, not every sweep — exactly the Dashboard's _activeXAlert/_lastXAlert shape. +/// On recovery a "…Resumed"/"…Restored"/"…Resolved" row is written to alert history (closing the audit loop +/// the same way the engine's resolution callback now does — see ). +/// Gated on the master alerts.enabled switch — plus, for the connect edge, the V20 +/// notify_connection_changes toggle (Lite's App.NotifyConnectionChanges twin); the +/// collection-stopped / capture-down / disk-pressure thresholds stay sensible hardcoded defaults. +/// +internal sealed class DarlingSelfAlertEvaluator +{ + /* No successful collection within this window (a server that HAS collected before) reads as + stopped — matches the Dashboard's CollectionStaleThresholdMinutes (NocHealth.cs). The frequent + Darling collectors run every ~1 minute, so 30 minutes of no success is unambiguously dead; the + connection-lost alert covers the fast path for an unreachable server, and the consecutive-failure + check below covers the fast path for a server that is connected but erroring every collector. */ + internal static readonly TimeSpan StaleWindow = TimeSpan.FromMinutes(30); + + /* The last N logged runs all failing (no SUCCESS/SKIPPED among them) fires "Collection Stopped" + faster than the staleness backstop when a connected server's collectors are erroring on every + cycle. 10 spans a couple of minutes of total failure across the frequently-scheduled collectors. */ + internal const int ConsecutiveFailureThreshold = 10; + + /* Store Disk Pressure fires when the store volume drops below this percent free — a percentage (not an + absolute floor) so it scales from a small managed box to a large fleet disk; 10% is the universal DBA + "act now" threshold for a database volume, and mirrors the shared engine's target-server low-disk + percent (LowDiskThresholdPercent). Percent-only by design (defaults over speculative config — a GB + floor is a trivial follow-up if an operator ever wants one). The condition no-ops when free space is + undeterminable (a remote BYO store), so it never false-alarms; the managed store's own volume is the + case it exists to protect. */ + internal const double DiskFreeWarnPercent = 10.0; + + private readonly IAlertEngineSettings _settings; + private readonly IAlertDeliverer _deliverer; + private readonly IAlertHistoryStore _historyStore; + private readonly Func _isAlertMuted; + private readonly ILogger? _logger; + private readonly Func _utcNow; + + /* The connection-change notify gate (V20), read live so a store reload takes effect on the next connect + edge. A Func rather than a settings-interface member because NotifyConnectionChanges is a Darling-specific + concrete DarlingAlertSettings knob, not on the shared IAlertEngineSettings (the DeliveryMode precedent); + the Func seam also keeps the test fakes — which implement only IAlertEngineSettings — untouched. Defaults + to always-on when unsupplied (preserving the pre-V20 behavior). */ + private readonly Func _notifyConnectionChanges; + + /* Edge state, keyed by the engine's serverKey (the server_id as an invariant string — the same + identity the deliverer/history/watermark stores use). In-memory only, exactly like the shared + engine's active-condition flags; the restart replay protection is the deliverer's own + history-seeded email/webhook cooldown, not these. */ + private readonly ConcurrentDictionary _activeCollectionStopped = new(); + private readonly ConcurrentDictionary _lastCollectionStoppedAlert = new(); + private readonly ConcurrentDictionary _activeCaptureDown = new(); + private readonly ConcurrentDictionary _lastCaptureDownAlert = new(); + private readonly ConcurrentDictionary _activeAgentDown = new(); + private readonly ConcurrentDictionary _lastAgentDownAlert = new(); + private readonly ConcurrentDictionary _connectionState = new(); + + /* Store Disk Pressure edge state. FLEET-level (one shared store, not per server), so it is keyed by a + single fixed sentinel (DiskKey) rather than a serverId — never dropped by Forget (that is per-server). + Dictionaries (not a plain bool) purely to reuse the same TryRemove-recovery + CooldownElapsed helpers + the per-server conditions use. */ + private readonly ConcurrentDictionary _activeDiskPressure = new(); + private readonly ConcurrentDictionary _lastDiskPressureAlert = new(); + + /// The fixed key for the fleet-level Store Disk Pressure edge (not a real server). + private const string DiskKey = "store"; + + /* Compression-job self-heal edge state (#1581). FLEET-level like disk pressure (one shared store), but + MULTI-keyed by job_id (a store has many compression policy jobs). The state is the re-arm-once/escalate + machine: ReArmed = detected + re-armed once this episode (a later still-stuck reading is a RE-HANG); + Escalated = re-hung after self-heal, or the re-arm itself failed — a human signal, so the service STOPS + re-arming and only re-fires the CRITICAL on cooldown. An entry is dropped (and a resolution row written) + when the job stops being stuck. Keyed by the CompressionKeyPrefix + job_id so the alert serverKey never + collides with a real server_id (an int hash) — the deliverer's #1236 int.TryParse override no-ops on it, + exactly like the non-numeric DiskKey. */ + private enum CompressionJobHealth { ReArmed, Escalated } + private readonly ConcurrentDictionary _compressionJobState = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _lastCompressionJobAlert = new(StringComparer.Ordinal); + + /// The alert metric name every compression-job self-alert fires under (first-detection + escalation + /// re-fires share it, so the deliverer's per-metric cooldown and the recovery resolution correlate cleanly; + /// escalation is distinguished by the message, not a second metric name). + private const string CompressionJobMetric = "Compression Job Stuck"; + + /// Prefixes the fleet-level compression-job alert serverKey so it never parses as a server_id. + private const string CompressionKeyPrefix = "compressjob:"; + + /* Whether the service has successfully connected to this server at least once THIS process-run. Guards + collection-stopped: unlike the Dashboard (whose target-side collection_log keeps filling regardless of + the app), Darling IS the collector, so the service's own downtime makes collection_log stale. Without + this guard a service restart after >30 min of downtime would false-alarm "Collection Stopped" on a + perfectly healthy server before its first fresh collection lands. Gating on a prior successful connect + makes collection-stopped a clean "was collecting, then stopped" transition (the same philosophy as the + connection-lost edge and the Dashboard's skip-first-check) rather than a judgement on pre-restart data. */ + private readonly ConcurrentDictionary _hasBeenOnline = new(); + + public DarlingSelfAlertEvaluator( + IAlertEngineSettings settings, + IAlertDeliverer deliverer, + IAlertHistoryStore historyStore, + Func isAlertMuted, + ILogger? logger = null, + Func? utcNow = null, + Func? notifyConnectionChanges = null) + { + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _deliverer = deliverer ?? throw new ArgumentNullException(nameof(deliverer)); + _historyStore = historyStore ?? throw new ArgumentNullException(nameof(historyStore)); + _isAlertMuted = isAlertMuted ?? throw new ArgumentNullException(nameof(isAlertMuted)); + _logger = logger; + _utcNow = utcNow ?? (() => DateTime.UtcNow); + _notifyConnectionChanges = notifyConnectionChanges ?? (() => true); + } + + private enum ConnectionState + { + /* Never yet observed — the baseline. Unknown→online/offline never fires (mirrors the + Dashboard's "skip the first check" so a server that is simply down at startup does not + page; only a transition FROM a known state does). */ + Unknown, + Online, + Offline + } + + /* ---------------- store-polled self-alerts (collection-stopped + capture-down) ---------------- */ + + /// + /// Evaluates the store-polled self-alerts for one server from its collection_log. Gated on the + /// master alerts switch (mirrors the engine's early return). Collection-stopped runs for EVERY server + /// whether or not it is currently connected (an unreachable server has stopped collecting — that is + /// exactly the case to catch); capture-down runs only for a connected server (its XE collectors only + /// run then). Failure-isolated per condition so a bad store read never breaks the loop. + /// + public async Task EvaluateStoreAlertsAsync( + NpgsqlDataSource postgres, int serverId, string serverName, bool connected, CancellationToken cancellationToken) + { + if (!_settings.AlertsEnabled) + { + return; + } + + /* Only judge collection-stopped once the service has actually collected from this server this run + (see _hasBeenOnline) — otherwise pre-restart / pre-re-add stale rows would false-alarm before the + first fresh collection lands. */ + if (_hasBeenOnline.ContainsKey(Key(serverId))) + { + try + { + var (lastSuccess, recentRuns, recentSuccess) = + await ReadCollectionSignalsAsync(postgres, serverId, ConsecutiveFailureThreshold, cancellationToken); + bool stopped = IsCollectionStopped(lastSuccess, recentRuns, recentSuccess, _utcNow(), out var reason); + await ApplyCollectionStoppedAsync(serverId, serverName, stopped, reason, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger?.LogError("[{Server}] Collection-health self-alert failed: {Message}", serverName, ex.Message); + } + } + + if (!connected) + { + return; + } + + try + { + var missing = await ReadMissingCaptureSessionsAsync(postgres, serverId, cancellationToken); + await ApplyCaptureDownAsync(serverId, serverName, missing, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger?.LogError("[{Server}] Capture-down self-alert failed: {Message}", serverName, ex.Message); + } + + try + { + /* Agent Not Running (#1433 Phase 2): the collected agent_status snapshot says the target's SQL + Agent service is stopped. Only a FRESH reading judges — a stale row (collection lagging) yields + null, so the collection-stopped alert owns staleness and this never false-alarms on old data. */ + var (agentCollectionTimeUtc, agentRunning) = await ReadLatestAgentStatusAsync(postgres, serverId, cancellationToken); + bool? freshRunning = agentRunning.HasValue + && agentCollectionTimeUtc.HasValue + && _utcNow() - agentCollectionTimeUtc.Value < StaleWindow + ? agentRunning + : null; + await ApplyAgentNotRunningAsync(serverId, serverName, freshRunning, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger?.LogError("[{Server}] Agent-not-running self-alert failed: {Message}", serverName, ex.Message); + } + } + + /// + /// Pure collection-stopped decision from the three store signals — no I/O, so it pins directly. + /// A NEVER-succeeded server ( null) is deliberately NOT flagged by + /// the staleness backstop: a freshly-added or never-connected server must not be misread as "stopped" + /// (the connection-lost alert covers that). It IS flagged by the consecutive-failure path if it has + /// actually run and failed N times. + /// + internal static bool IsCollectionStopped( + DateTime? lastSuccessUtc, int recentRunCount, int recentSuccessCount, DateTime nowUtc, out string reason) + { + /* Fast path: the most-recent N runs all failed. */ + if (recentRunCount >= ConsecutiveFailureThreshold && recentSuccessCount == 0) + { + reason = $"The last {recentRunCount.ToString(CultureInfo.InvariantCulture)} collector runs all failed — no data is landing."; + return true; + } + + /* Backstop: a server that HAS collected before but hasn't succeeded within the staleness window. */ + if (lastSuccessUtc.HasValue && nowUtc - lastSuccessUtc.Value >= StaleWindow) + { + int minutes = (int)(nowUtc - lastSuccessUtc.Value).TotalMinutes; + reason = $"No successful collection in {minutes.ToString(CultureInfo.InvariantCulture)} minutes — the collectors are failing or the server is unreachable."; + return true; + } + + reason = ""; + return false; + } + + /// + /// Edge-applies the collection-stopped decision (mirrors the Dashboard's + /// _activeCollectionStoppedAlert/_lastCollectionStoppedAlert): fire once on entry, re-fire + /// only after the alert cooldown while it persists, and write ONE "Collection Resumed" history row on + /// recovery. Testable directly with a recording deliverer + a controllable clock. + /// + internal async Task ApplyCollectionStoppedAsync( + int serverId, string serverName, bool stopped, string reason, CancellationToken cancellationToken) + { + var key = Key(serverId); + var now = _utcNow(); + + if (stopped) + { + _activeCollectionStopped[key] = true; + if (CooldownElapsed(_lastCollectionStoppedAlert, key, now)) + { + _lastCollectionStoppedAlert[key] = now; + await FireAsync( + key, serverName, "Collection Stopped", reason, "collecting", + detail: reason + " A headless service has no dashboard to watch, so this is the primary " + + "signal that a server's data has gone stale. Check the service log and the server's " + + "reachability, credentials, and collector permissions.", + severity: AlertSeverityLevel.Critical, + shortMessage: reason, cancellationToken); + } + } + else if (_activeCollectionStopped.TryRemove(key, out var was) && was) + { + await RecordResolutionAsync(new AlertResolution( + key, serverName, "Collection Stopped", + "Collection Resumed", $"{serverName}: Data collection is running again"), cancellationToken); + } + } + + /// + /// Edge-applies capture-down (mirrors the Dashboard's _activeCaptureDownAlert): gated on + /// blocking OR deadlock alerts being enabled (the alerts this protects — if the operator wants those, + /// they need to know when the data feeding them stops existing). Fire once on entry, re-fire only after + /// the cooldown, write ONE "Capture Restored" row on recovery. + /// + internal async Task ApplyCaptureDownAsync( + int serverId, string serverName, IReadOnlyList missing, CancellationToken cancellationToken) + { + if (!_settings.BlockingEnabled && !_settings.DeadlockEnabled) + { + return; + } + + var key = Key(serverId); + var now = _utcNow(); + + if (missing.Count > 0) + { + _activeCaptureDown[key] = true; + if (CooldownElapsed(_lastCaptureDownAlert, key, now)) + { + _lastCaptureDownAlert[key] = now; + var list = string.Join(" and ", missing); + await FireAsync( + key, serverName, "Capture Down", list, "session running", + detail: $"The {list} Extended Events session(s) are missing and could not be created. " + + "Blocking/deadlock data is NOT being captured, so those alerts can never fire. Check the " + + "collection log for the SESSION_MISSING detail (usually a permissions problem: " + + "ALTER ANY EVENT SESSION on-prem, CREATE ANY DATABASE EVENT SESSION on Azure SQL DB).", + severity: AlertSeverityLevel.Critical, + shortMessage: $"{list} capture is not running — XE session missing", cancellationToken); + } + } + else if (_activeCaptureDown.TryRemove(key, out var was) && was) + { + await RecordResolutionAsync(new AlertResolution( + key, serverName, "Capture Down", + "Capture Restored", $"{serverName}: Blocking/deadlock capture is running again"), cancellationToken); + } + } + + /// + /// Edge-applies "Agent Not Running" (#1433 Phase 2) from the target's latest FRESH agent_status snapshot + /// (mirrors the sibling per-server conditions' edge shape): fire once on entry, re-fire only after the + /// alert cooldown while the Agent stays stopped, and write ONE "Agent Restarted" history row on recovery. + /// is null when there is no fresh reading (never collected, or the + /// snapshot is stale — the collection-stopped alert owns staleness), in which case the standing state is + /// left untouched so a lagging feed neither fires nor spuriously clears. Gated on the master alerts switch. + /// Testable directly with a recording deliverer + a controllable clock. + /// + internal async Task ApplyAgentNotRunningAsync( + int serverId, string serverName, bool? agentRunningFresh, CancellationToken cancellationToken) + { + if (!_settings.AlertsEnabled) + { + return; + } + + /* No fresh reading — don't judge (neither fire nor clear a standing alert). */ + if (agentRunningFresh is not bool running) + { + return; + } + + var key = Key(serverId); + var now = _utcNow(); + + if (!running) + { + _activeAgentDown[key] = true; + if (CooldownElapsed(_lastAgentDownAlert, key, now)) + { + _lastAgentDownAlert[key] = now; + await FireAsync( + key, serverName, "Agent Not Running", "Stopped", "Running", + detail: "The SQL Server Agent service on this server is stopped. Scheduled jobs — backups, " + + "index and statistics maintenance, integrity checks, log shipping — will NOT run until it " + + "is restarted, and a headless service has no dashboard to warn you. Start the SQL Server " + + "Agent service and set its startup type to Automatic so it survives a host reboot.", + severity: AlertSeverityLevel.Critical, + shortMessage: "SQL Server Agent service is stopped — scheduled jobs will not run", cancellationToken); + } + } + else if (_activeAgentDown.TryRemove(key, out var was) && was) + { + await RecordResolutionAsync(new AlertResolution( + key, serverName, "Agent Not Running", + "Agent Restarted", $"{serverName}: SQL Server Agent service is running again"), cancellationToken); + } + } + + /* ---------------- connection lost / restored (connect-edge driven) ---------------- */ + + /// + /// Applies a connect-attempt outcome for one server and fires the connection edge. Called from the + /// loop's success (online) and failure (offline) branches. + /// The per-server state machine fires "Server Unreachable" only on a genuine online→offline transition + /// and "Server Restored" only on offline→online — a repeated failed reconnect (offline→offline) does + /// NOT re-fire, and the first-ever outcome (Unknown→online/offline) is a silent baseline, mirroring the + /// Dashboard's skip-first-check. Both edges are FULL alerts (email/webhook, Dashboard parity — the + /// restore is not a silent resolution). State is tracked even while alerts are disabled so re-enabling + /// resumes from the correct baseline; only delivery is gated — on the master switch AND the V20 + /// connection-change notify toggle (). + /// The delivery portion is failure-isolated (a throwing mute-check can't propagate out of the un-guarded + /// sweep loop and stop the fleet); the state machine advances first, so isolation never corrupts the edge. + /// + public async Task ApplyConnectionOutcomeAsync( + int serverId, string serverName, bool online, string? error, CancellationToken cancellationToken) + { + var key = Key(serverId); + var previous = _connectionState.TryGetValue(key, out var s) ? s : ConnectionState.Unknown; + _connectionState[key] = online ? ConnectionState.Online : ConnectionState.Offline; + + /* Record that collection is now possible for this server this run — arms the collection-stopped + check (tracked regardless of the alerts switch, so re-enabling has a correct baseline). */ + if (online) + { + _hasBeenOnline[key] = true; + } + + /* Delivery is gated on the master switch AND the connection-change notify toggle (V20); the state + machine above already advanced, so toggling either off then back on resumes from the correct + baseline rather than replaying a stale edge — the same "track always, deliver conditionally" + posture the master switch already had. */ + if (!_settings.AlertsEnabled || !_notifyConnectionChanges()) + { + return; + } + + /* Isolate the DELIVERY portion (the state machine already advanced above, so wrapping only the fire can + never corrupt the edge). FireAsync's pre-deliver mute-check seam (_isAlertMuted → a mute rule's + Matches()) is NOT internally isolated, and this method is called straight from the un-guarded sweep + loop (DarlingWorker.TryConnectAsync) — whose OWN catch RE-CALLS this with online:false — so a throwing + mute-check here would propagate out of that catch and stop collection for the whole fleet. Same + isolation the sibling self-alerts use (EvaluateStoreAlertsAsync / EvaluateDiskPressureAsync). + Cancellation still propagates. */ + try + { + if (online && previous == ConnectionState.Offline) + { + /* Severity null → the shared AlertSeverity map renders "Server Restored" green/RESOLVED. */ + await FireAsync( + key, serverName, "Server Restored", "Online", "Online", + detail: $"{serverName}: connection restored", + severity: null, + shortMessage: "connection restored", cancellationToken); + } + else if (!online && previous == ConnectionState.Online) + { + var reason = string.IsNullOrWhiteSpace(error) ? "Connection failed" : error!; + await FireAsync( + key, serverName, "Server Unreachable", reason, "Online", + detail: reason, + severity: AlertSeverityLevel.Critical, + shortMessage: reason, cancellationToken); + } + /* previous == Unknown → baseline only (no fire). */ + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger?.LogError("[{Server}] Connection-change self-alert delivery failed: {Message}", serverName, ex.Message); + } + } + + /* ---------------- store disk pressure (fleet-level, polled) ---------------- */ + + /// + /// Pure disk-pressure decision: the store volume is under pressure when its FREE space is below + /// of the volume total. No I/O, so it pins directly. A non-positive + /// total is treated as "can't tell" (false — the caller also guards this). The percentage scales across + /// disk sizes; see the constant for why it is percent-only. + /// + internal static bool IsDiskPressure(long freeBytes, long totalBytes, out string reason) + { + if (totalBytes <= 0) + { + reason = ""; + return false; + } + + double percentFree = (double)freeBytes / totalBytes * 100.0; + if (percentFree < DiskFreeWarnPercent) + { + reason = $"The monitor store's disk volume has only {percentFree.ToString("0.#", CultureInfo.InvariantCulture)}% free ({FormatGb(freeBytes)} of {FormatGb(totalBytes)})."; + return true; + } + + reason = ""; + return false; + } + + /// + /// The isolating entry point the worker's disk-pressure sweep calls — the fleet-level twin of + /// for the store-polled conditions. Wraps + /// in the SAME failure isolation the sibling store-alerts use, so a + /// throwing seam — most notably the pre-deliver mute check (_isAlertMuted → a mute rule's + /// Matches), which unlike Deliver/RecordResolution is NOT internally isolated — can never propagate + /// out of the (otherwise un-guarded) collection sweep loop and stop collection for the whole fleet. + /// Cancellation still propagates. + /// + public async Task EvaluateDiskPressureAsync( + long? freeBytes, long? totalBytes, long? storeSizeBytes, CancellationToken cancellationToken) + { + try + { + await ApplyDiskPressureAsync(freeBytes, totalBytes, storeSizeBytes, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger?.LogError("Store disk-pressure self-alert failed: {Message}", ex.Message); + } + } + + /// + /// Edge-applies the fleet-level Store Disk Pressure condition from a store-volume sample: fire once on + /// entry, re-fire only after the alert cooldown while it persists, and write ONE "Store Disk Pressure + /// Resolved" history row on recovery (mirrors the per-server conditions' edge shape). Gated on the master + /// alerts switch. NO-OPS when free/total are null — a remote BYO store whose volume the service can't see — + /// so it never false-alarms; the managed store's own volume is what it exists to protect. + /// (pg_database_size) is context for the alert text only, never the + /// trigger. Internal (tested directly, like the sibling Apply methods); the worker calls the isolating + /// . Testable directly with a recording deliverer + a controllable clock. + /// + internal async Task ApplyDiskPressureAsync( + long? freeBytes, long? totalBytes, long? storeSizeBytes, CancellationToken cancellationToken) + { + if (!_settings.AlertsEnabled) + { + return; + } + + /* Can't determine the store volume's free space (remote BYO store, or the drive was not ready) — no + signal, so neither fire nor clear a standing alert. */ + if (freeBytes is not long free || totalBytes is not long total || total <= 0) + { + return; + } + + var now = _utcNow(); + bool pressure = IsDiskPressure(free, total, out var reason); + + if (pressure) + { + _activeDiskPressure[DiskKey] = true; + if (CooldownElapsed(_lastDiskPressureAlert, DiskKey, now)) + { + _lastDiskPressureAlert[DiskKey] = now; + var storeText = storeSizeBytes is long size ? $" The store currently holds {FormatGb(size)}." : ""; + await FireAsync( + DiskKey, "Monitor Store", "Store Disk Pressure", reason, + $"{DiskFreeWarnPercent.ToString("0.#", CultureInfo.InvariantCulture)}% free", + detail: reason + storeText + " When the store volume fills, collection and every write stop " + + "for the WHOLE fleet, and a headless service has no dashboard to warn you. Free space on the " + + "store volume, shorten retention (config_collector_schedules), enable TimescaleDB compression, " + + "or move the store to a larger disk.", + severity: AlertSeverityLevel.Critical, + shortMessage: reason, cancellationToken); + } + } + else if (_activeDiskPressure.TryRemove(DiskKey, out var was) && was) + { + await RecordResolutionAsync(new AlertResolution( + DiskKey, "Monitor Store", "Store Disk Pressure", + "Store Disk Pressure Resolved", "Monitor store volume free space recovered"), cancellationToken); + } + } + + /* ---------------- compression-job self-heal (fleet-level, polled — #1581) ---------------- */ + + /// + /// The isolating entry point the worker's hourly compression-job health sweep calls — the fleet-level twin + /// of . Wraps in the SAME + /// failure isolation the sibling store-alerts use, so a throwing seam — the pre-deliver mute check, or the + /// caller-supplied re-arm delegate — can never propagate out of the (otherwise un-guarded) collection sweep + /// loop and stop collection for the whole fleet. Cancellation still propagates. + /// + public async Task EvaluateCompressionJobsAsync( + IReadOnlyList stuckJobs, + Func> rearmAsync, + CancellationToken cancellationToken) + { + try + { + await ApplyCompressionJobsStuckAsync(stuckJobs, rearmAsync, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger?.LogError("Compression-job health self-alert failed: {Message}", ex.Message); + } + } + + /// + /// Edge-applies the fleet-level compression-job self-heal machine (#1581) from the set of currently-stuck + /// compression jobs the worker detected. Per job, in one check: + /// + /// First detection — re-arm it ONCE ( → alter_job), then + /// FIRE a CRITICAL "self-healed" alert. If the re-arm itself fails (usually a permission problem the service + /// cannot fix), go straight to Escalated and FIRE an "auto-re-arm FAILED" alert instead — so we neither loop + /// alter_job nor stay silent. + /// Re-hang (still stuck after a prior self-heal) — ESCALATE: FIRE a CRITICAL "re-hung after + /// self-heal" alert and STOP re-arming (looping alter_job on a job that re-hangs is a product-bug signal for + /// a human). + /// Already escalated — never re-arm; re-fire the CRITICAL only on the alert cooldown. + /// + /// A job that is no longer stuck has RECOVERED: its state is dropped and one "Compression Job Recovered" + /// resolution row is written (the sibling conditions' edge shape). Gated on the master alerts switch. + /// Re-arm happens at most ONCE per job per check (only on the first-detection transition). Internal so it + /// pins directly with a recording deliverer, a controllable clock, and a fake re-arm delegate. + /// + internal async Task ApplyCompressionJobsStuckAsync( + IReadOnlyList stuckJobs, + Func> rearmAsync, + CancellationToken cancellationToken) + { + if (!_settings.AlertsEnabled) + { + return; + } + + var now = _utcNow(); + var stillStuck = new HashSet(StringComparer.Ordinal); + + foreach (var job in stuckJobs) + { + var key = job.JobId.ToString(CultureInfo.InvariantCulture); + stillStuck.Add(key); + + var label = string.IsNullOrEmpty(job.HypertableName) + ? $"compression job {key}" + : $"compression job {key} on {job.HypertableName}"; + + if (!_compressionJobState.TryGetValue(key, out var state)) + { + /* First detection this episode: re-arm ONCE, then alert on the outcome. */ + bool rearmed = await rearmAsync(job.JobId); + _lastCompressionJobAlert[key] = now; + if (rearmed) + { + _compressionJobState[key] = CompressionJobHealth.ReArmed; + await FireAsync( + CompressionKeyPrefix + key, "Monitor Store", CompressionJobMetric, + job.Reason, "running on schedule", + detail: $"TimescaleDB {label} was stuck ({job.Reason}) and has been automatically re-armed " + + "(alter_job next_start => now). A stuck compression policy halts the store's archival tier, so " + + "uncompressed data grows without bound until the disk fills and collection stops for the WHOLE " + + "fleet, and a headless service has no dashboard to warn you. If it re-hangs the service will " + + "escalate and stop auto-re-arming — investigate the TimescaleDB background-worker health.", + severity: AlertSeverityLevel.Critical, + shortMessage: $"{label} was stuck — auto-re-armed", cancellationToken); + } + else + { + /* The re-arm failed — usually the store login does not own the job. Treat as escalated so we + never loop alter_job on it, and page: a human must re-arm it (or grant ownership). */ + _compressionJobState[key] = CompressionJobHealth.Escalated; + await FireAsync( + CompressionKeyPrefix + key, "Monitor Store", CompressionJobMetric, + job.Reason, "running on schedule", + detail: $"TimescaleDB {label} is stuck ({job.Reason}) and the service could NOT re-arm it — " + + "alter_job failed, usually because the store login does not own the job. Compression is halted, " + + "so the store will grow without bound until the disk fills. Re-arm it manually as the store owner " + + "(SELECT alter_job(, next_start => now())), or grant ownership, then investigate why it hung.", + severity: AlertSeverityLevel.Critical, + shortMessage: $"{label} stuck — auto-re-arm FAILED", cancellationToken); + } + } + else if (state == CompressionJobHealth.ReArmed) + { + /* Still stuck after last check's self-heal = a RE-HANG. Escalate and STOP re-arming (looping + alter_job on a job that re-hangs just churns — it is a product-bug signal for a human). */ + _compressionJobState[key] = CompressionJobHealth.Escalated; + _lastCompressionJobAlert[key] = now; + await FireAsync( + CompressionKeyPrefix + key, "Monitor Store", CompressionJobMetric, + job.Reason, "running on schedule", + detail: $"TimescaleDB {label} is STILL stuck ({job.Reason}) after an automatic re-arm last cycle — it " + + "re-hung, so the service has STOPPED auto-re-arming it. This is a product-bug signal: the compression " + + "background worker is failing to make progress. Investigate the PostgreSQL/TimescaleDB logs and the " + + "background-worker settings; compression stays halted until it is fixed.", + severity: AlertSeverityLevel.Critical, + shortMessage: $"{label} re-hung after self-heal — escalated", cancellationToken); + } + else + { + /* Already escalated: never re-arm again; keep paging on the cooldown while it stays stuck. */ + if (CooldownElapsed(_lastCompressionJobAlert, key, now)) + { + _lastCompressionJobAlert[key] = now; + await FireAsync( + CompressionKeyPrefix + key, "Monitor Store", CompressionJobMetric, + job.Reason, "running on schedule", + detail: $"TimescaleDB {label} remains stuck ({job.Reason}) after escalation — still not compressing. " + + "Manual intervention is required; the service will not auto-re-arm it.", + severity: AlertSeverityLevel.Critical, + shortMessage: $"{label} still stuck after escalation", cancellationToken); + } + } + } + + /* Recovery: any job we were tracking that is no longer stuck has recovered — drop its state and write + one resolution row (the sibling conditions' edge shape). ToList() so the removal does not mutate the + collection under enumeration. */ + foreach (var key in _compressionJobState.Keys.ToList()) + { + if (stillStuck.Contains(key)) + { + continue; + } + + _compressionJobState.TryRemove(key, out _); + _lastCompressionJobAlert.TryRemove(key, out _); + await RecordResolutionAsync(new AlertResolution( + CompressionKeyPrefix + key, "Monitor Store", CompressionJobMetric, + "Compression Job Recovered", + $"TimescaleDB compression job {key} is running on schedule again"), cancellationToken); + } + } + + /// Drops all edge state for a server removed from the monitored set (reconcile), so a later + /// re-add starts fresh at the Unknown baseline rather than inheriting a stale connection/active flag. + public void Forget(int serverId) + { + var key = Key(serverId); + _activeCollectionStopped.TryRemove(key, out _); + _lastCollectionStoppedAlert.TryRemove(key, out _); + _activeCaptureDown.TryRemove(key, out _); + _lastCaptureDownAlert.TryRemove(key, out _); + _activeAgentDown.TryRemove(key, out _); + _lastAgentDownAlert.TryRemove(key, out _); + _connectionState.TryRemove(key, out _); + _hasBeenOnline.TryRemove(key, out _); + } + + /* ---------------- store reads ---------------- */ + + /// + /// One round trip for the collection-stopped signals: the newest SUCCESS/SKIPPED time across all of the + /// server's collectors (a SKIPPED is a healthy no-op, matching the viewer's GetCollectionHealthAsync), + /// plus — over the most recent logged runs — the run count and the + /// success count (feeding the consecutive-failure fast path). Static + parameterized so the gated live + /// test can seed rows and assert the raw signals directly. + /// + internal static async Task<(DateTime? LastSuccessUtc, int RecentRunCount, int RecentSuccessCount)> ReadCollectionSignalsAsync( + NpgsqlDataSource postgres, int serverId, int recentWindow, CancellationToken cancellationToken) + { + await using var connection = await postgres.OpenConnectionAsync(cancellationToken); + using var command = new NpgsqlCommand(@" +SELECT + (SELECT MAX(collection_time) + FROM collection_log + WHERE server_id = $1 + AND status IN ('SUCCESS', 'SKIPPED')) AS last_success, + (SELECT COUNT(*) + FROM (SELECT log_id FROM collection_log WHERE server_id = $1 ORDER BY log_id DESC LIMIT $2) r) AS recent_runs, + (SELECT COUNT(*) + FROM (SELECT status FROM collection_log WHERE server_id = $1 ORDER BY log_id DESC LIMIT $2) r + WHERE r.status IN ('SUCCESS', 'SKIPPED')) AS recent_success", connection); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(recentWindow); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + { + return (null, 0, 0); + } + + DateTime? lastSuccess = reader.IsDBNull(0) + ? null + : DateTime.SpecifyKind(reader.GetDateTime(0), DateTimeKind.Utc); + int recentRuns = reader.IsDBNull(1) ? 0 : Convert.ToInt32(reader.GetValue(1), CultureInfo.InvariantCulture); + int recentSuccess = reader.IsDBNull(2) ? 0 : Convert.ToInt32(reader.GetValue(2), CultureInfo.InvariantCulture); + return (lastSuccess, recentRuns, recentSuccess); + } + + /// + /// The blocking/deadlock XE collectors whose LATEST run logged SESSION_MISSING — the session is + /// absent and couldn't be created, so capture is non-functional even though the tolerant reader "succeeds" + /// with zero rows. The Darling twin of the Dashboard's GetMissingCaptureSessionsAsync, on Darling's + /// collector names. Returns the friendly capture names ("Blocking" / "Deadlock"). + /// + internal static async Task> ReadMissingCaptureSessionsAsync( + NpgsqlDataSource postgres, int serverId, CancellationToken cancellationToken) + { + var missing = new List(); + + await using var connection = await postgres.OpenConnectionAsync(cancellationToken); + using var command = new NpgsqlCommand(@" +SELECT x.collector_name +FROM +( + SELECT + cl.collector_name, + cl.status, + ROW_NUMBER() OVER (PARTITION BY cl.collector_name ORDER BY cl.log_id DESC) AS n + FROM collection_log AS cl + WHERE cl.server_id = $1 + AND cl.collector_name IN ('deadlocks', 'blocked_process_report') +) AS x +WHERE x.n = 1 +AND x.status = 'SESSION_MISSING' +ORDER BY x.collector_name", connection); + command.Parameters.AddWithValue(serverId); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var collectorName = reader.GetString(0); + missing.Add(collectorName == "deadlocks" ? "Deadlock" : "Blocking"); + } + + return missing; + } + + /// + /// The target's latest collected SQL Agent status (#1433 Phase 2): the newest agent_status row's + /// collection time (UTC) and its agent_running flag. Returns (null, null) when the collector has + /// never landed a row for the server (Azure SQL DB, msdb denied, or not yet collected) — the caller reads + /// that as "no signal" and does not judge. Static + parameterized so the gated live test can seed a row + /// and assert the raw signal. + /// + internal static async Task<(DateTime? CollectionTimeUtc, bool? AgentRunning)> ReadLatestAgentStatusAsync( + NpgsqlDataSource postgres, int serverId, CancellationToken cancellationToken) + { + await using var connection = await postgres.OpenConnectionAsync(cancellationToken); + using var command = new NpgsqlCommand(@" +SELECT agent_running, collection_time +FROM agent_status +WHERE server_id = $1 +ORDER BY collection_time DESC +LIMIT 1", connection); + command.Parameters.AddWithValue(serverId); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + { + return (null, null); + } + + bool? running = reader.IsDBNull(0) ? null : reader.GetBoolean(0); + DateTime? collectionTime = reader.IsDBNull(1) + ? null + : DateTime.SpecifyKind(reader.GetDateTime(1), DateTimeKind.Utc); + return (collectionTime, running); + } + + /* ---------------- shared helpers ---------------- */ + + /// + /// Builds a resolved-flavored history row for a recovered condition — the Darling twin of the + /// Dashboard's explicit "…Cleared/Resolved/Restored" RecordAlert rows. Used by BOTH this + /// evaluator's self-alert recoveries (Collection Resumed / Capture Restored) and the shared alert + /// engine's resolution callback (CPU Resolved, Blocking Cleared, …) so an operator reviewing alert + /// history sees the paired "Detected" then "Cleared" entries. Never email/webhook (a resolution has no + /// send channel — the deliverer's fire path is untouched): recorded as a delivered tray/history row. + /// + public static AlertHistoryRecord BuildResolutionRecord(AlertResolution resolution) => new( + resolution.ServerKey, resolution.ServerName, resolution.Title, + CurrentValueText: "resolved", ThresholdValueText: "", + NumericCurrentValue: null, NumericThresholdValue: null, + AlertSent: true, NotificationType: "tray", SendError: null, + Muted: false, DetailText: resolution.Message, ContextJson: null); + + private async Task FireAsync( + string serverKey, string serverName, string metricName, string currentValue, string thresholdValue, + string detail, AlertSeverityLevel? severity, string shortMessage, CancellationToken cancellationToken) + { + /* Same mute treatment as the engine: a muted self-alert is still recorded (flagged muted) but its + channels are skipped — the deliverer honors AlertOutcome.Muted. */ + bool muted = _isAlertMuted(new AlertMuteContext { ServerName = serverName, MetricName = metricName }); + + await _deliverer.DeliverAsync(new AlertOutcome( + serverKey, serverName, metricName, currentValue, thresholdValue, + Context: null, DetailText: detail, + NumericCurrentValue: null, NumericThresholdValue: null, + Muted: muted, Severity: severity, ShortMessage: shortMessage), cancellationToken); + } + + private async Task RecordResolutionAsync(AlertResolution resolution, CancellationToken cancellationToken) + { + _logger?.LogInformation("[{Server}] {Title}: {Message}", + resolution.ServerName, resolution.Title, resolution.Message); + try + { + await _historyStore.RecordAlertAsync(BuildResolutionRecord(resolution)); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + /* An audit-row write must never break the loop (RecordAlertAsync is already failure-isolated; + this is belt-and-suspenders for any other IAlertHistoryStore). */ + _logger?.LogError("Failed to record resolution '{Title}': {Message}", resolution.Title, ex.Message); + } + } + + private bool CooldownElapsed(ConcurrentDictionary lastFired, string key, DateTime now) => + !lastFired.TryGetValue(key, out var last) + || now - last >= TimeSpan.FromMinutes(_settings.CooldownMinutes); + + private static string Key(int serverId) => serverId.ToString(CultureInfo.InvariantCulture); + + /// Human-readable GiB for disk-pressure alert text (binary GiB, 2 dp). + private static string FormatGb(long bytes) => + (bytes / 1024.0 / 1024.0 / 1024.0).ToString("0.##", CultureInfo.InvariantCulture) + " GB"; +} diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingServerConnector.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingServerConnector.cs new file mode 100644 index 000000000..704b68f8e --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingServerConnector.cs @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// Per-server runtime state the collection loop carries: the resolved connection string, the +/// probed target facts (engine edition, major version — the same detection Lite's ServerManager +/// runs), and the shared-identity server id. +/// +public sealed class ServerRuntime +{ + public required MonitoredServer Config { get; init; } + + public required string ConnectionString { get; init; } + + public required CollectorTargetInfo Target { get; init; } + + /// host[:database][:RO] — the shared identity rule, hashed to . + public required string StorageName { get; init; } + + public required int ServerId { get; init; } + + public bool HasMsdbAccess { get; init; } + + public bool IsAwsRds { get; init; } + + /// + /// The raw SERVERPROPERTY('EngineEdition') value from the detection probe — 1 Personal, + /// 2 Standard, 3 Enterprise, 4 Express, 5 Azure SQL DB, 8 Managed Instance, etc. — carried + /// whole so the servers registry records the real edition, not just the 5/8 classification + /// booleans on . + /// + public int EngineEdition { get; init; } +} + +/// +/// Opens the first connection to a monitored server and probes the target facts the collector +/// definitions branch on. The detection query is verbatim from Lite's ServerManager connectivity +/// check, so both SKUs classify a server identically. +/// +public static class DarlingServerConnector +{ + /* The scalar detection query - verbatim (modulo whitespace) from Lite's ServerManager + connectivity check. Deliberately NO FROM sys.dm_os_sys_info: that DMV requires VIEW DATABASE + STATE, which an Azure SQL DB monitoring login often lacks, so edition detection must not + depend on it (#1535). sqlserver_start_time - the one column that needs the DMV - is not read + here (the service never surfaces a start time), so unlike Lite/Dashboard no best-effort + start-time read is needed. Columns: 0 sql_version, 1 major_version, 2 utc_offset, + 3 engine_edition, 4 is_aws_rds, 5 has_msdb_access. */ + public const string DetectionQueryText = @" +SELECT + @@VERSION AS sql_version, + CONVERT(integer, SERVERPROPERTY('ProductMajorVersion')) AS major_version, + DATEDIFF(MINUTE, GETUTCDATE(), GETDATE()) AS utc_offset_minutes, + CONVERT(integer, SERVERPROPERTY('EngineEdition')) AS engine_edition, + CASE WHEN DB_ID('rdsadmin') IS NOT NULL THEN 1 ELSE 0 END AS is_aws_rds, + HAS_DBACCESS(N'msdb') AS has_msdb_access"; + + public static string ResolveConnectionString(MonitoredServer config, ILogger? logger = null) + { + string? password = null; + if (config.UsesSqlAuth) + { + if (!OperatingSystem.IsWindows() && !string.IsNullOrWhiteSpace(config.EncryptedPassword)) + { + throw new PlatformNotSupportedException("encryptedPassword requires Windows (DPAPI)."); + } + + password = DarlingSecrets.ResolvePassword(config, out var usedPlaintext); + if (usedPlaintext) + { + logger?.LogWarning( + "Server '{Server}' uses a plaintext password in darling.json — run --encrypt-password and switch to encryptedPassword.", + config.DisplayName); + } + } + + return MonitoredServerConnection.BuildConnectionString(config, password); + } + + /// Connects, probes, and returns the runtime state for one configured server. + public static async Task ConnectAsync(MonitoredServer config, ILogger? logger, CancellationToken cancellationToken) + { + var connectionString = ResolveConnectionString(config, logger); + var storageName = config.StorageName; + + using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(cancellationToken); + + using var command = new SqlCommand(DetectionQueryText, connection) { CommandTimeout = 30 }; + using var reader = await command.ExecuteReaderAsync(cancellationToken); + + int majorVersion = 0, engineEdition = 0; + bool isAwsRds = false, hasMsdbAccess = true; + if (await reader.ReadAsync(cancellationToken)) + { + // Column indices per DetectionQueryText: 1 major_version, 3 engine_edition, + // 4 is_aws_rds, 5 has_msdb_access (sqlserver_start_time was dropped in #1535). + majorVersion = reader.IsDBNull(1) ? 0 : reader.GetInt32(1); + engineEdition = reader.IsDBNull(3) ? 0 : reader.GetInt32(3); + isAwsRds = !reader.IsDBNull(4) && reader.GetInt32(4) == 1; + hasMsdbAccess = reader.IsDBNull(5) || reader.GetInt32(5) == 1; + } + + return new ServerRuntime + { + Config = config, + ConnectionString = connectionString, + Target = new CollectorTargetInfo + { + IsAzureSqlDb = engineEdition == 5, + IsAzureManagedInstance = engineEdition == 8, + IsAwsRds = isAwsRds, + SqlMajorVersion = majorVersion, + /* Already probed above via HAS_DBACCESS(N'msdb'); wiring it into the gate is the fix — + before this it rode only on ServerRuntime and never reached the collectors' AppliesTo, + so Darling attempted running_jobs/job_history/agent_status every cycle on a no-msdb login. */ + HasMsdbAccess = hasMsdbAccess, + }, + StorageName = storageName, + ServerId = ServerIdHelper.GetDeterministicHashCode(storageName), + HasMsdbAccess = hasMsdbAccess, + IsAwsRds = isAwsRds, + EngineEdition = engineEdition, + }; + } + + /// + /// Non-throwing connect-and-probe: runs and packages the outcome as a + /// — success carries the probed version/edition/engine facts, a + /// failure carries the error message (never plaintext credentials). Shared by the test_connect + /// command (the Stage-3 Add-dialog validates a server BEFORE saving; the SERVICE holds the network + /// path + credentials) and the --test-connection/--validate-config CLI verb, so both + /// classify a server identically. propagates (shutdown). + /// + public static async Task ProbeAsync(MonitoredServer config, ILogger? logger, CancellationToken cancellationToken) + { + if (config is null) + { + throw new ArgumentNullException(nameof(config)); + } + + try + { + var runtime = await ConnectAsync(config, logger, cancellationToken); + return new ConnectionProbeResult( + Success: true, + MajorVersion: runtime.Target.SqlMajorVersion, + EngineEdition: runtime.EngineEdition, + EngineEditionDescription: DescribeEngineEdition(runtime.EngineEdition), + IsAzureSqlDb: runtime.Target.IsAzureSqlDb, + IsAzureManagedInstance: runtime.Target.IsAzureManagedInstance, + IsAwsRds: runtime.IsAwsRds, + HasMsdbAccess: runtime.HasMsdbAccess, + Error: null); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return new ConnectionProbeResult( + Success: false, + MajorVersion: 0, + EngineEdition: 0, + EngineEditionDescription: null, + IsAzureSqlDb: false, + IsAzureManagedInstance: false, + IsAwsRds: false, + HasMsdbAccess: false, + Error: ex.Message); + } + } + + /// Human-readable SERVERPROPERTY('EngineEdition') description for the probe result. + public static string DescribeEngineEdition(int engineEdition) => engineEdition switch + { + 1 => "Personal/Desktop", + 2 => "Standard", + 3 => "Enterprise", + 4 => "Express", + 5 => "Azure SQL Database", + 6 => "Azure Synapse Analytics", + 8 => "Azure SQL Managed Instance", + 9 => "Azure SQL Edge", + 11 => "Azure Synapse serverless SQL pool", + _ => $"Unknown ({engineEdition})", + }; +} + +/// +/// The outcome of a connect-and-probe attempt (): the +/// success flag plus the probed target facts, or the error message on failure. Deliberately carries NO +/// credentials so it is safe to serialize into config_command.result_json and print from the CLI. +/// +public sealed record ConnectionProbeResult( + bool Success, + int MajorVersion, + int EngineEdition, + string? EngineEditionDescription, + bool IsAzureSqlDb, + bool IsAzureManagedInstance, + bool IsAwsRds, + bool HasMsdbAccess, + string? Error); diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs new file mode 100644 index 000000000..ab326c5eb --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs @@ -0,0 +1,1603 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Analysis; +using PerformanceMonitor.Darling.Service.Mcp; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// The web dashboard's HTTP API surface (#1562). owns the host, the +/// pipeline order, and the auth gate; this class owns the ROUTES. The two are split so the host (Builder 1's +/// foundation) and the endpoints (Builder 2) evolve independently against the stable seam. +/// +/// The read surface. GET /api/read/{tool_name} is 1:1 with the READ-ONLY MCP tool catalog: +/// each endpoint calls the SAME public static tool method the MCP server exposes (the [McpServerTool] +/// attributes are inert — the tool bodies are plain statics), so there is ZERO SQL / projection drift between the +/// two surfaces. Query-string values bind to the tool's parameters (server/server_name, +/// hours/hours_back, top, limit, ...); missing optional parameters fall back to the +/// method's own defaults. The excluded tools are the ones that are not read-only-over-the-store: +/// analyze_server (a live monitored-server touch), mute_analysis_finding (a write), and the +/// analyze_*_plan compute family (phase 2). A reflection test pins the endpoint set == the tool catalog +/// minus exactly those exclusions, so a future tool cannot be silently missed. +/// +/// Response mapping. The tools always return a string: a serialized JSON object/array for data (and +/// for the {"status", ...} empty-result envelope), or a bare "Error during ..." string when the +/// body's try/catch caught an exception, or a bare validation/resolution message. A leading { or [ +/// passes through verbatim as application/json (200); a "Error during ..." string maps to 500; any +/// other bare (non-JSON) string is a client-correctable error (bad parameter, unknown server) and maps to 400 — +/// both wrapped as {"error": "..."}. +/// +/// The pre-banded fleet. GET /api/fleet and the get_fleet_overview MCP tool both read +/// through — the enriched per-server cards and the cross-server rollup, banded +/// once by the shared ServerHealthClassifier. +/// +public static class DarlingWebEndpoints +{ + /// The tool names deliberately absent from the /api/read/* 1:1 read surface. analyze_server + /// makes a live monitored-server connection; mute_analysis_finding writes; the analyze_*_plan family + /// is the compute-heavy plan-analysis phase-2 work; the Custom Views tools (#1599) are served by their OWN + /// richer web endpoints (/api/views CRUD + /api/compose/run + the /api/catalog compose + /// vocabulary that describe_custom_view_catalog mirrors), not a /api/read/{tool} query-string mirror; + /// and the alert-tuning tools (update_alert_settings / create_mute_rule / delete_mute_rule) + /// WRITE the alert config, and the server-onboarding tools (add_servers / remove_server) WRITE the + /// monitored-server registry, so — like mute_analysis_finding — they have no read endpoint. + public static readonly IReadOnlySet ExcludedToolNames = new HashSet(StringComparer.Ordinal) + { + "analyze_server", + "mute_analysis_finding", + "analyze_query_plan", + "analyze_procedure_plan", + "analyze_query_store_plan", + "analyze_plan_xml", + "describe_custom_view_catalog", + "list_custom_views", + "get_custom_view", + "validate_custom_view", + "create_custom_view", + "update_custom_view", + "delete_custom_view", + "run_custom_view_panel", + "update_alert_settings", + "create_mute_rule", + "delete_mute_rule", + "add_servers", + "remove_server", + }; + + /// The window (hours) the fleet card blocking / deadlock counts default to — the WPF Overview's window. + private const int DefaultFleetHours = 1; + + /// + /// Maps the web dashboard's HTTP endpoints onto , reading from + /// (the VIEWER-role store pool). Called ONCE from the web host's pipeline, after the auth middleware and before + /// the static files. Every route lives under /api/* so the SPA's static surface never collides. + /// + public static void MapAll(WebApplication app, NpgsqlDataSource postgres) + { + /* Liveness probe (Builder 1's stub, kept): proves the host is up and the pipeline reaches the API. */ + app.MapGet("/api/ping", () => Results.Json(new { status = "ok" })); + + /* The four analysis-READ tools take a DarlingAnalysisService; the web host does not register one, so + build it once here from the same VIEWER-role pool (its read methods — fact collection, period compare, + persisted-finding read — need only the store; the optional plan fetcher / logger are for the excluded + analyze/drill path). Shared across requests, like the MCP host's singleton. */ + var analysis = new DarlingAnalysisService(postgres); + + /* The pre-banded fleet roll-up (also surfaced as the get_fleet_overview MCP tool). */ + app.MapGet("/api/fleet", async (HttpContext context) => + { + var hours = Math.Clamp(QueryInt(context, "hours_back", "hours", DefaultFleetHours), 1, 168); + var worstCount = Math.Max(0, QueryInt(context, "worst_count", null, DarlingFleetReader.DefaultWorstCount)); + var now = DateTime.UtcNow; + var result = await DarlingFleetReader.GetFleetOverviewAsync( + postgres, now.AddHours(-hours), now, now, worstCount, context.RequestAborted); + return Results.Json(result, DarlingFleetReader.JsonOptions); + }); + + /* One GET per read-only tool, calling the tool method directly (no SQL/projection re-implementation). */ + foreach (var (name, handler) in BuildReadDispatch()) + { + app.MapGet("/api/read/" + name, async (HttpContext context) => + { + string result; + try + { + result = await handler(context, postgres, analysis); + } + catch (Exception ex) + { + /* The tools swallow their own exceptions into an "Error during ..." string; this is only a + backstop for a binding-layer throw, mapped the same way (-> HTTP 500). */ + result = $"Error during {name}: {ex.Message}"; + } + + return ToHttpResult(result); + }); + } + + MapCustomViews(app, postgres); + } + + /* ─────────────────────────── #1563 custom views: session, catalog, CRUD ─────────────────────────── */ + + /// + /// Maps the custom-views surface: the session capability probe, the read catalog the composer builds its + /// picker from, and the CRUD routes. Editing is available to any AUTHENTICATED seat — the same reach as the + /// rest of the web surface: over the network a caller has already passed the token→cookie + CIDR gate (the + /// auth middleware runs before these routes), and on loopback the operator is on the host. The mutations are + /// NOT restricted to loopback (network operation is the normal mode). Cross-site forgery is blocked by the + /// always-on Host-header allowlist (anti-rebind), the SameSite=Strict session cookie, and the + /// application/json content-type requirement below. Definitions are validated by + /// (the authority) before any write; the store adds optimistic concurrency + + /// duplicate-name conflict detection. Error bodies are always {"error": "..."}, matching the read surface. + /// + private static void MapCustomViews(WebApplication app, NpgsqlDataSource postgres) + { + var store = new CustomViewStore(postgres); + + /* A request that reaches this endpoint has already cleared the host's auth gate (network: token→cookie + + CIDR; loopback: on the host), so it is a trusted operator that may edit. The frontend reads this to + show/hide affordances; a failed probe fails closed to read-only in the UI. */ + app.MapGet("/api/session", () => + JsonNodeResult(new JsonObject { ["can_edit"] = true })); + + /* The read catalog: input truth (read names + their WIRE query keys) the composer binds params from. */ + app.MapGet("/api/catalog", () => JsonNodeResult(BuildCatalogNode())); + + /* List — a bare array of summaries (no definition); [] when none. */ + app.MapGet("/api/views", async (HttpContext context) => + { + var views = await store.ListAsync(context.RequestAborted); + return JsonNodeResult(BuildSummariesNode(views)); + }); + + /* Get one — full, including the definition; 404 when missing. */ + app.MapGet("/api/views/{id:long}", async (HttpContext context, long id) => + { + var result = await store.GetAsync(id, context.RequestAborted); + return result is CustomViewResult.Ok ok + ? JsonNodeResult(BuildFullViewNode(ok.View!)) + : NotFoundResult(); + }); + + /* Create — 201 + Location; 400 on a bad body/definition, 409 on a duplicate name. Any authenticated seat + (the auth middleware already gated the request); application/json required (CSRF defense). */ + app.MapPost("/api/views", async (HttpContext context) => + { + if (!IsJsonContentType(context.Request.ContentType)) + { + return UnsupportedMediaTypeResult(); + } + + var (request, bodyError) = await ParseViewBodyAsync(context); + if (request is null) + { + return ErrorResult(bodyError!, StatusCodes.Status400BadRequest); + } + + var validation = ValidateDefinition(request.DefinitionJson); + if (!validation.IsValid) + { + return ErrorResult(validation.Error!, StatusCodes.Status400BadRequest); + } + + try + { + var result = await store.CreateAsync( + request.Name, request.Description, request.DefinitionJson, WebEditorPrincipal, context.RequestAborted); + return result switch + { + CustomViewResult.Ok ok => CreatedResult(context, $"/api/views/{ok.View!.Id}", BuildFullViewNode(ok.View)), + CustomViewResult.Conflict conflict => ErrorResult(conflict.Message, StatusCodes.Status409Conflict), + CustomViewResult.Invalid invalid => ErrorResult(invalid.Message, StatusCodes.Status400BadRequest), + _ => ErrorResult("Could not create the view.", StatusCodes.Status500InternalServerError), + }; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return ErrorResult($"Error saving view: {ex.Message}", StatusCodes.Status500InternalServerError); + } + }); + + /* Update — 200 on success; 400 bad body/definition, 404 gone, 409 stale-version OR duplicate name. + Any authenticated seat; application/json required. The client sends the version it edited; a mismatch + is a 409, not a silent clobber. */ + app.MapPut("/api/views/{id:long}", async (HttpContext context, long id) => + { + if (!IsJsonContentType(context.Request.ContentType)) + { + return UnsupportedMediaTypeResult(); + } + + var (request, bodyError) = await ParseViewBodyAsync(context); + if (request is null) + { + return ErrorResult(bodyError!, StatusCodes.Status400BadRequest); + } + + if (request.Version is not { } expectedVersion) + { + return ErrorResult("'version' is required for an update (optimistic concurrency).", StatusCodes.Status400BadRequest); + } + + var validation = ValidateDefinition(request.DefinitionJson); + if (!validation.IsValid) + { + return ErrorResult(validation.Error!, StatusCodes.Status400BadRequest); + } + + try + { + var result = await store.UpdateAsync( + id, request.Name, request.Description, request.DefinitionJson, expectedVersion, WebEditorPrincipal, context.RequestAborted); + return result switch + { + CustomViewResult.Ok ok => JsonNodeResult(BuildFullViewNode(ok.View!)), + CustomViewResult.NotFound => NotFoundResult(), + CustomViewResult.Conflict conflict => ErrorResult(conflict.Message, StatusCodes.Status409Conflict), + CustomViewResult.Invalid invalid => ErrorResult(invalid.Message, StatusCodes.Status400BadRequest), + _ => ErrorResult("Could not update the view.", StatusCodes.Status500InternalServerError), + }; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return ErrorResult($"Error saving view: {ex.Message}", StatusCodes.Status500InternalServerError); + } + }); + + /* Delete — 204 on success, 404 when missing. Any authenticated seat; application/json required. */ + app.MapDelete("/api/views/{id:long}", async (HttpContext context, long id) => + { + if (!IsJsonContentType(context.Request.ContentType)) + { + return UnsupportedMediaTypeResult(); + } + + try + { + var result = await store.DeleteAsync(id, context.RequestAborted); + return result is CustomViewResult.Ok ? Results.NoContent() : NotFoundResult(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return ErrorResult($"Error deleting view: {ex.Message}", StatusCodes.Status500InternalServerError); + } + }); + + /* Compile-and-run a single composed panel spec (Custom Views v2, #1563) against the least-privilege + viewer pool — the composer's live preview + the "view compiled SQL" transparency. application/json + required (the same CSRF gate as the mutations). The panel is validated by the SAME + ComposeSpec.TryParsePanel authority the write path uses, so a panel that previews here is a panel that + stores — and the compiler emits only catalog identifiers, schema-qualified collect.*, every value + bound; the viewer role's statement_timeout is the runaway backstop. */ + app.MapPost("/api/compose/run", async (HttpContext context) => + { + if (!IsJsonContentType(context.Request.ContentType)) + { + return UnsupportedMediaTypeResult(); + } + + JsonNode? root; + try + { + root = await JsonNode.ParseAsync(context.Request.Body, cancellationToken: context.RequestAborted); + } + catch (JsonException) + { + return ErrorResult("Request body is not valid JSON.", StatusCodes.Status400BadRequest); + } + + if (root is not JsonObject body) + { + return ErrorResult("Request body must be a JSON object with a 'panel'.", StatusCodes.Status400BadRequest); + } + + /* The compile-and-run itself lives in the shared RunComposedPanelAsync (the ONE runner behind both + this endpoint and the MCP run_custom_view_panel tool); this route only adds the web concerns — + content-type gate, stream parse — then maps the discriminated outcome onto HTTP status. */ + var outcome = await RunComposedPanelAsync(postgres, body, context.RequestAborted); + return outcome.Payload is not null + ? JsonNodeResult(outcome.Payload) + : ErrorResult(outcome.Error!, outcome.IsServerError ? StatusCodes.Status500InternalServerError : StatusCodes.Status400BadRequest); + }); + } + + /// The discriminated outcome of : the {sql, rows, + /// annotations} payload on success, or an error the caller maps onto its own surface (HTTP status / + /// MCP envelope). distinguishes a client-correctable error — a bad spec or a + /// bounded query failure (statement_timeout / Postgres error), i.e. HTTP 400 — from an unexpected internal + /// failure (HTTP 500). + internal readonly record struct ComposeRunOutcome(JsonObject? Payload, string? Error, bool IsServerError) + { + internal static ComposeRunOutcome Ok(JsonObject payload) => new(payload, null, false); + + internal static ComposeRunOutcome BadRequest(string error) => new(null, error, false); + + internal static ComposeRunOutcome ServerError(string error) => new(null, error, true); + } + + /// + /// Compile-and-run a single composed panel spec (Custom Views v2, #1563) against + /// — the ONE runner shared by the web /api/compose/run endpoint and the MCP run_custom_view_panel + /// tool, so the two surfaces can never diverge on validation, compilation, or query shape. Takes an + /// already-parsed request body {panel, variables?, values?, server?, hours?} and returns a + /// the caller maps to its surface. The panel is validated by the SAME + /// authority the write path uses (so a panel that runs here is a + /// panel that stores), the compiler emits only catalog identifiers — schema-qualified collect.*, every + /// value bound — and the query runs under the pool's role statement_timeout backstop. A cancellation + /// () is deliberately NOT caught: it propagates to the caller as a + /// client-abort, exactly as the endpoint has always done. + /// + internal static async Task RunComposedPanelAsync( + NpgsqlDataSource postgres, JsonObject body, System.Threading.CancellationToken cancellationToken) + { + if (body["panel"] is not JsonObject panel) + { + return ComposeRunOutcome.BadRequest("Request body must be a JSON object with a 'panel'."); + } + + var (variables, variablesError) = ComposeSpec.ParseVariables(body["variables"]); + if (variablesError is not null) + { + return ComposeRunOutcome.BadRequest(variablesError); + } + + var declaredVariables = variables!.Select(v => v.Name).ToHashSet(StringComparer.Ordinal); + var (plan, planError) = ComposeSpec.TryParsePanel(panel, declaredVariables); + if (planError is not null) + { + return ComposeRunOutcome.BadRequest(planError); + } + + var values = ParseRunValues(body["values"]); + + /* Server scope (Erik's Decision 1 fleet axis): the top-level 'server' (a name or array), else the + $server variable value. Absent / "All" / empty => the WHOLE FLEET (no server predicate); one or + many names => a bound server_name = ANY filter. */ + var serverScope = ParseServerScope(body, values); + + var hours = body["hours"] is JsonValue hoursValue && hoursValue.TryGetValue(out var h) ? h : DefaultComposeHours; + hours = Math.Clamp(hours, 1, ComposeLimits.MaxWindowHours); + var end = DateTime.UtcNow; + var start = end.AddHours(-hours); + + var runContext = new ComposeRunContext(serverScope, start, end, values); + var (compiled, compileError) = ComposeCompiler.Compile(plan!, runContext); + if (compileError is not null) + { + return ComposeRunOutcome.BadRequest(compileError); + } + + try + { + var rows = await RunComposedQueryAsync(postgres, compiled!, cancellationToken); + /* Event-annotation overlays (design D5): one bounded, catalog-only event query per requested + source, on the SAME window + server scope, under the same statement_timeout. Additive — + {sql, rows} are unchanged; a panel that requests no annotations returns an empty array. */ + var annotations = await RunAnnotationsAsync(postgres, plan!, runContext, cancellationToken); + return ComposeRunOutcome.Ok(new JsonObject { ["sql"] = compiled!.Sql, ["rows"] = rows, ["annotations"] = annotations }); + } + catch (PostgresException ex) + { + /* A statement_timeout cancel (57014) or any bounded query error — client-correctable. */ + return ComposeRunOutcome.BadRequest($"Query failed: {ex.MessageText}"); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return ComposeRunOutcome.ServerError($"Error running query: {ex.Message}"); + } + } + + /// Default compose-run window (hours) when the request omits one. + private const int DefaultComposeHours = 24; + + /// Resolves the run's server scope (Erik's Decision 1): the top-level server (a name or an + /// array of names), else the $server variable value. Absent / "All" / empty ⇒ null (the whole fleet, + /// no server predicate); one or many names ⇒ that list, which the compiler binds as a + /// server_name = ANY($n) text[] parameter — never resolved to ids or interpolated. + private static IReadOnlyList? ParseServerScope(JsonObject body, IReadOnlyDictionary values) + { + var names = new List(); + + switch (body["server"]) + { + case JsonArray array: + foreach (var item in array) + { + if (item is JsonValue v && v.TryGetValue(out var s)) + { + AddServer(names, s); + } + } + + break; + case JsonValue value when value.TryGetValue(out var single): + AddServer(names, single); + break; + default: + if (values.TryGetValue(ComposeVariable.ServerDimension, out var fromVariable)) + { + AddServer(names, fromVariable); + } + + break; + } + + return names.Count == 0 ? null : names; + } + + /// Adds a server name to the scope, treating null/empty and the literal "All" as "no scope" (fleet). + private static void AddServer(List names, string? name) + { + if (!string.IsNullOrEmpty(name) && !string.Equals(name, "All", StringComparison.OrdinalIgnoreCase)) + { + names.Add(name); + } + } + + /// Parses the run's resolved variable values ({name: scalar}) into the string map the + /// compiler binds $var filters from. A non-scalar value maps to null (an unresolved variable). + private static IReadOnlyDictionary ParseRunValues(JsonNode? node) + { + var values = new Dictionary(StringComparer.Ordinal); + if (node is JsonObject obj) + { + foreach (var kvp in obj) + { + values[kvp.Key] = kvp.Value is JsonValue value + ? (value.TryGetValue(out var s) ? s : value.ToString()) + : null; + } + } + + return values; + } + + /// Runs a compiled composed query on the viewer pool and serializes its rows to a JSON array of + /// {column: value} objects — generic over the SELECT shape (bucket / group dims / value). + private static async Task RunComposedQueryAsync(NpgsqlDataSource postgres, ComposeCompiled compiled, System.Threading.CancellationToken cancellationToken) + { + await using var command = postgres.CreateCommand(compiled.Sql); + foreach (var parameter in compiled.Parameters) + { + command.Parameters.Add(parameter); + } + + var rows = new JsonArray(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var row = new JsonObject(); + for (var i = 0; i < reader.FieldCount; i++) + { + row[reader.GetName(i)] = reader.IsDBNull(i) ? null : DbValueToJson(reader.GetValue(i)); + } + + rows.Add(row); + } + + return rows; + } + + /// Compiles + runs the panel's event-annotation overlays (design D5) on the viewer pool, returning + /// the added top-level annotations array: one {source, events:[{ts, label}]} object per requested + /// source (an EMPTY events list when a source has no events in the window). Each query is catalog-only, + /// schema-qualified collect.*, window + server-scoped, and capped — the same discipline and viewer + /// statement_timeout backstop as the measure query. An annotation query failure surfaces through the + /// caller's try/catch exactly like the measure query's (a runaway overlay fails the run with a clear 400, + /// rather than silently dropping markers the caller can't tell are missing). + private static async Task RunAnnotationsAsync( + NpgsqlDataSource postgres, PanelPlan plan, ComposeRunContext runContext, System.Threading.CancellationToken cancellationToken) + { + var annotations = new JsonArray(); + foreach (var (source, compiled) in ComposeCompiler.CompileAnnotations(plan, runContext)) + { + var events = await RunComposedQueryAsync(postgres, compiled, cancellationToken); + annotations.Add(new JsonObject { ["source"] = source, ["events"] = events }); + } + + return annotations; + } + + private static JsonNode? DbValueToJson(object value) => value switch + { + DateTime dt => JsonValue.Create(dt.ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture)), + double d => JsonValue.Create(d), + float f => JsonValue.Create((double)f), + decimal m => JsonValue.Create((double)m), + long l => JsonValue.Create(l), + int n => JsonValue.Create(n), + short s => JsonValue.Create((int)s), + bool b => JsonValue.Create(b), + string str => JsonValue.Create(str), + _ => JsonValue.Create(value.ToString()), + }; + + /// The updated_by stamp for a web edit. The web surface has no per-user identity, so a + /// constant is honest — it marks the row as web-authored. + internal const string WebEditorPrincipal = "web"; + + /// The updated_by stamp for a custom view created/updated over MCP (the + /// create_custom_view / update_custom_view tools). Like it is a + /// constant — the MCP surface has no per-user identity — and marks the row's provenance as MCP-authored. + internal const string McpEditorPrincipal = "mcp"; + + /* ── loopback determination (the web host's tokenless-loopback auth arm) ── */ + + /// + /// PURE: whether a request's remote address is loopback — the tokenless-access arm of the web host's auth + /// gate ( calls this: a loopback request needs no token). + /// Unwraps an IPv4-mapped-IPv6 address (::ffff:127.0.0.1) then defers to + /// ; a null/unverifiable remote is NOT loopback (fail-closed). + /// + internal static bool IsLoopbackRemote(IPAddress? remoteIp) + { + if (remoteIp is null) + { + return false; + } + + var ip = remoteIp.IsIPv4MappedToIPv6 ? remoteIp.MapToIPv4() : remoteIp; + return IPAddress.IsLoopback(ip); + } + + /* ── Content-Type gate (the CSRF defense on the mutation routes) ── */ + + /// + /// PURE: whether a mutation request's Content-Type is application/json (a trailing ; charset=… + /// parameter is ignored; the match is case-insensitive). The POST/PUT/DELETE routes require this and return + /// 415 otherwise. Requiring a non-simple Content-Type forces the browser to CORS-preflight ANY cross-origin + /// write, and the dashboard answers no preflight — so a simple-request CSRF (which can only send + /// text/plain / application/x-www-form-urlencoded / multipart/form-data) can never reach + /// a write. A null/empty or non-json Content-Type is rejected. + /// + internal static bool IsJsonContentType(string? contentType) + { + if (string.IsNullOrEmpty(contentType)) + { + return false; + } + + var semicolon = contentType.IndexOf(';'); + var mediaType = semicolon >= 0 ? contentType.AsSpan(0, semicolon) : contentType.AsSpan(); + return mediaType.Trim().Equals("application/json", StringComparison.OrdinalIgnoreCase); + } + + /* ── definition validation (the authority: a bad doc can never be stored) ── */ + + /// The visualization vocabulary — ONE C# source of truth. The catalog serves this exact set (so the + /// composer's viz picker matches), and rejects any panel viz outside it. + internal static readonly IReadOnlyList KnownVizList = new[] { "table", "line", "stat", "bandlist" }; + + /// Set form of for O(1) validation membership. + internal static readonly IReadOnlySet KnownViz = new HashSet(KnownVizList, StringComparer.Ordinal); + + /// The abuse bound on a stored definition's size (UTF-8 bytes) — generous for a real dashboard, bounded against abuse. + internal const int MaxDefinitionBytes = 128 * 1024; + + /// The abuse bound on panel count — a dashboard far larger than any real one. + internal const int MaxPanelCount = 48; + + /// The abuse bound on a notebook's cell count (design D7) — a notebook far longer than any real one. + internal const int MaxNotebookCells = 100; + + /// The abuse bound on a single markdown cell's text (UTF-8 bytes, design D7) — generous for real + /// prose/tables, bounded against a definition padded out to the whole-doc size cap by one giant cell. + internal const int MaxMarkdownCellBytes = 32 * 1024; + + /// The outcome of : valid, or invalid with a caller-facing reason. + internal readonly record struct DefinitionValidation(bool IsValid, string? Error) + { + internal static DefinitionValidation Valid => new(true, null); + + internal static DefinitionValidation Fail(string error) => new(false, error); + } + + /// + /// PURE structural validation of a stored view definition — the authority the POST/PUT routes run before any + /// write (400 on failure, naming the offending panel/cell). After the shared preamble (size cap, JSON parse, + /// object check) it dispatches on the view kind: a "kind":"notebook" definition routes to + /// (design D7); everything else (no kind, or + /// "kind":"dashboard") is a DASHBOARD, validated below unchanged. + /// + /// Dashboard. Requires a JSON object with a non-empty panels array (size- and + /// count-capped) and validates the view-level variables (§2b) + range. Each panel is EITHER a v2 + /// COMPOSED panel (names a source) — cross-checked against the measure catalog + the declared variables + /// by , the same authority the compile-run path uses, so a stored + /// composed panel can always compile — or a v1 READ panel (names a read on the + /// allowlist with a viz in ); the two coexist and + /// are dispatched per panel. A span of 1 or 2 and any series color (a #rrggbb hex) are + /// validated for both modes. Raw path-mode is REJECTED — a definition must stay on the allowlists, + /// never name an arbitrary endpoint path. + /// + internal static DefinitionValidation ValidateDefinition(string? definitionJson) + { + if (string.IsNullOrWhiteSpace(definitionJson)) + { + return DefinitionValidation.Fail("definition is required."); + } + + if (System.Text.Encoding.UTF8.GetByteCount(definitionJson) > MaxDefinitionBytes) + { + return DefinitionValidation.Fail($"definition exceeds the maximum size of {MaxDefinitionBytes} bytes."); + } + + JsonNode? root; + try + { + root = JsonNode.Parse(definitionJson); + } + catch (JsonException) + { + return DefinitionValidation.Fail("definition is not valid JSON."); + } + + if (root is not JsonObject rootObject) + { + return DefinitionValidation.Fail("definition must be a JSON object."); + } + + /* Kind dispatch (design D7): a notebook is a distinct view kind stored in the SAME config.custom_views + table, marked by a top-level "kind":"notebook" and carrying "cells" in place of "panels". Route it to + its own validator; a dashboard (no "kind", or "kind":"dashboard") falls through to the panels path + below, unchanged. The shared preamble above (size cap, JSON parse, object check) already covered both. */ + if (string.Equals(TryGetString(rootObject, "kind"), "notebook", StringComparison.Ordinal)) + { + return ValidateNotebookDefinition(rootObject); + } + + if (rootObject["panels"] is not JsonArray panels) + { + return DefinitionValidation.Fail("definition.panels must be an array."); + } + + if (panels.Count == 0) + { + return DefinitionValidation.Fail("definition.panels must have at least one panel."); + } + + if (panels.Count > MaxPanelCount) + { + return DefinitionValidation.Fail($"definition has {panels.Count} panels; the maximum is {MaxPanelCount}."); + } + + /* View-level template variables (§2b) + global range — validated once; the declared variable names + are the allowlist a composed panel's $var filters must reference. */ + var (variables, variablesError) = ComposeSpec.ParseVariables(rootObject["variables"]); + if (variablesError is not null) + { + return DefinitionValidation.Fail(variablesError); + } + + var (_, rangeError) = ComposeSpec.ParseRange(rootObject["range"]); + if (rangeError is not null) + { + return DefinitionValidation.Fail(rangeError); + } + + var declaredVariables = variables!.Select(v => v.Name).ToHashSet(StringComparer.Ordinal); + + var reads = BuildReadDispatch(); + for (var i = 0; i < panels.Count; i++) + { + if (panels[i] is not JsonObject panel) + { + return DefinitionValidation.Fail($"panel {i} must be an object."); + } + + /* Stay on the allowlist: a raw endpoint path is never storable (either panel mode). */ + if (panel["path"] is not null) + { + return DefinitionValidation.Fail($"panel {i} uses raw 'path' mode, which is not allowed; use 'read' or 'source'."); + } + + /* A panel is EITHER a v2 composed panel (names a 'source') — validated by the compose authority + against the measure catalog + the declared variables — or a v1 read panel (names a 'read'). The + two coexist in one definition, dispatched per panel. */ + if (ComposeSpec.IsComposedPanel(panel)) + { + var (_, composeError) = ComposeSpec.TryParsePanel(panel, declaredVariables); + if (composeError is not null) + { + return DefinitionValidation.Fail($"panel {i}: {composeError}"); + } + } + else + { + var read = TryGetString(panel, "read"); + if (string.IsNullOrEmpty(read)) + { + return DefinitionValidation.Fail($"panel {i} is missing 'read' or 'source'."); + } + + if (!reads.ContainsKey(read)) + { + return DefinitionValidation.Fail($"panel {i} references unknown read '{read}'."); + } + + var viz = TryGetString(panel, "viz"); + if (string.IsNullOrEmpty(viz)) + { + return DefinitionValidation.Fail($"panel {i} is missing 'viz'."); + } + + if (!KnownViz.Contains(viz)) + { + return DefinitionValidation.Fail($"panel {i} has unknown viz '{viz}'."); + } + } + + if (panel["span"] is JsonValue spanValue) + { + if (!spanValue.TryGetValue(out var span) || (span != 1 && span != 2)) + { + return DefinitionValidation.Fail($"panel {i} has an invalid span (must be 1 or 2)."); + } + } + + /* Presentation guard (stored CSS-injection / air-gap break): a line panel's series colors flow into a + style="background:" write in the renderer (charts.js), so any color that IS present must be a + #rrggbb hex literal. A color is optional (the renderer falls back to the palette), but a free-form + value — a CSS function that fetches off-origin, a named color, a short #abc, or a non-string — is + made unstorable here, closing both the Import seed path and the loopback-write path. The series + array is walked wherever it appears (only line viz emits one). */ + if (panel["series"] is JsonArray seriesArray) + { + for (var j = 0; j < seriesArray.Count; j++) + { + if (seriesArray[j] is JsonObject seriesObject && seriesObject["color"] is JsonValue colorValue) + { + var color = colorValue.TryGetValue(out var colorText) ? colorText : null; + if (!IsHexColor(color)) + { + return DefinitionValidation.Fail( + $"panel {i} series {j} has an invalid color; a series color must be a '#rrggbb' hex value."); + } + } + } + } + } + + return DefinitionValidation.Valid; + } + + /// + /// PURE structural validation of a NOTEBOOK definition (design D7) — the "kind":"notebook" arm of + /// (the shared preamble — size cap, JSON parse, object check — already ran). + /// A notebook is a view kind stored in the SAME config.custom_views table as a dashboard, distinguished + /// by its top-level "kind":"notebook" and a cells array in place of panels. Requires a + /// non-empty cells array (count-capped at ) and validates the view-level + /// variables (§2b) + range through the SAME authorities a dashboard + /// routes through. Each cell is validated by its type: + /// + /// markdown — must carry a string text within (UTF-8). + /// panel — a v2 COMPOSED panel validated by against the + /// notebook's declared variables (the EXACT authority a dashboard's composed panel routes through), so a + /// notebook panel cell carries all the v2 safety — catalog-resolved identifiers, bound values, caps — and can + /// always compile. + /// + /// An unknown cell type, a missing/oversize markdown text, an invalid panel cell, or an over-cap + /// cell count is rejected (400), naming the offending cell by index. + /// + internal static DefinitionValidation ValidateNotebookDefinition(JsonObject rootObject) + { + if (rootObject["cells"] is not JsonArray cells) + { + return DefinitionValidation.Fail("notebook.cells must be an array."); + } + + if (cells.Count == 0) + { + return DefinitionValidation.Fail("notebook.cells must have at least one cell."); + } + + if (cells.Count > MaxNotebookCells) + { + return DefinitionValidation.Fail($"notebook has {cells.Count} cells; the maximum is {MaxNotebookCells}."); + } + + /* View-level template variables (§2b) + global range — the SAME authorities the dashboard path routes + through; the declared variable names are the allowlist a panel cell's $var filters must reference. */ + var (variables, variablesError) = ComposeSpec.ParseVariables(rootObject["variables"]); + if (variablesError is not null) + { + return DefinitionValidation.Fail(variablesError); + } + + var (_, rangeError) = ComposeSpec.ParseRange(rootObject["range"]); + if (rangeError is not null) + { + return DefinitionValidation.Fail(rangeError); + } + + var declaredVariables = variables!.Select(v => v.Name).ToHashSet(StringComparer.Ordinal); + + for (var i = 0; i < cells.Count; i++) + { + if (cells[i] is not JsonObject cell) + { + return DefinitionValidation.Fail($"cell {i} must be an object."); + } + + var type = TryGetString(cell, "type"); + switch (type) + { + case "markdown": + /* A markdown cell is prose only — a string 'text' (present, a real string) within the cell + byte cap. It never enters the compiler; the renderer is responsible for safe markdown->HTML. */ + if (cell["text"] is not JsonValue textValue || !textValue.TryGetValue(out var text)) + { + return DefinitionValidation.Fail($"cell {i} (markdown) must have a string 'text'."); + } + + if (System.Text.Encoding.UTF8.GetByteCount(text) > MaxMarkdownCellBytes) + { + return DefinitionValidation.Fail( + $"cell {i} (markdown) text exceeds the maximum size of {MaxMarkdownCellBytes} bytes."); + } + + break; + + case "panel": + /* A panel cell is a v2 composed panel, validated by the SAME ComposeSpec.TryParsePanel authority + a dashboard's composed panel routes through (against the notebook's declared variables) — so a + stored notebook panel cell can always compile and carries all the v2 safety. */ + var (_, panelError) = ComposeSpec.TryParsePanel(cell, declaredVariables); + if (panelError is not null) + { + return DefinitionValidation.Fail($"cell {i}: {panelError}"); + } + + break; + + default: + return DefinitionValidation.Fail( + $"cell {i} has an unknown type '{type}'; expected 'markdown' or 'panel'."); + } + } + + return DefinitionValidation.Valid; + } + + /// PURE: whether a series color is a #rrggbb hex literal (case-insensitive) — the ONLY form a + /// stored color may take (mirrors the renderer's normalizeColor and the composer's <input + /// type=color>). A named color, a short #abc, a CSS url(...) function, or a non-string + /// value are all rejected; a color that is ABSENT (or JSON null) is never passed here — the caller leaves it + /// as-is (the renderer falls back to the palette). + internal static bool IsHexColor(string? color) + { + if (color is null || color.Length != 7 || color[0] != '#') + { + return false; + } + + for (var i = 1; i < color.Length; i++) + { + var c = color[i]; + var isHexDigit = c is (>= '0' and <= '9') or (>= 'a' and <= 'f') or (>= 'A' and <= 'F'); + if (!isHexDigit) + { + return false; + } + } + + return true; + } + + private static string? TryGetString(JsonObject obj, string key) => + obj[key] is JsonValue value && value.TryGetValue(out var s) ? s : null; + + /* ── the read catalog (input truth): read names from BuildReadDispatch, param metadata hand-authored ── */ + + /// One catalog parameter: its WIRE query-string key (NOT the C# method param name), its + /// (server/int/text/bool/double), whether it is , and its + /// (null for text/required). + internal sealed record CatalogParam(string Name, string Type, bool Required, object? Default); + + /// One catalog read: its display , a short , and its + /// (the exact query keys its dispatch lambda binds). + internal sealed record CatalogRead(string Category, string Description, IReadOnlyList Params); + + private const string TypeServer = "server"; + private const string TypeInt = "int"; + private const string TypeText = "text"; + private const string TypeBool = "bool"; + private const string TypeDouble = "double"; + + private const string CatAnalysis = "Analysis"; + private const string CatSessions = "Sessions"; + private const string CatAlerts = "Alerts"; + private const string CatBlocking = "Blocking & Deadlocks"; + private const string CatConfig = "Configuration"; + private const string CatData = "Data"; + private const string CatTrends = "Trends"; + private const string CatOverview = "Overview"; + private const string CatLatch = "Latch & Spinlock"; + private const string CatMemoryGrants = "Memory Grants"; + private const string CatObjects = "Objects & Indexes"; + private const string CatPlanCache = "Plan Cache & Scheduler"; + private const string CatJobs = "Jobs"; + private const string CatPlans = "Plans"; + private const string CatDefaultTrace = "Default Trace"; + private const string CatSystemHealth = "System Health"; + + private static CatalogParam PServer() => new("server", TypeServer, false, null); + private static CatalogParam PHours(int def) => new("hours", TypeInt, false, def); + private static CatalogParam PLimit(int def) => new("limit", TypeInt, false, def); + private static CatalogParam PTop(int def) => new("top", TypeInt, false, def); + private static CatalogParam PText(string name) => new(name, TypeText, false, null); + private static CatalogParam PReqText(string name) => new(name, TypeText, true, null); + private static CatalogParam PInt(string name, int def) => new(name, TypeInt, false, def); + private static CatalogParam PBool(string name, bool def) => new(name, TypeBool, false, def); + private static CatalogParam PDouble(string name, double def) => new(name, TypeDouble, false, def); + + private static CatalogRead R(string category, string description, params CatalogParam[] parameters) => + new(category, description, parameters); + + /// + /// The hand-authored per-read param + category metadata, keyed by read name. There is NO declarative param + /// source (the dispatch lambdas bind STRING-LITERAL query keys imperatively, and those keys ≠ the C# method + /// param names — e.g. hours_back ≠ hoursBack — so reflection would send params under the wrong + /// keys). Each entry's is therefore the ACTUAL query-string key its + /// lambda reads. A test pins CatalogDescriptors.Keys == + /// BuildReadDispatch().Keys, so a new read forces a catalog entry (it cannot silently ship key-less). + /// + internal static readonly IReadOnlyDictionary CatalogDescriptors = + new Dictionary(StringComparer.Ordinal) + { + /* ── analysis reads (AuditConfig/CompareAnalysis/GetAnalysisFacts/GetAnalysisFindings) ── */ + ["audit_config"] = R(CatAnalysis, "Configuration-audit findings for a server.", PServer()), + ["compare_analysis"] = R(CatAnalysis, "Compare a window's analysis facts against an earlier baseline.", PServer(), PHours(4), PInt("baseline_hours_back", 28)), + ["get_analysis_facts"] = R(CatAnalysis, "Raw analysis facts for a window, filtered by source and minimum severity.", PServer(), PHours(4), PText("source"), PDouble("min_severity", 0)), + ["get_analysis_findings"] = R(CatAnalysis, "Persisted analysis findings for a server.", PServer(), PHours(24)), + + /* ── sessions (DarlingMcpSessionTools) ── */ + ["get_active_queries"] = R(CatSessions, "Currently-active queries, optionally blocking-only.", PServer(), PHours(1), PText("database_name"), PBool("blocking_only", false), PLimit(50)), + ["get_session_stats"] = R(CatSessions, "Session-level summary counters for a server.", PServer()), + ["get_waiting_tasks"] = R(CatSessions, "Tasks currently waiting, with wait type and duration.", PServer(), PHours(1), PLimit(30)), + + /* ── alerts / mute rules (DarlingMcpAlertTools) ── */ + ["get_alert_history"] = R(CatAlerts, "Recent fired-alert history for a server.", PServer(), PHours(24), PLimit(50)), + ["get_alert_settings"] = R(CatAlerts, "The current alert-settings configuration."), + ["get_mute_rules"] = R(CatAlerts, "The alert mute rules (enabled-only by default).", PBool("enabled_only", true)), + + /* ── blocking / deadlocks (DarlingMcpBlockingTools) ── */ + ["get_blocked_process_xml"] = R(CatBlocking, "Blocked-process-report XML captures.", PServer(), PHours(24), PLimit(5)), + ["get_blocking"] = R(CatBlocking, "Blocking chains observed in the window.", PServer(), PHours(24), PLimit(30)), + ["get_blocking_trend"] = R(CatBlocking, "Blocking-event counts over time.", PServer(), PHours(24)), + ["get_deadlock_detail"] = R(CatBlocking, "Deadlock graph detail for recent deadlocks.", PServer(), PHours(24), PLimit(5)), + ["get_deadlock_trend"] = R(CatBlocking, "Deadlock counts over time.", PServer(), PHours(24)), + ["get_deadlocks"] = R(CatBlocking, "Recent deadlocks with victim/resource summary.", PServer(), PHours(24), PLimit(20)), + + /* ── config: current + history (DarlingMcpConfigTools / DarlingMcpConfigHistoryTools) ── */ + ["get_database_config"] = R(CatConfig, "Database-level configuration for a server.", PServer(), PText("database_name")), + ["get_server_config"] = R(CatConfig, "Server-level configuration (sp_configure) for a server.", PServer()), + ["get_trace_flags"] = R(CatConfig, "Active trace flags for a server.", PServer()), + ["get_database_config_changes"] = R(CatConfig, "Database-configuration changes over time.", PServer(), PHours(168)), + ["get_database_scoped_config"] = R(CatConfig, "Database-scoped configuration for a database.", PServer(), PText("database_name")), + ["get_server_config_changes"] = R(CatConfig, "Server-configuration changes over time.", PServer(), PHours(168)), + ["get_trace_flag_changes"] = R(CatConfig, "Trace-flag changes over time.", PServer(), PHours(168)), + + /* ── core data reads (DarlingMcpDataTools + long-query / fleet tools) ── */ + ["get_collection_health"] = R(CatData, "Per-collector collection health for a server.", PServer()), + ["get_cpu_utilization"] = R(CatData, "CPU utilization over time.", PServer(), PHours(4)), + ["get_file_io_stats"] = R(CatData, "Per-file IO stall/throughput stats.", PServer()), + ["get_memory_clerks"] = R(CatData, "Top memory clerks by allocation.", PServer()), + ["get_memory_stats"] = R(CatData, "Server memory summary counters.", PServer()), + ["get_perfmon_stats"] = R(CatData, "Perfmon counter values, filtered by counter/instance.", PServer(), PText("counter_name"), PText("instance_name")), + ["get_query_store_top"] = R(CatData, "Top Query Store queries in the window.", PServer(), PHours(24), PTop(20), PText("database_name")), + ["get_long_query_completions"] = R(CatData, "Completed long-running queries captured by the XE trace.", PServer(), PHours(24), PLimit(30)), + ["get_server_properties"] = R(CatData, "Server properties/inventory for a server.", PServer()), + ["get_tempdb_trend"] = R(CatData, "tempdb space usage over time.", PServer(), PHours(24)), + ["get_top_procedures_by_cpu"] = R(CatData, "Top stored procedures by CPU.", PServer(), PHours(24), PTop(20), PText("database_name")), + ["get_top_queries_by_cpu"] = R(CatData, "Top queries by CPU, optionally parallel-only / min-DOP.", PServer(), PHours(24), PTop(20), PText("database_name"), PBool("parallel_only", false), PInt("min_dop", 0)), + ["get_wait_stats"] = R(CatData, "Top wait statistics in the window.", PServer(), PHours(24), PLimit(20)), + ["get_wait_trend"] = R(CatData, "One wait type's totals over time (requires wait_type).", PReqText("wait_type"), PServer(), PHours(24)), + ["get_wait_types"] = R(CatData, "The wait types observed in the window.", PServer(), PHours(24)), + ["list_servers"] = R(CatData, "The monitored servers known to the store."), + + /* ── trends (DarlingMcpTrendTools) ── */ + ["get_file_io_trend"] = R(CatTrends, "File-IO throughput over time.", PServer(), PHours(24)), + ["get_memory_trend"] = R(CatTrends, "Memory usage over time.", PServer(), PHours(24)), + ["get_perfmon_trend"] = R(CatTrends, "One perfmon counter over time (requires counter_name).", PReqText("counter_name"), PServer(), PHours(24)), + ["get_query_duration_trend"] = R(CatTrends, "Query-duration percentiles over time.", PServer(), PHours(24)), + ["get_query_trend"] = R(CatTrends, "One query's metrics over time (requires query_hash + database_name).", PReqText("query_hash"), PReqText("database_name"), PServer(), PHours(24)), + + /* ── health / overview (DarlingMcpHealthTools / DarlingMcpFleetTools) ── */ + ["get_server_summary"] = R(CatOverview, "A one-shot health summary for a server.", PServer()), + ["get_daily_summary"] = R(CatOverview, "The daily health summary (optionally for a specific date).", PServer(), PText("summary_date")), + ["get_fleet_overview"] = R(CatOverview, "The banded cross-server fleet roll-up.", PHours(DefaultFleetHours)), + + /* ── latch / spinlock (DarlingMcpLatchSpinlockTools) ── */ + ["get_latch_stats"] = R(CatLatch, "Top latch waits in the window.", PServer(), PHours(24), PTop(10)), + ["get_spinlock_stats"] = R(CatLatch, "Top spinlock activity in the window.", PServer(), PHours(24), PTop(10)), + + /* ── memory grants (DarlingMcpMemoryGrantTools) ── */ + ["get_memory_grants"] = R(CatMemoryGrants, "Active/recent memory grants.", PServer(), PHours(1)), + ["get_memory_pressure_events"] = R(CatMemoryGrants, "Memory-pressure events in the window.", PServer(), PHours(24)), + ["get_resource_semaphore"] = R(CatMemoryGrants, "Resource-semaphore state over time.", PServer(), PHours(24)), + + /* ── object / index stats (DarlingMcpObjectStatsTools) ── */ + ["get_database_sizes"] = R(CatObjects, "Per-database size breakdown.", PServer()), + ["get_index_usage"] = R(CatObjects, "Index usage (seeks/scans/updates) per index.", PServer()), + ["get_object_locking"] = R(CatObjects, "Per-object locking/contention stats.", PServer()), + ["get_table_index_sizes"] = R(CatObjects, "Per-table/index size breakdown.", PServer()), + + /* ── plan cache / scheduler (DarlingMcpPlanCacheSchedulerTools) ── */ + ["get_cpu_scheduler_pressure"] = R(CatPlanCache, "CPU scheduler pressure indicators.", PServer()), + ["get_plan_cache_bloat"] = R(CatPlanCache, "Plan-cache bloat / single-use plan indicators.", PServer(), PHours(24)), + + /* ── jobs (DarlingMcpJobTools) ── */ + ["get_running_jobs"] = R(CatJobs, "Currently-running SQL Agent jobs.", PServer()), + + /* ── stored plan XML (DarlingMcpPlanTools; the analyze_*_plan compute family stays excluded) ── */ + ["get_plan_xml"] = R(CatPlans, "The stored execution-plan XML for a query (requires query_hash).", PReqText("query_hash"), PServer(), PText("database_name")), + + /* ── default trace (DarlingMcpDefaultTraceTools) ── */ + ["get_default_trace_events"] = R(CatDefaultTrace, "Default-trace events (file growth, DDL, security).", PServer(), PHours(24), PLimit(100)), + + /* ── system_health parse-on-read family (DarlingMcpHealthParserTools) ── */ + ["get_health_parser_cpu_tasks"] = R(CatSystemHealth, "system_health: CPU-bound task snapshots.", PServer(), PHours(24), PLimit(50)), + ["get_health_parser_io_issues"] = R(CatSystemHealth, "system_health: IO-latency issues.", PServer(), PHours(24), PLimit(50)), + ["get_health_parser_memory_broker"] = R(CatSystemHealth, "system_health: memory-broker ring-buffer entries.", PServer(), PHours(24), PLimit(50)), + ["get_health_parser_memory_conditions"] = R(CatSystemHealth, "system_health: resource-monitor memory conditions.", PServer(), PHours(24), PLimit(50)), + ["get_health_parser_memory_node_oom"] = R(CatSystemHealth, "system_health: per-node out-of-memory events.", PServer(), PHours(24), PLimit(50)), + ["get_health_parser_scheduler_issues"] = R(CatSystemHealth, "system_health: non-yielding scheduler issues.", PServer(), PHours(24), PLimit(50)), + ["get_health_parser_severe_errors"] = R(CatSystemHealth, "system_health: severe (sev >= 17) errors.", PServer(), PHours(24), PLimit(50)), + ["get_health_parser_system_health"] = R(CatSystemHealth, "system_health: the raw parsed session records.", PServer(), PHours(24), PLimit(50)), + }; + + /// Builds the /api/catalog body: the reads (names taken from , + /// each enriched with its metadata) and the viz vocabulary. Iterating the + /// dispatch keys guarantees the catalog only advertises actually-dispatchable reads. + internal static JsonObject BuildCatalogNode() + { + var reads = new JsonArray(); + foreach (var name in BuildReadDispatch().Keys) + { + var descriptor = CatalogDescriptors[name]; + var parameters = new JsonArray(); + foreach (var p in descriptor.Params) + { + parameters.Add(new JsonObject + { + ["name"] = p.Name, + ["type"] = p.Type, + ["required"] = p.Required, + ["default"] = ToJsonValue(p.Default), + }); + } + + reads.Add(new JsonObject + { + ["name"] = name, + ["category"] = descriptor.Category, + ["description"] = descriptor.Description, + ["params"] = parameters, + }); + } + + var viz = new JsonArray(); + foreach (var v in KnownVizList) + { + viz.Add(v); + } + + /* v1 keys (reads/viz) unchanged; the v2 composed-view catalog rides alongside under "compose". */ + return new JsonObject { ["reads"] = reads, ["viz"] = viz, ["compose"] = BuildComposeCatalogNode() }; + } + + /// + /// The v2 (Custom Views composer) catalog: the measure/dimension/unit-family/aggregate/time-bucket/filter-op + /// vocabularies the composer picks from, straight off (the SOLE identifier + /// source the compiler emits). Served under /api/catalog's compose key so a single fetch backs + /// both the v1 read picker and the v2 composer. + /// + internal static JsonObject BuildComposeCatalogNode() + { + var measures = new JsonArray(); + foreach (var m in MeasureCatalog.Measures) + { + var validAggregates = new JsonArray(); + foreach (var a in m.ValidAggs) + { + validAggregates.Add(MeasureCatalog.WireName(a)); + } + + var allowedDimensions = new JsonArray(); + foreach (var d in m.AllowedDimensions) + { + allowedDimensions.Add(d); + } + + string? defaultAggregate = m.Kind == MeasureKind.Ratio ? null : MeasureCatalog.WireName(m.DefaultTimeAgg); + + measures.Add(new JsonObject + { + ["key"] = m.Key, + ["displayName"] = m.DisplayName, + ["category"] = m.Category, + ["source"] = m.SourceTable, + ["kind"] = m.Kind == MeasureKind.Ratio ? "ratio" : "scalar", + ["archetype"] = m.Archetype.ToString(), + ["nativeUnit"] = m.NativeUnit, + ["defaultUnit"] = m.DefaultUnit, + ["unitFamily"] = m.UnitFamily, + ["defaultAggregate"] = defaultAggregate, + ["validAggregates"] = validAggregates, + ["allowedDimensions"] = allowedDimensions, + /* Per-server-type availability (design D4), off the owning collector's AppliesTo gate — so the + composer can grey a measure a given target can't collect (e.g. Agent measures need msdb). */ + ["appliesTo"] = BuildAppliesToNode(m.SourceTable), + }); + } + + var dimensions = new JsonArray(); + foreach (var d in MeasureCatalog.Dimensions) + { + dimensions.Add(new JsonObject + { + ["source"] = d.SourceTable, + ["name"] = d.Name, + ["likeable"] = d.Likeable, + ["viaModuleJoin"] = d.ViaModuleJoin, + }); + } + + var unitFamilies = new JsonArray(); + foreach (var family in MeasureCatalog.UnitFamilies) + { + var units = new JsonArray(); + foreach (var unit in family.Units) + { + units.Add(new JsonObject { ["name"] = unit.Name, ["baseFactor"] = unit.BaseFactor }); + } + + unitFamilies.Add(new JsonObject { ["name"] = family.Name, ["units"] = units }); + } + + /* Event-annotation sources (design D5): the marker overlays the composer offers on a time-series panel. + Carries appliesTo off the owning collector's gate (like measures, D4) so the composer can grey a source + a given target can't collect — e.g. default_trace_events / system_health differ by platform. */ + var annotationSources = new JsonArray(); + foreach (var a in MeasureCatalog.AnnotationSources) + { + annotationSources.Add(new JsonObject + { + ["key"] = a.Key, + ["displayName"] = a.DisplayName, + ["category"] = a.Category, + ["appliesTo"] = BuildAppliesToNode(a.SourceTable), + }); + } + + return new JsonObject + { + ["measures"] = measures, + ["dimensions"] = dimensions, + ["annotationSources"] = annotationSources, + /* server is a virtual dimension present on every source (the fleet axis), not in the per-source + dimensions list — surfaced here so the composer offers it on every measure. */ + ["universalDimensions"] = ToJsonStringArray(new[] { MeasureCatalog.ServerDimensionName }), + ["unitFamilies"] = unitFamilies, + ["aggregates"] = ToJsonStringArray(MeasureCatalog.AggregateWireNames), + ["timeBuckets"] = ToJsonStringArray(MeasureCatalog.TimeBucketWireNames), + ["filterOps"] = ToJsonStringArray(MeasureCatalog.FilterOpWireNames), + /* the v2 composed-panel viz vocabulary (bar/pie/stacked/stacked-bar/area added over v1's KnownViz). */ + ["viz"] = ToJsonStringArray(ComposeSpec.ComposeVizList), + }; + } + + /// Collector definition by its destination table, for the per-measure availability lookup (a + /// measure's SourceTable is a collector TargetTable, pinned by DarlingComposeTests). + private static readonly Dictionary s_collectorByTable = + CollectorCatalog.All.ToDictionary(c => c.TargetTable, StringComparer.Ordinal); + + /* Representative target profiles a measure's collector AppliesTo gate is evaluated against (design D4). + SqlMajorVersion is pinned to a supported major (16 = SQL 2022) so the version-gated collectors + (query_stats/query_store, 2016+) read as available; the msdb-less on-prem profile isolates the SQL-Agent + dependency for the needsMsdb flag. */ + private static readonly CollectorTargetInfo s_onPremTarget = new() { SqlMajorVersion = 16, HasMsdbAccess = true }; + private static readonly CollectorTargetInfo s_onPremNoMsdbTarget = new() { SqlMajorVersion = 16, HasMsdbAccess = false }; + private static readonly CollectorTargetInfo s_azureSqlDbTarget = new() { IsAzureSqlDb = true, SqlMajorVersion = 16, HasMsdbAccess = false }; + private static readonly CollectorTargetInfo s_azureMiTarget = new() { IsAzureManagedInstance = true, SqlMajorVersion = 16, HasMsdbAccess = true }; + private static readonly CollectorTargetInfo s_awsRdsTarget = new() { IsAwsRds = true, SqlMajorVersion = 16, HasMsdbAccess = true }; + + /// The per-server-type availability of a measure (design D4), derived from its owning collector's + /// gate — the single authoritative target gate — so the composer + /// can label/grey a measure a given server type can't collect. needsMsdb is the SQL-Agent dependency + /// (job/agent measures), detected as "available with msdb, gated without it" on an otherwise-capable on-prem + /// target. Returns null only if a measure's source has no collector (impossible — pinned by test). + private static JsonObject? BuildAppliesToNode(string sourceTable) + { + if (!s_collectorByTable.TryGetValue(sourceTable, out var collector)) + { + return null; + } + + return new JsonObject + { + ["onPrem"] = collector.AppliesTo(s_onPremTarget), + ["azureSqlDb"] = collector.AppliesTo(s_azureSqlDbTarget), + ["azureMi"] = collector.AppliesTo(s_azureMiTarget), + ["awsRds"] = collector.AppliesTo(s_awsRdsTarget), + ["needsMsdb"] = collector.AppliesTo(s_onPremTarget) && !collector.AppliesTo(s_onPremNoMsdbTarget), + }; + } + + private static JsonArray ToJsonStringArray(IReadOnlyList values) + { + var array = new JsonArray(); + foreach (var value in values) + { + array.Add(value); + } + + return array; + } + + private static JsonValue? ToJsonValue(object? value) => value switch + { + null => null, + int i => JsonValue.Create(i), + bool b => JsonValue.Create(b), + double d => JsonValue.Create(d), + _ => JsonValue.Create(value.ToString()), + }; + + /* ── request body parsing + response building ── */ + + /// A parsed create/update body: name (required, trimmed), description (optional), the definition as + /// raw JSON text (re-serialized from the parsed node), and version (present only on updates). + private sealed record ViewWriteRequest(string Name, string? Description, string DefinitionJson, int? Version); + + /// Parses the request body into a , or returns a caller-facing error + /// (the definition's STRUCTURE is validated separately by ). + private static async Task<(ViewWriteRequest? Request, string? Error)> ParseViewBodyAsync(HttpContext context) + { + JsonNode? root; + try + { + root = await JsonNode.ParseAsync(context.Request.Body, cancellationToken: context.RequestAborted); + } + catch (JsonException) + { + return (null, "Request body is not valid JSON."); + } + + if (root is not JsonObject obj) + { + return (null, "Request body must be a JSON object."); + } + + var name = TryGetString(obj, "name"); + if (string.IsNullOrWhiteSpace(name)) + { + return (null, "'name' is required."); + } + + var description = TryGetString(obj, "description"); + + var definitionNode = obj["definition"]; + if (definitionNode is null) + { + return (null, "'definition' is required."); + } + + var definitionJson = definitionNode.ToJsonString(); + + int? version = null; + if (obj["version"] is JsonValue versionValue && versionValue.TryGetValue(out var v)) + { + version = v; + } + + return (new ViewWriteRequest(name.Trim(), description, definitionJson, version), null); + } + + /// The full single-view wire shape (definition embedded as JSON, NOT an escaped string). Shared by + /// the web GET/POST/PUT /api/views responses and the MCP get_custom_view / create_custom_view + /// / update_custom_view tools, so the two surfaces return an identical view shape. + internal static JsonObject BuildFullViewNode(CustomView view) => new() + { + ["id"] = view.Id, + ["name"] = view.Name, + ["description"] = view.Description, + ["definition"] = JsonNode.Parse(view.DefinitionJson), + ["version"] = view.Version, + ["created_at"] = view.CreatedAt, + ["updated_at"] = view.UpdatedAt, + ["updated_by"] = view.UpdatedBy, + }; + + /// The bare-array list wire shape (no definition body). Carries the view kind — always + /// concrete ("dashboard" when the stored definition declares none) so the list page/sidebar can badge + /// and route a notebook vs a dashboard without fetching each full definition. + internal static JsonArray BuildSummariesNode(IReadOnlyList views) + { + var array = new JsonArray(); + foreach (var view in views) + { + array.Add(new JsonObject + { + ["id"] = view.Id, + ["name"] = view.Name, + ["description"] = view.Description, + ["version"] = view.Version, + ["updated_at"] = view.UpdatedAt, + ["updated_by"] = view.UpdatedBy, + ["kind"] = view.Kind ?? "dashboard", + }); + } + + return array; + } + + /* ── result helpers (JSON body written verbatim, bypassing any serializer naming policy) ── */ + + private static IResult JsonNodeResult(JsonNode node, int statusCode = StatusCodes.Status200OK) => + Results.Text(node.ToJsonString(), "application/json", statusCode: statusCode); + + private static IResult CreatedResult(HttpContext context, string location, JsonNode node) + { + context.Response.Headers.Location = location; + return Results.Text(node.ToJsonString(), "application/json", statusCode: StatusCodes.Status201Created); + } + + private static IResult ErrorResult(string message, int statusCode) => + JsonNodeResult(new JsonObject { ["error"] = message }, statusCode); + + private static IResult NotFoundResult() => + ErrorResult("View not found.", StatusCodes.Status404NotFound); + + private static IResult UnsupportedMediaTypeResult() => + ErrorResult("Content-Type must be application/json.", StatusCodes.Status415UnsupportedMediaType); + + /// The signature every read endpoint's handler shares: bind the query string, call the tool. + internal delegate Task ReadToolHandler(HttpContext context, NpgsqlDataSource postgres, DarlingAnalysisService analysis); + + /// + /// The EXPLICIT endpoint-name -> tool-call dispatch table (no runtime reflection): one entry per read-only + /// tool, binding its parameters from the query string and calling the matching public static tool method. The + /// keys ARE the /api/read/* surface — the parity test asserts they equal the [McpServerTool] + /// catalog minus . + /// + internal static IReadOnlyDictionary BuildReadDispatch() + { + return new Dictionary(StringComparer.Ordinal) + { + /* ── analysis reads (take the DarlingAnalysisService) ── */ + ["audit_config"] = (c, pg, an) => DarlingMcpTools.AuditConfig(an, pg, Server(c)), + ["compare_analysis"] = (c, pg, an) => DarlingMcpTools.CompareAnalysis(an, pg, Server(c), Hours(c, 4), QueryInt(c, "baseline_hours_back", null, 28)), + ["get_analysis_facts"] = (c, pg, an) => DarlingMcpTools.GetAnalysisFacts(an, pg, Server(c), Hours(c, 4), Str(c, "source"), QueryDouble(c, "min_severity", 0)), + ["get_analysis_findings"] = (c, pg, an) => DarlingMcpTools.GetAnalysisFindings(an, pg, Server(c), Hours(c, 24)), + + /* ── sessions ── */ + ["get_active_queries"] = (c, pg, an) => DarlingMcpSessionTools.GetActiveQueries(pg, Server(c), Hours(c, 1), Str(c, "database_name"), QueryBool(c, "blocking_only", false), Rows(c, "limit", 50)), + ["get_session_stats"] = (c, pg, an) => DarlingMcpSessionTools.GetSessionStats(pg, Server(c)), + ["get_waiting_tasks"] = (c, pg, an) => DarlingMcpSessionTools.GetWaitingTasks(pg, Server(c), Hours(c, 1), Rows(c, "limit", 30)), + + /* ── alerts / mute rules ── */ + ["get_alert_history"] = (c, pg, an) => DarlingMcpAlertTools.GetAlertHistory(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50)), + ["get_alert_settings"] = (c, pg, an) => DarlingMcpAlertTools.GetAlertSettings(pg), + ["get_mute_rules"] = (c, pg, an) => DarlingMcpAlertTools.GetMuteRules(pg, QueryBool(c, "enabled_only", true)), + + /* ── blocking / deadlocks ── */ + ["get_blocked_process_xml"] = (c, pg, an) => DarlingMcpBlockingTools.GetBlockedProcessXml(pg, Server(c), Hours(c, 24), Rows(c, "limit", 5)), + ["get_blocking"] = (c, pg, an) => DarlingMcpBlockingTools.GetBlocking(pg, Server(c), Hours(c, 24), Rows(c, "limit", 30)), + ["get_blocking_trend"] = (c, pg, an) => DarlingMcpBlockingTools.GetBlockingTrend(pg, Server(c), Hours(c, 24)), + ["get_deadlock_detail"] = (c, pg, an) => DarlingMcpBlockingTools.GetDeadlockDetail(pg, Server(c), Hours(c, 24), Rows(c, "limit", 5)), + ["get_deadlock_trend"] = (c, pg, an) => DarlingMcpBlockingTools.GetDeadlockTrend(pg, Server(c), Hours(c, 24)), + ["get_deadlocks"] = (c, pg, an) => DarlingMcpBlockingTools.GetDeadlocks(pg, Server(c), Hours(c, 24), Rows(c, "limit", 20)), + + /* ── config (current + history) ── */ + ["get_database_config"] = (c, pg, an) => DarlingMcpConfigTools.GetDatabaseConfig(pg, Server(c), Str(c, "database_name")), + ["get_server_config"] = (c, pg, an) => DarlingMcpConfigTools.GetServerConfig(pg, Server(c)), + ["get_trace_flags"] = (c, pg, an) => DarlingMcpConfigTools.GetTraceFlags(pg, Server(c)), + ["get_database_config_changes"] = (c, pg, an) => DarlingMcpConfigHistoryTools.GetDatabaseConfigChanges(pg, Server(c), Hours(c, 168)), + ["get_database_scoped_config"] = (c, pg, an) => DarlingMcpConfigHistoryTools.GetDatabaseScopedConfig(pg, Server(c), Str(c, "database_name")), + ["get_server_config_changes"] = (c, pg, an) => DarlingMcpConfigHistoryTools.GetServerConfigChanges(pg, Server(c), Hours(c, 168)), + ["get_trace_flag_changes"] = (c, pg, an) => DarlingMcpConfigHistoryTools.GetTraceFlagChanges(pg, Server(c), Hours(c, 168)), + + /* ── core data reads ── */ + ["get_collection_health"] = (c, pg, an) => DarlingMcpDataTools.GetCollectionHealth(pg, Server(c)), + ["get_cpu_utilization"] = (c, pg, an) => DarlingMcpDataTools.GetCpuUtilization(pg, Server(c), Hours(c, 4)), + ["get_file_io_stats"] = (c, pg, an) => DarlingMcpDataTools.GetFileIoStats(pg, Server(c)), + ["get_memory_clerks"] = (c, pg, an) => DarlingMcpDataTools.GetMemoryClerks(pg, Server(c)), + ["get_memory_stats"] = (c, pg, an) => DarlingMcpDataTools.GetMemoryStats(pg, Server(c)), + ["get_perfmon_stats"] = (c, pg, an) => DarlingMcpDataTools.GetPerfmonStats(pg, Server(c), Str(c, "counter_name"), Str(c, "instance_name")), + ["get_query_store_top"] = (c, pg, an) => DarlingMcpDataTools.GetQueryStoreTop(pg, Server(c), Hours(c, 24), Rows(c, "top", 20), Str(c, "database_name")), + ["get_long_query_completions"] = (c, pg, an) => DarlingMcpLongQueryTools.GetLongQueryCompletions(pg, Server(c), Hours(c, 24), Rows(c, "limit", 30)), + ["get_server_properties"] = (c, pg, an) => DarlingMcpDataTools.GetServerProperties(pg, Server(c)), + ["get_tempdb_trend"] = (c, pg, an) => DarlingMcpDataTools.GetTempDbTrend(pg, Server(c), Hours(c, 24)), + ["get_top_procedures_by_cpu"] = (c, pg, an) => DarlingMcpDataTools.GetTopProceduresByCpu(pg, Server(c), Hours(c, 24), Rows(c, "top", 20), Str(c, "database_name")), + ["get_top_queries_by_cpu"] = (c, pg, an) => DarlingMcpDataTools.GetTopQueriesByCpu(pg, Server(c), Hours(c, 24), Rows(c, "top", 20), Str(c, "database_name"), QueryBool(c, "parallel_only", false), QueryInt(c, "min_dop", null, 0)), + ["get_wait_stats"] = (c, pg, an) => DarlingMcpDataTools.GetWaitStats(pg, Server(c), Hours(c, 24), Rows(c, "limit", 20)), + ["get_wait_trend"] = (c, pg, an) => RequireText(c, "wait_type", out var waitType) + ? DarlingMcpDataTools.GetWaitTrend(pg, waitType, Server(c), Hours(c, 24)) + : MissingParam("wait_type"), + ["get_wait_types"] = (c, pg, an) => DarlingMcpDataTools.GetWaitTypes(pg, Server(c), Hours(c, 24)), + ["list_servers"] = (c, pg, an) => DarlingMcpDataTools.ListServers(pg), + + /* ── trends ── */ + ["get_file_io_trend"] = (c, pg, an) => DarlingMcpTrendTools.GetFileIoTrend(pg, Server(c), Hours(c, 24)), + ["get_memory_trend"] = (c, pg, an) => DarlingMcpTrendTools.GetMemoryTrend(pg, Server(c), Hours(c, 24)), + ["get_perfmon_trend"] = (c, pg, an) => RequireText(c, "counter_name", out var counter) + ? DarlingMcpTrendTools.GetPerfmonTrend(pg, counter, Server(c), Hours(c, 24)) + : MissingParam("counter_name"), + ["get_query_duration_trend"] = (c, pg, an) => DarlingMcpTrendTools.GetQueryDurationTrend(pg, Server(c), Hours(c, 24)), + ["get_query_trend"] = (c, pg, an) => RequireText(c, "query_hash", out var queryHash) + ? (RequireText(c, "database_name", out var db) + ? DarlingMcpTrendTools.GetQueryTrend(pg, queryHash, db, Server(c), Hours(c, 24)) + : MissingParam("database_name")) + : MissingParam("query_hash"), + + /* ── health / overview ── */ + ["get_server_summary"] = (c, pg, an) => DarlingMcpHealthTools.GetServerSummary(pg, Server(c)), + ["get_daily_summary"] = (c, pg, an) => DarlingMcpHealthTools.GetDailySummary(pg, Server(c), Str(c, "summary_date")), + ["get_fleet_overview"] = (c, pg, an) => DarlingMcpFleetTools.GetFleetOverview(pg, Hours(c, DefaultFleetHours)), + + /* ── latch / spinlock ── */ + ["get_latch_stats"] = (c, pg, an) => DarlingMcpLatchSpinlockTools.GetLatchStats(pg, Server(c), Hours(c, 24), Rows(c, "top", 10)), + ["get_spinlock_stats"] = (c, pg, an) => DarlingMcpLatchSpinlockTools.GetSpinlockStats(pg, Server(c), Hours(c, 24), Rows(c, "top", 10)), + + /* ── memory grants ── */ + ["get_memory_grants"] = (c, pg, an) => DarlingMcpMemoryGrantTools.GetMemoryGrants(pg, Server(c), Hours(c, 1)), + ["get_memory_pressure_events"] = (c, pg, an) => DarlingMcpMemoryGrantTools.GetMemoryPressureEvents(pg, Server(c), Hours(c, 24)), + ["get_resource_semaphore"] = (c, pg, an) => DarlingMcpMemoryGrantTools.GetResourceSemaphore(pg, Server(c), Hours(c, 24)), + + /* ── object / index stats ── */ + ["get_database_sizes"] = (c, pg, an) => DarlingMcpObjectStatsTools.GetDatabaseSizes(pg, Server(c)), + ["get_index_usage"] = (c, pg, an) => DarlingMcpObjectStatsTools.GetIndexUsage(pg, Server(c)), + ["get_object_locking"] = (c, pg, an) => DarlingMcpObjectStatsTools.GetObjectLocking(pg, Server(c)), + ["get_table_index_sizes"] = (c, pg, an) => DarlingMcpObjectStatsTools.GetTableIndexSizes(pg, Server(c)), + + /* ── plan cache / scheduler ── */ + ["get_cpu_scheduler_pressure"] = (c, pg, an) => DarlingMcpPlanCacheSchedulerTools.GetCpuSchedulerPressure(pg, Server(c)), + ["get_plan_cache_bloat"] = (c, pg, an) => DarlingMcpPlanCacheSchedulerTools.GetPlanCacheBloat(pg, Server(c), Hours(c, 24)), + + /* ── jobs ── */ + ["get_running_jobs"] = (c, pg, an) => DarlingMcpJobTools.GetRunningJobs(pg, Server(c)), + + /* ── stored plan XML (READ; the analyze_*_plan compute family stays excluded) ── */ + ["get_plan_xml"] = (c, pg, an) => RequireText(c, "query_hash", out var queryHash) + ? DarlingMcpPlanTools.GetPlanXml(pg, queryHash, Server(c), Str(c, "database_name")) + : MissingParam("query_hash"), + + /* ── default trace ── */ + ["get_default_trace_events"] = (c, pg, an) => DarlingMcpDefaultTraceTools.GetDefaultTraceEvents(pg, Server(c), Hours(c, 24), Rows(c, "limit", 100)), + + /* ── system_health parse-on-read family ── */ + ["get_health_parser_cpu_tasks"] = (c, pg, an) => DarlingMcpHealthParserTools.GetCPUTasks(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50)), + ["get_health_parser_io_issues"] = (c, pg, an) => DarlingMcpHealthParserTools.GetIOIssues(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50)), + ["get_health_parser_memory_broker"] = (c, pg, an) => DarlingMcpHealthParserTools.GetMemoryBroker(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50)), + ["get_health_parser_memory_conditions"] = (c, pg, an) => DarlingMcpHealthParserTools.GetMemoryConditions(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50)), + ["get_health_parser_memory_node_oom"] = (c, pg, an) => DarlingMcpHealthParserTools.GetMemoryNodeOOM(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50)), + ["get_health_parser_scheduler_issues"] = (c, pg, an) => DarlingMcpHealthParserTools.GetSchedulerIssues(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50)), + ["get_health_parser_severe_errors"] = (c, pg, an) => DarlingMcpHealthParserTools.GetSevereErrors(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50)), + ["get_health_parser_system_health"] = (c, pg, an) => DarlingMcpHealthParserTools.GetSystemHealth(pg, Server(c), Hours(c, 24), Rows(c, "limit", 50)), + }; + } + + /* ─────────────────────────── response mapping ─────────────────────────── */ + + /// How a tool's returned string maps to an HTTP outcome. + internal enum ToolResponseKind + { + /// A serialized JSON object/array (data, or the {"status", ...} envelope) — 200 passthrough. + JsonPassthrough, + + /// A bare "Error during ..." string (the tool caught an exception) — HTTP 500. + ServerError, + + /// Any other bare string (validation / resolution) — a client-correctable HTTP 400. + ClientError, + } + + /// + /// Classifies a tool's returned string. A leading { or [ (after any whitespace) is a serialized + /// object/array and passes through; a "Error during ..." string is the tool's caught-exception shape + /// (HTTP 500); anything else is a bare validation / resolution message (HTTP 400). Pure so the whole mapping + /// is unit-testable. + /// + internal static ToolResponseKind ClassifyToolResponse(string result) + { + var trimmed = result.AsSpan().TrimStart(); + if (trimmed.Length > 0 && (trimmed[0] == '{' || trimmed[0] == '[')) + { + return ToolResponseKind.JsonPassthrough; + } + + if (result.StartsWith("Error during ", StringComparison.Ordinal)) + { + return ToolResponseKind.ServerError; + } + + return ToolResponseKind.ClientError; + } + + private static IResult ToHttpResult(string result) => ClassifyToolResponse(result) switch + { + ToolResponseKind.JsonPassthrough => Results.Text(result, "application/json"), + ToolResponseKind.ServerError => Results.Json(new { error = result }, statusCode: StatusCodes.Status500InternalServerError), + _ => Results.Json(new { error = result }, statusCode: StatusCodes.Status400BadRequest), + }; + + /* ─────────────────────────── query-string binding ─────────────────────────── */ + + /// The server name from ?server= (or the tool's own ?server_name=); null when absent, + /// which lets a tool auto-select a sole configured server exactly as the MCP surface does. + private static string? Server(HttpContext context) => First(context, "server") ?? First(context, "server_name"); + + /// The hours-back window from ?hours= (or the tool's own ?hours_back=), else the tool's default. + private static int Hours(HttpContext context, int def) => QueryInt(context, "hours", "hours_back", def); + + /// An optional text parameter; null when absent or empty (so the tool sees its own default). + private static string? Str(HttpContext context, string key) => First(context, key); + + /// A required text parameter — true with the value when present, false when absent/empty. + private static bool RequireText(HttpContext context, string key, out string value) + { + value = First(context, key) ?? ""; + return value.Length > 0; + } + + private static Task MissingParam(string key) => Task.FromResult($"Missing required parameter '{key}'."); + + private static int QueryInt(HttpContext context, string key, string? aliasKey, int def) => + ParseInt(First(context, key) ?? (aliasKey is null ? null : First(context, aliasKey)), def); + + /// + /// A row-count knob (?limit= / ?top=), clamped to [1, ] at the + /// dispatch layer so an authenticated/loopback caller can't request an unbounded result set from a reader + /// that binds the value straight into LIMIT $N (the /api/fleet window is clamped the same way). + /// + private static int Rows(HttpContext context, string key, int def) => + ClampRows(QueryInt(context, key, null, def)); + + /// The dispatch-layer ceiling on any caller-supplied row count — generous for real use, bounded against abuse. + internal const int MaxRowLimit = 1000; + + /// PURE row-count clamp to [1, ] — the abuse bound the row-knob binding applies. + internal static int ClampRows(int requested) => Math.Clamp(requested, 1, MaxRowLimit); + + private static bool QueryBool(HttpContext context, string key, bool def) => ParseBool(First(context, key), def); + + private static double QueryDouble(HttpContext context, string key, double def) => ParseDouble(First(context, key), def); + + /// The first non-empty value for a query key, or null. + private static string? First(HttpContext context, string key) + { + var value = context.Request.Query[key].ToString(); + return string.IsNullOrEmpty(value) ? null : value; + } + + /* Pure parse helpers (invariant culture) — the binding logic the tests pin without an HttpContext. */ + + internal static int ParseInt(string? raw, int def) => + int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : def; + + internal static bool ParseBool(string? raw, bool def) => + bool.TryParse(raw, out var value) ? value : def; + + internal static double ParseDouble(string? raw, double def) => + double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? value : def; +} diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs new file mode 100644 index 000000000..6599bab8d --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs @@ -0,0 +1,2965 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Data; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Npgsql; +using PerformanceMonitor.Alerting; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Analysis; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Service.Mcp; +using PerformanceMonitor.Notifications; +using PerformanceMonitor.PlanAnalysis; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// The 24/7 collection loop (headless plan M2): load darling.json, bootstrap the bundled +/// Postgres first when postgres.managed is true ( — +/// unpack/initdb/start before anything touches the store, stop-on-shutdown only if this +/// process started it), migrate the Postgres store, +/// detect optional TimescaleDB (hypertables + compression when present, plain PG otherwise — +/// see TimescaleSupport), re-seed delta baselines from it (restart continuity — the Postgres +/// twin of Lite's DuckDB +/// seeding, so a service restart doesn't zero the first cycle's deltas), connect and probe each +/// monitored server, ensure the XE sessions, run the on-load config +/// snapshots once, then run every scheduled collector on the shared +/// cadence through . +/// A server that fails to connect is retried every sweep; a collector that errors is logged and +/// retried on its next due time — the loop never dies for one bad cycle. Dispatch mirrors Lite's: +/// the deadlock/blocked-process readers tolerate a missing XE session as zero rows, and +/// trace_flags tolerates denied DBCC as zero rows with a warning. Every successful connect +/// upserts the servers registry and every collector run writes a collection_log row — both +/// failure-isolated (). On top of collection the loop runs +/// the shared alert engine per server every 30 seconds and, since AN3, the analysis pipeline +/// () per server every 30 minutes with findings routed +/// through the shared . +/// +public sealed class DarlingWorker : BackgroundService +{ + private static readonly TimeSpan s_sweepInterval = TimeSpan.FromSeconds(15); + + /* The alert engine's evaluation cadence — Lite's overview/alert sweep runs on its 30-second + status timer (MainWindow.xaml.cs:144), so the headless twin evaluates each connected server + every 30 seconds too (the collector sweep itself runs every 15). Cooldowns and the + edge-trigger gates shape delivery on top of this. */ + private static readonly TimeSpan s_alertSweepInterval = TimeSpan.FromSeconds(30); + + /* The command plane's poll cadence (Stage 2): a tighter 5-second tick, run on its OWN loop + independent of the 15-second collection sweep and the 30-second alert sweep, so an operator + command (pause, test_connect, snapshot_now, ...) is picked up within ~5s and a slow command + (a test_connect against an unreachable host can block for the connect timeout) never starves + collection — the two loops share a cancellation token and the guarded server set only. */ + private static readonly TimeSpan s_commandPollInterval = TimeSpan.FromSeconds(5); + + /* The store disk-pressure self-alert's poll cadence (fleet-level, Stage 4). Disk fills slowly, and the + check is one pg_database_size + one DriveInfo syscall, so 5 minutes is ample and cheap — no need to + run it on the 30-second alert sweep. */ + private static readonly TimeSpan s_diskCheckInterval = TimeSpan.FromMinutes(5); + + /* The compression-job self-heal check's cadence (fleet-level, #1581). Compression is a slow archival tier + and a stuck policy job takes hours to matter, so hourly is ample and cheap (one job_stats read + at most + one alter_job per stuck job) — no need for the 15s sweep or the 30s alert cadence. */ + private static readonly TimeSpan s_compressionCheckInterval = TimeSpan.FromHours(1); + + /* The analysis pipeline's per-run budget — Lite's App default hardcoded (AnalysisTimeoutSeconds + 120; not a control-plane knob). The CADENCE (interval), the enabled gate, and the notify gate + are now control-plane knobs read live from config.Analysis (config_alert_settings' analysis + columns) — see the loop below. Each run analyzes the last 4 hours (Lite's hoursBack default). */ + private static readonly TimeSpan s_analysisTimeout = TimeSpan.FromSeconds(120); + + /* Clamp the store-driven analysis interval to Lite's accepted range (App clamps 5-360). */ + private const int MinAnalysisIntervalMinutes = 5; + private const int MaxAnalysisIntervalMinutes = 360; + + /// + /// The bounded per-server collection concurrency (the fire-and-track sweep, #1553): at most this many + /// servers' collection bodies run at once, each opening at most ONE SQL connection (collectors stay + /// sequential within a body — Lite's RemoteCollectorService shape). Hardcoded, no control-plane knob + /// (defaults-over-config): 4 clears a 24-server worst case in ~6 waves while the 120s analysis budget stays + /// de-clustered by the cadence jitter, so one slow/hung server can never head-of-line-block the fleet the + /// way the old strictly-sequential foreach did (the 24-server field incident). Internal so a unit test pins + /// the value against this rationale (a cheap drift tripwire — see the plan). + /// + internal const int MaxConcurrentServerSweeps = 4; + + /// + /// Seconds an in-flight collection body may go unresolved before the sweep watchdog surfaces it. One + /// threshold serves both channels below — what differs is WHICH clock it is measured against. + /// + internal const int SweepWatchdogSeconds = 60; + + /// What (if anything) the in-flight watchdog should surface for one server this sweep. + internal enum SweepEpisodeSignal + { + /// Nothing to say — under threshold, or already surfaced once this episode. + None, + + /// The body is EXECUTING and has not finished: a genuine stall. Warning. + Hang, + + /// The body has not started — still queued behind the concurrency gate. Capacity, Info. + Queued + } + + /// + /// Pure decision for the in-flight sweep watchdog. Split out so the truth table is unit-pinned rather than + /// buried in the loop, because getting it wrong is expensive in BOTH directions: attributing gate queue time + /// to the hang channel fired ~82 warnings/hour at 24 servers behind the N=4 gate on a healthy fleet (burying + /// the real signal), while dropping the queued case entirely would hide genuine capacity pressure — a body + /// waiting minutes for a slot IS unserved. So a running body is judged on + /// (its own execution clock) and a queued one on (since launch), each + /// latched to fire once per episode. + /// + internal static SweepEpisodeSignal ClassifySweepEpisode( + double episodeSeconds, + bool running, + double runningSeconds, + bool alreadyWarned, + bool alreadyQueuedInfo) + { + if (running) + { + return !alreadyWarned && runningSeconds >= SweepWatchdogSeconds + ? SweepEpisodeSignal.Hang + : SweepEpisodeSignal.None; + } + + return !alreadyQueuedInfo && episodeSeconds >= SweepWatchdogSeconds + ? SweepEpisodeSignal.Queued + : SweepEpisodeSignal.None; + } + + /// + /// #1581 cold-start launch-spread window (seconds). On a service restart the whole fleet's FIRST sweep bodies + /// would otherwise launch in a single 15s tick and queue behind the + /// gate, so the ones that waited past 60s logged "collection body has not completed after 60s" en masse (the + /// field herd: 366 warnings over ~10 min, nothing actually broken — 0 collector errors, data landed). Each + /// server's first launch is deferred by a deterministic per-server offset in [0, ColdStartSpreadSeconds); 150s + /// mirrors the fixed post-connect analysis-phase window () so no new tuning + /// knob is introduced. Distinct from the per-collector #1575 seed jitter, which staggers WHICH collectors are + /// due once a body runs, not WHEN the heavyweight connect body itself launches. A drift tripwire pins it. + /// + internal const int ColdStartSpreadSeconds = 150; + + /// + /// The working-set launch-guard threshold, as a fraction of available memory (#1556). Pinned by a test + /// (a drift tripwire): the launch guard is the fleet-level backstop against the commit-limit exhaustion + /// the field incident hit, so its threshold must not silently drift. + /// + internal const double MemoryGuardFraction = 0.80; + + /// + /// The working-set launch guard (#1556): whether the fleet sweep may launch NEW collection bodies this + /// tick. Once the process working set crosses of available memory this + /// returns false, so the launch loop stops STARTING new bodies and lets the in-flight ones drain — the + /// process backs away from the 0→13GB commit-limit blowout instead of piling on more concurrent + /// collectors. Purge/disk/analysis/delay keep running (the guard only gates NEW launches). Pure so a unit + /// test pins the bands and the constant; the caller passes Process.PrivateMemorySize64 (the metric + /// that matched the incident — committed private bytes, not the GC heap) and + /// GC.GetGCMemoryInfo().TotalAvailableMemoryBytes. A non-positive available figure (an unknown + /// budget) never blocks collection. + /// + internal static bool ShouldLaunchSweeps(long workingSetBytes, long availableBytes) + { + if (availableBytes <= 0) + { + return true; + } + + return workingSetBytes < MemoryGuardFraction * availableBytes; + } + + /* The shutdown drain budget for the in-flight per-server bodies (#1553 fire-and-track): wait at most this + long for launched bodies to finish before falling through to the serial command-loop drain. Budgeted + INSIDE the host's default 30s ShutdownTimeout with headroom for that command-loop drain — a future bump + MUST respect that ceiling or shutdown starts being force-killed mid-drain. */ + private static readonly TimeSpan s_shutdownDrainBudget = TimeSpan.FromSeconds(15); + + /// Test hook: the hardcoded per-run analysis budget, pinned against Lite's default. + internal static TimeSpan AnalysisTimeout => s_analysisTimeout; + + /// + /// The Stage 2 pause gate: whether the collection sweep does work this tick. FALSE while the service is + /// paused (config_service.paused, mirrored into _paused on reload) — the loop then skips all + /// collection/alert/analysis/purge work but keeps polling the reload beacon and the command queue, so a + /// resume un-pauses it on the next tick. Pure so the gate is unit-testable without driving the loop. + /// + internal static bool ShouldRunCollection(bool paused) => !paused; + + /// + /// The network-endpoint startup warnings the worker emits AFTER + /// passes (darling-network-endpoints) — NEVER inside Validate(), which is all-fatal, so an optional, + /// default-off endpoint note can never abort collection (D-BYO / D-validate): + /// + /// BYO mode (managed=false) with any postgres.network.* or mcp.network.* + /// set — the fields are IGNORED; the operator's own PostgreSQL governs exposure. + /// Managed mode with an EXPOSED store whose network.role is admin — names the + /// config_command / config_monitored_servers / config_notification + /// service-credential pivot a remote admin connection can reach (D7 — the operator's informed opt-in). + /// + /// Pure so a unit test asserts the returned strings without driving the loop or a live logger. + /// + internal static IReadOnlyList GetNetworkStartupWarnings(DarlingConfig config) + { + var warnings = new List(); + + if (!config.Postgres.Managed) + { + /* BYO: network.* is managed-mode only. Warn per section that is set (D-BYO). */ + if (config.Postgres.Network?.IsConfigured == true) + { + warnings.Add( + "postgres.network.* is set but postgres.managed is false — it is IGNORED in bring-your-own mode; your own PostgreSQL governs its network exposure (pg_hba / listen_addresses / TLS)."); + } + + if (config.Mcp.Network?.IsConfigured == true) + { + warnings.Add( + "mcp.network.* is set but postgres.managed is false — the MCP network endpoint is managed-mode only, so it is IGNORED; the MCP server stays loopback-only."); + } + + return warnings; + } + + /* Managed + the store is genuinely exposed + the network role resolves to admin (D7 pivot warning). */ + var network = config.Postgres.Network; + if (network is not null + && DarlingNetwork.IsExposedListenAddress(network.Listen) + && string.Equals(DarlingNetwork.NormalizeNetworkRole(network.Role), "admin", StringComparison.Ordinal)) + { + warnings.Add( + "postgres.network.role is 'admin' — a REMOTE admin connection can write config_command (the test_connect service-credential pivot), config_monitored_servers, and config_notification (webhook exfil). This is an explicit opt-in; the secure default is 'viewer' (read-only). Only expose admin on a trusted network."); + } + + return warnings; + } + + private readonly ILogger _logger; + private readonly ILoggerFactory _loggerFactory; + + /* Set once by ExecuteAsync before the loop starts; the observability writes need it. */ + private NpgsqlDataSource? _postgres; + + /* The live control-plane state (Stage 1): the last-seen config_version reload beacon and the + current sparse per-collector schedule overrides. Both updated on startup and on each reload; + read by the schedule-resolution path (TryConnectAsync / RunDueCollectorsAsync) and the reload. */ + private long _lastConfigVersion = -1; + private IReadOnlyList _scheduleOverrides = Array.Empty(); + + /* The service-pause flag (Stage 2): read from config_service.paused on every reload and honored by + the collection loop (skip collection/alert/analysis/purge while paused — Lite's IsPaused gate). Set + ONLY by the main loop's reload (single writer); the command loop keeps running while paused so a + resume command is processed. A pause/resume command writes the store, the bump trigger fires the + reload beacon, and the reload sets this — the same path every other control-plane setting takes. */ + private bool _paused; + + /* Guards structural access to the monitored-server list because the command loop (a concurrent task) + looks servers up by id while the main loop reconciles (adds/removes) them and the alert / analysis + plan/failed-job fetchers enumerate them. Only ever held for the microsecond lookup/mutation — never + across collection I/O — so a long-running command never blocks the collection loop on it. */ + private readonly object _serversLock = new(); + + /* Server IDs whose scheduled analysis is currently running — prevents relaunching + analysis for a server whose previous (possibly hung) pass has not finished + (Lite's CollectionBackgroundService in-flight guard). */ + private readonly ConcurrentDictionary _analysisInFlight = new(); + + /* MinValue = the first sweep after startup runs the retention purge, then daily. */ + private DateTime _nextPurgeUtc = DateTime.MinValue; + + /* MinValue = the first sweep after startup evaluates the store disk-pressure self-alert, then every + s_diskCheckInterval. Fleet-level (one shared store), so it is a single field, not per-server. */ + private DateTime _nextDiskCheckUtc = DateTime.MinValue; + + /* MinValue = the first sweep after startup evaluates the compression-job self-heal check (#1581), then + every s_compressionCheckInterval. Fleet-level (one shared store), so it is a single field, not + per-server; only consulted when _timescaleAvailable. */ + private DateTime _nextCompressionCheckUtc = DateTime.MinValue; + + /* Fleet-level working-set launch-guard latch (#1556): true once ShouldLaunchSweeps has tripped this + episode, so its CRITICAL log is emitted ONCE rather than every sweep (the WarnedThisEpisode idiom — + but fleet-wide: the guard is about the whole process's working set, so it is a single worker field, + NOT a per-ServerLoopState flag). Cleared when the working set recovers below the threshold. */ + private bool _memoryGuardTrippedThisEpisode; + + /* Set once at startup by the TimescaleSupport detection (cached per data source — the + extension can't appear or vanish under a running service without a restart anyway); + branches the retention purge onto drop_chunks. */ + private bool _timescaleAvailable; + + /* Stage 4 service self-alerts (collection-stopped, connection lost/restored, capture-down). Built + once in RunCollectionLoopAsync over the SAME deliverer/history the shared engine uses, so the + self-alerts inherit its delivery/cooldown/restart-replay. Held as a field because the connection + edge fires from TryConnectAsync and the reconcile drops per-server state through it. */ + private DarlingSelfAlertEvaluator? _selfAlerts; + + /* #1560: the live MCP enable/port seam — published to the MCP host's supervisor at startup and on + every control-plane reload, so the viewer's Settings toggle takes effect without a restart. */ + private readonly McpRuntimeState _mcpState; + + /* #1562: the live WEB dashboard enable/port seam — the twin of _mcpState, published to the web host's + supervisor at startup and on every control-plane reload so the viewer's Settings toggle takes effect + without a restart. */ + private readonly WebRuntimeState _webState; + + public DarlingWorker(ILogger logger, ILoggerFactory loggerFactory, McpRuntimeState mcpState, WebRuntimeState webState) + { + _logger = logger; + _loggerFactory = loggerFactory; + _mcpState = mcpState; + _webState = webState; + } + + private sealed class ServerLoopState + { + /* Settable so the reconcile can replace a still-connected server's definition on a config + change (host/auth/excluded-dbs/cost) — paired with dropping Runtime to force a reconnect. */ + public required MonitoredServer Config { get; set; } + public ServerRuntime? Runtime { get; set; } + + /* ConcurrentDictionary (#1553 D1): with the fire-and-track sweep the per-server body runs on a pool + thread, so a reload's RecomputeNextDueAsync on the OUTER thread can touch this map concurrently with the + body's RunDueCollectorsAsync read-and-advance. It is only ever INDEXED by the static collector-catalog + keys (never enumerated), so a lock-free concurrent map is a drop-in and eliminates the one structure + the old strict single-threaded invariant (INV-1) existed to protect from tearing. */ + public ConcurrentDictionary NextDue { get; } = new(StringComparer.OrdinalIgnoreCase); + public DateTime NextConnectAttempt { get; set; } = DateTime.MinValue; + + /* MinValue = the first loop pass after connect evaluates alerts immediately. */ + public DateTime NextAlertSweep { get; set; } = DateTime.MinValue; + + /* MinValue = the first loop pass evaluates the Stage 4 service self-alerts (collection-stopped, + capture-down) immediately. Separate from NextAlertSweep because the self-alert sweep runs for + a DISCONNECTED server too (collection-stopped is exactly the unreachable-server case), above + the Runtime-null connect gate. */ + public DateTime NextSelfAlertSweep { get; set; } = DateTime.MinValue; + + /* Default MinValue; on connect TryConnectAsync PHASES this to now + a sub-2.5-minute per-server offset + (#1553 jitter site 3) when analysis is enabled, so a fleet restart does not make every freshly + connected server analysis-due in the same sweep (with N=4 that would cluster 4 analysis passes at + once). The pipeline's own 24h data-span gate still no-ops it until the store has enough history + (Lite's GetTotalDataSpanHoursAsync gate), so a fresh server simply re-checks every interval while an + already-populated store analyzes promptly (within a ~2.5-minute phase spread). Left at MinValue when + analysis is disabled so re-enabling runs immediately. */ + public DateTime NextAnalysisDue { get; set; } = DateTime.MinValue; + + /* Serializes this server's collector batch so an on-demand snapshot_now (from the command loop) + and the scheduled sweep (from the main loop) never run the same server's collectors at once — + which would double-COPY overlapping rows and race the shared delta baselines. The main loop + try-acquires with a zero timeout (skip this server this sweep if a snapshot holds it); the + snapshot waits its turn. Binary (1,1). */ + public SemaphoreSlim CollectionGate { get; } = new(1, 1); + + /* Fire-and-track sweep bookkeeping (#1553 D2/D2b) — these FOUR written ONLY by the OUTER sweep thread + (the launch loop and the shutdown drain); the per-server body NEVER touches them, so there is no + cross-thread tear to reason about (the torn-read note that applies to the DateTime schedule fields + above deliberately does NOT apply here). InFlightSweep is this server's currently-running (or + last-completed) body Task — the launch loop skips relaunch while it is not completed (INV-2: one body + per server) and the shutdown drain awaits it. SweepStartedUtc is stamped at LAUNCH, so it still + measures the whole EPISODE (queue + run): a body queued minutes behind the N=4 gate IS unserved, and + that remains true. WarnedThisEpisode gates the one-Warning-per-episode HANG log and + QueuedInfoThisEpisode the one-Info-per-episode QUEUED log, so each is surfaced once, not every sweep. */ + public Task? InFlightSweep { get; set; } + public DateTime SweepStartedUtc { get; set; } + public bool WarnedThisEpisode { get; set; } + public bool QueuedInfoThisEpisode { get; set; } + + /* The ONE piece of sweep bookkeeping the BODY writes — and the single reason it is a FIELD rather than a + property: Interlocked needs a ref to a field. UTC ticks stamped by the body the moment it acquires the + concurrency gate; 0 while it is still QUEUED behind that gate. Written once per episode by the body + (Interlocked.Exchange) and read by the outer launch loop (Interlocked.Read) — a long is not guaranteed + atomic on 32-bit, so BOTH sides go through Interlocked rather than assuming it. The existing three + fields above keep their outer-thread-only invariant untouched; this is a separate field precisely so + that invariant does not have to be weakened. + + Why it exists: the 60s watchdog is a HANG detector — "the field incident was HANGS, not throws" — but + measuring from SweepStartedUtc alone counted GATE QUEUE TIME as hang time, so at 24 servers behind the + N=4 gate it fired ~82 times/hour on a demonstrably healthy fleet (0 collector errors, data landing) and + buried the very signal it exists to raise. Splitting run time out restores it: a body merely waiting + its turn is reported as CAPACITY, never as a hang. */ + public long RunStartedTicks; + + /* #1581 cold-start stagger: the earliest UTC this server's FIRST post-startup sweep body may launch — + the captured startup instant plus a deterministic per-server CadencePhaseOffset capped at + ColdStartSpreadSeconds — so a service restart does not launch every server's initial catch-up body in + one tick and slam the N=4 gate (the field herd that logged "collection body has not completed after + 60s" en masse). Seeded ONCE at construction for the initial fleet (single-threaded startup) and read + only by the OUTER launch loop; a reconcile-ADDED server keeps the MinValue default and launches + promptly (a single add is not a herd). Gates ONLY the first launch (InFlightSweep still null), so + steady-state cadence is untouched. */ + public DateTime FirstSweepDueUtc { get; set; } = DateTime.MinValue; + + /* Retired containment (#1553 D1): set TRUE (write-once) by the reconcile-remove branch when this server + is disabled/deleted, alongside Runtime=null and _selfAlerts.Forget. A body launched — or gate-queued — + before the removal re-checks this as its FIRST statement after acquiring the fleet gate and no-ops, so + a just-disabled server is never connected, never has XE CREATE SESSION DDL run against it, and never + re-writes self-alert edge state after Forget removed it. volatile because the body reads it on a pool + thread after the outer thread's write. A remove+re-add mints a FRESH ServerLoopState, so there is no + reset and no ABA. */ + public volatile bool Retired; + + /* Last-applied enabled state of the OPT-IN long-query completion XE session (#1496), so the + per-sweep reconcile only opens a connection to run CREATE/DROP DDL when the desired state + actually changes — null = not yet reconciled (next sweep applies it), true = ensured + (enabled), false = confirmed dropped (disabled). Reset to null on every (re)connect so a + stopped Azure database-scoped session is re-ensured; in steady state a default-off collector + is drop-confirmed once per connect and then skipped every sweep. Written by the per-server + sweep body and the connect path (both run on the pool thread, one at a time per server — + INV-2), like the DateTime schedule fields above. */ + public bool? LongQueryTraceApplied { get; set; } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + DarlingConfig config; + try + { + var configPath = DarlingConfig.ResolveConfigPath(); + config = DarlingConfig.Load(); + _logger.LogInformation("Loaded configuration from {Path}: {ServerCount} server(s)", configPath, config.Servers.Count); + } + catch (Exception ex) + { + _logger.LogCritical("Cannot load configuration: {Message}", ex.Message); + return; + } + + var problems = config.Validate(); + if (problems.Count > 0) + { + foreach (var problem in problems) + { + _logger.LogCritical("Configuration problem: {Problem}", problem); + } + return; + } + + /* Network-endpoint caller warnings (darling-network-endpoints, D-BYO / D7) — emitted AFTER + Validate() passes and NEVER inside it (Validate is all-fatal; an optional-endpoint note must not + abort startup). Covers BYO-mode network.* being ignored and the network.role=admin pivot risk. */ + foreach (var warning in GetNetworkStartupWarnings(config)) + { + _logger.LogWarning("{Warning}", warning); + } + + /* Bundled-Postgres bootstrap (the shipped zero-admin default): in managed mode the + service unpacks/initializes/starts its own Postgres BEFORE the store connection + below, and the connection string is DERIVED (localhost + port + the generated + DPAPI credential), never configured. Windows-only, like every DPAPI surface here. + A bootstrap failure is the existing no-store behavior: LogCritical + clean exit. */ + DarlingManagedPostgres? managedPostgres = null; + var storeConnectionString = config.Postgres.ConnectionString; + if (config.Postgres.Managed) + { + if (!OperatingSystem.IsWindows()) + { + _logger.LogCritical( + "postgres.managed = true requires Windows (the bundled runtime and the DPAPI-protected credential); " + + "set postgres.managed = false and point postgres.connectionString at your own PostgreSQL instead."); + return; + } + + managedPostgres = new DarlingManagedPostgres(config.Postgres, _logger); + try + { + storeConnectionString = await managedPostgres.EnsureRunningAsync(stoppingToken); + } + catch (OperationCanceledException) + { + return; + } + catch (Exception ex) + { + _logger.LogCritical("Managed Postgres bootstrap failed: {Message}", ex.Message); + return; + } + } + + try + { + await RunCollectionLoopAsync(config, storeConnectionString, stoppingToken); + } + finally + { + /* Stop the bundled server ONLY if this process started it — never one the operator + (or a surviving previous run) owns. Runs on every exit path, including a failed + migration, AFTER the loop's data source is disposed. The IsWindows re-check is a + CA1416 guard only — a non-null managedPostgres already implies Windows. */ + if (managedPostgres is not null && OperatingSystem.IsWindows()) + { + await managedPostgres.StopIfStartedByThisProcessAsync(); + } + } + } + + /// + /// Everything after the (optional) managed-Postgres bootstrap: store connection, migration, + /// Timescale adoption, delta seeding, and the collection/alert/analysis loop. Split from + /// so the bootstrap's finally can stop the bundled server after + /// this method's data source is disposed. + /// + private async Task RunCollectionLoopAsync(DarlingConfig config, string storeConnectionString, CancellationToken stoppingToken) + { + /* Carry the collect/config search path on the store connection string BEFORE the data + source (and its pool) is created, so every pooled physical connection resolves the + shared SQL's bare table names to the V8 schemas from its very first use — deterministic + and independent of the pool's connection-open timing relative to PgMigrations' + best-effort ALTER DATABASE ... SET search_path. Without this a FRESH bring-your-own + store silently collects nothing until the service is restarted; see + EnsureStoreSearchPath for the pool-timing root cause. Managed mode already sets it, so + this is a no-op there. */ + storeConnectionString = EnsureStoreSearchPath(storeConnectionString); + await using var postgres = NpgsqlDataSource.Create(storeConnectionString); + _postgres = postgres; + try + { + await using var migrateConnection = await postgres.OpenConnectionAsync(stoppingToken); + /* MigrateAsync (logger overload) also best-effort sets the database-default search_path to + collect/config for every future connection (V8 security split); a least-privilege BYO + login that cannot ALTER DATABASE is warned, not failed — the managed connection strings + carry Search Path regardless. */ + var applied = await PgMigrations.MigrateAsync(migrateConnection, _logger, stoppingToken); + _logger.LogInformation("Postgres store ready (schema v{Version}, {Applied} migration(s) applied)", + StorageVersion.SchemaVersion, applied); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogCritical("Cannot reach or migrate the Postgres store: {Message}", ex.Message); + return; + } + + /* Least-privilege role provisioning (V8 security hardening), managed mode only: create / + refresh the admin + viewer login roles and their per-role DPAPI credentials, and grant the + collect/config privileges — idempotent and self-healing, the conf-append discipline applied + to roles. Windows-only (DPAPI credential files); a failure degrades (the Viewer cannot + connect as admin/viewer until a later start succeeds) but never kills collection, which + connects as the owner. BYO stores provision roles out-of-band via tools/provision-roles.sql. */ + if (config.Postgres.Managed && OperatingSystem.IsWindows()) + { + try + { + var dataDirectory = DarlingManagedPostgres.ResolveDataDirectory(config.Postgres); + await DarlingManagedRoles.EnsureProvisionedAsync(postgres, dataDirectory, _logger, stoppingToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError( + "Least-privilege role provisioning failed — the Viewer's admin/viewer roles may be stale " + + "until the next successful start: {Message}", ex.Message); + } + } + + /* Optional TimescaleDB adoption — runtime setup, deliberately NOT a versioned migration + (the store must work with or without the extension; migrations stay engine-plain). + Detected once at startup; when present, the collector tables become hypertables with + a compression policy (Darling's archival tier) and the daily retention purge below + switches to drop_chunks. All idempotent, so every restart re-converges. In its own + try/catch OUTSIDE the critical migrate block: an optional feature failing must degrade + to plain-PostgreSQL mode, never kill the service. + This block runs AFTER CREATE EXTENSION (TryEnableAsync), which is why the AUTHORITATIVE + collection_log conversion (EnsureCollectionLogHypertableAsync) lives here, not in the V23 + migration: MigrateAsync above runs BEFORE the extension exists, so a fresh store's V23 + guard skips the conversion and this heals it (collection_log is outside the collector + catalog, so the loop calls above never touch it). */ + try + { + await using var timescaleConnection = await postgres.OpenConnectionAsync(stoppingToken); + _timescaleAvailable = await TimescaleSupport.TryEnableAsync(timescaleConnection, _logger, stoppingToken); + if (_timescaleAvailable) + { + await TimescaleSupport.ConvertToHypertablesAsync(timescaleConnection, _logger, stoppingToken); + await TimescaleSupport.ApplyCompressionPolicyAsync(timescaleConnection, _logger, stoppingToken); + await TimescaleSupport.EnsureCollectionLogHypertableAsync(timescaleConnection, _logger, stoppingToken); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + /* A partially-converted store is fine: DELETE-based retention works on hypertables + too, so falling back to plain-PG mode is always safe. */ + _timescaleAvailable = false; + _logger.LogWarning("TimescaleDB setup failed — continuing in plain-PostgreSQL mode: {Message}", ex.Message); + } + + /* Restart continuity: re-seed delta baselines from the store (the Postgres twin of Lite's + DuckDB seeding) so the first cycle after a service restart produces real deltas instead + of zeroes. A seed failure logs a warning and collection proceeds with first-cycle-zero. */ + var deltas = new DarlingDeltaCalculator(); + await deltas.SeedFromStoreAsync(postgres, _logger, stoppingToken); + + /* Control-plane Stage 1: SEED the config store from darling.json once (idempotent; only empty + sections), then read the store view and make it authoritative — the held DarlingConfig is + mutated in place so the alert-settings/capture-plans/analysis seams below all reflect the + store. Store-unreachable degrades to the darling.json-loaded config (never worse than before). + The initial server set comes from config_monitored_servers WHERE is_enabled = TRUE (post-seed + equivalent to darling.json, but store-authoritative); the config_version read here is the + reload baseline, so the seed's own version bumps do not trigger a spurious first-sweep reload. */ + var configProvider = new StoreConfigProvider(postgres, _logger); + await configProvider.SeedIfEmptyAsync(config, stoppingToken); + var initialView = await configProvider.LoadViewAsync(config, stoppingToken); + IReadOnlyList initialServers = config.Servers; + if (initialView is not null) + { + StoreConfigProvider.ApplyToConfig(config, initialView); + _scheduleOverrides = initialView.ScheduleOverrides; + _lastConfigVersion = initialView.ConfigVersion; + /* Stage 2: honor config_service.paused from the very first sweep (a service that was paused + before a restart comes back up paused). */ + _paused = initialView.Paused; + /* Reconcile the observed collect.servers.is_enabled to the desired state up front, so a server + disabled in the store while the service was down shows disabled even though it never connects + (never upserts) this run. */ + await DarlingObservability.SyncServerEnabledStatesAsync(postgres, _logger, stoppingToken); + /* Post-seed the store carries darling.json's servers; fall back to the file only if the store + read is empty (a partially-seeded store), so the service never starts up monitoring nothing. */ + if (initialView.EnabledServers.Count > 0) + { + initialServers = initialView.EnabledServers; + } + } + + /* #1560/#1562: publish the effective MCP + web enable/port (store-authoritative when the view loaded, + else the darling.json values) so the two hosts' supervisors start from the same truth the worker + holds — including on a store-unreachable boot. Re-published on every reload below. */ + _mcpState.Publish(config.Mcp.Enabled, config.Mcp.Port); + _webState.Publish(config.Web.Enabled, config.Web.Port); + + /* Capture-plans is read live (() => config.CapturePlans) so a store reload of + config_service.capture_plans is honored on the next collector cycle without rebuilding. + CollectSchemaChangeEvents is a file-only knob (darling.json), read the same way for symmetry — + default true keeps every SKU collecting Object DDL; set false to silence a benchmark box's flood. */ + var runner = new DarlingCollectorRunner(postgres, deltas, _logger, () => config.CapturePlans, () => config.CollectSchemaChangeEvents); + var servers = new List(); + /* #1581 cold-start stagger: capture ONE startup instant so every initial server's first-sweep offset is + measured from the same base — the deterministic per-server ColdStartFirstSweepDue then spreads the + fleet's FIRST catch-up bodies across ColdStartSpreadSeconds instead of launching all of them in a + single sweep tick and slamming the N=4 gate (the field herd). Reconcile-ADDED servers below keep the + MinValue default and launch promptly. */ + var coldStartInstant = DateTime.UtcNow; + foreach (var server in initialServers) + { + servers.Add(new ServerLoopState + { + Config = server, + FirstSweepDueUtc = ColdStartFirstSweepDue( + coldStartInstant, ServerIdHelper.GetDeterministicHashCode(server.StorageName)), + }); + } + + /* Phase-5 slice D: the shared alert engine, wired to the PG-backed stores (V3) and the + shared email/webhook delivery. Constructed once — the engine holds the per-server + edge-trigger state for the service's lifetime. The settings/history/webhook pieces + are hoisted here because the AN3 analysis-notification path below shares them. The mute + service is hoisted too so a reload can re-LoadAsync() it (closes F16 — the engine holds + its IsAlertMuted delegate, so refreshing the same instance's cache mutes the next sweep). */ + var alertSettings = new DarlingAlertSettings(config); + var historyStore = new PgAlertHistoryStore(postgres, _logger); + var webhookAlertService = new WebhookAlertService( + alertSettings, DarlingAlertDeliverer.Branding, + _loggerFactory.CreateLogger(), historyStore); + var muteRuleService = new MuteRuleService( + new PgMuteRuleStore(postgres, _logger), _loggerFactory.CreateLogger()); + await muteRuleService.LoadAsync(); + + /* The shared record-and-send deliverer — hoisted so BOTH the shared alert engine and the Stage 4 + service self-alerts (DarlingSelfAlertEvaluator) fire through the SAME instance: identical + email/webhook delivery, per-fingerprint delivery cooldown, and history-seeded restart replay. */ + var deliverer = new DarlingAlertDeliverer( + alertSettings, historyStore, webhookAlertService, _logger, + /* #1236: map a fired alert's ServerKey (the storage-name hash the engine keys on) back to the live + server's per-server delivery override, under the servers lock (mirrors FetchFailedJobsAsync). A + store reload swaps ServerLoopState.Config, so this always reads the current override. */ + resolveServerOverride: serverKey => + { + if (!int.TryParse(serverKey, NumberStyles.Integer, CultureInfo.InvariantCulture, out var id)) + { + return null; + } + + lock (_serversLock) + { + return servers + .Find(s => ServerIdHelper.GetDeterministicHashCode(s.Config.StorageName) == id) + ?.Config.AlertDeliveryModeOverride; + } + }); + var engine = BuildAlertEngine(config, servers, alertSettings, historyStore, muteRuleService, deliverer); + + /* Stage 4: the service self-alerts, over the SAME deliverer + history + mute check the engine uses. + collection-stopped / capture-down are polled from collection_log on the alert cadence below; + connection lost/restored fire on the connect edges in TryConnectAsync. */ + _selfAlerts = new DarlingSelfAlertEvaluator( + alertSettings, deliverer, historyStore, muteRuleService.IsAlertMuted, _logger, + /* V20: the connect-edge Server-Unreachable/Restored delivery honors the notify toggle, read live + through the same by-reference alertSettings seam a store reload hot-swaps. */ + notifyConnectionChanges: () => alertSettings.NotifyConnectionChanges); + + /* Phase-5 analysis slice AN3: the analysis pipeline's shared pieces, constructed once. + The plan fetcher resolves a finding's serverId to the CONNECTED runtime's connection + string (the PgPlanFetcher seam — null for an unknown/disconnected server degrades the + fetch like Lite's ServerManager miss). The shared AnalysisNotificationService routes + high-severity findings through DarlingFindingAlertSender (email + webhook + history, + Lite's cadence); the serverId resolver is Lite's shape (the finding's int id as a + string), no silencing predicate and no tray sink (headless). */ + var planFetcher = new PgPlanFetcher( + /* Enumerated from the command loop (analyze_now) as well as the main loop, so guard the read + against a concurrent reconcile add/remove. Held only for the lookup, never across the fetch. */ + serverId => + { + lock (_serversLock) + { + return servers + .Select(s => s.Runtime) + .FirstOrDefault(r => r is not null && r.ServerId == serverId)?.ConnectionString; + } + }, + _logger); + var notificationService = new AnalysisNotificationService( + new DarlingFindingAlertSender(alertSettings, historyStore, webhookAlertService, _logger), + alertSettings, + finding => finding.ServerId.ToString(CultureInfo.InvariantCulture), + _loggerFactory.CreateLogger()); + + /* Command plane (Stage 2): the executor claims/executes/reports config_command rows on its OWN + 5-second loop, concurrent with the collection sweep, so a slow command never stalls collection. + The host lets snapshot_now/analyze_now reach the LIVE loop (the running server set + runner + + analysis pieces) without the executor touching that mutable state directly; every other command + only writes the config.* tables and rides the reload beacon. Launched here and awaited after the + collection loop stops so both drain cleanly on shutdown. */ + var commandHost = new WorkerCommandHost(this, servers, runner, planFetcher, notificationService, config); + var serviceInstance = $"{Environment.MachineName}:{Environment.ProcessId.ToString(CultureInfo.InvariantCulture)}"; + var commandExecutor = new DarlingCommandExecutor(postgres, commandHost, serviceInstance, _logger); + var commandLoop = RunCommandLoopAsync(commandExecutor, stoppingToken); + + /* The fleet concurrency gate (#1553 D2): at most N=4 per-server collection bodies open a SQL connection + at once, so one slow or hung server cannot head-of-line-block the fleet the way the old strictly + sequential foreach did. Deliberately NOT disposed (CA2000 suppressed, not "fixed" back by an analyzer + or a later builder): a body still running past the shutdown drain — or a reconcile-removed server's + body detached from the drain list — would otherwise reach its finally { gate.Release() } on a disposed + SemaphoreSlim and throw ObjectDisposedException, faulting an unobserved Task. A SemaphoreSlim needs no + deterministic disposal unless its AvailableWaitHandle is used, which it never is here. */ +#pragma warning disable CA2000 + var serverSweepGate = new SemaphoreSlim(MaxConcurrentServerSweeps, MaxConcurrentServerSweeps); +#pragma warning restore CA2000 + + _logger.LogInformation("PerformanceMonitor Darling collection loop started"); + + while (!stoppingToken.IsCancellationRequested) + { + /* Control-plane reload beacon: poll config_version at a SAFE point (top of the sweep, never + mid-collection). On change, re-read the store and hot-swap the live config: the alert / + SMTP / webhook / capture / analysis settings (via the by-reference DarlingAlertSettings + seam + the runner's capture provider), the monitored-server set (add/remove/replace + ServerLoopState), the per-collector schedule overrides + NextDue, and the mute-rule cache. */ + var configVersion = await configProvider.ReadConfigVersionAsync(stoppingToken); + if (configVersion.HasValue && configVersion.Value != _lastConfigVersion) + { + _lastConfigVersion = configVersion.Value; + await ReloadFromStoreAsync(configProvider, config, servers, muteRuleService, stoppingToken); + } + + /* Stage 2 pause gate (Lite's IsPaused): while paused, skip ALL collection/alert/analysis/purge + work but keep looping — the reload beacon above still runs (so a resume, applied via the same + reload, un-pauses on the very next tick) and the command loop keeps draining commands. A + resume that reloaded THIS iteration already flipped _paused to false above, so it takes effect + immediately, not a sweep later. */ + if (!ShouldRunCollection(_paused)) + { + try + { + await Task.Delay(s_sweepInterval, stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + + continue; + } + + /* P1: snapshot the server list under the lock so the fire-and-track launch loop below iterates a + STABLE array while the command loop reconciles the list concurrently (this also keeps the + deliverer's / plan-fetcher's own locked Find/Select provably race-free). Taken AFTER the reload + above so this sweep launches against the freshly reconciled set. */ + ServerLoopState[] sweepTargets; + lock (_serversLock) + { + sweepTargets = servers.ToArray(); + } + + /* Fire-and-track launch loop (#1553 D2/D2b): LAUNCH each server's collection body WITHOUT awaiting + it, so one slow or hung server can no longer stall the fleet or the fleet-level steps below (the + old foreach awaited every step inline — the 24-server field incident). At most N=4 bodies open a + connection at once (serverSweepGate, acquired inside the body). All tracking / skip-log state + (InFlightSweep, SweepStartedUtc, WarnedThisEpisode) is written ONLY here on the outer sweep thread + — the body never touches it, so there is no cross-thread tear on these fields. */ + + /* Working-set launch guard (#1556): before launching ANY new bodies this tick, check the process + working set against the guard threshold. Over the line, launch NOTHING this sweep so the in-flight + bodies drain and the process backs away from the commit-limit exhaustion the field incident hit — + but the purge / disk-pressure / delay steps below keep running. ONE CRITICAL per episode; a + recovery re-arms and logs at Information. */ + long workingSetBytes; + using (var currentProcess = System.Diagnostics.Process.GetCurrentProcess()) + { + workingSetBytes = currentProcess.PrivateMemorySize64; + } + var availableMemoryBytes = GC.GetGCMemoryInfo().TotalAvailableMemoryBytes; + var mayLaunchSweeps = ShouldLaunchSweeps(workingSetBytes, availableMemoryBytes); + if (!mayLaunchSweeps && !_memoryGuardTrippedThisEpisode) + { + _memoryGuardTrippedThisEpisode = true; + _logger.LogCritical( + "Working set {WorkingSetMb}MB is over {Pct:P0} of {AvailableMb}MB available — PAUSING new collection-body launches this tick so in-flight bodies drain (the #1556 commit-limit backstop). Purge/disk/analysis continue.", + workingSetBytes / (1024 * 1024), MemoryGuardFraction, availableMemoryBytes / (1024 * 1024)); + } + else if (mayLaunchSweeps && _memoryGuardTrippedThisEpisode) + { + _memoryGuardTrippedThisEpisode = false; + _logger.LogInformation( + "Working set recovered to {WorkingSetMb}MB of {AvailableMb}MB — resuming collection-body launches.", + workingSetBytes / (1024 * 1024), availableMemoryBytes / (1024 * 1024)); + } + + foreach (var server in sweepTargets) + { + /* D3: the per-sweep cancellation check HOISTS here from the old inline body — a bare `break` + inside the extracted async body would not compile (CS0139), and this launch loop is the loop + it belongs to. */ + if (stoppingToken.IsCancellationRequested) + { + break; + } + + /* Working-set guard tripped this tick: stop launching new bodies (evaluated once, before the + loop, so the decision applies uniformly to every server this sweep). */ + if (!mayLaunchSweeps) + { + break; + } + + /* #1581 cold-start stagger: hold this server's FIRST sweep body (InFlightSweep still null) until + its deterministic per-server offset elapses, so a service restart does not launch all N + servers' initial catch-up bodies in ONE tick — the field herd where 24 servers stamped + SweepStartedUtc together, queued behind the N=4 gate, and every one that waited past 60s logged + "collection body has not completed after 60s". The offset is bounded by ColdStartSpreadSeconds, + so no first launch is deferred beyond that window; once a body has launched (InFlightSweep + non-null) this no longer applies, so the relaunch path below and steady-state cadence are + untouched. A reconcile-added server has FirstSweepDueUtc == MinValue and launches immediately. */ + if (server.InFlightSweep is null && DateTime.UtcNow < server.FirstSweepDueUtc) + { + continue; + } + + if (server.InFlightSweep is { IsCompleted: false }) + { + /* Still in flight: do NOT relaunch (INV-2, one body per server). The field incident was + HANGS, not throws, and the body's catch-all (D3) only covers throws — so the stall is + surfaced HERE. The EPISODE (SweepStartedUtc, stamped at launch) still spans queue + run, + because a body queued behind the gate genuinely is unserved. But the two halves get + DIFFERENT channels: RunStartedTicks is 0 until the body acquires the concurrency gate, so + a body merely waiting its turn is CAPACITY pressure, never a hang. Attributing queue time + to the hang watchdog fired it ~82 times/hour at 24 servers behind the N=4 gate on a + demonstrably healthy fleet, which buried the one signal this warning exists to raise. */ + var episodeSeconds = (DateTime.UtcNow - server.SweepStartedUtc).TotalSeconds; + var runStartedTicks = Interlocked.Read(ref server.RunStartedTicks); + var running = runStartedTicks != 0; + var runningSeconds = running + ? (DateTime.UtcNow - new DateTime(runStartedTicks, DateTimeKind.Utc)).TotalSeconds + : 0; + + _logger.LogDebug( + "[{Server}] collection body still in flight after {Elapsed:F0}s ({State}) — skipping this sweep", + server.Config.DisplayName, + episodeSeconds, + running ? FormattableString.Invariant($"running {runningSeconds:F0}s") : "queued for a slot"); + + switch (ClassifySweepEpisode( + episodeSeconds, running, runningSeconds, server.WarnedThisEpisode, server.QueuedInfoThisEpisode)) + { + /* HANG — the body is actually EXECUTING and has not finished. ONE Warning per episode. + This is the channel that must stay quiet on a healthy fleet so a real stall is seen. */ + case SweepEpisodeSignal.Hang: + server.WarnedThisEpisode = true; + _logger.LogWarning( + "[{Server}] collection body has not completed after {Elapsed:F0}s of execution — skipping relaunch", + server.Config.DisplayName, runningSeconds); + break; + + /* CAPACITY — still QUEUED behind the gate, so nothing is wrong with this server: the + fleet is simply wider than MaxConcurrentServerSweeps at this moment. Info, once. */ + case SweepEpisodeSignal.Queued: + server.QueuedInfoThisEpisode = true; + _logger.LogInformation( + "[{Server}] collection body has waited {Elapsed:F0}s for a free slot (fleet concurrency limit {Limit}) — queued, not stalled; it has not started yet", + server.Config.DisplayName, episodeSeconds, MaxConcurrentServerSweeps); + break; + } + + continue; + } + + /* The body is null (never launched) or has completed since the last sweep. If we surfaced EITHER + channel for this episode, log its resolution with the total elapsed and re-arm before + relaunching — the re-arm happens HERE on observing IsCompleted, never in the body's finally + (which would race this launch loop). A body that finished under both thresholds was never + surfaced, so it simply relaunches. */ + if (server.WarnedThisEpisode || server.QueuedInfoThisEpisode) + { + var ranForSeconds = (DateTime.UtcNow - server.SweepStartedUtc).TotalSeconds; + _logger.LogInformation( + "[{Server}] collection body completed after {Elapsed:F0}s (episode, including any queue wait)", + server.Config.DisplayName, ranForSeconds); + server.WarnedThisEpisode = false; + server.QueuedInfoThisEpisode = false; + } + + /* Stamp the launch time BEFORE launching — time spent QUEUED on the gate is part of this + episode — and clear the run stamp so this episode starts as QUEUED. The reset must precede the + call: ProcessServerSweepAsync runs synchronously up to its gate WaitAsync, so with a free + permit the body may stamp RunStartedTicks before this statement returns, and resetting after + would erase it. Then fire-and-track: assign the Task to InFlightSweep (so it is observed and + the next sweep + the shutdown drain can see it) but do NOT await it here. */ + server.SweepStartedUtc = DateTime.UtcNow; + Interlocked.Exchange(ref server.RunStartedTicks, 0); + server.InFlightSweep = ProcessServerSweepAsync( + server, engine, runner, planFetcher, notificationService, config, serverSweepGate, stoppingToken); + } + + if (DateTime.UtcNow >= _nextPurgeUtc) + { + _nextPurgeUtc = DateTime.UtcNow.AddHours(24); + /* Honor fleet-wide retention overrides (config_collector_schedules, server_id NULL) layered + on CollectorScheduleDefaults; a per-server override can't apply to a shared-table purge. + Empty overrides (Stage 1 seeds none) resolve to the defaults — identical behavior. */ + var overrides = _scheduleOverrides; + await DarlingRetention.PurgeAsync( + postgres, _timescaleAvailable, _logger, stoppingToken, + name => StoreConfigProvider.ResolveFleetRetentionDays(name, overrides)); + + /* AN3: findings retention. Both apps' finding stores declare a 30-day cleanup + but neither app schedules it (Lite's DuckDB archive-reset bounds it + incidentally); a 24/7 service must actually invoke it or analysis_findings + grows unbounded. Rides the daily purge; never throws (logs + degrades). */ + await new PgFindingStore(postgres, _logger).CleanupOldFindingsAsync(retentionDays: 30); + } + + /* Stage 4 fleet-level self-alert: the store disk-pressure backstop. The daily purge is the ONLY + other maintenance cadence and it is purely time-based (no disk-free check), so on its own the + store can still fill between purges — this edge-fired condition is the flagship-appropriate + backstop. Own slow cadence; the master alerts gate + edge-trigger live inside the evaluator. */ + if (DateTime.UtcNow >= _nextDiskCheckUtc) + { + _nextDiskCheckUtc = DateTime.UtcNow.Add(s_diskCheckInterval); + await EvaluateStoreDiskPressureAsync(config, stoppingToken); + } + + /* #1581: the compression-job self-heal backstop. TimescaleDB compression policy jobs can silently + die (next_start = -infinity) or hang, halting the store's archival tier so uncompressed data grows + without bound until the disk fills and collection stops for the WHOLE fleet (the field incident). + Timescale-only; own hourly cadence; failure-isolated inside EvaluateCompressionJobHealthAsync. */ + if (_timescaleAvailable && DateTime.UtcNow >= _nextCompressionCheckUtc) + { + _nextCompressionCheckUtc = DateTime.UtcNow.Add(s_compressionCheckInterval); + await EvaluateCompressionJobHealthAsync(stoppingToken); + } + + try + { + await Task.Delay(s_sweepInterval, stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + } + + /* Shutdown drain for the fire-and-track bodies (#1553): the launch loop does NOT await the per-server + bodies, so on cancellation some may still be running (or queued on the gate). Collect the live ones + under the servers lock (the command loop reconciles concurrently) and wait up to the drain budget for + them to finish, bounded so a genuinely hung body cannot hold shutdown open indefinitely. Bodies never + fault (ProcessServerSweepAsync's catch-all), so Task.WhenAll completes cleanly rather than throwing. + A reconcile-removed server's body is not in `servers` and so is not awaited here — it detaches, but + its own catch-all + the never-disposed gate keep it non-fatal. The delay uses CancellationToken.None + because stoppingToken is already cancelled at this point. */ + List inFlightSweeps; + lock (_serversLock) + { + inFlightSweeps = servers + .Select(s => s.InFlightSweep) + .Where(t => t is not null) + .Select(t => t!) + .ToList(); + } + + if (inFlightSweeps.Count > 0) + { + await Task.WhenAny( + Task.WhenAll(inFlightSweeps), + Task.Delay(s_shutdownDrainBudget, CancellationToken.None)); + } + + /* Drain the concurrent command loop on shutdown (it observes the same token). */ + try + { + await commandLoop; + } + catch (OperationCanceledException) + { + /* Expected on shutdown. */ + } + + _logger.LogInformation("PerformanceMonitor Darling collection loop stopped"); + } + + /// + /// One server's collection body (#1553 D3), extracted from the old inline sweep loop for the fire-and-track + /// model (self-alert call rebased to the _postgres field, the loop's continue/break reshaped to a body + /// return + a hoisted cancellation check): self-alert eval -> connect-or-collect -> alert eval -> analysis, in that order, + /// SEQUENTIAL within this one server (Lite's RemoteCollectorService shape — parallel across servers, + /// sequential collectors per server). Runs on a pool thread; the outer launch loop tracks the returned Task + /// in and never awaits it inline, so one slow/hung server cannot + /// head-of-line-block the fleet. The fleet gate is acquired here (bounding fleet-wide concurrency to + /// ), and the whole body is wrapped so a hung server cannot starve + /// the fleet and one server's unexpected throw is CONTAINED (Design Goal 4) rather than killing the loop or + /// surfacing as an unobserved fire-and-track fault. + /// + private async Task ProcessServerSweepAsync( + ServerLoopState server, + AlertEngine engine, + DarlingCollectorRunner runner, + PgPlanFetcher planFetcher, + AnalysisNotificationService notificationService, + DarlingConfig config, + SemaphoreSlim gate, + CancellationToken stoppingToken) + { + /* Acquire the fleet concurrency gate OUTSIDE the try (the never-faulting-probe idiom): WaitAsync either + returns having TAKEN a permit — matched by the finally's Release — or THROWS owning nothing (a cancel + while queued on shutdown), so the finally can never over-release a permit we do not hold. */ + await gate.WaitAsync(stoppingToken); + + /* The permit is held: this body has STOPPED queueing and STARTED running. Stamp the run start so the + outer launch loop's watchdog can tell a genuine hang from a body that was merely waiting its turn. + This is the ONE sweep-bookkeeping field the body writes, via Interlocked (see RunStartedTicks) — the + three outer-thread-only fields are deliberately left alone. Placed before the Retired check so a + retired body still reports as "running" for the instant it takes to no-op out, rather than looking + permanently queued. */ + Interlocked.Exchange(ref server.RunStartedTicks, DateTime.UtcNow.Ticks); + + try + { + /* Retired containment (#1553 D1), the AUTHORITATIVE check: a reconcile-remove may have retired this + server AFTER this body was launched or while it sat QUEUED on the gate. An async method runs + synchronously only to its first await, so a check BEFORE WaitAsync would evaluate at LAUNCH time + (Retired still false) and a gate-queued body would never re-check on dequeue — so the check lives + HERE, as the first statement after acquiring the permit. A retired body no-ops entirely: it never + connects, never runs XE DDL, and never re-writes self-alert edge state after Forget removed it. + (The connect path adds a SECOND re-check for the narrow window where removal lands DURING the + connect I/O; this entry check covers the dominant queued-dequeue path.) */ + if (server.Retired) + { + return; + } + + /* Stage 4 service self-alerts (store-polled): collection-stopped is evaluated for EVERY server — + connected or not — because an unreachable server has stopped collecting, which is exactly the + case a headless service must page on. Capture-down is evaluated only for a connected server. Own + 30s cadence; the master alerts gate + edge-trigger live inside the evaluator. Runs ABOVE the + Runtime-null connect gate so a disconnected server is still checked. Connection lost/restored fire + on the connect edges in TryConnectAsync. (Uses the _postgres field — the loop-local `postgres` of + RunCollectionLoopAsync is out of scope in this extracted body.) */ + if (DateTime.UtcNow >= server.NextSelfAlertSweep) + { + server.NextSelfAlertSweep = DateTime.UtcNow.Add(s_alertSweepInterval); + await _selfAlerts!.EvaluateStoreAlertsAsync( + _postgres!, + ServerIdHelper.GetDeterministicHashCode(server.Config.StorageName), + server.Config.DisplayName, + connected: server.Runtime is not null, + stoppingToken); + } + + if (server.Runtime is null) + { + await TryConnectAsync(server, runner, config, stoppingToken); + return; + } + + /* Reconcile the opt-in long-query completion XE session (#1496) to its enabled flag before the + collector sweep. State-tracked (ServerLoopState.LongQueryTraceApplied), so it only opens a + connection when the desired state changes — enabling creates the session, disabling DROPS it. + Runs regardless of whether the collector is due or enabled, because a disabled collector is + never dispatched by RunDueCollectorsAsync and so the DROP-on-disable has nowhere else to run. */ + await ReconcileLongQueryTraceAsync(server, runner, stoppingToken); + + await RunDueCollectorsAsync(server, runner, stoppingToken); + + /* After the server's collector sweep: evaluate alerts against the freshly collected store — on + Lite's 30-second overview cadence. */ + if (DateTime.UtcNow >= server.NextAlertSweep) + { + server.NextAlertSweep = DateTime.UtcNow.Add(s_alertSweepInterval); + await EvaluateAlertsAsync(engine, server, stoppingToken); + } + + /* AN3: the scheduled analysis pipeline, per-server. The cadence, the enabled gate, and the notify + gate are now control-plane knobs read LIVE from config.Analysis (a reload takes effect on the next + tick). When analysis is disabled the pass is skipped and NextAnalysisDue is left in the past, so + re-enabling runs immediately. The next-due stamp advances up front (Lite's scheduler shape), so a + timed-out pass is skipped, not retried immediately. Delivery is gated on + analysis_notifications_enabled (Lite's D0 split: production unconditional, delivery gated) — + replacing the old alerts.enabled gate; the interval is clamped to Lite's 5-360 range. */ + if (config.Analysis.Enabled && DateTime.UtcNow >= server.NextAnalysisDue) + { + var intervalMinutes = Math.Clamp(config.Analysis.IntervalMinutes, MinAnalysisIntervalMinutes, MaxAnalysisIntervalMinutes); + server.NextAnalysisDue = DateTime.UtcNow.AddMinutes(intervalMinutes); + await RunScheduledAnalysisAsync( + server, planFetcher, notificationService, config.Analysis.NotificationsEnabled, stoppingToken); + } + } + catch (OperationCanceledException) + { + /* Shutdown (a body-internal await observed the token) — quiet and expected. */ + } + catch (Exception ex) + { + /* Design Goal 4 (exception containment — the gap the sweep loop previously acknowledged it had no + catch-all for): one server's unexpected throw must never kill the collection loop, and a faulted + fire-and-track Task must never surface as an unobserved exception. Log and isolate; the server + retries on its next sweep exactly like a failed collector. */ + _logger.LogError("[{Server}] Collection sweep failed: {Message}", server.Config.DisplayName, ex.Message); + } + finally + { + gate.Release(); + } + } + + /// + /// Reconciles the OPT-IN long-query completion XE session (#1496) to its resolved enabled flag, once + /// the desired state differs from what was last applied to this server (tracked in + /// so steady state — a default-off collector — + /// opens no connection at all). Enabling creates the server-side session; disabling drops it. A + /// failure leaves the applied state unchanged so the next sweep retries, and never breaks the sweep. + /// + private async Task ReconcileLongQueryTraceAsync(ServerLoopState server, DarlingCollectorRunner runner, CancellationToken cancellationToken) + { + if (server.Runtime is null) + { + return; + } + + var serverId = ServerIdHelper.GetDeterministicHashCode(server.Config.StorageName); + var enabled = StoreConfigProvider.ResolveSchedule("long_query_completions", serverId, _scheduleOverrides).Enabled; + + if (server.LongQueryTraceApplied == enabled) + { + return; + } + + try + { + await DarlingXeSessions.ReconcileLongQueryCompletionsAsync(server.Runtime, runner, enabled, _logger, cancellationToken); + server.LongQueryTraceApplied = enabled; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning("[{Server}] Failed to reconcile the long-query completion XE session: {Message}", + server.Config.DisplayName, ex.Message); + } + } + + /// + /// The command plane's poll loop (Stage 2), run concurrently with the collection sweep on its own + /// ~5-second tick. Each tick DRAINS every currently-pending command (claim one at a time until the + /// queue is empty), so a burst of viewer commands is not throttled to one per 5 seconds. Never throws + /// out — a per-command failure is reported on the row and swallowed by the executor — so the loop lives + /// for the service's lifetime. Cancellation ends it cleanly. + /// + private async Task RunCommandLoopAsync(DarlingCommandExecutor executor, CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + /* Robustness (Stage 2 follow-up): before draining the queue, reclaim any command left + in_progress by a crashed/restarted service instance so a Viewer polling it is not stranded + forever. Cheap (an indexed UPDATE matching nothing in the normal case) and safe to run + every tick — the 5-minute staleness margin can never catch a merely-slow live command. A + reclaimed row is marked terminal 'failed' (not re-queued), so the drain below never re-runs + a non-idempotent command; see DarlingCommandExecutor.ReclaimStaleCommandsSql. */ + await executor.ReclaimStaleCommandsAsync(stoppingToken); + + while (await executor.PollOnceAsync(stoppingToken)) + { + if (stoppingToken.IsCancellationRequested) + { + return; + } + } + } + catch (OperationCanceledException) + { + return; + } + catch (Exception ex) + { + /* Belt-and-suspenders: PollOnceAsync already swallows its own failures, but a truly + unexpected throw must not kill the loop. */ + _logger.LogWarning("Command loop tick failed: {Message}", ex.Message); + } + + try + { + await Task.Delay(s_commandPollInterval, stoppingToken); + } + catch (OperationCanceledException) + { + return; + } + } + } + + /// + /// Applies a control-plane reload at a SAFE point (top of a sweep, never mid-collection): re-reads the + /// store, hot-swaps the held config's alert/SMTP/webhook/capture/analysis settings IN PLACE (the + /// by-reference DarlingAlertSettings seam + the runner's capture provider reflect it immediately), + /// reconciles the monitored-server set, recomputes each connected server's NextDue from the fresh + /// schedule overrides, and reloads the mute-rule cache (F16). Store-unreachable is a no-op — the current + /// live config stands, never worse than before. + /// + private async Task ReloadFromStoreAsync( + StoreConfigProvider provider, DarlingConfig config, List servers, + MuteRuleService muteRuleService, CancellationToken cancellationToken) + { + var view = await provider.LoadViewAsync(config, cancellationToken); + if (view is null) + { + return; + } + + StoreConfigProvider.ApplyToConfig(config, view); + /* #1560/#1562: the MCP + web host supervisors pick these up within their poll interval — the viewer's + Settings toggles round-trip to a live start/stop/rebind with no service restart. */ + _mcpState.Publish(config.Mcp.Enabled, config.Mcp.Port); + _webState.Publish(config.Web.Enabled, config.Web.Port); + _scheduleOverrides = view.ScheduleOverrides; + /* Stage 2: honor a pause/resume issued through the store (config_service.paused) — the collection + loop reads this on its next tick. Single writer (this reload), so no interlock needed. */ + _paused = view.Paused; + + /* Structural reconcile mutates the server list; the command loop reads it concurrently, so hold + the lock across the add/remove. NextDue recompute mutates only per-server state (safe against a + concurrent id lookup) so it stays outside the lock. */ + lock (_serversLock) + { + ReconcileServers(servers, view.EnabledServers); + } + + await RecomputeNextDueAsync(servers, cancellationToken); + + /* Mirror the desired enable state onto the observed collect.servers registry so a disable_server + flips its observed row FALSE even though the disabled server drops out of the loop (stops + upserting), and an enable_server flips it back. */ + await DarlingObservability.SyncServerEnabledStatesAsync(_postgres!, _logger, cancellationToken); + + await muteRuleService.LoadAsync(); + + _logger.LogInformation( + "Control-plane reload applied (config_version {Version}, {Servers} monitored server(s), paused: {Paused})", + _lastConfigVersion, servers.Count, _paused); + } + + /// + /// Reconciles the live set to the store's desired enabled set, keyed by the + /// shared server_id. ADD: a new enabled server gets a disconnected state that connects on its next tick + /// exactly like a startup server (via — no novel connection logic). REMOVE: + /// a server no longer enabled/present is dropped; its runtime holds no persistent connection (the + /// collectors open per run), so dropping the state is a clean disconnect. STAY: the held config is always + /// refreshed to the desired definition (so a non-connection edit — cost, the #1236 alert-delivery override + /// — goes live without churn), but the connection + NextDue are preserved unless a connection/collection + /// field changed (per ), in which case the runtime is dropped so it + /// reconnects with the new definition through the same startup path. + /// + private void ReconcileServers(List servers, IReadOnlyList desired) + { + var desiredById = new Dictionary(); + foreach (var d in desired) + { + desiredById[ServerIdHelper.GetDeterministicHashCode(d.StorageName)] = d; + } + + for (int i = servers.Count - 1; i >= 0; i--) + { + var state = servers[i]; + var id = ServerIdHelper.GetDeterministicHashCode(state.Config.StorageName); + if (!desiredById.TryGetValue(id, out var desiredServer)) + { + _logger.LogInformation( + "[{Server}] Removed from the monitored set (disabled/deleted) — stopping collection", + state.Config.DisplayName); + state.Runtime = null; + /* Retired containment (#1553 D1): mark this state retired so any in-flight or gate-queued + fire-and-track body for it no-ops at its entry check (and its connect path bails before any + durable side-effect) instead of connecting / upserting / running XE DDL against a just-disabled + target, or re-writing self-alert edge state after the Forget below. Write-once; a re-add mints + a fresh ServerLoopState, so there is no reset and no ABA. */ + state.Retired = true; + /* Drop the Stage 4 self-alert edge state so a later re-add starts from the Unknown baseline + (no stale "was online" / "was stopped" flag carried across a remove+re-add). */ + _selfAlerts?.Forget(id); + servers.RemoveAt(i); + continue; + } + + /* Always refresh the held definition so a NON-connection edit — the FinOps cost, or the #1236 + alert-delivery override the deliverer's resolver reads live off state.Config — takes effect on + this reload without connection churn. Only a connection/collection-affecting change (per + ServerDefinitionEquals) drops the runtime to reconnect through the startup path. */ + var connectionChanged = !ServerDefinitionEquals(state.Config, desiredServer); + state.Config = desiredServer; + if (connectionChanged) + { + _logger.LogInformation( + "[{Server}] Definition changed — reconnecting with the new configuration", desiredServer.DisplayName); + state.Runtime = null; + state.NextConnectAttempt = DateTime.MinValue; + state.NextDue.Clear(); + } + + desiredById.Remove(id); + } + + foreach (var addition in desiredById.Values) + { + _logger.LogInformation( + "[{Server}] Added to the monitored set — will connect on the next sweep", addition.DisplayName); + servers.Add(new ServerLoopState { Config = addition }); + } + } + + /// + /// Recomputes each CONNECTED server's per-collector NextDue from the current schedule overrides after a + /// reload: a disabled or on-load-only (freq 0) collector is dropped from the schedule; a newly-enabled one is + /// seeded from its persisted watermark (the #1575 policy, one lazily + /// batched read per server that gains a new entry); an existing entry is pulled in to at most now + the + /// (possibly shortened) effective interval so a frequency change takes effect promptly without over-firing. A + /// server still connecting has no NextDue yet — seeds it from the same watermark + /// policy when it connects. + /// + private async Task RecomputeNextDueAsync(List servers, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + foreach (var server in servers) + { + var runtime = server.Runtime; + if (runtime is null) + { + continue; + } + + /* #1575: the watermark map is read at most ONCE per server, and only if this reload actually + introduces a NEW collector entry (a just-enabled collector) — a reload that only tweaks existing + entries costs no extra store round-trip. Lazily populated on the first new entry below. */ + Dictionary? watermarks = null; + + foreach (var name in CollectorScheduleDefaults.All.Keys) + { + var effective = StoreConfigProvider.ResolveSchedule(name, runtime.ServerId, _scheduleOverrides); + if (!effective.Enabled || effective.FrequencyMinutes == 0) + { + /* ConcurrentDictionary has no Remove(key) — TryRemove is the drop-in for the old Remove. */ + server.NextDue.TryRemove(name, out _); + continue; + } + + if (server.NextDue.TryGetValue(name, out var existing)) + { + /* Existing entry KEEPS its already-applied phase, but is pulled in to at most now + the + (possibly shortened) interval so a frequency change takes effect promptly without + over-firing — unchanged from before. */ + var capped = now.AddMinutes(effective.FrequencyMinutes); + server.NextDue[name] = existing < capped ? existing : capped; + } + else + { + /* NEW entry — a collector this reload newly enables. Seed it from the persisted watermark + (the same #1575 policy as the connect-seed) so a newly-enabled long-frequency collector + resumes its real cadence instead of deferring up to a full interval; the small capped + jitter still de-clusters an overdue / never-run fleet-wide enable. */ + watermarks ??= await ReadCollectorWatermarksAsync(_postgres!, runtime.ServerId, _logger, cancellationToken); + var lastRun = watermarks.TryGetValue(name, out var w) ? w : (DateTime?)null; + var jitter = SeedJitter(runtime.ServerId, effective.FrequencyMinutes * 60); + server.NextDue[name] = ComputeSeededNextDue(lastRun, effective.FrequencyMinutes, now, jitter); + } + } + } + } + + /// + /// Whether two server definitions are identical for the collection loop — the connection-relevant fields + /// plus the collection-affecting excluded databases. A difference triggers a reconnect on reconcile so the + /// new definition takes effect. MonthlyCostUsd is deliberately NOT compared: it does not affect + /// collection at all, and the reload's + /// mirrors a cost change straight onto collect.servers (which the FinOps display reads) with no + /// connection churn — so a cost-only edit must NOT trigger a disconnect+reconnect. Internal so a unit test + /// can pin exactly that. + /// + internal static bool ServerDefinitionEquals(MonitoredServer a, MonitoredServer b) + => string.Equals(a.Name, b.Name, StringComparison.Ordinal) + && string.Equals(a.Host, b.Host, StringComparison.OrdinalIgnoreCase) + && string.Equals(a.Database, b.Database, StringComparison.OrdinalIgnoreCase) + && string.Equals(a.Auth, b.Auth, StringComparison.OrdinalIgnoreCase) + && string.Equals(a.Username, b.Username, StringComparison.Ordinal) + && string.Equals(a.EncryptedPassword, b.EncryptedPassword, StringComparison.Ordinal) + && string.Equals(a.Password, b.Password, StringComparison.Ordinal) + && string.Equals(a.EncryptMode, b.EncryptMode, StringComparison.OrdinalIgnoreCase) + && a.TrustServerCertificate == b.TrustServerCertificate + && a.ReadOnlyIntent == b.ReadOnlyIntent + && a.MultiSubnetFailover == b.MultiSubnetFailover + && a.ExcludedDatabases.SequenceEqual(b.ExcludedDatabases, StringComparer.OrdinalIgnoreCase); + + /// + /// A deterministic, restart-stable per-server phase offset within a cadence period (#1553 cadence jitter), + /// used to break the fleet-wide lockstep at cadence boundaries — the field incident re-herded every server + /// at once, so at each boundary all collectors fired together. The is ALREADY an + /// FNV-1a hash (), so a plain modulo spreads it across + /// [0, period) without any further mixing (an extra multiply was reviewed out as unnecessary — the + /// input is already avalanched). Restart-stable because it is a pure function of the id — no . + /// A non-positive period yields no offset (guards the callers where a period could in principle be zero, and + /// keeps the result well-defined for tests). Applied ONLY at initial cadence stamps, never the steady-state + /// advance: directly for the on-connect analysis stamp, and — capped at min(interval, 150s) via + /// — as the small de-cluster jitter adds to an + /// overdue or never-run collector seed (#1575). Internal so a unit test can pin its shape. + /// + internal static TimeSpan CadencePhaseOffset(int serverId, int periodSeconds) + { + if (periodSeconds <= 0) + { + return TimeSpan.Zero; + } + + /* Cast to uint first so a negative FNV hash still maps into [0, period): a signed modulo would yield a + negative offset and pull the due time into the past. */ + return TimeSpan.FromSeconds((uint)serverId % periodSeconds); + } + + /// + /// The small, bounded per-server seed jitter (#1575): the deterministic + /// phase, but CAPPED at min(frequencySeconds, 150) so it de-clusters the fleet's overdue / never-run + /// seeds WITHOUT ever deferring a run by up to a full interval — the coarse full-interval offset applied at + /// the seed sites was the #1575 starvation bug (a daily collector re-phased up to ~24h forward on every + /// restart). Mirrors the fixed 150s post-connect analysis-phase jitter. A recently-run collector needs no + /// jitter at all: its lastRun + interval stamps are already spread across the fleet by when each + /// server actually last ran. Internal so a unit test can pin the cap. + /// + internal static TimeSpan SeedJitter(int serverId, int frequencySeconds) + => CadencePhaseOffset(serverId, Math.Min(frequencySeconds, 150)); + + /// + /// The #1581 cold-start launch time for one server's FIRST post-startup sweep body: the captured + /// plus a deterministic per-server capped + /// at . Spreads the fleet's initial catch-up across that small window so a + /// service restart does not launch all N servers' first bodies in ONE sweep tick and slam the N=4 gate — the + /// field herd where the queued bodies crossed 60s and logged "collection body has not completed after 60s" en + /// masse (nothing broken, but 366 spurious warnings over ~10 min). Distinct from the per-collector #1575 seed + /// jitter ( / ), which staggers WHICH collectors are + /// due once a body runs but not WHEN the heavyweight connect body itself launches — so cold start still + /// herded. Pure and restart-stable (a function of the id, no ) so a unit test can pin the + /// spread + bound; applied ONLY to the initial fleet's first launch, never the steady-state advance. Internal + /// so a unit test can pin its shape. + /// + internal static DateTime ColdStartFirstSweepDue(DateTime coldStartInstant, int serverId) + => coldStartInstant.Add(CadencePhaseOffset(serverId, ColdStartSpreadSeconds)); + + /// + /// The pure #1575 seed policy for one collector's first post-connect / newly-enabled due time, decided from + /// the persisted last-run watermark instead of a full-interval offset. The old seed stamped + /// now + CadencePhaseOffset(serverId, frequencySeconds) — up to a FULL interval — on EVERY connect, + /// discarding when the collector actually last ran, so each service restart re-phased every collector up to a + /// full interval forward and long-frequency collectors (the daily index_object_stats) were starved + /// across a restart-heavy window. From the collector's last run: + /// + /// recently run (lastRun + interval still in the future) → wait out the REMAINING interval + /// (lastRun + interval), resuming the real cadence; + /// overdue (lastRun + interval already reached) → run promptly at now + jitter; + /// never run (no watermark) → run promptly at now + jitter (a fresh daily collector runs shortly + /// after first connect, then daily — not up to 24h later). + /// + /// The steady-state advance in (exact interval) is unchanged. Pure and + /// Kind-agnostic (compares by ticks; the caller passes matching UTC values) so the policy is unit-tested + /// without a live store or a connect. Internal so a unit test can pin the decision table. + /// + internal static DateTime ComputeSeededNextDue(DateTime? lastRunUtc, int frequencyMinutes, DateTime nowUtc, TimeSpan jitter) + { + if (lastRunUtc is DateTime lastRun) + { + var due = lastRun.AddMinutes(frequencyMinutes); + return due <= nowUtc ? nowUtc + jitter : due; + } + + return nowUtc + jitter; + } + + /// + /// One batched round-trip (#1575): the newest collection_time per collector for a server, keyed by + /// collector_name — the persisted last-run watermark seeds the schedule + /// from on connect and on a newly-enabled collector, so a restart resumes the real cadence instead of + /// re-phasing it forward. ANY status counts (a failed attempt still wrote a row and still reset the cadence + /// clock, exactly as the steady-state advance retries every interval regardless of outcome). Bare + /// collection_log resolves through the store connection's collect/config search path, matching the + /// sibling readers (DarlingSelfAlertEvaluator.ReadCollectionSignalsAsync). The naive-UTC + /// timestamp value is relabeled Kind=Utc (a relabel, NOT a shift) so every NextDue entry stays + /// uniformly Kind=Utc. Failure-isolated: a store hiccup returns an EMPTY map so the caller seeds every + /// collector as never-run (a prompt, jittered run) rather than aborting the connect — an observability read + /// must never break the collection loop. Internal so a gated live test can seed a row and assert the read. + /// + internal static async Task> ReadCollectorWatermarksAsync( + NpgsqlDataSource postgres, int serverId, ILogger? logger, CancellationToken cancellationToken) + { + var watermarks = new Dictionary(StringComparer.OrdinalIgnoreCase); + try + { + await using var connection = await postgres.OpenConnectionAsync(cancellationToken); + using var command = new NpgsqlCommand( + "SELECT collector_name, MAX(collection_time) FROM collection_log WHERE server_id = $1 GROUP BY collector_name", connection); + command.Parameters.AddWithValue(serverId); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + if (reader.IsDBNull(1)) + { + continue; + } + + watermarks[reader.GetString(0)] = DateTime.SpecifyKind(reader.GetDateTime(1), DateTimeKind.Utc); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + /* Failure-isolated: fall back to never-run (prompt, jittered) seeds — an observability read must + never break the connect / reload path. */ + logger?.LogDebug( + "Observability: collector watermark read for server_id {ServerId} failed: {Message}", serverId, ex.Message); + } + + return watermarks; + } + + /// + /// Ensures the store connection string carries the collect/config search path (the V8 schema + /// split) so every pooled physical connection resolves the shared SQL's bare table names to the + /// collect/config schemas from its FIRST use. Managed mode already sets it + /// () — that string is returned unchanged, no + /// double-set. A bring-your-own connection string usually omits it and would otherwise rely on + /// the database-default search_path that + /// + /// best-effort sets via ALTER DATABASE ... SET search_path. + /// + /// That database default only governs the startup search_path of connections established + /// AFTER the ALTER commits, but the Npgsql pool's physical connections opened around it (for the + /// migration itself, the hypertable conversion, and the first collection sweep) keep their + /// pre-ALTER session search_path for their entire lifetime. On a FRESH BYO store that means the + /// whole first run fails — hypertable conversion, delta seeding, and every collector write hit + /// 42P01: relation "wait_stats" does not exist — and collection only starts working after + /// a service restart hands out a fresh pool. Carrying the search path on the connection string + /// itself is deterministic and pool-timing-independent; any login may SET its own + /// search_path, so this is safe for least-privilege BYO logins too. + /// + internal static string EnsureStoreSearchPath(string connectionString) + { + var builder = new NpgsqlConnectionStringBuilder(connectionString); + if (string.IsNullOrWhiteSpace(builder.SearchPath)) + { + /* DarlingManagedPostgres is annotated [SupportedOSPlatform("windows")] for its DPAPI / + bundled-cluster surface, but SearchPath is a platform-neutral compile-time constant + with no runtime dependency, and BYO mode runs on any OS — so the CA1416 cross-platform + reachability flag is spurious for this const reference. Suppressed narrowly rather than + forking the constant, keeping managed and BYO byte-identical (a test pins them equal). */ +#pragma warning disable CA1416 + builder.SearchPath = DarlingManagedPostgres.SearchPath; +#pragma warning restore CA1416 + return builder.ConnectionString; + } + + return connectionString; + } + + /// + /// Assembles the Phase-5 shared alert engine over Darling's third-party implementations: + /// darling.json thresholds/SMTP/webhooks (), the Postgres + /// collected feeds (), the V3 PG watermark/history/mute + /// stores, Lite-cadence delivery ( → the shared + /// EmailSendCore + WebhookAlertService), a live-msdb failed-jobs fetcher on the monitored + /// server's own connection, and a resolution hook that logs recovered conditions (the + /// headless stand-in for Lite's tray "Cleared" toasts). + /// + private AlertEngine BuildAlertEngine( + DarlingConfig config, List servers, + DarlingAlertSettings alertSettings, PgAlertHistoryStore historyStore, + MuteRuleService muteRuleService, DarlingAlertDeliverer deliverer) + { + var postgres = _postgres!; + var stateStore = new PgAlertStateStore(postgres, _logger); + + /* The mute service is loaded + owned by the caller (RunCollectionLoopAsync) so a control-plane + reload can re-LoadAsync() the SAME instance and mute the very next sweep (F16). The engine + binds its IsAlertMuted delegate, which reads the refreshed cache. The deliverer is hoisted by + the caller and shared with the Stage 4 self-alerts (same delivery/cooldown/restart-replay). */ + + return new AlertEngine( + alertSettings, + new DarlingAlertReadAdapter(postgres), + stateStore, + deliverer, + muteRuleService.IsAlertMuted, + failedJobsFetcher: (serverKey, lookbackMinutes, ct) => + FetchFailedJobsAsync(servers, serverKey, lookbackMinutes, ct), + resolutionCallback: async (resolution, _) => + { + _logger.LogInformation("[{Server}] {Title}: {Message}", + resolution.ServerName, resolution.Title, resolution.Message); + /* Stage 4 parity-gap fix: record a resolved-flavored history row so an operator reviewing + alert history sees the paired "Detected" then "Cleared/Resolved" entries (the Dashboard + records these explicitly; Darling previously only logged them). A resolution has no send + channel, so this never emails/webhooks; RecordAlertAsync is failure-isolated so it can + never break the sweep. */ + await historyStore.RecordAlertAsync(DarlingSelfAlertEvaluator.BuildResolutionRecord(resolution)); + }, + logger: _logger); + } + + /// + /// Builds this sweep's and runs the engine for one + /// connected server. The CPU pair mirrors what Lite's overview summary carries (the latest + /// cpu_utilization_stats sample; total = SQL + other-process, null when no SQL sample); + /// isOnline is true by definition here (a connected runtime) and suppression is always false + /// (headless — suppression is an engine INPUT owned by interactive hosts). Failure-isolated: + /// a failed sweep logs and retries on the next cadence tick, mirroring the collector loop. + /// + private async Task EvaluateAlertsAsync(AlertEngine engine, ServerLoopState server, CancellationToken cancellationToken) + { + var runtime = server.Runtime; + if (runtime is null) + { + return; + } + + try + { + var (sqlCpu, totalCpu) = await ReadLatestCpuAsync(runtime.ServerId, cancellationToken); + var snapshot = new AlertServerSnapshot( + runtime.ServerId.ToString(CultureInfo.InvariantCulture), + runtime.Config.DisplayName, + IsOnline: true, + SqlCpuPercent: sqlCpu, + TotalCpuPercent: totalCpu, + IsAzureSqlDb: runtime.Target.IsAzureSqlDb, + Suppressed: false); + + await engine.EvaluateServerAsync(snapshot, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError("[{Server}] Alert sweep failed: {Message}", server.Config.DisplayName, ex.Message); + } + } + + /// + /// The latest collected CPU sample for the snapshot — Lite's overview read + /// (LocalDataService.Overview.cs:37-51) against the raw PG table, and the + /// ServerSummaryItem.TotalCpuPercent derivation (:140-141): total = SQL + (other ?? 0), + /// null when there is no SQL sample (Azure SQL DB stores other as 0; Linux stores NULL). + /// + private async Task<(double? SqlCpu, double? TotalCpu)> ReadLatestCpuAsync(int serverId, CancellationToken cancellationToken) + { + double? sqlCpu = null; + double? otherCpu = null; + + await using var connection = await _postgres!.OpenConnectionAsync(cancellationToken); + using var command = new NpgsqlCommand(@" +SELECT sqlserver_cpu_utilization, other_process_cpu_utilization +FROM cpu_utilization_stats +WHERE server_id = $1 +ORDER BY sample_time DESC +LIMIT 1", connection); + command.Parameters.AddWithValue(serverId); + + using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (await reader.ReadAsync(cancellationToken)) + { + sqlCpu = reader.IsDBNull(0) ? null : Convert.ToDouble(reader.GetValue(0), CultureInfo.InvariantCulture); + otherCpu = reader.IsDBNull(1) ? null : Convert.ToDouble(reader.GetValue(1), CultureInfo.InvariantCulture); + } + + double? totalCpu = sqlCpu.HasValue ? sqlCpu.Value + (otherCpu ?? 0) : null; + return (sqlCpu, totalCpu); + } + + /// + /// Gathers the store disk-pressure sample and hands it to the Stage 4 evaluator (fleet-level). The store + /// size (pg_database_size, context only) is always readable; the store volume's free/total space is + /// resolved from the MANAGED data directory's drive — the bundled store this service owns and must protect. + /// In bring-your-own mode the store can be a remote Postgres whose disk the service cannot see, so + /// free/total stay null and the evaluator no-ops (never a false alarm — the operator owns their own + /// PostgreSQL's disk monitoring, consistent with the BYO posture elsewhere). Failure-isolated: a bad + /// sample logs at Debug and skips this tick, never breaking the loop. + /// + private async Task EvaluateStoreDiskPressureAsync(DarlingConfig config, CancellationToken cancellationToken) + { + long? storeSizeBytes = await ReadStoreSizeBytesAsync(cancellationToken); + + long? freeBytes = null; + long? totalBytes = null; + if (config.Postgres.Managed && OperatingSystem.IsWindows()) + { + try + { + var dataDirectory = DarlingManagedPostgres.ResolveDataDirectory(config.Postgres); + var root = Path.GetPathRoot(Path.GetFullPath(dataDirectory)); + if (!string.IsNullOrEmpty(root)) + { + var drive = new DriveInfo(root); + if (drive.IsReady) + { + freeBytes = drive.TotalFreeSpace; + totalBytes = drive.TotalSize; + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + /* Best-effort: an unreadable drive just means no disk signal this tick. */ + _logger.LogDebug("Store disk-pressure check: could not read the store volume free space: {Message}", ex.Message); + } + } + + /* EvaluateDiskPressureAsync (not ApplyDiskPressureAsync) so a throwing seam — e.g. a mute rule's + Matches() in the pre-deliver mute check — is isolated inside the evaluator, exactly like the + per-server EvaluateStoreAlertsAsync. This sweep-loop body has no catch-all of its own, so an + un-isolated throw here would stop collection for the whole fleet. */ + await _selfAlerts!.EvaluateDiskPressureAsync(freeBytes, totalBytes, storeSizeBytes, cancellationToken); + } + + /// + /// The store database's on-disk size in bytes (pg_database_size) — context for the disk-pressure + /// alert text, the same read the Viewer's status bar uses. Failure-isolated to null (Debug) so a transient + /// store hiccup never breaks the disk-pressure check. + /// + private async Task ReadStoreSizeBytesAsync(CancellationToken cancellationToken) + { + try + { + await using var connection = await _postgres!.OpenConnectionAsync(cancellationToken); + using var command = new NpgsqlCommand("SELECT pg_database_size(current_database())", connection); + var result = await command.ExecuteScalarAsync(cancellationToken); + return result is null || result == DBNull.Value ? null : Convert.ToInt64(result, CultureInfo.InvariantCulture); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogDebug("Store disk-pressure check: could not read pg_database_size: {Message}", ex.Message); + return null; + } + } + + /// + /// The #1581 compression-job self-heal check (fleet-level, hourly, Timescale-only): read every stuck + /// COMPRESSION-policy job () and hand them to the + /// self-alert evaluator's re-arm-once/escalate machine, wired to + /// on the SAME open connection. One stuck job whose next_start went -infinity silently halts the + /// store's archival tier — the field incident — so this makes it visible AND self-heals it. Failure-isolated + /// at the worker level too (the connection open is OUTSIDE the evaluator's own isolation): a store hiccup logs + /// and skips this check, never aborting the sweep — mirroring the purge / disk-check isolation. + /// + private async Task EvaluateCompressionJobHealthAsync(CancellationToken cancellationToken) + { + try + { + await using var connection = await _postgres!.OpenConnectionAsync(cancellationToken); + var stuckJobs = await TimescaleSupport.ReadStuckCompressionJobsAsync( + connection, DateTime.UtcNow, _logger, cancellationToken); + await _selfAlerts!.EvaluateCompressionJobsAsync( + stuckJobs, + jobId => TimescaleSupport.TryRearmJobAsync(connection, jobId, _logger, cancellationToken), + cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + /* Shutdown — quiet and expected. */ + } + catch (Exception ex) + { + _logger.LogError("Compression-job health check failed: {Message}", ex.Message); + } + } + + /// + /// Runs the AN3 analysis pipeline for one connected server and routes the findings to the + /// shared notification path — Lite's CollectionBackgroundService.RunAnalysisIfDueAsync + /// per-server body transplanted: the in-flight guard skips a server whose previous + /// (possibly hung) pass has not finished; a FRESH DarlingAnalysisService per run + /// (IsAnalyzing is a single instance flag, so a shared instance whose task is abandoned on + /// timeout would block analysis for every other server); the 120-second timeout moves the + /// loop on without clearing the in-flight marker (the continuation clears it only when the + /// task truly finishes, so a hung server is not relaunched); findings are persisted inside + /// AnalyzeAsync and only routed to the notification channels when delivery is on. The + /// finding identity is the STORAGE name + its hash id — the same identity the collectors + /// stamp on every row (Lite's GetServerNameForStorage semantics), so findings join the + /// collected data; the alert engine's DisplayName snapshot identity is deliberately not + /// used here. + /// + private async Task RunScheduledAnalysisAsync( + ServerLoopState server, + PgPlanFetcher planFetcher, + AnalysisNotificationService notificationService, + bool notifyFindings, + CancellationToken stoppingToken) + { + var runtime = server.Runtime; + if (runtime is null) + { + return; + } + + /* The scheduled caller discards the outcome — the analyze_now command maps it to a result. */ + await RunAnalysisPassAsync( + runtime.ServerId, runtime.StorageName, server.Config.DisplayName, + planFetcher, notificationService, notifyFindings, stoppingToken); + } + + /// Terminal states of one analysis pass — surfaced to the analyze_now command result. + private enum AnalysisPassStatus { Ran, Skipped, TimedOut, InsufficientData, Error } + + private sealed record AnalysisPassResult(AnalysisPassStatus Status, int FindingCount, string? Message); + + /// + /// One analysis pass for a server — the shared core of the scheduled sweep and the analyze_now + /// command. Lite's per-server body: the in-flight guard skips a server whose previous (possibly hung) + /// pass has not finished; a FRESH per run (IsAnalyzing is a single + /// instance flag); the 120-second timeout moves on without clearing the in-flight marker (the + /// continuation clears it only when the task truly finishes, so a hung server is not relaunched); + /// findings persist inside AnalyzeAsync and route to the notification channels only when delivery is on + /// (Lite's D0 split). Returns the terminal state so the command path can report it; the scheduled caller + /// ignores the return. Analyzes the STORAGE identity (Lite's GetServerNameForStorage), so a disconnected + /// but previously-collected server can still be analyzed on demand (its stored data drives the pass). + /// + private async Task RunAnalysisPassAsync( + int serverId, + string storageName, + string displayName, + PgPlanFetcher planFetcher, + AnalysisNotificationService notificationService, + bool notifyFindings, + CancellationToken stoppingToken) + { + if (!_analysisInFlight.TryAdd(serverId, 0)) + { + return new AnalysisPassResult(AnalysisPassStatus.Skipped, 0, "analysis is already running for this server"); + } + + try + { + var analysisService = new DarlingAnalysisService(_postgres!, planFetcher, _logger); + var analyzeTask = analysisService.AnalyzeAsync(serverId, storageName, hoursBack: 4); + + /* Clear the in-flight marker only when the task truly finishes — not + when the timeout below moves us on — so a hung server is not relaunched. */ + _ = analyzeTask.ContinueWith( + completed => _analysisInFlight.TryRemove(serverId, out _), + TaskScheduler.Default); + + var finished = await Task.WhenAny(analyzeTask, Task.Delay(s_analysisTimeout, stoppingToken)); + + if (stoppingToken.IsCancellationRequested) + { + return new AnalysisPassResult(AnalysisPassStatus.Skipped, 0, "service is stopping"); + } + + if (finished != analyzeTask) + { + _logger.LogWarning( + "[{Server}] Analysis exceeded {Timeout}s — skipped this cycle", + displayName, (int)s_analysisTimeout.TotalSeconds); + return new AnalysisPassResult(AnalysisPassStatus.TimedOut, 0, $"analysis exceeded {(int)s_analysisTimeout.TotalSeconds}s"); + } + + /* Analysis already persisted its findings inside AnalyzeAsync. Only route them + to the notification channels when delivery is on (Lite's D0 split: production + unconditional, delivery gated). */ + var findings = await analyzeTask; + if (notifyFindings) + { + await notificationService.NotifyAsync(findings); + } + + /* Persist the pass's insufficient-data determination (V19 marker) so the Viewer's + Recommendations tab shows "still collecting" instead of a false all-clear on a young + deployment: true + the engine's message when the pass hit the 24h data-span gate, cleared + (false) when a real pass completed on enough data. Failure-isolated like the other + observability writes. Only the two REAL terminal states write it — a Skipped/TimedOut/Error + pass (handled above / in the catch) leaves the last known marker untouched. */ + if (analysisService.InsufficientDataMessage is string insufficient) + { + await DarlingObservability.WriteAnalysisStateAsync( + _postgres!, serverId, insufficientData: true, insufficient, _logger, stoppingToken); + return new AnalysisPassResult(AnalysisPassStatus.InsufficientData, 0, insufficient); + } + + await DarlingObservability.WriteAnalysisStateAsync( + _postgres!, serverId, insufficientData: false, null, _logger, stoppingToken); + return new AnalysisPassResult(AnalysisPassStatus.Ran, findings.Count, null); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + /* Shutting down — the loop's own cancellation check ends the sweep. */ + return new AnalysisPassResult(AnalysisPassStatus.Skipped, 0, "service is stopping"); + } + catch (Exception ex) + { + _logger.LogError("[{Server}] Analysis failed: {Message}", displayName, ex.Message); + /* If analyzeTask was never created (e.g. ctor threw), the continuation + never ran — clear the marker defensively. */ + _analysisInFlight.TryRemove(serverId, out _); + return new AnalysisPassResult(AnalysisPassStatus.Error, 0, ex.Message); + } + } + + /// + /// The analyze_now command handler (Recommendations "Generate now", control-plane form): forces + /// an immediate analysis pass for one monitored server, bypassing its NextAnalysisDue wait, and maps the + /// terminal outcome to a command result. Shares the in-flight guard with the scheduled sweep, so it + /// no-ops (reported "already running") rather than racing a pass in flight for the same server. + /// + private async Task RunAnalyzeNowAsync( + List servers, + PgPlanFetcher planFetcher, + AnalysisNotificationService notificationService, + DarlingConfig config, + int serverId, + CancellationToken cancellationToken) + { + ServerLoopState? server; + lock (_serversLock) + { + server = servers.Find(s => ServerIdHelper.GetDeterministicHashCode(s.Config.StorageName) == serverId); + } + + if (server is null) + { + return new CommandOutcome(false, "server not monitored", JsonError($"no monitored server with server_id {serverId}")); + } + + var result = await RunAnalysisPassAsync( + serverId, server.Config.StorageName, server.Config.DisplayName, + planFetcher, notificationService, config.Analysis.NotificationsEnabled, cancellationToken); + + return result.Status switch + { + AnalysisPassStatus.Ran => new CommandOutcome(true, "analysis complete", + JsonSerializer.Serialize(new { success = true, server = server.Config.DisplayName, findings = result.FindingCount })), + AnalysisPassStatus.InsufficientData => new CommandOutcome(true, "insufficient data", + JsonSerializer.Serialize(new { success = true, server = server.Config.DisplayName, message = result.Message })), + AnalysisPassStatus.Skipped => new CommandOutcome(false, "analysis already running", JsonError(result.Message ?? "skipped")), + AnalysisPassStatus.TimedOut => new CommandOutcome(false, "analysis timed out", JsonError(result.Message ?? "timed out")), + _ => new CommandOutcome(false, "analysis failed", JsonError(result.Message ?? "error")), + }; + } + + /// A failure result_json body: { "success": false, "error": ... }. + private static string JsonError(string error) => JsonSerializer.Serialize(new { success = false, error }); + + /// + /// The purge_now command handler (the daily retention purge on demand): runs + /// over the shared store immediately and reports the tables + + /// rows purged. Fleet-wide over the SHARED tables (no target server). When + /// is set it purges every collector to that horizon (a + /// _ => customDays resolver — PurgeAsync clamps a sub-1-day horizon at its destructive sink, so a + /// bad custom-N can never wipe a table); otherwise it uses the SAME fleet resolver the scheduled daily + /// purge uses ( over the live overrides). Takes + /// NO collection gate: unlike snapshot_now a purge writes no collector state and races no delta baseline, + /// and PurgeAsync is idempotent + failure-isolated per table, so it may safely overlap the daily sweep. + /// + private async Task RunPurgeNowAsync(int? customRetentionDays, CancellationToken cancellationToken) + { + /* Reference read of the live overrides, matching the daily purge caller (never held under a lock — + the reload swaps the whole list atomically). */ + var overrides = _scheduleOverrides; + Func resolver = customRetentionDays is int days + ? _ => days + : name => StoreConfigProvider.ResolveFleetRetentionDays(name, overrides); + + var summary = await DarlingRetention.PurgeAsync( + _postgres!, _timescaleAvailable, _logger, cancellationToken, resolver); + + _logger.LogInformation( + "purge_now purged {Tables} table(s), {Rows} row(s)/chunk(s){Custom}", + summary.TablesPurged, summary.TotalPurged, + customRetentionDays is int cd ? $" (custom retention {cd}d)" : string.Empty); + + var json = JsonSerializer.Serialize(new + { + success = true, + tablesPurged = summary.TablesPurged, + rowsPurged = summary.TotalPurged, + rowsDeleted = summary.RowsDeleted, + chunksDropped = summary.ChunksDropped, + customRetentionDays, + }); + return new CommandOutcome(true, "purge complete", json); + } + + /// + /// The worker's adapter (Stage 2): lets the command executor reach the + /// two imperative commands that need the LIVE loop (snapshot_now / analyze_now) without the executor + /// holding the worker's mutable loop state. Captures the running server set + collector runner + analysis + /// pieces created in ; the worker methods it calls take the + /// for the server lookup. + /// + private sealed class WorkerCommandHost : IDarlingCommandHost + { + private readonly DarlingWorker _worker; + private readonly List _servers; + private readonly DarlingCollectorRunner _runner; + private readonly PgPlanFetcher _planFetcher; + private readonly AnalysisNotificationService _notificationService; + private readonly DarlingConfig _config; + + public WorkerCommandHost( + DarlingWorker worker, List servers, DarlingCollectorRunner runner, + PgPlanFetcher planFetcher, AnalysisNotificationService notificationService, DarlingConfig config) + { + _worker = worker; + _servers = servers; + _runner = runner; + _planFetcher = planFetcher; + _notificationService = notificationService; + _config = config; + } + + public Task SnapshotNowAsync(int serverId, CancellationToken cancellationToken) + => _worker.RunSnapshotAsync(_servers, _runner, serverId, cancellationToken); + + public Task AnalyzeNowAsync(int serverId, CancellationToken cancellationToken) + => _worker.RunAnalyzeNowAsync(_servers, _planFetcher, _notificationService, _config, serverId, cancellationToken); + + public Task PurgeNowAsync(int? customRetentionDays, CancellationToken cancellationToken) + => _worker.RunPurgeNowAsync(customRetentionDays, cancellationToken); + + public Task FetchPlanAsync(int serverId, PlanFetchRequest request, CancellationToken cancellationToken) + => _worker.RunFetchPlanAsync(_servers, _planFetcher, serverId, request, cancellationToken); + + public Task ExecuteActualPlanAsync(int serverId, ActualPlanRequest request, CancellationToken cancellationToken) + => _worker.RunExecuteActualPlanAsync(_servers, serverId, request, cancellationToken); + + public Task FetchActiveQueriesLiveAsync(int serverId, CancellationToken cancellationToken) + => _worker.RunFetchActiveQueriesLiveAsync(_servers, _runner, serverId, cancellationToken); + } + + /// + /// The engine's live-msdb failed-jobs feed: runs the shared on + /// the monitored server's own connection. Gated !IsAzureSqlDb (the engine also gates on the + /// snapshot; there is deliberately NO msdb-access probe — Phase-5 review F11) and degrades + /// exactly like the Dashboard's caller: a login without msdb / SQLAgentReaderRole access + /// raises SqlException 229/297/300/916 → Info + empty list; any other failure → Warning + + /// empty list — a permission gap or transient error never fails the alert cycle. + /// + private async Task> FetchFailedJobsAsync( + List servers, string serverKey, int lookbackMinutes, CancellationToken cancellationToken) + { + /* Lookup only — held briefly under the lock (the command loop reconciles the list concurrently), + then the connection I/O runs outside it. */ + ServerRuntime? runtime; + lock (_serversLock) + { + runtime = servers + .Select(s => s.Runtime) + .FirstOrDefault(r => r is not null + && string.Equals(r.ServerId.ToString(CultureInfo.InvariantCulture), serverKey, StringComparison.Ordinal)); + } + + if (runtime is null || runtime.Target.IsAzureSqlDb) + { + return new List(); + } + + try + { + using var connection = new SqlConnection(runtime.ConnectionString); + await connection.OpenAsync(cancellationToken); + + using var command = new SqlCommand(FailedJobsQuery.Sql, connection) { CommandTimeout = 10 }; + command.Parameters.Add(new SqlParameter(FailedJobsQuery.LookbackMinutesParameter, SqlDbType.Int) { Value = lookbackMinutes }); + using var reader = await command.ExecuteReaderAsync(cancellationToken); + + return await FailedJobsQuery.ReadAsync(reader, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (SqlException ex) when (ex.Number is 229 or 297 or 300 or 916) + { + /* Expected for read-only monitoring accounts; hit every alert cycle, so Info. */ + _logger.LogInformation("[{Server}] Skipping recently-failed-job check (msdb/SQLAgentReaderRole access needed): {Message}", + runtime.Config.DisplayName, ex.Message); + return new List(); + } + catch (Exception ex) + { + _logger.LogWarning("[{Server}] Recently-failed-job check errored: {Message}", + runtime.Config.DisplayName, ex.Message); + return new List(); + } + } + + private async Task TryConnectAsync(ServerLoopState server, DarlingCollectorRunner runner, DarlingConfig config, CancellationToken cancellationToken) + { + if (DateTime.UtcNow < server.NextConnectAttempt) + { + return; + } + + try + { + var runtime = await DarlingServerConnector.ConnectAsync(server.Config, _logger, cancellationToken); + server.Runtime = runtime; + /* Force the long-query trace (#1496) to re-reconcile on the next sweep after every (re)connect: + an Azure database-scoped session can stop on reconnect, so a still-"applied" flag would + otherwise skip restarting it. Cheap — the reconcile no-ops unless the desired state differs + from what actually exists (ensure is IF-NOT-running START; drop is IF EXISTS). */ + server.LongQueryTraceApplied = null; + /* Capture the id once, while the connection is freshly established and non-null: an on-load + RunOneAsync below can drop server.Runtime on a mid-collection connection-level failure, so any + later read of server.Runtime.ServerId (the schedule resolve, the connection edge) would NRE. */ + var serverId = runtime.ServerId; + + /* Retired containment (#1553 D1), the CONNECT-path re-check: a reconcile-remove may have retired this + server while this connect body sat QUEUED on the fleet gate or ran the connect I/O (3+ min under + distress). The connect SUCCEEDED, but a just-disabled server must incur ZERO durable side-effects — + so bail BEFORE the registry upsert, the XE CREATE SESSION DDL, and the Server-Restored edge, + dropping the runtime we just took. The entry check in ProcessServerSweepAsync covers the queued- + dequeue case; this closes the window where removal lands DURING the connect itself. */ + if (server.Retired) + { + server.Runtime = null; + return; + } + + _logger.LogInformation("[{Server}] Connected (major {Major}, edition {Edition}, server_id {ServerId})", + server.Config.DisplayName, + runtime.Target.SqlMajorVersion, + runtime.Target.IsAzureSqlDb ? "AzureSqlDb" : runtime.Target.IsAzureManagedInstance ? "ManagedInstance" : "Box", + serverId); + + /* Stage 4: the offline->online connection edge (Server Restored) — fired HERE, right after the + connection is established and BEFORE the on-load collectors run, using the captured serverId. + The connect succeeded regardless of what the on-load collectors do next, so this is the correct + point to record "online" (and it can't NRE on a Runtime the on-load loop might drop). Fires only + if the server was previously seen offline; the first-ever connect is a silent baseline (the + state machine mirrors the Dashboard's skip-first-check). */ + await _selfAlerts!.ApplyConnectionOutcomeAsync( + serverId, server.Config.DisplayName, online: true, error: null, cancellationToken); + + /* Same edge, second consumer: discard anything we concluded about this server while it was + unreachable. An Azure SQL DB firewall rejection or failover is reported with the same error + numbers as "this login may not read master", so a verdict formed during the outage can be + wrong — and used to persist until the service restarted, quietly degrading database-scoped + collection (#1506). */ + runner.OnServerReconnected(serverId); + + await DarlingObservability.UpsertServerAsync(_postgres!, runtime, _logger, cancellationToken); + + await DarlingXeSessions.EnsureAllAsync(runtime, runner, _logger, cancellationToken); + + /* On-load config snapshots (effective FrequencyMinutes 0) run once per connect, then every + scheduled collector becomes immediately due — mirrors Lite's server-open behavior. The + effective schedule layers config_collector_schedules overrides on CollectorScheduleDefaults; + a collector disabled by an override is neither run on-load nor scheduled. + + These on-load runs are NOT under the per-server CollectionGate (unlike the scheduled sweep and + snapshot_now). Safe today: this path only runs while Runtime is null, and a snapshot_now needs + a non-null Runtime, so the two never overlap for one server. If TryConnect's timing ever changes + so a connect can race a snapshot, gate this loop too. */ + var now = DateTime.UtcNow; + /* #1575: seed each scheduled collector's first post-connect due time from its persisted last-run + watermark so a restart RESUMES the real cadence instead of re-phasing it up to a full interval + forward (which starved long-frequency collectors like the daily index_object_stats across a + restart-heavy window). ONE batched round-trip reads every collector's MAX(collection_time) for this + server; ComputeSeededNextDue turns each into a due time — a recently-run collector waits out the + remaining interval, an overdue / never-run one runs promptly under a small per-server jitter that + de-clusters the fleet WITHOUT the old full-interval defer (#1553's anti-herd intent, capped). The + steady-state advance in RunDueCollectorsAsync stays on the exact interval. */ + var watermarks = await ReadCollectorWatermarksAsync(_postgres!, serverId, _logger, cancellationToken); + foreach (var name in CollectorScheduleDefaults.All.Keys) + { + /* Captured serverId, not server.Runtime.ServerId: an earlier on-load RunOneAsync in this loop + can null server.Runtime on a connection-level failure, which would otherwise NRE here. */ + var effective = StoreConfigProvider.ResolveSchedule(name, serverId, _scheduleOverrides); + if (!effective.Enabled) + { + continue; + } + + if (effective.FrequencyMinutes == 0) + { + await RunOneAsync(server, runner, name, cancellationToken); + } + else + { + var lastRun = watermarks.TryGetValue(name, out var w) ? w : (DateTime?)null; + var jitter = SeedJitter(serverId, effective.FrequencyMinutes * 60); + server.NextDue[name] = ComputeSeededNextDue(lastRun, effective.FrequencyMinutes, now, jitter); + } + } + + /* Phase the first scheduled analysis over a SMALL fixed sub-2.5-minute window (#1553 jitter site 3): + at a fleet restart every freshly connected server would otherwise become analysis-due in the same + sweep, and with N=4 concurrency that clusters 4 analysis passes at once. A deterministic per-server + offset de-clusters them while keeping post-restart analysis prompt (state-M3). The window is a + fixed 150s, NOT the full analysis interval — full-interval phasing would break the "already- + populated store analyzes promptly" promise by up to the interval, and is redundant since analysis + already runs inside the N=4-bounded body. Left at MinValue when analysis is disabled so re-enabling + still runs immediately (the sweep gates on config.Analysis.Enabled). */ + server.NextAnalysisDue = config.Analysis.Enabled + ? DateTime.UtcNow.Add(CadencePhaseOffset(serverId, 150)) + : DateTime.MinValue; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + server.Runtime = null; + server.NextConnectAttempt = DateTime.UtcNow.AddSeconds(60); + _logger.LogWarning("[{Server}] Connect failed, retrying in 60s: {Message}", server.Config.DisplayName, ex.Message); + + /* Stage 4: the online->offline connection edge (Server Unreachable) — fires once when a + previously-connected server can no longer be reached; a repeated failed reconnect does NOT + re-fire (the state machine dedups). server_id is derived from the config since Runtime is null. */ + await _selfAlerts!.ApplyConnectionOutcomeAsync( + ServerIdHelper.GetDeterministicHashCode(server.Config.StorageName), + server.Config.DisplayName, online: false, error: ex.Message, cancellationToken); + } + } + + private async Task RunDueCollectorsAsync(ServerLoopState server, DarlingCollectorRunner runner, CancellationToken cancellationToken) + { + var runtime = server.Runtime; + if (runtime is null) + { + return; + } + + /* Non-blocking: if an on-demand snapshot_now holds this server's gate, skip its scheduled sweep + this pass (the due collectors run next sweep) — the main loop NEVER blocks on the gate, so a + long snapshot cannot starve collection of the OTHER servers. */ + if (!await server.CollectionGate.WaitAsync(0, cancellationToken)) + { + return; + } + + try + { + var now = DateTime.UtcNow; + foreach (var name in CollectorScheduleDefaults.All.Keys) + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + /* Effective schedule = config_collector_schedules override layered on the code default. + A disabled or on-load-only (freq 0) collector is skipped; the frequency the NextDue stamp + advances by is the EFFECTIVE one, so an override takes effect immediately. */ + var effective = StoreConfigProvider.ResolveSchedule(name, runtime.ServerId, _scheduleOverrides); + if (!effective.Enabled + || effective.FrequencyMinutes == 0 + || !server.NextDue.TryGetValue(name, out var due) + || now < due) + { + continue; + } + + server.NextDue[name] = now.AddMinutes(effective.FrequencyMinutes); + await RunOneAsync(server, runner, name, cancellationToken); + } + } + finally + { + server.CollectionGate.Release(); + } + } + + /// + /// The snapshot_now command handler (Lite's Live Snapshot, control-plane form): runs EVERY + /// enabled collector for one connected server immediately, bypassing the schedule, and reports the + /// collectors run + total rows. Serialized against the scheduled sweep by the per-server + /// so the two never double-collect. Waits its turn for the + /// gate (unlike the main loop, which skips) because an explicit operator snapshot should not be dropped. + /// + private async Task RunSnapshotAsync( + List servers, DarlingCollectorRunner runner, int serverId, CancellationToken cancellationToken) + { + ServerLoopState? server; + lock (_serversLock) + { + server = servers.Find(s => ServerIdHelper.GetDeterministicHashCode(s.Config.StorageName) == serverId); + } + + if (server is null) + { + return new CommandOutcome(false, "server not monitored", JsonError($"no monitored server with server_id {serverId}")); + } + + if (server.Runtime is null) + { + return new CommandOutcome(false, "server not connected", + JsonError($"server '{server.Config.DisplayName}' is not currently connected — snapshot skipped")); + } + + await server.CollectionGate.WaitAsync(cancellationToken); + try + { + /* Runtime can be dropped by a concurrent connection failure between the check above and here; + re-read under the gate. */ + var runtime = server.Runtime; + if (runtime is null) + { + return new CommandOutcome(false, "server not connected", + JsonError($"server '{server.Config.DisplayName}' disconnected before the snapshot ran")); + } + + var collectorsRun = 0; + var totalRows = 0; + foreach (var name in CollectorScheduleDefaults.All.Keys) + { + cancellationToken.ThrowIfCancellationRequested(); + + /* Honor a disabled-by-override collector (mirrors the on-load/scheduled gates); run every + enabled one NOW regardless of frequency or NextDue — that is what "snapshot" means. */ + var effective = StoreConfigProvider.ResolveSchedule(name, runtime.ServerId, _scheduleOverrides); + if (!effective.Enabled) + { + continue; + } + + totalRows += await RunOneAsync(server, runner, name, cancellationToken); + collectorsRun++; + } + + _logger.LogInformation("[{Server}] snapshot_now ran {Collectors} collector(s), {Rows} row(s)", + server.Config.DisplayName, collectorsRun, totalRows); + var json = JsonSerializer.Serialize(new + { + success = true, + server = server.Config.DisplayName, + collectorsRun, + rows = totalRows, + }); + return new CommandOutcome(true, "snapshot complete", json); + } + finally + { + server.CollectionGate.Release(); + } + } + + /// + /// The fetch_plan command handler (headless-plan live-plan wave): reads an execution plan from one + /// monitored server's LIVE plan cache and returns the plan XML — the mechanism the viewer uses to fetch a + /// plan for ANY process in a deadlock graph / blocked-process report (by its sql_handle) or the + /// currently-cached plan for a query-grid row (by its plan_handle), neither of which the store holds. The + /// actual cache read is delegated to the SAME the analysis pipeline already uses + /// (it resolves the serverId to the connected runtime's connection string) — the plan_handle path is its + /// existing , the sql_handle path its + /// . Unlike snapshot_now it takes NO collection gate: a + /// DMV plan read touches no collector state and writes nothing, so it can run concurrently with a scheduled + /// sweep. The up-front lookup gives a precise "not monitored" / "not connected" outcome (mirroring + /// RunSnapshotAsync); a plan that has aged out of the cache — or any fetch error the fetcher swallows to null + /// per its analysis-safe contract — is reported as a clean "not in cache" (the command itself succeeded). + /// + private async Task RunFetchPlanAsync( + List servers, PgPlanFetcher planFetcher, int serverId, + PlanFetchRequest request, CancellationToken cancellationToken) + { + /* Lookup under the lock (the command loop reconciles the list concurrently); the fetcher re-resolves the + serverId to the runtime connection string itself, so this only gates the precise not-monitored / + not-connected outcomes. Held only for the microsecond lookup. */ + ServerLoopState? server; + bool connected; + string displayName; + lock (_serversLock) + { + server = servers.Find(s => ServerIdHelper.GetDeterministicHashCode(s.Config.StorageName) == serverId); + connected = server?.Runtime is not null; + displayName = server?.Config.DisplayName ?? serverId.ToString(CultureInfo.InvariantCulture); + } + + if (server is null) + { + return new CommandOutcome(false, "server not monitored", JsonError($"no monitored server with server_id {serverId}")); + } + + if (!connected) + { + return new CommandOutcome(false, "server not connected", + JsonError($"server '{displayName}' is not currently connected — the live plan cache can only be read from a connected server")); + } + + var planXml = request.UsePlanHandle + ? await planFetcher.FetchPlanXmlAsync(serverId, request.PlanHandle!) + : await planFetcher.FetchPlanBySqlHandleAsync( + serverId, request.DatabaseName, request.SqlHandle!, + request.StatementStartOffset, request.StatementEndOffset, cancellationToken); + + if (string.IsNullOrEmpty(planXml)) + { + _logger.LogInformation("[{Server}] fetch_plan: the requested plan is not in the cache", displayName); + /* Succeeded (the fetch ran) but the plan is gone — the viewer shows a "not in cache" info, distinct + from a failure. planXml is null so the viewer's parse hits the not-in-cache branch. */ + return new CommandOutcome(true, "not in cache", + JsonSerializer.Serialize(new { success = true, planXml = (string?)null })); + } + + _logger.LogInformation("[{Server}] fetch_plan returned a {Length}-char plan", displayName, planXml.Length); + return new CommandOutcome(true, "plan fetched", + JsonSerializer.Serialize(new { success = true, planXml })); + } + + /// The SQL command timeout (seconds) for the live active-queries DMV read. A "what is running now" + /// snapshot should return quickly; the shared collector query sets LOCK_TIMEOUT 1000 and runs under + /// READ UNCOMMITTED, so 30s is a wide margin. Bounded well under the viewer's + /// ActiveQueriesLiveTimeout poll budget so the viewer sees a real "timed out" outcome rather than a + /// poll miss, and so a wedged read cannot pin the single-threaded command loop past the stale-command reaper. + public const int ActiveQueriesFetchTimeoutSeconds = 30; + + /// + /// The fetch_active_queries command handler (headless-plan live-snapshot wave): reads the LIVE + /// running-request DMV snapshot from one monitored server on demand and returns the rows — the on-demand, + /// live counterpart of the collector's stored hourly snapshot, for the viewer's Current Active Queries tab. + /// The query and shredding are REUSED from the shared (via + /// ), so the live rows carry the SAME columns as the + /// stored ones. Read-only — sys.dm_exec_requests/sessions + the sql_text / query_plan DMFs — so + /// unlike it is not consent-class and takes NO collection gate (a DMV + /// read touches no collector state and writes nothing, so it runs concurrently with a scheduled sweep). The + /// up-front lookup gives a precise "not monitored" / "not connected" outcome (mirroring + /// ); a timeout / permission gap / SQL error is caught and reported as a + /// legible outcome rather than a raw exception (mirroring ). + /// + private async Task RunFetchActiveQueriesLiveAsync( + List servers, DarlingCollectorRunner runner, int serverId, CancellationToken cancellationToken) + { + /* Resolve the runtime under the lock (the command loop reconciles the list concurrently); ServerRuntime is + immutable, so capturing the reference and using it after the lock is safe. Held only for the lookup. */ + ServerLoopState? server; + ServerRuntime? runtime; + string displayName; + lock (_serversLock) + { + server = servers.Find(s => ServerIdHelper.GetDeterministicHashCode(s.Config.StorageName) == serverId); + runtime = server?.Runtime; + displayName = server?.Config.DisplayName ?? serverId.ToString(CultureInfo.InvariantCulture); + } + + if (server is null) + { + return new CommandOutcome(false, "server not monitored", JsonError($"no monitored server with server_id {serverId}")); + } + + if (runtime is null) + { + return new CommandOutcome(false, "server not connected", + JsonError($"server '{displayName}' is not currently connected — the live active queries can only be read from a connected server")); + } + + try + { + var rows = await runner.FetchRowsAsync( + QuerySnapshotsCollector.Instance, runtime, ActiveQueriesFetchTimeoutSeconds, cancellationToken); + _logger.LogInformation("[{Server}] fetch_active_queries returned {Count} running request(s)", displayName, rows.Count); + return new CommandOutcome(true, "active queries fetched", ActiveQueriesLivePayload.Serialize(rows, DateTime.UtcNow)); + } + catch (OperationCanceledException) + { + /* Service shutdown mid-read — let the poller's catch stop the loop cleanly. */ + throw; + } + catch (SqlException ex) when (ex.Number == -2) + { + return new CommandOutcome(false, "timed out", + JsonError($"reading the live active queries on '{displayName}' did not finish within the {ActiveQueriesFetchTimeoutSeconds}s budget and was cancelled")); + } + catch (SqlException ex) when (IsPermissionError(ex)) + { + return new CommandOutcome(false, "permission denied", + JsonError($"the monitoring login for '{displayName}' lacks permission to read the active-query DMVs (VIEW SERVER STATE is required). (SQL error {ex.Number}: {ex.Message})")); + } + catch (SqlException ex) + { + return new CommandOutcome(false, "sql error", + JsonError($"reading the live active queries on '{displayName}' failed (SQL error {ex.Number}): {ex.Message}")); + } + catch (Exception ex) + { + return new CommandOutcome(false, "error", + JsonError($"reading the live active queries on '{displayName}' failed: {ex.Message}")); + } + } + + /// The store lookup that resolves the actual-plan command's query_stats IDENTIFIER (server_id + + /// query_hash + database_name) to the latest captured query text + estimated plan XML — the SERVICE's own + /// copy, so no SQL text ever rides on the command payload. Serves Top Queries, Query-Stats history, and the + /// FinOps High Impact grid. The third column (isolation level) is NULL here (query_stats does not capture it). + /// Public const so a test can pin its shape ($1 server_id, $2 query_hash, $3 database_name). + public const string ResolveStoredQueryForActualPlanSql = @" +SELECT query_text, query_plan_xml, NULL::text AS transaction_isolation_level +FROM query_stats +WHERE server_id = $1 +AND query_hash = $2 +AND database_name = $3 +AND query_text IS NOT NULL +ORDER BY collection_time DESC +LIMIT 1"; + + /// The query_store_stats resolver — the Query Store history surface's identifier (server_id + + /// database_name + query_id) to its captured query text + stored plan. $1 server_id, $2 database_name, $3 + /// query_id. Isolation is NULL (Query Store does not capture it). + /// + /// The plan-presence tiebreak leads the sort (#1556): query_store now dedupes plan XML to ONE runtime-stats + /// interval per plan_id per cycle (NULL on the others), so a plain collection_time DESC could land on + /// a newer NULL-plan interval of the same query. (query_plan_text IS NOT NULL) DESC first prefers the + /// row that actually carries the plan; collection_time DESC then breaks ties toward the newest. The + /// query_text is the same query either way, so this is strictly more robust — the sibling stored-plan + /// readers' semantics. + /// + public const string ResolveStoredQueryStoreForActualPlanSql = @" +SELECT query_text, query_plan_text, NULL::text AS transaction_isolation_level +FROM query_store_stats +WHERE server_id = $1 +AND database_name = $2 +AND query_id = $3 +AND query_text IS NOT NULL +ORDER BY (query_plan_text IS NOT NULL) DESC, collection_time DESC +LIMIT 1"; + + /// The query_snapshots resolver — the Wait drill-down surface's identifier (server_id + + /// collection_time + session_id) to that captured request's query text, plan (live preferred), and isolation + /// level. $1 server_id, $2 collection_time, $3 session_id. The exact-timestamp match keys the one snapshot + /// the row represents. + public const string ResolveStoredSnapshotForActualPlanSql = @" +SELECT query_text, COALESCE(live_query_plan, query_plan), transaction_isolation_level +FROM query_snapshots +WHERE server_id = $1 +AND collection_time = $2 +AND session_id = $3 +AND query_text IS NOT NULL +ORDER BY collection_time DESC +LIMIT 1"; + + /// The command timeout (seconds) for the actual-plan re-execution. Bounded so a runaway query + /// cannot pin the single-threaded command loop or outlive the stale-command reaper; comfortably under the + /// viewer's 3-minute poll budget so the viewer sees a real "timed out" outcome rather than a poll miss. + public const int ActualPlanCaptureTimeoutSeconds = 120; + + /// + /// The execute_actual_plan command handler: RE-EXECUTES a stored query against one monitored server + /// with SET STATISTICS XML ON to capture its ACTUAL plan, and returns the plan XML. Unlike every other + /// worker-delegated command this WRITES to the target — re-running an INSERT/UPDATE/DELETE/MERGE re-applies + /// its changes (the viewer gates this behind informed consent). The command payload is an IDENTIFIER ONLY + /// (): the query text + estimated plan XML are resolved HERE from the service's + /// OWN store (), never from the payload, so a command writer + /// can never smuggle arbitrary SQL onto a target. The query runs as the server's stored monitoring credential; + /// the least-privilege VIEW SERVER STATE login has no SELECT on user tables, so a permission failure is caught + /// and surfaced legibly (as are timeouts), rather than a raw SQL exception. + /// + private async Task RunExecuteActualPlanAsync( + List servers, int serverId, ActualPlanRequest request, CancellationToken cancellationToken) + { + /* Resolve the connection under the lock (the command loop reconciles the list concurrently). Capture the + immutable connection string + Azure flag as locals — never hold the runtime across the execute. */ + bool serverExists; + string? connectionString; + bool isAzureSqlDb; + string displayName; + lock (_serversLock) + { + var server = servers.Find(s => ServerIdHelper.GetDeterministicHashCode(s.Config.StorageName) == serverId); + serverExists = server is not null; + connectionString = server?.Runtime?.ConnectionString; + isAzureSqlDb = server?.Runtime?.Target.IsAzureSqlDb ?? false; + displayName = server?.Config.DisplayName ?? serverId.ToString(CultureInfo.InvariantCulture); + } + + if (!serverExists) + { + return new CommandOutcome(false, "server not monitored", JsonError($"no monitored server with server_id {serverId}")); + } + + if (string.IsNullOrEmpty(connectionString)) + { + return new CommandOutcome(false, "server not connected", + JsonError($"server '{displayName}' is not currently connected — the actual plan can only be captured from a connected server")); + } + + if (_postgres is null) + { + return new CommandOutcome(false, "store unavailable", JsonError("the Postgres store is not available to resolve the query text")); + } + + /* Resolve the query text + estimated plan + isolation level from the store BY THE IDENTIFIER (the + collector's own capture) — the identifier-only contract: the payload named a stored row; the service + supplies the text. The SQL + bound parameters are chosen by the identifier kind (query_stats / + query_store_stats / query_snapshots). */ + string? queryText = null; + string? estimatedPlanXml = null; + string? isolationLevel = null; + try + { + await using var connection = await _postgres.OpenConnectionAsync(cancellationToken); + await using var command = new NpgsqlCommand(ResolveActualPlanSql(request.Source), connection); + BindActualPlanResolveParameters(command, serverId, request); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (await reader.ReadAsync(cancellationToken)) + { + queryText = reader.IsDBNull(0) ? null : reader.GetString(0); + estimatedPlanXml = reader.IsDBNull(1) ? null : reader.GetString(1); + isolationLevel = reader.IsDBNull(2) ? null : reader.GetString(2); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return new CommandOutcome(false, "store error", + JsonError($"could not resolve the stored query text for the actual-plan request: {ex.Message}")); + } + + if (string.IsNullOrWhiteSpace(queryText)) + { + return new CommandOutcome(false, "no stored query", + JsonError($"no stored query text was found for this {DescribeActualPlanIdentifier(request)} on '{displayName}' — the actual plan is captured by re-executing the stored query text, which is not available")); + } + + /* RE-EXECUTE via the SHARED executor (SET STATISTICS XML ON), as the server's stored credential. */ + try + { + var planXml = await ActualPlanExecutor.ExecuteForActualPlanAsync( + connectionString, + request.DatabaseName ?? "", + queryText, + estimatedPlanXml, + isolationLevel: isolationLevel, + isAzureSqlDb: isAzureSqlDb, + timeoutSeconds: ActualPlanCaptureTimeoutSeconds, + cancellationToken, + productName: "SQL Server Performance Monitor Darling"); + + if (string.IsNullOrEmpty(planXml)) + { + _logger.LogInformation("[{Server}] execute_actual_plan: the query ran but no plan was captured", displayName); + return new CommandOutcome(true, "no plan captured", + JsonSerializer.Serialize(new { success = true, planXml = (string?)null })); + } + + _logger.LogInformation("[{Server}] execute_actual_plan captured a {Length}-char actual plan", displayName, planXml.Length); + return new CommandOutcome(true, "actual plan captured", + JsonSerializer.Serialize(new { success = true, planXml })); + } + catch (OperationCanceledException) + { + /* Service shutdown mid-execute — let the poller's catch stop the loop cleanly. */ + throw; + } + catch (SqlException ex) when (ex.Number == -2) + { + return new CommandOutcome(false, "timed out", + JsonError($"the query did not finish within the {ActualPlanCaptureTimeoutSeconds}s actual-plan capture budget on '{displayName}' and was cancelled")); + } + catch (SqlException ex) when (IsPermissionError(ex)) + { + return new CommandOutcome(false, "permission denied", + JsonError($"the monitoring login for '{displayName}' lacks permission to execute this query. The least-privilege monitoring login has VIEW SERVER STATE for DMV reads but no SELECT/DML on the queried objects, so the actual plan cannot be captured. (SQL error {ex.Number}: {ex.Message})")); + } + catch (SqlException ex) + { + return new CommandOutcome(false, "sql error", + JsonError($"executing the query on '{displayName}' to capture the actual plan failed (SQL error {ex.Number}): {ex.Message}")); + } + catch (Exception ex) + { + return new CommandOutcome(false, "error", + JsonError($"executing the query on '{displayName}' to capture the actual plan failed: {ex.Message}")); + } + } + + /// Picks the store-resolution SQL for the actual-plan request's identifier kind. + private static string ResolveActualPlanSql(ActualPlanSource source) => source switch + { + ActualPlanSource.QueryStats => ResolveStoredQueryForActualPlanSql, + ActualPlanSource.QueryStore => ResolveStoredQueryStoreForActualPlanSql, + ActualPlanSource.QuerySnapshot => ResolveStoredSnapshotForActualPlanSql, + _ => throw new InvalidOperationException($"no store resolver for actual-plan source {source}"), + }; + + /// Binds the store-resolution parameters ($1 server_id, then the identifier's $2/$3) for the request's + /// identifier kind. The snapshot's collection_time binds as a naive-UTC timestamp (Unspecified), matching how + /// the collector stores it. + private static void BindActualPlanResolveParameters(NpgsqlCommand command, int serverId, ActualPlanRequest request) + { + command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId }); + switch (request.Source) + { + case ActualPlanSource.QueryStats: + command.Parameters.Add(new NpgsqlParameter { TypedValue = request.QueryHash! }); + command.Parameters.Add(new NpgsqlParameter { TypedValue = request.DatabaseName ?? "" }); + break; + case ActualPlanSource.QueryStore: + command.Parameters.Add(new NpgsqlParameter { TypedValue = request.DatabaseName ?? "" }); + command.Parameters.Add(new NpgsqlParameter { TypedValue = request.QueryId!.Value }); + break; + case ActualPlanSource.QuerySnapshot: + command.Parameters.Add(new NpgsqlParameter + { + TypedValue = DateTime.SpecifyKind(request.SnapshotCollectionTime!.Value, DateTimeKind.Unspecified), + }); + command.Parameters.Add(new NpgsqlParameter { TypedValue = request.SnapshotSessionId!.Value }); + break; + default: + throw new InvalidOperationException($"no store resolver for actual-plan source {request.Source}"); + } + } + + /// A human description of the request's identifier for the "no stored query" message. + private static string DescribeActualPlanIdentifier(ActualPlanRequest request) => request.Source switch + { + ActualPlanSource.QueryStats => $"query (query_hash {request.QueryHash})", + ActualPlanSource.QueryStore => $"Query Store query (query_id {request.QueryId})", + ActualPlanSource.QuerySnapshot => $"query snapshot (session {request.SnapshotSessionId})", + _ => "row", + }; + + /// + /// True when a SqlException is a permission denial — the expected failure when the least-privilege monitoring + /// login (VIEW SERVER STATE only) re-executes a query that reads/writes user objects. Detected by the known + /// permission error numbers or a "permission was denied" / "does not have permission" message, so the handler + /// can surface a clear cause instead of a raw exception. + /// + private static bool IsPermissionError(SqlException ex) + { + foreach (SqlError error in ex.Errors) + { + if (error.Number is 229 or 230 or 262 or 297 or 300 or 301 or 916 or 33044) + { + return true; + } + + if (error.Message.Contains("permission was denied", StringComparison.OrdinalIgnoreCase) + || error.Message.Contains("does not have permission", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + /// + /// Runs one collector for a server and logs its outcome to collection_log. Returns the rows written + /// (0 on skip/permissions/error) so an on-demand snapshot can tally them; the scheduled/on-load callers + /// simply discard the count. + /// + private async Task RunOneAsync(ServerLoopState server, DarlingCollectorRunner runner, string collectorName, CancellationToken cancellationToken) + { + var runtime = server.Runtime; + if (runtime is null || !s_dispatch.TryGetValue(collectorName, out var run)) + { + return 0; + } + + try + { + var result = await run(runner, runtime, cancellationToken); + _logger.LogInformation(" [{Server}] {Collector} => {Rows} rows (sql:{SqlMs}ms, pg:{PgMs}ms)", + server.Config.DisplayName, collectorName, result.Rows, result.SqlMs, result.StorageMs); + + await DarlingObservability.LogCollectionAsync( + _postgres!, runtime, collectorName, "SUCCESS", result.Rows, result.SqlMs, result.StorageMs, null, _logger, cancellationToken); + return result.Rows; + } + catch (OperationCanceledException) + { + throw; + } + catch (DarlingXeSessionMissingException ex) + { + /* The blocking/deadlock XE session is missing and couldn't be created — log a distinct + SESSION_MISSING status the Stage 4 Capture Down self-alert reads. Non-fatal, like a + permissions skip: log it and continue the rest of the sweep. RunXeTolerantAsync already + classified this (wrapping an XE 297/15151/'XE session' into the distinct type), so this + catch and the SqlException permissions filter below match disjoint types — their relative + order is not load-bearing; it only needs to precede the general Exception catch (which would + otherwise mislabel it ERROR). A non-XE 297 arrives as a plain SqlException and correctly + hits the permissions filter. */ + _logger.LogWarning(" [{Server}] {Collector} => XE session missing (capture down): {Message}", + server.Config.DisplayName, collectorName, ex.Message); + + await DarlingObservability.LogCollectionAsync( + _postgres!, runtime, collectorName, "SESSION_MISSING", 0, 0, 0, ex.Message, _logger, cancellationToken); + return 0; + } + catch (SqlException ex) when (ex.Number is 229 or 297 or 300) + { + _logger.LogWarning(" [{Server}] {Collector} => insufficient permissions ({Number}): {Message}", + server.Config.DisplayName, collectorName, ex.Number, ex.Message); + + await DarlingObservability.LogCollectionAsync( + _postgres!, runtime, collectorName, "PERMISSIONS", 0, 0, 0, ex.Message, _logger, cancellationToken); + return 0; + } + catch (Exception ex) + { + _logger.LogError(" [{Server}] {Collector} => ERROR: {Message}", + server.Config.DisplayName, collectorName, ex.Message); + + /* A dead connection poisons every collector — force a reconnect + reprobe. */ + if (ex is SqlException sqlEx && (sqlEx.Class >= 20 || sqlEx.Number == -2)) + { + server.Runtime = null; + server.NextConnectAttempt = DateTime.UtcNow.AddSeconds(60); + _logger.LogWarning("[{Server}] Connection-level failure — will reconnect", server.Config.DisplayName); + } + + /* Best-effort store write (#1556): this is also the OutOfMemoryException landing pad (OOM is an + Exception and no earlier catch claims it), and under an OOM this handler's own LogCollectionAsync + allocation can itself fail. A throw HERE would fault the fire-and-track body task instead of being + the isolated, already-logged ERROR above — so swallow a secondary failure (nothing is allocated in + the catch, to stay safe under the very condition it guards against). The LogError above already + recorded the fault to the app log, so no signal is lost. */ + try + { + await DarlingObservability.LogCollectionAsync( + _postgres!, runtime, collectorName, "ERROR", 0, 0, 0, ex.Message, _logger, cancellationToken); + } + catch + { + /* Intentionally empty — see the comment above. Do not add logging here; it must not throw. */ + } + + return 0; + } + } + + private delegate Task DispatchEntry(DarlingCollectorRunner runner, ServerRuntime server, CancellationToken cancellationToken); + + /// Test hook: the collector names the worker can dispatch (pinned against the catalog). + internal static IReadOnlyCollection DispatchedCollectorNames => s_dispatch.Keys.ToArray(); + + /// + /// Collector-name dispatch — the Darling twin of Lite's RunCollectorAsync switch, one typed + /// entry per shared definition, with Lite's forwarder tolerances mirrored: the XE readers + /// treat a missing/inaccessible session as zero rows, trace_flags treats denied DBCC as zero + /// rows with a warning. + /// + private static readonly IReadOnlyDictionary s_dispatch = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["wait_stats"] = (r, s, ct) => r.RunAsync(WaitStatsCollector.Instance, s, ct), + ["latch_stats"] = (r, s, ct) => r.RunAsync(LatchStatsCollector.Instance, s, ct), + ["spinlock_stats"] = (r, s, ct) => r.RunAsync(SpinlockStatsCollector.Instance, s, ct), + ["cpu_scheduler_stats"] = (r, s, ct) => r.RunAsync(CpuSchedulerStatsCollector.Instance, s, ct), + ["plan_cache_stats"] = (r, s, ct) => r.RunAsync(PlanCacheStatsCollector.Instance, s, ct), + ["tempdb_stats"] = (r, s, ct) => r.RunAsync(TempDbStatsCollector.Instance, s, ct), + ["memory_grant_stats"] = (r, s, ct) => r.RunAsync(MemoryGrantsCollector.Instance, s, ct), + ["cpu_utilization"] = (r, s, ct) => r.RunAsync(CpuUtilizationCollector.Instance, s, ct), + ["memory_stats"] = (r, s, ct) => r.RunAsync(MemoryStatsCollector.Instance, s, ct), + ["memory_clerks"] = (r, s, ct) => r.RunAsync(MemoryClerksCollector.Instance, s, ct), + ["memory_pressure_events"] = (r, s, ct) => r.RunAsync(MemoryPressureEventsCollector.Instance, s, ct), + ["file_io_stats"] = (r, s, ct) => r.RunAsync(FileIoStatsCollector.Instance, s, ct), + ["server_properties"] = (r, s, ct) => r.RunAsync(ServerPropertiesCollector.Instance, s, ct), + ["server_config"] = (r, s, ct) => r.RunAsync(ServerConfigCollector.Instance, s, ct), + ["database_config"] = (r, s, ct) => r.RunAsync(DatabaseConfigCollector.Instance, s, ct), + ["trace_flags"] = RunTraceFlagsTolerantAsync, + ["database_scoped_config"] = (r, s, ct) => r.RunAsync(DatabaseScopedConfigCollector.Instance, s, ct), + ["session_stats"] = (r, s, ct) => r.RunAsync(SessionStatsCollector.Instance, s, ct), + ["session_summary_stats"] = (r, s, ct) => r.RunAsync(SessionSummaryStatsCollector.Instance, s, ct), + ["waiting_tasks"] = (r, s, ct) => r.RunAsync(WaitingTasksCollector.Instance, s, ct), + ["procedure_stats"] = (r, s, ct) => r.RunAsync(ProcedureStatsCollector.Instance, s, ct), + ["running_jobs"] = (r, s, ct) => r.RunAsync(RunningJobsCollector.Instance, s, ct), + ["perfmon_stats"] = (r, s, ct) => r.RunAsync(PerfmonStatsCollector.Instance, s, ct), + ["dmv_blocking_snapshot"] = (r, s, ct) => r.RunAsync(DmvBlockingSnapshotCollector.Instance, s, ct), + ["database_size_stats"] = (r, s, ct) => r.RunAsync(DatabaseSizeStatsCollector.Instance, s, ct), + ["index_object_stats"] = (r, s, ct) => r.RunAsync(IndexObjectStatsCollector.Instance, s, ct), + ["query_stats"] = (r, s, ct) => r.RunAsync(QueryStatsCollector.Instance, s, ct), + ["query_snapshots"] = (r, s, ct) => r.RunAsync(QuerySnapshotsCollector.Instance, s, ct), + ["query_store"] = (r, s, ct) => r.RunAsync(QueryStoreCollector.Instance, s, ct), + ["deadlocks"] = (r, s, ct) => RunXeTolerantAsync(DeadlocksCollector.Instance, r, s, ct), + ["blocked_process_report"] = (r, s, ct) => RunXeTolerantAsync(BlockedProcessReportCollector.Instance, r, s, ct), + ["long_query_completions"] = (r, s, ct) => RunXeTolerantAsync(LongQueryCompletionsCollector.Instance, r, s, ct), + ["system_health_events"] = (r, s, ct) => r.RunAsync(SystemHealthEventsCollector.Instance, s, ct), + ["default_trace_events"] = (r, s, ct) => r.RunAsync(DefaultTraceEventsCollector.Instance, s, ct), + ["job_history"] = (r, s, ct) => r.RunAsync(JobHistoryCollector.Instance, s, ct), + ["agent_status"] = (r, s, ct) => r.RunAsync(AgentStatusCollector.Instance, s, ct), + }; + + /// + /// Signals that a blocking/deadlock XE session is missing or inaccessible so the reader returned no + /// events. throws it, catches it and logs a + /// distinct SESSION_MISSING collection_log status. Before Stage 4 this case swallowed to zero + /// rows and logged SUCCESS — indistinguishable from a genuinely idle session, so the Capture Down + /// self-alert had no signal to read. + /// + private sealed class DarlingXeSessionMissingException : Exception + { + public DarlingXeSessionMissingException(string message, Exception inner) : base(message, inner) { } + } + + private static async Task RunXeTolerantAsync( + ICollectorDefinition definition, DarlingCollectorRunner runner, ServerRuntime server, CancellationToken cancellationToken) + { + try + { + return await runner.RunAsync(definition, server, cancellationToken); + } + catch (SqlException ex) when (ex.Number == 297 || ex.Number == 15151 || ex.Message.Contains("XE session")) + { + /* XE session not found or not accessible. Lite swallows this to zero rows, but a headless + service (like the Dashboard) must surface it: raise it as a distinct SESSION_MISSING + collection_log status so the Stage 4 Capture Down self-alert can detect that blocking/ + deadlock capture is non-functional. RunOneAsync catches this and continues the sweep, so + collection is still tolerant — only the logged status differs from a real zero-row success. */ + throw new DarlingXeSessionMissingException(ex.Message, ex); + } + } + + private static async Task RunTraceFlagsTolerantAsync( + DarlingCollectorRunner runner, ServerRuntime server, CancellationToken cancellationToken) + { + try + { + return await runner.RunAsync(TraceFlagsCollector.Instance, server, cancellationToken); + } + catch (SqlException) + { + /* DBCC may be denied — degrade to zero rows, mirrors Lite's warning path. */ + return new CollectorRunResult(0, 0, 0); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingXeSessions.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingXeSessions.cs new file mode 100644 index 000000000..df748caec --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingXeSessions.cs @@ -0,0 +1,715 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; +using PerformanceMonitor.Collectors; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// Creates and starts the app-managed XE ring-buffer sessions the deadlock and blocked-process +/// collectors read — ported from Lite's Ensure* lifecycle (session names come from the shared +/// definitions so the reader and this lifecycle can never disagree). Server-scoped sessions on +/// on-prem/MI/RDS; on Azure SQL DB a database-scoped session in EVERY monitored database (#1535 +/// — a single session only ever captured the connection's own database), matching the +/// collectors' per-database ring-buffer reads. The blocked-process threshold sp_configure +/// bootstrap self-guards for platforms without sp_configure (AWS RDS). Ensure failures are +/// logged and tolerated — the collectors read zero rows until the session exists (#1251 benign +/// "already present" errors are success once the session passes the reader-visibility check), +/// and the SESSION_MISSING classification + Capture Down self-alert own the capture-is-down +/// story. Runs once per connect: a database created mid-run gets its sessions at the next +/// reconnect (its reads tolerate the missing session as zero rows until then). +/// +public static class DarlingXeSessions +{ + public static async Task EnsureAllAsync(ServerRuntime server, DarlingCollectorRunner runner, ILogger? logger, CancellationToken cancellationToken) + { + if (server.Target.IsAzureSqlDb) + { + await EnsureDatabaseScopedAsync(server, runner, logger, cancellationToken); + return; + } + + try + { + using var connection = new SqlConnection(server.ConnectionString); + await connection.OpenAsync(cancellationToken); + + /* Each capture ensures under its own filter — benign AND hard: a benign "already + exists" (or any hard failure) from the deadlock ensure used to abort the whole + batch, silently skipping the blocked-process ensure until the next reconnect. */ + try + { + await EnsureDeadlockOnPremAsync(connection, server, logger, cancellationToken); + } + catch (SqlException ex) when (IsBenignXeSessionAlreadyPresent(ex)) + { + logger?.LogInformation("[{Server}] Deadlock XE session already present (benign, #1251)", server.Config.DisplayName); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogError("[{Server}] Failed to ensure deadlock XE session: {Message} — deadlock collection will read zero rows until resolved", + server.Config.DisplayName, ex.Message); + } + + try + { + await EnsureBlockedProcessOnPremAsync(connection, server, logger, cancellationToken); + } + catch (SqlException ex) when (IsBenignXeSessionAlreadyPresent(ex)) + { + logger?.LogInformation("[{Server}] Blocked process XE session already present (benign, #1251)", server.Config.DisplayName); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogError("[{Server}] Failed to ensure blocked process XE session: {Message} — blocked-process collection will read zero rows until resolved", + server.Config.DisplayName, ex.Message); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogError("[{Server}] Failed to ensure XE sessions: {Message} — deadlock/blocked-process collection will read zero rows until resolved", + server.Config.DisplayName, ex.Message); + } + } + + /// + /// Azure SQL DB: one database-scoped session pair per monitored database (#1535), using the + /// runner's shared database list (master enumeration + fallback + ExcludedDatabases) and + /// per-database connections. Master is skipped — database-scoped sessions can't be created in + /// logical master. Failure isolation is per database AND per capture: one broken database (or + /// one broken session) never blocks the rest. + /// + private static async Task EnsureDatabaseScopedAsync(ServerRuntime server, DarlingCollectorRunner runner, ILogger? logger, CancellationToken cancellationToken) + { + List databases; + try + { + databases = await runner.GetAzureDatabaseListAsync(server, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogError("[{Server}] Failed to enumerate databases for XE sessions: {Message} — deadlock/blocked-process collection will read zero rows until resolved", + server.Config.DisplayName, ex.Message); + return; + } + + foreach (var databaseName in databases) + { + cancellationToken.ThrowIfCancellationRequested(); + + /* Logical master can't host database-scoped event sessions. */ + if (string.Equals(databaseName, "master", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + try + { + using var connection = await runner.OpenAzureDatabaseConnectionAsync(server, databaseName, cancellationToken); + + await EnsureOneDatabaseScopedAsync( + connection, server, databaseName, "deadlock", DeadlocksCollector.XeSessionName, + c => EnsureDeadlockAzureAsync(c, server, logger, cancellationToken), logger, cancellationToken); + + await EnsureOneDatabaseScopedAsync( + connection, server, databaseName, "blocked process", BlockedProcessReportCollector.XeSessionName, + c => EnsureBlockedProcessAzureAsync(c, server, logger, cancellationToken), logger, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogWarning("[{Server}] [{Database}] Failed to open a connection for XE session ensure: {Message}", + server.Config.DisplayName, databaseName, ex.Message); + } + } + } + + /// + /// One capture's database-scoped ensure, with the #1251 benign path grown a read-back check + /// (#1535, verbatim semantics from Lite): a benign "already exists"/"already started" proves + /// the session is there, but NOT that this principal can see it — the ring-buffer reader joins + /// sys.dm_xe_database_sessions, and a session invisible there (created by another + /// principal, or present-but-stopped) reads zero rows forever while collection reports + /// SUCCESS. So after a benign error the session is probed in the reader's own DMV, and an + /// invisible one is dropped and recreated under this principal. Hard failures are warn-logged + /// by the caller's per-capture catch here. + /// + private static async Task EnsureOneDatabaseScopedAsync( + SqlConnection connection, + ServerRuntime server, + string databaseName, + string captureName, + string sessionName, + Func ensureAsync, + ILogger? logger, + CancellationToken cancellationToken) + { + try + { + try + { + await ensureAsync(connection); + } + catch (SqlException ex) when (IsBenignXeSessionAlreadyPresent(ex)) + { + if (!await IsDatabaseScopedSessionVisibleAsync(connection, sessionName, cancellationToken)) + { + using (var dropCmd = new SqlCommand($"DROP EVENT SESSION [{sessionName}] ON DATABASE;", connection)) + { + dropCmd.CommandTimeout = 60; + await dropCmd.ExecuteNonQueryAsync(cancellationToken); + } + + await ensureAsync(connection); + + /* Read back once: recreated under THIS principal and still invisible means the + reader cannot see this database's capture at all — say so at Error rather than + announcing a recreate that didn't help. Ensure runs once per connect, so there + is no per-cycle churn to bound here (Lite's per-cycle driver keeps a give-up + set for the same case). */ + if (await IsDatabaseScopedSessionVisibleAsync(connection, sessionName, cancellationToken)) + { + logger?.LogInformation("[{Server}] [{Database}] {Capture} XE session existed but was not visible to the ring-buffer reader — dropped and recreated (#1535)", + server.Config.DisplayName, databaseName, captureName); + } + else + { + logger?.LogError("[{Server}] [{Database}] {Capture} XE session is still not visible in sys.dm_xe_database_sessions after recreating it — the ring-buffer reader cannot see this database's capture", + server.Config.DisplayName, databaseName, captureName); + } + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogWarning("[{Server}] [{Database}] Failed to ensure {Capture} XE session: {Message}", + server.Config.DisplayName, databaseName, captureName, ex.Message); + } + } + + /// + /// Whether the database-scoped session is visible to THIS principal in + /// sys.dm_xe_database_sessions — the reader's-eye view (false: the reader would see zero + /// rows regardless of captured events). + /// + private static async Task IsDatabaseScopedSessionVisibleAsync(SqlConnection connection, string sessionName, CancellationToken cancellationToken) + { + using var cmd = new SqlCommand(@" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT /* PerformanceMonitorDarling */ + is_visible = + CASE + WHEN EXISTS + ( + SELECT + 1/0 + FROM sys.dm_xe_database_sessions AS xes + WHERE xes.name = @session_name + ) + THEN 1 + ELSE 0 + END;", connection); + cmd.CommandTimeout = 60; + cmd.Parameters.Add(new SqlParameter("@session_name", SqlDbType.NVarChar, 128) { Value = sessionName }); + var result = await cmd.ExecuteScalarAsync(cancellationToken); + return result is int isVisible && isVisible == 1; + } + + private static async Task EnsureDeadlockOnPremAsync(SqlConnection connection, ServerRuntime server, ILogger? logger, CancellationToken cancellationToken) + { + using (var cmd = new SqlCommand(@" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT /* PerformanceMonitorDarling */ + is_running = CASE WHEN dxs.name IS NOT NULL THEN 1 ELSE 0 END +FROM sys.server_event_sessions AS ses +LEFT JOIN sys.dm_xe_sessions AS dxs + ON dxs.name = ses.name +WHERE ses.name = @session_name;", connection)) + { + cmd.CommandTimeout = 60; + cmd.Parameters.Add(new SqlParameter("@session_name", SqlDbType.NVarChar, 128) { Value = DeadlocksCollector.XeSessionName }); + var result = await cmd.ExecuteScalarAsync(cancellationToken); + + if (result != null) + { + if (result is int isRunning && isRunning == 0) + { + using var startCmd = new SqlCommand( + $"ALTER EVENT SESSION [{DeadlocksCollector.XeSessionName}] ON SERVER STATE = START;", connection); + startCmd.CommandTimeout = 60; + await startCmd.ExecuteNonQueryAsync(cancellationToken); + logger?.LogInformation("[{Server}] Started deadlock XE session", server.Config.DisplayName); + } + return; + } + } + + /* MEMORY_PARTITION_MODE = NONE for AWS RDS compatibility (mirrors Lite). */ + using var createCmd = new SqlCommand($@" +CREATE EVENT SESSION [{DeadlocksCollector.XeSessionName}] +ON SERVER +ADD EVENT sqlserver.xml_deadlock_report +ADD TARGET package0.ring_buffer +( + SET max_memory = 4096 +) +WITH +( + MAX_DISPATCH_LATENCY = 5 SECONDS, + EVENT_RETENTION_MODE = ALLOW_SINGLE_EVENT_LOSS, + MEMORY_PARTITION_MODE = NONE, + STARTUP_STATE = ON +); + +ALTER EVENT SESSION [{DeadlocksCollector.XeSessionName}] ON SERVER STATE = START;", connection); + createCmd.CommandTimeout = 60; + await createCmd.ExecuteNonQueryAsync(cancellationToken); + logger?.LogInformation("[{Server}] Created and started deadlock XE session", server.Config.DisplayName); + } + + private static async Task EnsureDeadlockAzureAsync(SqlConnection connection, ServerRuntime server, ILogger? logger, CancellationToken cancellationToken) + { + using (var cmd = new SqlCommand(@" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT /* PerformanceMonitorDarling */ + has_correct_event = CASE + WHEN EXISTS + ( + SELECT 1/0 + FROM sys.database_event_session_events AS dese + JOIN sys.database_event_sessions AS des + ON des.event_session_id = dese.event_session_id + WHERE des.name = @session_name + AND dese.name = N'database_xml_deadlock_report' + ) + THEN 1 + WHEN EXISTS + ( + SELECT 1/0 + FROM sys.database_event_sessions AS des + WHERE des.name = @session_name + ) + THEN 0 + ELSE NULL + END;", connection)) + { + cmd.CommandTimeout = 60; + cmd.Parameters.Add(new SqlParameter("@session_name", SqlDbType.NVarChar, 128) { Value = DeadlocksCollector.XeSessionName }); + var result = await cmd.ExecuteScalarAsync(cancellationToken); + + if (result is int hasCorrectEvent) + { + if (hasCorrectEvent == 0) + { + /* Wrong event — drop and recreate (mirrors Lite). */ + try + { + using var dropCmd = new SqlCommand( + $"DROP EVENT SESSION [{DeadlocksCollector.XeSessionName}] ON DATABASE;", connection); + dropCmd.CommandTimeout = 60; + await dropCmd.ExecuteNonQueryAsync(cancellationToken); + } + catch (SqlException ex) + { + logger?.LogError("[{Server}] Failed to drop old deadlock XE session: {Message}", server.Config.DisplayName, ex.Message); + } + } + else + { + using var startCmd = new SqlCommand($@" +IF NOT EXISTS +( + SELECT + 1/0 + FROM sys.dm_xe_database_sessions AS xes + WHERE xes.name = N'{DeadlocksCollector.XeSessionName}' +) +BEGIN + ALTER EVENT SESSION [{DeadlocksCollector.XeSessionName}] ON DATABASE STATE = START; +END;", connection); + startCmd.CommandTimeout = 60; + await startCmd.ExecuteNonQueryAsync(cancellationToken); + return; + } + } + } + + using (var cmd = new SqlCommand($@" +CREATE EVENT SESSION [{DeadlocksCollector.XeSessionName}] +ON DATABASE +ADD EVENT sqlserver.database_xml_deadlock_report +ADD TARGET package0.ring_buffer +( + SET max_memory = 4096 +) +WITH +( + MAX_DISPATCH_LATENCY = 5 SECONDS, + EVENT_RETENTION_MODE = ALLOW_SINGLE_EVENT_LOSS, + STARTUP_STATE = ON +); + +ALTER EVENT SESSION [{DeadlocksCollector.XeSessionName}] ON DATABASE STATE = START;", connection)) + { + cmd.CommandTimeout = 60; + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + + logger?.LogInformation("[{Server}] Created and started deadlock XE session (database-scoped)", server.Config.DisplayName); + } + + private static async Task EnsureBlockedProcessOnPremAsync(SqlConnection connection, ServerRuntime server, ILogger? logger, CancellationToken cancellationToken) + { + /* Blocked process threshold bootstrap — sp_configure is unavailable on AWS RDS + (parameter groups there), hence the tolerant catch (mirrors Lite). */ + try + { + using var thresholdCmd = new SqlCommand(@" +DECLARE + @threshold integer; + +SELECT + @threshold = CONVERT(integer, c.value_in_use) +FROM sys.configurations AS c +WHERE c.name = N'blocked process threshold (s)'; + +IF @threshold = 0 +BEGIN + EXECUTE sys.sp_configure + N'show advanced options', + 1; + + RECONFIGURE; + + EXECUTE sys.sp_configure + N'blocked process threshold (s)', + 5; + + RECONFIGURE; +END; + +SELECT @threshold;", connection); + thresholdCmd.CommandTimeout = 60; + var result = await thresholdCmd.ExecuteScalarAsync(cancellationToken); + if ((result as int? ?? 0) == 0) + { + logger?.LogInformation("[{Server}] Configured blocked process threshold to 5 seconds", server.Config.DisplayName); + } + } + catch (SqlException ex) + { + logger?.LogInformation("[{Server}] Cannot set blocked process threshold via sp_configure (may require platform config): {Message}", + server.Config.DisplayName, ex.Message); + } + + using (var cmd = new SqlCommand(@" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT /* PerformanceMonitorDarling */ + is_running = CASE WHEN dxs.name IS NOT NULL THEN 1 ELSE 0 END +FROM sys.server_event_sessions AS ses +LEFT JOIN sys.dm_xe_sessions AS dxs + ON dxs.name = ses.name +WHERE ses.name = @session_name;", connection)) + { + cmd.CommandTimeout = 60; + cmd.Parameters.Add(new SqlParameter("@session_name", SqlDbType.NVarChar, 128) { Value = BlockedProcessReportCollector.XeSessionName }); + var result = await cmd.ExecuteScalarAsync(cancellationToken); + + if (result != null) + { + if (result is int isRunning && isRunning == 0) + { + using var startCmd = new SqlCommand( + $"ALTER EVENT SESSION [{BlockedProcessReportCollector.XeSessionName}] ON SERVER STATE = START;", connection); + startCmd.CommandTimeout = 60; + await startCmd.ExecuteNonQueryAsync(cancellationToken); + logger?.LogInformation("[{Server}] Started blocked process XE session", server.Config.DisplayName); + } + return; + } + } + + using var createCmd = new SqlCommand($@" +CREATE EVENT SESSION [{BlockedProcessReportCollector.XeSessionName}] +ON SERVER +ADD EVENT sqlserver.blocked_process_report +ADD TARGET package0.ring_buffer +( + SET max_memory = 4096 +) +WITH +( + MAX_DISPATCH_LATENCY = 5 SECONDS, + STARTUP_STATE = ON +); + +ALTER EVENT SESSION [{BlockedProcessReportCollector.XeSessionName}] ON SERVER STATE = START;", connection); + createCmd.CommandTimeout = 60; + await createCmd.ExecuteNonQueryAsync(cancellationToken); + logger?.LogInformation("[{Server}] Created and started blocked process XE session", server.Config.DisplayName); + } + + private static async Task EnsureBlockedProcessAzureAsync(SqlConnection connection, ServerRuntime server, ILogger? logger, CancellationToken cancellationToken) + { + using (var cmd = new SqlCommand(@" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT /* PerformanceMonitorDarling */ + session_state = des.name +FROM sys.database_event_sessions AS des +WHERE des.name = @session_name;", connection)) + { + cmd.CommandTimeout = 60; + cmd.Parameters.Add(new SqlParameter("@session_name", SqlDbType.NVarChar, 128) { Value = BlockedProcessReportCollector.XeSessionName }); + var result = await cmd.ExecuteScalarAsync(cancellationToken); + + if (result != null) + { + using var startCmd = new SqlCommand($@" +IF NOT EXISTS +( + SELECT + 1/0 + FROM sys.dm_xe_database_sessions AS xes + WHERE xes.name = N'{BlockedProcessReportCollector.XeSessionName}' +) +BEGIN + ALTER EVENT SESSION [{BlockedProcessReportCollector.XeSessionName}] ON DATABASE STATE = START; +END;", connection); + startCmd.CommandTimeout = 60; + await startCmd.ExecuteNonQueryAsync(cancellationToken); + return; + } + } + + using (var cmd = new SqlCommand($@" +CREATE EVENT SESSION [{BlockedProcessReportCollector.XeSessionName}] +ON DATABASE +ADD EVENT sqlserver.blocked_process_report +ADD TARGET package0.ring_buffer +( + SET max_memory = 4096 +) +WITH +( + MAX_DISPATCH_LATENCY = 5 SECONDS, + STARTUP_STATE = ON +); + +ALTER EVENT SESSION [{BlockedProcessReportCollector.XeSessionName}] ON DATABASE STATE = START;", connection)) + { + cmd.CommandTimeout = 60; + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + + logger?.LogInformation("[{Server}] Created and started blocked process XE session (database-scoped)", server.Config.DisplayName); + } + + /// + /// Reconciles the OPT-IN long-query completion XE session (#1496) to the collector's enabled flag — + /// Erik's dedicated switch, default OFF. ENABLED: create/start the session (server-scoped on + /// on-prem/MI/RDS; database-scoped in every monitored database on Azure SQL DB, #1535). DISABLED: + /// DROP the session on the monitored server(s) — the server-side session is the actual busy-server + /// cost, so merely skipping collection is not enough. Unlike (which is + /// unconditional create-only, run once per connect), this is called from the per-server sweep and + /// its lifecycle FOLLOWS the flag both ways; the DarlingWorker state-tracks the last-applied value so + /// this only opens a connection when the desired state actually changes. Failures propagate to the + /// caller (the worker logs + leaves the applied state unchanged so the next sweep retries). + /// + public static async Task ReconcileLongQueryCompletionsAsync(ServerRuntime server, DarlingCollectorRunner runner, bool enabled, ILogger? logger, CancellationToken cancellationToken) + { + if (server.Target.IsAzureSqlDb) + { + await ReconcileLongQueryCompletionsAzureAsync(server, runner, enabled, logger, cancellationToken); + return; + } + + using var connection = new SqlConnection(server.ConnectionString); + await connection.OpenAsync(cancellationToken); + + if (enabled) + { + await EnsureLongQueryCompletionsOnPremAsync(connection, server, logger, cancellationToken); + } + else + { + await DropLongQueryCompletionsAsync(connection, databaseScoped: false, cancellationToken); + logger?.LogInformation("[{Server}] Long-query completion XE session reconciled OFF (collector disabled)", server.Config.DisplayName); + } + } + + private static async Task EnsureLongQueryCompletionsOnPremAsync(SqlConnection connection, ServerRuntime server, ILogger? logger, CancellationToken cancellationToken) + { + using (var cmd = new SqlCommand(@" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT /* PerformanceMonitorDarling */ + is_running = CASE WHEN dxs.name IS NOT NULL THEN 1 ELSE 0 END +FROM sys.server_event_sessions AS ses +LEFT JOIN sys.dm_xe_sessions AS dxs + ON dxs.name = ses.name +WHERE ses.name = @session_name;", connection)) + { + cmd.CommandTimeout = 60; + cmd.Parameters.Add(new SqlParameter("@session_name", SqlDbType.NVarChar, 128) { Value = LongQueryCompletionsCollector.XeSessionName }); + var result = await cmd.ExecuteScalarAsync(cancellationToken); + + if (result != null) + { + if (result is int isRunning && isRunning == 0) + { + using var startCmd = new SqlCommand(LongQueryCompletionsCollector.BuildStartSessionSql(databaseScoped: false), connection); + startCmd.CommandTimeout = 60; + await startCmd.ExecuteNonQueryAsync(cancellationToken); + logger?.LogInformation("[{Server}] Started long-query completion XE session", server.Config.DisplayName); + } + return; + } + } + + using var createCmd = new SqlCommand( + LongQueryCompletionsCollector.BuildCreateSessionSql(databaseScoped: false, LongQueryCompletionsCollector.DefaultDurationThresholdMicroseconds) + + "\n\n" + LongQueryCompletionsCollector.BuildStartSessionSql(databaseScoped: false), connection); + createCmd.CommandTimeout = 60; + await createCmd.ExecuteNonQueryAsync(cancellationToken); + logger?.LogInformation("[{Server}] Created and started long-query completion XE session", server.Config.DisplayName); + } + + private static async Task ReconcileLongQueryCompletionsAzureAsync(ServerRuntime server, DarlingCollectorRunner runner, bool enabled, ILogger? logger, CancellationToken cancellationToken) + { + List databases; + try + { + databases = await runner.GetAzureDatabaseListAsync(server, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogWarning("[{Server}] Failed to enumerate databases for the long-query completion XE session: {Message}", server.Config.DisplayName, ex.Message); + return; + } + + foreach (var databaseName in databases) + { + cancellationToken.ThrowIfCancellationRequested(); + + /* Logical master can't host database-scoped event sessions. */ + if (string.Equals(databaseName, "master", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + try + { + using var connection = await runner.OpenAzureDatabaseConnectionAsync(server, databaseName, cancellationToken); + + if (enabled) + { + try + { + await EnsureLongQueryCompletionsAzureAsync(connection, server, databaseName, logger, cancellationToken); + } + catch (SqlException ex) when (IsBenignXeSessionAlreadyPresent(ex)) + { + /* Already present per the engine — the reader tolerates it (#1251). */ + } + } + else + { + await DropLongQueryCompletionsAsync(connection, databaseScoped: true, cancellationToken); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogWarning("[{Server}] [{Database}] Failed to reconcile the long-query completion XE session: {Message}", + server.Config.DisplayName, databaseName, ex.Message); + } + } + } + + private static async Task EnsureLongQueryCompletionsAzureAsync(SqlConnection connection, ServerRuntime server, string databaseName, ILogger? logger, CancellationToken cancellationToken) + { + using (var cmd = new SqlCommand(@" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT /* PerformanceMonitorDarling */ + session_state = des.name +FROM sys.database_event_sessions AS des +WHERE des.name = @session_name;", connection)) + { + cmd.CommandTimeout = 60; + cmd.Parameters.Add(new SqlParameter("@session_name", SqlDbType.NVarChar, 128) { Value = LongQueryCompletionsCollector.XeSessionName }); + var result = await cmd.ExecuteScalarAsync(cancellationToken); + + if (result != null) + { + using var startCmd = new SqlCommand($@" +IF NOT EXISTS +( + SELECT + 1/0 + FROM sys.dm_xe_database_sessions AS xes + WHERE xes.name = N'{LongQueryCompletionsCollector.XeSessionName}' +) +BEGIN + ALTER EVENT SESSION [{LongQueryCompletionsCollector.XeSessionName}] ON DATABASE STATE = START; +END;", connection); + startCmd.CommandTimeout = 60; + await startCmd.ExecuteNonQueryAsync(cancellationToken); + return; + } + } + + using var createCmd = new SqlCommand( + LongQueryCompletionsCollector.BuildCreateSessionSql(databaseScoped: true, LongQueryCompletionsCollector.DefaultDurationThresholdMicroseconds) + + "\n\n" + LongQueryCompletionsCollector.BuildStartSessionSql(databaseScoped: true), connection); + createCmd.CommandTimeout = 60; + await createCmd.ExecuteNonQueryAsync(cancellationToken); + logger?.LogInformation("[{Server}] [{Database}] Created and started long-query completion XE session (database-scoped)", server.Config.DisplayName, databaseName); + } + + private static async Task DropLongQueryCompletionsAsync(SqlConnection connection, bool databaseScoped, CancellationToken cancellationToken) + { + using var cmd = new SqlCommand(LongQueryCompletionsCollector.BuildDropSessionSql(databaseScoped), connection); + cmd.CommandTimeout = 60; + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + + /// + /// True when every error is a benign "the session is already there" extended-events error: + /// 25631 (already exists) or 25705 (already started) — on Azure SQL DB the XE existence + /// catalogs are visibility-scoped per principal, so CREATE/START can report these even when + /// the pre-check came back empty; they confirm the session is up (#1251, verbatim from Lite). + /// + internal static bool IsBenignXeSessionAlreadyPresent(SqlException ex) + { + if (ex.Errors.Count == 0) + { + return false; + } + + foreach (SqlError error in ex.Errors) + { + if (error.Number != 25631 && error.Number != 25705) + { + return false; + } + } + + return true; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Hosting/DarlingHostBinding.cs b/Darling/PerformanceMonitor.Darling.Service/Hosting/DarlingHostBinding.cs new file mode 100644 index 000000000..76304adf2 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Hosting/DarlingHostBinding.cs @@ -0,0 +1,252 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Net; +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace PerformanceMonitor.Darling.Service.Hosting; + +/// +/// The surface-agnostic bind/auth helpers shared by the two optional Kestrel hosts — the MCP host +/// () and the web host (). +/// Extracted so the LAN-exposure decision ladder, the in-app CIDR check, the loopback-listener collision +/// guard, the constant-time token compare, and the best-effort firewall reconcile live ONCE, with ONE test +/// suite, instead of being copy-pasted per surface (darling-network-endpoints anti-drift). The MCP host is +/// refactored onto this class (its behavior byte-for-byte unchanged — its pre-existing tests stay green +/// untouched, the refactor proof); the web host is built on it from the start. +/// +/// Everything here is PURE (no logger, no server) except , which is +/// effectful but parameterized on the rule name + logger so both surfaces reconcile their own scoped rule. +/// The (mode, reason) split lets the caller map a reason to a log severity (Round-4 #7) while the pure ladder +/// stays free of side effects. +/// +internal static class DarlingHostBinding +{ + /// The effective bind. is the secure default; + /// binds the LAN interface behind the surface's auth gate. + internal enum BindMode + { + LoopbackOnly, + NetworkAndLoopback, + } + + /// WHY the bind resolved as it did — the caller maps this to a severity (Round-4 #7). + /// Member order is pinned equal to the MCP host's nested McpBindReason (a test enforces it) so the + /// MCP forwarders can cast between them. + internal enum BindReason + { + /// No network block, or a loopback/absent listen — the byte-for-byte-today loopback server. + LoopbackByDefault, + + /// All preconditions met: non-loopback listen + managed + token present + valid allowFrom CIDR. + NetworkExposed, + + /// network.* is set but postgres.managed = false — network exposure is managed-mode only (a warning). + ManagedModeRequired, + + /// Exposed + managed but the listen value is not a parseable IP — fail-closed to loopback. + ListenInvalid, + + /// Exposed + managed but no bearer token — fail-closed to loopback. + TokenMissing, + + /// Exposed + managed + token but allowFrom is missing/not a valid CIDR or its family does not match the listen — fail-closed to loopback. + AllowFromInvalid, + } + + /// The (mode, reason) pair returned by . + internal readonly record struct BindDecision(BindMode Mode, BindReason Reason); + + /// + /// PURE resolution of an effective host bind from the surface-agnostic inputs. Returns + /// ONLY when is a genuine network + /// (non-loopback) address (via — so 127.0.0.1 + /// stays loopback, never a network bind/collision) that ALSO parses as an IP, AND + /// is true, AND a bearer token is present ( — presence only; the host + /// decrypts later), AND is a valid CIDR of the SAME address family as the + /// listen. Otherwise loopback-only with the specific reason. Never throws; never consults a logger. + /// is "the surface's network block has any field set" — used only for + /// the BYO "network.* is ignored" notice (the network path never runs in BYO). + /// + internal static BindDecision ResolveBind( + string? listen, string? allowFrom, bool tokenPresent, bool networkConfigured, bool managed) + { + var exposed = DarlingNetwork.IsExposedListenAddress(listen); + + if (!exposed) + { + /* Not exposed = the secure default. The one exception worth a word: a BYO store with a network + block set at all (even a loopback/partial one) is ignored -> the managed-mode notice. */ + if (!managed && networkConfigured) + { + return new BindDecision(BindMode.LoopbackOnly, BindReason.ManagedModeRequired); + } + + return new BindDecision(BindMode.LoopbackOnly, BindReason.LoopbackByDefault); + } + + /* Exposed. Managed-mode only: BYO never binds the network path, and this dominates a missing/invalid + listen/token/allowFrom so the operator sees the actionable "managed only" notice first. */ + if (!managed) + { + return new BindDecision(BindMode.LoopbackOnly, BindReason.ManagedModeRequired); + } + + /* The listen must be a parseable IP. IsExposedListenAddress treats a non-IP value (localhost, a + hostname, "*") as "exposed" so it is never silently bound; here it degrades to loopback rather than + throwing when the host later does IPAddress.Parse. exposed => listen is non-empty. */ + if (!IPAddress.TryParse(listen!.Trim(), out var listenIp)) + { + return new BindDecision(BindMode.LoopbackOnly, BindReason.ListenInvalid); + } + + /* Token presence only (no decryption here — that is an effectful, Windows-only step the host does). */ + if (!tokenPresent) + { + return new BindDecision(BindMode.LoopbackOnly, BindReason.TokenMissing); + } + + /* allowFrom must be a valid CIDR (host bits zeroed) whose address family matches the listen: a + mismatched family would bind one family while the in-app CIDR check rejects the other, 403-ing every + network client — fail-closed but silently non-functional, so degrade with a clear reason instead. */ + if (string.IsNullOrWhiteSpace(allowFrom) || !IPNetwork.TryParse(allowFrom.Trim(), out var cidr)) + { + return new BindDecision(BindMode.LoopbackOnly, BindReason.AllowFromInvalid); + } + + if (cidr.BaseAddress.AddressFamily != listenIp.AddressFamily) + { + return new BindDecision(BindMode.LoopbackOnly, BindReason.AllowFromInvalid); + } + + return new BindDecision(BindMode.NetworkAndLoopback, BindReason.NetworkExposed); + } + + /// + /// PURE severity map for a reason (Round-4 #7): the fail-closed degrades + /// (// + /// ) are , the BYO "ignored" + /// notice () is , and the + /// non-degrade reasons (/) + /// are silent (null). + /// + internal static LogLevel? MapBindReasonSeverity(BindReason reason) => reason switch + { + BindReason.ListenInvalid => LogLevel.Critical, + BindReason.TokenMissing => LogLevel.Critical, + BindReason.AllowFromInvalid => LogLevel.Critical, + BindReason.ManagedModeRequired => LogLevel.Warning, + _ => null, + }; + + /// + /// Whether to ALSO bind the two loopback families beside the network listener. Skipped when the listen + /// value is itself a loopback address (already covered) or a wildcard (0.0.0.0 covers IPv4 loopback, + /// :: the IPv6) — binding an explicit loopback on the same port then would collide (WSAEADDRINUSE). + /// For a specific LAN IP the loopback binds are added so a local client resolving "localhost" still reaches + /// the server (which, in network mode, now also requires the surface's auth gate). + /// + internal static bool ShouldAddLoopbackListeners(IPAddress listenIp) + => !(IPAddress.IsLoopback(listenIp) + || listenIp.Equals(IPAddress.Any) + || listenIp.Equals(IPAddress.IPv6Any)); + + /// + /// PURE in-app CIDR check: is allowed? Loopback (127.0.0.0/8 or + /// ::1, incl. an IPv4-mapped-IPv6 form) is ALWAYS allowed — it is not in + /// , so otherwise the loopback bind's local clients would be rejected. + /// Everything else must fall inside the CIDR. A null remote (unverifiable origin) fails closed. + /// + internal static bool IsRemoteAddressAllowed(IPAddress? remoteIp, IPNetwork allowedCidr) + { + if (remoteIp is null) + { + return false; + } + + var ip = remoteIp.IsIPv4MappedToIPv6 ? remoteIp.MapToIPv4() : remoteIp; + return IPAddress.IsLoopback(ip) || allowedCidr.Contains(ip); + } + + /// + /// PURE constant-time token comparison. Both tokens are hashed to a fixed 32 bytes first, so the compare + /// is constant-time regardless of length and leaks neither token nor its length. An empty + /// never authorizes (the caller should also guard). Used by the MCP host's + /// Bearer-header check and the web host's ?token= check alike. + /// + internal static bool FixedTimeTokenEquals(string? presented, string? expected) + { + if (string.IsNullOrEmpty(expected) || string.IsNullOrEmpty(presented)) + { + return false; + } + + var expectedHash = SHA256.HashData(Encoding.UTF8.GetBytes(expected)); + var presentedHash = SHA256.HashData(Encoding.UTF8.GetBytes(presented)); + return CryptographicOperations.FixedTimeEquals(expectedHash, presentedHash); + } + + /// + /// Best-effort firewall reconcile (defense-in-depth): add/remove the scoped, idempotent-named inbound rule + /// via PowerShell, reusing the store's tested pure command builders and its shared PowerShell runner (no + /// duplication, no timeout divergence). Parameterized on so each surface + /// reconciles its own port-specific rule. NEVER fatal — a failure (no elevation, PowerShell missing) logs + /// the exact scoped command for the operator to run by hand. The token + in-app CIDR are the boundary; the + /// firewall is defense-in-depth. + /// + [SupportedOSPlatform("windows")] + internal static async Task ReconcileFirewallAsync( + string ruleName, int port, bool enable, string? cidr, ILogger logger, CancellationToken cancellationToken) + { + var command = enable + ? DarlingManagedPostgres.BuildFirewallEnableCommand(ruleName, port, cidr!) + : DarlingManagedPostgres.BuildFirewallDisableCommand(ruleName); + + try + { + var (exitCode, output) = await DarlingManagedPostgres.RunPowerShellAsync(command, cancellationToken); + if (exitCode == 0) + { + logger.LogInformation("Firewall rule '{Rule}' {Verb}.", ruleName, enable ? "ensured" : "removed"); + } + else if (enable) + { + logger.LogWarning( + "Could not configure the firewall automatically (exit {ExitCode}: {Output}). Run this in an elevated PowerShell:\n{Command}", + exitCode, output, command); + } + else + { + logger.LogWarning("Could not remove the firewall rule automatically (exit {ExitCode}: {Output}).", exitCode, output); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + if (enable) + { + logger.LogWarning( + "Could not configure the firewall automatically ({Message}). Run this in an elevated PowerShell:\n{Command}", + ex.Message, command); + } + else + { + logger.LogWarning("Could not remove the firewall rule automatically ({Message}).", ex.Message); + } + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingAlertReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingAlertReader.cs new file mode 100644 index 000000000..c9ed51949 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingAlertReader.cs @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// Service-side reads for the alerts MCP tools () — the alert-history log +/// (config_alert_log) and the single global alert-settings row (config_alert_settings), both +/// STORED reads (no live monitored-server hit). Each SQL is reproduced from the viewer's proven read +/// (ViewerDataService.AlertHistory.cs / .AlertSettings.cs) rather than referenced — the MCP +/// host is in the Service assembly and cannot reference the WPF Viewer, the same reason +/// reproduces the viewer's config SQL. The reads live in public +/// constants so Darling.Tests can pin the dialect + columns without a live Postgres. +/// +/// The alert-history read has the viewer's two shapes: one scoped to a server, one across ALL servers +/// (the fleet default) — both exclude dismissed rows, newest first, windowed on the naive-UTC +/// alert_time. The settings read is the single id=1 desired-state row the service hot-swaps into its +/// running DarlingAlertSettings, so it reports the alert engine + analysis config the service is +/// actually using (matching the viewer's Settings-window prefill), or null when the store has not seeded it +/// yet. +/// +internal static class DarlingAlertReader +{ + /* ─────────────────────────── alert history ─────────────────────────── */ + + public sealed record AlertHistoryReadRow( + DateTime AlertTime, int ServerId, string ServerName, string MetricName, + double CurrentValue, double ThresholdValue, bool AlertSent, string NotificationType, + string? SendError, bool Muted, string? DetailText); + + private const string AlertHistorySelectColumns = @" + alert_time, + server_id, + server_name, + metric_name, + current_value, + threshold_value, + alert_sent, + notification_type, + send_error, + muted, + detail_text"; + + /// Per-server alert history — the viewer's AlertHistorySql. $1 window start, $2 server_id, + /// $3 limit (naive UTC / int / int). + public const string AlertHistorySql = @" +SELECT" + AlertHistorySelectColumns + @" +FROM config_alert_log +WHERE alert_time >= $1 +AND server_id = $2 +AND dismissed = FALSE +ORDER BY alert_time DESC +LIMIT $3"; + + /// All-servers alert history (the fleet default) — the viewer's AlertHistoryAllServersSql. + /// $1 window start, $2 limit (naive UTC / int). + public const string AlertHistoryAllServersSql = @" +SELECT" + AlertHistorySelectColumns + @" +FROM config_alert_log +WHERE alert_time >= $1 +AND dismissed = FALSE +ORDER BY alert_time DESC +LIMIT $2"; + + /// + /// Recent alerts newest first, excluding dismissed rows — the Alert History read. With no + /// it aggregates ALL servers (the fleet default); with one it scopes to that + /// server. Mirrors the viewer's optional-serverId GetAlertHistoryAsync. + /// + public static async Task> GetAlertHistoryAsync( + NpgsqlDataSource postgres, DateTime sinceUtc, int? serverId, int limit, CancellationToken cancellationToken = default) + { + var rows = new List(); + + await using var command = postgres.CreateCommand(serverId.HasValue ? AlertHistorySql : AlertHistoryAllServersSql); + DarlingMcpReadParameters.AddTimestamp(command, sinceUtc); + if (serverId.HasValue) + { + DarlingMcpReadParameters.AddInt(command, serverId.Value); + } + DarlingMcpReadParameters.AddInt(command, limit); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new AlertHistoryReadRow( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : reader.GetInt32(1), + reader.IsDBNull(2) ? "" : reader.GetString(2), + reader.IsDBNull(3) ? "" : reader.GetString(3), + reader.IsDBNull(4) ? 0 : reader.GetDouble(4), + reader.IsDBNull(5) ? 0 : reader.GetDouble(5), + !reader.IsDBNull(6) && reader.GetBoolean(6), + reader.IsDBNull(7) ? "" : reader.GetString(7), + reader.IsDBNull(8) ? null : reader.GetString(8), + !reader.IsDBNull(9) && reader.GetBoolean(9), + reader.IsDBNull(10) ? null : reader.GetString(10))); + } + + return rows; + } + + /* ─────────────────────────── alert settings ─────────────────────────── */ + + /// The single global alert-settings row the service hot-swaps into DarlingAlertSettings — + /// the alert engine toggles/thresholds, delivery mode, and scheduled-analysis cadence. Mirror of the + /// viewer's AlertSettingsRow (only the store-honored fields). + public sealed record AlertSettingsReadRow( + bool Enabled, bool CpuEnabled, int CpuThresholdPercent, string CpuMode, + bool BlockingEnabled, int BlockingCountThreshold, bool DeadlockEnabled, int DeadlockCountThreshold, + bool PoisonWaitEnabled, int PoisonWaitThresholdMs, bool LongRunningQueryEnabled, int LongRunningQueryThresholdMinutes, + bool TempDbSpaceEnabled, int TempDbSpaceThresholdPercent, bool LowDiskEnabled, int LowDiskThresholdPercent, int LowDiskThresholdGb, + bool LongRunningJobEnabled, int LongRunningJobMultiplier, bool FailedJobEnabled, int FailedJobLookbackMinutes, + int CooldownMinutes, IReadOnlyList ExcludedDatabases, bool AnalysisEnabled, int AnalysisIntervalMinutes, + bool AnalysisNotificationsEnabled, double AnalysisNotifySeverity, string DeliveryMode, int PerEventMax, + int LongRunningQueryMaxResults, bool LongRunningQueryExcludeSpServerDiagnostics, bool LongRunningQueryExcludeWaitFor, + bool LongRunningQueryExcludeBackups, bool LongRunningQueryExcludeMiscWaits, bool LongRunningQueryExcludeCdc, + bool NotifyConnectionChanges); + + /// The single global alert-settings row (id=1) — the viewer's AlertSettingsSelectSql. The + /// 36 columns are read in the SAME order the service reads them (StoreConfigProvider). + public const string AlertSettingsSelectSql = @" +SELECT enabled, cpu_enabled, cpu_threshold_percent, cpu_mode, blocking_enabled, blocking_count_threshold, + deadlock_enabled, deadlock_count_threshold, poison_wait_enabled, poison_wait_threshold_ms, + long_running_query_enabled, long_running_query_threshold_minutes, tempdb_space_enabled, + tempdb_space_threshold_percent, low_disk_enabled, low_disk_threshold_percent, low_disk_threshold_gb, + long_running_job_enabled, long_running_job_multiplier, failed_job_enabled, failed_job_lookback_minutes, + cooldown_minutes, excluded_databases, analysis_enabled, analysis_interval_minutes, + analysis_notifications_enabled, analysis_notify_severity, delivery_mode, per_event_max, + long_running_query_max_results, long_running_query_exclude_sp_server_diagnostics, + long_running_query_exclude_wait_for, long_running_query_exclude_backups, + long_running_query_exclude_misc_waits, long_running_query_exclude_cdc, notify_connection_changes +FROM config_alert_settings +WHERE id = 1"; + + /// Reads the single global alert-settings row, or null when the store has not seeded it yet + /// (a pre-control-plane store, or the service has not started). + public static async Task GetAlertSettingsAsync( + NpgsqlDataSource postgres, CancellationToken cancellationToken = default) + { + await using var command = postgres.CreateCommand(AlertSettingsSelectSql); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + { + return null; + } + + return new AlertSettingsReadRow( + reader.GetBoolean(0), reader.GetBoolean(1), reader.GetInt32(2), reader.GetString(3), + reader.GetBoolean(4), reader.GetInt32(5), reader.GetBoolean(6), reader.GetInt32(7), + reader.GetBoolean(8), reader.GetInt32(9), reader.GetBoolean(10), reader.GetInt32(11), + reader.GetBoolean(12), reader.GetInt32(13), reader.GetBoolean(14), reader.GetInt32(15), reader.GetInt32(16), + reader.GetBoolean(17), reader.GetInt32(18), reader.GetBoolean(19), reader.GetInt32(20), + reader.GetInt32(21), reader.IsDBNull(22) ? Array.Empty() : reader.GetFieldValue(22), + reader.GetBoolean(23), reader.GetInt32(24), reader.GetBoolean(25), reader.GetDouble(26), + reader.GetString(27), reader.GetInt32(28), reader.GetInt32(29), reader.GetBoolean(30), + reader.GetBoolean(31), reader.GetBoolean(32), reader.GetBoolean(33), reader.GetBoolean(34), + reader.GetBoolean(35)); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingBlockingReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingBlockingReader.cs new file mode 100644 index 000000000..8ec82f82d --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingBlockingReader.cs @@ -0,0 +1,323 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Alerting; + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// Service-side reads for the blocking / deadlock MCP tools () — +/// the SAME collected data the viewer's ViewerDataService.Blocking.cs / .Deadlock.cs +/// partials read, adapted here so the MCP host never references the WPF viewer project. Each read is a +/// STORED read (no live monitored-server hit), keyed by server_id and windowed on the naive-UTC +/// collection_time prefix. +/// +/// +/// The blocked-process read reproduces the viewer's / alert adapter's XE-preferred + DMV-fallback merge +/// via the shared , and the row types derive from the shared +/// / so the merge and the parsed +/// come for free and cannot drift from Lite. The reads hit +/// the BASE tables blocked_process_reports / deadlocks (like the viewer's grid reads and the +/// merged ) — the V7 best-effort plan columns are not reliably +/// projected through the frozen SELECT * views on an upgraded store — and v_dmv_blocking_snapshots +/// for the always-on DMV fallback (which carries no report XML). Every SQL string is a public const so +/// Darling.Tests can pin the dialect + columns without a live Postgres. +/// +/// +/// +/// The tool result shapes mirror LITE's McpBlockingTools (get_blocked_process_reports / +/// get_deadlocks / get_deadlock_detail / get_blocked_process_xml) field-for-field — the store-faithful +/// shape Darling's collector-mirror schema can serve, the precedent slices 1+2 set. The Dashboard's +/// get_blocking additionally carries a pre-computed blocking_tree / wait_time_sec / +/// activity from its SQL-Server-side wide store; those columns do NOT exist in the Postgres store, +/// so get_blocking here returns the store-native blocked/blocking pair rows (the same rows the viewer's +/// chain reconstructor consumes) under the Dashboard tool name + param contract. +/// +/// +internal static class DarlingBlockingReader +{ + /* ─────────────────────────── result rows ─────────────────────────── */ + + /// One blocked-process event — the shared alert-row fields (used by the XE→DMV merge and the + /// context builders) plus the fuller per-report columns Lite's get_blocked_process_reports surfaces. + /// DMV-fallback rows leave the XE-only columns at their defaults (the DMV snapshot has no report XML, + /// isolation levels, transaction/batch times, or priorities). + public sealed class BlockedProcessReadRow : BlockedProcessAlertRow + { + public int BlockedEcid { get; set; } + public int BlockingEcid { get; set; } + public string? WaitResource { get; set; } + public string? BlockedStatus { get; set; } + public string? BlockedIsolationLevel { get; set; } + public long BlockedLogUsed { get; set; } + public int BlockedTransactionCount { get; set; } + public string? BlockedClientApp { get; set; } + public string? BlockedHostName { get; set; } + public string? BlockedLoginName { get; set; } + public string? BlockingStatus { get; set; } + public string? BlockingIsolationLevel { get; set; } + public string? BlockingClientApp { get; set; } + public string? BlockingHostName { get; set; } + public string? BlockingLoginName { get; set; } + public string? BlockedTransactionName { get; set; } + public string? BlockingTransactionName { get; set; } + public DateTime? BlockedLastTranStarted { get; set; } + public DateTime? BlockingLastTranStarted { get; set; } + public DateTime? BlockedLastBatchStarted { get; set; } + public DateTime? BlockingLastBatchStarted { get; set; } + public DateTime? BlockedLastBatchCompleted { get; set; } + public DateTime? BlockingLastBatchCompleted { get; set; } + public int BlockedPriority { get; set; } + public int BlockingPriority { get; set; } + } + + /// One deadlock event — the shared alert-row fields (victim id/SQL, graph XML, the parsed + /// ) plus the two store timestamps the grid/tool surface. + public sealed class DeadlockReadRow : DeadlockAlertRow + { + public DateTime CollectionTime { get; set; } + public DateTime? DeadlockTime { get; set; } + } + + /* ─────────────────────────── blocked-process reports (XE + DMV fallback) ─────────────────────────── */ + + /// + /// The XE blocked-process-report read — the viewer's BlockedProcessReportsSql projection trimmed + /// to the columns Lite's get_blocked_process_reports surfaces. Reads the BASE table (the viewer reads + /// base here too, for the V7 plan-column safety). $1 server_id, $2/$3 window (naive UTC). + /// + public const string BlockedProcessReportsSql = """ + SELECT + event_time, + database_name, + blocked_spid, + blocked_ecid, + blocking_spid, + blocking_ecid, + wait_time_ms, + wait_resource, + lock_mode, + blocked_status, + blocked_isolation_level, + blocked_log_used, + blocked_transaction_count, + blocked_client_app, + blocked_host_name, + blocked_login_name, + blocked_sql_text, + blocking_status, + blocking_isolation_level, + blocking_client_app, + blocking_host_name, + blocking_login_name, + blocking_sql_text, + blocked_transaction_name, + blocking_transaction_name, + blocked_last_tran_started, + blocking_last_tran_started, + blocked_last_batch_started, + blocking_last_batch_started, + blocked_last_batch_completed, + blocking_last_batch_completed, + blocked_priority, + blocking_priority, + blocked_process_report_xml, + contentious_object + FROM blocked_process_reports + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + ORDER BY event_time DESC + LIMIT 200 + """; + + /// + /// The always-on DMV blocking-snapshot fallback read — the viewer's DmvBlockingSnapshotsSql + /// projection (the DMV snapshot carries no report XML, isolation levels, transaction/batch times, or + /// priorities). Same parameters as . + /// + public const string DmvBlockingSnapshotsSql = """ + SELECT + event_time, + database_name, + blocked_spid, + blocked_ecid, + blocking_spid, + blocking_ecid, + wait_time_ms, + lock_mode, + blocking_status, + contentious_object, + blocked_sql_text, + blocking_sql_text, + blocked_login_name, + blocked_host_name, + blocked_client_app, + blocking_login_name, + blocking_host_name, + blocking_client_app, + blocked_last_tran_started, + blocking_last_tran_started + FROM v_dmv_blocking_snapshots + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + ORDER BY event_time DESC + LIMIT 200 + """; + + /// + /// The recent blocked-process reports over the window — the XE rows plus the DMV-fallback rows for any + /// (blocked, blocker) SPID pair the XE session did not capture in the same minute, merged and re-capped + /// to the newest 200 via the shared (Lite's exact semantics). + /// + public static async Task> GetRecentBlockedProcessReportsAsync( + NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var items = new List(); + var dmvItems = new List(); + + await using var connection = await postgres.OpenConnectionAsync(cancellationToken); + + await using (var command = new NpgsqlCommand(BlockedProcessReportsSql, connection)) + { + DarlingMcpReadParameters.AddWindow(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new BlockedProcessReadRow + { + EventTime = reader.IsDBNull(0) ? null : reader.GetDateTime(0), + DatabaseName = reader.IsDBNull(1) ? "" : reader.GetString(1), + BlockedSpid = reader.IsDBNull(2) ? 0 : reader.GetInt32(2), + BlockedEcid = reader.IsDBNull(3) ? 0 : reader.GetInt32(3), + BlockingSpid = reader.IsDBNull(4) ? 0 : reader.GetInt32(4), + BlockingEcid = reader.IsDBNull(5) ? 0 : reader.GetInt32(5), + WaitTimeMs = reader.IsDBNull(6) ? 0 : reader.GetInt64(6), + WaitResource = reader.IsDBNull(7) ? null : reader.GetString(7), + LockMode = reader.IsDBNull(8) ? "" : reader.GetString(8), + BlockedStatus = reader.IsDBNull(9) ? null : reader.GetString(9), + BlockedIsolationLevel = reader.IsDBNull(10) ? null : reader.GetString(10), + BlockedLogUsed = reader.IsDBNull(11) ? 0 : reader.GetInt64(11), + BlockedTransactionCount = reader.IsDBNull(12) ? 0 : reader.GetInt32(12), + BlockedClientApp = reader.IsDBNull(13) ? null : reader.GetString(13), + BlockedHostName = reader.IsDBNull(14) ? null : reader.GetString(14), + BlockedLoginName = reader.IsDBNull(15) ? null : reader.GetString(15), + BlockedSqlText = reader.IsDBNull(16) ? "" : reader.GetString(16), + BlockingStatus = reader.IsDBNull(17) ? null : reader.GetString(17), + BlockingIsolationLevel = reader.IsDBNull(18) ? null : reader.GetString(18), + BlockingClientApp = reader.IsDBNull(19) ? null : reader.GetString(19), + BlockingHostName = reader.IsDBNull(20) ? null : reader.GetString(20), + BlockingLoginName = reader.IsDBNull(21) ? null : reader.GetString(21), + BlockingSqlText = reader.IsDBNull(22) ? "" : reader.GetString(22), + BlockedTransactionName = reader.IsDBNull(23) ? null : reader.GetString(23), + BlockingTransactionName = reader.IsDBNull(24) ? null : reader.GetString(24), + BlockedLastTranStarted = reader.IsDBNull(25) ? null : reader.GetDateTime(25), + BlockingLastTranStarted = reader.IsDBNull(26) ? null : reader.GetDateTime(26), + BlockedLastBatchStarted = reader.IsDBNull(27) ? null : reader.GetDateTime(27), + BlockingLastBatchStarted = reader.IsDBNull(28) ? null : reader.GetDateTime(28), + BlockedLastBatchCompleted = reader.IsDBNull(29) ? null : reader.GetDateTime(29), + BlockingLastBatchCompleted = reader.IsDBNull(30) ? null : reader.GetDateTime(30), + BlockedPriority = reader.IsDBNull(31) ? 0 : reader.GetInt32(31), + BlockingPriority = reader.IsDBNull(32) ? 0 : reader.GetInt32(32), + BlockedProcessReportXml = reader.IsDBNull(33) ? "" : reader.GetString(33), + ContentiousObject = reader.IsDBNull(34) ? "" : reader.GetString(34), + }); + } + } + + await using (var command = new NpgsqlCommand(DmvBlockingSnapshotsSql, connection)) + { + DarlingMcpReadParameters.AddWindow(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + dmvItems.Add(new BlockedProcessReadRow + { + EventTime = reader.IsDBNull(0) ? null : reader.GetDateTime(0), + DatabaseName = reader.IsDBNull(1) ? "" : reader.GetString(1), + BlockedSpid = reader.IsDBNull(2) ? 0 : reader.GetInt32(2), + BlockedEcid = reader.IsDBNull(3) ? 0 : reader.GetInt32(3), + BlockingSpid = reader.IsDBNull(4) ? 0 : reader.GetInt32(4), + BlockingEcid = reader.IsDBNull(5) ? 0 : reader.GetInt32(5), + WaitTimeMs = reader.IsDBNull(6) ? 0 : reader.GetInt64(6), + LockMode = reader.IsDBNull(7) ? "" : reader.GetString(7), + BlockingStatus = reader.IsDBNull(8) ? null : reader.GetString(8), + ContentiousObject = reader.IsDBNull(9) ? "" : reader.GetString(9), + BlockedSqlText = reader.IsDBNull(10) ? "" : reader.GetString(10), + BlockingSqlText = reader.IsDBNull(11) ? "" : reader.GetString(11), + BlockedLoginName = reader.IsDBNull(12) ? null : reader.GetString(12), + BlockedHostName = reader.IsDBNull(13) ? null : reader.GetString(13), + BlockedClientApp = reader.IsDBNull(14) ? null : reader.GetString(14), + BlockingLoginName = reader.IsDBNull(15) ? null : reader.GetString(15), + BlockingHostName = reader.IsDBNull(16) ? null : reader.GetString(16), + BlockingClientApp = reader.IsDBNull(17) ? null : reader.GetString(17), + BlockedLastTranStarted = reader.IsDBNull(18) ? null : reader.GetDateTime(18), + BlockingLastTranStarted = reader.IsDBNull(19) ? null : reader.GetDateTime(19), + Source = BlockedProcessAlertRow.DmvSnapshotSource, + }); + } + } + + /* Lite's XE-preferred fallback, verbatim via the shared merge: keep all BPR rows; append a DMV row + only where no BPR covers the same SPID pair in the same minute; re-cap to the 200 newest. */ + BlockedProcessReportMerge.AppendDmvFallbackRows(items, dmvItems); + + return items; + } + + /* ─────────────────────────── deadlocks ─────────────────────────── */ + + /// + /// The recent deadlock events over the window — the viewer's RecentDeadlocksSql against the BASE + /// deadlocks table (base, for the V7 victim-plan column). The parsed + /// is computed on access from the graph XML. $1 server_id, + /// $2/$3 window (naive UTC). + /// + public const string RecentDeadlocksSql = """ + SELECT + collection_time, + deadlock_time, + victim_process_id, + victim_sql_text, + deadlock_graph_xml + FROM deadlocks + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + ORDER BY deadlock_time DESC + LIMIT 50 + """; + + public static async Task> GetRecentDeadlocksAsync( + NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var rows = new List(); + await using var command = postgres.CreateCommand(RecentDeadlocksSql); + DarlingMcpReadParameters.AddWindow(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new DeadlockReadRow + { + CollectionTime = reader.GetDateTime(0), + DeadlockTime = reader.IsDBNull(1) ? null : reader.GetDateTime(1), + VictimProcessId = reader.IsDBNull(2) ? "" : reader.GetString(2), + VictimSqlText = reader.IsDBNull(3) ? "" : reader.GetString(3), + DeadlockGraphXml = reader.IsDBNull(4) ? "" : reader.GetString(4), + }); + } + + return rows; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingBlockingTrendReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingBlockingTrendReader.cs new file mode 100644 index 000000000..0931fbe6d --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingBlockingTrendReader.cs @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// Service-side reads for the blocking-incident and deadlock per-minute trend MCP tools +/// ( get_blocking_trend / get_deadlock_trend). The SQL is reproduced +/// verbatim from the viewer's Blocking-Trends charts (ViewerDataService.BlockingTrends.cs), which are +/// Lite's GetBlockingTrendAsync / GetDeadlockTrendAsync ported to Postgres. Both are STORED +/// reads (no live monitored-server hit) sharing a (bucket timestamp, COUNT(*)) shape, so one reader +/// maps both; COUNT(*) is bigint in Postgres, read via GetInt64 and narrowed to the point's int. +/// Public-const SQL so Darling.Tests pin the dialect (the XE-preferred + DMV-fallback union, the deadlock +/// bucket-on-deadlock_time) without a live Postgres. +/// +internal static class DarlingBlockingTrendReader +{ + /// One incident-count-per-minute bucket (mirror of the viewer's BlockingTrendPoint). + public sealed record BlockingTrendReadPoint(DateTime Time, int Count); + + /// + /// Blocking-incident count per minute — the viewer's BlockingTrendSql. XE blocked-process reports + /// (v_blocked_process_reports) are the primary source, bucketed on event_time; the always-on + /// DMV snapshot (v_dmv_blocking_snapshots) is appended only when the XE source has no rows in the + /// window (WHERE NOT EXISTS), so a server with both sources never double-counts. $1 server_id, + /// $2 window start, $3 window end (naive UTC). + /// + public const string BlockingTrendSql = """ + WITH bpr AS ( + SELECT DATE_TRUNC('minute', event_time) AS bucket, COUNT(*) AS incident_count + FROM v_blocked_process_reports + WHERE server_id = $1 AND event_time >= $2 AND event_time <= $3 + GROUP BY DATE_TRUNC('minute', event_time) + ), + dmv AS ( + SELECT DATE_TRUNC('minute', event_time) AS bucket, COUNT(*) AS incident_count + FROM v_dmv_blocking_snapshots + WHERE server_id = $1 AND event_time >= $2 AND event_time <= $3 + GROUP BY DATE_TRUNC('minute', event_time) + ) + SELECT bucket, incident_count FROM bpr + UNION ALL + SELECT bucket, incident_count FROM dmv WHERE NOT EXISTS (SELECT 1 FROM bpr) + ORDER BY bucket + """; + + /// + /// Deadlock count per minute — the viewer's DeadlockTrendSql. Buckets on the deadlock's own + /// deadlock_time while windowing on the collection prefix. Reads v_deadlocks. $1 server_id, + /// $2 window start, $3 window end (naive UTC). + /// + public const string DeadlockTrendSql = """ + SELECT + bucket, + deadlock_count + FROM ( + SELECT + DATE_TRUNC('minute', deadlock_time) AS bucket, + COUNT(*) AS deadlock_count + FROM v_deadlocks + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + GROUP BY DATE_TRUNC('minute', deadlock_time) + ) sub + ORDER BY bucket + """; + + /// Blocking-incident-per-minute buckets for one server over the window. + public static Task> GetBlockingTrendAsync( + NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + => ReadCountTrendAsync(postgres, BlockingTrendSql, serverId, startUtc, endUtc, cancellationToken); + + /// Deadlock-per-minute buckets for one server over the window. + public static Task> GetDeadlockTrendAsync( + NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + => ReadCountTrendAsync(postgres, DeadlockTrendSql, serverId, startUtc, endUtc, cancellationToken); + + /// The blocking and deadlock trends share a (bucket timestamp, COUNT(*)) shape, so one reader + /// maps both. COUNT(*) is bigint in Postgres, read via GetInt64 and narrowed to the point's int. + private static async Task> ReadCountTrendAsync( + NpgsqlDataSource postgres, string sql, int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken) + { + var items = new List(); + await using var command = postgres.CreateCommand(sql); + DarlingMcpReadParameters.AddWindow(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new BlockingTrendReadPoint( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : (int)reader.GetInt64(1))); + } + + return items; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingConfigHistoryReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingConfigHistoryReader.cs new file mode 100644 index 000000000..fd3e08a90 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingConfigHistoryReader.cs @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// Service-side reads for the config / trace-flag MCP tools (). +/// The store keeps config as APPEND-ONLY snapshots keyed by capture_time (the collector writes every +/// setting every run, no dedup), so a change history genuinely exists — the three *_changes tools +/// fetch the ordered snapshots here and diff them via the shared +/// (one source, also used by the viewer and Lite — no +/// hand-mirrored twin). The diff runs in C# (not SQL) so it is unit-testable without a live Postgres and so +/// trace-flag enable/disable (a flag row appearing/disappearing between snapshots) is handled correctly; the +/// reads just fetch the ordered snapshots. get_database_scoped_config reads the latest snapshot directly (the +/// viewer's DatabaseScopedConfigSql). All are STORED reads (no live hit). +/// +/// +/// COLLECTION-CADENCE + SHAPE CAVEATS (surfaced in the tool descriptions, the MCP instructions, and the +/// READMEs, never silently dropped): the config collectors run ON CONNECT only (FrequencyMinutes = 0), +/// so change granularity equals the service's connect/restart cadence, and a stable always-connected +/// deployment may hold a single snapshot (→ no changes yet). The change tools emit ONLY collected values — +/// the Dashboard's enrichment columns that Darling does not collect are omitted: server-config +/// requires_restart / description, database-config setting_type (the store is WIDE, so +/// setting_name is the literal column name), and the generated change_description narrative. +/// +/// +internal static class DarlingConfigHistoryReader +{ + /* ─────────────────────────── database scoped config snapshot row (not part of the change diff) ─────────────────────────── */ + + public sealed record DatabaseScopedConfigReadRow( + string DatabaseName, string ConfigurationName, string? Value, string? ValueForSecondary); + + /* ─────────────────────────── server config snapshots ─────────────────────────── */ + + /// Every sys.configurations snapshot for a server, oldest-first — the change tool diffs + /// consecutive captures per configuration_name (rows are always all-present, so a per-name walk suffices). + /// $1 server_id. + public const string ServerConfigSnapshotsSql = """ + SELECT + capture_time, + configuration_name, + value_configured, + value_in_use, + is_dynamic, + is_advanced + FROM v_server_config + WHERE server_id = $1 + ORDER BY configuration_name, capture_time + """; + + public static async Task> GetServerConfigSnapshotsAsync( + NpgsqlDataSource postgres, int serverId, CancellationToken cancellationToken = default) + { + var rows = new List(); + await using var command = postgres.CreateCommand(ServerConfigSnapshotsSql); + DarlingMcpReadParameters.AddInt(command, serverId); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new ConfigChangeDiff.ServerConfigSnapshot( + reader.GetDateTime(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetInt64(2), + reader.IsDBNull(3) ? null : reader.GetInt64(3), + reader.IsDBNull(4) ? null : reader.GetBoolean(4), + reader.IsDBNull(5) ? null : reader.GetBoolean(5))); + } + + return rows; + } + + /* ─────────────────────────── database config snapshots (WIDE) ─────────────────────────── */ + + /// Every sys.databases snapshot for a server, with the 27 setting columns CAST to text for a + /// uniform value-diff. $1 server_id. The SELECT order is + /// . + public const string DatabaseConfigSnapshotsSql = """ + SELECT + capture_time, + database_name, + state_desc, + compatibility_level::text, + collation_name, + recovery_model, + is_read_only::text, + is_auto_close_on::text, + is_auto_shrink_on::text, + is_auto_create_stats_on::text, + is_auto_update_stats_on::text, + is_auto_update_stats_async_on::text, + is_read_committed_snapshot_on::text, + snapshot_isolation_state, + is_parameterization_forced::text, + is_query_store_on::text, + is_encrypted::text, + is_trustworthy_on::text, + is_db_chaining_on::text, + is_broker_enabled::text, + is_cdc_enabled::text, + is_mixed_page_allocation_on::text, + log_reuse_wait_desc, + page_verify_option, + target_recovery_time_seconds::text, + delayed_durability, + is_accelerated_database_recovery_on::text, + is_memory_optimized_enabled::text, + is_optimized_locking_on::text + FROM v_database_config + WHERE server_id = $1 + ORDER BY database_name, capture_time + """; + + public static async Task> GetDatabaseConfigSnapshotsAsync( + NpgsqlDataSource postgres, int serverId, CancellationToken cancellationToken = default) + { + var rows = new List(); + await using var command = postgres.CreateCommand(DatabaseConfigSnapshotsSql); + DarlingMcpReadParameters.AddInt(command, serverId); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var values = new string?[ConfigChangeDiff.DatabaseConfigChangeSettingNames.Count]; + for (var i = 0; i < values.Length; i++) + { + var ordinal = i + 2; /* capture_time, database_name precede the settings */ + values[i] = reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal); + } + + rows.Add(new ConfigChangeDiff.DatabaseConfigSnapshot( + reader.GetDateTime(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + values)); + } + + return rows; + } + + /* ─────────────────────────── trace flag snapshots ─────────────────────────── */ + + /// Every trace-flag snapshot for a server (a row exists only while a flag is enabled), oldest + /// first — the change tool set-diffs consecutive captures so a flag appearing = enabled, disappearing = + /// disabled. $1 server_id. + public const string TraceFlagSnapshotsSql = """ + SELECT + capture_time, + trace_flag, + status, + is_global, + is_session + FROM v_trace_flags + WHERE server_id = $1 + ORDER BY capture_time, trace_flag + """; + + public static async Task> GetTraceFlagSnapshotsAsync( + NpgsqlDataSource postgres, int serverId, CancellationToken cancellationToken = default) + { + var rows = new List(); + await using var command = postgres.CreateCommand(TraceFlagSnapshotsSql); + DarlingMcpReadParameters.AddInt(command, serverId); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new ConfigChangeDiff.TraceFlagSnapshot( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : reader.GetInt32(1), + reader.IsDBNull(2) ? null : reader.GetBoolean(2), + reader.IsDBNull(3) ? null : reader.GetBoolean(3), + reader.IsDBNull(4) ? null : reader.GetBoolean(4))); + } + + return rows; + } + + /* ─────────────────────────── database scoped config (latest snapshot) ─────────────────────────── */ + + /// The latest sys.database_scoped_configurations snapshot — the viewer's + /// DatabaseScopedConfigSql. $1 server_id. + public const string DatabaseScopedConfigSql = """ + SELECT database_name, configuration_name, value, value_for_secondary + FROM v_database_scoped_config + WHERE server_id = $1 + AND capture_time = (SELECT MAX(capture_time) FROM v_database_scoped_config WHERE server_id = $1) + ORDER BY database_name, configuration_name + """; + + public static async Task> GetLatestDatabaseScopedConfigAsync( + NpgsqlDataSource postgres, int serverId, CancellationToken cancellationToken = default) + { + var rows = new List(); + await using var command = postgres.CreateCommand(DatabaseScopedConfigSql); + DarlingMcpReadParameters.AddInt(command, serverId); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new DatabaseScopedConfigReadRow( + reader.IsDBNull(0) ? "" : reader.GetString(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetString(2), + reader.IsDBNull(3) ? null : reader.GetString(3))); + } + + return rows; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingCurrentConfigReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingCurrentConfigReader.cs new file mode 100644 index 000000000..ee2630ad8 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingCurrentConfigReader.cs @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// Service-side latest-snapshot reads for the current-config MCP tools () — +/// get_server_config / get_database_config / get_trace_flags. Where the sibling +/// DIFFS the append-only config snapshots into a change history, +/// these return the MOST RECENT capture for one server via capture_time = (SELECT MAX(capture_time) ...) +/// against the config passthrough views — the "what is it set to right now" read the *_changes diff +/// tools cannot answer on a stable server (an unchanged deployment yields no diffs). The SQL is reproduced +/// verbatim from the viewer's Configuration-tab reads (ViewerDataService.Config.cs), which are Lite's +/// LocalDataService.Config.cs ported to Postgres; the database-config SELECT's 28-column order is +/// load-bearing (the reader maps it by incrementing ordinal, exactly like the viewer/Lite). All STORED reads +/// (no live monitored-server hit); public-const SQL so Darling.Tests pin the dialect + columns without a +/// live Postgres. +/// +internal static class DarlingCurrentConfigReader +{ + /* ─────────────────────────── server config (sys.configurations) ─────────────────────────── */ + + public sealed record ServerConfigReadRow( + string ConfigurationName, long ValueConfigured, long ValueInUse, bool IsDynamic, bool IsAdvanced) + { + public bool ValuesMatch => ValueConfigured == ValueInUse; + } + + /// Latest sys.configurations snapshot for one server — the viewer's ServerConfigSql. + /// $1 server_id. + public const string ServerConfigSql = """ + SELECT configuration_name, value_configured, value_in_use, is_dynamic, is_advanced + FROM v_server_config + WHERE server_id = $1 + AND capture_time = (SELECT MAX(capture_time) FROM v_server_config WHERE server_id = $1) + ORDER BY configuration_name + """; + + public static async Task> GetLatestServerConfigAsync( + NpgsqlDataSource postgres, int serverId, CancellationToken cancellationToken = default) + { + var rows = new List(); + await using var command = postgres.CreateCommand(ServerConfigSql); + DarlingMcpReadParameters.AddInt(command, serverId); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new ServerConfigReadRow( + reader.IsDBNull(0) ? "" : reader.GetString(0), + reader.IsDBNull(1) ? 0 : reader.GetInt64(1), + reader.IsDBNull(2) ? 0 : reader.GetInt64(2), + !reader.IsDBNull(3) && reader.GetBoolean(3), + !reader.IsDBNull(4) && reader.GetBoolean(4))); + } + + return rows; + } + + /* ─────────────────────────── database config (sys.databases) ─────────────────────────── */ + + public sealed record DatabaseConfigReadRow( + string DatabaseName, string StateDesc, int CompatibilityLevel, string CollationName, string RecoveryModel, + bool IsReadOnly, bool IsAutoCloseOn, bool IsAutoShrinkOn, bool IsAutoCreateStatsOn, bool IsAutoUpdateStatsOn, + bool IsAutoUpdateStatsAsyncOn, bool IsRcsiOn, string SnapshotIsolationState, bool IsParameterizationForced, + bool IsQueryStoreOn, bool IsEncrypted, bool IsTrustworthyOn, bool IsDbChainingOn, bool IsBrokerEnabled, + bool IsCdcEnabled, bool IsMixedPageAllocationOn, string LogReuseWaitDesc, string PageVerifyOption, + int TargetRecoveryTimeSeconds, string DelayedDurability, bool IsAcceleratedDatabaseRecoveryOn, + bool IsMemoryOptimizedEnabled, bool IsOptimizedLockingOn); + + /* 28 columns in the viewer's / Lite's exact SELECT order — the reader below maps them by incrementing + ordinal, so this list's order is load-bearing and must stay byte-identical. */ + public const string DatabaseConfigSql = """ + SELECT database_name, state_desc, compatibility_level, collation_name, recovery_model, + is_read_only, is_auto_close_on, is_auto_shrink_on, + is_auto_create_stats_on, is_auto_update_stats_on, is_auto_update_stats_async_on, + is_read_committed_snapshot_on, snapshot_isolation_state, is_parameterization_forced, + is_query_store_on, is_encrypted, is_trustworthy_on, is_db_chaining_on, + is_broker_enabled, is_cdc_enabled, is_mixed_page_allocation_on, + log_reuse_wait_desc, page_verify_option, target_recovery_time_seconds, delayed_durability, + is_accelerated_database_recovery_on, is_memory_optimized_enabled, is_optimized_locking_on + FROM v_database_config + WHERE server_id = $1 + AND capture_time = (SELECT MAX(capture_time) FROM v_database_config WHERE server_id = $1) + ORDER BY database_name + """; + + public static async Task> GetLatestDatabaseConfigAsync( + NpgsqlDataSource postgres, int serverId, CancellationToken cancellationToken = default) + { + var rows = new List(); + await using var command = postgres.CreateCommand(DatabaseConfigSql); + DarlingMcpReadParameters.AddInt(command, serverId); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + /* Incrementing-ordinal mapping copied from the viewer/Lite so the 28-column order in + DatabaseConfigSql binds to the same fields — see the byte-identical note above. */ + var ordinal = 0; + rows.Add(new DatabaseConfigReadRow( + reader.IsDBNull(ordinal) ? "" : reader.GetString(ordinal), + reader.IsDBNull(++ordinal) ? "" : reader.GetString(ordinal), + reader.IsDBNull(++ordinal) ? 0 : reader.GetInt32(ordinal), + reader.IsDBNull(++ordinal) ? "" : reader.GetString(ordinal), + reader.IsDBNull(++ordinal) ? "" : reader.GetString(ordinal), + !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + reader.IsDBNull(++ordinal) ? "" : reader.GetString(ordinal), + !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + reader.IsDBNull(++ordinal) ? "" : reader.GetString(ordinal), + reader.IsDBNull(++ordinal) ? "" : reader.GetString(ordinal), + reader.IsDBNull(++ordinal) ? 0 : reader.GetInt32(ordinal), + reader.IsDBNull(++ordinal) ? "" : reader.GetString(ordinal), + !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal))); + } + + return rows; + } + + /* ─────────────────────────── trace flags (DBCC TRACESTATUS) ─────────────────────────── */ + + public sealed record TraceFlagReadRow(int TraceFlag, bool Status, bool IsGlobal, bool IsSession); + + /// Latest trace-flags snapshot for one server — the viewer's TraceFlagsSql. $1 server_id. + /// A row exists only while a flag is enabled, so an empty result means no active flags at the last capture. + public const string TraceFlagsSql = """ + SELECT trace_flag, status, is_global, is_session + FROM v_trace_flags + WHERE server_id = $1 + AND capture_time = (SELECT MAX(capture_time) FROM v_trace_flags WHERE server_id = $1) + ORDER BY trace_flag + """; + + public static async Task> GetLatestTraceFlagsAsync( + NpgsqlDataSource postgres, int serverId, CancellationToken cancellationToken = default) + { + var rows = new List(); + await using var command = postgres.CreateCommand(TraceFlagsSql); + DarlingMcpReadParameters.AddInt(command, serverId); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new TraceFlagReadRow( + reader.IsDBNull(0) ? 0 : reader.GetInt32(0), + !reader.IsDBNull(1) && reader.GetBoolean(1), + !reader.IsDBNull(2) && reader.GetBoolean(2), + !reader.IsDBNull(3) && reader.GetBoolean(3))); + } + + return rows; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs new file mode 100644 index 000000000..9daed1b80 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs @@ -0,0 +1,1008 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using NpgsqlTypes; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// Service-side reads for the core data-read MCP tools () — the +/// SAME collected data the viewer's ViewerDataService.* partials read, adapted here so the +/// MCP host never references the WPF viewer project. Each read is a STORED read (no live +/// monitored-server hit), keyed by server_id and windowed on the naive-UTC +/// collection_time prefix — the reliable clock every Darling read windows on, sent +/// Kind=Unspecified because the store's columns are timestamp without time zone +/// (a Kind=Utc DateTime maps to timestamptz and throws since Npgsql 6.0). +/// +/// +/// The result shapes mirror Lite's Mcp*Tools field-for-field (the same tool names the +/// Dashboard and Lite expose), because Darling's store is Lite's DuckDB schema column-for-column +/// () — the same reason the existing +/// mirror Lite's analysis tools. Every SQL string is a public const so +/// Darling.Tests can pin the dialect + columns without a live Postgres. Where a view exists the read +/// uses the v_* passthrough (the Darling-analysis convention); server_properties and the +/// three query-perf tables have NO v_* view (they are not in +/// ) so those reads hit the base table, exactly +/// like the viewer's twins and the merged . +/// +/// +internal static class DarlingDataReader +{ + /* ─────────────────────────── result records ─────────────────────────── */ + + /// One raw CPU ring-buffer sample: sample_time (de-skewed to naive UTC) + the SQL and + /// other-process CPU percentages. The tool buckets these to 1-minute averages. + public sealed record CpuSample(DateTime SampleTime, int SqlServerCpu, int OtherProcessCpu); + + /// One aggregated wait type over the window — summed deltas; resource / signal-pct are + /// computed by the tool (mirroring Lite's WaitStatsRow). + public sealed record WaitStatRow(string WaitType, long TotalWaitingTasks, long TotalWaitTimeMs, long TotalSignalWaitTimeMs); + + /// One point of a single wait type's per-second trend. + public sealed record WaitTrendPoint(DateTime CollectionTime, double WaitTimeMsPerSecond, double SignalWaitTimeMsPerSecond); + + /// The latest memory_stats snapshot (Lite's MemoryStatsRow); utilization is + /// computed by the tool. + public sealed record MemoryStatsRow( + DateTime CollectionTime, double TotalPhysicalMemoryMb, double AvailablePhysicalMemoryMb, + double TotalPageFileMb, double AvailablePageFileMb, string SystemMemoryState, string SqlMemoryModel, + double TargetServerMemoryMb, double TotalServerMemoryMb, double BufferPoolMb, double PlanCacheMb); + + /// One memory clerk's footprint at the latest snapshot. + public sealed record MemoryClerkRow(string ClerkType, double MemoryMb); + + /// One database file's latest I/O snapshot; avg latency is computed by the tool. + public sealed record FileIoRow( + string DatabaseName, string FileName, string FileType, string PhysicalName, double SizeMb, + long DeltaReads, long DeltaWrites, long DeltaReadBytes, long DeltaWriteBytes, + long DeltaStallReadMs, long DeltaStallWriteMs); + + /// One tempdb space-usage sample over the window. + public sealed record TempDbSample( + DateTime CollectionTime, double UserObjectReservedMb, double InternalObjectReservedMb, + double VersionStoreReservedMb, double TotalReservedMb, double UnallocatedMb, + long TotalSessionsUsingTempDb, int TopSessionId, double TopSessionTempDbMb); + + /// One perfmon counter at the latest snapshot. + public sealed record PerfmonRow(string CounterName, string InstanceName, long Value, long DeltaValue); + + /// One (database, query_hash) group's summed query-stats deltas over the window. Time + /// metrics are in microseconds (converted to ms by the tool, matching Lite). + public sealed record TopQueryRow( + string DatabaseName, string QueryHash, string QueryPlanHash, string SqlHandle, string PlanHandle, + long TotalExecutions, long TotalCpuUs, long TotalElapsedUs, long TotalLogicalReads, long TotalLogicalWrites, + long TotalPhysicalReads, long TotalRows, long TotalSpills, int MinDop, int MaxDop, + long MinCpuUs, long MaxCpuUs, long MinElapsedUs, long MaxElapsedUs, string QueryText); + + /// One (database, schema, object) group's summed procedure-stats deltas over the window. + public sealed record TopProcedureRow( + string DatabaseName, string SchemaName, string ObjectName, string ObjectType, string SqlHandle, string PlanHandle, + long TotalExecutions, long TotalCpuUs, long TotalElapsedUs, long TotalLogicalReads, long TotalLogicalWrites, + long TotalPhysicalReads, long TotalSpills, long MinCpuUs, long MaxCpuUs, long MinElapsedUs, long MaxElapsedUs); + + /// + /// One Query Store (database, query_id, plan_id, query_hash, replica_role) group's interval averages. + /// ReplicaRole is part of the grouping key, not a decoration: on SQL 2022+ with Query Store for + /// secondary replicas the primary holds one shared Query Store carrying every replica's rows, so + /// omitting it would report primary and secondary workload blended into one row. NULL when the server + /// did not attribute the row (pre-2022, or a 2022 standalone). + /// + public sealed record QueryStoreRow( + string DatabaseName, long QueryId, long PlanId, string QueryHash, string QueryPlanHash, + long TotalExecutions, double AvgDurationMs, double AvgCpuTimeMs, double AvgLogicalReads, + double AvgLogicalWrites, double AvgPhysicalReads, double AvgRowcount, DateTime? LastExecutionTime, string QueryText, + string? ReplicaRole); + + /// One server-list entry — the registry row plus its newest collection instant (drives the + /// freshness-derived status the tool assigns; the viewer has no live ping either). + public sealed record ServerListRow(int ServerId, string ServerName, string? DisplayName, int? SqlMajorVersion, DateTime? LastCollection); + + /// The latest server_properties snapshot (Lite's ServerPropertiesRow). + public sealed record ServerPropertiesReadRow( + DateTime CollectionTime, string Edition, string ProductVersion, string ProductLevel, string? ProductUpdateLevel, + int EngineEdition, int CpuCount, int HyperthreadRatio, long PhysicalMemoryMb, int SocketCount, int CoresPerSocket, + bool IsHadrEnabled, bool IsClustered, string? EnterpriseFeatures, string? ServiceObjective); + + /* ─────────────────────────── CPU ─────────────────────────── */ + + /// + /// Raw per-sample CPU (the viewer's CpuUtilizationSql): every ring-buffer sample since the + /// window start, with sample_time de-skewed from the monitored server's LOCAL wall clock to + /// naive UTC by subtracting the per-batch UTC offset (#1262). Windows on collection_time (the + /// reliable naive-UTC clock, not the server-local sample_time). Reads the base + /// cpu_utilization_stats table (the de-skew window function needs collection_time alongside + /// sample_time). $1 server_id, $2 window start (naive UTC). + /// + public const string CpuUtilizationSql = """ + SELECT + sample_time + - INTERVAL '15 minutes' + * ROUND(EXTRACT(EPOCH FROM ( + MAX(sample_time) OVER (PARTITION BY server_id, collection_time) - collection_time + )) / 900.0)::double precision AS sample_time, + sqlserver_cpu_utilization, + other_process_cpu_utilization + FROM cpu_utilization_stats + WHERE server_id = $1 + AND collection_time >= $2 + ORDER BY sample_time + """; + + public static async Task> GetCpuUtilizationAsync( + NpgsqlDataSource postgres, int serverId, DateTime startUtc, CancellationToken cancellationToken = default) + { + var samples = new List(); + await using var command = postgres.CreateCommand(CpuUtilizationSql); + AddInt(command, serverId); + AddTimestamp(command, startUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + samples.Add(new CpuSample( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : reader.GetInt32(1), + reader.IsDBNull(2) ? 0 : reader.GetInt32(2))); + } + + return samples; + } + + /* ─────────────────────────── wait stats ─────────────────────────── */ + + /// + /// Aggregated wait stats over the window — Lite's GetWaitStatsAsync: summed deltas per + /// wait_type, heaviest first. The SUMs CAST to bigint for the typed GetInt64 reader (Postgres + /// SUM(bigint) is numeric). Lite's per-user IgnoredWaitTypes exclusion is dropped (headless + /// has no per-user ignore config — the viewer's wait reads drop it the same way). $1 server_id, $2/$3 + /// window (naive UTC). + /// + public const string WaitStatsSql = """ + SELECT + wait_type, + CAST(SUM(delta_waiting_tasks) AS bigint) AS total_waiting_tasks, + CAST(SUM(delta_wait_time_ms) AS bigint) AS total_wait_time_ms, + CAST(SUM(delta_signal_wait_time_ms) AS bigint) AS total_signal_wait_time_ms + FROM v_wait_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + GROUP BY wait_type + ORDER BY SUM(delta_wait_time_ms) DESC + LIMIT 50 + """; + + public static async Task> GetWaitStatsAsync( + NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var rows = new List(); + await using var command = postgres.CreateCommand(WaitStatsSql); + AddWindow(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new WaitStatRow( + reader.GetString(0), + reader.IsDBNull(1) ? 0 : reader.GetInt64(1), + reader.IsDBNull(2) ? 0 : reader.GetInt64(2), + reader.IsDBNull(3) ? 0 : reader.GetInt64(3))); + } + + return rows; + } + + /// + /// The distinct wait types collected over the window, heaviest first — feeds the get_wait_trend + /// "not_collected" hint (Lite's GetDistinctWaitTypesAsync). $1 server_id, $2/$3 window. + /// + public const string DistinctWaitTypesSql = """ + SELECT wait_type + FROM v_wait_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + GROUP BY wait_type + ORDER BY SUM(delta_wait_time_ms) DESC + """; + + public static async Task> GetDistinctWaitTypesAsync( + NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var items = new List(); + await using var command = postgres.CreateCommand(DistinctWaitTypesSql); + AddWindow(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(reader.GetString(0)); + } + + return items; + } + + /// + /// A single wait type's per-second trend — Lite's GetWaitStatsTrendAsync: the interval rate is + /// this collection's delta divided by the seconds since the previous collection (a LAG over the + /// truncate-then-diff epoch idiom proven value-identical DuckDB↔Postgres). $1 server_id, $2 wait_type, + /// $3/$4 window (naive UTC). + /// + public const string WaitTrendSql = """ + WITH raw AS + ( + SELECT + collection_time, + delta_wait_time_ms, + delta_signal_wait_time_ms, + extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (ORDER BY collection_time)))) AS interval_seconds + FROM v_wait_stats + WHERE server_id = $1 + AND wait_type = $2 + AND collection_time >= $3 + AND collection_time <= $4 + ) + SELECT + collection_time, + CASE WHEN interval_seconds > 0 THEN CAST(delta_wait_time_ms AS DOUBLE PRECISION) / interval_seconds ELSE 0 END AS wait_time_ms_per_second, + CASE WHEN interval_seconds > 0 THEN CAST(delta_signal_wait_time_ms AS DOUBLE PRECISION) / interval_seconds ELSE 0 END AS signal_wait_time_ms_per_second + FROM raw + ORDER BY collection_time + """; + + public static async Task> GetWaitTrendAsync( + NpgsqlDataSource postgres, int serverId, string waitType, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var items = new List(); + await using var command = postgres.CreateCommand(WaitTrendSql); + AddInt(command, serverId); + AddText(command, waitType); + AddTimestamp(command, startUtc); + AddTimestamp(command, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new WaitTrendPoint( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : reader.GetDouble(1), + reader.IsDBNull(2) ? 0 : reader.GetDouble(2))); + } + + return items; + } + + /* ─────────────────────────── memory ─────────────────────────── */ + + /// + /// The latest memory_stats snapshot — the viewer's LatestMemoryStatsSql. The eight MB metrics + /// are numeric(18,2) and CAST to double precision for the typed reader. $1 server_id. + /// + public const string LatestMemoryStatsSql = """ + SELECT + collection_time, + CAST(total_physical_memory_mb AS double precision), + CAST(available_physical_memory_mb AS double precision), + CAST(total_page_file_mb AS double precision), + CAST(available_page_file_mb AS double precision), + system_memory_state, + sql_memory_model, + CAST(target_server_memory_mb AS double precision), + CAST(total_server_memory_mb AS double precision), + CAST(buffer_pool_mb AS double precision), + CAST(plan_cache_mb AS double precision) + FROM v_memory_stats + WHERE server_id = $1 + ORDER BY collection_time DESC + LIMIT 1 + """; + + public static async Task GetLatestMemoryStatsAsync( + NpgsqlDataSource postgres, int serverId, CancellationToken cancellationToken = default) + { + await using var command = postgres.CreateCommand(LatestMemoryStatsSql); + AddInt(command, serverId); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + { + return null; + } + + return new MemoryStatsRow( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : reader.GetDouble(1), + reader.IsDBNull(2) ? 0 : reader.GetDouble(2), + reader.IsDBNull(3) ? 0 : reader.GetDouble(3), + reader.IsDBNull(4) ? 0 : reader.GetDouble(4), + reader.IsDBNull(5) ? "" : reader.GetString(5), + reader.IsDBNull(6) ? "" : reader.GetString(6), + reader.IsDBNull(7) ? 0 : reader.GetDouble(7), + reader.IsDBNull(8) ? 0 : reader.GetDouble(8), + reader.IsDBNull(9) ? 0 : reader.GetDouble(9), + reader.IsDBNull(10) ? 0 : reader.GetDouble(10)); + } + + /// + /// The latest memory-clerk breakdown — Lite's GetLatestMemoryClerksAsync: every clerk at the + /// newest collection, heaviest first. memory_mb is numeric(18,2) → double precision. $1 server_id. + /// + public const string LatestMemoryClerksSql = """ + SELECT clerk_type, CAST(memory_mb AS double precision) + FROM v_memory_clerks + WHERE server_id = $1 + AND collection_time = (SELECT MAX(collection_time) FROM v_memory_clerks WHERE server_id = $1) + ORDER BY memory_mb DESC + """; + + public static async Task> GetLatestMemoryClerksAsync( + NpgsqlDataSource postgres, int serverId, CancellationToken cancellationToken = default) + { + var rows = new List(); + await using var command = postgres.CreateCommand(LatestMemoryClerksSql); + AddInt(command, serverId); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new MemoryClerkRow( + reader.GetString(0), + reader.IsDBNull(1) ? 0 : reader.GetDouble(1))); + } + + return rows; + } + + /* ─────────────────────────── file I/O ─────────────────────────── */ + + /// + /// The latest file-I/O snapshot per database file — Lite's GetLatestFileIoStatsAsync, ordered + /// by total stall descending; avg latency (stall/op) is computed by the tool. size_mb is + /// numeric → double precision; the delta columns are bigint. $1 server_id. + /// + public const string LatestFileIoStatsSql = """ + SELECT + database_name, + file_name, + file_type, + physical_name, + CAST(size_mb AS double precision), + delta_reads, + delta_writes, + delta_read_bytes, + delta_write_bytes, + delta_stall_read_ms, + delta_stall_write_ms + FROM v_file_io_stats + WHERE server_id = $1 + AND collection_time = (SELECT MAX(collection_time) FROM v_file_io_stats WHERE server_id = $1) + ORDER BY (delta_stall_read_ms + delta_stall_write_ms) DESC + """; + + public static async Task> GetLatestFileIoStatsAsync( + NpgsqlDataSource postgres, int serverId, CancellationToken cancellationToken = default) + { + var rows = new List(); + await using var command = postgres.CreateCommand(LatestFileIoStatsSql); + AddInt(command, serverId); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new FileIoRow( + reader.IsDBNull(0) ? "" : reader.GetString(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + reader.IsDBNull(2) ? "" : reader.GetString(2), + reader.IsDBNull(3) ? "" : reader.GetString(3), + reader.IsDBNull(4) ? 0 : reader.GetDouble(4), + reader.IsDBNull(5) ? 0 : reader.GetInt64(5), + reader.IsDBNull(6) ? 0 : reader.GetInt64(6), + reader.IsDBNull(7) ? 0 : reader.GetInt64(7), + reader.IsDBNull(8) ? 0 : reader.GetInt64(8), + reader.IsDBNull(9) ? 0 : reader.GetInt64(9), + reader.IsDBNull(10) ? 0 : reader.GetInt64(10))); + } + + return rows; + } + + /* ─────────────────────────── tempdb ─────────────────────────── */ + + /// + /// tempdb space-usage samples over the window — the viewer's TempDbTrendSql. MB columns are + /// numeric(18,2) → double precision; total_sessions_using_tempdb is bigint, top_session_id is + /// integer. $1 server_id, $2 window start (naive UTC). + /// + public const string TempDbTrendSql = """ + SELECT + collection_time, + CAST(user_object_reserved_mb AS double precision), + CAST(internal_object_reserved_mb AS double precision), + CAST(version_store_reserved_mb AS double precision), + CAST(total_reserved_mb AS double precision), + CAST(unallocated_mb AS double precision), + total_sessions_using_tempdb, + top_session_id, + CAST(top_session_tempdb_mb AS double precision) + FROM v_tempdb_stats + WHERE server_id = $1 + AND collection_time >= $2 + ORDER BY collection_time + """; + + public static async Task> GetTempDbTrendAsync( + NpgsqlDataSource postgres, int serverId, DateTime startUtc, CancellationToken cancellationToken = default) + { + var samples = new List(); + await using var command = postgres.CreateCommand(TempDbTrendSql); + AddInt(command, serverId); + AddTimestamp(command, startUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + samples.Add(new TempDbSample( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : reader.GetDouble(1), + reader.IsDBNull(2) ? 0 : reader.GetDouble(2), + reader.IsDBNull(3) ? 0 : reader.GetDouble(3), + reader.IsDBNull(4) ? 0 : reader.GetDouble(4), + reader.IsDBNull(5) ? 0 : reader.GetDouble(5), + reader.IsDBNull(6) ? 0 : reader.GetInt64(6), + reader.IsDBNull(7) ? 0 : reader.GetInt32(7), + reader.IsDBNull(8) ? 0 : reader.GetDouble(8))); + } + + return samples; + } + + /* ─────────────────────────── perfmon ─────────────────────────── */ + + /// + /// The latest perfmon counters — Lite's GetLatestPerfmonStatsAsync: counter_name / + /// instance_name / cntr_value / delta_cntr_value at the newest collection. $1 server_id. + /// + public const string LatestPerfmonStatsSql = """ + SELECT + counter_name, + instance_name, + cntr_value, + delta_cntr_value + FROM v_perfmon_stats + WHERE server_id = $1 + AND collection_time = (SELECT MAX(collection_time) FROM v_perfmon_stats WHERE server_id = $1) + ORDER BY counter_name + """; + + public static async Task> GetLatestPerfmonStatsAsync( + NpgsqlDataSource postgres, int serverId, CancellationToken cancellationToken = default) + { + var rows = new List(); + await using var command = postgres.CreateCommand(LatestPerfmonStatsSql); + AddInt(command, serverId); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new PerfmonRow( + reader.IsDBNull(0) ? "" : reader.GetString(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + reader.IsDBNull(2) ? 0 : reader.GetInt64(2), + reader.IsDBNull(3) ? 0 : reader.GetInt64(3))); + } + + return rows; + } + + /* ─────────────────────────── top queries ─────────────────────────── */ + + /// + /// Top query-stats groups over the window — a focused projection of the viewer's TopQueriesSql + /// (the columns Lite's get_top_queries_by_cpu returns): group by (database, query_hash), sum the + /// deltas + carry min/max spreads, rank by summed delta_elapsed_time descending, over-fetch by + /// 5 to drop WAITFOR shells via the latest-text LATERAL, cap at top. Summed bigints CAST back to bigint + /// for the typed reader. Reads the base query_stats table (no v_ view — the plan tools read it + /// the same way). $1 server_id, $2/$3 window (naive UTC), $4 top. + /// + public const string TopQueriesSql = """ + WITH ranked AS ( + SELECT + database_name, + query_hash, + CAST(SUM(delta_execution_count) AS bigint) AS total_executions, + CAST(SUM(delta_worker_time) AS bigint) AS total_cpu_us, + CAST(SUM(delta_elapsed_time) AS bigint) AS total_elapsed_us, + CAST(SUM(delta_logical_reads) AS bigint) AS total_reads, + CAST(SUM(delta_logical_writes) AS bigint) AS total_writes, + CAST(SUM(delta_physical_reads) AS bigint) AS total_physical_reads, + CAST(SUM(delta_rows) AS bigint) AS total_rows, + CAST(SUM(delta_spills) AS bigint) AS total_spills, + MIN(min_dop) AS min_dop, + MAX(max_dop) AS max_dop, + MIN(min_worker_time) AS min_worker_time, + MAX(max_worker_time) AS max_worker_time, + MIN(min_elapsed_time) AS min_elapsed_time, + MAX(max_elapsed_time) AS max_elapsed_time, + MAX(query_plan_hash) AS query_plan_hash, + MAX(sql_handle) AS sql_handle, + MAX(plan_handle) AS plan_handle + FROM query_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND ($5::text IS NULL OR database_name = $5) + GROUP BY database_name, query_hash + HAVING SUM(delta_execution_count) > 0 OR SUM(delta_elapsed_time) > 0 + ORDER BY SUM(delta_elapsed_time) DESC + LIMIT $4 + 5 + ) + SELECT + r.database_name, + r.query_hash, + r.query_plan_hash, + r.sql_handle, + r.plan_handle, + r.total_executions, + r.total_cpu_us, + r.total_elapsed_us, + r.total_reads, + r.total_writes, + r.total_physical_reads, + r.total_rows, + r.total_spills, + r.min_dop, + r.max_dop, + r.min_worker_time, + r.max_worker_time, + r.min_elapsed_time, + r.max_elapsed_time, + t.query_text + FROM ranked AS r + LEFT JOIN LATERAL ( + SELECT query_text + FROM query_stats + WHERE server_id = $1 + AND query_hash = r.query_hash + AND database_name = r.database_name + AND query_text IS NOT NULL + ORDER BY collection_time DESC + LIMIT 1 + ) AS t ON TRUE + WHERE t.query_text IS NULL OR t.query_text NOT LIKE 'WAITFOR%' + ORDER BY r.total_elapsed_us DESC + LIMIT $4 + """; + + public static async Task> GetTopQueriesByCpuAsync( + NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, int top, string? databaseName, CancellationToken cancellationToken = default) + { + var rows = new List(); + await using var command = postgres.CreateCommand(TopQueriesSql); + AddWindow(command, serverId, startUtc, endUtc); + AddInt(command, top); + AddNullableText(command, databaseName); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new TopQueryRow( + reader.IsDBNull(0) ? "" : reader.GetString(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + reader.IsDBNull(2) ? "" : reader.GetString(2), + reader.IsDBNull(3) ? "" : reader.GetString(3), + reader.IsDBNull(4) ? "" : reader.GetString(4), + reader.IsDBNull(5) ? 0 : reader.GetInt64(5), + reader.IsDBNull(6) ? 0 : reader.GetInt64(6), + reader.IsDBNull(7) ? 0 : reader.GetInt64(7), + reader.IsDBNull(8) ? 0 : reader.GetInt64(8), + reader.IsDBNull(9) ? 0 : reader.GetInt64(9), + reader.IsDBNull(10) ? 0 : reader.GetInt64(10), + reader.IsDBNull(11) ? 0 : reader.GetInt64(11), + reader.IsDBNull(12) ? 0 : reader.GetInt64(12), + reader.IsDBNull(13) ? 0 : Convert.ToInt32(reader.GetValue(13)), + reader.IsDBNull(14) ? 0 : Convert.ToInt32(reader.GetValue(14)), + reader.IsDBNull(15) ? 0 : reader.GetInt64(15), + reader.IsDBNull(16) ? 0 : reader.GetInt64(16), + reader.IsDBNull(17) ? 0 : reader.GetInt64(17), + reader.IsDBNull(18) ? 0 : reader.GetInt64(18), + reader.IsDBNull(19) ? "" : reader.GetString(19))); + } + + return rows; + } + + /* ─────────────────────────── top procedures ─────────────────────────── */ + + /// + /// Top procedure-stats groups over the window — a focused projection of the viewer's + /// TopProceduresSql (the columns Lite's get_top_procedures_by_cpu returns): group by + /// (database, schema, object, type), sum the deltas + carry min/max spreads, rank by summed + /// delta_elapsed_time descending, cap at top. Reads the base procedure_stats table + /// (no v_ view). $1 server_id, $2/$3 window (naive UTC), $4 top. + /// + public const string TopProceduresSql = """ + SELECT + database_name, + schema_name, + object_name, + object_type, + MAX(sql_handle) AS sql_handle, + MAX(plan_handle) AS plan_handle, + CAST(SUM(delta_execution_count) AS bigint) AS total_executions, + CAST(SUM(delta_worker_time) AS bigint) AS total_cpu_us, + CAST(SUM(delta_elapsed_time) AS bigint) AS total_elapsed_us, + CAST(SUM(delta_logical_reads) AS bigint) AS total_reads, + CAST(SUM(delta_logical_writes) AS bigint) AS total_writes, + CAST(SUM(delta_physical_reads) AS bigint) AS total_physical_reads, + CAST(SUM(delta_spills) AS bigint) AS total_spills, + MIN(min_worker_time) AS min_worker_time, + MAX(max_worker_time) AS max_worker_time, + MIN(min_elapsed_time) AS min_elapsed_time, + MAX(max_elapsed_time) AS max_elapsed_time + FROM procedure_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND ($5::text IS NULL OR database_name = $5) + GROUP BY database_name, schema_name, object_name, object_type + HAVING SUM(delta_execution_count) > 0 OR SUM(delta_elapsed_time) > 0 + ORDER BY SUM(delta_elapsed_time) DESC + LIMIT $4 + """; + + public static async Task> GetTopProceduresByCpuAsync( + NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, int top, string? databaseName, CancellationToken cancellationToken = default) + { + var rows = new List(); + await using var command = postgres.CreateCommand(TopProceduresSql); + AddWindow(command, serverId, startUtc, endUtc); + AddInt(command, top); + AddNullableText(command, databaseName); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new TopProcedureRow( + reader.IsDBNull(0) ? "" : reader.GetString(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + reader.IsDBNull(2) ? "" : reader.GetString(2), + reader.IsDBNull(3) ? "" : reader.GetString(3), + reader.IsDBNull(4) ? "" : reader.GetString(4), + reader.IsDBNull(5) ? "" : reader.GetString(5), + reader.IsDBNull(6) ? 0 : reader.GetInt64(6), + reader.IsDBNull(7) ? 0 : reader.GetInt64(7), + reader.IsDBNull(8) ? 0 : reader.GetInt64(8), + reader.IsDBNull(9) ? 0 : reader.GetInt64(9), + reader.IsDBNull(10) ? 0 : reader.GetInt64(10), + reader.IsDBNull(11) ? 0 : reader.GetInt64(11), + reader.IsDBNull(12) ? 0 : reader.GetInt64(12), + reader.IsDBNull(13) ? 0 : reader.GetInt64(13), + reader.IsDBNull(14) ? 0 : reader.GetInt64(14), + reader.IsDBNull(15) ? 0 : reader.GetInt64(15), + reader.IsDBNull(16) ? 0 : reader.GetInt64(16))); + } + + return rows; + } + + /* ─────────────────────────── query store ─────────────────────────── */ + + /// + /// Top Query Store groups over the window — a focused projection of the viewer's + /// QueryStoreTopSql (the columns Lite's get_query_store_top returns): group by + /// (database, query_id, plan_id, query_hash, replica_role), average the per-interval metrics, rank by total duration + /// (SUM(execution_count) * AVG(avg_duration_us)) descending, over-fetch by 5 for the WAITFOR + /// trim, cap at top. The avg columns are bigint (per-interval averages) → double precision before the + /// AVG/scale. Reads the base query_store_stats table (no v_ view). $1 server_id, $2/$3 window + /// (naive UTC), $4 top. + /// + public const string QueryStoreTopSql = """ + WITH ranked AS ( + SELECT + database_name, + query_id, + plan_id, + query_hash, + /* A GROUP BY key, not MAX(): an AG's Query Store for secondary replicas (2022+) keeps ONE + shared store on the primary holding every replica's rows, so grouping without it would + average primary and secondary workload into a single blended row. No-op on a + standalone/non-AG server, where every row shares one value. */ + replica_role, + CAST(SUM(execution_count) AS bigint) AS total_executions, + AVG(CAST(avg_duration_us AS double precision)) / 1000.0 AS avg_duration_ms, + AVG(CAST(avg_cpu_time_us AS double precision)) / 1000.0 AS avg_cpu_time_ms, + AVG(CAST(avg_logical_io_reads AS double precision)) AS avg_logical_reads, + AVG(CAST(avg_logical_io_writes AS double precision)) AS avg_logical_writes, + AVG(CAST(avg_physical_io_reads AS double precision)) AS avg_physical_reads, + AVG(CAST(avg_rowcount AS double precision)) AS avg_rowcount, + MAX(last_execution_time) AS last_execution_time, + MAX(query_plan_hash) AS query_plan_hash + FROM query_store_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND ($5::text IS NULL OR database_name = $5) + GROUP BY database_name, query_id, plan_id, query_hash, replica_role + ORDER BY SUM(execution_count) * AVG(CAST(avg_duration_us AS double precision)) DESC + LIMIT $4 + 5 + ) + SELECT + r.database_name, + r.query_id, + r.plan_id, + r.query_hash, + r.query_plan_hash, + r.total_executions, + r.avg_duration_ms, + r.avg_cpu_time_ms, + r.avg_logical_reads, + r.avg_logical_writes, + r.avg_physical_reads, + r.avg_rowcount, + r.last_execution_time, + t.query_text, + r.replica_role + FROM ranked AS r + LEFT JOIN LATERAL ( + SELECT query_text + FROM query_store_stats + WHERE server_id = $1 + AND query_id = r.query_id + AND database_name = r.database_name + AND query_text IS NOT NULL + ORDER BY collection_time DESC + LIMIT 1 + ) AS t ON TRUE + WHERE t.query_text IS NULL OR t.query_text NOT LIKE 'WAITFOR%' + ORDER BY r.total_executions * r.avg_duration_ms DESC + LIMIT $4 + """; + + public static async Task> GetQueryStoreTopAsync( + NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, int top, string? databaseName, CancellationToken cancellationToken = default) + { + var rows = new List(); + await using var command = postgres.CreateCommand(QueryStoreTopSql); + AddWindow(command, serverId, startUtc, endUtc); + AddInt(command, top); + AddNullableText(command, databaseName); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new QueryStoreRow( + reader.IsDBNull(0) ? "" : reader.GetString(0), + reader.IsDBNull(1) ? 0 : reader.GetInt64(1), + reader.IsDBNull(2) ? 0 : reader.GetInt64(2), + reader.IsDBNull(3) ? "" : reader.GetString(3), + reader.IsDBNull(4) ? "" : reader.GetString(4), + reader.IsDBNull(5) ? 0 : reader.GetInt64(5), + reader.IsDBNull(6) ? 0 : reader.GetDouble(6), + reader.IsDBNull(7) ? 0 : reader.GetDouble(7), + reader.IsDBNull(8) ? 0 : reader.GetDouble(8), + reader.IsDBNull(9) ? 0 : reader.GetDouble(9), + reader.IsDBNull(10) ? 0 : reader.GetDouble(10), + reader.IsDBNull(11) ? 0 : reader.GetDouble(11), + reader.IsDBNull(12) ? null : reader.GetDateTime(12), + reader.IsDBNull(13) ? "" : reader.GetString(13), + reader.IsDBNull(14) ? null : reader.GetString(14))); + } + + return rows; + } + + /* ─────────────────────────── discovery / health ─────────────────────────── */ + + /// + /// Every enabled server plus its newest collection instant — the list_servers read. The + /// correlated MAX(collection_time) per server drives the freshness-derived status the tool + /// assigns (the headless viewer has no live ping either — see ServerSummaryItem.ClassifyFreshness). + /// $-free (no parameters, no bare now()) so a test can pin the dialect ungated. + /// + public const string ServerListSql = """ + SELECT + s.server_id, + s.server_name, + s.display_name, + s.sql_major_version, + (SELECT MAX(cl.collection_time) FROM v_collection_log cl WHERE cl.server_id = s.server_id) AS last_collection + FROM servers s + WHERE s.is_enabled + ORDER BY s.server_name + """; + + public static async Task> GetServerListAsync( + NpgsqlDataSource postgres, CancellationToken cancellationToken = default) + { + var rows = new List(); + await using var command = postgres.CreateCommand(ServerListSql); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new ServerListRow( + reader.GetInt32(0), + reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetString(2), + reader.IsDBNull(3) ? null : reader.GetInt32(3), + reader.IsDBNull(4) ? null : reader.GetDateTime(4))); + } + + return rows; + } + + /// + /// Per-collector 7-day health aggregate — the viewer's CollectionHealthSql (Lite's + /// GetCollectionHealthAsync): one row per collector with run/success/error counts, average + /// duration, last success/run/error timestamps, and the permission-denied count for the banding. + /// SKIPPED counts as a healthy run. $1 server_id, $2 window start (naive UTC — the trailing 7 days). + /// + public const string CollectionHealthSql = """ + SELECT + collector_name, + COUNT(*) AS total_runs, + SUM(CASE WHEN status = 'SUCCESS' THEN 1 ELSE 0 END) AS success_count, + SUM(CASE WHEN status = 'ERROR' THEN 1 ELSE 0 END) AS error_count, + AVG(duration_ms) AS avg_duration_ms, + MAX(CASE WHEN status IN ('SUCCESS', 'SKIPPED') THEN collection_time END) AS last_success_time, + MAX(collection_time) AS last_run_time, + MAX(CASE WHEN status IN ('ERROR', 'PERMISSIONS') THEN error_message END) AS last_error, + MAX(CASE WHEN status IN ('ERROR', 'PERMISSIONS') THEN collection_time END) AS last_error_time, + SUM(CASE WHEN status = 'PERMISSIONS' THEN 1 ELSE 0 END) AS permission_denied_count + FROM v_collection_log + WHERE server_id = $1 + AND collection_time >= $2 + GROUP BY collector_name + ORDER BY collector_name + """; + + public static async Task> GetCollectionHealthAsync( + NpgsqlDataSource postgres, int serverId, DateTime windowStartUtc, CancellationToken cancellationToken = default) + { + var rows = new List(); + await using var command = postgres.CreateCommand(CollectionHealthSql); + AddInt(command, serverId); + AddTimestamp(command, windowStartUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new CollectorHealth + { + CollectorName = reader.GetString(0), + TotalRuns = reader.IsDBNull(1) ? 0 : Convert.ToInt64(reader.GetValue(1)), + SuccessCount = reader.IsDBNull(2) ? 0 : Convert.ToInt64(reader.GetValue(2)), + ErrorCount = reader.IsDBNull(3) ? 0 : Convert.ToInt64(reader.GetValue(3)), + AvgDurationMs = reader.IsDBNull(4) ? 0 : Convert.ToDouble(reader.GetValue(4)), + LastSuccessTime = reader.IsDBNull(5) ? null : reader.GetDateTime(5), + LastRunTime = reader.IsDBNull(6) ? null : reader.GetDateTime(6), + LastError = reader.IsDBNull(7) ? null : reader.GetString(7), + LastErrorTime = reader.IsDBNull(8) ? null : reader.GetDateTime(8), + PermissionDeniedCount = reader.IsDBNull(9) ? 0 : Convert.ToInt64(reader.GetValue(9)), + }); + } + + return rows; + } + + /// + /// The latest server_properties snapshot — Lite's GetLatestServerPropertiesAsync. Reads the + /// base server_properties table (the store has NO v_server_properties view — the viewer + /// reads the base table for its UTC-offset read too). $1 server_id. + /// + public const string LatestServerPropertiesSql = """ + SELECT + collection_time, + edition, + product_version, + product_level, + product_update_level, + engine_edition, + cpu_count, + hyperthread_ratio, + physical_memory_mb, + socket_count, + cores_per_socket, + is_hadr_enabled, + is_clustered, + enterprise_features, + service_objective + FROM server_properties + WHERE server_id = $1 + ORDER BY collection_time DESC + LIMIT 1 + """; + + public static async Task GetLatestServerPropertiesAsync( + NpgsqlDataSource postgres, int serverId, CancellationToken cancellationToken = default) + { + await using var command = postgres.CreateCommand(LatestServerPropertiesSql); + AddInt(command, serverId); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + { + return null; + } + + return new ServerPropertiesReadRow( + reader.GetDateTime(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + reader.IsDBNull(2) ? "" : reader.GetString(2), + reader.IsDBNull(3) ? "" : reader.GetString(3), + reader.IsDBNull(4) ? null : reader.GetString(4), + reader.IsDBNull(5) ? 0 : reader.GetInt32(5), + reader.IsDBNull(6) ? 0 : reader.GetInt32(6), + reader.IsDBNull(7) ? 0 : reader.GetInt32(7), + reader.IsDBNull(8) ? 0 : reader.GetInt64(8), + reader.IsDBNull(9) ? 0 : reader.GetInt32(9), + reader.IsDBNull(10) ? 0 : reader.GetInt32(10), + !reader.IsDBNull(11) && reader.GetBoolean(11), + !reader.IsDBNull(12) && reader.GetBoolean(12), + reader.IsDBNull(13) ? null : reader.GetString(13), + reader.IsDBNull(14) ? null : reader.GetString(14)); + } + + /* ─────────────────────────── parameter helpers ─────────────────────────── */ + + private static void AddWindow(NpgsqlCommand command, int serverId, DateTime startUtc, DateTime endUtc) + { + AddInt(command, serverId); + AddTimestamp(command, startUtc); + AddTimestamp(command, endUtc); + } + + private static void AddInt(NpgsqlCommand command, int value) => + command.Parameters.Add(new NpgsqlParameter { TypedValue = value }); + + private static void AddText(NpgsqlCommand command, string value) => + command.Parameters.Add(new NpgsqlParameter { TypedValue = value ?? "" }); + + /// Binds a nullable text filter (a null value binds SQL NULL, activating the read's + /// $N::text IS NULL OR ... "no filter" branch). + private static void AddNullableText(NpgsqlCommand command, string? value) => + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Text, Value = (object?)value ?? DBNull.Value }); + + /// Binds a naive-UTC timestamp: Kind=Unspecified maps to the store's timestamp without + /// time zone columns (a Kind=Utc DateTime maps to timestamptz and throws since Npgsql 6.0). + private static void AddTimestamp(NpgsqlCommand command, DateTime value) => + command.Parameters.Add(new NpgsqlParameter { TypedValue = DateTime.SpecifyKind(value, DateTimeKind.Unspecified) }); +} + +/// +/// One collector's 7-day health roll-up with its health band — a faithful service-side port of the +/// viewer's CollectorHealthRow (itself Lite's), carrying just the fields the MCP +/// get_collection_health tool surfaces plus the computed / failure rate. +/// delegates to the shared in +/// PerformanceMonitor.Common (#1573), so this service, Lite, and the viewer band identically and cannot +/// drift; it resolves the collector's cadence from the shared so a +/// healthy DAILY collector is no longer flagged stale/failing on the frequent-collector thresholds. +/// arithmetic is correct against the store's naive-UTC timestamps because +/// both are UTC instants (tick subtraction ignores Kind). +/// +internal sealed class CollectorHealth +{ + public string CollectorName { get; set; } = ""; + public long TotalRuns { get; set; } + public long SuccessCount { get; set; } + public long ErrorCount { get; set; } + public double AvgDurationMs { get; set; } + public DateTime? LastSuccessTime { get; set; } + public DateTime? LastRunTime { get; set; } + public string? LastError { get; set; } + public DateTime? LastErrorTime { get; set; } + public long PermissionDeniedCount { get; set; } + + public double FailureRatePercent => TotalRuns > 0 ? (double)ErrorCount / TotalRuns * 100 : 0; + + public double HoursSinceLastSuccess => LastSuccessTime.HasValue + ? (DateTime.UtcNow - LastSuccessTime.Value).TotalHours + : 999; + + /// The collector's default cadence from the shared + /// (0 for an on-load or unknown collector — both fall to the floor thresholds). The banding uses the + /// shipped default, not the resolved per-server override, so all three surfaces stay in parity. + private int FrequencyMinutes => + CollectorScheduleDefaults.All.TryGetValue(CollectorName, out var schedule) ? schedule.FrequencyMinutes : 0; + + public string HealthStatus => CollectorHealthClassifier.Classify( + TotalRuns, SuccessCount, ErrorCount, PermissionDeniedCount, + HoursSinceLastSuccess, FrequencyMinutes, CollectorHealthClassifier.IsOnLoadCollector(CollectorName)); +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDefaultTraceReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDefaultTraceReader.cs new file mode 100644 index 000000000..02868334d --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDefaultTraceReader.cs @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// Service-side read for the get_default_trace_events MCP tool — the stored Default Trace events the +/// shared collector captured from the monitored server's built-in trace (sys.fn_trace_gettable). +/// A STORED read (no live monitored-server hit) windowed on event_time (the trace StartTime — the +/// event's real time). Reads the BASE default_trace_events table directly (like server_properties, +/// it has no v_* passthrough view — no Lite analysis SQL ports through it); the bare name resolves +/// through the store's search_path = collect, config, public. The SQL is a public const so +/// Darling.Tests can pin the dialect + columns without a live Postgres. +/// +/// Server-local timestamps (load-bearing): unlike the XE collectors (deadlock / system_health), +/// whose event_time is the UTC XE @timestamp, the Default Trace StartTime — and thus this +/// table's event_time — is the monitored server's LOCAL wall-clock time (the .trc files store local +/// time; the Dashboard windows it against SYSDATETIME(), server-local). Storing it raw keeps the +/// collector's dedup watermark bulletproof (local StartTime vs a local watermark, no conversion). So this +/// monitor-side read, whose window bounds arrive in UTC, converts them to the server's local frame INSIDE the +/// query by the collected server_properties.utc_offset_minutes (V16 — the same offset the viewer uses +/// for server-local display); a server with no offset yet collected falls back to 0 (treat local == UTC), and +/// the single-row COALESCE CTE guarantees the cross join never drops the events. +/// +internal static class DarlingDefaultTraceReader +{ + /// One stored Default Trace event row (the fields the MCP tool surfaces + gates on). + public sealed record DefaultTraceEventRow( + DateTime? EventTime, + string? EventName, + int? EventClass, + int? Spid, + string? DatabaseName, + string? LoginName, + string? HostName, + string? ApplicationName, + string? ObjectName, + string? Filename, + string? TextData, + int? ErrorNumber, + int? Severity, + long? DurationUs, + long? IntegerData); + + /// + /// Stored Default Trace events for the window, newest first. Windows on event_time (the trace + /// StartTime — server-LOCAL), NOT collection_time, so "last 24 hours" means events that HAPPENED in the + /// last 24 hours. The window bounds ($2/$3) arrive as naive UTC and are converted to the server's LOCAL + /// frame in-query by the collected utc_offset_minutes (single-row COALESCE CTE — 0 when none is + /// collected yet, and the cross join keeps every event). $1 server_id, $2/$3 window (naive UTC). Reads the + /// base tables (no v_* views). + /// + public const string EventsByWindowSql = """ + WITH svr AS ( + SELECT COALESCE(( + SELECT sp.utc_offset_minutes + FROM server_properties AS sp + WHERE sp.server_id = $1 + AND sp.utc_offset_minutes IS NOT NULL + ORDER BY sp.collection_time DESC + LIMIT 1), 0) AS offset_minutes + ) + SELECT + dte.event_time, + dte.event_name, + dte.event_class, + dte.spid, + dte.database_name, + dte.login_name, + dte.host_name, + dte.application_name, + dte.object_name, + dte.filename, + dte.text_data, + dte.error_number, + dte.severity, + dte.duration_us, + dte.integer_data + FROM default_trace_events AS dte, svr + WHERE dte.server_id = $1 + AND dte.event_time >= $2 + make_interval(mins => svr.offset_minutes) + AND dte.event_time <= $3 + make_interval(mins => svr.offset_minutes) + ORDER BY dte.event_time DESC + """; + + /// Reads the stored Default Trace event rows over the window (newest first). + public static async Task> ReadEventsAsync( + NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var rows = new List(); + + await using var command = postgres.CreateCommand(EventsByWindowSql); + DarlingMcpReadParameters.AddWindow(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new DefaultTraceEventRow( + reader.IsDBNull(0) ? null : reader.GetDateTime(0), + reader.IsDBNull(1) ? null : reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetInt32(2), + reader.IsDBNull(3) ? null : reader.GetInt32(3), + reader.IsDBNull(4) ? null : reader.GetString(4), + reader.IsDBNull(5) ? null : reader.GetString(5), + reader.IsDBNull(6) ? null : reader.GetString(6), + reader.IsDBNull(7) ? null : reader.GetString(7), + reader.IsDBNull(8) ? null : reader.GetString(8), + reader.IsDBNull(9) ? null : reader.GetString(9), + reader.IsDBNull(10) ? null : reader.GetString(10), + reader.IsDBNull(11) ? null : reader.GetInt32(11), + reader.IsDBNull(12) ? null : reader.GetInt32(12), + reader.IsDBNull(13) ? null : reader.GetInt64(13), + reader.IsDBNull(14) ? null : reader.GetInt64(14))); + } + + return rows; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs new file mode 100644 index 000000000..0b5122559 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs @@ -0,0 +1,798 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// The Fleet-wide NOC roll-up read (#1562) — the enriched per-server Overview CARD and the cross-server rollup +/// lifted out of the WPF-only ViewerDataService.Overview.cs / .Fleet.cs into a service-side reader +/// so the SAME reads power the web dashboard's /api/fleet, the get_fleet_overview MCP tool, and +/// (via the shared banding) the WPF viewer. STORED reads only, no live +/// monitored-server hit. +/// +/// Scale lens (the plan's R6). The WPF Overview fans out N servers x ~8 per-server reads on every +/// refresh; that does not scale to a 500-server central store. Because Darling keys every row by +/// server_id in ONE Postgres database, this reader instead runs a BOUNDED set of cross-server aggregates +/// (one DISTINCT ON (server_id) latest-snapshot read per metric, one GROUP BY server_id windowed +/// count per incident source, one cross-server collection-health aggregate) — a fixed ~9 round-trips regardless +/// of fleet size — then assembles the per-server cards in C#. +/// +/// Banding lives once. Every band (per-metric severity, the card's overall band, collection +/// freshness, the fleet band + worst-first score) comes from in +/// PerformanceMonitor.Common — the SAME classifier the WPF cards use — so the thresholds are defined in exactly +/// one place. The fleet blocking / deadlock totals are the SUM of the per-server card counts (each already +/// applying Lite's per-server XE-preferred / DMV-fallback rule), so the totals reconcile with the cards by +/// construction; no separate totals query is needed. +/// +internal static class DarlingFleetReader +{ + /// Shared serializer options for the fleet DTOs — snake_case field names come from the DTOs' + /// [JsonPropertyName] attributes, enum bands serialize as their string names, and the output is + /// indented (matching the MCP tool convention). ONE options object so the web endpoint and the MCP tool + /// serialize the identical shape. + public static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + Converters = { new JsonStringEnumConverter() }, + }; + + /* ─────────────────────────── cross-server SQL (public for dialect pinning) ─────────────────────────── */ + + /// The enabled fleet — the servers the roll-up cards cover, from the registry the worker upserts on + /// each first connect (the same source DarlingServerResolver resolves against, so every card is + /// drillable via /api/read/{tool}?server=). sql_engine_edition is the raw probed + /// SERVERPROPERTY('EngineEdition') the worker stamped on connect (5 = Azure SQL DB, 8 = Azure MI, box + /// editions otherwise), the reliable per-server platform signal the composer's D4 auto-greying keys on; + /// nullable when a server has not yet connected. $ none. + public const string FleetServersSql = @" +SELECT server_id, COALESCE(display_name, server_name) AS display_name, server_name, sql_engine_edition +FROM servers +WHERE is_enabled +ORDER BY server_name"; + + /// Latest SQL + other-process CPU per server (newest ring-buffer sample). $ none. + public const string FleetCpuSql = @" +SELECT DISTINCT ON (server_id) + server_id, + sqlserver_cpu_utilization, + other_process_cpu_utilization +FROM v_cpu_utilization_stats +ORDER BY server_id, sample_time DESC"; + + /// Latest total server memory + buffer pool (MB) per server. $ none. + public const string FleetMemorySql = @" +SELECT DISTINCT ON (server_id) + server_id, + CAST(total_server_memory_mb AS double precision), + CAST(buffer_pool_mb AS double precision) +FROM v_memory_stats +ORDER BY server_id, collection_time DESC"; + + /// Latest resource-semaphore pressure per server — grant waiters / timeout + forced-grant deltas / + /// granted MB, summed across every pool at each server's newest grant-snapshot instant. $ none. + public const string FleetMemoryPressureSql = @" +SELECT + m.server_id, + CAST(COALESCE(SUM(m.waiter_count), 0) AS bigint), + CAST(COALESCE(SUM(m.timeout_error_count_delta), 0) AS bigint), + CAST(COALESCE(SUM(m.forced_grant_count_delta), 0) AS bigint), + CAST(COALESCE(SUM(m.granted_memory_mb), 0) AS double precision) +FROM v_memory_grant_stats m +JOIN +( + SELECT server_id, MAX(collection_time) AS max_collection_time + FROM v_memory_grant_stats + GROUP BY server_id +) latest + ON m.server_id = latest.server_id + AND m.collection_time = latest.max_collection_time +GROUP BY m.server_id"; + + /// Latest worker-thread pressure per server (newest cpu_scheduler_stats snapshot). $ none. + public const string FleetThreadsSql = @" +SELECT DISTINCT ON (server_id) + server_id, + max_workers_count, + total_current_workers_count, + total_runnable_tasks_count, + total_work_queue_count +FROM v_cpu_scheduler_stats +ORDER BY server_id, collection_time DESC"; + + /// Blocking in the window per server from BOTH sources — XE blocked-process reports and the always-on + /// DMV snapshot, each counted per server_id with its worst wait, lined up by a FULL OUTER JOIN so a + /// server present in only one source still appears. The caller applies Lite's XE-preferred / DMV-fallback per + /// server. $1 window start, $2 window end (both naive UTC). + public const string FleetBlockingSql = @" +SELECT + COALESCE(xe.server_id, dmv.server_id) AS server_id, + COALESCE(xe.cnt, 0) AS xe_count, + COALESCE(xe.max_wait, 0) AS xe_max_wait, + COALESCE(dmv.cnt, 0) AS dmv_count, + COALESCE(dmv.max_wait, 0) AS dmv_max_wait +FROM +( + SELECT server_id, COUNT(*) AS cnt, MAX(wait_time_ms) AS max_wait + FROM v_blocked_process_reports + WHERE event_time >= $1 + AND event_time <= $2 + GROUP BY server_id +) AS xe +FULL OUTER JOIN +( + SELECT server_id, COUNT(*) AS cnt, MAX(wait_time_ms) AS max_wait + FROM v_dmv_blocking_snapshots + WHERE event_time >= $1 + AND event_time <= $2 + GROUP BY server_id +) AS dmv ON xe.server_id = dmv.server_id"; + + /// Deadlocks in the window per server — count and newest deadlock instant (for each card's + /// "last seen" detail). $1 window start, $2 window end (both naive UTC). + public const string FleetDeadlockSql = @" +SELECT server_id, COUNT(*) AS cnt, MAX(deadlock_time) AS last_seen +FROM v_deadlocks +WHERE deadlock_time >= $1 +AND deadlock_time <= $2 +GROUP BY server_id"; + + /// Newest collection time per server — drives each card's freshness status. $ none. + public const string FleetLastCollectionSql = @" +SELECT server_id, MAX(collection_time) AS last_collection_time +FROM v_collection_log +GROUP BY server_id"; + + /// Cross-server per-collector 7-day health aggregate — one row per (server, collector) pair carrying + /// the columns the shared CollectorHealth.HealthStatus banding needs, so the caller counts each + /// server's FAILING collectors exactly as the per-server Collection Health tab does. $1 window start (the + /// trailing 7 days, naive UTC). + public const string FleetCollectionHealthSql = @" +SELECT + server_id, + collector_name, + COUNT(*) AS total_runs, + SUM(CASE WHEN status = 'SUCCESS' THEN 1 ELSE 0 END) AS success_count, + SUM(CASE WHEN status = 'ERROR' THEN 1 ELSE 0 END) AS error_count, + MAX(CASE WHEN status IN ('SUCCESS', 'SKIPPED') THEN collection_time END) AS last_success_time, + SUM(CASE WHEN status = 'PERMISSIONS' THEN 1 ELSE 0 END) AS permission_denied_count +FROM v_collection_log +WHERE collection_time >= $1 +GROUP BY server_id, collector_name"; + + /// The default depth of the worst-first "Needs attention" ranking. + public const int DefaultWorstCount = 5; + + /* ─────────────────────────── the read ─────────────────────────── */ + + /// + /// Rolls the whole enabled fleet up in a bounded set of cross-server reads: the pre-banded per-server cards, + /// the band counts, the cross-server blocking / deadlock totals (summed from the cards), and the worst-first + /// ranking. .. bound the blocking / deadlock + /// counts (the caller passes the same window the cards imply); is the freshness + /// reference (defaults to ). + /// + public static async Task GetFleetOverviewAsync( + NpgsqlDataSource postgres, + DateTime windowStartUtc, + DateTime windowEndUtc, + DateTime? nowUtc = null, + int worstCount = DefaultWorstCount, + CancellationToken cancellationToken = default) + { + var now = nowUtc ?? DateTime.UtcNow; + + var servers = await ReadServersAsync(postgres, cancellationToken); + + var cpu = await ReadCpuAsync(postgres, cancellationToken); + var memory = await ReadMemoryAsync(postgres, cancellationToken); + var memoryPressure = await ReadMemoryPressureAsync(postgres, cancellationToken); + var threads = await ReadThreadsAsync(postgres, cancellationToken); + var blocking = await ReadBlockingAsync(postgres, windowStartUtc, windowEndUtc, cancellationToken); + var deadlocks = await ReadDeadlocksAsync(postgres, windowStartUtc, windowEndUtc, cancellationToken); + var lastCollection = await ReadLastCollectionAsync(postgres, cancellationToken); + var failingCollectors = await ReadFailingCollectorCountsAsync(postgres, now, cancellationToken); + + var cards = new List(servers.Count); + foreach (var server in servers) + { + cpu.TryGetValue(server.ServerId, out var c); + memory.TryGetValue(server.ServerId, out var m); + memoryPressure.TryGetValue(server.ServerId, out var mp); + threads.TryGetValue(server.ServerId, out var t); + blocking.TryGetValue(server.ServerId, out var b); + deadlocks.TryGetValue(server.ServerId, out var deadlock); + lastCollection.TryGetValue(server.ServerId, out var lastColl); + failingCollectors.TryGetValue(server.ServerId, out var collectors); + + cards.Add(BuildCard(server, c, m, mp, t, b, deadlock, lastColl, collectors, now)); + } + + return BuildRollup(cards, now, windowStartUtc, windowEndUtc, worstCount); + } + + /// Builds one pre-banded card from a server's raw cross-server reads (pure — the reduction the WPF + /// ServerSummaryItem does, minus the brushes, over the shared classifier). + private static FleetServerCard BuildCard( + FleetServerRow server, + CpuRow cpu, + MemoryRow memory, + MemoryPressureRow pressure, + ThreadsRow threads, + BlockingRow blocking, + DeadlockRow deadlock, + DateTime? lastCollection, + CollectorCounts collectors, + DateTime now) + { + var deadlockCount = deadlock.Count; + + /* Lite's XE-preferred / DMV-fallback, per server: XE when it has any row this window, else the DMV + snapshot — both count and worst-wait come from whichever source wins. */ + var blockingCount = blocking.XeCount > 0 ? blocking.XeCount : blocking.DmvCount; + var maxBlockingWaitMs = blocking.XeCount > 0 ? blocking.XeMaxWait : blocking.DmvMaxWait; + + var cpuPercent = cpu.SqlCpu; + var otherCpu = cpu.OtherCpu; + var totalCpu = cpuPercent.HasValue ? cpuPercent.Value + (otherCpu ?? 0) : (double?)null; + var cpuForAlert = totalCpu ?? cpuPercent; + + var availableThreads = threads.TotalThreads.HasValue + ? threads.TotalThreads.Value - (threads.CurrentWorkers ?? 0) + : (int?)null; + + var hasMemoryPressure = pressure.WaiterCount > 0 || pressure.TimeoutCount > 0 || pressure.ForcedCount > 0; + var maxBlockedSeconds = maxBlockingWaitMs / 1000.0; + + var metrics = new ServerHealthMetrics + { + CpuPercentForAlert = cpuForAlert, + HasMemoryPressure = hasMemoryPressure, + BlockingCount = blockingCount, + MaxBlockedSeconds = maxBlockedSeconds, + DeadlockCount = deadlockCount, + TotalThreads = threads.TotalThreads, + AvailableThreads = availableThreads, + ThreadsWaitingForCpu = threads.RunnableTasks, + RequestsWaitingForThreads = threads.WorkQueue, + FailedCollectorCount = collectors.Failing, + }; + + /* Freshness -> the card's connection state, exactly as the WPF card's ApplyFreshness. */ + var freshness = ServerHealthClassifier.ClassifyFreshness(lastCollection, now); + bool? isOnline; + bool awaitingFirstCollection; + bool hasCollectorErrors; + if (freshness == ServerFreshness.NeverCollected) + { + isOnline = null; + awaitingFirstCollection = true; + hasCollectorErrors = false; + } + else + { + isOnline = freshness != ServerFreshness.Offline; + awaitingFirstCollection = false; + hasCollectorErrors = freshness == ServerFreshness.Stale; + } + + var overall = ServerHealthClassifier.OverallMetricSeverity(metrics); + var band = ServerHealthClassifier.ClassifyBand(isOnline, awaitingFirstCollection, hasCollectorErrors, overall); + + /* Per-server platform (design D4): the reliable signal the composer's measure auto-greying matches a + measure's appliesTo against — see ClassifyPlatform for the edition mapping and why AWS RDS / msdb are + deliberately not surfaced. */ + var (isAzureSqlDb, isAzureManagedInstance) = ClassifyPlatform(server.EngineEdition); + + return new FleetServerCard + { + ServerId = server.ServerId, + DisplayName = server.DisplayName, + ServerName = server.ServerName, + EngineEdition = server.EngineEdition, + IsAzureSqlDb = isAzureSqlDb, + IsAzureManagedInstance = isAzureManagedInstance, + Band = band, + Status = StatusLabel(isOnline, awaitingFirstCollection, hasCollectorErrors), + IsOnline = isOnline, + AwaitingFirstCollection = awaitingFirstCollection, + HasCollectorErrors = hasCollectorErrors, + LastCollectionTime = lastCollection, + CpuPercent = cpuPercent, + OtherProcessCpuPercent = otherCpu, + TotalCpuPercent = totalCpu, + CpuSeverity = ServerHealthClassifier.CpuSeverity(cpuForAlert), + MemoryMb = memory.MemoryMb, + BufferPoolMb = memory.BufferPoolMb, + GrantedMemoryMb = pressure.GrantedMemoryMb, + MemoryWaiterCount = pressure.WaiterCount, + MemoryTimeoutCount = pressure.TimeoutCount, + MemoryForcedCount = pressure.ForcedCount, + HasMemoryPressure = hasMemoryPressure, + MemorySeverity = ServerHealthClassifier.MemorySeverity(hasMemoryPressure), + BlockingCount = blockingCount, + MaxBlockingWaitMs = maxBlockingWaitMs, + BlockingSeverity = ServerHealthClassifier.BlockingSeverity(blockingCount, maxBlockedSeconds), + DeadlockCount = deadlockCount, + DeadlockLastSeen = deadlock.LastSeen, + DeadlockSeverity = ServerHealthClassifier.DeadlockSeverity(deadlockCount), + TotalThreads = threads.TotalThreads, + CurrentWorkers = threads.CurrentWorkers, + AvailableThreads = availableThreads, + ThreadsWaitingForCpu = threads.RunnableTasks, + RequestsWaitingForThreads = threads.WorkQueue, + ThreadsSeverity = ServerHealthClassifier.ThreadsSeverity(threads.TotalThreads, availableThreads, threads.RunnableTasks, threads.WorkQueue), + HealthyCollectorCount = collectors.Healthy, + FailedCollectorCount = collectors.Failing, + CollectorSeverity = ServerHealthClassifier.CollectorSeverity(collectors.Failing), + OverallMetricSeverity = overall, + }; + } + + /// Reduces the pre-banded cards to the fleet rollup — band counts, cross-server totals (summed from + /// the cards), and the worst-first ranking. Pure so the reduction is unit-testable without a store. + public static FleetOverviewResult BuildRollup( + IReadOnlyList cards, + DateTime now, + DateTime windowStartUtc, + DateTime windowEndUtc, + int worstCount = DefaultWorstCount) + { + var healthy = 0; + var warning = 0; + var critical = 0; + var offline = 0; + var failures = 0; + long totalBlocking = 0; + long totalDeadlocks = 0; + + foreach (var card in cards) + { + switch (card.Band) + { + case FleetHealthBand.Offline: offline++; break; + case FleetHealthBand.Critical: critical++; break; + case FleetHealthBand.Warning: warning++; break; + default: healthy++; break; + } + + if (card.FailedCollectorCount > 0) + { + failures++; + } + + totalBlocking += card.BlockingCount; + totalDeadlocks += card.DeadlockCount; + } + + var problems = cards + .Where(c => c.Band != FleetHealthBand.Healthy) + .OrderByDescending(c => ServerHealthClassifier.FleetHealthScore(c.Band, c.ToHealthMetrics())) + .ThenBy(c => c.DisplayName, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var worst = problems + .Take(worstCount) + .Select(c => new FleetRankedServer + { + ServerId = c.ServerId, + DisplayName = c.DisplayName, + Band = c.Band, + Score = ServerHealthClassifier.FleetHealthScore(c.Band, c.ToHealthMetrics()), + Reason = BuildReason(c), + }) + .ToList(); + + return new FleetOverviewResult + { + /* Emit every instant as naive UTC (Kind=Unspecified) so the JSON carries no zone suffix — the + house rule is localize-only-in-the-browser (#1562 R5). */ + GeneratedAt = DateTime.SpecifyKind(now, DateTimeKind.Unspecified), + WindowStart = DateTime.SpecifyKind(windowStartUtc, DateTimeKind.Unspecified), + WindowEnd = DateTime.SpecifyKind(windowEndUtc, DateTimeKind.Unspecified), + TotalServers = cards.Count, + HealthyCount = healthy, + WarningCount = warning, + CriticalCount = critical, + OfflineCount = offline, + ServersWithCollectionFailures = failures, + TotalBlockingEvents = totalBlocking, + TotalDeadlocks = totalDeadlocks, + WorstServers = worst, + AdditionalProblemCount = Math.Max(0, problems.Count - worst.Count), + Cards = cards, + }; + } + + /// A short "why it needs attention" line for a ranked server, from the card's own banded metrics — + /// mirrors the WPF FleetRollup.BuildReason content over the pre-banded card. + private static string BuildReason(FleetServerCard c) + { + if (c.IsOnline == false) + { + return "Offline - no recent collection"; + } + + if (c.AwaitingFirstCollection) + { + return "Awaiting first collection"; + } + + var parts = new List(); + + if (c.CpuSeverity >= HealthSeverity.Warning && c.TotalCpuPercent.HasValue) + { + parts.Add($"CPU {c.TotalCpuPercent.Value:F0}%"); + } + + if (c.ThreadsSeverity >= HealthSeverity.Warning) + { + parts.Add(c.RequestsWaitingForThreads > 0 + ? $"Threads {c.RequestsWaitingForThreads} starved" + : "Threads low"); + } + + if (c.MemorySeverity >= HealthSeverity.Warning && c.HasMemoryPressure) + { + parts.Add($"Memory {c.MemoryWaiterCount} grant waiter{(c.MemoryWaiterCount == 1 ? "" : "s")}"); + } + + if (c.BlockingSeverity >= HealthSeverity.Warning && c.BlockingCount > 0) + { + parts.Add($"Blocking {c.BlockingCount}"); + } + + if (c.DeadlockSeverity >= HealthSeverity.Warning && c.DeadlockCount > 0) + { + parts.Add($"Deadlocks {c.DeadlockCount}"); + } + + if (c.CollectorSeverity >= HealthSeverity.Warning) + { + parts.Add($"{c.FailedCollectorCount} collector{(c.FailedCollectorCount == 1 ? "" : "s")} failing"); + } + + if (c.HasCollectorErrors) + { + parts.Add("collection stale"); + } + + return parts.Count > 0 ? string.Join(", ", parts) : "Needs attention"; + } + + private static string StatusLabel(bool? isOnline, bool awaitingFirstCollection, bool hasCollectorErrors) => isOnline switch + { + true when hasCollectorErrors => "Warning", + true => "Online", + false => "Offline", + _ => awaitingFirstCollection ? "Awaiting first collection" : "Unknown", + }; + + /// + /// Classifies a server's raw SERVERPROPERTY('EngineEdition') into the RELIABLE per-server platform flags the + /// composer's D4 measure auto-greying keys on: 5 = Azure SQL Database, 8 = Azure Managed + /// Instance (the same 5/8 classification applies at connect). Any other + /// edition — a box edition (2/3/4…), or null (a server the worker has not yet connected/probed) — is + /// neither, so the composer keeps the measure badge (no signal rather than a wrong one). + /// + /// AWS RDS and msdb access are deliberately NOT returned: unlike engine edition they are not persisted on + /// the servers registry (RDS reports as an ordinary box edition, and HAS_DBACCESS('msdb') is + /// probed but never stored), so there is no reliable stored signal to derive them from here. + /// + internal static (bool IsAzureSqlDb, bool IsAzureManagedInstance) ClassifyPlatform(int? engineEdition) => + (engineEdition == 5, engineEdition == 8); + + /* ─────────────────────────── per-query readers ─────────────────────────── */ + + private static async Task> ReadServersAsync(NpgsqlDataSource postgres, CancellationToken cancellationToken) + { + var rows = new List(); + await using var command = postgres.CreateCommand(FleetServersSql); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new FleetServerRow( + reader.GetInt32(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + reader.IsDBNull(2) ? "" : reader.GetString(2), + reader.IsDBNull(3) ? null : reader.GetInt32(3))); + } + + return rows; + } + + private static async Task> ReadCpuAsync(NpgsqlDataSource postgres, CancellationToken cancellationToken) + { + var map = new Dictionary(); + await using var command = postgres.CreateCommand(FleetCpuSql); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + map[reader.GetInt32(0)] = new CpuRow( + reader.IsDBNull(1) ? null : Convert.ToDouble(reader.GetValue(1)), + reader.IsDBNull(2) ? null : Convert.ToDouble(reader.GetValue(2))); + } + + return map; + } + + private static async Task> ReadMemoryAsync(NpgsqlDataSource postgres, CancellationToken cancellationToken) + { + var map = new Dictionary(); + await using var command = postgres.CreateCommand(FleetMemorySql); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + map[reader.GetInt32(0)] = new MemoryRow( + reader.IsDBNull(1) ? null : Convert.ToDouble(reader.GetValue(1)), + reader.IsDBNull(2) ? null : Convert.ToDouble(reader.GetValue(2))); + } + + return map; + } + + private static async Task> ReadMemoryPressureAsync(NpgsqlDataSource postgres, CancellationToken cancellationToken) + { + var map = new Dictionary(); + await using var command = postgres.CreateCommand(FleetMemoryPressureSql); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + map[reader.GetInt32(0)] = new MemoryPressureRow( + reader.IsDBNull(1) ? 0 : Convert.ToInt64(reader.GetValue(1)), + reader.IsDBNull(2) ? 0 : Convert.ToInt64(reader.GetValue(2)), + reader.IsDBNull(3) ? 0 : Convert.ToInt64(reader.GetValue(3)), + reader.IsDBNull(4) ? null : Convert.ToDouble(reader.GetValue(4))); + } + + return map; + } + + private static async Task> ReadThreadsAsync(NpgsqlDataSource postgres, CancellationToken cancellationToken) + { + var map = new Dictionary(); + await using var command = postgres.CreateCommand(FleetThreadsSql); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + map[reader.GetInt32(0)] = new ThreadsRow( + reader.IsDBNull(1) ? null : Convert.ToInt32(reader.GetValue(1)), + reader.IsDBNull(2) ? null : Convert.ToInt32(reader.GetValue(2)), + reader.IsDBNull(3) ? 0 : Convert.ToInt32(reader.GetValue(3)), + reader.IsDBNull(4) ? 0 : Convert.ToInt64(reader.GetValue(4))); + } + + return map; + } + + private static async Task> ReadBlockingAsync( + NpgsqlDataSource postgres, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken) + { + var map = new Dictionary(); + await using var command = postgres.CreateCommand(FleetBlockingSql); + AddTimestamp(command, startUtc); + AddTimestamp(command, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + map[reader.GetInt32(0)] = new BlockingRow( + reader.IsDBNull(1) ? 0 : Convert.ToInt32(reader.GetValue(1)), + reader.IsDBNull(2) ? 0 : Convert.ToInt64(reader.GetValue(2)), + reader.IsDBNull(3) ? 0 : Convert.ToInt32(reader.GetValue(3)), + reader.IsDBNull(4) ? 0 : Convert.ToInt64(reader.GetValue(4))); + } + + return map; + } + + private static async Task> ReadDeadlocksAsync( + NpgsqlDataSource postgres, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken) + { + var map = new Dictionary(); + await using var command = postgres.CreateCommand(FleetDeadlockSql); + AddTimestamp(command, startUtc); + AddTimestamp(command, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + map[reader.GetInt32(0)] = new DeadlockRow( + reader.IsDBNull(1) ? 0 : Convert.ToInt32(reader.GetValue(1)), + reader.IsDBNull(2) ? null : reader.GetDateTime(2)); + } + + return map; + } + + private static async Task> ReadLastCollectionAsync(NpgsqlDataSource postgres, CancellationToken cancellationToken) + { + var map = new Dictionary(); + await using var command = postgres.CreateCommand(FleetLastCollectionSql); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + if (!reader.IsDBNull(1)) + { + map[reader.GetInt32(0)] = reader.GetDateTime(1); + } + } + + return map; + } + + /// Reads the cross-server 7-day collector health and counts each server's HEALTHY / FAILING + /// collectors through the shared banding. + private static async Task> ReadFailingCollectorCountsAsync( + NpgsqlDataSource postgres, DateTime now, CancellationToken cancellationToken) + { + var counts = new Dictionary(); + await using var command = postgres.CreateCommand(FleetCollectionHealthSql); + AddTimestamp(command, DateTime.SpecifyKind(now.AddDays(-7), DateTimeKind.Unspecified)); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var serverId = reader.GetInt32(0); + var health = new CollectorHealth + { + CollectorName = reader.GetString(1), + TotalRuns = reader.IsDBNull(2) ? 0 : Convert.ToInt64(reader.GetValue(2)), + SuccessCount = reader.IsDBNull(3) ? 0 : Convert.ToInt64(reader.GetValue(3)), + ErrorCount = reader.IsDBNull(4) ? 0 : Convert.ToInt64(reader.GetValue(4)), + LastSuccessTime = reader.IsDBNull(5) ? null : reader.GetDateTime(5), + PermissionDeniedCount = reader.IsDBNull(6) ? 0 : Convert.ToInt64(reader.GetValue(6)), + }; + + counts.TryGetValue(serverId, out var existing); + var status = health.HealthStatus; + counts[serverId] = new CollectorCounts( + existing.Healthy + (status == "HEALTHY" ? 1 : 0), + existing.Failing + (status == "FAILING" ? 1 : 0)); + } + + return counts; + } + + private static void AddTimestamp(NpgsqlCommand command, DateTime value) => + command.Parameters.Add(new NpgsqlParameter { TypedValue = DateTime.SpecifyKind(value, DateTimeKind.Unspecified) }); + + /* ─────────────────────────── raw-read carriers (internal) ─────────────────────────── */ + + private readonly record struct FleetServerRow(int ServerId, string DisplayName, string ServerName, int? EngineEdition); + private readonly record struct CpuRow(double? SqlCpu, double? OtherCpu); + private readonly record struct MemoryRow(double? MemoryMb, double? BufferPoolMb); + private readonly record struct MemoryPressureRow(long WaiterCount, long TimeoutCount, long ForcedCount, double? GrantedMemoryMb); + private readonly record struct ThreadsRow(int? TotalThreads, int? CurrentWorkers, int RunnableTasks, long WorkQueue); + private readonly record struct BlockingRow(int XeCount, long XeMaxWait, int DmvCount, long DmvMaxWait); + private readonly record struct DeadlockRow(int Count, DateTime? LastSeen); + private readonly record struct CollectorCounts(int Healthy, int Failing); +} + +/// +/// One pre-banded Overview card in the fleet roll-up — every band already computed by the shared +/// , every instant a naive-UTC value the browser localizes. Serialized with +/// snake_case field names for both /api/fleet and the get_fleet_overview MCP tool. +/// +public sealed class FleetServerCard +{ + [JsonPropertyName("server_id")] public int ServerId { get; init; } + [JsonPropertyName("display_name")] public string DisplayName { get; init; } = ""; + [JsonPropertyName("server_name")] public string ServerName { get; init; } = ""; + + /// The raw probed SERVERPROPERTY('EngineEdition') (5 = Azure SQL DB, 8 = Azure Managed Instance, a + /// box edition otherwise); null when the server has not yet connected. The reliable per-server platform + /// signal the composer's D4 measure auto-greying matches a measure's appliesTo against. + [JsonPropertyName("engine_edition")] public int? EngineEdition { get; init; } + + /// True when this server is Azure SQL Database (engine edition 5) — reliable, derived from + /// . + [JsonPropertyName("is_azure_sql_db")] public bool IsAzureSqlDb { get; init; } + + /// True when this server is Azure SQL Managed Instance (engine edition 8) — reliable, derived from + /// . + [JsonPropertyName("is_azure_mi")] public bool IsAzureManagedInstance { get; init; } + + [JsonPropertyName("band")] public FleetHealthBand Band { get; init; } + [JsonPropertyName("status")] public string Status { get; init; } = ""; + [JsonPropertyName("is_online")] public bool? IsOnline { get; init; } + [JsonPropertyName("awaiting_first_collection")] public bool AwaitingFirstCollection { get; init; } + [JsonPropertyName("has_collector_errors")] public bool HasCollectorErrors { get; init; } + [JsonPropertyName("last_collection")] public DateTime? LastCollectionTime { get; init; } + + [JsonPropertyName("cpu_percent")] public double? CpuPercent { get; init; } + [JsonPropertyName("other_process_cpu_percent")] public double? OtherProcessCpuPercent { get; init; } + [JsonPropertyName("total_cpu_percent")] public double? TotalCpuPercent { get; init; } + [JsonPropertyName("cpu_severity")] public HealthSeverity CpuSeverity { get; init; } + + [JsonPropertyName("memory_mb")] public double? MemoryMb { get; init; } + [JsonPropertyName("buffer_pool_mb")] public double? BufferPoolMb { get; init; } + [JsonPropertyName("granted_memory_mb")] public double? GrantedMemoryMb { get; init; } + [JsonPropertyName("memory_waiter_count")] public long MemoryWaiterCount { get; init; } + [JsonPropertyName("memory_timeout_count")] public long MemoryTimeoutCount { get; init; } + [JsonPropertyName("memory_forced_count")] public long MemoryForcedCount { get; init; } + [JsonPropertyName("has_memory_pressure")] public bool HasMemoryPressure { get; init; } + [JsonPropertyName("memory_severity")] public HealthSeverity MemorySeverity { get; init; } + + [JsonPropertyName("blocking_count")] public int BlockingCount { get; init; } + [JsonPropertyName("max_blocking_wait_ms")] public long MaxBlockingWaitMs { get; init; } + [JsonPropertyName("blocking_severity")] public HealthSeverity BlockingSeverity { get; init; } + + [JsonPropertyName("deadlock_count")] public int DeadlockCount { get; init; } + [JsonPropertyName("deadlock_last_seen")] public DateTime? DeadlockLastSeen { get; init; } + [JsonPropertyName("deadlock_severity")] public HealthSeverity DeadlockSeverity { get; init; } + + [JsonPropertyName("total_threads")] public int? TotalThreads { get; init; } + [JsonPropertyName("current_workers")] public int? CurrentWorkers { get; init; } + [JsonPropertyName("available_threads")] public int? AvailableThreads { get; init; } + [JsonPropertyName("threads_waiting_for_cpu")] public int ThreadsWaitingForCpu { get; init; } + [JsonPropertyName("requests_waiting_for_threads")] public long RequestsWaitingForThreads { get; init; } + [JsonPropertyName("threads_severity")] public HealthSeverity ThreadsSeverity { get; init; } + + [JsonPropertyName("healthy_collector_count")] public int HealthyCollectorCount { get; init; } + [JsonPropertyName("failed_collector_count")] public int FailedCollectorCount { get; init; } + [JsonPropertyName("collector_severity")] public HealthSeverity CollectorSeverity { get; init; } + + [JsonPropertyName("overall_metric_severity")] public HealthSeverity OverallMetricSeverity { get; init; } + + /// The card's raw per-metric inputs, for re-scoring in the rollup (not serialized). + [JsonIgnore] + public ServerHealthMetrics ToHealthMetricsValue => ToHealthMetrics(); + + internal ServerHealthMetrics ToHealthMetrics() => new() + { + CpuPercentForAlert = TotalCpuPercent ?? CpuPercent, + HasMemoryPressure = HasMemoryPressure, + BlockingCount = BlockingCount, + MaxBlockedSeconds = MaxBlockingWaitMs / 1000.0, + DeadlockCount = DeadlockCount, + TotalThreads = TotalThreads, + AvailableThreads = AvailableThreads, + ThreadsWaitingForCpu = ThreadsWaitingForCpu, + RequestsWaitingForThreads = RequestsWaitingForThreads, + FailedCollectorCount = FailedCollectorCount, + }; +} + +/// One entry in the fleet's worst-first "Needs attention" ranking. +public sealed class FleetRankedServer +{ + [JsonPropertyName("server_id")] public int ServerId { get; init; } + [JsonPropertyName("display_name")] public string DisplayName { get; init; } = ""; + [JsonPropertyName("band")] public FleetHealthBand Band { get; init; } + [JsonPropertyName("band_label")] public string BandLabel => ServerHealthClassifier.BandLabel(Band); + [JsonPropertyName("score")] public long Score { get; init; } + [JsonPropertyName("reason")] public string Reason { get; init; } = ""; +} + +/// The full fleet roll-up payload — the pre-banded per-server cards, the band counts, the cross-server +/// totals, and the worst-first ranking. The web dashboard's /api/fleet body and the +/// get_fleet_overview MCP tool's serialized result. +public sealed class FleetOverviewResult +{ + [JsonPropertyName("generated_at")] public DateTime GeneratedAt { get; init; } + [JsonPropertyName("window_start")] public DateTime WindowStart { get; init; } + [JsonPropertyName("window_end")] public DateTime WindowEnd { get; init; } + [JsonPropertyName("total_servers")] public int TotalServers { get; init; } + [JsonPropertyName("healthy_count")] public int HealthyCount { get; init; } + [JsonPropertyName("warning_count")] public int WarningCount { get; init; } + [JsonPropertyName("critical_count")] public int CriticalCount { get; init; } + [JsonPropertyName("offline_count")] public int OfflineCount { get; init; } + [JsonPropertyName("servers_with_collection_failures")] public int ServersWithCollectionFailures { get; init; } + [JsonPropertyName("total_blocking_events")] public long TotalBlockingEvents { get; init; } + [JsonPropertyName("total_deadlocks")] public long TotalDeadlocks { get; init; } + [JsonPropertyName("additional_problem_count")] public int AdditionalProblemCount { get; init; } + [JsonPropertyName("worst_servers")] public IReadOnlyList WorstServers { get; init; } = Array.Empty(); + [JsonPropertyName("cards")] public IReadOnlyList Cards { get; init; } = Array.Empty(); +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingHealthReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingHealthReader.cs new file mode 100644 index 000000000..6c321778e --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingHealthReader.cs @@ -0,0 +1,351 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// Service-side reads for the health MCP tools () — the one-shot per-server +/// summary (get_server_summary) and the daily health rollup (get_daily_summary). Both reproduce the viewer's +/// proven reads (ViewerDataService.Overview.cs / .DailySummary.cs, themselves Lite's +/// GetServerSummaryAsync / GetDailySummaryAsync ported to Postgres) rather than referencing them — +/// the MCP host is in the Service assembly and cannot reference the WPF Viewer (the viewer's +/// ServerSummaryItem carries WPF brushes; only the raw metric reads are lifted here). All STORED reads +/// (no live monitored-server hit). +/// +/// The server-summary read is the CPU + memory + blocking + deadlock + last-collection subset the +/// same-named Lite tool exposes: latest SQL CPU, latest total server memory, blocking count in the last hour +/// (XE blocked-process reports, falling back to the always-on DMV snapshot when the XE count is zero), deadlock +/// count in the last hour, and the newest collection time. The daily-summary read is the viewer's full +/// DailySummaryRangeSql; the composite health band is computed by the SHARED +/// so it bands identically to Lite and the Darling viewer. +/// +internal static class DarlingHealthReader +{ + /* ═══════════════════════════ server summary ═══════════════════════════ */ + + /// One server's one-shot health snapshot — the CPU/memory/blocking/deadlock/last-collection subset + /// the same-named Lite tool exposes. + public sealed record ServerSummaryReadResult( + double? CpuPercent, double? MemoryMb, int BlockingCount, int DeadlockCount, DateTime? LastCollectionTime) + { + /// True when the server has no collected data at all (no CPU/memory snapshot and no collection + /// log) — the tool surfaces the #1224 "unavailable" miss instead of an all-zero card. + public bool HasNoData => + CpuPercent is null && MemoryMb is null && LastCollectionTime is null; + } + + /// Latest SQL-process CPU for one server (newest ring-buffer sample). $1 server_id. + public const string ServerSummaryCpuSql = @" +SELECT sqlserver_cpu_utilization +FROM v_cpu_utilization_stats +WHERE server_id = $1 +ORDER BY sample_time DESC +LIMIT 1"; + + /// Latest total server memory (MB) for one server. $1 server_id. + public const string ServerSummaryMemorySql = @" +SELECT CAST(total_server_memory_mb AS double precision) +FROM v_memory_stats +WHERE server_id = $1 +ORDER BY collection_time DESC +LIMIT 1"; + + /// Blocking counts in the window from both sources — XE blocked-process reports and the always-on + /// DMV snapshot; the caller applies Lite's XE-preferred, DMV-fallback rule. $1 server_id, $2 window start. + public const string ServerSummaryBlockingSql = @" +SELECT + (SELECT COUNT(*) FROM v_blocked_process_reports WHERE server_id = $1 AND event_time >= $2), + (SELECT COUNT(*) FROM v_dmv_blocking_snapshots WHERE server_id = $1 AND event_time >= $2)"; + + /// Deadlock count in the window. $1 server_id, $2 window start. + public const string ServerSummaryDeadlockSql = @" +SELECT COUNT(*) FROM v_deadlocks WHERE server_id = $1 AND deadlock_time >= $2"; + + /// Newest collection time across all collectors for one server. $1 server_id. + public const string ServerSummaryLastCollectionSql = @" +SELECT MAX(collection_time) FROM v_collection_log WHERE server_id = $1"; + + /// + /// One server's one-shot health summary — the viewer's GetServerSummaryAsync reduced to the subset + /// the same-named Lite tool serves. Blocking / deadlock counts use a one-hour window (Lite's window); CPU + /// and memory take the newest snapshot. + /// + public static async Task GetServerSummaryAsync( + NpgsqlDataSource postgres, int serverId, CancellationToken cancellationToken = default) + { + var windowStart = DateTime.UtcNow.AddHours(-1); + + double? cpuPercent = null; + double? memoryMb = null; + var blockingCount = 0; + var deadlockCount = 0; + DateTime? lastCollection = null; + + await using (var command = postgres.CreateCommand(ServerSummaryCpuSql)) + { + DarlingMcpReadParameters.AddInt(command, serverId); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (await reader.ReadAsync(cancellationToken)) + { + cpuPercent = reader.IsDBNull(0) ? null : Convert.ToDouble(reader.GetValue(0)); + } + } + + await using (var command = postgres.CreateCommand(ServerSummaryMemorySql)) + { + DarlingMcpReadParameters.AddInt(command, serverId); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (await reader.ReadAsync(cancellationToken)) + { + memoryMb = reader.IsDBNull(0) ? null : Convert.ToDouble(reader.GetValue(0)); + } + } + + await using (var command = postgres.CreateCommand(ServerSummaryBlockingSql)) + { + DarlingMcpReadParameters.AddInt(command, serverId); + DarlingMcpReadParameters.AddTimestamp(command, windowStart); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (await reader.ReadAsync(cancellationToken)) + { + var xeCount = reader.IsDBNull(0) ? 0 : (int)reader.GetInt64(0); + var dmvCount = reader.IsDBNull(1) ? 0 : (int)reader.GetInt64(1); + /* Lite's fallback: use XE when it has any row this window, else the DMV snapshot. */ + blockingCount = xeCount > 0 ? xeCount : dmvCount; + } + } + + await using (var command = postgres.CreateCommand(ServerSummaryDeadlockSql)) + { + DarlingMcpReadParameters.AddInt(command, serverId); + DarlingMcpReadParameters.AddTimestamp(command, windowStart); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (await reader.ReadAsync(cancellationToken)) + { + deadlockCount = reader.IsDBNull(0) ? 0 : (int)reader.GetInt64(0); + } + } + + await using (var command = postgres.CreateCommand(ServerSummaryLastCollectionSql)) + { + DarlingMcpReadParameters.AddInt(command, serverId); + var result = await command.ExecuteScalarAsync(cancellationToken); + if (result is not null && result != DBNull.Value) + { + lastCollection = Convert.ToDateTime(result); + } + } + + return new ServerSummaryReadResult(cpuPercent, memoryMb, blockingCount, deadlockCount, lastCollection); + } + + /* ═══════════════════════════ daily summary ═══════════════════════════ */ + + /// One day's rolled-up signals plus the shared composite health band. Structurally a subset of the + /// viewer's DailySummaryRow; the band comes from the SHARED . + public sealed record DailySummaryReadRow( + DateTime SummaryDate, decimal TotalWaitTimeSec, string TopWaitType, long UniqueQueries, long DeadlockCount, + long BlockingEvents, long HighCpuEvents, long CollectionErrors, long MemoryPressureEvents, + long MemoryCriticalEvents, long AlertCount, long MaxBlockDurationMs, bool HasData) + { + public DailyHealthSignals ToSignals() => new() + { + HasData = HasData, + Deadlocks = DeadlockCount, + CollectionErrors = CollectionErrors, + HighCpuEvents = HighCpuEvents, + BlockingEvents = BlockingEvents, + MemoryPressureEvents = MemoryPressureEvents, + MemoryCriticalEvents = MemoryCriticalEvents, + AlertCount = AlertCount, + }; + + public DailyHealthBand HealthBand => DailyHealthBandCalculator.Classify(ToSignals()); + + /// Human label for the band ("Healthy" / "Warning" / "Critical" / "No Data"). + public string OverallHealth => DailyHealthBandCalculator.Label(HealthBand); + } + + /// + /// Grouped per-day daily-summary aggregate — the viewer's DailySummaryRangeSql verbatim. $1 + /// server_id, $2 range start, $3 range end (naive UTC, half-open [start, end)). Reads the + /// v_wait_stats / v_query_stats / v_deadlocks / v_blocked_process_reports (with + /// the v_dmv_blocking_snapshots fallback) / v_cpu_utilization_stats / v_collection_log / + /// v_memory_pressure_events passthrough views plus config_alert_log; one row per collected day. + /// + public const string DailySummaryRangeSql = """ + WITH wait_per_type AS ( + SELECT date_trunc('day', collection_time) AS d, wait_type, SUM(delta_wait_time_ms) AS ms + FROM v_wait_stats + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 AND delta_wait_time_ms > 0 + GROUP BY 1, 2 + ), + wait_totals AS ( + SELECT d, SUM(ms) / 1000.0 AS total_wait_sec + FROM wait_per_type + GROUP BY d + ), + wait_top AS ( + SELECT DISTINCT ON (d) d, wait_type AS top_wait_type + FROM wait_per_type + ORDER BY d, ms DESC + ), + waits AS ( + SELECT t.d, t.total_wait_sec, tp.top_wait_type + FROM wait_totals t + LEFT JOIN wait_top tp ON tp.d = t.d + ), + queries AS ( + SELECT date_trunc('day', collection_time) AS d, COUNT(DISTINCT query_hash) AS c + FROM v_query_stats + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + GROUP BY 1 + ), + deadlocks AS ( + SELECT date_trunc('day', collection_time) AS d, COUNT(*) AS c + FROM v_deadlocks + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + GROUP BY 1 + ), + bpr AS ( + SELECT date_trunc('day', collection_time) AS d, COUNT(*) AS c, MAX(wait_time_ms) AS max_wait_ms + FROM v_blocked_process_reports + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + GROUP BY 1 + ), + dmv AS ( + SELECT date_trunc('day', collection_time) AS d, COUNT(*) AS c, MAX(wait_time_ms) AS max_wait_ms + FROM v_dmv_blocking_snapshots + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + GROUP BY 1 + ), + cpu AS ( + SELECT date_trunc('day', collection_time) AS d, + COUNT(*) FILTER (WHERE (sqlserver_cpu_utilization + COALESCE(other_process_cpu_utilization, 0)) >= 80) AS c + FROM v_cpu_utilization_stats + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + GROUP BY 1 + ), + coll AS ( + SELECT date_trunc('day', collection_time) AS d, + COUNT(*) AS runs, + COUNT(*) FILTER (WHERE status = 'ERROR') AS errs + FROM v_collection_log + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + GROUP BY 1 + ), + mem AS ( + SELECT date_trunc('day', collection_time) AS d, + COUNT(*) FILTER (WHERE memory_indicators_process >= 2 OR memory_indicators_system >= 2) AS pressure, + COUNT(*) FILTER (WHERE memory_indicators_process >= 3) AS critical + FROM v_memory_pressure_events + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + GROUP BY 1 + ), + alerts AS ( + SELECT date_trunc('day', alert_time) AS d, COUNT(*) AS c + FROM config_alert_log + WHERE server_id = $1 AND alert_time >= $2 AND alert_time < $3 + AND dismissed = FALSE + AND metric_name NOT LIKE '%Cleared%' + AND metric_name NOT LIKE '%Resolved%' + AND metric_name NOT LIKE '%Restored%' + GROUP BY 1 + ), + day_spine AS ( + SELECT d FROM waits + UNION SELECT d FROM queries + UNION SELECT d FROM deadlocks + UNION SELECT d FROM bpr + UNION SELECT d FROM dmv + UNION SELECT d FROM cpu + UNION SELECT d FROM coll + UNION SELECT d FROM mem + UNION SELECT d FROM alerts + ) + SELECT + s.d AS day, + COALESCE(w.total_wait_sec, 0) AS total_wait_sec, + w.top_wait_type, + COALESCE(q.c, 0) AS unique_queries, + COALESCE(dl.c, 0) AS deadlock_count, + COALESCE(NULLIF(b.c, 0), dm.c, 0) AS blocking_events, + COALESCE(cp.c, 0) AS high_cpu_events, + COALESCE(cl.errs, 0) AS collection_errors, + COALESCE(m.pressure, 0) AS memory_pressure_events, + COALESCE(m.critical, 0) AS memory_critical_events, + COALESCE(al.c, 0) AS alert_count, + COALESCE(CASE WHEN COALESCE(b.c, 0) > 0 THEN b.max_wait_ms ELSE dm.max_wait_ms END, 0) AS peak_block_wait_ms + FROM day_spine s + LEFT JOIN waits w ON w.d = s.d + LEFT JOIN queries q ON q.d = s.d + LEFT JOIN deadlocks dl ON dl.d = s.d + LEFT JOIN bpr b ON b.d = s.d + LEFT JOIN dmv dm ON dm.d = s.d + LEFT JOIN cpu cp ON cp.d = s.d + LEFT JOIN coll cl ON cl.d = s.d + LEFT JOIN mem m ON m.d = s.d + LEFT JOIN alerts al ON al.d = s.d + ORDER BY s.d + """; + + /// One per collected day in the half-open [fromDate, toDate) + /// window (the viewer's GetDailySummaryRangeAsync). + public static async Task> GetDailySummaryRangeAsync( + NpgsqlDataSource postgres, int serverId, DateTime fromDate, DateTime toDate, CancellationToken cancellationToken = default) + { + var results = new List(); + await using var command = postgres.CreateCommand(DailySummaryRangeSql); + DarlingMcpReadParameters.AddInt(command, serverId); + DarlingMcpReadParameters.AddTimestamp(command, fromDate.Date); + DarlingMcpReadParameters.AddTimestamp(command, toDate.Date); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + results.Add(ReadDailySummaryRow(reader)); + } + + return results; + } + + /// Daily summary for one server on a specific date (or today, UTC, when + /// is null) — the viewer's GetDailySummaryAsync. Returns a No-Data row when the day had no collection. + public static async Task GetDailySummaryAsync( + NpgsqlDataSource postgres, int serverId, DateTime? summaryDate = null, CancellationToken cancellationToken = default) + { + var targetDate = summaryDate?.Date ?? DateTime.UtcNow.Date; + var rows = await GetDailySummaryRangeAsync(postgres, serverId, targetDate, targetDate.AddDays(1), cancellationToken); + return rows.Count > 0 + ? rows[0] + : new DailySummaryReadRow(targetDate, 0m, "", 0, 0, 0, 0, 0, 0, 0, 0, 0, HasData: false); + } + + private static DailySummaryReadRow ReadDailySummaryRow(DbDataReader reader) => new( + reader.IsDBNull(0) ? DateTime.MinValue : Convert.ToDateTime(reader.GetValue(0)), + reader.IsDBNull(1) ? 0m : Convert.ToDecimal(reader.GetValue(1)), + reader.IsDBNull(2) ? "" : reader.GetString(2), + reader.IsDBNull(3) ? 0L : Convert.ToInt64(reader.GetValue(3)), + reader.IsDBNull(4) ? 0L : Convert.ToInt64(reader.GetValue(4)), + reader.IsDBNull(5) ? 0L : Convert.ToInt64(reader.GetValue(5)), + reader.IsDBNull(6) ? 0L : Convert.ToInt64(reader.GetValue(6)), + reader.IsDBNull(7) ? 0L : Convert.ToInt64(reader.GetValue(7)), + reader.IsDBNull(8) ? 0L : Convert.ToInt64(reader.GetValue(8)), + reader.IsDBNull(9) ? 0L : Convert.ToInt64(reader.GetValue(9)), + reader.IsDBNull(10) ? 0L : Convert.ToInt64(reader.GetValue(10)), + reader.IsDBNull(11) ? 0L : Convert.ToInt64(reader.GetValue(11)), + HasData: true); +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingJobReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingJobReader.cs new file mode 100644 index 000000000..53cc46934 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingJobReader.cs @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// Service-side read for the running-jobs MCP tool () — the SAME +/// running_jobs data Lite's get_running_jobs and the viewer's ViewerDataService.RunningJobs +/// read, adapted here so the MCP host never references the WPF viewer project. A STORED read of the latest +/// snapshot only (the collection_time = MAX(...) self-subquery), on the v_running_jobs passthrough +/// view. Every derived column (avg / p95 / percent-of-average / is-running-long) is collector-side, so the read +/// is a plain projection; the human-readable duration strings are formatted in the tool. Every SQL string is a +/// public const so Darling.Tests can pin the dialect + columns without a live Postgres. +/// +internal static class DarlingJobReader +{ + /// One currently-running SQL Agent job with its historical duration comparison. + public sealed record RunningJobRow( + DateTime CollectionTime, string JobName, string JobId, bool JobEnabled, DateTime StartTime, + long CurrentDurationSeconds, long AvgDurationSeconds, long P95DurationSeconds, long SuccessfulRunCount, + bool IsRunningLong, decimal? PercentOfAverage); + + /// + /// The latest running-jobs snapshot for one server — Lite's GetRunningJobsAsync / the viewer's + /// RunningJobsSql: the newest collection only, longest-running first. The columns are exactly those + /// the alert engine's DarlingAlertReadAdapter.AnomalousJobsSql reads plus the display fields. + /// $1 server_id. + /// + public const string RunningJobsSql = """ + SELECT + collection_time, + job_name, + job_id, + job_enabled, + start_time, + current_duration_seconds, + avg_duration_seconds, + p95_duration_seconds, + successful_run_count, + is_running_long, + percent_of_average + FROM v_running_jobs + WHERE server_id = $1 + AND collection_time = ( + SELECT MAX(collection_time) + FROM v_running_jobs + WHERE server_id = $1 + ) + ORDER BY current_duration_seconds DESC + """; + + public static async Task> GetRunningJobsAsync( + NpgsqlDataSource postgres, int serverId, CancellationToken cancellationToken = default) + { + var rows = new List(); + await using var command = postgres.CreateCommand(RunningJobsSql); + DarlingMcpReadParameters.AddInt(command, serverId); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new RunningJobRow( + reader.GetDateTime(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + reader.IsDBNull(2) ? "" : reader.GetString(2), + !reader.IsDBNull(3) && reader.GetBoolean(3), + reader.GetDateTime(4), + reader.IsDBNull(5) ? 0 : reader.GetInt64(5), + reader.IsDBNull(6) ? 0 : reader.GetInt64(6), + reader.IsDBNull(7) ? 0 : reader.GetInt64(7), + reader.IsDBNull(8) ? 0 : reader.GetInt64(8), + !reader.IsDBNull(9) && reader.GetBoolean(9), + reader.IsDBNull(10) ? null : reader.GetDecimal(10))); + } + + return rows; + } + + /// Lite/Dashboard's job-duration display formatting (Xs / Xm Ys / Xh Ym). + public static string FormatDuration(long seconds) + { + if (seconds < 60) return $"{seconds}s"; + if (seconds < 3600) return $"{seconds / 60}m {seconds % 60}s"; + return $"{seconds / 3600}h {(seconds % 3600) / 60}m"; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingLatchSpinlockReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingLatchSpinlockReader.cs new file mode 100644 index 000000000..07fa1bc1f --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingLatchSpinlockReader.cs @@ -0,0 +1,264 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// Service-side reads for the latch / spinlock contention MCP tools +/// () — the SAME collected data the Dashboard's +/// McpLatchSpinlockTools and the viewer's ViewerDataService.LatchSpinlock read, adapted here +/// so the MCP host never references the WPF viewer project. Both are STORED reads (no live monitored-server +/// hit) keyed by server_id and windowed on the naive-UTC collection_time. +/// +/// +/// Darling's latch_stats / spinlock_stats collectors are cumulative-counter delta collectors +/// (like wait_stats) that store the last interval's delta_* directly but NO +/// sample_interval_seconds, so the per-second rate is derived in SQL from the per-class LAG +/// interval (the truncate-then-diff epoch idiom the viewer's trend reads use). Each read aggregates the +/// window per class into one row (the top N by total delta wait time / total delta collisions), carrying the +/// latest interval's per-second rate and last delta. The Dashboard's severity / latch_description +/// / recommendation and spinlock_description are NOT stored columns — they are the Dashboard +/// view's own CASE derivations (deterministic functions of latch_class / spinlock_name and the +/// latest delta), reproduced VERBATIM as pure static helpers here so the tools serve the FULL Dashboard result +/// shape without inventing any data. Every SQL string is a public const so Darling.Tests can pin the dialect +/// + columns without a live Postgres. +/// +/// +internal static class DarlingLatchSpinlockReader +{ + /* ─────────────────────────── result rows ─────────────────────────── */ + + /// One latch class aggregated over the window: the summed deltas plus the latest interval's + /// per-second rate and last delta wait (the severity input). + public sealed record LatchStatRow( + string LatchClass, long TotalDeltaWaitTimeMs, long TotalDeltaWaitingRequests, + double WaitsPerSecond, double WaitMsPerSecond, long LatestDeltaWaitTimeMs, DateTime LatestCollectionTime); + + /// One spinlock aggregated over the window: the summed deltas plus the latest interval's + /// per-second collision/spin rates. + public sealed record SpinlockStatRow( + string SpinlockName, long TotalDeltaCollisions, long TotalDeltaSpins, long TotalDeltaBackoffs, + double CollisionsPerSecond, double SpinsPerSecond, DateTime LatestCollectionTime); + + /* ─────────────────────────── latch stats (top N over the window) ─────────────────────────── */ + + /// + /// The top-N latch classes over the window, one row per class — mirroring the Dashboard's + /// get_latch_stats per-class aggregation (SUM of the last interval's deltas, top by total delta + /// wait time). The per-second rate comes from the latest interval via the per-class LAG interval + /// (Darling stores no sample_interval_seconds), the same idiom the viewer's LatchTrendSql + /// uses. latest_delta_wait_time_ms feeds the reproduced severity CASE. Runs on the + /// v_latch_stats passthrough view. $1 server_id, $2 window start, $3 window end (naive UTC), $4 top. + /// + public const string LatchStatsTopNSql = """ + WITH windowed AS + ( + SELECT + latch_class, + collection_time, + delta_waiting_requests_count, + delta_wait_time_ms, + extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (PARTITION BY latch_class ORDER BY collection_time)))) AS interval_seconds + FROM v_latch_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + ), + agg AS + ( + SELECT + latch_class, + CAST(SUM(delta_wait_time_ms) AS bigint) AS total_delta_wait_time_ms, + CAST(SUM(delta_waiting_requests_count) AS bigint) AS total_delta_waiting_requests, + MAX(collection_time) AS latest_collection_time + FROM windowed + GROUP BY latch_class + ), + latest AS + ( + SELECT DISTINCT ON (latch_class) + latch_class, + delta_wait_time_ms AS latest_delta_wait_time_ms, + CASE WHEN interval_seconds > 0 THEN CAST(delta_waiting_requests_count AS double precision) / interval_seconds ELSE 0 END AS waits_per_second, + CASE WHEN interval_seconds > 0 THEN CAST(delta_wait_time_ms AS double precision) / interval_seconds ELSE 0 END AS wait_ms_per_second + FROM windowed + ORDER BY latch_class, collection_time DESC + ) + SELECT + a.latch_class, + a.total_delta_wait_time_ms, + a.total_delta_waiting_requests, + l.waits_per_second, + l.wait_ms_per_second, + l.latest_delta_wait_time_ms, + a.latest_collection_time + FROM agg AS a + JOIN latest AS l ON l.latch_class = a.latch_class + ORDER BY a.total_delta_wait_time_ms DESC + LIMIT $4 + """; + + public static async Task> GetLatchStatsTopNAsync( + NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, int top, CancellationToken cancellationToken = default) + { + var rows = new List(); + await using var command = postgres.CreateCommand(LatchStatsTopNSql); + DarlingMcpReadParameters.AddWindow(command, serverId, startUtc, endUtc); + DarlingMcpReadParameters.AddInt(command, top); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new LatchStatRow( + reader.IsDBNull(0) ? "" : reader.GetString(0), + reader.IsDBNull(1) ? 0 : reader.GetInt64(1), + reader.IsDBNull(2) ? 0 : reader.GetInt64(2), + reader.IsDBNull(3) ? 0 : reader.GetDouble(3), + reader.IsDBNull(4) ? 0 : reader.GetDouble(4), + reader.IsDBNull(5) ? 0 : reader.GetInt64(5), + reader.GetDateTime(6))); + } + + return rows; + } + + /* ─────────────────────────── spinlock stats (top N over the window) ─────────────────────────── */ + + /// + /// The top-N spinlocks over the window, one row per spinlock — the collision analog of + /// , mirroring the Dashboard's get_spinlock_stats per-name + /// aggregation (top by total delta collisions). Per-second collision/spin rates from the latest interval + /// via the per-name LAG interval. Runs on v_spinlock_stats. $1 server_id, $2 start, $3 end + /// (naive UTC), $4 top. + /// + public const string SpinlockStatsTopNSql = """ + WITH windowed AS + ( + SELECT + spinlock_name, + collection_time, + delta_collisions, + delta_spins, + delta_backoffs, + extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (PARTITION BY spinlock_name ORDER BY collection_time)))) AS interval_seconds + FROM v_spinlock_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + ), + agg AS + ( + SELECT + spinlock_name, + CAST(SUM(delta_collisions) AS bigint) AS total_delta_collisions, + CAST(SUM(delta_spins) AS bigint) AS total_delta_spins, + CAST(SUM(delta_backoffs) AS bigint) AS total_delta_backoffs, + MAX(collection_time) AS latest_collection_time + FROM windowed + GROUP BY spinlock_name + ), + latest AS + ( + SELECT DISTINCT ON (spinlock_name) + spinlock_name, + CASE WHEN interval_seconds > 0 THEN CAST(delta_collisions AS double precision) / interval_seconds ELSE 0 END AS collisions_per_second, + CASE WHEN interval_seconds > 0 THEN CAST(delta_spins AS double precision) / interval_seconds ELSE 0 END AS spins_per_second + FROM windowed + ORDER BY spinlock_name, collection_time DESC + ) + SELECT + a.spinlock_name, + a.total_delta_collisions, + a.total_delta_spins, + a.total_delta_backoffs, + l.collisions_per_second, + l.spins_per_second, + a.latest_collection_time + FROM agg AS a + JOIN latest AS l ON l.spinlock_name = a.spinlock_name + ORDER BY a.total_delta_collisions DESC + LIMIT $4 + """; + + public static async Task> GetSpinlockStatsTopNAsync( + NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, int top, CancellationToken cancellationToken = default) + { + var rows = new List(); + await using var command = postgres.CreateCommand(SpinlockStatsTopNSql); + DarlingMcpReadParameters.AddWindow(command, serverId, startUtc, endUtc); + DarlingMcpReadParameters.AddInt(command, top); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new SpinlockStatRow( + reader.IsDBNull(0) ? "" : reader.GetString(0), + reader.IsDBNull(1) ? 0 : reader.GetInt64(1), + reader.IsDBNull(2) ? 0 : reader.GetInt64(2), + reader.IsDBNull(3) ? 0 : reader.GetInt64(3), + reader.IsDBNull(4) ? 0 : reader.GetDouble(4), + reader.IsDBNull(5) ? 0 : reader.GetDouble(5), + reader.GetDateTime(6))); + } + + return rows; + } + + /* ─────────────────────────── the Dashboard's CASE enrichment, reproduced ─────────────────────────── */ + /* severity / latch_description / recommendation / spinlock_description are NOT stored columns — they are + the Dashboard view's own CASE derivations (see DatabaseService.ResourceMetrics.LatchSpinlock.cs). + Reproduced verbatim so Darling serves the full Dashboard result shape without inventing any data. */ + + /// The Dashboard's latch severity band (from the last interval's delta wait time). + public static string LatchSeverity(long latestDeltaWaitTimeMs) => + latestDeltaWaitTimeMs > 10000 ? "HIGH" : + latestDeltaWaitTimeMs > 5000 ? "MEDIUM" : + "LOW"; + + /// The Dashboard's latch-class description (exact-name CASE). + public static string LatchDescription(string latchClass) => + latchClass switch + { + "BUFFER" => "Synchronize short term access to database pages.", + "BUFFER_POOL_GROW" => "Buffer pool grow operations.", + "DATABASE_CHECKPOINT" => "Serialize checkpoints within a database.", + "FCB" => "Synchronize access to the file control block.", + "FGCB_ADD_REMOVE" => "Synchronize file add/drop/grow/shrink operations.", + "LOG_MANAGER" => "Transaction log manager synchronization.", + _ => "Internal SQL Server synchronization.", + }; + + /// The Dashboard's latch-class recommendation (prefix / membership CASE). + public static string LatchRecommendation(string latchClass) + { + if (latchClass.StartsWith("PAGEIOLATCH", StringComparison.Ordinal)) return "I/O bottleneck - check disk latency, add memory"; + if (latchClass.StartsWith("PAGELATCH", StringComparison.Ordinal)) return "Page contention - check for hot pages, tempdb issues"; + if (latchClass == "BUFFER") return "Buffer pool contention - check for memory pressure"; + if (latchClass.StartsWith("ACCESS_METHODS", StringComparison.Ordinal)) return "Index/heap access contention"; + if (latchClass.StartsWith("ALLOC", StringComparison.Ordinal)) return "Allocation contention - consider pre-sizing files"; + if (latchClass is "LOG_MANAGER" or "LOGCACHE_ACCESS") return "Log contention - check log disk"; + return "Review latch class documentation"; + } + + /// The Dashboard's spinlock description (exact-name CASE). + public static string SpinlockDescription(string spinlockName) => + spinlockName switch + { + "BACKUP_CTX" => "Page I/O during backup - high spins during checkpoint/lazywriter.", + "DBTABLE" => "In-memory data structure access for database properties.", + "DP_LIST" => "Dirty page list with indirect checkpoint enabled.", + "LOCK_HASH" => "Lock manager hash table access.", + "LOCK_RW_SECURITY_CACHE" => "Security token and access check cache.", + "SOS_CACHESTORE" => "Various in-memory caches (plan cache, temp tables).", + _ => "Internal use only.", + }; +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingLongQueryReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingLongQueryReader.cs new file mode 100644 index 000000000..f51c66685 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingLongQueryReader.cs @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// Service-side read for the long-query completion MCP tool () — the +/// SAME collected data the viewer's ViewerDataService.LongQueries.cs reads, adapted here so the MCP +/// host never references the WPF viewer project (#1496). A STORED read (no live monitored-server hit), +/// keyed by server_id and windowed on the naive-UTC collection_time prefix, over the BASE +/// long_query_completions table (a post-V14 collector has no v_* passthrough view). Ordered by +/// duration DESC (NULLS LAST so attentions, whose duration is NULL, follow the ranked completions). The SQL +/// is a public const so Darling.Tests can pin the dialect + columns without a live Postgres. +/// +internal static class DarlingLongQueryReader +{ + /// One long-query completion row — a completed rpc/batch (runtime, CPU, I/O, rows, result) or an + /// attention (cancel/timeout; duration NULL). + public sealed class LongQueryReadRow + { + public DateTime? EventTime { get; set; } + public string? EventType { get; set; } + public string? DatabaseName { get; set; } + public int? SessionId { get; set; } + public string? ClientAppName { get; set; } + public int? ClientPid { get; set; } + public string? NtUserName { get; set; } + public string? ServerPrincipalName { get; set; } + public string? QueryHash { get; set; } + public long? EventSequence { get; set; } + public long? DurationMicroseconds { get; set; } + public long? CpuTimeMicroseconds { get; set; } + public long? PhysicalReads { get; set; } + public long? LogicalReads { get; set; } + public long? Writes { get; set; } + public long? RowCount { get; set; } + public string? Result { get; set; } + public string? StatementText { get; set; } + public string? ObjectName { get; set; } + } + + public const string LongQueryCompletionsSql = """ + SELECT + event_time, + event_type, + database_name, + session_id, + client_app_name, + client_pid, + nt_username, + server_principal_name, + query_hash, + event_sequence, + duration_microseconds, + cpu_time_microseconds, + physical_reads, + logical_reads, + writes, + row_count, + result, + statement_text, + object_name + FROM long_query_completions + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + ORDER BY duration_microseconds DESC NULLS LAST, event_time DESC + LIMIT 200 + """; + + public static async Task> GetRecentLongQueryCompletionsAsync( + NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var rows = new List(); + + await using var connection = await postgres.OpenConnectionAsync(cancellationToken); + await using var command = new NpgsqlCommand(LongQueryCompletionsSql, connection); + DarlingMcpReadParameters.AddWindow(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new LongQueryReadRow + { + EventTime = reader.IsDBNull(0) ? null : reader.GetDateTime(0), + EventType = reader.IsDBNull(1) ? null : reader.GetString(1), + DatabaseName = reader.IsDBNull(2) ? null : reader.GetString(2), + SessionId = reader.IsDBNull(3) ? null : reader.GetInt32(3), + ClientAppName = reader.IsDBNull(4) ? null : reader.GetString(4), + ClientPid = reader.IsDBNull(5) ? null : reader.GetInt32(5), + NtUserName = reader.IsDBNull(6) ? null : reader.GetString(6), + ServerPrincipalName = reader.IsDBNull(7) ? null : reader.GetString(7), + QueryHash = reader.IsDBNull(8) ? null : reader.GetString(8), + EventSequence = reader.IsDBNull(9) ? null : reader.GetInt64(9), + DurationMicroseconds = reader.IsDBNull(10) ? null : reader.GetInt64(10), + CpuTimeMicroseconds = reader.IsDBNull(11) ? null : reader.GetInt64(11), + PhysicalReads = reader.IsDBNull(12) ? null : reader.GetInt64(12), + LogicalReads = reader.IsDBNull(13) ? null : reader.GetInt64(13), + Writes = reader.IsDBNull(14) ? null : reader.GetInt64(14), + RowCount = reader.IsDBNull(15) ? null : reader.GetInt64(15), + Result = reader.IsDBNull(16) ? null : reader.GetString(16), + StatementText = reader.IsDBNull(17) ? null : reader.GetString(17), + ObjectName = reader.IsDBNull(18) ? null : reader.GetString(18), + }); + } + + return rows; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAlertTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAlertTools.cs new file mode 100644 index 000000000..31ee2f588 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAlertTools.cs @@ -0,0 +1,675 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Globalization; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using ModelContextProtocol.Server; +using Npgsql; +using NpgsqlTypes; +using PerformanceMonitor.Common; +using PerformanceMonitor.Notifications; + +#pragma warning disable CA1707 // MCP tools use snake_case naming convention + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// The alerts MCP tools — get_alert_history, get_alert_settings, get_mute_rules plus the three alert-TUNING +/// write tools update_alert_settings / create_mute_rule / delete_mute_rule — served over Darling's Postgres +/// store. The reads are the same names Lite (and the Dashboard) expose; the writes are Darling-only (the central +/// alert store the fleet shares, which Lite's single-instance DuckDB has no twin for). This is the FLEET +/// edition's biggest MCP win: an agent triaging N servers can now see what fired, whether delivery failed, the +/// current thresholds, and which servers are suppressed vs healthy-quiet — and now TUNE that alerting +/// conversationally. The reads are STORED reads (no live monitored-server hit): get_alert_history / +/// get_alert_settings read config_alert_log / config_alert_settings through +/// , and get_mute_rules reads through the SAME service-side +/// the delivery paths honor. +/// +/// get_alert_history adds a fleet dimension Lite's single-store tool lacks: an optional +/// server_name — omit it for the whole fleet (the viewer's all-servers Alert History default, with each +/// row carrying its server), or name a server to scope to it. get_alert_settings reports the single global +/// alert-settings row the service hot-swaps in (the viewer's Settings-window desired state); SMTP/webhook +/// delivery credentials are managed separately and are not exposed here (the least-privilege mcp role cannot +/// read the secret columns anyway). +/// +/// Writes route through the EXISTING authority, never a parallel copy. update_alert_settings is a +/// PARTIAL update of the single config_alert_settings row (id=1) — the caller reads via get_alert_settings, +/// changes fields, and sends only those back in the SAME nested shape; every provided field is validated against +/// the SAME ranges/enums the Viewer's Settings window enforces (SettingsWindow.BuildAlertRowFromControls) BEFORE +/// any write, and only the provided columns are written by a targeted parameterized UPDATE. The write self-bumps +/// config_service.config_version via the existing config-table trigger, so the running service hot-reloads +/// within one sweep (the tool never writes config_version itself). SMTP/webhook credential columns are out +/// of scope — the mcp role cannot read or write them. create_mute_rule / delete_mute_rule reuse the SAME +/// get_mute_rules reads through, generating the rule id the same way the Viewer's +/// mute-create path does (a fresh GUID on a new ). +/// +/// Security. These write tools connect (like every MCP tool) as the least-privilege mcp role, +/// granted (see ) INSERT/UPDATE/DELETE on config.config_mute_rules, UPDATE +/// on the singleton config.config_alert_settings, and the beacon columns of config.config_service +/// (so the settings write's self-bump trigger can fire) — and nothing else, so a token-holder can tune alerting +/// but still cannot reach the config_command service-credential pivot or the carved secret columns. +/// +[McpServerToolType] +public sealed class DarlingMcpAlertTools +{ + [McpServerTool(Name = "get_alert_history"), Description("Gets recent alert history from the alert log: what alerts fired, when, for which server, the current vs threshold value, whether email/webhook delivery succeeded, and whether the alert was muted. Omit server_name to see the whole fleet (each row names its server); pass one to scope to a single server.")] + public static async Task GetAlertHistory( + NpgsqlDataSource postgres, + [Description("Server name or display name. Omit to return alerts across all servers (the fleet default).")] string? server_name = null, + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description("Maximum rows. Default 50.")] int limit = 50) + { + var hoursError = McpHelpers.ValidateHoursBack(hours_back); + if (hoursError != null) return hoursError; + var limitError = McpHelpers.ValidateTop(limit); + if (limitError != null) return limitError; + + /* Optional server scope: a named server resolves + scopes; omitted = the whole fleet (the viewer's + all-servers Alert History default). Unlike the other tools this does NOT force a single server. */ + int? serverId = null; + var scope = "(all servers)"; + if (!string.IsNullOrWhiteSpace(server_name)) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + serverId = resolved.Value.ServerId; + scope = resolved.Value.ServerName; + } + + try + { + var since = DateTime.UtcNow.AddHours(-hours_back); + var rows = await DarlingAlertReader.GetAlertHistoryAsync(postgres, since, serverId, limit); + if (rows.Count == 0) + return McpHelpers.Status("empty", "No alerts found in the specified time range."); + + var alerts = rows.Select(r => new + { + alert_time = r.AlertTime.ToString("o"), + server_id = r.ServerId, + server_name = r.ServerName, + metric_name = r.MetricName, + current_value = r.CurrentValue, + threshold_value = r.ThresholdValue, + alert_sent = r.AlertSent, + notification_type = r.NotificationType, + send_error = r.SendError, + muted = r.Muted, + detail_text = r.DetailText + }); + + return JsonSerializer.Serialize(new + { + server = scope, + hours_back, + total_alerts = rows.Count, + alerts + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_alert_history", ex); + } + } + + [McpServerTool(Name = "get_alert_settings"), Description("Gets the current alert configuration the service is using: which alerts are enabled and their thresholds (CPU, blocking, deadlocks, poison waits, long-running queries/jobs, tempdb, low disk, failed jobs), the cooldown, excluded databases, the deadlock/blocking delivery mode, and the scheduled-analysis cadence. This is the single global settings row the service hot-swaps in. SMTP/webhook delivery credentials are managed separately and are not reported here.")] + public static async Task GetAlertSettings( + NpgsqlDataSource postgres) + { + try + { + var s = await DarlingAlertReader.GetAlertSettingsAsync(postgres); + if (s is null) + return McpHelpers.Status( + "unavailable", + "No alert-settings row is present in the store yet. The service seeds it on startup (or the Viewer's Settings window writes it); until then the service runs on its darling.json defaults."); + + return JsonSerializer.Serialize(BuildAlertSettingsPayload(s), McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_alert_settings", ex); + } + } + + /// The nested JSON shape get_alert_settings returns AND update_alert_settings echoes back — the same + /// field names update_alert_settings accepts on the way in, so a read → modify → write round-trips. + private static object BuildAlertSettingsPayload(DarlingAlertReader.AlertSettingsReadRow s) => new + { + alerts_enabled = s.Enabled, + notify_connection_changes = s.NotifyConnectionChanges, + cpu = new { enabled = s.CpuEnabled, threshold_percent = s.CpuThresholdPercent, mode = s.CpuMode }, + blocking = new { enabled = s.BlockingEnabled, count_threshold = s.BlockingCountThreshold }, + deadlocks = new { enabled = s.DeadlockEnabled, count_threshold = s.DeadlockCountThreshold }, + poison_wait = new { enabled = s.PoisonWaitEnabled, threshold_ms = s.PoisonWaitThresholdMs }, + long_running_query = new + { + enabled = s.LongRunningQueryEnabled, + threshold_minutes = s.LongRunningQueryThresholdMinutes, + max_results = s.LongRunningQueryMaxResults, + exclude_sp_server_diagnostics = s.LongRunningQueryExcludeSpServerDiagnostics, + exclude_wait_for = s.LongRunningQueryExcludeWaitFor, + exclude_backups = s.LongRunningQueryExcludeBackups, + exclude_misc_waits = s.LongRunningQueryExcludeMiscWaits, + exclude_cdc = s.LongRunningQueryExcludeCdc + }, + tempdb_space = new { enabled = s.TempDbSpaceEnabled, threshold_percent = s.TempDbSpaceThresholdPercent }, + low_disk = new { enabled = s.LowDiskEnabled, threshold_percent = s.LowDiskThresholdPercent, threshold_gb = s.LowDiskThresholdGb }, + long_running_job = new { enabled = s.LongRunningJobEnabled, multiplier = s.LongRunningJobMultiplier }, + failed_job = new { enabled = s.FailedJobEnabled, lookback_minutes = s.FailedJobLookbackMinutes }, + cooldown_minutes = s.CooldownMinutes, + excluded_databases = s.ExcludedDatabases, + delivery = new { mode = s.DeliveryMode, per_event_max = s.PerEventMax }, + analysis = new + { + enabled = s.AnalysisEnabled, + interval_minutes = s.AnalysisIntervalMinutes, + notifications_enabled = s.AnalysisNotificationsEnabled, + notify_severity = s.AnalysisNotifySeverity + } + }; + + [McpServerTool(Name = "get_mute_rules"), Description("Gets the configured alert mute rules. Mute rules suppress specific recurring alerts (by server, metric, database, query text, wait type, or job name) while still logging them — so an agent can tell a genuinely healthy-quiet server from one whose alerts are being suppressed.")] + public static async Task GetMuteRules( + NpgsqlDataSource postgres, + [Description("Include only enabled, non-expired rules. Default true.")] bool enabled_only = true) + { + try + { + var rules = (await new PgMuteRuleStore(postgres).LoadAllAsync()).AsEnumerable(); + if (enabled_only) + rules = rules.Where(r => r.Enabled && (r.ExpiresAtUtc == null || r.ExpiresAtUtc > DateTime.UtcNow)); + + var list = rules.ToList(); + return JsonSerializer.Serialize(new + { + total_count = list.Count, + mute_rules = list.Select(BuildMuteRulePayload) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_mute_rules", ex); + } + } + + /// The per-rule shape get_mute_rules returns AND create_mute_rule echoes back for the stored rule. + private static object BuildMuteRulePayload(MuteRule r) => new + { + id = r.Id, + enabled = r.Enabled, + created_at_utc = r.CreatedAtUtc.ToString("o"), + expires_at_utc = r.ExpiresAtUtc?.ToString("o"), + reason = r.Reason, + server_name = r.ServerName, + metric_name = r.MetricName, + database_pattern = r.DatabasePattern, + query_text_pattern = r.QueryTextPattern, + wait_type_pattern = r.WaitTypePattern, + job_name_pattern = r.JobNamePattern, + summary = r.Summary + }; + + [McpServerTool(Name = "update_alert_settings"), Description( + "Tunes the alert engine's configuration — a PARTIAL update of the single global alert-settings row. Call " + + "get_alert_settings FIRST, change only the fields you want, and pass THOSE fields back here as JSON in the " + + "SAME nested shape get_alert_settings returns (e.g. {\"cpu\":{\"threshold_percent\":90},\"cooldown_minutes\":10}); " + + "every setting you do NOT include is left unchanged. Each provided field is validated against the SAME rules " + + "the Viewer's Settings window enforces — thresholds in range, cpu.mode 'sql'|'total', delivery.mode " + + "'Summary'|'PerEvent', counts within their bounds; an out-of-range value or an unknown field returns " + + "{status:\"invalid\", ...} and writes NOTHING. On success the running service hot-reloads the change within " + + "one collection sweep. SMTP/webhook delivery credentials are managed separately and cannot be set here. " + + "Returns {status:\"updated\", updated_fields:[...], settings:{...}} with the full new settings, or " + + "{status:\"unavailable\"} when the settings row has not been seeded yet.")] + public static async Task UpdateAlertSettings( + NpgsqlDataSource postgres, + [Description("A JSON object with ONLY the alert-settings fields to change, in the nested shape get_alert_settings returns (e.g. {\"cpu\":{\"threshold_percent\":90},\"blocking\":{\"enabled\":false},\"cooldown_minutes\":10}).")] string settings_json) + { + try + { + JsonNode? root; + try + { + root = JsonNode.Parse(settings_json); + } + catch (JsonException ex) + { + return Outcome("invalid", $"settings_json is not valid JSON: {ex.Message}"); + } + + if (root is not JsonObject body) + { + return Outcome("invalid", "settings_json must be a JSON object of the fields to change (see get_alert_settings for the shape)."); + } + + var (updates, error) = BuildAlertSettingsUpdate(body); + if (error != null) + { + return Outcome("invalid", error); + } + + if (updates.Count == 0) + { + return Outcome("invalid", "No known alert-settings fields were provided. Send fields in the shape get_alert_settings returns (e.g. {\"cpu\":{\"threshold_percent\":90}})."); + } + + /* Only the provided columns are written. Column names are this method's compile-time constants (never + the caller's input), so interpolating them into the SET list is injection-safe; every VALUE is a + bound parameter. The single-row config_version self-bump is left to the config-table trigger. */ + var setClause = string.Join(", ", updates.Select((u, i) => $"{u.Column} = ${i + 1}")); + await using var command = postgres.CreateCommand($"UPDATE config_alert_settings SET {setClause} WHERE id = 1"); + foreach (var (_, param) in updates) + { + command.Parameters.Add(param); + } + + var affected = await command.ExecuteNonQueryAsync(); + if (affected == 0) + { + return Outcome("unavailable", + "No alert-settings row is present in the store yet (id = 1). The service seeds it on startup (or the Viewer's Settings window writes it); until then there is nothing to update."); + } + + /* Re-read so the caller sees the authoritative merged state — the write fired the config-table trigger + that self-bumps config_version, so the running service reloads this within one sweep. */ + var reread = await DarlingAlertReader.GetAlertSettingsAsync(postgres); + return JsonSerializer.Serialize(new + { + status = "updated", + updated_fields = updates.Select(u => u.Column).ToArray(), + settings = reread is null ? null : BuildAlertSettingsPayload(reread) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("update_alert_settings", ex); + } + } + + [McpServerTool(Name = "create_mute_rule"), Description( + "Creates an alert mute rule that suppresses matching alerts (they are still logged, just not delivered) — " + + "the same rules get_mute_rules lists and the Viewer's Manage Mute Rules surface writes. Provide any " + + "combination of the scope/pattern fields; a field left out does not narrow the rule, so a rule with NO " + + "fields set matches — and mutes — EVERY alert (a whole-fleet silence). server_name and metric_name match " + + "the alert's exact values (see get_alert_history / get_alert_settings for the names in use); the *_pattern " + + "fields are case-insensitive substring matches. expires_at is an optional ISO-8601 UTC timestamp after " + + "which the rule stops applying; omit it for a permanent rule. Returns the stored rule, including its " + + "generated id (for delete_mute_rule).")] + public static async Task CreateMuteRule( + NpgsqlDataSource postgres, + [Description("Scope the rule to this server (its display name, as get_alert_history reports). Omit for all servers.")] string? server_name = null, + [Description("Scope to this alert metric (e.g. 'High CPU', 'Blocking Detected', 'Deadlocks Detected'). Omit for all metrics.")] string? metric_name = null, + [Description("Case-insensitive substring the alert's database name must contain. Omit for any database.")] string? database_pattern = null, + [Description("Case-insensitive substring the alert's query text must contain. Omit for any query.")] string? query_text_pattern = null, + [Description("Case-insensitive substring the alert's wait type must contain. Omit for any wait type.")] string? wait_type_pattern = null, + [Description("Case-insensitive substring the alert's job name must contain. Omit for any job.")] string? job_name_pattern = null, + [Description("Optional human-readable reason, shown in the mute-rule list.")] string? reason = null, + [Description("Optional ISO-8601 UTC expiry (e.g. 2026-08-01T00:00:00Z); after this the rule no longer mutes. Omit for a permanent rule.")] string? expires_at = null) + { + try + { + DateTime? expiresAtUtc = null; + if (!string.IsNullOrWhiteSpace(expires_at)) + { + if (!DateTime.TryParse(expires_at, CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var parsed)) + { + return Outcome("invalid", $"expires_at '{expires_at}' is not a valid ISO-8601 timestamp (e.g. 2026-08-01T00:00:00Z)."); + } + + expiresAtUtc = DateTime.SpecifyKind(parsed, DateTimeKind.Utc); + } + + /* A new MuteRule defaults Id to a fresh GUID — the SAME id-generation the Viewer's mute-create path + uses (MuteRuleEditDialog builds a `new MuteRule()`), persisted through the SAME PgMuteRuleStore. */ + var rule = new MuteRule + { + Enabled = true, + CreatedAtUtc = DateTime.UtcNow, + ExpiresAtUtc = expiresAtUtc, + Reason = Trimmed(reason), + ServerName = Trimmed(server_name), + MetricName = Trimmed(metric_name), + DatabasePattern = Trimmed(database_pattern), + QueryTextPattern = Trimmed(query_text_pattern), + WaitTypePattern = Trimmed(wait_type_pattern), + JobNamePattern = Trimmed(job_name_pattern) + }; + + await new PgMuteRuleStore(postgres).InsertAsync(rule); + return JsonSerializer.Serialize(new { status = "created", mute_rule = BuildMuteRulePayload(rule) }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("create_mute_rule", ex); + } + } + + [McpServerTool(Name = "delete_mute_rule"), Description( + "Deletes an alert mute rule by its id (from get_mute_rules or create_mute_rule). Returns " + + "{status:\"deleted\", rule_id} on success, or {status:\"not_found\"} when no rule has that id. Permanent.")] + public static async Task DeleteMuteRule( + NpgsqlDataSource postgres, + [Description("The id of the mute rule to delete (from get_mute_rules or create_mute_rule).")] string rule_id) + { + try + { + if (string.IsNullOrWhiteSpace(rule_id)) + { + return Outcome("invalid", "rule_id is required."); + } + + /* Existence check through the SAME store get_mute_rules reads, so 'deleted' vs 'not_found' is honest + (PgMuteRuleStore.DeleteAsync is a bare DELETE that reports no row count). */ + var store = new PgMuteRuleStore(postgres); + var rules = await store.LoadAllAsync(); + if (!rules.Any(r => string.Equals(r.Id, rule_id, StringComparison.Ordinal))) + { + return Outcome("not_found", $"No mute rule with id '{rule_id}'."); + } + + await store.DeleteAsync(rule_id); + return JsonSerializer.Serialize(new { status = "deleted", rule_id }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("delete_mute_rule", ex); + } + } + + private static string? Trimmed(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + /// A small {status, message} envelope for a non-data write outcome (invalid / not_found / unavailable) + /// — the same shape uses, so an MCP client can branch on the outcome + /// kind. A successful update/create/delete returns its own data-bearing shape, not this. + private static string Outcome(string status, string message) => + JsonSerializer.Serialize(new { status, message }, McpHelpers.JsonOptions); + + /// + /// Parses a PARTIAL alert-settings update — the nested JSON shape get_alert_settings RETURNS, carrying only the + /// fields to change — into an ordered list of (column, bound parameter) pairs, validating each provided field + /// against the SAME ranges/enums the Viewer's Settings window enforces + /// (SettingsWindow.BuildAlertRowFromControls). Returns a non-null Error — and the caller writes + /// nothing — on the FIRST bad value or unknown field (top-level or nested). Column names are this method's + /// compile-time constants (never the input), so interpolating them into the UPDATE's SET list is injection-safe. + /// + private static (List<(string Column, NpgsqlParameter Param)> Updates, string? Error) BuildAlertSettingsUpdate(JsonObject body) + { + var updates = new List<(string Column, NpgsqlParameter Param)>(); + string? error = null; + + void AddBool(string column, JsonNode? node, string field) + { + if (error != null) return; + if (node is JsonValue v && v.TryGetValue(out var b)) + updates.Add((column, new NpgsqlParameter { TypedValue = b })); + else + error = $"'{field}' must be true or false."; + } + + void AddInt(string column, JsonNode? node, string field, int min, int max) + { + if (error != null) return; + if (node is JsonValue v && v.TryGetValue(out var i)) + { + if (i < min || i > max) + error = $"'{field}' must be an integer between {min} and {max}."; + else + updates.Add((column, new NpgsqlParameter { TypedValue = i })); + } + else + { + error = $"'{field}' must be an integer between {min} and {max}."; + } + } + + void AddDouble(string column, JsonNode? node, string field, double min, double max) + { + if (error != null) return; + if (node is JsonValue v && v.TryGetValue(out var d)) + { + if (d < min || d > max) + error = $"'{field}' must be a number between {min.ToString("0.0", CultureInfo.InvariantCulture)} and {max.ToString("0.0", CultureInfo.InvariantCulture)}."; + else + updates.Add((column, new NpgsqlParameter { TypedValue = d })); + } + else + { + error = $"'{field}' must be a number between {min.ToString("0.0", CultureInfo.InvariantCulture)} and {max.ToString("0.0", CultureInfo.InvariantCulture)}."; + } + } + + void AddEnum(string column, JsonNode? node, string field, params string[] allowed) + { + if (error != null) return; + if (node is JsonValue v && v.TryGetValue(out var s)) + { + var match = allowed.FirstOrDefault(a => string.Equals(a, s, StringComparison.OrdinalIgnoreCase)); + if (match == null) + error = $"'{field}' must be one of: {string.Join(", ", allowed)}."; + else + updates.Add((column, new NpgsqlParameter { TypedValue = match })); + } + else + { + error = $"'{field}' must be one of: {string.Join(", ", allowed)}."; + } + } + + void AddStringArray(string column, JsonNode? node, string field) + { + if (error != null) return; + if (node is JsonArray arr) + { + var array = new List(); + foreach (var el in arr) + { + if (el is JsonValue ev && ev.TryGetValue(out var s)) + { + array.Add(s); + } + else + { + error = $"'{field}' must be an array of strings."; + return; + } + } + + updates.Add((column, new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Array | NpgsqlDbType.Text, Value = array.ToArray() })); + } + else + { + error = $"'{field}' must be an array of strings."; + } + } + + /* Descends one nested group (e.g. "cpu": {...}); an unknown key inside it is rejected like an unknown + top-level field, so the whole write is refused rather than silently ignoring a typo'd field. */ + void Group(JsonNode? node, string group, Action handleKey) + { + if (error != null) return; + if (node is not JsonObject obj) + { + error = $"'{group}' must be an object."; + return; + } + + foreach (var kv in obj) + { + if (error != null) return; + handleKey(kv.Key, kv.Value); + } + } + + foreach (var prop in body) + { + if (error != null) break; + switch (prop.Key) + { + case "alerts_enabled": AddBool("enabled", prop.Value, "alerts_enabled"); break; + case "notify_connection_changes": AddBool("notify_connection_changes", prop.Value, "notify_connection_changes"); break; + case "cooldown_minutes": AddInt("cooldown_minutes", prop.Value, "cooldown_minutes", 1, 120); break; + case "excluded_databases": AddStringArray("excluded_databases", prop.Value, "excluded_databases"); break; + + case "cpu": + Group(prop.Value, "cpu", (k, n) => + { + switch (k) + { + case "enabled": AddBool("cpu_enabled", n, "cpu.enabled"); break; + case "threshold_percent": AddInt("cpu_threshold_percent", n, "cpu.threshold_percent", 1, 100); break; + case "mode": AddEnum("cpu_mode", n, "cpu.mode", "sql", "total"); break; + default: error = $"Unknown field 'cpu.{k}'."; break; + } + }); + break; + + case "blocking": + Group(prop.Value, "blocking", (k, n) => + { + switch (k) + { + case "enabled": AddBool("blocking_enabled", n, "blocking.enabled"); break; + case "count_threshold": AddInt("blocking_count_threshold", n, "blocking.count_threshold", 1, int.MaxValue); break; + default: error = $"Unknown field 'blocking.{k}'."; break; + } + }); + break; + + case "deadlocks": + Group(prop.Value, "deadlocks", (k, n) => + { + switch (k) + { + case "enabled": AddBool("deadlock_enabled", n, "deadlocks.enabled"); break; + case "count_threshold": AddInt("deadlock_count_threshold", n, "deadlocks.count_threshold", 1, int.MaxValue); break; + default: error = $"Unknown field 'deadlocks.{k}'."; break; + } + }); + break; + + case "poison_wait": + Group(prop.Value, "poison_wait", (k, n) => + { + switch (k) + { + case "enabled": AddBool("poison_wait_enabled", n, "poison_wait.enabled"); break; + case "threshold_ms": AddInt("poison_wait_threshold_ms", n, "poison_wait.threshold_ms", 1, int.MaxValue); break; + default: error = $"Unknown field 'poison_wait.{k}'."; break; + } + }); + break; + + case "long_running_query": + Group(prop.Value, "long_running_query", (k, n) => + { + switch (k) + { + case "enabled": AddBool("long_running_query_enabled", n, "long_running_query.enabled"); break; + case "threshold_minutes": AddInt("long_running_query_threshold_minutes", n, "long_running_query.threshold_minutes", 1, int.MaxValue); break; + case "max_results": AddInt("long_running_query_max_results", n, "long_running_query.max_results", 1, 1000); break; + case "exclude_sp_server_diagnostics": AddBool("long_running_query_exclude_sp_server_diagnostics", n, "long_running_query.exclude_sp_server_diagnostics"); break; + case "exclude_wait_for": AddBool("long_running_query_exclude_wait_for", n, "long_running_query.exclude_wait_for"); break; + case "exclude_backups": AddBool("long_running_query_exclude_backups", n, "long_running_query.exclude_backups"); break; + case "exclude_misc_waits": AddBool("long_running_query_exclude_misc_waits", n, "long_running_query.exclude_misc_waits"); break; + case "exclude_cdc": AddBool("long_running_query_exclude_cdc", n, "long_running_query.exclude_cdc"); break; + default: error = $"Unknown field 'long_running_query.{k}'."; break; + } + }); + break; + + case "tempdb_space": + Group(prop.Value, "tempdb_space", (k, n) => + { + switch (k) + { + case "enabled": AddBool("tempdb_space_enabled", n, "tempdb_space.enabled"); break; + case "threshold_percent": AddInt("tempdb_space_threshold_percent", n, "tempdb_space.threshold_percent", 1, 100); break; + default: error = $"Unknown field 'tempdb_space.{k}'."; break; + } + }); + break; + + case "low_disk": + Group(prop.Value, "low_disk", (k, n) => + { + switch (k) + { + case "enabled": AddBool("low_disk_enabled", n, "low_disk.enabled"); break; + case "threshold_percent": AddInt("low_disk_threshold_percent", n, "low_disk.threshold_percent", 0, 100); break; + case "threshold_gb": AddInt("low_disk_threshold_gb", n, "low_disk.threshold_gb", 0, int.MaxValue); break; + default: error = $"Unknown field 'low_disk.{k}'."; break; + } + }); + break; + + case "long_running_job": + Group(prop.Value, "long_running_job", (k, n) => + { + switch (k) + { + case "enabled": AddBool("long_running_job_enabled", n, "long_running_job.enabled"); break; + case "multiplier": AddInt("long_running_job_multiplier", n, "long_running_job.multiplier", 2, 20); break; + default: error = $"Unknown field 'long_running_job.{k}'."; break; + } + }); + break; + + case "failed_job": + Group(prop.Value, "failed_job", (k, n) => + { + switch (k) + { + case "enabled": AddBool("failed_job_enabled", n, "failed_job.enabled"); break; + case "lookback_minutes": AddInt("failed_job_lookback_minutes", n, "failed_job.lookback_minutes", 1, 1440); break; + default: error = $"Unknown field 'failed_job.{k}'."; break; + } + }); + break; + + case "delivery": + Group(prop.Value, "delivery", (k, n) => + { + switch (k) + { + case "mode": AddEnum("delivery_mode", n, "delivery.mode", "Summary", "PerEvent"); break; + case "per_event_max": AddInt("per_event_max", n, "delivery.per_event_max", 1, 100); break; + default: error = $"Unknown field 'delivery.{k}'."; break; + } + }); + break; + + case "analysis": + Group(prop.Value, "analysis", (k, n) => + { + switch (k) + { + case "enabled": AddBool("analysis_enabled", n, "analysis.enabled"); break; + case "interval_minutes": AddInt("analysis_interval_minutes", n, "analysis.interval_minutes", 5, 360); break; + case "notifications_enabled": AddBool("analysis_notifications_enabled", n, "analysis.notifications_enabled"); break; + case "notify_severity": AddDouble("analysis_notify_severity", n, "analysis.notify_severity", 0.0, 2.0); break; + default: error = $"Unknown field 'analysis.{k}'."; break; + } + }); + break; + + default: + error = $"Unknown field '{prop.Key}'. Send fields in the nested shape get_alert_settings returns."; + break; + } + } + + return (updates, error); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs new file mode 100644 index 000000000..f3a0a9d8f --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs @@ -0,0 +1,322 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.ComponentModel; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using ModelContextProtocol.Server; +using Npgsql; +using PerformanceMonitor.Common; + +#pragma warning disable CA1707 // MCP tools use snake_case naming convention + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// The blocking / deadlock diagnostic-depth MCP tools — get_blocking, get_deadlocks, +/// get_deadlock_detail, get_blocked_process_xml — served over Darling's Postgres store. Each tool body +/// mirrors LITE's McpBlockingTools field-for-field (the store-faithful shape Darling's +/// collector-mirror schema can serve, the precedent slices 1+2 set), keeping the tool names + parameter +/// contracts the Dashboard and Lite expose. Reads flow through — a +/// STORED read (no live monitored-server hit) that reproduces the viewer's XE-preferred + DMV-fallback +/// merge and the parsed deadlock process-summary. +/// +/// +/// get_blocking carries the Dashboard tool name + params but returns the store-native blocked/blocking +/// pair rows (the shape Lite's get_blocked_process_reports emits) — the Dashboard's pre-computed +/// blocking_tree / wait_time_sec / activity come from its SQL-Server-side wide store +/// and have no column in the Postgres store, so following LITE here keeps the result honest to the +/// collected data. get_blocking_deadlock_stats is intentionally NOT hosted: Darling has no +/// blocking_deadlock_stats aggregate table (the Dashboard populates its collect.blocking_deadlock_stats +/// via a T-SQL analyzer that has no Darling port) and the delta / victim-wait columns have no raw source — +/// a collection gap reported for a later slice rather than faked. +/// +/// +[McpServerToolType] +public sealed class DarlingMcpBlockingTools +{ + [McpServerTool(Name = "get_blocking"), Description("Gets blocking events captured by the blocked process report extended event (plus the always-on DMV blocking-snapshot fallback). Shows the blocked and blocking sessions, wait types, wait times, and query text for both. Use this first for a quick overview, then use get_blocked_process_xml for deep analysis of prolonged blocking.")] + public static async Task GetBlocking( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description("Maximum rows. Default 30.")] int limit = 30) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + validation = McpHelpers.ValidateTop(limit); + if (validation != null) return validation; + + try + { + var now = DateTime.UtcNow; + var rows = await DarlingBlockingReader.GetRecentBlockedProcessReportsAsync( + postgres, resolved.Value.ServerId, now.AddHours(-hours_back), now); + if (rows.Count == 0) + return McpHelpers.Status("empty", "No blocking events found in the specified time range."); + + var result = rows.Take(limit).Select(r => new + { + event_time = r.EventTime?.ToString("o"), + source = r.Source, + database_name = r.DatabaseName, + blocked_spid = r.BlockedSpid, + blocked_ecid = r.BlockedEcid, + blocking_spid = r.BlockingSpid, + blocking_ecid = r.BlockingEcid, + wait_time_ms = r.WaitTimeMs, + wait_resource = r.WaitResource, + lock_mode = r.LockMode, + contentious_object = string.IsNullOrEmpty(r.ContentiousObject) ? null : r.ContentiousObject, + blocked_status = r.BlockedStatus, + blocked_isolation_level = r.BlockedIsolationLevel, + blocked_log_used = r.BlockedLogUsed, + blocked_transaction_count = r.BlockedTransactionCount, + blocked_client_app = r.BlockedClientApp, + blocked_host_name = r.BlockedHostName, + blocked_login_name = r.BlockedLoginName, + blocked_sql_text = McpHelpers.Truncate(r.BlockedSqlText, 2000), + blocking_status = r.BlockingStatus, + blocking_isolation_level = r.BlockingIsolationLevel, + blocking_client_app = r.BlockingClientApp, + blocking_host_name = r.BlockingHostName, + blocking_login_name = r.BlockingLoginName, + blocking_sql_text = McpHelpers.Truncate(r.BlockingSqlText, 2000), + blocked_transaction_name = r.BlockedTransactionName, + blocking_transaction_name = r.BlockingTransactionName, + blocked_last_tran_started = r.BlockedLastTranStarted?.ToString("o"), + blocking_last_tran_started = r.BlockingLastTranStarted?.ToString("o"), + blocked_last_batch_started = r.BlockedLastBatchStarted?.ToString("o"), + blocking_last_batch_started = r.BlockingLastBatchStarted?.ToString("o"), + blocked_last_batch_completed = r.BlockedLastBatchCompleted?.ToString("o"), + blocking_last_batch_completed = r.BlockingLastBatchCompleted?.ToString("o"), + blocked_priority = r.BlockedPriority, + blocking_priority = r.BlockingPriority, + has_report_xml = r.HasReportXml + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + total_events = rows.Count, + events = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_blocking", ex); + } + } + + [McpServerTool(Name = "get_deadlocks"), Description("Gets recent deadlock events with victim process info. Deadlocks occur when two or more sessions permanently block each other. Use get_deadlock_detail for the full deadlock graph XML.")] + public static async Task GetDeadlocks( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description("Maximum rows. Default 20.")] int limit = 20) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + validation = McpHelpers.ValidateTop(limit); + if (validation != null) return validation; + + try + { + var now = DateTime.UtcNow; + var rows = await DarlingBlockingReader.GetRecentDeadlocksAsync( + postgres, resolved.Value.ServerId, now.AddHours(-hours_back), now); + if (rows.Count == 0) + return McpHelpers.Status("empty", "No deadlocks found in the specified time range."); + + var result = rows.Take(limit).Select(r => new + { + collection_time = r.CollectionTime.ToString("o"), + deadlock_time = r.DeadlockTime?.ToString("o"), + victim_process_id = r.VictimProcessId, + victim_sql_text = McpHelpers.Truncate(r.VictimSqlText, 2000), + process_summary = r.ProcessSummary, + has_deadlock_xml = r.HasDeadlockXml + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + total_deadlocks = rows.Count, + deadlocks = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_deadlocks", ex); + } + } + + [McpServerTool(Name = "get_deadlock_detail"), Description("Gets the full deadlock graph XML for a specific time range. Returns the raw XML that can be analyzed for lock resources, process details, and deadlock chains.")] + public static async Task GetDeadlockDetail( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description("Maximum deadlocks to return. Default 5.")] int limit = 5) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + validation = McpHelpers.ValidateTop(limit); + if (validation != null) return validation; + + try + { + var now = DateTime.UtcNow; + var rows = await DarlingBlockingReader.GetRecentDeadlocksAsync( + postgres, resolved.Value.ServerId, now.AddHours(-hours_back), now); + var withXml = rows.Where(r => r.HasDeadlockXml).Take(limit).ToList(); + if (withXml.Count == 0) + return McpHelpers.Status("empty", "No deadlock XML available in the specified time range."); + + var result = withXml.Select(r => new + { + collection_time = r.CollectionTime.ToString("o"), + deadlock_time = r.DeadlockTime?.ToString("o"), + victim_process_id = r.VictimProcessId, + deadlock_graph_xml = r.DeadlockGraphXml + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + deadlocks = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_deadlock_detail", ex); + } + } + + [McpServerTool(Name = "get_blocked_process_xml"), Description("Gets the raw blocked process report XML from extended events. Contains full detail about both the blocked and blocking sessions for deep analysis.")] + public static async Task GetBlockedProcessXml( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description("Maximum reports to return. Default 5.")] int limit = 5) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + validation = McpHelpers.ValidateTop(limit); + if (validation != null) return validation; + + try + { + var now = DateTime.UtcNow; + var rows = await DarlingBlockingReader.GetRecentBlockedProcessReportsAsync( + postgres, resolved.Value.ServerId, now.AddHours(-hours_back), now); + var withXml = rows.Where(r => r.HasReportXml).Take(limit).ToList(); + if (withXml.Count == 0) + return McpHelpers.Status("empty", "No blocked process report XML available in the specified time range."); + + var result = withXml.Select(r => new + { + event_time = r.EventTime?.ToString("o"), + database_name = r.DatabaseName, + blocked_spid = r.BlockedSpid, + blocking_spid = r.BlockingSpid, + wait_time_ms = r.WaitTimeMs, + blocked_process_report_xml = r.BlockedProcessReportXml + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + reports = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_blocked_process_xml", ex); + } + } + + [McpServerTool(Name = "get_blocking_trend"), Description("Gets a time-series of blocking event counts per minute over time (blocked process reports, falling back to the always-on DMV blocking snapshot). Useful for identifying patterns (e.g., blocking spikes during batch jobs) or confirming whether blocking is a new, worsening, or resolved issue.")] + public static async Task GetBlockingTrend( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history. Default 24.")] int hours_back = 24) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + + try + { + var now = DateTime.UtcNow; + var points = await DarlingBlockingTrendReader.GetBlockingTrendAsync( + postgres, resolved.Value.ServerId, now.AddHours(-hours_back), now); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + trend = points.Select(p => new { time = p.Time.ToString("o"), count = p.Count }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_blocking_trend", ex); + } + } + + [McpServerTool(Name = "get_deadlock_trend"), Description("Gets a time-series of deadlock event counts per minute over time. Useful for identifying patterns or confirming whether deadlock issues are new, worsening, or resolved.")] + public static async Task GetDeadlockTrend( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history. Default 24.")] int hours_back = 24) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + + try + { + var now = DateTime.UtcNow; + var points = await DarlingBlockingTrendReader.GetDeadlockTrendAsync( + postgres, resolved.Value.ServerId, now.AddHours(-hours_back), now); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + trend = points.Select(p => new { time = p.Time.ToString("o"), count = p.Count }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_deadlock_trend", ex); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigHistoryTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigHistoryTools.cs new file mode 100644 index 000000000..93a931f90 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigHistoryTools.cs @@ -0,0 +1,254 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using ModelContextProtocol.Server; +using Npgsql; +using PerformanceMonitor.Common; + +#pragma warning disable CA1707 // MCP tools use snake_case naming convention + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// The config / trace-flag diagnostic-depth MCP tools — get_server_config_changes, +/// get_database_config_changes, get_trace_flag_changes (the Dashboard's change-history names) and +/// get_database_scoped_config (the Lite latest-snapshot name) — served over Darling's Postgres store. The +/// three change tools diff the store's append-only config snapshots (the Dashboard reads pre-materialized +/// report.*_changes tables that Darling does not have, so Darling computes the diff from the raw +/// snapshot history via ); get_database_scoped_config ports Lite's +/// tool over the viewer's latest-snapshot read. All are STORED reads (no live monitored-server hit). +/// +/// +/// Each change tool's Description states the two honest caveats plainly (they are NOT silently dropped): +/// (1) config is captured ON CONNECT only, so change granularity equals the connect/restart cadence and a +/// stable deployment may show no changes until the next connect; (2) the tools emit only the values Darling +/// collects — the Dashboard's requires_restart / description / setting_type / +/// generated change_description enrichment is not collected here and is omitted. +/// +/// +[McpServerToolType] +public sealed class DarlingMcpConfigHistoryTools +{ + [McpServerTool(Name = "get_server_config_changes"), Description("Gets server configuration change history by diffing sp_configure (sys.configurations) snapshots. Shows which settings changed and their old vs new configured/in-use values. NOTE: this edition captures config on server connect (not on a fixed schedule), so changes are detected between connect snapshots and need at least two; requires_restart and the setting description are not collected and are omitted.")] + public static async Task GetServerConfigChanges( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history to retrieve. Default 168 (7 days).")] int hours_back = 168) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + + try + { + var windowStart = NaiveUtcNow().AddHours(-hours_back); + var snapshots = await DarlingConfigHistoryReader.GetServerConfigSnapshotsAsync(postgres, resolved.Value.ServerId); + /* The tool reads the full unbounded history and only lower-bounds, so pass DateTime.MaxValue as the + (no-op) upper edge — the shared both-edges diff then reproduces the prior behavior exactly. */ + var changes = ConfigChangeDiff.DiffServerConfigChanges(snapshots, windowStart, DateTime.MaxValue); + if (changes.Count == 0) + return NoChanges(resolved.Value.ServerName, hours_back, DistinctCaptures(snapshots.Select(s => s.CaptureTime))); + + var result = changes.Select(c => new + { + change_time = c.ChangeTime.ToString("o"), + configuration_name = c.ConfigurationName, + old_value_configured = c.OldValueConfigured, + new_value_configured = c.NewValueConfigured, + old_value_in_use = c.OldValueInUse, + new_value_in_use = c.NewValueInUse, + is_dynamic = c.IsDynamic, + is_advanced = c.IsAdvanced + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + change_count = changes.Count, + changes = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_server_config_changes", ex); + } + } + + [McpServerTool(Name = "get_database_config_changes"), Description("Gets database configuration change history by diffing sys.databases snapshots. Shows which database settings changed (recovery model, RCSI, compatibility level, etc.) with old and new values; setting_name is the underlying column name. NOTE: this edition captures config on server connect (not on a fixed schedule), so changes are detected between connect snapshots and need at least two; the setting category/description narrative is not collected and is omitted.")] + public static async Task GetDatabaseConfigChanges( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history to retrieve. Default 168 (7 days).")] int hours_back = 168) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + + try + { + var windowStart = NaiveUtcNow().AddHours(-hours_back); + var snapshots = await DarlingConfigHistoryReader.GetDatabaseConfigSnapshotsAsync(postgres, resolved.Value.ServerId); + var changes = ConfigChangeDiff.DiffDatabaseConfigChanges(snapshots, windowStart, DateTime.MaxValue); + if (changes.Count == 0) + return NoChanges(resolved.Value.ServerName, hours_back, DistinctCaptures(snapshots.Select(s => s.CaptureTime))); + + var result = changes.Select(c => new + { + change_time = c.ChangeTime.ToString("o"), + database_name = c.DatabaseName, + setting_name = c.SettingName, + old_value = c.OldValue, + new_value = c.NewValue + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + change_count = changes.Count, + changes = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_database_config_changes", ex); + } + } + + [McpServerTool(Name = "get_trace_flag_changes"), Description("Gets trace flag change history by diffing DBCC TRACESTATUS snapshots. Shows which trace flags were enabled or disabled (change_type), with scope (global/session) and the change time. NOTE: this edition captures config on server connect (not on a fixed schedule), so changes are detected between connect snapshots and need at least two.")] + public static async Task GetTraceFlagChanges( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history to retrieve. Default 168 (7 days).")] int hours_back = 168) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + + try + { + var windowStart = NaiveUtcNow().AddHours(-hours_back); + var snapshots = await DarlingConfigHistoryReader.GetTraceFlagSnapshotsAsync(postgres, resolved.Value.ServerId); + var changes = ConfigChangeDiff.DiffTraceFlagChanges(snapshots, windowStart, DateTime.MaxValue); + if (changes.Count == 0) + return NoChanges(resolved.Value.ServerName, hours_back, DistinctCaptures(snapshots.Select(s => s.CaptureTime))); + + var result = changes.Select(c => new + { + change_time = c.ChangeTime.ToString("o"), + trace_flag = c.TraceFlag, + change_type = c.ChangeType, + previous_status = c.PreviousStatus, + new_status = c.NewStatus, + scope = Scope(c.IsGlobal, c.IsSession), + is_global = c.IsGlobal, + is_session = c.IsSession + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + change_count = changes.Count, + changes = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_trace_flag_changes", ex); + } + } + + [McpServerTool(Name = "get_database_scoped_config"), Description("Gets database-scoped configuration settings (sys.database_scoped_configurations). Shows MAXDOP, legacy CE, parameter sniffing, and other per-database settings.")] + public static async Task GetDatabaseScopedConfig( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Filter to a specific database. Omit for all databases.")] string? database_name = null) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + try + { + var rows = await DarlingConfigHistoryReader.GetLatestDatabaseScopedConfigAsync(postgres, resolved.Value.ServerId); + if (rows.Count == 0) + return McpHelpers.Status( + "unavailable", + "No database-scoped configuration data available. The config collector may not have run yet."); + + IEnumerable filtered = rows; + if (!string.IsNullOrEmpty(database_name)) + filtered = filtered.Where(r => r.DatabaseName.Equals(database_name, StringComparison.OrdinalIgnoreCase)); + + var grouped = filtered + .GroupBy(r => r.DatabaseName) + .Select(g => new + { + database_name = g.Key, + settings = g.Select(r => new + { + name = r.ConfigurationName, + value = r.Value, + value_for_secondary = string.IsNullOrEmpty(r.ValueForSecondary) ? null : r.ValueForSecondary + }) + }).ToList(); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + database_count = grouped.Count, + databases = grouped + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_database_scoped_config", ex); + } + } + + /// The number of distinct config CAPTURES (capture_time values) among the snapshot rows — the + /// reads return one row per setting/database/flag per capture, so a raw row count would never hit the + /// "fewer than two snapshots" branch on a freshly-connected server. + private static int DistinctCaptures(IEnumerable captureTimes) => + captureTimes.Distinct().Count(); + + /// The "empty" miss for a change tool — distinguishes "no snapshots collected yet" from "snapshots + /// exist but nothing changed", so a caller understands why the history is empty (the on-connect cadence). + private static string NoChanges(string serverName, int hoursBack, int snapshotCount) => + McpHelpers.Status( + "empty", + snapshotCount <= 1 + ? "No configuration change history yet: fewer than two config snapshots have been captured for this server. Config is captured when the service connects to the server, so changes appear once a second connect snapshot exists." + : $"No configuration changes detected in the last {hoursBack}h across the captured snapshots.", + new { server = serverName, snapshot_count = snapshotCount }); + + /// Global / Session / Global+Session scope label from the two flags. + private static string Scope(bool? isGlobal, bool? isSession) => + (isGlobal == true, isSession == true) switch + { + (true, true) => "Global+Session", + (true, false) => "Global", + (false, true) => "Session", + _ => "", + }; + + private static DateTime NaiveUtcNow() => DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified); +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigTools.cs new file mode 100644 index 000000000..47e6395c1 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigTools.cs @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using ModelContextProtocol.Server; +using Npgsql; +using PerformanceMonitor.Common; + +#pragma warning disable CA1707 // MCP tools use snake_case naming convention + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// The current-config snapshot MCP tools — get_server_config, get_database_config, get_trace_flags — served +/// over Darling's Postgres store, the same names Lite exposes. Where the sibling +/// diffs config snapshots into a CHANGE history (empty on a stable +/// server), these answer "what is it set to RIGHT NOW" — the most recent capture per server, via the +/// latest-snapshot reads (MAX(capture_time) over the config +/// passthrough views). All STORED reads (no live monitored-server hit); each tool body mirrors Lite's +/// McpConfigTools field-for-field so an MCP client sees one consistent product across the SKUs. +/// +[McpServerToolType] +public sealed class DarlingMcpConfigTools +{ + [McpServerTool(Name = "get_server_config"), Description("Gets the current SQL Server instance configuration (sys.configurations). Shows all sp_configure settings with configured and in-use values. Useful for checking CTFP, MAXDOP, max memory, and other instance-level settings right now (unlike get_server_config_changes, which shows only what changed between connect snapshots).")] + public static async Task GetServerConfig( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + try + { + var rows = await DarlingCurrentConfigReader.GetLatestServerConfigAsync(postgres, resolved.Value.ServerId); + if (rows.Count == 0) + return McpHelpers.Status( + "unavailable", + "No server configuration data available. The config collector may not have run yet."); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + setting_count = rows.Count, + settings = rows.Select(r => new + { + name = r.ConfigurationName, + value_configured = r.ValueConfigured, + value_in_use = r.ValueInUse, + values_match = r.ValuesMatch, + is_dynamic = r.IsDynamic, + is_advanced = r.IsAdvanced + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_server_config", ex); + } + } + + [McpServerTool(Name = "get_database_config"), Description("Gets database-level configuration for all databases (sys.databases). Shows recovery model, RCSI, auto-shrink, auto-close, Query Store, compatibility level, page verify, and other settings. Critical for identifying misconfigured databases.")] + public static async Task GetDatabaseConfig( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Filter to a specific database. Omit for all databases.")] string? database_name = null) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + try + { + var rows = await DarlingCurrentConfigReader.GetLatestDatabaseConfigAsync(postgres, resolved.Value.ServerId); + if (rows.Count == 0) + return McpHelpers.Status( + "unavailable", + "No database configuration data available. The config collector may not have run yet."); + + IEnumerable filtered = rows; + if (!string.IsNullOrEmpty(database_name)) + filtered = filtered.Where(r => r.DatabaseName.Equals(database_name, StringComparison.OrdinalIgnoreCase)); + + var result = filtered.Select(r => new + { + database_name = r.DatabaseName, + state = r.StateDesc, + compatibility_level = r.CompatibilityLevel, + recovery_model = r.RecoveryModel, + rcsi = r.IsRcsiOn, + snapshot_isolation = r.SnapshotIsolationState, + auto_close = r.IsAutoCloseOn, + auto_shrink = r.IsAutoShrinkOn, + auto_create_stats = r.IsAutoCreateStatsOn, + auto_update_stats = r.IsAutoUpdateStatsOn, + auto_update_stats_async = r.IsAutoUpdateStatsAsyncOn, + query_store = r.IsQueryStoreOn, + page_verify = r.PageVerifyOption, + parameterization_forced = r.IsParameterizationForced, + delayed_durability = r.DelayedDurability, + target_recovery_time_seconds = r.TargetRecoveryTimeSeconds, + encrypted = r.IsEncrypted, + accelerated_database_recovery = r.IsAcceleratedDatabaseRecoveryOn, + optimized_locking = r.IsOptimizedLockingOn, + log_reuse_wait = r.LogReuseWaitDesc + }).ToList(); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + database_count = result.Count, + databases = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_database_config", ex); + } + } + + [McpServerTool(Name = "get_trace_flags"), Description("Gets active trace flags on the SQL Server instance. Shows flag number, enabled status, and whether the flag is global or session-scoped.")] + public static async Task GetTraceFlags( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + try + { + var rows = await DarlingCurrentConfigReader.GetLatestTraceFlagsAsync(postgres, resolved.Value.ServerId); + if (rows.Count == 0) + return McpHelpers.Status("empty", "No trace flags found (none enabled, or the config collector has not run yet)."); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + trace_flag_count = rows.Count, + trace_flags = rows.Select(r => new + { + trace_flag = r.TraceFlag, + enabled = r.Status, + is_global = r.IsGlobal, + is_session = r.IsSession + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_trace_flags", ex); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpCustomViewTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpCustomViewTools.cs new file mode 100644 index 000000000..17ba37306 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpCustomViewTools.cs @@ -0,0 +1,283 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using ModelContextProtocol.Server; +using Npgsql; +using PerformanceMonitor.Common; + +#pragma warning disable CA1707 // MCP tools use snake_case naming convention + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// The Custom Views v2 (#1563) MANAGEMENT tools — the one WRITE surface on Darling's MCP server. They let an MCP +/// client list / read / validate / create / update / delete the user-authored views stored in +/// config.custom_views (the same table + composer the web viewer's editor writes), and run back a single +/// composed panel's DATA so a generated view can be checked end-to-end. +/// +/// No divergent second implementation. Every tool routes through the EXISTING authority, never a +/// parallel copy: persistence is (the same typed CRUD + optimistic-concurrency + +/// duplicate-name detection the web CRUD uses); structural validation is +/// (the write-time authority routing through ComposeSpec.TryParsePanel and the hand-authored measure +/// catalog); the panel run is the SHARED that also backs +/// the web /api/compose/run; and the returned view shape is the SAME +/// / the +/// REST surface serves. Validation always runs BEFORE persistence on create/update, so a stored view can always +/// compile and render. +/// +/// Security. These tools connect (like every MCP tool) as the least-privilege mcp role, which +/// is granted INSERT/UPDATE/DELETE on ONLY config.custom_views (see ) — the +/// same single-table write the viewer role carries for the web composer. A token-holder can therefore +/// create/modify/delete custom views but still cannot reach the config_command service-credential pivot or +/// the carved secret columns, and a stored view is structurally incapable of reading a config control-plane table +/// (a composed query names only collect.* collector tables). Custom-view JSON carries no secrets. The +/// widened write is deliberate and documented in the README's MCP blast-radius section. +/// +[McpServerToolType] +public sealed class DarlingMcpCustomViewTools +{ + [McpServerTool(Name = "list_custom_views"), Description( + "Lists every saved custom view (dashboards and notebooks) as lightweight summaries — id, name, description, " + + "kind ('dashboard' or 'notebook'), version, and who/when it was last updated. No definition body. Use the " + + "id with get_custom_view to fetch a view's full spec, or with update_custom_view / delete_custom_view.")] + public static async Task ListCustomViews(NpgsqlDataSource postgres) + { + try + { + var store = new CustomViewStore(postgres); + var views = await store.ListAsync(); + return DarlingWebEndpoints.BuildSummariesNode(views).ToJsonString(McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("list_custom_views", ex); + } + } + + [McpServerTool(Name = "get_custom_view"), Description( + "Gets one saved custom view in full, including its definition JSON (a dashboard's panels or a notebook's " + + "cells) plus name, description, kind, and version. Get the id from list_custom_views. Use the returned " + + "version when calling update_custom_view (optimistic concurrency).")] + public static async Task GetCustomView( + NpgsqlDataSource postgres, + [Description("The numeric id of the view to fetch (from list_custom_views).")] long view_id) + { + try + { + var store = new CustomViewStore(postgres); + var result = await store.GetAsync(view_id); + return result is CustomViewResult.Ok ok + ? DarlingWebEndpoints.BuildFullViewNode(ok.View!).ToJsonString(McpHelpers.JsonOptions) + : Outcome("not_found", $"No custom view with id {view_id}."); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_custom_view", ex); + } + } + + [McpServerTool(Name = "validate_custom_view"), Description( + "Dry-run: validates a custom-view definition against the measure catalog and the composer rules WITHOUT " + + "persisting anything. Returns {valid:true} or {valid:false, error:\"...\"} naming the first problem. This is " + + "the exact authority create_custom_view / update_custom_view run before saving, so use it to iterate on a " + + "generated definition until it is valid. The definition is a dashboard {\"panels\":[...]} or a notebook " + + "{\"kind\":\"notebook\",\"cells\":[...]}; a composed panel names a catalog 'source' + 'measure'|'ratio', an " + + "'aggregate', an optional 'timeBucket' or 'topN', 'filters', 'groupBy', 'unit', and 'viz'.")] + public static Task ValidateCustomView( + [Description("The view definition JSON to validate (NOT persisted).")] string definition) + { + try + { + var validation = DarlingWebEndpoints.ValidateDefinition(definition); + return Task.FromResult(JsonSerializer.Serialize( + new { valid = validation.IsValid, error = validation.Error }, McpHelpers.JsonOptions)); + } + catch (Exception ex) + { + return Task.FromResult(McpHelpers.FormatError("validate_custom_view", ex)); + } + } + + [McpServerTool(Name = "create_custom_view"), Description( + "Creates a new saved custom view. The definition is VALIDATED first (same as validate_custom_view); an " + + "invalid definition returns {status:\"invalid\", ...} and saves nothing. On success returns the stored view " + + "(id, version 1, and the definition). A name collision returns {status:\"conflict\", ...}. The view is " + + "stamped as MCP-authored.")] + public static async Task CreateCustomView( + NpgsqlDataSource postgres, + [Description("The view name — unique across all views, max 200 characters.")] string name, + [Description("The view definition JSON (a dashboard {\"panels\":[...]} or a notebook {\"kind\":\"notebook\",\"cells\":[...]}). Validate it with validate_custom_view first.")] string definition, + [Description("Optional human-readable description.")] string? description = null) + { + try + { + var validation = DarlingWebEndpoints.ValidateDefinition(definition); + if (!validation.IsValid) + { + return Outcome("invalid", validation.Error!); + } + + var store = new CustomViewStore(postgres); + var result = await store.CreateAsync(name, description, definition, DarlingWebEndpoints.McpEditorPrincipal); + return result switch + { + CustomViewResult.Ok ok => DarlingWebEndpoints.BuildFullViewNode(ok.View!).ToJsonString(McpHelpers.JsonOptions), + CustomViewResult.Conflict conflict => Outcome("conflict", conflict.Message), + CustomViewResult.Invalid invalid => Outcome("invalid", invalid.Message), + _ => Outcome("error", "Could not create the view."), + }; + } + catch (Exception ex) + { + return McpHelpers.FormatError("create_custom_view", ex); + } + } + + [McpServerTool(Name = "update_custom_view"), Description( + "Updates an existing custom view in place (a full replacement of name/description/definition). The " + + "definition is VALIDATED first; an invalid one returns {status:\"invalid\", ...} and changes nothing. Pass " + + "the 'version' you last read via get_custom_view — if someone else changed the view since, this returns " + + "{status:\"conflict\", ...} rather than silently overwriting their edit (reload and re-apply). A missing id " + + "returns {status:\"not_found\", ...}; a name collision returns {status:\"conflict\", ...}. On success returns " + + "the stored view with its bumped version.")] + public static async Task UpdateCustomView( + NpgsqlDataSource postgres, + [Description("The id of the view to update (from list_custom_views).")] long view_id, + [Description("The view name (unique, max 200 characters).")] string name, + [Description("The full replacement view definition JSON. Validate it with validate_custom_view first.")] string definition, + [Description("The version you last read from get_custom_view (optimistic concurrency; a mismatch is a conflict, not an overwrite).")] int version, + [Description("Optional human-readable description.")] string? description = null) + { + try + { + var validation = DarlingWebEndpoints.ValidateDefinition(definition); + if (!validation.IsValid) + { + return Outcome("invalid", validation.Error!); + } + + var store = new CustomViewStore(postgres); + var result = await store.UpdateAsync( + view_id, name, description, definition, version, DarlingWebEndpoints.McpEditorPrincipal); + return result switch + { + CustomViewResult.Ok ok => DarlingWebEndpoints.BuildFullViewNode(ok.View!).ToJsonString(McpHelpers.JsonOptions), + CustomViewResult.NotFound => Outcome("not_found", $"No custom view with id {view_id}."), + CustomViewResult.Conflict conflict => Outcome("conflict", conflict.Message), + CustomViewResult.Invalid invalid => Outcome("invalid", invalid.Message), + _ => Outcome("error", "Could not update the view."), + }; + } + catch (Exception ex) + { + return McpHelpers.FormatError("update_custom_view", ex); + } + } + + [McpServerTool(Name = "delete_custom_view"), Description( + "Deletes a saved custom view by id. Returns {status:\"deleted\", view_id:N} on success, or " + + "{status:\"not_found\", ...} when no view has that id. This is permanent.")] + public static async Task DeleteCustomView( + NpgsqlDataSource postgres, + [Description("The id of the view to delete (from list_custom_views).")] long view_id) + { + try + { + var store = new CustomViewStore(postgres); + var result = await store.DeleteAsync(view_id); + return result is CustomViewResult.Ok + ? JsonSerializer.Serialize(new { status = "deleted", view_id }, McpHelpers.JsonOptions) + : Outcome("not_found", $"No custom view with id {view_id}."); + } + catch (Exception ex) + { + return McpHelpers.FormatError("delete_custom_view", ex); + } + } + + [McpServerTool(Name = "run_custom_view_panel"), Description( + "Runs a single composed (v2) panel and returns the DATA it produces — {sql, rows, annotations} — so a " + + "generated view can be checked end-to-end without saving it. This is the SAME compile-and-run the web " + + "composer's live preview uses: the panel is validated, compiled to catalog-only bound SQL, and executed " + + "against the collected store under a statement_timeout. The spec is a JSON object " + + "{\"panel\":{...}, \"variables\":[...], \"values\":{...}, \"server\":\"NAME\"|[\"A\",\"B\"], \"hours\":N} — only " + + "'panel' is required (a composed panel, the same shape a dashboard panel or a notebook panel cell uses); " + + "omit 'server' (or use \"All\") for the whole fleet, and 'hours' defaults to 24. To read back a SAVED view, " + + "call get_custom_view and run each of its composed panels here.")] + public static async Task RunCustomViewPanel( + NpgsqlDataSource postgres, + [Description("The composed-panel run spec as JSON (see the tool description for the shape). Only 'panel' is required.")] string spec) + { + try + { + JsonNode? root; + try + { + root = JsonNode.Parse(spec); + } + catch (JsonException ex) + { + return Outcome("invalid", $"spec is not valid JSON: {ex.Message}"); + } + + if (root is not JsonObject body) + { + return Outcome("invalid", "spec must be a JSON object with a 'panel'."); + } + + var outcome = await DarlingWebEndpoints.RunComposedPanelAsync(postgres, body, CancellationToken.None); + return outcome.Payload is not null + ? outcome.Payload.ToJsonString(McpHelpers.JsonOptions) + : Outcome(outcome.IsServerError ? "error" : "invalid", outcome.Error!); + } + catch (Exception ex) + { + return McpHelpers.FormatError("run_custom_view_panel", ex); + } + } + + [McpServerTool(Name = "describe_custom_view_catalog"), Description( + "Returns the COMPOSE CATALOG — the exact vocabulary a composed (v2) custom-view panel may draw from — so you " + + "can build a VALID panel without guessing at names. CALL THIS FIRST before create_custom_view / " + + "update_custom_view / validate_custom_view / run_custom_view_panel: the panel's 'source', 'measure'/'ratio', " + + "'aggregate', 'unit', 'groupBy'/'filters' dimensions, 'timeBucket', and 'viz' must all come from this " + + "catalog (the compiler emits ONLY these identifiers), and the validation errors do not enumerate the legal " + + "names, so guessing them is slow. Returns {measures, dimensions, annotationSources, universalDimensions, " + + "unitFamilies, aggregates, timeBuckets, filterOps, viz}. Each measure names its 'source' (collector table), " + + "its 'key' (use as the panel's 'measure', or as 'ratio' when kind='ratio'), its 'kind' (scalar|ratio), the " + + "'validAggregates' and 'allowedDimensions' legal for it, its unit family + default/native unit, and " + + "'appliesTo' (which server types — onPrem/azureSqlDb/azureMi/awsRds — can collect it). A panel then names a " + + "'source' + 'measure'|'ratio', an 'aggregate' from that measure's validAggregates, a 'unit' from its family, " + + "an optional 'timeBucket' (time series) OR 'topN' (ranked, not both), 'groupBy'/'filters' from its " + + "allowedDimensions (plus the universal 'server' axis), and a 'viz' coherent with the mode (line/area/" + + "stacked for time series; bar/pie for ranked; stat for a single value; table for any). Static reference " + + "data — no server or time window needed; it reads no monitored server and no collected data.")] + public static Task DescribeCustomViewCatalog() + { + try + { + return Task.FromResult(DarlingWebEndpoints.BuildComposeCatalogNode().ToJsonString(McpHelpers.JsonOptions)); + } + catch (Exception ex) + { + return Task.FromResult(McpHelpers.FormatError("describe_custom_view_catalog", ex)); + } + } + + /// A small {status, message} envelope for a non-data write outcome (conflict / invalid / + /// not_found / error) — the same shape mute_analysis_finding uses for its status, so an MCP client can branch + /// on the outcome kind. A successful create/update/get returns the view object itself, not this. + private static string Outcome(string status, string message) => + JsonSerializer.Serialize(new { status, message }, McpHelpers.JsonOptions); +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs new file mode 100644 index 000000000..c6c816933 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs @@ -0,0 +1,781 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using ModelContextProtocol.Server; +using Npgsql; +using PerformanceMonitor.Common; + +#pragma warning disable CA1707 // MCP tools use snake_case naming convention + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// The core data-read MCP tools — the SAME tool surface Lite and the Dashboard expose (resource +/// metrics, query performance, discovery/health), served over Darling's Postgres store. Each tool body +/// mirrors Lite's Mcp*Tools field-for-field (same tool names, same parameters, same response +/// fields, the #1224 miss vocabulary via ) so an MCP client sees one +/// consistent product across all three SKUs — the same reason the existing +/// mirror Lite's analysis tools. These are the tools the analysis engine's next_tools +/// recommendations already point at (get_cpu_utilization / get_wait_stats / get_top_queries_by_cpu / …), +/// so a client following a finding's advice can now resolve them on this same server. +/// +/// +/// THE SEAM: where Lite's tools read its local DuckDB via LocalDataService and resolve a name +/// through its in-memory ServerManager, these read the Postgres store via +/// — a STORED read (no live monitored-server hit), consistent with +/// Darling's read-from-collected-data posture — and resolve through the Postgres servers registry +/// (). Every read is Lite's / the viewer's proven read adapted to +/// Postgres. Where Lite and the Dashboard's result shapes diverge (Lite's get_cpu_utilization carries +/// idle_cpu, its get_file_io_stats the raw deltas rather than the Dashboard's SQL-Server-view-computed +/// latency assessment), this follows Lite — the store-faithful shape Darling's collector-mirror schema +/// can serve, matching the viewer's own port. A response-shape change here must land in Lite's +/// Mcp*Tools too, and vice versa. +/// +/// +[McpServerToolType] +public sealed class DarlingMcpDataTools +{ + /* ═══════════════════════════ resource metrics ═══════════════════════════ */ + + [McpServerTool(Name = "get_cpu_utilization"), Description("Gets CPU utilization over time showing SQL Server CPU %, other process CPU %, total CPU %, and idle %. Data is downsampled to 1-minute averages. Use this to identify CPU pressure periods, then use get_top_queries_by_cpu to find the culprit queries.")] + public static async Task GetCpuUtilization( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history. Default 4.")] int hours_back = 4) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + + try + { + var rows = await DarlingDataReader.GetCpuUtilizationAsync(postgres, resolved.Value.ServerId, DateTime.UtcNow.AddHours(-hours_back)); + if (rows.Count == 0) + return McpHelpers.Status("unavailable", "No CPU utilization data available."); + + /* Downsample to 1-minute buckets to avoid overwhelming LLM context (Lite's projection). */ + var bucketed = rows + .GroupBy(r => new DateTime(r.SampleTime.Year, r.SampleTime.Month, r.SampleTime.Day, + r.SampleTime.Hour, r.SampleTime.Minute, 0, r.SampleTime.Kind)) + .OrderBy(g => g.Key) + .Select(g => new + { + sample_time = g.Key.ToString("o"), + sql_server_cpu = (int)Math.Round(g.Average(r => r.SqlServerCpu)), + other_process_cpu = (int)Math.Round(g.Average(r => r.OtherProcessCpu)), + total_cpu = (int)Math.Round(g.Average(r => r.SqlServerCpu + r.OtherProcessCpu)), + idle_cpu = (int)Math.Round(g.Average(r => Math.Max(0, 100 - (r.SqlServerCpu + r.OtherProcessCpu)))), + samples_in_bucket = g.Count() + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + note = "Values are 1-minute averages of 15-second ring buffer samples.", + samples = bucketed + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_cpu_utilization", ex); + } + } + + [McpServerTool(Name = "get_wait_stats"), Description("Gets the top SQL Server wait types aggregated over a time period. Wait stats reveal what SQL Server spends time waiting on — high signal waits indicate CPU pressure, high resource waits indicate I/O or lock contention. Use this first to identify the dominant wait category, then drill into specific tools based on the wait type.")] + public static async Task GetWaitStats( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history to analyze. Default 24.")] int hours_back = 24, + [Description("Maximum rows to return. Default 20.")] int limit = 20) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + validation = McpHelpers.ValidateTop(limit); + if (validation != null) return validation; + + try + { + var now = DateTime.UtcNow; + var rows = await DarlingDataReader.GetWaitStatsAsync(postgres, resolved.Value.ServerId, now.AddHours(-hours_back), now); + if (rows.Count == 0) + return McpHelpers.Status("unavailable", "No wait stats data available for the specified time range."); + + var result = rows.Take(limit).Select(r => + { + var signalPct = r.TotalWaitTimeMs > 0 ? (double)r.TotalSignalWaitTimeMs / r.TotalWaitTimeMs * 100 : 0; + return new + { + wait_type = r.WaitType, + total_wait_time_ms = r.TotalWaitTimeMs, + total_signal_wait_ms = r.TotalSignalWaitTimeMs, + resource_wait_ms = r.TotalWaitTimeMs - r.TotalSignalWaitTimeMs, + waiting_tasks = r.TotalWaitingTasks, + signal_wait_pct = Math.Round(signalPct, 1) + }; + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + waits = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_wait_stats", ex); + } + } + + [McpServerTool(Name = "get_wait_types"), Description("Lists the distinct wait types observed on a server in the given time period, heaviest first. Useful for discovering which exact wait type to drill into with get_wait_trend.")] + public static async Task GetWaitTypes( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history. Default 24.")] int hours_back = 24) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + + try + { + var now = DateTime.UtcNow; + var types = await DarlingDataReader.GetDistinctWaitTypesAsync( + postgres, resolved.Value.ServerId, now.AddHours(-hours_back), now); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + wait_types = types + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_wait_types", ex); + } + } + + [McpServerTool(Name = "get_wait_trend"), Description("Gets a time-series trend for a specific wait type, showing how wait time changes over time. Use get_wait_stats first to discover the dominant wait types.")] + public static async Task GetWaitTrend( + NpgsqlDataSource postgres, + [Description("The exact wait type name, e.g. CXPACKET, PAGEIOLATCH_SH.")] string wait_type, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history. Default 24.")] int hours_back = 24) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + + try + { + var now = DateTime.UtcNow; + var start = now.AddHours(-hours_back); + var points = await DarlingDataReader.GetWaitTrendAsync(postgres, resolved.Value.ServerId, wait_type, start, now); + if (points.Count == 0) + { + /* Distinguish "unknown wait type here" from "nothing collected at all", handing back the + ones that do have data — Lite's get_wait_trend miss vocabulary. */ + var collected = await DarlingDataReader.GetDistinctWaitTypesAsync(postgres, resolved.Value.ServerId, start, now); + if (collected.Count == 0) + return McpHelpers.Status( + "unavailable", + $"No trend data for wait type '{wait_type}'. No wait stats have been collected for this server in the last {hours_back}h yet " + + "(the collector may not have run, or delta wait stats need a second collection cycle)."); + + return McpHelpers.Status( + "not_collected", + $"No trend data for wait type '{wait_type}'. It may not be a wait type observed on this server in this window — see hints.collected_wait_types for the {collected.Count} that have data.", + new { collected_wait_types = collected }); + } + + var result = points.Select(p => new + { + time = p.CollectionTime.ToString("o"), + wait_time_ms_per_second = p.WaitTimeMsPerSecond, + signal_wait_time_ms_per_second = p.SignalWaitTimeMsPerSecond + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + wait_type, + hours_back, + trend = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_wait_trend", ex); + } + } + + [McpServerTool(Name = "get_memory_stats"), Description("Gets the latest memory statistics snapshot: physical memory, buffer pool size, plan cache size, memory utilization %, and SQL Server memory model. Use this for a quick memory health check; use get_memory_clerks to see detailed breakdown by component.")] + public static async Task GetMemoryStats( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + try + { + var stats = await DarlingDataReader.GetLatestMemoryStatsAsync(postgres, resolved.Value.ServerId); + if (stats == null) + return McpHelpers.Status("unavailable", "No memory stats available."); + + var utilization = stats.TotalPhysicalMemoryMb > 0 + ? (stats.TotalPhysicalMemoryMb - stats.AvailablePhysicalMemoryMb) / stats.TotalPhysicalMemoryMb * 100 + : 0; + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + collection_time = stats.CollectionTime.ToString("o"), + total_physical_memory_mb = stats.TotalPhysicalMemoryMb, + available_physical_memory_mb = stats.AvailablePhysicalMemoryMb, + memory_utilization_pct = Math.Round(utilization, 1), + system_memory_state = stats.SystemMemoryState, + sql_memory_model = stats.SqlMemoryModel, + target_server_memory_mb = stats.TargetServerMemoryMb, + total_server_memory_mb = stats.TotalServerMemoryMb, + buffer_pool_mb = stats.BufferPoolMb, + plan_cache_mb = stats.PlanCacheMb + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_memory_stats", ex); + } + } + + [McpServerTool(Name = "get_memory_clerks"), Description("Gets the top memory consumers by memory clerk type — shows which SQL Server components are using the most memory.")] + public static async Task GetMemoryClerks( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + try + { + var rows = await DarlingDataReader.GetLatestMemoryClerksAsync(postgres, resolved.Value.ServerId); + var result = rows.Select(r => new + { + clerk_type = r.ClerkType, + memory_mb = Math.Round(r.MemoryMb, 2) + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + clerks = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_memory_clerks", ex); + } + } + + [McpServerTool(Name = "get_file_io_stats"), Description("Gets the latest file I/O statistics per database file: read/write counts, bytes, stall times, and calculated latency. High read latency (>20ms) or write latency (>10ms for data, >2ms for log) often indicates storage bottlenecks.")] + public static async Task GetFileIoStats( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + try + { + var rows = await DarlingDataReader.GetLatestFileIoStatsAsync(postgres, resolved.Value.ServerId); + if (rows.Count == 0) + return McpHelpers.Status("unavailable", "No file I/O stats available."); + + var result = rows.Select(r => new + { + database_name = r.DatabaseName, + file_name = r.FileName, + file_type = r.FileType, + physical_name = r.PhysicalName, + size_mb = Math.Round(r.SizeMb, 1), + delta_reads = r.DeltaReads, + delta_writes = r.DeltaWrites, + delta_read_bytes = r.DeltaReadBytes, + delta_write_bytes = r.DeltaWriteBytes, + delta_stall_read_ms = r.DeltaStallReadMs, + delta_stall_write_ms = r.DeltaStallWriteMs, + avg_read_latency_ms = Math.Round(r.DeltaReads > 0 ? (double)r.DeltaStallReadMs / r.DeltaReads : 0, 2), + avg_write_latency_ms = Math.Round(r.DeltaWrites > 0 ? (double)r.DeltaStallWriteMs / r.DeltaWrites : 0, 2) + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + files = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_file_io_stats", ex); + } + } + + [McpServerTool(Name = "get_tempdb_trend"), Description("Gets TempDB space usage over time: user objects, internal objects, version store, total reserved, and unallocated space. Also shows top TempDB consumer session. High version store can indicate long-running transactions under RCSI/SNAPSHOT isolation.")] + public static async Task GetTempDbTrend( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history. Default 24.")] int hours_back = 24) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + + try + { + var rows = await DarlingDataReader.GetTempDbTrendAsync(postgres, resolved.Value.ServerId, DateTime.UtcNow.AddHours(-hours_back)); + if (rows.Count == 0) + return McpHelpers.Status("unavailable", "No TempDB data available."); + + var result = rows.Select(r => new + { + time = r.CollectionTime.ToString("o"), + user_objects_mb = Math.Round(r.UserObjectReservedMb, 1), + internal_objects_mb = Math.Round(r.InternalObjectReservedMb, 1), + version_store_mb = Math.Round(r.VersionStoreReservedMb, 1), + total_reserved_mb = Math.Round(r.TotalReservedMb, 1), + unallocated_mb = Math.Round(r.UnallocatedMb, 1), + sessions_using_tempdb = r.TotalSessionsUsingTempDb, + top_consumer_session_id = r.TopSessionId, + top_consumer_mb = Math.Round(r.TopSessionTempDbMb, 1) + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + trend = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_tempdb_trend", ex); + } + } + + [McpServerTool(Name = "get_perfmon_stats"), Description("Gets the latest SQL Server performance counter values: batch requests/sec, compilations/sec, deadlocks/sec, and more. Provides throughput context to distinguish a busy server from a sick one. Use counter_name or instance_name to filter results.")] + public static async Task GetPerfmonStats( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Filter to a specific counter name, e.g. 'Batch Requests/sec'.")] string? counter_name = null, + [Description("Filter to a specific instance name, e.g. a database name.")] string? instance_name = null) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + try + { + var rows = await DarlingDataReader.GetLatestPerfmonStatsAsync(postgres, resolved.Value.ServerId); + if (rows.Count == 0) + return McpHelpers.Status("unavailable", "No perfmon stats available."); + + IEnumerable filtered = rows; + if (!string.IsNullOrEmpty(counter_name)) + filtered = filtered.Where(r => r.CounterName.Contains(counter_name, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrEmpty(instance_name)) + filtered = filtered.Where(r => r.InstanceName.Contains(instance_name, StringComparison.OrdinalIgnoreCase)); + + var result = filtered.Select(r => new + { + counter_name = r.CounterName, + instance_name = r.InstanceName, + value = r.Value, + delta_value = r.DeltaValue + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + counters = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_perfmon_stats", ex); + } + } + + /* ═══════════════════════════ query performance ═══════════════════════════ */ + + [McpServerTool(Name = "get_top_queries_by_cpu"), Description("Gets expensive queries from sys.dm_exec_query_stats (plan cache). Best for: currently cached queries with detailed per-execution stats, DOP, spills, and query_hash for trending. Returns query_hash, query_plan_hash, sql_handle, plan_handle. Supports database and parallelism filtering.")] + public static async Task GetTopQueriesByCpu( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description("Number of top queries. Default 20.")] int top = 20, + [Description("Filter to a specific database.")] string? database_name = null, + [Description("If true, only return queries that used parallelism (max_dop > 1).")] bool parallel_only = false, + [Description("Minimum DOP to filter on. Implies parallel filtering.")] int min_dop = 0) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + validation = McpHelpers.ValidateTop(top, "top"); + if (validation != null) return validation; + + try + { + var now = DateTime.UtcNow; + var rows = await DarlingDataReader.GetTopQueriesByCpuAsync(postgres, resolved.Value.ServerId, now.AddHours(-hours_back), now, top, database_name); + if (rows.Count == 0) + return McpHelpers.Status("unavailable", "No query stats available for the specified time range."); + + IEnumerable filtered = rows; + if (parallel_only || min_dop > 1) + filtered = filtered.Where(r => r.MaxDop > 1 && r.MaxDop >= (min_dop > 1 ? min_dop : 2)); + + var result = filtered.Select(r => new + { + database_name = r.DatabaseName, + query_hash = r.QueryHash, + query_plan_hash = r.QueryPlanHash, + sql_handle = r.SqlHandle, + plan_handle = r.PlanHandle, + execution_count = r.TotalExecutions, + total_cpu_ms = r.TotalCpuUs / 1000.0, + total_elapsed_ms = r.TotalElapsedUs / 1000.0, + avg_cpu_ms = r.TotalExecutions > 0 ? r.TotalCpuUs / 1000.0 / r.TotalExecutions : 0, + avg_elapsed_ms = r.TotalExecutions > 0 ? r.TotalElapsedUs / 1000.0 / r.TotalExecutions : 0, + min_cpu_ms = r.MinCpuUs / 1000.0, + max_cpu_ms = r.MaxCpuUs / 1000.0, + min_elapsed_ms = r.MinElapsedUs / 1000.0, + max_elapsed_ms = r.MaxElapsedUs / 1000.0, + min_dop = r.MinDop, + max_dop = r.MaxDop, + is_parallel = r.MaxDop > 1, + total_logical_reads = r.TotalLogicalReads, + total_logical_writes = r.TotalLogicalWrites, + total_physical_reads = r.TotalPhysicalReads, + total_rows = r.TotalRows, + total_spills = r.TotalSpills, + avg_reads = r.TotalExecutions > 0 ? (double)r.TotalLogicalReads / r.TotalExecutions : 0, + query_text = McpHelpers.Truncate(r.QueryText, 2000) + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + queries = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_top_queries_by_cpu", ex); + } + } + + [McpServerTool(Name = "get_top_procedures_by_cpu"), Description("Gets the most expensive stored procedures ranked by total CPU time. Shows execution counts, CPU/elapsed times, and I/O metrics. Delta-based: requires ~30 minutes after adding a new server before data appears.")] + public static async Task GetTopProceduresByCpu( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description("Number of top procedures. Default 20.")] int top = 20, + [Description("Filter to a specific database.")] string? database_name = null) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + validation = McpHelpers.ValidateTop(top, "top"); + if (validation != null) return validation; + + try + { + var now = DateTime.UtcNow; + var rows = await DarlingDataReader.GetTopProceduresByCpuAsync(postgres, resolved.Value.ServerId, now.AddHours(-hours_back), now, top, database_name); + if (rows.Count == 0) + return McpHelpers.Status( + "unavailable", + "No procedure stats available. Delta-based collection requires at least two collection cycles (~30 minutes) to produce non-zero values."); + + var result = rows.Select(r => new + { + database_name = r.DatabaseName, + full_name = string.IsNullOrEmpty(r.SchemaName) ? r.ObjectName : $"{r.SchemaName}.{r.ObjectName}", + object_type = r.ObjectType, + sql_handle = r.SqlHandle, + plan_handle = r.PlanHandle, + execution_count = r.TotalExecutions, + total_cpu_ms = r.TotalCpuUs / 1000.0, + total_elapsed_ms = r.TotalElapsedUs / 1000.0, + avg_cpu_ms = r.TotalExecutions > 0 ? r.TotalCpuUs / 1000.0 / r.TotalExecutions : 0, + avg_elapsed_ms = r.TotalExecutions > 0 ? r.TotalElapsedUs / 1000.0 / r.TotalExecutions : 0, + min_cpu_ms = r.MinCpuUs / 1000.0, + max_cpu_ms = r.MaxCpuUs / 1000.0, + min_elapsed_ms = r.MinElapsedUs / 1000.0, + max_elapsed_ms = r.MaxElapsedUs / 1000.0, + avg_reads = r.TotalExecutions > 0 ? (double)r.TotalLogicalReads / r.TotalExecutions : 0, + total_logical_reads = r.TotalLogicalReads, + total_logical_writes = r.TotalLogicalWrites, + total_physical_reads = r.TotalPhysicalReads, + total_spills = r.TotalSpills + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + procedures = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_top_procedures_by_cpu", ex); + } + } + + [McpServerTool(Name = "get_query_store_top"), Description("Gets expensive queries from Query Store (persistent, survives restarts). Best for: historical analysis, queries no longer in plan cache. Requires Query Store enabled on target databases. Supports database filtering.")] + public static async Task GetQueryStoreTop( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description("Number of top queries. Default 20.")] int top = 20, + [Description("Filter to a specific database.")] string? database_name = null) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + validation = McpHelpers.ValidateTop(top, "top"); + if (validation != null) return validation; + + try + { + var now = DateTime.UtcNow; + var rows = await DarlingDataReader.GetQueryStoreTopAsync(postgres, resolved.Value.ServerId, now.AddHours(-hours_back), now, top, database_name); + if (rows.Count == 0) + return McpHelpers.Status("unavailable", "No Query Store data available. Query Store may not be enabled on target databases."); + + var result = rows.Select(r => new + { + database_name = r.DatabaseName, + query_id = r.QueryId, + plan_id = r.PlanId, + query_hash = r.QueryHash, + query_plan_hash = r.QueryPlanHash, + execution_count = r.TotalExecutions, + avg_duration_ms = r.AvgDurationMs, + avg_cpu_ms = r.AvgCpuTimeMs, + avg_logical_reads = r.AvgLogicalReads, + avg_logical_writes = r.AvgLogicalWrites, + avg_physical_reads = r.AvgPhysicalReads, + avg_rowcount = r.AvgRowcount, + last_execution_time = r.LastExecutionTime?.ToString("o"), + query_text = McpHelpers.Truncate(r.QueryText, 2000), + /* Emitted because it is a grouping key: on a 2022+ AG the same query can appear once per + replica role, and without this the caller would see duplicate-looking rows with no way + to tell them apart. NULL when the server did not attribute the row. */ + replica_role = r.ReplicaRole + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + queries = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_query_store_top", ex); + } + } + + /* ═══════════════════════════ discovery / health ═══════════════════════════ */ + + [McpServerTool(Name = "list_servers"), Description("Lists all monitored SQL Server instances with their collection freshness status and last collection time. Use this first to see available servers before calling other tools. The service has no live connection to the monitored servers, so status is derived from how recently each server was collected (Online = fresh, Warning = stale, Offline = no recent collection).")] + public static async Task ListServers( + NpgsqlDataSource postgres) + { + try + { + List servers; + try + { + servers = await DarlingDataReader.GetServerListAsync(postgres); + } + catch (Exception ex) + { + return $"Could not read the servers registry from the Postgres store: {ex.Message}"; + } + + if (servers.Count == 0) + return "No servers are registered yet. The service registers each monitored server on its first successful connection."; + + var now = DateTime.UtcNow; + var result = servers.Select(s => new + { + server_name = s.ServerName, + display_name = string.IsNullOrEmpty(s.DisplayName) ? s.ServerName : s.DisplayName, + sql_version = SqlVersionLabel(s.SqlMajorVersion), + status = FreshnessStatus(s.LastCollection, now), + read_only = s.ServerName.EndsWith(":RO", StringComparison.Ordinal), + last_collection = s.LastCollection?.ToString("o") + }); + + return JsonSerializer.Serialize(new + { + server_count = servers.Count, + servers = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("list_servers", ex); + } + } + + [McpServerTool(Name = "get_collection_health"), Description("Shows the health status of all data collectors for a server — whether they're running successfully, failing, or stale. Check this before investigating data to ensure collectors are working properly.")] + public static async Task GetCollectionHealth( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + try + { + var rows = await DarlingDataReader.GetCollectionHealthAsync(postgres, resolved.Value.ServerId, DateTime.UtcNow.AddDays(-7)); + if (rows.Count == 0) + return McpHelpers.Status("unavailable", "No collection health data available."); + + var result = rows.Select(r => new + { + collector = r.CollectorName, + status = r.HealthStatus, + total_runs = r.TotalRuns, + errors = r.ErrorCount, + failure_rate_pct = Math.Round(r.FailureRatePercent, 1), + avg_duration_ms = Math.Round(r.AvgDurationMs, 0), + last_success = r.LastSuccessTime?.ToString("o"), + last_error = r.LastError + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + collectors = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_collection_health", ex); + } + } + + [McpServerTool(Name = "get_server_properties"), Description("Gets SQL Server instance properties: edition, version, CPU count, physical memory, socket/core topology, HADR status, and clustering. Use for capacity planning and edition-aware recommendations.")] + public static async Task GetServerProperties( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + try + { + var row = await DarlingDataReader.GetLatestServerPropertiesAsync(postgres, resolved.Value.ServerId); + if (row == null) + return McpHelpers.Status("unavailable", "No server properties available. The properties collector may not have run yet."); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + collection_time = row.CollectionTime.ToString("o"), + edition = row.Edition, + engine_edition = row.EngineEdition, + product_version = row.ProductVersion, + product_level = row.ProductLevel, + product_update_level = string.IsNullOrEmpty(row.ProductUpdateLevel) ? null : row.ProductUpdateLevel, + cpu_count = row.CpuCount, + hyperthread_ratio = row.HyperthreadRatio, + socket_count = row.SocketCount, + cores_per_socket = row.CoresPerSocket, + physical_memory_mb = row.PhysicalMemoryMb, + is_hadr_enabled = row.IsHadrEnabled, + is_clustered = row.IsClustered, + enterprise_features = string.IsNullOrEmpty(row.EnterpriseFeatures) ? null : row.EnterpriseFeatures, + service_objective = string.IsNullOrEmpty(row.ServiceObjective) ? null : row.ServiceObjective + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_server_properties", ex); + } + } + + /* ─────────────────────────── list_servers helpers ─────────────────────────── */ + + /// Older than twice the ~1-minute collector cadence = the collection has visibly lagged. + private static readonly TimeSpan StaleThreshold = TimeSpan.FromMinutes(2); + + /// Older than this (or no collection at all) = the server is treated as Offline. + private static readonly TimeSpan OfflineThreshold = TimeSpan.FromMinutes(15); + + /// + /// The freshness-derived status the headless viewer's cards use (ServerSummaryItem.ClassifyFreshness): + /// Fresh → Online, Stale → Warning, long-dead → Offline, never-collected → AwaitingFirstCollection + /// (the service hasn't reached the server yet — a bootstrap state, not an outage; additive status + /// value, existing values unchanged). Both instants are UTC. + /// + private static string FreshnessStatus(DateTime? lastCollectionUtc, DateTime nowUtc) + { + if (!lastCollectionUtc.HasValue) return "AwaitingFirstCollection"; + var age = nowUtc - lastCollectionUtc.Value; + if (age > OfflineThreshold) return "Offline"; + if (age > StaleThreshold) return "Warning"; + return "Online"; + } + + /// Product-name label for a sql_major_version (the viewer's SqlVersionLabel); 2016+ is + /// what the product supports, older/unknown majors fall back to a bare version tag, null to empty. + private static string SqlVersionLabel(int? sqlMajorVersion) => sqlMajorVersion switch + { + null => "", + 11 => "SQL Server 2012", + 12 => "SQL Server 2014", + 13 => "SQL Server 2016", + 14 => "SQL Server 2017", + 15 => "SQL Server 2019", + 16 => "SQL Server 2022", + 17 => "SQL Server 2025", + _ => $"SQL Server v{sqlMajorVersion}", + }; +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDefaultTraceTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDefaultTraceTools.cs new file mode 100644 index 000000000..55ef8dd2b --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDefaultTraceTools.cs @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.ComponentModel; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using ModelContextProtocol.Server; +using Npgsql; +using PerformanceMonitor.Common; + +#pragma warning disable CA1707 // MCP tools use snake_case naming convention + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// The Default Trace MCP tool — get_default_trace_events, the same name the Dashboard's +/// McpSystemEventTools exposes — served over Darling's Postgres store (a STORED read, no live +/// monitored-server hit). Where the Dashboard reads its server-side collect.default_trace_events +/// table, this reads Darling's collected copy () and returns the +/// SIGNIFICANT set, gating each row through the shared — the +/// exact same significant-set gate the viewer's System Events surface uses, so the tool and the viewer +/// agree. Each row is tagged with its . +/// +/// The collector already curates the event set server-side (auto-grow/shrink only when a stall > 1 s, +/// tempdb / _WA_ auto-stats DDL excluded), so the one filter this layer adds is the ErrorLog severity +/// floor. Config-change events are intentionally NOT here — Darling tracks that slice via the config +/// snapshot diffs, so use get_server_config_changes / get_database_config_changes / +/// get_trace_flag_changes for configuration changes. +/// +[McpServerToolType] +public sealed class DarlingMcpDefaultTraceTools +{ + [McpServerTool(Name = "get_default_trace_events"), Description("Gets significant server events captured by the built-in Default Trace (stored, read-only): data/log file auto-grow/shrink STALLS (over 1 second), severe ErrorLog writes (severity >= 16), schema DDL (object create/alter/delete), security audits (audit-change / DBCC / alter-trace), and Server Memory Change. Each event is tagged with a category. NOTE: configuration-change events are intentionally excluded here to avoid double-counting — use get_server_config_changes / get_database_config_changes / get_trace_flag_changes for those. Not available on Azure SQL Database (no default trace there).")] + public static async Task GetDefaultTraceEvents( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, + [Description("Maximum number of events to return. Default 100.")] int limit = 100) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back) ?? McpHelpers.ValidateTop(limit); + if (validation != null) return validation; + + try + { + var now = DateTime.UtcNow; + var all = await DarlingDefaultTraceReader.ReadEventsAsync( + postgres, resolved.Value.ServerId, now.AddHours(-hours_back), now); + + /* The significant-set gate (shared with the viewer's System Events surface): every curated + category is significant as collected, except ErrorLog which must clear the severity floor. */ + var significant = all + .Where(r => DefaultTraceEventSignificance.IsSignificant(r.EventName, r.Severity)) + .ToList(); + + if (significant.Count == 0) + return McpHelpers.Status("empty", "No significant default trace events found in the requested time range."); + + var events = significant.Take(limit).Select(r => + { + var category = DefaultTraceEventSignificance.Classify(r.EventName); + return new + { + event_time = r.EventTime?.ToString("o"), + category = category.ToString(), + event_name = r.EventName, + event_class = r.EventClass, + database_name = r.DatabaseName, + object_name = r.ObjectName, + login_name = r.LoginName, + host_name = r.HostName, + application_name = r.ApplicationName, + spid = r.Spid, + filename = r.Filename, + /* Duration is meaningful for the auto-grow/shrink stalls (how long the growth blocked). */ + duration_ms = r.DurationUs.HasValue ? r.DurationUs.Value / 1000.0 : (double?)null, + /* IntegerData is the 8-KB page count on an auto-grow/shrink event -> the grown/shrunk size. */ + growth_mb = category == DefaultTraceEventCategory.AutoGrowShrink && r.IntegerData.HasValue + ? r.IntegerData.Value * 8.0 / 1024.0 + : (double?)null, + error_number = r.ErrorNumber, + severity = r.Severity, + text_data = McpHelpers.Truncate(r.TextData, 2000) + }; + }).ToList(); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + total_events = significant.Count, + shown = events.Count, + events + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_default_trace_events", ex); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpFleetTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpFleetTools.cs new file mode 100644 index 000000000..357c95186 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpFleetTools.cs @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.ComponentModel; +using System.Text.Json; +using System.Threading.Tasks; +using ModelContextProtocol.Server; +using Npgsql; +using PerformanceMonitor.Common; + +#pragma warning disable CA1707 // MCP tools use snake_case naming convention + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// The fleet-overview MCP tool (#1562) — the signature cross-server roll-up Darling's single central store +/// enables and neither the Dashboard nor Lite can do (each monitors one server per database). It reads through +/// the SHARED — the SAME reader (and the SAME ServerHealthClassifier +/// banding) that powers the web dashboard's /api/fleet and the WPF viewer's Overview — so an MCP client, +/// a browser, and the desktop app all see one consistently-banded fleet. A STORED read (no live +/// monitored-server hit). +/// +/// This is ADDITIVE alongside get_server_summary (the Lite-parity one-shot per-server check, unchanged): +/// get_fleet_overview returns EVERY enabled server's pre-banded card plus the fleet rollup (band counts, the +/// cross-server blocking / deadlock totals, and the worst-first "needs attention" ranking) in one call. +/// +[McpServerToolType] +public sealed class DarlingMcpFleetTools +{ + [McpServerTool(Name = "get_fleet_overview"), Description( + "Gets the whole fleet's health at a glance: one pre-banded card per monitored server (CPU, memory, " + + "blocking, deadlocks, worker threads, and collector health, each with a Healthy/Warning/Critical band), " + + "plus a rollup — counts by band, cross-server blocking and deadlock totals, and a worst-first 'needs " + + "attention' ranking. Use this first to decide which server to drill into, then call the per-server tools. " + + "This cross-server view is unique to the central store.")] + public static async Task GetFleetOverview( + NpgsqlDataSource postgres, + [Description("Hours of blocking/deadlock history the per-server cards and fleet totals window over. Default 1.")] int hours_back = 1) + { + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + + try + { + var now = DateTime.UtcNow; + var result = await DarlingFleetReader.GetFleetOverviewAsync(postgres, now.AddHours(-hours_back), now, now); + + if (result.TotalServers == 0) + { + return McpHelpers.Status( + "empty", + "No servers are registered yet. The service registers each monitored server on its first successful connection."); + } + + return JsonSerializer.Serialize(result, DarlingFleetReader.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_fleet_overview", ex); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHealthParserTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHealthParserTools.cs new file mode 100644 index 000000000..949e1ae71 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHealthParserTools.cs @@ -0,0 +1,474 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using ModelContextProtocol.Server; +using Npgsql; +using PerformanceMonitor.Common; + +#pragma warning disable CA1707 // MCP tools use snake_case naming convention + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// The system_health parse-on-read MCP tools — get_health_parser_cpu_tasks / _io_issues / _memory_broker / +/// _memory_conditions / _memory_node_oom / _scheduler_issues / _severe_errors / _system_health — served over +/// Darling's Postgres store, the SAME eight tools the Dashboard's McpHealthParserTools exposes. Where +/// the Dashboard reads its server-side-parsed collect.HealthParser_* tables, these PARSE ON READ: the +/// raw system_health_events the collector captured are shredded by the shared +/// (PerformanceMonitor.Common — reused, NOT re-implemented) and gated by +/// , exactly as the viewer's System Events tab does. The +/// result is the same SIGNIFICANT warning set the Dashboard surfaces (its collector runs sp_HealthParser at +/// @warnings_only = 1); System Health is the one UNGATED category (its corruption/contention counter +/// series plots every snapshot, matching the viewer's GetSystemHealthAsync). +/// +/// +/// Reads flow through — a STORED read (no live monitored-server hit) +/// windowed on the XE event_time (the event's real time, the viewer's choice). The result rows carry +/// every column sp_HealthParser logs for the category (the Common record's full field set — a superset of +/// the Dashboard tool's projection), each surfaced as the event's event_time plus the category +/// columns; the store's synthetic row id and collection_time (a persistence artifact of the Dashboard's +/// parsed-table architecture) have no analog in the parse-on-read record and are omitted. Severe-error +/// database_name is resolved from the collected size-stats mapping (the DB-free shred left it null). +/// +/// +[McpServerToolType] +public sealed class DarlingMcpHealthParserTools +{ + [McpServerTool(Name = "get_health_parser_system_health"), Description("Gets parsed system_health extended event data: overall health indicators captured by sp_HealthParser.")] + public static async Task GetSystemHealth( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, + [Description("Maximum number of entries. Default 50.")] int limit = 50) + { + try + { + /* The corruption + contention counter series is UNGATED (no warnings-only filter) — the viewer's + GetSystemHealthAsync keeps every SYSTEM snapshot that has a timestamp. */ + var c = await CollectAsync(postgres, server_name, hours_back, limit, + SystemHealthParser.SpServerDiagnosticsEvent, + xml => One(SystemHealthParser.ParseSystemHealth(xml)), + r => r.EventTime.HasValue); + if (c.EarlyReturn != null) return c.EarlyReturn; + if (c.Rows.Count == 0) return McpHelpers.Status("empty", "No system health data found in the requested time range."); + + return JsonSerializer.Serialize(new + { + server = c.ServerName, + hours_back, + total_entries = c.Rows.Count, + shown = Math.Min(c.Rows.Count, limit), + entries = c.Rows.Take(limit).Select(r => new + { + event_time = r.EventTime?.ToString("o"), + state = r.State, + spinlock_backoffs = r.SpinlockBackoffs, + sick_spinlock_type = r.SickSpinlockType, + sick_spinlock_type_after_av = r.SickSpinlockTypeAfterAv, + latch_warnings = r.LatchWarnings, + is_access_violation_occurred = r.IsAccessViolationOccurred, + write_access_violation_count = r.WriteAccessViolationCount, + total_dump_requests = r.TotalDumpRequests, + interval_dump_requests = r.IntervalDumpRequests, + non_yielding_tasks_reported = r.NonYieldingTasksReported, + page_faults = r.PageFaults, + system_cpu_utilization = r.SystemCpuUtilization, + sql_cpu_utilization = r.SqlCpuUtilization, + bad_pages_detected = r.BadPagesDetected, + bad_pages_fixed = r.BadPagesFixed + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_system_health", ex); } + } + + [McpServerTool(Name = "get_health_parser_severe_errors"), Description("Gets severe errors from system_health: stack dumps, non-yielding schedulers, and other critical SQL Server events.")] + public static async Task GetSevereErrors( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, + [Description("Maximum number of entries. Default 50.")] int limit = 50) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back) ?? McpHelpers.ValidateTop(limit); + if (validation != null) return validation; + + try + { + var now = DateTime.UtcNow; + /* database_id → name resolution needs the collected size-stats mapping (the shred left it null). */ + var mapTask = DarlingSystemHealthReader.GetDatabaseNameMapAsync(postgres, resolved.Value.ServerId); + var xmls = await DarlingSystemHealthReader.ReadEventXmlAsync( + postgres, resolved.Value.ServerId, now.AddHours(-hours_back), now, SystemHealthParser.ErrorReportedEvent); + var map = await mapTask; + + var rows = xmls + .Select(SystemHealthParser.ParseSevereError) + .Where(r => r != null && SystemHealthSignificance.IsSignificant(r)) + .Select(r => r!) + .ToList(); + if (rows.Count == 0) return McpHelpers.Status("empty", "No severe errors found in the requested time range."); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + error_count = rows.Count, + shown = Math.Min(rows.Count, limit), + errors = rows.Take(limit).Select(r => new + { + event_time = r.EventTime?.ToString("o"), + error_number = r.ErrorNumber, + severity = r.Severity, + state = r.State, + database_id = r.DatabaseId, + database_name = DarlingSystemHealthReader.ResolveDatabaseName(r.DatabaseId, map), + message = r.Message + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_severe_errors", ex); } + } + + [McpServerTool(Name = "get_health_parser_io_issues"), Description("Gets I/O-related issues from system_health: 15-second I/O warnings, long I/O requests, and stalled I/O subsystems.")] + public static async Task GetIOIssues( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, + [Description("Maximum number of entries. Default 50.")] int limit = 50) + { + try + { + /* IO_SUBSYSTEM fans one event out to one row per pending-request file — a many-per-event shred. */ + var c = await CollectAsync(postgres, server_name, hours_back, limit, + SystemHealthParser.SpServerDiagnosticsEvent, + SystemHealthParser.ParseIoIssues, + SystemHealthSignificance.IsSignificant); + if (c.EarlyReturn != null) return c.EarlyReturn; + if (c.Rows.Count == 0) return McpHelpers.Status("empty", "No I/O issues found in the requested time range."); + + return JsonSerializer.Serialize(new + { + server = c.ServerName, + hours_back, + issue_count = c.Rows.Count, + shown = Math.Min(c.Rows.Count, limit), + issues = c.Rows.Take(limit).Select(r => new + { + event_time = r.EventTime?.ToString("o"), + state = r.State, + io_latch_timeouts = r.IoLatchTimeouts, + interval_long_ios = r.IntervalLongIos, + total_long_ios = r.TotalLongIos, + longest_pending_requests_duration_ms = r.LongestPendingRequestsDurationMs, + longest_pending_requests_file_path = r.LongestPendingRequestsFilePath + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_io_issues", ex); } + } + + [McpServerTool(Name = "get_health_parser_scheduler_issues"), Description("Gets scheduler issues from system_health: non-yielding schedulers, deadlocked schedulers, and scheduler monitor events.")] + public static async Task GetSchedulerIssues( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, + [Description("Maximum number of entries. Default 50.")] int limit = 50) + { + try + { + var c = await CollectAsync(postgres, server_name, hours_back, limit, + SystemHealthParser.SchedulerMonitorEvent, + xml => One(SystemHealthParser.ParseSchedulerIssue(xml)), + SystemHealthSignificance.IsSignificant); + if (c.EarlyReturn != null) return c.EarlyReturn; + if (c.Rows.Count == 0) return McpHelpers.Status("empty", "No scheduler issues found in the requested time range."); + + return JsonSerializer.Serialize(new + { + server = c.ServerName, + hours_back, + issue_count = c.Rows.Count, + shown = Math.Min(c.Rows.Count, limit), + issues = c.Rows.Take(limit).Select(r => new + { + event_time = r.EventTime?.ToString("o"), + scheduler_id = r.SchedulerId, + cpu_id = r.CpuId, + status = r.Status, + is_online = r.IsOnline, + is_runnable = r.IsRunnable, + is_running = r.IsRunning, + non_yielding_time_ms = r.NonYieldingTimeMs, + thread_quantum_ms = r.ThreadQuantumMs + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_scheduler_issues", ex); } + } + + [McpServerTool(Name = "get_health_parser_memory_conditions"), Description("Gets memory condition events from system_health: low memory notifications, memory broker adjustments, and memory pressure indicators.")] + public static async Task GetMemoryConditions( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, + [Description("Maximum number of entries. Default 50.")] int limit = 50) + { + try + { + var c = await CollectAsync(postgres, server_name, hours_back, limit, + SystemHealthParser.SpServerDiagnosticsEvent, + xml => One(SystemHealthParser.ParseMemoryConditions(xml)), + SystemHealthSignificance.IsSignificant); + if (c.EarlyReturn != null) return c.EarlyReturn; + if (c.Rows.Count == 0) return McpHelpers.Status("empty", "No memory condition events found in the requested time range."); + + return JsonSerializer.Serialize(new + { + server = c.ServerName, + hours_back, + event_count = c.Rows.Count, + shown = Math.Min(c.Rows.Count, limit), + events = c.Rows.Take(limit).Select(r => new + { + event_time = r.EventTime?.ToString("o"), + last_notification = r.LastNotification, + out_of_memory_exceptions = r.OutOfMemoryExceptions, + is_any_pool_out_of_memory = r.IsAnyPoolOutOfMemory, + process_out_of_memory_period = r.ProcessOutOfMemoryPeriod, + name = r.Name, + available_physical_memory_gb = r.AvailablePhysicalMemoryGb, + available_virtual_memory_gb = r.AvailableVirtualMemoryGb, + available_paging_file_gb = r.AvailablePagingFileGb, + working_set_gb = r.WorkingSetGb, + percent_of_committed_memory_in_ws = r.PercentOfCommittedMemoryInWs, + page_faults = r.PageFaults, + system_physical_memory_high = r.SystemPhysicalMemoryHigh, + system_physical_memory_low = r.SystemPhysicalMemoryLow, + process_physical_memory_low = r.ProcessPhysicalMemoryLow, + process_virtual_memory_low = r.ProcessVirtualMemoryLow, + vm_reserved_gb = r.VmReservedGb, + vm_committed_gb = r.VmCommittedGb, + locked_pages_allocated = r.LockedPagesAllocated, + large_pages_allocated = r.LargePagesAllocated, + emergency_memory_gb = r.EmergencyMemoryGb, + emergency_memory_in_use_gb = r.EmergencyMemoryInUseGb, + target_committed_gb = r.TargetCommittedGb, + current_committed_gb = r.CurrentCommittedGb, + pages_allocated = r.PagesAllocated, + pages_reserved = r.PagesReserved, + pages_free = r.PagesFree, + pages_in_use = r.PagesInUse, + page_alloc_potential = r.PageAllocPotential, + numa_growth_phase = r.NumaGrowthPhase, + last_oom_factor = r.LastOomFactor, + last_os_error = r.LastOsError + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_memory_conditions", ex); } + } + + [McpServerTool(Name = "get_health_parser_cpu_tasks"), Description("Gets CPU task events from system_health: long-running CPU-bound tasks, high CPU worker threads, and process utilization snapshots.")] + public static async Task GetCPUTasks( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, + [Description("Maximum number of entries. Default 50.")] int limit = 50) + { + try + { + var c = await CollectAsync(postgres, server_name, hours_back, limit, + SystemHealthParser.SpServerDiagnosticsEvent, + xml => One(SystemHealthParser.ParseCpuTasks(xml)), + SystemHealthSignificance.IsSignificant); + if (c.EarlyReturn != null) return c.EarlyReturn; + if (c.Rows.Count == 0) return McpHelpers.Status("empty", "No CPU task events found in the requested time range."); + + return JsonSerializer.Serialize(new + { + server = c.ServerName, + hours_back, + event_count = c.Rows.Count, + shown = Math.Min(c.Rows.Count, limit), + events = c.Rows.Take(limit).Select(r => new + { + event_time = r.EventTime?.ToString("o"), + state = r.State, + max_workers = r.MaxWorkers, + workers_created = r.WorkersCreated, + workers_idle = r.WorkersIdle, + tasks_completed_within_interval = r.TasksCompletedWithinInterval, + pending_tasks = r.PendingTasks, + oldest_pending_task_waiting_time = r.OldestPendingTaskWaitingTime, + has_unresolvable_deadlock_occurred = r.HasUnresolvableDeadlockOccurred, + has_deadlocked_schedulers_occurred = r.HasDeadlockedSchedulersOccurred, + did_blocking_occur = r.DidBlockingOccur + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_cpu_tasks", ex); } + } + + [McpServerTool(Name = "get_health_parser_memory_broker"), Description("Gets memory broker events from system_health: cache shrink/grow notifications, memory clerk adjustments, and broker-mediated memory redistribution.")] + public static async Task GetMemoryBroker( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, + [Description("Maximum number of entries. Default 50.")] int limit = 50) + { + try + { + var c = await CollectAsync(postgres, server_name, hours_back, limit, + SystemHealthParser.MemoryBrokerEvent, + xml => One(SystemHealthParser.ParseMemoryBroker(xml)), + SystemHealthSignificance.IsSignificant); + if (c.EarlyReturn != null) return c.EarlyReturn; + if (c.Rows.Count == 0) return McpHelpers.Status("empty", "No memory broker events found in the requested time range."); + + return JsonSerializer.Serialize(new + { + server = c.ServerName, + hours_back, + event_count = c.Rows.Count, + shown = Math.Min(c.Rows.Count, limit), + events = c.Rows.Take(limit).Select(r => new + { + event_time = r.EventTime?.ToString("o"), + broker_id = r.BrokerId, + pool_metadata_id = r.PoolMetadataId, + delta_time = r.DeltaTime, + memory_ratio = r.MemoryRatio, + new_target = r.NewTarget, + overall = r.Overall, + rate = r.Rate, + currently_predicated = r.CurrentlyPredicated, + currently_allocated = r.CurrentlyAllocated, + previously_allocated = r.PreviouslyAllocated, + broker = r.Broker, + notification = r.Notification + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_memory_broker", ex); } + } + + [McpServerTool(Name = "get_health_parser_memory_node_oom"), Description("Gets memory node OOM events from system_health: out-of-memory conditions on specific NUMA nodes.")] + public static async Task GetMemoryNodeOOM( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, + [Description("Maximum number of entries. Default 50.")] int limit = 50) + { + try + { + /* Memory-node OOM is never gated — every recorded OOM is significant (sp_HealthParser applies + no WHERE filter to this category). */ + var c = await CollectAsync(postgres, server_name, hours_back, limit, + SystemHealthParser.MemoryNodeOomEvent, + xml => One(SystemHealthParser.ParseMemoryNodeOom(xml)), + SystemHealthSignificance.IsSignificant); + if (c.EarlyReturn != null) return c.EarlyReturn; + if (c.Rows.Count == 0) return McpHelpers.Status("empty", "No memory node OOM events found in the requested time range."); + + return JsonSerializer.Serialize(new + { + server = c.ServerName, + hours_back, + event_count = c.Rows.Count, + shown = Math.Min(c.Rows.Count, limit), + events = c.Rows.Take(limit).Select(r => new + { + event_time = r.EventTime?.ToString("o"), + node_id = r.NodeId, + memory_node_id = r.MemoryNodeId, + memory_utilization_pct = r.MemoryUtilizationPct, + total_physical_memory_kb = r.TotalPhysicalMemoryKb, + available_physical_memory_kb = r.AvailablePhysicalMemoryKb, + total_page_file_kb = r.TotalPageFileKb, + available_page_file_kb = r.AvailablePageFileKb, + total_virtual_address_space_kb = r.TotalVirtualAddressSpaceKb, + available_virtual_address_space_kb = r.AvailableVirtualAddressSpaceKb, + target_kb = r.TargetKb, + reserved_kb = r.ReservedKb, + committed_kb = r.CommittedKb, + shared_committed_kb = r.SharedCommittedKb, + awe_kb = r.AweKb, + pages_kb = r.PagesKb, + failure_type = r.FailureType, + failure_value = r.FailureValue, + resources = r.Resources, + factor_text = r.FactorText, + factor_value = r.FactorValue, + last_error = r.LastError, + pool_metadata_id = r.PoolMetadataId, + is_process_in_job = r.IsProcessInJob, + is_system_physical_memory_high = r.IsSystemPhysicalMemoryHigh, + is_system_physical_memory_low = r.IsSystemPhysicalMemoryLow, + is_process_physical_memory_low = r.IsProcessPhysicalMemoryLow, + is_process_virtual_memory_low = r.IsProcessVirtualMemoryLow + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_memory_node_oom", ex); } + } + + /* ─────────────────────────── shared read + parse + filter ─────────────────────────── */ + + /// The outcome of resolve + validate + read + shred + significance-filter: either an + /// string (a resolution error or #1224 validation) the tool returns verbatim, + /// or the resolved + the SIGNIFICANT parsed . + private readonly record struct Collected(string? EarlyReturn, string ServerName, List Rows); + + /// + /// Resolves the server, validates hours_back + limit, reads the raw event_xml for + /// over the window, shreds each blob with (the + /// reused — 0..n records per event), and keeps only the rows + /// accepts. The seven gated categories pass their + /// predicate; System Health passes an EventTime-present + /// predicate (ungated, matching the viewer's chart read). + /// + private static async Task> CollectAsync( + NpgsqlDataSource postgres, string? serverName, int hoursBack, int limit, string eventType, + Func> shred, Func significant) where T : class + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, serverName); + if (error != null) return new Collected(error, "", new List()); + + var validation = McpHelpers.ValidateHoursBack(hoursBack) ?? McpHelpers.ValidateTop(limit); + if (validation != null) return new Collected(validation, "", new List()); + + var now = DateTime.UtcNow; + var xmls = await DarlingSystemHealthReader.ReadEventXmlAsync( + postgres, resolved.Value.ServerId, now.AddHours(-hoursBack), now, eventType); + + var rows = new List(); + foreach (var xml in xmls) + { + foreach (var record in shred(xml)) + { + if (record != null && significant(record)) + rows.Add(record); + } + } + + return new Collected(null, resolved.Value.ServerName, rows); + } + + /// Wraps a single-record shred (0-or-1) as the 0..n sequence expects. + private static IEnumerable One(T? record) where T : class => + record is null ? Enumerable.Empty() : new[] { record }; +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHealthTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHealthTools.cs new file mode 100644 index 000000000..03b9a1647 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHealthTools.cs @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.ComponentModel; +using System.Text.Json; +using System.Threading.Tasks; +using ModelContextProtocol.Server; +using Npgsql; +using PerformanceMonitor.Common; + +#pragma warning disable CA1707 // MCP tools use snake_case naming convention + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// The health-overview MCP tools — get_server_summary (Lite's one-shot per-server health) and +/// get_daily_summary (the Dashboard's daily rollup) — served over Darling's Postgres store, the same names +/// those SKUs expose. Both read through (STORED reads, no live +/// monitored-server hit). get_server_summary is the fast "is this server OK" check — current SQL CPU, memory, +/// recent blocking, recent deadlocks — before drilling in; get_daily_summary folds a day's signals into the +/// SHARED composite health band (DailyHealthBandCalculator), so an agent gets the same Healthy / +/// Warning / Critical verdict the Performance Calendar shows. +/// +[McpServerToolType] +public sealed class DarlingMcpHealthTools +{ + [McpServerTool(Name = "get_server_summary"), Description("Gets a quick health overview for a SQL Server instance: current CPU %, memory usage, recent blocking count, and deadlock count. Use this for a fast health check before drilling into specific areas.")] + public static async Task GetServerSummary( + NpgsqlDataSource postgres, + [Description("Server name or display name. Optional if only one server is configured.")] string? server_name = null) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + try + { + var summary = await DarlingHealthReader.GetServerSummaryAsync(postgres, resolved.Value.ServerId); + if (summary.HasNoData) + return McpHelpers.Status( + "unavailable", + $"No data available for {resolved.Value.ServerName}. The collector may not have run yet."); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + cpu_percent = summary.CpuPercent, + memory_mb = summary.MemoryMb, + blocking_count = summary.BlockingCount, + deadlock_count = summary.DeadlockCount, + last_collection = summary.LastCollectionTime?.ToString("o") + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_server_summary", ex); + } + } + + [McpServerTool(Name = "get_daily_summary"), Description("Gets a daily health summary: overall composite health band (Healthy/Warning/Critical), total wait time, top wait type, unique query count, deadlocks, blocking events, memory pressure (and severe memory pressure), high-CPU samples, collection errors, and actionable alert count for one day. Use this for a quick overview to decide which areas need investigation.")] + public static async Task GetDailySummary( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Summary date (yyyy-MM-dd), interpreted as a UTC day. Default is today.")] string? summary_date = null) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + DateTime? date = null; + if (!string.IsNullOrEmpty(summary_date)) + { + if (!DateTime.TryParse(summary_date, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var parsed)) + return $"Invalid date format '{summary_date}'. Use yyyy-MM-dd format (e.g., 2026-07-09)."; + date = parsed; + } + + try + { + var row = await DarlingHealthReader.GetDailySummaryAsync(postgres, resolved.Value.ServerId, date); + if (!row.HasData) + return McpHelpers.Status( + "empty", + $"No data collected for {resolved.Value.ServerName} on {row.SummaryDate:yyyy-MM-dd}.", + new { summary_date = row.SummaryDate.ToString("yyyy-MM-dd"), overall_health = row.OverallHealth }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + summary_date = row.SummaryDate.ToString("yyyy-MM-dd"), + overall_health = row.OverallHealth, + health_band = row.HealthBand.ToString(), + total_wait_time_sec = row.TotalWaitTimeSec, + top_wait_type = row.TopWaitType, + unique_queries = row.UniqueQueries, + deadlock_count = row.DeadlockCount, + blocking_events = row.BlockingEvents, + high_cpu_events = row.HighCpuEvents, + memory_pressure_events = row.MemoryPressureEvents, + memory_critical_events = row.MemoryCriticalEvents, + collection_errors = row.CollectionErrors, + alert_count = row.AlertCount, + max_block_duration_ms = row.MaxBlockDurationMs + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_daily_summary", ex); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs new file mode 100644 index 000000000..3a279943f --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs @@ -0,0 +1,824 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Net; +using System.Runtime.Versioning; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Npgsql; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Analysis; +using PerformanceMonitor.Darling.Service.Hosting; + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// Optional hosted service exposing Darling's full MCP tool surface over Streamable HTTP — the analysis +/// class (6 tools) plus the plan-analysis tools and the ~60 STORED data-read tools (resource metrics, +/// query performance, blocking/deadlocks, sessions, config history + current-config snapshots, index/object, +/// latch/spinlock/memory-grant/plan-cache/scheduler/jobs, windowed trends, the system_health parse-on-read +/// family, and the fleet-triage alerts + health-overview reads) +/// — the same names Lite and the Dashboard expose, all reading Darling's Postgres store (no live +/// monitored-server hit except analyze_server's plan fetch). Same transport/hosting model as +/// Lite's McpHostService (ModelContextProtocol.AspNetCore, Kestrel, stateless HTTP, +/// Gemini-compatible tool registration for #1074); both reasons for HTTP-over-stdio (the server outlives +/// any one client and serves concurrent clients) apply MORE to a 24/7 headless service. +/// +/// Network exposure — off by default, secure by default (darling-network-endpoints, D3): +/// with no mcp.network block the server binds loopback only and is TOKENLESS — byte-for-byte +/// today's local MCP, so existing local clients are unaffected. An opt-in mcp.network block +/// (MANAGED MODE ONLY) binds the specified LAN interface (plus both loopback families) behind two +/// middlewares installed FIRST in the pipeline, before any MCP handler/handshake: an in-app CIDR check on +/// RemoteIpAddress (loopback always allowed, Round-4 #2) and an unconditional constant-time bearer +/// token (NO loopback exemption — the loopback guard). The effective bind is decided by the pure +/// ; the caller maps its reason to a severity — LogCritical on a missing +/// precondition (token / valid allowFrom CIDR) and LogWarning in BYO mode — and degrades to loopback-only +/// either way. Fail-closed, enforced HERE (the MCP host), NEVER in the all-fatal +/// (the worker's abort would not stop this host). No TLS on MCP (a +/// self-signed cert breaks real clients; the named MITM control is a TLS reverse proxy in front of the +/// endpoint) — the token travels cleartext on-segment, so own that residual with the reverse proxy. A +/// best-effort, scoped, idempotent firewall rule is added when exposed and removed when not +/// (defense-in-depth; the token + CIDR are the boundary, not the firewall). +/// +/// Gated by darling.json's mcp.enabled (default OFF — a headless service should not open a +/// port unless the operator asks); when disabled or when the config cannot load (the worker already logs +/// that as critical), this service stands down without affecting collection. Registered always in +/// Program.cs and self-gating here, because config loading/validation is the worker's job and Program.cs +/// stays config-free. +/// +/// The MCP surface gets its OWN over the store, connecting as the +/// dedicated least-privilege mcp role (D3-role) — NOT the superuser owner — so a token-holder (or a +/// future/buggy tool) reaches only the viewer read surface plus the analysis_findings / +/// analysis_muted INSERTs the tools persist, never the config_command service-credential +/// pivot or the carved secret columns. It also gets its own . Store +/// migration + role provisioning are the WORKER's job; the mcp-role credential is written AFTER +/// migration (later than the owner's), so the first-boot poll budget tolerates the delay. The plan fetcher +/// resolves a finding's serverId to a live connection string built from darling.json (DPAPI resolution +/// lazy per fetch; any resolution/connection failure degrades the fetch to null inside +/// ). On a brand-new store, tool calls before the first migration/connect +/// simply return their error/miss envelopes. +/// +public sealed class DarlingMcpHostService : BackgroundService +{ + private readonly ILogger _logger; + private readonly McpRuntimeState _state; + private WebApplication? _app; + private NpgsqlDataSource? _appDataSource; + private int _runningPort; + private bool _runningNetworkMode; + + /// How often the supervisor re-reads the live control-plane state (#1560). + internal static readonly TimeSpan SupervisorPollInterval = TimeSpan.FromSeconds(5); + + /// Backoff after a FAILED start attempt (port in use, credential not ready) so a persistent + /// failure logs on a calm cadence instead of every poll tick. + internal static readonly TimeSpan FailedStartBackoff = TimeSpan.FromSeconds(30); + + public DarlingMcpHostService(ILogger logger, McpRuntimeState state) + { + _logger = logger; + _state = state; + } + + /// The supervisor's per-tick verdict — pure over (running, runningPort, enabled, desiredPort) + /// so a unit test pins the whole decision table without a server (#1560). + public enum McpSupervisorAction { None, Start, Stop, Restart } + + internal static McpSupervisorAction DecideMcpAction(bool running, int runningPort, bool enabled, int desiredPort) + { + if (!running) + { + return enabled ? McpSupervisorAction.Start : McpSupervisorAction.None; + } + + if (!enabled) + { + return McpSupervisorAction.Stop; + } + + return runningPort == desiredPort ? McpSupervisorAction.None : McpSupervisorAction.Restart; + } + + /// + /// The supervisor loop (#1560): the viewer's Settings toggle writes config_service.mcp_enabled / + /// mcp_port, the worker's reload beacon publishes the live values to , + /// and this loop starts / stops / rebinds the inner web app to match — no service restart. Until the + /// worker's first publish the FILE values apply (byte-for-byte the old behavior, and the only signal + /// available when the store is unreachable). The mcp.network exposure block stays file-defined and + /// restart-only by design; the store toggle still stops an exposed server instantly (a kill switch). + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + DarlingConfig config; + try + { + config = DarlingConfig.Load(); + } + catch (Exception ex) + { + /* The worker logs the missing/broken config as critical; without a file config there is no + bind/network/store definition to serve with, so the MCP host stands down entirely. */ + _logger.LogDebug("MCP server not started (configuration unavailable): {Message}", ex.Message); + return; + } + + var lastFailedStartUtc = DateTime.MinValue; + while (!stoppingToken.IsCancellationRequested) + { + var published = _state.Read(); + var enabled = published?.Enabled ?? config.Mcp.Enabled; + var desiredPort = published?.Port ?? config.Mcp.Port; + + switch (DecideMcpAction(_app is not null, _runningPort, enabled, desiredPort)) + { + case McpSupervisorAction.Start when DateTime.UtcNow - lastFailedStartUtc >= FailedStartBackoff: + if (!await TryStartServerAsync(config, desiredPort, stoppingToken)) + { + lastFailedStartUtc = DateTime.UtcNow; + } + break; + + case McpSupervisorAction.Stop: + _logger.LogInformation("MCP server disabled via the control plane — stopping (no restart needed)"); + await StopServerAsync(stoppingToken); + break; + + case McpSupervisorAction.Restart: + _logger.LogInformation( + "MCP port changed via the control plane ({Old} -> {New}) — rebinding", _runningPort, desiredPort); + await StopServerAsync(stoppingToken); + if (!await TryStartServerAsync(config, desiredPort, stoppingToken)) + { + lastFailedStartUtc = DateTime.UtcNow; + } + break; + } + + try + { + await Task.Delay(SupervisorPollInterval, stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + } + } + + /// Stops and disposes the running app + its data source; in managed network mode also + /// reconciles the firewall rule off (symmetric with the start-side reconcile — disabling closes the + /// box). Safe to call when nothing is running. + private async Task StopServerAsync(CancellationToken cancellationToken) + { + if (_app is null) + { + return; + } + + try + { + await _app.StopAsync(cancellationToken); + await _app.DisposeAsync(); + } + catch (Exception ex) + { + _logger.LogWarning("MCP server stop reported an error (continuing): {Message}", ex.Message); + } + + _app = null; + + if (_appDataSource is not null) + { + await _appDataSource.DisposeAsync(); + _appDataSource = null; + } + + if (_runningNetworkMode && OperatingSystem.IsWindows()) + { + await ReconcileMcpFirewallAsync(_runningPort, enable: false, null, cancellationToken); + } + + _runningNetworkMode = false; + _runningPort = 0; + } + + /// Failed-start cleanup: a partially built app / data source must not leak between attempts. + private async Task DisposeFailedStartAsync() + { + if (_app is not null) + { + try { await _app.DisposeAsync(); } catch { /* best-effort */ } + _app = null; + } + + if (_appDataSource is not null) + { + try { await _appDataSource.DisposeAsync(); } catch { /* best-effort */ } + _appDataSource = null; + } + } + + /// + /// One start ATTEMPT of the inner MCP web app at (#1560): the whole + /// pre-supervisor startup body, with two changes — the port comes from the live control-plane value + /// rather than the file, and every bail path returns false so the supervisor can retry with backoff + /// instead of standing down for the process lifetime. The bind/network/token decisions still come from + /// the FILE-loaded config (network exposure is deliberately restart-only); returns true when the app + /// is started and listening. + /// + private async Task TryStartServerAsync(DarlingConfig config, int effectivePort, CancellationToken stoppingToken) + { + /* Decide the effective bind PURELY, then map the reason -> severity here (Round-4 #7: the caller, + not the pure fn, chooses LogCritical vs LogWarning; tests assert (Mode, Reason) without a logger). */ + var bind = ResolveMcpBind(config.Mcp, config.Postgres.Managed); + LogBindReason(config.Mcp, bind.Reason); + + try + { + var networkMode = bind.Mode == McpBindMode.NetworkAndLoopback; + + /* In network mode ResolveMcpBind has already validated the listen IP, the allowFrom CIDR, AND their + address-family agreement, so these two parses cannot throw; only resolving the token can still + fail (a corrupt DPAPI blob), which fail-closes to loopback-only rather than exposing tokenless. */ + IPAddress? networkListenIp = null; + IPNetwork allowedCidr = default; + string bearerToken = ""; + if (networkMode) + { + networkListenIp = IPAddress.Parse(config.Mcp.Network!.Listen!.Trim()); + allowedCidr = IPNetwork.Parse(config.Mcp.Network.AllowFrom!.Trim()); + + try + { + var token = config.Mcp.Network.ResolveToken(out var usedPlaintext); + if (string.IsNullOrWhiteSpace(token)) + { + _logger.LogCritical( + "MCP network token resolved to empty after decryption — refusing to expose; binding loopback-only."); + networkMode = false; + } + else + { + bearerToken = token; + if (usedPlaintext) + { + _logger.LogWarning( + "mcp.network.token is set in plaintext (dev convenience) — prefer mcp.network.encryptedToken " + + "(produced by --encrypt-password). This token gates ALL MCP network access."); + } + } + } + catch (Exception ex) + { + _logger.LogCritical( + "MCP network token could not be decrypted ({Message}) — refusing to expose; binding loopback-only.", + ex.Message); + networkMode = false; + } + } + + /* The REAL primary bind address (network IP when exposed, else loopback): both the port precheck + and the Kestrel bind use it, so the precheck probes the actual address, not always loopback. */ + var primaryBind = networkMode ? networkListenIp! : IPAddress.Loopback; + + /* Port-in-use pre-check — Lite's StartMcpServerAsync guard, via the shared utility, against the + REAL bind address (D3-e: not always IPAddress.Loopback). Done before the firewall reconcile so a + bail here leaves the firewall untouched. */ + if (await PortUtilityService.IsTcpPortListeningAsync(effectivePort, primaryBind, stoppingToken)) + { + _logger.LogError("Port {Port} is already in use — MCP server not started this attempt; will retry", effectivePort); + return false; + } + + /* Firewall reconcile (managed mode only; best-effort, never fatal). Symmetric like the store's D1 + reconcile: ensure the scoped rule when exposed, remove it when not (so disabling the config + closes the box). The token + in-app CIDR are the boundary; the firewall is defense-in-depth. */ + if (config.Postgres.Managed && OperatingSystem.IsWindows()) + { + await ReconcileMcpFirewallAsync( + effectivePort, networkMode, networkMode ? allowedCidr.ToString() : null, stoppingToken); + } + + /* Managed mode: the WORKER owns the bundled server's lifecycle; the MCP host only derives the + least-privilege mcp-role connection string from the stored DPAPI credential (D3-role). */ + string? storeConnectionString; + if (config.Postgres.Managed) + { + if (!OperatingSystem.IsWindows()) + { + _logger.LogError("MCP server not started: postgres.managed = true requires Windows"); + return false; + } + + storeConnectionString = await WaitForManagedConnectionStringAsync(config.Postgres, stoppingToken); + if (storeConnectionString is null) + { + return false; + } + } + else + { + storeConnectionString = config.Postgres.ConnectionString; + } + + /* Lifetime tied to the running app (#1560): disposed by StopServerAsync, not this method's + scope — the supervisor may keep the app running across many poll ticks. */ + var postgres = NpgsqlDataSource.Create(storeConnectionString); + _appDataSource = postgres; + + /* serverId → connection string from config (first entry wins on a duplicate storage + name, mirroring the worker's FirstOrDefault over runtimes). Resolution is lazy so + DPAPI decrypt runs only when a plan fetch actually needs the connection. */ + var serversById = new Dictionary(); + foreach (var server in config.Servers) + { + serversById.TryAdd(ServerIdHelper.GetDeterministicHashCode(server.StorageName), server); + } + + var planFetcher = new PgPlanFetcher( + serverId => serversById.TryGetValue(serverId, out var server) + ? DarlingServerConnector.ResolveConnectionString(server, _logger) + : null, + _logger); + + var builder = WebApplication.CreateBuilder(); + + builder.WebHost.ConfigureKestrel(options => + { + if (networkMode) + { + /* Bind the specific family (not ListenAnyIP), then ALSO both loopback families so a local + client resolving "localhost" -> ::1 still works — skipping the loopback Listen(s) when the + listen value is itself loopback or a wildcard (0.0.0.0/::), which would collide on the port. */ + options.Listen(primaryBind, effectivePort); + if (ShouldAddLoopbackListeners(primaryBind)) + { + options.Listen(IPAddress.Loopback, effectivePort); + options.Listen(IPAddress.IPv6Loopback, effectivePort); + } + } + else + { + /* The default/degraded loopback-only server — byte-for-byte today's bind (both families). */ + options.ListenLocalhost(effectivePort); + } + }); + + /* Suppress ASP.NET Core console logging — the service's own logger reports lifecycle. */ + builder.Logging.ClearProviders(); + builder.Logging.SetMinimumLevel(LogLevel.Warning); + + /* Register services that MCP tools need via dependency injection. */ + builder.Services.AddSingleton(postgres); + builder.Services.AddSingleton(new DarlingAnalysisService(postgres, planFetcher, _logger)); + + /* Register MCP server with the analysis tool class. */ + builder.Services + .AddMcpServer(options => + { + options.ServerInfo = new() + { + Name = "PerformanceMonitorDarling", + Version = "1.0.0" + }; + options.ServerInstructions = DarlingMcpInstructions.Text; + }) + /* Stateless mode: each request is self-contained (no Mcp-Session-Id round-trip). + Required for clients like Google Antigravity that don't echo the session id, + which otherwise connect but list zero tools (issue #1074). */ + .WithHttpTransport(options => options.Stateless = true) + /* WithGeminiCompatibleTools (not the SDK's WithTools) rewrites parameter schemas into + the subset Gemini/Antigravity accepts — collapsing nullable type unions and + dropping the default keyword. The companion to stateless transport for issue #1074. */ + .WithGeminiCompatibleTools() + /* The five plan-analysis tools (analyze_query_plan / analyze_procedure_plan / + analyze_query_store_plan / analyze_plan_xml / get_plan_xml) — the same names the + Dashboard and Lite expose, fetching the collectors' STORED plan XML from Postgres + (no live monitored-server hit) and running the SHARED PlanAnalysis engine. */ + .WithGeminiCompatibleTools() + /* The core data-read tools (resource metrics, query performance, discovery/health — + get_cpu_utilization / get_wait_stats / get_wait_trend / get_memory_stats / + get_memory_clerks / get_file_io_stats / get_tempdb_trend / get_perfmon_stats / + get_top_queries_by_cpu / get_top_procedures_by_cpu / get_query_store_top / + list_servers / get_collection_health / get_server_properties), the same names Lite + and the Dashboard expose, over Darling's Postgres store (STORED reads, no live hit). + These are the tools the analysis findings' next_tools recommendations point at. */ + .WithGeminiCompatibleTools() + /* The diagnostic-depth data-read tools (blocking/deadlocks, sessions, config-history, + index/object) — get_blocking / get_deadlocks / get_deadlock_detail / + get_blocked_process_xml, get_session_stats / get_active_queries / get_waiting_tasks, + get_server_config_changes / get_database_config_changes / get_trace_flag_changes / + get_database_scoped_config, get_table_index_sizes / get_index_usage / get_object_locking / + get_database_sizes — the same names Lite and the Dashboard expose, over Darling's Postgres + store (STORED reads, no live hit). Result shapes follow Lite where the two SKUs diverge. */ + .WithGeminiCompatibleTools() + /* #1496 get_long_query_completions — the opt-in long-query completion trace (rpc/batch over + the duration threshold + attentions), over Darling's Postgres store (STORED read). */ + .WithGeminiCompatibleTools() + .WithGeminiCompatibleTools() + .WithGeminiCompatibleTools() + .WithGeminiCompatibleTools() + /* The resource-contention + jobs data-read tools — get_latch_stats / get_spinlock_stats, + get_resource_semaphore / get_memory_grants, get_plan_cache_bloat / get_cpu_scheduler_pressure, + get_running_jobs — the same names Lite and the Dashboard expose, over Darling's Postgres store + (STORED reads of the collected latch/spinlock/memory-grant/plan-cache/cpu-scheduler/running-job + snapshots, no live hit). The Dashboard-only CASE enrichment (latch severity/description/ + recommendation, spinlock description) and the #1410 client-side classifications (plan-cache + bloat_level, cpu-scheduler pressure_level) are reproduced service-side so the full result shape + is served; Darling's delta collectors store no sample_interval_seconds, so per-second rates are + derived from the LAG interval. */ + .WithGeminiCompatibleTools() + .WithGeminiCompatibleTools() + .WithGeminiCompatibleTools() + .WithGeminiCompatibleTools() + /* The windowed-trend siblings of the core data-read tools — get_memory_trend / + get_perfmon_trend / get_file_io_trend / get_query_trend / get_query_duration_trend — the + same names Lite and the Dashboard expose, over Darling's Postgres store (STORED reads of + the collected memory / perfmon / file-io / query-stats series, no live hit). Each mirrors + the viewer's proven chart read; the shape follows Lite where the SKUs diverge. */ + .WithGeminiCompatibleTools() + /* The fleet-triage quick-win reads the fleet edition previously lacked — the alerts family + (get_alert_history over config_alert_log, get_alert_settings over config_alert_settings, + get_mute_rules via the service-side PgMuteRuleStore), the CURRENT-config snapshot trio + (get_server_config / get_database_config / get_trace_flags — latest capture, the companion to + the *_changes diff tools), and the health overview (get_server_summary + the daily rollup + get_daily_summary, folded through the shared DailyHealthBandCalculator). Same names Lite and + the Dashboard expose, all STORED reads over Darling's Postgres store (no live hit). The + blocking-trend / deadlock-trend, memory-pressure-event, and wait-type siblings ride along on + the existing blocking / memory-grant / core data-read classes above. */ + .WithGeminiCompatibleTools() + .WithGeminiCompatibleTools() + .WithGeminiCompatibleTools() + /* The cross-server fleet overview — get_fleet_overview (#1562) — the roll-up only the central + store can serve, over the SHARED DarlingFleetReader that also powers the web /api/fleet and the + WPF viewer's Overview (one reader, one banding). ADDITIVE alongside get_server_summary. */ + .WithGeminiCompatibleTools() + /* The system_health parse-on-read family — get_health_parser_cpu_tasks / _io_issues / + _memory_broker / _memory_conditions / _memory_node_oom / _scheduler_issues / + _severe_errors / _system_health — the same names the Dashboard exposes. Where the Dashboard + reads its server-side-parsed collect.HealthParser_* tables, these shred the raw + system_health_events on read via the shared SystemHealthParser (Common) and gate with the + service-side twin of the viewer's SystemEventSignificance, exactly as the viewer's System + Events tab does — the same SIGNIFICANT warning set, no live hit. */ + .WithGeminiCompatibleTools() + /* The Default Trace tool — get_default_trace_events — the same name the Dashboard exposes. + Reads Darling's collected default_trace_events (the base table, no v_* view — like + server_properties) and returns the SIGNIFICANT set via the shared + DefaultTraceEventSignificance, the same significant-set gate the viewer's System Events + surface uses; config-change events are excluded (the config-snapshot diff tools own them). */ + .WithGeminiCompatibleTools() + /* The Custom Views v2 MANAGEMENT tools (#1563) — the one WRITE surface on this server: + list_custom_views / get_custom_view / validate_custom_view / create_custom_view / + update_custom_view / delete_custom_view / run_custom_view_panel. They CRUD the user-authored + views in config.custom_views through the SAME CustomViewStore + ValidateDefinition + compose + runner the web viewer's editor uses (no divergent second impl), and run back a composed panel's + data for a self-test loop. The mcp role carries the narrow INSERT/UPDATE/DELETE grant on ONLY + config.custom_views (mirroring viewer's) — never the config pivot or the secret columns. */ + .WithGeminiCompatibleTools() + /* The server-onboarding WRITE tools — add_servers (BULK) / remove_server: an MCP client can stand up + or tear down FLEET monitoring conversationally. The service-side twin of the Viewer's Add / Add- + Multiple dialogs: add_servers validates each entry, probes the connection IN-PROCESS (the service + holds the network path + credentials, so no test_connect command plane is needed), skips + case-folded duplicates via the shared ServerIdHelper identity, DPAPI-encrypts the SQL password + (the service identity, so it round-trips at collection time), and INSERTs config.config_monitored_ + servers mirroring StoreConfigProvider.SeedMonitoredServersAsync; remove_server DELETEs by the same + resolver the read tools use. The mcp role carries the narrow INSERT/UPDATE/DELETE grant on ONLY + config.config_monitored_servers (the encrypted_password column stays SELECT-carved) — never the + config pivot or a schema-wide write. */ + .WithGeminiCompatibleTools(); + + _app = builder.Build(); + + /* Access-control middleware — installed ONLY in network mode (Round-4 #6). The default/degraded + loopback-only server stays byte-for-byte today's tokenless local MCP, so existing local clients + keep working. Both run BEFORE MapMcp (D3-b: "first ... before any handler/handshake"): the + unconditional constant-time bearer token FIRST (NO loopback exemption — in exposed mode even a + local client must present the token; that IS the loopback guard against SSRF/sandboxed sockets), + then the in-app CIDR check (loopback-exempt so the loopback bind's local clients are not 403'd, + Round-4 #2 — it bounds WHO can route to the port, independent of the best-effort firewall). */ + if (networkMode) + { + var cidr = allowedCidr; + var token = bearerToken; + + _app.Use(async (context, next) => + { + if (!IsBearerTokenAuthorized(context.Request.Headers.Authorization.ToString(), token)) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.Headers.WWWAuthenticate = "Bearer"; + return; + } + + await next(context); + }); + + _app.Use(async (context, next) => + { + if (!IsRemoteAddressAllowed(context.Connection.RemoteIpAddress, cidr)) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return; + } + + await next(context); + }); + } + + _app.MapMcp(); + + if (networkMode) + { + _logger.LogInformation( + "Starting MCP server on http://{Listen}:{Port} (LAN-exposed to {Cidr} behind a bearer token + in-app CIDR; loopback also bound)", + primaryBind, effectivePort, allowedCidr); + } + else + { + _logger.LogInformation("Starting MCP server on http://localhost:{Port} (loopback only)", effectivePort); + } + + /* StartAsync, not RunAsync (#1560): the supervisor loop owns the wait — the app keeps + serving until StopServerAsync (toggle-off, port change, or shutdown). */ + await _app.StartAsync(stoppingToken); + _runningPort = effectivePort; + _runningNetworkMode = networkMode; + return true; + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + /* Normal shutdown mid-start. */ + return false; + } + catch (Exception ex) + { + _logger.LogError("MCP server failed to start: {Message}", ex.Message); + await DisposeFailedStartAsync(); + return false; + } + } + + /* --------------------------------------------------------------------------------------------------- + Pure decision functions (darling-network-endpoints, D3). Factored out of the Kestrel/middleware + wiring so they are unit-testable without a running server or a logger; the caller maps the reason to + a log severity and installs the middleware only in network mode. + --------------------------------------------------------------------------------------------------- */ + + /// The effective MCP bind. is the secure default; + /// binds the LAN interface behind the token + CIDR. + internal enum McpBindMode + { + LoopbackOnly, + NetworkAndLoopback, + } + + /// WHY the bind resolved as it did — the caller maps this to a severity (Round-4 #7): + /// / are non-degrade (no critical log), + /// / are fail-closed degrades (LogCritical), + /// and is the BYO "ignored" notice (LogWarning, D-BYO). + internal enum McpBindReason + { + /// No network block, or a loopback/absent listen — the byte-for-byte-today loopback server. + LoopbackByDefault, + + /// All preconditions met: non-loopback listen + managed + token present + valid allowFrom CIDR. + NetworkExposed, + + /// network.* is set but postgres.managed = false — network exposure is managed-mode only (D-BYO warning). + ManagedModeRequired, + + /// Exposed + managed but the listen value is not a parseable IP (localhost/hostname/"*") — fail-closed to loopback (LogCritical). + ListenInvalid, + + /// Exposed + managed but no bearer token — fail-closed to loopback (LogCritical). + TokenMissing, + + /// Exposed + managed + token but allowFrom is missing/not a valid CIDR or its family does not match the listen — fail-closed to loopback (LogCritical). + AllowFromInvalid, + } + + /// The (mode, reason) pair returned by . + internal readonly record struct McpBindDecision(McpBindMode Mode, McpBindReason Reason); + + /// + /// The effective MCP bind — a thin adapter over the shared + /// ladder (darling-network-endpoints anti-drift): projects the MCP network block onto the surface-agnostic + /// inputs and maps the shared decision back to the nested (Mode, Reason) the MCP host + its tests use. The + /// LADDER itself (exposed classifier, managed gate, listen/token/allowFrom validation, family match) now + /// lives ONCE in ; this method's contract is byte-for-byte what it was. + /// + internal static McpBindDecision ResolveMcpBind(McpConfig mcp, bool managed) + { + var network = mcp.Network; + var decision = DarlingHostBinding.ResolveBind( + network?.Listen, + network?.AllowFrom, + tokenPresent: network is not null + && (!string.IsNullOrWhiteSpace(network.EncryptedToken) || !string.IsNullOrWhiteSpace(network.Token)), + networkConfigured: network is { IsConfigured: true }, + managed: managed); + + /* The nested McpBind* enums mirror DarlingHostBinding's BindMode/BindReason 1:1 (same member order, + pinned equal by DarlingHostBindingTests), so a numeric cast maps them without a per-value switch. */ + return new McpBindDecision((McpBindMode)(int)decision.Mode, (McpBindReason)(int)decision.Reason); + } + + /// + /// Whether to ALSO bind the two loopback families beside the network listener (D3-e). Skipped when the + /// listen value is itself a loopback address (already covered) or a wildcard (0.0.0.0 covers IPv4 + /// loopback, :: the IPv6) — binding an explicit loopback on the same port then would collide + /// (WSAEADDRINUSE). For a specific LAN IP the loopback binds are added so a local client resolving + /// "localhost" still reaches the server (which, in network mode, now also requires the token). + /// + internal static bool ShouldAddLoopbackListeners(IPAddress listenIp) + => DarlingHostBinding.ShouldAddLoopbackListeners(listenIp); + + /// + /// PURE in-app CIDR check (D3-c, Round-4 #2): is allowed? Loopback + /// (127.0.0.0/8 or ::1, incl. an IPv4-mapped-IPv6 form) is ALWAYS allowed — it is not in + /// , so otherwise the loopback bind's local clients would get 403. Everything + /// else must fall inside the CIDR. A null remote (unverifiable origin) fails closed. + /// + internal static bool IsRemoteAddressAllowed(IPAddress? remoteIp, IPNetwork allowedCidr) + => DarlingHostBinding.IsRemoteAddressAllowed(remoteIp, allowedCidr); + + /// + /// PURE bearer-token check (D3-b): true only when carries a + /// Bearer token that matches . The compare is constant-time over + /// SHA-256 digests, so it leaks neither the token nor its length; empty/missing/mismatch all return false, + /// and an empty never authorizes. Has NO notion of the remote address — + /// so there is structurally no loopback exemption (the loopback guard). + /// + internal static bool IsBearerTokenAuthorized(string? authorizationHeaderValue, string expectedToken) + { + if (string.IsNullOrEmpty(expectedToken)) + { + return false; + } + + var presented = ExtractBearerToken(authorizationHeaderValue); + if (string.IsNullOrEmpty(presented)) + { + return false; + } + + /* The constant-time, length-hiding compare lives once in the shared host-binding helper (used by the + web host's ?token= check too); this method keeps the MCP-specific Bearer-header parsing above it. */ + return DarlingHostBinding.FixedTimeTokenEquals(presented, expectedToken); + } + + /// Extracts the token from a Bearer <token> Authorization header (scheme + /// case-insensitive); null when absent, malformed, or the token part is blank. PURE. + internal static string? ExtractBearerToken(string? authorizationHeaderValue) + { + if (string.IsNullOrWhiteSpace(authorizationHeaderValue)) + { + return null; + } + + const string prefix = "Bearer "; + var value = authorizationHeaderValue.Trim(); + if (!value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var token = value.Substring(prefix.Length).Trim(); + return string.IsNullOrEmpty(token) ? null : token; + } + + /// + /// PURE severity map for a reason (Round-4 #7): the fail-closed degrades + /// (// + /// ) are , the BYO "ignored" + /// notice () is , and the + /// non-degrade reasons (/) + /// are silent (null). drives its emit level off this, so the level and the + /// message can never diverge. + /// + internal static LogLevel? MapBindReasonSeverity(McpBindReason reason) + => DarlingHostBinding.MapBindReasonSeverity((DarlingHostBinding.BindReason)(int)reason); + + /// Emits the reason at its mapped severity (Round-4 #7). Silent for + /// the non-degrade reasons (the network-exposed line is logged at start with the real bind). + private void LogBindReason(McpConfig mcp, McpBindReason reason) + { + var level = MapBindReasonSeverity(reason); + if (level is null) + { + /* NetworkExposed is announced at start with the real address; LoopbackByDefault is the silent, + byte-for-byte-today path. */ + return; + } + + switch (reason) + { + case McpBindReason.ListenInvalid: + _logger.Log(level.Value, + "MCP network exposure requested but mcp.network.listen '{Listen}' is not a valid IP address — " + + "refusing to expose; binding loopback-only. Use a specific IP (e.g. 192.168.1.205), or 0.0.0.0 for all interfaces.", + mcp.Network?.Listen); + break; + + case McpBindReason.TokenMissing: + _logger.Log(level.Value, + "MCP network exposure requested (mcp.network.listen is non-loopback) but no bearer token is set — " + + "refusing to expose; binding loopback-only. Set mcp.network.encryptedToken (via --encrypt-password) or mcp.network.token."); + break; + + case McpBindReason.AllowFromInvalid: + _logger.Log(level.Value, + "MCP network exposure requested but mcp.network.allowFrom '{AllowFrom}' is not a valid CIDR or its " + + "address family does not match mcp.network.listen — refusing to expose; binding loopback-only. " + + "Use e.g. 192.168.1.0/24 (host bits zeroed, same family as listen).", + mcp.Network?.AllowFrom); + break; + + case McpBindReason.ManagedModeRequired: + _logger.Log(level.Value, + "mcp.network.* is set but postgres.managed = false — MCP network exposure is managed-mode only and is " + + "ignored; your own PostgreSQL/reverse proxy governs BYO exposure. Binding loopback-only."); + break; + + default: + break; + } + } + + /* --------------------------------------------------------------------------------------------------- + Best-effort MCP firewall reconcile (D1, defense-in-depth). Reuses the store's tested, pure command + builders (DarlingManagedPostgres.BuildFirewall*Command) for the exact scoped rule shape AND its shared + PowerShell runner (RunPowerShellAsync) — no duplication, no timeout divergence. Never fatal. + --------------------------------------------------------------------------------------------------- */ + + /// The scoped MCP firewall rule name (idempotent by DisplayName), port-specific and distinct + /// from the store's rule so the two endpoints reconcile independently. internal so the headless + /// endpoint-toggle CLI verbs (--enable-mcp/--disable-mcp) reconcile the SAME rule by DisplayName. + internal static string McpFirewallRuleName(int port) => $"PerformanceMonitor Darling MCP (port {port})"; + + [SupportedOSPlatform("windows")] + private Task ReconcileMcpFirewallAsync(int port, bool enable, string? cidr, CancellationToken cancellationToken) + => DarlingHostBinding.ReconcileFirewallAsync(McpFirewallRuleName(port), port, enable, cidr, _logger, cancellationToken); + + /// + /// Managed mode's first-boot race, handled: the dedicated mcp-role credential appears only after + /// the worker's initdb + migration + role provisioning finish (LATER than the owner credential), so poll + /// up to ~5 minutes (60 × 5s) instead of racing it, then stand down with a pointer at the worker log — + /// fail-closed (MCP just does not start; it self-heals on the next restart once the credential exists). + /// The 5-minute budget tolerates a cold first boot (unpack + initdb + start + migrate + provision) that + /// can exceed the owner credential's shorter window (Round-4 #8). + /// + [SupportedOSPlatform("windows")] + private async Task WaitForManagedConnectionStringAsync(PostgresConfig config, CancellationToken stoppingToken) + { + for (var attempt = 0; attempt < 60; attempt++) + { + var connectionString = DarlingManagedPostgres.TryBuildMcpConnectionStringFromStoredCredential(config); + if (connectionString is not null) + { + return connectionString; + } + + if (attempt == 0) + { + _logger.LogInformation("Waiting for the managed Postgres mcp-role credential (first-run initialization) before starting the MCP server"); + } + + try + { + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); + } + catch (OperationCanceledException) + { + return null; + } + } + + _logger.LogError("MCP server not started: the managed Postgres mcp-role credential never appeared — see the worker log for the bootstrap failure"); + return null; + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + if (_app != null) + { + _logger.LogInformation("Stopping MCP server"); + await StopServerAsync(cancellationToken); + } + + await base.StopAsync(cancellationToken); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs new file mode 100644 index 000000000..6bc3a232a --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs @@ -0,0 +1,232 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// Server instructions sent to MCP clients during initialization — Lite's McpInstructions +/// framing (read-only posture, collection-freshness notes, tool reference) scoped to the ~87 +/// analysis + plan-analysis + data-read tools (plus the Custom Views + alert-tuning + server-onboarding +/// write surfaces) this headless service exposes (the body enumerates them). +/// +internal static class DarlingMcpInstructions +{ + public const string Text = """ + You are connected to a SQL Server performance monitoring tool via PerformanceMonitor Darling, the headless collector service. + + ## CRITICAL: Read-Only Access + + This MCP server provides STRICTLY READ-ONLY access to previously collected performance data. You CANNOT: + - Execute arbitrary SQL queries against any monitored server + - Kill sessions, processes, or connections + - Change any server configuration or settings + - Modify, insert, or delete any collected data + - Run any ad-hoc diagnostics beyond what the collectors have already captured + + The writes this server performs are all to the MONITORING store, never a monitored SQL Server: mute_analysis_finding records a mute rule, analyze_server persists its findings, the custom-view management tools (create_custom_view / update_custom_view / delete_custom_view) save user-authored dashboard/notebook definitions to config.custom_views, and the alert-tuning tools (update_alert_settings / create_mute_rule / delete_mute_rule) change the shared alert configuration the service delivers on (all the same store the web viewer / Settings window writes). None of these touches a monitored SQL Server or the collected performance data itself. + + ## How Data Is Collected + + The Darling service collects from monitored SQL Server instances 24/7 and stores the data in a Postgres/TimescaleDB store. Data is collected in snapshots at regular intervals (typically every 1-15 minutes depending on the collector). This means: + + - Data is only as fresh as the last collection cycle. + - Wait stats represent delta values since the last collection, not instantaneous snapshots. + - All tools accept a `server_name` parameter. If only one server is monitored, it's used automatically. Names resolve against the service's server registry (exact match first, then partial, against the storage name and the display name). + - Analysis needs at least 24 hours of collected history for a server; before that, analyze_server returns `insufficient_data`. + + ## Tool Reference + + This server exposes eighty-seven tools. Seventy-four are the same names Performance Monitor Lite and the Dashboard expose: six diagnostic-analysis tools, five plan-analysis tools, fifteen core data-read tools, twenty-one diagnostic-depth data-read tools, eight resource-contention + jobs data-read tools, five trend data-read tools, eight system-health parse-on-read tools, five alert + health-overview tools, and one Default Trace tool. The remaining thirteen are unique to Darling's central store: eight are the Custom Views tools (seven manage the saved views — the one view-authoring write surface — and `describe_custom_view_catalog` returns the read-only compose vocabulary those authoring tools draw from), three are alert-tuning write tools (`update_alert_settings` tunes the alert engine's thresholds; `create_mute_rule` / `delete_mute_rule` manage the mute rules) that write only the shared alert configuration in the monitoring store, and two are server-onboarding write tools (`add_servers` bulk-adds monitored servers; `remove_server` removes one) that add or remove rows in the monitoring store's monitored-server registry. Every data-read tool reads the data the collectors already captured into the store — a stored read, never a live query against the monitored server. + + ### Diagnostic-analysis tools + + | Tool | Purpose | Key Parameters | + |------|---------|----------------| + | `analyze_server` | Runs the inference engine: scores facts, traverses relationship graph, returns evidence-backed findings with severity and recommended next tools. A remediable finding also carries `remediation_command` — the full copy-paste T-SQL remediation (identical to the viewer card), with a two-sided risk-disclosure header on destructive changes; advisory only, never executed | `server_name`, `hours_back` (default 4) | + | `get_analysis_facts` | Exposes raw scored facts from the collect+score pipeline — every observation the engine sees with base severity, amplifiers, and metadata | `server_name`, `hours_back` (default 4), `source` (filter), `min_severity` | + | `compare_analysis` | Compares two time periods (e.g., peak vs off-peak, before vs after a change) showing severity deltas for each fact | `server_name`, `hours_back` (default 4), `baseline_hours_back` (default 28) | + | `audit_config` | Edition-aware configuration audit: evaluates CTFP, MAXDOP, max memory, and max worker threads against best practices | `server_name` | + | `get_analysis_findings` | Retrieves persisted findings from previous analysis runs (the service also analyzes on its own schedule, every 30 minutes per server); each remediable finding carries `remediation_command` — the full copy-paste T-SQL remediation (identical to the viewer card), rendered from the persisted action, advisory only and never executed | `server_name`, `hours_back` (default 24) | + | `mute_analysis_finding` | Mutes a finding pattern by story_path_hash so it won't appear in future runs | `story_path_hash` (required), `server_name`, `reason` | + + ### Plan-analysis tools + + These run the shared execution-plan analyzer over the plan XML the collectors already captured into the store (a STORED-plan read — no live query to the monitored server), returning warnings, missing indexes, parameters, memory grants, and top operators. + + | Tool | Purpose | Key Parameters | + |------|---------|----------------| + | `analyze_query_plan` | Analyzes a stored query-stats plan by query_hash | `query_hash` (required), `server_name`, `database_name` (optional refinement) | + | `analyze_procedure_plan` | Analyzes a stored procedure-stats plan by sql_handle | `sql_handle` (required), `server_name` | + | `analyze_query_store_plan` | Analyzes a stored Query Store plan by database + query_id | `database_name` (required), `query_id` (required), `server_name`, `plan_id` (optional refinement) | + | `analyze_plan_xml` | Analyzes raw showplan XML passed directly (no fetch) | `plan_xml` (required) | + | `get_plan_xml` | Returns the raw stored plan XML for a query by query_hash (truncated at 500KB) | `query_hash` (required), `server_name`, `database_name` (optional refinement) | + + ### Core data-read tools + + These read the collected metrics directly. Resource-metric tools accept `hours_back`; discovery/health tools take no window. + + | Tool | Purpose | Key Parameters | + |------|---------|----------------| + | `get_cpu_utilization` | CPU % over time (SQL / other-process / total / idle), 1-minute averages | `server_name`, `hours_back` (default 4) | + | `get_wait_stats` | Top wait types aggregated over the window (wait/signal/resource ms, signal %) | `server_name`, `hours_back` (default 24), `limit` (default 20) | + | `get_wait_trend` | A single wait type's per-second trend over time | `wait_type` (required), `server_name`, `hours_back` (default 24) | + | `get_wait_types` | The distinct wait types observed on the server (heaviest first) — pick a `wait_type` for get_wait_trend | `server_name`, `hours_back` (default 24) | + | `get_memory_stats` | Latest memory snapshot: physical / buffer pool / plan cache / utilization %, memory model | `server_name` | + | `get_memory_clerks` | Latest top memory consumers by clerk type | `server_name` | + | `get_file_io_stats` | Latest per-file I/O: reads/writes/bytes/stall and computed read/write latency | `server_name` | + | `get_tempdb_trend` | TempDB space over time (user / internal / version store / unallocated) + top consumer | `server_name`, `hours_back` (default 24) | + | `get_perfmon_stats` | Latest perfmon counters (value + delta); filter by counter / instance | `server_name`, `counter_name`, `instance_name` | + | `get_top_queries_by_cpu` | Expensive queries from query stats (plan cache) with query_hash / sql_handle | `server_name`, `hours_back` (default 24), `top` (default 20), `database_name`, `parallel_only`, `min_dop` | + | `get_top_procedures_by_cpu` | Most expensive stored procedures by total CPU | `server_name`, `hours_back` (default 24), `top` (default 20), `database_name` | + | `get_query_store_top` | Expensive queries from Query Store with query_id / plan_id (survives restarts) | `server_name`, `hours_back` (default 24), `top` (default 20), `database_name` | + | `list_servers` | All monitored servers with collection-freshness status and last collection time | none | + | `get_collection_health` | Per-collector health (running / failing / stale) over the last 7 days | `server_name` | + | `get_server_properties` | Instance properties: edition, version, CPU count, memory, socket/core topology, HADR | `server_name` | + + ### Diagnostic-depth data-read tools + + Deeper reads for a blocking / deadlock / session / configuration / storage investigation. Same names + parameters Lite and the Dashboard expose; where the two SKUs' result shapes diverge these follow Lite (the store-faithful shape). + + | Tool | Purpose | Key Parameters | + |------|---------|----------------| + | `get_blocking` | Recent blocked/blocking pairs from the blocked-process-report XE + the always-on DMV fallback | `server_name`, `hours_back` (default 24), `limit` (default 30) | + | `get_deadlocks` | Recent deadlocks: victim process/SQL + a process summary | `server_name`, `hours_back` (default 24), `limit` (default 20) | + | `get_deadlock_detail` | The raw deadlock graph XML for the recent deadlocks | `server_name`, `hours_back` (default 24), `limit` (default 5) | + | `get_blocked_process_xml` | The raw blocked-process-report XML | `server_name`, `hours_back` (default 24), `limit` (default 5) | + | `get_long_query_completions` | Longest completed queries (rpc/batch over the trace threshold) + attentions/cancels from the opt-in long-query trace, duration DESC (empty until the collector is enabled) | `server_name`, `hours_back` (default 24), `limit` (default 30) | + | `get_blocking_trend` | Per-minute blocking-incident counts over time (XE, DMV-snapshot fallback) | `server_name`, `hours_back` (default 24) | + | `get_deadlock_trend` | Per-minute deadlock counts over time | `server_name`, `hours_back` (default 24) | + | `get_session_stats` | Latest per-application connection/session counts (running/sleeping/dormant) + resource totals | `server_name` | + | `get_active_queries` | Captured running-query snapshots over the window (waits, CPU, blocking, grants) | `server_name`, `hours_back` (default 1), `database_name`, `blocking_only`, `limit` (default 50) | + | `get_waiting_tasks` | Individual waiting tasks captured at collection time | `server_name`, `hours_back` (default 1), `limit` (default 30) | + | `get_server_config_changes` | sp_configure changes, diffed from config snapshots | `server_name`, `hours_back` (default 168) | + | `get_database_config_changes` | sys.databases setting changes, diffed from config snapshots | `server_name`, `hours_back` (default 168) | + | `get_trace_flag_changes` | Trace flags enabled/disabled/modified, diffed from config snapshots | `server_name`, `hours_back` (default 168) | + | `get_database_scoped_config` | Latest database-scoped configuration (MAXDOP, legacy CE, ...) | `server_name`, `database_name` | + | `get_server_config` | CURRENT sys.configurations (latest snapshot) — what CTFP / MAXDOP / max memory are set to now | `server_name` | + | `get_database_config` | CURRENT per-database settings (latest snapshot) — recovery model, RCSI, Query Store, ... | `server_name`, `database_name` | + | `get_trace_flags` | CURRENT active trace flags (latest snapshot) — flag number, enabled, global/session | `server_name` | + | `get_table_index_sizes` | Largest tables with size + growth (7d/30d/daily) from the latest daily snapshot | `server_name` | + | `get_index_usage` | Per-index usage classified Unused / Write-only / Active | `server_name` | + | `get_object_locking` | Per-index lock/latch contention, most contended first | `server_name` | + | `get_database_sizes` | Per-file database sizes, space usage, and volume free space | `server_name` | + + ### Resource-contention + jobs data-read tools + + Deeper reads for an internal-contention / worker-thread / memory-grant / plan-cache / SQL Agent investigation. Same names + parameters Lite and the Dashboard expose. The Dashboard's per-class latch `severity` / `description` / `recommendation`, spinlock `description`, plan-cache `bloat_level`, and CPU-scheduler `pressure_level` / `recommendation` are the Dashboard / reporting-view CASE derivations (not collected columns) — reproduced here so the full result shape is served. Per-second latch/spinlock rates are derived from the collection interval (Darling's delta collectors store no `sample_interval_seconds`). + + | Tool | Purpose | Key Parameters | + |------|---------|----------------| + | `get_latch_stats` | Top latch classes by wait time, with per-second rates + severity / description / recommendation | `server_name`, `hours_back` (default 24), `top` (default 10) | + | `get_spinlock_stats` | Top spinlocks by collisions, with per-second rates + description | `server_name`, `hours_back` (default 24), `top` (default 10) | + | `get_resource_semaphore` | Latest workspace-memory semaphores: target / max-target ceiling vs granted / used, waiter / timeout / forced | `server_name`, `hours_back` (default 24) | + | `get_memory_grants` | Latest per-pool grant detail: available / granted / used + waiter / timeout / forced deltas | `server_name`, `hours_back` (default 1) | + | `get_memory_pressure_events` | RING_BUFFER_RESOURCE_MONITOR memory-pressure notifications (process/system indicator scale 0-3+); not on Azure SQL DB | `server_name`, `hours_back` (default 24) | + | `get_plan_cache_bloat` | Plan cache single-use vs multi-use composition + bloat_level classification | `server_name`, `hours_back` (default 24) | + | `get_cpu_scheduler_pressure` | Latest scheduler snapshot: runnable queue, worker utilization, pressure_level + warnings | `server_name` | + | `get_running_jobs` | Currently running SQL Agent jobs with duration vs historical average / p95 | `server_name` | + + ### Trend data-read tools + + Windowed time-series siblings of the core data-read tools — the per-collection / per-second trend for a metric over the window. Same names + parameters Lite and the Dashboard expose; the shape follows Lite where the SKUs diverge. + + | Tool | Purpose | Key Parameters | + |------|---------|----------------| + | `get_memory_trend` | Memory usage over time: total / target server memory, buffer pool, plan cache | `server_name`, `hours_back` (default 24) | + | `get_perfmon_trend` | A single performance counter's value + delta over time (summed across instances) | `counter_name` (required), `server_name`, `hours_back` (default 24) | + | `get_file_io_trend` | Per-database file I/O read/write latency over time (top-10 busiest files) | `server_name`, `hours_back` (default 24) | + | `get_query_trend` | One query's per-collection history (deltas, avg cpu/elapsed, DOP) by query_hash | `query_hash` (required), `database_name` (required), `server_name`, `hours_back` (default 24) | + | `get_query_duration_trend` | Overall query elapsed-ms/sec + executions/sec across all queries over time | `server_name`, `hours_back` (default 24) | + + ### System-health parse-on-read tools + + The `get_health_parser_*` family the Dashboard exposes, over Darling's raw `system_health_events`. Where the Dashboard reads its server-side-parsed `collect.HealthParser_*` tables, these shred the raw extended-event XML ON READ with the shared SystemHealthParser and return the same SIGNIFICANT warning set (sp_HealthParser at `@warnings_only = 1`) — the exception is `get_health_parser_system_health`, whose corruption/contention counter series is UNGATED (every snapshot). Each returns the full sp_HealthParser column set per row keyed on the event's `event_time`; the tools window on `event_time` (the event's real time), so "last 24 hours" means events that happened in the last 24 hours. + + | Tool | Purpose | Key Parameters | + |------|---------|----------------| + | `get_health_parser_system_health` | SYSTEM-component snapshots: corruption (bad pages / dumps / access violations) + contention (non-yielding / latch / sick spinlock / CPU) counters | `server_name`, `hours_back` (default 24), `limit` (default 50) | + | `get_health_parser_severe_errors` | error_reported severity >= 19 (excl. 17830 / 18056), with database_id resolved to a name | `server_name`, `hours_back` (default 24), `limit` (default 50) | + | `get_health_parser_scheduler_issues` | Non-yielding / offline scheduler WARNING rows | `server_name`, `hours_back` (default 24), `limit` (default 50) | + | `get_health_parser_memory_conditions` | RESOURCE_MEMPHYSICAL_LOW memory-pressure snapshots | `server_name`, `hours_back` (default 24), `limit` (default 50) | + | `get_health_parser_memory_broker` | RESOURCE_MEMPHYSICAL_LOW memory-broker ratio changes | `server_name`, `hours_back` (default 24), `limit` (default 50) | + | `get_health_parser_memory_node_oom` | Every recorded per-NUMA-node out-of-memory event (never gated) | `server_name`, `hours_back` (default 24), `limit` (default 50) | + | `get_health_parser_cpu_tasks` | QUERY_PROCESSING WARNING rows with pendingTasks >= 10 (worker-thread exhaustion) | `server_name`, `hours_back` (default 24), `limit` (default 50) | + | `get_health_parser_io_issues` | IO_SUBSYSTEM WARNING rows (15-second I/O warnings), one per pending-request file | `server_name`, `hours_back` (default 24), `limit` (default 50) | + + ### Alert + health-overview tools + + The fleet-triage reads the fleet edition previously lacked: what alerts fired and the current alert config, the mute rules in force, and a fast per-server / per-day health verdict. All STORED reads over the monitoring store (no live hit). + + | Tool | Purpose | Key Parameters | + |------|---------|----------------| + | `get_alert_history` | Alerts that fired (metric, value vs threshold, delivery success/failure, muted); omit server_name for the whole fleet, each row names its server | `server_name` (optional — all servers if omitted), `hours_back` (default 24), `limit` (default 50) | + | `get_alert_settings` | The current alert config the service uses: per-alert enable + thresholds, cooldown, excluded databases, delivery mode, and the scheduled-analysis cadence | none | + | `get_mute_rules` | The alert mute rules in force, so a suppressed server is distinguishable from a healthy-quiet one | `enabled_only` (default true) | + | `get_server_summary` | One-shot per-server health: current CPU %, memory, recent blocking count, recent deadlock count | `server_name` | + | `get_daily_summary` | A day's composite health band (Healthy / Warning / Critical) plus the signals behind it (waits, deadlocks, blocking, high CPU, memory pressure, alerts) | `server_name`, `summary_date` (yyyy-MM-dd, default today) | + + **Tuning the alerting (write).** Three Darling-only tools change the shared alert configuration the service delivers on — the SAME config `get_alert_settings` / `get_mute_rules` read, and the same the Viewer's Settings window writes. They are the only alert writes here; none touches a monitored server or the collected data, and a change hot-reloads into the running service within one collection sweep. + + | Tool | Purpose | Key Parameters | + |------|---------|----------------| + | `update_alert_settings` | Tunes the alert engine — a PARTIAL update of the single global settings row. **Read-modify-write**: call `get_alert_settings` FIRST, change only the fields you want, and pass THOSE back in the same nested shape (e.g. `{"cpu":{"threshold_percent":90},"cooldown_minutes":10}`); everything you omit is left unchanged. Each field is validated (thresholds in range, `cpu.mode` `sql`/`total`, `delivery.mode` `Summary`/`PerEvent`); a bad value or an unknown field returns `{status:"invalid"}` and writes nothing. SMTP/webhook credentials cannot be set here | `settings_json` (required — the partial settings object) | + | `create_mute_rule` | Adds a mute rule that suppresses matching alerts (still logged, just not delivered). Any combination of scope/pattern fields; NO fields set = mute every alert (a whole-fleet silence). Returns the stored rule with its generated id | `server_name`, `metric_name`, `database_pattern`, `query_text_pattern`, `wait_type_pattern`, `job_name_pattern`, `reason`, `expires_at` (all optional) | + | `delete_mute_rule` | Deletes a mute rule by id (from `get_mute_rules` / `create_mute_rule`); returns `{status:"deleted"}` or `{status:"not_found"}` | `rule_id` (required) | + + Because `update_alert_settings` only touches the fields you send, always `get_alert_settings` FIRST to read the current values, change what you need, then send just those back — never guess the full row. + + ### Default Trace events + + `get_default_trace_events` returns the SIGNIFICANT server events the built-in Default Trace captures (stored, read-only, windowed on `event_time`) — data/log file auto-grow/shrink STALLS (over 1 second), severe ErrorLog writes (severity >= 16), schema DDL (object create/alter/delete), security audits (audit-change / DBCC / alter-trace), and Server Memory Change — each tagged with a `category`. It is the same significant set the viewer's System Events surface shows (the collector curates the event set server-side; the tool adds only the ErrorLog severity floor). Configuration-change events are intentionally excluded to avoid double-counting the config-snapshot diff — use `get_server_config_changes` / `get_database_config_changes` / `get_trace_flag_changes` for those. Not available on Azure SQL Database (no default trace there). + + | Tool | Purpose | Key Parameters | + |------|---------|----------------| + | `get_default_trace_events` | Significant Default Trace events — auto-grow/shrink stalls, severe ErrorLog, schema DDL, security audits, memory change — each categorized (config-change events excluded) | `server_name`, `hours_back` (default 24), `limit` (default 100) | + + ### Custom Views (create & manage) + + Custom Views are saved dashboards and notebooks a user composes from a curated catalog of measures and dimensions (the same views the web viewer's editor builds). The create/update/delete tools here are the only view-authoring WRITE surface on this server — they store view definitions in the monitoring store's `config.custom_views` table; none of these tools touches a monitored SQL Server or the collected performance data. A view definition is either a dashboard (`{"panels":[...]}`) or a notebook (`{"kind":"notebook","cells":[...]}`); a composed panel names a catalog `source` + `measure` (or `ratio`), an `aggregate`, an optional `timeBucket` (time series) or `topN` (ranked), `filters`, `groupBy`, a `unit`, and a `viz`. **Call `describe_custom_view_catalog` FIRST** to get the exact legal `source`/`measure`/`ratio`/dimension/`aggregate`/`unit`/`viz` names: the compiler accepts ONLY catalog identifiers, and the validation errors do not enumerate the legal names, so composing from the catalog is far faster and more reliable than guessing. Then `validate_custom_view` (and iterate until valid) before `create_custom_view` / `update_custom_view` — create/update run the same validation and reject an invalid definition. Use `run_custom_view_panel` to see the actual data a composed panel produces so a generated view can be checked end-to-end. + + | Tool | Purpose | Key Parameters | + |------|---------|----------------| + | `describe_custom_view_catalog` | The compose vocabulary — measures (each with its source, kind, valid aggregates, allowed dimensions, units, and per-server-type availability), dimensions, unit families, aggregates, time buckets, filter ops, and viz types. Call FIRST when authoring a view so panels use only legal identifiers | none | + | `list_custom_views` | Lists every saved view (dashboards + notebooks) as summaries — id, name, description, kind, version | none | + | `get_custom_view` | Gets one view in full, including its definition JSON and current version | `view_id` (required) | + | `validate_custom_view` | Dry-run: validates a definition against the catalog + composer rules WITHOUT saving; returns `{valid, error}` | `definition` (required) | + | `create_custom_view` | Validates then saves a new view (returns the stored view at version 1); conflict on a duplicate name | `name` (required), `definition` (required), `description` | + | `update_custom_view` | Validates then updates a view in place under optimistic concurrency (pass the `version` you read); conflict on a stale version or duplicate name | `view_id` (required), `name` (required), `definition` (required), `version` (required), `description` | + | `delete_custom_view` | Deletes a view by id (permanent) | `view_id` (required) | + | `run_custom_view_panel` | Compiles + runs a single composed panel and returns `{sql, rows, annotations}` — the composer's live preview, for checking a panel's data before saving | `spec` (required — a JSON object `{panel, variables?, values?, server?, hours?}`) | + + ### Server onboarding (add & remove) + + Two write tools stand up or tear down FLEET monitoring conversationally — the service-side twin of the WPF viewer's Add / Manage Servers dialogs. They write ONLY the monitoring store's monitored-server registry (`config.config_monitored_servers`); neither runs anything on a monitored SQL Server beyond a one-time connection probe, and neither touches the collected performance data. A change is picked up by the running service within one collection sweep (no restart). + + | Tool | Purpose | Key Parameters | + |------|---------|----------------| + | `add_servers` | BULK-adds monitored servers: pass a JSON ARRAY of server objects and each is validated, connection-tested IN the service, and (if new and reachable) saved. Per object: `host` (required), `display_name`, `database` (one DB only, e.g. an Azure SQL Database), `auth` (`Windows`/`SQL`, default `Windows`), `username`+`password` (required for `SQL`), `encrypt_mode` (`Optional`/`Mandatory`/`Strict`, default `Mandatory`), `trust_server_certificate` (default false), `read_only_intent`, `multi_subnet_failover`. Servers are processed in order; a duplicate (case-folded, vs existing or an earlier entry) is `duplicate`, an unreachable server is `connection_failed` (the batch continues), Entra/MFA/Service-Principal/Managed-Identity auth is `invalid` (Windows/SQL only). Returns `{added, skipped, failed, results:[{server, status, detail}]}` | `servers_json` (required — a JSON array of server objects) | + | `remove_server` | Removes a monitored server by name (resolved like every `server_name`). Returns `{status:"removed", server}` or `{status:"not_found"}`. Already-collected history is NOT deleted | `server_name` (required) | + + A SQL password is encrypted at rest (DPAPI, the service identity) and is NEVER returned by a read tool. It DOES travel to this endpoint inside `add_servers`' request JSON, so on a LAN deployment reach the endpoint only through the documented TLS reverse proxy. + + The three config-change tools diff the store's config snapshots. This edition captures configuration WHEN THE SERVICE CONNECTS to a server (not on a fixed schedule), so a change is detected between two connect snapshots and at least two are needed — a stable, always-connected deployment may show no changes until the next connect. They emit only the values the collectors capture; the Dashboard's `requires_restart` / setting `description` / `setting_type` / generated change-narrative enrichment is not collected here and is omitted. `get_blocking_deadlock_stats` (the Dashboard's blocking/deadlock aggregate) is NOT hosted: this edition has no blocking/deadlock rollup table — use `get_blocking` / `get_deadlocks` for the raw events. + + Note on `next_tools`: analyze_server findings include `next_tools` recommendations. Most are hosted on this server — the plan-analysis tools (`analyze_query_plan`, `analyze_query_store_plan`) and the data-read tools listed above (`get_wait_stats`, `get_top_queries_by_cpu`, `get_cpu_utilization`, `get_memory_stats`, `get_file_io_stats`, `get_tempdb_trend`, `get_blocking`, `get_deadlocks`, `get_waiting_tasks`, `get_active_queries`, ...) — so follow those here. `get_top_queries_by_cpu` / `get_top_procedures_by_cpu` / `get_query_store_top` are where the `query_hash` / `sql_handle` / `query_id` + `plan_id` keys for the plan-analysis tools come from. The resource-contention + jobs tools (`get_latch_stats`, `get_spinlock_stats`, `get_resource_semaphore`, `get_memory_grants`, `get_plan_cache_bloat`, `get_cpu_scheduler_pressure`, `get_running_jobs`), the trend siblings (`get_memory_trend`, `get_perfmon_trend`, `get_file_io_trend`, `get_query_trend`, `get_query_duration_trend`), the `get_health_parser_*` system-health family, and the blocking/deadlock trend + memory-pressure reads (`get_blocking_trend`, `get_deadlock_trend`, `get_memory_pressure_events`) are all hosted here too — follow those `next_tools` on this server. Two `next_tools` names differ from what this edition hosts: `get_blocked_process_reports` (a Lite name) is served here as `get_blocked_process_xml` (with `get_blocking` for a quick overview), and `get_blocking_deadlock_stats` (the Dashboard's blocking/deadlock rollup) is not hosted at all — use `get_blocking` / `get_deadlocks` instead. + + ## Recommended Workflow + + 1. **Discover**: `list_servers` — see the monitored servers and their collection freshness; `get_collection_health` confirms the collectors are current before you trust the data + 2. **Diagnose**: `analyze_server` — run the inference engine for an evidence-backed assessment with severity-ranked findings, each carrying `next_tools` + 3. **Review history**: `get_analysis_findings` — see what the service's scheduled analysis has already found + 4. **Investigate the metrics**: follow a finding's `next_tools` into the data tools — `get_cpu_utilization`, `get_wait_stats` / `get_wait_trend`, `get_memory_stats` / `get_memory_clerks`, `get_file_io_stats`, `get_tempdb_trend`, `get_perfmon_stats` + 5. **Find the query**: `get_top_queries_by_cpu` / `get_top_procedures_by_cpu` / `get_query_store_top` — identify the expensive query/procedure and get its `query_hash` / `sql_handle` / `query_id` + `plan_id` + 6. **Analyze its plan**: `analyze_query_plan` / `analyze_procedure_plan` / `analyze_query_store_plan` — analyze the captured plan for warnings, missing indexes, and grant/spill problems (or `analyze_plan_xml` for plan XML you already have) + 7. **Deep dive / compare / config**: `get_analysis_facts` (what the engine sees), `compare_analysis` (new vs baseline), `audit_config` (edition-aware config) + 8. **Silence noise**: `mute_analysis_finding` — mute a finding pattern the operator has accepted + """; +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpJobTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpJobTools.cs new file mode 100644 index 000000000..92827688d --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpJobTools.cs @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.ComponentModel; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using ModelContextProtocol.Server; +using Npgsql; +using PerformanceMonitor.Common; + +#pragma warning disable CA1707 // MCP tools use snake_case naming convention + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// The SQL Agent running-jobs MCP tool — get_running_jobs — served over Darling's Postgres store. The tool +/// body mirrors LITE's McpJobTools field-for-field (Lite and the Dashboard share this shape; Lite adds +/// collection_time to the envelope, which the store-faithful Darling read carries). Reads flow through +/// — a STORED read of the latest snapshot, no live monitored-server hit. +/// +[McpServerToolType] +public sealed class DarlingMcpJobTools +{ + [McpServerTool(Name = "get_running_jobs"), Description("Gets currently running SQL Agent jobs with duration comparison. Shows each job's current duration vs its historical average and p95, flagging jobs that are running longer than usual.")] + public static async Task GetRunningJobs( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + try + { + var rows = await DarlingJobReader.GetRunningJobsAsync(postgres, resolved.Value.ServerId); + if (rows.Count == 0) + return McpHelpers.Status("empty", "No running SQL Agent jobs found (or the running_jobs collector has not run yet)."); + + var jobs = rows.Select(r => new + { + job_name = r.JobName, + job_id = r.JobId, + job_enabled = r.JobEnabled, + start_time = r.StartTime.ToString("o"), + current_duration_seconds = r.CurrentDurationSeconds, + current_duration_formatted = DarlingJobReader.FormatDuration(r.CurrentDurationSeconds), + avg_duration_seconds = r.AvgDurationSeconds, + avg_duration_formatted = DarlingJobReader.FormatDuration(r.AvgDurationSeconds), + p95_duration_seconds = r.P95DurationSeconds, + p95_duration_formatted = DarlingJobReader.FormatDuration(r.P95DurationSeconds), + successful_run_count = r.SuccessfulRunCount, + is_running_long = r.IsRunningLong, + percent_of_average = r.PercentOfAverage + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + collection_time = rows[0].CollectionTime.ToString("o"), + running_job_count = rows.Count, + long_running_count = rows.Count(r => r.IsRunningLong), + jobs + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_running_jobs", ex); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLatchSpinlockTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLatchSpinlockTools.cs new file mode 100644 index 000000000..7ac8bd688 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLatchSpinlockTools.cs @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.ComponentModel; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using ModelContextProtocol.Server; +using Npgsql; +using PerformanceMonitor.Common; + +#pragma warning disable CA1707 // MCP tools use snake_case naming convention + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// The latch / spinlock contention MCP tools — get_latch_stats, get_spinlock_stats — served over Darling's +/// Postgres store. Each tool body mirrors the Dashboard's McpLatchSpinlockTools field-for-field (Lite +/// exposes neither tool, so the Dashboard is the only reference; there is no divergent Lite shape to follow). +/// Reads flow through — STORED reads (no live monitored-server hit), +/// windowed on hours_back. +/// +/// +/// The Dashboard's per-class severity / description / recommendation (latch) and +/// description (spinlock) are the Dashboard view's own CASE derivations, not collected columns; they are +/// reproduced verbatim in so these tools serve the full Dashboard +/// result shape. The per-second rate is derived in SQL from the per-class LAG interval, because Darling's +/// delta collectors store no sample_interval_seconds. +/// +/// +[McpServerToolType] +public sealed class DarlingMcpLatchSpinlockTools +{ + [McpServerTool(Name = "get_latch_stats"), Description("Gets top latch contention by class. Shows latch waits, wait time, and per-second rates. High LATCH_EX on ACCESS_METHODS_DATASET_PARENT or FGCB_ADD_REMOVE indicates TempDB allocation contention.")] + public static async Task GetLatchStats( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of data to analyze. Default 24.")] int hours_back = 24, + [Description("Number of top latch classes to return. Default 10.")] int top = 10) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + validation = McpHelpers.ValidateTop(top); + if (validation != null) return validation; + + try + { + var now = DateTime.UtcNow; + var rows = await DarlingLatchSpinlockReader.GetLatchStatsTopNAsync( + postgres, resolved.Value.ServerId, now.AddHours(-hours_back), now, top); + if (rows.Count == 0) + return McpHelpers.Status("unavailable", "No latch statistics available in the requested time range."); + + var latches = rows.Select(r => new + { + latch_class = r.LatchClass, + total_delta_wait_time_ms = r.TotalDeltaWaitTimeMs, + total_delta_waiting_requests = r.TotalDeltaWaitingRequests, + avg_wait_ms_per_request = r.TotalDeltaWaitingRequests > 0 + ? Math.Round((double)r.TotalDeltaWaitTimeMs / r.TotalDeltaWaitingRequests, 2) + : (double?)null, + waits_per_second = Math.Round(r.WaitsPerSecond, 2), + wait_ms_per_second = Math.Round(r.WaitMsPerSecond, 2), + severity = DarlingLatchSpinlockReader.LatchSeverity(r.LatestDeltaWaitTimeMs), + description = DarlingLatchSpinlockReader.LatchDescription(r.LatchClass), + recommendation = DarlingLatchSpinlockReader.LatchRecommendation(r.LatchClass), + latest_collection_time = r.LatestCollectionTime.ToString("o") + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + latch_count = rows.Count, + latches + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_latch_stats", ex); + } + } + + [McpServerTool(Name = "get_spinlock_stats"), Description("Gets top spinlock contention. Shows collisions, spins, backoffs, and per-second rates. High spinlock contention indicates CPU-bound internal contention that doesn't appear in wait stats.")] + public static async Task GetSpinlockStats( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of data to analyze. Default 24.")] int hours_back = 24, + [Description("Number of top spinlocks to return. Default 10.")] int top = 10) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + validation = McpHelpers.ValidateTop(top); + if (validation != null) return validation; + + try + { + var now = DateTime.UtcNow; + var rows = await DarlingLatchSpinlockReader.GetSpinlockStatsTopNAsync( + postgres, resolved.Value.ServerId, now.AddHours(-hours_back), now, top); + if (rows.Count == 0) + return McpHelpers.Status("unavailable", "No spinlock statistics available in the requested time range."); + + var spinlocks = rows.Select(r => new + { + spinlock_name = r.SpinlockName, + total_delta_collisions = r.TotalDeltaCollisions, + total_delta_spins = r.TotalDeltaSpins, + total_delta_backoffs = r.TotalDeltaBackoffs, + spins_per_collision = r.TotalDeltaCollisions > 0 + ? Math.Round((double)r.TotalDeltaSpins / r.TotalDeltaCollisions, 1) + : (double?)null, + collisions_per_second = Math.Round(r.CollisionsPerSecond, 2), + spins_per_second = Math.Round(r.SpinsPerSecond, 2), + description = DarlingLatchSpinlockReader.SpinlockDescription(r.SpinlockName), + latest_collection_time = r.LatestCollectionTime.ToString("o") + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + spinlock_count = rows.Count, + spinlocks + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_spinlock_stats", ex); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLongQueryTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLongQueryTools.cs new file mode 100644 index 000000000..de65c6143 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLongQueryTools.cs @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.ComponentModel; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using ModelContextProtocol.Server; +using Npgsql; +using PerformanceMonitor.Common; + +#pragma warning disable CA1707 // MCP tools use snake_case naming convention + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// The long-query completion MCP tool — get_long_query_completions — served over Darling's Postgres store +/// (#1496). Returns the longest completed queries (rpc/batch over the trace's duration threshold) plus +/// attentions (cancels/timeouts) captured by the opt-in PerformanceMonitor_LongQueryCompletions XE session, +/// ordered by duration DESC. Mirrors Lite's McpLongQueryTools field-for-field; reads flow through +/// — a STORED read (no live monitored-server hit). +/// +[McpServerToolType] +public sealed class DarlingMcpLongQueryTools +{ + [McpServerTool(Name = "get_long_query_completions"), Description("Gets long-running query completions captured by the opt-in long-query trace: rpc/batch completions whose duration exceeded the trace threshold, plus attentions (client cancels / query timeouts). Shows duration, CPU, reads/writes, row count, result (OK/Error/Abort — Abort means the long query was cancelled), the statement text, and the calling session/app/login. The collector is OFF by default; if it returns empty, enable the 'long_query_completions' collector in the schedule.")] + public static async Task GetLongQueryCompletions( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description("Maximum rows. Default 30.")] int limit = 30) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + validation = McpHelpers.ValidateTop(limit); + if (validation != null) return validation; + + try + { + var now = DateTime.UtcNow; + var rows = await DarlingLongQueryReader.GetRecentLongQueryCompletionsAsync( + postgres, resolved.Value.ServerId, now.AddHours(-hours_back), now); + if (rows.Count == 0) + return McpHelpers.Status("empty", "No long-running query completions found in the specified time range. The long_query_completions collector is opt-in (default OFF) — enable it in the collector schedule to capture data."); + + var result = rows.Take(limit).Select(r => new + { + event_time = r.EventTime?.ToString("o"), + event_type = r.EventType, + duration_ms = r.DurationMicroseconds.HasValue ? r.DurationMicroseconds.Value / 1000.0 : (double?)null, + cpu_ms = r.CpuTimeMicroseconds.HasValue ? r.CpuTimeMicroseconds.Value / 1000.0 : (double?)null, + logical_reads = r.LogicalReads, + physical_reads = r.PhysicalReads, + writes = r.Writes, + row_count = r.RowCount, + result = r.Result, + database_name = r.DatabaseName, + object_name = r.ObjectName, + statement = McpHelpers.Truncate(r.StatementText, 2000), + session_id = r.SessionId, + client_app_name = r.ClientAppName, + client_pid = r.ClientPid, + nt_username = r.NtUserName, + server_principal_name = r.ServerPrincipalName, + query_hash = r.QueryHash + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + total_completions = rows.Count, + completions = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_long_query_completions", ex); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpMemoryGrantTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpMemoryGrantTools.cs new file mode 100644 index 000000000..1842ab5dd --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpMemoryGrantTools.cs @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.ComponentModel; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using ModelContextProtocol.Server; +using Npgsql; +using PerformanceMonitor.Common; + +#pragma warning disable CA1707 // MCP tools use snake_case naming convention + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// The memory-grant MCP tools — get_resource_semaphore, get_memory_grants — served over Darling's Postgres +/// store, both reading the LATEST memory_grant_stats snapshot through +/// (STORED reads, no live monitored-server hit). The two names are two +/// lenses on the one collector table, so Darling hosts BOTH: get_resource_semaphore is the Dashboard's +/// semaphore/ceiling shape (per resource semaphore, with the workspace-memory target/max-target ceiling); +/// get_memory_grants is Lite's per-pool grant-detail shape. A client familiar with either SKU finds its tool. +/// +[McpServerToolType] +public sealed class DarlingMcpMemoryGrantTools +{ + [McpServerTool(Name = "get_resource_semaphore"), Description("Gets resource semaphore statistics showing granted vs available workspace memory against the target/max-target ceiling, waiter counts, and timeout/forced grant pressure indicators. High waiter counts or rising timeout/forced deltas indicate memory grant pressure affecting query performance.")] + public static async Task GetResourceSemaphore( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history. Default 24.")] int hours_back = 24) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + + try + { + var now = DateTime.UtcNow; + var rows = await DarlingMemoryGrantReader.GetResourceSemaphoreLatestAsync( + postgres, resolved.Value.ServerId, now.AddHours(-hours_back), now); + if (rows.Count == 0) + return McpHelpers.Status("unavailable", "No memory grant data available."); + + var grants = rows.Select(r => new + { + collection_time = r.CollectionTime.ToString("o"), + resource_semaphore_id = r.ResourceSemaphoreId, + pool_id = r.PoolId, + target_memory_mb = r.TargetMemoryMb, + max_target_memory_mb = r.MaxTargetMemoryMb, + total_memory_mb = r.TotalMemoryMb, + available_memory_mb = r.AvailableMemoryMb, + granted_memory_mb = r.GrantedMemoryMb, + used_memory_mb = r.UsedMemoryMb, + grantee_count = r.GranteeCount, + waiter_count = r.WaiterCount, + timeout_error_count = r.TimeoutErrorCount, + forced_grant_count = r.ForcedGrantCount, + timeout_error_count_delta = r.TimeoutErrorCountDelta, + forced_grant_count_delta = r.ForcedGrantCountDelta + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + grants + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_resource_semaphore", ex); + } + } + + [McpServerTool(Name = "get_memory_grants"), Description("Gets resource semaphore statistics showing granted vs available workspace memory per resource pool, waiter counts, and timeout/forced grant deltas. High waiter counts or rising timeout deltas indicate memory grant pressure affecting query performance.")] + public static async Task GetMemoryGrants( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history. Default 1.")] int hours_back = 1) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + + try + { + var now = DateTime.UtcNow; + var rows = await DarlingMemoryGrantReader.GetMemoryGrantsLatestAsync( + postgres, resolved.Value.ServerId, now.AddHours(-hours_back), now); + if (rows.Count == 0) + return McpHelpers.Status("unavailable", "No memory grant data available."); + + var grants = rows.Select(r => new + { + collection_time = r.CollectionTime.ToString("o"), + pool_id = r.PoolId, + available_memory_mb = Math.Round(r.AvailableMemoryMb, 2), + granted_memory_mb = Math.Round(r.GrantedMemoryMb, 2), + used_memory_mb = Math.Round(r.UsedMemoryMb, 2), + grantee_count = r.GranteeCount, + waiter_count = r.WaiterCount, + timeout_error_count_delta = r.TimeoutErrorCountDelta, + forced_grant_count_delta = r.ForcedGrantCountDelta + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + grants + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_memory_grants", ex); + } + } + + [McpServerTool(Name = "get_memory_pressure_events"), Description(@"Gets memory pressure notifications from the RING_BUFFER_RESOURCE_MONITOR ring buffer (same source as sp_pressuredetector). Returns RESOURCE_MEMPHYSICAL_LOW, RESOURCE_MEMVIRTUAL_LOW, RESOURCE_MEMPHYSICAL_HIGH, and RESOURCE_MEM_STEADY notifications with indicator values. + +Indicator scale (applies to both memory_indicators_process and memory_indicators_system): + 0-1 = normal, no pressure + 2 = medium pressure (SQL Server's Resource Monitor starts trimming caches and reducing grants) + 3+ = severe pressure (aggressive buffer pool / plan cache eviction) + +memory_indicators_process = SQL Server process itself is under memory pressure (workload-induced). +memory_indicators_system = Windows is signaling low memory system-wide (could be other tenants on the box). + +Not available on Azure SQL DB (ring buffer not exposed).")] + public static async Task GetMemoryPressureEvents( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history. Default 24.")] int hours_back = 24) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + + try + { + var now = DateTime.UtcNow; + var rows = await DarlingMemoryGrantReader.GetMemoryPressureEventsAsync( + postgres, resolved.Value.ServerId, now.AddHours(-hours_back), now); + if (rows.Count == 0) + return McpHelpers.Status("empty", "No memory pressure events found in the requested time range."); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + events = rows.Select(r => new + { + sample_time = r.SampleTime.ToString("o"), + memory_notification = r.MemoryNotification, + memory_indicators_process = r.MemoryIndicatorsProcess, + memory_indicators_system = r.MemoryIndicatorsSystem + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_memory_pressure_events", ex); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpObjectStatsTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpObjectStatsTools.cs new file mode 100644 index 000000000..bebcdd037 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpObjectStatsTools.cs @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.ComponentModel; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using ModelContextProtocol.Server; +using Npgsql; +using PerformanceMonitor.Common; + +#pragma warning disable CA1707 // MCP tools use snake_case naming convention + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// The index / object diagnostic-depth MCP tools — get_table_index_sizes, get_index_usage, +/// get_object_locking, get_database_sizes — served over Darling's Postgres store. Each tool body mirrors +/// LITE's McpObjectStatsTools / McpServerInfoTools field-for-field (byte-identical to the +/// Dashboard for the three object-stats tools), keeping the tool names + parameter contracts. Reads flow +/// through — STORED reads of the latest daily snapshot, no live +/// monitored-server hit. +/// +[McpServerToolType] +public sealed class DarlingMcpObjectStatsTools +{ + /// Lite's default result caps (the tools take no top parameter). + private const int TableSizesTop = 100; + private const int IndexUsageTop = 200; + private const int ObjectLockingTop = 200; + + [McpServerTool(Name = "get_table_index_sizes"), Description("Gets the largest tables with per-table size, growth (7d/30d/daily rate), and row counts from the latest daily snapshot. Indexes are rolled up per table. Use to find storage hot-spots and fast-growing tables for capacity planning.")] + public static async Task GetTableIndexSizes( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + try + { + var now = DateTime.UtcNow; + var rows = await DarlingObjectStatsReader.GetObjectSizeGrowthAsync( + postgres, resolved.Value.ServerId, now.AddDays(-7), now.AddDays(-30), TableSizesTop); + if (rows.Count == 0) + return McpHelpers.Status("unavailable", "No object size data available. Index/object stats are collected daily."); + + var result = rows.Select(r => new + { + database_name = r.DatabaseName, + schema_name = r.SchemaName, + table_name = r.TableName, + reserved_mb = r.CurrentReservedMb, + used_mb = r.CurrentUsedMb, + total_rows = r.TotalRows, + index_count = r.IndexCount, + growth_7d_mb = r.Growth7dMb, + growth_30d_mb = r.Growth30dMb, + daily_growth_rate_mb = r.DailyGrowthRateMb, + growth_pct_30d = r.GrowthPct30d + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + tables = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_table_index_sizes", ex); + } + } + + [McpServerTool(Name = "get_index_usage"), Description("Gets per-index usage (seeks, scans, lookups, updates) from the latest daily snapshot, classifying each index as Unused, Write-only, or Active. Unused and write-only indexes are listed first - these are drop candidates. Counters are cumulative since the last instance restart.")] + public static async Task GetIndexUsage( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + try + { + var rows = await DarlingObjectStatsReader.GetIndexUsageAsync(postgres, resolved.Value.ServerId, IndexUsageTop); + if (rows.Count == 0) + return McpHelpers.Status("unavailable", "No index usage data available. Index/object stats are collected daily."); + + var result = rows.Select(r => new + { + database_name = r.DatabaseName, + schema_name = r.SchemaName, + table_name = r.TableName, + index_name = r.IndexName, + index_type = r.IndexTypeDesc, + classification = r.Classification, + reserved_mb = r.ReservedMb, + total_rows = r.TotalRows, + user_seeks = r.UserSeeks, + user_scans = r.UserScans, + user_lookups = r.UserLookups, + total_reads = r.TotalReads, + user_updates = r.UserUpdates, + last_user_access = r.LastUserAccess?.ToString("o") + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + indexes = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_index_usage", ex); + } + } + + [McpServerTool(Name = "get_object_locking"), Description("Gets per-index locking and latch contention (row/page lock waits in ms, lock escalations, page-latch and page-IO-latch waits) from the latest daily snapshot, top contended objects first. Use to find tables/indexes driving blocking and contention. Counters are cumulative since the last instance restart.")] + public static async Task GetObjectLocking( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + try + { + var rows = await DarlingObjectStatsReader.GetIndexLockingAsync(postgres, resolved.Value.ServerId, ObjectLockingTop); + if (rows.Count == 0) + return McpHelpers.Status("unavailable", "No locking/contention data recorded. Index/object stats are collected daily."); + + var result = rows.Select(r => new + { + database_name = r.DatabaseName, + schema_name = r.SchemaName, + table_name = r.TableName, + index_name = r.IndexName, + index_type = r.IndexTypeDesc, + reserved_mb = r.ReservedMb, + total_rows = r.TotalRows, + row_lock_wait_count = r.RowLockWaitCount, + row_lock_wait_ms = r.RowLockWaitInMs, + page_lock_wait_count = r.PageLockWaitCount, + page_lock_wait_ms = r.PageLockWaitInMs, + lock_escalations = r.IndexLockPromotionCount, + page_latch_wait_ms = r.PageLatchWaitInMs, + page_io_latch_wait_ms = r.PageIoLatchWaitInMs + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + objects = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_object_locking", ex); + } + } + + [McpServerTool(Name = "get_database_sizes"), Description("Gets database file sizes, space usage, and volume free space. Shows each database file with total size, used space, auto-growth settings, and the underlying volume's capacity. Use for capacity planning and identifying space pressure.")] + public static async Task GetDatabaseSizes( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + try + { + var rows = await DarlingObjectStatsReader.GetLatestDatabaseSizesAsync(postgres, resolved.Value.ServerId); + if (rows.Count == 0) + return McpHelpers.Status("unavailable", "No database size data available. The size collector may not have run yet."); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + collection_time = rows[0].CollectionTime.ToString("o"), + file_count = rows.Count, + databases = rows + .GroupBy(r => r.DatabaseName) + .Select(g => new + { + database_name = g.Key, + total_size_mb = g.Sum(r => r.TotalSizeMb), + used_size_mb = g.Sum(r => r.UsedSizeMb ?? 0), + files = g.Select(r => new + { + file_name = r.FileName, + file_type = r.FileTypeDesc, + total_size_mb = r.TotalSizeMb, + used_size_mb = r.UsedSizeMb, + auto_growth_mb = r.AutoGrowthMb, + max_size_mb = r.MaxSizeMb, + volume_mount_point = r.VolumeMountPoint, + volume_total_mb = r.VolumeTotalMb, + volume_free_mb = r.VolumeFreeMb + }) + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_database_sizes", ex); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCacheSchedulerTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCacheSchedulerTools.cs new file mode 100644 index 000000000..8ed3eddc7 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCacheSchedulerTools.cs @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.ComponentModel; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using ModelContextProtocol.Server; +using Npgsql; +using PerformanceMonitor.Common; + +#pragma warning disable CA1707 // MCP tools use snake_case naming convention + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// The plan-cache + CPU-scheduler snapshot MCP tools — get_plan_cache_bloat, get_cpu_scheduler_pressure — +/// served over Darling's Postgres store. Each tool body mirrors the Dashboard's McpDiagnosticTools / +/// McpSchedulerTools field-for-field (Lite exposes neither, so the Dashboard is the only reference). +/// Reads flow through — STORED reads of the latest snapshot, no +/// live monitored-server hit. The Dashboard's bloat_level (#1410) and pressure_level / +/// recommendation (#1410) classifications are reproduced from the reporting-view CASE logic. +/// +[McpServerToolType] +public sealed class DarlingMcpPlanCacheSchedulerTools +{ + [McpServerTool(Name = "get_plan_cache_bloat"), Description("Gets plan cache composition showing single-use vs multi-use plans, with a bloat-level classification. High single-use plan counts indicate ad-hoc query bloat consuming buffer pool memory. Consider enabling 'optimize for ad hoc workloads'.")] + public static async Task GetPlanCacheBloat( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of data to analyze. Default 24.")] int hours_back = 24) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + + try + { + var now = DateTime.UtcNow; + var rows = await DarlingPlanCacheSchedulerReader.GetPlanCacheBloatAsync( + postgres, resolved.Value.ServerId, now.AddHours(-hours_back), now); + if (rows.Count == 0) + return McpHelpers.Status("unavailable", "No plan cache statistics available in the requested time range."); + + var totalPlans = rows.Sum(r => (long)r.TotalPlans); + var totalSingleUse = rows.Sum(r => (long)r.SingleUsePlans); + var totalSizeMb = rows.Sum(r => (long)r.TotalSizeMb); + var singleUseSizeMb = rows.Sum(r => (long)r.SingleUseSizeMb); + var (bloatLevel, bloatRecommendation) = DarlingPlanCacheSchedulerReader.ClassifyPlanCacheBloat(totalPlans, totalSingleUse); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + collection_time = rows[0].CollectionTime.ToString("o"), + summary = new + { + total_plans = totalPlans, + single_use_plans = totalSingleUse, + single_use_percent = totalPlans > 0 ? Math.Round(100.0 * totalSingleUse / totalPlans, 1) : 0, + total_size_mb = totalSizeMb, + single_use_size_mb = singleUseSizeMb, + wasted_percent = totalSizeMb > 0 ? Math.Round(100.0 * singleUseSizeMb / totalSizeMb, 1) : 0, + bloat_level = bloatLevel, + bloat_recommendation = bloatRecommendation + }, + cache_types = rows.Select(r => new + { + cache_type = r.CacheObjType, + object_type = r.ObjType, + total_plans = r.TotalPlans, + total_size_mb = r.TotalSizeMb, + single_use_plans = r.SingleUsePlans, + single_use_size_mb = r.SingleUseSizeMb, + multi_use_plans = r.MultiUsePlans, + multi_use_size_mb = r.MultiUseSizeMb, + avg_use_count = r.AvgUseCount + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_plan_cache_bloat", ex); + } + } + + [McpServerTool(Name = "get_cpu_scheduler_pressure"), Description("Gets CPU scheduler pressure: runnable task queue depth, worker thread utilization, and pressure warnings. Shows whether the server has enough worker threads and if tasks are queuing for CPU time.")] + public static async Task GetCpuSchedulerPressure( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + try + { + var item = await DarlingPlanCacheSchedulerReader.GetCpuSchedulerPressureAsync(postgres, resolved.Value.ServerId); + if (item == null) + return McpHelpers.Status("unavailable", "No CPU scheduler data available. The scheduler collector may not have run yet."); + + var workerUtilizationPercent = item.MaxWorkersCount > 0 + ? Math.Round(item.TotalCurrentWorkersCount * 100.0 / item.MaxWorkersCount, 2) + : 0; + var (pressureLevel, recommendation) = DarlingPlanCacheSchedulerReader.ClassifyCpuSchedulerPressure( + item.TotalRunnableTasksCount, item.TotalCurrentWorkersCount, item.MaxWorkersCount, + item.TotalQueuedRequestCount, item.WorkerThreadExhaustionWarning, item.RunnableTasksWarning, + item.QueuedRequestsWarning); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + collection_time = item.CollectionTime.ToString("o"), + schedulers = item.SchedulerCount, + runnable_tasks = item.TotalRunnableTasksCount, + avg_runnable_per_scheduler = item.AvgRunnableTasksCount, + workers = item.TotalCurrentWorkersCount, + max_workers = item.MaxWorkersCount, + worker_utilization_percent = workerUtilizationPercent, + runnable_percent = item.RunnablePercent, + queued_requests = item.TotalQueuedRequestCount, + active_requests = item.TotalActiveRequestCount, + pressure_level = pressureLevel, + recommendation, + warnings = new + { + worker_thread_exhaustion = item.WorkerThreadExhaustionWarning, + runnable_tasks = item.RunnableTasksWarning, + blocked_tasks = item.BlockedTasksWarning, + queued_requests = item.QueuedRequestsWarning, + physical_memory_pressure = item.PhysicalMemoryPressureWarning + } + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_cpu_scheduler_pressure", ex); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanTools.cs new file mode 100644 index 000000000..1ebfdebf0 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanTools.cs @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.ComponentModel; +using System.Threading.Tasks; +using ModelContextProtocol.Server; +using Npgsql; +using PerformanceMonitor.Common; +using PerformanceMonitor.PlanAnalysis; + +#pragma warning disable CA1707 // MCP tools use snake_case naming convention + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// The five plan-analysis MCP tools — the SAME tool surface the Dashboard and Lite expose +/// (analyze_query_plan, analyze_procedure_plan, analyze_query_store_plan, analyze_plan_xml, +/// get_plan_xml), served over Darling's Postgres store. Each tool body mirrors the Dashboard's +/// McpPlanTools field-for-field (same tool names, same required parameters, the #1224 miss +/// vocabulary via , and the SHARED +/// projection both apps already call) so an MCP client sees one +/// consistent product across all three SKUs. +/// +/// +/// THE SEAM: where the Dashboard fetches the plan XML from its SQL-Server store (query_hash → binary +/// CONVERT, most-recent by last_execution_time) and Lite from DuckDB, Darling reads the plan text the +/// collectors persisted into Postgres via — a STORED-plan read +/// (no live monitored-server hit), keyed by the same object identity the Dashboard's tools use, with +/// server resolution through the Postgres servers registry () +/// instead of the Dashboard's ServerManager. Two tools additionally accept the finer store key the +/// viewer's grids carry (database_name on the query-stats read, plan_id on the query-store read) as an +/// OPTIONAL refinement; omitting it reproduces the Dashboard's coarse-key behavior exactly, so a client +/// relying on the Dashboard contract works identically here. +/// +/// +[McpServerToolType] +public sealed class DarlingMcpPlanTools +{ + [McpServerTool(Name = "analyze_query_plan"), Description( + "Analyzes a stored execution plan captured from query stats by query_hash. " + + "Use after get_top_queries_by_cpu to understand why a query is expensive. " + + "Returns warnings, missing indexes, parameters, memory grants, and top operators.")] + public static async Task AnalyzeQueryPlan( + NpgsqlDataSource postgres, + [Description("The query_hash value from get_top_queries_by_cpu.")] string query_hash, + [Description("Server name or display name.")] string? server_name = null, + [Description("Optional database name to disambiguate the same query_hash across databases. Omit for the most recently captured plan.")] string? database_name = null) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + try + { + var xml = await DarlingStoredPlanReader.GetQueryStatsPlanXmlByHashAsync( + postgres, resolved.Value.ServerId, query_hash, database_name); + if (string.IsNullOrEmpty(xml)) + return McpHelpers.Status( + "unavailable", + $"No stored plan found for query_hash '{query_hash}'{DbSuffix(database_name)}. The plan collector may not have captured a plan for this query, or the row has aged out of the store."); + + return McpPlanAnalysisFormatter.BuildAnalysisResult(xml, resolved.Value.ServerName, "query_stats", query_hash); + } + catch (Exception ex) + { + return McpHelpers.FormatError("analyze_query_plan", ex); + } + } + + [McpServerTool(Name = "analyze_procedure_plan"), Description( + "Analyzes a stored execution plan captured from procedure stats by sql_handle. " + + "Use after get_top_procedures_by_cpu to understand why a procedure is expensive. " + + "Returns warnings, missing indexes, parameters, memory grants, and top operators.")] + public static async Task AnalyzeProcedurePlan( + NpgsqlDataSource postgres, + [Description("The sql_handle value from get_top_procedures_by_cpu.")] string sql_handle, + [Description("Server name or display name.")] string? server_name = null) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + try + { + var xml = await DarlingStoredPlanReader.GetProcedurePlanXmlBySqlHandleAsync( + postgres, resolved.Value.ServerId, sql_handle); + if (string.IsNullOrEmpty(xml)) + return McpHelpers.Status( + "unavailable", + $"No stored plan found for sql_handle '{sql_handle}'. The plan collector may not have captured a plan for this procedure, or the row has aged out of the store."); + + return McpPlanAnalysisFormatter.BuildAnalysisResult(xml, resolved.Value.ServerName, "procedure_stats", sql_handle); + } + catch (Exception ex) + { + return McpHelpers.FormatError("analyze_procedure_plan", ex); + } + } + + [McpServerTool(Name = "analyze_query_store_plan"), Description( + "Analyzes a stored Query Store execution plan by database name and query ID. " + + "Use after get_query_store_top to understand why a query is expensive. " + + "Returns warnings, missing indexes, parameters, memory grants, and top operators.")] + public static async Task AnalyzeQueryStorePlan( + NpgsqlDataSource postgres, + [Description("The database_name from get_query_store_top.")] string database_name, + [Description("The query_id from get_query_store_top.")] long query_id, + [Description("Server name or display name.")] string? server_name = null, + [Description("Optional plan_id to pin one specific compiled plan. Omit for the most recently captured plan for the query.")] long? plan_id = null) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + try + { + var xml = await DarlingStoredPlanReader.GetQueryStorePlanTextAsync( + postgres, resolved.Value.ServerId, database_name, query_id, plan_id); + if (string.IsNullOrEmpty(xml)) + return McpHelpers.Status( + "unavailable", + $"No stored Query Store plan found for query_id {query_id} in database '{database_name}'{PlanSuffix(plan_id)}. Query Store plan capture may be disabled for this database, or the plan has been purged."); + + var identifier = plan_id is null ? $"{database_name}:{query_id}" : $"{database_name}:{query_id}:{plan_id}"; + return McpPlanAnalysisFormatter.BuildAnalysisResult(xml, resolved.Value.ServerName, "query_store", identifier); + } + catch (Exception ex) + { + return McpHelpers.FormatError("analyze_query_store_plan", ex); + } + } + + [McpServerTool(Name = "analyze_plan_xml"), Description( + "Analyzes raw showplan XML directly. Use when you have plan XML from any source " + + "(clipboard, file, another tool). Returns warnings, missing indexes, parameters, " + + "memory grants, and top operators.")] + public static string AnalyzePlanXml( + [Description("Raw showplan XML content.")] string plan_xml) + { + if (string.IsNullOrWhiteSpace(plan_xml)) + return "No plan XML provided."; + + try + { + return McpPlanAnalysisFormatter.BuildAnalysisResult(plan_xml, null, "xml", null); + } + catch (Exception ex) + { + return McpHelpers.FormatError("analyze_plan_xml", ex); + } + } + + [McpServerTool(Name = "get_plan_xml"), Description( + "Returns the raw stored showplan XML for a query identified by query_hash. " + + "Use when you need to inspect plan details not captured in the structured analysis. " + + "Truncated at 500KB.")] + public static async Task GetPlanXml( + NpgsqlDataSource postgres, + [Description("The query_hash value from get_top_queries_by_cpu.")] string query_hash, + [Description("Server name or display name.")] string? server_name = null, + [Description("Optional database name to disambiguate the same query_hash across databases. Omit for the most recently captured plan.")] string? database_name = null) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + try + { + var xml = await DarlingStoredPlanReader.GetQueryStatsPlanXmlByHashAsync( + postgres, resolved.Value.ServerId, query_hash, database_name); + if (string.IsNullOrEmpty(xml)) + return McpHelpers.Status("unavailable", $"No stored plan found for query_hash '{query_hash}'{DbSuffix(database_name)}."); + + return McpHelpers.Truncate(xml, 512_000) ?? "No plan XML available."; + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_plan_xml", ex); + } + } + + /// " in database 'X'" when a database was supplied, else empty — keeps the miss message honest + /// about the coarse-vs-fine key the caller actually used. + private static string DbSuffix(string? databaseName) => + string.IsNullOrEmpty(databaseName) ? "" : $" in database '{databaseName}'"; + + /// " (plan_id N)" when a plan_id was supplied, else empty. + private static string PlanSuffix(long? planId) => + planId is null ? "" : $" (plan_id {planId})"; +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpReadParameters.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpReadParameters.cs new file mode 100644 index 000000000..367e07fc1 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpReadParameters.cs @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using Npgsql; +using NpgsqlTypes; + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// The naive-UTC parameter-binding helpers the slice-3 diagnostic-depth readers share — the SAME +/// bindings the merged slice-2 defines privately, lifted into one +/// place so the four new domain readers (blocking, sessions, config-history, object-stats) bind +/// identically without re-declaring them per file. Every timestamp binds +/// Kind=Unspecified so it maps to the store's timestamp without time zone columns (a +/// Kind=Utc DateTime maps to timestamptz and throws since Npgsql 6.0); the nullable-text +/// binder activates a read's $N::text IS NULL OR ... "no filter" branch when the value is null. +/// +internal static class DarlingMcpReadParameters +{ + public static void AddInt(NpgsqlCommand command, int value) => + command.Parameters.Add(new NpgsqlParameter { TypedValue = value }); + + public static void AddText(NpgsqlCommand command, string value) => + command.Parameters.Add(new NpgsqlParameter { TypedValue = value ?? "" }); + + /// Binds a nullable text filter (a null value binds SQL NULL). + public static void AddNullableText(NpgsqlCommand command, string? value) => + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Text, Value = (object?)value ?? DBNull.Value }); + + /// Binds a naive-UTC timestamp (Kind=Unspecified → the store's timestamp columns). + public static void AddTimestamp(NpgsqlCommand command, DateTime value) => + command.Parameters.Add(new NpgsqlParameter { TypedValue = DateTime.SpecifyKind(value, DateTimeKind.Unspecified) }); + + /// Binds the common (server_id, window start, window end) parameter triple. + public static void AddWindow(NpgsqlCommand command, int serverId, DateTime startUtc, DateTime endUtc) + { + AddInt(command, serverId); + AddTimestamp(command, startUtc); + AddTimestamp(command, endUtc); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpServerAdminTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpServerAdminTools.cs new file mode 100644 index 000000000..2f4e14801 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpServerAdminTools.cs @@ -0,0 +1,564 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using ModelContextProtocol.Server; +using Npgsql; +using NpgsqlTypes; +using PerformanceMonitor.Common; + +#pragma warning disable CA1707 // MCP tools use snake_case naming convention + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// The server-onboarding MCP tools — add_servers (BULK) and remove_server — the Darling-only WRITE +/// surface that lets an MCP client (an LLM assistant) stand up or tear down FLEET monitoring conversationally: +/// "monitor these twenty servers with this login" writes twenty config.config_monitored_servers rows the +/// running service reconciles into its collection set on the next reload beacon. This is the service-side twin of +/// the Viewer's Add / Add-Multiple dialogs (AddServerDialog / AddMultipleServersDialog), and the +/// direct sibling of the #1600 Custom Views and #1608 alert-tuning MCP write tools. +/// +/// No divergent second implementation. Every authority is REUSED, never re-invented: the connection +/// probe is run IN-PROCESS (the MCP host lives inside the service, +/// which holds the network path + credentials — unlike the Viewer, whose dialogs enqueue a test_connect +/// command for the service to run); the case-folded dedupe gate is the shared +/// identity in a HashSet<string>(OrdinalIgnoreCase) exactly as the bulk dialog (#1549) uses; the SQL +/// password is DPAPI-encrypted through (the SAME service identity that +/// decrypts it during collection, so it round-trips) and NEVER stored, logged, or echoed in plaintext; and the +/// INSERT mirrors StoreConfigProvider.SeedMonitoredServersAsync's exact column set + server_id = +/// ServerIdHelper.GetDeterministicHashCode(StorageName) identity, so a tool-written row JOINs the collected +/// data and the service's reconcile matches it. +/// +/// add_servers processes its JSON array SEQUENTIALLY (mirroring #1549's Darling bulk probe, which is +/// serial to avoid a probe storm): for each server it validates the fields, skips an exact/case-variant DUPLICATE +/// of an existing or earlier-in-batch server (status:"duplicate"), probes the connection (a failure is +/// status:"connection_failed" and does NOT abort the batch), DPAPI-encrypts the SQL password, and INSERTs +/// the row (status:"added"). Windows/integrated auth stores no secret; Entra/MFA/Service-Principal/Managed- +/// Identity auth is rejected (status:"invalid") — interactive MFA is nonsensical headless, the same belt the +/// bulk dialog applies. The whole call returns {added, skipped, failed, results:[...]}. remove_server +/// resolves a name through the SAME the read tools use and DELETEs the +/// config.config_monitored_servers row. +/// +/// Security. These tools connect (like every MCP tool) as the least-privilege mcp role, granted +/// (see ) INSERT/UPDATE/DELETE on config.config_monitored_servers — a single +/// non-secret-key table (the DPAPI password blob is written but the encrypted_password column is SELECT- +/// carved from mcp, so a token-holder can WRITE a credential but never READ one back) — and nothing else, so +/// it still cannot reach the config_command service-credential pivot or the carved secret columns. The +/// config_monitored_servers write fires the existing trg_bump_monitored_servers → config_bump_version +/// trigger (SECURITY INVOKER), which the #1608 config_service beacon column-grant already covers, so the +/// running service hot-reloads the monitored set within one sweep. The SQL password transits the MCP endpoint in +/// the request JSON; on a LAN deployment front the endpoint with the documented TLS reverse proxy. +/// +[McpServerToolType] +public sealed class DarlingMcpServerAdminTools +{ + /// The connect-and-probe seam — in production + /// ( in-process), a stub in the unit tests so the store-write / + /// dedupe / encrypt / result-aggregation logic can be exercised WITHOUT a real SQL Server. + internal delegate Task ServerProbe(MonitoredServer server, CancellationToken cancellationToken); + + private static Task DefaultProbeAsync(MonitoredServer server, CancellationToken cancellationToken) => + DarlingServerConnector.ProbeAsync(server, null, cancellationToken); + + [McpServerTool(Name = "add_servers"), Description( + "Adds one or more SQL Servers to the fleet the Darling service monitors — BULK onboarding: pass a JSON " + + "ARRAY of server objects and each is validated, connection-tested, and (if new and reachable) saved to the " + + "central monitoring store, which the running service picks up within one collection sweep (no restart). " + + "Each object: host (REQUIRED); display_name (optional, defaults to host); database (optional — set it only " + + "to monitor a single database, e.g. one Azure SQL Database); auth (\"Windows\" for integrated security or " + + "\"SQL\" for a SQL login — default \"Windows\"); username + password (REQUIRED for \"SQL\" auth, ignored for " + + "\"Windows\"); encrypt_mode (\"Optional\"|\"Mandatory\"|\"Strict\", default \"Mandatory\"); " + + "trust_server_certificate (bool, default false — set true to accept a self-signed server cert); " + + "read_only_intent (bool, default false); multi_subnet_failover (bool, default false). Servers are processed " + + "IN ORDER, one at a time. A case-variant or exact duplicate of an already-monitored server (or an earlier " + + "entry in the same array) is skipped as status \"duplicate\". A server that fails to connect is recorded as " + + "status \"connection_failed\" and does NOT stop the rest of the batch. Microsoft Entra / MFA / Service " + + "Principal / Managed Identity auth is rejected (status \"invalid\") — the service connects with Windows or " + + "SQL authentication only. A SQL password is encrypted at rest (DPAPI, the service identity) and is never " + + "returned. Returns {added:N, skipped:N, failed:N, results:[{server, status:\"added\"|\"duplicate\"|" + + "\"connection_failed\"|\"invalid\", detail}]}. NOTE: the password travels to this endpoint in the request; " + + "on a LAN use the documented TLS reverse proxy.")] + public static Task AddServers( + NpgsqlDataSource postgres, + [Description("A JSON ARRAY of server objects to add (see the tool description for the per-object fields), e.g. [{\"host\":\"sql01\",\"auth\":\"SQL\",\"username\":\"monitor\",\"password\":\"...\",\"encrypt_mode\":\"Mandatory\",\"trust_server_certificate\":true},{\"host\":\"sql02\"}].")] string servers_json) => + AddServersAsync(postgres, servers_json, DefaultProbeAsync, CancellationToken.None); + + /// The testable core of add_servers: validates + dedupes + probes (through the injected + /// seam) + encrypts + INSERTs, aggregating a per-server result. Structural validation + /// runs BEFORE any store access, and when NO structurally-valid candidate remains the store is never opened — + /// so a call whose entries are all invalid (bad field, MFA auth) returns without a connection or a probe. + internal static async Task AddServersAsync( + NpgsqlDataSource postgres, string servers_json, ServerProbe probe, CancellationToken cancellationToken) + { + try + { + var (entries, invalidResults, wholeError) = ParseRequest(servers_json); + if (wholeError != null) + { + return Outcome("invalid", wholeError); + } + + var results = new List(invalidResults); + + /* No structurally-valid candidate → never open the store (validate-before-write); the aggregate below + reports only the per-entry invalids. */ + if (entries.Count == 0) + { + return Aggregate(results); + } + + /* Seed the case-folded dedupe gate from the authoritative store rows FIRST, then partition the batch — + a duplicate (of an existing server OR an earlier entry in this batch, first occurrence wins) is + skipped WITHOUT a probe, exactly as the bulk dialog (#1549) does. */ + var existingKeys = await LoadExistingStorageKeysAsync(postgres, cancellationToken); + var (ready, duplicates) = PartitionDuplicates(entries, existingKeys); + results.AddRange(duplicates); + + foreach (var entry in ready) + { + /* Validate the connection IN-PROCESS (the service holds the network path + credentials). A failure + is recorded and the batch CONTINUES — one unreachable server never aborts the rest. */ + var probeResult = await probe(entry.ProbeConfig, cancellationToken); + if (!probeResult.Success) + { + results.Add(new ServerResult(entry.Order, entry.DisplayName, "connection_failed", + string.IsNullOrWhiteSpace(probeResult.Error) + ? "Could not connect to the server." + : $"Could not connect: {probeResult.Error}")); + continue; + } + + /* DPAPI-encrypt the SQL password for storage (the service identity encrypts here and decrypts it + during collection, so it round-trips); Windows-auth servers store no secret. The plaintext never + leaves this method — it is not logged, not echoed in a result. */ + var encryptedPassword = ProtectPasswordForStorage(entry.PlaintextPassword); + await InsertServerAsync(postgres, entry, encryptedPassword, cancellationToken); + results.Add(new ServerResult(entry.Order, entry.DisplayName, "added", DescribeProbe(probeResult))); + } + + return Aggregate(results); + } + catch (Exception ex) + { + return McpHelpers.FormatError("add_servers", ex); + } + } + + [McpServerTool(Name = "remove_server"), Description( + "Removes a monitored SQL Server from the fleet by name (the display name or address, resolved the same way " + + "the read tools resolve server_name — exact match first, then partial, against the storage name and the " + + "display name). Deletes the server's definition from the central monitoring store; the running service " + + "drops it from its collection set within one sweep. Already-collected historical data is NOT deleted. " + + "Returns {status:\"removed\", server} on success, or {status:\"not_found\", ...} when no monitored server " + + "matches the name.")] + public static async Task RemoveServer( + NpgsqlDataSource postgres, + [Description("The name of the monitored server to remove — its display name or address (as list_servers / get_alert_history report it).")] string server_name) + { + try + { + if (string.IsNullOrWhiteSpace(server_name)) + { + return Outcome("invalid", "server_name is required."); + } + + /* Resolve through the SAME resolver the read tools use, against the servers registry (the id it returns + is the shared-identity server_id that keys config_monitored_servers). A miss returns the resolver's + available-servers listing — surfaced as not_found here. */ + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) + { + return Outcome("not_found", error); + } + + await using var command = postgres.CreateCommand("DELETE FROM config_monitored_servers WHERE server_id = $1"); + command.Parameters.Add(new NpgsqlParameter { TypedValue = resolved!.Value.ServerId }); + var affected = await command.ExecuteNonQueryAsync(); + + return affected > 0 + ? JsonSerializer.Serialize(new { status = "removed", server = resolved.Value.ServerName }, McpHelpers.JsonOptions) + : Outcome("not_found", + $"'{resolved.Value.ServerName}' is registered but has no monitored-server definition to remove (it may have been added only via darling.json, or already removed)."); + } + catch (Exception ex) + { + return McpHelpers.FormatError("remove_server", ex); + } + } + + /* ─────────────────────────────── pure parse + validate (no I/O) ─────────────────────────────── */ + + /// One structurally-valid server ready for dedupe → probe → insert. is the + /// shared case-folded identity (); is the + /// the in-process probe connects with (carrying the plaintext password for the + /// connect); is retained ONLY until the post-probe DPAPI encrypt, never stored + /// or echoed. is the entry's index in the input array, so the aggregated results echo input + /// order. + internal sealed record ParsedServerEntry( + int Order, string DisplayName, string StorageKey, MonitoredServer ProbeConfig, string? PlaintextPassword); + + /// One per-server outcome (added / duplicate / connection_failed / invalid) + /// with a human-readable ; restores input order in the aggregate. + internal sealed record ServerResult(int Order, string Server, string Status, string Detail); + + /// + /// PURE structural validation of the servers_json request — no store, no probe, no crypto — so the + /// validate-before-write behavior is unit-testable without a live SQL Server. Returns the structurally-valid + /// Entries (with their dedupe keys + probe configs built), the per-entry Invalid results (a bad + /// field or an unsupported auth mode — recorded, the batch continues past them), and a non-null + /// WholeError when the whole payload is unusable (not JSON, not an array, or empty), for which the caller + /// returns a single {status:"invalid"} without opening the store. + /// + internal static (List Entries, List Invalid, string? WholeError) ParseRequest(string servers_json) + { + var entries = new List(); + var invalid = new List(); + + JsonNode? root; + try + { + root = JsonNode.Parse(servers_json); + } + catch (JsonException ex) + { + return (entries, invalid, $"servers_json is not valid JSON: {ex.Message}"); + } + + if (root is not JsonArray array) + { + return (entries, invalid, "servers_json must be a JSON array of server objects (e.g. [{\"host\":\"sql01\"}])."); + } + + if (array.Count == 0) + { + return (entries, invalid, "servers_json must be a non-empty JSON array — provide at least one server object."); + } + + for (var i = 0; i < array.Count; i++) + { + var (entry, result) = ParseEntry(i, array[i]); + if (entry != null) + { + entries.Add(entry); + } + else + { + invalid.Add(result!); + } + } + + return (entries, invalid, null); + } + + /// Parses + validates ONE array element into a ready entry, or an invalid result naming the + /// problem. Windows/SQL are the only auth modes the service can honor; every other value (including the + /// Entra/MFA/Service-Principal/Managed-Identity modes) is rejected — interactive MFA is nonsensical headless. + private static (ParsedServerEntry? Entry, ServerResult? Result) ParseEntry(int index, JsonNode? node) + { + if (node is not JsonObject obj) + { + return (null, new ServerResult(index, $"(entry {index + 1})", "invalid", "Each entry must be a JSON object.")); + } + + var host = TryGetString(obj, "host"); + var label = string.IsNullOrWhiteSpace(host) ? $"(entry {index + 1})" : host!.Trim(); + + ServerResult Invalid(string message) => new(index, label, "invalid", message); + + if (string.IsNullOrWhiteSpace(host)) + { + return (null, Invalid("host is required.")); + } + + host = host!.Trim(); + var displayName = TryGetString(obj, "display_name") is { Length: > 0 } dn ? dn.Trim() : host; + var databaseRaw = TryGetString(obj, "database"); + var database = string.IsNullOrWhiteSpace(databaseRaw) ? null : databaseRaw!.Trim(); + + /* Auth: Windows (integrated) or SQL only. Absent defaults to Windows (the MonitoredServer default). Any + other value — Entra / MFA / ServicePrincipal / ManagedIdentity, or a typo — is refused with the SAME + message, so a headless caller learns the service's two supported modes. */ + var authRaw = TryGetString(obj, "auth"); + string storeAuth; + if (string.IsNullOrWhiteSpace(authRaw) || authRaw.Trim().Equals("Windows", StringComparison.OrdinalIgnoreCase)) + { + storeAuth = ServerStoreAuth.Integrated; + } + else if (authRaw.Trim().Equals("SQL", StringComparison.OrdinalIgnoreCase)) + { + storeAuth = ServerStoreAuth.Sql; + } + else + { + return (null, Invalid( + "auth must be \"Windows\" or \"SQL\". Microsoft Entra / MFA / Service Principal / Managed Identity " + + "are not supported for headless onboarding — the Darling service connects with Windows (integrated) " + + "or SQL authentication only.")); + } + + string? username = null; + string? plaintextPassword = null; + if (storeAuth == ServerStoreAuth.Sql) + { + username = TryGetString(obj, "username"); + if (string.IsNullOrWhiteSpace(username)) + { + return (null, Invalid("username is required for SQL authentication.")); + } + + username = username!.Trim(); + plaintextPassword = TryGetString(obj, "password"); + if (string.IsNullOrEmpty(plaintextPassword)) + { + return (null, Invalid("password is required for SQL authentication.")); + } + } + + /* encrypt_mode + trust_server_certificate are deliberately EXPOSED (per Erik) — a headless caller sets the + TLS posture explicitly. Optional with the fail-closed MonitoredServer defaults (Mandatory / no trust). */ + var (encryptMode, encryptError) = ResolveEncryptMode(TryGetString(obj, "encrypt_mode")); + if (encryptError != null) + { + return (null, Invalid(encryptError)); + } + + var (trustCert, trustError) = ResolveBool(obj, "trust_server_certificate", false); + if (trustError != null) + { + return (null, Invalid(trustError)); + } + + var (readOnlyIntent, roError) = ResolveBool(obj, "read_only_intent", false); + if (roError != null) + { + return (null, Invalid(roError)); + } + + var (multiSubnet, msError) = ResolveBool(obj, "multi_subnet_failover", false); + if (msError != null) + { + return (null, Invalid(msError)); + } + + var probeConfig = new MonitoredServer + { + Name = displayName, + Host = host, + Database = database, + Auth = storeAuth, + Username = username, + /* Plaintext for the probe's connect (ResolvePassword's dev-plaintext fallback); the stored blob is the + DPAPI encryption of this, produced only AFTER a successful probe. */ + Password = plaintextPassword, + EncryptMode = encryptMode, + TrustServerCertificate = trustCert, + ReadOnlyIntent = readOnlyIntent, + MultiSubnetFailover = multiSubnet, + }; + + var storageKey = ServerIdHelper.BuildStorageName(host, database, readOnlyIntent); + return (new ParsedServerEntry(index, displayName, storageKey, probeConfig, plaintextPassword), null); + } + + /// + /// PURE dedupe partition — the case-folded gate seeded with the + /// existing store keys, first-occurrence-wins within the batch (the #1549 idiom). Returns the Ready + /// entries to probe + insert and the Duplicates as ready-to-report results. Unit-testable without a + /// store or probe. + /// + internal static (List Ready, List Duplicates) PartitionDuplicates( + IReadOnlyList entries, IEnumerable existingKeys) + { + var seen = new HashSet(existingKeys, StringComparer.OrdinalIgnoreCase); + var ready = new List(); + var duplicates = new List(); + + foreach (var entry in entries) + { + if (seen.Add(entry.StorageKey)) + { + ready.Add(entry); + } + else + { + duplicates.Add(new ServerResult(entry.Order, entry.DisplayName, "duplicate", + "Already monitored (or a duplicate of an earlier entry in this batch); skipped.")); + } + } + + return (ready, duplicates); + } + + /* ─────────────────────────────── store I/O ─────────────────────────────── */ + + /// Reads the identity fields of every existing monitored server so the dedupe gate can be seeded from + /// the authoritative set (mirrors the bulk dialog's LoadExistingKeysAsync). Non-secret columns only. + public const string ExistingServersSql = "SELECT host, database, read_only_intent FROM config_monitored_servers"; + + /// The INSERT — column set + shape mirrored from StoreConfigProvider.SeedMonitoredServersAsync + /// (the seed authority), so a tool-added row is byte-identical to a seeded one. capture_plans and + /// alert_delivery_mode_override default to NULL (inherit the globals), monthly_cost_usd/ + /// excluded_databases are the neutral defaults, and is_enabled is TRUE (collection starts at + /// once). ON CONFLICT DO NOTHING guards a race with a concurrent writer — the dedupe gate is the primary + /// guard. + public const string InsertServerSql = @" +INSERT INTO config_monitored_servers ( + server_id, name, host, database, auth, username, encrypted_password, encrypt_mode, + trust_server_certificate, read_only_intent, multi_subnet_failover, excluded_databases, + monthly_cost_usd, capture_plans, alert_delivery_mode_override, is_enabled, created_at, modified_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NULL, NULL, TRUE, $14, $14) +ON CONFLICT (server_id) DO NOTHING"; + + private static async Task> LoadExistingStorageKeysAsync(NpgsqlDataSource postgres, CancellationToken cancellationToken) + { + var keys = new List(); + await using var command = postgres.CreateCommand(ExistingServersSql); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var host = reader.GetString(0); + var database = reader.IsDBNull(1) ? null : reader.GetString(1); + var readOnlyIntent = !reader.IsDBNull(2) && reader.GetBoolean(2); + keys.Add(ServerIdHelper.BuildStorageName(host, database, readOnlyIntent)); + } + + return keys; + } + + private static async Task InsertServerAsync( + NpgsqlDataSource postgres, ParsedServerEntry entry, string? encryptedPassword, CancellationToken cancellationToken) + { + var config = entry.ProbeConfig; + var now = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified); + + await using var command = postgres.CreateCommand(InsertServerSql); + command.Parameters.Add(new NpgsqlParameter { TypedValue = ServerIdHelper.GetDeterministicHashCode(entry.StorageKey) }); // $1 + command.Parameters.Add(new NpgsqlParameter { TypedValue = config.Name }); // $2 + command.Parameters.Add(new NpgsqlParameter { TypedValue = config.Host }); // $3 + AddNullableText(command, config.Database); // $4 + command.Parameters.Add(new NpgsqlParameter { TypedValue = config.Auth }); // $5 + AddNullableText(command, config.Username); // $6 + AddNullableText(command, encryptedPassword); // $7 + command.Parameters.Add(new NpgsqlParameter { TypedValue = config.EncryptMode }); // $8 + command.Parameters.Add(new NpgsqlParameter { TypedValue = config.TrustServerCertificate }); // $9 + command.Parameters.Add(new NpgsqlParameter { TypedValue = config.ReadOnlyIntent }); // $10 + command.Parameters.Add(new NpgsqlParameter { TypedValue = config.MultiSubnetFailover }); // $11 + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Array | NpgsqlDbType.Text, Value = Array.Empty() }); // $12 + command.Parameters.Add(new NpgsqlParameter { TypedValue = 0m }); // $13 + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Timestamp, Value = now }); // $14 + await command.ExecuteNonQueryAsync(cancellationToken); + } + + /* ─────────────────────────────── helpers ─────────────────────────────── */ + + /// DPAPI-encrypts a SQL password for storage, or null for Windows auth (no secret). The + /// OperatingSystem.IsWindows() guard keeps CA1416 honest and gives a clear failure on a non-Windows BYO + /// host — the same platform posture as every other DPAPI surface here; managed mode (where onboarding runs) is + /// Windows-only. The plaintext is not logged and never leaves this method. + private static string? ProtectPasswordForStorage(string? plaintextPassword) + { + if (string.IsNullOrEmpty(plaintextPassword)) + { + return null; + } + + if (!OperatingSystem.IsWindows()) + { + throw new PlatformNotSupportedException("Storing a SQL-auth password requires Windows (DPAPI)."); + } + + return DarlingSecrets.Protect(plaintextPassword); + } + + /// Builds the {added, skipped, failed, results} envelope, results in input order. + private static string Aggregate(List results) + { + var ordered = results.OrderBy(r => r.Order).ToList(); + return JsonSerializer.Serialize(new + { + added = ordered.Count(r => r.Status == "added"), + skipped = ordered.Count(r => r.Status == "duplicate"), + failed = ordered.Count(r => r.Status is "connection_failed" or "invalid"), + results = ordered.Select(r => new { server = r.Server, status = r.Status, detail = r.Detail }), + }, McpHelpers.JsonOptions); + } + + /// The probed facts for an added server — edition / major version / msdb access, mirroring the + /// --test-connection CLI line (DarlingCliCommands.FormatProbeLine). + private static string DescribeProbe(ConnectionProbeResult probe) + { + var edition = string.IsNullOrEmpty(probe.EngineEditionDescription) + ? DarlingServerConnector.DescribeEngineEdition(probe.EngineEdition) + : probe.EngineEditionDescription; + var msdb = probe.HasMsdbAccess ? "msdb access: yes" : "msdb access: NO (SQL Agent job data unavailable)"; + return $"Connected — SQL major version {probe.MajorVersion}, {edition}, {msdb}."; + } + + /// Validates the optional encrypt_mode ("Optional"/"Mandatory"/"Strict"); absent → the + /// fail-closed "Mandatory" default (matching + the connection builder). + private static (string Mode, string? Error) ResolveEncryptMode(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return ("Mandatory", null); + } + + return raw.Trim().ToUpperInvariant() switch + { + "OPTIONAL" => ("Optional", null), + "MANDATORY" => ("Mandatory", null), + "STRICT" => ("Strict", null), + _ => ("Mandatory", "encrypt_mode must be \"Optional\", \"Mandatory\", or \"Strict\"."), + }; + } + + /// Reads an optional boolean field; absent → , a non-boolean value → error. + private static (bool Value, string? Error) ResolveBool(JsonObject obj, string field, bool fallback) + { + if (obj[field] is not JsonNode node) + { + return (fallback, null); + } + + if (node is JsonValue value && value.TryGetValue(out var b)) + { + return (b, null); + } + + return (fallback, $"{field} must be true or false."); + } + + private static string? TryGetString(JsonObject obj, string key) => + obj[key] is JsonValue value && value.TryGetValue(out var s) ? s : null; + + private static void AddNullableText(NpgsqlCommand command, string? value) => + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Text, Value = (object?)value ?? DBNull.Value }); + + /// A small {status, message} envelope for a non-data outcome (invalid / not_found) — the same + /// shape / use, so an MCP client can + /// branch on the outcome kind. A successful add-batch / removal returns its own data-bearing shape, not this. + private static string Outcome(string status, string message) => + JsonSerializer.Serialize(new { status, message }, McpHelpers.JsonOptions); + + /// The two config.config_monitored_servers.auth values the Darling service connect path honors + /// — the service-side twin of the Viewer's ServerStoreCredential constants (the Viewer project is not + /// referenced here, so the values are restated; they are pinned equal to the store's DDL default 'integrated'). + private static class ServerStoreAuth + { + public const string Integrated = "integrated"; + public const string Sql = "sql"; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs new file mode 100644 index 000000000..7d2321c2b --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using ModelContextProtocol.Server; +using Npgsql; +using PerformanceMonitor.Common; + +#pragma warning disable CA1707 // MCP tools use snake_case naming convention + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// The session diagnostic-depth MCP tools — get_session_stats, get_active_queries, get_waiting_tasks — +/// served over Darling's Postgres store. Each tool body mirrors LITE's McpSessionTools / +/// McpWaitTools field-for-field (the store-faithful shape Darling's collector-mirror schema can +/// serve), keeping the tool names + parameter contracts. Reads flow through +/// — STORED reads (no live monitored-server hit). +/// +/// +/// get_session_stats follows LITE's per-application shape (the store's session_stats table), not the +/// Dashboard's server-wide session_summary_stats summary — the two tools diverge and slices 1+2 +/// follow Lite. get_waiting_tasks is a Lite-only tool (the Dashboard has no equivalent); it surfaces the +/// always-NULL resource_description exactly as Lite does. +/// +/// +[McpServerToolType] +public sealed class DarlingMcpSessionTools +{ + [McpServerTool(Name = "get_session_stats"), Description("Gets connection and session statistics grouped by application. Shows connection counts, running/sleeping/dormant breakdown, and aggregate resource usage per application.")] + public static async Task GetSessionStats( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + try + { + var rows = await DarlingSessionReader.GetLatestSessionStatsAsync(postgres, resolved.Value.ServerId); + if (rows.Count == 0) + return McpHelpers.Status("unavailable", "No session statistics available. The session collector may not have run yet."); + + var totalConnections = rows.Sum(r => r.ConnectionCount); + var totalRunning = rows.Sum(r => r.RunningCount); + var totalSleeping = rows.Sum(r => r.SleepingCount); + var totalDormant = rows.Sum(r => r.DormantCount); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + collection_time = rows[0].CollectionTime.ToString("o"), + summary = new + { + total_connections = totalConnections, + total_running = totalRunning, + total_sleeping = totalSleeping, + total_dormant = totalDormant, + distinct_applications = rows.Count + }, + applications = rows.Select(r => new + { + program_name = r.ProgramName, + connections = r.ConnectionCount, + running = r.RunningCount, + sleeping = r.SleepingCount, + dormant = r.DormantCount, + total_cpu_time_ms = r.TotalCpuTimeMs, + total_logical_reads = r.TotalLogicalReads + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_session_stats", ex); + } + } + + [McpServerTool(Name = "get_active_queries"), Description("Gets active query snapshots captured by sp_WhoIsActive. Shows what queries were running at each collection point: session ID, query text, wait type, CPU time, elapsed time, blocking info, DOP, and memory grants. Use hours_back to look at a specific time window — critical for finding what was running during a CPU spike or blocking event.")] + public static async Task GetActiveQueries( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of data to retrieve. Default 1.")] int hours_back = 1, + [Description("Filter to a specific database.")] string? database_name = null, + [Description("Show only queries involved in blocking (blocking_session_id > 0 or is a head blocker).")] bool blocking_only = false, + [Description("Maximum number of rows to return. Default 50.")] int limit = 50) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + validation = McpHelpers.ValidateTop(limit); + if (validation != null) return validation; + + try + { + var now = DateTime.UtcNow; + var rows = await DarlingSessionReader.GetActiveQueriesAsync( + postgres, resolved.Value.ServerId, now.AddHours(-hours_back), now); + if (rows.Count == 0) + return McpHelpers.Status("empty", "No active query snapshots found in the requested time range."); + + IEnumerable filtered = rows; + + if (!string.IsNullOrEmpty(database_name)) + filtered = filtered.Where(r => (r.DatabaseName ?? "").Equals(database_name, StringComparison.OrdinalIgnoreCase)); + + if (blocking_only) + filtered = filtered.Where(r => r.BlockingSessionId > 0 + || rows.Any(other => other.BlockingSessionId == r.SessionId)); + + var result = filtered.Take(limit).Select(r => new + { + collection_time = r.CollectionTime.ToString("o"), + session_id = r.SessionId, + database_name = r.DatabaseName, + status = r.Status, + cpu_time_ms = r.CpuTimeMs, + elapsed_time_ms = r.TotalElapsedTimeMs, + elapsed_time_formatted = r.ElapsedTimeFormatted, + logical_reads = r.LogicalReads, + reads = r.Reads, + writes = r.Writes, + wait_type = string.IsNullOrEmpty(r.WaitType) ? null : r.WaitType, + wait_time_ms = r.WaitTimeMs > 0 ? r.WaitTimeMs : (long?)null, + blocking_session_id = r.BlockingSessionId > 0 ? r.BlockingSessionId : (int?)null, + dop = r.Dop > 0 ? r.Dop : (int?)null, + parallel_worker_count = r.ParallelWorkerCount > 0 ? r.ParallelWorkerCount : (int?)null, + granted_query_memory_gb = r.GrantedQueryMemoryGb > 0 ? r.GrantedQueryMemoryGb : (double?)null, + transaction_isolation_level = string.IsNullOrEmpty(r.TransactionIsolationLevel) ? null : r.TransactionIsolationLevel, + open_transaction_count = r.OpenTransactionCount > 0 ? r.OpenTransactionCount : (int?)null, + login_name = r.LoginName, + host_name = r.HostName, + program_name = r.ProgramName, + query_text = McpHelpers.Truncate(r.QueryText, 2000) + }).ToList(); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + total_snapshots = rows.Count, + shown = result.Count, + queries = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_active_queries", ex); + } + } + + [McpServerTool(Name = "get_waiting_tasks"), Description("Gets recently captured waiting tasks — queries that were actively waiting on a resource at collection time. Shows session ID, wait type, duration, blocking session, and database. Complements get_wait_stats by showing individual waiting queries rather than aggregated stats.")] + public static async Task GetWaitingTasks( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history. Default 1.")] int hours_back = 1, + [Description("Maximum rows. Default 30.")] int limit = 30) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + validation = McpHelpers.ValidateTop(limit); + if (validation != null) return validation; + + try + { + var now = DateTime.UtcNow; + var rows = await DarlingSessionReader.GetWaitingTasksAsync( + postgres, resolved.Value.ServerId, now.AddHours(-hours_back), now); + if (rows.Count == 0) + return McpHelpers.Status("empty", "No waiting tasks captured in the specified time range."); + + var result = rows.Take(limit).Select(r => new + { + session_id = r.SessionId, + wait_type = r.WaitType, + wait_duration_ms = r.WaitDurationMs, + blocking_session_id = r.BlockingSessionId, + database_name = r.DatabaseName, + resource_description = r.ResourceDescription, + collection_time = r.CollectionTime.ToString("o") + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + tasks = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_waiting_tasks", ex); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs new file mode 100644 index 000000000..e17f41fee --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs @@ -0,0 +1,968 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using ModelContextProtocol.Server; +using Npgsql; +using PerformanceMonitor.Analysis; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Analysis; + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// The six diagnostic-analysis MCP tools — the SAME tool surface Lite and the Dashboard expose +/// (analyze_server, get_analysis_facts, compare_analysis, audit_config, get_analysis_findings, +/// mute_analysis_finding), served over Darling's Postgres store. Each tool body mirrors Lite's +/// McpAnalysisTools field-for-field (same response envelopes, the #1224 miss vocabulary +/// via , the shared / +/// composition and the per-app +/// copy) so an MCP client sees one consistent product across all three SKUs; the two seam +/// differences are the service dependencies — instead of +/// Lite's AnalysisService, and server resolution through the Postgres servers registry +/// () instead of Lite's in-memory ServerManager. +/// A response-shape change here must land in BOTH apps' McpAnalysisTools too, and vice versa. +/// +[McpServerToolType] +public sealed class DarlingMcpTools +{ + [McpServerTool(Name = "analyze_server"), Description("Runs the diagnostic inference engine against a server's collected data. Scores wait stats, blocking, memory, config, and other facts, then traverses a relationship graph to build evidence-backed stories about what's wrong and why. Anomaly detection compares the analysis window against 30-day time-bucketed baselines (hour-of-day x day-of-week) to identify deviations that are unusual for this specific time slot, not just unusual overall. Returns structured findings with severity scores, evidence chains, baseline context for anomalies, and recommended next tools to call. A remediable finding also carries remediation_command: the full copy-paste T-SQL remediation (identical to the viewer card), including a two-sided risk-disclosure comment header on destructive changes; it is advisory only and never executed.")] + public static async Task AnalyzeServer( + DarlingAnalysisService analysisService, + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of data to analyze. Default 4. Longer windows give more stable results but may miss recent spikes.")] int hours_back = 4) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + + try + { + var findings = await analysisService.AnalyzeAsync( + resolved.Value.ServerId, resolved.Value.ServerName, hours_back); + + if (analysisService.InsufficientDataMessage != null) + { + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + status = "insufficient_data", + message = analysisService.InsufficientDataMessage + }, McpHelpers.JsonOptions); + } + + if (findings.Count == 0) + { + /* A successful analysis that found nothing wrong: a true negative ("all clear"), + surfaced with the shared miss vocabulary so callers branch on it uniformly. */ + return McpHelpers.Status( + "empty", + "No significant findings. All metrics are within normal ranges.", + new { analysis_time = analysisService.LastAnalysisTime?.ToString("o") }); + } + + // Correlate-and-focus slice 1 (review §1d): each finding's "what else fired this window". + var coFiredTitles = new List<(string, double)>(findings.Count); + foreach (var wf in findings) + coFiredTitles.Add((FactAdvice.GetForFinding(wf)?.Headline ?? wf.RootFactKey, wf.Severity)); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + status = "findings", + finding_count = findings.Count, + analysis_time = analysisService.LastAnalysisTime?.ToString("o"), + time_range = new + { + start = findings[0].TimeRangeStart?.ToString("o"), + end = findings[0].TimeRangeEnd?.ToString("o") + }, + findings = findings.Select(f => + { + var advice = FactAdvice.GetForFinding(f); + return new + { + severity = Math.Round(f.Severity, 2), + confidence = Math.Round(f.Confidence, 2), + category = f.Category, + root_fact = new { key = f.RootFactKey, value = f.RootFactValue }, + leaf_fact = f.LeafFactKey != null + ? new { key = f.LeafFactKey, value = f.LeafFactValue } + : null, + story_path = f.StoryPath, + story_path_hash = f.StoryPathHash, + fact_count = f.FactCount, + drill_down = f.DrillDown, + next_tools = ToolRecommendations.GetForStoryPath(f.StoryPath), + incident_id = f.IncidentId, + co_fired = CoFiredSummary.OtherTitles(advice?.Headline ?? f.RootFactKey, coFiredTitles), + advice = advice is null ? null : new + { + headline = advice.Headline, + investigation = advice.Investigation, + remediation = advice.Remediation + }, + suggested_remediation_sql = advice?.RemediationTsql, + // The FULL copy-paste remediation command — the SAME text the viewer cards + // render — from the PERSISTED RemediationAction via the shared renderer (all + // seven shapes + the two-sided risk-disclosure comment header on the destructive + // ones). ADDITIVE alongside suggested_remediation_sql (the older 3-shape, + // drill-down-sourced advice-block SQL): this covers all seven shapes and also + // renders on get_analysis_findings, where the drill-down is gone. Null when the + // finding has no remediable action. PRODUCE ONLY — advisory text; the read-only + // MCP never executes it. + remediation_command = FactRemediation.RenderCopyPasteCommand(f.Remediation), + // B3 Phase 3 (§6): two-sided risk DISCLOSURE for a destructive + // remediation, read-only (like Lite, Darling has no Apply path; its + // RCSI fields are null/0 so the inaction side shows the weak-case baseline). + destructive_risk_disclosure = advice?.Risks is null ? null : new + { + risks_of_changing = advice.Risks.RisksOfChanging.Select(r => r.Text).ToArray(), + risks_of_not_changing = advice.Risks.RisksOfNotChanging.Select(r => r.Text).ToArray() + } + }; + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("analyze_server", ex); + } + } + + [McpServerTool(Name = "get_analysis_facts"), Description("Exposes the raw scored facts from the inference engine's collect+score pipeline WITHOUT graph traversal. Shows every observation the engine sees: wait stats as fraction-of-period, blocking rates, config settings, memory stats, plus base severity, final severity after amplifiers, and which amplifiers matched. Use this to understand exactly what the engine is working with, or to investigate facts that didn't reach the severity threshold for findings.")] + public static async Task GetAnalysisFacts( + DarlingAnalysisService analysisService, + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of data to analyze. Default 4.")] int hours_back = 4, + [Description("Filter to a specific source category: waits, blocking, config, memory. Omit for all.")] string? source = null, + [Description("Minimum severity to include. Default 0 (all facts). Use 0.5 to see only significant facts.")] double min_severity = 0) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + + try + { + var facts = await analysisService.CollectAndScoreFactsAsync( + resolved.Value.ServerId, resolved.Value.ServerName, hours_back); + + if (facts.Count == 0) + { + /* No scored facts means the underlying collectors produced nothing for the window — + not retrievable now rather than an all-clear (mirrors get_perfmon_trend's empty case). */ + return McpHelpers.Status( + "unavailable", + "No facts collected. The collector may not have run yet, or no data exists in the requested time range."); + } + + var filtered = facts.AsEnumerable(); + if (source != null) + filtered = filtered.Where(f => f.Source.Equals(source, StringComparison.OrdinalIgnoreCase)); + if (min_severity > 0) + filtered = filtered.Where(f => f.Severity >= min_severity); + + var result = filtered + .OrderByDescending(f => f.Severity) + .Select(f => new + { + source = f.Source, + key = f.Key, + value = Math.Round(f.Value, 6), + base_severity = Math.Round(f.BaseSeverity, 4), + severity = Math.Round(f.Severity, 4), + metadata = f.Metadata.ToDictionary( + m => m.Key, + m => Math.Round(m.Value, 2)), + amplifiers = f.AmplifierResults.Count > 0 + ? f.AmplifierResults.Select(a => new + { + description = a.Description, + matched = a.Matched, + boost = a.Boost + }) + : null + }) + .ToList(); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + total_facts = facts.Count, + shown = result.Count, + filters = new { source, min_severity }, + facts = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_analysis_facts", ex); + } + } + + [McpServerTool(Name = "compare_analysis"), Description("Compares two time periods by running the inference engine's fact collection and scoring on each, then showing what changed. Use this to compare peak vs off-peak, before vs after a change, or yesterday vs today. Returns facts from both periods side-by-side with severity deltas. Note: for routine anomaly detection, use analyze_server instead — it automatically compares against 30-day time-bucketed baselines (hour-of-day x day-of-week). This tool is for explicit window-to-window comparisons.")] + public static async Task CompareAnalysis( + DarlingAnalysisService analysisService, + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours back for the comparison (recent) period. Default 4.")] int hours_back = 4, + [Description("Hours back for the baseline period start, measured from now. Default 28 (yesterday same time). The baseline period will be the same duration as the comparison period.")] int baseline_hours_back = 28) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + validation = McpHelpers.ValidateHoursBack(baseline_hours_back); + if (validation != null) return validation; + + if (baseline_hours_back <= hours_back) + return "baseline_hours_back must be greater than hours_back. The baseline period must be earlier than the comparison period."; + + try + { + var now = DateTime.UtcNow; + var comparisonEnd = now; + var comparisonStart = now.AddHours(-hours_back); + var baselineEnd = now.AddHours(-baseline_hours_back + hours_back); + var baselineStart = now.AddHours(-baseline_hours_back); + + var (baselineFacts, comparisonFacts) = await analysisService.ComparePeriodsAsync( + resolved.Value.ServerId, resolved.Value.ServerName, + baselineStart, baselineEnd, + comparisonStart, comparisonEnd); + + var baselineByKey = baselineFacts.ToFactLookup(); + var comparisonByKey = comparisonFacts.ToFactLookup(); + var allKeys = baselineByKey.Keys.Union(comparisonByKey.Keys).ToHashSet(); + + var comparisons = allKeys + .Select(key => + { + var baseline = baselineByKey.GetValueOrDefault(key); + var comparison = comparisonByKey.GetValueOrDefault(key); + var severityDelta = (comparison?.Severity ?? 0) - (baseline?.Severity ?? 0); + + return new + { + key, + source = baseline?.Source ?? comparison?.Source ?? "unknown", + baseline_value = baseline != null ? Math.Round(baseline.Value, 6) : (double?)null, + comparison_value = comparison != null ? Math.Round(comparison.Value, 6) : (double?)null, + baseline_severity = baseline != null ? Math.Round(baseline.Severity, 4) : (double?)null, + comparison_severity = comparison != null ? Math.Round(comparison.Severity, 4) : (double?)null, + severity_delta = Math.Round(severityDelta, 4), + status = severityDelta > 0.1 ? "worse" : severityDelta < -0.1 ? "better" : "stable" + }; + }) + .OrderByDescending(c => Math.Abs(c.severity_delta)) + .ToList(); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + baseline = new + { + start = baselineStart.ToString("o"), + end = baselineEnd.ToString("o"), + fact_count = baselineFacts.Count + }, + comparison = new + { + start = comparisonStart.ToString("o"), + end = comparisonEnd.ToString("o"), + fact_count = comparisonFacts.Count + }, + summary = new + { + worse = comparisons.Count(c => c.status == "worse"), + better = comparisons.Count(c => c.status == "better"), + stable = comparisons.Count(c => c.status == "stable"), + new_issues = comparisons.Count(c => c.baseline_severity == null && c.comparison_severity > 0), + resolved_issues = comparisons.Count(c => c.baseline_severity > 0 && c.comparison_severity == null) + }, + facts = comparisons + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("compare_analysis", ex); + } + } + + [McpServerTool(Name = "audit_config"), Description("Evaluates SQL Server configuration settings against best practices, accounting for edition (Standard vs Enterprise) and server resources. Checks CTFP, MAXDOP, max server memory, and max worker threads. Returns specific recommendations with current values, recommended values, and reasoning.")] + public static async Task AuditConfig( + DarlingAnalysisService analysisService, + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + try + { + var facts = await analysisService.CollectAndScoreFactsAsync( + resolved.Value.ServerId, resolved.Value.ServerName, 1); + + var factsByKey = facts.ToFactLookup(); + + var edition = factsByKey.TryGetValue("SERVER_EDITION", out var edFact) ? (int)edFact.Value : 0; + var totalMemoryMb = factsByKey.TryGetValue("MEMORY_TOTAL_PHYSICAL_MB", out var memFact) ? memFact.Value : 0; + var totalDbSizeMb = factsByKey.TryGetValue("DATABASE_TOTAL_SIZE_MB", out var dbFact) ? dbFact.Value : 0; + + // Edition names: 3 = Enterprise, 2 = Standard, 4 = Express + var editionName = edition switch + { + 1 => "Personal", + 2 => "Standard", + 3 => "Enterprise", + 4 => "Express", + 5 => "Azure SQL Database", + 6 => "Azure SQL Managed Instance", + 8 => "Azure SQL Managed Instance (HADR)", + 9 => "Azure SQL Edge", + 11 => "Azure Synapse serverless", + _ => "Unknown" + }; + var coresPerSocket = factsByKey.TryGetValue("SERVER_HARDWARE", out var hwFact) + && hwFact.Metadata.TryGetValue("cores_per_socket", out var cps) ? (int)cps : 0; + + var recommendations = new List(); + + // CTFP audit + if (factsByKey.TryGetValue("CONFIG_CTFP", out var ctfpFact)) + { + var ctfp = (int)ctfpFact.Value; + + if (ctfp <= 5) + { + recommendations.Add(new("cost threshold for parallelism", ctfp, 50, "warning", + $"CTFP is at the default ({ctfp}). Most OLTP workloads benefit from 50. " + + "A low CTFP causes excessive parallelism for trivial queries, wasting worker threads and causing CXPACKET waits.")); + } + else if (ctfp < 25) + { + recommendations.Add(new("cost threshold for parallelism", ctfp, 50, "review", + $"CTFP ({ctfp}) is low. Consider raising to 50 unless you have a specific reason for this value.")); + } + else if (ctfp > 100) + { + recommendations.Add(new("cost threshold for parallelism", ctfp, 50, "review", + $"CTFP ({ctfp}) is unusually high. This forces serial execution for many queries that would benefit from parallelism. " + + "Review whether this was set intentionally. Consider 50 as a starting point.")); + } + else + { + recommendations.Add(new("cost threshold for parallelism", ctfp, ctfp, "ok", + $"CTFP ({ctfp}) is in a reasonable range.")); + } + } + + // MAXDOP audit — topology-based (min(cores-per-socket, 8)), NOT edition-based. + if (factsByKey.TryGetValue("CONFIG_MAXDOP", out var maxdopFact)) + { + var maxdop = (int)maxdopFact.Value; + var recommended = (int)FactRemediation.RecommendedMaxdop(coresPerSocket); + + if (maxdop == 0) + { + recommendations.Add(new("max degree of parallelism", maxdop, recommended, "warning", + $"MAXDOP is 0 (unlimited). This lets one query fan out across all schedulers, " + + $"leading to CXPACKET waits and thread exhaustion under load. Microsoft's guidance is " + + $"topology-based: keep MAXDOP at or under the logical processors in a single NUMA node, capped at 8. " + + $"Start with {recommended} (this server's cores-per-socket, capped at 8) and adjust to the workload.")); + } + else if (maxdop == 1 && recommended > 1) + { + recommendations.Add(new("max degree of parallelism", maxdop, recommended, "review", + $"MAXDOP 1 forces every query serial. Large analytical queries, index rebuilds, and DBCC operations " + + $"will be significantly slower. Consider {recommended} unless this was set to fix a specific parallelism problem.")); + } + else if (maxdop > recommended) + { + recommendations.Add(new("max degree of parallelism", maxdop, recommended, "review", + $"MAXDOP {maxdop} is above the topology-based guidance of {recommended} " + + $"(logical processors in a single NUMA node, capped at 8). Review whether queries here genuinely " + + $"benefit from the higher degree, or lower it to {recommended}.")); + } + else + { + recommendations.Add(new("max degree of parallelism", maxdop, maxdop, "ok", + $"MAXDOP {maxdop} is within the topology-based guidance (≤ {recommended}).")); + } + } + + // Max memory audit + if (factsByKey.TryGetValue("CONFIG_MAX_MEMORY_MB", out var maxMemFact)) + { + var maxMemory = (int)maxMemFact.Value; + + if (maxMemory == 2147483647) // Default — unlimited + { + if (totalMemoryMb > 0) + { + var osReserve = Math.Max(4096, totalMemoryMb * 0.10); + var suggested = (int)(totalMemoryMb - osReserve); + recommendations.Add(new("max server memory (MB)", maxMemory, suggested, "warning", + $"Max server memory is at the default (unlimited). SQL Server will consume all available RAM, " + + $"starving the OS and other processes. With {totalMemoryMb:N0} MB physical RAM, set max server memory to " + + $"~{suggested:N0} MB (leaving {osReserve:N0} MB for the OS).")); + } + else + { + recommendations.Add(new("max server memory (MB)", maxMemory, maxMemory, "warning", + "Max server memory is at the default (unlimited). SQL Server will consume all available RAM. " + + "Set this to total physical memory minus 4 GB (or 10%, whichever is larger) to leave room for the OS.")); + } + } + else if (totalMemoryMb > 0) + { + var ratio = maxMemory / totalMemoryMb; + var osReserve = Math.Max(4096, totalMemoryMb * 0.10); + var suggested = (int)(totalMemoryMb - osReserve); + + if (ratio > 0.95) + { + recommendations.Add(new("max server memory (MB)", maxMemory, suggested, "review", + $"Max server memory ({maxMemory:N0} MB) is {ratio:P0} of physical RAM ({totalMemoryMb:N0} MB). " + + $"Consider reducing to ~{suggested:N0} MB to leave room for the OS.")); + } + else if (ratio < 0.50 && totalMemoryMb > 8192) + { + recommendations.Add(new("max server memory (MB)", maxMemory, suggested, "review", + $"Max server memory ({maxMemory:N0} MB) is only {ratio:P0} of physical RAM ({totalMemoryMb:N0} MB). " + + $"SQL Server may be under-utilizing available memory. Consider raising to ~{suggested:N0} MB unless other " + + "applications need the remaining RAM.")); + } + else + { + recommendations.Add(new("max server memory (MB)", maxMemory, maxMemory, "ok", + $"Max server memory ({maxMemory:N0} MB) looks reasonable for {totalMemoryMb:N0} MB physical RAM.")); + } + } + else + { + recommendations.Add(new("max server memory (MB)", maxMemory, maxMemory, "ok", + $"Max server memory is set to {maxMemory:N0} MB.")); + } + } + + // Max worker threads audit + if (factsByKey.TryGetValue("CONFIG_MAX_WORKER_THREADS", out var mwtFact)) + { + var mwt = (int)mwtFact.Value; + + if (mwt == 0) + { + recommendations.Add(new("max worker threads", mwt, 0, "ok", + "Max worker threads is 0 (auto-configured by SQL Server). This is the recommended setting " + + "for most workloads. SQL Server calculates the optimal value based on the number of processors.")); + } + else if (mwt < 256) + { + recommendations.Add(new("max worker threads", mwt, 0, "review", + $"Max worker threads is set to {mwt}, which is low. Unless this was set to diagnose a specific " + + "thread exhaustion issue, consider resetting to 0 (auto) and addressing the root cause of thread pressure instead.")); + } + else + { + recommendations.Add(new("max worker threads", mwt, 0, "ok", + $"Max worker threads is set to {mwt}. If this was explicitly configured, ensure it was for a documented reason.")); + } + } + + if (recommendations.Count == 0) + { + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + status = "no_config_data", + message = "No configuration data found. The config collector may not have run yet." + }, McpHelpers.JsonOptions); + } + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + edition = editionName, + total_physical_memory_mb = totalMemoryMb > 0 ? totalMemoryMb : (double?)null, + total_database_size_mb = totalDbSizeMb > 0 ? totalDbSizeMb : (double?)null, + summary = new + { + settings_checked = recommendations.Count, + warnings = recommendations.Count(r => r.Status == "warning"), + needs_review = recommendations.Count(r => r.Status == "review") + }, + recommendations = recommendations.Select(r => new + { + setting = r.Setting, + current_value = r.CurrentValue, + suggested_value = r.SuggestedValue, + status = r.Status, + recommendation = r.Recommendation + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("audit_config", ex); + } + } + + [McpServerTool(Name = "get_analysis_findings"), Description("Gets persisted findings from previous analysis runs without running a new analysis. Use this to review historical findings or check if anything has changed since the last analysis. A remediable finding carries remediation_command: the full copy-paste T-SQL remediation (identical to the viewer card), rendered from the finding's persisted action and including a two-sided risk-disclosure comment header on destructive changes; it is advisory only and never executed.")] + public static async Task GetAnalysisFindings( + DarlingAnalysisService analysisService, + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of finding history to retrieve. Default 24.")] int hours_back = 24) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + + try + { + var findings = await analysisService.GetRecentFindingsAsync( + resolved.Value.ServerId, hours_back); + + if (findings.Count == 0) + { + return McpHelpers.Status( + "empty", + "No findings in the requested time range. Run analyze_server to generate new findings."); + } + + // Correlate-and-focus slice 1 (review §1d): "what else fired", scoped per analysis run + // (this read can span multiple runs, unlike analyze_server's single run). + var coFiredByRun = new Dictionary>(); + foreach (var wf in findings) + { + if (!coFiredByRun.TryGetValue(wf.AnalysisTime, out var list)) + coFiredByRun[wf.AnalysisTime] = list = new List<(string, double)>(); + list.Add((FactAdvice.GetComposedForFinding(wf)?.Headline ?? wf.RootFactKey, wf.Severity)); + } + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + finding_count = findings.Count, + findings = findings.Select(f => + { + // Persisted findings carry no drill-down (it is ephemeral — + // see AnalysisModels.cs), so generate advice prose only. + // The prose IS value-stated: GetComposedForFinding reads the + // value-bearing advice (current MAXDOP/CTFP/etc.) frozen into + // StoryText at analysis time, falling back to the static block. + // suggested_remediation_sql STAYS omitted here: it is built from + // the drill-down (gone on read-back), so it would always be null. + // The FULL copy-paste command below is rendered from the PERSISTED + // RemediationAction instead — which DOES survive read-back — so a + // triaging agent gets the same runnable command a human sees on the + // card without re-running analyze_server. + var advice = FactAdvice.GetComposedForFinding(f); + return new + { + finding_id = f.FindingId, + analysis_time = f.AnalysisTime.ToString("o"), + severity = Math.Round(f.Severity, 2), + confidence = Math.Round(f.Confidence, 2), + category = f.Category, + root_fact = new { key = f.RootFactKey, value = f.RootFactValue }, + leaf_fact = f.LeafFactKey != null + ? new { key = f.LeafFactKey, value = f.LeafFactValue } + : null, + story_path = f.StoryPath, + story_path_hash = f.StoryPathHash, + fact_count = f.FactCount, + incident_id = f.IncidentId, + co_fired = CoFiredSummary.OtherTitles(advice?.Headline ?? f.RootFactKey, coFiredByRun[f.AnalysisTime]), + time_range = new + { + start = f.TimeRangeStart?.ToString("o"), + end = f.TimeRangeEnd?.ToString("o") + }, + advice = advice is null ? null : new + { + headline = advice.Headline, + investigation = advice.Investigation, + remediation = advice.Remediation + }, + // The SAME copy-paste remediation command the viewer cards render, from the + // persisted action via the shared renderer (all seven shapes + the two-sided + // risk-disclosure comment header on the destructive ones). Null when the finding + // has no remediable action. PRODUCE ONLY — the read-only MCP never executes it. + remediation_command = FactRemediation.RenderCopyPasteCommand(f.Remediation) + }; + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_analysis_findings", ex); + } + } + + [McpServerTool(Name = "mute_analysis_finding"), Description("Mutes a finding pattern so it won't appear in future analysis runs. Use the story_path_hash from analyze_server or get_analysis_findings output. Muting is per-pattern, not per-occurrence — the same diagnostic chain won't be reported again until unmuted.")] + public static async Task MuteAnalysisFinding( + DarlingAnalysisService analysisService, + NpgsqlDataSource postgres, + [Description("The story_path_hash from the finding to mute.")] string story_path_hash, + [Description("Server name. If omitted, mutes across all servers.")] string? server_name = null, + [Description("Optional reason for muting.")] string? reason = null) + { + try + { + int? serverId = null; + if (server_name != null) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + serverId = resolved.Value.ServerId; + } + + var finding = new AnalysisFinding + { + ServerId = serverId ?? 0, + StoryPathHash = story_path_hash, + StoryPath = story_path_hash + }; + + await analysisService.MuteFindingAsync(finding, reason); + + return JsonSerializer.Serialize(new + { + status = "muted", + story_path_hash, + server = server_name ?? "(all servers)", + reason + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("mute_analysis_finding", ex); + } + } +} + +/// +/// Maps fact keys to recommended MCP tools for further investigation. +/// Used by analyze_server to tell the AI client what to call next. +/// Verbatim per-app copy of Lite's table (the Dashboard carries one too): the named tools are +/// the PRODUCT's data-tool surface — and Darling's own MCP server now hosts them itself (the +/// analysis, plan-analysis, and ~60 stored data-read tools), so these recommendations point the +/// client at tools on THIS server, exactly as documented in DarlingMcpInstructions. +/// +internal static class ToolRecommendations +{ + private static readonly Dictionary> ByFactKey = new() + { + ["SOS_SCHEDULER_YIELD"] = + [ + new("get_cpu_utilization", "Check SQL Server vs other process CPU usage over time"), + new("get_top_queries_by_cpu", "Find the most CPU-expensive queries"), + new("get_perfmon_trend", "Check batch requests/sec trend", new() { ["counter_name"] = "Batch Requests/sec" }) + ], + ["CXPACKET"] = + [ + new("get_top_queries_by_cpu", "Find parallel queries consuming CPU", new() { ["parallel_only"] = "true" }), + new("get_wait_trend", "Track parallelism wait trend over time", new() { ["wait_type"] = "CXPACKET" }), + new("audit_config", "Check CTFP and MAXDOP settings") + ], + ["THREADPOOL"] = + [ + new("get_waiting_tasks", "See what's actively waiting for worker threads"), + new("get_top_queries_by_cpu", "Find queries consuming the most resources"), + new("get_blocked_process_reports", "Check if blocking is holding worker threads") + ], + ["PAGEIOLATCH_SH"] = + [ + new("get_file_io_stats", "Check I/O latency per database file"), + new("get_file_io_trend", "Track I/O latency trend"), + new("get_memory_stats", "Check buffer pool and memory pressure"), + new("get_memory_grants", "Check for memory grant pressure competing with buffer pool") + ], + ["PAGEIOLATCH_EX"] = + [ + new("get_file_io_stats", "Check I/O latency per database file"), + new("get_file_io_trend", "Track I/O latency trend"), + new("get_memory_stats", "Check buffer pool and memory pressure"), + new("get_tempdb_trend", "Check whether tempdb I/O is driving the EX-mode waits") + ], + ["RESOURCE_SEMAPHORE"] = + [ + new("get_memory_grants", "Check active/pending memory grants"), + new("get_memory_stats", "Check overall memory allocation"), + new("get_top_queries_by_cpu", "Find queries requesting large memory grants") + ], + ["WRITELOG"] = + [ + new("get_file_io_stats", "Check transaction log file latency"), + new("get_file_io_trend", "Track log I/O latency over time"), + new("get_perfmon_trend", "Check Transactions/sec to see commit rate driving log flush pressure", new() { ["counter_name"] = "Transactions/sec" }) + ], + ["LCK"] = + [ + new("get_blocked_process_reports", "Get detailed blocking event reports"), + new("get_blocking_trend", "Track blocking frequency over time"), + new("get_waiting_tasks", "See currently waiting tasks with lock details") + ], + ["LCK_M_S"] = + [ + new("get_blocked_process_reports", "Get reader/writer blocking details"), + new("get_blocking_trend", "Track blocking frequency over time") + ], + ["LCK_M_IS"] = + [ + new("get_blocked_process_reports", "Get reader/writer blocking details"), + new("get_blocking_trend", "Track blocking frequency over time") + ], + ["BLOCKING_EVENTS"] = + [ + new("get_blocked_process_reports", "Get detailed blocking reports with full query text"), + new("get_blocking_trend", "Track blocking event frequency over time"), + new("get_deadlocks", "Check if blocking is escalating to deadlocks") + ], + ["DEADLOCKS"] = + [ + new("get_deadlocks", "Get recent deadlock events with victim info"), + new("get_deadlock_detail", "Get full deadlock graph XML for deep analysis"), + new("get_deadlock_trend", "Track deadlock frequency over time") + ], + ["SCH_M"] = + [ + new("get_waiting_tasks", "See what's waiting on schema locks"), + new("get_blocked_process_reports", "Check if DDL operations are causing blocking"), + new("get_running_jobs", "See whether maintenance jobs (index rebuilds, stats updates) are taking schema-modification locks") + ], + ["CPU_SQL_PERCENT"] = + [ + new("get_cpu_utilization", "See CPU trend over time"), + new("get_top_queries_by_cpu", "Find queries consuming the most CPU"), + new("get_perfmon_trend", "Check batch requests/sec for throughput context", new() { ["counter_name"] = "Batch Requests/sec" }) + ], + ["CPU_SPIKE"] = + [ + new("get_cpu_utilization", "See CPU trend to identify when the spike occurred"), + new("get_top_queries_by_cpu", "Find queries that drove the CPU spike"), + new("get_query_duration_trend", "Check if query durations spiked at the same time") + ], + ["IO_READ_LATENCY_MS"] = + [ + new("get_file_io_stats", "Check per-file read latency"), + new("get_file_io_trend", "Track read latency over time"), + new("get_memory_stats", "Check if buffer pool is undersized") + ], + ["IO_WRITE_LATENCY_MS"] = + [ + new("get_file_io_stats", "Check per-file write latency"), + new("get_file_io_trend", "Track write latency over time") + ], + ["TEMPDB_USAGE"] = + [ + new("get_tempdb_trend", "Track TempDB usage over time"), + new("get_top_queries_by_cpu", "Find queries that may be spilling to TempDB") + ], + ["MEMORY_GRANT_PENDING"] = + [ + new("get_memory_grants", "Check active/pending memory grants"), + new("get_memory_stats", "Check overall memory allocation"), + new("get_top_queries_by_cpu", "Find queries requesting large grants") + ], + ["QUERY_SPILLS"] = + [ + new("get_top_queries_by_cpu", "Find queries with spills"), + new("get_memory_grants", "Check memory grant pressure"), + new("get_tempdb_trend", "Check TempDB impact from spills") + ], + ["QUERY_HIGH_DOP"] = + [ + new("get_top_queries_by_cpu", "Find high-DOP queries", new() { ["parallel_only"] = "true" }), + new("audit_config", "Check CTFP and MAXDOP settings") + ], + ["PARAMETER_SENSITIVITY"] = + [ + new("get_top_queries_by_cpu", "Find the sensitive query in the plan cache and see its current cached parameters"), + new("analyze_query_plan", "Examine the plan for the operators driving the runtime variance (seek vs scan, grant size, join type)"), + new("get_query_trend", "Confirm the bimodal duration pattern across executions over time"), + new("get_memory_grants", "Check whether the bad-parameter executions are also blowing up memory grants") + ], + ["PLAN_REGRESSION"] = + [ + new("analyze_query_store_plan", "Compare the regressed plan against the prior plan to see what the optimizer changed"), + new("get_query_trend", "Confirm the regression timing and that the new plan is consistently worse"), + new("get_query_store_top", "Pull the full Query Store entry including plan_id and forced-plan history before considering a force") + ], + ["LATCH_EX"] = + [ + new("get_tempdb_trend", "Check TempDB for allocation contention"), + new("get_top_queries_by_cpu", "Find queries causing latch contention"), + new("get_wait_trend", "Track latch contention trend", new() { ["wait_type"] = "LATCH_EX" }) + ], + ["LATCH_SH"] = + [ + new("get_tempdb_trend", "Check TempDB for allocation contention"), + new("get_wait_trend", "Track latch contention trend", new() { ["wait_type"] = "LATCH_SH" }) + ], + ["DB_CONFIG"] = + [ + new("audit_config", "Check server-level configuration"), + new("get_blocked_process_reports", "Check if RCSI-off databases have blocking") + ], + ["FILE_AUTOGROWTH_PERCENT"] = + [ + new("get_database_sizes", "See per-file sizes and autogrowth settings"), + new("get_file_io_stats", "Check per-file growth and latency") + ], + ["RUNNING_JOBS"] = + [ + new("get_running_jobs", "See currently running jobs with duration vs historical"), + new("get_cpu_utilization", "Check if long-running jobs are consuming CPU") + ], + ["ANOMALY_CPU"] = + [ + new("get_cpu_utilization", "See CPU trend to identify when the spike occurred"), + new("get_active_queries", "Find what queries were running during the spike"), + new("get_top_queries_by_cpu", "Find the most CPU-expensive queries in the period") + ], + ["ANOMALY_WAIT"] = + [ + new("get_wait_stats", "See full wait stats breakdown"), + new("get_wait_trend", "Track the anomalous wait type over time"), + new("compare_analysis", "Compare current vs baseline to see what changed") + ], + ["ANOMALY_BLOCKING"] = + [ + new("get_blocked_process_reports", "Get detailed blocking event reports"), + new("get_deadlocks", "Get recent deadlock events"), + new("get_blocking_trend", "Track blocking frequency over time") + ], + ["ANOMALY_IO"] = + [ + new("get_file_io_stats", "Check per-file I/O latency"), + new("get_file_io_trend", "Track I/O latency over time"), + new("get_memory_stats", "Check if buffer pool is undersized") + ], + ["ANOMALY_SESSION_SPIKE"] = + [ + new("get_session_stats", "See which application is driving the session-count spike"), + new("get_active_queries", "Find what those sessions were doing at the spike"), + new("get_waiting_tasks", "Check whether the new sessions are piling up on a shared wait") + ], + ["ANOMALY_QUERY_DURATION"] = + [ + new("get_query_duration_trend", "Confirm the duration shift across the analysis window"), + new("get_top_queries_by_cpu", "Find the queries whose runtime moved the average"), + new("analyze_query_plan", "Examine the plan for the queries that slowed down") + ], + ["ANOMALY_MEMORY_PRESSURE"] = + [ + new("get_memory_stats", "See current memory allocation and target vs total"), + new("get_memory_clerks", "Find which clerks are growing"), + new("get_memory_pressure_events", "Pull the RING_BUFFER_RESOURCE_MONITOR notifications driving the anomaly"), + new("get_memory_grants", "Check whether query grants are competing with buffer pool") + ], + ["ANOMALY_BATCH_REQUESTS"] = + [ + new("get_perfmon_trend", "Confirm the batch-rate change across the window", new() { ["counter_name"] = "Batch Requests/sec" }), + new("get_top_queries_by_cpu", "Find which queries account for the new batch volume"), + new("get_active_queries", "See what's actually running at the elevated rate") + ], + ["BAD_ACTOR"] = + [ + new("get_top_queries_by_cpu", "See full query stats for this query"), + new("analyze_query_plan", "Analyze the execution plan for optimization opportunities"), + new("get_query_trend", "Track this query's performance over time") + ], + ["DISK_SPACE"] = + [ + new("get_file_io_stats", "Check per-file sizes and I/O"), + new("get_tempdb_trend", "Check TempDB growth on the volume") + ] + }; + + /// + /// Returns tool recommendations for all fact keys in a story path. + /// Deduplicates across the path so each tool appears at most once. + /// + public static List GetForStoryPath(string storyPath) + { + var factKeys = storyPath.Split(" → ", StringSplitOptions.RemoveEmptyEntries); + var seen = new HashSet(); + var result = new List(); + + foreach (var key in factKeys) + { + if (!ByFactKey.TryGetValue(key, out var recommendations)) + { + // Handle dynamic keys by checking prefix + if (key.StartsWith("BAD_ACTOR_", StringComparison.OrdinalIgnoreCase)) + ByFactKey.TryGetValue("BAD_ACTOR", out recommendations); + else if (key.StartsWith("ANOMALY_CPU", StringComparison.OrdinalIgnoreCase)) + ByFactKey.TryGetValue("ANOMALY_CPU", out recommendations); + else if (key.StartsWith("ANOMALY_WAIT_", StringComparison.OrdinalIgnoreCase)) + ByFactKey.TryGetValue("ANOMALY_WAIT", out recommendations); + else if (key.StartsWith("ANOMALY_BLOCKING", StringComparison.OrdinalIgnoreCase) || key.StartsWith("ANOMALY_DEADLOCK", StringComparison.OrdinalIgnoreCase)) + ByFactKey.TryGetValue("ANOMALY_BLOCKING", out recommendations); + else if (key.StartsWith("ANOMALY_READ", StringComparison.OrdinalIgnoreCase) || key.StartsWith("ANOMALY_WRITE", StringComparison.OrdinalIgnoreCase)) + ByFactKey.TryGetValue("ANOMALY_IO", out recommendations); + if (recommendations == null) continue; + } + + foreach (var rec in recommendations) + { + if (!seen.Add(rec.Tool)) continue; + + if (rec.SuggestedParams != null && rec.SuggestedParams.Count > 0) + { + result.Add(new + { + tool = rec.Tool, + reason = rec.Reason, + suggested_params = rec.SuggestedParams + }); + } + else + { + result.Add(new + { + tool = rec.Tool, + reason = rec.Reason + }); + } + } + } + + return result; + } + +} + +internal record ToolRecommendation( + string Tool, + string Reason, + Dictionary? SuggestedParams = null); + +internal record ConfigRecommendation( + string Setting, + int CurrentValue, + int SuggestedValue, + string Status, + string Recommendation); diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs new file mode 100644 index 000000000..8fdcd445c --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs @@ -0,0 +1,303 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.ComponentModel; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using ModelContextProtocol.Server; +using Npgsql; +using PerformanceMonitor.Common; + +#pragma warning disable CA1707 // MCP tools use snake_case naming convention + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// The windowed-trend data-read MCP tools — get_memory_trend / get_perfmon_trend / get_file_io_trend / +/// get_query_trend / get_query_duration_trend — served over Darling's Postgres store. These are the trend +/// siblings of the merged core data-read tools (): each is a per-second / +/// per-collection time-series over the respective collected table, the SAME shape a client already sees on +/// Lite / the Dashboard. Every tool body mirrors LITE's Mcp*Tools trend tools field-for-field +/// (get_query_duration_trend has no Dashboard twin; the other four names + params match the Dashboard, the +/// shape follows Lite where the SKUs diverge — the same rule follows), so +/// an MCP client sees one consistent product across all three SKUs. +/// +/// +/// Reads flow through — STORED reads (no live monitored-server hit), +/// windowed BOTH-sides on the naive-UTC collection_time, byte-identical to the viewer's proven +/// chart reads. get_perfmon_trend reproduces Lite's miss vocabulary (the intentionally-uncollected Page +/// Life Expectancy special-case + the collected-counters hint); get_query_trend reproduces Lite's per-key +/// "empty" miss; the three unkeyed trends return the #1224 "unavailable" miss on an empty window, matching +/// the merged get_tempdb_trend sibling. A response-shape change here must land in Lite's Mcp*Tools too, and +/// vice versa. +/// +/// +[McpServerToolType] +public sealed class DarlingMcpTrendTools +{ + [McpServerTool(Name = "get_memory_trend"), Description("Gets memory usage trend over time: total server memory, target memory, buffer pool, plan cache, and granted memory. Useful for identifying memory growth patterns or pressure periods.")] + public static async Task GetMemoryTrend( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history. Default 24.")] int hours_back = 24) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + + try + { + var now = DateTime.UtcNow; + var points = await DarlingTrendReader.GetMemoryTrendAsync(postgres, resolved.Value.ServerId, now.AddHours(-hours_back), now); + if (points.Count == 0) + return McpHelpers.Status("unavailable", "No memory trend data available."); + + var result = points.Select(p => new + { + time = p.CollectionTime.ToString("o"), + total_server_memory_mb = p.TotalServerMemoryMb, + target_server_memory_mb = p.TargetServerMemoryMb, + buffer_pool_mb = p.BufferPoolMb, + plan_cache_mb = p.PlanCacheMb, + /* Lite carries total_granted_mb on its MemoryTrendPoint but GetMemoryTrendAsync (a + memory_stats-only read) leaves it unset — the grant overlay is a separate chart series. + Reproduced here as the same 0 placeholder for field-for-field parity with Lite's tool. */ + total_granted_mb = 0.0 + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + trend = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_memory_trend", ex); + } + } + + [McpServerTool(Name = "get_perfmon_trend"), Description("Gets a time-series trend for a specific performance counter. Use get_perfmon_stats first to see available counter names.")] + public static async Task GetPerfmonTrend( + NpgsqlDataSource postgres, + [Description("The exact counter name, e.g. 'Batch Requests/sec'.")] string counter_name, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history. Default 24.")] int hours_back = 24) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + + try + { + var now = DateTime.UtcNow; + var start = now.AddHours(-hours_back); + var points = await DarlingTrendReader.GetPerfmonTrendAsync(postgres, resolved.Value.ServerId, counter_name, start, now); + if (points.Count == 0) + { + /* No points can mean three different things to a caller. Distinguish them so an LLM + doesn't read a bad counter name as "this metric looks fine" — Lite's get_perfmon_trend + miss vocabulary. */ + var collected = await DarlingTrendReader.GetDistinctPerfmonCountersAsync(postgres, resolved.Value.ServerId, start, now); + + /* Page Life Expectancy is the counter people reach for by habit; it is intentionally + not collected, so an empty trend would otherwise be misread as "PLE looks fine." */ + if (IsPageLifeExpectancy(counter_name)) + return McpHelpers.Status( + "not_collected", + $"No trend data for counter '{counter_name}'. Page Life Expectancy is a legacy metric and is intentionally not collected. " + + "Use get_memory_stats for buffer pool / memory pressure instead.", + new { collected_counters = collected }); + + /* Nothing collected at all for this server in the window: the collector likely hasn't + produced perfmon data yet (delta counters need two cycles). Not retrievable now. */ + if (collected.Count == 0) + return McpHelpers.Status( + "unavailable", + $"No trend data for counter '{counter_name}'. No perfmon counters have been collected for this server in the last {hours_back}h yet " + + "(the collector may not have run, or delta counters need a second collection cycle)."); + + /* Other counters exist but not this one: the name is almost certainly wrong. Hand back + the collected names so the caller can correct it. */ + return McpHelpers.Status( + "not_collected", + $"No trend data for counter '{counter_name}'. It may not be a counter this server collects — see hints.collected_counters for the {collected.Count} that are.", + new { collected_counters = collected }); + } + + var result = points.Select(p => new + { + time = p.CollectionTime.ToString("o"), + value = p.Value, + delta_value = p.DeltaValue + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + counter_name, + hours_back, + trend = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_perfmon_trend", ex); + } + } + + [McpServerTool(Name = "get_file_io_trend"), Description("Gets I/O latency trend over time per database, useful for spotting degradation in storage performance.")] + public static async Task GetFileIoTrend( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history. Default 24.")] int hours_back = 24) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + + try + { + var now = DateTime.UtcNow; + var points = await DarlingTrendReader.GetFileIoLatencyTrendAsync(postgres, resolved.Value.ServerId, now.AddHours(-hours_back), now); + if (points.Count == 0) + return McpHelpers.Status("unavailable", "No I/O trend data available."); + + var result = points.Select(p => new + { + time = p.CollectionTime.ToString("o"), + database_name = p.DatabaseName, + avg_read_latency_ms = Math.Round(p.AvgReadLatencyMs, 2), + avg_write_latency_ms = Math.Round(p.AvgWriteLatencyMs, 2) + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + trend = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_file_io_trend", ex); + } + } + + [McpServerTool(Name = "get_query_trend"), Description("Gets a time-series of performance metrics for a specific query identified by its query_hash. Use this after identifying a problematic query from get_top_queries_by_cpu or get_query_store_top to see how it has changed over time.")] + public static async Task GetQueryTrend( + NpgsqlDataSource postgres, + [Description("The query_hash value from get_top_queries_by_cpu or get_query_store_top.")] string query_hash, + [Description("The database name the query belongs to.")] string database_name, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history. Default 24.")] int hours_back = 24) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + + try + { + var now = DateTime.UtcNow; + var rows = await DarlingTrendReader.GetQueryHistoryAsync(postgres, resolved.Value.ServerId, database_name, query_hash, now.AddHours(-hours_back), now); + if (rows.Count == 0) + return McpHelpers.Status("empty", $"No history found for query_hash '{query_hash}' in database '{database_name}' within the last {hours_back} hours."); + + var result = rows.Select(r => new + { + collection_time = r.CollectionTime.ToString("o"), + execution_count = r.DeltaExecutions, + cpu_ms = Math.Round(r.DeltaCpuUs / 1000.0, 2), + elapsed_ms = Math.Round(r.DeltaElapsedUs / 1000.0, 2), + avg_cpu_ms = Math.Round(r.DeltaExecutions > 0 ? r.DeltaCpuUs / 1000.0 / r.DeltaExecutions : 0, 2), + avg_elapsed_ms = Math.Round(r.DeltaExecutions > 0 ? r.DeltaElapsedUs / 1000.0 / r.DeltaExecutions : 0, 2), + logical_reads = r.DeltaLogicalReads, + logical_writes = r.DeltaLogicalWrites, + physical_reads = r.DeltaPhysicalReads, + rows = r.DeltaRows, + spills = r.DeltaSpills, + min_dop = r.MinDop, + max_dop = r.MaxDop, + query_plan_hash = r.QueryPlanHash + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + database_name, + query_hash, + hours_back, + data_points = rows.Count, + trend = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_query_trend", ex); + } + } + + [McpServerTool(Name = "get_query_duration_trend"), Description("Gets a time-series of average query duration over time. Useful for spotting overall performance degradation or improvement trends across all queries.")] + public static async Task GetQueryDurationTrend( + NpgsqlDataSource postgres, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history. Default 24.")] int hours_back = 24) + { + var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name); + if (error != null) return error; + + var validation = McpHelpers.ValidateHoursBack(hours_back); + if (validation != null) return validation; + + try + { + var now = DateTime.UtcNow; + var points = await DarlingTrendReader.GetQueryDurationTrendAsync(postgres, resolved.Value.ServerId, now.AddHours(-hours_back), now); + if (points.Count == 0) + return McpHelpers.Status("unavailable", "No query duration trend data available."); + + var result = points.Select(p => new + { + time = p.CollectionTime.ToString("o"), + value = p.Value, + execution_count = p.ExecutionCount + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + trend = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_query_duration_trend", ex); + } + } + + /// + /// True when the caller asked for Page Life Expectancy by any common spelling. Matches the full + /// counter name (case-insensitive) or an exact "PLE" — but not "PLE" as a substring, so counters + /// like "samples" don't false-positive. Lite's McpPerfmonTools.IsPageLifeExpectancy. + /// + private static bool IsPageLifeExpectancy(string counterName) => + counterName.Contains("page life expectancy", StringComparison.OrdinalIgnoreCase) || + counterName.Trim().Equals("PLE", StringComparison.OrdinalIgnoreCase); +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMemoryGrantReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMemoryGrantReader.cs new file mode 100644 index 000000000..be8aea149 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMemoryGrantReader.cs @@ -0,0 +1,222 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// Service-side reads for the memory-grant MCP tools () — the SAME +/// memory_grant_stats data the Dashboard's get_resource_semaphore, Lite's get_memory_grants, +/// and the viewer's ViewerDataService.Memory read, adapted here so the MCP host never references the WPF +/// viewer project. Both are STORED reads (no live monitored-server hit) of the LATEST snapshot in the window, +/// on the v_memory_grant_stats passthrough view. +/// +/// +/// get_resource_semaphore is the semaphore/ceiling lens: one row per (resource_semaphore_id, pool_id) with the +/// full workspace-memory sizing — target / max_target (the hard ceiling) / total / +/// available / granted / used plus grantee/waiter/timeout/forced counts and deltas. It +/// carries max_target_memory_mb (present in the store, the Dashboard tool omitted it) and drops the +/// Dashboard's sample_interval_seconds (Darling's delta collector stores no sample interval). get_memory_grants +/// is Lite's pool-detail lens: the sizing + activity SUMMED per pool (Lite's GetMemoryGrantChartDataAsync +/// shape). Every SQL string is a public const so Darling.Tests can pin the dialect + columns without a live Postgres. +/// +/// +internal static class DarlingMemoryGrantReader +{ + /* ─────────────────────────── result rows ─────────────────────────── */ + + /// One resource semaphore at the latest snapshot — the workspace-memory ceiling lens. + public sealed record ResourceSemaphoreRow( + DateTime CollectionTime, short ResourceSemaphoreId, int PoolId, double TargetMemoryMb, double MaxTargetMemoryMb, + double TotalMemoryMb, double AvailableMemoryMb, double GrantedMemoryMb, double UsedMemoryMb, + int GranteeCount, int WaiterCount, long TimeoutErrorCount, long ForcedGrantCount, + long TimeoutErrorCountDelta, long ForcedGrantCountDelta); + + /// One resource pool at the latest snapshot (summed across its semaphores) — Lite's grant lens. + public sealed record MemoryGrantRow( + DateTime CollectionTime, int PoolId, double AvailableMemoryMb, double GrantedMemoryMb, double UsedMemoryMb, + long GranteeCount, long WaiterCount, long TimeoutErrorCountDelta, long ForcedGrantCountDelta); + + /* ─────────────────────────── resource semaphore (latest snapshot, per semaphore) ─────────────────────────── */ + + /// + /// The latest snapshot's per-(semaphore, pool) rows — the Dashboard's get_resource_semaphore shape + /// over Darling's store (plus max_target_memory_mb from the store; minus the Dashboard's + /// unstored sample_interval_seconds). MB columns are numeric(18,2) → double precision. + /// $1 server_id, $2 window start, $3 window end (naive UTC). + /// + public const string ResourceSemaphoreLatestSql = """ + WITH latest AS + ( + SELECT MAX(collection_time) AS mx + FROM v_memory_grant_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + ) + SELECT + collection_time, + resource_semaphore_id, + pool_id, + CAST(target_memory_mb AS double precision) AS target_memory_mb, + CAST(max_target_memory_mb AS double precision) AS max_target_memory_mb, + CAST(total_memory_mb AS double precision) AS total_memory_mb, + CAST(available_memory_mb AS double precision) AS available_memory_mb, + CAST(granted_memory_mb AS double precision) AS granted_memory_mb, + CAST(used_memory_mb AS double precision) AS used_memory_mb, + grantee_count, + waiter_count, + timeout_error_count, + forced_grant_count, + timeout_error_count_delta, + forced_grant_count_delta + FROM v_memory_grant_stats + WHERE server_id = $1 + AND collection_time = (SELECT mx FROM latest) + ORDER BY resource_semaphore_id, pool_id + """; + + public static async Task> GetResourceSemaphoreLatestAsync( + NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var rows = new List(); + await using var command = postgres.CreateCommand(ResourceSemaphoreLatestSql); + DarlingMcpReadParameters.AddWindow(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new ResourceSemaphoreRow( + reader.GetDateTime(0), + reader.IsDBNull(1) ? (short)0 : reader.GetInt16(1), + reader.IsDBNull(2) ? 0 : reader.GetInt32(2), + reader.IsDBNull(3) ? 0 : reader.GetDouble(3), + reader.IsDBNull(4) ? 0 : reader.GetDouble(4), + reader.IsDBNull(5) ? 0 : reader.GetDouble(5), + reader.IsDBNull(6) ? 0 : reader.GetDouble(6), + reader.IsDBNull(7) ? 0 : reader.GetDouble(7), + reader.IsDBNull(8) ? 0 : reader.GetDouble(8), + reader.IsDBNull(9) ? 0 : reader.GetInt32(9), + reader.IsDBNull(10) ? 0 : reader.GetInt32(10), + reader.IsDBNull(11) ? 0 : reader.GetInt64(11), + reader.IsDBNull(12) ? 0 : reader.GetInt64(12), + reader.IsDBNull(13) ? 0 : reader.GetInt64(13), + reader.IsDBNull(14) ? 0 : reader.GetInt64(14))); + } + + return rows; + } + + /* ─────────────────────────── memory grants (latest snapshot, per pool) ─────────────────────────── */ + + /// + /// The latest snapshot's per-pool grant detail — Lite's get_memory_grants shape: sizing + activity + /// SUMMED per pool_id (Lite's GetMemoryGrantChartDataAsync aggregation) at the most recent + /// collection in the window. The MB SUMs CAST to double precision; the count SUMs CAST to bigint (a + /// Postgres SUM(integer) widens to bigint), matching the viewer. $1 server_id, $2 start, $3 end (naive UTC). + /// + public const string MemoryGrantsLatestSql = """ + WITH latest AS + ( + SELECT MAX(collection_time) AS mx + FROM v_memory_grant_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + ) + SELECT + collection_time, + pool_id, + CAST(SUM(available_memory_mb) AS double precision) AS available_memory_mb, + CAST(SUM(granted_memory_mb) AS double precision) AS granted_memory_mb, + CAST(SUM(used_memory_mb) AS double precision) AS used_memory_mb, + CAST(SUM(grantee_count) AS bigint) AS grantee_count, + CAST(SUM(waiter_count) AS bigint) AS waiter_count, + CAST(SUM(timeout_error_count_delta) AS bigint) AS timeout_error_count_delta, + CAST(SUM(forced_grant_count_delta) AS bigint) AS forced_grant_count_delta + FROM v_memory_grant_stats + WHERE server_id = $1 + AND collection_time = (SELECT mx FROM latest) + GROUP BY collection_time, pool_id + ORDER BY pool_id + """; + + public static async Task> GetMemoryGrantsLatestAsync( + NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var rows = new List(); + await using var command = postgres.CreateCommand(MemoryGrantsLatestSql); + DarlingMcpReadParameters.AddWindow(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new MemoryGrantRow( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : reader.GetInt32(1), + reader.IsDBNull(2) ? 0 : reader.GetDouble(2), + reader.IsDBNull(3) ? 0 : reader.GetDouble(3), + reader.IsDBNull(4) ? 0 : reader.GetDouble(4), + reader.IsDBNull(5) ? 0 : reader.GetInt64(5), + reader.IsDBNull(6) ? 0 : reader.GetInt64(6), + reader.IsDBNull(7) ? 0 : reader.GetInt64(7), + reader.IsDBNull(8) ? 0 : reader.GetInt64(8))); + } + + return rows; + } + + /* ─────────────────────────── memory pressure events (RING_BUFFER_RESOURCE_MONITOR) ─────────────────────────── */ + + /// One RING_BUFFER_RESOURCE_MONITOR sample — the sample time plus the SQL Server (process) and OS + /// (system) memory-pressure indicators. Mirror of the viewer's MemoryPressureEventRow. + public sealed record MemoryPressureEventRow( + DateTime SampleTime, string MemoryNotification, int MemoryIndicatorsProcess, int MemoryIndicatorsSystem); + + /// + /// The RING_BUFFER_RESOURCE_MONITOR memory-pressure samples over the window — the viewer's + /// MemoryPressureEventsSql (Lite's GetMemoryPressureEventsAsync ported to Postgres). Windowed + /// on sample_time (the payload's own clock, which the memory_pressure_events index keys on), + /// not collection_time, on the v_memory_pressure_events passthrough view. $1 server_id, $2 window + /// start, $3 window end (naive UTC). + /// + public const string MemoryPressureEventsSql = """ + SELECT + sample_time, + memory_notification, + memory_indicators_process, + memory_indicators_system + FROM v_memory_pressure_events + WHERE server_id = $1 + AND sample_time >= $2 + AND sample_time <= $3 + ORDER BY sample_time + """; + + public static async Task> GetMemoryPressureEventsAsync( + NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var rows = new List(); + await using var command = postgres.CreateCommand(MemoryPressureEventsSql); + DarlingMcpReadParameters.AddWindow(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new MemoryPressureEventRow( + reader.GetDateTime(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + reader.IsDBNull(2) ? 0 : reader.GetInt32(2), + reader.IsDBNull(3) ? 0 : reader.GetInt32(3))); + } + + return rows; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingObjectStatsReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingObjectStatsReader.cs new file mode 100644 index 000000000..9ccbb2545 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingObjectStatsReader.cs @@ -0,0 +1,360 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// Service-side reads for the index / object diagnostic-depth MCP tools +/// () — the SAME collected data Lite's McpObjectStatsTools / +/// McpServerInfoTools and the viewer read, adapted here so the MCP host never references the WPF +/// viewer project. All four read the LATEST daily snapshot (the collectors run index_object_stats +/// daily and database_size_stats hourly) via a correlated MAX(collection_time) — a STORED +/// read, no live monitored-server hit. +/// +/// +/// get_table_index_sizes / get_index_usage / get_object_locking all read v_index_object_stats (the +/// one collector table carries size + usage + locking columns together); get_database_sizes reads +/// v_database_size_stats. get_object_locking and get_database_sizes port the viewer's existing reads +/// (GetIndexLockingAsync, GetDatabaseSizeLatestAsync); get_table_index_sizes and +/// get_index_usage port Lite's server-wide reads (GetObjectSizeGrowthAsync, GetIndexUsageAsync) +/// which the viewer has only per-object variants of. Numeric MB columns are numeric(19,2) and CAST to +/// double precision for the typed reader. Every SQL string is a public const so Darling.Tests can pin the +/// dialect + columns without a live Postgres. +/// +/// +internal static class DarlingObjectStatsReader +{ + /* ─────────────────────────── result rows ─────────────────────────── */ + + /// One per-table size + growth row (indexes rolled up per table). + public sealed record ObjectSizeGrowthRow( + string DatabaseName, string SchemaName, string TableName, double CurrentReservedMb, double CurrentUsedMb, + long TotalRows, int IndexCount, double Growth7dMb, double Growth30dMb, double DailyGrowthRateMb, double GrowthPct30d); + + /// One per-index usage row with its Unused / Write-only / Active classification. + public sealed record IndexUsageRow( + string DatabaseName, string SchemaName, string TableName, string? IndexName, string? IndexTypeDesc, + double ReservedMb, long TotalRows, long UserSeeks, long UserScans, long UserLookups, long TotalReads, + long UserUpdates, DateTime? LastUserAccess, string Classification); + + /// One per-index locking / latch-contention row. + public sealed record IndexLockingRow( + string DatabaseName, string SchemaName, string TableName, string? IndexName, string? IndexTypeDesc, + double ReservedMb, long TotalRows, long RowLockWaitCount, long RowLockWaitInMs, long PageLockWaitCount, + long PageLockWaitInMs, long IndexLockPromotionCount, long PageLatchWaitInMs, long PageIoLatchWaitInMs); + + /// One database file's latest size snapshot. + public sealed record DatabaseSizeRow( + DateTime CollectionTime, string DatabaseName, string? FileName, string? FileTypeDesc, double TotalSizeMb, + double? UsedSizeMb, double? AutoGrowthMb, double? MaxSizeMb, string? VolumeMountPoint, double? VolumeTotalMb, double? VolumeFreeMb); + + /* ─────────────────────────── table / index sizes + growth ─────────────────────────── */ + + /// + /// Per-table size + growth over the daily snapshots — Lite's GetObjectSizeGrowthAsync ported to + /// Postgres: roll indexes up per (database, schema, table) at the latest snapshot, compare against the + /// newest snapshot at/older-than the 7-day ($2) and 30-day ($3) cutoffs (and the earliest snapshot as a + /// fallback), and derive the daily rate from the span of collected data. Ranks by current reserved size + /// descending, cap $4. $1 server_id. + /// + public const string ObjectSizeGrowthSql = """ + WITH boundaries AS ( + SELECT + MAX(collection_time) AS latest_time, + MIN(collection_time) AS earliest_time, + CAST(MAX(collection_time) AS date) - CAST(MIN(collection_time) AS date) AS days_of_data + FROM v_index_object_stats + WHERE server_id = $1 + ), + latest AS ( + SELECT database_name, schema_name, table_name, + SUM(reserved_mb) AS current_reserved_mb, + SUM(used_mb) AS current_used_mb, + MAX(total_rows) AS total_rows, + COUNT(*) AS index_count + FROM v_index_object_stats + WHERE server_id = $1 AND collection_time = (SELECT latest_time FROM boundaries) + GROUP BY database_name, schema_name, table_name + ), + past_7d AS ( + SELECT database_name, schema_name, table_name, SUM(reserved_mb) AS reserved_mb + FROM v_index_object_stats + WHERE server_id = $1 AND collection_time = ( + SELECT MAX(collection_time) FROM v_index_object_stats WHERE server_id = $1 AND collection_time <= $2) + GROUP BY database_name, schema_name, table_name + ), + past_30d AS ( + SELECT database_name, schema_name, table_name, SUM(reserved_mb) AS reserved_mb + FROM v_index_object_stats + WHERE server_id = $1 AND collection_time = ( + SELECT MAX(collection_time) FROM v_index_object_stats WHERE server_id = $1 AND collection_time <= $3) + GROUP BY database_name, schema_name, table_name + ), + oldest AS ( + SELECT database_name, schema_name, table_name, SUM(reserved_mb) AS reserved_mb + FROM v_index_object_stats + WHERE server_id = $1 AND collection_time = (SELECT earliest_time FROM boundaries) + GROUP BY database_name, schema_name, table_name + ) + SELECT + l.database_name, + l.schema_name, + l.table_name, + CAST(l.current_reserved_mb AS double precision) AS current_reserved_mb, + CAST(l.current_used_mb AS double precision) AS current_used_mb, + l.total_rows, + l.index_count, + CAST(l.current_reserved_mb - COALESCE(p7.reserved_mb, o.reserved_mb, l.current_reserved_mb) AS double precision) AS growth_7d_mb, + CAST(l.current_reserved_mb - COALESCE(p30.reserved_mb, p7.reserved_mb, o.reserved_mb, l.current_reserved_mb) AS double precision) AS growth_30d_mb, + CASE WHEN b.days_of_data >= 1 + THEN CAST(l.current_reserved_mb - COALESCE(o.reserved_mb, l.current_reserved_mb) AS double precision) / CAST(b.days_of_data AS double precision) + ELSE 0 END AS daily_growth_rate_mb, + CASE WHEN COALESCE(p30.reserved_mb, p7.reserved_mb, o.reserved_mb) > 0 + THEN CAST(l.current_reserved_mb - COALESCE(p30.reserved_mb, p7.reserved_mb, o.reserved_mb) AS double precision) * 100.0 + / CAST(COALESCE(p30.reserved_mb, p7.reserved_mb, o.reserved_mb) AS double precision) + ELSE 0 END AS growth_pct_30d + FROM latest l + CROSS JOIN boundaries b + LEFT JOIN past_7d p7 ON p7.database_name = l.database_name AND p7.schema_name = l.schema_name AND p7.table_name = l.table_name + LEFT JOIN past_30d p30 ON p30.database_name = l.database_name AND p30.schema_name = l.schema_name AND p30.table_name = l.table_name + LEFT JOIN oldest o ON o.database_name = l.database_name AND o.schema_name = l.schema_name AND o.table_name = l.table_name + ORDER BY l.current_reserved_mb DESC + LIMIT $4 + """; + + public static async Task> GetObjectSizeGrowthAsync( + NpgsqlDataSource postgres, int serverId, DateTime cutoff7dUtc, DateTime cutoff30dUtc, int top, CancellationToken cancellationToken = default) + { + var rows = new List(); + await using var command = postgres.CreateCommand(ObjectSizeGrowthSql); + DarlingMcpReadParameters.AddInt(command, serverId); + DarlingMcpReadParameters.AddTimestamp(command, cutoff7dUtc); + DarlingMcpReadParameters.AddTimestamp(command, cutoff30dUtc); + DarlingMcpReadParameters.AddInt(command, top); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new ObjectSizeGrowthRow( + reader.IsDBNull(0) ? "" : reader.GetString(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + reader.IsDBNull(2) ? "" : reader.GetString(2), + reader.IsDBNull(3) ? 0 : reader.GetDouble(3), + reader.IsDBNull(4) ? 0 : reader.GetDouble(4), + reader.IsDBNull(5) ? 0 : reader.GetInt64(5), + reader.IsDBNull(6) ? 0 : Convert.ToInt32(reader.GetValue(6)), + reader.IsDBNull(7) ? 0 : reader.GetDouble(7), + reader.IsDBNull(8) ? 0 : reader.GetDouble(8), + reader.IsDBNull(9) ? 0 : reader.GetDouble(9), + reader.IsDBNull(10) ? 0 : reader.GetDouble(10))); + } + + return rows; + } + + /* ─────────────────────────── index usage ─────────────────────────── */ + + /// + /// Per-index usage at the latest snapshot — Lite's GetIndexUsageAsync ported to Postgres: + /// seeks/scans/lookups/updates with the Unused / Write-only / Active classification, unused-first then + /// largest reserved. Counters are cumulative since the last restart. $1 server_id, $2 cap. + /// + public const string IndexUsageSql = """ + SELECT + database_name, + schema_name, + table_name, + index_name, + index_type_desc, + CAST(reserved_mb AS double precision) AS reserved_mb, + total_rows, + COALESCE(user_seeks, 0) AS user_seeks, + COALESCE(user_scans, 0) AS user_scans, + COALESCE(user_lookups, 0) AS user_lookups, + COALESCE(user_seeks, 0) + COALESCE(user_scans, 0) + COALESCE(user_lookups, 0) AS total_reads, + COALESCE(user_updates, 0) AS user_updates, + GREATEST(last_user_seek, last_user_scan, last_user_lookup, last_user_update) AS last_user_access, + CASE + WHEN COALESCE(user_seeks, 0) + COALESCE(user_scans, 0) + COALESCE(user_lookups, 0) = 0 + AND COALESCE(user_updates, 0) = 0 THEN 'Unused' + WHEN COALESCE(user_seeks, 0) + COALESCE(user_scans, 0) + COALESCE(user_lookups, 0) = 0 + AND COALESCE(user_updates, 0) > 0 THEN 'Write-only' + ELSE 'Active' + END AS classification + FROM v_index_object_stats + WHERE server_id = $1 + AND collection_time = (SELECT MAX(collection_time) FROM v_index_object_stats WHERE server_id = $1) + ORDER BY + CASE WHEN COALESCE(user_seeks, 0) + COALESCE(user_scans, 0) + COALESCE(user_lookups, 0) = 0 THEN 0 ELSE 1 END, + reserved_mb DESC + LIMIT $2 + """; + + public static async Task> GetIndexUsageAsync( + NpgsqlDataSource postgres, int serverId, int top, CancellationToken cancellationToken = default) + { + var rows = new List(); + await using var command = postgres.CreateCommand(IndexUsageSql); + DarlingMcpReadParameters.AddInt(command, serverId); + DarlingMcpReadParameters.AddInt(command, top); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new IndexUsageRow( + reader.IsDBNull(0) ? "" : reader.GetString(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + reader.IsDBNull(2) ? "" : reader.GetString(2), + reader.IsDBNull(3) ? null : reader.GetString(3), + reader.IsDBNull(4) ? null : reader.GetString(4), + reader.IsDBNull(5) ? 0 : reader.GetDouble(5), + reader.IsDBNull(6) ? 0 : reader.GetInt64(6), + reader.IsDBNull(7) ? 0 : reader.GetInt64(7), + reader.IsDBNull(8) ? 0 : reader.GetInt64(8), + reader.IsDBNull(9) ? 0 : reader.GetInt64(9), + reader.IsDBNull(10) ? 0 : reader.GetInt64(10), + reader.IsDBNull(11) ? 0 : reader.GetInt64(11), + reader.IsDBNull(12) ? null : reader.GetDateTime(12), + reader.IsDBNull(13) ? "" : reader.GetString(13))); + } + + return rows; + } + + /* ─────────────────────────── object locking ─────────────────────────── */ + + /// + /// Per-index locking / latch contention at each database's latest snapshot — the viewer's + /// IndexLockingAllSql projected to the columns get_object_locking surfaces: only rows with a + /// nonzero lock/latch wait or promotion, most contended first. Counters are cumulative since the last + /// restart. $1 server_id, $2 cap. + /// + public const string IndexLockingSql = """ + WITH latest AS ( + SELECT database_name, MAX(collection_time) AS latest_time + FROM v_index_object_stats + WHERE server_id = $1 + GROUP BY database_name + ) + SELECT + ios.database_name, + ios.schema_name, + ios.table_name, + ios.index_name, + ios.index_type_desc, + CAST(ios.reserved_mb AS double precision) AS reserved_mb, + ios.total_rows, + COALESCE(ios.row_lock_wait_count, 0) AS row_lock_wait_count, + COALESCE(ios.row_lock_wait_in_ms, 0) AS row_lock_wait_in_ms, + COALESCE(ios.page_lock_wait_count, 0) AS page_lock_wait_count, + COALESCE(ios.page_lock_wait_in_ms, 0) AS page_lock_wait_in_ms, + COALESCE(ios.index_lock_promotion_count, 0) AS index_lock_promotion_count, + COALESCE(ios.page_latch_wait_in_ms, 0) AS page_latch_wait_in_ms, + COALESCE(ios.page_io_latch_wait_in_ms, 0) AS page_io_latch_wait_in_ms + FROM v_index_object_stats ios + JOIN latest l ON l.database_name = ios.database_name AND l.latest_time = ios.collection_time + WHERE ios.server_id = $1 + AND ( + COALESCE(ios.row_lock_wait_in_ms, 0) > 0 + OR COALESCE(ios.page_lock_wait_in_ms, 0) > 0 + OR COALESCE(ios.page_latch_wait_in_ms, 0) > 0 + OR COALESCE(ios.page_io_latch_wait_in_ms, 0) > 0 + OR COALESCE(ios.index_lock_promotion_count, 0) > 0 + ) + ORDER BY + COALESCE(ios.row_lock_wait_in_ms, 0) + COALESCE(ios.page_lock_wait_in_ms, 0) + + COALESCE(ios.page_latch_wait_in_ms, 0) + COALESCE(ios.page_io_latch_wait_in_ms, 0) DESC + LIMIT $2 + """; + + public static async Task> GetIndexLockingAsync( + NpgsqlDataSource postgres, int serverId, int top, CancellationToken cancellationToken = default) + { + var rows = new List(); + await using var command = postgres.CreateCommand(IndexLockingSql); + DarlingMcpReadParameters.AddInt(command, serverId); + DarlingMcpReadParameters.AddInt(command, top); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new IndexLockingRow( + reader.IsDBNull(0) ? "" : reader.GetString(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + reader.IsDBNull(2) ? "" : reader.GetString(2), + reader.IsDBNull(3) ? null : reader.GetString(3), + reader.IsDBNull(4) ? null : reader.GetString(4), + reader.IsDBNull(5) ? 0 : reader.GetDouble(5), + reader.IsDBNull(6) ? 0 : reader.GetInt64(6), + reader.IsDBNull(7) ? 0 : reader.GetInt64(7), + reader.IsDBNull(8) ? 0 : reader.GetInt64(8), + reader.IsDBNull(9) ? 0 : reader.GetInt64(9), + reader.IsDBNull(10) ? 0 : reader.GetInt64(10), + reader.IsDBNull(11) ? 0 : reader.GetInt64(11), + reader.IsDBNull(12) ? 0 : reader.GetInt64(12), + reader.IsDBNull(13) ? 0 : reader.GetInt64(13))); + } + + return rows; + } + + /* ─────────────────────────── database sizes ─────────────────────────── */ + + /// + /// The latest per-file database-size snapshot — the viewer's DatabaseSizeLatestSql projected to + /// the columns Lite's get_database_sizes surfaces (plus collection_time for the envelope and + /// max_size_mb). MB columns are numeric(19,2) → double precision. $1 server_id. + /// + public const string DatabaseSizeLatestSql = """ + SELECT + collection_time, + database_name, + file_name, + file_type_desc, + CAST(total_size_mb AS double precision) AS total_size_mb, + CAST(used_size_mb AS double precision) AS used_size_mb, + CAST(auto_growth_mb AS double precision) AS auto_growth_mb, + CAST(max_size_mb AS double precision) AS max_size_mb, + volume_mount_point, + CAST(volume_total_mb AS double precision) AS volume_total_mb, + CAST(volume_free_mb AS double precision) AS volume_free_mb + FROM v_database_size_stats + WHERE server_id = $1 + AND collection_time = (SELECT MAX(collection_time) FROM v_database_size_stats WHERE server_id = $1) + ORDER BY database_name, file_type_desc, file_name + """; + + public static async Task> GetLatestDatabaseSizesAsync( + NpgsqlDataSource postgres, int serverId, CancellationToken cancellationToken = default) + { + var rows = new List(); + await using var command = postgres.CreateCommand(DatabaseSizeLatestSql); + DarlingMcpReadParameters.AddInt(command, serverId); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new DatabaseSizeRow( + reader.GetDateTime(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetString(2), + reader.IsDBNull(3) ? null : reader.GetString(3), + reader.IsDBNull(4) ? 0 : reader.GetDouble(4), + reader.IsDBNull(5) ? null : reader.GetDouble(5), + reader.IsDBNull(6) ? null : reader.GetDouble(6), + reader.IsDBNull(7) ? null : reader.GetDouble(7), + reader.IsDBNull(8) ? null : reader.GetString(8), + reader.IsDBNull(9) ? null : reader.GetDouble(9), + reader.IsDBNull(10) ? null : reader.GetDouble(10))); + } + + return rows; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingPlanCacheSchedulerReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingPlanCacheSchedulerReader.cs new file mode 100644 index 000000000..7d40e5f0d --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingPlanCacheSchedulerReader.cs @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// Service-side reads for the plan-cache + CPU-scheduler snapshot MCP tools +/// () — the SAME data the Dashboard's get_plan_cache_bloat +/// / get_cpu_scheduler_pressure and the viewer's ViewerDataService.PlanCache / +/// ViewerDataService.CpuScheduler read. Both are point-in-time snapshot collectors (one/many rows per +/// collection, no deltas), so each read is the LATEST snapshot: plan-cache = every (cacheobjtype, objtype) +/// group at the newest collection in the window; cpu-scheduler = the single newest row for the server. STORED +/// reads, no live monitored-server hit, on the v_plan_cache_stats / v_cpu_scheduler_stats views. +/// +/// +/// The Dashboard's bloat_level (plan cache) and pressure_level / recommendation (CPU +/// scheduler) are NOT stored columns — they are the report.plan_cache_bloat / report.cpu_scheduler_pressure +/// view's CASE derivations (#1410), the same client-side classification the viewer's +/// ClassifyPlanCacheBloat reproduces. Both are reproduced verbatim here as pure static helpers so the +/// tools serve the full result shape. Every SQL string is a public const so Darling.Tests can pin the dialect +/// + columns without a live Postgres. +/// +/// +internal static class DarlingPlanCacheSchedulerReader +{ + /* ─────────────────────────── result rows ─────────────────────────── */ + + /// One (cacheobjtype, objtype) group at the latest plan-cache snapshot. + public sealed record PlanCacheGroupRow( + DateTime CollectionTime, string CacheObjType, string ObjType, int TotalPlans, int TotalSizeMb, + int SingleUsePlans, int SingleUseSizeMb, int MultiUsePlans, int MultiUseSizeMb, decimal AvgUseCount); + + /// The latest CPU-scheduler snapshot — the columns get_cpu_scheduler_pressure surfaces plus the + /// pressure-level classification inputs. + public sealed record CpuSchedulerRow( + DateTime CollectionTime, int SchedulerCount, int TotalRunnableTasksCount, decimal AvgRunnableTasksCount, + int TotalCurrentWorkersCount, int MaxWorkersCount, decimal? RunnablePercent, int TotalQueuedRequestCount, + int TotalActiveRequestCount, bool WorkerThreadExhaustionWarning, bool RunnableTasksWarning, + bool BlockedTasksWarning, bool QueuedRequestsWarning, bool PhysicalMemoryPressureWarning); + + /* ─────────────────────────── plan cache (latest snapshot, all groups) ─────────────────────────── */ + + /// + /// Every (cacheobjtype, objtype) group at the latest collection in the window — the Dashboard's + /// get_plan_cache_bloat composition, ordered by cache size. The group cardinality is small + /// (cacheobjtype × objtype), so the read is uncapped and the tool sums it for the exact summary strip. + /// $1 server_id, $2 window start, $3 window end (naive UTC). + /// + public const string PlanCacheBloatSql = """ + WITH latest AS + ( + SELECT MAX(collection_time) AS mx + FROM v_plan_cache_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + ) + SELECT + collection_time, + cacheobjtype, + objtype, + total_plans, + total_size_mb, + single_use_plans, + single_use_size_mb, + multi_use_plans, + multi_use_size_mb, + avg_use_count + FROM v_plan_cache_stats + WHERE server_id = $1 + AND collection_time = (SELECT mx FROM latest) + ORDER BY total_size_mb DESC, total_plans DESC + """; + + public static async Task> GetPlanCacheBloatAsync( + NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var rows = new List(); + await using var command = postgres.CreateCommand(PlanCacheBloatSql); + DarlingMcpReadParameters.AddWindow(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new PlanCacheGroupRow( + reader.GetDateTime(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + reader.IsDBNull(2) ? "" : reader.GetString(2), + reader.IsDBNull(3) ? 0 : reader.GetInt32(3), + reader.IsDBNull(4) ? 0 : reader.GetInt32(4), + reader.IsDBNull(5) ? 0 : reader.GetInt32(5), + reader.IsDBNull(6) ? 0 : reader.GetInt32(6), + reader.IsDBNull(7) ? 0 : reader.GetInt32(7), + reader.IsDBNull(8) ? 0 : reader.GetInt32(8), + reader.IsDBNull(9) ? 0m : reader.GetDecimal(9))); + } + + return rows; + } + + /// + /// The plan-cache bloat classification — report.plan_cache_bloat / the viewer's + /// ClassifyPlanCacheBloat (#1410), reproduced. The ratio is single-use plans / total plans: + /// > 50% CRITICAL, > 30% HIGH, > 20% MEDIUM, else NORMAL; the recommendation flips at > 20%. + /// An empty cache (totalPlans == 0) is NORMAL. + /// + public static (string Level, string Recommendation) ClassifyPlanCacheBloat(long totalPlans, long singleUsePlans) + { + double singleUsePercent = totalPlans > 0 ? singleUsePlans * 100.0 / totalPlans : 0.0; + + var level = + singleUsePercent > 50 ? "CRITICAL" : + singleUsePercent > 30 ? "HIGH" : + singleUsePercent > 20 ? "MEDIUM" : + "NORMAL"; + + var recommendation = singleUsePercent > 20 + ? "Check for unparameterized queries / Consider Forced Parameterization" + : "Plan cache composition is healthy"; + + return (level, recommendation); + } + + /* ─────────────────────────── cpu scheduler (latest snapshot) ─────────────────────────── */ + + /// + /// The single most recent CPU-scheduler snapshot for the server — the Dashboard's + /// get_cpu_scheduler_pressure point-in-time read (the Dashboard tool takes no window, reading + /// report.cpu_scheduler_pressure's TOP 1). $1 server_id. + /// + public const string CpuSchedulerPressureSql = """ + SELECT + collection_time, + scheduler_count, + total_runnable_tasks_count, + avg_runnable_tasks_count, + total_current_workers_count, + max_workers_count, + runnable_percent, + total_queued_request_count, + total_active_request_count, + worker_thread_exhaustion_warning, + runnable_tasks_warning, + blocked_tasks_warning, + queued_requests_warning, + physical_memory_pressure_warning + FROM v_cpu_scheduler_stats + WHERE server_id = $1 + ORDER BY collection_time DESC + LIMIT 1 + """; + + public static async Task GetCpuSchedulerPressureAsync( + NpgsqlDataSource postgres, int serverId, CancellationToken cancellationToken = default) + { + await using var command = postgres.CreateCommand(CpuSchedulerPressureSql); + DarlingMcpReadParameters.AddInt(command, serverId); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + { + return null; + } + + return new CpuSchedulerRow( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : reader.GetInt32(1), + reader.IsDBNull(2) ? 0 : reader.GetInt32(2), + reader.IsDBNull(3) ? 0m : reader.GetDecimal(3), + reader.IsDBNull(4) ? 0 : reader.GetInt32(4), + reader.IsDBNull(5) ? 0 : reader.GetInt32(5), + reader.IsDBNull(6) ? null : reader.GetDecimal(6), + reader.IsDBNull(7) ? 0 : reader.GetInt32(7), + reader.IsDBNull(8) ? 0 : reader.GetInt32(8), + !reader.IsDBNull(9) && reader.GetBoolean(9), + !reader.IsDBNull(10) && reader.GetBoolean(10), + !reader.IsDBNull(11) && reader.GetBoolean(11), + !reader.IsDBNull(12) && reader.GetBoolean(12), + !reader.IsDBNull(13) && reader.GetBoolean(13)); + } + + /// + /// The CPU-scheduler pressure classification — report.cpu_scheduler_pressure's + /// pressure_level + recommendation CASE (#1410), reproduced verbatim. Runnable-task queue + /// bands (> 50 CRITICAL / > 20 HIGH / > 10 MEDIUM) come first, then a worker-utilization > 90% + /// escalation, then the collector's warning flags as a fallback. + /// + public static (string Level, string Recommendation) ClassifyCpuSchedulerPressure( + int totalRunnableTasks, int totalCurrentWorkers, int maxWorkers, int totalQueuedRequests, + bool workerThreadExhaustionWarning, bool runnableTasksWarning, bool queuedRequestsWarning) + { + double workerUtilization = maxWorkers > 0 ? totalCurrentWorkers * 100.0 / maxWorkers : 0.0; + + var level = + totalRunnableTasks > 50 ? "CRITICAL - High runnable task queue" : + totalRunnableTasks > 20 ? "HIGH - Moderate runnable task queue" : + totalRunnableTasks > 10 ? "MEDIUM - Some runnable tasks queued" : + workerUtilization > 90 ? "HIGH - Worker thread exhaustion" : + workerThreadExhaustionWarning ? "CRITICAL - Worker thread exhaustion warning" : + runnableTasksWarning ? "HIGH - Runnable tasks warning" : + queuedRequestsWarning ? "MEDIUM - Queued requests warning" : + "NORMAL"; + + var recommendation = + totalRunnableTasks > 20 ? "CPU pressure detected - check for CPU-intensive queries, consider adding CPU cores" : + workerThreadExhaustionWarning ? "Worker thread exhaustion - check max worker threads setting" : + totalQueuedRequests > 0 ? "Requests queued for execution - CPU or worker thread pressure" : + "No CPU scheduler pressure detected"; + + return (level, recommendation); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingServerResolver.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingServerResolver.cs new file mode 100644 index 000000000..18ddb1986 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingServerResolver.cs @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// Resolves a user-provided server name to a (server_id, storage name) for the analysis MCP +/// tools — Lite's ServerResolver semantics mirrored over Darling's third-party source +/// of truth: where Lite resolves against its in-memory ServerManager and re-derives the id +/// via the shared hash, Darling resolves against the Postgres servers registry, which +/// the worker upserts on every successful connect () with +/// server_id already derived from the storage name through the shared +/// ServerIdHelper. The lookup semantics are Lite's exactly: enabled servers only; a +/// missing name auto-selects a sole server; exact match (storage name OR display name, +/// case-insensitive) beats partial (Contains) match; a miss returns a ready-to-return error +/// listing the available servers, with Lite's [Read-Only] tag derived from the +/// storage-name :RO suffix (the registry's encoding of ReadOnlyIntent). +/// +internal static class DarlingServerResolver +{ + /// One enabled row from the servers registry — the resolver's pure-matching input. + internal sealed record RegisteredServer(int ServerId, string ServerName, string? DisplayName); + + /// + /// The registry read — exposed const so Darling.Tests can pin the dialect ungated + /// ($-free: no parameters, no bare now(), no N'' literals; the DarlingAlertReadAdapter + /// pattern). ORDER BY keeps the listing and first-partial-match deterministic. + /// + public const string LoadEnabledServersSql = @" +SELECT server_id, server_name, display_name +FROM servers +WHERE is_enabled +ORDER BY server_name"; + + /// + /// Resolves a server name against the enabled registry rows, returning either the resolved + /// (server_id, storage name) or a ready-to-return error string listing the available + /// servers — Lite's ServerResolver.ResolveOrError shape, so the tools collapse the + /// resolve-and-bail block to: var (resolved, error) = await ...; if (error != null) return error;. + /// Unlike Lite's in-memory resolution this one READS (the registry), so a store failure + /// degrades to an informative error string here — the tools' always-return-a-string + /// contract holds instead of surfacing the MCP SDK's generic invocation error. + /// + public static async Task<((int ServerId, string ServerName)? resolved, string? error)> ResolveOrErrorAsync( + NpgsqlDataSource postgres, + string? serverName) + { + List servers; + try + { + servers = await LoadEnabledAsync(postgres); + } + catch (Exception ex) + { + return (null, $"Could not read the servers registry from the Postgres store: {ex.Message}"); + } + + return ResolveOrError(servers, serverName); + } + + /// + /// The pure matching half — Lite's semantics over materialized registry rows, separated + /// from the Postgres read so the resolution rules unit-test without a live store. + /// + internal static ((int ServerId, string ServerName)? resolved, string? error) ResolveOrError( + IReadOnlyList servers, + string? serverName) + { + var resolved = Resolve(servers, serverName); + return resolved is null + ? (null, $"Could not resolve server. Available servers:\n{ListAvailableServers(servers)}") + : (resolved, null); + } + + private static (int ServerId, string ServerName)? Resolve( + IReadOnlyList servers, + string? serverName) + { + if (servers.Count == 0) + { + return null; + } + + if (string.IsNullOrWhiteSpace(serverName)) + { + if (servers.Count == 1) + { + return (servers[0].ServerId, servers[0].ServerName); + } + + return null; + } + + /* Exact match first — the registry's server_name IS the storage name the collectors + stamp on every row, so the resolved name joins the collected data directly. */ + var exact = servers.FirstOrDefault(s => + string.Equals(s.ServerName, serverName, StringComparison.OrdinalIgnoreCase) || + string.Equals(s.DisplayName, serverName, StringComparison.OrdinalIgnoreCase)); + + if (exact != null) + { + return (exact.ServerId, exact.ServerName); + } + + /* Partial match */ + var partial = servers.FirstOrDefault(s => + s.ServerName.Contains(serverName, StringComparison.OrdinalIgnoreCase) || + (s.DisplayName?.Contains(serverName, StringComparison.OrdinalIgnoreCase) ?? false)); + + if (partial != null) + { + return (partial.ServerId, partial.ServerName); + } + + return null; + } + + /// Reads the enabled rows from the servers registry. + internal static async Task> LoadEnabledAsync(NpgsqlDataSource postgres) + { + var servers = new List(); + + await using var connection = await postgres.OpenConnectionAsync(); + using var command = new NpgsqlCommand(LoadEnabledServersSql, connection); + using var reader = await command.ExecuteReaderAsync(); + + while (await reader.ReadAsync()) + { + servers.Add(new RegisteredServer( + reader.GetInt32(0), + reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetString(2))); + } + + return servers; + } + + private static string ListAvailableServers(IReadOnlyList servers) + { + if (servers.Count == 0) + { + /* Headless deviation from Lite's "No servers are configured.": the registry is + populated by the worker on each server's FIRST successful connect, so an empty + registry usually means the service just started or nothing has connected yet. */ + return "No servers are registered yet. The service registers each monitored server on its first successful connection."; + } + + var lines = servers.Select(s => + { + /* ReadOnlyIntent is encoded in the storage name (host[:database][:RO]) — + Lite's [Read-Only] tag, derived from the registry's identity encoding. */ + var roTag = s.ServerName.EndsWith(":RO", StringComparison.Ordinal) ? " [Read-Only]" : ""; + return string.IsNullOrEmpty(s.DisplayName) || s.DisplayName == s.ServerName + ? $"{s.ServerName}{roTag}" + : $"{s.DisplayName} ({s.ServerName}){roTag}"; + }); + + return string.Join("\n", lines); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingSessionReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingSessionReader.cs new file mode 100644 index 000000000..f0383d6d7 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingSessionReader.cs @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// Service-side reads for the session MCP tools () — the SAME +/// collected data Lite's McpSessionTools / McpWaitTools and the viewer read, adapted here +/// so the MCP host never references the WPF viewer project. Each is a STORED read (no live +/// monitored-server hit) keyed by server_id. +/// +/// +/// get_session_stats reads the LATEST per-application snapshot from v_session_stats (Lite's +/// GetLatestSessionStatsAsync — the store-faithful per-program_name shape, NOT the +/// Dashboard's server-wide session_summary_stats summary; where the two diverge slices 1+2 follow +/// Lite). get_active_queries reads the query_snapshots base table over the window (the viewer's +/// LatestQuerySnapshotsSql). get_waiting_tasks reads the waiting_tasks BASE table (there is +/// no v_waiting_tasks passthrough view — the base table has existed since V1, and the viewer's +/// trend reads hit it directly the same way); resource_description is stored but the collector +/// writes it NULL ("no longer collected"). Every SQL string is a public const so Darling.Tests can pin the +/// dialect + columns without a live Postgres. +/// +/// +internal static class DarlingSessionReader +{ + /* ─────────────────────────── result rows ─────────────────────────── */ + + /// One (program_name) group at the latest session_stats snapshot. + public sealed record SessionStatRow( + DateTime CollectionTime, string ProgramName, long ConnectionCount, int RunningCount, int SleepingCount, + int DormantCount, long? TotalCpuTimeMs, long? TotalReads, long? TotalWrites, long? TotalLogicalReads); + + /// One captured query snapshot — the columns Lite's get_active_queries surfaces. + public sealed record ActiveQueryRow( + DateTime CollectionTime, int SessionId, string? DatabaseName, string? Status, long CpuTimeMs, + long TotalElapsedTimeMs, string? ElapsedTimeFormatted, long LogicalReads, long Reads, long Writes, + string? WaitType, long WaitTimeMs, int BlockingSessionId, int Dop, int ParallelWorkerCount, + double GrantedQueryMemoryGb, string? TransactionIsolationLevel, int OpenTransactionCount, + string? LoginName, string? HostName, string? ProgramName, string? QueryText); + + /// One waiting-task snapshot row. + public sealed record WaitingTaskRow( + DateTime CollectionTime, int SessionId, string? WaitType, long WaitDurationMs, + int? BlockingSessionId, string? ResourceDescription, string? DatabaseName); + + /* ─────────────────────────── session stats (latest snapshot, per application) ─────────────────────────── */ + + /// + /// The latest per-application session_stats snapshot — Lite's GetLatestSessionStatsAsync: every + /// program_name at the newest collection, most connections first. connection_count is bigint; the + /// running/sleeping/dormant counts are integer; the cumulative totals are nullable bigint. $1 server_id. + /// + public const string LatestSessionStatsSql = """ + SELECT + collection_time, + program_name, + connection_count, + running_count, + sleeping_count, + dormant_count, + total_cpu_time_ms, + total_reads, + total_writes, + total_logical_reads + FROM v_session_stats + WHERE server_id = $1 + AND collection_time = (SELECT MAX(collection_time) FROM v_session_stats WHERE server_id = $1) + ORDER BY connection_count DESC + """; + + public static async Task> GetLatestSessionStatsAsync( + NpgsqlDataSource postgres, int serverId, CancellationToken cancellationToken = default) + { + var rows = new List(); + await using var command = postgres.CreateCommand(LatestSessionStatsSql); + DarlingMcpReadParameters.AddInt(command, serverId); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new SessionStatRow( + reader.GetDateTime(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + reader.IsDBNull(2) ? 0 : reader.GetInt64(2), + reader.IsDBNull(3) ? 0 : reader.GetInt32(3), + reader.IsDBNull(4) ? 0 : reader.GetInt32(4), + reader.IsDBNull(5) ? 0 : reader.GetInt32(5), + reader.IsDBNull(6) ? null : reader.GetInt64(6), + reader.IsDBNull(7) ? null : reader.GetInt64(7), + reader.IsDBNull(8) ? null : reader.GetInt64(8), + reader.IsDBNull(9) ? null : reader.GetInt64(9))); + } + + return rows; + } + + /* ─────────────────────────── active queries (query snapshots over the window) ─────────────────────────── */ + + /// + /// The captured query snapshots over the window — the viewer's LatestQuerySnapshotsSql projected + /// to the columns Lite's get_active_queries surfaces, from the base query_snapshots table (the + /// viewer reads base here too). granted_query_memory_gb is numeric(18,2) → double precision. WAITFOR + /// shells are trimmed. $1 server_id, $2/$3 window (naive UTC). + /// + public const string ActiveQueriesSql = """ + SELECT + collection_time, + session_id, + database_name, + status, + cpu_time_ms, + total_elapsed_time_ms, + elapsed_time_formatted, + logical_reads, + reads, + writes, + wait_type, + wait_time_ms, + blocking_session_id, + dop, + parallel_worker_count, + CAST(granted_query_memory_gb AS double precision), + transaction_isolation_level, + open_transaction_count, + login_name, + host_name, + program_name, + query_text + FROM query_snapshots + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND query_text NOT LIKE 'WAITFOR%' + ORDER BY collection_time DESC, cpu_time_ms DESC + """; + + public static async Task> GetActiveQueriesAsync( + NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var rows = new List(); + await using var command = postgres.CreateCommand(ActiveQueriesSql); + DarlingMcpReadParameters.AddWindow(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new ActiveQueryRow( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : reader.GetInt32(1), + reader.IsDBNull(2) ? null : reader.GetString(2), + reader.IsDBNull(3) ? null : reader.GetString(3), + reader.IsDBNull(4) ? 0 : reader.GetInt64(4), + reader.IsDBNull(5) ? 0 : reader.GetInt64(5), + reader.IsDBNull(6) ? null : reader.GetString(6), + reader.IsDBNull(7) ? 0 : reader.GetInt64(7), + reader.IsDBNull(8) ? 0 : reader.GetInt64(8), + reader.IsDBNull(9) ? 0 : reader.GetInt64(9), + reader.IsDBNull(10) ? null : reader.GetString(10), + reader.IsDBNull(11) ? 0 : reader.GetInt64(11), + reader.IsDBNull(12) ? 0 : reader.GetInt32(12), + reader.IsDBNull(13) ? 0 : reader.GetInt32(13), + reader.IsDBNull(14) ? 0 : reader.GetInt32(14), + reader.IsDBNull(15) ? 0 : reader.GetDouble(15), + reader.IsDBNull(16) ? null : reader.GetString(16), + reader.IsDBNull(17) ? 0 : reader.GetInt32(17), + reader.IsDBNull(18) ? null : reader.GetString(18), + reader.IsDBNull(19) ? null : reader.GetString(19), + reader.IsDBNull(20) ? null : reader.GetString(20), + reader.IsDBNull(21) ? null : reader.GetString(21))); + } + + return rows; + } + + /* ─────────────────────────── waiting tasks (base table over the window) ─────────────────────────── */ + + /// + /// The recently-captured waiting tasks over the window — the base waiting_tasks table (there is no + /// v_waiting_tasks view), newest first then longest wait. resource_description is stored but always + /// NULL (the collector no longer collects it). $1 server_id, $2/$3 window (naive UTC). + /// + public const string WaitingTasksSql = """ + SELECT + collection_time, + session_id, + wait_type, + wait_duration_ms, + blocking_session_id, + resource_description, + database_name + FROM waiting_tasks + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND wait_type IS NOT NULL + ORDER BY collection_time DESC, wait_duration_ms DESC + LIMIT 500 + """; + + public static async Task> GetWaitingTasksAsync( + NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var rows = new List(); + await using var command = postgres.CreateCommand(WaitingTasksSql); + DarlingMcpReadParameters.AddWindow(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new WaitingTaskRow( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : reader.GetInt32(1), + reader.IsDBNull(2) ? null : reader.GetString(2), + reader.IsDBNull(3) ? 0 : reader.GetInt64(3), + reader.IsDBNull(4) ? null : reader.GetInt32(4), + reader.IsDBNull(5) ? null : reader.GetString(5), + reader.IsDBNull(6) ? null : reader.GetString(6))); + } + + return rows; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoredPlanReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoredPlanReader.cs new file mode 100644 index 000000000..fd51acbc4 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoredPlanReader.cs @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using NpgsqlTypes; + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// Service-side stored-plan reads for the plan-analysis MCP tools — the SAME stored-plan-XML the +/// collectors persist alongside the query/query-store/procedure stats (query_stats.query_plan_xml, +/// query_store_stats.query_plan_text, procedure_stats.query_plan_xml; captured when +/// CollectorContext.CapturePlanXml is set — Darling sets it true, TOAST compresses the text). +/// +/// +/// This mirrors the viewer's ViewerDataService.Plans.cs reads (adapted DuckDB/Dashboard → +/// Postgres) but lives service-side so the MCP host never touches the WPF viewer project: the MCP +/// server reads the STORE exactly like analyze_server does, never the live monitored SQL +/// Server — consistent with Darling's read-only-from-collected-data posture (contrast +/// PgPlanFetcher, which the analysis engine uses to pull a live plan from the cache on demand). +/// +/// +/// +/// Each read keys on server_id + the object identity the Dashboard's McpPlanTools use (query_hash / +/// sql_handle / query_id) so the MCP contract matches the Dashboard's. Two of the reads accept an +/// OPTIONAL finer key the Postgres store carries (database_name for the query-stats read, plan_id for +/// the query-store read); when null the read behaves exactly like the Dashboard's (most-recently +/// collected plan for the coarse key), and when supplied it pins the exact row the viewer's grids key +/// on. ORDER BY collection_time DESC LIMIT 1 returns the most recently captured plan; the +/// IS NOT NULL guard skips rows where no plan was captured. If a change here alters a stored +/// column, the viewer's twin read must move with it. +/// +/// +internal static class DarlingStoredPlanReader +{ + /// + /// The latest captured query_stats plan for a query, keyed by (server, query_hash) with an OPTIONAL + /// database filter — the Dashboard's GetPlanXmlByQueryHashAsync keys on query_hash alone (most recent + /// wins); the $3 null-guard adds the viewer's (database, query_hash) precision when the caller knows the + /// database. $1 server_id, $2 query_hash, $3 database_name (NULL = no database filter). + /// + public const string QueryStatsPlanXmlByHashSql = """ + SELECT query_plan_xml + FROM query_stats + WHERE server_id = $1 + AND query_hash = $2 + AND ($3::text IS NULL OR database_name = $3) + AND query_plan_xml IS NOT NULL + ORDER BY collection_time DESC + LIMIT 1 + """; + + /// + /// The latest captured procedure_stats plan for a procedure, keyed by (server, sql_handle) — the + /// Dashboard's GetProcedurePlanXmlBySqlHandleAsync key. procedure_stats carries sql_handle as the + /// '0x...' hex string the collector stamps (CONVERT(varchar(130), ..., 1)), so the match is a direct + /// text compare (no varbinary CONVERT the SQL-Server side needs). $1 server_id, $2 sql_handle. + /// + public const string ProcedurePlanXmlBySqlHandleSql = """ + SELECT query_plan_xml + FROM procedure_stats + WHERE server_id = $1 + AND sql_handle = $2 + AND query_plan_xml IS NOT NULL + ORDER BY collection_time DESC + LIMIT 1 + """; + + /// + /// The latest captured query_store_stats plan for a query, keyed by (server, database, query_id) with an + /// OPTIONAL plan_id filter — the Dashboard's GetQueryStorePlanXmlAsync keys on (database, query_id); the + /// $4 null-guard adds the viewer's (query_id, plan_id) precision (a plan_id names one specific compiled + /// plan) when the caller knows it. $1 server_id, $2 database_name, $3 query_id, $4 plan_id (NULL = latest + /// plan for the query). + /// + public const string QueryStorePlanTextSql = """ + SELECT query_plan_text + FROM query_store_stats + WHERE server_id = $1 + AND database_name = $2 + AND query_id = $3 + AND ($4::bigint IS NULL OR plan_id = $4) + AND query_plan_text IS NOT NULL + ORDER BY collection_time DESC + LIMIT 1 + """; + + /// + /// The stored execution plan XML for a query (query_stats), or null when no plan was captured for the + /// key. Read as text — no length cap (the collector stored the whole plan; the MCP tool truncates for + /// transport). + /// + public static async Task GetQueryStatsPlanXmlByHashAsync( + NpgsqlDataSource postgres, int serverId, string queryHash, string? databaseName, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(queryHash)) + { + return null; + } + + await using var command = postgres.CreateCommand(QueryStatsPlanXmlByHashSql); + command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter { TypedValue = queryHash }); + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Text, Value = (object?)databaseName ?? DBNull.Value }); + var result = await command.ExecuteScalarAsync(cancellationToken); + return result is string s ? s : null; + } + + /// + /// The stored execution plan XML for a procedure (procedure_stats), or null when no plan was captured + /// for the sql_handle. + /// + public static async Task GetProcedurePlanXmlBySqlHandleAsync( + NpgsqlDataSource postgres, int serverId, string sqlHandle, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(sqlHandle)) + { + return null; + } + + await using var command = postgres.CreateCommand(ProcedurePlanXmlBySqlHandleSql); + command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter { TypedValue = sqlHandle }); + var result = await command.ExecuteScalarAsync(cancellationToken); + return result is string s ? s : null; + } + + /// + /// The stored Query Store execution plan for a query (query_store_stats.query_plan_text — Query Store + /// already stores plans as ShowPlanXML text), or null when no plan text was captured for the key. + /// + public static async Task GetQueryStorePlanTextAsync( + NpgsqlDataSource postgres, int serverId, string databaseName, long queryId, long? planId, + CancellationToken cancellationToken = default) + { + await using var command = postgres.CreateCommand(QueryStorePlanTextSql); + command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter { TypedValue = databaseName ?? "" }); + command.Parameters.Add(new NpgsqlParameter { TypedValue = queryId }); + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Bigint, Value = (object?)planId ?? DBNull.Value }); + var result = await command.ExecuteScalarAsync(cancellationToken); + return result is string s ? s : null; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingSystemHealthReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingSystemHealthReader.cs new file mode 100644 index 000000000..5b66ef3b0 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingSystemHealthReader.cs @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// Service-side reads for the system_health parse-on-read MCP tools () +/// — the SAME stored system_health_events the viewer's System Events tab reads +/// (ViewerDataService.SystemEvents), adapted here so the MCP host never references the WPF viewer +/// project. Each read is a STORED read (no live monitored-server hit): the raw event_xml for one XE +/// event type over the window is fetched from v_system_health_events, then the shared +/// (PerformanceMonitor.Common — Stage 2a, NOT re-implemented here) shreds +/// each blob into the category record, and (Stage 2b) keeps +/// only the SIGNIFICANT rows. This is exactly the viewer's pipeline; the raw table + the shared parser are +/// the whole thing (no persisted parsed tables). +/// +/// +/// The reads window on event_time (the XE @timestamp — the event's real time, which for the +/// ring-buffer categories can lag when it was collected), NOT collection_time, so "last 24 hours" +/// means events that happened in the last 24 hours — the viewer's deliberate choice (the sibling Dashboard +/// windows on collection_time). Bounds bind naive-UTC (Kind=Unspecified → the store's +/// timestamp without time zone columns). Severe-error database_id is resolved to a name from +/// the collected size-stats mapping (the viewer's derivation), since the +/// DB-free shred left it null. Every SQL string is a public const so Darling.Tests can pin the dialect + +/// columns without a live Postgres. +/// +/// +internal static class DarlingSystemHealthReader +{ + /// + /// Raw event_xml for one XE event type over the tab window, newest first — the viewer's + /// SystemHealthEventsByTypeSql. Windows on event_time (the event's real time), and on the + /// category's own event_type ($4). $1 server_id, $2/$3 window (naive UTC), $4 event_type. + /// + public const string SystemHealthEventsByTypeSql = """ + SELECT + event_xml + FROM v_system_health_events + WHERE server_id = $1 + AND event_time >= $2 + AND event_time <= $3 + AND event_type = $4 + AND event_xml IS NOT NULL + ORDER BY event_time DESC + """; + + /// + /// The server's latest database_id↔database_name mapping — the viewer's DatabaseNameMapSql. + /// DISTINCT ON (database_id) keeps the most-recently-collected name per id (handles a dropped-and- + /// recreated id). database_size_stats is the source because it is the only collected table carrying + /// BOTH database_id and database_name for every online DB. Feeds the Severe Errors DB resolution. + /// $1 server_id. + /// + public const string DatabaseNameMapSql = """ + SELECT DISTINCT ON (database_id) + database_id, + database_name + FROM v_database_size_stats + WHERE server_id = $1 + ORDER BY database_id, collection_time DESC + """; + + /// Reads the raw event_xml blobs for one XE event type over the window (newest first). + public static async Task> ReadEventXmlAsync( + NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, string eventType, CancellationToken cancellationToken = default) + { + var xmls = new List(); + await using var command = postgres.CreateCommand(SystemHealthEventsByTypeSql); + DarlingMcpReadParameters.AddInt(command, serverId); + DarlingMcpReadParameters.AddTimestamp(command, startUtc); + DarlingMcpReadParameters.AddTimestamp(command, endUtc); + DarlingMcpReadParameters.AddText(command, eventType); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + xmls.Add(reader.GetString(0)); + } + + return xmls; + } + + /// Loads the server's latest database_id → database_name map for Severe Errors DB resolution. + public static async Task> GetDatabaseNameMapAsync( + NpgsqlDataSource postgres, int serverId, CancellationToken cancellationToken = default) + { + var map = new Dictionary(); + await using var command = postgres.CreateCommand(DatabaseNameMapSql); + DarlingMcpReadParameters.AddInt(command, serverId); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + if (reader.IsDBNull(0) || reader.IsDBNull(1)) + continue; + map[reader.GetInt32(0)] = reader.GetString(1); + } + + return map; + } + + /// + /// Resolves a severe-error database_id to a display name using the collected mapping — the viewer's + /// ResolveDatabaseName. A null or 0 id means "no database context" (error_reported often carries + /// database_id 0; DB_NAME(0) is NULL server-side too) → empty. A real id absent from the map (a + /// database dropped before the latest size-stats snapshot, or one never captured) is surfaced as its raw + /// id rather than silently blanked. + /// + public static string ResolveDatabaseName(int? databaseId, IReadOnlyDictionary databaseNameMap) + { + if (databaseId is not { } id || id == 0) + return string.Empty; + if (databaseNameMap.TryGetValue(id, out var name)) + return name; + return $"database_id {id}"; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingTrendReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingTrendReader.cs new file mode 100644 index 000000000..e3f4fb074 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingTrendReader.cs @@ -0,0 +1,357 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// Service-side reads for the windowed-trend MCP tools () — the SAME +/// collected time-series the viewer's chart partials read (ViewerDataService.OverviewLanes / +/// .Perfmon / .FileIo / .QueryStats / .QueryTrends), adapted here so the MCP +/// host never references the WPF viewer project. Each read is a STORED read (no live monitored-server hit), +/// keyed by server_id and windowed BOTH-sides on the naive-UTC collection_time prefix — the +/// reliable clock every Darling read windows on, bound Kind=Unspecified because the store's columns +/// are timestamp without time zone (a Kind=Utc DateTime maps to timestamptz and throws since +/// Npgsql 6.0). +/// +/// +/// The result shapes mirror LITE's Mcp*Tools trend tools field-for-field (get_memory_trend / +/// get_perfmon_trend / get_file_io_trend / get_query_trend / get_query_duration_trend), the store-faithful +/// shape Darling's collector-mirror schema serves — the same rule the merged +/// follows (Lite's get_query_duration_trend has no Dashboard twin; the other four names + params match the +/// Dashboard, the shape follows Lite where the SKUs diverge). Every SQL string is byte-identical to the +/// viewer's proven Postgres read for that chart (the viewer already ported Lite's DuckDB SQL), so Darling +/// serves one consistent product. Each SQL string is a public const so Darling.Tests can pin the dialect + +/// columns without a live Postgres. +/// +/// +internal static class DarlingTrendReader +{ + /* ─────────────────────────── result records ─────────────────────────── */ + + /// One memory-trend point: the four MB metrics per collection (Lite's MemoryTrendPoint + /// minus its always-0 TotalGrantedMb overlay field, which the tool carries as a Lite parity + /// placeholder — see ). + public sealed record MemoryTrendPoint( + DateTime CollectionTime, double TotalServerMemoryMb, double TargetServerMemoryMb, + double BufferPoolMb, double PlanCacheMb); + + /// One perfmon-trend point for a single counter: the counter value and per-interval delta, + /// both summed across the counter's instances at that collection (Lite's PerfmonTrendPoint). + public sealed record PerfmonTrendPoint(DateTime CollectionTime, long Value, long DeltaValue); + + /// One file I/O-latency-trend point: average read/write latency (stall-ms / op) per collection + /// for one (database, file) — the tool surfaces database_name + latencies, mirroring Lite's + /// get_file_io_trend field set (file_name rides the top-10 grouping but is not projected). + public sealed record FileIoLatencyTrendPoint( + DateTime CollectionTime, string DatabaseName, double AvgReadLatencyMs, double AvgWriteLatencyMs); + + /// One query-duration / execution-count trend point: the per-second rate (elapsed ms/sec) plus + /// executions/sec (Lite's QueryTrendPoint shape). + public sealed record QueryDurationTrendPoint(DateTime CollectionTime, double Value, long ExecutionCount); + + /// One point of a single query's per-collection history (Lite's QueryStatsHistoryRow, + /// the columns get_query_trend surfaces): the interval deltas + DOP spread + the plan hash. Time metrics + /// are microseconds (converted to ms by the tool, matching Lite). + public sealed record QueryHistoryPoint( + DateTime CollectionTime, long DeltaExecutions, long DeltaCpuUs, long DeltaElapsedUs, + long DeltaLogicalReads, long DeltaLogicalWrites, long DeltaPhysicalReads, long DeltaRows, + long DeltaSpills, int MinDop, int MaxDop, string QueryPlanHash); + + /* ─────────────────────────── memory trend ─────────────────────────── */ + + /// + /// The memory trend — the viewer's MemoryTrendSql (Lite's GetMemoryTrendAsync): the four MB + /// metrics from v_memory_stats per collection over the window, each numeric(18,2) CAST to + /// double precision for the typed reader. $1 server_id, $2/$3 window (naive UTC). + /// + public const string MemoryTrendSql = """ + SELECT + collection_time, + CAST(total_server_memory_mb AS double precision) AS total_server_memory_mb, + CAST(target_server_memory_mb AS double precision) AS target_server_memory_mb, + CAST(buffer_pool_mb AS double precision) AS buffer_pool_mb, + CAST(plan_cache_mb AS double precision) AS plan_cache_mb + FROM v_memory_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + ORDER BY collection_time + """; + + public static async Task> GetMemoryTrendAsync( + NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var items = new List(); + await using var command = postgres.CreateCommand(MemoryTrendSql); + DarlingMcpReadParameters.AddWindow(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new MemoryTrendPoint( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : reader.GetDouble(1), + reader.IsDBNull(2) ? 0 : reader.GetDouble(2), + reader.IsDBNull(3) ? 0 : reader.GetDouble(3), + reader.IsDBNull(4) ? 0 : reader.GetDouble(4))); + } + + return items; + } + + /* ─────────────────────────── perfmon trend ─────────────────────────── */ + + /// + /// A single counter's value + per-interval delta trend — Lite's GetPerfmonTrendAsync / the + /// viewer's perfmon read: SUM the counter's instances per collection. Postgres SUM(bigint) is + /// numeric, so both SUMs CAST back to bigint for the typed GetInt64 reader (the viewer's PerfmonTrendsSql + /// makes the same cast). $1 server_id, $2 counter_name, $3/$4 window (naive UTC). + /// + public const string PerfmonTrendSql = """ + SELECT + collection_time, + CAST(SUM(cntr_value) AS bigint) AS cntr_value, + CAST(SUM(delta_cntr_value) AS bigint) AS delta_cntr_value + FROM v_perfmon_stats + WHERE server_id = $1 + AND counter_name = $2 + AND collection_time >= $3 + AND collection_time <= $4 + GROUP BY collection_time + ORDER BY collection_time + """; + + public static async Task> GetPerfmonTrendAsync( + NpgsqlDataSource postgres, int serverId, string counterName, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var items = new List(); + await using var command = postgres.CreateCommand(PerfmonTrendSql); + DarlingMcpReadParameters.AddInt(command, serverId); + DarlingMcpReadParameters.AddText(command, counterName); + DarlingMcpReadParameters.AddTimestamp(command, startUtc); + DarlingMcpReadParameters.AddTimestamp(command, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new PerfmonTrendPoint( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : reader.GetInt64(1), + reader.IsDBNull(2) ? 0 : reader.GetInt64(2))); + } + + return items; + } + + /// + /// The distinct counter names collected over the window, ordered by name — feeds the get_perfmon_trend + /// "not_collected" hint (Lite's GetDistinctPerfmonCountersAsync / the viewer's + /// DistinctPerfmonCountersSql). $1 server_id, $2/$3 window (naive UTC). + /// + public const string DistinctPerfmonCountersSql = """ + SELECT DISTINCT counter_name + FROM v_perfmon_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + ORDER BY counter_name + """; + + public static async Task> GetDistinctPerfmonCountersAsync( + NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var items = new List(); + await using var command = postgres.CreateCommand(DistinctPerfmonCountersSql); + DarlingMcpReadParameters.AddWindow(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(reader.GetString(0)); + } + + return items; + } + + /* ─────────────────────────── file I/O latency trend ─────────────────────────── */ + + /// + /// The file I/O latency trend — the viewer's FileIoLatencyTrendSql (Lite's + /// GetFileIoLatencyTrendAsync), focused to the columns get_file_io_trend surfaces: a + /// top_files CTE picks the 10 busiest (database, file) pairs by total delta ops over the window, + /// then per-collection average read/write latency (stall-ms / op, delta-stall sums CAST to double + /// precision before division) is computed per (collection, database, file). The tool projects + /// database_name + the two latencies (file_name rides the grouping but is not surfaced, mirroring Lite's + /// get_file_io_trend field set). $1 server_id, $2/$3 window (naive UTC). + /// + public const string FileIoLatencyTrendSql = """ + WITH top_files AS ( + SELECT database_name, file_name + FROM v_file_io_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND (delta_reads > 0 OR delta_writes > 0) + GROUP BY database_name, file_name + ORDER BY SUM(delta_reads + delta_writes) DESC + LIMIT 10 + ) + SELECT + f.collection_time, + f.database_name, + CASE WHEN SUM(f.delta_reads) > 0 + THEN SUM(CAST(f.delta_stall_read_ms AS double precision)) / SUM(f.delta_reads) + ELSE 0 END AS avg_read_latency_ms, + CASE WHEN SUM(f.delta_writes) > 0 + THEN SUM(CAST(f.delta_stall_write_ms AS double precision)) / SUM(f.delta_writes) + ELSE 0 END AS avg_write_latency_ms + FROM v_file_io_stats f + JOIN top_files tf ON tf.database_name = f.database_name AND tf.file_name = f.file_name + WHERE f.server_id = $1 + AND f.collection_time >= $2 + AND f.collection_time <= $3 + GROUP BY f.collection_time, f.database_name, f.file_name + ORDER BY f.collection_time, f.database_name, f.file_name + """; + + public static async Task> GetFileIoLatencyTrendAsync( + NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var items = new List(); + await using var command = postgres.CreateCommand(FileIoLatencyTrendSql); + DarlingMcpReadParameters.AddWindow(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new FileIoLatencyTrendPoint( + reader.GetDateTime(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + reader.IsDBNull(2) ? 0 : reader.GetDouble(2), + reader.IsDBNull(3) ? 0 : reader.GetDouble(3))); + } + + return items; + } + + /* ─────────────────────────── query duration trend ─────────────────────────── */ + + /// + /// The query-stats duration trend — the viewer's QueryDurationTrendSql (Lite's + /// GetQueryDurationTrendAsync): per collection, the summed delta_elapsed_time (→ ms) and + /// delta_execution_count divided by the seconds since the previous collection (the truncate-then- + /// diff LAG epoch idiom proven value-identical DuckDB↔Postgres) for an elapsed-ms/sec + executions/sec + /// rate. The first row's LAG is NULL → interval NULL → the CASE yields 0 (Lite's behaviour). Reads the + /// base query_stats table (no v_ view — the merged data reader reads it the same way). Summed + /// bigints come back as numeric, so the reads Convert tolerantly. $1 server_id, $2/$3 window (naive UTC). + /// + public const string QueryDurationTrendSql = """ + WITH raw AS + ( + SELECT + collection_time, + SUM(delta_elapsed_time) / 1000.0 AS total_elapsed_ms, + SUM(delta_execution_count) AS total_executions, + extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (ORDER BY collection_time)))) AS interval_seconds + FROM query_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + GROUP BY collection_time + ) + SELECT + collection_time, + CASE WHEN interval_seconds > 0 THEN total_elapsed_ms / interval_seconds ELSE 0 END AS elapsed_ms_per_second, + CASE WHEN interval_seconds > 0 THEN CAST(total_executions AS DOUBLE PRECISION) / interval_seconds ELSE 0 END AS executions_per_second + FROM raw + ORDER BY collection_time + """; + + public static async Task> GetQueryDurationTrendAsync( + NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var items = new List(); + await using var command = postgres.CreateCommand(QueryDurationTrendSql); + DarlingMcpReadParameters.AddWindow(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new QueryDurationTrendPoint( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : Convert.ToDouble(reader.GetValue(1)), + reader.IsDBNull(2) ? 0 : (long)Convert.ToDouble(reader.GetValue(2)))); + } + + return items; + } + + /* ─────────────────────────── single-query history trend ─────────────────────────── */ + + /// + /// A single query's per-collection history — Lite's GetQueryStatsHistoryAsync, focused to the + /// columns get_query_trend surfaces: the interval deltas + DOP spread + plan hash for one + /// (database, query_hash) over the window, oldest first. Reads the base query_stats table (no v_ + /// view — the merged data reader / plan reader read it the same way). $1 server_id, $2 database_name, + /// $3 query_hash, $4/$5 window (naive UTC). + /// + public const string QueryHistorySql = """ + SELECT + collection_time, + delta_execution_count, + delta_worker_time, + delta_elapsed_time, + delta_logical_reads, + delta_logical_writes, + delta_physical_reads, + delta_rows, + delta_spills, + min_dop, + max_dop, + query_plan_hash + FROM query_stats + WHERE server_id = $1 + AND database_name = $2 + AND query_hash = $3 + AND collection_time >= $4 + AND collection_time <= $5 + ORDER BY collection_time + """; + + public static async Task> GetQueryHistoryAsync( + NpgsqlDataSource postgres, int serverId, string databaseName, string queryHash, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var items = new List(); + await using var command = postgres.CreateCommand(QueryHistorySql); + DarlingMcpReadParameters.AddInt(command, serverId); + DarlingMcpReadParameters.AddText(command, databaseName); + DarlingMcpReadParameters.AddText(command, queryHash); + DarlingMcpReadParameters.AddTimestamp(command, startUtc); + DarlingMcpReadParameters.AddTimestamp(command, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new QueryHistoryPoint( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : reader.GetInt64(1), + reader.IsDBNull(2) ? 0 : reader.GetInt64(2), + reader.IsDBNull(3) ? 0 : reader.GetInt64(3), + reader.IsDBNull(4) ? 0 : reader.GetInt64(4), + reader.IsDBNull(5) ? 0 : reader.GetInt64(5), + reader.IsDBNull(6) ? 0 : reader.GetInt64(6), + reader.IsDBNull(7) ? 0 : reader.GetInt64(7), + reader.IsDBNull(8) ? 0 : reader.GetInt64(8), + reader.IsDBNull(9) ? 0 : Convert.ToInt32(reader.GetValue(9)), + reader.IsDBNull(10) ? 0 : Convert.ToInt32(reader.GetValue(10)), + reader.IsDBNull(11) ? "" : reader.GetString(11))); + } + + return items; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs new file mode 100644 index 000000000..8e179d5fb --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs @@ -0,0 +1,843 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Globalization; +using System.Net; +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Npgsql; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Service.Hosting; + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// Optional hosted service serving Darling's read-only WEB DASHBOARD over HTTP (#1562) — a SECOND, independent +/// Kestrel host beside the MCP host (), with its own port (default 5153), +/// its own kill switch (web.enabled / config_service.web_enabled), its own LAN-exposure block, and its +/// own firewall rule. Not co-hosted with MCP because MCP owns the root path on its port, the two need +/// independent enable switches (web must not require mcp_enabled), and they gate different blast radii (the MCP +/// token guards analyze_server's live outbound SQL connections; the web surface is read-only over the store). +/// +/// Shape mirrors the MCP host (5s supervisor poll, a pure decision +/// table, 30s failed-start backoff, StartAsync not RunAsync so the supervisor owns the wait) and +/// reuses the shared for the LAN-exposure ladder, the in-app CIDR check, the +/// loopback-listener guard, the constant-time token compare, and the firewall reconcile. The live +/// enable/port state arrives via (the worker publishes it), so the viewer's +/// Settings toggle starts/stops/rebinds the dashboard with no service restart. +/// +/// Browser auth (network mode only): loopback requests ALWAYS pass, tokenless, even while +/// LAN-exposed (Erik-ratified — the surface is read-only, so the MCP host's exposed-mode loopback-token SSRF +/// guard is deliberately not mirrored). A network (in-CIDR, non-loopback) request needs either a valid session +/// cookie or a valid ?token= (constant-time), which is exchanged for an HMAC-signed HttpOnly +/// SameSite=Strict cookie and 302-redirected to strip the token from the URL; out-of-CIDR is 403; no +/// cookie/token gets a minimal inline login form. The cookie signing key is a per-process 32-byte RNG value, +/// so a restart invalidates sessions (acceptable). No TLS (the same reverse-proxy story as MCP). +/// +/// The dashboard connects to the store as the least-privilege VIEWER role (not owner, not mcp) — a +/// read-only pool. Static assets ship from wwwroot (a csproj Content copy) with the content root AND +/// web root pinned to : a Windows service's CWD is System32, so the +/// parameterless builder would 404 every static file in production only. +/// +public sealed class DarlingWebHostService : BackgroundService +{ + private readonly ILogger _logger; + private readonly WebRuntimeState _state; + private WebApplication? _app; + private NpgsqlDataSource? _appDataSource; + private int _runningPort; + private bool _runningNetworkMode; + + /// How often the supervisor re-reads the live control-plane state (#1562, mirrors the MCP host). + internal static readonly TimeSpan SupervisorPollInterval = TimeSpan.FromSeconds(5); + + /// Backoff after a FAILED start attempt (port in use, credential not ready) so a persistent + /// failure logs on a calm cadence instead of every poll tick. + internal static readonly TimeSpan FailedStartBackoff = TimeSpan.FromSeconds(30); + + /// The session cookie name and lifetime for the network-mode token→cookie exchange. + internal const string SessionCookieName = "darling_web_session"; + internal static readonly TimeSpan SessionLifetime = TimeSpan.FromHours(12); + private const int SigningKeyBytes = 32; + + public DarlingWebHostService(ILogger logger, WebRuntimeState state) + { + _logger = logger; + _state = state; + } + + /// The supervisor's per-tick verdict — pure over (running, runningPort, enabled, desiredPort) so a + /// unit test pins the whole decision table without a server (#1562, mirrors the MCP host). + public enum WebSupervisorAction { None, Start, Stop, Restart } + + internal static WebSupervisorAction DecideWebAction(bool running, int runningPort, bool enabled, int desiredPort) + { + if (!running) + { + return enabled ? WebSupervisorAction.Start : WebSupervisorAction.None; + } + + if (!enabled) + { + return WebSupervisorAction.Stop; + } + + return runningPort == desiredPort ? WebSupervisorAction.None : WebSupervisorAction.Restart; + } + + /// + /// The supervisor loop (#1562): the viewer's Settings toggle writes config_service.web_enabled / + /// web_port, the worker's reload beacon publishes the live values to , and + /// this loop starts / stops / rebinds the inner web app to match — no service restart. Until the worker's + /// first publish the FILE values apply. The web.network exposure block stays file-defined and restart-only + /// by design; the store toggle still stops an exposed dashboard instantly (a kill switch). + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + DarlingConfig config; + try + { + config = DarlingConfig.Load(); + } + catch (Exception ex) + { + /* The worker logs the missing/broken config as critical; without a file config there is no + bind/network/store definition to serve with, so the web host stands down entirely. */ + _logger.LogDebug("Web dashboard not started (configuration unavailable): {Message}", ex.Message); + return; + } + + var lastFailedStartUtc = DateTime.MinValue; + while (!stoppingToken.IsCancellationRequested) + { + var published = _state.Read(); + var enabled = published?.Enabled ?? config.Web.Enabled; + var desiredPort = published?.Port ?? config.Web.Port; + + switch (DecideWebAction(_app is not null, _runningPort, enabled, desiredPort)) + { + case WebSupervisorAction.Start when DateTime.UtcNow - lastFailedStartUtc >= FailedStartBackoff: + if (!await TryStartServerAsync(config, desiredPort, stoppingToken)) + { + lastFailedStartUtc = DateTime.UtcNow; + } + break; + + case WebSupervisorAction.Stop: + _logger.LogInformation("Web dashboard disabled via the control plane — stopping (no restart needed)"); + await StopServerAsync(stoppingToken); + break; + + case WebSupervisorAction.Restart: + _logger.LogInformation( + "Web dashboard port changed via the control plane ({Old} -> {New}) — rebinding", _runningPort, desiredPort); + await StopServerAsync(stoppingToken); + if (!await TryStartServerAsync(config, desiredPort, stoppingToken)) + { + lastFailedStartUtc = DateTime.UtcNow; + } + break; + } + + try + { + await Task.Delay(SupervisorPollInterval, stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + } + } + + /// Stops and disposes the running app + its data source; in managed network mode also reconciles + /// the firewall rule off (symmetric with the start-side reconcile — disabling closes the box). Safe to call + /// when nothing is running. + private async Task StopServerAsync(CancellationToken cancellationToken) + { + if (_app is null) + { + return; + } + + try + { + await _app.StopAsync(cancellationToken); + await _app.DisposeAsync(); + } + catch (Exception ex) + { + _logger.LogWarning("Web dashboard stop reported an error (continuing): {Message}", ex.Message); + } + + _app = null; + + if (_appDataSource is not null) + { + await _appDataSource.DisposeAsync(); + _appDataSource = null; + } + + if (_runningNetworkMode && OperatingSystem.IsWindows()) + { + await ReconcileWebFirewallAsync(_runningPort, enable: false, null, cancellationToken); + } + + _runningNetworkMode = false; + _runningPort = 0; + } + + /// Failed-start cleanup: a partially built app / data source must not leak between attempts. + private async Task DisposeFailedStartAsync() + { + if (_app is not null) + { + try { await _app.DisposeAsync(); } catch { /* best-effort */ } + _app = null; + } + + if (_appDataSource is not null) + { + try { await _appDataSource.DisposeAsync(); } catch { /* best-effort */ } + _appDataSource = null; + } + } + + /// + /// One start ATTEMPT of the inner web app at : the port comes from the live + /// control-plane value, and every bail path returns false so the supervisor retries with backoff instead of + /// standing down for the process lifetime. The bind/network/token decisions come from the FILE-loaded config + /// (network exposure is deliberately restart-only); returns true when the app is started and listening. + /// + private async Task TryStartServerAsync(DarlingConfig config, int effectivePort, CancellationToken stoppingToken) + { + var web = config.Web; + var network = web.Network; + + /* Decide the effective bind PURELY (shared ladder), then map the reason -> severity here. */ + var bind = DarlingHostBinding.ResolveBind( + network?.Listen, + network?.AllowFrom, + tokenPresent: network is not null + && (!string.IsNullOrWhiteSpace(network.EncryptedToken) || !string.IsNullOrWhiteSpace(network.Token)), + networkConfigured: network is { IsConfigured: true }, + managed: config.Postgres.Managed); + LogBindReason(web, bind.Reason); + + try + { + var networkMode = bind.Mode == DarlingHostBinding.BindMode.NetworkAndLoopback; + + /* In network mode ResolveBind has already validated the listen IP, the allowFrom CIDR, AND their + address-family agreement, so these two parses cannot throw; only resolving the token can still + fail (a corrupt DPAPI blob), which fail-closes to loopback-only rather than exposing tokenless. */ + IPAddress? networkListenIp = null; + IPNetwork allowedCidr = default; + string accessToken = ""; + if (networkMode) + { + networkListenIp = IPAddress.Parse(network!.Listen!.Trim()); + allowedCidr = IPNetwork.Parse(network.AllowFrom!.Trim()); + + try + { + var token = network.ResolveToken(out var usedPlaintext); + if (string.IsNullOrWhiteSpace(token)) + { + _logger.LogCritical( + "Web dashboard token resolved to empty after decryption — refusing to expose; binding loopback-only."); + networkMode = false; + } + else + { + accessToken = token; + if (usedPlaintext) + { + _logger.LogWarning( + "web.network.token is set in plaintext (dev convenience) — prefer web.network.encryptedToken " + + "(produced by --encrypt-password). This token gates web dashboard network access."); + } + } + } + catch (Exception ex) + { + _logger.LogCritical( + "Web dashboard token could not be decrypted ({Message}) — refusing to expose; binding loopback-only.", + ex.Message); + networkMode = false; + } + } + + /* The REAL primary bind address (network IP when exposed, else loopback): both the port precheck and + the Kestrel bind use it, so the precheck probes the actual address, not always loopback. */ + var primaryBind = networkMode ? networkListenIp! : IPAddress.Loopback; + + /* Port-in-use pre-check via the shared utility against the REAL bind address. Done before the + firewall reconcile so a bail here leaves the firewall untouched. */ + if (await PortUtilityService.IsTcpPortListeningAsync(effectivePort, primaryBind, stoppingToken)) + { + _logger.LogError("Port {Port} is already in use — web dashboard not started this attempt; will retry", effectivePort); + return false; + } + + /* Firewall reconcile (managed mode only; best-effort, never fatal): ensure the scoped rule when + exposed, remove it when not. The in-app CIDR is the boundary; the firewall is defense-in-depth. */ + if (config.Postgres.Managed && OperatingSystem.IsWindows()) + { + await ReconcileWebFirewallAsync( + effectivePort, networkMode, networkMode ? allowedCidr.ToString() : null, stoppingToken); + } + + /* Managed mode: the WORKER owns the bundled server's lifecycle; the web host only derives the + least-privilege VIEWER-role connection string from the stored DPAPI credential. */ + string? storeConnectionString; + if (config.Postgres.Managed) + { + if (!OperatingSystem.IsWindows()) + { + _logger.LogError("Web dashboard not started: postgres.managed = true requires Windows"); + return false; + } + + storeConnectionString = await WaitForManagedConnectionStringAsync(config.Postgres, stoppingToken); + if (storeConnectionString is null) + { + return false; + } + } + else + { + storeConnectionString = config.Postgres.ConnectionString; + } + + /* Lifetime tied to the running app: disposed by StopServerAsync, not this method's scope. */ + var postgres = NpgsqlDataSource.Create(storeConnectionString); + _appDataSource = postgres; + + /* FOOTGUN (load-bearing): pin BOTH the content root AND the web root to the binary's directory. A + Windows service's CWD is System32, so the parameterless CreateBuilder would 404 every static file + in production only. WebRootPath is resolved against ContentRootPath -> AppContext.BaseDirectory/wwwroot. */ + var builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + ContentRootPath = AppContext.BaseDirectory, + WebRootPath = "wwwroot", + }); + + builder.WebHost.ConfigureKestrel(options => + { + if (networkMode) + { + /* Bind the specific family, then ALSO both loopback families (unless the listen is itself + loopback/wildcard, which would collide) so a local client resolving "localhost" still + reaches the dashboard (loopback stays tokenless). */ + options.Listen(primaryBind, effectivePort); + if (DarlingHostBinding.ShouldAddLoopbackListeners(primaryBind)) + { + options.Listen(IPAddress.Loopback, effectivePort); + options.Listen(IPAddress.IPv6Loopback, effectivePort); + } + } + else + { + /* The default/degraded loopback-only server — both families. */ + options.ListenLocalhost(effectivePort); + } + }); + + /* Suppress ASP.NET Core console logging — the service's own logger reports lifecycle. */ + builder.Logging.ClearProviders(); + builder.Logging.SetMinimumLevel(LogLevel.Warning); + + /* The VIEWER-role store pool the read endpoints query (registered for DI + passed to MapAll). */ + builder.Services.AddSingleton(postgres); + + _app = builder.Build(); + + /* Pipeline order: the Host-allowlist middleware runs FIRST on EVERY request (both modes) as the + DNS-rebinding guard, then (network mode only) the auth middleware, then DarlingWebEndpoints.MapAll + -> UseDefaultFiles -> UseStaticFiles. WebApplication auto-inserts UseRouting at the head and + UseEndpoints at the tail, so the static-file middleware sits behind these gates and serves the SPA + for non-API paths. */ + + /* DNS-rebinding guard — runs in BOTH modes (the #1576 fix: it previously guarded network mode only, + leaving the tokenless loopback write path reachable cross-origin via a DNS rebind). The loopback + surface is tokenless, so a browser ON the host that loads attacker content could be rebound to + 127.0.0.1:5153 and read/write the whole surface same-origin. Require the Host header to name an + address we actually bind — a loopback name/IP (localhost / 127.0.0.1 / [::1]) or, in network mode, + the configured listen IP. networkListenIp is null in loopback mode, so ONLY loopback Hosts pass + there; a rebound foreign hostname pointed at 127.0.0.1:5153 is rejected 400 before any auth + decision, route handler, or static file. */ + _app.Use(async (context, next) => + { + if (!IsAllowedHost(context.Request.Host.Host, networkListenIp)) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + + await next(context); + }); + + if (networkMode) + { + var cidr = allowedCidr; + var token = accessToken; + /* Per-process signing key: a restart regenerates it and thereby invalidates every session + cookie (acceptable — the operator re-presents the token once). */ + var signingKey = RandomNumberGenerator.GetBytes(SigningKeyBytes); + + _app.Use(async (context, next) => + { + /* The Host-allowlist / DNS-rebinding guard already ran above (both modes); this gate owns + only the network auth decision (session cookie / ?token= / in-CIDR). */ + var remote = context.Connection.RemoteIpAddress; + var hasValidCookie = TryValidateSessionCookie( + context.Request.Cookies[SessionCookieName], signingKey, DateTimeOffset.UtcNow); + var hasValidToken = DarlingHostBinding.FixedTimeTokenEquals( + context.Request.Query["token"].ToString(), token); + + switch (DecideWebAuth(remote, cidr, hasValidCookie, hasValidToken)) + { + case WebAuthAction.Allow: + await next(context); + return; + + case WebAuthAction.SetCookieAndRedirect: + AppendSessionCookie(context, signingKey); + /* 302 (default) to the same path with ?token= stripped, so the token never lingers + in browser history, bookmarks, or a Referer header. */ + context.Response.Redirect(BuildPathWithoutToken(context.Request)); + return; + + case WebAuthAction.Forbid: + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return; + + default: /* ShowLogin */ + await WriteLoginPageAsync(context); + return; + } + }); + } + + DarlingWebEndpoints.MapAll(_app, postgres); + _app.UseDefaultFiles(); + _app.UseStaticFiles(); + + if (networkMode) + { + _logger.LogInformation( + "Starting web dashboard on http://{Listen}:{Port} (LAN-exposed to {Cidr} behind a token->cookie gate + in-app CIDR; loopback also bound, tokenless)", + primaryBind, effectivePort, allowedCidr); + } + else + { + _logger.LogInformation("Starting web dashboard on http://localhost:{Port} (loopback only)", effectivePort); + } + + /* StartAsync, not RunAsync: the supervisor loop owns the wait — the app keeps serving until + StopServerAsync (toggle-off, port change, or shutdown). */ + await _app.StartAsync(stoppingToken); + _runningPort = effectivePort; + _runningNetworkMode = networkMode; + return true; + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + /* Normal shutdown mid-start. */ + return false; + } + catch (Exception ex) + { + _logger.LogError("Web dashboard failed to start: {Message}", ex.Message); + await DisposeFailedStartAsync(); + return false; + } + } + + /* --------------------------------------------------------------------------------------------------- + Browser auth (network mode only). The decision is a PURE function over the resolved facts (loopback, + in-CIDR, cookie-valid, token-valid) so a unit test pins the whole matrix without a server; the + middleware above owns only the HTTP mechanics (read cookie/token, then act on the verdict). + --------------------------------------------------------------------------------------------------- */ + + /// The verdict for one network-mode request. + internal enum WebAuthAction { Allow, Forbid, SetCookieAndRedirect, ShowLogin } + + /// + /// PURE Host-header allowlist — the DNS-rebinding guard. A request is served only when its Host names an + /// address this host actually binds: a loopback name/IP (localhost, 127.0.0.1, ::1) or, + /// when LAN-exposed, the configured listen IP (). Any port suffix is + /// ignored (already split into ), and an empty Host is allowed (HTTP/1.0 / some + /// health probes). This rejects a rebound foreign hostname pointed at 127.0.0.1:5153 before the tokenless + /// loopback allow, while a direct IP request from the real operator passes. + /// + internal static bool IsAllowedHost(string? host, IPAddress? networkListenIp) + { + if (string.IsNullOrEmpty(host)) + { + return true; + } + + if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (IPAddress.TryParse(host, out var hostIp)) + { + var ip = hostIp.IsIPv4MappedToIPv6 ? hostIp.MapToIPv4() : hostIp; + if (IPAddress.IsLoopback(ip)) + { + return true; + } + + if (networkListenIp is not null && ip.Equals(networkListenIp)) + { + return true; + } + } + + return false; + } + + /// + /// PURE route-auth decision (network mode). Loopback ALWAYS passes, tokenless, even while LAN-exposed + /// (Erik-ratified — the web surface is read-only). A non-loopback remote must be inside + /// (a null/unverifiable remote fails closed to ); + /// once in-CIDR, a valid session cookie passes, a valid ?token= is exchanged for one + /// (), and anything else gets the login form. + /// + internal static WebAuthAction DecideWebAuth(IPAddress? remoteIp, IPNetwork allowedCidr, bool hasValidCookie, bool hasValidToken) + { + /* Loopback determination (the tokenless-access arm) lives in DarlingWebEndpoints.IsLoopbackRemote + so the network auth gate and the mutation gate can never drift on how they unwrap IPv4-mapped-IPv6. */ + if (DarlingWebEndpoints.IsLoopbackRemote(remoteIp)) + { + return WebAuthAction.Allow; + } + + if (!DarlingHostBinding.IsRemoteAddressAllowed(remoteIp, allowedCidr)) + { + return WebAuthAction.Forbid; + } + + if (hasValidCookie) + { + return WebAuthAction.Allow; + } + + if (hasValidToken) + { + return WebAuthAction.SetCookieAndRedirect; + } + + return WebAuthAction.ShowLogin; + } + + /// + /// Builds a session cookie value {expiryUnix}.{base64url(HMAC-SHA256(key, expiryUnix))}. PURE — the + /// HMAC is over the exact expiry string that is stored, so verifies + /// the same bytes it signs (a tampered expiry fails the HMAC). + /// + internal static string BuildSessionCookieValue(byte[] signingKey, DateTimeOffset expiry) + { + var expiryUnix = expiry.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture); + var signature = HMACSHA256.HashData(signingKey, Encoding.ASCII.GetBytes(expiryUnix)); + return $"{expiryUnix}.{Base64UrlEncode(signature)}"; + } + + /// + /// PURE verify of a session cookie against and the current time: false on a + /// malformed value, an unparsable/expired expiry, a bad base64url signature, or an HMAC mismatch (tamper). + /// The signature compare is constant-time. + /// + internal static bool TryValidateSessionCookie(string? cookieValue, byte[] signingKey, DateTimeOffset now) + { + if (string.IsNullOrEmpty(cookieValue)) + { + return false; + } + + var dot = cookieValue.IndexOf('.'); + if (dot <= 0 || dot >= cookieValue.Length - 1) + { + return false; + } + + var expiryPart = cookieValue.Substring(0, dot); + var signaturePart = cookieValue.Substring(dot + 1); + + if (!long.TryParse(expiryPart, NumberStyles.None, CultureInfo.InvariantCulture, out var expiryUnix)) + { + return false; + } + + if (now.ToUnixTimeSeconds() > expiryUnix) + { + return false; + } + + byte[] presented; + try + { + presented = Base64UrlDecode(signaturePart); + } + catch (FormatException) + { + return false; + } + + var expected = HMACSHA256.HashData(signingKey, Encoding.ASCII.GetBytes(expiryPart)); + return CryptographicOperations.FixedTimeEquals(presented, expected); + } + + private static void AppendSessionCookie(HttpContext context, byte[] signingKey) + { + var value = BuildSessionCookieValue(signingKey, DateTimeOffset.UtcNow.Add(SessionLifetime)); + context.Response.Cookies.Append(SessionCookieName, value, new CookieOptions + { + HttpOnly = true, + SameSite = SameSiteMode.Strict, + /* The dashboard endpoint is plain HTTP (no TLS on this surface); a Secure cookie would never be + sent, so it must be false here. The reverse-proxy story is the same as MCP for on-wire secrecy. */ + Secure = false, + Path = "/", + MaxAge = SessionLifetime, + }); + } + + /// Rebuilds the current request's path + query with the token parameter removed, for the + /// post-exchange 302. Preserves any other query parameters. + private static string BuildPathWithoutToken(HttpRequest request) + { + var query = QueryString.Empty; + foreach (var pair in request.Query) + { + if (string.Equals(pair.Key, "token", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + foreach (var value in pair.Value) + { + query = query.Add(pair.Key, value ?? string.Empty); + } + } + + var location = request.PathBase.Add(request.Path).ToUriComponent() + query.ToUriComponent(); + return SanitizeRedirectPath(location); + } + + /// + /// PURE open-redirect guard for the token-strip 302 target: forces a single-slash site-relative path. A + /// request path beginning // (or /\) is a protocol-relative URL the browser follows off-site, + /// so a crafted //evil.com/?token=... would 302 the operator away; any leading slash/backslash run is + /// collapsed to one /. Empty maps to /. + /// + internal static string SanitizeRedirectPath(string location) + { + if (string.IsNullOrEmpty(location)) + { + return "/"; + } + + var i = 0; + while (i < location.Length && (location[i] == '/' || location[i] == '\\')) + { + i++; + } + + return "/" + location.Substring(i); + } + + private static Task WriteLoginPageAsync(HttpContext context) + { + context.Response.StatusCode = StatusCodes.Status200OK; + context.Response.ContentType = "text/html; charset=utf-8"; + return context.Response.WriteAsync(LoginPageHtml); + } + + /* A minimal, fully self-contained login form (no external references) — a GET form whose only field is the + access token, so submitting it re-requests the same URL with ?token=, which the middleware exchanges for + a session cookie. It renders BEFORE auth, so it cannot link the gated /css/theme.css (the static files + sit behind this same auth gate); instead it INLINES the same custom-property token block as theme.css + (keep in sync) and carries the same wordmark/subtitle as index.html, plus an input focus style and the + host being accessed. Single-quoted HTML attributes so the C# verbatim string needs no quote-doubling. */ + private const string LoginPageHtml = @" + + + + +Darling Web + + + +
+
+

Darling Web

+
SQL Server fleet monitor
+
+ + + +
+
+ + +"; + + private static string Base64UrlEncode(byte[] bytes) + => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private static byte[] Base64UrlDecode(string value) + { + var b64 = value.Replace('-', '+').Replace('_', '/'); + b64 = (b64.Length % 4) switch + { + 2 => b64 + "==", + 3 => b64 + "=", + _ => b64, + }; + return Convert.FromBase64String(b64); + } + + /* --------------------------------------------------------------------------------------------------- + Bind-reason logging + managed-store credential wait + firewall reconcile (mirrors the MCP host). + --------------------------------------------------------------------------------------------------- */ + + /// Emits the reason at its mapped severity. Silent for + /// the non-degrade reasons (the network-exposed line is logged at start with the real bind). + private void LogBindReason(WebConfig web, DarlingHostBinding.BindReason reason) + { + var level = DarlingHostBinding.MapBindReasonSeverity(reason); + if (level is null) + { + return; + } + + switch (reason) + { + case DarlingHostBinding.BindReason.ListenInvalid: + _logger.Log(level.Value, + "Web dashboard network exposure requested but web.network.listen '{Listen}' is not a valid IP address — " + + "refusing to expose; binding loopback-only. Use a specific IP (e.g. 192.168.1.205), or 0.0.0.0 for all interfaces.", + web.Network?.Listen); + break; + + case DarlingHostBinding.BindReason.TokenMissing: + _logger.Log(level.Value, + "Web dashboard network exposure requested (web.network.listen is non-loopback) but no access token is set — " + + "refusing to expose; binding loopback-only. Set web.network.encryptedToken (via --encrypt-password) or web.network.token."); + break; + + case DarlingHostBinding.BindReason.AllowFromInvalid: + _logger.Log(level.Value, + "Web dashboard network exposure requested but web.network.allowFrom '{AllowFrom}' is not a valid CIDR or its " + + "address family does not match web.network.listen — refusing to expose; binding loopback-only. " + + "Use e.g. 192.168.1.0/24 (host bits zeroed, same family as listen).", + web.Network?.AllowFrom); + break; + + case DarlingHostBinding.BindReason.ManagedModeRequired: + _logger.Log(level.Value, + "web.network.* is set but postgres.managed = false — web dashboard network exposure is managed-mode only and is " + + "ignored; your own reverse proxy governs BYO exposure. Binding loopback-only."); + break; + + default: + break; + } + } + + /// The scoped web firewall rule name (idempotent by DisplayName), port-specific and distinct from + /// the store's and MCP's rules so the endpoints reconcile independently. internal so the headless + /// endpoint-toggle CLI verbs (--enable-web/--disable-web) reconcile the SAME rule by DisplayName. + internal static string WebFirewallRuleName(int port) => $"PerformanceMonitor Darling Web (port {port})"; + + [SupportedOSPlatform("windows")] + private Task ReconcileWebFirewallAsync(int port, bool enable, string? cidr, CancellationToken cancellationToken) + => DarlingHostBinding.ReconcileFirewallAsync(WebFirewallRuleName(port), port, enable, cidr, _logger, cancellationToken); + + /// + /// Managed mode's first-boot race, handled: the VIEWER-role credential appears only after the worker's + /// initdb + migration + role provisioning finish, so poll up to ~5 minutes (60 x 5s) instead of racing it, + /// then stand down with a pointer at the worker log — fail-closed (the dashboard just does not start; it + /// self-heals on the next attempt once the credential exists). + /// + [SupportedOSPlatform("windows")] + private async Task WaitForManagedConnectionStringAsync(PostgresConfig config, CancellationToken stoppingToken) + { + for (var attempt = 0; attempt < 60; attempt++) + { + var connectionString = DarlingManagedPostgres.TryBuildViewerConnectionStringFromStoredCredential(config); + if (connectionString is not null) + { + return connectionString; + } + + if (attempt == 0) + { + _logger.LogInformation("Waiting for the managed Postgres viewer-role credential (first-run initialization) before starting the web dashboard"); + } + + try + { + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); + } + catch (OperationCanceledException) + { + return null; + } + } + + _logger.LogError("Web dashboard not started: the managed Postgres viewer-role credential never appeared — see the worker log for the bootstrap failure"); + return null; + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + if (_app != null) + { + _logger.LogInformation("Stopping web dashboard"); + await StopServerAsync(cancellationToken); + } + + await base.StopAsync(cancellationToken); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/McpRuntimeState.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/McpRuntimeState.cs new file mode 100644 index 000000000..75bb9a609 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/McpRuntimeState.cs @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// The live MCP enable/port state, published by the WORKER (the control-plane owner) and observed by +/// 's supervisor loop (#1560). This is the seam that makes the +/// viewer's Settings toggle (config_service.mcp_enabled / mcp_port) take effect WITHOUT a service +/// restart: the worker's reload beacon applies the store row to the live config every ~15 seconds and +/// publishes it here; the MCP host polls and starts/stops/rebinds its inner web app to match. +/// +/// Before the first (worker still bootstrapping, or the store and file both +/// unreadable) the host falls back to the file-loaded values — exactly the pre-#1560 behavior. The +/// LAN-exposure block (mcp.network) is deliberately NOT carried here: network exposure stays +/// file-defined and restart-only (changing an exposure surface should require touching the host), but +/// the store toggle still acts as an instant kill switch for an exposed server. +/// +/// Thread-safety: one writer (the worker's sweep thread), one reader (the MCP host's supervisor). +/// State is swapped as one immutable record reference, so the reader always sees a coherent +/// (Enabled, Port) pair — never a torn mix of old and new. +/// +public sealed class McpRuntimeState +{ + /// A coherent published snapshot; null until the worker first publishes. + public sealed record Snapshot(bool Enabled, int Port); + + private volatile Snapshot? _current; + + /// Publishes the live control-plane values (worker only; called at startup and on every reload). + public void Publish(bool enabled, int port) => _current = new Snapshot(enabled, port); + + /// The latest published snapshot, or null when the worker has not published yet. + public Snapshot? Read() => _current; +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/WebRuntimeState.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/WebRuntimeState.cs new file mode 100644 index 000000000..290549d9a --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/WebRuntimeState.cs @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +namespace PerformanceMonitor.Darling.Service.Mcp; + +/// +/// The live web-dashboard enable/port state, published by the WORKER (the control-plane owner) and observed +/// by 's supervisor loop (#1562) — the exact twin of +/// for the second Kestrel host. This is the seam that makes the viewer's +/// Settings toggle (config_service.web_enabled / web_port) take effect WITHOUT a service restart: the +/// worker's reload beacon applies the store row to the live config every ~15 seconds and publishes it here; +/// the web host polls and starts/stops/rebinds its inner web app to match. +/// +/// Before the first (worker still bootstrapping, or the store and file both +/// unreadable) the host falls back to the file-loaded values. The LAN-exposure block (web.network) is +/// deliberately NOT carried here: network exposure stays file-defined and restart-only (changing an +/// exposure surface should require touching the host), but the store toggle still acts as an instant kill +/// switch for an exposed dashboard. +/// +/// Thread-safety: one writer (the worker's sweep thread), one reader (the web host's supervisor). +/// State is swapped as one immutable record reference, so the reader always sees a coherent +/// (Enabled, Port) pair — never a torn mix of old and new. +/// +public sealed class WebRuntimeState +{ + /// A coherent published snapshot; null until the worker first publishes. + public sealed record Snapshot(bool Enabled, int Port); + + private volatile Snapshot? _current; + + /// Publishes the live control-plane values (worker only; called at startup and on every reload). + public void Publish(bool enabled, int port) => _current = new Snapshot(enabled, port); + + /// The latest published snapshot, or null when the worker has not published yet. + public Snapshot? Read() => _current; +} diff --git a/Darling/PerformanceMonitor.Darling.Service/MonitoredServerConnection.cs b/Darling/PerformanceMonitor.Darling.Service/MonitoredServerConnection.cs new file mode 100644 index 000000000..05dc33009 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/MonitoredServerConnection.cs @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using Microsoft.Data.SqlClient; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// Builds the SqlClient connection string for a monitored server, mirroring Lite's +/// ServerConnection.BuildConnectionString shape (MARS on for the collection loop, 15-second +/// connect budget, Encrypt fail-closed to Mandatory for unknown modes) so the two SKUs present +/// the same connection posture to monitored servers — only the ApplicationName differs. +/// +public static class MonitoredServerConnection +{ + public static string BuildConnectionString(MonitoredServer server, string? resolvedPassword = null) + { + if (server is null) + { + throw new ArgumentNullException(nameof(server)); + } + + var builder = new SqlConnectionStringBuilder + { + DataSource = server.Host, + InitialCatalog = string.IsNullOrWhiteSpace(server.Database) ? "master" : server.Database, + ApplicationName = "PerformanceMonitorDarling", + ConnectTimeout = 15, + CommandTimeout = 60, + TrustServerCertificate = server.TrustServerCertificate, + MultipleActiveResultSets = true, + ApplicationIntent = server.ReadOnlyIntent ? ApplicationIntent.ReadOnly : ApplicationIntent.ReadWrite, + MultiSubnetFailover = server.MultiSubnetFailover, + }; + + /* Encrypt fail-closed: unknown/blank modes get Mandatory, matching Lite. */ + builder.Encrypt = server.EncryptMode?.Trim().ToUpperInvariant() switch + { + "STRICT" => SqlConnectionEncryptOption.Strict, + "OPTIONAL" => SqlConnectionEncryptOption.Optional, + _ => SqlConnectionEncryptOption.Mandatory, + }; + + if (server.UsesSqlAuth) + { + builder.UserID = server.Username; + builder.Password = resolvedPassword + ?? throw new InvalidOperationException($"Server '{server.DisplayName}' uses sql auth but no password was resolved."); + } + else + { + builder.IntegratedSecurity = true; + } + + return builder.ConnectionString; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj b/Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj new file mode 100644 index 000000000..9719e3970 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj @@ -0,0 +1,65 @@ + + + net10.0 + enable + disable + PerformanceMonitor.Darling.Service + PerformanceMonitor.Darling.Service + 3.2.0 + 3.2.0.0 + 3.2.0.0 + Darling Data, LLC + Copyright © 2026 Darling Data, LLC + true + latest-recommended + CA1849;CA2007;CA1508;CA1822;CA1805;CA1510;CA1816;CA1861;CA1845;CA2201;CA1848;CA1852;CA1305;CA1860;CA1707;CA1507;CA1806;CA2254 + CS4014 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Darling/PerformanceMonitor.Darling.Service/PgAlertHistoryStore.cs b/Darling/PerformanceMonitor.Darling.Service/PgAlertHistoryStore.cs new file mode 100644 index 000000000..2ed841bcd --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/PgAlertHistoryStore.cs @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Globalization; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Npgsql; +using NpgsqlTypes; +using PerformanceMonitor.Notifications; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// Postgres-backed over the V3 config_alert_log table — +/// Lite's DuckDbAlertHistoryStore ported query-for-query (same columns, same numeric +/// display-text fallback parse, same cooldown-seed filters including the #1154 anchored +/// context_json LIKE on the #1140 dedup fingerprint). RecordAlertAsync is failure-isolated with +/// Lite's escalating log cadence (first 3 failures, then every 50th) so a down store can't spam +/// the service log every sweep; the seed reads degrade to null. alert_time is stamped +/// naive-UTC at record time and tagged Utc on read, exactly like Lite's UtcNow discipline. +/// +public sealed class PgAlertHistoryStore : IAlertHistoryStore +{ + private readonly NpgsqlDataSource _postgres; + private readonly ILogger? _logger; + + /* Failure tracking for louder logging — Lite's _consecutiveLogFailures cadence. */ + private int _consecutiveLogFailures; + + public PgAlertHistoryStore(NpgsqlDataSource postgres, ILogger? logger = null) + { + _postgres = postgres ?? throw new ArgumentNullException(nameof(postgres)); + _logger = logger; + } + + public async Task RecordAlertAsync(AlertHistoryRecord record) + { + if (record is null) + { + throw new ArgumentNullException(nameof(record)); + } + + /* Resolve the current_value/threshold_value doubles from the optional numerics, falling + back to parsing the display text — Lite's DuckDbAlertHistoryStore ?? fallback verbatim. */ + var currentValue = record.NumericCurrentValue + ?? (double.TryParse(record.CurrentValueText.TrimEnd('%'), out var cv) ? cv : 0); + var thresholdValue = record.NumericThresholdValue + ?? (double.TryParse(record.ThresholdValueText.TrimEnd('%'), out var tv) ? tv : 0); + var serverId = int.TryParse(record.ServerId, out var sid) ? sid : 0; + + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + using var command = new NpgsqlCommand(@" +INSERT INTO config_alert_log (alert_time, server_id, server_name, metric_name, current_value, threshold_value, alert_sent, notification_type, send_error, muted, detail_text, context_json) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", connection); + + command.Parameters.AddWithValue(NaiveUtcNow()); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(record.ServerName); + command.Parameters.AddWithValue(record.MetricName); + command.Parameters.AddWithValue(currentValue); + command.Parameters.AddWithValue(thresholdValue); + command.Parameters.AddWithValue(record.AlertSent); + command.Parameters.AddWithValue(record.NotificationType); + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Text, Value = (object?)record.SendError ?? DBNull.Value }); + command.Parameters.AddWithValue(record.Muted); + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Text, Value = (object?)record.DetailText ?? DBNull.Value }); + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Text, Value = (object?)record.ContextJson ?? DBNull.Value }); + + await command.ExecuteNonQueryAsync(); + + if (_consecutiveLogFailures > 0) + { + _logger?.LogInformation("Alert logging recovered after {Failures} failure(s)", _consecutiveLogFailures); + } + _consecutiveLogFailures = 0; + } + catch (Exception ex) + { + _consecutiveLogFailures++; + if (_consecutiveLogFailures <= 3) + { + _logger?.LogError("Failed to log alert ({Failures}x): {Message}", _consecutiveLogFailures, ex.Message); + } + else if (_consecutiveLogFailures % 50 == 0) + { + _logger?.LogError("Alert logging STILL broken: {Failures} failures. Last: {Message}", _consecutiveLogFailures, ex.Message); + } + } + } + + public Task GetLastEmailSentUtcAsync(string serverId, string metricName, string? dedupKey = null) => + /* A successful email send is logged with notification_type 'email'/'email+webhook' and a + null send_error — exactly when Lite stamps the email cooldown (#981). */ + ReadMaxAlertTimeAsync(serverId, metricName, dedupKey, @" +AND notification_type IN ('email', 'email+webhook') +AND send_error IS NULL", "EmailAlert"); + + public Task GetLastWebhookSentUtcAsync(string serverId, string metricName, string? dedupKey = null) => + /* 'webhook'/'email+webhook' types are only written on a successful post, so the type + alone implies success; send_error tracks the EMAIL channel and is NOT filtered (#1145). */ + ReadMaxAlertTimeAsync(serverId, metricName, dedupKey, @" +AND notification_type IN ('webhook', 'email+webhook')", "WebhookAlert"); + + public Task GetLastAlertTimeAsync(string serverId, string metricName) => + /* Unfiltered — any channel/result (the analysis cooldown is stamped unconditionally). */ + ReadMaxAlertTimeAsync(serverId, metricName, dedupKey: null, extraFilter: "", logScope: "AnalysisNotify"); + + private async Task ReadMaxAlertTimeAsync( + string serverId, string metricName, string? dedupKey, string extraFilter, string logScope) + { + var sid = int.TryParse(serverId, out var s) ? s : 0; + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + using var command = new NpgsqlCommand(@" +SELECT MAX(alert_time) +FROM config_alert_log +WHERE server_id = $1 +AND metric_name = $2" + extraFilter + /* #1154: the per-fingerprint filter — Lite's anchored LIKE on the serialized + "DedupKey":"" property (the hex key has no LIKE wildcards; NULL + context_json rows fail the match). */ + + (dedupKey is null ? "" : "\nAND context_json LIKE $3"), connection); + command.Parameters.AddWithValue(sid); + command.Parameters.AddWithValue(metricName); + if (dedupKey is not null) + { + command.Parameters.AddWithValue("%\"DedupKey\":\"" + dedupKey + "\"%"); + } + + var result = await command.ExecuteScalarAsync(); + if (result == null || result == DBNull.Value) + { + return null; + } + + /* alert_time is written as naive UTC; tag it UTC so the kind is explicit. */ + return DateTime.SpecifyKind(Convert.ToDateTime(result, CultureInfo.InvariantCulture), DateTimeKind.Utc); + } + catch (Exception ex) + { + _logger?.LogError("{Scope}: could not read persisted alert cooldown: {Message}", logScope, ex.Message); + return null; + } + } + + /// Naive-UTC now, Kind-Unspecified — the product's PG timestamp discipline. + private static DateTime NaiveUtcNow() => + DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified); +} diff --git a/Darling/PerformanceMonitor.Darling.Service/PgAlertStateStore.cs b/Darling/PerformanceMonitor.Darling.Service/PgAlertStateStore.cs new file mode 100644 index 000000000..d09c4e78c --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/PgAlertStateStore.cs @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Globalization; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Npgsql; +using PerformanceMonitor.Alerting; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// Postgres-backed over the V3 config_edge_trigger_watermarks +/// table — the Darling twin of Lite's DuckDbAlertHistoryStore watermark methods, with +/// DuckDB's INSERT OR REPLACE upsert expressed as INSERT ... ON CONFLICT ... DO UPDATE +/// on the same (server_id, metric_name) primary key. Count metrics (blocking/deadlock) live in +/// the INTEGER watermark column with watermark_time NULL; the failed-job metric +/// reserves the 'Failed Agent Job' row, keeps watermark at 0 (the column is NOT NULL) and +/// stores the SERVER-LOCAL run time in watermark_time — Lite's exact row shapes, so a +/// future cross-store viewer reads both identically. All methods are failure-isolated like Lite's +/// (a broken store logs and degrades: loads return null, saves drop the write — the in-memory +/// watermark still gates the current process). serverKey is the storage-name hash string, +/// parsed to the server_id int (the DarlingAlertReadAdapter convention). +/// +public sealed class PgAlertStateStore : IAlertStateStore +{ + /* Lite's reserved failed-job row key — DuckDbAlertHistoryStore.FailedJobWatermarkMetric. */ + private const string FailedJobWatermarkMetric = "Failed Agent Job"; + + private readonly NpgsqlDataSource _postgres; + private readonly ILogger? _logger; + + public PgAlertStateStore(NpgsqlDataSource postgres, ILogger? logger = null) + { + _postgres = postgres ?? throw new ArgumentNullException(nameof(postgres)); + _logger = logger; + } + + public async Task LoadEdgeTriggerWatermarkAsync(string serverKey, string metricName) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + using var command = new NpgsqlCommand(@" +SELECT watermark +FROM config_edge_trigger_watermarks +WHERE server_id = $1 +AND metric_name = $2 +AND watermark_time IS NULL", connection); + command.Parameters.AddWithValue(ParseServerKey(serverKey)); + command.Parameters.AddWithValue(metricName); + + var result = await command.ExecuteScalarAsync(); + if (result == null || result == DBNull.Value) + { + return null; + } + + return Convert.ToInt32(result, CultureInfo.InvariantCulture); + } + catch (Exception ex) + { + _logger?.LogError("Could not load edge-trigger watermark ({Metric}): {Message}", metricName, ex.Message); + return null; + } + } + + public async Task SaveEdgeTriggerWatermarkAsync(string serverKey, string metricName, int watermark) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + /* Full-row replace semantics, matching Lite's INSERT OR REPLACE: a count row always + carries watermark_time NULL. */ + using var command = new NpgsqlCommand(@" +INSERT INTO config_edge_trigger_watermarks (server_id, metric_name, watermark, watermark_time, updated_at) +VALUES ($1, $2, $3, NULL, $4) +ON CONFLICT (server_id, metric_name) DO UPDATE SET + watermark = EXCLUDED.watermark, + watermark_time = NULL, + updated_at = EXCLUDED.updated_at", connection); + command.Parameters.AddWithValue(ParseServerKey(serverKey)); + command.Parameters.AddWithValue(metricName); + command.Parameters.AddWithValue(watermark); + command.Parameters.AddWithValue(NaiveUtcNow()); + + await command.ExecuteNonQueryAsync(); + } + catch (Exception ex) + { + _logger?.LogError("Could not persist edge-trigger watermark ({Metric}): {Message}", metricName, ex.Message); + } + } + + public async Task LoadFailedJobWatermarkAsync(string serverKey) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + using var command = new NpgsqlCommand(@" +SELECT watermark_time +FROM config_edge_trigger_watermarks +WHERE server_id = $1 +AND metric_name = $2 +AND watermark_time IS NOT NULL", connection); + command.Parameters.AddWithValue(ParseServerKey(serverKey)); + command.Parameters.AddWithValue(FailedJobWatermarkMetric); + + var result = await command.ExecuteScalarAsync(); + if (result == null || result == DBNull.Value) + { + return null; + } + + /* Returned in its native server-local basis — NEVER coerced to UTC (the + IAlertStateStore contract; it compares against FailedJobInfo.RunDateTime). */ + return Convert.ToDateTime(result, CultureInfo.InvariantCulture); + } + catch (Exception ex) + { + _logger?.LogError("Could not load failed-job watermark: {Message}", ex.Message); + return null; + } + } + + public async Task SaveFailedJobWatermarkAsync(string serverKey, DateTime watermark) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + /* watermark (the INTEGER count column) is unused for the time-based failed-job row; + it is non-nullable, so write 0 — Lite's exact shape. The value is server-local; + only the Kind flag is normalized (Npgsql rejects Kind=Utc against `timestamp`). */ + using var command = new NpgsqlCommand(@" +INSERT INTO config_edge_trigger_watermarks (server_id, metric_name, watermark, watermark_time, updated_at) +VALUES ($1, $2, 0, $3, $4) +ON CONFLICT (server_id, metric_name) DO UPDATE SET + watermark = 0, + watermark_time = EXCLUDED.watermark_time, + updated_at = EXCLUDED.updated_at", connection); + command.Parameters.AddWithValue(ParseServerKey(serverKey)); + command.Parameters.AddWithValue(FailedJobWatermarkMetric); + command.Parameters.AddWithValue(DateTime.SpecifyKind(watermark, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(NaiveUtcNow()); + + await command.ExecuteNonQueryAsync(); + } + catch (Exception ex) + { + _logger?.LogError("Could not persist failed-job watermark: {Message}", ex.Message); + } + } + + /// Naive-UTC now, Kind-Unspecified — the product's PG timestamp discipline. + private static DateTime NaiveUtcNow() => + DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified); + + private static int ParseServerKey(string serverKey) => + int.Parse(serverKey, CultureInfo.InvariantCulture); +} diff --git a/Darling/PerformanceMonitor.Darling.Service/PgMuteRuleStore.cs b/Darling/PerformanceMonitor.Darling.Service/PgMuteRuleStore.cs new file mode 100644 index 000000000..3f928d023 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/PgMuteRuleStore.cs @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Npgsql; +using NpgsqlTypes; +using PerformanceMonitor.Notifications; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// Postgres-backed over the V3 config_mute_rules table — +/// Lite's DuckDbMuteRuleStore ported statement-for-statement. The shared +/// keeps the in-memory cache + matching; per its contract the +/// mutating methods here THROW on failure (the service catches, logs, and skips the cache +/// mutation — persist-then-cache ordering) while swallows errors and +/// returns an empty set (a headless service with no rules configured — or a store not yet +/// migrated — simply mutes nothing). Timestamps are stored naive-UTC and tagged UTC on read. +/// +public sealed class PgMuteRuleStore : IMuteRuleStore +{ + private readonly NpgsqlDataSource _postgres; + private readonly ILogger? _logger; + + public PgMuteRuleStore(NpgsqlDataSource postgres, ILogger? logger = null) + { + _postgres = postgres ?? throw new ArgumentNullException(nameof(postgres)); + _logger = logger; + } + + public async Task> LoadAllAsync() + { + var rules = new List(); + try + { + await using var connection = await _postgres.OpenConnectionAsync(); + using var command = new NpgsqlCommand(@" +SELECT id, enabled, created_at_utc, expires_at_utc, reason, + server_name, metric_name, database_pattern, + query_text_pattern, wait_type_pattern, job_name_pattern +FROM config_mute_rules +ORDER BY created_at_utc DESC", connection); + + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + rules.Add(new MuteRule + { + Id = reader.GetString(0), + Enabled = reader.GetBoolean(1), + CreatedAtUtc = DateTime.SpecifyKind(reader.GetDateTime(2), DateTimeKind.Utc), + ExpiresAtUtc = reader.IsDBNull(3) ? null : DateTime.SpecifyKind(reader.GetDateTime(3), DateTimeKind.Utc), + Reason = reader.IsDBNull(4) ? null : reader.GetString(4), + ServerName = reader.IsDBNull(5) ? null : reader.GetString(5), + MetricName = reader.IsDBNull(6) ? null : reader.GetString(6), + DatabasePattern = reader.IsDBNull(7) ? null : reader.GetString(7), + QueryTextPattern = reader.IsDBNull(8) ? null : reader.GetString(8), + WaitTypePattern = reader.IsDBNull(9) ? null : reader.GetString(9), + JobNamePattern = reader.IsDBNull(10) ? null : reader.GetString(10) + }); + } + } + catch (Exception ex) + { + /* Non-fatal — start with empty rules if the store is not ready (Lite's behavior). */ + _logger?.LogWarning("Could not load mute rules — starting with none: {Message}", ex.Message); + rules.Clear(); + } + + return rules; + } + + public async Task InsertAsync(MuteRule rule) + { + if (rule is null) + { + throw new ArgumentNullException(nameof(rule)); + } + + await using var connection = await _postgres.OpenConnectionAsync(); + using var command = new NpgsqlCommand(@" +INSERT INTO config_mute_rules + (id, enabled, created_at_utc, expires_at_utc, reason, + server_name, metric_name, database_pattern, + query_text_pattern, wait_type_pattern, job_name_pattern) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", connection); + command.Parameters.AddWithValue(rule.Id); + command.Parameters.AddWithValue(rule.Enabled); + command.Parameters.AddWithValue(Naive(rule.CreatedAtUtc)); + AddNullable(command, rule.ExpiresAtUtc); + AddNullableText(command, rule.Reason); + AddNullableText(command, rule.ServerName); + AddNullableText(command, rule.MetricName); + AddNullableText(command, rule.DatabasePattern); + AddNullableText(command, rule.QueryTextPattern); + AddNullableText(command, rule.WaitTypePattern); + AddNullableText(command, rule.JobNamePattern); + await command.ExecuteNonQueryAsync(); + } + + public async Task UpdateAsync(MuteRule rule) + { + if (rule is null) + { + throw new ArgumentNullException(nameof(rule)); + } + + await using var connection = await _postgres.OpenConnectionAsync(); + using var command = new NpgsqlCommand(@" +UPDATE config_mute_rules SET + enabled = $2, expires_at_utc = $3, reason = $4, + server_name = $5, metric_name = $6, database_pattern = $7, + query_text_pattern = $8, wait_type_pattern = $9, job_name_pattern = $10 +WHERE id = $1", connection); + command.Parameters.AddWithValue(rule.Id); + command.Parameters.AddWithValue(rule.Enabled); + AddNullable(command, rule.ExpiresAtUtc); + AddNullableText(command, rule.Reason); + AddNullableText(command, rule.ServerName); + AddNullableText(command, rule.MetricName); + AddNullableText(command, rule.DatabasePattern); + AddNullableText(command, rule.QueryTextPattern); + AddNullableText(command, rule.WaitTypePattern); + AddNullableText(command, rule.JobNamePattern); + await command.ExecuteNonQueryAsync(); + } + + public async Task SetEnabledAsync(string ruleId, bool enabled) + { + await using var connection = await _postgres.OpenConnectionAsync(); + using var command = new NpgsqlCommand("UPDATE config_mute_rules SET enabled = $2 WHERE id = $1", connection); + command.Parameters.AddWithValue(ruleId); + command.Parameters.AddWithValue(enabled); + await command.ExecuteNonQueryAsync(); + } + + public async Task DeleteAsync(string ruleId) + { + await using var connection = await _postgres.OpenConnectionAsync(); + using var command = new NpgsqlCommand("DELETE FROM config_mute_rules WHERE id = $1", connection); + command.Parameters.AddWithValue(ruleId); + await command.ExecuteNonQueryAsync(); + } + + public async Task DeleteExpiredAsync(IReadOnlyList expiredIds) + { + if (expiredIds is null || expiredIds.Count == 0) + { + return; + } + + await using var connection = await _postgres.OpenConnectionAsync(); + foreach (var id in expiredIds) + { + using var command = new NpgsqlCommand("DELETE FROM config_mute_rules WHERE id = $1", connection); + command.Parameters.AddWithValue(id); + await command.ExecuteNonQueryAsync(); + } + } + + /// Npgsql rejects Kind=Utc against `timestamp`; the values are UTC by contract. + private static DateTime Naive(DateTime value) => + DateTime.SpecifyKind(value, DateTimeKind.Unspecified); + + private static void AddNullable(NpgsqlCommand command, DateTime? value) => + command.Parameters.Add(new NpgsqlParameter + { + NpgsqlDbType = NpgsqlDbType.Timestamp, + Value = value.HasValue ? Naive(value.Value) : DBNull.Value + }); + + private static void AddNullableText(NpgsqlCommand command, string? value) => + command.Parameters.Add(new NpgsqlParameter + { + NpgsqlDbType = NpgsqlDbType.Text, + Value = (object?)value ?? DBNull.Value + }); +} diff --git a/Darling/PerformanceMonitor.Darling.Service/PlanFetchRequest.cs b/Darling/PerformanceMonitor.Darling.Service/PlanFetchRequest.cs new file mode 100644 index 000000000..0e508cdc6 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/PlanFetchRequest.cs @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Text.Json; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// The parsed fetch_plan command payload an args_json carries (the viewer builds it, the service +/// parses it). Exactly one of the two handle modes is populated: for the query-grid +/// fetch (the currently-cached plan by plan_handle, via PgPlanFetcher.FetchPlanXmlAsync) or +/// + the statement offsets for the deadlock-graph / blocked-process fetch (those XE +/// payloads carry only a sql_handle per executionStack frame — no plan_handle — so they need the by-sql_handle +/// path, PgPlanFetcher.FetchPlanBySqlHandleAsync). Property names are camelCase to match the viewer's +/// serialization (parsed case-insensitively). This is a pure args model + validation +/// () — the actual cache read is PgPlanFetcher's, not duplicated here. +/// +public sealed record PlanFetchRequest( + string? PlanHandle, + string? SqlHandle, + int StatementStartOffset, + int StatementEndOffset, + string? DatabaseName) +{ + /// True when at least one usable handle is present — the dispatch fails a request with neither. + public bool HasHandle => !string.IsNullOrWhiteSpace(PlanHandle) || !string.IsNullOrWhiteSpace(SqlHandle); + + /// True when the plan_handle mode should run (it takes precedence when both are somehow present). + public bool UsePlanHandle => !string.IsNullOrWhiteSpace(PlanHandle); + + private static readonly JsonSerializerOptions s_options = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + /// + /// Parses args_json into a request, returning false for null/blank/malformed JSON or a request that + /// carries neither handle. Statement offsets default to whole-statement (0 / -1) when absent. Never throws. + /// + public static bool TryParse(string? argsJson, out PlanFetchRequest request) + { + request = new PlanFetchRequest(null, null, 0, -1, null); + if (string.IsNullOrWhiteSpace(argsJson)) + { + return false; + } + + Dto? dto; + try + { + dto = JsonSerializer.Deserialize(argsJson, s_options); + } + catch (JsonException) + { + return false; + } + + if (dto is null) + { + return false; + } + + var parsed = new PlanFetchRequest( + NullIfBlank(dto.PlanHandle), + NullIfBlank(dto.SqlHandle), + dto.StatementStartOffset ?? 0, + dto.StatementEndOffset ?? -1, + NullIfBlank(dto.DatabaseName)); + + if (!parsed.HasHandle) + { + return false; + } + + request = parsed; + return true; + } + + private static string? NullIfBlank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value; + + /// The wire shape of args_json — offsets nullable so an absent pair defaults to whole-statement. + private sealed class Dto + { + public string? PlanHandle { get; set; } + public string? SqlHandle { get; set; } + public int? StatementStartOffset { get; set; } + public int? StatementEndOffset { get; set; } + public string? DatabaseName { get; set; } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Program.cs b/Darling/PerformanceMonitor.Darling.Service/Program.cs new file mode 100644 index 000000000..67baef7c1 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Program.cs @@ -0,0 +1,261 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Service.Mcp; + +/* #1581: classify the FIRST argument before anything else. Only a no-arg invocation (StartHost — how the + Windows Service Control Manager starts the exe) or a RECOGNIZED verb reaches the host; --version/--help + print and exit 0; an UNKNOWN option prints usage to stderr and exits NON-ZERO without starting the host. + The incident: `Service.exe --version` used to fall through into a real service startup and spawn a second + instance that fought the first over the bundled PostgreSQL and ports — the outage. The recognized-verb + dispatch below is unchanged; it only runs for the RunKnownVerb / StartHost fall-through. */ +switch (DarlingCliCommands.ClassifyStartupArgs(args)) +{ + case StartupAction.PrintVersion: + Console.WriteLine(DarlingCliCommands.ProductVersion()); + return 0; + + case StartupAction.PrintHelp: + Console.WriteLine(DarlingCliCommands.UsageText()); + return 0; + + case StartupAction.UnknownOption: + Console.Error.WriteLine($"Unknown option: {args[0]}"); + Console.Error.WriteLine(); + Console.Error.WriteLine(DarlingCliCommands.UsageText()); + return 1; + + case StartupAction.RunKnownVerb: + case StartupAction.StartHost: + default: + /* Fall through to the recognized-verb dispatch (RunKnownVerb) or the host build (StartHost). */ + break; +} + +/* CLI verb: encrypt a SQL-auth password for darling.json. Reads from stdin (not an argument, + so the plaintext never lands in shell history) and prints the DPAPI-LocalMachine blob. */ +if (args.Length > 0 && DarlingCliCommands.IsEncryptPasswordVerb(args[0])) +{ + if (!OperatingSystem.IsWindows()) + { + Console.Error.WriteLine("--encrypt-password requires Windows (DPAPI)."); + return 1; + } + + Console.Error.Write("Password: "); + var plaintext = Console.ReadLine(); + if (string.IsNullOrEmpty(plaintext)) + { + Console.Error.WriteLine("No password read from stdin."); + return 1; + } + + Console.WriteLine(DarlingSecrets.Protect(plaintext)); + Console.Error.WriteLine("Paste the line above into the server's \"encryptedPassword\" in darling.json."); + return 0; +} + +/* CLI verb: validate darling.json and probe every configured server (reachability + permission pre-flight), + reusing the same DarlingServerConnector probe the test_connect command runs. Optional second arg = an + explicit config path (else the usual DARLING_CONFIG / next-to-binary resolution). Exit 0 iff all pass. */ +if (args.Length > 0 && DarlingCliCommands.IsValidateConfigVerb(args[0])) +{ + var configPath = args.Length > 1 ? args[1] : null; + return await DarlingCliCommands.ValidateConfigAsync(configPath, Console.Out, Console.Error, CancellationToken.None); +} + +/* CLI verb: print a paste-ready remote-viewer connection string + the server TLS cert for the opt-in store + network endpoint (darling-network-endpoints D8). It DPAPI-decrypts the network role's credential, so it is + Windows-only (same guard shape as --encrypt-password). Optional second arg = an explicit config path. */ +if (args.Length > 0 && DarlingCliCommands.IsPrintViewerConnectionVerb(args[0])) +{ + if (!OperatingSystem.IsWindows()) + { + Console.Error.WriteLine("--print-viewer-connection requires Windows (DPAPI)."); + return 1; + } + + var configPath = args.Length > 1 ? args[1] : null; + return await DarlingCliCommands.PrintViewerConnectionAsync(configPath, Console.Out, Console.Error, CancellationToken.None); +} + +/* CLI verb: the interactive --configure-network wizard (#1561) — guides the operator through the opt-in + store / MCP LAN exposure, validating every input by delegation to the SAME resolvers the running service + fail-closes on, then splicing a comment-preserving edit into darling.json behind a timestamped backup. It + generates + DPAPI-protects the MCP bearer token, so it is Windows-only (same guard shape as the two verbs + above). Optional second arg = an explicit config path. Console.In is the scripted-input testability lever. */ +if (args.Length > 0 && DarlingCliCommands.IsConfigureNetworkVerb(args[0])) +{ + if (!OperatingSystem.IsWindows()) + { + Console.Error.WriteLine("--configure-network requires Windows (DPAPI + service control)."); + return 1; + } + + var configPath = args.Length > 1 ? args[1] : null; + return await DarlingCliCommands.ConfigureNetworkAsync( + configPath, Console.In, Console.Out, Console.Error, CancellationToken.None); +} + +/* CLI verbs: enable/disable the embedded MCP + web-dashboard endpoints on a HEADLESS managed deployment. Each + flips the live switch in config.config_service (mcp_enabled/web_enabled — the store is authoritative after the + first run; darling.json's enabled is only the seed) via a targeted UPDATE whose self-bump trigger makes the + worker hot-reload within one sweep, and — when the endpoint opts into LAN exposure — opens/removes the matching + scoped firewall rule if elevated (else prints it as an elevated handoff). They decrypt the managed owner + credential + touch the firewall, so they are Windows-only (same guard shape as --print-viewer-connection). + Optional second arg = an explicit config path. The IsKnownVerb allow-list mirrors this dispatch (they cannot drift). */ +if (args.Length > 0 && DarlingCliCommands.IsEnableMcpVerb(args[0])) +{ + if (!OperatingSystem.IsWindows()) + { + Console.Error.WriteLine("--enable-mcp requires Windows (DPAPI + firewall)."); + return 1; + } + + var configPath = args.Length > 1 ? args[1] : null; + return await DarlingCliCommands.EnableMcpAsync(configPath, Console.Out, Console.Error, CancellationToken.None); +} + +if (args.Length > 0 && DarlingCliCommands.IsDisableMcpVerb(args[0])) +{ + if (!OperatingSystem.IsWindows()) + { + Console.Error.WriteLine("--disable-mcp requires Windows (DPAPI + firewall)."); + return 1; + } + + var configPath = args.Length > 1 ? args[1] : null; + return await DarlingCliCommands.DisableMcpAsync(configPath, Console.Out, Console.Error, CancellationToken.None); +} + +if (args.Length > 0 && DarlingCliCommands.IsEnableWebVerb(args[0])) +{ + if (!OperatingSystem.IsWindows()) + { + Console.Error.WriteLine("--enable-web requires Windows (DPAPI + firewall)."); + return 1; + } + + var configPath = args.Length > 1 ? args[1] : null; + return await DarlingCliCommands.EnableWebAsync(configPath, Console.Out, Console.Error, CancellationToken.None); +} + +if (args.Length > 0 && DarlingCliCommands.IsDisableWebVerb(args[0])) +{ + if (!OperatingSystem.IsWindows()) + { + Console.Error.WriteLine("--disable-web requires Windows (DPAPI + firewall)."); + return 1; + } + + var configPath = args.Length > 1 ? args[1] : null; + return await DarlingCliCommands.DisableWebAsync(configPath, Console.Out, Console.Error, CancellationToken.None); +} + +var builder = Host.CreateApplicationBuilder(args); + +/* Windows-service lifetime is a no-op when run from a console, so the same exe + serves interactive debugging and `sc create` installation (plan HP7/Phase 8). */ +builder.Services.AddWindowsService(options => +{ + options.ServiceName = "PerformanceMonitor Darling"; +}); + +/* The rolling file log under %ProgramData%\PerformanceMonitorDarling\logs — the service's PRIMARY + diagnostic surface (see DarlingFileLoggerProvider remarks). Registered unconditionally: console + runs get the same file, which is exactly what an operator collecting a bug report wants. */ +builder.Logging.AddProvider(new DarlingFileLoggerProvider()); + +if (OperatingSystem.IsWindows()) +{ + /* Pin the Event Log source to the SERVICE NAME so operators find events where the docs say to + look ("PerformanceMonitor Darling"), not under the assembly name AddWindowsService defaults + to. The source itself can only be REGISTERED by an elevated principal: the recommended + NT SERVICE virtual account cannot, so the attempt below is best-effort (it succeeds on an + elevated console run or an install script that pre-created it; see the README install step). + When the source does not exist, the Event Log provider silently drops events — which is why + the file log above, not this, is the primary surface. */ + builder.Logging.AddEventLog(settings => settings.SourceName = "PerformanceMonitor Darling"); + try + { + if (!System.Diagnostics.EventLog.SourceExists("PerformanceMonitor Darling")) + { + System.Diagnostics.EventLog.CreateEventSource("PerformanceMonitor Darling", "Application"); + } + } + catch + { + /* SourceExists itself throws for a non-elevated caller when the source is missing (it + probes the Security log). Degrade: the file log carries the diagnostics regardless. */ + } +} + +/* #1560/#1562: the live MCP + web enable/port seams — the worker publishes the control-plane values on + every reload; each host's supervisor observes and starts/stops/rebinds without a service restart. */ +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +builder.Services.AddHostedService(); + +/* AN4: the analysis MCP tools over Streamable HTTP — registered always, self-gating on + darling.json's mcp.enabled (default OFF), so Program.cs stays config-free like the worker. */ +builder.Services.AddHostedService(); + +/* #1562: the read-only web dashboard on its own port — registered always, self-gating on + darling.json's web.enabled (default OFF), same config-free posture as the worker and MCP host. */ +builder.Services.AddHostedService(); + +var host = builder.Build(); + +/* #1581 single-instance guard: acquire a system-wide named mutex BEFORE running the host (Build() only + constructs the DI graph; Run() is what starts the worker and touches Postgres). If another instance + already holds it, log and exit non-zero WITHOUT starting — a second instance would fight the first over + the bundled PostgreSQL shared-memory segment and the MCP/web ports (the ~7.5h outage). Windows-only: the + Global\ namespace and the shared-memory fight are Windows concerns, and the managed-Postgres bootstrap + already fail-closes on non-Windows — matching how the codebase gates Windows behavior. The guard is + acquired and released on THIS (main) thread, around the blocking host.Run(), honoring the mutex's thread + affinity. */ +SingleInstanceGuard? instanceGuard = null; +if (OperatingSystem.IsWindows()) +{ + var startupLogger = host.Services.GetRequiredService().CreateLogger("SingleInstance"); + instanceGuard = SingleInstanceGuard.TryAcquire(SingleInstanceGuard.ServiceMutexName); + if (!instanceGuard.Owns) + { + startupLogger.LogCritical( + "Another PerformanceMonitor Darling service instance already holds the single-instance lock ({Mutex}) — exiting without starting a second instance (a second instance would fight the first over the bundled PostgreSQL and the MCP/web ports).", + SingleInstanceGuard.ServiceMutexName); + instanceGuard.Dispose(); + /* Run() disposes the host on the normal path; we are skipping Run(), so dispose it ourselves. */ + host.Dispose(); + return 4; + } + + if (instanceGuard.WasAbandoned) + { + startupLogger.LogWarning( + "Acquired the single-instance lock ({Mutex}) left abandoned by a previous instance that exited without a clean shutdown — continuing startup.", + SingleInstanceGuard.ServiceMutexName); + } +} + +try +{ + host.Run(); + return 0; +} +finally +{ + instanceGuard?.Dispose(); +} diff --git a/Darling/PerformanceMonitor.Darling.Service/SingleInstanceGuard.cs b/Darling/PerformanceMonitor.Darling.Service/SingleInstanceGuard.cs new file mode 100644 index 000000000..23d6e6fb1 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/SingleInstanceGuard.cs @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// A system-wide single-instance lock for the Darling service (#1581). The field incident: a stray +/// Service.exe --version fell through into a real startup and spun up a SECOND service instance, which +/// fought the first over the bundled PostgreSQL shared-memory segment and the MCP/web ports — a ~7.5-hour +/// outage. This guard is the defense-in-depth backstop (the arg handling in +/// stops the specific --version path; this stops ANY second invocation that reaches startup): acquire a +/// named mutex BEFORE running the host (which is what touches Postgres); if another instance already holds it, +/// the caller logs and exits without starting. +/// +/// The production name is in the Global\ namespace so it spans +/// terminal-services sessions — the service runs in session 0 and an interactive Service.exe run (the +/// incident) is in the user session, so a session-local (Local\) mutex would NOT collide. Creating a +/// Global\ object needs SeCreateGlobalPrivilege, which the service account and elevated consoles +/// hold; tests pass a Local\ name to stay permission-independent. +/// +/// An (a previous instance crashed or was force-killed without a +/// clean shutdown) is treated as acquired, with set so the caller warns — a stale +/// abandoned mutex must never wedge every future start. The owning instance releases the mutex on graceful +/// shutdown (), so a normal SCM stop→start re-acquires cleanly (the SCM serializes the two +/// processes, and each start is a fresh process). +/// +/// Windows-guarded by the CALLER (OperatingSystem.IsWindows()), matching how the codebase gates +/// Windows-only behavior: the Global\ namespace and the managed-Postgres shared-memory fight are Windows +/// concerns, and the managed bootstrap already fail-closes on non-Windows. +/// +internal sealed class SingleInstanceGuard : IDisposable +{ + /// The production mutex name — Global\ so it spans sessions (service in session 0 vs an + /// interactive user-session invocation). Pinned by test. + internal const string ServiceMutexName = @"Global\PerformanceMonitorDarlingService"; + + private Mutex? _mutex; + private bool _owns; + private bool _disposed; + + private SingleInstanceGuard() + { + } + + /// True when this process acquired the single-instance slot and should proceed with startup; + /// false when another instance already holds it and the caller must exit without starting. + public bool Owns => _owns; + + /// True when the slot was acquired from a previous instance that left the mutex abandoned (it + /// crashed / was killed without releasing) — the caller should log a warning but proceed. + public bool WasAbandoned { get; private set; } + + /// + /// Attempts to become the single running instance under . Never throws for the + /// expected "already held" / "abandoned" outcomes — inspect / . + /// Must be paired with on the SAME thread (a mutex has thread affinity — the acquiring + /// thread must release it), which the caller satisfies by disposing in the finally around the blocking + /// host.Run(). + /// + public static SingleInstanceGuard TryAcquire(string mutexName) + { + var guard = new SingleInstanceGuard(); + guard._mutex = new Mutex(initiallyOwned: false, mutexName); + try + { + /* WaitOne(0): take the mutex if free, otherwise return false immediately (another instance holds + it) — never block. */ + guard._owns = guard._mutex.WaitOne(TimeSpan.Zero, exitContext: false); + } + catch (AbandonedMutexException) + { + /* A previous owner died without releasing — WaitOne throws but we NOW hold it. */ + guard._owns = true; + guard.WasAbandoned = true; + } + + if (!guard._owns) + { + /* We never owned it — dispose our handle now (nothing to release). */ + guard._mutex.Dispose(); + guard._mutex = null; + } + + return guard; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + if (_mutex is null) + { + return; + } + + if (_owns) + { + /* ReleaseMutex must run on the acquiring thread; the caller disposes on the same (main) thread it + acquired on. Swallow the rare not-held/wrong-thread case on an abnormal exit path. */ + try + { + _mutex.ReleaseMutex(); + } + catch (ApplicationException) + { + } + } + + _mutex.Dispose(); + _mutex = null; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs b/Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs new file mode 100644 index 000000000..dc81c7134 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs @@ -0,0 +1,702 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Npgsql; +using NpgsqlTypes; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; +using PerformanceMonitor.Notifications; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// The service side of the store<->service control plane (Stage 1): the service reads its LIVE +/// operational config from the V17 config.* tables instead of only from darling.json. Generalizes +/// the mute-rule load pattern (): on first startup, if a config section is +/// empty, SEED it once from darling.json; thereafter the STORE is authoritative. The worker polls +/// config_service.config_version each sweep and, on change, re-reads and hot-swaps the held +/// in place — the by-reference seam and the +/// runner's () => config.CapturePlans provider reflect the change immediately. +/// +/// Store-unreachable is non-fatal (Lite's mute-store posture): the read/seed methods log a warning +/// and return null / no-op so the service keeps running on the darling.json-loaded config — never +/// worse than before this feature. Timestamps are naive-UTC (Npgsql rejects Kind=Utc against +/// timestamp). Secrets are never plaintext in the store: encrypted_password carries the DPAPI +/// blob, and a darling.json plaintext dev password is backfilled from the in-memory bootstrap config by +/// server_id at read time (so it drives the connect path without ever being written to Postgres). +/// +public sealed class StoreConfigProvider +{ + private readonly NpgsqlDataSource _postgres; + private readonly ILogger? _logger; + + public StoreConfigProvider(NpgsqlDataSource postgres, ILogger? logger = null) + { + _postgres = postgres ?? throw new ArgumentNullException(nameof(postgres)); + _logger = logger; + } + + /* ---------------- reload beacon ---------------- */ + + /// + /// Reads config_service.config_version — the reload beacon the worker polls each sweep. + /// Returns null when the store is unreachable or the single row is missing (the caller keeps its + /// last-seen version and reloads nothing, never crashing on a transient store blip). + /// + public async Task ReadConfigVersionAsync(CancellationToken cancellationToken) + { + try + { + await using var connection = await _postgres.OpenConnectionAsync(cancellationToken); + using var command = new NpgsqlCommand("SELECT config_version FROM config_service WHERE id = 1", connection); + var result = await command.ExecuteScalarAsync(cancellationToken); + return result is null or DBNull ? null : Convert.ToInt64(result, System.Globalization.CultureInfo.InvariantCulture); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger?.LogWarning("Could not read config_version — keeping the current live config: {Message}", ex.Message); + return null; + } + } + + /* ---------------- seed (darling.json -> store, once, only empty sections) ---------------- */ + + /// + /// Seeds the store from darling.json ONCE — idempotent, seeding only sections that are still empty + /// (each guarded by a row-count check so a re-seed writes nothing AND fires no config_version bump). + /// The desired-state tables seed first and config_service (the beacon + completion marker) LAST, + /// so a seed interrupted before it completes leaves config_service absent — the worker then reads + /// a null config_version and never reloads a half-seeded store, re-seeding on the next start. + /// config_collector_schedules is intentionally left empty (absent row = + /// ). Failure-isolated: a seed error is warned and the service + /// proceeds on darling.json. + /// + public async Task SeedIfEmptyAsync(DarlingConfig config, CancellationToken cancellationToken) + { + if (config is null) + { + throw new ArgumentNullException(nameof(config)); + } + + try + { + await using var connection = await _postgres.OpenConnectionAsync(cancellationToken); + var now = Naive(DateTime.UtcNow); + + if (await CountAsync(connection, "config_alert_settings", cancellationToken) == 0) + { + await SeedAlertSettingsAsync(connection, config, now, cancellationToken); + } + + if (await CountAsync(connection, "config_notification", cancellationToken) == 0) + { + await SeedNotificationAsync(connection, config, now, cancellationToken); + } + + if (await CountAsync(connection, "config_monitored_servers", cancellationToken) == 0) + { + await SeedMonitoredServersAsync(connection, config, now, cancellationToken); + } + + /* LAST — its presence marks the seed complete (the reload gate keys on config_version). */ + if (await CountAsync(connection, "config_service", cancellationToken) == 0) + { + await SeedServiceRowAsync(connection, config, now, cancellationToken); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger?.LogWarning( + "Could not seed the config store from darling.json — running on the file config; the store will seed on a later start: {Message}", + ex.Message); + } + } + + private static async Task CountAsync(NpgsqlConnection connection, string table, CancellationToken ct) + { + /* table is a compile-time constant name, never user input — interpolation is safe. */ + using var command = new NpgsqlCommand($"SELECT COUNT(*) FROM config.{table}", connection); + return Convert.ToInt64(await command.ExecuteScalarAsync(ct), System.Globalization.CultureInfo.InvariantCulture); + } + + private static async Task SeedServiceRowAsync(NpgsqlConnection connection, DarlingConfig config, DateTime now, CancellationToken ct) + { + /* config_version starts at 0; the four desired-state seed writes below bump it via the trigger, + so the worker's post-seed baseline read reflects the seeded state and triggers no spurious reload. */ + using var command = new NpgsqlCommand(@" +INSERT INTO config_service (id, paused, capture_plans, mcp_enabled, mcp_port, web_enabled, web_port, config_version, updated_at, updated_by) +VALUES (1, FALSE, $1, $2, $3, $4, $5, 0, $6, 'seed') +ON CONFLICT (id) DO NOTHING", connection); + command.Parameters.AddWithValue(config.CapturePlans); + command.Parameters.AddWithValue(config.Mcp.Enabled); + command.Parameters.AddWithValue(config.Mcp.Port); + command.Parameters.AddWithValue(config.Web.Enabled); + command.Parameters.AddWithValue(config.Web.Port); + command.Parameters.AddWithValue(now); + await command.ExecuteNonQueryAsync(ct); + } + + private static async Task SeedAlertSettingsAsync(NpgsqlConnection connection, DarlingConfig config, DateTime now, CancellationToken ct) + { + var a = config.Alerts; + var an = config.Analysis; + using var command = new NpgsqlCommand(@" +INSERT INTO config_alert_settings ( + id, enabled, cpu_enabled, cpu_threshold_percent, cpu_mode, blocking_enabled, blocking_count_threshold, + deadlock_enabled, deadlock_count_threshold, poison_wait_enabled, poison_wait_threshold_ms, + long_running_query_enabled, long_running_query_threshold_minutes, tempdb_space_enabled, + tempdb_space_threshold_percent, low_disk_enabled, low_disk_threshold_percent, low_disk_threshold_gb, + long_running_job_enabled, long_running_job_multiplier, failed_job_enabled, failed_job_lookback_minutes, + cooldown_minutes, excluded_databases, analysis_enabled, analysis_interval_minutes, + analysis_notifications_enabled, analysis_notify_severity, delivery_mode, per_event_max, + long_running_query_max_results, long_running_query_exclude_sp_server_diagnostics, + long_running_query_exclude_wait_for, long_running_query_exclude_backups, + long_running_query_exclude_misc_waits, long_running_query_exclude_cdc, notify_connection_changes, modified_at) +VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, + $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37) +ON CONFLICT (id) DO NOTHING", connection); + command.Parameters.AddWithValue(a.Enabled); + command.Parameters.AddWithValue(a.CpuEnabled); + command.Parameters.AddWithValue(a.CpuThresholdPercent); + command.Parameters.AddWithValue(a.CpuMode); + command.Parameters.AddWithValue(a.BlockingEnabled); + command.Parameters.AddWithValue(a.BlockingCountThreshold); + command.Parameters.AddWithValue(a.DeadlockEnabled); + command.Parameters.AddWithValue(a.DeadlockCountThreshold); + command.Parameters.AddWithValue(a.PoisonWaitEnabled); + command.Parameters.AddWithValue(a.PoisonWaitThresholdMs); + command.Parameters.AddWithValue(a.LongRunningQueryEnabled); + command.Parameters.AddWithValue(a.LongRunningQueryThresholdMinutes); + command.Parameters.AddWithValue(a.TempDbSpaceEnabled); + command.Parameters.AddWithValue(a.TempDbSpaceThresholdPercent); + command.Parameters.AddWithValue(a.LowDiskEnabled); + command.Parameters.AddWithValue(a.LowDiskThresholdPercent); + command.Parameters.AddWithValue(a.LowDiskThresholdGb); + command.Parameters.AddWithValue(a.LongRunningJobEnabled); + command.Parameters.AddWithValue(a.LongRunningJobMultiplier); + command.Parameters.AddWithValue(a.FailedJobEnabled); + command.Parameters.AddWithValue(a.FailedJobLookbackMinutes); + command.Parameters.AddWithValue(a.CooldownMinutes); + AddTextArray(command, a.ExcludedDatabases); + command.Parameters.AddWithValue(an.Enabled); + command.Parameters.AddWithValue(an.IntervalMinutes); + command.Parameters.AddWithValue(an.NotificationsEnabled); + command.Parameters.AddWithValue(an.NotifySeverity); + /* #1141 delivery mode: the enum name ("Summary"/"PerEvent") into the text column; the read parses it back. */ + command.Parameters.AddWithValue(a.DeliveryMode.ToString()); + command.Parameters.AddWithValue(a.PerEventMax); + /* V20 long-running-query read shape (max results + the five noise-filter opt-outs). */ + command.Parameters.AddWithValue(a.LongRunningQueryMaxResults); + command.Parameters.AddWithValue(a.LongRunningQueryExcludeSpServerDiagnostics); + command.Parameters.AddWithValue(a.LongRunningQueryExcludeWaitFor); + command.Parameters.AddWithValue(a.LongRunningQueryExcludeBackups); + command.Parameters.AddWithValue(a.LongRunningQueryExcludeMiscWaits); + command.Parameters.AddWithValue(a.LongRunningQueryExcludeCdc); + /* V20 connection-change notify gate. */ + command.Parameters.AddWithValue(a.NotifyConnectionChanges); + command.Parameters.AddWithValue(now); + await command.ExecuteNonQueryAsync(ct); + } + + private static async Task SeedNotificationAsync(NpgsqlConnection connection, DarlingConfig config, DateTime now, CancellationToken ct) + { + var s = config.Smtp; + var w = config.Webhooks; + using var command = new NpgsqlCommand(@" +INSERT INTO config_notification ( + id, smtp_host, smtp_port, smtp_use_ssl, smtp_username, smtp_encrypted_password, smtp_from_address, + smtp_recipients, email_cooldown_minutes, teams_url, teams_proxy, slack_url, slack_proxy, modified_at) +VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) +ON CONFLICT (id) DO NOTHING", connection); + command.Parameters.AddWithValue(s.Host); + command.Parameters.AddWithValue(s.Port); + command.Parameters.AddWithValue(s.UseSsl); + AddNullableText(command, s.Username); + AddNullableText(command, s.EncryptedPassword); + command.Parameters.AddWithValue(s.From); + command.Parameters.AddWithValue(s.To); + command.Parameters.AddWithValue(s.EmailCooldownMinutes); + command.Parameters.AddWithValue(w.TeamsUrl); + command.Parameters.AddWithValue(w.TeamsProxy); + command.Parameters.AddWithValue(w.SlackUrl); + command.Parameters.AddWithValue(w.SlackProxy); + command.Parameters.AddWithValue(now); + await command.ExecuteNonQueryAsync(ct); + } + + private static async Task SeedMonitoredServersAsync(NpgsqlConnection connection, DarlingConfig config, DateTime now, CancellationToken ct) + { + /* Guarded by the caller's COUNT == 0 check — only reached when the registry is empty, so a later + Viewer deletion (Stage 3) is never resurrected by a re-seed. */ + foreach (var server in config.Servers) + { + using var command = new NpgsqlCommand(@" +INSERT INTO config_monitored_servers ( + server_id, name, host, database, auth, username, encrypted_password, encrypt_mode, + trust_server_certificate, read_only_intent, multi_subnet_failover, excluded_databases, + monthly_cost_usd, capture_plans, alert_delivery_mode_override, is_enabled, created_at, modified_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NULL, $14, TRUE, $15, $15) +ON CONFLICT (server_id) DO NOTHING", connection); + command.Parameters.AddWithValue(ServerIdHelper.GetDeterministicHashCode(server.StorageName)); + command.Parameters.AddWithValue(server.DisplayName); + command.Parameters.AddWithValue(server.Host); + AddNullableText(command, server.Database); + command.Parameters.AddWithValue(server.Auth); + AddNullableText(command, server.Username); + /* Only the DPAPI blob is ever stored; a plaintext dev password stays in darling.json and is + backfilled at read time (BuildServerFromRow's bootstrap merge). */ + AddNullableText(command, server.EncryptedPassword); + command.Parameters.AddWithValue(server.EncryptMode); + command.Parameters.AddWithValue(server.TrustServerCertificate); + command.Parameters.AddWithValue(server.ReadOnlyIntent); + command.Parameters.AddWithValue(server.MultiSubnetFailover); + AddTextArray(command, server.ExcludedDatabases); + command.Parameters.AddWithValue(server.MonthlyCostUsd); + /* Per-server delivery override (#1236): the enum name or NULL = "inherit the global". */ + AddNullableText(command, server.AlertDeliveryModeOverride?.ToString()); + command.Parameters.AddWithValue(now); + await command.ExecuteNonQueryAsync(ct); + } + } + + /* ---------------- read (store -> in-memory view) ---------------- */ + + /// + /// Reads every config.* table into an in-memory the worker applies. + /// The bootstrap config supplies the plaintext-dev-password backfill for + /// SQL-auth servers whose store row carries no DPAPI blob (never persisted; matched by server_id). + /// Returns null when the store is unreachable, so the caller keeps the current live config. + /// + public async Task LoadViewAsync(DarlingConfig bootstrap, CancellationToken cancellationToken) + { + if (bootstrap is null) + { + throw new ArgumentNullException(nameof(bootstrap)); + } + + try + { + await using var connection = await _postgres.OpenConnectionAsync(cancellationToken); + + var (paused, capturePlans, mcpEnabled, mcpPort, webEnabled, webPort, configVersion) = await ReadServiceRowAsync(connection, cancellationToken); + var (alerts, analysis) = await ReadAlertSettingsAsync(connection, cancellationToken); + var (smtp, webhooks) = await ReadNotificationAsync(connection, cancellationToken); + var servers = await ReadMonitoredServersAsync(connection, bootstrap, cancellationToken); + var schedules = await ReadScheduleOverridesAsync(connection, cancellationToken); + + return new StoreConfigView + { + ConfigVersion = configVersion, + Paused = paused, + CapturePlans = capturePlans, + McpEnabled = mcpEnabled, + McpPort = mcpPort, + WebEnabled = webEnabled, + WebPort = webPort, + Alerts = alerts, + Analysis = analysis, + Smtp = smtp, + Webhooks = webhooks, + EnabledServers = servers, + ScheduleOverrides = schedules, + }; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger?.LogWarning("Could not read the config store — keeping the current live config: {Message}", ex.Message); + return null; + } + } + + private static async Task<(bool Paused, bool CapturePlans, bool McpEnabled, int McpPort, bool WebEnabled, int WebPort, long ConfigVersion)> + ReadServiceRowAsync(NpgsqlConnection connection, CancellationToken ct) + { + using var command = new NpgsqlCommand( + "SELECT paused, capture_plans, mcp_enabled, mcp_port, web_enabled, web_port, config_version FROM config_service WHERE id = 1", connection); + using var reader = await command.ExecuteReaderAsync(ct); + if (!await reader.ReadAsync(ct)) + { + /* Row missing (unseeded) — treat as defaults; capture stays on (Darling's SKU default). */ + return (false, true, false, 5152, false, 5153, 0); + } + + return (reader.GetBoolean(0), reader.GetBoolean(1), reader.GetBoolean(2), reader.GetInt32(3), + reader.GetBoolean(4), reader.GetInt32(5), reader.GetInt64(6)); + } + + private static async Task<(AlertsConfig Alerts, AnalysisConfig Analysis)> ReadAlertSettingsAsync(NpgsqlConnection connection, CancellationToken ct) + { + using var command = new NpgsqlCommand(@" +SELECT enabled, cpu_enabled, cpu_threshold_percent, cpu_mode, blocking_enabled, blocking_count_threshold, + deadlock_enabled, deadlock_count_threshold, poison_wait_enabled, poison_wait_threshold_ms, + long_running_query_enabled, long_running_query_threshold_minutes, tempdb_space_enabled, + tempdb_space_threshold_percent, low_disk_enabled, low_disk_threshold_percent, low_disk_threshold_gb, + long_running_job_enabled, long_running_job_multiplier, failed_job_enabled, failed_job_lookback_minutes, + cooldown_minutes, excluded_databases, analysis_enabled, analysis_interval_minutes, + analysis_notifications_enabled, analysis_notify_severity, delivery_mode, per_event_max, + long_running_query_max_results, long_running_query_exclude_sp_server_diagnostics, + long_running_query_exclude_wait_for, long_running_query_exclude_backups, + long_running_query_exclude_misc_waits, long_running_query_exclude_cdc, notify_connection_changes +FROM config_alert_settings WHERE id = 1", connection); + using var reader = await command.ExecuteReaderAsync(ct); + if (!await reader.ReadAsync(ct)) + { + return (new AlertsConfig(), new AnalysisConfig()); + } + + var alerts = new AlertsConfig + { + Enabled = reader.GetBoolean(0), + CpuEnabled = reader.GetBoolean(1), + CpuThresholdPercent = reader.GetInt32(2), + CpuMode = reader.GetString(3), + BlockingEnabled = reader.GetBoolean(4), + BlockingCountThreshold = reader.GetInt32(5), + DeadlockEnabled = reader.GetBoolean(6), + DeadlockCountThreshold = reader.GetInt32(7), + PoisonWaitEnabled = reader.GetBoolean(8), + PoisonWaitThresholdMs = reader.GetInt32(9), + LongRunningQueryEnabled = reader.GetBoolean(10), + LongRunningQueryThresholdMinutes = reader.GetInt32(11), + TempDbSpaceEnabled = reader.GetBoolean(12), + TempDbSpaceThresholdPercent = reader.GetInt32(13), + LowDiskEnabled = reader.GetBoolean(14), + LowDiskThresholdPercent = reader.GetInt32(15), + LowDiskThresholdGb = reader.GetInt32(16), + LongRunningJobEnabled = reader.GetBoolean(17), + LongRunningJobMultiplier = reader.GetInt32(18), + FailedJobEnabled = reader.GetBoolean(19), + FailedJobLookbackMinutes = reader.GetInt32(20), + CooldownMinutes = reader.GetInt32(21), + ExcludedDatabases = ReadTextArray(reader, 22), + /* delivery_mode/per_event_max appended (V18) so ordinals 0–26 stay pinned; a store row from before + V18 can't reach here (the column is NOT NULL DEFAULT), but ParseDeliveryMode fails safe to Summary. */ + DeliveryMode = ParseDeliveryMode(reader.IsDBNull(27) ? null : reader.GetString(27)), + PerEventMax = reader.GetInt32(28), + /* long-running-query read shape appended (V20) at ordinals 29–34; NOT NULL DEFAULT so a pre-V20 + store row can't reach here without the columns present. */ + LongRunningQueryMaxResults = reader.GetInt32(29), + LongRunningQueryExcludeSpServerDiagnostics = reader.GetBoolean(30), + LongRunningQueryExcludeWaitFor = reader.GetBoolean(31), + LongRunningQueryExcludeBackups = reader.GetBoolean(32), + LongRunningQueryExcludeMiscWaits = reader.GetBoolean(33), + LongRunningQueryExcludeCdc = reader.GetBoolean(34), + /* connection-change notify gate appended (V20) at ordinal 35. */ + NotifyConnectionChanges = reader.GetBoolean(35), + }; + var analysis = new AnalysisConfig + { + Enabled = reader.GetBoolean(23), + IntervalMinutes = reader.GetInt32(24), + NotificationsEnabled = reader.GetBoolean(25), + NotifySeverity = reader.GetDouble(26), + }; + return (alerts, analysis); + } + + private static async Task<(SmtpConfig Smtp, WebhooksConfig Webhooks)> ReadNotificationAsync(NpgsqlConnection connection, CancellationToken ct) + { + using var command = new NpgsqlCommand(@" +SELECT smtp_host, smtp_port, smtp_use_ssl, smtp_username, smtp_encrypted_password, smtp_from_address, + smtp_recipients, email_cooldown_minutes, teams_url, teams_proxy, slack_url, slack_proxy, + generic_url, generic_headers, generic_body_template, generic_proxy +FROM config_notification WHERE id = 1", connection); + using var reader = await command.ExecuteReaderAsync(ct); + if (!await reader.ReadAsync(ct)) + { + return (new SmtpConfig(), new WebhooksConfig()); + } + + var smtp = new SmtpConfig + { + Host = reader.GetString(0), + Port = reader.GetInt32(1), + UseSsl = reader.GetBoolean(2), + Username = reader.IsDBNull(3) ? null : reader.GetString(3), + EncryptedPassword = reader.IsDBNull(4) ? null : reader.GetString(4), + From = reader.GetString(5), + To = reader.GetString(6), + EmailCooldownMinutes = reader.GetInt32(7), + }; + var webhooks = new WebhooksConfig + { + TeamsUrl = reader.GetString(8), + TeamsProxy = reader.GetString(9), + SlackUrl = reader.GetString(10), + SlackProxy = reader.GetString(11), + GenericUrl = reader.GetString(12), + GenericHeaders = reader.GetString(13), + GenericBodyTemplate = reader.GetString(14), + GenericProxy = reader.GetString(15), + }; + return (smtp, webhooks); + } + + private static async Task> ReadMonitoredServersAsync( + NpgsqlConnection connection, DarlingConfig bootstrap, CancellationToken ct) + { + var servers = new List(); + using var command = new NpgsqlCommand(@" +SELECT name, host, database, auth, username, encrypted_password, encrypt_mode, trust_server_certificate, + read_only_intent, multi_subnet_failover, excluded_databases, monthly_cost_usd, alert_delivery_mode_override +FROM config_monitored_servers WHERE is_enabled = TRUE +ORDER BY name", connection); + using var reader = await command.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + servers.Add(BuildServerFromRow(reader, bootstrap)); + } + + return servers; + } + + /// + /// Reconstructs a from a store row, backfilling the SQL-auth secret from + /// the in-memory bootstrap config when the store row carries no DPAPI blob — this is how a darling.json + /// plaintext dev password (never stored) still drives the connect path. The bootstrap match is on the + /// FULL identity (exact storage name + auth kind + username), NOT the derived server_id alone: + /// is a 32-bit hash whose collisions are craftable, + /// so matching on the id alone would let a config-write principal cross-wire another server's plaintext + /// secret onto an attacker-chosen host. The secret is copied only when EXACTLY ONE bootstrap server + /// matches the full identity. + /// + private static MonitoredServer BuildServerFromRow(NpgsqlDataReader reader, DarlingConfig bootstrap) + { + var server = new MonitoredServer + { + Name = reader.GetString(0), + Host = reader.GetString(1), + Database = reader.IsDBNull(2) ? null : reader.GetString(2), + Auth = reader.GetString(3), + Username = reader.IsDBNull(4) ? null : reader.GetString(4), + EncryptedPassword = reader.IsDBNull(5) ? null : reader.GetString(5), + EncryptMode = reader.GetString(6), + TrustServerCertificate = reader.GetBoolean(7), + ReadOnlyIntent = reader.GetBoolean(8), + MultiSubnetFailover = reader.GetBoolean(9), + ExcludedDatabases = ReadTextArray(reader, 10), + MonthlyCostUsd = reader.GetDecimal(11), + /* #1236: the per-server delivery override (null = inherit the global), available at delivery time. */ + AlertDeliveryModeOverride = ParseDeliveryOverride(reader.IsDBNull(12) ? null : reader.GetString(12)), + }; + + if (server.UsesSqlAuth && string.IsNullOrWhiteSpace(server.EncryptedPassword)) + { + var matches = bootstrap.Servers.Where(s => + s.UsesSqlAuth + && string.Equals(s.StorageName, server.StorageName, StringComparison.OrdinalIgnoreCase) + && string.Equals(s.Username, server.Username, StringComparison.Ordinal)).ToList(); + + if (matches.Count == 1) + { + server.EncryptedPassword = matches[0].EncryptedPassword; + server.Password = matches[0].Password; + } + } + + return server; + } + + private static async Task> ReadScheduleOverridesAsync(NpgsqlConnection connection, CancellationToken ct) + { + var overrides = new List(); + using var command = new NpgsqlCommand( + "SELECT server_id, collector_name, frequency_minutes, retention_days, enabled FROM config_collector_schedules", connection); + using var reader = await command.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + overrides.Add(new ScheduleOverride( + reader.IsDBNull(0) ? null : reader.GetInt32(0), + reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetInt32(2), + reader.IsDBNull(3) ? null : reader.GetInt32(3), + reader.GetBoolean(4))); + } + + return overrides; + } + + /* ---------------- apply (view -> held config, in place) ---------------- */ + + /// + /// Swaps the held 's .Alerts/.Analysis/.Smtp/.Webhooks/.CapturePlans/.Mcp/.Web + /// to the store view IN PLACE — the by-reference seam and the runner's + /// capture-plans provider read the new values on their next use, no reconstruction needed. Pure (no I/O), + /// so it is unit-testable without a live store. + /// + public static void ApplyToConfig(DarlingConfig config, StoreConfigView view) + { + if (config is null) + { + throw new ArgumentNullException(nameof(config)); + } + + if (view is null) + { + throw new ArgumentNullException(nameof(view)); + } + + config.Alerts = view.Alerts; + config.Analysis = view.Analysis; + config.Smtp = view.Smtp; + config.Webhooks = view.Webhooks; + config.CapturePlans = view.CapturePlans; + config.Mcp.Enabled = view.McpEnabled; + config.Mcp.Port = view.McpPort; + config.Web.Enabled = view.WebEnabled; + config.Web.Port = view.WebPort; + } + + /* ---------------- schedule resolution (pure) ---------------- */ + + /// + /// The effective schedule for one collector on one server: a per-server override wins over a fleet-wide + /// override (server_id NULL) wins over the code default, + /// per column (a NULL override column falls through to the next level). Pure — unit-testable without a store. + /// + public static EffectiveSchedule ResolveSchedule(string collectorName, int serverId, IReadOnlyList overrides) + { + var def = CollectorScheduleDefaults.All[collectorName]; + + ScheduleOverride? perServer = null; + ScheduleOverride? fleet = null; + if (overrides is not null) + { + foreach (var o in overrides) + { + if (!string.Equals(o.CollectorName, collectorName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (o.ServerId == serverId) + { + perServer = o; + } + else if (o.ServerId is null) + { + fleet = o; + } + } + } + + /* Sanitize operator-supplied overrides before they drive scheduling / a destructive purge: a + negative frequency or a retention < 1 (0 would invert the purge cutoff and wipe the table) + is treated as "no override" and falls through to the next level. Defense in depth with the + V17 CHECK constraints and the DarlingRetention sink clamp. */ + var frequency = ValidFrequency(perServer?.FrequencyMinutes) ?? ValidFrequency(fleet?.FrequencyMinutes) ?? def.FrequencyMinutes; + var retention = ValidRetention(perServer?.RetentionDays) ?? ValidRetention(fleet?.RetentionDays) ?? def.RetentionDays; + /* No override row falls back to the collector's shared default enabled state — true for nearly + every collector, but false for an opt-in one like long_query_completions (#1496). Falling back + to def.DefaultEnabled (not a bare true) is what makes "reset to defaults" — which DELETES the + override rows — return a default-off collector to OFF instead of silently re-enabling it. */ + var enabled = perServer?.Enabled ?? fleet?.Enabled ?? def.DefaultEnabled; + return new EffectiveSchedule(frequency, retention, enabled); + } + + /// + /// The effective FLEET-WIDE retention horizon for a collector (a per-server override can't apply to a + /// shared-table purge): the fleet override (server_id NULL) retention_days if set, else the + /// default. Pure. Feeds . + /// + public static int ResolveFleetRetentionDays(string collectorName, IReadOnlyList overrides) + { + var def = CollectorScheduleDefaults.All[collectorName]; + if (overrides is not null) + { + foreach (var o in overrides) + { + if (o.ServerId is null + && string.Equals(o.CollectorName, collectorName, StringComparison.OrdinalIgnoreCase) + && ValidRetention(o.RetentionDays) is int days) + { + return days; + } + } + } + + return def.RetentionDays; + } + + /// A retention override is honored only when >= 1 day; 0/negative would invert the purge + /// cutoff and delete everything, so it degrades to "no override" (fall through to the default). + private static int? ValidRetention(int? days) => days is int v && v >= 1 ? v : null; + + /// A frequency override is honored only when >= 0 (0 = on-load-only); negative degrades to + /// "no override" so a bad value can't make a collector run every sweep. + private static int? ValidFrequency(int? minutes) => minutes is int v && v >= 0 ? v : null; + + /* ---------------- helpers ---------------- */ + + /// Npgsql rejects Kind=Utc against `timestamp`; store all timestamps naive-UTC. + private static DateTime Naive(DateTime value) => DateTime.SpecifyKind(value, DateTimeKind.Unspecified); + + /// Parses the delivery_mode/alert_delivery_mode_override text ("Summary"/"PerEvent") + /// to the enum; an unknown or empty value fails safe to . + private static AlertNotificationMode ParseDeliveryMode(string? value) => + Enum.TryParse(value, ignoreCase: true, out var mode) ? mode : AlertNotificationMode.Summary; + + /// Parses a nullable per-server delivery override; null/empty = "inherit the global" (returns null). + private static AlertNotificationMode? ParseDeliveryOverride(string? value) => + string.IsNullOrWhiteSpace(value) ? null : ParseDeliveryMode(value); + + private static void AddNullableText(NpgsqlCommand command, string? value) => + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Text, Value = (object?)value ?? DBNull.Value }); + + private static void AddTextArray(NpgsqlCommand command, IEnumerable? values) => + command.Parameters.Add(new NpgsqlParameter + { + NpgsqlDbType = NpgsqlDbType.Array | NpgsqlDbType.Text, + Value = (values ?? Enumerable.Empty()).ToArray(), + }); + + private static List ReadTextArray(NpgsqlDataReader reader, int ordinal) => + reader.IsDBNull(ordinal) ? new List() : reader.GetFieldValue(ordinal).ToList(); +} + +/// One sparse config_collector_schedules row — NULL ServerId = fleet-wide. +public sealed record ScheduleOverride(int? ServerId, string CollectorName, int? FrequencyMinutes, int? RetentionDays, bool Enabled); + +/// The resolved per-collector schedule (override layered on ). +public sealed record EffectiveSchedule(int FrequencyMinutes, int RetentionDays, bool Enabled); + +/// +/// The in-memory snapshot of the config.* tables the worker applies on a reload. Sub-configs are +/// fresh instances (not the darling.json ones), so can swap +/// them into the held by reference. +/// +public sealed class StoreConfigView +{ + public long ConfigVersion { get; init; } + + /// + /// The service-pause flag. Surfaced from config_service for completeness but NOT enforced in Stage 1 — + /// gating the collection loop on it is Stage 2 (the command plane), where pause/resume becomes reachable. + /// Nothing writes it in Stage 1 (no viewer/command path), so there is no operator-facing dormant toggle. + /// + public bool Paused { get; init; } + + public bool CapturePlans { get; init; } + public bool McpEnabled { get; init; } + public int McpPort { get; init; } + public bool WebEnabled { get; init; } + public int WebPort { get; init; } = 5153; + public AlertsConfig Alerts { get; init; } = new(); + public AnalysisConfig Analysis { get; init; } = new(); + public SmtpConfig Smtp { get; init; } = new(); + public WebhooksConfig Webhooks { get; init; } = new(); + public IReadOnlyList EnabledServers { get; init; } = Array.Empty(); + public IReadOnlyList ScheduleOverrides { get; init; } = Array.Empty(); +} diff --git a/Darling/PerformanceMonitor.Darling.Service/darling.sample.json b/Darling/PerformanceMonitor.Darling.Service/darling.sample.json new file mode 100644 index 000000000..d715d8cb5 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/darling.sample.json @@ -0,0 +1,246 @@ +{ + // Copy this file to darling.json next to the service binary (or point the + // DARLING_CONFIG environment variable at it) and edit. Comments are allowed. + // + // FIRST-RUN SEED, then the store is authoritative: on first start the service seeds the + // config.* control-plane tables from this file (servers, alerts, analysis, smtp, webhooks, + // capturePlans, mcp) and thereafter reads its LIVE operational config from the store. Edits the + // Viewer makes to the store win over this file across restarts; this file is only re-consulted + // for a config section that is still empty in the store (and always for the postgres bootstrap + + // the SQL-auth secrets, which are never written to the store). If the store is unreachable the + // service falls back to this file — never worse than a file-only config. + // + // The Postgres store has TWO modes: + // + // 1) Managed (the shipped default, below): the service runs its own bundled + // PostgreSQL 18 + TimescaleDB — on first run it unpacks pg-runtime.zip (shipped beside + // the binary), initializes a cluster with scram-sha-256 auth and a generated password + // (DPAPI-protected in pg-credential.dpapi beside the data directory; never trust auth, + // even on localhost), preloads TimescaleDB, and starts the server on 127.0.0.1 by default + // (see the opt-in "network" block below to reach the store from the LAN over TLS). + // The connection string is DERIVED — do NOT also set connectionString (validation error). + // "port" 5641 is deliberately uncommon so the bundled instance coexists with any + // PostgreSQL (5432) already on the machine. "dataDirectory" null means + // %ProgramData%\PerformanceMonitorDarling\pg. The service stops the server on shutdown + // only when it started it. Windows-only. + // + // Least-privilege roles: the service owns the store as the "darling" superuser, but it + // provisions two login roles the Viewer connects as instead — "admin" (reads everything + + // writes the mute-rule / alert-dismiss / analysis-mute config tables) and "viewer" + // (read-only). Their credentials are generated + DPAPI-protected (pg-admin-credential.dpapi / + // pg-viewer-credential.dpapi) beside the data directory, same as the owner's. "connectAs" + // (below) picks which one the Viewer uses; the data schemas live in "collect" (read-only) and + // "config" (admin-writable), resolved by search_path so bare table names still work. + // + // 2) Bring-your-own PostgreSQL: remove "managed"/"port"/"dataDirectory"/"connectAs" and set + // "connectionString" instead, e.g. + // "postgres": { + // "connectionString": "Host=localhost;Port=5432;Username=darling;Database=darling" + // }, + // The service never touches that server's lifecycle; TimescaleDB is detected and used + // automatically when present, plain PostgreSQL works too. The collect/config schema split + // still applies (it is a migration); provision the least-privilege roles yourself with + // Darling/tools/provision-roles.sql and point a read-only Viewer's connectionString at the + // "viewer" role. + "postgres": { + "managed": true, + "port": 5641, + "dataDirectory": null, + // Which least-privilege role the Viewer connects as OVER LOOPBACK (managed mode): "admin" + // (default — can dismiss alerts and manage mute rules) or "viewer" (read-only; those actions + // are hidden/disabled). Ignored in bring-your-own mode (the connectionString picks the role). + // This is the LOCAL VM viewer's role; it is INDEPENDENT of network.role below (the REMOTE + // pg_hba role, default "viewer") — note the opposite defaults. + "connectAs": "admin", + + // OPT-IN NETWORK EXPOSURE for the store (managed mode only) — omit this whole block for the + // secure default (loopback-only, byte-for-byte today's behavior: no pg_hba network rule, no + // firewall rule, ssl=off). When present with a non-loopback "listen", the service reconciles it + // on every start: it adds the bind IP to listen_addresses, generates a self-signed TLS cert for + // verify-full, writes a marked `hostssl darling scram-sha-256` pg_hba rule + + // reloads, and best-effort adds a firewall rule (also add the exact scoped rule yourself; see + // Darling/README.md). Reconciliation is symmetric (delete the block to close the box) and + // fail-closed: any invalid/incomplete field degrades the store to loopback + a critical log line, + // never exposed. Get the remote viewer's paste-ready connection string + the server cert with: + // PerformanceMonitor.Darling.Service.exe --print-viewer-connection + // Uncomment (a comma already follows connectAs above): + // "network": { + // "listen": "192.168.1.205", // the specific bind IP (preferred; = the cert's iPAddress SAN). + // // "0.0.0.0" = all interfaces (then connect by a cert SAN name). + // "allowFrom": "192.168.1.0/24", // pg_hba + firewall CIDR (its address family must match "listen"). + // "role": "viewer" // "viewer" (default, read-only remote — the secure default) or + // // "admin" (full remote WRITES; the service warns, because admin + // // holds the config-table service-credential pivot). Never the superuser. + // } + }, + "servers": [ + { + // Integrated auth (recommended): grant the service account VIEW SERVER STATE etc. + "name": "SQL2022", + "host": "SQL2022", + "auth": "integrated", + // Optional per-server monthly cost/budget in USD for the Viewer's FinOps tab. Every FinOps cost + // figure is proportional math on this one number (a database's share = its size fraction x budget). + // Omit or 0 (the default) to hide all cost columns/cards, exactly like Performance Monitor Lite. + "monthlyCostUsd": 0, + // Optional per-server override of the global alerts.deliveryMode: "Summary" or "PerEvent" forces + // that mode for THIS server (e.g. Per-event for one noisy prod box while the fleet stays Summary); + // omit (or null) to inherit the global setting. + "alertDeliveryModeOverride": null, + "excludedDatabases": [] + }, + { + // SQL auth: run `PerformanceMonitor.Darling.Service --encrypt-password` on THIS + // machine and paste the output below. Plaintext "password" also works for dev, + // but the service logs a warning. The blob is machine-bound (DPAPI LocalMachine). + "name": "PROD1", + "host": "prod1.example.com", + "auth": "sql", + "username": "monitor", + "encryptedPassword": "", + "trustServerCertificate": false, + "encryptMode": "Mandatory", + "excludedDatabases": [ "StageDb" ] + } + ], + + // Capture execution plans into query_stats.query_plan_xml and query_store_stats.query_plan_text + // (default true). PostgreSQL TOAST compresses the plan text transparently (pglz) and TimescaleDB + // chunk compression squeezes it further, so plans are cheap to keep here — unlike Performance + // Monitor Lite, which stores to DuckDB/parquet and deliberately never captures them. Set false to + // skip plan capture (e.g. to shave storage across a very large fleet). + "capturePlans": true, + + // Record Object:Created / Object:Altered / Object:Deleted schema-change (DDL) events in the built-in + // default-trace collector (default true = today's behavior). Set false on a NOISY or BENCHMARK box where + // a create/drop-happy workload floods the viewer's System Events > Default Trace tab -- e.g. HammerDB's + // TPC-H Query 15 creates and drops a "revenue" view thousands of times, and the collector faithfully + // records every create/delete. Only the Object DDL slice is suppressed; file auto-grow/shrink, ErrorLog, + // and security-audit events are still collected. This is the shared collector's equivalent of the full + // Dashboard's @include_object_events. A file-only knob (not stored in the control plane): edit + restart. + "collectSchemaChangeEvents": true, + + // The alert engine (optional section). Every default mirrors Performance Monitor Lite's: + // cpu 80% (total mode), blocking 1, deadlock 1, poison wait 500 ms, long-running query + // 30 min, tempdb 80%, low disk 10% / 5 GB, job multiplier 3x, failed-job lookback 60 min, + // cooldown 5 min. Omit the whole section (or any key) for those defaults. + "alerts": { + "enabled": true, + // Deliver the Server-Unreachable / Server-Restored alerts when a monitored server's reachability flips + // (the headless twin of Lite's connection-change toasts). Default true; gated together with "enabled". + "notifyConnectionChanges": true, + "cpuThresholdPercent": 80, + "cpuMode": "total", // "total" (SQL + other processes) or "sql" (SQL process only) + // How deadlock/blocking alerts are delivered: "Summary" (one batched card per cycle, the default) + // or "PerEvent" (one notification per distinct incident, so downstream automation can open one + // ticket per incident). A per-server "alertDeliveryModeOverride" (see servers[]) wins over this. + "deliveryMode": "Summary", + // Per-event mode's cap on incidents delivered individually per cycle before a trailing "+N more" + // batch (so no incident is ever silently dropped). Clamped 1-100. + "perEventMax": 5, + // Long-running-query alert read shape: the row cap (clamped 1-1000) and the five noise-filter opt-outs + // (skip SP_SERVER_DIAGNOSTICS / WAITFOR / backup / XE-live-target / CDC-capture sessions). Defaults 5 / + // all true suppress the usual always-on noise; set a filter false to include those sessions. + "longRunningQueryMaxResults": 5, + "longRunningQueryExcludeSpServerDiagnostics": true, + "longRunningQueryExcludeWaitFor": true, + "longRunningQueryExcludeBackups": true, + "longRunningQueryExcludeMiscWaits": true, + "longRunningQueryExcludeCdc": true, + "excludedDatabases": [] + }, + + // Scheduled analysis (optional section). The engine analyzes each server on a cadence and persists + // its findings; notifications are delivered independently when a finding's severity meets the floor. + // Defaults: enabled, every 30 minutes (clamped 5-360), notifications on, severity floor 1.5 (0-2) with + // a fixed 6-hour per-finding re-notify cooldown. Omit the section (or any key) for the defaults. + "analysis": { + "enabled": true, + "intervalMinutes": 30, + "notificationsEnabled": true, + "notifySeverity": 1.5 + }, + + // SMTP alert delivery (optional). Enabled when host + from + to are all set. For an + // authenticated relay, set username and run --encrypt-password for the password blob. + "smtp": { + "host": "", + "port": 587, + "useSsl": true, + "username": "", + "encryptedPassword": "", + "from": "", + "to": "dba-team@example.com", + "emailCooldownMinutes": 15 + }, + + // Teams / Slack incoming webhooks (optional). A channel is enabled by a non-empty URL. + "webhooks": { + "teamsUrl": "", + "slackUrl": "" + }, + + // Embedded MCP server (optional): the SAME ~72-tool surface Lite and the Dashboard expose — the + // six analysis tools (analyze_server, get_analysis_facts, compare_analysis, audit_config, + // get_analysis_findings, mute_analysis_finding) PLUS the plan-analysis tools and the ~60 STORED + // data-read tools (every read is served from the collected store, never a live query against a + // monitored server). Over Streamable HTTP at http://localhost:{port}; see Darling/README.md for the + // full tool list. Default OFF; port 5152 avoids the Dashboard (5150) and Lite (5151) defaults so all + // three can run on one machine. + // + // OPT-IN NETWORK EXPOSURE for MCP (managed mode only) — omit "network" for the secure default + // (loopback-only, tokenless HTTP, today's behavior). When present with a non-loopback "listen", the + // MCP host binds the LAN interface behind a REQUIRED bearer token + an in-app CIDR check, fail-closed + // (any missing precondition — token / valid allowFrom / managed mode — keeps it loopback-only). + // THREAT MODEL: the token gates the whole read surface AND analyze_server, which opens LIVE outbound + // connections to your monitored SQL Servers — so this is a trusted-LAN opt-in, NEVER internet-exposed. + // There is deliberately NO TLS on MCP (a self-signed cert breaks real MCP clients), so the token + // travels cleartext on-segment: the MITM control is a TLS REVERSE PROXY in front of the endpoint + // (the in-app CIDR only bounds WHO can route to it, not the wire). + // "enabled"/"port" here are the SEED; after first start they live in the control plane and the + // Viewer's Settings toggles them LIVE (the service starts/stops/rebinds the endpoint within + // seconds - no restart). The "network" exposure block below stays file-defined and restart-only + // on purpose; the Viewer toggle still stops an exposed endpoint instantly (a kill switch). + "mcp": { + "enabled": false, + "port": 5152, + + // Uncomment to expose MCP on the LAN (a comma already follows "port" above): + // "network": { + // "listen": "192.168.1.205", // specific bind IP preferred; "0.0.0.0" = all interfaces. + // "allowFrom": "192.168.1.0/24", // in-app RemoteIpAddress check + firewall CIDR (loopback always allowed). + // "encryptedToken": "" // REQUIRED to expose; or a plaintext "token" (dev only, warned). + // } + }, + + // Embedded WEB DASHBOARD (optional): a read-only, browser-based view of the monitoring store, served by + // the Darling service on its OWN port (default 5153, avoiding the MCP family: Dashboard 5150, Lite 5151, + // Darling MCP 5152). A SEPARATE surface from "mcp" above -- its own enable flag, port, token, and exposure + // block -- because the two gate different blast radii (the MCP token guards analyze_server's LIVE outbound + // connections to your monitored SQL Servers; the web dashboard is read-only over the collected store). + // Default OFF. + // + // OPT-IN NETWORK EXPOSURE for the dashboard (managed mode only) -- omit "network" for the secure default + // (loopback-only HTTP, today's behavior). When present with a non-loopback "listen", the web host binds the + // LAN interface behind an in-app CIDR check and a token gate: a browser presents the token ONCE via + // ?token=, which is exchanged for an HMAC-signed session cookie (then the token is stripped from the URL). + // LOOPBACK stays TOKENLESS even while exposed (the surface is read-only). Fail-closed: any missing + // precondition (token / valid allowFrom / managed mode) keeps it loopback-only. There is deliberately NO + // TLS (a browser over plain HTTP on a trusted LAN); the MITM control is a TLS REVERSE PROXY in front of the + // port, same as MCP -- so this is a trusted-LAN opt-in, NEVER internet-exposed. + // "enabled"/"port" here are the SEED; after first start they live in the control plane and the Viewer's + // Settings toggles them LIVE (the service starts/stops/rebinds the endpoint within seconds - no restart). + // The "network" exposure block below stays file-defined and restart-only; the Viewer toggle still stops an + // exposed dashboard instantly (a kill switch). + "web": { + "enabled": false, + "port": 5153, + + // Uncomment to expose the dashboard on the LAN (a comma already follows "port" above): + // "network": { + // "listen": "192.168.1.205", // specific bind IP preferred; "0.0.0.0" = all interfaces. + // "allowFrom": "192.168.1.0/24", // in-app RemoteIpAddress check + firewall CIDR (loopback always allowed). + // "encryptedToken": "" // REQUIRED to expose; or a plaintext "token" (dev only, warned). + // } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/css/app.css b/Darling/PerformanceMonitor.Darling.Service/wwwroot/css/app.css new file mode 100644 index 000000000..bd7e2d22d --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/css/app.css @@ -0,0 +1,723 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +/* + * Darling Web layout + components (#1562). App shell = a fixed sidebar + a scrolling main pane. Everything + * below is plain fl/grid layout over the theme tokens — no framework, no build step. + */ + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + height: 100%; + background: var(--bg); + color: var(--fg); + font-family: var(--font); + font-size: 14px; + line-height: 1.45; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* ─────────────────────────── app shell ─────────────────────────── */ + +#app { + display: flex; + min-height: 100vh; +} + +#sidebar { + width: var(--sidebar-width); + flex: 0 0 var(--sidebar-width); + background: var(--bg-dark); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; +} + +.brand { + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--border); +} +.brand h1 { font-size: 1.05rem; margin: 0; color: var(--accent); letter-spacing: 0.2px; } +.brand .sub { font-size: 0.72rem; color: var(--muted); margin-top: 2px; } + +.nav { padding: 0.5rem 0; border-bottom: 1px solid var(--border); } +.nav a, +.server-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1.25rem; + color: var(--dim); + cursor: pointer; + border-left: 3px solid transparent; + font-size: 0.9rem; +} +.nav a:hover, +.server-item:hover { background: var(--lighter); text-decoration: none; } +.nav a.active, +.server-item.active { + background: var(--card); + border-left-color: var(--accent); + color: var(--fg); +} + +.server-list-header { + padding: 0.75rem 1.25rem 0.4rem; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--muted); +} +.server-item .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +#main { + flex: 1; + min-width: 0; + padding: 1.25rem 1.5rem calc(3rem + 28px); /* extra bottom padding clears the fixed status bar */ + overflow-x: hidden; +} + +/* ─────────────────────────── page header ─────────────────────────── */ + +.page-head { + display: flex; + align-items: baseline; + gap: 0.75rem; + flex-wrap: wrap; + margin-bottom: 1rem; +} +.page-head h2 { margin: 0; font-size: 1.35rem; } +.page-head .meta { color: var(--muted); font-size: 0.82rem; } +.page-head .spacer { flex: 1; } + +.refresh-note { color: var(--muted); font-size: 0.75rem; } + +/* ─────────────────────────── status dot + bands ─────────────────────────── */ + +.dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex: 0 0 auto; + background: var(--band-unknown); + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.25) inset; +} +.dot.band-Healthy { background: var(--band-healthy); } +.dot.band-Warning { background: var(--band-warning); } +.dot.band-Critical { background: var(--band-critical); } +.dot.band-Offline { background: transparent; border: 2px solid var(--band-offline); box-shadow: none; } + +.badge { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.15rem 0.55rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + background: var(--lighter); + color: var(--dim); +} +.badge.band-Healthy { background: rgba(129, 199, 132, 0.16); color: var(--ok); } +.badge.band-Warning { background: rgba(255, 213, 79, 0.16); color: var(--warn); } +.badge.band-Critical { background: rgba(229, 115, 115, 0.18); color: var(--err); } +.badge.band-Offline { background: rgba(168, 174, 186, 0.16); color: var(--muted); } + +/* ─────────────────────────── cards / grid ─────────────────────────── */ + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 1rem; +} + +.card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem; +} +.panel { margin-bottom: 1.25rem; } + +/* Custom-views renderer grid (#1563): stored panels tile responsively; a span-2 panel spans both columns via + the existing .panel.span-2 { grid-column: 1 / -1 } rule below. The grid gap owns spacing, so the panel's + stacked-page bottom margin is reset inside the grid. */ +.panel-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); + gap: 1.25rem; +} +.panel-grid .panel { margin-bottom: 0; } + +.panel > h3 { + margin: 0 0 0.6rem; + font-size: 0.95rem; + font-weight: 600; + color: var(--fg); + display: flex; + align-items: center; + gap: 0.5rem; +} +.panel .panel-sub { font-size: 0.75rem; color: var(--muted); font-weight: 400; } +.panel.span-2 { grid-column: 1 / -1; } + +/* server card */ +.server-card { + background: var(--card); + border: 1px solid var(--border); + border-left: 4px solid var(--band-unknown); + border-radius: var(--radius); + padding: 0.85rem 1rem; + cursor: pointer; + transition: border-color 0.1s ease, background 0.1s ease; +} +.server-card:hover { background: var(--lighter); } +.server-card.band-Healthy { border-left-color: var(--band-healthy); } +.server-card.band-Warning { border-left-color: var(--band-warning); } +.server-card.band-Critical { border-left-color: var(--band-critical); } +.server-card.band-Offline { border-left-color: var(--band-offline); } + +.server-card .head { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.15rem; +} +.server-card .head .title { font-weight: 600; font-size: 0.98rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.server-card .status-line { font-size: 0.75rem; color: var(--muted); margin-bottom: 0.6rem; } +.server-card .awaiting { color: var(--warn); } + +.metric-bands { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.4rem; +} +.metric-chip { + background: var(--bg-dark); + border-radius: var(--radius-sm); + padding: 0.3rem 0.45rem; + border-left: 3px solid var(--band-unknown); +} +.metric-chip.sev-Healthy { border-left-color: var(--ok); } +.metric-chip.sev-Warning { border-left-color: var(--warn); } +.metric-chip.sev-Critical { border-left-color: var(--err); } +.metric-chip.sev-Unknown { border-left-color: var(--muted); opacity: 0.7; } +.metric-chip .label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.3rem; + font-size: 0.7rem; + letter-spacing: 0.2px; + color: var(--muted); +} +.metric-chip .value { font-size: 0.85rem; font-weight: 600; font-variant-numeric: tabular-nums; } +.metric-chip .detail { + font-size: 0.66rem; + color: var(--muted); + margin-top: 1px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.metric-chip .sev-badge { + font-size: 0.58rem; + font-weight: 700; + letter-spacing: 0.3px; + padding: 0 0.25rem; + border-radius: 3px; + flex: 0 0 auto; +} +.metric-chip.sev-Warning .sev-badge { color: var(--warn); background: rgba(255, 213, 79, 0.16); } +.metric-chip.sev-Critical .sev-badge { color: var(--err); background: rgba(229, 115, 115, 0.18); } + +/* ─────────────────────────── rollup summary ─────────────────────────── */ + +.rollup { + display: flex; + flex-wrap: wrap; + align-items: stretch; + gap: 0.6rem; + margin-bottom: 1.25rem; +} +.rollup-group { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(88px, 1fr); gap: 0.6rem; } +.rollup-divider { width: 1px; background: var(--border); margin: 0.1rem 0.3rem; flex: 0 0 auto; } +.rollup .tile { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.6rem 0.9rem; + min-width: 88px; +} +.rollup .tile .num { font-size: 1.5rem; font-weight: 700; line-height: 1.1; font-variant-numeric: tabular-nums; } +.rollup .tile .lbl { font-size: 0.72rem; color: var(--muted); letter-spacing: 0.3px; } +.rollup .tile.healthy .num { color: var(--ok); } +.rollup .tile.warning .num { color: var(--warn); } +.rollup .tile.critical .num { color: var(--err); } +.rollup .tile.offline .num { color: var(--muted); } + +/* ─────────────────────────── stat tiles ─────────────────────────── */ + +.stats { + display: grid; + /* auto-FIT (not auto-fill): 3 tiles stretch to fill the row instead of leaving an empty 4th track. */ + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 0.75rem; +} +.stat { + background: var(--bg-dark); + border-radius: var(--radius-sm); + padding: 0.7rem 0.85rem; +} +.stat .value { font-size: 1.4rem; font-weight: 700; font-variant-numeric: tabular-nums; } +.stat .value.small { font-size: 1rem; font-weight: 600; } +/* A stat whose descriptor carries a pre-computed severity hint colors its value (coloring only, never a re-band). */ +.stat .value.sev-Healthy { color: var(--ok); } +.stat .value.sev-Warning { color: var(--warn); } +.stat .value.sev-Critical { color: var(--err); } +.stat .label { font-size: 0.72rem; color: var(--muted); letter-spacing: 0.3px; margin-top: 2px; } + +/* ─────────────────────────── tables ─────────────────────────── */ + +.table-wrap { overflow-x: auto; } +table.data { + width: 100%; + border-collapse: collapse; + font-size: 0.83rem; +} +table.data th, +table.data td { + text-align: left; + padding: 0.4rem 0.6rem; + border-bottom: 1px solid var(--border); + white-space: nowrap; +} +table.data th { + color: var(--muted); + font-weight: 600; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.4px; + background: var(--card); +} +table.data tbody tr:nth-child(even) { background: var(--row-alt); } +table.data td.num { text-align: right; font-variant-numeric: tabular-nums; } +table.data td.wrap { white-space: normal; max-width: 620px; } +table.data td.mono { font-family: var(--mono); font-size: 0.78rem; } +table.data td.sev-Warning { color: var(--warn); } +table.data td.sev-Critical { color: var(--err); } +table.data td.sev-Healthy { color: var(--ok); } + +/* ─────────────────────────── band list ─────────────────────────── */ + +.bandlist { display: flex; flex-direction: column; gap: 0.4rem; } +.bandlist .row { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.5rem 0.7rem; + background: var(--bg-dark); + border-radius: var(--radius-sm); + border-left: 3px solid var(--band-unknown); + cursor: pointer; +} +.bandlist .row:hover { background: var(--lighter); } +.bandlist .row.band-Healthy { border-left-color: var(--band-healthy); } +.bandlist .row.band-Warning { border-left-color: var(--band-warning); } +.bandlist .row.band-Critical { border-left-color: var(--band-critical); } +.bandlist .row.band-Offline { border-left-color: var(--band-offline); } +.bandlist .row .primary { font-weight: 600; } +.bandlist .row .reason { color: var(--muted); font-size: 0.82rem; flex: 1; } + +/* ─────────────────────────── charts ─────────────────────────── */ + +.chart { width: 100%; position: relative; } +.chart svg { display: block; width: 100%; height: auto; max-height: 300px; } +.chart .axis text { fill: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; } +.chart .axis .axis-unit { fill: var(--dim); font-size: 10px; } +.chart .grid-line { stroke: var(--border); stroke-width: 1; } +.chart .series-line { fill: none; stroke-width: 2; } +.chart .hover-line { stroke: var(--muted); stroke-width: 1; stroke-dasharray: 3 3; } +.chart .hover-dot { stroke: var(--bg-dark); stroke-width: 1.5; } + +.chart-legend { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 0.4rem; + font-size: 0.78rem; + color: var(--dim); +} +.chart-legend .item { display: inline-flex; align-items: center; gap: 0.35rem; } +.chart-legend .swatch { width: 12px; height: 3px; border-radius: 2px; } + +.chart-tooltip { + position: absolute; + pointer-events: none; + background: var(--bg-dark); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.4rem 0.55rem; + font-size: 0.75rem; + color: var(--fg); + white-space: nowrap; + z-index: 5; + display: none; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.4); +} +.chart-tooltip .t-time { color: var(--muted); margin-bottom: 0.2rem; } +.chart-tooltip .t-row { display: flex; align-items: center; gap: 0.35rem; } +.chart-tooltip .t-row .swatch { width: 10px; height: 3px; border-radius: 2px; } +.chart-tooltip .t-val { margin-left: auto; font-variant-numeric: tabular-nums; } + +/* Composed charts (#1563): area/stacked fills, ranked bars + donut, and the capped/partial-fleet note. */ +.chart .series-area { stroke: none; } +.chart-note { margin-top: 0.4rem; font-size: 0.72rem; color: var(--muted); } + +/* Time-series stacked-bar segments (#1563 D2): per-series fill set inline; a hairline card-colored seam separates + stacked segments and adjacent bars, and a slight fill-opacity keeps them in the same family as the area fills. */ +.chart .series-bar { stroke: var(--card); stroke-width: 0.5; fill-opacity: 0.9; } + +/* Render-only threshold reference lines (#1563 D3): a dashed neutral guide + small value label. Deliberately NOT a + severity color — a chart mark must never imply a health state (same rule as the categorical series palette). */ +.chart .threshold-line { stroke: var(--dim); stroke-width: 1; stroke-dasharray: 6 4; opacity: 0.7; } +.chart .threshold-label { fill: var(--dim); font-size: 10px; font-variant-numeric: tabular-nums; } + +/* A ranked bar chart scrolls vertically inside the panel when its list is tall (the SVG grows per row). */ +.chart-bar { max-height: 340px; overflow-y: auto; } +.chart-bar svg { max-height: none; } +.chart .bar-label { fill: var(--dim); font-size: 12px; } +.chart .bar-track { fill: var(--bg-dark); } +.chart .bar-value { fill: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; } + +/* A ranked donut: the wedges, the center total, and a value-bearing legend below. */ +.chart-pie .pie-wrap { display: flex; justify-content: center; } +.chart-pie .pie-svg { width: 100%; max-width: 260px; height: auto; } +.chart .pie-slice { stroke: var(--card); stroke-width: 1.5; } +.chart .pie-center { fill: var(--fg); font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums; } +.chart .pie-center-label { fill: var(--muted); font-size: 11px; letter-spacing: 0.3px; } +.chart-legend.pie-legend { flex-direction: column; gap: 0.25rem; margin-top: 0.6rem; } +.chart-legend .swatch.dot { width: 10px; height: 10px; border-radius: 50%; } +.chart-legend .leg-val { margin-left: auto; color: var(--muted); font-variant-numeric: tabular-nums; } +.chart-legend.pie-legend .item { width: 100%; } + +/* Event-annotation overlays (#1563 D5): a thin colored vertical marker (color set inline per source, from the + annotation palette — never a severity/series color) with a wider transparent hit line on top carrying the hover + title. The visible line is non-interactive so it never steals the series-hover overlay; the hit line does. */ +.chart .annotation-line { stroke-width: 1.5; opacity: 0.85; pointer-events: none; } +.chart .annotation-hit { stroke: transparent; stroke-width: 7; pointer-events: stroke; cursor: help; } +/* The annotation key below the chart: a short vertical tick swatch (distinct from the series' horizontal bar). */ +.chart-legend.annotation-legend { margin-top: 0.25rem; } +.chart-legend .swatch.annotation-swatch { width: 3px; height: 12px; border-radius: 1px; } + +/* Drill-down (#1563 D6): a drillable mark / legend entry gets a pointer cursor + a focus ring (legend entries are + activatable, so keyboard users get the :focus-visible ring the util.el onActivate path installs). */ +.chart .drillable { cursor: pointer; } +.chart-legend .item.drillable { cursor: pointer; border-radius: var(--radius-sm); } +.chart-legend .item.drillable:hover { color: var(--fg); } +.chart-legend .item.drillable:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +/* The transient drill-down chip above a drilled panel (#1563 D6): the pinned filter(s), a jump to the server page + (server dimension), and a clear that pops back. View-state only — nothing is persisted. */ +.drill-chip { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.4rem; + margin-bottom: 0.5rem; + padding: 0.25rem 0.5rem; + background: var(--bg-dark); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 0.78rem; +} +.drill-chip .drill-label { color: var(--dim); } +.drill-chip .drill-open { margin-left: auto; } +.drill-chip .drill-clear { padding-left: 0.5rem; padding-right: 0.5rem; } + +/* The folded "compiled SQL" transparency block inside a composed panel. */ +.compiled-sql { + margin: 0; + padding: 0.5rem 0.6rem; + background: var(--bg-dark); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-family: var(--mono); + font-size: 0.72rem; + color: var(--dim); + white-space: pre; + overflow-x: auto; + max-height: 220px; + overflow-y: auto; +} +.panel .disclosure { max-width: none; margin-top: 0.6rem; } + +/* ─────────────────────────── custom view chrome (#1563 view-level scope) ─────────────────────────── */ + +.view-controls { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0.85rem; + margin-bottom: 1.1rem; + padding: 0.75rem 0.9rem; + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); +} +.view-controls .vc-field { display: flex; flex-direction: column; gap: 0.25rem; min-width: 160px; } +.view-controls .vc-label { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.4px; color: var(--muted); } + +/* Server scope picker: a
dropdown of checkboxes (single / multi / All). */ +.scope-picker { position: relative; } +.scope-picker > .scope-summary { + list-style: none; + cursor: pointer; + padding: 0.4rem 1.5rem 0.4rem 0.6rem; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-dark); + color: var(--fg); + font-size: 0.85rem; + min-width: 180px; + position: relative; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.scope-picker > .scope-summary::-webkit-details-marker { display: none; } +.scope-picker > .scope-summary::after { content: "▾"; position: absolute; right: 0.55rem; color: var(--muted); } +.scope-picker[open] > .scope-summary::after { content: "▴"; } +.scope-picker > .scope-summary:hover { border-color: var(--accent); } +.scope-list { + position: absolute; + z-index: 10; + margin-top: 0.25rem; + min-width: 220px; + max-height: 320px; + overflow-y: auto; + padding: 0.35rem; + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45); +} +.scope-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.3rem 0.4rem; + border-radius: var(--radius-sm); + font-size: 0.85rem; + cursor: pointer; +} +.scope-item:hover { background: var(--lighter); } + +/* ─────────────────────────── states (error / empty / loading) ─────────────────────────── */ + +.strip { + padding: 0.6rem 0.85rem; + border-radius: var(--radius-sm); + font-size: 0.85rem; +} +.strip.error { background: rgba(229, 115, 115, 0.14); border: 1px solid rgba(229, 115, 115, 0.4); color: var(--err); } +.strip.empty { background: var(--bg-dark); border: 1px solid var(--border); color: var(--muted); } +.strip.loading { color: var(--muted); } + +.all-healthy { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + background: rgba(129, 199, 132, 0.1); + border: 1px solid rgba(129, 199, 132, 0.35); + border-radius: var(--radius); + color: var(--ok); + font-weight: 600; + margin-bottom: 1.25rem; +} + +.filter-box { + padding: 0.4rem 0.6rem; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-dark); + color: var(--fg); + font-size: 0.85rem; + min-width: 200px; +} +.filter-box:focus { outline: none; border-color: var(--accent); } + +.muted { color: var(--muted); } +.section-title { font-size: 1rem; font-weight: 600; margin: 0 0 0.6rem; } + +/* ─────────────────────────── keyboard focus (a11y) ─────────────────────────── */ + +.server-card:focus-visible, +.bandlist .row:focus-visible, +.server-item:focus-visible, +.nav a:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +/* ─────────────────────────── server-detail header ─────────────────────────── */ + +.page-head .server-title { display: flex; align-items: center; gap: 0.5rem; } +.page-head .server-title h2 { margin: 0; } +.page-head .server-band { display: inline-flex; } + +/* ─────────────────────────── sort control ─────────────────────────── */ + +.sort-control { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.78rem; color: var(--muted); } +.sort-select { + padding: 0.25rem 0.4rem; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-dark); + color: var(--fg); + font-size: 0.8rem; +} +.sort-select:focus { outline: none; border-color: var(--accent); } + +/* ─────────────────────────── status bar footer ─────────────────────────── */ + +#statusbar { + position: fixed; + left: 0; + right: 0; + bottom: 0; + height: 28px; + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0 1rem; + background: var(--bg-dark); + border-top: 1px solid var(--border); + font-size: 0.72rem; + color: var(--muted); + z-index: 20; +} +#statusbar .sb-sep { color: var(--border); } +#server-list { padding-bottom: 34px; } + +/* ─────────────────────────── alert status cell (collapsed channel/sent/muted/error) ─────────────────────────── */ + +.status-cell { display: inline-flex; align-items: center; gap: 0.35rem; white-space: nowrap; } +.status-cell .glyph { font-weight: 700; } +.status-cell .channel { color: var(--muted); font-size: 0.75rem; } +.status-cell.sev-Healthy { color: var(--ok); } +.status-cell.sev-Warning { color: var(--warn); } +.status-cell.sev-Critical { color: var(--err); } +.status-cell.sev-Unknown { color: var(--muted); } + +/* ─────────────────────────── disclosure (expandable alert detail / query text) ─────────────────────────── */ + +.disclosure { max-width: 640px; } +.disclosure > .disc-summary { + display: block; + cursor: pointer; + color: var(--dim); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + list-style: none; +} +.disclosure > .disc-summary::-webkit-details-marker { display: none; } +.disclosure > .disc-summary::before { content: "▸ "; color: var(--muted); } +.disclosure[open] > .disc-summary { white-space: normal; } +.disclosure[open] > .disc-summary::before { content: "▾ "; } +.disclosure > .disc-summary:hover { color: var(--fg); } +.disc-body { margin-top: 0.4rem; } + +.detail-fields { display: grid; grid-template-columns: max-content 1fr; gap: 0.15rem 0.6rem; margin-bottom: 0.3rem; } +.detail-field { display: contents; } +.detail-field .fk { color: var(--muted); font-weight: 600; } +.detail-field .fv { color: var(--fg); white-space: pre-wrap; overflow-wrap: anywhere; } +.detail-fields.detail-error .fk, +.detail-fields.detail-error .fv { color: var(--err); } +.detail-raw { white-space: pre-wrap; overflow-wrap: anywhere; color: var(--dim); } +pre.code { + margin: 0; + font-family: var(--mono); + font-size: 0.78rem; + white-space: pre-wrap; + overflow-wrap: anywhere; + color: var(--fg); +} + +/* ─────────────────────────── notebook mode (#1563 D7) ─────────────────────────── */ + +/* Rendered markdown (notebook document cells + the composer's live preview). Every node comes from the air-gapped + markdown.js (textContent-only); these are just the block styles over the theme tokens. */ +.markdown-body { color: var(--fg); line-height: 1.6; overflow-wrap: anywhere; } +.markdown-body > *:first-child { margin-top: 0; } +.markdown-body > *:last-child { margin-bottom: 0; } +.markdown-body .md-h { font-weight: 600; line-height: 1.3; margin: 1.1em 0 0.5em; color: var(--fg); } +.markdown-body h1.md-h { font-size: 1.5rem; } +.markdown-body h2.md-h { font-size: 1.25rem; } +.markdown-body h3.md-h { font-size: 1.08rem; } +.markdown-body h4.md-h, +.markdown-body h5.md-h, +.markdown-body h6.md-h { font-size: 0.95rem; } +.markdown-body .md-p { margin: 0.5em 0; } +.markdown-body .md-ul, +.markdown-body .md-ol { margin: 0.5em 0; padding-left: 1.5rem; } +.markdown-body li { margin: 0.15em 0; } +.markdown-body .md-code { + font-family: var(--mono); + font-size: 0.85em; + background: var(--bg-dark); + border: 1px solid var(--border); + border-radius: 3px; + padding: 0.05em 0.35em; +} +.markdown-body .md-pre { + margin: 0.6em 0; + padding: 0.6rem 0.75rem; + background: var(--bg-dark); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + overflow-x: auto; +} +.markdown-body .md-pre code { font-family: var(--mono); font-size: 0.8rem; color: var(--dim); white-space: pre; } +.markdown-body .md-link { color: var(--accent); } +.markdown-body .md-link:hover { text-decoration: underline; } + +/* The notebook DOCUMENT: markdown cells + composed-panel cells stacked vertically. Prose is kept to a readable + measure; panel cells fill the document width. */ +.notebook-doc { display: flex; flex-direction: column; gap: 1.1rem; max-width: 1100px; } +.notebook-md { max-width: 820px; } +.notebook-panel .panel.card { margin-bottom: 0; } + +/* The "Notebook" pill shown beside a notebook's title (page header) — a quiet accent badge, not a severity color. */ +.notebook-badge { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.5rem; + border-radius: 999px; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.3px; + text-transform: uppercase; + background: rgba(46, 174, 241, 0.16); + color: var(--accent); + border: 1px solid rgba(46, 174, 241, 0.35); +} + +/* A notebook entry in the sidebar view list gets a small document glyph so it reads apart from a dashboard view. */ +.view-item.is-notebook::before { content: "▤ "; color: var(--muted); } diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/css/editor.css b/Darling/PerformanceMonitor.Darling.Service/wwwroot/css/editor.css new file mode 100644 index 000000000..d53a1d104 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/css/editor.css @@ -0,0 +1,532 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +/* + * Custom Views (#1563) UI: the shared buttons, the sidebar + list-page view entries, the renderer chrome, and + * the composer form (panel editors, typed param inputs, the vizcfg sub-editors, and the live preview). Layered + * over the theme tokens (theme.css) like app.css; loaded globally from index.html. + */ + +/* ─────────────────────────── buttons ─────────────────────────── */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + padding: 0.4rem 0.8rem; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--card); + color: var(--fg); + font-size: 0.85rem; + font-weight: 600; + font-family: var(--font); + cursor: pointer; + line-height: 1.2; + text-decoration: none; +} +.btn:hover { background: var(--lighter); text-decoration: none; } +.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } + +.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--bg-dark); } +.btn.primary:hover { filter: brightness(1.08); background: var(--accent); } +.btn.danger { color: var(--err); border-color: rgba(229, 115, 115, 0.4); } +.btn.danger:hover { background: rgba(229, 115, 115, 0.14); } + +.btn.small { padding: 0.25rem 0.55rem; font-size: 0.78rem; font-weight: 600; } +.btn.icon { padding: 0.25rem 0.5rem; min-width: 1.9rem; } + +/* ─────────────────────────── sidebar view list ─────────────────────────── */ + +#view-list { padding-bottom: 0.5rem; } +.view-item { + display: block; + padding: 0.4rem 1.25rem; + color: var(--dim); + font-size: 0.9rem; + border-left: 3px solid transparent; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.view-item:hover { background: var(--lighter); text-decoration: none; } +.view-item.active { background: var(--card); border-left-color: var(--accent); color: var(--fg); } +.view-item.new-view { color: var(--accent); font-weight: 600; } +.view-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } + +/* ─────────────────────────── list page ─────────────────────────── */ + +.view-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 1rem; +} +.view-card { display: block; cursor: pointer; transition: border-color 0.1s ease, background 0.1s ease; } +.view-card:hover { background: var(--lighter); text-decoration: none; } +.view-card .vc-name { font-weight: 600; font-size: 1rem; color: var(--fg); margin-bottom: 0.2rem; display: flex; align-items: center; gap: 0.4rem; } +.view-card .vc-name-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } +.view-card .vc-name .notebook-badge { flex: 0 0 auto; } +.view-card .vc-desc { + font-size: 0.82rem; + color: var(--dim); + margin-bottom: 0.4rem; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +.view-card .vc-meta { font-size: 0.72rem; color: var(--muted); } + +/* enriched card: panel count + distinct viz-type chips (fetched per view — the list summary omits the definition) */ +.view-card .vc-chips { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.45rem; } +.view-card .vc-chips:empty { display: none; } +.view-card .vc-chip { + font-size: 0.7rem; + color: var(--dim); + background: var(--bg-dark); + border: 1px solid var(--border); + border-radius: 3px; + padding: 0.1rem 0.4rem; +} +.view-card .vc-chip.count { color: var(--fg); font-weight: 600; } + +/* ─────────────────────────── first-run hero (empty list) ─────────────────────────── */ + +.views-hero { + max-width: 640px; + margin: 2rem auto; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.85rem; +} +.views-hero .hero-title { font-size: 1.4rem; font-weight: 700; color: var(--fg); } +.views-hero .hero-pitch { font-size: 0.95rem; color: var(--dim); line-height: 1.55; } +.views-hero .hero-cta { font-size: 0.95rem; padding: 0.55rem 1.4rem; } +.views-hero .hero-suggest { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; margin-top: 0.25rem; } +.views-hero .hero-suggest-label { font-size: 0.75rem; color: var(--muted); } +.views-hero .hero-suggest-chips { display: flex; flex-wrap: wrap; gap: 0.4rem; justify-content: center; } +.views-hero .hero-chip { + font-size: 0.78rem; + color: var(--dim); + background: var(--card); + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.25rem 0.7rem; +} +.views-hero .import-panel { width: 100%; text-align: left; margin: 0.5rem 0 0; } + +/* ─────────────────────────── import panel ─────────────────────────── */ + +/* Secondary to the primary "New view": a quiet dashed disclosure, not a filled card that competes for the eye. */ +.import-panel { + background: transparent; + border: 1px dashed var(--border); + border-radius: var(--radius); + padding: 0.5rem 0.85rem; + margin-bottom: 1.25rem; +} +.import-panel > summary { cursor: pointer; font-weight: 600; font-size: 0.82rem; color: var(--muted); } +.import-panel > summary:hover { color: var(--fg); } +.import-body { margin-top: 0.6rem; display: flex; flex-direction: column; gap: 0.5rem; } +.import-box { + width: 100%; + padding: 0.5rem 0.6rem; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-dark); + color: var(--fg); + font-family: var(--mono); + font-size: 0.78rem; + resize: vertical; +} +.import-box:focus { outline: none; border-color: var(--accent); } +.import-actions { display: flex; gap: 0.5rem; } + +/* ─────────────────────────── renderer chrome ─────────────────────────── */ + +.page-head .btn { align-self: center; } +.view-status:empty { display: none; } +.view-status { margin-bottom: 0.85rem; } + +/* ─────────────────────────── composer ─────────────────────────── */ + +.editor-meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 0.85rem; + margin-bottom: 1.25rem; +} + +.editor-field { display: flex; flex-direction: column; gap: 0.25rem; } +.editor-field .field-label { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.4px; color: var(--muted); } + +.editor-input, +.editor-select { + padding: 0.4rem 0.55rem; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-dark); + color: var(--fg); + font-family: var(--font); + font-size: 0.85rem; + width: 100%; +} +.editor-input.mono { font-family: var(--mono); font-size: 0.8rem; } +.editor-input:focus, +.editor-select:focus { outline: none; border-color: var(--accent); } +.editor-check { width: 16px; height: 16px; accent-color: var(--accent); } + +.panels-box { display: flex; flex-direction: column; gap: 1.1rem; margin-bottom: 1rem; } + +.panel-editor { padding: 0.9rem 1rem; } +.panel-editor-head { display: flex; align-items: baseline; gap: 0.4rem; margin-bottom: 0.75rem; } +.panel-editor-head .panel-editor-title { font-weight: 700; font-size: 0.9rem; color: var(--fg); } +/* The header echo — "Panel N · Top Waits (SQL2025)" — reads as a lighter continuation of the panel number. */ +.panel-editor-head .panel-editor-sub { color: var(--muted); font-weight: 400; font-size: 0.85rem; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.panel-editor-head .panel-editor-sub:empty { display: none; } +.panel-editor-head .spacer { flex: 1; } + +/* Two-column panel editor (M2): config left, live preview right (sticky). Collapses to stacked under 900px. The + preview column only appears once a viz is chosen (progressive disclosure), so single-column is the default. */ +.panel-editor-body { display: grid; grid-template-columns: 1fr; gap: 1rem; } +.panel-editor-body.has-preview { grid-template-columns: minmax(0, 1fr) minmax(320px, 40%); } +.panel-config { min-width: 0; } +.panel-preview-col { position: sticky; top: 1rem; align-self: start; min-width: 0; } +@media (min-width: 901px) { + /* in two-column mode the preview sits at the top of its own column — drop the section divider line there */ + .panel-editor-body.has-preview .panel-preview-col { border-top: none; padding-top: 0; margin-top: 0; } +} +@media (max-width: 900px) { + .panel-editor-body.has-preview { grid-template-columns: 1fr; } + .panel-preview-col { position: static; } +} + +/* Identity row: Title is free text and deserves a wider track than the 2-option Width select (widest -> Title, + narrowest -> Width). Collapses to 2-up, then 1-up, as the (already narrowed) config column shrinks. */ +.editor-grid { + display: grid; + grid-template-columns: minmax(0, 1.3fr) minmax(0, 1.3fr) minmax(0, 0.7fr) minmax(0, 2fr); + gap: 0.75rem; + margin-bottom: 0.85rem; + align-items: end; +} +@media (max-width: 1200px) { + .editor-grid { grid-template-columns: 1fr 1fr; } +} +@media (max-width: 620px) { + .editor-grid { grid-template-columns: 1fr; } +} + +/* Section blocks (Parameters / Fields / Preview) are a distinct HEAVIER tier than the identity-row field labels: + a bordered divider + a larger, non-uppercase, semibold, brighter label. Uppercase stays reserved for the + baseline's sidebar headers + table th; the identity-row .field-label keeps the lighter (small/uppercase) tier. */ +.editor-block { margin-top: 0.85rem; border-top: 1px solid var(--border); padding-top: 0.85rem; } +.editor-block .block-label { + font-size: 0.8rem; + text-transform: none; + font-weight: 600; + letter-spacing: 0; + color: var(--dim); + margin-bottom: 0.5rem; +} +/* A one-line helper under a section label (e.g. the Fields "detected from a live sample" note). */ +.block-help { font-size: 0.75rem; color: var(--muted); margin: -0.15rem 0 0.55rem; } +/* Progressive-disclosure hint shown before a read / viz is chosen (M5). */ +.panel-hint { + font-size: 0.85rem; + color: var(--dim); + margin-top: 0.85rem; + border-top: 1px solid var(--border); + padding-top: 0.85rem; +} + +/* params */ +.params-box { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 0.6rem; +} +.param-field { display: flex; flex-direction: column; gap: 0.2rem; } +.param-field .param-name { font-size: 0.8rem; color: var(--dim); font-family: var(--mono); } +.param-field.required .param-name { color: var(--fg); } +.param-field .req { color: var(--warn); font-weight: 700; } +.param-field .param-hint { font-size: 0.68rem; color: var(--muted); } +.param-field .editor-check { align-self: flex-start; margin-top: 0.2rem; } + +/* vizcfg sub-editor */ +.vizcfg-wrap { display: flex; flex-direction: column; gap: 0.6rem; } +.vizcfg-actions { display: flex; gap: 0.5rem; } +.cfg { display: flex; flex-direction: column; gap: 0.65rem; } + +.item-list { display: flex; flex-direction: column; gap: 0.4rem; } +.item-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; + background: var(--bg-dark); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.4rem 0.5rem; +} +.item-row .editor-input, +.item-row .editor-select { width: auto; flex: 1 1 120px; min-width: 90px; } +.item-row .keyfield { flex: 1 1 140px; min-width: 100px; display: inline-flex; } +.item-row .keyfield .editor-input { width: 100%; } +.item-row .color-field { flex: 0 0 auto; } + +.keyfield { display: inline-flex; } + +/* series color: a constrained picker (#rrggbb) + palette shortcuts — never free text */ +.color-field { display: inline-flex; align-items: center; gap: 0.35rem; } +.color-picker { + width: 34px; + height: 28px; + padding: 2px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-dark); + cursor: pointer; +} +.palette { display: inline-flex; gap: 0.2rem; } +.swatch-btn { + width: 16px; + height: 16px; + border-radius: 3px; + border: 1px solid rgba(0, 0, 0, 0.3); + padding: 0; + cursor: pointer; +} +.swatch-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } + +/* preview */ +.panel-preview { + border: 1px dashed var(--border); + border-radius: var(--radius-sm); + padding: 0.75rem; + background: var(--bg-dark); +} +.panel-preview .panel { margin-bottom: 0; } +.panel-preview .panel.card { background: var(--card); } + +/* footer */ +.editor-footer { + display: flex; + align-items: center; + gap: 0.6rem; + padding-top: 1rem; + margin-top: 0.5rem; + border-top: 1px solid var(--border); +} +.save-status { font-size: 0.82rem; } +.save-status .strip { padding: 0.35rem 0.6rem; } +.save-status .strip.error { display: inline-flex; align-items: center; gap: 0.5rem; } + +/* ─────────────────────────── composed (v2) panel composer (#1563) ─────────────────────────── */ + +/* The Composed / Read kind toggle in the panel head, and the Over-time / Ranked / Single-value shape segmented + control — the same pill idiom (a bordered group of buttons; the active one filled in the accent). */ +.kind-toggle, +.seg-control { + display: inline-flex; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + overflow: hidden; +} +.kind-opt, +.seg-opt { + padding: 0.3rem 0.65rem; + border: none; + border-left: 1px solid var(--border); + background: var(--bg-dark); + color: var(--dim); + font-size: 0.78rem; + font-weight: 600; + font-family: var(--font); + cursor: pointer; + line-height: 1.2; +} +.kind-opt:first-child, +.seg-opt:first-child { border-left: none; } +.kind-opt:hover, +.seg-opt:hover { background: var(--lighter); color: var(--fg); } +.kind-opt.active, +.seg-opt.active { background: var(--accent); color: var(--bg-dark); } +.kind-opt:focus-visible, +.seg-opt:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } + +/* The composed config field grid (Aggregate / Shape / Bucket|Top-N; Title / Width) — like the read identity grid + but even tracks that wrap gracefully as the (possibly narrowed) config column shrinks. */ +.composed-fields { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + gap: 0.75rem; + align-items: end; +} + +/* The self-describing metric caption under the metric picker (category · archetype · dimensions). */ +.measure-caption { margin-top: 0.35rem; font-size: 0.75rem; color: var(--muted); } + +/* Per-server-type availability badges under the measure picker (#1563 D4): informational pills — the measure still + works where it applies; the warn variant flags a platform it can't collect on, the plain one a msdb dependency. + The grey variant is the stronger per-server verdict shown when the view's $server resolves to one concrete server + the measure can't collect on (dashed to read as blocked/unavailable, distinct from the solid informational warn). */ +.measure-badges { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.35rem; } +.measure-badges:empty { display: none; } +.measure-badge { + font-size: 0.68rem; + padding: 0.08rem 0.42rem; + border-radius: 999px; + background: var(--lighter); + color: var(--muted); + border: 1px solid var(--border); + white-space: nowrap; +} +.measure-badge.warn { color: var(--warn); } +.measure-badge.grey { color: var(--warn); border-color: var(--warn); border-style: dashed; } + +/* Static (non-editable) field content — the ratio "fixed aggregation" note, the count "unitless" note. */ +.static-field { padding: 0.4rem 0; font-size: 0.85rem; } + +/* Group-by dimension chips (toggle buttons). */ +.chip-row { display: flex; flex-wrap: wrap; gap: 0.4rem; } +.chip { + padding: 0.3rem 0.7rem; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--bg-dark); + color: var(--dim); + font-size: 0.8rem; + font-family: var(--font); + cursor: pointer; +} +.chip:hover { background: var(--lighter); color: var(--fg); } +.chip.on { background: var(--accent); border-color: var(--accent); color: var(--bg-dark); font-weight: 600; } +.chip:disabled { opacity: 0.45; cursor: not-allowed; } +.chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } + +/* The grain-trap tip (per-database alternatives) and the viz↔shape coherence warning. */ +.grain-hint { margin-top: 0.5rem; font-size: 0.75rem; color: var(--muted); } +.grain-hint .grain-alts { color: var(--dim); } +.warn-hint { color: var(--warn); } + +/* The folded Advanced block (unit / per-panel time / percentile note). */ +.composed-advanced { margin-top: 0.85rem; } +.composed-advanced > summary { cursor: pointer; font-size: 0.8rem; font-weight: 600; color: var(--muted); } +.composed-advanced > summary:hover { color: var(--fg); } +.composed-advanced > summary::-webkit-details-marker { display: none; } +.composed-advanced > summary::before { content: "▸ "; color: var(--muted); } +.composed-advanced[open] > summary::before { content: "▾ "; } +.composed-advanced > .cfg { margin-top: 0.6rem; } + +/* ─────────────────────────── view scope (variables + default range) ─────────────────────────── */ + +.view-scope { margin: 0.5rem 0 1.25rem; } +.view-scope .section-title { margin-bottom: 0.6rem; } +.view-scope-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.75rem; + margin-bottom: 0.6rem; +} +.vars-panel { + border: 1px dashed var(--border); + border-radius: var(--radius); + padding: 0.5rem 0.85rem; +} +.vars-panel > summary { cursor: pointer; font-weight: 600; font-size: 0.82rem; color: var(--muted); } +.vars-panel > summary:hover { color: var(--fg); } +.vars-body { margin-top: 0.6rem; display: flex; flex-direction: column; gap: 0.5rem; } +.item-list.vars .item-row { align-items: center; } +.item-list.vars .var-dollar { color: var(--muted); font-family: var(--mono); font-weight: 700; } + +/* ─────────────────────────── notebook composer (#1563 D7) ─────────────────────────── */ + +/* List-page affordances: the hero's CTA row, the "New from template" dropdown, and the notebook card chip. */ +.views-hero .hero-ctas { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: center; justify-content: center; } + +.template-menu { position: relative; } +.template-menu > .template-summary { list-style: none; cursor: pointer; } +.template-menu > .template-summary::-webkit-details-marker { display: none; } +.template-list { + position: absolute; + right: 0; + z-index: 10; + margin-top: 0.3rem; + min-width: 300px; + max-width: 360px; + padding: 0.35rem; + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45); + display: flex; + flex-direction: column; + gap: 0.15rem; + text-align: left; +} +.template-item { display: flex; flex-direction: column; gap: 0.1rem; padding: 0.45rem 0.55rem; border-radius: var(--radius-sm); color: var(--fg); } +.template-item:hover { background: var(--lighter); text-decoration: none; } +.template-item .template-label { font-weight: 600; font-size: 0.85rem; } +.template-item .template-desc { font-size: 0.72rem; color: var(--muted); } + +/* The editor: a stacked list of cells + the add-cell row. Each cell is a card with a head (Cell N + kind tag + + reorder/remove); a markdown cell is a textarea beside a live preview, a panel cell embeds the shared composer. */ +.cells-box { display: flex; flex-direction: column; gap: 1.1rem; margin-bottom: 1rem; } +.cells-add { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; } + +.notebook-cell { padding: 0.9rem 1rem; } +.cell-editor-head { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; } +.cell-editor-head .cell-editor-title { font-weight: 700; font-size: 0.9rem; color: var(--fg); } +.cell-editor-head .cell-kind-tag { + font-size: 0.64rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.4px; + color: var(--muted); + background: var(--bg-dark); + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.05rem 0.5rem; +} +.cell-editor-head .spacer { flex: 1; } + +.md-cell-body { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } +@media (max-width: 900px) { + .md-cell-body { grid-template-columns: 1fr; } +} +.md-cell-body .md-edit-col, +.md-cell-body .md-preview-col { min-width: 0; display: flex; flex-direction: column; gap: 0.4rem; } +.md-cell-body .block-label { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.4px; color: var(--muted); } +.md-textarea { + width: 100%; + min-height: 190px; + resize: vertical; + padding: 0.6rem 0.7rem; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-dark); + color: var(--fg); + font-family: var(--mono); + font-size: 0.82rem; + line-height: 1.5; +} +.md-textarea:focus { outline: none; border-color: var(--accent); } +.md-preview { + border: 1px dashed var(--border); + border-radius: var(--radius-sm); + padding: 0.6rem 0.75rem; + background: var(--bg-dark); + min-height: 190px; + overflow-x: auto; +} diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/css/theme.css b/Darling/PerformanceMonitor.Darling.Service/wwwroot/css/theme.css new file mode 100644 index 000000000..857c3f760 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/css/theme.css @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +/* + * Darling Web theme tokens (#1562) — the CSS custom properties are the exact values lifted from the WPF + * viewer's Themes/DarkTheme.xaml, so the browser dashboard reads as the same product as the desktop viewer. + * Dark-only in phase 1 (the WPF viewer's light theme is deferred). Every color the app uses resolves to one + * of these tokens; the band/severity colors map onto ok/warn/err so the pre-banded API values render with + * one palette. + */ +:root { + /* ── raw palette (verbatim from DarkTheme.xaml) ── */ + --accent: #2eaef1; + --bg: #181b1f; + --bg-dark: #111217; + --card: #22252b; + --lighter: #2a2d35; + --row-alt: #1c1f25; + --fg: #E4E6EB; + --dim: #C7CBD4; + --muted: #A8AEBA; + --ok: #81C784; + --warn: #FFD54F; + --err: #E57373; + + /* ── derived tokens ── */ + --border: #2a2d35; + --radius: 6px; + --radius-sm: 4px; + + /* Band / severity semantic aliases — the API is pre-banded; these only map a band name to a color. */ + --band-healthy: var(--ok); + --band-warning: var(--warn); + --band-critical: var(--err); + --band-offline: var(--muted); + --band-unknown: var(--muted); + + --font: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --mono: ui-monospace, "Cascadia Code", "Consolas", "Courier New", monospace; + + --sidebar-width: 260px; + + color-scheme: dark; +} diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/index.html b/Darling/PerformanceMonitor.Darling.Service/wwwroot/index.html new file mode 100644 index 000000000..cb8e0a34f --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/index.html @@ -0,0 +1,38 @@ + + + + + + + Darling Web + + + + + +
+ +
+
+
+ + + diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/app.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/app.js new file mode 100644 index 000000000..8e77b9004 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/app.js @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +/* + * Darling Web entry point (#1562, extended for #1563): the hash router, the sidebar server + view lists, and the + * refresh loop. Routes: + * #/fleet — Fleet Overview (default) + * #/server/{name} — one server's detail + * #/alerts — fleet-wide Alert History + * #/views — Custom Views list (#1563) + * #/view/{id} — a saved custom view, rendered (#1563) + * #/view/{id}/edit — the composer editing a saved view (#1563) + * #/view/new — the composer creating a new view (#1563) + * #/notebook/{id} — a saved notebook, rendered as a document (#1563 D7; renderView kind-detects) + * #/notebook/{id}/edit— the notebook composer editing a saved notebook (#1563 D7) + * #/notebook/new — the notebook composer creating a new notebook (optionally /new/{template}) + * The refresh loop re-renders the active page every 60s and PAUSES while the tab is hidden (the interval skips + * work when document.hidden), refreshing once immediately when the tab becomes visible again. The 60s refresh + * DELIBERATELY does NOT re-render either composer route (the editor-route poll guard) — a background rebuild there + * would discard an in-progress edit — while the sidebar (server list + view list) still refreshes. + */ + +import { el, mount, apiGet, bandClass, localTime } from "./util.js"; +import { navigateServer } from "./panels.js"; +import { renderFleet } from "./pages/fleet.js"; +import { renderServer } from "./pages/server.js"; +import { renderAlerts } from "./pages/alerts.js"; +import { renderViewList, renderView } from "./pages/views.js"; +import { renderEditor } from "./editor.js"; +import { renderNotebookEditor } from "./notebook.js"; +import { getSession, listViews } from "./views-api.js"; + +const POLL_MS = 60000; + +const main = document.getElementById("main"); +const serverList = document.getElementById("server-list"); +const viewList = document.getElementById("view-list"); +const statusbar = document.getElementById("statusbar"); + +/* ─────────────────────────── routing ─────────────────────────── */ + +function currentRoute() { + const h = location.hash || "#/fleet"; + if (h.startsWith("#/server/")) return { name: "server", param: decodeURIComponent(h.slice("#/server/".length)) }; + if (h.startsWith("#/alerts")) return { name: "alerts" }; + /* #/views (list) is checked before the #/view/ forms; and the /edit form is tested before the bare /view/. */ + if (h === "#/views" || h === "#/views/") return { name: "views" }; + if (h === "#/view/new") return { name: "editor", id: "new" }; + if (h.startsWith("#/view/") && h.endsWith("/edit")) { + return { name: "editor", id: decodeURIComponent(h.slice("#/view/".length, h.length - "/edit".length)) }; + } + if (h.startsWith("#/view/")) return { name: "view", id: decodeURIComponent(h.slice("#/view/".length)) }; + /* Notebook routes (#1563 D7): /new (optionally /new/{template}) + /{id} + /{id}/edit, tested most-specific first. */ + if (h === "#/notebook/new") return { name: "notebookEditor", id: "new" }; + if (h.startsWith("#/notebook/new/")) { + return { name: "notebookEditor", id: "new", template: decodeURIComponent(h.slice("#/notebook/new/".length)) }; + } + if (h.startsWith("#/notebook/") && h.endsWith("/edit")) { + return { name: "notebookEditor", id: decodeURIComponent(h.slice("#/notebook/".length, h.length - "/edit".length)) }; + } + if (h.startsWith("#/notebook/")) return { name: "notebook", id: decodeURIComponent(h.slice("#/notebook/".length)) }; + return { name: "fleet" }; +} + +function route() { + const r = currentRoute(); + setActiveNav(r); + if (r.name === "server") renderServer(main, r.param); + else if (r.name === "alerts") renderAlerts(main); + else if (r.name === "views") renderViewList(main); + else if (r.name === "view") renderView(main, r.id); + else if (r.name === "editor") renderEditor(main, r.id); + else if (r.name === "notebook") renderView(main, r.id); // renderView kind-detects -> notebook document + else if (r.name === "notebookEditor") renderNotebookEditor(main, r.id, r.template); + else renderFleet(main); +} + +/* Whether a route targets a specific saved view/notebook (its renderer or composer) — the routes whose sidebar + entry should light up, and which the poll guard must not clobber (for the composer forms). */ +function isViewItemRoute(name) { + return name === "view" || name === "editor" || name === "notebook" || name === "notebookEditor"; +} + +/* The sidebar's "Custom Views" nav stays lit across the list, both renderers, and both composers. */ +function navKeyFor(r) { + return r.name === "views" || isViewItemRoute(r.name) ? "views" : r.name; +} + +function setActiveNav(r) { + const navKey = navKeyFor(r); + document.querySelectorAll(".nav a").forEach((a) => a.classList.toggle("active", a.dataset.route === navKey)); + updateServerActive(r); + updateViewActive(r); +} + +function updateServerActive(r) { + serverList.querySelectorAll(".server-item").forEach((item) => { + const active = r.name === "server" && (item.dataset.server === r.param || item.dataset.display === r.param); + item.classList.toggle("active", active); + }); +} + +function updateViewActive(r) { + if (!viewList) return; + viewList.querySelectorAll(".view-item").forEach((item) => { + const active = isViewItemRoute(r.name) && item.dataset.view === String(r.id); + item.classList.toggle("active", active); + }); +} + +/* ─────────────────────────── sidebar ─────────────────────────── */ + +async function refreshSidebar() { + const res = await apiGet("/api/fleet"); + if (res.kind !== "data") { + mount(serverList, el("div", { class: "muted", style: "padding:0.5rem 1.25rem", text: res.kind === "error" ? "Fleet unavailable" : "" })); + updateStatusBar(null); + return; + } + + const cards = [...(res.data.cards || [])].sort((a, b) => a.display_name.localeCompare(b.display_name)); + const r = currentRoute(); + mount( + serverList, + cards.map((c) => { + const target = c.server_name || c.display_name; + const active = r.name === "server" && (r.param === c.server_name || r.param === c.display_name); + return el( + "div", + { + class: "server-item" + (active ? " active" : ""), + dataset: { server: target, display: c.display_name }, + onActivate: () => navigateServer(target), + }, + [el("span", { class: "dot " + bandClass(c.band) }), el("span", { class: "name", text: c.display_name })] + ); + }) + ); + updateStatusBar(res.data); +} + +/* ─────────────────────────── sidebar view list (#1563) ─────────────────────────── */ + +/* Populated like refreshSidebar: the saved custom views + a "New view" affordance shown ONLY when the session + reports can_edit. Kept fresh on the 60s poll even while the composer is open. */ +async function refreshViewList() { + if (!viewList) return; + const [session, res] = await Promise.all([getSession(), listViews()]); + const r = currentRoute(); + const items = []; + + if (res.kind === "data" && Array.isArray(res.data)) { + for (const v of res.data) { + const active = isViewItemRoute(r.name) && String(r.id) === String(v.id); + /* The summary carries the view kind (definition->>'kind'), so the sidebar links + badges a notebook without + fetching its definition; a notebook links to its own route and gets a document glyph (CSS). */ + const isNotebook = v.kind === "notebook"; + items.push( + el("a", { + class: "view-item" + (active ? " active" : "") + (isNotebook ? " is-notebook" : ""), + href: (isNotebook ? "#/notebook/" : "#/view/") + encodeURIComponent(v.id), + dataset: { view: String(v.id) }, + title: v.description || v.name, + text: v.name, + }) + ); + } + } + + if (session.can_edit) { + items.push(el("a", { class: "view-item new-view", href: "#/view/new", text: "+ New view" })); + items.push(el("a", { class: "view-item new-view", href: "#/notebook/new", text: "+ New notebook" })); + } + + if (!items.length) { + items.push(el("div", { class: "muted", style: "padding:0.35rem 1.25rem", text: "No views yet" })); + } + + mount(viewList, items); +} + +/* ─────────────────────────── status bar ─────────────────────────── */ + +/* A fixed footer mirroring the WPF viewer's status bar: fleet server count, collectors healthy/failing across + the fleet, and the last refresh time. Built from the SAME /api/fleet response the sidebar just read (no extra + round-trip). Store size has no web endpoint, so it is deliberately omitted here. */ +function updateStatusBar(d) { + if (!statusbar) return; + if (!d) { + mount(statusbar, el("span", { class: "sb-item muted", text: "Fleet unavailable" })); + return; + } + let healthy = 0; + let failing = 0; + for (const c of d.cards || []) { + healthy += c.healthy_collector_count || 0; + failing += c.failed_collector_count || 0; + } + const servers = d.total_servers || 0; + mount(statusbar, [ + el("span", { class: "sb-item", text: servers + (servers === 1 ? " server" : " servers") }), + el("span", { class: "sb-sep", text: "·" }), + el("span", { class: "sb-item", text: healthy + " collectors healthy · " + failing + " failing" }), + el("span", { class: "sb-sep", text: "·" }), + el("span", { class: "sb-item", text: "Updated " + localTime(d.generated_at) }), + ]); +} + +/* ─────────────────────────── refresh loop ─────────────────────────── */ + +function refresh() { + refreshSidebar(); + refreshViewList(); + /* Poll-clobber guard (#1563): never re-render a composer (dashboard OR notebook, #1563 D7) from the background + poll — a rebuild would discard an in-progress edit. hashchange still routes to it normally; only this periodic + refresh skips it. */ + const routeName = currentRoute().name; + if (routeName === "editor" || routeName === "notebookEditor") return; + route(); +} + +function start() { + window.addEventListener("hashchange", route); + document.addEventListener("visibilitychange", () => { + if (!document.hidden) refresh(); + }); + setInterval(() => { + if (!document.hidden) refresh(); + }, POLL_MS); + + refreshSidebar(); + refreshViewList(); + route(); +} + +start(); diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/charts.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/charts.js new file mode 100644 index 000000000..fcb848c6e --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/charts.js @@ -0,0 +1,640 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +/* + * Dependency-free inline SVG line charts for Darling Web (#1562) — multi-series polylines, theme colors via + * CSS vars / the caller's series color, and a mousemove tooltip. No chart library, no build step, no remote + * anything (the WPF viewer uses ScottPlot; this is the honest phase-1 browser equivalent). + * + * NOTE (air-gap): SVG_NS is the W3C XML *namespace identifier* required by createElementNS — it is never + * dereferenced over the network. The self-containment test (DarlingWebSelfContainmentTests) allowlists exactly + * this string; keep it as the single occurrence in wwwroot. + */ + +import { el, parseUtc, axisTime, emptyStrip } from "./util.js"; + +const SVG_NS = "http://www.w3.org/2000/svg"; + +/* viewBox geometry — the SVG scales to its container width via CSS (width:100%, height:auto). */ +const W = 1000; +const H = 320; +/* Top margin leaves headroom for the y-axis unit caption to sit fully clear of the top tick's label. */ +const M = { l: 58, r: 16, t: 26, b: 30 }; +const PLOT_W = W - M.l - M.r; +const PLOT_H = H - M.t - M.b; +const Y_TICKS = 4; + +function svg(tag, attrs) { + const node = document.createElementNS(SVG_NS, tag); + if (attrs) for (const [k, v] of Object.entries(attrs)) if (v != null) node.setAttribute(k, String(v)); + return node; +} + +/** + * Render a multi-series time chart into a returned `.chart` node. + * spec: { points, xKey, series:[{key,label,color}], formatValue?, clampMax?, unit?, mode?, thresholds? } + * points — array of row objects; each row[xKey] is a naive-UTC ISO string, each row[series.key] a number. + * clampMax — cap the y-axis top at this value (percentage charts pass 100 so the domain never exceeds 100). + * unit — a short y-axis unit caption ("%", "ms", "ms/s", ...). + * mode — "line" (default; plain polylines), "area" (each series filled to the baseline), "stacked" + * (series stacked on one another — the "parts of a whole over time" view; the y-domain is the + * per-bucket stack SUM), or "stacked-bar" (the same stack, drawn as a vertical bar per bucket + * segmented by series). "line"/absent is byte-for-byte the original behavior. + * thresholds — optional array of render-only reference-line values in the chart's unit (design D3); each + * in-domain value draws a dashed guide line + label, out-of-domain values are skipped. + * annotations— optional event-marker overlays (design D5): [{ source, displayName, events:[{ts, label}] }] in + * catalog order; each source draws vertical markers in its own ANNOTATION_COLORS hue with a hover + * title (source · label · local time) and an entry in a small annotation key below the chart. + * onSelect — optional drill callback (design D6): a legend entry for a grouped series calls onSelect(series.drill) + * — [{dimension, value}] — so the caller can re-run the panel filtered to that series' group value. + */ +export function renderLineChart(spec) { + const { points, xKey, series, formatValue = (v) => String(v), clampMax = null, unit = null, mode = "line", thresholds = null, annotations = null, onSelect = null } = spec; + const stacked = mode === "stacked"; + const stackedBar = mode === "stacked-bar"; + /* Both stacked modes share the cumulative pre-pass, the sum-based y-domain, and the hover-at-stack-top dots. */ + const usesStack = stacked || stackedBar; + const filled = mode === "area" || stacked; + + /* Parse + sort the x axis (naive UTC -> real Date via parseUtc). */ + const rows = (points || []) + .map((r) => ({ t: parseUtc(r[xKey]), r })) + .filter((p) => p.t) + .sort((a, b) => a.t - b.t); + + if (rows.length < 2) { + return el("div", { class: "chart" }, [emptyStrip("Not enough data points to chart yet.")]); + } + + const tMin = rows[0].t.getTime(); + const tMax = rows[rows.length - 1].t.getTime(); + const spanMs = tMax - tMin; + + /* A numeric reader: null/NaN reads as null for line/area (a gap), or 0 for a stacked mode (a continuous baseline). */ + const readVal = (r, key) => { + const v = r[key]; + return v == null || isNaN(v) ? (usesStack ? 0 : null) : Number(v); + }; + + /* Stacked pre-pass: cumulative top per series at each row (stackTops[i][k] = sum of series 0..k at row i). */ + const stackTops = usesStack ? rows.map(() => new Array(series.length).fill(0)) : null; + + /* y domain. Stacked: 0..max stack sum. Line/area: across every series (0-baselined; non-negative metrics). */ + let dataMax = -Infinity; + let dataMin = Infinity; + if (usesStack) { + for (let i = 0; i < rows.length; i++) { + let running = 0; + for (let k = 0; k < series.length; k++) { + running += readVal(rows[i].r, series[k].key); + stackTops[i][k] = running; + } + if (running > dataMax) dataMax = running; + } + dataMin = 0; + if (dataMax === -Infinity) dataMax = 1; + } else { + for (const { r } of rows) { + for (const s of series) { + const v = readVal(r, s.key); + if (v == null) continue; + if (v > dataMax) dataMax = v; + if (v < dataMin) dataMin = v; + } + } + if (dataMax === -Infinity) return el("div", { class: "chart" }, [emptyStrip("No numeric values to chart.")]); + dataMin = Math.min(0, dataMin); + } + if (dataMax === dataMin) dataMax = dataMin + 1; + + /* Nice-rounded domain so gridline labels land on round values; percentage charts (clampMax=100) cap the top + at 100 and never exceed it, so a 96% reading no longer rounds the axis up to a "120%" tick. */ + const scale = niceScale(dataMin, dataMax, Y_TICKS, clampMax); + const yMin = scale.min; + const yMax = scale.max; + + const scaleX = (t) => M.l + ((t - tMin) / (spanMs || 1)) * PLOT_W; + const scaleY = (v) => M.t + (1 - (v - yMin) / (yMax - yMin)) * PLOT_H; + /* Plotted points clamp into the plot box so a value above a clamped (pct) domain can't draw outside it. */ + const plotY = (v) => Math.max(M.t, Math.min(M.t + PLOT_H, scaleY(v))); + const baseY = plotY(0); + + const root = svg("svg", { viewBox: `0 0 ${W} ${H}`, preserveAspectRatio: "none", role: "img" }); + + /* Horizontal gridlines + y labels (on the nice tick values). */ + const axis = svg("g", { class: "axis" }); + for (const val of scale.ticks) { + const y = scaleY(val); + axis.appendChild(svg("line", { class: "grid-line", x1: M.l, y1: y, x2: W - M.r, y2: y })); + const label = svg("text", { x: M.l - 8, y: y + 4, "text-anchor": "end" }); + label.textContent = formatValue(val); + axis.appendChild(label); + } + + /* Y-axis unit caption. Skipped for "%" (the tick labels already carry the unit, and stacking a caption + on the top tick collided with its label — the design review's must-fix); for bare-number axes it sits + in the extra top headroom reserved above (well clear of the top tick's label). */ + if (unit && unit !== "%") { + const cap = svg("text", { class: "axis-unit", x: M.l - 8, y: 11, "text-anchor": "end" }); + cap.textContent = unit; + axis.appendChild(cap); + } + + /* Vertical gridlines + x labels (6 ticks). The label widens to include the calendar date when the domain + spans more than one day, so a window crossing midnight is unambiguous even if it is under 24h wide. */ + const crossesDay = rows[0].t.toDateString() !== rows[rows.length - 1].t.toDateString(); + const X_TICKS = 5; + for (let i = 0; i <= X_TICKS; i++) { + const t = tMin + (spanMs * i) / X_TICKS; + const x = scaleX(t); + axis.appendChild(svg("line", { class: "grid-line", x1: x, y1: M.t, x2: x, y2: M.t + PLOT_H })); + const label = svg("text", { + x: Math.min(Math.max(x, M.l + 2), W - M.r - 2), + y: H - 8, + "text-anchor": i === 0 ? "start" : i === X_TICKS ? "end" : "middle", + }); + label.textContent = axisTime(new Date(t), crossesDay); + axis.appendChild(label); + } + root.appendChild(axis); + + const xs = rows.map((p) => scaleX(p.t.getTime())); + + if (stackedBar) { + /* Time-series stacked BAR: one vertical bar per bucket, segmented bottom-up by series using the same cumulative + tops as the stacked area. Bar width is a fraction of the per-bucket spacing, centered on the bucket and clamped + into the plot; a sub-pixel segment is dropped so a dense window degrades cleanly toward a filled band. */ + const barW = Math.max(1, (PLOT_W / rows.length) * 0.7); + for (let i = 0; i < rows.length; i++) { + const bx = Math.max(M.l, Math.min(M.l + PLOT_W - barW, xs[i] - barW / 2)); + for (let k = 0; k < series.length; k++) { + const yTop = plotY(stackTops[i][k]); + const yBot = plotY(k === 0 ? 0 : stackTops[i][k - 1]); + const h = yBot - yTop; + if (h < 0.5) continue; + root.appendChild( + svg("rect", { class: "series-bar", x: bx, y: yTop, width: barW, height: h, fill: normalizeColor(series[k].color) }) + ); + } + } + } else if (stacked) { + /* Filled bands drawn top series first so lower bands paint over the seams; each band is bounded above by its + own cumulative top and below by the previous series' cumulative top (the x-axis for series 0). */ + for (let k = series.length - 1; k >= 0; k--) { + const top = []; + const bottom = []; + for (let i = 0; i < rows.length; i++) { + top.push(xs[i] + "," + plotY(stackTops[i][k])); + bottom.push(xs[i] + "," + plotY(k === 0 ? 0 : stackTops[i][k - 1])); + } + bottom.reverse(); + root.appendChild( + svg("polygon", { + class: "series-area", + points: top.concat(bottom).join(" "), + fill: normalizeColor(series[k].color), + "fill-opacity": "0.72", + }) + ); + } + } else { + /* Area fill (each series to the baseline) then the line on top; or just the line. Nulls drop the gap. */ + for (const s of series) { + const linePts = []; + for (let i = 0; i < rows.length; i++) { + const v = readVal(rows[i].r, s.key); + if (v == null) continue; + linePts.push(xs[i] + "," + plotY(v)); + } + if (linePts.length < 2) continue; + if (filled) { + const first = linePts[0].split(",")[0]; + const last = linePts[linePts.length - 1].split(",")[0]; + root.appendChild( + svg("polygon", { + class: "series-area", + points: `${first},${baseY} ${linePts.join(" ")} ${last},${baseY}`, + fill: normalizeColor(s.color), + "fill-opacity": "0.15", + }) + ); + } + root.appendChild(svg("polyline", { class: "series-line", points: linePts.join(" "), stroke: normalizeColor(s.color) })); + } + } + + /* Render-only threshold reference lines (design D3): a horizontal dashed guide at each in-domain value (the value + runs along the y axis here). An out-of-domain threshold is skipped, never clamped onto an edge — a clamped line + would read as a real reference at the wrong value. Drawn above the series, below the hover overlay. */ + if (Array.isArray(thresholds)) { + for (const tv of thresholds) { + if (tv == null || isNaN(tv) || tv < yMin || tv > yMax) continue; + const ty = scaleY(tv); + root.appendChild(thresholdLine(M.l, ty, W - M.r, ty, W - M.r - 4, ty - 4, "end", formatValue(tv))); + } + } + + /* Hover overlay: a transparent rect over the plot capturing mousemove. */ + const hoverLine = svg("line", { class: "hover-line", y1: M.t, y2: M.t + PLOT_H, style: "display:none" }); + root.appendChild(hoverLine); + const hoverDots = svg("g", { style: "display:none" }); + root.appendChild(hoverDots); + const overlay = svg("rect", { x: M.l, y: M.t, width: PLOT_W, height: PLOT_H, fill: "transparent" }); + root.appendChild(overlay); + + /* Event-annotation overlays (design D5): a vertical marker at each event's time, one color per source (drawn ON + TOP of the hover overlay so each marker's native — source · label · local time — is hoverable, matching + the ranked charts' native-title idiom). The visible line is thin + non-interactive; a wider transparent hit line + carries the title. Out-of-window events are skipped, not clamped (the backend scopes them to the panel window, + but a marker outside the plotted domain would otherwise pile onto an edge); dense windows just fill toward a band. */ + const annotationKey = []; + if (Array.isArray(annotations) && annotations.length) { + const g = svg("g", { class: "annotations" }); + annotations.forEach((layer, li) => { + const color = normalizeColor(ANNOTATION_COLORS[li % ANNOTATION_COLORS.length]); + let drawn = 0; + for (const ev of layer.events || []) { + const t = parseUtc(ev.ts); + if (!t) continue; + const ms = t.getTime(); + if (ms < tMin || ms > tMax) continue; + const mx = scaleX(ms); + g.appendChild(svg("line", { class: "annotation-line", x1: mx, y1: M.t, x2: mx, y2: M.t + PLOT_H, stroke: color })); + const hit = svg("line", { class: "annotation-hit", x1: mx, y1: M.t, x2: mx, y2: M.t + PLOT_H }); + const title = svg("title"); + const lbl = ev.label == null || ev.label === "" ? "" : String(ev.label); + title.textContent = layer.displayName + (lbl ? " · " + lbl : "") + " · " + t.toLocaleString(); + hit.appendChild(title); + g.appendChild(hit); + drawn++; + } + if (drawn > 0) annotationKey.push({ label: layer.displayName, color, count: drawn }); + }); + root.appendChild(g); + } + + const chart = el("div", { class: "chart" }, [root]); + const tooltip = el("div", { class: "chart-tooltip" }); + chart.appendChild(tooltip); + chart.appendChild(buildLegend(series, onSelect)); + if (annotationKey.length) chart.appendChild(buildAnnotationLegend(annotationKey)); + + overlay.addEventListener("mousemove", (ev) => { + const rect = root.getBoundingClientRect(); + const vbX = ((ev.clientX - rect.left) / rect.width) * W; + let idx = 0; + let best = Infinity; + for (let i = 0; i < xs.length; i++) { + const d = Math.abs(xs[i] - vbX); + if (d < best) { + best = d; + idx = i; + } + } + const { t, r } = rows[idx]; + const px = xs[idx]; + + hoverLine.setAttribute("x1", px); + hoverLine.setAttribute("x2", px); + hoverLine.style.display = ""; + + /* Dots sit at each series' plotted position: its cumulative top when stacked, its own value otherwise. */ + while (hoverDots.firstChild) hoverDots.removeChild(hoverDots.firstChild); + for (let k = 0; k < series.length; k++) { + const v = readVal(r, series[k].key); + if (!usesStack && v == null) continue; + const cy = usesStack ? plotY(stackTops[idx][k]) : plotY(v); + hoverDots.appendChild(svg("circle", { class: "hover-dot", cx: px, cy, r: 3.5, fill: normalizeColor(series[k].color) })); + } + hoverDots.style.display = ""; + + /* Tooltip is built with textContent only (values may include untrusted series labels). */ + while (tooltip.firstChild) tooltip.removeChild(tooltip.firstChild); + tooltip.appendChild(el("div", { class: "t-time", text: t.toLocaleString() })); + for (const s of series) { + const v = r[s.key]; + tooltip.appendChild( + el("div", { class: "t-row" }, [ + el("span", { class: "swatch", style: "background:" + normalizeColor(s.color) }), + el("span", { text: s.label }), + el("span", { class: "t-val", text: v == null || isNaN(v) ? "—" : formatValue(v) }), + ]) + ); + } + const renderedX = (px / W) * rect.width; + tooltip.style.display = "block"; + tooltip.style.left = Math.min(renderedX + 12, rect.width - tooltip.offsetWidth - 4) + "px"; + tooltip.style.top = "8px"; + }); + + overlay.addEventListener("mouseleave", () => { + hoverLine.style.display = "none"; + hoverDots.style.display = "none"; + tooltip.style.display = "none"; + }); + + return chart; +} + +/** + * Render a horizontal RANKED bar chart into a returned `.chart` node (the compose "bar" viz — a topN result). + * spec: { items:[{label,value,color?}], formatValue?, unit? } — items already ordered (value DESC) + bounded by + * the query's topN. Bars scale to the largest value; each carries a native SVG <title> for the full label+value. + * Rendered at a per-row height so a tall list stays readable (the container scrolls; the SVG never squashes). + * onSelect (design D6): when an item carries a `drill` ([{dimension, value}]), its bar becomes clickable and calls + * onSelect(item.drill) so the caller can re-run the panel filtered to that category (a transient drill-down). + */ +export function renderBarChart(spec) { + const { items, formatValue = (v) => String(v), thresholds = null, onSelect = null } = spec; + const rows = (items || []).filter((d) => d && d.value != null && !isNaN(d.value)); + if (!rows.length) return el("div", { class: "chart" }, [emptyStrip("No values to chart.")]); + + const shown = rows.slice(0, MAX_BARS); + const maxVal = Math.max(0, ...shown.map((d) => Number(d.value))); + const domainMax = maxVal > 0 ? maxVal : 1; + + const rowH = 26; + const gap = 8; + const labelW = 220; + const valueW = 110; + const barLeft = labelW + 8; + const barW = W - barLeft - valueW; + const height = M.t + shown.length * (rowH + gap); + + const root = svg("svg", { viewBox: `0 0 ${W} ${height}`, preserveAspectRatio: "xMinYMin meet", role: "img" }); + + shown.forEach((d, i) => { + const y = M.t + i * (rowH + gap); + const val = Number(d.value); + const w = Math.max(1, (val / domainMax) * barW); + const color = normalizeColor(d.color); + + const label = svg("text", { class: "bar-label", x: labelW, y: y + rowH * 0.7, "text-anchor": "end" }); + label.textContent = trunclabel(d.label); + + const drillable = !!(onSelect && d.drill); + const track = svg("rect", { class: drillable ? "bar-track drillable" : "bar-track", x: barLeft, y, width: barW, height: rowH, rx: 3 }); + const bar = svg("rect", { class: drillable ? "bar drillable" : "bar", x: barLeft, y, width: w, height: rowH, rx: 3, fill: color }); + const title = svg("title"); + title.textContent = (d.label == null || d.label === "" ? "—" : String(d.label)) + " · " + formatValue(val) + (drillable ? " · click to filter" : ""); + bar.appendChild(title); + if (drillable) { + const fire = () => onSelect(d.drill); + bar.addEventListener("click", fire); + track.addEventListener("click", fire); + } + + const value = svg("text", { class: "bar-value", x: barLeft + w + 6, y: y + rowH * 0.7 }); + value.textContent = formatValue(val); + + root.appendChild(label); + root.appendChild(track); + root.appendChild(bar); + root.appendChild(value); + }); + + /* Render-only threshold reference lines (design D3): a ranked bar's value runs along the x axis, so each in-domain + threshold draws a VERTICAL dashed guide across the bars (value label at the top). Out-of-domain values skip. */ + if (Array.isArray(thresholds)) { + for (const tv of thresholds) { + if (tv == null || isNaN(tv) || tv < 0 || tv > domainMax) continue; + const tx = barLeft + (tv / domainMax) * barW; + root.appendChild(thresholdLine(tx, M.t, tx, height, tx, M.t - 4, "middle", formatValue(tv))); + } + } + + const chart = el("div", { class: "chart chart-bar" }, [root]); + if (rows.length > MAX_BARS) { + chart.appendChild(el("div", { class: "chart-note", text: `Showing the top ${MAX_BARS} of ${rows.length}.` })); + } + return chart; +} + +/** + * Render a RANKED donut chart into a returned `.chart` node (the compose "pie" viz). Slices beyond MAX_SLICES are + * pooled into an "Other" wedge so a long tail stays legible; the center carries the grand total. A legend with + * per-slice values sits below. Slice/legend text is textContent-only (labels may be untrusted). + */ +export function renderPieChart(spec) { + const { items, formatValue = (v) => String(v), onSelect = null } = spec; + const rows = (items || []) + .filter((d) => d && d.value != null && !isNaN(d.value) && Number(d.value) > 0) + .map((d) => ({ label: d.label == null || d.label === "" ? "—" : String(d.label), value: Number(d.value), color: d.color, drill: d.drill || null })); + if (!rows.length) return el("div", { class: "chart" }, [emptyStrip("No positive values to chart.")]); + + /* Pool the long tail into "Other" so the donut never fans into unreadable slivers (the pooled wedge is a mix of + categories, so it carries no drill — D6). */ + let slices = rows; + if (rows.length > MAX_SLICES) { + const head = rows.slice(0, MAX_SLICES - 1); + const tail = rows.slice(MAX_SLICES - 1); + const otherValue = tail.reduce((sum, d) => sum + d.value, 0); + slices = head.concat([{ label: `Other (${tail.length})`, value: otherValue, color: NEUTRAL_SLICE, drill: null }]); + } + slices.forEach((d, i) => { + if (!d.color) d.color = CATEGORICAL_COLORS[i % CATEGORICAL_COLORS.length]; + }); + + const total = slices.reduce((sum, d) => sum + d.value, 0); + const size = 260; + const cx = size / 2; + const cy = size / 2; + const rOuter = size / 2 - 6; + const rInner = rOuter * 0.58; + + const root = svg("svg", { viewBox: `0 0 ${size} ${size}`, class: "pie-svg", role: "img" }); + let angle = -90; + for (const d of slices) { + /* Clamp a lone 100% slice below a full turn — a 360° SVG arc (start == end point) renders nothing. */ + const sweep = Math.min((d.value / total) * 360, 359.999); + const drillable = !!(onSelect && d.drill); + const path = svg("path", { class: drillable ? "pie-slice drillable" : "pie-slice", d: donutArc(cx, cy, rOuter, rInner, angle, angle + sweep), fill: normalizeColor(d.color) }); + const title = svg("title"); + title.textContent = `${d.label} · ${formatValue(d.value)} (${Math.round((d.value / total) * 100)}%)` + (drillable ? " · click to filter" : ""); + path.appendChild(title); + if (drillable) path.addEventListener("click", () => onSelect(d.drill)); + root.appendChild(path); + angle += sweep; + } + + const centerTotal = svg("text", { class: "pie-center", x: cx, y: cy - 2, "text-anchor": "middle" }); + centerTotal.textContent = formatValue(total); + const centerLabel = svg("text", { class: "pie-center-label", x: cx, y: cy + 14, "text-anchor": "middle" }); + centerLabel.textContent = "total"; + root.appendChild(centerTotal); + root.appendChild(centerLabel); + + const legend = el( + "div", + { class: "chart-legend pie-legend" }, + slices.map((d) => { + const drillable = !!(onSelect && d.drill); + const props = { class: drillable ? "item drillable" : "item" }; + if (drillable) { + props.onActivate = () => onSelect(d.drill); + props.title = "Filter to " + d.label; + } + return el("span", props, [ + el("span", { class: "swatch dot", style: "background:" + normalizeColor(d.color) }), + el("span", { text: d.label }), + el("span", { class: "leg-val", text: formatValue(d.value) }), + ]); + }) + ); + + return el("div", { class: "chart chart-pie" }, [el("div", { class: "pie-wrap" }, [root]), legend]); +} + +/** Bounds on how much a ranked chart draws before it stops reading (the container scrolls a bar list; a pie pools). */ +const MAX_BARS = 30; +const MAX_SLICES = 9; + +/** Truncate a bar's category label to keep it inside the label gutter. */ +function trunclabel(s) { + const flat = String(s == null || s === "" ? "—" : s); + return flat.length > 30 ? flat.slice(0, 29) + "…" : flat; +} + +/** SVG path `d` for a donut wedge from a1° to a2° (0° = 3 o'clock, angles increase clockwise in SVG's y-down space). */ +function donutArc(cx, cy, rOuter, rInner, a1, a2) { + const p = (r, a) => { + const rad = (a * Math.PI) / 180; + return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)]; + }; + const large = a2 - a1 > 180 ? 1 : 0; + const [ox1, oy1] = p(rOuter, a1); + const [ox2, oy2] = p(rOuter, a2); + const [ix2, iy2] = p(rInner, a2); + const [ix1, iy1] = p(rInner, a1); + return ( + `M ${ox1} ${oy1} A ${rOuter} ${rOuter} 0 ${large} 1 ${ox2} ${oy2} ` + + `L ${ix2} ${iy2} A ${rInner} ${rInner} 0 ${large} 0 ${ix1} ${iy1} Z` + ); +} + +/** + * One render-only threshold reference line (design D3): a dashed, neutral line from (x1,y1) to (x2,y2) with a small + * value label at (lx,ly). Shared by the time-series charts (horizontal, value on the y axis) and the ranked bar + * (vertical, value on the x axis) so the two can never drift in styling. `text` goes through textContent (R4/XSS). + */ +function thresholdLine(x1, y1, x2, y2, lx, ly, anchor, text) { + const g = svg("g", { class: "threshold" }); + g.appendChild(svg("line", { class: "threshold-line", x1, y1, x2, y2 })); + const label = svg("text", { class: "threshold-label", x: lx, y: ly, "text-anchor": anchor }); + label.textContent = text; + g.appendChild(label); + return g; +} + +/** The series key below a time chart. onSelect (design D6): a grouped series' entry becomes an activatable (mouse + + * keyboard) control that calls onSelect(series.drill) to re-run the panel filtered to that series' group value. */ +function buildLegend(series, onSelect) { + return el( + "div", + { class: "chart-legend" }, + series.map((s) => { + const drillable = !!(onSelect && s.drill); + const props = { class: drillable ? "item drillable" : "item" }; + if (drillable) { + props.onActivate = () => onSelect(s.drill); + props.title = "Filter to " + s.label; + } + return el("span", props, [ + el("span", { class: "swatch", style: "background:" + normalizeColor(s.color) }), + el("span", { text: s.label }), + ]); + }) + ); +} + +/** The event-annotation key below a time chart (design D5): one entry per active source (its marker color + a count + * of markers drawn in view). A plain key — annotation sources are not a data series, so it is never drillable. */ +function buildAnnotationLegend(entries) { + return el( + "div", + { class: "chart-legend annotation-legend" }, + entries.map((e) => + el("span", { class: "item" }, [ + el("span", { class: "swatch annotation-swatch", style: "background:" + normalizeColor(e.color) }), + el("span", { text: e.label }), + el("span", { class: "leg-val", text: String(e.count) }), + ]) + ) + ); +} + +/** + * A NEUTRAL categorical ramp for chart series — deliberately NOT the ok/warn/err severity colors, so a chart's + * lines never imply a health state (the alert/band palette stays severity-only). Distinct, colorblind-tolerant. + */ +export const SERIES_COLORS = ["#2eaef1", "#4dd0e1", "#b39ddb", "#7f8fa6", "#e0e0e0"]; + +/** + * The wider categorical palette for composed multi-series / bar / pie charts (#1563) — the five SERIES_COLORS + * (so the family reads the same) plus five more cool/neutral hues, staying clear of the ok/warn/err severity + * colors for the same reason. All #rrggbb literals (air-gap safe); cycled when a chart has more categories. + */ +export const CATEGORICAL_COLORS = [ + "#2eaef1", "#4dd0e1", "#b39ddb", "#7f8fa6", "#e0e0e0", + "#64b5f6", "#9575cd", "#4fc3f7", "#ba68c8", "#90a4ae", +]; + +/** The muted color for a pie's pooled "Other" wedge — a neutral gray that recedes behind the real categories. */ +const NEUTRAL_SLICE = "#5b626e"; + +/** + * The event-annotation marker palette (#1563 D5) — deliberately DISJOINT from both CATEGORICAL_COLORS (the cool + * blues/purples/grays the data series use) and the ok/warn/err severity colors, so an annotation marker never + * reads as a data series OR as a health state. Warm/distinct hues (pink, orange, brown, indigo), one per source, + * cycled at the D5 cap of four. All #rrggbb literals (air-gap safe); pass through normalizeColor like every color. + */ +export const ANNOTATION_COLORS = ["#ec407a", "#ff8f00", "#8d6e63", "#5e35b1"]; + +/** + * Presentation guard (defense-in-depth behind the server-side ValidateDefinition authority): a series color + * reaches a style="background:<color>" sink in the tooltip/legend swatches, so a stored definition's color must + * be a #rrggbb hex literal (case-insensitive). Anything else — a named color, a short #abc, a CSS function that + * would fetch off-origin and defeat the air-gap, or a non-string — falls back to a safe palette default. Mirrors + * the composer's normalizeColor (editor.js imports this one) so the two can never drift. + */ +export function normalizeColor(c) { + return typeof c === "string" && /^#[0-9a-fA-F]{6}$/.test(c) ? c : SERIES_COLORS[0]; +} + +/** Classic "nice number" rounding (Heckbert): the round-friendly value at or just past `range`. */ +function niceNum(range, round) { + if (!(range > 0) || !isFinite(range)) return 1; + const exp = Math.floor(Math.log10(range)); + const frac = range / Math.pow(10, exp); + let nf; + if (round) { + nf = frac < 1.5 ? 1 : frac < 3 ? 2 : frac < 7 ? 5 : 10; + } else { + nf = frac <= 1 ? 1 : frac <= 2 ? 2 : frac <= 5 ? 5 : 10; + } + return nf * Math.pow(10, exp); +} + +/** + * A "nice" y-axis over [min, max]: rounded bounds and evenly-spaced tick values that land on round numbers. + * `clampMax` caps the top (percentage charts pass 100 so the axis never exceeds 100%). + */ +function niceScale(min, max, maxTicks, clampMax) { + const range = niceNum(max - min || 1, false); + const step = niceNum(range / Math.max(1, maxTicks), true) || 1; + const niceMin = Math.floor(min / step) * step; + let niceMax = Math.ceil(max / step) * step; + if (clampMax != null && niceMax > clampMax) niceMax = clampMax; + const n = Math.max(1, Math.round((niceMax - niceMin) / step)); + const ticks = []; + for (let i = 0; i <= n; i++) { + const v = niceMin + i * step; + ticks.push(v > niceMax ? niceMax : v); + } + return { min: niceMin, max: niceMax, step, ticks }; +} diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/compose.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/compose.js new file mode 100644 index 000000000..57b018751 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/compose.js @@ -0,0 +1,482 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +/* + * The Custom Views v2 render path (#1563) — the twin of panels.js's v1 renderPanel, for COMPOSED (`source`) panels. + * A composed panel is not a GET read; it is a spec {source, measure|ratio, aggregate, unit, timeBucket|topN, filters, + * groupBy, viz} that the composer previews and a saved view renders by POSTing to /api/compose/run (with the view's + * server/time/variable scope) and charting the returned {sql, rows}. The compiler emits three row shapes, keyed by + * mode (ComposeCompiler): TIME SERIES `{bucket, <groupDim>…, value}` (bucket ASC), RANKED `{<groupDim>…, value}` + * (value DESC, LIMIT topN), and SCALAR `{value}` (one row). This module shapes those rows for charts.js and formats + * every value in the panel's chosen unit (the value is already scaled server-side, so we format — never re-scale). + * + * R4 (XSS): every value — dimension values, series/category labels, the compiled SQL — reaches the DOM through + * util.el()'s text/textContent path (never innerHTML), so an untrusted wait type / object name / server name renders + * inert. The chart SVG lives entirely in charts.js (one SVG_NS occurrence, the air-gap allowlist); this file has no SVG. + */ + +import { el, mount, loadingStrip, errorStrip, emptyStrip, disclosure, fmtInt, fmtNum, apiSend } from "./util.js"; +import { renderLineChart, renderBarChart, renderPieChart, CATEGORICAL_COLORS } from "./charts.js"; +import { navigateServer } from "./panels.js"; +import { getCatalog } from "./views-api.js"; + +/** The most series a time chart draws before pooling the rest into a "+N more" note (readability + palette size). */ +const MAX_SERIES = 8; + +/* ─────────────────────────── panel card + fill ─────────────────────────── */ + +/** + * A composed panel as a self-filling `.panel.card` (the saved-view grid's unit) — matches panels.js's renderPanel + * shape (title + span-2 + a body that shows a loading strip, then the chart / a state). `scope` is the view-level + * run context {server, hours, variables, values}; flipping it and re-rendering re-scopes every panel at once. + */ +export function renderComposedPanelCard(panelSpec, scope) { + const body = el("div", { class: "panel-body" }, [loadingStrip()]); + const panel = el("div", { class: "panel card" + (panelSpec.span === 2 ? " span-2" : "") }, [ + el("h3", {}, [panelSpec.title || measureLabel(panelSpec)]), + body, + ]); + driveComposedPanel(body, panelSpec, scope); + return panel; +} + +/** + * The per-card drill controller (design D6): runs the panel, and — when the viewer clicks a categorical mark — + * re-runs it with a TRANSIENT drill filter pinning that mark's group value(s). The drill is view-state only: it is + * never persisted (the stored definition renders verbatim), and a "clear" chip pops back to the base spec. Each new + * selection REPLACES the drill (a simple "you are viewing X" model), so drilling never stacks into a dead end. + */ +function driveComposedPanel(body, panelSpec, scope) { + let drill = null; // { keys: [{dimension, value}] } or null + function onDrill(next) { + drill = next && Array.isArray(next.keys) && next.keys.length ? next : null; + run(); + } + function run() { + const spec = drill ? withDrillFilters(panelSpec, drill) : panelSpec; + renderComposedInto(body, spec, scope, { drill, onDrill }); + } + run(); +} + +/** A base panel spec + the drill's pinned filters (design D6): each key becomes an `eq` filter appended to the + * panel's own filters. The value is a scalar string — the run endpoint treats it as a single literal (no comma + * splitting server-side), so a category containing a comma pins correctly. Never mutates the base spec. */ +function withDrillFilters(spec, drill) { + const extra = drill.keys.map((k) => ({ dimension: k.dimension, op: "eq", value: k.value })); + return { ...spec, filters: [...(Array.isArray(spec.filters) ? spec.filters : []), ...extra] }; +} + +/** Run a composed panel and fill `body` with the chart (or the empty/error state). Used by the card + the live + * preview. `opts` (design D5/D6): {drill, onDrill} drive the transient drill-down chip + mark clicks. */ +export async function renderComposedInto(body, panelSpec, scope, opts = {}) { + mount(body, loadingStrip()); + const res = await runCompose(panelSpec, scope); + if (res.kind === "error") { + /* Keep the drill chip (with its clear) above a failed drill so the viewer can always pop back. */ + const nodes = [errorStrip(res.message || "Could not run this panel.")]; + if (opts.drill && opts.onDrill) nodes.unshift(drillChip(opts.drill, opts.onDrill)); + mount(body, nodes); + return; + } + const data = res.data || {}; + /* Resolve annotation display names off the catalog (data-driven, not hardcoded) only when there are markers to + draw; getCatalog is a cached promise, so this is effectively free after the first page load. */ + let annotationMeta = null; + if (isTimeSeriesViz(panelSpec.viz) && Array.isArray(data.annotations) && data.annotations.length) { + annotationMeta = await annotationMetaMap().catch(() => null); + } + try { + mount(body, renderComposedResult(data, panelSpec, { ...opts, annotationMeta })); + } catch (e) { + mount(body, errorStrip("Could not render this panel: " + (e && e.message ? e.message : String(e)))); + } +} + +/** POST the composed panel to /api/compose/run with the view scope; returns the apiSend result ({sql, rows} on data). */ +export function runCompose(panelSpec, scope) { + return apiSend("POST", "/api/compose/run", buildRunBody(panelSpec, scope)); +} + +/** Build the /api/compose/run body from a panel spec + scope (a per-panel `hours` overrides the view range). */ +function buildRunBody(panelSpec, scope) { + const body = { panel: toRunPanel(panelSpec) }; + const s = scope || {}; + if (Array.isArray(s.variables) && s.variables.length) body.variables = s.variables; + if (s.values && Object.keys(s.values).length) body.values = s.values; + if (s.server != null) body.server = s.server; + if (s.hours != null) body.hours = s.hours; + if (panelSpec.hours != null) body.hours = panelSpec.hours; + return body; +} + +/** The exact compose fields the run endpoint reads — never the frontend-only keys (kind/title/span/hours). */ +function toRunPanel(p) { + const out = { source: p.source, viz: p.viz }; + if (p.measure != null) out.measure = p.measure; + if (p.ratio != null) out.ratio = p.ratio; + if (p.aggregate != null && p.aggregate !== "") out.aggregate = p.aggregate; + if (p.unit != null && p.unit !== "") out.unit = p.unit; + if (p.timeBucket != null && p.timeBucket !== "" && p.timeBucket !== "none") out.timeBucket = p.timeBucket; + if (p.topN != null && p.topN !== "") out.topN = p.topN; + if (Array.isArray(p.filters) && p.filters.length) out.filters = p.filters; + if (Array.isArray(p.groupBy) && p.groupBy.length) out.groupBy = p.groupBy; + /* Event-annotation sources (design D5) ride only on a time-series spec — the run endpoint rejects them on a + ranked/scalar panel — so gate on the emitted timeBucket, mirroring the server's rule. */ + if (out.timeBucket && Array.isArray(p.annotations) && p.annotations.length) out.annotations = p.annotations; + return out; +} + +/* ─────────────────────────── result -> chart ─────────────────────────── */ + +/** + * Shape a run result ({sql, rows}) into the panel body: the chart for the panel's viz, any partial-fleet / capped + * note, and a folded "compiled SQL" disclosure (read-only transparency). Empty rows are the honest NO-DATA state + * (not an error). Returns an array of nodes for the caller to mount. + */ +export function renderComposedResult(result, panelSpec, opts = {}) { + const rows = Array.isArray(result.rows) ? result.rows : []; + const nodes = []; + + /* The transient drill-down chip (design D6) sits above the chart whenever a drill is active — with the pinned + filter(s), a clear, and (for a server dimension) a jump to that server's detail page. */ + const drillNode = opts.drill && opts.onDrill ? drillChip(opts.drill, opts.onDrill) : null; + + if (!rows.length) { + if (drillNode) nodes.push(drillNode); + nodes.push(emptyStrip("No data in this window.")); + nodes.push(sqlDisclosure(result.sql)); + return nodes; + } + + const unit = panelSpec.unit || ""; + const fmt = (v) => formatComposedValue(v, unit); + const groupDims = Array.isArray(panelSpec.groupBy) ? panelSpec.groupBy : []; + /* Render-only reference lines (design D3), in the panel's unit — the chart draws each in-domain value. */ + const thresholds = Array.isArray(panelSpec.thresholds) ? panelSpec.thresholds : null; + /* A mark-click drills IN by pinning that mark's group value(s) as a filter (design D6); only offered when the + panel groups by real declared dimensions (the chart shapers attach `drill` per mark). */ + const onSelect = opts.onDrill ? (keys) => opts.onDrill(keys && keys.length ? { keys } : null) : null; + + if (drillNode) nodes.push(drillNode); + + switch (panelSpec.viz) { + case "line": + case "area": + case "stacked": + case "stacked-bar": { + const { points, series, hidden } = pivotTimeSeries(rows, groupDims, measureLabel(panelSpec)); + nodes.push( + renderLineChart({ + points, + xKey: "bucket", + series, + formatValue: fmt, + unit: axisUnit(unit), + clampMax: unit === "percent" ? 100 : null, + mode: panelSpec.viz, + thresholds, + annotations: annotationLayers(result.annotations, opts.annotationMeta), + onSelect, + }) + ); + if (hidden > 0) nodes.push(el("div", { class: "chart-note", text: `+${hidden} more series not shown.` })); + break; + } + case "bar": + nodes.push(renderBarChart({ items: rankedItems(rows, groupDims), formatValue: fmt, unit: axisUnit(unit), thresholds, onSelect })); + break; + case "pie": + nodes.push(renderPieChart({ items: rankedItems(rows, groupDims), formatValue: fmt, onSelect })); + break; + case "stat": + nodes.push(renderScalar(rows, panelSpec, fmt)); + break; + case "table": + default: + nodes.push(renderComposedTable(rows, unit)); + break; + } + + const partial = partialFleetNote(groupDims); + if (partial) nodes.push(partial); + nodes.push(sqlDisclosure(result.sql)); + return nodes; +} + +/* ─────────────────────────── row shaping ─────────────────────────── */ + +/** + * Pivot TIME SERIES rows into charts.js's {points, series} shape. With no group-by it is one series (the measure); + * with a group-by, one series per distinct dimension combination, values re-keyed per bucket. Series are capped to + * MAX_SERIES by total magnitude (the rest reported as `hidden`) so a high-cardinality group stays legible. + */ +export function pivotTimeSeries(rows, groupDims, label) { + if (!groupDims.length) { + const points = rows.map((r) => ({ bucket: r.bucket, value: numOrNull(r.value) })); + return { points, series: [{ key: "value", label, color: CATEGORICAL_COLORS[0] }], hidden: 0 }; + } + + const totals = new Map(); // seriesKey -> running total (for the top-N cut) + const labels = new Map(); // seriesKey -> display label + const drills = new Map(); // seriesKey -> drill keys [{dimension, value}] (design D6) + const byBucket = new Map(); // bucket -> point object + const order = []; // seriesKey first-seen order (stable colors) + + for (const r of rows) { + /* "s:"-prefixed so a group value equal to "bucket"/"value" can never collide with a point object's own keys. */ + const key = "s:" + JSON.stringify(groupDims.map((d) => (r[d] == null ? "" : String(r[d])))); + if (!labels.has(key)) { + labels.set(key, comboLabel(r, groupDims)); + drills.set(key, buildDrillKeys(r, groupDims)); + totals.set(key, 0); + order.push(key); + } + const v = numOrNull(r.value); + totals.set(key, totals.get(key) + (v || 0)); + let pt = byBucket.get(r.bucket); + if (!pt) { + pt = { bucket: r.bucket }; + byBucket.set(r.bucket, pt); + } + pt[key] = v; + } + + let keptKeys = order; + let hidden = 0; + if (order.length > MAX_SERIES) { + keptKeys = [...order].sort((a, b) => totals.get(b) - totals.get(a)).slice(0, MAX_SERIES); + hidden = order.length - keptKeys.length; + } + + const series = keptKeys.map((key, i) => ({ + key, + label: labels.get(key), + color: CATEGORICAL_COLORS[i % CATEGORICAL_COLORS.length], + drill: drills.get(key), + })); + return { points: [...byBucket.values()], series, hidden }; +} + +/** Shape RANKED rows into charts.js's items[] ({label, value, color, drill}); the category is the group-by combo, + * and `drill` (design D6) carries the row's declared group-dimension values so a click can filter to that category. */ +export function rankedItems(rows, groupDims) { + const dims = groupDims.length ? groupDims : otherColumns(rows[0]); + return rows.map((r, i) => ({ + label: dims.length ? comboLabel(r, dims) : "value", + value: numOrNull(r.value), + color: CATEGORICAL_COLORS[i % CATEGORICAL_COLORS.length], + /* Only DECLARED group-by dims are drillable — a fallback otherColumns() dim isn't a catalog dimension the run + endpoint would accept as a filter, so it stays non-drillable. */ + drill: groupDims.length ? buildDrillKeys(r, groupDims) : null, + })); +} + +/** A group-by combo's display label — the dimension values joined by " / " (a null/empty value reads as "(none)"). */ +function comboLabel(row, dims) { + return dims + .map((d) => { + const v = row[d]; + return v == null || v === "" ? "(none)" : String(v); + }) + .join(" / "); +} + +/** The non-`value` columns of a row (the fallback category keys when a ranked result carries no declared group-by). */ +function otherColumns(row) { + return row ? Object.keys(row).filter((k) => k !== "value" && k !== "bucket") : []; +} + +/* ─────────────────────────── scalar + table renderers ─────────────────────────── */ + +/** A single-aggregate SCALAR result as one big stat tile (the same .stats/.stat markup the v1 stat viz uses). */ +function renderScalar(rows, panelSpec, fmt) { + const value = rows[0] ? rows[0].value : null; + return el("div", { class: "stats" }, [ + el("div", { class: "stat" }, [ + el("div", { class: "value", text: fmt(value) }), + el("div", { class: "label", text: panelSpec.title || measureLabel(panelSpec) }), + ]), + ]); +} + +/** Any result as a table of its returned columns — bucket localized, value formatted in the unit, dims as text. */ +function renderComposedTable(rows, unit) { + const cols = Object.keys(rows[0] || {}); + if (!cols.length) return emptyStrip("No columns to show."); + const head = el( + "tr", + {}, + cols.map((c) => el("th", { text: columnLabel(c), class: c === "value" ? "num" : null })) + ); + const bodyRows = rows.map((row) => + el( + "tr", + {}, + cols.map((c) => { + if (c === "value") return el("td", { class: "num", text: formatComposedValue(row[c], unit) }); + if (c === "bucket") return el("td", { text: localBucket(row[c]) }); + const v = row[c]; + return el("td", { text: v == null || v === "" ? "—" : String(v) }); + }) + ) + ); + return el("div", { class: "table-wrap" }, [ + el("table", { class: "data" }, [el("thead", {}, [head]), el("tbody", {}, bodyRows)]), + ]); +} + +/** A table header for a result column: "Value" gains its unit, "bucket" -> "Time", a dim name is humanized. */ +function columnLabel(c) { + if (c === "bucket") return "Time"; + if (c === "value") return "Value"; + return humanize(c); +} + +/** Localize a naive-UTC bucket timestamp for the table's Time column. */ +function localBucket(s) { + if (!s || typeof s !== "string") return "—"; + const hasZone = /[zZ]$|[+\-]\d\d:?\d\d$/.test(s); + const d = new Date(hasZone ? s : s + "Z"); + return isNaN(d.getTime()) ? String(s) : d.toLocaleString(); +} + +/* ─────────────────────────── formatting ─────────────────────────── */ + +/** + * Format a composed value already scaled to `unit` (the compiler did the conversion, so we FORMAT, never re-scale): + * percent -> "n.n%", count -> integer, ratio -> a 0..1 fraction, bytes/pages -> integer + unit, any other unit + * (ms/s/min/kb/mb/gb/…) -> "n.n unit", and a bare number when there is no unit. + */ +export function formatComposedValue(v, unit) { + if (v == null || isNaN(v)) return "—"; + const n = Number(v); + if (unit === "percent") return fmtNum(n, 1) + "%"; + if (unit === "count") return fmtInt(n); + if (unit === "ratio") return fmtNum(n, 3); + if (unit === "bytes" || unit === "pages") return fmtInt(n) + " " + unit; + if (unit) return fmtNum(n, 1) + " " + unit; + return fmtNum(n, 1); +} + +/** The short y-axis unit caption: "%" for percent, the bare unit for a real unit, null for count/ratio (unitless-ish). */ +function axisUnit(unit) { + if (unit === "percent") return "%"; + if (!unit || unit === "count" || unit === "ratio") return null; + return unit; +} + +/* ─────────────────────────── small helpers ─────────────────────────── */ + +/** The measure/ratio's catalog key as a fallback panel title (the composer usually sets a real title). */ +function measureLabel(panelSpec) { + return panelSpec.title || panelSpec.measure || panelSpec.ratio || "Metric"; +} + +/** snake_case -> Title Case, mirroring derive.js's humanizeKey (kept local so this module imports nothing DOM-y). */ +function humanize(key) { + return String(key == null ? "" : key) + .replace(/_/g, " ") + .replace(/\s+/g, " ") + .trim() + .replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** A number or null (never NaN) — the chart readers treat null as a gap. */ +function numOrNull(v) { + if (v == null || v === "") return null; + const n = Number(v); + return isNaN(n) ? null : n; +} + +/* ─────────────────────────── D5 annotations + D6 drill-down ─────────────────────────── */ + +/** Whether a viz is a time-series chart (the only shape event annotations overlay). */ +function isTimeSeriesViz(viz) { + return viz === "line" || viz === "area" || viz === "stacked" || viz === "stacked-bar"; +} + +/** The drill keys for a row (design D6): one {dimension, value} per declared group dimension, so a click pins that + * exact category/combo. Returns null when any dimension value is null/empty (a "(none)" bucket can't be filtered to). */ +function buildDrillKeys(row, dims) { + if (!Array.isArray(dims) || !dims.length) return null; + const keys = []; + for (const d of dims) { + const v = row[d]; + if (v == null || v === "") return null; + keys.push({ dimension: d, value: String(v) }); + } + return keys.length ? keys : null; +} + +/** + * Reshape the run response's `annotations` ([{source, events:[{ts,label}]}], in requested order) into the chart's + * layer shape ([{source, displayName, events}]), resolving each source's display name off the catalog map (falling + * back to a humanized key). Sources with no events in the window are dropped so the annotation key stays meaningful. + */ +function annotationLayers(resultAnnotations, meta) { + if (!Array.isArray(resultAnnotations) || !resultAnnotations.length) return null; + const layers = []; + for (const a of resultAnnotations) { + const events = Array.isArray(a.events) ? a.events : []; + if (!events.length) continue; + const m = meta && typeof meta.get === "function" ? meta.get(a.source) : null; + layers.push({ source: a.source, displayName: (m && m.displayName) || humanize(a.source), events }); + } + return layers.length ? layers : null; +} + +/** A cached key -> {displayName} map of the catalog's annotation sources (design D5), so the chart legend/tooltip + * use the same display names the composer offered. getCatalog() is itself cached, so this resolves once per load. */ +let _annotationMetaPromise = null; +function annotationMetaMap() { + if (!_annotationMetaPromise) { + _annotationMetaPromise = getCatalog().then((cat) => { + const map = new Map(); + for (const a of ((cat && cat.compose) || {}).annotationSources || []) { + if (a && a.key) map.set(a.key, { displayName: a.displayName || a.key }); + } + return map; + }); + } + return _annotationMetaPromise; +} + +/** + * The transient drill-down chip (design D6): the pinned filter(s) as text, a "clear" that pops back to the base + * spec, and — when a pinned dimension is `server` — a jump to that server's existing detail page. Every value is + * textContent (R4/XSS); the chip is view-state only (the stored definition is never touched). + */ +function drillChip(drill, onDrill) { + const parts = drill.keys.map((k) => humanize(k.dimension) + " = " + k.value); + const chip = el("div", { class: "drill-chip" }, [ + el("span", { class: "drill-label", text: "Filtered: " + parts.join(", ") }), + ]); + const serverKey = drill.keys.find((k) => k.dimension === "server"); + if (serverKey) { + const open = el("button", { class: "btn small drill-open", type: "button", text: "Open server page →" }); + open.addEventListener("click", () => navigateServer(serverKey.value)); + chip.appendChild(open); + } + const clear = el("button", { class: "btn small drill-clear", type: "button", title: "Clear drill-down", "aria-label": "Clear drill-down", text: "×" }); + clear.addEventListener("click", () => onDrill(null)); + chip.appendChild(clear); + return chip; +} + +/** A soft note when a panel groups by server: a fleet-wide group only lists servers that collect the measure. */ +function partialFleetNote(groupDims) { + if (!groupDims.includes("server")) return null; + return el("div", { class: "chart-note", text: "Only servers collecting this measure appear." }); +} + +/** The folded, read-only "compiled SQL" disclosure (transparency; progressive disclosure keeps it out of the way). */ +function sqlDisclosure(sql) { + if (!sql) return null; + return disclosure("View compiled SQL", el("pre", { class: "compiled-sql", text: sql }), { max: 40 }); +} diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/derive.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/derive.js new file mode 100644 index 000000000..46365b7e2 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/derive.js @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +/* + * PURE client-side derivation for the #1563 custom-view composer — NO DOM, NO fetch, and it imports nothing (so + * it unit-tests in isolation and can never pull a browser/chart dependency in). Given a parsed read SAMPLE (the + * JSON object a /api/read/{tool} returned) and a target viz, it infers a sensible starting vizcfg so the + * composer seeds its sub-editor instead of making the operator hand-author every column: + * - rowsKey = the top-level key whose value is a non-empty array (the row set the table/line/bandlist plots) + * - columns / stats / series = the row's / object's scalar keys, each with a humanized label and a FORMATTERS + * key guessed from the column NAME + the sample VALUE (mirrors util.js's registry: int/num1/ms/mb/pct/...) + * - line xKey = the first timestamp-looking key; series = the remaining numeric keys, colored from the palette + * The palette is a PARAMETER (the composer passes charts.js SERIES_COLORS) so this file stays import-free. + * Everything is deterministic; the composer always lets the operator override the guesses. + */ + +/** An ISO-8601-ish naive/zoned timestamp at the START of a string (matches the store's naive-UTC columns). */ +const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/; + +/** Whether a sample VALUE looks like a timestamp string (so a *_time-less key still formats as time). */ +export function looksLikeTimestamp(value) { + return typeof value === "string" && ISO_DATE_RE.test(value); +} + +/** A scalar is anything that can render in a cell/stat: string, number, boolean, or null (never an object/array). */ +export function isScalar(value) { + return value === null || typeof value !== "object"; +} + +/** snake_case / lower_case key -> a Title Case label ("wait_type" -> "Wait Type"). */ +export function humanizeKey(key) { + return String(key == null ? "" : key) + .replace(/_/g, " ") + .replace(/\s+/g, " ") + .trim() + .replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** + * Guess a FORMATTERS registry key (util.js) for a column from its NAME and a sample VALUE. A concrete + * value wins first (a boolean is bool, an ISO string is time, any other string is text — a non-numeric string + * can't take a numeric formatter); otherwise the name's unit suffix decides (ms / mb / pct), then a number + * falls back to int (whole) or num1 (fractional), and finally a count/id name hint or plain text. + */ +export function inferFormat(key, value) { + const k = String(key == null ? "" : key).toLowerCase(); + + if (typeof value === "boolean") return "bool"; + if (looksLikeTimestamp(value)) return k.startsWith("last_") ? "reltime" : "time"; + if (typeof value === "string") return "text"; + + /* value is a number, null, or undefined -> lean on the name. */ + if (/(_ms$|_ms_|milliseconds|latency|duration|elapsed)/.test(k)) return "ms"; + if (/(_mb$|_mb_|megabytes)/.test(k)) return "mb"; + if (/(pct|percent)/.test(k)) return "pct"; + if (k.endsWith("_enabled") || /^(is_|has_|can_|are_)/.test(k)) return "bool"; + if (k.endsWith("_at") || k.endsWith("_date") || k.startsWith("last_")) return "time"; + if (typeof value === "number") return Number.isInteger(value) ? "int" : "num1"; + if (/(_id$|_count$|count|_runs$|tasks)/.test(k)) return "int"; + return "text"; +} + +/** The top-level key whose value is a (preferably non-empty) array — the row set a table/line/bandlist plots. */ +export function findRowsKey(data) { + if (!data || typeof data !== "object" || Array.isArray(data)) return null; + for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v) && v.length > 0) return k; + } + /* Fall back to an empty array key so a windowed read with no rows still maps to the right key. */ + for (const [k, v] of Object.entries(data)) { + if (Array.isArray(v)) return k; + } + return null; +} + +/** The first row object under `rowsKey`, or null. */ +function firstRow(data, rowsKey) { + const rows = data && rowsKey ? data[rowsKey] : null; + return Array.isArray(rows) && rows.length && rows[0] && typeof rows[0] === "object" ? rows[0] : null; +} + +/** Derive table columns from a row array — every scalar key of the first row, labeled + formatted. */ +export function deriveColumns(rows) { + const first = Array.isArray(rows) && rows.length ? rows[0] : null; + if (!first || typeof first !== "object") return []; + return Object.keys(first) + .filter((k) => isScalar(first[k])) + .map((k) => ({ key: k, label: humanizeKey(k), format: inferFormat(k, first[k]) })); +} + +/** Derive stat tiles from a flat top-level object — every scalar top-level key. */ +export function deriveStats(data) { + if (!data || typeof data !== "object" || Array.isArray(data)) return []; + return Object.entries(data) + .filter(([, v]) => isScalar(v)) + .map(([k, v]) => ({ key: k, label: humanizeKey(k), format: inferFormat(k, v) })); +} + +/** Derive a line config: rowsKey + a timestamp-ish xKey + one series per remaining numeric key (palette-colored). */ +export function deriveLine(data, palette) { + const rowsKey = findRowsKey(data) || ""; + const cfg = { rowsKey, xKey: "", series: [] }; + const first = firstRow(data, rowsKey); + if (!first) return cfg; + + const keys = Object.keys(first); + cfg.xKey = + keys.find((k) => looksLikeTimestamp(first[k])) || + keys.find((k) => /(_at$|_time$|^time$|_date$)/.test(k.toLowerCase())) || + keys[0] || + ""; + + const colors = Array.isArray(palette) ? palette : []; + let i = 0; + for (const k of keys) { + if (k === cfg.xKey) continue; + if (typeof first[k] === "number") { + cfg.series.push({ + key: k, + label: humanizeKey(k), + color: colors.length ? colors[i % colors.length] : undefined, + }); + i++; + } + } + return cfg; +} + +/** Derive a bandlist config: rowsKey + a best-guess primary/band/reason key (all operator-overridable). */ +export function deriveBandlist(data) { + const rowsKey = findRowsKey(data) || ""; + const first = firstRow(data, rowsKey) || {}; + const keys = Object.keys(first); + const lc = (s) => String(s).toLowerCase(); + const bandKey = keys.find((k) => lc(k).includes("band") || lc(k).includes("severity")) || ""; + const primaryKey = keys.find((k) => typeof first[k] === "string" && k !== bandKey) || keys[0] || ""; + const reasonKey = + keys.find((k) => /(reason|message|detail|status)/.test(lc(k)) && k !== primaryKey && k !== bandKey) || ""; + return { rowsKey, primaryKey, bandKey, reasonKey }; +} + +/** Suggest a viz for a sample: a table when it carries a row array, else a stat block over its flat scalars. */ +export function suggestViz(data) { + return findRowsKey(data) ? "table" : "stat"; +} + +/** Derive the full vizcfg for a given viz from a sample (the composer's seed; empty {} for an unknown viz). */ +export function deriveVizConfig(data, viz, palette) { + switch (viz) { + case "table": { + const rowsKey = findRowsKey(data) || ""; + const rows = rowsKey && data ? data[rowsKey] : []; + return { rowsKey, columns: deriveColumns(rows) }; + } + case "line": + return deriveLine(data, palette); + case "stat": + return { stats: deriveStats(data) }; + case "bandlist": + return deriveBandlist(data); + default: + return {}; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/editor.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/editor.js new file mode 100644 index 000000000..5f46f4401 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/editor.js @@ -0,0 +1,2185 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +/* + * The #1563 custom-view COMPOSER — the editor for a view (name + description + an ordered panel + * list). Each panel is a renderPanel descriptor built through the SAME viz registry the renderer uses, previewed + * LIVE (debounced) through renderPanel so the operator sees exactly what will be stored. + * + * read picker (grouped by catalog category) -> typed param inputs from the catalog (int/double -> number, + * bool -> checkbox, text -> input, server -> a fleet dropdown; a required param blocks preview + save until + * filled) -> viz picker -> width (span 1|2) -> title -> a vizcfg sub-editor SEEDED by client-side derivation + * (derive.js) from a live sample fetch, then hand-tunable (columns/series/stats + a format per field). + * + * ACCESS: editing is available to any AUTHENTICATED seat — the same reach as viewing (network operation is the + * normal mode). The server gates writes by the host auth (token->cookie + CIDR over the network; tokenless on + * loopback) plus a Content-Type check; can_edit only goes false if the session probe itself failed, which shows + * the reload notice below. All user text reaches the DOM via el()/textContent + * (R4 — el() throws on an html prop). Series COLOR is constrained to an <input type=color> (#rrggbb) + the + * charts.js palette, NEVER free text, so it can't inject a style-attribute sink (reconciliation #4). + * + * The 60s poll in app.js SKIPS re-rendering this route (the editor-route poll guard), so an in-progress edit is + * never clobbered by a background refresh. + */ + +import { el, mount, apiGet, readTool } from "./util.js"; +import { renderPanel, VIZ } from "./panels.js"; +import { SERIES_COLORS, normalizeColor } from "./charts.js"; +import { renderComposedPanelCard } from "./compose.js"; +import * as api from "./views-api.js"; +import * as derive from "./derive.js"; + +/** The FORMATTERS keys the format pickers offer (mirrors util.js FORMATTERS). */ +const FORMAT_OPTIONS = ["text", "int", "num1", "num2", "pct", "ms", "mb", "time", "reltime", "bool"]; +const PREVIEW_DEBOUNCE_MS = 350; + +/** The view-level default time-range choices (hours) offered in the composer + the rendered view's chrome. */ +const RANGE_OPTIONS = [ + { hours: 1, label: "Last hour" }, + { hours: 6, label: "Last 6 hours" }, + { hours: 24, label: "Last 24 hours" }, + { hours: 24 * 7, label: "Last 7 days" }, + { hours: 24 * 30, label: "Last 30 days" }, + { hours: 24 * 90, label: "Last 90 days" }, +]; +const DEFAULT_RANGE_HOURS = 24; + +/** The default chart per composed-panel shape (a working chart the instant a measure is picked). */ +const DEFAULT_VIZ_FOR_SHAPE = { timeseries: "line", ranked: "bar", scalar: "stat" }; + +/** The most event-annotation sources a time-series panel may overlay (design D5) — mirrors the server's + * ComposeLimits.MaxAnnotations so the composer caps at the same number the run endpoint accepts. */ +const MAX_ANNOTATIONS = 4; + +/* ─────────────────────────── entry ─────────────────────────── */ + +export async function renderEditor(main, id) { + mount(main, el("div", { class: "strip loading", text: "Loading composer…" })); + + const session = await api.getSession(); + if (!session.can_edit) { + mount(main, [ + backHead(id), + el("div", { class: "strip empty" }, [ + "Couldn't confirm your session, so the composer is read-only for now. Reload the page to edit; " + + "you can still open and export views.", + ]), + ]); + return; + } + + const [catalog, fleet] = await Promise.all([api.getCatalog(), loadFleetOptions()]); + + let model; + let editingId = null; + let loadedVersion = null; + + if (id != null && id !== "new") { + const res = await api.getView(id); + if (res.kind !== "data" || !res.data) { + mount(main, [backHead(id), el("div", { class: "strip error", text: res.message || "Could not load this view." })]); + return; + } + editingId = res.data.id; + loadedVersion = res.data.version; + model = viewToModel(res.data); + } else { + model = { name: "", description: "", panels: [newPanel()], variables: [], rangeHours: null }; + } + + buildEditor(main, { model, editingId, loadedVersion, catalog, fleet }); +} + +/* Fleet server options for the `server` param dropdown (value = stored server_name, label = display name). Each + option also carries the per-server platform (/api/fleet's D4 fields) the composed measure picker uses for D4 + auto-greying: engineEdition (null = not yet probed), isAzureSqlDb (edition 5), isAzureMi (edition 8). The server + dropdown reads only value/label, so the extra fields are inert there. Exported so the notebook composer — which + greys the same way — shares this one enrichment. */ +export async function loadFleetOptions() { + const res = await apiGet("/api/fleet"); + if (res.kind !== "data" || !res.data) return []; + return [...(res.data.cards || [])] + .map((c) => ({ + value: c.server_name || c.display_name, + label: c.display_name, + engineEdition: typeof c.engine_edition === "number" ? c.engine_edition : null, + isAzureSqlDb: c.is_azure_sql_db === true, + isAzureMi: c.is_azure_mi === true, + })) + .filter((o) => o.value) + .sort((a, b) => a.label.localeCompare(b.label)); +} + +/* ─────────────────────────── model <-> stored definition ─────────────────────────── */ + +/** A new panel — a COMPOSED metric by default (the v2 headline); a read panel is the "advanced" alternative. */ +function newPanel() { + return newComposedPanel(); +} + +export function newComposedPanel() { + return { + kind: "source", + source: "", + measure: "", + ratio: "", + aggregate: "", + unit: "", + shape: "timeseries", + timeBucket: "hour", + topN: 10, + filters: [], + groupBy: [], + viz: "", + span: 1, + title: "", + hours: null, + thresholds: [], + annotations: [], + }; +} + +function newReadPanel() { + return { kind: "read", read: "", params: {}, viz: "", span: 1, title: "", vizcfg: {} }; +} + +function viewToModel(view) { + const def = view.definition || {}; + const panels = Array.isArray(def.panels) ? def.panels.map(descToPanel) : []; + return { + name: view.name || "", + description: view.description || "", + panels: panels.length ? panels : [newPanel()], + variables: parseVariables(def.variables), + rangeHours: def.range && typeof def.range.hours === "number" ? def.range.hours : null, + }; +} + +/** The view's stored template variables -> the model list ({name, dimension, default}); malformed entries dropped. */ +function parseVariables(node) { + if (!Array.isArray(node)) return []; + return node + .filter((v) => v && typeof v === "object" && v.name) + .map((v) => ({ name: String(v.name), dimension: v.dimension ? String(v.dimension) : "", default: v.default != null ? String(v.default) : "" })); +} + +/* A stored descriptor -> the editor's panel model, dispatched on kind: a v2 composed panel names a `source`, a v1 + read panel names a `read`. `kind` is an editor-only field (the stored def carries neither `kind` nor `shape` — the + server infers the panel kind from `source`, and shape from timeBucket/topN). */ +function descToPanel(d) { + const src = d && typeof d === "object" ? d : {}; + return src.source != null ? descToComposedPanel(src) : descToReadPanel(src); +} + +export function descToComposedPanel(d) { + const hasBucket = d.timeBucket && d.timeBucket !== "none"; + const hasTopN = d.topN != null; + return { + kind: "source", + source: d.source || "", + measure: d.measure || "", + ratio: d.ratio || "", + aggregate: d.aggregate || "", + unit: d.unit || "", + shape: hasBucket ? "timeseries" : hasTopN ? "ranked" : "scalar", + timeBucket: hasBucket ? d.timeBucket : "hour", + topN: hasTopN ? d.topN : 10, + filters: Array.isArray(d.filters) ? d.filters.map(descToFilter) : [], + groupBy: Array.isArray(d.groupBy) ? d.groupBy.map(String) : [], + viz: d.viz || "", + span: d.span === 2 ? 2 : 1, + title: d.title || "", + hours: typeof d.hours === "number" ? d.hours : null, + thresholds: cleanThresholds(d.thresholds), + annotations: Array.isArray(d.annotations) ? d.annotations.map(String) : [], + }; +} + +/* A stored filter -> the model's single-text-box form: an array value (eq/neq multi) joins to a comma string; a + scalar / "$var" value stays as-is. panelToDesc reverses this. */ +function descToFilter(f) { + const src = f && typeof f === "object" ? f : {}; + const value = Array.isArray(src.value) ? src.value.join(", ") : src.value != null ? String(src.value) : ""; + return { dimension: src.dimension || "", op: src.op || "eq", value }; +} + +/* The v1 read panel: everything beyond title/read/params/viz/span is preserved verbatim as `vizcfg` and spread + back on save, so nothing is lost on an edit round-trip. */ +function descToReadPanel(d) { + const { title, read, params, viz, span, kind, ...vizcfg } = d; + return { + kind: "read", + read: read || "", + params: params && typeof params === "object" && !Array.isArray(params) ? { ...params } : {}, + viz: viz || "", + span: span === 2 ? 2 : 1, + title: title || "", + vizcfg: vizcfg || {}, + }; +} + +function panelToDesc(p) { + return p.kind === "source" ? composedPanelToDesc(p) : readPanelToDesc(p); +} + +/* A composed panel model -> the stored descriptor. `kind`/`shape`/preview-only fields are dropped: the server + reads the panel kind from `source` and the mode from timeBucket/topN. measure XOR ratio; aggregate only for a + scalar; timeBucket only for a time series; topN only for a ranked panel. */ +export function composedPanelToDesc(p) { + const d = { source: p.source, viz: p.viz, span: p.span === 2 ? 2 : 1, title: p.title || "" }; + if (p.ratio) d.ratio = p.ratio; + else d.measure = p.measure; + if (!p.ratio && p.aggregate) d.aggregate = p.aggregate; + if (p.unit) d.unit = p.unit; + if (p.shape === "timeseries") d.timeBucket = p.timeBucket || "hour"; + else if (p.shape === "ranked") d.topN = clampInt(p.topN, 1, 1000, 10); + const filters = (p.filters || []).map(filterToDesc).filter((f) => f.dimension && f.op); + if (filters.length) d.filters = filters; + if ((p.groupBy || []).length) d.groupBy = [...p.groupBy]; + if (p.hours != null) d.hours = p.hours; + const thresholds = cleanThresholds(p.thresholds); + if (thresholds.length) d.thresholds = thresholds; + /* Event-annotation sources (design D5) are only meaningful on a time-series panel — the server rejects them + otherwise — so emit them only for that shape. Kept in the model across shape changes (like thresholds), just + not serialized off a ranked/scalar panel. */ + if (p.shape === "timeseries") { + const annotations = cleanAnnotations(p.annotations); + if (annotations.length) d.annotations = annotations; + } + return d; +} + +/** Normalize a thresholds list to at most 4 finite numbers (design D3) — drops the editor's null/blank entries so + * a stored def (and the preview) only ever carries real reference-line values, matching the server's bounds. */ +function cleanThresholds(list) { + if (!Array.isArray(list)) return []; + return list.filter((n) => typeof n === "number" && isFinite(n)).slice(0, 4); +} + +/** Normalize an annotation-source list to at most MAX_ANNOTATIONS distinct non-empty string keys (design D5), + * mirroring the server's dedupe + cap so a stored def (and the preview) matches what the run endpoint accepts. */ +function cleanAnnotations(list) { + if (!Array.isArray(list)) return []; + const seen = new Set(); + const out = []; + for (const k of list) { + if (typeof k !== "string" || !k || seen.has(k)) continue; + seen.add(k); + out.push(k); + if (out.length >= MAX_ANNOTATIONS) break; + } + return out; +} + +/* A model filter -> the stored form: an eq/neq value with commas splits into a multi-value array; everything else + (a single literal, a LIKE pattern, a threshold, or a "$var" reference) stays a scalar string. */ +function filterToDesc(f) { + const raw = f.value == null ? "" : String(f.value); + let value = raw; + if ((f.op === "eq" || f.op === "neq") && raw.includes(",")) { + value = raw + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length); + } + return { dimension: f.dimension, op: f.op, value }; +} + +function readPanelToDesc(p) { + return { + title: p.title || "", + read: p.read, + viz: p.viz, + span: p.span === 2 ? 2 : 1, + ...(p.vizcfg || {}), + params: cleanParams(p.params), + }; +} + +function modelToDefinition(model) { + const def = { panels: model.panels.map(panelToDesc) }; + const variables = (model.variables || []) + .filter((v) => v.name && v.dimension) + .map((v) => (v.default ? { name: v.name, dimension: v.dimension, default: v.default } : { name: v.name, dimension: v.dimension })); + if (variables.length) def.variables = variables; + if (model.rangeHours) def.range = { hours: model.rangeHours }; + return def; +} + +/** Drop null / undefined / empty-string param values so an optional-left-blank param uses the tool's default. */ +function cleanParams(params) { + const out = {}; + for (const [k, v] of Object.entries(params || {})) { + if (v === null || v === undefined || v === "") continue; + out[k] = v; + } + return out; +} + +/* ─────────────────────────── the editor shell ─────────────────────────── */ + +function buildEditor(main, ctx) { + const { model } = ctx; + + const nameInput = el("input", { + class: "editor-input", + type: "text", + placeholder: "View name (required)", + "aria-label": "View name", + }); + nameInput.value = model.name; + nameInput.addEventListener("input", () => { + model.name = nameInput.value; + refreshSaveState(); + }); + + const descInput = el("input", { + class: "editor-input", + type: "text", + placeholder: "Description (optional)", + "aria-label": "View description", + }); + descInput.value = model.description; + descInput.addEventListener("input", () => { + model.description = descInput.value; + }); + + const panelsBox = el("div", { class: "panels-box" }); + const saveStatus = el("div", { class: "save-status" }); + const saveBtn = el("button", { + class: "btn primary", + type: "button", + text: ctx.editingId != null ? "Save changes" : "Create view", + }); + const cancelBtn = el("button", { class: "btn", type: "button", text: "Cancel" }); + + const backHash = ctx.editingId != null ? "#/view/" + encodeURIComponent(ctx.editingId) : "#/views"; + cancelBtn.addEventListener("click", () => { + location.hash = backHash; + }); + saveBtn.addEventListener("click", onSave); + + const addBtn = el("button", { class: "btn", type: "button", text: "+ Add panel" }); + addBtn.addEventListener("click", () => { + model.panels.push(newPanel()); + rebuildPanels(); + }); + + /* The scope a composed panel's LIVE preview runs against — the whole fleet + the view's default range + the + declared variables (bound to their defaults). Read live at preview time, so it always reflects the current + view-scope model. The rendered view lets the viewer change server/time/variable values; this is the author's + preview baseline. */ + function previewScope() { + const values = {}; + for (const v of model.variables || []) { + if (v.name && v.dimension && v.dimension !== "server" && v.default) values[v.name] = v.default; + } + return { + server: "All", + hours: model.rangeHours || DEFAULT_RANGE_HOURS, + variables: (model.variables || []) + .filter((v) => v.name && v.dimension) + .map((v) => ({ name: v.name, dimension: v.dimension, default: v.default || undefined })), + values, + }; + } + + const inner = { ...ctx, rebuildPanels, refreshSaveState, previewScope }; + + const viewScopeSection = buildViewScopeSection(model, ctx.catalog, rebuildPanels); + + function rebuildPanels() { + mount(panelsBox, model.panels.map((p, i) => buildPanelEditor(p, i, inner))); + refreshSaveState(); + } + + function refreshSaveState() { + const problem = saveBlocker(model, ctx.catalog); + saveBtn.disabled = problem != null; + mount(saveStatus, problem ? el("span", { class: "muted", text: problem }) : null); + } + + async function onSave() { + saveBtn.disabled = true; + + /* Auto-derive-on-save (B1): seed any field-less table/line/stat panel from a fresh live sample so the stored + definition never carries an undefined columns/series/stats array (which would crash the shared renderer for + EVERY seat). A panel with no live sample stays un-derived and is caught by the field-config backstop below. */ + mount(saveStatus, el("span", { class: "muted", text: "Preparing…" })); + await ensureFieldConfigs(model, ctx.catalog); + + const problem = saveBlocker(model, ctx.catalog) || fieldConfigProblem(model); + if (problem) { + saveBtn.disabled = false; + rebuildPanels(); // surface any field-config the auto-derive DID populate + mount(saveStatus, el("span", { class: "strip error", text: problem })); + return; + } + + mount(saveStatus, el("span", { class: "muted", text: "Saving…" })); + + const body = { + name: model.name.trim(), + description: model.description ? model.description : null, + definition: modelToDefinition(model), + }; + + let res; + if (ctx.editingId != null) { + body.version = ctx.loadedVersion; + res = await api.updateView(ctx.editingId, body); + } else { + res = await api.createView(body); + } + + if (res.kind === "data" && res.data) { + location.hash = "#/view/" + encodeURIComponent(res.data.id); + return; + } + + saveBtn.disabled = false; + handleSaveError(res, saveStatus, ctx, main); + } + + mount(main, [ + el("div", { class: "page-head" }, [ + el("a", { href: backHash, text: "← Back" }), + el("h2", { text: ctx.editingId != null ? "Edit view" : "New view" }), + ]), + el("div", { class: "editor-meta" }, [field("Name", nameInput), field("Description", descInput)]), + viewScopeSection, + el("h3", { class: "section-title", text: "Panels" }), + panelsBox, + addBtn, + el("div", { class: "editor-footer" }, [saveBtn, cancelBtn, saveStatus]), + ]); + + rebuildPanels(); +} + +/* On a 409 while EDITING, the row changed under us (stale version) or the name now collides — either way, offer + to reload the latest (discarding local edits) so the operator re-applies against fresh state. */ +function handleSaveError(res, saveStatus, ctx, main) { + const parts = [el("span", { text: res.message || "Could not save the view." })]; + if (res.status === 409 && ctx.editingId != null) { + const reload = el("button", { class: "btn small", type: "button", text: "Reload latest" }); + reload.addEventListener("click", () => renderEditor(main, ctx.editingId)); + parts.push(reload); + } + mount(saveStatus, el("div", { class: "strip error" }, parts)); +} + +/** The reason SAVE is blocked (name/panels + each panel's prerequisites), or null when the view is savable. This is + * the button-DISABLING gate; the field-config requirement is enforced separately in onSave (see + * fieldConfigProblem) AFTER an auto-derive pass, so the Save click is never blocked from running that derive. */ +function saveBlocker(model, catalog) { + if (!model.name || !model.name.trim()) return "Enter a view name."; + if (!model.panels.length) return "Add at least one panel."; + for (let i = 0; i < model.panels.length; i++) { + const p = model.panels[i]; + const n = i + 1; + const problem = p.kind === "source" ? composedBlocker(p, catalog) : readBlocker(p, catalog); + if (problem) return "Panel " + n + ": " + problem; + } + return null; +} + +/** A read panel's SAVE blocker (read/viz/required-params), or null. */ +function readBlocker(p, catalog) { + if (!p.read) return "choose a read."; + if (!p.viz) return "choose a visualization."; + const missing = missingRequired(p, catalog); + if (missing.length) return "fill required " + missing.join(", ") + "."; + return null; +} + +/** A composed panel's SAVE blocker — the coherence rules mirrored from the server so the author gets an inline reason + * (the run endpoint stays the authority; anything subtler surfaces as a preview error). */ +export function composedBlocker(p, catalog) { + const measure = composedMeasure(p, catalog); + if (!measure) return "choose a measure."; + const isRatio = measure.kind === "ratio"; + if (!isRatio && !p.aggregate) return "choose an aggregate."; + if (!p.viz) return "choose a chart type."; + const groups = (p.groupBy || []).length; + if (p.shape === "scalar" && groups > 0) return "a single value can't group — switch to Over time or Ranked."; + if (p.shape === "ranked" && groups === 0) return "a ranked chart needs a group-by (the categories to rank)."; + const reason = vizModeReason(p.viz, p.shape, groups); + if (reason) return reason; + return null; +} + +/** Which read-panel vizzes need a load-bearing field array: table -> columns, line -> series, stat -> stats. + * Composed panels never do (their fields come from the measure/group-by), so they are excluded here. */ +function needsFieldConfig(p) { + if (p.kind === "source") return false; + return p.viz === "table" || p.viz === "line" || p.viz === "stat"; +} + +/** The panel's load-bearing field array for its viz, or null for a viz that has none. */ +function fieldArray(p) { + const c = p.vizcfg || {}; + if (p.viz === "table") return c.columns; + if (p.viz === "line") return c.series; + if (p.viz === "stat") return c.stats; + return null; +} + +/** Whether a table/line/stat panel has at least one configured field. */ +function hasFieldConfig(p) { + const arr = fieldArray(p); + return Array.isArray(arr) && arr.length > 0; +} + +/* Auto-derive-on-save feeder (B1): fetch a live sample for each field-less table/line/stat panel and seed its + vizcfg via derive.js, so the author rarely hits the fieldConfigProblem backstop. Skips a panel with no read / an + unfilled required param / no live sample — those the backstop reports. */ +async function ensureFieldConfigs(model, catalog) { + for (const p of model.panels) { + if (!needsFieldConfig(p) || hasFieldConfig(p)) continue; + if (!p.read || missingRequired(p, catalog).length) continue; + const res = await readTool(p.read, cleanParams(p.params)); + if (res.kind === "data" && res.data) { + p.vizcfg = derive.deriveVizConfig(res.data, p.viz, SERIES_COLORS); + } + } +} + +/** The reason SAVE's field-config backstop trips (a table/line/stat panel still without fields), or null. */ +function fieldConfigProblem(model) { + for (let i = 0; i < model.panels.length; i++) { + const p = model.panels[i]; + if (needsFieldConfig(p) && !hasFieldConfig(p)) { + return ( + "Panel " + (i + 1) + ": add at least one field (use Auto-detect fields) or adjust the read so a sample is available." + ); + } + } + return null; +} + +/* ─────────────────────────── one panel editor ─────────────────────────── */ + +/* A panel editor is dispatched on kind: a composed (v2) metric editor or a read (v1) editor. Both share the head + (Panel N + reorder/remove) and a "Composed / Read" kind toggle; switching kind swaps the panel to that kind's + default shape (title + width are carried over) and rebuilds. */ +function buildPanelEditor(p, index, ctx) { + const kindToggle = buildKindToggle(p, index, ctx); + return p.kind === "source" + ? buildComposedPanelEditor(p, index, ctx, kindToggle) + : buildReadPanelEditor(p, index, ctx, kindToggle); +} + +/* The segmented "Composed metric / Read" toggle. Switching preserves title + width and resets the rest to the new + kind's defaults (the two shapes share almost nothing), then rebuilds every panel so the swapped body renders. */ +function buildKindToggle(p, index, ctx) { + const seg = el("div", { class: "kind-toggle", role: "group", "aria-label": "Panel data source" }); + const opts = [ + { kind: "source", label: "Composed metric" }, + { kind: "read", label: "Read (advanced)" }, + ]; + for (const o of opts) { + const active = (p.kind || "source") === o.kind; + const btn = el("button", { + class: "kind-opt" + (active ? " active" : ""), + type: "button", + text: o.label, + "aria-pressed": active ? "true" : "false", + }); + btn.addEventListener("click", () => { + if ((p.kind || "source") === o.kind) return; + const carried = { span: p.span || 1, title: p.title || "" }; + const next = o.kind === "source" ? newComposedPanel() : newReadPanel(); + Object.assign(next, carried); + ctx.model.panels[index] = next; + ctx.rebuildPanels(); + }); + seg.appendChild(btn); + } + return seg; +} + +function buildReadPanelEditor(p, index, ctx, kindToggle) { + const { catalog, fleet, model } = ctx; + let lastSample = null; + let previewTimer = null; + + const paramsBox = el("div", { class: "params-box" }); + const vizcfgBox = el("div", { class: "vizcfg-box" }); + const previewBox = el("div", { class: "panel-preview" }); + const bodyBox = el("div", { class: "panel-editor-body" }); + const headSub = el("span", { class: "panel-editor-sub" }); + + const readSelect = buildReadSelect(catalog, p.read); + readSelect.addEventListener("change", async () => { + p.read = readSelect.value; + p.params = defaultParams(catalog, p.read); + p.vizcfg = {}; + rebuildParams(); + rebuildBody(); // reveal Parameters (or the "choose a read" hint) as prerequisites are met (M5) + refreshHead(); + ctx.refreshSaveState(); + await refreshSampleAndDerive({ resetViz: true }); + rebuildBody(); // a viz may now be auto-suggested -> reveal Fields + Preview + schedulePreview(); + }); + + const vizSelect = buildVizSelect(catalog, p.viz); + vizSelect.addEventListener("change", () => { + p.viz = vizSelect.value; + if (lastSample) p.vizcfg = derive.deriveVizConfig(lastSample, p.viz, SERIES_COLORS); + rebuildVizcfg(); + rebuildBody(); // reveal Fields + Preview once a viz is chosen (M5) + ctx.refreshSaveState(); + schedulePreview(); + }); + + const spanSelect = el("select", { class: "editor-select", "aria-label": "Panel width" }, [ + el("option", { value: "1", text: "1 column" }), + el("option", { value: "2", text: "2 columns (wide)" }), + ]); + spanSelect.value = String(p.span || 1); + spanSelect.addEventListener("change", () => { + p.span = spanSelect.value === "2" ? 2 : 1; + schedulePreview(); + }); + + const titleInput = el("input", { class: "editor-input", type: "text", placeholder: "Panel title", "aria-label": "Panel title" }); + titleInput.value = p.title; + titleInput.addEventListener("input", () => { + p.title = titleInput.value; + refreshHead(); + schedulePreview(); + }); + + /* "Auto-detect fields" first, then "Re-detect from sample" once a config exists — its real job is re-inspect- + and-rebuild (it DISCARDS manual field edits), so the label distinguishes the first detect from a re-detect. */ + const autoBtn = el("button", { class: "btn small", type: "button" }); + function refreshAutoBtn() { + autoBtn.textContent = hasVizcfg(p) ? "Re-detect from sample" : "Auto-detect fields"; + } + autoBtn.addEventListener("click", async () => { + await refreshSampleAndDerive({ resetViz: false, force: true }); + rebuildBody(); + schedulePreview(); + }); + const fieldsHelp = el("div", { + class: "block-help", + text: "Detected from a live sample; edit below or re-detect.", + }); + + const upBtn = el("button", { class: "btn small icon", type: "button", text: "↑", title: "Move up", "aria-label": "Move panel up" }); + const downBtn = el("button", { class: "btn small icon", type: "button", text: "↓", title: "Move down", "aria-label": "Move panel down" }); + const removeBtn = el("button", { class: "btn small danger", type: "button", text: "Remove", "aria-label": "Remove panel" }); + upBtn.disabled = index === 0; + downBtn.disabled = index === model.panels.length - 1; + upBtn.addEventListener("click", () => { + swap(model.panels, index, index - 1); + ctx.rebuildPanels(); + }); + downBtn.addEventListener("click", () => { + swap(model.panels, index, index + 1); + ctx.rebuildPanels(); + }); + removeBtn.addEventListener("click", () => { + model.panels.splice(index, 1); + if (!model.panels.length) model.panels.push(newPanel()); + ctx.rebuildPanels(); + }); + + /* Stable section nodes composed by rebuildBody() — created once so their inputs keep focus/state across a + progressive-disclosure rebuild. */ + const identityGrid = el("div", { class: "editor-grid" }, [ + field("Read", readSelect), + field("Visualization", vizSelect), + field("Width", spanSelect), + field("Title", titleInput, "field-title"), + ]); + const paramsSection = labeledBlock("Parameters", paramsBox); + const fieldsSection = labeledBlock( + "Fields", + el("div", { class: "vizcfg-wrap" }, [fieldsHelp, el("div", { class: "vizcfg-actions" }, [autoBtn]), vizcfgBox]) + ); + const previewSection = labeledBlock("Preview", previewBox); + previewSection.classList.add("panel-preview-col"); + + function rebuildParams() { + mount( + paramsBox, + buildParamInputs(p, catalog, fleet, () => { + refreshHead(); // a `server` param feeds the panel-header echo + ctx.refreshSaveState(); + schedulePreview(); + maybeDeriveOnParamChange(); + }) + ); + } + + function rebuildVizcfg() { + mount(vizcfgBox, buildVizcfgEditor(p, lastSample, schedulePreview)); + refreshAutoBtn(); + } + + /* The panel-header echo: "Panel N · <title or read> (<server param>)" so a long list of panels is scannable. */ + function refreshHead() { + const label = p.title || p.read || ""; + const server = serverParamValue(p, catalog); + let text = ""; + if (label) text += " · " + label; + if (server) text += " (" + server + ")"; + headSub.textContent = text; + } + + /* Progressive disclosure (M5) + two-column layout (M2): the identity row is always shown; Parameters appears + once a read is chosen and Fields + the live Preview once a viz is chosen. With a preview present the body is a + two-column grid (config left, sticky preview right) that collapses to stacked under ~900px. */ + function rebuildBody() { + const config = [identityGrid]; + if (!p.read) { + config.push(el("div", { class: "panel-hint", text: "Choose a read to begin." })); + } else { + config.push(paramsSection); + if (p.viz) { + config.push(fieldsSection); + } else { + config.push(el("div", { class: "panel-hint", text: "Choose a visualization to configure its fields and preview." })); + } + } + const hasPreview = !!(p.read && p.viz); + const kids = [el("div", { class: "panel-config" }, config)]; + if (hasPreview) kids.push(previewSection); + bodyBox.className = "panel-editor-body" + (hasPreview ? " has-preview" : ""); + mount(bodyBox, kids); + } + + /* If the operator finishes a previously-missing required param and there is no vizcfg yet, derive one. */ + async function maybeDeriveOnParamChange() { + if (!p.read || hasVizcfg(p)) return; + if (missingRequired(p, catalog).length) return; + await refreshSampleAndDerive({ resetViz: !p.viz }); + rebuildBody(); + } + + /* Fetch one live sample with the current params and (re)seed the vizcfg from it. Skips when a required param + is unfilled (nothing to fetch yet). `force` re-derives even when a config already exists (the button). */ + async function refreshSampleAndDerive({ resetViz, force }) { + if (!p.read) return; + if (missingRequired(p, catalog).length) { + lastSample = null; + return; + } + const res = await readTool(p.read, cleanParams(p.params)); + if (res.kind !== "data" || !res.data) { + lastSample = null; + if (force) mount(vizcfgBox, el("div", { class: "muted", text: sampleUnavailableText(res) })); + return; + } + lastSample = res.data; + if (resetViz || !p.viz) { + p.viz = derive.suggestViz(lastSample); + vizSelect.value = p.viz; + } + if (force || !hasVizcfg(p)) { + p.vizcfg = derive.deriveVizConfig(lastSample, p.viz, SERIES_COLORS); + } + rebuildVizcfg(); + ctx.refreshSaveState(); + } + + /* Prime a sample for an EXISTING panel (populates the field dropdowns) WITHOUT clobbering its saved config. */ + async function primeSample() { + if (!p.read || missingRequired(p, catalog).length) return; + const res = await readTool(p.read, cleanParams(p.params)); + if (res.kind === "data" && res.data) { + lastSample = res.data; + rebuildVizcfg(); + } + } + + function schedulePreview() { + clearTimeout(previewTimer); + previewTimer = setTimeout(renderPreview, PREVIEW_DEBOUNCE_MS); + } + + function renderPreview() { + const problem = previewBlocker(p, catalog); + if (problem) { + mount(previewBox, el("div", { class: "strip empty", text: problem })); + return; + } + mount(previewBox, renderPanel(panelToDesc(p))); + } + + rebuildParams(); + rebuildVizcfg(); + refreshHead(); + rebuildBody(); + primeSample(); + schedulePreview(); + + return el("div", { class: "panel-editor card" }, [ + el("div", { class: "panel-editor-head" }, [ + el("span", { class: "panel-editor-title", text: "Panel " + (index + 1) }), + headSub, + el("div", { class: "spacer" }), + kindToggle, + upBtn, + downBtn, + removeBtn, + ]), + bodyBox, + ]); +} + +/** The reason a panel can't PREVIEW (missing read/viz/required param), or null when it can render. */ +function previewBlocker(p, catalog) { + if (!p.read) return "Choose a read to preview this panel."; + if (!p.viz) return "Choose a visualization to preview this panel."; + const missing = missingRequired(p, catalog); + if (missing.length) { + return "Fill required parameter" + (missing.length > 1 ? "s" : "") + ": " + missing.join(", ") + "."; + } + return null; +} + +function sampleUnavailableText(res) { + if (res.kind === "empty") return "No sample rows in this window yet — add fields manually or adjust the parameters."; + if (res.kind === "error") return "Could not fetch a sample (" + res.message + ") — add fields manually."; + return "No sample data — add fields manually."; +} + +/* ─────────────────────────── composed (v2) panel editor ─────────────────────────── */ + +/* Display maps for the compose vocabularies (data-driven off /api/catalog; these only humanize the wire tokens). */ +const VIZ_LABELS = { line: "Line", area: "Area", stacked: "Stacked area", "stacked-bar": "Stacked bar", bar: "Bar", pie: "Pie / donut", table: "Table", stat: "Single value" }; +const AGG_LABELS = { sum: "Sum", avg: "Average", min: "Minimum", max: "Maximum", count: "Count", percentile_cont: "95th percentile" }; +const OP_LABELS = { eq: "is (=)", neq: "is not (≠)", like: "matches (LIKE)", gt: ">", gte: "≥", lt: "<", lte: "≤" }; +const BUCKET_LABELS = { minute: "Per minute", hour: "Per hour", day: "Per day" }; +const ARCHETYPE_LABELS = { Cumulative: "counter", Delta: "per-interval delta", Gauge: "gauge", PerEvent: "per-event" }; +const SHAPE_LABELS = { timeseries: "Over time", ranked: "Ranked", scalar: "Single value" }; + +/* + * The v2 metric composer for one panel: pick a measure (or ratio), an aggregate, a SHAPE (over time / ranked / + * single value ⇒ the timeBucket|topN mode), optional group-by + filters, then a chart coherent with that shape. + * The live preview POSTs the composed spec to /api/compose/run (through compose.js) against the view's preview + * scope. Progressive disclosure: only the metric picker shows until a measure is chosen; unit / per-panel time / + * the percentile note live under an Advanced disclosure. The whole config re-renders on a structural change + * (measure/aggregate/shape/group/viz) so dependent controls stay coherent; free-text edits update in place. + */ +function buildComposedPanelEditor(p, index, ctx, kindToggle) { + const { catalog, model } = ctx; + const headSub = el("span", { class: "panel-editor-sub" }); + + const upBtn = el("button", { class: "btn small icon", type: "button", text: "↑", title: "Move up", "aria-label": "Move panel up" }); + const downBtn = el("button", { class: "btn small icon", type: "button", text: "↓", title: "Move down", "aria-label": "Move panel down" }); + const removeBtn = el("button", { class: "btn small danger", type: "button", text: "Remove", "aria-label": "Remove panel" }); + upBtn.disabled = index === 0; + downBtn.disabled = index === model.panels.length - 1; + upBtn.addEventListener("click", () => { + swap(model.panels, index, index - 1); + ctx.rebuildPanels(); + }); + downBtn.addEventListener("click", () => { + swap(model.panels, index, index + 1); + ctx.rebuildPanels(); + }); + removeBtn.addEventListener("click", () => { + model.panels.splice(index, 1); + if (!model.panels.length) model.panels.push(newPanel()); + ctx.rebuildPanels(); + }); + + function refreshHead() { + const m = composedMeasure(p, catalog); + const label = p.title || (m ? m.displayName : ""); + headSub.textContent = label ? " · " + label : ""; + } + + /* The dashboard wraps the shared composer body in a panel head (kind toggle + reorder/remove). The body owns its + own config rebuild + debounced preview; onChange only refreshes the head echo + the view-level save state. */ + const body = buildComposedPanelBody(p, { + catalog, + getVariables: () => ctx.model.variables, + getScopeServer: () => scopeServerPlatform(ctx.model.variables, ctx.fleet), + previewScope: ctx.previewScope, + onChange: () => { + refreshHead(); + ctx.refreshSaveState(); + }, + showWidth: true, + }); + + refreshHead(); + + return el("div", { class: "panel-editor card" }, [ + el("div", { class: "panel-editor-head" }, [ + el("span", { class: "panel-editor-title", text: "Panel " + (index + 1) }), + headSub, + el("div", { class: "spacer" }), + kindToggle, + upBtn, + downBtn, + removeBtn, + ]), + body, + ]); +} + +/** + * The composed (v2) panel COMPOSER BODY — the config form (metric / shape / group / filters / chart / advanced / + * identity) plus the live WYSIWYG preview — extracted from buildComposedPanelEditor (#1563 D7) so it is shared + * VERBATIM by BOTH the dashboard composer (wrapped in a panel head with a kind toggle + reorder/remove) and the + * notebook composer (wrapped in a cell head). Returns the `.panel-editor-body` node; the caller owns the head. + * + * opts: + * catalog — the compose catalog (compose = catalog.compose). + * getVariables — () => the view/notebook's declared template variables (for the filter "$name" hints). + * getScopeServer— () => the concrete fleet card the view's $server resolves to (D4 per-server greying), or null + * for All / multi / unprobed / unknown; omitted ⇒ never grey (only the Wave-1 badges show). + * previewScope — () => the scope the live preview runs against (whole fleet + default range + variable defaults). + * onChange — () => notify the wrapper a field changed (refresh its head echo + save state); the body owns + * its own config rebuild + debounced preview, so onChange never rebuilds anything itself. + * showWidth — show the Width (span 1|2) control; false for a notebook cell (single-column document flow). + */ +export function buildComposedPanelBody(p, opts) { + const catalog = opts.catalog; + const compose = catalog.compose || {}; + const getVariables = opts.getVariables || (() => []); + const getScopeServer = opts.getScopeServer || (() => null); + const previewScope = opts.previewScope || (() => ({})); + const onChange = opts.onChange || (() => {}); + const showWidth = opts.showWidth !== false; + let previewTimer = null; + + const bodyBox = el("div", { class: "panel-editor-body" }); + const previewBox = el("div", { class: "panel-preview" }); + const previewSection = labeledBlock("Preview", previewBox); + previewSection.classList.add("panel-preview-col"); + + function schedulePreview() { + clearTimeout(previewTimer); + previewTimer = setTimeout(renderComposedPreview, PREVIEW_DEBOUNCE_MS); + } + + function renderComposedPreview() { + const problem = composedPreviewBlocker(p, catalog); + if (problem) { + mount(previewBox, el("div", { class: "strip empty", text: problem })); + return; + } + /* Render the exact saved card (title + framing) against the preview scope — WYSIWYG. */ + mount(previewBox, renderComposedPanelCard(composedPanelToDesc(p), previewScope())); + } + + /* A structural change (measure/aggregate/shape/group/viz) may reveal or retune dependent controls — re-render + the config. A free-text / live change (title/unit/topN/bucket/filters/time) only refreshes save + preview. */ + function onStructural() { + onChange(); + rebuildBody(); + schedulePreview(); + } + function onLive() { + onChange(); + schedulePreview(); + } + + /* ── config sub-builders (fresh nodes each rebuild, read from the model) ── */ + + function measureBlock(m) { + /* D4: the concrete fleet server the view's $server resolves to (or null for All / multi / unprobed / unknown) — + read live each rebuild so re-scoping re-greys the picker. */ + const scopeServer = getScopeServer(); + const sel = buildComposedMeasureSelect(compose, currentMeasureKey(p), scopeServer, (key) => { + applyMeasureChoice(p, key, compose); + onStructural(); + }); + return el("div", {}, [ + sel, + m ? el("div", { class: "measure-caption", text: measureCaption(m, compose) }) : null, + m ? measureAvailabilityNote(m, scopeServer) : null, + ]); + } + + function shapeAndAggBlock(m) { + const kids = []; + if (m.kind === "ratio") { + kids.push(field("Aggregation", el("div", { class: "static-field muted", text: "Ratio (numerator ÷ denominator)" }))); + } else { + kids.push(field("Aggregate", aggregateSelect(m))); + } + kids.push(field("Shape", shapeSegmented())); + if (p.shape === "timeseries") kids.push(field("Bucket", bucketSelect())); + else if (p.shape === "ranked") kids.push(field("Top N", topNInput())); + return el("div", { class: "composed-fields" }, kids); + } + + function aggregateSelect(m) { + if (!p.aggregate) p.aggregate = m.defaultAggregate || (m.validAggregates || [])[0] || ""; + const sel = el("select", { class: "editor-select", "aria-label": "Aggregate" }); + for (const a of m.validAggregates || []) sel.appendChild(el("option", { value: a, text: AGG_LABELS[a] || a })); + sel.value = p.aggregate || ""; + sel.addEventListener("change", () => { + p.aggregate = sel.value; + if (p.aggregate === "count") p.unit = "count"; + else if (p.unit === "count") p.unit = m.defaultUnit || ""; + onStructural(); + }); + return sel; + } + + function shapeSegmented() { + const seg = el("div", { class: "seg-control", role: "group", "aria-label": "Panel shape" }); + for (const v of ["timeseries", "ranked", "scalar"]) { + const active = p.shape === v; + const btn = el("button", { class: "seg-opt" + (active ? " active" : ""), type: "button", text: SHAPE_LABELS[v], "aria-pressed": active ? "true" : "false" }); + btn.addEventListener("click", () => { + if (p.shape === v) return; + p.shape = v; + if (v === "timeseries" && !p.timeBucket) p.timeBucket = "hour"; + if (v === "ranked" && !p.topN) p.topN = 10; + if (v === "scalar") p.groupBy = []; + if (!vizShapeCompatible(p.viz, p.shape)) p.viz = DEFAULT_VIZ_FOR_SHAPE[p.shape]; + onStructural(); + }); + seg.appendChild(btn); + } + return seg; + } + + function bucketSelect() { + const sel = el("select", { class: "editor-select", "aria-label": "Time bucket" }); + for (const b of (compose.timeBuckets || []).filter((x) => x !== "none")) { + sel.appendChild(el("option", { value: b, text: BUCKET_LABELS[b] || b })); + } + sel.value = p.timeBucket || "hour"; + sel.addEventListener("change", () => { + p.timeBucket = sel.value; + onLive(); + }); + return sel; + } + + function topNInput() { + const inp = el("input", { class: "editor-input", type: "number", step: "1", min: "1", max: "1000", "aria-label": "Top N" }); + inp.value = String(p.topN || 10); + inp.addEventListener("input", () => { + p.topN = clampInt(inp.value, 1, 1000, 10); + onLive(); + }); + return inp; + } + + function groupByBlock(m) { + const dims = dimNamesForMeasure(m, compose); + const disabled = p.shape === "scalar"; + const chips = dims.map((d) => { + const on = (p.groupBy || []).includes(d); + const btn = el("button", { class: "chip" + (on ? " on" : ""), type: "button", text: dimLabel(d), "aria-pressed": on ? "true" : "false" }); + btn.disabled = disabled; + btn.addEventListener("click", () => { + const idx = p.groupBy.indexOf(d); + if (idx >= 0) p.groupBy.splice(idx, 1); + else p.groupBy.push(d); + onStructural(); + }); + return btn; + }); + const row = el("div", { class: "chip-row" }, chips.length ? chips : [el("span", { class: "muted", text: "This measure has no group-by dimensions." })]); + const hint = disabled + ? el("div", { class: "block-help", text: "A single value can't group. Switch to Over time or Ranked to group." }) + : databaseGrainHint(m, compose); + return el("div", {}, [row, hint]); + } + + function filtersBlock(m) { + const editor = buildComposedFiltersEditor(p, m, compose, onLive); + const declared = (getVariables() || []).filter((v) => v.name).map((v) => "$" + v.name); + const help = declared.length + ? el("div", { class: "block-help", text: "Values are literals (comma-separated for is/is-not); reference a view variable as " + declared.join(", ") + "." }) + : el("div", { class: "block-help", text: "Values are literals (comma-separated for is/is-not). Declare a view variable to filter by $name." }); + return el("div", {}, [editor, help]); + } + + function chartBlock(m) { + const sel = buildComposedVizSelect(compose, p.shape, p.viz, (v) => { + p.viz = v; + onStructural(); + }); + const reason = vizModeReason(p.viz, p.shape, (p.groupBy || []).length); + return el("div", {}, [field("Chart type", sel), reason ? el("div", { class: "block-help warn-hint", text: reason }) : null]); + } + + function advancedBlock(m) { + const rows = [field("Unit / magnitude", unitControl(m)), field("Time range", timeOverrideControl())]; + if (vizDrawsThresholds(p.viz)) rows.push(field("Reference lines", thresholdsControl(m))); + /* Event markers (design D5) overlay only on a time-series panel — the run endpoint rejects them otherwise. */ + if (p.shape === "timeseries") rows.push(field("Event markers", annotationsControl())); + if (p.aggregate === "percentile_cont") rows.push(el("div", { class: "block-help", text: "Percentile is fixed at p95." })); + return el("details", { class: "composed-advanced" }, [el("summary", { text: "Advanced" }), el("div", { class: "cfg" }, rows)]); + } + + /* Up to MAX_ANNOTATIONS event-annotation sources (design D5) to overlay as vertical markers on a time chart, a + multi-select of chips off the catalog's annotationSources (data-driven — never a hardcoded list). Toggling a + chip is a LIVE change (a local redraw so the Advanced section stays open + keeps its scroll), never a structural + rebuild. A constrained source (appliesTo, D4) carries its availability as the chip's hover title. */ + function annotationsControl() { + if (!Array.isArray(p.annotations)) p.annotations = []; + const sources = compose.annotationSources || []; + if (!sources.length) { + return el("div", { class: "static-field muted", text: "No annotation sources are available." }); + } + const box = el("div", { class: "annotations-picker" }); + + function redraw() { + const chips = sources.map((a) => { + const on = p.annotations.includes(a.key); + const avail = annotationAvailabilitySummary(a); + const btn = el("button", { class: "chip" + (on ? " on" : ""), type: "button", text: a.displayName, "aria-pressed": on ? "true" : "false", title: avail || null }); + btn.disabled = !on && p.annotations.length >= MAX_ANNOTATIONS; + btn.addEventListener("click", () => { + const idx = p.annotations.indexOf(a.key); + if (idx >= 0) p.annotations.splice(idx, 1); + else if (p.annotations.length < MAX_ANNOTATIONS) p.annotations.push(a.key); + redraw(); + onLive(); + }); + return btn; + }); + mount(box, el("div", { class: "chip-row" }, chips)); + } + + redraw(); + const help = el("div", { + class: "block-help", + text: "Overlay event markers (deadlocks, blocked-process reports, …) as vertical lines on this time chart. Up to " + MAX_ANNOTATIONS + " sources.", + }); + return el("div", {}, [box, help]); + } + + /* Up to 4 render-only threshold reference lines (design D3), in the panel's unit. A value edit / add / remove is a + LIVE change (its own local redraw so the number inputs keep focus) — never a structural rebuild. Blank/invalid + inputs are stored as null and dropped by cleanThresholds on save + preview, so a half-typed value never renders. */ + function thresholdsControl(m) { + if (!Array.isArray(p.thresholds)) p.thresholds = []; + const list = p.thresholds; + const box = el("div", { class: "item-list thresholds" }); + + function redraw() { + const rows = list.map((val, i) => { + const inp = el("input", { class: "editor-input", type: "number", step: "any", "aria-label": "reference line value " + (i + 1) }); + inp.value = val == null ? "" : String(val); + inp.addEventListener("input", () => { + const n = parseFloat(inp.value); + list[i] = inp.value.trim() === "" || isNaN(n) ? null : n; + onLive(); + }); + const rm = el("button", { class: "btn small danger icon", type: "button", text: "×", title: "Remove reference line", "aria-label": "remove reference line" }); + rm.addEventListener("click", () => { + list.splice(i, 1); + redraw(); + onLive(); + }); + return el("div", { class: "item-row" }, [inp, rm]); + }); + const addBtn = el("button", { class: "btn small", type: "button", text: "+ Add reference line" }); + addBtn.disabled = list.length >= 4; + addBtn.addEventListener("click", () => { + list.push(null); + redraw(); + onLive(); + }); + mount(box, [...rows, addBtn]); + } + + redraw(); + const unitText = p.aggregate === "count" ? "count" : p.unit || m.defaultUnit || ""; + const help = el("div", { + class: "block-help", + text: unitText + ? "Dashed guide lines drawn at these values (in " + unitText + "). Up to 4; values outside the chart's range are skipped." + : "Dashed guide lines drawn at these values. Up to 4; values outside the chart's range are skipped.", + }); + return el("div", {}, [box, help]); + } + + function unitControl(m) { + if (p.aggregate === "count") return el("div", { class: "static-field muted", text: "Count is unitless." }); + const fam = (compose.unitFamilies || []).find((f) => f.name === m.unitFamily); + const units = fam && fam.units.length ? fam.units.map((u) => u.name) : [m.defaultUnit]; + if (!p.unit || !units.includes(p.unit)) p.unit = m.defaultUnit; + const sel = el("select", { class: "editor-select", "aria-label": "Unit" }); + for (const u of units) sel.appendChild(el("option", { value: u, text: u })); + sel.value = p.unit; + sel.addEventListener("change", () => { + p.unit = sel.value; + onLive(); + }); + return sel; + } + + function timeOverrideControl() { + const sel = el("select", { class: "editor-select", "aria-label": "Per-panel time range" }); + sel.appendChild(el("option", { value: "", text: "Use view default" })); + for (const r of RANGE_OPTIONS) sel.appendChild(el("option", { value: String(r.hours), text: r.label })); + sel.value = p.hours != null ? String(p.hours) : ""; + sel.addEventListener("change", () => { + p.hours = sel.value ? parseInt(sel.value, 10) : null; + onLive(); + }); + return sel; + } + + function identityBlock() { + const titleInput = el("input", { class: "editor-input", type: "text", placeholder: "Panel title", "aria-label": "Panel title" }); + titleInput.value = p.title || ""; + titleInput.addEventListener("input", () => { + p.title = titleInput.value; + onChange(); + }); + const fields = [field("Title", titleInput, "field-title")]; + if (showWidth) { + const spanSelect = el("select", { class: "editor-select", "aria-label": "Panel width" }, [ + el("option", { value: "1", text: "1 column" }), + el("option", { value: "2", text: "2 columns (wide)" }), + ]); + spanSelect.value = String(p.span || 1); + spanSelect.addEventListener("change", () => { + p.span = spanSelect.value === "2" ? 2 : 1; + }); + fields.push(field("Width", spanSelect)); + } + return el("div", { class: "composed-fields" }, fields); + } + + function rebuildBody() { + const m = composedMeasure(p, catalog); + const config = [labeledBlock("Metric", measureBlock(m))]; + if (!m) { + config.push(el("div", { class: "panel-hint", text: "Choose a metric to begin." })); + } else { + config.push(shapeAndAggBlock(m)); + config.push(labeledBlock("Group by", groupByBlock(m))); + config.push(labeledBlock("Filters", filtersBlock(m))); + config.push(labeledBlock("Chart", chartBlock(m))); + config.push(advancedBlock(m)); + config.push(identityBlock()); + } + const hasPreview = !!(m && p.viz); + const kids = [el("div", { class: "panel-config" }, config)]; + if (hasPreview) kids.push(previewSection); + bodyBox.className = "panel-editor-body" + (hasPreview ? " has-preview" : ""); + mount(bodyBox, kids); + } + + rebuildBody(); + schedulePreview(); + return bodyBox; +} + +/** The reason a composed panel can't PREVIEW yet (missing measure/aggregate/chart); subtler run errors surface in + * the preview body itself (the run endpoint is the authority). */ +function composedPreviewBlocker(p, catalog) { + const m = composedMeasure(p, catalog); + if (!m) return "Choose a metric to preview this panel."; + if (m.kind !== "ratio" && !p.aggregate) return "Choose an aggregate to preview this panel."; + if (!p.viz) return "Choose a chart type to preview this panel."; + /* Catch an incoherent shape/chart/group combo here so the preview shows a clean hint, not a red run error. */ + return vizModeReason(p.viz, p.shape, (p.groupBy || []).length); +} + +/* The filters sub-editor (its own redraw, so a dimension change can retune the operator list — LIKE only shows on a + LIKE-able dimension). A value edit updates the model + preview in place (no redraw, so the text input keeps focus). */ +function buildComposedFiltersEditor(p, measure, compose, onLive) { + const box = el("div", { class: "item-list filters" }); + const dims = dimNamesForMeasure(measure, compose); + + function redraw() { + const rows = (p.filters || []).map((f, i) => filterRow(f, i)); + const addBtn = el("button", { class: "btn small", type: "button", text: "+ Add filter" }); + addBtn.disabled = !dims.length; + addBtn.addEventListener("click", () => { + p.filters.push({ dimension: dims[0] || "", op: "eq", value: "" }); + redraw(); + onLive(); + }); + mount(box, [...rows, addBtn]); + } + + function filterRow(f, i) { + const dimSel = el("select", { class: "editor-select", "aria-label": "filter dimension" }); + for (const d of dims) dimSel.appendChild(el("option", { value: d, text: dimLabel(d) })); + dimSel.value = f.dimension || dims[0] || ""; + dimSel.addEventListener("change", () => { + f.dimension = dimSel.value; + if (f.op === "like" && !dimMeta(compose, measure.source, f.dimension).likeable) f.op = "eq"; + redraw(); + onLive(); + }); + + const likeable = dimMeta(compose, measure.source, f.dimension || dims[0]).likeable; + const opSel = el("select", { class: "editor-select", "aria-label": "filter operator" }); + for (const op of compose.filterOps || []) { + if (op === "like" && !likeable) continue; + opSel.appendChild(el("option", { value: op, text: OP_LABELS[op] || op })); + } + opSel.value = f.op || "eq"; + opSel.addEventListener("change", () => { + f.op = opSel.value; + onLive(); + }); + + const valInput = el("input", { class: "editor-input", type: "text", "aria-label": "filter value", placeholder: valuePlaceholder(f.op) }); + valInput.value = f.value || ""; + valInput.addEventListener("input", () => { + f.value = valInput.value; + onLive(); + }); + + const rm = el("button", { class: "btn small danger icon", type: "button", text: "×", title: "Remove filter", "aria-label": "remove filter" }); + rm.addEventListener("click", () => { + p.filters.splice(i, 1); + redraw(); + onLive(); + }); + + return el("div", { class: "item-row" }, [dimSel, opSel, valInput, rm]); + } + + redraw(); + return box; +} + +function valuePlaceholder(op) { + if (op === "eq" || op === "neq") return "value, value, … or $variable"; + if (op === "like") return "pattern with % wildcards"; + return "threshold or $variable"; +} + +/* ── composed pure helpers ── */ + +/** The measure object a composed panel references (by its measure or ratio key), or null. */ +function composedMeasure(p, catalog) { + const key = p.ratio || p.measure; + if (!key) return null; + return ((catalog.compose || {}).measures || []).find((m) => m.key === key) || null; +} + +function currentMeasureKey(p) { + return p.ratio || p.measure || ""; +} + +/* Apply a measure choice to the panel model: set the source, measure XOR ratio, a default aggregate + unit, and + drop any group-by / filter dimension the new measure doesn't allow (so a measure swap never leaves an off-catalog + dimension behind). A viz that is now shape-incompatible falls back to the shape's default. */ +function applyMeasureChoice(p, key, compose) { + const m = (compose.measures || []).find((x) => x.key === key); + if (!m) { + p.measure = ""; + p.ratio = ""; + p.source = ""; + return; + } + p.source = m.source; + if (m.kind === "ratio") { + p.ratio = m.key; + p.measure = ""; + p.aggregate = ""; + } else { + p.measure = m.key; + p.ratio = ""; + p.aggregate = m.defaultAggregate || (m.validAggregates || [])[0] || ""; + } + p.unit = p.aggregate === "count" ? "count" : m.defaultUnit || ""; + const allowed = new Set(dimNamesForMeasure(m, compose)); + p.groupBy = (p.groupBy || []).filter((d) => allowed.has(d)); + p.filters = (p.filters || []).filter((f) => !f.dimension || allowed.has(f.dimension)); + if (!p.viz || !vizShapeCompatible(p.viz, p.shape)) p.viz = DEFAULT_VIZ_FOR_SHAPE[p.shape] || "line"; +} + +/** The dimensions a measure can group/filter by: the universal `server` (fleet axis) plus its allowed dimensions. */ +function dimNamesForMeasure(m, compose) { + const names = [...((compose && compose.universalDimensions) || []), ...((m && m.allowedDimensions) || [])]; + return [...new Set(names)]; +} + +/** A dimension's metadata for a source ({likeable}); the virtual `server` dimension is exact-match only. */ +function dimMeta(compose, source, name) { + if (name === "server") return { name, likeable: false }; + const d = ((compose && compose.dimensions) || []).find((x) => x.source === source && x.name === name); + return d ? { name, likeable: !!d.likeable } : { name, likeable: false }; +} + +/** A friendly label for a dimension name (the fleet axis is called out; the rest are humanized). */ +function dimLabel(d) { + return d === "server" ? "server (fleet)" : derive.humanizeKey(d); +} + +/** The self-describing measure caption: category · archetype · group-by dimensions. */ +function measureCaption(m, compose) { + const dims = dimNamesForMeasure(m, compose); + const grain = m.kind === "ratio" ? "ratio" : ARCHETYPE_LABELS[m.archetype] || m.archetype; + const dimText = dims.length ? dims.map(dimLabel).join(", ") : "server only"; + return m.category + " · " + grain + " · groups by " + dimText; +} + +/* The per-server-type constraints on an appliesTo gate (design D4), shared by measures AND annotation sources (both + carry the identical appliesTo node — the same BuildAppliesToNode on the server). Returns null when it collects + everywhere with no msdb dependency (the common case — no badge). `missing` names the platforms it can't collect on; + `needsMsdb` flags a SQL-Agent (job) dependency. These drive the always-on informational BADGES; when the view's + $server resolves to one concrete server (scopeServerPlatform) the measure picker layers a stronger per-server GREY + on top (measureUnavailableOn). AWS RDS + msdb have no reliable per-server signal, so they stay badges only. */ +function availabilityConstraints(a) { + if (!a) return null; + const missing = []; + if (a.azureSqlDb === false) missing.push("Azure SQL DB"); + if (a.azureMi === false) missing.push("Azure MI"); + if (a.awsRds === false) missing.push("AWS RDS"); + if (a.onPrem === false) missing.push("on-prem"); + const needsMsdb = a.needsMsdb === true; + if (!missing.length && !needsMsdb) return null; + return { missing, needsMsdb }; +} + +/** A compact one-line availability summary from constraints (a picker option/chip's hover title), or "". */ +function availabilitySummary(c) { + if (!c) return ""; + const parts = []; + if (c.missing.length) parts.push("not on " + c.missing.join(", ")); + if (c.needsMsdb) parts.push("needs SQL Agent (msdb)"); + return parts.join("; "); +} + +function measureAvailabilityConstraints(m) { + return availabilityConstraints(m && m.appliesTo); +} + +/** A compact one-line availability summary for a measure (the picker option's hover title), or "" when unconstrained. */ +function measureAvailabilitySummary(m) { + return availabilitySummary(measureAvailabilityConstraints(m)); +} + +/** A compact one-line availability summary for an annotation source (the composer chip's hover title, design D5). */ +function annotationAvailabilitySummary(a) { + return availabilitySummary(availabilityConstraints(a && a.appliesTo)); +} + +/* ── D4 per-server auto-greying ── */ + +/* The view-scope $server signal: when the view's server-dimension template variable resolves to exactly ONE concrete, + platform-probed fleet server, return that enriched fleet option so the measure picker can GREY the measures it + can't collect. Returns null for every "no reliable signal" case — no $server variable (or more than one carrying a + value), a blank / "All" / multi (comma/semicolon) / $-referencing default, an unknown server name, or a server + whose engine edition hasn't been probed (null/0) — so the picker falls back to the always-on Wave-1 badges. + `variables` is the view/notebook model's declared template variables; `fleet` is loadFleetOptions' enriched output. */ +export function scopeServerPlatform(variables, fleet) { + const serverVars = (variables || []).filter((v) => v && v.dimension === "server" && (v.default || "").trim()); + if (serverVars.length !== 1) return null; + const raw = serverVars[0].default.trim(); + if (raw.charAt(0) === "$") return null; // references another variable — not a concrete server + if (raw.toLowerCase() === "all") return null; // explicit whole-fleet + if (/[,;]/.test(raw)) return null; // a multi-server list + const card = (fleet || []).find((c) => c.value === raw || c.label === raw); + if (!card || !(card.engineEdition > 0)) return null; // unknown, or not-yet-probed (null/0) = no signal + return card; +} + +/* The platform label for a probed fleet card, used in the D4 grey reason: the two Azure platforms are named; every + box / RDS / on-prem edition collapses to "this server" (no reliable per-server RDS signal to distinguish them). */ +function scopeServerLabel(card) { + if (card && card.isAzureSqlDb) return "Azure SQL DB"; + if (card && card.isAzureMi) return "Azure MI"; + return "this server"; +} + +/* The platform a measure/annotation CANNOT collect on given the scoped fleet card (the D4 grey verdict), or null when + it can. Only the three signals /api/fleet reliably exposes drive it — is_azure_sql_db, is_azure_mi, else the + box/RDS/on-prem gate (engine_edition non-null and not 5/8, so appliesTo.onPrem); AWS RDS + msdb have no per-server + signal so they never grey here (they stay Wave-1 badges). Defensive against an unprobed card (returns null). */ +function measureUnavailableOn(appliesTo, card) { + if (!appliesTo || !card || !(card.engineEdition > 0)) return null; + const gate = card.isAzureSqlDb ? appliesTo.azureSqlDb : card.isAzureMi ? appliesTo.azureMi : appliesTo.onPrem; + return gate === false ? scopeServerLabel(card) : null; +} + +/** The availability line under the measure picker (design D4), or null. Always-on informational badges come from the + * measure's appliesTo; when the view's $server resolves to one concrete server the measure can't collect on, a + * stronger per-server GREY badge REPLACES the generic "Not on <platforms>" one (same fact, server-specific) while + * the orthogonal msdb note stays. `scopeServer` is the resolved fleet card, or null (⇒ Wave-1 badges unchanged). */ +function measureAvailabilityNote(m, scopeServer) { + const platform = measureUnavailableOn(m.appliesTo, scopeServer); + const c = measureAvailabilityConstraints(m); + const badges = []; + if (platform) { + badges.push(el("span", { class: "measure-badge grey", text: "Not collected on " + platform })); + if (c && c.needsMsdb) badges.push(el("span", { class: "measure-badge", text: "Needs SQL Agent (msdb)" })); + } else if (c) { + if (c.missing.length) badges.push(el("span", { class: "measure-badge warn", text: "Not on " + c.missing.join(", ") })); + if (c.needsMsdb) badges.push(el("span", { class: "measure-badge", text: "Needs SQL Agent (msdb)" })); + } + return badges.length ? el("div", { class: "measure-badges" }, badges) : null; +} + +/** Whether a viz is compatible with a shape ignoring the group-by requirement (table works with any shape). */ +function vizShapeCompatible(viz, shape) { + if (viz === "table") return true; + if (shape === "timeseries") return viz === "line" || viz === "area" || viz === "stacked" || viz === "stacked-bar"; + if (shape === "ranked") return viz === "bar" || viz === "pie"; + return viz === "stat"; +} + +/** Where a viz belongs (for a disabled option's reason on the wrong shape). */ +function shapeHintFor(viz) { + if (viz === "line" || viz === "area" || viz === "stacked" || viz === "stacked-bar") return "over time"; + if (viz === "bar" || viz === "pie") return "ranked"; + if (viz === "stat") return "single value"; + return ""; +} + +/** Which composed vizzes draw threshold reference lines (design D3): the value-axis charts (line/area/stacked/ + * stacked-bar draw them horizontally, the ranked bar vertically). Pie/stat/table have no value axis to mark. */ +function vizDrawsThresholds(viz) { + return viz === "line" || viz === "area" || viz === "stacked" || viz === "stacked-bar" || viz === "bar"; +} + +/* The full viz↔shape coherence reason (mirrors the server's ValidateVizMode), or null when coherent — drives the + inline hint under the chart picker. */ +function vizModeReason(viz, shape, groupCount) { + if (!viz) return null; + if (viz === "table") return null; + if (shape === "timeseries") { + if (!["line", "area", "stacked", "stacked-bar"].includes(viz)) return "A " + (VIZ_LABELS[viz] || viz) + " chart isn't a time series — use line, area, stacked, or stacked bar."; + if ((viz === "stacked" || viz === "stacked-bar") && groupCount === 0) return "A stacked chart needs a group-by (the parts that stack)."; + return null; + } + if (shape === "ranked") { + if (!["bar", "pie"].includes(viz)) return "A " + (VIZ_LABELS[viz] || viz) + " chart can't rank a top-N — use bar or pie."; + if (groupCount === 0) return "A " + (VIZ_LABELS[viz] || viz) + " chart needs a group-by (the categories to rank)."; + return null; + } + if (groupCount > 0) return "A single value can't group — switch to Over time or Ranked."; + if (viz !== "stat") return "A single value uses the stat chart (or a table)."; + return null; +} + +function buildComposedMeasureSelect(compose, current, scopeServer, onChange) { + const sel = el("select", { class: "editor-select", "aria-label": "Metric" }); + sel.appendChild(el("option", { value: "", text: "— choose a metric —" })); + const byCat = new Map(); + for (const m of compose.measures || []) { + if (!byCat.has(m.category)) byCat.set(m.category, []); + byCat.get(m.category).push(m); + } + for (const [cat, ms] of [...byCat.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { + const group = el("optgroup", { label: cat }); + for (const m of ms.sort((a, b) => a.displayName.localeCompare(b.displayName))) { + const suffix = m.kind === "ratio" ? " (ratio)" : ""; + const caption = measureCaption(m, compose); + /* D4: when the view is scoped to one concrete server this measure can't collect on, grey (disable) the option + — but keep an already-CHOSEN measure selectable so re-scoping never silently drops the panel's metric (the + reason surfaces in the note under the picker). Otherwise fall back to the informational availability title. */ + const platform = measureUnavailableOn(m.appliesTo, scopeServer); + const avail = measureAvailabilitySummary(m); + const label = m.displayName + suffix + (platform ? " — not on " + platform : ""); + const title = platform ? caption + " · not collected on " + platform : avail ? caption + " · " + avail : caption; + const opt = el("option", { value: m.key, text: label, title }); + if (platform && m.key !== current) opt.disabled = true; + group.appendChild(opt); + } + sel.appendChild(group); + } + sel.value = current || ""; + sel.addEventListener("change", () => onChange(sel.value)); + return sel; +} + +/* The chart picker: every compose viz, with the shapes it can't render disabled + annotated ("Bar — ranked"), so + only the coherent charts are selectable for the current shape (design §4 shape-steering). */ +function buildComposedVizSelect(compose, shape, current, onChange) { + const sel = el("select", { class: "editor-select", "aria-label": "Chart type" }); + for (const v of compose.viz || []) { + const compatible = vizShapeCompatible(v, shape); + const label = (VIZ_LABELS[v] || v) + (compatible ? "" : " — " + shapeHintFor(v)); + const opt = el("option", { value: v, text: label }); + if (!compatible) opt.disabled = true; + sel.appendChild(opt); + } + sel.value = current || ""; + sel.addEventListener("change", () => onChange(sel.value)); + return sel; +} + +/* The grain-trap guide (design §5): when a measure has NO per-database breakdown, point to same-category measures + that DO (else cross-category), so "group CPU by database" doesn't dead-end — returns null when the measure IS + per-database or when no alternative exists (no dead-end nag). */ +function databaseGrainHint(measure, compose) { + if (!measure || (measure.allowedDimensions || []).includes("database_name")) return null; + const alts = (compose.measures || []).filter((m) => m.key !== measure.key && (m.allowedDimensions || []).includes("database_name")); + const sameCat = alts.filter((m) => m.category === measure.category); + const pick = (sameCat.length ? sameCat : alts).slice(0, 4); + if (!pick.length) return null; + return el("div", { class: "grain-hint" }, [ + "No per-database breakdown for this measure. For per-database data, try: ", + el("span", { class: "grain-alts", text: pick.map((m) => m.displayName).join(", ") }), + ".", + ]); +} + +/* ─────────────────────────── view scope (variables + default range) ─────────────────────────── */ + +/* The view-level scope editor: the DEFAULT time range (the range the rendered view opens with; the viewer can + change it) and, folded away, the template variables a panel filter can reference as $name. $database is offered + as a one-click add (the common per-database focus). Adding/removing a variable — or changing the range — rebuilds + the panels (so previews re-run and the filter var hints refresh) via onStructuralChange. */ +export function buildViewScopeSection(model, catalog, onStructuralChange) { + const compose = catalog.compose || {}; + const dimNames = variableDimensionNames(compose); + + const rangeSel = el("select", { class: "editor-select", "aria-label": "Default time range" }); + rangeSel.appendChild(el("option", { value: "", text: "Default (last 24 hours)" })); + for (const r of RANGE_OPTIONS) rangeSel.appendChild(el("option", { value: String(r.hours), text: r.label })); + rangeSel.value = model.rangeHours != null ? String(model.rangeHours) : ""; + rangeSel.addEventListener("change", () => { + model.rangeHours = rangeSel.value ? parseInt(rangeSel.value, 10) : null; + onStructuralChange(); + }); + + const varsBox = el("div", { class: "item-list vars" }); + + function redrawVars() { + const rows = model.variables.map((v, i) => varRow(v, i)); + const addDb = el("button", { class: "btn small", type: "button", text: "+ $database" }); + addDb.addEventListener("click", () => addVariable("database", "database_name")); + const addBtn = el("button", { class: "btn small", type: "button", text: "+ Add variable" }); + addBtn.addEventListener("click", () => addVariable("", dimNames[0] || "server")); + mount(varsBox, [...rows, el("div", { class: "vizcfg-actions" }, [addDb, addBtn])]); + } + + function addVariable(name, dimension) { + if (name && model.variables.some((v) => v.name === name)) return; + model.variables.push({ name, dimension, default: "" }); + redrawVars(); + onStructuralChange(); + } + + function varRow(v, i) { + const nameInp = el("input", { class: "editor-input", type: "text", placeholder: "name", "aria-label": "variable name" }); + nameInp.value = v.name || ""; + nameInp.addEventListener("input", () => { + v.name = nameInp.value.replace(/^\$+/, ""); + }); + const dimSel = el("select", { class: "editor-select", "aria-label": "variable dimension" }); + for (const d of dimNames) dimSel.appendChild(el("option", { value: d, text: d === "server" ? "server" : derive.humanizeKey(d) })); + dimSel.value = v.dimension || dimNames[0] || "server"; + v.dimension = dimSel.value; + dimSel.addEventListener("change", () => { + v.dimension = dimSel.value; + onStructuralChange(); // a var becoming / ceasing to be the $server re-greys the measure pickers (D4) + }); + const defInp = el("input", { class: "editor-input", type: "text", placeholder: "default (optional)", "aria-label": "variable default" }); + defInp.value = v.default || ""; + defInp.addEventListener("input", () => { + v.default = defInp.value; + }); + // Commit (blur / Enter) re-greys the measure pickers for the new $server default (D4); not per-keystroke. + defInp.addEventListener("change", () => onStructuralChange()); + const rm = el("button", { class: "btn small danger icon", type: "button", text: "×", title: "Remove variable", "aria-label": "remove variable" }); + rm.addEventListener("click", () => { + model.variables.splice(i, 1); + redrawVars(); + onStructuralChange(); + }); + return el("div", { class: "item-row" }, [el("span", { class: "var-dollar", text: "$" }), nameInp, dimSel, defInp, rm]); + } + + redrawVars(); + + return el("div", { class: "view-scope" }, [ + el("h3", { class: "section-title", text: "View scope" }), + el("div", { class: "view-scope-row" }, [field("Default time range", rangeSel)]), + el("details", { class: "vars-panel" }, [ + el("summary", { text: "Template variables (advanced)" }), + el("div", { class: "vars-body" }, [ + el("div", { + class: "block-help", + text: "Declare a variable to scope panels view-wide: a panel filter can reference it as $name and the viewer picks its value. $database is the common one.", + }), + varsBox, + ]), + ]), + ]); +} + +/** The dimension names a template variable may bind to: every catalog dimension name plus the reserved `server`. */ +function variableDimensionNames(compose) { + const set = new Set(["server"]); + for (const d of compose.dimensions || []) set.add(d.name); + return [...set]; +} + +/* ─────────────────────────── read / viz pickers ─────────────────────────── */ + +function buildReadSelect(catalog, current) { + const sel = el("select", { class: "editor-select", "aria-label": "Read" }); + sel.appendChild(el("option", { value: "", text: "— choose a read —" })); + + const byCategory = new Map(); + for (const r of catalog.reads || []) { + if (!byCategory.has(r.category)) byCategory.set(r.category, []); + byCategory.get(r.category).push(r); + } + for (const [category, reads] of [...byCategory.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { + const group = el("optgroup", { label: category }); + for (const r of reads.sort((a, b) => a.name.localeCompare(b.name))) { + group.appendChild(el("option", { value: r.name, text: r.name, title: r.description || r.name })); + } + sel.appendChild(group); + } + + sel.value = current || ""; + return sel; +} + +function buildVizSelect(catalog, current) { + const sel = el("select", { class: "editor-select", "aria-label": "Visualization" }); + sel.appendChild(el("option", { value: "", text: "— choose —" })); + const vizList = catalog.viz && catalog.viz.length ? catalog.viz : Object.keys(VIZ); + for (const v of vizList) sel.appendChild(el("option", { value: v, text: v })); + sel.value = current || ""; + return sel; +} + +/* ─────────────────────────── typed param inputs ─────────────────────────── */ + +function defaultParams(catalog, read) { + const rd = readByName(catalog, read); + const out = {}; + if (!rd) return out; + for (const prm of rd.params || []) { + if (prm.default !== null && prm.default !== undefined) out[prm.name] = prm.default; + } + return out; +} + +function buildParamInputs(p, catalog, fleet, onChange) { + const rd = readByName(catalog, p.read); + if (!rd || !(rd.params || []).length) { + return el("div", { class: "muted", text: p.read ? "This read takes no parameters." : "Choose a read first." }); + } + return (rd.params || []).map((prm) => { + const input = buildParamInput(prm, p.params[prm.name], fleet, (val) => { + if (val === "" || val == null) delete p.params[prm.name]; + else p.params[prm.name] = val; + onChange(); + }); + return el("label", { class: "param-field" + (prm.required ? " required" : "") }, [ + el("span", { class: "param-name" }, [prm.name, prm.required ? el("span", { class: "req", text: " *" }) : null]), + input, + hintFor(prm), + ]); + }); +} + +function buildParamInput(prm, currentValue, fleet, setValue) { + switch (prm.type) { + case "server": { + const sel = el("select", { class: "editor-select", "aria-label": prm.name }); + sel.appendChild(el("option", { value: "", text: "(auto / all servers)" })); + for (const o of fleet) sel.appendChild(el("option", { value: o.value, text: o.label })); + sel.value = currentValue != null ? String(currentValue) : ""; + sel.addEventListener("change", () => setValue(sel.value)); + return sel; + } + case "bool": { + const box = el("input", { type: "checkbox", class: "editor-check", "aria-label": prm.name }); + box.checked = currentValue != null ? currentValue === true || currentValue === "true" : !!prm.default; + box.addEventListener("change", () => setValue(box.checked)); + return box; + } + case "int": { + const inp = el("input", { + class: "editor-input", + type: "number", + step: "1", + "aria-label": prm.name, + placeholder: prm.default != null ? String(prm.default) : "", + }); + if (currentValue != null) inp.value = String(currentValue); + inp.addEventListener("input", () => setValue(toNumber(inp.value, true))); + return inp; + } + case "double": { + const inp = el("input", { + class: "editor-input", + type: "number", + step: "any", + "aria-label": prm.name, + placeholder: prm.default != null ? String(prm.default) : "", + }); + if (currentValue != null) inp.value = String(currentValue); + inp.addEventListener("input", () => setValue(toNumber(inp.value, false))); + return inp; + } + default: { + const inp = el("input", { class: "editor-input", type: "text", "aria-label": prm.name }); + if (currentValue != null) inp.value = String(currentValue); + inp.addEventListener("input", () => setValue(inp.value)); + return inp; + } + } +} + +function hintFor(prm) { + let t = prm.type; + if (prm.default !== null && prm.default !== undefined) t += " · default " + prm.default; + return el("span", { class: "param-hint", text: t }); +} + +/* ─────────────────────────── vizcfg sub-editors ─────────────────────────── */ + +function buildVizcfgEditor(p, sample, onChange) { + p.vizcfg = p.vizcfg || {}; + switch (p.viz) { + case "table": + return tableCfgEditor(p.vizcfg, sample, onChange); + case "stat": + return statCfgEditor(p.vizcfg, sample, onChange); + case "line": + return lineCfgEditor(p.vizcfg, sample, onChange); + case "bandlist": + return bandlistCfgEditor(p.vizcfg, sample, onChange); + default: + return el("div", { class: "muted", text: "Choose a visualization to configure its fields." }); + } +} + +function tableCfgEditor(cfg, sample, onChange) { + cfg.columns = cfg.columns || []; + const arrayKeys = topLevelArrayKeys(sample); + const rowsField = keyField(arrayKeys, cfg.rowsKey, (v) => { + cfg.rowsKey = v; + onChange(); + }, "rows key"); + const columns = itemList( + "columns", + cfg.columns, + (col) => [ + keyField(rowKeys(sample, cfg.rowsKey), col.key, (v) => { + col.key = v; + onChange(); + }, "column key"), + textField(col.label, (v) => { + col.label = v; + onChange(); + }, "column label"), + formatSelect(col.format, (v) => { + col.format = v; + onChange(); + }), + ], + onChange, + "+ Add column", + () => ({ key: "", label: "", format: "text" }) + ); + return el("div", { class: "cfg" }, [field("Rows key", rowsField), labeledBlock("Columns", columns)]); +} + +function statCfgEditor(cfg, sample, onChange) { + cfg.stats = cfg.stats || []; + const keys = topLevelScalarKeys(sample); + const list = itemList( + "stats", + cfg.stats, + (s) => [ + keyField(keys, s.key, (v) => { + s.key = v; + onChange(); + }, "stat key"), + textField(s.label, (v) => { + s.label = v; + onChange(); + }, "stat label"), + formatSelect(s.format, (v) => { + s.format = v; + onChange(); + }), + ], + onChange, + "+ Add stat", + () => ({ key: "", label: "", format: "text" }) + ); + return el("div", { class: "cfg" }, [labeledBlock("Stats", list)]); +} + +function lineCfgEditor(cfg, sample, onChange) { + cfg.series = cfg.series || []; + const arrayKeys = topLevelArrayKeys(sample); + const numericKeys = rowNumericKeys(sample, cfg.rowsKey); + const rowsField = keyField(arrayKeys, cfg.rowsKey, (v) => { + cfg.rowsKey = v; + onChange(); + }, "rows key"); + const xField = keyField(rowKeys(sample, cfg.rowsKey), cfg.xKey, (v) => { + cfg.xKey = v; + onChange(); + }, "x-axis key"); + const series = itemList( + "series", + cfg.series, + (s) => [ + keyField(numericKeys, s.key, (v) => { + s.key = v; + onChange(); + }, "series key"), + textField(s.label, (v) => { + s.label = v; + onChange(); + }, "series label"), + colorField(s.color, (v) => { + s.color = v; + onChange(); + }), + ], + onChange, + "+ Add series", + () => ({ key: "", label: "", color: SERIES_COLORS[0] }) + ); + const fmt = formatSelect(cfg.format || "text", (v) => { + cfg.format = v === "text" ? undefined : v; + onChange(); + }); + const unit = textField(cfg.unit, (v) => { + cfg.unit = v || undefined; + onChange(); + }, "unit"); + return el("div", { class: "cfg" }, [ + field("Rows key", rowsField), + field("X-axis key", xField), + labeledBlock("Series", series), + field("Value format", fmt), + field("Unit (optional)", unit), + ]); +} + +function bandlistCfgEditor(cfg, sample, onChange) { + const arrayKeys = topLevelArrayKeys(sample); + const rKeys = rowKeys(sample, cfg.rowsKey); + const keyRow = (label, prop, aria) => + field( + label, + keyField(rKeys, cfg[prop], (v) => { + cfg[prop] = v || undefined; + onChange(); + }, aria) + ); + return el("div", { class: "cfg" }, [ + field( + "Rows key", + keyField(arrayKeys, cfg.rowsKey, (v) => { + cfg.rowsKey = v; + onChange(); + }, "rows key") + ), + keyRow("Primary key", "primaryKey", "primary key"), + keyRow("Band key", "bandKey", "band key"), + keyRow("Band label key (optional)", "bandLabelKey", "band label key"), + keyRow("Reason key (optional)", "reasonKey", "reason key"), + keyRow("Nav key — server (optional)", "navKey", "nav key"), + ]); +} + +/* ─────────────────────────── small field builders ─────────────────────────── */ + +let _keyListId = 0; + +/* A key input backed by a <datalist> of the sample's available keys — autocompletes from the sample while still + allowing a hand-typed key (a sample is a subset; the operator may know a key not in the first row). */ +function keyField(keys, current, onChange, ariaLabel) { + const input = el("input", { class: "editor-input mono", type: "text", "aria-label": ariaLabel || "key" }); + if (current != null) input.value = String(current); + input.addEventListener("input", () => onChange(input.value)); + if (keys && keys.length) { + const id = "keys-" + ++_keyListId; + const datalist = el("datalist", { id }); + for (const k of keys) datalist.appendChild(el("option", { value: k })); + input.setAttribute("list", id); + return el("span", { class: "keyfield" }, [input, datalist]); + } + return input; +} + +function textField(current, onChange, ariaLabel) { + const input = el("input", { class: "editor-input", type: "text", "aria-label": ariaLabel || "text" }); + if (current != null) input.value = String(current); + input.addEventListener("input", () => onChange(input.value)); + return input; +} + +function formatSelect(current, onChange) { + const sel = el("select", { class: "editor-select", "aria-label": "format" }); + for (const f of FORMAT_OPTIONS) sel.appendChild(el("option", { value: f, text: f })); + sel.value = current || "text"; + sel.addEventListener("change", () => onChange(sel.value)); + return sel; +} + +/* Series color is CONSTRAINED (reconciliation #4): an <input type=color> only ever yields #rrggbb, and the + palette buttons are charts.js constants — no free-text ever reaches the chart's style-attribute sink. */ +function colorField(current, onChange) { + const picker = el("input", { type: "color", class: "color-picker", "aria-label": "series color" }); + picker.value = normalizeColor(current); + picker.addEventListener("input", () => onChange(picker.value)); + const palette = el( + "span", + { class: "palette" }, + SERIES_COLORS.map((c) => + el("button", { + type: "button", + class: "swatch-btn", + title: c, + style: "background:" + c, + "aria-label": "use color " + c, + onClick: () => { + picker.value = c; + onChange(c); + }, + }) + ) + ); + return el("span", { class: "color-field" }, [picker, palette]); +} + +/* A remove-able, add-able list of config rows (columns / series / stats). renderRow returns the row's field + nodes; itemList appends the remove button and the add button, redrawing in place on any structural change. */ +function itemList(className, items, renderRow, onChange, addLabel, makeNew) { + const box = el("div", { class: "item-list " + className }); + + function redraw() { + const rows = items.map((item, i) => { + const removeBtn = el("button", { class: "btn small danger icon", type: "button", text: "×", title: "Remove", "aria-label": "remove" }); + removeBtn.addEventListener("click", () => { + items.splice(i, 1); + redraw(); + onChange(); + }); + return el("div", { class: "item-row" }, [...renderRow(item, i), removeBtn]); + }); + const addBtn = el("button", { class: "btn small", type: "button", text: addLabel }); + addBtn.addEventListener("click", () => { + items.push(makeNew()); + redraw(); + onChange(); + }); + mount(box, [...rows, addBtn]); + } + + redraw(); + return box; +} + +export function field(label, control, extraClass) { + return el("label", { class: "editor-field" + (extraClass ? " " + extraClass : "") }, [ + el("span", { class: "field-label", text: label }), + control, + ]); +} + +export function labeledBlock(label, node) { + return el("div", { class: "editor-block" }, [el("div", { class: "block-label", text: label }), node]); +} + +function backHead(id) { + return el("div", { class: "page-head" }, [ + el("a", { href: id != null && id !== "new" ? "#/view/" + encodeURIComponent(id) : "#/views", text: "← Back" }), + el("h2", { text: "Composer" }), + ]); +} + +/* ─────────────────────────── sample-key helpers ─────────────────────────── */ + +function topLevelArrayKeys(sample) { + if (!sample || typeof sample !== "object" || Array.isArray(sample)) return []; + return Object.keys(sample).filter((k) => Array.isArray(sample[k])); +} + +function topLevelScalarKeys(sample) { + if (!sample || typeof sample !== "object" || Array.isArray(sample)) return []; + return Object.keys(sample).filter((k) => sample[k] === null || typeof sample[k] !== "object"); +} + +function rowKeys(sample, rowsKey) { + const first = firstRow(sample, rowsKey); + return first ? Object.keys(first) : []; +} + +function rowNumericKeys(sample, rowsKey) { + const first = firstRow(sample, rowsKey); + return first ? Object.keys(first).filter((k) => typeof first[k] === "number") : []; +} + +function firstRow(sample, rowsKey) { + const rows = sample && rowsKey ? sample[rowsKey] : null; + return Array.isArray(rows) && rows.length && rows[0] && typeof rows[0] === "object" ? rows[0] : null; +} + +/* ─────────────────────────── misc helpers ─────────────────────────── */ + +function readByName(catalog, name) { + return (catalog.reads || []).find((r) => r.name === name) || null; +} + +/* The value of a read's `server`-typed param (if any) — feeds the panel-header echo "Panel N · Title (SERVER)". */ +function serverParamValue(p, catalog) { + const rd = readByName(catalog, p.read); + if (!rd) return ""; + const sp = (rd.params || []).find((prm) => prm.type === "server"); + if (!sp) return ""; + const v = p.params ? p.params[sp.name] : null; + return v == null || v === "" ? "" : String(v); +} + +function missingRequired(panel, catalog) { + const rd = readByName(catalog, panel.read); + if (!rd) return []; + return (rd.params || []) + .filter((prm) => prm.required && isEmpty(panel.params[prm.name])) + .map((prm) => prm.name); +} + +function hasVizcfg(p) { + const c = p.vizcfg || {}; + return !!( + (c.columns && c.columns.length) || + (c.series && c.series.length) || + (c.stats && c.stats.length) || + c.rowsKey || + c.primaryKey + ); +} + +function isEmpty(v) { + return v === null || v === undefined || v === ""; +} + +function toNumber(raw, isInt) { + if (raw === "" || raw == null) return ""; + const n = isInt ? parseInt(raw, 10) : parseFloat(raw); + return Number.isNaN(n) ? "" : n; +} + +/** Parse an int and clamp it into [min, max], falling back to `dflt` on a non-number. */ +function clampInt(raw, min, max, dflt) { + const n = parseInt(raw, 10); + if (Number.isNaN(n)) return dflt; + return Math.max(min, Math.min(max, n)); +} + +function swap(arr, i, j) { + const tmp = arr[i]; + arr[i] = arr[j]; + arr[j] = tmp; +} diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/markdown.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/markdown.js new file mode 100644 index 000000000..225474687 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/markdown.js @@ -0,0 +1,282 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +/* + * markdown.js — a minimal, air-gapped, XSS-safe Markdown -> DOM renderer for notebook markdown cells (#1563 D7). + * + * SECURITY (the load-bearing property): EVERY node is built through util.el() / textContent / text nodes — there is + * NO innerHTML anywhere, and nothing is ever assigned as markup. Raw HTML in the input (a <script> tag, an + * onerror= attribute, a stray angle bracket) is therefore inert BY CONSTRUCTION: it can only ever become the TEXT + * of a node, never a parsed element. Links are the one attribute sink; ONLY an http / https href becomes an <a> + * (with rel="noopener noreferrer"), and any other scheme (javascript:, data:, vbscript:, …) or a malformed link + * renders as its literal source text. No external library, no network, no off-origin literal — the CI + * self-containment scan forbids a literal scheme+// URL anywhere in wwwroot, so the one scheme test below is + * written so its regex SOURCE never contains that substring (the two slashes stay non-adjacent as \/\/). + * + * Supported (a deliberately small subset — enough to document an investigation, no more): ATX headings + * (# … ######), paragraphs, **bold** / *italic* (and __ / _), `inline code`, ``` fenced code blocks ```, unordered + * (- * +) and ordered (1. / 1)) lists, and [text](url) links. Everything else renders as its literal text. + */ + +import { el } from "./util.js"; + +/** + * Render Markdown source into a DocumentFragment of block nodes (headings / paragraphs / lists / code / …). A + * fragment (not a wrapper element) so the caller mounts it wherever it likes; a fresh fragment each call, so it is + * safe to re-render into a live-preview box. Never throws on odd input — anything unrecognized becomes text. + */ +export function renderMarkdown(text) { + const frag = document.createDocumentFragment(); + const lines = normalize(text).split("\n"); + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + /* Blank line: a block separator — skip it. */ + if (line.trim() === "") { + i++; + continue; + } + + /* Fenced code block: ``` or ~~~ (>=3), closed by a fence of the same char (>= the opening length). The body is + verbatim text (no inline parsing), so anything inside — including markup — is literal by definition. */ + const fence = line.match(/^\s*(`{3,}|~{3,})(.*)$/); + if (fence) { + const fenceChar = fence[1][0]; + const fenceLen = fence[1].length; + const body = []; + i++; + while (i < lines.length) { + const close = lines[i].match(/^\s*(`{3,}|~{3,})\s*$/); + if (close && close[1][0] === fenceChar && close[1].length >= fenceLen) { + i++; + break; + } + body.push(lines[i]); + i++; + } + frag.appendChild(el("pre", { class: "md-pre" }, [el("code", { text: body.join("\n") })])); + continue; + } + + /* ATX heading: 1–6 leading #, a required space, optional trailing #'s stripped. */ + const heading = line.match(/^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/); + if (heading) { + frag.appendChild(el("h" + heading[1].length, { class: "md-h" }, parseInline(heading[2]))); + i++; + continue; + } + + /* Unordered list: consecutive - / * / + item lines. */ + if (/^\s*[-*+]\s+/.test(line)) { + const items = []; + while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) { + items.push(el("li", {}, parseInline(lines[i].replace(/^\s*[-*+]\s+/, "")))); + i++; + } + frag.appendChild(el("ul", { class: "md-ul" }, items)); + continue; + } + + /* Ordered list: consecutive "N." / "N)" item lines. */ + if (/^\s*\d+[.)]\s+/.test(line)) { + const items = []; + while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i])) { + items.push(el("li", {}, parseInline(lines[i].replace(/^\s*\d+[.)]\s+/, "")))); + i++; + } + frag.appendChild(el("ol", { class: "md-ol" }, items)); + continue; + } + + /* Paragraph: gather consecutive non-blank lines until the next block start; soft-wrap joins with a space. */ + const para = []; + while (i < lines.length && lines[i].trim() !== "" && !isBlockStart(lines[i])) { + para.push(lines[i].trim()); + i++; + } + frag.appendChild(el("p", { class: "md-p" }, parseInline(para.join(" ")))); + } + + return frag; +} + +/** Normalize source: CRLF/CR -> LF, tabs -> spaces, null-safe to "". */ +function normalize(text) { + return String(text == null ? "" : text).replace(/\r\n?/g, "\n").replace(/\t/g, " "); +} + +/** Whether a line opens a non-paragraph block (so a paragraph run stops before it). Mirrors the block detectors. */ +function isBlockStart(line) { + return ( + /^\s*(`{3,}|~{3,})/.test(line) || + /^\s{0,3}#{1,6}\s+/.test(line) || + /^\s*[-*+]\s+/.test(line) || + /^\s*\d+[.)]\s+/.test(line) + ); +} + +/* ─────────────────────────── inline parsing (the XSS-critical path) ─────────────────────────── */ + +/** Parse inline markdown into an array of DOM nodes (text nodes + <strong>/<em>/<code>/<a>). */ +function parseInline(text) { + return parseInlineImpl(String(text == null ? "" : text), true); +} + +/** + * The inline tokenizer: a single left-to-right pass. Precedence: inline code (backtick) first (its content is + * literal, so a * or [ inside a code span never reaches the emphasis/link handlers), then links, then bold, then + * italic. `allowLinks` is false inside a link label so links never nest. Every branch that fails to find its close + * falls back to emitting the marker as literal text — the parser never throws and never drops input. + */ +function parseInlineImpl(text, allowLinks) { + const nodes = []; + let buf = ""; + const flush = () => { + if (buf) { + nodes.push(document.createTextNode(buf)); + buf = ""; + } + }; + + let i = 0; + const n = text.length; + while (i < n) { + const c = text[i]; + + /* `inline code` — verbatim content, no nested formatting. */ + if (c === "`") { + const close = text.indexOf("`", i + 1); + if (close > i) { + flush(); + nodes.push(el("code", { class: "md-code", text: text.slice(i + 1, close) })); + i = close + 1; + continue; + } + buf += c; + i++; + continue; + } + + /* [label](url) — only an http / https url becomes an <a>; anything else renders as its literal source. */ + if (c === "[" && allowLinks) { + const link = matchLink(text, i); + if (link) { + flush(); + const href = safeUrl(link.url); + if (href) { + nodes.push( + el("a", { class: "md-link", href, rel: "noopener noreferrer", target: "_blank" }, parseInlineImpl(link.label, false)) + ); + } else { + nodes.push(document.createTextNode(link.raw)); + } + i = link.end; + continue; + } + buf += c; + i++; + continue; + } + + /* **bold** / __bold__ */ + if ((c === "*" || c === "_") && text[i + 1] === c) { + const close = text.indexOf(c + c, i + 2); + if (close > i + 1) { + flush(); + nodes.push(el("strong", {}, parseInlineImpl(text.slice(i + 2, close), allowLinks))); + i = close + 2; + continue; + } + buf += c; + i++; + continue; + } + + /* *italic* / _italic_ */ + if (c === "*" || c === "_") { + const close = indexOfSingle(text, c, i + 1); + if (close > i) { + flush(); + nodes.push(el("em", {}, parseInlineImpl(text.slice(i + 1, close), allowLinks))); + i = close + 1; + continue; + } + buf += c; + i++; + continue; + } + + buf += c; + i++; + } + + flush(); + return nodes; +} + +/** The next LONE emphasis marker `c` at or after `from` (one whose next char isn't also `c`, so it's not a `**`). */ +function indexOfSingle(text, c, from) { + for (let j = from; j < text.length; j++) { + if (text[j] === c && text[j + 1] !== c) { + return j; + } + } + return -1; +} + +/** + * Match a link starting at `start` (a '['): the bracket-balanced label, a required '(' immediately after ']', and + * the URL up to the first ')'. Returns {label, url, raw, end} or null when the shape doesn't hold (the caller then + * treats '[' as literal). Kept intentionally simple — a ')' inside a URL truncates it, which safeUrl then rejects. + */ +function matchLink(text, start) { + let depth = 0; + let labelEnd = -1; + for (let j = start; j < text.length; j++) { + const ch = text[j]; + if (ch === "[") { + depth++; + } else if (ch === "]") { + depth--; + if (depth === 0) { + labelEnd = j; + break; + } + } + } + if (labelEnd < 0 || text[labelEnd + 1] !== "(") { + return null; + } + const urlStart = labelEnd + 2; + const urlEnd = text.indexOf(")", urlStart); + if (urlEnd < 0) { + return null; + } + return { + label: text.slice(start + 1, labelEnd), + url: text.slice(urlStart, urlEnd), + raw: text.slice(start, urlEnd + 1), + end: urlEnd + 1, + }; +} + +/** + * The ONLY safe href: an absolute http / https URL with no whitespace / quotes / angle brackets. Any other scheme + * (javascript:, data:, vbscript:, mailto:, …), a protocol-relative //host, or a relative path returns null and the + * link renders as literal text. NOTE the regex source deliberately keeps the two slashes non-adjacent (\/\/) and + * uses `https?` so the file never contains a literal scheme+slashslash substring — the air-gap CI scan forbids one. + */ +function safeUrl(url) { + const u = String(url == null ? "" : url).trim(); + if (!u || /[\s"'<>]/.test(u)) { + return null; + } + return /^https?:\/\//i.test(u) ? u : null; +} diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/notebook.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/notebook.js new file mode 100644 index 000000000..c94274e2f --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/notebook.js @@ -0,0 +1,735 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +/* + * Notebook mode (#1563 D7, scope B) — the COMPOSER for a notebook and the seed TEMPLATES. A notebook is a saved + * custom view whose definition is { kind:"notebook", cells:[…], variables, range } instead of a dashboard's + * { panels:[…], … }; both live in config.custom_views behind the same /api/views CRUD, and the backend validates + * the notebook shape (the authority). A cell is either { type:"markdown", text } — rendered by the air-gapped + * markdown.js — or { type:"panel", <a v2 composed panel spec> } — authored by the SAME composer body the dashboard + * uses (buildComposedPanelBody, shared from editor.js) and rendered by compose.js. The DOCUMENT renderer lives in + * pages/views.js beside the dashboard renderer (it reuses that page's server/time/variable scope bar); this module + * owns the editor + templates. All user text reaches the DOM via util.el()/textContent (R4 — never innerHTML). + * + * The 60s poll in app.js SKIPS the notebook editor route (the poll-clobber guard), so an in-progress edit is never + * discarded by a background refresh — the same guarantee the dashboard composer has. + */ + +import { el, mount } from "./util.js"; +import { renderMarkdown } from "./markdown.js"; +import { + buildComposedPanelBody, + buildViewScopeSection, + loadFleetOptions, + scopeServerPlatform, + newComposedPanel, + composedPanelToDesc, + descToComposedPanel, + composedBlocker, + field, +} from "./editor.js"; +import * as api from "./views-api.js"; + +/** The definition discriminator: a notebook definition carries kind:"notebook"; a dashboard omits it. */ +export const NOTEBOOK_KIND = "notebook"; + +/** Whether a stored definition is a notebook (vs a dashboard). Null-safe. */ +export function isNotebookDefinition(def) { + return !!def && typeof def === "object" && def.kind === NOTEBOOK_KIND; +} + +/** The view-level default range a new notebook opens with (the viewer can still change it). */ +const DEFAULT_RANGE_HOURS = 24; + +/** Abuse bounds mirrored from the server (DarlingWebEndpoints) so the composer reports them inline, not as a 400. */ +const MAX_NOTEBOOK_CELLS = 100; +const MAX_MARKDOWN_BYTES = 32 * 1024; +const _utf8 = new TextEncoder(); + +/* ─────────────────────────── entry ─────────────────────────── */ + +export async function renderNotebookEditor(main, id, templateKey) { + mount(main, el("div", { class: "strip loading", text: "Loading notebook composer…" })); + + const session = await api.getSession(); + if (!session.can_edit) { + mount(main, [ + notebookBackHead(id), + el("div", { class: "strip empty" }, [ + "Couldn't confirm your session, so the composer is read-only for now. Reload the page to edit; " + + "you can still open and export notebooks.", + ]), + ]); + return; + } + + /* Fleet options carry the per-server platform for D4 measure auto-greying (loadFleetOptions is shared from the + dashboard composer so the enrichment lives in one place). */ + const [catalog, fleet] = await Promise.all([api.getCatalog(), loadFleetOptions()]); + + let model; + let editingId = null; + let loadedVersion = null; + + if (id != null && id !== "new") { + const res = await api.getView(id); + if (res.kind !== "data" || !res.data) { + mount(main, [notebookBackHead(id), el("div", { class: "strip error", text: res.message || "Could not load this notebook." })]); + return; + } + /* If the id is actually a DASHBOARD, hand off to the dashboard composer (no import cycle — a hash redirect). */ + if (!isNotebookDefinition(res.data.definition)) { + location.hash = "#/view/" + encodeURIComponent(id) + "/edit"; + return; + } + editingId = res.data.id; + loadedVersion = res.data.version; + model = notebookToModel(res.data); + } else { + const tpl = templateKey ? NOTEBOOK_TEMPLATES.find((t) => t.key === templateKey) : null; + model = tpl ? notebookToModel(tpl.make()) : blankNotebookModel(); + } + + buildNotebookEditor(main, { model, editingId, loadedVersion, catalog, fleet }); +} + +/* ─────────────────────────── model <-> definition ─────────────────────────── */ + +/** A brand-new notebook: one empty markdown cell + one empty panel cell (both cell kinds visible to start). */ +function blankNotebookModel() { + return { name: "", description: "", cells: [markdownCellModel(""), panelCellModel()], variables: [], rangeHours: null }; +} + +function markdownCellModel(text) { + return { type: "markdown", text: typeof text === "string" ? text : "" }; +} + +function panelCellModel() { + return { type: "panel", panel: newComposedPanel() }; +} + +/** A stored view/template ({name, description, definition}) -> the editor model. Malformed cells are dropped; a + * notebook with no valid cells falls back to a single empty markdown cell (a notebook always has >=1 cell). */ +function notebookToModel(view) { + const def = view.definition || {}; + const cells = Array.isArray(def.cells) ? def.cells.map(cellToModel).filter(Boolean) : []; + return { + name: view.name || "", + description: view.description || "", + cells: cells.length ? cells : [markdownCellModel("")], + variables: parseVariables(def.variables), + rangeHours: def.range && typeof def.range.hours === "number" ? def.range.hours : null, + }; +} + +/* A stored cell -> the editor's cell model. A markdown cell keeps its text; a panel cell's composed spec is parsed + by the SAME descToComposedPanel the dashboard composer uses (it reads only the composed keys, ignoring `type`). */ +function cellToModel(c) { + if (!c || typeof c !== "object" || Array.isArray(c)) return null; + if (c.type === "markdown") return markdownCellModel(typeof c.text === "string" ? c.text : ""); + if (c.type === "panel") return { type: "panel", panel: descToComposedPanel(c) }; + return null; +} + +/** The view's stored template variables -> the model list ({name, dimension, default}); malformed entries dropped + * (a local mirror of the dashboard composer's parser — same shape, same rules). */ +function parseVariables(node) { + if (!Array.isArray(node)) return []; + return node + .filter((v) => v && typeof v === "object" && v.name) + .map((v) => ({ name: String(v.name), dimension: v.dimension ? String(v.dimension) : "", default: v.default != null ? String(v.default) : "" })); +} + +/** The editor model -> the stored notebook definition ({ kind:"notebook", cells, variables?, range? }). */ +function modelToNotebookDefinition(model) { + const def = { kind: NOTEBOOK_KIND, cells: model.cells.map(cellToStored) }; + const variables = (model.variables || []) + .filter((v) => v.name && v.dimension) + .map((v) => (v.default ? { name: v.name, dimension: v.dimension, default: v.default } : { name: v.name, dimension: v.dimension })); + if (variables.length) def.variables = variables; + if (model.rangeHours) def.range = { hours: model.rangeHours }; + return def; +} + +/* A cell model -> its stored form. A panel cell serializes through the shared composedPanelToDesc, then drops + `span` — a notebook is a single-column document, so a panel cell is always full width. */ +function cellToStored(cell) { + if (cell.type === "markdown") return { type: "markdown", text: cell.text || "" }; + const { span, ...desc } = composedPanelToDesc(cell.panel); + return { type: "panel", ...desc }; +} + +/** The reason SAVE is blocked (name/cells + each panel cell's composed prerequisites), or null when savable. */ +function notebookSaveBlocker(model, catalog) { + if (!model.name || !model.name.trim()) return "Enter a notebook name."; + if (!model.cells.length) return "Add at least one cell."; + if (model.cells.length > MAX_NOTEBOOK_CELLS) return "A notebook can have at most " + MAX_NOTEBOOK_CELLS + " cells."; + for (let i = 0; i < model.cells.length; i++) { + const cell = model.cells[i]; + if (cell.type === "panel") { + const problem = composedBlocker(cell.panel, catalog); + if (problem) return "Cell " + (i + 1) + " (panel): " + problem; + } else if (cell.type === "markdown" && _utf8.encode(cell.text || "").length > MAX_MARKDOWN_BYTES) { + return "Cell " + (i + 1) + " (markdown) text is too long (max " + MAX_MARKDOWN_BYTES / 1024 + " KB)."; + } + } + return null; +} + +/* ─────────────────────────── the editor shell ─────────────────────────── */ + +function buildNotebookEditor(main, ctx) { + const { model } = ctx; + + const nameInput = el("input", { class: "editor-input", type: "text", placeholder: "Notebook name (required)", "aria-label": "Notebook name" }); + nameInput.value = model.name; + nameInput.addEventListener("input", () => { + model.name = nameInput.value; + refreshSaveState(); + }); + + const descInput = el("input", { class: "editor-input", type: "text", placeholder: "Description (optional)", "aria-label": "Notebook description" }); + descInput.value = model.description; + descInput.addEventListener("input", () => { + model.description = descInput.value; + }); + + const cellsBox = el("div", { class: "cells-box" }); + const saveStatus = el("div", { class: "save-status" }); + const saveBtn = el("button", { class: "btn primary", type: "button", text: ctx.editingId != null ? "Save changes" : "Create notebook" }); + const cancelBtn = el("button", { class: "btn", type: "button", text: "Cancel" }); + + const backHash = ctx.editingId != null ? "#/notebook/" + encodeURIComponent(ctx.editingId) : "#/views"; + cancelBtn.addEventListener("click", () => { + location.hash = backHash; + }); + saveBtn.addEventListener("click", onSave); + + const addMdBtn = el("button", { class: "btn", type: "button", text: "+ Markdown cell" }); + addMdBtn.addEventListener("click", () => { + if (model.cells.length >= MAX_NOTEBOOK_CELLS) return; + model.cells.push(markdownCellModel("")); + rebuildCells(); + }); + const addPanelBtn = el("button", { class: "btn", type: "button", text: "+ Panel cell" }); + addPanelBtn.addEventListener("click", () => { + if (model.cells.length >= MAX_NOTEBOOK_CELLS) return; + model.cells.push(panelCellModel()); + rebuildCells(); + }); + + /* The scope a panel cell's LIVE preview runs against — the whole fleet + the notebook's default range + the + declared variables bound to their defaults; read live so it always reflects the current scope model. */ + function previewScope() { + const values = {}; + for (const v of model.variables || []) { + if (v.name && v.dimension && v.dimension !== "server" && v.default) values[v.name] = v.default; + } + return { + server: "All", + hours: model.rangeHours || DEFAULT_RANGE_HOURS, + variables: (model.variables || []) + .filter((v) => v.name && v.dimension) + .map((v) => ({ name: v.name, dimension: v.dimension, default: v.default || undefined })), + values, + }; + } + + const inner = { ...ctx, rebuildCells, refreshSaveState, previewScope }; + + /* Reuse the dashboard composer's view-scope editor verbatim (default time range + template variables). */ + const viewScopeSection = buildViewScopeSection(model, ctx.catalog, rebuildCells); + + function rebuildCells() { + mount(cellsBox, model.cells.map((cell, i) => buildCellEditor(cell, i, inner))); + refreshSaveState(); + } + + function refreshSaveState() { + const problem = notebookSaveBlocker(model, ctx.catalog); + saveBtn.disabled = problem != null; + const atCap = model.cells.length >= MAX_NOTEBOOK_CELLS; + addMdBtn.disabled = atCap; + addPanelBtn.disabled = atCap; + mount(saveStatus, problem ? el("span", { class: "muted", text: problem }) : null); + } + + async function onSave() { + saveBtn.disabled = true; + const problem = notebookSaveBlocker(model, ctx.catalog); + if (problem) { + saveBtn.disabled = false; + mount(saveStatus, el("span", { class: "strip error", text: problem })); + return; + } + + mount(saveStatus, el("span", { class: "muted", text: "Saving…" })); + const body = { + name: model.name.trim(), + description: model.description ? model.description : null, + definition: modelToNotebookDefinition(model), + }; + + let res; + if (ctx.editingId != null) { + body.version = ctx.loadedVersion; + res = await api.updateView(ctx.editingId, body); + } else { + res = await api.createView(body); + } + + if (res.kind === "data" && res.data) { + location.hash = "#/notebook/" + encodeURIComponent(res.data.id); + return; + } + + saveBtn.disabled = false; + handleNotebookSaveError(res, saveStatus, ctx, main); + } + + mount(main, [ + el("div", { class: "page-head" }, [ + el("a", { href: backHash, text: "← Back" }), + el("h2", { text: ctx.editingId != null ? "Edit notebook" : "New notebook" }), + ]), + el("div", { class: "editor-meta" }, [field("Name", nameInput), field("Description", descInput)]), + viewScopeSection, + el("h3", { class: "section-title", text: "Cells" }), + cellsBox, + el("div", { class: "cells-add" }, [addMdBtn, addPanelBtn]), + el("div", { class: "editor-footer" }, [saveBtn, cancelBtn, saveStatus]), + ]); + + rebuildCells(); +} + +/* On a 409 while EDITING, the row changed under us (stale version) or the name now collides — offer a reload of the + latest (discarding local edits), mirroring the dashboard composer. */ +function handleNotebookSaveError(res, saveStatus, ctx, main) { + const parts = [el("span", { text: res.message || "Could not save the notebook." })]; + if (res.status === 409 && ctx.editingId != null) { + const reload = el("button", { class: "btn small", type: "button", text: "Reload latest" }); + reload.addEventListener("click", () => renderNotebookEditor(main, ctx.editingId)); + parts.push(reload); + } + mount(saveStatus, el("div", { class: "strip error" }, parts)); +} + +/* ─────────────────────────── one cell editor ─────────────────────────── */ + +function buildCellEditor(cell, index, ctx) { + return cell.type === "markdown" ? buildMarkdownCellEditor(cell, index, ctx) : buildPanelCellEditor(cell, index, ctx); +} + +/* The shared cell head: "Cell N", a kind tag, and reorder/remove wired to model.cells (a11y: up/down buttons with + labels + disabled at the ends, matching the dashboard panel reorder). Removing the last cell reseeds a markdown + cell so a notebook always has >=1 cell. */ +function cellHead(index, ctx, kindLabel) { + const cells = ctx.model.cells; + const upBtn = el("button", { class: "btn small icon", type: "button", text: "↑", title: "Move up", "aria-label": "Move cell up" }); + const downBtn = el("button", { class: "btn small icon", type: "button", text: "↓", title: "Move down", "aria-label": "Move cell down" }); + const removeBtn = el("button", { class: "btn small danger", type: "button", text: "Remove", "aria-label": "Remove cell" }); + upBtn.disabled = index === 0; + downBtn.disabled = index === cells.length - 1; + upBtn.addEventListener("click", () => { + swap(cells, index, index - 1); + ctx.rebuildCells(); + }); + downBtn.addEventListener("click", () => { + swap(cells, index, index + 1); + ctx.rebuildCells(); + }); + removeBtn.addEventListener("click", () => { + cells.splice(index, 1); + if (!cells.length) cells.push(markdownCellModel("")); + ctx.rebuildCells(); + }); + return el("div", { class: "cell-editor-head" }, [ + el("span", { class: "cell-editor-title", text: "Cell " + (index + 1) }), + el("span", { class: "cell-kind-tag", text: kindLabel }), + el("div", { class: "spacer" }), + upBtn, + downBtn, + removeBtn, + ]); +} + +/* A markdown cell: a textarea beside a live renderMarkdown preview (two columns, collapsing to stacked on narrow + screens). Typing re-renders the preview locally (no full cell rebuild, so the textarea keeps focus + caret). */ +function buildMarkdownCellEditor(cell, index, ctx) { + const previewBox = el("div", { class: "md-preview markdown-body" }); + function renderPreview() { + mount(previewBox, cell.text && cell.text.trim() ? renderMarkdown(cell.text) : el("div", { class: "muted", text: "Nothing to preview yet." })); + } + + const textarea = el("textarea", { + class: "md-textarea", + rows: "8", + placeholder: "Write markdown… # heading, **bold**, *italic*, `code`, - list, 1. list, [text](url)", + "aria-label": "Markdown for cell " + (index + 1), + }); + textarea.value = cell.text || ""; + textarea.addEventListener("input", () => { + cell.text = textarea.value; + renderPreview(); + }); + + renderPreview(); + + const body = el("div", { class: "md-cell-body" }, [ + el("div", { class: "md-edit-col" }, [el("div", { class: "block-label", text: "Markdown" }), textarea]), + el("div", { class: "md-preview-col" }, [el("div", { class: "block-label", text: "Preview" }), previewBox]), + ]); + + return el("div", { class: "notebook-cell md-cell card" }, [cellHead(index, ctx, "Markdown"), body]); +} + +/* A panel cell: the SHARED composer body (metric / shape / group / filters / chart / advanced / identity + live + preview) with the Width control hidden (a notebook cell is always full width). onChange just re-checks save + state; the body owns its own config rebuild + debounced preview. */ +function buildPanelCellEditor(cell, index, ctx) { + const body = buildComposedPanelBody(cell.panel, { + catalog: ctx.catalog, + getVariables: () => ctx.model.variables, + getScopeServer: () => scopeServerPlatform(ctx.model.variables, ctx.fleet), + previewScope: ctx.previewScope, + onChange: () => ctx.refreshSaveState(), + showWidth: false, + }); + return el("div", { class: "notebook-cell panel-cell card" }, [cellHead(index, ctx, "Panel"), body]); +} + +function notebookBackHead(id) { + return el("div", { class: "page-head" }, [ + el("a", { href: id != null && id !== "new" ? "#/notebook/" + encodeURIComponent(id) : "#/views", text: "← Back" }), + el("h2", { text: "Notebook composer" }), + ]); +} + +function swap(arr, i, j) { + const tmp = arr[i]; + arr[i] = arr[j]; + arr[j] = tmp; +} + +/* ─────────────────────────── templates (scope B) ─────────────────────────── */ + +/* + * Seed notebooks for common investigations. Each template is a full notebook DEFINITION — prose markdown headings + * plus panel cells that reference REAL catalog measures / sources / dimensions / annotation sources (grounded + * against MeasureCatalog so a seeded notebook validates + renders immediately). Templates declare no view + * variables: every panel shows data out of the box, scoped by the built-in server + time bar (grouping gives the + * per-database / per-object breakdowns). A count-aggregate panel is unitless ('count'); other panels carry the + * measure's default unit for a labeled axis; ratios omit the aggregate. + */ + +/** A markdown cell for a template. */ +function md(text) { + return { type: "markdown", text }; +} + +/** A time-series panel cell (bucketed over time). */ +function tsPanel(o) { + const cell = { type: "panel", source: o.source, viz: o.viz || "line", timeBucket: o.timeBucket || "hour", title: o.title }; + applyMetric(cell, o); + if (Array.isArray(o.groupBy) && o.groupBy.length) cell.groupBy = o.groupBy; + if (Array.isArray(o.annotations) && o.annotations.length) cell.annotations = o.annotations; + if (Array.isArray(o.thresholds) && o.thresholds.length) cell.thresholds = o.thresholds; + return cell; +} + +/** A ranked (top-N) panel cell. */ +function rankedPanel(o) { + const cell = { type: "panel", source: o.source, viz: o.viz || "bar", topN: o.topN || 10, title: o.title, groupBy: o.groupBy }; + applyMetric(cell, o); + return cell; +} + +/* Apply the metric fields common to both shapes: a ratio names `ratio` (no aggregate); a scalar measure names + `measure` + `aggregate`. A `count` aggregate is unitless ('count'); otherwise carry the given unit. */ +function applyMetric(cell, o) { + if (o.ratio) { + cell.ratio = o.ratio; + if (o.unit) cell.unit = o.unit; + return; + } + cell.measure = o.measure; + cell.aggregate = o.aggregate; + cell.unit = o.aggregate === "count" ? "count" : o.unit; +} + +export const NOTEBOOK_TEMPLATES = [ + { + key: "blocking-rca", + label: "Blocking-chain RCA", + description: "Root-cause a blocking incident: when it spiked, what was contended, which databases, which lock modes.", + make() { + return { + name: "Blocking-chain RCA", + description: "Root-cause analysis for a blocking incident.", + definition: { + kind: NOTEBOOK_KIND, + cells: [ + md( + "# Blocking-chain root cause analysis\n\n" + + "Work top-to-bottom: find **when** blocking spiked, **what** was contended, **which** databases, and the " + + "**lock modes** involved. Set the server and time range at the top to scope every panel. The timeline " + + "carries deadlock markers so you can see whether blocking and deadlocks cluster together." + ), + md("## 1. Blocking over time"), + tsPanel({ + title: "Blocked-process reports over time", + source: "blocked_process_reports", + measure: "bpr_wait_time_ms", + aggregate: "count", + viz: "line", + annotations: ["deadlocks"], + }), + md("## 2. Most-contended objects"), + rankedPanel({ + title: "Most-blocked objects", + source: "blocked_process_reports", + measure: "bpr_wait_time_ms", + aggregate: "count", + viz: "bar", + groupBy: ["contentious_object"], + topN: 10, + }), + md("## 3. Blocking by database"), + rankedPanel({ + title: "Blocking by database", + source: "blocked_process_reports", + measure: "bpr_wait_time_ms", + aggregate: "count", + viz: "bar", + groupBy: ["database_name"], + topN: 10, + }), + md("## 4. Lock modes involved"), + rankedPanel({ + title: "Lock modes", + source: "blocked_process_reports", + measure: "bpr_wait_time_ms", + aggregate: "count", + viz: "pie", + groupBy: ["lock_mode"], + topN: 8, + }), + md( + "### Next steps\n\n" + + "- Open the blocking chain for the spike window to find the head blocker.\n" + + "- Cross-check the contended objects against index and query activity.\n" + + "- If deadlocks cluster with the blocking, continue in the **Deadlock post-mortem** notebook." + ), + ], + }, + }; + }, + }, + { + key: "deadlock-postmortem", + label: "Deadlock post-mortem", + description: "Reconstruct a deadlock incident: frequency, affected databases, and correlated blocking.", + make() { + return { + name: "Deadlock post-mortem", + description: "Reconstruct and explain a deadlock incident.", + definition: { + kind: NOTEBOOK_KIND, + cells: [ + md( + "# Deadlock post-mortem\n\n" + + "Reconstruct a deadlock incident: how often it fired, on which databases, and how it lines up with " + + "blocking. Scope with the server and time controls above." + ), + md("## Deadlocks over time"), + tsPanel({ + title: "Deadlocks over time", + source: "deadlocks", + measure: "deadlock_count", + aggregate: "count", + viz: "line", + annotations: ["blocked_process_reports"], + }), + md("## Deadlocks by database"), + rankedPanel({ + title: "Deadlocks by database", + source: "deadlocks", + measure: "deadlock_count", + aggregate: "count", + viz: "bar", + groupBy: ["database_name"], + topN: 10, + }), + md("## Correlated blocking"), + tsPanel({ + title: "Blocked-process reports (deadlock markers)", + source: "blocked_process_reports", + measure: "bpr_wait_time_ms", + aggregate: "count", + viz: "line", + annotations: ["deadlocks"], + }), + md( + "### Reading the deadlock graph\n\n" + + "Open the **Deadlocks** detail for the spike window to see the victim, the surviving session, and the " + + "resources in the cycle. Look for a consistent object or index at the center of repeated cycles — that is " + + "usually where an index or access-order change breaks the deadlock." + ), + ], + }, + }; + }, + }, + { + key: "what-changed", + label: "What changed at 2am?", + description: "Line up configuration / default-trace and system_health events against CPU to find what shifted.", + make() { + return { + name: "What changed at 2am?", + description: "Config-drift and default-trace correlation for an overnight change.", + definition: { + kind: NOTEBOOK_KIND, + cells: [ + md( + "# What changed at 2am?\n\n" + + "Something shifted overnight. Line up configuration / default-trace events and `system_health` against " + + "CPU to find the moment things changed. Set the time range to bracket the incident window." + ), + md("## Default-trace events over time"), + tsPanel({ + title: "Default-trace events over time", + source: "default_trace_events", + measure: "deftrace_duration_us", + aggregate: "count", + viz: "line", + annotations: ["system_health_events", "deadlocks"], + }), + md("## Events by type"), + rankedPanel({ + title: "Default-trace events by type", + source: "default_trace_events", + measure: "deftrace_duration_us", + aggregate: "count", + viz: "bar", + groupBy: ["event_name"], + topN: 10, + }), + md("## Events by database"), + rankedPanel({ + title: "Default-trace events by database", + source: "default_trace_events", + measure: "deftrace_duration_us", + aggregate: "count", + viz: "bar", + groupBy: ["database_name"], + topN: 10, + }), + md("## Correlate with CPU"), + tsPanel({ + title: "SQL Server CPU % (event markers)", + source: "cpu_utilization_stats", + measure: "sqlserver_cpu_utilization", + aggregate: "avg", + unit: "percent", + viz: "line", + annotations: ["default_trace_events", "system_health_events"], + }), + md( + "### Where to look next\n\n" + + "- Check server and database configuration history for changes inside the window.\n" + + "- Review trace flags and any Agent jobs that run overnight.\n" + + "- If CPU jumped with no event, correlate with the query and wait notebooks." + ), + ], + }, + }; + }, + }, + { + key: "memory-oom", + label: "Memory / OOM walkthrough", + description: "Follow the memory story from the whole server down to clerks and query-memory grant pressure.", + make() { + return { + name: "Memory / OOM walkthrough", + description: "Diagnose server memory and query-memory grant pressure.", + definition: { + kind: NOTEBOOK_KIND, + cells: [ + md( + "# Memory / OOM walkthrough\n\n" + + "Follow the memory story from the whole server down to individual clerks and query-memory grants. " + + "`RESOURCE_SEMAPHORE` waits, grant timeouts, and a climbing used-grant % all point at query-memory pressure." + ), + md("## Server memory over time"), + tsPanel({ + title: "Total server memory", + source: "memory_stats", + measure: "mem_total_server_mb", + aggregate: "avg", + unit: "mb", + viz: "line", + }), + md("## Query-memory grants"), + tsPanel({ + title: "Granted query memory", + source: "memory_grant_stats", + measure: "grant_granted_mb", + aggregate: "avg", + unit: "mb", + viz: "line", + }), + tsPanel({ + title: "Query-memory grant used %", + source: "memory_grant_stats", + ratio: "grant_used_pct", + unit: "percent", + viz: "line", + thresholds: [90], + }), + md("## Grant pressure: waiters & timeouts"), + tsPanel({ + title: "Memory-grant waiters (peak)", + source: "memory_grant_stats", + measure: "grant_waiters", + aggregate: "max", + unit: "count", + viz: "line", + }), + tsPanel({ + title: "Memory-grant timeouts", + source: "memory_grant_stats", + measure: "grant_timeouts", + aggregate: "sum", + unit: "count", + viz: "line", + }), + md("## Top memory clerks"), + rankedPanel({ + title: "Top memory clerks", + source: "memory_clerks", + measure: "clerk_memory_mb", + aggregate: "max", + unit: "mb", + viz: "bar", + groupBy: ["clerk_type"], + topN: 10, + }), + md( + "### If you see RESOURCE_SEMAPHORE waits\n\n" + + "That is queries waiting for a memory grant. Cross-reference the wait stats, look for oversized grants in " + + "Query Store, and review `MAX_GRANT_PERCENT` / Resource Governor limits." + ), + ], + }, + }; + }, + }, +]; diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/alerts.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/alerts.js new file mode 100644 index 000000000..138d1e24b --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/alerts.js @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +/* + * Alert History page (#1562) — the fleet-wide alert log from get_alert_history (server omitted = whole fleet, + * each row naming its server). A filter box narrows the already-fetched rows by server name client-side (no + * re-fetch). Every value is untrusted and reaches the DOM through el()/textContent (R4 — never innerHTML), + * the expanders included (B2): the channel / sent / muted / delivery-error columns collapse into one compact + * Status column, and the Detail column shows a truncated one-liner that expands to the full payload (structured + * "Story:/Severity:/Confidence:" findings render as labeled fields). + */ + +import { el, mount, readTool, loadingStrip, errorStrip, emptyStrip, disclosure } from "../util.js"; +import { VIZ } from "../panels.js"; + +const ALERT_COLUMNS = [ + { key: "alert_time", label: "Time", format: "time" }, + { key: "server_name", label: "Server" }, + { key: "metric_name", label: "Metric" }, + { key: "current_value", label: "Value", format: "num1" }, + { key: "threshold_value", label: "Threshold", format: "num1" }, + { key: "status", label: "Status", render: (a) => statusCell(a) }, + { key: "detail_text", label: "Detail", render: (a) => detailCell(a) }, +]; + +/* Channel (always tray) + Sent + Muted + Delivery Error collapse into one glyph+text status cell (B2). */ +function statusCell(a) { + let glyph, label, sev; + if (a.muted) { + glyph = "⊘"; + label = "Muted"; + sev = "Warning"; + } else if (a.send_error) { + glyph = "✕"; + label = "Delivery failed"; + sev = "Critical"; + } else if (a.alert_sent) { + glyph = "✓"; + label = "Sent"; + sev = "Healthy"; + } else { + glyph = "•"; + label = "Not sent"; + sev = "Unknown"; + } + return el("span", { class: "status-cell sev-" + sev, title: a.send_error || null }, [ + el("span", { class: "glyph", text: glyph }), + el("span", { text: label }), + a.notification_type ? el("span", { class: "channel", text: a.notification_type }) : null, + ]); +} + +/* The Detail cell (B2): a truncated one-liner that expands to the full payload. Structured finding detail_text + * (indented "Story:/Severity:/Confidence:/..." lines) renders as labeled fields; anything else renders as a + * plain text block. A delivery error is appended as its own labeled field so it is fully readable in the + * expansion, not merely a hover title on the status glyph. */ +function detailCell(a) { + const hasDetail = a.detail_text != null && String(a.detail_text).trim().length > 0; + if (!hasDetail && !a.send_error) return el("span", { class: "muted", text: "—" }); + + const fields = hasDetail ? parseDetailFields(a.detail_text) : null; + const body = []; + if (fields) { + body.push(el("div", { class: "detail-fields" }, fields.map(fieldRow))); + } else if (hasDetail) { + body.push(el("div", { class: "detail-raw", text: a.detail_text })); + } + if (a.send_error) { + body.push(el("div", { class: "detail-fields detail-error" }, [fieldRow(["Delivery error", a.send_error])])); + } + + const summary = hasDetail ? a.detail_text : "Delivery error: " + a.send_error; + return disclosure(summary, body, { max: 120 }); +} + +function fieldRow([k, v]) { + return el("div", { class: "detail-field" }, [ + k ? el("span", { class: "fk", text: k }) : null, + el("span", { class: "fv", text: v }), + ]); +} + +/* Parse an indented "Key: value" detail block into [key, value] pairs; returns null (=> render as raw) unless + * at least two lines match, so a plain one-sentence detail stays a plain block. A non-matching continuation + * line folds into the previous field's value (wrapped story text). */ +function parseDetailFields(text) { + const lines = String(text) + .split(/\r?\n/) + .map((l) => l.trim()) + .filter((l) => l.length > 0); + const fields = []; + let matched = 0; + for (const line of lines) { + const m = /^([A-Za-z][A-Za-z ]{0,28}):\s*(.*)$/.exec(line); + if (m) { + fields.push([m[1], m[2]]); + matched++; + } else if (fields.length) { + fields[fields.length - 1][1] += " " + line; + } else { + fields.push(["", line]); + } + } + return matched >= 2 ? fields : null; +} + +export async function renderAlerts(main) { + const filter = el("input", { + class: "filter-box", + type: "text", + placeholder: "Filter by server…", + "aria-label": "Filter alerts by server", + }); + + const tableBox = el("div", {}); + mount(main, [ + el("div", { class: "page-head" }, [ + el("h2", { text: "Alert History" }), + el("div", { class: "meta", text: "fleet-wide · last 24h" }), + el("div", { class: "spacer" }), + filter, + ]), + tableBox, + ]); + + mount(tableBox, loadingStrip("Loading alerts…")); + const res = await readTool("get_alert_history", { hours_back: 24, limit: 200 }); + if (res.kind === "error") return mount(tableBox, errorStrip(res.message)); + if (res.kind === "empty") return mount(tableBox, emptyStrip(res.message)); + + const alerts = res.data.alerts || []; + + const draw = () => { + const q = filter.value.trim().toLowerCase(); + const rows = q ? alerts.filter((a) => String(a.server_name || "").toLowerCase().includes(q)) : alerts; + if (!rows.length) { + mount(tableBox, emptyStrip(q ? 'No alerts match "' + filter.value + '".' : "No alerts in this window.")); + return; + } + mount(tableBox, VIZ.table({ alerts: rows }, { rowsKey: "alerts", columns: ALERT_COLUMNS })); + }; + + filter.addEventListener("input", draw); + draw(); +} diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/fleet.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/fleet.js new file mode 100644 index 000000000..22acb412c --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/fleet.js @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +/* + * Fleet Overview page (#1562) — the NOC roll-up from GET /api/fleet. The API is PRE-BANDED: every band, status, + * and the worst-first ranking are computed server-side by ServerHealthClassifier. This page ONLY renders them; + * it never re-derives a threshold (R1). The amber "Awaiting first collection" status is rendered exactly as the + * API reports it (band = Warning, status text verbatim) — never the red offline treatment. + */ + +import { el, mount, apiGet, loadingStrip, errorStrip, emptyStrip, localTime, localClock, relTime, fmtInt, fmtPct, fmtMb, fmtMs, bandClass } from "../util.js"; +import { VIZ, navigateServer } from "../panels.js"; + +const BAND_RANK = { Offline: 0, Critical: 1, Warning: 2, Healthy: 3 }; + +/** Client-side card orderings (M8). Severity is the default; CPU sorts busiest-first (grids default DESC). */ +const SORTS = { + severity: (a, b) => (BAND_RANK[a.band] ?? 9) - (BAND_RANK[b.band] ?? 9) || a.display_name.localeCompare(b.display_name), + name: (a, b) => a.display_name.localeCompare(b.display_name), + cpu: (a, b) => + (b.total_cpu_percent ?? b.cpu_percent ?? -1) - (a.total_cpu_percent ?? a.cpu_percent ?? -1) || + a.display_name.localeCompare(b.display_name), +}; + +/* The sort choice persists across the 60s refresh (a full re-render): the header <select> re-reads it, and the + grid re-sorts in place without a refetch when it changes. */ +let fleetSort = "severity"; +let lastCards = []; +let gridNode = null; + +export async function renderFleet(main) { + mount(main, [pageHead(null), loadingStrip("Loading fleet…")]); + + const res = await apiGet("/api/fleet"); + if (res.kind === "error") { + mount(main, [pageHead(null), errorStrip(res.message)]); + return; + } + + const d = res.data; + const nodes = [pageHead(d), rollup(d)]; + + if (!d.total_servers) { + nodes.push( + emptyStrip("No servers are enabled yet. Add servers to darling.json and cards appear here as collection begins.") + ); + mount(main, nodes); + return; + } + + const problems = d.critical_count + d.warning_count + d.offline_count; + if (problems === 0) { + nodes.push( + el("div", { class: "all-healthy" }, [ + el("span", { class: "dot band-Healthy" }), + "All " + d.total_servers + " server" + (d.total_servers === 1 ? "" : "s") + " healthy.", + ]) + ); + } else { + nodes.push(el("h3", { class: "section-title", text: "Needs attention" })); + nodes.push( + VIZ.bandlist(d, { + rowsKey: "worst_servers", + primaryKey: "display_name", + bandKey: "band", + bandLabelKey: "band_label", + reasonKey: "reason", + navKey: "display_name", + }) + ); + if (d.additional_problem_count > 0) { + nodes.push(el("div", { class: "muted", style: "margin:0.4rem 0 0.2rem", text: "+ " + d.additional_problem_count + " more need attention" })); + } + } + + nodes.push(el("h3", { class: "section-title", style: "margin-top:1.25rem", text: "Servers" })); + lastCards = d.cards || []; + gridNode = el("div", { class: "grid" }); + redrawCards(); + nodes.push(gridNode); + + mount(main, nodes); +} + +/** Re-sort the cached cards by the current sort choice and (re)fill the grid — no refetch. */ +function redrawCards() { + if (!gridNode) return; + const sorted = [...lastCards].sort(SORTS[fleetSort] || SORTS.severity); + mount(gridNode, sorted.map(serverCard)); +} + +function pageHead(d) { + return el("div", { class: "page-head" }, [ + el("h2", { text: "Fleet Overview" }), + el("div", { class: "spacer" }), + d && d.total_servers ? sortControl() : null, + d ? el("div", { class: "meta", text: "Updated " + localTime(d.generated_at) }) : null, + ]); +} + +/** Client-side card-sort control on the fleet header (M8): severity (default) / name / CPU. */ +function sortControl() { + const sel = el("select", { class: "sort-select", "aria-label": "Sort servers" }, [ + el("option", { value: "severity", text: "Severity" }), + el("option", { value: "name", text: "Name" }), + el("option", { value: "cpu", text: "CPU" }), + ]); + sel.value = fleetSort; + sel.addEventListener("change", () => { + fleetSort = sel.value; + redrawCards(); + }); + return el("label", { class: "sort-control" }, [el("span", { text: "Sort" }), sel]); +} + +function rollup(d) { + const tile = (num, lbl, cls) => + el("div", { class: "tile " + (cls || "") }, [ + el("div", { class: "num", text: fmtInt(num) }), + el("div", { class: "lbl", text: lbl }), + ]); + /* Two fixed groups (server-band counts | event counts) split by a divider; a non-zero blocking / deadlock + total takes a severity color. */ + return el("div", { class: "rollup" }, [ + el("div", { class: "rollup-group" }, [ + tile(d.total_servers, "Servers"), + tile(d.healthy_count, "Healthy", "healthy"), + tile(d.warning_count, "Warning", "warning"), + tile(d.critical_count, "Critical", "critical"), + tile(d.offline_count, "Offline", "offline"), + ]), + el("div", { class: "rollup-divider" }), + el("div", { class: "rollup-group" }, [ + tile(d.total_blocking_events, "Blocking (recent)", d.total_blocking_events > 0 ? "warning" : ""), + tile(d.total_deadlocks, "Deadlocks (recent)", d.total_deadlocks > 0 ? "critical" : ""), + ]), + ]); +} + +function serverCard(c) { + const cls = bandClass(c.band); + const statusLine = c.awaiting_first_collection + ? el("div", { class: "status-line awaiting", text: c.status }) + : el("div", { class: "status-line", text: c.status + " · last collect " + localClock(c.last_collection) }); + + return el( + "div", + { class: "server-card " + cls, onActivate: () => navigateServer(c.server_name || c.display_name) }, + [ + el("div", { class: "head" }, [el("span", { class: "dot " + cls }), el("span", { class: "title", text: c.display_name })]), + statusLine, + metricBands(c), + ] + ); +} + +/* Enriched metric chips (M1): each carries a secondary detail line from fields /api/fleet already returns — + the SQL-vs-total CPU split, threads available/max, memory + buffer-pool GB, blocking max wait, deadlocks + last-seen, and collectors healthy/failing. */ +function metricBands(c) { + const threadsValue = + c.threads_severity === "Unknown" + ? "n/a" + : c.requests_waiting_for_threads > 0 + ? fmtInt(c.requests_waiting_for_threads) + " starved" + : c.available_threads != null + ? fmtInt(c.available_threads) + " free" + : "ok"; + const threadsDetail = + c.total_threads != null + ? fmtInt(c.available_threads ?? c.total_threads - (c.current_workers ?? 0)) + " / " + fmtInt(c.total_threads) + " threads" + : null; + + const cpuValue = + c.total_cpu_percent != null || c.cpu_percent != null ? fmtPct(c.total_cpu_percent ?? c.cpu_percent) : "n/a"; + const cpuDetail = + c.cpu_percent != null + ? "SQL " + fmtPct(c.cpu_percent) + (c.other_process_cpu_percent != null ? " · other " + fmtPct(c.other_process_cpu_percent) : "") + : null; + + const memValue = c.has_memory_pressure ? fmtInt(c.memory_waiter_count) + " waiters" : "ok"; + const memDetail = + c.memory_mb != null + ? fmtMb(c.memory_mb) + (c.buffer_pool_mb != null ? " · BP " + fmtMb(c.buffer_pool_mb) : "") + : null; + + const blockingDetail = c.blocking_count > 0 && c.max_blocking_wait_ms > 0 ? "max wait " + fmtMs(c.max_blocking_wait_ms) : null; + const deadlockDetail = c.deadlock_count > 0 && c.deadlock_last_seen ? "last " + relTime(c.deadlock_last_seen) : null; + + const collectorsValue = c.failed_collector_count > 0 ? fmtInt(c.failed_collector_count) + " failing" : "OK"; + const collectorsDetail = fmtInt(c.healthy_collector_count) + " healthy · " + fmtInt(c.failed_collector_count) + " failing"; + + return el("div", { class: "metric-bands" }, [ + chip("CPU", cpuValue, c.cpu_severity, cpuDetail), + chip("Threads", threadsValue, c.threads_severity, threadsDetail), + chip("Memory", memValue, c.memory_severity, memDetail), + chip("Blocking", fmtInt(c.blocking_count), c.blocking_severity, blockingDetail), + chip("Deadlocks", fmtInt(c.deadlock_count), c.deadlock_severity, deadlockDetail), + chip("Collectors", collectorsValue, c.collector_severity, collectorsDetail), + ]); +} + +/* A short non-color severity cue so severity isn't conveyed by border color alone (M2). */ +const SEV_BADGE = { Critical: "CRIT", Warning: "WARN" }; + +function chip(label, value, sev, detail) { + const badge = SEV_BADGE[sev]; + return el("div", { class: "metric-chip sev-" + (sev || "Unknown") }, [ + el("div", { class: "label" }, [ + el("span", { text: label }), + badge ? el("span", { class: "sev-badge", text: badge }) : null, + ]), + el("div", { class: "value", text: value }), + detail ? el("div", { class: "detail", text: detail }) : null, + ]); +} diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server.js new file mode 100644 index 000000000..8c18d1650 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server.js @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +/* + * Server detail page (#1562) — the per-server drill-down reached by clicking a fleet card. Most sections are + * plain panel DESCRIPTORS run through renderPanel (the #1563 seam). Two are composites the page owns because + * they chain reads or reshape rows: Wait Stats (the table + a trend of the HEAVIEST wait) and File I/O (the + * flat per-(time,database) trend pivoted into one read-latency series per database). + */ + +import { el, mount, readTool, apiGet, loadingStrip, errorStrip, emptyStrip, disclosure, bandClass } from "../util.js"; +import { renderPanel, VIZ } from "../panels.js"; +import { renderLineChart, SERIES_COLORS } from "../charts.js"; + +export function renderServer(main, server) { + const dot = el("span", { class: "dot" }); + const badgeSlot = el("span", { class: "server-band" }); + const head = el("div", { class: "page-head" }, [ + el("a", { href: "#/fleet", text: "← Fleet" }), + el("span", { class: "server-title" }, [dot, el("h2", { text: server })]), + badgeSlot, + ]); + enrichServerHead(dot, badgeSlot, server); + + mount(main, [ + head, + renderPanel({ title: "Overview", read: "get_server_summary", params: { server }, viz: "stat", stats: OVERVIEW_STATS }), + renderPanel({ + title: "CPU Utilization", + subtitle: "last 4h", + read: "get_cpu_utilization", + params: { server, hours_back: 4 }, + viz: "line", + rowsKey: "samples", + xKey: "sample_time", + series: CPU_SERIES, + format: "pct", + unit: "%", + }), + waitsPanel(server), + renderPanel({ + title: "Active Queries", + subtitle: "last 1h", + read: "get_active_queries", + params: { server, hours_back: 1, limit: 50 }, + viz: "table", + rowsKey: "queries", + columns: ACTIVE_COLUMNS, + }), + renderPanel({ + title: "Memory Trend", + subtitle: "last 24h", + read: "get_memory_trend", + params: { server, hours_back: 24 }, + viz: "line", + rowsKey: "trend", + xKey: "time", + series: MEMORY_SERIES, + format: "mb", + }), + fileIoPanel(server), + renderPanel({ + title: "Collection Health", + subtitle: "trailing 7 days", + read: "get_collection_health", + params: { server }, + viz: "table", + rowsKey: "collectors", + columns: COLLECTOR_COLUMNS, + }), + ]); +} + +/* The server header's status dot + band badge come from this server's pre-banded fleet card (one /api/fleet + * read); the header renders immediately and this fills the dot/badge in when the card arrives. */ +function enrichServerHead(dot, badgeSlot, server) { + (async () => { + const res = await apiGet("/api/fleet"); + if (res.kind !== "data") return; + const card = (res.data.cards || []).find((c) => c.server_name === server || c.display_name === server); + if (!card) return; + dot.className = "dot " + bandClass(card.band); + mount(badgeSlot, el("span", { class: "badge " + bandClass(card.band), text: card.status || card.band })); + })(); +} + +/** Query-text cell: a truncated one-liner that expands to the full statement (mono) — the B2 disclosure. */ +function codeDisclosure(text) { + if (text == null || text === "") return document.createTextNode("—"); + return disclosure(text, el("pre", { class: "code" }, [text]), { max: 100 }); +} + +/* ─────────────────────────── composites ─────────────────────────── */ + +function panelShell(title, subtitle) { + const body = el("div", { class: "panel-body" }, [loadingStrip()]); + const panel = el("div", { class: "panel card" }, [ + el("h3", {}, [title, subtitle ? el("span", { class: "panel-sub", text: " " + subtitle }) : null]), + body, + ]); + return { panel, body }; +} + +/* Wait Stats table + a trend line for the single heaviest wait type. */ +function waitsPanel(server) { + const { panel, body } = panelShell("Wait Stats", "last 24h, with a trend for the heaviest wait"); + (async () => { + const res = await readTool("get_wait_stats", { server, hours_back: 24 }); + if (res.kind === "error") return mount(body, errorStrip(res.message)); + if (res.kind === "empty") return mount(body, emptyStrip(res.message)); + + const waits = res.data.waits || []; + const parts = [VIZ.table(res.data, { rowsKey: "waits", columns: WAIT_COLUMNS })]; + + if (waits.length) { + const top = waits[0].wait_type; + parts.push(el("div", { class: "muted", style: "margin-top:0.85rem;margin-bottom:0.3rem", text: "Trend — " + top })); + const trend = await readTool("get_wait_trend", { server, wait_type: top, hours_back: 24 }); + if (trend.kind === "data") { + parts.push( + renderLineChart({ + points: trend.data.trend || [], + xKey: "time", + series: [ + { key: "wait_time_ms_per_second", label: "Wait ms/s", color: SERIES_COLORS[0] }, + { key: "signal_wait_time_ms_per_second", label: "Signal ms/s", color: SERIES_COLORS[1] }, + ], + formatValue: (v) => Math.round(v).toLocaleString(), + unit: "ms/s", + }) + ); + } else { + parts.push(trend.kind === "empty" ? emptyStrip(trend.message) : errorStrip(trend.message)); + } + } + mount(body, parts); + })(); + return panel; +} + +/* File I/O latency: pivot the flat per-(time, database) trend into one read-latency series per database. */ +function fileIoPanel(server) { + const { panel, body } = panelShell("File I/O Latency", "avg read latency per database, last 24h"); + (async () => { + const res = await readTool("get_file_io_trend", { server, hours_back: 24 }); + if (res.kind === "error") return mount(body, errorStrip(res.message)); + if (res.kind === "empty") return mount(body, emptyStrip(res.message)); + + const { points, series } = pivot(res.data.trend || [], { + xKey: "time", + seriesKey: "database_name", + valueKey: "avg_read_latency_ms", + }); + if (!series.length) return mount(body, emptyStrip("No file I/O samples in this window.")); + mount(body, renderLineChart({ points, xKey: "time", series, formatValue: (v) => Math.round(v) + " ms" })); + })(); + return panel; +} + +/** Reshape flat rows into per-series points, keeping the top `maxSeries` series by peak value. */ +function pivot(rows, { xKey, seriesKey, valueKey }, maxSeries = 8) { + const byTime = new Map(); + const peak = new Map(); + for (const r of rows) { + const t = r[xKey]; + const name = r[seriesKey]; + const v = r[valueKey]; + if (t == null || name == null) continue; + if (!byTime.has(t)) byTime.set(t, { [xKey]: t }); + byTime.get(t)[name] = v; + peak.set(name, Math.max(peak.get(name) ?? -Infinity, v ?? -Infinity)); + } + const names = [...peak.keys()].sort((a, b) => peak.get(b) - peak.get(a)).slice(0, maxSeries); + const points = [...byTime.values()].sort((a, b) => String(a[xKey]).localeCompare(String(b[xKey]))); + const series = names.map((n, i) => ({ key: n, label: n, color: SERIES_COLORS[i % SERIES_COLORS.length] })); + return { points, series }; +} + +/* ─────────────────────────── descriptors ─────────────────────────── */ + +const OVERVIEW_STATS = [ + { key: "cpu_percent", label: "CPU", format: "pct" }, + { key: "memory_mb", label: "Memory", format: "mb" }, + { key: "blocking_count", label: "Blocking (recent)", format: "int" }, + { key: "deadlock_count", label: "Deadlocks (recent)", format: "int" }, + { key: "last_collection", label: "Last collection", format: "reltime", small: true }, +]; + +/* Neutral series colors assigned by the chart's ramp (B1) — no severity colors on chart lines. idle_cpu is + dropped (B3): it would force a 0-100 domain and crush the real SQL/other/total series. */ +const CPU_SERIES = [ + { key: "sql_server_cpu", label: "SQL CPU %" }, + { key: "other_process_cpu", label: "Other %" }, + { key: "total_cpu", label: "Total %" }, +]; + +const MEMORY_SERIES = [ + { key: "total_server_memory_mb", label: "Total Server" }, + { key: "target_server_memory_mb", label: "Target" }, + { key: "buffer_pool_mb", label: "Buffer Pool" }, + { key: "plan_cache_mb", label: "Plan Cache" }, +]; + +const WAIT_COLUMNS = [ + { key: "wait_type", label: "Wait Type" }, + { key: "total_wait_time_ms", label: "Total Wait", format: "ms" }, + { key: "resource_wait_ms", label: "Resource", format: "ms" }, + { key: "total_signal_wait_ms", label: "Signal", format: "ms" }, + { key: "waiting_tasks", label: "Tasks", format: "int" }, + { key: "signal_wait_pct", label: "Signal %", format: "num1" }, +]; + +const ACTIVE_COLUMNS = [ + { key: "collection_time", label: "Time", format: "time" }, + { key: "session_id", label: "SPID", format: "int" }, + { key: "database_name", label: "Database" }, + { key: "status", label: "Status" }, + { key: "cpu_time_ms", label: "CPU", format: "ms" }, + { key: "elapsed_time_formatted", label: "Elapsed" }, + { key: "wait_type", label: "Wait" }, + { key: "blocking_session_id", label: "Blocked by", format: "int" }, + { key: "query_text", label: "Query", render: (r) => codeDisclosure(r.query_text) }, +]; + +const COLLECTOR_COLUMNS = [ + { key: "collector", label: "Collector" }, + { key: "status", label: "Status", statusSev: true }, + { key: "total_runs", label: "Runs", format: "int" }, + { key: "errors", label: "Errors", format: "int" }, + { key: "failure_rate_pct", label: "Failure %", format: "num1" }, + { key: "avg_duration_ms", label: "Avg Dur", format: "ms" }, + { key: "last_success", label: "Last Success", format: "time" }, + { key: "last_error", label: "Last Error", wrap: true }, +]; diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/views.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/views.js new file mode 100644 index 000000000..bb185a9fc --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/views.js @@ -0,0 +1,604 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +/* + * Custom Views (#1563): the list page and the renderer. A view is stored JSON {panels:[<renderPanel descriptor>]} + * authored in the composer (editor.js); this page RENDERS it by mapping each stored panel through the UNMODIFIED + * renderPanel into a `.panel-grid` — so a stored dashboard uses the exact same viz registry the built-in pages do + * (per-panel isolation is free: renderPanel error-strips its own fetch/throw/unknown-viz). Before calling + * renderPanel, each panel's read is checked against the cached catalog and its viz against the VIZ registry, so a + * stale/unknown read renders a clean "unknown read" strip instead of an opaque 404 inside the panel. + * + * Edit affordances (New / Edit / Delete / Import) are shown when the session reports can_edit (the server is the + * authority for every write); RENDER and EXPORT are available to every seat. All user text (names, + * descriptions) reaches the DOM through el()/textContent (R4 — never innerHTML). + */ + +import { el, mount, apiGet, loadingStrip, errorStrip, emptyStrip, relTime } from "../util.js"; +import { renderPanel, VIZ } from "../panels.js"; +import { renderComposedPanelCard } from "../compose.js"; +import { renderMarkdown } from "../markdown.js"; +import { NOTEBOOK_TEMPLATES, isNotebookDefinition } from "../notebook.js"; +import * as api from "../views-api.js"; + +/** The time-range choices the rendered view's chrome offers (mirrors the composer's RANGE_OPTIONS). */ +const VIEW_RANGE_OPTIONS = [ + { hours: 1, label: "Last hour" }, + { hours: 6, label: "Last 6 hours" }, + { hours: 24, label: "Last 24 hours" }, + { hours: 24 * 7, label: "Last 7 days" }, + { hours: 24 * 30, label: "Last 30 days" }, + { hours: 24 * 90, label: "Last 90 days" }, +]; + +/* ─────────────────────────── list page ─────────────────────────── */ + +export async function renderViewList(main) { + mount(main, [listHead(false), loadingStrip("Loading views…")]); + + const [session, res] = await Promise.all([api.getSession(), api.listViews()]); + const canEdit = !!session.can_edit; + + if (res.kind === "error") { + mount(main, [listHead(canEdit), errorStrip(res.message)]); + return; + } + + const views = Array.isArray(res.data) ? res.data : []; + const nodes = [listHead(canEdit)]; + + /* Empty list -> a real first-run hero that sells the feature (M4), not a bare strip. The hero carries its own + New view + Import affordances (can_edit only), so the standalone import panel is only shown once views exist. */ + if (!views.length) { + nodes.push(firstRunHero(canEdit)); + mount(main, nodes); + return; + } + + if (canEdit) { + nodes.push(importPanel()); + } + + nodes.push(el("div", { class: "view-cards" }, views.map((v) => viewCard(v)))); + mount(main, nodes); +} + +/* First-run hero (M4): a centered pitch + a prominent New view + a few starting-point ideas + the (secondary) + import. A read-only network seat can't compose, so it gets an explanatory variant (no New view / import). */ +function firstRunHero(canEdit) { + if (!canEdit) { + return el("div", { class: "views-hero" }, [ + el("div", { class: "hero-title", text: "No custom views yet" }), + el("div", { + class: "hero-pitch", + text: + "Your session couldn't be confirmed, so the composer is read-only right now — reload the page to create a " + + "view. Once created, custom views appear here for every seat to open and export.", + }), + ]); + } + const suggestions = ["Top waits by server", "CPU trend over time", "Slowest procedures by database"]; + return el("div", { class: "views-hero" }, [ + el("div", { class: "hero-title", text: "Compose your own dashboards & notebooks" }), + el("div", { + class: "hero-pitch", + text: + "Build a DASHBOARD — pick a metric, aggregate it, group and filter it, and chart it as a line, bar, pie, " + + "stacked area, or stat — or a NOTEBOOK that mixes markdown notes with those same charts into a guided " + + "investigation. Both save for every seat and re-scope live by server and time range. No SQL to write.", + }), + el("div", { class: "hero-ctas" }, [ + el("a", { class: "btn primary hero-cta", href: "#/view/new", text: "New view" }), + el("a", { class: "btn hero-cta", href: "#/notebook/new", text: "New notebook" }), + templateMenu(), + ]), + el("div", { class: "hero-suggest" }, [ + el("span", { class: "hero-suggest-label", text: "Popular starting points" }), + el("div", { class: "hero-suggest-chips" }, suggestions.map((s) => el("span", { class: "hero-chip", text: s }))), + ]), + importPanel(), + ]); +} + +function listHead(canEdit) { + return el("div", { class: "page-head" }, [ + el("h2", { text: "Custom Views" }), + el("div", { class: "spacer" }), + canEdit ? templateMenu() : null, + canEdit ? el("a", { class: "btn", href: "#/notebook/new", text: "New notebook" }) : null, + canEdit ? el("a", { class: "btn primary", href: "#/view/new", text: "New view" }) : null, + ]); +} + +/* The "New from template" menu (#1563 D7): a <details> dropdown of the seed notebooks, each linking to the + notebook composer pre-filled from that template. Built with el()/textContent like everything else. */ +function templateMenu() { + const items = NOTEBOOK_TEMPLATES.map((t) => + el("a", { class: "template-item", href: "#/notebook/new/" + encodeURIComponent(t.key), title: t.description }, [ + el("span", { class: "template-label", text: t.label }), + el("span", { class: "template-desc", text: t.description }), + ]) + ); + return el("details", { class: "template-menu" }, [ + el("summary", { class: "btn template-summary", text: "New from template ▾" }), + el("div", { class: "template-list" }, items), + ]); +} + +function viewCard(v) { + const isNotebook = v.kind === "notebook"; + const meta = + "v" + v.version + " · updated " + relTime(v.updated_at) + (v.updated_by ? " by " + v.updated_by : ""); + const chips = el("div", { class: "vc-chips" }); + /* The list summary carries a concrete `kind` (backend #56), so the link + "Notebook" badge are set SYNCHRONOUSLY + here — no wait on the per-card definition fetch (which only fills in the count chips). */ + const href = (isNotebook ? "#/notebook/" : "#/view/") + encodeURIComponent(v.id); + const card = el("a", { class: "view-card card", href }, [ + el("div", { class: "vc-name" }, [ + el("span", { class: "vc-name-text", text: v.name }), + isNotebook ? el("span", { class: "notebook-badge", text: "Notebook" }) : null, + ]), + v.description ? el("div", { class: "vc-desc", text: v.description }) : null, + chips, + el("div", { class: "vc-meta", text: meta }), + ]); + enrichCard(v.id, chips, isNotebook); + return card; +} + +/* The list summary carries `kind` (link + badge already set in viewCard) but NOT the panel/cell counts, so those + are fetched per card and filled in progressively — the card renders immediately and never blocks on this fetch, + and a failed/empty fetch just leaves the (display:none-when-empty) chip row absent. `isNotebook` comes from the + summary; the fetched definition is a robust fallback for the count shape. */ +async function enrichCard(id, chips, isNotebook) { + const res = await api.getView(id); + if (res.kind !== "data" || !res.data) return; + const def = res.data.definition || {}; + + if (isNotebook || isNotebookDefinition(def)) { + const cells = Array.isArray(def.cells) ? def.cells : []; + if (!cells.length) return; + const panelCount = cells.filter((c) => c && c.type === "panel").length; + const nodes = [el("span", { class: "vc-chip count", text: cells.length + (cells.length === 1 ? " cell" : " cells") })]; + if (panelCount) nodes.push(el("span", { class: "vc-chip", text: panelCount + (panelCount === 1 ? " panel" : " panels") })); + mount(chips, nodes); + return; + } + + const panels = Array.isArray(def.panels) ? def.panels : []; + if (!panels.length) return; + const vizTypes = []; + for (const p of panels) { + const viz = p && typeof p.viz === "string" ? p.viz : null; + if (viz && !vizTypes.includes(viz)) vizTypes.push(viz); + } + const nodes = [ + el("span", { class: "vc-chip count", text: panels.length + (panels.length === 1 ? " panel" : " panels") }), + ]; + for (const viz of vizTypes) nodes.push(el("span", { class: "vc-chip viz", text: viz })); + mount(chips, nodes); +} + +/* Paste-to-import (can_edit only): parse -> client-validate against the catalog -> POST (backend re-validates). + Accepts an exported view/notebook {name, description?, definition} or a bare definition ({panels:[…]} dashboard + or {kind:"notebook", cells:[…]} notebook) plus a name. */ +function importPanel() { + const textarea = el("textarea", { + class: "import-box", + rows: "4", + placeholder: 'Paste exported view or notebook JSON — {"name": "...", "definition": { ... }}', + "aria-label": "Paste exported view or notebook JSON", + }); + const status = el("div", { class: "import-status" }); + const importBtn = el("button", { class: "btn", type: "button", text: "Import" }); + + importBtn.addEventListener("click", async () => { + mount(status, null); + const raw = textarea.value.trim(); + if (!raw) { + mount(status, errorStrip("Paste a view's JSON first.")); + return; + } + + let parsed; + try { + parsed = JSON.parse(raw); + } catch (e) { + mount(status, errorStrip("That is not valid JSON: " + (e && e.message ? e.message : String(e)))); + return; + } + + const def = + parsed && parsed.definition ? parsed.definition : parsed && (parsed.panels || parsed.cells) ? parsed : null; + if (!def) { + mount(status, errorStrip("The JSON must contain a 'definition' (or be a definition with 'panels' or 'cells').")); + return; + } + const name = (parsed.name || "").trim(); + if (!name) { + mount(status, errorStrip('The imported JSON has no "name". Add a top-level "name" field and try again.')); + return; + } + + const catalog = await api.getCatalog(); + const invalid = api.validateDefinition(def, catalog); + if (invalid) { + mount(status, errorStrip(invalid)); + return; + } + + importBtn.disabled = true; + mount(status, el("div", { class: "strip loading", text: "Importing…" })); + const result = await api.createView({ name, description: parsed.description || null, definition: def }); + importBtn.disabled = false; + if (result.kind === "data" && result.data) { + const route = isNotebookDefinition(def) ? "#/notebook/" : "#/view/"; + location.hash = route + encodeURIComponent(result.data.id); + return; + } + mount(status, errorStrip(result.message || "Could not import.")); + }); + + return el("details", { class: "import-panel" }, [ + el("summary", { text: "Import a view or notebook" }), + el("div", { class: "import-body" }, [textarea, el("div", { class: "import-actions" }, [importBtn]), status]), + ]); +} + +/* ─────────────────────────── renderer ─────────────────────────── */ + +export async function renderView(main, id) { + mount(main, loadingStrip("Loading view…")); + + const [session, catalog, res, fleetRes] = await Promise.all([ + api.getSession(), + api.getCatalog(), + api.getView(id), + apiGet("/api/fleet"), + ]); + if (res.kind === "error") { + mount(main, [el("div", { class: "page-head" }, [backToViews(), el("h2", { text: "View" })]), errorStrip(res.message)]); + return; + } + + const view = res.data || {}; + const def = view.definition || {}; + + /* A notebook is the same stored row with a kind:"notebook" definition — render it as a document, not a grid. */ + if (isNotebookDefinition(def)) { + renderNotebookDoc(main, view, session, catalog, fleetRes); + return; + } + + const panels = Array.isArray(def.panels) ? def.panels : []; + const canEdit = !!session.can_edit; + const readSet = new Set((catalog.reads || []).map((r) => r.name)); + const sourceSet = new Set(((catalog.compose || {}).measures || []).map((m) => m.source)); + + const status = el("div", { class: "view-status" }); + const head = el("div", { class: "page-head" }, [ + backToViews(), + el("h2", { text: view.name || "View" }), + view.description ? el("div", { class: "meta", text: view.description }) : null, + el("div", { class: "spacer" }), + exportButton(view), + canEdit ? el("a", { class: "btn small", href: "#/view/" + encodeURIComponent(id) + "/edit", text: "Edit" }) : null, + canEdit ? deleteButton(id, status) : null, + ]); + + if (!panels.length) { + mount(main, [head, status, emptyStrip("This view has no panels yet.")]); + return; + } + + /* View-level scope (Erik's Decision 1): server / time / template-variable controls at the top re-scope EVERY + panel at once. The scope only affects composed panels (v1 read panels carry their own fixed params); flipping + it re-runs the whole grid. `state` is the live control state; currentScope() snapshots it for a run. */ + const fleet = fleetOptions(fleetRes); + const variables = Array.isArray(def.variables) ? def.variables.filter((v) => v && v.name) : []; + const defaultHours = def.range && typeof def.range.hours === "number" ? def.range.hours : 24; + const hasComposed = panels.some((p) => p && p.source != null); + + const state = { server: "All", hours: defaultHours, values: {} }; + for (const v of variables) { + if (v.dimension !== "server" && v.default) state.values[v.name] = v.default; + } + + function currentScope() { + return { + server: state.server, + hours: state.hours, + variables: variables.map((v) => ({ name: v.name, dimension: v.dimension, default: v.default || undefined })), + values: { ...state.values }, + }; + } + + const gridBox = el("div", { class: "panel-grid" }); + function renderGrid() { + mount(gridBox, panels.map((p) => panelOrError(p, readSet, sourceSet, currentScope()))); + } + + /* The chrome is only meaningful when a composed panel can re-scope; a pure v1 read view skips it. */ + const controls = hasComposed ? buildViewControls(fleet, variables, state, defaultHours, renderGrid) : null; + + mount(main, [head, status, controls, gridBox]); + renderGrid(); +} + +/* ─────────────────────────── notebook renderer (#1563 D7) ─────────────────────────── */ + +/* + * Render a notebook definition as a vertical DOCUMENT: markdown cells through the air-gapped renderMarkdown, panel + * cells through the SAME composed-panel card + validation the dashboard grid uses (panelOrError). It reuses this + * page's server/time/variable scope bar (buildViewControls) exactly as a dashboard does, so flipping the scope + * re-scopes every panel cell at once. Export/Delete/Edit are shared with the dashboard renderer; Edit points at the + * notebook composer route. + */ +async function renderNotebookDoc(main, view, session, catalog, fleetRes) { + const def = view.definition || {}; + const cells = Array.isArray(def.cells) ? def.cells : []; + const canEdit = !!session.can_edit; + const readSet = new Set((catalog.reads || []).map((r) => r.name)); + const sourceSet = new Set(((catalog.compose || {}).measures || []).map((m) => m.source)); + + const status = el("div", { class: "view-status" }); + const head = el("div", { class: "page-head" }, [ + backToViews(), + el("h2", { text: view.name || "Notebook" }), + el("span", { class: "notebook-badge", text: "Notebook" }), + view.description ? el("div", { class: "meta", text: view.description }) : null, + el("div", { class: "spacer" }), + exportButton(view), + canEdit ? el("a", { class: "btn small", href: "#/notebook/" + encodeURIComponent(view.id) + "/edit", text: "Edit" }) : null, + canEdit ? deleteButton(view.id, status) : null, + ]); + + if (!cells.length) { + mount(main, [head, status, emptyStrip("This notebook has no cells yet.")]); + return; + } + + /* Scope bar identical to a dashboard's: it only drives the panel cells (markdown is static). Skipped when a + notebook is prose-only (no panel cells to re-scope). */ + const fleet = fleetOptions(fleetRes); + const variables = Array.isArray(def.variables) ? def.variables.filter((v) => v && v.name) : []; + const defaultHours = def.range && typeof def.range.hours === "number" ? def.range.hours : 24; + const hasPanels = cells.some((c) => c && c.type === "panel" && c.source != null); + + const state = { server: "All", hours: defaultHours, values: {} }; + for (const v of variables) { + if (v.dimension !== "server" && v.default) state.values[v.name] = v.default; + } + + function currentScope() { + return { + server: state.server, + hours: state.hours, + variables: variables.map((v) => ({ name: v.name, dimension: v.dimension, default: v.default || undefined })), + values: { ...state.values }, + }; + } + + const docBox = el("div", { class: "notebook-doc" }); + function renderDoc() { + mount(docBox, cells.map((cell) => renderCell(cell, readSet, sourceSet, currentScope()))); + } + + const controls = hasPanels ? buildViewControls(fleet, variables, state, defaultHours, renderDoc) : null; + mount(main, [head, status, controls, docBox]); + renderDoc(); +} + +/* One notebook cell -> a document block: a markdown cell renders through renderMarkdown (XSS-safe); a panel cell + through the shared panelOrError (unknown source/viz -> a clean strip, exactly as in the dashboard grid). */ +function renderCell(cell, readSet, sourceSet, scope) { + if (!cell || typeof cell !== "object") return null; + if (cell.type === "markdown") { + return el("div", { class: "notebook-md markdown-body" }, [renderMarkdown(cell.text)]); + } + if (cell.type === "panel") { + return el("div", { class: "notebook-panel" }, [panelOrError(cell, readSet, sourceSet, scope)]); + } + return null; +} + +function backToViews() { + return el("a", { href: "#/views", text: "← Views" }); +} + +/* Fleet server options for the scope picker (value = stored server_name, label = display name). */ +function fleetOptions(fleetRes) { + if (fleetRes.kind !== "data" || !fleetRes.data) return []; + return [...(fleetRes.data.cards || [])] + .map((c) => ({ value: c.server_name || c.display_name, label: c.display_name })) + .filter((o) => o.value) + .sort((a, b) => a.label.localeCompare(b.label)); +} + +/* The view-scope control bar: a server scope picker (single / multi / All), a time-range select, and a value input + per declared template variable (a server-typed variable is covered by the scope picker, so it is not repeated). */ +function buildViewControls(fleet, variables, state, defaultHours, onChange) { + const fields = [ + el("div", { class: "vc-field" }, [el("span", { class: "vc-label", text: "Servers" }), buildServerScopePicker(fleet, state, onChange)]), + el("div", { class: "vc-field" }, [el("span", { class: "vc-label", text: "Time range" }), buildRangeSelect(defaultHours, state, onChange)]), + ]; + for (const v of variables) { + if (v.dimension === "server") continue; + fields.push(el("div", { class: "vc-field" }, [el("span", { class: "vc-label", text: "$" + v.name }), varControl(v, state, onChange)])); + } + return el("div", { class: "view-controls" }, fields); +} + +/* A template-variable value input. Bound to state.values on `change` (not each keystroke, so the grid re-runs on + commit, not per character). Empty clears the binding (the panels referencing it then return no rows — the honest + "pick a value" state, since the compose contract has no wildcard for an unbound $var). */ +function varControl(v, state, onChange) { + const inp = el("input", { class: "filter-box", type: "text", placeholder: "(pick a value)", "aria-label": v.name }); + inp.value = state.values[v.name] || ""; + inp.addEventListener("change", () => { + const val = inp.value.trim(); + if (val) state.values[v.name] = val; + else delete state.values[v.name]; + onChange(); + }); + return inp; +} + +/* A native <select> time-range picker bound to state.hours. A stored default outside the standard options is added + so the view opens on exactly what it declared. */ +function buildRangeSelect(defaultHours, state, onChange) { + const sel = el("select", { class: "filter-box", "aria-label": "Time range" }); + for (const r of VIEW_RANGE_OPTIONS) sel.appendChild(el("option", { value: String(r.hours), text: r.label })); + if (!VIEW_RANGE_OPTIONS.some((r) => r.hours === defaultHours)) { + sel.appendChild(el("option", { value: String(defaultHours), text: "Default (" + defaultHours + "h)" })); + } + sel.value = String(state.hours); + sel.addEventListener("change", () => { + state.hours = parseInt(sel.value, 10) || defaultHours; + onChange(); + }); + return sel; +} + +/* The server scope picker: a <details> dropdown of checkboxes — "All servers (fleet)" plus each fleet server. + state.server is "All" (or an empty selection ⇒ fleet) or an array of names; checking a server switches off All, + and clearing every server falls back to All. <details> gives open/close + keyboard for free (no outside-click JS). */ +function buildServerScopePicker(fleet, state, onChange) { + const summary = el("summary", { class: "scope-summary" }); + const list = el("div", { class: "scope-list" }); + + const isAll = () => state.server === "All" || (Array.isArray(state.server) && state.server.length === 0); + const names = () => (Array.isArray(state.server) ? state.server : []); + const labelFor = (val) => { + const o = fleet.find((x) => x.value === val); + return o ? o.label : val; + }; + + function toggle(val, on) { + let arr = isAll() ? [] : [...names()]; + if (on) { + if (!arr.includes(val)) arr.push(val); + } else { + arr = arr.filter((x) => x !== val); + } + state.server = arr.length ? arr : "All"; + } + + function refresh() { + const n = names(); + summary.textContent = isAll() ? "All servers (fleet)" : n.length === 1 ? labelFor(n[0]) : n.length + " servers"; + } + + function redraw() { + const rows = [ + checkRow("All servers (fleet)", isAll(), () => { + state.server = "All"; + redraw(); + onChange(); + }), + ]; + for (const o of fleet) { + const on = !isAll() && names().includes(o.value); + rows.push( + checkRow(o.label, on, () => { + toggle(o.value, !on); + redraw(); + onChange(); + }) + ); + } + mount(list, rows); + refresh(); + } + + redraw(); + return el("details", { class: "scope-picker" }, [summary, list]); +} + +function checkRow(label, checked, onToggle) { + const box = el("input", { type: "checkbox", class: "editor-check" }); + box.checked = checked; + box.addEventListener("change", onToggle); + return el("label", { class: "scope-item" }, [box, el("span", { text: label })]); +} + +/* Pre-check a panel against the catalog so an unknown read/source/viz is a clean strip, not an opaque 404 / crash. + A composed panel (names a `source`) runs through /api/compose/run with the view scope; a read panel through the + v1 renderPanel (its own fixed params, scope-independent). */ +function panelOrError(p, readSet, sourceSet, scope) { + if (!p || typeof p !== "object") { + return panelErrorCard("Invalid panel", "This panel is not an object."); + } + if (p.path != null) { + return panelErrorCard(p.title, "This panel uses raw 'path' mode, which is not supported."); + } + if (p.source != null) { + if (!sourceSet.has(p.source)) { + return panelErrorCard(p.title, "Unknown source '" + p.source + "'. It may have been renamed or removed."); + } + if (!p.viz) { + return panelErrorCard(p.title, "This composed panel has no chart type."); + } + return renderComposedPanelCard(p, scope); + } + if (!p.read || !readSet.has(p.read)) { + return panelErrorCard(p.title, "Unknown read '" + (p.read || "") + "'. It may have been renamed or removed."); + } + if (!p.viz || !VIZ[p.viz]) { + return panelErrorCard(p.title, "Unknown visualization '" + (p.viz || "") + "'."); + } + return renderPanel(p); +} + +function panelErrorCard(title, message) { + return el("div", { class: "panel card" }, [ + el("h3", {}, [title || "Panel"]), + el("div", { class: "panel-body" }, [errorStrip(message)]), + ]); +} + +/* Export = a runtime Blob download of {name, description?, definition} (re-importable). Available to every seat; + the Blob URL is created at runtime (never a source literal), so the air-gap scan stays green. */ +function exportButton(view) { + const btn = el("button", { class: "btn small", type: "button", text: "Export" }); + btn.addEventListener("click", () => { + const payload = { name: view.name, description: view.description || null, definition: view.definition || {} }; + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = el("a", { href: url, download: safeFileName(view.name) + ".json" }); + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + }); + return btn; +} + +function safeFileName(name) { + const cleaned = String(name || "view").replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_+|_+$/g, ""); + return cleaned || "view"; +} + +function deleteButton(id, status) { + const btn = el("button", { class: "btn small danger", type: "button", text: "Delete" }); + btn.addEventListener("click", async () => { + if (!window.confirm("Delete this view? This cannot be undone.")) { + return; + } + btn.disabled = true; + mount(status, el("div", { class: "strip loading", text: "Deleting…" })); + const res = await api.deleteView(id); + if (res.kind === "data") { + location.hash = "#/views"; + return; + } + btn.disabled = false; + mount(status, errorStrip(res.message || "Could not delete the view.")); + }); + return btn; +} diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js new file mode 100644 index 000000000..f30431a1c --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +/* + * The panel renderer + viz registry (#1562) — the #1563 seam. Every data view on the server/alerts pages is a + * PANEL DESCRIPTOR run through renderPanel({read, params, viz, span, ...}): + * read — an MCP tool name (-> GET /api/read/{read}) or, via `path`, a raw API path (-> GET path) + * params — query-string params + * viz — one of the registry keys below: "table" | "line" | "stat" | "bandlist" + * span — 2 to span both columns of a grid + * Phase-1 pages hand-build descriptor ARRAYS (js/pages/*). #1563 later replaces those arrays with stored JSON + * loaded through this same renderPanel — so nothing downstream of here is built now, but the seam is stable. + * + * R4 (XSS): every value reaches the DOM through util.el()'s text/textContent path — the table cells, stat + * values, band rows, and chart tooltip never touch innerHTML, so query text / object names / wait types / + * server names render inert. + */ + +import { + el, + mount, + loadingStrip, + errorStrip, + emptyStrip, + readTool, + apiGet, + buildQuery, + getPath, + applyFormat, + bandClass, + sevClass, +} from "./util.js"; +import { renderLineChart, SERIES_COLORS } from "./charts.js"; + +/** + * Build a panel node. It returns immediately with a loading strip and fills itself once the fetch resolves, + * mapping the three API response kinds (data / empty envelope / error) to the right UI. + */ +export function renderPanel(desc) { + const body = el("div", { class: "panel-body" }, [loadingStrip()]); + const panel = el("div", { class: "panel card" + (desc.span === 2 ? " span-2" : "") }, [ + el("h3", {}, [desc.title, desc.subtitle ? el("span", { class: "panel-sub", text: " " + desc.subtitle }) : null]), + body, + ]); + loadPanel(desc, body); + return panel; +} + +async function loadPanel(desc, body) { + const res = desc.read ? await readTool(desc.read, desc.params) : await apiGet(desc.path + buildQuery(desc.params)); + + if (res.kind === "error") { + mount(body, errorStrip(res.message)); + return; + } + if (res.kind === "empty") { + mount(body, emptyStrip(res.message)); + return; + } + + const render = VIZ[desc.viz]; + if (!render) { + mount(body, errorStrip("Unknown visualization: " + desc.viz)); + return; + } + try { + mount(body, render(res.data, desc)); + } catch (e) { + mount(body, errorStrip("Could not render this panel: " + (e && e.message ? e.message : String(e)))); + } +} + +/* ─────────────────────────── viz registry ─────────────────────────── */ + +/** + * The viz registry — the four phase-1 renderers. Each is (data, desc) -> Node. Adding a viz here is the ONLY + * change #1563 needs to grow the descriptor vocabulary. + */ +export const VIZ = { + table: vizTable, + line: vizLine, + stat: vizStat, + bandlist: vizBandlist, +}; + +/** + * A descriptor whose load-bearing field array (table.columns / stat.stats / line.series) is missing or empty — + * a stored/imported/AI-drafted/older-JSON panel that never got a field-config. Rather than let `.map` throw a raw + * "Cannot read properties of undefined" at EVERY seat (including the read-only network viewer), each viz below + * guards its array and renders this instead. The shared renderer must tolerate any structurally-valid-but- + * incomplete descriptor. + */ +const NO_FIELDS_MSG = "No fields configured — edit this view and run Auto-detect fields."; + +/* table: desc = { rowsKey, columns:[{key,label,format,align,wrap,mono,sevKey,statusSev}] } */ +function vizTable(data, desc) { + const cols = Array.isArray(desc.columns) ? desc.columns : []; + if (!cols.length) return emptyStrip(NO_FIELDS_MSG); + const rows = getPath(data, desc.rowsKey) || []; + if (!rows.length) return emptyStrip(desc.emptyText || "No rows in this window."); + + const head = el( + "tr", + {}, + cols.map((c) => el("th", { text: c.label, class: isNumericCol(c) ? "num" : null })) + ); + const bodyRows = rows.map((row) => el("tr", {}, cols.map((c) => cell(row, c)))); + + return el("div", { class: "table-wrap" }, [ + el("table", { class: "data" }, [el("thead", {}, [head]), el("tbody", {}, bodyRows)]), + ]); +} + +function isNumericCol(c) { + return c.align === "right" || ["int", "num1", "num2", "ms", "mb", "pct"].includes(c.format); +} + +function cell(row, c) { + /* A column may supply a custom cell renderer (row) -> Node — used for the alert status/detail columns and the + query-text expander. It owns its own content; wrap/mono classes still apply if the column asks for them. */ + if (typeof c.render === "function") { + const rcls = []; + if (c.wrap) rcls.push("wrap"); + if (c.mono) rcls.push("mono"); + return el("td", { class: rcls.join(" ") || null }, [c.render(row)]); + } + const raw = getPath(row, c.key); + const cls = []; + if (isNumericCol(c)) cls.push("num"); + if (c.wrap) cls.push("wrap"); + if (c.mono) cls.push("mono"); + if (c.sevKey) cls.push(sevClass(getPath(row, c.sevKey))); + if (c.statusSev) cls.push(sevClass(statusToSev(raw))); + const text = c.format ? applyFormat(c.format, raw) : raw == null || raw === "" ? "—" : String(raw); + return el("td", { class: cls.join(" ") || null, text }); +} + +/* stat: desc = { stats:[{key,label,format,small?,sev?}] } over the tool's top-level object. A stat descriptor may + carry a PRE-COMPUTED severity (`sev`/`severity`, e.g. "Critical") — colored here from that hint only (R1: the + browser never re-derives a band); absent the hint the value keeps the default color. */ +function vizStat(data, desc) { + const stats = Array.isArray(desc.stats) ? desc.stats : []; + if (!stats.length) return emptyStrip(NO_FIELDS_MSG); + return el( + "div", + { class: "stats" }, + stats.map((s) => { + const sev = s.sev || s.severity; + const valueClass = "value" + (s.small ? " small" : "") + (sev ? " " + sevClass(sev) : ""); + return el("div", { class: "stat" }, [ + el("div", { class: valueClass, text: applyFormat(s.format, getPath(data, s.key)) }), + el("div", { class: "label", text: s.label }), + ]); + }) + ); +} + +/* line: desc = { rowsKey, xKey, series:[{key,label,color?}], format? } */ +function vizLine(data, desc) { + const seriesCfg = Array.isArray(desc.series) ? desc.series : []; + if (!seriesCfg.length) return emptyStrip(NO_FIELDS_MSG); + const points = getPath(data, desc.rowsKey) || []; + const series = seriesCfg.map((s, i) => ({ + key: s.key, + label: s.label, + color: s.color || SERIES_COLORS[i % SERIES_COLORS.length], + })); + const formatValue = desc.format ? (v) => applyFormat(desc.format, v) : (v) => String(Math.round(v)); + /* Percentage charts cap the y-domain at 100 so a 96% reading never rounds the axis up past 100% (B3). */ + const clampMax = desc.clampMax ?? (desc.format === "pct" ? 100 : null); + return renderLineChart({ points, xKey: desc.xKey, series, formatValue, clampMax, unit: desc.unit ?? null }); +} + +/* bandlist: desc = { rowsKey, primaryKey, bandKey, bandLabelKey?, reasonKey?, navKey?, emptyText? } */ +function vizBandlist(data, desc) { + const rows = getPath(data, desc.rowsKey) || []; + if (!rows.length) return emptyStrip(desc.emptyText || "Nothing to show."); + return el( + "div", + { class: "bandlist" }, + rows.map((r) => { + const band = getPath(r, desc.bandKey); + const props = { class: "row " + bandClass(band) }; + if (desc.navKey) { + const target = getPath(r, desc.navKey); + if (target) props.onActivate = () => navigateServer(target); + } + return el("div", props, [ + el("span", { class: "dot " + bandClass(band) }), + el("span", { class: "primary", text: getPath(r, desc.primaryKey) }), + desc.reasonKey ? el("span", { class: "reason", text: getPath(r, desc.reasonKey) }) : null, + band ? el("span", { class: "badge " + bandClass(band), text: getPath(r, desc.bandLabelKey) || band }) : null, + ]); + }) + ); +} + +/* ─────────────────────────── shared helpers ─────────────────────────── */ + +/** Set the hash route to a server's detail page. */ +export function navigateServer(serverName) { + location.hash = "#/server/" + encodeURIComponent(serverName); +} + +/** + * Map a collector/status STRING (already computed server-side) to a severity CSS class — this is coloring a + * pre-computed label, not re-deriving a band from raw metrics (R1: the browser never re-computes thresholds). + */ +export function statusToSev(status) { + switch (String(status || "").toUpperCase()) { + case "HEALTHY": + case "OK": + case "SUCCESS": + case "ONLINE": + return "Healthy"; + case "STALE": + case "WARNING": + case "PERMISSIONS": + case "SKIPPED": + return "Warning"; + case "FAILING": + case "ERROR": + case "OFFLINE": + return "Critical"; + default: + return "Unknown"; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js new file mode 100644 index 000000000..6686f7540 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js @@ -0,0 +1,326 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +/* + * Shared leaf utilities for Darling Web (#1562): DOM builders, UTC->local time, value formatters, and the API + * fetch helper. This module imports nothing (it is the base of the module DAG: app/panels/charts/pages all + * import it, so there is no import cycle). Two rules are enforced HERE so every caller inherits them: + * R4 (XSS): the el() builder assigns untrusted text ONLY through textContent / text nodes; it throws if a + * caller ever tries to pass raw HTML, so no data path can reach innerHTML. + * R5 (time): every timestamp from the API is naive UTC ISO-8601 (no zone suffix) — parseUtc() appends 'Z' + * so the browser reads it as UTC, then formatting localizes it to the viewer's zone. + */ + +/* ─────────────────────────── DOM builders (textContent-only) ─────────────────────────── */ + +/** + * Build an element. `props` supports: class, text (safe textContent), onClick, dataset (object), and any + * other key set as an attribute. `children` may be nodes, strings (-> text nodes), arrays, or null/false + * (skipped). There is deliberately NO html/innerHTML affordance — untrusted data can only ever become text. + */ +export function el(tag, props = {}, children = []) { + const node = document.createElement(tag); + for (const [k, v] of Object.entries(props || {})) { + if (v == null) continue; + if (k === "class") node.className = v; + else if (k === "text") node.textContent = v; // safe: text, never markup + else if (k === "html") throw new Error("el(): the 'html' prop is banned — render data as text (R4/XSS)."); + else if (k === "onClick") node.addEventListener("click", v); + else if (k === "onActivate") makeActivatable(node, v); + else if (k === "dataset") Object.assign(node.dataset, v); + else node.setAttribute(k, v); + } + appendChildren(node, children); + return node; +} + +/** + * Make a non-button element behave like a button for keyboard users: role=button, focusable, and Enter/Space + * activate it exactly like a click. Used by the clickable-div affordances (fleet cards, band rows, sidebar + * servers) so they get a keyboard path + a :focus-visible ring without becoming real <button>s (a11y). + */ +function makeActivatable(node, handler) { + node.setAttribute("role", "button"); + node.setAttribute("tabindex", "0"); + node.addEventListener("click", handler); + node.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handler(e); + } + }); +} + +function appendChildren(node, children) { + if (children == null || children === false) return; + if (Array.isArray(children)) { + for (const c of children) appendChildren(node, c); + return; + } + if (children instanceof Node) { + node.appendChild(children); + return; + } + node.appendChild(document.createTextNode(String(children))); // safe: text node +} + +/** Remove all children from a node. */ +export function clear(node) { + while (node.firstChild) node.removeChild(node.firstChild); + return node; +} + +/** Replace a container's contents with `content` (a node or array of nodes). */ +export function mount(container, content) { + clear(container); + appendChildren(container, content); + return container; +} + +/** Collapse whitespace/newlines to single spaces and truncate to `max` chars with an ellipsis. */ +export function truncate(s, max = 120) { + const flat = String(s == null ? "" : s).replace(/\s+/g, " ").trim(); + return flat.length > max ? flat.slice(0, max - 1) + "…" : flat; +} + +/** + * A native <details> disclosure: a one-line truncated `summaryText` that expands to reveal `detail`. Built on + * the platform <details>/<summary> so it is keyboard-accessible with no JS handlers; every piece of text goes + * through el()/textContent (R4 — never innerHTML). `detail` may be a node, an array of nodes, or a string. + */ +export function disclosure(summaryText, detail, opts = {}) { + const summary = el("summary", { class: "disc-summary", title: opts.title || null }, [ + truncate(summaryText, opts.max || 120), + ]); + return el("details", { class: "disclosure" }, [summary, el("div", { class: "disc-body" }, detail)]); +} + +/* ─────────────────────────── state strips ─────────────────────────── */ + +export function errorStrip(message) { + return el("div", { class: "strip error", role: "alert" }, [message]); +} +export function emptyStrip(message) { + return el("div", { class: "strip empty" }, [message]); +} +export function loadingStrip(label) { + return el("div", { class: "strip loading" }, [label || "Loading…"]); +} + +/* ─────────────────────────── time (UTC -> local) ─────────────────────────── */ + +/** Parse a naive-UTC ISO string (no zone) as UTC; pass through strings that already carry a zone. */ +export function parseUtc(s) { + if (!s || typeof s !== "string") return null; + const hasZone = /[zZ]$|[+\-]\d\d:?\d\d$/.test(s); + const d = new Date(hasZone ? s : s + "Z"); + return isNaN(d.getTime()) ? null : d; +} + +/** Full localized date-time, or an em dash for null/unparseable. */ +export function localTime(s) { + const d = parseUtc(s); + return d ? d.toLocaleString() : "—"; +} + +/** Time-of-day only (HH:MM local), or an em dash — for compact "last collect" lines where the date is implied. */ +export function localClock(s) { + const d = parseUtc(s); + return d ? d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "—"; +} + +/** Relative time from now: "just now", "2m ago", "3h ago", "5d ago"; older than ~30d or a future instant falls + * back to the full local time. Null/unparseable -> em dash. */ +export function relTime(s) { + const d = parseUtc(s); + if (!d) return "—"; + const diffMs = Date.now() - d.getTime(); + if (diffMs < 0) return d.toLocaleString(); + const sec = Math.floor(diffMs / 1000); + if (sec < 45) return "just now"; + const min = Math.floor(sec / 60); + if (min < 60) return min + "m ago"; + const hr = Math.floor(min / 60); + if (hr < 24) return hr + "h ago"; + const day = Math.floor(hr / 24); + if (day < 30) return day + "d ago"; + return d.toLocaleString(); +} + +/** Compact axis label for a Date: HH:MM, widening to include the calendar date when `withDate` is set (the + * chart passes true whenever the domain's first and last samples fall on different calendar days). */ +export function axisTime(date, withDate) { + if (!date) return ""; + if (withDate) { + return date.toLocaleString([], { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }); + } + return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} + +/* ─────────────────────────── value formatters ─────────────────────────── */ + +export function fmtInt(v) { + return v == null ? "—" : Number(v).toLocaleString(undefined, { maximumFractionDigits: 0 }); +} +export function fmtNum(v, d = 1) { + return v == null ? "—" : Number(v).toLocaleString(undefined, { minimumFractionDigits: d, maximumFractionDigits: d }); +} +export function fmtPct(v) { + return v == null ? "—" : Math.round(Number(v)) + "%"; +} +export function fmtMs(v) { + if (v == null) return "—"; + const n = Number(v); + if (!isFinite(n)) return "—"; + const abs = Math.abs(n); + if (abs < 1000) return fmtInt(n) + " ms"; + if (abs < 60000) return fmtNum(n / 1000, 1) + " s"; + if (abs < 3600000) return fmtNum(n / 60000, 1) + " min"; + return fmtNum(n / 3600000, 1) + " h"; +} +export function fmtMb(v) { + if (v == null) return "—"; + const n = Number(v); + return n >= 1024 ? fmtNum(n / 1024, 1) + " GB" : fmtInt(n) + " MB"; +} +export function fmtText(v) { + return v == null || v === "" ? "—" : String(v); +} +export function fmtBool(v) { + return v === true ? "yes" : v === false ? "no" : "—"; +} + +/** Named formatters a panel descriptor can reference (the #1563-friendly, string-keyed registry). */ +export const FORMATTERS = { + int: fmtInt, + num1: (v) => fmtNum(v, 1), + num2: (v) => fmtNum(v, 2), + pct: fmtPct, + ms: fmtMs, + mb: fmtMb, + time: localTime, + reltime: relTime, + bool: fmtBool, + text: fmtText, +}; + +export function applyFormat(name, value) { + const f = (name && FORMATTERS[name]) || FORMATTERS.text; + return f(value); +} + +/* ─────────────────────────── band / severity classes ─────────────────────────── */ + +/** CSS class for a fleet band ("Healthy"/"Warning"/"Critical"/"Offline") — colors live in CSS. */ +export function bandClass(band) { + return "band-" + (band || "Unknown"); +} +/** CSS class for a per-metric severity ("Unknown"/"Healthy"/"Warning"/"Critical"). */ +export function sevClass(sev) { + return "sev-" + (sev || "Unknown"); +} + +/* ─────────────────────────── object access ─────────────────────────── */ + +/** Read a possibly-dotted path out of an object; undefined-safe. */ +export function getPath(obj, path) { + if (obj == null || !path) return undefined; + if (!path.includes(".")) return obj[path]; + return path.split(".").reduce((o, k) => (o == null ? undefined : o[k]), obj); +} + +/* ─────────────────────────── API fetch ─────────────────────────── */ + +/** Build a query string from a params object, skipping null/undefined/empty values. */ +export function buildQuery(params) { + if (!params) return ""; + const parts = []; + for (const [k, v] of Object.entries(params)) { + if (v == null || v === "") continue; + parts.push(encodeURIComponent(k) + "=" + encodeURIComponent(v)); + } + return parts.length ? "?" + parts.join("&") : ""; +} + +/** + * GET a same-origin API path and classify the response into one of three kinds, mirroring the service's + * three response shapes (DarlingWebEndpoints): + * { kind: "data", data } — a data object/array (HTTP 200 JSON passthrough) + * { kind: "empty", status, message, ... } — the {status,message[,hints]} empty envelope (HTTP 200) + * { kind: "error", message, status } — { "error": ... } (HTTP 400/500) or a transport failure + * Auth is handled entirely server-side (loopback is tokenless; network mode sets the session cookie before + * the SPA loads), so requests carry it automatically — nothing to do here. + */ +export async function apiGet(path) { + let resp; + try { + resp = await fetch(path, { headers: { Accept: "application/json" } }); + } catch (e) { + return { kind: "error", message: "Network error: " + (e && e.message ? e.message : String(e)) }; + } + return classifyResponse(resp); +} + +/** + * Send a MUTATING request (POST / PUT / DELETE) with an optional JSON body, classified exactly like apiGet + * (#1563 custom-view CRUD). A 204/empty body yields { kind: "data", data: null }; an { "error": ... } body on a + * non-2xx yields { kind: "error", message, status } — the status is preserved so a caller can branch on it + * (409 = a duplicate name or a stale optimistic-concurrency version, 415 = a non-JSON write was refused, ...). + * Every mutation ALWAYS declares Content-Type: application/json — the server rejects a mutation without it (415), + * which is what forces a CORS preflight on any cross-origin write and thereby kills the simple-request CSRF + * vector; a bodyless DELETE carries the header too (it sends no body, but must still satisfy that gate). + */ +export async function apiSend(method, path, body) { + const hasBody = body !== undefined && body !== null; + let resp; + try { + resp = await fetch(path, { + method, + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: hasBody ? JSON.stringify(body) : undefined, + }); + } catch (e) { + return { kind: "error", message: "Network error: " + (e && e.message ? e.message : String(e)) }; + } + return classifyResponse(resp); +} + +/** + * Classify a completed Response into the same three-kind shape apiGet returns (shared by apiGet + apiSend): + * an { "error": ... } body / non-2xx -> "error"; the {status, message[, hints]} envelope -> "empty"; anything + * else (including a 204/empty body -> data:null) -> "data". + */ +async function classifyResponse(resp) { + const raw = await resp.text(); + let body = null; + if (raw) { + try { + body = JSON.parse(raw); + } catch { + body = null; + } + } + + if (!resp.ok) { + const msg = body && typeof body.error === "string" ? body.error : "Request failed (HTTP " + resp.status + ")"; + return { kind: "error", message: msg, status: resp.status }; + } + + /* The empty envelope is exactly {status, message[, hints]}: a top-level string status AND string message. + Data payloads never carry a top-level message, so this never misfires on real data. */ + if (body && !Array.isArray(body) && typeof body.status === "string" && typeof body.message === "string") { + return { kind: "empty", status: body.status, message: body.message, hints: body.hints || null, data: body }; + } + + return { kind: "data", data: body }; +} + +/** GET a read-only tool by its MCP name with query-string params. */ +export function readTool(tool, params) { + return apiGet("/api/read/" + tool + buildQuery(params)); +} diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/views-api.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/views-api.js new file mode 100644 index 000000000..a2e234fc8 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/views-api.js @@ -0,0 +1,264 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +/* + * The #1563 custom-views API client — thin wrappers over util.js's apiGet/apiSend for the session probe, the + * read catalog, and the view CRUD, plus a PURE client-side definition validator that mirrors the server's + * ValidateDefinition (so a bad import/compose is caught before the round-trip — the backend re-validates as the + * authority). The session ({can_edit}) and catalog are immutable per page-load, so they are fetched ONCE and + * cached as promises; every caller shares the one in-flight request. Editing is available to any authenticated seat, so + * can_edit is true for any request past the host auth gate; it only goes false if the session probe itself failed (fail-closed), and the UI hides every edit + * affordance (render + export stay open to all). + */ + +import { apiGet, apiSend } from "./util.js"; + +let _sessionPromise = null; +let _catalogPromise = null; + +/** The session capability probe — resolves to {can_edit} (false on any transport failure: fail-closed UI). */ +export function getSession() { + if (!_sessionPromise) { + _sessionPromise = apiGet("/api/session").then((r) => + r.kind === "data" && r.data && typeof r.data.can_edit === "boolean" ? r.data : { can_edit: false } + ); + } + return _sessionPromise; +} + +/** An empty compose sub-catalog — the shell a consumer reads array fields off when the fetch failed. */ +function emptyCompose() { + return { + measures: [], + dimensions: [], + annotationSources: [], + universalDimensions: [], + unitFamilies: [], + aggregates: [], + timeBuckets: [], + filterOps: [], + viz: [], + }; +} + +/** + * The catalog — {reads, viz} for the v1 read picker AND `compose` (measures/dimensions/unit-families/aggregates/ + * time-buckets/filter-ops/viz) for the v2 composer (#1563). One fetch backs both pickers; empty shells on failure so + * every caller can read the array fields without a guard. The compose node is DATA-DRIVEN (B3 grows the measure list + * server-side) — the composer never hardcodes a measure. + */ +export function getCatalog() { + if (!_catalogPromise) { + _catalogPromise = apiGet("/api/catalog").then((r) => { + const d = r.kind === "data" && r.data ? r.data : null; + const compose = d && d.compose ? { ...emptyCompose(), ...d.compose } : emptyCompose(); + return { reads: (d && d.reads) || [], viz: (d && d.viz) || [], compose }; + }); + } + return _catalogPromise; +} + +/** GET the bare-array list of view summaries (no definition). */ +export function listViews() { + return apiGet("/api/views"); +} + +/** GET one view in full (with its embedded definition). */ +export function getView(id) { + return apiGet("/api/views/" + encodeURIComponent(id)); +} + +/** POST a new view {name, description?, definition} — 201 + the full view, or 400/409(dup)/403. */ +export function createView(body) { + return apiSend("POST", "/api/views", body); +} + +/** PUT an existing view {name, description?, definition, version} — 200 + the full view, or 400/404/409/403. */ +export function updateView(id, body) { + return apiSend("PUT", "/api/views/" + encodeURIComponent(id), body); +} + +/** DELETE a view — 204, or 404/403. */ +export function deleteView(id) { + return apiSend("DELETE", "/api/views/" + encodeURIComponent(id)); +} + +/** + * PURE client-side validation of a view definition against the cached catalog — a fast, precise pre-check on + * import that mirrors the structural rules the server's ValidateDefinition enforces. A panel is EITHER a v2 + * COMPOSED panel (names a `source`: source/measure|ratio/viz checked against catalog.compose) or a v1 READ panel + * (names a `read` on the allowlist, viz in the vocabulary, param keys ⊆ the read's params, required params present). + * span 1|2 and a raw-`path` ban apply to both. Returns null when valid, else a caller-facing error string. The + * backend re-validates as the authority (it owns the full compose cross-check); this only gives an immediate error. + */ +export function validateDefinition(def, catalog) { + if (!def || typeof def !== "object" || Array.isArray(def)) { + return "Definition must be a JSON object."; + } + + /* A notebook is a distinct view kind (kind:"notebook" + a `cells` array) sharing the same CRUD; route it to its + own structural check (#1563 D7). A dashboard (no kind / kind:"dashboard") validates its `panels` below. */ + if (def.kind === "notebook") { + return validateNotebookDefinition(def, catalog); + } + + if (!Array.isArray(def.panels)) { + return "Definition must have a 'panels' array."; + } + if (def.panels.length === 0) { + return "Definition must have at least one panel."; + } + if (def.variables != null && !Array.isArray(def.variables)) { + return "Definition 'variables' must be an array."; + } + if (def.range != null && (typeof def.range !== "object" || Array.isArray(def.range))) { + return "Definition 'range' must be an object."; + } + + const reads = new Map((catalog.reads || []).map((r) => [r.name, r])); + const vizSet = new Set(catalog.viz || []); + const sets = composeCatalogSets(catalog); + + for (let i = 0; i < def.panels.length; i++) { + const p = def.panels[i]; + const n = i + 1; + if (!p || typeof p !== "object" || Array.isArray(p)) { + return "Panel " + n + " must be an object."; + } + if (p.path != null) { + return "Panel " + n + " uses raw 'path' mode, which is not allowed; use 'read' or 'source'."; + } + if (p.span != null && p.span !== 1 && p.span !== 2) { + return "Panel " + n + " span must be 1 or 2."; + } + + /* A composed panel names a source; a read panel names a read. */ + if (p.source != null) { + const composeErr = validateComposedPanelSpec(p, "Panel " + n, sets); + if (composeErr) return composeErr; + continue; + } + + if (!p.read) { + return "Panel " + n + " is missing a read or source."; + } + const rd = reads.get(p.read); + if (!rd) { + return "Panel " + n + " references unknown read '" + p.read + "'."; + } + if (!p.viz) { + return "Panel " + n + " is missing a visualization."; + } + if (!vizSet.has(p.viz)) { + return "Panel " + n + " has unknown visualization '" + p.viz + "'."; + } + + const params = p.params || {}; + if (typeof params !== "object" || Array.isArray(params)) { + return "Panel " + n + " params must be an object."; + } + const allowed = new Set((rd.params || []).map((x) => x.name)); + for (const k of Object.keys(params)) { + if (!allowed.has(k)) { + return "Panel " + n + " has an unknown parameter '" + k + "' for read '" + p.read + "'."; + } + } + for (const rp of rd.params || []) { + if (rp.required && (params[rp.name] == null || params[rp.name] === "")) { + return "Panel " + n + " is missing the required parameter '" + rp.name + "'."; + } + } + } + + return null; +} + +/** + * PURE client-side validation of a NOTEBOOK definition (#1563 D7), mirroring the server's ValidateNotebookDefinition + * so a bad import is caught before the round-trip (the backend re-validates as the authority). Requires a non-empty + * `cells` array; each cell is a `markdown` cell (a string `text`) or a `panel` cell (a v2 COMPOSED panel spec + * spread at the cell's top level — the SAME shape a dashboard's composed panel uses). Returns null when valid, else + * a caller-facing error string. The compose CROSS-checks (aggregate/shape/unit coherence, $var resolution) belong + * to the server; this catches the structural mistakes an import is likely to carry. + */ +function validateNotebookDefinition(def, catalog) { + if (!Array.isArray(def.cells)) { + return "Notebook definition must have a 'cells' array."; + } + if (def.cells.length === 0) { + return "Notebook must have at least one cell."; + } + if (def.variables != null && !Array.isArray(def.variables)) { + return "Definition 'variables' must be an array."; + } + if (def.range != null && (typeof def.range !== "object" || Array.isArray(def.range))) { + return "Definition 'range' must be an object."; + } + + const sets = composeCatalogSets(catalog); + for (let i = 0; i < def.cells.length; i++) { + const cell = def.cells[i]; + const n = i + 1; + if (!cell || typeof cell !== "object" || Array.isArray(cell)) { + return "Cell " + n + " must be an object."; + } + if (cell.type === "markdown") { + if (cell.text != null && typeof cell.text !== "string") { + return "Cell " + n + " (markdown) text must be a string."; + } + continue; + } + if (cell.type === "panel") { + if (cell.path != null) { + return "Cell " + n + " uses raw 'path' mode, which is not allowed."; + } + if (cell.source == null) { + return "Cell " + n + " (panel) is missing a 'source'."; + } + const composeErr = validateComposedPanelSpec(cell, "Cell " + n, sets); + if (composeErr) return composeErr; + continue; + } + return "Cell " + n + " has an unknown type '" + (cell.type || "") + "' (expected 'markdown' or 'panel')."; + } + + return null; +} + +/** The catalog lookups a composed-panel check needs (shared by the dashboard + notebook validators). */ +function composeCatalogSets(catalog) { + const compose = catalog.compose || {}; + return { + sourceSet: new Set((compose.measures || []).map((m) => m.source)), + measureByKey: new Map((compose.measures || []).map((m) => [m.key, m])), + composeVizSet: new Set(compose.viz || []), + }; +} + +/** Validate a v2 COMPOSED panel spec (names a `source`) against the catalog. `label` prefixes the error ("Panel 2" + * / "Cell 3"). Returns null when valid, else the error string. Shared by dashboard panels + notebook panel cells. */ +function validateComposedPanelSpec(p, label, sets) { + if (!sets.sourceSet.has(p.source)) { + return label + " references unknown source '" + p.source + "'."; + } + if (p.measure != null && p.ratio != null) { + return label + " must set exactly one of 'measure' or 'ratio', not both."; + } + const key = p.measure != null ? p.measure : p.ratio; + const m = sets.measureByKey.get(key); + if (!m) { + return label + " references unknown measure '" + (key || "") + "'."; + } + if (m.source !== p.source) { + return label + " measure '" + m.key + "' is not on source '" + p.source + "'."; + } + if (!p.viz || !sets.composeVizSet.has(p.viz)) { + return label + " has an unknown or missing chart type '" + (p.viz || "") + "'."; + } + return null; +} diff --git a/Darling/PerformanceMonitor.Darling.Storage/PerformanceMonitor.Darling.Storage.csproj b/Darling/PerformanceMonitor.Darling.Storage/PerformanceMonitor.Darling.Storage.csproj new file mode 100644 index 000000000..393201d15 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Storage/PerformanceMonitor.Darling.Storage.csproj @@ -0,0 +1,23 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <Nullable>enable</Nullable> + <ImplicitUsings>disable</ImplicitUsings> + <RootNamespace>PerformanceMonitor.Darling.Storage</RootNamespace> + <AssemblyName>PerformanceMonitor.Darling.Storage</AssemblyName> + <Company>Darling Data, LLC</Company> + <Copyright>Copyright © 2026 Darling Data, LLC</Copyright> + <EnableNETAnalyzers>true</EnableNETAnalyzers> + <AnalysisLevel>latest-recommended</AnalysisLevel> + <NoWarn>CA1849;CA2007;CA1508;CA1822;CA1805;CA1510;CA1816;CA1861;CA1845;CA2201;CA1848;CA1852;CA1305;CA1860;CA1707;CA1507;CA1806;CA2254</NoWarn> + <WarningsAsErrors>CS4014</WarningsAsErrors> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Npgsql" Version="10.0.3" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\PerformanceMonitor.Collectors\PerformanceMonitor.Collectors.csproj" /> + </ItemGroup> +</Project> diff --git a/Darling/PerformanceMonitor.Darling.Storage/PgCollectorRowWriter.cs b/Darling/PerformanceMonitor.Darling.Storage/PgCollectorRowWriter.cs new file mode 100644 index 000000000..175839073 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Storage/PgCollectorRowWriter.cs @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Text; +using Npgsql; +using NpgsqlTypes; +using PerformanceMonitor.Collectors; + +namespace PerformanceMonitor.Darling.Storage; + +/// <summary> +/// Adapts the collector definitions' positional row writes onto Postgres binary COPY — Darling's +/// counterpart of Lite's DuckDB appender adapter. The host opens one binary import per +/// collection batch (<see cref="CopyCommandFor"/>), calls StartRow + writes the prefix columns, +/// then hands the writer to the definition's WritePayload; column ORDER is the contract, exactly +/// as with the appender. +/// All DateTimes in the product are UTC by convention but stored in naive `timestamp` columns +/// (mirroring Lite's DuckDB TIMESTAMP), so every DateTime is written with +/// DateTimeKind.Unspecified: since Npgsql 6.0 a Kind=Utc DateTime maps strictly to timestamptz +/// and throws InvalidCastException against `timestamp without time zone` +/// (https://www.npgsql.org/doc/types/datetime.html). +/// </summary> +public sealed class PgCollectorRowWriter : ICollectorRowWriter +{ + /// <summary>The active binary importer; the host sets this once per COPY batch.</summary> + public NpgsqlBinaryImporter? Importer { get; set; } + + private NpgsqlBinaryImporter Target + => Importer ?? throw new InvalidOperationException("Importer not set — open a binary import first."); + + /// <summary>Maps an engine-neutral collector column type to its binary-COPY parameter type.</summary> + public static NpgsqlDbType DbTypeFor(CollectorColumnType type) + { + switch (type) + { + case CollectorColumnType.BigInt: return NpgsqlDbType.Bigint; + case CollectorColumnType.Integer: return NpgsqlDbType.Integer; + case CollectorColumnType.SmallInt: return NpgsqlDbType.Smallint; + case CollectorColumnType.Varchar: return NpgsqlDbType.Text; + case CollectorColumnType.Timestamp: return NpgsqlDbType.Timestamp; + case CollectorColumnType.Double: return NpgsqlDbType.Double; + case CollectorColumnType.Decimal: return NpgsqlDbType.Numeric; + case CollectorColumnType.Boolean: return NpgsqlDbType.Boolean; + default: + throw new ArgumentOutOfRangeException(nameof(type), type, "Unmapped collector column type"); + } + } + + /// <summary> + /// The binary COPY command for one collector's destination table, prefix columns first in + /// the exact order the host writes them (id column omitted for running_jobs' no-id prefix). + /// </summary> + public static string CopyCommandFor(ICollectorSchemaInfo schema) + { + if (schema is null) + { + throw new ArgumentNullException(nameof(schema)); + } + + var sb = new StringBuilder(); + sb.Append("COPY ").Append(schema.TargetTable).Append(" ("); + + if (schema.IncludesCollectionId) + { + sb.Append(schema.PrefixIdColumnName).Append(", "); + } + + sb.Append(schema.PrefixTimeColumnName).Append(", server_id, server_name"); + + foreach (var column in schema.PayloadColumns) + { + sb.Append(", ").Append(column.Name); + } + + sb.Append(") FROM STDIN (FORMAT BINARY)"); + return sb.ToString(); + } + + /// <summary>Naive-UTC storage: strip the Kind so Npgsql accepts the value for `timestamp`.</summary> + private static DateTime Naive(DateTime value) => DateTime.SpecifyKind(value, DateTimeKind.Unspecified); + + public ICollectorRowWriter Value(string? value) + { + if (value is null) { Target.WriteNull(); } else { Target.Write(value, NpgsqlDbType.Text); } + return this; + } + + public ICollectorRowWriter Value(long value) + { + Target.Write(value, NpgsqlDbType.Bigint); + return this; + } + + public ICollectorRowWriter Value(long? value) + { + if (value is null) { Target.WriteNull(); } else { Target.Write(value.Value, NpgsqlDbType.Bigint); } + return this; + } + + public ICollectorRowWriter Value(int value) + { + Target.Write(value, NpgsqlDbType.Integer); + return this; + } + + public ICollectorRowWriter Value(int? value) + { + if (value is null) { Target.WriteNull(); } else { Target.Write(value.Value, NpgsqlDbType.Integer); } + return this; + } + + public ICollectorRowWriter Value(short value) + { + Target.Write(value, NpgsqlDbType.Smallint); + return this; + } + + public ICollectorRowWriter Value(short? value) + { + if (value is null) { Target.WriteNull(); } else { Target.Write(value.Value, NpgsqlDbType.Smallint); } + return this; + } + + public ICollectorRowWriter Value(double value) + { + Target.Write(value, NpgsqlDbType.Double); + return this; + } + + public ICollectorRowWriter Value(double? value) + { + if (value is null) { Target.WriteNull(); } else { Target.Write(value.Value, NpgsqlDbType.Double); } + return this; + } + + public ICollectorRowWriter Value(decimal value) + { + Target.Write(value, NpgsqlDbType.Numeric); + return this; + } + + public ICollectorRowWriter Value(decimal? value) + { + if (value is null) { Target.WriteNull(); } else { Target.Write(value.Value, NpgsqlDbType.Numeric); } + return this; + } + + public ICollectorRowWriter Value(bool value) + { + Target.Write(value, NpgsqlDbType.Boolean); + return this; + } + + public ICollectorRowWriter Value(bool? value) + { + if (value is null) { Target.WriteNull(); } else { Target.Write(value.Value, NpgsqlDbType.Boolean); } + return this; + } + + public ICollectorRowWriter Value(DateTime value) + { + Target.Write(Naive(value), NpgsqlDbType.Timestamp); + return this; + } + + public ICollectorRowWriter Value(DateTime? value) + { + if (value is null) { Target.WriteNull(); } else { Target.Write(Naive(value.Value), NpgsqlDbType.Timestamp); } + return this; + } + + public ICollectorRowWriter NullValue() + { + Target.WriteNull(); + return this; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs b/Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs new file mode 100644 index 000000000..15eb31a5d --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs @@ -0,0 +1,1187 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace PerformanceMonitor.Darling.Storage; + +/// <summary> +/// Darling's versioned schema migrations — plain SQL scripts the service applies on startup +/// (headless plan: no migration framework). Each script runs once, inside its own transaction, +/// tracked in darling_schema_version. V1 is generated from the collector definitions +/// (<see cref="PgSchemaGenerator.GenerateFullSchema"/>); later versions are appended, never +/// edited. Migrations stay engine-plain on purpose: TimescaleDB hypertable conversion is +/// RUNTIME setup (<see cref="TimescaleSupport"/>), applied by the service only when the +/// extension is detected — the same store must work on plain PostgreSQL. +/// </summary> +public static class PgMigrations +{ + public sealed class Migration + { + public Migration(int version, string name, string sql) + { + Version = version; + Name = name; + Sql = sql; + } + + public int Version { get; } + + public string Name { get; } + + public string Sql { get; } + } + + public static IReadOnlyList<Migration> Scripts { get; } = new[] + { + new Migration(1, "collector-tables", PgSchemaGenerator.GenerateFullSchema()), + new Migration(2, "server-registry-and-collection-log", V2Sql), + new Migration(3, "alerting-stores", V3Sql), + new Migration(4, "analysis-tables", V4Sql), + new Migration(5, "viewer-passthrough-views", V5Sql), + new Migration(6, "memory-tab-passthrough-views", V6Sql), + new Migration(7, "viewer-plan-capture-columns", V7Sql), + new Migration(8, "schema-split-collect-config", PgSchemaGenerator.GenerateV8Move()), + new Migration(9, "server-inventory-cost-fields", V9Sql), + new Migration(10, "latch-spinlock-collectors", PgSchemaGenerator.GenerateV10AddLatchSpinlock()), + new Migration(11, "cpu-scheduler-plan-cache-collectors", PgSchemaGenerator.GenerateV11AddCpuSchedulerPlanCache()), + new Migration(12, "session-summary-collector", PgSchemaGenerator.GenerateV12AddSessionSummary()), + new Migration(13, "system-health-events-collector", PgSchemaGenerator.GenerateV13AddSystemHealthEvents()), + new Migration(14, "refresh-passthrough-views", PgSchemaGenerator.GenerateV14RefreshViews()), + new Migration(15, "index-metadata-columns", V15Sql), + new Migration(16, "server-utc-offset", V16Sql), + new Migration(17, "config-control-plane", V17Sql), + new Migration(18, "alert-delivery-mode", V18Sql), + new Migration(19, "analysis-state-marker", V19Sql), + new Migration(20, "alert-tuning-knobs", V20Sql), + new Migration(21, "default-trace-events-collector", V21Sql), + new Migration(22, "index-object-stats-latest-index", V22Sql), + new Migration(23, "collection-log-hypertable", V23Sql), + new Migration(24, "job-history-collector", V24Sql), + new Migration(25, "agent-status-collector", V25Sql), + new Migration(26, "generic-webhook-channel", V26Sql), + new Migration(27, "deadlocks-database-name", V27Sql), + new Migration(28, "query-store-replica-role", V28Sql), + new Migration(29, "long-query-completions-collector", V29Sql), + new Migration(30, "web-dashboard-config", V30Sql), + new Migration(31, "custom-views-table", V31Sql), + }; + + /// <summary> + /// V2 — the service's observability store: the servers registry (upserted on every + /// successful connect) and the per-run collection_log. Column names deliberately mirror + /// Lite's DuckDB schema so viewer/analysis SQL can twin across stores — including + /// duckdb_duration_ms, which in Darling records the Postgres storage phase. Lite's servers + /// table also carries auth columns (use_windows_auth/username); Darling deliberately omits + /// them because auth lives in darling.json. servers keeps its PRIMARY KEY (a registry, not + /// a hypertable candidate); collection_log has none, same reasoning as the collector tables. + /// </summary> + private const string V2Sql = @" +CREATE TABLE IF NOT EXISTS servers ( + server_id integer NOT NULL PRIMARY KEY, + server_name text NOT NULL, + display_name text, + is_enabled boolean NOT NULL DEFAULT TRUE, + sql_engine_edition integer, + sql_major_version integer, + created_date timestamp, + modified_date timestamp +); + +CREATE TABLE IF NOT EXISTS collection_log ( + log_id bigint NOT NULL, + server_id integer NOT NULL, + server_name text, + collector_name text NOT NULL, + collection_time timestamp NOT NULL, + duration_ms integer, + status text NOT NULL, + error_message text, + rows_collected integer, + sql_duration_ms integer, + duckdb_duration_ms integer +); + +CREATE INDEX IF NOT EXISTS idx_collection_log_time ON collection_log(server_id, collection_time);"; + + /// <summary> + /// V3 — the alerting stores behind the Phase-5 shared alert engine (slice D), each mirroring + /// its Lite DuckDB twin column-for-column so viewer/analysis SQL can twin across stores: + /// <c>config_alert_log</c> (one combined history row per fired alert — Lite's Schema.cs + /// CreateAlertLogTable), <c>config_edge_trigger_watermarks</c> (the #1091 rolling-count + /// watermarks + the time-based failed-job watermark, #1145 restart survival — Lite's + /// CreateEdgeTriggerWatermarksTable, including its (server_id, metric_name) primary key the + /// upserts conflict on), and <c>config_mute_rules</c> (Lite's CreateMuteRulesTable). The + /// alert-log index serves the per-(server, metric) MAX(alert_time) cooldown seeds. + /// </summary> + private const string V3Sql = @" +CREATE TABLE IF NOT EXISTS config_alert_log ( + alert_time timestamp NOT NULL, + server_id integer NOT NULL, + server_name text NOT NULL, + metric_name text NOT NULL, + current_value double precision NOT NULL, + threshold_value double precision NOT NULL, + alert_sent boolean NOT NULL DEFAULT FALSE, + notification_type text NOT NULL DEFAULT 'tray', + send_error text, + dismissed boolean NOT NULL DEFAULT FALSE, + muted boolean NOT NULL DEFAULT FALSE, + detail_text text, + context_json text +); + +CREATE INDEX IF NOT EXISTS idx_config_alert_log_time ON config_alert_log(server_id, metric_name, alert_time); + +CREATE TABLE IF NOT EXISTS config_edge_trigger_watermarks ( + server_id integer NOT NULL, + metric_name text NOT NULL, + watermark integer NOT NULL, + watermark_time timestamp, + updated_at timestamp NOT NULL, + PRIMARY KEY (server_id, metric_name) +); + +CREATE TABLE IF NOT EXISTS config_mute_rules ( + id text NOT NULL PRIMARY KEY, + enabled boolean NOT NULL DEFAULT TRUE, + created_at_utc timestamp NOT NULL, + expires_at_utc timestamp, + reason text, + server_name text, + metric_name text, + database_pattern text, + query_text_pattern text, + wait_type_pattern text, + job_name_pattern text +);"; + + /// <summary> + /// V4 — the analysis-engine stores (Phase-5 analysis slice AN1) plus the passthrough views + /// the ported analysis SQL reads. <c>analysis_findings</c> is Lite's AnalysisSchema v3 shape + /// column-for-column PLUS the Dashboard's <c>remediation_action_json</c> (recommendations + /// rebuild D2: the BUILT RemediationAction persisted on the row, serialized by the shared + /// AlertContextSerializer) — no primary key, same hypertable/COPY reasoning as the collector + /// tables (TimescaleDB hypertables require the partition column in any unique constraint, and + /// bulk ingest doesn't want one); finding_id stays a NOT NULL bigint, and the two Lite index + /// column sets carry over. <c>analysis_muted</c> is a small mute registry and KEEPS its + /// primary key (like V2's servers); Lite's DuckDB <c>DEFAULT CURRENT_TIMESTAMP</c> on + /// muted_date is deliberately NOT carried — in Postgres that default would stamp the PG + /// server's LOCAL clock, and the store always supplies naive-UTC explicitly. + /// + /// The <c>v_<table></c> views exist so analysis SQL ports VERBATIM (AN2/AN3): in Lite + /// they union the hot DuckDB table with the parquet archive; Darling has no parquet tier, so + /// they are plain passthroughs. Seventeen views — the fourteen the fact collectors read + /// (wait/query/query-store/cpu/memory-grant/memory/perfmon/session/file-io stats, + /// blocked_process_reports, deadlocks, dmv_blocking_snapshots, index_object_stats, + /// database_size_stats) plus the three Lite's drill-down/storage collectors also read + /// (v_tempdb_stats in DuckDbFactCollector.Storage + DrillDownCollector.Storage, + /// v_query_snapshots in DrillDownCollector.Queries, v_database_config in + /// DrillDownCollector.Config). Every view targets a V1 collector table, pinned by test. + /// </summary> + private const string V4Sql = @" +CREATE TABLE IF NOT EXISTS analysis_findings ( + finding_id bigint NOT NULL, + analysis_time timestamp NOT NULL, + server_id integer NOT NULL, + server_name text NOT NULL, + database_name text, + time_range_start timestamp, + time_range_end timestamp, + severity double precision NOT NULL, + confidence double precision NOT NULL, + category text NOT NULL, + story_path text NOT NULL, + story_path_hash text NOT NULL, + story_text text NOT NULL, + root_fact_key text NOT NULL, + root_fact_value double precision, + leaf_fact_key text, + leaf_fact_value double precision, + fact_count integer NOT NULL, + incident_id text, + remediation_action_json text +); + +CREATE INDEX IF NOT EXISTS idx_analysis_findings_time ON analysis_findings(server_id, analysis_time); +CREATE INDEX IF NOT EXISTS idx_analysis_findings_hash ON analysis_findings(story_path_hash); + +CREATE TABLE IF NOT EXISTS analysis_muted ( + mute_id bigint NOT NULL PRIMARY KEY, + server_id integer, + database_name text, + story_path_hash text NOT NULL, + story_path text NOT NULL, + muted_date timestamp NOT NULL, + reason text +); + +CREATE INDEX IF NOT EXISTS idx_analysis_muted_hash ON analysis_muted(story_path_hash); + +CREATE OR REPLACE VIEW v_wait_stats AS SELECT * FROM wait_stats; +CREATE OR REPLACE VIEW v_query_stats AS SELECT * FROM query_stats; +CREATE OR REPLACE VIEW v_query_store_stats AS SELECT * FROM query_store_stats; +CREATE OR REPLACE VIEW v_cpu_utilization_stats AS SELECT * FROM cpu_utilization_stats; +CREATE OR REPLACE VIEW v_memory_grant_stats AS SELECT * FROM memory_grant_stats; +CREATE OR REPLACE VIEW v_memory_stats AS SELECT * FROM memory_stats; +CREATE OR REPLACE VIEW v_perfmon_stats AS SELECT * FROM perfmon_stats; +CREATE OR REPLACE VIEW v_session_stats AS SELECT * FROM session_stats; +CREATE OR REPLACE VIEW v_file_io_stats AS SELECT * FROM file_io_stats; +CREATE OR REPLACE VIEW v_blocked_process_reports AS SELECT * FROM blocked_process_reports; +CREATE OR REPLACE VIEW v_deadlocks AS SELECT * FROM deadlocks; +CREATE OR REPLACE VIEW v_dmv_blocking_snapshots AS SELECT * FROM dmv_blocking_snapshots; +CREATE OR REPLACE VIEW v_index_object_stats AS SELECT * FROM index_object_stats; +CREATE OR REPLACE VIEW v_database_size_stats AS SELECT * FROM database_size_stats; +CREATE OR REPLACE VIEW v_tempdb_stats AS SELECT * FROM tempdb_stats; +CREATE OR REPLACE VIEW v_query_snapshots AS SELECT * FROM query_snapshots; +CREATE OR REPLACE VIEW v_database_config AS SELECT * FROM database_config;"; + + /* V5 — the five passthrough views V4 left out, completing the v_* twin of Lite's DuckDB + view layer so every ported viewer query stays byte-identical to Lite's (the copy-parity + program's tail tabs read these: Running Jobs, Configuration ×3, Daily Summary / + Collection Health). Tables all exist since V1/V2; views only. */ + private const string V5Sql = @" +CREATE OR REPLACE VIEW v_running_jobs AS SELECT * FROM running_jobs; +CREATE OR REPLACE VIEW v_server_config AS SELECT * FROM server_config; +CREATE OR REPLACE VIEW v_database_scoped_config AS SELECT * FROM database_scoped_config; +CREATE OR REPLACE VIEW v_trace_flags AS SELECT * FROM trace_flags; +CREATE OR REPLACE VIEW v_collection_log AS SELECT * FROM collection_log;"; + + /* V6 — the two memory passthrough views V4/V5 left out, needed by the Memory tab port (W1j): the + Memory Clerks + Memory Pressure Events sub-tabs read v_memory_clerks / v_memory_pressure_events, + mirroring Lite, so their ported SQL stays byte-identical. Tables exist since V1; views only. */ + private const string V6Sql = @" +CREATE OR REPLACE VIEW v_memory_clerks AS SELECT * FROM memory_clerks; +CREATE OR REPLACE VIEW v_memory_pressure_events AS SELECT * FROM memory_pressure_events;"; + + /* V7 — the deferred-plan-capture columns the viewer's blocked-process/deadlock/procedure "View + Plan" surfaces read (PR #1262, extending the #1349 pattern to three more collectors). All are + nullable text appended to existing collector tables, so a store already at V1's pre-plan shape + comes up to shape with one ADD COLUMN each; a fresh install already has them (V1 is generated + from the current collector definitions, which now include these columns), and ADD COLUMN IF NOT + EXISTS makes that a harmless no-op. Darling captures the plans because DarlingConfig.CapturePlans + defaults true (CollectorContext.CapturePlanXml) — no config change; Lite never sets the flag and + always writes NULL here. Appended (not inserted) so a fresh V1 store and an upgraded V7 store keep + an identical physical column order. */ + private const string V7Sql = @" +ALTER TABLE procedure_stats ADD COLUMN IF NOT EXISTS query_plan_xml text; +ALTER TABLE blocked_process_reports ADD COLUMN IF NOT EXISTS blocked_query_plan_xml text; +ALTER TABLE blocked_process_reports ADD COLUMN IF NOT EXISTS blocking_query_plan_xml text; +ALTER TABLE deadlocks ADD COLUMN IF NOT EXISTS victim_query_plan_xml text;"; + + /* V27 — deadlocks.database_name: the victim process's currentdbname, keying the Azure SQL DB + per-database watermark (#1535: capture is one database-scoped session per monitored database). + Nullable text appended at the end (identical physical column order for the binary COPY whether + fresh — V1 is generated from the current collector definition, which now includes it — or + upgraded; ADD COLUMN IF NOT EXISTS no-ops on fresh). blocked_process_reports already had + database_name. The trailing CREATE OR REPLACE VIEW re-expands v_deadlocks' pinned SELECT * + (Postgres freezes it at CREATE; append-only ADDs keep the refresh legal), mirroring V15. + Runs after V8, so the bare names resolve through search_path = collect, config, public. */ + private const string V27Sql = @" +ALTER TABLE deadlocks ADD COLUMN IF NOT EXISTS database_name text; +CREATE OR REPLACE VIEW v_deadlocks AS SELECT * FROM deadlocks;"; + + /* V28 — query_store_stats.replica_role: the replica role SQL Server 2022+ attributed each + runtime-stats row to (sys.query_store_replicas.replica_name, LEFT JOINed by replica_group_id). + With "Query Store for secondary replicas" enabled an AG keeps ONE shared Query Store on the + PRIMARY holding every replica's rows, so the primary's numbers silently blend in secondary + workload unless split by this column. Nullable text appended at the end (identical physical + column order for the binary COPY whether fresh — V1 is generated from the current collector + definition, which now includes it — or upgraded; ADD COLUMN IF NOT EXISTS no-ops on fresh). + The trailing CREATE OR REPLACE VIEW re-expands v_query_store_stats' pinned SELECT * (Postgres + freezes it at CREATE; append-only ADDs keep the refresh legal), mirroring V15/V27. Runs after + V8, so the bare names resolve through search_path = collect, config, public. */ + private const string V28Sql = @" +ALTER TABLE query_store_stats ADD COLUMN IF NOT EXISTS replica_role text; +CREATE OR REPLACE VIEW v_query_store_stats AS SELECT * FROM query_store_stats;"; + + /// <summary> + /// V29 — the <c>long_query_completions</c> collector table (#1496): long-running query completions + /// (rpc_completed / sql_batch_completed >= duration threshold, plus attention/cancels) from a + /// dedicated, OPT-IN XE ring-buffer session. Added additively exactly like V21/V24/V25 — a fresh + /// store already has it (V1's <see cref="PgSchemaGenerator.GenerateFullSchema"/> walks the collector + /// and V8 moved it to <c>collect</c>), so <c>CREATE TABLE IF NOT EXISTS</c> is a no-op on fresh and + /// the real create on upgrade. Column order/types are exactly <see cref="PgSchemaGenerator.CreateTable"/>'s + /// output for the <see cref="LongQueryCompletionsCollector"/> catalog entry (prefix NOT NULL, payload + /// nullable). No <c>v_*</c> passthrough view (a post-V14 collector); the viewer reads the base table + /// directly. Hypertable / compression / 30-day retention flow from the catalog at runtime. + /// </summary> + private const string V29Sql = @" +CREATE TABLE IF NOT EXISTS collect.long_query_completions ( + long_query_completion_id bigint NOT NULL, + collection_time timestamp NOT NULL, + server_id integer NOT NULL, + server_name text NOT NULL, + event_time timestamp, + event_type text, + database_id integer, + database_name text, + session_id integer, + client_app_name text, + client_pid integer, + nt_username text, + server_principal_name text, + query_hash text, + event_sequence bigint, + duration_microseconds bigint, + cpu_time_microseconds bigint, + physical_reads bigint, + logical_reads bigint, + writes bigint, + row_count bigint, + result text, + statement_text text, + object_name text +); + +CREATE INDEX IF NOT EXISTS idx_long_query_completions_time ON collect.long_query_completions(server_id, collection_time);"; + + /// <summary> + /// V30 — the web dashboard's live toggle (#1562): config_service gains web_enabled + web_port, the twins of + /// mcp_enabled/mcp_port, so the viewer's Settings toggle drives the second Kestrel host through the same + /// config_version reload beacon (the BEFORE-UPDATE self-bump trigger already covers the new columns). Additive + /// ALTERs, schema-qualified config.* exactly like V18 (the migrate session's search_path resolves a bare name + /// to collect — the wrong schema/ACL); IF NOT EXISTS matches the file's ALTER idiom so a re-run is a no-op. Both + /// columns are NOT NULL with the shipped defaults (off / 5153) so the single seeded row comes up honoring them. + /// config_service has no v_* passthrough view, so nothing to refresh. Role grants are table-wide SELECT for + /// viewer/mcp and table-wide writes for admin (config_service is NOT column-carved in DarlingManagedRoles), so + /// the new columns inherit the grants automatically — no grant change needed. + /// </summary> + private const string V30Sql = @" +ALTER TABLE config.config_service ADD COLUMN IF NOT EXISTS web_enabled boolean NOT NULL DEFAULT FALSE; +ALTER TABLE config.config_service ADD COLUMN IF NOT EXISTS web_port integer NOT NULL DEFAULT 5153;"; + + /// <summary> + /// V31 — the web dashboard's user-authored CUSTOM VIEWS store (#1563): the saved dashboard/notebook + /// definitions the web renderer composes from the existing read allowlist. One row per named view; the + /// <c>definition</c> is the view JSON (<c>{panels:[{read,params,viz,span,...}]}</c>) validated app-side + /// before storage. A NEW config-plane table (the web renderer reads it; the Viewer's composer writes it), + /// added additively exactly like V17's control-plane tables. + /// + /// <para><b>Schema — <c>config</c>, qualified.</b> V31 CREATEs directly in <c>config</c>, and the migrate + /// session runs under <c>search_path = collect, config, public</c> (<see cref="PgSchemaGenerator.SearchPath"/>), + /// so an UNQUALIFIED <c>CREATE TABLE custom_views</c> would resolve to <c>collect</c> (first in the path) — + /// the wrong schema and the wrong ACL (config is the admin-writable surface). Qualifying <c>config.</c> pins + /// it into the admin-writable schema, mirroring V17/V18/V30. <c>id</c> is <c>GENERATED ALWAYS AS IDENTITY</c> + /// (not <c>serial</c>) so INSERTs need no sequence USAGE grant — the same reasoning as V17's + /// <c>config_command</c>. <c>name</c> is UNIQUE (a duplicate insert/rename surfaces as a 409 to the caller); + /// <c>definition</c> is <c>jsonb NOT NULL</c> (the engine rejects malformed JSON as a second line of defense + /// behind the app-side validator). Timestamps store naive-UTC (<c>now() AT TIME ZONE 'UTC'</c>) to match the + /// store-wide convention (Npgsql rejects Kind=Utc against a bare <c>timestamp</c>). + /// + /// <para><b>Versioned = an IN-PLACE <c>version</c> column + optimistic concurrency</b> (the store's UPDATE + /// carries <c>WHERE id = $ AND version = $expected</c>; a 0-rowcount is a stale-write 409), NOT append-only + /// history rows — a <c>custom_view_history</c> audit trail is a clean later add if it is ever wanted.</para> + /// + /// <para><b>NO <c>config_bump_version</c> trigger</b> (deliberately, unlike V17's four desired-state tables): + /// custom_views feeds the WEB RENDERER, never the collector/service loop, so a write here has nothing for the + /// service to reload — bumping the <c>config_version</c> reload beacon would only trigger a needless service + /// reload on every view save. The write grant for the least-privilege viewer role (the web dashboard's + /// identity) is an EXPLICIT single-table grant in <see cref="!:DarlingManagedRoles.BuildProvisioningSql"/> + /// (and BYO <c>tools/provision-roles.sql</c>), NOT an <c>ALTER DEFAULT PRIVILEGES</c> widening — custom_views + /// has no secret columns, so it needs no <c>ViewerRestrictedConfigTables</c> carve.</para> + /// </summary> + private const string V31Sql = @" +CREATE TABLE IF NOT EXISTS config.custom_views ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name text NOT NULL UNIQUE, + definition jsonb NOT NULL, + description text, + version integer NOT NULL DEFAULT 1, + created_at timestamp NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'), + updated_at timestamp NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'), + updated_by text +);"; + + /// <summary> + /// V9 — the FinOps copy-parity fields that were user-input config or previously live-only: + /// <c>server_properties</c> gains the three inventory columns the shared ServerPropertiesCollector now + /// SELECTs (start time / host OS / AG replica role — Lite's FinOps Server Inventory previously read + /// them from a LIVE query the headless viewer can't run), and <c>servers</c> gains + /// <c>monthly_cost_usd</c> (the per-server FinOps budget — Lite's <c>ServerConnection.MonthlyCostUsd</c>, + /// user config, upserted from darling.json). All are nullable appended columns: a fresh store's V1 + /// server_properties is generated from the current collector (which already includes the three), and + /// V2's servers table gets the cost column here — so <c>ADD COLUMN IF NOT EXISTS</c> is a harmless + /// no-op on the generated columns and the real add on an upgraded store. Appended (not inserted) so a + /// fresh V1 store and an upgraded store keep an identical physical column order for the binary COPY. + /// The bare names resolve through the migrate session's <c>search_path = collect, config, public</c> + /// (V8) to <c>collect.server_properties</c> / <c>collect.servers</c>. + /// </summary> + private const string V9Sql = @" +ALTER TABLE server_properties ADD COLUMN IF NOT EXISTS sqlserver_start_time timestamp; +ALTER TABLE server_properties ADD COLUMN IF NOT EXISTS host_os_version text; +ALTER TABLE server_properties ADD COLUMN IF NOT EXISTS ag_replica_role text; +ALTER TABLE servers ADD COLUMN IF NOT EXISTS monthly_cost_usd numeric;"; + + /// <summary> + /// V15 — the per-index DEFINITION metadata monitor-side UNUSED/DUPLICATE index analysis needs + /// (FinOps Index Analysis, Stage 1), added additively to <c>index_object_stats</c>: the ordered + /// <c>key_columns</c> / <c>included_columns</c> lists (sp_IndexCleanup's delimited representation, + /// so the Stage-2 analyzer's string-comparison dedupe ports cleanly), <c>filter_definition</c>, + /// the uniqueness/constraint/FK discriminators (<c>is_unique_constraint</c>, <c>is_foreign_key</c>, + /// <c>is_foreign_key_reference</c>) + <c>is_disabled</c>, and the reconstruct-a-CREATE options + /// (<c>data_compression_desc</c>, <c>optimize_for_sequential_key</c>, <c>fill_factor</c>, + /// <c>is_padded</c>, <c>allow_page_locks</c>, <c>allow_row_locks</c>). Every column is nullable and + /// appended, so a fresh store's V1 <c>index_object_stats</c> (generated from the current collector + /// definition, which now includes them) already has them and <c>ADD COLUMN IF NOT EXISTS</c> + /// no-ops, while an upgraded store gets the real add — with an identical physical column order for + /// the binary COPY either way. The trailing <c>CREATE OR REPLACE VIEW</c> re-expands + /// <c>v_index_object_stats</c>' pinned <c>SELECT *</c> (Postgres freezes it at CREATE, so an + /// upgraded store's view — last refreshed by V14 before these columns existed — would otherwise + /// omit them; append-only ADDs keep the refresh legal). Runs after V8, so the bare names resolve + /// through <c>search_path = collect, config, public</c>. + /// </summary> + private const string V15Sql = @" +ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS key_columns text; +ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS included_columns text; +ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS filter_definition text; +ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS is_unique_constraint boolean; +ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS is_foreign_key boolean; +ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS is_foreign_key_reference boolean; +ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS is_disabled boolean; +ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS data_compression_desc text; +ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS optimize_for_sequential_key boolean; +ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS fill_factor smallint; +ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS is_padded boolean; +ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS allow_page_locks boolean; +ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS allow_row_locks boolean; +ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS is_indexed_view boolean; + +CREATE OR REPLACE VIEW v_index_object_stats AS SELECT * FROM index_object_stats;"; + + /// <summary> + /// V16 — the monitored server's UTC offset, added additively to <c>server_properties</c> so the + /// headless viewer can render timestamps in the server's own local time (the Server-time display + /// mode ported from Lite). The store is naive-UTC; Server-time = UTC + this offset. It is nullable + /// and appended, so a fresh store's V1 <c>server_properties</c> (generated from the current collector + /// definition, which now includes it) already has it and <c>ADD COLUMN IF NOT EXISTS</c> no-ops, + /// while an upgraded store gets the real add — with an identical physical column order for the binary + /// COPY either way. <c>server_properties</c> has no <c>v_*</c> passthrough view, so nothing to refresh. + /// Runs after V8, so the bare name resolves through <c>search_path = collect, config, public</c>. + /// </summary> + private const string V16Sql = @" +ALTER TABLE server_properties ADD COLUMN IF NOT EXISTS utc_offset_minutes integer;"; + + /// <summary> + /// V17 — the store<->service control plane (Phase A, Stage 1): the six operator-writable + /// <c>config.*</c> tables the Viewer will WRITE and the service READS + honors on the + /// <c>config_version</c> reload beacon. Two directions over the V8 <c>config</c> schema (the + /// admin-writable surface): the config plane (desired state — monitored servers, alert / + /// analysis knobs, notification delivery, per-collector schedule overrides, service flags) and + /// the command plane (<c>config_command</c>, the imperative queue Stage 2 executes). + /// + /// <para><b>CRITICAL — every object is schema-qualified <c>config.<name></c>.</b> V17 is + /// the first migration to CREATE directly in <c>config</c>; the migrate session runs under + /// <c>search_path = collect, config, public</c> (<see cref="PgSchemaGenerator.SearchPath"/>), so + /// an UNQUALIFIED <c>CREATE TABLE foo</c> would resolve to <c>collect</c> (first in the path) — + /// wrong schema, wrong ACL (the V8 split grants config-writes to <c>admin</c> only). Qualifying + /// every table/index/function/trigger with <c>config.</c> pins them into the admin-writable + /// schema. The managed role provisioning (<c>DarlingManagedRoles</c>) and BYO + /// <c>tools/provision-roles.sql</c> both <c>GRANT … ON ALL TABLES IN SCHEMA config</c> after + /// migration + carry <c>ALTER DEFAULT PRIVILEGES</c>, so these new tables auto-inherit the + /// admin/viewer grants with no per-table grant; <c>config_command</c> uses + /// <c>GENERATED ALWAYS AS IDENTITY</c> (not <c>serial</c>) so INSERTs need no sequence USAGE. + /// + /// <para>The <c>config_version</c> reload beacon: statement-level bump triggers on the four + /// desired-state tables increment <c>config_service.config_version</c> on any write (so the + /// Viewer cannot forget to signal), and a BEFORE-UPDATE trigger on <c>config_service</c> itself + /// self-increments the beacon on direct writes (pause/capture/mcp) without recursing — the + /// service polls this one integer each sweep and reloads only when it changes. Single-row global + /// tables are pinned to <c>id = 1</c>; <c>config_collector_schedules</c> is sparse (absent row / + /// NULL column = the <c>CollectorScheduleDefaults</c> code default, <c>server_id</c> NULL = + /// fleet-wide) with two partial-unique indexes because a PRIMARY KEY cannot span a nullable + /// <c>server_id</c>. Timestamps store naive-UTC (<c>now() AT TIME ZONE 'UTC'</c>) to match the + /// store-wide convention (Npgsql rejects Kind=Utc against <c>timestamp</c>). Server secrets are + /// NEVER plaintext here — <c>encrypted_password</c>/<c>smtp_encrypted_password</c> are the + /// DPAPI blobs (the <c>--encrypt-password</c> pattern); integrated auth needs none.</para> + /// </summary> + private const string V17Sql = @" +/* V17: store<->service control plane (Stage 1). EVERY object is schema-qualified config.* — + the migrate session's search_path resolves bare names to collect (wrong schema/ACL). */ + +/* --- A. Config plane: the Viewer writes desired state, the service reads + honors it. --- */ + +/* 1. config_monitored_servers — the desired-state twin of the collect.servers observed registry. + server_id = ServerIdHelper.GetDeterministicHashCode(BuildStorageName(host,database,ro)), the + SAME identity the collectors stamp, so it JOINs collected data. is_enabled drives collection; + the connection fields reconstruct a MonitoredServer for the service's connect path. */ +CREATE TABLE IF NOT EXISTS config.config_monitored_servers ( + server_id integer NOT NULL PRIMARY KEY, + name text NOT NULL, + host text NOT NULL, + database text, + auth text NOT NULL DEFAULT 'integrated', + username text, + encrypted_password text, + encrypt_mode text NOT NULL DEFAULT 'Mandatory', + trust_server_certificate boolean NOT NULL DEFAULT FALSE, + read_only_intent boolean NOT NULL DEFAULT FALSE, + multi_subnet_failover boolean NOT NULL DEFAULT FALSE, + excluded_databases text[] NOT NULL DEFAULT '{}'::text[], + monthly_cost_usd numeric NOT NULL DEFAULT 0, + capture_plans boolean, + is_enabled boolean NOT NULL DEFAULT TRUE, + created_at timestamp NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'), + modified_at timestamp NOT NULL DEFAULT (now() AT TIME ZONE 'UTC') +); + +/* 2. config_alert_settings — single row (id=1), one column per AlertsConfig field + the analysis + cadence knobs (analysis_enabled / interval / notifications_enabled / notify_severity). */ +CREATE TABLE IF NOT EXISTS config.config_alert_settings ( + id smallint NOT NULL PRIMARY KEY DEFAULT 1 CHECK (id = 1), + enabled boolean NOT NULL DEFAULT TRUE, + cpu_enabled boolean NOT NULL DEFAULT TRUE, + cpu_threshold_percent integer NOT NULL DEFAULT 80, + cpu_mode text NOT NULL DEFAULT 'total', + blocking_enabled boolean NOT NULL DEFAULT TRUE, + blocking_count_threshold integer NOT NULL DEFAULT 1, + deadlock_enabled boolean NOT NULL DEFAULT TRUE, + deadlock_count_threshold integer NOT NULL DEFAULT 1, + poison_wait_enabled boolean NOT NULL DEFAULT TRUE, + poison_wait_threshold_ms integer NOT NULL DEFAULT 500, + long_running_query_enabled boolean NOT NULL DEFAULT TRUE, + long_running_query_threshold_minutes integer NOT NULL DEFAULT 30, + tempdb_space_enabled boolean NOT NULL DEFAULT TRUE, + tempdb_space_threshold_percent integer NOT NULL DEFAULT 80, + low_disk_enabled boolean NOT NULL DEFAULT TRUE, + low_disk_threshold_percent integer NOT NULL DEFAULT 10, + low_disk_threshold_gb integer NOT NULL DEFAULT 5, + long_running_job_enabled boolean NOT NULL DEFAULT TRUE, + long_running_job_multiplier integer NOT NULL DEFAULT 3, + failed_job_enabled boolean NOT NULL DEFAULT TRUE, + failed_job_lookback_minutes integer NOT NULL DEFAULT 60, + cooldown_minutes integer NOT NULL DEFAULT 5, + excluded_databases text[] NOT NULL DEFAULT '{}'::text[], + analysis_enabled boolean NOT NULL DEFAULT TRUE, + analysis_interval_minutes integer NOT NULL DEFAULT 30, + analysis_notifications_enabled boolean NOT NULL DEFAULT TRUE, + analysis_notify_severity double precision NOT NULL DEFAULT 1.5, + modified_at timestamp NOT NULL DEFAULT (now() AT TIME ZONE 'UTC') +); + +/* 3. config_notification — single row: SMTP + webhook delivery. Non-secret fields plus the SMTP + DPAPI blob (smtp_encrypted_password); webhook URLs/proxies carry as darling.json holds them. */ +CREATE TABLE IF NOT EXISTS config.config_notification ( + id smallint NOT NULL PRIMARY KEY DEFAULT 1 CHECK (id = 1), + smtp_host text NOT NULL DEFAULT '', + smtp_port integer NOT NULL DEFAULT 587, + smtp_use_ssl boolean NOT NULL DEFAULT TRUE, + smtp_username text, + smtp_encrypted_password text, + smtp_from_address text NOT NULL DEFAULT '', + smtp_recipients text NOT NULL DEFAULT '', + email_cooldown_minutes integer NOT NULL DEFAULT 15, + teams_url text NOT NULL DEFAULT '', + teams_proxy text NOT NULL DEFAULT '', + slack_url text NOT NULL DEFAULT '', + slack_proxy text NOT NULL DEFAULT '', + modified_at timestamp NOT NULL DEFAULT (now() AT TIME ZONE 'UTC') +); + +/* 4. config_collector_schedules — SPARSE per-collector overrides layered on CollectorScheduleDefaults. + Absent row / NULL column = code default; server_id NULL = fleet-wide. A PRIMARY KEY cannot span a + nullable server_id, so two partial-unique indexes enforce one fleet-wide row + one per-server row + per collector. */ +CREATE TABLE IF NOT EXISTS config.config_collector_schedules ( + server_id integer, + collector_name text NOT NULL, + frequency_minutes integer CHECK (frequency_minutes >= 0), + retention_days integer CHECK (retention_days >= 1), + enabled boolean NOT NULL DEFAULT TRUE +); +CREATE UNIQUE INDEX IF NOT EXISTS ux_config_collector_schedules_fleet + ON config.config_collector_schedules (collector_name) WHERE server_id IS NULL; +CREATE UNIQUE INDEX IF NOT EXISTS ux_config_collector_schedules_server + ON config.config_collector_schedules (server_id, collector_name) WHERE server_id IS NOT NULL; + +/* 5. config_service — single row: the service-wide flags + the config_version reload beacon. */ +CREATE TABLE IF NOT EXISTS config.config_service ( + id smallint NOT NULL PRIMARY KEY DEFAULT 1 CHECK (id = 1), + paused boolean NOT NULL DEFAULT FALSE, + capture_plans boolean NOT NULL DEFAULT TRUE, + mcp_enabled boolean NOT NULL DEFAULT FALSE, + mcp_port integer NOT NULL DEFAULT 5152, + config_version bigint NOT NULL DEFAULT 0, + updated_at timestamp NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'), + updated_by text +); + +/* --- B. Command plane: the Viewer enqueues, the service (Stage 2) claims/executes/reports. --- */ + +/* 6. config_command — the imperative queue. GENERATED ALWAYS AS IDENTITY (a natural queue key that + needs no sequence USAGE grant for admin INSERTs, unlike serial). */ +CREATE TABLE IF NOT EXISTS config.config_command ( + command_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + created_at timestamp NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'), + requested_by text, + command_type text NOT NULL, + target_server_id integer, + args_json jsonb, + status text NOT NULL DEFAULT 'pending', + claimed_at timestamp, + completed_at timestamp, + result_status text, + result_json jsonb, + service_instance text +); +CREATE INDEX IF NOT EXISTS idx_config_command_status ON config.config_command (status); + +/* --- C. The config_version reload beacon (bump triggers). --- */ + +/* Statement-level bump on the four desired-state tables: any write increments the beacon so the + Viewer can never forget to signal. Targets config_service by qualified name (SECURITY INVOKER — + both writers, the owner during seed and admin via the Viewer, hold UPDATE on config_service). */ +CREATE OR REPLACE FUNCTION config.config_bump_version() RETURNS trigger +LANGUAGE plpgsql AS $bump$ +BEGIN + UPDATE config.config_service + SET config_version = config_version + 1, + updated_at = (now() AT TIME ZONE 'UTC') + WHERE id = 1; + RETURN NULL; +END; +$bump$; + +/* Direct writes to config_service (pause/capture/mcp) self-bump the beacon without recursion: the + BEFORE-UPDATE trigger increments NEW.config_version only when the writer did not already change it + (so the config_bump_version UPDATE above, which sets config_version explicitly, is not doubled). */ +CREATE OR REPLACE FUNCTION config.config_service_bump() RETURNS trigger +LANGUAGE plpgsql AS $svc$ +BEGIN + IF NEW.config_version = OLD.config_version THEN + NEW.config_version := OLD.config_version + 1; + END IF; + NEW.updated_at := (now() AT TIME ZONE 'UTC'); + RETURN NEW; +END; +$svc$; + +DROP TRIGGER IF EXISTS trg_bump_monitored_servers ON config.config_monitored_servers; +CREATE TRIGGER trg_bump_monitored_servers + AFTER INSERT OR UPDATE OR DELETE ON config.config_monitored_servers + FOR EACH STATEMENT EXECUTE FUNCTION config.config_bump_version(); + +DROP TRIGGER IF EXISTS trg_bump_alert_settings ON config.config_alert_settings; +CREATE TRIGGER trg_bump_alert_settings + AFTER INSERT OR UPDATE OR DELETE ON config.config_alert_settings + FOR EACH STATEMENT EXECUTE FUNCTION config.config_bump_version(); + +DROP TRIGGER IF EXISTS trg_bump_notification ON config.config_notification; +CREATE TRIGGER trg_bump_notification + AFTER INSERT OR UPDATE OR DELETE ON config.config_notification + FOR EACH STATEMENT EXECUTE FUNCTION config.config_bump_version(); + +DROP TRIGGER IF EXISTS trg_bump_collector_schedules ON config.config_collector_schedules; +CREATE TRIGGER trg_bump_collector_schedules + AFTER INSERT OR UPDATE OR DELETE ON config.config_collector_schedules + FOR EACH STATEMENT EXECUTE FUNCTION config.config_bump_version(); + +DROP TRIGGER IF EXISTS trg_service_self_bump ON config.config_service; +CREATE TRIGGER trg_service_self_bump + BEFORE UPDATE ON config.config_service + FOR EACH ROW EXECUTE FUNCTION config.config_service_bump();"; + + /// <summary> + /// V18 — the per-server + per-event alert delivery mode (#1236 / #1141) the Viewer writes and the + /// service honors at delivery time. The GLOBAL default lives on <c>config_alert_settings</c> + /// (<c>delivery_mode</c> = Summary/PerEvent, <c>per_event_max</c> = the "+N more" cap the shared + /// <c>PerEventNotification.Split</c> applies); a PER-SERVER override lives on + /// <c>config_monitored_servers</c> (<c>alert_delivery_mode_override</c>, nullable = "use the global"), + /// resolved through the shared <c>AlertDeliveryModeResolver</c>. Additive ALTERs, schema-qualified + /// <c>config.*</c> exactly like V17 (the migrate session's <c>search_path = collect, config, public</c> + /// resolves a bare name to <c>collect</c> — the wrong schema/ACL); <c>IF NOT EXISTS</c> matches the + /// file's ALTER idiom (V7/V9/V15/V16) so a re-run is a harmless no-op. The two settings columns are + /// NOT NULL with the shipped defaults (Summary / 5) so the single seeded row comes up honoring Summary; + /// the per-server column is nullable so an un-overridden server inherits the global. Neither table has a + /// <c>v_*</c> passthrough view, so nothing to refresh. + /// </summary> + private const string V18Sql = @" +ALTER TABLE config.config_alert_settings ADD COLUMN IF NOT EXISTS delivery_mode text NOT NULL DEFAULT 'Summary'; +ALTER TABLE config.config_alert_settings ADD COLUMN IF NOT EXISTS per_event_max integer NOT NULL DEFAULT 5; +ALTER TABLE config.config_monitored_servers ADD COLUMN IF NOT EXISTS alert_delivery_mode_override text;"; + + /// <summary> + /// V19 — the per-server ANALYSIS-STATE marker: the analysis pass's insufficient-data determination, + /// persisted so the Viewer's Recommendations tab can tell a young deployment ("still collecting — + /// need ~24h of history") apart from a genuine all-clear. The engine ALREADY computes this + /// (<c>DarlingAnalysisService.InsufficientDataMessage</c>, surfaced as the AN3 pass's + /// <c>InsufficientData</c> status when total history is under the 24h data-span gate); the Viewer + /// makes no engine call, so without a persisted marker a zero-finding read collapses to "All clear" + /// even when the engine only skipped for want of data. One row per server (<c>server_id</c> PRIMARY + /// KEY), upserted by the service after each completed pass and read by the Viewer. + /// + /// <para><b>Schema — <c>collect</c>, qualified.</b> This is service-produced OBSERVED analysis output + /// the Viewer READS, so it belongs in <c>collect</c> beside <c>analysis_findings</c> / + /// <c>collection_log</c> / <c>servers</c> — NOT the <c>config</c> control plane (which is the opposite + /// direction: the Viewer writes DESIRED state the service reads). The service connects as the owner + /// and writes it; the managed <c>admin</c>/<c>viewer</c> roles auto-inherit SELECT via + /// <c>ALTER DEFAULT PRIVILEGES … IN SCHEMA collect</c> (<see cref="!:DarlingManagedRoles"/>), so no + /// per-table grant is needed. Qualifying <c>collect.</c> is belt-and-suspenders — the migrate + /// session's <c>search_path = collect, config, public</c> already resolves a bare name to + /// <c>collect</c> — but it makes the intent explicit (the mirror of V17/V18's <c>config.</c> + /// qualification). <c>message</c> is nullable (null when a real pass cleared the gate); + /// <c>analysis_time</c> stores naive-UTC like the store-wide convention. No <c>v_*</c> passthrough + /// view: no Lite SQL ports through this Darling-specific marker. + /// </summary> + private const string V19Sql = @" +CREATE TABLE IF NOT EXISTS collect.analysis_state ( + server_id integer NOT NULL PRIMARY KEY, + insufficient_data boolean NOT NULL DEFAULT FALSE, + message text, + analysis_time timestamp NOT NULL +);"; + + /// <summary> + /// V20 — the alert-tuning knobs the Viewer writes and the service honors that had no store column before: + /// the long-running-query read shape (<c>long_running_query_max_results</c> + the five noise-filter opt-outs + /// the shared <c>AlertEngine</c> forwards to <c>GetLongRunningQueriesAsync</c>) and + /// <c>notify_connection_changes</c> (the Server-Unreachable/Restored connect-edge gate in + /// <see cref="!:DarlingSelfAlertEvaluator"/>). Before this the service HARDCODED all of them (Lite's App + /// defaults: max 5, every filter on, connection-notify on), so suppression happened by default but the + /// operator could not customize it — the Settings controls were inert. All columns are additive + /// <c>ADD COLUMN IF NOT EXISTS</c> and NOT NULL with the shipped defaults, so a pre-V20 store's single + /// seeded row comes up honoring exactly the old hardcoded behavior and a re-run is a harmless no-op. + /// Schema-qualified <c>config.*</c> exactly like V17/V18 (the migrate session's + /// <c>search_path = collect, config, public</c> resolves a bare name to <c>collect</c> — the wrong + /// schema/ACL). <c>config_alert_settings</c> has no <c>v_*</c> passthrough view, so nothing to refresh. + /// </summary> + private const string V20Sql = @" +ALTER TABLE config.config_alert_settings ADD COLUMN IF NOT EXISTS long_running_query_max_results integer NOT NULL DEFAULT 5; +ALTER TABLE config.config_alert_settings ADD COLUMN IF NOT EXISTS long_running_query_exclude_sp_server_diagnostics boolean NOT NULL DEFAULT TRUE; +ALTER TABLE config.config_alert_settings ADD COLUMN IF NOT EXISTS long_running_query_exclude_wait_for boolean NOT NULL DEFAULT TRUE; +ALTER TABLE config.config_alert_settings ADD COLUMN IF NOT EXISTS long_running_query_exclude_backups boolean NOT NULL DEFAULT TRUE; +ALTER TABLE config.config_alert_settings ADD COLUMN IF NOT EXISTS long_running_query_exclude_misc_waits boolean NOT NULL DEFAULT TRUE; +ALTER TABLE config.config_alert_settings ADD COLUMN IF NOT EXISTS long_running_query_exclude_cdc boolean NOT NULL DEFAULT TRUE; +ALTER TABLE config.config_alert_settings ADD COLUMN IF NOT EXISTS notify_connection_changes boolean NOT NULL DEFAULT TRUE;"; + + /// <summary> + /// V21 — the <c>default_trace_events</c> collector table (built-in Default Trace read via + /// <c>sys.fn_trace_gettable</c>: file auto-grow/shrink stalls, severe ErrorLog writes, schema DDL, + /// security audits, Server Memory Change). A NEW collector table added additively for a store built + /// before this collector existed; a FRESH store already has it (V1's + /// <see cref="PgSchemaGenerator.GenerateFullSchema"/> now walks the collector — which includes + /// default_trace_events — and V8 moved it to <c>collect</c>), so <c>CREATE TABLE IF NOT EXISTS</c> is a + /// harmless no-op on fresh and the real create on upgrade, with an identical <c>collect.default_trace_events</c> + /// shape either way. EXPLICITLY <c>collect.</c>-qualified (mirroring V19's <c>analysis_state</c>): this + /// runs after V8, whose <c>search_path = collect, config, public</c> already resolves a bare name to + /// <c>collect</c>, but the qualification makes the collect-schema intent explicit. Column order + types + /// mirror the generated V1 shape exactly (the NOT NULL prefix, no PRIMARY KEY — the hypertable / binary + /// COPY reasoning), so the positional COPY aligns on both paths. + /// + /// <para>It has NO <c>v_*</c> passthrough view (no Lite analysis SQL ports through it — the + /// <c>get_default_trace_events</c> MCP tool reads the base table directly, exactly like + /// <c>server_properties</c>), so the <see cref="PgSchemaGenerator.AllPassthroughViews"/> cross-check is + /// unaffected. TimescaleDB hypertable conversion + compression + retention all flow from the catalog + /// automatically (TimescaleSupport / DarlingRetention walk <see cref="CollectorCatalog.All"/>).</para> + /// </summary> + private const string V21Sql = @" +CREATE TABLE IF NOT EXISTS collect.default_trace_events ( + default_trace_event_id bigint NOT NULL, + collection_time timestamp NOT NULL, + server_id integer NOT NULL, + server_name text NOT NULL, + event_time timestamp, + event_name text, + event_class integer, + spid integer, + database_name text, + database_id integer, + login_name text, + host_name text, + application_name text, + object_name text, + filename text, + integer_data bigint, + integer_data_2 bigint, + text_data text, + session_login_name text, + error_number integer, + severity integer, + state integer, + event_sequence bigint, + duration_us bigint, + end_time timestamp +); + +CREATE INDEX IF NOT EXISTS idx_default_trace_events_time ON collect.default_trace_events(server_id, collection_time);"; + + /// <summary> + /// V22 — the supporting index for the hot FinOps Index Analysis read. The viewer's + /// <c>ViewerDataService.IndexObjectStatsLatestSql</c> picks the newest row per index identity with + /// <c>SELECT DISTINCT ON (database_id, object_id, index_id) … WHERE server_id = $1 + /// ORDER BY database_id, object_id, index_id, collection_time DESC</c> — no time bound, so it walks the + /// whole per-server history (daily cadence, 90-day retention) to find each index's latest snapshot. The V1 + /// index <c>idx_index_object_stats_object</c> leads <c>(server_id, database_name, …)</c> — a database_NAME + /// vs the query's database_ID mismatch, so its ordering cannot satisfy the <c>DISTINCT ON (database_id, …)</c> + /// and Postgres sorts the entire server row set. This index matches the read's exact + /// <c>DISTINCT ON</c>/<c>ORDER BY</c> key (<c>server_id, database_id, object_id, index_id, + /// collection_time DESC</c>), so the <c>DISTINCT ON</c> reads in index order with no sort. It is additive: + /// it does NOT replace the V1 index (the anomaly-detector self-joins in <c>PgAnomalyDetector</c> key + /// <c>database_name</c>, which that index still serves) — the two readers diverged on the database key, so + /// each gets its own index. + /// + /// <para><c>index_object_stats</c> is a TimescaleDB hypertable (every collector table is — + /// <c>TimescaleSupport.HypertableTables</c>), so a plain <c>CREATE INDEX</c> is applied across every chunk + /// by Timescale (the standard post-hypertable index path, the same shape as the V1/V21 collector indexes); + /// on a plain-PostgreSQL store it is an ordinary btree. It is a NON-unique index that includes the partition + /// column (<c>collection_time</c>), so it is legal on the hypertable either way. <c>CREATE INDEX IF NOT + /// EXISTS</c> makes it idempotent: an existing V21 store gets the real create, a fresh store (whose V1 built + /// only the differently-keyed <c>idx_index_object_stats_object</c>) gets this second index when its + /// migrations run through V22, and a re-run is a harmless no-op. EXPLICITLY <c>collect.</c>-qualified like + /// V21 (the migrate session's <c>search_path = collect, config, public</c> already resolves the bare name + /// to <c>collect</c>, but the qualification makes the collect-schema intent explicit). No table shape + /// changes, so nothing to refresh for the binary COPY.</para> + /// </summary> + private const string V22Sql = @" +CREATE INDEX IF NOT EXISTS idx_index_object_stats_latest ON collect.index_object_stats (server_id, database_id, object_id, index_id, collection_time DESC);"; + + /// <summary> + /// V23 — converts <c>collect.collection_log</c> (the per-collector-run observability log, the store's + /// highest-volume plain table) from a heap to a TimescaleDB hypertable, and applies the same compression + /// policy the collector hypertables get. As a hypertable its daily retention becomes O(1) + /// <c>drop_chunks</c> (no DELETE churn — see <see cref="!:DarlingRetention"/>), the Overview's per-server + /// <c>MAX(collection_time)</c> freshness read touches only the newest chunk per server (chunk exclusion on + /// the existing <c>idx_collection_log_time (server_id, collection_time)</c> index, kept), and old chunks + /// compress — the scale-readiness pass for 500 servers. + /// + /// <para><b>Engine-plain, GUARDED, and NON-FATAL — a best-effort UPGRADE fast-path, NOT the authoritative + /// conversion.</b> The versioned migrations must run on plain PostgreSQL too (the store works with or + /// without TimescaleDB — see <see cref="TimescaleSupport"/>), where <c>create_hypertable</c> does not + /// exist. So the body is a <c>DO</c> block gated on <c>pg_extension</c> (plain PostgreSQL skips it — plpgsql + /// parses the guarded statements lazily, so the missing function is never parsed) AND wrapped in an + /// <c>EXCEPTION WHEN OTHERS</c> handler so any failure only RAISEs a WARNING and the migration COMMITS + /// regardless. That non-fatality is deliberate and load-bearing: <c>MigrateAsync</c> runs in the service's + /// startup-CRITICAL path (a thrown migration aborts startup), and it runs BEFORE the runtime + /// <c>CREATE EXTENSION</c> (<see cref="TimescaleSupport.TryEnableAsync"/>) — so on a FRESH managed store the + /// guard here is false (the extension is not created yet) and this block no-ops. The AUTHORITATIVE, + /// proven-live, self-healing conversion is <see cref="TimescaleSupport.EnsureCollectionLogHypertableAsync"/>, + /// applied at runtime AFTER the extension exists (the same non-transactional path validated for every + /// collector table). This migration only wins the case where an UPGRADE's store already had the extension + /// created by a prior startup — then it converts collection_log's existing rows a step early — and quietly + /// yields to the runtime path otherwise. <c>collection_log</c> lives OUTSIDE + /// <see cref="CollectorCatalog.All"/> (it is a registry-side table with no <c>ICollectorSchemaInfo</c>), so + /// the catalog-driven convert/compress loops never reach it — it must be handled directly, here and at + /// runtime.</para> + /// + /// <para><b>create_hypertable.</b> <c>migrate_data => true</c> moves existing rows into chunks (the + /// migrate connection uses a long command timeout — <see cref="MigrationCommandTimeoutSeconds"/> — so a + /// large / many-server backlog is not abandoned at the 30s default); <c>if_not_exists => true</c> makes + /// a re-run or an already-converted table a harmless no-op NOTICE (idempotent, so it composes with the + /// runtime path). <c>collection_log</c> has NO PRIMARY KEY / UNIQUE constraint (see V2), so the hypertable + /// rule that a unique key must include the partition column cannot conflict — the usual main risk is simply + /// absent. <c>collection_time</c> stays a naive <c>timestamp</c> (the product-wide cross-store contract), so + /// <c>create_hypertable</c> emits the advisory use-TIMESTAMPTZ WARNING — expected, exactly like the + /// collector hypertables.</para> + /// + /// <para><b>Compression.</b> Enabled + policy added mirroring <see cref="TimescaleSupport"/> for this one + /// table: segment by <c>server_id</c> (every read filters it first) and compress chunks older than the same + /// <see cref="TimescaleSupport.CompressAfterDays"/>, with the same <see cref="TimescaleSupport.ChunkIntervalDays"/> + /// chunk width — the constants are interpolated so the two never drift. <c>if_not_exists</c> on the policy + /// keeps it idempotent. Explicitly <c>collect.</c>-qualified like V21/V22.</para> + /// </summary> + private static string V23Sql => + $@" +/* V23: best-effort UPGRADE fast-path that converts collect.collection_log to a TimescaleDB hypertable + compresses + it, mirroring the collector hypertables. GUARDED on the extension (plain PostgreSQL skips it, keeping the table a + heap with batched-DELETE retention) and wrapped in EXCEPTION WHEN OTHERS so it can NEVER abort the startup-critical + migration. The AUTHORITATIVE conversion is TimescaleSupport.EnsureCollectionLogHypertableAsync at runtime (after + CREATE EXTENSION), which is the proven-live path and self-heals a fresh store this guard skipped. collection_log is + NOT in the collector catalog, so the runtime catalog loops never touch it. */ +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') THEN + PERFORM create_hypertable('collect.collection_log', by_range('collection_time', INTERVAL '{TimescaleSupport.ChunkIntervalDays} days'), if_not_exists => true, migrate_data => true); + ALTER TABLE collect.collection_log SET (timescaledb.compress, timescaledb.compress_segmentby = 'server_id'); + PERFORM add_compression_policy('collect.collection_log', compress_after => INTERVAL '{TimescaleSupport.CompressAfterDays} days', if_not_exists => true); + END IF; +EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'V23: deferred collection_log hypertable conversion to the runtime path (%): %', SQLSTATE, SQLERRM; +END +$$;"; + + /// <summary> + /// V24 — the <c>job_history</c> collector table (retained SQL Agent job-run history from + /// <c>msdb.dbo.sysjobhistory</c>: every step row + the job-outcome row, deduped on the monotonic + /// instance_id high-water mark, 365-day retention) for the fleet-wide Job History tab (issue #1433). A + /// NEW collector table added additively for a store built before this collector existed; a FRESH store + /// already has it (V1's <see cref="PgSchemaGenerator.GenerateFullSchema"/> walks the collector — which + /// now includes job_history — and V8 moved it to <c>collect</c>), so <c>CREATE TABLE IF NOT EXISTS</c> + /// is a harmless no-op on fresh and the real create on upgrade, with an identical + /// <c>collect.job_history</c> shape either way. EXPLICITLY <c>collect.</c>-qualified (mirroring + /// V21's default_trace_events): this runs after V8, whose <c>search_path</c> resolves a bare name to + /// <c>collect</c>, but the explicit schema is defensive. + /// <para>Column order/types are exactly <see cref="PgSchemaGenerator.CreateTable"/>'s output for the + /// <see cref="JobHistoryCollector"/> catalog entry (prefix columns NOT NULL, payload columns nullable — + /// the generator's convention). Like default_trace_events it has NO <c>v_*</c> passthrough view (a + /// collector added after V14 cannot join the V14 view refresh; the viewer reads the base table directly, + /// exactly like server_properties), so the <see cref="PgSchemaGenerator.AllPassthroughViews"/> cross-check + /// is unaffected. TimescaleDB hypertable conversion + compression + 365-day retention all flow from the + /// catalog at runtime (DarlingRetention / TimescaleSupport iterate CollectorCatalog.All), so none is + /// emitted here.</para> + /// </summary> + private const string V24Sql = @" +CREATE TABLE IF NOT EXISTS collect.job_history ( + job_history_id bigint NOT NULL, + collection_time timestamp NOT NULL, + server_id integer NOT NULL, + server_name text NOT NULL, + instance_id bigint, + job_id text, + job_name text, + job_enabled boolean, + category_name text, + step_id integer, + step_name text, + run_status integer, + run_status_desc text, + run_datetime timestamp, + run_duration_seconds bigint, + retries_attempted integer, + message text +); + +CREATE INDEX IF NOT EXISTS idx_job_history_time ON collect.job_history(server_id, collection_time);"; + + /// <summary> + /// V25 — the <c>agent_status</c> collector table (SQL Agent service Running/Stopped from + /// <c>sys.dm_server_services</c> + next scheduled run from <c>msdb.dbo.sysjobschedules</c>): the + /// current-state snapshot behind the Job History tab header and the "Agent Not Running" self-alert + /// (issue #1433 Phase 2). Added additively exactly like V21/V24 — a fresh store already has it (V1's + /// <see cref="PgSchemaGenerator.GenerateFullSchema"/> walks the collector, V8 moved it to + /// <c>collect</c>), so <c>CREATE TABLE IF NOT EXISTS</c> is a no-op on fresh and the real create on + /// upgrade. Column order/types are exactly <see cref="PgSchemaGenerator.CreateTable"/>'s output for the + /// <see cref="AgentStatusCollector"/> catalog entry (prefix NOT NULL, payload nullable). No <c>v_*</c> + /// passthrough view (a post-V14 collector); the viewer reads the base table directly. Hypertable / + /// compression / 7-day retention flow from the catalog at runtime. + /// </summary> + private const string V25Sql = @" +CREATE TABLE IF NOT EXISTS collect.agent_status ( + collection_id bigint NOT NULL, + collection_time timestamp NOT NULL, + server_id integer NOT NULL, + server_name text NOT NULL, + agent_running boolean, + agent_status_desc text, + agent_startup_desc text, + next_scheduled_run timestamp +); + +CREATE INDEX IF NOT EXISTS idx_agent_status_time ON collect.agent_status(server_id, collection_time);"; + + /// <summary> + /// V26 — the generic webhook channel (#1506): a third delivery channel beside Teams/Slack that POSTs an + /// operator-authored JSON body to any endpoint, so an alert can drive automation we ship no adapter for + /// (PagerDuty, Opsgenie, n8n, or a GitHub <c>repository_dispatch</c> that re-runs a workflow). It is the + /// deliberate alternative to executing a script/exe on alert — same automation reach, no process-execution + /// surface. Additive ALTERs on the V17 control-plane table, schema-qualified <c>config.*</c> and + /// <c>IF NOT EXISTS</c> per the file's ALTER idiom (V7/V9/V15/V16/V18), so a re-run is a harmless no-op. + /// The channel is enabled by a non-empty <c>generic_url</c> — the same derivation Teams/Slack use — so the + /// empty-string defaults leave an upgraded store with the channel off. + /// + /// <para><b>Two of these columns are secrets.</b> <c>generic_url</c> is a bearer secret exactly like + /// <c>teams_url</c>/<c>slack_url</c>, and <c>generic_headers</c> holds the <c>Authorization</c> token + /// itself — both are carved out of the read-only <c>viewer</c> role's column grants in + /// <c>DarlingManagedRoles.ViewerRestrictedConfigTables</c>. That list's union-equals-the-table invariant is + /// gated live by the build, so adding these columns without carving them there FAILS the gate rather than + /// silently exposing a token to a viewer seat.</para> + /// </summary> + private const string V26Sql = @" +ALTER TABLE config.config_notification ADD COLUMN IF NOT EXISTS generic_url text NOT NULL DEFAULT ''; +ALTER TABLE config.config_notification ADD COLUMN IF NOT EXISTS generic_headers text NOT NULL DEFAULT ''; +ALTER TABLE config.config_notification ADD COLUMN IF NOT EXISTS generic_body_template text NOT NULL DEFAULT ''; +ALTER TABLE config.config_notification ADD COLUMN IF NOT EXISTS generic_proxy text NOT NULL DEFAULT '';"; + + private const string VersionTableSql = @" +CREATE TABLE IF NOT EXISTS darling_schema_version ( + version integer NOT NULL PRIMARY KEY, + name text NOT NULL, + applied_at timestamp NOT NULL +);"; + + /// <summary> + /// Session-scoped advisory lock key serializing concurrent migrators — two connections + /// racing MigrateAsync (a second service instance misconfigured onto the same store, or + /// parallel test classes) would otherwise both read the same current version and collide on + /// the darling_schema_version primary key. Released explicitly and on connection close. + /// </summary> + private const long MigrationLockKey = 0x4441524C_494E47; /* "DARLING" */ + + /// <summary> + /// Command timeout (seconds) for applying one migration — well above Npgsql's 30s default so a + /// data-moving migration is never abandoned half-done. V23 converts collection_log to a hypertable with + /// <c>migrate_data => true</c>, which rewrites every existing row into chunks; on a long-collected / + /// many-server store that can exceed 30s. Mirrors <see cref="TimescaleSupport"/>'s runtime-conversion + /// budget. Harmless for the DDL-only migrations — they finish in milliseconds regardless. + /// </summary> + private const int MigrationCommandTimeoutSeconds = 300; + + /// <summary> + /// Applies every migration newer than the store's current version, each in its own + /// transaction, stamping darling_schema_version as it goes. Idempotent — a fully migrated + /// store is a no-op — and safe under concurrent callers (advisory-locked). The connection + /// must be open. + /// </summary> + public static Task<int> MigrateAsync(NpgsqlConnection connection, CancellationToken cancellationToken = default) + => MigrateAsync(connection, logger: null, cancellationToken); + + /// <summary> + /// The logger-aware overload: after applying migrations it best-effort sets the database-default + /// <c>search_path = collect, config, public</c> (V8 split) so EVERY future connection resolves the + /// bare table names without a per-connection setting — the store-establishing side of migration. + /// Best-effort because <c>ALTER DATABASE</c> needs the database-owner privilege a least-privilege + /// BYO login may lack; a failure is warned (via <paramref name="logger"/>) but never fails the + /// migration, since the moves already committed and the managed connection strings carry Search + /// Path anyway (see <see cref="TrySetDatabaseSearchPathAsync"/>). + /// </summary> + public static async Task<int> MigrateAsync( + NpgsqlConnection connection, ILogger? logger, CancellationToken cancellationToken = default) + { + if (connection is null) + { + throw new ArgumentNullException(nameof(connection)); + } + + using (var acquireLock = new NpgsqlCommand("SELECT pg_advisory_lock($1)", connection)) + { + acquireLock.Parameters.AddWithValue(MigrationLockKey); + await acquireLock.ExecuteNonQueryAsync(cancellationToken); + } + + int applied; + try + { + applied = await MigrateLockedAsync(connection, cancellationToken); + } + finally + { + try + { + using var releaseLock = new NpgsqlCommand("SELECT pg_advisory_unlock($1)", connection); + releaseLock.Parameters.AddWithValue(MigrationLockKey); + await releaseLock.ExecuteNonQueryAsync(CancellationToken.None); + } + catch + { + /* Connection close releases session advisory locks anyway. */ + } + } + + /* Outside the advisory lock and the per-migration transactions: establish the durable + database-default search_path for all future connections. Idempotent, best-effort. */ + await TrySetDatabaseSearchPathAsync(connection, logger, cancellationToken); + return applied; + } + + private static async Task<int> MigrateLockedAsync(NpgsqlConnection connection, CancellationToken cancellationToken) + { + /* Resolve bare names through collect/config for this migrate session. Load-bearing from V8 + on: V8 moves darling_schema_version into collect, and the version stamp below writes it by + its bare name — without this the post-move stamp would resolve against the default path + ("$user", public) and fail. Setting a path whose schemas don't exist yet is legal (pre-V8 + they simply resolve to public, exactly as before), so this is safe on every store version + and independent of any connection-string Search Path. Session-scoped (outside the + per-migration transactions), so a migration rollback never unsets it. */ + using (var setPath = new NpgsqlCommand("SET search_path = " + PgSchemaGenerator.SearchPath, connection)) + { + await setPath.ExecuteNonQueryAsync(cancellationToken); + } + + using (var createVersionTable = new NpgsqlCommand(VersionTableSql, connection)) + { + await createVersionTable.ExecuteNonQueryAsync(cancellationToken); + } + + int currentVersion; + using (var readVersion = new NpgsqlCommand("SELECT COALESCE(MAX(version), 0) FROM darling_schema_version", connection)) + { + currentVersion = Convert.ToInt32(await readVersion.ExecuteScalarAsync(cancellationToken), System.Globalization.CultureInfo.InvariantCulture); + } + + var applied = 0; + foreach (var migration in Scripts) + { + if (migration.Version <= currentVersion) + { + continue; + } + + using var transaction = await connection.BeginTransactionAsync(cancellationToken); + + using (var apply = new NpgsqlCommand(migration.Sql, connection, transaction) { CommandTimeout = MigrationCommandTimeoutSeconds }) + { + await apply.ExecuteNonQueryAsync(cancellationToken); + } + + using (var stamp = new NpgsqlCommand( + "INSERT INTO darling_schema_version (version, name, applied_at) VALUES ($1, $2, $3)", connection, transaction)) + { + stamp.Parameters.AddWithValue(migration.Version); + stamp.Parameters.AddWithValue(migration.Name); + /* Naive-UTC storage: Npgsql 6+ rejects Kind=Utc against `timestamp` — see PgCollectorRowWriter. */ + stamp.Parameters.AddWithValue(DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified)); + await stamp.ExecuteNonQueryAsync(cancellationToken); + } + + await transaction.CommitAsync(cancellationToken); + applied++; + } + + return applied; + } + + /// <summary> + /// Best-effort: make <c>search_path = collect, config, public</c> the database default, so + /// EVERY future connection (the service pool's collector writes, the MCP host, the Viewer, + /// <c>psql</c>/<c>pg_dump</c>, BYO) resolves the bare table names to the V8 schemas without any + /// per-connection setting. Complements — does not replace — the session <c>SET</c> in + /// <see cref="MigrateAsync"/> (which covers the migrate connection itself) and the + /// <c>Search Path</c> keyword the managed connection strings carry. + /// + /// <para>Deliberately OUTSIDE the V8 transaction and swallowing failure: <c>ALTER DATABASE</c> + /// needs the database-owner privilege, which a least-privilege bring-your-own-Postgres login may + /// lack. A failure here must NOT fail the migration (the moves already committed) — it logs a + /// warning telling the operator to run the statement themselves as owner. Idempotent, so it + /// re-asserts harmlessly on every start. Targets the connection's live database name (managed = + /// <c>darling</c>; BYO = whatever the operator connected to), identifier-quoted.</para> + /// </summary> + public static async Task TrySetDatabaseSearchPathAsync( + NpgsqlConnection connection, ILogger? logger = null, CancellationToken cancellationToken = default) + { + if (connection is null) + { + throw new ArgumentNullException(nameof(connection)); + } + + var databaseName = connection.Database; + if (string.IsNullOrEmpty(databaseName)) + { + return; + } + + var quotedDatabase = "\"" + databaseName.Replace("\"", "\"\"", StringComparison.Ordinal) + "\""; + try + { + using var command = new NpgsqlCommand( + $"ALTER DATABASE {quotedDatabase} SET search_path = {PgSchemaGenerator.SearchPath}", connection); + await command.ExecuteNonQueryAsync(cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogWarning( + "Could not set the database default search_path on {Database} ({Message}). The managed " + + "connection strings still carry Search Path, but if you point your own tools at this store, " + + "run this once as the database owner: ALTER DATABASE {Database} SET search_path = {SearchPath};", + databaseName, ex.Message, databaseName, PgSchemaGenerator.SearchPath); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Storage/PgSchemaGenerator.cs b/Darling/PerformanceMonitor.Darling.Storage/PgSchemaGenerator.cs new file mode 100644 index 000000000..2632423b5 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Storage/PgSchemaGenerator.cs @@ -0,0 +1,479 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using PerformanceMonitor.Collectors; + +namespace PerformanceMonitor.Darling.Storage; + +/// <summary> +/// Generates Darling's Postgres schema from the collector definitions' engine-neutral column +/// metadata (<see cref="ICollectorSchemaInfo"/>) — the same metadata Lite's DuckDB schema was +/// hand-written against, so a collector change lands in exactly one place and both stores stay +/// column-for-column identical. Names and types mirror Lite's DuckDB schema (collection_id / +/// deadlock_id / config_id prefixes, capture_time on the config snapshots, per-column +/// numeric(p,s)) so analysis SQL can twin without translation. Two deliberate physical +/// deviations from Lite: (1) DuckDB's PRIMARY KEY on the prefix id column is NOT carried over — +/// TimescaleDB hypertables require the partition column in any unique constraint, and bulk COPY +/// ingest doesn't want one; the id stays a NOT NULL bigint. (2) Index names are uniform +/// (idx_<table>_time) where Lite's handwritten names are irregular (idx_cpu_time) — index +/// names are physical-only and never referenced by analysis SQL. +/// </summary> +public static class PgSchemaGenerator +{ + /// <summary>Maps an engine-neutral collector column to its Postgres type.</summary> + public static string TypeFor(CollectorColumn column) + { + if (column is null) + { + throw new ArgumentNullException(nameof(column)); + } + + switch (column.Type) + { + case CollectorColumnType.BigInt: return "bigint"; + case CollectorColumnType.Integer: return "integer"; + case CollectorColumnType.SmallInt: return "smallint"; + case CollectorColumnType.Varchar: return "text"; + /* UTC by convention across the product; matches DuckDB TIMESTAMP (no tz). */ + case CollectorColumnType.Timestamp: return "timestamp"; + case CollectorColumnType.Double: return "double precision"; + case CollectorColumnType.Decimal: + if (column.Precision <= 0) + { + throw new InvalidOperationException( + $"Decimal column '{column.Name}' has no declared precision/scale — mirror the DuckDB DECIMAL(p,s) from Lite's schema."); + } + return $"numeric({column.Precision},{column.Scale})"; + case CollectorColumnType.Boolean: return "boolean"; + default: + throw new ArgumentOutOfRangeException(nameof(column), column.Type, "Unmapped collector column type"); + } + } + + /// <summary>Emits CREATE TABLE IF NOT EXISTS for one collector's destination table.</summary> + public static string CreateTable(ICollectorSchemaInfo schema) + { + if (schema is null) + { + throw new ArgumentNullException(nameof(schema)); + } + + var sb = new StringBuilder(); + sb.Append("CREATE TABLE IF NOT EXISTS ").Append(schema.TargetTable).Append(" (\n"); + + if (schema.IncludesCollectionId) + { + sb.Append(" ").Append(schema.PrefixIdColumnName).Append(" bigint NOT NULL,\n"); + } + + sb.Append(" ").Append(schema.PrefixTimeColumnName).Append(" timestamp NOT NULL,\n"); + sb.Append(" server_id integer NOT NULL,\n"); + sb.Append(" server_name text NOT NULL"); + + foreach (var column in schema.PayloadColumns) + { + sb.Append(",\n ").Append(column.Name).Append(' ').Append(TypeFor(column)); + } + + sb.Append("\n);"); + return sb.ToString(); + } + + /// <summary> + /// Emits the table's retrieval index, mirroring Lite's index COLUMNS exactly: the default is + /// (server_id, <prefix time>); memory_pressure_events indexes its payload sample_time, + /// index_object_stats has its composite object drill index, and server_config / + /// database_config have no index in Lite. Returns null when the table has none. + /// </summary> + public static string? CreateIndex(ICollectorSchemaInfo schema) + { + if (schema is null) + { + throw new ArgumentNullException(nameof(schema)); + } + + switch (schema.TargetTable) + { + case "server_config": + case "database_config": + return null; + case "memory_pressure_events": + return "CREATE INDEX IF NOT EXISTS idx_memory_pressure_events_time ON memory_pressure_events(server_id, sample_time);"; + case "index_object_stats": + return "CREATE INDEX IF NOT EXISTS idx_index_object_stats_object ON index_object_stats(server_id, database_name, object_id, index_id, collection_time);"; + default: + return $"CREATE INDEX IF NOT EXISTS idx_{schema.TargetTable}_time ON {schema.TargetTable}(server_id, {schema.PrefixTimeColumnName});"; + } + } + + /// <summary> + /// The two schemas the V8 security split introduces. <c>collect</c> holds the collector + /// hypertables plus the service-written / user-read metadata (read-only to the admin/viewer + /// roles); <c>config</c> holds exactly the tables a human operator mutates through the + /// Viewer/MCP (the admin role's write surface). Table NAMES are unchanged — only the schema + /// moves; every SQL site keeps its bare table reference and resolves through + /// <c>search_path = collect, config, public</c>. See darling-security-hardening §3.1. + /// </summary> + public const string CollectSchema = "collect"; + public const string ConfigSchema = "config"; + + /// <summary>The database-default search path V8 establishes (see <see cref="GenerateV8Move"/>).</summary> + public const string SearchPath = "collect, config, public"; + + /// <summary> + /// The service-written / user-read metadata tables that join the collector hypertables in + /// <c>collect</c> (they are NOT in <see cref="CollectorCatalog"/>). <c>analysis_findings</c> is + /// service-written and viewer-read-only (a keyless hypertable candidate), so it lives with the + /// other collected data rather than with the operator-writable mutes (design fork #1 → collect). + /// <c>darling_schema_version</c> is migration metadata (owner-only; no user grant needed). + /// </summary> + public static readonly IReadOnlyList<string> CollectMetadataTables = new[] + { + "servers", + "collection_log", + "analysis_findings", + "darling_schema_version", + }; + + /// <summary> + /// The operator-writable coordination + analysis-mute tables that move to <c>config</c> — the + /// admin role's precise write surface (mute rules, alert dismissals, analysis mutes; the + /// edge-trigger watermarks ride along with the alert-coordination family). See §3.1. + /// </summary> + public static readonly IReadOnlyList<string> ConfigTables = new[] + { + "config_alert_log", + "config_edge_trigger_watermarks", + "config_mute_rules", + "analysis_muted", + }; + + /// <summary> + /// The 24 <c>v_*</c> passthrough views (V4 ×17, V5 ×5, V6 ×2) — all read-only, all moving to + /// <c>collect</c> with the tables they wrap. Listed explicitly because the view set is not + /// derivable from the collector catalog (e.g. <c>v_collection_log</c> wraps a registry table, + /// and several collector tables have no view). Pinned by test against the migration DDL. + /// </summary> + public static readonly IReadOnlyList<string> CollectViews = new[] + { + "v_wait_stats", "v_query_stats", "v_query_store_stats", "v_cpu_utilization_stats", + "v_memory_grant_stats", "v_memory_stats", "v_perfmon_stats", "v_session_stats", + "v_file_io_stats", "v_blocked_process_reports", "v_deadlocks", "v_dmv_blocking_snapshots", + "v_index_object_stats", "v_database_size_stats", "v_tempdb_stats", "v_query_snapshots", + "v_database_config", "v_running_jobs", "v_server_config", "v_database_scoped_config", + "v_trace_flags", "v_collection_log", "v_memory_clerks", "v_memory_pressure_events", + }; + + /// <summary> + /// The collectors whose <c>v_*</c> passthrough views were added AFTER the V8 schema split + /// (V10 latch/spinlock, V11 cpu-scheduler/plan-cache, V12 session-summary, V13 system-health), + /// so their views are NOT in <see cref="CollectViews"/> (which lists only the V4–V6 views V8 + /// moves). Referenced as the SAME collector instances the V10–V13 generators emit, so the set + /// tracks the collector definitions rather than a parallel name list. + /// </summary> + private static readonly IReadOnlyList<ICollectorSchemaInfo> PostV8ViewCollectors = new ICollectorSchemaInfo[] + { + LatchStatsCollector.Instance, + SpinlockStatsCollector.Instance, + CpuSchedulerStatsCollector.Instance, + PlanCacheStatsCollector.Instance, + SessionSummaryStatsCollector.Instance, + SystemHealthEventsCollector.Instance, + }; + + /// <summary> + /// Every <c>v_*</c> passthrough view that exists in a fully-migrated store, in creation order: + /// the V4–V6 views (<see cref="CollectViews"/>) followed by the post-V8 collector views + /// (<see cref="PostV8ViewCollectors"/>). The single authoritative view list — + /// <see cref="GenerateV14RefreshViews"/> refreshes exactly this set, and a test cross-checks it + /// against every <c>CREATE OR REPLACE VIEW</c> statement across all migrations so the two can + /// never diverge. Every entry is <c>v_<table></c>; the base table is the name minus the + /// <c>v_</c> prefix. + /// </summary> + public static readonly IReadOnlyList<string> AllPassthroughViews = + CollectViews.Concat(PostV8ViewCollectors.Select(c => "v_" + c.TargetTable)).ToArray(); + + /// <summary> + /// The V8 schema-split migration body — creates <c>collect</c>/<c>config</c> and moves every + /// existing object out of <c>public</c> with <c>ALTER … SET SCHEMA</c>. Generated (not + /// hand-listed) so a collector added to <see cref="CollectorCatalog"/> is moved automatically; + /// the registry/config/view names that are not catalog-derived come from the explicit lists + /// above. Every move is <c>IF EXISTS</c> (a no-op when a fresh V1–V7 run has not created the + /// object, or a partial prior run already moved it — though the whole migration is + /// transactional, so partial runs roll back; the guard is defense in depth). + /// + /// <para>Runs identically on a FRESH store (V1–V7 just created plain tables in <c>public</c>, + /// which V8 moves before TimescaleDB converts the now-in-<c>collect</c> tables to hypertables) + /// AND on an EXISTING store (Erik's fixture, where the collector tables are already + /// hypertables — <c>ALTER TABLE <hypertable> SET SCHEMA</c> moves the hypertable and + /// propagates to its chunks; validated live on TimescaleDB 2.28.1). The + /// <c>ALTER DATABASE … SET search_path</c> is deliberately NOT here — it needs the live + /// database name and is a best-effort step outside this transaction + /// (<see cref="PgMigrations"/>), so a least-privilege BYO login that lacks it does not fail the + /// whole migration.</para> + /// </summary> + public static string GenerateV8Move() + { + var sb = new StringBuilder(); + sb.Append("/* V8: split public into collect/config (Darling security hardening, #1262).\n"); + sb.Append(" Table NAMES are unchanged; search_path = collect, config, public resolves the bare\n"); + sb.Append(" references every SQL site already uses, so no query is re-qualified. */\n\n"); + + sb.Append("CREATE SCHEMA IF NOT EXISTS ").Append(CollectSchema).Append(" AUTHORIZATION ") + .Append(OwnerRole).Append(";\n"); + sb.Append("CREATE SCHEMA IF NOT EXISTS ").Append(ConfigSchema).Append(" AUTHORIZATION ") + .Append(OwnerRole).Append(";\n\n"); + + sb.Append("/* collect: all collector tables (from the catalog) + registry/metadata + the V4-V6 views */\n"); + foreach (var schema in CollectorCatalog.All) + { + AppendSetSchema(sb, "TABLE", schema.TargetTable, CollectSchema); + } + + foreach (var table in CollectMetadataTables) + { + AppendSetSchema(sb, "TABLE", table, CollectSchema); + } + + foreach (var view in CollectViews) + { + AppendSetSchema(sb, "VIEW", view, CollectSchema); + } + + sb.Append("\n/* config: the operator-writable coordination + analysis-mute tables */\n"); + foreach (var table in ConfigTables) + { + AppendSetSchema(sb, "TABLE", table, ConfigSchema); + } + + return sb.ToString(); + } + + /// <summary>The owner role every managed object is authored by (the bootstrap superuser).</summary> + private const string OwnerRole = "darling"; + + private static void AppendSetSchema(StringBuilder sb, string objectKind, string objectName, string targetSchema) + { + sb.Append("ALTER ").Append(objectKind).Append(" IF EXISTS public.").Append(objectName) + .Append(" SET SCHEMA ").Append(targetSchema).Append(";\n"); + } + + /// <summary> + /// The full collector-table schema script in catalog order — the body of Darling's first + /// versioned migration. TimescaleDB hypertable conversion is deliberately NOT emitted here; + /// it is runtime setup (<see cref="TimescaleSupport"/>), applied by the service only when + /// the extension is detected, so this script stays valid on plain PostgreSQL. + /// </summary> + public static string GenerateFullSchema() + { + var sb = new StringBuilder(); + sb.Append("/* Darling collector tables — generated from PerformanceMonitor.Collectors definitions.\n"); + sb.Append(" Column names and types mirror Lite's DuckDB schema (see PgSchemaGenerator remarks). */\n"); + + foreach (var schema in CollectorCatalog.All) + { + sb.Append('\n').Append(CreateTable(schema)).Append('\n'); + + var index = CreateIndex(schema); + if (index is not null) + { + sb.Append(index).Append('\n'); + } + } + + return sb.ToString(); + } + + /// <summary> + /// The V10 migration body — adds the latch_stats and spinlock_stats collector tables plus their + /// <c>v_*</c> passthrough views. Generated from the collector definitions (not hand-listed) so + /// the tables are column-for-column identical to the fresh-store V1 shape. <c>CREATE TABLE IF + /// NOT EXISTS</c> makes this a harmless no-op on a fresh store (V1 already created these from the + /// current catalog and V8 moved them to <c>collect</c>) and the real create on a store built + /// before these collectors existed; the views are created directly in <c>collect</c> because V10 + /// runs after V8, resolving the bare names through <c>search_path = collect, config, public</c>. + /// </summary> + public static string GenerateV10AddLatchSpinlock() + { + var sb = new StringBuilder(); + sb.Append("/* V10: latch_stats + spinlock_stats collectors (Dashboard->Darling parity, #1262).\n"); + sb.Append(" Generated from the collector definitions so the tables match the fresh V1 shape. */\n"); + + foreach (ICollectorSchemaInfo schema in new ICollectorSchemaInfo[] + { + LatchStatsCollector.Instance, + SpinlockStatsCollector.Instance, + }) + { + sb.Append('\n').Append(CreateTable(schema)).Append('\n'); + + var index = CreateIndex(schema); + if (index is not null) + { + sb.Append(index).Append('\n'); + } + + sb.Append("CREATE OR REPLACE VIEW v_").Append(schema.TargetTable) + .Append(" AS SELECT * FROM ").Append(schema.TargetTable).Append(";\n"); + } + + return sb.ToString(); + } + + /// <summary> + /// The V11 migration body — adds the cpu_scheduler_stats and plan_cache_stats collector tables + /// plus their <c>v_*</c> passthrough views. Generated from the collector definitions (not + /// hand-listed) so the tables are column-for-column identical to the fresh-store V1 shape. + /// <c>CREATE TABLE IF NOT EXISTS</c> makes this a harmless no-op on a fresh store (V1 already + /// created these from the current catalog and V8 moved them to <c>collect</c>) and the real + /// create on a store built before these collectors existed; the views are created directly in + /// <c>collect</c> because V11 runs after V8, resolving the bare names through + /// <c>search_path = collect, config, public</c>. The Darling service collects cpu_scheduler_stats + /// only on non-Azure-SQL-DB targets (its AppliesTo gate); the table still exists everywhere. + /// </summary> + public static string GenerateV11AddCpuSchedulerPlanCache() + { + var sb = new StringBuilder(); + sb.Append("/* V11: cpu_scheduler_stats + plan_cache_stats collectors (Dashboard->Darling parity, #1262).\n"); + sb.Append(" Generated from the collector definitions so the tables match the fresh V1 shape. */\n"); + + foreach (ICollectorSchemaInfo schema in new ICollectorSchemaInfo[] + { + CpuSchedulerStatsCollector.Instance, + PlanCacheStatsCollector.Instance, + }) + { + sb.Append('\n').Append(CreateTable(schema)).Append('\n'); + + var index = CreateIndex(schema); + if (index is not null) + { + sb.Append(index).Append('\n'); + } + + sb.Append("CREATE OR REPLACE VIEW v_").Append(schema.TargetTable) + .Append(" AS SELECT * FROM ").Append(schema.TargetTable).Append(";\n"); + } + + return sb.ToString(); + } + + /// <summary> + /// The V12 migration body — adds the session_summary_stats collector table (server-wide session + /// SUMMARY: the connection-leak / idle signal from sys.dm_exec_sessions + sys.dm_exec_requests) + /// plus its <c>v_*</c> passthrough view. Generated from the collector definition (not hand-listed) + /// so the table is column-for-column identical to the fresh-store V1 shape. <c>CREATE TABLE IF NOT + /// EXISTS</c> makes this a harmless no-op on a fresh store (V1 already created it from the current + /// catalog and V8 moved it to <c>collect</c>) and the real create on a store built before this + /// collector existed; the view is created directly in <c>collect</c> because V12 runs after V8, + /// resolving the bare names through <c>search_path = collect, config, public</c>. Distinct from the + /// per-application session_stats table (V1), which this does not touch. + /// </summary> + public static string GenerateV12AddSessionSummary() + { + var sb = new StringBuilder(); + sb.Append("/* V12: session_summary_stats collector (Dashboard->Darling connection-leak / idle parity, #1262).\n"); + sb.Append(" Generated from the collector definition so the table matches the fresh V1 shape. */\n"); + + foreach (ICollectorSchemaInfo schema in new ICollectorSchemaInfo[] + { + SessionSummaryStatsCollector.Instance, + }) + { + sb.Append('\n').Append(CreateTable(schema)).Append('\n'); + + var index = CreateIndex(schema); + if (index is not null) + { + sb.Append(index).Append('\n'); + } + + sb.Append("CREATE OR REPLACE VIEW v_").Append(schema.TargetTable) + .Append(" AS SELECT * FROM ").Append(schema.TargetTable).Append(";\n"); + } + + return sb.ToString(); + } + + /// <summary> + /// The V13 migration body — adds the system_health_events collector table (Stage 1 raw + /// <c>system_health</c> Extended Events capture: one row per event, raw XML only, no shredding) + /// plus its <c>v_*</c> passthrough view. Generated from the collector definition (not hand-listed) + /// so the table is column-for-column identical to the fresh-store V1 shape. <c>CREATE TABLE IF NOT + /// EXISTS</c> makes this a harmless no-op on a fresh store (V1 already created it from the current + /// catalog and V8 moved it to <c>collect</c>) and the real create on a store built before this + /// collector existed; the view is created directly in <c>collect</c> because V13 runs after V8, + /// resolving the bare names through <c>search_path = collect, config, public</c>. + /// </summary> + public static string GenerateV13AddSystemHealthEvents() + { + var sb = new StringBuilder(); + sb.Append("/* V13: system_health_events collector (Dashboard->Darling health-parser capture parity, #1262).\n"); + sb.Append(" Generated from the collector definition so the table matches the fresh V1 shape. */\n"); + + foreach (ICollectorSchemaInfo schema in new ICollectorSchemaInfo[] + { + SystemHealthEventsCollector.Instance, + }) + { + sb.Append('\n').Append(CreateTable(schema)).Append('\n'); + + var index = CreateIndex(schema); + if (index is not null) + { + sb.Append(index).Append('\n'); + } + + sb.Append("CREATE OR REPLACE VIEW v_").Append(schema.TargetTable) + .Append(" AS SELECT * FROM ").Append(schema.TargetTable).Append(";\n"); + } + + return sb.ToString(); + } + + /// <summary> + /// The V14 migration body — <c>CREATE OR REPLACE</c> every <c>v_*</c> passthrough view + /// (<see cref="AllPassthroughViews"/>) as <c>SELECT * FROM</c> its base table, refreshing the + /// column list a store UPGRADED across a column-adding migration would otherwise have pinned + /// stale. Postgres (and DuckDB) freeze a view's <c>SELECT *</c> expansion at CREATE time, so the + /// views created in V4–V6/V10–V13 do NOT include columns later <c>ADD</c>ed to their base tables + /// — the V7 plan columns on <c>blocked_process_reports</c>/<c>deadlocks</c> and the V9 inventory + /// columns. A FRESH store is unaffected (its views were created from the current table shapes); + /// only an upgraded store is stale, which PR #1376 discovered and worked around for the plan + /// columns by reading base tables — this migration closes the root cause for every view. + /// + /// <para>Safe because every view is a bare <c>v_<table></c> passthrough and every post-V4 + /// change was <c>ADD COLUMN</c> (Postgres always appends), so the refreshed <c>SELECT *</c> only + /// ADDs columns at the end — the one alteration <c>CREATE OR REPLACE VIEW</c> permits. Runs after + /// V8, so the bare names resolve through <c>search_path = collect, config, public</c> to the + /// collect-schema views/tables (the existing view is found and replaced in place). Idempotent: + /// on an already-current view it re-installs the identical definition.</para> + /// </summary> + public static string GenerateV14RefreshViews() + { + var sb = new StringBuilder(); + sb.Append("/* V14: refresh every v_* passthrough view's pinned SELECT * column list (#1262).\n"); + sb.Append(" Postgres freezes SELECT * at CREATE, so an upgraded store's views omit columns\n"); + sb.Append(" ADDed later (V7 plan columns, V9 inventory); CREATE OR REPLACE re-expands them.\n"); + sb.Append(" Append-only ADD COLUMNs => the refresh only adds columns at the end (always legal). */\n\n"); + + foreach (var view in AllPassthroughViews) + { + /* Every view is v_<table>; the base table is the name minus the "v_" prefix. */ + var table = view.Substring("v_".Length); + sb.Append("CREATE OR REPLACE VIEW ").Append(view) + .Append(" AS SELECT * FROM ").Append(table).Append(";\n"); + } + + return sb.ToString(); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs b/Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs new file mode 100644 index 000000000..eaaa28829 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +namespace PerformanceMonitor.Darling.Storage; + +/// <summary> +/// The latest schema version this build knows — always the highest version in +/// <see cref="PgMigrations.Scripts"/> (pinned by test). The service applies pending versioned +/// SQL migrations on startup (headless plan: "plain versioned SQL scripts the service applies +/// on startup"); a store at this version is fully migrated. +/// </summary> +public static class StorageVersion +{ + public const int SchemaVersion = 31; +} diff --git a/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs b/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs new file mode 100644 index 000000000..de3c1fde5 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs @@ -0,0 +1,550 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Npgsql; +using PerformanceMonitor.Collectors; + +namespace PerformanceMonitor.Darling.Storage; + +/// <summary> +/// Optional TimescaleDB adoption — RUNTIME setup, deliberately NOT a versioned migration. The +/// store must work with or without the extension (plain PostgreSQL remains fully supported), so +/// the versioned <see cref="PgMigrations"/> scripts stay engine-plain and every Timescale feature +/// here is gated on extension presence, detected at runtime, never assumed. The service calls +/// <see cref="TryEnableAsync"/> once at startup right after migration; when the extension is +/// present it converts the collector tables to hypertables and applies compression policies — +/// all idempotent (<c>if_not_exists</c> everywhere), so every restart re-converges, and a store +/// that grew new collector tables since the last start picks them up on the next. +/// +/// Scope: the COLLECTOR tables only (<see cref="HypertableTables"/> = the shared catalog). The +/// registry/config tables (servers, config_alert_log, config_edge_trigger_watermarks, +/// config_mute_rules, analysis_muted, darling_schema_version) are deliberately excluded — +/// registries keep their PRIMARY KEYs, which TimescaleDB would reject or force onto the partition +/// column, and none of them is time-series-shaped growth. analysis_findings COULD be a hypertable +/// later (it was designed keyless for exactly this, see the V4 remarks) — deliberately not +/// converted yet; revisit when finding volume warrants it. +/// +/// <para><c>collection_log</c> IS a hypertable (the per-run observability log — the store's +/// highest-volume plain table), but it is converted + compressed DIRECTLY by the V23 migration +/// (<see cref="PgMigrations"/>), NOT here, because it lives OUTSIDE the collector catalog (it has no +/// <c>ICollectorSchemaInfo</c>), so the catalog-driven loops below never reach it. Its retention is +/// likewise handled directly by DarlingRetention (<c>drop_chunks</c>). It is counted in +/// <see cref="HypertableCount"/> so worker sizing reflects its compression policy.</para> +/// +/// The collector tables were designed for this conversion: no PRIMARY KEY (see the +/// <see cref="PgSchemaGenerator"/> remarks) and a NOT NULL prefix time column per table +/// (<see cref="ICollectorSchemaInfo.PrefixTimeColumnName"/> — "collection_time" almost +/// everywhere, the config snapshots' "capture_time", memory_pressure_events included: its +/// prefix column is still collection_time; payload sample_time is not the partition column). +/// The partition columns are naive-UTC <c>timestamp</c> by the product-wide cross-store +/// contract, so create_hypertable emits an advisory use-TIMESTAMPTZ WARNING — expected and +/// accepted (validated live on TimescaleDB 2.28.1). +/// </summary> +public static class TimescaleSupport +{ + /// <summary> + /// Compress chunks older than this many days — hardcoded (defaults over speculative config). + /// Compressed chunks remain fully queryable, just columnar and ~10-20x smaller: this IS + /// Darling's archival tier, the centralized-store answer to Lite's parquet archive, keeping the + /// full retention horizon cheap instead of splitting hot/cold stores. Kept short (1 day) to + /// match <see cref="ChunkIntervalDays"/>: at the collectors' 1-minute cadence a longer lag left + /// the whole store uncompressed (a chunk cannot compress until it closes AND then ages past + /// this), so even a near-idle fleet grew ~1 GB in a couple of days of hot data. Collectors only + /// ever append current-time rows, so a day-old chunk never takes another write — safe to + /// compress. Measured on this data: perfmon ~16.7x, plan-XML-heavy query_stats ~6.4x. + /// </summary> + public const int CompressAfterDays = 1; + + /// <summary> + /// Hypertable chunk width in days. TimescaleDB's 7-day default is far too coarse for + /// 1-minute-cadence monitoring data: a chunk stays open (and uncompressible) for its whole + /// span, so 7-day chunks meant nothing compressed for ~2 weeks. 1-day chunks close daily and + /// become compressible within <see cref="CompressAfterDays"/>, keeping the store compact. + /// Applies at hypertable creation (fresh stores); existing chunks keep their original width. + /// </summary> + public const int ChunkIntervalDays = 1; + + /* The first conversion of a long-collected plain-PG store rewrites every row into chunks + (migrate_data); Npgsql's default 30-second command timeout would abandon it halfway. + Same budget reasoning as DarlingRetention's first-purge DELETE. */ + private const int SetupTimeoutSeconds = 300; + + /// <summary> + /// The tables converted to hypertables — exactly the shared collector catalog, pinned by + /// test so scope can never silently widen to the registry/config/analysis tables (see the + /// class remarks for why those stay plain). + /// </summary> + public static IReadOnlyList<ICollectorSchemaInfo> HypertableTables => CollectorCatalog.All; + + /// <summary> + /// The TRUE number of TimescaleDB hypertables in the store: the collector catalog + /// (<see cref="HypertableTables"/>) PLUS <c>collection_log</c>, which is a hypertable (converted by the + /// V23 migration) but lives OUTSIDE the catalog. Worker sizing derives from THIS so it is not under-sized + /// by one background-worker slot for collection_log's compression policy. The <c>+ 1</c> must move if + /// another non-catalog table is ever converted (pinned by test). + /// </summary> + public static int HypertableCount => HypertableTables.Count + 1; + + /// <summary> + /// Is the timescaledb extension installed AND created in this database (extensions are + /// per-database, so pg_extension is the authoritative check)? Callers cache the answer per + /// data source — the worker detects once at startup and passes the flag around. + /// </summary> + public static async Task<bool> DetectAsync(NpgsqlConnection connection, CancellationToken cancellationToken = default) + { + if (connection is null) + { + throw new ArgumentNullException(nameof(connection)); + } + + using var command = new NpgsqlCommand( + "SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')", connection); + return (bool)(await command.ExecuteScalarAsync(cancellationToken))!; + } + + /// <summary> + /// Attempts <c>CREATE EXTENSION IF NOT EXISTS timescaledb</c> and reports whether the + /// extension is usable. IF NOT EXISTS short-circuits before any privilege check, so a store + /// whose administrator pre-created the extension works for a service account that could + /// never create it; a server without the loadable library (or without the privilege to + /// create it) throws, which degrades gracefully to "not available" — logged once at + /// Information (plain-PostgreSQL mode is a fully supported configuration, not a problem). + /// </summary> + public static async Task<bool> TryEnableAsync(NpgsqlConnection connection, ILogger? logger, CancellationToken cancellationToken = default) + { + if (connection is null) + { + throw new ArgumentNullException(nameof(connection)); + } + + try + { + using var create = new NpgsqlCommand("CREATE EXTENSION IF NOT EXISTS timescaledb", connection); + await create.ExecuteNonQueryAsync(cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogInformation("TimescaleDB not available — running in plain-PostgreSQL mode ({Message})", ex.Message); + return false; + } + + /* Belt-and-suspenders: CREATE EXTENSION IF NOT EXISTS succeeding means present, but + pg_extension stays the single source of truth for "installed AND created". */ + var present = await DetectAsync(connection, cancellationToken); + if (present) + { + logger?.LogInformation("TimescaleDB detected — hypertables, chunk-based retention, and compression enabled"); + } + else + { + logger?.LogInformation("TimescaleDB not available — running in plain-PostgreSQL mode"); + } + + return present; + } + + /// <summary> + /// One collector table's hypertable conversion, partitioned on the definition's own prefix + /// time column. The generalized <c>by_range</c> dimension form, validated live on + /// TimescaleDB 2.28.1: <c>if_not_exists</c> makes an already-converted table a no-op NOTICE + /// and <c>migrate_data</c> moves any rows a plain-PG store collected before the extension + /// arrived. Table and column names come from the shared catalog constants, never from user + /// input, so interpolation is safe here — the same reasoning as + /// DarlingRetention.DeleteSqlFor. + /// </summary> + public static string CreateHypertableSql(ICollectorSchemaInfo schema) + { + if (schema is null) + { + throw new ArgumentNullException(nameof(schema)); + } + + return CreateHypertableSql(schema.TargetTable, schema.PrefixTimeColumnName); + } + + /// <summary> + /// The raw-name hypertable-conversion overload — the collection_log path (a hypertable since V23 but + /// outside the collector catalog, so it has no <see cref="ICollectorSchemaInfo"/>). Identical shape to the + /// schema overload; table/column come from compile-time constants, never user input, so interpolation is + /// safe (the same reasoning as DarlingRetention.DeleteSqlFor). + /// </summary> + public static string CreateHypertableSql(string table, string timeColumn) + => $"SELECT create_hypertable('{table}', by_range('{timeColumn}', INTERVAL '{ChunkIntervalDays} days'), if_not_exists => true, migrate_data => true)"; + + /// <summary> + /// One collector table's compression enablement, segmented by server_id so each server's + /// rows compress together (every query filters server_id first — the retrieval indexes lead + /// with it). The order-by defaults to the partition time column descending, which is exactly + /// the read order. NOTE for the live validator: this is the long-stable pre-2.18 compression + /// vocabulary (<c>timescaledb.compress</c> / <c>compress_segmentby</c>); TimescaleDB 2.18+ + /// rebranded it "columnstore" (<c>timescaledb.enable_columnstore</c> / <c>segmentby</c>) but + /// keeps these as supported aliases — preferred here for compatibility across 2.x. + /// </summary> + public static string EnableCompressionSql(ICollectorSchemaInfo schema) + { + if (schema is null) + { + throw new ArgumentNullException(nameof(schema)); + } + + return EnableCompressionSql(schema.TargetTable); + } + + /// <summary>The raw-name compression-enable overload — the collection_log path (see + /// <see cref="CreateHypertableSql(string, string)"/>).</summary> + public static string EnableCompressionSql(string table) + => $"ALTER TABLE {table} SET (timescaledb.compress, timescaledb.compress_segmentby = 'server_id')"; + + /// <summary> + /// One collector table's background compression policy — chunks older than + /// <see cref="CompressAfterDays"/> compress automatically; <c>if_not_exists</c> makes the + /// re-apply on every service start a no-op. Same 2.18+ naming note as + /// <see cref="EnableCompressionSql"/> (<c>add_compression_policy</c> is the long-stable + /// alias of the newer <c>add_columnstore_policy</c>). + /// </summary> + public static string AddCompressionPolicySql(ICollectorSchemaInfo schema) + { + if (schema is null) + { + throw new ArgumentNullException(nameof(schema)); + } + + return AddCompressionPolicySql(schema.TargetTable); + } + + /// <summary>The raw-name compression-policy overload — the collection_log path (see + /// <see cref="CreateHypertableSql(string, string)"/>).</summary> + public static string AddCompressionPolicySql(string table) + => $"SELECT add_compression_policy('{table}', compress_after => INTERVAL '{CompressAfterDays} days', if_not_exists => true)"; + + /// <summary> + /// Converts every collector table to a hypertable (<see cref="HypertableTables"/> scope; + /// <see cref="CreateHypertableSql"/> per table). Failure-isolated per table: one failed + /// conversion warns and the sweep continues — that table stays a plain PG table, keeps + /// working (COPY and DELETE-based retention are hypertable-agnostic), and is retried on the + /// next service start. Returns the number of tables that converted (or no-op'd) cleanly. + /// </summary> + public static async Task<int> ConvertToHypertablesAsync(NpgsqlConnection connection, ILogger? logger, CancellationToken cancellationToken = default) + { + if (connection is null) + { + throw new ArgumentNullException(nameof(connection)); + } + + var converted = 0; + foreach (var schema in HypertableTables) + { + try + { + using var command = new NpgsqlCommand(CreateHypertableSql(schema), connection) { CommandTimeout = SetupTimeoutSeconds }; + await command.ExecuteNonQueryAsync(cancellationToken); + converted++; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogWarning("Hypertable conversion failed for {Table} — it stays a plain table: {Message}", + schema.TargetTable, ex.Message); + } + } + + logger?.LogInformation("TimescaleDB: {Converted}/{Total} collector table(s) are hypertables", + converted, HypertableTables.Count); + return converted; + } + + /// <summary> + /// Enables compression and adds the <see cref="CompressAfterDays"/>-day background policy on + /// every collector table (both statements per table, failure-isolated per table — a table + /// that failed hypertable conversion warns here too and stays uncompressed). Compressed + /// chunks remain fully queryable: this is Darling's archival tier (see + /// <see cref="CompressAfterDays"/>). Returns the number of tables with a policy in place. + /// </summary> + public static async Task<int> ApplyCompressionPolicyAsync(NpgsqlConnection connection, ILogger? logger, CancellationToken cancellationToken = default) + { + if (connection is null) + { + throw new ArgumentNullException(nameof(connection)); + } + + var applied = 0; + foreach (var schema in HypertableTables) + { + try + { + using (var enable = new NpgsqlCommand(EnableCompressionSql(schema), connection) { CommandTimeout = SetupTimeoutSeconds }) + { + await enable.ExecuteNonQueryAsync(cancellationToken); + } + + using (var policy = new NpgsqlCommand(AddCompressionPolicySql(schema), connection) { CommandTimeout = SetupTimeoutSeconds }) + { + await policy.ExecuteNonQueryAsync(cancellationToken); + } + + applied++; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogWarning("Compression policy failed for {Table} — it stays uncompressed: {Message}", + schema.TargetTable, ex.Message); + } + } + + logger?.LogInformation("TimescaleDB: compression policy ({Days}d) in place on {Applied}/{Total} collector table(s)", + CompressAfterDays, applied, HypertableTables.Count); + return applied; + } + + /// <summary>The V23 non-catalog hypertable: the per-run observability log. Bare name — the connection's + /// <c>collect,config,public</c> search path resolves it to <c>collect.collection_log</c>, exactly like the + /// collector tables' bare TargetTable names.</summary> + public const string CollectionLogTable = "collection_log"; + + /// <summary>collection_log's partition (prefix time) column.</summary> + public const string CollectionLogTimeColumn = "collection_time"; + + /// <summary> + /// The AUTHORITATIVE conversion + compression of <c>collection_log</c> — a hypertable since V23, but OUTSIDE + /// the collector catalog, so <see cref="ConvertToHypertablesAsync"/>/<see cref="ApplyCompressionPolicyAsync"/> + /// (which iterate the catalog) never reach it. Called by the worker in the runtime TimescaleDB block, AFTER + /// <see cref="TryEnableAsync"/> has created the extension — which is exactly why this, not the V23 migration, + /// is authoritative: migrations run BEFORE <c>CREATE EXTENSION</c>, so a fresh store's V23 guard skips the + /// conversion, and this heals it. Same three statements the collector tables get, via the raw-name overloads + /// (<see cref="CreateHypertableSql(string, string)"/>: <c>migrate_data</c> moves any existing rows into + /// chunks — the proven non-transactional path, so no migration-transaction risk; compression segments by + /// <c>server_id</c> at <see cref="CompressAfterDays"/>). Idempotent (<c>if_not_exists</c>), so it re-converges + /// every restart and no-ops a store the V23 migration already converted. Failure-isolated: a failure warns and + /// collection_log stays a plain table — its DELETE-based retention (DarlingRetention) still honors the horizon. + /// The long <see cref="SetupTimeoutSeconds"/> command timeout covers a large first <c>migrate_data</c>. + /// </summary> + public static async Task<bool> EnsureCollectionLogHypertableAsync(NpgsqlConnection connection, ILogger? logger, CancellationToken cancellationToken = default) + { + if (connection is null) + { + throw new ArgumentNullException(nameof(connection)); + } + + try + { + using (var convert = new NpgsqlCommand(CreateHypertableSql(CollectionLogTable, CollectionLogTimeColumn), connection) { CommandTimeout = SetupTimeoutSeconds }) + { + await convert.ExecuteNonQueryAsync(cancellationToken); + } + + using (var enable = new NpgsqlCommand(EnableCompressionSql(CollectionLogTable), connection) { CommandTimeout = SetupTimeoutSeconds }) + { + await enable.ExecuteNonQueryAsync(cancellationToken); + } + + using (var policy = new NpgsqlCommand(AddCompressionPolicySql(CollectionLogTable), connection) { CommandTimeout = SetupTimeoutSeconds }) + { + await policy.ExecuteNonQueryAsync(cancellationToken); + } + + logger?.LogInformation("TimescaleDB: collection_log is a hypertable with a {Days}d compression policy", CompressAfterDays); + return true; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogWarning( + "collection_log hypertable setup failed — it stays a plain table (DELETE-based retention still honors its horizon): {Message}", + ex.Message); + return false; + } + } + + /* ---------------- compression-job self-heal (#1581) ---------------- */ + + /// <summary> + /// The parameterized re-arm statement (#1581): reschedule a background job to run immediately, which + /// un-sticks a job whose <c>next_start</c> has become <c>-infinity</c> (the scheduler will never re-fire + /// it otherwise — the field-incident root cause). The job_id is ALWAYS bound as <c>$1</c>, never + /// interpolated (the discipline is uniform with DarlingRetention's parameterized paths); <c>now()</c> is + /// SQL, not a value. It is cast <c>$1::integer</c> because TimescaleDB's <c>alter_job</c> takes + /// <c>job_id integer</c>, but <see cref="StuckCompressionJob.JobId"/> is a <c>long</c> that Npgsql sends as + /// <c>bigint</c>; Postgres does NOT down-cast bigint→integer during function resolution, so an un-cast bind + /// fails with <c>42883: function alter_job(bigint, ...) does not exist</c> (a real defect the gated-live + /// test caught — a TimescaleDB job_id never exceeds int4, so the cast is always safe). + /// </summary> + public const string RearmJobSql = "SELECT alter_job($1::integer, next_start => now())"; + + /* The stuck-Running bound floor: a compression run on a single day-chunk of 1-minute-cadence data + finishes in seconds-to-minutes, so a run still 'Running' past this floor (when it dominates + 2x the schedule interval) has hung. Kept generous so a genuinely long first-compression of a + large adopted store is not false-flagged; next_start = -infinity (the dominant failure mode) is + caught immediately regardless of this. */ + private static readonly TimeSpan s_stuckRunningFloor = TimeSpan.FromHours(2); + + /// <summary> + /// The stuck-<c>Running</c> bound: <c>max(2x the schedule interval, a floor)</c>. A run legitimately in + /// progress finishes well within twice its own cadence; crossing this bound means it hung. A missing or + /// non-positive schedule interval falls back to the floor. Pure so the predicate pins directly. + /// </summary> + public static TimeSpan StuckRunningBound(TimeSpan? scheduleInterval) + { + if (scheduleInterval is TimeSpan interval && interval > TimeSpan.Zero) + { + var twice = interval + interval; + return twice > s_stuckRunningFloor ? twice : s_stuckRunningFloor; + } + + return s_stuckRunningFloor; + } + + /// <summary> + /// The pure stuck-compression-job decision (#1581). A compression policy job is STUCK when either: + /// <list type="bullet"> + /// <item>its <c>next_start</c> is <c>-infinity</c> — the scheduler will NEVER re-fire it (the dead-job + /// bug that let uncompressed data grow without bound until the disk filled), or</item> + /// <item>it has been in the <c>Running</c> state since a <c>last_run_started_at</c> older than + /// <see cref="StuckRunningBound"/> — a run that began long ago and never finished (a hung run).</item> + /// </list> + /// A job with neither condition is healthy and is NOT flagged. No I/O, so it pins directly with a + /// controllable clock. Scoping to compression jobs happens in the query — this decides only "stuck". + /// </summary> + public static bool IsCompressionJobStuck( + bool nextStartIsNegativeInfinity, + string? jobStatus, + DateTime? lastRunStartedAtUtc, + TimeSpan? scheduleInterval, + DateTime nowUtc, + out string reason) + { + if (nextStartIsNegativeInfinity) + { + reason = "next_start is -infinity — the scheduler will never run it again"; + return true; + } + + if (string.Equals(jobStatus, "Running", StringComparison.OrdinalIgnoreCase) + && lastRunStartedAtUtc is DateTime startedUtc) + { + var bound = StuckRunningBound(scheduleInterval); + var elapsed = nowUtc - startedUtc; + if (elapsed > bound) + { + reason = string.Format( + CultureInfo.InvariantCulture, + "stuck in the Running state for {0:F0} minutes (over the {1:F0}-minute bound) — the run hung and never finished", + elapsed.TotalMinutes, bound.TotalMinutes); + return true; + } + } + + reason = ""; + return false; + } + + /// <summary> + /// Reads every COMPRESSION-policy background job (<c>proc_name</c> is <c>policy_compression</c>, or the + /// 2.18+ columnstore rebrand's name — the same tolerant LIKE the compression test uses) and returns the + /// ones the pure <see cref="IsCompressionJobStuck"/> predicate flags as stuck. The <c>-infinity</c> test + /// runs IN SQL (so it never depends on Npgsql's infinity-to-DateTime conversion setting); the + /// stuck-Running bound is computed in C# from the raw fields. Scoped to compression jobs ONLY — retention, + /// continuous-aggregate refresh, reorder, and every other job type are untouched. Failure-isolated: a + /// store hiccup, or the views being absent (a plain-PostgreSQL store — the caller also gates on the + /// extension), yields an empty list and a Debug line, never a throw. + /// </summary> + public static async Task<IReadOnlyList<StuckCompressionJob>> ReadStuckCompressionJobsAsync( + NpgsqlConnection connection, DateTime nowUtc, ILogger? logger, CancellationToken cancellationToken = default) + { + if (connection is null) + { + throw new ArgumentNullException(nameof(connection)); + } + + var stuck = new List<StuckCompressionJob>(); + try + { + using var command = new NpgsqlCommand(@" +SELECT + js.job_id, + (js.next_start = '-infinity'::timestamptz) AS next_start_neg_infinity, + js.job_status, + js.last_run_started_at, + EXTRACT(EPOCH FROM j.schedule_interval) AS schedule_interval_seconds, + j.hypertable_name +FROM timescaledb_information.job_stats AS js +JOIN timescaledb_information.jobs AS j USING (job_id) +WHERE j.proc_name LIKE '%compression%' + OR j.proc_name LIKE '%columnstore%'", connection); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + long jobId = Convert.ToInt64(reader.GetValue(0), CultureInfo.InvariantCulture); + bool negInfinity = !reader.IsDBNull(1) && reader.GetBoolean(1); + string? jobStatus = reader.IsDBNull(2) ? null : reader.GetString(2); + DateTime? lastRunStartedAt = reader.IsDBNull(3) + ? null + : DateTime.SpecifyKind(reader.GetDateTime(3), DateTimeKind.Utc); + TimeSpan? scheduleInterval = reader.IsDBNull(4) + ? null + : TimeSpan.FromSeconds(Convert.ToDouble(reader.GetValue(4), CultureInfo.InvariantCulture)); + string? hypertable = reader.IsDBNull(5) ? null : reader.GetString(5); + + if (IsCompressionJobStuck(negInfinity, jobStatus, lastRunStartedAt, scheduleInterval, nowUtc, out var reason)) + { + stuck.Add(new StuckCompressionJob(jobId, hypertable, reason)); + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + /* The views are absent (a plain-PG store or the extension was removed) or the store hiccuped — + no signal this check. The caller already gates on the extension; this is belt-and-suspenders. */ + logger?.LogDebug("Compression-job health check: could not read job stats: {Message}", ex.Message); + } + + return stuck; + } + + /// <summary> + /// Re-arms one stuck background job via the parameterized <see cref="RearmJobSql"/> (job_id BOUND). Returns + /// true when <c>alter_job</c> succeeds; false (logged once, no throw) when it fails — most often because the + /// store login does not OWN the job (a least-privilege bring-your-own store), which the service cannot fix + /// itself. Cancellation propagates; every other failure degrades so a single un-re-armable job can never + /// crash the health check or the sweep. + /// </summary> + public static async Task<bool> TryRearmJobAsync( + NpgsqlConnection connection, long jobId, ILogger? logger, CancellationToken cancellationToken = default) + { + if (connection is null) + { + throw new ArgumentNullException(nameof(connection)); + } + + try + { + using var command = new NpgsqlCommand(RearmJobSql, connection); + command.Parameters.AddWithValue(jobId); + await command.ExecuteNonQueryAsync(cancellationToken); + return true; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogWarning( + "Could not re-arm compression job {JobId} via alter_job (often a permission issue — the store login must own the job): {Message}", + jobId, ex.Message); + return false; + } + } +} + +/// <summary> +/// A COMPRESSION-policy background job that <see cref="TimescaleSupport.ReadStuckCompressionJobsAsync"/> flagged +/// as stuck (#1581): its immutable <c>job_id</c>, the hypertable it compresses (for a friendlier alert label — +/// may be null on an odd catalog), and the human-readable reason the pure predicate produced. +/// </summary> +public sealed record StuckCompressionJob(long JobId, string? HypertableName, string Reason); diff --git a/Darling/PerformanceMonitor.Darling.Viewer/AboutWindow.xaml b/Darling/PerformanceMonitor.Darling.Viewer/AboutWindow.xaml new file mode 100644 index 000000000..cca3e42cb --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/AboutWindow.xaml @@ -0,0 +1,87 @@ +<Window x:Class="PerformanceMonitor.Darling.Viewer.AboutWindow" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + Title="About Performance Monitor Darling" + Height="380" Width="460" + WindowStartupLocation="CenterOwner" + ResizeMode="NoResize" + Icon="EDD.ico" + Background="{DynamicResource BackgroundLightBrush}" + Foreground="{DynamicResource ForegroundBrush}"> + <!-- Mirrors Lite's AboutWindow (Lite/Windows/AboutWindow.xaml), dark-styled via the shared + ViewerDarkTheme resource dictionary like the viewer's other dialogs (Manage Mute Rules). + Includes Lite's "Check for Updates" affordance: the remote-seat viewer ships as its own + Velopack Setup.exe (#1555) and self-updates from the releases feed; on the co-located viewer + inside the service zip the Velopack path no-ops and it falls back to opening the releases page. + The version is read from the viewer assembly (csproj 3.1.0). --> + <Window.Resources> + <ResourceDictionary> + <ResourceDictionary.MergedDictionaries> + <ResourceDictionary Source="ViewerDarkTheme.xaml"/> + </ResourceDictionary.MergedDictionaries> + <Style TargetType="Hyperlink"> + <Setter Property="Foreground" Value="#6CB6FF"/> + </Style> + </ResourceDictionary> + </Window.Resources> + + <Grid Margin="24"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + + <TextBlock Grid.Row="0" Text="SQL Server Performance Monitor Darling" + FontSize="18" FontWeight="Bold" Foreground="{DynamicResource ForegroundBrush}" + TextWrapping="Wrap" Margin="0,0,0,8"/> + + <TextBlock Grid.Row="1" x:Name="VersionText" Text="Version 3.1.0" + Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,0,0,16"/> + + <TextBlock Grid.Row="2" TextWrapping="Wrap" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,8"> + The desktop viewer for the Performance Monitor Darling service. Connects to the central + Postgres store to show per-server dashboards, recommendations, and alerts across every + monitored SQL Server. + </TextBlock> + + <StackPanel Grid.Row="3" Margin="0,0,0,8"> + <TextBlock Foreground="{DynamicResource ForegroundBrush}" FontSize="12"> + © 2026 Erik Darling, Darling Data LLC + </TextBlock> + <TextBlock Foreground="{DynamicResource ForegroundMutedBrush}" FontSize="12"> + Licensed under the MIT License + </TextBlock> + </StackPanel> + + <!-- Links --> + <StackPanel Grid.Row="4"> + <TextBlock FontSize="12" Margin="0,0,0,5"> + <Hyperlink Click="GitHubLink_Click"> + GitHub Repository + </Hyperlink> + </TextBlock> + <TextBlock FontSize="12" Margin="0,0,0,5"> + <Hyperlink Click="ReportIssueLink_Click"> + Report an Issue + </Hyperlink> + </TextBlock> + <StackPanel Orientation="Horizontal" Margin="0,4,0,5"> + <Button Content="Check for Updates" Click="CheckUpdatesLink_Click" Padding="10,4" FontSize="12"/> + <TextBlock x:Name="UpdateStatusText" FontSize="11" VerticalAlignment="Center" Margin="8,0,0,0" + Foreground="{DynamicResource ForegroundMutedBrush}" Visibility="Collapsed"/> + </StackPanel> + <TextBlock FontSize="12"> + <Hyperlink Click="DarlingDataLink_Click"> + www.erikdarling.com + </Hyperlink> + </TextBlock> + </StackPanel> + + <Button Grid.Row="5" Content="Close" Click="CloseButton_Click" + HorizontalAlignment="Right" Padding="16,4"/> + </Grid> +</Window> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/AboutWindow.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/AboutWindow.xaml.cs new file mode 100644 index 000000000..6aeecd32f --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/AboutWindow.xaml.cs @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Diagnostics; +using System.Reflection; +using System.Windows; +using PerformanceMonitor.Common; +using Velopack; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The viewer's About dialog — app name, version (read from the viewer assembly), copyright, and +/// links. A faithful mirror of Lite's <see cref="T:PerformanceMonitorLite.Windows.AboutWindow"/>, +/// including the "Check for Updates" affordance now that the remote-seat viewer ships as its own +/// Velopack Setup.exe (#1555): it tries Velopack (check → download → apply-and-restart) first, and +/// falls back to the shared GitHub-releases check (opens the releases page) — which is also what the +/// co-located viewer inside the service zip gets, since Velopack no-ops on a non-Velopack install. +/// </summary> +public partial class AboutWindow : Window +{ + private const string GitHubUrl = "https://github.com/erikdarlingdata/PerformanceMonitor"; + private const string IssuesUrl = "https://github.com/erikdarlingdata/PerformanceMonitor/issues"; + private const string DarlingDataUrl = "https://www.erikdarling.com"; + + private string? _updateReleaseUrl; + private UpdateManager? _velopackMgr; + private Velopack.UpdateInfo? _pendingUpdate; + private bool _updateDownloaded; + + public AboutWindow() + { + InitializeComponent(); + + var version = Assembly.GetExecutingAssembly().GetName().Version; + VersionText.Text = $"Version {version?.Major}.{version?.Minor}.{version?.Build}"; + } + + private void GitHubLink_Click(object sender, RoutedEventArgs e) => OpenUrl(GitHubUrl); + + private void ReportIssueLink_Click(object sender, RoutedEventArgs e) => OpenUrl(IssuesUrl); + + private void DarlingDataLink_Click(object sender, RoutedEventArgs e) => OpenUrl(DarlingDataUrl); + + private async void CheckUpdatesLink_Click(object sender, RoutedEventArgs e) + { + UpdateStatusText.Text = "Checking for updates..."; + UpdateStatusText.Visibility = Visibility.Visible; + + // Try Velopack first (supports download + apply). The installed remote-seat viewer reads its + // own channel from the Velopack metadata, so a bare GithubSource resolves the right feed. + try + { + _velopackMgr = new UpdateManager( + new Velopack.Sources.GithubSource("https://github.com/erikdarlingdata/PerformanceMonitor", null, false)); + + var updateInfo = await _velopackMgr.CheckForUpdatesAsync(); + if (updateInfo != null) + { + _pendingUpdate = updateInfo; + UpdateStatusText.Text = $"Update available: v{updateInfo.TargetFullRelease.Version} — click to install"; + UpdateStatusText.Cursor = System.Windows.Input.Cursors.Hand; + UpdateStatusText.MouseLeftButtonUp -= UpdateStatusText_Click; + UpdateStatusText.MouseLeftButtonUp += VelopackDownload_Click; + UpdateStatusText.TextDecorations = System.Windows.TextDecorations.Underline; + UpdateStatusText.Foreground = TryFindResource("AccentBrush") as System.Windows.Media.Brush + ?? System.Windows.Media.Brushes.DodgerBlue; + return; + } + } + catch + { + // Velopack packages may not exist yet, or this is the co-located (non-Velopack) viewer — fall through + } + + // Fallback: GitHub Releases API check (opens browser) + var result = await UpdateCheckService.CheckForUpdateAsync(bypassCache: true); + + if (result == null) + { + UpdateStatusText.Text = "Unable to check for updates. Please try again later."; + } + else if (result.IsUpdateAvailable) + { + _updateReleaseUrl = result.ReleaseUrl; + UpdateStatusText.Text = $"Update available: {result.LatestVersion} (you have {result.CurrentVersion}) — click to open releases"; + UpdateStatusText.Cursor = System.Windows.Input.Cursors.Hand; + UpdateStatusText.MouseLeftButtonUp -= VelopackDownload_Click; + UpdateStatusText.MouseLeftButtonUp += UpdateStatusText_Click; + UpdateStatusText.TextDecorations = System.Windows.TextDecorations.Underline; + UpdateStatusText.Foreground = TryFindResource("AccentBrush") as System.Windows.Media.Brush + ?? System.Windows.Media.Brushes.DodgerBlue; + } + else + { + UpdateStatusText.Text = $"You're up to date ({result.CurrentVersion})"; + } + } + + private async void VelopackDownload_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + if (_velopackMgr == null || _pendingUpdate == null) return; + + // Step 3: restart with confirmation + if (_updateDownloaded) + { + var result = MessageBox.Show(this, + "The application will close and restart with the new version. Continue?", + "Update Ready", + MessageBoxButton.OKCancel, + MessageBoxImage.Question); + + if (result == MessageBoxResult.OK) + { + _velopackMgr.ApplyUpdatesAndRestart(_pendingUpdate.TargetFullRelease); + } + return; + } + + // Step 2: download + try + { + UpdateStatusText.MouseLeftButtonUp -= VelopackDownload_Click; + UpdateStatusText.TextDecorations = null; + UpdateStatusText.Cursor = System.Windows.Input.Cursors.Arrow; + UpdateStatusText.Text = "Downloading update..."; + + await _velopackMgr.DownloadUpdatesAsync(_pendingUpdate); + + _updateDownloaded = true; + UpdateStatusText.Text = "Update downloaded."; + UpdateStatusText.Cursor = System.Windows.Input.Cursors.Hand; + UpdateStatusText.TextDecorations = System.Windows.TextDecorations.Underline; + UpdateStatusText.MouseLeftButtonUp += VelopackDownload_Click; + } + catch (Exception ex) + { + UpdateStatusText.Text = $"Download failed: {ex.Message}"; + } + } + + private void UpdateStatusText_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + if (!string.IsNullOrEmpty(_updateReleaseUrl)) + OpenUrl(_updateReleaseUrl); + } + + private void CloseButton_Click(object sender, RoutedEventArgs e) => Close(); + + private static void OpenUrl(string url) + { + try + { + Process.Start(new ProcessStartInfo + { + FileName = url, + UseShellExecute = true + }); + } + catch + { + MessageBox.Show($"Could not open URL: {url}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/AddMultipleServersDialog.xaml b/Darling/PerformanceMonitor.Darling.Viewer/AddMultipleServersDialog.xaml new file mode 100644 index 000000000..6244fa53f --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/AddMultipleServersDialog.xaml @@ -0,0 +1,120 @@ +<Window x:Class="PerformanceMonitor.Darling.Viewer.AddMultipleServersDialog" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + Title="Add Multiple Servers" + Height="720" Width="640" MinHeight="540" MinWidth="520" + WindowStartupLocation="CenterOwner" + ResizeMode="CanResizeWithGrip" + Icon="EDD.ico" + Background="{DynamicResource BackgroundBrush}"> + <Grid Margin="16"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + + <!-- Header --> + <TextBlock Grid.Row="0" Text="Add Multiple Servers" FontSize="18" FontWeight="Bold" Margin="0,0,0,6" + Foreground="{DynamicResource ForegroundBrush}"/> + + <!-- Format hint --> + <TextBlock Grid.Row="1" TextWrapping="Wrap" FontSize="11" Margin="0,0,0,10" + Foreground="{DynamicResource ForegroundMutedBrush}" + Text="Paste one server per line as: server[, display name][, database]. Fields separate on a comma, or on a tab for spreadsheet pastes (a display name with a comma needs the tab form). Blank lines and lines starting with # are ignored. All servers share the authentication and encryption below — the Darling service connects with Windows or SQL authentication (a SQL credential is entered once and stamped, encrypted, onto every server). Duplicates of servers you already monitor are skipped."/> + + <!-- Paste box --> + <TextBlock Grid.Row="2" Text="Servers" FontWeight="Bold" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <TextBox Grid.Row="3" x:Name="PasteBox" Height="130" Margin="0,0,0,10" + AcceptsReturn="True" AcceptsTab="False" TextWrapping="NoWrap" + FontFamily="Consolas" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto" + TextChanged="PasteBox_TextChanged"/> + + <!-- Shared settings block --> + <StackPanel Grid.Row="4" Margin="0,0,0,10"> + <TextBlock Text="Shared authentication (applied to every server)" FontWeight="Bold" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + + <!-- Credential source: inline (shared) vs a shared credential profile --> + <RadioButton x:Name="ConfigureCredsRadio" Content="Configure credentials for all servers" + Foreground="{DynamicResource ForegroundBrush}" IsChecked="True" + Checked="CredentialSource_Changed" Margin="0,0,0,4"/> + <RadioButton x:Name="UseProfileRadio" Content="Use a credential profile" + Foreground="{DynamicResource ForegroundBrush}" + Checked="CredentialSource_Changed" Margin="0,0,0,4" + ToolTip="Use a named, shared credential (set up under Manage Servers → Credential Profiles). The profile is resolved to a concrete SQL credential and stamped onto every server."/> + + <!-- Profile picker (shown when 'Use a credential profile' is selected) --> + <StackPanel x:Name="ProfilePickerPanel" Visibility="Collapsed" Margin="0,4,0,0"> + <TextBlock Text="Credential Profile" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <ComboBox x:Name="ProfileComboBox" DisplayMemberPath="PickerDisplay"/> + <TextBlock x:Name="NoProfilesNote" TextWrapping="Wrap" FontSize="11" Visibility="Collapsed" + Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,4,0,0" + Text="No credential profiles exist yet. Create one under Manage Servers → Credential Profiles."/> + </StackPanel> + + <!-- Inline shared auth radios. Only the two modes the Darling service honors — Windows / SQL. The + three Azure/Entra modes have no service connect path, so bulk does not offer them (single-add + shows-then-blocks them; showing them here per row would be theater). --> + <StackPanel x:Name="InlineAuthRadios" Margin="0,4,0,0"> + <RadioButton x:Name="WindowsAuthRadio" Content="Windows Authentication" Foreground="{DynamicResource ForegroundBrush}" + IsChecked="True" Checked="AuthMode_Changed" Margin="0,0,0,4"/> + <RadioButton x:Name="SqlAuthRadio" Content="SQL Server Authentication" Foreground="{DynamicResource ForegroundBrush}" + Checked="AuthMode_Changed"/> + </StackPanel> + + <!-- SQL Auth Credentials (entered ONCE — encrypted to a single DPAPI blob stamped on every row) --> + <StackPanel x:Name="SqlCredentialsPanel" Visibility="Collapsed" Margin="0,8,0,0"> + <TextBlock Text="Username" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <TextBox x:Name="UsernameBox" Margin="0,0,0,8"/> + <TextBlock Text="Password" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <PasswordBox x:Name="PasswordBox"/> + <TextBlock TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,6,0,0" + Text="The password is encrypted with the service host's machine key (DPAPI-LocalMachine), so it can be decrypted only on the machine running the Darling service — the supported single-box deploy."/> + </StackPanel> + + <!-- Encryption --> + <StackPanel Orientation="Horizontal" Margin="0,10,0,0"> + <TextBlock Text="Encryption:" Foreground="{DynamicResource ForegroundBrush}" VerticalAlignment="Center" Width="80"/> + <ComboBox x:Name="EncryptModeComboBox" Width="150" SelectedIndex="1"> + <ComboBoxItem Content="Optional"/> + <ComboBoxItem Content="Mandatory"/> + <ComboBoxItem Content="Strict"/> + </ComboBox> + </StackPanel> + <CheckBox x:Name="TrustCertCheckBox" Content="Trust server certificate (skip certificate validation)" + Foreground="{DynamicResource ForegroundBrush}" IsChecked="False" Margin="0,6,0,0"/> + </StackPanel> + + <!-- Preview / results grid (plain read-only DataGrid — no DataGridFilterManager) --> + <DataGrid Grid.Row="5" x:Name="PreviewGrid" AutoGenerateColumns="False" IsReadOnly="True" + CanUserAddRows="False" HeadersVisibility="Column" SelectionMode="Single" Margin="0,0,0,8"> + <DataGrid.Columns> + <DataGridTextColumn Header="Line" Binding="{Binding LineNumber}" Width="50"/> + <DataGridTextColumn Header="Server" Binding="{Binding Server}" Width="*"/> + <DataGridTextColumn Header="Display" Binding="{Binding Display}" Width="*"/> + <DataGridTextColumn Header="Database" Binding="{Binding Database}" Width="*"/> + <DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="2*"/> + </DataGrid.Columns> + </DataGrid> + + <!-- Status --> + <TextBlock Grid.Row="6" x:Name="StatusText" Foreground="{DynamicResource ForegroundMutedBrush}" + TextWrapping="Wrap" Margin="0,0,0,8"/> + + <!-- Buttons --> + <StackPanel Grid.Row="7" Orientation="Horizontal" HorizontalAlignment="Right"> + <Button x:Name="TestAllButton" Content="Test All" Height="30" MinWidth="90" Click="TestAllButton_Click" Margin="0,0,8,0" + ToolTip="Ask the Darling service to probe each server with the shared credentials, one at a time. Informational only — no server is added."/> + <Button x:Name="CancelTestButton" Content="Cancel Test" Height="30" MinWidth="90" Click="CancelTestButton_Click" + Visibility="Collapsed" Margin="0,0,8,0"/> + <Button x:Name="AddButton" Content="Add" Width="90" Height="30" Click="AddButton_Click" Style="{StaticResource AccentButton}" IsDefault="True" Margin="0,0,8,0"/> + <Button Content="Cancel" Width="80" Height="30" Click="CancelButton_Click" IsCancel="True"/> + </StackPanel> + </Grid> +</Window> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/AddMultipleServersDialog.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/AddMultipleServersDialog.xaml.cs new file mode 100644 index 000000000..c612df733 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/AddMultipleServersDialog.xaml.cs @@ -0,0 +1,676 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// "Add Multiple Servers" (Darling viewer) — paste a server list, set one shared authentication + encryption +/// block, optionally probe every server through the service, and write all non-duplicate rows to +/// <c>config.config_monitored_servers</c> via the existing <see cref="ViewerDataService.UpsertMonitoredServerAsync"/>. +/// The shared credential is resolved ONCE (one <see cref="ViewerServerSecret.Protect"/> call) and the same DPAPI +/// blob is stamped onto every row — the Darling service has no store-side profile concept, so a picked profile +/// is resolved to concrete creds here (the asymmetry vs Lite, which mints one shared profile). Only the two auth +/// modes the service honors are offered (Windows / SQL / a SQL profile); Test All runs SEQUENTIALLY through the +/// service's <c>test_connect</c> command (the service drains commands serially, so viewer-side parallelism would +/// be theater). Duplicates are skipped via the shared <see cref="ServerIdHelper"/> identity, case-folded. The +/// single AddServerDialog is not edited. +/// </summary> +public partial class AddMultipleServersDialog : Window +{ + private readonly ViewerDataService? _dataService; + private readonly ViewerProfileStore _profileStore; + private readonly ObservableCollection<BulkRowVm> _rows = new(); + + /// <summary>Existing servers' dedupe keys, loaded once on open for the preview's duplicate marking (the Add + /// path re-reads the authoritative set at write time).</summary> + private HashSet<string> _existingKeys = new(StringComparer.OrdinalIgnoreCase); + + private bool _testRunning; + private bool _cancelTest; + + /// <summary>Number of servers actually upserted (the caller reloads when > 0).</summary> + public int AddedCount { get; private set; } + + /// <summary>Number of rows skipped as duplicates of already-monitored servers (or earlier rows in the paste).</summary> + public int SkippedCount { get; private set; } + + /// <summary>Number of rows that failed to build/write (per-row errors; the batch continues past them).</summary> + public int FailedCount { get; private set; } + + /// <param name="serverStore">Accepted for ctor parity with the single Add dialog; bulk sets no per-server + /// favorites, so it is not retained.</param> + public AddMultipleServersDialog(ViewerDataService? dataService, ViewerServerStore serverStore, ViewerProfileStore profileStore) + { + InitializeComponent(); + _dataService = dataService; + _profileStore = profileStore; + PreviewGrid.ItemsSource = _rows; + PopulateProfilePicker(); + ApplyReadOnlyGate(); + Loaded += async (_, _) => await LoadExistingKeysAsync(); + } + + private void PopulateProfilePicker() + { + var profiles = _profileStore.GetAll(); + ProfileComboBox.ItemsSource = profiles; + if (profiles.Count == 0) + { + UseProfileRadio.IsEnabled = false; + NoProfilesNote.Visibility = Visibility.Visible; + } + } + + /// <summary>Disables the write actions on a read-only connection (or when disconnected), with a clear note + /// (mirrors the single dialog's ApplyReadOnlyGate).</summary> + private void ApplyReadOnlyGate() + { + if (_dataService is null) + { + AddButton.IsEnabled = false; + TestAllButton.IsEnabled = false; + StatusText.Text = "Not connected to a Darling store — server changes are unavailable."; + } + else if (_dataService.IsReadOnly) + { + AddButton.IsEnabled = false; + TestAllButton.IsEnabled = false; + StatusText.Text = "This viewer is connected read-only, so servers can't be added or changed. " + + "Set postgres.connectAs to \"admin\" in darling.json and restart."; + } + } + + private async Task LoadExistingKeysAsync() + { + if (_dataService is null) return; + try + { + var existing = await _dataService.GetMonitoredServersAsync(); + _existingKeys = SeedGate(existing); + RefreshPreview(); + } + catch (Exception ex) + { + ViewerLogger.Warn("AddMultipleServersDialog", $"Could not load existing servers for dedupe: {ex.Message}"); + } + } + + // ── Credential source + auth mode (copied idiom from AddServerDialog, trimmed to Windows / SQL / profile) ── + + private void CredentialSource_Changed(object sender, RoutedEventArgs e) + { + if (ProfilePickerPanel is null || InlineAuthRadios is null) return; + + bool useProfile = UseProfileRadio.IsChecked == true; + ProfilePickerPanel.Visibility = useProfile ? Visibility.Visible : Visibility.Collapsed; + InlineAuthRadios.Visibility = useProfile ? Visibility.Collapsed : Visibility.Visible; + + if (useProfile) + { + if (SqlCredentialsPanel is not null) SqlCredentialsPanel.Visibility = Visibility.Collapsed; + } + else + { + AuthMode_Changed(sender, e); + } + } + + private void AuthMode_Changed(object sender, RoutedEventArgs e) + { + if (SqlCredentialsPanel is null) return; + SqlCredentialsPanel.Visibility = SqlAuthRadio.IsChecked == true ? Visibility.Visible : Visibility.Collapsed; + } + + // ── Preview ───────────────────────────────────────────────────────────────────────────────────────── + + private void PasteBox_TextChanged(object sender, TextChangedEventArgs e) => RefreshPreview(); + + /// <summary>Re-parses the paste box, marks duplicates (vs existing servers and earlier rows), and rebuilds + /// the line-ordered preview (valid rows + parse errors merged on LineNumber).</summary> + private void RefreshPreview() + { + var result = BulkServerListParser.Parse(PasteBox.Text); + + // Seed from the existing keys loaded on open; candidates are always read-write. + var seen = new HashSet<string>(_existingKeys, StringComparer.OrdinalIgnoreCase); + + var rows = new List<BulkRowVm>(); + foreach (var line in result.Servers) + { + // Display key = BuildStorageName(name, db, ro=false), identical by construction to the built row's + // GateKey (built.Host == line.ServerName, built.Database == line.DatabaseName, ro=false). + var key = ServerIdHelper.BuildStorageName(line.ServerName, line.DatabaseName, false); + bool duplicate = !seen.Add(key); + rows.Add(new BulkRowVm + { + LineNumber = line.LineNumber, + Server = line.ServerName, + Display = line.DisplayName, + Database = line.DatabaseName, + IsValid = true, + IsDuplicate = duplicate, + ParsedLine = line, + Status = duplicate ? "Duplicate" : "", + }); + } + + foreach (var error in result.Errors) + { + rows.Add(new BulkRowVm + { + LineNumber = error.LineNumber, + Server = error.RawText.Trim(), + IsValid = false, + Status = "Invalid: " + error.Message, + }); + } + + rows.Sort((a, b) => a.LineNumber.CompareTo(b.LineNumber)); + _rows.Clear(); + foreach (var r in rows) _rows.Add(r); + + int newCount = rows.Count(r => r.IsValid && !r.IsDuplicate); + int dupCount = rows.Count(r => r.IsValid && r.IsDuplicate); + int badCount = rows.Count(r => !r.IsValid); + StatusText.Text = rows.Count == 0 + ? "" + : $"{newCount} new server(s), {dupCount} duplicate(s), {badCount} invalid line(s)."; + } + + // ── Test All (SEQUENTIAL sweep through the service; Cancel stops between rows) ──────────────────────── + + private async void TestAllButton_Click(object sender, RoutedEventArgs e) + { + if (_dataService is null || _testRunning) return; + + var candidates = _rows.Where(r => r.IsValid && !r.IsDuplicate).ToList(); + if (candidates.Count == 0) + { + StatusText.Text = "No valid, non-duplicate servers to test."; + return; + } + + if (!TryResolveSharedCredential(out var shared, out var error)) + { + StatusText.Text = error; + return; + } + + _testRunning = true; + _cancelTest = false; + PasteBox.IsEnabled = false; + AddButton.IsEnabled = false; + TestAllButton.IsEnabled = false; + CancelTestButton.Visibility = Visibility.Visible; + + foreach (var row in candidates) row.Status = ""; + + try + { + int index = 0; + foreach (var row in candidates) + { + index++; + // Cancel is a between-rows STOP flag; remaining un-started rows end "Canceled" (the in-flight + // row, if any, has already completed normally and self-deleted its command). + if (_cancelTest) + { + row.Status = "Canceled"; + continue; + } + if (row.ParsedLine is null) continue; + + var (testServer, buildError) = BuildTestConnectServer(row.ParsedLine, shared); + if (testServer is null) + { + row.Status = "Failed: " + buildError; + continue; + } + + row.Status = "Testing…"; + StatusText.Text = $"Testing {index} of {candidates.Count}…"; + try + { + // Do NOT pass a cancel token into the in-flight command — cancelling the poll orphans the + // credential-bearing row before the viewer's delete. Bound each row with a per-row timeout + // floored at ~30s so it can't expire before the ~5s service claim tick + connect budget. + var result = await _dataService.RunTestConnectAsync( + ViewerDataService.BuildTestConnectArgs(testServer), + requestedBy: "viewer-bulk", + timeout: TimeSpan.FromSeconds(30)); + row.Status = DescribeTestResult(result); + } + catch (Exception ex) + { + row.Status = "Failed: " + ex.Message; + } + } + } + finally + { + _testRunning = false; + bool writable = _dataService is { IsReadOnly: false }; + PasteBox.IsEnabled = true; + AddButton.IsEnabled = writable; + TestAllButton.IsEnabled = writable; + CancelTestButton.Visibility = Visibility.Collapsed; + StatusText.Text = _cancelTest ? "Connection test canceled." : "Connection test complete."; + } + } + + private void CancelTestButton_Click(object sender, RoutedEventArgs e) => _cancelTest = true; + + // ── Add ───────────────────────────────────────────────────────────────────────────────────────────── + + private async void AddButton_Click(object sender, RoutedEventArgs e) + { + if (_dataService is null || _testRunning) return; + if (_dataService.IsReadOnly) return; + + var result = BulkServerListParser.Parse(PasteBox.Text); + if (result.Servers.Count == 0) + { + StatusText.Text = "No servers to add. Enter at least one server line."; + return; + } + + // Resolve the shared credential ONCE (one Protect call) — the same DPAPI blob is stamped on every row. + if (!TryResolveSharedCredential(out var shared, out var credError)) + { + StatusText.Text = credError; + return; + } + + try + { + AddButton.IsEnabled = false; + TestAllButton.IsEnabled = false; + // Freeze the paste box during the async add too (same rationale as the Test-All freeze): a + // TextChanged re-parse would rebuild the row VMs while MarkRow is writing failure statuses, + // orphaning them. The adds themselves work from the parse snapshot either way. + PasteBox.IsEnabled = false; + + // Seed the gate from the authoritative store (secret-free), REAL (host, db, ro). Each candidate's + // key derives FROM THE BUILT row (one composition feeds the gate AND the stored server_id); + // candidates are always read-write. First occurrence in the paste wins. + var existing = await _dataService.GetMonitoredServersAsync(); + var seen = SeedGate(existing); + + var toAdd = new List<(BulkServerParseLine Line, MonitoredServerRow Row)>(); + int skipped = 0; + int failed = 0; + string? firstError = null; + + foreach (var line in result.Servers) + { + var (row, buildError) = BuildMonitoredServerRow(line, shared); + if (row is null) + { + failed++; + firstError ??= buildError; + MarkRow(line.LineNumber, "Failed: " + buildError); + continue; + } + if (!seen.Add(GateKey(row))) + { + skipped++; + continue; + } + toAdd.Add((line, row)); + } + + if (toAdd.Count == 0) + { + AddedCount = 0; + SkippedCount = skipped; + FailedCount = failed; + StatusText.Text = firstError != null + ? $"No servers added — {firstError}." + : $"No new servers to add ({skipped} duplicate(s) skipped)."; + return; + } + + int added = 0; + foreach (var (line, row) in toAdd) + { + try + { + // Per-row write; the upsert is idempotent on server_id. No rollback — a partial batch is fine. + await _dataService.UpsertMonitoredServerAsync(row); + added++; + } + catch (ViewerReadOnlyException ex) + { + // The whole seat is read-only — stop; nothing further will write. + StatusText.Text = ex.Message; + break; + } + catch (Exception ex) + { + failed++; + MarkRow(line.LineNumber, "Failed: " + ex.Message); + } + } + + AddedCount = added; + SkippedCount = skipped; + FailedCount = failed; + + if (added > 0) + { + DialogResult = true; + Close(); + return; + } + + StatusText.Text = $"No servers added ({skipped} duplicate(s), {failed} failed)."; + } + catch (Exception ex) + { + StatusText.Text = $"Error adding servers: {ex.Message}"; + ViewerLogger.Error("AddMultipleServersDialog", "bulk add failed", ex); + } + finally + { + bool writable = _dataService is { IsReadOnly: false }; + PasteBox.IsEnabled = true; + AddButton.IsEnabled = writable; + TestAllButton.IsEnabled = writable; + } + } + + private void MarkRow(int lineNumber, string status) + { + var vm = _rows.FirstOrDefault(r => r.LineNumber == lineNumber && r.IsValid); + if (vm is not null) vm.Status = status; + } + + private void CancelButton_Click(object sender, RoutedEventArgs e) + { + DialogResult = false; + Close(); + } + + // ── Shared settings + mapping (static helpers — unit-tested in Darling.Tests) ───────────────────────── + + private string GetSelectedEncryptMode() => EncryptModeComboBox.SelectedIndex switch + { + 1 => "Mandatory", + 2 => "Strict", + _ => "Optional" + }; + + /// <summary>Resolves the chosen credential source ONCE into the store's (auth type, username, DPAPI blob) — + /// <see cref="ViewerServerSecret.Protect"/> is called exactly once and the blob is reused for every row. + /// A picked profile is resolved to its concrete secret (the Darling store keeps no profile table); an + /// Azure/Entra profile the service can't honor is blocked here.</summary> + private bool TryResolveSharedCredential(out BulkSharedSettings shared, out string? error) + { + shared = new BulkSharedSettings(); + error = null; + + string encryptMode = GetSelectedEncryptMode(); + bool trustCert = TrustCertCheckBox.IsChecked == true; + + if (UseProfileRadio.IsChecked == true) + { + if (ProfileComboBox.SelectedItem is not ViewerCredentialProfile profile) + { + error = "Select a credential profile, or choose \"Configure credentials for all servers\"."; + return false; + } + + if (ServerStoreCredential.MapAuth(profile.AuthType) is null) + { + error = ServerStoreCredential.UnsupportedAuthMessage; + return false; + } + + var secret = _profileStore.GetSecret(profile.Id); + if (secret is null || string.IsNullOrEmpty(secret.Value.Password)) + { + error = $"The credential profile '{profile.Name}' has no stored secret on this machine. Re-enter it under Credential Profiles."; + return false; + } + + shared = new BulkSharedSettings + { + AuthType = profile.AuthType, + Username = string.IsNullOrWhiteSpace(secret.Value.Username) ? profile.Username : secret.Value.Username, + EncryptedPassword = ViewerServerSecret.Protect(secret.Value.Password), + EncryptMode = encryptMode, + TrustServerCertificate = trustCert, + }; + return true; + } + + if (WindowsAuthRadio.IsChecked == true) + { + shared = new BulkSharedSettings { AuthType = AuthenticationTypes.Windows, EncryptMode = encryptMode, TrustServerCertificate = trustCert }; + return true; + } + + // SQL inline. + var username = UsernameBox.Text.Trim(); + if (string.IsNullOrEmpty(username)) { error = "Username is required for SQL Server authentication."; return false; } + var typed = PasswordBox.Password; + if (string.IsNullOrEmpty(typed)) { error = "Password is required for SQL Server authentication."; return false; } + + shared = new BulkSharedSettings + { + AuthType = AuthenticationTypes.SqlServer, + Username = username, + EncryptedPassword = ViewerServerSecret.Protect(typed), + EncryptMode = encryptMode, + TrustServerCertificate = trustCert, + }; + return true; + } + + /// <summary>Maps one parsed row + the shared settings into a <see cref="MonitoredServerRow"/> (the store + /// upsert shape). REJECTS an auth the Darling service can't honor via <see cref="ServerStoreCredential.MapAuth"/> + /// (the belt — the trimmed radios never offer Azure, but a profile could resolve to one). A blank/whitespace + /// database maps to NULL, NEVER "master" (coercing would mint a mismatched server_id and split collected data).</summary> + internal static (MonitoredServerRow? Row, string? Error) BuildMonitoredServerRow(BulkServerParseLine line, BulkSharedSettings shared) + { + var mapped = ServerStoreCredential.MapAuth(shared.AuthType); + if (mapped is null) + { + return (null, ServerStoreCredential.UnsupportedAuthMessage); + } + + var host = line.ServerName; + var database = string.IsNullOrWhiteSpace(line.DatabaseName) ? null : line.DatabaseName.Trim(); + var displayName = string.IsNullOrEmpty(line.DisplayName) ? host : line.DisplayName; + + var row = new MonitoredServerRow + { + ServerId = ViewerDataService.ComputeServerId(host, database, readOnlyIntent: false), + Name = displayName, + Host = host, + Database = database, + Auth = mapped, + Username = shared.Username, + EncryptedPassword = shared.EncryptedPassword, + EncryptMode = shared.EncryptMode, + TrustServerCertificate = shared.TrustServerCertificate, + ReadOnlyIntent = false, + MultiSubnetFailover = false, + MonthlyCostUsd = 0m, + IsEnabled = true, + }; + return (row, null); + } + + /// <summary>Maps one parsed row + the shared settings into a <see cref="TestConnectServer"/> for the probe — + /// the same fields as the store row (single source of truth via <see cref="BuildMonitoredServerRow"/>).</summary> + internal static (TestConnectServer? Server, string? Error) BuildTestConnectServer(BulkServerParseLine line, BulkSharedSettings shared) + { + var (row, error) = BuildMonitoredServerRow(line, shared); + if (row is null) + { + return (null, error); + } + + return (new TestConnectServer + { + Name = row.Name, + Host = row.Host, + Database = row.Database, + Auth = row.Auth, + Username = row.Username, + EncryptedPassword = row.EncryptedPassword, + ReadOnlyIntent = row.ReadOnlyIntent, + TrustServerCertificate = row.TrustServerCertificate, + EncryptMode = row.EncryptMode, + MultiSubnetFailover = row.MultiSubnetFailover, + }, null); + } + + /// <summary>The dedupe gate key for a built row — the shared storage-name identity (case-folded by the + /// gate's comparer). One composition feeds both the gate and the stored server_id.</summary> + internal static string GateKey(MonitoredServerRow row) => ServerIdHelper.BuildStorageName(row.Host, row.Database, row.ReadOnlyIntent); + + /// <summary>Seeds the OrdinalIgnoreCase dedupe gate from the existing servers' REAL (host, db, read-only) keys.</summary> + internal static HashSet<string> SeedGate(IEnumerable<MonitoredServerRow> existing) + => new(existing.Select(GateKey), StringComparer.OrdinalIgnoreCase); + + // ── test_connect result formatting (copied from AddServerDialog per the COPY rule) ─────────────────── + + private static string DescribeTestResult(CommandResult? result) + { + if (result is null) + { + return "Still running on the service — try again in a moment."; + } + + if (result.Status == ViewerDataService.StatusSucceeded) + { + return DescribeProbeFacts(result.ResultJson); + } + + var error = TryReadJsonString(result.ResultJson, "error"); + return string.IsNullOrWhiteSpace(error) + ? $"Failed ({result.ResultStatus ?? "error"})." + : $"Failed: {error}"; + } + + private static string DescribeProbeFacts(string? resultJson) + { + if (string.IsNullOrWhiteSpace(resultJson)) + { + return "Connected."; + } + + try + { + using var document = JsonDocument.Parse(resultJson); + var root = document.RootElement; + + var major = root.TryGetProperty("majorVersion", out var mv) && mv.ValueKind == JsonValueKind.Number ? mv.GetInt32() : 0; + var edition = root.TryGetProperty("engineEditionDescription", out var ed) && ed.ValueKind == JsonValueKind.String + ? ed.GetString() + : null; + + var versionLabel = ViewerDataService.SqlVersionLabel(major == 0 ? null : major); + var parts = new List<string> { "Connected" }; + if (!string.IsNullOrEmpty(versionLabel)) + { + parts.Add(versionLabel); + } + if (!string.IsNullOrWhiteSpace(edition)) + { + parts.Add(edition!); + } + + if (root.TryGetProperty("hasMsdbAccess", out var msdb) && msdb.ValueKind == JsonValueKind.False) + { + parts.Add("no msdb access (SQL Agent job data unavailable)"); + } + + return string.Join(" — ", parts) + "."; + } + catch (JsonException) + { + return "Connected."; + } + } + + private static string? TryReadJsonString(string? json, string property) + { + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + try + { + using var document = JsonDocument.Parse(json); + return document.RootElement.ValueKind == JsonValueKind.Object + && document.RootElement.TryGetProperty(property, out var element) + && element.ValueKind == JsonValueKind.String + ? element.GetString() + : null; + } + catch (JsonException) + { + return null; + } + } + + /// <summary>One preview row (a parsed server or a parse error). Status is mutable + observable so the + /// sequential Test All can update it live.</summary> + private sealed class BulkRowVm : INotifyPropertyChanged + { + public int LineNumber { get; init; } + public string Server { get; init; } = ""; + public string? Display { get; init; } + public string? Database { get; init; } + public bool IsValid { get; init; } + public bool IsDuplicate { get; init; } + public BulkServerParseLine? ParsedLine { get; init; } + + private string _status = ""; + public string Status + { + get => _status; + set + { + if (_status == value) return; + _status = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Status))); + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + } +} + +/// <summary>The one shared auth/encryption block applied to every bulk row. <see cref="AuthType"/> is the +/// viewer <see cref="AuthenticationTypes"/> value (mapped to the store's <c>auth</c> by the mapping helper); +/// <see cref="EncryptedPassword"/> is the single DPAPI blob (<see cref="ViewerServerSecret.Protect"/> called +/// once) stamped onto every row.</summary> +internal sealed class BulkSharedSettings +{ + public string AuthType { get; init; } = AuthenticationTypes.Windows; + public string? Username { get; init; } + public string? EncryptedPassword { get; init; } + public string EncryptMode { get; init; } = "Mandatory"; + public bool TrustServerCertificate { get; init; } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/AddServerDialog.xaml b/Darling/PerformanceMonitor.Darling.Viewer/AddServerDialog.xaml new file mode 100644 index 000000000..cf3976d7c --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/AddServerDialog.xaml @@ -0,0 +1,172 @@ +<Window x:Class="PerformanceMonitor.Darling.Viewer.AddServerDialog" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + Title="Add SQL Server" + SizeToContent="Height" Width="450" + WindowStartupLocation="CenterOwner" + ResizeMode="NoResize" + Icon="EDD.ico" + Background="{DynamicResource BackgroundBrush}"> + <Grid Margin="16"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + + <!-- Header --> + <TextBlock Grid.Row="0" x:Name="HeaderText" Text="Add SQL Server Connection" FontSize="18" FontWeight="Bold" Margin="0,0,0,15" + Foreground="{DynamicResource ForegroundBrush}"/> + + <!-- Server Name --> + <TextBlock Grid.Row="1" Text="Server Name / Address" FontWeight="Bold" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <TextBox Grid.Row="2" x:Name="ServerNameBox" Margin="0,0,0,12"/> + + <!-- Display Name --> + <TextBlock Grid.Row="3" Text="Display Name (optional)" FontWeight="Bold" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <TextBox Grid.Row="4" x:Name="DisplayNameBox" Margin="0,0,0,12"/> + + <!-- Authentication. The credential source is either inline per-server auth or a shared credential + profile (the Darling service resolves the profile at connect time — the viewer never connects). + This is declarative config the service consumes. --> + <TextBlock Grid.Row="5" Text="Authentication" FontWeight="Bold" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <StackPanel Grid.Row="6" Margin="0,0,0,12"> + <!-- Credential source: inline (per-server) vs a shared credential profile --> + <RadioButton Content="Configure credentials on this server" + Foreground="{DynamicResource ForegroundBrush}" IsChecked="True" + Checked="CredentialSource_Changed" Margin="0,0,0,4"/> + <RadioButton x:Name="UseProfileRadio" Content="Use a credential profile" + Foreground="{DynamicResource ForegroundBrush}" + Checked="CredentialSource_Changed" Margin="0,0,0,4" + ToolTip="Use a named, shared credential (set up under Manage Servers → Credential Profiles). The profile fully overrides this server's auth type and credentials."/> + + <!-- Profile picker (shown when 'Use a credential profile' is selected) --> + <StackPanel x:Name="ProfilePickerPanel" Visibility="Collapsed" Margin="0,4,0,0"> + <TextBlock Text="Credential Profile" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <ComboBox x:Name="ProfileComboBox" DisplayMemberPath="PickerDisplay"/> + <TextBlock x:Name="NoProfilesNote" TextWrapping="Wrap" FontSize="11" Visibility="Collapsed" + Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,4,0,0" + Text="No credential profiles exist yet. Create one under Manage Servers → Credential Profiles."/> + </StackPanel> + + <StackPanel x:Name="InlineAuthRadios" Margin="0,4,0,0"> + <RadioButton x:Name="WindowsAuthRadio" Content="Windows Authentication" Foreground="{DynamicResource ForegroundBrush}" + IsChecked="True" Checked="AuthMode_Changed" Margin="0,0,0,4"/> + <RadioButton x:Name="SqlAuthRadio" Content="SQL Server Authentication" Foreground="{DynamicResource ForegroundBrush}" + Checked="AuthMode_Changed" Margin="0,0,0,4"/> + <RadioButton x:Name="EntraMfaAuthRadio" Content="Microsoft Entra MFA" Foreground="{DynamicResource ForegroundBrush}" + Checked="AuthMode_Changed" + ToolTip="Interactive authentication with MFA for Azure SQL Database."/> + <RadioButton x:Name="ServicePrincipalAuthRadio" Content="Azure — Service Principal" Foreground="{DynamicResource ForegroundBrush}" + Checked="AuthMode_Changed" Margin="0,4,0,0" + ToolTip="Non-interactive Azure SQL auth using an Entra app registration (client id + secret)."/> + <RadioButton x:Name="ManagedIdentityAuthRadio" Content="Azure — Managed Identity" Foreground="{DynamicResource ForegroundBrush}" + Checked="AuthMode_Changed" Margin="0,4,0,0" + ToolTip="Non-interactive Azure SQL auth using the managed identity of the Azure resource running the Darling service."/> + </StackPanel> + </StackPanel> + + <!-- SQL Auth Credentials --> + <StackPanel Grid.Row="7" x:Name="SqlCredentialsPanel" Visibility="Collapsed" Margin="0,0,0,12"> + <TextBlock Text="Username" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <TextBox x:Name="UsernameBox" Margin="0,0,0,8"/> + <TextBlock Text="Password" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <PasswordBox x:Name="PasswordBox"/> + </StackPanel> + + <!-- Entra MFA Credentials --> + <StackPanel Grid.Row="7" x:Name="EntraMfaPanel" Visibility="Collapsed" Margin="0,0,0,12"> + <TextBlock Text="Username (optional)" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <TextBox x:Name="EntraMfaUsernameBox" Margin="0,0,0,8" + ToolTip="Optional: the UPN the service should pre-populate the authentication dialog with."/> + </StackPanel> + + <!-- Service Principal Credentials --> + <StackPanel Grid.Row="7" x:Name="ServicePrincipalPanel" Visibility="Collapsed" Margin="0,0,0,12"> + <TextBlock Text="Client (Application) ID" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <TextBox x:Name="AzureClientIdBox" Margin="0,0,0,8" + ToolTip="The Application (client) ID of the Entra app registration / service principal."/> + <TextBlock Text="Client Secret" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <PasswordBox x:Name="AzureClientSecretBox" Margin="0,0,0,8"/> + <TextBlock Text="Tenant ID (optional)" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <TextBox x:Name="AzureTenantIdBox" Margin="0,0,0,4" + ToolTip="Optional. Stored for reference; the tenant is resolved automatically from the target server."/> + </StackPanel> + + <!-- Managed Identity Credentials --> + <StackPanel Grid.Row="7" x:Name="ManagedIdentityPanel" Visibility="Collapsed" Margin="0,0,0,12"> + <TextBlock Text="User-Assigned Identity Client ID (optional)" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <TextBox x:Name="ManagedIdentityClientIdBox" Margin="0,0,0,8" + ToolTip="Leave blank to use the system-assigned managed identity. Set to a client id to use a specific user-assigned identity."/> + <TextBlock TextWrapping="Wrap" Foreground="{DynamicResource ForegroundMutedBrush}" FontSize="11" + Text="Managed Identity only works when the Darling service runs on an Azure VM / resource that has a managed identity assigned. Use Service Principal for non-interactive auth from a workstation."/> + </StackPanel> + + <!-- Connection Options --> + <TextBlock Grid.Row="8" Text="Connection Options" FontWeight="Bold" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <StackPanel Grid.Row="9" Margin="0,0,0,12"> + <StackPanel Orientation="Horizontal" Margin="0,0,0,6"> + <TextBlock Text="Encryption:" Foreground="{DynamicResource ForegroundBrush}" VerticalAlignment="Center" Width="110"/> + <ComboBox x:Name="EncryptModeComboBox" Width="150" SelectedIndex="1"> + <ComboBoxItem Content="Optional"/> + <ComboBoxItem Content="Mandatory"/> + <ComboBoxItem Content="Strict"/> + </ComboBox> + </StackPanel> + <CheckBox x:Name="TrustCertCheckBox" Content="Trust server certificate (skip certificate validation)" + Foreground="{DynamicResource ForegroundBrush}" IsChecked="False"/> + <CheckBox x:Name="EnabledCheckBox" Content="Enable data collection for this server" + Foreground="{DynamicResource ForegroundBrush}" IsChecked="True" Margin="0,6,0,0"/> + <CheckBox x:Name="FavoriteCheckBox" Content="Mark as favorite (appears at top of list)" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,6,0,0"/> + <StackPanel Orientation="Horizontal" Margin="0,6,0,0"> + <TextBlock Text="Database:" Foreground="{DynamicResource ForegroundBrush}" VerticalAlignment="Center" Width="110"/> + <TextBox x:Name="DatabaseNameBox" Width="250" + ToolTip="Required for Azure SQL Database. Leave empty for on-premises SQL Server."/> + </StackPanel> + <CheckBox x:Name="ReadOnlyIntentCheckBox" Content="Read-only intent (for AG listeners and readable replicas)" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,6,0,0" + ToolTip="Sets ApplicationIntent=ReadOnly. Required when connecting via an AG listener or Azure failover group endpoint to route to a readable secondary."/> + <CheckBox x:Name="MultiSubnetFailoverCheckBox" Content="Multi-subnet failover (for AG listeners and FCIs)" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,6,0,0" + ToolTip="Sets MultiSubnetFailover=true. Connects to all IPs in parallel during failover. Recommended for AG listeners and failover cluster instances spanning multiple subnets."/> + <StackPanel Orientation="Horizontal" Margin="0,6,0,0"> + <TextBlock Text="Monthly Cost ($):" Foreground="{DynamicResource ForegroundBrush}" VerticalAlignment="Center" Width="110"/> + <TextBox x:Name="MonthlyCostBox" Width="100" TextAlignment="Right" Text="0" + ToolTip="What this server costs per month (license, compute, storage combined). Used for FinOps cost attribution. Leave 0 to hide cost columns."/> + </StackPanel> + <StackPanel Orientation="Horizontal" Margin="0,6,0,0"> + <TextBlock Text="Alert delivery:" Foreground="{DynamicResource ForegroundBrush}" VerticalAlignment="Center" Width="110"/> + <ComboBox x:Name="AlertDeliveryOverrideBox" Width="240" SelectedIndex="0" + ToolTip="How deadlock/blocking alerts for THIS server are delivered. 'Use global setting' follows the app-wide Alerts setting; override to force Summary or Per-event for just this server."> + <ComboBoxItem Content="Use global setting"/> + <ComboBoxItem Content="Summary — one card per cycle"/> + <ComboBoxItem Content="Per-event — one notification per incident"/> + </ComboBox> + </StackPanel> + </StackPanel> + + <!-- Status --> + <TextBlock Grid.Row="10" x:Name="StatusText" Foreground="{DynamicResource ForegroundMutedBrush}" + TextWrapping="Wrap" VerticalAlignment="Bottom" Margin="0,0,0,8"/> + + <!-- Buttons. Test Connection enqueues a test_connect command; the SERVICE opens the connection and + writes the probe result back, which the dialog polls for and shows. --> + <DockPanel Grid.Row="11" LastChildFill="False"> + <Button x:Name="TestConnectionButton" Content="Test Connection" Width="130" Height="30" + Click="TestConnectionButton_Click" DockPanel.Dock="Left" + ToolTip="Asks the Darling service to connect to this server and report the version/edition, before saving."/> + <Button Content="Cancel" Width="80" Height="30" Click="CancelButton_Click" DockPanel.Dock="Right"/> + <Button x:Name="SaveButton" Content="Save" Width="80" Height="30" Click="SaveButton_Click" Style="{StaticResource AccentButton}" DockPanel.Dock="Right" Margin="0,0,8,0"/> + </DockPanel> + </Grid> +</Window> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/AddServerDialog.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/AddServerDialog.xaml.cs new file mode 100644 index 000000000..097b489f8 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/AddServerDialog.xaml.cs @@ -0,0 +1,598 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Globalization; +using System.Text.Json; +using System.Threading.Tasks; +using System.Windows; +using PerformanceMonitor.Common; +using PerformanceMonitor.Notifications; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The viewer's Add / Edit server dialog — the control-plane authoring surface. It WRITES the server +/// definition to <c>config.config_monitored_servers</c> through <see cref="ViewerDataService"/> so the +/// running Darling service actually collects it (Stage 3), replacing the severed +/// <c>viewer-servers.json</c> definition writes. <b>Test Connection</b> is restored as a +/// <c>test_connect</c> command the SERVICE executes (it holds the network path + credentials); the dialog +/// enqueues it and polls the result. +/// +/// <para><b>Auth + secrets.</b> The service connects with Windows (integrated) or SQL auth only, so those +/// are the two modes written (Windows → <c>integrated</c>, SQL → <c>sql</c> + a DPAPI-LocalMachine password +/// blob via <see cref="ViewerServerSecret"/>, never plaintext). A SQL credential PROFILE is resolved to its +/// concrete username + secret at write time. The three Azure/Entra modes have no service connect path yet +/// (<see cref="ServerStoreCredential"/>) and are blocked with a clear message rather than written +/// un-honorable. Favorites stay viewer-local (<see cref="ViewerServerStore.SetFavorite"/>).</para> +/// </summary> +public partial class AddServerDialog : Window +{ + private readonly ViewerDataService? _dataService; + private readonly ViewerServerStore _serverStore; + private readonly ViewerProfileStore _profileStore; + + /// <summary>The row being edited (null when adding); carries the existing DPAPI blob so a blank password on edit is kept.</summary> + private readonly MonitoredServerRow? _existing; + + /// <summary>The identity of the row being edited; when the identity fields change, the old row is replaced.</summary> + private readonly int? _originalServerId; + + private bool _testInFlight; + + /// <summary>The display name of the server that was saved (for the caller's status message), or null when cancelled.</summary> + public string? SavedDisplayName { get; private set; } + + /// <summary>Add constructor. <paramref name="seedServerName"/>/<paramref name="seedDisplayName"/> prefill the + /// address/name when "adopting" a server the sidebar shows but the store does not have yet.</summary> + public AddServerDialog( + ViewerDataService? dataService, + ViewerServerStore serverStore, + ViewerProfileStore profileStore, + string? seedServerName = null, + string? seedDisplayName = null) + { + InitializeComponent(); + _dataService = dataService; + _serverStore = serverStore; + _profileStore = profileStore; + PopulateProfilePicker(); + + if (!string.IsNullOrWhiteSpace(seedServerName)) + { + ServerNameBox.Text = seedServerName; + } + if (!string.IsNullOrWhiteSpace(seedDisplayName)) + { + DisplayNameBox.Text = seedDisplayName; + } + + ApplyReadOnlyGate(); + } + + /// <summary>Edit constructor — prefills from an existing store row.</summary> + public AddServerDialog( + ViewerDataService dataService, + ViewerServerStore serverStore, + ViewerProfileStore profileStore, + MonitoredServerRow existing, + bool isFavorite) + : this(dataService, serverStore, profileStore) + { + ArgumentNullException.ThrowIfNull(existing); + + _existing = existing; + _originalServerId = existing.ServerId; + Title = "Edit SQL Server"; + HeaderText.Text = "Edit SQL Server Connection"; + + ServerNameBox.Text = existing.Host; + DisplayNameBox.Text = existing.Name; + DatabaseNameBox.Text = existing.Database ?? ""; + EnabledCheckBox.IsChecked = existing.IsEnabled; + TrustCertCheckBox.IsChecked = existing.TrustServerCertificate; + ReadOnlyIntentCheckBox.IsChecked = existing.ReadOnlyIntent; + MultiSubnetFailoverCheckBox.IsChecked = existing.MultiSubnetFailover; + MonthlyCostBox.Text = existing.MonthlyCostUsd.ToString(CultureInfo.InvariantCulture); + /* #1236: prefill the per-server delivery override (null = "Use global setting"). */ + AlertDeliveryOverrideBox.SelectedIndex = existing.AlertDeliveryModeOverride switch + { + AlertNotificationMode.Summary => 1, + AlertNotificationMode.PerEvent => 2, + _ => 0 + }; + FavoriteCheckBox.IsChecked = isFavorite; + + EncryptModeComboBox.SelectedIndex = existing.EncryptMode switch + { + "Mandatory" => 1, + "Strict" => 2, + _ => 0 + }; + + if (string.Equals(existing.Auth, ServerStoreCredential.Sql, StringComparison.OrdinalIgnoreCase)) + { + SqlAuthRadio.IsChecked = true; + UsernameBox.Text = existing.Username ?? ""; + /* Decrypt the stored blob to pre-fill the password (same-machine managed deploy). A blob produced + on another machine can't be read here — leave it blank; a blank password on save keeps the + existing blob (see the save path), so the server is not broken by an un-readable pre-fill. */ + var decrypted = OperatingSystem.IsWindows() ? ViewerServerSecret.TryUnprotect(existing.EncryptedPassword) : null; + PasswordBox.Password = decrypted ?? ""; + if (decrypted is null && !string.IsNullOrEmpty(existing.EncryptedPassword)) + { + StatusText.Text = "The stored password can't be read on this machine — leave it blank to keep it, or type a new one."; + } + } + else + { + WindowsAuthRadio.IsChecked = true; + } + } + + /// <summary>Disables the write actions on a read-only connection (or when disconnected), with a clear note.</summary> + private void ApplyReadOnlyGate() + { + if (_dataService is null) + { + SaveButton.IsEnabled = false; + TestConnectionButton.IsEnabled = false; + StatusText.Text = "Not connected to a Darling store — server changes are unavailable."; + } + else if (_dataService.IsReadOnly) + { + SaveButton.IsEnabled = false; + /* Test Connection enqueues a command (a config write), so a read-only seat can't run it either. */ + TestConnectionButton.IsEnabled = false; + StatusText.Text = "This viewer is connected read-only, so servers can't be added or changed. " + + "Set postgres.connectAs to \"admin\" in darling.json and restart."; + } + } + + private void PopulateProfilePicker() + { + var profiles = _profileStore.GetAll(); + ProfileComboBox.ItemsSource = profiles; + if (profiles.Count == 0) + { + UseProfileRadio.IsEnabled = false; + NoProfilesNote.Visibility = Visibility.Visible; + } + } + + /// <summary> + /// Toggles between the inline per-server auth UI and the credential-profile picker. When a profile is + /// chosen the inline auth radios + all per-mode credential panels are hidden. + /// </summary> + private void CredentialSource_Changed(object sender, RoutedEventArgs e) + { + if (ProfilePickerPanel is null || InlineAuthRadios is null) + { + return; + } + + var useProfile = UseProfileRadio.IsChecked == true; + + ProfilePickerPanel.Visibility = useProfile ? Visibility.Visible : Visibility.Collapsed; + InlineAuthRadios.Visibility = useProfile ? Visibility.Collapsed : Visibility.Visible; + + if (useProfile) + { + if (SqlCredentialsPanel is not null) SqlCredentialsPanel.Visibility = Visibility.Collapsed; + if (EntraMfaPanel is not null) EntraMfaPanel.Visibility = Visibility.Collapsed; + if (ServicePrincipalPanel is not null) ServicePrincipalPanel.Visibility = Visibility.Collapsed; + if (ManagedIdentityPanel is not null) ManagedIdentityPanel.Visibility = Visibility.Collapsed; + } + else + { + AuthMode_Changed(sender, e); + } + } + + private void AuthMode_Changed(object sender, RoutedEventArgs e) + { + if (SqlCredentialsPanel is null || EntraMfaPanel is null || + ServicePrincipalPanel is null || ManagedIdentityPanel is null) + { + return; + } + + SqlCredentialsPanel.Visibility = SqlAuthRadio.IsChecked == true ? Visibility.Visible : Visibility.Collapsed; + EntraMfaPanel.Visibility = EntraMfaAuthRadio.IsChecked == true ? Visibility.Visible : Visibility.Collapsed; + ServicePrincipalPanel.Visibility = ServicePrincipalAuthRadio.IsChecked == true ? Visibility.Visible : Visibility.Collapsed; + ManagedIdentityPanel.Visibility = ManagedIdentityAuthRadio.IsChecked == true ? Visibility.Visible : Visibility.Collapsed; + + /* Warn as soon as an Azure/Entra mode is picked — the service can't connect with it, and Save/Test + will block. (A profile-backed Azure identity is caught at resolve time.) */ + var azureSelected = EntraMfaAuthRadio.IsChecked == true + || ServicePrincipalAuthRadio.IsChecked == true + || ManagedIdentityAuthRadio.IsChecked == true; + if (azureSelected) + { + StatusText.Text = ServerStoreCredential.UnsupportedAuthMessage; + } + else if (StatusText.Text == ServerStoreCredential.UnsupportedAuthMessage) + { + StatusText.Text = ""; + } + } + + private string GetSelectedEncryptMode() => EncryptModeComboBox.SelectedIndex switch + { + 1 => "Mandatory", + 2 => "Strict", + _ => "Optional" + }; + + /// <summary> + /// Resolves the chosen credential source into the store's (auth, username, encrypted blob), or a + /// user-facing error. Shared by Save and Test Connection so both apply the identical rules — including + /// blocking the Azure/Entra modes the service can't honor and keeping an existing blob when the password + /// box is left blank on edit. + /// </summary> + private bool TryResolveCredential(out string auth, out string? username, out string? encryptedPassword, out string? error) + { + auth = ServerStoreCredential.Integrated; + username = null; + encryptedPassword = null; + error = null; + + if (UseProfileRadio.IsChecked == true) + { + if (ProfileComboBox.SelectedItem is not ViewerCredentialProfile profile) + { + error = "Select a credential profile, or choose \"Configure credentials on this server\"."; + return false; + } + + var mapped = ServerStoreCredential.MapAuth(profile.AuthType); + if (mapped is null) + { + error = ServerStoreCredential.UnsupportedAuthMessage; + return false; + } + + /* Profile auth is SqlServer here (the only profile type the service honors). Resolve its concrete + secret and write those onto the row — the store keeps concrete creds; profiles are a viewer + authoring convenience the store needs no table for. */ + var secret = _profileStore.GetSecret(profile.Id); + if (secret is null || string.IsNullOrEmpty(secret.Value.Password)) + { + error = $"The credential profile '{profile.Name}' has no stored secret on this machine. Re-enter it under Credential Profiles."; + return false; + } + + auth = mapped; + username = string.IsNullOrWhiteSpace(secret.Value.Username) ? profile.Username : secret.Value.Username; + encryptedPassword = ViewerServerSecret.Protect(secret.Value.Password); + return true; + } + + if (WindowsAuthRadio.IsChecked == true) + { + auth = ServerStoreCredential.Integrated; + return true; + } + + if (SqlAuthRadio.IsChecked == true) + { + auth = ServerStoreCredential.Sql; + username = UsernameBox.Text.Trim(); + if (string.IsNullOrEmpty(username)) + { + error = "Username is required for SQL Server authentication."; + return false; + } + + var typed = PasswordBox.Password; + if (!string.IsNullOrEmpty(typed)) + { + encryptedPassword = ViewerServerSecret.Protect(typed); + return true; + } + + /* Blank password on edit → keep the existing stored blob (Lite's "blank means leave it"). */ + if (_existing is not null && !string.IsNullOrEmpty(_existing.EncryptedPassword)) + { + encryptedPassword = _existing.EncryptedPassword; + return true; + } + + error = "Password is required for SQL Server authentication."; + return false; + } + + /* EntraMFA / ServicePrincipal / ManagedIdentity — no Darling service connect path. */ + error = ServerStoreCredential.UnsupportedAuthMessage; + return false; + } + + /// <summary>Reads the form into a fresh store row (server_id derived from identity), or a user-facing error.</summary> + private MonitoredServerRow? BuildRowFromForm(out string? error) + { + error = null; + + var host = ServerNameBox.Text.Trim(); + if (string.IsNullOrEmpty(host)) + { + error = "Server name is required."; + return null; + } + + if (!TryResolveCredential(out var auth, out var username, out var encryptedPassword, out var credError)) + { + error = credError; + return null; + } + + var displayName = DisplayNameBox.Text.Trim(); + if (string.IsNullOrEmpty(displayName)) + { + displayName = host; + } + + var database = string.IsNullOrWhiteSpace(DatabaseNameBox.Text) ? null : DatabaseNameBox.Text.Trim(); + var readOnlyIntent = ReadOnlyIntentCheckBox.IsChecked == true; + + decimal monthlyCost = 0m; + if (decimal.TryParse(MonthlyCostBox.Text, NumberStyles.Any, CultureInfo.InvariantCulture, out var parsedCost) && parsedCost >= 0) + { + monthlyCost = parsedCost; + } + + return new MonitoredServerRow + { + ServerId = ViewerDataService.ComputeServerId(host, database, readOnlyIntent), + Name = displayName, + Host = host, + Database = database, + Auth = auth, + Username = username, + EncryptedPassword = encryptedPassword, + EncryptMode = GetSelectedEncryptMode(), + TrustServerCertificate = TrustCertCheckBox.IsChecked == true, + ReadOnlyIntent = readOnlyIntent, + MultiSubnetFailover = MultiSubnetFailoverCheckBox.IsChecked == true, + /* Preserve the excluded-databases (edited via the dedicated dialog), not lost on a plain save. */ + ExcludedDatabases = _existing?.ExcludedDatabases ?? new System.Collections.Generic.List<string>(), + MonthlyCostUsd = monthlyCost, + CapturePlans = _existing?.CapturePlans, + AlertDeliveryModeOverride = GetSelectedDeliveryOverride(), + IsEnabled = EnabledCheckBox.IsChecked == true, + }; + } + + /// <summary>The per-server delivery override the combo encodes: index 0 = inherit the global (null), + /// 1 = force Summary, 2 = force Per-event (#1236).</summary> + private AlertNotificationMode? GetSelectedDeliveryOverride() => AlertDeliveryOverrideBox.SelectedIndex switch + { + 1 => AlertNotificationMode.Summary, + 2 => AlertNotificationMode.PerEvent, + _ => null + }; + + private async void SaveButton_Click(object sender, RoutedEventArgs e) + { + if (_dataService is null) + { + return; + } + + try + { + SaveButton.IsEnabled = false; + + /* Build (incl. DPAPI Protect, which can throw) inside the try so nothing escapes this async void. */ + var row = BuildRowFromForm(out var error); + if (row is null) + { + StatusText.Text = error; + SaveButton.IsEnabled = true; + return; + } + + /* Refuse to silently overwrite a DIFFERENT existing server that shares this identity — the upsert's + ON CONFLICT DO UPDATE would clobber its excluded databases / capture override. Covers Add and an + edit that re-points host/database/read-only-intent onto another server's identity. */ + if (_originalServerId != row.ServerId + && await _dataService.GetMonitoredServerAsync(row.ServerId) is not null) + { + StatusText.Text = "A server with this address (and database / read-only intent) is already monitored. Edit it from Manage Servers instead."; + SaveButton.IsEnabled = true; + return; + } + + /* Write the NEW row first, THEN drop the old identity on an edit that moved it — if the delete + fails we leave a recoverable duplicate rather than losing the definition entirely. */ + await _dataService.UpsertMonitoredServerAsync(row); + if (_originalServerId is int original && original != row.ServerId) + { + await _dataService.DeleteMonitoredServerAsync(original); + } + + /* Favorites are viewer-local (the service never reads them) — keyed by the server address. */ + _serverStore.SetFavorite(row.Host, FavoriteCheckBox.IsChecked == true); + + SavedDisplayName = row.Name; + DialogResult = true; + Close(); + } + catch (ViewerReadOnlyException ex) + { + StatusText.Text = ex.Message; + SaveButton.IsEnabled = true; + } + catch (ViewerSchemaSkewException ex) + { + StatusText.Text = ex.Message; + SaveButton.IsEnabled = true; + } + catch (Exception ex) + { + StatusText.Text = $"Error saving server: {ex.Message}"; + SaveButton.IsEnabled = true; + ViewerLogger.Error("AddServerDialog", "Failed to save server definition", ex); + } + } + + private async void TestConnectionButton_Click(object sender, RoutedEventArgs e) + { + if (_dataService is null || _testInFlight) + { + return; + } + + try + { + _testInFlight = true; + TestConnectionButton.IsEnabled = false; + SaveButton.IsEnabled = false; + + /* Build (incl. DPAPI Protect) inside the try — a crypto failure must not escape this async void. */ + var row = BuildRowFromForm(out var error); + if (row is null) + { + StatusText.Text = error; + return; + } + + var args = new TestConnectServer + { + Name = row.Name, + Host = row.Host, + Database = row.Database, + Auth = row.Auth, + Username = row.Username, + EncryptedPassword = row.EncryptedPassword, + ReadOnlyIntent = row.ReadOnlyIntent, + TrustServerCertificate = row.TrustServerCertificate, + EncryptMode = row.EncryptMode, + MultiSubnetFailover = row.MultiSubnetFailover, + }; + + StatusText.Text = "Testing connection via the Darling service…"; + + /* Enqueue + poll + delete the credential-bearing command row on a terminal result. */ + var result = await _dataService.RunTestConnectAsync( + ViewerDataService.BuildTestConnectArgs(args), + requestedBy: "viewer"); + + StatusText.Text = DescribeTestResult(result); + } + catch (ViewerReadOnlyException ex) + { + StatusText.Text = ex.Message; + } + catch (ViewerSchemaSkewException ex) + { + StatusText.Text = ex.Message; + } + catch (Exception ex) + { + StatusText.Text = $"Could not run the connection test: {ex.Message}"; + ViewerLogger.Error("AddServerDialog", "test_connect failed", ex); + } + finally + { + _testInFlight = false; + /* Stay disabled on a read-only / disconnected seat (matches ApplyReadOnlyGate). */ + var writable = _dataService is { IsReadOnly: false }; + TestConnectionButton.IsEnabled = writable; + SaveButton.IsEnabled = writable; + } + } + + /// <summary>Turns a polled <c>test_connect</c> result into a one-line status: the probed facts on success, + /// the service's error on failure, or a still-running note on timeout.</summary> + private static string DescribeTestResult(CommandResult? result) + { + if (result is null) + { + return "The connection test is still running on the service — try again in a moment."; + } + + if (result.Status == ViewerDataService.StatusSucceeded) + { + return DescribeProbeFacts(result.ResultJson); + } + + var error = TryReadJsonString(result.ResultJson, "error"); + return string.IsNullOrWhiteSpace(error) + ? $"Connection failed ({result.ResultStatus ?? "error"})." + : $"Connection failed: {error}"; + } + + private static string DescribeProbeFacts(string? resultJson) + { + if (string.IsNullOrWhiteSpace(resultJson)) + { + return "Connected."; + } + + try + { + using var document = JsonDocument.Parse(resultJson); + var root = document.RootElement; + + var major = root.TryGetProperty("majorVersion", out var mv) && mv.ValueKind == JsonValueKind.Number ? mv.GetInt32() : 0; + var edition = root.TryGetProperty("engineEditionDescription", out var ed) && ed.ValueKind == JsonValueKind.String + ? ed.GetString() + : null; + + var versionLabel = ViewerDataService.SqlVersionLabel(major == 0 ? null : major); + var parts = new System.Collections.Generic.List<string> { "Connected" }; + if (!string.IsNullOrEmpty(versionLabel)) + { + parts.Add(versionLabel); + } + if (!string.IsNullOrWhiteSpace(edition)) + { + parts.Add(edition!); + } + + if (root.TryGetProperty("hasMsdbAccess", out var msdb) && msdb.ValueKind == JsonValueKind.False) + { + parts.Add("no msdb access (SQL Agent job data unavailable)"); + } + + return string.Join(" — ", parts) + "."; + } + catch (JsonException) + { + return "Connected."; + } + } + + private static string? TryReadJsonString(string? json, string property) + { + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + try + { + using var document = JsonDocument.Parse(json); + return document.RootElement.ValueKind == JsonValueKind.Object + && document.RootElement.TryGetProperty(property, out var element) + && element.ValueKind == JsonValueKind.String + ? element.GetString() + : null; + } + catch (JsonException) + { + return null; + } + } + + private void CancelButton_Click(object sender, RoutedEventArgs e) + { + DialogResult = false; + Close(); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/AlertDetailWindow.xaml b/Darling/PerformanceMonitor.Darling.Viewer/AlertDetailWindow.xaml new file mode 100644 index 000000000..cf537c52c --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/AlertDetailWindow.xaml @@ -0,0 +1,173 @@ +<Window x:Class="PerformanceMonitor.Darling.Viewer.AlertDetailWindow" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + Title="Alert Details" + Width="480" SizeToContent="Height" + WindowStartupLocation="CenterOwner" + Icon="EDD.ico" + ResizeMode="NoResize" + Background="{DynamicResource BackgroundLightBrush}" + Foreground="{DynamicResource ForegroundBrush}"> + <!-- Copied from Lite's AlertDetailWindow (Lite/Windows/AlertDetailWindow.xaml) for the W2a Alert + History parity: the modal alert detail the grid opens on double-click / View Details, replacing + the shell's inline detail pane (Lite has no inline pane, only this window). Dark-styled via the + shared ViewerDarkTheme dictionary (closer than the app theme) with the same inline dark palette + MainWindow uses; the DynamicResource theme keys Lite used are inlined because the viewer's dark + dictionary keys them differently. --> + <Window.Resources> + <ResourceDictionary> + <ResourceDictionary.MergedDictionaries> + <ResourceDictionary Source="ViewerDarkTheme.xaml"/> + </ResourceDictionary.MergedDictionaries> + <BooleanToVisibilityConverter x:Key="BoolToVis"/> + </ResourceDictionary> + </Window.Resources> + <Grid Margin="20"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + + <!-- Header --> + <TextBlock Grid.Row="0" Text="Alert Details" FontSize="16" FontWeight="SemiBold" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,16"/> + + <!-- Detail fields --> + <Grid Grid.Row="1"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="130"/> + <ColumnDefinition Width="*"/> + </Grid.ColumnDefinitions> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + + <TextBlock Grid.Row="0" Grid.Column="0" Text="Time:" FontWeight="SemiBold" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,4"/> + <TextBlock Grid.Row="0" Grid.Column="1" x:Name="TimeText" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,4" TextWrapping="Wrap"/> + + <TextBlock Grid.Row="1" Grid.Column="0" Text="Server:" FontWeight="SemiBold" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,4"/> + <TextBlock Grid.Row="1" Grid.Column="1" x:Name="ServerText" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,4" TextWrapping="Wrap"/> + + <TextBlock Grid.Row="2" Grid.Column="0" Text="Metric:" FontWeight="SemiBold" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,4"/> + <TextBlock Grid.Row="2" Grid.Column="1" x:Name="MetricText" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,4" TextWrapping="Wrap"/> + + <TextBlock Grid.Row="3" Grid.Column="0" Text="Current Value:" FontWeight="SemiBold" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,4"/> + <TextBlock Grid.Row="3" Grid.Column="1" x:Name="CurrentValueText" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,4" TextWrapping="Wrap"/> + + <TextBlock Grid.Row="4" Grid.Column="0" Text="Threshold:" FontWeight="SemiBold" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,4"/> + <TextBlock Grid.Row="4" Grid.Column="1" x:Name="ThresholdText" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,4" TextWrapping="Wrap"/> + + <TextBlock Grid.Row="5" Grid.Column="0" Text="Notification:" FontWeight="SemiBold" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,4"/> + <TextBlock Grid.Row="5" Grid.Column="1" x:Name="NotificationText" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,4" TextWrapping="Wrap"/> + + <TextBlock Grid.Row="6" Grid.Column="0" Text="Status:" FontWeight="SemiBold" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,4"/> + <TextBlock Grid.Row="6" Grid.Column="1" x:Name="StatusValueText" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,4" TextWrapping="Wrap"/> + </Grid> + + <!-- Muted indicator --> + <Border Grid.Row="2" x:Name="MutedBanner" Visibility="Collapsed" + Background="#33FFD54F" + CornerRadius="4" Padding="8" Margin="0,12,0,0"> + <TextBlock Text="🔇 This alert was muted by a mute rule" + Foreground="{DynamicResource ForegroundBrush}" FontStyle="Italic"/> + </Border> + + <!-- Context-specific details --> + <Border Grid.Row="3" x:Name="DetailPanel" Visibility="Collapsed" + Background="{DynamicResource BackgroundDarkBrush}" BorderBrush="{DynamicResource BorderBrush}" + BorderThickness="1" CornerRadius="4" Padding="10" Margin="0,12,0,0"> + <StackPanel> + <TextBlock Text="Details" FontWeight="SemiBold" FontSize="13" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,6"/> + + <!-- Structured detail items (advice prose, remediation T-SQL, drill-down). + Shown when the persisted context_json deserializes. --> + <ScrollViewer x:Name="DetailItemsScroll" Visibility="Collapsed" + MaxHeight="380" VerticalScrollBarVisibility="Auto"> + <ItemsControl x:Name="DetailItems"> + <ItemsControl.ItemTemplate> + <DataTemplate> + <StackPanel Margin="0,0,0,10"> + <TextBlock Text="{Binding Heading}" FontWeight="SemiBold" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + + <!-- Remediation T-SQL: monospace, read-only, with Copy. --> + <StackPanel Visibility="{Binding IsCodeBlock, Converter={StaticResource BoolToVis}}"> + <Button Content="Copy" HorizontalAlignment="Right" + Padding="8,2" Margin="0,0,0,4" + Click="CopyTsqlButton_Click"/> + <TextBox Text="{Binding Body, Mode=OneWay}" IsReadOnly="True" + FontFamily="Consolas" FontSize="12" TextWrapping="NoWrap" + MaxHeight="240" + VerticalScrollBarVisibility="Auto" + HorizontalScrollBarVisibility="Auto" + Background="Transparent" + Foreground="{DynamicResource ForegroundBrush}" + BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"/> + </StackPanel> + + <!-- Advice prose. --> + <TextBlock Text="{Binding Body}" TextWrapping="Wrap" + Visibility="{Binding IsProse, Converter={StaticResource BoolToVis}}" + Foreground="{DynamicResource ForegroundBrush}"/> + + <!-- Label/value field rows. --> + <ItemsControl ItemsSource="{Binding Fields}" + Visibility="{Binding HasFields, Converter={StaticResource BoolToVis}}"> + <ItemsControl.ItemTemplate> + <DataTemplate> + <Grid Margin="0,1"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="130"/> + <ColumnDefinition Width="*"/> + </Grid.ColumnDefinitions> + <TextBlock Grid.Column="0" Text="{Binding Label}" + Foreground="{DynamicResource ForegroundBrush}" + TextWrapping="Wrap"/> + <TextBlock Grid.Column="1" Text="{Binding Value}" + Foreground="{DynamicResource ForegroundBrush}" + FontFamily="Consolas" FontSize="12" + TextWrapping="Wrap"/> + </Grid> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + </StackPanel> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + </ScrollViewer> + + <!-- Legacy / no-context fallback: flat persisted detail_text. --> + <TextBox x:Name="DetailTextBox" IsReadOnly="True" TextWrapping="Wrap" + Visibility="Collapsed" + Background="Transparent" BorderThickness="0" + Foreground="{DynamicResource ForegroundBrush}" FontFamily="Consolas" + FontSize="12" MaxHeight="300" + VerticalScrollBarVisibility="Auto"/> + </StackPanel> + </Border> + </Grid> +</Window> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/AlertDetailWindow.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/AlertDetailWindow.xaml.cs new file mode 100644 index 000000000..5192dafc1 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/AlertDetailWindow.xaml.cs @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Collections.Generic; +using System.Windows; +using PerformanceMonitor.Notifications; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The modal alert-detail window — copied from Lite's <c>AlertDetailWindow</c> +/// (Lite/Windows/AlertDetailWindow.xaml.cs) for the W2a Alert History parity, over the viewer's +/// <see cref="ViewerAlertRow"/> instead of Lite's <c>AlertHistoryRow</c>. Prefers the structured +/// context (advice / remediation T-SQL / drill-down fields) persisted as <c>context_json</c>, falling +/// back to the flat <c>detail_text</c> for rows written before the structured context existed. +/// </summary> +public partial class AlertDetailWindow : Window +{ + public AlertDetailWindow(ViewerAlertRow item) + { + InitializeComponent(); + + TimeText.Text = item.TimeLocal; + ServerText.Text = item.ServerName; + MetricText.Text = item.MetricName; + CurrentValueText.Text = item.CurrentValueDisplay; + ThresholdText.Text = item.ThresholdValueDisplay; + NotificationText.Text = item.NotificationType; + StatusValueText.Text = item.StatusDisplay; + + if (item.Muted) + { + MutedBanner.Visibility = Visibility.Visible; + } + + /* Prefer the structured context (advice / remediation T-SQL / drill-down) persisted as + context_json; fall back to the flat detail_text for rows that have no context. */ + if (TryBuildDetailViews(item.ContextJson, out var views)) + { + DetailItems.ItemsSource = views; + DetailItemsScroll.Visibility = Visibility.Visible; + DetailPanel.Visibility = Visibility.Visible; + } + else if (!string.IsNullOrWhiteSpace(item.DetailText)) + { + DetailTextBox.Text = item.DetailText; + DetailTextBox.Visibility = Visibility.Visible; + DetailPanel.Visibility = Visibility.Visible; + } + } + + /* WPF cannot data-bind to AlertDetailItem.Fields (a List<(string,string)> ValueTuple — its + Item1/Item2 are fields, not properties), so the deserialized context is projected into + bindable view-models the DataTemplate can render. */ + private static bool TryBuildDetailViews(string? contextJson, out List<DetailItemView> views) + { + views = new List<DetailItemView>(); + if (!AlertContextSerializer.TryDeserialize(contextJson, out var context)) + { + return false; + } + + foreach (var detail in context.Details) + { + var view = new DetailItemView + { + Heading = detail.Heading, + Body = detail.Body, + IsCodeBlock = detail.IsCodeBlock + }; + foreach (var (label, value) in detail.Fields) + { + view.Fields.Add(new FieldView { Label = label, Value = value }); + } + views.Add(view); + } + + return views.Count > 0; + } + + private void CopyTsqlButton_Click(object sender, RoutedEventArgs e) + { + if (sender is FrameworkElement { DataContext: DetailItemView view } && !string.IsNullOrEmpty(view.Body)) + { + try + { + Clipboard.SetText(view.Body); + } + catch + { + /* Clipboard can be locked by another process — swallow contention. */ + } + } + } + + public sealed class DetailItemView + { + public string Heading { get; set; } = ""; + public string? Body { get; set; } + public bool IsCodeBlock { get; set; } + public List<FieldView> Fields { get; } = new(); + + public bool IsProse => !IsCodeBlock && !string.IsNullOrEmpty(Body); + public bool HasFields => Fields.Count > 0; + } + + public sealed class FieldView + { + public string Label { get; set; } = ""; + public string Value { get; set; } = ""; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/AlertToastCoordinator.cs b/Darling/PerformanceMonitor.Darling.Viewer/AlertToastCoordinator.cs new file mode 100644 index 000000000..8a668c998 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/AlertToastCoordinator.cs @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// Decides which polled alert-history rows become in-app tray toasts — the headless viewer's stand-in for +/// Lite's in-process alert engine, which fires a toast at the moment it detects a condition. The viewer has +/// no engine: alerts already fired SERVICE-side (written to <c>config_alert_log</c> and delivered by +/// email/Teams/Slack), so the MainWindow re-reads recent history on a timer and this coordinator turns that +/// stream into non-duplicated, non-spammy toasts. Pure and clock-injected so the two load-bearing seams — +/// the "new since last-seen" watermark and the per-condition cooldown gate — are unit-testable without WPF, +/// Postgres, or a real clock. +/// +/// <para>Two-stage gate, per polled row:</para> +/// <list type="number"> +/// <item><b>Seen-once watermark.</b> Each distinct alert row is identified by the same triple the dismiss +/// path keys on — (alert_time, server_id, metric_name) — so a row that has already been offered as a toast +/// (or was present at startup, via <see cref="Prime"/>) is never re-toasted, even though the poll re-reads +/// an overlapping window each cycle.</item> +/// <item><b>Per-condition cooldown.</b> A genuinely-new row still toasts only if the same condition — +/// (server_id, metric_name) — has not toasted within the cooldown window, so a recurring condition the +/// service logs every cycle (e.g. High CPU) nudges at most once per cooldown instead of storming the tray. +/// Mirrors the spirit of the shared <c>IncidentCooldown</c> keyed on a fingerprint.</item> +/// </list> +/// <para>Muted rows never toast (Lite parity: the service still logs a muted row, flagged, with channels +/// skipped). Bookkeeping is pruned each call so neither map grows without bound over a long-lived viewer.</para> +/// </summary> +public sealed class AlertToastCoordinator +{ + /// <summary>How long a seen-row key is retained. Must be at least the poll fetch window so a row still + /// inside that window is never pruned early and re-toasted.</summary> + private readonly TimeSpan _seenRetention; + + /// <summary>rowKey (alert_time|server_id|metric) → the row's alert_time, for seen-once + pruning.</summary> + private readonly Dictionary<string, DateTime> _seen = new(StringComparer.Ordinal); + + /// <summary>condKey (server_id|metric) → the last toast's nowUtc, for the cooldown gate + pruning.</summary> + private readonly Dictionary<string, DateTime> _lastToast = new(StringComparer.Ordinal); + + /// <param name="seenRetention"> + /// How long to remember a row so the overlapping poll window doesn't re-toast it. Pass at least the + /// poll fetch window (the MainWindow passes fetch-window + a margin). + /// </param> + public AlertToastCoordinator(TimeSpan seenRetention) + { + if (seenRetention <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(seenRetention), "Retention must be positive."); + } + + _seenRetention = seenRetention; + } + + /// <summary>The distinct-row identity (matches the dismiss key: alert_time + server_id + metric_name).</summary> + private static string RowKey(ViewerAlertRow row) => + string.Concat(row.AlertTime.Ticks.ToString(System.Globalization.CultureInfo.InvariantCulture), "|", + row.ServerId.ToString(System.Globalization.CultureInfo.InvariantCulture), "|", row.MetricName); + + /// <summary>The condition identity the cooldown throttles (server + metric).</summary> + private static string ConditionKey(ViewerAlertRow row) => + string.Concat(row.ServerId.ToString(System.Globalization.CultureInfo.InvariantCulture), "|", row.MetricName); + + /// <summary> + /// Seeds the seen-set from rows that already existed when the viewer opened, so startup does NOT replay + /// the whole alert history as a toast storm — only alerts that arrive AFTER priming can toast. Does not + /// arm the cooldown (a post-startup recurrence of a primed condition is free to toast once). + /// </summary> + public void Prime(IEnumerable<ViewerAlertRow> existingRows) + { + ArgumentNullException.ThrowIfNull(existingRows); + + foreach (var row in existingRows) + { + _seen[RowKey(row)] = row.AlertTime; + } + } + + /// <summary> + /// Filters a freshly-polled batch to the rows that should toast now, updating the seen-set and cooldown + /// state. Rows are considered oldest-first so, within a burst that shares a condition, the EARLIEST row + /// wins the cooldown slot (the rest are suppressed until the window elapses). A row is emitted only when + /// it is new (not seen/primed), not muted, and its condition is outside the cooldown window; every + /// processed row is marked seen regardless of outcome so it is considered exactly once. + /// </summary> + /// <param name="polledRows">The latest read of recent, non-dismissed alert rows (any order).</param> + /// <param name="nowUtc">The current time (injected for testability).</param> + /// <param name="cooldown"> + /// The per-condition cooldown window (the "Tray notification cooldown" setting). <see cref="TimeSpan.Zero"/> + /// or negative disables the cooldown so every new, unmuted row toasts. + /// </param> + public IReadOnlyList<ViewerAlertRow> SelectToasts( + IEnumerable<ViewerAlertRow> polledRows, DateTime nowUtc, TimeSpan cooldown) + { + ArgumentNullException.ThrowIfNull(polledRows); + + var toasts = new List<ViewerAlertRow>(); + + foreach (var row in polledRows.OrderBy(r => r.AlertTime)) + { + var rowKey = RowKey(row); + if (_seen.ContainsKey(rowKey)) + { + continue; /* already offered (or primed at startup) — never re-toast */ + } + + _seen[rowKey] = row.AlertTime; /* processed exactly once, whatever we decide below */ + + if (row.Muted) + { + continue; /* Lite parity: a muted row is logged but never toasted */ + } + + var conditionKey = ConditionKey(row); + if (cooldown > TimeSpan.Zero + && _lastToast.TryGetValue(conditionKey, out var last) + && nowUtc - last < cooldown) + { + continue; /* same condition toasted too recently */ + } + + _lastToast[conditionKey] = nowUtc; + toasts.Add(row); + } + + Prune(nowUtc, cooldown); + return toasts; + } + + /// <summary> + /// Drops bookkeeping that can no longer affect a decision: a seen row older than the retention window + /// (the poll can never re-read it) and a cooldown entry older than the effective cooldown window. + /// </summary> + private void Prune(DateTime nowUtc, TimeSpan cooldown) + { + var seenCutoff = nowUtc - _seenRetention; + foreach (var key in _seen.Where(kv => kv.Value < seenCutoff).Select(kv => kv.Key).ToList()) + { + _seen.Remove(key); + } + + /* Keep a cooldown entry until it can no longer block anything — retain for the longer of the + cooldown window and the seen-retention so a slow poll can't forget an active cooldown early. */ + var cooldownWindow = cooldown > _seenRetention ? cooldown : _seenRetention; + var toastCutoff = nowUtc - cooldownWindow; + foreach (var key in _lastToast.Where(kv => kv.Value < toastCutoff).Select(kv => kv.Key).ToList()) + { + _lastToast.Remove(key); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/AlertsHistoryTab.xaml b/Darling/PerformanceMonitor.Darling.Viewer/AlertsHistoryTab.xaml new file mode 100644 index 000000000..2fcac3994 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/AlertsHistoryTab.xaml @@ -0,0 +1,192 @@ +<UserControl x:Class="PerformanceMonitor.Darling.Viewer.AlertsHistoryTab" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + mc:Ignorable="d" + d:DesignHeight="450" d:DesignWidth="800"> + <!-- The full Alert History surface (W2a viewer copy-parity), copied from Lite's + Controls/AlertsHistoryTab.xaml and rewired to Postgres via ViewerDataService: all-servers by + default with a Server filter + Server column, Dismiss Selected / Dismiss All (the write path), + column-filter headers, copy/export, count + last-refreshed indicators, and Mute This / Mute + Similar. Per Erik's faithful-copy decision, mute lives HERE (this tab + the Manage Mute Rules + window, which the shell's Alerts tab already had). Darling has no parquet archive tier, so the + archived-row states (IsArchived tint, "archival in progress" banner) are dropped. Controls theme + via the app-merged Dark theme's implicit styles; ColumnFilterButtonStyle resolves app-wide. --> + <UserControl.Resources> + <ResourceDictionary> + <ContextMenu x:Key="DataGridContextMenu"> + <MenuItem Header="View Details..." Click="ViewAlertDetails_Click"> + <MenuItem.Icon><TextBlock Text="🔍"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <MenuItem Header="Copy Cell" Click="CopyCell_Click"> + <MenuItem.Icon><TextBlock Text="📋"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy Row" Click="CopyRow_Click"> + <MenuItem.Icon><TextBlock Text="📄"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy All Rows" Click="CopyAllRows_Click"> + <MenuItem.Icon><TextBlock Text="📑"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <MenuItem Header="Export to CSV..." Click="ExportToCsv_Click"> + <MenuItem.Icon><TextBlock Text="📊"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <MenuItem Header="Mute This Alert..." Click="MuteThisAlert_Click"> + <MenuItem.Icon><TextBlock Text="🔕"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Mute Similar Alerts..." Click="MuteSimilarAlerts_Click"> + <MenuItem.Icon><TextBlock Text="🔇"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <MenuItem Header="Dismiss Selected" Click="DismissSelected_Click"> + <MenuItem.Icon><TextBlock Text="✗"/></MenuItem.Icon> + </MenuItem> + </ContextMenu> + + <Style x:Key="DefaultRowStyle" TargetType="DataGridRow"> + <Setter Property="ContextMenu" Value="{StaticResource DataGridContextMenu}"/> + </Style> + + <Style x:Key="AlertRowStyle" TargetType="DataGridRow" BasedOn="{StaticResource DefaultRowStyle}"> + <Style.Triggers> + <DataTrigger Binding="{Binding IsCritical}" Value="True"> + <Setter Property="Background" Value="#33DC2626"/> + </DataTrigger> + <DataTrigger Binding="{Binding IsWarning}" Value="True"> + <Setter Property="Background" Value="#33D97706"/> + </DataTrigger> + <DataTrigger Binding="{Binding IsResolved}" Value="True"> + <Setter Property="Background" Value="#3322C55E"/> + </DataTrigger> + <DataTrigger Binding="{Binding Muted}" Value="True"> + <Setter Property="Opacity" Value="0.5"/> + <Setter Property="FontStyle" Value="Italic"/> + </DataTrigger> + </Style.Triggers> + </Style> + </ResourceDictionary> + </UserControl.Resources> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + + <!-- Header Controls --> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="10,8" Height="36"> + <TextBlock Text="Alert History" VerticalAlignment="Center" Margin="0,0,10,0" FontWeight="Bold" FontSize="14" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBlock Text="Time Range:" VerticalAlignment="Center" Margin="5,0,4,0" + Foreground="{DynamicResource ForegroundMutedBrush}"/> + <ComboBox x:Name="TimeRangeComboBox" Width="100" VerticalAlignment="Center" + SelectionChanged="TimeRangeComboBox_SelectionChanged"> + <ComboBoxItem Content="Last 1 Hour" Tag="1"/> + <ComboBoxItem Content="Last 4 Hours" Tag="4"/> + <ComboBoxItem Content="Last 24 Hours" Tag="24" IsSelected="True"/> + <ComboBoxItem Content="Last 7 Days" Tag="168"/> + <ComboBoxItem Content="All" Tag="8760"/> + </ComboBox> + <TextBlock Text="Server:" VerticalAlignment="Center" Margin="12,0,4,0" + Foreground="{DynamicResource ForegroundMutedBrush}"/> + <ComboBox x:Name="ServerFilterComboBox" Width="160" VerticalAlignment="Center" + SelectionChanged="ServerFilterComboBox_SelectionChanged"> + <ComboBoxItem Content="All Servers" Tag="" IsSelected="True"/> + </ComboBox> + <Button Content="Refresh" Click="RefreshButton_Click" Margin="12,0,0,0" Padding="10,4" MinWidth="70"/> + <Button x:Name="DismissSelectedButton" Content="Dismiss Selected" Click="DismissSelected_Click" + Margin="8,0,0,0" Padding="10,4" MinWidth="90" IsEnabled="False"/> + <Button x:Name="DismissAllButton" Content="Dismiss All" Click="DismissAll_Click" + Margin="4,0,0,0" Padding="10,4" MinWidth="70"/> + <Button Content="Manage Mute Rules…" Click="ManageMuteRules_Click" + Margin="8,0,0,0" Padding="10,4" MinWidth="90"/> + <TextBlock x:Name="AlertCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" + FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + <TextBlock x:Name="LastRefreshedIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" + FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}"/> + </StackPanel> + + <!-- DataGrid --> + <Grid Grid.Row="1" Margin="10,0,10,10"> + <DataGrid x:Name="AlertsDataGrid" + AutoGenerateColumns="False" + IsReadOnly="True" + CanUserSortColumns="True" + GridLinesVisibility="Horizontal" + CanUserResizeColumns="True" + HeadersVisibility="Column" + SelectionMode="Extended" + SelectionChanged="AlertsDataGrid_SelectionChanged" + MouseDoubleClick="AlertsDataGrid_MouseDoubleClick" + RowStyle="{StaticResource AlertRowStyle}"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding TimeLocal}" Width="150" SortMemberPath="AlertTime" SortDirection="Descending"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Time" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ServerName}" Width="140"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ServerName" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Server" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MetricName}" Width="160"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MetricName" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Alert Type" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CurrentValueDisplay}" Width="160"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CurrentValueDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Value" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ThresholdValueDisplay}" Width="120"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ThresholdValueDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Threshold" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding NotificationType}" Width="80"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="NotificationType" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Channel" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding StatusDisplay}" Width="100"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="StatusDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Status" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + + <!-- Empty State --> + <TextBlock x:Name="NoAlertsMessage" + Text="No alerts recorded in the selected time range" + FontSize="14" Foreground="{DynamicResource ForegroundMutedBrush}" + HorizontalAlignment="Center" VerticalAlignment="Center" + Visibility="Collapsed"/> + </Grid> + </Grid> +</UserControl> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/AlertsHistoryTab.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/AlertsHistoryTab.xaml.cs new file mode 100644 index 000000000..8a5301079 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/AlertsHistoryTab.xaml.cs @@ -0,0 +1,535 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Threading; +using PerformanceMonitor.Common; +using PerformanceMonitor.Notifications; +using PerformanceMonitor.Ui; +using static PerformanceMonitor.Ui.DataGridHelpers; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The full Alert History surface (W2a viewer copy-parity), copied from Lite's <c>AlertsHistoryTab</c> +/// (Lite/Controls/AlertsHistoryTab.xaml.cs) and rewired to the Darling Postgres store via +/// <see cref="ViewerDataService"/>: all-servers by default with a Server filter + Server column, the +/// Dismiss Selected / Dismiss All write path, column-filter headers, copy/export, count + last-refreshed +/// indicators, and Mute This / Mute Similar plus the Manage Mute Rules window. Darling has no parquet +/// archive tier, so Lite's archived-row handling (the IsArchived split, the "archival in progress" +/// banner, the partial-dismiss confirmation) is dropped — every row is live and dismissable. Load / +/// dismiss failures surface on the host's status bar via <see cref="StatusChanged"/>. +/// </summary> +public partial class AlertsHistoryTab : UserControl +{ + /// <summary> + /// Whether alert dismiss + mute actions are written to the viewer log (the "Log alert dismiss and mute + /// actions" Settings checkbox — <see cref="ViewerAppSettings.LogAlertDismissals"/>). Seeded from the app + /// settings by <see cref="MainWindow"/> at startup and after the Settings window saves; a process-wide static + /// because the viewer loads its app settings per-window (the twin of Lite's <c>App.LogAlertDismissals</c>). + /// </summary> + public static bool LogDismissals { get; set; } = true; + + private ViewerDataService? _dataService; + private DataGridFilterManager<ViewerAlertRow>? _filterManager; + private Popup? _filterPopup; + private ColumnFilterPopup? _filterPopupContent; + private DateTime? _lastRefreshed; + private readonly DispatcherTimer _staleDataTimer; + + /// <summary>Raised with a short status message on load/dismiss/mute outcomes so the shell can show it.</summary> + public event Action<string>? StatusChanged; + + public AlertsHistoryTab() + { + InitializeComponent(); + _staleDataTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(10) }; + _staleDataTimer.Tick += StaleDataTimer_Tick; + } + + /// <summary>Wires the data service and the column-filter manager, and starts the stale-data ticker.</summary> + public void Initialize(ViewerDataService dataService) + { + _dataService = dataService; + _filterManager = new DataGridFilterManager<ViewerAlertRow>(AlertsDataGrid); + + /* V8 security hardening: a read-only (viewer-role) connection can't dismiss alerts, so disable + the Dismiss buttons up front with an explaining tooltip (the Dismiss Selected button is also + selection-gated below). Mute-from-alert and context-menu Dismiss stay clickable but surface + the read-only message via StatusChanged if used — the reactive backstop. */ + if (dataService.IsReadOnly) + { + const string readOnlyTip = "This viewer is connected read-only (postgres.connectAs = \"viewer\"); alert dismissal is disabled."; + DismissAllButton.IsEnabled = false; + DismissAllButton.ToolTip = readOnlyTip; + DismissSelectedButton.ToolTip = readOnlyTip; + } + + _staleDataTimer.Start(); + } + + /// <summary>Reloads the alert history (the shell calls this when the Alerts tab becomes visible / on the timer).</summary> + public Task RefreshAlertsAsync() => LoadAlertsAsync(); + + private async Task LoadAlertsAsync() + { + if (_dataService == null) + { + return; + } + + try + { + var hoursBack = GetSelectedHoursBack(); + int? serverId = GetSelectedServerId(); + var sinceUtc = DateTime.UtcNow.AddHours(-hoursBack); + + var alerts = await _dataService.GetAlertHistoryAsync(sinceUtc, serverId); + + if (_filterManager != null) + { + _filterManager.UpdateData(alerts); + } + else + { + AlertsDataGrid.ItemsSource = alerts; + } + + var displayCount = AlertsDataGrid.Items.Count; + NoAlertsMessage.Visibility = displayCount == 0 ? Visibility.Visible : Visibility.Collapsed; + AlertCountIndicator.Text = displayCount > 0 ? $"{displayCount} alert(s)" : ""; + + _lastRefreshed = DateTime.UtcNow; + UpdateStaleDataIndicator(); + + PopulateServerFilter(alerts); + } + catch (Exception ex) + { + StatusChanged?.Invoke($"failed to load alert history: {ex.Message}"); + } + } + + /// <summary> + /// Rebuilds the Server filter combo from the servers present in the current read (Lite's + /// PopulateServerFilter): "All Servers" first, then each distinct server, preserving the selection. + /// </summary> + private void PopulateServerFilter(List<ViewerAlertRow> alerts) + { + var servers = alerts + .Select(a => (a.ServerId, a.ServerName)) + .Where(s => !string.IsNullOrEmpty(s.ServerName)) + .Distinct() + .OrderBy(s => s.ServerName) + .ToList(); + + var currentSelection = ServerFilterComboBox.SelectedIndex > 0 + ? (ServerFilterComboBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() + : null; + + var existingIds = ServerFilterComboBox.Items + .OfType<ComboBoxItem>() + .Skip(1) + .Select(i => i.Tag?.ToString()) + .ToList(); + + var newIds = servers.Select(s => s.ServerId.ToString()).ToList(); + if (newIds.SequenceEqual(existingIds)) + { + return; + } + + ServerFilterComboBox.SelectionChanged -= ServerFilterComboBox_SelectionChanged; + + while (ServerFilterComboBox.Items.Count > 1) + { + ServerFilterComboBox.Items.RemoveAt(1); + } + + foreach (var (serverId, serverName) in servers) + { + ServerFilterComboBox.Items.Add(new ComboBoxItem + { + Content = serverName, + Tag = serverId.ToString() + }); + } + + if (currentSelection != null) + { + for (var i = 1; i < ServerFilterComboBox.Items.Count; i++) + { + if ((ServerFilterComboBox.Items[i] as ComboBoxItem)?.Tag?.ToString() == currentSelection) + { + ServerFilterComboBox.SelectedIndex = i; + break; + } + } + } + + ServerFilterComboBox.SelectionChanged += ServerFilterComboBox_SelectionChanged; + } + + private int GetSelectedHoursBack() + { + if (TimeRangeComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tagStr) + { + return int.TryParse(tagStr, out var hours) ? hours : 24; + } + return 24; + } + + private int? GetSelectedServerId() + { + if (ServerFilterComboBox.SelectedIndex > 0 && + ServerFilterComboBox.SelectedItem is ComboBoxItem item && + item.Tag is string tagStr && + int.TryParse(tagStr, out var serverId)) + { + return serverId; + } + return null; + } + + #region Column Filter Handlers + + private void FilterButton_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button button || button.Tag is not string columnName) + { + return; + } + + if (_filterPopup == null) + { + _filterPopupContent = new ColumnFilterPopup(); + _filterPopupContent.FilterApplied += FilterPopup_FilterApplied; + _filterPopupContent.FilterCleared += FilterPopup_FilterCleared; + + _filterPopup = new Popup + { + Child = _filterPopupContent, + StaysOpen = false, + Placement = PlacementMode.Bottom, + AllowsTransparency = true + }; + } + + ColumnFilterState? existingFilter = null; + _filterManager?.Filters.TryGetValue(columnName, out existingFilter); + _filterPopupContent!.Initialize(columnName, existingFilter); + + _filterPopup.PlacementTarget = button; + _filterPopup.IsOpen = true; + } + + private void FilterPopup_FilterApplied(object? sender, FilterAppliedEventArgs e) + { + if (_filterPopup != null) + { + _filterPopup.IsOpen = false; + } + + _filterManager?.SetFilter(e.FilterState); + AlertCountIndicator.Text = AlertsDataGrid.Items.Count > 0 ? $"{AlertsDataGrid.Items.Count} alert(s)" : ""; + } + + private void FilterPopup_FilterCleared(object? sender, EventArgs e) + { + if (_filterPopup != null) + { + _filterPopup.IsOpen = false; + } + } + + #endregion + + #region Stale Data Indicator + + private void StaleDataTimer_Tick(object? sender, EventArgs e) => UpdateStaleDataIndicator(); + + private void UpdateStaleDataIndicator() + { + if (_lastRefreshed.HasValue) + { + var elapsed = DateTime.UtcNow - _lastRefreshed.Value; + LastRefreshedIndicator.Text = elapsed.TotalSeconds < 5 + ? "Refreshed just now" + : elapsed.TotalMinutes < 1 + ? $"Refreshed {(int)elapsed.TotalSeconds}s ago" + : $"Refreshed {(int)elapsed.TotalMinutes}m ago"; + } + } + + #endregion + + #region Event Handlers + + private async void TimeRangeComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (IsLoaded) + { + await LoadAlertsAsync(); + } + } + + private async void ServerFilterComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (IsLoaded) + { + await LoadAlertsAsync(); + } + } + + private async void RefreshButton_Click(object sender, RoutedEventArgs e) => await LoadAlertsAsync(); + + private void AlertsDataGrid_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + /* Read-only connections never enable Dismiss Selected, regardless of selection (V8 hardening). */ + DismissSelectedButton.IsEnabled = + _dataService?.IsReadOnly != true && AlertsDataGrid.SelectedItems.OfType<ViewerAlertRow>().Any(); + } + + private async void DismissSelected_Click(object sender, RoutedEventArgs e) + { + if (_dataService == null) + { + return; + } + + var selected = AlertsDataGrid.SelectedItems.OfType<ViewerAlertRow>().ToList(); + if (selected.Count == 0) + { + return; + } + + try + { + var affected = await _dataService.DismissAlertsAsync(selected); + /* Log the dismiss action when the operator opted in (mirrors Lite's App.LogAlertDismissals gate, + including the partial-dismiss note when fewer rows updated than were selected). */ + if (LogDismissals) + { + ViewerLogger.Info("AlertsHistory", affected < selected.Count + ? $"Dismissed {affected} of {selected.Count} selected alert(s) (some were already dismissed)" + : $"Dismissed {affected} selected alert(s)"); + } + await LoadAlertsAsync(); + StatusChanged?.Invoke($"dismissed {affected} alert(s) — {DateTime.Now:HH:mm:ss}"); + } + catch (Exception ex) + { + StatusChanged?.Invoke($"failed to dismiss selected alerts: {ex.Message}"); + } + } + + private async void DismissAll_Click(object sender, RoutedEventArgs e) + { + if (_dataService == null) + { + return; + } + + var allAlerts = (AlertsDataGrid.ItemsSource as IEnumerable<ViewerAlertRow>)?.ToList(); + if (allAlerts == null || allAlerts.Count == 0) + { + return; + } + + var result = MessageBox.Show( + $"Dismiss {allAlerts.Count} alert(s)?\n\nDismissed alerts are hidden from this view but remain in the store.", + "Dismiss All Alerts", + MessageBoxButton.YesNo, + MessageBoxImage.Question); + + if (result != MessageBoxResult.Yes) + { + return; + } + + try + { + var hoursBack = GetSelectedHoursBack(); + int? serverId = GetSelectedServerId(); + var sinceUtc = DateTime.UtcNow.AddHours(-hoursBack); + var affected = await _dataService.DismissAllVisibleAlertsAsync(sinceUtc, serverId); + if (LogDismissals) + { + var scope = serverId.HasValue ? $"server {serverId.Value}" : "all servers"; + ViewerLogger.Info("AlertsHistory", $"Dismissed all {affected} visible alert(s) ({scope}, last {hoursBack}h)"); + } + await LoadAlertsAsync(); + StatusChanged?.Invoke($"dismissed {affected} alert(s) — {DateTime.Now:HH:mm:ss}"); + } + catch (Exception ex) + { + StatusChanged?.Invoke($"failed to dismiss all alerts: {ex.Message}"); + } + } + + #endregion + + #region Context Menu Handlers + + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); + + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); + + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); + + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "alert_history", ViewerExportSettings.CsvSeparator); + + #endregion + + #region Detail + Mute Handlers + + private void AlertsDataGrid_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + if (sender is not DataGrid) + { + return; + } + + /* Walk up from the click target to the DataGridRow; ignore header / empty-area clicks. */ + var source = e.OriginalSource as DependencyObject; + while (source != null && source is not DataGridRow && source is not DataGridColumnHeader) + { + source = System.Windows.Media.VisualTreeHelper.GetParent(source); + } + + if (source is not DataGridRow row || row.DataContext is not ViewerAlertRow item) + { + return; + } + + ShowDetail(item); + } + + private void ViewAlertDetails_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem) + { + return; + } + var dataGrid = FindParentDataGrid(menuItem); + if (dataGrid?.SelectedItem is not ViewerAlertRow item) + { + return; + } + + ShowDetail(item); + } + + private void ShowDetail(ViewerAlertRow item) + { + var detailWindow = new AlertDetailWindow(item); + var owner = Window.GetWindow(this); + if (owner != null) + { + detailWindow.Owner = owner; + } + detailWindow.ShowDialog(); + } + + private async void MuteThisAlert_Click(object sender, RoutedEventArgs e) + { + if (_dataService == null || sender is not MenuItem menuItem) + { + return; + } + var dataGrid = FindParentDataGrid(menuItem); + if (dataGrid?.SelectedItem is not ViewerAlertRow item) + { + return; + } + + var context = new AlertMuteContext + { + ServerName = item.ServerName, + MetricName = item.MetricName + }; + context.PopulateFromDetailText(item.DetailText); + + await CreateMuteRuleAsync(context); + } + + private async void MuteSimilarAlerts_Click(object sender, RoutedEventArgs e) + { + if (_dataService == null || sender is not MenuItem menuItem) + { + return; + } + var dataGrid = FindParentDataGrid(menuItem); + if (dataGrid?.SelectedItem is not ViewerAlertRow item) + { + return; + } + + /* Metric-only mute: suppress every alert of this type, on any server. */ + var context = new AlertMuteContext + { + MetricName = item.MetricName + }; + + await CreateMuteRuleAsync(context); + } + + private async Task CreateMuteRuleAsync(AlertMuteContext context) + { + if (_dataService == null) + { + return; + } + + var dialog = new MuteRuleEditDialog(context) { Owner = Window.GetWindow(this) }; + if (dialog.ShowDialog() != true) + { + return; + } + + try + { + await _dataService.InsertMuteRuleAsync(dialog.Rule); + /* The "Log alert dismiss and mute actions" opt-in covers mute creation too. */ + if (LogDismissals) + { + var expiry = dialog.Rule.ExpiresAtUtc.HasValue + ? dialog.Rule.ExpiresAtUtc.Value.ToString("u", System.Globalization.CultureInfo.InvariantCulture) + : "never"; + ViewerLogger.Info("AlertsHistory", + $"Created mute rule (server={dialog.Rule.ServerName ?? "any"}, metric={dialog.Rule.MetricName ?? "any"}, expires={expiry})"); + } + await LoadAlertsAsync(); + StatusChanged?.Invoke($"mute rule created — {DateTime.Now:HH:mm:ss}"); + } + catch (Exception ex) + { + StatusChanged?.Invoke($"mute rule save failed: {ex.Message}"); + } + } + + private void ManageMuteRules_Click(object sender, RoutedEventArgs e) + { + if (_dataService == null) + { + return; + } + + var window = new MuteRulesWindow(_dataService) { Owner = Window.GetWindow(this) }; + window.ShowDialog(); + } + + #endregion +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/App.xaml b/Darling/PerformanceMonitor.Darling.Viewer/App.xaml new file mode 100644 index 000000000..f4bf19428 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/App.xaml @@ -0,0 +1,17 @@ +<Application x:Class="PerformanceMonitor.Darling.Viewer.App" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + StartupUri="MainWindow.xaml"> + <!-- The shared Dark theme is merged app-wide so copied Lite XAML resolves its {DynamicResource} + theme keys (ForegroundBrush, BackgroundDarkBrush, SubTabItemStyle, ColumnFilterButtonStyle, + NumericCell, …). OnStartup re-applies it through ThemeManager so ThemeManager owns the merged + dictionary at runtime (Dark is fixed for v1 — no theme picker). The two write-back windows + keep their own ViewerDarkTheme.xaml, which wins as the closer dictionary. --> + <Application.Resources> + <ResourceDictionary> + <ResourceDictionary.MergedDictionaries> + <ResourceDictionary Source="Themes/DarkTheme.xaml"/> + </ResourceDictionary.MergedDictionaries> + </ResourceDictionary> + </Application.Resources> +</Application> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/App.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/App.xaml.cs new file mode 100644 index 000000000..179952d51 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/App.xaml.cs @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Reflection; +using System.Windows; +using System.Windows.Threading; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Darling viewer — a Postgres client of the central store. MainWindow owns data startup: +/// it loads the viewer's sliver of darling.json (<see cref="ViewerSettings"/>) and connects +/// on first render. App startup applies the operator's saved color theme (Dark / Light / CoolBreeze) +/// through <see cref="ThemeManager"/> so copied Lite XAML resolves its theme keys; the Settings window's +/// theme selector live-previews and persists the choice to <see cref="ViewerAppSettings"/>, and +/// <see cref="ThemeManager.CurrentTheme"/> drives whether <c>ChartStyle</c> draws dark or light chrome. +/// Single-instance enforcement uses the shared <see cref="SingleInstanceCoordinator"/> (as Lite / +/// Dashboard) so a second launch surfaces the existing window instead of opening a duplicate viewer, +/// and a newer Velopack build launched over an older one takes over. +/// </summary> +public partial class App : Application +{ + private const string MutexName = "PerformanceMonitorDarlingViewer_SingleInstance"; + private const string ExitForUpgradeEventName = "PerformanceMonitorDarlingViewer_ExitForUpgrade"; + private const string ShowWindowEventName = "PerformanceMonitorDarlingViewer_ShowWindow"; + private SingleInstanceCoordinator? _instanceCoordinator; + private SingleInstanceSignal? _instanceSignal; + + protected override void OnStartup(StartupEventArgs e) + { + /* Apply the saved color theme through ThemeManager (App.xaml merges Dark as the design-time + default) so ThemeManager owns the app-level merged dictionary at runtime, before StartupUri + creates MainWindow. Reads the viewer-local settings directly (cheap JSON read) so the very + first paint is already in the operator's chosen theme — no flash of Dark. Falls back to Dark + on any error. Light / CoolBreeze are honored via the Settings window's theme selector. */ + try + { + ThemeManager.Apply(new ViewerAppSettingsStore().Load().ColorTheme); + } + catch + { + ThemeManager.Apply("Dark"); + } + + /* #1050 (companion to the ported tray's WindowResumeGuard): WPF's GPU render thread can zombie its + surface across sleep/wake or RDP, leaving a live-but-blank window — now reachable in the viewer + because minimize-to-tray can hide it. Software rendering removes the GPU dependency entirely; charts + are unaffected (ScottPlot renders via SkiaSharp/CPU into a bitmap, not WPF's GPU path). Matches Lite. */ + System.Windows.Media.RenderOptions.ProcessRenderMode = + System.Windows.Interop.RenderMode.SoftwareOnly; + + /* Minimal file logging (ported from Lite's AppLogger) so the sidebar's View Log / Open Log + Folder buttons have a real target and operator bug reports carry viewer diagnostics. */ + ViewerLogger.Initialize(); + + /* Single-instance with upgrade handoff (shared PerformanceMonitor.Ui coordinator, mirroring Lite / + Dashboard). Runs before base.OnStartup creates the StartupUri window, so a second launch surfaces + the existing window and exits instead of opening a duplicate viewer; a newer build launched over an + older one closes it and takes over (Velopack upgrade). The viewer holds no exclusive local resource + (it is a stateless Postgres read-client), so the exit-for-upgrade listener can open immediately. */ + _instanceCoordinator = new SingleInstanceCoordinator(new SingleInstanceOptions + { + MutexName = MutexName, + ProcessName = "PerformanceMonitor.Darling.Viewer", + ExitEventName = ExitForUpgradeEventName, + SurfaceRunningInstance = () => SingleInstanceSignal.TrySignal(ShowWindowEventName), + GracefulSelfExit = () => Dispatcher.BeginInvoke(new Action(Shutdown)), + Prompts = new MessageBoxHandoffPrompts("Performance Monitor Darling"), + AutoConfirm = Array.Exists(e.Args, a => string.Equals(a, HandoffArgs.AutoConfirm, StringComparison.OrdinalIgnoreCase)), + Log = msg => { try { ViewerLogger.Info("SingleInstance", msg); } catch { /* logger not yet ready */ } }, + }); + + if (!_instanceCoordinator.TryBecomeOwner()) + { + Shutdown(); + return; + } + + /* Own the "surface me" channel before the (possibly slow) first render, so a fast second launch finds + it; a signal that lands before the window exists is a harmless no-op (the callback null-checks). */ + _instanceSignal = new SingleInstanceSignal(ShowWindowEventName, OnSurfaceWindowRequested); + + /* No risky exclusive init to protect, so let a newer build ask us to step aside for an upgrade now. */ + _instanceCoordinator.EnableUpgradeHandoff(); + + ViewerLogger.Info("App", $"Starting PerformanceMonitor Darling Viewer v{Assembly.GetExecutingAssembly().GetName().Version}"); + + /* Surface otherwise-invisible crashes into the log now that we have one. */ + DispatcherUnhandledException += OnDispatcherUnhandledException; + AppDomain.CurrentDomain.UnhandledException += OnDomainUnhandledException; + + base.OnStartup(e); + + // Right-click selects the DataGrid row under the cursor app-wide, so context-menu actions + // (e.g. View Stored Plan) act on the clicked row even after an auto-refresh cleared the selection. + // (Lite/Dashboard already do this; the viewer's plan handlers read CurrentItem, so without it a + // right-click could act on a different row than the one whose enablement was evaluated.) + PerformanceMonitor.Ui.DataGridRowSelectionBehavior.Enable(); + } + + /// <summary> + /// Invoked on <see cref="SingleInstanceSignal"/>'s background thread when a second launch asks us to + /// surface the window. Marshals to the UI thread and brings the existing window to the front via WPF's + /// own path — the shared <see cref="MainWindow.SurfaceWindow"/> the tray Restore also uses, so a + /// tray-hidden window is un-hidden consistently. + /// </summary> + private void OnSurfaceWindowRequested() + { + Dispatcher.BeginInvoke(new Action(() => + { + if (Current.MainWindow is MainWindow window) + { + window.SurfaceWindow(); + } + })); + } + + protected override void OnExit(ExitEventArgs e) + { + ViewerLogger.Info("App", "Shutting down"); + _instanceSignal?.Dispose(); + _instanceCoordinator?.Dispose(); + ViewerLogger.Shutdown(); + base.OnExit(e); + } + + private static void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) + { + /* Silently swallow the Hardcodet TrayToolTip race condition (issue #422): showing the custom + visual tooltip can throw "root Visual of a VisualTarget cannot have a parent" in + Popup.CreateWindow, and it is harmless (the tooltip just does not show that one time). Setting + e.Handled here on the Dispatcher path is what actually keeps the viewer alive. Ported from + Lite's App.xaml.cs (562269f6 / 50257182) to restore Lite<->Darling parity -- the shared + SystemTrayService custom TrayToolTip is the common crash source. */ + if (IsTrayToolTipCrash(e.Exception)) + { + ViewerLogger.Warn("Dispatcher", "Suppressed Hardcodet TrayToolTip crash (issue #422)"); + e.Handled = true; + return; + } + + ViewerLogger.Error("App", "Unhandled UI exception", e.Exception); + } + + private static void OnDomainUnhandledException(object sender, UnhandledExceptionEventArgs e) + { + var exception = e.ExceptionObject as Exception; + + /* Same #422 crash when it escapes the Dispatcher path (e.g. during tray-Exit shutdown after the + Dispatcher hooks are torn down). AppDomain.UnhandledException cannot mark it handled, but the + Dispatcher branch above catches the live case; log-and-return so it is not surfaced as a fatal + domain crash. */ + if (exception != null && IsTrayToolTipCrash(exception)) + { + ViewerLogger.Warn("AppDomain", "Suppressed Hardcodet TrayToolTip crash (issue #422)"); + ViewerLogger.Flush(); + return; + } + + ViewerLogger.Error("App", "Unhandled domain exception", exception); + } + + /// <summary> + /// Detects the Hardcodet TrayToolTip race-condition crash (issue #422): an ArgumentException from + /// Popup.CreateWindow ("root Visual of a VisualTarget cannot have a parent") surfacing on the + /// TaskbarIcon tooltip path. Matches Lite's detector so both front ends suppress identically. + /// </summary> + private static bool IsTrayToolTipCrash(Exception ex) + { + return ex is System.ArgumentException + && ex.Message.Contains("VisualTarget") + && ex.StackTrace?.Contains("TaskbarIcon") == true; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/BlockingChainViewerProjection.cs b/Darling/PerformanceMonitor.Darling.Viewer/BlockingChainViewerProjection.cs new file mode 100644 index 000000000..100647865 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/BlockingChainViewerProjection.cs @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using PerformanceMonitor.Analysis; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The trivial, mechanical projection from the analysis engine's (internal) reconstruction into the shared +/// public <see cref="BlockingChainModel"/> for the viewer. This is the only per-app block-chain code — it +/// must bridge an internal analysis type to a Common type, and no shared assembly can see both (Ui sees +/// Common but not Analysis; Analysis does not reference Common by design). Copied byte-identical from +/// Lite's/Dashboard's copy (W1e copy-parity — the viewer owns its own copy; Lite/Dashboard are untouched). +/// </summary> +internal static class BlockingChainViewerProjection +{ + public const string EmptyStateDetail = + "No blocking was captured for this session in the selected time range — from either the " + + "blocked-process-report Extended Event or the always-on DMV blocking snapshot. Its wait may not " + + "have overlapped a collection in this window."; + + /// <summary> + /// Builds the model for the ONE chain that contains the clicked session (matched by SessionKey — the + /// clicked session may be the apex, a mid-level blocker, or a leaf victim). The chain is rooted at its + /// apex with the full descendant hierarchy. Returns null when the session isn't in any reconstructed + /// chain (its wait may not have met the blocked-process threshold in the selected range). + /// </summary> + public static BlockingChainModel? BuildModelForSession( + BlockingReconstruction reconstruction, + int? monitorLoop, int spid, int ecid) + { + var chain = BlockingChainReconstructor.FindChainForSession( + reconstruction, monitorLoop, spid, ecid); + if (chain == null) + return null; + + return BlockingChainTreeBuilder.Build( + new[] { ToInput(chain) }, + reconstruction.CycleDetected, + reconstruction.DepthCapped, + reconstruction.TraversalTruncated); + } + + private static BlockingChainInput ToInput(ReconstructedChain c) => new() + { + ApexSpid = c.ApexSpid, + ApexEcid = c.ApexEcid, + ApexTranStarted = c.ApexTranStarted, + ApexSleeping = c.ApexSleeping, + Magnitude = c.Magnitude, + Edges = c.Levels.Select(static l => new BlockingEdgeInput + { + Level = l.Level, + BlockingSpid = l.BlockingSpid, + BlockingEcid = l.BlockingEcid, + BlockingTranStarted = l.BlockingTranStarted, + BlockedSpid = l.BlockedSpid, + BlockedEcid = l.BlockedEcid, + BlockedTranStarted = l.BlockedTranStarted, + LockMode = l.LockMode, + WaitTimeMs = l.WaitTimeMs, + DatabaseName = l.DatabaseName, + BlockingSqlText = l.BlockingSqlText, + BlockedSqlText = l.BlockedSqlText, + BlockedLoginName = l.BlockedLoginName, + BlockedHostName = l.BlockedHostName, + BlockedClientApp = l.BlockedClientApp, + BlockingLoginName = l.BlockingLoginName, + BlockingHostName = l.BlockingHostName, + BlockingClientApp = l.BlockingClientApp, + ContentiousObject = l.ContentiousObject + }).ToList() + }; +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/CollectionLogWindow.xaml b/Darling/PerformanceMonitor.Darling.Viewer/CollectionLogWindow.xaml new file mode 100644 index 000000000..e10768d99 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/CollectionLogWindow.xaml @@ -0,0 +1,113 @@ +<Window x:Class="PerformanceMonitor.Darling.Viewer.CollectionLogWindow" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + Title="Collection History" Height="600" Width="1100" + WindowStartupLocation="CenterOwner" + Icon="EDD.ico" + Background="{DynamicResource BackgroundLightBrush}" + Foreground="{DynamicResource ForegroundBrush}"> + <!-- Copied from Lite's Windows/CollectionLogWindow.xaml for the W1i copy-parity port: the + per-collector collection-history drill the Collection Health "Health Summary" grid opens on + double-click. Purely data-driven; the one behavioral change is the data call is repointed to + ViewerDataService.GetCollectionLogByCollectorAsync (Postgres). Dark-styled via the shared + ViewerDarkTheme resource dictionary (DarkGrid + implicit control styles), with a local + right-align NumericCell (Lite's shipped style lives in a dictionary the viewer windows don't + merge). Copy/Export delegate to the shared PerformanceMonitor.Ui DataGridExport. --> + <Window.Resources> + <ResourceDictionary> + <ResourceDictionary.MergedDictionaries> + <ResourceDictionary Source="ViewerDarkTheme.xaml"/> + </ResourceDictionary.MergedDictionaries> + + <Style x:Key="NumericCell" TargetType="TextBlock"> + <Setter Property="TextAlignment" Value="Right"/> + </Style> + + <ContextMenu x:Key="DataGridContextMenu"> + <MenuItem Header="Copy Cell" Click="CopyCell_Click"> + <MenuItem.Icon><TextBlock Text="📋"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy Row" Click="CopyRow_Click"> + <MenuItem.Icon><TextBlock Text="📄"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy All Rows" Click="CopyAllRows_Click"> + <MenuItem.Icon><TextBlock Text="📑"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <MenuItem Header="Export to CSV..." Click="ExportToCsv_Click"> + <MenuItem.Icon><TextBlock Text="📊"/></MenuItem.Icon> + </MenuItem> + </ContextMenu> + </ResourceDictionary> + </Window.Resources> + + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + + <!-- Header --> + <Border Grid.Row="0" Background="{DynamicResource BackgroundDarkBrush}" Padding="10" Margin="10,10,10,0" CornerRadius="4"> + <StackPanel> + <TextBlock x:Name="CollectorNameText" FontSize="16" FontWeight="Bold"/> + <TextBlock x:Name="SummaryText" Margin="0,5,0,0"/> + </StackPanel> + </Border> + + <!-- Data Grid --> + <DataGrid Grid.Row="1" x:Name="LogDataGrid" + Style="{StaticResource DarkGrid}" + CanUserSortColumns="True" + Margin="10" + ContextMenu="{StaticResource DataGridContextMenu}"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding CollectionTimeFormatted}" Width="140"> + <DataGridTextColumn.Header> + <TextBlock Text="Collection Time" FontWeight="Bold"/> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Status}" Width="100"> + <DataGridTextColumn.Header> + <TextBlock Text="Status" FontWeight="Bold"/> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding RowsCollected}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header> + <TextBlock Text="Rows" FontWeight="Bold"/> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DurationFormatted}" ElementStyle="{StaticResource NumericCell}" Width="100" SortMemberPath="DurationMs"> + <DataGridTextColumn.Header> + <TextBlock Text="Duration" FontWeight="Bold"/> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding SqlDurationFormatted}" ElementStyle="{StaticResource NumericCell}" Width="100" SortMemberPath="SqlDurationMs"> + <DataGridTextColumn.Header> + <TextBlock Text="SQL Duration" FontWeight="Bold"/> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <!-- Store phase: stored under the legacy duckdb_duration_ms column; in the Darling store + it is the Postgres write time. --> + <DataGridTextColumn Binding="{Binding DuckDbDurationFormatted}" ElementStyle="{StaticResource NumericCell}" Width="120" SortMemberPath="DuckDbDurationMs"> + <DataGridTextColumn.Header> + <TextBlock Text="Store Duration" FontWeight="Bold"/> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ErrorMessage}" Width="*"> + <DataGridTextColumn.Header> + <TextBlock Text="Error Message" FontWeight="Bold"/> + </DataGridTextColumn.Header> + </DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + + <!-- Footer --> + <Border Grid.Row="2" Background="{DynamicResource BackgroundDarkBrush}" Padding="10" Margin="10,0,10,10" CornerRadius="4"> + <StackPanel Orientation="Horizontal" HorizontalAlignment="Right"> + <Button Content="Close" Width="100" Height="30" Click="Close_Click"/> + </StackPanel> + </Border> + </Grid> +</Window> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/CollectionLogWindow.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/CollectionLogWindow.xaml.cs new file mode 100644 index 000000000..e56de9d3c --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/CollectionLogWindow.xaml.cs @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer +{ + /// <summary> + /// The per-collector collection-history drill window (W1i), copied from Lite's + /// <c>Windows/CollectionLogWindow.xaml.cs</c>. The Collection Health "Health Summary" grid opens it + /// on double-click. Purely data-driven; the one change from Lite is the data call — repointed to + /// <see cref="ViewerDataService.GetCollectionLogByCollectorAsync"/> (Postgres). Copy/Export use the + /// shared PerformanceMonitor.Ui <see cref="DataGridExport"/> with the viewer's comma separator. + /// </summary> + public partial class CollectionLogWindow : Window + { + private readonly string _collectorName; + private readonly ViewerDataService _dataService; + private readonly int _serverId; + + public CollectionLogWindow(ViewerDataService dataService, int serverId, string collectorName) + { + InitializeComponent(); + + _dataService = dataService; + _serverId = serverId; + _collectorName = collectorName; + + CollectorNameText.Text = $"Collection History: {collectorName}"; + + Loaded += CollectionLogWindow_Loaded; + } + + private async void CollectionLogWindow_Loaded(object sender, RoutedEventArgs e) + { + await LoadCollectionLogAsync(); + } + + private async Task LoadCollectionLogAsync() + { + try + { + var logs = await _dataService.GetCollectionLogByCollectorAsync(_serverId, _collectorName); + LogDataGrid.ItemsSource = logs; + + if (logs.Count > 0) + { + var successCount = logs.Count(l => l.Status == "SUCCESS"); + var errorCount = logs.Count(l => l.Status == "ERROR"); + var avgDuration = logs.Where(l => l.Status == "SUCCESS" && l.DurationMs.HasValue) + .Select(l => (double)l.DurationMs!.Value) + .DefaultIfEmpty(0) + .Average(); + + SummaryText.Text = $"Total Runs: {logs.Count} | Success: {successCount} | Errors: {errorCount} | Avg Duration: {avgDuration:F0} ms"; + } + else + { + SummaryText.Text = "No collection history found for this collector."; + } + } + catch (Exception ex) + { + MessageBox.Show( + $"Failed to load collection history:\n\n{ex.Message}", + "Error", + MessageBoxButton.OK, + MessageBoxImage.Error + ); + } + } + + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => DataGridExport.ExportToCsv(sender, "collection_log", ViewerExportSettings.CsvSeparator); + + private void Close_Click(object sender, RoutedEventArgs e) + { + Close(); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/CollectorScheduleEditorWindow.xaml b/Darling/PerformanceMonitor.Darling.Viewer/CollectorScheduleEditorWindow.xaml new file mode 100644 index 000000000..886c1b5b6 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/CollectorScheduleEditorWindow.xaml @@ -0,0 +1,106 @@ +<Window x:Class="PerformanceMonitor.Darling.Viewer.CollectorScheduleEditorWindow" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + Title="Collector Schedules" + Height="620" Width="760" + MinHeight="440" MinWidth="620" + WindowStartupLocation="CenterOwner" + Icon="EDD.ico" + ResizeMode="CanResizeWithGrip" + Background="{DynamicResource BackgroundLightBrush}" + Foreground="{DynamicResource ForegroundBrush}"> + <!-- The Darling viewer's collector-schedule editor: a faithful port of Lite's CollectorScheduleEditorWindow, + rewired to write the control-plane config.config_collector_schedules the running service honors (scope = + All servers / a specific server; presets change frequencies only). Dark-styled via the shared + ViewerDarkTheme resource dictionary, like the other write-back dialogs. --> + <Window.Resources> + <ResourceDictionary> + <ResourceDictionary.MergedDictionaries> + <ResourceDictionary Source="ViewerDarkTheme.xaml"/> + </ResourceDictionary.MergedDictionaries> + </ResourceDictionary> + </Window.Resources> + + <Grid Margin="16"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + + <StackPanel Grid.Row="0" Margin="0,0,0,10"> + <TextBlock x:Name="HeaderText" Text="Collector Schedules" FontSize="18" FontWeight="Bold"/> + <TextBlock x:Name="SubHeaderText" FontSize="12" Foreground="{StaticResource DimTextBrush}" Margin="0,4,0,0" TextWrapping="Wrap" + Text="Override how often the Darling service collects each metric, and how long it keeps it. Blank overrides fall back to the built-in defaults."/> + <TextBlock x:Name="ReadOnlyBanner" FontSize="12" Foreground="{DynamicResource WarningTextBrush}" Margin="0,6,0,0" TextWrapping="Wrap" + Visibility="Collapsed" + Text="This viewer is connected with a read-only role, so collector schedules can't be changed here."/> + </StackPanel> + + <!-- Scope + use-default --> + <StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,0,0,8"> + <TextBlock Text="Scope:" VerticalAlignment="Center" Margin="0,0,8,0"/> + <ComboBox x:Name="ScopeCombo" Width="280" VerticalAlignment="Center" + SelectionChanged="ScopeCombo_SelectionChanged" + ToolTip="Edit the fleet-wide default schedule or one server's custom override"/> + <CheckBox x:Name="UseDefaultCheckBox" Content="Use default schedule" VerticalAlignment="Center" Margin="20,0,0,0" + Visibility="Collapsed" + Checked="UseDefaultCheckBox_Changed" Unchecked="UseDefaultCheckBox_Changed" + ToolTip="When checked, this server follows the fleet-wide default (no custom override)"/> + </StackPanel> + + <!-- Preset + copy/reset --> + <StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,0,0,8"> + <TextBlock Text="Preset:" VerticalAlignment="Center" Margin="0,0,8,0"/> + <ComboBox x:Name="PresetCombo" Width="150" VerticalAlignment="Center" + SelectionChanged="PresetCombo_SelectionChanged" + ToolTip="Bulk-set collection frequencies; enabled state and retention are not affected"> + <ComboBoxItem Content="Custom"/> + <ComboBoxItem Content="Aggressive"/> + <ComboBoxItem Content="Balanced"/> + <ComboBoxItem Content="Low-Impact"/> + </ComboBox> + <Button x:Name="ResetDefaultsButton" Content="Reset to Defaults" Margin="16,0,0,0" Click="ResetDefaults_Click" + ToolTip="Replace the current values with the built-in default frequencies and retention"/> + <TextBlock Text="Copy from:" VerticalAlignment="Center" Margin="20,0,8,0"/> + <ComboBox x:Name="CopyFromServerCombo" Width="180" VerticalAlignment="Center"/> + <Button x:Name="CopyFromServerButton" Content="Copy" Margin="8,0,0,0" Click="CopyFromServer_Click" + ToolTip="Replace these values with a copy of another server's effective schedule"/> + </StackPanel> + + <TextBlock Grid.Row="3" x:Name="StatusText" FontSize="11" FontStyle="Italic" + Foreground="{StaticResource DimTextBrush}" Margin="0,0,0,6" TextWrapping="Wrap"/> + + <DataGrid Grid.Row="4" x:Name="ScheduleGrid" Style="{StaticResource DarkGrid}" + AutoGenerateColumns="False" CanUserAddRows="False" CanUserDeleteRows="False" + HeadersVisibility="Column" HorizontalScrollBarVisibility="Auto"> + <DataGrid.Columns> + <DataGridTextColumn Header="Collector" Binding="{Binding Name}" Width="180" IsReadOnly="True"/> + <DataGridCheckBoxColumn Header="Enabled" Binding="{Binding Enabled, UpdateSourceTrigger=PropertyChanged}" Width="70"/> + <DataGridTextColumn Header="Every (min)" Binding="{Binding FrequencyMinutes}" Width="90"/> + <DataGridTextColumn Header="Keep (days)" Binding="{Binding RetentionDays}" Width="90"/> + <DataGridTextColumn Header="Description" Binding="{Binding Description}" Width="*" IsReadOnly="True"/> + </DataGrid.Columns> + </DataGrid> + + <TextBlock Grid.Row="5" Margin="0,8,0,0" FontSize="11" Foreground="{StaticResource DimTextBrush}" TextWrapping="Wrap" + Text="Every (min): 0 collects once on server load. Keep (days) must be at least 1. Changes apply on the service's next reload."/> + + <DockPanel Grid.Row="6" Margin="0,12,0,0" LastChildFill="False"> + <!-- Fleet-scale bulk reset: clears every server's per-server override in one write so they all fall + back to the fleet-wide default (the shortcut over reverting servers one at a time). Confirmed + + applied immediately, separate from the per-scope Save. --> + <Button x:Name="ApplyDefaultToAllButton" DockPanel.Dock="Left" Content="Apply Default to All Servers" + Click="ApplyDefaultToAll_Click" MinWidth="80" + ToolTip="Remove every server's custom schedule so they all follow the fleet-wide default"/> + <StackPanel DockPanel.Dock="Right" Orientation="Horizontal"> + <Button x:Name="SaveButton" Content="Save" Click="SaveButton_Click" Margin="0,0,8,0" MinWidth="80"/> + <Button Content="Cancel" Click="CancelButton_Click" MinWidth="80"/> + </StackPanel> + </DockPanel> + </Grid> +</Window> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/CollectorScheduleEditorWindow.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/CollectorScheduleEditorWindow.xaml.cs new file mode 100644 index 000000000..7b87db9d2 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/CollectorScheduleEditorWindow.xaml.cs @@ -0,0 +1,417 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The viewer's collector-schedule editor — a faithful port of Lite's <c>CollectorScheduleEditorWindow</c>, +/// rewired onto the control-plane store: it edits the fleet-wide default schedule (<c>server_id</c> NULL) or a +/// single server's override, overlays the store's sparse <c>config.config_collector_schedules</c> rows on the +/// shared <see cref="CollectorSchedulePresets.BuildDefaultSchedule"/> baseline, and writes the result back via +/// <see cref="ViewerDataService.ReplaceFleetSchedulesAsync"/> / <see cref="ViewerDataService.ReplaceServerSchedulesAsync"/>. +/// Presets change frequencies only (enabled + retention untouched), exactly as Lite. A read-only seat shows a +/// banner and disables the writes. +/// </summary> +public partial class CollectorScheduleEditorWindow : Window +{ + private readonly ViewerDataService _dataService; + private readonly IReadOnlyList<DarlingServer> _servers; + + private List<CollectorScheduleRow> _allOverrides = new(); + private List<CollectorScheduleEditItem> _editing = new(); + private int? _scopeServerId; // null = fleet-wide default scope + private bool _suppressPresetChange; + private bool _suppressScopeReload; + + /// <summary>True when the user saved changes (the caller then re-reads if it cares).</summary> + public bool Saved { get; private set; } + + public CollectorScheduleEditorWindow(ViewerDataService dataService, IReadOnlyList<DarlingServer> servers) + { + ArgumentNullException.ThrowIfNull(dataService); + ArgumentNullException.ThrowIfNull(servers); + + InitializeComponent(); + _dataService = dataService; + _servers = servers; + + Loaded += OnLoaded; + } + + private async void OnLoaded(object sender, RoutedEventArgs e) + { + Loaded -= OnLoaded; + + if (_dataService.IsReadOnly) + { + ReadOnlyBanner.Visibility = Visibility.Visible; + SaveButton.IsEnabled = false; + ApplyDefaultToAllButton.IsEnabled = false; + } + + PopulateScopeCombos(); + + try + { + _allOverrides = await _dataService.GetCollectorSchedulesAsync(); + } + catch (Exception ex) + { + _allOverrides = new List<CollectorScheduleRow>(); + StatusText.Text = $"Could not read the current schedules: {ex.Message}"; + } + + /* Default to the fleet scope (index 0). */ + _suppressScopeReload = true; + ScopeCombo.SelectedIndex = 0; + _suppressScopeReload = false; + LoadScopeSchedule(); + } + + private void PopulateScopeCombos() + { + ScopeCombo.Items.Clear(); + ScopeCombo.Items.Add(new ComboBoxItem { Content = "All servers (default schedule)", Tag = null }); + foreach (var server in _servers) + { + ScopeCombo.Items.Add(new ComboBoxItem { Content = server.DisplayName, Tag = server.ServerId }); + } + + CopyFromServerCombo.ItemsSource = _servers; + CopyFromServerCombo.DisplayMemberPath = nameof(DarlingServer.DisplayName); + if (_servers.Count > 0) + { + CopyFromServerCombo.SelectedIndex = 0; + } + else + { + CopyFromServerCombo.IsEnabled = false; + CopyFromServerButton.IsEnabled = false; + } + } + + private void ScopeCombo_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_suppressScopeReload) + { + return; + } + + LoadScopeSchedule(); + } + + private void LoadScopeSchedule() + { + _scopeServerId = (ScopeCombo.SelectedItem as ComboBoxItem)?.Tag as int?; + + var isServerScope = _scopeServerId is not null; + UseDefaultCheckBox.Visibility = isServerScope ? Visibility.Visible : Visibility.Collapsed; + + if (isServerScope) + { + HeaderText.Text = $"Collector Schedules — {(ScopeCombo.SelectedItem as ComboBoxItem)?.Content}"; + SubHeaderText.Text = "This server's custom schedule. Uncheck 'Use default schedule' to override the fleet-wide defaults for it."; + var hasOverride = CollectorScheduleOverlay.ServerHasOverride(_allOverrides, _scopeServerId!.Value); + + _suppressPresetChange = true; + UseDefaultCheckBox.IsChecked = !hasOverride; + _suppressPresetChange = false; + } + else + { + HeaderText.Text = "Collector Schedules — All servers"; + SubHeaderText.Text = "The fleet-wide default schedule. Every server without its own override collects on this schedule."; + } + + RebuildEditingForScope(); + } + + /// <summary>Rebuilds the editable list for the current scope + use-default state and re-binds the grid.</summary> + private void RebuildEditingForScope() + { + var usesDefault = _scopeServerId is not null && UseDefaultCheckBox.IsChecked == true; + + /* Fleet scope, or a server "using default", shows the fleet-over-default effective (server rows + excluded); a customizing server shows its own effective schedule. */ + var overlayScope = usesDefault ? (int?)null : _scopeServerId; + _editing = CollectorScheduleOverlay.BuildEffectiveSchedule(_allOverrides, overlayScope); + + BindGrid(); + UpdateEditableState(!usesDefault); + DetectActivePreset(); + } + + private void BindGrid() + { + ScheduleGrid.ItemsSource = null; + ScheduleGrid.ItemsSource = _editing; + } + + private void UpdateEditableState(bool editable) + { + var writable = editable && !_dataService.IsReadOnly; + ScheduleGrid.IsReadOnly = !writable; + ScheduleGrid.Opacity = writable ? 1.0 : 0.6; + PresetCombo.IsEnabled = writable; + ResetDefaultsButton.IsEnabled = writable; + CopyFromServerCombo.IsEnabled = writable && _servers.Count > 0; + CopyFromServerButton.IsEnabled = writable && _servers.Count > 0; + + if (_dataService.IsReadOnly) + { + StatusText.Text = "Read-only connection — schedules can't be changed."; + } + else if (_scopeServerId is null) + { + StatusText.Text = "Fleet-wide default schedule. Applies to every server without its own override."; + } + else + { + StatusText.Text = writable + ? "Custom schedule. Changes apply only to this server." + : "Using the fleet-wide default (read-only). Uncheck 'Use default schedule' to customize this server."; + } + } + + private void UseDefaultCheckBox_Changed(object sender, RoutedEventArgs e) + { + if (_suppressPresetChange || _scopeServerId is null) + { + return; + } + + RebuildEditingForScope(); + } + + private void DetectActivePreset() + { + _suppressPresetChange = true; + try + { + var active = CollectorSchedulePresets.DetectPreset(_editing); + for (var i = 0; i < PresetCombo.Items.Count; i++) + { + if (PresetCombo.Items[i] is ComboBoxItem item && + string.Equals(item.Content?.ToString(), active, StringComparison.OrdinalIgnoreCase)) + { + PresetCombo.SelectedIndex = i; + return; + } + } + + PresetCombo.SelectedIndex = 0; /* Custom */ + } + finally + { + _suppressPresetChange = false; + } + } + + private void PresetCombo_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_suppressPresetChange || PresetCombo.SelectedItem is not ComboBoxItem selected) + { + return; + } + + var presetName = selected.Content?.ToString() ?? ""; + if (presetName == CollectorSchedulePresets.Custom) + { + return; + } + + var result = MessageBox.Show( + $"Apply the \"{presetName}\" preset?\n\nThis changes all collection frequencies. Enabled/disabled state and retention are not affected.", + "Apply Collection Preset", MessageBoxButton.YesNo, MessageBoxImage.Question); + + if (result != MessageBoxResult.Yes) + { + DetectActivePreset(); + return; + } + + CollectorSchedulePresets.ApplyPreset(_editing, presetName); + BindGrid(); + DetectActivePreset(); + } + + private void ResetDefaults_Click(object sender, RoutedEventArgs e) + { + var result = MessageBox.Show( + "Replace the current values with the built-in default frequencies and retention?", + "Reset to Defaults", MessageBoxButton.YesNo, MessageBoxImage.Question); + + if (result != MessageBoxResult.Yes) + { + return; + } + + _editing = CollectorSchedulePresets.BuildDefaultSchedule(); + BindGrid(); + DetectActivePreset(); + } + + private void CopyFromServer_Click(object sender, RoutedEventArgs e) + { + if (CopyFromServerCombo.SelectedItem is not DarlingServer source) + { + return; + } + + var result = MessageBox.Show( + $"Replace the current values with a copy of {source.DisplayName}'s effective schedule?", + "Copy from Server", MessageBoxButton.YesNo, MessageBoxImage.Question); + + if (result != MessageBoxResult.Yes) + { + return; + } + + _editing = CollectorScheduleOverlay.BuildEffectiveSchedule(_allOverrides, source.ServerId); + BindGrid(); + DetectActivePreset(); + } + + private async void SaveButton_Click(object sender, RoutedEventArgs e) + { + /* Flush any in-progress cell edit into the bound items before we read them. */ + ScheduleGrid.CommitEdit(DataGridEditingUnit.Row, true); + + var usesDefault = _scopeServerId is not null && UseDefaultCheckBox.IsChecked == true; + + if (!usesDefault && !ValidateSchedule(out var error)) + { + MessageBox.Show(error, "Collector Schedules", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + SaveButton.IsEnabled = false; + try + { + if (_scopeServerId is int serverId) + { + var rows = usesDefault + ? new List<CollectorScheduleRow>() + : CollectorScheduleOverlay.ToServerOverrideRows(_editing, serverId); + await _dataService.ReplaceServerSchedulesAsync(serverId, rows); + } + else + { + await _dataService.ReplaceFleetSchedulesAsync(CollectorScheduleOverlay.ToFleetOverrideRows(_editing)); + } + + Saved = true; + Close(); + } + catch (ViewerReadOnlyException ex) + { + MessageBox.Show(ex.Message, "Read-only connection", MessageBoxButton.OK, MessageBoxImage.Warning); + } + catch (ViewerSchemaSkewException ex) + { + MessageBox.Show(ex.Message, "Store out of date", MessageBoxButton.OK, MessageBoxImage.Warning); + } + catch (Exception ex) + { + MessageBox.Show($"Could not save the collector schedules:\n\n{ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + SaveButton.IsEnabled = !_dataService.IsReadOnly; + } + } + + /// <summary>Enforces the V17 CHECK constraints before the write (frequency >= 0, retention >= 1) so a + /// bad value surfaces as a friendly message rather than a raw Postgres error.</summary> + private bool ValidateSchedule(out string error) + { + foreach (var item in _editing) + { + if (item.FrequencyMinutes < 0) + { + error = $"'{item.Name}': frequency (minutes) can't be negative. Use 0 to collect once on server load."; + return false; + } + + if (item.RetentionDays < 1) + { + error = $"'{item.Name}': retention (days) must be at least 1."; + return false; + } + } + + error = ""; + return true; + } + + /// <summary> + /// "Apply Default to All Servers" — the fleet-scale bulk reset: removes EVERY server's per-server schedule + /// override in one write (<see cref="ViewerDataService.ResetAllServerSchedulesAsync"/>) so they all fall back + /// to the fleet-wide default, the shortcut over reverting each server one at a time. The fleet-wide default + /// itself is untouched. Confirmed first (it clears every server's customization), then the editor re-reads so + /// the current scope reflects the reset. + /// </summary> + private async void ApplyDefaultToAll_Click(object sender, RoutedEventArgs e) + { + if (_dataService.IsReadOnly) + { + return; + } + + var result = MessageBox.Show( + "Reset EVERY server to the fleet-wide default schedule?\n\n" + + "This removes all per-server schedule overrides; the fleet-wide default schedule is not changed. " + + "This can't be undone.", + "Apply Default to All Servers", MessageBoxButton.YesNo, MessageBoxImage.Warning); + + if (result != MessageBoxResult.Yes) + { + return; + } + + ApplyDefaultToAllButton.IsEnabled = false; + try + { + var removed = await _dataService.ResetAllServerSchedulesAsync(); + Saved = true; + + /* Re-read the overrides so the editor reflects the reset (every per-server row is now gone) and + reload the current scope's grid + preset detection. */ + _allOverrides = await _dataService.GetCollectorSchedulesAsync(); + LoadScopeSchedule(); + + StatusText.Text = removed > 0 + ? $"Reset {removed} per-server schedule override(s) — every server now follows the fleet default." + : "No per-server overrides to reset — every server already follows the fleet default."; + } + catch (ViewerReadOnlyException ex) + { + MessageBox.Show(ex.Message, "Read-only connection", MessageBoxButton.OK, MessageBoxImage.Warning); + } + catch (ViewerSchemaSkewException ex) + { + MessageBox.Show(ex.Message, "Store out of date", MessageBoxButton.OK, MessageBoxImage.Warning); + } + catch (Exception ex) + { + MessageBox.Show($"Could not reset the schedules:\n\n{ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + ApplyDefaultToAllButton.IsEnabled = !_dataService.IsReadOnly; + } + } + + private void CancelButton_Click(object sender, RoutedEventArgs e) => Close(); +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/CollectorScheduleOverlay.cs b/Darling/PerformanceMonitor.Darling.Viewer/CollectorScheduleOverlay.cs new file mode 100644 index 000000000..3f95ebb50 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/CollectorScheduleOverlay.cs @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using PerformanceMonitor.Collectors; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// Pure store↔editor resolution for the Collector Schedule editor — builds the EFFECTIVE editable schedule +/// the grid shows (the same per-column layering the service's +/// <c>StoreConfigProvider.ResolveSchedule</c> applies: per-server override > fleet override > the shared +/// <see cref="CollectorScheduleDefaults"/> code default) and turns an edited schedule back into the sparse +/// override rows to persist. No WPF or I/O, so Darling.Tests exercise the round-trip without a live store. +/// </summary> +public static class CollectorScheduleOverlay +{ + /// <summary> + /// The effective editable schedule for a scope: start from the code defaults, apply the fleet overrides + /// (<c>server_id</c> NULL), then — when <paramref name="serverId"/> is a server — apply that server's + /// overrides on top (each override's non-null column wins; a NULL column falls through). Editing the fleet + /// scope (<paramref name="serverId"/> null) shows code-default-over-fleet; editing a server shows the full + /// effective schedule it currently collects on. + /// </summary> + public static List<CollectorScheduleEditItem> BuildEffectiveSchedule( + IReadOnlyList<CollectorScheduleRow> allOverrides, int? serverId) + { + ArgumentNullException.ThrowIfNull(allOverrides); + + var schedule = CollectorSchedulePresets.BuildDefaultSchedule(); + + ApplyScope(schedule, allOverrides.Where(o => o.ServerId is null)); + if (serverId is int sid) + { + ApplyScope(schedule, allOverrides.Where(o => o.ServerId == sid)); + } + + return schedule; + } + + private static void ApplyScope(List<CollectorScheduleEditItem> schedule, IEnumerable<CollectorScheduleRow> scopeRows) + { + foreach (var row in scopeRows) + { + var item = schedule.FirstOrDefault(s => s.Name.Equals(row.CollectorName, StringComparison.OrdinalIgnoreCase)); + if (item is null) + { + continue; /* An override for a collector this build no longer defines — ignore it. */ + } + + if (row.FrequencyMinutes is int freq) + { + item.FrequencyMinutes = freq; + } + + if (row.RetentionDays is int retention) + { + item.RetentionDays = retention; + } + + item.Enabled = row.Enabled; + } + } + + /// <summary>True when the store holds any override row for this server (the editor's "custom vs. use + /// default" initial state).</summary> + public static bool ServerHasOverride(IReadOnlyList<CollectorScheduleRow> allOverrides, int serverId) + { + ArgumentNullException.ThrowIfNull(allOverrides); + return allOverrides.Any(o => o.ServerId == serverId); + } + + /// <summary> + /// The SPARSE fleet override rows to persist: one explicit row per collector that differs from its code + /// default (frequency, retention, or disabled) — collectors matching the default emit no row, so the fleet + /// scope stays sparse and un-set collectors fall through to the code default. + /// </summary> + public static List<CollectorScheduleRow> ToFleetOverrideRows(IReadOnlyList<CollectorScheduleEditItem> edited) + { + ArgumentNullException.ThrowIfNull(edited); + + var rows = new List<CollectorScheduleRow>(); + foreach (var item in edited) + { + if (!CollectorScheduleDefaults.All.TryGetValue(item.Name, out var def)) + { + continue; /* Not a known collector — never persist an override for it. */ + } + + if (item.FrequencyMinutes == def.FrequencyMinutes && item.RetentionDays == def.RetentionDays && item.Enabled) + { + continue; /* Matches the code default — no override row (keeps the table sparse). */ + } + + rows.Add(new CollectorScheduleRow(null, item.Name, item.FrequencyMinutes, item.RetentionDays, item.Enabled)); + } + + return rows; + } + + /// <summary> + /// The full PER-SERVER override rows to persist: one explicit row per known collector (a full snapshot, + /// matching Lite's per-server schedule model) so the server collects on EXACTLY the shown schedule + /// regardless of the fleet layer — WYSIWYG. Used only when the server is "custom" (not using defaults). + /// </summary> + public static List<CollectorScheduleRow> ToServerOverrideRows(IReadOnlyList<CollectorScheduleEditItem> edited, int serverId) + { + ArgumentNullException.ThrowIfNull(edited); + + return edited + .Where(item => CollectorScheduleDefaults.All.ContainsKey(item.Name)) + .Select(item => new CollectorScheduleRow(serverId, item.Name, item.FrequencyMinutes, item.RetentionDays, item.Enabled)) + .ToList(); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/CollectorSchedulePresets.cs b/Darling/PerformanceMonitor.Darling.Viewer/CollectorSchedulePresets.cs new file mode 100644 index 000000000..26d895cd1 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/CollectorSchedulePresets.cs @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using PerformanceMonitor.Collectors; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// One editable collector row in the viewer's Collector Schedule editor — the effective (override-resolved) +/// schedule for a collector, mutated in place by the grid and the presets. Plain mutable class (matching +/// Lite's <c>CollectorSchedule</c>) so the WPF DataGrid two-way-binds its cells; the editor re-binds the grid +/// after a preset apply. <see cref="FrequencyMinutes"/> 0 = collect once on server load only. +/// </summary> +public sealed class CollectorScheduleEditItem +{ + public string Name { get; set; } = ""; + public string Description { get; set; } = ""; + public bool Enabled { get; set; } = true; + public int FrequencyMinutes { get; set; } + public int RetentionDays { get; set; } +} + +/// <summary> +/// The viewer's port of Lite's collection-cadence presets (<c>ScheduleManager.s_presets</c> + +/// <c>DetectPreset</c>/<c>ApplyPreset</c>) — the SINGLE source of preset logic for the Darling schedule +/// editor. The three tables (Aggressive / Balanced / Low-Impact) are duplicated byte-for-byte from Lite's +/// (the projects don't share a schedule library); a Darling.Tests pin asserts they cannot drift from Lite's +/// intervals. A preset changes FREQUENCIES ONLY — enabled and retention are untouched — exactly as Lite's. +/// </summary> +public static class CollectorSchedulePresets +{ + public const string Custom = "Custom"; + + /// <summary>The preset names offered in the editor combo (plus the <see cref="Custom"/> sentinel).</summary> + public static readonly string[] Names = { "Aggressive", "Balanced", "Low-Impact" }; + + /// <summary>Per-preset collector→frequency (minutes) tables — duplicated from Lite's ScheduleManager.s_presets.</summary> + public static readonly IReadOnlyDictionary<string, IReadOnlyDictionary<string, int>> Presets = + new Dictionary<string, IReadOnlyDictionary<string, int>>(StringComparer.OrdinalIgnoreCase) + { + ["Aggressive"] = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase) + { + ["wait_stats"] = 1, ["latch_stats"] = 1, ["spinlock_stats"] = 1, + ["cpu_scheduler_stats"] = 1, ["plan_cache_stats"] = 2, + ["query_stats"] = 1, ["procedure_stats"] = 1, + ["query_store"] = 2, ["query_snapshots"] = 1, ["cpu_utilization"] = 1, + ["file_io_stats"] = 1, ["memory_stats"] = 1, ["memory_clerks"] = 2, + ["memory_pressure_events"] = 5, + ["tempdb_stats"] = 1, ["perfmon_stats"] = 1, ["deadlocks"] = 1, + ["memory_grant_stats"] = 1, ["waiting_tasks"] = 1, + ["dmv_blocking_snapshot"] = 1, + ["blocked_process_report"] = 1, ["running_jobs"] = 2, + ["session_summary_stats"] = 2, ["system_health_events"] = 2, + ["default_trace_events"] = 2, ["job_history"] = 2, ["agent_status"] = 2, + }, + ["Balanced"] = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase) + { + ["wait_stats"] = 1, ["latch_stats"] = 1, ["spinlock_stats"] = 1, + ["cpu_scheduler_stats"] = 1, ["plan_cache_stats"] = 5, + ["query_stats"] = 1, ["procedure_stats"] = 1, + ["query_store"] = 5, ["query_snapshots"] = 1, ["cpu_utilization"] = 1, + ["file_io_stats"] = 1, ["memory_stats"] = 1, ["memory_clerks"] = 5, + ["memory_pressure_events"] = 5, + ["tempdb_stats"] = 1, ["perfmon_stats"] = 1, ["deadlocks"] = 1, + ["memory_grant_stats"] = 1, ["waiting_tasks"] = 1, + ["dmv_blocking_snapshot"] = 1, + ["blocked_process_report"] = 1, ["running_jobs"] = 5, + ["session_summary_stats"] = 5, ["system_health_events"] = 5, + ["default_trace_events"] = 5, ["job_history"] = 5, ["agent_status"] = 5, + }, + ["Low-Impact"] = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase) + { + ["wait_stats"] = 5, ["latch_stats"] = 5, ["spinlock_stats"] = 5, + ["cpu_scheduler_stats"] = 5, ["plan_cache_stats"] = 15, + ["query_stats"] = 10, ["procedure_stats"] = 10, + ["query_store"] = 30, ["query_snapshots"] = 5, ["cpu_utilization"] = 5, + ["file_io_stats"] = 10, ["memory_stats"] = 10, ["memory_clerks"] = 30, + ["memory_pressure_events"] = 15, + ["tempdb_stats"] = 5, ["perfmon_stats"] = 5, ["deadlocks"] = 5, + ["memory_grant_stats"] = 5, ["waiting_tasks"] = 5, + ["dmv_blocking_snapshot"] = 5, + ["blocked_process_report"] = 5, ["running_jobs"] = 30, + ["session_summary_stats"] = 15, ["system_health_events"] = 15, + ["default_trace_events"] = 15, ["job_history"] = 15, ["agent_status"] = 15, + }, + }; + + /// <summary> + /// The full editable schedule seeded from the shared <see cref="CollectorScheduleDefaults"/> (every + /// collector, at its code-default frequency/retention, enabled) — the baseline the editor overlays store + /// overrides onto. Ordered by collector name for a stable grid. + /// </summary> + public static List<CollectorScheduleEditItem> BuildDefaultSchedule() => + CollectorScheduleDefaults.All + .OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase) + .Select(kv => new CollectorScheduleEditItem + { + Name = kv.Key, + Enabled = true, + FrequencyMinutes = kv.Value.FrequencyMinutes, + RetentionDays = kv.Value.RetentionDays, + }) + .ToList(); + + /// <summary>Detects which preset a schedule's frequencies match, or <see cref="Custom"/> (mirrors + /// Lite's <c>ScheduleManager.DetectPreset</c> — frequency-only comparison).</summary> + public static string DetectPreset(IReadOnlyList<CollectorScheduleEditItem> schedules) + { + ArgumentNullException.ThrowIfNull(schedules); + + foreach (var (presetName, intervals) in Presets) + { + var matches = true; + foreach (var (collector, freq) in intervals) + { + var schedule = schedules.FirstOrDefault(s => s.Name.Equals(collector, StringComparison.OrdinalIgnoreCase)); + if (schedule is not null && schedule.FrequencyMinutes != freq) + { + matches = false; + break; + } + } + + if (matches) + { + return presetName; + } + } + + return Custom; + } + + /// <summary>Applies a named preset's frequencies to a schedule list IN PLACE (enabled + retention + /// untouched); an unknown name is a no-op (mirrors Lite's <c>ScheduleManager.ApplyPreset</c>).</summary> + public static void ApplyPreset(IReadOnlyList<CollectorScheduleEditItem> schedules, string presetName) + { + ArgumentNullException.ThrowIfNull(schedules); + + if (!Presets.TryGetValue(presetName, out var intervals)) + { + return; + } + + foreach (var (collector, freq) in intervals) + { + var schedule = schedules.FirstOrDefault(s => s.Name.Equals(collector, StringComparison.OrdinalIgnoreCase)); + if (schedule is not null) + { + schedule.FrequencyMinutes = freq; + } + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/CorrelatedTimelineLanesControl.xaml b/Darling/PerformanceMonitor.Darling.Viewer/CorrelatedTimelineLanesControl.xaml new file mode 100644 index 000000000..3ea91244e --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/CorrelatedTimelineLanesControl.xaml @@ -0,0 +1,80 @@ +<UserControl x:Class="PerformanceMonitor.Darling.Viewer.CorrelatedTimelineLanesControl" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:ScottPlot="clr-namespace:ScottPlot.WPF;assembly=ScottPlot.WPF"> + + <Grid Margin="8"> + <Grid.RowDefinitions> + <!-- 5 lanes, equal height --> + <RowDefinition Height="*"/> + <RowDefinition Height="*"/> + <RowDefinition Height="*"/> + <RowDefinition Height="*"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + + <!-- Lane 1: CPU --> + <Grid Grid.Row="0" Margin="0,0,0,2"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="100"/> + <ColumnDefinition Width="*"/> + </Grid.ColumnDefinitions> + <TextBlock Grid.Column="0" Text="CPU %" VerticalAlignment="Center" + FontSize="12" FontWeight="SemiBold" + Foreground="{DynamicResource ForegroundBrush}" Margin="4,0"/> + <ScottPlot:WpfPlot Grid.Column="1" x:Name="CpuChart"/> + </Grid> + + <!-- Lane 2: Wait Stats --> + <Grid Grid.Row="1" Margin="0,2,0,2"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="100"/> + <ColumnDefinition Width="*"/> + </Grid.ColumnDefinitions> + <TextBlock Grid.Column="0" Text="Wait ms/sec" VerticalAlignment="Center" + FontSize="12" FontWeight="SemiBold" + Foreground="{DynamicResource ForegroundBrush}" Margin="4,0"/> + <ScottPlot:WpfPlot Grid.Column="1" x:Name="WaitStatsChart"/> + </Grid> + + <!-- Lane 3: Blocking --> + <Grid Grid.Row="2" Margin="0,2,0,2"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="100"/> + <ColumnDefinition Width="*"/> + </Grid.ColumnDefinitions> + <TextBlock Grid.Column="0" VerticalAlignment="Center" + FontSize="12" FontWeight="SemiBold" + Foreground="{DynamicResource ForegroundBrush}" Margin="4,0"> + <Run Text="Blocking &"/> + <LineBreak/> + <Run Text="Deadlocking"/> + </TextBlock> + <ScottPlot:WpfPlot Grid.Column="1" x:Name="BlockingChart"/> + </Grid> + + <!-- Lane 4: Buffer Pool --> + <Grid Grid.Row="3" Margin="0,2,0,2"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="100"/> + <ColumnDefinition Width="*"/> + </Grid.ColumnDefinitions> + <TextBlock Grid.Column="0" Text="Buffer Pool MB" VerticalAlignment="Center" + FontSize="12" FontWeight="SemiBold" + Foreground="{DynamicResource ForegroundBrush}" Margin="4,0"/> + <ScottPlot:WpfPlot Grid.Column="1" x:Name="MemoryChart"/> + </Grid> + + <!-- Lane 5: File I/O --> + <Grid Grid.Row="4" Margin="0,2,0,0"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="100"/> + <ColumnDefinition Width="*"/> + </Grid.ColumnDefinitions> + <TextBlock Grid.Column="0" Text="I/O Latency" VerticalAlignment="Center" + FontSize="12" FontWeight="SemiBold" + Foreground="{DynamicResource ForegroundBrush}" Margin="4,0"/> + <ScottPlot:WpfPlot Grid.Column="1" x:Name="FileIoChart"/> + </Grid> + </Grid> +</UserControl> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/CorrelatedTimelineLanesControl.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/CorrelatedTimelineLanesControl.xaml.cs new file mode 100644 index 000000000..ecaca5595 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/CorrelatedTimelineLanesControl.xaml.cs @@ -0,0 +1,637 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + * + * Darling Viewer W1d: a COPY of Lite's Overview control (Lite/Controls/CorrelatedTimelineLanesControl) + * with only the data layer rewired to Postgres — the five correlated lanes (CPU, total wait ms/sec, + * blocking + deadlocking, buffer pool MB, file-I/O latency) with baseline bands + anomaly markers, over + * the shared PerformanceMonitor.Ui CorrelatedCrosshairManager and ChartStyle chrome. The render bodies + * (UpdateCpuLane / UpdateBlockingLane / UpdateLane / ShowEmpty / SyncXAxes / theme helpers) are Lite's, + * byte-for-byte. Rewires from Lite: + * - LocalDataService (DuckDB) -> ViewerDataService (Npgsql); reads take explicit naive-UTC windows + * (serverId, startUtc[, endUtc]) instead of Lite's (serverId, hoursBack, fromDate, toDate), and run + * genuinely async (no Task.Run wrap — Npgsql is async), matching the W1a/W1c viewer ports. + * - PerformanceMonitorLite.Analysis BaselineBucket + MetricNames -> the structurally-identical + * PerformanceMonitor.Darling.Analysis twins (PgBaselineProvider.cs). + * - AppLogger -> Debug.WriteLine. + * - Time axis: every lane plots collection_time (naive-UTC) through ViewerTimeHelper.ForDisplay, the + * viewer's mode-aware Server/Local/UTC conversion (Server mode adds the collected + * server_properties.utc_offset_minutes, matching Lite's per-server ServerTimeHelper shift). + * The CPU lane's sample_time is the monitored server's LOCAL wall clock, so GetCpuUtilizationAsync + * de-skews it to naive UTC in SQL before it too goes through ForDisplay (#1262: per-batch offset = + * round(MAX(sample_time) over the batch - collection_time) to 15 min, recovered from the batch — a + * data correction independent of the display mode). The CPU lane therefore aligns with the + * collection_time lanes on any server timezone; a UTC server is unchanged. + * Deliberately dropped for v1 (noted in the PR): Lite's comparison-range ghost-line overlay (the whole + * comparisonRange block, AddGhostLine, ComparisonLabel). The "Show Active Queries at This Time" + * drill-down event is wired by the host ServerTab — ViewerServerTab.xaml.cs subscribes + * ShowActiveQueriesRequested to OnActiveQueriesDrillDown, which navigates to the Active Queries surface. + */ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using PerformanceMonitor.Analysis.Baselines; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Analysis; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +public partial class CorrelatedTimelineLanesControl : UserControl +{ + private ViewerDataService? _dataService; + private int _serverId; + private CorrelatedCrosshairManager? _crosshairManager; + private bool _isRefreshing; + + public CorrelatedTimelineLanesControl() + { + InitializeComponent(); + /* No Unloaded → Dispose() handler: WPF fires Unloaded for transient + reasons (tab virtualization, layout rebuilds) and Dispose() clears + the crosshair manager's lane list, permanently breaking the crosshair + until the ServerTab is rebuilt. The manager holds only managed state + (a Popup + lane references) — letting GC clean it up with the control + is fine. */ + } + + /// <summary> + /// Initializes the control with the data service and server ID. + /// Must be called before RefreshAsync. + /// </summary> + public void Initialize(ViewerDataService dataService, int serverId) + { + _dataService = dataService; + _serverId = serverId; + + var charts = new[] { CpuChart, WaitStatsChart, BlockingChart, MemoryChart, FileIoChart }; + foreach (var chart in charts) + { + ApplyTheme(chart); + // Disable zoom/pan/drag but keep mouse events for crosshair + chart.UserInputProcessor.UserActionResponses.Clear(); + SetupLaneDrillDown(chart); + } + + _crosshairManager = new CorrelatedCrosshairManager(); + _crosshairManager.AddLane(CpuChart, "SQL CPU", "%"); + _crosshairManager.AddLane(WaitStatsChart, "Wait Stats", "ms/sec"); + _crosshairManager.AddLane(BlockingChart, "Blocking", "events"); + _crosshairManager.AddLane(MemoryChart, "Buffer Pool", "MB"); + _crosshairManager.AddLane(FileIoChart, "I/O Latency", "ms"); + } + + /// <summary> + /// Raised when the user picks "Show Active Queries at This Time" on a lane. The argument is the + /// clicked time in the lanes' (viewer-local) X-axis space. The host ServerTab subscribes this + /// (ViewerServerTab.xaml.cs) to OnActiveQueriesDrillDown, which navigates to the Active Queries surface. + /// </summary> + public event Action<DateTime>? ShowActiveQueriesRequested; + + /// <summary> + /// Adds a minimal right-click menu (just the Active Queries drill-down) to a lane. The lanes are a + /// stripped-chrome view with pan/zoom disabled, so the clicked time is read straight from the X axis. + /// </summary> + private void SetupLaneDrillDown(ScottPlot.WPF.WpfPlot chart) + { + var menu = new ContextMenu(); + var item = new MenuItem { Header = "Show Active Queries at This Time" }; + menu.Items.Add(item); + + menu.Opened += (s, _) => + { + try + { + var pos = System.Windows.Input.Mouse.GetPosition(chart); + var dpi = System.Windows.Media.VisualTreeHelper.GetDpi(chart); + var pixel = new ScottPlot.Pixel((float)(pos.X * dpi.DpiScaleX), (float)(pos.Y * dpi.DpiScaleY)); + var t = DateTime.FromOADate(chart.Plot.GetCoordinates(pixel).X); + // Empty-state lanes set the X axis to [-1, 1] (~year 1899); only offer the drill-down + // when the click resolves to a real timestamp. + bool valid = t.Year >= 2000; + item.Tag = valid ? t : (DateTime?)null; + item.IsEnabled = valid; + } + catch + { + item.Tag = null; + item.IsEnabled = false; + } + }; + + item.Click += (s, _) => + { + if (item.Tag is DateTime t) + ShowActiveQueriesRequested?.Invoke(t); + }; + + chart.PreviewMouseRightButtonDown += (s, e) => + { + e.Handled = true; + menu.PlacementTarget = chart; + menu.Placement = System.Windows.Controls.Primitives.PlacementMode.MousePoint; + menu.IsOpen = true; + }; + } + + /// <summary> + /// Refreshes all lane data for the given time range. The viewer's Overview always passes a fixed + /// 24-hour window (hoursBack = 24, fromDate/toDate null); the fromDate/toDate branch is kept for a + /// future custom-range picker. Reads run against explicit naive-UTC windows derived here. + /// </summary> + public async Task RefreshAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + if (_dataService == null || _isRefreshing) return; + _isRefreshing = true; + + try + { + _crosshairManager?.PrepareForRefresh(); + + /* Explicit naive-UTC window bounds for the viewer's (serverId, startUtc, endUtc) reads. */ + var startUtc = fromDate ?? DateTime.UtcNow.AddHours(-hoursBack); + var endUtc = toDate ?? DateTime.UtcNow; + + /* Reads run concurrently — NpgsqlDataSource pools a connection for each; genuinely async so + no Task.Run wrap (matches the W1a/W1c viewer ports). CPU reuses W1a's raw-sample read; + blocking/deadlock/file-IO reuse W1c's trend reads; total-wait + memory are new (W1d). */ + var cpuTask = _dataService.GetCpuUtilizationAsync(_serverId, startUtc); + var waitTask = _dataService.GetTotalWaitTrendAsync(_serverId, startUtc, endUtc); + var blockingTask = _dataService.GetBlockingTrendAsync(_serverId, startUtc, endUtc); + var deadlockTask = _dataService.GetDeadlockTrendAsync(_serverId, startUtc, endUtc); + var memoryTask = _dataService.GetMemoryTrendAsync(_serverId, startUtc, endUtc); + var fileIoTask = _dataService.GetFileIoLatencyTrendAsync(_serverId, startUtc, endUtc); + + // Fetch baselines for band rendering — chart-unit-matched metrics. referenceTime stays UTC + // (the baseline buckets key on hour-of-day / day-of-week of the naive-UTC store timestamps). + var referenceTime = fromDate ?? DateTime.UtcNow.AddHours(-hoursBack); + var cpuBaselineTask = _dataService.GetBaselineForLaneAsync(_serverId, MetricNames.Cpu, referenceTime); + var waitBaselineTask = _dataService.GetBaselineForLaneAsync(_serverId, MetricNames.WaitMsPerSec, referenceTime); + var ioBaselineTask = _dataService.GetBaselineForLaneAsync(_serverId, MetricNames.IoLatency, referenceTime); + var blockingBaselineTask = _dataService.GetBaselineForLaneAsync(_serverId, MetricNames.BlockingPerMinute, referenceTime); + + try + { + await Task.WhenAll(cpuTask, waitTask, blockingTask, deadlockTask, memoryTask, fileIoTask, + cpuBaselineTask, waitBaselineTask, ioBaselineTask, blockingBaselineTask); + } + catch (Exception ex) + { + Debug.WriteLine($"CorrelatedLanes data fetch failed: {ex.Message}"); + } + + var cpuBaseline = cpuBaselineTask.IsCompletedSuccessfully ? cpuBaselineTask.Result : null; + var waitBaseline = waitBaselineTask.IsCompletedSuccessfully ? waitBaselineTask.Result : null; + var ioBaseline = ioBaselineTask.IsCompletedSuccessfully ? ioBaselineTask.Result : null; + var blockingBaseline = blockingBaselineTask.IsCompletedSuccessfully ? blockingBaselineTask.Result : null; + + // minAnomalyValue: absolute floor below which dots/arrows are suppressed even if outside band. + // Prevents "1% CPU above 0.5% baseline" false alarms on idle servers. + if (cpuTask.IsCompletedSuccessfully) + { + /* Total non-idle CPU = SQL + other-process (Lite's CpuUtilizationData.TotalCpu). The + viewer's raw read exposes the two components; sum them here for the Total series. */ + var sqlSeries = cpuTask.Result.Select(d => (ViewerTimeHelper.ForDisplay(d.SampleTime).ToOADate(), (double)d.SqlServerCpu)).ToList(); + var totalSeries = cpuTask.Result.Select(d => (ViewerTimeHelper.ForDisplay(d.SampleTime).ToOADate(), (double)(d.SqlServerCpu + d.OtherProcessCpu))).ToList(); + UpdateCpuLane(sqlSeries, totalSeries, cpuBaseline); + } + else + ShowEmpty(CpuChart, "CPU %"); + + if (waitTask.IsCompletedSuccessfully) + UpdateLane(WaitStatsChart, "Wait ms/sec", + waitTask.Result.Select(d => (ViewerTimeHelper.ForDisplay(d.CollectionTime).ToOADate(), d.WaitTimeMsPerSecond)).ToList(), + "#FFB74D", baseline: waitBaseline, minAnomalyValue: 100); + else + ShowEmpty(WaitStatsChart, "Wait ms/sec"); + + { + var blockingData = blockingTask.IsCompletedSuccessfully + ? blockingTask.Result.Select(d => (ViewerTimeHelper.ForDisplay(d.Time).ToOADate(), (double)d.Count)).ToList() + : new List<(double, double)>(); + var deadlockData = deadlockTask.IsCompletedSuccessfully + ? deadlockTask.Result.Select(d => (ViewerTimeHelper.ForDisplay(d.Time).ToOADate(), (double)d.Count)).ToList() + : new List<(double, double)>(); + UpdateBlockingLane(blockingData, deadlockData, blockingBaseline); + } + + if (memoryTask.IsCompletedSuccessfully) + UpdateLane(MemoryChart, "Buffer Pool MB", + memoryTask.Result.Select(d => (ViewerTimeHelper.ForDisplay(d.CollectionTime).ToOADate(), d.BufferPoolMb)).ToList(), + "#CE93D8"); + else + ShowEmpty(MemoryChart, "Memory MB"); + + if (fileIoTask.IsCompletedSuccessfully) + { + var ioGrouped = fileIoTask.Result + .GroupBy(d => d.CollectionTime) + .OrderBy(g => g.Key) + .Select(g => (ViewerTimeHelper.ForDisplay(g.Key).ToOADate(), g.Average(x => x.AvgReadLatencyMs))) + .ToList(); + UpdateLane(FileIoChart, "I/O ms", ioGrouped, "#81C784", baseline: ioBaseline, minAnomalyValue: 2); + } + else + ShowEmpty(FileIoChart, "I/O ms"); + + /* VLines must be re-attached before SyncXAxes so they're part of + the render set when the chart refreshes. */ + _crosshairManager?.ReattachVLines(); + SyncXAxes(hoursBack, fromDate, toDate); + } + finally + { + /* Safety net: if something threw between PrepareForRefresh() and the + ReattachVLines() call above, VLines are still null. EnsureVLinesAttached + creates them only for lanes where VLine is null, so it's idempotent. */ + _crosshairManager?.EnsureVLinesAttached(); + _isRefreshing = false; + } + } + + private void UpdateBlockingLane(List<(double Time, double Value)> blockingData, + List<(double Time, double Value)> deadlockData, BaselineBucket? baseline = null) + { + ClearChart(BlockingChart); + ApplyTheme(BlockingChart); + + // Register blocking and deadlock as separate named series for the tooltip + var blockTimes = blockingData.Select(d => d.Time).ToArray(); + var blockValues = blockingData.Select(d => d.Value).ToArray(); + var deadTimes = deadlockData.Select(d => d.Time).ToArray(); + var deadValues = deadlockData.Select(d => d.Value).ToArray(); + + // First series clears any previous data + _crosshairManager?.SetLaneData(BlockingChart, blockTimes, blockValues, isEventBased: true); + // Rename the auto-created series and add the second + _crosshairManager?.AddLaneSeries(BlockingChart, "Deadlocks", "events", + deadTimes, deadValues, isEventBased: true); + + if (blockingData.Count == 0 && deadlockData.Count == 0) + { + ShowEmpty(BlockingChart, "Block/Dead"); + return; + } + + double barWidth = 30.0 / 86400.0; + double maxCount = 0; + + // Blocking bars — red + if (blockingData.Count > 0) + { + var bars = blockingData.Select(d => new ScottPlot.Bar + { + Position = d.Time, + Value = d.Value, + Size = barWidth, + FillColor = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("Blocking")), + LineWidth = 0 + }).ToArray(); + BlockingChart.Plot.Add.Bars(bars); + maxCount = Math.Max(maxCount, blockingData.Max(d => d.Value)); + } + + // Deadlock bars — slightly narrower so both are visible over the blocking bars + if (deadlockData.Count > 0) + { + var bars = deadlockData.Select(d => new ScottPlot.Bar + { + Position = d.Time, + Value = d.Value, + Size = barWidth * 0.6, + FillColor = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("Deadlocks")), + LineWidth = 0 + }).ToArray(); + BlockingChart.Plot.Add.Bars(bars); + maxCount = Math.Max(maxCount, deadlockData.Max(d => d.Value)); + } + + // Baseline band for blocking + if (baseline != null && baseline.SampleCount > 0 && baseline.EffectiveStdDev > 0) + { + var upper = baseline.Mean + 2 * baseline.EffectiveStdDev; + var lower = Math.Max(0, baseline.Mean - 2 * baseline.EffectiveStdDev); + + _crosshairManager?.SetLaneBaseline(BlockingChart, lower, upper, isEventBased: true); + + var band = BlockingChart.Plot.Add.HorizontalSpan(lower, upper); + band.FillStyle.Color = ScottPlot.Color.FromHex(ChartPalette.AccentColor("BaselineBlocking")).WithAlpha(25); + band.LineStyle.Width = 0; + + var meanLine = BlockingChart.Plot.Add.HorizontalLine(baseline.Mean); + meanLine.Color = ScottPlot.Color.FromHex(ChartPalette.AccentColor("BaselineBlocking")).WithAlpha(60); + meanLine.LinePattern = ScottPlot.LinePattern.Dashed; + meanLine.LineWidth = 1; + } + + BlockingChart.Plot.Axes.DateTimeTicksBottomDateChange(); + BlockingChart.Plot.Axes.Bottom.TickLabelStyle.IsVisible = false; + ReapplyAxisColors(BlockingChart); + + BlockingChart.Plot.Title(""); + BlockingChart.Plot.YLabel(""); + BlockingChart.Plot.Legend.IsVisible = false; + BlockingChart.Plot.Axes.Margins(bottom: 0); + BlockingChart.Plot.Axes.SetLimitsY(0, Math.Max(maxCount * 1.3, 2)); + + BlockingChart.Refresh(); + } + + /* Two-series CPU lane: SQL CPU (blue) + Total non-idle CPU (orange). + Total is what the alert evaluates by default (see CpuAlertMode), so + plotting it directly avoids the "alert says 95% but chart shows 60%" + confusion (PM #1004). Baseline band + anomaly markers stay on SQL + because that's the metric the CPU baseline was computed from. */ + private void UpdateCpuLane( + List<(double Time, double Value)> sqlData, + List<(double Time, double Value)> totalData, + BaselineBucket? baseline = null) + { + ClearChart(CpuChart); + ApplyTheme(CpuChart); + + if (sqlData.Count == 0 && totalData.Count == 0) + { + ShowEmpty(CpuChart, "CPU %"); + return; + } + + var sqlTimes = sqlData.Select(d => d.Time).ToArray(); + var sqlValues = sqlData.Select(d => d.Value).ToArray(); + var totalTimes = totalData.Select(d => d.Time).ToArray(); + var totalValues = totalData.Select(d => d.Value).ToArray(); + + // Register SQL as the first (lane-default-named) series, Total as a named second series + _crosshairManager?.SetLaneData(CpuChart, sqlTimes, sqlValues); + _crosshairManager?.AddLaneSeries(CpuChart, "Total", "%", totalTimes, totalValues); + + // Baseline band — applies to SQL CPU (what the baseline was computed on) + if (baseline != null && baseline.SampleCount > 0 && baseline.EffectiveStdDev > 0) + { + var upper = baseline.Mean + 2 * baseline.EffectiveStdDev; + var lower = Math.Max(0, baseline.Mean - 2 * baseline.EffectiveStdDev); + + _crosshairManager?.SetLaneBaseline(CpuChart, lower, upper, 10); + + var band = CpuChart.Plot.Add.HorizontalSpan(lower, upper); + band.FillStyle.Color = ScottPlot.Color.FromHex(ChartPalette.AccentColor("BaselineCpu")).WithAlpha(25); + band.LineStyle.Width = 0; + + var meanLine = CpuChart.Plot.Add.HorizontalLine(baseline.Mean); + meanLine.Color = ScottPlot.Color.FromHex(ChartPalette.AccentColor("BaselineCpu")).WithAlpha(60); + meanLine.LinePattern = ScottPlot.LinePattern.Dashed; + meanLine.LineWidth = 1; + + // Anomaly markers on SQL series only + var anomalyIndices = new List<int>(); + for (int i = 0; i < sqlValues.Length; i++) + { + if ((sqlValues[i] > upper && sqlValues[i] >= 10) || sqlValues[i] < lower) + anomalyIndices.Add(i); + } + + if (anomalyIndices.Count > 0) + { + var anomalyTimes = anomalyIndices.Select(i => sqlTimes[i]).ToArray(); + var anomalyVals = anomalyIndices.Select(i => sqlValues[i]).ToArray(); + var anomalyScatter = CpuChart.Plot.Add.Scatter(anomalyTimes, anomalyVals); + anomalyScatter.Color = ScottPlot.Color.FromHex(ChartPalette.AccentColor("Anomaly")); + anomalyScatter.MarkerSize = 6; + anomalyScatter.MarkerShape = ScottPlot.MarkerShape.FilledCircle; + anomalyScatter.LineWidth = 0; + } + } + + if (totalValues.Length > 0) + { + var totalScatter = CpuChart.Plot.Add.Scatter(totalTimes, totalValues); + totalScatter.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("TotalCpu")); + totalScatter.MarkerSize = 0; + totalScatter.LineWidth = 1.5f; + totalScatter.LegendText = "Total"; + totalScatter.ConnectStyle = ScottPlot.ConnectStyle.Straight; + } + + if (sqlValues.Length > 0) + { + var sqlScatter = CpuChart.Plot.Add.Scatter(sqlTimes, sqlValues); + sqlScatter.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("SqlCpu")); + sqlScatter.MarkerSize = 0; + sqlScatter.LineWidth = 1.5f; + sqlScatter.LegendText = "SQL"; + sqlScatter.ConnectStyle = ScottPlot.ConnectStyle.Straight; + } + + CpuChart.Plot.Axes.DateTimeTicksBottomDateChange(); + if (CpuChart != FileIoChart) + CpuChart.Plot.Axes.Bottom.TickLabelStyle.IsVisible = false; + + ReapplyAxisColors(CpuChart); + + CpuChart.Plot.Title(""); + CpuChart.Plot.YLabel(""); + CpuChart.Plot.Legend.IsVisible = false; + CpuChart.Plot.Axes.Margins(bottom: 0); + CpuChart.Plot.Axes.SetLimitsY(0, 105); + + CpuChart.Refresh(); + } + + private void UpdateLane(ScottPlot.WPF.WpfPlot chart, string title, + List<(double Time, double Value)> data, string colorHex, + double? yMin = null, double? yMax = null, BaselineBucket? baseline = null, + double minAnomalyValue = 0) + { + ClearChart(chart); + ApplyTheme(chart); + + if (data.Count == 0) + { + ShowEmpty(chart, title); + return; + } + + var times = data.Select(d => d.Time).ToArray(); + var values = data.Select(d => d.Value).ToArray(); + + // Render baseline band FIRST (behind the data line) + if (baseline != null && baseline.SampleCount > 0 && baseline.EffectiveStdDev > 0) + { + var upper = baseline.Mean + 2 * baseline.EffectiveStdDev; + var lower = Math.Max(0, baseline.Mean - 2 * baseline.EffectiveStdDev); + + _crosshairManager?.SetLaneBaseline(chart, lower, upper, minAnomalyValue); + + var band = chart.Plot.Add.HorizontalSpan(lower, upper); + band.FillStyle.Color = ScottPlot.Color.FromHex(colorHex).WithAlpha(25); + band.LineStyle.Width = 0; + + var meanLine = chart.Plot.Add.HorizontalLine(baseline.Mean); + meanLine.Color = ScottPlot.Color.FromHex(colorHex).WithAlpha(60); + meanLine.LinePattern = ScottPlot.LinePattern.Dashed; + meanLine.LineWidth = 1; + + // Highlight anomalous points (outside ± 2σ band AND above absolute minimum) + var anomalyIndices = new List<int>(); + for (int i = 0; i < values.Length; i++) + { + if ((values[i] > upper && values[i] >= minAnomalyValue) || values[i] < lower) + anomalyIndices.Add(i); + } + + if (anomalyIndices.Count > 0) + { + var anomalyTimes = anomalyIndices.Select(i => times[i]).ToArray(); + var anomalyValues = anomalyIndices.Select(i => values[i]).ToArray(); + var anomalyScatter = chart.Plot.Add.Scatter(anomalyTimes, anomalyValues); + anomalyScatter.Color = ScottPlot.Color.FromHex(ChartPalette.AccentColor("Anomaly")); + anomalyScatter.MarkerSize = 6; + anomalyScatter.MarkerShape = ScottPlot.MarkerShape.FilledCircle; + anomalyScatter.LineWidth = 0; + } + } + + var scatter = chart.Plot.Add.Scatter(times, values); + scatter.Color = ScottPlot.Color.FromHex(colorHex); + scatter.MarkerSize = 0; + scatter.LineWidth = 1.5f; + scatter.LegendText = title; + scatter.ConnectStyle = ScottPlot.ConnectStyle.Straight; + + _crosshairManager?.SetLaneData(chart, times, values); + + chart.Plot.Axes.DateTimeTicksBottomDateChange(); + // Hide bottom tick labels on all lanes except the last (File I/O) + if (chart != FileIoChart) + chart.Plot.Axes.Bottom.TickLabelStyle.IsVisible = false; + + ReapplyAxisColors(chart); + + // Compact layout: hide Y label, minimize title, no legend + chart.Plot.Title(""); + chart.Plot.YLabel(""); + chart.Plot.Legend.IsVisible = false; + chart.Plot.Axes.Margins(bottom: 0); + + if (yMin.HasValue && yMax.HasValue) + chart.Plot.Axes.SetLimitsY(yMin.Value, yMax.Value); + else + { + var maxVal = data.Max(d => d.Value); + var minVal = data.Min(d => d.Value); + var padding = Math.Max((maxVal - minVal) * 0.1, 1); + chart.Plot.Axes.SetLimitsY(Math.Max(0, minVal - padding), maxVal + padding); + } + + chart.Refresh(); + } + + /// <summary> + /// Sets identical X-axis limits across all lanes. + /// </summary> + private void SyncXAxes(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + DateTime xStart, xEnd; + if (fromDate.HasValue && toDate.HasValue) + { + xStart = ViewerTimeHelper.ForDisplay(fromDate.Value); + xEnd = ViewerTimeHelper.ForDisplay(toDate.Value); + } + else + { + xEnd = ViewerTimeHelper.ForDisplay(DateTime.UtcNow); + xStart = xEnd.AddHours(-hoursBack); + } + + double xMin = xStart.ToOADate(); + double xMax = xEnd.ToOADate(); + + var charts = new[] { CpuChart, WaitStatsChart, BlockingChart, MemoryChart, FileIoChart }; + foreach (var chart in charts) + { + chart.Plot.Axes.SetLimitsX(xMin, xMax); + chart.Refresh(); + } + } + + private static void ClearChart(ScottPlot.WPF.WpfPlot chart) + { + chart.Reset(); + chart.Plot.Clear(); + } + + /* Render an empty lane as a live, gridded chart instead of a dead black box. This is most often + the Blocking/Deadlocking lane on a healthy server (no events = good news), so make it match the + populated lanes: keep the grid and show a 0-1 Y axis. We no longer blank the axes or add the old + "No Data" text (which SyncXAxes pushed off-screen anyway). X limits and the vertical gridlines are + set afterward by SyncXAxes so the time axis aligns with the other lanes; the left axis keeps its + default numeric ticks, so 0/1 labels and horizontal gridlines render. The title arg is retained + for call-site readability. */ + private void ShowEmpty(ScottPlot.WPF.WpfPlot chart, string title) + { + chart.Plot.Axes.DateTimeTicksBottomDateChange(); + // Only the bottom (File I/O) lane shows time labels; the upper lanes hide them (matches UpdateLane). + if (chart != FileIoChart) + chart.Plot.Axes.Bottom.TickLabelStyle.IsVisible = false; + + ReapplyAxisColors(chart); + + chart.Plot.Title(""); + chart.Plot.YLabel(""); + chart.Plot.Legend.IsVisible = false; + chart.Plot.Axes.Margins(bottom: 0); + chart.Plot.Axes.SetLimitsY(0, 1); + + chart.Refresh(); + } + + /// <summary> + /// Reapplies theme to all lane charts (call on theme change). + /// </summary> + public void ReapplyTheme() + { + var charts = new[] { CpuChart, WaitStatsChart, BlockingChart, MemoryChart, FileIoChart }; + foreach (var chart in charts) + { + ApplyTheme(chart); + chart.Refresh(); + } + } + + private static void ApplyTheme(ScottPlot.WPF.WpfPlot chart) + { + // Lanes are a deliberately stripped chrome variant (legend hidden, no font-size / first-render + // hook). Colors come from the shared ChartStyle so the per-theme hexes can't drift. + var c = ChartStyle.GetThemeColors(); + chart.Plot.FigureBackground.Color = c.FigureBackground; + chart.Plot.DataBackground.Color = c.DataBackground; + chart.Plot.Axes.Color(c.Text); + chart.Plot.Grid.MajorLineColor = c.Grid; + chart.Plot.Legend.IsVisible = false; + chart.Plot.Axes.Margins(bottom: 0); + chart.Plot.Axes.Bottom.TickLabelStyle.ForeColor = c.Text; + chart.Plot.Axes.Left.TickLabelStyle.ForeColor = c.Text; + chart.Plot.Axes.Bottom.Label.ForeColor = c.Text; + chart.Plot.Axes.Left.Label.ForeColor = c.Text; + + chart.Background = new System.Windows.Media.SolidColorBrush( + System.Windows.Media.Color.FromRgb(c.FigureBackground.R, c.FigureBackground.G, c.FigureBackground.B)); + } + + private static void ReapplyAxisColors(ScottPlot.WPF.WpfPlot chart) + { + var text = ChartStyle.GetThemeColors().Text; + chart.Plot.Axes.Bottom.TickLabelStyle.ForeColor = text; + chart.Plot.Axes.Left.TickLabelStyle.ForeColor = text; + chart.Plot.Axes.Bottom.Label.ForeColor = text; + chart.Plot.Axes.Left.Label.ForeColor = text; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/EDD.ico b/Darling/PerformanceMonitor.Darling.Viewer/EDD.ico new file mode 100644 index 000000000..a7f07aa37 Binary files /dev/null and b/Darling/PerformanceMonitor.Darling.Viewer/EDD.ico differ diff --git a/Darling/PerformanceMonitor.Darling.Viewer/EditCredentialProfileDialog.xaml b/Darling/PerformanceMonitor.Darling.Viewer/EditCredentialProfileDialog.xaml new file mode 100644 index 000000000..546289730 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/EditCredentialProfileDialog.xaml @@ -0,0 +1,77 @@ +<Window x:Class="PerformanceMonitor.Darling.Viewer.EditCredentialProfileDialog" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + Title="Credential Profile" + SizeToContent="Height" Width="440" + WindowStartupLocation="CenterOwner" + ResizeMode="NoResize" + Icon="EDD.ico" + Background="{DynamicResource BackgroundBrush}"> + <Grid Margin="16"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + + <TextBlock Grid.Row="0" Text="Credential Profile" FontSize="18" FontWeight="Bold" Margin="0,0,0,12" + Foreground="{DynamicResource ForegroundBrush}"/> + + <TextBlock Grid.Row="1" Text="Profile Name" FontWeight="Bold" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <TextBox Grid.Row="2" x:Name="NameBox" Margin="0,0,0,12"/> + + <TextBlock Grid.Row="3" Text="Authentication" FontWeight="Bold" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <StackPanel Grid.Row="4" Margin="0,0,0,12"> + <RadioButton x:Name="SqlAuthRadio" Content="SQL Server Authentication" Foreground="{DynamicResource ForegroundBrush}" + IsChecked="True" Checked="AuthMode_Changed" Margin="0,0,0,4"/> + <RadioButton x:Name="ServicePrincipalAuthRadio" Content="Azure — Service Principal" Foreground="{DynamicResource ForegroundBrush}" + Checked="AuthMode_Changed" Margin="0,0,0,4" + ToolTip="Non-interactive Azure SQL auth using an Entra app registration (client id + secret)."/> + <RadioButton x:Name="ManagedIdentityAuthRadio" Content="Azure — Managed Identity" Foreground="{DynamicResource ForegroundBrush}" + Checked="AuthMode_Changed" + ToolTip="Non-interactive Azure SQL auth using the managed identity of the Azure resource running the Darling service."/> + </StackPanel> + + <!-- SQL Auth --> + <StackPanel Grid.Row="5" x:Name="SqlCredentialsPanel" Margin="0,0,0,12"> + <TextBlock Text="Username" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <TextBox x:Name="UsernameBox" Margin="0,0,0,8"/> + <TextBlock Text="Password" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <PasswordBox x:Name="PasswordBox"/> + </StackPanel> + + <!-- Service Principal --> + <StackPanel Grid.Row="5" x:Name="ServicePrincipalPanel" Visibility="Collapsed" Margin="0,0,0,12"> + <TextBlock Text="Client (Application) ID" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <TextBox x:Name="AzureClientIdBox" Margin="0,0,0,8" + ToolTip="The Application (client) ID of the Entra app registration / service principal."/> + <TextBlock Text="Client Secret" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <PasswordBox x:Name="AzureClientSecretBox" Margin="0,0,0,8"/> + <TextBlock Text="Tenant ID (optional)" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <TextBox x:Name="AzureTenantIdBox" Margin="0,0,0,4" + ToolTip="Optional. Stored for reference; the tenant is resolved automatically from the target server."/> + </StackPanel> + + <!-- Managed Identity --> + <StackPanel Grid.Row="5" x:Name="ManagedIdentityPanel" Visibility="Collapsed" Margin="0,0,0,12"> + <TextBlock Text="User-Assigned Identity Client ID (optional)" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <TextBox x:Name="ManagedIdentityClientIdBox" Margin="0,0,0,8" + ToolTip="Leave blank to use the system-assigned managed identity. Set to a client id to use a specific user-assigned identity."/> + <TextBlock TextWrapping="Wrap" Foreground="{DynamicResource ForegroundMutedBrush}" FontSize="11" + Text="Managed Identity only works when the Darling service runs on an Azure VM / resource that has a managed identity assigned. Use Service Principal for non-interactive auth from a workstation."/> + </StackPanel> + + <TextBlock Grid.Row="6" x:Name="StatusText" Foreground="{DynamicResource ForegroundMutedBrush}" + TextWrapping="Wrap" Margin="0,0,0,8"/> + + <StackPanel Grid.Row="7" Orientation="Horizontal" HorizontalAlignment="Right"> + <Button Content="Save" Width="80" Height="30" Click="SaveButton_Click" Style="{StaticResource AccentButton}" Margin="0,0,8,0"/> + <Button Content="Cancel" Width="80" Height="30" Click="CancelButton_Click" IsCancel="True"/> + </StackPanel> + </Grid> +</Window> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/EditCredentialProfileDialog.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/EditCredentialProfileDialog.xaml.cs new file mode 100644 index 000000000..45ba7990f --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/EditCredentialProfileDialog.xaml.cs @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Windows; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// Add/Edit editor for a single <see cref="ViewerCredentialProfile"/> — the viewer port of Lite's +/// <c>EditCredentialProfileDialog</c>. Offers only the three profile-eligible auth types +/// (SqlServer / ServicePrincipal / ManagedIdentity). Secrets are entered into a PasswordBox and stored +/// only in Windows Credential Manager via <see cref="ViewerProfileStore"/> — never in viewer-profiles.json. +/// </summary> +public partial class EditCredentialProfileDialog : Window +{ + private readonly ViewerProfileStore _profileStore; + private readonly ViewerCredentialProfile? _existing; + + /// <summary>The saved/updated profile, or null if cancelled.</summary> + public ViewerCredentialProfile? SavedProfile { get; private set; } + + public EditCredentialProfileDialog(ViewerProfileStore profileStore, ViewerCredentialProfile? existing = null) + { + InitializeComponent(); + _profileStore = profileStore; + _existing = existing; + + if (existing is not null) + { + Title = "Edit Credential Profile"; + NameBox.Text = existing.Name; + + switch (existing.AuthType) + { + case AuthenticationTypes.ServicePrincipal: + ServicePrincipalAuthRadio.IsChecked = true; + AzureClientIdBox.Text = existing.AzureClientId ?? ""; + AzureTenantIdBox.Text = existing.AzureTenantId ?? ""; + break; + case AuthenticationTypes.ManagedIdentity: + ManagedIdentityAuthRadio.IsChecked = true; + ManagedIdentityClientIdBox.Text = existing.ManagedIdentityClientId ?? ""; + break; + default: + SqlAuthRadio.IsChecked = true; + UsernameBox.Text = existing.Username ?? ""; + break; + } + + /* Pre-fill the secret + username from Credential Manager (pre-fill-on-edit pattern). */ + if (existing.AuthType is AuthenticationTypes.SqlServer or AuthenticationTypes.ServicePrincipal) + { + var cred = _profileStore.GetSecret(existing.Id); + if (cred.HasValue) + { + if (existing.AuthType == AuthenticationTypes.SqlServer) + { + if (string.IsNullOrEmpty(UsernameBox.Text)) + { + UsernameBox.Text = cred.Value.Username; + } + PasswordBox.Password = cred.Value.Password; + } + else + { + if (string.IsNullOrEmpty(AzureClientIdBox.Text)) + { + AzureClientIdBox.Text = cred.Value.Username; + } + AzureClientSecretBox.Password = cred.Value.Password; + } + } + } + + var inUse = _profileStore.CountServersUsingProfile(existing.Id); + if (inUse > 0) + { + StatusText.Text = $"{inUse} server(s) use this profile — editing its secret affects all of them."; + } + } + } + + private void AuthMode_Changed(object sender, RoutedEventArgs e) + { + if (SqlCredentialsPanel is null || ServicePrincipalPanel is null || ManagedIdentityPanel is null) + { + return; + } + + SqlCredentialsPanel.Visibility = SqlAuthRadio.IsChecked == true ? Visibility.Visible : Visibility.Collapsed; + ServicePrincipalPanel.Visibility = ServicePrincipalAuthRadio.IsChecked == true ? Visibility.Visible : Visibility.Collapsed; + ManagedIdentityPanel.Visibility = ManagedIdentityAuthRadio.IsChecked == true ? Visibility.Visible : Visibility.Collapsed; + } + + private void SaveButton_Click(object sender, RoutedEventArgs e) + { + var name = NameBox.Text.Trim(); + if (string.IsNullOrEmpty(name)) + { + StatusText.Text = "Profile name is required."; + return; + } + + string authType; + string? username = null; + string? secret = null; + string? azureClientId = null; + string? azureTenantId = null; + string? miClientId = null; + + if (ServicePrincipalAuthRadio.IsChecked == true) + { + authType = AuthenticationTypes.ServicePrincipal; + azureClientId = string.IsNullOrWhiteSpace(AzureClientIdBox.Text) ? null : AzureClientIdBox.Text.Trim(); + azureTenantId = string.IsNullOrWhiteSpace(AzureTenantIdBox.Text) ? null : AzureTenantIdBox.Text.Trim(); + username = azureClientId; + secret = AzureClientSecretBox.Password; + + if (string.IsNullOrEmpty(azureClientId)) + { + StatusText.Text = "Client (Application) ID is required for service principal profiles."; + return; + } + /* On add (or when no stored secret yet), require the secret. On edit, a blank secret means + "leave the stored secret unchanged". */ + if (_existing is null && string.IsNullOrEmpty(secret)) + { + StatusText.Text = "Client secret is required for service principal profiles."; + return; + } + } + else if (ManagedIdentityAuthRadio.IsChecked == true) + { + authType = AuthenticationTypes.ManagedIdentity; + miClientId = string.IsNullOrWhiteSpace(ManagedIdentityClientIdBox.Text) ? null : ManagedIdentityClientIdBox.Text.Trim(); + } + else + { + authType = AuthenticationTypes.SqlServer; + username = UsernameBox.Text.Trim(); + secret = PasswordBox.Password; + + if (string.IsNullOrEmpty(username)) + { + StatusText.Text = "Username is required for SQL Server profiles."; + return; + } + if (_existing is null && string.IsNullOrEmpty(secret)) + { + StatusText.Text = "Password is required for SQL Server profiles."; + return; + } + } + + try + { + if (_existing is null) + { + var profile = new ViewerCredentialProfile + { + Name = name, + AuthType = authType, + Username = username, + AzureClientId = azureClientId, + AzureTenantId = azureTenantId, + ManagedIdentityClientId = miClientId + }; + _profileStore.AddProfile(profile, username, secret); + SavedProfile = profile; + } + else + { + _existing.Name = name; + _existing.AuthType = authType; + _existing.Username = username; + _existing.AzureClientId = azureClientId; + _existing.AzureTenantId = azureTenantId; + _existing.ManagedIdentityClientId = miClientId; + + /* A blank secret on edit leaves the stored secret unchanged (pass null username so + UpdateProfile skips the re-store); a non-blank one updates it. */ + if (string.IsNullOrEmpty(secret)) + { + _profileStore.UpdateProfile(_existing); + } + else + { + _profileStore.UpdateProfile(_existing, username, secret); + } + + SavedProfile = _existing; + } + + DialogResult = true; + Close(); + } + catch (Exception ex) + { + StatusText.Text = $"Error: {ex.Message}"; + } + } + + private void CancelButton_Click(object sender, RoutedEventArgs e) + { + DialogResult = false; + Close(); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ExcludedDatabasesDialog.xaml b/Darling/PerformanceMonitor.Darling.Viewer/ExcludedDatabasesDialog.xaml new file mode 100644 index 000000000..831f8dda3 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ExcludedDatabasesDialog.xaml @@ -0,0 +1,61 @@ +<Window x:Class="PerformanceMonitor.Darling.Viewer.ExcludedDatabasesDialog" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + Title="Excluded Databases" + Height="520" Width="500" + WindowStartupLocation="CenterOwner" + Icon="EDD.ico" + ResizeMode="CanResizeWithGrip" + Background="{DynamicResource BackgroundBrush}"> + <Grid Margin="16"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + + <TextBlock Grid.Row="0" x:Name="HeaderText" Text="Excluded Databases" FontSize="16" FontWeight="Bold" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + + <!-- The viewer never connects to the monitored server, but the Darling store HAS the databases the + service collected — so the list below is the collected user databases (not a free-text editor). + A not-yet-collected database can still be added by hand at the bottom. --> + <TextBlock Grid.Row="1" TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,0,0,8" + Text="User databases to skip in per-database collectors. Checked databases are excluded. The list is the databases the Darling service has collected for this server; system databases (master/model/msdb/tempdb) are always excluded. Add a not-yet-collected database by name below."/> + + <Border Grid.Row="2" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="4"> + <ScrollViewer VerticalScrollBarVisibility="Auto" Padding="8"> + <ItemsControl x:Name="DatabasesItemsControl"> + <ItemsControl.ItemTemplate> + <DataTemplate> + <CheckBox Content="{Binding DisplayName}" + IsChecked="{Binding IsExcluded, Mode=TwoWay}" + IsEnabled="{Binding IsEnabled}" + Foreground="{Binding ForegroundBrush}" + Margin="2,3"/> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + </ScrollViewer> + </Border> + + <!-- Manual add for a database the store hasn't collected yet. --> + <StackPanel Grid.Row="3" Orientation="Horizontal" Margin="0,8,0,0"> + <TextBox x:Name="ManualAddBox" Width="360" VerticalContentAlignment="Center" + KeyDown="ManualAddBox_KeyDown" + ToolTip="Enter a database name the service has not collected yet, then click Add."/> + <Button Content="Add" Width="70" Height="26" Margin="8,0,0,0" Click="ManualAdd_Click"/> + </StackPanel> + + <TextBlock Grid.Row="4" x:Name="StatusText" FontSize="10" FontStyle="Italic" + Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,8,0,0"/> + + <StackPanel Grid.Row="5" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0"> + <Button Content="Save" Width="80" Height="28" Margin="0,0,8,0" Click="Save_Click" Style="{StaticResource AccentButton}"/> + <Button Content="Cancel" Width="80" Height="28" Click="Cancel_Click" IsCancel="True"/> + </StackPanel> + </Grid> +</Window> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ExcludedDatabasesDialog.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/ExcludedDatabasesDialog.xaml.cs new file mode 100644 index 000000000..9e1529346 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ExcludedDatabasesDialog.xaml.cs @@ -0,0 +1,237 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Input; +using System.Windows.Media; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The viewer's Excluded Databases editor — a checkbox picker over the databases the Darling service has +/// COLLECTED for this server, adapted from Lite's dialog. Lite reads the server's LIVE database list (it +/// connects to the monitored server); the viewer never connects, but the store already holds the +/// collected databases (<c>v_database_config</c> / <c>v_database_size_stats</c>), so the picker offers +/// those instead of a free-text editor. A not-yet-collected database can still be added by hand (the +/// manual fallback). Persists the checked names to <c>config.config_monitored_servers.excluded_databases</c> +/// (Stage 3) so the Darling service honors them on its next reload — keyed by the shared <c>server_id</c>, +/// which is why the collected-database read needs no name→id lookup. +/// </summary> +public partial class ExcludedDatabasesDialog : Window +{ + private readonly ViewerDataService _dataService; + private readonly MonitoredServerRow _row; + private ObservableCollection<ExcludedDatabaseItem> _items = new(); + + /// <summary>Set true when the exclusion list was changed and saved, so the caller refreshes.</summary> + public bool ExclusionsModified { get; private set; } + + /// <param name="dataService">The store this reads the collected-database list from and writes the exclusions to.</param> + /// <param name="row">The store row being edited (its <c>ExcludedDatabases</c> is updated in place on save).</param> + /// <param name="displayName">The header label (the server's display name with its read-only-intent suffix).</param> + public ExcludedDatabasesDialog(ViewerDataService dataService, MonitoredServerRow row, string displayName) + { + InitializeComponent(); + _dataService = dataService ?? throw new ArgumentNullException(nameof(dataService)); + _row = row ?? throw new ArgumentNullException(nameof(row)); + HeaderText.Text = $"Excluded Databases — {displayName}"; + Loaded += async (_, _) => await LoadAsync(); + } + + private async Task LoadAsync() + { + var existing = _row.ExcludedDatabases ?? new List<string>(); + StatusText.Text = "Loading collected databases…"; + + List<string> collected = new(); + var collectedCount = 0; + try + { + /* The config row already carries the shared server_id, so the collected-database read needs no + name→id lookup (unlike the old registry path). */ + collected = await _dataService.GetCollectedDatabaseNamesAsync(_row.ServerId); + collectedCount = collected.Count; + } + catch (Exception ex) + { + /* A store read failure must never block editing exclusions — fall back to manual-only. */ + ViewerLogger.Warn("ExcludedDatabasesDialog", $"Could not read collected databases: {ex.Message}"); + } + + _items = new ObservableCollection<ExcludedDatabaseItem>(BuildDatabaseItems(collected, existing)); + DatabasesItemsControl.ItemsSource = _items; + + StatusText.Text = collectedCount > 0 + ? $"{collectedCount} collected database(s), {existing.Count} currently excluded." + : $"No collected databases for this server yet — add names to exclude. {existing.Count} currently excluded."; + } + + /// <summary> + /// Merges the store's collected user-database names with the entry's existing exclusions into the + /// checkbox list: every collected database is a checkbox (checked when already excluded), and any + /// existing exclusion the store has NOT collected is preserved as a checked "(not collected)" row so + /// saving never silently drops it (the viewer can't verify liveness, so it stays enabled/removable). + /// Pure so it is unit-testable without a store. Case-insensitive throughout. + /// </summary> + public static List<ExcludedDatabaseItem> BuildDatabaseItems(IEnumerable<string> collectedNames, IEnumerable<string> existingExclusions) + { + var excluded = new HashSet<string>( + (existingExclusions ?? Enumerable.Empty<string>()) + .Where(n => !string.IsNullOrWhiteSpace(n)) + .Select(n => n.Trim()), + StringComparer.OrdinalIgnoreCase); + + var collected = (collectedNames ?? Enumerable.Empty<string>()) + .Where(n => !string.IsNullOrWhiteSpace(n)) + .Select(n => n.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(n => n, StringComparer.OrdinalIgnoreCase) + .ToList(); + var collectedSet = new HashSet<string>(collected, StringComparer.OrdinalIgnoreCase); + + var items = new List<ExcludedDatabaseItem>(); + foreach (var name in collected) + { + items.Add(new ExcludedDatabaseItem + { + Name = name, + DisplayName = name, + IsExcluded = excluded.Contains(name), + IsCollected = true + }); + } + + /* Existing exclusions the store hasn't collected (yet, or ever): keep them, checked, so a save + preserves them. Enabled (not disabled like Lite's stale rows) — the viewer can't confirm the + database is truly gone, and this is exactly the not-yet-collected manual case. */ + foreach (var name in excluded.Where(n => !collectedSet.Contains(n)).OrderBy(n => n, StringComparer.OrdinalIgnoreCase)) + { + items.Add(new ExcludedDatabaseItem + { + Name = name, + DisplayName = $"{name} (not collected)", + IsExcluded = true, + IsCollected = false + }); + } + + return items; + } + + private void ManualAddBox_KeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Enter) + { + AddManualDatabase(); + e.Handled = true; + } + } + + private void ManualAdd_Click(object sender, RoutedEventArgs e) => AddManualDatabase(); + + private void AddManualDatabase() + { + var name = ManualAddBox.Text.Trim(); + if (name.Length == 0) + { + return; + } + + var match = _items.FirstOrDefault(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); + if (match is not null) + { + /* Already listed (collected or previously added) — just tick it. */ + match.IsExcluded = true; + StatusText.Text = $"'{name}' is already in the list — marked excluded."; + } + else + { + _items.Insert(0, new ExcludedDatabaseItem + { + Name = name, + DisplayName = $"{name} (not collected)", + IsExcluded = true, + IsCollected = false + }); + StatusText.Text = $"Added '{name}'."; + } + + ManualAddBox.Clear(); + ManualAddBox.Focus(); + } + + private async void Save_Click(object sender, RoutedEventArgs e) + { + try + { + var names = _items + .Where(i => i.IsExcluded) + .Select(i => i.Name) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + await _dataService.SetMonitoredServerExcludedDatabasesAsync(_row.ServerId, names); + _row.ExcludedDatabases = names; + ExclusionsModified = true; + DialogResult = true; + } + catch (ViewerReadOnlyException ex) + { + StatusText.Text = ex.Message; + } + catch (ViewerSchemaSkewException ex) + { + StatusText.Text = ex.Message; + } + catch (Exception ex) + { + StatusText.Text = $"Failed to save: {ex.Message}"; + ViewerLogger.Error("ExcludedDatabasesDialog", "Failed to save exclusions", ex); + } + } + + private void Cancel_Click(object sender, RoutedEventArgs e) => DialogResult = false; +} + +/// <summary> +/// One row of the Excluded Databases picker — a database name, its checked (excluded) state, and whether +/// the store has actually collected it. Mirrors Lite's <c>DatabaseExclusionItem</c>; the viewer keeps +/// not-collected rows enabled (Lite disabled its stale rows) because the viewer can't verify liveness. +/// </summary> +public sealed class ExcludedDatabaseItem : INotifyPropertyChanged +{ + public string Name { get; set; } = ""; + public string DisplayName { get; set; } = ""; + + private bool _isExcluded; + public bool IsExcluded + { + get => _isExcluded; + set { _isExcluded = value; OnPropertyChanged(nameof(IsExcluded)); } + } + + /// <summary>True when the store has collected this database; false for a manually-added / not-yet-collected name.</summary> + public bool IsCollected { get; set; } = true; + + /// <summary>Always editable — the viewer can't confirm a database is truly gone, so nothing is disabled.</summary> + public bool IsEnabled => true; + + public Brush ForegroundBrush => IsCollected + ? (Brush)Application.Current.FindResource("ForegroundBrush") + : (Brush)Application.Current.FindResource("ForegroundMutedBrush"); + + public event PropertyChangedEventHandler? PropertyChanged; + private void OnPropertyChanged(string propertyName) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.IndexAnalysis.cs b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.IndexAnalysis.cs new file mode 100644 index 000000000..729e3cde0 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.IndexAnalysis.cs @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// FinOps Index Analysis sub-tab (Stage 3) — the monitor-side reproduction of Erik's sp_IndexCleanup, run in +/// the viewer over the latest collected <c>v_index_object_stats</c> snapshot (parse-on-read, like the System +/// Events tab; NO live proc, NO "install the proc" prompt). <see cref="ViewerDataService.GetIndexAnalysisAsync"/> +/// reads + maps the snapshot, derives the edition/uptime options from <c>server_properties</c>, and runs the +/// pure Common <c>IndexCleanupAnalyzer</c> off the UI thread. This partial projects the result into the +/// reclaimable-space rollup grid (overall + per database) and the recommendations detail grid (DISABLE / MAKE +/// UNIQUE / MERGE / DROP CONSTRAINT / COMPRESS / REVIEW — REVIEW rows carry no destructive script), and surfaces +/// the analyzer's <c>UptimeWarning</c> / <c>DedupeOnlyApplied</c> flags + <c>Notes</c> (its stated limitations) +/// as banners. Load-on-view + a Refresh button, mirroring the other FinOps sub-tabs. +/// </summary> +public partial class FinOpsTab +{ + /// <summary> + /// Runs the analysis over the latest collected snapshot and repaints the rollup + detail grids and the + /// banners. When no index statistics have been collected yet (no rollups), shows the empty hint and clears + /// the grids/banners. + /// </summary> + private async Task LoadFinOpsIndexAnalysisAsync() + { + var result = await _dataService.GetIndexAnalysisAsync(_server.ServerId); + + var nothingCollected = result.DatabaseRollups.Count == 0; + + if (nothingCollected) + { + _finopsIndexAnalysisRollupFilterMgr!.UpdateData(new List<IndexCleanupRollupRow>()); + _finopsIndexAnalysisFilterMgr!.UpdateData(new List<IndexCleanupRecommendationRow>()); + FinOpsIndexAnalysisBannerPanel.Children.Clear(); + FinOpsIndexAnalysisBannerPanel.Visibility = Visibility.Collapsed; + FinOpsIndexAnalysisNoDataMessage.Visibility = Visibility.Visible; + FinOpsIndexAnalysisCountIndicator.Text = ""; + return; + } + + var recs = ViewerDataService.ProjectRecommendations(result); + var rollups = ViewerDataService.ProjectRollups(result); + + _finopsIndexAnalysisRollupFilterMgr!.UpdateData(rollups); + _finopsIndexAnalysisFilterMgr!.UpdateData(recs); + + BuildIndexAnalysisBanners(result); + + FinOpsIndexAnalysisNoDataMessage.Visibility = Visibility.Collapsed; + FinOpsIndexAnalysisCountIndicator.Text = recs.Count > 0 + ? $"{recs.Count} recommendation(s)" + : "no cleanup recommendations — indexes look clean"; + } + + /// <summary>Rebuilds the banner strip: the <14-day uptime caveat, the dedupe-only notice, and every analyzer Note (its stated limitations).</summary> + private void BuildIndexAnalysisBanners(IndexCleanupAnalysisResult result) + { + FinOpsIndexAnalysisBannerPanel.Children.Clear(); + + if (result.UptimeWarning) + { + AddIndexAnalysisBanner( + "Server uptime is under 14 days — index usage data may be incomplete, so some \"Unused Index\" findings could be premature.", + "#F39C12", "#33290F"); + } + + if (result.DedupeOnlyApplied) + { + AddIndexAnalysisBanner( + "Dedupe-only mode is in effect — unused indexes were NOT flagged (server uptime is 7 days or less). Only duplicate/consolidation and compression findings are shown.", + "#3498DB", "#0F2433"); + } + + foreach (var note in result.Notes) + { + AddIndexAnalysisBanner(note, "#7F8C8D", "#202628"); + } + + FinOpsIndexAnalysisBannerPanel.Visibility = + FinOpsIndexAnalysisBannerPanel.Children.Count > 0 ? Visibility.Visible : Visibility.Collapsed; + } + + /// <summary>Adds one left-accented banner row to the Index Analysis banner strip.</summary> + private void AddIndexAnalysisBanner(string text, string accentHex, string backgroundHex) + { + var border = new Border + { + Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(backgroundHex)), + BorderBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(accentHex)), + BorderThickness = new Thickness(3, 0, 0, 0), + CornerRadius = new CornerRadius(2), + Margin = new Thickness(0, 0, 0, 4), + Padding = new Thickness(8, 5, 8, 5), + Child = new TextBlock + { + Text = text, + TextWrapping = TextWrapping.Wrap, + FontSize = 12, + Foreground = new SolidColorBrush(Color.FromRgb(0xE0, 0xE0, 0xE0)), + }, + }; + + FinOpsIndexAnalysisBannerPanel.Children.Add(border); + } + + /// <summary>Refresh button — re-runs the analysis over the latest snapshot with the shell's status-bar error surfacing.</summary> + private async void FinOpsRefreshIndexAnalysis_Click(object sender, RoutedEventArgs e) + => await RunFinOpsLoad(LoadFinOpsIndexAnalysisAsync); +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs new file mode 100644 index 000000000..83d8c11b6 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs @@ -0,0 +1,606 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The FinOps top-level aggregate tab — a COPY of Lite's cross-server FinOps tab +/// (<c>Controls/FinOpsTab.xaml.cs</c>), reads rewired to <see cref="ViewerDataService"/> Postgres. It restores +/// Lite's original shape: a top-level tab in the viewer shell (beside Overview / Recommendations / Alerts) with +/// its OWN server selector (independent of the sidebar, mirroring the Recommendations tab), NOT the earlier +/// per-server inner tab. All eleven sub-tabs are ported (Utilization, Database Resources, Storage Growth, +/// Locking & Contention, Database Sizes, Optimization, High Impact, Application Connections, Server +/// Inventory, plus Index Analysis and Recommendations); Lite's two originally live-target sub-tabs (Index +/// Analysis / Recommendations) are reproduced MONITOR-SIDE over the collected store — Index Analysis (#1387) +/// reruns sp_IndexCleanup via the Common analyzer, and Recommendations runs its ~10 cost checks over collected +/// views — so neither touches a live target. The per-server sub-tabs scope to the selector's SELECTED server +/// (<c>_server.ServerId</c>); Server Inventory stays cross-server (it lists every registered server) and now +/// lives ONCE at the aggregate level instead of being duplicated inside every server's tab. FinOps COST +/// attribution is sourced from the registry (<c>servers.monthly_cost_usd</c> → <c>_server.MonthlyCostUsd</c>, +/// carried on the selected <see cref="DarlingServer"/>; 0 hides the cost affordances); the health score is +/// kept. This partial holds the sub-tab dispatch, the filter-manager wiring, and the simple grid loaders; +/// Locking lives in <c>FinOpsTab.Locking.cs</c>, the Storage Growth drill in <c>FinOpsTab.ObjectHeatmap.cs</c>, +/// Index Analysis in <c>FinOpsTab.IndexAnalysis.cs</c>, and Recommendations in +/// <c>FinOpsTab.Recommendations.cs</c>; the shell (server selector, filter plumbing, copy/export) lives in +/// <c>FinOpsTab.xaml.cs</c>. +/// </summary> +public partial class FinOpsTab +{ + /* FinOps sub-tab order (matches the task's port list; Lite's Recommendations + Index Analysis omitted). */ + private const int FinOpsUtilizationSubTabIndex = 0; + private const int FinOpsDatabaseResourcesSubTabIndex = 1; + private const int FinOpsStorageGrowthSubTabIndex = 2; + private const int FinOpsLockingSubTabIndex = 3; + private const int FinOpsDatabaseSizesSubTabIndex = 4; + private const int FinOpsOptimizationSubTabIndex = 5; + private const int FinOpsHighImpactSubTabIndex = 6; + private const int FinOpsApplicationConnectionsSubTabIndex = 7; + private const int FinOpsServerInventorySubTabIndex = 8; + private const int FinOpsIndexAnalysisSubTabIndex = 9; + private const int FinOpsRecommendationsSubTabIndex = 10; + + private DataGridFilterManager<DatabaseResourceUsageRow>? _finopsDbResourcesFilterMgr; + private DataGridFilterManager<StorageGrowthRow>? _finopsStorageGrowthFilterMgr; + private DataGridFilterManager<DatabaseSizeRow>? _finopsDbSizesFilterMgr; + private DataGridFilterManager<ApplicationConnectionRow>? _finopsAppConnectionsFilterMgr; + private DataGridFilterManager<ServerPropertyRow>? _finopsServerInventoryFilterMgr; + private DataGridFilterManager<HighImpactQueryRow>? _finopsHighImpactFilterMgr; + private DataGridFilterManager<IdleDatabaseRow>? _finopsIdleDbsFilterMgr; + private DataGridFilterManager<TempdbSummaryRow>? _finopsTempdbFilterMgr; + private DataGridFilterManager<WaitCategorySummaryRow>? _finopsWaitCategoryFilterMgr; + private DataGridFilterManager<ExpensiveQueryRow>? _finopsExpensiveQueriesFilterMgr; + private DataGridFilterManager<MemoryGrantEfficiencyRow>? _finopsMemoryGrantFilterMgr; + private DataGridFilterManager<IndexLockingRow>? _finopsIndexLockingFilterMgr; + private DataGridFilterManager<IndexCleanupRollupRow>? _finopsIndexAnalysisRollupFilterMgr; + private DataGridFilterManager<IndexCleanupRecommendationRow>? _finopsIndexAnalysisFilterMgr; + private DataGridFilterManager<RecommendationRow>? _finopsRecommendationsFilterMgr; + + /// <summary> + /// Registers the FinOps grids' column-filter managers into the shared <c>_filterManagers</c> map + /// (defined in <c>ViewerServerTab.Filters.cs</c>). Called from the constructor after InitializeComponent, + /// alongside the other Initialize* hooks, so the named grids exist. + /// </summary> + private void InitializeFinOpsTab() + { + _finopsDbResourcesFilterMgr = new DataGridFilterManager<DatabaseResourceUsageRow>(FinOpsDatabaseResourcesDataGrid); + _finopsStorageGrowthFilterMgr = new DataGridFilterManager<StorageGrowthRow>(FinOpsStorageGrowthDataGrid); + _finopsDbSizesFilterMgr = new DataGridFilterManager<DatabaseSizeRow>(FinOpsDatabaseSizesDataGrid); + _finopsAppConnectionsFilterMgr = new DataGridFilterManager<ApplicationConnectionRow>(FinOpsApplicationConnectionsDataGrid); + _finopsServerInventoryFilterMgr = new DataGridFilterManager<ServerPropertyRow>(FinOpsServerInventoryDataGrid); + _finopsHighImpactFilterMgr = new DataGridFilterManager<HighImpactQueryRow>(FinOpsHighImpactDataGrid); + _finopsIdleDbsFilterMgr = new DataGridFilterManager<IdleDatabaseRow>(FinOpsIdleDatabasesDataGrid); + _finopsTempdbFilterMgr = new DataGridFilterManager<TempdbSummaryRow>(FinOpsTempdbPressureDataGrid); + _finopsWaitCategoryFilterMgr = new DataGridFilterManager<WaitCategorySummaryRow>(FinOpsWaitCategorySummaryDataGrid); + _finopsExpensiveQueriesFilterMgr = new DataGridFilterManager<ExpensiveQueryRow>(FinOpsExpensiveQueriesDataGrid); + _finopsMemoryGrantFilterMgr = new DataGridFilterManager<MemoryGrantEfficiencyRow>(FinOpsMemoryGrantEfficiencyDataGrid); + _finopsIndexLockingFilterMgr = new DataGridFilterManager<IndexLockingRow>(FinOpsIndexLockingDataGrid); + _finopsIndexAnalysisRollupFilterMgr = new DataGridFilterManager<IndexCleanupRollupRow>(FinOpsIndexAnalysisRollupGrid); + _finopsIndexAnalysisFilterMgr = new DataGridFilterManager<IndexCleanupRecommendationRow>(FinOpsIndexAnalysisDetailGrid); + _finopsRecommendationsFilterMgr = new DataGridFilterManager<RecommendationRow>(FinOpsRecommendationsDataGrid); + + _filterManagers[FinOpsDatabaseResourcesDataGrid] = _finopsDbResourcesFilterMgr; + _filterManagers[FinOpsStorageGrowthDataGrid] = _finopsStorageGrowthFilterMgr; + _filterManagers[FinOpsDatabaseSizesDataGrid] = _finopsDbSizesFilterMgr; + _filterManagers[FinOpsApplicationConnectionsDataGrid] = _finopsAppConnectionsFilterMgr; + _filterManagers[FinOpsServerInventoryDataGrid] = _finopsServerInventoryFilterMgr; + _filterManagers[FinOpsHighImpactDataGrid] = _finopsHighImpactFilterMgr; + _filterManagers[FinOpsIdleDatabasesDataGrid] = _finopsIdleDbsFilterMgr; + _filterManagers[FinOpsTempdbPressureDataGrid] = _finopsTempdbFilterMgr; + _filterManagers[FinOpsWaitCategorySummaryDataGrid] = _finopsWaitCategoryFilterMgr; + _filterManagers[FinOpsExpensiveQueriesDataGrid] = _finopsExpensiveQueriesFilterMgr; + _filterManagers[FinOpsMemoryGrantEfficiencyDataGrid] = _finopsMemoryGrantFilterMgr; + _filterManagers[FinOpsIndexLockingDataGrid] = _finopsIndexLockingFilterMgr; + _filterManagers[FinOpsIndexAnalysisRollupGrid] = _finopsIndexAnalysisRollupFilterMgr; + _filterManagers[FinOpsIndexAnalysisDetailGrid] = _finopsIndexAnalysisFilterMgr; + _filterManagers[FinOpsRecommendationsDataGrid] = _finopsRecommendationsFilterMgr; + } + + /// <summary> + /// A FinOps sub-tab switch reloads through this control's overlap-guarded + /// <see cref="RefreshActiveSubTabAsync"/> (mirrors the per-server tab's Queries/File I/O sub-tab handlers). + /// Gated on <see cref="FrameworkElement.IsLoaded"/> and the sub-TabControl's own selection so build-time and + /// bubbled child selections are ignored. + /// </summary> + private async void FinOpsSubTabs_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (!ReferenceEquals(e.OriginalSource, FinOpsSubTabControl) || !IsLoaded) + { + return; + } + + await RefreshActiveSubTabAsync(); + } + + /// <summary> + /// Loads the FinOps tab's ACTIVE sub-tab only (Lite's visible-only rule). The shell's + /// <see cref="LoadInnerTabAsync"/> owns the try/catch that surfaces failures on the status bar. + /// </summary> + private async Task LoadFinOpsAsync() + { + switch (FinOpsSubTabControl.SelectedIndex) + { + case FinOpsDatabaseResourcesSubTabIndex: + await LoadFinOpsDatabaseResourcesAsync(); + break; + case FinOpsStorageGrowthSubTabIndex: + await LoadFinOpsStorageGrowthActiveAsync(); + break; + case FinOpsLockingSubTabIndex: + await LoadFinOpsIndexLockingAsync(); + break; + case FinOpsDatabaseSizesSubTabIndex: + await LoadFinOpsDatabaseSizesAsync(); + break; + case FinOpsOptimizationSubTabIndex: + await LoadFinOpsOptimizationAsync(); + break; + case FinOpsHighImpactSubTabIndex: + await LoadFinOpsHighImpactAsync(); + break; + case FinOpsApplicationConnectionsSubTabIndex: + await LoadFinOpsApplicationConnectionsAsync(); + break; + case FinOpsServerInventorySubTabIndex: + await LoadFinOpsServerInventoryAsync(); + break; + case FinOpsIndexAnalysisSubTabIndex: + await LoadFinOpsIndexAnalysisAsync(); + break; + case FinOpsRecommendationsSubTabIndex: + await LoadFinOpsRecommendationsAsync(); + break; + case FinOpsUtilizationSubTabIndex: + default: + await LoadFinOpsUtilizationAsync(); + break; + } + } + + // ── Utilization ── + + private async Task LoadFinOpsUtilizationAsync() + { + var data = await _dataService.GetUtilizationEfficiencyAsync(_server.ServerId); + + if (data != null) + { + /* Per-server FinOps budget (0 = hide the cost cards, like Lite). */ + data.MonthlyCost = _server.MonthlyCostUsd; + + /* Free space % for the storage health score, from the latest database sizes. */ + var dbSizes = await _dataService.GetDatabaseSizeLatestAsync(_server.ServerId); + var totalStorageMb = dbSizes.Sum(d => d.TotalSizeMb); + var totalFreeMb = dbSizes.Sum(d => d.FreeSpaceMb ?? 0m); + data.FreeSpacePct = totalStorageMb > 0 ? totalFreeMb / totalStorageMb * 100m : 100m; + } + + UpdateUtilizationSummary(data); + FinOpsNoUtilizationMessage.Visibility = data == null ? Visibility.Visible : Visibility.Collapsed; + FinOpsSummaryContent.Visibility = data == null ? Visibility.Collapsed : Visibility.Visible; + + if (data != null) + { + FinOpsTopTotalGrid.ItemsSource = await _dataService.GetTopResourceConsumersByTotalAsync(_server.ServerId); + FinOpsTopAvgGrid.ItemsSource = await _dataService.GetTopResourceConsumersByAvgAsync(_server.ServerId); + FinOpsDbSizeChart.ItemsSource = await _dataService.GetDatabaseSizeSummaryAsync(_server.ServerId); + FinOpsProvisioningTrendGrid.ItemsSource = await _dataService.GetProvisioningTrendAsync(_server.ServerId); + } + else + { + FinOpsTopTotalGrid.ItemsSource = null; + FinOpsTopAvgGrid.ItemsSource = null; + FinOpsDbSizeChart.ItemsSource = null; + FinOpsProvisioningTrendGrid.ItemsSource = null; + } + } + + private void UpdateUtilizationSummary(UtilizationEfficiencyRow? data) + { + if (data == null) + { + FinOpsProvisioningStatusText.Text = "No Data"; + FinOpsProvisioningStatusBorder.Background = new SolidColorBrush(Colors.Gray); + FinOpsAvgCpuText.Text = FinOpsP95CpuText.Text = FinOpsMaxCpuText.Text = FinOpsCpuSamplesText.Text = "-"; + FinOpsCpuCountText.Text = "-"; + FinOpsWorkerThreadsText.Text = "-"; + FinOpsAvgCpuBar.Width = FinOpsP95CpuBar.Width = FinOpsMaxCpuBar.Width = 0; + FinOpsMemoryUtilBar.Width = FinOpsMemoryRatioBar.Width = 0; + FinOpsMemoryUtilText.Text = FinOpsMemoryRatioText.Text = "-"; + FinOpsPhysicalMemoryText.Text = FinOpsTargetMemoryText.Text = FinOpsTotalMemoryText.Text = FinOpsBufferPoolText.Text = "-"; + FinOpsClassificationExplanation.Text = ""; + FinOpsUtilizationContent.Visibility = Visibility.Collapsed; + FinOpsHealthScoreBorder.Visibility = Visibility.Collapsed; + FinOpsComputeCostCard.Visibility = Visibility.Collapsed; + FinOpsTotalCostCard.Visibility = Visibility.Collapsed; + return; + } + + FinOpsUtilizationContent.Visibility = Visibility.Visible; + + FinOpsProvisioningStatusText.Text = data.ProvisioningStatus.Replace("_", " "); + switch (data.ProvisioningStatus) + { + case "RIGHT_SIZED": + FinOpsProvisioningStatusBorder.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#27AE60")); + FinOpsProvisioningStatusText.Foreground = Brushes.White; + break; + case "OVER_PROVISIONED": + FinOpsProvisioningStatusBorder.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#F39C12")); + FinOpsProvisioningStatusText.Foreground = Brushes.Black; + break; + case "UNDER_PROVISIONED": + FinOpsProvisioningStatusBorder.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#E74C3C")); + FinOpsProvisioningStatusText.Foreground = Brushes.White; + break; + default: + FinOpsProvisioningStatusBorder.Background = new SolidColorBrush(Colors.Gray); + FinOpsProvisioningStatusText.Foreground = Brushes.White; + break; + } + + /* CPU text + bars */ + FinOpsAvgCpuText.Text = $"{data.AvgCpuPct:N2}%"; + FinOpsP95CpuText.Text = $"{data.P95CpuPct:N2}%"; + FinOpsMaxCpuText.Text = $"{data.MaxCpuPct}%"; + FinOpsCpuSamplesText.Text = data.CpuSamples.ToString("N0"); + FinOpsCpuCountText.Text = data.CpuCount.ToString("N0"); + FinOpsWorkerThreadsText.Text = $"{data.CurrentWorkersCount:N0} / {data.MaxWorkersCount:N0}"; + + SetBar(FinOpsAvgCpuBar, FinOpsAvgCpuFilled, FinOpsAvgCpuEmpty, (double)data.AvgCpuPct); + SetBar(FinOpsP95CpuBar, FinOpsP95CpuFilled, FinOpsP95CpuEmpty, (double)data.P95CpuPct); + SetBar(FinOpsMaxCpuBar, FinOpsMaxCpuFilled, FinOpsMaxCpuEmpty, data.MaxCpuPct); + + /* Stolen Memory % = (Total Server Memory - Buffer Pool) / Total Server Memory */ + var stolenPct = data.TotalMemoryMb > 0 + ? (double)(data.TotalMemoryMb - data.BufferPoolMb) / data.TotalMemoryMb * 100.0 + : 0; + FinOpsMemoryUtilText.Text = $"{stolenPct:N0}%"; + SetBar(FinOpsMemoryUtilBar, FinOpsMemUtilFilled, FinOpsMemUtilEmpty, stolenPct); + + /* Buffer Pool % = Buffer Pool / Physical Memory */ + var bpPct = data.PhysicalMemoryMb > 0 + ? (double)data.BufferPoolMb / data.PhysicalMemoryMb * 100.0 + : 0; + FinOpsMemoryRatioText.Text = $"{bpPct:N0}%"; + SetBar(FinOpsMemoryRatioBar, FinOpsMemRatioFilled, FinOpsMemRatioEmpty, bpPct); + + FinOpsPhysicalMemoryText.Text = $"{data.PhysicalMemoryMb:N0} MB"; + FinOpsTargetMemoryText.Text = $"{data.TargetMemoryMb:N0} MB"; + FinOpsTotalMemoryText.Text = $"{data.TotalMemoryMb:N0} MB"; + FinOpsBufferPoolText.Text = $"{data.BufferPoolMb:N0} MB"; + + FinOpsClassificationExplanation.Text = data.ProvisioningStatus switch + { + "RIGHT_SIZED" => $"CPU is moderately loaded (avg {data.AvgCpuPct:N1}%, p95 {data.P95CpuPct:N1}%) and memory is well-utilized (buffer pool uses {bpPct:N0}% of physical RAM). No action needed.", + "OVER_PROVISIONED" => $"CPU is lightly loaded (avg {data.AvgCpuPct:N1}%, max {data.MaxCpuPct}%) and buffer pool uses only {bpPct:N0}% of physical RAM. This server may have more resources than it needs.", + "UNDER_PROVISIONED" => data.P95CpuPct > 85 + ? $"CPU p95 is {data.P95CpuPct:N1}% (threshold: 85%). This server may need more CPU capacity." + : $"Buffer pool uses {bpPct:N0}% of physical RAM and memory ratio is {data.MemoryRatio:N2} (threshold: 0.95). Memory pressure is high.", + _ => "" + }; + + /* Cost summary cards — shown only when a monthly budget is configured (0 = hidden, like Lite). */ + if (data.MonthlyCost > 0) + { + FinOpsAnnualComputeCostText.Text = $"${data.MonthlyCost:N0}/mo"; + FinOpsAnnualTotalCostText.Text = $"${data.AnnualCost:N0}/yr"; + FinOpsComputeCostCard.Visibility = Visibility.Visible; + FinOpsTotalCostCard.Visibility = Visibility.Visible; + } + else + { + FinOpsComputeCostCard.Visibility = Visibility.Collapsed; + FinOpsTotalCostCard.Visibility = Visibility.Collapsed; + } + + /* Health score */ + var bpRatio = data.PhysicalMemoryMb > 0 ? (decimal)data.BufferPoolMb / data.PhysicalMemoryMb : 0m; + var cpuScore = FinOpsHealthCalculator.CpuScore(data.P95CpuPct); + var memScore = FinOpsHealthCalculator.MemoryScore(bpRatio); + var storScore = FinOpsHealthCalculator.StorageScore(data.FreeSpacePct); + data.HealthScore = FinOpsHealthCalculator.Overall(cpuScore, memScore, storScore); + FinOpsHealthScoreText.Text = $"Health: {data.HealthScore}"; + FinOpsHealthScoreBorder.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(data.HealthScoreColor)); + FinOpsHealthScoreBorder.Visibility = Visibility.Visible; + } + + private static void SetBar(Border bar, ColumnDefinition filled, ColumnDefinition empty, double pct) + { + var clamped = Math.Max(0, Math.Min(100, pct)); + + var color = clamped switch + { + > 85 => "#E74C3C", + > 60 => "#F39C12", + _ => "#27AE60" + }; + bar.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(color)); + + filled.Width = new GridLength(Math.Max(clamped, 0.1), GridUnitType.Star); + empty.Width = new GridLength(Math.Max(100 - clamped, 0.1), GridUnitType.Star); + } + + private static int HoursBackFromIndex(ComboBox combo) => combo.SelectedIndex switch { 0 => 1, 1 => 4, 2 => 12, 3 => 24, 4 => 168, _ => 24 }; + + // ── Database Resources ── + + private async Task LoadFinOpsDatabaseResourcesAsync() + { + var hoursBack = HoursBackFromIndex(FinOpsResourceUsageTimeRangeCombo); + var data = await _dataService.GetDatabaseResourceUsageAsync(_server.ServerId, hoursBack); + _finopsDbResourcesFilterMgr!.UpdateData(data); + FinOpsNoDatabaseResourcesMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + FinOpsDbResourcesCountIndicator.Text = data.Count > 0 ? $"{data.Count} database(s)" : ""; + } + + // ── Database Sizes ── + + private async Task LoadFinOpsDatabaseSizesAsync() + { + var data = await _dataService.GetDatabaseSizeLatestAsync(_server.ServerId); + + /* Proportional cost share by size (mirrors Lite's LoadDatabaseSizesAsync). */ + if (_server.MonthlyCostUsd > 0 && data.Count > 0) + { + var totalMb = data.Sum(d => d.TotalSizeMb); + if (totalMb > 0) + { + foreach (var d in data) + d.MonthlyCostShare = (d.TotalSizeMb / totalMb) * _server.MonthlyCostUsd; + } + } + + _finopsDbSizesFilterMgr!.UpdateData(data); + FinOpsNoDbSizesMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + FinOpsDbSizeCountIndicator.Text = data.Count > 0 ? $"{data.Count} file(s)" : ""; + } + + // ── Application Connections ── + + private async Task LoadFinOpsApplicationConnectionsAsync() + { + var data = await _dataService.GetApplicationConnectionsAsync(_server.ServerId); + _finopsAppConnectionsFilterMgr!.UpdateData(data); + FinOpsNoAppConnectionsMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + FinOpsAppConnectionsCountIndicator.Text = data.Count > 0 ? $"{data.Count} application(s)" : ""; + } + + // ── High Impact ── + + private async Task LoadFinOpsHighImpactAsync() + { + var hoursBack = HoursBackFromIndex(FinOpsHighImpactTimeRangeCombo); + var data = await _dataService.GetHighImpactQueriesAsync(_server.ServerId, hoursBack); + _finopsHighImpactFilterMgr!.UpdateData(data); + FinOpsHighImpactNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + FinOpsHighImpactCountIndicator.Text = data.Count > 0 ? $"{data.Count} high-impact query(s)" : ""; + } + + // ── Optimization (idle DBs + tempdb + wait categories + expensive queries + memory grants) ── + + private async Task LoadFinOpsOptimizationAsync() + { + await Task.WhenAll( + LoadFinOpsIdleDatabasesAsync(), + LoadFinOpsTempdbSummaryAsync(), + LoadFinOpsWaitCategorySummaryAsync(), + LoadFinOpsExpensiveQueriesAsync(), + LoadFinOpsMemoryGrantEfficiencyAsync()); + } + + private async Task LoadFinOpsIdleDatabasesAsync() + { + var data = await _dataService.GetIdleDatabasesAsync(_server.ServerId); + _finopsIdleDbsFilterMgr!.UpdateData(data); + FinOpsIdleDatabasesNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + FinOpsIdleDatabasesCountIndicator.Text = data.Count > 0 ? $"{data.Count} idle database(s)" : ""; + } + + private async Task LoadFinOpsTempdbSummaryAsync() + { + var data = await _dataService.GetTempdbSummaryAsync(_server.ServerId); + _finopsTempdbFilterMgr!.UpdateData(data); + FinOpsTempdbPressureNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + } + + private async Task LoadFinOpsWaitCategorySummaryAsync() + { + var hoursBack = HoursBackFromIndex(FinOpsWaitStatsTimeRangeCombo); + var data = await _dataService.GetWaitCategorySummaryAsync(_server.ServerId, hoursBack); + + /* Proportional cost share scaled to the window (mirrors Lite: budget * hoursBack/730). */ + if (_server.MonthlyCostUsd > 0 && data.Count > 0) + { + var windowBudget = _server.MonthlyCostUsd * (hoursBack / 730.0m); + var totalWait = data.Sum(w => w.TotalWaitTimeMs); + if (totalWait > 0) + { + foreach (var w in data) + w.MonthlyCostShare = (w.TotalWaitTimeMs / (decimal)totalWait) * windowBudget; + } + } + + _finopsWaitCategoryFilterMgr!.UpdateData(data); + FinOpsWaitCategorySummaryNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + } + + private async Task LoadFinOpsExpensiveQueriesAsync() + { + var hoursBack = HoursBackFromIndex(FinOpsExpensiveQueriesTimeRangeCombo); + var data = await _dataService.GetExpensiveQueriesAsync(_server.ServerId, hoursBack); + + /* Proportional cost share scaled to the window (mirrors Lite: budget * hoursBack/730). */ + if (_server.MonthlyCostUsd > 0 && data.Count > 0) + { + var windowBudget = _server.MonthlyCostUsd * (hoursBack / 730.0m); + var totalCpu = data.Sum(q => q.TotalCpuMs); + if (totalCpu > 0) + { + foreach (var q in data) + q.MonthlyCostShare = (q.TotalCpuMs / (decimal)totalCpu) * windowBudget; + } + } + + _finopsExpensiveQueriesFilterMgr!.UpdateData(data); + FinOpsExpensiveQueriesNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + FinOpsExpensiveQueriesCountIndicator.Text = data.Count > 0 ? $"{data.Count} query(s)" : ""; + } + + private async Task LoadFinOpsMemoryGrantEfficiencyAsync() + { + var data = await _dataService.GetMemoryGrantEfficiencyAsync(_server.ServerId); + _finopsMemoryGrantFilterMgr!.UpdateData(data); + FinOpsMemoryGrantEfficiencyNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + } + + // ── Server Inventory (cross-server) ── + + private async Task LoadFinOpsServerInventoryAsync() + { + var servers = await _dataService.GetServerInventoryAsync(); + + /* Overlay each server's collected metrics + compute the health score (mirrors Lite's + LoadServerInventoryAsync minus the live query). memScore/storScore use Lite's inventory-path + defaults (buffer-pool ratio / file-level free space aren't in the inventory read). */ + await Task.WhenAll(servers.Select(async item => + { + var (avgCpu, storageGb, idleDbs, status) = await _dataService.GetServerMetricsAsync(item.ServerId); + if (avgCpu.HasValue) item.AvgCpuPct = avgCpu; + if (storageGb.HasValue) item.StorageTotalGb = storageGb; + if (idleDbs.HasValue) item.IdleDbCount = idleDbs; + if (status != null) item.ProvisioningStatus = status; + + var cpuScore = FinOpsHealthCalculator.CpuScore(item.AvgCpuPct ?? 0m); + var memScore = 80; + var storScore = FinOpsHealthCalculator.StorageScore(50); + item.HealthScore = FinOpsHealthCalculator.Overall(cpuScore, memScore, storScore); + })); + + _finopsServerInventoryFilterMgr!.UpdateData(servers); + FinOpsNoServerInventoryMessage.Visibility = servers.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + FinOpsServerInventoryCountIndicator.Text = servers.Count > 0 ? $"{servers.Count} server(s)" : ""; + } + + // ── Refresh buttons + time-range combos (all route through the shell's overlap-guarded loop where they + // reload the active sub-tab, or call the specific loader directly for a non-active grid) ── + + private async void FinOpsRefreshUtilization_Click(object sender, RoutedEventArgs e) => await RunFinOpsLoad(LoadFinOpsUtilizationAsync); + private async void FinOpsRefreshDatabaseResources_Click(object sender, RoutedEventArgs e) => await RunFinOpsLoad(LoadFinOpsDatabaseResourcesAsync); + private async void FinOpsRefreshDatabaseSizes_Click(object sender, RoutedEventArgs e) => await RunFinOpsLoad(LoadFinOpsDatabaseSizesAsync); + private async void FinOpsRefreshApplicationConnections_Click(object sender, RoutedEventArgs e) => await RunFinOpsLoad(LoadFinOpsApplicationConnectionsAsync); + private async void FinOpsRefreshHighImpact_Click(object sender, RoutedEventArgs e) => await RunFinOpsLoad(LoadFinOpsHighImpactAsync); + private async void FinOpsRefreshServerInventory_Click(object sender, RoutedEventArgs e) => await RunFinOpsLoad(LoadFinOpsServerInventoryAsync); + private async void FinOpsOptimizationRefresh_Click(object sender, RoutedEventArgs e) => await RunFinOpsLoad(LoadFinOpsOptimizationAsync); + + private async void FinOpsResourceUsageTimeRange_Changed(object sender, SelectionChangedEventArgs e) + { + if (!IsLoaded) return; + await RunFinOpsLoad(LoadFinOpsDatabaseResourcesAsync); + } + + private async void FinOpsWaitStatsTimeRange_Changed(object sender, SelectionChangedEventArgs e) + { + if (!IsLoaded) return; + await RunFinOpsLoad(LoadFinOpsWaitCategorySummaryAsync); + } + + private async void FinOpsExpensiveQueriesTimeRange_Changed(object sender, SelectionChangedEventArgs e) + { + if (!IsLoaded) return; + await RunFinOpsLoad(LoadFinOpsExpensiveQueriesAsync); + } + + private async void FinOpsHighImpactTimeRange_Changed(object sender, SelectionChangedEventArgs e) + { + if (!IsLoaded) return; + await RunFinOpsLoad(LoadFinOpsHighImpactAsync); + } + + /// <summary> + /// "View Plan" for the FinOps High Impact + Expensive Queries grids: opens the stored + /// <c>query_stats.query_plan_xml</c> the row already carries (Darling captures statement-level plans — + /// CapturePlans defaults on) by raising <see cref="PlanRequested"/>, which the shell routes into the + /// standalone Plan Viewer surface (this aggregate tab has no per-server plan host) — NO live SQL. Lite's + /// "Get Actual Plan" (live execution) has no viewer equivalent and stays omitted everywhere, consistent + /// with every other viewer grid. + /// </summary> + private void FinOpsViewPlan_Click(object sender, RoutedEventArgs e) + { + switch (FinOpsRowFromMenu(sender)) + { + case HighImpactQueryRow hi when hi.HasQueryPlan: + PlanRequested?.Invoke(hi.QueryPlanXml!, $"Plan - {hi.QueryHash}", hi.FullQueryText); + break; + case ExpensiveQueryRow ex when ex.HasQueryPlan: + PlanRequested?.Invoke(ex.QueryPlanXml!, "Plan - Expensive Query", ex.FullQueryText); + break; + case HighImpactQueryRow: + case ExpensiveQueryRow: + MessageBox.Show( + "No execution plan was captured for this query. The plan may not have been collected yet, " + + "or it aged out of the store's retention window.", + "No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information); + break; + } + } + + /// <summary> + /// "Get Actual Plan" for the FinOps High Impact grid (Dashboard parity) — RE-EXECUTES the row's query on the + /// target (via the service's <c>execute_actual_plan</c> command) and routes the captured actual plan into the + /// shell's Plan Viewer. Identifier-only: the service resolves the text from <c>query_stats</c> by (query_hash, + /// database). ONLY High Impact rows carry a query_hash — the Expensive Queries grid groups by text, so its + /// rows have no store key and the menu item stays disabled there (gated on <c>CanGetActualPlan</c>). The + /// shared consent + data-modification flag + read-only guard + permission handling live in + /// <see cref="ViewerActualPlanFlow"/>; the row's stored plan drives the modification detection. + /// </summary> + private async void FinOpsGetActualPlan_Click(object sender, RoutedEventArgs e) + { + if (FinOpsRowFromMenu(sender) is not HighImpactQueryRow hi || string.IsNullOrEmpty(hi.QueryHash)) + { + return; + } + + var argsJson = ViewerDataService.BuildActualPlanArgs(hi.QueryHash, hi.DatabaseName); + var label = $"Actual Plan - {hi.QueryHash}"; + + var planXml = await ViewerActualPlanFlow.RequestActualPlanAsync( + Window.GetWindow(this)!, _dataService, _server.ServerId, _server.DisplayName, hi.DatabaseName, + hi.FullQueryText, hi.QueryPlanXml, argsJson, + onStarted: () => System.Windows.Input.Mouse.OverrideCursor = System.Windows.Input.Cursors.Wait, + onFinished: () => System.Windows.Input.Mouse.OverrideCursor = null, + System.Threading.CancellationToken.None); + + if (planXml != null) + { + PlanRequested?.Invoke(planXml, label, hi.FullQueryText); + } + } + + /// <summary>Runs a FinOps loader with the same status-bar error surfacing the shell's <see cref="LoadInnerTabAsync"/> uses.</summary> + private async Task RunFinOpsLoad(Func<Task> loader) + { + try + { + await loader(); + StatusChanged?.Invoke($"{_server.DisplayName} — refreshed {DateTime.Now:HH:mm:ss}"); + } + catch (Exception ex) + { + StatusChanged?.Invoke($"refresh failed: {ex.Message}"); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Locking.cs b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Locking.cs new file mode 100644 index 000000000..95af6d220 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Locking.cs @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// FinOps Locking & Contention sub-tab — a COPY of Lite's <c>FinOpsTab.Locking.cs</c> (#1138 Phase 2): +/// a color-scaled wait-type grid (a heatmap in grid form) with a database selector and an index-detail +/// drill. The four <c>*_wait_in_ms</c> columns are shaded per-column (log) by their wait category's hue via +/// <see cref="PerformanceMonitor.Ui.HeatIntensityToBrushConverter"/> (the shared .Ui converter); the numeric +/// values stay visible. Cumulative snapshot, no delta (§3B). Reads rewired to the viewer's Postgres reads; +/// the heat math (<see cref="FinOpsHeatmapBuilder.ColumnLogIntensities"/>) is the shared Common helper. +/// </summary> +public partial class FinOpsTab +{ + private enum FinOpsLockingLevel { Parent, Detail } + + private bool _suppressFinOpsLockingDbChange; + + /// <summary>Entry point (refresh / sub-tab activate): reset to the grid, repopulate the DB selector, load.</summary> + private async Task LoadFinOpsIndexLockingAsync() + { + ShowFinOpsLockingView(FinOpsLockingLevel.Parent); + await PopulateFinOpsLockingDbSelectorAsync(); + await LoadFinOpsIndexLockingGridAsync(); + } + + private async Task PopulateFinOpsLockingDbSelectorAsync() + { + var dbs = await _dataService.GetIndexLockingDatabasesAsync(_server.ServerId); + var items = new List<string> { "All databases" }; + items.AddRange(dbs); + + _suppressFinOpsLockingDbChange = true; + FinOpsLockingDbSelector.ItemsSource = items; + FinOpsLockingDbSelector.SelectedIndex = 0; + _suppressFinOpsLockingDbChange = false; + } + + private string? SelectedFinOpsLockingDb() + => FinOpsLockingDbSelector.SelectedIndex <= 0 ? null : FinOpsLockingDbSelector.SelectedItem as string; + + private async Task LoadFinOpsIndexLockingGridAsync() + { + var db = SelectedFinOpsLockingDb(); + var data = await _dataService.GetIndexLockingAsync(_server.ServerId, 200, db); + ApplyFinOpsLockingHeat(data); + _finopsIndexLockingFilterMgr!.UpdateData(data); + FinOpsNoIndexLockingMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + FinOpsIndexLockingCountIndicator.Text = data.Count > 0 ? $"{data.Count} index(es)" : ""; + } + + /// <summary>Per-column log color-scale over the visible rows (#1138 §3B) — each wait column independent.</summary> + private static void ApplyFinOpsLockingHeat(List<IndexLockingRow> rows) + { + var rowLock = FinOpsHeatmapBuilder.ColumnLogIntensities(rows.Select(r => r.RowLockWaitInMs).ToList()); + var pageLock = FinOpsHeatmapBuilder.ColumnLogIntensities(rows.Select(r => r.PageLockWaitInMs).ToList()); + var pageLatch = FinOpsHeatmapBuilder.ColumnLogIntensities(rows.Select(r => r.PageLatchWaitInMs).ToList()); + var pageIo = FinOpsHeatmapBuilder.ColumnLogIntensities(rows.Select(r => r.PageIoLatchWaitInMs).ToList()); + for (int i = 0; i < rows.Count; i++) + { + rows[i].RowLockHeat = rowLock[i]; + rows[i].PageLockHeat = pageLock[i]; + rows[i].PageLatchHeat = pageLatch[i]; + rows[i].PageIoLatchHeat = pageIo[i]; + } + } + + private async void FinOpsLockingDbSelector_Changed(object sender, SelectionChangedEventArgs e) + { + if (_suppressFinOpsLockingDbChange || !IsLoaded) return; + ShowFinOpsLockingView(FinOpsLockingLevel.Parent); + await RunFinOpsLoad(LoadFinOpsIndexLockingGridAsync); + } + + private async void FinOpsRefreshIndexLocking_Click(object sender, RoutedEventArgs e) + => await RunFinOpsLoad(LoadFinOpsIndexLockingAsync); + + private void FinOpsIndexLockingGrid_DoubleClick(object sender, MouseButtonEventArgs e) + { + if (FinOpsIndexLockingDataGrid.SelectedItem is IndexLockingRow row) + ShowFinOpsLockingDetail(row); + } + + private void FinOpsLockingBack_Click(object sender, RoutedEventArgs e) + => ShowFinOpsLockingView(FinOpsLockingLevel.Parent); + + private void ShowFinOpsLockingView(FinOpsLockingLevel level) + { + FinOpsLockingParentView.Visibility = level == FinOpsLockingLevel.Parent ? Visibility.Visible : Visibility.Collapsed; + FinOpsLockingDetailView.Visibility = level == FinOpsLockingLevel.Detail ? Visibility.Visible : Visibility.Collapsed; + FinOpsLockingBackButton.Visibility = level == FinOpsLockingLevel.Detail ? Visibility.Visible : Visibility.Collapsed; + var selectorVisible = level == FinOpsLockingLevel.Parent ? Visibility.Visible : Visibility.Collapsed; + FinOpsLockingDbSelector.Visibility = selectorVisible; + FinOpsLockingDbLabel.Visibility = selectorVisible; + FinOpsLockingBreadcrumb.Text = level == FinOpsLockingLevel.Detail ? "Locking & Contention › index detail" : "Locking & Contention"; + } + + private void ShowFinOpsLockingDetail(IndexLockingRow row) + { + FinOpsLockingDetailPanel.DataContext = row; + + FinOpsLockingDetailIdentity.Children.Clear(); + AddFinOpsLockingDetailRow(FinOpsLockingDetailIdentity, "Database", row.DatabaseName); + AddFinOpsLockingDetailRow(FinOpsLockingDetailIdentity, "Schema", row.SchemaName); + AddFinOpsLockingDetailRow(FinOpsLockingDetailIdentity, "Table", row.TableName); + AddFinOpsLockingDetailRow(FinOpsLockingDetailIdentity, "Index", row.IndexName); + AddFinOpsLockingDetailRow(FinOpsLockingDetailIdentity, "Type", row.IndexTypeDesc); + AddFinOpsLockingDetailRow(FinOpsLockingDetailIdentity, "Reserved (MB)", row.ReservedMb.ToString("N1")); + AddFinOpsLockingDetailRow(FinOpsLockingDetailIdentity, "Rows", row.TotalRows.ToString("N0")); + + FinOpsLockingDetailCounters.Children.Clear(); + AddFinOpsLockingDetailRow(FinOpsLockingDetailCounters, "Row Lock Count", row.RowLockCount.ToString("N0")); + AddFinOpsLockingDetailRow(FinOpsLockingDetailCounters, "Row Lock Wait Count", row.RowLockWaitCount.ToString("N0")); + AddFinOpsLockingDetailRow(FinOpsLockingDetailCounters, "Row Lock Wait (ms)", row.RowLockWaitInMs.ToString("N0")); + AddFinOpsLockingDetailRow(FinOpsLockingDetailCounters, "Page Lock Count", row.PageLockCount.ToString("N0")); + AddFinOpsLockingDetailRow(FinOpsLockingDetailCounters, "Page Lock Wait Count", row.PageLockWaitCount.ToString("N0")); + AddFinOpsLockingDetailRow(FinOpsLockingDetailCounters, "Page Lock Wait (ms)", row.PageLockWaitInMs.ToString("N0")); + AddFinOpsLockingDetailRow(FinOpsLockingDetailCounters, "Lock Escalations", row.IndexLockPromotionCount.ToString("N0")); + AddFinOpsLockingDetailRow(FinOpsLockingDetailCounters, "Page Latch Wait Count", row.PageLatchWaitCount.ToString("N0")); + AddFinOpsLockingDetailRow(FinOpsLockingDetailCounters, "Page Latch Wait (ms)", row.PageLatchWaitInMs.ToString("N0")); + AddFinOpsLockingDetailRow(FinOpsLockingDetailCounters, "Page IO Latch Wait Count", row.PageIoLatchWaitCount.ToString("N0")); + AddFinOpsLockingDetailRow(FinOpsLockingDetailCounters, "Page IO Latch Wait (ms)", row.PageIoLatchWaitInMs.ToString("N0")); + + ShowFinOpsLockingView(FinOpsLockingLevel.Detail); + } + + private void AddFinOpsLockingDetailRow(Panel panel, string label, string value) + { + var grid = new Grid { Margin = new Thickness(0, 2, 0, 2) }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(170) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + var labelBlock = new TextBlock + { + Text = label, + FontWeight = FontWeights.SemiBold, + Foreground = (Brush)FindResource("ForegroundMutedBrush") + }; + var valueBlock = new TextBlock + { + Text = value, + Foreground = (Brush)FindResource("ForegroundBrush"), + HorizontalAlignment = HorizontalAlignment.Right + }; + Grid.SetColumn(labelBlock, 0); + Grid.SetColumn(valueBlock, 1); + grid.Children.Add(labelBlock); + grid.Children.Add(valueBlock); + panel.Children.Add(grid); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.ObjectHeatmap.cs b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.ObjectHeatmap.cs new file mode 100644 index 000000000..c947ca461 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.ObjectHeatmap.cs @@ -0,0 +1,279 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// FinOps Storage Growth sub-tab (parent → object → index drill) — a COPY of Lite's +/// <c>FinOpsTab.ObjectHeatmap.cs</c> (#1138). The parent grid (per-database growth) drills into a database's +/// object-growth heatmap (top-N objects by reserved-MB growth × daily buckets, colored by absolute reserved +/// MB) plus a companion grid, and an object drills to its per-index detail. Heatmap shaping/rendering flow +/// through the SHARED <see cref="FinOpsHeatmapBuilder"/> / <see cref="FinOpsHeatmapRenderer"/> so the viewer +/// renders identically to Dashboard/Lite; reads are rewired to the viewer's Postgres reads. +/// </summary> +public partial class FinOpsTab +{ + private enum FinOpsStorageDrillLevel { Parent, Objects, Indexes } + + private FinOpsStorageDrillLevel _finopsStorageLevel = FinOpsStorageDrillLevel.Parent; + private string _finopsObjDrillDb = ""; + private string _finopsObjDrillSchema = ""; + private string _finopsObjDrillTable = ""; + + private FinOpsHeatmapMatrix? _finopsObjHeatmapMatrix; + private FinOpsHeatmapHandle? _finopsObjHeatmapHandle; + private ScottPlot.Plottables.Heatmap? _finopsObjHeatmapPlottable; + private Popup? _finopsObjHeatmapPopup; + private TextBlock? _finopsObjHeatmapPopupText; + private DateTime _finopsLastObjHeatmapHover; + + private int GetFinOpsObjectHeatmapDaysBack() => FinOpsObjectHeatmapWindowCombo?.SelectedIndex switch + { + 0 => 7, + 1 => 30, + 2 => 90, + _ => 30 + }; + + /// <summary>Refresh-aware load: reloads whichever drill level is currently showing (server tab timer / Refresh).</summary> + private async Task LoadFinOpsStorageGrowthActiveAsync() + { + switch (_finopsStorageLevel) + { + case FinOpsStorageDrillLevel.Objects when !string.IsNullOrEmpty(_finopsObjDrillDb): + await LoadFinOpsObjectGrowthAsync(_finopsObjDrillDb); + break; + case FinOpsStorageDrillLevel.Indexes when !string.IsNullOrEmpty(_finopsObjDrillTable): + await LoadFinOpsObjectIndexDetailAsync(_finopsObjDrillDb, _finopsObjDrillSchema, _finopsObjDrillTable); + break; + default: + await LoadFinOpsStorageGrowthAsync(); + break; + } + } + + private async Task LoadFinOpsStorageGrowthAsync() + { + var data = await _dataService.GetStorageGrowthAsync(_server.ServerId); + _finopsStorageGrowthFilterMgr!.UpdateData(data); + FinOpsNoStorageGrowthMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + FinOpsStorageGrowthCountIndicator.Text = data.Count > 0 ? $"{data.Count} database(s)" : ""; + } + + /// <summary>Refresh button — reloads whichever drill level is showing (mirrors Lite's RefreshStorageGrowth_Click).</summary> + private async void FinOpsRefreshStorageGrowth_Click(object sender, RoutedEventArgs e) + => await RunFinOpsLoad(LoadFinOpsStorageGrowthActiveAsync); + + private void ShowFinOpsStorageView(FinOpsStorageDrillLevel level) + { + _finopsStorageLevel = level; + FinOpsStorageParentView.Visibility = level == FinOpsStorageDrillLevel.Parent ? Visibility.Visible : Visibility.Collapsed; + FinOpsStorageObjectView.Visibility = level == FinOpsStorageDrillLevel.Objects ? Visibility.Visible : Visibility.Collapsed; + FinOpsStorageIndexView.Visibility = level == FinOpsStorageDrillLevel.Indexes ? Visibility.Visible : Visibility.Collapsed; + + FinOpsStorageBackButton.Visibility = level == FinOpsStorageDrillLevel.Parent ? Visibility.Collapsed : Visibility.Visible; + var windowVisible = level == FinOpsStorageDrillLevel.Objects ? Visibility.Visible : Visibility.Collapsed; + FinOpsObjectHeatmapWindowCombo.Visibility = windowVisible; + FinOpsObjectHeatmapWindowLabel.Visibility = windowVisible; + + FinOpsStorageBreadcrumb.Text = level switch + { + FinOpsStorageDrillLevel.Objects => $"Storage Growth › {_finopsObjDrillDb}", + FinOpsStorageDrillLevel.Indexes => $"Storage Growth › {_finopsObjDrillDb} › {_finopsObjDrillSchema}.{_finopsObjDrillTable}", + _ => "Storage Growth" + }; + } + + private async void FinOpsStorageGrowthGrid_DoubleClick(object sender, MouseButtonEventArgs e) + { + if (FinOpsStorageGrowthDataGrid.SelectedItem is StorageGrowthRow row) + await DrillFinOpsToObjectsAsync(row.DatabaseName); + } + + private async void FinOpsStorageGrowthShowObjects_Click(object sender, RoutedEventArgs e) + { + if (FinOpsRowFromMenu(sender) is StorageGrowthRow row) + await DrillFinOpsToObjectsAsync(row.DatabaseName); + } + + private async void FinOpsObjectGrowthGrid_DoubleClick(object sender, MouseButtonEventArgs e) + { + if (FinOpsObjectGrowthDetailGrid.SelectedItem is ObjectSizeGrowthRow row) + await DrillFinOpsToIndexesAsync(row.DatabaseName, row.SchemaName, row.TableName); + } + + private void FinOpsStorageGrowthBack_Click(object sender, RoutedEventArgs e) + { + if (_finopsStorageLevel == FinOpsStorageDrillLevel.Indexes) + ShowFinOpsStorageView(FinOpsStorageDrillLevel.Objects); + else + ShowFinOpsStorageView(FinOpsStorageDrillLevel.Parent); + } + + private async void FinOpsObjectHeatmapWindow_Changed(object sender, SelectionChangedEventArgs e) + { + if (!IsLoaded) return; + if (_finopsStorageLevel != FinOpsStorageDrillLevel.Objects || string.IsNullOrEmpty(_finopsObjDrillDb)) return; + await RunFinOpsLoad(() => LoadFinOpsObjectGrowthAsync(_finopsObjDrillDb)); + } + + private async Task DrillFinOpsToObjectsAsync(string databaseName) + { + if (string.IsNullOrEmpty(databaseName)) return; + _finopsObjDrillDb = databaseName; + ShowFinOpsStorageView(FinOpsStorageDrillLevel.Objects); + await RunFinOpsLoad(() => LoadFinOpsObjectGrowthAsync(databaseName)); + } + + private async Task DrillFinOpsToIndexesAsync(string databaseName, string schemaName, string tableName) + { + if (string.IsNullOrEmpty(tableName)) return; + _finopsObjDrillSchema = schemaName; + _finopsObjDrillTable = tableName; + ShowFinOpsStorageView(FinOpsStorageDrillLevel.Indexes); + await RunFinOpsLoad(() => LoadFinOpsObjectIndexDetailAsync(databaseName, schemaName, tableName)); + } + + private async Task LoadFinOpsObjectGrowthAsync(string databaseName) + { + var days = GetFinOpsObjectHeatmapDaysBack(); + var (objects, samples) = await _dataService.GetObjectGrowthHeatmapDataAsync(_server.ServerId, databaseName, days); + + /* One canonical, deterministic top-of-chart-first ranking drives BOTH the companion grid and the + heatmap rows, so they can never disagree (and stay identical across Dashboard/Lite). */ + var orderedKeys = FinOpsHeatmapBuilder.RankTopGrowers( + objects.Select(o => ($"{o.SchemaName}.{o.TableName}", (double)o.Growth30dMb)), objects.Count); + var byKey = objects.ToDictionary(o => $"{o.SchemaName}.{o.TableName}"); + var orderedObjects = orderedKeys.Select(k => byKey[k]).ToList(); + + FinOpsObjectGrowthDetailGrid.ItemsSource = orderedObjects; + FinOpsStorageGrowthCountIndicator.Text = orderedObjects.Count > 0 ? $"{orderedObjects.Count} object(s)" : ""; + FinOpsObjectGrowthNoDataMessage.Visibility = orderedObjects.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + + /* Heatmap rows are bottom-to-top: the renderer flips vertically (row 0 = bottom), so reverse the + top-first ranking to put the biggest grower at the TOP of the chart. */ + var rowKeysBottomToTop = Enumerable.Reverse(orderedKeys).ToList(); + _finopsObjHeatmapMatrix = FinOpsHeatmapBuilder.BuildMatrix(rowKeysBottomToTop, samples); + _finopsObjHeatmapHandle = FinOpsHeatmapRenderer.Render( + FinOpsObjectGrowthHeatmapChart, + _finopsObjHeatmapMatrix, + "Reserved (MB)", + $"{databaseName} — Object Reserved Footprint", + _finopsObjHeatmapHandle); + _finopsObjHeatmapPlottable = _finopsObjHeatmapHandle.Plottable; + } + + private async Task LoadFinOpsObjectIndexDetailAsync(string databaseName, string schemaName, string tableName) + { + var data = await _dataService.GetObjectIndexDetailAsync(_server.ServerId, databaseName, schemaName, tableName); + FinOpsObjectIndexDetailGrid.ItemsSource = data; + FinOpsStorageGrowthCountIndicator.Text = data.Count > 0 ? $"{data.Count} index(es)" : ""; + FinOpsObjectIndexNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + } + + /// <summary>Finds the row a FinOps context-menu item was opened on (menu placed on the DataGridRow via its RowStyle).</summary> + private static object? FinOpsRowFromMenu(object sender) + { + if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) + { + if (contextMenu.PlacementTarget is DataGridRow row) return row.DataContext; + if (contextMenu.PlacementTarget is DataGrid grid) return grid.CurrentCell.Item ?? grid.SelectedItem; + } + return null; + } + + // ── Heatmap hover (copied from Lite; the popup shows object | day | reserved MB under the cursor) ── + + private void EnsureFinOpsObjHeatmapPopup() + { + if (_finopsObjHeatmapPopup != null) return; + _finopsObjHeatmapPopupText = new TextBlock + { + Foreground = new SolidColorBrush(Color.FromRgb(0xE0, 0xE0, 0xE0)), + FontSize = 13, + MaxWidth = 460, + TextTrimming = TextTrimming.CharacterEllipsis + }; + _finopsObjHeatmapPopup = new Popup + { + PlacementTarget = FinOpsObjectGrowthHeatmapChart, + Placement = PlacementMode.Relative, + IsHitTestVisible = false, + AllowsTransparency = true, + Child = new Border + { + Background = new SolidColorBrush(Color.FromRgb(0x33, 0x33, 0x33)), + BorderBrush = new SolidColorBrush(Color.FromRgb(0x55, 0x55, 0x55)), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(3), + Padding = new Thickness(8, 4, 8, 4), + Child = _finopsObjHeatmapPopupText + } + }; + } + + private void FinOpsObjectHeatmapChart_MouseLeave(object sender, MouseEventArgs e) + { + if (_finopsObjHeatmapPopup != null) _finopsObjHeatmapPopup.IsOpen = false; + } + + private void FinOpsObjectHeatmapChart_MouseMove(object sender, MouseEventArgs e) + { + EnsureFinOpsObjHeatmapPopup(); + if (_finopsObjHeatmapPopup == null || _finopsObjHeatmapPopupText == null || _finopsObjHeatmapPlottable == null) return; + if (_finopsObjHeatmapMatrix == null || _finopsObjHeatmapMatrix.IsEmpty) return; + + var now = DateTime.UtcNow; + if ((now - _finopsLastObjHeatmapHover).TotalMilliseconds < 50) return; + _finopsLastObjHeatmapHover = now; + + var pos = e.GetPosition(FinOpsObjectGrowthHeatmapChart); + var dpi = VisualTreeHelper.GetDpi(FinOpsObjectGrowthHeatmapChart); + var pixel = new ScottPlot.Pixel((float)(pos.X * dpi.DpiScaleX), (float)(pos.Y * dpi.DpiScaleY)); + var coords = FinOpsObjectGrowthHeatmapChart.Plot.GetCoordinates(pixel); + + int numRows = _finopsObjHeatmapMatrix.Intensities.GetLength(0); + int numCols = _finopsObjHeatmapMatrix.Intensities.GetLength(1); + + var (col, rowIdx) = _finopsObjHeatmapPlottable.GetIndexes(coords); + int row = (numRows - 1) - rowIdx; // FlipVertically + + if (row < 0 || row >= numRows || col < 0 || col >= numCols) + { + _finopsObjHeatmapPopup.IsOpen = false; + return; + } + + double mb = _finopsObjHeatmapMatrix.Intensities[row, col]; + if (mb <= 0) + { + _finopsObjHeatmapPopup.IsOpen = false; + return; + } + + var label = row < _finopsObjHeatmapMatrix.RowLabels.Length ? _finopsObjHeatmapMatrix.RowLabels[row] : "?"; + var day = _finopsObjHeatmapMatrix.Days[col]; + _finopsObjHeatmapPopupText.Text = $"{label} | {day:M/d} | {mb:N1} MB reserved"; + + _finopsObjHeatmapPopup.HorizontalOffset = pos.X + 15; + _finopsObjHeatmapPopup.VerticalOffset = pos.Y + 15; + _finopsObjHeatmapPopup.IsOpen = true; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Recommendations.cs b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Recommendations.cs new file mode 100644 index 000000000..57811be7a --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Recommendations.cs @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Threading.Tasks; +using System.Windows; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// FinOps Recommendations sub-tab — the last deferred FinOps sub-tab, reproduced MONITOR-SIDE. Lite's +/// Recommendations mixed collected reads with LIVE target queries; the headless viewer runs every check over the +/// already-collected Postgres store (<see cref="ViewerDataService.GetRecommendationsAsync"/>) — NO live SQL. This +/// partial projects the resulting rows (Category / Severity / Confidence / Finding / Detail / Est. Savings, sorted +/// by severity in the read) into the grid; the selected server's monthly budget (<c>_server.MonthlyCostUsd</c>) +/// drives the savings estimates (0 → the Est. Savings column stays blank, mirroring Lite). Load-on-view + a +/// Refresh button, like the other FinOps sub-tabs. +/// </summary> +public partial class FinOpsTab +{ + /// <summary>Reads the collected recommendations for this server and repaints the grid + count indicator.</summary> + private async Task LoadFinOpsRecommendationsAsync() + { + var data = await _dataService.GetRecommendationsAsync(_server.ServerId, _server.MonthlyCostUsd); + + _finopsRecommendationsFilterMgr!.UpdateData(data); + FinOpsRecommendationsNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + FinOpsRecommendationsCountIndicator.Text = data.Count > 0 + ? $"{data.Count} recommendation(s)" + : "no recommendations — this server looks right-sized"; + } + + /// <summary>Refresh button — re-runs the checks over the latest collected data with the shell's status-bar error surfacing.</summary> + private async void FinOpsRefreshRecommendations_Click(object sender, RoutedEventArgs e) + => await RunFinOpsLoad(LoadFinOpsRecommendationsAsync); +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml new file mode 100644 index 000000000..4e2206ac7 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml @@ -0,0 +1,1101 @@ +<UserControl x:Class="PerformanceMonitor.Darling.Viewer.FinOpsTab" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:local="clr-namespace:PerformanceMonitor.Darling.Viewer" + xmlns:scottplot="clr-namespace:ScottPlot.WPF;assembly=ScottPlot.WPF" + xmlns:ui="clr-namespace:PerformanceMonitor.Ui;assembly=PerformanceMonitor.Ui" + Background="Transparent"> + <!-- + The FinOps top-level aggregate tab (a 4th MainTabs tab beside Overview / Recommendations / Alerts), + restoring Lite's cross-server FinOps shape: its OWN server selector (independent of the sidebar, + mirroring the Recommendations tab) drives the eleven per-server sub-tabs, while Server Inventory stays + cross-server and lives ONCE here (no longer duplicated inside every server tab). The sub-tab content is + the copy-parity FinOps surface relocated verbatim out of the per-server ViewerServerTab; only the + server-selector header is new. The grids keep the hardcoded Dark styling (pure relocation); Only the + ACTIVE sub-tab loads (Lite's visible-only rule) and the shell's fleet-refresh timer refreshes it. + --> + <UserControl.Resources> + <Style x:Key="EmptyHint" TargetType="TextBlock"> + <Setter Property="Foreground" Value="{DynamicResource ForegroundMutedBrush}"/> + <Setter Property="HorizontalAlignment" Value="Center"/> + <Setter Property="VerticalAlignment" Value="Center"/> + <Setter Property="TextWrapping" Value="Wrap"/> + <Setter Property="TextAlignment" Value="Center"/> + <Setter Property="MaxWidth" Value="480"/> + <Setter Property="IsHitTestVisible" Value="False"/> + <Setter Property="Visibility" Value="Collapsed"/> + </Style> + + <!-- Dark grid chrome shared by the FinOps grids (copied from ViewerServerTab.xaml — same setters, + zero visual change). --> + <Style x:Key="DarkGridHeader" TargetType="DataGridColumnHeader"> + <Setter Property="Background" Value="{DynamicResource ViewerGridHeaderBrush}"/> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="0,0,1,1"/> + <Setter Property="Padding" Value="8,5"/> + <Setter Property="FontWeight" Value="SemiBold"/> + </Style> + + <Style x:Key="DarkGridRow" TargetType="DataGridRow"> + <Setter Property="Background" Value="Transparent"/> + <Style.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter Property="Background" Value="{DynamicResource ViewerHoverBrush}"/> + </Trigger> + </Style.Triggers> + </Style> + + <Style x:Key="DarkGridCell" TargetType="DataGridCell"> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Style.Triggers> + <Trigger Property="IsSelected" Value="True"> + <Setter Property="Background" Value="{DynamicResource ViewerSelectionBrush}"/> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + </Trigger> + </Style.Triggers> + </Style> + + <Style x:Key="DarkGrid" TargetType="DataGrid"> + <!-- DarkGrid has no BasedOn, so it must re-declare the implicit DataGrid style's behaviors or grids using it silently lose them (sort-descending-first, header-copy, middle-click panning). --> + <Setter Property="ui:DataGridClipboardBehavior.FixHeaderCopy" Value="True"/> + <Setter Property="ui:DataGridSortBehavior.SortDescendingFirst" Value="True"/> + <Setter Property="ui:ScrollPanBehavior.EnableMiddleClickPanning" Value="True"/> + <Setter Property="AutoGenerateColumns" Value="False"/> + <Setter Property="IsReadOnly" Value="True"/> + <Setter Property="CanUserAddRows" Value="False"/> + <Setter Property="HeadersVisibility" Value="Column"/> + <Setter Property="SelectionMode" Value="Single"/> + <Setter Property="SelectionUnit" Value="FullRow"/> + <Setter Property="GridLinesVisibility" Value="Horizontal"/> + <Setter Property="HorizontalGridLinesBrush" Value="{DynamicResource BorderBrush}"/> + <Setter Property="Background" Value="{DynamicResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="RowBackground" Value="{DynamicResource BackgroundDarkBrush}"/> + <Setter Property="AlternatingRowBackground" Value="{DynamicResource ViewerAltRowBrush}"/> + <Setter Property="ColumnHeaderStyle" Value="{StaticResource DarkGridHeader}"/> + <Setter Property="RowStyle" Value="{StaticResource DarkGridRow}"/> + <Setter Property="CellStyle" Value="{StaticResource DarkGridCell}"/> + </Style> + + <!-- Server-selector header chrome (copied from MainWindow.xaml's DarkCombo / DarkButton so the + selector + Refresh match the Recommendations tab's own selector exactly). --> + <Style x:Key="DarkComboItem" TargetType="ComboBoxItem"> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="Padding" Value="6,4"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="ComboBoxItem"> + <Border x:Name="Bd" Background="{TemplateBinding Background}" Padding="{TemplateBinding Padding}"> + <ContentPresenter/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="Bd" Property="Background" Value="{DynamicResource ViewerHoverBrush}"/> + </Trigger> + <Trigger Property="IsHighlighted" Value="True"> + <Setter TargetName="Bd" Property="Background" Value="{DynamicResource ViewerSelectionBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <Style x:Key="DarkButton" TargetType="Button"> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Setter Property="Background" Value="{DynamicResource ViewerSelectionBrush}"/> + <Setter Property="BorderBrush" Value="{DynamicResource ViewerButtonBorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Cursor" Value="Hand"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="Button"> + <Border x:Name="Bd" Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" + CornerRadius="3" Padding="{TemplateBinding Padding}"> + <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="Bd" Property="Background" Value="{DynamicResource ViewerButtonBorderBrush}"/> + </Trigger> + <Trigger Property="IsEnabled" Value="False"> + <Setter TargetName="Bd" Property="Background" Value="{DynamicResource ViewerDisabledBgBrush}"/> + <Setter Property="Foreground" Value="{DynamicResource ViewerDisabledTextBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <Style x:Key="DarkCombo" TargetType="ComboBox"> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Setter Property="Background" Value="{DynamicResource BackgroundDarkBrush}"/> + <Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Padding" Value="6,4"/> + <Setter Property="ItemContainerStyle" Value="{StaticResource DarkComboItem}"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="ComboBox"> + <Grid> + <ToggleButton x:Name="ToggleButton" Focusable="False" + IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}" + ClickMode="Press"> + <ToggleButton.Template> + <ControlTemplate TargetType="ToggleButton"> + <Border Background="{DynamicResource BackgroundDarkBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="3"> + <Grid> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*"/> + <ColumnDefinition Width="20"/> + </Grid.ColumnDefinitions> + <Path Grid.Column="1" HorizontalAlignment="Center" VerticalAlignment="Center" + Data="M 0 0 L 4 4 L 8 0 Z" Fill="{DynamicResource ForegroundMutedBrush}"/> + </Grid> + </Border> + </ControlTemplate> + </ToggleButton.Template> + </ToggleButton> + <ContentPresenter x:Name="ContentSite" IsHitTestVisible="False" + Content="{TemplateBinding SelectionBoxItem}" + ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}" + Margin="{TemplateBinding Padding}" + VerticalAlignment="Center" HorizontalAlignment="Left"/> + <Popup x:Name="PART_Popup" Placement="Bottom" IsOpen="{TemplateBinding IsDropDownOpen}" + AllowsTransparency="True" Focusable="False" PopupAnimation="Slide"> + <Border Background="{DynamicResource ViewerAltRowBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" + MinWidth="{TemplateBinding ActualWidth}" MaxHeight="240"> + <ScrollViewer> + <StackPanel IsItemsHost="True"/> + </ScrollViewer> + </Border> + </Popup> + </Grid> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- FinOps tab (copy-parity program). The four *_wait_in_ms cells in the Locking grid are shaded by + this shared .Ui converter; the copy/export context menus reuse this control's DataGridExport + handlers (FinOpsTab.xaml.cs). Lite's FinOps "View Plan" IS ported for the High Impact / Expensive + Queries grids (their rows carry the stored query_stats.query_plan_xml — see FinOpsQueryPlanContextMenu + below); only Lite's "Get Actual Plan" (a LIVE re-execution) has no headless-viewer equivalent and + stays omitted everywhere, as on every other viewer grid. --> + <ui:HeatIntensityToBrushConverter x:Key="FinOpsHeatBrush"/> + + <ContextMenu x:Key="FinOpsGridContextMenu"> + <MenuItem Header="Copy Cell" Click="CopyCell_Click"><MenuItem.Icon><TextBlock Text="📋"/></MenuItem.Icon></MenuItem> + <MenuItem Header="Copy Row" Click="CopyRow_Click"><MenuItem.Icon><TextBlock Text="📄"/></MenuItem.Icon></MenuItem> + <MenuItem Header="Copy All Rows" Click="CopyAllRows_Click"><MenuItem.Icon><TextBlock Text="📑"/></MenuItem.Icon></MenuItem> + <Separator/> + <MenuItem Header="Export to CSV..." Click="ExportToCsv_Click"><MenuItem.Icon><TextBlock Text="📊"/></MenuItem.Icon></MenuItem> + </ContextMenu> + <Style x:Key="FinOpsRowStyle" TargetType="DataGridRow" BasedOn="{StaticResource DarkGridRow}"> + <Setter Property="ContextMenu" Value="{StaticResource FinOpsGridContextMenu}"/> + </Style> + + <!-- Storage Growth parent grid: base copy/export + the #1138 drill entry into the per-object heatmap. --> + <ContextMenu x:Key="FinOpsStorageGrowthContextMenu"> + <MenuItem Header="Copy Cell" Click="CopyCell_Click"><MenuItem.Icon><TextBlock Text="📋"/></MenuItem.Icon></MenuItem> + <MenuItem Header="Copy Row" Click="CopyRow_Click"><MenuItem.Icon><TextBlock Text="📄"/></MenuItem.Icon></MenuItem> + <MenuItem Header="Copy All Rows" Click="CopyAllRows_Click"><MenuItem.Icon><TextBlock Text="📑"/></MenuItem.Icon></MenuItem> + <Separator/> + <MenuItem Header="Export to CSV..." Click="ExportToCsv_Click"><MenuItem.Icon><TextBlock Text="📊"/></MenuItem.Icon></MenuItem> + <Separator/> + <MenuItem Header="Show objects" Click="FinOpsStorageGrowthShowObjects_Click"><MenuItem.Icon><TextBlock Text="🔎"/></MenuItem.Icon></MenuItem> + </ContextMenu> + <Style x:Key="FinOpsStorageGrowthRowStyle" TargetType="DataGridRow" BasedOn="{StaticResource DarkGridRow}"> + <Setter Property="ContextMenu" Value="{StaticResource FinOpsStorageGrowthContextMenu}"/> + </Style> + + <!-- Plan-enabled variant for High Impact + Expensive Queries: their rows carry the stored + query_stats.query_plan_xml (Darling captures plans), so "View Plan" raises PlanRequested, which the + shell routes into the standalone Plan Viewer — the same stored-plan surface as Top Queries. "Get + Actual Plan" (a LIVE re-execution via the service) is enabled on High Impact rows only, gated on + CanGetActualPlan — the Expensive Queries grid groups by text and carries no query_hash, so its rows + fall back to disabled (no store key to resolve the re-executable text by). --> + <ContextMenu x:Key="FinOpsQueryPlanContextMenu"> + <MenuItem Header="Copy Cell" Click="CopyCell_Click"><MenuItem.Icon><TextBlock Text="📋"/></MenuItem.Icon></MenuItem> + <MenuItem Header="Copy Row" Click="CopyRow_Click"><MenuItem.Icon><TextBlock Text="📄"/></MenuItem.Icon></MenuItem> + <MenuItem Header="Copy All Rows" Click="CopyAllRows_Click"><MenuItem.Icon><TextBlock Text="📑"/></MenuItem.Icon></MenuItem> + <Separator/> + <MenuItem Header="Export to CSV..." Click="ExportToCsv_Click"><MenuItem.Icon><TextBlock Text="📊"/></MenuItem.Icon></MenuItem> + <Separator/> + <MenuItem Header="View Stored Plan" Click="FinOpsViewPlan_Click"><MenuItem.Icon><TextBlock Text="🔍"/></MenuItem.Icon></MenuItem> + <MenuItem Header="Get Actual Plan (re-run)" Click="FinOpsGetActualPlan_Click" + IsEnabled="{Binding PlacementTarget.DataContext.CanGetActualPlan, RelativeSource={RelativeSource AncestorType=ContextMenu}, FallbackValue=False}"> + <MenuItem.Icon><TextBlock Text="▶"/></MenuItem.Icon> + </MenuItem> + </ContextMenu> + <Style x:Key="FinOpsQueryPlanRowStyle" TargetType="DataGridRow" BasedOn="{StaticResource DarkGridRow}"> + <Setter Property="ContextMenu" Value="{StaticResource FinOpsQueryPlanContextMenu}"/> + </Style> + </UserControl.Resources> + + <Grid Margin="0,8,0,0"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + + <!-- Header bar: own server selector (independent of the sidebar, mirroring the Recommendations tab) + Refresh. --> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,8"> + <TextBlock Text="Server:" VerticalAlignment="Center" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,0,6,0"/> + <ComboBox x:Name="ServerSelector" Width="260" VerticalAlignment="Center" + Style="{StaticResource DarkCombo}" + SelectionChanged="ServerSelector_SelectionChanged"> + <ComboBox.ItemTemplate> + <DataTemplate> + <TextBlock Text="{Binding DisplayName}"/> + </DataTemplate> + </ComboBox.ItemTemplate> + </ComboBox> + <Button Content="Refresh" Margin="10,0,0,0" Padding="12,4" + Click="FinOpsRefresh_Click" + ToolTip="Reload FinOps data for the selected server"/> + </StackPanel> + + <Grid Grid.Row="1"> + <TabControl x:Name="FinOpsSubTabControl" Background="Transparent" BorderThickness="0" + ItemContainerStyle="{DynamicResource SubTabItemStyle}" + SelectionChanged="FinOpsSubTabs_SelectionChanged"> + + <!-- Utilization Sub-Tab --> + <TabItem Header="Utilization"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="10,8" Height="36"> + <TextBlock Text="Utilization Efficiency" VerticalAlignment="Center" Margin="0,0,10,0" + FontWeight="Bold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="FinOpsRefreshUtilization_Click" Margin="12,0,0,0" Padding="10,4" MinWidth="70"/> + <Border x:Name="FinOpsProvisioningStatusBorder" CornerRadius="4" Padding="8,2" Margin="12,0,0,0" + VerticalAlignment="Center" Background="Gray"> + <TextBlock x:Name="FinOpsProvisioningStatusText" Text="No Data" FontWeight="Bold" Foreground="White"/> + </Border> + <Border x:Name="FinOpsHealthScoreBorder" CornerRadius="4" Padding="6,2" Margin="8,0,0,0" + VerticalAlignment="Center" Background="Gray" Visibility="Collapsed"> + <TextBlock x:Name="FinOpsHealthScoreText" Text="" FontWeight="Bold" Foreground="White" FontSize="12"/> + </Border> + <Border Background="{DynamicResource BackgroundLightBrush}" CornerRadius="4" Padding="10,4" Margin="8,0,0,0" + x:Name="FinOpsComputeCostCard" VerticalAlignment="Center" Visibility="Collapsed"> + <TextBlock Foreground="{DynamicResource ForegroundBrush}" FontSize="12"><Run Text="Budget: " FontWeight="SemiBold"/><Run x:Name="FinOpsAnnualComputeCostText" Text="$0"/></TextBlock> + </Border> + <Border Background="{DynamicResource BackgroundLightBrush}" CornerRadius="4" Padding="10,4" Margin="8,0,0,0" + x:Name="FinOpsTotalCostCard" VerticalAlignment="Center" Visibility="Collapsed"> + <TextBlock Foreground="{DynamicResource ForegroundBrush}" FontSize="12" FontWeight="Bold"><Run Text="Annual: "/><Run x:Name="FinOpsAnnualTotalCostText" Text="$0"/></TextBlock> + </Border> + </StackPanel> + + <Grid Grid.Row="1" Margin="10,0,10,0" x:Name="FinOpsUtilizationContent"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*"/> + <ColumnDefinition Width="*"/> + </Grid.ColumnDefinitions> + + <GroupBox Grid.Column="0" Header="CPU" Margin="0,0,5,0" VerticalAlignment="Top" Foreground="{DynamicResource ForegroundBrush}"> + <StackPanel Margin="10"> + <Grid Margin="0,0,0,8"> + <Grid.ColumnDefinitions><ColumnDefinition Width="90"/><ColumnDefinition Width="*"/><ColumnDefinition Width="60"/></Grid.ColumnDefinitions> + <TextBlock Grid.Column="0" Text="Avg CPU" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundBrush}" VerticalAlignment="Center"/> + <Grid Grid.Column="1" Margin="0,0,8,0"> + <Border Height="16" CornerRadius="3" Background="#1AFFFFFF"/> + <Grid> + <Grid.ColumnDefinitions><ColumnDefinition x:Name="FinOpsAvgCpuFilled" Width="0*"/><ColumnDefinition x:Name="FinOpsAvgCpuEmpty" Width="100*"/></Grid.ColumnDefinitions> + <Border Grid.Column="0" Height="16" CornerRadius="3" x:Name="FinOpsAvgCpuBar" Background="#27AE60"/> + </Grid> + </Grid> + <TextBlock x:Name="FinOpsAvgCpuText" Grid.Column="2" Text="-" Foreground="{DynamicResource ForegroundBrush}" HorizontalAlignment="Right" VerticalAlignment="Center"/> + </Grid> + <Grid Margin="0,0,0,8"> + <Grid.ColumnDefinitions><ColumnDefinition Width="90"/><ColumnDefinition Width="*"/><ColumnDefinition Width="60"/></Grid.ColumnDefinitions> + <TextBlock Grid.Column="0" Text="P95 CPU" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundBrush}" VerticalAlignment="Center"/> + <Grid Grid.Column="1" Margin="0,0,8,0"> + <Border Height="16" CornerRadius="3" Background="#1AFFFFFF"/> + <Grid> + <Grid.ColumnDefinitions><ColumnDefinition x:Name="FinOpsP95CpuFilled" Width="0*"/><ColumnDefinition x:Name="FinOpsP95CpuEmpty" Width="100*"/></Grid.ColumnDefinitions> + <Border Grid.Column="0" Height="16" CornerRadius="3" x:Name="FinOpsP95CpuBar" Background="#27AE60"/> + </Grid> + </Grid> + <TextBlock x:Name="FinOpsP95CpuText" Grid.Column="2" Text="-" Foreground="{DynamicResource ForegroundBrush}" HorizontalAlignment="Right" VerticalAlignment="Center"/> + </Grid> + <Grid Margin="0,0,0,8"> + <Grid.ColumnDefinitions><ColumnDefinition Width="90"/><ColumnDefinition Width="*"/><ColumnDefinition Width="60"/></Grid.ColumnDefinitions> + <TextBlock Grid.Column="0" Text="Max CPU" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundBrush}" VerticalAlignment="Center"/> + <Grid Grid.Column="1" Margin="0,0,8,0"> + <Border Height="16" CornerRadius="3" Background="#1AFFFFFF"/> + <Grid> + <Grid.ColumnDefinitions><ColumnDefinition x:Name="FinOpsMaxCpuFilled" Width="0*"/><ColumnDefinition x:Name="FinOpsMaxCpuEmpty" Width="100*"/></Grid.ColumnDefinitions> + <Border Grid.Column="0" Height="16" CornerRadius="3" x:Name="FinOpsMaxCpuBar" Background="#27AE60"/> + </Grid> + </Grid> + <TextBlock x:Name="FinOpsMaxCpuText" Grid.Column="2" Text="-" Foreground="{DynamicResource ForegroundBrush}" HorizontalAlignment="Right" VerticalAlignment="Center"/> + </Grid> + <TextBlock Foreground="{DynamicResource ForegroundMutedBrush}" FontSize="11.5" Margin="0,2,0,0"> + <Run x:Name="FinOpsCpuCountText" Text="-"/><Run Text=" CPUs,"/> + <Run x:Name="FinOpsWorkerThreadsText" Text="-"/><Run Text=" workers,"/> + <Run x:Name="FinOpsCpuSamplesText" Text="-"/><Run Text=" samples (24h)"/> + </TextBlock> + </StackPanel> + </GroupBox> + + <GroupBox Grid.Column="1" Header="Memory" Margin="5,0,0,0" VerticalAlignment="Top" Foreground="{DynamicResource ForegroundBrush}"> + <StackPanel Margin="10"> + <Grid Margin="0,0,0,8"> + <Grid.ColumnDefinitions><ColumnDefinition Width="120"/><ColumnDefinition Width="*"/><ColumnDefinition Width="60"/></Grid.ColumnDefinitions> + <TextBlock Grid.Column="0" Text="Stolen Mem %" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundBrush}" VerticalAlignment="Center" + ToolTip="(Total Server Memory - Buffer Pool) / Total Server Memory — memory used by plan cache, locks, grants, CLR, etc."/> + <Grid Grid.Column="1" Margin="0,0,8,0"> + <Border Height="16" CornerRadius="3" Background="#1AFFFFFF"/> + <Grid> + <Grid.ColumnDefinitions><ColumnDefinition x:Name="FinOpsMemUtilFilled" Width="0*"/><ColumnDefinition x:Name="FinOpsMemUtilEmpty" Width="100*"/></Grid.ColumnDefinitions> + <Border Grid.Column="0" Height="16" CornerRadius="3" x:Name="FinOpsMemoryUtilBar" Background="#3498DB"/> + </Grid> + </Grid> + <TextBlock x:Name="FinOpsMemoryUtilText" Grid.Column="2" Text="-" Foreground="{DynamicResource ForegroundBrush}" HorizontalAlignment="Right" VerticalAlignment="Center"/> + </Grid> + <Grid Margin="0,0,0,8"> + <Grid.ColumnDefinitions><ColumnDefinition Width="120"/><ColumnDefinition Width="*"/><ColumnDefinition Width="60"/></Grid.ColumnDefinitions> + <TextBlock Grid.Column="0" Text="Buffer Pool %" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundBrush}" VerticalAlignment="Center" + ToolTip="Buffer Pool / Physical Memory — how much of physical RAM is used by the buffer pool"/> + <Grid Grid.Column="1" Margin="0,0,8,0"> + <Border Height="16" CornerRadius="3" Background="#1AFFFFFF"/> + <Grid> + <Grid.ColumnDefinitions><ColumnDefinition x:Name="FinOpsMemRatioFilled" Width="0*"/><ColumnDefinition x:Name="FinOpsMemRatioEmpty" Width="100*"/></Grid.ColumnDefinitions> + <Border Grid.Column="0" Height="16" CornerRadius="3" x:Name="FinOpsMemoryRatioBar" Background="#3498DB"/> + </Grid> + </Grid> + <TextBlock x:Name="FinOpsMemoryRatioText" Grid.Column="2" Text="-" Foreground="{DynamicResource ForegroundBrush}" HorizontalAlignment="Right" VerticalAlignment="Center"/> + </Grid> + <Grid Margin="0,4,0,0"> + <Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="*"/></Grid.ColumnDefinitions> + <StackPanel Grid.Column="0"> + <TextBlock Foreground="{DynamicResource ForegroundMutedBrush}" FontSize="11.5"><Run Text="Physical: " FontWeight="SemiBold"/><Run x:Name="FinOpsPhysicalMemoryText" Text="-"/></TextBlock> + <TextBlock Foreground="{DynamicResource ForegroundMutedBrush}" FontSize="11.5" Margin="0,2,0,0"><Run Text="Target: " FontWeight="SemiBold"/><Run x:Name="FinOpsTargetMemoryText" Text="-"/></TextBlock> + </StackPanel> + <StackPanel Grid.Column="1"> + <TextBlock Foreground="{DynamicResource ForegroundMutedBrush}" FontSize="11.5"><Run Text="Total: " FontWeight="SemiBold"/><Run x:Name="FinOpsTotalMemoryText" Text="-"/></TextBlock> + <TextBlock Foreground="{DynamicResource ForegroundMutedBrush}" FontSize="11.5" Margin="0,2,0,0"><Run Text="Buffer Pool: " FontWeight="SemiBold"/><Run x:Name="FinOpsBufferPoolText" Text="-"/></TextBlock> + </StackPanel> + </Grid> + </StackPanel> + </GroupBox> + </Grid> + + <TextBlock x:Name="FinOpsClassificationExplanation" Grid.Row="2" Margin="12,8,10,4" VerticalAlignment="Top" + TextWrapping="Wrap" FontSize="12" Foreground="{DynamicResource ForegroundMutedBrush}"/> + + <Expander Header="7-Day Provisioning Trend" IsExpanded="False" Grid.Row="3" Margin="10,4,10,0"> + <DataGrid x:Name="FinOpsProvisioningTrendGrid" Style="{StaticResource DarkGrid}" RowStyle="{StaticResource FinOpsRowStyle}" + CanUserSortColumns="False" MaxHeight="200"> + <DataGrid.Columns> + <DataGridTextColumn Header="Day" Binding="{Binding DayDisplay}" Width="100"/> + <DataGridTextColumn Header="Status" Binding="{Binding StatusDisplay}" Width="130"> + <DataGridTextColumn.ElementStyle> + <Style TargetType="TextBlock"> + <Style.Triggers> + <DataTrigger Binding="{Binding Status}" Value="RIGHT_SIZED"><Setter Property="Foreground" Value="#27AE60"/><Setter Property="FontWeight" Value="SemiBold"/></DataTrigger> + <DataTrigger Binding="{Binding Status}" Value="OVER_PROVISIONED"><Setter Property="Foreground" Value="#F39C12"/><Setter Property="FontWeight" Value="SemiBold"/></DataTrigger> + <DataTrigger Binding="{Binding Status}" Value="UNDER_PROVISIONED"><Setter Property="Foreground" Value="#E74C3C"/><Setter Property="FontWeight" Value="SemiBold"/></DataTrigger> + </Style.Triggers> + </Style> + </DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Header="Avg CPU %" Binding="{Binding AvgCpuPct, StringFormat=N1}" Width="90" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="P95 CPU %" Binding="{Binding P95CpuPct, StringFormat=N1}" Width="90" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Max CPU %" Binding="{Binding MaxCpuPct}" Width="90" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Mem Ratio" Binding="{Binding MemoryRatio, StringFormat=N2}" Width="90" ElementStyle="{StaticResource NumericCell}"/> + </DataGrid.Columns> + </DataGrid> + </Expander> + + <ScrollViewer Grid.Row="4" VerticalScrollBarVisibility="Auto"> + <Grid x:Name="FinOpsSummaryContent"> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/></Grid.RowDefinitions> + <Grid Grid.Row="0" Margin="10,4,10,0"> + <Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="*"/></Grid.ColumnDefinitions> + <GroupBox Grid.Column="0" Header="Top Databases by Total CPU" Margin="0,0,5,0" Foreground="{DynamicResource ForegroundBrush}"> + <DataGrid x:Name="FinOpsTopTotalGrid" Style="{StaticResource DarkGrid}" RowStyle="{StaticResource FinOpsRowStyle}" MinHeight="40" MaxHeight="200"> + <DataGrid.Columns> + <DataGridTextColumn Header="Database" Binding="{Binding DatabaseName}" Width="*"/> + <DataGridTextColumn Header="CPU %" Binding="{Binding PctCpu, StringFormat=N1}" Width="60" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="IO %" Binding="{Binding PctIo, StringFormat=N1}" Width="55" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="CPU (ms)" Binding="{Binding CpuTimeMs, StringFormat=N0}" Width="80" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Execs" Binding="{Binding ExecutionCount, StringFormat=N0}" Width="70" ElementStyle="{StaticResource NumericCell}"/> + </DataGrid.Columns> + </DataGrid> + </GroupBox> + <GroupBox Grid.Column="1" Header="Top Databases by Avg CPU / Execution" Margin="5,0,0,0" Foreground="{DynamicResource ForegroundBrush}"> + <DataGrid x:Name="FinOpsTopAvgGrid" Style="{StaticResource DarkGrid}" RowStyle="{StaticResource FinOpsRowStyle}" MinHeight="40" MaxHeight="200"> + <DataGrid.Columns> + <DataGridTextColumn Header="Database" Binding="{Binding DatabaseName}" Width="*"/> + <DataGridTextColumn Header="Avg CPU (ms)" Binding="{Binding CpuTimeMs, StringFormat=N2}" Width="95" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Avg IO (MB)" Binding="{Binding AvgIoMb, StringFormat=N2}" Width="90" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Execs" Binding="{Binding ExecutionCount, StringFormat=N0}" Width="70" ElementStyle="{StaticResource NumericCell}"/> + </DataGrid.Columns> + </DataGrid> + </GroupBox> + </Grid> + <GroupBox Grid.Row="1" Header="Database Sizes — Allocated vs Used" Margin="10,8,10,10" Foreground="{DynamicResource ForegroundBrush}"> + <ItemsControl x:Name="FinOpsDbSizeChart" Margin="8,4,8,4"> + <ItemsControl.ItemTemplate> + <DataTemplate> + <Grid Margin="0,2" Height="22"> + <Grid.ColumnDefinitions><ColumnDefinition Width="160"/><ColumnDefinition Width="*"/><ColumnDefinition Width="90"/></Grid.ColumnDefinitions> + <TextBlock Grid.Column="0" Text="{Binding DatabaseName}" VerticalAlignment="Center" FontSize="11.5" Foreground="{DynamicResource ForegroundBrush}" TextTrimming="CharacterEllipsis"/> + <Grid Grid.Column="1" Margin="4,2"> + <Border CornerRadius="3" Background="#1AFFFFFF"/> + <Grid> + <Grid.ColumnDefinitions><ColumnDefinition Width="{Binding UsedStarWidth}"/><ColumnDefinition Width="{Binding FreeStarWidth}"/></Grid.ColumnDefinitions> + <Border Grid.Column="0" CornerRadius="3" Background="#3498DB"/> + </Grid> + </Grid> + <TextBlock Grid.Column="2" VerticalAlignment="Center" HorizontalAlignment="Right" FontSize="11.5" Foreground="{DynamicResource ForegroundBrush}"> + <TextBlock.Text> + <MultiBinding StringFormat="{}{0:N0} / {1:N0} MB"><Binding Path="UsedMb"/><Binding Path="TotalMb"/></MultiBinding> + </TextBlock.Text> + </TextBlock> + </Grid> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + </GroupBox> + </Grid> + </ScrollViewer> + + <TextBlock x:Name="FinOpsNoUtilizationMessage" Grid.Row="1" Grid.RowSpan="4" Style="{StaticResource EmptyHint}" + Text="No utilization data collected yet for this server." VerticalAlignment="Top" Margin="0,20,0,0"/> + </Grid> + </TabItem> + + <!-- Database Resources Sub-Tab --> + <TabItem Header="Database Resources"> + <Grid> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="10,8" Height="36"> + <TextBlock Text="Database Resource Usage" VerticalAlignment="Center" Margin="0,0,10,0" FontWeight="Bold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <ComboBox x:Name="FinOpsResourceUsageTimeRangeCombo" SelectedIndex="3" Width="120" Margin="0,0,8,0" SelectionChanged="FinOpsResourceUsageTimeRange_Changed"> + <ComboBoxItem Content="Last 1 hour"/><ComboBoxItem Content="Last 4 hours"/><ComboBoxItem Content="Last 12 hours"/><ComboBoxItem Content="Last 24 hours"/><ComboBoxItem Content="Last 7 days"/> + </ComboBox> + <Button Content="Refresh" Click="FinOpsRefreshDatabaseResources_Click" Margin="4,0,0,0" Padding="10,4" MinWidth="70"/> + <TextBlock x:Name="FinOpsDbResourcesCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + </StackPanel> + <Grid Grid.Row="1" Margin="10,0,10,10"> + <DataGrid x:Name="FinOpsDatabaseResourcesDataGrid" Style="{StaticResource DarkGrid}" RowStyle="{StaticResource FinOpsRowStyle}" HorizontalScrollBarVisibility="Auto"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding DatabaseName}" Width="180"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DatabaseName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Database" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CpuTimeMs, StringFormat=N0}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CpuTimeMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="CPU Time (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PctCpuShare, StringFormat=N1}" Width="70" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PctCpuShare" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="CPU %" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LogicalReads, StringFormat=N0}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LogicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Logical Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PhysicalReads, StringFormat=N0}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PhysicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Physical Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LogicalWrites, StringFormat=N0}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LogicalWrites" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Logical Writes" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ExecutionCount, StringFormat=N0}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ExecutionCount" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Executions" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding IoReadMb, StringFormat=N2}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IoReadMb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="IO Read MB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding IoWriteMb, StringFormat=N2}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IoWriteMb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="IO Write MB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PctIoShare, StringFormat=N1}" Width="70" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PctIoShare" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="IO %" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding IoStallMs, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IoStallMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="IO Stall (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="FinOpsNoDatabaseResourcesMessage" Style="{StaticResource EmptyHint}" Text="No database resource data collected yet for this server."/> + </Grid> + </Grid> + </TabItem> + + <!-- Storage Growth Sub-Tab --> + <TabItem Header="Storage Growth"> + <Grid> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="10,8" Height="36"> + <Button x:Name="FinOpsStorageBackButton" Content="← Back" Click="FinOpsStorageGrowthBack_Click" Visibility="Collapsed" Margin="0,0,10,0" Padding="10,4" MinWidth="70"/> + <TextBlock x:Name="FinOpsStorageBreadcrumb" Text="Storage Growth" VerticalAlignment="Center" Margin="0,0,10,0" FontWeight="Bold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="FinOpsRefreshStorageGrowth_Click" Margin="12,0,0,0" Padding="10,4" MinWidth="70"/> + <TextBlock x:Name="FinOpsStorageGrowthCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + <TextBlock Text="Window:" VerticalAlignment="Center" Margin="16,0,6,0" FontSize="11" x:Name="FinOpsObjectHeatmapWindowLabel" Visibility="Collapsed" Foreground="{DynamicResource ForegroundMutedBrush}"/> + <ComboBox x:Name="FinOpsObjectHeatmapWindowCombo" Visibility="Collapsed" SelectedIndex="1" Width="120" FontSize="11" Padding="4,2" SelectionChanged="FinOpsObjectHeatmapWindow_Changed"> + <ComboBoxItem Content="Last 7 days"/><ComboBoxItem Content="Last 30 days"/><ComboBoxItem Content="Last 90 days"/> + </ComboBox> + </StackPanel> + <Grid Grid.Row="1" Margin="10,0,10,10"> + <Grid x:Name="FinOpsStorageParentView"> + <DataGrid x:Name="FinOpsStorageGrowthDataGrid" Style="{StaticResource DarkGrid}" RowStyle="{StaticResource FinOpsStorageGrowthRowStyle}" MouseDoubleClick="FinOpsStorageGrowthGrid_DoubleClick"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding DatabaseName}" Width="180"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DatabaseName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Database" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CurrentSizeMb, StringFormat=N2}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CurrentSizeMb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Current Size MB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Size7dAgoMb, StringFormat=N2}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Size7dAgoMb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Size 7d Ago MB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Size30dAgoMb, StringFormat=N2}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Size30dAgoMb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Size 30d Ago MB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Growth7dMb, StringFormat=N2}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Growth7dMb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Growth 7d MB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Growth30dMb, StringFormat=N2}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Growth30dMb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Growth 30d MB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DailyGrowthRateMb, StringFormat=N2}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DailyGrowthRateMb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Daily Rate MB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding GrowthPct30d, StringFormat=N1}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="GrowthPct30d" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Growth % 30d" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="FinOpsNoStorageGrowthMessage" Style="{StaticResource EmptyHint}" Text="No storage growth data available yet"/> + </Grid> + + <Grid x:Name="FinOpsStorageObjectView" Visibility="Collapsed"> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="2*"/><RowDefinition Height="3*"/></Grid.RowDefinitions> + <TextBlock Grid.Row="0" TextWrapping="Wrap" Margin="0,0,0,4" FontSize="11" FontStyle="Italic" Foreground="{DynamicResource ForegroundMutedBrush}" + Text="Object / reserved footprint (daily). This is per-object reservation, not file allocation — it may not fully explain a database's file-level growth (log / free space)."/> + <scottplot:WpfPlot Grid.Row="1" x:Name="FinOpsObjectGrowthHeatmapChart" MouseMove="FinOpsObjectHeatmapChart_MouseMove" MouseLeave="FinOpsObjectHeatmapChart_MouseLeave"/> + <DataGrid Grid.Row="2" x:Name="FinOpsObjectGrowthDetailGrid" Margin="0,6,0,0" Style="{StaticResource DarkGrid}" RowStyle="{StaticResource FinOpsRowStyle}" + HorizontalScrollBarVisibility="Auto" MouseDoubleClick="FinOpsObjectGrowthGrid_DoubleClick"> + <DataGrid.Columns> + <DataGridTextColumn Header="Schema" Binding="{Binding SchemaName}" Width="100"/> + <DataGridTextColumn Header="Table" Binding="{Binding TableName}" Width="220"/> + <DataGridTextColumn Header="Reserved MB" Binding="{Binding CurrentReservedMb, StringFormat=N1}" Width="110" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Used MB" Binding="{Binding CurrentUsedMb, StringFormat=N1}" Width="100" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Rows" Binding="{Binding TotalRows, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Indexes" Binding="{Binding IndexCount}" Width="70" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Growth MB" Binding="{Binding Growth30dMb, StringFormat=N1}" Width="110" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Growth %" Binding="{Binding GrowthPct30d, StringFormat=N1}" Width="100" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Daily Rate MB" Binding="{Binding DailyGrowthRateMb, StringFormat=N2}" Width="110" ElementStyle="{StaticResource NumericCell}"/> + </DataGrid.Columns> + </DataGrid> + <TextBlock Grid.Row="1" Grid.RowSpan="2" x:Name="FinOpsObjectGrowthNoDataMessage" Style="{StaticResource EmptyHint}" + Text="No object size data for this database yet. Index/object stats are collected daily."/> + </Grid> + + <Grid x:Name="FinOpsStorageIndexView" Visibility="Collapsed"> + <DataGrid x:Name="FinOpsObjectIndexDetailGrid" Style="{StaticResource DarkGrid}" RowStyle="{StaticResource FinOpsRowStyle}" HorizontalScrollBarVisibility="Auto"> + <DataGrid.Columns> + <DataGridTextColumn Header="Index" Binding="{Binding IndexName}" Width="220"/> + <DataGridTextColumn Header="Status" Binding="{Binding Classification}" Width="90"/> + <DataGridTextColumn Header="Type" Binding="{Binding IndexTypeDesc}" Width="140"/> + <DataGridTextColumn Header="Reserved MB" Binding="{Binding ReservedMb, StringFormat=N1}" Width="110" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Rows" Binding="{Binding TotalRows, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Seeks" Binding="{Binding UserSeeks, StringFormat=N0}" Width="90" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Scans" Binding="{Binding UserScans, StringFormat=N0}" Width="90" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Lookups" Binding="{Binding UserLookups, StringFormat=N0}" Width="90" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Total Reads" Binding="{Binding TotalReads, StringFormat=N0}" Width="100" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Updates" Binding="{Binding UserUpdates, StringFormat=N0}" Width="100" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Last Access" Binding="{Binding LastUserAccess, StringFormat=\{0:yyyy-MM-dd HH:mm\}}" Width="140"/> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="FinOpsObjectIndexNoDataMessage" Style="{StaticResource EmptyHint}" Text="No index detail for this object."/> + </Grid> + </Grid> + </Grid> + </TabItem> + + <!-- Locking & Contention Sub-Tab (#1138: color-scaled wait-type grid + DB selector + index drill) --> + <TabItem Header="Locking & Contention"> + <Grid> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="10,8" Height="36"> + <Button x:Name="FinOpsLockingBackButton" Content="← Back" Click="FinOpsLockingBack_Click" Visibility="Collapsed" Margin="0,0,10,0" Padding="10,4" MinWidth="70"/> + <TextBlock x:Name="FinOpsLockingBreadcrumb" Text="Locking & Contention" VerticalAlignment="Center" Margin="0,0,10,0" FontWeight="Bold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="FinOpsRefreshIndexLocking_Click" Margin="12,0,0,0" Padding="10,4" MinWidth="70"/> + <TextBlock x:Name="FinOpsIndexLockingCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + <TextBlock x:Name="FinOpsLockingDbLabel" Text="Database:" VerticalAlignment="Center" Margin="16,0,6,0" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}"/> + <ComboBox x:Name="FinOpsLockingDbSelector" Width="200" FontSize="11" Padding="4,2" SelectionChanged="FinOpsLockingDbSelector_Changed"/> + <TextBlock Text="Cumulative since restart; cells shaded per-column by wait time" Margin="12,0,0,0" VerticalAlignment="Center" FontStyle="Italic" Foreground="{DynamicResource ForegroundMutedBrush}"/> + </StackPanel> + <Grid Grid.Row="1" Margin="10,0,10,10"> + <Grid x:Name="FinOpsLockingParentView"> + <DataGrid x:Name="FinOpsIndexLockingDataGrid" Style="{StaticResource DarkGrid}" RowStyle="{StaticResource FinOpsRowStyle}" MouseDoubleClick="FinOpsIndexLockingGrid_DoubleClick" HorizontalScrollBarVisibility="Auto"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding DatabaseName}" Width="140"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DatabaseName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Database" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TableName}" Width="170"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TableName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Table" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding IndexName}" Width="180"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IndexName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Index" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Header="Row Lock Waits" Binding="{Binding RowLockWaitCount, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTemplateColumn Header="Row Lock Wait ms" Width="130" SortMemberPath="RowLockWaitInMs"><DataGridTemplateColumn.CellTemplate><DataTemplate><Border Background="{Binding RowLockHeat, Converter={StaticResource FinOpsHeatBrush}, ConverterParameter=Lock}"><TextBlock Text="{Binding RowLockWaitInMs, StringFormat=N0}" HorizontalAlignment="Right" VerticalAlignment="Center" Margin="6,2"/></Border></DataTemplate></DataGridTemplateColumn.CellTemplate></DataGridTemplateColumn> + <DataGridTemplateColumn Header="Page Lock Wait ms" Width="130" SortMemberPath="PageLockWaitInMs"><DataGridTemplateColumn.CellTemplate><DataTemplate><Border Background="{Binding PageLockHeat, Converter={StaticResource FinOpsHeatBrush}, ConverterParameter=Lock}"><TextBlock Text="{Binding PageLockWaitInMs, StringFormat=N0}" HorizontalAlignment="Right" VerticalAlignment="Center" Margin="6,2"/></Border></DataTemplate></DataGridTemplateColumn.CellTemplate></DataGridTemplateColumn> + <DataGridTextColumn Header="Lock Escalations" Binding="{Binding IndexLockPromotionCount, StringFormat=N0}" Width="120" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTemplateColumn Header="Page Latch Wait ms" Width="130" SortMemberPath="PageLatchWaitInMs"><DataGridTemplateColumn.CellTemplate><DataTemplate><Border Background="{Binding PageLatchHeat, Converter={StaticResource FinOpsHeatBrush}, ConverterParameter='Buffer Latch'}"><TextBlock Text="{Binding PageLatchWaitInMs, StringFormat=N0}" HorizontalAlignment="Right" VerticalAlignment="Center" Margin="6,2"/></Border></DataTemplate></DataGridTemplateColumn.CellTemplate></DataGridTemplateColumn> + <DataGridTemplateColumn Header="Page IO Latch Wait ms" Width="150" SortMemberPath="PageIoLatchWaitInMs"><DataGridTemplateColumn.CellTemplate><DataTemplate><Border Background="{Binding PageIoLatchHeat, Converter={StaticResource FinOpsHeatBrush}, ConverterParameter='Buffer IO'}"><TextBlock Text="{Binding PageIoLatchWaitInMs, StringFormat=N0}" HorizontalAlignment="Right" VerticalAlignment="Center" Margin="6,2"/></Border></DataTemplate></DataGridTemplateColumn.CellTemplate></DataGridTemplateColumn> + <DataGridTextColumn Header="Reserved MB" Binding="{Binding ReservedMb, StringFormat=N1}" Width="100" ElementStyle="{StaticResource NumericCell}"/> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="FinOpsNoIndexLockingMessage" Style="{StaticResource EmptyHint}" Text="No locking/contention data recorded yet"/> + </Grid> + <Grid x:Name="FinOpsLockingDetailView" Visibility="Collapsed"> + <ScrollViewer VerticalScrollBarVisibility="Auto"> + <StackPanel x:Name="FinOpsLockingDetailPanel" Margin="2,2,2,8" MaxWidth="720" HorizontalAlignment="Left"> + <TextBlock Margin="0,0,0,8" FontSize="15" FontWeight="Bold" Foreground="{DynamicResource ForegroundBrush}"> + <Run Text="{Binding SchemaName, Mode=OneWay}"/><Run Text="."/><Run Text="{Binding TableName, Mode=OneWay}"/><Run Text=" · "/><Run Text="{Binding IndexName, Mode=OneWay}"/> + </TextBlock> + <Grid> + <Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="*"/></Grid.ColumnDefinitions> + <GroupBox Grid.Column="0" Header="Identity & Size" Margin="0,0,5,0" Foreground="{DynamicResource ForegroundBrush}"><StackPanel Margin="8" x:Name="FinOpsLockingDetailIdentity"/></GroupBox> + <GroupBox Grid.Column="1" Header="Lock & Latch Waits" Margin="5,0,0,0" Foreground="{DynamicResource ForegroundBrush}"><StackPanel Margin="8" x:Name="FinOpsLockingDetailCounters"/></GroupBox> + </Grid> + </StackPanel> + </ScrollViewer> + </Grid> + </Grid> + </Grid> + </TabItem> + + <!-- Database Sizes Sub-Tab --> + <TabItem Header="Database Sizes"> + <Grid> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="10,8" Height="36"> + <TextBlock Text="Database Sizes" VerticalAlignment="Center" Margin="0,0,10,0" FontWeight="Bold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="FinOpsRefreshDatabaseSizes_Click" Margin="12,0,0,0" Padding="10,4" MinWidth="70"/> + <TextBlock x:Name="FinOpsDbSizeCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + </StackPanel> + <Grid Grid.Row="1" Margin="10,0,10,10"> + <DataGrid x:Name="FinOpsDatabaseSizesDataGrid" Style="{StaticResource DarkGrid}" RowStyle="{StaticResource FinOpsRowStyle}" HorizontalScrollBarVisibility="Auto"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding DatabaseName}" Width="160"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DatabaseName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Database" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding FileTypeDesc}" Width="80"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="FileTypeDesc" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="File Type" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding FileName}" Width="160"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="FileName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="File Name" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalSizeMb, StringFormat=N2}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalSizeMb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total Size MB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding UsedSizeMb, StringFormat=N2}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="UsedSizeMb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Used Size MB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding FreeSpaceMb, StringFormat=N2}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="FreeSpaceMb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Free Space MB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding UsedPct, StringFormat=N1}" Width="70" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="UsedPct" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Used %" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding VolumeMountPoint}" Width="80"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="VolumeMountPoint" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Volume" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding VolumeTotalMb, StringFormat=N0}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="VolumeTotalMb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Volume Total MB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding VolumeFreeMb, StringFormat=N0}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="VolumeFreeMb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Volume Free MB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding RecoveryModel}" Width="120"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="RecoveryModel" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Recovery Model" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding GrowthDisplay}" Width="110" SortMemberPath="AutoGrowthSort"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="GrowthDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Auto Growth" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding VlfCountDisplay}" Width="80" SortMemberPath="VlfCountSort" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="VlfCountDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="VLF Count" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Header="Monthly Cost ($)" Binding="{Binding MonthlyCostShare, StringFormat=N2}" Width="110" ElementStyle="{StaticResource NumericCell}" + ToolTipService.ToolTip="Proportional share of the server monthly budget by size (0 when no budget is set)."/> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="FinOpsNoDbSizesMessage" Style="{StaticResource EmptyHint}" Text="No database size data collected yet"/> + </Grid> + </Grid> + </TabItem> + + <!-- Optimization Sub-Tab --> + <TabItem Header="Optimization"> + <Grid> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="10,8" Height="36"> + <TextBlock Text="Optimization" VerticalAlignment="Center" Margin="0,0,10,0" FontWeight="Bold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="FinOpsOptimizationRefresh_Click" Margin="12,0,0,0" Padding="10,4" MinWidth="70"/> + </StackPanel> + <ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" Margin="10,0,10,10"> + <StackPanel> + <Expander Header="Idle Databases" IsExpanded="True" Margin="0,0,0,8"> + <StackPanel> + <StackPanel Orientation="Horizontal" Margin="0,4,0,4"><TextBlock x:Name="FinOpsIdleDatabasesCountIndicator" Text="" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/></StackPanel> + <DataGrid x:Name="FinOpsIdleDatabasesDataGrid" Style="{StaticResource DarkGrid}" RowStyle="{StaticResource FinOpsRowStyle}" MaxHeight="250"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding DatabaseName}" Width="180"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DatabaseName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Database" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalSizeMb, StringFormat=N2}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalSizeMb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total Size MB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding FileCount}" Width="60" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="FileCount" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Files" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LastExecutionTime, StringFormat=\{0:yyyy-MM-dd HH:mm\}}" Width="160"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LastExecutionTime" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Last Execution" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="FinOpsIdleDatabasesNoDataMessage" Text="No idle databases detected" FontSize="12" Foreground="{DynamicResource ForegroundMutedBrush}" HorizontalAlignment="Center" Margin="0,4,0,0" Visibility="Collapsed"/> + </StackPanel> + </Expander> + + <Expander Header="tempdb Pressure" IsExpanded="True" Margin="0,0,0,8"> + <StackPanel> + <DataGrid x:Name="FinOpsTempdbPressureDataGrid" Style="{StaticResource DarkGrid}" RowStyle="{StaticResource FinOpsRowStyle}" MaxHeight="200"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding Metric}" Width="180"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Metric" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Metric" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CurrentMb, StringFormat=N2}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CurrentMb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Current MB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Peak24hMb, StringFormat=N2}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Peak24hMb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Peak 24h MB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Warning}" Width="*"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Warning" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Warning" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="FinOpsTempdbPressureNoDataMessage" Text="No tempdb data available" FontSize="12" Foreground="{DynamicResource ForegroundMutedBrush}" HorizontalAlignment="Center" Margin="0,4,0,0" Visibility="Collapsed"/> + </StackPanel> + </Expander> + + <Expander IsExpanded="True" Margin="0,0,0,8"> + <Expander.Header> + <StackPanel Orientation="Horizontal"> + <TextBlock Text="Wait Stats Summary" VerticalAlignment="Center"/> + <TextBlock Text=" Time Range:" VerticalAlignment="Center" Margin="12,0,4,0" FontSize="11"/> + <ComboBox x:Name="FinOpsWaitStatsTimeRangeCombo" SelectedIndex="3" Width="120" FontSize="11" SelectionChanged="FinOpsWaitStatsTimeRange_Changed"> + <ComboBoxItem Content="Last 1 hour"/><ComboBoxItem Content="Last 4 hours"/><ComboBoxItem Content="Last 12 hours"/><ComboBoxItem Content="Last 24 hours"/><ComboBoxItem Content="Last 7 days"/> + </ComboBox> + </StackPanel> + </Expander.Header> + <StackPanel> + <DataGrid x:Name="FinOpsWaitCategorySummaryDataGrid" Style="{StaticResource DarkGrid}" RowStyle="{StaticResource FinOpsRowStyle}" MaxHeight="250"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding Category}" Width="100"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Category" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Category" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalWaitTimeMs, StringFormat=N0}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalWaitTimeMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total Wait ms" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding WaitingTasks, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="WaitingTasks" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Waiting Tasks" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PctOfTotal, StringFormat=N1}" Width="90" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PctOfTotal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="% of Total" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TopWaitType}" Width="200"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TopWaitType" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Top Wait Type" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TopWaitTimeMs, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TopWaitTimeMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Top Wait ms" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MonthlyCostShare, StringFormat=N2}" Width="120" ElementStyle="{StaticResource NumericCell}" + ToolTipService.ToolTip="Proportional share of the server budget for this window, by wait-time fraction (0 when no budget is set)."><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MonthlyCostShare" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Est. Cost ($)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="FinOpsWaitCategorySummaryNoDataMessage" Text="No wait stats data available" FontSize="12" Foreground="{DynamicResource ForegroundMutedBrush}" HorizontalAlignment="Center" Margin="0,4,0,0" Visibility="Collapsed"/> + </StackPanel> + </Expander> + + <Expander IsExpanded="True" Margin="0,0,0,8"> + <Expander.Header> + <StackPanel Orientation="Horizontal"> + <TextBlock Text="Expensive Queries (Top 20 by CPU)" VerticalAlignment="Center"/> + <TextBlock Text=" Time Range:" VerticalAlignment="Center" Margin="12,0,4,0" FontSize="11"/> + <ComboBox x:Name="FinOpsExpensiveQueriesTimeRangeCombo" SelectedIndex="3" Width="120" FontSize="11" SelectionChanged="FinOpsExpensiveQueriesTimeRange_Changed"> + <ComboBoxItem Content="Last 1 hour"/><ComboBoxItem Content="Last 4 hours"/><ComboBoxItem Content="Last 12 hours"/><ComboBoxItem Content="Last 24 hours"/><ComboBoxItem Content="Last 7 days"/> + </ComboBox> + </StackPanel> + </Expander.Header> + <StackPanel> + <StackPanel Orientation="Horizontal" Margin="0,4,0,4"><TextBlock x:Name="FinOpsExpensiveQueriesCountIndicator" Text="" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/></StackPanel> + <DataGrid x:Name="FinOpsExpensiveQueriesDataGrid" Style="{StaticResource DarkGrid}" RowStyle="{StaticResource FinOpsQueryPlanRowStyle}" MaxHeight="350"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding DatabaseName}" Width="140"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DatabaseName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Database" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalCpuMs, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalCpuMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total CPU ms" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgCpuMsPerExec, StringFormat=N2}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgCpuMsPerExec" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg CPU/Exec" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalReads, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgReadsPerExec, StringFormat=N0}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgReadsPerExec" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Reads/Exec" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Executions, StringFormat=N0}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Executions" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Executions" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MonthlyCostShare, StringFormat=N2}" Width="100" ElementStyle="{StaticResource NumericCell}" + ToolTipService.ToolTip="Proportional share of the server budget for this window, by CPU fraction (0 when no budget is set)."><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MonthlyCostShare" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Est. Cost ($)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTemplateColumn Header="Query Preview" Width="400"><DataGridTemplateColumn.CellTemplate><DataTemplate><TextBlock Text="{Binding QueryPreview}" TextWrapping="Wrap" MaxHeight="90" Margin="5,2" ToolTip="{Binding FullQueryText}"/></DataTemplate></DataGridTemplateColumn.CellTemplate></DataGridTemplateColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="FinOpsExpensiveQueriesNoDataMessage" Text="No expensive queries found" FontSize="12" Foreground="{DynamicResource ForegroundMutedBrush}" HorizontalAlignment="Center" Margin="0,4,0,0" Visibility="Collapsed"/> + </StackPanel> + </Expander> + + <Expander Header="Memory Grant Efficiency" IsExpanded="True" Margin="0,0,0,8"> + <StackPanel> + <DataGrid x:Name="FinOpsMemoryGrantEfficiencyDataGrid" Style="{StaticResource DarkGrid}" RowStyle="{StaticResource FinOpsRowStyle}" MaxHeight="250"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding DayDisplay}" Width="100"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DayDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Day" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgGrantedMb, StringFormat=N1}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgGrantedMb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Granted MB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgUsedMb, StringFormat=N1}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgUsedMb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Used MB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding EfficiencyPct, StringFormat=N1}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EfficiencyPct" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Efficiency %" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PeakGrantedMb, StringFormat=N1}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PeakGrantedMb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Peak Grant MB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding WastedMb, StringFormat=N1}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="WastedMb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Wasted MB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalGrantees, StringFormat=N0}" Width="90" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalGrantees" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Grantees" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalWaiters, StringFormat=N0}" Width="80" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalWaiters" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Waiters" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TimeoutErrors, StringFormat=N0}" Width="90" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TimeoutErrors" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Timeouts" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ForcedGrants, StringFormat=N0}" Width="80" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ForcedGrants" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Forced" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="FinOpsMemoryGrantEfficiencyNoDataMessage" Text="No memory grant data available" FontSize="12" Foreground="{DynamicResource ForegroundMutedBrush}" HorizontalAlignment="Center" Margin="0,4,0,0" Visibility="Collapsed"/> + </StackPanel> + </Expander> + </StackPanel> + </ScrollViewer> + </Grid> + </TabItem> + + <!-- High Impact Sub-Tab --> + <TabItem Header="High Impact"> + <Grid> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="10,8" Height="36"> + <TextBlock Text="High Impact Queries" VerticalAlignment="Center" Margin="0,0,10,0" FontWeight="Bold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="FinOpsRefreshHighImpact_Click" Margin="12,0,0,0" Padding="10,4" MinWidth="70"/> + <TextBlock Text="Time Range:" VerticalAlignment="Center" Margin="16,0,4,0" FontSize="11" Foreground="{DynamicResource ForegroundBrush}"/> + <ComboBox x:Name="FinOpsHighImpactTimeRangeCombo" SelectedIndex="3" Width="120" FontSize="11" SelectionChanged="FinOpsHighImpactTimeRange_Changed"> + <ComboBoxItem Content="Last 1 hour"/><ComboBoxItem Content="Last 4 hours"/><ComboBoxItem Content="Last 12 hours"/><ComboBoxItem Content="Last 24 hours"/><ComboBoxItem Content="Last 7 days"/> + </ComboBox> + <TextBlock x:Name="FinOpsHighImpactCountIndicator" VerticalAlignment="Center" Margin="12,0,0,0" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + </StackPanel> + <TextBlock x:Name="FinOpsHighImpactNoDataMessage" Grid.Row="1" Text="No high-impact queries found for this time range." Margin="10,0" Visibility="Collapsed" Foreground="{DynamicResource ForegroundMutedBrush}" FontStyle="Italic"/> + <DataGrid x:Name="FinOpsHighImpactDataGrid" Grid.Row="2" Margin="10,4,10,10" Style="{StaticResource DarkGrid}" RowStyle="{StaticResource FinOpsQueryPlanRowStyle}" HorizontalScrollBarVisibility="Auto"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding ImpactScore}" Width="60"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ImpactScore" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Score" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle> + <Style TargetType="TextBlock"> + <Setter Property="HorizontalAlignment" Value="Center"/><Setter Property="FontWeight" Value="Bold"/> + <Style.Triggers> + <DataTrigger Binding="{Binding ImpactScoreColor}" Value="#E74C3C"><Setter Property="Foreground" Value="#E74C3C"/></DataTrigger> + <DataTrigger Binding="{Binding ImpactScoreColor}" Value="#F39C12"><Setter Property="Foreground" Value="#F39C12"/></DataTrigger> + <DataTrigger Binding="{Binding ImpactScoreColor}" Value="#27AE60"><Setter Property="Foreground" Value="#27AE60"/></DataTrigger> + </Style.Triggers> + </Style> + </DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DatabaseName}" Width="120"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DatabaseName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Database" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalExecutions, StringFormat=N0}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalExecutions" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Executions" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalCpuMs, StringFormat=N0}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalCpuMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CpuShare, StringFormat=N1}" Width="70" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CpuShare" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="CPU %" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalDurationMs, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalDurationMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Duration (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DurationShare, StringFormat=N1}" Width="80" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DurationShare" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Duration %" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalReads, StringFormat=N0}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ReadsShare, StringFormat=N1}" Width="70" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ReadsShare" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Reads %" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalWrites, StringFormat=N0}" Width="90" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalWrites" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Writes" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalMemoryMb, StringFormat=N1}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalMemoryMb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Memory (MB)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTemplateColumn Width="400"><DataGridTemplateColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SampleQueryText" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Query Preview" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTemplateColumn.Header><DataGridTemplateColumn.CellTemplate><DataTemplate><TextBlock Text="{Binding SampleQueryText}" TextWrapping="Wrap" MaxHeight="90" Margin="5,2" ToolTip="{Binding FullQueryText}"/></DataTemplate></DataGridTemplateColumn.CellTemplate></DataGridTemplateColumn> + </DataGrid.Columns> + </DataGrid> + </Grid> + </TabItem> + + <!-- Application Connections Sub-Tab --> + <TabItem Header="Application Connections"> + <Grid> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="10,8" Height="36"> + <TextBlock Text="Application Connections (24h)" VerticalAlignment="Center" Margin="0,0,10,0" FontWeight="Bold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="FinOpsRefreshApplicationConnections_Click" Margin="12,0,0,0" Padding="10,4" MinWidth="70"/> + <TextBlock x:Name="FinOpsAppConnectionsCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + </StackPanel> + <Grid Grid.Row="1" Margin="10,0,10,10"> + <DataGrid x:Name="FinOpsApplicationConnectionsDataGrid" Style="{StaticResource DarkGrid}" RowStyle="{StaticResource FinOpsRowStyle}" HorizontalScrollBarVisibility="Auto"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding ApplicationName}" Width="300"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ApplicationName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Application" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgConnections, StringFormat=N0}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgConnections" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Connections" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxConnections, StringFormat=N0}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxConnections" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Connections" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgRunning, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgRunning" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Running" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxRunning, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxRunning" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Running" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgSleeping, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgSleeping" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Sleeping" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxSleeping, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxSleeping" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Sleeping" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgDormant, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgDormant" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Dormant" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxDormant, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxDormant" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Dormant" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgCpuTimeMs, StringFormat=N0}" Width="130" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgCpuTimeMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxCpuTimeMs, StringFormat=N0}" Width="130" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxCpuTimeMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgReads, StringFormat=N0}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxReads, StringFormat=N0}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgWrites, StringFormat=N0}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgWrites" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Writes" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxWrites, StringFormat=N0}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxWrites" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Writes" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgLogicalReads, StringFormat=N0}" Width="140" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgLogicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Logical Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxLogicalReads, StringFormat=N0}" Width="140" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxLogicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Logical Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding SampleCount, StringFormat=N0}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SampleCount" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Samples" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding FirstSeenLocal, StringFormat=\{0:yyyy-MM-dd HH:mm\}}" Width="150"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="FirstSeenLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="First Seen" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LastSeenLocal, StringFormat=\{0:yyyy-MM-dd HH:mm\}}" Width="150"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LastSeenLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Last Seen" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="FinOpsNoAppConnectionsMessage" Style="{StaticResource EmptyHint}" Text="No application connection data collected yet for this server."/> + </Grid> + </Grid> + </TabItem> + + <!-- Server Inventory Sub-Tab (cross-server) --> + <TabItem Header="Server Inventory"> + <Grid> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="10,8" Height="36"> + <TextBlock Text="Server Inventory (All Servers)" VerticalAlignment="Center" Margin="0,0,10,0" FontWeight="Bold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="FinOpsRefreshServerInventory_Click" Margin="12,0,0,0" Padding="10,4" MinWidth="70"/> + <TextBlock x:Name="FinOpsServerInventoryCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + </StackPanel> + <Grid Grid.Row="1" Margin="10,0,10,10"> + <DataGrid x:Name="FinOpsServerInventoryDataGrid" Style="{StaticResource DarkGrid}" RowStyle="{StaticResource FinOpsRowStyle}" HorizontalScrollBarVisibility="Auto"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding ServerName}" Width="160"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ServerName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Server" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Edition}" Width="200"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Edition" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Edition" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ProductVersion}" Width="180"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ProductVersion" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Version" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding HostOsVersion}" Width="200"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="HostOsVersion" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Host OS" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CpuCount, StringFormat=N0}" Width="70" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CpuCount" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Logical CPUs" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PhysicalMemoryMb, StringFormat=N0}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PhysicalMemoryMb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Memory MB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding SocketCount}" Width="70" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SocketCount" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Sockets" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CoresPerSocket}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CoresPerSocket" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Cores/Socket" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ProvisioningDisplay}" Width="130"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ProvisioningDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Status" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle> + <Style TargetType="TextBlock"> + <Style.Triggers> + <DataTrigger Binding="{Binding ProvisioningStatus}" Value="RIGHT_SIZED"><Setter Property="Foreground" Value="#27AE60"/><Setter Property="FontWeight" Value="SemiBold"/></DataTrigger> + <DataTrigger Binding="{Binding ProvisioningStatus}" Value="OVER_PROVISIONED"><Setter Property="Foreground" Value="#F39C12"/><Setter Property="FontWeight" Value="SemiBold"/></DataTrigger> + <DataTrigger Binding="{Binding ProvisioningStatus}" Value="UNDER_PROVISIONED"><Setter Property="Foreground" Value="#E74C3C"/><Setter Property="FontWeight" Value="SemiBold"/></DataTrigger> + </Style.Triggers> + </Style> + </DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgCpuPct, StringFormat=N1}" Width="80" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgCpuPct" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg CPU %" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding StorageTotalGb, StringFormat=N1}" Width="95" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="StorageTotalGb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Storage GB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding IdleDbCount}" Width="70" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IdleDbCount" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Idle DBs" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding UptimeDisplay}" Width="90"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="UptimeDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Uptime" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding SqlServerStartTime, StringFormat=\{0:yyyy-MM-dd HH:mm\}}" Width="150"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SqlServerStartTime" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Start Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LastUpdated, StringFormat=\{0:yyyy-MM-dd HH:mm\}}" Width="150"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LastUpdated" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Last Updated" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Header="Monthly ($)" Binding="{Binding MonthlyCost, StringFormat=N0}" Width="100" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Annual ($)" Binding="{Binding AnnualCost, StringFormat=N0}" Width="100" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Health" Binding="{Binding HealthScore}" Width="60"> + <DataGridTextColumn.ElementStyle> + <Style TargetType="TextBlock"> + <Setter Property="HorizontalAlignment" Value="Center"/><Setter Property="FontWeight" Value="Bold"/> + <Style.Triggers> + <DataTrigger Binding="{Binding HealthScoreColor}" Value="#27AE60"><Setter Property="Foreground" Value="#27AE60"/></DataTrigger> + <DataTrigger Binding="{Binding HealthScoreColor}" Value="#F39C12"><Setter Property="Foreground" Value="#F39C12"/></DataTrigger> + <DataTrigger Binding="{Binding HealthScoreColor}" Value="#E74C3C"><Setter Property="Foreground" Value="#E74C3C"/></DataTrigger> + </Style.Triggers> + </Style> + </DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Header="License Warning" Binding="{Binding LicenseWarning}" Width="250"> + <DataGridTextColumn.ElementStyle><Style TargetType="TextBlock"><Setter Property="Foreground" Value="#E74C3C"/><Setter Property="FontWeight" Value="SemiBold"/></Style></DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding HadrDisplay}" Width="60"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="HadrDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="HADR" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AgReplicaRoleDisplay}" Width="90"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AgReplicaRoleDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="AG Role" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ClusteredDisplay}" Width="80"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ClusteredDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Clustered" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="FinOpsNoServerInventoryMessage" Style="{StaticResource EmptyHint}" Text="No server property data collected yet"/> + </Grid> + </Grid> + </TabItem> + + <!-- Index Analysis Sub-Tab (Stage 3): monitor-side sp_IndexCleanup reproduction over the LATEST + collected v_index_object_stats snapshot via the Common IndexCleanupAnalyzer. Parse-on-read — + NO live proc, NO "install the proc" prompt. A reclaimable-space rollup (overall + per db) + over the recommendations grid (DISABLE / MAKE UNIQUE / MERGE / DROP CONSTRAINT / COMPRESS / + REVIEW; REVIEW rows carry no destructive script) with the analyzer's uptime/dedupe/Notes + banners. --> + <TabItem Header="Index Analysis"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="10,8" Height="36"> + <TextBlock Text="Index Analysis" VerticalAlignment="Center" Margin="0,0,10,0" FontWeight="Bold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="FinOpsRefreshIndexAnalysis_Click" Margin="12,0,0,0" Padding="10,4" MinWidth="70"/> + <TextBlock x:Name="FinOpsIndexAnalysisCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + <TextBlock Text="Analyzed from the latest collected snapshot (no live proc)." Margin="16,0,0,0" VerticalAlignment="Center" FontStyle="Italic" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}"/> + </StackPanel> + + <StackPanel x:Name="FinOpsIndexAnalysisBannerPanel" Grid.Row="1" Margin="10,0,10,4" Visibility="Collapsed"/> + + <Grid Grid.Row="2" Margin="10,0,10,10"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + + <TextBlock Grid.Row="0" Text="Reclaimable Space (overall + per database)" FontWeight="SemiBold" FontSize="12" Margin="0,0,0,4" Foreground="{DynamicResource ForegroundMutedBrush}"/> + + <DataGrid x:Name="FinOpsIndexAnalysisRollupGrid" Grid.Row="1" Style="{StaticResource DarkGrid}" RowStyle="{StaticResource FinOpsRowStyle}" MaxHeight="220" HorizontalScrollBarVisibility="Auto" AutoGenerateColumns="False" IsReadOnly="True"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding Scope}" Width="180"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Scope" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Database" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Header="Tables" Binding="{Binding TablesAnalyzed, StringFormat=N0}" Width="70" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Indexes" Binding="{Binding IndexCount, StringFormat=N0}" Width="75" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Size GB" Binding="{Binding TotalSizeGb, StringFormat=N2}" Width="80" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Rows" Binding="{Binding TotalRows, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="To Disable" Binding="{Binding IndexesToDisable, StringFormat=N0}" Width="80" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="To Merge" Binding="{Binding IndexesToMerge, StringFormat=N0}" Width="80" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Compressable" Binding="{Binding CompressableIndexes, StringFormat=N0}" Width="100" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Unused" Binding="{Binding UnusedIndexes, StringFormat=N0}" Width="70" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Disable GB" Binding="{Binding UnusedSizeGb, StringFormat=N2}" Width="90" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Compress Min GB" Binding="{Binding CompressionMinSavingsGb, StringFormat=N2}" Width="120" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Compress Max GB" Binding="{Binding CompressionMaxSavingsGb, StringFormat=N2}" Width="120" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Total Min GB" Binding="{Binding TotalMinSavingsGb, StringFormat=N2}" Width="100" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Total Max GB" Binding="{Binding TotalMaxSavingsGb, StringFormat=N2}" Width="100" ElementStyle="{StaticResource NumericCell}"/> + <!-- Workload-impact columns (sp_IndexCleanup #index_reporting_stats parity): pre-formatted strings, + 'N/A' on the overall row; numeric SortMemberPath keys keep sorting correct. Reads shows the + seeks/scans/lookups breakdown (left-aligned text, like Lite). --> + <DataGridTextColumn Header="Reads" Binding="{Binding ReadsBreakdown}" Width="220"> + <DataGridTextColumn.ElementStyle><Style TargetType="TextBlock"><Setter Property="ToolTip" Value="{Binding ReadsBreakdown}"/></Style></DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Header="Writes" Binding="{Binding Writes}" SortMemberPath="WritesSort" Width="90" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Lock Waits" Binding="{Binding LockWaitCount}" SortMemberPath="LockWaitCountSort" Width="95" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Avg Lock Wait ms" Binding="{Binding AvgLockWaitMs}" SortMemberPath="AvgLockWaitMsSort" Width="120" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Latch Waits" Binding="{Binding LatchWaitCount}" SortMemberPath="LatchWaitCountSort" Width="95" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Avg Latch Wait ms" Binding="{Binding AvgLatchWaitMs}" SortMemberPath="AvgLatchWaitMsSort" Width="130" ElementStyle="{StaticResource NumericCell}"/> + </DataGrid.Columns> + </DataGrid> + + <TextBlock Grid.Row="2" Text="Recommendations" FontWeight="SemiBold" FontSize="12" Margin="0,8,0,4" Foreground="{DynamicResource ForegroundMutedBrush}"/> + + <DataGrid x:Name="FinOpsIndexAnalysisDetailGrid" Grid.Row="3" Style="{StaticResource DarkGrid}" RowStyle="{StaticResource FinOpsRowStyle}" HorizontalScrollBarVisibility="Auto" AutoGenerateColumns="False" IsReadOnly="True"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding ActionDisplay}" Width="110"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ActionDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Action" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ResultKindDisplay}" Width="110"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ResultKindDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Result Kind" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ConsolidationRule}" Width="170"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ConsolidationRule" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Rule" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DatabaseName}" Width="140"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DatabaseName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Database" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding SchemaName}" Width="90"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SchemaName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Schema" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TableName}" Width="170"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TableName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Table" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding IndexName}" Width="200"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IndexName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Index" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Header="Size GB" Binding="{Binding IndexSizeGb, StringFormat=N3}" Width="80" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Rows" Binding="{Binding IndexRows, StringFormat=N0}" Width="100" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Reads" Binding="{Binding IndexReads, StringFormat=N0}" Width="90" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Header="Writes" Binding="{Binding IndexWrites, StringFormat=N0}" Width="90" ElementStyle="{StaticResource NumericCell}"/> + <DataGridTextColumn Binding="{Binding TargetIndexName}" Width="180"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TargetIndexName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Target Index" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Header="Superseded / Related" Binding="{Binding SupersededBy}" Width="220"> + <DataGridTextColumn.ElementStyle><Style TargetType="TextBlock"><Setter Property="ToolTip" Value="{Binding SupersededBy}"/></Style></DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Header="Info" Binding="{Binding AdditionalInfo}" Width="220"> + <DataGridTextColumn.ElementStyle><Style TargetType="TextBlock"><Setter Property="ToolTip" Value="{Binding AdditionalInfo}"/></Style></DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Header="Original Definition" Binding="{Binding OriginalIndexDefinition}" Width="320"> + <DataGridTextColumn.ElementStyle><Style TargetType="TextBlock"><Setter Property="ToolTip" Value="{Binding OriginalIndexDefinition}"/></Style></DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Header="Script" Binding="{Binding Script}" Width="320"> + <DataGridTextColumn.ElementStyle><Style TargetType="TextBlock"><Setter Property="ToolTip" Value="{Binding Script}"/></Style></DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + + <TextBlock x:Name="FinOpsIndexAnalysisNoDataMessage" Grid.Row="0" Grid.RowSpan="4" Style="{StaticResource EmptyHint}" Text="No analyzable index statistics collected yet for this server."/> + </Grid> + </Grid> + </TabItem> + + <!-- Recommendations Sub-Tab (last deferred FinOps sub-tab): the ~10 cost-saving checks reproduced + MONITOR-SIDE over the collected store (edition/license audit, CPU/memory right-sizing, + compression candidates, dormant + dev/test DBs, maintenance window, VM right-sizing, storage + tier, reserved capacity) — NO live target. Lite's sp_IndexCleanup-existence check is dropped + (superseded by the native Index Analysis sub-tab). Sorted by severity; the per-server budget + drives Est. Savings (0 → blank). --> + <TabItem Header="Recommendations"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="10,8" Height="36"> + <TextBlock Text="Cost-Saving Recommendations" VerticalAlignment="Center" Margin="0,0,10,0" FontWeight="Bold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="FinOpsRefreshRecommendations_Click" Margin="12,0,0,0" Padding="10,4" MinWidth="70"/> + <TextBlock x:Name="FinOpsRecommendationsCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + <TextBlock Text="Analyzed from collected data (no live target)." Margin="16,0,0,0" VerticalAlignment="Center" FontStyle="Italic" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}"/> + </StackPanel> + + <Grid Grid.Row="1" Margin="10,0,10,10"> + <DataGrid x:Name="FinOpsRecommendationsDataGrid" Style="{StaticResource DarkGrid}" RowStyle="{StaticResource FinOpsRowStyle}" HorizontalScrollBarVisibility="Auto" AutoGenerateColumns="False" IsReadOnly="True"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding Category}" Width="120"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Category" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Category" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Severity}" Width="90" SortMemberPath="SeveritySort"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Severity" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Severity" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle> + <Style TargetType="TextBlock"> + <Setter Property="FontWeight" Value="SemiBold"/> + <Style.Triggers> + <DataTrigger Binding="{Binding Severity}" Value="High"><Setter Property="Foreground" Value="#E74C3C"/></DataTrigger> + <DataTrigger Binding="{Binding Severity}" Value="Medium"><Setter Property="Foreground" Value="#F39C12"/></DataTrigger> + <DataTrigger Binding="{Binding Severity}" Value="Low"><Setter Property="Foreground" Value="#27AE60"/></DataTrigger> + </Style.Triggers> + </Style> + </DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Confidence}" Width="100"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Confidence" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Confidence" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Finding}" Width="360"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Finding" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Finding" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle><Style TargetType="TextBlock"><Setter Property="ToolTip" Value="{Binding Finding}"/></Style></DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Header="Detail" Binding="{Binding Detail}" Width="560"> + <DataGridTextColumn.ElementStyle><Style TargetType="TextBlock"><Setter Property="ToolTip" Value="{Binding Detail}"/></Style></DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Header="Est. Savings ($/mo)" Binding="{Binding EstMonthlySavingsDisplay}" Width="130" SortMemberPath="EstMonthlySavings" ElementStyle="{StaticResource NumericCell}"/> + </DataGrid.Columns> + </DataGrid> + + <TextBlock x:Name="FinOpsRecommendationsNoDataMessage" Style="{StaticResource EmptyHint}" Text="No recommendations — this server looks right-sized, or not enough has been collected yet."/> + </Grid> + </Grid> + </TabItem> + </TabControl> + </Grid> + </Grid> +</UserControl> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml.cs new file mode 100644 index 000000000..d94767bdc --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml.cs @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Media; +using PerformanceMonitor.Ui; +using static PerformanceMonitor.Ui.DataGridHelpers; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The FinOps top-level aggregate surface for the Darling viewer's shell (a 4th <c>MainTabs</c> tab beside +/// Overview / Recommendations / Alerts). This restores Lite's original cross-server FinOps shape: a top-level +/// tab with its OWN server selector (independent of the sidebar, mirroring the Recommendations tab) rather than +/// the earlier per-server inner tab that duplicated the fleet-wide Server Inventory inside every server's tab. +/// The per-server sub-tabs scope to the selector's selected <see cref="DarlingServer"/> (which carries +/// <c>MonthlyCostUsd</c>, so cost attribution is preserved); Server Inventory stays cross-server and now lives +/// ONCE here. This partial owns the shell — the data service, the server selector, the overlap-guarded +/// active-sub-tab refresh, the column-filter popup plumbing, and the copy/export handlers — mirroring the +/// self-contained <see cref="AlertsHistoryTab"/>. The sub-tab loaders live in <c>FinOpsTab.Loaders.cs</c>, +/// Locking in <c>FinOpsTab.Locking.cs</c>, the Storage Growth drill in <c>FinOpsTab.ObjectHeatmap.cs</c>, +/// Index Analysis in <c>FinOpsTab.IndexAnalysis.cs</c>, and Recommendations in +/// <c>FinOpsTab.Recommendations.cs</c>. +/// </summary> +public partial class FinOpsTab : UserControl +{ + /// <summary>Set once via <see cref="Initialize"/>; every load runs after that, so the loaders read it non-null.</summary> + private ViewerDataService _dataService = null!; + + /// <summary>Suppresses the server selector's SelectionChanged during <see cref="SetServers"/> population.</summary> + private bool _populatingServers; + + private bool _refreshInFlight; + private bool _refreshRequested; + + /// <summary>Raised after a load (or on failure) so the shell can surface progress/errors in its status bar.</summary> + public event Action<string>? StatusChanged; + + /// <summary> + /// Raised when a FinOps grid's "View Plan" is clicked (planXml, label, queryText). This aggregate tab has + /// no per-server plan host, so the shell routes it into the standalone Plan Viewer surface. + /// </summary> + public event Action<string, string, string?>? PlanRequested; + + public FinOpsTab() + { + InitializeComponent(); + + /* Register the FinOps grids' column-filter managers into _filterManagers (defined below), after + InitializeComponent so the named grids exist. Body lives in FinOpsTab.Loaders.cs. */ + InitializeFinOpsTab(); + } + + /// <summary>The selector's currently-selected server. Entry points guard on a valid selection before any loader runs, so the loaders read this non-null.</summary> + private DarlingServer _server => (DarlingServer)ServerSelector.SelectedItem!; + + /// <summary>Wires the data service. Call once, before the tab is first shown.</summary> + public void Initialize(ViewerDataService dataService) => _dataService = dataService; + + /// <summary> + /// Populates the server selector from the shell's server list (mirrors the Recommendations tab's own + /// selector). Suppresses SelectionChanged during population, preserving the current selection when the list + /// is re-supplied. The shell drives the first load once the tab becomes visible. + /// </summary> + public void SetServers(IReadOnlyList<DarlingServer> servers) + { + var previousId = (ServerSelector.SelectedItem as DarlingServer)?.ServerId; + + _populatingServers = true; + ServerSelector.ItemsSource = servers; + if (servers.Count > 0) + { + var match = previousId is int pid ? servers.FirstOrDefault(s => s.ServerId == pid) : null; + ServerSelector.SelectedItem = match ?? servers[0]; + } + _populatingServers = false; + } + + /// <summary> + /// Syncs the server selector to the shell's sidebar selection WITHOUT triggering a load — it suppresses + /// SelectionChanged like <see cref="SetServers"/>, so the tab reloads with the synced server on its next + /// activation / refresh rather than loading FinOps data while it isn't the visible tab. Keeps this tab's + /// picker in step with the sidebar (the selector remains independently changeable while FinOps is open). + /// No-op when the id isn't in the list or is already selected. + /// </summary> + public void SelectServer(int serverId) + { + if (ServerSelector.ItemsSource is not IEnumerable<DarlingServer> servers) + { + return; + } + + var match = servers.FirstOrDefault(s => s.ServerId == serverId); + if (match is null || ReferenceEquals(ServerSelector.SelectedItem, match)) + { + return; + } + + _populatingServers = true; + ServerSelector.SelectedItem = match; + _populatingServers = false; + } + + /// <summary>The tab's own server selector drives it; single-clicking a sidebar server syncs it here (and + /// on Recommendations) via the shell, and it stays independently changeable. A new server resets any open + /// drill, then reloads the active sub-tab.</summary> + private async void ServerSelector_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_populatingServers) + { + return; + } + + /* A new server invalidates any open Storage Growth / Locking drill (their breadcrumbs + detail views + belong to the previous server), so reset both to their parent view before reloading. */ + ShowFinOpsStorageView(FinOpsStorageDrillLevel.Parent); + ShowFinOpsLockingView(FinOpsLockingLevel.Parent); + + await RefreshActiveSubTabAsync(); + } + + private async void FinOpsRefresh_Click(object sender, RoutedEventArgs e) => await RefreshActiveSubTabAsync(); + + /// <summary> + /// Loads the ACTIVE FinOps sub-tab, with the overlap guard mirroring the per-server tab's + /// <c>RefreshActiveInnerTabAsync</c>. The shell calls this on tab activation and on its fleet-refresh timer; + /// the sub-tab switch handler and the server selector call it too. If the sub-tab (or server) switches + /// mid-load, the triggering event bounces off the guard and the running loop reloads once more, leaving no + /// sub-tab stranded. + /// </summary> + public async Task RefreshActiveSubTabAsync() + { + if (_dataService is null || ServerSelector.SelectedItem is not DarlingServer) + { + return; + } + + if (_refreshInFlight) + { + _refreshRequested = true; + return; + } + + _refreshInFlight = true; + try + { + do + { + _refreshRequested = false; + + int loadedTab; + do + { + loadedTab = FinOpsSubTabControl.SelectedIndex; + await LoadActiveSubTabGuardedAsync(); + } + while (FinOpsSubTabControl.SelectedIndex != loadedTab); + } + while (_refreshRequested); + } + finally + { + _refreshInFlight = false; + } + } + + /// <summary>Runs the active sub-tab's loader (FinOpsTab.Loaders.cs) with the status-bar error surfacing the per-server tab's LoadInnerTabAsync uses.</summary> + private async Task LoadActiveSubTabGuardedAsync() + { + try + { + await LoadFinOpsAsync(); + StatusChanged?.Invoke($"{_server.DisplayName} — refreshed {DateTime.Now:HH:mm:ss}"); + } + catch (Exception ex) + { + StatusChanged?.Invoke($"refresh failed: {ex.Message}"); + } + } + + // ── Copy / export (copied from ViewerServerTab.CopyExport.cs; the FinOps context menus bind to these) ── + + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); + + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); + + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); + + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, _server.DisplayName, ViewerExportSettings.CsvSeparator); + + // ── Column filtering (copied from ViewerServerTab.Filters.cs — the visual-tree-walk variant) ── + + /* Grid -> its filter manager, keyed for the FilterButton_Click visual-tree walk. Populated by + InitializeFinOpsTab (FinOpsTab.Loaders.cs). */ + private readonly Dictionary<DataGrid, IDataGridFilterManager> _filterManagers = new(); + + /* Host/apply plumbing lives in the shared Ui controller. Lazy (a field initializer can't reference the + instance field _filterManagers); the XAML-wired FilterButton_Click forwards to it. */ + private ColumnFilterPopupController? _filterPopupControllerField; + private ColumnFilterPopupController FilterPopupController => _filterPopupControllerField ??= new ColumnFilterPopupController(_filterManagers); + + private void FilterButton_Click(object sender, RoutedEventArgs e) => FilterPopupController.HandleFilterButtonClick(sender); +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/JobHistoryTab.xaml b/Darling/PerformanceMonitor.Darling.Viewer/JobHistoryTab.xaml new file mode 100644 index 000000000..cbe7df2fc --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/JobHistoryTab.xaml @@ -0,0 +1,213 @@ +<UserControl x:Class="PerformanceMonitor.Darling.Viewer.JobHistoryTab" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + mc:Ignorable="d" + d:DesignHeight="450" d:DesignWidth="900"> + <!-- Fleet-wide retained SQL Agent job-run history (issue #1433), a structural sibling of + AlertsHistoryTab rewired to Postgres via ViewerDataService.GetJobHistoryAsync. Time-range + Server + + Status + Category filters, per-column filter headers, failure / long-runtime / retry row + color-coding, and CSV export. Job history is a durable record, so there is no dismiss/mute surface. + run_datetime is de-skewed to naive-UTC in SQL and rendered in the viewer's local time. --> + <UserControl.Resources> + <ResourceDictionary> + <ContextMenu x:Key="DataGridContextMenu"> + <MenuItem Header="Copy Cell" Click="CopyCell_Click"> + <MenuItem.Icon><TextBlock Text="📋"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy Row" Click="CopyRow_Click"> + <MenuItem.Icon><TextBlock Text="📄"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy All Rows" Click="CopyAllRows_Click"> + <MenuItem.Icon><TextBlock Text="📑"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <MenuItem Header="Export to CSV..." Click="ExportToCsv_Click"> + <MenuItem.Icon><TextBlock Text="📊"/></MenuItem.Icon> + </MenuItem> + </ContextMenu> + + <Style x:Key="DefaultRowStyle" TargetType="DataGridRow"> + <Setter Property="ContextMenu" Value="{StaticResource DataGridContextMenu}"/> + </Style> + + <!-- Color-coding: canceled (gray), retry (yellow), long-running (amber), failed (red) — later + triggers win, so failed red takes precedence. Disabled jobs are dimmed/italic. --> + <Style x:Key="JobHistoryRowStyle" TargetType="DataGridRow" BasedOn="{StaticResource DefaultRowStyle}"> + <Style.Triggers> + <DataTrigger Binding="{Binding JobEnabled}" Value="False"> + <Setter Property="Opacity" Value="0.6"/> + <Setter Property="FontStyle" Value="Italic"/> + </DataTrigger> + <DataTrigger Binding="{Binding IsCanceled}" Value="True"> + <Setter Property="Background" Value="#33808080"/> + </DataTrigger> + <DataTrigger Binding="{Binding IsRetry}" Value="True"> + <Setter Property="Background" Value="#33EAB308"/> + </DataTrigger> + <DataTrigger Binding="{Binding IsLongRunning}" Value="True"> + <Setter Property="Background" Value="#33D97706"/> + </DataTrigger> + <DataTrigger Binding="{Binding IsFailed}" Value="True"> + <Setter Property="Background" Value="#33DC2626"/> + </DataTrigger> + </Style.Triggers> + </Style> + </ResourceDictionary> + </UserControl.Resources> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + + <!-- Header Controls --> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="10,8" Height="36"> + <TextBlock Text="Job History" VerticalAlignment="Center" Margin="0,0,10,0" FontWeight="Bold" FontSize="14" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBlock Text="Time Range:" VerticalAlignment="Center" Margin="5,0,4,0" + Foreground="{DynamicResource ForegroundMutedBrush}"/> + <ComboBox x:Name="TimeRangeComboBox" Width="100" VerticalAlignment="Center" + SelectionChanged="Filter_SelectionChanged"> + <ComboBoxItem Content="Last 24 Hours" Tag="24" IsSelected="True"/> + <ComboBoxItem Content="Last 7 Days" Tag="168"/> + <ComboBoxItem Content="Last 30 Days" Tag="720"/> + <ComboBoxItem Content="Last 90 Days" Tag="2160"/> + <ComboBoxItem Content="Last Year" Tag="8760"/> + </ComboBox> + <TextBlock Text="Server:" VerticalAlignment="Center" Margin="12,0,4,0" + Foreground="{DynamicResource ForegroundMutedBrush}"/> + <ComboBox x:Name="ServerFilterComboBox" Width="150" VerticalAlignment="Center" + SelectionChanged="Filter_SelectionChanged"> + <ComboBoxItem Content="All Servers" Tag="" IsSelected="True"/> + </ComboBox> + <TextBlock Text="Status:" VerticalAlignment="Center" Margin="12,0,4,0" + Foreground="{DynamicResource ForegroundMutedBrush}"/> + <ComboBox x:Name="StatusFilterComboBox" Width="110" VerticalAlignment="Center" + SelectionChanged="Filter_SelectionChanged"> + <ComboBoxItem Content="All Statuses" Tag="" IsSelected="True"/> + <ComboBoxItem Content="Failed" Tag="0"/> + <ComboBoxItem Content="Succeeded" Tag="1"/> + <ComboBoxItem Content="Retry" Tag="2"/> + <ComboBoxItem Content="Canceled" Tag="3"/> + </ComboBox> + <TextBlock Text="Category:" VerticalAlignment="Center" Margin="12,0,4,0" + Foreground="{DynamicResource ForegroundMutedBrush}"/> + <ComboBox x:Name="CategoryFilterComboBox" Width="150" VerticalAlignment="Center" + SelectionChanged="Filter_SelectionChanged"> + <ComboBoxItem Content="All Categories" Tag="" IsSelected="True"/> + </ComboBox> + <Button Content="Refresh" Click="RefreshButton_Click" Margin="12,0,0,0" Padding="10,4" MinWidth="70"/> + <TextBlock x:Name="JobCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" + FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + <TextBlock x:Name="AgentStatusIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" + FontSize="11" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundMutedBrush}"/> + <TextBlock x:Name="LastRefreshedIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" + FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}"/> + </StackPanel> + + <!-- DataGrid --> + <Grid Grid.Row="1" Margin="10,0,10,10"> + <DataGrid x:Name="JobHistoryDataGrid" + AutoGenerateColumns="False" + IsReadOnly="True" + CanUserSortColumns="True" + GridLinesVisibility="Horizontal" + CanUserResizeColumns="True" + HeadersVisibility="Column" + SelectionMode="Extended" + RowStyle="{StaticResource JobHistoryRowStyle}"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding RunTimeLocal}" Width="150" SortMemberPath="RunDateTimeUtc" SortDirection="Descending"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="RunTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Run Time" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ServerName}" Width="130"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ServerName" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Server" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding JobName}" Width="180"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="JobName" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Job" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CategoryName}" Width="120"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CategoryName" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Category" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding StepDisplay}" Width="150"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="StepDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Step" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding RunStatusDesc}" Width="90"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="RunStatusDesc" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Status" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DurationFormatted}" Width="90"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DurationFormatted" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Duration" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding RetriesDisplay}" Width="65"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="RetriesDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Retries" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LastSuccessfulRunLocal}" Width="150"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LastSuccessfulRunLocal" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Last Success" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Message}" Width="*" MinWidth="200"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Message" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Message" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + + <!-- Empty State --> + <TextBlock x:Name="NoJobsMessage" + Text="No job runs recorded in the selected time range" + FontSize="14" Foreground="{DynamicResource ForegroundMutedBrush}" + HorizontalAlignment="Center" VerticalAlignment="Center" + Visibility="Collapsed"/> + </Grid> + </Grid> +</UserControl> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/JobHistoryTab.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/JobHistoryTab.xaml.cs new file mode 100644 index 000000000..7fe8e5af6 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/JobHistoryTab.xaml.cs @@ -0,0 +1,408 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Threading; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// Fleet-wide retained SQL Agent job-run history (issue #1433) — the Darling viewer twin of Lite's +/// <c>JobHistoryTab</c>, a structural sibling of <see cref="AlertsHistoryTab"/> reading the Postgres store +/// via <see cref="ViewerDataService.GetJobHistoryAsync"/>. Time-range + Server + Status + Category filters, +/// per-column filter popups, failure / long-runtime / retry row color-coding, and CSV export. Job history +/// is a durable record, so there is no dismiss/mute surface (unlike alerts). Load failures surface on the +/// host status bar via <see cref="StatusChanged"/>. +/// </summary> +public partial class JobHistoryTab : UserControl +{ + private ViewerDataService? _dataService; + private DataGridFilterManager<ViewerJobHistoryRow>? _filterManager; + private Popup? _filterPopup; + private ColumnFilterPopup? _filterPopupContent; + private DateTime? _lastRefreshed; + private readonly DispatcherTimer _staleDataTimer; + + /// <summary>Raised with a short status message on load outcomes so the shell can show it.</summary> + public event Action<string>? StatusChanged; + + public JobHistoryTab() + { + InitializeComponent(); + _staleDataTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(10) }; + _staleDataTimer.Tick += StaleDataTimer_Tick; + } + + /// <summary>Wires the data service and the column-filter manager, and starts the stale-data ticker.</summary> + public void Initialize(ViewerDataService dataService) + { + _dataService = dataService; + _filterManager = new DataGridFilterManager<ViewerJobHistoryRow>(JobHistoryDataGrid); + _staleDataTimer.Start(); + } + + /// <summary>Reloads the job history (the shell calls this when the tab becomes visible / on the timer).</summary> + public Task RefreshJobsAsync() => LoadJobsAsync(); + + private async Task LoadJobsAsync() + { + if (_dataService == null) + { + return; + } + + try + { + var hoursBack = GetSelectedHoursBack(); + int? serverId = GetSelectedServerId(); + var sinceUtc = DateTime.UtcNow.AddHours(-hoursBack); + + var all = await _dataService.GetJobHistoryAsync(sinceUtc, serverId, 2000); + + /* Populate the Server / Category combos from the full (pre status/category) result, then apply + Status + Category client-side — those must NOT go into the reader's window (they'd skew the + per-job long-running / last-success baselines, computed over every run in the window). The + per-column filter popups still layer on top. */ + PopulateServerFilter(all); + PopulateCategoryFilter(all); + + var statusFilter = GetSelectedStatus(); + var categoryFilter = GetSelectedCategory(); + + var filtered = all.Where(r => + (statusFilter is null || r.RunStatus == statusFilter.Value) && + (categoryFilter is null || string.Equals(r.CategoryName, categoryFilter, StringComparison.OrdinalIgnoreCase))) + .ToList(); + + if (_filterManager != null) + { + _filterManager.UpdateData(filtered); + } + else + { + JobHistoryDataGrid.ItemsSource = filtered; + } + + var displayCount = JobHistoryDataGrid.Items.Count; + NoJobsMessage.Visibility = displayCount == 0 ? Visibility.Visible : Visibility.Collapsed; + JobCountIndicator.Text = displayCount > 0 ? $"{displayCount} run(s)" : ""; + + _lastRefreshed = DateTime.UtcNow; + UpdateStaleDataIndicator(); + + await UpdateAgentStatusAsync(serverId); + } + catch (Exception ex) + { + StatusChanged?.Invoke($"failed to load job history: {ex.Message}"); + } + } + + /// <summary> + /// Populates the header Agent indicator (issue #1433 Phase 2). With a server selected it shows that + /// server's Agent status + next scheduled run; across all servers it shows a running/stopped roll-up. + /// A stopped Agent is drawn in red. Best-effort — an agent_status read failure just clears the indicator. + /// </summary> + private async Task UpdateAgentStatusAsync(int? serverId) + { + if (_dataService == null) return; + + try + { + var statuses = await _dataService.GetAgentStatusAsync(serverId); + + var okBrush = TryFindResource("ForegroundMutedBrush") as System.Windows.Media.Brush + ?? System.Windows.Media.Brushes.Gray; + var alertBrush = new System.Windows.Media.SolidColorBrush( + System.Windows.Media.Color.FromRgb(0xDC, 0x26, 0x26)); + + if (statuses.Count == 0) + { + AgentStatusIndicator.Text = ""; + return; + } + + if (serverId.HasValue) + { + var s = statuses[0]; + AgentStatusIndicator.Text = $"Agent: {s.StatusDisplay} · Next run: {s.NextScheduledRunLocal}"; + AgentStatusIndicator.Foreground = s.AgentRunning ? okBrush : alertBrush; + } + else + { + var running = statuses.Count(x => x.AgentRunning); + var stopped = statuses.Count - running; + AgentStatusIndicator.Text = stopped > 0 + ? $"Agents: {running}/{statuses.Count} running, {stopped} stopped" + : $"Agents: {running}/{statuses.Count} running"; + AgentStatusIndicator.Foreground = stopped > 0 ? alertBrush : okBrush; + } + } + catch (Exception ex) + { + StatusChanged?.Invoke($"failed to load agent status: {ex.Message}"); + AgentStatusIndicator.Text = ""; + } + } + + private void PopulateServerFilter(List<ViewerJobHistoryRow> rows) + { + var servers = rows + .Select(r => (r.ServerId, r.ServerName)) + .Where(s => !string.IsNullOrEmpty(s.ServerName)) + .Distinct() + .OrderBy(s => s.ServerName) + .ToList(); + + var currentSelection = ServerFilterComboBox.SelectedIndex > 0 + ? (ServerFilterComboBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() + : null; + + var existingIds = ServerFilterComboBox.Items + .OfType<ComboBoxItem>() + .Skip(1) + .Select(i => i.Tag?.ToString()) + .ToList(); + + var newIds = servers.Select(s => s.ServerId.ToString()).ToList(); + if (newIds.SequenceEqual(existingIds)) + { + return; + } + + ServerFilterComboBox.SelectionChanged -= Filter_SelectionChanged; + + while (ServerFilterComboBox.Items.Count > 1) + { + ServerFilterComboBox.Items.RemoveAt(1); + } + + foreach (var (serverId, serverName) in servers) + { + ServerFilterComboBox.Items.Add(new ComboBoxItem { Content = serverName, Tag = serverId.ToString() }); + } + + if (currentSelection != null) + { + for (var i = 1; i < ServerFilterComboBox.Items.Count; i++) + { + if ((ServerFilterComboBox.Items[i] as ComboBoxItem)?.Tag?.ToString() == currentSelection) + { + ServerFilterComboBox.SelectedIndex = i; + break; + } + } + } + + ServerFilterComboBox.SelectionChanged += Filter_SelectionChanged; + } + + private void PopulateCategoryFilter(List<ViewerJobHistoryRow> rows) + { + var categories = rows + .Select(r => r.CategoryName) + .Where(c => !string.IsNullOrEmpty(c)) + .Select(c => c!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(c => c, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var currentSelection = CategoryFilterComboBox.SelectedIndex > 0 + ? (CategoryFilterComboBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() + : null; + + var existing = CategoryFilterComboBox.Items + .OfType<ComboBoxItem>() + .Skip(1) + .Select(i => i.Tag?.ToString()) + .ToList(); + + if (categories.SequenceEqual(existing, StringComparer.OrdinalIgnoreCase)) + { + return; + } + + CategoryFilterComboBox.SelectionChanged -= Filter_SelectionChanged; + + while (CategoryFilterComboBox.Items.Count > 1) + { + CategoryFilterComboBox.Items.RemoveAt(1); + } + + foreach (var category in categories) + { + CategoryFilterComboBox.Items.Add(new ComboBoxItem { Content = category, Tag = category }); + } + + if (currentSelection != null) + { + for (var i = 1; i < CategoryFilterComboBox.Items.Count; i++) + { + if ((CategoryFilterComboBox.Items[i] as ComboBoxItem)?.Tag?.ToString() == currentSelection) + { + CategoryFilterComboBox.SelectedIndex = i; + break; + } + } + } + + CategoryFilterComboBox.SelectionChanged += Filter_SelectionChanged; + } + + private int GetSelectedHoursBack() + { + if (TimeRangeComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tagStr) + { + return int.TryParse(tagStr, out var hours) ? hours : 24; + } + return 24; + } + + private int? GetSelectedServerId() + { + if (ServerFilterComboBox.SelectedIndex > 0 && + ServerFilterComboBox.SelectedItem is ComboBoxItem item && + item.Tag is string tagStr && + int.TryParse(tagStr, out var serverId)) + { + return serverId; + } + return null; + } + + private int? GetSelectedStatus() + { + if (StatusFilterComboBox.SelectedIndex > 0 && + StatusFilterComboBox.SelectedItem is ComboBoxItem item && + item.Tag is string tagStr && + int.TryParse(tagStr, out var status)) + { + return status; + } + return null; + } + + private string? GetSelectedCategory() + { + if (CategoryFilterComboBox.SelectedIndex > 0 && + CategoryFilterComboBox.SelectedItem is ComboBoxItem item && + item.Tag is string tagStr && + !string.IsNullOrEmpty(tagStr)) + { + return tagStr; + } + return null; + } + + #region Column Filter Handlers + + private void FilterButton_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button button || button.Tag is not string columnName) + { + return; + } + + if (_filterPopup == null) + { + _filterPopupContent = new ColumnFilterPopup(); + _filterPopupContent.FilterApplied += FilterPopup_FilterApplied; + _filterPopupContent.FilterCleared += FilterPopup_FilterCleared; + + _filterPopup = new Popup + { + Child = _filterPopupContent, + StaysOpen = false, + Placement = PlacementMode.Bottom, + AllowsTransparency = true + }; + } + + ColumnFilterState? existingFilter = null; + _filterManager?.Filters.TryGetValue(columnName, out existingFilter); + _filterPopupContent!.Initialize(columnName, existingFilter); + + _filterPopup.PlacementTarget = button; + _filterPopup.IsOpen = true; + } + + private void FilterPopup_FilterApplied(object? sender, FilterAppliedEventArgs e) + { + if (_filterPopup != null) + { + _filterPopup.IsOpen = false; + } + + _filterManager?.SetFilter(e.FilterState); + JobCountIndicator.Text = JobHistoryDataGrid.Items.Count > 0 ? $"{JobHistoryDataGrid.Items.Count} run(s)" : ""; + } + + private void FilterPopup_FilterCleared(object? sender, EventArgs e) + { + if (_filterPopup != null) + { + _filterPopup.IsOpen = false; + } + } + + #endregion + + #region Stale Data Indicator + + private void StaleDataTimer_Tick(object? sender, EventArgs e) => UpdateStaleDataIndicator(); + + private void UpdateStaleDataIndicator() + { + if (_lastRefreshed.HasValue) + { + var elapsed = DateTime.UtcNow - _lastRefreshed.Value; + LastRefreshedIndicator.Text = elapsed.TotalSeconds < 5 + ? "Refreshed just now" + : elapsed.TotalMinutes < 1 + ? $"Refreshed {(int)elapsed.TotalSeconds}s ago" + : $"Refreshed {(int)elapsed.TotalMinutes}m ago"; + } + } + + #endregion + + #region Event Handlers + + private async void Filter_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (IsLoaded) + { + await LoadJobsAsync(); + } + } + + private async void RefreshButton_Click(object sender, RoutedEventArgs e) => await LoadJobsAsync(); + + #endregion + + #region Context Menu Handlers + + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); + + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); + + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); + + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "job_history", ViewerExportSettings.CsvSeparator); + + #endregion +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.AlertBadges.cs b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.AlertBadges.cs new file mode 100644 index 000000000..c6e2203d1 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.AlertBadges.cs @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The per-server alert BADGE surface — the headless viewer's port of Lite's per-tab alert badge + Acknowledge. +/// Lite renders a count badge on each server tab and quiets it with an "ack-until-worse" gesture; this brings the +/// same at-a-glance "which servers need attention" surface to the viewer, on BOTH the per-server tab header and +/// the sidebar server row, driven off the same <c>config_alert_log</c> poll that already feeds the tray toasts. +/// +/// <para>The alert state comes from <see cref="ServerAttentionDeriver"/> (pure grouping of the polled rows) gated +/// through <see cref="ViewerAlertStateService"/> (viewer-local acknowledgement). Silence is NOT re-implemented +/// here: a silenced server's alerts come back <c>muted</c> and the deriver drops them, so the badge respects the +/// existing store-side "Silence This Server" mute rule automatically.</para> +/// </summary> +public partial class MainWindow +{ + /// <summary> + /// Viewer-local per-server acknowledgement store (ack-until-worse). Persists to + /// %APPDATA%\PerformanceMonitorDarling\alert-ack-state.json so an acknowledgement survives a restart. + /// </summary> + private readonly ViewerAlertStateService _alertStateService = new(); + + /// <summary> + /// Recomputes every server's badge from a freshly-polled alert-history batch (the SAME read the toast poll + /// uses — no extra query). For each managed server it derives the active alert count, clears/holds the + /// acknowledgement via <see cref="ViewerAlertStateService.UpdateAlertState"/>, then paints the sidebar row + /// badge and (if open) the tab-header badge. Called from the alert poll on the fleet-refresh cadence. + /// </summary> + private void UpdateServerAttention(IReadOnlyList<ViewerAlertRow> rows) + { + var states = ServerAttentionDeriver.Derive(rows); + + var servers = (ServerList.ItemsSource as IEnumerable<DarlingServer>)?.ToList() + ?? new List<DarlingServer>(); + + foreach (var server in servers) + { + /* Absent from the dictionary => default(ServerAttentionState) => AlertCount 0 => badge clears. */ + states.TryGetValue(server.ServerId, out var state); + + bool show = _alertStateService.UpdateAlertState(server.ServerId, state.AlertCount, state.LatestEventUtc); + + server.SetAttention( + show ? state.AlertCount : 0, + show && state.IsCritical, + show ? SidebarBadgeTooltip(state) : null); + + if (_openServerTabs.TryGet(server.ServerId, out var tab) && tab.Header is StackPanel header) + { + RenderTabBadge(header, show, state); + } + } + } + + /// <summary>The sidebar badge tooltip: the metric breakdown plus how to quiet it (the row's right-click menu).</summary> + private static string SidebarBadgeTooltip(ServerAttentionState state) + { + var summaryLine = string.IsNullOrEmpty(state.Summary) ? "" : state.Summary + "\n"; + return summaryLine + "Right-click to acknowledge"; + } + + /// <summary> + /// Adds the (collapsed) alert badge to a per-server tab header, wired for acknowledgement: a left-click or the + /// "Acknowledge Alerts" context-menu item quiets it until the condition worsens/recurs. Mirrors Lite's tab + /// badge affordances (the plain left-click was added in Lite #1092 because the right-click menu was + /// undiscoverable). Called from <c>CreateServerTabHeader</c>. + /// </summary> + private void AttachAlertBadge(StackPanel header, int serverId) + { + var badge = new Border + { + CornerRadius = new CornerRadius(7), + Padding = new Thickness(5, 0, 5, 0), + Margin = new Thickness(0, 0, 4, 0), + MinWidth = 16, + Height = 15, + VerticalAlignment = VerticalAlignment.Center, + Visibility = Visibility.Collapsed, + Tag = "AlertBadge", + Cursor = Cursors.Hand, + Child = new TextBlock + { + Foreground = Brushes.White, + FontSize = 9, + FontWeight = FontWeights.Bold, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + } + }; + badge.SetResourceReference(Border.BackgroundProperty, "WarningBrush"); + + var acknowledgeItem = new MenuItem + { + Header = "Acknowledge Alerts", + Icon = new TextBlock { Text = "✓", FontWeight = FontWeights.Bold } + }; + acknowledgeItem.Click += (_, _) => AcknowledgeServer(serverId); + var menu = new ContextMenu(); + menu.Items.Add(acknowledgeItem); + badge.ContextMenu = menu; + + badge.MouseLeftButtonUp += (_, e) => + { + AcknowledgeServer(serverId); + e.Handled = true; + }; + + header.Children.Add(badge); + } + + /// <summary> + /// Paints (or hides) the alert badge Border on a tab header. Critical alerts colour it with the theme + /// ErrorBrush, warnings with WarningBrush (via a DynamicResource reference so it tracks theme changes) — + /// the viewer-theme analog of Lite's deadlock→Red / else→OrangeRed. The count caps at "99+" like Lite. + /// </summary> + private static void RenderTabBadge(StackPanel header, bool show, ServerAttentionState state) + { + foreach (var child in header.Children) + { + if (child is not Border border || (border.Tag as string) != "AlertBadge") + { + continue; + } + + if (show && state.AlertCount > 0) + { + border.Visibility = Visibility.Visible; + border.SetResourceReference(Border.BackgroundProperty, state.IsCritical ? "ErrorBrush" : "WarningBrush"); + + if (border.Child is TextBlock text) + { + text.Text = state.AlertCount > 99 + ? "99+" + : state.AlertCount.ToString(CultureInfo.InvariantCulture); + } + + var summaryLine = string.IsNullOrEmpty(state.Summary) ? "" : state.Summary + "\n"; + border.ToolTip = summaryLine + "Click to acknowledge · Right-click for options"; + } + else + { + border.Visibility = Visibility.Collapsed; + } + + return; + } + } + + /// <summary> + /// Acknowledges a server's alerts (records the ack-until-worse watermark) and immediately clears its badge on + /// both surfaces so the gesture feels responsive — the next poll would clear them anyway. Shared by the tab + /// badge left-click / context menu and the sidebar row's "Acknowledge Alerts" menu. + /// </summary> + private void AcknowledgeServer(int serverId) + { + _alertStateService.AcknowledgeAlert(serverId); + + if (ServerList.ItemsSource is IEnumerable<DarlingServer> servers) + { + servers.FirstOrDefault(s => s.ServerId == serverId)?.SetAttention(0, false, null); + } + + if (_openServerTabs.TryGet(serverId, out var tab) && tab.Header is StackPanel header) + { + RenderTabBadge(header, show: false, default); + } + + StatusText.Text = $"Acknowledged alerts — {DateTime.Now:HH:mm:ss}"; + } + + /// <summary>Sidebar row context-menu "Acknowledge Alerts" — quiets the badge for the right-clicked server.</summary> + private void ServerContextMenu_Acknowledge_Click(object sender, RoutedEventArgs e) + { + var server = GetServerFromContextMenu(sender); + if (server is null) + { + return; + } + + AcknowledgeServer(server.ServerId); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.PlanViewer.cs b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.PlanViewer.cs new file mode 100644 index 000000000..810e5a193 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.PlanViewer.cs @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Threading; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The standalone Plan Viewer surface for the Darling viewer's top tab strip (<c>MainTabs</c>), ported +/// from Lite's <c>MainWindow.PlanViewer.cs</c>: a hidden-until-used, closable "Plan Viewer" aggregate tab +/// (<c>MainWindowPlanViewerTab</c>) that hosts the SHARED <see cref="PlanViewerControl"/> as closable +/// sub-tabs. Unlike the per-server Plan Viewer (<see cref="ViewerServerTab"/>'s <c>OpenPlanTab</c>, which +/// fetches a STORED plan from Postgres), this surface reads no store data and needs no server connection -- +/// the user supplies the plan by opening a .sqlplan / plan-XML file, pasting plan XML, or dragging a file +/// in. Opened / focused from the sidebar's "Open Plan Viewer" button. +/// +/// The tab management itself (empty "New Plan" sub-tabs, open/paste/drag-drop, plan rendering, unique +/// labelling, teardown) is the shared <see cref="StandalonePlanViewerController"/> -- this file keeps only +/// the app-specific outer-container reveal/close (<c>MainTabs</c> vs the no-servers empty state) and forwards +/// the XAML-wired drag-over / drop / key-down handlers. +/// </summary> +public partial class MainWindow +{ + /* Lazy (a field initializer can't reference the instance-named MainWindowPlanTabControl). */ + private StandalonePlanViewerController? _planViewerControllerField; + private StandalonePlanViewerController PlanViewerController => + _planViewerControllerField ??= new StandalonePlanViewerController(MainWindowPlanTabControl); + + private void OpenPlanViewerButton_Click(object sender, RoutedEventArgs e) + { + PlanViewerController.EnsureInitialized(); + /* The Plan Viewer is server-independent: show it even when no servers are registered (in which + case the no-servers empty state otherwise owns the content column with MainTabs collapsed). */ + EmptyStatePanel.Visibility = Visibility.Collapsed; + MainTabs.Visibility = Visibility.Visible; + MainWindowPlanViewerTab.Visibility = Visibility.Visible; + if (MainWindowPlanViewerTab.IsSelected) + { + // Already on Plan Viewer -- just add a new empty sub-tab + PlanViewerController.AddNewEmptyPlanSubTab(); + } + else + { + MainWindowPlanViewerTab.IsSelected = true; + PlanViewerController.AddNewEmptyPlanSubTab(); + } + Dispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(() => MainWindowPlanTabControl.Focus())); + } + + /// <summary> + /// Opens a STORED plan in the standalone Plan Viewer surface -- the destination for a "View Plan" click on + /// the FinOps aggregate tab's High Impact / Expensive Queries grids (raised via <c>FinOpsTab.PlanRequested</c>). + /// That aggregate tab has no per-server plan host, so it feeds the plan XML it already holds here: reveal the + /// Plan Viewer aggregate tab and render the plan in a fresh sub-tab. Unlike <see cref="OpenPlanViewerButton_Click"/> + /// (which opens an empty sub-tab for the user to paste/open into), this loads a plan the caller supplies. + /// </summary> + private void OpenStoredPlanInPlanViewer(string planXml, string label, string? queryText) + { + PlanViewerController.EnsureInitialized(); + /* Server-independent surface: show it even when the no-servers empty state otherwise owns the column. */ + EmptyStatePanel.Visibility = Visibility.Collapsed; + MainTabs.Visibility = Visibility.Visible; + MainWindowPlanViewerTab.Visibility = Visibility.Visible; + MainWindowPlanViewerTab.IsSelected = true; + + var subTab = PlanViewerController.AddNewEmptyPlanSubTab(); + PlanViewerController.LoadPlanIntoSubTab(subTab, planXml, label, queryText); + } + + private void MainWindowPlanViewerClose_Click(object sender, RoutedEventArgs e) + { + // Cleanup each sub-tab's viewer + reset the inner tab control (shared), then restore this app's shell. + PlanViewerController.Reset(); + MainWindowPlanViewerTab.Visibility = Visibility.Collapsed; + + /* Return the content column to the state it was in before the Plan Viewer opened: if no servers + are registered, restore the no-servers empty state (MainTabs collapsed, mirroring + LoadServersAsync's no-servers branch); otherwise select the first visible non-plan tab. */ + if (ServerList.ItemsSource is IEnumerable<DarlingServer> servers && servers.Any()) + { + foreach (TabItem item in MainTabs.Items) + { + if (item.Visibility == Visibility.Visible && item != MainWindowPlanViewerTab) + { + item.IsSelected = true; + break; + } + } + } + else + { + MainTabs.Visibility = Visibility.Collapsed; + EmptyStatePanel.Visibility = Visibility.Visible; + } + } + + private void MainWindowPlanViewer_DragOver(object sender, DragEventArgs e) => + PlanViewerController.HandleDragOver(e); + + private void MainWindowPlanViewer_Drop(object sender, DragEventArgs e) => + PlanViewerController.HandleDrop(e); + + private void MainWindowPlanViewer_KeyDown(object sender, System.Windows.Input.KeyEventArgs e) => + PlanViewerController.HandleKeyDown(e); +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.ServerManagement.cs b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.ServerManagement.cs new file mode 100644 index 000000000..72af79cb4 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.ServerManagement.cs @@ -0,0 +1,703 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The MainWindow server-management + status-chrome partial (ported to parity with Lite's MainWindow): +/// the sidebar's favorite pinning + collection-freshness status dots, the right-click server menu, the +/// footer Add / Manage / Import / log buttons, the sidebar collapse toggle, and the multi-field status bar. +/// +/// <para>Control plane (Stage 3): Add / Edit / Remove / Enable WRITE the desired-state +/// <c>config.config_monitored_servers</c> through <see cref="ViewerDataService"/>, so the Darling service +/// starts/stops collecting on its next reload — the sidebar shows that config-driven managed set +/// (<see cref="ViewerDataService.GetManagedServersAsync"/>), enriched with the observed collection facts. +/// The dots still come from collection freshness (the viewer never pings a monitored server), and +/// Connect/Disconnect have no meaning and are omitted. Favorites remain viewer-local +/// (<see cref="ViewerServerStore"/>, matched by server name), starred and sorted-to-top here.</para> +/// </summary> +public partial class MainWindow +{ + /// <summary>The viewer's own server-definition registry, backing Add / Manage / Edit / Remove + favorites.</summary> + private readonly ViewerServerStore _serverStore = new(); + + /// <summary>The viewer's credential-profile registry, lazily built (it depends on <see cref="_serverStore"/>).</summary> + private ViewerProfileStore? _profileStoreInstance; + private ViewerProfileStore ProfileStore => _profileStoreInstance ??= new ViewerProfileStore(_serverStore); + + private bool _sidebarCollapsed; + + // ── Server-list enrichment (favorites + freshness) ────────────────────────────── + + /// <summary> + /// Stamps each row's favorite flag from the registry (by server name) and returns the list sorted + /// favorites-first, then by display name — Lite's pin ordering. Called on load and after a registry change. + /// </summary> + private List<DarlingServer> ApplyFavoritesAndSort(List<DarlingServer> servers) + { + foreach (var s in servers) + { + s.IsFavorite = _serverStore.IsFavorite(s.ServerName); + } + + return SortWithFavorites(servers); + } + + private static List<DarlingServer> SortWithFavorites(List<DarlingServer> servers) => + servers + .OrderByDescending(s => s.IsFavorite) + .ThenBy(s => s.DisplayName, StringComparer.OrdinalIgnoreCase) + .ToList(); + + /// <summary>Re-stamps favorites on the currently-shown rows and re-sorts, preserving the selection.</summary> + private void ReapplyFavoritesToServerList() + { + if (ServerList.ItemsSource is not IEnumerable<DarlingServer> current) + { + return; + } + + foreach (var s in current) + { + s.IsFavorite = _serverStore.IsFavorite(s.ServerName); + } + + var selected = ServerList.SelectedItem as DarlingServer; + ServerList.ItemsSource = SortWithFavorites(current.ToList()); + if (selected is not null) + { + ServerList.SelectedItem = selected; + } + } + + /// <summary> + /// Refreshes the sidebar status dots and the status bar's collector-health + collection fields from a + /// single collection-freshness query (the same MAX(collection_time) signal the Overview cards use). + /// Updates each row in place so the dots recolour without resetting the list's selection. + /// </summary> + private async Task RefreshServerStatusAsync() + { + if (_dataService is null) + { + return; + } + + Dictionary<int, DateTime> freshness; + try + { + freshness = await _dataService.GetServerFreshnessAsync(); + } + catch (Exception ex) + { + ViewerLogger.Warn("ServerStatus", $"freshness read failed: {ex.Message}"); + return; + } + + var nowUtc = DateTime.UtcNow; + var servers = (ServerList.ItemsSource as IEnumerable<DarlingServer>)?.ToList() ?? new List<DarlingServer>(); + + DateTime? newest = null; + + foreach (var s in servers) + { + DateTime? last = freshness.TryGetValue(s.ServerId, out var t) ? t : null; + s.ApplyFreshness(last, nowUtc); + + if (last.HasValue && (newest is null || last.Value > newest.Value)) + { + newest = last.Value; + } + } + + /* The status bar's collector-health field is the SELECTED server's REAL per-collector health — + the server COUNT is a separate field (ServerCountText). Mirrors Lite's UpdateCollectorHealth. */ + await UpdateCollectorHealthTextAsync(); + + if (newest is null) + { + CollectionStatusText.Text = "Collection: Idle"; + } + else + { + var age = nowUtc - newest.Value; + CollectionStatusText.Text = age.TotalSeconds < 90 + ? "Collection: Active" + : $"Collection: {FormatAge(age)} ago"; + } + } + + /// <summary> + /// Sets the status bar's collector-health field from the ACTIVE TAB's scope, mirroring Lite's + /// <c>UpdateCollectorHealth</c>: a per-server tab shows THAT server's collectors; an aggregate tab + /// (Overview / Alert History / FinOps / Recommendations) shows the FLEET-CUMULATIVE total across all + /// enabled servers (Lite's <c>GetHealthSummary(null)</c> on a non-server view). "Collectors: N OK" when + /// all healthy, or "Collectors: N erroring" (with the failing names) when any collector is FAILING. Reuses + /// the Collection Health tab's <see cref="ViewerDataService.GetCollectionHealthAsync"/> / + /// <see cref="ViewerDataService.GetFleetCollectionHealthAsync"/> aggregates and the + /// <see cref="CollectorHealthRow.HealthStatus"/> banding, so the status bar and that tab always agree. + /// FAILING is the only "erroring" band — NO_PERMISSIONS (e.g. an RDS login lacking msdb rights) and + /// SKIPPED-as-healthy (e.g. running_jobs on Azure SQL DB) surface in the Collection Health tab, not here, + /// exactly like Lite. The server COUNT is a separate field (ServerCountText); this one is collectors, + /// which is why the pre-fix "Collectors: {online-servers} OK" read wrong. + /// </summary> + private async Task UpdateCollectorHealthTextAsync() + { + if (_dataService is null) + { + CollectorHealthText.Text = ""; + CollectorHealthText.ToolTip = null; + return; + } + + /* Per-server tab -> that server's collectors; an aggregate tab -> the fleet-cumulative total across all + enabled servers (mirrors Lite's cumulative count on a non-server view). */ + int? serverId = MainTabs.SelectedItem is System.Windows.Controls.TabItem { Content: ViewerServerTab serverTab } + ? serverTab.ServerId + : null; + + List<CollectorHealthRow> health; + try + { + health = serverId.HasValue + ? await _dataService.GetCollectionHealthAsync(serverId.Value) + : await _dataService.GetFleetCollectionHealthAsync(); + } + catch (Exception ex) + { + ViewerLogger.Warn("ServerStatus", $"collector health read failed: {ex.Message}"); + return; + } + + if (health.Count == 0) + { + CollectorHealthText.Text = ""; + CollectorHealthText.ToolTip = null; + return; + } + + var failing = health.Where(h => h.HealthStatus == "FAILING").ToList(); + if (failing.Count > 0) + { + CollectorHealthText.Text = $"Collectors: {failing.Count} erroring"; + CollectorHealthText.Foreground = System.Windows.Media.Brushes.OrangeRed; + CollectorHealthText.ToolTip = "Failing: " + string.Join(", ", failing.Select(h => h.CollectorName).Distinct()); + } + else + { + CollectorHealthText.Text = $"Collectors: {health.Count} OK"; + CollectorHealthText.Foreground = (System.Windows.Media.Brush)FindResource("ForegroundMutedBrush"); + CollectorHealthText.ToolTip = null; + } + } + + /// <summary>Refreshes the status bar's database-size field from the store's on-disk size.</summary> + private async Task RefreshStoreSizeAsync() + { + if (_dataService is null) + { + return; + } + + try + { + var bytes = await _dataService.GetStoreSizeBytesAsync(); + DatabaseSizeText.Text = bytes.HasValue ? $"Database: {FormatBytes(bytes.Value)}" : "Database: --"; + } + catch (Exception ex) + { + ViewerLogger.Warn("ServerStatus", $"store size read failed: {ex.Message}"); + } + } + + private static string FormatAge(TimeSpan age) + { + if (age.TotalMinutes < 1) return $"{age.TotalSeconds:F0}s"; + if (age.TotalHours < 1) return $"{age.TotalMinutes:F0}m"; + if (age.TotalDays < 1) return $"{age.TotalHours:F0}h"; + return $"{age.TotalDays:F0}d"; + } + + private static string FormatBytes(long bytes) + { + const double mb = 1024.0 * 1024.0; + const double gb = mb * 1024.0; + return bytes >= gb ? $"{bytes / gb:F1} GB" : $"{bytes / mb:F0} MB"; + } + + // ── Server-row context menu (Toggle Favorite / Edit / Remove — no Connect/Disconnect) ───── + + /// <summary>The <see cref="DarlingServer"/> behind a server-row context-menu click (mirrors Lite's resolver).</summary> + private static DarlingServer? GetServerFromContextMenu(object sender) + { + if (sender is not MenuItem menuItem) + { + return null; + } + + var contextMenu = menuItem.Parent as ContextMenu; + var target = contextMenu?.PlacementTarget as FrameworkElement; + return target?.DataContext as DarlingServer; + } + + private void ServerContextMenu_ToggleFavorite_Click(object sender, RoutedEventArgs e) + { + var server = GetServerFromContextMenu(sender); + if (server is null) + { + return; + } + + var isFavorite = _serverStore.ToggleFavorite(server.ServerName); + server.IsFavorite = isFavorite; + ReapplyFavoritesToServerList(); + StatusText.Text = isFavorite ? $"Pinned {server.DisplayName}" : $"Unpinned {server.DisplayName}"; + } + + private async void ServerContextMenu_Edit_Click(object sender, RoutedEventArgs e) + { + var server = GetServerFromContextMenu(sender); + if (server is null || _dataService is null) + { + return; + } + + try + { + /* Load the store row by the shared server_id. It is normally present (the sidebar shows the + config-driven set); on a pre-seed store showing collect.servers, seed a new definition from the + server's name so the operator can add it to the store. */ + var row = await _dataService.GetMonitoredServerAsync(server.ServerId); + var favorite = _serverStore.IsFavorite(server.ServerName); + + var dialog = row is not null + ? new AddServerDialog(_dataService, _serverStore, ProfileStore, row, favorite) { Owner = this } + : new AddServerDialog(_dataService, _serverStore, ProfileStore, server.ServerName, server.DisplayName) { Owner = this }; + + if (dialog.ShowDialog() == true) + { + await LoadServersAsync(preserveSelection: true); + StatusText.Text = $"Saved '{dialog.SavedDisplayName ?? server.DisplayName}'."; + } + } + catch (Exception ex) + { + ViewerLogger.Error("ServerManagement", "Failed to open the edit dialog", ex); + StatusText.Text = $"Could not edit the server: {ex.Message}"; + } + } + + private async void ServerContextMenu_Remove_Click(object sender, RoutedEventArgs e) + { + var server = GetServerFromContextMenu(sender); + if (server is null || _dataService is null) + { + return; + } + + var result = MessageBox.Show( + $"Remove '{server.DisplayName}' from monitoring?\n\n" + + "This deletes the server definition from the store, so the Darling service stops collecting it on " + + "its next reload. Already-collected history is kept.", + "Remove Server", + MessageBoxButton.YesNo, + MessageBoxImage.Question); + + if (result != MessageBoxResult.Yes) + { + return; + } + + try + { + await _dataService.DeleteMonitoredServerAsync(server.ServerId); + /* Drop the viewer-local favorite pin + alert-acknowledgement state for the gone server. */ + _serverStore.SetFavorite(server.ServerName, false); + _alertStateService.RemoveServerState(server.ServerId); + await LoadServersAsync(preserveSelection: true); + StatusText.Text = $"Removed '{server.DisplayName}' from monitoring."; + } + catch (ViewerReadOnlyException ex) + { + StatusText.Text = ex.Message; + } + catch (ViewerSchemaSkewException ex) + { + StatusText.Text = ex.Message; + } + catch (Exception ex) + { + ViewerLogger.Error("ServerManagement", "Failed to remove server", ex); + StatusText.Text = $"Could not remove the server: {ex.Message}"; + } + } + + // ── Server-row silence (one-click whole-server alert mute via a server-scoped MuteRule) ───── + + /// <summary> + /// "Silence This Server" — writes a whole-server mute rule (see + /// <see cref="ViewerDataService.BuildServerSilenceRule"/>) the running Darling service honors on its next + /// config reload: every future alert for this server is flagged muted (channels skipped for email/Teams/Slack) + /// and so never toasts. The Darling shortcut over the multi-step Manage Mute Rules dialog, mirroring Lite's + /// one-click "Silence This Server". Idempotent: an existing active silence is reported, not duplicated. Keyed + /// on the server's DISPLAY name (what the alert engine's mute context + the alert rows carry). A read-only + /// seat / schema-skew / failure degrades to the friendly status message like the other server-row writes. + /// </summary> + private async void ServerContextMenu_Silence_Click(object sender, RoutedEventArgs e) + { + var server = GetServerFromContextMenu(sender); + if (server is null || _dataService is null) + { + return; + } + + try + { + var rules = await _dataService.GetMuteRulesAsync(); + if (rules.Any(r => ViewerDataService.IsWholeServerSilence(r, server.DisplayName) && r.Enabled && !r.IsExpired)) + { + StatusText.Text = $"'{server.DisplayName}' is already silenced."; + return; + } + + await _dataService.InsertMuteRuleAsync(ViewerDataService.BuildServerSilenceRule(server.DisplayName)); + StatusText.Text = $"Silenced all alerts for '{server.DisplayName}'. Right-click → Unsilence to restore."; + } + catch (ViewerReadOnlyException ex) + { + StatusText.Text = ex.Message; + } + catch (ViewerSchemaSkewException ex) + { + StatusText.Text = ex.Message; + } + catch (Exception ex) + { + ViewerLogger.Error("ServerManagement", "Failed to silence the server", ex); + StatusText.Text = $"Could not silence the server: {ex.Message}"; + } + } + + /// <summary> + /// "Unsilence" — removes every whole-server silence rule for this server (the counter-action to + /// <see cref="ServerContextMenu_Silence_Click"/>), so the service resumes delivering its alerts on the next + /// reload. Only blanket silences match (see <see cref="ViewerDataService.IsWholeServerSilence"/>), so a + /// specific metric/query mute the operator authored for the server is left alone. Reports when the server was + /// not silenced. + /// </summary> + private async void ServerContextMenu_Unsilence_Click(object sender, RoutedEventArgs e) + { + var server = GetServerFromContextMenu(sender); + if (server is null || _dataService is null) + { + return; + } + + try + { + var rules = await _dataService.GetMuteRulesAsync(); + var silences = rules.Where(r => ViewerDataService.IsWholeServerSilence(r, server.DisplayName)).ToList(); + if (silences.Count == 0) + { + StatusText.Text = $"'{server.DisplayName}' is not silenced."; + return; + } + + foreach (var rule in silences) + { + await _dataService.DeleteMuteRuleAsync(rule.Id); + } + + StatusText.Text = $"Unsilenced '{server.DisplayName}'."; + } + catch (ViewerReadOnlyException ex) + { + StatusText.Text = ex.Message; + } + catch (ViewerSchemaSkewException ex) + { + StatusText.Text = ex.Message; + } + catch (Exception ex) + { + ViewerLogger.Error("ServerManagement", "Failed to unsilence the server", ex); + StatusText.Text = $"Could not unsilence the server: {ex.Message}"; + } + } + + // ── Footer buttons (Add / Manage / Import Settings / View Log / Open Log Folder) ───── + + private async void AddServerButton_Click(object sender, RoutedEventArgs e) + { + if (_dataService is null) + { + StatusText.Text = "Connect to a Darling store before adding servers."; + return; + } + + var dialog = new AddServerDialog(_dataService, _serverStore, ProfileStore) { Owner = this }; + if (dialog.ShowDialog() == true) + { + await LoadServersAsync(preserveSelection: true); + StatusText.Text = string.IsNullOrEmpty(dialog.SavedDisplayName) + ? "Server saved." + : $"Saved '{dialog.SavedDisplayName}'. The Darling service will start collecting it on its next reload."; + } + } + + private async void AddMultipleServersButton_Click(object sender, RoutedEventArgs e) + { + if (_dataService is null) + { + StatusText.Text = "Connect to a Darling store before adding servers."; + return; + } + + var dialog = new AddMultipleServersDialog(_dataService, _serverStore, ProfileStore) { Owner = this }; + if (dialog.ShowDialog() == true && dialog.AddedCount > 0) + { + await LoadServersAsync(preserveSelection: true); + var msg = $"Added {dialog.AddedCount} server(s)"; + if (dialog.SkippedCount > 0) msg += $", skipped {dialog.SkippedCount} duplicate(s)"; + if (dialog.FailedCount > 0) msg += $", {dialog.FailedCount} failed"; + StatusText.Text = msg + ". The Darling service will start collecting them on its next reload."; + } + } + + private async void ManageServersButton_Click(object sender, RoutedEventArgs e) + { + var window = new ManageServersWindow(_serverStore, ProfileStore, _dataService) { Owner = this }; + window.ShowDialog(); + if (window.ServersChanged) + { + await LoadServersAsync(preserveSelection: true); + } + } + + /// <summary> + /// One-time import of the pre-Stage-3 <c>viewer-servers.json</c> server DEFINITIONS into the store, run + /// once on first connect (see <see cref="ViewerServerMigration"/>). Best-effort: a failure never blocks + /// startup — the sidebar still shows whatever the store already holds. + /// </summary> + private async Task MigrateViewerServersAsync() + { + if (_dataService is null || !OperatingSystem.IsWindows()) + { + return; + } + + try + { + var migration = new ViewerServerMigration(_serverStore, ProfileStore); + var imported = await migration.MigrateAsync(_dataService); + if (imported > 0) + { + ViewerLogger.Info("ServerMigration", $"Imported {imported} viewer-registered server(s) into the store."); + } + } + catch (Exception ex) + { + ViewerLogger.Warn("ServerMigration", $"viewer-servers.json migrate-in failed: {ex.Message}"); + } + } + + /// <summary> + /// One-time import of the pre-Stage-3b viewer-local OPERATIONAL settings (alert engine, automated + /// analysis, SMTP + Teams/Slack, MCP) out of viewer-settings.json into the control-plane store — the + /// operational twin of <see cref="MigrateViewerServersAsync"/>. Defaults-only + marker-guarded (see + /// <see cref="ViewerControlPlaneMigration"/>): never clobbers an operator-tuned store, runs once, + /// best-effort. Read-only/disconnected seats skip. + /// </summary> + private async Task MigrateControlPlaneSettingsAsync() + { + if (_dataService is null || !OperatingSystem.IsWindows()) + { + return; + } + + try + { + var migration = new ViewerControlPlaneMigration(_appSettingsStore.Load()); + var imported = await migration.MigrateAsync(_dataService); + if (imported > 0) + { + ViewerLogger.Info("ControlPlaneMigration", $"Imported {imported} viewer-local settings section(s) into the store."); + } + } + catch (Exception ex) + { + ViewerLogger.Warn("ControlPlaneMigration", $"viewer-settings.json operational migrate-in failed: {ex.Message}"); + } + } + + private void ViewLogButton_Click(object sender, RoutedEventArgs e) + { + var logFile = ViewerLogger.GetCurrentLogFile(); + try + { + var target = File.Exists(logFile) ? logFile : ViewerLogger.GetLogDirectory(); + if (string.IsNullOrEmpty(target)) + { + MessageBox.Show("No log file has been written yet.", "View Log", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + Process.Start(new ProcessStartInfo { FileName = target, UseShellExecute = true }); + } + catch (Exception ex) + { + MessageBox.Show($"Could not open log file: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + private void OpenLogFolderButton_Click(object sender, RoutedEventArgs e) + { + var logDir = ViewerLogger.GetLogDirectory(); + if (string.IsNullOrEmpty(logDir)) + { + logDir = ViewerLogger.DefaultLogDirectory(); + } + + try + { + Directory.CreateDirectory(logDir); + Process.Start(new ProcessStartInfo { FileName = logDir, UseShellExecute = true }); + } + catch (Exception ex) + { + MessageBox.Show($"Could not open log folder: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + /// <summary> + /// Imports server definitions (and copies viewer settings/preferences when absent) from a previous + /// viewer's per-user config folder — the Darling analog of Lite's Import Settings. Lite's DuckDB + /// "Import Data" has no viewer meaning and is omitted. The migratable definitions are pushed into the + /// store (Stage 3) so the service picks them up; cross-machine secrets don't travel, so SQL servers + /// without a locally-resolvable secret land in the local registry only until re-credentialed. + /// </summary> + private async void ImportSettingsButton_Click(object sender, RoutedEventArgs e) + { + var dialog = new Microsoft.Win32.OpenFolderDialog + { + Title = "Select a previous PerformanceMonitorDarling config folder" + }; + + if (dialog.ShowDialog() != true) + { + return; + } + + var sourceServers = Path.Combine(dialog.FolderName, "viewer-servers.json"); + if (!File.Exists(sourceServers)) + { + MessageBox.Show( + "No viewer-servers.json found in the selected folder.\n\n" + + "Select the PerformanceMonitorDarling folder of a previous viewer install " + + "(under %APPDATA%).", + "Import Settings", + MessageBoxButton.OK, + MessageBoxImage.Warning); + return; + } + + try + { + var (imported, skipped) = _serverStore.ImportServersFromFile(sourceServers); + + var targetDir = Path.GetDirectoryName(ViewerServerStore.DefaultFilePath()); + var copied = 0; + if (!string.IsNullOrEmpty(targetDir)) + { + /* viewer-profiles.json rides along so imported profile-backed servers don't reference a + missing profile (secrets stay per-user in Credential Manager and are re-entered). */ + foreach (var fileName in new[] { "viewer-settings.json", "viewer-preferences.json", "viewer-profiles.json" }) + { + var source = Path.Combine(dialog.FolderName, fileName); + var destination = Path.Combine(targetDir, fileName); + if (File.Exists(source) && !File.Exists(destination)) + { + Directory.CreateDirectory(targetDir); + File.Copy(source, destination); + copied++; + } + } + } + + /* Push the migratable definitions into the store so the service collects them (integrated auth + travels; a SQL server's secret does not cross machines, so it lands locally only). */ + var pushedToStore = 0; + if (_dataService is not null && !_dataService.IsReadOnly && OperatingSystem.IsWindows()) + { + pushedToStore = await ViewerServerMigration.ImportFromStoreAsync(_serverStore, ProfileStore, _dataService); + } + + var message = $"Imported {imported} server definition(s)."; + if (skipped > 0) + { + message += $"\nSkipped {skipped} already-configured server(s)."; + } + if (pushedToStore > 0) + { + message += $"\nAdded {pushedToStore} to the monitored store (the service will collect them on its next reload)."; + } + if (copied > 0) + { + message += $"\nCopied {copied} settings file(s). Restart the viewer to apply them."; + } + message += "\n\nServer credentials are NOT importable (they live only in Windows Credential Manager, " + + "per user). Re-enter any SQL/service-principal secret in Manage Servers."; + + MessageBox.Show(message, "Import Settings", MessageBoxButton.OK, MessageBoxImage.Information); + + if (imported > 0 || pushedToStore > 0) + { + await LoadServersAsync(preserveSelection: true); + } + } + catch (Exception ex) + { + ViewerLogger.Error("ImportSettings", "Import failed", ex); + MessageBox.Show($"Failed to import settings: {ex.Message}", "Import Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + // ── Sidebar collapse toggle ───────────────────────────────────────────────────── + + private void ToggleSidebar_Click(object sender, RoutedEventArgs e) + { + _sidebarCollapsed = !_sidebarCollapsed; + + if (_sidebarCollapsed) + { + SidebarColumn.Width = new GridLength(40); + SidebarHeaderText.Visibility = Visibility.Collapsed; + ServerList.Visibility = Visibility.Collapsed; + SidebarFooter.Visibility = Visibility.Collapsed; + ServersHintText.Visibility = Visibility.Collapsed; + SidebarToggleButton.Content = "»"; + } + else + { + SidebarColumn.Width = new GridLength(280); + SidebarHeaderText.Visibility = Visibility.Visible; + ServerList.Visibility = Visibility.Visible; + SidebarFooter.Visibility = Visibility.Visible; + ServersHintText.Visibility = ServerList.Items.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + SidebarToggleButton.Content = "«"; + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml new file mode 100644 index 000000000..eed27f56f --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml @@ -0,0 +1,1113 @@ +<Window x:Class="PerformanceMonitor.Darling.Viewer.MainWindow" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:local="clr-namespace:PerformanceMonitor.Darling.Viewer" + xmlns:ui="clr-namespace:PerformanceMonitor.Ui;assembly=PerformanceMonitor.Ui" + Title="Performance Monitor Darling" + Width="1280" + Height="800" + MinWidth="900" + MinHeight="600" + WindowState="Maximized" + WindowStartupLocation="CenterScreen" + Icon="EDD.ico" + Background="{DynamicResource BackgroundLightBrush}" + Foreground="{DynamicResource ForegroundBrush}"> + <!-- + Theme-aware shell (headless plan). The window's chrome colors now resolve from the shared + theme dictionary via {DynamicResource} (merged app-wide in App.xaml) — the same keys the + copied Lite XAML (the per-server ViewerServerTab and the later tab ports) already use — so + this window follows the active Dark/Light/CoolBreeze theme. Each token's Dark value equals + the hex it replaced (#22252b chrome, #111217 wells, #2a2d35 borders, #E4E6EB text), so Dark + renders exactly as before; only a few semantic/keep colors (offline grey, badge text, modal + scrim, favorite-star gold) stay inline. + + Navigation mirrors Lite's shape (headless-plan W0 IA inversion): a left sidebar server list, + and ONE top tab strip holding the fixed aggregate tabs (Overview, Alert History, FinOps, + Recommendations — matching Lite's order — plus the hidden-until-used Plan Viewer) and the + dynamically-added closable per-server tabs. Overview and Alert History are aggregates over ALL + servers (Overview is the server-cards grid; Alert History is the all-servers history, filterable + by server); FinOps and Recommendations are server-scoped, their own pickers synced to the sidebar + selection. Single-click a server drives the server-scoped aggregate tabs to it; double-click a + server row (or an Overview card) opens (or focuses) that server's ViewerServerTab. The fleet-refresh + timer (the configurable NocRefreshIntervalSeconds) refreshes the visible tab (an aggregate tab or the + visible server tab's active inner tab); the Overview has its own timer at the same interval. + --> + <Window.Resources> + <local:StatusToBrushConverter x:Key="StatusToBrush"/> + <BooleanToVisibilityConverter x:Key="BoolToVisibility"/> + + <Style x:Key="SectionHeader" TargetType="TextBlock"> + <Setter Property="FontSize" Value="14"/> + <Setter Property="FontWeight" Value="SemiBold"/> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Setter Property="Margin" Value="0,0,0,6"/> + </Style> + + <Style x:Key="EmptyHint" TargetType="TextBlock"> + <Setter Property="Foreground" Value="{DynamicResource ForegroundMutedBrush}"/> + <Setter Property="HorizontalAlignment" Value="Center"/> + <Setter Property="VerticalAlignment" Value="Center"/> + <Setter Property="TextWrapping" Value="Wrap"/> + <Setter Property="TextAlignment" Value="Center"/> + <Setter Property="MaxWidth" Value="480"/> + <Setter Property="IsHitTestVisible" Value="False"/> + <Setter Property="Visibility" Value="Collapsed"/> + </Style> + + <!-- One dark grid chrome shared by the aggregate-tab data grids (hoisted from the wave-1 + HealthGrid inline styles — same setters, zero visual change). --> + <Style x:Key="DarkGridHeader" TargetType="DataGridColumnHeader"> + <Setter Property="Background" Value="{DynamicResource ViewerGridHeaderBrush}"/> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="0,0,1,1"/> + <Setter Property="Padding" Value="8,5"/> + <Setter Property="FontWeight" Value="SemiBold"/> + </Style> + + <Style x:Key="DarkGridRow" TargetType="DataGridRow"> + <Setter Property="Background" Value="Transparent"/> + <Style.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter Property="Background" Value="{DynamicResource ViewerHoverBrush}"/> + </Trigger> + </Style.Triggers> + </Style> + + <Style x:Key="DarkGridCell" TargetType="DataGridCell"> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Style.Triggers> + <Trigger Property="IsSelected" Value="True"> + <Setter Property="Background" Value="{DynamicResource ViewerSelectionBrush}"/> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + </Trigger> + </Style.Triggers> + </Style> + + <Style x:Key="DarkGrid" TargetType="DataGrid"> + <!-- DarkGrid has no BasedOn, so it must re-declare the implicit DataGrid style's behaviors or grids using it silently lose them (sort-descending-first, header-copy, middle-click panning). --> + <Setter Property="ui:DataGridClipboardBehavior.FixHeaderCopy" Value="True"/> + <Setter Property="ui:DataGridSortBehavior.SortDescendingFirst" Value="True"/> + <Setter Property="ui:ScrollPanBehavior.EnableMiddleClickPanning" Value="True"/> + <Setter Property="AutoGenerateColumns" Value="False"/> + <Setter Property="IsReadOnly" Value="True"/> + <Setter Property="CanUserAddRows" Value="False"/> + <Setter Property="HeadersVisibility" Value="Column"/> + <Setter Property="SelectionMode" Value="Single"/> + <Setter Property="SelectionUnit" Value="FullRow"/> + <Setter Property="GridLinesVisibility" Value="Horizontal"/> + <Setter Property="HorizontalGridLinesBrush" Value="{DynamicResource BorderBrush}"/> + <Setter Property="Background" Value="{DynamicResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="RowBackground" Value="{DynamicResource BackgroundDarkBrush}"/> + <Setter Property="AlternatingRowBackground" Value="{DynamicResource ViewerAltRowBrush}"/> + <Setter Property="ColumnHeaderStyle" Value="{StaticResource DarkGridHeader}"/> + <Setter Property="RowStyle" Value="{StaticResource DarkGridRow}"/> + <Setter Property="CellStyle" Value="{StaticResource DarkGridCell}"/> + </Style> + + <Style x:Key="DarkTabItem" TargetType="TabItem"> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="TabItem"> + <Border x:Name="Bd" + Background="Transparent" + CornerRadius="3,3,0,0" + Padding="14,6" + Margin="0,0,2,0"> + <ContentPresenter ContentSource="Header" + HorizontalAlignment="Center" + VerticalAlignment="Center"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="Bd" Property="Background" Value="{DynamicResource ViewerHoverBrush}"/> + </Trigger> + <Trigger Property="IsSelected" Value="True"> + <Setter TargetName="Bd" Property="Background" Value="{DynamicResource ViewerSelectionBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- Dark ComboBox / Button used by the Recommendations tab's own server selector + Refresh + button (they match the hardcoded Dark palette instead of the default light WPF chrome). --> + <Style x:Key="DarkComboItem" TargetType="ComboBoxItem"> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="Padding" Value="6,4"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="ComboBoxItem"> + <Border x:Name="Bd" Background="{TemplateBinding Background}" Padding="{TemplateBinding Padding}"> + <ContentPresenter/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="Bd" Property="Background" Value="{DynamicResource ViewerHoverBrush}"/> + </Trigger> + <Trigger Property="IsHighlighted" Value="True"> + <Setter TargetName="Bd" Property="Background" Value="{DynamicResource ViewerSelectionBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <Style x:Key="DarkButton" TargetType="Button"> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Setter Property="Background" Value="{DynamicResource ViewerSelectionBrush}"/> + <Setter Property="BorderBrush" Value="{DynamicResource ViewerButtonBorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Cursor" Value="Hand"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="Button"> + <Border x:Name="Bd" Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" + CornerRadius="3" Padding="{TemplateBinding Padding}"> + <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="Bd" Property="Background" Value="{DynamicResource ViewerButtonBorderBrush}"/> + </Trigger> + <Trigger Property="IsEnabled" Value="False"> + <Setter TargetName="Bd" Property="Background" Value="{DynamicResource ViewerDisabledBgBrush}"/> + <Setter Property="Foreground" Value="{DynamicResource ViewerDisabledTextBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <Style x:Key="DarkCombo" TargetType="ComboBox"> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Setter Property="Background" Value="{DynamicResource BackgroundDarkBrush}"/> + <Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Padding" Value="6,4"/> + <Setter Property="ItemContainerStyle" Value="{StaticResource DarkComboItem}"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="ComboBox"> + <Grid> + <ToggleButton x:Name="ToggleButton" Focusable="False" + IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}" + ClickMode="Press"> + <ToggleButton.Template> + <ControlTemplate TargetType="ToggleButton"> + <Border Background="{DynamicResource BackgroundDarkBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="3"> + <Grid> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*"/> + <ColumnDefinition Width="20"/> + </Grid.ColumnDefinitions> + <Path Grid.Column="1" HorizontalAlignment="Center" VerticalAlignment="Center" + Data="M 0 0 L 4 4 L 8 0 Z" Fill="{DynamicResource ForegroundMutedBrush}"/> + </Grid> + </Border> + </ControlTemplate> + </ToggleButton.Template> + </ToggleButton> + <ContentPresenter x:Name="ContentSite" IsHitTestVisible="False" + Content="{TemplateBinding SelectionBoxItem}" + ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}" + Margin="{TemplateBinding Padding}" + VerticalAlignment="Center" HorizontalAlignment="Left"/> + <Popup x:Name="PART_Popup" Placement="Bottom" IsOpen="{TemplateBinding IsDropDownOpen}" + AllowsTransparency="True" Focusable="False" PopupAnimation="Slide"> + <Border Background="{DynamicResource ViewerAltRowBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" + MinWidth="{TemplateBinding ActualWidth}" MaxHeight="240"> + <ScrollViewer> + <StackPanel IsItemsHost="True"/> + </ScrollViewer> + </Border> + </Popup> + </Grid> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + </Window.Resources> + + <Grid Margin="10"> + <Grid.RowDefinitions> + <RowDefinition Height="*"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + + <Grid Grid.Row="0"> + <Grid.ColumnDefinitions> + <ColumnDefinition x:Name="SidebarColumn" Width="280" MinWidth="40"/> + <ColumnDefinition Width="*"/> + </Grid.ColumnDefinitions> + + <!-- Left: the server list. Single-click drives the aggregate tabs; double-click opens + (or focuses) that server's per-server tab. The header's « / » button collapses the + sidebar to a thin rail (mirrors Lite's ToggleSidebar). --> + <DockPanel Grid.Column="0" Margin="0,0,10,0"> + <Grid DockPanel.Dock="Top"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*"/> + <ColumnDefinition Width="Auto"/> + </Grid.ColumnDefinitions> + <StackPanel Grid.Column="0" x:Name="SidebarHeaderText"> + <TextBlock Text="Servers" Style="{StaticResource SectionHeader}" Margin="0,0,0,2"/> + <TextBlock Text="Double-click to open a server tab" Foreground="{DynamicResource ForegroundMutedBrush}" FontSize="11" Margin="0,0,0,6"/> + </StackPanel> + <Button Grid.Column="1" x:Name="SidebarToggleButton" Content="«" Click="ToggleSidebar_Click" + Style="{StaticResource DarkButton}" + ToolTip="Collapse/expand the sidebar" VerticalAlignment="Top" + Width="24" Height="24" Padding="0" FontSize="14"/> + </Grid> + + <!-- Sidebar footer: the button stack pinned to the bottom of the server list, ported to + parity with Lite. Server management (Add Server / Manage Servers) writes the viewer's + own registry; the service honoring it is separate wiring (see the PR). Import Data + (Lite's DuckDB import) is omitted — it has no viewer meaning. Declared as the first + Bottom-docked child so it sits at the very bottom, below the no-servers hint. --> + <Border x:Name="SidebarFooter" DockPanel.Dock="Bottom" + BorderBrush="{DynamicResource BorderBrush}" BorderThickness="0,1,0,0" + Margin="0,8,0,0" Padding="0,8,0,0"> + <StackPanel> + <Button Click="AddServerButton_Click" + Style="{StaticResource DarkButton}" + Padding="8,5" + ToolTip="Add a monitored SQL Server to the viewer's registry"> + <StackPanel Orientation="Horizontal"> + <TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="12" + VerticalAlignment="Center" Margin="0,0,6,0"/> + <TextBlock Text="Add Server" VerticalAlignment="Center"/> + </StackPanel> + </Button> + <Button Click="AddMultipleServersButton_Click" + Style="{StaticResource DarkButton}" + Padding="8,5" + Margin="0,8,0,0" + ToolTip="Paste a list of servers and add them all at once with shared credentials"> + <StackPanel Orientation="Horizontal"> + <TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="12" + VerticalAlignment="Center" Margin="0,0,6,0"/> + <TextBlock Text="Add Multiple" VerticalAlignment="Center"/> + </StackPanel> + </Button> + <Button Click="ManageServersButton_Click" + Style="{StaticResource DarkButton}" + Padding="8,5" + Margin="0,8,0,0" + ToolTip="Add, edit, or remove monitored servers"> + <StackPanel Orientation="Horizontal"> + <TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="12" + VerticalAlignment="Center" Margin="0,0,6,0"/> + <TextBlock Text="Manage Servers" VerticalAlignment="Center"/> + </StackPanel> + </Button> + <Button Click="OpenPlanViewerButton_Click" + Style="{StaticResource DarkButton}" + Padding="8,5" + Margin="0,8,0,0" + ToolTip="Open or paste an execution plan (independent of any server)"> + <StackPanel Orientation="Horizontal"> + <TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="12" + VerticalAlignment="Center" Margin="0,0,6,0"/> + <TextBlock Text="Open Plan Viewer" VerticalAlignment="Center"/> + </StackPanel> + </Button> + <Button Click="ImportSettingsButton_Click" + Style="{StaticResource DarkButton}" + Padding="8,5" + Margin="0,8,0,0" + ToolTip="Import server definitions and settings from a previous viewer install"> + <StackPanel Orientation="Horizontal"> + <TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="12" + VerticalAlignment="Center" Margin="0,0,6,0"/> + <TextBlock Text="Import Settings" VerticalAlignment="Center"/> + </StackPanel> + </Button> + <Button Click="ViewLogButton_Click" + Style="{StaticResource DarkButton}" + Padding="8,5" + Margin="0,8,0,0" + ToolTip="Open the current application log file"> + <StackPanel Orientation="Horizontal"> + <TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="12" + VerticalAlignment="Center" Margin="0,0,6,0"/> + <TextBlock Text="View Log" VerticalAlignment="Center"/> + </StackPanel> + </Button> + <Button Click="OpenLogFolderButton_Click" + Style="{StaticResource DarkButton}" + Padding="8,5" + Margin="0,8,0,0" + ToolTip="Open the log folder in Explorer (for grabbing logs to attach to bug reports)"> + <StackPanel Orientation="Horizontal"> + <TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="12" + VerticalAlignment="Center" Margin="0,0,6,0"/> + <TextBlock Text="Open Log Folder" VerticalAlignment="Center"/> + </StackPanel> + </Button> + <Button Click="SettingsButton_Click" + Style="{StaticResource DarkButton}" + Padding="8,5" + Margin="0,8,0,0" + ToolTip="Viewer settings — defaults for newly-opened server tabs"> + <StackPanel Orientation="Horizontal"> + <TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="12" + VerticalAlignment="Center" Margin="0,0,6,0"/> + <TextBlock Text="Settings" VerticalAlignment="Center"/> + </StackPanel> + </Button> + <Button Click="AboutButton_Click" + Style="{StaticResource DarkButton}" + Padding="8,5" + Margin="0,8,0,0" + ToolTip="About Performance Monitor Darling"> + <StackPanel Orientation="Horizontal"> + <TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="12" + VerticalAlignment="Center" Margin="0,0,6,0"/> + <TextBlock Text="About" VerticalAlignment="Center"/> + </StackPanel> + </Button> + </StackPanel> + </Border> + + <TextBlock x:Name="ServersHintText" + DockPanel.Dock="Bottom" + Text="no servers registered — is the Darling service running?" + Foreground="{DynamicResource ForegroundMutedBrush}" + TextWrapping="Wrap" + Margin="2,8,2,0" + Visibility="Collapsed"/> + <ListBox x:Name="ServerList" + Background="{DynamicResource BackgroundDarkBrush}" + BorderBrush="{DynamicResource BorderBrush}" + BorderThickness="1" + Foreground="{DynamicResource ForegroundBrush}" + SelectionChanged="ServerList_SelectionChanged" + MouseDoubleClick="ServerList_MouseDoubleClick"> + <ListBox.ItemContainerStyle> + <Style TargetType="ListBoxItem"> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Setter Property="Padding" Value="8,5"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="ListBoxItem"> + <Border x:Name="Bd" + Background="{TemplateBinding Background}" + Padding="{TemplateBinding Padding}" + CornerRadius="3" + Margin="2"> + <ContentPresenter/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="Bd" Property="Background" Value="{DynamicResource ViewerHoverBrush}"/> + </Trigger> + <Trigger Property="IsSelected" Value="True"> + <Setter TargetName="Bd" Property="Background" Value="{DynamicResource ViewerSelectionBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + </ListBox.ItemContainerStyle> + <ListBox.ItemTemplate> + <DataTemplate> + <!-- Ported from Lite's server-row template: a freshness status dot (green/amber/red + from DotStatus), the name + version, a favorite star, and the right-click menu. + Connect/Disconnect are omitted — the viewer never connects to monitored servers + (it reads only the Postgres store). Transparent Border hosts the context menu so + the whole row is a right-click target; the item background comes from the + container style above. --> + <Border Background="Transparent"> + <Border.ContextMenu> + <ContextMenu> + <MenuItem Header="Toggle Favorite" Click="ServerContextMenu_ToggleFavorite_Click"> + <MenuItem.Icon><TextBlock Text="⭐"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <MenuItem Header="Edit..." Click="ServerContextMenu_Edit_Click"> + <MenuItem.Icon><TextBlock Text="✏"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Remove" Click="ServerContextMenu_Remove_Click"> + <MenuItem.Icon><TextBlock Text="🗑"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <!-- One-click whole-server alert silence: writes/removes a server-scoped + mute rule the running service honors (email/Teams/Slack + in-app toasts), + the shortcut over the multi-step Manage Mute Rules dialog. --> + <MenuItem Header="Silence This Server" Click="ServerContextMenu_Silence_Click" + ToolTip="Mute every alert for this server until you Unsilence it"> + <MenuItem.Icon><TextBlock Text="🔇"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Unsilence" Click="ServerContextMenu_Unsilence_Click" + ToolTip="Remove this server's whole-server alert silence"> + <MenuItem.Icon><TextBlock Text="🔔"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <!-- Acknowledge: quiet the alert badge (viewer-local) until a NEW alert + arrives — unlike Silence, it does NOT stop the service's delivery. --> + <MenuItem Header="Acknowledge Alerts" Click="ServerContextMenu_Acknowledge_Click" + IsEnabled="{Binding HasAttention}" + ToolTip="Quiet this server's alert badge until a new alert arrives (delivery continues)"> + <MenuItem.Icon><TextBlock Text="✓" FontWeight="Bold"/></MenuItem.Icon> + </MenuItem> + </ContextMenu> + </Border.ContextMenu> + <Grid> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="*"/> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + </Grid.ColumnDefinitions> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + + <!-- Status dot (collection freshness) --> + <Ellipse Grid.Column="0" Grid.RowSpan="2" + Width="10" Height="10" + Margin="0,0,8,0" + VerticalAlignment="Center"> + <Ellipse.Style> + <Style TargetType="Ellipse"> + <Setter Property="Fill" Value="{DynamicResource ForegroundMutedBrush}"/> + <Style.Triggers> + <DataTrigger Binding="{Binding DotStatus}" Value="Online"> + <Setter Property="Fill" Value="{DynamicResource SuccessBrush}"/> + </DataTrigger> + <DataTrigger Binding="{Binding DotStatus}" Value="Offline"> + <Setter Property="Fill" Value="{DynamicResource ErrorBrush}"/> + </DataTrigger> + <DataTrigger Binding="{Binding DotStatus}" Value="Warning"> + <Setter Property="Fill" Value="{DynamicResource WarningBrush}"/> + </DataTrigger> + </Style.Triggers> + </Style> + </Ellipse.Style> + </Ellipse> + + <!-- Server name --> + <TextBlock Grid.Column="1" Grid.Row="0" + Text="{Binding DisplayName}" + FontSize="13" FontWeight="SemiBold" + Foreground="{DynamicResource ForegroundBrush}"/> + + <!-- Details (SQL version) --> + <TextBlock Grid.Column="1" Grid.Row="1" + Text="{Binding VersionLabel}" + FontSize="11" + Foreground="{DynamicResource ForegroundMutedBrush}"/> + + <!-- Alert "needs attention" badge: count of active (unacknowledged, un-muted) + alerts for this server, hidden when none. Colour follows severity + (Critical→ErrorBrush, Warning→WarningBrush), matching Lite's + deadlock→red / else→orange-red tab badge. Acknowledge via the row's + right-click "Acknowledge Alerts" (or the tab-header badge). --> + <Border Grid.Column="2" Grid.RowSpan="2" + CornerRadius="7" Padding="5,0" MinWidth="16" Height="15" + VerticalAlignment="Center" Margin="4,0,0,0" + ToolTip="{Binding AttentionTooltip}"> + <Border.Style> + <Style TargetType="Border"> + <Setter Property="Visibility" Value="Collapsed"/> + <Setter Property="Background" Value="{DynamicResource WarningBrush}"/> + <Style.Triggers> + <DataTrigger Binding="{Binding HasAttention}" Value="True"> + <Setter Property="Visibility" Value="Visible"/> + </DataTrigger> + <DataTrigger Binding="{Binding AttentionSeverity}" Value="Critical"> + <Setter Property="Background" Value="{DynamicResource ErrorBrush}"/> + </DataTrigger> + </Style.Triggers> + </Style> + </Border.Style> + <TextBlock Text="{Binding AttentionText}" + Foreground="White" FontSize="9" FontWeight="Bold" + HorizontalAlignment="Center" VerticalAlignment="Center"/> + </Border> + + <!-- Favorite star --> + <TextBlock Grid.Column="3" Grid.RowSpan="2" + Text="★" FontSize="16" + VerticalAlignment="Center" + Foreground="#FFD700" + Margin="4,0,0,0"> + <TextBlock.Style> + <Style TargetType="TextBlock"> + <Setter Property="Visibility" Value="Collapsed"/> + <Style.Triggers> + <DataTrigger Binding="{Binding IsFavorite}" Value="True"> + <Setter Property="Visibility" Value="Visible"/> + </DataTrigger> + </Style.Triggers> + </Style> + </TextBlock.Style> + </TextBlock> + </Grid> + </Border> + </DataTemplate> + </ListBox.ItemTemplate> + </ListBox> + </DockPanel> + + <!-- Right: the top tab strip. Fixed aggregate tabs (Overview, Recommendations, Alerts) plus + the dynamically-added closable per-server tabs (appended in code). Only the visible tab + loads (Lite's visible-only rule); the fleet-refresh timer re-reads the visible aggregate tab + (Overview on its own timer at the same interval), while per-server tabs self-refresh on their own + toolbar auto-refresh timer. --> + <TabControl x:Name="MainTabs" + Grid.Column="1" + Background="Transparent" + BorderThickness="0" + Padding="0" + ItemContainerStyle="{StaticResource DarkTabItem}" + SelectionChanged="MainTabs_SelectionChanged"> + + <!-- Overview: the all-servers server-cards grid (W2a), copied from Lite's Overview tab + (Lite/MainWindow.xaml). One 260px card per server; double-click a card opens that + server's tab. Status is derived from collection freshness (see ServerSummaryItem), + not a live ping — the viewer has no live connection to the monitored servers. --> + <TabItem x:Name="OverviewTab" Header="Overview"> + <DockPanel Margin="0,8,0,0"> + <!-- Fleet-wide NOC roll-up (the signature capability Darling's single central + store enables — the Dashboard/Lite each see one server's database and CANNOT + rank/total servers against each other). Sits ABOVE the per-server cards, always + visible. Three parts: (1) fleet health summary — total servers + counts by the + SAME band #1426's cards compute (ServerSummaryItem.OverallMetricSeverity + + freshness, reduced in FleetRollup.ClassifyBand); (2) fleet totals — the + cross-server blocking / deadlock SQL aggregate (GetFleetTotalsAsync) + servers + with collection failures; (3) the worst-first "Needs Attention" ranking, whose + rows open that server's tab. DataContext (a FleetRollup) is set in code-behind + after LoadOverviewAsync builds it. --> + <Border x:Name="FleetRollupContainer" DockPanel.Dock="Top" + Background="{DynamicResource BackgroundLightBrush}" + BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="6" + Margin="6,0,6,10" Padding="16,12" Visibility="Collapsed"> + <StackPanel> + <!-- Header --> + <DockPanel Margin="0,0,0,10"> + <StackPanel DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center"> + <TextBlock Text="Sort:" FontSize="11" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,0,6,0"/> + <ComboBox x:Name="OverviewSortSelector" Width="120" VerticalAlignment="Center" + Style="{StaticResource DarkCombo}" + SelectionChanged="OverviewSortSelector_SelectionChanged"> + <ComboBoxItem Content="CPU %"/> + <ComboBoxItem Content="Name"/> + </ComboBox> + </StackPanel> + <TextBlock Text="Fleet Health" FontSize="16" FontWeight="SemiBold" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBlock Text="{Binding MonitoringText}" FontSize="11" + Foreground="{DynamicResource ForegroundMutedBrush}" + VerticalAlignment="Center" Margin="12,1,0,0" + TextTrimming="CharacterEllipsis"/> + </DockPanel> + + <!-- (1) Health-band summary tiles: total + counts by band. Tile colours are + the card severity palette (green / amber / red / grey-for-offline). --> + <WrapPanel Margin="0,0,0,12"> + <Border Background="{DynamicResource BackgroundBrush}" BorderBrush="{DynamicResource CardBorderBrush}" + BorderThickness="1" CornerRadius="5" MinWidth="86" Margin="0,0,8,0" Padding="12,6"> + <StackPanel> + <TextBlock Text="{Binding TotalServers}" FontSize="22" FontWeight="Bold" + Foreground="{DynamicResource ForegroundBrush}" HorizontalAlignment="Center"/> + <TextBlock Text="Servers" FontSize="10" Foreground="{DynamicResource ForegroundMutedBrush}" + HorizontalAlignment="Center"/> + </StackPanel> + </Border> + <Border Background="{DynamicResource BackgroundBrush}" BorderBrush="{DynamicResource SuccessBrush}" + BorderThickness="1" CornerRadius="5" MinWidth="86" Margin="0,0,8,0" Padding="12,6"> + <StackPanel> + <TextBlock Text="{Binding HealthyCount}" FontSize="22" FontWeight="Bold" + Foreground="{DynamicResource SuccessBrush}" HorizontalAlignment="Center"/> + <TextBlock Text="Healthy" FontSize="10" Foreground="{DynamicResource ForegroundMutedBrush}" + HorizontalAlignment="Center"/> + </StackPanel> + </Border> + <Border Background="{DynamicResource BackgroundBrush}" BorderBrush="{DynamicResource WarningBrush}" + BorderThickness="1" CornerRadius="5" MinWidth="86" Margin="0,0,8,0" Padding="12,6"> + <StackPanel> + <TextBlock Text="{Binding WarningCount}" FontSize="22" FontWeight="Bold" + Foreground="{DynamicResource WarningBrush}" HorizontalAlignment="Center"/> + <TextBlock Text="Warning" FontSize="10" Foreground="{DynamicResource ForegroundMutedBrush}" + HorizontalAlignment="Center"/> + </StackPanel> + </Border> + <Border Background="{DynamicResource BackgroundBrush}" BorderBrush="{DynamicResource ErrorBrush}" + BorderThickness="1" CornerRadius="5" MinWidth="86" Margin="0,0,8,0" Padding="12,6"> + <StackPanel> + <TextBlock Text="{Binding CriticalCount}" FontSize="22" FontWeight="Bold" + Foreground="{DynamicResource ErrorBrush}" HorizontalAlignment="Center"/> + <TextBlock Text="Critical" FontSize="10" Foreground="{DynamicResource ForegroundMutedBrush}" + HorizontalAlignment="Center"/> + </StackPanel> + </Border> + <Border Background="{DynamicResource BackgroundBrush}" BorderBrush="#888888" + BorderThickness="1" CornerRadius="5" MinWidth="86" Margin="0,0,8,0" Padding="12,6"> + <StackPanel> + <TextBlock Text="{Binding OfflineCount}" FontSize="22" FontWeight="Bold" + Foreground="#888888" HorizontalAlignment="Center"/> + <TextBlock Text="Offline" FontSize="10" Foreground="{DynamicResource ForegroundMutedBrush}" + HorizontalAlignment="Center"/> + </StackPanel> + </Border> + </WrapPanel> + + <!-- (2) Fleet totals: the cross-server aggregate over the last hour (the + same window the per-server cards use, so these reconcile with the card + counts). --> + <WrapPanel Margin="0,0,0,12"> + <TextBlock Text="Last hour:" FontSize="11" FontWeight="SemiBold" + Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,0,10,0" VerticalAlignment="Center"/> + <TextBlock FontSize="11" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,16,0" VerticalAlignment="Center" + Text="{Binding TotalBlockingEvents, StringFormat='Blocking events: {0}'}"/> + <TextBlock FontSize="11" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,16,0" VerticalAlignment="Center" + Text="{Binding TotalDeadlocks, StringFormat='Deadlocks: {0}'}"/> + <TextBlock FontSize="11" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,16,0" VerticalAlignment="Center" + Text="{Binding CollectionFailuresText, StringFormat='Collection failures: {0}'}"/> + </WrapPanel> + + <!-- (3) Worst-first "Needs Attention" ranking — problem servers only, opens + the server's tab on click. Hidden and replaced by the all-clear line + when the fleet is healthy (toggled in code-behind). --> + <TextBlock Text="Needs Attention" FontSize="12" FontWeight="SemiBold" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <ItemsControl x:Name="FleetWorstServersList" ItemsSource="{Binding WorstServers}"> + <ItemsControl.ItemTemplate> + <DataTemplate> + <Border Cursor="Hand" Padding="2,3" Margin="0,1" + ToolTip="Open this server's tab" + MouseLeftButtonUp="FleetWorstServer_Click"> + <Border.Style> + <Style TargetType="Border"> + <Setter Property="Background" Value="Transparent"/> + <Style.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter Property="Background" Value="{DynamicResource ViewerHoverBrush}"/> + </Trigger> + </Style.Triggers> + </Style> + </Border.Style> + <DockPanel LastChildFill="True"> + <Ellipse Width="9" Height="9" Fill="{Binding BandBrush}" + VerticalAlignment="Center" Margin="0,0,8,0" DockPanel.Dock="Left"/> + <TextBlock Text="{Binding BandLabel}" Foreground="{Binding BandBrush}" + FontSize="11" FontWeight="SemiBold" Width="58" + VerticalAlignment="Center" DockPanel.Dock="Left"/> + <TextBlock Text="{Binding DisplayName}" Foreground="{DynamicResource ForegroundBrush}" + FontSize="11" FontWeight="SemiBold" Width="160" + VerticalAlignment="Center" TextTrimming="CharacterEllipsis" DockPanel.Dock="Left"/> + <TextBlock Text="{Binding Reason}" Foreground="{DynamicResource ForegroundMutedBrush}" + FontSize="11" VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/> + </DockPanel> + </Border> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + <TextBlock x:Name="FleetAdditionalProblemsText" Text="{Binding AdditionalProblemText}" + FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}" + Margin="17,2,0,0" Visibility="Collapsed"/> + <TextBlock x:Name="FleetAllHealthyText" Text="{Binding AllHealthyText}" + FontSize="12" Foreground="{DynamicResource SuccessBrush}" Margin="0,0,0,2" Visibility="Collapsed"/> + </StackPanel> + </Border> + + <ScrollViewer VerticalScrollBarVisibility="Auto"> + <ItemsControl x:Name="OverviewItemsControl"> + <ItemsControl.ItemsPanel> + <ItemsPanelTemplate> + <WrapPanel/> + </ItemsPanelTemplate> + </ItemsControl.ItemsPanel> + <ItemsControl.ItemTemplate> + <DataTemplate> + <Border Background="{DynamicResource BackgroundLightBrush}" + BorderBrush="{Binding CardBorderBrush}" + BorderThickness="2" CornerRadius="6" + Margin="6" + Width="300" MinHeight="210" + Cursor="Hand" + MouseLeftButtonDown="OverviewCard_MouseLeftButtonDown"> + <Grid> + <StackPanel Margin="16"> + <DockPanel Margin="0,0,0,4"> + <TextBlock Text="{Binding DisplayName}" FontSize="16" FontWeight="SemiBold" + Foreground="{DynamicResource ForegroundBrush}"/> + <Ellipse Width="10" Height="10" Fill="{Binding StatusBrush}" + HorizontalAlignment="Right" VerticalAlignment="Center"/> + </DockPanel> + <TextBlock Text="{Binding StatusDisplay}" Foreground="{Binding StatusBrush}" + FontSize="11" Margin="0,0,0,10"/> + <!-- Metric rows: [severity dot] [label] [value] [detail], + mirroring the Dashboard's ServerHealthCard. Each dot + + value is coloured by the metric's band (ServerSummaryItem + reproduces ServerHealthStatus's CASE logic). Threads + + Collectors are the enrichment rows; Memory / Blocking gain + resource-semaphore + duration detail. Last Collect keeps + its neutral footer row (no dot). --> + <Grid> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="*"/> + </Grid.ColumnDefinitions> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + + <!-- CPU --> + <Ellipse Grid.Row="0" Grid.Column="0" Width="9" Height="9" Fill="{Binding CpuSeverityBrush}" VerticalAlignment="Center" Margin="0,2,6,2"/> + <TextBlock Grid.Row="0" Grid.Column="1" Text="CPU:" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,2,8,2"/> + <TextBlock Grid.Row="0" Grid.Column="2" Text="{Binding CpuDisplay}" FontSize="11" Foreground="{Binding CpuSeverityBrush}" FontWeight="SemiBold" Margin="0,2,8,2"/> + <TextBlock Grid.Row="0" Grid.Column="3" Text="{Binding CpuDetail}" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" Margin="0,2"/> + + <!-- Threads (enrichment) --> + <Ellipse Grid.Row="1" Grid.Column="0" Width="9" Height="9" Fill="{Binding ThreadsSeverityBrush}" VerticalAlignment="Center" Margin="0,2,6,2"/> + <TextBlock Grid.Row="1" Grid.Column="1" Text="Threads:" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,2,8,2"/> + <TextBlock Grid.Row="1" Grid.Column="2" Text="{Binding ThreadsDisplay}" FontSize="11" Foreground="{Binding ThreadsSeverityBrush}" FontWeight="SemiBold" Margin="0,2,8,2"/> + <TextBlock Grid.Row="1" Grid.Column="3" Text="{Binding ThreadsDetail}" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" Margin="0,2"/> + + <!-- Memory (richer: total value + resource-semaphore detail) --> + <Ellipse Grid.Row="2" Grid.Column="0" Width="9" Height="9" Fill="{Binding MemorySeverityBrush}" VerticalAlignment="Center" Margin="0,2,6,2"/> + <TextBlock Grid.Row="2" Grid.Column="1" Text="Memory:" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,2,8,2"/> + <TextBlock Grid.Row="2" Grid.Column="2" Text="{Binding MemoryDisplay}" FontSize="11" Foreground="{DynamicResource ForegroundBrush}" FontWeight="SemiBold" Margin="0,2,8,2"/> + <TextBlock Grid.Row="2" Grid.Column="3" Text="{Binding MemoryDetail}" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" Margin="0,2"/> + + <!-- Blocking (richer: count value + max-duration detail) --> + <Ellipse Grid.Row="3" Grid.Column="0" Width="9" Height="9" Fill="{Binding BlockingSeverityBrush}" VerticalAlignment="Center" Margin="0,2,6,2"/> + <TextBlock Grid.Row="3" Grid.Column="1" Text="Blocking:" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,2,8,2"/> + <TextBlock Grid.Row="3" Grid.Column="2" Text="{Binding BlockingDisplay}" FontSize="11" Foreground="{Binding BlockingSeverityBrush}" FontWeight="SemiBold" Margin="0,2,8,2"/> + <TextBlock Grid.Row="3" Grid.Column="3" Text="{Binding BlockingDetail}" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" Margin="0,2"/> + + <!-- Deadlocks --> + <Ellipse Grid.Row="4" Grid.Column="0" Width="9" Height="9" Fill="{Binding DeadlockSeverityBrush}" VerticalAlignment="Center" Margin="0,2,6,2"/> + <TextBlock Grid.Row="4" Grid.Column="1" Text="Deadlocks:" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,2,8,2"/> + <TextBlock Grid.Row="4" Grid.Column="2" Text="{Binding DeadlockDisplay}" FontSize="11" Foreground="{Binding DeadlockSeverityBrush}" FontWeight="SemiBold" Margin="0,2,8,2"/> + <TextBlock Grid.Row="4" Grid.Column="3" Text="{Binding DeadlockDetail}" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" Margin="0,2"/> + + <!-- Collectors (enrichment) --> + <Ellipse Grid.Row="5" Grid.Column="0" Width="9" Height="9" Fill="{Binding CollectorSeverityBrush}" VerticalAlignment="Center" Margin="0,2,6,2"/> + <TextBlock Grid.Row="5" Grid.Column="1" Text="Collectors:" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,2,8,2"/> + <TextBlock Grid.Row="5" Grid.Column="2" Text="{Binding CollectorDisplay}" FontSize="11" Foreground="{Binding CollectorSeverityBrush}" FontWeight="SemiBold" Margin="0,2,8,2"/> + <TextBlock Grid.Row="5" Grid.Column="3" Text="{Binding CollectorDetail}" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" Margin="0,2"/> + + <!-- Last Collect (footer row, no severity dot) --> + <TextBlock Grid.Row="6" Grid.Column="1" Text="Last Collect:" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,2,8,2"/> + <TextBlock Grid.Row="6" Grid.Column="2" Text="{Binding LastCollectionDisplay}" FontSize="11" Foreground="{DynamicResource ForegroundBrush}" FontWeight="SemiBold" Margin="0,2,8,2"/> + </Grid> + </StackPanel> + <!-- Offline overlay --> + <Border Background="#80000000" CornerRadius="6" + Visibility="{Binding IsOffline, Converter={StaticResource BoolToVisibility}}"> + <StackPanel VerticalAlignment="Center" HorizontalAlignment="Center"> + <TextBlock Text="✗" FontSize="28" Foreground="{DynamicResource ErrorBrush}" + HorizontalAlignment="Center"/> + <TextBlock Text="Offline" FontSize="13" Foreground="{DynamicResource ErrorBrush}" + FontWeight="SemiBold" HorizontalAlignment="Center" Margin="0,4,0,0"/> + </StackPanel> + </Border> + </Grid> + </Border> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + </ScrollViewer> + </DockPanel> + </TabItem> + + <!-- Alerts: the full all-servers Alert History surface (W2a), a copy of Lite's + AlertsHistoryTab hosted as a control. All-servers by default with a Server filter, + Dismiss Selected / Dismiss All, column filters, copy/export, and Mute This / Mute + Similar + Manage Mute Rules (mute lives here per Erik's faithful-copy decision). It + owns its own load, so it isn't scoped by the sidebar selection. Ordered right after + Overview to match Lite (Overview -> Alert History -> FinOps -> Recommendations). --> + <TabItem x:Name="AlertsTab" Header="Alert History"> + <local:AlertsHistoryTab x:Name="AlertsHistoryContent" Margin="0,8,0,0"/> + </TabItem> + + <!-- Job History: the fleet-wide retained SQL Agent job-run history (issue #1433), a copy of + Lite's JobHistoryTab. All-servers by default with Server / Status / Category filters, + per-step rows, failure + long-runtime + retry color-coding, and CSV export. Self-loading + (not scoped by the sidebar selection), ordered right after Alert History to match Lite. --> + <TabItem x:Name="JobHistoryTabItem" Header="Job History"> + <local:JobHistoryTab x:Name="JobHistoryContent" Margin="0,8,0,0"/> + </TabItem> + + <!-- FinOps: the top-level cross-server aggregate tab (restored to Lite's shape from the earlier + per-server inner tab, which had duplicated the fleet-wide Server Inventory inside every + server's tab). Its OWN server selector (synced to the sidebar selection on a single-click, + mirroring the Recommendations tab, and independently changeable while the tab is open) + drives the eleven per-server sub-tabs; Server Inventory stays cross-server and now lives + ONCE here. The control owns its own load (server selector + sub-tab activation + the + shell's fleet-refresh timer). "View Plan" on its query grids raises PlanRequested, which the + shell routes into the standalone Plan Viewer surface. --> + <TabItem x:Name="FinOpsTab" Header="FinOps"> + <local:FinOpsTab x:Name="FinOpsContent" Margin="0,8,0,0"/> + </TabItem> + + <!-- Recommendations: re-skinned to Lite's advise-only card design (W2b). Its OWN server + selector (synced to the sidebar selection on a single-click, independently changeable), a Refresh button, and a status line that + shows the last analysis time; then a scrollable list of collapsible Expander + sections grouped by incident, each holding severity-banded cards (badge + [db] + + title + advice + Open in Active Queries + Copy fix + Ask AI). Advise-only: no Apply, and (per the re-skin + brief) no mute — mute lives on the Alert History surface. Card templates copy Lite's + RecommendationsTab.xaml and resolve their {DynamicResource} theme keys from the + app-merged Dark theme. "Generate now" asks the Darling service to run an immediate + analysis pass for the selected server (analyze_now) and then refreshes; the status line + also surfaces the last analysis time. A read-only seat can't command the service, so the + button is disabled. NOT on the fleet-refresh timer (tab-activation refresh only, + matching Lite). --> + <TabItem x:Name="RecommendationsTab" Header="Recommendations"> + <TabItem.Resources> + <BooleanToVisibilityConverter x:Key="RecoBoolToVis"/> + + <!-- One card: severity badge + [database] + title + advice, then advise-only + affordances (Open in Active Queries for incidents + Copy fix when derivable + + Ask AI). Copied from Lite's LiteRecommendationCardTemplate; the Open-in-Active- + Queries deep-link is the Dashboard incident affordance ported to the viewer. --> + <DataTemplate x:Key="RecoCardTemplate"> + <Border Background="{DynamicResource BackgroundLightBrush}" + BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" + CornerRadius="4" Padding="10" Margin="0,0,0,8"> + <StackPanel> + <!-- Header row: severity badge + [database] --> + <StackPanel Orientation="Horizontal" Margin="0,0,0,6"> + <Border x:Name="SeverityBadge" CornerRadius="3" Padding="6,1" + VerticalAlignment="Center" + Background="{DynamicResource InfoBrush}"> + <StackPanel Orientation="Horizontal"> + <TextBlock Text="{Binding SeverityGlyph}" + FontFamily="Segoe MDL2 Assets" FontSize="11" + Foreground="#1A1A1A" VerticalAlignment="Center" + Margin="0,0,4,0"/> + <TextBlock Text="{Binding SeverityLabel}" FontWeight="Bold" + FontSize="11" Foreground="#1A1A1A" + VerticalAlignment="Center"/> + </StackPanel> + </Border> + <TextBlock Text="{Binding DatabaseBracketed}" + Visibility="{Binding HasDatabase, Converter={StaticResource RecoBoolToVis}}" + FontFamily="Consolas" FontSize="12" FontWeight="SemiBold" + Foreground="{DynamicResource AccentBrush}" + VerticalAlignment="Center" Margin="8,0,0,0"/> + </StackPanel> + + <!-- Title --> + <TextBlock Text="{Binding Title}" FontWeight="SemiBold" FontSize="13" + TextWrapping="Wrap" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + + <!-- Advice prose --> + <TextBlock Text="{Binding AdviceText}" TextWrapping="Wrap" + Visibility="{Binding HasAdvice, Converter={StaticResource RecoBoolToVis}}" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + + <!-- Affordances (advise-only): Open in Active Queries (incidents), Copy + fix (when derivable), Ask AI. --> + <StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,8,0,0"> + <Button Content="Open in Active Queries" Padding="10,3" Margin="0,0,8,0" + Visibility="{Binding ShowOpenInActiveQueries, Converter={StaticResource RecoBoolToVis}}" + Click="OpenInActiveQueries_Click" + ToolTip="Open this server's Active Queries scoped to the finding's time window"/> + <Button Content="Copy fix" Padding="10,3" Margin="0,0,8,0" + Visibility="{Binding ShowCopyFix, Converter={StaticResource RecoBoolToVis}}" + Click="CopyFix_Click" + ToolTip="Copy the suggested T-SQL to the clipboard"/> + <Button Content="Ask AI" Padding="10,3" + Visibility="{Binding ShowAskAi, Converter={StaticResource RecoBoolToVis}}" + Click="AskAi_Click" + ToolTip="Copy an MCP investigation prompt to the clipboard"/> + </StackPanel> + </StackPanel> + </Border> + + <DataTemplate.Triggers> + <!-- Severity-driven badge colour, from the theme status brushes. --> + <DataTrigger Binding="{Binding Severity}" Value="Critical"> + <Setter TargetName="SeverityBadge" Property="Background" + Value="{DynamicResource ErrorBrush}"/> + </DataTrigger> + <DataTrigger Binding="{Binding Severity}" Value="Warning"> + <Setter TargetName="SeverityBadge" Property="Background" + Value="{DynamicResource WarningBrush}"/> + </DataTrigger> + <DataTrigger Binding="{Binding Severity}" Value="Info"> + <Setter TargetName="SeverityBadge" Property="Background" + Value="{DynamicResource InfoBrush}"/> + </DataTrigger> + </DataTemplate.Triggers> + </DataTemplate> + + <!-- One incident section: a collapsible Expander whose header names the primary + finding + count + severity, and whose body is that incident's card list. --> + <DataTemplate x:Key="RecoSectionTemplate"> + <Expander Header="{Binding Header}" IsExpanded="{Binding IsExpanded, Mode=OneTime}" + Foreground="{DynamicResource ForegroundBrush}" FontWeight="SemiBold" + FontSize="14" Margin="0,0,0,10"> + <ItemsControl ItemsSource="{Binding Cards}" + ItemTemplate="{StaticResource RecoCardTemplate}" + Margin="0,8,0,0"/> + </Expander> + </DataTemplate> + </TabItem.Resources> + + <Grid Margin="0,8,0,0"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + + <!-- Header bar: own server selector + Refresh + status (last-analysis time). --> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,8"> + <TextBlock Text="Server:" VerticalAlignment="Center" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,0,6,0"/> + <ComboBox x:Name="RecommendationsServerSelector" Width="260" VerticalAlignment="Center" + Style="{StaticResource DarkCombo}" + SelectionChanged="RecommendationsServerSelector_SelectionChanged"> + <ComboBox.ItemTemplate> + <DataTemplate> + <TextBlock Text="{Binding DisplayName}"/> + </DataTemplate> + </ComboBox.ItemTemplate> + </ComboBox> + <Button Content="Refresh" Margin="10,0,0,0" Padding="12,4" + Click="RecommendationsRefresh_Click" + ToolTip="Re-read recommendations for the selected server"/> + <Button x:Name="RecommendationsGenerateButton" Content="Generate now" Margin="8,0,0,0" Padding="12,4" + Style="{StaticResource DarkButton}" + Click="RecommendationsGenerateNow_Click" + ToolTip="Ask the Darling service to run analysis for this server now, then refresh"/> + <TextBlock x:Name="RecommendationsStatusText" Margin="12,0,0,0" VerticalAlignment="Center" + FontStyle="Italic" Foreground="{DynamicResource ForegroundMutedBrush}"/> + </StackPanel> + + <!-- Content: exactly one region is visible per state. --> + <Grid Grid.Row="1"> + <ScrollViewer x:Name="RecommendationsScroll" VerticalScrollBarVisibility="Auto" + Padding="10" Visibility="Collapsed"> + <ItemsControl x:Name="RecommendationsSectionsList" + ItemTemplate="{StaticResource RecoSectionTemplate}"/> + </ScrollViewer> + + <TextBlock x:Name="RecommendationsEmptyText" + Style="{StaticResource EmptyHint}" + Text="All clear — no current recommendations."/> + + <!-- Insufficient data: the analysis pass has not cleared its 24h data-span + gate yet (the persisted V19 marker). Text is set from the view-model. --> + <TextBlock x:Name="RecommendationsInsufficientText" + Style="{StaticResource EmptyHint}" + MaxWidth="520" TextWrapping="Wrap" TextAlignment="Center" + Visibility="Collapsed"/> + + <TextBlock x:Name="RecommendationsLoadingText" + Style="{StaticResource EmptyHint}" + Text="Loading recommendations…"/> + </Grid> + </Grid> + </TabItem> + + <!-- Plan Viewer: a standalone, hidden-until-used aggregate tab (mirrors Lite's + MainWindowPlanViewerTab) — the one plan surface Lite and the Dashboard both have that + the viewer lacked. Server-independent: the user opens or pastes a .sqlplan / plan XML + (or drags a file in) and it renders in the SHARED PerformanceMonitor.Ui + PlanViewerControl as closable sub-tabs, exactly like the per-server Plan Viewer — but + with no store read and no server connection. Opened via the sidebar "Open Plan Viewer" + button; the header's close button hides it again. Built in MainWindow.PlanViewer.cs. + Its inner sub-tabs pick up the theme's implicit TabItem style automatically (no + ItemContainerStyle), matching the per-server PlanTabControl. --> + <TabItem x:Name="MainWindowPlanViewerTab" Visibility="Collapsed"> + <TabItem.Header> + <StackPanel Orientation="Horizontal"> + <TextBlock Text="Plan Viewer" VerticalAlignment="Center" FontWeight="SemiBold"/> + <Button Style="{DynamicResource TabCloseButton}" + Click="MainWindowPlanViewerClose_Click" + Margin="4,0,0,0"/> + </StackPanel> + </TabItem.Header> + <Grid AllowDrop="True" PreviewDrop="MainWindowPlanViewer_Drop" + PreviewDragOver="MainWindowPlanViewer_DragOver" + Background="Transparent" + Focusable="True" KeyDown="MainWindowPlanViewer_KeyDown"> + <TabControl x:Name="MainWindowPlanTabControl" + Background="Transparent" + BorderThickness="0"/> + </Grid> + </TabItem> + </TabControl> + + <!-- Empty state: no servers registered. Shown in the content column with MainTabs hidden + until the store has at least one server (mirrors Lite's no-servers empty state). --> + <Border x:Name="EmptyStatePanel" + Grid.Column="1" + Background="Transparent" + Visibility="Collapsed"> + <StackPanel VerticalAlignment="Center" HorizontalAlignment="Center"> + <TextBlock Text="No Servers Registered" + FontSize="24" FontWeight="Light" + Foreground="{DynamicResource ForegroundMutedBrush}" HorizontalAlignment="Center"/> + <TextBlock Text="Start the Darling service to populate the store, then double-click a server to open its dashboard." + FontSize="14" Foreground="{DynamicResource ForegroundMutedBrush}" + Margin="0,8,0,0" HorizontalAlignment="Center" + TextAlignment="Center" MaxWidth="480" TextWrapping="Wrap"/> + </StackPanel> + </Border> + </Grid> + + <!-- Full-window message state: missing config / unreachable store --> + <Border x:Name="MessageOverlay" + Grid.Row="0" + Background="{DynamicResource BackgroundLightBrush}" + Visibility="Collapsed"> + <TextBlock x:Name="MessageText" + HorizontalAlignment="Center" + VerticalAlignment="Center" + TextWrapping="Wrap" + TextAlignment="Center" + MaxWidth="640" + FontSize="14" + Foreground="{DynamicResource ForegroundBrush}"/> + </Border> + + <!-- Multi-field status bar (ported from Lite, adapted to what the viewer knows): general status, + collector health across all servers (from collection freshness), store database size, overall + collection status, and server count. --> + <Grid Grid.Row="1" Margin="0,8,0,0"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*"/> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + </Grid.ColumnDefinitions> + <TextBlock Grid.Column="0" x:Name="StatusText" FontSize="12" Foreground="{DynamicResource ForegroundMutedBrush}" VerticalAlignment="Center"/> + <TextBlock Grid.Column="1" x:Name="CollectorHealthText" FontSize="12" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="16,0" VerticalAlignment="Center"/> + <TextBlock Grid.Column="2" x:Name="DatabaseSizeText" Text="Database: --" FontSize="12" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="16,0" VerticalAlignment="Center"/> + <TextBlock Grid.Column="3" x:Name="CollectionStatusText" Text="Collection: --" FontSize="12" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="16,0" VerticalAlignment="Center"/> + <TextBlock Grid.Column="4" x:Name="ServerCountText" Text="Servers: 0" FontSize="12" Foreground="{DynamicResource ForegroundMutedBrush}" VerticalAlignment="Center"/> + </Grid> + </Grid> +</Window> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs new file mode 100644 index 000000000..cb5a20951 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs @@ -0,0 +1,1423 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Threading; +using PerformanceMonitor.Common; +using PerformanceMonitor.Notifications; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Darling viewer shell (headless plan M3 + viewer waves 2-4, W0 IA inversion): the server list +/// from the central store on the left, and — mirroring Lite's navigation shape — ONE top tab strip on +/// the right holding the fixed aggregate tabs (Overview and the all-servers Alert History span every +/// server; Recommendations and FinOps are server-scoped, their own pickers synced to the sidebar +/// selection) plus dynamically-added, closable per-server tabs. Single-clicking a server drives the +/// server-scoped aggregate tabs to it; double-clicking opens (or focuses) that server's +/// <see cref="ViewerServerTab"/>, +/// whose inner tabs (Overview charts, Queries, Blocking, Collection Health) hold the per-server surfaces. +/// All reads go straight to Postgres via <see cref="ViewerDataService"/>. Loads are lazy per visible +/// tab (Lite's visible-only rule): the fleet-refresh timer (the configurable +/// <see cref="ViewerAppSettings.NocRefreshIntervalSeconds"/>) refreshes only the visible tab — an aggregate +/// tab for the selected server, or the visible server tab's active inner tab. Every data load is async. +/// </summary> +[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1001:Types that own disposable fields should be disposable", + Justification = "WPF windows are never disposed by the framework; the data service is disposed in OnClosed.")] +public partial class MainWindow : Window +{ + /// <summary> + /// The fleet auto-refresh cadence for the Overview and the aggregate tabs — the single operator knob + /// (<see cref="ViewerAppSettings.NocRefreshIntervalSeconds"/>) that replaced the two hardcoded cadences + /// (60s aggregate / 30s Overview), mirroring the Dashboard's one NocRefreshIntervalSeconds. Both fleet-scope + /// timers run at this interval so an operator can dial the store-query load back at 500-server scale. Seeded + /// in the constructor; re-applied to the live timers when the Settings window closes. + /// </summary> + private TimeSpan _refreshInterval; + + /// <summary>The viewer's connect-timeout preference, applied to the store connection string when the data + /// service is created (see <see cref="ViewerDataService(string, int?)"/>). Seeded in the constructor.</summary> + private readonly int _connectionTimeoutSeconds; + + private ViewerDataService? _dataService; + private DispatcherTimer? _refreshTimer; + private DispatcherTimer? _overviewTimer; + private bool _refreshInFlight; + private bool _refreshRequested; + + /// <summary>Suppresses the Recommendations server combo's SelectionChanged during initial population.</summary> + private bool _populatingRecoServers; + + /// <summary> + /// Open per-server tabs keyed by server id, so a double-click on an already-open server focuses its + /// existing tab instead of opening a duplicate (Lite's dedupe-by-id rule). + /// </summary> + private readonly OpenServerTabRegistry<TabItem> _openServerTabs = new(); + + /// <summary> + /// The viewer's per-user preferences store (a small JSON file in %APPDATA%) and the current in-memory + /// copy. These are the persisted DEFAULTS that seed each newly-opened <see cref="ViewerServerTab"/>'s + /// toolbar (time window + auto-refresh); the Settings dialog edits them. Loaded once on startup. + /// </summary> + private readonly ViewerPreferencesStore _preferencesStore = new(); + private ViewerPreferences _preferences; + + /// <summary> + /// The viewer's per-user application-settings store (the Darling equivalent of Lite's settings.json): + /// the MCP toggle, alert thresholds + toggles, notification options, SMTP/webhook config, and the + /// automated-analysis knobs the full Settings window edits. Held here so the Settings dialog loads from + /// and saves to one shared instance. + /// </summary> + private readonly ViewerAppSettingsStore _appSettingsStore = new(); + + /// <summary> + /// The system-tray icon + minimize-to-tray behavior (ported from Lite's SystemTrayService, adapted for + /// the headless viewer). Null until <see cref="OnLoaded"/> initializes it once the store connects. + /// </summary> + private SystemTrayService? _trayService; + + /// <summary> + /// Turns polled alert-history rows into non-duplicated, cooldown-gated tray toasts — the headless + /// stand-in for Lite's in-process alert-engine toast. Primed at startup so history isn't replayed. + /// </summary> + private AlertToastCoordinator? _toastCoordinator; + private bool _alertPollInFlight; + + /// <summary> + /// Restores the window from the tray if a sleep-/lock-driven minimize hid it (Lite #1050). Reachable now + /// that minimize-to-tray can hide the viewer; paired with the App-startup SoftwareOnly render mode. + /// </summary> + private WindowResumeGuard? _resumeGuard; + + /* Live copies of the three ViewerAppSettings values the tray/toast path honors at runtime: seeded in + the constructor and refreshed when the Settings window closes (mirroring ViewerExportSettings). */ + private bool _minimizeToTray; + private bool _alertsEnabled; + private int _alertCooldownMinutes; + + /// <summary>How far back each toast poll reads alert history; the coordinator dedupes the overlap.</summary> + private static readonly TimeSpan s_alertPollWindow = TimeSpan.FromMinutes(15); + + /// <summary>Seen-row retention (poll window + margin) so a row still inside the window is never pruned early.</summary> + private static readonly TimeSpan s_alertSeenRetention = TimeSpan.FromMinutes(20); + + /// <summary>The Overview tile sort mode (CPU% descending default / Name): seeded from ViewerAppSettings in + /// the constructor, persisted back on selector change, and applied by <see cref="ServerOverviewSort.Order"/> + /// at the fleet bind site so tiles re-sort deterministically every refresh.</summary> + private ServerOverviewSortMode _overviewSortMode; + + public MainWindow() + { + InitializeComponent(); + _preferences = _preferencesStore.Load(); + /* Seed the persisted app settings the viewer honors at runtime BEFORE any tab/chart renders: the CSV + export separator (grid exports) and the Server/Local/UTC time-display mode (every timestamp render + routes through ViewerTimeHelper, which reads CurrentDisplayMode). UiTimeContext is deliberately left + at its identity default — Darling charts pre-convert their X through ForDisplay, so wiring it would + double-convert on hover/crosshair. */ + var appSettings = _appSettingsStore.Load(); + ViewerExportSettings.Apply(appSettings); + /* Seed the new-mute-rule default expiration preference so every MuteRuleEditDialog opens on it, and the + dismiss/mute action-logging opt-in the Alerts tab honors. */ + MuteRuleEditDialog.DefaultExpiration = appSettings.MuteRuleDefaultExpiration; + AlertsHistoryTab.LogDismissals = appSettings.LogAlertDismissals; + /* Seed the runtime tray/toast knobs. Minimize-to-tray is genuinely viewer-local (ViewerAppSettings). + The alerts-master and the "Tray notification cooldown" are STORE-backed (AlertSettingsRow.Enabled / + .CooldownMinutes — the same fields the Settings window's "Enable notifications" + cooldown drive and + the service honors); these ViewerAppSettings copies are only a pre-connect fallback, overwritten by + RefreshAlertToastSettingsFromStoreAsync once the store connects and again on Settings close. */ + _minimizeToTray = appSettings.MinimizeToTray; + _alertsEnabled = appSettings.AlertsEnabled; + _alertCooldownMinutes = appSettings.AlertCooldownMinutes; + /* Seed the connect-timeout applied to the store connection (at ViewerDataService creation, on connect) + and the single fleet-refresh cadence both shell timers run at. */ + _connectionTimeoutSeconds = appSettings.ConnectionTimeoutSeconds; + _refreshInterval = TimeSpan.FromSeconds(appSettings.NocRefreshIntervalSeconds); + ApplyTimeDisplayMode(appSettings.TimeDisplayMode); + /* Overview tile sort: seed the mode from settings and select the matching header-selector item. There is + deliberately NO XAML default selection, so this is the only pre-load SelectedIndex assignment; the + handler's IsLoaded guard suppresses the SelectionChanged it raises here. */ + _overviewSortMode = ServerOverviewSort.ParseMode(appSettings.OverviewSortMode); + OverviewSortSelector.SelectedIndex = _overviewSortMode == ServerOverviewSortMode.Name ? 1 : 0; + Loaded += OnLoaded; + Closed += OnClosed; + } + + private async void OnLoaded(object sender, RoutedEventArgs e) + { + Loaded -= OnLoaded; + + ViewerSettings? settings; + try + { + settings = await Task.Run(() => ViewerSettings.TryLoad(ExplicitConfigPathFromArgs())); + } + catch (Exception ex) + { + ShowMessage($"darling.json could not be read: {ex.Message}"); + return; + } + + if (settings is null) + { + ShowMessage("darling.json not found — copy darling.sample.json from the service and point DARLING_CONFIG at it."); + return; + } + + _dataService = new ViewerDataService(settings.ConnectionString, _connectionTimeoutSeconds); + + try + { + /* Finding B3: prove the store is reachable with one real connect, so an unreachable store (the + service down / wrong postgres section in darling.json) shows a dedicated message instead of + being misread as a read-only seat (DetectReadOnlyAsync used to collapse both to read-only). */ + await _dataService.EnsureStoreReachableAsync(); + + /* Finding B1: gate on schema skew BEFORE any control-plane write can hit a raw Postgres + 42703/42P01 (e.g. Add/Edit Server against a store missing the V18 alert_delivery_mode_override + column). Probe the store's effective version via information_schema — the darling_schema_version + table is owner-only — and block if it is behind this build. NEVER migrate from the viewer: that + is the service's job. The probe fails open (null), so a healthy store is never falsely blocked. */ + var storeVersion = await _dataService.GetStoreSchemaVersionAsync(); + if (storeVersion is int version && version < ViewerDataService.RequiredStoreSchemaVersion) + { + ShowMessage( + $"The Darling store is at schema v{version}, but this viewer needs v{ViewerDataService.RequiredStoreSchemaVersion}. " + + "Update or restart the Darling service so it migrates the store, then reopen the viewer."); + return; + } + + /* V8 security hardening: probe whether this connection can write the operator-config tables + (admin/owner) or is the read-only viewer role, before showing any write affordance. The + probe fails safe to read-only; the write surfaces gate on ViewerDataService.IsReadOnly and + the write paths translate a live 42501 into a friendly message as a backstop. */ + await _dataService.DetectReadOnlyAsync(); + } + catch (ViewerStoreUnreachableException ex) + { + ViewerLogger.Error("App", "Darling store unreachable", ex); + ShowMessage(ex.Message); + return; + } + catch (Exception ex) + { + /* Reachable but the first connection failed for another reason (e.g. authentication, or the + configured database does not exist) — show it rather than dead-ending on a blank window. */ + ViewerLogger.Error("App", "Darling store connection failed", ex); + ShowMessage($"Couldn't connect to the Darling store: {ex.Message}"); + return; + } + + /* A read-only seat cannot command the service, so "Generate now" (analyze_now) is disabled. */ + RecommendationsGenerateButton.IsEnabled = !_dataService.IsReadOnly; + + /* The Alerts tab is a self-loading control (all-servers by default); give it the store and let + it surface load/dismiss/mute outcomes on the shared status bar. */ + AlertsHistoryContent.Initialize(_dataService); + AlertsHistoryContent.StatusChanged += OnServerTabStatusChanged; + + /* The Job History tab is a self-loading all-servers control (retained SQL Agent job-run history); + give it the store and surface its load outcomes on the shared status bar. */ + JobHistoryContent.Initialize(_dataService); + JobHistoryContent.StatusChanged += OnServerTabStatusChanged; + + /* The FinOps tab is a self-loading cross-server aggregate control with its own server selector; give + it the store, surface its load/refresh outcomes on the shared status bar, and route its query grids' + "View Plan" requests into the standalone Plan Viewer surface (it has no per-server plan host). */ + FinOpsContent.Initialize(_dataService); + FinOpsContent.StatusChanged += OnServerTabStatusChanged; + FinOpsContent.PlanRequested += OpenStoredPlanInPlanViewer; + + /* One-time import of any pre-Stage-3 viewer-servers.json definitions into the store, before the first + list load so imported servers appear immediately. Guarded (marker + ON CONFLICT DO NOTHING) so it + never double-seeds the service's darling.json seed nor resurrects a removed server. */ + await MigrateViewerServersAsync(); + + /* One-time import of any pre-Stage-3b viewer-local operational settings (alerts, analysis, SMTP, + Teams/Slack, MCP) into the control-plane store — only when the store section is still at defaults, + so an operator-tuned store is never clobbered. Guarded by a marker; read-only/disconnected skip. */ + await MigrateControlPlaneSettingsAsync(); + + await LoadServersAsync(); + + /* --open-server <name>: deep-link straight into a server's per-server tab on startup — + the same tab a double-click opens. Case-insensitive on the registered server name; + an unknown name is ignored (the window still opens normally). */ + var openServer = OpenServerNameFromArgs(); + if (openServer is not null && ServerList.ItemsSource is IEnumerable<DarlingServer> loaded) + { + var match = loaded.FirstOrDefault(s => + string.Equals(s.ServerName, openServer, StringComparison.OrdinalIgnoreCase)); + if (match is not null) + { + OpenServerTab(match); + } + } + + /* System tray + in-app alert toasts (the ported Lite tray, adapted for headless). The tray icon lives + for the process; restore shares the single-instance SurfaceWindow path so the tray Restore and a + second-launch surface behave identically. Minimize-to-tray reads the live _minimizeToTray value. */ + _trayService = new SystemTrayService(this, SurfaceWindow, () => _minimizeToTray); + _trayService.Initialize(); + + /* #1050: restore the window from the tray on resume/unlock if a sleep- or lock-driven minimize hid it. + ??= so a repeated Loaded can't double-subscribe (static SystemEvents). Shares the SurfaceWindow path. */ + _resumeGuard ??= new WindowResumeGuard(this, SurfaceWindow); + + /* Pull the store-authoritative alerts-master + toast cooldown (AlertSettingsRow) so the toast path + honors the same "Enable notifications" + "Tray notification cooldown" the service does. */ + await RefreshAlertToastSettingsFromStoreAsync(); + + /* Prime the toast watermark with the rows already present so startup doesn't replay history as a + toast storm — only alerts that arrive after this point can toast. Best-effort: a failed prime just + means the first poll may toast up to one card per condition in the window (bounded by the cooldown). */ + _toastCoordinator = new AlertToastCoordinator(s_alertSeenRetention); + try + { + var existing = await _dataService.GetAlertHistoryAsync( + DateTime.UtcNow - s_alertPollWindow, serverId: null, limit: 500); + _toastCoordinator.Prime(existing); + + /* Paint the per-server "needs attention" badges straight away from the same read (they are a + state indicator, not a nudge, so — unlike the toast prime — startup SHOULD surface them), + rather than leaving the sidebar/tabs blank until the first refresh tick ~30s later. */ + UpdateServerAttention(existing); + } + catch (Exception ex) + { + ViewerLogger.Warn("AlertToasts", $"Priming the alert-toast watermark failed: {ex.Message}"); + } + + _refreshTimer = new DispatcherTimer { Interval = _refreshInterval }; + _refreshTimer.Tick += OnRefreshTimerTick; + _refreshTimer.Start(); + + /* The Overview keeps its own timer so it refreshes when it is the visible tab and the aggregate timer + skips it (they never double-refresh the same grid); both run at the one configurable fleet interval. */ + _overviewTimer = new DispatcherTimer { Interval = _refreshInterval }; + _overviewTimer.Tick += OnOverviewTimerTick; + _overviewTimer.Start(); + } + + /// <summary> + /// First non-option command-line argument = explicit config path, mirroring the service + /// (option pairs like --open-server <name> are skipped). + /// </summary> + private static string? ExplicitConfigPathFromArgs() + { + var args = Environment.GetCommandLineArgs(); + for (var i = 1; i < args.Length; i++) + { + if (string.Equals(args[i], "--open-server", StringComparison.OrdinalIgnoreCase)) + { + i++; /* Skip the option's value too. */ + continue; + } + + return args[i]; + } + + return null; + } + + /// <summary>The value following --open-server, or null when absent/dangling.</summary> + private static string? OpenServerNameFromArgs() + { + var args = Environment.GetCommandLineArgs(); + for (var i = 1; i < args.Length - 1; i++) + { + if (string.Equals(args[i], "--open-server", StringComparison.OrdinalIgnoreCase)) + { + return args[i + 1]; + } + } + + return null; + } + + private async void OnRefreshTimerTick(object? sender, EventArgs e) + { + /* Refresh the sidebar status dots + the status bar every cycle regardless of the visible tab (a + cheap pair of single-query reads), so freshness stays current even while a per-server tab is up. */ + _ = RefreshServerStatusAsync(); + _ = RefreshStoreSizeAsync(); + + /* Poll alert history once, regardless of the visible tab: refresh the per-server "needs attention" + badges (sidebar + open tabs) and surface genuinely-new rows as tray toasts. */ + _ = PollAlertsAsync(); + + /* Two tabs opt out of this timer: Recommendations refreshes on tab-activation only (matching Lite — + analysis findings change on the service's 30-minute cadence, so an interval auto-refresh is pointless + churn and would reset the incident expanders' state under the reader), and the Overview has its OWN + timer, so this one skips it (they'd otherwise double-refresh the same grid). Every other aggregate tab + still auto-refreshes. */ + if (ReferenceEquals(MainTabs.SelectedItem, RecommendationsTab) + || ReferenceEquals(MainTabs.SelectedItem, OverviewTab)) + { + return; + } + + /* Per-server tabs now self-refresh on their own toolbar auto-refresh timer (at the user's chosen + interval / on/off), so the global fleet-refresh timer skips them — otherwise both would fire and the + user's interval choice wouldn't stick. */ + if (MainTabs.SelectedItem is TabItem { Content: ViewerServerTab }) + { + return; + } + + await RefreshVisibleAsync(); + } + + private async void OnOverviewTimerTick(object? sender, EventArgs e) + { + if (ReferenceEquals(MainTabs.SelectedItem, OverviewTab)) + { + /* Refresh the sidebar dots on the Overview cadence too, so they track the cards. */ + _ = RefreshServerStatusAsync(); + await RefreshVisibleAsync(); + } + } + + // ── In-app alert toasts (headless poll of alert history) ───────────────────────────── + + /// <summary> + /// Refreshes the two store-authoritative toast knobs — the alerts master (<see cref="AlertSettingsRow.Enabled"/>, + /// the Settings window's "Enable notifications") and the per-condition cooldown + /// (<see cref="AlertSettingsRow.CooldownMinutes"/>, the "Tray notification cooldown") — from the store, so + /// the viewer's toasts honor exactly what the Settings window drives + the service applies. Best-effort: + /// a null row (store not seeded yet) or a read failure leaves the pre-connect fallback values in place. + /// </summary> + private async Task RefreshAlertToastSettingsFromStoreAsync() + { + if (_dataService is null) + { + return; + } + + try + { + var row = await _dataService.GetAlertSettingsAsync(); + if (row is not null) + { + _alertsEnabled = row.Enabled; + _alertCooldownMinutes = row.CooldownMinutes; + } + } + catch (Exception ex) + { + ViewerLogger.Warn("AlertToasts", $"Reading alert settings for toasts failed: {ex.Message}"); + } + } + + /// <summary> + /// Polls recent, non-dismissed alert history once per fleet-refresh tick and drives BOTH the per-server + /// "needs attention" badges and the tray toasts off that single read (no second query): + /// <list type="bullet"> + /// <item>the badges (sidebar rows + open tab headers) are refreshed every poll regardless of the toast + /// master switch — they reflect alert HISTORY, the same rows the Alert History tab shows, gated only by the + /// viewer-local ack-until-worse state (<see cref="ViewerAlertStateService"/>);</item> + /// <item>the tray toasts — the headless substitute for Lite's in-process alert-engine toast — fire only when + /// notifications are enabled and the tray is up, with duplicate-suppression + the per-condition "Tray + /// notification cooldown" gate in <see cref="AlertToastCoordinator"/>.</item> + /// </list> + /// Never throws (a broken poll must not disturb the refresh loop). + /// </summary> + private async Task PollAlertsAsync() + { + if (_dataService is null || _toastCoordinator is null) + { + return; + } + + if (_alertPollInFlight) + { + return; + } + + _alertPollInFlight = true; + try + { + var rows = await _dataService.GetAlertHistoryAsync( + DateTime.UtcNow - s_alertPollWindow, serverId: null, limit: 500); + + /* Per-server badges: always, independent of the toast master switch. */ + UpdateServerAttention(rows); + + /* Tray toasts: only when notifications are enabled and the tray exists. */ + if (_alertsEnabled && _trayService is not null) + { + var cooldown = TimeSpan.FromMinutes(Math.Max(0, _alertCooldownMinutes)); + foreach (var row in _toastCoordinator.SelectToasts(rows, DateTime.UtcNow, cooldown)) + { + ShowAlertToast(row); + } + } + } + catch (Exception ex) + { + ViewerLogger.Warn("AlertToasts", $"Alert poll failed: {ex.Message}"); + } + finally + { + _alertPollInFlight = false; + } + } + + /// <summary> + /// Renders one polled alert row as a tray toast: a green "resolved" styled card for resolution notices + /// (Cleared/Resolved/Restored), otherwise an interactive snoozable card (Error accent for + /// deadlock/poison, Warning otherwise) whose Snooze writes a scoped mute rule to the store. + /// </summary> + private void ShowAlertToast(ViewerAlertRow row) + { + if (_trayService is null) + { + return; + } + + var body = $"{row.ServerName}: {ToastBody(row)}"; + + if (row.IsResolved) + { + _trayService.ShowStyledNotification(row.MetricName, body, ToastSeverity.Success); + return; + } + + var icon = row.IsCritical + ? Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Error + : Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning; + + /* Capture the identity so the snooze callback scopes the mute rule to THIS alert's server + metric. */ + var serverName = row.ServerName; + var metricName = row.MetricName; + _trayService.ShowSnoozableNotification( + row.MetricName, body, icon, duration => SnoozeAlertAsync(serverName, metricName, duration)); + } + + /// <summary> + /// The toast one-liner: the first meaningful line of the stored detail (the richest summary, the analog + /// of Lite's per-metric ShortMessage), falling back to the formatted current value. + /// </summary> + private static string ToastBody(ViewerAlertRow row) + { + if (!string.IsNullOrEmpty(row.DetailText)) + { + foreach (var line in row.DetailText.Split('\n')) + { + var trimmed = line.Trim(); + if (!string.IsNullOrEmpty(trimmed)) + { + return trimmed; + } + } + } + + return row.CurrentValueDisplay; + } + + /// <summary> + /// Persists a snooze from a tray toast: a temporary mute rule scoped to the alert's server + metric + /// (Lite's SnoozeBalloon semantics), written straight to <c>config_mute_rules</c>. The running Darling + /// service re-reads it on its next config load, so the condition stops re-alerting until it expires. + /// </summary> + private async Task SnoozeAlertAsync(string serverName, string metricName, TimeSpan duration) + { + if (_dataService is null) + { + return; + } + + var rule = new MuteRule + { + ServerName = string.IsNullOrEmpty(serverName) ? null : serverName, + MetricName = metricName, + ExpiresAtUtc = DateTime.UtcNow + duration, + Reason = $"Snoozed from tray ({FormatSnoozeDuration(duration)})", + }; + + await _dataService.InsertMuteRuleAsync(rule); + StatusText.Text = $"Snoozed {metricName} on {serverName} for {FormatSnoozeDuration(duration)} — {DateTime.Now:HH:mm:ss}"; + } + + private static string FormatSnoozeDuration(TimeSpan d) => + d.TotalHours >= 1 ? $"{(int)d.TotalHours}h" : $"{(int)d.TotalMinutes}m"; + + /// <summary> + /// Single-clicking a sidebar server drives the server-scoped aggregate tabs to it: it syncs the + /// Recommendations and FinOps server pickers to the selected server (each remains independently + /// changeable) so the tabs show the sidebar server instead of whatever the dropdowns last held, then + /// reloads the visible tab. The two syncs suppress their own SelectionChanged, so the visible tab is + /// loaded exactly once here by <see cref="RefreshVisibleAsync"/>. + /// </summary> + private async void ServerList_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (ServerList.SelectedItem is DarlingServer server) + { + SyncAggregateServerSelectors(server); + } + + await RefreshVisibleAsync(); + } + + /// <summary> + /// Points the Recommendations and FinOps tabs' own server pickers at the sidebar-selected server so the + /// two server-scoped aggregate tabs track the sidebar. Both selections are set under a suppression guard + /// (the Recommendations combo via <see cref="_populatingRecoServers"/>, FinOps inside + /// <see cref="FinOpsTab.SelectServer"/>), so neither fires a redundant refresh — the caller reloads the + /// visible tab once. The lists are populated from the same managed-server set as the sidebar, so a + /// selected server is present in both (matched by id). + /// </summary> + private void SyncAggregateServerSelectors(DarlingServer server) + { + if (RecommendationsServerSelector.ItemsSource is IEnumerable<DarlingServer> recoServers) + { + var match = recoServers.FirstOrDefault(s => s.ServerId == server.ServerId); + if (match is not null && !ReferenceEquals(RecommendationsServerSelector.SelectedItem, match)) + { + _populatingRecoServers = true; + RecommendationsServerSelector.SelectedItem = match; + _populatingRecoServers = false; + } + } + + FinOpsContent.SelectServer(server.ServerId); + } + + /// <summary>Double-click opens (or focuses) the selected server's per-server tab (Lite's rule).</summary> + private void ServerList_MouseDoubleClick(object sender, MouseButtonEventArgs e) + { + if (ServerList.SelectedItem is DarlingServer server) + { + OpenServerTab(server); + } + } + + /// <summary> + /// Lazy per-tab load: switching top-level tabs loads the newly visible one. SelectionChanged is a + /// bubbling routed event, so selections inside the tab content (a findings grid, an inner server + /// tab, the server list template) reach here too — only react to the top TabControl's own. + /// </summary> + private async void MainTabs_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (!ReferenceEquals(e.OriginalSource, MainTabs)) + { + return; + } + + await RefreshVisibleAsync(); + /* The status bar's collector-health scope flips with the active tab (a per-server tab shows that + server; an aggregate tab shows the fleet-cumulative total), so refresh it now rather than waiting + for the next fleet-refresh tick. */ + await UpdateCollectorHealthTextAsync(); + } + + private async Task LoadServersAsync(bool preserveSelection = false) + { + if (_dataService is null) + { + return; + } + + try + { + var previousSelection = (ServerList.SelectedItem as DarlingServer)?.ServerId; + + /* The DESIRED-state managed set (config_monitored_servers), enriched with the observed + collect.servers facts by the shared server_id, so a viewer add/remove/enable is reflected at + once. Stamp the viewer's favorite pins (matched by server name) and sort favorites-first. */ + var servers = ApplyFavoritesAndSort(await _dataService.GetManagedServersAsync()); + ServerList.ItemsSource = servers; + ServerCountText.Text = $"Servers: {servers.Count}"; + + /* The Recommendations tab has its OWN server selector, synced to the sidebar selection on a + single-click (SyncAggregateServerSelectors) yet independently changeable while the tab is open. + Populate it from the same list; the guard suppresses its SelectionChanged during this initial + population so the first load comes from the sidebar-driven RefreshVisibleAsync below (which + reads the now-populated combo). */ + _populatingRecoServers = true; + RecommendationsServerSelector.ItemsSource = servers; + if (servers.Count > 0) + { + RecommendationsServerSelector.SelectedIndex = 0; + } + _populatingRecoServers = false; + + /* The FinOps aggregate tab has its OWN server selector too; hand it the same list. It suppresses + its selector's SelectionChanged during population (like the Recommendations selector above), so + the first FinOps load comes from tab activation, not this call. */ + FinOpsContent.SetServers(servers); + + var hasServers = servers.Count > 0; + ServersHintText.Visibility = hasServers ? Visibility.Collapsed : Visibility.Visible; + EmptyStatePanel.Visibility = hasServers ? Visibility.Collapsed : Visibility.Visible; + MainTabs.Visibility = hasServers ? Visibility.Visible : Visibility.Collapsed; + + if (hasServers) + { + /* Triggers SelectionChanged, which loads the active aggregate tab. Restore the prior + selection after a server add/edit/remove (preserveSelection) so the view doesn't jump + back to the first server; the initial load selects the first. */ + var restore = preserveSelection && previousSelection is int prev + ? servers.Find(s => s.ServerId == prev) + : null; + if (restore is not null) + { + ServerList.SelectedItem = restore; + } + else + { + ServerList.SelectedIndex = 0; + } + } + else + { + StatusText.Text = $"connected — no servers registered yet ({DateTime.Now:HH:mm:ss})"; + } + + /* Seed the sidebar status dots + the status bar's collector-health / collection / database-size + fields (each a single lightweight store read). */ + _ = RefreshServerStatusAsync(); + _ = RefreshStoreSizeAsync(); + } + catch (Exception ex) + { + ShowMessage($"Cannot read the Darling store: {ex.Message}"); + } + } + + /// <summary> + /// Refreshes whichever top-level tab is visible, with the overlap guard: a per-server tab delegates + /// to its own inner-tab refresh; the aggregate tabs reload for the sidebar-selected server. If the + /// user switches tab or server while a load is in flight, the triggering event bounces off the guard + /// and sets <see cref="_refreshRequested"/>, so the running loop reloads once more when it finishes — + /// no user action is silently swallowed. + /// </summary> + private async Task RefreshVisibleAsync() + { + if (_dataService is null) + { + return; + } + + if (_refreshInFlight) + { + _refreshRequested = true; + return; + } + + _refreshInFlight = true; + try + { + do + { + _refreshRequested = false; + await LoadVisibleTabAsync(); + } + while (_refreshRequested); + } + finally + { + _refreshInFlight = false; + } + } + + private async Task LoadVisibleTabAsync() + { + try + { + switch (MainTabs.SelectedItem) + { + case TabItem { Content: ViewerServerTab serverTab }: + await serverTab.RefreshActiveInnerTabAsync(); + break; + case TabItem tab when ReferenceEquals(tab, OverviewTab): + await LoadOverviewAsync(); + break; + case TabItem tab when ReferenceEquals(tab, RecommendationsTab): + await LoadRecommendationsAsync(); + break; + case TabItem tab when ReferenceEquals(tab, AlertsTab): + await AlertsHistoryContent.RefreshAlertsAsync(); + break; + case TabItem tab when ReferenceEquals(tab, JobHistoryTabItem): + await JobHistoryContent.RefreshJobsAsync(); + break; + case TabItem tab when ReferenceEquals(tab, FinOpsTab): + await FinOpsContent.RefreshActiveSubTabAsync(); + break; + } + } + catch (Exception ex) + { + StatusText.Text = $"refresh failed: {ex.Message}"; + } + } + + // ── Per-server tabs (open / focus / close) ────────────────────────────────────── + + /// <summary> + /// Opens the given server's per-server tab, or focuses it if already open (dedupe by server id). + /// The new tab's <see cref="ViewerServerTab"/> loads its active inner tab on first show. Returns the + /// tab's <see cref="ViewerServerTab"/> so a caller (the Recommendations deep-link) can navigate inside + /// it after opening; null only when the data service is not yet ready. + /// </summary> + private ViewerServerTab? OpenServerTab(DarlingServer server) + { + if (_dataService is null) + { + return null; + } + + if (_openServerTabs.TryGet(server.ServerId, out var existing)) + { + MainTabs.SelectedItem = existing; + return existing.Content as ViewerServerTab; + } + + var serverTab = new ViewerServerTab(_dataService, server, _preferences, _serverStore); + serverTab.StatusChanged += OnServerTabStatusChanged; + serverTab.ApplyTimeRangeRequested += OnApplyTimeRangeToAllRequested; + serverTab.DisplayModeChanged += OnDisplayModeChanged; + + var tabItem = new TabItem + { + Header = CreateServerTabHeader(server), + Content = serverTab + }; + + _openServerTabs.Add(server.ServerId, tabItem); + MainTabs.Items.Add(tabItem); + MainTabs.SelectedItem = tabItem; + return serverTab; + } + + /// <summary>Removes a per-server tab and unwires it. WPF selects an adjacent tab when the closed one was active.</summary> + private void CloseServerTab(int serverId) + { + if (!_openServerTabs.TryGet(serverId, out var tab)) + { + return; + } + + if (tab.Content is ViewerServerTab serverTab) + { + serverTab.StatusChanged -= OnServerTabStatusChanged; + serverTab.ApplyTimeRangeRequested -= OnApplyTimeRangeToAllRequested; + serverTab.DisplayModeChanged -= OnDisplayModeChanged; + /* One dispose path: tears down every chart hover helper the tab's partials own. */ + serverTab.Dispose(); + } + + MainTabs.Items.Remove(tab); + _openServerTabs.Remove(serverId); + /* NOTE: the acknowledgement state is deliberately NOT dropped here — unlike Lite, the viewer's badge also + lives on the always-present sidebar row, so an ack must survive a tab close. It is cleared only when the + server is removed from the managed set (see ServerContextMenu_Remove_Click). */ + } + + /// <summary> + /// The per-server tab header: the server name, the alert badge (hidden until the server has active alerts — + /// left-click or right-click to acknowledge), and a close button. Mirrors Lite's CreateTabHeader shape; the + /// close affordance uses the shared theme's TabCloseButton style. + /// </summary> + private StackPanel CreateServerTabHeader(DarlingServer server) + { + var panel = new StackPanel { Orientation = Orientation.Horizontal }; + + panel.Children.Add(new TextBlock + { + Text = server.DisplayName, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 4, 0) + }); + + /* The per-server alert badge (collapsed until UpdateServerAttention lights it). */ + AttachAlertBadge(panel, server.ServerId); + + var closeButton = new Button + { + Cursor = Cursors.Hand, + ToolTip = "Close tab" + }; + if (TryFindResource("TabCloseButton") is Style closeStyle) + { + closeButton.Style = closeStyle; + } + else + { + closeButton.Content = "✕"; + } + closeButton.Click += (s, e) => CloseServerTab(server.ServerId); + panel.Children.Add(closeButton); + + return panel; + } + + private void OnServerTabStatusChanged(string message) => StatusText.Text = message; + + /// <summary> + /// "Apply to All" from one server tab's toolbar: copy its selected range (and, for a custom range, the + /// From/To in the current display-mode wall clock) to every OTHER open server tab so they window on the + /// same period. The source tab is skipped — it already holds the range. + /// </summary> + private void OnApplyTimeRangeToAllRequested(ViewerServerTab source, int index, DateTime? customFromLocal, DateTime? customToLocal) + { + foreach (var tab in _openServerTabs.Values) + { + if (tab.Content is ViewerServerTab serverTab && !ReferenceEquals(serverTab, source)) + { + serverTab.ApplyExternalTimeRange(index, customFromLocal, customToLocal); + } + } + } + + // ── Time-display mode (Server / Local / UTC) ───────────────────────────────────── + + /// <summary> + /// A server tab's Server/Local/UTC picker changed (the raising tab already set the global mode via + /// <see cref="ViewerTimeHelper.CurrentDisplayMode"/> and reloaded itself). Persist the new mode and sync + /// every OTHER open tab's picker so they agree — the mode is process-wide, and a background tab reloads + /// on activation and picks it up. + /// </summary> + private void OnDisplayModeChanged(TimeDisplayMode mode) + { + var settings = _appSettingsStore.Load(); + settings.TimeDisplayMode = mode.ToString(); + _appSettingsStore.Save(settings); + SyncDisplayModeToOpenTabs(mode); + } + + /// <summary>Sets every open server tab's toolbar picker to <paramref name="mode"/> without raising a + /// change (each tab suppresses it), keeping the process-wide mode's UI in sync across tabs.</summary> + private void SyncDisplayModeToOpenTabs(TimeDisplayMode mode) + { + foreach (var tab in _openServerTabs.Values) + { + if (tab.Content is ViewerServerTab serverTab) + { + serverTab.SetDisplayModeSelection(mode); + } + } + } + + /// <summary>Sets the process-wide display mode from a persisted string ("ServerTime"/"LocalTime"/"UTC"); + /// an invalid/absent value falls to Server-time (Lite's default, matching ViewerAppSettings.Normalize).</summary> + private static void ApplyTimeDisplayMode(string? modeText) + { + ViewerTimeHelper.CurrentDisplayMode = + Enum.TryParse<TimeDisplayMode>(modeText, ignoreCase: true, out var mode) ? mode : TimeDisplayMode.ServerTime; + } + + // ── Overview tab (all-servers server cards; refreshes on its own timer at the fleet interval) ────── + + /// <summary>Re-sorts the Overview tiles when the header selector changes and persists the choice to + /// ViewerAppSettings (mirroring <see cref="OnDisplayModeChanged"/>'s load/set/save). Maps the two fixed + /// selector positions EXPLICITLY (index 1 = Name, else CPU%) so a transient -1 during seeding is never + /// treated as an undefined enum. Re-sorts the already-bound list directly — no store round-trip.</summary> + private void OverviewSortSelector_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (!IsLoaded) + { + return; + } + + _overviewSortMode = OverviewSortSelector.SelectedIndex == 1 + ? ServerOverviewSortMode.Name + : ServerOverviewSortMode.Cpu; + + var settings = _appSettingsStore.Load(); + settings.OverviewSortMode = ServerOverviewSort.ToToken(_overviewSortMode); + _appSettingsStore.Save(settings); + + if (OverviewItemsControl.ItemsSource is IEnumerable<ServerSummaryItem> current) + { + OverviewItemsControl.ItemsSource = ServerOverviewSort.Order( + current.ToList(), _overviewSortMode, + s => s.CpuPercentForAlert, s => s.DisplayName, s => s.ServerId); + } + } + + /// <summary> + /// Loads a summary card for every registered server (Lite's RefreshOverviewAsync), reading each + /// server's latest metrics concurrently over the pooled data source. Status is derived from + /// collection freshness — the viewer has no live ping — via <see cref="ServerSummaryItem.ApplyFreshness"/>. + /// This aggregate ignores the sidebar selection; it always shows all servers. + /// </summary> + private async Task LoadOverviewAsync() + { + if (_dataService is null) + { + return; + } + + if (ServerList.ItemsSource is not IEnumerable<DarlingServer> servers) + { + OverviewItemsControl.ItemsSource = null; + FleetRollupContainer.Visibility = Visibility.Collapsed; + return; + } + + var list = servers.ToList(); + if (list.Count == 0) + { + OverviewItemsControl.ItemsSource = null; + FleetRollupContainer.Visibility = Visibility.Collapsed; + return; + } + + var service = _dataService; + var nowUtc = DateTime.UtcNow; + var summaries = await Task.WhenAll(list.Select(async server => + { + try + { + var summary = await service.GetServerSummaryAsync(server.ServerId, server.DisplayName); + summary.ServerName = server.ServerName; + summary.ApplyFreshness(nowUtc); + return summary; + } + catch + { + /* A per-server read failure shouldn't blank the whole grid (Lite logs + continues). */ + return null; + } + })); + + var cards = ServerOverviewSort.Order( + summaries.OfType<ServerSummaryItem>().ToList(), + _overviewSortMode, + s => s.CpuPercentForAlert, s => s.DisplayName, s => s.ServerId); + OverviewItemsControl.ItemsSource = cards; + + /* Fleet-wide NOC roll-up above the cards — the cross-server totals SQL aggregate (the read only + Darling's central store can serve) plus the band counts / worst-N ranking reduced from the + cards' own #1426 banding. The totals use the SAME one-hour window the per-server cards use, so + they reconcile with the sum of the card counts. A totals-read failure degrades to zero totals + rather than blanking the Overview — the band counts and ranking still render from the cards. */ + FleetTotals totals; + try + { + totals = await service.GetFleetTotalsAsync(nowUtc.AddHours(-1), nowUtc); + } + catch + { + totals = new FleetTotals(); + } + + ApplyFleetRollup(FleetRollup.Build(cards, totals)); + + StatusText.Text = $"overview — refreshed {DateTime.Now:HH:mm:ss}"; + } + + /// <summary> + /// Renders the fleet roll-up above the Overview cards: sets the FleetRollupContainer's DataContext and + /// toggles the worst-first "Needs Attention" ranking against the all-clear line (there is no + /// inverse-visibility converter, so the swap is done here). The container shows only when there are + /// servers to roll up. + /// </summary> + private void ApplyFleetRollup(FleetRollup rollup) + { + FleetRollupContainer.DataContext = rollup; + FleetRollupContainer.Visibility = rollup.TotalServers > 0 ? Visibility.Visible : Visibility.Collapsed; + + FleetWorstServersList.Visibility = rollup.HasProblems ? Visibility.Visible : Visibility.Collapsed; + FleetAdditionalProblemsText.Visibility = + rollup.AdditionalProblemCount > 0 ? Visibility.Visible : Visibility.Collapsed; + FleetAllHealthyText.Visibility = rollup.HasProblems ? Visibility.Collapsed : Visibility.Visible; + } + + /// <summary>Double-clicking an Overview card opens (or focuses) that server's tab (Lite's rule).</summary> + private void OverviewCard_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (e.ClickCount == 2 + && sender is FrameworkElement { DataContext: ServerSummaryItem summary } + && ServerList.ItemsSource is IEnumerable<DarlingServer> servers) + { + var server = servers.FirstOrDefault(s => s.ServerId == summary.ServerId); + if (server is not null) + { + OpenServerTab(server); + } + } + } + + /// <summary> + /// Clicking a "Needs Attention" ranking row opens (or focuses) that server's tab — the same + /// server-id → sidebar-server → OpenServerTab hop the Overview cards use (#1427 made OpenServerTab + /// return the tab). + /// </summary> + private void FleetWorstServer_Click(object sender, MouseButtonEventArgs e) + { + if (sender is FrameworkElement { DataContext: FleetRankedServer ranked } + && ServerList.ItemsSource is IEnumerable<DarlingServer> servers) + { + var server = servers.FirstOrDefault(s => s.ServerId == ranked.ServerId); + if (server is not null) + { + OpenServerTab(server); + } + } + } + + // ── Recommendations tab (advise-only cards; own server selector, tab-activation refresh) ───── + + /// <summary> + /// Reads the latest analysis findings AND the per-server analysis-state marker (V19) for the + /// Recommendations tab's OWN selected server, and renders Lite's advise-only, incident-grouped cards. + /// Advise-only: no Apply, and no mute (mute lives on the Alert History surface per the re-skin). The + /// marker lets a zero-finding read on a young deployment show "still collecting" (the engine skipped + /// for its 24h data-span gate) instead of a false all-clear; a server with findings shows them, and a + /// server with enough data and zero findings shows the genuine all-clear. The tab's status line + /// surfaces the last analysis time; "Generate now" forces an immediate pass via the analyze_now command. + /// </summary> + private async Task LoadRecommendationsAsync() + { + if (_dataService is null) + { + return; + } + + if (RecommendationsServerSelector.SelectedItem is not DarlingServer server) + { + ApplyRecommendationsViewModel( + RecommendationsViewModel.FromFindings(Array.Empty<ViewerFindingRow>(), string.Empty)); + RecommendationsStatusText.Text = string.Empty; + return; + } + + ApplyRecommendationsViewModel(RecommendationsViewModel.Loading()); + + var rows = await _dataService.GetLatestFindingsAsync(server.ServerId); + + /* Read the per-server analysis-state marker (V19) the Darling service persists after each pass, so + a zero-finding read on a young deployment renders "still collecting" (the engine skipped for its + 24h data-span gate) instead of a false all-clear. The marker only decides the ZERO-finding case; + a server with findings shows them regardless. Null (no pass yet / pre-V19 store) = not insufficient. */ + var analysisState = await _dataService.GetAnalysisStateAsync(server.ServerId); + + ApplyRecommendationsViewModel( + RecommendationsViewModel.FromFindings( + rows, server.DisplayName, LocalUtcOffsetMinutes(), + insufficientData: analysisState?.InsufficientData == true, + insufficientDataMessage: analysisState?.Message)); + + RecommendationsStatusText.Text = rows.Count > 0 + ? $"Last analyzed {rows[0].AnalysisTimeLocal:yyyy-MM-dd HH:mm:ss} (local)" + : string.Empty; + StatusText.Text = $"{server.DisplayName} — refreshed {DateTime.Now:HH:mm:ss}"; + } + + /// <summary>Swaps the visible content region to match the view-model's state (mirrors Lite's ApplyViewModel).</summary> + private void ApplyRecommendationsViewModel(RecommendationsViewModel vm) + { + switch (vm.State) + { + case RecommendationsState.Loading: + RecommendationsLoadingText.Visibility = Visibility.Visible; + RecommendationsScroll.Visibility = Visibility.Collapsed; + RecommendationsEmptyText.Visibility = Visibility.Collapsed; + RecommendationsInsufficientText.Visibility = Visibility.Collapsed; + break; + + case RecommendationsState.InsufficientData: + RecommendationsLoadingText.Visibility = Visibility.Collapsed; + RecommendationsSectionsList.ItemsSource = null; + RecommendationsScroll.Visibility = Visibility.Collapsed; + RecommendationsEmptyText.Visibility = Visibility.Collapsed; + RecommendationsInsufficientText.Text = vm.InsufficientDataMessage; + RecommendationsInsufficientText.Visibility = Visibility.Visible; + break; + + case RecommendationsState.Empty: + RecommendationsLoadingText.Visibility = Visibility.Collapsed; + RecommendationsSectionsList.ItemsSource = null; + RecommendationsScroll.Visibility = Visibility.Collapsed; + RecommendationsInsufficientText.Visibility = Visibility.Collapsed; + RecommendationsEmptyText.Visibility = Visibility.Visible; + break; + + case RecommendationsState.Loaded: + default: + RecommendationsLoadingText.Visibility = Visibility.Collapsed; + RecommendationsEmptyText.Visibility = Visibility.Collapsed; + RecommendationsInsufficientText.Visibility = Visibility.Collapsed; + RecommendationsSectionsList.ItemsSource = vm.Sections; + RecommendationsScroll.Visibility = Visibility.Visible; + break; + } + } + + /// <summary>The viewer machine's current UTC offset in minutes, for the Ask-AI prompt's local-time window.</summary> + private static int LocalUtcOffsetMinutes() + => (int)Math.Round(TimeZoneInfo.Local.GetUtcOffset(DateTime.UtcNow).TotalMinutes); + + /// <summary>The tab's own server selector drives it (independent of the sidebar); reload on change.</summary> + private async void RecommendationsServerSelector_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_populatingRecoServers) + { + return; + } + + await RefreshVisibleAsync(); + } + + private async void RecommendationsRefresh_Click(object sender, RoutedEventArgs e) + => await RefreshVisibleAsync(); + + /// <summary> + /// "Generate now" — asks the Darling service to run an immediate analysis pass for the selected server via + /// an <c>analyze_now</c> command, then refreshes the cards from the fresh findings. Restores Lite's + /// on-demand analysis (the Darling service otherwise runs it on its own ~30-minute cadence). A read-only + /// seat can't command the service (the button is disabled); a timeout surfaces a "still running" note. + /// </summary> + private async void RecommendationsGenerateNow_Click(object sender, RoutedEventArgs e) + { + if (_dataService is null || _dataService.IsReadOnly) + { + return; + } + + if (RecommendationsServerSelector.SelectedItem is not DarlingServer server) + { + return; + } + + RecommendationsGenerateButton.IsEnabled = false; + RecommendationsStatusText.Text = $"Analyzing {server.DisplayName}..."; + try + { + var result = await _dataService.RequestAnalyzeNowAsync(server.ServerId); + if (result is null) + { + RecommendationsStatusText.Text = "Analysis is still running — refresh in a moment."; + } + else if (result.Status != ViewerDataService.StatusSucceeded) + { + RecommendationsStatusText.Text = $"Analysis did not complete: {result.ResultStatus}"; + } + + await LoadRecommendationsAsync(); + } + catch (ViewerReadOnlyException ex) + { + MessageBox.Show(ex.Message, "Read-only connection", MessageBoxButton.OK, MessageBoxImage.Warning); + } + catch (ViewerSchemaSkewException ex) + { + MessageBox.Show(ex.Message, "Store out of date", MessageBoxButton.OK, MessageBoxImage.Warning); + } + catch (Exception ex) + { + RecommendationsStatusText.Text = $"Generate now failed: {ex.Message}"; + } + finally + { + RecommendationsGenerateButton.IsEnabled = _dataService?.IsReadOnly == false; + } + } + + /// <summary> + /// Copies a card's suggested T-SQL to the clipboard (advise-only). SetDataObject with copy=false + /// avoids WPF's problematic Clipboard.Flush() (matches the Lite convention). + /// </summary> + private void CopyFix_Click(object sender, RoutedEventArgs e) + { + if (sender is not FrameworkElement fe || fe.DataContext is not RecommendationCardViewModel card) + { + return; + } + + if (string.IsNullOrEmpty(card.CopyPasteSql)) + { + return; + } + + Clipboard.SetDataObject(card.CopyPasteSql, false); + RecommendationsStatusText.Text = "Fix copied to clipboard."; + } + + /// <summary>Copies a card's MCP investigation prompt to the clipboard (mirrors Lite's Ask AI).</summary> + private void AskAi_Click(object sender, RoutedEventArgs e) + { + if (sender is not FrameworkElement fe || fe.DataContext is not RecommendationCardViewModel card) + { + return; + } + + Clipboard.SetDataObject(card.AskAiPrompt, false); + RecommendationsStatusText.Text = "AI prompt copied to clipboard."; + } + + /// <summary> + /// "Open in Active Queries" deep-link on an incident card (Dashboard-parity port): resolves the + /// finding's server (the aggregate Recommendations tab is cross-server, so the card carries its own + /// server id), opens/focuses that server's per-server tab via the shell's existing open-tab path, then + /// navigates it straight to Active Queries scoped to the finding's window. The whole hop is pure viewer + /// navigation over already-stored data — no live query, no control-plane write. Only incident cards + /// (a finding with a time window + server id) show the button, so the predicate is already satisfied. + /// </summary> + private async void OpenInActiveQueries_Click(object sender, RoutedEventArgs e) + { + if (sender is not FrameworkElement fe || fe.DataContext is not RecommendationCardViewModel card) + { + return; + } + + if (!card.ShowOpenInActiveQueries) + { + return; + } + + var server = FindManagedServerById(card.ServerId); + if (server is null) + { + RecommendationsStatusText.Text = "Cannot open Active Queries — the finding's server is no longer registered."; + return; + } + + var (fromUtc, toUtc) = card.DeepLinkWindowUtc(); + var serverTab = OpenServerTab(server); + if (serverTab is null) + { + return; + } + + try + { + await serverTab.DeepLinkToActiveQueriesAsync(fromUtc, toUtc); + } + catch (Exception ex) + { + RecommendationsStatusText.Text = $"Open in Active Queries failed: {ex.Message}"; + } + } + + /// <summary>Finds a registered server by id in the Recommendations tab's own selector list (populated + /// from the same managed-server set as the sidebar); null when the server is no longer registered.</summary> + private DarlingServer? FindManagedServerById(int serverId) + => RecommendationsServerSelector.ItemsSource is IEnumerable<DarlingServer> servers + ? servers.FirstOrDefault(s => s.ServerId == serverId) + : null; + + // ── Settings ───────────────────────────────────────────────────────────────────── + + /// <summary> + /// Opens the viewer's full Settings window (the faithful port of Lite's SettingsWindow). It self-persists + /// the application settings (MCP, alerts, notifications, SMTP, webhooks, analysis) to + /// <see cref="_appSettingsStore"/> and secrets to Windows Credential Manager; the folded-in viewer + /// preferences (default time range + auto-refresh) come back via <see cref="SettingsWindow.Result"/> on a + /// successful Save, so we save them and update the in-memory copy that seeds newly-opened server tabs. + /// Already-open tabs keep their own toolbar state (the simple, predictable rule). The store connection is + /// passed through for "Manage Mute Rules" (null before the store is connected disables that button). + /// </summary> + private void SettingsButton_Click(object sender, RoutedEventArgs e) + { + /* Hand the current managed-server list to the collector-schedule editor's per-server scope. */ + var servers = (ServerList.ItemsSource as IReadOnlyList<DarlingServer>) ?? Array.Empty<DarlingServer>(); + var settings = new SettingsWindow(_preferences, _appSettingsStore, _dataService, servers) { Owner = this }; + if (settings.ShowDialog() == true && settings.Result is not null) + { + _preferences = settings.Result; + _preferencesStore.Save(_preferences); + } + + /* Re-seed the runtime settings the viewer honors from whatever the window persisted (it self-saves): + the CSV export separator (next export) and the Server/Local/UTC time-display mode. If the mode + changed there, sync every open tab's picker and reload the visible tab so its timestamps re-render. */ + var reloaded = _appSettingsStore.Load(); + ViewerExportSettings.Apply(reloaded); + /* Re-seed the new-mute-rule default expiration + dismiss/mute logging opt-in in case the operator changed them. */ + MuteRuleEditDialog.DefaultExpiration = reloaded.MuteRuleDefaultExpiration; + AlertsHistoryTab.LogDismissals = reloaded.LogAlertDismissals; + /* Re-seed the runtime tray/toast knobs so a change takes effect immediately (the tray reads + _minimizeToTray live). Minimize-to-tray is viewer-local (the reloaded file); the alerts-master + + "Tray notification cooldown" the window just wrote to the STORE are re-read from there. */ + _minimizeToTray = reloaded.MinimizeToTray; + _ = RefreshAlertToastSettingsFromStoreAsync(); + /* Re-apply the fleet refresh interval to the live shell timers so a change takes effect immediately + (the connection timeout is a connect-time setting and applies on the next viewer launch). */ + _refreshInterval = TimeSpan.FromSeconds(reloaded.NocRefreshIntervalSeconds); + if (_refreshTimer is not null) + { + _refreshTimer.Interval = _refreshInterval; + } + if (_overviewTimer is not null) + { + _overviewTimer.Interval = _refreshInterval; + } + var previousMode = ViewerTimeHelper.CurrentDisplayMode; + ApplyTimeDisplayMode(reloaded.TimeDisplayMode); + if (ViewerTimeHelper.CurrentDisplayMode != previousMode) + { + SyncDisplayModeToOpenTabs(ViewerTimeHelper.CurrentDisplayMode); + _ = LoadVisibleTabAsync(); + } + } + + // ── About ──────────────────────────────────────────────────────────────────────── + + /// <summary>Opens the viewer's About dialog (app name, version, copyright, links).</summary> + private void AboutButton_Click(object sender, RoutedEventArgs e) + { + var about = new AboutWindow { Owner = this }; + about.ShowDialog(); + } + + /// <summary> + /// The one true window-restore path, shared by the tray (Show Window / double-click), minimize-to-tray + /// restore, and the single-instance second-launch surface, so the window can never be left hidden-but-blank. + /// Reconciles a tray-hidden window — minimize-to-tray calls <see cref="Window.Hide"/>, which sets + /// Visibility=Hidden; only <see cref="Window.Show"/> re-runs layout/render (a raw Win32 ShowWindow would + /// leave it blank, Lite #1050) — and restores a minimized window to the viewer's Maximized startup state. + /// Must run on the UI thread. + /// </summary> + public void SurfaceWindow() + { + if (WindowState == WindowState.Minimized) + { + WindowState = WindowState.Maximized; + } + + Show(); + ShowInTaskbar = true; + Activate(); + /* Nudge to the top without pinning topmost. */ + Topmost = true; + Topmost = false; + } + + private void ShowMessage(string message) + { + MessageText.Text = message; + MessageOverlay.Visibility = Visibility.Visible; + StatusText.Text = ""; + } + + private async void OnClosed(object? sender, EventArgs e) + { + _refreshTimer?.Stop(); + _overviewTimer?.Stop(); + _resumeGuard?.Dispose(); + _trayService?.Dispose(); + + if (_dataService is not null) + { + await _dataService.DisposeAsync(); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ManageCredentialProfilesDialog.xaml b/Darling/PerformanceMonitor.Darling.Viewer/ManageCredentialProfilesDialog.xaml new file mode 100644 index 000000000..f77ac16fd --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ManageCredentialProfilesDialog.xaml @@ -0,0 +1,54 @@ +<Window x:Class="PerformanceMonitor.Darling.Viewer.ManageCredentialProfilesDialog" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + Title="Credential Profiles" + Height="420" Width="640" + WindowStartupLocation="CenterOwner" + Icon="EDD.ico" + ResizeMode="CanResizeWithGrip" + Background="{DynamicResource BackgroundBrush}"> + <Grid Margin="16"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + + <TextBlock Grid.Row="0" Text="Credential Profiles" FontWeight="Bold" FontSize="16" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,8"/> + + <TextBlock Grid.Row="1" TextWrapping="Wrap" FontSize="11" + Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,0,0,12" + Text="A credential profile is a named, reusable identity (a shared SQL login, an Azure service principal, or a managed identity) that many servers can reference. Secrets are stored only in Windows Credential Manager, never in config files. A profile can't be deleted while a server still uses it."/> + + <DataGrid Grid.Row="2" x:Name="ProfilesGrid" AutoGenerateColumns="False" IsReadOnly="True" + CanUserAddRows="False" HeadersVisibility="Column" SelectionMode="Single" + MouseDoubleClick="ProfilesGrid_MouseDoubleClick"> + <DataGrid.Columns> + <DataGridTextColumn Header="Name" Binding="{Binding Name}" Width="*"/> + <DataGridTextColumn Header="Auth Type" Binding="{Binding AuthTypeDisplay}" Width="200"/> + <DataGridTextColumn Header="Username / Client ID" Width="200"> + <DataGridTextColumn.Binding> + <PriorityBinding> + <Binding Path="Username"/> + <Binding Path="AzureClientId"/> + <Binding Path="ManagedIdentityClientId"/> + </PriorityBinding> + </DataGridTextColumn.Binding> + </DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + + <TextBlock Grid.Row="3" x:Name="StatusText" FontSize="10" FontStyle="Italic" + Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,8,0,0"/> + + <StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0"> + <Button Content="Add…" Width="80" Height="28" Margin="0,0,8,0" Click="AddButton_Click" Style="{StaticResource AccentButton}"/> + <Button Content="Edit…" Width="80" Height="28" Margin="0,0,8,0" Click="EditButton_Click"/> + <Button Content="Delete" Width="80" Height="28" Margin="0,0,8,0" Click="DeleteButton_Click"/> + <Button Content="Close" Width="80" Height="28" Click="CloseButton_Click" IsCancel="True"/> + </StackPanel> + </Grid> +</Window> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ManageCredentialProfilesDialog.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/ManageCredentialProfilesDialog.xaml.cs new file mode 100644 index 000000000..2e2566a4b --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ManageCredentialProfilesDialog.xaml.cs @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Windows; +using System.Windows.Input; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// Manages the list of <see cref="ViewerCredentialProfile"/> records (Add/Edit/Delete) — the viewer port +/// of Lite's <c>ManageCredentialProfilesDialog</c>, launched from <see cref="ManageServersWindow"/>. +/// Delete is a constraint, not a cascade: a profile referenced by a server can't be deleted until the +/// server is reassigned. +/// </summary> +public partial class ManageCredentialProfilesDialog : Window +{ + private readonly ViewerProfileStore _profileStore; + + /// <summary>True if any profile was added/edited/deleted, so the caller can refresh.</summary> + public bool ProfilesChanged { get; private set; } + + public ManageCredentialProfilesDialog(ViewerProfileStore profileStore) + { + InitializeComponent(); + _profileStore = profileStore; + RefreshGrid(); + } + + private void RefreshGrid() + { + ProfilesGrid.ItemsSource = null; + var profiles = _profileStore.GetAll(); + ProfilesGrid.ItemsSource = profiles; + StatusText.Text = $"{profiles.Count} profile(s)."; + } + + private void AddButton_Click(object sender, RoutedEventArgs e) + { + var dialog = new EditCredentialProfileDialog(_profileStore) { Owner = this }; + if (dialog.ShowDialog() == true) + { + ProfilesChanged = true; + RefreshGrid(); + } + } + + private void EditButton_Click(object sender, RoutedEventArgs e) => EditSelected(); + + private void ProfilesGrid_MouseDoubleClick(object sender, MouseButtonEventArgs e) => EditSelected(); + + private void EditSelected() + { + if (ProfilesGrid.SelectedItem is not ViewerCredentialProfile selected) + { + return; + } + + var dialog = new EditCredentialProfileDialog(_profileStore, selected) { Owner = this }; + if (dialog.ShowDialog() == true) + { + ProfilesChanged = true; + RefreshGrid(); + } + } + + private void DeleteButton_Click(object sender, RoutedEventArgs e) + { + if (ProfilesGrid.SelectedItem is not ViewerCredentialProfile selected) + { + return; + } + + var confirm = MessageBox.Show( + $"Delete credential profile '{selected.Name}'?\n\nThis removes the profile and its stored secret.", + "Delete Credential Profile", + MessageBoxButton.YesNo, + MessageBoxImage.Warning); + + if (confirm != MessageBoxResult.Yes) + { + return; + } + + try + { + _profileStore.DeleteProfile(selected.Id); + ProfilesChanged = true; + RefreshGrid(); + } + catch (Exception ex) + { + /* Referential-integrity block (or any failure) surfaces here. */ + MessageBox.Show(ex.Message, "Cannot Delete Profile", MessageBoxButton.OK, MessageBoxImage.Warning); + } + } + + private void CloseButton_Click(object sender, RoutedEventArgs e) => Close(); +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ManageServersWindow.xaml b/Darling/PerformanceMonitor.Darling.Viewer/ManageServersWindow.xaml new file mode 100644 index 000000000..682863a59 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ManageServersWindow.xaml @@ -0,0 +1,88 @@ +<Window x:Class="PerformanceMonitor.Darling.Viewer.ManageServersWindow" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + Title="Manage Servers" + Height="500" Width="980" + WindowStartupLocation="CenterOwner" + Icon="EDD.ico" + Background="{DynamicResource BackgroundBrush}"> + <Window.Resources> + <ResourceDictionary> + <ContextMenu x:Key="DataGridContextMenu"> + <MenuItem Header="Edit" Click="EditMenuItem_Click"/> + <MenuItem Header="Enable / Disable Collection" Click="ToggleEnabledMenuItem_Click"/> + <MenuItem Header="Excluded Databases" Click="ExcludedDatabases_Click"/> + <MenuItem Header="Delete" Click="DeleteMenuItem_Click" + Foreground="{DynamicResource ErrorBrush}"/> + <Separator/> + <MenuItem Header="Copy Cell" Click="CopyCell_Click"> + <MenuItem.Icon><TextBlock Text="📋"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy Row" Click="CopyRow_Click"> + <MenuItem.Icon><TextBlock Text="📄"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy All Rows" Click="CopyAllRows_Click"> + <MenuItem.Icon><TextBlock Text="📑"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <MenuItem Header="Export to CSV..." Click="ExportToCsv_Click"> + <MenuItem.Icon><TextBlock Text="📊"/></MenuItem.Icon> + </MenuItem> + </ContextMenu> + </ResourceDictionary> + </Window.Resources> + <Grid Margin="16"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + + <!-- Header --> + <StackPanel Grid.Row="0" Margin="0,0,0,12"> + <TextBlock Text="Manage Servers" FontSize="18" FontWeight="Bold" Foreground="{DynamicResource ForegroundBrush}"/> + <TextBlock Text="Add, edit, or remove monitored SQL Servers" FontSize="12" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,4,0,0"/> + </StackPanel> + + <!-- Server Grid --> + <DataGrid Grid.Row="1" x:Name="ServersGrid" + AutoGenerateColumns="False" IsReadOnly="True" + SelectionMode="Single" + Background="Transparent" BorderThickness="0" + HeadersVisibility="Column" GridLinesVisibility="Horizontal" + MouseDoubleClick="ServersGrid_MouseDoubleClick" + ContextMenu="{StaticResource DataGridContextMenu}"> + <DataGrid.Columns> + <DataGridTextColumn Header="Display Name" Binding="{Binding DisplayNameWithIntent}" Width="150"/> + <DataGridTextColumn Header="Server" Binding="{Binding ServerNameDisplay}" Width="200"/> + <DataGridTextColumn Header="Auth" Binding="{Binding AuthenticationDisplay}" Width="100"/> + <DataGridTextColumn Header="Installed Version" Binding="{Binding InstalledVersion}" Width="130"/> + <DataGridTextColumn Header="Status" Binding="{Binding StatusDisplay}" Width="80"/> + <DataGridTextColumn Header="Monthly Cost ($)" Binding="{Binding MonthlyCostUsd, StringFormat='{}{0:N0}'}" Width="120"> + <DataGridTextColumn.ElementStyle> + <Style TargetType="TextBlock"> + <Setter Property="HorizontalAlignment" Value="Right"/> + </Style> + </DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <!-- "Added" (creation time), not Lite's "Last Connected": the viewer never connects to a + monitored server. --> + <DataGridTextColumn Header="Added" Binding="{Binding CreatedDate, StringFormat=yyyy-MM-dd HH:mm}" Width="140"/> + <!-- "Last Collected" (not "Last Connected"): the Darling SERVICE connects + collects, not the + viewer. Reuses the sidebar dots' MAX(collection_time) freshness read; "Never" until first collect. --> + <DataGridTextColumn Header="Last Collected" Binding="{Binding LastCollectedDisplay}" Width="140"/> + </DataGrid.Columns> + </DataGrid> + + <!-- Buttons --> + <StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0"> + <Button Content="Add Server" Click="AddButton_Click" Style="{StaticResource AccentButton}" Margin="0,0,8,0"/> + <Button Content="Edit" Click="EditButton_Click" Margin="0,0,8,0"/> + <Button Content="Excluded Databases" Click="ExcludedDatabases_Click" Margin="0,0,8,0"/> + <Button Content="Credential Profiles…" Click="CredentialProfiles_Click" Margin="0,0,8,0" + ToolTip="Manage named, reusable credentials that many servers can share."/> + <Button Content="Delete" Click="DeleteButton_Click" Margin="0,0,8,0"/> + <Button Content="Close" Click="CloseButton_Click"/> + </StackPanel> + </Grid> +</Window> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ManageServersWindow.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/ManageServersWindow.xaml.cs new file mode 100644 index 000000000..77c3d5cd2 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ManageServersWindow.xaml.cs @@ -0,0 +1,327 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Input; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The viewer's Manage Servers window — the control-plane server list. It reads the DESIRED-state +/// <c>config.config_monitored_servers</c> through <see cref="ViewerDataService"/> (the source of truth for +/// what the Darling service collects, Stage 3) and Add / Edit / Delete / Enable-Disable all WRITE that store, +/// so a change here starts or stops collection on the service's next reload. Excluded-databases editing and +/// shared credential-profile management ride along. Favorites stay viewer-local (<see cref="ViewerServerStore"/>). +/// </summary> +public partial class ManageServersWindow : Window +{ + private readonly ViewerServerStore _serverStore; + private readonly ViewerProfileStore _profileStore; + private readonly ViewerDataService? _dataService; + + /// <summary>True when servers were modified, so the caller refreshes the sidebar.</summary> + public bool ServersChanged { get; private set; } + + /// <param name="dataService">The store the server list reads + writes; null (viewer not connected) shows an + /// empty list with a note — server management needs the store.</param> + public ManageServersWindow(ViewerServerStore serverStore, ViewerProfileStore profileStore, ViewerDataService? dataService) + { + InitializeComponent(); + _serverStore = serverStore; + _profileStore = profileStore; + _dataService = dataService; + Loaded += async (_, _) => await RefreshGridAsync(); + } + + private async Task RefreshGridAsync() + { + if (_dataService is null) + { + ServersGrid.ItemsSource = null; + return; + } + + try + { + /* Preserve the selected row across the reload (after Edit/Delete/Enable), matching the sidebar. */ + var previouslySelected = (ServersGrid.SelectedItem as ManagedServerListItem)?.Row.ServerId; + + var rows = await _dataService.GetMonitoredServersAsync(); + var appVersion = GetAppVersion(); + + /* One collection-freshness read for the whole list — the same MAX(collection_time) per server the + sidebar dots + Overview cards derive freshness from — so the grid shows when the Darling SERVICE + last collected each server (the viewer never connects to a monitored server). Defensive: a + read-only seat or a transient failure just leaves the column showing "Never". */ + Dictionary<int, DateTime> freshness; + try + { + freshness = await _dataService.GetServerFreshnessAsync(); + } + catch (Exception ex) + { + ViewerLogger.Warn("ManageServersWindow", $"collection-freshness read failed: {ex.Message}"); + freshness = new Dictionary<int, DateTime>(); + } + + var items = new List<ManagedServerListItem>(rows.Count); + foreach (var row in rows) + { + var lastCollected = freshness.TryGetValue(row.ServerId, out var t) ? t : (DateTime?)null; + items.Add(new ManagedServerListItem(row, _serverStore.IsFavorite(row.Host)) + { + InstalledVersion = appVersion, + LastCollectedUtc = lastCollected, + }); + } + + ServersGrid.ItemsSource = items; + if (previouslySelected is int prev) + { + ServersGrid.SelectedItem = items.Find(i => i.Row.ServerId == prev); + } + } + catch (Exception ex) + { + ViewerLogger.Error("ManageServersWindow", "Failed to read the configured servers", ex); + MessageBox.Show($"Could not read the configured servers: {ex.Message}", + "Manage Servers", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + private static string GetAppVersion() + { + var raw = Assembly.GetExecutingAssembly() + .GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion + ?? Assembly.GetExecutingAssembly().GetName().Version?.ToString() + ?? "0.0.0"; + + var plusIndex = raw.IndexOf('+'); + var trimmed = plusIndex >= 0 ? raw[..plusIndex] : raw; + return Version.TryParse(trimmed, out var v) + ? new Version(v.Major, v.Minor, v.Build).ToString() + : trimmed; + } + + private async void AddButton_Click(object sender, RoutedEventArgs e) + { + if (_dataService is null) + { + return; + } + + var dialog = new AddServerDialog(_dataService, _serverStore, _profileStore) { Owner = this }; + if (dialog.ShowDialog() == true) + { + ServersChanged = true; + await RefreshGridAsync(); + } + } + + private void EditButton_Click(object sender, RoutedEventArgs e) => EditSelected(); + + private void ServersGrid_MouseDoubleClick(object sender, MouseButtonEventArgs e) => EditSelected(); + + private async void EditSelected() + { + if (_dataService is null || ServersGrid.SelectedItem is not ManagedServerListItem selected) + { + return; + } + + /* The Manage Servers LIST read no longer carries encrypted_password (#1416: a read-only viewer seat + lost SELECT on it, so MonitoredServersSelectSql omits the column). Editing needs the DPAPI blob to + keep the password when the user doesn't retype it, so reload the full row by id here — the edit-load + read (MonitoredServerByIdSql) DOES select the secret, which an admin-role seat can read. On a + read-only seat this read 42501s and the edit is aborted with a message (a read-only seat can't save + anyway). */ + MonitoredServerRow row; + try + { + row = await _dataService.GetMonitoredServerAsync(selected.Row.ServerId) ?? selected.Row; + } + catch (Exception ex) + { + ViewerLogger.Error("ManageServersWindow", "Failed to load the server for editing", ex); + MessageBox.Show($"Could not load the server for editing: {ex.Message}", + "Edit Server", MessageBoxButton.OK, MessageBoxImage.Error); + return; + } + + var dialog = new AddServerDialog(_dataService, _serverStore, _profileStore, row, selected.IsFavorite) { Owner = this }; + if (dialog.ShowDialog() == true) + { + ServersChanged = true; + await RefreshGridAsync(); + } + } + + private void EditMenuItem_Click(object sender, RoutedEventArgs e) => EditSelected(); + + private void DeleteMenuItem_Click(object sender, RoutedEventArgs e) => DeleteButton_Click(sender, e); + + private async void ToggleEnabledMenuItem_Click(object sender, RoutedEventArgs e) + { + if (_dataService is null || ServersGrid.SelectedItem is not ManagedServerListItem selected) + { + return; + } + + var newState = !selected.Row.IsEnabled; + try + { + await _dataService.SetMonitoredServerEnabledAsync(selected.Row.ServerId, newState); + ServersChanged = true; + await RefreshGridAsync(); + } + catch (ViewerReadOnlyException ex) + { + MessageBox.Show(ex.Message, "Manage Servers", MessageBoxButton.OK, MessageBoxImage.Information); + } + catch (ViewerSchemaSkewException ex) + { + MessageBox.Show(ex.Message, "Store out of date", MessageBoxButton.OK, MessageBoxImage.Warning); + } + catch (Exception ex) + { + ViewerLogger.Error("ManageServersWindow", "Failed to toggle collection", ex); + MessageBox.Show($"Could not change collection state: {ex.Message}", + "Manage Servers", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + private void CredentialProfiles_Click(object sender, RoutedEventArgs e) + { + var dialog = new ManageCredentialProfilesDialog(_profileStore) { Owner = this }; + dialog.ShowDialog(); + if (dialog.ProfilesChanged) + { + /* Server rows may reference a profile; refresh to reflect reassignments. */ + _ = RefreshGridAsync(); + } + } + + private async void ExcludedDatabases_Click(object sender, RoutedEventArgs e) + { + if (_dataService is null || ServersGrid.SelectedItem is not ManagedServerListItem selected) + { + return; + } + + var dialog = new ExcludedDatabasesDialog(_dataService, selected.Row, selected.DisplayNameWithIntent) { Owner = this }; + if (dialog.ShowDialog() == true && dialog.ExclusionsModified) + { + ServersChanged = true; + await RefreshGridAsync(); + } + } + + private async void DeleteButton_Click(object sender, RoutedEventArgs e) + { + if (_dataService is null || ServersGrid.SelectedItem is not ManagedServerListItem selected) + { + return; + } + + var result = MessageBox.Show( + $"Delete server '{selected.DisplayNameWithIntent}'?\n\n" + + "This removes the server definition from the store, so the Darling service stops collecting it on " + + "its next reload. Already-collected history is kept.", + "Delete Server", + MessageBoxButton.YesNo, + MessageBoxImage.Warning); + + if (result != MessageBoxResult.Yes) + { + return; + } + + try + { + await _dataService.DeleteMonitoredServerAsync(selected.Row.ServerId); + /* Drop the viewer-local favorite pin too, so a removed server doesn't linger starred. */ + _serverStore.SetFavorite(selected.Row.Host, false); + ServersChanged = true; + await RefreshGridAsync(); + } + catch (ViewerReadOnlyException ex) + { + MessageBox.Show(ex.Message, "Delete Server", MessageBoxButton.OK, MessageBoxImage.Information); + } + catch (ViewerSchemaSkewException ex) + { + MessageBox.Show(ex.Message, "Store out of date", MessageBoxButton.OK, MessageBoxImage.Warning); + } + catch (Exception ex) + { + ViewerLogger.Error("ManageServersWindow", "Failed to delete server", ex); + MessageBox.Show($"Could not delete the server: {ex.Message}", + "Delete Server", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => DataGridExport.ExportToCsv(sender, "servers", ViewerExportSettings.CsvSeparator); + + private void CloseButton_Click(object sender, RoutedEventArgs e) => Close(); +} + +/// <summary> +/// One Manage-Servers grid row — a display projection of a <see cref="MonitoredServerRow"/> from the store. +/// The bound property names match the grid's columns (<c>DisplayNameWithIntent</c> / <c>ServerNameDisplay</c> +/// / <c>AuthenticationDisplay</c> / <c>StatusDisplay</c> / <c>MonthlyCostUsd</c> / <c>CreatedDate</c>), so the +/// XAML is unchanged from the former <c>ViewerServerEntry</c> binding; the underlying <see cref="Row"/> drives +/// the Edit / Delete / Enable / Excluded-Databases actions. +/// </summary> +public sealed class ManagedServerListItem +{ + public ManagedServerListItem(MonitoredServerRow row, bool isFavorite) + { + Row = row ?? throw new ArgumentNullException(nameof(row)); + IsFavorite = isFavorite; + } + + public MonitoredServerRow Row { get; } + + public bool IsFavorite { get; } + + /// <summary>Set once before binding; one viewer shows the same version for every row.</summary> + public string? InstalledVersion { get; set; } + + public string DisplayNameWithIntent => Row.ReadOnlyIntent ? $"{Row.Name} (Read-Only)" : Row.Name; + + public string ServerNameDisplay => Row.ReadOnlyIntent ? $"{Row.Host} (Read-Only)" : Row.Host; + + public string AuthenticationDisplay => + string.Equals(Row.Auth, ServerStoreCredential.Sql, StringComparison.OrdinalIgnoreCase) ? "SQL Server" : "Windows"; + + public string StatusDisplay => Row.IsEnabled ? "Enabled" : "Disabled"; + + public decimal MonthlyCostUsd => Row.MonthlyCostUsd; + + /// <summary>The store's creation time (local, for the grid's "Added" column); falls back to now for a row read without it.</summary> + public DateTime CreatedDate => (Row.CreatedAt ?? DateTime.UtcNow).ToLocalTime(); + + /// <summary>The store's naive-UTC newest collection time for this server (MAX(collection_time) across all + /// collectors), set once before binding; null when the Darling service has not collected this server yet.</summary> + public DateTime? LastCollectedUtc { get; set; } + + /// <summary>The "Last Collected" cell: the newest collection time rendered in the viewer's timestamp-display + /// mode (Server/Local/UTC via <see cref="ViewerTimeHelper.ForDisplay"/>), or "Never" when the service has + /// not collected this server yet. Labeled "Last Collected" — the SERVICE connects and collects, the viewer + /// never does — so this reflects service activity, not a viewer connection.</summary> + public string LastCollectedDisplay => + LastCollectedUtc is { } utc ? ViewerTimeHelper.ForDisplay(utc).ToString("yyyy-MM-dd HH:mm") : "Never"; +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/MuteRuleEditDialog.xaml b/Darling/PerformanceMonitor.Darling.Viewer/MuteRuleEditDialog.xaml new file mode 100644 index 000000000..8dacba517 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/MuteRuleEditDialog.xaml @@ -0,0 +1,112 @@ +<Window x:Class="PerformanceMonitor.Darling.Viewer.MuteRuleEditDialog" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + Title="Create Mute Rule" + SizeToContent="Height" Width="520" + WindowStartupLocation="CenterOwner" + Icon="EDD.ico" + ResizeMode="NoResize" + Background="{DynamicResource BackgroundLightBrush}" + Foreground="{DynamicResource ForegroundBrush}"> + <!-- Mirrors Lite's MuteRuleDialog (Lite/Windows/MuteRuleDialog.xaml) field-for-field, dark-styled + via the shared ViewerDarkTheme resource dictionary. --> + <Window.Resources> + <ResourceDictionary> + <ResourceDictionary.MergedDictionaries> + <ResourceDictionary Source="ViewerDarkTheme.xaml"/> + </ResourceDictionary.MergedDictionaries> + </ResourceDictionary> + </Window.Resources> + + <Grid Margin="20"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + + <TextBlock Grid.Row="0" x:Name="HeaderText" Text="Create Mute Rule" FontSize="18" FontWeight="Bold" + HorizontalAlignment="Left" Margin="0,0,0,5"/> + <TextBlock Grid.Row="1" Text="Alerts matching all specified fields will be suppressed from notifications." + Foreground="{DynamicResource ForegroundMutedBrush}" TextWrapping="Wrap" HorizontalAlignment="Left" Margin="0,0,0,15"/> + + <TextBlock Grid.Row="2" Text="Reason (optional)" FontWeight="Bold" HorizontalAlignment="Left" Margin="0,0,0,4"/> + <TextBox Grid.Row="3" x:Name="ReasonBox" Margin="0,0,0,12"/> + + <TextBlock Grid.Row="4" Text="Expires After" FontWeight="Bold" HorizontalAlignment="Left" Margin="0,0,0,4"/> + <ComboBox Grid.Row="5" x:Name="ExpirationCombo" SelectedIndex="3" Margin="0,0,0,12"> + <ComboBoxItem Content="1 hour"/> + <ComboBoxItem Content="24 hours"/> + <ComboBoxItem Content="7 days"/> + <ComboBoxItem Content="Never (permanent)"/> + </ComboBox> + + <Border Grid.Row="6" BorderThickness="0,0,0,1" BorderBrush="{DynamicResource BorderBrush}" Margin="0,0,0,12"/> + + <TextBlock Grid.Row="7" Text="Match Criteria" FontSize="14" FontWeight="SemiBold" HorizontalAlignment="Left" Margin="0,0,0,4"/> + <TextBlock Grid.Row="8" Text="Leave fields blank to match any value. All filled fields must match (AND logic)." + Foreground="{DynamicResource ForegroundMutedBrush}" TextWrapping="Wrap" HorizontalAlignment="Left" Margin="0,0,0,12"/> + + <TextBlock Grid.Row="9" Text="Server Name (exact match)" HorizontalAlignment="Left" Margin="0,0,0,4"/> + <TextBox Grid.Row="10" x:Name="ServerNameBox" Margin="0,0,0,8"/> + + <TextBlock Grid.Row="11" Text="Metric" HorizontalAlignment="Left" Margin="0,0,0,4"/> + <ComboBox Grid.Row="12" x:Name="MetricCombo" SelectedIndex="0" Margin="0,0,0,8" + SelectionChanged="MetricCombo_SelectionChanged"> + <ComboBoxItem Content="(any)"/> + <ComboBoxItem Content="Blocking Detected"/> + <ComboBoxItem Content="Deadlocks Detected"/> + <ComboBoxItem Content="High CPU"/> + <ComboBoxItem Content="Poison Wait"/> + <ComboBoxItem Content="Long-Running Query"/> + <ComboBoxItem Content="tempdb Space"/> + <ComboBoxItem Content="Volume Free Space"/> + <ComboBoxItem Content="Long-Running Job"/> + </ComboBox> + + <Grid Grid.Row="13" x:Name="PatternFieldsGrid" Margin="0,0,0,8"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*"/> + <ColumnDefinition Width="8"/> + <ColumnDefinition Width="*"/> + </Grid.ColumnDefinitions> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + + <TextBlock Grid.Row="0" Grid.Column="0" x:Name="DatabaseLabel" Text="Database (substring)" HorizontalAlignment="Left" Margin="0,0,0,4"/> + <TextBox Grid.Row="1" Grid.Column="0" x:Name="DatabasePatternBox" Margin="0,0,0,8"/> + + <TextBlock Grid.Row="0" Grid.Column="2" x:Name="WaitTypeLabel" Text="Wait Type (substring)" HorizontalAlignment="Left" Margin="0,0,0,4"/> + <TextBox Grid.Row="1" Grid.Column="2" x:Name="WaitTypePatternBox" Margin="0,0,0,8"/> + + <TextBlock Grid.Row="2" Grid.Column="0" x:Name="QueryTextLabel" Text="Query Text (substring)" HorizontalAlignment="Left" Margin="0,0,0,4"/> + <TextBox Grid.Row="3" Grid.Column="0" x:Name="QueryTextPatternBox" Margin="0,0,0,8" + ToolTip="Alerts whose query text contains this value (case-insensitive) will be muted. Use a distinctive fragment such as a table name or procedure call."/> + + <TextBlock Grid.Row="2" Grid.Column="2" x:Name="JobNameLabel" Text="Job Name (substring)" HorizontalAlignment="Left" Margin="0,0,0,4"/> + <TextBox Grid.Row="3" Grid.Column="2" x:Name="JobNamePatternBox" Margin="0,0,0,8"/> + </Grid> + + <StackPanel Grid.Row="14" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0"> + <Button Content="Save" Width="80" Height="30" Margin="0,0,8,0" Click="Save_Click" IsDefault="True"/> + <Button Content="Cancel" Width="80" Height="30" Click="Cancel_Click" IsCancel="True"/> + </StackPanel> + </Grid> +</Window> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/MuteRuleEditDialog.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/MuteRuleEditDialog.xaml.cs new file mode 100644 index 000000000..2f9c85d7d --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/MuteRuleEditDialog.xaml.cs @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Windows; +using System.Windows.Controls; +using PerformanceMonitor.Notifications; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The viewer's Add/Edit mute-rule editor — Lite's <c>MuteRuleDialog</c> +/// (Lite/Windows/MuteRuleDialog.xaml.cs) ported field-for-field, including the metric-driven +/// pattern-field show/hide and the "no filters = mute all" confirmation, dark-styled for the +/// viewer. On Save it composes the edited <see cref="MuteRule"/>; the caller persists it straight +/// to Postgres (config_mute_rules). New rules default to the operator's +/// <see cref="ViewerAppSettings.MuteRuleDefaultExpiration"/> preference (via <see cref="DefaultExpiration"/>), +/// mirroring Lite's <c>App.MuteRuleDefaultExpiration</c> / the Dashboard's <c>MuteRuleDialog.DefaultExpiration</c>. +/// </summary> +public partial class MuteRuleEditDialog : Window +{ + /// <summary> + /// The default expiration selection for NEW rules ("1 hour" / "24 hours" / "7 days" / "Never"), seeded from + /// <see cref="ViewerAppSettings.MuteRuleDefaultExpiration"/> by <see cref="MainWindow"/> at startup and after + /// the Settings window saves — the viewer's static twin of the Dashboard's <c>MuteRuleDialog.DefaultExpiration</c> + /// (the viewer loads its app settings per-window, so a process-wide static carries the preference to every dialog). + /// </summary> + public static string DefaultExpiration { get; set; } = "24 hours"; + + public MuteRule Rule { get; private set; } + + public MuteRuleEditDialog(MuteRule? existingRule = null) + { + InitializeComponent(); + + if (existingRule != null) + { + Title = "Edit Mute Rule"; + HeaderText.Text = "Edit Mute Rule"; + Rule = existingRule.Clone(); + PopulateFromRule(Rule); + } + else + { + Rule = new MuteRule(); + ApplyDefaultExpiration(); + } + } + + /// <summary>Selects the operator's default-expiration preference for a new rule; unknown values fall back to + /// "Never (permanent)" (SelectedIndex 3), matching Lite's / the Dashboard's switch.</summary> + private void ApplyDefaultExpiration() + { + ExpirationCombo.SelectedIndex = DefaultExpiration switch + { + "1 hour" => 0, + "24 hours" => 1, + "7 days" => 2, + _ => 3 + }; + } + + /// <summary>Creates a dialog pre-populated for muting from an alert context (Lite parity).</summary> + public MuteRuleEditDialog(AlertMuteContext context) : this() + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (!string.IsNullOrEmpty(context.ServerName)) + ServerNameBox.Text = context.ServerName; + if (!string.IsNullOrEmpty(context.MetricName)) + SelectMetric(context.MetricName); + if (!string.IsNullOrEmpty(context.DatabaseName)) + DatabasePatternBox.Text = context.DatabaseName; + if (!string.IsNullOrEmpty(context.QueryText)) + QueryTextPatternBox.Text = context.QueryText.Length > 200 + ? context.QueryText.Substring(0, 200) + : context.QueryText; + if (!string.IsNullOrEmpty(context.WaitType)) + WaitTypePatternBox.Text = context.WaitType; + if (!string.IsNullOrEmpty(context.JobName)) + JobNamePatternBox.Text = context.JobName; + } + + private void PopulateFromRule(MuteRule rule) + { + ReasonBox.Text = rule.Reason ?? ""; + ServerNameBox.Text = rule.ServerName ?? ""; + DatabasePatternBox.Text = rule.DatabasePattern ?? ""; + QueryTextPatternBox.Text = rule.QueryTextPattern ?? ""; + WaitTypePatternBox.Text = rule.WaitTypePattern ?? ""; + JobNamePatternBox.Text = rule.JobNamePattern ?? ""; + + if (!string.IsNullOrEmpty(rule.MetricName)) + SelectMetric(rule.MetricName); + + if (rule.ExpiresAtUtc == null) + { + ExpirationCombo.SelectedIndex = 3; + } + else + { + var remaining = rule.ExpiresAtUtc.Value - DateTime.UtcNow; + if (remaining.TotalHours <= 1.5) ExpirationCombo.SelectedIndex = 0; + else if (remaining.TotalHours <= 25) ExpirationCombo.SelectedIndex = 1; + else ExpirationCombo.SelectedIndex = 2; + } + } + + private void SelectMetric(string metricName) + { + for (int i = 0; i < MetricCombo.Items.Count; i++) + { + if (MetricCombo.Items[i] is ComboBoxItem item && + string.Equals(item.Content?.ToString(), metricName, StringComparison.OrdinalIgnoreCase)) + { + MetricCombo.SelectedIndex = i; + return; + } + } + } + + private void Save_Click(object sender, RoutedEventArgs e) + { + Rule.Reason = string.IsNullOrWhiteSpace(ReasonBox.Text) ? null : ReasonBox.Text.Trim(); + Rule.ServerName = string.IsNullOrWhiteSpace(ServerNameBox.Text) ? null : ServerNameBox.Text.Trim(); + + Rule.DatabasePattern = DatabasePatternBox.Visibility == Visibility.Visible && !string.IsNullOrWhiteSpace(DatabasePatternBox.Text) + ? DatabasePatternBox.Text.Trim() : null; + Rule.QueryTextPattern = QueryTextPatternBox.Visibility == Visibility.Visible && !string.IsNullOrWhiteSpace(QueryTextPatternBox.Text) + ? QueryTextPatternBox.Text.Trim() : null; + Rule.WaitTypePattern = WaitTypePatternBox.Visibility == Visibility.Visible && !string.IsNullOrWhiteSpace(WaitTypePatternBox.Text) + ? WaitTypePatternBox.Text.Trim() : null; + Rule.JobNamePattern = JobNamePatternBox.Visibility == Visibility.Visible && !string.IsNullOrWhiteSpace(JobNamePatternBox.Text) + ? JobNamePatternBox.Text.Trim() : null; + + if (MetricCombo.SelectedIndex > 0 && MetricCombo.SelectedItem is ComboBoxItem selected) + Rule.MetricName = selected.Content?.ToString(); + else + Rule.MetricName = null; + + Rule.ExpiresAtUtc = ExpirationCombo.SelectedIndex switch + { + 0 => DateTime.UtcNow.AddHours(1), + 1 => DateTime.UtcNow.AddHours(24), + 2 => DateTime.UtcNow.AddDays(7), + _ => null + }; + + if (Rule.ServerName == null && Rule.MetricName == null && Rule.DatabasePattern == null + && Rule.QueryTextPattern == null && Rule.WaitTypePattern == null && Rule.JobNamePattern == null) + { + var result = MessageBox.Show( + "This rule has no filters and will mute ALL alerts. Are you sure?", + "Mute All Alerts", MessageBoxButton.YesNo, MessageBoxImage.Warning); + if (result != MessageBoxResult.Yes) return; + } + + DialogResult = true; + } + + private void Cancel_Click(object sender, RoutedEventArgs e) => DialogResult = false; + + private void MetricCombo_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (DatabasePatternBox == null) return; + + var metric = (MetricCombo.SelectedItem as ComboBoxItem)?.Content?.ToString(); + + bool showDatabase = metric is null or "(any)" or "Blocking Detected" or "Deadlocks Detected" or "Long-Running Query"; + bool showWaitType = metric is null or "(any)" or "Poison Wait" or "Long-Running Query"; + bool showQueryText = metric is null or "(any)" or "Blocking Detected" or "Long-Running Query"; + bool showJobName = metric is null or "(any)" or "Long-Running Job"; + + DatabaseLabel.Visibility = DatabasePatternBox.Visibility = showDatabase ? Visibility.Visible : Visibility.Collapsed; + WaitTypeLabel.Visibility = WaitTypePatternBox.Visibility = showWaitType ? Visibility.Visible : Visibility.Collapsed; + QueryTextLabel.Visibility = QueryTextPatternBox.Visibility = showQueryText ? Visibility.Visible : Visibility.Collapsed; + JobNameLabel.Visibility = JobNamePatternBox.Visibility = showJobName ? Visibility.Visible : Visibility.Collapsed; + + PatternFieldsGrid.Visibility = (showDatabase || showWaitType || showQueryText || showJobName) + ? Visibility.Visible : Visibility.Collapsed; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/MuteRulesWindow.xaml b/Darling/PerformanceMonitor.Darling.Viewer/MuteRulesWindow.xaml new file mode 100644 index 000000000..593dea80d --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/MuteRulesWindow.xaml @@ -0,0 +1,77 @@ +<Window x:Class="PerformanceMonitor.Darling.Viewer.MuteRulesWindow" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + Title="Manage Mute Rules" + Height="500" Width="820" + WindowStartupLocation="CenterOwner" + Icon="EDD.ico" + ResizeMode="CanResizeWithGrip" + Background="{DynamicResource BackgroundLightBrush}" + Foreground="{DynamicResource ForegroundBrush}"> + <!-- Mirrors Lite's ManageMuteRulesWindow (Lite/Windows/ManageMuteRulesWindow.xaml), dark-styled + via the shared ViewerDarkTheme resource dictionary. Persists straight to Postgres via + ViewerDataService (no in-memory MuteRuleService cache — the running service re-reads). --> + <Window.Resources> + <ResourceDictionary> + <ResourceDictionary.MergedDictionaries> + <ResourceDictionary Source="ViewerDarkTheme.xaml"/> + </ResourceDictionary.MergedDictionaries> + </ResourceDictionary> + </Window.Resources> + + <Grid Margin="16"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + + <TextBlock Grid.Row="0" Text="Mute Rules" FontSize="18" FontWeight="Bold" HorizontalAlignment="Left" Margin="0,0,0,4"/> + <TextBlock Grid.Row="1" Text="Muted alerts are still collected and logged, but suppressed from alert delivery (email and webhooks). Changes are written to the shared store and take effect on the service's next mute-rule load." + Foreground="{DynamicResource ForegroundMutedBrush}" TextWrapping="Wrap" HorizontalAlignment="Left" Margin="0,0,0,12"/> + + <Grid Grid.Row="2"> + <DataGrid x:Name="RulesGrid" + Style="{StaticResource DarkGrid}" + CanUserDeleteRows="False" + MouseDoubleClick="RulesGrid_MouseDoubleClick"> + <DataGrid.Columns> + <DataGridTemplateColumn Header="Enabled" Width="60"> + <DataGridTemplateColumn.CellTemplate> + <DataTemplate> + <CheckBox IsChecked="{Binding Enabled, UpdateSourceTrigger=PropertyChanged}" + IsEnabled="{Binding RulesAreEditable, RelativeSource={RelativeSource AncestorType=Window}}" + HorizontalAlignment="Center" VerticalAlignment="Center" + Click="EnabledCheckBox_Click"/> + </DataTemplate> + </DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + <DataGridTextColumn Header="Metric" Binding="{Binding MetricName}" Width="130"/> + <DataGridTextColumn Header="Server" Binding="{Binding ServerName}" Width="130"/> + <DataGridTextColumn Header="Reason" Binding="{Binding Reason}" Width="*"/> + <DataGridTextColumn Header="Expires" Binding="{Binding ExpiresDisplay}" Width="140"/> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="NoRulesMessage" + Text="No mute rules yet. Add one, or create one from an alert in the Alerts tab." + Foreground="{DynamicResource ForegroundMutedBrush}" + HorizontalAlignment="Center" VerticalAlignment="Center" + TextWrapping="Wrap" TextAlignment="Center" MaxWidth="420" + Visibility="Collapsed"/> + </Grid> + + <StackPanel x:Name="ActionButtonsPanel" Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Left" Margin="0,12,0,0"> + <Button Content="Add Rule..." Width="100" Height="28" Margin="0,0,8,0" Click="AddRule_Click"/> + <Button Content="Edit" Width="80" Height="28" Margin="0,0,8,0" Click="EditRule_Click"/> + <Button Content="Toggle" Width="80" Height="28" Margin="0,0,8,0" Click="ToggleRule_Click"/> + <Button Content="Delete" Width="80" Height="28" Margin="0,0,8,0" Click="DeleteRule_Click"/> + <Button Content="Purge Expired" Width="110" Height="28" Click="PurgeExpired_Click"/> + </StackPanel> + <TextBlock x:Name="ReadOnlyBanner" Grid.Row="3" HorizontalAlignment="Left" VerticalAlignment="Center" + Margin="0,12,0,0" Foreground="{DynamicResource WarningTextBrush}" TextWrapping="Wrap" MaxWidth="560" Visibility="Collapsed" + Text="Read-only connection (postgres.connectAs = "viewer"): mute rules can be viewed but not changed here. Set connectAs to "admin" and restart the viewer to edit them."/> + <Button Grid.Row="3" Content="Close" Width="80" Height="28" Margin="0,12,0,0" + HorizontalAlignment="Right" Click="Close_Click" IsCancel="True"/> + </Grid> +</Window> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/MuteRulesWindow.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/MuteRulesWindow.xaml.cs new file mode 100644 index 000000000..26b7c352e --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/MuteRulesWindow.xaml.cs @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.ObjectModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using PerformanceMonitor.Notifications; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The viewer's mute-rule manager — Lite's <c>ManageMuteRulesWindow</c> +/// (Lite/Windows/ManageMuteRulesWindow.xaml.cs) ported: Add/Edit/Toggle/Delete/Purge-Expired over +/// the alert mute rules. The one structural difference from Lite is the persistence seam: Lite goes +/// through the in-memory <c>MuteRuleService</c> cache, while the viewer writes STRAIGHT to Postgres +/// via <see cref="ViewerDataService"/> (config_mute_rules) — the store is the coordination point, +/// and the running Darling service re-reads it on its next mute-rule load. Each write is awaited and +/// the list reloaded from the store, so the grid always reflects what was persisted. +/// </summary> +public partial class MuteRulesWindow : Window +{ + private readonly ViewerDataService _dataService; + private readonly ObservableCollection<MuteRule> _rules = new(); + + /// <summary> + /// False when the viewer is connected with the read-only <c>viewer</c> role — bound by the + /// Enabled-column checkbox's <c>IsEnabled</c> so a read-only user can't toggle a rule. Set once at + /// construction (the connection's role does not change while the window is open). + /// </summary> + public bool RulesAreEditable { get; } + + public MuteRulesWindow(ViewerDataService dataService) + { + InitializeComponent(); + _dataService = dataService ?? throw new ArgumentNullException(nameof(dataService)); + RulesAreEditable = !dataService.IsReadOnly; + + /* V8 security hardening: a read-only connection views rules but can't Add/Edit/Toggle/Delete/ + Purge them — replace the action buttons with an explaining banner. The reactive 42501 catch + in the write paths is the backstop if grants change under the open window. */ + if (dataService.IsReadOnly) + { + ActionButtonsPanel.Visibility = Visibility.Collapsed; + ReadOnlyBanner.Visibility = Visibility.Visible; + } + + RulesGrid.ItemsSource = _rules; + Loaded += async (_, _) => await LoadRulesAsync(); + } + + private async System.Threading.Tasks.Task LoadRulesAsync() + { + try + { + var rules = await _dataService.GetMuteRulesAsync(); + _rules.Clear(); + foreach (var rule in rules) + { + _rules.Add(rule); + } + + NoRulesMessage.Visibility = _rules.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + } + catch (Exception ex) + { + ShowError("Could not read mute rules", ex); + } + } + + private async void AddRule_Click(object sender, RoutedEventArgs e) + { + var dialog = new MuteRuleEditDialog { Owner = this }; + if (dialog.ShowDialog() != true) + { + return; + } + + try + { + await _dataService.InsertMuteRuleAsync(dialog.Rule); + await LoadRulesAsync(); + } + catch (Exception ex) + { + ShowError("Could not save the mute rule", ex); + } + } + + private async void EditRule_Click(object sender, RoutedEventArgs e) + { + if (RulesGrid.SelectedItem is not MuteRule selected) + { + return; + } + + var dialog = new MuteRuleEditDialog(selected) { Owner = this }; + if (dialog.ShowDialog() != true) + { + return; + } + + try + { + await _dataService.UpdateMuteRuleAsync(dialog.Rule); + await LoadRulesAsync(); + } + catch (Exception ex) + { + ShowError("Could not update the mute rule", ex); + } + } + + private async void ToggleRule_Click(object sender, RoutedEventArgs e) + { + if (RulesGrid.SelectedItem is not MuteRule selected) + { + return; + } + + try + { + await _dataService.SetMuteRuleEnabledAsync(selected.Id, !selected.Enabled); + await LoadRulesAsync(); + } + catch (Exception ex) + { + ShowError("Could not toggle the mute rule", ex); + } + } + + private async void DeleteRule_Click(object sender, RoutedEventArgs e) + { + if (RulesGrid.SelectedItem is not MuteRule selected) + { + return; + } + + var result = MessageBox.Show( + $"Delete this mute rule?\n\n{selected.Summary}", + "Confirm Delete", MessageBoxButton.YesNo, MessageBoxImage.Question); + if (result != MessageBoxResult.Yes) + { + return; + } + + try + { + await _dataService.DeleteMuteRuleAsync(selected.Id); + await LoadRulesAsync(); + } + catch (Exception ex) + { + ShowError("Could not delete the mute rule", ex); + } + } + + private async void PurgeExpired_Click(object sender, RoutedEventArgs e) + { + try + { + var removed = await _dataService.PurgeExpiredMuteRulesAsync(); + if (removed > 0) + { + await LoadRulesAsync(); + } + + MessageBox.Show( + removed > 0 ? $"Removed {removed} expired rule(s)." : "No expired rules to remove.", + "Purge Complete", MessageBoxButton.OK, MessageBoxImage.Information); + } + catch (Exception ex) + { + ShowError("Could not purge expired mute rules", ex); + } + } + + private async void EnabledCheckBox_Click(object sender, RoutedEventArgs e) + { + if (sender is not CheckBox cb || cb.DataContext is not MuteRule rule) + { + return; + } + + try + { + await _dataService.SetMuteRuleEnabledAsync(rule.Id, cb.IsChecked == true); + } + catch (Exception ex) + { + /* The two-way binding already wrote the attempted value into rule.Enabled, so reload + from the store (unchanged by the failed write) to restore the grid to the truth. */ + ShowError("Could not change the enabled state", ex); + await LoadRulesAsync(); + } + } + + private void RulesGrid_MouseDoubleClick(object sender, MouseButtonEventArgs e) => EditRule_Click(sender, e); + + private void Close_Click(object sender, RoutedEventArgs e) => Close(); + + private void ShowError(string what, Exception ex) => + MessageBox.Show($"{what}: {ex.Message}", "Mute Rules", MessageBoxButton.OK, MessageBoxImage.Warning); +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/OpenServerTabRegistry.cs b/Darling/PerformanceMonitor.Darling.Viewer/OpenServerTabRegistry.cs new file mode 100644 index 000000000..5f6fcedf7 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/OpenServerTabRegistry.cs @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// Tracks the open per-server tabs, keyed by server id, so re-opening a server focuses its existing +/// tab instead of duplicating it (Lite's dedupe-by-id rule). Generic over the tab payload and free of +/// any WPF dependency, so the dedupe/close bookkeeping is unit-testable; MainWindow parameterizes it +/// with <see cref="System.Windows.Controls.TabItem"/>. Keying by server id (not display name) means a +/// server whose display name later changes still resolves to its one open tab. +/// </summary> +internal sealed class OpenServerTabRegistry<TTab> +{ + private readonly Dictionary<int, TTab> _tabs = new(); + + /// <summary>How many server tabs are currently open.</summary> + public int Count => _tabs.Count; + + /// <summary>Every open tab payload, so MainWindow can broadcast (e.g. "Apply time range to all").</summary> + public IEnumerable<TTab> Values => _tabs.Values; + + /// <summary>The existing tab for a server; false when none is open (the caller opens a new one).</summary> + public bool TryGet(int serverId, [MaybeNullWhen(false)] out TTab tab) => _tabs.TryGetValue(serverId, out tab); + + /// <summary>Registers (or replaces) the tab for a server.</summary> + public void Add(int serverId, TTab tab) => _tabs[serverId] = tab; + + /// <summary>Drops the tab for a server; returns false when nothing was tracked for it.</summary> + public bool Remove(int serverId) => _tabs.Remove(serverId); +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/PerformanceMonitor.Darling.Viewer.csproj b/Darling/PerformanceMonitor.Darling.Viewer/PerformanceMonitor.Darling.Viewer.csproj new file mode 100644 index 000000000..963e9c39a --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/PerformanceMonitor.Darling.Viewer.csproj @@ -0,0 +1,70 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <OutputType>WinExe</OutputType> + <TargetFramework>net10.0-windows</TargetFramework> + <Nullable>enable</Nullable> + <ImplicitUsings>disable</ImplicitUsings> + <UseWPF>true</UseWPF> + <RootNamespace>PerformanceMonitor.Darling.Viewer</RootNamespace> + <AssemblyName>PerformanceMonitor.Darling.Viewer</AssemblyName> + <!-- Custom entry point (Program.Main) so VelopackApp.Build().Run() runs first, exactly as Lite + (StartupObject=PerformanceMonitorLite.Program) and Dashboard do. Setting StartupObject + suppresses the WPF-generated App.Main; App.xaml keeps its StartupUri (applied in + InitializeComponent), so MainWindow still opens as before. Required for the remote-seat + Velopack Setup.exe (#1555): without VelopackApp.Build().Run() the install/update/uninstall + hooks go unhandled. It is a harmless no-op for the co-located (non-Velopack) viewer that + ships inside the service zip. --> + <StartupObject>PerformanceMonitor.Darling.Viewer.Program</StartupObject> + <Version>3.2.0</Version> + <AssemblyVersion>3.2.0.0</AssemblyVersion> + <FileVersion>3.2.0.0</FileVersion> + <Company>Darling Data, LLC</Company> + <Copyright>Copyright © 2026 Darling Data, LLC</Copyright> + <ApplicationIcon>EDD.ico</ApplicationIcon> + <ApplicationManifest>app.manifest</ApplicationManifest> + <EnableNETAnalyzers>true</EnableNETAnalyzers> + <AnalysisLevel>latest-recommended</AnalysisLevel> + <!-- NU1701: SkiaSharp.Views.WPF via ScottPlot.WPF — same suppression as Lite and PerformanceMonitor.Ui. + CA1001: the copied CorrelatedTimelineLanesControl owns the disposable crosshair manager but is + deliberately NOT disposable (WPF fires Unloaded transiently — see the control's header), matching + Lite's suppression. --> + <NoWarn>CA1849;CA2007;CA1508;CA1822;CA1805;CA1510;CA1816;CA1861;CA1845;CA2201;NU1701;CA1001;CA1848;CA1852;CA1305;CA1860;CA1707;CA1507;CA1806;CA2254</NoWarn> + <WarningsAsErrors>CS4014</WarningsAsErrors> + </PropertyGroup> + + <ItemGroup> + <!-- Same package + version as Lite, so both chart hosts pin one ScottPlot. --> + <PackageReference Include="ScottPlot.WPF" Version="5.1.58" /> + <!-- System tray icon + balloon toasts (ported from Lite's SystemTrayService). Same package + version + as Lite so both WPF front ends pin one Hardcodet.NotifyIcon.Wpf. --> + <PackageReference Include="Hardcodet.NotifyIcon.Wpf" Version="2.0.1" /> + <!-- Velopack: the remote-seat Setup.exe + in-app self-update (#1555). Same version as Lite and + Dashboard (keep in sync with the vpk pin in .github/workflows/build.yml + nightly.yml). --> + <PackageReference Include="Velopack" Version="1.2.0" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\PerformanceMonitor.Common\PerformanceMonitor.Common.csproj" /> + <ProjectReference Include="..\..\PerformanceMonitor.Ui\PerformanceMonitor.Ui.csproj" /> + <!-- ReproScriptBuilder for the query grids' "Copy Repro Script" (built from stored row fields, no live + connection). Reachable transitively via .Ui, but referenced explicitly since it is a direct dependency. --> + <ProjectReference Include="..\..\PerformanceMonitor.PlanAnalysis\PerformanceMonitor.PlanAnalysis.csproj" /> + <!-- Wave 2: the shared blocked-process row/merge (Alerting), the finding model + advice + table (Analysis), the persisted-action serializer (Notifications), and the Darling + finding store the Recommendations tab reads through (Darling.Analysis). --> + <ProjectReference Include="..\..\PerformanceMonitor.Alerting\PerformanceMonitor.Alerting.csproj" /> + <ProjectReference Include="..\..\PerformanceMonitor.Analysis\PerformanceMonitor.Analysis.csproj" /> + <ProjectReference Include="..\..\PerformanceMonitor.Notifications\PerformanceMonitor.Notifications.csproj" /> + <ProjectReference Include="..\PerformanceMonitor.Darling.Analysis\PerformanceMonitor.Darling.Analysis.csproj" /> + <ProjectReference Include="..\PerformanceMonitor.Darling.Storage\PerformanceMonitor.Darling.Storage.csproj" /> + </ItemGroup> + + <ItemGroup> + <!-- The tests pin pure viewer logic (e.g. the per-server tab dedupe registry) that stays internal. --> + <InternalsVisibleTo Include="Darling.Tests" /> + </ItemGroup> + + <ItemGroup> + <Resource Include="EDD.ico" /> + </ItemGroup> +</Project> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/PlanFrameExtractor.cs b/Darling/PerformanceMonitor.Darling.Viewer/PlanFrameExtractor.cs new file mode 100644 index 000000000..fe798656a --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/PlanFrameExtractor.cs @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Xml.Linq; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// Pulls the (sql_handle, statement-start, statement-end) frames out of a deadlock graph's process node or a +/// blocked-process report's blocked/blocking side — the keys the <c>fetch_plan</c> command needs to read a +/// process's plan from the live cache. The shared <see cref="PerformanceMonitor.Common.DeadlockGraphParser"/> +/// deliberately drops the raw <c>sqlhandle</c> (it exposes the parsed SqlText / SPID / procname for display), +/// so this walks the raw XML the store rows carry directly — Lite's <c>ServerTab.Plans.cs</c> frame-walk lifted +/// to a pure, testable helper (no UI coupling). The all-zero dynamic-SQL / system-context handle is skipped +/// (SQL Server records it when there is no persistent sql_handle, and no plan can be recovered from it). +/// </summary> +public static class PlanFrameExtractor +{ + /// <summary>SQL Server's 42-byte all-zero sql_handle sentinel for dynamic SQL / system contexts (no plan).</summary> + public static readonly string ZeroSqlHandle = "0x" + new string('0', 84); + + /// <summary> + /// The candidate frames for ONE deadlock-graph process (matched by its graph node id): the process-level + /// sqlhandle first (deadlock graphs frequently put it on <c><process></c>), then the executionStack + /// <c><frame></c> handles top-down. Empty when the graph / id is blank, the process is not found, or + /// every handle is the zero sentinel. + /// </summary> + public static IReadOnlyList<(string SqlHandle, int StmtStart, int StmtEnd)> ExtractDeadlockProcessFrames( + string graphXml, string processId) + { + var empty = Array.Empty<(string, int, int)>(); + if (string.IsNullOrWhiteSpace(graphXml) || string.IsNullOrWhiteSpace(processId)) return empty; + try + { + var doc = XElement.Parse(graphXml); + var process = doc.Descendants("process") + .FirstOrDefault(p => string.Equals(p.Attribute("id")?.Value, processId, StringComparison.OrdinalIgnoreCase)); + if (process == null) return empty; + + var frames = new List<(string, int, int)>(); + + var procHandle = process.Attribute("sqlhandle")?.Value; + if (!string.IsNullOrWhiteSpace(procHandle) && + !string.Equals(procHandle, ZeroSqlHandle, StringComparison.OrdinalIgnoreCase)) + { + int ps = 0, pe = -1; + int.TryParse(process.Attribute("stmtstart")?.Value, out ps); + if (int.TryParse(process.Attribute("stmtend")?.Value, out var peParsed)) pe = peParsed; + frames.Add((procHandle!, ps, pe)); + } + + var stack = process.Element("executionStack"); + if (stack != null) + { + foreach (var frame in stack.Elements("frame")) + { + var handle = frame.Attribute("sqlhandle")?.Value; + if (string.IsNullOrWhiteSpace(handle)) continue; + if (string.Equals(handle, ZeroSqlHandle, StringComparison.OrdinalIgnoreCase)) continue; + + int fs = 0, fe = -1; + int.TryParse(frame.Attribute("stmtstart")?.Value, out fs); + if (int.TryParse(frame.Attribute("stmtend")?.Value, out var feParsed)) fe = feParsed; + frames.Add((handle!, fs, fe)); + } + } + + return frames; + } + catch + { + return empty; + } + } + + /// <summary> + /// The candidate frames for the blocked OR blocking side of a blocked-process report — the executionStack + /// <c><frame></c> handles, zero sentinel skipped. Searches by descendant (matching the Dashboard) so + /// it works regardless of how deep the process node sits. Empty when the XML is blank / has no matching + /// executionStack, or every handle is the zero sentinel. + /// </summary> + public static IReadOnlyList<(string SqlHandle, int StmtStart, int StmtEnd)> ExtractBlockedProcessFrames( + string bprXml, bool blockingSide) + { + var empty = Array.Empty<(string, int, int)>(); + if (string.IsNullOrWhiteSpace(bprXml)) return empty; + try + { + var doc = XElement.Parse(bprXml); + var processContainer = doc + .Descendants(blockingSide ? "blocking-process" : "blocked-process") + .FirstOrDefault(); + var stack = processContainer?.Descendants("executionStack").FirstOrDefault(); + if (stack == null) return empty; + + var frames = new List<(string, int, int)>(); + foreach (var frame in stack.Elements("frame")) + { + var handle = frame.Attribute("sqlhandle")?.Value; + if (string.IsNullOrWhiteSpace(handle)) continue; + if (string.Equals(handle, ZeroSqlHandle, StringComparison.OrdinalIgnoreCase)) continue; + + int stmtStart = 0; + int stmtEnd = -1; + int.TryParse(frame.Attribute("stmtstart")?.Value, out stmtStart); + if (int.TryParse(frame.Attribute("stmtend")?.Value, out var se)) stmtEnd = se; + + frames.Add((handle!, stmtStart, stmtEnd)); + } + return frames; + } + catch + { + return empty; + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ProcedureHistoryWindow.xaml b/Darling/PerformanceMonitor.Darling.Viewer/ProcedureHistoryWindow.xaml new file mode 100644 index 000000000..b1c1e6c93 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ProcedureHistoryWindow.xaml @@ -0,0 +1,152 @@ +<Window x:Class="PerformanceMonitor.Darling.Viewer.ProcedureHistoryWindow" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:scottplot="clr-namespace:ScottPlot.WPF;assembly=ScottPlot.WPF" + Title="Procedure Execution History" Width="1200" Height="800" + WindowStartupLocation="CenterOwner" + Icon="EDD.ico" + TextOptions.TextFormattingMode="Display" + UseLayoutRounding="True" + Background="{DynamicResource BackgroundLightBrush}" + Foreground="{DynamicResource ForegroundBrush}"> + <!-- Ported from Lite's Windows/ProcedureHistoryWindow.xaml (the Top Procedures per-row double-click + history window): the selected procedure's metric trend chart + a filterable grid of its per-collection + snapshots. Data repointed to ViewerDataService Postgres reads; "View Plan" / the per-row Download + button surface the STORED plan (GetProcedureStatsPlanXmlAsync). Procedures were already View-Plan-only + in Lite (re-executing a proc without parameter values is unsafe), so nothing extra is dropped here. --> + <Window.Resources> + <ResourceDictionary> + <ResourceDictionary.MergedDictionaries> + <ResourceDictionary Source="ViewerDarkTheme.xaml"/> + </ResourceDictionary.MergedDictionaries> + + <Style x:Key="NumericCell" TargetType="TextBlock"> + <Setter Property="TextAlignment" Value="Right"/> + </Style> + + <ContextMenu x:Key="DataGridContextMenu"> + <MenuItem Header="Copy Cell" Click="CopyCell_Click"> + <MenuItem.Icon><TextBlock Text="📋"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy Row" Click="CopyRow_Click"> + <MenuItem.Icon><TextBlock Text="📄"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy All Rows" Click="CopyAllRows_Click"> + <MenuItem.Icon><TextBlock Text="📑"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <MenuItem Header="Export to CSV..." Click="ExportToCsv_Click"> + <MenuItem.Icon><TextBlock Text="📊"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <MenuItem Header="View Stored Plan" Click="ViewPlan_Click"> + <MenuItem.Icon><TextBlock Text="🔍"/></MenuItem.Icon> + </MenuItem> + <!-- Live plan fetch for the RIGHT-CLICKED snapshot by its plan_handle (headless-safe cached read, + no re-exec) — distinct from the stored "View Plan". Gated on the current row's + HasLivePlanHandle so a snapshot without a plan_handle shows it disabled. --> + <MenuItem Header="View Cached Plan" Click="FetchLivePlan_Click" + IsEnabled="{Binding CurrentItem.HasLivePlanHandle, ElementName=HistoryDataGrid, FallbackValue=False}"> + <MenuItem.Icon><TextBlock Text="🌐"/></MenuItem.Icon> + </MenuItem> + </ContextMenu> + </ResourceDictionary> + </Window.Resources> + + <Grid Margin="10"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="250"/> + <RowDefinition Height="*"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + + <!-- Header --> + <StackPanel Grid.Row="0" Margin="0,0,0,10"> + <TextBlock x:Name="ProcIdentifierText" FontSize="16" FontWeight="Bold"/> + <TextBlock x:Name="SummaryText" FontSize="12" Margin="0,4,0,0" Foreground="{DynamicResource ForegroundDimBrush}"/> + </StackPanel> + + <!-- Chart --> + <Border Grid.Row="1" Background="{DynamicResource BackgroundDarkBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="3"> + <Grid> + <ComboBox x:Name="MetricSelector" Width="180" Height="26" + VerticalAlignment="Top" HorizontalAlignment="Right" Margin="0,6,10,0" + SelectionChanged="MetricSelector_SelectionChanged"> + <ComboBoxItem Content="Avg CPU (ms)" Tag="AvgCpuMs" IsSelected="True"/> + <ComboBoxItem Content="Avg Duration (ms)" Tag="AvgElapsedMs"/> + <ComboBoxItem Content="Avg Logical Reads" Tag="AvgReads"/> + <ComboBoxItem Content="Execution Count Delta" Tag="DeltaExecutions"/> + <ComboBoxItem Content="CPU Delta (ms)" Tag="DeltaCpuMs"/> + <ComboBoxItem Content="Logical Reads Delta" Tag="DeltaLogicalReads"/> + <ComboBoxItem Content="Spills Delta" Tag="DeltaSpills"/> + </ComboBox> + <scottplot:WpfPlot x:Name="HistoryChart" Margin="0,0,0,0"/> + </Grid> + </Border> + + <!-- Data Grid --> + <DataGrid Grid.Row="2" x:Name="HistoryDataGrid" Margin="0,10,0,0" + Style="{StaticResource DarkGrid}" + AutoGenerateColumns="False" IsReadOnly="True" + CanUserSortColumns="True" CanUserReorderColumns="True" + HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" + ContextMenu="{StaticResource DataGridContextMenu}"> + <DataGrid.Columns> + <DataGridTextColumn Header="Collection Time" Binding="{Binding CollectionTimeLocal}" SortMemberPath="CollectionTime" SortDirection="Descending" Width="130"/> + <DataGridTextColumn Header="Last Execution" Binding="{Binding LastExecutionTimeLocal}" Width="130"/> + <DataGridTextColumn Header="Cached Time" Binding="{Binding CachedTimeLocal}" Width="130"/> + <DataGridTextColumn Header="Object Type" Binding="{Binding ObjectType}" Width="95"/> + <DataGridTextColumn Header="Exec Delta" Binding="{Binding DeltaExecutions, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Total Executions" Binding="{Binding TotalExecutions, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="CPU Delta (ms)" Binding="{Binding DeltaCpuMs, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="Duration Delta (ms)" Binding="{Binding DeltaElapsedMs, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="130"/> + <DataGridTextColumn Header="Avg CPU (ms)" Binding="{Binding AvgCpuMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Avg Duration (ms)" Binding="{Binding AvgElapsedMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Total CPU (ms)" Binding="{Binding TotalCpuMs, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="Total Duration (ms)" Binding="{Binding TotalElapsedMs, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="130"/> + <DataGridTextColumn Header="Logical Reads" Binding="{Binding DeltaLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Avg Reads" Binding="{Binding AvgReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Total Logical Reads" Binding="{Binding TotalLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="125"/> + <DataGridTextColumn Header="Writes" Binding="{Binding DeltaLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="80"/> + <DataGridTextColumn Header="Avg Writes" Binding="{Binding AvgWrites, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="95"/> + <DataGridTextColumn Header="Total Writes" Binding="{Binding TotalLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Physical Reads" Binding="{Binding DeltaPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Avg Phys Reads" Binding="{Binding AvgPhysicalReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="Total Phys Reads" Binding="{Binding TotalPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Spills" Binding="{Binding DeltaSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="70"/> + <DataGridTextColumn Header="Avg Spills" Binding="{Binding AvgSpills, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Total Spills" Binding="{Binding TotalSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="95"/> + <DataGridTextColumn Header="Min CPU (ms)" Binding="{Binding MinCpuMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Max CPU (ms)" Binding="{Binding MaxCpuMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Min Duration (ms)" Binding="{Binding MinElapsedMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Max Duration (ms)" Binding="{Binding MaxElapsedMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Min Reads" Binding="{Binding MinLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Max Reads" Binding="{Binding MaxLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Min Phys Reads" Binding="{Binding MinPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="Max Phys Reads" Binding="{Binding MaxPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="Min Writes" Binding="{Binding MinLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Max Writes" Binding="{Binding MaxLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Min Spills" Binding="{Binding MinSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Max Spills" Binding="{Binding MaxSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Interval (sec)" Binding="{Binding SampleIntervalSeconds}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="SQL Handle" Binding="{Binding SqlHandle}" Width="140"/> + <DataGridTextColumn Header="Plan Handle" Binding="{Binding PlanHandle}" Width="140"/> + <DataGridTemplateColumn Header="Plan" Width="Auto" MinWidth="80"> + <DataGridTemplateColumn.CellTemplate> + <DataTemplate> + <Button Content="Download" Click="DownloadPlan_Click" + HorizontalAlignment="Center" VerticalAlignment="Center" + Padding="6,2"/> + </DataTemplate> + </DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + </DataGrid.Columns> + </DataGrid> + + <!-- Footer --> + <StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0"> + <Button Content="Close" Padding="20,6" Click="Close_Click"/> + </StackPanel> + </Grid> +</Window> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ProcedureHistoryWindow.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/ProcedureHistoryWindow.xaml.cs new file mode 100644 index 000000000..68ca5b5ce --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ProcedureHistoryWindow.xaml.cs @@ -0,0 +1,379 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using Microsoft.Win32; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Top Procedures per-row double-click history window — ported from Lite's +/// <c>Windows/ProcedureHistoryWindow.xaml.cs</c>. Shows the selected procedure's (by database + schema + +/// object) metric trend over the window plus a filterable grid of its per-collection snapshots, with a metric +/// selector driving the chart. Reads come from <see cref="ViewerDataService.GetProcedureStatsHistoryAsync"/> +/// (Postgres). "View Plan" and the per-row Download button surface the STORED plan +/// (<see cref="ViewerDataService.GetProcedureStatsPlanXmlAsync"/>); procedures were already View-Plan-only in +/// Lite (re-executing a proc without parameter values is unsafe), so nothing extra is dropped. +/// </summary> +public partial class ProcedureHistoryWindow : Window +{ + private readonly ViewerDataService _dataService; + private readonly int _serverId; + private readonly string _databaseName; + private readonly string _schemaName; + private readonly string _objectName; + private readonly DateTime _startUtc; + private readonly DateTime _endUtc; + private List<ViewerProcedureStatsHistoryRow> _historyData = new(); + private ChartHoverHelper? _chartHover; + private readonly DataGridFilterManager<ViewerProcedureStatsHistoryRow> _filterManager; + private Popup? _filterPopup; + private ColumnFilterPopup? _filterPopupContent; + + public ProcedureHistoryWindow( + ViewerDataService dataService, int serverId, string databaseName, string schemaName, string objectName, + DateTime startUtc, DateTime endUtc) + { + InitializeComponent(); + _dataService = dataService; + _serverId = serverId; + _databaseName = databaseName; + _schemaName = schemaName; + _objectName = objectName; + _startUtc = startUtc; + _endUtc = endUtc; + + _filterManager = new DataGridFilterManager<ViewerProcedureStatsHistoryRow>(HistoryDataGrid); + DataGridFilterColumns.AddFilterButtons(HistoryDataGrid, Filter_Click); + _filterManager.UpdateFilterButtonStyles(); + + var fullName = string.IsNullOrEmpty(schemaName) ? objectName : $"{schemaName}.{objectName}"; + ProcIdentifierText.Text = $"Procedure History: {fullName} in [{databaseName}]"; + Loaded += async (_, _) => await LoadHistoryAsync(); + ThemeManager.ThemeChanged += OnThemeChanged; + Closed += (_, _) => ThemeManager.ThemeChanged -= OnThemeChanged; + } + + private async Task LoadHistoryAsync() + { + try + { + _historyData = await _dataService.GetProcedureStatsHistoryAsync(_serverId, _databaseName, _schemaName, _objectName, _startUtc, _endUtc); + _filterManager.UpdateData(_historyData); + ApplyDefaultDescendingByCollectionTime(); + + if (_historyData.Count > 0) + { + var totalExec = _historyData.Sum(r => r.DeltaExecutions); + var totalCpu = _historyData.Sum(r => r.DeltaCpuMs); + var first = ViewerTimeHelper.ForDisplay(_historyData.First().CollectionTime); + var last = ViewerTimeHelper.ForDisplay(_historyData.Last().CollectionTime); + SummaryText.Text = $"{_historyData.Count} samples from {first:MM/dd HH:mm} to {last:MM/dd HH:mm} | " + + $"Total Executions: {totalExec:N0} | Total CPU: {totalCpu:N1} ms"; + } + else + { + SummaryText.Text = "No history data found for this procedure in the selected time range."; + } + + UpdateChart(); + } + catch (Exception ex) + { + SummaryText.Text = $"Error loading history: {ex.Message}"; + } + } + + /// <summary> + /// Defaults the history grid to newest-first (Erik's descending-default grid convention). The SQL read stays + /// ascending on purpose — the chart plots <c>_historyData</c> in reader order and the summary reads + /// <c>First()</c>/<c>Last()</c> — so this reorders ONLY the grid's view, mirroring the parent Queries grids' + /// <c>SetDefaultSortIfNone(..., ListSortDirection.Descending)</c>. Guarded so a user's later sort survives. + /// </summary> + private void ApplyDefaultDescendingByCollectionTime() + { + if (HistoryDataGrid.Items.SortDescriptions.Count != 0) + return; + + HistoryDataGrid.Items.SortDescriptions.Add(new System.ComponentModel.SortDescription( + nameof(ViewerProcedureStatsHistoryRow.CollectionTime), System.ComponentModel.ListSortDirection.Descending)); + } + + private void UpdateChart() + { + if (_historyData == null || _historyData.Count == 0) + { + HistoryChart.Plot.Clear(); + HistoryChart.Refresh(); + return; + } + + HistoryChart.Plot.Clear(); + + var selected = MetricSelector.SelectedItem as ComboBoxItem; + var tag = selected?.Tag?.ToString() ?? "AvgCpuMs"; + var label = selected?.Content?.ToString() ?? "Avg CPU (ms)"; + + var xs = _historyData.Select(r => ViewerTimeHelper.ForDisplay(r.CollectionTime).ToOADate()).ToArray(); + var ys = _historyData.Select(r => GetMetricValue(r, tag)).ToArray(); + + var scatter = HistoryChart.Plot.Add.Scatter(xs, ys); + scatter.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("MetricTrend")); + ChartStyle.StyleScatter(scatter); + scatter.LegendText = label; + + var unit = tag.Contains("Ms") ? "ms" : ""; + if (_chartHover == null) + _chartHover = new ChartHoverHelper(HistoryChart, unit); + else + _chartHover.Unit = unit; + _chartHover.Clear(); + _chartHover.Add(scatter, label); + + HistoryChart.Plot.Axes.DateTimeTicksBottom(); + ApplyTheme(HistoryChart); + + HistoryChart.Refresh(); + } + + /// <summary>Projects one history row to the metric the selector shows — the chart's Y value. Pure + + /// static for unit testing (mirrors Lite's GetMetricValue switch).</summary> + internal static double GetMetricValue(ViewerProcedureStatsHistoryRow row, string tag) => tag switch + { + "AvgCpuMs" => row.AvgCpuMs, + "AvgElapsedMs" => row.AvgElapsedMs, + "AvgReads" => row.AvgReads, + "DeltaExecutions" => row.DeltaExecutions, + "DeltaCpuMs" => row.DeltaCpuMs, + "DeltaLogicalReads" => row.DeltaLogicalReads, + "DeltaSpills" => row.DeltaSpills, + _ => row.AvgCpuMs + }; + + private void MetricSelector_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (IsLoaded) UpdateChart(); } + + private static void ApplyTheme(ScottPlot.WPF.WpfPlot chart) => ChartStyle.ApplyMinimalChartTheme(chart); + + private void OnThemeChanged(string _) + { + ApplyTheme(HistoryChart); + HistoryChart.Refresh(); + _filterManager.UpdateFilterButtonStyles(); + } + + // ── Stored plan surface (viewer has no live server — Lite's live fetch becomes a stored read) ── + + private async void DownloadPlan_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button btn) return; + if (string.IsNullOrEmpty(_objectName)) return; + + btn.IsEnabled = false; + btn.Content = "..."; + try + { + var plan = await _dataService.GetProcedureStatsPlanXmlAsync(_serverId, _databaseName, _schemaName, _objectName); + if (string.IsNullOrEmpty(plan)) + { + MessageBox.Show(this, "No plan was captured in the collected data for this procedure.", + "Plan Not Found", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + var dialog = new SaveFileDialog + { + Filter = "SQL Plan files (*.sqlplan)|*.sqlplan|All files (*.*)|*.*", + DefaultExt = ".sqlplan", + FileName = $"proc_plan_{_objectName}_{DateTime.Now:yyyyMMdd_HHmmss}.sqlplan" + }; + + if (dialog.ShowDialog() != true) return; + File.WriteAllText(dialog.FileName, plan, Encoding.UTF8); + btn.Content = "Saved"; + return; + } + catch (Exception ex) + { + MessageBox.Show(this, $"Failed to retrieve plan: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + if (btn.Content is "...") + btn.Content = "Download"; + btn.IsEnabled = true; + } + } + + /// <summary>"View Plan" — shows the procedure's stored estimated plan in a shared + /// <see cref="PlanViewerControl"/> floated above this window. Procedures are View-Plan-only (Lite too).</summary> + private async void ViewPlan_Click(object sender, RoutedEventArgs e) + { + var fullName = string.IsNullOrEmpty(_schemaName) ? _objectName : $"{_schemaName}.{_objectName}"; + string? planXml; + try + { + planXml = await _dataService.GetProcedureStatsPlanXmlAsync(_serverId, _databaseName, _schemaName, _objectName); + } + catch (Exception ex) + { + MessageBox.Show(this, $"Failed to retrieve plan: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + return; + } + + if (string.IsNullOrEmpty(planXml)) + { + MessageBox.Show(this, "No plan was captured in the collected data for this procedure.", + "No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + var label = $"Est Plan - {fullName}"; + var viewer = new PlanViewerControl(); + try + { + await viewer.LoadPlan(planXml, label, queryText: null); + } + catch (Exception ex) + { + viewer.Cleanup(); + MessageBox.Show(this, $"Failed to load the execution plan:\n\n{ex.Message}", + "Plan Load Error", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + var window = GraphViewerWindow.ShowGraph(this, viewer, label); + window.Closed += (_, _) => viewer.Cleanup(); + } + + /// <summary>"View Cached Plan" — asks the service to read the RIGHT-CLICKED snapshot's plan from the target's + /// LIVE cache by its plan_handle (headless-safe: a cached read via the fetch_plan command, no re-execution), + /// mirroring the parent grid's FetchLiveQueryPlan_Click. Distinct from "View Plan" (the stored plan XML). + /// Gated per row on HasLivePlanHandle; the same plan_handle-bearing row gets it on the parent Top Procedures grid.</summary> + private async void FetchLivePlan_Click(object sender, RoutedEventArgs e) + { + if (HistoryDataGrid.CurrentItem is not ViewerProcedureStatsHistoryRow row || string.IsNullOrEmpty(row.PlanHandle)) + return; + + var fullName = string.IsNullOrEmpty(_schemaName) ? _objectName : $"{_schemaName}.{_objectName}"; + var label = $"Cached Plan - {fullName}"; + var argsJson = ViewerDataService.BuildPlanFetchArgsByPlanHandle(row.PlanHandle, _databaseName); + await FetchAndShowLivePlanAsync(argsJson, label, queryText: null); + } + + /// <summary>Runs a fetch_plan round-trip and shows the plan in a shared PlanViewerControl floated above this + /// window (a read-only seat / not-in-cache / failure surfaces the service's message). Mirrors + /// ViewerServerTab.FetchAndOpenLivePlanAsync, adapted to this window's GraphViewerWindow plan host.</summary> + private async Task FetchAndShowLivePlanAsync(string argsJson, string label, string? queryText) + { + LivePlanFetchResult result; + try + { + result = await _dataService.FetchLivePlanAsync(_serverId, argsJson); + } + catch (ViewerReadOnlyException) + { + MessageBox.Show(this, "This is a read-only viewer seat, which cannot ask the service to fetch a live plan.", + "Read-Only", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + catch (Exception ex) + { + MessageBox.Show(this, $"Failed to fetch the live plan: {ex.Message}", "Cached Plan Error", + MessageBoxButton.OK, MessageBoxImage.Error); + return; + } + + if (result.Status != LivePlanFetchStatus.Fetched || string.IsNullOrEmpty(result.PlanXml)) + { + MessageBox.Show(this, result.Message ?? "The live plan could not be fetched.", + "Cached Plan", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + var viewer = new PlanViewerControl(); + try + { + await viewer.LoadPlan(result.PlanXml, label, queryText); + } + catch (Exception ex) + { + viewer.Cleanup(); + MessageBox.Show(this, $"Failed to load the execution plan:\n\n{ex.Message}", + "Plan Load Error", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + var window = GraphViewerWindow.ShowGraph(this, viewer, label); + window.Closed += (_, _) => viewer.Cleanup(); + } + + // ── Column Filter Popup (mirrors WaitDrillDownWindow / ViewerServerTab.Filters.cs) ── + + private void EnsureFilterPopup() + { + if (_filterPopup == null) + { + _filterPopupContent = new ColumnFilterPopup(); + _filterPopup = new Popup + { + Child = _filterPopupContent, + StaysOpen = false, + Placement = PlacementMode.Bottom, + AllowsTransparency = true + }; + } + } + + private void Filter_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button button || button.Tag is not string columnName) return; + + EnsureFilterPopup(); + + _filterPopupContent!.FilterApplied -= FilterPopup_FilterApplied; + _filterPopupContent.FilterCleared -= FilterPopup_FilterCleared; + _filterPopupContent.FilterApplied += FilterPopup_FilterApplied; + _filterPopupContent.FilterCleared += FilterPopup_FilterCleared; + + _filterManager.Filters.TryGetValue(columnName, out var existingFilter); + _filterPopupContent.Initialize(columnName, existingFilter); + + _filterPopup!.PlacementTarget = button; + _filterPopup.IsOpen = true; + } + + private void FilterPopup_FilterApplied(object? sender, FilterAppliedEventArgs e) + { + if (_filterPopup != null) _filterPopup.IsOpen = false; + _filterManager.SetFilter(e.FilterState); + } + + private void FilterPopup_FilterCleared(object? sender, EventArgs e) + { + if (_filterPopup != null) _filterPopup.IsOpen = false; + } + + // ── Copy / Export / Close ── + + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => DataGridExport.ExportToCsv(sender, "procedure_history", ViewerExportSettings.CsvSeparator); + + private void Close_Click(object sender, RoutedEventArgs e) => Close(); +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/Program.cs b/Darling/PerformanceMonitor.Darling.Viewer/Program.cs new file mode 100644 index 000000000..f8c1ce0d9 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/Program.cs @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using Velopack; + +namespace PerformanceMonitor.Darling.Viewer +{ + /// <summary> + /// Explicit entry point (wired via <StartupObject> in the csproj) so + /// <see cref="VelopackApp"/>.Build().Run() runs before any WPF window is created — exactly as + /// Lite's and Dashboard's Program.Main do. This is what makes the viewer a first-class Velopack + /// app for the remote-seat Setup.exe (#1555): it handles the install / update / uninstall hooks + /// Velopack invokes the exe with. On the co-located viewer that ships inside the service zip + /// (not a Velopack install) it is a no-op, so the same binary serves both deployment modes. + /// </summary> + public static class Program + { + [STAThread] + public static void Main(string[] args) + { + VelopackApp.Build().Run(); + + var app = new App(); + app.InitializeComponent(); + app.Run(); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/QueryStatsHistoryWindow.xaml b/Darling/PerformanceMonitor.Darling.Viewer/QueryStatsHistoryWindow.xaml new file mode 100644 index 000000000..d9278bf4a --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/QueryStatsHistoryWindow.xaml @@ -0,0 +1,174 @@ +<Window x:Class="PerformanceMonitor.Darling.Viewer.QueryStatsHistoryWindow" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:scottplot="clr-namespace:ScottPlot.WPF;assembly=ScottPlot.WPF" + Title="Query Stats History" Width="1400" Height="800" + WindowStartupLocation="CenterOwner" + Icon="EDD.ico" + TextOptions.TextFormattingMode="Display" + UseLayoutRounding="True" + Background="{DynamicResource BackgroundLightBrush}" + Foreground="{DynamicResource ForegroundBrush}"> + <!-- Ported from Lite's Windows/QueryStatsHistoryWindow.xaml (the Top Queries per-row double-click + history window): the selected query's metric trend chart + a filterable grid of its per-collection + snapshots, delta columns and all. Data repointed to ViewerDataService Postgres reads; the one viewer + adaptation is the plan surface — Lite fetches the plan live (DuckDB cache then the monitored server), + the viewer shows/downloads the STORED plan (GetQueryStatsPlanXmlAsync), and Lite's live "Get Actual + Plan" is dropped. Dark-styled via the shared ViewerDarkTheme dictionary + a local NumericCell, + mirroring WaitDrillDownWindow; column filter buttons added at runtime by DataGridFilterColumns. --> + <Window.Resources> + <ResourceDictionary> + <ResourceDictionary.MergedDictionaries> + <ResourceDictionary Source="ViewerDarkTheme.xaml"/> + </ResourceDictionary.MergedDictionaries> + + <Style x:Key="NumericCell" TargetType="TextBlock"> + <Setter Property="TextAlignment" Value="Right"/> + </Style> + + <ContextMenu x:Key="DataGridContextMenu"> + <MenuItem Header="Copy Cell" Click="CopyCell_Click"> + <MenuItem.Icon><TextBlock Text="📋"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy Row" Click="CopyRow_Click"> + <MenuItem.Icon><TextBlock Text="📄"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy All Rows" Click="CopyAllRows_Click"> + <MenuItem.Icon><TextBlock Text="📑"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <MenuItem Header="Export to CSV..." Click="ExportToCsv_Click"> + <MenuItem.Icon><TextBlock Text="📊"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <MenuItem Header="View Stored Plan" Click="ViewPlan_Click"> + <MenuItem.Icon><TextBlock Text="🔍"/></MenuItem.Icon> + </MenuItem> + <!-- Live plan fetch for the RIGHT-CLICKED snapshot by its plan_handle (headless-safe cached read, + no re-exec) — distinct from the stored "View Plan". Gated on the current row's + HasLivePlanHandle so a snapshot without a plan_handle shows it disabled. --> + <MenuItem Header="View Cached Plan" Click="FetchLivePlan_Click" + IsEnabled="{Binding CurrentItem.HasLivePlanHandle, ElementName=HistoryDataGrid, FallbackValue=False}"> + <MenuItem.Icon><TextBlock Text="🌐"/></MenuItem.Icon> + </MenuItem> + <!-- Get Actual Plan (Dashboard parity): asks the SERVICE to RE-EXECUTE this query (identifier-only: + query_hash + database) and float the actual plan. Window-scoped to this query_hash, so always + enabled; the informed-consent + data-modification flag runs before it executes. --> + <MenuItem Header="Get Actual Plan (re-run)" Click="GetActualPlan_Click"> + <MenuItem.Icon><TextBlock Text="▶"/></MenuItem.Icon> + </MenuItem> + </ContextMenu> + </ResourceDictionary> + </Window.Resources> + + <Grid Margin="10"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="250"/> + <RowDefinition Height="*"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + + <!-- Header --> + <StackPanel Grid.Row="0" Margin="0,0,0,10"> + <TextBlock x:Name="QueryIdentifierText" FontSize="16" FontWeight="Bold"/> + <TextBlock x:Name="SummaryText" FontSize="12" Margin="0,4,0,0" Foreground="{DynamicResource ForegroundDimBrush}"/> + </StackPanel> + + <!-- Chart --> + <Border Grid.Row="1" Background="{DynamicResource BackgroundDarkBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="3"> + <Grid> + <ComboBox x:Name="MetricSelector" Width="180" Height="26" + VerticalAlignment="Top" HorizontalAlignment="Right" Margin="0,6,10,0" + SelectionChanged="MetricSelector_SelectionChanged"> + <ComboBoxItem Content="Avg CPU (ms)" Tag="AvgCpuMs" IsSelected="True"/> + <ComboBoxItem Content="Avg Duration (ms)" Tag="AvgElapsedMs"/> + <ComboBoxItem Content="Avg Logical Reads" Tag="AvgReads"/> + <ComboBoxItem Content="Execution Count Delta" Tag="DeltaExecutions"/> + <ComboBoxItem Content="CPU Delta (ms)" Tag="DeltaCpuMs"/> + <ComboBoxItem Content="Logical Reads Delta" Tag="DeltaLogicalReads"/> + <ComboBoxItem Content="Spills Delta" Tag="DeltaSpills"/> + </ComboBox> + <scottplot:WpfPlot x:Name="HistoryChart" Margin="0,0,0,0"/> + </Grid> + </Border> + + <!-- Data Grid --> + <DataGrid Grid.Row="2" x:Name="HistoryDataGrid" Margin="0,10,0,0" + Style="{StaticResource DarkGrid}" + AutoGenerateColumns="False" IsReadOnly="True" + CanUserSortColumns="True" CanUserReorderColumns="True" + HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" + ContextMenu="{StaticResource DataGridContextMenu}"> + <DataGrid.Columns> + <DataGridTextColumn Header="Collection Time" Binding="{Binding CollectionTimeLocal}" SortMemberPath="CollectionTime" SortDirection="Descending" Width="130"/> + <DataGridTextColumn Header="Last Execution" Binding="{Binding LastExecutionTimeLocal}" Width="130"/> + <DataGridTextColumn Header="Creation Time" Binding="{Binding CreationTimeLocal}" Width="130"/> + <DataGridTextColumn Header="Exec Delta" Binding="{Binding DeltaExecutions, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Total Executions" Binding="{Binding TotalExecutions, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="CPU Delta (ms)" Binding="{Binding DeltaCpuMs, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="Duration Delta (ms)" Binding="{Binding DeltaElapsedMs, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="130"/> + <DataGridTextColumn Header="Avg CPU (ms)" Binding="{Binding AvgCpuMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Avg Duration (ms)" Binding="{Binding AvgElapsedMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Total CPU (ms)" Binding="{Binding TotalCpuMs, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="Total Duration (ms)" Binding="{Binding TotalElapsedMs, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="130"/> + <DataGridTextColumn Header="Logical Reads" Binding="{Binding DeltaLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Avg Reads" Binding="{Binding AvgReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Total Logical Reads" Binding="{Binding TotalLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="125"/> + <DataGridTextColumn Header="Rows" Binding="{Binding DeltaRows, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="80"/> + <DataGridTextColumn Header="Avg Rows" Binding="{Binding AvgRows, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Total Rows" Binding="{Binding TotalRows, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="95"/> + <DataGridTextColumn Header="Writes" Binding="{Binding DeltaLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="80"/> + <DataGridTextColumn Header="Avg Writes" Binding="{Binding AvgWrites, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="95"/> + <DataGridTextColumn Header="Total Writes" Binding="{Binding TotalLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Physical Reads" Binding="{Binding DeltaPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Avg Phys Reads" Binding="{Binding AvgPhysicalReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="Total Phys Reads" Binding="{Binding TotalPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Spills" Binding="{Binding DeltaSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="70"/> + <DataGridTextColumn Header="Total Spills" Binding="{Binding TotalSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="95"/> + <DataGridTextColumn Header="Min CPU (ms)" Binding="{Binding MinCpuMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Max CPU (ms)" Binding="{Binding MaxCpuMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Min Duration (ms)" Binding="{Binding MinElapsedMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Max Duration (ms)" Binding="{Binding MaxElapsedMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Min DOP" Binding="{Binding MinDop}" ElementStyle="{StaticResource NumericCell}" Width="70"/> + <DataGridTextColumn Header="Max DOP" Binding="{Binding MaxDop}" ElementStyle="{StaticResource NumericCell}" Width="70"/> + <DataGridTextColumn Header="Min Phys Reads" Binding="{Binding MinPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="Max Phys Reads" Binding="{Binding MaxPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="Min Rows" Binding="{Binding MinRows, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="80"/> + <DataGridTextColumn Header="Max Rows" Binding="{Binding MaxRows, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="80"/> + <DataGridTextColumn Header="Min Spills" Binding="{Binding MinSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="80"/> + <DataGridTextColumn Header="Max Spills" Binding="{Binding MaxSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="80"/> + <DataGridTextColumn Header="Min Grant KB" Binding="{Binding MinGrantKb, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Max Grant KB" Binding="{Binding MaxGrantKb, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Min Used Grant KB" Binding="{Binding MinUsedGrantKb, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Max Used Grant KB" Binding="{Binding MaxUsedGrantKb, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Min Ideal Grant KB" Binding="{Binding MinIdealGrantKb, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Max Ideal Grant KB" Binding="{Binding MaxIdealGrantKb, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Min Reserved Threads" Binding="{Binding MinReservedThreads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="130"/> + <DataGridTextColumn Header="Max Reserved Threads" Binding="{Binding MaxReservedThreads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="130"/> + <DataGridTextColumn Header="Min Used Threads" Binding="{Binding MinUsedThreads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Max Used Threads" Binding="{Binding MaxUsedThreads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Total CLR (ms)" Binding="{Binding TotalClrMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="Interval (sec)" Binding="{Binding SampleIntervalSeconds}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="SQL Handle" Binding="{Binding SqlHandle}" Width="140"/> + <DataGridTextColumn Header="Plan Handle" Binding="{Binding PlanHandle}" Width="140"/> + <DataGridTextColumn Header="Query Hash" Binding="{Binding QueryHash}" Width="140"/> + <DataGridTextColumn Header="Plan Hash" Binding="{Binding QueryPlanHash}" Width="140"/> + <DataGridTemplateColumn Header="Plan" Width="Auto" MinWidth="80"> + <DataGridTemplateColumn.CellTemplate> + <DataTemplate> + <Button Content="Download" Click="DownloadPlan_Click" + HorizontalAlignment="Center" VerticalAlignment="Center" + Padding="6,2"/> + </DataTemplate> + </DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + </DataGrid.Columns> + </DataGrid> + + <!-- Footer --> + <StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0"> + <Button Content="Close" Padding="20,6" Click="Close_Click"/> + </StackPanel> + </Grid> +</Window> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/QueryStatsHistoryWindow.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/QueryStatsHistoryWindow.xaml.cs new file mode 100644 index 000000000..a450146f7 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/QueryStatsHistoryWindow.xaml.cs @@ -0,0 +1,404 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using Microsoft.Win32; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Top Queries per-row double-click history window — ported from Lite's +/// <c>Windows/QueryStatsHistoryWindow.xaml.cs</c>. Shows the selected query's (by query_hash) metric trend +/// over the window plus a filterable grid of its per-collection snapshots, with a metric selector driving the +/// chart. The reads come from <see cref="ViewerDataService.GetQueryStatsHistoryAsync"/> (Postgres). The one +/// viewer adaptation: Lite fetches the plan live (DuckDB cache then the monitored server) — the viewer never +/// opens a SqlClient, so "View Stored Plan" and the per-row Download button surface the STORED plan +/// (<see cref="ViewerDataService.GetQueryStatsPlanXmlAsync"/>); "Get Actual Plan (re-run)" asks the service to RE-EXECUTE the query for a runtime plan. +/// </summary> +public partial class QueryStatsHistoryWindow : Window +{ + private readonly ViewerDataService _dataService; + private readonly int _serverId; + private readonly string _databaseName; + private readonly string _queryHash; + private readonly string _queryText; + private readonly DateTime _startUtc; + private readonly DateTime _endUtc; + private List<ViewerQueryStatsHistoryRow> _historyData = new(); + private ChartHoverHelper? _chartHover; + private readonly DataGridFilterManager<ViewerQueryStatsHistoryRow> _filterManager; + private Popup? _filterPopup; + private ColumnFilterPopup? _filterPopupContent; + private System.Threading.CancellationTokenSource? _actualPlanCts; + + public QueryStatsHistoryWindow( + ViewerDataService dataService, int serverId, string databaseName, string queryHash, + string queryText, DateTime startUtc, DateTime endUtc) + { + InitializeComponent(); + _dataService = dataService; + _serverId = serverId; + _databaseName = databaseName; + _queryHash = queryHash; + _queryText = queryText; + _startUtc = startUtc; + _endUtc = endUtc; + + _filterManager = new DataGridFilterManager<ViewerQueryStatsHistoryRow>(HistoryDataGrid); + DataGridFilterColumns.AddFilterButtons(HistoryDataGrid, Filter_Click); + _filterManager.UpdateFilterButtonStyles(); + + QueryIdentifierText.Text = $"Query Stats History: {queryHash} in [{databaseName}]"; + Loaded += async (_, _) => await LoadHistoryAsync(); + ThemeManager.ThemeChanged += OnThemeChanged; + Closed += (_, _) => { ThemeManager.ThemeChanged -= OnThemeChanged; _actualPlanCts?.Cancel(); }; + } + + private async Task LoadHistoryAsync() + { + try + { + _historyData = await _dataService.GetQueryStatsHistoryAsync(_serverId, _databaseName, _queryHash, _startUtc, _endUtc); + _filterManager.UpdateData(_historyData); + ApplyDefaultDescendingByCollectionTime(); + + if (_historyData.Count > 0) + { + var totalExec = _historyData.Sum(r => r.DeltaExecutions); + var totalCpu = _historyData.Sum(r => r.DeltaCpuMs); + var first = ViewerTimeHelper.ForDisplay(_historyData.First().CollectionTime); + var last = ViewerTimeHelper.ForDisplay(_historyData.Last().CollectionTime); + SummaryText.Text = $"{_historyData.Count} samples from {first:MM/dd HH:mm} to {last:MM/dd HH:mm} | " + + $"Total Executions: {totalExec:N0} | Total CPU: {totalCpu:N1} ms"; + } + else + { + SummaryText.Text = "No history data found for this query in the selected time range."; + } + + UpdateChart(); + } + catch (Exception ex) + { + SummaryText.Text = $"Error loading history: {ex.Message}"; + } + } + + /// <summary> + /// Defaults the history grid to newest-first (Erik's descending-default grid convention). The SQL read stays + /// ascending on purpose — the chart plots <c>_historyData</c> in reader order and the summary reads + /// <c>First()</c>/<c>Last()</c> — so this reorders ONLY the grid's view, mirroring the parent Queries grids' + /// <c>SetDefaultSortIfNone(..., ListSortDirection.Descending)</c>. Guarded so a user's later sort survives. + /// </summary> + private void ApplyDefaultDescendingByCollectionTime() + { + if (HistoryDataGrid.Items.SortDescriptions.Count != 0) + return; + + HistoryDataGrid.Items.SortDescriptions.Add(new System.ComponentModel.SortDescription( + nameof(ViewerQueryStatsHistoryRow.CollectionTime), System.ComponentModel.ListSortDirection.Descending)); + } + + private void UpdateChart() + { + if (_historyData == null || _historyData.Count == 0) + { + HistoryChart.Plot.Clear(); + HistoryChart.Refresh(); + return; + } + + HistoryChart.Plot.Clear(); + + var selected = MetricSelector.SelectedItem as ComboBoxItem; + var tag = selected?.Tag?.ToString() ?? "AvgCpuMs"; + var label = selected?.Content?.ToString() ?? "Avg CPU (ms)"; + + var xs = _historyData.Select(r => ViewerTimeHelper.ForDisplay(r.CollectionTime).ToOADate()).ToArray(); + var ys = _historyData.Select(r => GetMetricValue(r, tag)).ToArray(); + + var scatter = HistoryChart.Plot.Add.Scatter(xs, ys); + scatter.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("MetricTrend")); + ChartStyle.StyleScatter(scatter); + scatter.LegendText = label; + + var unit = tag.Contains("Ms") ? "ms" : ""; + if (_chartHover == null) + _chartHover = new ChartHoverHelper(HistoryChart, unit); + else + _chartHover.Unit = unit; + _chartHover.Clear(); + _chartHover.Add(scatter, label); + + HistoryChart.Plot.Axes.DateTimeTicksBottom(); + ApplyTheme(HistoryChart); + + HistoryChart.Refresh(); + } + + /// <summary>Projects one history row to the metric the selector shows — the chart's Y value. Pure + + /// static for unit testing (mirrors Lite's GetMetricValue switch).</summary> + internal static double GetMetricValue(ViewerQueryStatsHistoryRow row, string tag) => tag switch + { + "AvgCpuMs" => row.AvgCpuMs, + "AvgElapsedMs" => row.AvgElapsedMs, + "AvgReads" => row.AvgReads, + "DeltaExecutions" => row.DeltaExecutions, + "DeltaCpuMs" => row.DeltaCpuMs, + "DeltaLogicalReads" => row.DeltaLogicalReads, + "DeltaSpills" => row.DeltaSpills, + _ => row.AvgCpuMs + }; + + private void MetricSelector_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (IsLoaded) UpdateChart(); } + + private static void ApplyTheme(ScottPlot.WPF.WpfPlot chart) => ChartStyle.ApplyMinimalChartTheme(chart); + + private void OnThemeChanged(string _) + { + ApplyTheme(HistoryChart); + HistoryChart.Refresh(); + _filterManager.UpdateFilterButtonStyles(); + } + + // ── Stored plan surface (viewer has no live server — Lite's live fetch becomes a stored read) ── + + private async void DownloadPlan_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button btn) return; + if (string.IsNullOrEmpty(_queryHash)) return; + + btn.IsEnabled = false; + btn.Content = "..."; + try + { + var plan = await _dataService.GetQueryStatsPlanXmlAsync(_serverId, _databaseName, _queryHash); + if (string.IsNullOrEmpty(plan)) + { + MessageBox.Show(this, "No query plan was captured in the collected data for this query hash.", + "Plan Not Found", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + var dialog = new SaveFileDialog + { + Filter = "SQL Plan files (*.sqlplan)|*.sqlplan|All files (*.*)|*.*", + DefaultExt = ".sqlplan", + FileName = $"query_plan_{_queryHash}_{DateTime.Now:yyyyMMdd_HHmmss}.sqlplan" + }; + + if (dialog.ShowDialog() != true) return; + File.WriteAllText(dialog.FileName, plan, Encoding.UTF8); + btn.Content = "Saved"; + return; + } + catch (Exception ex) + { + MessageBox.Show(this, $"Failed to retrieve plan: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + if (btn.Content is "...") + btn.Content = "Download"; + btn.IsEnabled = true; + } + } + + /// <summary>"View Stored Plan" — shows the row's stored estimated plan in a shared <see cref="PlanViewerControl"/> + /// floated above this window. "Get Actual Plan (re-run)" (a separate menu item) re-executes via the service.</summary> + private async void ViewPlan_Click(object sender, RoutedEventArgs e) + { + string? planXml; + try + { + planXml = await _dataService.GetQueryStatsPlanXmlAsync(_serverId, _databaseName, _queryHash); + } + catch (Exception ex) + { + MessageBox.Show(this, $"Failed to retrieve plan: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + return; + } + + if (string.IsNullOrEmpty(planXml)) + { + MessageBox.Show(this, "No query plan was captured in the collected data for this query hash.", + "No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + var label = $"Est Plan - {_queryHash}"; + var viewer = new PlanViewerControl(); + try + { + await viewer.LoadPlan(planXml, label, _queryText); + } + catch (Exception ex) + { + viewer.Cleanup(); + MessageBox.Show(this, $"Failed to load the execution plan:\n\n{ex.Message}", + "Plan Load Error", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + var window = GraphViewerWindow.ShowGraph(this, viewer, label); + window.Closed += (_, _) => viewer.Cleanup(); + } + + /// <summary>"Get Actual Plan" — asks the SERVICE to RE-EXECUTE this query (SET STATISTICS XML) and floats the + /// captured actual plan. Identifier-only: the service resolves the text from query_stats by (query_hash, + /// database). Shared consent + data-modification flag + read-only guard via <see cref="ViewerActualPlanFlow"/>.</summary> + private async void GetActualPlan_Click(object sender, RoutedEventArgs e) + { + if (string.IsNullOrEmpty(_queryHash)) return; + + string? estimatedPlanXml = null; + try { estimatedPlanXml = await _dataService.GetQueryStatsPlanXmlAsync(_serverId, _databaseName, _queryHash); } + catch { /* detection degrades to the fail-safe uncertain path */ } + + var argsJson = ViewerDataService.BuildActualPlanArgs(_queryHash, _databaseName); + var label = $"Actual Plan - {_queryHash}"; + + _actualPlanCts?.Dispose(); + _actualPlanCts = new System.Threading.CancellationTokenSource(); + + var planXml = await ViewerActualPlanFlow.RequestActualPlanAsync( + this, _dataService, _serverId, "the monitored server", _databaseName, _queryText, estimatedPlanXml, argsJson, + onStarted: () => System.Windows.Input.Mouse.OverrideCursor = System.Windows.Input.Cursors.Wait, + onFinished: () => System.Windows.Input.Mouse.OverrideCursor = null, + _actualPlanCts.Token); + + if (planXml != null) + await ViewerActualPlanFlow.OpenFloatingPlanAsync(this, planXml, label, _queryText); + } + + /// <summary>"View Cached Plan" — asks the service to read the RIGHT-CLICKED snapshot's plan from the target's + /// LIVE cache by its plan_handle (headless-safe: a cached read via the fetch_plan command, no re-execution), + /// mirroring the parent grid's FetchLiveQueryPlan_Click. Distinct from "View Plan" (the stored plan XML). + /// Gated per row on HasLivePlanHandle; the same plan_handle-bearing row gets it on the parent Top Queries grid.</summary> + private async void FetchLivePlan_Click(object sender, RoutedEventArgs e) + { + if (HistoryDataGrid.CurrentItem is not ViewerQueryStatsHistoryRow row || string.IsNullOrEmpty(row.PlanHandle)) + return; + + var label = $"Cached Plan - {_queryHash}"; + var argsJson = ViewerDataService.BuildPlanFetchArgsByPlanHandle(row.PlanHandle, _databaseName); + await FetchAndShowLivePlanAsync(argsJson, label, _queryText); + } + + /// <summary>Runs a fetch_plan round-trip and shows the plan in a shared PlanViewerControl floated above this + /// window (a read-only seat / not-in-cache / failure surfaces the service's message). Mirrors + /// ViewerServerTab.FetchAndOpenLivePlanAsync, adapted to this window's GraphViewerWindow plan host.</summary> + private async Task FetchAndShowLivePlanAsync(string argsJson, string label, string? queryText) + { + LivePlanFetchResult result; + try + { + result = await _dataService.FetchLivePlanAsync(_serverId, argsJson); + } + catch (ViewerReadOnlyException) + { + MessageBox.Show(this, "This is a read-only viewer seat, which cannot ask the service to fetch a live plan.", + "Read-Only", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + catch (Exception ex) + { + MessageBox.Show(this, $"Failed to fetch the live plan: {ex.Message}", "Cached Plan Error", + MessageBoxButton.OK, MessageBoxImage.Error); + return; + } + + if (result.Status != LivePlanFetchStatus.Fetched || string.IsNullOrEmpty(result.PlanXml)) + { + MessageBox.Show(this, result.Message ?? "The live plan could not be fetched.", + "Cached Plan", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + var viewer = new PlanViewerControl(); + try + { + await viewer.LoadPlan(result.PlanXml, label, queryText); + } + catch (Exception ex) + { + viewer.Cleanup(); + MessageBox.Show(this, $"Failed to load the execution plan:\n\n{ex.Message}", + "Plan Load Error", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + var window = GraphViewerWindow.ShowGraph(this, viewer, label); + window.Closed += (_, _) => viewer.Cleanup(); + } + + // ── Column Filter Popup (mirrors WaitDrillDownWindow / ViewerServerTab.Filters.cs) ── + + private void EnsureFilterPopup() + { + if (_filterPopup == null) + { + _filterPopupContent = new ColumnFilterPopup(); + _filterPopup = new Popup + { + Child = _filterPopupContent, + StaysOpen = false, + Placement = PlacementMode.Bottom, + AllowsTransparency = true + }; + } + } + + private void Filter_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button button || button.Tag is not string columnName) return; + + EnsureFilterPopup(); + + _filterPopupContent!.FilterApplied -= FilterPopup_FilterApplied; + _filterPopupContent.FilterCleared -= FilterPopup_FilterCleared; + _filterPopupContent.FilterApplied += FilterPopup_FilterApplied; + _filterPopupContent.FilterCleared += FilterPopup_FilterCleared; + + _filterManager.Filters.TryGetValue(columnName, out var existingFilter); + _filterPopupContent.Initialize(columnName, existingFilter); + + _filterPopup!.PlacementTarget = button; + _filterPopup.IsOpen = true; + } + + private void FilterPopup_FilterApplied(object? sender, FilterAppliedEventArgs e) + { + if (_filterPopup != null) _filterPopup.IsOpen = false; + _filterManager.SetFilter(e.FilterState); + } + + private void FilterPopup_FilterCleared(object? sender, EventArgs e) + { + if (_filterPopup != null) _filterPopup.IsOpen = false; + } + + // ── Copy / Export / Close ── + + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => DataGridExport.ExportToCsv(sender, "query_stats_history", ViewerExportSettings.CsvSeparator); + + private void Close_Click(object sender, RoutedEventArgs e) => Close(); +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/QueryStoreHistoryWindow.xaml b/Darling/PerformanceMonitor.Darling.Viewer/QueryStoreHistoryWindow.xaml new file mode 100644 index 000000000..d1cc20461 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/QueryStoreHistoryWindow.xaml @@ -0,0 +1,164 @@ +<Window x:Class="PerformanceMonitor.Darling.Viewer.QueryStoreHistoryWindow" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:scottplot="clr-namespace:ScottPlot.WPF;assembly=ScottPlot.WPF" + Title="Query Store History" Width="1200" Height="800" + WindowStartupLocation="CenterOwner" + Icon="EDD.ico" + TextOptions.TextFormattingMode="Display" + UseLayoutRounding="True" + Background="{DynamicResource BackgroundLightBrush}" + Foreground="{DynamicResource ForegroundBrush}"> + <!-- Ported from Lite's Windows/QueryStoreHistoryWindow.xaml (the Query Store per-row double-click history + window): the selected query's per-plan metric trend over the window (one chart series per plan_id, so + plan switches / regressions are visible) plus a filterable grid of its per-collection snapshots. Data + repointed to ViewerDataService Postgres reads; "View Plan" / the per-row Download button surface the + STORED Query Store plan (GetQueryStorePlanTextAsync) and Lite's live "Get Actual Plan" is dropped. --> + <Window.Resources> + <ResourceDictionary> + <ResourceDictionary.MergedDictionaries> + <ResourceDictionary Source="ViewerDarkTheme.xaml"/> + </ResourceDictionary.MergedDictionaries> + + <Style x:Key="NumericCell" TargetType="TextBlock"> + <Setter Property="TextAlignment" Value="Right"/> + </Style> + + <ContextMenu x:Key="DataGridContextMenu"> + <MenuItem Header="Copy Cell" Click="CopyCell_Click"> + <MenuItem.Icon><TextBlock Text="📋"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy Row" Click="CopyRow_Click"> + <MenuItem.Icon><TextBlock Text="📄"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy All Rows" Click="CopyAllRows_Click"> + <MenuItem.Icon><TextBlock Text="📑"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <MenuItem Header="Export to CSV..." Click="ExportToCsv_Click"> + <MenuItem.Icon><TextBlock Text="📊"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <MenuItem Header="View Stored Plan" Click="ViewPlan_Click"> + <MenuItem.Icon><TextBlock Text="🔍"/></MenuItem.Icon> + </MenuItem> + <!-- Get Actual Plan (Dashboard parity): asks the SERVICE to RE-EXECUTE this Query Store query + (identifier-only: query_id + database) and float the actual plan. The informed-consent + + data-modification flag runs before it executes. --> + <MenuItem Header="Get Actual Plan (re-run)" Click="GetActualPlan_Click"> + <MenuItem.Icon><TextBlock Text="▶"/></MenuItem.Icon> + </MenuItem> + </ContextMenu> + </ResourceDictionary> + </Window.Resources> + + <Grid Margin="10"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="250"/> + <RowDefinition Height="*"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + + <!-- Header --> + <StackPanel Grid.Row="0" Margin="0,0,0,10"> + <TextBlock x:Name="QueryIdentifierText" FontSize="16" FontWeight="Bold"/> + <TextBlock x:Name="SummaryText" FontSize="12" Margin="0,4,0,0" Foreground="{DynamicResource ForegroundDimBrush}"/> + </StackPanel> + + <!-- Chart --> + <Border Grid.Row="1" Background="{DynamicResource BackgroundDarkBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="3"> + <Grid> + <ComboBox x:Name="MetricSelector" Width="180" Height="26" + VerticalAlignment="Top" HorizontalAlignment="Right" Margin="0,6,10,0" + SelectionChanged="MetricSelector_SelectionChanged"> + <ComboBoxItem Content="Avg CPU (ms)" Tag="AvgCpuTimeMs" IsSelected="True"/> + <ComboBoxItem Content="Avg Duration (ms)" Tag="AvgDurationMs"/> + <ComboBoxItem Content="Avg Logical Reads" Tag="AvgLogicalReads"/> + <ComboBoxItem Content="Avg Rows" Tag="AvgRowcount"/> + <ComboBoxItem Content="Execution Count" Tag="ExecutionCount"/> + <ComboBoxItem Content="Total CPU (ms)" Tag="TotalCpuMs"/> + <ComboBoxItem Content="Total Duration (ms)" Tag="TotalDurationMs"/> + </ComboBox> + <scottplot:WpfPlot x:Name="HistoryChart" Margin="0,0,0,0"/> + </Grid> + </Border> + + <!-- Data Grid --> + <DataGrid Grid.Row="2" x:Name="HistoryDataGrid" Margin="0,10,0,0" + Style="{StaticResource DarkGrid}" + AutoGenerateColumns="False" IsReadOnly="True" + CanUserSortColumns="True" CanUserReorderColumns="True" + HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" + ContextMenu="{StaticResource DataGridContextMenu}"> + <DataGrid.Columns> + <DataGridTextColumn Header="Collection Time" Binding="{Binding CollectionTimeLocal}" SortMemberPath="CollectionTime" SortDirection="Descending" Width="130"/> + <DataGridTextColumn Header="Plan ID" Binding="{Binding PlanId}" ElementStyle="{StaticResource NumericCell}" Width="70"/> + <DataGridTextColumn Header="Exec Type" Binding="{Binding ExecutionTypeDesc}" Width="90"/> + <DataGridTextColumn Header="First Execution" Binding="{Binding FirstExecutionTimeLocal}" Width="130"/> + <DataGridTextColumn Header="Last Execution" Binding="{Binding LastExecutionTimeLocal}" Width="130"/> + <DataGridTextColumn Header="Module" Binding="{Binding ModuleName}" Width="160"/> + <DataGridTextColumn Header="Executions" Binding="{Binding ExecutionCount, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Total Duration (ms)" Binding="{Binding TotalDurationMs, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="130"/> + <DataGridTextColumn Header="Avg Duration (ms)" Binding="{Binding AvgDurationMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Min Duration (ms)" Binding="{Binding MinDurationMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Max Duration (ms)" Binding="{Binding MaxDurationMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Total CPU (ms)" Binding="{Binding TotalCpuMs, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="Avg CPU (ms)" Binding="{Binding AvgCpuTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Min CPU (ms)" Binding="{Binding MinCpuTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Max CPU (ms)" Binding="{Binding MaxCpuTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Avg CLR (ms)" Binding="{Binding AvgClrTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Min CLR (ms)" Binding="{Binding MinClrTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Max CLR (ms)" Binding="{Binding MaxClrTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Avg Reads" Binding="{Binding AvgLogicalReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Min Reads" Binding="{Binding MinLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Max Reads" Binding="{Binding MaxLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Avg Writes" Binding="{Binding AvgLogicalWrites, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Min Writes" Binding="{Binding MinLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Max Writes" Binding="{Binding MaxLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Avg Log (MB)" Binding="{Binding AvgLogMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Min Log (MB)" Binding="{Binding MinLogMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Max Log (MB)" Binding="{Binding MaxLogMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Avg Physical Reads" Binding="{Binding AvgPhysicalReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Min Physical Reads" Binding="{Binding MinPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Max Physical Reads" Binding="{Binding MaxPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Avg Num Physical Reads" Binding="{Binding AvgNumPhysicalReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="150"/> + <DataGridTextColumn Header="Min Num Physical Reads" Binding="{Binding MinNumPhysicalReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="150"/> + <DataGridTextColumn Header="Max Num Physical Reads" Binding="{Binding MaxNumPhysicalReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="150"/> + <DataGridTextColumn Header="Avg Rows" Binding="{Binding AvgRowcount, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Min Rows" Binding="{Binding MinRowcount, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="85"/> + <DataGridTextColumn Header="Max Rows" Binding="{Binding MaxRowcount, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="85"/> + <DataGridTextColumn Header="Avg Memory (MB)" Binding="{Binding AvgMemoryMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Min Memory (MB)" Binding="{Binding MinMemoryMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Max Memory (MB)" Binding="{Binding MaxMemoryMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Avg tempdb (MB)" Binding="{Binding AvgTempdbMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Min tempdb (MB)" Binding="{Binding MinTempdbMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Max tempdb (MB)" Binding="{Binding MaxTempdbMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Min DOP" Binding="{Binding MinDop}" ElementStyle="{StaticResource NumericCell}" Width="70"/> + <DataGridTextColumn Header="Max DOP" Binding="{Binding MaxDop}" ElementStyle="{StaticResource NumericCell}" Width="70"/> + <DataGridTextColumn Header="Plan Type" Binding="{Binding PlanType}" Width="100"/> + <DataGridCheckBoxColumn Header="Forced" Binding="{Binding IsForcedPlan}" Width="60"/> + <DataGridTextColumn Header="Force Failures" Binding="{Binding ForceFailureCount, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Last Failure Reason" Binding="{Binding LastForceFailureReason}" Width="150"/> + <DataGridTextColumn Header="Forcing Type" Binding="{Binding PlanForcingType}" Width="100"/> + <DataGridTextColumn Header="Compat Level" Binding="{Binding CompatibilityLevel}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Query Hash" Binding="{Binding QueryHash}" Width="140"/> + <DataGridTextColumn Header="Query Plan Hash" Binding="{Binding QueryPlanHash}" Width="140"/> + <DataGridTemplateColumn Header="Plan" Width="Auto" MinWidth="80"> + <DataGridTemplateColumn.CellTemplate> + <DataTemplate> + <Button Content="Download" Click="DownloadPlan_Click" + HorizontalAlignment="Center" VerticalAlignment="Center" + Padding="6,2"/> + </DataTemplate> + </DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + </DataGrid.Columns> + </DataGrid> + + <!-- Footer --> + <StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0"> + <Button Content="Close" Padding="20,6" Click="Close_Click"/> + </StackPanel> + </Grid> +</Window> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/QueryStoreHistoryWindow.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/QueryStoreHistoryWindow.xaml.cs new file mode 100644 index 000000000..40a31de0b --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/QueryStoreHistoryWindow.xaml.cs @@ -0,0 +1,378 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using Microsoft.Win32; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Query Store per-row double-click history window — ported from Lite's +/// <c>Windows/QueryStoreHistoryWindow.xaml.cs</c>. Shows the selected query's (by query_id, across every plan) +/// metric trend over the window — one chart series per plan_id, so plan switches / regressions are visible — +/// plus a filterable grid of its per-collection snapshots. Reads come from +/// <see cref="ViewerDataService.GetQueryStoreHistoryAsync"/> (Postgres, query-scoped). "View Plan" and the +/// per-row Download button surface the STORED Query Store plan for that row's plan_id +/// (<see cref="ViewerDataService.GetQueryStorePlanTextAsync"/>); "Get Actual Plan (re-run)" asks the service to RE-EXECUTE by QueryId for a runtime plan. +/// </summary> +public partial class QueryStoreHistoryWindow : Window +{ + private readonly ViewerDataService _dataService; + private readonly int _serverId; + private readonly string _databaseName; + private readonly long _queryId; + private readonly long _planId; + private readonly string _queryText; + private readonly DateTime _startUtc; + private readonly DateTime _endUtc; + private List<ViewerQueryStoreHistoryRow> _historyData = new(); + private ChartHoverHelper? _chartHover; + private ScottPlot.IPanel? _legendPanel; + private readonly DataGridFilterManager<ViewerQueryStoreHistoryRow> _filterManager; + private Popup? _filterPopup; + private ColumnFilterPopup? _filterPopupContent; + private System.Threading.CancellationTokenSource? _actualPlanCts; + + public QueryStoreHistoryWindow( + ViewerDataService dataService, int serverId, string databaseName, long queryId, long planId, + string queryText, DateTime startUtc, DateTime endUtc) + { + InitializeComponent(); + _dataService = dataService; + _serverId = serverId; + _databaseName = databaseName; + _queryId = queryId; + _planId = planId; + _queryText = queryText; + _startUtc = startUtc; + _endUtc = endUtc; + + _filterManager = new DataGridFilterManager<ViewerQueryStoreHistoryRow>(HistoryDataGrid); + DataGridFilterColumns.AddFilterButtons(HistoryDataGrid, Filter_Click); + _filterManager.UpdateFilterButtonStyles(); + + var displayText = queryText.Length > 120 ? queryText[..120] + "..." : queryText; + QueryIdentifierText.Text = $"Query Store History: Query {queryId} in [{databaseName}]"; + SummaryText.Text = displayText; + Loaded += async (_, _) => await LoadHistoryAsync(); + ThemeManager.ThemeChanged += OnThemeChanged; + Closed += (_, _) => { ThemeManager.ThemeChanged -= OnThemeChanged; _actualPlanCts?.Cancel(); }; + } + + private async Task LoadHistoryAsync() + { + try + { + _historyData = await _dataService.GetQueryStoreHistoryAsync(_serverId, _databaseName, _queryId, _startUtc, _endUtc); + _filterManager.UpdateData(_historyData); + ApplyDefaultDescendingByCollectionTime(); + + if (_historyData.Count > 0) + { + var totalExec = _historyData.Sum(r => r.ExecutionCount); + var planCount = _historyData.Select(r => r.PlanId).Distinct().Count(); + var first = ViewerTimeHelper.ForDisplay(_historyData.First().CollectionTime); + var last = ViewerTimeHelper.ForDisplay(_historyData.Last().CollectionTime); + SummaryText.Text = $"{_historyData.Count} samples from {first:MM/dd HH:mm} to {last:MM/dd HH:mm} | " + + $"Total Executions: {totalExec:N0} | " + + (planCount > 1 ? $"{planCount} different plans" : "Single plan"); + } + else + { + SummaryText.Text = "No history data found for this query in the selected time range."; + } + + UpdateChart(); + } + catch (Exception ex) + { + SummaryText.Text = $"Error loading history: {ex.Message}"; + } + } + + /// <summary> + /// Defaults the history grid to newest-first (Erik's descending-default grid convention). The SQL read stays + /// ascending on purpose — the chart re-sorts per plan group and the summary reads <c>First()</c>/<c>Last()</c> + /// — so this reorders ONLY the grid's view, mirroring the parent Queries grids' + /// <c>SetDefaultSortIfNone(..., ListSortDirection.Descending)</c>. Guarded so a user's later sort survives. + /// </summary> + private void ApplyDefaultDescendingByCollectionTime() + { + if (HistoryDataGrid.Items.SortDescriptions.Count != 0) + return; + + HistoryDataGrid.Items.SortDescriptions.Add(new System.ComponentModel.SortDescription( + nameof(ViewerQueryStoreHistoryRow.CollectionTime), System.ComponentModel.ListSortDirection.Descending)); + } + + private void UpdateChart() + { + if (_historyData == null || _historyData.Count == 0) + { + HistoryChart.Plot.Clear(); + HistoryChart.Refresh(); + return; + } + + if (_legendPanel != null) + { + HistoryChart.Plot.Axes.Remove(_legendPanel); + _legendPanel = null; + } + HistoryChart.Plot.Clear(); + + var selected = MetricSelector.SelectedItem as ComboBoxItem; + var tag = selected?.Tag?.ToString() ?? "AvgCpuTimeMs"; + var label = selected?.Content?.ToString() ?? "Avg CPU (ms)"; + + var unit = tag.Contains("Ms") ? "ms" : ""; + if (_chartHover == null) + _chartHover = new ChartHoverHelper(HistoryChart, unit); + else + _chartHover.Unit = unit; + _chartHover.Clear(); + + // One series per plan so plan switches/regressions are visible — same as Lite / the Dashboard drilldown. + var planGroups = _historyData.GroupBy(r => r.PlanId).OrderBy(g => g.Key).ToList(); + var colors = ChartPalette.CyclingPalette.Select(ScottPlot.Color.FromHex).ToArray(); + + int colorIndex = 0; + foreach (var planGroup in planGroups) + { + var ordered = planGroup.OrderBy(r => r.CollectionTime).ToList(); + var xs = ordered.Select(r => ViewerTimeHelper.ForDisplay(r.CollectionTime).ToOADate()).ToArray(); + var ys = ordered.Select(r => GetMetricValue(r, tag)).ToArray(); + + var scatter = HistoryChart.Plot.Add.Scatter(xs, ys); + scatter.Color = colors[colorIndex % colors.Length]; + ChartStyle.StyleScatter(scatter); + var seriesLabel = planGroups.Count > 1 ? $"Plan {planGroup.Key}" : label; + scatter.LegendText = seriesLabel; + _chartHover.Add(scatter, seriesLabel); + colorIndex++; + } + + HistoryChart.Plot.Axes.DateTimeTicksBottom(); + if (planGroups.Count > 1) + { + _legendPanel = HistoryChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); + HistoryChart.Plot.Legend.FontSize = 12; + } + ApplyTheme(HistoryChart); + + HistoryChart.Refresh(); + } + + /// <summary>Projects one history row to the metric the selector shows — the chart's Y value. Pure + + /// static for unit testing (mirrors Lite's GetMetricValue switch).</summary> + internal static double GetMetricValue(ViewerQueryStoreHistoryRow row, string tag) => tag switch + { + "AvgCpuTimeMs" => row.AvgCpuTimeMs, + "AvgDurationMs" => row.AvgDurationMs, + "AvgLogicalReads" => row.AvgLogicalReads, + "AvgRowcount" => row.AvgRowcount, + "ExecutionCount" => row.ExecutionCount, + "TotalCpuMs" => row.TotalCpuMs, + "TotalDurationMs" => row.TotalDurationMs, + _ => row.AvgCpuTimeMs + }; + + private void MetricSelector_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (IsLoaded) UpdateChart(); } + + private static void ApplyTheme(ScottPlot.WPF.WpfPlot chart) => ChartStyle.ApplyMinimalChartTheme(chart); + + private void OnThemeChanged(string _) + { + ApplyTheme(HistoryChart); + HistoryChart.Refresh(); + _filterManager.UpdateFilterButtonStyles(); + } + + // ── Stored plan surface (viewer has no live server — Lite's live fetch becomes a stored read) ── + + private async void DownloadPlan_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button btn) return; + // Rows can span multiple plans — download the plan for THIS row, not the launching one. + var rowPlanId = (btn.DataContext as ViewerQueryStoreHistoryRow)?.PlanId ?? _planId; + if (string.IsNullOrEmpty(_databaseName) || rowPlanId == 0) return; + + btn.IsEnabled = false; + btn.Content = "..."; + try + { + var plan = await _dataService.GetQueryStorePlanTextAsync(_serverId, _databaseName, _queryId, rowPlanId); + if (string.IsNullOrEmpty(plan)) + { + MessageBox.Show(this, "No Query Store plan was captured in the collected data for this plan ID.", + "Plan Not Found", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + var dialog = new SaveFileDialog + { + Filter = "SQL Plan files (*.sqlplan)|*.sqlplan|All files (*.*)|*.*", + DefaultExt = ".sqlplan", + FileName = $"qs_plan_{_queryId}_{rowPlanId}_{DateTime.Now:yyyyMMdd_HHmmss}.sqlplan" + }; + + if (dialog.ShowDialog() != true) return; + File.WriteAllText(dialog.FileName, plan, Encoding.UTF8); + btn.Content = "Saved"; + return; + } + catch (Exception ex) + { + MessageBox.Show(this, $"Failed to retrieve plan: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + if (btn.Content is "...") + btn.Content = "Download"; + btn.IsEnabled = true; + } + } + + private long SelectedPlanId => + ((HistoryDataGrid.CurrentItem ?? HistoryDataGrid.SelectedItem) as ViewerQueryStoreHistoryRow)?.PlanId ?? _planId; + + /// <summary>"View Plan" — shows the selected row's stored Query Store plan in a shared + /// <see cref="PlanViewerControl"/> floated above this window. No live "Get Actual Plan".</summary> + private async void ViewPlan_Click(object sender, RoutedEventArgs e) + { + var planId = SelectedPlanId; + string? planXml; + try + { + planXml = await _dataService.GetQueryStorePlanTextAsync(_serverId, _databaseName, _queryId, planId); + } + catch (Exception ex) + { + MessageBox.Show(this, $"Failed to retrieve plan: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + return; + } + + if (string.IsNullOrEmpty(planXml)) + { + MessageBox.Show(this, "No Query Store plan was captured in the collected data for this plan ID.", + "No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + var label = $"Est Plan - QS {_queryId}/{planId}"; + var viewer = new PlanViewerControl(); + try + { + await viewer.LoadPlan(planXml, label, _queryText); + } + catch (Exception ex) + { + viewer.Cleanup(); + MessageBox.Show(this, $"Failed to load the execution plan:\n\n{ex.Message}", + "Plan Load Error", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + var window = GraphViewerWindow.ShowGraph(this, viewer, label); + window.Closed += (_, _) => viewer.Cleanup(); + } + + /// <summary>"Get Actual Plan" — asks the SERVICE to RE-EXECUTE this Query Store query (SET STATISTICS XML) and + /// floats the captured actual plan. Identifier-only: the service resolves the text from query_store_stats by + /// (query_id, database). Shared consent + data-modification flag + read-only guard via + /// <see cref="ViewerActualPlanFlow"/>.</summary> + private async void GetActualPlan_Click(object sender, RoutedEventArgs e) + { + if (_queryId == 0) return; + + var planId = SelectedPlanId; + string? estimatedPlanXml = null; + try { estimatedPlanXml = await _dataService.GetQueryStorePlanTextAsync(_serverId, _databaseName, _queryId, planId); } + catch { /* detection degrades to the fail-safe uncertain path */ } + + var argsJson = ViewerDataService.BuildActualPlanArgsForQueryStore(_queryId, _databaseName); + var label = $"Actual Plan - QS {_queryId}/{planId}"; + + _actualPlanCts?.Dispose(); + _actualPlanCts = new System.Threading.CancellationTokenSource(); + + var planXml = await ViewerActualPlanFlow.RequestActualPlanAsync( + this, _dataService, _serverId, "the monitored server", _databaseName, _queryText, estimatedPlanXml, argsJson, + onStarted: () => System.Windows.Input.Mouse.OverrideCursor = System.Windows.Input.Cursors.Wait, + onFinished: () => System.Windows.Input.Mouse.OverrideCursor = null, + _actualPlanCts.Token); + + if (planXml != null) + await ViewerActualPlanFlow.OpenFloatingPlanAsync(this, planXml, label, _queryText); + } + + // ── Column Filter Popup (mirrors WaitDrillDownWindow / ViewerServerTab.Filters.cs) ── + + private void EnsureFilterPopup() + { + if (_filterPopup == null) + { + _filterPopupContent = new ColumnFilterPopup(); + _filterPopup = new Popup + { + Child = _filterPopupContent, + StaysOpen = false, + Placement = PlacementMode.Bottom, + AllowsTransparency = true + }; + } + } + + private void Filter_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button button || button.Tag is not string columnName) return; + + EnsureFilterPopup(); + + _filterPopupContent!.FilterApplied -= FilterPopup_FilterApplied; + _filterPopupContent.FilterCleared -= FilterPopup_FilterCleared; + _filterPopupContent.FilterApplied += FilterPopup_FilterApplied; + _filterPopupContent.FilterCleared += FilterPopup_FilterCleared; + + _filterManager.Filters.TryGetValue(columnName, out var existingFilter); + _filterPopupContent.Initialize(columnName, existingFilter); + + _filterPopup!.PlacementTarget = button; + _filterPopup.IsOpen = true; + } + + private void FilterPopup_FilterApplied(object? sender, FilterAppliedEventArgs e) + { + if (_filterPopup != null) _filterPopup.IsOpen = false; + _filterManager.SetFilter(e.FilterState); + } + + private void FilterPopup_FilterCleared(object? sender, EventArgs e) + { + if (_filterPopup != null) _filterPopup.IsOpen = false; + } + + // ── Copy / Export / Close ── + + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => DataGridExport.ExportToCsv(sender, "query_store_history", ViewerExportSettings.CsvSeparator); + + private void Close_Click(object sender, RoutedEventArgs e) => Close(); +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/RecommendationsViewModel.cs b/Darling/PerformanceMonitor.Darling.Viewer/RecommendationsViewModel.cs new file mode 100644 index 000000000..5a194d85f --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/RecommendationsViewModel.cs @@ -0,0 +1,652 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using PerformanceMonitor.Analysis; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// Canonical three-band severity for the viewer's Recommendations surface — the viewer-local +/// mirror of Lite's <c>LiteRecommendationSeverity</c> (the viewer reads only the engine producer; +/// there is no legacy critical_issues store here). The engine scores findings on a +/// <c>double</c> (0-~2.0); that scale maps onto this enum so one list sorts and renders +/// consistently. The ordinals are deliberately Critical > Warning > Info so a descending +/// enum sort matches a descending severity sort. +/// </summary> +public enum RecommendationSeverity +{ + Info = 0, + Warning = 1, + Critical = 2 +} + +/// <summary> +/// Which top-level state the Recommendations surface is in. The tab swaps a single visible region +/// per state. Mirrors Lite's <c>LiteRecommendationsState</c>, including <see cref="InsufficientData"/>: +/// Lite reaches that state through its in-app engine call (which reads back the insufficient-data +/// determination), whereas the Darling service runs analysis on its own cadence and PERSISTS the +/// determination to the V19 <c>analysis_state</c> marker (<c>DarlingObservability.WriteAnalysisStateAsync</c>). +/// The viewer reads that marker alongside the findings, so a zero-finding read on a young deployment +/// (the engine skipped for want of its 24h history) renders <see cref="InsufficientData"/> — "still +/// collecting" — instead of a false <see cref="Empty"/> all-clear. +/// </summary> +public enum RecommendationsState +{ + /// <summary>A read is in flight.</summary> + Loading, + + /// <summary> + /// The read produced zero recommendations AND the persisted analysis-state marker says the engine + /// has not yet cleared its minimum-history window (the 24h data-span gate) — recommendations are not + /// meaningful yet, so "still collecting" is shown rather than a false all-clear. + /// </summary> + InsufficientData, + + /// <summary>The read completed and produced zero recommendations — the all-clear.</summary> + Empty, + + /// <summary>The read completed with one or more recommendations to render.</summary> + Loaded +} + +/// <summary> +/// A single advise-only recommendation row — the viewer-local mirror of Lite's +/// <see cref="PerformanceMonitorLite.Analysis.Recommendations.LiteRecommendationItem"/> shape +/// (referenced here only in prose; the viewer does not depend on the Lite assembly). A plain DTO +/// (no WPF dependency) built from a persisted <see cref="AnalysisFinding"/> via a +/// <see cref="ViewerFindingRow"/>. +/// +/// <para> +/// The viewer is ADVISE-ONLY (Postgres reads only; SQL-side remediation is Dashboard-only per +/// project scope). A card offers the diagnosis, copy-paste T-SQL when the persisted remediation +/// action carries one, and an "Ask AI" MCP prompt — never an in-app Apply, and (mirroring the +/// re-skin brief) no mute affordance (mute lives on the Alert History surface). +/// </para> +/// </summary> +public sealed class RecommendationItem +{ + /// <summary>The three-band severity used for sorting and the badge glyph/colour.</summary> + public RecommendationSeverity Severity { get; init; } + + /// <summary>The raw engine severity (0-~2.0), the secondary sort within a band.</summary> + public double RawSeverity { get; init; } + + /// <summary>The affected database, or null for a server-scoped finding.</summary> + public string? Database { get; init; } + + /// <summary>The one-line card heading.</summary> + public string Title { get; init; } = string.Empty; + + /// <summary> + /// Operator-facing advice prose, composed from <see cref="FactAdvice.GetComposedForFinding"/> + /// (remediation line + investigation line). Settable so the co-fired cross-reference can be + /// appended after mapping (mirrors Lite's reader). + /// </summary> + public string? AdviceText { get; set; } + + /// <summary> + /// Copy-paste-ready T-SQL derived from the finding's PERSISTED + /// <see cref="AnalysisFinding.Remediation"/> action — the same read-back source the Dashboard's + /// RecommendationsReader uses (BuildCopyPasteFromAction). Null when the action carries no + /// copy-paste target. NOTE: the viewer reads persisted findings, whose ephemeral drill-down is + /// NOT stored, so <see cref="FactRemediation.GenerateForFinding"/> (Lite's live-path source) + /// returns null here; the persisted action is the authoritative copy-paste source on read. + /// </summary> + public string? CopyPasteSql { get; init; } + + /// <summary> + /// The finding's PERSISTED remediation action (or null) — the same object <see cref="CopyPasteSql"/> + /// is derived from. Carried so the card can reconstruct the incident-vs-config-fix distinction the + /// Dashboard draws from its RecommendationSetting taxonomy (<c>RecommendationsViewModel.IsIncident</c>). + /// The viewer reads only engine findings (there is no legacy critical_issues RecommendationSetting + /// store here), so a structured standing-config action — DB_CONFIG / RCSI / FILE_AUTOGROWTH_PERCENT / + /// SERVER_CONFIG — is the only signal that a finding is a config fix rather than a time-bound incident, + /// and it gates whether the "Open in Active Queries" deep-link is offered. + /// </summary> + public RemediationAction? Remediation { get; init; } + + /// <summary> + /// The finding's incident id — the group key the surface collapses cards under so related + /// findings render as one report. Empty for findings analyzed before incident_id existed; the + /// view-model treats an empty id as a standalone single-card incident. + /// </summary> + public string IncidentId { get; init; } = string.Empty; + + /// <summary>The monitored server's display name (for the Ask-AI prompt).</summary> + public string ServerName { get; init; } = string.Empty; + + /// <summary> + /// The finding's monitored-server id (<see cref="AnalysisFinding.ServerId"/>) — drives which + /// per-server tab the "Open in Active Queries" deep-link opens. A real server id is never 0, so the + /// deep-link predicate treats 0 as "no server anchor". + /// </summary> + public int ServerId { get; init; } + + /// <summary>UTC start of the finding's time window (analysis TimeRangeStart), or null.</summary> + public DateTime? WindowStartUtc { get; init; } + + /// <summary>UTC end of the finding's time window (analysis TimeRangeEnd), or null.</summary> + public DateTime? WindowEndUtc { get; init; } +} + +/// <summary> +/// A single recommendation rendered as a card. A plain DTO wrapping a <see cref="RecommendationItem"/> +/// plus the pre-computed display/visibility flags the XAML binds to, so the affordance model and the +/// Ask-AI prompt are unit-testable. Mirrors Lite's <c>LiteRecommendationCardViewModel</c>: ADVISE-ONLY +/// (no Apply, no mute); every card offers "Ask AI", and cards whose persisted action carries a +/// copy-paste statement also offer "Copy fix". +/// </summary> +public sealed class RecommendationCardViewModel +{ + private readonly int _utcOffsetMinutes; + + public RecommendationCardViewModel(RecommendationItem item, int utcOffsetMinutes = 0) + { + Item = item ?? throw new ArgumentNullException(nameof(item)); + _utcOffsetMinutes = utcOffsetMinutes; + } + + /// <summary>The underlying advise-only recommendation row.</summary> + public RecommendationItem Item { get; } + + /// <summary>The three-band severity (drives the badge glyph + colour).</summary> + public RecommendationSeverity Severity => Item.Severity; + + /// <summary>Short uppercase severity label for the badge, e.g. "CRITICAL".</summary> + public string SeverityLabel => Item.Severity switch + { + RecommendationSeverity.Critical => "CRITICAL", + RecommendationSeverity.Warning => "WARNING", + _ => "INFO" + }; + + /// <summary>A glyph for the badge (Segoe MDL2 Assets code point) per severity — Lite's glyphs.</summary> + public string SeverityGlyph => Item.Severity switch + { + RecommendationSeverity.Critical => "", // error / critical + RecommendationSeverity.Warning => "", // warning triangle + _ => "" // info + }; + + /// <summary>The card heading.</summary> + public string Title => Item.Title; + + /// <summary> + /// The affected database wrapped in brackets for display, or empty for a server-scoped finding + /// (so the database line collapses). + /// </summary> + public string DatabaseBracketed => + string.IsNullOrEmpty(Item.Database) ? string.Empty : $"[{Item.Database}]"; + + /// <summary>Whether a database line should be shown at all.</summary> + public bool HasDatabase => !string.IsNullOrEmpty(Item.Database); + + /// <summary>The operator-facing advice prose.</summary> + public string? AdviceText => Item.AdviceText; + + /// <summary>Whether there is advice prose to render.</summary> + public bool HasAdvice => !string.IsNullOrEmpty(Item.AdviceText); + + /// <summary>The copy-paste-ready fix T-SQL, if any.</summary> + public string? CopyPasteSql => Item.CopyPasteSql; + + /// <summary>Whether "Copy fix" is shown — only when a copy-paste statement exists.</summary> + public bool ShowCopyFix => !string.IsNullOrEmpty(Item.CopyPasteSql); + + /// <summary> + /// Whether "Ask AI" is shown. Shown for every card — the Darling service exposes the same MCP + /// investigation tools the prompt references, so any finding can be handed to AI. + /// </summary> + public bool ShowAskAi => true; + + // ---- "Open in Active Queries" deep-link (aggregate → per-server tab) --------------- + + /// <summary>The finding's monitored-server id, so the shell opens the right per-server tab.</summary> + public int ServerId => Item.ServerId; + + /// <summary>Raw UTC start of the finding window (drives the deep-link read), or null.</summary> + public DateTime? WindowStartUtc => Item.WindowStartUtc; + + /// <summary>Raw UTC end of the finding window (drives the deep-link read), or null.</summary> + public DateTime? WindowEndUtc => Item.WindowEndUtc; + + /// <summary> + /// Whether this row carries a STRUCTURED standing config-fix action — a persisted + /// <see cref="RemediationAction"/> whose typed per-target lists map to a database- or server-level + /// configuration change: DB_CONFIG safe settings (<see cref="RemediationAction.DbConfigTargets"/>), + /// per-database RCSI (<see cref="RemediationAction.RcsiTargets"/> — a DB_CONFIG action can carry ONLY + /// these, so they must be checked), percent-autogrowth files + /// (<see cref="RemediationAction.FileGrowthTargets"/>), or server config MAXDOP/CTFP/memory + /// (<see cref="RemediationAction.ServerConfigTargets"/>). Mirrors the Dashboard card's + /// <c>HasStructuredFixAction</c>. Plan-regression (force-plan), clear-plan, and MISSING_INDEX actions + /// are deliberately NOT structured config fixes — their target lists are empty here — so they remain + /// incidents, matching the Dashboard (where MISSING_INDEX keeps the incident affordances). + /// </summary> + private bool HasStructuredFixAction => + Item.Remediation is { } r && + ((r.DbConfigTargets is { Count: > 0 }) || + (r.RcsiTargets is { Count: > 0 }) || + (r.FileGrowthTargets is { Count: > 0 }) || + (r.ServerConfigTargets is { Count: > 0 })); + + /// <summary> + /// Whether this is a standing CONFIG-FIX finding (a structured config action) rather than a time-bound + /// incident. The viewer reads only engine findings (no legacy critical_issues RecommendationSetting + /// store), so — unlike the Dashboard, which also flags a config fix via a non-None Setting — the + /// structured action is the only config-fix signal available here. + /// </summary> + public bool IsConfigFix => HasStructuredFixAction; + + /// <summary> + /// Whether this is a time-bound INCIDENT finding (CPU/memory/blocking/waits/plan-regression/ + /// missing-index) — anything that is NOT a structured standing config fix. Mirrors the Dashboard's + /// <c>IsIncident</c> (<c>Setting == None && !HasStructuredFixAction</c>), reduced to the + /// <c>!HasStructuredFixAction</c> half the viewer can observe. Incidents get the deep-link to Active + /// Queries; config fixes do not. + /// </summary> + public bool IsIncident => !HasStructuredFixAction; + + /// <summary> + /// Whether the "Open in Active Queries" deep-link is shown — an INCIDENT-type finding (not a standing + /// config fix) that ALSO carries BOTH a time window (to scope the Active Queries read to when it fired) + /// AND a server id (to open the right per-server tab). This is the Dashboard's incident affordance + /// (<c>ShowOpenInActiveQueries => IsIncident</c>) ported to the viewer's advise-only model: a config-fix + /// finding (e.g. AUTO_SHRINK, RCSI, autogrowth, MAXDOP) hides the deep-link even when it carries a + /// window+server, because sending the operator to Active Queries makes no sense for a standing + /// misconfiguration; a finding with no time window (or no server id) also hides it, because deep-linking + /// needs both. + /// </summary> + public bool ShowOpenInActiveQueries => + IsIncident && Item.ServerId != 0 && Item.WindowStartUtc.HasValue && Item.WindowEndUtc.HasValue; + + /// <summary> + /// The UTC window the deep-link scopes Active Queries to: the finding's own window when it carries a + /// real range, widened to +/-<see cref="ViewerServerTab.DrillDownHalfWindowMinutes"/> around the point + /// for a degenerate (start >= end) window so the read is never empty. Reuses #1409's drill + /// half-window for consistency with the chart/heatmap drills. Only meaningful when + /// <see cref="ShowOpenInActiveQueries"/> is true; falls back to a "now"-anchored band otherwise. + /// </summary> + public (DateTime FromUtc, DateTime ToUtc) DeepLinkWindowUtc() + { + var from = Item.WindowStartUtc ?? DateTime.UtcNow; + var to = Item.WindowEndUtc ?? from; + if (to <= from) + { + from = from.AddMinutes(-ViewerServerTab.DrillDownHalfWindowMinutes); + to = to.AddMinutes(ViewerServerTab.DrillDownHalfWindowMinutes); + } + + return (from, to); + } + + /// <summary> + /// The MCP investigation prompt copied to the clipboard by "Ask AI". The window is rendered in + /// the viewer machine's local time (UTC window + offset) for operator legibility. + /// </summary> + public string AskAiPrompt + { + get + { + var (from, to) = LocalWindow(); + return RecommendationsViewModel.BuildAskAiPrompt(Item.ServerName, Title, from, to); + } + } + + /// <summary> + /// The finding window converted to local time via the passed offset, with a sensible fallback + /// when the producer carried no window (a 2h band ending "now"). Tests pass offset 0 (UTC) for + /// determinism; the tab passes the viewer machine's local offset. + /// </summary> + private (DateTime From, DateTime To) LocalWindow() + { + if (Item.WindowStartUtc is { } su && Item.WindowEndUtc is { } eu) + return (su.AddMinutes(_utcOffsetMinutes), eu.AddMinutes(_utcOffsetMinutes)); + + var now = DateTime.UtcNow.AddMinutes(_utcOffsetMinutes); + return (now.AddHours(-2), now); + } +} + +/// <summary> +/// An incident group of cards, rendered as a collapsible section. Plain DTO so the grouping is +/// unit-testable. Mirrors Lite's <c>LiteRecommendationSectionViewModel</c>. +/// </summary> +public sealed class RecommendationSectionViewModel +{ + public RecommendationSectionViewModel( + RecommendationSeverity severity, IReadOnlyList<RecommendationCardViewModel> cards, bool expanded, + string header) + { + Severity = severity; + Cards = cards ?? throw new ArgumentNullException(nameof(cards)); + IsExpanded = expanded; + Header = header; + } + + /// <summary>The severity of the section's primary (highest) finding.</summary> + public RecommendationSeverity Severity { get; } + + /// <summary>The cards in this section, in the reader's severity-desc order.</summary> + public IReadOnlyList<RecommendationCardViewModel> Cards { get; } + + /// <summary>How many cards the section holds.</summary> + public int Count => Cards.Count; + + /// <summary>Whether the section's expander starts expanded.</summary> + public bool IsExpanded { get; } + + /// <summary>The header label: primary finding + count (when > 1) + severity.</summary> + public string Header { get; } +} + +/// <summary> +/// The pure, WPF-free core of the viewer's Recommendations surface. Maps the persisted +/// <see cref="ViewerFindingRow"/> list (already severity-sorted by +/// <see cref="ViewerDataService.MapFindings"/>) to advise-only items, appends the co-fired +/// cross-reference, and groups by INCIDENT into the collapsible sections the tab renders. Keeping +/// the mapping + grouping here (rather than in code-behind) makes it directly unit-testable. +/// Mirrors Lite's <c>LiteRecommendationsReader</c> (per-finding mapping) + <c>LiteRecommendationsViewModel</c> +/// (grouping), reconciled to the viewer's persisted-finding read. +/// +/// <para> +/// This is a snapshot view-model: each load builds a fresh instance and the tab reassigns its bound +/// collections, so there is no <c>INotifyPropertyChanged</c> surface to reason about. +/// </para> +/// </summary> +public sealed class RecommendationsViewModel +{ + /// <summary>The incident sections, in severity order, or empty.</summary> + public IReadOnlyList<RecommendationSectionViewModel> Sections { get; } + + /// <summary>The selected top-level state.</summary> + public RecommendationsState State { get; } + + /// <summary> + /// The message shown in the <see cref="RecommendationsState.InsufficientData"/> state — the engine's + /// own persisted message, or <see cref="DefaultInsufficientDataMessage"/> when it supplied none. Empty + /// in every other state. + /// </summary> + public string InsufficientDataMessage { get; } + + /// <summary>Total card count across all sections.</summary> + public int TotalCount => Sections.Sum(s => s.Count); + + private RecommendationsViewModel( + IReadOnlyList<RecommendationSectionViewModel> sections, RecommendationsState state, string insufficientDataMessage) + { + Sections = sections; + State = state; + InsufficientDataMessage = insufficientDataMessage; + } + + /// <summary>The default insufficient-data prose when the engine supplied no message (mirrors Lite's).</summary> + public const string DefaultInsufficientDataMessage = + "Still collecting — the engine needs about 24 hours of history before recommendations are " + + "meaningful. Keep the collector running and check back later."; + + /// <summary>Builds the loading-state view-model (no data yet, read in flight).</summary> + public static RecommendationsViewModel Loading() => + new(Array.Empty<RecommendationSectionViewModel>(), RecommendationsState.Loading, string.Empty); + + /// <summary> + /// Builds the insufficient-data-state view-model from the persisted analysis-state marker's message + /// (or the default when it is null/blank) — the viewer's mirror of Lite's + /// <c>LiteRecommendationsViewModel.InsufficientData</c>, sourced from the V19 marker the Darling + /// service writes rather than a live engine call. + /// </summary> + public static RecommendationsViewModel InsufficientData(string? message) => + new( + Array.Empty<RecommendationSectionViewModel>(), + RecommendationsState.InsufficientData, + string.IsNullOrWhiteSpace(message) ? DefaultInsufficientDataMessage : message!); + + /// <summary> + /// Builds a loaded/empty/insufficient-data view-model from the persisted finding rows and the + /// per-server analysis-state marker. Maps each row to an advise-only item, appends the co-fired + /// cross-reference, and groups by incident. State selection: + /// <list type="bullet"> + /// <item>one or more findings -> <see cref="RecommendationsState.Loaded"/> (findings always win);</item> + /// <item>zero findings AND <paramref name="insufficientData"/> (the persisted marker says the engine + /// has not cleared its 24h data-span gate) -> <see cref="RecommendationsState.InsufficientData"/> + /// ("still collecting");</item> + /// <item>zero findings and no insufficient-data marker -> <see cref="RecommendationsState.Empty"/> + /// (the genuine all-clear — enough data, nothing to report).</item> + /// </list> + /// <paramref name="utcOffsetMinutes"/> is carried onto each card for the Ask-AI prompt's window. The + /// rows arrive pre-sorted (severity band desc, raw desc, database, title) from the read, and grouping + /// preserves that order. <paramref name="insufficientData"/> defaults false so the callers that carry + /// no marker keep the prior loaded/empty behavior. + /// </summary> + public static RecommendationsViewModel FromFindings( + IReadOnlyList<ViewerFindingRow> rows, string serverName, int utcOffsetMinutes = 0, + bool insufficientData = false, string? insufficientDataMessage = null) + { + if (rows is null || rows.Count == 0) + return ZeroFindingState(insufficientData, insufficientDataMessage); + + var items = new List<RecommendationItem>(rows.Count); + foreach (var row in rows) + { + if (row is null) + continue; + items.Add(MapItem(row, serverName)); + } + + if (items.Count == 0) + return ZeroFindingState(insufficientData, insufficientDataMessage); + + AppendCoFired(items); + return new(GroupByIncident(items, utcOffsetMinutes), RecommendationsState.Loaded, string.Empty); + } + + /// <summary> + /// Picks the state for a zero-finding read: <see cref="RecommendationsState.InsufficientData"/> when + /// the persisted marker says the analysis pass has not cleared the 24h data-span gate (so the tab + /// shows "still collecting" rather than a false all-clear), else <see cref="RecommendationsState.Empty"/> + /// (a genuine all-clear). + /// </summary> + private static RecommendationsViewModel ZeroFindingState(bool insufficientData, string? message) => + insufficientData + ? InsufficientData(message) + : new(Array.Empty<RecommendationSectionViewModel>(), RecommendationsState.Empty, string.Empty); + + /// <summary> + /// Maps one persisted finding row to an advise-only <see cref="RecommendationItem"/>. Reuses the + /// row's already-computed Title / RawSeverity / DatabaseName (so the card banding and title match + /// the grid mapping exactly), and derives the advice prose + copy-paste SQL + incident/window from + /// the carried <see cref="AnalysisFinding"/>. Advice comes from <see cref="FactAdvice"/> for the + /// root fact key; copy-paste SQL comes from the PERSISTED remediation action + /// (<see cref="BuildCopyPasteSql"/>). Exposed <c>internal</c> for tests. + /// </summary> + internal static RecommendationItem MapItem(ViewerFindingRow row, string serverName) + { + var finding = row.Finding; + var advice = FactAdvice.GetComposedForFinding(finding); + + return new RecommendationItem + { + Severity = ToSeverity(row.RawSeverity), + RawSeverity = row.RawSeverity, + Database = string.IsNullOrEmpty(row.DatabaseName) ? null : row.DatabaseName, + Title = row.Title, + AdviceText = ComposeAdvice(advice), + CopyPasteSql = BuildCopyPasteSql(finding.Remediation), + Remediation = finding.Remediation, + IncidentId = finding.IncidentId, + ServerName = serverName ?? string.Empty, + ServerId = finding.ServerId, + WindowStartUtc = AsUtc(finding.TimeRangeStart), + WindowEndUtc = AsUtc(finding.TimeRangeEnd) + }; + } + + /// <summary> + /// Maps an engine finding's <c>double</c> severity onto the canonical band with the SAME cutoffs + /// both apps' recommendation readers use (and <see cref="ViewerDataService.SeverityBand"/>): + /// >= 1.5 Critical, >= 0.75 Warning, else Info. + /// </summary> + internal static RecommendationSeverity ToSeverity(double severity) + { + if (severity >= 1.5) + return RecommendationSeverity.Critical; + if (severity >= 0.75) + return RecommendationSeverity.Warning; + return RecommendationSeverity.Info; + } + + /// <summary> + /// Composes the advice prose from an <see cref="AdviceBlock"/>: the remediation line, with the + /// investigation line appended when present. Null when no advice matched the root fact key. + /// Mirrors Lite's / the Dashboard's ComposeEngineAdvice. + /// </summary> + internal static string? ComposeAdvice(AdviceBlock? advice) + { + if (advice is null) + return null; + + var sb = new StringBuilder(); + if (!string.IsNullOrEmpty(advice.Remediation)) + sb.Append(advice.Remediation); + if (!string.IsNullOrEmpty(advice.Investigation)) + { + if (sb.Length > 0) + sb.Append(' '); + sb.Append(advice.Investigation); + } + + return sb.Length == 0 ? null : sb.ToString(); + } + + /// <summary> + /// Rebuilds the copy-paste T-SQL for a card from the finding's PERSISTED + /// <see cref="RemediationAction"/> by delegating to the shared + /// <see cref="FactRemediation.RenderCopyPasteCommand"/> — the viewer reads persisted findings, whose + /// ephemeral drill-down is NOT stored (so <see cref="FactRemediation.GenerateForFinding"/> returns + /// null on read), and the built action IS persisted. Because the viewer has NO in-app remediation + /// executor (advise-only, Postgres read-through), a runnable copy-paste command is the ONLY + /// remediation surface, so the shared renderer covers ALL seven remediation shapes — the three + /// always-safe ones (percent-autogrowth MODIFY FILE, missing-index CREATE, safe DB-config + /// ALTER DATABASE … SET) render bare, and force-plan, server-config, RCSI, and clear-plan (the four + /// that were previously copy-paste dead ends) now render too, the two destructive ones (RCSI, + /// clear-plan) carrying their two-sided risk disclosure as a comment header. Kept as a thin + /// <c>internal</c> seam (exercised directly by the viewer tests) over the shared renderer so the + /// viewer and the Dashboard reader cannot drift. Null when the action carries no renderable target. + /// </summary> + internal static string? BuildCopyPasteSql(RemediationAction? action) => + FactRemediation.RenderCopyPasteCommand(action); + + /// <summary> + /// Appends a "what else fired in this analysis window" cross-reference to each card's advice text + /// (correlate-and-focus slice 1) so related findings link to each other. Render-time, not frozen; + /// no-op for a single card. Mirrors Lite's / the Dashboard's reader so all surfaces cross-reference + /// the same way. + /// </summary> + internal static void AppendCoFired(List<RecommendationItem> items) + { + if (items.Count <= 1) + return; + + var windowTitles = new List<(string Title, double Severity)>(items.Count); + foreach (var it in items) + windowTitles.Add((it.Title, it.RawSeverity)); + + foreach (var it in items) + { + var line = CoFiredSummary.Line(CoFiredSummary.OtherTitles(it.Title, windowTitles)); + if (line is null) + continue; + it.AdviceText = string.IsNullOrEmpty(it.AdviceText) ? line : it.AdviceText + " " + line; + } + } + + /// <summary> + /// Groups the severity-sorted items into one collapsible section per INCIDENT: cards sharing an + /// <see cref="RecommendationItem.IncidentId"/> form one group headed by their primary + /// (highest-severity) finding, so related findings read as one report. A finding with no incident + /// id is its own single-card group. Groups appear in severity order (the input is severity-desc + /// sorted). A group expands unless it is Info-only. Mirrors Lite's GroupByIncident. + /// </summary> + private static List<RecommendationSectionViewModel> GroupByIncident( + IReadOnlyList<RecommendationItem> list, int utcOffsetMinutes) + { + var order = new List<string>(); + var buckets = new Dictionary<string, List<RecommendationItem>>(StringComparer.Ordinal); + var soloCount = 0; + foreach (var item in list) + { + var key = string.IsNullOrEmpty(item.IncidentId) ? "__solo_" + soloCount++ : item.IncidentId; + if (!buckets.TryGetValue(key, out var bucket)) + { + bucket = new List<RecommendationItem>(); + buckets[key] = bucket; + order.Add(key); + } + bucket.Add(item); + } + + var sections = new List<RecommendationSectionViewModel>(order.Count); + foreach (var key in order) + sections.Add(BuildIncidentSection(buckets[key], utcOffsetMinutes)); + return sections; + } + + /// <summary> + /// Builds one incident section from its cards (kept in the reader's severity-desc order). The + /// header names the primary (first) finding, the finding count, and the incident severity; the + /// section expands unless the incident is Info-only. Mirrors Lite's BuildIncidentSection. + /// </summary> + private static RecommendationSectionViewModel BuildIncidentSection( + IReadOnlyList<RecommendationItem> incidentItems, int utcOffsetMinutes) + { + var cards = incidentItems + .Select(i => new RecommendationCardViewModel(i, utcOffsetMinutes)) + .ToList(); + var primary = cards[0]; // severity-desc sorted -> the first card is the incident primary + var severity = primary.Severity; + var label = severity switch + { + RecommendationSeverity.Critical => "CRITICAL", + RecommendationSeverity.Warning => "WARNING", + _ => "INFO" + }; + var header = cards.Count > 1 + ? $"{primary.Title} · {cards.Count} findings · {label}" + : $"{primary.Title} · {label}"; + return new RecommendationSectionViewModel( + severity, cards, expanded: severity != RecommendationSeverity.Info, header); + } + + /// <summary> + /// Builds the MCP investigation prompt "Ask AI" copies to the clipboard for a finding. Pure (no + /// WPF / clock) so the interpolation is unit-testable. The window times are formatted in whatever + /// timezone the caller passed (the card passes viewer-local). Ported verbatim from Lite's prompt + /// (RecommendationsTab AskAi_Click -> LiteRecommendationsViewModel.BuildAskAiPrompt). + /// </summary> + public static string BuildAskAiPrompt(string serverName, string title, DateTime from, DateTime to) + { + return string.Format( + CultureInfo.InvariantCulture, + "Using the PerformanceMonitor MCP tools, investigate this finding on server \"{0}\": " + + "\"{1}\". It was flagged around {2:yyyy-MM-dd HH:mm}–{3:HH:mm}. Call analyze_server / " + + "get_analysis_findings and the relevant wait/blocking/memory tools, then tell me the " + + "likely cause and what to do.", + serverName, title, from, to); + } + + /// <summary> + /// Stamps a nullable engine timestamp as UTC (the pipeline records TimeRangeStart/End in UTC; the + /// PG read-back tags them Utc, but re-stamp defensively). Null passes through. + /// </summary> + private static DateTime? AsUtc(DateTime? value) => + value is null ? null : DateTime.SpecifyKind(value.Value, DateTimeKind.Utc); +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ServerAttentionDeriver.cs b/Darling/PerformanceMonitor.Darling.Viewer/ServerAttentionDeriver.cs new file mode 100644 index 000000000..0b0ab2162 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ServerAttentionDeriver.cs @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The per-server "needs attention" summary the badge derivation produces for one server from the polled +/// alert-history rows: how many actionable alerts are active, whether any is critical (drives the badge color), +/// the newest alert instant (feeds the ack-until-worse clear in <see cref="ViewerAlertStateService"/>), and a +/// human breakdown for the tooltip. A <c>default</c> value (all zero/false/null) is the "no attention" state, +/// so a server absent from the derivation dictionary reads as quiet without a special case. +/// </summary> +public readonly record struct ServerAttentionState( + int ServerId, + int AlertCount, + bool IsCritical, + DateTime? LatestEventUtc, + string Summary); + +/// <summary> +/// Turns a flat batch of polled <see cref="ViewerAlertRow"/> (the same <c>config_alert_log</c> read that feeds +/// the tray toasts) into per-server attention state for the sidebar + tab badges — the headless viewer's +/// stand-in for Lite's per-tab badge counts. Pure and clock-free so the grouping/filter rules are unit-testable +/// without WPF or Postgres. +/// +/// <para>Two rows never light a badge, matching the toast path and Lite's silence semantics:</para> +/// <list type="bullet"> +/// <item><b>Muted rows.</b> A server the operator silenced (a store-side whole-server mute rule) logs its later +/// alerts flagged <c>muted</c>; excluding them here is how the viewer mirrors Lite's "a silenced server shows no +/// badge" — no separate viewer-local silence flag needed.</item> +/// <item><b>Resolution notices.</b> A "… Cleared / Resolved / Restored" row (<see cref="ViewerAlertRow.IsResolved"/>, +/// via the shared <c>AlertMetricClassifier</c>) is good news, not an actionable alert, so it never counts toward +/// the badge.</item> +/// </list> +/// </summary> +public static class ServerAttentionDeriver +{ + /// <summary> + /// Groups the actionable (non-muted, non-resolved) rows by server and summarizes each. Servers with no + /// actionable rows are simply absent from the result (their attention state is <c>default</c>). + /// </summary> + public static Dictionary<int, ServerAttentionState> Derive(IEnumerable<ViewerAlertRow> rows) + { + ArgumentNullException.ThrowIfNull(rows); + + var result = new Dictionary<int, ServerAttentionState>(); + + foreach (var group in rows + .Where(r => !r.Muted && !r.IsResolved) + .GroupBy(r => r.ServerId)) + { + var list = group.ToList(); + result[group.Key] = new ServerAttentionState( + ServerId: group.Key, + AlertCount: list.Count, + IsCritical: list.Any(r => r.IsCritical), + LatestEventUtc: list.Max(r => r.AlertTime), + Summary: BuildSummary(list)); + } + + return result; + } + + /// <summary> + /// A compact metric breakdown for the badge tooltip, most-frequent first — e.g. + /// <c>"Blocking Detected ×2, High CPU, Deadlocks Detected"</c>. + /// </summary> + private static string BuildSummary(IReadOnlyList<ViewerAlertRow> rows) + { + return string.Join(", ", + rows.GroupBy(r => r.MetricName) + .OrderByDescending(g => g.Count()) + .ThenBy(g => g.Key, StringComparer.Ordinal) + .Select(g => g.Count() > 1 + ? string.Create(CultureInfo.InvariantCulture, $"{g.Key} ×{g.Count()}") + : g.Key)); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ServerStoreCredential.cs b/Darling/PerformanceMonitor.Darling.Viewer/ServerStoreCredential.cs new file mode 100644 index 000000000..34e3b01e5 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ServerStoreCredential.cs @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// Maps a viewer auth choice onto the <c>config.config_monitored_servers.auth</c> value the Darling +/// SERVICE can actually honor. The service's connect path +/// (<c>PerformanceMonitor.Darling.Service.MonitoredServerConnection</c>) supports exactly two modes — +/// <see cref="Integrated"/> (Windows / integrated security) and <see cref="Sql"/> (SQL login + password) — +/// so those are the only two the control-plane writes. +/// +/// <para>The viewer UI (a faithful Lite port) also offers Microsoft Entra MFA, Azure Service Principal, and +/// Azure Managed Identity. The Darling service has NO Azure/AAD connect path (verified: nothing in the +/// Service project builds an access token), so writing one of those into the store would produce a row the +/// service rejects or silently mis-connects as integrated. Rather than persist an un-honorable definition, +/// the write path BLOCKS those modes with <see cref="UnsupportedAuthMessage"/>. Adding an Azure connect path +/// is a Service-project feature, out of scope here — surfaced in the PR, not silently dropped.</para> +/// </summary> +public static class ServerStoreCredential +{ + /// <summary>Windows / integrated security — no per-server secret.</summary> + public const string Integrated = "integrated"; + + /// <summary>SQL login — username + a DPAPI-protected password blob.</summary> + public const string Sql = "sql"; + + /// <summary> + /// The store <c>auth</c> value for a viewer <see cref="AuthenticationTypes"/> mode, or null when the + /// Darling service cannot honor it (the three Azure/Entra modes). Callers treat null as "block the save". + /// </summary> + public static string? MapAuth(string? authenticationType) => authenticationType switch + { + AuthenticationTypes.Windows => Integrated, + AuthenticationTypes.SqlServer => Sql, + _ => null, + }; + + /// <summary>True when the Darling service can connect with this viewer auth mode (Windows or SQL).</summary> + public static bool IsSupported(string? authenticationType) => MapAuth(authenticationType) is not null; + + /// <summary>True when the mapped store auth needs a per-server secret (SQL only).</summary> + public static bool RequiresSecret(string? authenticationType) => + MapAuth(authenticationType) == Sql; + + /// <summary>The user-facing message shown when an Azure/Entra auth mode is chosen (service can't honor it).</summary> + public const string UnsupportedAuthMessage = + "The Darling service connects with Windows (integrated) or SQL authentication only. " + + "Microsoft Entra MFA, Service Principal, and Managed Identity aren't supported by the service's " + + "collection path yet — choose Windows or SQL authentication, or a SQL credential profile."; +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml b/Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml new file mode 100644 index 000000000..e419a6dbe --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml @@ -0,0 +1,672 @@ +<Window x:Class="PerformanceMonitor.Darling.Viewer.SettingsWindow" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + Title="Settings" + Height="750" Width="750" + MinHeight="500" MinWidth="600" + ResizeMode="CanResizeWithGrip" + WindowStartupLocation="CenterOwner" + Icon="EDD.ico" + Background="{DynamicResource BackgroundBrush}"> + <!-- Faithful port of Performance Monitor Lite's SettingsWindow (Lite/Windows/SettingsWindow.xaml), + section-for-section. Dark-only like the rest of the viewer: it does NOT merge ViewerDarkTheme.xaml + (the two small write-back dialogs do) — it relies on the app-merged Themes/DarkTheme.xaml (App.xaml) + for its implicit control styles + brush keys, exactly as the copied per-server / aggregate tab XAML + does, so {DynamicResource BackgroundLightBrush} / {StaticResource AccentButton} / the PasswordBox + + ComboBox + CheckBox chrome all resolve and render as they do in Lite. + + Persistence adaptation (matching the port): the alert/notification/SMTP/webhook/MCP sections drive + Lite's in-process engine; in Darling those are the SERVICE's domain (darling.json). The viewer + persists them faithfully in ViewerAppSettings so the window is complete and its state survives a + restart — the service honoring a change is a separate wiring concern. The controls that act inside + the viewer today work immediately: Manage Mute Rules (writes Postgres), Send Test Email / Send Test + Notification (shared connection-independent renderers), and the folded-in viewer preferences + (default time range + auto-refresh, formerly #1401). Two Lite-only mechanisms are adapted sensibly: + Pause Collection can't reach the remote service (shown disabled/managed), and the per-server + Collector Schedule editor has no store model in Darling (shown as an informational panel). --> + <Window.Resources> + <ResourceDictionary> + <Style TargetType="Hyperlink"> + <Setter Property="Foreground" Value="#6CB6FF"/> + </Style> + </ResourceDictionary> + </Window.Resources> + <ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"> + <Grid Margin="16"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + + <!-- Header --> + <StackPanel Grid.Row="0" Margin="0,0,0,16"> + <TextBlock Text="Settings" FontSize="18" FontWeight="Bold" Foreground="{DynamicResource ForegroundBrush}"/> + <TextBlock Text="Configure the viewer and the Darling monitoring service" + FontSize="12" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,4,0,0"/> + </StackPanel> + + <!-- Collection Control --> + <Border Grid.Row="1" Background="{DynamicResource BackgroundLightBrush}" CornerRadius="4" Padding="12" Margin="0,0,0,12"> + <StackPanel> + <Grid> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*"/> + <ColumnDefinition Width="Auto"/> + </Grid.ColumnDefinitions> + <StackPanel Grid.Column="0"> + <TextBlock Text="Data Collection" FontSize="14" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundBrush}"/> + <TextBlock x:Name="CollectionStatusText" Text="Status: Managed by the Darling service" FontSize="12" + Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,4,0,0" TextWrapping="Wrap"/> + </StackPanel> + <Button Grid.Column="1" x:Name="PauseResumeButton" Content="Pause Collection" + Click="PauseResumeButton_Click" VerticalAlignment="Center"/> + </Grid> + <CheckBox x:Name="CapturePlansCheckBox" Content="Capture execution plans (all servers)" Margin="0,12,0,0" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBlock Text="Store query and Query Store execution-plan XML across the fleet. Disable to shave storage on a very large fleet; a per-server override lives on each server's dialog." + FontSize="11" FontStyle="Italic" Foreground="{DynamicResource ForegroundMutedBrush}" + Margin="0,4,0,0" TextWrapping="Wrap"/> + <TextBlock x:Name="SettingsReadOnlyBanner" FontSize="12" Foreground="{DynamicResource WarningTextBrush}" Margin="0,8,0,0" TextWrapping="Wrap" + Visibility="Collapsed" + Text="This viewer is connected with a read-only role. Monitoring settings below are shown for reference but can't be changed from here."/> + </StackPanel> + </Border> + + <!-- MCP Server Settings --> + <Border Grid.Row="2" Background="{DynamicResource BackgroundLightBrush}" CornerRadius="4" Padding="12" Margin="0,0,0,12"> + <StackPanel> + <TextBlock Text="MCP Server (LLM Tool Access)" FontSize="14" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundBrush}"/> + <TextBlock Text="Allows LLM clients (Claude Code, etc.) to query monitoring data via the Model Context Protocol. The MCP server is hosted by the Darling service." + FontSize="12" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,4,0,0" TextWrapping="Wrap"/> + <StackPanel Orientation="Horizontal" Margin="0,8,0,0"> + <CheckBox x:Name="McpEnabledCheckBox" Content="Enable MCP server" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBlock Text="Port:" VerticalAlignment="Center" Margin="20,0,6,0" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBox x:Name="McpPortTextBox" Width="70" Text="5152" VerticalAlignment="Center"/> + <Button Content="Auto" ToolTip="Find an available port" + Margin="6,0,0,0" Padding="8,2" + VerticalAlignment="Center" + Click="AutoPortButton_Click"/> + </StackPanel> + <TextBlock x:Name="McpStatusText" FontSize="12" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,6,0,0"/> + <StackPanel Orientation="Horizontal" Margin="0,6,0,0"> + <Button x:Name="CopyMcpCommandButton" Content="Copy Setup Command" Click="CopyMcpCommandButton_Click"/> + <TextBlock Text="Works with Claude Code. Other LLMs may apply, but why would you?" + FontSize="11" FontStyle="Italic" Foreground="{DynamicResource ForegroundMutedBrush}" + VerticalAlignment="Center" Margin="8,0,0,0"/> + </StackPanel> + </StackPanel> + </Border> + + <!-- Web Dashboard Settings --> + <Border Grid.Row="3" Background="{DynamicResource BackgroundLightBrush}" CornerRadius="4" Padding="12" Margin="0,0,0,12"> + <StackPanel> + <TextBlock Text="Web Dashboard (Browser Access)" FontSize="14" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundBrush}"/> + <TextBlock Text="Serves a read-only monitoring dashboard over HTTP so a browser can view the fleet. Loopback-only by default; configure LAN exposure in darling.json. The dashboard is hosted by the Darling service." + FontSize="12" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,4,0,0" TextWrapping="Wrap"/> + <StackPanel Orientation="Horizontal" Margin="0,8,0,0"> + <CheckBox x:Name="WebEnabledCheckBox" Content="Enable web dashboard" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBlock Text="Port:" VerticalAlignment="Center" Margin="20,0,6,0" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBox x:Name="WebPortTextBox" Width="70" Text="5153" VerticalAlignment="Center"/> + <Button Content="Auto" ToolTip="Find an available port" + Margin="6,0,0,0" Padding="8,2" + VerticalAlignment="Center" + Click="WebAutoPortButton_Click"/> + </StackPanel> + <TextBlock x:Name="WebStatusText" FontSize="12" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,6,0,0"/> + <StackPanel Orientation="Horizontal" Margin="0,6,0,0"> + <Button x:Name="CopyWebUrlButton" Content="Copy URL" Click="CopyWebUrlButton_Click"/> + <TextBlock Text="Open this URL in a browser on this machine (or across the LAN when exposed)." + FontSize="11" FontStyle="Italic" Foreground="{DynamicResource ForegroundMutedBrush}" + VerticalAlignment="Center" Margin="8,0,0,0"/> + </StackPanel> + </StackPanel> + </Border> + + <!-- Viewer Defaults --> + <Border Grid.Row="4" Background="{DynamicResource BackgroundLightBrush}" CornerRadius="4" Padding="12" Margin="0,0,0,12"> + <StackPanel> + <TextBlock Text="Viewer Defaults" FontSize="14" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundBrush}"/> + <StackPanel Orientation="Horizontal" Margin="0,8,0,0"> + <TextBlock Text="Default time range:" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,8,0"/> + <ComboBox x:Name="DefaultTimeRangeCombo" Width="140" VerticalAlignment="Center" + ToolTip="Time window each newly-opened server tab starts on"> + <ComboBoxItem Content="Last 1 hour"/> + <ComboBoxItem Content="Last 4 hours"/> + <ComboBoxItem Content="Last 12 hours"/> + <ComboBoxItem Content="Last 24 hours"/> + <ComboBoxItem Content="Last 7 days"/> + </ComboBox> + </StackPanel> + <!-- Folded in from #1401: the auto-refresh defaults for newly-opened server tabs. --> + <CheckBox x:Name="AutoRefreshCheckBox" Content="Auto-refresh newly-opened server tabs" + Margin="0,10,0,0" Foreground="{DynamicResource ForegroundBrush}" + Checked="AutoRefreshCheckBox_Toggled" Unchecked="AutoRefreshCheckBox_Toggled"/> + <StackPanel Orientation="Horizontal" Margin="0,8,0,0"> + <TextBlock Text="Auto-refresh interval:" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,8,0"/> + <ComboBox x:Name="AutoRefreshIntervalCombo" Width="140" VerticalAlignment="Center" + ToolTip="How often an auto-refreshing server tab reloads"> + <ComboBoxItem Content="30 seconds"/> + <ComboBoxItem Content="1 minute"/> + <ComboBoxItem Content="5 minutes"/> + </ComboBox> + </StackPanel> + <!-- The shell's fleet cadence: one knob over what were two hardcoded timers (60s aggregate / 30s + Overview). Raise it to cut store-query load at fleet scale. --> + <StackPanel Orientation="Horizontal" Margin="0,8,0,0"> + <TextBlock Text="Fleet refresh interval:" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,8,0"/> + <ComboBox x:Name="NocRefreshIntervalCombo" Width="140" VerticalAlignment="Center" + ToolTip="How often the Overview and aggregate tabs refresh from the store. Raise it to cut store load when monitoring many servers."> + <ComboBoxItem Content="10 seconds" Tag="10"/> + <ComboBoxItem Content="30 seconds" Tag="30"/> + <ComboBoxItem Content="1 minute" Tag="60"/> + <ComboBoxItem Content="2 minutes" Tag="120"/> + <ComboBoxItem Content="5 minutes" Tag="300"/> + </ComboBox> + </StackPanel> + <StackPanel Orientation="Horizontal" Margin="0,10,0,0"> + <TextBlock Text="Connection timeout:" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,8,0"/> + <TextBox x:Name="ConnectionTimeoutBox" Width="50" Text="5" VerticalAlignment="Center"/> + <TextBlock Text="seconds (5–60, increase for VPN/remote connections)" + FontSize="11" FontStyle="Italic" Foreground="{DynamicResource ForegroundMutedBrush}" + VerticalAlignment="Center" Margin="6,0,0,0"/> + </StackPanel> + <StackPanel Orientation="Horizontal" Margin="0,8,0,0"> + <TextBlock Text="CSV separator:" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,8,0"/> + <ComboBox x:Name="CsvSeparatorCombo" Width="140" VerticalAlignment="Center"> + <ComboBoxItem Content="Comma (,)" Tag=","/> + <ComboBoxItem Content="Semicolon (;)" Tag=";"/> + <ComboBoxItem Content="Tab" Tag=" "/> + </ComboBox> + <TextBlock Text="auto-detected from locale" + FontSize="11" FontStyle="Italic" Foreground="{DynamicResource ForegroundMutedBrush}" + VerticalAlignment="Center" Margin="6,0,0,0" x:Name="CsvSeparatorHint"/> + </StackPanel> + <StackPanel Orientation="Horizontal" Margin="0,8,0,0"> + <TextBlock Text="Show timestamps in:" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,8,0"/> + <ComboBox x:Name="TimeDisplayModeCombo" Width="140" VerticalAlignment="Center"> + <ComboBoxItem Content="Server Time" Tag="ServerTime"/> + <ComboBoxItem Content="Local Time" Tag="LocalTime"/> + <ComboBoxItem Content="UTC" Tag="UTC"/> + </ComboBox> + </StackPanel> + <StackPanel Orientation="Horizontal" Margin="0,8,0,0"> + <TextBlock Text="Color theme:" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,8,0"/> + <ComboBox x:Name="ColorThemeCombo" Width="140" VerticalAlignment="Center" + SelectionChanged="ColorThemeCombo_SelectionChanged"> + <ComboBoxItem Content="Dark" Tag="Dark"/> + <ComboBoxItem Content="Light" Tag="Light"/> + <ComboBoxItem Content="Cool Breeze" Tag="CoolBreeze"/> + </ComboBox> + </StackPanel> + </StackPanel> + </Border> + + <!-- Notifications --> + <Border Grid.Row="5" Background="{DynamicResource BackgroundLightBrush}" CornerRadius="4" Padding="12" Margin="0,0,0,12"> + <StackPanel> + <StackPanel Orientation="Horizontal"> + <TextBlock Text="Notifications" FontSize="14" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundBrush}" VerticalAlignment="Center"/> + <Button x:Name="RestoreAlertDefaultsButton" Content="Restore Defaults" Margin="12,0,0,0" + FontSize="11" VerticalAlignment="Center" Click="RestoreAlertDefaultsButton_Click"/> + </StackPanel> + <CheckBox x:Name="MinimizeToTrayCheckBox" Content="Minimize to system tray" Margin="0,8,0,0" + Foreground="{DynamicResource ForegroundBrush}"/> + <CheckBox x:Name="AlertsEnabledCheckBox" Content="Enable notifications" Margin="0,8,0,0" + Foreground="{DynamicResource ForegroundBrush}" Checked="AlertsEnabledCheckBox_Changed" Unchecked="AlertsEnabledCheckBox_Changed"/> + <CheckBox x:Name="NotifyConnectionCheckBox" Content="Connection lost / restored" Margin="20,6,0,0" + Foreground="{DynamicResource ForegroundBrush}"/> + <StackPanel Orientation="Horizontal" Margin="20,6,0,0"> + <CheckBox x:Name="AlertCpuCheckBox" Content="CPU above" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBox x:Name="AlertCpuThresholdBox" Width="45" Text="80" Margin="6,0,0,0" VerticalAlignment="Center"/> + <TextBlock Text="%" VerticalAlignment="Center" Margin="2,0,0,0" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBlock Text="measured as" VerticalAlignment="Center" Margin="8,0,4,0" + Foreground="{DynamicResource ForegroundBrush}"/> + <ComboBox x:Name="AlertCpuModeBox" Width="220" VerticalAlignment="Center"> + <ComboBoxItem Content="Total non-idle CPU (SQL + other processes)" Tag="Total"/> + <ComboBoxItem Content="SQL Server only (scheduler %)" Tag="SqlOnly"/> + </ComboBox> + </StackPanel> + <StackPanel Orientation="Horizontal" Margin="20,6,0,0"> + <CheckBox x:Name="AlertBlockingCheckBox" Content="Blocking sessions ≥" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBox x:Name="AlertBlockingThresholdBox" Width="45" Text="1" Margin="6,0,0,0" VerticalAlignment="Center"/> + </StackPanel> + <StackPanel Orientation="Horizontal" Margin="20,6,0,0"> + <CheckBox x:Name="AlertDeadlockCheckBox" Content="Deadlocks ≥" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBox x:Name="AlertDeadlockThresholdBox" Width="45" Text="1" Margin="6,0,0,0" VerticalAlignment="Center"/> + </StackPanel> + <StackPanel Orientation="Horizontal" Margin="20,6,0,0"> + <CheckBox x:Name="AlertPoisonWaitCheckBox" Content="Poison waits (THREADPOOL, etc.) avg wait ≥" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBox x:Name="AlertPoisonWaitThresholdBox" Width="45" Text="500" Margin="6,0,0,0" VerticalAlignment="Center"/> + <TextBlock Text="ms" VerticalAlignment="Center" Margin="2,0,0,0" + Foreground="{DynamicResource ForegroundBrush}"/> + </StackPanel> + <StackPanel Orientation="Horizontal" Margin="20,6,0,0"> + <CheckBox x:Name="AlertLongRunningQueryCheckBox" Content="Long-running queries over" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBox x:Name="AlertLongRunningQueryThresholdBox" Width="45" Text="30" Margin="6,0,0,0" VerticalAlignment="Center"/> + <TextBlock Text="minutes," VerticalAlignment="Center" Margin="2,0,0,0" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBlock Text="return up to" VerticalAlignment="Center" Margin="6,0,6,0" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBox x:Name="AlertLongRunningQueryMaxResultsBox" Width="45" Text="5" VerticalAlignment="Center"/> + <TextBlock Text="results" VerticalAlignment="Center" Margin="2,0,0,0" + Foreground="{DynamicResource ForegroundBrush}"/> + </StackPanel> + <!-- Long Running Query Filters --> + <TextBlock Text="Long Running Query Filters" FontWeight="SemiBold" FontSize="12" + Foreground="{DynamicResource ForegroundBrush}" Margin="20,10,0,4"/> + <CheckBox x:Name="LrqExcludeSpServerDiagnosticsCheckBox" + Content="Exclude SP_SERVER_DIAGNOSTICS" + Foreground="{DynamicResource ForegroundBrush}" Margin="20,0,0,4"/> + <CheckBox x:Name="LrqExcludeWaitForCheckBox" + Content="Exclude WAITFOR / BROKER_RECEIVE_WAITFOR" + Foreground="{DynamicResource ForegroundBrush}" Margin="20,0,0,4"/> + <CheckBox x:Name="LrqExcludeBackupsCheckBox" + Content="Exclude backup waits (BACKUPTHREAD, BACKUPIO)" + Foreground="{DynamicResource ForegroundBrush}" Margin="20,0,0,4"/> + <CheckBox x:Name="LrqExcludeMiscWaitsCheckBox" + Content="Exclude miscellaneous waits (XE_LIVE_TARGET_TVF)" + Foreground="{DynamicResource ForegroundBrush}" Margin="20,0,0,4"/> + <CheckBox x:Name="LrqExcludeCdcCheckBox" + Content="Exclude CDC capture jobs (sp_MScdc_capture_job, sp_cdc_scan)" + Foreground="{DynamicResource ForegroundBrush}" Margin="20,0,0,0"/> + <StackPanel Orientation="Horizontal" Margin="20,6,0,0"> + <CheckBox x:Name="AlertTempDbSpaceCheckBox" Content="tempdb space usage over" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBox x:Name="AlertTempDbSpaceThresholdBox" Width="45" Text="80" Margin="6,0,0,0" VerticalAlignment="Center"/> + <TextBlock Text="%" VerticalAlignment="Center" Margin="2,0,0,0" + Foreground="{DynamicResource ForegroundBrush}"/> + </StackPanel> + <StackPanel Orientation="Horizontal" Margin="20,6,0,0"> + <CheckBox x:Name="AlertLowDiskCheckBox" Content="Volume free space under" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBox x:Name="AlertLowDiskThresholdPercentBox" Width="45" Text="10" Margin="6,0,0,0" VerticalAlignment="Center"/> + <TextBlock Text="% or" VerticalAlignment="Center" Margin="2,0,0,0" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBox x:Name="AlertLowDiskThresholdGbBox" Width="45" Text="5" Margin="6,0,0,0" VerticalAlignment="Center"/> + <TextBlock Text="GB free (Azure SQL DB excluded)" VerticalAlignment="Center" Margin="2,0,0,0" + Foreground="{DynamicResource ForegroundBrush}"/> + </StackPanel> + <StackPanel Orientation="Horizontal" Margin="20,6,0,0"> + <CheckBox x:Name="AlertLongRunningJobCheckBox" Content="Agent jobs running over" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBox x:Name="AlertLongRunningJobMultiplierBox" Width="35" Text="3" Margin="6,0,0,0" VerticalAlignment="Center"/> + <TextBlock Text="x historical average" VerticalAlignment="Center" Margin="2,0,0,0" + Foreground="{DynamicResource ForegroundBrush}"/> + </StackPanel> + <StackPanel Orientation="Horizontal" Margin="20,6,0,0"> + <CheckBox x:Name="AlertFailedJobCheckBox" Content="Agent job failed in the last" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBox x:Name="AlertFailedJobLookbackBox" Width="45" Text="60" Margin="6,0,0,0" VerticalAlignment="Center"/> + <TextBlock Text="minutes" VerticalAlignment="Center" Margin="2,0,0,0" + Foreground="{DynamicResource ForegroundBrush}"/> + </StackPanel> + <StackPanel Orientation="Horizontal" Margin="20,10,0,0"> + <TextBlock Text="Tray notification cooldown:" VerticalAlignment="Center" Margin="0,0,8,0" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBox x:Name="AlertCooldownBox" Width="40" TextAlignment="Center" VerticalAlignment="Center"/> + <TextBlock Text="minutes" VerticalAlignment="Center" Margin="4,0,0,0" + Foreground="{DynamicResource ForegroundBrush}"/> + </StackPanel> + <StackPanel Orientation="Horizontal" Margin="20,6,0,0"> + <TextBlock Text="Email alert cooldown:" VerticalAlignment="Center" Margin="0,0,8,0" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBox x:Name="EmailCooldownBox" Width="40" TextAlignment="Center" VerticalAlignment="Center"/> + <TextBlock Text="minutes" VerticalAlignment="Center" Margin="4,0,0,0" + Foreground="{DynamicResource ForegroundBrush}"/> + </StackPanel> + <!-- deadlock/blocking notification delivery mode + per-event cap --> + <StackPanel Orientation="Horizontal" Margin="20,6,0,0"> + <TextBlock Text="Deadlock/blocking delivery:" VerticalAlignment="Center" Margin="0,0,8,0" + Foreground="{DynamicResource ForegroundBrush}"/> + <ComboBox x:Name="AlertDeliveryModeBox" Width="260" VerticalAlignment="Center"> + <ComboBoxItem Content="Summary — one card per cycle" Tag="Summary"/> + <ComboBoxItem Content="Per-event — one notification per incident" Tag="PerEvent"/> + </ComboBox> + <TextBlock Text="max" VerticalAlignment="Center" Margin="8,0,4,0" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBox x:Name="AlertPerEventMaxBox" Width="40" TextAlignment="Center" VerticalAlignment="Center"/> + <TextBlock Text="/cycle" VerticalAlignment="Center" Margin="4,0,0,0" + Foreground="{DynamicResource ForegroundBrush}"/> + </StackPanel> + <TextBlock x:Name="AlertPreviewText" FontSize="11" FontStyle="Italic" + Foreground="{DynamicResource ForegroundMutedBrush}" TextWrapping="Wrap" Margin="20,10,0,0"/> + <!-- Global Alert Filters --> + <TextBlock Text="Global Alert Filters" FontWeight="SemiBold" FontSize="12" + Foreground="{DynamicResource ForegroundBrush}" Margin="20,14,0,4"/> + <TextBlock Text="Exclude databases from alerts (comma-separated):" + Foreground="{DynamicResource ForegroundBrush}" Margin="20,0,0,4" FontSize="12"/> + <TextBox x:Name="AlertExcludedDatabasesBox" Margin="20,0,0,4"/> + <TextBlock Text="e.g. DBAtools, msdb, tempdb — applies to blocking, deadlock, and long-running query alerts" + FontSize="11" FontStyle="Italic" Foreground="{DynamicResource ForegroundMutedBrush}" + Margin="20,0,0,0" TextWrapping="Wrap"/> + + <!-- Mute Rules --> + <TextBlock Text="Alert Muting" FontWeight="SemiBold" FontSize="12" + Foreground="{DynamicResource ForegroundBrush}" Margin="20,14,0,4"/> + <StackPanel Orientation="Horizontal" Margin="20,0,0,4"> + <TextBlock Text="Default expiration for new mute rules:" VerticalAlignment="Center" Margin="0,0,8,0" + Foreground="{DynamicResource ForegroundBrush}"/> + <ComboBox x:Name="MuteRuleDefaultExpirationCombo" Width="130" VerticalAlignment="Center"> + <ComboBoxItem Content="1 hour"/> + <ComboBoxItem Content="24 hours"/> + <ComboBoxItem Content="7 days"/> + <ComboBoxItem Content="Never"/> + </ComboBox> + </StackPanel> + <Button x:Name="ManageMuteRulesButton" Content="Manage Mute Rules..." + Click="ManageMuteRulesButton_Click" HorizontalAlignment="Left" + Padding="12,4" Margin="20,4,0,4"/> + <TextBlock Text="Create rules to suppress specific recurring alerts while still logging them." + FontSize="11" FontStyle="Italic" Foreground="{DynamicResource ForegroundMutedBrush}" + Margin="20,0,0,0" TextWrapping="Wrap"/> + <CheckBox x:Name="LogAlertDismissalsCheckBox" Content="Log alert dismiss and mute actions" + Margin="20,8,0,0" Foreground="{DynamicResource ForegroundBrush}"/> + + <!-- Automated Analysis --> + <TextBlock Text="Automated Analysis" FontWeight="SemiBold" FontSize="12" + Foreground="{DynamicResource ForegroundBrush}" Margin="20,14,0,4"/> + <TextBlock Text="Periodically run the triage engine and record findings in the Alerts tab. Notifications below are optional — analysis runs and stores findings even with notifications off." + FontSize="11" FontStyle="Italic" Foreground="{DynamicResource ForegroundMutedBrush}" + Margin="20,0,0,4" TextWrapping="Wrap"/> + <CheckBox x:Name="AnalysisEnabledCheckBox" Content="Enable automated analysis" + Margin="20,4,0,0" Foreground="{DynamicResource ForegroundBrush}"/> + <CheckBox x:Name="AnalysisNotificationsCheckBox" Content="Send scheduled analysis notifications" + Margin="20,4,0,0" Foreground="{DynamicResource ForegroundBrush}"/> + <StackPanel Orientation="Horizontal" Margin="20,6,0,0"> + <TextBlock Text="Run analysis every" VerticalAlignment="Center" Margin="0,0,8,0" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBox x:Name="AnalysisIntervalBox" Width="45" TextAlignment="Center" VerticalAlignment="Center"/> + <TextBlock Text="minutes" VerticalAlignment="Center" Margin="4,0,0,0" + Foreground="{DynamicResource ForegroundBrush}"/> + </StackPanel> + <StackPanel Orientation="Horizontal" Margin="20,6,0,0"> + <TextBlock Text="Notify on finding severity at or above" VerticalAlignment="Center" Margin="0,0,8,0" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBox x:Name="AnalysisNotifySeverityBox" Width="45" TextAlignment="Center" VerticalAlignment="Center"/> + <TextBlock Text="(0.0 - 2.0)" VerticalAlignment="Center" Margin="4,0,0,0" + Foreground="{DynamicResource ForegroundMutedBrush}"/> + </StackPanel> + <TextBlock Text="Requires SMTP or a webhook configured below for delivery; findings are recorded in the Alerts tab either way." + FontSize="11" FontStyle="Italic" Foreground="{DynamicResource ForegroundMutedBrush}" + Margin="20,4,0,0" TextWrapping="Wrap"/> + </StackPanel> + </Border> + + <!-- Email Alerts (Optional) --> + <Border Grid.Row="6" Background="{DynamicResource BackgroundLightBrush}" CornerRadius="4" Padding="12" Margin="0,0,0,12"> + <StackPanel> + <TextBlock Text="Email Alerts (Optional)" FontSize="14" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundBrush}"/> + <TextBlock Text="Send email notifications alongside system tray alerts. Requires an SMTP server." + FontSize="12" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,4,0,0" TextWrapping="Wrap"/> + <CheckBox x:Name="SmtpEnabledCheckBox" Content="Enable email alerts" Margin="0,8,0,0" + Foreground="{DynamicResource ForegroundBrush}" Checked="SmtpEnabledCheckBox_Changed" Unchecked="SmtpEnabledCheckBox_Changed"/> + <Grid Margin="20,6,0,0"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="100"/> + <ColumnDefinition Width="*"/> + </Grid.ColumnDefinitions> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + <TextBlock Grid.Row="0" Grid.Column="0" Text="SMTP Server:" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,8,4"/> + <TextBox Grid.Row="0" Grid.Column="1" x:Name="SmtpServerBox" Margin="0,0,0,4"/> + + <TextBlock Grid.Row="1" Grid.Column="0" Text="Port:" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,8,4"/> + <StackPanel Grid.Row="1" Grid.Column="1" Orientation="Horizontal" Margin="0,0,0,4"> + <TextBox x:Name="SmtpPortBox" Width="70" Text="587"/> + <CheckBox x:Name="SmtpSslCheckBox" Content="Use SSL/TLS" VerticalAlignment="Center" Margin="16,0,0,0" + Foreground="{DynamicResource ForegroundBrush}" IsChecked="True"/> + </StackPanel> + + <TextBlock Grid.Row="2" Grid.Column="0" Text="Username:" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,8,4"/> + <TextBox Grid.Row="2" Grid.Column="1" x:Name="SmtpUsernameBox" Margin="0,0,0,4"/> + + <TextBlock Grid.Row="3" Grid.Column="0" Text="Password:" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,8,4"/> + <PasswordBox Grid.Row="3" Grid.Column="1" x:Name="SmtpPasswordBox" Margin="0,0,0,4"/> + + <TextBlock Grid.Row="4" Grid.Column="0" Text="From Address:" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,8,4"/> + <TextBox Grid.Row="4" Grid.Column="1" x:Name="SmtpFromBox" Margin="0,0,0,4"/> + + <TextBlock Grid.Row="5" Grid.Column="0" Text="Recipients:" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,8,4"/> + <TextBox Grid.Row="5" Grid.Column="1" x:Name="SmtpRecipientsBox" Margin="0,0,0,4" + ToolTip="Comma-separated email addresses"/> + + <StackPanel Grid.Row="6" Grid.Column="1" Orientation="Horizontal" Margin="0,4,0,0"> + <Button x:Name="TestEmailButton" Content="Send Test Email" Click="TestEmailButton_Click"/> + <Button x:Name="ValidateSmtpButton" Content="Validate Settings" Margin="8,0,0,0" + Click="ValidateSmtpButton_Click"/> + <TextBlock x:Name="SmtpStatusText" FontSize="11" FontStyle="Italic" + Foreground="{DynamicResource ForegroundMutedBrush}" VerticalAlignment="Center" Margin="8,0,0,0"/> + </StackPanel> + </Grid> + </StackPanel> + </Border> + + <!-- Webhooks (Teams / Slack) --> + <Border Grid.Row="7" Background="{DynamicResource BackgroundLightBrush}" CornerRadius="4" Padding="12" Margin="0,0,0,12"> + <StackPanel> + <!-- Microsoft Teams --> + <TextBlock Text="Microsoft Teams Notifications" FontSize="14" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundBrush}"/> + <TextBlock Text="Send alert notifications to a Teams channel via incoming webhook. Color-coded accent bars indicate alert severity." + FontSize="12" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,4,0,0" TextWrapping="Wrap"/> + <CheckBox x:Name="TeamsWebhookEnabledCheckBox" Content="Enable Teams notifications" Margin="0,8,0,0" + Foreground="{DynamicResource ForegroundBrush}" Checked="TeamsWebhookEnabledCheckBox_Changed" Unchecked="TeamsWebhookEnabledCheckBox_Changed"/> + <Grid Margin="20,6,0,0"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="110"/> + <ColumnDefinition Width="*"/> + </Grid.ColumnDefinitions> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + <TextBlock Grid.Row="0" Grid.Column="0" Text="Webhook URL:" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,8,4"/> + <TextBox Grid.Row="0" Grid.Column="1" x:Name="TeamsWebhookUrlBox" Margin="0,0,0,4" + ToolTip="Paste your Teams Incoming Webhook or Power Automate Workflows URL here"/> + + <TextBlock Grid.Row="1" Grid.Column="0" Text="Proxy Address:" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,8,4"/> + <TextBox Grid.Row="1" Grid.Column="1" x:Name="TeamsProxyAddressBox" Margin="0,0,0,4" + ToolTip="Optional HTTP proxy (e.g. http://proxy.corp:8080). Leave blank for direct connection."/> + + <StackPanel Grid.Row="2" Grid.Column="1" Orientation="Horizontal" Margin="0,4,0,0"> + <Button x:Name="TestTeamsButton" Content="Send Test Notification" Click="TestTeamsButton_Click"/> + <TextBlock x:Name="TeamsStatusText" FontSize="11" FontStyle="Italic" + Foreground="{DynamicResource ForegroundMutedBrush}" VerticalAlignment="Center" Margin="8,0,0,0"/> + </StackPanel> + </Grid> + <TextBlock Margin="20,8,0,0" TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}"> + <Run Text="Setup: Create an Incoming Webhook connector or a Power Automate Workflows webhook for your Teams channel. "/> + <Hyperlink NavigateUri="https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook" + RequestNavigate="Hyperlink_RequestNavigate"> + Teams Webhooks Documentation + </Hyperlink> + </TextBlock> + + <!-- Slack --> + <TextBlock Text="Slack Notifications" FontSize="14" FontWeight="SemiBold" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,20,0,0"/> + <TextBlock Text="Send alert notifications to a Slack channel via incoming webhook. Color-coded sidebar indicates alert severity." + FontSize="12" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,4,0,0" TextWrapping="Wrap"/> + <CheckBox x:Name="SlackWebhookEnabledCheckBox" Content="Enable Slack notifications" Margin="0,8,0,0" + Foreground="{DynamicResource ForegroundBrush}" Checked="SlackWebhookEnabledCheckBox_Changed" Unchecked="SlackWebhookEnabledCheckBox_Changed"/> + <Grid Margin="20,6,0,0"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="110"/> + <ColumnDefinition Width="*"/> + </Grid.ColumnDefinitions> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + <TextBlock Grid.Row="0" Grid.Column="0" Text="Webhook URL:" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,8,4"/> + <TextBox Grid.Row="0" Grid.Column="1" x:Name="SlackWebhookUrlBox" Margin="0,0,0,4" + ToolTip="Paste your Slack Incoming Webhook URL here"/> + + <TextBlock Grid.Row="1" Grid.Column="0" Text="Proxy Address:" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,8,4"/> + <TextBox Grid.Row="1" Grid.Column="1" x:Name="SlackProxyAddressBox" Margin="0,0,0,4" + ToolTip="Optional HTTP proxy (e.g. http://proxy.corp:8080). Leave blank for direct connection."/> + + <StackPanel Grid.Row="2" Grid.Column="1" Orientation="Horizontal" Margin="0,4,0,0"> + <Button x:Name="TestSlackButton" Content="Send Test Notification" Click="TestSlackButton_Click"/> + <TextBlock x:Name="SlackStatusText" FontSize="11" FontStyle="Italic" + Foreground="{DynamicResource ForegroundMutedBrush}" VerticalAlignment="Center" Margin="8,0,0,0"/> + </StackPanel> + </Grid> + <TextBlock Margin="20,8,0,0" TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}"> + <Run Text="Setup: Create an Incoming Webhook app in your Slack workspace settings. "/> + <Hyperlink NavigateUri="https://api.slack.com/messaging/webhooks" + RequestNavigate="Hyperlink_RequestNavigate"> + Slack Webhooks Documentation + </Hyperlink> + </TextBlock> + + <!-- Generic webhook --> + <TextBlock Text="Generic Webhook (HTTP POST)" FontSize="14" FontWeight="SemiBold" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,20,0,0"/> + <TextBlock Text="POST alerts as JSON to any endpoint — PagerDuty, Opsgenie, n8n, or a GitHub repository_dispatch that re-runs a workflow. Use this to trigger automation when an alert fires (for example, re-adding your IP to an Azure SQL firewall rule after a connection is lost)." + FontSize="12" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,4,0,0" TextWrapping="Wrap"/> + <CheckBox x:Name="GenericWebhookEnabledCheckBox" Content="Enable generic webhook" Margin="0,8,0,0" + Foreground="{DynamicResource ForegroundBrush}" Checked="GenericWebhookEnabledCheckBox_Changed" Unchecked="GenericWebhookEnabledCheckBox_Changed"/> + <Grid Margin="20,6,0,0"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="110"/> + <ColumnDefinition Width="*"/> + </Grid.ColumnDefinitions> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + <TextBlock Grid.Row="0" Grid.Column="0" Text="URL:" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,8,4"/> + <TextBox Grid.Row="0" Grid.Column="1" x:Name="GenericWebhookUrlBox" Margin="0,0,0,4" + ToolTip="The endpoint to POST to, e.g. https://api.github.com/repos/OWNER/REPO/dispatches"/> + + <TextBlock Grid.Row="1" Grid.Column="0" Text="Headers (JSON):" VerticalAlignment="Top" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,4,8,4"/> + <TextBox Grid.Row="1" Grid.Column="1" x:Name="GenericWebhookHeadersBox" Margin="0,0,0,4" + AcceptsReturn="True" TextWrapping="Wrap" MinHeight="44" VerticalScrollBarVisibility="Auto" + ToolTip="A JSON object of request headers. Held as a secret in the control plane — it carries your token."/> + + <TextBlock Grid.Row="2" Grid.Column="0" Text="Body (JSON):" VerticalAlignment="Top" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,4,8,4"/> + <TextBox Grid.Row="2" Grid.Column="1" x:Name="GenericWebhookBodyBox" Margin="0,0,0,4" + AcceptsReturn="True" TextWrapping="Wrap" MinHeight="80" VerticalScrollBarVisibility="Auto" + FontFamily="Consolas" + ToolTip="The JSON body to POST. Leave blank to use the built-in default. Placeholders are substituted and JSON-escaped."/> + + <TextBlock Grid.Row="3" Grid.Column="0" Text="Proxy Address:" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,8,4"/> + <TextBox Grid.Row="3" Grid.Column="1" x:Name="GenericWebhookProxyAddressBox" Margin="0,0,0,4" + ToolTip="Optional HTTP proxy (e.g. http://proxy.corp:8080). Leave blank for direct connection."/> + + <StackPanel Grid.Row="4" Grid.Column="1" Orientation="Horizontal" Margin="0,4,0,0"> + <Button x:Name="TestGenericButton" Content="Send Test Notification" Click="TestGenericButton_Click"/> + <TextBlock x:Name="GenericStatusText" FontSize="11" FontStyle="Italic" + Foreground="{DynamicResource ForegroundMutedBrush}" VerticalAlignment="Center" Margin="8,0,0,0"/> + </StackPanel> + </Grid> + <TextBlock Margin="20,8,0,0" TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}"> + <Run Text="Placeholders (each is JSON-escaped when substituted): "/> + <Run FontFamily="Consolas" Text="{}{{metric}} {{server}} {{value}} {{threshold}} {{severity}} {{context}} {{timestamp}}"/> + </TextBlock> + <TextBlock Margin="20,6,0,0" TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}"> + <Run FontWeight="SemiBold" Text="Example — re-run a GitHub Actions workflow when an alert fires:"/> + <LineBreak/> + <Run FontFamily="Consolas" Text="URL: https://api.github.com/repos/OWNER/REPO/dispatches"/> + <LineBreak/> + <Run FontFamily="Consolas" Text="{}Headers: {"Authorization": "Bearer ghp_YOUR_TOKEN", "Accept": "application/vnd.github+json"}"/> + <LineBreak/> + <Run FontFamily="Consolas" Text="{}Body: {"event_type": "sql-monitor-alert", "client_payload": {"metric": "{{metric}}", "server": "{{server}}"}}"/> + <LineBreak/> + <Run Text="The repository's workflow triggers on "/> + <Run FontFamily="Consolas" Text="repository_dispatch"/> + <Run Text=". The token needs Contents: write on that repository."/> + </TextBlock> + + <!-- Severity Color Reference --> + <TextBlock Text="Alert Severity Colors" FontWeight="SemiBold" FontSize="12" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,16,0,4"/> + <TextBlock TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,0,0,2"> + <Run Foreground="#DC2626" FontWeight="Bold" Text="■ Red" /> — Critical (Deadlocks, Poison Waits, Server Unreachable) + </TextBlock> + <TextBlock TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,0,0,2"> + <Run Foreground="#D97706" FontWeight="Bold" Text="■ Orange" /> — Alert / Warning (Blocking, Long-Running Queries, tempdb, Jobs) + </TextBlock> + <TextBlock TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,0,0,2"> + <Run Foreground="#F59E0B" FontWeight="Bold" Text="■ Yellow" /> — Warning (High CPU) + </TextBlock> + <TextBlock TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,0,0,2"> + <Run Foreground="#16A34A" FontWeight="Bold" Text="■ Green" /> — Resolved (Server Restored) + </TextBlock> + <TextBlock TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}"> + <Run Foreground="{DynamicResource InfoBrush}" FontWeight="Bold" Text="■ Blue" /> — Info + </TextBlock> + </StackPanel> + </Border> + + <!-- Collection Schedule (control-plane editor) --> + <Border Grid.Row="8" Background="{DynamicResource BackgroundLightBrush}" CornerRadius="4" Padding="12" Margin="0,0,0,12"> + <StackPanel> + <TextBlock Text="Collection Schedule" FontSize="14" FontWeight="SemiBold" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBlock Text="Tune how often the Darling service collects each metric and how long it keeps it — fleet-wide or per server. Blank overrides fall back to the built-in defaults; presets bulk-set frequencies." + FontSize="12" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,6,0,0" TextWrapping="Wrap"/> + <Button x:Name="EditSchedulesButton" Content="Edit Collector Schedules..." HorizontalAlignment="Left" + Padding="12,4" Margin="0,8,0,0" Click="EditSchedulesButton_Click"/> + </StackPanel> + </Border> + + <!-- Buttons --> + <StackPanel Grid.Row="9" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0"> + <Button Content="Save" Click="SaveButton_Click" Style="{DynamicResource AccentButton}" Margin="0,0,8,0"/> + <Button Content="Close" Click="CloseButton_Click"/> + </StackPanel> + </Grid> + </ScrollViewer> +</Window> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml.cs new file mode 100644 index 000000000..0c29b74ea --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml.cs @@ -0,0 +1,1435 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Navigation; +using PerformanceMonitor.Notifications; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The viewer's full Settings window — a faithful, section-for-section port of Performance Monitor Lite's +/// <c>SettingsWindow</c>, now (Stage 3b) writing the CONTROL-PLANE store so the running Darling service honors +/// the operator's changes. The Notifications (alert engine + automated analysis), Email (SMTP), and Teams/Slack +/// sections write <c>config.config_alert_settings</c> + <c>config.config_notification</c>; the MCP toggle/port +/// and the global plan-capture flag write <c>config.config_service</c>; the Data Collection Pause/Resume button +/// drives a <c>pause</c>/<c>resume</c> command; and the Collection Schedule section opens the real per-server / +/// per-collector editor (<see cref="CollectorScheduleEditorWindow"/>) writing <c>config.config_collector_schedules</c>. +/// Each store write flows through <see cref="ViewerDataService"/> (the shared 42501 → <see +/// cref="ViewerReadOnlyException"/> gate); a read-only seat shows a banner and the writes degrade gracefully. +/// +/// <para>The genuinely viewer-LOCAL preferences stay in <see cref="ViewerAppSettings"/> / +/// <see cref="ViewerPreferences"/>: the default time range + auto-refresh, connection timeout, CSV separator, +/// timestamp-display mode, tray-minimize, the connection-change/LRQ-noise/delivery-mode/mute-default/dismissal- +/// logging toggles the service has no honored column for, and the theme (dark-only). Send Test Email / Send Test +/// Notification stay working — they render + send straight from the live UI via the shared, connection- +/// independent renderers, so the operator verifies exactly what they typed before saving.</para> +/// </summary> +public partial class SettingsWindow : Window +{ + /// <summary>Branding the shared email/webhook renderers stamp on test messages (mirrors the service's).</summary> + private static readonly AlertBranding s_branding = new("Performance Monitor Darling", null); + + private readonly ViewerAppSettingsStore _appSettingsStore; + private readonly ViewerAppSettings _appSettings; + private readonly ViewerDataService? _dataService; + private readonly IReadOnlyList<DarlingServer> _servers; + + /// <summary>The store's current SMTP blob, held so an unchanged (or undecryptable) password survives a Save + /// without being wiped — mirrors the server dialog's DPAPI "re-enter to change" handling.</summary> + private string? _loadedSmtpBlob; + + /// <summary>The service's paused state as last read from <c>config_service</c>, reflected on the button.</summary> + private bool _paused; + + /// <summary>Guards the theme combo's SelectionChanged from firing an Apply while <see cref="LoadColorTheme"/> + /// is seeding the selection.</summary> + private bool _isLoadingTheme; + + /// <summary>The theme active when the window opened, so a Cancel/close-without-save reverts the live preview.</summary> + private readonly string _originalTheme = ThemeManager.CurrentTheme; + + /// <summary>Set once the operator saves, so the close handlers do not revert the (now persisted) theme.</summary> + private bool _themeSaved; + + /// <summary> + /// True once the operator config was READ from the store without error. Save writes the store sections only + /// when this is set — so a transient read failure (which leaves the controls showing defaults) can never + /// clobber an operator-tuned store back to defaults. False when disconnected or a read threw. + /// </summary> + private bool _storeLoaded; + + /// <summary> + /// The edited viewer preferences (default time range + auto-refresh), populated on a successful Save so + /// <see cref="MainWindow"/> can refresh its in-memory copy that seeds newly-opened server tabs. Null until + /// Save succeeds (Close without saving leaves it null). + /// </summary> + public ViewerPreferences? Result { get; private set; } + + /// <param name="preferences">Current viewer preferences (the toolbar-seed defaults) to seed the controls.</param> + /// <param name="appSettingsStore">The store for the viewer-LOCAL preferences (operational settings now live in the control-plane store).</param> + /// <param name="dataService">The control-plane connection; null when not connected yet (store surfaces disabled).</param> + /// <param name="servers">The managed servers, for the collector-schedule editor's per-server scope.</param> + public SettingsWindow( + ViewerPreferences preferences, + ViewerAppSettingsStore appSettingsStore, + ViewerDataService? dataService, + IReadOnlyList<DarlingServer>? servers = null) + { + ArgumentNullException.ThrowIfNull(preferences); + ArgumentNullException.ThrowIfNull(appSettingsStore); + + InitializeComponent(); + + _appSettingsStore = appSettingsStore; + _appSettings = appSettingsStore.Load(); + _dataService = dataService; + _servers = servers ?? Array.Empty<DarlingServer>(); + + LoadViewerPreferences(preferences); + UpdateCollectionStatus(); + LoadMcpSettings(); + UpdateMcpStatus(); + LoadWebSettings(); + UpdateWebStatus(); + LoadConnectionTimeout(); + LoadNocRefreshInterval(); + LoadCsvSeparator(); + LoadTimeDisplayMode(); + LoadColorTheme(); + LoadViewerLocalAlertFields(); + SeedAlertControlsFrom(AlertSettingsRow.Defaults()); + SeedNotificationControlsFrom(NotificationRow.Defaults()); + /* Default the global plan-capture flag to the store's default (TRUE) so an unread/unseeded state never + shows it off — the store read overwrites it, and Save is guarded by _storeLoaded regardless. */ + CapturePlansCheckBox.IsChecked = true; + + /* Manage Mute Rules writes the shared Postgres config; needs a live store connection. */ + ManageMuteRulesButton.IsEnabled = _dataService is not null; + EditSchedulesButton.IsEnabled = _dataService is not null; + + /* Read the authoritative operator config from the store (async), then apply read-only gating. */ + Loaded += async (_, _) => await LoadFromStoreAsync(); + } + + /// <summary> + /// Overwrites the store-backed controls with the authoritative values from the control-plane store (the + /// alert engine + analysis, SMTP + webhooks, MCP + plan capture, and the paused state), leaving the + /// synchronous defaults in place if the store is unreachable or has not seeded a section yet. Then applies + /// read-only gating. Runs once on load. + /// </summary> + private async Task LoadFromStoreAsync() + { + if (_dataService is not null) + { + /* Read the three store-backed sections INDEPENDENTLY (D7): a read-only viewer role is column-denied + the config_notification secrets, so selecting them 42501s — and in a single shared try-block one + such throw blanks every LATER section. GetNotificationAsync already falls back to a secret-free + projection for a read-only seat, and this per-section isolation is the belt-and-suspenders that + keeps any other single-section failure from blanking the rest. */ + var sections = await ReadStoreSectionsAsync( + () => _dataService.GetAlertSettingsAsync(), + () => _dataService.GetNotificationAsync(), + () => _dataService.GetServiceConfigAsync()); + + if (sections.Alert is not null) + { + SeedAlertControlsFrom(sections.Alert); + } + + if (sections.Notification is not null) + { + SeedNotificationControlsFrom(sections.Notification); + } + + if (sections.Service is not null) + { + CapturePlansCheckBox.IsChecked = sections.Service.CapturePlans; + McpEnabledCheckBox.IsChecked = sections.Service.McpEnabled; + McpPortTextBox.Text = sections.Service.McpPort.ToString(CultureInfo.InvariantCulture); + WebEnabledCheckBox.IsChecked = sections.Service.WebEnabled; + WebPortTextBox.Text = sections.Service.WebPort.ToString(CultureInfo.InvariantCulture); + _paused = sections.Service.Paused; + } + + /* Save may write the store sections only when ALL THREE loaded. config_service is written LAST by + the service (its presence marks the seed complete), and on a seeded store the alert/notification + rows are present too — so requiring all three preserves the original anti-clobber guard while + tolerating a per-section read failure: any missing section leaves this false so Save declines + rather than overwriting a section it could not read with the on-screen defaults. (On a + reachable-but-UNSEEDED store every read returns null → false, exactly as before.) */ + _storeLoaded = sections.Alert is not null && sections.Notification is not null && sections.Service is not null; + } + + ApplyReadOnlyGating(); + UpdateCollectionStatus(); + UpdateMcpStatus(); + UpdateWebStatus(); + UpdateAlertControlStates(); + UpdateSmtpControlStates(); + UpdateTeamsControlStates(); + UpdateSlackControlStates(); + } + + /// <summary> + /// Reads the three control-plane Settings sections (alert engine, notification, service flags) + /// INDEPENDENTLY, so one section's failure — most importantly a read-only <c>viewer</c> seat's SQLSTATE + /// 42501 on the secret-bearing <c>config_notification</c> columns — degrades only THAT section to its + /// on-screen defaults instead of aborting a shared read and blanking the rest (D7). Each getter runs in its + /// own try/catch; a thrown or null result leaves that section null and its synchronous defaults stand. + /// Pure of WPF (the caller applies the results to controls), so it is unit-testable without a live store or + /// an STA window. + /// </summary> + internal static async Task<StoreSections> ReadStoreSectionsAsync( + Func<Task<AlertSettingsRow?>> getAlert, + Func<Task<NotificationRow?>> getNotification, + Func<Task<ServiceConfigRow?>> getService) + { + ArgumentNullException.ThrowIfNull(getAlert); + ArgumentNullException.ThrowIfNull(getNotification); + ArgumentNullException.ThrowIfNull(getService); + + return new StoreSections( + await TryReadSectionAsync(getAlert), + await TryReadSectionAsync(getNotification), + await TryReadSectionAsync(getService)); + } + + /// <summary>Runs one section read, degrading any non-cancellation failure (a read-only 42501, a transient + /// blip) to <c>null</c> so that section keeps its on-screen defaults; cancellation propagates.</summary> + private static async Task<T?> TryReadSectionAsync<T>(Func<Task<T?>> read) where T : class + { + try + { + return await read(); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + Debug.WriteLine($"SettingsWindow: a control-plane section could not be read, keeping its defaults: {ex.Message}"); + return null; + } + } + + /// <summary>The three independently-loaded Settings store sections; a null member means that section could + /// not be read (an unseeded store, or a per-section failure such as a read-only 42501) and its on-screen + /// defaults stand.</summary> + internal readonly record struct StoreSections( + AlertSettingsRow? Alert, NotificationRow? Notification, ServiceConfigRow? Service); + + /// <summary>Reflects a read-only seat: a banner + the operator-action buttons disabled. The threshold + /// inputs stay visible for reference; a Save attempt surfaces the friendly read-only message.</summary> + private void ApplyReadOnlyGating() + { + var readOnly = _dataService?.IsReadOnly == true; + SettingsReadOnlyBanner.Visibility = readOnly ? Visibility.Visible : Visibility.Collapsed; + if (readOnly) + { + PauseResumeButton.IsEnabled = false; + } + } + + // ── Viewer preferences (folded in from #1401: default time range + auto-refresh) ── + + private void LoadViewerPreferences(ViewerPreferences preferences) + { + /* Normalize so an out-of-range persisted index can never leave a combo unselected; the combos mirror + the toolbar's item order, so the stored index selects the matching row directly. */ + var normalized = new ViewerPreferences + { + DefaultTimeRangeIndex = preferences.DefaultTimeRangeIndex, + AutoRefreshEnabled = preferences.AutoRefreshEnabled, + AutoRefreshIntervalIndex = preferences.AutoRefreshIntervalIndex, + }.Normalize(); + + DefaultTimeRangeCombo.SelectedIndex = normalized.DefaultTimeRangeIndex; + AutoRefreshCheckBox.IsChecked = normalized.AutoRefreshEnabled; + AutoRefreshIntervalCombo.SelectedIndex = normalized.AutoRefreshIntervalIndex; + AutoRefreshIntervalCombo.IsEnabled = normalized.AutoRefreshEnabled; + } + + private ViewerPreferences BuildViewerPreferences() => new ViewerPreferences + { + DefaultTimeRangeIndex = DefaultTimeRangeCombo.SelectedIndex, + AutoRefreshEnabled = AutoRefreshCheckBox.IsChecked == true, + AutoRefreshIntervalIndex = AutoRefreshIntervalCombo.SelectedIndex, + }.Normalize(); + + private void AutoRefreshCheckBox_Toggled(object sender, RoutedEventArgs e) + { + /* Guard the load-time raise before the combo exists (Checked can fire during InitializeComponent). */ + if (AutoRefreshIntervalCombo is not null) + { + AutoRefreshIntervalCombo.IsEnabled = AutoRefreshCheckBox.IsChecked == true; + } + } + + // ── Data Collection (pause/resume drives the service via the command plane) ── + + private void UpdateCollectionStatus() + { + if (_dataService is null) + { + CollectionStatusText.Text = "Status: not connected to the Darling store"; + PauseResumeButton.IsEnabled = false; + PauseResumeButton.Content = "Pause Collection"; + return; + } + + CollectionStatusText.Text = _paused + ? "Status: Paused — the Darling service is not collecting" + : "Status: Collecting — managed by the Darling service"; + PauseResumeButton.Content = _paused ? "Resume Collection" : "Pause Collection"; + PauseResumeButton.IsEnabled = !_dataService.IsReadOnly; + } + + /// <summary>Enqueues a <c>pause</c>/<c>resume</c> command, waits for the result, then reflects the new + /// state. A read-only seat can't command the service (the button is disabled; a stray click no-ops).</summary> + private async void PauseResumeButton_Click(object sender, RoutedEventArgs e) + { + if (_dataService is null || _dataService.IsReadOnly) + { + return; + } + + var pausing = !_paused; + PauseResumeButton.IsEnabled = false; + PauseResumeButton.Content = pausing ? "Pausing..." : "Resuming..."; + try + { + var result = pausing + ? await _dataService.PauseServiceAsync() + : await _dataService.ResumeServiceAsync(); + + if (result is null) + { + CollectionStatusText.Text = "Status: command sent — the service has not confirmed yet"; + } + else if (result.Status == ViewerDataService.StatusSucceeded) + { + _paused = pausing; + } + else + { + MessageBox.Show( + $"The service could not {(pausing ? "pause" : "resume")} collection: {result.ResultStatus}", + "Data Collection", MessageBoxButton.OK, MessageBoxImage.Warning); + } + } + catch (ViewerReadOnlyException ex) + { + MessageBox.Show(ex.Message, "Read-only connection", MessageBoxButton.OK, MessageBoxImage.Warning); + } + catch (ViewerSchemaSkewException ex) + { + MessageBox.Show(ex.Message, "Store out of date", MessageBoxButton.OK, MessageBoxImage.Warning); + } + catch (Exception ex) + { + MessageBox.Show($"Could not reach the service: {ex.Message}", "Data Collection", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + UpdateCollectionStatus(); + } + } + + // ── MCP server (config_service.mcp_enabled / mcp_port) ── + + private void LoadMcpSettings() + { + /* Seed from the last-known viewer copy for an immediate, non-blank UI; the store read overwrites it. */ + McpEnabledCheckBox.IsChecked = _appSettings.McpEnabled; + McpPortTextBox.Text = _appSettings.McpPort.ToString(CultureInfo.InvariantCulture); + } + + private void UpdateMcpStatus() + { + McpStatusText.Text = McpEnabledCheckBox.IsChecked == true + ? "The Darling service hosts the MCP server; it applies on the service's next reload." + : "Status: Disabled"; + } + + // ── Web dashboard (config_service.web_enabled / web_port), #1562 — mirrors the MCP section above ── + + private void LoadWebSettings() + { + /* Seed from the last-known viewer copy for an immediate, non-blank UI; the store read overwrites it. */ + WebEnabledCheckBox.IsChecked = _appSettings.WebEnabled; + WebPortTextBox.Text = _appSettings.WebPort.ToString(CultureInfo.InvariantCulture); + } + + private void UpdateWebStatus() + { + WebStatusText.Text = WebEnabledCheckBox.IsChecked == true + ? "The Darling service hosts the web dashboard; it applies on the service's next reload." + : "Status: Disabled"; + } + + /// <summary>Validates the MCP + web dashboard ports and the (always-valid) capture-plans + enabled flags for + /// the <c>config_service</c> write. Returns false when an ENABLED surface has a bad port (a disabled surface + /// with a bad port keeps its last-known value and does not block the save).</summary> + private bool TryReadServiceFlags( + out bool capturePlans, out bool mcpEnabled, out int mcpPort, out bool webEnabled, out int webPort) + { + capturePlans = CapturePlansCheckBox.IsChecked == true; + mcpEnabled = McpEnabledCheckBox.IsChecked == true; + mcpPort = _appSettings.McpPort; + webEnabled = WebEnabledCheckBox.IsChecked == true; + webPort = _appSettings.WebPort; + + if (int.TryParse(McpPortTextBox.Text, out var parsedMcpPort) && parsedMcpPort is >= 1024 and <= 65535) + { + mcpPort = parsedMcpPort; + } + else if (mcpEnabled) + { + MessageBox.Show( + "MCP port must be between 1024 and 65535.\nPorts 0-1023 are well-known privileged ports reserved by the operating system.", + "Validation", MessageBoxButton.OK, MessageBoxImage.Warning); + return false; + } + + if (int.TryParse(WebPortTextBox.Text, out var parsedWebPort) && parsedWebPort is >= 1024 and <= 65535) + { + webPort = parsedWebPort; + } + else if (webEnabled) + { + MessageBox.Show( + "Web dashboard port must be between 1024 and 65535.\nPorts 0-1023 are well-known privileged ports reserved by the operating system.", + "Validation", MessageBoxButton.OK, MessageBoxImage.Warning); + return false; + } + + return true; + } + + private void CopyMcpCommandButton_Click(object sender, RoutedEventArgs e) + { + var port = McpPortTextBox.Text; + var command = $"claude mcp add --transport http --scope user sql-monitor-darling http://localhost:{port}/"; + /* SetDataObject with copy=false avoids WPF's problematic Clipboard.Flush(). */ + Clipboard.SetDataObject(command, false); + McpStatusText.Text = "Copied to clipboard!"; + } + + private void AutoPortButton_Click(object sender, RoutedEventArgs e) + { + try + { + McpPortTextBox.Text = FindFreeTcpPort().ToString(CultureInfo.InvariantCulture); + } + catch (Exception ex) + { + MessageBox.Show($"Could not find an available port: {ex.Message}", + "Error", MessageBoxButton.OK, MessageBoxImage.Warning); + } + } + + private void WebAutoPortButton_Click(object sender, RoutedEventArgs e) + { + try + { + WebPortTextBox.Text = FindFreeTcpPort().ToString(CultureInfo.InvariantCulture); + } + catch (Exception ex) + { + MessageBox.Show($"Could not find an available port: {ex.Message}", + "Error", MessageBoxButton.OK, MessageBoxImage.Warning); + } + } + + private void CopyWebUrlButton_Click(object sender, RoutedEventArgs e) + { + var url = $"http://localhost:{WebPortTextBox.Text}/"; + /* SetDataObject with copy=false avoids WPF's problematic Clipboard.Flush(). */ + Clipboard.SetDataObject(url, false); + WebStatusText.Text = "Copied to clipboard!"; + } + + /// <summary>Asks the OS for a free loopback TCP port (bind to port 0, read the assignment, release).</summary> + private static int FindFreeTcpPort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + finally + { + listener.Stop(); + } + } + + // ── Collection schedule (the real per-server / per-collector editor) ── + + private void EditSchedulesButton_Click(object sender, RoutedEventArgs e) + { + if (_dataService is null) + { + return; + } + + var editor = new CollectorScheduleEditorWindow(_dataService, _servers) { Owner = this }; + editor.ShowDialog(); + } + + // ── Viewer defaults (connection timeout, CSV separator, timestamp display) — viewer-local ── + + private void LoadConnectionTimeout() => + ConnectionTimeoutBox.Text = _appSettings.ConnectionTimeoutSeconds.ToString(CultureInfo.InvariantCulture); + + private void SaveConnectionTimeout() + { + if (int.TryParse(ConnectionTimeoutBox.Text, out var timeout) && timeout is >= 5 and <= 60) + { + _appSettings.ConnectionTimeoutSeconds = timeout; + } + } + + /// <summary>Selects the combo row whose Tag matches the persisted fleet-refresh interval; falls back to the + /// 30-second default when the stored value isn't one of the presets.</summary> + private void LoadNocRefreshInterval() + { + foreach (ComboBoxItem item in NocRefreshIntervalCombo.Items) + { + if (item.Tag is string tag + && int.TryParse(tag, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds) + && seconds == _appSettings.NocRefreshIntervalSeconds) + { + NocRefreshIntervalCombo.SelectedItem = item; + break; + } + } + + if (NocRefreshIntervalCombo.SelectedItem == null) + { + NocRefreshIntervalCombo.SelectedIndex = 1; // 30 seconds (the default) + } + } + + private void SaveNocRefreshInterval() + { + if (NocRefreshIntervalCombo.SelectedItem is ComboBoxItem { Tag: string tag } + && int.TryParse(tag, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds)) + { + _appSettings.NocRefreshIntervalSeconds = seconds; + } + } + + private void LoadCsvSeparator() + { + foreach (ComboBoxItem item in CsvSeparatorCombo.Items) + { + if (item.Tag?.ToString() == _appSettings.CsvSeparator) + { + CsvSeparatorCombo.SelectedItem = item; + break; + } + } + if (CsvSeparatorCombo.SelectedItem == null) + { + CsvSeparatorCombo.SelectedIndex = 0; + } + } + + private void SaveCsvSeparator() + { + if (CsvSeparatorCombo.SelectedItem is ComboBoxItem { Tag: string sep }) + { + _appSettings.CsvSeparator = sep; + } + } + + private void LoadTimeDisplayMode() + { + foreach (ComboBoxItem item in TimeDisplayModeCombo.Items) + { + if (item.Tag?.ToString() == _appSettings.TimeDisplayMode) + { + TimeDisplayModeCombo.SelectedItem = item; + break; + } + } + if (TimeDisplayModeCombo.SelectedItem == null) + { + TimeDisplayModeCombo.SelectedIndex = 0; + } + } + + private void SaveTimeDisplayMode() + { + if (TimeDisplayModeCombo.SelectedItem is ComboBoxItem { Tag: string mode }) + { + _appSettings.TimeDisplayMode = mode; + } + } + + // ── Color theme (viewer-local; live-previewed via the shared ThemeManager, mirrors Lite) ── + + /// <summary>Live-applies the picked theme so the operator sees Dark / Light / CoolBreeze immediately. + /// A Cancel/close-without-save reverts to <see cref="_originalTheme"/>; Save persists it.</summary> + private void ColorThemeCombo_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isLoadingTheme) + { + return; + } + + if (ColorThemeCombo.SelectedItem is ComboBoxItem { Tag: string theme }) + { + ThemeManager.Apply(theme); + } + } + + private void LoadColorTheme() + { + _isLoadingTheme = true; + foreach (ComboBoxItem item in ColorThemeCombo.Items) + { + if (item.Tag?.ToString() == _appSettings.ColorTheme) + { + ColorThemeCombo.SelectedItem = item; + break; + } + } + if (ColorThemeCombo.SelectedItem == null) + { + ColorThemeCombo.SelectedIndex = 0; + } + _isLoadingTheme = false; + } + + private void SaveColorTheme() + { + if (ColorThemeCombo.SelectedItem is ComboBoxItem { Tag: string theme }) + { + _appSettings.ColorTheme = theme; + ThemeManager.Apply(theme); + } + _themeSaved = true; + } + + // ── Notifications / alert thresholds ── + + /// <summary>Seeds the viewer-LOCAL alert fields (no honored store column: tray minimize, mute-rule default, + /// dismissal logging).</summary> + private void LoadViewerLocalAlertFields() + { + MinimizeToTrayCheckBox.IsChecked = _appSettings.MinimizeToTray; + /* AlertDeliveryMode/PerEventMax (#1141/#1236), the long-running-query read shape (max-results + the five + noise filters), and connection-change notify (V20) moved to the STORE-backed controls + (SeedAlertControlsFrom / BuildAlertRowFromControls) now that the service honors them; no longer viewer-local. */ + MuteRuleDefaultExpirationCombo.SelectedIndex = _appSettings.MuteRuleDefaultExpiration switch + { + "1 hour" => 0, + "24 hours" => 1, + "7 days" => 2, + _ => 3 + }; + LogAlertDismissalsCheckBox.IsChecked = _appSettings.LogAlertDismissals; + } + + /// <summary>Seeds the STORE-backed alert + analysis controls from a <see cref="AlertSettingsRow"/> (the + /// store's authoritative values, or defaults before the store read completes).</summary> + private void SeedAlertControlsFrom(AlertSettingsRow r) + { + AlertsEnabledCheckBox.IsChecked = r.Enabled; + /* V20: connection-change notify is store-backed now (the service gates the connect edge on it). */ + NotifyConnectionCheckBox.IsChecked = r.NotifyConnectionChanges; + AlertCpuCheckBox.IsChecked = r.CpuEnabled; + AlertCpuThresholdBox.Text = r.CpuThresholdPercent.ToString(CultureInfo.InvariantCulture); + AlertCpuModeBox.SelectedIndex = ViewerDataService.MapCpuModeFromStore(r.CpuMode) == "SqlOnly" ? 1 : 0; + AlertBlockingCheckBox.IsChecked = r.BlockingEnabled; + AlertBlockingThresholdBox.Text = r.BlockingCountThreshold.ToString(CultureInfo.InvariantCulture); + AlertDeadlockCheckBox.IsChecked = r.DeadlockEnabled; + AlertDeadlockThresholdBox.Text = r.DeadlockCountThreshold.ToString(CultureInfo.InvariantCulture); + AlertPoisonWaitCheckBox.IsChecked = r.PoisonWaitEnabled; + AlertPoisonWaitThresholdBox.Text = r.PoisonWaitThresholdMs.ToString(CultureInfo.InvariantCulture); + AlertLongRunningQueryCheckBox.IsChecked = r.LongRunningQueryEnabled; + AlertLongRunningQueryThresholdBox.Text = r.LongRunningQueryThresholdMinutes.ToString(CultureInfo.InvariantCulture); + /* V20: the long-running-query read shape (max-results + the five noise filters) is store-backed now. */ + AlertLongRunningQueryMaxResultsBox.Text = r.LongRunningQueryMaxResults.ToString(CultureInfo.InvariantCulture); + LrqExcludeSpServerDiagnosticsCheckBox.IsChecked = r.LongRunningQueryExcludeSpServerDiagnostics; + LrqExcludeWaitForCheckBox.IsChecked = r.LongRunningQueryExcludeWaitFor; + LrqExcludeBackupsCheckBox.IsChecked = r.LongRunningQueryExcludeBackups; + LrqExcludeMiscWaitsCheckBox.IsChecked = r.LongRunningQueryExcludeMiscWaits; + LrqExcludeCdcCheckBox.IsChecked = r.LongRunningQueryExcludeCdc; + AlertExcludedDatabasesBox.Text = string.Join(", ", r.ExcludedDatabases); + AlertTempDbSpaceCheckBox.IsChecked = r.TempDbSpaceEnabled; + AlertTempDbSpaceThresholdBox.Text = r.TempDbSpaceThresholdPercent.ToString(CultureInfo.InvariantCulture); + AlertLowDiskCheckBox.IsChecked = r.LowDiskEnabled; + AlertLowDiskThresholdPercentBox.Text = r.LowDiskThresholdPercent.ToString(CultureInfo.InvariantCulture); + AlertLowDiskThresholdGbBox.Text = r.LowDiskThresholdGb.ToString(CultureInfo.InvariantCulture); + AlertLongRunningJobCheckBox.IsChecked = r.LongRunningJobEnabled; + AlertLongRunningJobMultiplierBox.Text = r.LongRunningJobMultiplier.ToString(CultureInfo.InvariantCulture); + AlertFailedJobCheckBox.IsChecked = r.FailedJobEnabled; + AlertFailedJobLookbackBox.Text = r.FailedJobLookbackMinutes.ToString(CultureInfo.InvariantCulture); + AlertCooldownBox.Text = r.CooldownMinutes.ToString(CultureInfo.InvariantCulture); + AnalysisEnabledCheckBox.IsChecked = r.AnalysisEnabled; + AnalysisIntervalBox.Text = r.AnalysisIntervalMinutes.ToString(CultureInfo.InvariantCulture); + AnalysisNotificationsCheckBox.IsChecked = r.AnalysisNotificationsEnabled; + AnalysisNotifySeverityBox.Text = r.AnalysisNotifySeverity.ToString("0.0", CultureInfo.InvariantCulture); + /* #1141/#1236: the delivery mode + per-event cap are now STORE-backed (the service honors them), + seeded from the row like every other alert-engine control. */ + AlertDeliveryModeBox.SelectedIndex = r.DeliveryMode == "PerEvent" ? 1 : 0; + AlertPerEventMaxBox.Text = r.PerEventMax.ToString(CultureInfo.InvariantCulture); + UpdateAlertControlStates(); + } + + /// <summary>Builds the store row from the alert + analysis controls, validating the numeric fields. Adds + /// range errors to <paramref name="errors"/>; a bad field keeps its default rather than throwing.</summary> + private AlertSettingsRow BuildAlertRowFromControls(List<string> errors) + { + var row = new AlertSettingsRow + { + Enabled = AlertsEnabledCheckBox.IsChecked == true, + NotifyConnectionChanges = NotifyConnectionCheckBox.IsChecked == true, + CpuEnabled = AlertCpuCheckBox.IsChecked == true, + CpuMode = ViewerDataService.MapCpuModeToStore((AlertCpuModeBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "Total"), + BlockingEnabled = AlertBlockingCheckBox.IsChecked == true, + DeadlockEnabled = AlertDeadlockCheckBox.IsChecked == true, + PoisonWaitEnabled = AlertPoisonWaitCheckBox.IsChecked == true, + LongRunningQueryEnabled = AlertLongRunningQueryCheckBox.IsChecked == true, + /* V20 long-running-query read shape (the five noise-filter opt-outs; max-results parsed below). */ + LongRunningQueryExcludeSpServerDiagnostics = LrqExcludeSpServerDiagnosticsCheckBox.IsChecked == true, + LongRunningQueryExcludeWaitFor = LrqExcludeWaitForCheckBox.IsChecked == true, + LongRunningQueryExcludeBackups = LrqExcludeBackupsCheckBox.IsChecked == true, + LongRunningQueryExcludeMiscWaits = LrqExcludeMiscWaitsCheckBox.IsChecked == true, + LongRunningQueryExcludeCdc = LrqExcludeCdcCheckBox.IsChecked == true, + TempDbSpaceEnabled = AlertTempDbSpaceCheckBox.IsChecked == true, + LowDiskEnabled = AlertLowDiskCheckBox.IsChecked == true, + LongRunningJobEnabled = AlertLongRunningJobCheckBox.IsChecked == true, + FailedJobEnabled = AlertFailedJobCheckBox.IsChecked == true, + AnalysisEnabled = AnalysisEnabledCheckBox.IsChecked == true, + AnalysisNotificationsEnabled = AnalysisNotificationsCheckBox.IsChecked == true, + ExcludedDatabases = AlertExcludedDatabasesBox.Text + .Split(',') + .Select(s => s.Trim()) + .Where(s => s.Length > 0) + .ToList(), + }; + + if (int.TryParse(AlertCpuThresholdBox.Text, out var cpu) && cpu is > 0 and <= 100) + row.CpuThresholdPercent = cpu; + if (int.TryParse(AlertBlockingThresholdBox.Text, out var blocking) && blocking > 0) + row.BlockingCountThreshold = blocking; + if (int.TryParse(AlertDeadlockThresholdBox.Text, out var deadlock) && deadlock > 0) + row.DeadlockCountThreshold = deadlock; + if (int.TryParse(AlertPoisonWaitThresholdBox.Text, out var poisonWait) && poisonWait > 0) + row.PoisonWaitThresholdMs = poisonWait; + if (int.TryParse(AlertLongRunningQueryThresholdBox.Text, out var lrq) && lrq > 0) + row.LongRunningQueryThresholdMinutes = lrq; + /* V20: max-results (the read adapter re-clamps 1–1000; a bad value keeps the default 5). */ + if (int.TryParse(AlertLongRunningQueryMaxResultsBox.Text, out var lrqMax) && lrqMax is >= 1 and <= 1000) + row.LongRunningQueryMaxResults = lrqMax; + if (int.TryParse(AlertTempDbSpaceThresholdBox.Text, out var tempDb) && tempDb is > 0 and <= 100) + row.TempDbSpaceThresholdPercent = tempDb; + if (int.TryParse(AlertLowDiskThresholdPercentBox.Text, out var lowDiskPct) && lowDiskPct is >= 0 and <= 100) + row.LowDiskThresholdPercent = lowDiskPct; + if (int.TryParse(AlertLowDiskThresholdGbBox.Text, out var lowDiskGb) && lowDiskGb >= 0) + row.LowDiskThresholdGb = lowDiskGb; + if (int.TryParse(AlertLongRunningJobMultiplierBox.Text, out var jobMult) && jobMult is >= 2 and <= 20) + row.LongRunningJobMultiplier = jobMult; + if (int.TryParse(AlertFailedJobLookbackBox.Text, out var failedJobLookback) && failedJobLookback is >= 1 and <= 1440) + row.FailedJobLookbackMinutes = failedJobLookback; + + if (int.TryParse(AlertCooldownBox.Text, out var alertCooldown) && alertCooldown is >= 1 and <= 120) + row.CooldownMinutes = alertCooldown; + else + errors.Add("Tray notification cooldown must be between 1 and 120 minutes."); + + if (int.TryParse(AnalysisIntervalBox.Text, out var analysisInterval) && analysisInterval is >= 5 and <= 360) + row.AnalysisIntervalMinutes = analysisInterval; + else + errors.Add("Analysis interval must be between 5 and 360 minutes."); + + if (double.TryParse(AnalysisNotifySeverityBox.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var analysisSeverity) + && analysisSeverity is >= 0.0 and <= 2.0) + row.AnalysisNotifySeverity = analysisSeverity; + else + errors.Add("Analysis notify severity must be between 0.0 and 2.0."); + + /* #1141/#1236: delivery mode + per-event cap (store-backed). */ + row.DeliveryMode = AlertDeliveryModeBox.SelectedIndex == 1 ? "PerEvent" : "Summary"; + if (int.TryParse(AlertPerEventMaxBox.Text, out var perEventMax) && perEventMax is >= 1 and <= 100) + row.PerEventMax = perEventMax; + else + errors.Add("Per-event max-per-cycle must be between 1 and 100."); + + return row; + } + + /// <summary>Persists the viewer-LOCAL alert fields to <see cref="ViewerAppSettings"/> (the store never sees them).</summary> + private void SaveViewerLocalAlertFields(List<string> errors) + { + _appSettings.MinimizeToTray = MinimizeToTrayCheckBox.IsChecked == true; + /* AlertDeliveryMode/PerEventMax, the long-running-query read shape (max-results + the five noise filters), + and connection-change notify (V20) are STORE-backed now (BuildAlertRowFromControls writes them); not here. */ + _appSettings.MuteRuleDefaultExpiration = (MuteRuleDefaultExpirationCombo.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "24 hours"; + _appSettings.LogAlertDismissals = LogAlertDismissalsCheckBox.IsChecked == true; + } + + private void AlertsEnabledCheckBox_Changed(object sender, RoutedEventArgs e) => UpdateAlertControlStates(); + + private void RestoreAlertDefaultsButton_Click(object sender, RoutedEventArgs e) + { + AlertCpuThresholdBox.Text = "80"; + AlertCpuModeBox.SelectedIndex = 0; // Total + AlertBlockingThresholdBox.Text = "1"; + AlertDeadlockThresholdBox.Text = "1"; + AlertPoisonWaitThresholdBox.Text = "500"; + AlertLongRunningQueryThresholdBox.Text = "30"; + /* V20: the long-running-query read shape resets to Lite's App defaults (5 rows, every filter on). */ + AlertLongRunningQueryMaxResultsBox.Text = "5"; + LrqExcludeSpServerDiagnosticsCheckBox.IsChecked = true; + LrqExcludeWaitForCheckBox.IsChecked = true; + LrqExcludeBackupsCheckBox.IsChecked = true; + LrqExcludeMiscWaitsCheckBox.IsChecked = true; + LrqExcludeCdcCheckBox.IsChecked = true; + AlertTempDbSpaceThresholdBox.Text = "80"; + AlertLowDiskThresholdPercentBox.Text = "10"; + AlertLowDiskThresholdGbBox.Text = "5"; + AlertLongRunningJobMultiplierBox.Text = "3"; + AlertFailedJobLookbackBox.Text = "60"; + AlertCooldownBox.Text = "5"; + EmailCooldownBox.Text = "15"; + AlertDeliveryModeBox.SelectedIndex = 0; + AlertPerEventMaxBox.Text = "5"; + AnalysisIntervalBox.Text = "30"; + AnalysisNotifySeverityBox.Text = "1.5"; + AlertExcludedDatabasesBox.Text = ""; + MuteRuleDefaultExpirationCombo.SelectedIndex = 1; // 24 hours + UpdateAlertPreviewText(); + } + + private void ManageMuteRulesButton_Click(object sender, RoutedEventArgs e) + { + if (_dataService is null) + { + return; + } + + var window = new MuteRulesWindow(_dataService) { Owner = this }; + window.ShowDialog(); + } + + private void UpdateAlertPreviewText() + { + var parts = new List<string>(); + + if (AlertCpuCheckBox.IsChecked == true) + { + var cpuLabel = AlertCpuModeBox.SelectedIndex == 1 ? "SQL CPU" : "Total CPU"; + parts.Add($"{cpuLabel} > {AlertCpuThresholdBox.Text}%"); + } + if (AlertBlockingCheckBox.IsChecked == true) + parts.Add($"blocking >= {AlertBlockingThresholdBox.Text}"); + if (AlertDeadlockCheckBox.IsChecked == true) + parts.Add($"deadlocks >= {AlertDeadlockThresholdBox.Text}"); + if (AlertPoisonWaitCheckBox.IsChecked == true) + parts.Add($"poison waits >= {AlertPoisonWaitThresholdBox.Text}ms avg"); + if (AlertLongRunningQueryCheckBox.IsChecked == true) + parts.Add($"queries > {AlertLongRunningQueryThresholdBox.Text}min"); + if (AlertTempDbSpaceCheckBox.IsChecked == true) + parts.Add($"tempdb > {AlertTempDbSpaceThresholdBox.Text}%"); + if (AlertLowDiskCheckBox.IsChecked == true) + parts.Add($"disk free < {AlertLowDiskThresholdPercentBox.Text}% or {AlertLowDiskThresholdGbBox.Text}GB"); + if (AlertLongRunningJobCheckBox.IsChecked == true) + parts.Add($"jobs > {AlertLongRunningJobMultiplierBox.Text}x avg"); + if (AlertFailedJobCheckBox.IsChecked == true) + parts.Add($"failed jobs (last {AlertFailedJobLookbackBox.Text}m)"); + + AlertPreviewText.Text = parts.Count > 0 + ? $"Will alert when: {string.Join(", ", parts)}" + : "No alerts enabled"; + } + + private void UpdateAlertControlStates() + { + var enabled = AlertsEnabledCheckBox.IsChecked == true; + NotifyConnectionCheckBox.IsEnabled = enabled; + AlertCpuCheckBox.IsEnabled = enabled; + AlertCpuThresholdBox.IsEnabled = enabled; + AlertCpuModeBox.IsEnabled = enabled; + AlertBlockingCheckBox.IsEnabled = enabled; + AlertBlockingThresholdBox.IsEnabled = enabled; + AlertDeadlockCheckBox.IsEnabled = enabled; + AlertDeadlockThresholdBox.IsEnabled = enabled; + AlertPoisonWaitCheckBox.IsEnabled = enabled; + AlertPoisonWaitThresholdBox.IsEnabled = enabled; + AlertLongRunningQueryCheckBox.IsEnabled = enabled; + AlertLongRunningQueryThresholdBox.IsEnabled = enabled; + /* V20 long-running-query read-shape controls follow the master switch like the rest of the engine. */ + AlertLongRunningQueryMaxResultsBox.IsEnabled = enabled; + LrqExcludeSpServerDiagnosticsCheckBox.IsEnabled = enabled; + LrqExcludeWaitForCheckBox.IsEnabled = enabled; + LrqExcludeBackupsCheckBox.IsEnabled = enabled; + LrqExcludeMiscWaitsCheckBox.IsEnabled = enabled; + LrqExcludeCdcCheckBox.IsEnabled = enabled; + AlertTempDbSpaceCheckBox.IsEnabled = enabled; + AlertTempDbSpaceThresholdBox.IsEnabled = enabled; + AlertLowDiskCheckBox.IsEnabled = enabled; + AlertLowDiskThresholdPercentBox.IsEnabled = enabled; + AlertLowDiskThresholdGbBox.IsEnabled = enabled; + AlertLongRunningJobCheckBox.IsEnabled = enabled; + AlertLongRunningJobMultiplierBox.IsEnabled = enabled; + AlertFailedJobCheckBox.IsEnabled = enabled; + AlertFailedJobLookbackBox.IsEnabled = enabled; + UpdateAlertPreviewText(); + } + + // ── SMTP email + webhooks (config_notification) ── + + /// <summary>Seeds the SMTP + Teams/Slack controls from a <see cref="NotificationRow"/> (the store's values, + /// or defaults). The SMTP enable toggle reflects whether the store carries connect fields; the password box + /// is prefilled from the decrypted store blob (blank + remembered when it was sealed on another machine).</summary> + private void SeedNotificationControlsFrom(NotificationRow r) + { + var smtpConfigured = !string.IsNullOrWhiteSpace(r.SmtpHost) + || !string.IsNullOrWhiteSpace(r.SmtpFromAddress) + || !string.IsNullOrWhiteSpace(r.SmtpRecipients); + SmtpEnabledCheckBox.IsChecked = smtpConfigured; + SmtpServerBox.Text = r.SmtpHost; + SmtpPortBox.Text = r.SmtpPort.ToString(CultureInfo.InvariantCulture); + SmtpSslCheckBox.IsChecked = r.SmtpUseSsl; + SmtpUsernameBox.Text = r.SmtpUsername ?? ""; + SmtpFromBox.Text = r.SmtpFromAddress; + SmtpRecipientsBox.Text = r.SmtpRecipients; + EmailCooldownBox.Text = r.EmailCooldownMinutes.ToString(CultureInfo.InvariantCulture); + + _loadedSmtpBlob = r.SmtpEncryptedPassword; + SmtpPasswordBox.Password = OperatingSystem.IsWindows() + ? ViewerServerSecret.TryUnprotect(r.SmtpEncryptedPassword) ?? "" + : ""; + + TeamsWebhookEnabledCheckBox.IsChecked = !string.IsNullOrWhiteSpace(r.TeamsUrl); + TeamsWebhookUrlBox.Text = r.TeamsUrl; + TeamsProxyAddressBox.Text = r.TeamsProxy; + SlackWebhookEnabledCheckBox.IsChecked = !string.IsNullOrWhiteSpace(r.SlackUrl); + SlackWebhookUrlBox.Text = r.SlackUrl; + SlackProxyAddressBox.Text = r.SlackProxy; + + GenericWebhookEnabledCheckBox.IsChecked = !string.IsNullOrWhiteSpace(r.GenericUrl); + GenericWebhookUrlBox.Text = r.GenericUrl; + GenericWebhookHeadersBox.Text = r.GenericHeaders; + /* Blank means "use the built-in default" — show it, so the operator has something to edit rather + than a blank box whose shape they have to guess. */ + GenericWebhookBodyBox.Text = string.IsNullOrWhiteSpace(r.GenericBodyTemplate) + ? WebhookAlertService.DefaultGenericBodyTemplate + : r.GenericBodyTemplate; + GenericWebhookProxyAddressBox.Text = r.GenericProxy; + + UpdateSmtpControlStates(); + UpdateTeamsControlStates(); + UpdateSlackControlStates(); + UpdateGenericControlStates(); + } + + /// <summary>Builds the <see cref="NotificationRow"/> from the SMTP + webhook controls. A DISABLED channel + /// writes EMPTY key fields so the service (which derives enablement from non-empty fields) treats it as off; + /// an enabled SMTP channel seals the typed password (or preserves the existing blob when left unchanged).</summary> + private NotificationRow BuildNotificationRowFromControls(List<string> errors) + { + var row = new NotificationRow(); + + if (int.TryParse(EmailCooldownBox.Text, out var emailCooldown) && emailCooldown is >= 1 and <= 120) + row.EmailCooldownMinutes = emailCooldown; + else + errors.Add("Email alert cooldown must be between 1 and 120 minutes."); + + if (SmtpEnabledCheckBox.IsChecked == true) + { + row.SmtpHost = SmtpServerBox.Text?.Trim() ?? ""; + if (int.TryParse(SmtpPortBox.Text, out var port) && port is > 0 and < 65536) + row.SmtpPort = port; + row.SmtpUseSsl = SmtpSslCheckBox.IsChecked == true; + var username = SmtpUsernameBox.Text?.Trim(); + row.SmtpUsername = string.IsNullOrWhiteSpace(username) ? null : username; + row.SmtpFromAddress = SmtpFromBox.Text?.Trim() ?? ""; + row.SmtpRecipients = SmtpRecipientsBox.Text?.Trim() ?? ""; + row.SmtpEncryptedPassword = ResolveSmtpBlob(); + } + + if (TeamsWebhookEnabledCheckBox.IsChecked == true) + { + row.TeamsUrl = TeamsWebhookUrlBox.Text?.Trim() ?? ""; + row.TeamsProxy = TeamsProxyAddressBox.Text?.Trim() ?? ""; + } + + if (SlackWebhookEnabledCheckBox.IsChecked == true) + { + row.SlackUrl = SlackWebhookUrlBox.Text?.Trim() ?? ""; + row.SlackProxy = SlackProxyAddressBox.Text?.Trim() ?? ""; + } + + if (GenericWebhookEnabledCheckBox.IsChecked == true) + { + row.GenericUrl = GenericWebhookUrlBox.Text?.Trim() ?? ""; + row.GenericHeaders = GenericWebhookHeadersBox.Text?.Trim() ?? ""; + /* Persist the empty "use built-in default" sentinel unless the operator actually edited the body box + (the Settings load pre-fills it with the default), so a future release can still improve it. */ + row.GenericBodyTemplate = WebhookAlertService.IsDefaultBodyTemplate(GenericWebhookBodyBox.Text) + ? "" + : GenericWebhookBodyBox.Text?.Trim() ?? ""; + row.GenericProxy = GenericWebhookProxyAddressBox.Text?.Trim() ?? ""; + + /* A malformed headers JSON / body template would let the service accept the channel and then + drop every alert with only a log line to show for it — block the Save instead (#1506). */ + var configError = WebhookAlertService.ValidateGenericConfig(row.GenericHeaders, row.GenericBodyTemplate); + if (configError != null) + { + errors.Add(configError); + } + } + + return row; + } + + /// <summary>The SMTP blob to persist: seal the typed password when the box holds one; otherwise keep the + /// store's existing blob (so an unchanged — or another-machine, undecryptable — password survives Save).</summary> + private string? ResolveSmtpBlob() + { + var typed = SmtpPasswordBox.Password; + if (!string.IsNullOrEmpty(typed) && OperatingSystem.IsWindows()) + { + return ViewerServerSecret.Protect(typed); + } + + return string.IsNullOrEmpty(typed) ? _loadedSmtpBlob : null; + } + + private void SmtpEnabledCheckBox_Changed(object sender, RoutedEventArgs e) => UpdateSmtpControlStates(); + + private void UpdateSmtpControlStates() + { + var enabled = SmtpEnabledCheckBox.IsChecked == true; + SmtpServerBox.IsEnabled = enabled; + SmtpPortBox.IsEnabled = enabled; + SmtpSslCheckBox.IsEnabled = enabled; + SmtpUsernameBox.IsEnabled = enabled; + SmtpPasswordBox.IsEnabled = enabled; + SmtpFromBox.IsEnabled = enabled; + SmtpRecipientsBox.IsEnabled = enabled; + TestEmailButton.IsEnabled = enabled; + ValidateSmtpButton.IsEnabled = enabled; + } + + private void ValidateSmtpButton_Click(object sender, RoutedEventArgs e) + { + var errors = new List<string>(); + + if (string.IsNullOrWhiteSpace(SmtpServerBox.Text)) + errors.Add("SMTP server is required"); + if (!int.TryParse(SmtpPortBox.Text, out var port) || port is < 1 or > 65535) + errors.Add("Port must be between 1 and 65535"); + if (string.IsNullOrWhiteSpace(SmtpFromBox.Text)) + errors.Add("From address is required"); + else if (!SmtpFromBox.Text.Trim().Contains('@')) + errors.Add("From address must be a valid email"); + if (string.IsNullOrWhiteSpace(SmtpRecipientsBox.Text)) + errors.Add("At least one recipient is required"); + + if (errors.Count == 0) + { + SmtpStatusText.Text = "Settings look good. Use 'Send Test Email' to verify delivery."; + } + else + { + SmtpStatusText.Text = ""; + MessageBox.Show( + "SMTP configuration has issues:\n\n" + string.Join("\n", errors), + "SMTP Validation", MessageBoxButton.OK, MessageBoxImage.Warning); + } + } + + private async void TestEmailButton_Click(object sender, RoutedEventArgs e) + { + TestEmailButton.IsEnabled = false; + TestEmailButton.Content = "Sending..."; + + try + { + /* Build the test settings straight from the live UI (test before save), so the user verifies + exactly what they typed. The shared EmailSendCore renders + sends — no store/service needed. */ + var settings = TestAlertSettings.FromUi(this); + var error = await EmailSendCore.SendTestEmailAsync(settings, s_branding); + if (error == null) + { + MessageBox.Show("Test email sent successfully!", "Test Email", MessageBoxButton.OK, MessageBoxImage.Information); + } + else + { + MessageBox.Show($"Failed to send test email:\n\n{error}", "Test Email Failed", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + finally + { + TestEmailButton.Content = "Send Test Email"; + TestEmailButton.IsEnabled = true; + } + } + + private void TeamsWebhookEnabledCheckBox_Changed(object sender, RoutedEventArgs e) => UpdateTeamsControlStates(); + + private void SlackWebhookEnabledCheckBox_Changed(object sender, RoutedEventArgs e) => UpdateSlackControlStates(); + + private void UpdateTeamsControlStates() + { + var enabled = TeamsWebhookEnabledCheckBox.IsChecked == true; + TeamsWebhookUrlBox.IsEnabled = enabled; + TeamsProxyAddressBox.IsEnabled = enabled; + TestTeamsButton.IsEnabled = enabled; + } + + private void UpdateSlackControlStates() + { + var enabled = SlackWebhookEnabledCheckBox.IsChecked == true; + SlackWebhookUrlBox.IsEnabled = enabled; + SlackProxyAddressBox.IsEnabled = enabled; + TestSlackButton.IsEnabled = enabled; + } + + private void GenericWebhookEnabledCheckBox_Changed(object sender, RoutedEventArgs e) => UpdateGenericControlStates(); + + private void UpdateGenericControlStates() + { + var enabled = GenericWebhookEnabledCheckBox.IsChecked == true; + GenericWebhookUrlBox.IsEnabled = enabled; + GenericWebhookHeadersBox.IsEnabled = enabled; + GenericWebhookBodyBox.IsEnabled = enabled; + GenericWebhookProxyAddressBox.IsEnabled = enabled; + TestGenericButton.IsEnabled = enabled; + } + + private async void TestTeamsButton_Click(object sender, RoutedEventArgs e) + { + TestTeamsButton.IsEnabled = false; + TestTeamsButton.Content = "Sending..."; + + try + { + var url = TeamsWebhookUrlBox.Text?.Trim() ?? ""; + var proxy = TeamsProxyAddressBox.Text?.Trim(); + var error = await WebhookAlertService.SendTestTeamsAsync(url, proxy, s_branding); + + if (error == null) + { + MessageBox.Show("Teams test notification sent successfully!", "Test Webhook", MessageBoxButton.OK, MessageBoxImage.Information); + } + else + { + MessageBox.Show($"Failed to send Teams test notification:\n\n{error}", "Test Webhook Failed", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + catch (Exception ex) + { + MessageBox.Show($"Failed to send Teams test notification:\n\n{ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + TestTeamsButton.Content = "Send Test Notification"; + TestTeamsButton.IsEnabled = true; + } + } + + private async void TestSlackButton_Click(object sender, RoutedEventArgs e) + { + TestSlackButton.IsEnabled = false; + TestSlackButton.Content = "Sending..."; + + try + { + var url = SlackWebhookUrlBox.Text?.Trim() ?? ""; + var proxy = SlackProxyAddressBox.Text?.Trim(); + var error = await WebhookAlertService.SendTestSlackAsync(url, proxy, s_branding); + + if (error == null) + { + MessageBox.Show("Slack test notification sent successfully!", "Test Webhook", MessageBoxButton.OK, MessageBoxImage.Information); + } + else + { + MessageBox.Show($"Failed to send Slack test notification:\n\n{error}", "Test Webhook Failed", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + catch (Exception ex) + { + MessageBox.Show($"Failed to send Slack test notification:\n\n{ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + TestSlackButton.Content = "Send Test Notification"; + TestSlackButton.IsEnabled = true; + } + } + + /// <summary> + /// Non-blocking confirm shown when an http:// generic-webhook URL carries headers — the Authorization + /// token would go on the wire in cleartext (#1506). Yes proceeds; No cancels the action. <paramref + /// name="action"/> is the verb ("Save" / "Send"). Not blocked outright: a plaintext POST to a trusted LAN + /// listener is a legitimate setup. + /// </summary> + private static bool ConfirmCleartextWebhook(string action) + { + var result = MessageBox.Show( + "The webhook URL uses http://, so the Authorization header and any credentials are sent in cleartext " + + $"and can be intercepted on the network.\n\n{action} anyway?", + "Insecure Webhook URL", MessageBoxButton.YesNo, MessageBoxImage.Warning); + return result == MessageBoxResult.Yes; + } + + /// <summary> + /// Tests the generic webhook with the values currently in the boxes (not the stored ones), like the + /// Teams/Slack test buttons. A malformed headers JSON or body template comes back as the error message + /// rather than an exception, so the operator fixes it here instead of discovering it when a real alert + /// silently fails to deliver. + /// </summary> + private async void TestGenericButton_Click(object sender, RoutedEventArgs e) + { + var url = GenericWebhookUrlBox.Text?.Trim() ?? ""; + var headers = GenericWebhookHeadersBox.Text?.Trim(); + + /* Warn before a cleartext http:// POST would send the Authorization header unencrypted (#1506). */ + if (WebhookAlertService.IsCleartextHttpWithHeaders(url, headers) && !ConfirmCleartextWebhook("Send")) + { + return; + } + + TestGenericButton.IsEnabled = false; + TestGenericButton.Content = "Sending..."; + + try + { + var body = GenericWebhookBodyBox.Text?.Trim(); + var proxy = GenericWebhookProxyAddressBox.Text?.Trim(); + var error = await WebhookAlertService.SendTestGenericAsync(url, headers, body, proxy, s_branding); + + if (error == null) + { + MessageBox.Show("Generic webhook test notification sent successfully!", "Test Webhook", MessageBoxButton.OK, MessageBoxImage.Information); + } + else + { + MessageBox.Show($"Failed to send generic webhook test notification:\n\n{error}", "Test Webhook Failed", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + catch (Exception ex) + { + MessageBox.Show($"Failed to send generic webhook test notification:\n\n{ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + TestGenericButton.Content = "Send Test Notification"; + TestGenericButton.IsEnabled = true; + } + } + + private void Hyperlink_RequestNavigate(object sender, RequestNavigateEventArgs e) + { + try + { + Process.Start(new ProcessStartInfo { FileName = e.Uri.AbsoluteUri, UseShellExecute = true }); + } + catch { /* A missing default browser must not crash the settings window. */ } + e.Handled = true; + } + + // ── Save / Close ── + + private async void SaveButton_Click(object sender, RoutedEventArgs e) + { + var errors = new List<string>(); + var mcpValid = TryReadServiceFlags( + out var capturePlans, out var mcpEnabled, out var mcpPort, out var webEnabled, out var webPort); + var alertRow = BuildAlertRowFromControls(errors); + SaveViewerLocalAlertFields(errors); + var notifyRow = BuildNotificationRowFromControls(errors); + + /* Cleartext warning (#1506): an http:// generic-webhook URL carrying headers sends the Authorization + token in the clear. Confirm before persisting anything; No cancels the save and the window stays open + to fix the URL. NOT blocked — a plaintext POST to a trusted LAN listener is legitimate. */ + if (GenericWebhookEnabledCheckBox.IsChecked == true + && WebhookAlertService.IsCleartextHttpWithHeaders(GenericWebhookUrlBox.Text?.Trim(), GenericWebhookHeadersBox.Text?.Trim()) + && !ConfirmCleartextWebhook("Save")) + { + return; + } + + /* Persist the viewer-LOCAL preferences immediately (valid values applied above), and capture the + edited viewer preferences for MainWindow to save + re-seed tabs from. */ + SaveConnectionTimeout(); + SaveNocRefreshInterval(); + SaveCsvSeparator(); + SaveTimeDisplayMode(); + SaveColorTheme(); + _appSettingsStore.Save(_appSettings); + Result = BuildViewerPreferences(); + + if (errors.Count > 0) + { + MessageBox.Show( + "Some settings have invalid values and were not saved:\n\n" + string.Join("\n", errors), + "Settings", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + if (!mcpValid) + { + return; /* TryReadServiceFlags already warned about the bad MCP or web dashboard port. */ + } + + /* Guard against clobbering: if the current settings could not be READ (a transient store error left the + controls at defaults), do NOT write them back — that would overwrite an operator-tuned store with the + on-screen defaults. Save the viewer-local preferences (already done above) and say so. */ + if (_dataService is not null && !_storeLoaded) + { + MessageBox.Show( + "Your viewer preferences were saved. The current monitoring settings could not be read from the " + + "Darling store (it may be unreachable, or the service hasn't finished initializing its settings " + + "yet), so they were left unchanged to avoid overwriting them. Try again in a moment.", + "Settings", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + /* Write the operator config to the control-plane store (the service reloads on the config_version bump). + A read-only seat surfaces the friendly message and the window stays open. */ + if (_dataService is not null) + { + try + { + await _dataService.UpsertAlertSettingsAsync(alertRow); + await _dataService.UpsertNotificationAsync(notifyRow); + await _dataService.UpdateServiceFlagsAsync(capturePlans, mcpEnabled, mcpPort, webEnabled, webPort); + } + catch (ViewerReadOnlyException ex) + { + MessageBox.Show(ex.Message, "Read-only connection", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + catch (ViewerSchemaSkewException ex) + { + MessageBox.Show(ex.Message, "Store out of date", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + catch (Exception ex) + { + MessageBox.Show( + $"The viewer-local preferences were saved, but the monitoring settings could not be written to the store:\n\n{ex.Message}", + "Settings", MessageBoxButton.OK, MessageBoxImage.Error); + return; + } + } + + DialogResult = true; + Close(); + } + + private void CloseButton_Click(object sender, RoutedEventArgs e) + { + /* Revert the live theme preview when closing without saving (the X button is handled in OnClosing). */ + if (!_themeSaved) + { + ThemeManager.Apply(_originalTheme); + } + Close(); + } + + protected override void OnClosing(System.ComponentModel.CancelEventArgs e) + { + /* Catches the title-bar X / Esc: an unsaved theme preview reverts to what was active on open. */ + if (!_themeSaved) + { + ThemeManager.Apply(_originalTheme); + } + base.OnClosing(e); + } + + /// <summary> + /// A throwaway <see cref="IAlertSettings"/> built from the live SMTP/webhook controls, so "Send Test + /// Email" verifies exactly what the user typed without first saving (Lite's "test before save"). Only the + /// SMTP members matter for the email test; the rest satisfy the interface with the current UI values. + /// </summary> + private sealed class TestAlertSettings : IAlertSettings + { + public bool SmtpEnabled { get; private init; } + public string SmtpServer { get; private init; } = ""; + public int SmtpPort { get; private init; } + public bool SmtpUseSsl { get; private init; } + public string SmtpUsername { get; private init; } = ""; + public string SmtpFromAddress { get; private init; } = ""; + public string SmtpRecipients { get; private init; } = ""; + private string SmtpPassword { get; init; } = ""; + public string? GetSmtpPassword() => string.IsNullOrEmpty(SmtpPassword) ? null : SmtpPassword; + + public int EmailCooldownMinutes { get; private init; } + + public bool TeamsWebhookEnabled { get; private init; } + public string TeamsWebhookUrl { get; private init; } = ""; + public string TeamsProxyAddress { get; private init; } = ""; + + public bool SlackWebhookEnabled { get; private init; } + public string SlackWebhookUrl { get; private init; } = ""; + public string SlackProxyAddress { get; private init; } = ""; + + public bool GenericWebhookEnabled { get; private init; } + public string GenericWebhookUrl { get; private init; } = ""; + public string GenericWebhookHeadersJson { get; private init; } = ""; + public string GenericWebhookBodyTemplate { get; private init; } = ""; + public string GenericWebhookProxyAddress { get; private init; } = ""; + + public double AnalysisNotifySeverity { get; private init; } + public int AnalysisNotifyCooldownMinutes { get; private init; } + + public static TestAlertSettings FromUi(SettingsWindow w) + { + int.TryParse(w.SmtpPortBox.Text, out var smtpPort); + int.TryParse(w.EmailCooldownBox.Text, out var emailCooldown); + return new TestAlertSettings + { + SmtpEnabled = w.SmtpEnabledCheckBox.IsChecked == true, + SmtpServer = w.SmtpServerBox.Text?.Trim() ?? "", + SmtpPort = smtpPort, + SmtpUseSsl = w.SmtpSslCheckBox.IsChecked == true, + SmtpUsername = w.SmtpUsernameBox.Text?.Trim() ?? "", + SmtpFromAddress = w.SmtpFromBox.Text?.Trim() ?? "", + SmtpRecipients = w.SmtpRecipientsBox.Text?.Trim() ?? "", + SmtpPassword = w.SmtpPasswordBox.Password, + EmailCooldownMinutes = emailCooldown, + TeamsWebhookEnabled = w.TeamsWebhookEnabledCheckBox.IsChecked == true, + TeamsWebhookUrl = w.TeamsWebhookUrlBox.Text?.Trim() ?? "", + TeamsProxyAddress = w.TeamsProxyAddressBox.Text?.Trim() ?? "", + SlackWebhookEnabled = w.SlackWebhookEnabledCheckBox.IsChecked == true, + SlackWebhookUrl = w.SlackWebhookUrlBox.Text?.Trim() ?? "", + SlackProxyAddress = w.SlackProxyAddressBox.Text?.Trim() ?? "", + GenericWebhookEnabled = w.GenericWebhookEnabledCheckBox.IsChecked == true, + GenericWebhookUrl = w.GenericWebhookUrlBox.Text?.Trim() ?? "", + GenericWebhookHeadersJson = w.GenericWebhookHeadersBox.Text?.Trim() ?? "", + GenericWebhookBodyTemplate = w.GenericWebhookBodyBox.Text?.Trim() ?? "", + GenericWebhookProxyAddress = w.GenericWebhookProxyAddressBox.Text?.Trim() ?? "", + AnalysisNotifySeverity = w._appSettings.AnalysisNotifySeverity, + AnalysisNotifyCooldownMinutes = w._appSettings.AnalysisNotifyCooldownMinutes, + }; + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/SnoozeBalloon.xaml b/Darling/PerformanceMonitor.Darling.Viewer/SnoozeBalloon.xaml new file mode 100644 index 000000000..70a9de6f9 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/SnoozeBalloon.xaml @@ -0,0 +1,63 @@ +<UserControl x:Class="PerformanceMonitor.Darling.Viewer.SnoozeBalloon" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + Width="360" + FontFamily="Segoe UI"> + <!-- Interactive tray toast with Snooze/Dismiss. Ported verbatim from Lite's SnoozeBalloon + (adapted only in namespace + a snooze callback in the code-behind, since the viewer writes the + mute rule straight to Postgres rather than through Lite's in-process MuteRuleService). --> + <Border Background="{DynamicResource BackgroundBrush}" + BorderBrush="{DynamicResource BorderBrush}" + BorderThickness="1" + CornerRadius="6" + TextElement.FontFamily="Segoe UI"> + <StackPanel Margin="14,12,14,12"> + <DockPanel Margin="0,0,0,4"> + <TextBlock x:Name="SeverityIcon" + Text="!" + FontSize="16" + FontWeight="Bold" + VerticalAlignment="Center" + Margin="0,0,8,0" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBlock x:Name="TitleText" + Text="Alert" + FontWeight="SemiBold" + FontSize="13" + Foreground="{DynamicResource ForegroundBrush}" + VerticalAlignment="Center"/> + </DockPanel> + + <TextBlock x:Name="MessageText" + Text="" + TextWrapping="Wrap" + FontSize="12" + Foreground="{DynamicResource ForegroundBrush}" + Margin="0,0,0,10"/> + + <DockPanel> + <Button x:Name="DismissButton" + Content="Dismiss" + DockPanel.Dock="Right" + Padding="10,4" + Click="DismissButton_Click"/> + <StackPanel Orientation="Horizontal" HorizontalAlignment="Left"> + <Button x:Name="Snooze15Button" + Content="Snooze 15m" + Padding="8,4" + Margin="0,0,4,0" + Click="Snooze15Button_Click"/> + <Button x:Name="Snooze1hButton" + Content="Snooze 1h" + Padding="8,4" + Margin="0,0,4,0" + Click="Snooze1hButton_Click"/> + <Button x:Name="Snooze4hButton" + Content="Snooze 4h" + Padding="8,4" + Click="Snooze4hButton_Click"/> + </StackPanel> + </DockPanel> + </StackPanel> + </Border> +</UserControl> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/SnoozeBalloon.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/SnoozeBalloon.xaml.cs new file mode 100644 index 000000000..fa0a35da0 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/SnoozeBalloon.xaml.cs @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using Hardcodet.Wpf.TaskbarNotification; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// An interactive tray toast with Snooze (15m / 1h / 4h) and Dismiss, ported from Lite's SnoozeBalloon. +/// The one adaptation for the headless viewer: instead of holding Lite's in-process +/// <c>MuteRuleService</c> + a server/metric pair, it takes an <paramref name="onSnooze"/> callback the +/// caller supplies. The viewer's callback writes a temporary <c>config_mute_rules</c> row straight to +/// Postgres (scoped to the alert's server + metric), which the running Darling service re-reads on its +/// next config load — the store is the coordination point, so there is no IPC to the service. +/// </summary> +public partial class SnoozeBalloon : UserControl +{ + private readonly Func<TimeSpan, Task> _onSnooze; + private readonly Action _closePopup; + private bool _closed; + + /// <param name="onSnooze">Persists a snooze of the given duration (writes the scoped mute rule).</param> + /// <param name="closePopup">Tears the popup down — call <c>TaskbarIcon.CloseBalloon()</c>.</param> + public SnoozeBalloon( + string title, + string message, + BalloonIcon icon, + Func<TimeSpan, Task> onSnooze, + Action closePopup) + { + InitializeComponent(); + + _onSnooze = onSnooze ?? throw new ArgumentNullException(nameof(onSnooze)); + _closePopup = closePopup ?? throw new ArgumentNullException(nameof(closePopup)); + + TitleText.Text = title; + MessageText.Text = message; + ApplySeverity(icon); + } + + private void ApplySeverity(BalloonIcon icon) + { + switch (icon) + { + case BalloonIcon.Error: + SeverityIcon.Text = "⚠"; /* warning sign */ + SeverityIcon.Foreground = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)); + break; + case BalloonIcon.Warning: + SeverityIcon.Text = "⚠"; + SeverityIcon.Foreground = new SolidColorBrush(Color.FromRgb(0xF5, 0x9E, 0x0B)); + break; + default: + SeverityIcon.Text = "ℹ"; /* info */ + SeverityIcon.Foreground = new SolidColorBrush(Color.FromRgb(0x3B, 0x82, 0xF6)); + break; + } + } + + private void Snooze15Button_Click(object sender, RoutedEventArgs e) => Snooze(TimeSpan.FromMinutes(15)); + private void Snooze1hButton_Click(object sender, RoutedEventArgs e) => Snooze(TimeSpan.FromHours(1)); + private void Snooze4hButton_Click(object sender, RoutedEventArgs e) => Snooze(TimeSpan.FromHours(4)); + + private async void Snooze(TimeSpan duration) + { + if (_closed) return; + _closed = true; + + try + { + await _onSnooze(duration); + } + catch (Exception ex) + { + ViewerLogger.Warn("SnoozeBalloon", $"Failed to add snooze rule: {ex.Message}"); + } + + CloseBalloon(); + } + + private void DismissButton_Click(object sender, RoutedEventArgs e) + { + if (_closed) return; + _closed = true; + CloseBalloon(); + } + + private void CloseBalloon() + { + /* Hardcodet's BalloonClosingEvent is emitted BY the library when it closes; it has no + listener that translates a balloon-initiated raise back into a close. To actually + tear the popup down we have to call TaskbarIcon.CloseBalloon() directly. */ + _closePopup(); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/StatusToBrushConverter.cs b/Darling/PerformanceMonitor.Darling.Viewer/StatusToBrushConverter.cs new file mode 100644 index 000000000..89176f895 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/StatusToBrushConverter.cs @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Globalization; +using System.Windows.Data; +using System.Windows.Media; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// Colors the Recommendations severity bands: CRITICAL red, WARNING orange, INFO the palette's +/// light blue. It also maps the collector status vocabulary — SUCCESS green, PERMISSIONS orange +/// (shared with WARNING), ERROR red (shared with CRITICAL) — a holdover from the shell's original +/// Collection Health grid, which W1i changed to Lite's plain-text status (no color), leaving the +/// Recommendations grid the sole consumer. Anything else (SKIPPED, unknown) gets the window's +/// normal foreground. The hexes are the product's Material-300 cycling colors (ChartPalette) so +/// the grid reads like the charts. Brushes are frozen and shared across rows. +/// </summary> +public sealed class StatusToBrushConverter : IValueConverter +{ + private static readonly SolidColorBrush s_success = Frozen("#81C784"); + private static readonly SolidColorBrush s_permissions = Frozen("#FFD54F"); + private static readonly SolidColorBrush s_error = Frozen("#E57373"); + private static readonly SolidColorBrush s_info = Frozen("#4FC3F7"); + private static readonly SolidColorBrush s_default = Frozen("#E4E6EB"); + + private static SolidColorBrush Frozen(string hex) + { + var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(hex)); + brush.Freeze(); + return brush; + } + + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + var status = value as string; + if (string.Equals(status, "SUCCESS", StringComparison.OrdinalIgnoreCase)) + { + return s_success; + } + + if (string.Equals(status, "PERMISSIONS", StringComparison.OrdinalIgnoreCase) || + string.Equals(status, "WARNING", StringComparison.OrdinalIgnoreCase)) + { + return s_permissions; + } + + if (string.Equals(status, "ERROR", StringComparison.OrdinalIgnoreCase) || + string.Equals(status, "CRITICAL", StringComparison.OrdinalIgnoreCase)) + { + return s_error; + } + + if (string.Equals(status, "INFO", StringComparison.OrdinalIgnoreCase)) + { + return s_info; + } + + return s_default; + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException("Status colors are one-way."); +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/StyledBalloon.xaml b/Darling/PerformanceMonitor.Darling.Viewer/StyledBalloon.xaml new file mode 100644 index 000000000..b6102fe26 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/StyledBalloon.xaml @@ -0,0 +1,39 @@ +<UserControl x:Class="PerformanceMonitor.Darling.Viewer.StyledBalloon" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + Width="360" + FontFamily="Segoe UI"> + <!-- Button-less themed card for resolved/cleared (and other informational) tray notifications. + Ported verbatim from Lite's StyledBalloon; mirrors SnoozeBalloon's chrome so an "all clear" toast + matches the snoozable condition cards instead of rendering as a plain, unthemed Windows balloon. --> + <Border Background="{DynamicResource BackgroundBrush}" + BorderBrush="{DynamicResource BorderBrush}" + BorderThickness="1" + CornerRadius="6" + TextElement.FontFamily="Segoe UI"> + <StackPanel Margin="14,12,14,12"> + <DockPanel Margin="0,0,0,4"> + <TextBlock x:Name="SeverityIcon" + Text="!" + FontSize="16" + FontWeight="Bold" + VerticalAlignment="Center" + Margin="0,0,8,0" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBlock x:Name="TitleText" + Text="Alert" + FontWeight="SemiBold" + FontSize="13" + Foreground="{DynamicResource ForegroundBrush}" + VerticalAlignment="Center"/> + </DockPanel> + + <TextBlock x:Name="MessageText" + Text="" + TextWrapping="Wrap" + FontSize="12" + Foreground="{DynamicResource ForegroundBrush}" + Margin="0"/> + </StackPanel> + </Border> +</UserControl> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/StyledBalloon.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/StyledBalloon.xaml.cs new file mode 100644 index 000000000..555b7be6a --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/StyledBalloon.xaml.cs @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Windows.Controls; +using System.Windows.Media; +using PerformanceMonitor.Notifications; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// A themed, button-less tray balloon for resolved/cleared (and other informational) notifications. +/// Ported from Lite's StyledBalloon (adapted only in namespace) so the two front ends render the same +/// card. Shares <see cref="SnoozeBalloon"/>'s card chrome (background/border/severity icon) so a "Cleared" +/// or "Resolved" toast looks like the snoozable condition cards rather than a plain Windows balloon. +/// </summary> +public partial class StyledBalloon : UserControl +{ + public StyledBalloon(string title, string message, ToastSeverity severity) + { + InitializeComponent(); + + TitleText.Text = title; + MessageText.Text = message; + ApplySeverity(severity); + } + + /* Mirrors SnoozeBalloon.ApplySeverity (identical Warning/Error/Info glyphs + colors) and adds the + Success case — a green check (#16A34A), the same green the email/webhook "RESOLVED" badge uses — + for cleared/resolved conditions. */ + private void ApplySeverity(ToastSeverity severity) + { + switch (severity) + { + case ToastSeverity.Error: + SeverityIcon.Text = "⚠"; /* warning sign */ + SeverityIcon.Foreground = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)); + break; + case ToastSeverity.Warning: + SeverityIcon.Text = "⚠"; + SeverityIcon.Foreground = new SolidColorBrush(Color.FromRgb(0xF5, 0x9E, 0x0B)); + break; + case ToastSeverity.Success: + SeverityIcon.Text = "✓"; /* check mark */ + SeverityIcon.Foreground = new SolidColorBrush(Color.FromRgb(0x16, 0xA3, 0x4A)); + break; + default: + SeverityIcon.Text = "ℹ"; /* info */ + SeverityIcon.Foreground = new SolidColorBrush(Color.FromRgb(0x3B, 0x82, 0xF6)); + break; + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/SystemEventSignificance.cs b/Darling/PerformanceMonitor.Darling.Viewer/SystemEventSignificance.cs new file mode 100644 index 000000000..2c75c2abc --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/SystemEventSignificance.cs @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Collections.Generic; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The viewer-side entry point the System Events tab calls for every category's significant-set gate. The +/// system_health predicates live once in <see cref="SystemHealthSignificance"/> (PerformanceMonitor.Common) +/// and the Default Trace gate in <see cref="DefaultTraceEventSignificance"/>; this facade simply re-exposes +/// both so the data-service reads have a single uniform call surface +/// (<c>SystemEventSignificance.IsSignificant(...)</c> for every category, system_health or default-trace). +/// It carries NO predicate logic of its own — every member delegates — so it cannot drift from the shared +/// source the Lite viewer and the headless MCP host also call (the whole point of hoisting the predicates: +/// this was previously a hand-mirrored twin of the service's <c>DarlingSystemHealthSignificance</c>, since +/// deleted). +/// </summary> +public static class SystemEventSignificance +{ + /// <summary>sp_HealthParser severe-error cutoff (severity >= 19). See <see cref="SystemHealthSignificance.SevereErrorMinSeverity"/>.</summary> + public const int SevereErrorMinSeverity = SystemHealthSignificance.SevereErrorMinSeverity; + + /// <summary>sp_HealthParser <c>@wait_duration_ms</c> default (500 ms). See <see cref="SystemHealthSignificance.SignificantWaitMinDurationMs"/>.</summary> + public const long SignificantWaitMinDurationMs = SystemHealthSignificance.SignificantWaitMinDurationMs; + + /// <summary>sp_HealthParser <c>@pending_task_threshold</c> default (10). See <see cref="SystemHealthSignificance.CpuTaskMinPendingTasks"/>.</summary> + public const long CpuTaskMinPendingTasks = SystemHealthSignificance.CpuTaskMinPendingTasks; + + /// <summary>The scheduler status text kept under warnings_only. See <see cref="SystemHealthSignificance.WarningStatus"/>.</summary> + public const string WarningStatus = SystemHealthSignificance.WarningStatus; + + /// <summary>The low-physical-memory notification kept under warnings_only. See <see cref="SystemHealthSignificance.LowMemoryNotification"/>.</summary> + public const string LowMemoryNotification = SystemHealthSignificance.LowMemoryNotification; + + /// <summary>error_reported numbers always excluded from severe errors. See <see cref="SystemHealthSignificance.IgnoredSevereErrorNumbers"/>.</summary> + public static readonly IReadOnlySet<int> IgnoredSevereErrorNumbers = SystemHealthSignificance.IgnoredSevereErrorNumbers; + + /// <summary>The idle/background "boring waits" ignore list. See <see cref="SystemHealthSignificance.IgnoredWaitTypes"/>.</summary> + public static readonly IReadOnlySet<string> IgnoredWaitTypes = SystemHealthSignificance.IgnoredWaitTypes; + + /// <summary>A scheduler-monitor row is significant when its status is WARNING. Delegates to <see cref="SystemHealthSignificance"/>.</summary> + public static bool IsSignificant(SchedulerIssueRecord record) => SystemHealthSignificance.IsSignificant(record); + + /// <summary>A severe-error row is significant at severity >= 19 excluding the benign reset numbers. Delegates to <see cref="SystemHealthSignificance"/>.</summary> + public static bool IsSignificant(SevereErrorRecord record) => SystemHealthSignificance.IsSignificant(record); + + /// <summary>A memory-conditions snapshot is significant under RESOURCE_MEMPHYSICAL_LOW. Delegates to <see cref="SystemHealthSignificance"/>.</summary> + public static bool IsSignificant(MemoryConditionsRecord record) => SystemHealthSignificance.IsSignificant(record); + + /// <summary>A memory-broker ratio change is significant under RESOURCE_MEMPHYSICAL_LOW. Delegates to <see cref="SystemHealthSignificance"/>.</summary> + public static bool IsSignificant(MemoryBrokerRecord record) => SystemHealthSignificance.IsSignificant(record); + + /// <summary>Every recorded memory-node OOM is significant. Delegates to <see cref="SystemHealthSignificance"/>.</summary> + public static bool IsSignificant(MemoryNodeOomRecord record) => SystemHealthSignificance.IsSignificant(record); + + /// <summary>A wait_info row is a significant wait per sp_HealthParser's always-on predicate. Delegates to <see cref="SystemHealthSignificance"/>.</summary> + public static bool IsSignificant(SignificantWaitRecord record) => SystemHealthSignificance.IsSignificant(record); + + /// <summary>A CPU-task row is significant at QUERY_PROCESSING WARNING with pendingTasks >= threshold. Delegates to <see cref="SystemHealthSignificance"/>.</summary> + public static bool IsSignificant(CpuTasksRecord record) => SystemHealthSignificance.IsSignificant(record); + + /// <summary>A potential-IO-issue row is significant at IO_SUBSYSTEM WARNING. Delegates to <see cref="SystemHealthSignificance"/>.</summary> + public static bool IsSignificant(IoIssuesRecord record) => SystemHealthSignificance.IsSignificant(record); + + /* ── Default Trace events (Dashboard->shared Default Trace parity) ── + The Default Trace significant-set gate is authored ONCE in PerformanceMonitor.Common + (DefaultTraceEventSignificance), which the viewer and the headless MCP host both reference. These + members delegate to it, keeping the System Events surface's significant-set entry point uniform (the + viewer calls SystemEventSignificance for every category, system_health or default-trace). */ + + /// <summary>Minimum ErrorLog severity for the significant set — the shared + /// <see cref="DefaultTraceEventSignificance.SevereErrorLogMinSeverity"/> (16).</summary> + public const int DefaultTraceSevereErrorLogMinSeverity = DefaultTraceEventSignificance.SevereErrorLogMinSeverity; + + /// <summary> + /// Whether a collected Default Trace event belongs on the System Events surface: ErrorLog is gated by + /// severity, every other curated category (auto-grow/shrink, schema DDL, security audit, memory change) + /// is significant as collected. Delegates to the ONE shared classifier so the viewer and the headless + /// <c>get_default_trace_events</c> MCP tool gate identically. + /// </summary> + public static bool IsSignificantDefaultTraceEvent(string? eventName, int? severity) => + DefaultTraceEventSignificance.IsSignificant(eventName, severity); + + /// <summary>The System-Events category a Default Trace event name maps to (the shared classifier).</summary> + public static DefaultTraceEventCategory ClassifyDefaultTraceEvent(string? eventName) => + DefaultTraceEventSignificance.Classify(eventName); +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/SystemTrayService.cs b/Darling/PerformanceMonitor.Darling.Viewer/SystemTrayService.cs new file mode 100644 index 000000000..6c1062908 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/SystemTrayService.cs @@ -0,0 +1,219 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using Hardcodet.Wpf.TaskbarNotification; +using PerformanceMonitor.Notifications; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The viewer's system-tray icon + minimize-to-tray behavior, ported from Lite's SystemTrayService and +/// adapted for the headless viewer: +/// <list type="bullet"> +/// <item>there is no in-process collector to pause, so the context menu is just Show Window / Exit (Lite's +/// Pause/Resume item is dropped) — collection is the separate Darling service's job;</item> +/// <item>minimize-to-tray reads a caller-supplied <c>Func<bool></c> (the live +/// <see cref="ViewerAppSettings.MinimizeToTray"/> value) instead of Lite's <c>App.MinimizeToTray</c> static;</item> +/// <item>the snoozable toast takes a persist-snooze callback (the viewer writes the mute rule straight to +/// Postgres) instead of Lite's in-process <c>MuteRuleService</c>.</item> +/// </list> +/// The tray icon is created once and lives for the process lifetime; toasts are surfaced by the +/// MainWindow's alert-history poll (there is no in-process alert engine in the viewer). +/// </summary> +public sealed class SystemTrayService : IDisposable +{ + private const string TooltipText = "Performance Monitor Darling Viewer"; + + private TaskbarIcon? _trayIcon; + private readonly Window _mainWindow; + private readonly Action _restoreWindow; + private readonly Func<bool> _minimizeToTrayEnabled; + private bool _disposed; + private TextBlock? _tooltipText; + + /// <param name="mainWindow">The window whose minimize is intercepted and hidden to the tray.</param> + /// <param name="restoreWindow"> + /// The window's own WPF restore (the viewer's <c>MainWindow.SurfaceWindow</c>, shared with the + /// single-instance surface path). The tray must restore through WPF's <see cref="Window.Show"/> path, + /// never a raw Win32 ShowWindow, or a tray-hidden window comes back blank (Lite #1050). + /// </param> + /// <param name="minimizeToTrayEnabled">Reads the live Minimize-to-tray setting each time the window minimizes.</param> + public SystemTrayService(Window mainWindow, Action restoreWindow, Func<bool> minimizeToTrayEnabled) + { + _mainWindow = mainWindow ?? throw new ArgumentNullException(nameof(mainWindow)); + _restoreWindow = restoreWindow ?? throw new ArgumentNullException(nameof(restoreWindow)); + _minimizeToTrayEnabled = minimizeToTrayEnabled ?? throw new ArgumentNullException(nameof(minimizeToTrayEnabled)); + ThemeManager.ThemeChanged += OnThemeChanged; + } + + /// <summary> + /// Initializes the system tray icon with its context menu. Safe to call again (theme change): it + /// disposes the old icon and re-subscribes the StateChanged handler exactly once. + /// </summary> + public void Initialize() + { + _trayIcon?.Dispose(); + + _trayIcon = new TaskbarIcon(); + + bool hasLightBackground = ThemeManager.HasLightBackground; + + /* Custom tooltip styled to match current theme. + Note: Hardcodet TrayToolTip can rarely trigger a race condition in Popup.CreateWindow + that throws "The root Visual of a VisualTarget cannot have a parent." (issue #422). + App.xaml.cs's IsTrayToolTipCrash suppresses it: e.Handled on the Dispatcher path (keeps the + app alive) + log-only on the AppDomain path -- matching Lite's proven handling of #422. */ + _tooltipText = new TextBlock + { + Text = TooltipText, + Foreground = new SolidColorBrush(hasLightBackground + ? (Color)ColorConverter.ConvertFromString("#1A1D23") + : (Color)ColorConverter.ConvertFromString("#E4E6EB")), + FontSize = 12 + }; + _trayIcon.TrayToolTip = new Border + { + Background = new SolidColorBrush(hasLightBackground + ? (Color)ColorConverter.ConvertFromString("#FFFFFF") + : (Color)ColorConverter.ConvertFromString("#22252b")), + BorderBrush = new SolidColorBrush(hasLightBackground + ? (Color)ColorConverter.ConvertFromString("#DEE2E6") + : (Color)ColorConverter.ConvertFromString("#33363e")), + BorderThickness = new Thickness(1), + Padding = new Thickness(10, 8, 10, 8), + CornerRadius = new CornerRadius(4), + Child = _tooltipText + }; + + /* Load icon */ + try + { + var iconUri = new Uri("pack://application:,,,/EDD.ico", UriKind.Absolute); + _trayIcon.IconSource = new BitmapImage(iconUri); + } + catch + { + /* Icon loading failed - tray icon will be blank but functional */ + } + + /* Build context menu — Show Window / Exit. The viewer has no in-process collection to pause. */ + var contextMenu = new ContextMenu(); + + var showItem = new MenuItem { Header = "Show Window", Icon = new TextBlock { Text = "📊", Background = Brushes.Transparent } }; + showItem.Click += (s, e) => _restoreWindow(); + contextMenu.Items.Add(showItem); + + contextMenu.Items.Add(new Separator()); + + var exitItem = new MenuItem { Header = "Exit", Icon = new TextBlock { Text = "✕", Background = Brushes.Transparent } }; + exitItem.Click += (s, e) => ExitApplication(); + contextMenu.Items.Add(exitItem); + + _trayIcon.ContextMenu = contextMenu; + + /* Double-click to show window */ + _trayIcon.TrayMouseDoubleClick += (s, e) => _restoreWindow(); + + /* Handle minimize to tray. Unsubscribe first: Initialize re-runs on theme change, so a + plain += would stack a duplicate StateChanged handler on the app-lifetime window each time. */ + _mainWindow.StateChanged -= MainWindow_StateChanged; + _mainWindow.StateChanged += MainWindow_StateChanged; + } + + private void MainWindow_StateChanged(object? sender, EventArgs e) + { + if (_mainWindow.WindowState == WindowState.Minimized && _minimizeToTrayEnabled()) + { + _mainWindow.Hide(); + } + } + + private static void ExitApplication() + { + Application.Current.Shutdown(); + } + + /// <summary> + /// Shows a plain balloon notification from the system tray icon. No-op if the tray icon hasn't been + /// initialized. + /// </summary> + public void ShowNotification(string title, string message, BalloonIcon icon) + { + _trayIcon?.ShowBalloonTip(title, message, icon); + } + + /// <summary> + /// Shows a themed, button-less balloon (the same card chrome as the snoozable condition cards) + /// for resolved/cleared conditions, so an "all clear" toast no longer renders as a plain, unthemed + /// Windows balloon. Pass <see cref="ToastSeverity.Success"/> for a green-check "resolved" accent. + /// No-op if the tray icon hasn't been initialized. + /// </summary> + public void ShowStyledNotification(string title, string message, ToastSeverity severity) + { + if (_trayIcon == null) + { + return; + } + + var balloon = new StyledBalloon(title, message, severity); + _trayIcon.ShowCustomBalloon(balloon, System.Windows.Controls.Primitives.PopupAnimation.Slide, 10000); + } + + /// <summary> + /// Shows a custom interactive balloon with snooze buttons. The <paramref name="onSnooze"/> callback + /// persists a temporary mute rule scoped to the alert's server + metric (the viewer writes it straight + /// to <c>config_mute_rules</c>). No-op if the tray icon hasn't been initialized. + /// </summary> + public void ShowSnoozableNotification( + string title, + string message, + BalloonIcon icon, + Func<TimeSpan, Task> onSnooze) + { + if (_trayIcon == null) + { + return; + } + + var trayIcon = _trayIcon; + var balloon = new SnoozeBalloon(title, message, icon, onSnooze, () => trayIcon.CloseBalloon()); + _trayIcon.ShowCustomBalloon(balloon, System.Windows.Controls.Primitives.PopupAnimation.Slide, 15000); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + ThemeManager.ThemeChanged -= OnThemeChanged; + _mainWindow.StateChanged -= MainWindow_StateChanged; + + if (_trayIcon != null) + { + _trayIcon.Visibility = Visibility.Collapsed; + _trayIcon.Dispose(); + _trayIcon = null; + } + + _disposed = true; + } + + private void OnThemeChanged(string _) + { + _mainWindow.Dispatcher.InvokeAsync(Initialize); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/Themes/CoolBreezeTheme.xaml b/Darling/PerformanceMonitor.Darling.Viewer/Themes/CoolBreezeTheme.xaml new file mode 100644 index 000000000..4c42dc07f --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/Themes/CoolBreezeTheme.xaml @@ -0,0 +1,1324 @@ +<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:sys="clr-namespace:System;assembly=mscorlib" + xmlns:helpers="clr-namespace:PerformanceMonitor.Ui;assembly=PerformanceMonitor.Ui"> + + <!-- ============================================ --> + <!-- Darling Data Cool Breeze Theme --> + <!-- Flat/Minimal Design --> + <!-- ============================================ --> + + <!-- Brand Colors --> + <Color x:Key="AccentColor">#1E6FA8</Color> + <Color x:Key="AccentHoverColor">#2B87C8</Color> + <Color x:Key="AccentPressedColor">#155A8A</Color> + + <!-- Background Colors --> + <Color x:Key="BackgroundDarkColor">#CFDDE9</Color> + <Color x:Key="AlternatingRowColor">#C5D5E4</Color> + <Color x:Key="BackgroundColor">#DAE6F0</Color> + <Color x:Key="BackgroundLightColor">#EEF4FA</Color> + <Color x:Key="BackgroundLighterColor">#BDD0E1</Color> + + <!-- Text Colors (dark on light backgrounds) --> + <Color x:Key="ForegroundColor">#1A2A3A</Color> + <Color x:Key="ForegroundDimColor">#1A2A3A</Color> + <!-- Muted (tertiary) text. #1441: the old #5B7A90 was only 2.87:1 on the light-blue wells and + failed WCAG AA. #4A5D72 is ~6.5:1 on #EEF4FA — a readable secondary tone that keeps the + cool blue character while reading quieter than primary, mirroring the Dark muted tier. --> + <Color x:Key="ForegroundMutedColor">#4A5D72</Color> + + <!-- Border Colors (subtle, low-contrast) --> + <Color x:Key="BorderColor">#A8BDD0</Color> + <Color x:Key="BorderLightColor">#8AACC4</Color> + + <!-- Status Colors (Material Design 700 — visible against light backgrounds) --> + <Color x:Key="SuccessColor">#2E7D32</Color> + <Color x:Key="WarningColor">#F57F17</Color> + <Color x:Key="ErrorColor">#C62828</Color> + <Color x:Key="InfoColor">#1E6FA8</Color> + + <!-- Spacing Scale --> + <sys:Double x:Key="SpacingXS">2</sys:Double> + <sys:Double x:Key="SpacingSm">4</sys:Double> + <sys:Double x:Key="SpacingMd">8</sys:Double> + <sys:Double x:Key="SpacingLg">12</sys:Double> + <sys:Double x:Key="SpacingXL">16</sys:Double> + + <!-- Corner Radius --> + <CornerRadius x:Key="CornerRadiusSm">2</CornerRadius> + <CornerRadius x:Key="CornerRadiusMd">4</CornerRadius> + <CornerRadius x:Key="CornerRadiusLg">6</CornerRadius> + + <!-- Brushes --> + <SolidColorBrush x:Key="AccentBrush" Color="{StaticResource AccentColor}"/> + <SolidColorBrush x:Key="AccentHoverBrush" Color="{StaticResource AccentHoverColor}"/> + <SolidColorBrush x:Key="AccentPressedBrush" Color="{StaticResource AccentPressedColor}"/> + + <SolidColorBrush x:Key="BackgroundDarkBrush" Color="{StaticResource BackgroundDarkColor}"/> + <SolidColorBrush x:Key="AlternatingRowBrush" Color="{StaticResource AlternatingRowColor}"/> + <SolidColorBrush x:Key="BackgroundBrush" Color="{StaticResource BackgroundColor}"/> + <SolidColorBrush x:Key="BackgroundLightBrush" Color="{StaticResource BackgroundLightColor}"/> + <SolidColorBrush x:Key="BackgroundLighterBrush" Color="{StaticResource BackgroundLighterColor}"/> + + <SolidColorBrush x:Key="ForegroundBrush" Color="{StaticResource ForegroundColor}"/> + <SolidColorBrush x:Key="ForegroundDimBrush" Color="{StaticResource ForegroundDimColor}"/> + <!-- #1441: use the (now readable) muted color so CoolBreeze has a real 3-tier text hierarchy + like Dark, instead of collapsing muted onto the primary foreground. --> + <SolidColorBrush x:Key="ForegroundMutedBrush" Color="{StaticResource ForegroundMutedColor}"/> + + <SolidColorBrush x:Key="BorderBrush" Color="{StaticResource BorderColor}"/> + <SolidColorBrush x:Key="BorderLightBrush" Color="{StaticResource BorderLightColor}"/> + + <SolidColorBrush x:Key="SuccessBrush" Color="{StaticResource SuccessColor}"/> + <SolidColorBrush x:Key="WarningBrush" Color="{StaticResource WarningColor}"/> + <!-- Legible warning amber for content text/borders (block-chain longest-wait, flag banner). + Darker on light themes so it clears >=4.5:1 as TEXT on content backgrounds; any control may adopt it. --> + <SolidColorBrush x:Key="WarningTextBrush" Color="#92400E"/> + <!-- Apex (lead-blocker) node + legend border. Darker blue on light themes so it clears >=3:1 as a + graphical border on the light node card (bright #4FA3FF is only ~2.6:1 on white). The LEAD BLOCKER + badge fill keeps the bright ApexBrush constant so the apex still pops. --> + <SolidColorBrush x:Key="ApexBorderBrush" Color="#1976D2"/> + <!-- Deadlock victim node border + VICTIM badge fill, and the "waits for" edge/arrow. Tuned per theme + so the victim red clears >=3:1 as a border on the light node card and the edge reads on the canvas. --> + <SolidColorBrush x:Key="DeadlockVictimBrush" Color="#C62828"/> + <SolidColorBrush x:Key="DeadlockEdgeBrush" Color="#455A6E"/> + <SolidColorBrush x:Key="ErrorBrush" Color="{StaticResource ErrorColor}"/> + <SolidColorBrush x:Key="InfoBrush" Color="{StaticResource InfoColor}"/> + + <!-- ============================================ --> + <!-- Window Style --> + <!-- ============================================ --> + <Style TargetType="Window"> + <Setter Property="Background" Value="{StaticResource BackgroundBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + </Style> + + <!-- ============================================ --> + <!-- TextBlock Style --> + <!-- ============================================ --> + <Style TargetType="TextBlock"> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + </Style> + + <!-- ============================================ --> + <!-- Label Style --> + <!-- ============================================ --> + <Style TargetType="Label"> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + </Style> + + <!-- ============================================ --> + <!-- Button Style - Flat Design --> + <!-- ============================================ --> + <Style TargetType="Button"> + <Setter Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + <Setter Property="Foreground" Value="#1A2A3A"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Padding" Value="12,6"/> + <Setter Property="Cursor" Value="Hand"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="Button"> + <Border x:Name="border" + Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + CornerRadius="3" + Padding="{TemplateBinding Padding}" + TextElement.Foreground="{TemplateBinding Foreground}"> + <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Trigger.EnterActions> + <BeginStoryboard> + <Storyboard> + <ColorAnimation Storyboard.TargetName="border" Storyboard.TargetProperty="(Border.Background).(SolidColorBrush.Color)" To="{StaticResource BackgroundLighterColor}" Duration="0:0:0.12"/> + </Storyboard> + </BeginStoryboard> + </Trigger.EnterActions> + <Trigger.ExitActions> + <BeginStoryboard> + <Storyboard> + <ColorAnimation Storyboard.TargetName="border" Storyboard.TargetProperty="(Border.Background).(SolidColorBrush.Color)" To="{StaticResource BackgroundLightColor}" Duration="0:0:0.12"/> + </Storyboard> + </BeginStoryboard> + </Trigger.ExitActions> + </Trigger> + <Trigger Property="IsPressed" Value="True"> + <Setter TargetName="border" Property="Background" Value="{StaticResource AccentPressedBrush}"/> + </Trigger> + <Trigger Property="IsEnabled" Value="False"> + <Setter Property="Opacity" Value="0.5"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- Accent Button Style (for primary actions) --> + <Style x:Key="AccentButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}"> + <Setter Property="Background" Value="{StaticResource AccentBrush}"/> + <Setter Property="FontWeight" Value="SemiBold"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="Button"> + <Border x:Name="border" + Background="{TemplateBinding Background}" + BorderThickness="0" + CornerRadius="3" + Padding="{TemplateBinding Padding}" + TextElement.Foreground="{TemplateBinding Foreground}"> + <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="border" Property="Background" Value="{StaticResource AccentHoverBrush}"/> + </Trigger> + <Trigger Property="IsPressed" Value="True"> + <Setter TargetName="border" Property="Background" Value="{StaticResource AccentPressedBrush}"/> + </Trigger> + <Trigger Property="IsEnabled" Value="False"> + <Setter Property="Opacity" Value="0.5"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- No Data Message Style (for empty DataGrids) --> + <Style x:Key="NoDataMessage" TargetType="TextBlock"> + <Setter Property="Text" Value="No data for selected time range"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundMutedBrush}"/> + <Setter Property="FontSize" Value="14"/> + <Setter Property="HorizontalAlignment" Value="Center"/> + <Setter Property="VerticalAlignment" Value="Center"/> + <Setter Property="Visibility" Value="Collapsed"/> + </Style> + + <!-- Alert Badge Styles --> + <Style x:Key="AlertBadge" TargetType="Border"> + <Setter Property="Background" Value="#D97706"/> + <Setter Property="CornerRadius" Value="3"/> + <Setter Property="Padding" Value="4,1"/> + <Setter Property="Margin" Value="6,0,0,0"/> + <Setter Property="VerticalAlignment" Value="Center"/> + </Style> + + <Style x:Key="AlertBadgeCritical" TargetType="Border"> + <Setter Property="Background" Value="#DC2626"/> + <Setter Property="CornerRadius" Value="3"/> + <Setter Property="Padding" Value="4,1"/> + <Setter Property="Margin" Value="6,0,0,0"/> + <Setter Property="VerticalAlignment" Value="Center"/> + </Style> + + <!-- ToggleButton Style (respects Background in all states) --> + <Style TargetType="ToggleButton"> + <Setter Property="Background" Value="#E8ECF0"/> + <Setter Property="Foreground" Value="#1A2A3A"/> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Padding" Value="12,5"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="ToggleButton"> + <Border x:Name="Border" + Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + Padding="{TemplateBinding Padding}" + CornerRadius="2"> + <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="Border" Property="Opacity" Value="0.85"/> + </Trigger> + <Trigger Property="IsPressed" Value="True"> + <Setter TargetName="Border" Property="Opacity" Value="0.7"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- Tab Close Button Style --> + <Style x:Key="TabCloseButton" TargetType="Button"> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="Foreground" Value="White"/> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Width" Value="22"/> + <Setter Property="Height" Value="22"/> + <Setter Property="Padding" Value="0"/> + <Setter Property="Margin" Value="6,0,0,0"/> + <Setter Property="Cursor" Value="Hand"/> + <Setter Property="FontSize" Value="15"/> + <Setter Property="FontWeight" Value="Bold"/> + <Setter Property="VerticalAlignment" Value="Center"/> + <Setter Property="ToolTip" Value="Close tab"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="Button"> + <Border x:Name="border" + Background="{TemplateBinding Background}" + CornerRadius="3" + Width="{TemplateBinding Width}" + Height="{TemplateBinding Height}"> + <TextBlock Text="×" + FontSize="{TemplateBinding FontSize}" + FontWeight="{TemplateBinding FontWeight}" + Foreground="{TemplateBinding Foreground}" + HorizontalAlignment="Center" + VerticalAlignment="Center" + Margin="0,-2,0,0"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="border" Property="Background" Value="#C42B1C"/> + <Setter Property="Foreground" Value="White"/> + </Trigger> + <Trigger Property="IsPressed" Value="True"> + <Setter TargetName="border" Property="Background" Value="#9A2316"/> + <Setter Property="Foreground" Value="White"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- Success Button Style (for Refresh) --> + <Style x:Key="SuccessButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}"> + <Setter Property="Background" Value="{StaticResource SuccessBrush}"/> + <Setter Property="Foreground" Value="#EEF4FA"/> + <Setter Property="FontWeight" Value="SemiBold"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="Button"> + <Border x:Name="border" + Background="{TemplateBinding Background}" + BorderThickness="0" + CornerRadius="3" + Padding="{TemplateBinding Padding}" + TextElement.Foreground="{TemplateBinding Foreground}"> + <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="border" Property="Background" Value="#5DBF60"/> + </Trigger> + <Trigger Property="IsPressed" Value="True"> + <Setter TargetName="border" Property="Background" Value="#3D9140"/> + </Trigger> + <Trigger Property="IsEnabled" Value="False"> + <Setter Property="Opacity" Value="0.5"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- TextBox Style --> + <!-- ============================================ --> + + <!-- Named style for filter TextBoxes in DataGrid column headers --> + <Style x:Key="FilterTextBoxStyle" TargetType="TextBox"> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="#1A2A3A"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Padding" Value="4,2"/> + <Setter Property="CaretBrush" Value="#1A2A3A"/> + <Setter Property="SelectionBrush" Value="{StaticResource AccentBrush}"/> + </Style> + + <!-- Column Filter Button Style - small button with filter icon --> + <Style x:Key="ColumnFilterButtonStyle" TargetType="Button"> + <Setter Property="Width" Value="18"/> + <Setter Property="Height" Value="18"/> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Cursor" Value="Hand"/> + <Setter Property="Content" Value=""/> + <Setter Property="FontFamily" Value="Segoe MDL2 Assets"/> + <Setter Property="FontSize" Value="11"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundDimBrush}"/> + <Setter Property="VerticalAlignment" Value="Center"/> + <Setter Property="Padding" Value="0"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="Button"> + <Border Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}"> + <ContentPresenter HorizontalAlignment="Center" + VerticalAlignment="Center"/> + </Border> + </ControlTemplate> + </Setter.Value> + </Setter> + <Style.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + </Trigger> + </Style.Triggers> + </Style> + + <!-- Column Filter Button Style when filter is active --> + <Style x:Key="ColumnFilterButtonActiveStyle" TargetType="Button" BasedOn="{StaticResource ColumnFilterButtonStyle}"> + <Setter Property="Foreground" Value="#FFD700"/> + <Setter Property="Background" Value="#33FFD700"/> + <Setter Property="Content" Value=""/> + <Setter Property="ToolTip" Value="Filter active — click to modify"/> + <Style.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter Property="Foreground" Value="#FFEC8B"/> + <Setter Property="Background" Value="#55FFD700"/> + </Trigger> + </Style.Triggers> + </Style> + + <Style TargetType="TextBox"> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Padding" Value="6,4"/> + <Setter Property="CaretBrush" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="SelectionBrush" Value="{StaticResource AccentBrush}"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="TextBox"> + <Border x:Name="border" + Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + CornerRadius="3" + TextElement.Foreground="{StaticResource ForegroundBrush}"> + <ScrollViewer x:Name="PART_ContentHost" + Background="{StaticResource BackgroundDarkBrush}" + Margin="{TemplateBinding Padding}" + Foreground="{StaticResource ForegroundBrush}"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsFocused" Value="True"> + <Setter TargetName="border" Property="BorderBrush" Value="{StaticResource AccentBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- PasswordBox Style --> + <!-- ============================================ --> + <Style TargetType="PasswordBox"> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Padding" Value="6,4"/> + <Setter Property="CaretBrush" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="SelectionBrush" Value="{StaticResource AccentBrush}"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="PasswordBox"> + <Border x:Name="border" + Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + CornerRadius="3"> + <ScrollViewer x:Name="PART_ContentHost" + Margin="{TemplateBinding Padding}"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsFocused" Value="True"> + <Setter TargetName="border" Property="BorderBrush" Value="{StaticResource AccentBrush}"/> + </Trigger> + <Trigger Property="IsEnabled" Value="False"> + <Setter Property="Opacity" Value="0.5"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- ComboBox Style --> + <!-- ============================================ --> + <ControlTemplate x:Key="ComboBoxToggleButton" TargetType="ToggleButton"> + <Grid> + <Grid.ColumnDefinitions> + <ColumnDefinition/> + <ColumnDefinition Width="20"/> + </Grid.ColumnDefinitions> + <Border x:Name="Border" Grid.ColumnSpan="2" + Background="{StaticResource BackgroundDarkBrush}" + BorderBrush="{StaticResource BorderBrush}" + BorderThickness="1" CornerRadius="2"/> + <Border Grid.Column="0" Margin="1" + Background="{StaticResource BackgroundDarkBrush}" + BorderThickness="0"/> + <Path x:Name="Arrow" Grid.Column="1" HorizontalAlignment="Center" VerticalAlignment="Center" + Data="M 0 0 L 4 4 L 8 0 Z" Fill="{StaticResource ForegroundBrush}"/> + </Grid> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="Border" Property="BorderBrush" Value="{StaticResource AccentBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + + <Style TargetType="ComboBox"> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Padding" Value="6,4"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="ComboBox"> + <Grid> + <ToggleButton x:Name="ToggleButton" + Template="{StaticResource ComboBoxToggleButton}" + Focusable="False" + IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}" + ClickMode="Press"/> + <ContentPresenter x:Name="ContentSite" IsHitTestVisible="False" + Content="{TemplateBinding SelectionBoxItem}" + ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}" + ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}" + Margin="6,4,23,4" VerticalAlignment="Center" HorizontalAlignment="Left"> + <ContentPresenter.Resources> + <Style TargetType="TextBlock"> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + </Style> + </ContentPresenter.Resources> + </ContentPresenter> + <TextBox x:Name="PART_EditableTextBox" + Style="{x:Null}" + HorizontalAlignment="Left" VerticalAlignment="Center" + Margin="6,4,23,4" Focusable="True" + Background="Transparent" Foreground="{StaticResource ForegroundBrush}" + Visibility="Hidden" IsReadOnly="{TemplateBinding IsReadOnly}"/> + <Popup x:Name="Popup" Placement="Bottom" IsOpen="{TemplateBinding IsDropDownOpen}" + AllowsTransparency="True" Focusable="False" PopupAnimation="Slide"> + <Grid x:Name="DropDown" SnapsToDevicePixels="True" + MinWidth="{TemplateBinding ActualWidth}" MaxHeight="{TemplateBinding MaxDropDownHeight}"> + <Border x:Name="DropDownBorder" + Background="{StaticResource BackgroundDarkBrush}" + BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/> + <ScrollViewer Margin="1" SnapsToDevicePixels="True"> + <StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Contained"/> + </ScrollViewer> + </Grid> + </Popup> + </Grid> + <ControlTemplate.Triggers> + <Trigger Property="IsEnabled" Value="False"> + <Setter Property="Foreground" Value="{StaticResource ForegroundMutedBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <Style TargetType="ComboBoxItem"> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="Padding" Value="6,4"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="ComboBoxItem"> + <Border x:Name="Bd" Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + Padding="{TemplateBinding Padding}"> + <ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" + VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsSelected" Value="True"> + <Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentBrush}"/> + </Trigger> + <Trigger Property="IsHighlighted" Value="True"> + <Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentHoverBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- TabControl Style --> + <!-- ============================================ --> + <Style TargetType="TabControl"> + <Setter Property="Background" Value="{StaticResource BackgroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Padding" Value="0"/> + </Style> + + <Style TargetType="TabItem"> + <Setter Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundDimBrush}"/> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Padding" Value="12,8"/> + <Setter Property="Margin" Value="0,0,2,0"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="TabItem"> + <Border x:Name="border" + Background="{TemplateBinding Background}" + BorderThickness="0" + CornerRadius="3,3,0,0" + Padding="{TemplateBinding Padding}" + Margin="{TemplateBinding Margin}"> + <ContentPresenter x:Name="contentPresenter" + ContentSource="Header" + HorizontalAlignment="Center" + VerticalAlignment="Center"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsSelected" Value="True"> + <Setter TargetName="border" Property="Background" Value="{StaticResource AccentBrush}"/> + <Setter Property="Foreground" Value="#EEF4FA"/> + </Trigger> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="border" Property="Background" Value="{StaticResource BackgroundLighterBrush}"/> + </Trigger> + <MultiTrigger> + <MultiTrigger.Conditions> + <Condition Property="IsSelected" Value="True"/> + <Condition Property="IsMouseOver" Value="True"/> + </MultiTrigger.Conditions> + <Setter TargetName="border" Property="Background" Value="{StaticResource AccentHoverBrush}"/> + </MultiTrigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- Sub-tab style: thin accent underline instead of filled background, so the hierarchy + reads at a glance when a main tab's sub-tabs are shown alongside the main tab bar. --> + <Style x:Key="SubTabItemStyle" TargetType="TabItem"> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundDimBrush}"/> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Padding" Value="10,6"/> + <Setter Property="Margin" Value="0,0,4,0"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="TabItem"> + <Border x:Name="border" + Background="{TemplateBinding Background}" + BorderBrush="{StaticResource AccentBrush}" + BorderThickness="0" + Padding="{TemplateBinding Padding}"> + <ContentPresenter ContentSource="Header" + HorizontalAlignment="Center" + VerticalAlignment="Center"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsSelected" Value="True"> + <Setter TargetName="border" Property="BorderThickness" Value="0,0,0,2"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + </Trigger> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="border" Property="Background" Value="{StaticResource BackgroundLighterBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- DataGrid Style --> + <!-- ============================================ --> + <Style TargetType="DataGrid"> + <Setter Property="helpers:DataGridClipboardBehavior.FixHeaderCopy" Value="True"/> + <Setter Property="helpers:DataGridSortBehavior.SortDescendingFirst" Value="True"/> + <Setter Property="helpers:ScrollPanBehavior.EnableMiddleClickPanning" Value="True"/> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="ClipToBounds" Value="True"/> + <Setter Property="Padding" Value="0"/> + <Setter Property="RowBackground" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="AlternatingRowBackground" Value="{StaticResource AlternatingRowBrush}"/> + <Setter Property="GridLinesVisibility" Value="Horizontal"/> + <Setter Property="HorizontalGridLinesBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="VerticalGridLinesBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="HeadersVisibility" Value="Column"/> + <Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Visible"/> + <Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Visible"/> + </Style> + + <!-- Column header resize gripper --> + <Style x:Key="ColumnHeaderGripperStyle" TargetType="{x:Type Thumb}"> + <Setter Property="Width" Value="8"/> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="Cursor" Value="SizeWE"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="{x:Type Thumb}"> + <Border Background="{TemplateBinding Background}" Padding="{TemplateBinding Padding}"/> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <Style TargetType="DataGridColumnHeader"> + <Setter Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="0,0,1,1"/> + <Setter Property="Padding" Value="8,6"/> + <Setter Property="FontWeight" Value="SemiBold"/> + <Setter Property="HorizontalContentAlignment" Value="Left"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="DataGridColumnHeader"> + <Grid> + <Border Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + Padding="{TemplateBinding Padding}"> + <Grid> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*"/> + <ColumnDefinition Width="Auto"/> + </Grid.ColumnDefinitions> + <ContentPresenter Grid.Column="0" + HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" + VerticalAlignment="Center" + SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"/> + <Path x:Name="SortArrow" + Grid.Column="1" + HorizontalAlignment="Center" + VerticalAlignment="Center" + Width="8" Height="6" + Margin="4,0,2,0" + Stretch="Fill" + Opacity="0.7" + Fill="{StaticResource ForegroundDimBrush}" + RenderTransformOrigin="0.5,0.5" + Visibility="Collapsed" + Data="M 0,0 L 8,0 L 4,6 Z"/> + </Grid> + </Border> + <Thumb x:Name="PART_LeftHeaderGripper" + HorizontalAlignment="Left" + Style="{StaticResource ColumnHeaderGripperStyle}"/> + <Thumb x:Name="PART_RightHeaderGripper" + HorizontalAlignment="Right" + Style="{StaticResource ColumnHeaderGripperStyle}"/> + </Grid> + <ControlTemplate.Triggers> + <Trigger Property="SortDirection" Value="Ascending"> + <Setter TargetName="SortArrow" Property="Visibility" Value="Visible"/> + <Setter TargetName="SortArrow" Property="RenderTransform"> + <Setter.Value> + <RotateTransform Angle="180"/> + </Setter.Value> + </Setter> + </Trigger> + <Trigger Property="SortDirection" Value="Descending"> + <Setter TargetName="SortArrow" Property="Visibility" Value="Visible"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- Column header style when filter is active - subtle blue highlight --> + <Style x:Key="FilteredColumnHeaderStyle" TargetType="DataGridColumnHeader" + BasedOn="{StaticResource {x:Type DataGridColumnHeader}}"> + <Setter Property="Background" Value="#9DBFD9"/> + </Style> + + <!-- Foreground to use on selected rows (white text on the cool-breeze accent background) --> + <SolidColorBrush x:Key="GridSelectionForegroundBrush" Color="#FFFFFF"/> + + <!-- Override WPF system selection brushes so all controls (DataGrid, ListBox, etc.) + use the theme accent and the matching readable text color. --> + <SolidColorBrush x:Key="{x:Static SystemColors.HighlightBrushKey}" Color="{StaticResource AccentColor}"/> + <SolidColorBrush x:Key="{x:Static SystemColors.HighlightTextBrushKey}" Color="#FFFFFF"/> + <SolidColorBrush x:Key="{x:Static SystemColors.InactiveSelectionHighlightBrushKey}" Color="{StaticResource AccentColor}"/> + <SolidColorBrush x:Key="{x:Static SystemColors.InactiveSelectionHighlightTextBrushKey}" Color="#FFFFFF"/> + + <!-- Named base style for DataGridTextColumn ElementStyles that must set an explicit + Foreground. BasedOn this style; keep the unselected Foreground setter in the + derived style and let this base trigger override it during row selection. --> + <Style x:Key="DataGridCellText" TargetType="TextBlock"> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Style.Triggers> + <DataTrigger Binding="{Binding IsSelected, RelativeSource={RelativeSource AncestorType=DataGridRow, AncestorLevel=1}, FallbackValue=False}" Value="True"> + <Setter Property="Foreground" Value="{DynamicResource GridSelectionForegroundBrush}"/> + </DataTrigger> + </Style.Triggers> + </Style> + + <!-- Right-aligned text style for numeric DataGrid columns --> + <Style x:Key="NumericCell" TargetType="TextBlock" BasedOn="{StaticResource DataGridCellText}"> + <Setter Property="TextAlignment" Value="Right"/> + </Style> + + <Style TargetType="DataGridCell"> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Padding" Value="8,6"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="DataGridCell"> + <Border Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + Padding="{TemplateBinding Padding}"> + <ContentPresenter VerticalAlignment="Center"/> + </Border> + </ControlTemplate> + </Setter.Value> + </Setter> + <Style.Triggers> + <Trigger Property="IsSelected" Value="True"> + <Setter Property="Background" Value="{StaticResource AccentBrush}"/> + <Setter Property="Foreground" Value="#FFFFFF"/> + </Trigger> + </Style.Triggers> + </Style> + + <Style TargetType="DataGridRow"> + <Setter Property="Background" Value="Transparent"/> + <Style.Triggers> + <Trigger Property="IsSelected" Value="True"> + <Setter Property="Background" Value="{StaticResource AccentBrush}"/> + <Setter Property="Foreground" Value="{StaticResource GridSelectionForegroundBrush}"/> + </Trigger> + <Trigger Property="IsMouseOver" Value="True"> + <Setter Property="Background" Value="{StaticResource BackgroundLighterBrush}"/> + </Trigger> + </Style.Triggers> + </Style> + + <!-- ============================================ --> + <!-- ListView Style --> + <!-- ============================================ --> + <Style TargetType="ListView"> + <Setter Property="helpers:ScrollPanBehavior.EnableMiddleClickPanning" Value="True"/> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="0"/> + </Style> + + <Style TargetType="ListViewItem"> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="Padding" Value="8,6"/> + <Setter Property="Margin" Value="2"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="ListViewItem"> + <Border x:Name="border" + Background="{TemplateBinding Background}" + CornerRadius="3" + Padding="{TemplateBinding Padding}"> + <ContentPresenter/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="border" Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + </Trigger> + <Trigger Property="IsSelected" Value="True"> + <Setter TargetName="border" Property="Background" Value="{StaticResource BackgroundLighterBrush}"/> + <Setter TargetName="border" Property="BorderBrush" Value="{StaticResource AccentBrush}"/> + <Setter TargetName="border" Property="BorderThickness" Value="1"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- ScrollBar Style --> + <!-- ============================================ --> + <Style TargetType="ScrollBar"> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + </Style> + + <!-- ============================================ --> + <!-- Border Style for Panels --> + <!-- ============================================ --> + <Style x:Key="PanelBorder" TargetType="Border"> + <Setter Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="CornerRadius" Value="3"/> + <Setter Property="Padding" Value="10"/> + </Style> + + <!-- Card Style for Chart Containers --> + <Style x:Key="ChartCard" TargetType="Border"> + <Setter Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="CornerRadius" Value="3"/> + <Setter Property="Margin" Value="5"/> + </Style> + + <!-- ============================================ --> + <!-- DatePickerTextBox Style (internal control) --> + <!-- ============================================ --> + <Style TargetType="DatePickerTextBox"> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="DatePickerTextBox"> + <Grid Background="{StaticResource BackgroundDarkBrush}"> + <Border x:Name="Border" + Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}"> + <ScrollViewer x:Name="PART_ContentHost" Background="{StaticResource BackgroundDarkBrush}" + HorizontalContentAlignment="Stretch" + VerticalContentAlignment="Center"/> + </Border> + <!-- Watermark: hidden via Visibility=Collapsed --> + <ContentControl x:Name="PART_Watermark" + Focusable="False" + IsHitTestVisible="False" + Visibility="Collapsed" + Foreground="{StaticResource ForegroundMutedBrush}"/> + </Grid> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- DatePicker Style --> + <!-- ============================================ --> + <Style TargetType="DatePicker"> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Padding" Value="4,2"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="DatePicker"> + <Border x:Name="OuterBorder" + Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + CornerRadius="3" + Padding="{TemplateBinding Padding}"> + <Grid x:Name="PART_Root" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*"/> + <ColumnDefinition Width="Auto"/> + </Grid.ColumnDefinitions> + <DatePickerTextBox x:Name="PART_TextBox" + Grid.Column="0" + Focusable="True" + HorizontalContentAlignment="Stretch" + VerticalContentAlignment="Center" + Background="Transparent" + Foreground="{StaticResource ForegroundBrush}"/> + <Button x:Name="PART_Button" + Grid.Column="1" + Focusable="False" + Foreground="{StaticResource ForegroundBrush}" + Background="Transparent" + BorderThickness="0" + Padding="4,0"> + <Path Data="M0,0 L4,4 L8,0 Z" + Fill="{StaticResource ForegroundBrush}" + VerticalAlignment="Center"/> + </Button> + <Popup x:Name="PART_Popup" + AllowsTransparency="True" + Placement="Bottom" + PlacementTarget="{Binding ElementName=OuterBorder}"/> + </Grid> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="OuterBorder" Property="BorderBrush" Value="{StaticResource AccentBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- Calendar Styles for DatePicker dropdown --> + <!-- ============================================ --> + + <!-- Calendar main style with full template --> + <Style TargetType="Calendar"> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="Calendar"> + <Border Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + CornerRadius="3"> + <CalendarItem x:Name="PART_CalendarItem" + Background="{StaticResource BackgroundDarkBrush}" + BorderThickness="0"/> + </Border> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- CalendarItem style (the container for month/year views) --> + <Style TargetType="CalendarItem"> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="CalendarItem"> + <Border Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + + <!-- Header: Previous/Next buttons and Month/Year title --> + <Grid Grid.Row="0" Background="{StaticResource BackgroundLightBrush}"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="*"/> + <ColumnDefinition Width="Auto"/> + </Grid.ColumnDefinitions> + + <Button x:Name="PART_PreviousButton" Grid.Column="0" + Content="◀" FontWeight="Bold" + Background="Transparent" Foreground="{StaticResource ForegroundBrush}" + BorderThickness="0" Padding="10,5" + Cursor="Hand"/> + + <Button x:Name="PART_HeaderButton" Grid.Column="1" + HorizontalAlignment="Center" + Background="Transparent" Foreground="{StaticResource ForegroundBrush}" + BorderThickness="0" FontWeight="SemiBold" FontSize="14" + Padding="5" Cursor="Hand"/> + + <Button x:Name="PART_NextButton" Grid.Column="2" + Content="▶" FontWeight="Bold" + Background="Transparent" Foreground="{StaticResource ForegroundBrush}" + BorderThickness="0" Padding="10,5" + Cursor="Hand"/> + </Grid> + + <!-- Day grid or Month/Year grid --> + <Grid Grid.Row="1" Background="{StaticResource BackgroundDarkBrush}"> + <Grid x:Name="PART_MonthView" Visibility="Visible"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + </Grid.ColumnDefinitions> + </Grid> + <Grid x:Name="PART_YearView" Visibility="Hidden"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + </Grid.ColumnDefinitions> + </Grid> + </Grid> + </Grid> + </Border> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- CalendarDayButton style (individual day cells) --> + <Style TargetType="CalendarDayButton"> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="MinWidth" Value="32"/> + <Setter Property="MinHeight" Value="32"/> + <Setter Property="FontSize" Value="12"/> + <Setter Property="HorizontalContentAlignment" Value="Center"/> + <Setter Property="VerticalContentAlignment" Value="Center"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="CalendarDayButton"> + <Grid> + <Border x:Name="Background" Background="Transparent" CornerRadius="3"/> + <ContentPresenter x:Name="Content" + HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" + VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/> + </Grid> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="Background" Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + </Trigger> + <Trigger Property="IsSelected" Value="True"> + <Setter TargetName="Background" Property="Background" Value="{StaticResource AccentBrush}"/> + </Trigger> + <Trigger Property="IsToday" Value="True"> + <Setter TargetName="Background" Property="BorderBrush" Value="{StaticResource AccentBrush}"/> + <Setter TargetName="Background" Property="BorderThickness" Value="1"/> + </Trigger> + <Trigger Property="IsInactive" Value="True"> + <Setter Property="Foreground" Value="{StaticResource ForegroundMutedBrush}"/> + </Trigger> + <Trigger Property="IsBlackedOut" Value="True"> + <Setter Property="Foreground" Value="{StaticResource ForegroundMutedBrush}"/> + <Setter Property="Opacity" Value="0.4"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- CalendarButton style (month/year selection cells) --> + <Style TargetType="CalendarButton"> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="MinWidth" Value="50"/> + <Setter Property="MinHeight" Value="32"/> + <Setter Property="FontSize" Value="12"/> + <Setter Property="HorizontalContentAlignment" Value="Center"/> + <Setter Property="VerticalContentAlignment" Value="Center"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="CalendarButton"> + <Grid> + <Border x:Name="Background" Background="Transparent" CornerRadius="3"/> + <ContentPresenter x:Name="Content" + HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" + VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/> + </Grid> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="Background" Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + </Trigger> + <Trigger Property="HasSelectedDays" Value="True"> + <Setter TargetName="Background" Property="Background" Value="{StaticResource AccentBrush}"/> + </Trigger> + <Trigger Property="IsInactive" Value="True"> + <Setter Property="Foreground" Value="{StaticResource ForegroundMutedBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- CheckBox Style --> + <!-- ============================================ --> + <Style TargetType="CheckBox"> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="CheckBox"> + <BulletDecorator Background="Transparent" VerticalAlignment="Center"> + <BulletDecorator.Bullet> + <Border x:Name="Border" Width="16" Height="16" + Background="{StaticResource BackgroundDarkBrush}" + BorderBrush="{StaticResource BorderBrush}" + BorderThickness="1" CornerRadius="2"> + <Path x:Name="CheckMark" Width="10" Height="10" Stretch="Fill" + Stroke="{StaticResource AccentBrush}" StrokeThickness="2" + Data="M 0,5 L 4,9 L 10,0" Visibility="Collapsed" + HorizontalAlignment="Center" VerticalAlignment="Center"/> + </Border> + </BulletDecorator.Bullet> + <ContentPresenter Margin="6,0,0,0" + VerticalAlignment="Center" + HorizontalAlignment="Left" + RecognizesAccessKey="True"/> + </BulletDecorator> + <ControlTemplate.Triggers> + <Trigger Property="IsChecked" Value="True"> + <Setter TargetName="CheckMark" Property="Visibility" Value="Visible"/> + <Setter TargetName="Border" Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + </Trigger> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="Border" Property="BorderBrush" Value="{StaticResource AccentBrush}"/> + </Trigger> + <Trigger Property="IsEnabled" Value="False"> + <Setter TargetName="Border" Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter TargetName="Border" Property="BorderBrush" Value="{StaticResource ForegroundMutedBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundMutedBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- ContextMenu Style --> + <!-- ============================================ --> + <Style TargetType="ContextMenu"> + <Setter Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderLightBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Padding" Value="2"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="ContextMenu"> + <Border Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + Padding="{TemplateBinding Padding}" + CornerRadius="4"> + <StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Cycle"/> + </Border> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <Style TargetType="MenuItem"> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="MenuItem"> + <Border x:Name="Border" Background="{TemplateBinding Background}" BorderThickness="0"> + <Grid> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="26" SharedSizeGroup="MenuItemIconColumnGroup"/> + <ColumnDefinition Width="*"/> + <ColumnDefinition Width="17"/> + </Grid.ColumnDefinitions> + <!-- Icon column --> + <Border x:Name="IconColumn" Grid.Column="0" Background="Transparent"/> + <ContentPresenter x:Name="Icon" Grid.Column="0" Margin="4,0,6,0" VerticalAlignment="Center" ContentSource="Icon"/> + <ContentPresenter Grid.Column="1" Margin="8,4" VerticalAlignment="Center" ContentSource="Header" RecognizesAccessKey="True"/> + </Grid> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsHighlighted" Value="True"> + <Setter TargetName="Border" Property="Background" Value="{StaticResource BackgroundLighterBrush}"/> + </Trigger> + <Trigger Property="IsEnabled" Value="False"> + <Setter Property="Foreground" Value="{StaticResource ForegroundMutedBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- Separator Style --> + <!-- ============================================ --> + <Style TargetType="Separator"> + <Setter Property="Background" Value="{StaticResource BorderBrush}"/> + <Setter Property="Margin" Value="0,4"/> + <Setter Property="Height" Value="1"/> + </Style> + + <!-- ============================================ --> + <!-- ToolTip Style --> + <!-- ============================================ --> + <Style TargetType="ToolTip"> + <Setter Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderLightBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Padding" Value="10,8"/> + </Style> + + + <!-- Viewer secondary-window surfaces (write-back windows that merge ViewerDarkTheme.xaml). Cool-breeze values. --> + <SolidColorBrush x:Key="CardBorderBrush" Color="{StaticResource BorderLightColor}"/> + <SolidColorBrush x:Key="ViewerSelectionBrush" Color="#B7CEE2"/> + <SolidColorBrush x:Key="ViewerHoverBrush" Color="{StaticResource BackgroundLighterColor}"/> + <SolidColorBrush x:Key="ViewerGridHeaderBrush" Color="{StaticResource BackgroundLighterColor}"/> + <SolidColorBrush x:Key="ViewerAltRowBrush" Color="{StaticResource AlternatingRowColor}"/> + <SolidColorBrush x:Key="ViewerButtonBorderBrush" Color="{StaticResource BorderLightColor}"/> + <SolidColorBrush x:Key="ViewerDisabledBgBrush" Color="{StaticResource BackgroundLighterColor}"/> + <SolidColorBrush x:Key="ViewerDisabledTextBrush" Color="#8CA0B2"/> + + <!-- Time Range Slicer (cool-breeze values; #1441 completeness — these 10 keys existed only in Dark) --> + <SolidColorBrush x:Key="SlicerBackgroundBrush" Color="{StaticResource BackgroundDarkColor}"/> + <SolidColorBrush x:Key="SlicerChartLineBrush" Color="{StaticResource AccentColor}"/> + <SolidColorBrush x:Key="SlicerChartFillBrush" Color="#331E6FA8"/> + <SolidColorBrush x:Key="SlicerOverlayBrush" Color="#40000000"/> + <SolidColorBrush x:Key="SlicerSelectedBrush" Color="#221E6FA8"/> + <SolidColorBrush x:Key="SlicerHandleBrush" Color="{StaticResource ForegroundColor}"/> + <SolidColorBrush x:Key="SlicerHandleHoverBrush" Color="{StaticResource AccentColor}"/> + <SolidColorBrush x:Key="SlicerBorderBrush" Color="{StaticResource BorderColor}"/> + <SolidColorBrush x:Key="SlicerLabelBrush" Color="{StaticResource ForegroundColor}"/> + <SolidColorBrush x:Key="SlicerToggleBrush" Color="{StaticResource ForegroundColor}"/> + + <!-- ============================================ --> + <!-- Plan Viewer Resources --> + <!-- ============================================ --> + <SolidColorBrush x:Key="PlanTooltipBgBrush" Color="{StaticResource BackgroundLightColor}"/> + <SolidColorBrush x:Key="PlanTooltipBorderBrush" Color="{StaticResource BorderColor}"/> + <SolidColorBrush x:Key="PlanPanelTextBrush" Color="{StaticResource ForegroundColor}"/> + <SolidColorBrush x:Key="PlanPanelMutedBrush" Color="{StaticResource ForegroundMutedColor}"/> + <SolidColorBrush x:Key="PlanSectionHeaderBrush" Color="{StaticResource AccentPressedColor}"/> + <SolidColorBrush x:Key="PlanPropSeparatorBrush" Color="{StaticResource BorderColor}"/> + <SolidColorBrush x:Key="PlanMissingIndexBgBrush" Color="#FFF3C8"/> + <SolidColorBrush x:Key="PlanMissingIndexBorderBrush" Color="#EEB800"/> + <SolidColorBrush x:Key="PlanMissingIndexHeaderBrush" Color="#BF4700"/> + <SolidColorBrush x:Key="PlanWaitStatsBgBrush" Color="#D5E9F5"/> + <SolidColorBrush x:Key="PlanWaitStatsHeaderBrush" Color="#0D47A1"/> + +</ResourceDictionary> + + diff --git a/Darling/PerformanceMonitor.Darling.Viewer/Themes/DarkTheme.xaml b/Darling/PerformanceMonitor.Darling.Viewer/Themes/DarkTheme.xaml new file mode 100644 index 000000000..e5c5e6e01 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/Themes/DarkTheme.xaml @@ -0,0 +1,1322 @@ +<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:sys="clr-namespace:System;assembly=mscorlib" + xmlns:helpers="clr-namespace:PerformanceMonitor.Ui;assembly=PerformanceMonitor.Ui"> + + <!-- ============================================ --> + <!-- Darling Data Dark Theme --> + <!-- Flat/Minimal Design --> + <!-- ============================================ --> + + <!-- Brand Colors --> + <Color x:Key="AccentColor">#2eaef1</Color> + <Color x:Key="AccentHoverColor">#5bc4f5</Color> + <Color x:Key="AccentPressedColor">#1a9ae0</Color> + + <!-- Background Colors (Grafana-inspired blue-gray tints) --> + <Color x:Key="BackgroundDarkColor">#111217</Color> + <Color x:Key="AlternatingRowColor">#1c1f25</Color> + <Color x:Key="BackgroundColor">#181b1f</Color> + <Color x:Key="BackgroundLightColor">#22252b</Color> + <Color x:Key="BackgroundLighterColor">#2a2d35</Color> + + <!-- Text Colors: 3-tier readable hierarchy, all >= 4.5:1 on the dark backgrounds + (Foreground ~11-14:1, Dim ~8.5:1, Muted ~6:1). Foreground = primary values/headers; + Dim = secondary captions/labels (dimmed a step off primary); Muted = the quietest tier + for empty-state/placeholder text. Dim stays brighter than Muted. NOTE: no truly-dim tone + here on purpose; every tier must remain comfortably readable. --> + <Color x:Key="ForegroundColor">#E4E6EB</Color> + <Color x:Key="ForegroundDimColor">#C7CBD4</Color> + <Color x:Key="ForegroundMutedColor">#A8AEBA</Color> + + <!-- Border Colors (subtle, low-contrast) --> + <Color x:Key="BorderColor">#2a2d35</Color> + <Color x:Key="BorderLightColor">#33363e</Color> + + <!-- Status Colors (Material Design 300 — softer against dark backgrounds) --> + <Color x:Key="SuccessColor">#81C784</Color> + <Color x:Key="WarningColor">#FFD54F</Color> + <Color x:Key="ErrorColor">#E57373</Color> + <Color x:Key="InfoColor">#2eaef1</Color> + + <!-- Spacing Scale --> + <sys:Double x:Key="SpacingXS">2</sys:Double> + <sys:Double x:Key="SpacingSm">4</sys:Double> + <sys:Double x:Key="SpacingMd">8</sys:Double> + <sys:Double x:Key="SpacingLg">12</sys:Double> + <sys:Double x:Key="SpacingXL">16</sys:Double> + + <!-- Corner Radius --> + <CornerRadius x:Key="CornerRadiusSm">2</CornerRadius> + <CornerRadius x:Key="CornerRadiusMd">4</CornerRadius> + <CornerRadius x:Key="CornerRadiusLg">6</CornerRadius> + + <!-- Brushes --> + <SolidColorBrush x:Key="AccentBrush" Color="{StaticResource AccentColor}"/> + <SolidColorBrush x:Key="AccentHoverBrush" Color="{StaticResource AccentHoverColor}"/> + <SolidColorBrush x:Key="AccentPressedBrush" Color="{StaticResource AccentPressedColor}"/> + + <SolidColorBrush x:Key="BackgroundDarkBrush" Color="{StaticResource BackgroundDarkColor}"/> + <SolidColorBrush x:Key="AlternatingRowBrush" Color="{StaticResource AlternatingRowColor}"/> + <SolidColorBrush x:Key="BackgroundBrush" Color="{StaticResource BackgroundColor}"/> + <SolidColorBrush x:Key="BackgroundLightBrush" Color="{StaticResource BackgroundLightColor}"/> + <SolidColorBrush x:Key="BackgroundLighterBrush" Color="{StaticResource BackgroundLighterColor}"/> + + <SolidColorBrush x:Key="ForegroundBrush" Color="{StaticResource ForegroundColor}"/> + <SolidColorBrush x:Key="ForegroundDimBrush" Color="{StaticResource ForegroundDimColor}"/> + <SolidColorBrush x:Key="ForegroundMutedBrush" Color="{StaticResource ForegroundMutedColor}"/> + + <SolidColorBrush x:Key="BorderBrush" Color="{StaticResource BorderColor}"/> + <SolidColorBrush x:Key="BorderLightBrush" Color="{StaticResource BorderLightColor}"/> + + <SolidColorBrush x:Key="SuccessBrush" Color="{StaticResource SuccessColor}"/> + <SolidColorBrush x:Key="WarningBrush" Color="{StaticResource WarningColor}"/> + <!-- Legible warning amber for content text/borders (block-chain longest-wait, flag banner). + Tuned per theme so it clears >=4.5:1 as TEXT on content backgrounds; any control may adopt it. --> + <SolidColorBrush x:Key="WarningTextBrush" Color="#FFB347"/> + <!-- Apex (lead-blocker) node + legend border. Theme-aware so it clears >=3:1 as a graphical border on + the light node card (bright #4FA3FF is only ~2.6:1 on white). The LEAD BLOCKER badge fill keeps the + bright ApexBrush constant so the apex still pops. --> + <SolidColorBrush x:Key="ApexBorderBrush" Color="#4FA3FF"/> + <!-- Deadlock victim node border + VICTIM badge fill, and the "waits for" edge/arrow. Tuned per theme + so the victim red clears >=3:1 as a border and the edge reads as a line on the canvas. --> + <SolidColorBrush x:Key="DeadlockVictimBrush" Color="#FF5C5C"/> + <SolidColorBrush x:Key="DeadlockEdgeBrush" Color="#8B93A1"/> + <SolidColorBrush x:Key="ErrorBrush" Color="{StaticResource ErrorColor}"/> + <SolidColorBrush x:Key="InfoBrush" Color="{StaticResource InfoColor}"/> + + <!-- ============================================ --> + <!-- Window Style --> + <!-- ============================================ --> + <Style TargetType="Window"> + <Setter Property="Background" Value="{StaticResource BackgroundBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + </Style> + + <!-- ============================================ --> + <!-- TextBlock Style --> + <!-- ============================================ --> + <Style TargetType="TextBlock"> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + </Style> + + <!-- ============================================ --> + <!-- Label Style --> + <!-- ============================================ --> + <Style TargetType="Label"> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + </Style> + + <!-- ============================================ --> + <!-- Button Style - Flat Design --> + <!-- ============================================ --> + <Style TargetType="Button"> + <Setter Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + <Setter Property="Foreground" Value="#E4E6EB"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Padding" Value="12,6"/> + <Setter Property="Cursor" Value="Hand"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="Button"> + <Border x:Name="border" + Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + CornerRadius="3" + Padding="{TemplateBinding Padding}" + TextElement.Foreground="{TemplateBinding Foreground}"> + <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Trigger.EnterActions> + <BeginStoryboard> + <Storyboard> + <ColorAnimation Storyboard.TargetName="border" Storyboard.TargetProperty="(Border.Background).(SolidColorBrush.Color)" To="{StaticResource BackgroundLighterColor}" Duration="0:0:0.12"/> + </Storyboard> + </BeginStoryboard> + </Trigger.EnterActions> + <Trigger.ExitActions> + <BeginStoryboard> + <Storyboard> + <ColorAnimation Storyboard.TargetName="border" Storyboard.TargetProperty="(Border.Background).(SolidColorBrush.Color)" To="{StaticResource BackgroundLightColor}" Duration="0:0:0.12"/> + </Storyboard> + </BeginStoryboard> + </Trigger.ExitActions> + </Trigger> + <Trigger Property="IsPressed" Value="True"> + <Setter TargetName="border" Property="Background" Value="{StaticResource AccentPressedBrush}"/> + </Trigger> + <Trigger Property="IsEnabled" Value="False"> + <Setter Property="Opacity" Value="0.5"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- Accent Button Style (for primary actions) --> + <Style x:Key="AccentButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}"> + <Setter Property="Background" Value="{StaticResource AccentBrush}"/> + <Setter Property="FontWeight" Value="SemiBold"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="Button"> + <Border x:Name="border" + Background="{TemplateBinding Background}" + BorderThickness="0" + CornerRadius="3" + Padding="{TemplateBinding Padding}" + TextElement.Foreground="{TemplateBinding Foreground}"> + <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="border" Property="Background" Value="{StaticResource AccentHoverBrush}"/> + </Trigger> + <Trigger Property="IsPressed" Value="True"> + <Setter TargetName="border" Property="Background" Value="{StaticResource AccentPressedBrush}"/> + </Trigger> + <Trigger Property="IsEnabled" Value="False"> + <Setter Property="Opacity" Value="0.5"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- No Data Message Style (for empty DataGrids) --> + <Style x:Key="NoDataMessage" TargetType="TextBlock"> + <Setter Property="Text" Value="No data for selected time range"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundMutedBrush}"/> + <Setter Property="FontSize" Value="14"/> + <Setter Property="HorizontalAlignment" Value="Center"/> + <Setter Property="VerticalAlignment" Value="Center"/> + <Setter Property="Visibility" Value="Collapsed"/> + </Style> + + <!-- Alert Badge Styles --> + <Style x:Key="AlertBadge" TargetType="Border"> + <Setter Property="Background" Value="#D97706"/> + <Setter Property="CornerRadius" Value="3"/> + <Setter Property="Padding" Value="4,1"/> + <Setter Property="Margin" Value="6,0,0,0"/> + <Setter Property="VerticalAlignment" Value="Center"/> + </Style> + + <Style x:Key="AlertBadgeCritical" TargetType="Border"> + <Setter Property="Background" Value="#DC2626"/> + <Setter Property="CornerRadius" Value="3"/> + <Setter Property="Padding" Value="4,1"/> + <Setter Property="Margin" Value="6,0,0,0"/> + <Setter Property="VerticalAlignment" Value="Center"/> + </Style> + + <!-- ToggleButton Style (respects Background in all states) --> + <Style TargetType="ToggleButton"> + <Setter Property="Background" Value="#2a2d35"/> + <Setter Property="Foreground" Value="#E4E6EB"/> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Padding" Value="12,5"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="ToggleButton"> + <Border x:Name="Border" + Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + Padding="{TemplateBinding Padding}" + CornerRadius="2"> + <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="Border" Property="Opacity" Value="0.85"/> + </Trigger> + <Trigger Property="IsPressed" Value="True"> + <Setter TargetName="Border" Property="Opacity" Value="0.7"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- Tab Close Button Style --> + <Style x:Key="TabCloseButton" TargetType="Button"> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="Foreground" Value="White"/> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Width" Value="22"/> + <Setter Property="Height" Value="22"/> + <Setter Property="Padding" Value="0"/> + <Setter Property="Margin" Value="6,0,0,0"/> + <Setter Property="Cursor" Value="Hand"/> + <Setter Property="FontSize" Value="15"/> + <Setter Property="FontWeight" Value="Bold"/> + <Setter Property="VerticalAlignment" Value="Center"/> + <Setter Property="ToolTip" Value="Close tab"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="Button"> + <Border x:Name="border" + Background="{TemplateBinding Background}" + CornerRadius="3" + Width="{TemplateBinding Width}" + Height="{TemplateBinding Height}"> + <TextBlock Text="×" + FontSize="{TemplateBinding FontSize}" + FontWeight="{TemplateBinding FontWeight}" + Foreground="{TemplateBinding Foreground}" + HorizontalAlignment="Center" + VerticalAlignment="Center" + Margin="0,-2,0,0"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="border" Property="Background" Value="#C42B1C"/> + <Setter Property="Foreground" Value="White"/> + </Trigger> + <Trigger Property="IsPressed" Value="True"> + <Setter TargetName="border" Property="Background" Value="#9A2316"/> + <Setter Property="Foreground" Value="White"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- Success Button Style (for Refresh) --> + <Style x:Key="SuccessButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}"> + <Setter Property="Background" Value="{StaticResource SuccessBrush}"/> + <Setter Property="Foreground" Value="#E4E6EB"/> + <Setter Property="FontWeight" Value="SemiBold"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="Button"> + <Border x:Name="border" + Background="{TemplateBinding Background}" + BorderThickness="0" + CornerRadius="3" + Padding="{TemplateBinding Padding}" + TextElement.Foreground="{TemplateBinding Foreground}"> + <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="border" Property="Background" Value="#5DBF60"/> + </Trigger> + <Trigger Property="IsPressed" Value="True"> + <Setter TargetName="border" Property="Background" Value="#3D9140"/> + </Trigger> + <Trigger Property="IsEnabled" Value="False"> + <Setter Property="Opacity" Value="0.5"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- TextBox Style --> + <!-- ============================================ --> + + <!-- Named style for filter TextBoxes in DataGrid column headers --> + <Style x:Key="FilterTextBoxStyle" TargetType="TextBox"> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="#E4E6EB"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Padding" Value="4,2"/> + <Setter Property="CaretBrush" Value="#E4E6EB"/> + <Setter Property="SelectionBrush" Value="{StaticResource AccentBrush}"/> + </Style> + + <!-- Column Filter Button Style - small button with filter icon --> + <Style x:Key="ColumnFilterButtonStyle" TargetType="Button"> + <Setter Property="Width" Value="18"/> + <Setter Property="Height" Value="18"/> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Cursor" Value="Hand"/> + <Setter Property="Content" Value=""/> + <Setter Property="FontFamily" Value="Segoe MDL2 Assets"/> + <Setter Property="FontSize" Value="11"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundDimBrush}"/> + <Setter Property="VerticalAlignment" Value="Center"/> + <Setter Property="Padding" Value="0"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="Button"> + <Border Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}"> + <ContentPresenter HorizontalAlignment="Center" + VerticalAlignment="Center"/> + </Border> + </ControlTemplate> + </Setter.Value> + </Setter> + <Style.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + </Trigger> + </Style.Triggers> + </Style> + + <!-- Column Filter Button Style when filter is active --> + <Style x:Key="ColumnFilterButtonActiveStyle" TargetType="Button" BasedOn="{StaticResource ColumnFilterButtonStyle}"> + <Setter Property="Foreground" Value="#FFD700"/> + <Setter Property="Background" Value="#33FFD700"/> + <Setter Property="Content" Value=""/> + <Setter Property="ToolTip" Value="Filter active — click to modify"/> + <Style.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter Property="Foreground" Value="#FFEC8B"/> + <Setter Property="Background" Value="#55FFD700"/> + </Trigger> + </Style.Triggers> + </Style> + + <Style TargetType="TextBox"> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Padding" Value="6,4"/> + <Setter Property="CaretBrush" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="SelectionBrush" Value="{StaticResource AccentBrush}"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="TextBox"> + <Border x:Name="border" + Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + CornerRadius="3" + TextElement.Foreground="{StaticResource ForegroundBrush}"> + <ScrollViewer x:Name="PART_ContentHost" + Background="{StaticResource BackgroundDarkBrush}" + Margin="{TemplateBinding Padding}" + Foreground="{StaticResource ForegroundBrush}"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsFocused" Value="True"> + <Setter TargetName="border" Property="BorderBrush" Value="{StaticResource AccentBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- PasswordBox Style --> + <!-- ============================================ --> + <Style TargetType="PasswordBox"> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Padding" Value="6,4"/> + <Setter Property="CaretBrush" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="SelectionBrush" Value="{StaticResource AccentBrush}"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="PasswordBox"> + <Border x:Name="border" + Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + CornerRadius="3"> + <ScrollViewer x:Name="PART_ContentHost" + Margin="{TemplateBinding Padding}"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsFocused" Value="True"> + <Setter TargetName="border" Property="BorderBrush" Value="{StaticResource AccentBrush}"/> + </Trigger> + <Trigger Property="IsEnabled" Value="False"> + <Setter Property="Opacity" Value="0.5"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- ComboBox Style --> + <!-- ============================================ --> + <ControlTemplate x:Key="ComboBoxToggleButton" TargetType="ToggleButton"> + <Grid> + <Grid.ColumnDefinitions> + <ColumnDefinition/> + <ColumnDefinition Width="20"/> + </Grid.ColumnDefinitions> + <Border x:Name="Border" Grid.ColumnSpan="2" + Background="{StaticResource BackgroundDarkBrush}" + BorderBrush="{StaticResource BorderBrush}" + BorderThickness="1" CornerRadius="2"/> + <Border Grid.Column="0" Margin="1" + Background="{StaticResource BackgroundDarkBrush}" + BorderThickness="0"/> + <Path x:Name="Arrow" Grid.Column="1" HorizontalAlignment="Center" VerticalAlignment="Center" + Data="M 0 0 L 4 4 L 8 0 Z" Fill="{StaticResource ForegroundBrush}"/> + </Grid> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="Border" Property="BorderBrush" Value="{StaticResource AccentBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + + <Style TargetType="ComboBox"> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Padding" Value="6,4"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="ComboBox"> + <Grid> + <ToggleButton x:Name="ToggleButton" + Template="{StaticResource ComboBoxToggleButton}" + Focusable="False" + IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}" + ClickMode="Press"/> + <ContentPresenter x:Name="ContentSite" IsHitTestVisible="False" + Content="{TemplateBinding SelectionBoxItem}" + ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}" + ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}" + Margin="6,4,23,4" VerticalAlignment="Center" HorizontalAlignment="Left"> + <ContentPresenter.Resources> + <Style TargetType="TextBlock"> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + </Style> + </ContentPresenter.Resources> + </ContentPresenter> + <TextBox x:Name="PART_EditableTextBox" + Style="{x:Null}" + HorizontalAlignment="Left" VerticalAlignment="Center" + Margin="6,4,23,4" Focusable="True" + Background="Transparent" Foreground="{StaticResource ForegroundBrush}" + Visibility="Hidden" IsReadOnly="{TemplateBinding IsReadOnly}"/> + <Popup x:Name="Popup" Placement="Bottom" IsOpen="{TemplateBinding IsDropDownOpen}" + AllowsTransparency="True" Focusable="False" PopupAnimation="Slide"> + <Grid x:Name="DropDown" SnapsToDevicePixels="True" + MinWidth="{TemplateBinding ActualWidth}" MaxHeight="{TemplateBinding MaxDropDownHeight}"> + <Border x:Name="DropDownBorder" + Background="{StaticResource BackgroundDarkBrush}" + BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/> + <ScrollViewer Margin="1" SnapsToDevicePixels="True"> + <StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Contained"/> + </ScrollViewer> + </Grid> + </Popup> + </Grid> + <ControlTemplate.Triggers> + <Trigger Property="IsEnabled" Value="False"> + <Setter Property="Foreground" Value="{StaticResource ForegroundMutedBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <Style TargetType="ComboBoxItem"> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="Padding" Value="6,4"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="ComboBoxItem"> + <Border x:Name="Bd" Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + Padding="{TemplateBinding Padding}"> + <ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" + VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsSelected" Value="True"> + <Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentBrush}"/> + </Trigger> + <Trigger Property="IsHighlighted" Value="True"> + <Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentHoverBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- TabControl Style --> + <!-- ============================================ --> + <Style TargetType="TabControl"> + <Setter Property="Background" Value="{StaticResource BackgroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Padding" Value="0"/> + </Style> + + <Style TargetType="TabItem"> + <Setter Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundDimBrush}"/> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Padding" Value="12,8"/> + <Setter Property="Margin" Value="0,0,2,0"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="TabItem"> + <Border x:Name="border" + Background="{TemplateBinding Background}" + BorderThickness="0" + CornerRadius="3,3,0,0" + Padding="{TemplateBinding Padding}" + Margin="{TemplateBinding Margin}"> + <ContentPresenter x:Name="contentPresenter" + ContentSource="Header" + HorizontalAlignment="Center" + VerticalAlignment="Center"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsSelected" Value="True"> + <Setter TargetName="border" Property="Background" Value="{StaticResource AccentBrush}"/> + <Setter Property="Foreground" Value="#E4E6EB"/> + </Trigger> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="border" Property="Background" Value="{StaticResource BackgroundLighterBrush}"/> + </Trigger> + <MultiTrigger> + <MultiTrigger.Conditions> + <Condition Property="IsSelected" Value="True"/> + <Condition Property="IsMouseOver" Value="True"/> + </MultiTrigger.Conditions> + <Setter TargetName="border" Property="Background" Value="{StaticResource AccentHoverBrush}"/> + </MultiTrigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- Sub-tab style: thin accent underline instead of filled background, so the hierarchy + reads at a glance when a main tab's sub-tabs are shown alongside the main tab bar. --> + <Style x:Key="SubTabItemStyle" TargetType="TabItem"> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundDimBrush}"/> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Padding" Value="10,6"/> + <Setter Property="Margin" Value="0,0,4,0"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="TabItem"> + <Border x:Name="border" + Background="{TemplateBinding Background}" + BorderBrush="{StaticResource AccentBrush}" + BorderThickness="0" + Padding="{TemplateBinding Padding}"> + <ContentPresenter ContentSource="Header" + HorizontalAlignment="Center" + VerticalAlignment="Center"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsSelected" Value="True"> + <Setter TargetName="border" Property="BorderThickness" Value="0,0,0,2"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + </Trigger> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="border" Property="Background" Value="{StaticResource BackgroundLighterBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- DataGrid Style --> + <!-- ============================================ --> + <Style TargetType="DataGrid"> + <Setter Property="helpers:DataGridClipboardBehavior.FixHeaderCopy" Value="True"/> + <Setter Property="helpers:DataGridSortBehavior.SortDescendingFirst" Value="True"/> + <Setter Property="helpers:ScrollPanBehavior.EnableMiddleClickPanning" Value="True"/> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="ClipToBounds" Value="True"/> + <Setter Property="Padding" Value="0"/> + <Setter Property="RowBackground" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="AlternatingRowBackground" Value="{StaticResource AlternatingRowBrush}"/> + <Setter Property="GridLinesVisibility" Value="Horizontal"/> + <Setter Property="HorizontalGridLinesBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="VerticalGridLinesBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="HeadersVisibility" Value="Column"/> + <Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Visible"/> + <Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Visible"/> + </Style> + + <!-- Column header resize gripper --> + <Style x:Key="ColumnHeaderGripperStyle" TargetType="{x:Type Thumb}"> + <Setter Property="Width" Value="8"/> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="Cursor" Value="SizeWE"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="{x:Type Thumb}"> + <Border Background="{TemplateBinding Background}" Padding="{TemplateBinding Padding}"/> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <Style TargetType="DataGridColumnHeader"> + <Setter Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="0,0,1,1"/> + <Setter Property="Padding" Value="8,6"/> + <Setter Property="FontWeight" Value="SemiBold"/> + <Setter Property="HorizontalContentAlignment" Value="Left"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="DataGridColumnHeader"> + <Grid> + <Border Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + Padding="{TemplateBinding Padding}"> + <Grid> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*"/> + <ColumnDefinition Width="Auto"/> + </Grid.ColumnDefinitions> + <ContentPresenter Grid.Column="0" + HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" + VerticalAlignment="Center" + SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"/> + <Path x:Name="SortArrow" + Grid.Column="1" + HorizontalAlignment="Center" + VerticalAlignment="Center" + Width="8" Height="6" + Margin="4,0,2,0" + Stretch="Fill" + Opacity="0.7" + Fill="{StaticResource ForegroundDimBrush}" + RenderTransformOrigin="0.5,0.5" + Visibility="Collapsed" + Data="M 0,0 L 8,0 L 4,6 Z"/> + </Grid> + </Border> + <Thumb x:Name="PART_LeftHeaderGripper" + HorizontalAlignment="Left" + Style="{StaticResource ColumnHeaderGripperStyle}"/> + <Thumb x:Name="PART_RightHeaderGripper" + HorizontalAlignment="Right" + Style="{StaticResource ColumnHeaderGripperStyle}"/> + </Grid> + <ControlTemplate.Triggers> + <Trigger Property="SortDirection" Value="Ascending"> + <Setter TargetName="SortArrow" Property="Visibility" Value="Visible"/> + <Setter TargetName="SortArrow" Property="RenderTransform"> + <Setter.Value> + <RotateTransform Angle="180"/> + </Setter.Value> + </Setter> + </Trigger> + <Trigger Property="SortDirection" Value="Descending"> + <Setter TargetName="SortArrow" Property="Visibility" Value="Visible"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- Column header style when filter is active - subtle blue highlight --> + <Style x:Key="FilteredColumnHeaderStyle" TargetType="DataGridColumnHeader" + BasedOn="{StaticResource {x:Type DataGridColumnHeader}}"> + <Setter Property="Background" Value="#3D5A80"/> + </Style> + + <!-- Foreground to use on selected rows (light text on the blue accent background) --> + <SolidColorBrush x:Key="GridSelectionForegroundBrush" Color="#E4E6EB"/> + + <!-- Override WPF system selection brushes so all controls (DataGrid, ListBox, etc.) + use the theme accent and the matching readable text color. --> + <SolidColorBrush x:Key="{x:Static SystemColors.HighlightBrushKey}" Color="{StaticResource AccentColor}"/> + <SolidColorBrush x:Key="{x:Static SystemColors.HighlightTextBrushKey}" Color="#E4E6EB"/> + <SolidColorBrush x:Key="{x:Static SystemColors.InactiveSelectionHighlightBrushKey}" Color="{StaticResource AccentColor}"/> + <SolidColorBrush x:Key="{x:Static SystemColors.InactiveSelectionHighlightTextBrushKey}" Color="#E4E6EB"/> + + <!-- Named base style for DataGridTextColumn ElementStyles that must set an explicit + Foreground. BasedOn this style; keep the unselected Foreground setter in the + derived style and let this base trigger override it during row selection. --> + <Style x:Key="DataGridCellText" TargetType="TextBlock"> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Style.Triggers> + <DataTrigger Binding="{Binding IsSelected, RelativeSource={RelativeSource AncestorType=DataGridRow, AncestorLevel=1}, FallbackValue=False}" Value="True"> + <Setter Property="Foreground" Value="{DynamicResource GridSelectionForegroundBrush}"/> + </DataTrigger> + </Style.Triggers> + </Style> + + <!-- Right-aligned text style for numeric DataGrid columns --> + <Style x:Key="NumericCell" TargetType="TextBlock" BasedOn="{StaticResource DataGridCellText}"> + <Setter Property="TextAlignment" Value="Right"/> + </Style> + + <Style TargetType="DataGridCell"> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Padding" Value="8,6"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="DataGridCell"> + <Border Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + Padding="{TemplateBinding Padding}"> + <ContentPresenter VerticalAlignment="Center"/> + </Border> + </ControlTemplate> + </Setter.Value> + </Setter> + <Style.Triggers> + <Trigger Property="IsSelected" Value="True"> + <Setter Property="Background" Value="{StaticResource AccentBrush}"/> + <Setter Property="Foreground" Value="#E4E6EB"/> + </Trigger> + </Style.Triggers> + </Style> + + <Style TargetType="DataGridRow"> + <Setter Property="Background" Value="Transparent"/> + <Style.Triggers> + <Trigger Property="IsSelected" Value="True"> + <Setter Property="Background" Value="{StaticResource AccentBrush}"/> + <Setter Property="Foreground" Value="{StaticResource GridSelectionForegroundBrush}"/> + </Trigger> + <Trigger Property="IsMouseOver" Value="True"> + <Setter Property="Background" Value="{StaticResource BackgroundLighterBrush}"/> + </Trigger> + </Style.Triggers> + </Style> + + <!-- ============================================ --> + <!-- ListView Style --> + <!-- ============================================ --> + <Style TargetType="ListView"> + <Setter Property="helpers:ScrollPanBehavior.EnableMiddleClickPanning" Value="True"/> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="0"/> + </Style> + + <Style TargetType="ListViewItem"> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="Padding" Value="8,6"/> + <Setter Property="Margin" Value="2"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="ListViewItem"> + <Border x:Name="border" + Background="{TemplateBinding Background}" + CornerRadius="3" + Padding="{TemplateBinding Padding}"> + <ContentPresenter/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="border" Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + </Trigger> + <Trigger Property="IsSelected" Value="True"> + <Setter TargetName="border" Property="Background" Value="{StaticResource BackgroundLighterBrush}"/> + <Setter TargetName="border" Property="BorderBrush" Value="{StaticResource AccentBrush}"/> + <Setter TargetName="border" Property="BorderThickness" Value="1"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- ScrollBar Style --> + <!-- ============================================ --> + <Style TargetType="ScrollBar"> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + </Style> + + <!-- ============================================ --> + <!-- Border Style for Panels --> + <!-- ============================================ --> + <Style x:Key="PanelBorder" TargetType="Border"> + <Setter Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="CornerRadius" Value="3"/> + <Setter Property="Padding" Value="10"/> + </Style> + + <!-- Card Style for Chart Containers --> + <Style x:Key="ChartCard" TargetType="Border"> + <Setter Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="CornerRadius" Value="3"/> + <Setter Property="Margin" Value="5"/> + </Style> + + <!-- ============================================ --> + <!-- DatePickerTextBox Style (internal control) --> + <!-- ============================================ --> + <Style TargetType="DatePickerTextBox"> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="DatePickerTextBox"> + <Grid Background="{StaticResource BackgroundDarkBrush}"> + <Border x:Name="Border" + Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}"> + <ScrollViewer x:Name="PART_ContentHost" Background="{StaticResource BackgroundDarkBrush}" + HorizontalContentAlignment="Stretch" + VerticalContentAlignment="Center"/> + </Border> + <!-- Watermark: hidden via Visibility=Collapsed --> + <ContentControl x:Name="PART_Watermark" + Focusable="False" + IsHitTestVisible="False" + Visibility="Collapsed" + Foreground="{StaticResource ForegroundMutedBrush}"/> + </Grid> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- DatePicker Style --> + <!-- ============================================ --> + <Style TargetType="DatePicker"> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Padding" Value="4,2"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="DatePicker"> + <Border x:Name="OuterBorder" + Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + CornerRadius="3" + Padding="{TemplateBinding Padding}"> + <Grid x:Name="PART_Root" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*"/> + <ColumnDefinition Width="Auto"/> + </Grid.ColumnDefinitions> + <DatePickerTextBox x:Name="PART_TextBox" + Grid.Column="0" + Focusable="True" + HorizontalContentAlignment="Stretch" + VerticalContentAlignment="Center" + Background="Transparent" + Foreground="{StaticResource ForegroundBrush}"/> + <Button x:Name="PART_Button" + Grid.Column="1" + Focusable="False" + Foreground="{StaticResource ForegroundBrush}" + Background="Transparent" + BorderThickness="0" + Padding="4,0"> + <Path Data="M0,0 L4,4 L8,0 Z" + Fill="{StaticResource ForegroundBrush}" + VerticalAlignment="Center"/> + </Button> + <Popup x:Name="PART_Popup" + AllowsTransparency="True" + Placement="Bottom" + PlacementTarget="{Binding ElementName=OuterBorder}"/> + </Grid> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="OuterBorder" Property="BorderBrush" Value="{StaticResource AccentBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- Calendar Styles for DatePicker dropdown --> + <!-- ============================================ --> + + <!-- Calendar main style with full template --> + <Style TargetType="Calendar"> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="Calendar"> + <Border Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + CornerRadius="3"> + <CalendarItem x:Name="PART_CalendarItem" + Background="{StaticResource BackgroundDarkBrush}" + BorderThickness="0"/> + </Border> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- CalendarItem style (the container for month/year views) --> + <Style TargetType="CalendarItem"> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="CalendarItem"> + <Border Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + + <!-- Header: Previous/Next buttons and Month/Year title --> + <Grid Grid.Row="0" Background="{StaticResource BackgroundLightBrush}"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="*"/> + <ColumnDefinition Width="Auto"/> + </Grid.ColumnDefinitions> + + <Button x:Name="PART_PreviousButton" Grid.Column="0" + Content="◀" FontWeight="Bold" + Background="Transparent" Foreground="{StaticResource ForegroundBrush}" + BorderThickness="0" Padding="10,5" + Cursor="Hand"/> + + <Button x:Name="PART_HeaderButton" Grid.Column="1" + HorizontalAlignment="Center" + Background="Transparent" Foreground="{StaticResource ForegroundBrush}" + BorderThickness="0" FontWeight="SemiBold" FontSize="14" + Padding="5" Cursor="Hand"/> + + <Button x:Name="PART_NextButton" Grid.Column="2" + Content="▶" FontWeight="Bold" + Background="Transparent" Foreground="{StaticResource ForegroundBrush}" + BorderThickness="0" Padding="10,5" + Cursor="Hand"/> + </Grid> + + <!-- Day grid or Month/Year grid --> + <Grid Grid.Row="1" Background="{StaticResource BackgroundDarkBrush}"> + <Grid x:Name="PART_MonthView" Visibility="Visible"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + </Grid.ColumnDefinitions> + </Grid> + <Grid x:Name="PART_YearView" Visibility="Hidden"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + </Grid.ColumnDefinitions> + </Grid> + </Grid> + </Grid> + </Border> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- CalendarDayButton style (individual day cells) --> + <Style TargetType="CalendarDayButton"> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="MinWidth" Value="32"/> + <Setter Property="MinHeight" Value="32"/> + <Setter Property="FontSize" Value="12"/> + <Setter Property="HorizontalContentAlignment" Value="Center"/> + <Setter Property="VerticalContentAlignment" Value="Center"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="CalendarDayButton"> + <Grid> + <Border x:Name="Background" Background="Transparent" CornerRadius="3"/> + <ContentPresenter x:Name="Content" + HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" + VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/> + </Grid> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="Background" Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + </Trigger> + <Trigger Property="IsSelected" Value="True"> + <Setter TargetName="Background" Property="Background" Value="{StaticResource AccentBrush}"/> + </Trigger> + <Trigger Property="IsToday" Value="True"> + <Setter TargetName="Background" Property="BorderBrush" Value="{StaticResource AccentBrush}"/> + <Setter TargetName="Background" Property="BorderThickness" Value="1"/> + </Trigger> + <Trigger Property="IsInactive" Value="True"> + <Setter Property="Foreground" Value="{StaticResource ForegroundMutedBrush}"/> + </Trigger> + <Trigger Property="IsBlackedOut" Value="True"> + <Setter Property="Foreground" Value="{StaticResource ForegroundMutedBrush}"/> + <Setter Property="Opacity" Value="0.4"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- CalendarButton style (month/year selection cells) --> + <Style TargetType="CalendarButton"> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="MinWidth" Value="50"/> + <Setter Property="MinHeight" Value="32"/> + <Setter Property="FontSize" Value="12"/> + <Setter Property="HorizontalContentAlignment" Value="Center"/> + <Setter Property="VerticalContentAlignment" Value="Center"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="CalendarButton"> + <Grid> + <Border x:Name="Background" Background="Transparent" CornerRadius="3"/> + <ContentPresenter x:Name="Content" + HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" + VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/> + </Grid> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="Background" Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + </Trigger> + <Trigger Property="HasSelectedDays" Value="True"> + <Setter TargetName="Background" Property="Background" Value="{StaticResource AccentBrush}"/> + </Trigger> + <Trigger Property="IsInactive" Value="True"> + <Setter Property="Foreground" Value="{StaticResource ForegroundMutedBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- CheckBox Style --> + <!-- ============================================ --> + <Style TargetType="CheckBox"> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="CheckBox"> + <BulletDecorator Background="Transparent" VerticalAlignment="Center"> + <BulletDecorator.Bullet> + <Border x:Name="Border" Width="16" Height="16" + Background="{StaticResource BackgroundDarkBrush}" + BorderBrush="{StaticResource BorderBrush}" + BorderThickness="1" CornerRadius="2"> + <Path x:Name="CheckMark" Width="10" Height="10" Stretch="Fill" + Stroke="{StaticResource AccentBrush}" StrokeThickness="2" + Data="M 0,5 L 4,9 L 10,0" Visibility="Collapsed" + HorizontalAlignment="Center" VerticalAlignment="Center"/> + </Border> + </BulletDecorator.Bullet> + <ContentPresenter Margin="6,0,0,0" + VerticalAlignment="Center" + HorizontalAlignment="Left" + RecognizesAccessKey="True"/> + </BulletDecorator> + <ControlTemplate.Triggers> + <Trigger Property="IsChecked" Value="True"> + <Setter TargetName="CheckMark" Property="Visibility" Value="Visible"/> + <Setter TargetName="Border" Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + </Trigger> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="Border" Property="BorderBrush" Value="{StaticResource AccentBrush}"/> + </Trigger> + <Trigger Property="IsEnabled" Value="False"> + <Setter TargetName="Border" Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter TargetName="Border" Property="BorderBrush" Value="{StaticResource ForegroundMutedBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundMutedBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- ContextMenu Style --> + <!-- ============================================ --> + <Style TargetType="ContextMenu"> + <Setter Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderLightBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Padding" Value="2"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="ContextMenu"> + <Border Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + Padding="{TemplateBinding Padding}" + CornerRadius="4"> + <StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Cycle"/> + </Border> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <Style TargetType="MenuItem"> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="MenuItem"> + <Border x:Name="Border" Background="{TemplateBinding Background}" BorderThickness="0"> + <Grid> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="26" SharedSizeGroup="MenuItemIconColumnGroup"/> + <ColumnDefinition Width="*"/> + <ColumnDefinition Width="17"/> + </Grid.ColumnDefinitions> + <!-- Icon column --> + <Border x:Name="IconColumn" Grid.Column="0" Background="Transparent"/> + <ContentPresenter x:Name="Icon" Grid.Column="0" Margin="4,0,6,0" VerticalAlignment="Center" ContentSource="Icon"/> + <ContentPresenter Grid.Column="1" Margin="8,4" VerticalAlignment="Center" ContentSource="Header" RecognizesAccessKey="True"/> + </Grid> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsHighlighted" Value="True"> + <Setter TargetName="Border" Property="Background" Value="{StaticResource BackgroundLighterBrush}"/> + </Trigger> + <Trigger Property="IsEnabled" Value="False"> + <Setter Property="Foreground" Value="{StaticResource ForegroundMutedBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- Separator Style --> + <!-- ============================================ --> + <Style TargetType="Separator"> + <Setter Property="Background" Value="{StaticResource BorderBrush}"/> + <Setter Property="Margin" Value="0,4"/> + <Setter Property="Height" Value="1"/> + </Style> + + <!-- ============================================ --> + <!-- ToolTip Style --> + <!-- ============================================ --> + <Style TargetType="ToolTip"> + <Setter Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderLightBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Padding" Value="10,8"/> + </Style> + + <!-- Viewer secondary-window surfaces (the read-only write-back windows that merge + ViewerDarkTheme.xaml). Dark values equal the literals they replace so dark is pixel-identical; + Light/CoolBreeze give them themed values. --> + <SolidColorBrush x:Key="CardBorderBrush" Color="#3A3D45"/> + <SolidColorBrush x:Key="ViewerSelectionBrush" Color="#2F3542"/> + <SolidColorBrush x:Key="ViewerHoverBrush" Color="#1F232B"/> + <SolidColorBrush x:Key="ViewerGridHeaderBrush" Color="#262932"/> + <SolidColorBrush x:Key="ViewerAltRowBrush" Color="#16181D"/> + <SolidColorBrush x:Key="ViewerButtonBorderBrush" Color="#3A4150"/> + <SolidColorBrush x:Key="ViewerDisabledBgBrush" Color="#1B1E24"/> + <SolidColorBrush x:Key="ViewerDisabledTextBrush" Color="#5C6472"/> + + <!-- Time Range Slicer --> + <SolidColorBrush x:Key="SlicerBackgroundBrush" Color="#15171C"/> + <SolidColorBrush x:Key="SlicerChartLineBrush" Color="#2EAEF1"/> + <SolidColorBrush x:Key="SlicerChartFillBrush" Color="#332EAEF1"/> + <SolidColorBrush x:Key="SlicerOverlayBrush" Color="#99000000"/> + <SolidColorBrush x:Key="SlicerSelectedBrush" Color="#22FFFFFF"/> + <SolidColorBrush x:Key="SlicerHandleBrush" Color="#E4E6EB"/> + <SolidColorBrush x:Key="SlicerHandleHoverBrush" Color="#2EAEF1"/> + <SolidColorBrush x:Key="SlicerBorderBrush" Color="#3A3D45"/> + <SolidColorBrush x:Key="SlicerLabelBrush" Color="#E4E6EB"/> + <SolidColorBrush x:Key="SlicerToggleBrush" Color="#E4E6EB"/> + + <!-- ============================================ --> + <!-- Plan Viewer Resources --> + <!-- ============================================ --> + <SolidColorBrush x:Key="PlanTooltipBgBrush" Color="#1A1D23"/> + <SolidColorBrush x:Key="PlanTooltipBorderBrush" Color="#3A3D45"/> + <SolidColorBrush x:Key="PlanPanelTextBrush" Color="#E4E6EB"/> + <SolidColorBrush x:Key="PlanPanelMutedBrush" Color="#E4E6EB"/> + <SolidColorBrush x:Key="PlanSectionHeaderBrush" Color="#4FA3FF"/> + <SolidColorBrush x:Key="PlanPropSeparatorBrush" Color="#2A2D35"/> + <SolidColorBrush x:Key="PlanMissingIndexBgBrush" Color="#3D2A0E"/> + <SolidColorBrush x:Key="PlanMissingIndexBorderBrush" Color="#7A5A1E"/> + <SolidColorBrush x:Key="PlanMissingIndexHeaderBrush" Color="#FFB347"/> + <SolidColorBrush x:Key="PlanWaitStatsBgBrush" Color="#1A2A3D"/> + <SolidColorBrush x:Key="PlanWaitStatsHeaderBrush" Color="#4FA3FF"/> + +</ResourceDictionary> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/Themes/LightTheme.xaml b/Darling/PerformanceMonitor.Darling.Viewer/Themes/LightTheme.xaml new file mode 100644 index 000000000..e21307a5b --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/Themes/LightTheme.xaml @@ -0,0 +1,1322 @@ +<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:sys="clr-namespace:System;assembly=mscorlib" + xmlns:helpers="clr-namespace:PerformanceMonitor.Ui;assembly=PerformanceMonitor.Ui"> + + <!-- ============================================ --> + <!-- Darling Data Light Theme --> + <!-- Flat/Minimal Design --> + <!-- ============================================ --> + + <!-- Brand Colors --> + <Color x:Key="AccentColor">#2eaef1</Color> + <Color x:Key="AccentHoverColor">#5bc4f5</Color> + <Color x:Key="AccentPressedColor">#1a9ae0</Color> + + <!-- Background Colors --> + <Color x:Key="BackgroundDarkColor">#F0F2F5</Color> + <Color x:Key="AlternatingRowColor">#EDF1F7</Color> + <Color x:Key="BackgroundColor">#F5F7FA</Color> + <Color x:Key="BackgroundLightColor">#FFFFFF</Color> + <Color x:Key="BackgroundLighterColor">#E8ECF0</Color> + + <!-- Text Colors (dark on light backgrounds) --> + <Color x:Key="ForegroundColor">#1A1D23</Color> + <Color x:Key="ForegroundDimColor">#1A1D23</Color> + <!-- Muted (tertiary) text. #1441: the old #718096 was only 3.38:1 on the light wells and + failed WCAG AA. #5A6472 is ~6:1 on #FFFFFF / ~5.6:1 on #F5F7FA — a readable secondary + tone that still reads quieter than primary, mirroring the Dark muted tier. --> + <Color x:Key="ForegroundMutedColor">#5A6472</Color> + + <!-- Border Colors (subtle, low-contrast) --> + <Color x:Key="BorderColor">#DEE2E6</Color> + <Color x:Key="BorderLightColor">#CED4DA</Color> + + <!-- Status Colors (Material Design 700 — visible against light backgrounds) --> + <Color x:Key="SuccessColor">#2E7D32</Color> + <Color x:Key="WarningColor">#F57F17</Color> + <Color x:Key="ErrorColor">#C62828</Color> + <Color x:Key="InfoColor">#2eaef1</Color> + + <!-- Spacing Scale --> + <sys:Double x:Key="SpacingXS">2</sys:Double> + <sys:Double x:Key="SpacingSm">4</sys:Double> + <sys:Double x:Key="SpacingMd">8</sys:Double> + <sys:Double x:Key="SpacingLg">12</sys:Double> + <sys:Double x:Key="SpacingXL">16</sys:Double> + + <!-- Corner Radius --> + <CornerRadius x:Key="CornerRadiusSm">2</CornerRadius> + <CornerRadius x:Key="CornerRadiusMd">4</CornerRadius> + <CornerRadius x:Key="CornerRadiusLg">6</CornerRadius> + + <!-- Brushes --> + <SolidColorBrush x:Key="AccentBrush" Color="{StaticResource AccentColor}"/> + <SolidColorBrush x:Key="AccentHoverBrush" Color="{StaticResource AccentHoverColor}"/> + <SolidColorBrush x:Key="AccentPressedBrush" Color="{StaticResource AccentPressedColor}"/> + + <SolidColorBrush x:Key="BackgroundDarkBrush" Color="{StaticResource BackgroundDarkColor}"/> + <SolidColorBrush x:Key="AlternatingRowBrush" Color="{StaticResource AlternatingRowColor}"/> + <SolidColorBrush x:Key="BackgroundBrush" Color="{StaticResource BackgroundColor}"/> + <SolidColorBrush x:Key="BackgroundLightBrush" Color="{StaticResource BackgroundLightColor}"/> + <SolidColorBrush x:Key="BackgroundLighterBrush" Color="{StaticResource BackgroundLighterColor}"/> + + <SolidColorBrush x:Key="ForegroundBrush" Color="{StaticResource ForegroundColor}"/> + <SolidColorBrush x:Key="ForegroundDimBrush" Color="{StaticResource ForegroundDimColor}"/> + <!-- #1441: use the (now readable) muted color so Light has a real 3-tier text hierarchy + like Dark, instead of collapsing muted onto the primary foreground. --> + <SolidColorBrush x:Key="ForegroundMutedBrush" Color="{StaticResource ForegroundMutedColor}"/> + + <SolidColorBrush x:Key="BorderBrush" Color="{StaticResource BorderColor}"/> + <SolidColorBrush x:Key="BorderLightBrush" Color="{StaticResource BorderLightColor}"/> + + <SolidColorBrush x:Key="SuccessBrush" Color="{StaticResource SuccessColor}"/> + <SolidColorBrush x:Key="WarningBrush" Color="{StaticResource WarningColor}"/> + <!-- Legible warning amber for content text/borders (block-chain longest-wait, flag banner). + Darker on light themes so it clears >=4.5:1 as TEXT on content backgrounds; any control may adopt it. --> + <SolidColorBrush x:Key="WarningTextBrush" Color="#92400E"/> + <!-- Apex (lead-blocker) node + legend border. Darker blue on light themes so it clears >=3:1 as a + graphical border on the light node card (bright #4FA3FF is only ~2.6:1 on white). The LEAD BLOCKER + badge fill keeps the bright ApexBrush constant so the apex still pops. --> + <SolidColorBrush x:Key="ApexBorderBrush" Color="#1976D2"/> + <!-- Deadlock victim node border + VICTIM badge fill, and the "waits for" edge/arrow. Tuned per theme + so the victim red clears >=3:1 as a border on the light node card and the edge reads on the canvas. --> + <SolidColorBrush x:Key="DeadlockVictimBrush" Color="#C62828"/> + <SolidColorBrush x:Key="DeadlockEdgeBrush" Color="#5A6270"/> + <SolidColorBrush x:Key="ErrorBrush" Color="{StaticResource ErrorColor}"/> + <SolidColorBrush x:Key="InfoBrush" Color="{StaticResource InfoColor}"/> + + <!-- ============================================ --> + <!-- Window Style --> + <!-- ============================================ --> + <Style TargetType="Window"> + <Setter Property="Background" Value="{StaticResource BackgroundBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + </Style> + + <!-- ============================================ --> + <!-- TextBlock Style --> + <!-- ============================================ --> + <Style TargetType="TextBlock"> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + </Style> + + <!-- ============================================ --> + <!-- Label Style --> + <!-- ============================================ --> + <Style TargetType="Label"> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + </Style> + + <!-- ============================================ --> + <!-- Button Style - Flat Design --> + <!-- ============================================ --> + <Style TargetType="Button"> + <Setter Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + <Setter Property="Foreground" Value="#1A1D23"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Padding" Value="12,6"/> + <Setter Property="Cursor" Value="Hand"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="Button"> + <Border x:Name="border" + Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + CornerRadius="3" + Padding="{TemplateBinding Padding}" + TextElement.Foreground="{TemplateBinding Foreground}"> + <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Trigger.EnterActions> + <BeginStoryboard> + <Storyboard> + <ColorAnimation Storyboard.TargetName="border" Storyboard.TargetProperty="(Border.Background).(SolidColorBrush.Color)" To="{StaticResource BackgroundLighterColor}" Duration="0:0:0.12"/> + </Storyboard> + </BeginStoryboard> + </Trigger.EnterActions> + <Trigger.ExitActions> + <BeginStoryboard> + <Storyboard> + <ColorAnimation Storyboard.TargetName="border" Storyboard.TargetProperty="(Border.Background).(SolidColorBrush.Color)" To="{StaticResource BackgroundLightColor}" Duration="0:0:0.12"/> + </Storyboard> + </BeginStoryboard> + </Trigger.ExitActions> + </Trigger> + <Trigger Property="IsPressed" Value="True"> + <Setter TargetName="border" Property="Background" Value="{StaticResource AccentPressedBrush}"/> + </Trigger> + <Trigger Property="IsEnabled" Value="False"> + <Setter Property="Opacity" Value="0.5"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- Accent Button Style (for primary actions) --> + <Style x:Key="AccentButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}"> + <Setter Property="Background" Value="{StaticResource AccentBrush}"/> + <Setter Property="FontWeight" Value="SemiBold"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="Button"> + <Border x:Name="border" + Background="{TemplateBinding Background}" + BorderThickness="0" + CornerRadius="3" + Padding="{TemplateBinding Padding}" + TextElement.Foreground="{TemplateBinding Foreground}"> + <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="border" Property="Background" Value="{StaticResource AccentHoverBrush}"/> + </Trigger> + <Trigger Property="IsPressed" Value="True"> + <Setter TargetName="border" Property="Background" Value="{StaticResource AccentPressedBrush}"/> + </Trigger> + <Trigger Property="IsEnabled" Value="False"> + <Setter Property="Opacity" Value="0.5"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- No Data Message Style (for empty DataGrids) --> + <Style x:Key="NoDataMessage" TargetType="TextBlock"> + <Setter Property="Text" Value="No data for selected time range"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundMutedBrush}"/> + <Setter Property="FontSize" Value="14"/> + <Setter Property="HorizontalAlignment" Value="Center"/> + <Setter Property="VerticalAlignment" Value="Center"/> + <Setter Property="Visibility" Value="Collapsed"/> + </Style> + + <!-- Alert Badge Styles --> + <Style x:Key="AlertBadge" TargetType="Border"> + <Setter Property="Background" Value="#D97706"/> + <Setter Property="CornerRadius" Value="3"/> + <Setter Property="Padding" Value="4,1"/> + <Setter Property="Margin" Value="6,0,0,0"/> + <Setter Property="VerticalAlignment" Value="Center"/> + </Style> + + <Style x:Key="AlertBadgeCritical" TargetType="Border"> + <Setter Property="Background" Value="#DC2626"/> + <Setter Property="CornerRadius" Value="3"/> + <Setter Property="Padding" Value="4,1"/> + <Setter Property="Margin" Value="6,0,0,0"/> + <Setter Property="VerticalAlignment" Value="Center"/> + </Style> + + <!-- ToggleButton Style (respects Background in all states) --> + <Style TargetType="ToggleButton"> + <Setter Property="Background" Value="#E8ECF0"/> + <Setter Property="Foreground" Value="#1A1D23"/> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Padding" Value="12,5"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="ToggleButton"> + <Border x:Name="Border" + Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + Padding="{TemplateBinding Padding}" + CornerRadius="2"> + <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="Border" Property="Opacity" Value="0.85"/> + </Trigger> + <Trigger Property="IsPressed" Value="True"> + <Setter TargetName="Border" Property="Opacity" Value="0.7"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- Tab Close Button Style --> + <Style x:Key="TabCloseButton" TargetType="Button"> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="Foreground" Value="White"/> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Width" Value="22"/> + <Setter Property="Height" Value="22"/> + <Setter Property="Padding" Value="0"/> + <Setter Property="Margin" Value="6,0,0,0"/> + <Setter Property="Cursor" Value="Hand"/> + <Setter Property="FontSize" Value="15"/> + <Setter Property="FontWeight" Value="Bold"/> + <Setter Property="VerticalAlignment" Value="Center"/> + <Setter Property="ToolTip" Value="Close tab"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="Button"> + <Border x:Name="border" + Background="{TemplateBinding Background}" + CornerRadius="3" + Width="{TemplateBinding Width}" + Height="{TemplateBinding Height}"> + <TextBlock Text="×" + FontSize="{TemplateBinding FontSize}" + FontWeight="{TemplateBinding FontWeight}" + Foreground="{TemplateBinding Foreground}" + HorizontalAlignment="Center" + VerticalAlignment="Center" + Margin="0,-2,0,0"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="border" Property="Background" Value="#C42B1C"/> + <Setter Property="Foreground" Value="White"/> + </Trigger> + <Trigger Property="IsPressed" Value="True"> + <Setter TargetName="border" Property="Background" Value="#9A2316"/> + <Setter Property="Foreground" Value="White"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- Success Button Style (for Refresh) --> + <Style x:Key="SuccessButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}"> + <Setter Property="Background" Value="{StaticResource SuccessBrush}"/> + <Setter Property="Foreground" Value="#E4E6EB"/> + <Setter Property="FontWeight" Value="SemiBold"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="Button"> + <Border x:Name="border" + Background="{TemplateBinding Background}" + BorderThickness="0" + CornerRadius="3" + Padding="{TemplateBinding Padding}" + TextElement.Foreground="{TemplateBinding Foreground}"> + <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="border" Property="Background" Value="#5DBF60"/> + </Trigger> + <Trigger Property="IsPressed" Value="True"> + <Setter TargetName="border" Property="Background" Value="#3D9140"/> + </Trigger> + <Trigger Property="IsEnabled" Value="False"> + <Setter Property="Opacity" Value="0.5"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- TextBox Style --> + <!-- ============================================ --> + + <!-- Named style for filter TextBoxes in DataGrid column headers --> + <Style x:Key="FilterTextBoxStyle" TargetType="TextBox"> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="#1A1D23"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Padding" Value="4,2"/> + <Setter Property="CaretBrush" Value="#1A1D23"/> + <Setter Property="SelectionBrush" Value="{StaticResource AccentBrush}"/> + </Style> + + <!-- Column Filter Button Style - small button with filter icon --> + <Style x:Key="ColumnFilterButtonStyle" TargetType="Button"> + <Setter Property="Width" Value="18"/> + <Setter Property="Height" Value="18"/> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Cursor" Value="Hand"/> + <Setter Property="Content" Value=""/> + <Setter Property="FontFamily" Value="Segoe MDL2 Assets"/> + <Setter Property="FontSize" Value="11"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundDimBrush}"/> + <Setter Property="VerticalAlignment" Value="Center"/> + <Setter Property="Padding" Value="0"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="Button"> + <Border Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}"> + <ContentPresenter HorizontalAlignment="Center" + VerticalAlignment="Center"/> + </Border> + </ControlTemplate> + </Setter.Value> + </Setter> + <Style.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + </Trigger> + </Style.Triggers> + </Style> + + <!-- Column Filter Button Style when filter is active --> + <Style x:Key="ColumnFilterButtonActiveStyle" TargetType="Button" BasedOn="{StaticResource ColumnFilterButtonStyle}"> + <Setter Property="Foreground" Value="#FFD700"/> + <Setter Property="Background" Value="#33FFD700"/> + <Setter Property="Content" Value=""/> + <Setter Property="ToolTip" Value="Filter active — click to modify"/> + <Style.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter Property="Foreground" Value="#FFEC8B"/> + <Setter Property="Background" Value="#55FFD700"/> + </Trigger> + </Style.Triggers> + </Style> + + <Style TargetType="TextBox"> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Padding" Value="6,4"/> + <Setter Property="CaretBrush" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="SelectionBrush" Value="{StaticResource AccentBrush}"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="TextBox"> + <Border x:Name="border" + Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + CornerRadius="3" + TextElement.Foreground="{StaticResource ForegroundBrush}"> + <ScrollViewer x:Name="PART_ContentHost" + Background="{StaticResource BackgroundDarkBrush}" + Margin="{TemplateBinding Padding}" + Foreground="{StaticResource ForegroundBrush}"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsFocused" Value="True"> + <Setter TargetName="border" Property="BorderBrush" Value="{StaticResource AccentBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- PasswordBox Style --> + <!-- ============================================ --> + <Style TargetType="PasswordBox"> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Padding" Value="6,4"/> + <Setter Property="CaretBrush" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="SelectionBrush" Value="{StaticResource AccentBrush}"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="PasswordBox"> + <Border x:Name="border" + Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + CornerRadius="3"> + <ScrollViewer x:Name="PART_ContentHost" + Margin="{TemplateBinding Padding}"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsFocused" Value="True"> + <Setter TargetName="border" Property="BorderBrush" Value="{StaticResource AccentBrush}"/> + </Trigger> + <Trigger Property="IsEnabled" Value="False"> + <Setter Property="Opacity" Value="0.5"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- ComboBox Style --> + <!-- ============================================ --> + <ControlTemplate x:Key="ComboBoxToggleButton" TargetType="ToggleButton"> + <Grid> + <Grid.ColumnDefinitions> + <ColumnDefinition/> + <ColumnDefinition Width="20"/> + </Grid.ColumnDefinitions> + <Border x:Name="Border" Grid.ColumnSpan="2" + Background="{StaticResource BackgroundDarkBrush}" + BorderBrush="{StaticResource BorderBrush}" + BorderThickness="1" CornerRadius="2"/> + <Border Grid.Column="0" Margin="1" + Background="{StaticResource BackgroundDarkBrush}" + BorderThickness="0"/> + <Path x:Name="Arrow" Grid.Column="1" HorizontalAlignment="Center" VerticalAlignment="Center" + Data="M 0 0 L 4 4 L 8 0 Z" Fill="{StaticResource ForegroundBrush}"/> + </Grid> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="Border" Property="BorderBrush" Value="{StaticResource AccentBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + + <Style TargetType="ComboBox"> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Padding" Value="6,4"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="ComboBox"> + <Grid> + <ToggleButton x:Name="ToggleButton" + Template="{StaticResource ComboBoxToggleButton}" + Focusable="False" + IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}" + ClickMode="Press"/> + <ContentPresenter x:Name="ContentSite" IsHitTestVisible="False" + Content="{TemplateBinding SelectionBoxItem}" + ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}" + ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}" + Margin="6,4,23,4" VerticalAlignment="Center" HorizontalAlignment="Left"> + <ContentPresenter.Resources> + <Style TargetType="TextBlock"> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + </Style> + </ContentPresenter.Resources> + </ContentPresenter> + <TextBox x:Name="PART_EditableTextBox" + Style="{x:Null}" + HorizontalAlignment="Left" VerticalAlignment="Center" + Margin="6,4,23,4" Focusable="True" + Background="Transparent" Foreground="{StaticResource ForegroundBrush}" + Visibility="Hidden" IsReadOnly="{TemplateBinding IsReadOnly}"/> + <Popup x:Name="Popup" Placement="Bottom" IsOpen="{TemplateBinding IsDropDownOpen}" + AllowsTransparency="True" Focusable="False" PopupAnimation="Slide"> + <Grid x:Name="DropDown" SnapsToDevicePixels="True" + MinWidth="{TemplateBinding ActualWidth}" MaxHeight="{TemplateBinding MaxDropDownHeight}"> + <Border x:Name="DropDownBorder" + Background="{StaticResource BackgroundDarkBrush}" + BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"/> + <ScrollViewer Margin="1" SnapsToDevicePixels="True"> + <StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Contained"/> + </ScrollViewer> + </Grid> + </Popup> + </Grid> + <ControlTemplate.Triggers> + <Trigger Property="IsEnabled" Value="False"> + <Setter Property="Foreground" Value="{StaticResource ForegroundMutedBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <Style TargetType="ComboBoxItem"> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="Padding" Value="6,4"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="ComboBoxItem"> + <Border x:Name="Bd" Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + Padding="{TemplateBinding Padding}"> + <ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" + VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsSelected" Value="True"> + <Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentBrush}"/> + </Trigger> + <Trigger Property="IsHighlighted" Value="True"> + <Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentHoverBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- TabControl Style --> + <!-- ============================================ --> + <Style TargetType="TabControl"> + <Setter Property="Background" Value="{StaticResource BackgroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Padding" Value="0"/> + </Style> + + <Style TargetType="TabItem"> + <Setter Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundDimBrush}"/> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Padding" Value="12,8"/> + <Setter Property="Margin" Value="0,0,2,0"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="TabItem"> + <Border x:Name="border" + Background="{TemplateBinding Background}" + BorderThickness="0" + CornerRadius="3,3,0,0" + Padding="{TemplateBinding Padding}" + Margin="{TemplateBinding Margin}"> + <ContentPresenter x:Name="contentPresenter" + ContentSource="Header" + HorizontalAlignment="Center" + VerticalAlignment="Center"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsSelected" Value="True"> + <Setter TargetName="border" Property="Background" Value="{StaticResource AccentBrush}"/> + <Setter Property="Foreground" Value="#E4E6EB"/> + </Trigger> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="border" Property="Background" Value="{StaticResource BackgroundLighterBrush}"/> + </Trigger> + <MultiTrigger> + <MultiTrigger.Conditions> + <Condition Property="IsSelected" Value="True"/> + <Condition Property="IsMouseOver" Value="True"/> + </MultiTrigger.Conditions> + <Setter TargetName="border" Property="Background" Value="{StaticResource AccentHoverBrush}"/> + </MultiTrigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- Sub-tab style: thin accent underline instead of filled background, so the hierarchy + reads at a glance when a main tab's sub-tabs are shown alongside the main tab bar. --> + <Style x:Key="SubTabItemStyle" TargetType="TabItem"> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundDimBrush}"/> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Padding" Value="10,6"/> + <Setter Property="Margin" Value="0,0,4,0"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="TabItem"> + <Border x:Name="border" + Background="{TemplateBinding Background}" + BorderBrush="{StaticResource AccentBrush}" + BorderThickness="0" + Padding="{TemplateBinding Padding}"> + <ContentPresenter ContentSource="Header" + HorizontalAlignment="Center" + VerticalAlignment="Center"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsSelected" Value="True"> + <Setter TargetName="border" Property="BorderThickness" Value="0,0,0,2"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + </Trigger> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="border" Property="Background" Value="{StaticResource BackgroundLighterBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- DataGrid Style --> + <!-- ============================================ --> + <Style TargetType="DataGrid"> + <Setter Property="helpers:DataGridClipboardBehavior.FixHeaderCopy" Value="True"/> + <Setter Property="helpers:DataGridSortBehavior.SortDescendingFirst" Value="True"/> + <Setter Property="helpers:ScrollPanBehavior.EnableMiddleClickPanning" Value="True"/> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="ClipToBounds" Value="True"/> + <Setter Property="Padding" Value="0"/> + <Setter Property="RowBackground" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="AlternatingRowBackground" Value="{StaticResource AlternatingRowBrush}"/> + <Setter Property="GridLinesVisibility" Value="Horizontal"/> + <Setter Property="HorizontalGridLinesBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="VerticalGridLinesBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="HeadersVisibility" Value="Column"/> + <Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Visible"/> + <Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Visible"/> + </Style> + + <!-- Column header resize gripper --> + <Style x:Key="ColumnHeaderGripperStyle" TargetType="{x:Type Thumb}"> + <Setter Property="Width" Value="8"/> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="Cursor" Value="SizeWE"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="{x:Type Thumb}"> + <Border Background="{TemplateBinding Background}" Padding="{TemplateBinding Padding}"/> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <Style TargetType="DataGridColumnHeader"> + <Setter Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="0,0,1,1"/> + <Setter Property="Padding" Value="8,6"/> + <Setter Property="FontWeight" Value="SemiBold"/> + <Setter Property="HorizontalContentAlignment" Value="Left"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="DataGridColumnHeader"> + <Grid> + <Border Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + Padding="{TemplateBinding Padding}"> + <Grid> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*"/> + <ColumnDefinition Width="Auto"/> + </Grid.ColumnDefinitions> + <ContentPresenter Grid.Column="0" + HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" + VerticalAlignment="Center" + SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"/> + <Path x:Name="SortArrow" + Grid.Column="1" + HorizontalAlignment="Center" + VerticalAlignment="Center" + Width="8" Height="6" + Margin="4,0,2,0" + Stretch="Fill" + Opacity="0.7" + Fill="{StaticResource ForegroundDimBrush}" + RenderTransformOrigin="0.5,0.5" + Visibility="Collapsed" + Data="M 0,0 L 8,0 L 4,6 Z"/> + </Grid> + </Border> + <Thumb x:Name="PART_LeftHeaderGripper" + HorizontalAlignment="Left" + Style="{StaticResource ColumnHeaderGripperStyle}"/> + <Thumb x:Name="PART_RightHeaderGripper" + HorizontalAlignment="Right" + Style="{StaticResource ColumnHeaderGripperStyle}"/> + </Grid> + <ControlTemplate.Triggers> + <Trigger Property="SortDirection" Value="Ascending"> + <Setter TargetName="SortArrow" Property="Visibility" Value="Visible"/> + <Setter TargetName="SortArrow" Property="RenderTransform"> + <Setter.Value> + <RotateTransform Angle="180"/> + </Setter.Value> + </Setter> + </Trigger> + <Trigger Property="SortDirection" Value="Descending"> + <Setter TargetName="SortArrow" Property="Visibility" Value="Visible"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- Column header style when filter is active - subtle blue highlight --> + <Style x:Key="FilteredColumnHeaderStyle" TargetType="DataGridColumnHeader" + BasedOn="{StaticResource {x:Type DataGridColumnHeader}}"> + <Setter Property="Background" Value="#BBDEFB"/> + </Style> + + <!-- Foreground to use on selected rows (dark text on the light accent background) --> + <SolidColorBrush x:Key="GridSelectionForegroundBrush" Color="#1A1D23"/> + + <!-- Override WPF system selection brushes so all controls (DataGrid, ListBox, etc.) + use the theme accent and the matching readable text color. --> + <SolidColorBrush x:Key="{x:Static SystemColors.HighlightBrushKey}" Color="{StaticResource AccentColor}"/> + <SolidColorBrush x:Key="{x:Static SystemColors.HighlightTextBrushKey}" Color="#1A1D23"/> + <SolidColorBrush x:Key="{x:Static SystemColors.InactiveSelectionHighlightBrushKey}" Color="{StaticResource AccentColor}"/> + <SolidColorBrush x:Key="{x:Static SystemColors.InactiveSelectionHighlightTextBrushKey}" Color="#1A1D23"/> + + <!-- Named base style for DataGridTextColumn ElementStyles that must set an explicit + Foreground. BasedOn this style; keep the unselected Foreground setter in the + derived style and let this base trigger override it during row selection. --> + <Style x:Key="DataGridCellText" TargetType="TextBlock"> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Style.Triggers> + <DataTrigger Binding="{Binding IsSelected, RelativeSource={RelativeSource AncestorType=DataGridRow, AncestorLevel=1}, FallbackValue=False}" Value="True"> + <Setter Property="Foreground" Value="{DynamicResource GridSelectionForegroundBrush}"/> + </DataTrigger> + </Style.Triggers> + </Style> + + <!-- Right-aligned text style for numeric DataGrid columns --> + <Style x:Key="NumericCell" TargetType="TextBlock" BasedOn="{StaticResource DataGridCellText}"> + <Setter Property="TextAlignment" Value="Right"/> + </Style> + + <Style TargetType="DataGridCell"> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Padding" Value="8,6"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="DataGridCell"> + <Border Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + Padding="{TemplateBinding Padding}"> + <ContentPresenter VerticalAlignment="Center"/> + </Border> + </ControlTemplate> + </Setter.Value> + </Setter> + <Style.Triggers> + <Trigger Property="IsSelected" Value="True"> + <Setter Property="Background" Value="{StaticResource AccentBrush}"/> + <Setter Property="Foreground" Value="#1A1D23"/> + </Trigger> + </Style.Triggers> + </Style> + + <Style TargetType="DataGridRow"> + <Setter Property="Background" Value="Transparent"/> + <Style.Triggers> + <Trigger Property="IsSelected" Value="True"> + <Setter Property="Background" Value="{StaticResource AccentBrush}"/> + <Setter Property="Foreground" Value="{StaticResource GridSelectionForegroundBrush}"/> + </Trigger> + <Trigger Property="IsMouseOver" Value="True"> + <Setter Property="Background" Value="{StaticResource BackgroundLighterBrush}"/> + </Trigger> + </Style.Triggers> + </Style> + + <!-- ============================================ --> + <!-- ListView Style --> + <!-- ============================================ --> + <Style TargetType="ListView"> + <Setter Property="helpers:ScrollPanBehavior.EnableMiddleClickPanning" Value="True"/> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="0"/> + </Style> + + <Style TargetType="ListViewItem"> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="Padding" Value="8,6"/> + <Setter Property="Margin" Value="2"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="ListViewItem"> + <Border x:Name="border" + Background="{TemplateBinding Background}" + CornerRadius="3" + Padding="{TemplateBinding Padding}"> + <ContentPresenter/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="border" Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + </Trigger> + <Trigger Property="IsSelected" Value="True"> + <Setter TargetName="border" Property="Background" Value="{StaticResource BackgroundLighterBrush}"/> + <Setter TargetName="border" Property="BorderBrush" Value="{StaticResource AccentBrush}"/> + <Setter TargetName="border" Property="BorderThickness" Value="1"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- ScrollBar Style --> + <!-- ============================================ --> + <Style TargetType="ScrollBar"> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + </Style> + + <!-- ============================================ --> + <!-- Border Style for Panels --> + <!-- ============================================ --> + <Style x:Key="PanelBorder" TargetType="Border"> + <Setter Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="CornerRadius" Value="3"/> + <Setter Property="Padding" Value="10"/> + </Style> + + <!-- Card Style for Chart Containers --> + <Style x:Key="ChartCard" TargetType="Border"> + <Setter Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="CornerRadius" Value="3"/> + <Setter Property="Margin" Value="5"/> + </Style> + + <!-- ============================================ --> + <!-- DatePickerTextBox Style (internal control) --> + <!-- ============================================ --> + <Style TargetType="DatePickerTextBox"> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="DatePickerTextBox"> + <Grid Background="{StaticResource BackgroundDarkBrush}"> + <Border x:Name="Border" + Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}"> + <ScrollViewer x:Name="PART_ContentHost" Background="{StaticResource BackgroundDarkBrush}" + HorizontalContentAlignment="Stretch" + VerticalContentAlignment="Center"/> + </Border> + <!-- Watermark: hidden via Visibility=Collapsed --> + <ContentControl x:Name="PART_Watermark" + Focusable="False" + IsHitTestVisible="False" + Visibility="Collapsed" + Foreground="{StaticResource ForegroundMutedBrush}"/> + </Grid> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- DatePicker Style --> + <!-- ============================================ --> + <Style TargetType="DatePicker"> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Padding" Value="4,2"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="DatePicker"> + <Border x:Name="OuterBorder" + Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + CornerRadius="3" + Padding="{TemplateBinding Padding}"> + <Grid x:Name="PART_Root" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*"/> + <ColumnDefinition Width="Auto"/> + </Grid.ColumnDefinitions> + <DatePickerTextBox x:Name="PART_TextBox" + Grid.Column="0" + Focusable="True" + HorizontalContentAlignment="Stretch" + VerticalContentAlignment="Center" + Background="Transparent" + Foreground="{StaticResource ForegroundBrush}"/> + <Button x:Name="PART_Button" + Grid.Column="1" + Focusable="False" + Foreground="{StaticResource ForegroundBrush}" + Background="Transparent" + BorderThickness="0" + Padding="4,0"> + <Path Data="M0,0 L4,4 L8,0 Z" + Fill="{StaticResource ForegroundBrush}" + VerticalAlignment="Center"/> + </Button> + <Popup x:Name="PART_Popup" + AllowsTransparency="True" + Placement="Bottom" + PlacementTarget="{Binding ElementName=OuterBorder}"/> + </Grid> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="OuterBorder" Property="BorderBrush" Value="{StaticResource AccentBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- Calendar Styles for DatePicker dropdown --> + <!-- ============================================ --> + + <!-- Calendar main style with full template --> + <Style TargetType="Calendar"> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="Calendar"> + <Border Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + CornerRadius="3"> + <CalendarItem x:Name="PART_CalendarItem" + Background="{StaticResource BackgroundDarkBrush}" + BorderThickness="0"/> + </Border> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- CalendarItem style (the container for month/year views) --> + <Style TargetType="CalendarItem"> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="CalendarItem"> + <Border Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + + <!-- Header: Previous/Next buttons and Month/Year title --> + <Grid Grid.Row="0" Background="{StaticResource BackgroundLightBrush}"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="*"/> + <ColumnDefinition Width="Auto"/> + </Grid.ColumnDefinitions> + + <Button x:Name="PART_PreviousButton" Grid.Column="0" + Content="◀" FontWeight="Bold" + Background="Transparent" Foreground="{StaticResource ForegroundBrush}" + BorderThickness="0" Padding="10,5" + Cursor="Hand"/> + + <Button x:Name="PART_HeaderButton" Grid.Column="1" + HorizontalAlignment="Center" + Background="Transparent" Foreground="{StaticResource ForegroundBrush}" + BorderThickness="0" FontWeight="SemiBold" FontSize="14" + Padding="5" Cursor="Hand"/> + + <Button x:Name="PART_NextButton" Grid.Column="2" + Content="▶" FontWeight="Bold" + Background="Transparent" Foreground="{StaticResource ForegroundBrush}" + BorderThickness="0" Padding="10,5" + Cursor="Hand"/> + </Grid> + + <!-- Day grid or Month/Year grid --> + <Grid Grid.Row="1" Background="{StaticResource BackgroundDarkBrush}"> + <Grid x:Name="PART_MonthView" Visibility="Visible"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + </Grid.ColumnDefinitions> + </Grid> + <Grid x:Name="PART_YearView" Visibility="Hidden"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="Auto"/> + </Grid.ColumnDefinitions> + </Grid> + </Grid> + </Grid> + </Border> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- CalendarDayButton style (individual day cells) --> + <Style TargetType="CalendarDayButton"> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="MinWidth" Value="32"/> + <Setter Property="MinHeight" Value="32"/> + <Setter Property="FontSize" Value="12"/> + <Setter Property="HorizontalContentAlignment" Value="Center"/> + <Setter Property="VerticalContentAlignment" Value="Center"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="CalendarDayButton"> + <Grid> + <Border x:Name="Background" Background="Transparent" CornerRadius="3"/> + <ContentPresenter x:Name="Content" + HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" + VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/> + </Grid> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="Background" Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + </Trigger> + <Trigger Property="IsSelected" Value="True"> + <Setter TargetName="Background" Property="Background" Value="{StaticResource AccentBrush}"/> + </Trigger> + <Trigger Property="IsToday" Value="True"> + <Setter TargetName="Background" Property="BorderBrush" Value="{StaticResource AccentBrush}"/> + <Setter TargetName="Background" Property="BorderThickness" Value="1"/> + </Trigger> + <Trigger Property="IsInactive" Value="True"> + <Setter Property="Foreground" Value="{StaticResource ForegroundMutedBrush}"/> + </Trigger> + <Trigger Property="IsBlackedOut" Value="True"> + <Setter Property="Foreground" Value="{StaticResource ForegroundMutedBrush}"/> + <Setter Property="Opacity" Value="0.4"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- CalendarButton style (month/year selection cells) --> + <Style TargetType="CalendarButton"> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="MinWidth" Value="50"/> + <Setter Property="MinHeight" Value="32"/> + <Setter Property="FontSize" Value="12"/> + <Setter Property="HorizontalContentAlignment" Value="Center"/> + <Setter Property="VerticalContentAlignment" Value="Center"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="CalendarButton"> + <Grid> + <Border x:Name="Background" Background="Transparent" CornerRadius="3"/> + <ContentPresenter x:Name="Content" + HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" + VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/> + </Grid> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="Background" Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + </Trigger> + <Trigger Property="HasSelectedDays" Value="True"> + <Setter TargetName="Background" Property="Background" Value="{StaticResource AccentBrush}"/> + </Trigger> + <Trigger Property="IsInactive" Value="True"> + <Setter Property="Foreground" Value="{StaticResource ForegroundMutedBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- CheckBox Style --> + <!-- ============================================ --> + <Style TargetType="CheckBox"> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="CheckBox"> + <BulletDecorator Background="Transparent" VerticalAlignment="Center"> + <BulletDecorator.Bullet> + <Border x:Name="Border" Width="16" Height="16" + Background="{StaticResource BackgroundDarkBrush}" + BorderBrush="{StaticResource BorderBrush}" + BorderThickness="1" CornerRadius="2"> + <Path x:Name="CheckMark" Width="10" Height="10" Stretch="Fill" + Stroke="{StaticResource AccentBrush}" StrokeThickness="2" + Data="M 0,5 L 4,9 L 10,0" Visibility="Collapsed" + HorizontalAlignment="Center" VerticalAlignment="Center"/> + </Border> + </BulletDecorator.Bullet> + <ContentPresenter Margin="6,0,0,0" + VerticalAlignment="Center" + HorizontalAlignment="Left" + RecognizesAccessKey="True"/> + </BulletDecorator> + <ControlTemplate.Triggers> + <Trigger Property="IsChecked" Value="True"> + <Setter TargetName="CheckMark" Property="Visibility" Value="Visible"/> + <Setter TargetName="Border" Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + </Trigger> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="Border" Property="BorderBrush" Value="{StaticResource AccentBrush}"/> + </Trigger> + <Trigger Property="IsEnabled" Value="False"> + <Setter TargetName="Border" Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> + <Setter TargetName="Border" Property="BorderBrush" Value="{StaticResource ForegroundMutedBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundMutedBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- ContextMenu Style --> + <!-- ============================================ --> + <Style TargetType="ContextMenu"> + <Setter Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderLightBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Padding" Value="2"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="ContextMenu"> + <Border Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + Padding="{TemplateBinding Padding}" + CornerRadius="4"> + <StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Cycle"/> + </Border> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <Style TargetType="MenuItem"> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="MenuItem"> + <Border x:Name="Border" Background="{TemplateBinding Background}" BorderThickness="0"> + <Grid> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="26" SharedSizeGroup="MenuItemIconColumnGroup"/> + <ColumnDefinition Width="*"/> + <ColumnDefinition Width="17"/> + </Grid.ColumnDefinitions> + <!-- Icon column --> + <Border x:Name="IconColumn" Grid.Column="0" Background="Transparent"/> + <ContentPresenter x:Name="Icon" Grid.Column="0" Margin="4,0,6,0" VerticalAlignment="Center" ContentSource="Icon"/> + <ContentPresenter Grid.Column="1" Margin="8,4" VerticalAlignment="Center" ContentSource="Header" RecognizesAccessKey="True"/> + </Grid> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsHighlighted" Value="True"> + <Setter TargetName="Border" Property="Background" Value="{StaticResource BackgroundLighterBrush}"/> + </Trigger> + <Trigger Property="IsEnabled" Value="False"> + <Setter Property="Foreground" Value="{StaticResource ForegroundMutedBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- ============================================ --> + <!-- Separator Style --> + <!-- ============================================ --> + <Style TargetType="Separator"> + <Setter Property="Background" Value="{StaticResource BorderBrush}"/> + <Setter Property="Margin" Value="0,4"/> + <Setter Property="Height" Value="1"/> + </Style> + + <!-- ============================================ --> + <!-- ToolTip Style --> + <!-- ============================================ --> + <Style TargetType="ToolTip"> + <Setter Property="Background" Value="{StaticResource BackgroundLightBrush}"/> + <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{StaticResource BorderLightBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Padding" Value="10,8"/> + </Style> + + + <!-- Viewer secondary-window surfaces (write-back windows that merge ViewerDarkTheme.xaml). Light values. --> + <SolidColorBrush x:Key="CardBorderBrush" Color="{StaticResource BorderLightColor}"/> + <SolidColorBrush x:Key="ViewerSelectionBrush" Color="#D6E4F5"/> + <SolidColorBrush x:Key="ViewerHoverBrush" Color="{StaticResource BackgroundLighterColor}"/> + <SolidColorBrush x:Key="ViewerGridHeaderBrush" Color="{StaticResource BackgroundLighterColor}"/> + <SolidColorBrush x:Key="ViewerAltRowBrush" Color="{StaticResource AlternatingRowColor}"/> + <SolidColorBrush x:Key="ViewerButtonBorderBrush" Color="{StaticResource BorderLightColor}"/> + <SolidColorBrush x:Key="ViewerDisabledBgBrush" Color="{StaticResource BackgroundLighterColor}"/> + <SolidColorBrush x:Key="ViewerDisabledTextBrush" Color="#A0A8B4"/> + + <!-- Time Range Slicer (light values; #1441 completeness — these 10 keys existed only in Dark) --> + <SolidColorBrush x:Key="SlicerBackgroundBrush" Color="{StaticResource BackgroundDarkColor}"/> + <SolidColorBrush x:Key="SlicerChartLineBrush" Color="{StaticResource AccentColor}"/> + <SolidColorBrush x:Key="SlicerChartFillBrush" Color="#332EAEF1"/> + <SolidColorBrush x:Key="SlicerOverlayBrush" Color="#40000000"/> + <SolidColorBrush x:Key="SlicerSelectedBrush" Color="#222EAEF1"/> + <SolidColorBrush x:Key="SlicerHandleBrush" Color="{StaticResource ForegroundColor}"/> + <SolidColorBrush x:Key="SlicerHandleHoverBrush" Color="{StaticResource AccentColor}"/> + <SolidColorBrush x:Key="SlicerBorderBrush" Color="{StaticResource BorderColor}"/> + <SolidColorBrush x:Key="SlicerLabelBrush" Color="{StaticResource ForegroundColor}"/> + <SolidColorBrush x:Key="SlicerToggleBrush" Color="{StaticResource ForegroundColor}"/> + + <!-- ============================================ --> + <!-- Plan Viewer Resources --> + <!-- ============================================ --> + <SolidColorBrush x:Key="PlanTooltipBgBrush" Color="#FFFFFF"/> + <SolidColorBrush x:Key="PlanTooltipBorderBrush" Color="{StaticResource BorderColor}"/> + <SolidColorBrush x:Key="PlanPanelTextBrush" Color="{StaticResource ForegroundColor}"/> + <SolidColorBrush x:Key="PlanPanelMutedBrush" Color="{StaticResource ForegroundMutedColor}"/> + <SolidColorBrush x:Key="PlanSectionHeaderBrush" Color="{StaticResource AccentPressedColor}"/> + <SolidColorBrush x:Key="PlanPropSeparatorBrush" Color="{StaticResource BorderColor}"/> + <SolidColorBrush x:Key="PlanMissingIndexBgBrush" Color="#FFF8E1"/> + <SolidColorBrush x:Key="PlanMissingIndexBorderBrush" Color="#FFD54F"/> + <SolidColorBrush x:Key="PlanMissingIndexHeaderBrush" Color="#E65100"/> + <SolidColorBrush x:Key="PlanWaitStatsBgBrush" Color="#E3F2FD"/> + <SolidColorBrush x:Key="PlanWaitStatsHeaderBrush" Color="#1565C0"/> + +</ResourceDictionary> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/TimeRangeSlicerControl.xaml b/Darling/PerformanceMonitor.Darling.Viewer/TimeRangeSlicerControl.xaml new file mode 100644 index 000000000..abf2b6123 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/TimeRangeSlicerControl.xaml @@ -0,0 +1,91 @@ +<UserControl x:Class="PerformanceMonitor.Darling.Viewer.TimeRangeSlicerControl" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> + <UserControl.Resources> + <!-- Quick-range pill/chip: rounded, theme-aware, lights up in the slicer accent on hover. --> + <Style x:Key="SlicerChipStyle" TargetType="Button"> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="Foreground" Value="{DynamicResource SlicerLabelBrush}"/> + <Setter Property="BorderBrush" Value="{DynamicResource SlicerBorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Padding" Value="7,1"/> + <Setter Property="Margin" Value="3,0,0,0"/> + <Setter Property="FontSize" Value="10"/> + <Setter Property="Cursor" Value="Hand"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="Button"> + <Border x:Name="ChipBorder" CornerRadius="8" + Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + Padding="{TemplateBinding Padding}"> + <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="ChipBorder" Property="Background" Value="{DynamicResource SlicerHandleHoverBrush}"/> + <Setter TargetName="ChipBorder" Property="BorderBrush" Value="{DynamicResource SlicerHandleHoverBrush}"/> + <Setter Property="Foreground" Value="White"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + </UserControl.Resources> + <Border Background="{DynamicResource SlicerBackgroundBrush}" + BorderBrush="{DynamicResource SlicerBorderBrush}" + BorderThickness="0,0,0,1"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + <Grid Grid.Row="0"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*"/> + <ColumnDefinition Width="Auto"/> + </Grid.ColumnDefinitions> + <Button x:Name="ToggleButton" Grid.Column="0" + Background="Transparent" BorderThickness="0" + HorizontalAlignment="Stretch" HorizontalContentAlignment="Left" + Padding="8,4" Cursor="Hand" + Click="Toggle_Click"> + <StackPanel Orientation="Horizontal"> + <TextBlock x:Name="ToggleIcon" Text="▾" FontSize="12" + Foreground="{DynamicResource SlicerToggleBrush}" + VerticalAlignment="Center" Margin="0,0,6,0"/> + <TextBlock x:Name="ToggleLabel" Text="Time Range" + FontSize="11" FontWeight="SemiBold" + Foreground="{DynamicResource SlicerToggleBrush}" + VerticalAlignment="Center"/> + <TextBlock x:Name="RangeLabel" Text="" + FontSize="11" + Foreground="{DynamicResource SlicerLabelBrush}" + VerticalAlignment="Center" Margin="12,0,0,0"/> + </StackPanel> + </Button> + <StackPanel x:Name="QuickRangeChips" Grid.Column="1" Orientation="Horizontal" + VerticalAlignment="Center" Margin="0,0,8,0"> + <Button Content="15m" Tag="15" Click="QuickRange_Click" Style="{StaticResource SlicerChipStyle}"/> + <Button Content="1h" Tag="60" Click="QuickRange_Click" Style="{StaticResource SlicerChipStyle}"/> + <Button Content="6h" Tag="360" Click="QuickRange_Click" Style="{StaticResource SlicerChipStyle}"/> + <Button Content="24h" Tag="1440" Click="QuickRange_Click" Style="{StaticResource SlicerChipStyle}"/> + <Button Content="All" Tag="0" Click="QuickRange_Click" Style="{StaticResource SlicerChipStyle}"/> + </StackPanel> + </Grid> + <Border x:Name="SlicerBorder" Grid.Row="1" Height="130" Margin="8,0,8,6" + Background="{DynamicResource SlicerBackgroundBrush}" + CornerRadius="4" ClipToBounds="True"> + <Canvas x:Name="SlicerCanvas" + Background="Transparent" + MouseLeftButtonDown="Canvas_MouseLeftButtonDown" + MouseMove="Canvas_MouseMove" + MouseLeftButtonUp="Canvas_MouseLeftButtonUp" + MouseLeave="Canvas_MouseLeave" + MouseWheel="Canvas_MouseWheel"/> + </Border> + </Grid> + </Border> +</UserControl> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/TimeRangeSlicerControl.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/TimeRangeSlicerControl.xaml.cs new file mode 100644 index 000000000..800a134c6 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/TimeRangeSlicerControl.xaml.cs @@ -0,0 +1,560 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Shapes; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Blocking tab's UTC-based time-range slicer — copied verbatim from Lite's +/// <c>TimeRangeSlicerControl</c> (W1e copy-parity; the viewer owns its own copy, Lite/Dashboard are +/// untouched). Its bucket timestamps are UTC (matching the Darling store's naive-UTC collection_time), +/// so <see cref="SelectionStartUtc"/>/<see cref="SelectionEndUtc"/> and the <see cref="RangeChanged"/> +/// payload are UTC and flow straight into the viewer's naive-UTC reads with no clock conversion. The +/// only deviation from Lite is the two display strings (axis labels + range label): Lite's per-server +/// <c>ServerTimeHelper.FormatServerTime</c> becomes <see cref="ViewerTimeHelper.ForDisplay"/> (the +/// viewer's one machine-local convention). +/// </summary> +public partial class TimeRangeSlicerControl : UserControl +{ + private List<TimeSliceBucket> _data = new(); + private DateTime? _requestedStartUtc; + private DateTime? _requestedEndUtc; + private string _metricLabel = "Sessions"; + private bool _isExpanded = true; + + // Overlay: per-item trend drawn on top of the aggregate area chart + private List<(DateTime TimeUtc, double Value)>? _overlayData; + private string? _overlayLabel; + + // Range as normalised [0..1] positions within the data span + private double _rangeStart; + private double _rangeEnd = 1.0; + + private const double HandleWidthPx = 8; + private const double HandleGripWidthPx = 20; + private const double MinRangeNorm = 0.02; // minimum ~2% of total span + private const double ChartPaddingTop = 16; + private const double ChartPaddingBottom = 20; + + private enum DragMode { None, MoveRange, DragStart, DragEnd } + private DragMode _dragMode = DragMode.None; + + private enum HandleHover { None, Start, End } + private HandleHover _hoveredHandle = HandleHover.None; + private double _dragOriginX; + private double _dragOriginRangeStart; + private double _dragOriginRangeEnd; + + /// <summary> + /// Fired when the user finishes adjusting the slicer handles. + /// StartUtc/EndUtc are in UTC (matching the store's collection_time). + /// </summary> + public event EventHandler<SlicerRangeEventArgs>? RangeChanged; + + public TimeRangeSlicerControl() + { + InitializeComponent(); + SlicerBorder.SizeChanged += (_, _) => Redraw(); + IsVisibleChanged += (_, _) => { if (IsVisible) Redraw(); }; + } + + public bool IsExpanded + { + get => _isExpanded; + set + { + _isExpanded = value; + SlicerBorder.Visibility = _isExpanded ? Visibility.Visible : Visibility.Collapsed; + ToggleIcon.Text = _isExpanded ? "▾" : "▸"; + } + } + + /// <summary> + /// Loads slicer data. All timestamps must be UTC. + /// requestedStartUtc/requestedEndUtc define the full time window so the slicer + /// shows the correct span even when data is sparse. Empty hours get zero-value buckets. + /// Selection defaults to the full range (no filtering until user interacts). + /// </summary> + public void LoadData(List<TimeSliceBucket> data, string metricLabel, + DateTime? requestedStartUtc = null, DateTime? requestedEndUtc = null) + { + // Preserve selection if we already have data (auto-refresh) + DateTime? prevStart = null, prevEnd = null; + if (_data.Count > 0 && (_rangeStart > 0 || _rangeEnd < 1.0)) + { + prevStart = UtcAtNorm(_rangeStart); + prevEnd = UtcAtNorm(_rangeEnd); + } + + _requestedStartUtc = requestedStartUtc; + _requestedEndUtc = requestedEndUtc; + _data = FillEmptyBuckets(data, requestedStartUtc, requestedEndUtc); + _metricLabel = metricLabel; + + if (prevStart.HasValue && prevEnd.HasValue && _data.Count >= 2) + { + _rangeStart = NormAtUtc(prevStart.Value); + _rangeEnd = NormAtUtc(prevEnd.Value); + } + else + { + _rangeStart = 0; + _rangeEnd = 1.0; + } + + UpdateRangeLabel(); + Redraw(); + } + + /// <summary> + /// Sets an overlay trend line on the slicer. Drawn on its own Y scale + /// so it's visible regardless of the aggregate chart's magnitude. + /// </summary> + public void SetOverlay(List<(DateTime TimeUtc, double Value)> data, string label) + { + _overlayData = data.Count >= 1 ? data : null; + _overlayLabel = label; + Redraw(); + } + + /// <summary>Removes the overlay trend line.</summary> + public void ClearOverlay() + { + if (_overlayData == null) return; + _overlayData = null; + _overlayLabel = null; + Redraw(); + } + + /// <summary>Updates the metric label and redraws (when sort column changes).</summary> + public void UpdateMetric(string metricLabel) + { + _metricLabel = metricLabel; + Redraw(); + } + + public DateTime? SelectionStartUtc => _data.Count > 0 ? UtcAtNorm(_rangeStart) : null; + public DateTime? SelectionEndUtc => _data.Count > 0 ? UtcAtNorm(_rangeEnd) : null; + + // ── Time mapping ── + + private DateTime DataStartUtc => _requestedStartUtc ?? _data[0].BucketTime; + private DateTime DataEndUtc => _requestedEndUtc ?? _data[^1].BucketTime.AddHours(1); + + /// <summary> + /// Fills in zero-value buckets for hours with no data so the slicer + /// spans the full requested time range. + /// </summary> + private static List<TimeSliceBucket> FillEmptyBuckets( + List<TimeSliceBucket> data, DateTime? requestedStart, DateTime? requestedEnd) + { + if (data.Count == 0 || !requestedStart.HasValue || !requestedEnd.HasValue) + return data; + + var floorStart = FloorToHour(requestedStart.Value); + // Floor the end too — we don't want a bucket for the current partial hour extending into the future + var floorEnd = FloorToHour(requestedEnd.Value); + + var existing = new Dictionary<long, TimeSliceBucket>(); + foreach (var b in data) + existing[FloorToHour(b.BucketTime).Ticks] = b; + + var result = new List<TimeSliceBucket>(); + for (var t = floorStart; t <= floorEnd; t = t.AddHours(1)) + { + if (existing.TryGetValue(t.Ticks, out var bucket)) + result.Add(bucket); + else + result.Add(new TimeSliceBucket { BucketTime = t }); + } + + return result; + } + + private DateTime UtcAtNorm(double norm) + { + var ticks = DataStartUtc.Ticks + (long)((DataEndUtc.Ticks - DataStartUtc.Ticks) * norm); + return new DateTime(Math.Clamp(ticks, DataStartUtc.Ticks, DataEndUtc.Ticks), DateTimeKind.Utc); + } + + private double NormAtUtc(DateTime utc) + { + var span = DataEndUtc.Ticks - DataStartUtc.Ticks; + if (span <= 0) return 0; + return Math.Clamp((double)(utc.Ticks - DataStartUtc.Ticks) / span, 0, 1); + } + + // ── Drawing ── + + public void Redraw() + { + SlicerCanvas.Children.Clear(); + if (_data.Count < 1) return; + + var w = SlicerBorder.ActualWidth; + var h = SlicerBorder.ActualHeight; + if (w <= 0 || h <= 0) return; + + var values = _data.Select(d => d.Value).ToArray(); + var max = values.Max(); + if (max <= 0) max = 1; + + var chartTop = ChartPaddingTop; + var chartBottom = h - ChartPaddingBottom; + var chartHeight = chartBottom - chartTop; + if (chartHeight <= 0) return; + + var n = values.Length; + + // Area chart — position points proportionally by time + var linePoints = new List<Point>(n); + for (int i = 0; i < n; i++) + { + var x = NormAtUtc(_data[i].BucketTime) * w; + var y = chartBottom - (values[i] / max) * chartHeight; + linePoints.Add(new Point(x, y)); + } + + var fillBrush = FindBrush("SlicerChartFillBrush", "#332EAEF1"); + var areaGeo = new StreamGeometry(); + using (var ctx = areaGeo.Open()) + { + ctx.BeginFigure(new Point(linePoints[0].X, chartBottom), true, true); + foreach (var pt in linePoints) ctx.LineTo(pt, true, false); + ctx.LineTo(new Point(linePoints[^1].X, chartBottom), true, false); + } + SlicerCanvas.Children.Add(new Path { Data = areaGeo, Fill = fillBrush }); + + var lineBrush = FindBrush("SlicerChartLineBrush", "#2EAEF1"); + var lineGeo = new StreamGeometry(); + using (var ctx = lineGeo.Open()) + { + ctx.BeginFigure(linePoints[0], false, false); + for (int i = 1; i < linePoints.Count; i++) ctx.LineTo(linePoints[i], true, false); + } + SlicerCanvas.Children.Add(new Path { Data = lineGeo, Stroke = lineBrush, StrokeThickness = 1.5 }); + + // X-axis labels — evenly spaced by TIME, skip if too close + var labelBrush = FindBrush("SlicerLabelBrush", "#E4E6EB"); + const double minLabelSpacingPx = 90; + double lastLabelX = -minLabelSpacingPx; + int targetLabels = Math.Max(2, (int)(w / minLabelSpacingPx)); + var timeStep = (DataEndUtc - DataStartUtc).TotalHours / targetLabels; + for (int tick = 0; tick <= targetLabels; tick++) + { + var tickTime = DataStartUtc.AddHours(tick * timeStep); + var x = NormAtUtc(tickTime) * w; + if (x - lastLabelX < minLabelSpacingPx) continue; + if (x < 10 || x > w - 40) continue; + var dt = ViewerTimeHelper.ForDisplay(tickTime).ToString("MM/dd HH:mm"); + var tb = new TextBlock { Text = dt, FontSize = 9, Foreground = labelBrush }; + Canvas.SetLeft(tb, x - 25); + Canvas.SetTop(tb, chartBottom + 2); + SlicerCanvas.Children.Add(tb); + lastLabelX = x; + } + + // Metric label top-right + var metricBrush = FindBrush("SlicerToggleBrush", "#E4E6EB"); + var metricTb = new TextBlock { Text = _metricLabel, FontSize = 13, FontWeight = FontWeights.SemiBold, Foreground = metricBrush }; + Canvas.SetLeft(metricTb, w - 120); + Canvas.SetTop(metricTb, 2); + SlicerCanvas.Children.Add(metricTb); + + // Selection overlays + var overlayBrush = FindBrush("SlicerOverlayBrush", "#99000000"); + var selectedBrush = FindBrush("SlicerSelectedBrush", "#22FFFFFF"); + var handleBrush = FindBrush("SlicerHandleBrush", "#E4E6EB"); + var handleHoverBrush = FindBrush("SlicerHandleHoverBrush", "#2EAEF1"); + + var selLeft = _rangeStart * w; + var selRight = _rangeEnd * w; + + if (selLeft > 0) AddRect(0, 0, selLeft, h, overlayBrush); + if (selRight < w) AddRect(selRight, 0, w - selRight, h, overlayBrush); + AddRect(selLeft, 0, Math.Max(0, selRight - selLeft), h, selectedBrush); + + DrawHandle(selLeft, h, _hoveredHandle == HandleHover.Start ? handleHoverBrush : handleBrush); + DrawHandle(selRight - HandleWidthPx, h, _hoveredHandle == HandleHover.End ? handleHoverBrush : handleBrush); + + AddLine(selLeft, 0, selRight, 0, handleBrush, 0.5); + AddLine(selLeft, h, selRight, h, handleBrush, 0.5); + + // Overlay dots (per-item highlight from grid selection) + if (_overlayData != null && _overlayData.Count >= 1) + { + var overlayMax = _overlayData.Max(d => d.Value); + if (overlayMax <= 0) overlayMax = 1; + + var dotBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#FF6F00")); + var firstBucket = _data[0].BucketTime; + var lastBucket = _data[^1].BucketTime; + foreach (var pt in _overlayData) + { + if (pt.TimeUtc < firstBucket || pt.TimeUtc > lastBucket) continue; + var norm = NormAtUtc(pt.TimeUtc); + var ox = norm * w; + var oy = Math.Clamp(chartBottom - (pt.Value / overlayMax) * chartHeight, chartTop, chartBottom); + var dot = new Ellipse { Width = 5, Height = 5, Fill = dotBrush }; + Canvas.SetLeft(dot, ox - 2.5); + Canvas.SetTop(dot, oy - 2.5); + SlicerCanvas.Children.Add(dot); + } + + if (!string.IsNullOrEmpty(_overlayLabel)) + { + var overlayLabelTb = new TextBlock + { + Text = _overlayLabel, + FontSize = 11, + FontWeight = FontWeights.SemiBold, + Foreground = dotBrush + }; + Canvas.SetLeft(overlayLabelTb, 8); + Canvas.SetTop(overlayLabelTb, 2); + SlicerCanvas.Children.Add(overlayLabelTb); + } + } + } + + private void AddRect(double x, double y, double width, double height, Brush fill) + { + var rect = new Rectangle { Width = width, Height = height, Fill = fill }; + Canvas.SetLeft(rect, x); Canvas.SetTop(rect, y); + SlicerCanvas.Children.Add(rect); + } + + private void AddLine(double x1, double y1, double x2, double y2, Brush stroke, double opacity) + { + SlicerCanvas.Children.Add(new Line + { + X1 = x1, Y1 = y1, X2 = x2, Y2 = y2, + Stroke = stroke, StrokeThickness = 1, Opacity = opacity + }); + } + + private void DrawHandle(double x, double canvasHeight, Brush brush) + { + AddRect(x, 0, HandleWidthPx, canvasHeight, brush); + ((Rectangle)SlicerCanvas.Children[^1]).Opacity = 0.7; + var midY = canvasHeight / 2; + for (int i = -1; i <= 1; i++) + { + SlicerCanvas.Children.Add(new Line + { + X1 = x + 2, Y1 = midY + i * 5, X2 = x + HandleWidthPx - 2, Y2 = midY + i * 5, + Stroke = Brushes.Black, StrokeThickness = 1, Opacity = 0.6 + }); + } + } + + private Brush FindBrush(string key, string fallbackHex) + { + if (TryFindResource(key) is Brush b) return b; + return new SolidColorBrush((Color)ColorConverter.ConvertFromString(fallbackHex)); + } + + // ── Range label ── + + private void UpdateRangeLabel() + { + if (_data.Count == 0) { RangeLabel.Text = ""; return; } + var startDisplay = ViewerTimeHelper.ForDisplay(UtcAtNorm(_rangeStart)).ToString("yyyy-MM-dd HH:mm"); + var endDisplay = ViewerTimeHelper.ForDisplay(UtcAtNorm(_rangeEnd)).ToString("yyyy-MM-dd HH:mm"); + var spanHours = (UtcAtNorm(_rangeEnd) - UtcAtNorm(_rangeStart)).TotalHours; + var spanLabel = spanHours >= 1 ? $"{spanHours:F0}h" : $"{spanHours * 60:F0}m"; + RangeLabel.Text = $"{startDisplay} → {endDisplay} ({spanLabel})"; + } + + // ── Mouse interaction ── + + private void Toggle_Click(object sender, RoutedEventArgs e) => IsExpanded = !IsExpanded; + + /// <summary> + /// Quick-range chip: selects the last N minutes of the loaded window (Tag = minutes), or the + /// full window when Tag is 0 ("All"). A duration longer than the window clamps to the start. + /// </summary> + private void QuickRange_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button b || _data.Count < 2) return; + if (b.Tag is not string tag || !int.TryParse(tag, out int minutes)) return; + + if (minutes <= 0) + { + _rangeStart = 0; + _rangeEnd = 1.0; + } + else + { + var start = DataEndUtc.AddMinutes(-minutes); + if (start < DataStartUtc) start = DataStartUtc; + _rangeStart = NormAtUtc(start); + _rangeEnd = 1.0; + } + + UpdateRangeLabel(); + Redraw(); + FireRangeChanged(); + } + + private void Canvas_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (_data.Count < 2) return; + var w = SlicerBorder.ActualWidth; + if (w <= 0) return; + var pos = e.GetPosition(SlicerCanvas); + + var selLeft = _rangeStart * w; + var selRight = _rangeEnd * w; + + _dragOriginX = pos.X; + _dragOriginRangeStart = _rangeStart; + _dragOriginRangeEnd = _rangeEnd; + + if (Math.Abs(pos.X - selLeft) <= HandleGripWidthPx) + { _dragMode = DragMode.DragStart; SlicerCanvas.CaptureMouse(); e.Handled = true; return; } + if (Math.Abs(pos.X - selRight) <= HandleGripWidthPx) + { _dragMode = DragMode.DragEnd; SlicerCanvas.CaptureMouse(); e.Handled = true; return; } + if (pos.X >= selLeft && pos.X <= selRight) + { _dragMode = DragMode.MoveRange; SlicerCanvas.CaptureMouse(); e.Handled = true; } + } + + private void Canvas_MouseMove(object sender, MouseEventArgs e) + { + if (_data.Count < 2) return; + var w = SlicerBorder.ActualWidth; + if (w <= 0) return; + var pos = e.GetPosition(SlicerCanvas); + + if (_dragMode == DragMode.None) + { + var selLeft = _rangeStart * w; + var selRight = _rangeEnd * w; + HandleHover newHover = HandleHover.None; + if (Math.Abs(pos.X - selLeft) <= HandleGripWidthPx) + { SlicerCanvas.Cursor = Cursors.SizeWE; newHover = HandleHover.Start; } + else if (Math.Abs(pos.X - selRight) <= HandleGripWidthPx) + { SlicerCanvas.Cursor = Cursors.SizeWE; newHover = HandleHover.End; } + else if (pos.X >= selLeft && pos.X <= selRight) + SlicerCanvas.Cursor = Cursors.SizeAll; + else + SlicerCanvas.Cursor = Cursors.Arrow; + if (newHover != _hoveredHandle) { _hoveredHandle = newHover; Redraw(); } + return; + } + + var deltaNorm = (pos.X - _dragOriginX) / w; + + switch (_dragMode) + { + case DragMode.DragStart: + _rangeStart = Math.Clamp(_dragOriginRangeStart + deltaNorm, 0, _rangeEnd - MinRangeNorm); + break; + case DragMode.DragEnd: + _rangeEnd = Math.Clamp(_dragOriginRangeEnd + deltaNorm, _rangeStart + MinRangeNorm, 1); + break; + case DragMode.MoveRange: + var span = _dragOriginRangeEnd - _dragOriginRangeStart; + var newStart = _dragOriginRangeStart + deltaNorm; + if (newStart < 0) newStart = 0; + if (newStart + span > 1) newStart = 1 - span; + _rangeStart = newStart; + _rangeEnd = newStart + span; + break; + } + + UpdateRangeLabel(); + Redraw(); + e.Handled = true; + } + + private void Canvas_MouseLeave(object sender, MouseEventArgs e) + { + if (_dragMode == DragMode.None && _hoveredHandle != HandleHover.None) + { + _hoveredHandle = HandleHover.None; + Redraw(); + } + } + + private void Canvas_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + if (_dragMode != DragMode.None) + { + _dragMode = DragMode.None; + SlicerCanvas.ReleaseMouseCapture(); + FireRangeChanged(); + e.Handled = true; + } + } + + private void Canvas_MouseWheel(object sender, MouseWheelEventArgs e) + { + if (_data.Count < 2) return; + if (!Keyboard.Modifiers.HasFlag(ModifierKeys.Control)) return; + + var w = SlicerBorder.ActualWidth; + if (w <= 0) return; + + var pos = e.GetPosition(SlicerCanvas); + var pivot = Math.Clamp(pos.X / w, 0, 1); + var span = _rangeEnd - _rangeStart; + + var zoomFactor = e.Delta > 0 ? 0.85 : 1.0 / 0.85; + var newSpan = Math.Clamp(span * zoomFactor, MinRangeNorm, 1.0); + + var pivotInRange = (pivot - _rangeStart) / span; + var newStart = pivot - pivotInRange * newSpan; + var newEnd = newStart + newSpan; + + if (newStart < 0) { newStart = 0; newEnd = newSpan; } + if (newEnd > 1) { newEnd = 1; newStart = 1 - newSpan; } + + _rangeStart = Math.Max(0, newStart); + _rangeEnd = Math.Min(1, newEnd); + + UpdateRangeLabel(); + Redraw(); + FireRangeChanged(); + e.Handled = true; + } + + private void FireRangeChanged() + { + if (_data.Count == 0) return; + // Snap to hour boundaries so slider positions align with the hourly buckets shown in the graph + var startUtc = FloorToHour(UtcAtNorm(_rangeStart)); + var endUtc = CeilToHour(UtcAtNorm(_rangeEnd)); + RangeChanged?.Invoke(this, new SlicerRangeEventArgs(startUtc, endUtc)); + } + + private static DateTime FloorToHour(DateTime dt) => + new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, 0, 0, dt.Kind); + + private static DateTime CeilToHour(DateTime dt) + { + var floored = FloorToHour(dt); + return floored == dt ? dt : floored.AddHours(1); + } +} + +public class SlicerRangeEventArgs : EventArgs +{ + public DateTime StartUtc { get; } + public DateTime EndUtc { get; } + public SlicerRangeEventArgs(DateTime startUtc, DateTime endUtc) { StartUtc = startUtc; EndUtc = endUtc; } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerActualPlanFlow.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerActualPlanFlow.cs new file mode 100644 index 000000000..c4c2fef00 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerActualPlanFlow.cs @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using PerformanceMonitor.PlanAnalysis; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The ONE shared "Get Actual Plan" flow every viewer surface uses (Top Queries, Query-Stats history, Query +/// Store history, Wait drill-down, FinOps High Impact). It owns the pieces that must be identical everywhere: +/// the read-only-seat guard, the data-modification detection + PROMINENT informed-consent flag, the enqueue/poll +/// of the <c>execute_actual_plan</c> command, and the legible permission / timeout / not-connected / no-plan +/// messaging. Each surface differs only in the identifier it builds, where it reads the estimated plan for +/// detection, and how it hosts the resulting plan (a Plan Viewer tab vs a floated window) — so this returns the +/// captured plan XML (or null when the user declined / it failed / was cancelled, all already surfaced) and the +/// caller opens it however it hosts plans. Enqueuing is a config write, so a read-only seat is short-circuited +/// (and the enqueue's <see cref="ViewerReadOnlyException"/> is the backstop) — the same rule as Fetch Live Plan. +/// </summary> +public static class ViewerActualPlanFlow +{ + /// <summary> + /// Runs the full shared flow and returns the captured actual-plan XML, or null when it should not be opened + /// (read-only seat, declined consent, cancelled, failure, or no plan — every non-null-returning path has + /// already shown the user a message). <paramref name="onStarted"/> / <paramref name="onFinished"/> bracket + /// the enqueue so each host shows its own progress indicator; consent runs BEFORE <paramref name="onStarted"/>. + /// </summary> + /// <param name="estimatedPlanXml">The stored estimated plan for modification detection (the service re-resolves + /// the text by identifier when it executes); null/unparseable makes the detector fail-safe to "modifying".</param> + public static async Task<string?> RequestActualPlanAsync( + Window owner, + ViewerDataService dataService, + int serverId, + string serverDisplayName, + string databaseName, + string? queryText, + string? estimatedPlanXml, + string argsJson, + Action? onStarted, + Action? onFinished, + CancellationToken cancellationToken) + { + /* A read-only seat can't enqueue a command — short-circuit with the explanation (the enqueue's + ViewerReadOnlyException is the belt-and-braces backstop, handled below too). */ + if (dataService.IsReadOnly) + { + ShowReadOnly(owner); + return null; + } + + var modification = QueryModificationDetector.Detect(estimatedPlanXml, queryText); + if (!Confirm(owner, serverDisplayName, databaseName, modification)) + { + return null; + } + + onStarted?.Invoke(); + try + { + var result = await dataService.ExecuteActualPlanAsync(serverId, argsJson, cancellationToken); + if (result.Status == ActualPlanStatus.Captured) + { + return result.PlanXml; + } + + ShowOutcome(owner, result); + return null; + } + catch (OperationCanceledException) + { + return null; + } + catch (ViewerReadOnlyException) + { + ShowReadOnly(owner); + return null; + } + catch (Exception ex) + { + MessageBox.Show(owner, $"Failed to capture the actual plan:\n\n{ex.Message}", "Actual Plan Error", + MessageBoxButton.OK, MessageBoxImage.Error); + return null; + } + finally + { + onFinished?.Invoke(); + } + } + + /// <summary> + /// Opens a captured plan in a shared <see cref="PlanViewerControl"/> floated above <paramref name="owner"/> + /// via <see cref="GraphViewerWindow"/> — the plan host the history / drill-down windows use (they have no + /// Plan Viewer tab). The Top Queries surface opens into its tab instead, so it does not call this. + /// </summary> + public static async Task OpenFloatingPlanAsync(Window owner, string planXml, string label, string? queryText) + { + var viewer = new PlanViewerControl(); + try + { + await viewer.LoadPlan(planXml, label, queryText); + } + catch (Exception ex) + { + viewer.Cleanup(); + MessageBox.Show(owner, $"Failed to load the execution plan:\n\n{ex.Message}", + "Plan Load Error", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + try + { + var window = GraphViewerWindow.ShowGraph(owner, viewer, label); + window.Closed += (_, _) => viewer.Cleanup(); + } + catch (InvalidOperationException) + { + /* The owner window was closed while the plan was loading (a slow capture the user abandoned) — + setting Owner to a closed window throws. Clean up the orphaned control rather than crash the + async-void caller. */ + viewer.Cleanup(); + } + } + + /// <summary> + /// The informed-consent gate: warns that the query WILL EXECUTE against the server/database, and — when the + /// modification detector says it writes data — prepends a PROMINENT, distinct block naming the statement types + /// and stating plainly that re-executing RE-APPLIES those changes. Returns true only on OK. + /// </summary> + private static bool Confirm(Window owner, string serverDisplayName, string databaseName, QueryModificationInfo modification) + { + var prompt = new StringBuilder(); + + var modificationWarning = QueryModificationDetector.BuildConsentWarning(modification, databaseName); + if (modificationWarning.Length > 0) + { + prompt.AppendLine(modificationWarning); + prompt.AppendLine("──────────────────────────────────────────"); + prompt.AppendLine(); + } + + var db = string.IsNullOrWhiteSpace(databaseName) ? "the default database" : $"[{databaseName}]"; + prompt.AppendLine($"The service will EXECUTE this query against {serverDisplayName} in database {db} to capture its actual plan."); + prompt.AppendLine(); + prompt.AppendLine("It runs as the service's stored monitoring login, with SET STATISTICS XML ON. All data results are discarded — only the plan is returned."); + + var result = MessageBox.Show( + owner, + prompt.ToString(), + modification.ModifiesData ? "Get Actual Plan (re-run) — DATA WILL BE MODIFIED" : "Get Actual Plan (re-run)", + MessageBoxButton.OKCancel, + MessageBoxImage.Warning); + + return result == MessageBoxResult.OK; + } + + /// <summary>Surfaces a non-captured outcome (no-plan info, or a failure/timeout warning with the service's message).</summary> + private static void ShowOutcome(Window owner, ActualPlanResult result) + { + var (caption, icon) = result.Status switch + { + ActualPlanStatus.NoPlanCaptured => ("No Plan Captured", MessageBoxImage.Information), + ActualPlanStatus.TimedOut => ("Actual Plan Still Running", MessageBoxImage.Warning), + _ => ("Actual Plan Failed", MessageBoxImage.Warning), + }; + MessageBox.Show(owner, result.Message ?? "The actual plan could not be captured.", caption, MessageBoxButton.OK, icon); + } + + /// <summary>The shared read-only-seat explanation (a read-only viewer cannot enqueue a command).</summary> + private static void ShowReadOnly(Window owner) + => MessageBox.Show(owner, + "Capturing an actual plan asks the service to re-execute the query, which it does by running a " + + "command — a read-only viewer seat can't enqueue commands. Reconnect with a read-write profile to " + + "capture actual plans.", + "Read-Only Viewer", MessageBoxButton.OK, MessageBoxImage.Information); +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerAlertStateService.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerAlertStateService.cs new file mode 100644 index 000000000..3a294f29a --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerAlertStateService.cs @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// Viewer-local per-server alert ACKNOWLEDGEMENT state — the "ack-until-worse" half of Lite's +/// <c>AlertStateService</c> (Lite/Services/AlertStateService.cs), ported for the headless viewer's +/// per-server attention badge. State is persisted to a small JSON file so an acknowledgement survives +/// a restart, exactly like Lite. Thread-safe: every operation is guarded by <c>_lock</c>. +/// +/// <para><b>Why only the ack half.</b> Lite's <c>AlertStateService</c> tracks two suppressions — a whole-server +/// SILENCE and a per-server ACKNOWLEDGEMENT. In the headless viewer the silence half is already handled a +/// better way: "Silence This Server" writes a store-side whole-server mute rule +/// (<see cref="ViewerDataService.BuildServerSilenceRule"/>) the running Darling service honors, so a silenced +/// server's later alerts come back flagged <c>muted</c> and the badge derivation +/// (<see cref="ServerAttentionDeriver"/>) drops them — no viewer-local silence flag needed. Acknowledgement, +/// by contrast, is genuinely viewer-local: it quiets the at-a-glance badge for an operator who has SEEN the +/// alerts, WITHOUT stopping the service's email/Teams/Slack delivery. So this service ports the acknowledgement +/// state machine only, keyed on the Postgres <c>server_id</c> (int) instead of Lite's storage-name GUID.</para> +/// +/// <para><b>Ack-until-worse.</b> An acknowledgement records the wall-clock instant it was taken; the badge stays +/// suppressed for that server until a genuinely NEWER alert arrives (a row whose <c>alert_time</c> is later than +/// the ack), at which point <see cref="UpdateAlertState"/> clears it and the badge re-lights. This is the same +/// "Dismiss = until it worsens or recurs" spirit as the tray toast path — but a different UI surface (a persistent +/// per-server badge, not a transient toast), so it is a separate, small store, exactly as Lite keeps +/// <c>AlertStateService</c> separate from its toast/deliverer plumbing.</para> +/// </summary> +public sealed class ViewerAlertStateService +{ + private readonly object _lock = new(); + private readonly string _stateFilePath; + private readonly Func<DateTime> _nowUtc; + + /* Acknowledged servers: server_id -> the UTC instant of acknowledgement. An alert row older than + that instant stays suppressed; a newer row clears the entry (see UpdateAlertState). */ + private readonly Dictionary<int, DateTime> _acknowledged = new(); + + /// <summary>Raised when an acknowledgement is taken or cleared, so a host can re-render immediately.</summary> + public event EventHandler? SuppressionStateChanged; + + /// <param name="stateFilePath"> + /// Override the on-disk location (tests pass a unique temp file so their state never collides); null uses + /// <see cref="DefaultFilePath"/>. + /// </param> + /// <param name="nowUtc">Injectable clock for deterministic ack-until-worse tests; null uses <see cref="DateTime.UtcNow"/>.</param> + public ViewerAlertStateService(string? stateFilePath = null, Func<DateTime>? nowUtc = null) + { + _stateFilePath = stateFilePath ?? DefaultFilePath(); + _nowUtc = nowUtc ?? (() => DateTime.UtcNow); + Load(); + } + + /// <summary>%APPDATA%\PerformanceMonitorDarling\alert-ack-state.json (the viewer's per-user data dir).</summary> + public static string DefaultFilePath() + { + var directory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "PerformanceMonitorDarling"); + return Path.Combine(directory, "alert-ack-state.json"); + } + + /// <summary>The resolved state-file path (surfaced mainly so tests and diagnostics can name it).</summary> + public string FilePath => _stateFilePath; + + /// <summary>Whether alerts should surface (badge shown) for a server — false only while acknowledged.</summary> + public bool ShouldShowAlerts(int serverId) + { + lock (_lock) + { + return !_acknowledged.ContainsKey(serverId); + } + } + + /// <summary>True while the server's alerts are acknowledged (badge suppressed).</summary> + public bool IsAcknowledged(int serverId) + { + lock (_lock) + { + return _acknowledged.ContainsKey(serverId); + } + } + + /// <summary> + /// Applies this poll's derived alert state for a server and returns whether its badge should show. + /// Clears the acknowledgement first when <paramref name="latestEventTimeUtc"/> is newer than the ack + /// timestamp — meaning a genuinely new alert arrived (the "until it worsens or recurs" clear), not just a + /// re-read of the same rows. Mirrors Lite's <c>AlertStateService.UpdateAlertCounts</c>. + /// </summary> + public bool UpdateAlertState(int serverId, int alertCount, DateTime? latestEventTimeUtc) + { + lock (_lock) + { + if (latestEventTimeUtc.HasValue + && _acknowledged.TryGetValue(serverId, out var ackTime) + && latestEventTimeUtc.Value > ackTime) + { + /* A newer alert than the acknowledgement — clear it so the badge re-lights. */ + _acknowledged.Remove(serverId); + Save(); + } + + return alertCount > 0 && !_acknowledged.ContainsKey(serverId); + } + } + + /// <summary> + /// Acknowledges a server's alerts and suppresses its badge until a newer alert arrives. Persisted across + /// restarts. Mirrors Lite's <c>AlertStateService.AcknowledgeAlert</c>. + /// </summary> + public void AcknowledgeAlert(int serverId) + { + lock (_lock) + { + _acknowledged[serverId] = _nowUtc(); + Save(); + } + SuppressionStateChanged?.Invoke(this, EventArgs.Empty); + } + + /// <summary> + /// Clears a server's acknowledgement outright (the badge re-lights on the next poll if alerts remain). The + /// counterpart to Lite's <c>ClearAcknowledgementForNewCondition</c> — the viewer's alert rows always carry an + /// <c>alert_time</c>, so <see cref="UpdateAlertState"/> normally clears the ack on its own; this is the + /// explicit escape hatch. No-op (and no event) when nothing was acknowledged. + /// </summary> + public void ClearAcknowledgement(int serverId) + { + bool changed; + lock (_lock) + { + changed = _acknowledged.Remove(serverId); + if (changed) + { + Save(); + } + } + + if (changed) + { + SuppressionStateChanged?.Invoke(this, EventArgs.Empty); + } + } + + /// <summary>Removes all state for a server (call when its tab is closed / it is unregistered).</summary> + public void RemoveServerState(int serverId) + { + lock (_lock) + { + if (_acknowledged.Remove(serverId)) + { + Save(); + } + } + } + + private static readonly JsonSerializerOptions s_writeOptions = new() + { + WriteIndented = true + }; + + private void Save() + { + try + { + var directory = Path.GetDirectoryName(_stateFilePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + var state = new AlertAckPersisted + { + AcknowledgedAlerts = new Dictionary<int, DateTime>(_acknowledged) + }; + var json = JsonSerializer.Serialize(state, s_writeOptions); + File.WriteAllText(_stateFilePath, json); + } + catch + { + /* Best effort — an ack that can't be persisted still holds in memory for the session. */ + } + } + + private void Load() + { + try + { + if (!File.Exists(_stateFilePath)) + { + return; + } + + var json = File.ReadAllText(_stateFilePath); + var state = JsonSerializer.Deserialize<AlertAckPersisted>(json); + if (state?.AcknowledgedAlerts == null) + { + return; + } + + foreach (var kv in state.AcknowledgedAlerts) + { + _acknowledged[kv.Key] = kv.Value; + } + } + catch + { + /* Best effort — start fresh if the file is missing or corrupted. */ + } + } + + private sealed class AlertAckPersisted + { + public Dictionary<int, DateTime>? AcknowledgedAlerts { get; set; } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerAppSettings.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerAppSettings.cs new file mode 100644 index 000000000..7a28fac20 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerAppSettings.cs @@ -0,0 +1,387 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Text.Json; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The viewer's per-USER application settings — the Darling equivalent of Performance Monitor Lite's +/// <c>App.*</c> statics (persisted to Lite's settings.json). This is the settings model the full +/// <see cref="SettingsWindow"/> port reads and writes: the MCP server toggle/port, every alert threshold +/// and toggle, notification options, SMTP (non-secret fields), Teams/Slack (non-secret fields), automated +/// analysis, the mute-rule default, and the viewer-side display preferences (connection timeout, CSV +/// separator, timestamp display mode). Every default mirrors Lite's <c>App.*</c> exactly. +/// +/// <para> +/// This is deliberately distinct from the two existing viewer stores: +/// <see cref="ViewerPreferences"/> holds the tiny toolbar-seed defaults (time range + auto-refresh) that the +/// server tabs consume directly, and <see cref="ViewerSettings"/> reads the SERVICE's darling.json (the +/// Postgres connection). These are the front-end's own copy of the operator-facing settings. +/// </para> +/// +/// <para> +/// Scope note (persistence adaptation, matching the rest of the Lite→Darling port): in Lite these values +/// drive Lite's in-process collector and alert engine; in Darling those are the SERVICE's domain +/// (darling.json). The viewer persists them here faithfully so the Settings window is complete and its +/// state survives a restart — making the running service honor a change is a separate wiring concern +/// (the service reads darling.json, not this file). The controls that DO act inside the viewer today — +/// Manage Mute Rules (writes Postgres), Send Test Email / Send Test Notification (the shared, connection- +/// independent renderers), the viewer preferences folded in, <see cref="MinimizeToTray"/> (hides the window +/// to the system tray on minimize), and the in-app alert toasts (the MainWindow polls alert history and +/// surfaces new rows through the ported tray, honoring the store-backed "Tray notification cooldown") — +/// work immediately. +/// </para> +/// +/// <para> +/// Secrets (the SMTP password and the Teams/Slack webhook URLs) are NOT stored in this file — like Lite, +/// they go to Windows Credential Manager via <see cref="ViewerSecretStore"/>. The mutable properties here +/// are what <see cref="JsonSerializer"/> round-trips; <see cref="Normalize"/> clamps hand-edited or +/// forward-version values back into range on load. +/// </para> +/// </summary> +public sealed class ViewerAppSettings +{ + /* ---------------- MCP server (service's embedded MCP; darling.json mcp.*) ---------------- */ + + /// <summary>Whether the Darling service's embedded MCP server should run. Persisted here; honored by the service.</summary> + public bool McpEnabled { get; set; } + + /// <summary>MCP HTTP port. Darling's default (5152) avoids Lite (5151) and the Dashboard (5150).</summary> + public int McpPort { get; set; } = 5152; + + /// <summary>Whether the Darling service's read-only web dashboard should run. Persisted here (an immediate + /// non-blank seed for the Settings window); the control-plane store is authoritative and honored by the service.</summary> + public bool WebEnabled { get; set; } + + /// <summary>Web dashboard HTTP port. Darling's default (5153) avoids the MCP family (5150-5152).</summary> + public int WebPort { get; set; } = 5153; + + /* ---------------- Viewer display preferences ---------------- */ + + /// <summary>Connection timeout in seconds for the viewer's store reads (5-60).</summary> + public int ConnectionTimeoutSeconds { get; set; } = 5; + + /// <summary> + /// How often (seconds) the shell auto-refreshes the fleet Overview and the aggregate tabs — the single + /// operator knob over what were two hardcoded cadences (60s aggregate / 30s Overview), mirroring the + /// Dashboard's one <c>NocRefreshIntervalSeconds</c>. Both fleet-scope timers run at this interval so an + /// operator can dial the store-query load back at 500-server scale. Default 30 (the Dashboard's default and + /// the former Overview cadence). Clamped to 10–600. + /// </summary> + public int NocRefreshIntervalSeconds { get; set; } = 30; + + /// <summary>CSV export separator: "," ";" or a tab. Auto-detected from locale on first run.</summary> + public string CsvSeparator { get; set; } = DefaultCsvSeparator(); + + /// <summary>Timestamp display mode: "ServerTime", "LocalTime", or "UTC".</summary> + public string TimeDisplayMode { get; set; } = "ServerTime"; + + /// <summary>Overview tile sort: "Cpu" (CPU% descending, default) or "Name".</summary> + public string OverviewSortMode { get; set; } = "Cpu"; + + /// <summary>Color theme: "Dark" (default), "Light", or "CoolBreeze". Mirrors Lite's <c>App.ColorTheme</c>; + /// applied at startup and live-previewed from the Settings window via the shared <c>ThemeManager</c>.</summary> + public string ColorTheme { get; set; } = "Dark"; + + /* ---------------- Notifications / alert thresholds (mirrors Lite App.*) ---------------- */ + + public bool MinimizeToTray { get; set; } = true; + public bool AlertsEnabled { get; set; } = true; + + /// <summary>Whether the service delivers the Server-Unreachable/Restored connect-edge alerts. Store-backed since + /// V20 (the service honors it); retained here only as the pre-3b migrate-in source, its default matching + /// <see cref="AlertSettingsRow.Defaults"/> so a fresh viewer imports nothing.</summary> + public bool NotifyConnectionChanges { get; set; } = true; + + public bool AlertCpuEnabled { get; set; } = true; + public int AlertCpuThreshold { get; set; } = 80; + + /// <summary>Which CPU metric the alert evaluates against: "Total" (SQL + other processes) or "SqlOnly".</summary> + public string AlertCpuMode { get; set; } = "Total"; + + public bool AlertBlockingEnabled { get; set; } = true; + public int AlertBlockingThreshold { get; set; } = 1; + public bool AlertDeadlockEnabled { get; set; } = true; + public int AlertDeadlockThreshold { get; set; } = 1; + public bool AlertPoisonWaitEnabled { get; set; } = true; + public int AlertPoisonWaitThresholdMs { get; set; } = 500; + + public bool AlertLongRunningQueryEnabled { get; set; } = true; + public int AlertLongRunningQueryThresholdMinutes { get; set; } = 30; + + /* The long-running-query read shape (max-results + the five noise filters). Store-backed since V20 (the + service forwards them to the LRQ read); retained here only as the pre-3b migrate-in source, defaults + matching AlertSettingsRow.Defaults so a fresh viewer imports nothing. */ + public int AlertLongRunningQueryMaxResults { get; set; } = 5; + public bool AlertLongRunningQueryExcludeSpServerDiagnostics { get; set; } = true; + public bool AlertLongRunningQueryExcludeWaitFor { get; set; } = true; + public bool AlertLongRunningQueryExcludeBackups { get; set; } = true; + public bool AlertLongRunningQueryExcludeMiscWaits { get; set; } = true; + public bool AlertLongRunningQueryExcludeCdc { get; set; } = true; + + public List<string> AlertExcludedDatabases { get; set; } = new(); + + public bool AlertTempDbSpaceEnabled { get; set; } = true; + public int AlertTempDbSpaceThresholdPercent { get; set; } = 80; + + public bool AlertLowDiskEnabled { get; set; } = true; + public int AlertLowDiskThresholdPercent { get; set; } = 10; + public int AlertLowDiskThresholdGb { get; set; } = 5; + + public bool AlertLongRunningJobEnabled { get; set; } = true; + public int AlertLongRunningJobMultiplier { get; set; } = 3; + public bool AlertFailedJobEnabled { get; set; } = true; + public int AlertFailedJobLookbackMinutes { get; set; } = 60; + + public int AlertCooldownMinutes { get; set; } = 5; + public int EmailCooldownMinutes { get; set; } = 15; + + /// <summary>Deadlock/blocking notification delivery: "Summary" (one card per cycle) or "PerEvent". + /// Store-backed since V18 (the service honors it); retained here only as the pre-3b migrate-in source, so its + /// defaults match <see cref="AlertSettingsRow.Defaults"/> (Summary / 5) — a fresh viewer imports nothing.</summary> + public string AlertDeliveryMode { get; set; } = "Summary"; + public int AlertPerEventMaxPerCycle { get; set; } = 5; + + /// <summary>Default expiration for new mute rules: "1 hour", "24 hours", "7 days", or "Never".</summary> + public string MuteRuleDefaultExpiration { get; set; } = "24 hours"; + public bool LogAlertDismissals { get; set; } = true; + + public bool AnalysisEnabled { get; set; } = true; + public bool AnalysisNotificationsEnabled { get; set; } + public int AnalysisIntervalMinutes { get; set; } = 30; + public double AnalysisNotifySeverity { get; set; } = 1.5; + public int AnalysisNotifyCooldownMinutes { get; set; } = 360; + + /* ---------------- SMTP (non-secret; password lives in Credential Manager) ---------------- */ + + public bool SmtpEnabled { get; set; } + public string SmtpServer { get; set; } = ""; + public int SmtpPort { get; set; } = 587; + public bool SmtpUseSsl { get; set; } = true; + public string SmtpUsername { get; set; } = ""; + public string SmtpFromAddress { get; set; } = ""; + public string SmtpRecipients { get; set; } = ""; + + /* ---------------- Webhooks (non-secret; URLs live in Credential Manager) ---------------- */ + + public bool TeamsWebhookEnabled { get; set; } + public string TeamsProxyAddress { get; set; } = ""; + public bool SlackWebhookEnabled { get; set; } + public string SlackProxyAddress { get; set; } = ""; + + /// <summary> + /// Auto-detect the CSV separator: semicolon when the locale's decimal separator is a comma + /// (Italian, German, French, ...), otherwise a comma. Mirrors Lite's <c>App.GetDefaultCsvSeparator</c>. + /// </summary> + internal static string DefaultCsvSeparator() => + CultureInfo.CurrentCulture.NumberFormat.NumberDecimalSeparator == "," ? ";" : ","; + + /// <summary> + /// Clamps every numeric value back into the range the Settings window accepts and coerces the small + /// string enums to a known value, then returns this same instance for chaining. Mirrors the clamps in + /// Lite's <c>App.LoadAlertSettings</c> plus the window's save-time validation, so a corrupt, hand-edited, + /// or forward-version file can never seed an out-of-range control. Pure and file-I/O-free (unit-testable). + /// </summary> + internal ViewerAppSettings Normalize() + { + McpPort = Clamp(McpPort, 1024, 65535, 5152); + WebPort = Clamp(WebPort, 1024, 65535, 5153); + ConnectionTimeoutSeconds = Clamp(ConnectionTimeoutSeconds, 5, 60, 5); + NocRefreshIntervalSeconds = Clamp(NocRefreshIntervalSeconds, 10, 600, 30); + CsvSeparator = (CsvSeparator == "," || CsvSeparator == ";" || CsvSeparator == "\t") ? CsvSeparator : DefaultCsvSeparator(); + TimeDisplayMode = (TimeDisplayMode is "ServerTime" or "LocalTime" or "UTC") ? TimeDisplayMode : "ServerTime"; + OverviewSortMode = (OverviewSortMode is "Cpu" or "Name") ? OverviewSortMode : "Cpu"; + ColorTheme = (ColorTheme is "Dark" or "Light" or "CoolBreeze") ? ColorTheme : "Dark"; + + AlertCpuThreshold = Clamp(AlertCpuThreshold, 1, 100, 80); + AlertCpuMode = (AlertCpuMode is "Total" or "SqlOnly") ? AlertCpuMode : "Total"; + AlertBlockingThreshold = Clamp(AlertBlockingThreshold, 1, int.MaxValue, 1); + AlertDeadlockThreshold = Clamp(AlertDeadlockThreshold, 1, int.MaxValue, 1); + AlertPoisonWaitThresholdMs = Clamp(AlertPoisonWaitThresholdMs, 1, int.MaxValue, 500); + AlertLongRunningQueryThresholdMinutes = Clamp(AlertLongRunningQueryThresholdMinutes, 1, int.MaxValue, 30); + AlertLongRunningQueryMaxResults = Clamp(AlertLongRunningQueryMaxResults, 1, 1000, 5); + AlertTempDbSpaceThresholdPercent = Clamp(AlertTempDbSpaceThresholdPercent, 1, 100, 80); + AlertLowDiskThresholdPercent = Clamp(AlertLowDiskThresholdPercent, 0, 100, 10); + AlertLowDiskThresholdGb = Clamp(AlertLowDiskThresholdGb, 0, int.MaxValue, 5); + AlertLongRunningJobMultiplier = Clamp(AlertLongRunningJobMultiplier, 2, 20, 3); + AlertFailedJobLookbackMinutes = Clamp(AlertFailedJobLookbackMinutes, 1, 1440, 60); + AlertCooldownMinutes = Clamp(AlertCooldownMinutes, 1, 120, 5); + EmailCooldownMinutes = Clamp(EmailCooldownMinutes, 1, 120, 15); + AlertDeliveryMode = (AlertDeliveryMode is "Summary" or "PerEvent") ? AlertDeliveryMode : "Summary"; + AlertPerEventMaxPerCycle = Clamp(AlertPerEventMaxPerCycle, 1, 100, 5); + MuteRuleDefaultExpiration = (MuteRuleDefaultExpiration is "1 hour" or "24 hours" or "7 days" or "Never") + ? MuteRuleDefaultExpiration : "24 hours"; + + AnalysisIntervalMinutes = Clamp(AnalysisIntervalMinutes, 5, 360, 30); + AnalysisNotifySeverity = Math.Clamp(AnalysisNotifySeverity, 0.0, 2.0); + AnalysisNotifyCooldownMinutes = Clamp(AnalysisNotifyCooldownMinutes, 30, 10080, 360); + + if (SmtpPort is < 1 or > 65535) + { + SmtpPort = 587; + } + + AlertExcludedDatabases ??= new List<string>(); + + return this; + } + + private static int Clamp(int value, int min, int max, int fallback) + { + if (value < min || value > max) + { + /* Snap a value that landed off the low/high end to the nearest bound, but a completely + absent (zero) numeric on a min>=1 field reads as "unset" -> use the historical default. */ + return value <= 0 && min >= 1 ? fallback : Math.Clamp(value, min, max); + } + + return value; + } +} + +/// <summary> +/// Loads and saves <see cref="ViewerAppSettings"/> as a JSON file in the viewer's per-user app-data +/// directory, mirroring <see cref="ViewerPreferencesStore"/> exactly (indented JSON, defaults on a missing +/// or corrupt file, an injectable path so tests round-trip against a temp file). Default path: +/// %APPDATA%\PerformanceMonitorDarling\viewer-settings.json — the same Darling-branded per-user folder +/// <see cref="ViewerPreferencesStore"/> uses, with the file namespaced to the settings. +/// </summary> +public sealed class ViewerAppSettingsStore +{ + private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true }; + + private readonly string _filePath; + + /// <param name="filePath">Override the on-disk location (tests pass a temp file); null uses <see cref="DefaultFilePath"/>.</param> + public ViewerAppSettingsStore(string? filePath = null) + { + _filePath = filePath ?? DefaultFilePath(); + } + + /// <summary>The resolved settings file path (surfaced mainly so tests and diagnostics can name it).</summary> + public string FilePath => _filePath; + + /// <summary>%APPDATA%\PerformanceMonitorDarling\viewer-settings.json.</summary> + public static string DefaultFilePath() + { + var directory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "PerformanceMonitorDarling"); + return Path.Combine(directory, "viewer-settings.json"); + } + + /// <summary> + /// Reads the settings, returning defaults when the file does not exist yet or cannot be read/parsed — + /// the viewer never blocks on a first run or a corrupt file. Loaded values are normalized into range. + /// </summary> + public ViewerAppSettings Load() + { + try + { + if (!File.Exists(_filePath)) + { + return new ViewerAppSettings(); + } + + var json = File.ReadAllText(_filePath); + var settings = JsonSerializer.Deserialize<ViewerAppSettings>(json, s_jsonOptions); + return (settings ?? new ViewerAppSettings()).Normalize(); + } + catch (Exception ex) + { + Debug.WriteLine($"ViewerAppSettingsStore: failed to load '{_filePath}', using defaults: {ex.Message}"); + return new ViewerAppSettings(); + } + } + + /// <summary>Writes the settings as indented JSON, creating the app-data directory on first save.</summary> + public void Save(ViewerAppSettings settings) + { + ArgumentNullException.ThrowIfNull(settings); + + var directory = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + File.WriteAllText(_filePath, JsonSerializer.Serialize(settings, s_jsonOptions)); + } +} + +/// <summary> +/// The one <see cref="ViewerAppSettings"/> value the viewer honors at RUNTIME today: the CSV export +/// separator, read by the grid copy/export handlers (they call <c>DataGridExport.ExportToCsv(..., separator)</c>). +/// Held as a process-wide value that <see cref="MainWindow"/> seeds from the persisted settings on startup and +/// re-applies whenever the Settings window closes, so a change takes effect on the next export without a +/// restart — mirroring how Lite's export reads <c>App.CsvSeparator</c>. The connection timeout is honored at +/// CONNECT (applied to the store connection string in the <see cref="ViewerDataService"/> constructor) and the +/// fleet refresh interval drives the shell's aggregate/Overview timers; the remaining persisted settings +/// (alerts/SMTP/webhooks/MCP/timestamp-display) are the service's or a larger wiring concern. +/// </summary> +public static class ViewerExportSettings +{ + /// <summary>Current CSV export separator ("," ";" or a tab). Defaults to the locale-based default.</summary> + public static string CsvSeparator { get; set; } = ViewerAppSettings.DefaultCsvSeparator(); + + /// <summary>Seeds the runtime export values from a loaded settings instance.</summary> + public static void Apply(ViewerAppSettings settings) + { + ArgumentNullException.ThrowIfNull(settings); + CsvSeparator = settings.CsvSeparator; + } +} + +/// <summary> +/// The viewer's secret store for the Settings window: the SMTP password and the Teams/Slack webhook URLs, +/// kept out of viewer-settings.json and in Windows Credential Manager (DPAPI-encrypted, per-user), exactly +/// as Lite keeps them. Wraps the shared <see cref="CredentialStore"/> with a Darling-specific prefix so the +/// viewer's secrets never collide with Lite's or the Dashboard's. Failures are swallowed to null/no-op — +/// a Settings window must never crash because the credential vault is unavailable. +/// </summary> +public static class ViewerSecretStore +{ + /* Distinct per-app prefix (see CredentialStore's load-bearing-prefix note). */ + private static readonly CredentialStore s_store = new("PerformanceMonitorDarling_"); + + private const string SmtpKey = "SMTP"; + private const string TeamsKey = "TeamsWebhook"; + private const string SlackKey = "SlackWebhook"; + + public static string? GetSmtpPassword() => s_store.GetCredential(SmtpKey)?.Password; + + public static void SaveSmtpPassword(string username, string password) => + s_store.SaveCredential(SmtpKey, string.IsNullOrEmpty(username) ? "smtp" : username, password); + + public static string GetTeamsWebhookUrl() => s_store.GetCredential(TeamsKey)?.Password ?? ""; + + public static string GetSlackWebhookUrl() => s_store.GetCredential(SlackKey)?.Password ?? ""; + + public static void SaveTeamsWebhookUrl(string url) => SaveWebhookUrl(TeamsKey, url); + + public static void SaveSlackWebhookUrl(string url) => SaveWebhookUrl(SlackKey, url); + + private static void SaveWebhookUrl(string key, string url) + { + if (string.IsNullOrWhiteSpace(url)) + { + s_store.DeleteCredential(key); + } + else + { + s_store.SaveCredential(key, "webhook", url); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerControlPlaneMigration.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerControlPlaneMigration.cs new file mode 100644 index 000000000..6b6c14d8e --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerControlPlaneMigration.cs @@ -0,0 +1,279 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.Versioning; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The one-time import of a pre-Stage-3b viewer's OPERATIONAL settings (the alert engine, automated analysis, +/// SMTP + Teams/Slack delivery, and the MCP toggle) out of the now-severed <c>viewer-settings.json</c> +/// (<see cref="ViewerAppSettings"/>) into the control-plane store. Stage 3b makes the store the source of +/// truth for what the running service honors; this carries a viewer's existing customizations across so they +/// keep taking effect without re-entering them — the operational twin of <see cref="ViewerServerMigration"/> +/// (which carried the SERVER definitions). +/// +/// <para><b>Defaults-only, no clobber.</b> A section is imported ONLY when the store's copy is still at the +/// V17 seed defaults (the operator has NOT configured it service-side via darling.json) AND the viewer's copy +/// actually differs from those defaults (there is a real customization to carry). So a store an operator +/// already tuned is never overwritten, and a fresh viewer with nothing customized writes nothing (no spurious +/// <c>config_version</c> bump). <b>Runs once.</b> A marker file guards it after a clean pass. Read-only seats +/// and a disconnected viewer skip it (nothing to write); a <see cref="ViewerReadOnlyException"/> mid-pass +/// leaves the marker unwritten so a later writable run finishes.</para> +/// +/// <para><b>Secrets.</b> The SMTP password is read from Windows Credential Manager (the old viewer-local vault) +/// and re-sealed as the service-decryptable DPAPI-LocalMachine blob (<see cref="ViewerServerSecret"/>); the +/// Teams/Slack URLs (also formerly in the vault) carry as the store's plain-text channel URLs. So the SMTP +/// secret path is Windows-only; the alert/analysis/MCP imports are platform-independent.</para> +/// +/// <para><b>Schedules.</b> There is deliberately NO schedule import: the Darling viewer never had a local +/// collector-schedule editor (it was an informational panel before Stage 3b), so there is no source to carry — +/// an un-customized store already collects on the shared <c>CollectorScheduleDefaults</c>, which is the intent.</para> +/// </summary> +public sealed class ViewerControlPlaneMigration +{ + private readonly ViewerAppSettings _appSettings; + private readonly string _markerPath; + + /// <param name="appSettings">The loaded viewer app settings (the pre-Stage-3b operational values).</param> + /// <param name="markerPath">Override the once-marker path (tests pass a temp file); null uses <see cref="DefaultMarkerPath"/>.</param> + public ViewerControlPlaneMigration(ViewerAppSettings appSettings, string? markerPath = null) + { + _appSettings = appSettings ?? throw new ArgumentNullException(nameof(appSettings)); + _markerPath = markerPath ?? DefaultMarkerPath(); + } + + /// <summary>%APPDATA%\PerformanceMonitorDarling\viewer-controlplane-migrated.marker.</summary> + public static string DefaultMarkerPath() + { + var directory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "PerformanceMonitorDarling"); + return Path.Combine(directory, "viewer-controlplane-migrated.marker"); + } + + /// <summary>True once the migrate-in has completed a clean pass (the marker exists).</summary> + public bool AlreadyMigrated => File.Exists(_markerPath); + + /// <summary> + /// Imports every still-default store section the viewer has a customization for, then writes the marker. A + /// no-op (returns 0) when the viewer is disconnected, connected read-only, or already migrated. Returns the + /// number of sections actually written (0–3). A <see cref="ViewerReadOnlyException"/> mid-pass stops + /// without marking done, so a later writable run retries. + /// </summary> + [SupportedOSPlatform("windows")] + public async Task<int> MigrateAsync(ViewerDataService? dataService, CancellationToken cancellationToken = default) + { + if (dataService is null || dataService.IsReadOnly || AlreadyMigrated) + { + return 0; + } + + /* Wait for the service to finish seeding before importing. config_service is written LAST (its presence + marks the seed complete), so on a reachable-but-unseeded (or partially-seeded) store every section + read returns null: importing nothing AND burning the once-marker would strand the operator's pre-3b + settings forever, and a partial seed would drop the MCP section. Return WITHOUT the marker so a + later, fully-seeded run carries everything across. */ + if (!await dataService.IsConfigSeededAsync(cancellationToken)) + { + return 0; + } + + var imported = 0; + try + { + /* Alerts + automated analysis. */ + var storeAlerts = await dataService.GetAlertSettingsAsync(cancellationToken); + var viewerAlerts = BuildAlertRow(_appSettings); + if (storeAlerts is not null && ShouldImportAlerts(storeAlerts, viewerAlerts)) + { + await dataService.UpsertAlertSettingsAsync(viewerAlerts, cancellationToken); + imported++; + } + + /* SMTP + Teams/Slack delivery (secrets from the old vault, re-sealed for the service). */ + var storeNotify = await dataService.GetNotificationAsync(cancellationToken); + var viewerNotify = BuildNotificationRow( + _appSettings, + ViewerSecretStore.GetSmtpPassword(), + ViewerSecretStore.GetTeamsWebhookUrl(), + ViewerSecretStore.GetSlackWebhookUrl()); + if (storeNotify is not null && ShouldImportNotification(storeNotify, viewerNotify)) + { + await dataService.UpsertNotificationAsync(viewerNotify, cancellationToken); + imported++; + } + + /* MCP toggle/port (config_service, UPDATE-only; preserve the store's current capture_plans). The web + dashboard columns (#1562) are carried through from the viewer's local settings — they default to + the store defaults (off / 5153) for a pre-web viewer, so this is a no-op unless the operator set them. */ + var storeService = await dataService.GetServiceConfigAsync(cancellationToken); + if (storeService is not null && ShouldImportMcp(storeService, _appSettings)) + { + await dataService.UpdateServiceFlagsAsync( + storeService.CapturePlans, _appSettings.McpEnabled, _appSettings.McpPort, + _appSettings.WebEnabled, _appSettings.WebPort, cancellationToken); + imported++; + } + } + catch (ViewerReadOnlyException) + { + /* Grants tightened under us — leave the marker unwritten so a later writable run finishes. */ + ViewerLogger.Warn("ViewerControlPlaneMigration", "Store went read-only mid-migrate; will retry next run."); + return imported; + } + + WriteMarker(); + return imported; + } + + /// <summary>Projects the viewer's alert + analysis app settings onto a store row (pure — pinned by tests).</summary> + public static AlertSettingsRow BuildAlertRow(ViewerAppSettings s) + { + ArgumentNullException.ThrowIfNull(s); + return new AlertSettingsRow + { + Enabled = s.AlertsEnabled, + /* V20: carry a pre-3b viewer's connection-change notify choice into the store now that the service + honors it (was a dead knob before V20). Fresh-viewer default matches Defaults() → nothing imported. */ + NotifyConnectionChanges = s.NotifyConnectionChanges, + CpuEnabled = s.AlertCpuEnabled, + CpuThresholdPercent = s.AlertCpuThreshold, + CpuMode = ViewerDataService.MapCpuModeToStore(s.AlertCpuMode), + BlockingEnabled = s.AlertBlockingEnabled, + BlockingCountThreshold = s.AlertBlockingThreshold, + DeadlockEnabled = s.AlertDeadlockEnabled, + DeadlockCountThreshold = s.AlertDeadlockThreshold, + PoisonWaitEnabled = s.AlertPoisonWaitEnabled, + PoisonWaitThresholdMs = s.AlertPoisonWaitThresholdMs, + LongRunningQueryEnabled = s.AlertLongRunningQueryEnabled, + LongRunningQueryThresholdMinutes = s.AlertLongRunningQueryThresholdMinutes, + TempDbSpaceEnabled = s.AlertTempDbSpaceEnabled, + TempDbSpaceThresholdPercent = s.AlertTempDbSpaceThresholdPercent, + LowDiskEnabled = s.AlertLowDiskEnabled, + LowDiskThresholdPercent = s.AlertLowDiskThresholdPercent, + LowDiskThresholdGb = s.AlertLowDiskThresholdGb, + LongRunningJobEnabled = s.AlertLongRunningJobEnabled, + LongRunningJobMultiplier = s.AlertLongRunningJobMultiplier, + FailedJobEnabled = s.AlertFailedJobEnabled, + FailedJobLookbackMinutes = s.AlertFailedJobLookbackMinutes, + CooldownMinutes = s.AlertCooldownMinutes, + ExcludedDatabases = new List<string>(s.AlertExcludedDatabases ?? new List<string>()), + AnalysisEnabled = s.AnalysisEnabled, + AnalysisIntervalMinutes = s.AnalysisIntervalMinutes, + AnalysisNotificationsEnabled = s.AnalysisNotificationsEnabled, + AnalysisNotifySeverity = s.AnalysisNotifySeverity, + /* #1141/#1236: carry a pre-3b viewer's delivery customization into the store now that the service + honors it (was a dead knob before V18). Fresh-viewer defaults match Defaults() → nothing imported. */ + DeliveryMode = (s.AlertDeliveryMode is "Summary" or "PerEvent") ? s.AlertDeliveryMode : "Summary", + PerEventMax = s.AlertPerEventMaxPerCycle, + /* V20: carry a pre-3b viewer's long-running-query read customization into the store now that the + service honors it (was a dead knob before V20). Fresh-viewer defaults match Defaults() → nothing imported. */ + LongRunningQueryMaxResults = s.AlertLongRunningQueryMaxResults, + LongRunningQueryExcludeSpServerDiagnostics = s.AlertLongRunningQueryExcludeSpServerDiagnostics, + LongRunningQueryExcludeWaitFor = s.AlertLongRunningQueryExcludeWaitFor, + LongRunningQueryExcludeBackups = s.AlertLongRunningQueryExcludeBackups, + LongRunningQueryExcludeMiscWaits = s.AlertLongRunningQueryExcludeMiscWaits, + LongRunningQueryExcludeCdc = s.AlertLongRunningQueryExcludeCdc, + }; + } + + /// <summary> + /// Projects the viewer's SMTP/webhook app settings + the vault secrets onto a store row (pure — pinned by + /// tests). A DISABLED channel writes EMPTY key fields (SMTP host/from/to; a webhook URL) so the service — + /// which derives enablement from non-empty fields — treats it as off; an enabled channel carries its + /// values, with the SMTP password sealed as the service-decryptable DPAPI blob. + /// </summary> + [SupportedOSPlatform("windows")] + public static NotificationRow BuildNotificationRow(ViewerAppSettings s, string? smtpPassword, string teamsUrl, string slackUrl) + { + ArgumentNullException.ThrowIfNull(s); + var row = new NotificationRow + { + EmailCooldownMinutes = s.EmailCooldownMinutes, + }; + + if (s.SmtpEnabled) + { + row.SmtpHost = s.SmtpServer ?? ""; + row.SmtpPort = s.SmtpPort; + row.SmtpUseSsl = s.SmtpUseSsl; + row.SmtpUsername = string.IsNullOrWhiteSpace(s.SmtpUsername) ? null : s.SmtpUsername; + row.SmtpFromAddress = s.SmtpFromAddress ?? ""; + row.SmtpRecipients = s.SmtpRecipients ?? ""; + row.SmtpEncryptedPassword = string.IsNullOrEmpty(smtpPassword) ? null : ViewerServerSecret.Protect(smtpPassword); + } + + if (s.TeamsWebhookEnabled) + { + row.TeamsUrl = teamsUrl ?? ""; + row.TeamsProxy = s.TeamsProxyAddress ?? ""; + } + + if (s.SlackWebhookEnabled) + { + row.SlackUrl = slackUrl ?? ""; + row.SlackProxy = s.SlackProxyAddress ?? ""; + } + + return row; + } + + /// <summary>Import alerts only when the store section is untouched (defaults) AND the viewer has a real + /// customization to carry — never clobber an operator-tuned store, never write defaults-over-defaults.</summary> + public static bool ShouldImportAlerts(AlertSettingsRow storeRow, AlertSettingsRow viewerRow) + { + ArgumentNullException.ThrowIfNull(storeRow); + ArgumentNullException.ThrowIfNull(viewerRow); + return storeRow.ValueEquals(AlertSettingsRow.Defaults()) && !viewerRow.ValueEquals(AlertSettingsRow.Defaults()); + } + + /// <summary>Import notification config only when the store section is untouched AND the viewer differs.</summary> + public static bool ShouldImportNotification(NotificationRow storeRow, NotificationRow viewerRow) + { + ArgumentNullException.ThrowIfNull(storeRow); + ArgumentNullException.ThrowIfNull(viewerRow); + return storeRow.ValueEquals(NotificationRow.Defaults()) && !viewerRow.ValueEquals(NotificationRow.Defaults()); + } + + /// <summary>Import the MCP toggle/port only when the store's MCP flags are still at defaults AND the viewer + /// differs (capture_plans/paused are not viewer-local, so they don't gate this).</summary> + public static bool ShouldImportMcp(ServiceConfigRow storeRow, ViewerAppSettings viewer) + { + ArgumentNullException.ThrowIfNull(storeRow); + ArgumentNullException.ThrowIfNull(viewer); + var storeAtMcpDefaults = !storeRow.McpEnabled && storeRow.McpPort == 5152; + var viewerCustomized = viewer.McpEnabled || viewer.McpPort != 5152; + return storeAtMcpDefaults && viewerCustomized; + } + + private void WriteMarker() + { + try + { + var directory = Path.GetDirectoryName(_markerPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + File.WriteAllText(_markerPath, $"migrated {DateTime.UtcNow:O}"); + } + catch (Exception ex) + { + /* A failed marker write only means the migrate re-runs next launch; the defaults-only guard makes + a re-run a no-op once the store carries the imported values. Don't crash the viewer. */ + ViewerLogger.Warn("ViewerControlPlaneMigration", $"Could not write the migrate marker '{_markerPath}': {ex.Message}"); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerCredentialProfile.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerCredentialProfile.cs new file mode 100644 index 000000000..51f32b74d --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerCredentialProfile.cs @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Text.Json.Serialization; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// A named, reusable credential that many <see cref="ViewerServerEntry"/> definitions can reference via +/// <see cref="ViewerServerEntry.CredentialProfileId"/> — the Darling viewer's port of Lite's +/// <c>CredentialProfile</c>. One identity (a shared SQL login, an Azure service principal, or a managed +/// identity) covers a whole fleet of servers without per-server secret entry, and the Darling service +/// consumes the reference when it connects. +/// +/// <para>SECURITY: this type holds NO secret. The SQL password / SP client secret lives ONLY in Windows +/// Credential Manager (DPAPI), keyed by <c>profile_{Id}</c> under the viewer's <c>PerformanceMonitorDarling_</c> +/// prefix, via <see cref="ViewerProfileStore"/> — mirroring the secret-free discipline of +/// <see cref="ViewerServerEntry"/>. viewer-profiles.json never stores a secret. As with the rest of the +/// Lite→Darling port the viewer persists these faithfully; the running SERVICE honoring a profile-backed +/// server is the separate wiring flagged in the PR.</para> +/// </summary> +public sealed class ViewerCredentialProfile +{ + /// <summary>Stable unique identifier (Guid string) — the REAL key; display-name uniqueness is a per-process UX nicety.</summary> + public string Id { get; set; } = Guid.NewGuid().ToString(); + + /// <summary>Human-friendly display name shown in the profile picker.</summary> + public string Name { get; set; } = string.Empty; + + /// <summary> + /// Authentication type. Only the three profile-eligible modes are valid: + /// <see cref="AuthenticationTypes.SqlServer"/>, <see cref="AuthenticationTypes.ServicePrincipal"/>, + /// or <see cref="AuthenticationTypes.ManagedIdentity"/>. Windows (no credential) and EntraMFA + /// (interactive, per-user) are NOT profile-eligible — a shared profile is meaningless for them. + /// </summary> + public string AuthType { get; set; } = AuthenticationTypes.SqlServer; + + /// <summary>SQL login username for <see cref="AuthenticationTypes.SqlServer"/> profiles. Non-secret.</summary> + public string? Username { get; set; } + + /// <summary>Service-principal application/client id for <see cref="AuthenticationTypes.ServicePrincipal"/> profiles. Non-secret.</summary> + public string? AzureClientId { get; set; } + + /// <summary>Optional Microsoft Entra tenant id. Stored for display/reference only.</summary> + public string? AzureTenantId { get; set; } + + /// <summary>Optional user-assigned managed-identity client id for <see cref="AuthenticationTypes.ManagedIdentity"/> profiles. Blank = system-assigned. Non-secret.</summary> + public string? ManagedIdentityClientId { get; set; } + + /// <summary>Display-only label for the profile's auth type.</summary> + [JsonIgnore] + public string AuthTypeDisplay => AuthType switch + { + AuthenticationTypes.SqlServer => "SQL Server", + AuthenticationTypes.ServicePrincipal => "Azure — Service Principal", + AuthenticationTypes.ManagedIdentity => "Azure — Managed Identity", + _ => AuthType + }; + + /// <summary> + /// Display text for the profile picker: name plus a short id suffix to disambiguate duplicate display + /// names (the shared file can hold same-named profiles; the Id is the real key). + /// </summary> + [JsonIgnore] + public string PickerDisplay => + string.IsNullOrEmpty(Id) || Id.Length < 8 + ? $"{Name} ({AuthTypeDisplay})" + : $"{Name} ({AuthTypeDisplay}) — {Id[..8]}"; +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDarkTheme.xaml b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDarkTheme.xaml new file mode 100644 index 000000000..c7f1fada3 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDarkTheme.xaml @@ -0,0 +1,227 @@ +<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:ui="clr-namespace:PerformanceMonitor.Ui;assembly=PerformanceMonitor.Ui"> + <!-- + Shared control styles for the viewer's read-only write-back / dialog windows (Manage Mute + Rules, the Mute Rule editor, history popups, collection log, etc.). Merged locally by those + windows so their controls pick up a consistent look. + + THEME-AWARE: every color is a {DynamicResource} into the app-wide theme dictionary that + ThemeManager swaps at runtime, so these windows follow Dark / Light / CoolBreeze like the + main window. The token dark values are unchanged, so Dark is pixel-identical to the previous + hardcoded palette (#22252b chrome, #111217 wells, #2a2d35 borders, #E4E6EB text, + #2f3542 selection). Implicit (keyless) styles so a merged window's controls theme + automatically — MainWindow keeps its own inline copies. + (File name kept for the windows that reference it; it is no longer "dark-only".) + --> + + <!-- DimTextBrush: the only by-key color reference from the windows (3 uses). Theme-aware alias + to the readable muted tier (#1441 — replaces the old #8B93A1). --> + <SolidColorBrush x:Key="DimTextBrush" Color="{DynamicResource ForegroundMutedColor}"/> + + <Style TargetType="TextBlock"> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Setter Property="VerticalAlignment" Value="Center"/> + </Style> + + <Style TargetType="TextBox"> + <Setter Property="Background" Value="{DynamicResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Setter Property="CaretBrush" Value="{DynamicResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Padding" Value="6,4"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="TextBox"> + <Border Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + CornerRadius="3"> + <ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}"/> + </Border> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <Style TargetType="CheckBox"> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Setter Property="VerticalAlignment" Value="Center"/> + </Style> + + <Style x:Key="ViewerButton" TargetType="Button"> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Setter Property="Background" Value="{DynamicResource ViewerSelectionBrush}"/> + <Setter Property="BorderBrush" Value="{DynamicResource ViewerButtonBorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Padding" Value="12,5"/> + <Setter Property="Cursor" Value="Hand"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="Button"> + <Border x:Name="Bd" + Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + CornerRadius="3" + Padding="{TemplateBinding Padding}"> + <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="Bd" Property="Background" Value="{DynamicResource ViewerButtonBorderBrush}"/> + </Trigger> + <Trigger Property="IsEnabled" Value="False"> + <Setter TargetName="Bd" Property="Background" Value="{DynamicResource ViewerDisabledBgBrush}"/> + <Setter Property="Foreground" Value="{DynamicResource ViewerDisabledTextBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <Style TargetType="Button" BasedOn="{StaticResource ViewerButton}"/> + + <!-- ComboBox: compact re-template (toggle + popup), theme-aware. --> + <Style TargetType="ComboBoxItem"> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="Padding" Value="6,4"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="ComboBoxItem"> + <Border x:Name="Bd" Background="{TemplateBinding Background}" Padding="{TemplateBinding Padding}"> + <ContentPresenter/> + </Border> + <ControlTemplate.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter TargetName="Bd" Property="Background" Value="{DynamicResource ViewerHoverBrush}"/> + </Trigger> + <Trigger Property="IsHighlighted" Value="True"> + <Setter TargetName="Bd" Property="Background" Value="{DynamicResource ViewerSelectionBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <Style TargetType="ComboBox"> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Setter Property="Background" Value="{DynamicResource BackgroundDarkBrush}"/> + <Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Padding" Value="6,4"/> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="ComboBox"> + <Grid> + <ToggleButton x:Name="ToggleButton" + Focusable="False" + IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}" + ClickMode="Press"> + <ToggleButton.Template> + <ControlTemplate TargetType="ToggleButton"> + <Border Background="{DynamicResource BackgroundDarkBrush}" + BorderBrush="{DynamicResource BorderBrush}" + BorderThickness="1" + CornerRadius="3"> + <Grid> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*"/> + <ColumnDefinition Width="20"/> + </Grid.ColumnDefinitions> + <Path Grid.Column="1" + HorizontalAlignment="Center" VerticalAlignment="Center" + Data="M 0 0 L 4 4 L 8 0 Z" Fill="{DynamicResource ForegroundMutedBrush}"/> + </Grid> + </Border> + </ControlTemplate> + </ToggleButton.Template> + </ToggleButton> + <ContentPresenter x:Name="ContentSite" + IsHitTestVisible="False" + Content="{TemplateBinding SelectionBoxItem}" + ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}" + Margin="{TemplateBinding Padding}" + VerticalAlignment="Center" + HorizontalAlignment="Left"/> + <Popup x:Name="PART_Popup" Placement="Bottom" + IsOpen="{TemplateBinding IsDropDownOpen}" + AllowsTransparency="True" Focusable="False" PopupAnimation="Slide"> + <Border Background="{DynamicResource ViewerAltRowBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" + MinWidth="{TemplateBinding ActualWidth}" MaxHeight="240"> + <ScrollViewer> + <StackPanel IsItemsHost="True"/> + </ScrollViewer> + </Border> + </Popup> + </Grid> + <ControlTemplate.Triggers> + <Trigger Property="IsEnabled" Value="False"> + <Setter Property="Foreground" Value="{DynamicResource ViewerDisabledTextBrush}"/> + </Trigger> + </ControlTemplate.Triggers> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- DataGrid chrome, matching MainWindow's DarkGrid family (theme-aware). --> + <Style x:Key="DarkGridHeader" TargetType="DataGridColumnHeader"> + <Setter Property="Background" Value="{DynamicResource ViewerGridHeaderBrush}"/> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="0,0,1,1"/> + <Setter Property="Padding" Value="8,5"/> + <Setter Property="FontWeight" Value="SemiBold"/> + </Style> + + <Style x:Key="DarkGridRow" TargetType="DataGridRow"> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Style.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter Property="Background" Value="{DynamicResource ViewerHoverBrush}"/> + </Trigger> + </Style.Triggers> + </Style> + + <Style x:Key="DarkGridCell" TargetType="DataGridCell"> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Style.Triggers> + <Trigger Property="IsSelected" Value="True"> + <Setter Property="Background" Value="{DynamicResource ViewerSelectionBrush}"/> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + </Trigger> + </Style.Triggers> + </Style> + + <Style x:Key="DarkGrid" TargetType="DataGrid"> + <!-- DarkGrid has no BasedOn, so it must re-declare the implicit DataGrid style's behaviors or grids using it silently lose them (sort-descending-first, header-copy, middle-click panning). --> + <Setter Property="ui:DataGridClipboardBehavior.FixHeaderCopy" Value="True"/> + <Setter Property="ui:DataGridSortBehavior.SortDescendingFirst" Value="True"/> + <Setter Property="ui:ScrollPanBehavior.EnableMiddleClickPanning" Value="True"/> + <Setter Property="AutoGenerateColumns" Value="False"/> + <Setter Property="IsReadOnly" Value="True"/> + <Setter Property="CanUserAddRows" Value="False"/> + <Setter Property="HeadersVisibility" Value="Column"/> + <Setter Property="SelectionMode" Value="Single"/> + <Setter Property="SelectionUnit" Value="FullRow"/> + <Setter Property="GridLinesVisibility" Value="Horizontal"/> + <Setter Property="HorizontalGridLinesBrush" Value="{DynamicResource BorderBrush}"/> + <Setter Property="Background" Value="{DynamicResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="RowBackground" Value="{DynamicResource BackgroundDarkBrush}"/> + <Setter Property="AlternatingRowBackground" Value="{DynamicResource ViewerAltRowBrush}"/> + <Setter Property="ColumnHeaderStyle" Value="{StaticResource DarkGridHeader}"/> + <Setter Property="RowStyle" Value="{StaticResource DarkGridRow}"/> + <Setter Property="CellStyle" Value="{StaticResource DarkGridCell}"/> + </Style> +</ResourceDictionary> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ActiveQueriesLive.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ActiveQueriesLive.cs new file mode 100644 index 000000000..c6dd3a303 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ActiveQueriesLive.cs @@ -0,0 +1,249 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The viewer's typed wrapper over the Stage-2 <c>fetch_active_queries</c> command — the LIVE "what is running +/// right now" snapshot the store-only viewer cannot read itself. The viewer holds no SqlClient connection to a +/// monitored server, but the SERVICE keeps a live runtime connection to every one, so a "read this server's +/// active queries now" request rides the SAME command channel <c>test_connect</c> / <c>snapshot_now</c> / +/// <c>fetch_plan</c> use: the viewer enqueues a <c>fetch_active_queries</c> row, the service runs the SHARED +/// Active Queries collector's DMV query on the target, and the viewer polls for the rows in <c>result_json</c> +/// and renders them in the same grid shape as the stored Active Queries tab. This is the distinct LIVE, +/// on-demand counterpart of the collector's stored hourly snapshot. +/// +/// <para>Like every other command it is a WRITE (enqueue), so a read-only <c>viewer</c> seat throws +/// <see cref="ViewerReadOnlyException"/> from the enqueue — correct, and consistent with Pause / Snapshot / +/// fetch_plan: enqueuing a command is a config write a read-only seat cannot make. The command row is DELETED +/// after its terminal result is read (like <c>test_connect</c> / <c>fetch_plan</c>): a live snapshot's rows — +/// with inline plan XML — can be large and there is no reason to retain them in <c>config_command</c> once the +/// viewer has them.</para> +/// </summary> +public sealed partial class ViewerDataService +{ + /// <summary>The command type the viewer enqueues for a live active-queries fetch (must match the service + /// executor's <c>fetch_active_queries</c> case — Darling.Tests pin the two ends against each other).</summary> + public const string CommandFetchActiveQueries = "fetch_active_queries"; + + /// <summary>The overall poll budget for a live active-queries fetch. Wider than a config write's + /// <see cref="DefaultCommandTimeout"/> because the service reads a live DMV snapshot (potentially many + /// running requests with inline plans), but the service caps the SQL read itself at + /// <c>DarlingWorker.ActiveQueriesFetchTimeoutSeconds</c> (~30s) — comfortably under this — so the viewer + /// sees the service's real "timed out" outcome rather than a poll miss.</summary> + public static readonly TimeSpan ActiveQueriesLiveTimeout = TimeSpan.FromSeconds(60); + + private static readonly JsonSerializerOptions s_activeQueriesResultOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + /// <summary> + /// Enqueues a <c>fetch_active_queries</c> for one server, polls for its terminal result, deletes the + /// (possibly large) command row, and maps the outcome to an <see cref="ActiveQueriesLiveResult"/>. A + /// read-only seat throws <see cref="ViewerReadOnlyException"/> from the enqueue (a read-only seat cannot + /// command the service). Returns <see cref="ActiveQueriesLiveStatus.TimedOut"/> when the service does not + /// report within the budget (the caller shows "still reading / try again"). + /// </summary> + public async Task<ActiveQueriesLiveResult> RequestActiveQueriesLiveAsync( + int serverId, CancellationToken cancellationToken = default) + { + var commandId = await EnqueueCommandAsync(CommandFetchActiveQueries, serverId, argsJson: null, RequestedBy(), cancellationToken); + var result = await PollCommandResultAsync(commandId, ActiveQueriesLiveTimeout, cancellationToken); + + if (result is not null) + { + try + { + await DeleteCommandAsync(commandId, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + /* The rows-bearing row could not be cleaned up now; the service-side reaper is the backstop. + Never let cleanup hide the snapshot the user is waiting on. */ + } + } + + return ParseActiveQueriesLiveResult(result); + } + + /// <summary> + /// Maps a polled command result to an <see cref="ActiveQueriesLiveResult"/> (pure — pinned by Darling.Tests): + /// a null poll → <see cref="ActiveQueriesLiveStatus.TimedOut"/>; a <c>succeeded</c> row → the running-request + /// rows parsed from <c>result_json</c> (an empty list when nothing is running); a <c>failed</c> row → + /// <see cref="ActiveQueriesLiveStatus.Failed"/> with the service's error / status message. + /// </summary> + public static ActiveQueriesLiveResult ParseActiveQueriesLiveResult(CommandResult? result) + { + if (result is null) + { + return new ActiveQueriesLiveResult(ActiveQueriesLiveStatus.TimedOut, new List<ViewerQuerySnapshotRow>(), + "The service did not return the live active queries in time. It may still be reading — try again."); + } + + if (result.Status == StatusSucceeded) + { + return new ActiveQueriesLiveResult( + ActiveQueriesLiveStatus.Fetched, DeserializeActiveQueriesRows(result.ResultJson), null); + } + + /* ReadStringField lives in the LivePlan partial of this same class. */ + var error = ReadStringField(result.ResultJson, "error"); + return new ActiveQueriesLiveResult(ActiveQueriesLiveStatus.Failed, new List<ViewerQuerySnapshotRow>(), + !string.IsNullOrEmpty(error) ? error + : !string.IsNullOrEmpty(result.ResultStatus) ? result.ResultStatus + : "The service could not read the live active queries."); + } + + /// <summary> + /// Parses the <c>fetch_active_queries</c> <c>result_json</c> (built by the service's + /// <c>ActiveQueriesLivePayload.Serialize</c>) into <see cref="ViewerQuerySnapshotRow"/>s — the SAME row model + /// the stored Active Queries grid binds, so the live and stored views render identically. Each row's + /// <see cref="ViewerQuerySnapshotRow.CollectionTime"/> is stamped from the payload's <c>fetchedAtUtc</c> (the + /// DMV row has no capture timestamp — the collector stamps it at store time; the live fetch stamps the fetch + /// time). Malformed / empty JSON degrades to an empty list, never throws. + /// </summary> + private static List<ViewerQuerySnapshotRow> DeserializeActiveQueriesRows(string? json) + { + var rows = new List<ViewerQuerySnapshotRow>(); + if (string.IsNullOrWhiteSpace(json)) + { + return rows; + } + + ActiveQueriesResultDto? dto; + try + { + dto = JsonSerializer.Deserialize<ActiveQueriesResultDto>(json, s_activeQueriesResultOptions); + } + catch (JsonException) + { + return rows; + } + + if (dto?.Rows is null) + { + return rows; + } + + var fetchedAt = dto.FetchedAtUtc ?? DateTime.MinValue; + foreach (var r in dto.Rows) + { + rows.Add(new ViewerQuerySnapshotRow + { + SessionId = r.SessionId, + DatabaseName = r.DatabaseName ?? "", + ElapsedTimeFormatted = r.ElapsedTimeFormatted ?? "", + QueryText = r.QueryText ?? "", + Status = r.Status ?? "", + BlockingSessionId = r.BlockingSessionId, + WaitType = r.WaitType ?? "", + WaitTimeMs = r.WaitTimeMs, + WaitResource = r.WaitResource ?? "", + CpuTimeMs = r.CpuTimeMs, + TotalElapsedTimeMs = r.TotalElapsedTimeMs, + Reads = r.Reads, + Writes = r.Writes, + LogicalReads = r.LogicalReads, + GrantedQueryMemoryGb = r.GrantedQueryMemoryGb, + TransactionIsolationLevel = r.TransactionIsolationLevel ?? "", + Dop = r.Dop, + ParallelWorkerCount = r.ParallelWorkerCount, + QueryPlan = r.QueryPlan, + LiveQueryPlan = r.LiveQueryPlan, + CollectionTime = fetchedAt, + LoginName = r.LoginName ?? "", + HostName = r.HostName ?? "", + ProgramName = r.ProgramName ?? "", + OpenTransactionCount = r.OpenTransactionCount, + PercentComplete = r.PercentComplete, + QueryHash = r.QueryHash ?? "", + RequestedMemoryMb = r.RequestedMemoryMb ?? 0, + UsedMemoryMb = r.UsedMemoryMb ?? 0, + MaxUsedMemoryMb = r.MaxUsedMemoryMb ?? 0, + TempdbCurrentMb = r.TempdbCurrentMb ?? 0, + TempdbAllocationsMb = r.TempdbAllocationsMb ?? 0, + TranLogUsedMb = r.TranLogUsedMb ?? 0, + TranStartTime = r.TranStartTime, + RequestId = r.RequestId, + }); + } + + return rows; + } + + /// <summary>The wire shape of a <c>fetch_active_queries</c> <c>result_json</c> — the service serializes + /// <c>QuerySnapshotsCollector.Row</c>s whole; the property names below mirror that row (matched + /// case-insensitively), so the round-trip test guards the two ends against drift.</summary> + private sealed class ActiveQueriesResultDto + { + public bool Success { get; set; } + public DateTime? FetchedAtUtc { get; set; } + public List<ActiveQueryRowDto>? Rows { get; set; } + } + + private sealed class ActiveQueryRowDto + { + public int SessionId { get; set; } + public string? DatabaseName { get; set; } + public string? ElapsedTimeFormatted { get; set; } + public string? QueryText { get; set; } + public string? QueryPlan { get; set; } + public string? LiveQueryPlan { get; set; } + public string? Status { get; set; } + public int BlockingSessionId { get; set; } + public string? WaitType { get; set; } + public long WaitTimeMs { get; set; } + public string? WaitResource { get; set; } + public long CpuTimeMs { get; set; } + public long TotalElapsedTimeMs { get; set; } + public long Reads { get; set; } + public long Writes { get; set; } + public long LogicalReads { get; set; } + public double GrantedQueryMemoryGb { get; set; } + public string? TransactionIsolationLevel { get; set; } + public int Dop { get; set; } + public int ParallelWorkerCount { get; set; } + public string? LoginName { get; set; } + public string? HostName { get; set; } + public string? ProgramName { get; set; } + public int OpenTransactionCount { get; set; } + public decimal PercentComplete { get; set; } + public string? QueryHash { get; set; } + public double? RequestedMemoryMb { get; set; } + public double? UsedMemoryMb { get; set; } + public double? MaxUsedMemoryMb { get; set; } + public double? TempdbCurrentMb { get; set; } + public double? TempdbAllocationsMb { get; set; } + public double? TranLogUsedMb { get; set; } + public DateTime? TranStartTime { get; set; } + public int RequestId { get; set; } + } +} + +/// <summary>The terminal state of a live active-queries fetch — the viewer's Current Active Queries handler switches on it.</summary> +public enum ActiveQueriesLiveStatus +{ + /// <summary>The live snapshot was read (Rows may be empty when nothing is running).</summary> + Fetched, + + /// <summary>The service reported a failure (server not connected, connect / SQL / permission error).</summary> + Failed, + + /// <summary>The service did not report a terminal result within the poll budget.</summary> + TimedOut, +} + +/// <summary>A live active-queries fetch's outcome: the running-request rows on success, else a message to surface.</summary> +public sealed record ActiveQueriesLiveResult(ActiveQueriesLiveStatus Status, List<ViewerQuerySnapshotRow> Rows, string? Message); diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ActualPlan.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ActualPlan.cs new file mode 100644 index 000000000..45ed1f4fe --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ActualPlan.cs @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The viewer's typed wrapper over the Stage-2 <c>execute_actual_plan</c> command — the "Get Actual Plan" +/// path. Where <c>fetch_plan</c> (<see cref="FetchLivePlanAsync"/>) READS a cached plan, this RE-EXECUTES the +/// stored query on the target (SET STATISTICS XML ON) so the plan comes back with runtime stats. The viewer holds +/// no SqlClient connection, so — exactly like <c>fetch_plan</c> — the request rides the command channel to the +/// SERVICE, which holds the live connection. +/// +/// <para><b>Identifier-only payload (security):</b> the args carry ONLY the stored-row key (query_hash + +/// database_name), never the SQL text. The service resolves the text + estimated plan from its own store before +/// re-executing, so a command writer can never make the service run arbitrary SQL against a target (see +/// <c>ActualPlanRequest</c>). <b>Read-only seats:</b> enqueuing a command is a config write, so a read-only +/// <c>viewer</c> seat throws <see cref="ViewerReadOnlyException"/> from the enqueue (same rule as Pause / Snapshot +/// / Fetch-Live-Plan). The command row is DELETED after its terminal result is read (the plan XML can be large).</para> +/// </summary> +public sealed partial class ViewerDataService +{ + /// <summary>The command type the viewer enqueues for an actual-plan capture (must match the service executor's case).</summary> + public const string CommandExecuteActualPlan = "execute_actual_plan"; + + /// <summary> + /// Builds the <c>args_json</c> for a <c>query_stats</c> actual-plan capture — the stored-row key ONLY + /// (query_hash + database_name), NO query text. Serves Top Queries, Query-Stats history, and FinOps High + /// Impact. Pure — pinned by Darling.Tests against the service's <c>ActualPlanRequest.TryParse</c> and asserted + /// to carry no SQL text. + /// </summary> + public static string BuildActualPlanArgs(string queryHash, string? databaseName) + => JsonSerializer.Serialize( + new ActualPlanArgs { QueryHash = queryHash, DatabaseName = NullIfBlank(databaseName) }, + s_planFetchArgsOptions); + + /// <summary> + /// Builds the <c>args_json</c> for a Query Store actual-plan capture — the stored-row key ONLY (query_id + + /// database_name), NO query text. The service resolves the text from <c>query_store_stats</c>. Pure. + /// </summary> + public static string BuildActualPlanArgsForQueryStore(long queryId, string? databaseName) + => JsonSerializer.Serialize( + new ActualPlanArgs { QueryId = queryId, DatabaseName = NullIfBlank(databaseName) }, + s_planFetchArgsOptions); + + /// <summary> + /// Builds the <c>args_json</c> for a Wait drill-down (query snapshot) actual-plan capture — the stored-row key + /// ONLY (collection_time + session_id, plus database for the USE context), NO query text. The service resolves + /// the text from <c>query_snapshots</c> by the exact capture time + session. Pure. + /// </summary> + public static string BuildActualPlanArgsForSnapshot(DateTime collectionTime, int sessionId, string? databaseName) + => JsonSerializer.Serialize( + new ActualPlanArgs + { + SnapshotCollectionTime = DateTime.SpecifyKind(collectionTime, DateTimeKind.Unspecified), + SnapshotSessionId = sessionId, + DatabaseName = NullIfBlank(databaseName), + }, + s_planFetchArgsOptions); + + /// <summary> + /// Enqueues an <c>execute_actual_plan</c> with the given <paramref name="argsJson"/> (built by + /// <see cref="BuildActualPlanArgs"/>), polls for its terminal result (the re-execution can take a while, so + /// the imperative 3-minute budget), deletes the (possibly large) command row, and maps the outcome. A + /// read-only seat throws <see cref="ViewerReadOnlyException"/> from the enqueue. + /// </summary> + public async Task<ActualPlanResult> ExecuteActualPlanAsync( + int serverId, string argsJson, CancellationToken cancellationToken = default) + { + var commandId = await EnqueueCommandAsync(CommandExecuteActualPlan, serverId, argsJson, RequestedBy(), cancellationToken); + var result = await PollCommandResultAsync(commandId, ImperativeCommandTimeout, cancellationToken); + + if (result is not null) + { + try + { + await DeleteCommandAsync(commandId, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + /* The plan-bearing row could not be cleaned up now; the service-side reaper is the backstop. + Never let cleanup hide the plan the user is waiting on. */ + } + } + + return ParseActualPlanResult(result); + } + + /// <summary> + /// Maps a polled command result to an <see cref="ActualPlanResult"/> (pure — pinned by Darling.Tests): + /// a null poll → <see cref="ActualPlanStatus.TimedOut"/>; a <c>succeeded</c> row → the plan XML from + /// <c>result_json</c> (or <see cref="ActualPlanStatus.NoPlanCaptured"/> when it is null); a <c>failed</c> row → + /// <see cref="ActualPlanStatus.Failed"/> with the service's legible error (permission / timeout / SQL error). + /// </summary> + public static ActualPlanResult ParseActualPlanResult(CommandResult? result) + { + if (result is null) + { + return new ActualPlanResult(ActualPlanStatus.TimedOut, null, + "The service did not return the actual plan in time. The query may still be running — try again."); + } + + if (result.Status == StatusSucceeded) + { + var planXml = ReadStringField(result.ResultJson, "planXml"); + return string.IsNullOrEmpty(planXml) + ? new ActualPlanResult(ActualPlanStatus.NoPlanCaptured, null, + "The query executed but no actual execution plan was captured.") + : new ActualPlanResult(ActualPlanStatus.Captured, planXml, null); + } + + var error = ReadStringField(result.ResultJson, "error"); + return new ActualPlanResult(ActualPlanStatus.Failed, null, + !string.IsNullOrEmpty(error) ? error + : !string.IsNullOrEmpty(result.ResultStatus) ? result.ResultStatus + : "The service could not capture the actual plan."); + } + + /// <summary>The wire shape of an <c>execute_actual_plan</c> <c>args_json</c> — the stored-row key only (one + /// identifier kind per request; null fields are omitted). There is deliberately NO query-text field: the + /// service resolves the text from its own store.</summary> + private sealed class ActualPlanArgs + { + public string? QueryHash { get; set; } + public long? QueryId { get; set; } + public DateTime? SnapshotCollectionTime { get; set; } + public int? SnapshotSessionId { get; set; } + public string? DatabaseName { get; set; } + } +} + +/// <summary>The terminal state of an actual-plan capture — the viewer's handler switches on it.</summary> +public enum ActualPlanStatus +{ + /// <summary>The query was re-executed and its actual plan XML captured.</summary> + Captured, + + /// <summary>The query ran but SQL Server returned no plan (e.g. a statement that produced none).</summary> + NoPlanCaptured, + + /// <summary>The service reported a failure (permission denied, SQL error, not connected).</summary> + Failed, + + /// <summary>The service did not report a terminal result within the poll budget (still running).</summary> + TimedOut, +} + +/// <summary>An actual-plan capture's outcome: the plan XML on success, else a message to surface.</summary> +public sealed record ActualPlanResult(ActualPlanStatus Status, string? PlanXml, string? Message); diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AlertHistory.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AlertHistory.cs new file mode 100644 index 000000000..bff1098f0 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AlertHistory.cs @@ -0,0 +1,275 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// One Alerts-tab row over <c>config_alert_log</c>, mirroring Lite's <c>AlertHistoryRow</c> +/// (Lite/Services/LocalDataService.AlertHistory.cs): the same metric-keyed value formatting +/// (#1134), the same email-vs-tray <see cref="StatusDisplay"/>, and the shared +/// <see cref="AlertMetricClassifier"/> for critical/warning/resolved row emphasis. Carries +/// <see cref="ServerId"/> + <see cref="ServerName"/> so the all-servers Alert History surface (W2a) +/// can show a Server column and key the dismiss write on (alert_time, server_id, metric_name). +/// Darling has no parquet archive tier, so there is no <c>Source</c>/<c>IsArchived</c> split — every +/// row is live and dismissable. +/// </summary> +public sealed class ViewerAlertRow +{ + /// <summary>Stored naive-UTC alert time.</summary> + public required DateTime AlertTime { get; init; } + + /// <summary>The server the alert fired for (dismiss keys on it; the Server column shows the name).</summary> + public int ServerId { get; init; } + + /// <summary>The alert's server display name (the Server column + mute-from-alert context).</summary> + public string ServerName { get; init; } = ""; + + public required string MetricName { get; init; } + + public required double CurrentValue { get; init; } + + public required double ThresholdValue { get; init; } + + public required bool AlertSent { get; init; } + + public required string NotificationType { get; init; } + + public string? SendError { get; init; } + + public required bool Muted { get; init; } + + public string? DetailText { get; init; } + + public string? ContextJson { get; init; } + + /// <summary>Stored naive-UTC; shown in the viewer machine's local time (the viewer convention).</summary> + public string TimeLocal => ViewerTimeHelper.ForDisplay(AlertTime).ToString("yyyy-MM-dd HH:mm:ss"); + + public string CurrentValueDisplay => FormatValue(MetricName, CurrentValue); + + public string ThresholdValueDisplay => FormatValue(MetricName, ThresholdValue); + + /// <summary>Email rows show send outcome; tray/other rows show shown-vs-delivered (Lite's mapping).</summary> + public string StatusDisplay + { + get + { + if (NotificationType == "email") + { + return AlertSent ? "Sent" : (!string.IsNullOrEmpty(SendError) ? "Failed" : "Not sent"); + } + + return AlertSent ? "Delivered" : "Shown"; + } + } + + public bool IsResolved => AlertMetricClassifier.IsResolution(MetricName); + + public bool IsCritical => AlertMetricClassifier.IsCritical(MetricName); + + public bool IsWarning => AlertMetricClassifier.IsWarning(MetricName); + + /* #1134 — render the stored value with the unit/precision matching each metric the alert engine + emits, keyed on the exact metric_name strings. Copied from Lite's AlertHistoryRow.FormatValue + so both grids read identically; the :F2 fallback (never :G) keeps an unmapped metric — e.g. an + "Analysis: <category> [<hash>]" finding severity — from rendering as a raw full-precision float. */ + private static string FormatValue(string metricName, double value) => metricName switch + { + "High CPU" or "tempdb Space" or "Volume Free Space" or "Long-Running Job" => $"{value:F1}%", + "Poison Wait" => $"{value:F0} ms", + "Long-Running Query" => $"{value:F0} m", + "Blocking Detected" or "Deadlocks Detected" or "Failed Agent Job" => $"{value:F0}", + _ => $"{value:F2}", + }; +} + +public sealed partial class ViewerDataService +{ + /* The alert-history reads — Lite's GetAlertHistoryAsync query adapted to Darling's config_alert_log + (no source column and no archive tier, so no v_config_alert_log union; Darling rows are always + "live"). Same dismissed = FALSE filter, newest first, windowed on alert_time. Two shapes mirror + Lite: one scoped to a server, one across ALL servers (the Alert History default). Both SELECT + server_id + server_name so the grid's Server column and the dismiss key have them. */ + + private const string AlertHistorySelectColumns = @" + alert_time, + server_id, + server_name, + metric_name, + current_value, + threshold_value, + alert_sent, + notification_type, + send_error, + muted, + detail_text, + context_json"; + + /// <summary>Per-server read. $1 window start, $2 server_id, $3 limit (naive UTC / int / int).</summary> + public const string AlertHistorySql = @" +SELECT" + AlertHistorySelectColumns + @" +FROM config_alert_log +WHERE alert_time >= $1 +AND server_id = $2 +AND dismissed = FALSE +ORDER BY alert_time DESC +LIMIT $3"; + + /// <summary>All-servers read (the Alert History default). $1 window start, $2 limit (naive UTC / int).</summary> + public const string AlertHistoryAllServersSql = @" +SELECT" + AlertHistorySelectColumns + @" +FROM config_alert_log +WHERE alert_time >= $1 +AND dismissed = FALSE +ORDER BY alert_time DESC +LIMIT $2"; + + /// <summary> + /// Recent alerts newest first, excluding dismissed rows — the Alert History tab's read. With no + /// <paramref name="serverId"/> it aggregates ALL servers (the tab's default); with one it scopes to + /// that server (the Server filter combo). Mirrors Lite's optional-serverId GetAlertHistoryAsync. + /// </summary> + public async Task<List<ViewerAlertRow>> GetAlertHistoryAsync( + DateTime sinceUtc, int? serverId = null, int limit = 500, CancellationToken cancellationToken = default) + { + var rows = new List<ViewerAlertRow>(); + + await using var command = _dataSource.CreateCommand(serverId.HasValue ? AlertHistorySql : AlertHistoryAllServersSql); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(sinceUtc, DateTimeKind.Unspecified), + }); + if (serverId.HasValue) + { + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId.Value }); + } + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = limit }); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new ViewerAlertRow + { + AlertTime = reader.GetDateTime(0), + ServerId = reader.IsDBNull(1) ? 0 : reader.GetInt32(1), + ServerName = reader.IsDBNull(2) ? "" : reader.GetString(2), + MetricName = reader.IsDBNull(3) ? "" : reader.GetString(3), + CurrentValue = reader.IsDBNull(4) ? 0 : reader.GetDouble(4), + ThresholdValue = reader.IsDBNull(5) ? 0 : reader.GetDouble(5), + AlertSent = !reader.IsDBNull(6) && reader.GetBoolean(6), + NotificationType = reader.IsDBNull(7) ? "" : reader.GetString(7), + SendError = reader.IsDBNull(8) ? null : reader.GetString(8), + Muted = !reader.IsDBNull(9) && reader.GetBoolean(9), + DetailText = reader.IsDBNull(10) ? null : reader.GetString(10), + ContextJson = reader.IsDBNull(11) ? null : reader.GetString(11), + }); + } + + return rows; + } + + /* ── Dismiss write path (W2a) ────────────────────────────────────────────────────────────────── + Ports Lite's DismissAlertsAsync / DismissAllVisibleAlertsAsync UPDATE semantics (mark + dismissed = TRUE so the row drops out of the default read). Lite wrapped the update in a + transaction because it also wrote a dismissed_archive_alerts sidecar for rows that had aged into + parquet; Darling is a single Postgres store with NO archive tier, so the sidecar is dropped and + the two-phase live+sidecar dismiss collapses to one atomic UPDATE — no explicit transaction + needed. Every match is keyed the same way Lite keyed it: (alert_time, server_id, metric_name). */ + + /// <summary> + /// Dismiss the given rows by (alert_time, server_id, metric_name). One set-based UPDATE keyed off + /// unnested arrays (so the whole batch dismisses in a single round-trip). Already-dismissed rows are + /// left alone by the <c>dismissed = FALSE</c> guard. + /// </summary> + public const string DismissAlertsSql = @" +UPDATE config_alert_log +SET dismissed = TRUE +WHERE dismissed = FALSE +AND (alert_time, server_id, metric_name) IN ( + SELECT t.alert_time, t.server_id, t.metric_name + FROM unnest($1::timestamp[], $2::integer[], $3::text[]) AS t(alert_time, server_id, metric_name) +)"; + + /// <summary>Dismiss all visible rows in the window across every server. $1 window start.</summary> + public const string DismissAllAlertsSql = @" +UPDATE config_alert_log +SET dismissed = TRUE +WHERE alert_time >= $1 +AND dismissed = FALSE"; + + /// <summary>Dismiss all visible rows in the window for one server. $1 window start, $2 server_id.</summary> + public const string DismissAllAlertsForServerSql = @" +UPDATE config_alert_log +SET dismissed = TRUE +WHERE alert_time >= $1 +AND server_id = $2 +AND dismissed = FALSE"; + + /// <summary> + /// Marks the given alerts dismissed. Returns how many live rows the UPDATE actually changed + /// (already-dismissed or unknown rows count 0). See <see cref="DismissAlertsSql"/>. + /// </summary> + public async Task<int> DismissAlertsAsync(IReadOnlyList<ViewerAlertRow> alerts, CancellationToken cancellationToken = default) + { + if (alerts is null) + { + throw new ArgumentNullException(nameof(alerts)); + } + + if (alerts.Count == 0) + { + return 0; + } + + var times = new DateTime[alerts.Count]; + var ids = new int[alerts.Count]; + var metrics = new string[alerts.Count]; + for (var i = 0; i < alerts.Count; i++) + { + times[i] = DateTime.SpecifyKind(alerts[i].AlertTime, DateTimeKind.Unspecified); + ids[i] = alerts[i].ServerId; + metrics[i] = alerts[i].MetricName; + } + + await using var command = _dataSource.CreateCommand(DismissAlertsSql); + command.Parameters.Add(new NpgsqlParameter { Value = times }); + command.Parameters.Add(new NpgsqlParameter { Value = ids }); + command.Parameters.Add(new NpgsqlParameter { Value = metrics }); + + return await ExecuteWriteAsync(command, cancellationToken); + } + + /// <summary> + /// Marks every visible (non-dismissed) alert in the window dismissed — scoped to one server when + /// <paramref name="serverId"/> is set, otherwise across all servers (matching the current filter). + /// Returns the number of rows changed. + /// </summary> + public async Task<int> DismissAllVisibleAlertsAsync( + DateTime sinceUtc, int? serverId = null, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand( + serverId.HasValue ? DismissAllAlertsForServerSql : DismissAllAlertsSql); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(sinceUtc, DateTimeKind.Unspecified), + }); + if (serverId.HasValue) + { + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId.Value }); + } + + return await ExecuteWriteAsync(command, cancellationToken); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AlertSettings.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AlertSettings.cs new file mode 100644 index 000000000..26f704926 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AlertSettings.cs @@ -0,0 +1,347 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using NpgsqlTypes; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The viewer's control-plane writes to <c>config.config_alert_settings</c> — the single-row (id=1) desired +/// state for the shared alert engine's toggles/thresholds plus the scheduled-analysis cadence knobs the +/// Stage-1 <c>StoreConfigProvider</c> reads and hot-swaps into the running service's +/// <c>DarlingAlertSettings</c>/<c>AnalysisConfig</c> on every reload beacon. This is the Stage-3b rewire that +/// replaces the severed <c>viewer-settings.json</c> alert block: saving the Settings window's Notifications + +/// Automated-Analysis sections now drives the running service. +/// +/// <para>Mirrors <see cref="MonitoredServerUpsertSql"/>/<see cref="GetMuteRulesAsync"/> discipline exactly: +/// public-const SQL (so Darling.Tests pin the dialect + column parity with the service's +/// <c>StoreConfigProvider.ReadAlertSettingsAsync</c> without a live Postgres), every value bound as a +/// <c>$N</c> parameter, the write routed through <see cref="ExecuteWriteAsync"/> so a read-only <c>viewer</c> +/// seat degrades to <see cref="ViewerReadOnlyException"/> (42501). The single global row is pinned to +/// <c>id = 1</c>; <c>modified_at</c> is server-side naive-UTC. The bare table name resolves through the +/// <c>collect,config,public</c> search_path to <c>config.config_alert_settings</c>.</para> +/// +/// <para><b>cpu_mode.</b> The store carries the SERVICE's vocabulary — <c>"sql"</c> (SQL-process CPU) vs +/// <c>"total"</c> (all non-idle CPU) — which the service compares case-insensitively against <c>"sql"</c>. +/// The viewer's <see cref="AlertSettingsRow.CpuMode"/> holds that same store value; the Settings window maps +/// its "Total"/"SqlOnly" combo to/from it (see <see cref="MapCpuModeToStore"/>).</para> +/// </summary> +public sealed partial class ViewerDataService +{ + /* The 36 AlertsConfig + AnalysisConfig columns in the SAME order the service reads them + (StoreConfigProvider.ReadAlertSettingsAsync), so the parity test pins one list against both ends. + delivery_mode/per_event_max (V18, #1141) then the six long-running-query read knobs + the connection-change + notify toggle (V20) are appended so the existing ordinals stay pinned. */ + private const string AlertSettingsColumns = + "enabled, cpu_enabled, cpu_threshold_percent, cpu_mode, blocking_enabled, blocking_count_threshold, " + + "deadlock_enabled, deadlock_count_threshold, poison_wait_enabled, poison_wait_threshold_ms, " + + "long_running_query_enabled, long_running_query_threshold_minutes, tempdb_space_enabled, " + + "tempdb_space_threshold_percent, low_disk_enabled, low_disk_threshold_percent, low_disk_threshold_gb, " + + "long_running_job_enabled, long_running_job_multiplier, failed_job_enabled, failed_job_lookback_minutes, " + + "cooldown_minutes, excluded_databases, analysis_enabled, analysis_interval_minutes, " + + "analysis_notifications_enabled, analysis_notify_severity, delivery_mode, per_event_max, " + + "long_running_query_max_results, long_running_query_exclude_sp_server_diagnostics, " + + "long_running_query_exclude_wait_for, long_running_query_exclude_backups, " + + "long_running_query_exclude_misc_waits, long_running_query_exclude_cdc, notify_connection_changes"; + + /// <summary>The single global alert-settings row (id=1), for the Settings window prefill + the migrate-in + /// defaults check. Column order matches <see cref="AlertSettingsColumns"/>.</summary> + public const string AlertSettingsSelectSql = + "SELECT " + AlertSettingsColumns + " FROM config_alert_settings WHERE id = 1"; + + /// <summary>Upserts the single global alert-settings row (Settings window Save). ON CONFLICT rewrites every + /// column and bumps <c>modified_at</c> (and, via the V17 statement trigger, <c>config_version</c> — the + /// service reloads on its next sweep). $1..$36 bind the columns in <see cref="AlertSettingsColumns"/> order.</summary> + public const string AlertSettingsUpsertSql = @" +INSERT INTO config_alert_settings (id, " + AlertSettingsColumns + @", modified_at) +VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, + $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, (now() AT TIME ZONE 'UTC')) +ON CONFLICT (id) DO UPDATE SET + enabled = EXCLUDED.enabled, + cpu_enabled = EXCLUDED.cpu_enabled, + cpu_threshold_percent = EXCLUDED.cpu_threshold_percent, + cpu_mode = EXCLUDED.cpu_mode, + blocking_enabled = EXCLUDED.blocking_enabled, + blocking_count_threshold = EXCLUDED.blocking_count_threshold, + deadlock_enabled = EXCLUDED.deadlock_enabled, + deadlock_count_threshold = EXCLUDED.deadlock_count_threshold, + poison_wait_enabled = EXCLUDED.poison_wait_enabled, + poison_wait_threshold_ms = EXCLUDED.poison_wait_threshold_ms, + long_running_query_enabled = EXCLUDED.long_running_query_enabled, + long_running_query_threshold_minutes = EXCLUDED.long_running_query_threshold_minutes, + tempdb_space_enabled = EXCLUDED.tempdb_space_enabled, + tempdb_space_threshold_percent = EXCLUDED.tempdb_space_threshold_percent, + low_disk_enabled = EXCLUDED.low_disk_enabled, + low_disk_threshold_percent = EXCLUDED.low_disk_threshold_percent, + low_disk_threshold_gb = EXCLUDED.low_disk_threshold_gb, + long_running_job_enabled = EXCLUDED.long_running_job_enabled, + long_running_job_multiplier = EXCLUDED.long_running_job_multiplier, + failed_job_enabled = EXCLUDED.failed_job_enabled, + failed_job_lookback_minutes = EXCLUDED.failed_job_lookback_minutes, + cooldown_minutes = EXCLUDED.cooldown_minutes, + excluded_databases = EXCLUDED.excluded_databases, + analysis_enabled = EXCLUDED.analysis_enabled, + analysis_interval_minutes = EXCLUDED.analysis_interval_minutes, + analysis_notifications_enabled = EXCLUDED.analysis_notifications_enabled, + analysis_notify_severity = EXCLUDED.analysis_notify_severity, + delivery_mode = EXCLUDED.delivery_mode, + per_event_max = EXCLUDED.per_event_max, + long_running_query_max_results = EXCLUDED.long_running_query_max_results, + long_running_query_exclude_sp_server_diagnostics = EXCLUDED.long_running_query_exclude_sp_server_diagnostics, + long_running_query_exclude_wait_for = EXCLUDED.long_running_query_exclude_wait_for, + long_running_query_exclude_backups = EXCLUDED.long_running_query_exclude_backups, + long_running_query_exclude_misc_waits = EXCLUDED.long_running_query_exclude_misc_waits, + long_running_query_exclude_cdc = EXCLUDED.long_running_query_exclude_cdc, + notify_connection_changes = EXCLUDED.notify_connection_changes, + modified_at = (now() AT TIME ZONE 'UTC')"; + + /// <summary>The two <c>cpu_mode</c> values the service honors (it compares case-insensitively against + /// <see cref="CpuModeSql"/>; anything else is total).</summary> + public const string CpuModeSql = "sql"; + public const string CpuModeTotal = "total"; + + /// <summary>Reads the single global alert-settings row, or null when the store has not seeded it yet + /// (a pre-Stage-1 store, or the service has not started) — the caller then shows viewer defaults.</summary> + public async Task<AlertSettingsRow?> GetAlertSettingsAsync(CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(AlertSettingsSelectSql); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + return await reader.ReadAsync(cancellationToken) ? ReadAlertSettingsRow(reader) : null; + } + + /// <summary>Upserts the single global alert-settings row (Settings window Save). Read-only seats throw + /// <see cref="ViewerReadOnlyException"/> via <see cref="ExecuteWriteAsync"/>.</summary> + public async Task UpsertAlertSettingsAsync(AlertSettingsRow row, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(row); + + await using var command = _dataSource.CreateCommand(AlertSettingsUpsertSql); + BindAlertSettings(command, row); + await ExecuteWriteAsync(command, cancellationToken); + } + + private static void BindAlertSettings(NpgsqlCommand command, AlertSettingsRow r) + { + command.Parameters.Add(new NpgsqlParameter<bool> { TypedValue = r.Enabled }); // $1 + command.Parameters.Add(new NpgsqlParameter<bool> { TypedValue = r.CpuEnabled }); // $2 + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = r.CpuThresholdPercent }); // $3 + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = r.CpuMode }); // $4 + command.Parameters.Add(new NpgsqlParameter<bool> { TypedValue = r.BlockingEnabled }); // $5 + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = r.BlockingCountThreshold }); // $6 + command.Parameters.Add(new NpgsqlParameter<bool> { TypedValue = r.DeadlockEnabled }); // $7 + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = r.DeadlockCountThreshold }); // $8 + command.Parameters.Add(new NpgsqlParameter<bool> { TypedValue = r.PoisonWaitEnabled }); // $9 + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = r.PoisonWaitThresholdMs }); // $10 + command.Parameters.Add(new NpgsqlParameter<bool> { TypedValue = r.LongRunningQueryEnabled }); // $11 + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = r.LongRunningQueryThresholdMinutes }); // $12 + command.Parameters.Add(new NpgsqlParameter<bool> { TypedValue = r.TempDbSpaceEnabled }); // $13 + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = r.TempDbSpaceThresholdPercent }); // $14 + command.Parameters.Add(new NpgsqlParameter<bool> { TypedValue = r.LowDiskEnabled }); // $15 + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = r.LowDiskThresholdPercent }); // $16 + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = r.LowDiskThresholdGb }); // $17 + command.Parameters.Add(new NpgsqlParameter<bool> { TypedValue = r.LongRunningJobEnabled }); // $18 + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = r.LongRunningJobMultiplier }); // $19 + command.Parameters.Add(new NpgsqlParameter<bool> { TypedValue = r.FailedJobEnabled }); // $20 + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = r.FailedJobLookbackMinutes }); // $21 + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = r.CooldownMinutes }); // $22 + AddTextArray(command, r.ExcludedDatabases); // $23 + command.Parameters.Add(new NpgsqlParameter<bool> { TypedValue = r.AnalysisEnabled }); // $24 + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = r.AnalysisIntervalMinutes }); // $25 + command.Parameters.Add(new NpgsqlParameter<bool> { TypedValue = r.AnalysisNotificationsEnabled }); // $26 + command.Parameters.Add(new NpgsqlParameter<double> { TypedValue = r.AnalysisNotifySeverity }); // $27 + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = r.DeliveryMode }); // $28 + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = r.PerEventMax }); // $29 + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = r.LongRunningQueryMaxResults }); // $30 + command.Parameters.Add(new NpgsqlParameter<bool> { TypedValue = r.LongRunningQueryExcludeSpServerDiagnostics }); // $31 + command.Parameters.Add(new NpgsqlParameter<bool> { TypedValue = r.LongRunningQueryExcludeWaitFor }); // $32 + command.Parameters.Add(new NpgsqlParameter<bool> { TypedValue = r.LongRunningQueryExcludeBackups }); // $33 + command.Parameters.Add(new NpgsqlParameter<bool> { TypedValue = r.LongRunningQueryExcludeMiscWaits }); // $34 + command.Parameters.Add(new NpgsqlParameter<bool> { TypedValue = r.LongRunningQueryExcludeCdc }); // $35 + command.Parameters.Add(new NpgsqlParameter<bool> { TypedValue = r.NotifyConnectionChanges }); // $36 + } + + private static AlertSettingsRow ReadAlertSettingsRow(NpgsqlDataReader reader) => new() + { + Enabled = reader.GetBoolean(0), + CpuEnabled = reader.GetBoolean(1), + CpuThresholdPercent = reader.GetInt32(2), + CpuMode = reader.GetString(3), + BlockingEnabled = reader.GetBoolean(4), + BlockingCountThreshold = reader.GetInt32(5), + DeadlockEnabled = reader.GetBoolean(6), + DeadlockCountThreshold = reader.GetInt32(7), + PoisonWaitEnabled = reader.GetBoolean(8), + PoisonWaitThresholdMs = reader.GetInt32(9), + LongRunningQueryEnabled = reader.GetBoolean(10), + LongRunningQueryThresholdMinutes = reader.GetInt32(11), + TempDbSpaceEnabled = reader.GetBoolean(12), + TempDbSpaceThresholdPercent = reader.GetInt32(13), + LowDiskEnabled = reader.GetBoolean(14), + LowDiskThresholdPercent = reader.GetInt32(15), + LowDiskThresholdGb = reader.GetInt32(16), + LongRunningJobEnabled = reader.GetBoolean(17), + LongRunningJobMultiplier = reader.GetInt32(18), + FailedJobEnabled = reader.GetBoolean(19), + FailedJobLookbackMinutes = reader.GetInt32(20), + CooldownMinutes = reader.GetInt32(21), + ExcludedDatabases = reader.IsDBNull(22) ? new List<string>() : reader.GetFieldValue<string[]>(22).ToList(), + AnalysisEnabled = reader.GetBoolean(23), + AnalysisIntervalMinutes = reader.GetInt32(24), + AnalysisNotificationsEnabled = reader.GetBoolean(25), + AnalysisNotifySeverity = reader.GetDouble(26), + DeliveryMode = reader.GetString(27), + PerEventMax = reader.GetInt32(28), + LongRunningQueryMaxResults = reader.GetInt32(29), + LongRunningQueryExcludeSpServerDiagnostics = reader.GetBoolean(30), + LongRunningQueryExcludeWaitFor = reader.GetBoolean(31), + LongRunningQueryExcludeBackups = reader.GetBoolean(32), + LongRunningQueryExcludeMiscWaits = reader.GetBoolean(33), + LongRunningQueryExcludeCdc = reader.GetBoolean(34), + NotifyConnectionChanges = reader.GetBoolean(35), + }; + + /// <summary>Maps the Settings window's CPU-mode combo tag ("Total"/"SqlOnly") to the store value.</summary> + public static string MapCpuModeToStore(string viewerMode) => + string.Equals(viewerMode, "SqlOnly", StringComparison.OrdinalIgnoreCase) ? CpuModeSql : CpuModeTotal; + + /// <summary>Maps the store value back to the Settings window's combo tag.</summary> + public static string MapCpuModeFromStore(string? storeMode) => + string.Equals(storeMode, CpuModeSql, StringComparison.OrdinalIgnoreCase) ? "SqlOnly" : "Total"; +} + +/// <summary> +/// A <c>config.config_alert_settings</c> row (the single id=1 global row) as the viewer authors + reads it — +/// the desired-state twin of the service's <c>AlertsConfig</c> + <c>AnalysisConfig</c> the store hot-swaps in. +/// Carries ONLY the columns the store (and hence the service) has, now INCLUDING the Summary/Per-event delivery +/// mode + per-event cap (#1141/#1236, V18 — the service honors them at delivery time), the long-running-query +/// read shape (max-results + the five noise filters, V20 — the service forwards them to the LRQ read), and the +/// connection-change notify toggle (V20 — the service gates the Server-Unreachable/Restored edge on it). The +/// remaining viewer-only alert fields the Settings window also edits (tray minimize, mute-rule default expiration, +/// dismissal logging, analysis re-notify cooldown) are NOT service-honored config and stay viewer-local in +/// <see cref="ViewerAppSettings"/>. Defaults mirror the V17/V18/V20 DDL (and Lite's <c>App.*</c>) member-for-member +/// so <see cref="Defaults"/> equals a freshly-seeded row. +/// </summary> +public sealed class AlertSettingsRow +{ + public bool Enabled { get; set; } = true; + + /// <summary>Whether the service delivers the Server-Unreachable/Restored connect-edge alerts (V20 DDL default true).</summary> + public bool NotifyConnectionChanges { get; set; } = true; + + public bool CpuEnabled { get; set; } = true; + public int CpuThresholdPercent { get; set; } = 80; + + /// <summary><see cref="ViewerDataService.CpuModeSql"/> or <see cref="ViewerDataService.CpuModeTotal"/>.</summary> + public string CpuMode { get; set; } = ViewerDataService.CpuModeTotal; + + public bool BlockingEnabled { get; set; } = true; + public int BlockingCountThreshold { get; set; } = 1; + public bool DeadlockEnabled { get; set; } = true; + public int DeadlockCountThreshold { get; set; } = 1; + public bool PoisonWaitEnabled { get; set; } = true; + public int PoisonWaitThresholdMs { get; set; } = 500; + public bool LongRunningQueryEnabled { get; set; } = true; + public int LongRunningQueryThresholdMinutes { get; set; } = 30; + public bool TempDbSpaceEnabled { get; set; } = true; + public int TempDbSpaceThresholdPercent { get; set; } = 80; + public bool LowDiskEnabled { get; set; } = true; + public int LowDiskThresholdPercent { get; set; } = 10; + public int LowDiskThresholdGb { get; set; } = 5; + public bool LongRunningJobEnabled { get; set; } = true; + public int LongRunningJobMultiplier { get; set; } = 3; + public bool FailedJobEnabled { get; set; } = true; + public int FailedJobLookbackMinutes { get; set; } = 60; + public int CooldownMinutes { get; set; } = 5; + public List<string> ExcludedDatabases { get; set; } = new(); + public bool AnalysisEnabled { get; set; } = true; + public int AnalysisIntervalMinutes { get; set; } = 30; + + /// <summary>Darling's shipped default is TRUE (Lite's App default is false) — matches the V17 DDL.</summary> + public bool AnalysisNotificationsEnabled { get; set; } = true; + public double AnalysisNotifySeverity { get; set; } = 1.5; + + /// <summary>Deadlock/blocking delivery mode: "Summary" (one card per cycle) or "PerEvent" (#1141) — the store's + /// <c>delivery_mode</c> text, matching the service's <c>AlertNotificationMode</c> names. Default "Summary" (V18 DDL).</summary> + public string DeliveryMode { get; set; } = "Summary"; + + /// <summary>Per-event mode's per-cycle incident cap before the "+N more" batch. Default 5 (V18 DDL); clamped 1–100.</summary> + public int PerEventMax { get; set; } = 5; + + /* The long-running-query read shape (V20): the service forwards these to the LRQ read. Defaults match Lite's + App.* (5 rows / every filter on) so a freshly-seeded row suppresses the same noise it always did. */ + + /// <summary>Row cap for the long-running-query read (V20 DDL default 5; the read adapter clamps 1–1000).</summary> + public int LongRunningQueryMaxResults { get; set; } = 5; + + /// <summary>Exclude sessions waiting on SP_SERVER_DIAGNOSTICS (V20 DDL default true).</summary> + public bool LongRunningQueryExcludeSpServerDiagnostics { get; set; } = true; + + /// <summary>Exclude WAITFOR / BROKER_RECEIVE_WAITFOR sessions (V20 DDL default true).</summary> + public bool LongRunningQueryExcludeWaitFor { get; set; } = true; + + /// <summary>Exclude BACKUPTHREAD / BACKUPIO sessions (V20 DDL default true).</summary> + public bool LongRunningQueryExcludeBackups { get; set; } = true; + + /// <summary>Exclude XE_LIVE_TARGET_TVF sessions (V20 DDL default true).</summary> + public bool LongRunningQueryExcludeMiscWaits { get; set; } = true; + + /// <summary>Exclude CDC capture sessions (V20 DDL default true).</summary> + public bool LongRunningQueryExcludeCdc { get; set; } = true; + + /// <summary>A row equal to the V17/V18/V20 seed defaults — the migrate-in "is the service section still at defaults?" baseline.</summary> + public static AlertSettingsRow Defaults() => new(); + + /// <summary>Value-equality against another row (sequence-comparing the excluded-databases list) — the + /// migrate-in uses it to detect an untouched (default) store section and a genuine viewer customization.</summary> + public bool ValueEquals(AlertSettingsRow other) + { + ArgumentNullException.ThrowIfNull(other); + return Enabled == other.Enabled + && NotifyConnectionChanges == other.NotifyConnectionChanges + && CpuEnabled == other.CpuEnabled + && CpuThresholdPercent == other.CpuThresholdPercent + && string.Equals(CpuMode, other.CpuMode, StringComparison.OrdinalIgnoreCase) + && BlockingEnabled == other.BlockingEnabled + && BlockingCountThreshold == other.BlockingCountThreshold + && DeadlockEnabled == other.DeadlockEnabled + && DeadlockCountThreshold == other.DeadlockCountThreshold + && PoisonWaitEnabled == other.PoisonWaitEnabled + && PoisonWaitThresholdMs == other.PoisonWaitThresholdMs + && LongRunningQueryEnabled == other.LongRunningQueryEnabled + && LongRunningQueryThresholdMinutes == other.LongRunningQueryThresholdMinutes + && TempDbSpaceEnabled == other.TempDbSpaceEnabled + && TempDbSpaceThresholdPercent == other.TempDbSpaceThresholdPercent + && LowDiskEnabled == other.LowDiskEnabled + && LowDiskThresholdPercent == other.LowDiskThresholdPercent + && LowDiskThresholdGb == other.LowDiskThresholdGb + && LongRunningJobEnabled == other.LongRunningJobEnabled + && LongRunningJobMultiplier == other.LongRunningJobMultiplier + && FailedJobEnabled == other.FailedJobEnabled + && FailedJobLookbackMinutes == other.FailedJobLookbackMinutes + && CooldownMinutes == other.CooldownMinutes + && (ExcludedDatabases ?? new List<string>()).SequenceEqual(other.ExcludedDatabases ?? new List<string>()) + && AnalysisEnabled == other.AnalysisEnabled + && AnalysisIntervalMinutes == other.AnalysisIntervalMinutes + && AnalysisNotificationsEnabled == other.AnalysisNotificationsEnabled + && Math.Abs(AnalysisNotifySeverity - other.AnalysisNotifySeverity) < 0.0001 + && string.Equals(DeliveryMode, other.DeliveryMode, StringComparison.OrdinalIgnoreCase) + && PerEventMax == other.PerEventMax + && LongRunningQueryMaxResults == other.LongRunningQueryMaxResults + && LongRunningQueryExcludeSpServerDiagnostics == other.LongRunningQueryExcludeSpServerDiagnostics + && LongRunningQueryExcludeWaitFor == other.LongRunningQueryExcludeWaitFor + && LongRunningQueryExcludeBackups == other.LongRunningQueryExcludeBackups + && LongRunningQueryExcludeMiscWaits == other.LongRunningQueryExcludeMiscWaits + && LongRunningQueryExcludeCdc == other.LongRunningQueryExcludeCdc; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Blocking.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Blocking.cs new file mode 100644 index 000000000..7529c617f --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Blocking.cs @@ -0,0 +1,393 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Alerting; +using PerformanceMonitor.Analysis; +using PerformanceMonitor.Darling.Analysis; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Blocking-tab grid row — widened to Lite parity (W1e). All alert-consumed members (event time, +/// database, SPID pair, wait/lock, the query pair, report XML, contentious object, Source) live on the +/// shared <see cref="BlockedProcessAlertRow"/> base — the same shape Lite's grid row derives from — so +/// the viewer's store reads flow through the shared XE→DMV fallback merge +/// (<see cref="BlockedProcessReportMerge"/>) without a mapping copy. The grid-display extras below mirror +/// Lite's <c>BlockedProcessReportRow</c> exactly. event_time is stored naive-UTC (the XE @timestamp is +/// UTC; the DMV snapshot stamps the collector's UTC collection time), so <see cref="EventTimeLocal"/> +/// converts to viewer-local like every other collection_time — Lite's per-server +/// <c>ServerTimeHelper.FormatServerTime</c> becomes <see cref="ViewerTimeHelper.ForDisplay"/> (the +/// viewer's one machine-local convention; the same swap the trend charts already document). +/// </summary> +public sealed class ViewerBlockedProcessRow : BlockedProcessAlertRow +{ + public DateTime CollectionTime { get; set; } + public int BlockedEcid { get; set; } + public int BlockingEcid { get; set; } + public int? MonitorLoop { get; set; } + + public string WaitResource { get; set; } = ""; + public string BlockedStatus { get; set; } = ""; + public string BlockedIsolationLevel { get; set; } = ""; + public long BlockedLogUsed { get; set; } + public int BlockedTransactionCount { get; set; } + public string BlockedClientApp { get; set; } = ""; + public string BlockedHostName { get; set; } = ""; + public string BlockedLoginName { get; set; } = ""; + public string BlockingStatus { get; set; } = ""; + public string BlockingIsolationLevel { get; set; } = ""; + public string BlockingClientApp { get; set; } = ""; + public string BlockingHostName { get; set; } = ""; + public string BlockingLoginName { get; set; } = ""; + public string BlockedTransactionName { get; set; } = ""; + public string BlockingTransactionName { get; set; } = ""; + public DateTime? BlockedLastTranStarted { get; set; } + public DateTime? BlockingLastTranStarted { get; set; } + public DateTime? BlockedLastBatchStarted { get; set; } + public DateTime? BlockingLastBatchStarted { get; set; } + public DateTime? BlockedLastBatchCompleted { get; set; } + public DateTime? BlockingLastBatchCompleted { get; set; } + public int BlockedPriority { get; set; } + public int BlockingPriority { get; set; } + + /// <summary>The stored naive-UTC event time in the viewer machine's local time (Lite's grid format).</summary> + public string EventTimeLocal + => EventTime is { } eventTime ? ViewerTimeHelper.ForDisplay(eventTime).ToString("yyyy-MM-dd HH:mm:ss") : ""; + + /// <summary>Lite's wait-time rendering: sub-second in ms, else one-decimal seconds.</summary> + public string WaitTimeFormatted => ViewerDataService.FormatWaitTime(WaitTimeMs); + + /// <summary>Lite's long-block tint threshold: over 30 seconds waiting.</summary> + public bool IsLongBlock => WaitTimeMs > 30000; + + /// <summary> + /// The blocked / blocking query plans the Darling collector resolved BEST-EFFORT alongside the report + /// (blocked_process_reports.blocked_query_plan_xml / blocking_query_plan_xml, #1368 / V7 — from the + /// report's sql_handle via dm_exec_query_stats → dm_exec_text_query_plan, only when the host sets + /// CapturePlanXml; often NULL, and always NULL on the DMV-snapshot fallback rows and under Lite). The + /// Has* flags gate the two "View Plan" context items per row so a plan-less row shows them disabled + /// rather than shown-and-failed. The XML rides in-row (like blocked_process_report_xml already does), + /// so "View Plan" opens it with no second fetch. + /// </summary> + public string? BlockedQueryPlanXml { get; set; } + public string? BlockingQueryPlanXml { get; set; } + public bool HasBlockedQueryPlan => !string.IsNullOrEmpty(BlockedQueryPlanXml); + public bool HasBlockingQueryPlan => !string.IsNullOrEmpty(BlockingQueryPlanXml); + + /// <summary>True when this row carries the blocked-process-report XML (the XE rows; the DMV-snapshot fallback + /// rows have none). The report XML's executionStack frames carry the sql_handle the "Fetch Live Plan" items + /// key on, so those items are gated on this — a DMV-snapshot row shows them disabled.</summary> + public bool HasReportXml => !string.IsNullOrEmpty(BlockedProcessReportXml); +} + +public sealed partial class ViewerDataService +{ + /// <summary> + /// The XE blocked-process-report read — Lite's <c>GetRecentBlockedProcessReportsAsync</c> full + /// 37-column SELECT ported to Postgres, including <c>blocked_process_report_xml</c> (the block-chain / + /// XML-save surface needs it) and the two BEST-EFFORT plan columns (#1368 / V7) the grid's "View Plan" + /// items open. Same WHERE / ORDER BY event_time DESC / LIMIT 200 semantics. + /// + /// <para>Reads the BASE <c>blocked_process_reports</c> table, not <c>v_blocked_process_reports</c> + /// (deliberate divergence from the other blocking reads' v_-view twinning): the V7 plan columns are + /// projected reliably only by the base table. Postgres pins <c>SELECT *</c> in a view to the column + /// list at view-creation time (V4, before V7 added the plan columns), and V8 only <c>ALTER VIEW … SET + /// SCHEMA</c>s the view (it never re-creates it) — so on a store upgraded across the V7 boundary the + /// view would not expose the plan columns and this read would fail. The plan columns are Darling-only + /// (Lite's DuckDB view has no such column), so a plan-carrying read was never twinnable with Lite + /// anyway. The shared alert read (<c>DarlingAlertReadAdapter</c>) already reads this base table.</para> + /// $1 server_id, $2 window start, $3 window end (naive UTC). + /// </summary> + public const string BlockedProcessReportsSql = """ + SELECT + collection_time, + event_time, + database_name, + blocked_spid, + blocked_ecid, + blocking_spid, + blocking_ecid, + wait_time_ms, + wait_resource, + lock_mode, + blocked_status, + blocked_isolation_level, + blocked_log_used, + blocked_transaction_count, + blocked_client_app, + blocked_host_name, + blocked_login_name, + blocked_sql_text, + blocking_status, + blocking_isolation_level, + blocking_client_app, + blocking_host_name, + blocking_login_name, + blocking_sql_text, + blocked_process_report_xml, + blocked_transaction_name, + blocking_transaction_name, + blocked_last_tran_started, + blocking_last_tran_started, + blocked_last_batch_started, + blocking_last_batch_started, + blocked_last_batch_completed, + blocking_last_batch_completed, + blocked_priority, + blocking_priority, + contentious_object, + monitor_loop, + blocked_query_plan_xml, + blocking_query_plan_xml + FROM blocked_process_reports + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND ($4::text[] IS NULL OR database_name = ANY($4)) + ORDER BY event_time DESC + LIMIT 200 + """; + + /// <summary> + /// The always-on DMV blocking-snapshot fallback read for the grid — Lite's + /// <c>AppendDmvBlockedProcessGridRowsAsync</c> 19-column SELECT ported to Postgres + /// (dmv_blocking_snapshots carries no report XML). Same parameters as + /// <see cref="BlockedProcessReportsSql"/>. + /// </summary> + public const string DmvBlockingSnapshotsSql = """ + SELECT + collection_time, + event_time, + database_name, + blocked_spid, + blocked_ecid, + blocking_spid, + blocking_ecid, + wait_time_ms, + lock_mode, + blocking_status, + contentious_object, + blocked_sql_text, + blocking_sql_text, + blocked_login_name, + blocked_host_name, + blocked_client_app, + blocked_last_tran_started, + blocking_last_tran_started, + monitor_loop, + blocking_login_name, + blocking_host_name, + blocking_client_app + FROM v_dmv_blocking_snapshots + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND ($4::text[] IS NULL OR database_name = ANY($4)) + ORDER BY event_time DESC + LIMIT 200 + """; + + /// <summary> + /// The pair-row read for the block-chain viewer — Lite's <c>GetBlockingPairRowsAsync</c> ported to + /// Postgres, built from the shared <see cref="PgBlockingPairRowQuery"/> column fragments (the same + /// source of truth the Darling drill-down + fact collectors use, so the apex and column order can't + /// drift). Selects the full SQL text (the chain viewer renders it). <see cref="PgBlockingPairRowQuery.SpidFilter"/> + /// drops the missing-blocker sentinel so no consumer invents a SPID-0 apex. + /// </summary> + public const string BlockingPairRowsSql = $""" + SELECT + {PgBlockingPairRowQuery.LeadingColumns}, + blocked_sql_text, blocking_sql_text, + {PgBlockingPairRowQuery.IdentityColumns}, + contentious_object, + {PgBlockingPairRowQuery.TrailingIdentityColumns} + FROM v_blocked_process_reports + WHERE server_id = $1 AND event_time >= $2 AND event_time <= $3 + {PgBlockingPairRowQuery.SpidFilter} + ORDER BY event_time DESC + LIMIT 5000 + """; + + /// <summary> + /// Recent blocked-process events for one server — XE-preferred with the always-on DMV + /// snapshot as fallback, merged through the shared <see cref="BlockedProcessReportMerge"/> + /// (keep every XE row; append a DMV row only when no XE row covers the same + /// blocked/blocker SPID pair within the same minute; re-cap to the 200 newest) — EXACTLY + /// the alert path's semantics, on the shared row shape. + /// </summary> + public async Task<List<ViewerBlockedProcessRow>> GetRecentBlockedProcessReportsAsync( + int serverId, DateTime startUtc, DateTime endUtc, IReadOnlyList<string>? databaseNames = null, CancellationToken cancellationToken = default) + { + var items = await ReadBlockedProcessRowsAsync(serverId, startUtc, endUtc, databaseNames, cancellationToken); + var dmvItems = await ReadDmvBlockedProcessRowsAsync(serverId, startUtc, endUtc, databaseNames, cancellationToken); + + BlockedProcessReportMerge.AppendDmvFallbackRows(items, dmvItems); + + return items; + } + + /// <summary>Maps the full 37-column blocked-process-report read into the widened grid row.</summary> + private async Task<List<ViewerBlockedProcessRow>> ReadBlockedProcessRowsAsync( + int serverId, DateTime startUtc, DateTime endUtc, IReadOnlyList<string>? databaseNames, CancellationToken cancellationToken) + { + var rows = new List<ViewerBlockedProcessRow>(); + + await using var command = _dataSource.CreateCommand(BlockedProcessReportsSql); + AddBlockingParameters(command, serverId, startUtc, endUtc); + command.Parameters.Add(DatabaseFilterParameter(databaseNames)); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new ViewerBlockedProcessRow + { + CollectionTime = reader.GetDateTime(0), + EventTime = reader.IsDBNull(1) ? null : reader.GetDateTime(1), + DatabaseName = reader.IsDBNull(2) ? "" : reader.GetString(2), + BlockedSpid = reader.IsDBNull(3) ? 0 : reader.GetInt32(3), + BlockedEcid = reader.IsDBNull(4) ? 0 : reader.GetInt32(4), + BlockingSpid = reader.IsDBNull(5) ? 0 : reader.GetInt32(5), + BlockingEcid = reader.IsDBNull(6) ? 0 : reader.GetInt32(6), + WaitTimeMs = reader.IsDBNull(7) ? 0 : reader.GetInt64(7), + WaitResource = reader.IsDBNull(8) ? "" : reader.GetString(8), + LockMode = reader.IsDBNull(9) ? "" : reader.GetString(9), + BlockedStatus = reader.IsDBNull(10) ? "" : reader.GetString(10), + BlockedIsolationLevel = reader.IsDBNull(11) ? "" : reader.GetString(11), + BlockedLogUsed = reader.IsDBNull(12) ? 0 : reader.GetInt64(12), + BlockedTransactionCount = reader.IsDBNull(13) ? 0 : reader.GetInt32(13), + BlockedClientApp = reader.IsDBNull(14) ? "" : reader.GetString(14), + BlockedHostName = reader.IsDBNull(15) ? "" : reader.GetString(15), + BlockedLoginName = reader.IsDBNull(16) ? "" : reader.GetString(16), + BlockedSqlText = reader.IsDBNull(17) ? "" : reader.GetString(17), + BlockingStatus = reader.IsDBNull(18) ? "" : reader.GetString(18), + BlockingIsolationLevel = reader.IsDBNull(19) ? "" : reader.GetString(19), + BlockingClientApp = reader.IsDBNull(20) ? "" : reader.GetString(20), + BlockingHostName = reader.IsDBNull(21) ? "" : reader.GetString(21), + BlockingLoginName = reader.IsDBNull(22) ? "" : reader.GetString(22), + BlockingSqlText = reader.IsDBNull(23) ? "" : reader.GetString(23), + BlockedProcessReportXml = reader.IsDBNull(24) ? "" : reader.GetString(24), + BlockedTransactionName = reader.IsDBNull(25) ? "" : reader.GetString(25), + BlockingTransactionName = reader.IsDBNull(26) ? "" : reader.GetString(26), + BlockedLastTranStarted = reader.IsDBNull(27) ? null : reader.GetDateTime(27), + BlockingLastTranStarted = reader.IsDBNull(28) ? null : reader.GetDateTime(28), + BlockedLastBatchStarted = reader.IsDBNull(29) ? null : reader.GetDateTime(29), + BlockingLastBatchStarted = reader.IsDBNull(30) ? null : reader.GetDateTime(30), + BlockedLastBatchCompleted = reader.IsDBNull(31) ? null : reader.GetDateTime(31), + BlockingLastBatchCompleted = reader.IsDBNull(32) ? null : reader.GetDateTime(32), + BlockedPriority = reader.IsDBNull(33) ? 0 : reader.GetInt32(33), + BlockingPriority = reader.IsDBNull(34) ? 0 : reader.GetInt32(34), + ContentiousObject = reader.IsDBNull(35) ? "" : reader.GetString(35), + MonitorLoop = reader.IsDBNull(36) ? null : reader.GetInt32(36), + BlockedQueryPlanXml = reader.IsDBNull(37) ? null : reader.GetString(37), + BlockingQueryPlanXml = reader.IsDBNull(38) ? null : reader.GetString(38), + Source = BlockedProcessAlertRow.XeReportSource, + }); + } + + return rows; + } + + /// <summary>Maps the 19-column DMV blocking-snapshot fallback read into the widened grid row.</summary> + private async Task<List<ViewerBlockedProcessRow>> ReadDmvBlockedProcessRowsAsync( + int serverId, DateTime startUtc, DateTime endUtc, IReadOnlyList<string>? databaseNames, CancellationToken cancellationToken) + { + var rows = new List<ViewerBlockedProcessRow>(); + + await using var command = _dataSource.CreateCommand(DmvBlockingSnapshotsSql); + AddBlockingParameters(command, serverId, startUtc, endUtc); + command.Parameters.Add(DatabaseFilterParameter(databaseNames)); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new ViewerBlockedProcessRow + { + CollectionTime = reader.GetDateTime(0), + EventTime = reader.IsDBNull(1) ? null : reader.GetDateTime(1), + DatabaseName = reader.IsDBNull(2) ? "" : reader.GetString(2), + BlockedSpid = reader.IsDBNull(3) ? 0 : reader.GetInt32(3), + BlockedEcid = reader.IsDBNull(4) ? 0 : reader.GetInt32(4), + BlockingSpid = reader.IsDBNull(5) ? 0 : reader.GetInt32(5), + BlockingEcid = reader.IsDBNull(6) ? 0 : reader.GetInt32(6), + WaitTimeMs = reader.IsDBNull(7) ? 0 : reader.GetInt64(7), + LockMode = reader.IsDBNull(8) ? "" : reader.GetString(8), + BlockingStatus = reader.IsDBNull(9) ? "" : reader.GetString(9), + ContentiousObject = reader.IsDBNull(10) ? "" : reader.GetString(10), + BlockedSqlText = reader.IsDBNull(11) ? "" : reader.GetString(11), + BlockingSqlText = reader.IsDBNull(12) ? "" : reader.GetString(12), + BlockedLoginName = reader.IsDBNull(13) ? "" : reader.GetString(13), + BlockedHostName = reader.IsDBNull(14) ? "" : reader.GetString(14), + BlockedClientApp = reader.IsDBNull(15) ? "" : reader.GetString(15), + BlockedLastTranStarted = reader.IsDBNull(16) ? null : reader.GetDateTime(16), + BlockingLastTranStarted = reader.IsDBNull(17) ? null : reader.GetDateTime(17), + MonitorLoop = reader.IsDBNull(18) ? null : reader.GetInt32(18), + /* Blocking-side identity — the DMV snapshot is the only blocking source on AWS RDS / when the + BPR threshold is unset, so surface it here too (the XE path already sets these). */ + BlockingLoginName = reader.IsDBNull(19) ? "" : reader.GetString(19), + BlockingHostName = reader.IsDBNull(20) ? "" : reader.GetString(20), + BlockingClientApp = reader.IsDBNull(21) ? "" : reader.GetString(21), + Source = BlockedProcessAlertRow.DmvSnapshotSource, + }); + } + + return rows; + } + + /// <summary> + /// Fetches the blocked/blocker pair rows for a window, for the block-chain viewer to feed + /// <see cref="BlockingChainReconstructor"/>. Internal (not public): <see cref="BlockingPairRow"/> is + /// internal to the analysis assembly — a public method returning it would be CS0050 (mirror of Lite's + /// <c>GetBlockingPairRowsAsync</c>). Opens one pooled connection so the BPR fetch and the DMV-snapshot + /// append share it (the append takes a command factory, the Lite shape). Uses + /// <see cref="PgBlockingPairRowQuery"/> so the viewer agrees with the drill-down + fact collectors on + /// the apex. + /// </summary> + internal async Task<List<BlockingPairRow>> GetBlockingPairRowsAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var rows = new List<BlockingPairRow>(); + + await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); + await using (var command = connection.CreateCommand()) + { + command.CommandText = BlockingPairRowsSql; + AddBlockingParameters(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + rows.Add(PgBlockingPairRowQuery.Read(reader)); + } + + // Always-on DMV blocking snapshot: merge in the fallback rows so the viewer works even when the + // blocked-process-report XE captured nothing (threshold unset / AWS RDS). Same connection. + await PgBlockingPairRowQuery.AppendDmvSnapshotRowsAsync( + connection.CreateCommand, rows, serverId, startUtc, endUtc); + + return rows; + } + + /// <summary>The three blocking-window parameters ($1 server_id, $2/$3 naive-UTC window bounds).</summary> + private static void AddBlockingParameters(NpgsqlCommand command, int serverId, DateTime startUtc, DateTime endUtc) + { + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified), + }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified), + }); + } + + /// <summary>Lite's wait-time rendering: < 1000 ms as "N ms", else "N.N sec".</summary> + public static string FormatWaitTime(long waitTimeMs) + => waitTimeMs < 1000 ? $"{waitTimeMs} ms" : $"{waitTimeMs / 1000.0:F1} sec"; +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingSlicers.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingSlicers.cs new file mode 100644 index 000000000..dcb80ad90 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingSlicers.cs @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Blocking-tab time-range slicer bucket reads (W1e) — Lite's <c>GetBlockingSlicerDataAsync</c> and +/// <c>GetDeadlockSlicerDataAsync</c> (LocalDataService.Blocking.cs) ported to Postgres. Both bucket by +/// <c>date_trunc('hour', collection_time)</c> into the shared <see cref="TimeSliceBucket"/> the copied +/// slicer control consumes. Two dialect notes: Postgres <c>COUNT(*)</c>/<c>COUNT(DISTINCT …)</c> are +/// <c>bigint</c> and <c>SUM(…)/1000.0</c> is <c>numeric</c>, so the reads go through +/// <c>Convert.ToInt64</c>/<c>Convert.ToDouble</c> (the same tolerant idiom the pair-row + aggregate reads +/// use). The blocking read prefers the XE source and appends the always-on DMV snapshot only when the XE +/// source has no buckets in the window (<c>WHERE NOT EXISTS</c>), so a server with both never +/// double-counts. +/// </summary> +public sealed partial class ViewerDataService +{ + /// <summary> + /// Hourly blocking-event buckets for the slicer — Lite's <c>GetBlockingSlicerDataAsync</c> ported. + /// The XE blocked-process reports are the primary source; the DMV snapshot contributes only when the + /// XE source has no buckets in the window. Columns feed the slicer's sort-driven metric overlay + /// (events / total-wait-sec / distinct blockers / blocked / databases). + /// $1 server_id, $2 window start, $3 window end (naive UTC). + /// </summary> + public const string BlockingSlicerSql = """ + WITH bpr AS ( + SELECT + date_trunc('hour', collection_time) AS bucket, + COUNT(*) AS event_count, + COALESCE(SUM(wait_time_ms), 0) / 1000.0 AS total_wait_sec, + COUNT(DISTINCT blocking_spid) AS distinct_blockers, + COUNT(DISTINCT blocked_spid) AS distinct_blocked, + COUNT(DISTINCT database_name) AS distinct_databases + FROM v_blocked_process_reports + WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 + AND ($4::text[] IS NULL OR database_name = ANY($4)) + GROUP BY date_trunc('hour', collection_time) + ), + dmv AS ( + SELECT + date_trunc('hour', collection_time) AS bucket, + COUNT(*) AS event_count, + COALESCE(SUM(wait_time_ms), 0) / 1000.0 AS total_wait_sec, + COUNT(DISTINCT blocking_spid) AS distinct_blockers, + COUNT(DISTINCT blocked_spid) AS distinct_blocked, + COUNT(DISTINCT database_name) AS distinct_databases + FROM v_dmv_blocking_snapshots + WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 + AND ($4::text[] IS NULL OR database_name = ANY($4)) + GROUP BY date_trunc('hour', collection_time) + ) + SELECT bucket, event_count, total_wait_sec, distinct_blockers, distinct_blocked, distinct_databases FROM bpr + UNION ALL + SELECT bucket, event_count, total_wait_sec, distinct_blockers, distinct_blocked, distinct_databases FROM dmv + WHERE NOT EXISTS (SELECT 1 FROM bpr) + ORDER BY bucket + """; + + public async Task<List<TimeSliceBucket>> GetBlockingSlicerDataAsync( + int serverId, DateTime startUtc, DateTime endUtc, IReadOnlyList<string>? databaseNames = null, CancellationToken cancellationToken = default) + { + var items = new List<TimeSliceBucket>(); + + await using var command = _dataSource.CreateCommand(BlockingSlicerSql); + AddBlockingParameters(command, serverId, startUtc, endUtc); + command.Parameters.Add(DatabaseFilterParameter(databaseNames)); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var eventCount = reader.IsDBNull(1) ? 0 : Convert.ToInt64(reader.GetValue(1)); + items.Add(new TimeSliceBucket + { + BucketTime = reader.GetDateTime(0), + SessionCount = eventCount, + TotalCpu = reader.IsDBNull(2) ? 0 : Convert.ToDouble(reader.GetValue(2)), + TotalElapsed = reader.IsDBNull(3) ? 0 : Convert.ToDouble(reader.GetValue(3)), + TotalReads = reader.IsDBNull(4) ? 0 : Convert.ToDouble(reader.GetValue(4)), + TotalLogicalReads = reader.IsDBNull(5) ? 0 : Convert.ToDouble(reader.GetValue(5)), + Value = eventCount, + }); + } + + return items; + } + + /// <summary> + /// Hourly deadlock-count buckets for the slicer — Lite's <c>GetDeadlockSlicerDataAsync</c> ported. + /// $1 server_id, $2 window start, $3 window end (naive UTC). + /// </summary> + public const string DeadlockSlicerSql = """ + SELECT + date_trunc('hour', collection_time) AS bucket, + COUNT(*) AS deadlock_count + FROM v_deadlocks + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + GROUP BY date_trunc('hour', collection_time) + ORDER BY bucket + """; + + public async Task<List<TimeSliceBucket>> GetDeadlockSlicerDataAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var items = new List<TimeSliceBucket>(); + + await using var command = _dataSource.CreateCommand(DeadlockSlicerSql); + AddBlockingParameters(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var count = reader.IsDBNull(1) ? 0 : Convert.ToInt64(reader.GetValue(1)); + items.Add(new TimeSliceBucket + { + BucketTime = reader.GetDateTime(0), + SessionCount = count, + Value = count, + }); + } + + return items; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingStats.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingStats.cs new file mode 100644 index 000000000..28181db6d --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingStats.cs @@ -0,0 +1,249 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// One per-minute bucket of blocking SEVERITY (the Blocking Stats sub-tab): the count of blocked-process +/// events in the bucket plus the total / max / avg blocking DURATION (ms) over them. The on-the-fly +/// Darling equivalent of the Dashboard's pre-aggregated <c>collect.blocking_deadlock_stats</c> +/// (blocking_event_count / total_blocking_duration_ms / max_blocking_duration_ms / avg_blocking_duration_ms) +/// — computed from the raw <c>blocked_process_reports</c> rows the store already keeps, so no collector or +/// schema change is needed. The duration source is each blocked process's <c>wait_time_ms</c> (bigint) — +/// the same column the Blocked Process Reports grid and the alert path read as the block's wait time. +/// </summary> +public sealed record BlockingDurationStatsPoint( + DateTime Time, int EventCount, long TotalDurationMs, long MaxDurationMs, double AvgDurationMs); + +/// <summary> +/// One per-minute bucket of deadlock SEVERITY (the Blocking Stats sub-tab's deadlock companion to +/// <see cref="BlockingDurationStatsPoint"/>): the count of deadlock VICTIM processes in the bucket plus the +/// total / max / avg deadlock <c>wait_time_ms</c> over EVERY process in the bucket's graphs. The on-the-fly +/// Darling equivalent of the Dashboard's pre-aggregated <c>collect.blocking_deadlock_stats</c> +/// (<c>victim_count</c> / <c>total_deadlock_wait_time_ms</c>) — computed by parsing the +/// <c>deadlock_graph_xml</c> the store already keeps, so (like the blocking-duration aggregate) it needs no +/// collector and no schema change. Matches the Dashboard analyzer's exact semantics +/// (<c>26_blocking_deadlock_analyzer.sql</c>: <c>victim_count = SUM(CASE WHEN … VICTIM)</c>, +/// <c>total_deadlock_wait_time_ms = SUM(every process's wait_time)</c>) via the shared +/// <see cref="DeadlockGraphParser"/>'s per-process <see cref="DeadlockProcessNode.IsVictim"/> / +/// <see cref="DeadlockProcessNode.WaitTimeMs"/>. +/// </summary> +public sealed record DeadlockSeverityStatsPoint( + DateTime Time, int VictimCount, long TotalWaitMs, long MaxWaitMs, double AvgWaitMs); + +public sealed partial class ViewerDataService +{ + /// <summary> + /// Blocking-duration aggregate per minute — the SEVERITY companion to <see cref="BlockingTrendSql"/>'s + /// count-only trend, built on the SAME XE-preferred / DMV-fallback source selection so the two + /// reconcile exactly. XE blocked-process reports (<c>v_blocked_process_reports</c>) are the primary + /// source, bucketed on <c>event_time</c>; the always-on DMV snapshot (<c>v_dmv_blocking_snapshots</c>) + /// contributes only when the XE source has no rows in the window (<c>WHERE NOT EXISTS</c>), so a server + /// with both sources never double-counts (identical exclusion to the count trend). Each bucket carries + /// COUNT(*) events and the SUM / MAX / AVG of <c>wait_time_ms</c> (the per-blocked-process block + /// duration). Postgres dialect notes mirror the sibling reads: COUNT(*) is <c>bigint</c> (read via + /// GetInt64, narrowed to int); SUM of a bigint column is <c>numeric</c>, CAST back to <c>bigint</c> for + /// the typed GetInt64 reader (the same adaptation the waiting-task read makes); AVG is <c>numeric</c>, + /// CAST to <c>double precision</c> for GetDouble. $1 server_id, $2 window start, $3 window end (naive + /// UTC). + /// </summary> + public const string BlockingDurationStatsSql = """ + WITH bpr AS ( + SELECT + DATE_TRUNC('minute', event_time) AS bucket, + COUNT(*) AS event_count, + CAST(SUM(wait_time_ms) AS bigint) AS total_duration_ms, + MAX(wait_time_ms) AS max_duration_ms, + CAST(AVG(wait_time_ms) AS double precision) AS avg_duration_ms + FROM v_blocked_process_reports + WHERE server_id = $1 AND event_time >= $2 AND event_time <= $3 + GROUP BY DATE_TRUNC('minute', event_time) + ), + dmv AS ( + SELECT + DATE_TRUNC('minute', event_time) AS bucket, + COUNT(*) AS event_count, + CAST(SUM(wait_time_ms) AS bigint) AS total_duration_ms, + MAX(wait_time_ms) AS max_duration_ms, + CAST(AVG(wait_time_ms) AS double precision) AS avg_duration_ms + FROM v_dmv_blocking_snapshots + WHERE server_id = $1 AND event_time >= $2 AND event_time <= $3 + GROUP BY DATE_TRUNC('minute', event_time) + ) + SELECT bucket, event_count, total_duration_ms, max_duration_ms, avg_duration_ms FROM bpr + UNION ALL + SELECT bucket, event_count, total_duration_ms, max_duration_ms, avg_duration_ms FROM dmv WHERE NOT EXISTS (SELECT 1 FROM bpr) + ORDER BY bucket + """; + + /// <summary> + /// Blocking-duration-severity buckets for one server over the window (Blocking Stats sub-tab): total + /// events and total / max / avg block duration per minute, XE-preferred with the DMV-snapshot fallback + /// so it reconciles with the blocking-incident count trend. + /// </summary> + public async Task<List<BlockingDurationStatsPoint>> GetBlockingDurationStatsAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var items = new List<BlockingDurationStatsPoint>(); + + await using var command = _dataSource.CreateCommand(BlockingDurationStatsSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified), + }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified), + }); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new BlockingDurationStatsPoint( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : (int)reader.GetInt64(1), + reader.IsDBNull(2) ? 0 : reader.GetInt64(2), + reader.IsDBNull(3) ? 0 : reader.GetInt64(3), + reader.IsDBNull(4) ? 0 : reader.GetDouble(4))); + } + + return items; + } + + /// <summary> + /// The deadlock graphs in the window, for the deadlock-severity aggregate. Deliberately parses NOTHING in + /// SQL: the victim / wait-time signal lives inside <c>deadlock_graph_xml</c>, which only the shared + /// <see cref="DeadlockGraphParser"/> can read, so this read hands back just the raw + /// (<c>deadlock_time</c>, <c>deadlock_graph_xml</c>) pairs and the C# aggregate does the parse + bucket. + /// + /// <para>Windows on <c>collection_time</c> (NOT <c>deadlock_time</c>) and reads <c>v_deadlocks</c> — the + /// IDENTICAL row-selection predicate the deadlock COUNT trend (<see cref="DeadlockTrendSql"/>) uses — so + /// the count already shown on this tab's summary strip and the new victim_count / wait aggregate are drawn + /// from the exact same set of deadlock rows and reconcile in period. Bucketing (on <c>deadlock_time</c>, + /// matching the count trend's <c>DATE_TRUNC('minute', deadlock_time)</c>) happens C#-side after the parse, + /// so it is not expressed here. <c>deadlock_graph_xml</c> is an original deadlocks column (predates the V7 + /// <c>victim_query_plan_xml</c>), so unlike <see cref="RecentDeadlocksSql"/> the view exposes it. + /// No LIMIT: deadlocks are rare, and a cap would drop rows the count trend keeps (breaking reconciliation). + /// $1 server_id, $2 window start, $3 window end (naive UTC).</para> + /// </summary> + public const string DeadlockSeverityGraphsSql = """ + SELECT + deadlock_time, + deadlock_graph_xml + FROM v_deadlocks + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + ORDER BY deadlock_time + """; + + /// <summary> + /// Deadlock-severity buckets for one server over the window (Blocking Stats sub-tab): victim count and + /// total / max / avg deadlock wait per minute. Reads the raw graphs (async I/O), then parses + aggregates + /// them OFF the UI thread (<see cref="Task.Run"/>) — the graph walk is CPU-bound XML work, the same reason + /// the Deadlocks grid parses off-thread. Reconciles in period with the deadlock COUNT trend (same + /// <c>v_deadlocks</c> / <c>collection_time</c> window). + /// </summary> + public async Task<List<DeadlockSeverityStatsPoint>> GetDeadlockSeverityStatsAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var graphs = new List<(DateTime? DeadlockTime, string? Xml)>(); + + await using var command = _dataSource.CreateCommand(DeadlockSeverityGraphsSql); + AddBlockingParameters(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + graphs.Add(( + reader.IsDBNull(0) ? null : reader.GetDateTime(0), + reader.IsDBNull(1) ? null : reader.GetString(1))); + } + + /* Parse each graph + aggregate on the thread pool (Lite's #1193 discipline): deadlocks are low-volume, + but the sp_BlitzLock-style walk is still CPU-bound, so it never runs on the dispatcher. */ + return await Task.Run(() => AggregateDeadlockSeverity(graphs), cancellationToken); + } + + /// <summary> + /// Parses each deadlock graph via the shared <see cref="DeadlockGraphParser"/> and rolls the processes up + /// into per-minute severity buckets (bucketed on <c>deadlock_time</c> truncated to the minute, matching the + /// count trend's <c>DATE_TRUNC('minute', deadlock_time)</c>). Per bucket: <c>victim_count</c> = SUM of + /// <see cref="DeadlockProcessNode.IsVictim"/>, and total / max / avg deadlock wait over EVERY process's + /// <see cref="DeadlockProcessNode.WaitTimeMs"/> (avg is process-weighted = total ÷ process count) — the + /// Dashboard <c>collect.blocking_deadlock_stats</c> semantics. A graph with no parseable process (empty / + /// malformed XML) or a null <c>deadlock_time</c> (unplaceable on the time axis) contributes nothing. + /// Pure + off-WPF so Darling.Tests pin it without a live store; the ORDER BY is re-applied here because the + /// dictionary rollup does not preserve the read order. + /// </summary> + internal static List<DeadlockSeverityStatsPoint> AggregateDeadlockSeverity( + IReadOnlyList<(DateTime? DeadlockTime, string? Xml)> graphs) + { + var buckets = new Dictionary<DateTime, DeadlockSeverityAccumulator>(); + + foreach (var (deadlockTime, xml) in graphs) + { + if (deadlockTime is not { } dt) + continue; + + var model = DeadlockGraphParser.Parse(xml); + if (model.IsEmpty) + continue; + + var bucket = TruncateToMinute(dt); + if (!buckets.TryGetValue(bucket, out var acc)) + { + acc = new DeadlockSeverityAccumulator(); + buckets[bucket] = acc; + } + + foreach (var process in model.Processes) + { + if (process.IsVictim) + acc.VictimCount++; + acc.TotalWaitMs += process.WaitTimeMs; + if (process.WaitTimeMs > acc.MaxWaitMs) + acc.MaxWaitMs = process.WaitTimeMs; + acc.ProcessCount++; + } + } + + return buckets + .OrderBy(kvp => kvp.Key) + .Select(kvp => new DeadlockSeverityStatsPoint( + kvp.Key, + kvp.Value.VictimCount, + kvp.Value.TotalWaitMs, + kvp.Value.MaxWaitMs, + kvp.Value.ProcessCount > 0 ? (double)kvp.Value.TotalWaitMs / kvp.Value.ProcessCount : 0.0)) + .ToList(); + } + + /// <summary>Mutable per-bucket accumulator for <see cref="AggregateDeadlockSeverity"/> (a reference type so + /// the <c>TryGetValue</c> handle mutates the stored instance in place — one alloc per populated minute, + /// which is negligible at deadlock volumes).</summary> + private sealed class DeadlockSeverityAccumulator + { + public int VictimCount; + public long TotalWaitMs; + public long MaxWaitMs; + public int ProcessCount; + } + + /// <summary>Truncates to the minute to match Postgres <c>DATE_TRUNC('minute', …)</c> (the count trend's + /// bucketing), preserving the value's <see cref="DateTimeKind"/> (naive UTC from the store).</summary> + private static DateTime TruncateToMinute(DateTime value) => + new(value.Ticks - (value.Ticks % TimeSpan.TicksPerMinute), value.Kind); +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingTrends.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingTrends.cs new file mode 100644 index 000000000..92544c872 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingTrends.cs @@ -0,0 +1,305 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary>One incident-count-per-minute bucket for the Blocking Trends charts (mirror of Lite's +/// <c>TrendPoint</c>); shared by the blocking-incident and deadlock trend charts, which spike-plot it.</summary> +public sealed record BlockingTrendPoint(DateTime Time, int Count); + +/// <summary>One LCK% wait's per-second rate at a collection (mirror of Lite's <c>LockWaitTrendPoint</c>), +/// grouped by wait type for the Blocking Trends lock-wait chart.</summary> +public sealed record LockWaitTrendPoint(DateTime CollectionTime, string WaitType, double WaitTimeMsPerSecond); + +/// <summary>One wait type's total waiting-task duration at a collection (mirror of Lite's +/// <c>WaitingTaskTrendPoint</c>), for the Current Waits duration chart.</summary> +public sealed record WaitingTaskTrendPoint(DateTime CollectionTime, string WaitType, long TotalWaitMs); + +/// <summary>One database's blocked-session count at a collection (mirror of Lite's +/// <c>BlockedSessionTrendPoint</c>), for the Current Waits blocked-sessions chart.</summary> +public sealed record BlockedSessionTrendPoint(DateTime CollectionTime, string DatabaseName, int BlockedCount); + +/// <summary> +/// The Blocking tab's trend reads (W1c viewer copy-parity): the three Blocking-Trends charts +/// (lock-wait rate, blocking incidents, deadlocks) and the two Current-Waits charts (waiting-task +/// duration by wait type, blocked sessions by database). Each is Lite's <c>LocalDataService</c> query +/// (<c>.Blocking.cs</c> / <c>.WaitingTasks.cs</c>) ported to Postgres. Two dialect notes: (1) COUNT(*) +/// is <c>bigint</c> in Postgres, read via GetInt64 then narrowed to the record's int; (2) SUM of a +/// bigint column is <c>numeric</c> in Postgres, so the waiting-task total is CAST back to bigint for the +/// typed GetInt64 reader — the same adaptation the Queries read makes. The waiting-task reads hit the +/// base <c>waiting_tasks</c> table directly: unlike Lite's DuckDB, the Darling store has no +/// <c>v_waiting_tasks</c> passthrough view (it was left out of the V4/V5 migration view lists); the base +/// table has existed since V1, so no migration is added in this wave. +/// </summary> +public sealed partial class ViewerDataService +{ + /// <summary> + /// Blocking-incident count per minute — Lite's <c>GetBlockingTrendAsync</c> ported to Postgres. + /// XE blocked-process reports (<c>v_blocked_process_reports</c>) are the primary source, bucketed on + /// <c>event_time</c>; the always-on DMV snapshot (<c>v_dmv_blocking_snapshots</c>) is appended only + /// when the XE source has no rows in the window (<c>WHERE NOT EXISTS</c>), so a server with both + /// sources never double-counts. $1 server_id, $2 window start, $3 window end (naive UTC). + /// </summary> + public const string BlockingTrendSql = """ + WITH bpr AS ( + SELECT DATE_TRUNC('minute', event_time) AS bucket, COUNT(*) AS incident_count + FROM v_blocked_process_reports + WHERE server_id = $1 AND event_time >= $2 AND event_time <= $3 + AND ($4::text[] IS NULL OR database_name = ANY($4)) + GROUP BY DATE_TRUNC('minute', event_time) + ), + dmv AS ( + SELECT DATE_TRUNC('minute', event_time) AS bucket, COUNT(*) AS incident_count + FROM v_dmv_blocking_snapshots + WHERE server_id = $1 AND event_time >= $2 AND event_time <= $3 + AND ($4::text[] IS NULL OR database_name = ANY($4)) + GROUP BY DATE_TRUNC('minute', event_time) + ) + SELECT bucket, incident_count FROM bpr + UNION ALL + SELECT bucket, incident_count FROM dmv WHERE NOT EXISTS (SELECT 1 FROM bpr) + ORDER BY bucket + """; + + /// <summary> + /// Deadlock count per minute — Lite's <c>GetDeadlockTrendAsync</c> ported to Postgres. Buckets on + /// the deadlock's own <c>deadlock_time</c> while windowing on the collection prefix. Reads + /// <c>v_deadlocks</c>. $1 server_id, $2 window start, $3 window end (naive UTC). + /// </summary> + public const string DeadlockTrendSql = """ + SELECT + bucket, + deadlock_count + FROM ( + SELECT + DATE_TRUNC('minute', deadlock_time) AS bucket, + COUNT(*) AS deadlock_count + FROM v_deadlocks + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + GROUP BY DATE_TRUNC('minute', deadlock_time) + ) sub + ORDER BY bucket + """; + + /// <summary> + /// LCK% wait per-second rates — Lite's <c>GetLockWaitTrendAsync</c> ported to Postgres. Reads + /// <c>v_wait_stats</c> filtered to lock waits, derives each row's collection interval from the LAG of + /// the prior collection_time (partitioned by wait type), and divides the delta wait time by that + /// interval for a per-second rate (delta cast to double precision). $1 server_id, $2 window start, + /// $3 window end (naive UTC). + /// </summary> + public const string LockWaitTrendSql = """ + WITH raw AS + ( + SELECT + collection_time, + wait_type, + delta_wait_time_ms, + extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (PARTITION BY wait_type ORDER BY collection_time)))) AS interval_seconds + FROM v_wait_stats + WHERE server_id = $1 + AND wait_type LIKE 'LCK%' + AND collection_time >= $2 + AND collection_time <= $3 + ) + SELECT + collection_time, + wait_type, + CASE WHEN interval_seconds > 0 THEN CAST(delta_wait_time_ms AS double precision) / interval_seconds ELSE 0 END AS wait_time_ms_per_second + FROM raw + WHERE delta_wait_time_ms >= 0 + ORDER BY collection_time, wait_type + """; + + /// <summary> + /// Waiting-task total duration per (collection, wait type) — Lite's <c>GetWaitingTaskTrendAsync</c> + /// ported to Postgres. Reads the base <c>waiting_tasks</c> table (no v_ view exists). SUM of the + /// bigint duration is <c>numeric</c> in Postgres, so it is CAST back to bigint for the typed reader. + /// $1 server_id, $2 window start, $3 window end (naive UTC). + /// </summary> + public const string WaitingTaskTrendSql = """ + SELECT + collection_time, + wait_type, + CAST(SUM(wait_duration_ms) AS bigint) AS total_wait_ms + FROM waiting_tasks + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND wait_type IS NOT NULL + GROUP BY + collection_time, + wait_type + ORDER BY + collection_time, + wait_type + """; + + /// <summary> + /// Blocked-session count per (collection, database) — Lite's <c>GetBlockedSessionTrendAsync</c> + /// ported to Postgres. Reads the base <c>waiting_tasks</c> table (no v_ view exists), counting rows + /// whose <c>blocking_session_id</c> is set. $1 server_id, $2 window start, $3 window end (naive UTC). + /// </summary> + public const string BlockedSessionTrendSql = """ + SELECT + collection_time, + database_name, + COUNT(*) AS blocked_count + FROM waiting_tasks + WHERE server_id = $1 + AND blocking_session_id > 0 + AND collection_time >= $2 + AND collection_time <= $3 + AND ($4::text[] IS NULL OR database_name = ANY($4)) + AND database_name IS NOT NULL + GROUP BY + collection_time, + database_name + ORDER BY + collection_time, + database_name + """; + + /// <summary>Blocking-incident-per-minute buckets for one server over the window (Blocking Trends).</summary> + public async Task<List<BlockingTrendPoint>> GetBlockingTrendAsync( + int serverId, DateTime startUtc, DateTime endUtc, IReadOnlyList<string>? databaseNames = null, CancellationToken cancellationToken = default) + => await ReadCountTrendAsync(BlockingTrendSql, serverId, startUtc, endUtc, applyDatabaseFilter: true, databaseNames: databaseNames, cancellationToken: cancellationToken); + + /// <summary>Deadlock-per-minute buckets for one server over the window (Blocking Trends).</summary> + public async Task<List<BlockingTrendPoint>> GetDeadlockTrendAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + => await ReadCountTrendAsync(DeadlockTrendSql, serverId, startUtc, endUtc, cancellationToken: cancellationToken); + + /// <summary> + /// The blocking and deadlock trends share a (bucket timestamp, COUNT(*)) shape, so one reader maps + /// both. COUNT(*) is bigint in Postgres, read via GetInt64 and narrowed to the record's int. + /// The blocking trend applies the #1319 database filter (both CTEs carry database_name); the deadlock + /// trend is server-global (v_deadlocks has no database_name column), so it leaves the filter off. + /// </summary> + private async Task<List<BlockingTrendPoint>> ReadCountTrendAsync( + string sql, int serverId, DateTime startUtc, DateTime endUtc, + bool applyDatabaseFilter = false, IReadOnlyList<string>? databaseNames = null, CancellationToken cancellationToken = default) + { + var items = new List<BlockingTrendPoint>(); + + await using var command = _dataSource.CreateCommand(sql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified), + }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified), + }); + if (applyDatabaseFilter) + command.Parameters.Add(DatabaseFilterParameter(databaseNames)); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new BlockingTrendPoint( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : (int)reader.GetInt64(1))); + } + + return items; + } + + /// <summary>LCK% wait per-second rates for one server over the window (Blocking Trends).</summary> + public async Task<List<LockWaitTrendPoint>> GetLockWaitTrendAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var items = new List<LockWaitTrendPoint>(); + + await using var command = _dataSource.CreateCommand(LockWaitTrendSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified), + }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified), + }); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new LockWaitTrendPoint( + reader.GetDateTime(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + reader.IsDBNull(2) ? 0 : reader.GetDouble(2))); + } + + return items; + } + + /// <summary>Waiting-task total duration by wait type for one server over the window (Current Waits).</summary> + public async Task<List<WaitingTaskTrendPoint>> GetWaitingTaskTrendAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var items = new List<WaitingTaskTrendPoint>(); + + await using var command = _dataSource.CreateCommand(WaitingTaskTrendSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified), + }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified), + }); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new WaitingTaskTrendPoint( + reader.GetDateTime(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + reader.IsDBNull(2) ? 0 : reader.GetInt64(2))); + } + + return items; + } + + /// <summary>Blocked-session count by database for one server over the window (Current Waits).</summary> + public async Task<List<BlockedSessionTrendPoint>> GetBlockedSessionTrendAsync( + int serverId, DateTime startUtc, DateTime endUtc, IReadOnlyList<string>? databaseNames = null, CancellationToken cancellationToken = default) + { + var items = new List<BlockedSessionTrendPoint>(); + + await using var command = _dataSource.CreateCommand(BlockedSessionTrendSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified), + }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified), + }); + command.Parameters.Add(DatabaseFilterParameter(databaseNames)); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new BlockedSessionTrendPoint( + reader.GetDateTime(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + reader.IsDBNull(2) ? 0 : (int)reader.GetInt64(2))); + } + + return items; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectionHealth.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectionHealth.cs new file mode 100644 index 000000000..c6654a4f3 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectionHealth.cs @@ -0,0 +1,370 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Collection Health tab's three ported reads (W1i), copied from Lite's +/// <c>LocalDataService.CollectionHealth.cs</c> and run on the <c>v_collection_log</c> passthrough view. +/// This REPLACES the shell's placeholder Collection Health read (the DISTINCT-ON latest-run-per-collector +/// query and its simple <c>CollectorHealthRow</c> record, both removed from <c>ViewerDataService.cs</c>) +/// with Lite's rich 7-day aggregate: per-collector run/success/error counts, average duration, last +/// success / last run / last error timestamps, and the <see cref="CollectorHealthRow.HealthStatus"/> +/// banding. The SQL is byte-portable between DuckDB and Postgres (positional params, plain aggregates), +/// so only the parameter binding differs: window <see cref="DateTime"/>s go in with +/// <c>DateTimeKind.Unspecified</c> (the naive-UTC store convention), and the SUM/COUNT/AVG results are +/// read type-agnostically (Postgres returns <c>bigint</c> for the counts and <c>numeric</c> for AVG, +/// where DuckDB returned HUGEINT/DECIMAL) — the same intent as Lite's <c>ToInt64</c>/<c>ToDouble</c> +/// helpers, minus the DuckDB BigInteger case Postgres never produces. +/// <para> +/// The <b>Health Summary</b> aggregate keeps Lite's fixed 7-day horizon (its staleness banding needs a +/// stable window regardless of the toolbar). The <b>Collection Log</b> read (feeding the log grid + the +/// Duration Trends chart) diverges from Lite in ONE deliberate way: it bounds <c>collection_time</c> on +/// BOTH sides so the per-server toolbar's custom From/To is honored EXACTLY — Lite (and this file's first +/// port) took a single now-relative <c>hoursBack</c> lower bound, which rounded a custom range to a +/// hours-back-from-now span. +/// </para> +/// </summary> +public sealed partial class ViewerDataService +{ + /// <summary> + /// Lite's 7-day per-collector health aggregate (<c>GetCollectionHealthAsync</c>) verbatim: one row + /// per collector over the trailing window, with the SKIPPED-counts-as-a-healthy-run rule baked into + /// the last-success MAX and the PERMISSIONS bucket split out for the NO_PERMISSIONS banding. $1 + /// server_id, $2 window start (naive UTC). + /// </summary> + public const string CollectionHealthSql = """ + SELECT + collector_name, + COUNT(*) AS total_runs, + SUM(CASE WHEN status = 'SUCCESS' THEN 1 ELSE 0 END) AS success_count, + SUM(CASE WHEN status = 'ERROR' THEN 1 ELSE 0 END) AS error_count, + AVG(duration_ms) AS avg_duration_ms, + -- SKIPPED counts as a healthy run (dedup / version-gated collectors no-op without being stale) + MAX(CASE WHEN status IN ('SUCCESS', 'SKIPPED') THEN collection_time END) AS last_success_time, + MAX(collection_time) AS last_run_time, + MAX(CASE WHEN status IN ('ERROR', 'PERMISSIONS') THEN error_message END) AS last_error, + MAX(CASE WHEN status IN ('ERROR', 'PERMISSIONS') THEN collection_time END) AS last_error_time, + SUM(CASE WHEN status = 'PERMISSIONS' THEN 1 ELSE 0 END) AS permission_denied_count + FROM v_collection_log + WHERE server_id = $1 + AND collection_time >= $2 + GROUP BY collector_name + ORDER BY collector_name + """; + + /// <summary> + /// The fleet-cumulative variant of <see cref="CollectionHealthSql"/>: the same 10-column per-collector + /// aggregate but across ALL enabled monitored servers (GROUP BY server_id, collector_name — one row per + /// server/collector pair), for the status bar's aggregate-view total (mirrors Lite's cumulative + /// GetHealthSummary(null)). Scoped to enabled servers so a removed server's aged-out rows don't read as + /// erroring. $1 window start (naive UTC). + /// </summary> + public const string FleetCollectionHealthSql = """ + SELECT + collector_name, + COUNT(*) AS total_runs, + SUM(CASE WHEN status = 'SUCCESS' THEN 1 ELSE 0 END) AS success_count, + SUM(CASE WHEN status = 'ERROR' THEN 1 ELSE 0 END) AS error_count, + AVG(duration_ms) AS avg_duration_ms, + MAX(CASE WHEN status IN ('SUCCESS', 'SKIPPED') THEN collection_time END) AS last_success_time, + MAX(collection_time) AS last_run_time, + MAX(CASE WHEN status IN ('ERROR', 'PERMISSIONS') THEN error_message END) AS last_error, + MAX(CASE WHEN status IN ('ERROR', 'PERMISSIONS') THEN collection_time END) AS last_error_time, + SUM(CASE WHEN status = 'PERMISSIONS' THEN 1 ELSE 0 END) AS permission_denied_count + FROM v_collection_log + WHERE collection_time >= $1 + AND server_id IN (SELECT server_id FROM config_monitored_servers WHERE is_enabled) + GROUP BY server_id, collector_name + """; + + /// <summary> + /// The Collection Log sub-tab's recent-log read, adapted from Lite's <c>GetRecentCollectionLogAsync</c> + /// to honor the per-server toolbar's settable window EXACTLY: the collection_log rows for one server + /// between the window's start and end bounds, newest first, capped. Where Lite (and the shell's first + /// port) passed a single now-relative <c>hoursBack</c> lower bound — so a custom From/To rounded to a + /// hours-back-from-now span — this bounds <c>collection_time</c> on BOTH sides, matching how the Wait + /// Stats / Blocking tabs window their reads. $1 server_id, $2 window start, $3 window end (all naive + /// UTC), $4 row cap. + /// </summary> + public const string RecentCollectionLogSql = """ + SELECT + collector_name, + collection_time, + duration_ms, + sql_duration_ms, + duckdb_duration_ms, + rows_collected, + status, + error_message, + server_name + FROM v_collection_log + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + ORDER BY collection_time DESC + LIMIT $4 + """; + + /// <summary> + /// Lite's per-collector drill read (<c>GetCollectionLogByCollectorAsync</c>) verbatim: every + /// collection_log row for one collector on one server since the window start, newest first. Feeds + /// the CollectionLogWindow the Health Summary grid opens on double-click. $1 server_id, $2 + /// collector_name, $3 window start (naive UTC). + /// </summary> + public const string CollectionLogByCollectorSql = """ + SELECT + collector_name, + collection_time, + duration_ms, + sql_duration_ms, + duckdb_duration_ms, + rows_collected, + status, + error_message, + server_name + FROM v_collection_log + WHERE server_id = $1 + AND collector_name = $2 + AND collection_time >= $3 + ORDER BY collection_time DESC + """; + + /// <summary> + /// Per-collector health summary for one server over the trailing 7 days. Copied from Lite's + /// <c>GetCollectionHealthAsync</c> — same window, same columns, HealthStatus computed on the row. + /// </summary> + public async Task<List<CollectorHealthRow>> GetCollectionHealthAsync(int serverId, CancellationToken cancellationToken = default) + { + var items = new List<CollectorHealthRow>(); + + await using var command = _dataSource.CreateCommand(CollectionHealthSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(DateTime.UtcNow.AddDays(-7), DateTimeKind.Unspecified), + }); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(MapHealthRow(reader)); + } + + return items; + } + + /// <summary> + /// Fleet-cumulative per-collector health across ALL enabled monitored servers over the trailing 7 days — + /// the status bar's aggregate-view total (Overview / Alert History / FinOps / Recommendations), where Lite + /// shows a cumulative count rather than one server's. ONE query (<see cref="FleetCollectionHealthSql"/>) + /// regardless of fleet size; each row is a (server, collector) pair carrying its own + /// <see cref="CollectorHealthRow.HealthStatus"/>, so the caller counts total + FAILING exactly as per-server. + /// </summary> + public async Task<List<CollectorHealthRow>> GetFleetCollectionHealthAsync(CancellationToken cancellationToken = default) + { + var items = new List<CollectorHealthRow>(); + + await using var command = _dataSource.CreateCommand(FleetCollectionHealthSql); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(DateTime.UtcNow.AddDays(-7), DateTimeKind.Unspecified), + }); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(MapHealthRow(reader)); + } + + return items; + } + + /// <summary>Maps one row of the shared 10-column health projection (per-server or fleet) to a <see cref="CollectorHealthRow"/>.</summary> + private static CollectorHealthRow MapHealthRow(NpgsqlDataReader reader) => new() + { + CollectorName = reader.GetString(0), + TotalRuns = reader.IsDBNull(1) ? 0 : Convert.ToInt64(reader.GetValue(1)), + SuccessCount = reader.IsDBNull(2) ? 0 : Convert.ToInt64(reader.GetValue(2)), + ErrorCount = reader.IsDBNull(3) ? 0 : Convert.ToInt64(reader.GetValue(3)), + AvgDurationMs = reader.IsDBNull(4) ? 0 : Convert.ToDouble(reader.GetValue(4)), + LastSuccessTime = reader.IsDBNull(5) ? null : reader.GetDateTime(5), + LastRunTime = reader.IsDBNull(6) ? null : reader.GetDateTime(6), + LastError = reader.IsDBNull(7) ? null : reader.GetString(7), + LastErrorTime = reader.IsDBNull(8) ? null : reader.GetDateTime(8), + PermissionDeniedCount = reader.IsDBNull(9) ? 0 : Convert.ToInt64(reader.GetValue(9)), + }; + + /// <summary> + /// Collection_log entries for one server between the window's naive-UTC start/end bounds, most recent + /// first (500-row cap). Feeds the Collection Log sub-tab grid and the Duration Trends chart. The window + /// is the per-server toolbar's settable range (<c>GetWindowUtc</c>): a preset ends "now", a custom + /// From/To bounds EXACTLY — unlike the old hours-back read, a custom range no longer rounds to a + /// hours-back-from-now span. Mirrors how <see cref="GetDistinctWaitTypesAsync"/> windows its read. + /// </summary> + public async Task<List<CollectionLogRow>> GetRecentCollectionLogAsync(int serverId, DateTime startUtc, DateTime endUtc, int maxRows = 500, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(RecentCollectionLogSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified), + }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified), + }); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = maxRows }); + + return await ReadCollectionLogAsync(command, cancellationToken); + } + + /// <summary> + /// Collection_log entries for a specific collector on one server, most recent first. Copied from + /// Lite's <c>GetCollectionLogByCollectorAsync</c> (default 168-hour / 7-day window). Feeds the + /// CollectionLogWindow drill. + /// </summary> + public async Task<List<CollectionLogRow>> GetCollectionLogByCollectorAsync(int serverId, string collectorName, int hoursBack = 168, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(CollectionLogByCollectorSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = collectorName }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(DateTime.UtcNow.AddHours(-hoursBack), DateTimeKind.Unspecified), + }); + + return await ReadCollectionLogAsync(command, cancellationToken); + } + + /// <summary>Shared reader for the two collection-log projections (identical column list).</summary> + private static async Task<List<CollectionLogRow>> ReadCollectionLogAsync(NpgsqlCommand command, CancellationToken cancellationToken) + { + var items = new List<CollectionLogRow>(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new CollectionLogRow + { + CollectorName = reader.GetString(0), + CollectionTime = reader.GetDateTime(1), + DurationMs = reader.IsDBNull(2) ? null : Convert.ToInt32(reader.GetValue(2)), + SqlDurationMs = reader.IsDBNull(3) ? null : Convert.ToInt32(reader.GetValue(3)), + DuckDbDurationMs = reader.IsDBNull(4) ? null : Convert.ToInt32(reader.GetValue(4)), + RowsCollected = reader.IsDBNull(5) ? null : Convert.ToInt32(reader.GetValue(5)), + Status = reader.GetString(6), + ErrorMessage = reader.IsDBNull(7) ? null : reader.GetString(7), + ServerName = reader.IsDBNull(8) ? null : reader.GetString(8), + }); + } + + return items; + } +} + +/// <summary> +/// One row of the Collection Log grid / drill window — a single collector run's outcome. Copied +/// VERBATIM from Lite's <c>CollectionLogRow</c> (LocalDataService.CollectionHealth.cs): every display +/// property is a pure format of stored values, and <see cref="CollectionTimeFormatted"/> routes the +/// store's naive-UTC collection_time through <see cref="ViewerTimeHelper.ForDisplay"/> — the viewer's +/// mode-aware Server/Local/UTC conversion every other Darling timestamp also uses. +/// <see cref="DuckDbDurationMs"/> keeps its store column name (<c>duckdb_duration_ms</c>) +/// but in the Darling store that column records the POSTGRES write phase — the Collection Log grid +/// labels it "Store (ms)". +/// </summary> +public class CollectionLogRow +{ + public string CollectorName { get; set; } = ""; + public string? ServerName { get; set; } + public DateTime CollectionTime { get; set; } + public int? DurationMs { get; set; } + public int? SqlDurationMs { get; set; } + + /// <summary>Stored under the legacy <c>duckdb_duration_ms</c> column name; in the Darling store it + /// carries the Postgres storage-phase milliseconds. Surfaced as the grid's "Store (ms)" column.</summary> + public int? DuckDbDurationMs { get; set; } + public int? RowsCollected { get; set; } + public string Status { get; set; } = ""; + public string? ErrorMessage { get; set; } + + public string CollectionTimeFormatted => ViewerTimeHelper.ForDisplay(CollectionTime).ToString("g"); + + public string DurationFormatted => DurationMs.HasValue + ? (DurationMs.Value < 1000 ? $"{DurationMs.Value} ms" : $"{DurationMs.Value / 1000.0:F1} s") + : ""; + + public string SqlDurationFormatted => SqlDurationMs.HasValue ? $"{SqlDurationMs.Value} ms" : ""; + + public string DuckDbDurationFormatted => DuckDbDurationMs.HasValue ? $"{DuckDbDurationMs.Value} ms" : ""; +} + +/// <summary> +/// One Collection Health "Health Summary" grid row — a collector's 7-day roll-up with its health band. +/// Copied VERBATIM from Lite's rich <c>CollectorHealthRow</c> (LocalDataService.CollectionHealth.cs); +/// it REPLACES the shell's placeholder <c>CollectorHealthRow</c> record (a single latest-run snapshot). +/// Every property is a pure computation over the aggregate — <see cref="HealthStatus"/> delegates to the +/// shared <see cref="CollectorHealthClassifier"/> in PerformanceMonitor.Common (#1573), so Lite, this +/// viewer, and the service band identically and cannot drift; it resolves the collector's cadence from the +/// shared <see cref="CollectorScheduleDefaults"/> so a healthy DAILY collector is no longer flagged +/// stale/failing on the frequent-collector thresholds. The <see cref="DateTime.UtcNow"/> arithmetic in +/// <see cref="HoursSinceLastSuccess"/> is correct against the store's naive-UTC timestamps because both +/// sides are UTC instants (tick subtraction ignores Kind), matching Lite. +/// </summary> +public class CollectorHealthRow +{ + public string CollectorName { get; set; } = ""; + public long TotalRuns { get; set; } + public long SuccessCount { get; set; } + public long ErrorCount { get; set; } + public double AvgDurationMs { get; set; } + public DateTime? LastSuccessTime { get; set; } + public DateTime? LastRunTime { get; set; } + public string? LastError { get; set; } + public DateTime? LastErrorTime { get; set; } + public long PermissionDeniedCount { get; set; } + + public double FailureRatePercent => TotalRuns > 0 ? (double)ErrorCount / TotalRuns * 100 : 0; + public double HoursSinceLastSuccess => LastSuccessTime.HasValue + ? (DateTime.UtcNow - LastSuccessTime.Value).TotalHours + : 999; + + /// <summary>The collector's default cadence from the shared <see cref="CollectorScheduleDefaults"/> + /// (0 for an on-load or unknown collector — both fall to the floor thresholds). The banding uses the + /// shipped default, not any per-install override: the viewer has no cheap per-collector effective + /// frequency at the row level, and using the same default across all three surfaces keeps them in parity.</summary> + private int FrequencyMinutes => + CollectorScheduleDefaults.All.TryGetValue(CollectorName, out var schedule) ? schedule.FrequencyMinutes : 0; + + public string HealthStatus => CollectorHealthClassifier.Classify( + TotalRuns, SuccessCount, ErrorCount, PermissionDeniedCount, + HoursSinceLastSuccess, FrequencyMinutes, CollectorHealthClassifier.IsOnLoadCollector(CollectorName)); + + public string AvgDurationFormatted => AvgDurationMs < 1000 + ? $"{AvgDurationMs:F0} ms" + : $"{AvgDurationMs / 1000:F1} s"; + + public string LastSuccessFormatted => LastSuccessTime.HasValue + ? ViewerTimeHelper.ForDisplay(LastSuccessTime.Value).ToString("g") + : "Never"; + + public string LastRunFormatted => LastRunTime.HasValue + ? ViewerTimeHelper.ForDisplay(LastRunTime.Value).ToString("g") + : "Never"; + + public string LastErrorFormatted => LastErrorTime.HasValue + ? ViewerTimeHelper.ForDisplay(LastErrorTime.Value).ToString("g") + : ""; +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectorSchedules.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectorSchedules.cs new file mode 100644 index 000000000..1e74692a1 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectorSchedules.cs @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using NpgsqlTypes; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The viewer's control-plane writes to <c>config.config_collector_schedules</c> — the SPARSE per-collector +/// override table (absent row / NULL column = the shared <c>CollectorScheduleDefaults</c> code default; +/// <c>server_id</c> NULL = fleet-wide) the Stage-1 <c>StoreConfigProvider.ResolveSchedule</c> layers over the +/// code defaults (per-server override > fleet override > default, per column). This restores Lite's +/// per-server/per-collector schedule editor + presets, now writing the store the running service honors. +/// +/// <para>Same discipline as the sibling write partials: public-const SQL (Darling.Tests pin the dialect + the +/// two ON CONFLICT arbiters against V17's partial-unique indexes AND the service's own +/// <c>DarlingCommandExecutor.ResolveCollectorToggle</c> upsert shape), bound <c>$N</c> parameters, routed +/// through <see cref="ExecuteWriteAsync"/> so a read-only seat degrades to <see cref="ViewerReadOnlyException"/>. +/// A PRIMARY KEY cannot span the nullable <c>server_id</c>, so the fleet upsert arbitrates on +/// <c>(collector_name) WHERE server_id IS NULL</c> and the per-server upsert on +/// <c>(server_id, collector_name) WHERE server_id IS NOT NULL</c>.</para> +/// +/// <para>A whole-scope Save is atomic: the replace deletes the scope's rows and re-inserts its overrides +/// inside ONE transaction, so a mid-save failure never leaves a half-written schedule, and the many +/// per-collector writes collapse into a single service reload (the worker reads the latest +/// <c>config_version</c> once per sweep). The fleet scope stays sparse (only collectors that differ from the +/// code default get a row); a per-server "custom" scope writes an explicit row per collector, matching Lite's +/// full-snapshot per-server override so the grid is WYSIWYG regardless of the fleet layer.</para> +/// </summary> +public sealed partial class ViewerDataService +{ + /// <summary>All override rows (both scopes), for the editor to overlay on the code defaults. Column order + /// matches the service's <c>ReadScheduleOverridesAsync</c>.</summary> + public const string CollectorSchedulesSelectSql = + "SELECT server_id, collector_name, frequency_minutes, retention_days, enabled FROM config_collector_schedules ORDER BY server_id NULLS FIRST, collector_name"; + + /// <summary>Upserts one FLEET-WIDE override row (server_id NULL). Arbiter matches V17's + /// <c>ux_config_collector_schedules_fleet</c>. $1 collector_name, $2 frequency (nullable), $3 retention + /// (nullable), $4 enabled.</summary> + public const string CollectorScheduleFleetUpsertSql = @" +INSERT INTO config_collector_schedules (server_id, collector_name, frequency_minutes, retention_days, enabled) +VALUES (NULL, $1, $2, $3, $4) +ON CONFLICT (collector_name) WHERE server_id IS NULL DO UPDATE SET + frequency_minutes = EXCLUDED.frequency_minutes, + retention_days = EXCLUDED.retention_days, + enabled = EXCLUDED.enabled"; + + /// <summary>Upserts one PER-SERVER override row. Arbiter matches V17's + /// <c>ux_config_collector_schedules_server</c>. $1 server_id, $2 collector_name, $3 frequency (nullable), + /// $4 retention (nullable), $5 enabled.</summary> + public const string CollectorScheduleServerUpsertSql = @" +INSERT INTO config_collector_schedules (server_id, collector_name, frequency_minutes, retention_days, enabled) +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (server_id, collector_name) WHERE server_id IS NOT NULL DO UPDATE SET + frequency_minutes = EXCLUDED.frequency_minutes, + retention_days = EXCLUDED.retention_days, + enabled = EXCLUDED.enabled"; + + /// <summary>Deletes every fleet-wide override row (revert the fleet scope to code defaults).</summary> + public const string CollectorScheduleDeleteFleetScopeSql = + "DELETE FROM config_collector_schedules WHERE server_id IS NULL"; + + /// <summary>Deletes every override row for one server (the "use default schedule" revert). $1 server_id.</summary> + public const string CollectorScheduleDeleteServerScopeSql = + "DELETE FROM config_collector_schedules WHERE server_id = $1"; + + /// <summary>Deletes EVERY per-server override row in one statement (the fleet-wide "Apply Default to All" + /// reset), leaving the fleet-wide defaults (<c>server_id IS NULL</c>) untouched.</summary> + public const string CollectorScheduleDeleteAllServerScopesSql = + "DELETE FROM config_collector_schedules WHERE server_id IS NOT NULL"; + + /// <summary>All override rows in the store (both fleet + per-server), for the editor overlay.</summary> + public async Task<List<CollectorScheduleRow>> GetCollectorSchedulesAsync(CancellationToken cancellationToken = default) + { + var rows = new List<CollectorScheduleRow>(); + + await using var command = _dataSource.CreateCommand(CollectorSchedulesSelectSql); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new CollectorScheduleRow( + reader.IsDBNull(0) ? null : reader.GetInt32(0), + reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetInt32(2), + reader.IsDBNull(3) ? null : reader.GetInt32(3), + reader.GetBoolean(4))); + } + + return rows; + } + + /// <summary> + /// Atomically replaces the FLEET scope's overrides: delete every fleet row, then upsert the supplied ones + /// (pass only collectors that differ from the code default to keep the table sparse). Read-only seats throw + /// <see cref="ViewerReadOnlyException"/>. + /// </summary> + public Task ReplaceFleetSchedulesAsync(IEnumerable<CollectorScheduleRow> rows, CancellationToken cancellationToken = default) => + ReplaceScheduleScopeAsync(serverId: null, rows, cancellationToken); + + /// <summary> + /// Atomically replaces one SERVER's overrides: delete the server's rows, then upsert the supplied ones. Pass + /// an empty sequence to revert the server to the fleet/default schedule (the "use default" case). + /// </summary> + public Task ReplaceServerSchedulesAsync(int serverId, IEnumerable<CollectorScheduleRow> rows, CancellationToken cancellationToken = default) => + ReplaceScheduleScopeAsync(serverId, rows, cancellationToken); + + /// <summary> + /// Reverts EVERY server's per-server schedule override back to the fleet/default schedule in one statement + /// (the "Apply Default to All" bulk reset) — the fleet-scale shortcut over reverting one server at a time. + /// Deletes all per-server rows and leaves the fleet-wide (<c>server_id IS NULL</c>) overrides in place; + /// returns the number of override rows removed. The V17 <c>trg_bump_collector_schedules</c> trigger bumps + /// <c>config_version</c> on the DELETE, so the service re-resolves schedules on its next sweep (same reload + /// path as <see cref="ReplaceServerSchedulesAsync"/>). A read-only seat throws <see cref="ViewerReadOnlyException"/>. + /// </summary> + public async Task<int> ResetAllServerSchedulesAsync(CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(CollectorScheduleDeleteAllServerScopesSql); + return await ExecuteWriteAsync(command, cancellationToken); + } + + private async Task ReplaceScheduleScopeAsync(int? serverId, IEnumerable<CollectorScheduleRow> rows, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(rows); + + try + { + await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + + /* Clear the scope first so removed overrides don't linger, then re-insert the current set. */ + await using (var delete = new NpgsqlCommand( + serverId is null ? CollectorScheduleDeleteFleetScopeSql : CollectorScheduleDeleteServerScopeSql, connection, transaction)) + { + if (serverId is int sid) + { + delete.Parameters.Add(new NpgsqlParameter<int> { TypedValue = sid }); + } + + await delete.ExecuteNonQueryAsync(cancellationToken); + } + + foreach (var row in rows) + { + await using var upsert = new NpgsqlCommand( + serverId is null ? CollectorScheduleFleetUpsertSql : CollectorScheduleServerUpsertSql, connection, transaction); + if (serverId is int sid) + { + upsert.Parameters.Add(new NpgsqlParameter<int> { TypedValue = sid }); // $1 (server scope) + } + + upsert.Parameters.Add(new NpgsqlParameter<string> { TypedValue = row.CollectorName }); // $1 fleet / $2 server + AddNullableInt(upsert, row.FrequencyMinutes); // frequency + AddNullableInt(upsert, row.RetentionDays); // retention + upsert.Parameters.Add(new NpgsqlParameter<bool> { TypedValue = row.Enabled }); // enabled + await upsert.ExecuteNonQueryAsync(cancellationToken); + } + + await transaction.CommitAsync(cancellationToken); + } + catch (PostgresException ex) when (ex.SqlState == InsufficientPrivilegeSqlState) + { + throw new ViewerReadOnlyException(ex); + } + } + + private static void AddNullableInt(NpgsqlCommand command, int? value) => + command.Parameters.Add(new NpgsqlParameter + { + NpgsqlDbType = NpgsqlDbType.Integer, + Value = value.HasValue ? value.Value : DBNull.Value, + }); +} + +/// <summary> +/// One <c>config.config_collector_schedules</c> override row as the viewer authors + reads it — the twin of +/// the service's <c>ScheduleOverride</c>. <see cref="ServerId"/> NULL = fleet-wide; a NULL +/// <see cref="FrequencyMinutes"/>/<see cref="RetentionDays"/> falls through to the next resolution level (the +/// service's per-column layering). The editor works in effective values and only emits rows for collectors +/// that carry an actual override. +/// </summary> +public sealed record CollectorScheduleRow(int? ServerId, string CollectorName, int? FrequencyMinutes, int? RetentionDays, bool Enabled); diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Commands.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Commands.cs new file mode 100644 index 000000000..885abd747 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Commands.cs @@ -0,0 +1,259 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using NpgsqlTypes; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The viewer half of the store<->service COMMAND plane (Stage 2's <c>DarlingCommandExecutor</c> is the +/// service half): the viewer INSERTs a <c>pending</c> row into <c>config.config_command</c> and POLLS that +/// row until the service claims, executes, and writes back a terminal <c>status</c> +/// (<c>succeeded</c>/<c>failed</c>) plus <c>result_status</c>/<c>result_json</c>. This is how the Add-server +/// dialog drives <c>test_connect</c> now (the SERVICE holds the network path + credentials and does the +/// actual connect) and how Stage 3b will drive <c>snapshot_now</c>/<c>analyze_now</c>. +/// +/// <para>Same discipline as the config writes: public-const SQL, bound <c>$N</c> parameters, the enqueue +/// routed through <see cref="ExecuteWriteScalarAsync"/> so a read-only <c>viewer</c> seat degrades to +/// <see cref="ViewerReadOnlyException"/> (correct — it can't command the service). The bare table name +/// resolves through the search_path to <c>config.config_command</c>. <c>args_json</c> is bound as +/// <c>jsonb</c>.</para> +/// </summary> +public sealed partial class ViewerDataService +{ + /// <summary>Enqueues a pending command and returns its generated id. $1 requested_by, $2 command_type, + /// $3 target_server_id (nullable), $4 args_json (jsonb, nullable).</summary> + public const string CommandEnqueueSql = @" +INSERT INTO config_command (requested_by, command_type, target_server_id, args_json, status, created_at) +VALUES ($1, $2, $3, $4, 'pending', (now() AT TIME ZONE 'UTC')) +RETURNING command_id"; + + /// <summary>Reads a command's current status + terminal result for the poll loop. $1 command_id.</summary> + public const string CommandPollSql = + "SELECT status, result_status, result_json FROM config_command WHERE command_id = $1"; + + /// <summary>Deletes a command row by id. $1 command_id.</summary> + public const string CommandDeleteSql = "DELETE FROM config_command WHERE command_id = $1"; + + /// <summary>The two terminal command states (the poll stops when it sees either).</summary> + public const string StatusSucceeded = "succeeded"; + public const string StatusFailed = "failed"; + + /// <summary>Default overall wait for a command's terminal result: the service polls the queue on a ~5s + /// tick and a <c>test_connect</c> carries a 15s SqlClient connect budget, so 45s comfortably covers a + /// slow claim + a timing-out connection without hanging the dialog forever.</summary> + public static readonly TimeSpan DefaultCommandTimeout = TimeSpan.FromSeconds(45); + + private static readonly TimeSpan s_commandPollInterval = TimeSpan.FromMilliseconds(400); + + private static readonly JsonSerializerOptions s_argsJsonOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + /// <summary> + /// Enqueues a command and returns its <c>command_id</c>. A read-only seat throws + /// <see cref="ViewerReadOnlyException"/> (it cannot command the service — correct). + /// </summary> + public async Task<long> EnqueueCommandAsync( + string commandType, + int? targetServerId, + string? argsJson, + string? requestedBy, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(commandType)) + { + throw new ArgumentException("commandType is required.", nameof(commandType)); + } + + await using var command = _dataSource.CreateCommand(CommandEnqueueSql); + AddNullableText(command, requestedBy); // $1 + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = commandType }); // $2 + command.Parameters.Add(new NpgsqlParameter // $3 + { + NpgsqlDbType = NpgsqlDbType.Integer, + Value = targetServerId.HasValue ? targetServerId.Value : DBNull.Value, + }); + command.Parameters.Add(new NpgsqlParameter // $4 + { + NpgsqlDbType = NpgsqlDbType.Jsonb, + Value = (object?)argsJson ?? DBNull.Value, + }); + + var result = await ExecuteWriteScalarAsync(command, cancellationToken); + return Convert.ToInt64(result, System.Globalization.CultureInfo.InvariantCulture); + } + + /// <summary> + /// Reads a command row's current status + result (one poll). Returns null when the row is gone (never, + /// under normal use — a command is never deleted before it terminates). + /// </summary> + public async Task<CommandResult?> ReadCommandResultAsync(long commandId, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(CommandPollSql); + command.Parameters.Add(new NpgsqlParameter<long> { TypedValue = commandId }); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + { + return null; + } + + return new CommandResult( + Status: reader.GetString(0), + ResultStatus: reader.IsDBNull(1) ? null : reader.GetString(1), + ResultJson: reader.IsDBNull(2) ? null : reader.GetFieldValue<string>(2)); + } + + /// <summary> + /// Polls a command row until it reaches a terminal state (<c>succeeded</c>/<c>failed</c>) or the timeout + /// elapses, returning the terminal <see cref="CommandResult"/> or null on timeout (the caller shows a + /// "still running / try again" message). Polls every <see cref="s_commandPollInterval"/>. + /// </summary> + public async Task<CommandResult?> PollCommandResultAsync( + long commandId, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default) + { + var deadline = Stopwatch.StartNew(); + var budget = timeout ?? DefaultCommandTimeout; + + while (deadline.Elapsed < budget) + { + var result = await ReadCommandResultAsync(commandId, cancellationToken); + if (result is not null && IsTerminal(result.Status)) + { + return result; + } + + await Task.Delay(s_commandPollInterval, cancellationToken); + } + + return null; + } + + /// <summary>Enqueue + poll in one call — the common "run a command and wait for its result" path.</summary> + public async Task<CommandResult?> RunCommandAsync( + string commandType, + int? targetServerId, + string? argsJson, + string? requestedBy, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default) + { + var commandId = await EnqueueCommandAsync(commandType, targetServerId, argsJson, requestedBy, cancellationToken); + return await PollCommandResultAsync(commandId, timeout, cancellationToken); + } + + /// <summary> + /// Runs a <c>test_connect</c>: enqueue the inline server definition, poll for the probe result, then + /// DELETE the command row so its <c>args_json</c> DPAPI credential blob does not linger in the store. On a + /// poll timeout the row is LEFT in place (the service may still be connecting) — a service-side retention + /// sweep is the backstop for that case. Best-effort cleanup: a delete failure never masks the result. + /// </summary> + public async Task<CommandResult?> RunTestConnectAsync( + string argsJson, + string? requestedBy, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default) + { + var commandId = await EnqueueCommandAsync("test_connect", targetServerId: null, argsJson, requestedBy, cancellationToken); + var result = await PollCommandResultAsync(commandId, timeout, cancellationToken); + + if (result is not null) + { + try + { + await DeleteCommandAsync(commandId, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + /* The credential-bearing row couldn't be cleaned up now; the service-side purge is the + backstop. Never let cleanup hide the probe result the user is waiting on. */ + } + } + + return result; + } + + /// <summary>Deletes a command row (the viewer cleans up its own terminal test_connect command).</summary> + public async Task DeleteCommandAsync(long commandId, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(CommandDeleteSql); + command.Parameters.Add(new NpgsqlParameter<long> { TypedValue = commandId }); + await ExecuteWriteAsync(command, cancellationToken); + } + + /// <summary>Whether a status string is one of the two terminal states.</summary> + public static bool IsTerminal(string? status) => + status is StatusSucceeded or StatusFailed; + + /// <summary> + /// Serializes a <c>test_connect</c> server definition into the <c>args_json</c> the service deserializes + /// as its <c>MonitoredServer</c> (property names match its <c>[JsonPropertyName]</c> contract, camelCase). + /// The password rides as the DPAPI <c>encryptedPassword</c> blob, NOT plaintext — the service decrypts it + /// on its own host (same-machine managed deploy) exactly as it does a stored server's password, so no + /// plaintext credential ever lands in Postgres. Pure — pinned by Darling.Tests. + /// </summary> + public static string BuildTestConnectArgs(TestConnectServer server) + { + ArgumentNullException.ThrowIfNull(server); + return JsonSerializer.Serialize(server, s_argsJsonOptions); + } +} + +/// <summary>A command row's polled status + terminal result (<c>result_json</c> is the service's probe facts +/// or error, as raw JSON text).</summary> +public sealed record CommandResult(string Status, string? ResultStatus, string? ResultJson); + +/// <summary> +/// The inline server definition a <c>test_connect</c> command carries in <c>args_json</c>. The property +/// names/casing mirror the service's <c>MonitoredServer</c> <c>[JsonPropertyName]</c> contract so its +/// case-insensitive deserializer reconstructs the server and probes it. Carries only the connect-relevant +/// fields; the password is the DPAPI <see cref="EncryptedPassword"/> blob (never plaintext). +/// </summary> +public sealed class TestConnectServer +{ + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("host")] + public string Host { get; set; } = ""; + + [JsonPropertyName("database")] + public string? Database { get; set; } + + [JsonPropertyName("auth")] + public string Auth { get; set; } = ServerStoreCredential.Integrated; + + [JsonPropertyName("username")] + public string? Username { get; set; } + + /// <summary>DPAPI-LocalMachine blob (<see cref="ViewerServerSecret.Protect"/>) — the service decrypts it on its host.</summary> + [JsonPropertyName("encryptedPassword")] + public string? EncryptedPassword { get; set; } + + [JsonPropertyName("readOnlyIntent")] + public bool ReadOnlyIntent { get; set; } + + [JsonPropertyName("trustServerCertificate")] + public bool TrustServerCertificate { get; set; } + + [JsonPropertyName("encryptMode")] + public string EncryptMode { get; set; } = "Mandatory"; + + [JsonPropertyName("multiSubnetFailover")] + public bool MultiSubnetFailover { get; set; } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Config.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Config.cs new file mode 100644 index 000000000..457799bba --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Config.cs @@ -0,0 +1,277 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Configuration tab's four latest-snapshot reads (W1g viewer copy-parity) — Lite's +/// <c>LocalDataService.Config.cs</c> ported byte-for-byte to Postgres. Each read pulls the most +/// recent capture for one server via <c>capture_time = (SELECT MAX(capture_time) ...)</c> against +/// the config passthrough views (<c>v_server_config</c> / <c>v_database_config</c> / +/// <c>v_database_scoped_config</c> / <c>v_trace_flags</c>, all V4/V5 twins of Lite's DuckDB views). +/// The SELECT column lists — and the database-config reader's incrementing-ordinal mapping — stay +/// identical to Lite so the 28-column database-config grid binds the same properties. +/// Only <c>server_id</c> ($1) is parameterized; there is no timestamp parameter (the snapshot is +/// chosen in SQL), so no naive-UTC handling is needed here. The row classes port as-is from Lite +/// (the display properties are pure C#), so the copied XAML column bindings resolve unchanged. +/// </summary> +public sealed partial class ViewerDataService +{ + public const string ServerConfigSql = """ + SELECT configuration_name, value_configured, value_in_use, is_dynamic, is_advanced + FROM v_server_config + WHERE server_id = $1 + AND capture_time = (SELECT MAX(capture_time) FROM v_server_config WHERE server_id = $1) + ORDER BY configuration_name + """; + + /* 28 columns in Lite's exact SELECT order — the database-config reader below maps them by + incrementing ordinal, so this list's order is load-bearing and must stay byte-identical. */ + public const string DatabaseConfigSql = """ + SELECT database_name, state_desc, compatibility_level, collation_name, recovery_model, + is_read_only, is_auto_close_on, is_auto_shrink_on, + is_auto_create_stats_on, is_auto_update_stats_on, is_auto_update_stats_async_on, + is_read_committed_snapshot_on, snapshot_isolation_state, is_parameterization_forced, + is_query_store_on, is_encrypted, is_trustworthy_on, is_db_chaining_on, + is_broker_enabled, is_cdc_enabled, is_mixed_page_allocation_on, + log_reuse_wait_desc, page_verify_option, target_recovery_time_seconds, delayed_durability, + is_accelerated_database_recovery_on, is_memory_optimized_enabled, is_optimized_locking_on + FROM v_database_config + WHERE server_id = $1 + AND capture_time = (SELECT MAX(capture_time) FROM v_database_config WHERE server_id = $1) + AND ($2::text[] IS NULL OR database_name = ANY($2)) + ORDER BY database_name + """; + + public const string DatabaseScopedConfigSql = """ + SELECT database_name, configuration_name, value, value_for_secondary + FROM v_database_scoped_config + WHERE server_id = $1 + AND capture_time = (SELECT MAX(capture_time) FROM v_database_scoped_config WHERE server_id = $1) + AND ($2::text[] IS NULL OR database_name = ANY($2)) + ORDER BY database_name, configuration_name + """; + + public const string TraceFlagsSql = """ + SELECT trace_flag, status, is_global, is_session + FROM v_trace_flags + WHERE server_id = $1 + AND capture_time = (SELECT MAX(capture_time) FROM v_trace_flags WHERE server_id = $1) + ORDER BY trace_flag + """; + + /// <summary>Latest sys.configurations snapshot for one server (Server Configuration grid).</summary> + public async Task<List<ServerConfigRow>> GetLatestServerConfigAsync(int serverId, CancellationToken cancellationToken = default) + { + var items = new List<ServerConfigRow>(); + + await using var command = _dataSource.CreateCommand(ServerConfigSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new ServerConfigRow + { + ConfigurationName = reader.GetString(0), + ValueConfigured = reader.IsDBNull(1) ? 0 : reader.GetInt64(1), + ValueInUse = reader.IsDBNull(2) ? 0 : reader.GetInt64(2), + IsDynamic = !reader.IsDBNull(3) && reader.GetBoolean(3), + IsAdvanced = !reader.IsDBNull(4) && reader.GetBoolean(4) + }); + } + + return items; + } + + /// <summary>Latest sys.databases snapshot for one server (Database Configuration grid).</summary> + public async Task<List<DatabaseConfigRow>> GetLatestDatabaseConfigAsync(int serverId, IReadOnlyList<string>? databaseNames = null, CancellationToken cancellationToken = default) + { + var items = new List<DatabaseConfigRow>(); + + await using var command = _dataSource.CreateCommand(DatabaseConfigSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(DatabaseFilterParameter(databaseNames)); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + /* Incrementing-ordinal mapping copied verbatim from Lite so the 28-column order in + DatabaseConfigSql binds to the same properties — see the SELECT's byte-identical note. */ + var ordinal = 0; + items.Add(new DatabaseConfigRow + { + DatabaseName = reader.GetString(ordinal++), + StateDesc = reader.IsDBNull(ordinal) ? "" : reader.GetString(ordinal), + CompatibilityLevel = reader.IsDBNull(++ordinal) ? 0 : reader.GetInt32(ordinal), + CollationName = reader.IsDBNull(++ordinal) ? "" : reader.GetString(ordinal), + RecoveryModel = reader.IsDBNull(++ordinal) ? "" : reader.GetString(ordinal), + IsReadOnly = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + IsAutoCloseOn = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + IsAutoShrinkOn = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + IsAutoCreateStatsOn = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + IsAutoUpdateStatsOn = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + IsAutoUpdateStatsAsyncOn = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + IsRcsiOn = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + SnapshotIsolationState = reader.IsDBNull(++ordinal) ? "" : reader.GetString(ordinal), + IsParameterizationForced = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + IsQueryStoreOn = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + IsEncrypted = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + IsTrustworthyOn = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + IsDbChainingOn = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + IsBrokerEnabled = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + IsCdcEnabled = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + IsMixedPageAllocationOn = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + LogReuseWaitDesc = reader.IsDBNull(++ordinal) ? "" : reader.GetString(ordinal), + PageVerifyOption = reader.IsDBNull(++ordinal) ? "" : reader.GetString(ordinal), + TargetRecoveryTimeSeconds = reader.IsDBNull(++ordinal) ? 0 : reader.GetInt32(ordinal), + DelayedDurability = reader.IsDBNull(++ordinal) ? "" : reader.GetString(ordinal), + IsAcceleratedDatabaseRecoveryOn = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + IsMemoryOptimizedEnabled = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + IsOptimizedLockingOn = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + }); + } + + return items; + } + + /// <summary>Latest database-scoped configuration snapshot for one server (Scoped Configuration grid).</summary> + public async Task<List<DatabaseScopedConfigRow>> GetLatestDatabaseScopedConfigAsync(int serverId, IReadOnlyList<string>? databaseNames = null, CancellationToken cancellationToken = default) + { + var items = new List<DatabaseScopedConfigRow>(); + + await using var command = _dataSource.CreateCommand(DatabaseScopedConfigSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(DatabaseFilterParameter(databaseNames)); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new DatabaseScopedConfigRow + { + DatabaseName = reader.GetString(0), + ConfigurationName = reader.GetString(1), + Value = reader.IsDBNull(2) ? "" : reader.GetString(2), + ValueForSecondary = reader.IsDBNull(3) ? "" : reader.GetString(3) + }); + } + + return items; + } + + /// <summary>Latest trace-flags snapshot for one server (Trace Flags grid).</summary> + public async Task<List<TraceFlagRow>> GetLatestTraceFlagsAsync(int serverId, CancellationToken cancellationToken = default) + { + var items = new List<TraceFlagRow>(); + + await using var command = _dataSource.CreateCommand(TraceFlagsSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new TraceFlagRow + { + TraceFlag = reader.GetInt32(0), + Status = !reader.IsDBNull(1) && reader.GetBoolean(1), + IsGlobal = !reader.IsDBNull(2) && reader.GetBoolean(2), + IsSession = !reader.IsDBNull(3) && reader.GetBoolean(3) + }); + } + + return items; + } +} + +/* The four row classes port as-is from Lite's LocalDataService.Config.cs — the display properties + (bool -> "Yes"/"No", etc.) are pure C#, so the copied XAML column bindings resolve unchanged. */ + +public class ServerConfigRow +{ + public string ConfigurationName { get; set; } = ""; + public long ValueConfigured { get; set; } + public long ValueInUse { get; set; } + public bool IsDynamic { get; set; } + public bool IsAdvanced { get; set; } + public string DynamicDisplay => IsDynamic ? "Yes" : "No"; + public string AdvancedDisplay => IsAdvanced ? "Yes" : "No"; + public bool ValuesMatch => ValueConfigured == ValueInUse; +} + +public class DatabaseConfigRow +{ + public string DatabaseName { get; set; } = ""; + public string StateDesc { get; set; } = ""; + public int CompatibilityLevel { get; set; } + public string CollationName { get; set; } = ""; + public string RecoveryModel { get; set; } = ""; + public bool IsReadOnly { get; set; } + public bool IsAutoCloseOn { get; set; } + public bool IsAutoShrinkOn { get; set; } + public bool IsAutoCreateStatsOn { get; set; } + public bool IsAutoUpdateStatsOn { get; set; } + public bool IsAutoUpdateStatsAsyncOn { get; set; } + public bool IsRcsiOn { get; set; } + public string SnapshotIsolationState { get; set; } = ""; + public bool IsParameterizationForced { get; set; } + public bool IsQueryStoreOn { get; set; } + public bool IsEncrypted { get; set; } + public bool IsTrustworthyOn { get; set; } + public bool IsDbChainingOn { get; set; } + public bool IsBrokerEnabled { get; set; } + public bool IsCdcEnabled { get; set; } + public bool IsMixedPageAllocationOn { get; set; } + public string LogReuseWaitDesc { get; set; } = ""; + public string PageVerifyOption { get; set; } = ""; + public int TargetRecoveryTimeSeconds { get; set; } + public string DelayedDurability { get; set; } = ""; + public bool IsAcceleratedDatabaseRecoveryOn { get; set; } + public bool IsMemoryOptimizedEnabled { get; set; } + public bool IsOptimizedLockingOn { get; set; } + + /* Display properties for DataGrid (bool -> Yes/No) */ + public string ReadOnlyDisplay => IsReadOnly ? "Yes" : "No"; + public string AutoCloseDisplay => IsAutoCloseOn ? "Yes" : "No"; + public string AutoShrinkDisplay => IsAutoShrinkOn ? "Yes" : "No"; + public string AutoCreateStatsDisplay => IsAutoCreateStatsOn ? "Yes" : "No"; + public string AutoUpdateStatsDisplay => IsAutoUpdateStatsOn ? "Yes" : "No"; + public string AutoUpdateStatsAsyncDisplay => IsAutoUpdateStatsAsyncOn ? "Yes" : "No"; + public string RcsiDisplay => IsRcsiOn ? "Yes" : "No"; + public string ParameterizationForcedDisplay => IsParameterizationForced ? "Yes" : "No"; + public string QueryStoreDisplay => IsQueryStoreOn ? "Yes" : "No"; + public string EncryptedDisplay => IsEncrypted ? "Yes" : "No"; + public string TrustworthyDisplay => IsTrustworthyOn ? "Yes" : "No"; + public string DbChainingDisplay => IsDbChainingOn ? "Yes" : "No"; + public string BrokerEnabledDisplay => IsBrokerEnabled ? "Yes" : "No"; + public string CdcEnabledDisplay => IsCdcEnabled ? "Yes" : "No"; + public string MixedPageAllocationDisplay => IsMixedPageAllocationOn ? "Yes" : "No"; + public string AdrDisplay => IsAcceleratedDatabaseRecoveryOn ? "Yes" : "No"; + public string MemoryOptimizedDisplay => IsMemoryOptimizedEnabled ? "Yes" : "No"; + public string OptimizedLockingDisplay => IsOptimizedLockingOn ? "Yes" : "No"; +} + +public class DatabaseScopedConfigRow +{ + public string DatabaseName { get; set; } = ""; + public string ConfigurationName { get; set; } = ""; + public string Value { get; set; } = ""; + public string ValueForSecondary { get; set; } = ""; +} + +public class TraceFlagRow +{ + public int TraceFlag { get; set; } + public bool Status { get; set; } + public bool IsGlobal { get; set; } + public bool IsSession { get; set; } + public string StatusDisplay => Status ? "Enabled" : "Disabled"; + public string GlobalDisplay => IsGlobal ? "Yes" : "No"; + public string SessionDisplay => IsSession ? "Yes" : "No"; +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ConfigChanges.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ConfigChanges.cs new file mode 100644 index 000000000..bfd1c37f6 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ConfigChanges.cs @@ -0,0 +1,305 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Configuration Changes tab's three change histories (Dashboard's <c>ConfigChangesContent</c> ported to +/// the viewer): server-config, database-config and trace-flag DRIFT over time. The Dashboard reads +/// pre-materialized <c>report.*_configuration_changes</c> / <c>report.trace_flag_changes</c> views (LAG-diff +/// over a persisted config history); Darling has no such views, so the change history is computed in C# by +/// diffing the store's APPEND-ONLY config SNAPSHOTS (<c>v_server_config</c> / <c>v_database_config</c> / +/// <c>v_trace_flags</c>, every setting written every capture, keyed by <c>capture_time</c>). The pure diff + +/// the change/snapshot records + the derived columns live ONCE in +/// <see cref="PerformanceMonitor.Common.ConfigChangeDiff"/> (shared by the headless MCP host and Lite — no +/// hand-mirrored twin); this class owns only the viewer-side reads and the display-row wrappers that add +/// Darling's timezone rendering. +/// +/// <para> +/// WINDOWING — the read is upper-bounded at the window end (<c>capture_time <= $2</c>) and BOTH window edges +/// are applied to the computed <c>change_time</c> in <see cref="ConfigChangeDiff"/> (NOT to the snapshot read). +/// This deliberately keeps the snapshot immediately BEFORE the window as the diff baseline, so a change landing +/// on the first in-window snapshot is still detected — mirroring the Dashboard, whose <c>report.*_changes</c> +/// views LAG over the full history and then filter <c>change_time >= @from AND change_time <= @to</c>. +/// Strictly lower-bounding the snapshot read would drop that baseline and under-report the left-edge change. +/// </para> +/// +/// <para> +/// COLLECTION-CADENCE + SHAPE CAVEATS (surfaced honestly, never silently dropped): the config collectors run ON +/// CONNECT only (<c>FrequencyMinutes = 0</c>), so change granularity equals the service's connect/restart +/// cadence, and a fresh / stably-connected server may hold a single snapshot — then there is no change yet (the +/// diff needs >= 2 snapshots). The change grids emit ONLY collected values; the Dashboard enrichment columns +/// Darling does not collect are OMITTED (not fabricated): server-config <c>description</c> (the setting's +/// human blurb) and database-config <c>setting_type</c> (the store is WIDE — <c>setting_name</c> is the literal +/// column name). <c>requires_restart</c> is NOT one of the gaps: it is derivable from collected columns +/// (<c>is_dynamic = false AND value_configured != value_in_use</c>, the Dashboard view's own definition), so it +/// is kept as a derived column. The per-row change narrative is DERIVED from the values (allowed), matching +/// the Dashboard view's wording. +/// </para> +/// </summary> +public sealed partial class ViewerDataService +{ + /* ─────────────────────────── snapshot-read SQL (upper-bound at window end) ─────────────────────────── */ + + /// <summary>Every sys.configurations snapshot for a server up to the window end, oldest-first per name — + /// the diff walks consecutive captures per configuration_name (every config row is present in every + /// snapshot, so a per-name walk suffices). $1 server_id, $2 window end (naive UTC).</summary> + public const string ServerConfigChangesSnapshotsSql = """ + SELECT + capture_time, + configuration_name, + value_configured, + value_in_use, + is_dynamic, + is_advanced + FROM v_server_config + WHERE server_id = $1 + AND capture_time <= $2 + ORDER BY configuration_name, capture_time + """; + + /// <summary>Every sys.databases snapshot for a server up to the window end, with the 27 setting columns + /// CAST to text for a uniform value-diff. $1 server_id, $2 window end (naive UTC). The SELECT order is + /// <see cref="ConfigChangeDiff.DatabaseConfigChangeSettingNames"/> (load-bearing — the diff walks the wide + /// row positionally).</summary> + public const string DatabaseConfigChangesSnapshotsSql = """ + SELECT + capture_time, + database_name, + state_desc, + compatibility_level::text, + collation_name, + recovery_model, + is_read_only::text, + is_auto_close_on::text, + is_auto_shrink_on::text, + is_auto_create_stats_on::text, + is_auto_update_stats_on::text, + is_auto_update_stats_async_on::text, + is_read_committed_snapshot_on::text, + snapshot_isolation_state, + is_parameterization_forced::text, + is_query_store_on::text, + is_encrypted::text, + is_trustworthy_on::text, + is_db_chaining_on::text, + is_broker_enabled::text, + is_cdc_enabled::text, + is_mixed_page_allocation_on::text, + log_reuse_wait_desc, + page_verify_option, + target_recovery_time_seconds::text, + delayed_durability, + is_accelerated_database_recovery_on::text, + is_memory_optimized_enabled::text, + is_optimized_locking_on::text + FROM v_database_config + WHERE server_id = $1 + AND capture_time <= $2 + AND ($3::text[] IS NULL OR database_name = ANY($3)) + ORDER BY database_name, capture_time + """; + + /// <summary>Every trace-flag snapshot for a server up to the window end (a row exists only while a flag is + /// enabled), oldest-first — the diff set-diffs consecutive captures. $1 server_id, $2 window end (naive UTC).</summary> + public const string TraceFlagChangesSnapshotsSql = """ + SELECT + capture_time, + trace_flag, + status, + is_global, + is_session + FROM v_trace_flags + WHERE server_id = $1 + AND capture_time <= $2 + ORDER BY capture_time, trace_flag + """; + + /* ─────────────────────────── public read + diff (composes the read with the shared ConfigChangeDiff) ─────────────────────────── */ + + /// <summary>Server-configuration changes in the window [startUtc, endUtc]. Reads every snapshot up to + /// endUtc (so the pre-window baseline is kept) then diffs per configuration_name via the shared + /// <see cref="ConfigChangeDiff"/>, wrapping each change in a display row that adds Darling's timezone render.</summary> + public async Task<List<ServerConfigChangeRow>> GetServerConfigChangesAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var snapshots = await ReadServerConfigSnapshotsAsync(serverId, endUtc, cancellationToken); + return ConfigChangeDiff.DiffServerConfigChanges(snapshots, startUtc, endUtc) + .Select(c => new ServerConfigChangeRow(c)) + .ToList(); + } + + /// <summary>Database-configuration changes in the window [startUtc, endUtc]. Reads every snapshot up to + /// endUtc then UNPIVOTs the wide row per (database, setting) whose value moved via the shared + /// <see cref="ConfigChangeDiff"/>.</summary> + public async Task<List<DatabaseConfigChangeRow>> GetDatabaseConfigChangesAsync( + int serverId, DateTime startUtc, DateTime endUtc, IReadOnlyList<string>? databaseNames = null, CancellationToken cancellationToken = default) + { + var snapshots = await ReadDatabaseConfigSnapshotsAsync(serverId, endUtc, databaseNames, cancellationToken); + return ConfigChangeDiff.DiffDatabaseConfigChanges(snapshots, startUtc, endUtc) + .Select(c => new DatabaseConfigChangeRow(c)) + .ToList(); + } + + /// <summary>Trace-flag changes in the window [startUtc, endUtc]. Reads every snapshot up to endUtc then + /// set-diffs consecutive captures via the shared <see cref="ConfigChangeDiff"/> (flag appears = enabled, + /// disappears = disabled, status/scope moves = modified).</summary> + public async Task<List<TraceFlagChangeRow>> GetTraceFlagChangesAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var snapshots = await ReadTraceFlagSnapshotsAsync(serverId, endUtc, cancellationToken); + return ConfigChangeDiff.DiffTraceFlagChanges(snapshots, startUtc, endUtc) + .Select(c => new TraceFlagChangeRow(c)) + .ToList(); + } + + private async Task<List<ConfigChangeDiff.ServerConfigSnapshot>> ReadServerConfigSnapshotsAsync( + int serverId, DateTime endUtc, CancellationToken cancellationToken) + { + var rows = new List<ConfigChangeDiff.ServerConfigSnapshot>(); + await using var command = _dataSource.CreateCommand(ServerConfigChangesSnapshotsSql); + AddServerIdAndWindowEnd(command, serverId, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new ConfigChangeDiff.ServerConfigSnapshot( + reader.GetDateTime(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetInt64(2), + reader.IsDBNull(3) ? null : reader.GetInt64(3), + reader.IsDBNull(4) ? null : reader.GetBoolean(4), + reader.IsDBNull(5) ? null : reader.GetBoolean(5))); + } + + return rows; + } + + private async Task<List<ConfigChangeDiff.DatabaseConfigSnapshot>> ReadDatabaseConfigSnapshotsAsync( + int serverId, DateTime endUtc, IReadOnlyList<string>? databaseNames, CancellationToken cancellationToken) + { + var rows = new List<ConfigChangeDiff.DatabaseConfigSnapshot>(); + await using var command = _dataSource.CreateCommand(DatabaseConfigChangesSnapshotsSql); + AddServerIdAndWindowEnd(command, serverId, endUtc); + command.Parameters.Add(DatabaseFilterParameter(databaseNames)); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var values = new string?[ConfigChangeDiff.DatabaseConfigChangeSettingNames.Count]; + for (var i = 0; i < values.Length; i++) + { + var ordinal = i + 2; /* capture_time, database_name precede the settings */ + values[i] = reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal); + } + + rows.Add(new ConfigChangeDiff.DatabaseConfigSnapshot( + reader.GetDateTime(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + values)); + } + + return rows; + } + + private async Task<List<ConfigChangeDiff.TraceFlagSnapshot>> ReadTraceFlagSnapshotsAsync( + int serverId, DateTime endUtc, CancellationToken cancellationToken) + { + var rows = new List<ConfigChangeDiff.TraceFlagSnapshot>(); + await using var command = _dataSource.CreateCommand(TraceFlagChangesSnapshotsSql); + AddServerIdAndWindowEnd(command, serverId, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new ConfigChangeDiff.TraceFlagSnapshot( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : reader.GetInt32(1), + reader.IsDBNull(2) ? null : reader.GetBoolean(2), + reader.IsDBNull(3) ? null : reader.GetBoolean(3), + reader.IsDBNull(4) ? null : reader.GetBoolean(4))); + } + + return rows; + } + + /// <summary>$1 server_id, $2 window end re-stamped naive-UTC (the store is naive-UTC, matching the other + /// windowed viewer reads — see ViewerDataService.SystemEvents.cs).</summary> + private static void AddServerIdAndWindowEnd(NpgsqlCommand command, int serverId, DateTime endUtc) + { + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified) }); + } +} + +/* ─────────────────────────── grid row view-models — thin DISPLAY WRAPPERS over the shared change records: + they add ONLY the timezone-rendered ChangeTimeDisplay (Darling's ViewerTimeHelper, applied at bind time on + the UI thread) and bind the shared pure-derived columns. No diff logic here — that lives in ConfigChangeDiff. ─────────────────────────── */ + +/// <summary>One server-configuration change (Server Config Changes grid). Wraps +/// <see cref="ConfigChangeDiff.ServerConfigChange"/>; only <see cref="ChangeTimeDisplay"/> is viewer-specific.</summary> +public sealed class ServerConfigChangeRow(ConfigChangeDiff.ServerConfigChange change) +{ + public DateTime ChangeTime => change.ChangeTime; + public string ConfigurationName => change.ConfigurationName; + public long? OldValueConfigured => change.OldValueConfigured; + public long? NewValueConfigured => change.NewValueConfigured; + public long? OldValueInUse => change.OldValueInUse; + public long? NewValueInUse => change.NewValueInUse; + public bool? IsDynamic => change.IsDynamic; + public bool? IsAdvanced => change.IsAdvanced; + + public string ChangeTimeDisplay => ViewerTimeHelper.ForDisplay(change.ChangeTime).ToString("yyyy-MM-dd HH:mm:ss"); + public string DynamicDisplay => change.DynamicDisplay; + public string AdvancedDisplay => change.AdvancedDisplay; + public bool RequiresRestart => change.RequiresRestart; + public string RequiresRestartDisplay => change.RequiresRestartDisplay; + public string ChangeDescription => change.ChangeDescription; +} + +/// <summary>One database-configuration change (Database Config Changes grid). Wraps +/// <see cref="ConfigChangeDiff.DatabaseConfigChange"/>; only <see cref="ChangeTimeDisplay"/> is viewer-specific.</summary> +public sealed class DatabaseConfigChangeRow(ConfigChangeDiff.DatabaseConfigChange change) +{ + public DateTime ChangeTime => change.ChangeTime; + public string DatabaseName => change.DatabaseName; + public string SettingName => change.SettingName; + public string? OldValue => change.OldValue; + public string? NewValue => change.NewValue; + + public string ChangeTimeDisplay => ViewerTimeHelper.ForDisplay(change.ChangeTime).ToString("yyyy-MM-dd HH:mm:ss"); + public string ChangeDescription => change.ChangeDescription; +} + +/// <summary>One trace-flag change (Trace Flag Changes grid). Wraps +/// <see cref="ConfigChangeDiff.TraceFlagChange"/>; only <see cref="ChangeTimeDisplay"/> is viewer-specific.</summary> +public sealed class TraceFlagChangeRow(ConfigChangeDiff.TraceFlagChange change) +{ + public DateTime ChangeTime => change.ChangeTime; + public int TraceFlag => change.TraceFlag; + public bool? PreviousStatus => change.PreviousStatus; + public bool? NewStatus => change.NewStatus; + public bool? IsGlobal => change.IsGlobal; + public bool? IsSession => change.IsSession; + + /// <summary>enabled / disabled / modified (the set-diff outcome).</summary> + public string ChangeType => change.ChangeType; + + public string ChangeTimeDisplay => ViewerTimeHelper.ForDisplay(change.ChangeTime).ToString("yyyy-MM-dd HH:mm:ss"); + public string PreviousStatusDisplay => change.PreviousStatusDisplay; + public string NewStatusDisplay => change.NewStatusDisplay; + public string GlobalDisplay => change.GlobalDisplay; + public string SessionDisplay => change.SessionDisplay; + public string Scope => change.Scope; + public string ChangeDescription => change.ChangeDescription; +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ControlCommands.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ControlCommands.cs new file mode 100644 index 000000000..a89e204f8 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ControlCommands.cs @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The viewer's typed wrappers over the Stage-2 command plane (<see cref="EnqueueCommandAsync"/> / +/// <see cref="RunCommandAsync"/>) for the operator's imperative actions: <c>pause</c>/<c>resume</c> the whole +/// service (the Settings window's Data Collection toggle), <c>snapshot_now</c> for one server (the "Latest +/// Snapshot" button), <c>analyze_now</c> for one server (the Recommendations "Generate now" button), and +/// <c>purge_now</c> fleet-wide (the Collection Health "Purge Now" button). The +/// command TYPES match <c>DarlingCommandExecutor.ResolvePlan</c> exactly so the two ends agree; Darling.Tests +/// pin the constants against the executor's cases. +/// +/// <para>Each is a write (enqueue), so a read-only <c>viewer</c> seat throws +/// <see cref="ViewerReadOnlyException"/> from <see cref="EnqueueCommandAsync"/> — correct: a read-only seat +/// can neither pause the service nor command a capture. Pause/resume only WRITE <c>config_service.paused</c> +/// (the service's reconcile applies it) so a 45s wait is ample; <c>snapshot_now</c>/<c>analyze_now</c> drive +/// the running collection/analysis loop for a server and can run a full sweep, so they use a longer +/// <see cref="ImperativeCommandTimeout"/>. <c>snapshot_now</c> runs EVEN WHILE PAUSED (explicit operator +/// intent — the service honors it), so the button stays enabled while paused.</para> +/// </summary> +public sealed partial class ViewerDataService +{ + /// <summary>The control-command types the viewer enqueues (must match the service executor's cases).</summary> + public const string CommandPause = "pause"; + public const string CommandResume = "resume"; + public const string CommandSnapshotNow = "snapshot_now"; + public const string CommandAnalyzeNow = "analyze_now"; + public const string CommandPurgeNow = "purge_now"; + + /// <summary>A full-server snapshot or analysis pass can take longer than a config write's <see + /// cref="DefaultCommandTimeout"/>, so the imperative per-server commands wait up to three minutes for the + /// running loop to finish before the UI reports "still running / try again".</summary> + public static readonly TimeSpan ImperativeCommandTimeout = TimeSpan.FromMinutes(3); + + /// <summary>Enqueues a <c>pause</c> (service stops collecting on its next reconcile) and waits for the + /// terminal result. Returns null on timeout. Read-only seats throw <see cref="ViewerReadOnlyException"/>.</summary> + public Task<CommandResult?> PauseServiceAsync(CancellationToken cancellationToken = default) => + RunCommandAsync(CommandPause, targetServerId: null, argsJson: null, RequestedBy(), timeout: null, cancellationToken); + + /// <summary>Enqueues a <c>resume</c> (service resumes collecting) and waits for the terminal result.</summary> + public Task<CommandResult?> ResumeServiceAsync(CancellationToken cancellationToken = default) => + RunCommandAsync(CommandResume, targetServerId: null, argsJson: null, RequestedBy(), timeout: null, cancellationToken); + + /// <summary>Enqueues a <c>snapshot_now</c> for one server (run its collectors now, even while paused) and + /// waits for the terminal result. Returns null on timeout.</summary> + public Task<CommandResult?> RequestSnapshotNowAsync(int serverId, CancellationToken cancellationToken = default) => + RunCommandAsync(CommandSnapshotNow, serverId, argsJson: null, RequestedBy(), ImperativeCommandTimeout, cancellationToken); + + /// <summary>Enqueues an <c>analyze_now</c> for one server (force an analysis pass now) and waits for the + /// terminal result. Returns null on timeout.</summary> + public Task<CommandResult?> RequestAnalyzeNowAsync(int serverId, CancellationToken cancellationToken = default) => + RunCommandAsync(CommandAnalyzeNow, serverId, argsJson: null, RequestedBy(), ImperativeCommandTimeout, cancellationToken); + + /// <summary>Enqueues a <c>purge_now</c> (run the retention purge over the shared store now) and waits for + /// the terminal result. Fleet-wide over the shared tables, so NO server id. <paramref + /// name="customRetentionDays"/> (when set) purges every collector to that horizon instead of the configured + /// fleet retention; null = the configured horizons. Returns null on timeout. Read-only seats throw + /// <see cref="ViewerReadOnlyException"/> (a purge is a config-write command, like Pause).</summary> + public Task<CommandResult?> RequestPurgeNowAsync(int? customRetentionDays = null, CancellationToken cancellationToken = default) + { + var argsJson = customRetentionDays is int days ? BuildPurgeNowArgs(days) : null; + return RunCommandAsync(CommandPurgeNow, targetServerId: null, argsJson, RequestedBy(), ImperativeCommandTimeout, cancellationToken); + } + + /// <summary> + /// Builds the <c>purge_now</c> <c>args_json</c> carrying a custom retention horizon; the service executor + /// reads the <c>retention_days</c> key, so the two ends must agree on it. Pure; pinned by Darling.Tests. + /// </summary> + public static string BuildPurgeNowArgs(int customRetentionDays) => + JsonSerializer.Serialize(new { retention_days = customRetentionDays }); + + /// <summary>The audit label stamped on <c>config_command.requested_by</c> — the interactive user + a viewer tag.</summary> + private static string RequestedBy() + { + try + { + return $"{Environment.UserName} (viewer)"; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return "viewer"; + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Cpu.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Cpu.cs new file mode 100644 index 000000000..c79c1d174 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Cpu.cs @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// One RAW CPU ring-buffer sample: a sample time plus the SQL Server and other-process CPU percentages. +/// This raw-per-sample read (up to ~60 samples per collection) feeds BOTH the CPU tab's scatter chart +/// (SQL Server vs other processes) and — since W1d — the Overview's CPU lane (SQL vs SQL+other Total), +/// mirroring Lite's CPU tab (<c>ServerTab.Charts.cs</c> <c>UpdateCpuChart</c>) and Lite's Overview lanes, +/// both over <c>LocalDataService.GetCpuUtilizationAsync</c>. Both CPU columns are <c>integer</c> in the +/// store, read as int and cast to double at plot time, byte-for-byte with Lite's chart body. +/// <c>SampleTime</c> is the store's server-LOCAL <c>sample_time</c> de-skewed to naive UTC by the read +/// (see <see cref="CpuUtilizationSql"/>, #1262), so both consumers plot it through +/// <see cref="ViewerTimeHelper.ForDisplay"/> like every other Darling series. +/// </summary> +public sealed record CpuUtilizationSample(DateTime SampleTime, int SqlServerCpu, int OtherProcessCpu); + +public sealed partial class ViewerDataService +{ + /// <summary> + /// The raw per-sample CPU read: every ring-buffer sample since <paramref name="sinceUtc"/> (not an + /// average-per-collection roll-up), feeding both the CPU tab's scatter chart and — since W1d — the + /// Overview's CPU lane. The window filters on <c>collection_time</c> (the naive-UTC collection + /// prefix — the reliable clock every Darling read windows on); a NULL + /// <c>other_process_cpu_utilization</c> (SQL on Linux, #1048) reads as 0. + /// + /// <para><b>sample_time de-skew (#1262).</b> Unlike every other stored column, <c>sample_time</c> is + /// the MONITORED SERVER'S LOCAL wall clock (<c>SYSDATETIME()</c> on the server, minus each + /// ring-buffer sample's age), NOT naive UTC — so feeding it straight to <see cref="ViewerTimeHelper.ForDisplay"/> + /// (which assumes naive-UTC input) shifts the whole CPU series by the server's UTC offset relative + /// to every <c>collection_time</c>-based lane — a visible misalignment in the correlated Overview + /// (e.g. a Pacific-time server, offset -7/-8h, sits 7-8h off). + /// The viewer has no per-server timezone config the way Lite does + /// (<c>ServerTimeHelper.UtcOffsetMinutes</c>), so we recover the offset from the batch itself: all + /// rows of one collection share a single <c>collection_time</c> (the batch key — <c>collection_id</c> + /// is assigned per row, a distinct value per sample, NOT a batch id), and the NEWEST sample in a + /// batch is at most ~1 minute older than that collection instant. So + /// <c>MAX(sample_time) OVER (batch) - collection_time</c> is + /// the server's UTC offset plus that sub-minute anchor age; rounding to 15 minutes recovers the exact + /// whole/half/quarter-hour offset and absorbs the anchor age (and any few-second clock skew). + /// Subtracting that per-batch offset turns each local <c>sample_time</c> into true naive UTC, so + /// <see cref="ViewerTimeHelper.ForDisplay"/> then aligns the CPU series with every other lane. A UTC server yields + /// offset 0 — byte-identical to the pre-fix read. The per-batch derivation also handles a DST + /// transition inside the window (each batch recovers its own offset). Reads the base + /// <c>cpu_utilization_stats</c> table. $1 server_id, $2 window start (naive UTC).</para> + /// </summary> + public const string CpuUtilizationSql = """ + SELECT + /* De-skew sample_time (the monitored server's LOCAL wall clock) to naive UTC by subtracting + the per-batch UTC offset = round(MAX(sample_time) over the batch - collection_time) to the + nearest 15 minutes. collection_time (shared by every row of a collection) is the batch key; + collection_id is assigned per row (a distinct value per sample). A UTC server yields offset + 0. See the C# summary above for the full derivation (#1262). */ + sample_time + - INTERVAL '15 minutes' + * ROUND(EXTRACT(EPOCH FROM ( + MAX(sample_time) OVER (PARTITION BY server_id, collection_time) - collection_time + )) / 900.0)::double precision AS sample_time, + sqlserver_cpu_utilization, + other_process_cpu_utilization + FROM cpu_utilization_stats + WHERE server_id = $1 + AND collection_time >= $2 + ORDER BY sample_time + """; + + /// <summary> + /// Raw CPU samples for one server since <paramref name="sinceUtc"/>, time-ordered — one point per + /// ring-buffer sample, feeding both the CPU tab's scatter chart and the Overview's CPU lane. Each + /// sample's <c>SampleTime</c> is de-skewed to naive UTC in the read (see <see cref="CpuUtilizationSql"/>, + /// #1262), so both consumers can plot it through <see cref="ViewerTimeHelper.ForDisplay"/> unchanged. + /// </summary> + public async Task<List<CpuUtilizationSample>> GetCpuUtilizationAsync(int serverId, DateTime sinceUtc, CancellationToken cancellationToken = default) + { + var samples = new List<CpuUtilizationSample>(); + + await using var command = _dataSource.CreateCommand(CpuUtilizationSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(sinceUtc, DateTimeKind.Unspecified), + }); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + samples.Add(new CpuUtilizationSample( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : reader.GetInt32(1), + /* NULL other-process CPU (SQL on Linux) reads as 0, like Lite's CPU chart. */ + reader.IsDBNull(2) ? 0 : reader.GetInt32(2))); + } + + return samples; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CpuScheduler.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CpuScheduler.cs new file mode 100644 index 000000000..a86206d62 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CpuScheduler.cs @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary>One point on the CPU-scheduler pressure trend: the runnable / blocked / queued task +/// counts at a collection instant. A point-in-time snapshot collector (one row per collection), so +/// these plot directly with no delta math.</summary> +public sealed record CpuSchedulerTrendPoint( + DateTime CollectionTime, + int RunnableTasks, + int BlockedTasks, + int QueuedRequests) : ICpuSchedulerTrendPoint; + +/// <summary> +/// The most recent CPU-scheduler snapshot in the window — every column the cpu_scheduler_stats +/// collector captures (scheduler / worker / NUMA / OS-memory pressure), feeding the CPU Scheduler +/// sub-tab's latest-snapshot metric grid and its warning highlights. +/// </summary> +public sealed record CpuSchedulerSnapshot( + DateTime CollectionTime, + int MaxWorkersCount, + int SchedulerCount, + int CpuCount, + int TotalRunnableTasksCount, + long TotalWorkQueueCount, + int TotalCurrentWorkersCount, + decimal AvgRunnableTasksCount, + int TotalActiveRequestCount, + int TotalQueuedRequestCount, + int TotalBlockedTaskCount, + long TotalActiveParallelThreadCount, + int? RunnableRequestCount, + int? TotalRequestCount, + decimal? RunnablePercent, + bool WorkerThreadExhaustionWarning, + bool RunnableTasksWarning, + bool BlockedTasksWarning, + bool QueuedRequestsWarning, + long TotalPhysicalMemoryKb, + long AvailablePhysicalMemoryKb, + string? SystemMemoryStateDesc, + bool PhysicalMemoryPressureWarning, + int TotalNodeCount, + int NodesOnlineCount, + int OfflineCpuCount, + bool OfflineCpuWarning) : ICpuSchedulerSnapshot +{ + /* The two averaged/percentage columns come off Postgres NUMERIC as decimal; the shared + ICpuSchedulerSnapshot surfaces them as double (Lite's native type). They feed only the metric grid's + F2 display strings in CpuSchedulerMetrics.BuildMetrics — never the pressure banding — so this widening + cast is display-only and not observably different for these small values. */ + double ICpuSchedulerSnapshot.AvgRunnableTasksCount => (double)AvgRunnableTasksCount; + + double? ICpuSchedulerSnapshot.RunnablePercent => + RunnablePercent.HasValue ? (double)RunnablePercent.Value : (double?)null; +} + +public sealed partial class ViewerDataService +{ + /// <summary> + /// The CPU Scheduler pressure trend read: the runnable / blocked / queued task counts per collection + /// over the window, from the <c>v_cpu_scheduler_stats</c> passthrough view. A point-in-time collector + /// (single row per collection), so the three counts plot directly — no delta normalization. + /// $1 server_id, $2 window start, $3 window end (all naive UTC). + /// </summary> + public const string CpuSchedulerTrendSql = """ + SELECT + collection_time, + total_runnable_tasks_count, + total_blocked_task_count, + total_queued_request_count + FROM v_cpu_scheduler_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + ORDER BY collection_time + """; + + /// <summary> + /// The CPU Scheduler latest-snapshot read: the single most recent cpu_scheduler_stats row in the + /// window (all pressure columns), for the metric grid. $1 server_id, $2 start, $3 end (naive UTC). + /// </summary> + public const string CpuSchedulerSnapshotSql = """ + SELECT + collection_time, + max_workers_count, + scheduler_count, + cpu_count, + total_runnable_tasks_count, + total_work_queue_count, + total_current_workers_count, + avg_runnable_tasks_count, + total_active_request_count, + total_queued_request_count, + total_blocked_task_count, + total_active_parallel_thread_count, + runnable_request_count, + total_request_count, + runnable_percent, + worker_thread_exhaustion_warning, + runnable_tasks_warning, + blocked_tasks_warning, + queued_requests_warning, + total_physical_memory_kb, + available_physical_memory_kb, + system_memory_state_desc, + physical_memory_pressure_warning, + total_node_count, + nodes_online_count, + offline_cpu_count, + offline_cpu_warning + FROM v_cpu_scheduler_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + ORDER BY collection_time DESC + LIMIT 1 + """; + + /// <summary>The scheduler pressure trend (runnable/blocked/queued counts) over the window.</summary> + public async Task<List<CpuSchedulerTrendPoint>> GetCpuSchedulerTrendAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var result = new List<CpuSchedulerTrendPoint>(); + + await using var command = _dataSource.CreateCommand(CpuSchedulerTrendSql); + AddWindowParameters(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + result.Add(new CpuSchedulerTrendPoint( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : reader.GetInt32(1), + reader.IsDBNull(2) ? 0 : reader.GetInt32(2), + reader.IsDBNull(3) ? 0 : reader.GetInt32(3))); + } + + return result; + } + + /// <summary>The most recent CPU-scheduler snapshot in the window, or null when none was collected.</summary> + public async Task<CpuSchedulerSnapshot?> GetCpuSchedulerSnapshotAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(CpuSchedulerSnapshotSql); + AddWindowParameters(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + { + return null; + } + + return new CpuSchedulerSnapshot( + CollectionTime: reader.GetDateTime(0), + MaxWorkersCount: reader.IsDBNull(1) ? 0 : reader.GetInt32(1), + SchedulerCount: reader.IsDBNull(2) ? 0 : reader.GetInt32(2), + CpuCount: reader.IsDBNull(3) ? 0 : reader.GetInt32(3), + TotalRunnableTasksCount: reader.IsDBNull(4) ? 0 : reader.GetInt32(4), + TotalWorkQueueCount: reader.IsDBNull(5) ? 0 : reader.GetInt64(5), + TotalCurrentWorkersCount: reader.IsDBNull(6) ? 0 : reader.GetInt32(6), + AvgRunnableTasksCount: reader.IsDBNull(7) ? 0m : reader.GetDecimal(7), + TotalActiveRequestCount: reader.IsDBNull(8) ? 0 : reader.GetInt32(8), + TotalQueuedRequestCount: reader.IsDBNull(9) ? 0 : reader.GetInt32(9), + TotalBlockedTaskCount: reader.IsDBNull(10) ? 0 : reader.GetInt32(10), + TotalActiveParallelThreadCount: reader.IsDBNull(11) ? 0 : reader.GetInt64(11), + RunnableRequestCount: reader.IsDBNull(12) ? null : reader.GetInt32(12), + TotalRequestCount: reader.IsDBNull(13) ? null : reader.GetInt32(13), + RunnablePercent: reader.IsDBNull(14) ? null : reader.GetDecimal(14), + WorkerThreadExhaustionWarning: !reader.IsDBNull(15) && reader.GetBoolean(15), + RunnableTasksWarning: !reader.IsDBNull(16) && reader.GetBoolean(16), + BlockedTasksWarning: !reader.IsDBNull(17) && reader.GetBoolean(17), + QueuedRequestsWarning: !reader.IsDBNull(18) && reader.GetBoolean(18), + TotalPhysicalMemoryKb: reader.IsDBNull(19) ? 0 : reader.GetInt64(19), + AvailablePhysicalMemoryKb: reader.IsDBNull(20) ? 0 : reader.GetInt64(20), + SystemMemoryStateDesc: reader.IsDBNull(21) ? null : reader.GetString(21), + PhysicalMemoryPressureWarning: !reader.IsDBNull(22) && reader.GetBoolean(22), + TotalNodeCount: reader.IsDBNull(23) ? 0 : reader.GetInt32(23), + NodesOnlineCount: reader.IsDBNull(24) ? 0 : reader.GetInt32(24), + OfflineCpuCount: reader.IsDBNull(25) ? 0 : reader.GetInt32(25), + OfflineCpuWarning: !reader.IsDBNull(26) && reader.GetBoolean(26)); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DailySummary.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DailySummary.cs new file mode 100644 index 000000000..89f661dfc --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DailySummary.cs @@ -0,0 +1,275 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Performance Calendar / Daily Summary aggregate, grouped one row per day over a half-open +/// [fromDate, toDate) window and run on the <c>v_wait_stats</c> / <c>v_query_stats</c> / <c>v_deadlocks</c> / +/// <c>v_blocked_process_reports</c> (with the <c>v_dmv_blocking_snapshots</c> fallback) / +/// <c>v_cpu_utilization_stats</c> / <c>v_collection_log</c> / <c>v_memory_pressure_events</c> passthrough +/// views plus <c>config_alert_log</c>. One row is returned for every day that had any collection; days with +/// none are absent (the calendar renders them No-Data). Each row's composite band is computed by the shared +/// <see cref="DailyHealthBandCalculator"/> so it bands identically to Lite. This is the single source of +/// truth for the daily aggregate; <see cref="GetDailySummaryAsync"/> (one day) delegates here. +/// </summary> +public sealed partial class ViewerDataService +{ + /// <summary> + /// Grouped per-day daily-summary aggregate. $1 server_id, $2 range start, $3 range end (naive UTC, + /// half-open <c>[start, end)</c>). Postgres dialect: <c>date_trunc('day', ...)</c> day bucketing, + /// <c>(array_agg(wait_type ORDER BY ...))[1]</c> for the per-day top wait, <c>FILTER</c> conditional + /// counts, and a day spine (UNION of every source's days) LEFT JOINed so a quiet-but-collected day still + /// appears (Healthy, not No-Data). The high-CPU rule (total host CPU = SQL + other-process ≥ 80, Linux + /// NULL-other-process fallback) mirrors the alert engine and the Overview headline. + /// </summary> + public const string DailySummaryRangeSql = """ + WITH wait_per_type AS ( + SELECT date_trunc('day', collection_time) AS d, wait_type, SUM(delta_wait_time_ms) AS ms + FROM v_wait_stats + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 AND delta_wait_time_ms > 0 + GROUP BY 1, 2 + ), + wait_totals AS ( + SELECT d, SUM(ms) / 1000.0 AS total_wait_sec + FROM wait_per_type + GROUP BY d + ), + wait_top AS ( + /* Per-day top wait type = the wait with the most delta time that day (Postgres DISTINCT ON). */ + SELECT DISTINCT ON (d) d, wait_type AS top_wait_type + FROM wait_per_type + ORDER BY d, ms DESC + ), + waits AS ( + SELECT t.d, t.total_wait_sec, tp.top_wait_type + FROM wait_totals t + LEFT JOIN wait_top tp ON tp.d = t.d + ), + queries AS ( + SELECT date_trunc('day', collection_time) AS d, COUNT(DISTINCT query_hash) AS c + FROM v_query_stats + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + GROUP BY 1 + ), + deadlocks AS ( + SELECT date_trunc('day', collection_time) AS d, COUNT(*) AS c + FROM v_deadlocks + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + GROUP BY 1 + ), + bpr AS ( + SELECT date_trunc('day', collection_time) AS d, COUNT(*) AS c, MAX(wait_time_ms) AS max_wait_ms + FROM v_blocked_process_reports + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + GROUP BY 1 + ), + dmv AS ( + SELECT date_trunc('day', collection_time) AS d, COUNT(*) AS c, MAX(wait_time_ms) AS max_wait_ms + FROM v_dmv_blocking_snapshots + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + GROUP BY 1 + ), + cpu AS ( + /* Total host CPU = SQL + other-process (NULL on Linux -> 0), matching the alert engine and the + Overview headline; sustained >= 80 samples drive the day's band. */ + SELECT date_trunc('day', collection_time) AS d, + COUNT(*) FILTER (WHERE (sqlserver_cpu_utilization + COALESCE(other_process_cpu_utilization, 0)) >= 80) AS c + FROM v_cpu_utilization_stats + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + GROUP BY 1 + ), + coll AS ( + /* Any run (all statuses) marks the day as collected -> it appears even if every metric is quiet + (a quiet monitored day is Healthy/green, not No-Data/grey). errs feeds the Critical band. */ + SELECT date_trunc('day', collection_time) AS d, + COUNT(*) AS runs, + COUNT(*) FILTER (WHERE status = 'ERROR') AS errs + FROM v_collection_log + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + GROUP BY 1 + ), + mem AS ( + SELECT date_trunc('day', collection_time) AS d, + COUNT(*) FILTER (WHERE memory_indicators_process >= 2 OR memory_indicators_system >= 2) AS pressure, + COUNT(*) FILTER (WHERE memory_indicators_process >= 3) AS critical + FROM v_memory_pressure_events + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + GROUP BY 1 + ), + alerts AS ( + /* Actionable alerts only: exclude dismissed rows and resolution/good-news notices (Cleared / + Resolved / Restored), mirroring AlertMetricClassifier.IsResolution. */ + SELECT date_trunc('day', alert_time) AS d, COUNT(*) AS c + FROM config_alert_log + WHERE server_id = $1 AND alert_time >= $2 AND alert_time < $3 + AND dismissed = FALSE + AND metric_name NOT LIKE '%Cleared%' + AND metric_name NOT LIKE '%Resolved%' + AND metric_name NOT LIKE '%Restored%' + GROUP BY 1 + ), + day_spine AS ( + SELECT d FROM waits + UNION SELECT d FROM queries + UNION SELECT d FROM deadlocks + UNION SELECT d FROM bpr + UNION SELECT d FROM dmv + UNION SELECT d FROM cpu + UNION SELECT d FROM coll + UNION SELECT d FROM mem + UNION SELECT d FROM alerts + ) + SELECT + s.d AS day, + COALESCE(w.total_wait_sec, 0) AS total_wait_sec, + w.top_wait_type, + COALESCE(q.c, 0) AS unique_queries, + COALESCE(dl.c, 0) AS deadlock_count, + COALESCE(NULLIF(b.c, 0), dm.c, 0) AS blocking_events, + COALESCE(cp.c, 0) AS high_cpu_events, + COALESCE(cl.errs, 0) AS collection_errors, + COALESCE(m.pressure, 0) AS memory_pressure_events, + COALESCE(m.critical, 0) AS memory_critical_events, + COALESCE(al.c, 0) AS alert_count, + /* Peak block wait (ms) from the SAME source the blocking count came from (BPR preferred, DMV-snapshot + fallback), so the day-detail blocking reason ('N blocking events (peak block X)') reconciles with + the count. 0 when the blocking came from a source without a wait time. */ + COALESCE(CASE WHEN COALESCE(b.c, 0) > 0 THEN b.max_wait_ms ELSE dm.max_wait_ms END, 0) AS peak_block_wait_ms + FROM day_spine s + LEFT JOIN waits w ON w.d = s.d + LEFT JOIN queries q ON q.d = s.d + LEFT JOIN deadlocks dl ON dl.d = s.d + LEFT JOIN bpr b ON b.d = s.d + LEFT JOIN dmv dm ON dm.d = s.d + LEFT JOIN cpu cp ON cp.d = s.d + LEFT JOIN coll cl ON cl.d = s.d + LEFT JOIN mem m ON m.d = s.d + LEFT JOIN alerts al ON al.d = s.d + ORDER BY s.d + """; + + /// <summary> + /// Returns one <see cref="DailySummaryRow"/> per collected day in the half-open [fromDate, toDate) + /// window. Powers the Performance Calendar month grid. + /// </summary> + public async Task<List<DailySummaryRow>> GetDailySummaryRangeAsync( + int serverId, DateTime fromDate, DateTime toDate, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(DailySummaryRangeSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(fromDate.Date, DateTimeKind.Unspecified) }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(toDate.Date, DateTimeKind.Unspecified) }); + + var results = new List<DailySummaryRow>(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + results.Add(ReadDailySummaryRow(reader)); + } + + return results; + } + + /// <summary> + /// Daily summary for one server on a specific date (or today, UTC, when <paramref name="summaryDate"/> + /// is null). Delegates to the range query for a single day; returns a No-Data row when the day had no + /// collection. + /// </summary> + public async Task<DailySummaryRow?> GetDailySummaryAsync(int serverId, DateTime? summaryDate = null, CancellationToken cancellationToken = default) + { + var targetDate = summaryDate?.Date ?? DateTime.UtcNow.Date; + var rows = await GetDailySummaryRangeAsync(serverId, targetDate, targetDate.AddDays(1), cancellationToken); + return rows.Count > 0 + ? rows[0] + : new DailySummaryRow { SummaryDate = targetDate, HasData = false, HealthBand = DailyHealthBand.NoData }; + } + + private static DailySummaryRow ReadDailySummaryRow(DbDataReader reader) + { + var row = new DailySummaryRow + { + SummaryDate = reader.IsDBNull(0) ? DateTime.MinValue : Convert.ToDateTime(reader.GetValue(0)), + TotalWaitTimeSec = reader.IsDBNull(1) ? 0m : Convert.ToDecimal(reader.GetValue(1)), + TopWaitType = reader.IsDBNull(2) ? "" : reader.GetString(2), + UniqueQueries = reader.IsDBNull(3) ? 0L : Convert.ToInt64(reader.GetValue(3)), + DeadlockCount = reader.IsDBNull(4) ? 0L : Convert.ToInt64(reader.GetValue(4)), + BlockingEvents = reader.IsDBNull(5) ? 0L : Convert.ToInt64(reader.GetValue(5)), + HighCpuEvents = reader.IsDBNull(6) ? 0L : Convert.ToInt64(reader.GetValue(6)), + CollectionErrors = reader.IsDBNull(7) ? 0L : Convert.ToInt64(reader.GetValue(7)), + MemoryPressureEvents = reader.IsDBNull(8) ? 0L : Convert.ToInt64(reader.GetValue(8)), + MemoryCriticalEvents = reader.IsDBNull(9) ? 0L : Convert.ToInt64(reader.GetValue(9)), + AlertCount = reader.IsDBNull(10) ? 0L : Convert.ToInt64(reader.GetValue(10)), + MaxBlockDurationMs = reader.IsDBNull(11) ? 0L : Convert.ToInt64(reader.GetValue(11)), + HasData = true, + }; + row.HealthBand = DailyHealthBandCalculator.Classify(row.ToSignals()); + return row; + } +} + +/// <summary> +/// The Daily Summary grid row, structurally identical to Lite's <c>DailySummaryRow</c>. The composite +/// <see cref="HealthBand"/> is computed by the shared <see cref="DailyHealthBandCalculator"/>. +/// </summary> +public class DailySummaryRow +{ + public DateTime SummaryDate { get; set; } + public decimal TotalWaitTimeSec { get; set; } + public string TopWaitType { get; set; } = ""; + public long UniqueQueries { get; set; } + public long DeadlockCount { get; set; } + public long BlockingEvents { get; set; } + public long HighCpuEvents { get; set; } + public long MemoryPressureEvents { get; set; } + public long MemoryCriticalEvents { get; set; } + public long CollectionErrors { get; set; } + public long AlertCount { get; set; } + + /// <summary>The day's peak/max block wait in ms (0 when no blocking, or blocking from a source without a + /// wait time). Surfaced on the day-detail panel's blocking reason.</summary> + public long MaxBlockDurationMs { get; set; } + + /// <summary>True when the day had any collection. False renders the calendar cell as No-Data (grey).</summary> + public bool HasData { get; set; } + + /// <summary>The composite health band that colors this day's calendar cell.</summary> + public DailyHealthBand HealthBand { get; set; } = DailyHealthBand.NoData; + + /// <summary>Human label for the band ("Healthy" / "Warning" / "Critical" / "No Data"), shown in the day detail.</summary> + public string OverallHealth => DailyHealthBandCalculator.Label(HealthBand); + + public string SummaryDateFormatted => SummaryDate.ToString("yyyy-MM-dd"); + public string TotalWaitFormatted => TotalWaitTimeSec < 1000 + ? $"{TotalWaitTimeSec:N1} s" + : $"{TotalWaitTimeSec / 60:N1} min"; + + /// <summary>Multi-line hover text summarizing the day's signals, for the calendar cell tooltip.</summary> + public string SignalsTooltip => DailyHealthBandCalculator.Describe(ToSignals()); + + /// <summary>Projects this row's counts into the shared banding input.</summary> + public DailyHealthSignals ToSignals() => new() + { + HasData = HasData, + Deadlocks = DeadlockCount, + CollectionErrors = CollectionErrors, + HighCpuEvents = HighCpuEvents, + BlockingEvents = BlockingEvents, + MemoryPressureEvents = MemoryPressureEvents, + MemoryCriticalEvents = MemoryCriticalEvents, + AlertCount = AlertCount, + }; +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DatabaseFilter.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DatabaseFilter.cs new file mode 100644 index 000000000..e7c939565 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DatabaseFilter.cs @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using Npgsql; +using NpgsqlTypes; + +namespace PerformanceMonitor.Darling.Viewer; + +public sealed partial class ViewerDataService +{ + /// <summary> + /// #1319: the global database filter parameter for a database-scoped Postgres reader. Each such + /// reader's SQL bakes in an always-present predicate of the form + /// <c>AND ($n::text[] IS NULL OR database_name = ANY($n))</c> and ALWAYS binds this parameter, so a + /// null/empty selection binds SQL NULL and the guard short-circuits to unfiltered (today's behavior). + /// A non-empty selection binds a <c>text[]</c> and restricts to those databases. Mirrors the array + /// binding in <see cref="ViewerDataService.MonitoredServers"/>' <c>AddTextArray</c>. + /// </summary> + internal static NpgsqlParameter DatabaseFilterParameter(IReadOnlyList<string>? databases) => + new() + { + NpgsqlDbType = NpgsqlDbType.Array | NpgsqlDbType.Text, + Value = databases is { Count: > 0 } ? databases.ToArray() : (object)DBNull.Value, + }; + + /// <summary> + /// The always-present SQL predicate for the global database filter, spliced into a database-scoped + /// WHERE clause. <paramref name="column"/> is the (optionally qualified) database-name column and + /// <paramref name="paramIndex"/> is the positional index bound by <see cref="DatabaseFilterParameter"/>. + /// </summary> + internal static string DatabaseFilterClause(string column, int paramIndex) => + $" AND (${paramIndex}::text[] IS NULL OR {column} = ANY(${paramIndex}))"; +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Deadlock.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Deadlock.cs new file mode 100644 index 000000000..db1c5df0b --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Deadlock.cs @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Alerting; +using PerformanceMonitor.Common; +using static PerformanceMonitor.Common.DeadlockGraphProcessParser; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The viewer's deadlock grid row (W1e). The alert-consumed members (victim process/SQL, graph XML) +/// live on the shared <see cref="DeadlockAlertRow"/> base — the same shape Lite's <c>DeadlockRow</c> +/// derives from — so the store reads flow into the shared surfaces without a mapping copy; only the +/// grid-fetch extras stay here. Mirror of Lite's <c>DeadlockRow</c>. +/// </summary> +public sealed class ViewerDeadlockRow : DeadlockAlertRow +{ + public DateTime CollectionTime { get; set; } + public DateTime? DeadlockTime { get; set; } + + /// <summary> + /// The deadlock's BEST-EFFORT victim plan (deadlocks.victim_query_plan_xml, #1368 / V7 — resolved at + /// collection time from the victim's sql_handle, only when the host sets CapturePlanXml; often NULL, + /// always NULL under Lite). Threaded onto every <see cref="DeadlockProcessDetail"/> parsed from this row. + /// </summary> + public string? VictimQueryPlanXml { get; set; } +} + +/// <summary> +/// One parsed process inside a deadlock graph — the Deadlocks grid binds a +/// <c>List<DeadlockProcessDetail></c> (one row per process, parsed from each +/// <see cref="ViewerDeadlockRow"/>'s graph XML). The raw parsed fields and the sp_BlitzLock-style graph +/// walk now live once on the shared <see cref="DeadlockProcessInfo"/> / <see cref="DeadlockGraphProcessParser"/> +/// (Common), de-duplicated with Lite; the viewer adds only its machine-local display getters +/// (<see cref="ViewerTimeHelper.ForDisplay"/>, where Lite uses its per-server <c>ServerTimeHelper</c>) and the +/// per-row "View Victim Plan" gate. The walk is CPU-bound XML work, so callers run +/// <see cref="ParseFromRows"/> off the UI thread. +/// </summary> +public sealed class DeadlockProcessDetail : DeadlockProcessInfo +{ + public bool HasVictimQueryPlan => !string.IsNullOrEmpty(VictimQueryPlanXml); + + /// <summary> + /// Whether "View Victim Plan" should be active for THIS row. The victim plan is a single per-deadlock plan + /// copied onto every process row, so gating only on <see cref="HasVictimQueryPlan"/> lit it up on the + /// deadlocker rows too (where it reads as "this process's plan", which it isn't). Requires the row to be the + /// victim AND to carry a plan, so the item is active only on the victim row. + /// </summary> + public bool CanViewVictimPlan => IsVictim && HasVictimQueryPlan; + + public string DeadlockTimeLocal + => DeadlockTime is { } t ? ViewerTimeHelper.ForDisplay(t).ToString("yyyy-MM-dd HH:mm:ss") : ""; + public string VictimDisplay => IsVictim ? "Victim" : ""; + public string WaitTimeFormatted => WaitTime > 0 ? $"{WaitTime:N0} ms" : ""; + public string LastTranStartedLocal + => LastTranStarted is { } t ? ViewerTimeHelper.ForDisplay(t).ToString("yyyy-MM-dd HH:mm:ss") : ""; + + /// <summary> + /// Parses a list of <see cref="ViewerDeadlockRow"/> into per-process detail rows via the shared + /// <see cref="DeadlockGraphProcessParser"/> (the sp_BlitzLock-style graph walk, de-duplicated with Lite). + /// Each row's best-effort victim plan (#1368 / V7) is threaded onto every process it produces. + /// </summary> + public static List<DeadlockProcessDetail> ParseFromRows(List<ViewerDeadlockRow> rows) + => DeadlockGraphProcessParser.Parse<DeadlockProcessDetail>( + rows.Select(r => new DeadlockGraphInput(r.DeadlockGraphXml, r.DeadlockTime, r.VictimSqlText, r.VictimQueryPlanXml))).ToList(); +} + +public sealed partial class ViewerDataService +{ + /// <summary> + /// Recent deadlock events for one server — Lite's <c>GetRecentDeadlocksAsync</c> ported to Postgres. + /// Windows on the collection prefix, orders newest deadlock first, caps at 50 (Lite's cap). Carries the + /// graph XML so <see cref="DeadlockProcessDetail.ParseFromRows"/> and the deadlock-graph viewer work + /// without a second fetch, and the BEST-EFFORT victim plan (#1368 / V7) the grid's "View Victim Plan" + /// item opens. + /// + /// <para>Reads the BASE <c>deadlocks</c> table, not <c>v_deadlocks</c> (deliberate divergence from the + /// other deadlock reads' v_-view twinning): the V7 <c>victim_query_plan_xml</c> column is projected + /// reliably only by the base table. Postgres pins <c>SELECT *</c> in a view to the columns present at + /// view-creation (V4, before V7), and V8 only <c>ALTER VIEW … SET SCHEMA</c>s it (never re-creates it), + /// so an upgraded store's view would not expose the plan column and this read would fail. The column is + /// Darling-only (Lite's DuckDB view has none), so this read was never twinnable with Lite once it + /// carries the plan.</para> + /// $1 server_id, $2 window start, $3 window end (naive UTC). + /// </summary> + public const string RecentDeadlocksSql = """ + SELECT + collection_time, + deadlock_time, + victim_process_id, + victim_sql_text, + deadlock_graph_xml, + victim_query_plan_xml + FROM deadlocks + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + ORDER BY deadlock_time DESC + LIMIT 50 + """; + + /// <summary>The raw deadlock rows for the window; the grid parses them into per-process detail rows.</summary> + public async Task<List<ViewerDeadlockRow>> GetRecentDeadlocksAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var rows = new List<ViewerDeadlockRow>(); + + await using var command = _dataSource.CreateCommand(RecentDeadlocksSql); + AddBlockingParameters(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new ViewerDeadlockRow + { + CollectionTime = reader.GetDateTime(0), + DeadlockTime = reader.IsDBNull(1) ? null : reader.GetDateTime(1), + VictimProcessId = reader.IsDBNull(2) ? "" : reader.GetString(2), + VictimSqlText = reader.IsDBNull(3) ? "" : reader.GetString(3), + DeadlockGraphXml = reader.IsDBNull(4) ? "" : reader.GetString(4), + VictimQueryPlanXml = reader.IsDBNull(5) ? null : reader.GetString(5), + }); + } + + return rows; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ExcludedDatabases.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ExcludedDatabases.cs new file mode 100644 index 000000000..b2b9ccd60 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ExcludedDatabases.cs @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// Store reads backing the Excluded Databases picker. The viewer never connects to a monitored SQL +/// Server (so it can't enumerate its LIVE database list the way Lite's dialog does), but the Darling +/// store already HAS the databases the service collected — <c>v_database_config</c> (the sys.databases +/// snapshot) and <c>v_database_size_stats</c> (per-file sizes). The picker offers those collected user +/// databases as checkboxes instead of a free-text editor, keeping a manual-add fallback for a +/// not-yet-collected database. System databases (master/model/msdb/tempdb) are always excluded from the +/// list — they mirror Lite's <c>database_id > 4</c> user-database filter and are never per-database +/// collector targets anyway. +/// </summary> +public sealed partial class ViewerDataService +{ + /// <summary> + /// DISTINCT user-database names the store has collected for one server, from either the database + /// config snapshot or the per-file size stats (UNION dedupes across both), system databases removed, + /// ordered by name. $1 server_id. + /// </summary> + public const string CollectedDatabaseNamesSql = """ + SELECT database_name + FROM ( + SELECT database_name FROM v_database_config WHERE server_id = $1 + UNION + SELECT database_name FROM v_database_size_stats WHERE server_id = $1 + ) AS d + WHERE database_name IS NOT NULL + AND database_name NOT IN ('master', 'model', 'msdb', 'tempdb') + ORDER BY database_name + """; + + /// <summary>The store's server_id for a registry server name, matched on server_name or display_name. $1 name.</summary> + public const string ServerIdByNameSql = """ + SELECT server_id + FROM servers + WHERE lower(server_name) = lower($1) + OR lower(display_name) = lower($1) + ORDER BY server_id + LIMIT 1 + """; + + /// <summary> + /// The distinct user databases the store has collected for one server (empty when nothing has been + /// collected yet). Feeds the Excluded Databases picker's checkbox list. + /// </summary> + public async Task<List<string>> GetCollectedDatabaseNamesAsync(int serverId, CancellationToken cancellationToken = default) + { + var names = new List<string>(); + + await using var command = _dataSource.CreateCommand(CollectedDatabaseNamesSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + names.Add(reader.GetString(0)); + } + + return names; + } + + /// <summary> + /// Resolves a viewer-registry server name to the store's server_id, or null when the service has not + /// registered a server by that name yet (nothing collected → the picker falls back to manual-only). + /// </summary> + public async Task<int?> GetServerIdByNameAsync(string serverName, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(serverName)) + { + return null; + } + + await using var command = _dataSource.CreateCommand(ServerIdByNameSql); + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = serverName }); + var result = await command.ExecuteScalarAsync(cancellationToken); + return result is null or DBNull ? null : Convert.ToInt32(result, System.Globalization.CultureInfo.InvariantCulture); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FileIo.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FileIo.cs new file mode 100644 index 000000000..05e2064ad --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FileIo.cs @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// One file's I/O-latency point for the File I/O tab's Latency sub-tab — a mirror of Lite's +/// <c>FileIoTrendPoint</c> (<c>LocalDataService.FileIo.cs</c>). The read averages stall-ms / op per +/// (collection_time, database_name, file_name); the chart groups on <c>database_name.file_name</c>, +/// sums read+write latency to rank the top files, and overlays the queued-I/O latencies as dashed +/// lines when any are present. +/// </summary> +public sealed record FileIoLatencyPoint( + DateTime CollectionTime, + string DatabaseName, + string FileName, + double AvgReadLatencyMs, + double AvgWriteLatencyMs, + double AvgQueuedReadLatencyMs, + double AvgQueuedWriteLatencyMs); + +/// <summary> +/// One file's throughput point (MB/s) for the File I/O tab's Throughput sub-tab — a mirror of Lite's +/// <c>FileIoThroughputPoint</c>. <c>file_label</c> is the pre-concatenated <c>database_name.file_name</c> +/// the read builds, and the per-second rates come from the delta bytes divided by the LAG-derived +/// collection interval. +/// </summary> +public sealed record FileIoThroughputPoint( + DateTime CollectionTime, + string FileLabel, + double ReadMbPerSec, + double WriteMbPerSec); + +public sealed partial class ViewerDataService +{ + /// <summary> + /// The File I/O latency read — Lite's <c>GetFileIoLatencyTrendAsync</c> ported to Postgres. Reads + /// <c>v_file_io_stats</c> (a <c>SELECT *</c> passthrough over <c>file_io_stats</c>) exactly as Lite's + /// view-based query does: a <c>top_files</c> CTE picks the 10 busiest (database, file) pairs by total + /// delta ops over the window, then per-collection average read/write (and queued read/write) latency + /// is computed as stall-ms / op with the delta-stall sums CAST to double precision before division. + /// The queued-stall columns are COALESCE'd to 0 so a server whose build predates them still reads 0. + /// $1 server_id, $2 window start, $3 window end (naive UTC). + /// </summary> + public const string FileIoLatencyTrendSql = """ + WITH top_files AS ( + SELECT database_name, file_name + FROM v_file_io_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND (delta_reads > 0 OR delta_writes > 0) + GROUP BY database_name, file_name + ORDER BY SUM(delta_reads + delta_writes) DESC + LIMIT 10 + ) + SELECT + f.collection_time, + f.database_name, + f.file_name, + CASE WHEN SUM(f.delta_reads) > 0 + THEN SUM(CAST(f.delta_stall_read_ms AS double precision)) / SUM(f.delta_reads) + ELSE 0 END AS avg_read_latency_ms, + CASE WHEN SUM(f.delta_writes) > 0 + THEN SUM(CAST(f.delta_stall_write_ms AS double precision)) / SUM(f.delta_writes) + ELSE 0 END AS avg_write_latency_ms, + CASE WHEN SUM(f.delta_reads) > 0 + THEN SUM(CAST(COALESCE(f.delta_stall_queued_read_ms, 0) AS double precision)) / SUM(f.delta_reads) + ELSE 0 END AS avg_queued_read_latency_ms, + CASE WHEN SUM(f.delta_writes) > 0 + THEN SUM(CAST(COALESCE(f.delta_stall_queued_write_ms, 0) AS double precision)) / SUM(f.delta_writes) + ELSE 0 END AS avg_queued_write_latency_ms + FROM v_file_io_stats f + JOIN top_files tf ON tf.database_name = f.database_name AND tf.file_name = f.file_name + WHERE f.server_id = $1 + AND f.collection_time >= $2 + AND f.collection_time <= $3 + GROUP BY f.collection_time, f.database_name, f.file_name + ORDER BY f.collection_time, f.database_name, f.file_name + """; + + /// <summary> + /// The File I/O throughput read — Lite's <c>GetFileIoThroughputTrendAsync</c> ported to Postgres + /// verbatim (LAG + EXTRACT(EPOCH ...) run identically on PG). A <c>top_files</c> CTE picks the 10 + /// busiest (database, file) pairs by total delta bytes, a <c>with_interval</c> CTE derives each + /// row's collection interval from the LAG of the prior collection_time (partitioned by + /// server/database/file), and the per-second MB rate is delta-bytes / interval-seconds / 1 MiB. + /// Rows with no prior sample (NULL interval) are dropped so the first point per file isn't plotted. + /// $1 server_id, $2 window start, $3 window end (naive UTC). + /// </summary> + public const string FileIoThroughputTrendSql = """ + WITH top_files AS ( + SELECT database_name, file_name + FROM v_file_io_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND (delta_read_bytes > 0 OR delta_write_bytes > 0) + GROUP BY database_name, file_name + ORDER BY SUM(delta_read_bytes + delta_write_bytes) DESC + LIMIT 10 + ), + with_interval AS ( + SELECT + f.collection_time, + f.database_name || '.' || f.file_name AS file_label, + f.delta_read_bytes, + f.delta_write_bytes, + EXTRACT(EPOCH FROM (f.collection_time - LAG(f.collection_time) OVER ( + PARTITION BY f.server_id, f.database_name, f.file_name + ORDER BY f.collection_time + ))) AS interval_seconds + FROM v_file_io_stats f + JOIN top_files tf ON tf.database_name = f.database_name AND tf.file_name = f.file_name + WHERE f.server_id = $1 + AND f.collection_time >= $2 + AND f.collection_time <= $3 + ) + SELECT + collection_time, + file_label, + CASE WHEN interval_seconds > 0 + THEN CAST(delta_read_bytes AS double precision) / interval_seconds / 1048576.0 + ELSE 0 END AS read_mb_per_sec, + CASE WHEN interval_seconds > 0 + THEN CAST(delta_write_bytes AS double precision) / interval_seconds / 1048576.0 + ELSE 0 END AS write_mb_per_sec + FROM with_interval + WHERE interval_seconds IS NOT NULL AND interval_seconds > 0 + ORDER BY collection_time, file_label + """; + + /// <summary> + /// Per-file I/O-latency points (top 10 files by delta ops) for one server over the window, for the + /// File I/O tab's Latency sub-tab. + /// </summary> + public async Task<List<FileIoLatencyPoint>> GetFileIoLatencyTrendAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var items = new List<FileIoLatencyPoint>(); + + await using var command = _dataSource.CreateCommand(FileIoLatencyTrendSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified), + }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified), + }); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new FileIoLatencyPoint( + reader.GetDateTime(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + reader.IsDBNull(2) ? "" : reader.GetString(2), + reader.IsDBNull(3) ? 0 : reader.GetDouble(3), + reader.IsDBNull(4) ? 0 : reader.GetDouble(4), + reader.IsDBNull(5) ? 0 : reader.GetDouble(5), + reader.IsDBNull(6) ? 0 : reader.GetDouble(6))); + } + + return items; + } + + /// <summary> + /// Per-file throughput points (MB/s, top 10 files by delta bytes) for one server over the window, + /// for the File I/O tab's Throughput sub-tab. + /// </summary> + public async Task<List<FileIoThroughputPoint>> GetFileIoThroughputTrendAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var items = new List<FileIoThroughputPoint>(); + + await using var command = _dataSource.CreateCommand(FileIoThroughputTrendSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified), + }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified), + }); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new FileIoThroughputPoint( + reader.GetDateTime(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + reader.IsDBNull(2) ? 0 : reader.GetDouble(2), + reader.IsDBNull(3) ? 0 : reader.GetDouble(3))); + } + + return items; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.IndexAnalysis.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.IndexAnalysis.cs new file mode 100644 index 000000000..b90da5da7 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.IndexAnalysis.cs @@ -0,0 +1,541 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/* + * FinOps Index Analysis sub-tab reads (Stage 3, deferred from the #1372 FinOps port). Lite runs the LIVE + * dbo.sp_IndexCleanup proc against the target; the headless viewer cannot reach targets, so it reproduces the + * analysis MONITOR-SIDE: it reads the latest collected per-index snapshot from v_index_object_stats (Stage 1), + * maps each row into the Common IndexCleanupIndexInput contract, derives the server-fact options + * (edition -> compression/online support, start time -> uptime days) from the collected server_properties, and + * runs the pure Common IndexCleanupAnalyzer (Stage 2, 53 tests, incl. the Rule 7 Unique Constraint Replacement + * family). No live proc, no "install sp_IndexCleanup" prompt, no server-side work — parse-on-read, mirroring the + * System Events tab. The analysis is string-comparison-heavy so it runs off the UI thread (Task.Run), like the + * deadlock-graph / system_health shreds. SQL kept in public const so tests pin it. + */ + +public sealed partial class ViewerDataService +{ + /// <summary> + /// The latest collected snapshot of EVERY index for one server: DISTINCT ON keeps the newest row per + /// (database_id, object_id, index_id) so the analyzer sees each index exactly once (never the whole + /// history). Per-database collection cycles can carry different collection_times, so the dedupe keys on the + /// index identity, not a single server-wide MAX(collection_time). $1 server_id. Column order is the read + /// contract for <see cref="ReadIndexObjectStatsRow"/>. + /// </summary> + public const string IndexObjectStatsLatestSql = @" +SELECT DISTINCT ON (database_id, object_id, index_id) + database_name, + database_id, + schema_name, + object_id, + table_name, + index_id, + index_name, + index_type_desc, + key_columns, + included_columns, + filter_definition, + is_unique, + is_unique_constraint, + is_primary_key, + is_foreign_key, + is_foreign_key_reference, + is_disabled, + is_indexed_view, + data_compression_desc, + optimize_for_sequential_key, + fill_factor, + is_padded, + allow_page_locks, + allow_row_locks, + user_seeks, + user_scans, + user_lookups, + user_updates, + reserved_mb, + total_rows, + partition_count, + sqlserver_start_time, + /* Operational lock/latch counters (dm_db_index_operational_stats), appended after the pre-existing + columns so ordinals 0-31 are unchanged. Feed the reclaimable-space rollup's workload-impact columns + (Lock Waits / Latch Waits), the same collected data the FinOps Locking sub-tab already reads. */ + row_lock_wait_count, + row_lock_wait_in_ms, + page_lock_wait_count, + page_lock_wait_in_ms, + page_latch_wait_count, + page_latch_wait_in_ms, + page_io_latch_wait_count, + page_io_latch_wait_in_ms +FROM v_index_object_stats +WHERE server_id = $1 +ORDER BY database_id, object_id, index_id, collection_time DESC"; + + /// <summary> + /// The server's latest edition / version / start-time facts for the analyzer options — the same three + /// SERVERPROPERTY-derived values sp_IndexCleanup reads at runtime, sourced from the collected + /// server_properties (Darling has no v_server_properties view; the base table is bare-name resolved via the + /// connection Search Path, exactly like the Utilization/Inventory reads). $1 server_id. + /// </summary> + public const string ServerCompressionInfoSql = @" +SELECT + engine_edition, + product_version, + sqlserver_start_time +FROM server_properties +WHERE server_id = $1 +ORDER BY collection_time DESC +LIMIT 1"; + + /// <summary> + /// The analysis-relevant projection of one <c>v_index_object_stats</c> row — the viewer's own read-layer + /// shape (NOT the collector Row: the Common contract is deliberately collector-free), mapped into + /// <see cref="IndexCleanupIndexInput"/> by <see cref="MapToIndexInput"/>. Booleans are nullable because the + /// Postgres columns are; the mapper collapses a NULL flag to <c>false</c> (an absent guard is not a guard). + /// </summary> + public sealed record IndexObjectStatsRow + { + public string DatabaseName { get; init; } = ""; + public int DatabaseId { get; init; } + public string SchemaName { get; init; } = ""; + public int ObjectId { get; init; } + public string TableName { get; init; } = ""; + public int IndexId { get; init; } + public string? IndexName { get; init; } + public string? IndexTypeDesc { get; init; } + public string? KeyColumns { get; init; } + public string? IncludedColumns { get; init; } + public string? FilterDefinition { get; init; } + public bool? IsUnique { get; init; } + public bool? IsUniqueConstraint { get; init; } + public bool? IsPrimaryKey { get; init; } + public bool? IsForeignKey { get; init; } + public bool? IsForeignKeyReference { get; init; } + public bool? IsDisabled { get; init; } + public bool? IsIndexedView { get; init; } + public string? DataCompressionDesc { get; init; } + public bool? OptimizeForSequentialKey { get; init; } + public short? FillFactor { get; init; } + public bool? IsPadded { get; init; } + public bool? AllowPageLocks { get; init; } + public bool? AllowRowLocks { get; init; } + public long? UserSeeks { get; init; } + public long? UserScans { get; init; } + public long? UserLookups { get; init; } + public long? UserUpdates { get; init; } + public decimal? ReservedMb { get; init; } + public long? TotalRows { get; init; } + public int? PartitionCount { get; init; } + public DateTime? SqlServerStartTime { get; init; } + public long? RowLockWaitCount { get; init; } + public long? RowLockWaitInMs { get; init; } + public long? PageLockWaitCount { get; init; } + public long? PageLockWaitInMs { get; init; } + public long? PageLatchWaitCount { get; init; } + public long? PageLatchWaitInMs { get; init; } + public long? PageIoLatchWaitCount { get; init; } + public long? PageIoLatchWaitInMs { get; init; } + } + + /// <summary>Maps a collected snapshot row into the analyzer's per-index input contract (nullable flag -> false).</summary> + public static IndexCleanupIndexInput MapToIndexInput(IndexObjectStatsRow row) => new() + { + DatabaseName = row.DatabaseName, + DatabaseId = row.DatabaseId, + SchemaName = row.SchemaName, + ObjectId = row.ObjectId, + TableName = row.TableName, + IndexId = row.IndexId, + IndexName = row.IndexName, + IndexTypeDesc = row.IndexTypeDesc, + KeyColumns = row.KeyColumns, + IncludedColumns = row.IncludedColumns, + FilterDefinition = row.FilterDefinition, + IsUnique = row.IsUnique ?? false, + IsUniqueConstraint = row.IsUniqueConstraint ?? false, + IsPrimaryKey = row.IsPrimaryKey ?? false, + IsForeignKey = row.IsForeignKey ?? false, + IsForeignKeyReference = row.IsForeignKeyReference ?? false, + IsDisabled = row.IsDisabled ?? false, + IsIndexedView = row.IsIndexedView ?? false, + DataCompressionDesc = row.DataCompressionDesc, + OptimizeForSequentialKey = row.OptimizeForSequentialKey ?? false, + FillFactor = row.FillFactor, + IsPadded = row.IsPadded ?? false, + AllowPageLocks = row.AllowPageLocks ?? false, + AllowRowLocks = row.AllowRowLocks ?? false, + UserSeeks = row.UserSeeks ?? 0, + UserScans = row.UserScans ?? 0, + UserLookups = row.UserLookups ?? 0, + UserUpdates = row.UserUpdates ?? 0, + RowLockWaitCount = row.RowLockWaitCount ?? 0, + RowLockWaitInMs = row.RowLockWaitInMs ?? 0, + PageLockWaitCount = row.PageLockWaitCount ?? 0, + PageLockWaitInMs = row.PageLockWaitInMs ?? 0, + PageLatchWaitCount = row.PageLatchWaitCount ?? 0, + PageLatchWaitInMs = row.PageLatchWaitInMs ?? 0, + PageIoLatchWaitCount = row.PageIoLatchWaitCount ?? 0, + PageIoLatchWaitInMs = row.PageIoLatchWaitInMs ?? 0, + ReservedMb = row.ReservedMb, + TotalRows = row.TotalRows, + PartitionCount = row.PartitionCount, + SqlServerStartTime = row.SqlServerStartTime, + }; + + /// <summary> + /// Derives the analyzer options from the collected server facts, reproducing sp_IndexCleanup's runtime + /// gates: <c>@can_compress</c> = EngineEdition IN (3,5,8) [Enterprise / Azure SQL DB / Managed Instance] OR + /// (EngineEdition IN (2,4) [Standard / Express family] AND ProductVersion major >= 13 [SQL 2016+]); + /// <c>@online</c> = EngineEdition IN (3,5,8). The size/read/write/row minimums and dedupe-only match the + /// proc's parameter DEFAULTS (all 0 / off) — no speculative settings knobs. Uptime days is computed from the + /// server-LOCAL <c>sqlserver_start_time</c> vs the viewer's local now (both wall-clock, mirroring the proc's + /// <c>DATEDIFF(DAY, sqlserver_start_time, SYSDATETIME())</c>); it drives the analyzer's <14-day + /// unused caveat and <=7-day auto-dedupe. When no server_properties row is collected yet, edition is + /// unknown: fall back to the safe defaults (compression assumed available — 2016+ is the product minimum — + /// and ONLINE off). + /// </summary> + public static IndexCleanupOptions DeriveIndexCleanupOptions( + int? engineEdition, + string? productVersion, + DateTime? sqlServerStartTime, + DateTime referenceLocalTime) + { + bool supportsOnline; + bool supportsCompression; + + if (engineEdition is { } edition) + { + bool isEnterpriseOrAzure = edition is 3 or 5 or 8; + bool isStandardFamily = edition is 2 or 4; + supportsOnline = isEnterpriseOrAzure; + supportsCompression = + isEnterpriseOrAzure + || (isStandardFamily && ParseMajorVersion(productVersion) >= 13); + } + else + { + /* No collected properties row: assume compression is available (SQL 2016+ is the product minimum) + and keep ONLINE off (the safe default the generated scripts also default to). */ + supportsOnline = false; + supportsCompression = true; + } + + double? uptimeDays = null; + if (sqlServerStartTime is { } start) + { + var days = (referenceLocalTime - start).TotalDays; + if (days >= 0) + { + uptimeDays = days; + } + } + + return new IndexCleanupOptions + { + DedupeOnly = false, + MinSizeGb = 0m, + MinReads = 0, + MinWrites = 0, + MinRows = 0, + ServerSupportsCompression = supportsCompression, + ServerSupportsOnline = supportsOnline, + ServerUptimeDays = uptimeDays, + /* CompressionMin/MaxSavingsFactor keep the record defaults (0.20 / 0.60 — sp_IndexCleanup's band). */ + }; + } + + /// <summary>Parses the leading major-version integer from a ProductVersion string (e.g. "16.0.1000.6" -> 16); 0 when unparseable.</summary> + public static int ParseMajorVersion(string? productVersion) + { + if (string.IsNullOrWhiteSpace(productVersion)) + { + return 0; + } + + var head = productVersion.Split('.', 2)[0].Trim(); + return int.TryParse(head, NumberStyles.Integer, CultureInfo.InvariantCulture, out var major) ? major : 0; + } + + /// <summary>Reads one <c>v_index_object_stats</c> row at the <see cref="IndexObjectStatsLatestSql"/> column ordinals.</summary> + private static IndexObjectStatsRow ReadIndexObjectStatsRow(NpgsqlDataReader reader) + { + long? L(int i) => reader.IsDBNull(i) ? null : Convert.ToInt64(reader.GetValue(i), CultureInfo.InvariantCulture); + int? I(int i) => reader.IsDBNull(i) ? null : Convert.ToInt32(reader.GetValue(i), CultureInfo.InvariantCulture); + short? Sh(int i) => reader.IsDBNull(i) ? null : Convert.ToInt16(reader.GetValue(i), CultureInfo.InvariantCulture); + decimal? D(int i) => reader.IsDBNull(i) ? null : Convert.ToDecimal(reader.GetValue(i), CultureInfo.InvariantCulture); + bool? B(int i) => reader.IsDBNull(i) ? null : reader.GetBoolean(i); + string? S(int i) => reader.IsDBNull(i) ? null : reader.GetString(i); + + return new IndexObjectStatsRow + { + DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0), + DatabaseId = reader.IsDBNull(1) ? 0 : Convert.ToInt32(reader.GetValue(1), CultureInfo.InvariantCulture), + SchemaName = reader.IsDBNull(2) ? "" : reader.GetString(2), + ObjectId = reader.IsDBNull(3) ? 0 : Convert.ToInt32(reader.GetValue(3), CultureInfo.InvariantCulture), + TableName = reader.IsDBNull(4) ? "" : reader.GetString(4), + IndexId = reader.IsDBNull(5) ? 0 : Convert.ToInt32(reader.GetValue(5), CultureInfo.InvariantCulture), + IndexName = S(6), + IndexTypeDesc = S(7), + KeyColumns = S(8), + IncludedColumns = S(9), + FilterDefinition = S(10), + IsUnique = B(11), + IsUniqueConstraint = B(12), + IsPrimaryKey = B(13), + IsForeignKey = B(14), + IsForeignKeyReference = B(15), + IsDisabled = B(16), + IsIndexedView = B(17), + DataCompressionDesc = S(18), + OptimizeForSequentialKey = B(19), + FillFactor = Sh(20), + IsPadded = B(21), + AllowPageLocks = B(22), + AllowRowLocks = B(23), + UserSeeks = L(24), + UserScans = L(25), + UserLookups = L(26), + UserUpdates = L(27), + ReservedMb = D(28), + TotalRows = L(29), + PartitionCount = I(30), + SqlServerStartTime = reader.IsDBNull(31) ? null : reader.GetDateTime(31), + RowLockWaitCount = L(32), + RowLockWaitInMs = L(33), + PageLockWaitCount = L(34), + PageLockWaitInMs = L(35), + PageLatchWaitCount = L(36), + PageLatchWaitInMs = L(37), + PageIoLatchWaitCount = L(38), + PageIoLatchWaitInMs = L(39), + }; + } + + /// <summary>Reads the latest per-index snapshot for the server and maps each row to the analyzer input contract.</summary> + public async Task<List<IndexCleanupIndexInput>> GetIndexCleanupInputsAsync(int serverId, CancellationToken cancellationToken = default) + { + var inputs = new List<IndexCleanupIndexInput>(); + + await using var command = _dataSource.CreateCommand(IndexObjectStatsLatestSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + inputs.Add(MapToIndexInput(ReadIndexObjectStatsRow(reader))); + } + + return inputs; + } + + /// <summary>Reads the server's latest collected edition/version/start-time facts and derives the analyzer options.</summary> + public async Task<IndexCleanupOptions> GetIndexCleanupOptionsAsync(int serverId, CancellationToken cancellationToken = default) + { + int? engineEdition = null; + string? productVersion = null; + DateTime? startTime = null; + + await using var command = _dataSource.CreateCommand(ServerCompressionInfoSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (await reader.ReadAsync(cancellationToken)) + { + engineEdition = reader.IsDBNull(0) ? null : Convert.ToInt32(reader.GetValue(0), CultureInfo.InvariantCulture); + productVersion = reader.IsDBNull(1) ? null : reader.GetString(1); + startTime = reader.IsDBNull(2) ? null : reader.GetDateTime(2); + } + + /* sqlserver_start_time is the server's LOCAL wall clock, compared to the viewer's local now — mirrors + the proc's SYSDATETIME() derivation (both wall-clock; the coarse days measure tolerates any offset). */ + return DeriveIndexCleanupOptions(engineEdition, productVersion, startTime, DateTime.Now); + } + + /// <summary> + /// The full monitor-side index-cleanup analysis for one server: reads the latest collected snapshot, derives + /// the server-fact options, and runs the pure Common analyzer off the UI thread (the dedupe is + /// string-comparison-heavy). Returns the analyzer result verbatim — the tab projects it into the grids + + /// rollup + banners. + /// </summary> + public async Task<IndexCleanupAnalysisResult> GetIndexAnalysisAsync(int serverId, CancellationToken cancellationToken = default) + { + var inputs = await GetIndexCleanupInputsAsync(serverId, cancellationToken); + var options = await GetIndexCleanupOptionsAsync(serverId, cancellationToken); + return await Task.Run(() => IndexCleanupAnalyzer.Analyze(inputs, options), cancellationToken); + } + + /// <summary>Projects the analyzer recommendations into the detail-grid view-models.</summary> + public static List<IndexCleanupRecommendationRow> ProjectRecommendations(IndexCleanupAnalysisResult result) => + result.Recommendations.Select(r => new IndexCleanupRecommendationRow(r)).ToList(); + + /// <summary> + /// Projects the analyzer rollups into the summary-grid view-models: the overall "ALL DATABASES" total first, + /// then one row per database. + /// </summary> + public static List<IndexCleanupRollupRow> ProjectRollups(IndexCleanupAnalysisResult result) + { + var rows = new List<IndexCleanupRollupRow> + { + new(result.OverallRollup, "ALL DATABASES"), + }; + rows.AddRange(result.DatabaseRollups.Select(r => new IndexCleanupRollupRow(r, r.DatabaseName ?? ""))); + return rows; + } +} + +/// <summary> +/// One recommendation row for the Index Analysis detail grid — a flat, display-formatted projection of the +/// Common <see cref="IndexCleanupRecommendation"/>. <see cref="ActionDisplay"/> collapses (Action, ResultKind) +/// into the single operation label the tab filters by (DISABLE / MAKE UNIQUE / MERGE / DROP CONSTRAINT / +/// COMPRESS / REVIEW); REVIEW rows carry an empty <see cref="Script"/> (informational only — a recognized-but- +/// not-consolidated Reverse Duplicate / Equal-Except-Filter relationship). +/// </summary> +public sealed class IndexCleanupRecommendationRow +{ + private readonly IndexCleanupRecommendation _rec; + + public IndexCleanupRecommendationRow(IndexCleanupRecommendation rec) + { + _rec = rec; + } + + public string DatabaseName => _rec.DatabaseName; + public string SchemaName => _rec.SchemaName; + public string TableName => _rec.TableName; + public string IndexName => _rec.IndexName; + public string ConsolidationRule => _rec.ConsolidationRule ?? ""; + public string ActionDisplay => ActionLabelFor(_rec.Action, _rec.ResultKind); + public string ResultKindDisplay => ResultKindLabelFor(_rec.ResultKind); + public string TargetIndexName => _rec.TargetIndexName ?? ""; + public string SupersededBy => _rec.SupersededBy ?? ""; + public string AdditionalInfo => _rec.AdditionalInfo ?? ""; + public decimal IndexSizeGb => _rec.IndexSizeGb; + public long IndexRows => _rec.IndexRows; + public long IndexReads => _rec.IndexReads; + public long IndexWrites => _rec.IndexWrites; + public bool CanCompress => _rec.CanCompress; + public string OriginalIndexDefinition => _rec.OriginalIndexDefinition; + public string Script => _rec.Script; + + /// <summary>The single filterable operation label mirroring sp_IndexCleanup's script buckets: MAKE UNIQUE wins over its MERGE bucket; otherwise the result kind names the bucket.</summary> + public static string ActionLabelFor(IndexCleanupAction action, IndexCleanupResultKind resultKind) + { + if (action == IndexCleanupAction.MakeUnique) + { + return "MAKE UNIQUE"; + } + + return ResultKindLabelFor(resultKind); + } + + /// <summary>The display label for a script bucket.</summary> + public static string ResultKindLabelFor(IndexCleanupResultKind resultKind) => resultKind switch + { + IndexCleanupResultKind.Disable => "DISABLE", + IndexCleanupResultKind.Merge => "MERGE", + IndexCleanupResultKind.Compress => "COMPRESS", + IndexCleanupResultKind.Review => "REVIEW", + IndexCleanupResultKind.DisableConstraint => "DROP CONSTRAINT", + _ => resultKind.ToString().ToUpperInvariant(), + }; +} + +/// <summary> +/// One reclaimable-space rollup row for the Index Analysis summary grid — a flat projection of the Common +/// <see cref="IndexCleanupRollup"/> plus its scope label (a database name, or "ALL DATABASES" for the overall +/// total). Mirrors sp_IndexCleanup's <c>#index_reporting_stats</c> DATABASE row. +/// </summary> +public sealed class IndexCleanupRollupRow +{ + private readonly IndexCleanupRollup _rollup; + + public IndexCleanupRollupRow(IndexCleanupRollup rollup, string scope) + { + _rollup = rollup; + Scope = scope; + } + + /// <summary>Database name, or "ALL DATABASES" for the overall total.</summary> + public string Scope { get; } + + public int TablesAnalyzed => _rollup.TablesAnalyzed; + public int IndexCount => _rollup.IndexCount; + public decimal TotalSizeGb => _rollup.TotalSizeGb; + public long TotalRows => _rollup.TotalRows; + public int IndexesToDisable => _rollup.IndexesToDisable; + public int IndexesToMerge => _rollup.IndexesToMerge; + public int CompressableIndexes => _rollup.CompressableIndexes; + public int UnusedIndexes => _rollup.UnusedIndexes; + public decimal UnusedSizeGb => _rollup.UnusedSizeGb; + public decimal CompressionMinSavingsGb => _rollup.CompressionMinSavingsGb; + public decimal CompressionMaxSavingsGb => _rollup.CompressionMaxSavingsGb; + public decimal TotalMinSavingsGb => _rollup.TotalMinSavingsGb; + public decimal TotalMaxSavingsGb => _rollup.TotalMaxSavingsGb; + + /* + * Workload-impact columns (Reads / Writes / Lock Waits / Latch Waits), ported from sp_IndexCleanup's + * #index_reporting_stats. The proc surfaces these at its DATABASE level but emits 'N/A' at the SUMMARY + * level (it does not populate the workload counters there), so the overall ("ALL DATABASES") row renders + * 'N/A' to match Lite exactly; the per-database rows render the real aggregates. The formulas mirror the + * proc's DATABASE-level display (reads_breakdown / writes / lock_wait_count / avg_lock_wait_ms / + * latch_wait_count / avg_latch_wait_ms). + */ + private bool IsOverall => _rollup.DatabaseName is null; + + private long TotalReads => _rollup.UserSeeks + _rollup.UserScans + _rollup.UserLookups; + + /// <summary>Total reads with the seeks/scans/lookups breakdown (e.g. "1,234 (1,000 seeks, 200 scans, 34 lookups)"); 'N/A' on the overall row.</summary> + public string ReadsBreakdown => + IsOverall + ? "N/A" + : $"{TotalReads:N0} ({_rollup.UserSeeks:N0} seeks, {_rollup.UserScans:N0} scans, {_rollup.UserLookups:N0} lookups)"; + + /// <summary>Total <c>user_updates</c> across analyzed indexes; 'N/A' on the overall row.</summary> + public string Writes => IsOverall ? "N/A" : _rollup.TotalWrites.ToString("N0"); + + /// <summary>Row + page lock waits across analyzed indexes; 'N/A' on the overall row.</summary> + public string LockWaitCount => IsOverall ? "N/A" : _rollup.LockWaitCount.ToString("N0"); + + /// <summary>Average lock wait (ms/wait) = lock_wait_in_ms / lock_wait_count; "0" when there were no waits; 'N/A' on the overall row.</summary> + public string AvgLockWaitMs => + IsOverall + ? "N/A" + : _rollup.LockWaitCount > 0 + ? ((decimal)_rollup.LockWaitInMs / _rollup.LockWaitCount).ToString("N2") + : "0"; + + /// <summary>Page + page-IO latch waits across analyzed indexes; 'N/A' on the overall row.</summary> + public string LatchWaitCount => IsOverall ? "N/A" : _rollup.LatchWaitCount.ToString("N0"); + + /// <summary>Average latch wait (ms/wait) = latch_wait_in_ms / latch_wait_count; "0" when there were no waits; 'N/A' on the overall row.</summary> + public string AvgLatchWaitMs => + IsOverall + ? "N/A" + : _rollup.LatchWaitCount > 0 + ? ((decimal)_rollup.LatchWaitInMs / _rollup.LatchWaitCount).ToString("N2") + : "0"; + + /* Numeric sort keys for the workload columns — the overall ('N/A') row sorts below every real value, like + Lite's NumericSortHelper parse of "N/A" (→ -1). ReadsBreakdown sorts as text (no key), matching Lite. */ + public decimal WritesSort => IsOverall ? -1m : _rollup.TotalWrites; + public decimal LockWaitCountSort => IsOverall ? -1m : _rollup.LockWaitCount; + public decimal AvgLockWaitMsSort => IsOverall ? -1m : (_rollup.LockWaitCount > 0 ? (decimal)_rollup.LockWaitInMs / _rollup.LockWaitCount : 0m); + public decimal LatchWaitCountSort => IsOverall ? -1m : _rollup.LatchWaitCount; + public decimal AvgLatchWaitMsSort => IsOverall ? -1m : (_rollup.LatchWaitCount > 0 ? (decimal)_rollup.LatchWaitInMs / _rollup.LatchWaitCount : 0m); +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs new file mode 100644 index 000000000..a614376d0 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// FinOps Server Inventory reads. Lite queries each target LIVE (<c>GetServerPropertiesLiveAsync</c>) — the +/// headless viewer can't reach targets, so the inventory rows come from the COLLECTED +/// <c>server_properties</c> table (Darling collects it; <c>PgFactCollector.Config.ServerPropertiesSql</c> +/// reads it the same way) joined to the <c>servers</c> registry, with the collected metrics +/// (<see cref="ServerMetricsSql"/>) overlaid per server by the loader. Column parity vs Lite's live query: +/// the collected table carries edition/version/level/CPU/memory/sockets/cores/HADR/clustered, so those +/// surface; it does NOT carry sqlserver_start_time / host OS / AG replica role, so those Lite columns are +/// omitted (nothing stubbed). SQL kept in <c>public const</c> so tests pin it. +/// </summary> +public sealed partial class ViewerDataService +{ + /// <summary>Collected 24h-CPU / storage / idle-DB / provisioning metrics for one server. $1 server_id, $2 cpu cutoff (24h), $3 idle cutoff (7d).</summary> + public const string ServerMetricsSql = @" +WITH cpu_24h AS ( + SELECT + AVG(CAST(sqlserver_cpu_utilization AS DECIMAL(5,2))) AS avg_cpu_pct, + MAX(sqlserver_cpu_utilization) AS max_cpu_pct, + PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY sqlserver_cpu_utilization) AS p95_cpu_pct + FROM v_cpu_utilization_stats + WHERE server_id = $1 + AND collection_time >= $2 +), +mem_latest AS ( + SELECT + CAST(total_server_memory_mb AS DECIMAL(10,2)) / NULLIF(target_server_memory_mb, 0) AS memory_ratio + FROM v_memory_stats + WHERE server_id = $1 + AND (server_id, collection_time) IN ( + SELECT server_id, MAX(collection_time) + FROM v_memory_stats + WHERE server_id = $1 + GROUP BY server_id + ) +), +storage_totals AS ( + SELECT + SUM(total_size_mb) / 1024.0 AS total_storage_gb + FROM v_database_size_stats + WHERE server_id = $1 + AND (server_id, collection_time) IN ( + SELECT server_id, MAX(collection_time) + FROM v_database_size_stats + WHERE server_id = $1 + GROUP BY server_id + ) +), +idle_dbs AS ( + SELECT + COUNT(DISTINCT database_name) AS idle_db_count + FROM ( + SELECT database_name + FROM v_database_size_stats + WHERE server_id = $1 + AND (server_id, collection_time) IN ( + SELECT server_id, MAX(collection_time) + FROM v_database_size_stats + WHERE server_id = $1 + GROUP BY server_id + ) + AND database_name NOT IN ('master', 'model', 'msdb', 'tempdb', 'PerformanceMonitor') + EXCEPT + SELECT DISTINCT database_name + FROM v_query_stats + WHERE server_id = $1 + AND collection_time >= $3 + AND delta_execution_count > 0 + ) AS idle +) +SELECT + c.avg_cpu_pct, + st.total_storage_gb, + id.idle_db_count, + CASE + WHEN c.avg_cpu_pct < 15 AND c.max_cpu_pct < 40 AND COALESCE(m.memory_ratio, 0) < 0.5 + THEN 'OVER_PROVISIONED' + WHEN c.p95_cpu_pct > 85 OR COALESCE(m.memory_ratio, 0) > 0.95 + THEN 'UNDER_PROVISIONED' + ELSE 'RIGHT_SIZED' + END AS provisioning_status +FROM (SELECT 1) AS anchor +LEFT JOIN cpu_24h c ON true +LEFT JOIN mem_latest m ON true +LEFT JOIN storage_totals st ON true +LEFT JOIN idle_dbs id ON true"; + + public async Task<(decimal? AvgCpuPct, decimal? StorageTotalGb, int? IdleDbCount, string? ProvisioningStatus)> GetServerMetricsAsync(int serverId, CancellationToken cancellationToken = default) + { + var cpuCutoff = DateTime.UtcNow.AddHours(-24); + var idleCutoff = DateTime.UtcNow.AddDays(-7); + + await using var command = _dataSource.CreateCommand(ServerMetricsSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(cpuCutoff, DateTimeKind.Unspecified) }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(idleCutoff, DateTimeKind.Unspecified) }); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (await reader.ReadAsync(cancellationToken)) + { + return ( + reader.IsDBNull(0) ? null : Convert.ToDecimal(reader.GetValue(0)), + reader.IsDBNull(1) ? null : Convert.ToDecimal(reader.GetValue(1)), + reader.IsDBNull(2) ? null : Convert.ToInt32(reader.GetValue(2)), + reader.IsDBNull(3) ? null : reader.GetString(3) + ); + } + + return (null, null, null, null); + } + + /// <summary> + /// Latest collected properties per server joined to the registry — the Server Inventory base rows (metrics + /// overlaid per row by the loader). DISTINCT ON keeps each server's newest server_properties row. + /// </summary> + public const string ServerInventorySql = @" +SELECT + sp.server_id, + COALESCE(s.display_name, s.server_name) AS server_name, + sp.edition, + sp.product_version, + sp.product_level, + sp.product_update_level, + sp.engine_edition, + sp.cpu_count, + sp.physical_memory_mb, + sp.socket_count, + sp.cores_per_socket, + sp.is_hadr_enabled, + sp.is_clustered, + sp.collection_time, + sp.sqlserver_start_time, + sp.host_os_version, + sp.ag_replica_role, + COALESCE(s.monthly_cost_usd, 0) AS monthly_cost_usd +FROM ( + SELECT DISTINCT ON (server_id) + server_id, edition, product_version, product_level, product_update_level, + engine_edition, cpu_count, physical_memory_mb, socket_count, cores_per_socket, + is_hadr_enabled, is_clustered, collection_time, + sqlserver_start_time, host_os_version, ag_replica_role + FROM server_properties + ORDER BY server_id, collection_time DESC +) sp +JOIN servers s ON s.server_id = sp.server_id +ORDER BY server_name"; + + public async Task<List<ServerPropertyRow>> GetServerInventoryAsync(CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(ServerInventorySql); + + var items = new List<ServerPropertyRow>(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var version = reader.IsDBNull(3) ? "" : reader.GetString(3); + var level = reader.IsDBNull(4) ? "" : reader.GetString(4); + var updateLevel = reader.IsDBNull(5) ? null : reader.GetString(5); + var versionDisplay = !string.IsNullOrEmpty(updateLevel) + ? $"{version} - {updateLevel}" + : $"{version} - {level}"; + + items.Add(new ServerPropertyRow + { + ServerId = reader.GetInt32(0), + ServerName = reader.IsDBNull(1) ? "" : reader.GetString(1), + Edition = reader.IsDBNull(2) ? "" : reader.GetString(2), + ProductVersion = versionDisplay, + EngineEdition = reader.IsDBNull(6) ? 0 : Convert.ToInt32(reader.GetValue(6)), + CpuCount = reader.IsDBNull(7) ? 0 : Convert.ToInt32(reader.GetValue(7)), + PhysicalMemoryMb = reader.IsDBNull(8) ? 0L : Convert.ToInt64(reader.GetValue(8)), + SocketCount = reader.IsDBNull(9) ? null : Convert.ToInt32(reader.GetValue(9)), + CoresPerSocket = reader.IsDBNull(10) ? null : Convert.ToInt32(reader.GetValue(10)), + IsHadrEnabled = reader.IsDBNull(11) ? null : reader.GetBoolean(11), + IsClustered = reader.IsDBNull(12) ? null : reader.GetBoolean(12), + LastUpdated = reader.IsDBNull(13) ? null : ViewerTimeHelper.ForDisplay(reader.GetDateTime(13)), + /* sqlserver_start_time is the server's LOCAL clock — read verbatim, shown as-is like Lite + (UptimeDisplay = Now - start). host OS + AG role are the collected guarded values. */ + SqlServerStartTime = reader.IsDBNull(14) ? null : reader.GetDateTime(14), + HostOsVersion = reader.IsDBNull(15) ? "" : reader.GetString(15), + AgReplicaRole = reader.IsDBNull(16) ? "Standalone" : reader.GetString(16), + MonthlyCost = reader.IsDBNull(17) ? 0m : Convert.ToDecimal(reader.GetValue(17)) + }); + } + return items; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Locking.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Locking.cs new file mode 100644 index 000000000..787bbd231 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Locking.cs @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// FinOps Locking & Contention reads — Lite's <c>GetIndexLockingAsync</c> / +/// <c>GetIndexLockingDatabasesAsync</c> (<c>LocalDataService.FinOps.IndexObjects.cs</c>) ported to +/// Postgres. Cumulative-snapshot top-N indexes by total lock+latch wait (#1138 §3B), per-database latest so +/// "all databases" shows every DB's newest row. Where Lite splices an optional DB filter into one SQL +/// string, the viewer uses two <c>const</c> strings (all-databases / single-database) so both stay +/// test-pinnable; the DuckDB SQL is otherwise byte-identical on PG. +/// </summary> +public sealed partial class ViewerDataService +{ + /// <summary>Top-N indexes by lock+latch wait across ALL databases (per-database latest). $1 server_id, $2 topN.</summary> + public const string IndexLockingAllSql = @" +WITH latest AS ( + SELECT database_name, MAX(collection_time) AS latest_time + FROM v_index_object_stats + WHERE server_id = $1 + GROUP BY database_name +) +SELECT + ios.database_name, + ios.schema_name, + ios.table_name, + ios.index_name, + ios.index_type_desc, + ios.reserved_mb, + ios.total_rows, + COALESCE(ios.row_lock_count, 0) AS row_lock_count, + COALESCE(ios.row_lock_wait_count, 0) AS row_lock_wait_count, + COALESCE(ios.row_lock_wait_in_ms, 0) AS row_lock_wait_in_ms, + COALESCE(ios.page_lock_count, 0) AS page_lock_count, + COALESCE(ios.page_lock_wait_count, 0) AS page_lock_wait_count, + COALESCE(ios.page_lock_wait_in_ms, 0) AS page_lock_wait_in_ms, + COALESCE(ios.index_lock_promotion_count, 0) AS index_lock_promotion_count, + COALESCE(ios.page_latch_wait_in_ms, 0) AS page_latch_wait_in_ms, + COALESCE(ios.page_io_latch_wait_in_ms, 0) AS page_io_latch_wait_in_ms, + COALESCE(ios.page_latch_wait_count, 0) AS page_latch_wait_count, + COALESCE(ios.page_io_latch_wait_count, 0) AS page_io_latch_wait_count +FROM v_index_object_stats ios +JOIN latest l ON l.database_name = ios.database_name AND l.latest_time = ios.collection_time +WHERE ios.server_id = $1 +AND ( + COALESCE(ios.row_lock_wait_in_ms, 0) > 0 + OR COALESCE(ios.page_lock_wait_in_ms, 0) > 0 + OR COALESCE(ios.page_latch_wait_in_ms, 0) > 0 + OR COALESCE(ios.page_io_latch_wait_in_ms, 0) > 0 + OR COALESCE(ios.index_lock_promotion_count, 0) > 0 +) +ORDER BY + COALESCE(ios.row_lock_wait_in_ms, 0) + COALESCE(ios.page_lock_wait_in_ms, 0) + + COALESCE(ios.page_latch_wait_in_ms, 0) + COALESCE(ios.page_io_latch_wait_in_ms, 0) DESC +LIMIT $2"; + + /// <summary>Top-N indexes by lock+latch wait for ONE database. $1 server_id, $2 database, $3 topN.</summary> + public const string IndexLockingByDbSql = @" +WITH latest AS ( + SELECT database_name, MAX(collection_time) AS latest_time + FROM v_index_object_stats + WHERE server_id = $1 AND database_name = $2 + GROUP BY database_name +) +SELECT + ios.database_name, + ios.schema_name, + ios.table_name, + ios.index_name, + ios.index_type_desc, + ios.reserved_mb, + ios.total_rows, + COALESCE(ios.row_lock_count, 0) AS row_lock_count, + COALESCE(ios.row_lock_wait_count, 0) AS row_lock_wait_count, + COALESCE(ios.row_lock_wait_in_ms, 0) AS row_lock_wait_in_ms, + COALESCE(ios.page_lock_count, 0) AS page_lock_count, + COALESCE(ios.page_lock_wait_count, 0) AS page_lock_wait_count, + COALESCE(ios.page_lock_wait_in_ms, 0) AS page_lock_wait_in_ms, + COALESCE(ios.index_lock_promotion_count, 0) AS index_lock_promotion_count, + COALESCE(ios.page_latch_wait_in_ms, 0) AS page_latch_wait_in_ms, + COALESCE(ios.page_io_latch_wait_in_ms, 0) AS page_io_latch_wait_in_ms, + COALESCE(ios.page_latch_wait_count, 0) AS page_latch_wait_count, + COALESCE(ios.page_io_latch_wait_count, 0) AS page_io_latch_wait_count +FROM v_index_object_stats ios +JOIN latest l ON l.database_name = ios.database_name AND l.latest_time = ios.collection_time +WHERE ios.server_id = $1 +AND ( + COALESCE(ios.row_lock_wait_in_ms, 0) > 0 + OR COALESCE(ios.page_lock_wait_in_ms, 0) > 0 + OR COALESCE(ios.page_latch_wait_in_ms, 0) > 0 + OR COALESCE(ios.page_io_latch_wait_in_ms, 0) > 0 + OR COALESCE(ios.index_lock_promotion_count, 0) > 0 +) +ORDER BY + COALESCE(ios.row_lock_wait_in_ms, 0) + COALESCE(ios.page_lock_wait_in_ms, 0) + + COALESCE(ios.page_latch_wait_in_ms, 0) + COALESCE(ios.page_io_latch_wait_in_ms, 0) DESC +LIMIT $3"; + + public async Task<List<IndexLockingRow>> GetIndexLockingAsync(int serverId, int topN = 200, string? databaseName = null, CancellationToken cancellationToken = default) + { + await using var command = databaseName == null + ? _dataSource.CreateCommand(IndexLockingAllSql) + : _dataSource.CreateCommand(IndexLockingByDbSql); + + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + if (databaseName != null) + { + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = databaseName }); + } + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = topN }); + + var items = new List<IndexLockingRow>(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new IndexLockingRow + { + DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0), + SchemaName = reader.IsDBNull(1) ? "" : reader.GetString(1), + TableName = reader.IsDBNull(2) ? "" : reader.GetString(2), + IndexName = reader.IsDBNull(3) ? "(heap)" : reader.GetString(3), + IndexTypeDesc = reader.IsDBNull(4) ? "" : reader.GetString(4), + ReservedMb = reader.IsDBNull(5) ? 0m : Convert.ToDecimal(reader.GetValue(5)), + TotalRows = reader.IsDBNull(6) ? 0L : Convert.ToInt64(reader.GetValue(6)), + RowLockCount = reader.IsDBNull(7) ? 0L : Convert.ToInt64(reader.GetValue(7)), + RowLockWaitCount = reader.IsDBNull(8) ? 0L : Convert.ToInt64(reader.GetValue(8)), + RowLockWaitInMs = reader.IsDBNull(9) ? 0L : Convert.ToInt64(reader.GetValue(9)), + PageLockCount = reader.IsDBNull(10) ? 0L : Convert.ToInt64(reader.GetValue(10)), + PageLockWaitCount = reader.IsDBNull(11) ? 0L : Convert.ToInt64(reader.GetValue(11)), + PageLockWaitInMs = reader.IsDBNull(12) ? 0L : Convert.ToInt64(reader.GetValue(12)), + IndexLockPromotionCount = reader.IsDBNull(13) ? 0L : Convert.ToInt64(reader.GetValue(13)), + PageLatchWaitInMs = reader.IsDBNull(14) ? 0L : Convert.ToInt64(reader.GetValue(14)), + PageIoLatchWaitInMs = reader.IsDBNull(15) ? 0L : Convert.ToInt64(reader.GetValue(15)), + PageLatchWaitCount = reader.IsDBNull(16) ? 0L : Convert.ToInt64(reader.GetValue(16)), + PageIoLatchWaitCount = reader.IsDBNull(17) ? 0L : Convert.ToInt64(reader.GetValue(17)) + }); + } + return items; + } + + /// <summary>Distinct databases with any lock/latch contention at their latest snapshot (the DB selector source). $1 server_id.</summary> + public const string IndexLockingDatabasesSql = @" +WITH latest AS ( + SELECT database_name, MAX(collection_time) AS latest_time + FROM v_index_object_stats + WHERE server_id = $1 + GROUP BY database_name +) +SELECT DISTINCT ios.database_name +FROM v_index_object_stats ios +JOIN latest l ON l.database_name = ios.database_name AND l.latest_time = ios.collection_time +WHERE ios.server_id = $1 +AND ( + COALESCE(ios.row_lock_wait_in_ms, 0) > 0 + OR COALESCE(ios.page_lock_wait_in_ms, 0) > 0 + OR COALESCE(ios.page_latch_wait_in_ms, 0) > 0 + OR COALESCE(ios.page_io_latch_wait_in_ms, 0) > 0 + OR COALESCE(ios.index_lock_promotion_count, 0) > 0 +) +ORDER BY ios.database_name"; + + public async Task<List<string>> GetIndexLockingDatabasesAsync(int serverId, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(IndexLockingDatabasesSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + + var items = new List<string>(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + if (!reader.IsDBNull(0)) items.Add(reader.GetString(0)); + } + return items; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Recommendations.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Recommendations.cs new file mode 100644 index 000000000..8a430636d --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Recommendations.cs @@ -0,0 +1,805 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/* + * FinOps Recommendations sub-tab reads (the last deferred FinOps sub-tab from the #1372 port). Lite's + * GetRecommendationsAsync (LocalDataService.FinOps.Recommendations.cs) runs ~11 checks, MIXING collected DuckDB + * reads with LIVE queries against the target. The headless viewer can't reach targets, so every check here runs + * MONITOR-SIDE over already-collected Postgres data — NO live SQL: + * - The time-series checks (CPU/memory right-sizing, dormant DBs, maintenance window, VM right-sizing, storage + * tier, reserved capacity) reuse the Utilization/Storage FinOps helpers the port already created, or read the + * same v_* views Lite's DuckDB versions did. + * - The three checks Lite ran "live" are reproduced from collected data: the Edition/license audit reads the + * collected server_properties + database_config.is_encrypted (instead of a live dm_db_persisted_sku_features + * probe); Compression candidates come from the collected index_object_stats snapshot (data_compression_desc); + * Dev/test detection matches the collected database name list. + * - Lite's sp_IndexCleanup-existence check (check 4) is DROPPED — Darling has native monitor-side Index Analysis + * (the FinOps Index Analysis sub-tab, #1387), so the "install sp_IndexCleanup" recommendation is obsolete. + * - AG-topology nuance (secondary-replica branch, advanced-AG downgrade caveat, AG-driven confidence downgrades) + * IS reproduced from the collected server_properties.ag_replica_role + is_hadr_enabled (ServerPropertiesCollector + * resolves the role live from sys.dm_hadr_availability_replica_states; is_hadr_enabled is SERVERPROPERTY). The one + * Lite input Darling does not collect is the non-basic AG COUNT (sys.availability_groups.basic_features); on an + * Enterprise instance a hosted AG (role = Primary) is effectively always advanced — Basic AGs are Standard-only — + * so the collected primary role stands in for Lite's advanced-AG count. No AG data is invented. + * The per-check threshold/severity/confidence logic and wording are ported VERBATIM from Lite (not re-tuned). Each + * check is independently try/caught (Lite's structure) so one failing read never suppresses the others. SQL kept + * in public const so tests pin it. + */ + +public sealed partial class ViewerDataService +{ + /// <summary>The four system databases (master/model/msdb/tempdb) — Lite's <c>database_id > 4</c> filter by name.</summary> + private static readonly HashSet<string> RecommendationsSystemDatabases = + new(StringComparer.OrdinalIgnoreCase) { "master", "model", "msdb", "tempdb" }; + + /// <summary> + /// The server's latest collected edition / product version / logical CPU count for the license audit, plus the + /// collected AG replica role + Always On master switch that drive the AG-aware branches. $1 server_id. + /// </summary> + public const string RecommendationsEditionFactsSql = @" +SELECT + edition, + product_version, + cpu_count, + ag_replica_role, + is_hadr_enabled +FROM server_properties +WHERE server_id = $1 +ORDER BY collection_time DESC +LIMIT 1"; + + /// <summary>7-day P95 of Total Server Memory (MB) + sample count, for the memory right-sizing checks. $1 server_id, $2 cutoff (naive UTC).</summary> + public const string RecommendationsMemoryP95Sql = @" +SELECT + PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY total_server_memory_mb) AS p95_mb, + COUNT(*) AS sample_count +FROM v_memory_stats +WHERE server_id = $1 +AND collection_time >= $2"; + + /// <summary>7-day P95 of SQL Server CPU utilization, for the VM right-sizing CPU prescription. $1 server_id, $2 cutoff (naive UTC).</summary> + public const string RecommendationsCpuP95Sql = @" +SELECT PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY sqlserver_cpu_utilization) AS p95_cpu +FROM v_cpu_utilization_stats +WHERE server_id = $1 +AND collection_time >= $2"; + + /// <summary>SQL Agent jobs that ran long at least 3 times in the window (maintenance-window efficiency). $1 server_id, $2 cutoff (naive UTC).</summary> + public const string RecommendationsMaintenanceWindowSql = @" +SELECT + job_name, + COUNT(*) AS run_count, + AVG(current_duration_seconds) AS avg_duration_seconds, + MAX(current_duration_seconds) AS max_duration_seconds, + AVG(avg_duration_seconds) AS avg_historical, + SUM(CASE WHEN is_running_long THEN 1 ELSE 0 END) AS times_ran_long +FROM v_running_jobs +WHERE server_id = $1 +AND collection_time >= $2 +AND avg_duration_seconds > 0 +GROUP BY job_name +HAVING SUM(CASE WHEN is_running_long THEN 1 ELSE 0 END) >= 3 +ORDER BY times_ran_long DESC +LIMIT 10"; + + /// <summary>Per-database aggregate read/write I/O + stall over the window (storage-tier optimization). $1 server_id, $2 cutoff (naive UTC).</summary> + public const string RecommendationsStorageTierSql = @" +SELECT + database_name, + SUM(delta_reads) AS total_reads, + SUM(delta_stall_read_ms) AS total_stall_read_ms, + SUM(delta_writes) AS total_writes, + SUM(delta_stall_write_ms) AS total_stall_write_ms +FROM v_file_io_stats +WHERE server_id = $1 +AND collection_time >= $2 +AND delta_reads > 0 +GROUP BY database_name +HAVING SUM(delta_reads) > 1000"; + + /// <summary>CPU utilization mean + standard deviation + sample count (reserved-capacity stability). $1 server_id, $2 cutoff (naive UTC).</summary> + public const string RecommendationsReservedCapacitySql = @" +SELECT + AVG(sqlserver_cpu_utilization) AS avg_cpu, + STDDEV(sqlserver_cpu_utilization) AS stddev_cpu, + COUNT(*) AS sample_count +FROM v_cpu_utilization_stats +WHERE server_id = $1 +AND collection_time >= $2 +HAVING COUNT(*) >= 24"; + + /// <summary> + /// The server's latest collected edition facts for the license audit (null when nothing collected yet), including + /// the collected Availability Group replica role (<c>Primary</c> / <c>Secondary</c> / <c>Standalone</c>) and the + /// Always On master switch (<c>is_hadr_enabled</c>) that drive the AG-aware edition/license branches. + /// </summary> + public readonly record struct EditionFacts( + string Edition, int MajorVersion, int CpuCount, string AgReplicaRole, bool IsHadrEnabled); + + /// <summary>Reads the server's latest collected edition / product-version-major / CPU count + AG role / HADR flag, or null when no server_properties row exists yet.</summary> + public async Task<EditionFacts?> GetEditionFactsAsync(int serverId, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(RecommendationsEditionFactsSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + { + return null; + } + + var edition = reader.IsDBNull(0) ? "" : reader.GetString(0); + var productVersion = reader.IsDBNull(1) ? null : reader.GetString(1); + var cpuCount = reader.IsDBNull(2) ? 0 : Convert.ToInt32(reader.GetValue(2), CultureInfo.InvariantCulture); + /* The collected AG state: ServerPropertiesCollector resolves ag_replica_role live from + sys.dm_hadr_availability_replica_states, and is_hadr_enabled is SERVERPROPERTY('IsHadrEnabled'). + Absent/NULL (non-AG platforms, Azure SQL DB, nothing collected yet) => the Standalone / feature-off + fallback — exactly Lite's own behaviour where the AG DMVs are unavailable. */ + var agReplicaRole = reader.IsDBNull(3) ? "Standalone" : reader.GetString(3); + var isHadrEnabled = !reader.IsDBNull(4) && reader.GetBoolean(4); + return new EditionFacts(edition, ParseMajorVersion(productVersion), cpuCount, agReplicaRole, isHadrEnabled); + } + + /// <summary> + /// Selects the databases running Transparent Data Encryption from a collected database-config snapshot — the + /// monitor-side stand-in for Lite's live <c>dm_db_persisted_sku_features</c> probe: <c>is_encrypted = true</c> + /// marks a TDE database. System databases (Lite's <c>database_id > 4</c>) and non-ONLINE databases are + /// excluded, mirroring Lite's probe. Ordered by name for stable output. + /// </summary> + public static List<string> SelectTdeDatabaseNames(IEnumerable<DatabaseConfigRow> configRows) => + configRows + .Where(r => r.IsEncrypted + && string.Equals(r.StateDesc, "ONLINE", StringComparison.OrdinalIgnoreCase) + && !RecommendationsSystemDatabases.Contains(r.DatabaseName)) + .Select(r => r.DatabaseName) + .OrderBy(n => n, StringComparer.OrdinalIgnoreCase) + .ToList(); + + /// <summary> + /// Matches the collected database names against Lite's dev/test name patterns (<c>%dev% / %test% / %staging% / + /// %qa%</c>, case-insensitive) excluding the system databases (Lite's <c>database_id > 4</c>). Pure so the + /// pattern logic is unit-testable without a store. + /// </summary> + public static List<string> MatchDevTestDatabases(IEnumerable<string> databaseNames) + { + static bool IsDevTest(string name) => + name.Contains("dev", StringComparison.OrdinalIgnoreCase) + || name.Contains("test", StringComparison.OrdinalIgnoreCase) + || name.Contains("staging", StringComparison.OrdinalIgnoreCase) + || name.Contains("qa", StringComparison.OrdinalIgnoreCase); + + return databaseNames + .Where(n => !RecommendationsSystemDatabases.Contains(n) && IsDevTest(n)) + .ToList(); + } + + /// <summary> + /// Builds the Edition / license audit recommendations from the collected facts (check 1 + Lite's check 10 + /// license-cost math). Returns an empty list for non-Enterprise editions. The full Enterprise branching is + /// ported verbatim from Lite — 2019+ (TDE moved to Standard) vs pre-2019 (TDE is the last Enterprise-only + /// blocker, read from <paramref name="tdeDbNames"/>), the 40%-of-budget downgrade estimate, and the + /// $5,000/core/year list-price core math — INCLUDING the AG-aware branches driven by the collected + /// <paramref name="agReplicaRole"/> + <paramref name="isHadrEnabled"/>: the Availability-Group secondary-replica + /// branch (a downgrade decision belongs to the whole group, evaluated on the primary — no savings estimate), the + /// advanced-AG Basic-AG-limitation downgrade caveat, and the AG-driven confidence downgrades. Lite gates the + /// caveat/confidence on <c>GetAdvancedAgCountAsync() > 0</c> (the non-basic <c>sys.availability_groups</c> + /// count, which Darling does not collect); on an Enterprise instance a hosted AG (role = <c>Primary</c>) is + /// effectively always advanced — Basic AGs are Standard-only — so the collected primary role + HADR flag is the + /// faithful stand-in. Standalone / feature-off keeps the standalone confidence and adds no caveat. + /// </summary> + public static List<RecommendationRow> BuildEditionAuditRecommendations( + string edition, + int majorVersion, + int cpuCount, + IReadOnlyList<string> tdeDbNames, + decimal monthlyCost, + string agReplicaRole, + bool isHadrEnabled) + { + var recommendations = new List<RecommendationRow>(); + + if (!edition.Contains("Enterprise", StringComparison.OrdinalIgnoreCase)) + { + return recommendations; + } + + var agRole = string.IsNullOrWhiteSpace(agReplicaRole) ? "Standalone" : agReplicaRole.Trim(); + var isSecondary = string.Equals(agRole, "Secondary", StringComparison.OrdinalIgnoreCase); + + /* Lite computes advancedAgCount = GetAdvancedAgCountAsync() (COUNT of sys.availability_groups WHERE + basic_features = 0) and skips the probe on a secondary. Darling's collected stand-in: an Enterprise + instance acting as an AG PRIMARY with Always On enabled — a Basic AG is a Standard-Edition-only feature, + so an Enterprise-hosted AG is effectively always advanced. Secondary gets its own branch below (Lite + forces advancedAgCount = 0 there); Standalone / feature-off => no caveat, no confidence downgrade. */ + var hasAdvancedAg = !isSecondary + && string.Equals(agRole, "Primary", StringComparison.OrdinalIgnoreCase) + && isHadrEnabled; + + /* Standard Edition offers only Basic Availability Groups, so caveat any edition-downgrade guidance when this + instance hosts an (advanced) AG: a downgrade would force the workload onto Basic AG limitations (#1085). + Lite names the advanced-AG count; Darling has only the collected role, so it names the primary replica. */ + var agDowngradeCaveat = hasAdvancedAg + ? " Note: this instance is the primary replica of an Always On Availability Group. Standard Edition " + + "supports only Basic Availability Groups, which are limited to two replicas, a single database per " + + "group, and provide no readable secondary or backups on the secondary " + + "(see https://learn.microsoft.com/en-us/sql/database-engine/availability-groups/windows/basic-availability-groups-always-on-availability-groups#limitations). " + + "Factor this into any downgrade decision." + : ""; + + if (isSecondary) + { + /* On an Availability Group secondary, a "downgrade to Standard to save money" recommendation is + misleading: every replica in an AG must run the same SQL Server edition, so the decision belongs to + the AG as a whole and must be evaluated on the primary. Emit an informational note instead and skip + the savings estimates (#980). */ + recommendations.Add(new RecommendationRow + { + Category = "Licensing", + Severity = "Low", + Confidence = "High", + Finding = "Enterprise Edition — Availability Group secondary replica", + Detail = "This instance is currently a secondary replica in an Availability Group. " + + "Every replica in an AG must run the same SQL Server edition, so edition and " + + "licensing decisions apply to the whole group and should be evaluated on the " + + "primary replica. A secondary used only for failover may also be covered by " + + "Software Assurance rather than separately licensed." + }); + } + // SQL Server 2019 (major version 15) moved TDE to Standard Edition, so on 2019+ we give + // version-appropriate guidance rather than a TDE-specific check. + else if (majorVersion >= 15) + { + recommendations.Add(new RecommendationRow + { + Category = "Licensing", + Severity = "High", + Confidence = hasAdvancedAg ? "Low" : "Medium", + Finding = "Enterprise Edition may not be required", + Detail = "Starting with SQL Server 2019, most previously Enterprise-only features " + + "(including TDE, compression, partitioning, and columnstore) are available " + + "in Standard Edition. Review whether remaining Enterprise-only features " + + "(such as Always On availability groups with multiple secondaries) are in use " + + "before considering a downgrade to Standard Edition." + agDowngradeCaveat, + EstMonthlySavings = monthlyCost > 0 ? monthlyCost * 0.40m : null + }); + } + else if (tdeDbNames.Count == 0) + { + // Pre-2019: TDE is the only commonly-used feature still restricted to Enterprise since 2016 SP1. + recommendations.Add(new RecommendationRow + { + Category = "Licensing", + Severity = "High", + Confidence = hasAdvancedAg ? "Medium" : "High", + Finding = hasAdvancedAg + ? "Enterprise Edition — review Availability Group requirements before downgrading" + : "Enterprise Edition with no Enterprise-only features detected", + Detail = "No databases use Transparent Data Encryption (TDE), the only feature " + + "still restricted to Enterprise Edition since SQL Server 2016 SP1. " + + "Review whether Standard Edition would meet workload requirements for potential license savings." + + agDowngradeCaveat, + EstMonthlySavings = monthlyCost > 0 ? monthlyCost * 0.40m : null + }); + } + else + { + recommendations.Add(new RecommendationRow + { + Category = "Licensing", + Severity = "Low", + Confidence = "High", + Finding = "TDE in use — Enterprise Edition downgrade blocker", + Detail = $"The following databases use Transparent Data Encryption: {string.Join(", ", tdeDbNames.Take(20))}" + + (tdeDbNames.Count > 20 ? $" and {tdeDbNames.Count - 20} more" : "") + + ". TDE must be removed before downgrading to Standard Edition." + }); + + // Check 10: license cost impact estimate (only when features ARE in use). + if (cpuCount > 0) + { + var monthlySavings = cpuCount * 5000m / 12m; + recommendations.Add(new RecommendationRow + { + Category = "Licensing", + Severity = "Low", + Confidence = "Low", + Finding = $"Enterprise to Standard would save ~${monthlySavings:N0}/mo at list pricing ({cpuCount} cores)", + Detail = "Based on list pricing differential of ~$5,000/core/year between Enterprise and Standard. " + + "Actual savings depend on your licensing agreement. See Enterprise feature audit for downgrade blockers.", + EstMonthlySavings = monthlySavings + }); + } + } + + return recommendations; + } + + /// <summary> + /// Builds the Compression-candidate recommendation (check 5) from the collected per-index snapshot — the + /// monitor-side stand-in for Lite's live <c>sys.partitions</c> scan: uncompressed + /// (<c>data_compression_desc = 'NONE'</c>, which excludes columnstore) rowstore indexes / heaps whose reserved + /// size is at least 1 GB. Returns null when none qualify. Severity/wording ported verbatim from Lite. + /// </summary> + public static RecommendationRow? BuildCompressionRecommendation(IEnumerable<IndexCleanupIndexInput> indexes) + { + var candidates = indexes + .Where(i => string.Equals(i.DataCompressionDesc, "NONE", StringComparison.OrdinalIgnoreCase) + && (i.ReservedMb ?? 0m) >= 1024m) + .OrderByDescending(i => i.ReservedMb ?? 0m) + .ToList(); + + if (candidates.Count == 0) + { + return null; + } + + var totalGb = candidates.Sum(c => c.ReservedMb ?? 0m) / 1024m; + var topItems = candidates.Take(5) + .Select(c => $"{c.SchemaName}.{c.TableName} ({(c.ReservedMb ?? 0m) / 1024m:N1}GB)") + .ToList(); + + return new RecommendationRow + { + Category = "Storage", + Severity = totalGb > 50 ? "High" : totalGb > 10 ? "Medium" : "Low", + Confidence = "High", + Finding = $"{candidates.Count} uncompressed object(s) >= 1GB ({totalGb:N1}GB total)", + Detail = $"Large uncompressed tables/indexes: {string.Join("; ", topItems)}" + + (candidates.Count > 5 ? $" and {candidates.Count - 5} more" : "") + + ". Consider PAGE or ROW compression to reduce storage and improve I/O." + }; + } + + /// <summary> + /// Runs every monitor-side FinOps recommendation check over the collected store and returns the consolidated + /// list sorted by severity. All reads are async I/O (they don't block the UI thread), and each check is + /// isolated in its own try/catch so a single failing read degrades to "that check absent" rather than an + /// empty tab. <paramref name="monthlyCost"/> is the per-server budget (0 → findings emit with no savings + /// estimate, mirroring Lite's <c>monthlyCost > 0 ? … : null</c>). + /// </summary> + public async Task<List<RecommendationRow>> GetRecommendationsAsync(int serverId, decimal monthlyCost, CancellationToken cancellationToken = default) + { + var recommendations = new List<RecommendationRow>(); + var memoryCutoff = DateTime.SpecifyKind(DateTime.UtcNow.AddDays(-7), DateTimeKind.Unspecified); + + // 1. Enterprise feature / license audit (collected server_properties + database_config.is_encrypted). + try + { + var facts = await GetEditionFactsAsync(serverId, cancellationToken); + if (facts is { } f) + { + var isEnterprise = f.Edition.Contains("Enterprise", StringComparison.OrdinalIgnoreCase); + + // TDE is only the deciding factor on pre-2019 Enterprise — read the config snapshot just then. + var tdeDbNames = new List<string>(); + if (isEnterprise && f.MajorVersion < 15) + { + var configRows = await GetLatestDatabaseConfigAsync(serverId, cancellationToken: cancellationToken); + tdeDbNames = SelectTdeDatabaseNames(configRows); + } + + recommendations.AddRange( + BuildEditionAuditRecommendations( + f.Edition, f.MajorVersion, f.CpuCount, tdeDbNames, monthlyCost, f.AgReplicaRole, f.IsHadrEnabled)); + } + } + catch (Exception ex) + { + Debug.WriteLine($"Recommendation check failed (Enterprise features): {ex.Message}"); + } + + // 2. CPU right-sizing (collected utilization efficiency). + try + { + var util = await GetUtilizationEfficiencyAsync(serverId, cancellationToken); + if (util != null && util.P95CpuPct < 30 && util.CpuCount > 4) + { + var targetCores = Math.Max(4, (int)(util.CpuCount * (util.P95CpuPct / 70m))); + var savingsPct = 1m - ((decimal)targetCores / util.CpuCount); + recommendations.Add(new RecommendationRow + { + Category = "Compute", + Severity = util.P95CpuPct < 15 ? "High" : "Medium", + Confidence = "Medium", + Finding = $"CPU over-provisioned ({util.CpuCount} cores, P95 = {util.P95CpuPct:N1}%)", + Detail = $"P95 CPU utilization is {util.P95CpuPct:N1}% (avg {util.AvgCpuPct:N1}%, max {util.MaxCpuPct}%) across {util.CpuCount} cores. " + + $"Consider reducing to ~{targetCores} cores.", + EstMonthlySavings = monthlyCost > 0 ? monthlyCost * savingsPct * 0.60m : null + }); + } + } + catch (Exception ex) + { + Debug.WriteLine($"Recommendation check failed (CPU right-sizing): {ex.Message}"); + } + + // 3. Memory right-sizing (7-day P95 Total Server Memory vs physical RAM). + try + { + var util = await GetUtilizationEfficiencyAsync(serverId, cancellationToken); + if (util != null && util.PhysicalMemoryMb > 8192) + { + var (p95Mb, sampleCount) = await ReadMemoryP95Async(serverId, memoryCutoff, cancellationToken); + + // Need ~16 samples to smooth a single-point anomaly without delaying the recommendation for hours. + if (sampleCount >= 16) + { + var memRatio = (decimal)p95Mb / util.PhysicalMemoryMb; + if (memRatio < 0.50m) + { + var targetMb = Math.Max(8192, p95Mb * 2); + recommendations.Add(new RecommendationRow + { + Category = "Memory", + Severity = memRatio < 0.30m ? "High" : "Medium", + Confidence = "Medium", + Finding = $"Memory over-provisioned (P95 SQL memory uses {memRatio:P0} of {util.PhysicalMemoryMb / 1024}GB RAM)", + Detail = $"P95 SQL Server memory over 7 days is {p95Mb:N0} MB out of {util.PhysicalMemoryMb:N0} MB physical RAM ({memRatio:P0} utilization). " + + $"Consider reducing to ~{targetMb / 1024}GB.", + EstMonthlySavings = monthlyCost > 0 ? monthlyCost * (1m - (decimal)targetMb / util.PhysicalMemoryMb) * 0.30m : null + }); + } + } + } + } + catch (Exception ex) + { + Debug.WriteLine($"Recommendation check failed (Memory right-sizing): {ex.Message}"); + } + + // 5. Compression candidates (collected index_object_stats snapshot; Lite's check 4 is dropped — Darling + // has native Index Analysis, so the sp_IndexCleanup-existence prompt is obsolete). + try + { + var indexes = await GetIndexCleanupInputsAsync(serverId, cancellationToken); + var rec = BuildCompressionRecommendation(indexes); + if (rec != null) + { + recommendations.Add(rec); + } + } + catch (Exception ex) + { + Debug.WriteLine($"Recommendation check failed (Compression): {ex.Message}"); + } + + // 6. Dormant database detection with cost impact (collected idle DBs + database sizes). + try + { + var idleDbs = await GetIdleDatabasesAsync(serverId, cancellationToken: cancellationToken); + if (idleDbs.Count > 0) + { + var totalSizeGb = idleDbs.Sum(d => d.TotalSizeMb) / 1024m; + var dbNames = string.Join(", ", idleDbs.Take(5).Select(d => d.DatabaseName)); + var costShare = 0m; + if (monthlyCost > 0) + { + var allDbSizes = await GetDatabaseSizeLatestAsync(serverId, cancellationToken); + var totalMb = allDbSizes.Sum(d => d.TotalSizeMb); + if (totalMb > 0) + costShare = (idleDbs.Sum(d => d.TotalSizeMb) / totalMb) * monthlyCost; + } + + recommendations.Add(new RecommendationRow + { + Category = "Databases", + Severity = idleDbs.Count >= 3 ? "High" : "Medium", + Confidence = "High", + Finding = $"{idleDbs.Count} idle database(s) consuming {totalSizeGb:N1}GB", + Detail = $"No query activity in 7 days: {dbNames}" + + (idleDbs.Count > 5 ? $" and {idleDbs.Count - 5} more" : "") + + ". Consider archiving or removing these databases.", + EstMonthlySavings = costShare > 0 ? costShare : null + }); + } + } + catch (Exception ex) + { + Debug.WriteLine($"Recommendation check failed (Dormant databases): {ex.Message}"); + } + + // 7. Dev/test workload detection (collected database name list). + try + { + var configRows = await GetLatestDatabaseConfigAsync(serverId, cancellationToken: cancellationToken); + var devDbs = MatchDevTestDatabases(configRows.Select(r => r.DatabaseName)); + if (devDbs.Count > 0) + { + recommendations.Add(new RecommendationRow + { + Category = "Environment", + Severity = "Medium", + Confidence = "Low", + Finding = $"{devDbs.Count} possible dev/test database(s) on production server", + Detail = $"Databases matching dev/test patterns: {string.Join(", ", devDbs.Take(10))}" + + (devDbs.Count > 10 ? $" and {devDbs.Count - 10} more" : "") + + ". If these are non-production workloads, consider moving to a lower-cost tier or separate server." + }); + } + } + catch (Exception ex) + { + Debug.WriteLine($"Recommendation check failed (Dev/test detection): {ex.Message}"); + } + + // 11. Maintenance window efficiency — jobs running long (collected running_jobs). + try + { + await using var command = _dataSource.CreateCommand(RecommendationsMaintenanceWindowSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = memoryCutoff }); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var jobName = reader.IsDBNull(0) ? "" : reader.GetString(0); + var avgDuration = reader.IsDBNull(2) ? 0L : Convert.ToInt64(reader.GetValue(2), CultureInfo.InvariantCulture); + var maxDuration = reader.IsDBNull(3) ? 0L : Convert.ToInt64(reader.GetValue(3), CultureInfo.InvariantCulture); + var avgHistorical = reader.IsDBNull(4) ? 0L : Convert.ToInt64(reader.GetValue(4), CultureInfo.InvariantCulture); + var timesLong = reader.IsDBNull(5) ? 0 : Convert.ToInt32(reader.GetValue(5), CultureInfo.InvariantCulture); + + recommendations.Add(new RecommendationRow + { + Category = "Maintenance", + Severity = timesLong >= 5 ? "Medium" : "Low", + Confidence = "High", + Finding = $"{jobName} ran long {timesLong} times in 7 days", + Detail = $"Average duration: {FormatDuration(avgDuration)}, max: {FormatDuration(maxDuration)}, " + + $"historical average: {FormatDuration(avgHistorical)}. " + + "Review whether this job's schedule or operations need tuning." + }); + } + } + catch (Exception ex) + { + Debug.WriteLine($"Recommendation check failed (Maintenance window): {ex.Message}"); + } + + // 12. VM right-sizing — prescriptive core/memory targets (collected 7-day P95 CPU + memory). + try + { + var vmUtil = await GetUtilizationEfficiencyAsync(serverId, cancellationToken); + if (vmUtil != null) + { + decimal p95Cpu7d = vmUtil.P95CpuPct; + int cpuCount = vmUtil.CpuCount; + int physMb = vmUtil.PhysicalMemoryMb; + + // Prefer the 7-day P95 CPU; fall back to the 24-hour P95 already on vmUtil. + try + { + await using var cpuCommand = _dataSource.CreateCommand(RecommendationsCpuP95Sql); + cpuCommand.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + cpuCommand.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = memoryCutoff }); + + await using var cpuReader = await cpuCommand.ExecuteReaderAsync(cancellationToken); + if (await cpuReader.ReadAsync(cancellationToken) && !cpuReader.IsDBNull(0)) + { + p95Cpu7d = Convert.ToDecimal(cpuReader.GetValue(0), CultureInfo.InvariantCulture); + } + } + catch (Exception ex) + { + Debug.WriteLine($"Recommendation check (VM right-sizing) 7-day CPU P95 fell back to 24h: {ex.Message}"); + } + + var (p95MemMb, memSampleCount) = await ReadMemoryP95Async(serverId, memoryCutoff, cancellationToken); + + // CPU prescription: only if >= 4 cores. + if (cpuCount >= 4) + { + int targetCores = 0; + if (p95Cpu7d < 15) + targetCores = Math.Max(2, cpuCount / 4); + else if (p95Cpu7d < 30) + targetCores = Math.Max(2, cpuCount / 2); + + if (targetCores > 0 && targetCores < cpuCount) + { + recommendations.Add(new RecommendationRow + { + Category = "Hardware", + Severity = "Medium", + Confidence = "Medium", + Finding = $"CPU: reduce from {cpuCount} to {targetCores} cores (P95 CPU {p95Cpu7d:N1}%)", + Detail = $"Over the last 7 days, P95 CPU utilization was {p95Cpu7d:N1}%. " + + $"Current allocation of {cpuCount} cores can safely be reduced to {targetCores} cores.", + EstMonthlySavings = monthlyCost > 0 + ? monthlyCost * (1m - (decimal)targetCores / cpuCount) * 0.50m + : null + }); + } + } + + // Memory prescription: needs >= 4 GB physical and a handful of samples. + if (physMb >= 4096 && physMb > 0 && memSampleCount >= 16) + { + var memRatio = (decimal)p95MemMb / physMb; + int targetMb = 0; + if (memRatio < 0.25m) + targetMb = Math.Max(4096, physMb / 4); + else if (memRatio < 0.40m) + targetMb = Math.Max(4096, physMb / 2); + + if (targetMb > 0 && targetMb < physMb) + { + recommendations.Add(new RecommendationRow + { + Category = "Hardware", + Severity = "Medium", + Confidence = "Medium", + Finding = $"Memory: reduce from {physMb / 1024}GB to {targetMb / 1024}GB (P95 SQL memory uses {memRatio:P0})", + Detail = $"P95 SQL Server memory over 7 days is {p95MemMb:N0} MB of {physMb:N0} MB physical RAM ({memRatio:P0}). " + + $"Reducing to {targetMb / 1024}GB would still leave headroom.", + EstMonthlySavings = monthlyCost > 0 + ? monthlyCost * (1m - (decimal)targetMb / physMb) * 0.30m + : null + }); + } + } + } + } + catch (Exception ex) + { + Debug.WriteLine($"Recommendation check failed (VM right-sizing): {ex.Message}"); + } + + // 13. Storage tier optimization — databases with low I/O latency (collected file_io_stats). + try + { + var lowLatencyDbs = new List<(string Name, decimal AvgReadMs, decimal AvgWriteMs)>(); + + await using (var command = _dataSource.CreateCommand(RecommendationsStorageTierSql)) + { + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = memoryCutoff }); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var dbName = reader.IsDBNull(0) ? "" : reader.GetString(0); + var totalReads = reader.IsDBNull(1) ? 0L : Convert.ToInt64(reader.GetValue(1), CultureInfo.InvariantCulture); + var totalStallRead = reader.IsDBNull(2) ? 0L : Convert.ToInt64(reader.GetValue(2), CultureInfo.InvariantCulture); + var totalWrites = reader.IsDBNull(3) ? 0L : Convert.ToInt64(reader.GetValue(3), CultureInfo.InvariantCulture); + var totalStallWrite = reader.IsDBNull(4) ? 0L : Convert.ToInt64(reader.GetValue(4), CultureInfo.InvariantCulture); + + var avgReadMs = totalReads > 0 ? (decimal)totalStallRead / totalReads : 0m; + var avgWriteMs = totalWrites > 0 ? (decimal)totalStallWrite / totalWrites : 0m; + + if (avgReadMs < 5m && avgWriteMs < 3m) + { + lowLatencyDbs.Add((dbName, avgReadMs, avgWriteMs)); + } + } + } + + if (lowLatencyDbs.Count > 0) + { + var detail = string.Join("; ", lowLatencyDbs.Take(10) + .Select(d => $"{d.Name} (read {d.AvgReadMs:N1}ms, write {d.AvgWriteMs:N1}ms)")); + recommendations.Add(new RecommendationRow + { + Category = "Storage", + Severity = "Low", + Confidence = "Medium", + Finding = $"{lowLatencyDbs.Count} database(s) with low IO latency — standard storage may suffice", + Detail = $"These databases have avg read latency under 5ms and write under 3ms over 7 days: {detail}" + + (lowLatencyDbs.Count > 10 ? $" and {lowLatencyDbs.Count - 10} more" : "") + + ". Premium/high-performance storage may not be needed." + }); + } + } + catch (Exception ex) + { + Debug.WriteLine($"Recommendation check failed (Storage tier): {ex.Message}"); + } + + // 14. Reserved capacity candidates — stable CPU utilization (collected cpu_utilization_stats). + try + { + await using var command = _dataSource.CreateCommand(RecommendationsReservedCapacitySql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = memoryCutoff }); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (await reader.ReadAsync(cancellationToken) && !reader.IsDBNull(0)) + { + var avgCpu = Convert.ToDecimal(reader.GetValue(0), CultureInfo.InvariantCulture); + var stddevCpu = reader.IsDBNull(1) ? 0m : Convert.ToDecimal(reader.GetValue(1), CultureInfo.InvariantCulture); + + if (avgCpu > 20 && stddevCpu > 0) + { + var cv = stddevCpu / avgCpu; + if (cv < 0.3m) + { + var confidence = cv < 0.15m ? "High" : "Medium"; + recommendations.Add(new RecommendationRow + { + Category = "Cloud", + Severity = "Low", + Confidence = confidence, + Finding = $"Stable CPU utilization (avg {avgCpu:N1}%, CV {cv:N2}) — reserved capacity candidate", + Detail = $"CPU utilization is consistently {avgCpu:N1}% with low variance (±{stddevCpu:N1}%). " + + "Reserved pricing typically saves 30-40% over pay-as-you-go for predictable workloads." + }); + } + } + } + } + catch (Exception ex) + { + Debug.WriteLine($"Recommendation check failed (Reserved capacity): {ex.Message}"); + } + + return recommendations.OrderBy(r => r.SeveritySort).ToList(); + } + + /// <summary>Reads the 7-day P95 Total Server Memory (MB) + sample count (shared by the memory + VM right-sizing checks).</summary> + private async Task<(int P95Mb, long SampleCount)> ReadMemoryP95Async(int serverId, DateTime cutoff, CancellationToken cancellationToken) + { + await using var command = _dataSource.CreateCommand(RecommendationsMemoryP95Sql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = cutoff }); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (await reader.ReadAsync(cancellationToken)) + { + var p95Mb = reader.IsDBNull(0) ? 0 : Convert.ToInt32(reader.GetValue(0), CultureInfo.InvariantCulture); + var sampleCount = reader.IsDBNull(1) ? 0L : Convert.ToInt64(reader.GetValue(1), CultureInfo.InvariantCulture); + return (p95Mb, sampleCount); + } + + return (0, 0L); + } + + /// <summary>Human-readable duration formatting for the maintenance-window finding (Lite's FinOps FormatDuration, verbatim).</summary> + private static string FormatDuration(long seconds) + { + if (seconds >= 3600) + return $"{seconds / 3600}h {(seconds % 3600) / 60}m {seconds % 60}s"; + if (seconds >= 60) + return $"{seconds / 60}m {seconds % 60}s"; + return $"{seconds}s"; + } +} + +/// <summary> +/// One FinOps recommendation row — a flat projection mirroring Lite's <c>RecommendationRow</c> +/// (LocalDataService.FinOps.cs): Category / Severity / Confidence / Finding / Detail / EstMonthlySavings, with a +/// formatted savings string and the severity sort key the grid orders by. Copied (not promoted) so Lite stays +/// untouched. +/// </summary> +public sealed class RecommendationRow +{ + public string Category { get; set; } = ""; + public string Severity { get; set; } = ""; + public string Confidence { get; set; } = ""; + public string Finding { get; set; } = ""; + public string Detail { get; set; } = ""; + public decimal? EstMonthlySavings { get; set; } + public string EstMonthlySavingsDisplay => EstMonthlySavings.HasValue ? $"${EstMonthlySavings.Value:N0}" : ""; + public int SeveritySort => Severity switch + { + "High" => 1, + "Medium" => 2, + "Low" => 3, + _ => 4 + }; +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Storage.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Storage.cs new file mode 100644 index 000000000..c6ff7c541 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Storage.cs @@ -0,0 +1,552 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// FinOps Database Sizes / Storage Growth (parent + object-growth heatmap drill + index detail) / +/// Optimization (idle databases + tempdb) reads — Lite's <c>LocalDataService.FinOps.StorageGrowth.cs</c>, +/// <c>.Inventory.cs</c>, and <c>.IndexObjects.cs</c> storage halves ported to Postgres. The DuckDB SQL +/// ports near-verbatim: the correlated <c>collection_time = (SELECT MAX(...))</c> latest-snapshot pattern, +/// <c>EXCEPT</c>, <c>GREATEST</c>, <c>date_trunc('day', ...)</c>, and date subtraction all run identically +/// on PG. The object-growth heatmap's two DuckDB commands (DuckDB returns one result set per command) +/// become two pooled Npgsql commands. SQL kept in <c>public const</c> so tests pin it. +/// </summary> +public sealed partial class ViewerDataService +{ + /// <summary>Latest database size snapshot per file for one server, biggest files first (a capacity view — + /// you hunt the largest files, not browse alphabetically; files can interleave across databases by design). + /// The <c>total_size_mb DESC</c> lead is display-only and safe to change: the grid is the sole order-sensitive + /// consumer — the other two callers (<c>GetUtilizationEfficiencyAsync</c>'s free-space math and the dormant-DB + /// recommendation's cost share) only <c>Sum</c> the rows, and the "Allocated vs Used" chart is a separate read + /// (<c>GetDatabaseSizeSummaryAsync</c>). $1 server_id.</summary> + public const string DatabaseSizeLatestSql = @" +SELECT + database_name, + file_type_desc, + file_name, + total_size_mb, + used_size_mb, + volume_mount_point, + volume_total_mb, + volume_free_mb, + recovery_model_desc, + auto_growth_mb, + is_percent_growth, + growth_pct, + vlf_count +FROM v_database_size_stats +WHERE server_id = $1 +AND collection_time = ( + SELECT MAX(collection_time) + FROM v_database_size_stats + WHERE server_id = $1 +) +ORDER BY total_size_mb DESC, database_name, file_type_desc, file_name"; + + public async Task<List<DatabaseSizeRow>> GetDatabaseSizeLatestAsync(int serverId, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(DatabaseSizeLatestSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + + var items = new List<DatabaseSizeRow>(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new DatabaseSizeRow + { + DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0), + FileTypeDesc = reader.IsDBNull(1) ? "" : reader.GetString(1), + FileName = reader.IsDBNull(2) ? "" : reader.GetString(2), + TotalSizeMb = reader.IsDBNull(3) ? 0m : Convert.ToDecimal(reader.GetValue(3)), + UsedSizeMb = reader.IsDBNull(4) ? null : Convert.ToDecimal(reader.GetValue(4)), + VolumeMountPoint = reader.IsDBNull(5) ? null : reader.GetString(5), + VolumeTotalMb = reader.IsDBNull(6) ? null : Convert.ToDecimal(reader.GetValue(6)), + VolumeFreeMb = reader.IsDBNull(7) ? null : Convert.ToDecimal(reader.GetValue(7)), + RecoveryModel = reader.IsDBNull(8) ? null : reader.GetString(8), + AutoGrowthMb = reader.IsDBNull(9) ? null : Convert.ToDecimal(reader.GetValue(9)), + IsPercentGrowth = reader.IsDBNull(10) ? null : reader.GetBoolean(10), + GrowthPct = reader.IsDBNull(11) ? null : Convert.ToInt32(reader.GetValue(11)), + VlfCount = reader.IsDBNull(12) ? null : Convert.ToInt32(reader.GetValue(12)) + }); + } + return items; + } + + /// <summary>Per-database allocated + used space for the Utilization size chart. $1 server_id, $2 topN.</summary> + public const string DatabaseSizeSummarySql = @" +SELECT + database_name, + SUM(total_size_mb) AS total_mb, + SUM(used_size_mb) AS used_mb +FROM v_database_size_stats +WHERE server_id = $1 +AND collection_time = ( + SELECT MAX(collection_time) FROM v_database_size_stats WHERE server_id = $1 +) +GROUP BY database_name +ORDER BY total_mb DESC +LIMIT $2"; + + public async Task<List<DatabaseSizeSummaryRow>> GetDatabaseSizeSummaryAsync(int serverId, int topN = 10, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(DatabaseSizeSummarySql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = topN }); + + var items = new List<DatabaseSizeSummaryRow>(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new DatabaseSizeSummaryRow + { + DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0), + TotalMb = reader.IsDBNull(1) ? 0m : Convert.ToDecimal(reader.GetValue(1)), + UsedMb = reader.IsDBNull(2) ? null : Convert.ToDecimal(reader.GetValue(2)) + }); + } + return items; + } + + /// <summary>Databases with zero query executions over the last N days. $1 server_id, $2 cutoff.</summary> + public const string IdleDatabasesSql = @" +WITH db_sizes AS ( + SELECT + database_name, + SUM(total_size_mb) AS total_size_mb, + COUNT(*) AS file_count + FROM v_database_size_stats + WHERE server_id = $1 + AND collection_time = ( + SELECT MAX(collection_time) + FROM v_database_size_stats + WHERE server_id = $1 + ) + GROUP BY database_name +), +db_activity AS ( + SELECT + database_name, + SUM(delta_execution_count) AS total_executions, + MAX(last_execution_time) AS last_execution + FROM v_query_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND delta_execution_count IS NOT NULL + GROUP BY database_name +) +SELECT + ds.database_name, + ds.total_size_mb, + ds.file_count, + a.last_execution +FROM db_sizes ds +LEFT JOIN db_activity a ON a.database_name = ds.database_name +WHERE COALESCE(a.total_executions, 0) = 0 +AND ds.database_name NOT IN ('master', 'model', 'msdb', 'tempdb', 'PerformanceMonitor') +ORDER BY ds.total_size_mb DESC"; + + public async Task<List<IdleDatabaseRow>> GetIdleDatabasesAsync(int serverId, int daysBack = 7, CancellationToken cancellationToken = default) + { + var cutoff = DateTime.UtcNow.AddDays(-daysBack); + + await using var command = _dataSource.CreateCommand(IdleDatabasesSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(cutoff, DateTimeKind.Unspecified) }); + + var items = new List<IdleDatabaseRow>(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new IdleDatabaseRow + { + DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0), + TotalSizeMb = reader.IsDBNull(1) ? 0m : Convert.ToDecimal(reader.GetValue(1)), + FileCount = reader.IsDBNull(2) ? 0 : Convert.ToInt32(reader.GetValue(2)), + /* last_execution_time (sys.dm_exec_query_stats) is SERVER-LOCAL in the store, not naive + UTC, so it is read verbatim like Lite — NOT through ViewerTimeHelper.ForDisplay (which + assumes naive UTC and, in Local/Server mode, would shift it). Contrast the + collection_time-derived timestamps in this port, which ARE naive UTC and correctly use ForDisplay. */ + LastExecutionTime = reader.IsDBNull(3) ? null : reader.GetDateTime(3) + }); + } + return items; + } + + /// <summary>tempdb pressure summary: latest and 24h peak values. $1 server_id, $2 cutoff.</summary> + public const string TempdbSummarySql = @" +WITH latest AS ( + SELECT + user_object_reserved_mb, + internal_object_reserved_mb, + version_store_reserved_mb, + total_reserved_mb + FROM v_tempdb_stats + WHERE server_id = $1 + ORDER BY collection_time DESC + LIMIT 1 +), +peak AS ( + SELECT + MAX(user_object_reserved_mb) AS max_user_mb, + MAX(internal_object_reserved_mb) AS max_internal_mb, + MAX(version_store_reserved_mb) AS max_version_store_mb, + MAX(total_reserved_mb) AS max_total_mb + FROM v_tempdb_stats + WHERE server_id = $1 + AND collection_time >= $2 +) +SELECT 'User Objects', l.user_object_reserved_mb, p.max_user_mb, + CASE WHEN p.max_user_mb > 1024 THEN 'High user object usage' ELSE '' END +FROM latest l CROSS JOIN peak p +UNION ALL +SELECT 'Internal Objects', l.internal_object_reserved_mb, p.max_internal_mb, + CASE WHEN p.max_internal_mb > 1024 THEN 'High internal object usage (sorts/hashes)' ELSE '' END +FROM latest l CROSS JOIN peak p +UNION ALL +SELECT 'Version Store', l.version_store_reserved_mb, p.max_version_store_mb, + CASE WHEN p.max_version_store_mb > 2048 THEN 'Version store pressure — check long-running transactions' ELSE '' END +FROM latest l CROSS JOIN peak p +UNION ALL +SELECT 'Total Reserved', l.total_reserved_mb, p.max_total_mb, '' +FROM latest l CROSS JOIN peak p"; + + public async Task<List<TempdbSummaryRow>> GetTempdbSummaryAsync(int serverId, CancellationToken cancellationToken = default) + { + var cutoff = DateTime.UtcNow.AddHours(-24); + + await using var command = _dataSource.CreateCommand(TempdbSummarySql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(cutoff, DateTimeKind.Unspecified) }); + + var items = new List<TempdbSummaryRow>(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new TempdbSummaryRow + { + Metric = reader.IsDBNull(0) ? "" : reader.GetString(0), + CurrentMb = reader.IsDBNull(1) ? 0m : Convert.ToDecimal(reader.GetValue(1)), + Peak24hMb = reader.IsDBNull(2) ? 0m : Convert.ToDecimal(reader.GetValue(2)), + Warning = reader.IsDBNull(3) ? "" : reader.GetString(3) + }); + } + return items; + } + + /// <summary>Per-database storage growth vs 7d/30d ago (Storage Growth parent grid). $1 server_id, $2 cutoff7d, $3 cutoff30d.</summary> + public const string StorageGrowthSql = @" +WITH latest AS ( + SELECT + database_name, + SUM(total_size_mb) AS current_size_mb + FROM v_database_size_stats + WHERE server_id = $1 + AND collection_time = ( + SELECT MAX(collection_time) + FROM v_database_size_stats + WHERE server_id = $1 + ) + GROUP BY database_name +), +past_7d AS ( + SELECT + database_name, + SUM(total_size_mb) AS size_mb + FROM v_database_size_stats + WHERE server_id = $1 + AND collection_time = ( + SELECT MAX(collection_time) + FROM v_database_size_stats + WHERE server_id = $1 + AND collection_time <= $2 + ) + GROUP BY database_name +), +past_30d AS ( + SELECT + database_name, + SUM(total_size_mb) AS size_mb + FROM v_database_size_stats + WHERE server_id = $1 + AND collection_time = ( + SELECT MAX(collection_time) + FROM v_database_size_stats + WHERE server_id = $1 + AND collection_time <= $3 + ) + GROUP BY database_name +) +SELECT + l.database_name, + l.current_size_mb, + p7.size_mb, + p30.size_mb, + l.current_size_mb - COALESCE(p7.size_mb, l.current_size_mb) AS growth_7d_mb, + l.current_size_mb - COALESCE(p30.size_mb, l.current_size_mb) AS growth_30d_mb, + CASE + WHEN p30.size_mb IS NOT NULL + THEN (l.current_size_mb - p30.size_mb) / 30.0 + WHEN p7.size_mb IS NOT NULL + THEN (l.current_size_mb - p7.size_mb) / 7.0 + ELSE 0 + END AS daily_growth_rate_mb, + CASE + WHEN p30.size_mb IS NOT NULL AND p30.size_mb > 0 + THEN (l.current_size_mb - p30.size_mb) * 100.0 / p30.size_mb + ELSE 0 + END AS growth_pct_30d +FROM latest l +LEFT JOIN past_7d p7 ON p7.database_name = l.database_name +LEFT JOIN past_30d p30 ON p30.database_name = l.database_name +ORDER BY growth_30d_mb DESC"; + + public async Task<List<StorageGrowthRow>> GetStorageGrowthAsync(int serverId, CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + var cutoff7d = now.AddDays(-7); + var cutoff30d = now.AddDays(-30); + + await using var command = _dataSource.CreateCommand(StorageGrowthSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(cutoff7d, DateTimeKind.Unspecified) }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(cutoff30d, DateTimeKind.Unspecified) }); + + var items = new List<StorageGrowthRow>(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new StorageGrowthRow + { + DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0), + CurrentSizeMb = reader.IsDBNull(1) ? 0m : Convert.ToDecimal(reader.GetValue(1)), + Size7dAgoMb = reader.IsDBNull(2) ? null : Convert.ToDecimal(reader.GetValue(2)), + Size30dAgoMb = reader.IsDBNull(3) ? null : Convert.ToDecimal(reader.GetValue(3)), + Growth7dMb = reader.IsDBNull(4) ? 0m : Convert.ToDecimal(reader.GetValue(4)), + Growth30dMb = reader.IsDBNull(5) ? 0m : Convert.ToDecimal(reader.GetValue(5)), + DailyGrowthRateMb = reader.IsDBNull(6) ? 0m : Convert.ToDecimal(reader.GetValue(6)), + GrowthPct30d = reader.IsDBNull(7) ? 0m : Convert.ToDecimal(reader.GetValue(7)) + }); + } + return items; + } + + /// <summary>Ranked top-N object growth summary for the object drill (#1138 §3A). $1 server_id, $2 database, $3 window start, $4 topN.</summary> + public const string ObjectGrowthSummarySql = @" +WITH bounds AS ( + SELECT MAX(collection_time) AS latest_time, MIN(collection_time) AS earliest_time + FROM v_index_object_stats + WHERE server_id = $1 AND database_name = $2 AND collection_time >= $3 +), +latest AS ( + SELECT schema_name, table_name, + SUM(reserved_mb) AS cur_reserved_mb, + SUM(used_mb) AS cur_used_mb, + MAX(total_rows) AS cur_rows, + COUNT(*) AS index_count + FROM v_index_object_stats + WHERE server_id = $1 AND database_name = $2 AND collection_time = (SELECT latest_time FROM bounds) + GROUP BY schema_name, table_name +), +earliest AS ( + SELECT schema_name, table_name, SUM(reserved_mb) AS e_reserved_mb + FROM v_index_object_stats + WHERE server_id = $1 AND database_name = $2 AND collection_time = (SELECT earliest_time FROM bounds) + GROUP BY schema_name, table_name +) +SELECT + l.schema_name, + l.table_name, + l.cur_reserved_mb, + l.cur_used_mb, + l.cur_rows, + l.index_count, + l.cur_reserved_mb - COALESCE(e.e_reserved_mb, l.cur_reserved_mb) AS growth_mb +FROM latest l +LEFT JOIN earliest e ON e.schema_name = l.schema_name AND e.table_name = l.table_name +ORDER BY growth_mb DESC, l.schema_name, l.table_name +LIMIT $4"; + + /// <summary>Daily reserved-MB series for the ranked top-N objects (heatmap). $1 server_id, $2 database, $3 window start, $4 topN.</summary> + public const string ObjectGrowthSeriesSql = @" +WITH bounds AS ( + SELECT MAX(collection_time) AS latest_time, MIN(collection_time) AS earliest_time + FROM v_index_object_stats + WHERE server_id = $1 AND database_name = $2 AND collection_time >= $3 +), +latest AS ( + SELECT schema_name, table_name, SUM(reserved_mb) AS cur_reserved_mb + FROM v_index_object_stats + WHERE server_id = $1 AND database_name = $2 AND collection_time = (SELECT latest_time FROM bounds) + GROUP BY schema_name, table_name +), +earliest AS ( + SELECT schema_name, table_name, SUM(reserved_mb) AS e_reserved_mb + FROM v_index_object_stats + WHERE server_id = $1 AND database_name = $2 AND collection_time = (SELECT earliest_time FROM bounds) + GROUP BY schema_name, table_name +), +ranked AS ( + SELECT l.schema_name, l.table_name, + l.cur_reserved_mb - COALESCE(e.e_reserved_mb, l.cur_reserved_mb) AS growth_mb + FROM latest l + LEFT JOIN earliest e ON e.schema_name = l.schema_name AND e.table_name = l.table_name + ORDER BY growth_mb DESC, l.schema_name, l.table_name + LIMIT $4 +) +SELECT + ios.schema_name, + ios.table_name, + date_trunc('day', ios.collection_time) AS the_day, + SUM(ios.reserved_mb) AS reserved_mb +FROM v_index_object_stats ios +JOIN ranked r ON r.schema_name = ios.schema_name AND r.table_name = ios.table_name +WHERE ios.server_id = $1 AND ios.database_name = $2 AND ios.collection_time >= $3 +GROUP BY ios.schema_name, ios.table_name, date_trunc('day', ios.collection_time) +ORDER BY ios.schema_name, ios.table_name, the_day"; + + /// <summary> + /// Object-growth heatmap data for a single database (#1138 §3A): the ranked top-N summary rows and the + /// long-form daily reserved-MB samples (pivoted to a matrix by FinOpsHeatmapBuilder). Two pooled + /// commands (mirrors Lite's two-command split for DuckDB's one-result-set-per-command limit). + /// </summary> + public async Task<(List<ObjectSizeGrowthRow> Objects, List<FinOpsObjectDaySample> Samples)> GetObjectGrowthHeatmapDataAsync( + int serverId, string databaseName, int daysBack = 30, int topN = 20, CancellationToken cancellationToken = default) + { + var windowStart = DateTime.SpecifyKind(DateTime.UtcNow.AddDays(-daysBack), DateTimeKind.Unspecified); + + var objects = new List<ObjectSizeGrowthRow>(); + var samples = new List<FinOpsObjectDaySample>(); + + await using (var command = _dataSource.CreateCommand(ObjectGrowthSummarySql)) + { + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = databaseName }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = windowStart }); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = topN }); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var current = reader.IsDBNull(2) ? 0m : Convert.ToDecimal(reader.GetValue(2)); + var growth = reader.IsDBNull(6) ? 0m : Convert.ToDecimal(reader.GetValue(6)); + var earlier = current - growth; + objects.Add(new ObjectSizeGrowthRow + { + DatabaseName = databaseName, + SchemaName = reader.IsDBNull(0) ? "" : reader.GetString(0), + TableName = reader.IsDBNull(1) ? "" : reader.GetString(1), + CurrentReservedMb = current, + CurrentUsedMb = reader.IsDBNull(3) ? 0m : Convert.ToDecimal(reader.GetValue(3)), + TotalRows = reader.IsDBNull(4) ? 0L : Convert.ToInt64(reader.GetValue(4)), + IndexCount = reader.IsDBNull(5) ? 0 : Convert.ToInt32(reader.GetValue(5)), + Growth30dMb = growth, + DailyGrowthRateMb = daysBack > 0 ? growth / daysBack : 0m, + GrowthPct30d = earlier > 0 ? growth * 100m / earlier : 0m + }); + } + } + + await using (var command = _dataSource.CreateCommand(ObjectGrowthSeriesSql)) + { + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = databaseName }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = windowStart }); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = topN }); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var schema = reader.IsDBNull(0) ? "" : reader.GetString(0); + var table = reader.IsDBNull(1) ? "" : reader.GetString(1); + var day = reader.IsDBNull(2) ? DateTime.MinValue : reader.GetDateTime(2); + var reservedMb = reader.IsDBNull(3) ? 0d : Convert.ToDouble(reader.GetValue(3)); + samples.Add(new FinOpsObjectDaySample($"{schema}.{table}", day, reservedMb)); + } + } + + return (objects, samples); + } + + /// <summary>Per-index size + usage for one object at its database's latest snapshot (Storage Growth index drill).</summary> + public const string ObjectIndexDetailSql = @" +SELECT + database_name, + schema_name, + table_name, + index_name, + index_type_desc, + index_id, + reserved_mb, + total_rows, + COALESCE(user_seeks, 0) AS user_seeks, + COALESCE(user_scans, 0) AS user_scans, + COALESCE(user_lookups, 0) AS user_lookups, + COALESCE(user_seeks, 0) + COALESCE(user_scans, 0) + COALESCE(user_lookups, 0) AS total_reads, + COALESCE(user_updates, 0) AS user_updates, + GREATEST(last_user_seek, last_user_scan, last_user_lookup, last_user_update) AS last_user_access, + CASE + WHEN COALESCE(user_seeks, 0) + COALESCE(user_scans, 0) + COALESCE(user_lookups, 0) = 0 + AND COALESCE(user_updates, 0) = 0 THEN 'Unused' + WHEN COALESCE(user_seeks, 0) + COALESCE(user_scans, 0) + COALESCE(user_lookups, 0) = 0 + AND COALESCE(user_updates, 0) > 0 THEN 'Write-only' + ELSE 'Active' + END AS classification +FROM v_index_object_stats +WHERE server_id = $1 +AND database_name = $2 +AND schema_name = $3 +AND table_name = $4 +AND collection_time = ( + SELECT MAX(collection_time) FROM v_index_object_stats WHERE server_id = $1 AND database_name = $2) +ORDER BY index_id"; + + public async Task<List<IndexUsageRow>> GetObjectIndexDetailAsync(int serverId, string databaseName, string schemaName, string tableName, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(ObjectIndexDetailSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = databaseName }); + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = schemaName }); + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = tableName }); + + var items = new List<IndexUsageRow>(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new IndexUsageRow + { + DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0), + SchemaName = reader.IsDBNull(1) ? "" : reader.GetString(1), + TableName = reader.IsDBNull(2) ? "" : reader.GetString(2), + IndexName = reader.IsDBNull(3) ? "(heap)" : reader.GetString(3), + IndexTypeDesc = reader.IsDBNull(4) ? "" : reader.GetString(4), + IndexId = reader.IsDBNull(5) ? 0 : Convert.ToInt32(reader.GetValue(5)), + ReservedMb = reader.IsDBNull(6) ? 0m : Convert.ToDecimal(reader.GetValue(6)), + TotalRows = reader.IsDBNull(7) ? 0L : Convert.ToInt64(reader.GetValue(7)), + UserSeeks = reader.IsDBNull(8) ? 0L : Convert.ToInt64(reader.GetValue(8)), + UserScans = reader.IsDBNull(9) ? 0L : Convert.ToInt64(reader.GetValue(9)), + UserLookups = reader.IsDBNull(10) ? 0L : Convert.ToInt64(reader.GetValue(10)), + TotalReads = reader.IsDBNull(11) ? 0L : Convert.ToInt64(reader.GetValue(11)), + UserUpdates = reader.IsDBNull(12) ? 0L : Convert.ToInt64(reader.GetValue(12)), + /* last_user_seek/scan/lookup/update (sys.dm_db_index_usage_stats) are SERVER-LOCAL in the + store, not naive UTC, so this GREATEST is read verbatim like Lite — NOT through + ViewerTimeHelper.ForDisplay (which assumes naive UTC and, in Local/Server mode, would shift it). */ + LastUserAccess = reader.IsDBNull(13) ? null : reader.GetDateTime(13), + Classification = reader.IsDBNull(14) ? "" : reader.GetString(14) + }); + } + return items; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs new file mode 100644 index 000000000..105c3f13e --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs @@ -0,0 +1,236 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// FinOps Utilization sub-tab reads — Lite's <c>LocalDataService.FinOps.Utilization.cs</c> ported to +/// Postgres. The DuckDB→PG rewrite is near-verbatim (positional <c>$1/$2</c> params, PERCENTILE_CONT, +/// <c>CAST(... AS DECIMAL(p,s))</c>, and <c>LEFT JOIN ... ON true</c> all run identically on PG); the ONE +/// substantive change is that Lite's <c>server_info</c> CTE reads <c>v_server_properties</c>, which Darling +/// has no view for, so it reads the collected <c>server_properties</c> base table directly (bare name +/// resolves through the connection's Search Path). SQL kept in <c>public const</c> so tests pin it. +/// </summary> +public sealed partial class ViewerDataService +{ + /// <summary> + /// Utilization efficiency from cpu_utilization_stats + memory_stats (last 24h) + latest + /// server_properties for the core count. $1 server_id, $2 cutoff (naive UTC). + /// </summary> + public const string UtilizationEfficiencySql = @" +WITH cpu_stats AS ( + SELECT + AVG(CAST(sqlserver_cpu_utilization AS DECIMAL(5,2))) AS avg_cpu_pct, + MAX(sqlserver_cpu_utilization) AS max_cpu_pct, + PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY sqlserver_cpu_utilization) AS p95_cpu_pct, + COUNT(*) AS cpu_samples + FROM v_cpu_utilization_stats + WHERE server_id = $1 + AND collection_time >= $2 +), +mem_latest AS ( + SELECT + total_server_memory_mb, + target_server_memory_mb, + total_physical_memory_mb, + buffer_pool_mb, + max_workers_count, + current_workers_count, + CAST(total_server_memory_mb AS DECIMAL(10,2)) / NULLIF(target_server_memory_mb, 0) AS memory_ratio + FROM v_memory_stats + WHERE server_id = $1 + ORDER BY collection_time DESC + LIMIT 1 +), +server_info AS ( + SELECT COALESCE(vcore_count, cpu_count) AS cpu_count + FROM server_properties + WHERE server_id = $1 + ORDER BY collection_time DESC + LIMIT 1 +) +SELECT + c.avg_cpu_pct, + c.max_cpu_pct, + c.p95_cpu_pct, + c.cpu_samples, + m.total_server_memory_mb, + m.target_server_memory_mb, + m.total_physical_memory_mb, + m.buffer_pool_mb, + m.memory_ratio, + m.max_workers_count, + m.current_workers_count, + s.cpu_count +FROM cpu_stats c +CROSS JOIN mem_latest m +LEFT JOIN server_info s ON true"; + + public async Task<UtilizationEfficiencyRow?> GetUtilizationEfficiencyAsync(int serverId, CancellationToken cancellationToken = default) + { + var cutoff = DateTime.UtcNow.AddHours(-24); + + await using var command = _dataSource.CreateCommand(UtilizationEfficiencySql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(cutoff, DateTimeKind.Unspecified) }); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) return null; + + var avgCpu = reader.IsDBNull(0) ? 0m : Convert.ToDecimal(reader.GetValue(0)); + var maxCpu = reader.IsDBNull(1) ? 0 : Convert.ToInt32(reader.GetValue(1)); + var p95Cpu = reader.IsDBNull(2) ? 0m : Convert.ToDecimal(reader.GetValue(2)); + var memRatio = reader.IsDBNull(8) ? 0m : Convert.ToDecimal(reader.GetValue(8)); + + var status = "RIGHT_SIZED"; + if (avgCpu < 15 && maxCpu < 40 && memRatio < 0.5m) + status = "OVER_PROVISIONED"; + else if (p95Cpu > 85 || memRatio > 0.95m) + status = "UNDER_PROVISIONED"; + + return new UtilizationEfficiencyRow + { + AvgCpuPct = avgCpu, + MaxCpuPct = maxCpu, + P95CpuPct = p95Cpu, + CpuSamples = reader.IsDBNull(3) ? 0L : Convert.ToInt64(reader.GetValue(3)), + TotalMemoryMb = reader.IsDBNull(4) ? 0 : Convert.ToInt32(reader.GetValue(4)), + TargetMemoryMb = reader.IsDBNull(5) ? 0 : Convert.ToInt32(reader.GetValue(5)), + PhysicalMemoryMb = reader.IsDBNull(6) ? 0 : Convert.ToInt32(reader.GetValue(6)), + BufferPoolMb = reader.IsDBNull(7) ? 0 : Convert.ToInt32(reader.GetValue(7)), + MemoryRatio = memRatio, + ProvisioningStatus = status, + MaxWorkersCount = reader.IsDBNull(9) ? 0 : Convert.ToInt32(reader.GetValue(9)), + CurrentWorkersCount = reader.IsDBNull(10) ? 0 : Convert.ToInt32(reader.GetValue(10)), + CpuCount = reader.IsDBNull(11) ? 0 : Convert.ToInt32(reader.GetValue(11)) + }; + } + + /// <summary>7-day daily provisioning classification trend. $1 server_id, $2 cutoff (naive UTC).</summary> + public const string ProvisioningTrendSql = @" +WITH daily_cpu AS ( + SELECT + CAST(collection_time AS DATE) AS day, + AVG(CAST(sqlserver_cpu_utilization AS DECIMAL(5,2))) AS avg_cpu_pct, + MAX(sqlserver_cpu_utilization) AS max_cpu_pct, + PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY sqlserver_cpu_utilization) AS p95_cpu_pct + FROM v_cpu_utilization_stats + WHERE server_id = $1 + AND collection_time >= $2 + GROUP BY CAST(collection_time AS DATE) +), +daily_mem AS ( + SELECT + CAST(collection_time AS DATE) AS day, + AVG(CAST(total_server_memory_mb AS DECIMAL(10,2)) / NULLIF(target_server_memory_mb, 0)) AS avg_memory_ratio + FROM v_memory_stats + WHERE server_id = $1 + AND collection_time >= $2 + GROUP BY CAST(collection_time AS DATE) +) +SELECT + c.day, + c.avg_cpu_pct, + c.max_cpu_pct, + c.p95_cpu_pct, + COALESCE(m.avg_memory_ratio, 0) +FROM daily_cpu c +LEFT JOIN daily_mem m ON m.day = c.day +ORDER BY c.day"; + + public async Task<List<ProvisioningTrendRow>> GetProvisioningTrendAsync(int serverId, CancellationToken cancellationToken = default) + { + var cutoff = DateTime.UtcNow.AddDays(-7); + + await using var command = _dataSource.CreateCommand(ProvisioningTrendSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(cutoff, DateTimeKind.Unspecified) }); + + var items = new List<ProvisioningTrendRow>(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var avgCpu = reader.IsDBNull(1) ? 0m : Convert.ToDecimal(reader.GetValue(1)); + var maxCpu = reader.IsDBNull(2) ? 0 : Convert.ToInt32(reader.GetValue(2)); + var p95Cpu = reader.IsDBNull(3) ? 0m : Convert.ToDecimal(reader.GetValue(3)); + var memRatio = reader.IsDBNull(4) ? 0m : Convert.ToDecimal(reader.GetValue(4)); + + var status = "RIGHT_SIZED"; + if (avgCpu < 15 && maxCpu < 40 && memRatio < 0.5m) + status = "OVER_PROVISIONED"; + else if (p95Cpu > 85 || memRatio > 0.95m) + status = "UNDER_PROVISIONED"; + + items.Add(new ProvisioningTrendRow + { + Day = reader.GetDateTime(0), + AvgCpuPct = avgCpu, + MaxCpuPct = maxCpu, + P95CpuPct = p95Cpu, + MemoryRatio = memRatio, + Status = status + }); + } + return items; + } + + /// <summary> + /// Memory-grant vs used efficiency per day from resource-semaphore snapshots (Optimization sub-tab). + /// $1 server_id, $2 cutoff (naive UTC). + /// </summary> + public const string MemoryGrantEfficiencySql = @" +SELECT + CAST(collection_time AS DATE) AS day, + AVG(granted_memory_mb) AS avg_granted_mb, + AVG(used_memory_mb) AS avg_used_mb, + CAST(AVG(used_memory_mb) * 100.0 / NULLIF(AVG(granted_memory_mb), 0) AS DECIMAL(5,1)) AS efficiency_pct, + MAX(granted_memory_mb) AS peak_granted_mb, + SUM(grantee_count) AS total_grantees, + SUM(waiter_count) AS total_waiters, + SUM(timeout_error_count_delta) AS timeout_errors, + SUM(forced_grant_count_delta) AS forced_grants +FROM v_memory_grant_stats +WHERE server_id = $1 +AND collection_time >= $2 +GROUP BY CAST(collection_time AS DATE) +ORDER BY CAST(collection_time AS DATE)"; + + public async Task<List<MemoryGrantEfficiencyRow>> GetMemoryGrantEfficiencyAsync(int serverId, int hoursBack = 24, CancellationToken cancellationToken = default) + { + var cutoff = DateTime.UtcNow.AddHours(-hoursBack); + + await using var command = _dataSource.CreateCommand(MemoryGrantEfficiencySql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(cutoff, DateTimeKind.Unspecified) }); + + var items = new List<MemoryGrantEfficiencyRow>(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new MemoryGrantEfficiencyRow + { + Day = reader.GetDateTime(0), + AvgGrantedMb = reader.IsDBNull(1) ? 0m : Convert.ToDecimal(reader.GetValue(1)), + AvgUsedMb = reader.IsDBNull(2) ? 0m : Convert.ToDecimal(reader.GetValue(2)), + EfficiencyPct = reader.IsDBNull(3) ? 0m : Convert.ToDecimal(reader.GetValue(3)), + PeakGrantedMb = reader.IsDBNull(4) ? 0m : Convert.ToDecimal(reader.GetValue(4)), + TotalGrantees = reader.IsDBNull(5) ? 0 : Convert.ToInt64(reader.GetValue(5)), + TotalWaiters = reader.IsDBNull(6) ? 0 : Convert.ToInt64(reader.GetValue(6)), + TimeoutErrors = reader.IsDBNull(7) ? 0 : Convert.ToInt64(reader.GetValue(7)), + ForcedGrants = reader.IsDBNull(8) ? 0 : Convert.ToInt64(reader.GetValue(8)) + }); + } + return items; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs new file mode 100644 index 000000000..2ca27942e --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs @@ -0,0 +1,551 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// FinOps Database Resources / Application Connections / Optimization (wait + expensive) / High Impact +/// reads — Lite's <c>LocalDataService.FinOps.Workload.cs</c> ported to Postgres. The DuckDB SQL ports +/// near-verbatim: positional params, <c>FULL JOIN</c>, <c>NULLIF</c>/<c>COALESCE</c>, <c>ILIKE</c>, the +/// repeated wait-category CASE, and the correlated sample-text subqueries all run identically on PG. The +/// high-impact query reads the <c>query_stats</c> base table (as Lite does) for its correlated subqueries; +/// every other read uses the <c>v_*</c> passthrough views. SQL kept in <c>public const</c> so tests pin it. +/// </summary> +public sealed partial class ViewerDataService +{ + /// <summary>Per-database resource usage from query_stats + file_io_stats deltas. $1 server_id, $2 cutoff.</summary> + public const string DatabaseResourceUsageSql = @" +WITH workload AS ( + SELECT + database_name, + SUM(delta_worker_time) / 1000.0 AS cpu_time_ms, + SUM(delta_logical_reads) AS logical_reads, + SUM(delta_physical_reads) AS physical_reads, + SUM(delta_logical_writes) AS logical_writes, + SUM(delta_execution_count) AS execution_count + FROM v_query_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND delta_worker_time IS NOT NULL + GROUP BY database_name +), +io AS ( + SELECT + database_name, + SUM(delta_read_bytes) / 1048576.0 AS io_read_mb, + SUM(delta_write_bytes) / 1048576.0 AS io_write_mb, + SUM(delta_stall_read_ms + delta_stall_write_ms) AS io_stall_ms + FROM v_file_io_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND delta_read_bytes IS NOT NULL + GROUP BY database_name +), +combined AS ( + SELECT + COALESCE(w.database_name, i.database_name) AS database_name, + COALESCE(w.cpu_time_ms, 0) AS cpu_time_ms, + COALESCE(w.logical_reads, 0) AS logical_reads, + COALESCE(w.physical_reads, 0) AS physical_reads, + COALESCE(w.logical_writes, 0) AS logical_writes, + COALESCE(w.execution_count, 0) AS execution_count, + COALESCE(i.io_read_mb, 0) AS io_read_mb, + COALESCE(i.io_write_mb, 0) AS io_write_mb, + COALESCE(i.io_stall_ms, 0) AS io_stall_ms + FROM workload w + FULL JOIN io i ON i.database_name = w.database_name +), +totals AS ( + SELECT + NULLIF(SUM(cpu_time_ms), 0) AS total_cpu, + NULLIF(SUM(io_read_mb + io_write_mb), 0) AS total_io + FROM combined +) +SELECT + c.database_name, + c.cpu_time_ms, + c.logical_reads, + c.physical_reads, + c.logical_writes, + c.execution_count, + CAST(c.io_read_mb AS DECIMAL(19,2)), + CAST(c.io_write_mb AS DECIMAL(19,2)), + c.io_stall_ms, + CAST(c.cpu_time_ms * 100.0 / t.total_cpu AS DECIMAL(5,2)) AS pct_cpu_share, + CAST((c.io_read_mb + c.io_write_mb) * 100.0 / t.total_io AS DECIMAL(5,2)) AS pct_io_share +FROM combined c +CROSS JOIN totals t +WHERE c.database_name IS NOT NULL +ORDER BY c.cpu_time_ms DESC"; + + public async Task<List<DatabaseResourceUsageRow>> GetDatabaseResourceUsageAsync(int serverId, int hoursBack = 24, CancellationToken cancellationToken = default) + { + var cutoff = DateTime.UtcNow.AddHours(-hoursBack); + + await using var command = _dataSource.CreateCommand(DatabaseResourceUsageSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(cutoff, DateTimeKind.Unspecified) }); + + var items = new List<DatabaseResourceUsageRow>(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new DatabaseResourceUsageRow + { + DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0), + CpuTimeMs = reader.IsDBNull(1) ? 0L : Convert.ToInt64(reader.GetValue(1)), + LogicalReads = reader.IsDBNull(2) ? 0L : Convert.ToInt64(reader.GetValue(2)), + PhysicalReads = reader.IsDBNull(3) ? 0L : Convert.ToInt64(reader.GetValue(3)), + LogicalWrites = reader.IsDBNull(4) ? 0L : Convert.ToInt64(reader.GetValue(4)), + ExecutionCount = reader.IsDBNull(5) ? 0L : Convert.ToInt64(reader.GetValue(5)), + IoReadMb = reader.IsDBNull(6) ? 0m : Convert.ToDecimal(reader.GetValue(6)), + IoWriteMb = reader.IsDBNull(7) ? 0m : Convert.ToDecimal(reader.GetValue(7)), + IoStallMs = reader.IsDBNull(8) ? 0L : Convert.ToInt64(reader.GetValue(8)), + PctCpuShare = reader.IsDBNull(9) ? 0m : Convert.ToDecimal(reader.GetValue(9)), + PctIoShare = reader.IsDBNull(10) ? 0m : Convert.ToDecimal(reader.GetValue(10)) + }); + } + return items; + } + + /// <summary> + /// Per-application connection counts plus the collected per-app resource + session-status metrics from + /// session_stats (last 24h). AVG/MAX over the window for connection/running/sleeping/dormant counts and + /// CPU/reads/writes/logical-reads (the resource columns are nullable, so AVG/MAX yield NULL until populated). + /// $1 server_id, $2 cutoff. + /// </summary> + public const string ApplicationConnectionsSql = @" +SELECT + program_name, + CAST(AVG(connection_count) AS INTEGER) AS avg_connections, + MAX(connection_count) AS max_connections, + CAST(AVG(running_count) AS INTEGER) AS avg_running, + MAX(running_count) AS max_running, + CAST(AVG(sleeping_count) AS INTEGER) AS avg_sleeping, + MAX(sleeping_count) AS max_sleeping, + CAST(AVG(dormant_count) AS INTEGER) AS avg_dormant, + MAX(dormant_count) AS max_dormant, + CAST(AVG(total_cpu_time_ms) AS BIGINT) AS avg_cpu_time_ms, + MAX(total_cpu_time_ms) AS max_cpu_time_ms, + CAST(AVG(total_reads) AS BIGINT) AS avg_reads, + MAX(total_reads) AS max_reads, + CAST(AVG(total_writes) AS BIGINT) AS avg_writes, + MAX(total_writes) AS max_writes, + CAST(AVG(total_logical_reads) AS BIGINT) AS avg_logical_reads, + MAX(total_logical_reads) AS max_logical_reads, + COUNT(*) AS sample_count, + MIN(collection_time) AS first_seen, + MAX(collection_time) AS last_seen +FROM v_session_stats +WHERE server_id = $1 +AND collection_time >= $2 +GROUP BY program_name +ORDER BY max_connections DESC"; + + public async Task<List<ApplicationConnectionRow>> GetApplicationConnectionsAsync(int serverId, CancellationToken cancellationToken = default) + { + var cutoff = DateTime.UtcNow.AddHours(-24); + + await using var command = _dataSource.CreateCommand(ApplicationConnectionsSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(cutoff, DateTimeKind.Unspecified) }); + + var items = new List<ApplicationConnectionRow>(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new ApplicationConnectionRow + { + ApplicationName = reader.IsDBNull(0) ? "" : reader.GetString(0), + AvgConnections = reader.IsDBNull(1) ? 0 : Convert.ToInt32(reader.GetValue(1)), + MaxConnections = reader.IsDBNull(2) ? 0 : Convert.ToInt32(reader.GetValue(2)), + AvgRunning = reader.IsDBNull(3) ? 0 : Convert.ToInt32(reader.GetValue(3)), + MaxRunning = reader.IsDBNull(4) ? 0 : Convert.ToInt32(reader.GetValue(4)), + AvgSleeping = reader.IsDBNull(5) ? 0 : Convert.ToInt32(reader.GetValue(5)), + MaxSleeping = reader.IsDBNull(6) ? 0 : Convert.ToInt32(reader.GetValue(6)), + AvgDormant = reader.IsDBNull(7) ? 0 : Convert.ToInt32(reader.GetValue(7)), + MaxDormant = reader.IsDBNull(8) ? 0 : Convert.ToInt32(reader.GetValue(8)), + AvgCpuTimeMs = reader.IsDBNull(9) ? 0L : Convert.ToInt64(reader.GetValue(9)), + MaxCpuTimeMs = reader.IsDBNull(10) ? 0L : Convert.ToInt64(reader.GetValue(10)), + AvgReads = reader.IsDBNull(11) ? 0L : Convert.ToInt64(reader.GetValue(11)), + MaxReads = reader.IsDBNull(12) ? 0L : Convert.ToInt64(reader.GetValue(12)), + AvgWrites = reader.IsDBNull(13) ? 0L : Convert.ToInt64(reader.GetValue(13)), + MaxWrites = reader.IsDBNull(14) ? 0L : Convert.ToInt64(reader.GetValue(14)), + AvgLogicalReads = reader.IsDBNull(15) ? 0L : Convert.ToInt64(reader.GetValue(15)), + MaxLogicalReads = reader.IsDBNull(16) ? 0L : Convert.ToInt64(reader.GetValue(16)), + SampleCount = reader.IsDBNull(17) ? 0 : Convert.ToInt64(reader.GetValue(17)), + FirstSeenLocal = ViewerTimeHelper.ForDisplay(reader.GetDateTime(18)), + LastSeenLocal = ViewerTimeHelper.ForDisplay(reader.GetDateTime(19)) + }); + } + return items; + } + + /// <summary>Top-N databases by total CPU for the Utilization summary. $1 server_id, $2 cutoff, $3 topN.</summary> + public const string TopResourceConsumersByTotalSql = @" +WITH workload AS ( + SELECT + database_name, + SUM(delta_worker_time) / 1000.0 AS cpu_time_ms, + SUM(delta_execution_count) AS execution_count + FROM v_query_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND delta_worker_time IS NOT NULL + GROUP BY database_name +), +io AS ( + SELECT + database_name, + SUM(delta_read_bytes + delta_write_bytes) / 1048576.0 AS io_total_mb + FROM v_file_io_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND delta_read_bytes IS NOT NULL + GROUP BY database_name +), +combined AS ( + SELECT + COALESCE(w.database_name, i.database_name) AS database_name, + COALESCE(w.cpu_time_ms, 0) AS cpu_time_ms, + COALESCE(w.execution_count, 0) AS execution_count, + COALESCE(i.io_total_mb, 0) AS io_total_mb + FROM workload w + FULL JOIN io i ON i.database_name = w.database_name +), +totals AS ( + SELECT + NULLIF(SUM(cpu_time_ms), 0) AS total_cpu, + NULLIF(SUM(io_total_mb), 0) AS total_io + FROM combined +) +SELECT + c.database_name, + c.cpu_time_ms, + c.execution_count, + CAST(c.io_total_mb AS DECIMAL(19,2)), + CAST(c.cpu_time_ms * 100.0 / t.total_cpu AS DECIMAL(5,2)), + CAST(c.io_total_mb * 100.0 / t.total_io AS DECIMAL(5,2)) +FROM combined c +CROSS JOIN totals t +WHERE c.database_name IS NOT NULL +ORDER BY c.cpu_time_ms DESC +LIMIT $3"; + + public async Task<List<TopResourceConsumerRow>> GetTopResourceConsumersByTotalAsync(int serverId, int hoursBack = 24, int topN = 5, CancellationToken cancellationToken = default) + { + var cutoff = DateTime.UtcNow.AddHours(-hoursBack); + + await using var command = _dataSource.CreateCommand(TopResourceConsumersByTotalSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(cutoff, DateTimeKind.Unspecified) }); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = topN }); + + var items = new List<TopResourceConsumerRow>(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new TopResourceConsumerRow + { + DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0), + CpuTimeMs = reader.IsDBNull(1) ? 0 : Convert.ToInt64(reader.GetValue(1)), + ExecutionCount = reader.IsDBNull(2) ? 0 : Convert.ToInt64(reader.GetValue(2)), + IoTotalMb = reader.IsDBNull(3) ? 0m : Convert.ToDecimal(reader.GetValue(3)), + PctCpu = reader.IsDBNull(4) ? 0m : Convert.ToDecimal(reader.GetValue(4)), + PctIo = reader.IsDBNull(5) ? 0m : Convert.ToDecimal(reader.GetValue(5)) + }); + } + return items; + } + + /// <summary>Top-N databases by average CPU per execution for the Utilization summary. $1 server_id, $2 cutoff, $3 topN.</summary> + public const string TopResourceConsumersByAvgSql = @" +WITH workload AS ( + SELECT + database_name, + SUM(delta_worker_time) / 1000.0 AS cpu_time_ms, + SUM(delta_execution_count) AS execution_count + FROM v_query_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND delta_worker_time IS NOT NULL + GROUP BY database_name + HAVING SUM(delta_execution_count) > 0 +), +io AS ( + SELECT + database_name, + SUM(delta_read_bytes + delta_write_bytes) / 1048576.0 AS io_total_mb + FROM v_file_io_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND delta_read_bytes IS NOT NULL + GROUP BY database_name +) +SELECT + w.database_name, + CAST(w.cpu_time_ms * 1.0 / w.execution_count AS DECIMAL(19,2)) AS avg_cpu_ms, + w.execution_count, + CAST(COALESCE(i.io_total_mb, 0) AS DECIMAL(19,2)), + w.cpu_time_ms, + CAST(COALESCE(i.io_total_mb, 0) * 1.0 / w.execution_count AS DECIMAL(19,4)) AS avg_io_mb +FROM workload w +LEFT JOIN io i ON i.database_name = w.database_name +ORDER BY avg_cpu_ms DESC +LIMIT $3"; + + public async Task<List<TopResourceConsumerRow>> GetTopResourceConsumersByAvgAsync(int serverId, int hoursBack = 24, int topN = 5, CancellationToken cancellationToken = default) + { + var cutoff = DateTime.UtcNow.AddHours(-hoursBack); + + await using var command = _dataSource.CreateCommand(TopResourceConsumersByAvgSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(cutoff, DateTimeKind.Unspecified) }); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = topN }); + + var items = new List<TopResourceConsumerRow>(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new TopResourceConsumerRow + { + DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0), + CpuTimeMs = reader.IsDBNull(1) ? 0 : Convert.ToInt64(reader.GetValue(1)), + ExecutionCount = reader.IsDBNull(2) ? 0 : Convert.ToInt64(reader.GetValue(2)), + IoTotalMb = reader.IsDBNull(3) ? 0m : Convert.ToDecimal(reader.GetValue(3)), + TotalCpuTimeMs = reader.IsDBNull(4) ? 0 : Convert.ToInt64(reader.GetValue(4)), + AvgIoMb = reader.IsDBNull(5) ? 0m : Convert.ToDecimal(reader.GetValue(5)) + }); + } + return items; + } + + /// <summary>Wait stats grouped by cost category over the window. $1 server_id, $2 cutoff.</summary> + public const string WaitCategorySummarySql = @" +WITH categorized AS ( + SELECT + CASE + WHEN wait_type IN ('SOS_SCHEDULER_YIELD', 'CXPACKET', 'CXCONSUMER', 'CXSYNC_PORT', 'CXSYNC_CONSUMER') THEN 'CPU' + WHEN wait_type ILIKE 'PAGEIOLATCH%' + OR wait_type IN ('WRITELOG', 'IO_COMPLETION', 'ASYNC_IO_COMPLETION') THEN 'Storage' + WHEN wait_type IN ('RESOURCE_SEMAPHORE', 'RESOURCE_SEMAPHORE_QUERY_COMPILE', 'CMEMTHREAD') THEN 'Memory' + WHEN wait_type = 'ASYNC_NETWORK_IO' THEN 'Network' + WHEN wait_type ILIKE 'LCK_M_%' THEN 'Locks' + ELSE 'Other' + END AS category, + wait_type, + SUM(delta_wait_time_ms) AS wait_time_ms, + SUM(delta_waiting_tasks) AS waiting_tasks + FROM v_wait_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND delta_wait_time_ms IS NOT NULL + AND delta_wait_time_ms > 0 + GROUP BY + CASE + WHEN wait_type IN ('SOS_SCHEDULER_YIELD', 'CXPACKET', 'CXCONSUMER', 'CXSYNC_PORT', 'CXSYNC_CONSUMER') THEN 'CPU' + WHEN wait_type ILIKE 'PAGEIOLATCH%' + OR wait_type IN ('WRITELOG', 'IO_COMPLETION', 'ASYNC_IO_COMPLETION') THEN 'Storage' + WHEN wait_type IN ('RESOURCE_SEMAPHORE', 'RESOURCE_SEMAPHORE_QUERY_COMPILE', 'CMEMTHREAD') THEN 'Memory' + WHEN wait_type = 'ASYNC_NETWORK_IO' THEN 'Network' + WHEN wait_type ILIKE 'LCK_M_%' THEN 'Locks' + ELSE 'Other' + END, + wait_type +), +ranked AS ( + SELECT + *, + ROW_NUMBER() OVER (PARTITION BY category ORDER BY wait_time_ms DESC) AS rn + FROM categorized +), +by_category AS ( + SELECT + category, + SUM(wait_time_ms) AS total_wait_time_ms, + SUM(waiting_tasks) AS total_waiting_tasks, + MAX(CASE WHEN rn = 1 THEN wait_type END) AS top_wait_type, + MAX(CASE WHEN rn = 1 THEN wait_time_ms END) AS top_wait_time_ms + FROM ranked + GROUP BY category +), +grand_total AS ( + SELECT NULLIF(SUM(total_wait_time_ms), 0) AS total + FROM by_category +) +SELECT + bc.category, + bc.total_wait_time_ms, + bc.total_waiting_tasks, + CAST(bc.total_wait_time_ms * 100.0 / gt.total AS DECIMAL(5,1)), + bc.top_wait_type, + bc.top_wait_time_ms +FROM by_category bc +CROSS JOIN grand_total gt +ORDER BY bc.total_wait_time_ms DESC"; + + public async Task<List<WaitCategorySummaryRow>> GetWaitCategorySummaryAsync(int serverId, int hoursBack = 24, CancellationToken cancellationToken = default) + { + var cutoff = DateTime.UtcNow.AddHours(-hoursBack); + + await using var command = _dataSource.CreateCommand(WaitCategorySummarySql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(cutoff, DateTimeKind.Unspecified) }); + + var items = new List<WaitCategorySummaryRow>(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new WaitCategorySummaryRow + { + Category = reader.IsDBNull(0) ? "" : reader.GetString(0), + TotalWaitTimeMs = reader.IsDBNull(1) ? 0 : Convert.ToInt64(reader.GetValue(1)), + WaitingTasks = reader.IsDBNull(2) ? 0 : Convert.ToInt64(reader.GetValue(2)), + PctOfTotal = reader.IsDBNull(3) ? 0m : Convert.ToDecimal(reader.GetValue(3)), + TopWaitType = reader.IsDBNull(4) ? "" : reader.GetString(4), + TopWaitTimeMs = reader.IsDBNull(5) ? 0 : Convert.ToInt64(reader.GetValue(5)) + }); + } + return items; + } + + /// <summary>Top-N most expensive queries by total CPU over the window. $1 server_id, $2 cutoff, $3 topN.</summary> + public const string ExpensiveQueriesSql = @" +SELECT + database_name, + SUM(delta_worker_time) / 1000.0 AS total_cpu_ms, + CAST(SUM(delta_worker_time) / 1000.0 / NULLIF(SUM(delta_execution_count), 0) AS DECIMAL(19,2)) AS avg_cpu_ms, + SUM(delta_logical_reads) AS total_reads, + CAST(SUM(delta_logical_reads) * 1.0 / NULLIF(SUM(delta_execution_count), 0) AS DECIMAL(19,0)) AS avg_reads, + SUM(delta_execution_count) AS executions, + LEFT(query_text, 200) AS query_preview, + query_text AS full_query_text, + MAX(query_plan_xml) AS query_plan_xml +FROM v_query_stats +WHERE server_id = $1 +AND collection_time >= $2 +AND delta_worker_time IS NOT NULL +AND delta_worker_time > 0 +GROUP BY + database_name, + sql_handle, + query_text +ORDER BY SUM(delta_worker_time) DESC +LIMIT $3"; + + public async Task<List<ExpensiveQueryRow>> GetExpensiveQueriesAsync(int serverId, int hoursBack = 24, int topN = 20, CancellationToken cancellationToken = default) + { + var cutoff = DateTime.UtcNow.AddHours(-hoursBack); + + await using var command = _dataSource.CreateCommand(ExpensiveQueriesSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(cutoff, DateTimeKind.Unspecified) }); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = topN }); + + var items = new List<ExpensiveQueryRow>(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new ExpensiveQueryRow + { + DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0), + TotalCpuMs = reader.IsDBNull(1) ? 0 : Convert.ToInt64(reader.GetValue(1)), + AvgCpuMsPerExec = reader.IsDBNull(2) ? 0m : Convert.ToDecimal(reader.GetValue(2)), + TotalReads = reader.IsDBNull(3) ? 0 : Convert.ToInt64(reader.GetValue(3)), + AvgReadsPerExec = reader.IsDBNull(4) ? 0m : Convert.ToDecimal(reader.GetValue(4)), + Executions = reader.IsDBNull(5) ? 0 : Convert.ToInt64(reader.GetValue(5)), + QueryPreview = reader.IsDBNull(6) ? "" : reader.GetString(6), + FullQueryText = reader.IsDBNull(7) ? "" : reader.GetString(7), + QueryPlanXml = reader.IsDBNull(8) ? null : reader.GetString(8) + }); + } + return items; + } + + /// <summary> + /// High-impact queries — 80/20 analysis across CPU/duration/reads/writes/memory/executions. Aggregates + /// to query_hash level in SQL (reading the <c>query_stats</c> base table for the correlated sample-text + /// subqueries, as Lite does), then scores in C# via <see cref="HighImpactScorer"/>. $1 server_id, $2 cutoff. + /// </summary> + public const string HighImpactQueriesSql = @" +SELECT + query_hash, + MIN(database_name) AS database_name, + SUM(delta_execution_count) AS total_executions, + SUM(delta_worker_time) / 1000.0 AS total_cpu_ms, + SUM(delta_elapsed_time) / 1000.0 AS total_duration_ms, + SUM(delta_logical_reads) AS total_reads, + SUM(delta_logical_writes) AS total_writes, + SUM(COALESCE(max_grant_kb, 0)) / 1024.0 AS total_memory_mb, + (SELECT LEFT(qs2.query_text, 200) FROM query_stats qs2 + WHERE qs2.query_hash = qs.query_hash + AND qs2.server_id = $1 + AND qs2.collection_time >= $2 + AND qs2.query_text IS NOT NULL AND qs2.query_text != '' + ORDER BY qs2.delta_execution_count DESC NULLS LAST + LIMIT 1) AS sample_query_text, + (SELECT qs2.query_text FROM query_stats qs2 + WHERE qs2.query_hash = qs.query_hash + AND qs2.server_id = $1 + AND qs2.collection_time >= $2 + AND qs2.query_text IS NOT NULL AND qs2.query_text != '' + ORDER BY qs2.delta_execution_count DESC NULLS LAST + LIMIT 1) AS full_query_text, + (SELECT qs2.query_plan_xml FROM query_stats qs2 + WHERE qs2.query_hash = qs.query_hash + AND qs2.server_id = $1 + AND qs2.collection_time >= $2 + AND qs2.query_plan_xml IS NOT NULL AND qs2.query_plan_xml != '' + ORDER BY qs2.delta_execution_count DESC NULLS LAST + LIMIT 1) AS query_plan_xml +FROM query_stats AS qs +WHERE server_id = $1 +AND collection_time >= $2 +AND query_hash IS NOT NULL AND query_hash != '' +AND delta_execution_count > 0 +GROUP BY query_hash +HAVING SUM(delta_execution_count) > 0 +ORDER BY SUM(delta_worker_time) DESC"; + + public async Task<List<HighImpactQueryRow>> GetHighImpactQueriesAsync(int serverId, int hoursBack = 24, CancellationToken cancellationToken = default) + { + var cutoff = DateTime.UtcNow.AddHours(-hoursBack); + + await using var command = _dataSource.CreateCommand(HighImpactQueriesSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(cutoff, DateTimeKind.Unspecified) }); + + var allRows = new List<HighImpactQueryRow>(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + allRows.Add(new HighImpactQueryRow + { + QueryHash = reader.IsDBNull(0) ? "" : reader.GetString(0), + DatabaseName = reader.IsDBNull(1) ? "" : reader.GetString(1), + TotalExecutions = reader.IsDBNull(2) ? 0 : Convert.ToInt64(reader.GetValue(2)), + TotalCpuMs = reader.IsDBNull(3) ? 0m : Convert.ToDecimal(reader.GetValue(3)), + TotalDurationMs = reader.IsDBNull(4) ? 0m : Convert.ToDecimal(reader.GetValue(4)), + TotalReads = reader.IsDBNull(5) ? 0 : Convert.ToInt64(reader.GetValue(5)), + TotalWrites = reader.IsDBNull(6) ? 0 : Convert.ToInt64(reader.GetValue(6)), + TotalMemoryMb = reader.IsDBNull(7) ? 0m : Convert.ToDecimal(reader.GetValue(7)), + SampleQueryText = reader.IsDBNull(8) ? "" : reader.GetString(8), + FullQueryText = reader.IsDBNull(9) ? "" : reader.GetString(9), + QueryPlanXml = reader.IsDBNull(10) ? null : reader.GetString(10) + }); + } + + return HighImpactScorer.Score(allRows); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs new file mode 100644 index 000000000..bca230342 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs @@ -0,0 +1,534 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace PerformanceMonitor.Darling.Viewer; + +/* + * FinOps tab row models — a COPY of Lite's LocalDataService.FinOps.cs model layer for the copy-parity + * program (copy-don't-promote: the Darling viewer owns its own copy; Lite/Dashboard are untouched). + * Two deliberate deviations from Lite's models, both because the headless store lacks the source: + * (1) The per-server FinOps COST attribution (MonthlyCost / MonthlyCostShare / AnnualCost) is dropped + * everywhere — that budget lives in Lite/Dashboard's ServerConnection config, which the Postgres + * store has no equivalent of. The health score (pure CPU/memory/storage math) is kept. + * (2) ServerPropertyRow drops the fields the collected server_properties table doesn't carry + * (sqlserver_start_time / host_os_version / ag_replica_role — Lite got them from a LIVE query the + * headless viewer can't run). Everything the collector DOES persist is surfaced. + * The pure scoring helpers (FinOpsHealthCalculator, HighImpactScorer) are copied verbatim. + */ + +/// <summary>7-day daily provisioning classification trend (Utilization sub-tab).</summary> +public sealed class ProvisioningTrendRow +{ + public DateTime Day { get; set; } + public decimal AvgCpuPct { get; set; } + public int MaxCpuPct { get; set; } + public decimal P95CpuPct { get; set; } + public decimal MemoryRatio { get; set; } + public string Status { get; set; } = ""; + public string DayDisplay => Day.ToString("ddd MM/dd"); + public string StatusDisplay => Status.Replace("_", " "); +} + +/// <summary>Pool-level memory-grant vs used efficiency per day (Optimization sub-tab).</summary> +public sealed class MemoryGrantEfficiencyRow +{ + public DateTime Day { get; set; } + public decimal AvgGrantedMb { get; set; } + public decimal AvgUsedMb { get; set; } + public decimal EfficiencyPct { get; set; } + public decimal PeakGrantedMb { get; set; } + public long TotalGrantees { get; set; } + public long TotalWaiters { get; set; } + public long TimeoutErrors { get; set; } + public long ForcedGrants { get; set; } + public string DayDisplay => Day.ToString("ddd MM/dd"); + public decimal WastedMb => AvgGrantedMb - AvgUsedMb; +} + +/// <summary>Top database by CPU/IO for the Utilization summary grids.</summary> +public sealed class TopResourceConsumerRow +{ + public string DatabaseName { get; set; } = ""; + public long CpuTimeMs { get; set; } + public long ExecutionCount { get; set; } + public decimal IoTotalMb { get; set; } + public decimal PctCpu { get; set; } + public decimal PctIo { get; set; } + public long TotalCpuTimeMs { get; set; } + public decimal AvgIoMb { get; set; } +} + +/// <summary>Per-database allocated vs used space for the Utilization size chart (with star-width bars).</summary> +public sealed class DatabaseSizeSummaryRow +{ + public string DatabaseName { get; set; } = ""; + public decimal TotalMb { get; set; } + public decimal? UsedMb { get; set; } + public decimal FreeMb => UsedMb.HasValue ? TotalMb - UsedMb.Value : TotalMb; + public decimal UsedPct => TotalMb > 0 && UsedMb.HasValue ? Math.Round(UsedMb.Value * 100m / TotalMb, 1) : 0; + + /* Star-width GridLength for XAML binding — drives the stacked bar proportions. */ + public System.Windows.GridLength UsedStarWidth => + new(Math.Max((double)(UsedMb ?? 0m), 0.1), System.Windows.GridUnitType.Star); + public System.Windows.GridLength FreeStarWidth => + new(Math.Max((double)FreeMb, 0.1), System.Windows.GridUnitType.Star); +} + +/// <summary>Utilization efficiency summary (Utilization sub-tab header + bars + health score).</summary> +public sealed class UtilizationEfficiencyRow +{ + public decimal AvgCpuPct { get; set; } + public int MaxCpuPct { get; set; } + public decimal P95CpuPct { get; set; } + public long CpuSamples { get; set; } + public int TotalMemoryMb { get; set; } + public int TargetMemoryMb { get; set; } + public int PhysicalMemoryMb { get; set; } + public int BufferPoolMb { get; set; } + public decimal MemoryRatio { get; set; } + public int MaxWorkersCount { get; set; } + public int CurrentWorkersCount { get; set; } + public int CpuCount { get; set; } + public string ProvisioningStatus { get; set; } = ""; + + // FinOps cost — proportional to the server's monthly budget (0 = hidden) + public decimal MonthlyCost { get; set; } + public decimal AnnualCost => MonthlyCost * 12m; + + // Health score + public decimal FreeSpacePct { get; set; } + public int HealthScore { get; set; } + public string HealthScoreColor => FinOpsHealthCalculator.ScoreColor(HealthScore); +} + +/// <summary>Per-database resource usage (Database Resources sub-tab).</summary> +public sealed class DatabaseResourceUsageRow +{ + public string DatabaseName { get; set; } = ""; + public long CpuTimeMs { get; set; } + public long LogicalReads { get; set; } + public long PhysicalReads { get; set; } + public long LogicalWrites { get; set; } + public long ExecutionCount { get; set; } + public decimal IoReadMb { get; set; } + public decimal IoWriteMb { get; set; } + public long IoStallMs { get; set; } + public decimal PctCpuShare { get; set; } + public decimal PctIoShare { get; set; } +} + +/// <summary>Per-application connection counts plus collected per-app resource + session-status metrics (Application Connections sub-tab). Timestamps are localized in the read.</summary> +public sealed class ApplicationConnectionRow +{ + public string ApplicationName { get; set; } = ""; + public int AvgConnections { get; set; } + public int MaxConnections { get; set; } + public int AvgRunning { get; set; } + public int MaxRunning { get; set; } + public int AvgSleeping { get; set; } + public int MaxSleeping { get; set; } + public int AvgDormant { get; set; } + public int MaxDormant { get; set; } + public long AvgCpuTimeMs { get; set; } + public long MaxCpuTimeMs { get; set; } + public long AvgReads { get; set; } + public long MaxReads { get; set; } + public long AvgWrites { get; set; } + public long MaxWrites { get; set; } + public long AvgLogicalReads { get; set; } + public long MaxLogicalReads { get; set; } + public long SampleCount { get; set; } + public DateTime FirstSeenLocal { get; set; } + public DateTime LastSeenLocal { get; set; } +} + +/// <summary>Per-file database size + growth config (Database Sizes sub-tab).</summary> +public sealed class DatabaseSizeRow +{ + public string DatabaseName { get; set; } = ""; + public string FileTypeDesc { get; set; } = ""; + public string FileName { get; set; } = ""; + public decimal TotalSizeMb { get; set; } + public decimal? UsedSizeMb { get; set; } + public decimal? FreeSpaceMb => UsedSizeMb.HasValue ? TotalSizeMb - UsedSizeMb.Value : null; + public decimal? UsedPct => UsedSizeMb.HasValue && TotalSizeMb > 0 ? Math.Round(UsedSizeMb.Value * 100m / TotalSizeMb, 1) : null; + public string? VolumeMountPoint { get; set; } + public decimal? VolumeTotalMb { get; set; } + public decimal? VolumeFreeMb { get; set; } + public string? RecoveryModel { get; set; } + public decimal? AutoGrowthMb { get; set; } + public bool? IsPercentGrowth { get; set; } + public int? GrowthPct { get; set; } + public int? VlfCount { get; set; } + + /// <summary>FinOps cost — proportional share of the server monthly budget by size (set by the loader).</summary> + public decimal MonthlyCostShare { get; set; } + + public string GrowthDisplay => IsPercentGrowth switch + { + null => "-", + true => GrowthPct.HasValue ? $"{GrowthPct}%" : "-", + false => AutoGrowthMb == null || AutoGrowthMb == 0 ? "Disabled" : $"{AutoGrowthMb:N0} MB" + }; + + public decimal AutoGrowthSort => IsPercentGrowth switch + { + null => -1m, + true => (decimal)(GrowthPct ?? -1), + false => AutoGrowthMb ?? 0m + }; + + public string VlfCountDisplay => string.Equals(FileTypeDesc, "LOG", StringComparison.OrdinalIgnoreCase) + ? (VlfCount?.ToString() ?? "-") : "N/A"; + + public int VlfCountSort => string.Equals(FileTypeDesc, "LOG", StringComparison.OrdinalIgnoreCase) + ? (VlfCount ?? 0) : -1; +} + +/// <summary> +/// One server's inventory row (Server Inventory sub-tab), from the collected <c>server_properties</c> +/// table (Lite used a LIVE query — the headless viewer can't reach the target). The fields the collector +/// does not persist (start time / host OS / AG replica role) and the FinOps cost attribution are dropped. +/// </summary> +public sealed class ServerPropertyRow +{ + /// <summary>The store's server_id — carried so the loader can overlay this server's collected metrics; not shown in the grid.</summary> + public int ServerId { get; set; } + public string ServerName { get; set; } = ""; + public string Edition { get; set; } = ""; + public string ProductVersion { get; set; } = ""; + public string HostOsVersion { get; set; } = ""; + public int EngineEdition { get; set; } + public int CpuCount { get; set; } + public long PhysicalMemoryMb { get; set; } + public int? SocketCount { get; set; } + public int? CoresPerSocket { get; set; } + /// <summary>The server's LOCAL start clock (sys.dm_os_sys_info) — stored verbatim, shown as-is like Lite.</summary> + public DateTime? SqlServerStartTime { get; set; } + public DateTime? LastUpdated { get; set; } + public bool? IsHadrEnabled { get; set; } + public bool? IsClustered { get; set; } + public string AgReplicaRole { get; set; } = "Standalone"; + + public decimal? AvgCpuPct { get; set; } + public decimal? StorageTotalGb { get; set; } + public int? IdleDbCount { get; set; } + public string? ProvisioningStatus { get; set; } + + /// <summary>Per-server FinOps budget (servers.monthly_cost_usd from darling.json); 0 hides the cost columns.</summary> + public decimal MonthlyCost { get; set; } + public decimal AnnualCost => MonthlyCost * 12m; + + public string UptimeDisplay + { + get + { + if (SqlServerStartTime == null) return ""; + var uptime = DateTime.Now - SqlServerStartTime.Value; + return $"{(int)uptime.TotalDays}d {uptime.Hours}h"; + } + } + public string HadrDisplay => IsHadrEnabled.HasValue ? (IsHadrEnabled.Value ? "Yes" : "No") : ""; + public string ClusteredDisplay => IsClustered.HasValue ? (IsClustered.Value ? "Yes" : "No") : ""; + public string AgReplicaRoleDisplay => string.Equals(AgReplicaRole, "Standalone", StringComparison.OrdinalIgnoreCase) ? "—" : AgReplicaRole; + public string ProvisioningDisplay => ProvisioningStatus?.Replace("_", " ") ?? ""; + + /// <summary>License-limit warning for Standard edition (CPU/RAM caps). Same math as Lite.</summary> + public string? LicenseWarning + { + get + { + if (!Edition.Contains("Standard", StringComparison.OrdinalIgnoreCase)) return null; + var warnings = new List<string>(); + if (CpuCount > 24) warnings.Add($"CPU: {CpuCount} cores (Standard limited to 24)"); + if (PhysicalMemoryMb > 131072) warnings.Add($"RAM: {PhysicalMemoryMb / 1024}GB (Standard limited to 128GB)"); + return warnings.Count > 0 ? string.Join("; ", warnings) : null; + } + } + + public int HealthScore { get; set; } + public string HealthScoreColor => FinOpsHealthCalculator.ScoreColor(HealthScore); +} + +/// <summary>Per-database storage growth vs 7d/30d ago (Storage Growth parent grid).</summary> +public sealed class StorageGrowthRow +{ + public string DatabaseName { get; set; } = ""; + public decimal CurrentSizeMb { get; set; } + public decimal? Size7dAgoMb { get; set; } + public decimal? Size30dAgoMb { get; set; } + public decimal Growth7dMb { get; set; } + public decimal Growth30dMb { get; set; } + public decimal DailyGrowthRateMb { get; set; } + public decimal GrowthPct30d { get; set; } +} + +/// <summary>Database with zero query executions over the window (Optimization sub-tab). LastExecutionTime localized in read.</summary> +public sealed class IdleDatabaseRow +{ + public string DatabaseName { get; set; } = ""; + public decimal TotalSizeMb { get; set; } + public int FileCount { get; set; } + public DateTime? LastExecutionTime { get; set; } +} + +/// <summary>tempdb pressure metric current vs 24h peak (Optimization sub-tab).</summary> +public sealed class TempdbSummaryRow +{ + public string Metric { get; set; } = ""; + public decimal CurrentMb { get; set; } + public decimal Peak24hMb { get; set; } + public string Warning { get; set; } = ""; +} + +/// <summary>Wait time grouped by cost category (Optimization sub-tab).</summary> +public sealed class WaitCategorySummaryRow +{ + public string Category { get; set; } = ""; + public long TotalWaitTimeMs { get; set; } + public long WaitingTasks { get; set; } + public decimal PctOfTotal { get; set; } + public string TopWaitType { get; set; } = ""; + public long TopWaitTimeMs { get; set; } + + /// <summary>FinOps cost — proportional share of the window's budget by wait-time fraction (set by the loader).</summary> + public decimal MonthlyCostShare { get; set; } +} + +/// <summary>Top-20 query by total CPU (Optimization sub-tab).</summary> +public sealed class ExpensiveQueryRow +{ + public string DatabaseName { get; set; } = ""; + public long TotalCpuMs { get; set; } + public decimal AvgCpuMsPerExec { get; set; } + public long TotalReads { get; set; } + public decimal AvgReadsPerExec { get; set; } + public long Executions { get; set; } + public string QueryPreview { get; set; } = ""; + public string FullQueryText { get; set; } = ""; + + /// <summary>FinOps cost — proportional share of the window's budget by CPU fraction (set by the loader).</summary> + public decimal MonthlyCostShare { get; set; } + + /// <summary>The stored statement-level plan (query_stats.query_plan_xml, captured by Darling); opens in the Plan Viewer.</summary> + public string? QueryPlanXml { get; set; } + public bool HasQueryPlan => !string.IsNullOrEmpty(QueryPlanXml); +} + +/// <summary>Pure health-score math (Utilization + Server Inventory). Copied verbatim from Lite.</summary> +public static class FinOpsHealthCalculator +{ + public static int CpuScore(decimal p95Pct) + { + if (p95Pct <= 70) return (int)(100 - p95Pct * 50 / 70); + return (int)Math.Max(0, 50 - (p95Pct - 70) * 50 / 30); + } + + public static int MemoryScore(decimal bufferPoolRatio) + { + if (bufferPoolRatio <= 0.30m) return 60; + if (bufferPoolRatio <= 0.85m) return 100; + if (bufferPoolRatio <= 0.95m) return (int)(100 - (bufferPoolRatio - 0.85m) * 800); + return (int)Math.Max(0, 20 - (bufferPoolRatio - 0.95m) * 400); + } + + public static int StorageScore(decimal freeSpacePct) + { + if (freeSpacePct >= 30) return 100; + if (freeSpacePct >= 10) return (int)(50 + (freeSpacePct - 10) * 2.5m); + return (int)(freeSpacePct * 5); + } + + public static int Overall(int cpu, int memory, int storage) => + (int)(cpu * 0.40 + memory * 0.30 + storage * 0.30); + + public static string ScoreColor(int score) => score switch + { + >= 80 => "#27AE60", + >= 60 => "#F39C12", + _ => "#E74C3C" + }; +} + +/// <summary>High-impact query row (High Impact sub-tab) — 80/20 impact score across six dimensions.</summary> +public sealed class HighImpactQueryRow +{ + public string QueryHash { get; set; } = ""; + public string DatabaseName { get; set; } = ""; + public long TotalExecutions { get; set; } + public decimal TotalCpuMs { get; set; } + public decimal TotalDurationMs { get; set; } + public long TotalReads { get; set; } + public long TotalWrites { get; set; } + public decimal TotalMemoryMb { get; set; } + public decimal CpuShare { get; set; } + public decimal DurationShare { get; set; } + public decimal ReadsShare { get; set; } + public decimal WritesShare { get; set; } + public decimal MemoryShare { get; set; } + public decimal ExecutionsShare { get; set; } + public int ImpactScore { get; set; } + public string SampleQueryText { get; set; } = ""; + public string FullQueryText { get; set; } = ""; + + /// <summary>The stored statement-level plan (query_stats.query_plan_xml, captured by Darling); opens in the Plan Viewer.</summary> + public string? QueryPlanXml { get; set; } + public bool HasQueryPlan => !string.IsNullOrEmpty(QueryPlanXml); + + /// <summary>True when this row can have its ACTUAL plan captured — it carries the query_hash the service + /// re-executes by (identifier-only, resolved from query_stats). Gates the shared FinOps plan menu's "Get + /// Actual Plan"; the Expensive Queries rows (grouped by text, no query_hash) lack it and fall back to disabled.</summary> + public bool CanGetActualPlan => !string.IsNullOrEmpty(QueryHash); + + public string ImpactScoreColor => ImpactScore switch + { + >= 80 => "#E74C3C", + >= 60 => "#F39C12", + _ => "#27AE60" + }; +} + +/// <summary> +/// Identifies top-N queries per resource dimension, computes PERCENT_RANK and share percentages, and +/// returns the "interesting" set sorted by impact score. Copied verbatim from Lite's HighImpactScorer. +/// </summary> +public static class HighImpactScorer +{ + public static List<HighImpactQueryRow> Score(List<HighImpactQueryRow> allRows, int topN = 10) + { + if (allRows.Count == 0) return allRows; + + var interesting = new HashSet<string>(); + foreach (var hash in allRows.OrderByDescending(r => r.TotalCpuMs).Take(topN).Select(r => r.QueryHash)) interesting.Add(hash); + foreach (var hash in allRows.OrderByDescending(r => r.TotalDurationMs).Take(topN).Select(r => r.QueryHash)) interesting.Add(hash); + foreach (var hash in allRows.OrderByDescending(r => r.TotalReads).Take(topN).Select(r => r.QueryHash)) interesting.Add(hash); + foreach (var hash in allRows.OrderByDescending(r => r.TotalWrites).Take(topN).Select(r => r.QueryHash)) interesting.Add(hash); + foreach (var hash in allRows.OrderByDescending(r => r.TotalMemoryMb).Take(topN).Select(r => r.QueryHash)) interesting.Add(hash); + foreach (var hash in allRows.OrderByDescending(r => r.TotalExecutions).Take(topN).Select(r => r.QueryHash)) interesting.Add(hash); + + var filtered = allRows.Where(r => interesting.Contains(r.QueryHash)).ToList(); + + if (filtered.Count == 0) return filtered; + + var cpuValues = filtered.Select(r => r.TotalCpuMs).OrderBy(v => v).ToList(); + var durationValues = filtered.Select(r => r.TotalDurationMs).OrderBy(v => v).ToList(); + var readsValues = filtered.Select(r => (decimal)r.TotalReads).OrderBy(v => v).ToList(); + var writesValues = filtered.Select(r => (decimal)r.TotalWrites).OrderBy(v => v).ToList(); + var memoryValues = filtered.Select(r => r.TotalMemoryMb).OrderBy(v => v).ToList(); + var execValues = filtered.Select(r => (decimal)r.TotalExecutions).OrderBy(v => v).ToList(); + + var totalCpu = filtered.Sum(r => r.TotalCpuMs); + var totalDuration = filtered.Sum(r => r.TotalDurationMs); + var totalReads = filtered.Sum(r => (decimal)r.TotalReads); + var totalWrites = filtered.Sum(r => (decimal)r.TotalWrites); + var totalMemory = filtered.Sum(r => r.TotalMemoryMb); + var totalExecs = filtered.Sum(r => (decimal)r.TotalExecutions); + + foreach (var row in filtered) + { + var cpuPctl = PercentRank(cpuValues, row.TotalCpuMs); + var durationPctl = PercentRank(durationValues, row.TotalDurationMs); + var readsPctl = PercentRank(readsValues, (decimal)row.TotalReads); + var writesPctl = PercentRank(writesValues, (decimal)row.TotalWrites); + var memoryPctl = PercentRank(memoryValues, row.TotalMemoryMb); + var execsPctl = PercentRank(execValues, (decimal)row.TotalExecutions); + + row.CpuShare = totalCpu > 0 ? Math.Round(100m * row.TotalCpuMs / totalCpu, 1) : 0; + row.DurationShare = totalDuration > 0 ? Math.Round(100m * row.TotalDurationMs / totalDuration, 1) : 0; + row.ReadsShare = totalReads > 0 ? Math.Round(100m * row.TotalReads / totalReads, 1) : 0; + row.WritesShare = totalWrites > 0 ? Math.Round(100m * row.TotalWrites / totalWrites, 1) : 0; + row.MemoryShare = totalMemory > 0 ? Math.Round(100m * row.TotalMemoryMb / totalMemory, 1) : 0; + row.ExecutionsShare = totalExecs > 0 ? Math.Round(100m * row.TotalExecutions / totalExecs, 1) : 0; + + var pctlSum = cpuPctl + durationPctl + readsPctl + writesPctl + memoryPctl + execsPctl; + row.ImpactScore = (int)(pctlSum / 6m * 100m); + } + + return filtered.OrderByDescending(r => r.ImpactScore).ToList(); + } + + internal static decimal PercentRank(List<decimal> sortedValues, decimal value) + { + if (sortedValues.Count <= 1) return 0; + int rank = sortedValues.Count(v => v < value); + return Math.Min(1.0m, (decimal)rank / (sortedValues.Count - 1)); + } +} + +/// <summary>Per-table size + growth for the Storage Growth object drill (indexes rolled up).</summary> +public sealed class ObjectSizeGrowthRow +{ + public string DatabaseName { get; set; } = ""; + public string SchemaName { get; set; } = ""; + public string TableName { get; set; } = ""; + public decimal CurrentReservedMb { get; set; } + public decimal CurrentUsedMb { get; set; } + public long TotalRows { get; set; } + public int IndexCount { get; set; } + public decimal Growth7dMb { get; set; } + public decimal Growth30dMb { get; set; } + public decimal DailyGrowthRateMb { get; set; } + public decimal GrowthPct30d { get; set; } +} + +/// <summary>Per-index usage with unused/write-only classification (Storage Growth index drill). LastUserAccess localized in read.</summary> +public sealed class IndexUsageRow +{ + public string DatabaseName { get; set; } = ""; + public string SchemaName { get; set; } = ""; + public string TableName { get; set; } = ""; + public string IndexName { get; set; } = ""; + public string IndexTypeDesc { get; set; } = ""; + public int IndexId { get; set; } + public decimal ReservedMb { get; set; } + public long TotalRows { get; set; } + public long UserSeeks { get; set; } + public long UserScans { get; set; } + public long UserLookups { get; set; } + public long TotalReads { get; set; } + public long UserUpdates { get; set; } + public DateTime? LastUserAccess { get; set; } + public string Classification { get; set; } = ""; +} + +/// <summary>Per-index locking/latch contention (Locking & Contention sub-tab).</summary> +public sealed class IndexLockingRow +{ + public string DatabaseName { get; set; } = ""; + public string SchemaName { get; set; } = ""; + public string TableName { get; set; } = ""; + public string IndexName { get; set; } = ""; + public string IndexTypeDesc { get; set; } = ""; + public decimal ReservedMb { get; set; } + public long TotalRows { get; set; } + public long RowLockCount { get; set; } + public long RowLockWaitCount { get; set; } + public long RowLockWaitInMs { get; set; } + public long PageLockCount { get; set; } + public long PageLockWaitCount { get; set; } + public long PageLockWaitInMs { get; set; } + public long IndexLockPromotionCount { get; set; } + public long PageLatchWaitInMs { get; set; } + public long PageIoLatchWaitInMs { get; set; } + public long PageLatchWaitCount { get; set; } + public long PageIoLatchWaitCount { get; set; } + + /// <summary> + /// Per-column 0..1 log color-scale intensities for the four *_wait_in_ms cells (#1138 §3B). Set by the + /// loader after fetch via <see cref="PerformanceMonitor.Common.FinOpsHeatmapBuilder.ColumnLogIntensities"/>; + /// bound to the cell background through HeatIntensityToBrushConverter. Not from the database. + /// </summary> + public double RowLockHeat { get; set; } + public double PageLockHeat { get; set; } + public double PageLatchHeat { get; set; } + public double PageIoLatchHeat { get; set; } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Findings.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Findings.cs new file mode 100644 index 000000000..bb0d12442 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Findings.cs @@ -0,0 +1,402 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Analysis; +using PerformanceMonitor.Darling.Analysis; +using PerformanceMonitor.Notifications; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// One Recommendations-tab list row: a persisted <see cref="AnalysisFinding"/> banded and +/// titled the way Lite's recommendations reader maps it (same severity cutoffs, same +/// headline-or-root-fact-key title, same display sort), carrying the underlying finding for +/// the detail pane. Advise-only — the viewer renders the stored remediation action as +/// read-only JSON; there is no Apply here. +/// </summary> +public sealed class ViewerFindingRow +{ + public required string SeverityBand { get; init; } + + public required double RawSeverity { get; init; } + + public required string Title { get; init; } + + public required string Category { get; init; } + + public required string DatabaseName { get; init; } + + public required AnalysisFinding Finding { get; init; } + + /// <summary>The batch's analysis time in the viewer machine's local time.</summary> + public DateTime AnalysisTimeLocal => ViewerTimeHelper.ForDisplay(Finding.AnalysisTime); + + /// <summary> + /// True when this finding's story pattern is in <c>analysis_muted</c> for the server (the + /// engine will drop it on the next analysis run; it stays in this already-persisted batch, + /// shown muted). Set after the mapping from the muted-story registry, so <see cref="MapFindings"/> + /// stays pure. + /// </summary> + public bool IsMuted { get; set; } + + /// <summary>The <c>mute_id</c> of the matching registry row when <see cref="IsMuted"/>, for unmute.</summary> + public long? MuteId { get; set; } + + /// <summary>Grid cell text for the muted state — "Muted" or blank.</summary> + public string MutedLabel => IsMuted ? "Muted" : ""; +} + +/// <summary> +/// The per-server analysis-state marker (V19) the Darling service writes after each analysis pass: +/// whether the pass hit the 24h data-span gate (<see cref="InsufficientData"/> true, with the engine's +/// <see cref="Message"/>) or completed on enough data (false). The Recommendations tab reads it so a +/// zero-finding young deployment shows "still collecting" instead of a false all-clear. A read yields +/// <c>null</c> when no pass has run for the server yet (no row), which the tab treats as "not +/// insufficient" — an explicit marker is required to show the collecting state. +/// </summary> +public sealed record AnalysisStateMarker(bool InsufficientData, string? Message, DateTime AnalysisTimeUtc); + +public sealed partial class ViewerDataService +{ + private PgFindingStore? _findingStore; + + private static readonly JsonSerializerOptions s_indentedJson = new() { WriteIndented = true }; + + /// <summary> + /// The per-server analysis-state marker (V19). Bare <c>analysis_state</c> resolves through the + /// database-default search path to <c>collect.analysis_state</c> (the same schema the viewer reads + /// <c>analysis_findings</c> from). + /// </summary> + public const string GetAnalysisStateSql = @" +SELECT insufficient_data, message, analysis_time +FROM analysis_state +WHERE server_id = $1"; + + /// <summary> + /// Reads the per-server analysis-state marker the Darling service writes after each analysis pass + /// (<c>DarlingObservability.WriteAnalysisStateAsync</c>). Returns <c>null</c> when no pass has run for + /// the server yet (no row) — the tab then treats a zero-finding read as a genuine all-clear. Degrades + /// to <c>null</c> on any read error (a store not yet migrated to V19, a transient failure), never + /// throwing, matching the finding read's read-degrades discipline so the tab never crashes on it. + /// </summary> + public async Task<AnalysisStateMarker?> GetAnalysisStateAsync(int serverId) + { + try + { + await using var command = _dataSource.CreateCommand(GetAnalysisStateSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + + await using var reader = await command.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) + { + return null; + } + + var insufficient = !reader.IsDBNull(0) && reader.GetBoolean(0); + var message = reader.IsDBNull(1) ? null : reader.GetString(1); + /* Stored naive-UTC; tag Utc on read so the kind is explicit (the store-wide read discipline). */ + var analysisTime = DateTime.SpecifyKind(reader.GetDateTime(2), DateTimeKind.Utc); + return new AnalysisStateMarker(insufficient, message, analysisTime); + } + catch + { + return null; + } + } + + /// <summary> + /// The latest analysis run's findings for one server, mapped to display rows. Reads + /// through the shared <see cref="PgFindingStore"/> (its <c>GetLatestFindingsSql</c> — + /// the MAX(analysis_time) batch, severity descending, including each row's persisted + /// <c>remediation_action_json</c>), so the viewer binds the exact store contract the + /// analysis pipeline writes. Note the store's read degrades to an empty list on error + /// (its twins' discipline); the tab then shows the analysis-cadence empty state. + /// </summary> + public async Task<List<ViewerFindingRow>> GetLatestFindingsAsync(int serverId) + { + _findingStore ??= new PgFindingStore(_dataSource); + var findings = await _findingStore.GetLatestFindingsAsync(serverId); + var rows = MapFindings(findings); + + /* Mark rows whose story pattern is already muted so the tab shows the state and can offer + the unmute. Muting doesn't retract this persisted batch — the engine filters the pattern + on the NEXT analysis run — so a just-muted finding stays visible here, flagged "Muted". + + GetMutedStoriesAsync spans this server's mutes AND global ones (server_id IS NULL, plus legacy + server_id = 0 all-servers rows). Only a PER-SERVER mute carries a MuteId here — the viewer + offers unmute for those — while a global mute flags the finding muted but is left un-unmutable, + because deleting it would unmute the pattern for EVERY server (a scope the per-server viewer + must not silently change). A real server_id is never 0, so the m.ServerId == serverId check + below routes both NULL and legacy-0 rows to the global branch. Among duplicate per-server rows + for one hash the smallest mute_id wins, so the choice is stable. */ + var muted = await _findingStore.GetMutedStoriesAsync(serverId); + if (muted.Count > 0) + { + var perServerMuteId = new Dictionary<string, long>(StringComparer.Ordinal); + var globallyMuted = new HashSet<string>(StringComparer.Ordinal); + foreach (var m in muted) + { + if (m.ServerId == serverId) + { + if (!perServerMuteId.TryGetValue(m.StoryPathHash, out var existing) || m.MuteId < existing) + { + perServerMuteId[m.StoryPathHash] = m.MuteId; + } + } + else + { + globallyMuted.Add(m.StoryPathHash); + } + } + + foreach (var row in rows) + { + var hash = row.Finding.StoryPathHash; + if (perServerMuteId.TryGetValue(hash, out var muteId)) + { + row.IsMuted = true; + row.MuteId = muteId; + } + else if (globallyMuted.Contains(hash)) + { + row.IsMuted = true; /* MuteId stays null — global mute, not unmutable from here. */ + } + } + } + + return rows; + } + + /// <summary> + /// Mutes a finding's story pattern for the selected server so the engine drops it on the next + /// analysis run — the viewer's user-initiated write, straight to Postgres through the SAME + /// <see cref="PgFindingStore.MuteStoryAsync"/> the MCP <c>mute_analysis_finding</c> tool uses. + /// The viewer always mutes per-selected-server (a real non-zero server_id), so it never takes the + /// MCP "all servers" path (which now persists a NULL server_id — see + /// <see cref="PgFindingStore.GetMutedStoriesAsync"/>). + /// + /// <para>Under a read-only (<c>viewer</c>-role) connection this write degrades gracefully already: + /// <see cref="PgFindingStore.MuteStoryAsync"/> logs and swallows the permission error (its shared + /// read-and-write no-op discipline, also relied on by the service/MCP), so there is no crash. The + /// viewer has no finding-mute affordance today; a future one should gate on + /// <see cref="IsReadOnly"/> like the mute-rule and alert-dismiss surfaces.</para> + /// </summary> + public async Task MuteFindingAsync(int serverId, AnalysisFinding finding) + { + if (finding is null) + { + throw new ArgumentNullException(nameof(finding)); + } + + _findingStore ??= new PgFindingStore(_dataSource); + await _findingStore.MuteStoryAsync( + serverId, + finding.StoryPathHash, + string.IsNullOrEmpty(finding.StoryPath) ? finding.StoryPathHash : finding.StoryPath, + reason: "Muted from Recommendations"); + } + + /// <summary> + /// Removes a mute registry row (<see cref="PgFindingStore.UnmuteStoryAsync"/>), so the pattern + /// is reported again from the next analysis run. + /// </summary> + public async Task UnmuteFindingAsync(long muteId) + { + _findingStore ??= new PgFindingStore(_dataSource); + await _findingStore.UnmuteStoryAsync(muteId); + } + + /// <summary> + /// Maps stored findings to display rows and applies Lite's deterministic display order: + /// severity band descending, then raw severity descending, then database + /// (case-insensitive), then title. Pure over the input — unit-testable without a store. + /// </summary> + public static List<ViewerFindingRow> MapFindings(IReadOnlyList<AnalysisFinding> findings) + { + if (findings is null) + { + throw new ArgumentNullException(nameof(findings)); + } + + var rows = new List<ViewerFindingRow>(findings.Count); + foreach (var finding in findings) + { + if (finding is null) + { + continue; + } + + rows.Add(new ViewerFindingRow + { + SeverityBand = SeverityBand(finding.Severity), + RawSeverity = finding.Severity, + Title = FindingTitle(finding), + Category = finding.Category, + DatabaseName = finding.DatabaseName ?? "", + Finding = finding, + }); + } + + rows.Sort(CompareForDisplay); + return rows; + } + + /// <summary> + /// The engine's double severity (0–~2.0) mapped onto the canonical three bands with the + /// SAME cutoffs both apps' recommendation readers use: >= 1.5 CRITICAL, + /// >= 0.75 WARNING, else INFO. Uppercase so <see cref="StatusToBrushConverter"/> bands the + /// Recommendations grid (its case-insensitive CRITICAL/WARNING/INFO mapping). + /// </summary> + public static string SeverityBand(double severity) + { + if (severity >= 1.5) + { + return "CRITICAL"; + } + + if (severity >= 0.75) + { + return "WARNING"; + } + + return "INFO"; + } + + /// <summary> + /// The list title: the composed advice headline (value-stated advice frozen into + /// StoryText at analysis time, static block fallback), falling back to the root fact key — + /// Lite's reader's title mapping. + /// </summary> + public static string FindingTitle(AnalysisFinding finding) + { + if (finding is null) + { + throw new ArgumentNullException(nameof(finding)); + } + + var advice = FactAdvice.GetComposedForFinding(finding); + return !string.IsNullOrEmpty(advice?.Headline) ? advice!.Headline : finding.RootFactKey; + } + + /// <summary> + /// The detail pane's text for one finding: the composed story (headline, investigation, + /// remediation via <see cref="FactAdvice.GetForFinding"/>, which also derives any + /// copy-paste T-SQL the stored row supports), then the persisted remediation action + /// pretty-printed as read-only JSON. No Apply in the viewer — the action is disclosure. + /// </summary> + public static string ComposeFindingDetailText(AnalysisFinding finding) + { + if (finding is null) + { + throw new ArgumentNullException(nameof(finding)); + } + + var advice = FactAdvice.GetForFinding(finding); + var sb = new StringBuilder(); + + sb.AppendLine(!string.IsNullOrEmpty(advice?.Headline) ? advice!.Headline : finding.RootFactKey); + sb.AppendLine(); + + if (advice is null) + { + sb.AppendLine($"No advice text is available for fact key '{finding.RootFactKey}'."); + sb.AppendLine(); + } + else + { + if (!string.IsNullOrEmpty(advice.Investigation)) + { + sb.AppendLine("Investigation:"); + sb.AppendLine(advice.Investigation); + sb.AppendLine(); + } + + if (!string.IsNullOrEmpty(advice.Remediation)) + { + sb.AppendLine("Remediation:"); + sb.AppendLine(advice.Remediation); + sb.AppendLine(); + } + + if (!string.IsNullOrEmpty(advice.RemediationTsql)) + { + sb.AppendLine("Suggested T-SQL (copy/paste):"); + sb.AppendLine(advice.RemediationTsql); + sb.AppendLine(); + } + } + + var remediationJson = PrettyPrintRemediation(finding.Remediation); + if (remediationJson is not null) + { + sb.AppendLine("Stored remediation action (read-only):"); + sb.AppendLine(remediationJson); + } + + return sb.ToString().TrimEnd(); + } + + /// <summary> + /// Pretty-prints a finding's persisted remediation action through the SAME serializer + /// that wrote the <c>remediation_action_json</c> column + /// (<see cref="AlertContextSerializer.SerializeAction"/>), re-indented for reading. + /// Null when the finding carries no action. + /// </summary> + public static string? PrettyPrintRemediation(RemediationAction? action) + { + var json = AlertContextSerializer.SerializeAction(action); + if (json is null) + { + return null; + } + + using var document = JsonDocument.Parse(json); + return JsonSerializer.Serialize(document.RootElement, s_indentedJson); + } + + /// <summary> + /// Lite's recommendations display order: band descending, raw severity descending, + /// database (ordinal, case-insensitive), then title — deterministic regardless of + /// input order. + /// </summary> + private static int CompareForDisplay(ViewerFindingRow x, ViewerFindingRow y) + { + int bySeverityBand = BandRank(y.SeverityBand).CompareTo(BandRank(x.SeverityBand)); + if (bySeverityBand != 0) + { + return bySeverityBand; + } + + int byRaw = y.RawSeverity.CompareTo(x.RawSeverity); + if (byRaw != 0) + { + return byRaw; + } + + int byDb = string.Compare(x.DatabaseName, y.DatabaseName, StringComparison.OrdinalIgnoreCase); + if (byDb != 0) + { + return byDb; + } + + return string.Compare(x.Title, y.Title, StringComparison.Ordinal); + } + + private static int BandRank(string band) => band switch + { + "CRITICAL" => 2, + "WARNING" => 1, + _ => 0, + }; +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Fleet.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Fleet.cs new file mode 100644 index 000000000..d81bb0c60 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Fleet.cs @@ -0,0 +1,385 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Media; +using Npgsql; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Fleet-wide NOC roll-up — the signature capability Darling's single central store enables that +/// NEITHER the Dashboard nor Lite can do. Each of those monitors one server per PerformanceMonitor +/// database, so they physically cannot rank or total servers against each other in SQL; Darling keys +/// every server's rows by <c>server_id</c> in ONE Postgres store, so a single cross-server GROUP-BY / +/// aggregate rolls the whole fleet up at once. +/// +/// <para>This roll-up is deliberately split across two mechanisms so it can REUSE the per-server surface +/// rather than re-derive it:</para> +/// <list type="bullet"> +/// <item><b>Fleet totals</b> (<see cref="GetFleetTotalsAsync"/> / <see cref="FleetTotalsSql"/>) are a +/// genuine single cross-server aggregate over the collector views — total blocking events and total +/// deadlocks in the window. Blocking honors the SAME per-server XE→DMV fallback the Overview cards use +/// (<c>ServerSummaryBlockingSql</c>): it counts per <c>server_id</c> from each source, takes the XE count +/// when a server has any XE row this window else its DMV count, then SUMs across the fleet — so the fleet +/// total reconciles with the sum of the per-server card counts. These are pure COUNT metrics with no C# +/// banding, so they belong in SQL.</item> +/// <item><b>Health-band counts, servers-with-collection-failures, and the worst-N ranking</b> +/// (<see cref="FleetRollup.Build"/>) are a pure C# reduction over the <see cref="ServerSummaryItem"/> +/// list the Overview already loads. They REUSE #1426's card banding verbatim: a server's fleet band comes +/// from <see cref="ServerSummaryItem.OverallMetricSeverity"/> + its freshness status (which themselves +/// reuse every per-metric band, including <see cref="CollectorHealthRow.HealthStatus"/> via the card's +/// FailedCollectorCount). Reproducing that six-metric composite in SQL would be inventing a parallel +/// banding, so the classification stays where the banding lives — in C#, over the already-read cards.</item> +/// </list> +/// +/// <para>The totals read is windowed (parameterized, naive-UTC per the store convention). The Overview tab +/// has no per-tab toolbar; the caller passes the SAME one-hour window the per-server cards use, so the +/// fleet totals and the cards describe the same span.</para> +/// </summary> +public sealed partial class ViewerDataService +{ + /// <summary> + /// The cross-server fleet totals over a window — the read only Darling's central store can serve. + /// <c>total_blocking_events</c> respects the Overview card's XE-preferred / DMV-fallback rule PER + /// server before summing: each source is counted per <c>server_id</c> (a GROUP BY), a FULL OUTER JOIN + /// lines the two sources up per server, and each server contributes its XE count when it has any XE row + /// this window else its DMV count (Lite's <c>COALESCE(NULLIF(xe,0), dmv)</c>, applied per server) — so + /// an AWS RDS server with only DMV snapshots still counts and a server with XE reports is never + /// double-counted. <c>total_deadlocks</c> is a plain cross-server COUNT. $1 window start, $2 window end + /// (both naive UTC). + /// </summary> + public const string FleetTotalsSql = @" +SELECT + ( + SELECT COALESCE(SUM(CASE WHEN per_server.xe_count > 0 THEN per_server.xe_count ELSE per_server.dmv_count END), 0) + FROM + ( + SELECT + COALESCE(xe.server_id, dmv.server_id) AS server_id, + COALESCE(xe.cnt, 0) AS xe_count, + COALESCE(dmv.cnt, 0) AS dmv_count + FROM + ( + SELECT server_id, COUNT(*) AS cnt + FROM v_blocked_process_reports + WHERE event_time >= $1 + AND event_time <= $2 + GROUP BY server_id + ) AS xe + FULL OUTER JOIN + ( + SELECT server_id, COUNT(*) AS cnt + FROM v_dmv_blocking_snapshots + WHERE event_time >= $1 + AND event_time <= $2 + GROUP BY server_id + ) AS dmv ON xe.server_id = dmv.server_id + ) AS per_server + ) AS total_blocking_events, + ( + SELECT COUNT(*) + FROM v_deadlocks + WHERE deadlock_time >= $1 + AND deadlock_time <= $2 + ) AS total_deadlocks"; + + /// <summary> + /// Reads the fleet's cross-server blocking + deadlock totals over the window. The single query that + /// neither the Dashboard nor Lite can run (each sees one server's database). Window bounds are sent + /// Kind=Unspecified (the naive-UTC store convention). + /// </summary> + public async Task<FleetTotals> GetFleetTotalsAsync(DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(FleetTotalsSql); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified), + }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified), + }); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (await reader.ReadAsync(cancellationToken)) + { + return new FleetTotals + { + TotalBlockingEvents = reader.IsDBNull(0) ? 0 : Convert.ToInt64(reader.GetValue(0)), + TotalDeadlocks = reader.IsDBNull(1) ? 0 : Convert.ToInt64(reader.GetValue(1)), + }; + } + + return new FleetTotals(); + } +} + +/// <summary>The cross-server fleet totals over a window — pure SQL aggregates keyed by <c>server_id</c>.</summary> +public sealed class FleetTotals +{ + /// <summary>Blocking events across the whole fleet this window (per-server XE→DMV fallback, then summed).</summary> + public long TotalBlockingEvents { get; set; } + + /// <summary>Deadlocks across the whole fleet this window.</summary> + public long TotalDeadlocks { get; set; } +} + +/// <summary> +/// One entry in the fleet's worst-first "Needs attention" ranking — a problem server with its band, a +/// short human reason (built from the card's own metric displays), and its composite score. Clicking the +/// entry opens that server's tab (the caller maps <see cref="ServerId"/> back to the sidebar server and +/// calls MainWindow's OpenServerTab). Every display is a pure format of the underlying +/// <see cref="ServerSummaryItem"/>; the brushes reuse the card's severity palette. +/// </summary> +public sealed class FleetRankedServer +{ + private static readonly SolidColorBrush s_criticalBrush = MakeBrush("#E57373"); + private static readonly SolidColorBrush s_warningBrush = MakeBrush("#FFD54F"); + private static readonly SolidColorBrush s_healthyBrush = MakeBrush("#81C784"); + private static readonly SolidColorBrush s_offlineBrush = MakeBrush("#888888"); + + public int ServerId { get; init; } + public string DisplayName { get; init; } = ""; + public FleetHealthBand Band { get; init; } + + /// <summary>The composite worst-first ordering score (band rank + severity magnitude + incident tiebreak).</summary> + public long Score { get; init; } + + /// <summary>A short "why it needs attention" line, e.g. "CPU 97%, Blocking 6" or "Offline — no recent collection".</summary> + public string Reason { get; init; } = ""; + + public string BandLabel => ServerHealthClassifier.BandLabel(Band); + + /// <summary>The band's dot / label brush — the card's severity palette (offline = the Unknown grey).</summary> + public SolidColorBrush BandBrush => Band switch + { + FleetHealthBand.Critical => s_criticalBrush, + FleetHealthBand.Warning => s_warningBrush, + FleetHealthBand.Offline => s_offlineBrush, + _ => s_healthyBrush, + }; + + private static SolidColorBrush MakeBrush(string hex) + { + var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(hex)); + brush.Freeze(); + return brush; + } +} + +/// <summary> +/// The fleet-wide roll-up view-model rendered above the Overview's per-server cards: the fleet health +/// summary (total servers + counts by band), the fleet totals (cross-server blocking / deadlock sums + +/// servers with collection failures), and the worst-first "Needs attention" ranking. Built by +/// <see cref="Build"/> from the same <see cref="ServerSummaryItem"/> list the Overview loads plus the +/// <see cref="FleetTotals"/> SQL read — REUSING #1426's card banding for every band decision. +/// </summary> +public sealed class FleetRollup +{ + /// <summary>The default depth of the worst-first ranking (the "Needs attention" list caps at this).</summary> + public const int DefaultWorstCount = 5; + + /// <summary>An empty fleet (no servers registered) — every count zero, no ranking.</summary> + public static readonly FleetRollup Empty = new(); + + public int TotalServers { get; init; } + public int HealthyCount { get; init; } + public int WarningCount { get; init; } + public int CriticalCount { get; init; } + public int OfflineCount { get; init; } + + /// <summary>Servers whose 7-day collection banding flags at least one FAILING collector (card reuse).</summary> + public int ServersWithCollectionFailures { get; init; } + + /// <summary>Fleet-wide blocking events this window (from the cross-server SQL total).</summary> + public long TotalBlockingEvents { get; init; } + + /// <summary>Fleet-wide deadlocks this window (from the cross-server SQL total).</summary> + public long TotalDeadlocks { get; init; } + + /// <summary>The worst-first problem servers (band != Healthy), capped at the requested depth.</summary> + public IReadOnlyList<FleetRankedServer> WorstServers { get; init; } = Array.Empty<FleetRankedServer>(); + + /// <summary>Problem servers beyond the capped ranking — surfaced as a "+N more" affordance.</summary> + public int AdditionalProblemCount { get; init; } + + /// <summary>Any server needs attention (band != Healthy) — drives the ranking list vs the all-clear line.</summary> + public bool HasProblems => WorstServers.Count > 0; + + /// <summary>"+N more need attention" when the ranking overflows its cap, else empty.</summary> + public string AdditionalProblemText => + AdditionalProblemCount > 0 ? $"+{AdditionalProblemCount} more need attention" : ""; + + /// <summary>The all-clear affirmation shown when nothing needs attention.</summary> + public string AllHealthyText => TotalServers == 1 + ? "All 1 server healthy" + : $"All {TotalServers} servers healthy"; + + /// <summary>"Monitoring N servers" subtitle for the fleet header.</summary> + public string MonitoringText => TotalServers == 1 + ? "Monitoring 1 server" + : $"Monitoring {TotalServers} servers"; + + /// <summary>"N server(s)" for the collection-failures total line.</summary> + public string CollectionFailuresText => ServersWithCollectionFailures == 1 + ? "1 server" + : $"{ServersWithCollectionFailures} servers"; + + /// <summary> + /// Rolls the per-server Overview cards up into the fleet view-model. The band counts, + /// servers-with-failures, and worst-first ranking reduce the <paramref name="summaries"/> using #1426's + /// card banding (no new bands); the blocking / deadlock totals come from the cross-server + /// <paramref name="totals"/> SQL read. <paramref name="worstCount"/> caps the "Needs attention" list. + /// </summary> + public static FleetRollup Build(IReadOnlyList<ServerSummaryItem> summaries, FleetTotals totals, int worstCount = DefaultWorstCount) + { + if (summaries.Count == 0) + { + return new FleetRollup + { + TotalBlockingEvents = totals.TotalBlockingEvents, + TotalDeadlocks = totals.TotalDeadlocks, + }; + } + + var healthy = 0; + var warning = 0; + var critical = 0; + var offline = 0; + var failures = 0; + + foreach (var s in summaries) + { + switch (ClassifyBand(s)) + { + case FleetHealthBand.Offline: offline++; break; + case FleetHealthBand.Critical: critical++; break; + case FleetHealthBand.Warning: warning++; break; + default: healthy++; break; + } + + /* Servers with collection failures — the card's own FAILING count (which reuses + CollectorHealthRow.HealthStatus), not a re-derived SQL band. */ + if (s.FailedCollectorCount > 0) + { + failures++; + } + } + + /* Worst-first ranking: problem servers only (band != Healthy), highest composite score first, + name as the stable tiebreak. The composite score keeps offline > critical > warning and, within + a band, ranks by how many metrics are bad — see FleetHealthScore. */ + var problems = summaries + .Where(s => ClassifyBand(s) != FleetHealthBand.Healthy) + .OrderByDescending(FleetHealthScore) + .ThenBy(s => s.DisplayName, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var worst = problems + .Take(worstCount) + .Select(s => new FleetRankedServer + { + ServerId = s.ServerId, + DisplayName = s.DisplayName, + Band = ClassifyBand(s), + Score = FleetHealthScore(s), + Reason = BuildReason(s), + }) + .ToList(); + + return new FleetRollup + { + TotalServers = summaries.Count, + HealthyCount = healthy, + WarningCount = warning, + CriticalCount = critical, + OfflineCount = offline, + ServersWithCollectionFailures = failures, + TotalBlockingEvents = totals.TotalBlockingEvents, + TotalDeadlocks = totals.TotalDeadlocks, + WorstServers = worst, + AdditionalProblemCount = Math.Max(0, problems.Count - worst.Count), + }; + } + + /// <summary> + /// Collapses a card's health to one fleet band — REUSING #1426's banding, mirroring + /// <see cref="ServerSummaryItem.CardBorderBrush"/>: offline collection → Offline; else the card's + /// worst metric band (<see cref="ServerSummaryItem.OverallMetricSeverity"/>) maps Critical → Critical, + /// Warning → Warning, and a stale collection (<see cref="ServerSummaryItem.HasCollectorErrors"/>) is + /// Warning too; otherwise Healthy. No new thresholds are introduced here. + /// </summary> + public static FleetHealthBand ClassifyBand(ServerSummaryItem s) => + ServerHealthClassifier.ClassifyBand(s.IsOnline, s.AwaitingFirstCollection, s.HasCollectorErrors, s.OverallMetricSeverity); + + /// <summary> + /// The worst-first ordering score — the SHARED <see cref="ServerHealthClassifier.FleetHealthScore"/> over + /// the card's raw metrics: band rank dominates (Offline > Critical > Warning > Healthy) in steps + /// of 1000; within a band, how many card metrics are Critical / Warning, then the blocking + deadlock + /// incident count as a tiebreak. + /// </summary> + public static long FleetHealthScore(ServerSummaryItem s) => + ServerHealthClassifier.FleetHealthScore(ClassifyBand(s), s.ToHealthMetrics()); + + /// <summary> + /// A short human reason for the ranking, built from the card's OWN metric displays so it never drifts + /// from what the card shows. Offline states say so; otherwise it names the metrics that are Warning or + /// Critical (CPU / Blocking / Deadlocks / Memory / Threads / Collectors), plus a stale-collection note. + /// </summary> + public static string BuildReason(ServerSummaryItem s) + { + if (s.IsOnline == false) + { + return "Offline — no recent collection"; + } + + if (s.AwaitingFirstCollection) + { + return "Awaiting first collection"; + } + + var parts = new List<string>(); + + if (s.CpuSeverity >= HealthSeverity.Warning) + { + parts.Add($"CPU {s.CpuDisplay}"); + } + if (s.ThreadsSeverity >= HealthSeverity.Warning) + { + parts.Add($"Threads {s.ThreadsDisplay}"); + } + if (s.MemorySeverity >= HealthSeverity.Warning && s.HasMemoryPressure) + { + parts.Add($"Memory: {s.MemoryDetail}"); + } + if (s.BlockingSeverity >= HealthSeverity.Warning && s.BlockingCount > 0) + { + parts.Add($"Blocking {s.BlockingCount}"); + } + if (s.DeadlockSeverity >= HealthSeverity.Warning && s.DeadlockCount > 0) + { + parts.Add($"Deadlocks {s.DeadlockCount}"); + } + if (s.CollectorSeverity >= HealthSeverity.Warning) + { + parts.Add($"{s.FailedCollectorCount} collector{(s.FailedCollectorCount == 1 ? "" : "s")} failing"); + } + if (s.HasCollectorErrors) + { + parts.Add("collection stale"); + } + + return parts.Count > 0 ? string.Join(", ", parts) : "Needs attention"; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ItemHistory.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ItemHistory.cs new file mode 100644 index 000000000..cf37e2db9 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ItemHistory.cs @@ -0,0 +1,446 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// Per-item execution HISTORY reads backing the double-click drill-down windows (Query Stats / Procedure / +/// Query Store): the full per-collection-snapshot row set for one query (by query_hash), procedure (by +/// db+schema+object), or Query Store query (by query_id, across every plan) over the window. This is the +/// FULLER companion to <see cref="ItemTimelinePoint"/> (ViewerDataService.ItemTimeline.cs, #1409): that read +/// returns a five-metric per-interval curve for the slicer overlay; the history windows need every collected +/// column (the deltas, the min/max spreads, the memory-grant / spill / thread columns, the plan identity), so +/// these reads carry Lite's whole <c>Get{QueryStats,ProcedureStats,QueryStore}HistoryAsync</c> column list +/// (LocalDataService.QueryStats.cs / .QueryStore.cs) ported DuckDB → Postgres. Every column was verified +/// present in the shared collector schema (QueryStatsCollector / ProcedureStatsCollector / query_store_stats). +/// +/// <para> +/// Convention deviations from Lite (matching every other viewer read): (1) reads are both-sides window-bound +/// on naive-UTC <c>collection_time</c> ($ positional params, Kind=Unspecified); (2) <c>collection_time</c> is +/// naive UTC and renders through <see cref="ViewerTimeHelper.ForDisplay"/>, while the DMV wall-clock stamps +/// (creation_time / last_execution_time / cached_time / first_execution_time) are the SQL server's own local +/// time and render raw through <see cref="FormatServerClock"/>; (3) Darling's <c>delta_*</c> columns are +/// already per-collection-cycle deltas, so — like the overlay — there is no C# row-over-row differencing +/// (Lite's history rows carry the same collector-computed deltas). Numeric columns read through +/// <c>Convert.ToXxx(GetValue)</c> so a bigint / integer / numeric provider type all bind cleanly. +/// </para> +/// </summary> +public sealed partial class ViewerDataService +{ + // ══════════════════════════════ Query Stats history ══════════════════════════════ + + /// <summary> + /// Every collected query_stats snapshot for one (database, query_hash) over the window — Lite's + /// <c>GetQueryStatsHistoryAsync</c> column list against the base <c>query_stats</c> table (Lite reads its + /// <c>v_query_stats</c> view; Darling's collector writes the deltas directly, so the base table already + /// carries the per-cycle <c>delta_*</c> the view computes). $1 server_id, $2 database_name, $3 query_hash, + /// $4 window start, $5 window end (naive UTC). + /// </summary> + public const string QueryStatsHistorySql = """ + SELECT + collection_time, + delta_execution_count, + delta_worker_time, + delta_elapsed_time, + delta_logical_reads, + delta_logical_writes, + delta_physical_reads, + delta_rows, + delta_spills, + min_dop, + max_dop, + min_worker_time, + max_worker_time, + min_elapsed_time, + max_elapsed_time, + query_plan_hash, + min_grant_kb, + max_grant_kb, + min_used_grant_kb, + max_used_grant_kb, + min_ideal_grant_kb, + max_ideal_grant_kb, + min_reserved_threads, + max_reserved_threads, + min_used_threads, + max_used_threads, + min_physical_reads, + max_physical_reads, + min_rows, + max_rows, + min_spills, + max_spills, + total_clr_time, + creation_time, + last_execution_time, + execution_count, + total_worker_time, + total_elapsed_time, + total_logical_reads, + total_logical_writes, + total_physical_reads, + total_rows, + total_spills, + sql_handle, + plan_handle, + query_hash, + sample_interval_seconds + FROM query_stats + WHERE server_id = $1 + AND database_name = $2 + AND query_hash = $3 + AND collection_time >= $4 + AND collection_time <= $5 + ORDER BY collection_time + """; + + /// <summary>The selected Top-Queries row's full per-collection history over [<paramref name="startUtc"/>, + /// <paramref name="endUtc"/>], ordered by collection time (the drill-down window's grid + metric chart).</summary> + public async Task<List<ViewerQueryStatsHistoryRow>> GetQueryStatsHistoryAsync( + int serverId, string databaseName, string queryHash, DateTime startUtc, DateTime endUtc, + CancellationToken cancellationToken = default) + { + var rows = new List<ViewerQueryStatsHistoryRow>(); + + await using var command = _dataSource.CreateCommand(QueryStatsHistorySql); + AddItemWindowParameters(command, serverId, databaseName, queryHash, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new ViewerQueryStatsHistoryRow + { + CollectionTime = reader.GetDateTime(0), + DeltaExecutions = ReadLong(reader, 1), + DeltaCpuUs = ReadLong(reader, 2), + DeltaElapsedUs = ReadLong(reader, 3), + DeltaLogicalReads = ReadLong(reader, 4), + DeltaLogicalWrites = ReadLong(reader, 5), + DeltaPhysicalReads = ReadLong(reader, 6), + DeltaRows = ReadLong(reader, 7), + DeltaSpills = ReadLong(reader, 8), + MinDop = ReadInt(reader, 9), + MaxDop = ReadInt(reader, 10), + MinCpuUs = ReadLong(reader, 11), + MaxCpuUs = ReadLong(reader, 12), + MinElapsedUs = ReadLong(reader, 13), + MaxElapsedUs = ReadLong(reader, 14), + QueryPlanHash = reader.IsDBNull(15) ? "" : reader.GetString(15), + MinGrantKb = ReadLong(reader, 16), + MaxGrantKb = ReadLong(reader, 17), + MinUsedGrantKb = ReadLong(reader, 18), + MaxUsedGrantKb = ReadLong(reader, 19), + MinIdealGrantKb = ReadLong(reader, 20), + MaxIdealGrantKb = ReadLong(reader, 21), + MinReservedThreads = ReadLong(reader, 22), + MaxReservedThreads = ReadLong(reader, 23), + MinUsedThreads = ReadLong(reader, 24), + MaxUsedThreads = ReadLong(reader, 25), + MinPhysicalReads = ReadLong(reader, 26), + MaxPhysicalReads = ReadLong(reader, 27), + MinRows = ReadLong(reader, 28), + MaxRows = ReadLong(reader, 29), + MinSpills = ReadLong(reader, 30), + MaxSpills = ReadLong(reader, 31), + TotalClrTimeUs = ReadLong(reader, 32), + CreationTime = reader.IsDBNull(33) ? null : reader.GetDateTime(33), + LastExecutionTime = reader.IsDBNull(34) ? null : reader.GetDateTime(34), + TotalExecutions = ReadLong(reader, 35), + TotalCpuUs = ReadLong(reader, 36), + TotalElapsedUs = ReadLong(reader, 37), + TotalLogicalReads = ReadLong(reader, 38), + TotalLogicalWrites = ReadLong(reader, 39), + TotalPhysicalReads = ReadLong(reader, 40), + TotalRows = ReadLong(reader, 41), + TotalSpills = ReadLong(reader, 42), + SqlHandle = reader.IsDBNull(43) ? "" : reader.GetString(43), + PlanHandle = reader.IsDBNull(44) ? "" : reader.GetString(44), + QueryHash = reader.IsDBNull(45) ? "" : reader.GetString(45), + SampleIntervalSeconds = reader.IsDBNull(46) ? null : ReadInt(reader, 46), + }); + } + + return rows; + } + + // ══════════════════════════════ Procedure stats history ══════════════════════════════ + + /// <summary> + /// Every collected procedure_stats snapshot for one (database, schema, object) over the window — Lite's + /// <c>GetProcedureStatsHistoryAsync</c> column list against the base <c>procedure_stats</c> table. + /// procedure_stats has no <c>sample_interval_seconds</c> column (unlike query_stats), so — exactly as Lite + /// does — the interval is derived per row from the gap to the previous collection via + /// <c>LAG(collection_time)</c> (identical syntax in Postgres). $1 server_id, $2 database_name, $3 + /// schema_name, $4 object_name, $5 window start, $6 window end (naive UTC). + /// </summary> + public const string ProcStatsHistorySql = """ + SELECT + collection_time, + delta_execution_count, + delta_worker_time, + delta_elapsed_time, + delta_logical_reads, + delta_logical_writes, + delta_physical_reads, + min_worker_time, + max_worker_time, + min_elapsed_time, + max_elapsed_time, + total_spills, + min_logical_reads, + max_logical_reads, + min_physical_reads, + max_physical_reads, + min_logical_writes, + max_logical_writes, + min_spills, + max_spills, + sql_handle, + plan_handle, + cached_time, + last_execution_time, + object_type, + execution_count, + total_worker_time, + total_elapsed_time, + total_logical_reads, + total_physical_reads, + total_logical_writes, + delta_spills, + CAST(extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (ORDER BY collection_time)))) AS bigint) AS sample_interval_seconds + FROM procedure_stats + WHERE server_id = $1 + AND database_name = $2 + AND schema_name = $3 + AND object_name = $4 + AND collection_time >= $5 + AND collection_time <= $6 + ORDER BY collection_time + """; + + /// <summary>The selected Top-Procedures row's full per-collection history over [<paramref name="startUtc"/>, + /// <paramref name="endUtc"/>], ordered by collection time.</summary> + public async Task<List<ViewerProcedureStatsHistoryRow>> GetProcedureStatsHistoryAsync( + int serverId, string databaseName, string schemaName, string objectName, DateTime startUtc, DateTime endUtc, + CancellationToken cancellationToken = default) + { + var rows = new List<ViewerProcedureStatsHistoryRow>(); + + await using var command = _dataSource.CreateCommand(ProcStatsHistorySql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = databaseName ?? "" }); + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = schemaName ?? "" }); + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = objectName ?? "" }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified) }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified) }); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new ViewerProcedureStatsHistoryRow + { + CollectionTime = reader.GetDateTime(0), + DeltaExecutions = ReadLong(reader, 1), + DeltaCpuUs = ReadLong(reader, 2), + DeltaElapsedUs = ReadLong(reader, 3), + DeltaLogicalReads = ReadLong(reader, 4), + DeltaLogicalWrites = ReadLong(reader, 5), + DeltaPhysicalReads = ReadLong(reader, 6), + MinWorkerTimeUs = ReadLong(reader, 7), + MaxWorkerTimeUs = ReadLong(reader, 8), + MinElapsedTimeUs = ReadLong(reader, 9), + MaxElapsedTimeUs = ReadLong(reader, 10), + TotalSpills = ReadLong(reader, 11), + MinLogicalReads = ReadLong(reader, 12), + MaxLogicalReads = ReadLong(reader, 13), + MinPhysicalReads = ReadLong(reader, 14), + MaxPhysicalReads = ReadLong(reader, 15), + MinLogicalWrites = ReadLong(reader, 16), + MaxLogicalWrites = ReadLong(reader, 17), + MinSpills = ReadLong(reader, 18), + MaxSpills = ReadLong(reader, 19), + SqlHandle = reader.IsDBNull(20) ? "" : reader.GetString(20), + PlanHandle = reader.IsDBNull(21) ? "" : reader.GetString(21), + CachedTime = reader.IsDBNull(22) ? null : reader.GetDateTime(22), + LastExecutionTime = reader.IsDBNull(23) ? null : reader.GetDateTime(23), + ObjectType = reader.IsDBNull(24) ? "" : reader.GetString(24), + TotalExecutions = ReadLong(reader, 25), + TotalCpuUs = ReadLong(reader, 26), + TotalElapsedUs = ReadLong(reader, 27), + TotalLogicalReads = ReadLong(reader, 28), + TotalPhysicalReads = ReadLong(reader, 29), + TotalLogicalWrites = ReadLong(reader, 30), + DeltaSpills = ReadLong(reader, 31), + SampleIntervalSeconds = reader.IsDBNull(32) ? null : ReadInt(reader, 32), + }); + } + + return rows; + } + + // ══════════════════════════════ Query Store history ══════════════════════════════ + + /// <summary> + /// Every collected query_store_stats snapshot for one (database, query_id) over the window — Lite's + /// <c>GetQueryStoreHistoryAsync</c> column list against the base <c>query_store_stats</c> table. Query-scoped + /// (NOT plan-filtered): the window returns every plan the query ran under so plan switches / regressions + /// draw as separate chart series, exactly like Lite. Duration / CPU / CLR convert us → ms and memory pages + /// → MB in SQL (mirroring the DuckDB casts). $1 server_id, $2 database_name, $3 query_id, $4 window start, + /// $5 window end (naive UTC). + /// </summary> + public const string QueryStoreHistorySql = """ + SELECT + collection_time, + execution_count, + CAST(avg_duration_us AS double precision) / 1000.0 AS avg_duration_ms, + CAST(avg_cpu_time_us AS double precision) / 1000.0 AS avg_cpu_time_ms, + CAST(avg_logical_io_reads AS double precision) AS avg_logical_reads, + CAST(avg_logical_io_writes AS double precision) AS avg_logical_writes, + CAST(avg_physical_io_reads AS double precision) AS avg_physical_reads, + CAST(avg_rowcount AS double precision) AS avg_rowcount, + last_execution_time, + min_dop, + max_dop, + execution_type_desc, + first_execution_time, + module_name, + CAST(avg_num_physical_io_reads AS double precision) AS avg_num_physical_reads, + CAST(min_num_physical_io_reads AS double precision) AS min_num_physical_reads, + CAST(max_num_physical_io_reads AS double precision) AS max_num_physical_reads, + CAST(avg_clr_time_us AS double precision) / 1000.0 AS avg_clr_time_ms, + CAST(min_clr_time_us AS double precision) / 1000.0 AS min_clr_time_ms, + CAST(max_clr_time_us AS double precision) / 1000.0 AS max_clr_time_ms, + CAST(avg_log_bytes_used AS double precision) / 1048576.0 AS avg_log_mb, + CAST(min_log_bytes_used AS double precision) / 1048576.0 AS min_log_mb, + CAST(max_log_bytes_used AS double precision) / 1048576.0 AS max_log_mb, + plan_id, + CAST(min_duration_us AS double precision) / 1000.0 AS min_duration_ms, + CAST(max_duration_us AS double precision) / 1000.0 AS max_duration_ms, + CAST(min_cpu_time_us AS double precision) / 1000.0 AS min_cpu_time_ms, + CAST(max_cpu_time_us AS double precision) / 1000.0 AS max_cpu_time_ms, + CAST(min_logical_io_reads AS double precision) AS min_logical_reads, + CAST(max_logical_io_reads AS double precision) AS max_logical_reads, + CAST(min_logical_io_writes AS double precision) AS min_logical_writes, + CAST(max_logical_io_writes AS double precision) AS max_logical_writes, + CAST(min_physical_io_reads AS double precision) AS min_physical_reads, + CAST(max_physical_io_reads AS double precision) AS max_physical_reads, + CAST(min_rowcount AS double precision) AS min_rowcount, + CAST(max_rowcount AS double precision) AS max_rowcount, + CAST(avg_query_max_used_memory AS double precision) * 8.0 / 1024.0 AS avg_memory_mb, + CAST(min_query_max_used_memory AS double precision) * 8.0 / 1024.0 AS min_memory_mb, + CAST(max_query_max_used_memory AS double precision) * 8.0 / 1024.0 AS max_memory_mb, + CAST(avg_tempdb_space_used AS double precision) * 8.0 / 1024.0 AS avg_tempdb_mb, + CAST(min_tempdb_space_used AS double precision) * 8.0 / 1024.0 AS min_tempdb_mb, + CAST(max_tempdb_space_used AS double precision) * 8.0 / 1024.0 AS max_tempdb_mb, + plan_type, + is_forced_plan, + force_failure_count, + last_force_failure_reason, + plan_forcing_type, + compatibility_level, + query_hash, + query_plan_hash + FROM query_store_stats + WHERE server_id = $1 + AND database_name = $2 + AND query_id = $3 + AND collection_time >= $4 + AND collection_time <= $5 + ORDER BY collection_time + """; + + /// <summary>The selected Query Store row's full per-collection history for its query_id (every plan) over + /// [<paramref name="startUtc"/>, <paramref name="endUtc"/>], ordered by collection time.</summary> + public async Task<List<ViewerQueryStoreHistoryRow>> GetQueryStoreHistoryAsync( + int serverId, string databaseName, long queryId, DateTime startUtc, DateTime endUtc, + CancellationToken cancellationToken = default) + { + var rows = new List<ViewerQueryStoreHistoryRow>(); + + await using var command = _dataSource.CreateCommand(QueryStoreHistorySql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = databaseName ?? "" }); + command.Parameters.Add(new NpgsqlParameter<long> { TypedValue = queryId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified) }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified) }); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new ViewerQueryStoreHistoryRow + { + CollectionTime = reader.GetDateTime(0), + ExecutionCount = ReadLong(reader, 1), + AvgDurationMs = ReadDouble(reader, 2), + AvgCpuTimeMs = ReadDouble(reader, 3), + AvgLogicalReads = ReadDouble(reader, 4), + AvgLogicalWrites = ReadDouble(reader, 5), + AvgPhysicalReads = ReadDouble(reader, 6), + AvgRowcount = ReadDouble(reader, 7), + LastExecutionTime = reader.IsDBNull(8) ? null : reader.GetDateTime(8), + MinDop = ReadLong(reader, 9), + MaxDop = ReadLong(reader, 10), + ExecutionTypeDesc = reader.IsDBNull(11) ? "" : reader.GetString(11), + FirstExecutionTime = reader.IsDBNull(12) ? null : reader.GetDateTime(12), + ModuleName = reader.IsDBNull(13) ? "" : reader.GetString(13), + AvgNumPhysicalReads = ReadDouble(reader, 14), + MinNumPhysicalReads = ReadDouble(reader, 15), + MaxNumPhysicalReads = ReadDouble(reader, 16), + AvgClrTimeMs = ReadDouble(reader, 17), + MinClrTimeMs = ReadDouble(reader, 18), + MaxClrTimeMs = ReadDouble(reader, 19), + AvgLogMb = ReadDouble(reader, 20), + MinLogMb = ReadDouble(reader, 21), + MaxLogMb = ReadDouble(reader, 22), + PlanId = ReadLong(reader, 23), + MinDurationMs = ReadDouble(reader, 24), + MaxDurationMs = ReadDouble(reader, 25), + MinCpuTimeMs = ReadDouble(reader, 26), + MaxCpuTimeMs = ReadDouble(reader, 27), + MinLogicalReads = ReadDouble(reader, 28), + MaxLogicalReads = ReadDouble(reader, 29), + MinLogicalWrites = ReadDouble(reader, 30), + MaxLogicalWrites = ReadDouble(reader, 31), + MinPhysicalReads = ReadDouble(reader, 32), + MaxPhysicalReads = ReadDouble(reader, 33), + MinRowcount = ReadDouble(reader, 34), + MaxRowcount = ReadDouble(reader, 35), + AvgMemoryMb = ReadDouble(reader, 36), + MinMemoryMb = ReadDouble(reader, 37), + MaxMemoryMb = ReadDouble(reader, 38), + AvgTempdbMb = ReadDouble(reader, 39), + MinTempdbMb = ReadDouble(reader, 40), + MaxTempdbMb = ReadDouble(reader, 41), + PlanType = reader.IsDBNull(42) ? "" : reader.GetString(42), + IsForcedPlan = !reader.IsDBNull(43) && reader.GetBoolean(43), + ForceFailureCount = ReadLong(reader, 44), + LastForceFailureReason = reader.IsDBNull(45) ? "" : reader.GetString(45), + PlanForcingType = reader.IsDBNull(46) ? "" : reader.GetString(46), + CompatibilityLevel = ReadInt(reader, 47), + QueryHash = reader.IsDBNull(48) ? "" : reader.GetString(48), + QueryPlanHash = reader.IsDBNull(49) ? "" : reader.GetString(49), + }); + } + + return rows; + } + + // ── Type-tolerant column readers (the history reads pull raw bigint / integer / numeric columns) ── + + private static long ReadLong(NpgsqlDataReader reader, int ordinal) + => reader.IsDBNull(ordinal) ? 0L : Convert.ToInt64(reader.GetValue(ordinal)); + + private static int ReadInt(NpgsqlDataReader reader, int ordinal) + => reader.IsDBNull(ordinal) ? 0 : Convert.ToInt32(reader.GetValue(ordinal)); + + private static double ReadDouble(NpgsqlDataReader reader, int ordinal) + => reader.IsDBNull(ordinal) ? 0.0 : Convert.ToDouble(reader.GetValue(ordinal)); +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ItemTimeline.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ItemTimeline.cs new file mode 100644 index 000000000..8256b1b96 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ItemTimeline.cs @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// Per-item execution-timeline reads backing the slicer overlay-on-select (#683): selecting a Top Queries / +/// Top Procedures / Query Store row overlays THAT item's activity curve on its sub-tab's slicer. Ports Lite's +/// <c>ServerTab.Grids.cs</c> overlay feed (GetQueryStatsHistoryAsync / GetProcedureStatsHistoryAsync / +/// GetQueryStoreHistoryAsync, filtered to one item) to Postgres. One deviation from Lite: Darling's +/// <c>delta_*</c> columns are already per-collection-cycle deltas (Lite's history rows carry cumulative +/// values it diffs row-over-row), so the per-interval magnitude is read directly — no C# differencing. Query +/// Store keeps its per-execution averages, scaled by <c>execution_count</c> to a per-interval total the way +/// the Query Store slicer aggregate does. +/// </summary> +public sealed partial class ViewerDataService +{ + /// <summary>One point on an item's execution timeline: the collection cycle's per-interval magnitudes + /// in the same units the slicer's sort-driven metric overlay uses (ms for CPU/elapsed, raw counts for + /// reads/writes).</summary> + public sealed record ItemTimelinePoint( + DateTime CollectionTime, double CpuMs, double ElapsedMs, double Reads, double Writes, double PhysicalReads); + + public const string QueryStatsItemTimelineSql = """ + SELECT + collection_time, + COALESCE(delta_worker_time, 0) / 1000.0 AS cpu_ms, + COALESCE(delta_elapsed_time, 0) / 1000.0 AS elapsed_ms, + COALESCE(delta_logical_reads, 0) AS reads, + COALESCE(delta_logical_writes, 0) AS writes, + COALESCE(delta_physical_reads, 0) AS physical_reads + FROM query_stats + WHERE server_id = $1 + AND database_name = $2 + AND query_hash = $3 + AND collection_time >= $4 + AND collection_time <= $5 + ORDER BY collection_time + """; + + /// <summary>The selected Top-Queries row's per-interval execution timeline over the window.</summary> + public async Task<List<ItemTimelinePoint>> GetQueryStatsItemTimelineAsync( + int serverId, string databaseName, string queryHash, DateTime startUtc, DateTime endUtc, + CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(QueryStatsItemTimelineSql); + AddItemWindowParameters(command, serverId, databaseName, queryHash, startUtc, endUtc); + return await ReadItemTimelineAsync(command, cancellationToken); + } + + public const string ProcStatsItemTimelineSql = """ + SELECT + collection_time, + COALESCE(delta_worker_time, 0) / 1000.0 AS cpu_ms, + COALESCE(delta_elapsed_time, 0) / 1000.0 AS elapsed_ms, + COALESCE(delta_logical_reads, 0) AS reads, + COALESCE(delta_logical_writes, 0) AS writes, + COALESCE(delta_physical_reads, 0) AS physical_reads + FROM procedure_stats + WHERE server_id = $1 + AND database_name = $2 + AND schema_name = $3 + AND object_name = $4 + AND collection_time >= $5 + AND collection_time <= $6 + ORDER BY collection_time + """; + + /// <summary>The selected Top-Procedures row's per-interval execution timeline over the window.</summary> + public async Task<List<ItemTimelinePoint>> GetProcStatsItemTimelineAsync( + int serverId, string databaseName, string schemaName, string objectName, DateTime startUtc, DateTime endUtc, + CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(ProcStatsItemTimelineSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = databaseName ?? "" }); + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = schemaName ?? "" }); + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = objectName ?? "" }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified) }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified) }); + return await ReadItemTimelineAsync(command, cancellationToken); + } + + public const string QueryStoreItemTimelineSql = """ + SELECT + collection_time, + COALESCE(CAST(avg_cpu_time_us AS double precision) * execution_count, 0) / 1000.0 AS cpu_ms, + COALESCE(CAST(avg_duration_us AS double precision) * execution_count, 0) / 1000.0 AS elapsed_ms, + COALESCE(CAST(avg_logical_io_reads AS double precision) * execution_count, 0) AS reads, + COALESCE(CAST(avg_logical_io_writes AS double precision) * execution_count, 0) AS writes, + COALESCE(CAST(avg_physical_io_reads AS double precision) * execution_count, 0) AS physical_reads + FROM query_store_stats + WHERE server_id = $1 + AND database_name = $2 + AND query_id = $3 + AND plan_id = $4 + AND collection_time >= $5 + AND collection_time <= $6 + ORDER BY collection_time + """; + + /// <summary>The selected Query Store row's per-interval execution timeline (avg × exec count) over the window.</summary> + public async Task<List<ItemTimelinePoint>> GetQueryStoreItemTimelineAsync( + int serverId, string databaseName, long queryId, long planId, DateTime startUtc, DateTime endUtc, + CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(QueryStoreItemTimelineSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = databaseName ?? "" }); + command.Parameters.Add(new NpgsqlParameter<long> { TypedValue = queryId }); + command.Parameters.Add(new NpgsqlParameter<long> { TypedValue = planId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified) }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified) }); + return await ReadItemTimelineAsync(command, cancellationToken); + } + + /// <summary>$1 server_id, $2 database_name, $3 query_hash, $4 start, $5 end (window naive UTC).</summary> + private static void AddItemWindowParameters( + NpgsqlCommand command, int serverId, string databaseName, string queryHash, DateTime startUtc, DateTime endUtc) + { + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = databaseName ?? "" }); + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = queryHash ?? "" }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified) }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified) }); + } + + private static async Task<List<ItemTimelinePoint>> ReadItemTimelineAsync( + NpgsqlCommand command, CancellationToken cancellationToken) + { + var points = new List<ItemTimelinePoint>(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + points.Add(new ItemTimelinePoint( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : Convert.ToDouble(reader.GetValue(1)), + reader.IsDBNull(2) ? 0 : Convert.ToDouble(reader.GetValue(2)), + reader.IsDBNull(3) ? 0 : Convert.ToDouble(reader.GetValue(3)), + reader.IsDBNull(4) ? 0 : Convert.ToDouble(reader.GetValue(4)), + reader.IsDBNull(5) ? 0 : Convert.ToDouble(reader.GetValue(5)))); + } + return points; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.JobHistory.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.JobHistory.cs new file mode 100644 index 000000000..36d9f0454 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.JobHistory.cs @@ -0,0 +1,301 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// One retained job-history row for the Job History tab (issue #1433) — a single collected +/// <c>sysjobhistory</c> row (a step or the step_id 0 job-outcome). The Darling twin of Lite's +/// <c>JobHistoryRow</c>: the same duration / step / retry display helpers and the same failure / +/// long-runtime / retry flags for the grid's color-coding. run_datetime is the monitored server's LOCAL +/// wall clock, so <see cref="ViewerDataService.GetJobHistoryAsync"/> de-skews it to naive-UTC in SQL +/// (subtracting <c>server_properties.utc_offset_minutes</c>) before it reaches this row — exactly like the +/// Default Trace reader — so <see cref="RunTimeLocal"/> renders through the same +/// <see cref="ViewerTimeHelper.ForDisplay"/> as every other viewer grid and sorts consistently with them. +/// </summary> +public sealed class ViewerJobHistoryRow +{ + public int ServerId { get; init; } + public string ServerName { get; init; } = ""; + public long InstanceId { get; init; } + public string JobId { get; init; } = ""; + public string JobName { get; init; } = ""; + public bool JobEnabled { get; init; } + public string? CategoryName { get; init; } + public int StepId { get; init; } + public string? StepName { get; init; } + public int RunStatus { get; init; } + public string? RunStatusDesc { get; init; } + + /// <summary>run_datetime de-skewed to naive-UTC in SQL (server-local minus utc_offset_minutes).</summary> + public DateTime? RunDateTimeUtc { get; init; } + + public long RunDurationSeconds { get; init; } + public int RetriesAttempted { get; init; } + public string? Message { get; init; } + public DateTime? LastSuccessfulRunUtc { get; init; } + public bool IsLongRunning { get; init; } + + /// <summary>Stored naive-UTC; shown in the viewer machine's local time (the viewer convention).</summary> + public string RunTimeLocal => RunDateTimeUtc is { } t + ? ViewerTimeHelper.ForDisplay(t).ToString("yyyy-MM-dd HH:mm:ss") + : ""; + + public string LastSuccessfulRunLocal => LastSuccessfulRunUtc is { } t + ? ViewerTimeHelper.ForDisplay(t).ToString("yyyy-MM-dd HH:mm:ss") + : "Never"; + + public string DurationFormatted => FormatDuration(RunDurationSeconds); + + public string StepDisplay => StepId == 0 ? "(Job outcome)" : $"{StepId}: {StepName}"; + + public string RetriesDisplay => RetriesAttempted > 0 ? RetriesAttempted.ToString(System.Globalization.CultureInfo.InvariantCulture) : ""; + + public string JobEnabledDisplay => JobEnabled ? "Yes" : "No"; + + public bool IsFailed => RunStatus == 0; + public bool IsSucceeded => RunStatus == 1; + public bool IsRetry => RunStatus == 2; + public bool IsCanceled => RunStatus == 3; + + private static string FormatDuration(long seconds) + { + if (seconds < 60) return $"{seconds}s"; + if (seconds < 3600) return $"{seconds / 60}m {seconds % 60}s"; + return $"{seconds / 3600}h {(seconds % 3600) / 60}m"; + } +} + +public sealed partial class ViewerDataService +{ + /// <summary> + /// Retained SQL Agent job-run history for the fleet Job History tab. Reads the BASE + /// <c>job_history</c> table (no <c>v_*</c> view — a collector added after V14 has none; the bare name + /// resolves through the store's <c>search_path</c>, like default_trace_events / server_properties), + /// windowing on the run time so "last N hours/days" means jobs that RAN in that window (a first-run + /// backfill of a year of history does not flood a short window the way a collection_time filter would). + /// <para> + /// run_datetime is the monitored server's LOCAL wall clock, so it is DE-SKEWED to naive-UTC in SQL — + /// subtracting the collected <c>server_properties.utc_offset_minutes</c> (per-server latest, 0 when none + /// yet) — and windowed against the naive-UTC bounds ($1), so the returned timestamps share the viewer's + /// UTC frame and render/sort consistently on the tab (the same de-skew the Default Trace reader uses). + /// Long-runtime is computed reader-side via a per-job window function (a step_id 0 outcome exceeding 2x + /// its job's average successful-outcome duration, floored at 60s), and each row carries its job's last + /// successful outcome run. With no <paramref name="serverId"/> it aggregates ALL servers (the tab + /// default); with one it scopes to that server (the Server filter combo). + /// </para> + /// </summary> + public async Task<List<ViewerJobHistoryRow>> GetJobHistoryAsync( + DateTime sinceUtc, int? serverId = null, int limit = 2000, CancellationToken cancellationToken = default) + { + var serverFilter = serverId.HasValue ? "AND jh.server_id = $2" : string.Empty; + var limitParam = serverId.HasValue ? "$3" : "$2"; + + var sql = $@" +WITH svr AS ( + SELECT DISTINCT ON (server_id) + server_id, + utc_offset_minutes + FROM server_properties + WHERE utc_offset_minutes IS NOT NULL + ORDER BY server_id, collection_time DESC +), +base AS ( + SELECT + jh.server_id, + jh.server_name, + jh.instance_id, + jh.job_id, + jh.job_name, + jh.job_enabled, + jh.category_name, + jh.step_id, + jh.step_name, + jh.run_status, + jh.run_status_desc, + jh.run_datetime - make_interval(mins => COALESCE(svr.utc_offset_minutes, 0)) AS run_datetime_utc, + jh.run_duration_seconds, + jh.retries_attempted, + jh.message, + AVG(CASE WHEN jh.step_id = 0 AND jh.run_status = 1 THEN jh.run_duration_seconds END) + OVER (PARTITION BY jh.server_id, jh.job_id) AS avg_success_duration, + MAX(CASE WHEN jh.step_id = 0 AND jh.run_status = 1 + THEN jh.run_datetime - make_interval(mins => COALESCE(svr.utc_offset_minutes, 0)) END) + OVER (PARTITION BY jh.server_id, jh.job_id) AS last_success_run_utc + FROM job_history AS jh + LEFT JOIN svr ON svr.server_id = jh.server_id + WHERE jh.run_datetime - make_interval(mins => COALESCE(svr.utc_offset_minutes, 0)) >= $1 + {serverFilter} +) +SELECT + server_id, + server_name, + instance_id, + job_id, + job_name, + job_enabled, + category_name, + step_id, + step_name, + run_status, + run_status_desc, + run_datetime_utc, + run_duration_seconds, + retries_attempted, + message, + last_success_run_utc, + CASE + WHEN step_id = 0 + AND avg_success_duration IS NOT NULL + AND avg_success_duration > 0 + AND run_duration_seconds > avg_success_duration * 2 + AND run_duration_seconds > 60 + THEN true + ELSE false + END AS is_long_running +FROM base +ORDER BY run_datetime_utc DESC, instance_id DESC +LIMIT {limitParam}"; + + var rows = new List<ViewerJobHistoryRow>(); + + await using var command = _dataSource.CreateCommand(sql); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(sinceUtc, DateTimeKind.Unspecified) }); + if (serverId.HasValue) + { + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId.Value }); + } + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = limit }); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new ViewerJobHistoryRow + { + ServerId = reader.IsDBNull(0) ? 0 : reader.GetInt32(0), + ServerName = reader.IsDBNull(1) ? "" : reader.GetString(1), + InstanceId = reader.IsDBNull(2) ? 0 : reader.GetInt64(2), + JobId = reader.IsDBNull(3) ? "" : reader.GetString(3), + JobName = reader.IsDBNull(4) ? "" : reader.GetString(4), + JobEnabled = !reader.IsDBNull(5) && reader.GetBoolean(5), + CategoryName = reader.IsDBNull(6) ? null : reader.GetString(6), + StepId = reader.IsDBNull(7) ? 0 : reader.GetInt32(7), + StepName = reader.IsDBNull(8) ? null : reader.GetString(8), + RunStatus = reader.IsDBNull(9) ? 0 : reader.GetInt32(9), + RunStatusDesc = reader.IsDBNull(10) ? null : reader.GetString(10), + RunDateTimeUtc = reader.IsDBNull(11) ? null : reader.GetDateTime(11), + RunDurationSeconds = reader.IsDBNull(12) ? 0 : reader.GetInt64(12), + RetriesAttempted = reader.IsDBNull(13) ? 0 : reader.GetInt32(13), + Message = reader.IsDBNull(14) ? null : reader.GetString(14), + LastSuccessfulRunUtc = reader.IsDBNull(15) ? null : reader.GetDateTime(15), + IsLongRunning = !reader.IsDBNull(16) && reader.GetBoolean(16), + }); + } + + return rows; + } + + /// <summary> + /// The latest SQL Agent status snapshot per server (issue #1433 Phase 2) — Running/Stopped, startup + /// type, and next scheduled run — read from the base <c>agent_status</c> table (newest row per server). + /// <c>next_scheduled_run</c> is the server's local wall clock (from msdb), so it is de-skewed to + /// naive-UTC in SQL (like the job run times) and rendered in the viewer's local time. With no + /// <paramref name="serverId"/> it returns one row per server (the fleet header roll-up); with one it + /// scopes to that server. The tab header consumes this; the "Agent Not Running" self-alert reads the same + /// <c>agent_status</c> data service-side. + /// </summary> + public async Task<List<ViewerAgentStatusRow>> GetAgentStatusAsync(int? serverId = null, CancellationToken cancellationToken = default) + { + var serverFilter = serverId.HasValue ? "WHERE a.server_id = $1" : string.Empty; + + var sql = $@" +WITH svr AS ( + SELECT DISTINCT ON (server_id) + server_id, + utc_offset_minutes + FROM server_properties + WHERE utc_offset_minutes IS NOT NULL + ORDER BY server_id, collection_time DESC +), +latest AS ( + SELECT + a.server_id, + a.server_name, + a.agent_running, + a.agent_status_desc, + a.agent_startup_desc, + a.next_scheduled_run - make_interval(mins => COALESCE(svr.utc_offset_minutes, 0)) AS next_scheduled_run_utc, + ROW_NUMBER() OVER (PARTITION BY a.server_id ORDER BY a.collection_time DESC) AS rn + FROM agent_status AS a + LEFT JOIN svr ON svr.server_id = a.server_id + {serverFilter} +) +SELECT + server_id, + server_name, + agent_running, + agent_status_desc, + agent_startup_desc, + next_scheduled_run_utc +FROM latest +WHERE rn = 1 +ORDER BY server_name"; + + var rows = new List<ViewerAgentStatusRow>(); + + await using var command = _dataSource.CreateCommand(sql); + if (serverId.HasValue) + { + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId.Value }); + } + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new ViewerAgentStatusRow + { + ServerId = reader.IsDBNull(0) ? 0 : reader.GetInt32(0), + ServerName = reader.IsDBNull(1) ? "" : reader.GetString(1), + AgentRunning = !reader.IsDBNull(2) && reader.GetBoolean(2), + AgentStatusDesc = reader.IsDBNull(3) ? null : reader.GetString(3), + AgentStartupDesc = reader.IsDBNull(4) ? null : reader.GetString(4), + NextScheduledRunUtc = reader.IsDBNull(5) ? null : reader.GetDateTime(5), + }); + } + + return rows; + } +} + +/// <summary> +/// The latest SQL Agent status snapshot for one server (issue #1433 Phase 2) — the Darling twin of Lite's +/// <c>AgentStatusRow</c>. Drives the Job History tab header and the "Agent Not Running" self-alert. +/// next_scheduled_run is de-skewed to naive-UTC in SQL and rendered in the viewer's local time. +/// </summary> +public sealed class ViewerAgentStatusRow +{ + public int ServerId { get; init; } + public string ServerName { get; init; } = ""; + public bool AgentRunning { get; init; } + public string? AgentStatusDesc { get; init; } + public string? AgentStartupDesc { get; init; } + public DateTime? NextScheduledRunUtc { get; init; } + + public string StatusDisplay => AgentRunning ? "Running" : (AgentStatusDesc ?? "Stopped"); + + public string NextScheduledRunLocal => NextScheduledRunUtc is { } t + ? ViewerTimeHelper.ForDisplay(t).ToString("yyyy-MM-dd HH:mm:ss") + : "None scheduled"; +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.LatchSpinlock.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.LatchSpinlock.cs new file mode 100644 index 000000000..6141e6d1b --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.LatchSpinlock.cs @@ -0,0 +1,276 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary>One point on a latch class's per-second wait trend (this interval's +/// <c>delta_wait_time_ms</c> divided by the seconds since the previous collection of the SAME +/// class, via a per-class <c>LAG</c> window — the wait-stats idiom, minus the avg-per-wait metric).</summary> +public sealed record LatchStatsTrendPoint(string LatchClass, DateTime CollectionTime, double WaitTimeMsPerSecond); + +/// <summary>One row of the Latch Stats latest-snapshot grid: the cumulative counters plus the last +/// interval's deltas for one latch class at the most recent collection in the window.</summary> +public sealed record LatchStatsSnapshotRow( + string LatchClass, + long WaitingRequestsCount, + long WaitTimeMs, + long MaxWaitTimeMs, + long DeltaWaitingRequestsCount, + long DeltaWaitTimeMs); + +/// <summary>One point on a spinlock's per-second collision trend (this interval's +/// <c>delta_collisions</c> divided by the seconds since the previous collection of the SAME +/// spinlock, via a per-name <c>LAG</c> window).</summary> +public sealed record SpinlockStatsTrendPoint(string SpinlockName, DateTime CollectionTime, double CollisionsPerSecond); + +/// <summary>One row of the Spinlock Stats latest-snapshot grid: the cumulative counters plus the +/// last interval's deltas for one spinlock at the most recent collection in the window.</summary> +public sealed record SpinlockStatsSnapshotRow( + string SpinlockName, + long Collisions, + long Spins, + double SpinsPerCollision, + long SleepTime, + long Backoffs, + long DeltaCollisions, + long DeltaSpins); + +public sealed partial class ViewerDataService +{ + /// <summary> + /// The Latch Stats trend read: the per-second wait rate for the TOP 5 latch classes (by total delta + /// wait time over the window), mirroring the Dashboard's <c>GetLatchStatsTopNAsync</c> top-5 grouping + /// but normalizing to ms/sec in SQL via the per-class <c>LAG</c> interval (Darling's cumulative-delta + /// tables have no stored <c>sample_interval_seconds</c>, so the seconds come from the same + /// truncate-then-diff epoch idiom the wait-stats trend uses). Runs on the <c>v_latch_stats</c> + /// passthrough view. $1 server_id, $2 window start, $3 window end (all naive UTC). + /// </summary> + public const string LatchTrendSql = """ + WITH top_latches AS + ( + SELECT latch_class + FROM v_latch_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + GROUP BY latch_class + ORDER BY SUM(delta_wait_time_ms) DESC + LIMIT 5 + ), + raw AS + ( + SELECT + latch_class, + collection_time, + delta_wait_time_ms, + extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (PARTITION BY latch_class ORDER BY collection_time)))) AS interval_seconds + FROM v_latch_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND latch_class IN (SELECT latch_class FROM top_latches) + ) + SELECT + latch_class, + collection_time, + CASE WHEN interval_seconds > 0 THEN CAST(delta_wait_time_ms AS DOUBLE PRECISION) / interval_seconds ELSE 0 END AS wait_time_ms_per_second + FROM raw + ORDER BY latch_class, collection_time + """; + + /// <summary> + /// The Latch Stats latest-snapshot grid read: every latch class captured at the most recent + /// collection in the window, ordered by the last interval's delta wait time (recent contention) + /// then cumulative wait time, capped at 20 rows. $1 server_id, $2 start, $3 end (naive UTC). + /// </summary> + public const string LatchSnapshotSql = """ + WITH latest AS + ( + SELECT MAX(collection_time) AS mx + FROM v_latch_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + ) + SELECT + latch_class, + waiting_requests_count, + wait_time_ms, + max_wait_time_ms, + delta_waiting_requests_count, + delta_wait_time_ms + FROM v_latch_stats + WHERE server_id = $1 + AND collection_time = (SELECT mx FROM latest) + ORDER BY delta_wait_time_ms DESC, wait_time_ms DESC + LIMIT 20 + """; + + /// <summary> + /// The Spinlock Stats trend read: the per-second collision rate for the TOP 5 spinlocks (by total + /// delta collisions over the window), the collision analog of <see cref="LatchTrendSql"/> — top-5 + /// grouping mirrors the Dashboard's <c>GetSpinlockStatsTopNAsync</c>, collisions/sec via the per-name + /// <c>LAG</c> interval. Runs on <c>v_spinlock_stats</c>. $1 server_id, $2 start, $3 end (naive UTC). + /// </summary> + public const string SpinlockTrendSql = """ + WITH top_spinlocks AS + ( + SELECT spinlock_name + FROM v_spinlock_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + GROUP BY spinlock_name + ORDER BY SUM(delta_collisions) DESC + LIMIT 5 + ), + raw AS + ( + SELECT + spinlock_name, + collection_time, + delta_collisions, + extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (PARTITION BY spinlock_name ORDER BY collection_time)))) AS interval_seconds + FROM v_spinlock_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND spinlock_name IN (SELECT spinlock_name FROM top_spinlocks) + ) + SELECT + spinlock_name, + collection_time, + CASE WHEN interval_seconds > 0 THEN CAST(delta_collisions AS DOUBLE PRECISION) / interval_seconds ELSE 0 END AS collisions_per_second + FROM raw + ORDER BY spinlock_name, collection_time + """; + + /// <summary> + /// The Spinlock Stats latest-snapshot grid read: every spinlock captured at the most recent + /// collection in the window, ordered by the last interval's delta collisions then cumulative + /// collisions, capped at 20 rows. $1 server_id, $2 start, $3 end (naive UTC). + /// </summary> + public const string SpinlockSnapshotSql = """ + WITH latest AS + ( + SELECT MAX(collection_time) AS mx + FROM v_spinlock_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + ) + SELECT + spinlock_name, + collisions, + spins, + spins_per_collision, + sleep_time, + backoffs, + delta_collisions, + delta_spins + FROM v_spinlock_stats + WHERE server_id = $1 + AND collection_time = (SELECT mx FROM latest) + ORDER BY delta_collisions DESC, collisions DESC + LIMIT 20 + """; + + /// <summary>The per-second wait trend for the top-5 latch classes over the window (empty when none).</summary> + public async Task<List<LatchStatsTrendPoint>> GetLatchStatsTrendAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var result = new List<LatchStatsTrendPoint>(); + + await using var command = _dataSource.CreateCommand(LatchTrendSql); + AddWindowParameters(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + result.Add(new LatchStatsTrendPoint( + reader.GetString(0), + reader.GetDateTime(1), + reader.IsDBNull(2) ? 0 : reader.GetDouble(2))); + } + + return result; + } + + /// <summary>The latest-snapshot latch-class rows (top 20 by recent delta wait) for the window.</summary> + public async Task<List<LatchStatsSnapshotRow>> GetLatchStatsSnapshotAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var result = new List<LatchStatsSnapshotRow>(); + + await using var command = _dataSource.CreateCommand(LatchSnapshotSql); + AddWindowParameters(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + result.Add(new LatchStatsSnapshotRow( + reader.GetString(0), + reader.IsDBNull(1) ? 0 : reader.GetInt64(1), + reader.IsDBNull(2) ? 0 : reader.GetInt64(2), + reader.IsDBNull(3) ? 0 : reader.GetInt64(3), + reader.IsDBNull(4) ? 0 : reader.GetInt64(4), + reader.IsDBNull(5) ? 0 : reader.GetInt64(5))); + } + + return result; + } + + /// <summary>The per-second collision trend for the top-5 spinlocks over the window (empty when none).</summary> + public async Task<List<SpinlockStatsTrendPoint>> GetSpinlockStatsTrendAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var result = new List<SpinlockStatsTrendPoint>(); + + await using var command = _dataSource.CreateCommand(SpinlockTrendSql); + AddWindowParameters(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + result.Add(new SpinlockStatsTrendPoint( + reader.GetString(0), + reader.GetDateTime(1), + reader.IsDBNull(2) ? 0 : reader.GetDouble(2))); + } + + return result; + } + + /// <summary>The latest-snapshot spinlock rows (top 20 by recent delta collisions) for the window.</summary> + public async Task<List<SpinlockStatsSnapshotRow>> GetSpinlockStatsSnapshotAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var result = new List<SpinlockStatsSnapshotRow>(); + + await using var command = _dataSource.CreateCommand(SpinlockSnapshotSql); + AddWindowParameters(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + result.Add(new SpinlockStatsSnapshotRow( + reader.GetString(0), + reader.IsDBNull(1) ? 0 : reader.GetInt64(1), + reader.IsDBNull(2) ? 0 : reader.GetInt64(2), + reader.IsDBNull(3) ? 0 : reader.GetDouble(3), + reader.IsDBNull(4) ? 0 : reader.GetInt64(4), + reader.IsDBNull(5) ? 0 : reader.GetInt64(5), + reader.IsDBNull(6) ? 0 : reader.GetInt64(6), + reader.IsDBNull(7) ? 0 : reader.GetInt64(7))); + } + + return result; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.LivePlan.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.LivePlan.cs new file mode 100644 index 000000000..2ded6c566 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.LivePlan.cs @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The viewer's typed wrapper over the Stage-2 <c>fetch_plan</c> command — the missing live-plan path. The +/// viewer holds no SqlClient connection to a monitored server, but the SERVICE keeps a live runtime connection +/// to every one, so a "fetch this process's plan from the live cache" request rides the SAME command channel +/// <c>test_connect</c> / <c>snapshot_now</c> use: the viewer enqueues a <c>fetch_plan</c> row carrying the +/// handle to fetch (a plan_handle for a query-grid row, or a sql_handle + statement offsets for a deadlock-graph +/// / blocked-process process), the service reads the plan off the target server's plan cache, and the viewer +/// polls for the plan XML in <c>result_json</c>. +/// +/// <para>Like every other command it is a WRITE (enqueue), so a read-only <c>viewer</c> seat throws +/// <see cref="ViewerReadOnlyException"/> — correct, and consistent with Pause / Snapshot: enqueuing a command is +/// a config write a read-only seat cannot make. The command row is DELETED after its terminal result is read +/// (like <c>test_connect</c>): the plan XML in <c>result_json</c> can be large and there is no reason to retain +/// it in <c>config_command</c> once the viewer has it.</para> +/// </summary> +public sealed partial class ViewerDataService +{ + /// <summary>The command type the viewer enqueues for a live plan fetch (must match the service executor's case).</summary> + public const string CommandFetchPlan = "fetch_plan"; + + private static readonly JsonSerializerOptions s_planFetchArgsOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + }; + + private static readonly JsonSerializerOptions s_planFetchResultOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + /// <summary> + /// Builds the <c>args_json</c> for a fetch BY PLAN_HANDLE (the query-grids path — the currently-cached plan + /// for a query/procedure row, distinct from the stored one). Pure — pinned by Darling.Tests against the + /// service's <c>PlanFetchRequest.TryParse</c>. + /// </summary> + public static string BuildPlanFetchArgsByPlanHandle(string planHandle, string? databaseName) + => JsonSerializer.Serialize( + new PlanFetchArgs { PlanHandle = planHandle, DatabaseName = NullIfBlank(databaseName) }, + s_planFetchArgsOptions); + + /// <summary> + /// Builds the <c>args_json</c> for a fetch BY SQL_HANDLE + statement offsets (the deadlock-graph / + /// blocked-process path — the plan for a process whose only key is a sql_handle from an executionStack + /// frame). Pure — pinned by Darling.Tests against the service's <c>PlanFetchRequest.TryParse</c>. + /// </summary> + public static string BuildPlanFetchArgsBySqlHandle( + string sqlHandle, int statementStartOffset, int statementEndOffset, string? databaseName) + => JsonSerializer.Serialize( + new PlanFetchArgs + { + SqlHandle = sqlHandle, + StatementStartOffset = statementStartOffset, + StatementEndOffset = statementEndOffset, + DatabaseName = NullIfBlank(databaseName), + }, + s_planFetchArgsOptions); + + /// <summary> + /// Enqueues a <c>fetch_plan</c> with the given <paramref name="argsJson"/> (built by one of the Build* + /// helpers), polls for its terminal result, deletes the (possibly large) command row, and maps the outcome + /// to a <see cref="LivePlanFetchResult"/>. A read-only seat throws <see cref="ViewerReadOnlyException"/> from + /// the enqueue (a read-only seat cannot command the service). Returns <see cref="LivePlanFetchStatus.TimedOut"/> + /// when the service does not report within the budget (the caller shows "still fetching / try again"). + /// </summary> + public async Task<LivePlanFetchResult> FetchLivePlanAsync( + int serverId, string argsJson, CancellationToken cancellationToken = default) + { + var commandId = await EnqueueCommandAsync(CommandFetchPlan, serverId, argsJson, RequestedBy(), cancellationToken); + var result = await PollCommandResultAsync(commandId, DefaultCommandTimeout, cancellationToken); + + if (result is not null) + { + try + { + await DeleteCommandAsync(commandId, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + /* The plan-bearing row could not be cleaned up now; the service-side reaper is the backstop. + Never let cleanup hide the plan the user is waiting on. */ + } + } + + return ParsePlanFetchResult(result); + } + + /// <summary> + /// Maps a polled command result to a <see cref="LivePlanFetchResult"/> (pure — pinned by Darling.Tests): + /// a null poll → <see cref="LivePlanFetchStatus.TimedOut"/>; a <c>succeeded</c> row → the plan XML from + /// <c>result_json</c> (or <see cref="LivePlanFetchStatus.NotInCache"/> when it is null); a <c>failed</c> row → + /// <see cref="LivePlanFetchStatus.Failed"/> with the service's error / status message. + /// </summary> + public static LivePlanFetchResult ParsePlanFetchResult(CommandResult? result) + { + if (result is null) + { + return new LivePlanFetchResult(LivePlanFetchStatus.TimedOut, null, + "The service did not return the plan in time. It may still be fetching — try again."); + } + + if (result.Status == StatusSucceeded) + { + var planXml = ReadStringField(result.ResultJson, "planXml"); + return string.IsNullOrEmpty(planXml) + ? new LivePlanFetchResult(LivePlanFetchStatus.NotInCache, null, + "The plan is no longer in the server's plan cache (it was evicted or recompiled since it was captured).") + : new LivePlanFetchResult(LivePlanFetchStatus.Fetched, planXml, null); + } + + var error = ReadStringField(result.ResultJson, "error"); + return new LivePlanFetchResult(LivePlanFetchStatus.Failed, null, + !string.IsNullOrEmpty(error) ? error + : !string.IsNullOrEmpty(result.ResultStatus) ? result.ResultStatus + : "The service could not fetch the plan."); + } + + private static string? ReadStringField(string? json, string field) + { + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + try + { + using var document = JsonDocument.Parse(json); + if (document.RootElement.ValueKind == JsonValueKind.Object + && document.RootElement.TryGetProperty(field, out var element) + && element.ValueKind == JsonValueKind.String) + { + return element.GetString(); + } + } + catch (JsonException) + { + /* Malformed result_json — degrade to null. */ + } + + return null; + } + + private static string? NullIfBlank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value; + + /// <summary>The wire shape of a <c>fetch_plan</c> <c>args_json</c> (camelCase to match the service's parser).</summary> + private sealed class PlanFetchArgs + { + public string? PlanHandle { get; set; } + public string? SqlHandle { get; set; } + public int? StatementStartOffset { get; set; } + public int? StatementEndOffset { get; set; } + public string? DatabaseName { get; set; } + } +} + +/// <summary>The terminal state of a live plan fetch — the viewer's plan handlers switch on it.</summary> +public enum LivePlanFetchStatus +{ + /// <summary>The plan XML was read from the live cache.</summary> + Fetched, + + /// <summary>The fetch ran but the plan is no longer cached (evicted / recompiled).</summary> + NotInCache, + + /// <summary>The service reported a failure (server not connected, connect / SQL error).</summary> + Failed, + + /// <summary>The service did not report a terminal result within the poll budget.</summary> + TimedOut, +} + +/// <summary>A live plan fetch's outcome: the plan XML on success, else a message to surface.</summary> +public sealed record LivePlanFetchResult(LivePlanFetchStatus Status, string? PlanXml, string? Message); diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.LongQueries.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.LongQueries.cs new file mode 100644 index 000000000..b2093e124 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.LongQueries.cs @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// One long-query completion grid row (#1496): a completed rpc/batch over the duration threshold (with +/// runtime, CPU, I/O, row count, result) or an <c>attention</c> (a cancelled/timed-out query — duration +/// NULL, because the attention event's own duration is cancellation-handling time, not query runtime). +/// Mirrors Lite's <c>LongQueryCompletionRow</c>; event_time is stored naive-UTC, so +/// <see cref="EventTimeLocal"/> converts to viewer-local via <see cref="ViewerTimeHelper.ForDisplay"/>. +/// </summary> +public sealed class ViewerLongQueryRow +{ + public DateTime CollectionTime { get; set; } + public DateTime? EventTime { get; set; } + public string EventType { get; set; } = ""; + public string DatabaseName { get; set; } = ""; + public int? SessionId { get; set; } + public string ClientAppName { get; set; } = ""; + public int? ClientPid { get; set; } + public string NtUserName { get; set; } = ""; + public string ServerPrincipalName { get; set; } = ""; + public string QueryHash { get; set; } = ""; + public long? EventSequence { get; set; } + public long? DurationMicroseconds { get; set; } + public long? CpuTimeMicroseconds { get; set; } + public long? PhysicalReads { get; set; } + public long? LogicalReads { get; set; } + public long? Writes { get; set; } + public long? RowCountValue { get; set; } + public string Result { get; set; } = ""; + public string StatementText { get; set; } = ""; + public string ObjectName { get; set; } = ""; + + public string EventTimeLocal + => EventTime is { } eventTime ? ViewerTimeHelper.ForDisplay(eventTime).ToString("yyyy-MM-dd HH:mm:ss") : ""; + + public string DurationFormatted => FormatMicroseconds(DurationMicroseconds); + public string CpuFormatted => FormatMicroseconds(CpuTimeMicroseconds); + + /// <summary>An attention row is a cancelled/timed-out query — no runtime, secondary evidence.</summary> + public bool IsAttention => string.Equals(EventType, "attention", StringComparison.OrdinalIgnoreCase); + + /// <summary>A completed event aborted mid-flight (client cancel / query timeout) — the row tints.</summary> + public bool IsAborted => string.Equals(Result, "Abort", StringComparison.OrdinalIgnoreCase); + + private static string FormatMicroseconds(long? microseconds) => + microseconds is not long us ? "" : us < 1_000_000 ? $"{us / 1000.0:F0} ms" : $"{us / 1_000_000.0:F1} sec"; +} + +public sealed partial class ViewerDataService +{ + /// <summary> + /// Recent long-running query completions (#1496) for one server, from the BASE + /// <c>long_query_completions</c> table (a post-V14 collector has no <c>v_*</c> passthrough view). Same + /// windowed shape as the blocking read (event_time DESC, LIMIT 200); the grid applies a view-only + /// DESCENDING-by-duration sort. $1 server_id, $2 window start, $3 window end (naive UTC), $4 db filter. + /// </summary> + public const string LongQueryCompletionsSql = """ + SELECT + collection_time, + event_time, + event_type, + database_name, + session_id, + client_app_name, + client_pid, + nt_username, + server_principal_name, + query_hash, + event_sequence, + duration_microseconds, + cpu_time_microseconds, + physical_reads, + logical_reads, + writes, + row_count, + result, + statement_text, + object_name + FROM long_query_completions + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND ($4::text[] IS NULL OR database_name = ANY($4)) + ORDER BY event_time DESC + LIMIT 200 + """; + + public async Task<List<ViewerLongQueryRow>> GetRecentLongQueryCompletionsAsync( + int serverId, DateTime startUtc, DateTime endUtc, IReadOnlyList<string>? databaseNames = null, CancellationToken cancellationToken = default) + { + var rows = new List<ViewerLongQueryRow>(); + + await using var command = _dataSource.CreateCommand(LongQueryCompletionsSql); + AddWindowParameters(command, serverId, startUtc, endUtc); + command.Parameters.Add(DatabaseFilterParameter(databaseNames)); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new ViewerLongQueryRow + { + CollectionTime = reader.GetDateTime(0), + EventTime = reader.IsDBNull(1) ? null : reader.GetDateTime(1), + EventType = reader.IsDBNull(2) ? "" : reader.GetString(2), + DatabaseName = reader.IsDBNull(3) ? "" : reader.GetString(3), + SessionId = reader.IsDBNull(4) ? null : reader.GetInt32(4), + ClientAppName = reader.IsDBNull(5) ? "" : reader.GetString(5), + ClientPid = reader.IsDBNull(6) ? null : reader.GetInt32(6), + NtUserName = reader.IsDBNull(7) ? "" : reader.GetString(7), + ServerPrincipalName = reader.IsDBNull(8) ? "" : reader.GetString(8), + QueryHash = reader.IsDBNull(9) ? "" : reader.GetString(9), + EventSequence = reader.IsDBNull(10) ? null : reader.GetInt64(10), + DurationMicroseconds = reader.IsDBNull(11) ? null : reader.GetInt64(11), + CpuTimeMicroseconds = reader.IsDBNull(12) ? null : reader.GetInt64(12), + PhysicalReads = reader.IsDBNull(13) ? null : reader.GetInt64(13), + LogicalReads = reader.IsDBNull(14) ? null : reader.GetInt64(14), + Writes = reader.IsDBNull(15) ? null : reader.GetInt64(15), + RowCountValue = reader.IsDBNull(16) ? null : reader.GetInt64(16), + Result = reader.IsDBNull(17) ? "" : reader.GetString(17), + StatementText = reader.IsDBNull(18) ? "" : reader.GetString(18), + ObjectName = reader.IsDBNull(19) ? "" : reader.GetString(19), + }); + } + + return rows; + } + + /// <summary> + /// The effective enabled state of the opt-in <c>long_query_completions</c> collector for one server, + /// resolved from <c>config_collector_schedules</c> (per-server override > fleet override > the + /// shared default), so the Long Queries tab can show its "trace is OFF" empty-state banner. Mirrors the + /// service's <c>StoreConfigProvider.ResolveSchedule</c> enabled precedence; the shared default for this + /// collector is OFF (CollectorScheduleDefaults DefaultEnabled=false — the viewer doesn't reference the + /// collector library, so the false default is inlined here rather than read from it). + /// </summary> + public async Task<bool> GetLongQueryTraceEnabledAsync(int serverId, CancellationToken cancellationToken = default) + { + var overrides = await GetCollectorSchedulesAsync(cancellationToken); + + bool? perServer = null; + bool? fleet = null; + foreach (var o in overrides) + { + if (!string.Equals(o.CollectorName, "long_query_completions", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (o.ServerId == serverId) + { + perServer = o.Enabled; + } + else if (o.ServerId is null) + { + fleet = o.Enabled; + } + } + + return perServer ?? fleet ?? false; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Memory.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Memory.cs new file mode 100644 index 000000000..34d583b4c --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Memory.cs @@ -0,0 +1,423 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary>The Memory Overview summary snapshot — the latest memory_stats row, a mirror of Lite's +/// <c>MemoryStatsRow</c> (<c>LocalDataService.Memory.cs</c>). The eight MB metrics are +/// <c>numeric(18,2)</c> in the store and CAST to double precision in the read for the typed GetDouble +/// reader; the two state strings are text. Lite's computed <c>UsedPhysicalMemoryMb</c> / +/// <c>MemoryUtilizationPercent</c> helpers are dropped — the summary strip displays only these fields.</summary> +public sealed record MemoryStatsRow( + DateTime CollectionTime, + double TotalPhysicalMemoryMb, + double AvailablePhysicalMemoryMb, + double TotalPageFileMb, + double AvailablePageFileMb, + string SystemMemoryState, + string SqlMemoryModel, + double TargetServerMemoryMb, + double TotalServerMemoryMb, + double BufferPoolMb, + double PlanCacheMb); + +/// <summary>One point of the Memory Overview's memory-grant overlay line: total granted MB across all +/// pools at a collection. Lite crammed this into its mutable <c>MemoryTrendPoint.TotalGrantedMb</c> and +/// left the other fields 0; the viewer's <see cref="MemoryTrendPoint"/> is an immutable positional +/// record with no grant slot, so the overlay gets its own two-field record.</summary> +public sealed record MemoryGrantTrendPoint( + DateTime CollectionTime, + double TotalGrantedMb); + +/// <summary>One point on a selected memory clerk's trend line: its memory footprint (MB) at a +/// collection. Mirror of Lite's <c>MemoryClerkTrendPoint</c> — the clerk type is the dictionary key of +/// the batched read, so it is not repeated on each point.</summary> +public sealed record MemoryClerkTrendPoint( + DateTime CollectionTime, + double MemoryMb); + +/// <summary>One aggregated Memory Grants chart point (per collection_time + resource pool): the three +/// sizing MB metrics, the workspace-memory ceiling (<see cref="TargetMemoryMb"/> = the semaphore's current +/// grant target, <see cref="MaxTargetMemoryMb"/> = its hard maximum — the granted-vs-ceiling headroom +/// signal the Dashboard's get_resource_semaphore surfaces), and the four activity counts. A mirror of Lite's +/// <c>MemoryGrantChartPoint</c> plus the ceiling columns. MB SUMs CAST to double precision; the count SUMs +/// CAST to bigint (Postgres <c>SUM(integer)</c> widens to bigint and <c>SUM(bigint)</c> to numeric — the CAST +/// keeps the typed GetInt64 reader happy either way, the same reason the perfmon trend CASTs its +/// aggregates).</summary> +public sealed record MemoryGrantChartPoint( + DateTime CollectionTime, + int PoolId, + double AvailableMemoryMb, + double GrantedMemoryMb, + double UsedMemoryMb, + int GranteeCount, + int WaiterCount, + long TimeoutErrorCountDelta, + long ForcedGrantCountDelta, + double TargetMemoryMb, + double MaxTargetMemoryMb); + +/// <summary>One RING_BUFFER_RESOURCE_MONITOR sample for the Memory Pressure Events chart, a mirror of +/// Lite's <c>MemoryPressureEventRow</c>: the sample time plus SQL Server (process) and OS (system) +/// pressure indicators. The chart hour-buckets these and counts indicator>=2 as pressure.</summary> +public sealed record MemoryPressureEventRow( + DateTime SampleTime, + string MemoryNotification, + int MemoryIndicatorsProcess, + int MemoryIndicatorsSystem); + +/// <summary> +/// The Memory inner tab's reads (W1j viewer copy-parity), ported from Lite's +/// <c>LocalDataService.Memory.cs</c> / <c>LocalDataService.MemoryGrants.cs</c> to Postgres. The Overview's +/// memory trend (Lite's <c>GetMemoryTrendAsync</c>) is REUSED as-is from +/// <c>ViewerDataService.OverviewLanes.cs</c> (the W1d Overview lanes added it for the buffer-pool lane) — +/// only the reads that did not already exist live here: the latest-snapshot summary, the grant overlay, +/// the clerk picker's distinct + batched-trend reads, the grant sizing/activity chart data, and the +/// pressure-event samples. All run against the <c>v_memory_stats</c> / <c>v_memory_grant_stats</c> / +/// <c>v_memory_clerks</c> / <c>v_memory_pressure_events</c> passthrough views (the Darling-analysis +/// convention); the last two views are created by migration V7 (V4/V5 left them out). Window bounds bind +/// naive-UTC (Kind-Unspecified), like every other viewer read. +/// </summary> +public sealed partial class ViewerDataService +{ + /// <summary> + /// The Overview summary snapshot — Lite's <c>GetLatestMemoryStatsAsync</c> ported to Postgres: the + /// most recent memory_stats row for the server (no window; ORDER BY collection_time DESC LIMIT 1). + /// The eight MB metrics are CAST to double precision for the typed reader. $1 server_id. + /// </summary> + public const string LatestMemoryStatsSql = """ + SELECT + collection_time, + CAST(total_physical_memory_mb AS double precision) AS total_physical_memory_mb, + CAST(available_physical_memory_mb AS double precision) AS available_physical_memory_mb, + CAST(total_page_file_mb AS double precision) AS total_page_file_mb, + CAST(available_page_file_mb AS double precision) AS available_page_file_mb, + system_memory_state, + sql_memory_model, + CAST(target_server_memory_mb AS double precision) AS target_server_memory_mb, + CAST(total_server_memory_mb AS double precision) AS total_server_memory_mb, + CAST(buffer_pool_mb AS double precision) AS buffer_pool_mb, + CAST(plan_cache_mb AS double precision) AS plan_cache_mb + FROM v_memory_stats + WHERE server_id = $1 + ORDER BY collection_time DESC + LIMIT 1 + """; + + /// <summary> + /// The Overview's memory-grant overlay — Lite's <c>GetMemoryGrantTrendAsync</c> ported to Postgres: + /// total granted MB across all pools per collection. SUM CAST to double precision. Lite selected four + /// dummy 0-columns to reuse its <c>MemoryTrendPoint</c> shape; the viewer reads only the two fields it + /// plots. $1 server_id, $2 window start, $3 window end (naive UTC). + /// </summary> + public const string MemoryGrantTrendSql = """ + SELECT + collection_time, + CAST(SUM(granted_memory_mb) AS double precision) AS total_granted_mb + FROM v_memory_grant_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + GROUP BY collection_time + ORDER BY collection_time + """; + + /// <summary> + /// The clerk picker's population read — Lite's <c>GetDistinctMemoryClerkTypesAsync</c> ported to + /// Postgres verbatim: every clerk_type collected for the server over the window, ranked by total + /// memory descending so the picker + "Top Clerks" fill see the heaviest clerks first. $1 server_id, + /// $2 window start, $3 window end (naive UTC). + /// </summary> + public const string DistinctMemoryClerkTypesSql = """ + SELECT + clerk_type + FROM v_memory_clerks + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + GROUP BY clerk_type + ORDER BY SUM(memory_mb) DESC + """; + + /// <summary> + /// Lite's batched <c>GetMemoryClerkTrendsByTypesAsync</c> body, ported to Postgres: the trend for ALL + /// selected clerk types in ONE query (replacing an N+1 loop), grouped by clerk type. The + /// <c>clerk_type IN (...)</c> list is built dynamically from <c>$4</c> onward + /// (server_id/start/end take <c>$1</c>/<c>$2</c>/<c>$3</c>), exactly like Lite's numbering. memory_mb + /// is <c>numeric(18,2)</c> in the store, CAST to double precision for the typed reader. A caller + /// passing <paramref name="typeCount"/> = 0 is a bug the <see cref="GetMemoryClerkTrendsByTypesAsync"/> + /// guard prevents. + /// </summary> + public static string MemoryClerkTrendsSql(int typeCount) + { + var typeParams = string.Join(", ", Enumerable.Range(0, typeCount).Select(i => "$" + (i + 4))); + return $""" + SELECT + clerk_type, + collection_time, + CAST(memory_mb AS double precision) AS memory_mb + FROM v_memory_clerks + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND clerk_type IN ({typeParams}) + ORDER BY clerk_type, collection_time + """; + } + + /// <summary> + /// The Memory Grants chart data — Lite's <c>GetMemoryGrantChartDataAsync</c> ported to Postgres: + /// per collection_time + pool_id, the summed sizing MB (available/granted/used) and activity counts + /// (grantees/waiters/timeouts/forced). The MB SUMs CAST to double precision; the count SUMs CAST to + /// bigint (see <see cref="MemoryGrantChartPoint"/>). $1 server_id, $2 window start, $3 window end + /// (naive UTC). + /// </summary> + public const string MemoryGrantChartDataSql = """ + SELECT + collection_time, + pool_id, + CAST(SUM(available_memory_mb) AS double precision) AS available_memory_mb, + CAST(SUM(granted_memory_mb) AS double precision) AS granted_memory_mb, + CAST(SUM(used_memory_mb) AS double precision) AS used_memory_mb, + CAST(SUM(grantee_count) AS bigint) AS grantee_count, + CAST(SUM(waiter_count) AS bigint) AS waiter_count, + CAST(SUM(timeout_error_count_delta) AS bigint) AS timeout_error_count_delta, + CAST(SUM(forced_grant_count_delta) AS bigint) AS forced_grant_count_delta, + CAST(SUM(target_memory_mb) AS double precision) AS target_memory_mb, + CAST(SUM(max_target_memory_mb) AS double precision) AS max_target_memory_mb + FROM v_memory_grant_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + GROUP BY collection_time, pool_id + ORDER BY collection_time, pool_id + """; + + /// <summary> + /// The Memory Pressure Events samples — Lite's <c>GetMemoryPressureEventsAsync</c> ported to Postgres: + /// the RING_BUFFER_RESOURCE_MONITOR samples over the window, windowed on <c>sample_time</c> (the + /// payload's own clock, not collection_time — the memory_pressure_events index keys on it). $1 + /// server_id, $2 window start, $3 window end (naive UTC). + /// </summary> + public const string MemoryPressureEventsSql = """ + SELECT + sample_time, + memory_notification, + memory_indicators_process, + memory_indicators_system + FROM v_memory_pressure_events + WHERE server_id = $1 + AND sample_time >= $2 + AND sample_time <= $3 + ORDER BY sample_time + """; + + /// <summary>The most recent memory_stats snapshot for the Overview summary strip, or null when the + /// server has no memory_stats rows yet.</summary> + public async Task<MemoryStatsRow?> GetLatestMemoryStatsAsync( + int serverId, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(LatestMemoryStatsSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + { + return null; + } + + return new MemoryStatsRow( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : reader.GetDouble(1), + reader.IsDBNull(2) ? 0 : reader.GetDouble(2), + reader.IsDBNull(3) ? 0 : reader.GetDouble(3), + reader.IsDBNull(4) ? 0 : reader.GetDouble(4), + reader.IsDBNull(5) ? "" : reader.GetString(5), + reader.IsDBNull(6) ? "" : reader.GetString(6), + reader.IsDBNull(7) ? 0 : reader.GetDouble(7), + reader.IsDBNull(8) ? 0 : reader.GetDouble(8), + reader.IsDBNull(9) ? 0 : reader.GetDouble(9), + reader.IsDBNull(10) ? 0 : reader.GetDouble(10)); + } + + /// <summary>Total granted MB across all pools per collection over the window — the Overview memory + /// chart's grant overlay line.</summary> + public async Task<List<MemoryGrantTrendPoint>> GetMemoryGrantTrendAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var items = new List<MemoryGrantTrendPoint>(); + + await using var command = _dataSource.CreateCommand(MemoryGrantTrendSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified), + }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified), + }); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new MemoryGrantTrendPoint( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : reader.GetDouble(1))); + } + + return items; + } + + /// <summary>The distinct clerk types collected for one server in the window, ranked by total memory + /// descending — feeds the clerk picker's population + "Top Clerks" fill.</summary> + public async Task<List<string>> GetDistinctMemoryClerkTypesAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var items = new List<string>(); + + await using var command = _dataSource.CreateCommand(DistinctMemoryClerkTypesSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified), + }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified), + }); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(reader.GetString(0)); + } + + return items; + } + + /// <summary>The memory trend for every selected clerk type in one query, grouped by clerk type. + /// Empty selection returns an empty map without touching the store.</summary> + public async Task<Dictionary<string, List<MemoryClerkTrendPoint>>> GetMemoryClerkTrendsByTypesAsync( + int serverId, List<string> clerkTypes, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var result = new Dictionary<string, List<MemoryClerkTrendPoint>>(); + if (clerkTypes.Count == 0) + { + return result; + } + + await using var command = _dataSource.CreateCommand(MemoryClerkTrendsSql(clerkTypes.Count)); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified), + }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified), + }); + foreach (var clerkType in clerkTypes) + { + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = clerkType }); + } + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var clerkType = reader.GetString(0); + if (!result.TryGetValue(clerkType, out var list)) + { + list = new List<MemoryClerkTrendPoint>(); + result[clerkType] = list; + } + + list.Add(new MemoryClerkTrendPoint( + reader.GetDateTime(1), + reader.IsDBNull(2) ? 0 : reader.GetDouble(2))); + } + + return result; + } + + /// <summary>The per-pool grant sizing + activity chart data over the window (the Memory Grants + /// sub-tab's two charts).</summary> + public async Task<List<MemoryGrantChartPoint>> GetMemoryGrantChartDataAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var items = new List<MemoryGrantChartPoint>(); + + await using var command = _dataSource.CreateCommand(MemoryGrantChartDataSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified), + }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified), + }); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new MemoryGrantChartPoint( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : reader.GetInt32(1), + reader.IsDBNull(2) ? 0 : reader.GetDouble(2), + reader.IsDBNull(3) ? 0 : reader.GetDouble(3), + reader.IsDBNull(4) ? 0 : reader.GetDouble(4), + reader.IsDBNull(5) ? 0 : (int)reader.GetInt64(5), + reader.IsDBNull(6) ? 0 : (int)reader.GetInt64(6), + reader.IsDBNull(7) ? 0 : reader.GetInt64(7), + reader.IsDBNull(8) ? 0 : reader.GetInt64(8), + reader.IsDBNull(9) ? 0 : reader.GetDouble(9), + reader.IsDBNull(10) ? 0 : reader.GetDouble(10))); + } + + return items; + } + + /// <summary>The RING_BUFFER_RESOURCE_MONITOR pressure samples over the window (the Memory Pressure + /// Events sub-tab's chart), windowed on <c>sample_time</c>.</summary> + public async Task<List<MemoryPressureEventRow>> GetMemoryPressureEventsAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var items = new List<MemoryPressureEventRow>(); + + await using var command = _dataSource.CreateCommand(MemoryPressureEventsSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified), + }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified), + }); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new MemoryPressureEventRow( + reader.GetDateTime(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + reader.IsDBNull(2) ? 0 : reader.GetInt32(2), + reader.IsDBNull(3) ? 0 : reader.GetInt32(3))); + } + + return items; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.MonitoredServers.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.MonitoredServers.cs new file mode 100644 index 000000000..aea0fd354 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.MonitoredServers.cs @@ -0,0 +1,461 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using NpgsqlTypes; +using PerformanceMonitor.Common; +using PerformanceMonitor.Notifications; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The viewer's control-plane writes to <c>config.config_monitored_servers</c> — the desired-state twin of +/// the observed <c>collect.servers</c> registry the Stage-1 <c>StoreConfigProvider</c> reads and reconciles +/// its monitored set from on every reload beacon. Adding/editing a server here makes the running Darling +/// service COLLECT it (on its next ~15s sweep); removing stops it; the enable flag drives collection. This +/// is the write half of the Stage-3 rewire that replaces the severed <c>viewer-servers.json</c>. +/// +/// <para>Mirrors <see cref="GetMuteRulesAsync"/>'s discipline exactly: public-const SQL (so Darling.Tests +/// pin the dialect + column parity with the service's <c>StoreConfigProvider</c> without a live Postgres), +/// all values bound as <c>$N</c> parameters, writes routed through <see cref="ExecuteWriteAsync"/> so a +/// read-only <c>viewer</c> seat degrades to <see cref="ViewerReadOnlyException"/> (SQLSTATE 42501) rather +/// than a silent no-op. Timestamps are set server-side with <c>now() AT TIME ZONE 'UTC'</c> (naive UTC, +/// matching the DDL defaults and the command executor). The bare table name resolves through the +/// <c>collect,config,public</c> search_path to <c>config.config_monitored_servers</c>.</para> +/// +/// <para><b>Identity.</b> <c>server_id</c> is <c>ServerIdHelper.GetDeterministicHashCode(BuildStorageName( +/// host, database, readOnlyIntent))</c> — the SAME identity the collectors stamp and the service's seed uses +/// (<see cref="ComputeServerId"/>), so a viewer-written row JOINs the collected data and the service's +/// reconcile matches it. <b>Secrets.</b> <c>encrypted_password</c> is a DPAPI-LocalMachine blob produced by +/// <see cref="ViewerServerSecret"/> (never plaintext); integrated auth stores none. Azure/Entra auth modes +/// are not written — the service can't honor them (see <see cref="ServerStoreCredential"/>).</para> +/// </summary> +public sealed partial class ViewerDataService +{ + /* The full column list, shared by the upsert (Add/Edit) and the insert-if-absent (migrate-in). The + toggled-boolean-free VALUES bind every field as a parameter; created_at/modified_at are server-side. */ + private const string MonitoredServerColumns = + "server_id, name, host, database, auth, username, encrypted_password, encrypt_mode, " + + "trust_server_certificate, read_only_intent, multi_subnet_failover, excluded_databases, " + + "monthly_cost_usd, capture_plans, is_enabled, alert_delivery_mode_override"; + + private const string MonitoredServerValues = + "$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, " + + "(now() AT TIME ZONE 'UTC'), (now() AT TIME ZONE 'UTC')"; + + /// <summary>Upsert by <c>server_id</c> — the Add/Edit save. ON CONFLICT rewrites every field but + /// <c>created_at</c>, bumping <c>modified_at</c> (and, via the V17 trigger, <c>config_version</c>).</summary> + public const string MonitoredServerUpsertSql = @" +INSERT INTO config_monitored_servers (" + MonitoredServerColumns + @", created_at, modified_at) +VALUES (" + MonitoredServerValues + @") +ON CONFLICT (server_id) DO UPDATE SET + name = EXCLUDED.name, + host = EXCLUDED.host, + database = EXCLUDED.database, + auth = EXCLUDED.auth, + username = EXCLUDED.username, + encrypted_password = EXCLUDED.encrypted_password, + encrypt_mode = EXCLUDED.encrypt_mode, + trust_server_certificate = EXCLUDED.trust_server_certificate, + read_only_intent = EXCLUDED.read_only_intent, + multi_subnet_failover = EXCLUDED.multi_subnet_failover, + excluded_databases = EXCLUDED.excluded_databases, + monthly_cost_usd = EXCLUDED.monthly_cost_usd, + capture_plans = EXCLUDED.capture_plans, + is_enabled = EXCLUDED.is_enabled, + alert_delivery_mode_override = EXCLUDED.alert_delivery_mode_override, + modified_at = (now() AT TIME ZONE 'UTC')"; + + /// <summary>Insert only when the <c>server_id</c> is absent — the one-time <c>viewer-servers.json</c> + /// migrate-in. DO NOTHING so it never overwrites a row the service already seeded from darling.json (no + /// double-seed) or a later viewer edit.</summary> + public const string MonitoredServerInsertIfAbsentSql = @" +INSERT INTO config_monitored_servers (" + MonitoredServerColumns + @", created_at, modified_at) +VALUES (" + MonitoredServerValues + @") +ON CONFLICT (server_id) DO NOTHING"; + + /// <summary>Deletes a server definition (the Remove action) — the service drops it from the monitored + /// set on its next reload. $1 server_id.</summary> + public const string MonitoredServerDeleteSql = "DELETE FROM config_monitored_servers WHERE server_id = $1"; + + /// <summary>Flips just <c>is_enabled</c> without rewriting the other columns (the enable toggle). $1 + /// server_id, $2 enabled.</summary> + public const string MonitoredServerSetEnabledSql = + "UPDATE config_monitored_servers SET is_enabled = $2, modified_at = (now() AT TIME ZONE 'UTC') WHERE server_id = $1"; + + /// <summary>Rewrites just <c>excluded_databases</c> (the Excluded Databases editor). $1 server_id, $2 text[].</summary> + public const string MonitoredServerSetExcludedDatabasesSql = + "UPDATE config_monitored_servers SET excluded_databases = $2, modified_at = (now() AT TIME ZONE 'UTC') WHERE server_id = $1"; + + /// <summary>All configured servers, for the Manage Servers list. Ordered by display name. Deliberately + /// OMITS <c>encrypted_password</c>: the list is a display / sidebar-reconcile read that never needs the + /// secret, and #1416 revoked the read-only <c>viewer</c> role's SELECT on that column — selecting it here + /// would fail the whole list with SQLSTATE 42501 for a read-only seat. The Edit dialog reloads the row + /// (including the DPAPI blob) by id via <see cref="MonitoredServerByIdSql"/>, which is an admin-role action.</summary> + public const string MonitoredServersSelectSql = @" +SELECT server_id, name, host, database, auth, username, encrypt_mode, + trust_server_certificate, read_only_intent, multi_subnet_failover, excluded_databases, + monthly_cost_usd, capture_plans, is_enabled, created_at, alert_delivery_mode_override +FROM config_monitored_servers +ORDER BY name"; + + /// <summary>One configured server by id (the Edit prefill, incl. the DPAPI blob for the password box). $1 server_id. + /// An <c>admin</c>-role action — the read-only <c>viewer</c> role is column-denied <c>encrypted_password</c> + /// (#1416), so a viewer seat uses <see cref="MonitoredServerByIdNoSecretSql"/> instead.</summary> + public const string MonitoredServerByIdSql = @" +SELECT server_id, name, host, database, auth, username, encrypted_password, encrypt_mode, + trust_server_certificate, read_only_intent, multi_subnet_failover, excluded_databases, + monthly_cost_usd, capture_plans, is_enabled, created_at, alert_delivery_mode_override +FROM config_monitored_servers +WHERE server_id = $1"; + + /// <summary>One configured server by id WITHOUT the <c>encrypted_password</c> secret — the read-only + /// <c>viewer</c> Edit prefill (D7). The viewer role lost SELECT on that column (#1416), so the full + /// <see cref="MonitoredServerByIdSql"/> would 42501 for a read-only seat; this projection matches the + /// secret-free LIST columns (so <see cref="ReadMonitoredServerRowNoSecret"/> reads it), leaving the + /// password box empty (the read-only seat can't save an edit anyway). $1 server_id.</summary> + public const string MonitoredServerByIdNoSecretSql = @" +SELECT server_id, name, host, database, auth, username, encrypt_mode, + trust_server_certificate, read_only_intent, multi_subnet_failover, excluded_databases, + monthly_cost_usd, capture_plans, is_enabled, created_at, alert_delivery_mode_override +FROM config_monitored_servers +WHERE server_id = $1"; + + /// <summary>Row count — the migrate-in / reconcile "is the config-server set seeded yet?" guard.</summary> + public const string MonitoredServersCountSql = "SELECT COUNT(*) FROM config_monitored_servers"; + + /// <summary> + /// Whether the service has seeded the config store yet — <c>config_service</c> is written LAST in the + /// Stage-1 seed (its presence marks the seed complete). Distinguishes a genuinely empty managed set (the + /// user removed every server) from a pre-Stage-1 store the service has never seeded, so the sidebar + /// reconcile can be config-authoritative in the former and fall back to <c>collect.servers</c> in the + /// latter (never worse than today). + /// </summary> + public const string ConfigSeededSql = "SELECT EXISTS (SELECT 1 FROM config_service WHERE id = 1)"; + + /// <summary> + /// The managed server set the sidebar shows, sourced from the DESIRED-state + /// <c>config_monitored_servers</c> and enriched with the OBSERVED <c>collect.servers</c> facts (SQL major + /// version, and the collected server/display names) by the shared <c>server_id</c>. This makes a + /// viewer-added server appear immediately (before its first collection) and a removed one disappear at + /// once, instead of waiting for the service's next reconcile. <c>is_enabled</c> and <c>monthly_cost_usd</c> + /// come from config so a viewer toggle/edit is reflected without a round-trip through the service. + /// </summary> + public const string ManagedServersSql = @" +SELECT + c.server_id, + COALESCE(s.server_name, c.host) AS server_name, + COALESCE(s.display_name, c.name) AS display_name, + c.is_enabled, + s.sql_major_version, + c.monthly_cost_usd +FROM config_monitored_servers c +LEFT JOIN servers s ON s.server_id = c.server_id +ORDER BY COALESCE(s.display_name, c.name)"; + + /// <summary> + /// The managed server set for the sidebar. Config-authoritative once the service has seeded the store + /// (added servers appear, removed ones disappear); on a pre-seed store (<c>config_service</c> absent) it + /// falls back to the observed <see cref="GetServersAsync"/> read so the viewer never shows fewer servers + /// than it does today. + /// </summary> + public async Task<List<DarlingServer>> GetManagedServersAsync(CancellationToken cancellationToken = default) + { + if (!await IsConfigSeededAsync(cancellationToken)) + { + return await GetServersAsync(cancellationToken); + } + + var servers = new List<DarlingServer>(); + + await using var command = _dataSource.CreateCommand(ManagedServersSql); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var serverName = reader.GetString(1); + servers.Add(new DarlingServer( + reader.GetInt32(0), + serverName, + reader.IsDBNull(2) ? serverName : reader.GetString(2), + !reader.IsDBNull(3) && reader.GetBoolean(3), + reader.IsDBNull(4) ? null : reader.GetInt32(4), + reader.IsDBNull(5) ? 0m : Convert.ToDecimal(reader.GetValue(5)))); + } + + return servers; + } + + /// <summary> + /// Whether the service has seeded the config store (the reconcile fallback signal). Fails safe to + /// <c>false</c> on ANY non-cancellation error — most importantly a pre-V17 store where + /// <c>config_service</c> does not exist yet (Postgres 42P01): a rolling upgrade where the viewer is newer + /// than the service must degrade to the observed <see cref="GetServersAsync"/> read, never blank the + /// sidebar. Mirrors <see cref="DetectReadOnlyAsync"/>'s fail-safe posture. + /// </summary> + public async Task<bool> IsConfigSeededAsync(CancellationToken cancellationToken = default) + { + try + { + await using var command = _dataSource.CreateCommand(ConfigSeededSql); + var result = await command.ExecuteScalarAsync(cancellationToken); + return result is true; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return false; + } + } + + /// <summary> + /// The shared-identity <c>server_id</c> for a server: FNV-1a hash of the canonical storage name + /// (<c>host[:database][:RO]</c>). Identical to the service's seed + /// (<c>ServerIdHelper.GetDeterministicHashCode(server.StorageName)</c>) and the collectors' stamp, so the + /// row JOINs collected data and the service reconcile matches it. Pure — unit-testable. + /// </summary> + public static int ComputeServerId(string host, string? database, bool readOnlyIntent) => + ServerIdHelper.GetDeterministicHashCode(ServerIdHelper.BuildStorageName(host, database, readOnlyIntent)); + + /// <summary>All configured servers (Manage Servers list + the sidebar reconcile source), read without the + /// <c>encrypted_password</c> secret so a read-only <c>viewer</c> seat can list them (#1416). The Edit dialog + /// reloads the blob by id via <see cref="GetMonitoredServerAsync"/>.</summary> + public async Task<List<MonitoredServerRow>> GetMonitoredServersAsync(CancellationToken cancellationToken = default) + { + var servers = new List<MonitoredServerRow>(); + + await using var command = _dataSource.CreateCommand(MonitoredServersSelectSql); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + servers.Add(ReadMonitoredServerRowNoSecret(reader)); + } + + return servers; + } + + /// <summary>One configured server by id, or null when it is not in the store (the Edit prefill). A read-only + /// <c>viewer</c> seat is column-denied <c>encrypted_password</c> (#1416), so it degrades to the secret-free + /// <see cref="MonitoredServerByIdNoSecretSql"/> projection (D7) — leaving the password box empty — instead + /// of failing the prefill with 42501.</summary> + public async Task<MonitoredServerRow?> GetMonitoredServerAsync(int serverId, CancellationToken cancellationToken = default) + { + if (IsReadOnly) + { + await using var noSecretCommand = _dataSource.CreateCommand(MonitoredServerByIdNoSecretSql); + noSecretCommand.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + await using var noSecretReader = await noSecretCommand.ExecuteReaderAsync(cancellationToken); + return await noSecretReader.ReadAsync(cancellationToken) ? ReadMonitoredServerRowNoSecret(noSecretReader) : null; + } + + await using var command = _dataSource.CreateCommand(MonitoredServerByIdSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + return await reader.ReadAsync(cancellationToken) ? ReadMonitoredServerRow(reader) : null; + } + + /// <summary>How many servers the config-server registry holds (the migrate-in / reconcile guard).</summary> + public async Task<long> GetMonitoredServerCountAsync(CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(MonitoredServersCountSql); + var result = await command.ExecuteScalarAsync(cancellationToken); + return result is null or DBNull ? 0 : Convert.ToInt64(result, System.Globalization.CultureInfo.InvariantCulture); + } + + /// <summary> + /// Upserts a server definition (Add / Edit save). The row's <c>server_id</c> is (re)derived from + /// host/database/read-only-intent so a definition always lands on its shared identity; the caller passes + /// a row whose <see cref="MonitoredServerRow.ServerId"/> is already <see cref="ComputeServerId"/>. + /// </summary> + public async Task UpsertMonitoredServerAsync(MonitoredServerRow row, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(row); + + await using var command = _dataSource.CreateCommand(MonitoredServerUpsertSql); + BindMonitoredServer(command, row); + await ExecuteWriteAsync(command, cancellationToken); + } + + /// <summary> + /// Inserts a server definition only when its <c>server_id</c> is absent — the migrate-in. Returns true + /// when a row was actually written (so the caller can count the imported servers), false when the id + /// already existed (the service seed or a prior migrate already has it). + /// </summary> + public async Task<bool> InsertMonitoredServerIfAbsentAsync(MonitoredServerRow row, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(row); + + await using var command = _dataSource.CreateCommand(MonitoredServerInsertIfAbsentSql); + BindMonitoredServer(command, row); + return await ExecuteWriteAsync(command, cancellationToken) > 0; + } + + /// <summary>Removes a server definition by id (the Remove action).</summary> + public async Task DeleteMonitoredServerAsync(int serverId, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(MonitoredServerDeleteSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + await ExecuteWriteAsync(command, cancellationToken); + } + + /// <summary>Toggles a server's <c>is_enabled</c> flag without rewriting its other columns.</summary> + public async Task SetMonitoredServerEnabledAsync(int serverId, bool enabled, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(MonitoredServerSetEnabledSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<bool> { TypedValue = enabled }); + await ExecuteWriteAsync(command, cancellationToken); + } + + /// <summary>Rewrites a server's excluded-databases list (the Excluded Databases editor).</summary> + public async Task SetMonitoredServerExcludedDatabasesAsync(int serverId, IEnumerable<string> excludedDatabases, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(MonitoredServerSetExcludedDatabasesSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + AddTextArray(command, excludedDatabases); + await ExecuteWriteAsync(command, cancellationToken); + } + + /// <summary>Binds the 15 upsert/insert parameters ($1..$15) from a row (created_at/modified_at are server-side).</summary> + private static void BindMonitoredServer(NpgsqlCommand command, MonitoredServerRow row) + { + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = row.ServerId }); // $1 + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = row.Name }); // $2 + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = row.Host }); // $3 + AddNullableText(command, row.Database); // $4 + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = row.Auth }); // $5 + AddNullableText(command, row.Username); // $6 + AddNullableText(command, row.EncryptedPassword); // $7 + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = row.EncryptMode }); // $8 + command.Parameters.Add(new NpgsqlParameter<bool> { TypedValue = row.TrustServerCertificate }); // $9 + command.Parameters.Add(new NpgsqlParameter<bool> { TypedValue = row.ReadOnlyIntent }); // $10 + command.Parameters.Add(new NpgsqlParameter<bool> { TypedValue = row.MultiSubnetFailover }); // $11 + AddTextArray(command, row.ExcludedDatabases); // $12 + command.Parameters.Add(new NpgsqlParameter<decimal> { TypedValue = row.MonthlyCostUsd }); // $13 + AddNullableBool(command, row.CapturePlans); // $14 + command.Parameters.Add(new NpgsqlParameter<bool> { TypedValue = row.IsEnabled }); // $15 + /* #1236: per-server delivery override as its enum name, or NULL = "inherit the global". */ + AddNullableText(command, row.AlertDeliveryModeOverride?.ToString()); // $16 + } + + private static MonitoredServerRow ReadMonitoredServerRow(NpgsqlDataReader reader) => new() + { + ServerId = reader.GetInt32(0), + Name = reader.GetString(1), + Host = reader.GetString(2), + Database = reader.IsDBNull(3) ? null : reader.GetString(3), + Auth = reader.GetString(4), + Username = reader.IsDBNull(5) ? null : reader.GetString(5), + EncryptedPassword = reader.IsDBNull(6) ? null : reader.GetString(6), + EncryptMode = reader.GetString(7), + TrustServerCertificate = reader.GetBoolean(8), + ReadOnlyIntent = reader.GetBoolean(9), + MultiSubnetFailover = reader.GetBoolean(10), + ExcludedDatabases = reader.IsDBNull(11) ? new List<string>() : reader.GetFieldValue<string[]>(11).ToList(), + MonthlyCostUsd = reader.GetDecimal(12), + CapturePlans = reader.IsDBNull(13) ? null : reader.GetBoolean(13), + IsEnabled = reader.GetBoolean(14), + CreatedAt = reader.IsDBNull(15) ? null : DateTime.SpecifyKind(reader.GetDateTime(15), DateTimeKind.Utc), + AlertDeliveryModeOverride = ParseDeliveryOverride(reader.IsDBNull(16) ? null : reader.GetString(16)), + }; + + /// <summary> + /// Reads a <see cref="MonitoredServerRow"/> from the secret-free LIST projection + /// (<see cref="MonitoredServersSelectSql"/>): identical to <see cref="ReadMonitoredServerRow"/> but the + /// query omits <c>encrypted_password</c> (a read-only <c>viewer</c> seat lost SELECT on it, #1416), so + /// <see cref="MonitoredServerRow.EncryptedPassword"/> stays null and every column after <c>username</c> + /// shifts down one ordinal. The Manage Servers grid never displays the secret; the Edit dialog reloads the + /// blob by id (<see cref="GetMonitoredServerAsync"/>). + /// </summary> + private static MonitoredServerRow ReadMonitoredServerRowNoSecret(NpgsqlDataReader reader) => new() + { + ServerId = reader.GetInt32(0), + Name = reader.GetString(1), + Host = reader.GetString(2), + Database = reader.IsDBNull(3) ? null : reader.GetString(3), + Auth = reader.GetString(4), + Username = reader.IsDBNull(5) ? null : reader.GetString(5), + EncryptedPassword = null, /* not selected — the LIST read omits the secret column (#1416) */ + EncryptMode = reader.GetString(6), + TrustServerCertificate = reader.GetBoolean(7), + ReadOnlyIntent = reader.GetBoolean(8), + MultiSubnetFailover = reader.GetBoolean(9), + ExcludedDatabases = reader.IsDBNull(10) ? new List<string>() : reader.GetFieldValue<string[]>(10).ToList(), + MonthlyCostUsd = reader.GetDecimal(11), + CapturePlans = reader.IsDBNull(12) ? null : reader.GetBoolean(12), + IsEnabled = reader.GetBoolean(13), + CreatedAt = reader.IsDBNull(14) ? null : DateTime.SpecifyKind(reader.GetDateTime(14), DateTimeKind.Utc), + AlertDeliveryModeOverride = ParseDeliveryOverride(reader.IsDBNull(15) ? null : reader.GetString(15)), + }; + + private static void AddTextArray(NpgsqlCommand command, IEnumerable<string>? values) => + command.Parameters.Add(new NpgsqlParameter + { + NpgsqlDbType = NpgsqlDbType.Array | NpgsqlDbType.Text, + Value = (values ?? Enumerable.Empty<string>()).ToArray(), + }); + + private static void AddNullableBool(NpgsqlCommand command, bool? value) => + command.Parameters.Add(new NpgsqlParameter + { + NpgsqlDbType = NpgsqlDbType.Boolean, + Value = value.HasValue ? value.Value : DBNull.Value, + }); + + /// <summary>Parses the nullable <c>alert_delivery_mode_override</c> text ("Summary"/"PerEvent") to the enum; + /// null/empty/unknown = null = "inherit the global delivery mode". Mirrors the service's read parse.</summary> + private static AlertNotificationMode? ParseDeliveryOverride(string? value) => + Enum.TryParse<AlertNotificationMode>(value, ignoreCase: true, out var mode) ? mode : null; +} + +/// <summary> +/// A <c>config.config_monitored_servers</c> row as the viewer authors + reads it — the connection definition +/// the Darling service reconstructs a <c>MonitoredServer</c> from. Deliberately carries only the columns the +/// store (and hence the service) has — now INCLUDING the per-server alert-delivery override (#1236, V18, the +/// service honors it at delivery time); the remaining viewer-only cosmetics some Lite fields kept (description, +/// utility DB, the Azure client ids) are NOT part of the service-honored server model and stay out of the store. +/// <see cref="EncryptedPassword"/> is a DPAPI-LocalMachine blob, never plaintext. Favorites remain viewer-local +/// (<see cref="ViewerServerStore"/>). +/// </summary> +public sealed class MonitoredServerRow +{ + public int ServerId { get; set; } + public string Name { get; set; } = ""; + public string Host { get; set; } = ""; + public string? Database { get; set; } + + /// <summary><see cref="ServerStoreCredential.Integrated"/> or <see cref="ServerStoreCredential.Sql"/>.</summary> + public string Auth { get; set; } = ServerStoreCredential.Integrated; + + public string? Username { get; set; } + + /// <summary>DPAPI-LocalMachine base64 blob (<see cref="ViewerServerSecret.Protect"/>), or null for integrated auth.</summary> + public string? EncryptedPassword { get; set; } + + public string EncryptMode { get; set; } = "Mandatory"; + public bool TrustServerCertificate { get; set; } + public bool ReadOnlyIntent { get; set; } + public bool MultiSubnetFailover { get; set; } + public List<string> ExcludedDatabases { get; set; } = new(); + public decimal MonthlyCostUsd { get; set; } + + /// <summary>Per-server plan-capture override; null = follow the global <c>config_service.capture_plans</c>.</summary> + public bool? CapturePlans { get; set; } + + /// <summary>Per-server deadlock/blocking delivery-mode override (#1236); null = inherit the global + /// <c>config_alert_settings.delivery_mode</c>. Stored as the enum name in <c>alert_delivery_mode_override</c>.</summary> + public AlertNotificationMode? AlertDeliveryModeOverride { get; set; } + + public bool IsEnabled { get; set; } = true; + + /// <summary>Server-set creation time (read-only, from the store's <c>created_at</c>); null when not read.</summary> + public DateTime? CreatedAt { get; set; } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.MuteRules.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.MuteRules.cs new file mode 100644 index 000000000..b79b1159d --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.MuteRules.cs @@ -0,0 +1,263 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using NpgsqlTypes; +using PerformanceMonitor.Notifications; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The viewer's user-initiated writes to <c>config_mute_rules</c> — the alert mute-rule CRUD the +/// Alerts tab's "Manage Mute Rules" surface drives. This goes STRAIGHT to Postgres (the store is +/// the coordination point; the running Darling service's shared +/// <see cref="MuteRuleService"/>/<c>PgMuteRuleStore</c> re-reads it on its next load), so there is +/// no IPC to the service. +/// +/// <para> +/// The SQL mirrors the service's <c>PerformanceMonitor.Darling.Service.PgMuteRuleStore</c> +/// statement-for-statement — same columns, same $N positional parameters, same naive-UTC timestamp +/// discipline (Npgsql 6+ rejects Kind=Utc against <c>timestamp</c>). That store lives in the Service +/// project, which the viewer does not (and should not) reference, so the shape is duplicated here +/// rather than shared; the ungated <c>ViewerMuteRuleSqlTests</c> pin the two in lockstep. +/// </para> +/// +/// <para> +/// Scope note (the cross-app "server_id" question previous agents flagged): <c>config_mute_rules</c> +/// has NO server_id column — a rule scopes to a server by its <c>server_name</c> text +/// (<see cref="MuteRule.ServerName"/>, nullable = all servers), exactly as Lite's +/// <c>DuckDbMuteRuleStore</c>/<c>MuteRule.Matches</c> do. The separate ANALYSIS-FINDING mute path +/// (<c>analysis_muted</c>) instead keys on an integer <c>server_id</c> where NULL = all servers; its +/// MCP "all servers" tool path now persists NULL and honors legacy <c>server_id = 0</c> rows as global +/// (see <see cref="PerformanceMonitor.Darling.Analysis.PgFindingStore.GetMutedStoriesAsync"/>). This +/// mute-RULE surface has no server_id column at all and mirrors Lite exactly. +/// </para> +/// </summary> +public sealed partial class ViewerDataService +{ + /* Exposed const so Darling.Tests can pin the dialect and column parity with PgMuteRuleStore + without a live Postgres (the DarlingAlertReadAdapter/ViewerWave2 pattern). */ + + public const string MuteRulesSelectSql = @" +SELECT id, enabled, created_at_utc, expires_at_utc, reason, + server_name, metric_name, database_pattern, + query_text_pattern, wait_type_pattern, job_name_pattern +FROM config_mute_rules +ORDER BY created_at_utc DESC"; + + public const string MuteRuleInsertSql = @" +INSERT INTO config_mute_rules + (id, enabled, created_at_utc, expires_at_utc, reason, + server_name, metric_name, database_pattern, + query_text_pattern, wait_type_pattern, job_name_pattern) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)"; + + public const string MuteRuleUpdateSql = @" +UPDATE config_mute_rules SET + enabled = $2, expires_at_utc = $3, reason = $4, + server_name = $5, metric_name = $6, database_pattern = $7, + query_text_pattern = $8, wait_type_pattern = $9, job_name_pattern = $10 +WHERE id = $1"; + + public const string MuteRuleSetEnabledSql = "UPDATE config_mute_rules SET enabled = $2 WHERE id = $1"; + + public const string MuteRuleDeleteSql = "DELETE FROM config_mute_rules WHERE id = $1"; + + /// <summary>The <see cref="MuteRule.Reason"/> stamped on a whole-server silence created from the sidebar's + /// "Silence This Server" one-click, so it reads clearly in the Manage Mute Rules list.</summary> + public const string ServerSilenceReason = "Silenced from server list"; + + /// <summary> + /// Builds the whole-server silence rule the sidebar's "Silence This Server" writes: a <see cref="MuteRule"/> + /// scoped to <paramref name="serverName"/> with every pattern field left null, so it matches EVERY alert for + /// that server (<see cref="MuteRule.Matches"/> only filters on the fields that are set). No expiry — a silence + /// stays until "Unsilence" removes it, mirroring Lite's indefinite per-server silence. + /// + /// <para><paramref name="serverName"/> must be the server's DISPLAY name, not its host name: the service's + /// alert engine builds its mute context with <c>ServerName = snapshot.ServerName</c> (the monitored server's + /// display name), and the alert rows the viewer reads carry that same display name — so a rule keyed on the + /// display name is what actually suppresses the alerts (matching the existing "Mute This Server" action, + /// which mutes on the alert row's <c>ServerName</c>).</para> + /// </summary> + public static MuteRule BuildServerSilenceRule(string serverName) => new() + { + ServerName = serverName, + Reason = ServerSilenceReason, + Enabled = true, + ExpiresAtUtc = null, + /* MetricName/DatabasePattern/QueryTextPattern/WaitTypePattern/JobNamePattern stay null → matches all. */ + }; + + /// <summary> + /// True when <paramref name="rule"/> is a WHOLE-SERVER silence for <paramref name="serverName"/> — scoped to + /// that server (case-insensitive) with no narrowing pattern on any other field. This is the shape + /// <see cref="BuildServerSilenceRule"/> writes, and the predicate "Unsilence" uses to find the rule(s) to + /// remove (so it targets only blanket silences, never a specific metric/query/db mute the operator authored + /// for that server). Enabled/expiry are intentionally not part of the match — Unsilence clears any lingering + /// blanket silence regardless. + /// </summary> + public static bool IsWholeServerSilence(MuteRule rule, string serverName) => + rule is not null + && string.Equals(rule.ServerName, serverName, StringComparison.OrdinalIgnoreCase) + && rule.MetricName is null + && rule.DatabasePattern is null + && rule.QueryTextPattern is null + && rule.WaitTypePattern is null + && rule.JobNamePattern is null; + + /// <summary>All mute rules, newest first — the "Manage Mute Rules" list. Timestamps come back + /// tagged Utc (stored naive), so <see cref="MuteRule.ExpiresDisplay"/> converts correctly.</summary> + public async Task<List<MuteRule>> GetMuteRulesAsync(CancellationToken cancellationToken = default) + { + var rules = new List<MuteRule>(); + + await using var command = _dataSource.CreateCommand(MuteRulesSelectSql); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rules.Add(new MuteRule + { + Id = reader.GetString(0), + Enabled = reader.GetBoolean(1), + CreatedAtUtc = DateTime.SpecifyKind(reader.GetDateTime(2), DateTimeKind.Utc), + ExpiresAtUtc = reader.IsDBNull(3) ? null : DateTime.SpecifyKind(reader.GetDateTime(3), DateTimeKind.Utc), + Reason = reader.IsDBNull(4) ? null : reader.GetString(4), + ServerName = reader.IsDBNull(5) ? null : reader.GetString(5), + MetricName = reader.IsDBNull(6) ? null : reader.GetString(6), + DatabasePattern = reader.IsDBNull(7) ? null : reader.GetString(7), + QueryTextPattern = reader.IsDBNull(8) ? null : reader.GetString(8), + WaitTypePattern = reader.IsDBNull(9) ? null : reader.GetString(9), + JobNamePattern = reader.IsDBNull(10) ? null : reader.GetString(10), + }); + } + + return rules; + } + + /// <summary>Inserts a new mute rule (the Add dialog's Save).</summary> + public async Task InsertMuteRuleAsync(MuteRule rule, CancellationToken cancellationToken = default) + { + if (rule is null) + { + throw new ArgumentNullException(nameof(rule)); + } + + await using var command = _dataSource.CreateCommand(MuteRuleInsertSql); + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = rule.Id }); + command.Parameters.Add(new NpgsqlParameter<bool> { TypedValue = rule.Enabled }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = Naive(rule.CreatedAtUtc) }); + AddNullableTimestamp(command, rule.ExpiresAtUtc); + AddNullableText(command, rule.Reason); + AddNullableText(command, rule.ServerName); + AddNullableText(command, rule.MetricName); + AddNullableText(command, rule.DatabasePattern); + AddNullableText(command, rule.QueryTextPattern); + AddNullableText(command, rule.WaitTypePattern); + AddNullableText(command, rule.JobNamePattern); + await ExecuteWriteAsync(command, cancellationToken); + await SignalConfigReloadAsync(cancellationToken); + } + + /// <summary>Updates an existing mute rule (the Edit dialog's Save).</summary> + public async Task UpdateMuteRuleAsync(MuteRule rule, CancellationToken cancellationToken = default) + { + if (rule is null) + { + throw new ArgumentNullException(nameof(rule)); + } + + await using var command = _dataSource.CreateCommand(MuteRuleUpdateSql); + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = rule.Id }); + command.Parameters.Add(new NpgsqlParameter<bool> { TypedValue = rule.Enabled }); + AddNullableTimestamp(command, rule.ExpiresAtUtc); + AddNullableText(command, rule.Reason); + AddNullableText(command, rule.ServerName); + AddNullableText(command, rule.MetricName); + AddNullableText(command, rule.DatabasePattern); + AddNullableText(command, rule.QueryTextPattern); + AddNullableText(command, rule.WaitTypePattern); + AddNullableText(command, rule.JobNamePattern); + await ExecuteWriteAsync(command, cancellationToken); + await SignalConfigReloadAsync(cancellationToken); + } + + /// <summary>Toggles a rule's enabled flag without rewriting its other columns.</summary> + public async Task SetMuteRuleEnabledAsync(string ruleId, bool enabled, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(MuteRuleSetEnabledSql); + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = ruleId }); + command.Parameters.Add(new NpgsqlParameter<bool> { TypedValue = enabled }); + await ExecuteWriteAsync(command, cancellationToken); + await SignalConfigReloadAsync(cancellationToken); + } + + /// <summary>Deletes a mute rule by id (the Delete action).</summary> + public async Task DeleteMuteRuleAsync(string ruleId, CancellationToken cancellationToken = default) + { + await DeleteMuteRuleRawAsync(ruleId, cancellationToken); + await SignalConfigReloadAsync(cancellationToken); + } + + /// <summary>The bare delete without the reload signal — shared by the single delete and the purge so the + /// purge signals the beacon ONCE for the whole batch rather than per row.</summary> + private async Task DeleteMuteRuleRawAsync(string ruleId, CancellationToken cancellationToken) + { + await using var command = _dataSource.CreateCommand(MuteRuleDeleteSql); + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = ruleId }); + await ExecuteWriteAsync(command, cancellationToken); + } + + /// <summary> + /// Deletes every currently-expired rule (the "Purge Expired" action). Returns the number + /// removed. Expiry is evaluated in memory from the read (matching Lite's + /// <see cref="MuteRuleService.PurgeExpiredRulesAsync"/> flow: load, pick expired, delete). + /// Signals the reload beacon once if anything was removed so the service drops the rules live (F16). + /// </summary> + public async Task<int> PurgeExpiredMuteRulesAsync(CancellationToken cancellationToken = default) + { + var rules = await GetMuteRulesAsync(cancellationToken); + var removed = 0; + foreach (var rule in rules) + { + if (rule.IsExpired) + { + await DeleteMuteRuleRawAsync(rule.Id, cancellationToken); + removed++; + } + } + + if (removed > 0) + { + await SignalConfigReloadAsync(cancellationToken); + } + + return removed; + } + + /// <summary>Npgsql 6+ rejects Kind=Utc against `timestamp`; the values are UTC by contract.</summary> + private static DateTime Naive(DateTime value) => + DateTime.SpecifyKind(value, DateTimeKind.Unspecified); + + private static void AddNullableTimestamp(NpgsqlCommand command, DateTime? value) => + command.Parameters.Add(new NpgsqlParameter + { + NpgsqlDbType = NpgsqlDbType.Timestamp, + Value = value.HasValue ? Naive(value.Value) : DBNull.Value, + }); + + private static void AddNullableText(NpgsqlCommand command, string? value) => + command.Parameters.Add(new NpgsqlParameter + { + NpgsqlDbType = NpgsqlDbType.Text, + Value = (object?)value ?? DBNull.Value, + }); +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Notification.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Notification.cs new file mode 100644 index 000000000..8187aa5fc --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Notification.cs @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The viewer's control-plane writes to <c>config.config_notification</c> — the single-row (id=1) desired +/// state for SMTP + Teams/Slack alert delivery the Stage-1 <c>StoreConfigProvider</c> reads and hot-swaps into +/// the running service's <c>SmtpConfig</c>/<c>WebhooksConfig</c>. This is the Stage-3b rewire that replaces the +/// severed viewer-local SMTP/webhook block (formerly <see cref="ViewerAppSettings"/> + Windows Credential +/// Manager): saving the Settings window's Email + Teams/Slack sections now drives the running service's +/// delivery. +/// +/// <para>Same discipline as the other write partials: public-const SQL (Darling.Tests pin the dialect + +/// column parity with <c>StoreConfigProvider.ReadNotificationAsync</c>), bound <c>$N</c> parameters, routed +/// through <see cref="ExecuteWriteAsync"/> so a read-only seat degrades to <see cref="ViewerReadOnlyException"/>. +/// The single global row is <c>id = 1</c>; <c>modified_at</c> is server-side naive-UTC.</para> +/// +/// <para><b>The SMTP secret.</b> <c>smtp_encrypted_password</c> is NEVER plaintext in Postgres — it is the +/// DPAPI-LocalMachine blob <see cref="ViewerServerSecret.Protect"/> produces, the SAME format the service's +/// <c>DarlingAlertSettings.GetSmtpPassword</c> reads back with <c>DarlingSecrets.Unprotect</c> (identical +/// entropy + scope, pinned by a Darling.Tests round-trip). The managed single-box deploy (viewer + service on +/// one host) shares the machine DPAPI key, so the service decrypts what the viewer sealed. The webhook URLs +/// carry as plain text in <c>teams_url</c>/<c>slack_url</c> exactly as darling.json holds them (the service +/// treats a channel as enabled by a non-empty URL — so a disabled channel writes an EMPTY url, matching the +/// service's <c>TeamsWebhookEnabled => !IsNullOrWhiteSpace(TeamsUrl)</c> derivation).</para> +/// </summary> +public sealed partial class ViewerDataService +{ + /* The 16 SmtpConfig + WebhooksConfig columns in the SAME order the service reads them + (StoreConfigProvider.ReadNotificationAsync), so the parity test pins one list against both ends. */ + private const string NotificationColumns = + "smtp_host, smtp_port, smtp_use_ssl, smtp_username, smtp_encrypted_password, smtp_from_address, " + + "smtp_recipients, email_cooldown_minutes, teams_url, teams_proxy, slack_url, slack_proxy, " + + "generic_url, generic_headers, generic_body_template, generic_proxy"; + + /// <summary>The single global notification row (id=1), for the Settings window prefill + migrate-in check.</summary> + public const string NotificationSelectSql = + "SELECT " + NotificationColumns + " FROM config_notification WHERE id = 1"; + + /* The non-secret subset of NotificationColumns a read-only viewer role CAN read. The six secret + columns (smtp_encrypted_password, smtp_username, teams_url, slack_url, generic_url — a webhook URL + is a bearer secret — and generic_headers, which holds the Authorization token itself) are + column-REVOKEd from the viewer role (#1262 / DarlingManagedRoles.ViewerRestrictedConfigTables), so + selecting them raises SQLSTATE 42501 for a read-only seat and — in the Settings load — one such + throw would blank every later section. */ + private const string NotificationNonSecretColumns = + "smtp_host, smtp_port, smtp_use_ssl, smtp_from_address, smtp_recipients, " + + "email_cooldown_minutes, teams_proxy, slack_proxy, generic_body_template, generic_proxy"; + + /// <summary>The secret-free notification projection a read-only <c>viewer</c> seat reads (D7 degradation): + /// the four carved secret columns are omitted, so it never 42501s for a viewer. The secret fields come back + /// null/empty (the viewer can't author them anyway); the non-secret fields prefill the Settings window.</summary> + public const string NotificationSelectNoSecretSql = + "SELECT " + NotificationNonSecretColumns + " FROM config_notification WHERE id = 1"; + + /// <summary>Upserts the single global notification row (Settings window Save). ON CONFLICT rewrites every + /// column and bumps <c>config_version</c> via the V17 trigger. $1..$16 in <see cref="NotificationColumns"/> order.</summary> + public const string NotificationUpsertSql = @" +INSERT INTO config_notification (id, " + NotificationColumns + @", modified_at) +VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, (now() AT TIME ZONE 'UTC')) +ON CONFLICT (id) DO UPDATE SET + smtp_host = EXCLUDED.smtp_host, + smtp_port = EXCLUDED.smtp_port, + smtp_use_ssl = EXCLUDED.smtp_use_ssl, + smtp_username = EXCLUDED.smtp_username, + smtp_encrypted_password = EXCLUDED.smtp_encrypted_password, + smtp_from_address = EXCLUDED.smtp_from_address, + smtp_recipients = EXCLUDED.smtp_recipients, + email_cooldown_minutes = EXCLUDED.email_cooldown_minutes, + teams_url = EXCLUDED.teams_url, + teams_proxy = EXCLUDED.teams_proxy, + slack_url = EXCLUDED.slack_url, + slack_proxy = EXCLUDED.slack_proxy, + generic_url = EXCLUDED.generic_url, + generic_headers = EXCLUDED.generic_headers, + generic_body_template = EXCLUDED.generic_body_template, + generic_proxy = EXCLUDED.generic_proxy, + modified_at = (now() AT TIME ZONE 'UTC')"; + + /// <summary>Reads the single global notification row, or null when the store has not seeded it yet. A + /// read-only <c>viewer</c> seat is column-denied the four secret columns, so it degrades to the secret-free + /// <see cref="NotificationSelectNoSecretSql"/> projection (D7) instead of failing the read with 42501 — + /// which, in the Settings load, would otherwise blank the later sections.</summary> + public async Task<NotificationRow?> GetNotificationAsync(CancellationToken cancellationToken = default) + { + if (IsReadOnly) + { + await using var noSecretCommand = _dataSource.CreateCommand(NotificationSelectNoSecretSql); + await using var noSecretReader = await noSecretCommand.ExecuteReaderAsync(cancellationToken); + return await noSecretReader.ReadAsync(cancellationToken) ? ReadNotificationRowNoSecret(noSecretReader) : null; + } + + await using var command = _dataSource.CreateCommand(NotificationSelectSql); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + return await reader.ReadAsync(cancellationToken) ? ReadNotificationRow(reader) : null; + } + + /// <summary>Upserts the single global notification row (Settings window Save). Read-only seats throw + /// <see cref="ViewerReadOnlyException"/> via <see cref="ExecuteWriteAsync"/>.</summary> + public async Task UpsertNotificationAsync(NotificationRow row, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(row); + + await using var command = _dataSource.CreateCommand(NotificationUpsertSql); + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = row.SmtpHost }); // $1 + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = row.SmtpPort }); // $2 + command.Parameters.Add(new NpgsqlParameter<bool> { TypedValue = row.SmtpUseSsl }); // $3 + AddNullableText(command, row.SmtpUsername); // $4 + AddNullableText(command, row.SmtpEncryptedPassword); // $5 + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = row.SmtpFromAddress }); // $6 + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = row.SmtpRecipients }); // $7 + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = row.EmailCooldownMinutes }); // $8 + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = row.TeamsUrl }); // $9 + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = row.TeamsProxy }); // $10 + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = row.SlackUrl }); // $11 + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = row.SlackProxy }); // $12 + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = row.GenericUrl }); // $13 + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = row.GenericHeaders }); // $14 + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = row.GenericBodyTemplate }); // $15 + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = row.GenericProxy }); // $16 + await ExecuteWriteAsync(command, cancellationToken); + } + + private static NotificationRow ReadNotificationRow(NpgsqlDataReader reader) => new() + { + SmtpHost = reader.GetString(0), + SmtpPort = reader.GetInt32(1), + SmtpUseSsl = reader.GetBoolean(2), + SmtpUsername = reader.IsDBNull(3) ? null : reader.GetString(3), + SmtpEncryptedPassword = reader.IsDBNull(4) ? null : reader.GetString(4), + SmtpFromAddress = reader.GetString(5), + SmtpRecipients = reader.GetString(6), + EmailCooldownMinutes = reader.GetInt32(7), + TeamsUrl = reader.GetString(8), + TeamsProxy = reader.GetString(9), + SlackUrl = reader.GetString(10), + SlackProxy = reader.GetString(11), + GenericUrl = reader.GetString(12), + GenericHeaders = reader.GetString(13), + GenericBodyTemplate = reader.GetString(14), + GenericProxy = reader.GetString(15), + }; + + /// <summary> + /// Reads a <see cref="NotificationRow"/> from the secret-free projection + /// (<see cref="NotificationSelectNoSecretSql"/>) a read-only <c>viewer</c> seat uses (D7): identical to + /// <see cref="ReadNotificationRow"/> but the six carved secret columns (<c>smtp_username</c>, + /// <c>smtp_encrypted_password</c>, <c>teams_url</c>, <c>slack_url</c>, <c>generic_url</c>, + /// <c>generic_headers</c>) are not selected, so they stay null/empty and every column after each shifts + /// ordinal. The viewer can't author those secrets on a read-only seat anyway, so the empty values are + /// harmless — the non-secret fields still prefill. + /// </summary> + private static NotificationRow ReadNotificationRowNoSecret(NpgsqlDataReader reader) => new() + { + SmtpHost = reader.GetString(0), + SmtpPort = reader.GetInt32(1), + SmtpUseSsl = reader.GetBoolean(2), + SmtpUsername = null, /* carved — not selected for a read-only viewer (#1262) */ + SmtpEncryptedPassword = null, /* carved */ + SmtpFromAddress = reader.GetString(3), + SmtpRecipients = reader.GetString(4), + EmailCooldownMinutes = reader.GetInt32(5), + TeamsUrl = "", /* carved */ + TeamsProxy = reader.GetString(6), + SlackUrl = "", /* carved */ + SlackProxy = reader.GetString(7), + GenericUrl = "", /* carved */ + GenericHeaders = "", /* carved — holds the Authorization bearer token (#1506) */ + GenericBodyTemplate = reader.GetString(8), + GenericProxy = reader.GetString(9), + }; +} + +/// <summary> +/// A <c>config.config_notification</c> row (the single id=1 global row) as the viewer authors + reads it — +/// the desired-state twin of the service's <c>SmtpConfig</c> + <c>WebhooksConfig</c>. There is no per-channel +/// "enabled" column (the service derives enablement from non-empty fields: SMTP from host+from+to, a webhook +/// from its URL); the Settings window maps its enable toggles onto that by clearing the channel's fields when +/// unchecked. <see cref="SmtpEncryptedPassword"/> is a DPAPI-LocalMachine blob, never plaintext. Defaults +/// mirror the V17 DDL so <see cref="Defaults"/> equals a freshly-seeded row. +/// </summary> +public sealed class NotificationRow +{ + public string SmtpHost { get; set; } = ""; + public int SmtpPort { get; set; } = 587; + public bool SmtpUseSsl { get; set; } = true; + public string? SmtpUsername { get; set; } + + /// <summary>DPAPI-LocalMachine base64 blob (<see cref="ViewerServerSecret.Protect"/>), or null when unset.</summary> + public string? SmtpEncryptedPassword { get; set; } + + public string SmtpFromAddress { get; set; } = ""; + public string SmtpRecipients { get; set; } = ""; + public int EmailCooldownMinutes { get; set; } = 15; + public string TeamsUrl { get; set; } = ""; + public string TeamsProxy { get; set; } = ""; + public string SlackUrl { get; set; } = ""; + public string SlackProxy { get; set; } = ""; + + /* Generic webhook (#1506 / V26). GenericUrl and GenericHeaders are secrets — the headers JSON carries + the Authorization bearer token — and are column-REVOKEd from the read-only viewer role. */ + public string GenericUrl { get; set; } = ""; + public string GenericHeaders { get; set; } = ""; + public string GenericBodyTemplate { get; set; } = ""; + public string GenericProxy { get; set; } = ""; + + /// <summary>A row equal to the V17 seed defaults — the migrate-in "is the service section still at defaults?" baseline.</summary> + public static NotificationRow Defaults() => new(); + + /// <summary>Value-equality against another row — the migrate-in uses it to detect an untouched (default) + /// store section and a genuine viewer customization. The DPAPI blob is compared ordinally (it is opaque).</summary> + public bool ValueEquals(NotificationRow other) + { + ArgumentNullException.ThrowIfNull(other); + return string.Equals(SmtpHost, other.SmtpHost, StringComparison.Ordinal) + && SmtpPort == other.SmtpPort + && SmtpUseSsl == other.SmtpUseSsl + && string.Equals(SmtpUsername ?? "", other.SmtpUsername ?? "", StringComparison.Ordinal) + && string.Equals(SmtpEncryptedPassword ?? "", other.SmtpEncryptedPassword ?? "", StringComparison.Ordinal) + && string.Equals(SmtpFromAddress, other.SmtpFromAddress, StringComparison.Ordinal) + && string.Equals(SmtpRecipients, other.SmtpRecipients, StringComparison.Ordinal) + && EmailCooldownMinutes == other.EmailCooldownMinutes + && string.Equals(TeamsUrl, other.TeamsUrl, StringComparison.Ordinal) + && string.Equals(TeamsProxy, other.TeamsProxy, StringComparison.Ordinal) + && string.Equals(SlackUrl, other.SlackUrl, StringComparison.Ordinal) + && string.Equals(SlackProxy, other.SlackProxy, StringComparison.Ordinal) + && string.Equals(GenericUrl, other.GenericUrl, StringComparison.Ordinal) + && string.Equals(GenericHeaders, other.GenericHeaders, StringComparison.Ordinal) + && string.Equals(GenericBodyTemplate, other.GenericBodyTemplate, StringComparison.Ordinal) + && string.Equals(GenericProxy, other.GenericProxy, StringComparison.Ordinal); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs new file mode 100644 index 000000000..073241c88 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs @@ -0,0 +1,706 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Media; +using Npgsql; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Overview server-cards read (W2a viewer copy-parity), copied from Lite's +/// <c>LocalDataService.Overview.cs</c> (GetServerSummaryAsync + ServerSummaryItem) and rewired to the +/// Darling Postgres store, then ENRICHED toward the Dashboard's richer <c>ServerHealthCard</c> +/// (Dashboard/Controls/ServerHealthCard.xaml + Dashboard/Models/ServerHealthStatus.cs). Alongside Lite's +/// original five per-server reads — latest CPU (SQL + other), latest total server memory, blocking count +/// in the last hour (XE blocked-process reports, falling back to the always-on DMV snapshot when the XE +/// count is zero), deadlock count in the last hour, and the last collection time — the card now also +/// surfaces the Dashboard's <b>Threads</b> row (worker-thread pressure from the latest +/// <c>cpu_scheduler_stats</c> snapshot), <b>Collectors</b> row (healthy / failing counts reusing the +/// viewer's own <see cref="CollectorHealthRow.HealthStatus"/> banding), a richer <b>Memory</b> signal +/// (resource-semaphore waiters / timeouts / forced grants from <c>memory_grant_stats</c>, not just total +/// MB), and a <b>Blocking</b> duration (max wait in the window). Every metric drives a per-row severity +/// band that mirrors <c>ServerHealthStatus</c>'s deterministic CASE logic. All reads run over the same +/// <c>v_*</c> passthrough views the other viewer tabs read; the SQL lives in public constants so tests can +/// pin the load-bearing clauses without a live Postgres. +/// +/// <para><b>The viewer adaptations (#1262 headless plan).</b> (1) Lite's <c>IsOnline</c> comes from a live +/// per-server connection ping; the viewer has no live connection to the monitored servers, so it derives +/// the card's status from COLLECTION FRESHNESS instead — how old the newest <c>v_collection_log</c> row +/// is (see <see cref="ServerSummaryItem.ClassifyFreshness"/>): fresh → Online (green), stale (older than +/// twice the fastest collector's cadence) → Warning (amber), and no collection / long-dead → Offline +/// (the red overlay). (2) The Dashboard's health card live-queries currently-blocked sessions / current +/// scheduler state; the viewer reads the newest COLLECTED snapshot instead (the freshness band already +/// answers "is this server reporting"). The severity BANDS themselves are reproduced verbatim from +/// <c>ServerHealthStatus</c> so a viewer card colours a metric exactly as the Dashboard would.</para> +/// </summary> +public sealed partial class ViewerDataService +{ + /// <summary>Latest SQL + other-process CPU for one server (newest ring-buffer sample). $1 server_id.</summary> + public const string ServerSummaryCpuSql = @" +SELECT sqlserver_cpu_utilization, other_process_cpu_utilization +FROM v_cpu_utilization_stats +WHERE server_id = $1 +ORDER BY sample_time DESC +LIMIT 1"; + + /// <summary>Latest total server memory + buffer pool (MB) for one server. $1 server_id.</summary> + public const string ServerSummaryMemorySql = @" +SELECT + CAST(total_server_memory_mb AS double precision), + CAST(buffer_pool_mb AS double precision) +FROM v_memory_stats +WHERE server_id = $1 +ORDER BY collection_time DESC +LIMIT 1"; + + /// <summary> + /// The latest resource-semaphore pressure for one server — the workspace-memory signal the Dashboard's + /// <c>get_resource_semaphore</c> / <c>ServerHealthStatus.MemorySeverity</c> reads: grant waiters, + /// timeout-error and forced-grant deltas, and total granted MB, summed across every pool at the newest + /// collection instant. The Dashboard live-sums <c>sys.dm_exec_query_resource_semaphores.waiter_count</c> + /// (WHERE max_target_memory_kb IS NOT NULL, which the <c>memory_grant_stats</c> collector already + /// applies at capture); the viewer sums the collected per-pool rows at MAX(collection_time). $1 server_id. + /// </summary> + public const string ServerSummaryMemoryPressureSql = @" +SELECT + CAST(COALESCE(SUM(waiter_count), 0) AS bigint), + CAST(COALESCE(SUM(timeout_error_count_delta), 0) AS bigint), + CAST(COALESCE(SUM(forced_grant_count_delta), 0) AS bigint), + CAST(COALESCE(SUM(granted_memory_mb), 0) AS double precision) +FROM v_memory_grant_stats +WHERE server_id = $1 +AND collection_time = (SELECT MAX(collection_time) FROM v_memory_grant_stats WHERE server_id = $1)"; + + /// <summary> + /// The latest worker-thread pressure for one server — the Dashboard's Threads row inputs + /// (<c>ServerHealthStatus.ThreadsSeverity</c>) from the newest <c>cpu_scheduler_stats</c> snapshot: + /// total worker ceiling (<c>max_workers_count</c>), workers in use (<c>total_current_workers_count</c> — + /// available = ceiling − in-use, the same figure the collector's own worker-thread-exhaustion warning + /// bands on at 90%), runnable tasks waiting for CPU (<c>total_runnable_tasks_count</c>), and requests + /// starved of a worker (<c>total_work_queue_count</c>). A point-in-time snapshot collector, so the + /// newest row is the current state. NULL/absent on Azure SQL DB (the collector does not apply there), + /// which the card renders as "--". $1 server_id. + /// </summary> + public const string ServerSummaryThreadsSql = @" +SELECT + max_workers_count, + total_current_workers_count, + total_runnable_tasks_count, + total_work_queue_count +FROM v_cpu_scheduler_stats +WHERE server_id = $1 +ORDER BY collection_time DESC +LIMIT 1"; + + /// <summary> + /// Blocking in the window with its worst wait, plus the newest blocking event ever — XE blocked-process + /// reports preferred, the always-on DMV blocking snapshot as fallback (AWS RDS has no XE), keeping + /// Lite's XE→DMV fallback but returning the COUNT and the MAX wait (ms) from the SAME source so the + /// card's count and its "max: Ns" duration can never come from different feeds. The last two columns are + /// each source's newest event_time (unbounded — the same <c>MAX(event_time) WHERE server_id</c> read the + /// collector runs every cycle for its watermark), for the Dashboard's "Last: N ago" detail when the + /// window is clear. The caller applies the fallback in C# (identical to Lite's + /// <c>COALESCE(NULLIF(xe,0), dmv)</c>: use XE when it has any row, else DMV). $1 server_id, $2 window + /// start (naive UTC). + /// </summary> + public const string ServerSummaryBlockingSql = @" +SELECT + (SELECT COUNT(*) FROM v_blocked_process_reports WHERE server_id = $1 AND event_time >= $2), + (SELECT MAX(wait_time_ms) FROM v_blocked_process_reports WHERE server_id = $1 AND event_time >= $2), + (SELECT COUNT(*) FROM v_dmv_blocking_snapshots WHERE server_id = $1 AND event_time >= $2), + (SELECT MAX(wait_time_ms) FROM v_dmv_blocking_snapshots WHERE server_id = $1 AND event_time >= $2), + (SELECT MAX(event_time) FROM v_blocked_process_reports WHERE server_id = $1), + (SELECT MAX(event_time) FROM v_dmv_blocking_snapshots WHERE server_id = $1)"; + + /// <summary> + /// Deadlock count in the window plus the newest deadlock ever — the windowed count for the card value, + /// and the unbounded MAX(deadlock_time) for the Dashboard's "Last: N ago" detail. $1 server_id, $2 + /// window start (naive UTC). + /// </summary> + public const string ServerSummaryDeadlockSql = @" +SELECT + (SELECT COUNT(*) FROM v_deadlocks WHERE server_id = $1 AND deadlock_time >= $2), + (SELECT MAX(deadlock_time) FROM v_deadlocks WHERE server_id = $1)"; + + /// <summary>Newest collection time across all collectors for one server. $1 server_id.</summary> + public const string ServerSummaryLastCollectionSql = @" +SELECT MAX(collection_time) +FROM v_collection_log +WHERE server_id = $1"; + + /// <summary> + /// One server's Overview-card summary — Lite's <c>GetServerSummaryAsync</c> ported to Postgres and + /// enriched toward the Dashboard's <c>ServerHealthCard</c>. The caller sets + /// <see cref="ServerSummaryItem.ServerName"/> and applies the freshness-derived status + /// (<see cref="ServerSummaryItem.ApplyFreshness"/>) after the read, exactly where Lite set IsOnline + /// from the live ping. Blocking / deadlock counts use a one-hour window (Lite's window); the Threads / + /// Memory-pressure reads take the newest snapshot; the Collectors row REUSES the viewer's 7-day + /// <see cref="GetCollectionHealthAsync"/> banding. + /// </summary> + public async Task<ServerSummaryItem> GetServerSummaryAsync(int serverId, string displayName, CancellationToken cancellationToken = default) + { + var nowUtc = DateTime.UtcNow; + var windowStart = DateTime.SpecifyKind(nowUtc.AddHours(-1), DateTimeKind.Unspecified); + + double? cpuPercent = null; + double? otherProcessCpuPercent = null; + double? memoryMb = null; + double? bufferPoolMb = null; + var blockingCount = 0; + long maxBlockingWaitMs = 0; + int? lastBlockingMinutesAgo = null; + var deadlockCount = 0; + int? lastDeadlockMinutesAgo = null; + DateTime? lastCollection = null; + + int? totalThreads = null; + int? currentWorkers = null; + var threadsWaitingForCpu = 0; + long requestsWaitingForThreads = 0; + + long memoryWaiterCount = 0; + long memoryTimeoutCount = 0; + long memoryForcedCount = 0; + double? grantedMemoryMb = null; + + /* Latest CPU — SQL and other-process, so the card can show total non-idle CPU with the SQL-only + number alongside (Lite's headline). */ + await using (var command = _dataSource.CreateCommand(ServerSummaryCpuSql)) + { + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (await reader.ReadAsync(cancellationToken)) + { + cpuPercent = reader.IsDBNull(0) ? null : Convert.ToDouble(reader.GetValue(0)); + otherProcessCpuPercent = reader.IsDBNull(1) ? null : Convert.ToDouble(reader.GetValue(1)); + } + } + + /* Latest total server memory + buffer pool. */ + await using (var command = _dataSource.CreateCommand(ServerSummaryMemorySql)) + { + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (await reader.ReadAsync(cancellationToken)) + { + memoryMb = reader.IsDBNull(0) ? null : Convert.ToDouble(reader.GetValue(0)); + bufferPoolMb = reader.IsDBNull(1) ? null : Convert.ToDouble(reader.GetValue(1)); + } + } + + /* Latest resource-semaphore pressure (grant waiters / timeouts / forced grants + granted MB). */ + await using (var command = _dataSource.CreateCommand(ServerSummaryMemoryPressureSql)) + { + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (await reader.ReadAsync(cancellationToken)) + { + memoryWaiterCount = reader.IsDBNull(0) ? 0 : Convert.ToInt64(reader.GetValue(0)); + memoryTimeoutCount = reader.IsDBNull(1) ? 0 : Convert.ToInt64(reader.GetValue(1)); + memoryForcedCount = reader.IsDBNull(2) ? 0 : Convert.ToInt64(reader.GetValue(2)); + grantedMemoryMb = reader.IsDBNull(3) ? null : Convert.ToDouble(reader.GetValue(3)); + } + } + + /* Latest worker-thread pressure (max / in-use / runnable-waiting / work-queue). Absent on Azure. */ + await using (var command = _dataSource.CreateCommand(ServerSummaryThreadsSql)) + { + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (await reader.ReadAsync(cancellationToken)) + { + totalThreads = reader.IsDBNull(0) ? null : Convert.ToInt32(reader.GetValue(0)); + currentWorkers = reader.IsDBNull(1) ? null : Convert.ToInt32(reader.GetValue(1)); + threadsWaitingForCpu = reader.IsDBNull(2) ? 0 : Convert.ToInt32(reader.GetValue(2)); + requestsWaitingForThreads = reader.IsDBNull(3) ? 0 : Convert.ToInt64(reader.GetValue(3)); + } + } + + /* Blocking count + worst wait in the last hour (XE preferred, DMV fallback — same source for both). */ + await using (var command = _dataSource.CreateCommand(ServerSummaryBlockingSql)) + { + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = windowStart }); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (await reader.ReadAsync(cancellationToken)) + { + var xeCount = reader.IsDBNull(0) ? 0 : Convert.ToInt32(reader.GetValue(0)); + var xeMaxWait = reader.IsDBNull(1) ? 0L : Convert.ToInt64(reader.GetValue(1)); + var dmvCount = reader.IsDBNull(2) ? 0 : Convert.ToInt32(reader.GetValue(2)); + var dmvMaxWait = reader.IsDBNull(3) ? 0L : Convert.ToInt64(reader.GetValue(3)); + + /* Lite's fallback: use XE when it has any row this window, else the DMV snapshot. Both the + count and the max wait come from whichever source wins, so they never disagree. */ + if (xeCount > 0) + { + blockingCount = xeCount; + maxBlockingWaitMs = xeMaxWait; + } + else + { + blockingCount = dmvCount; + maxBlockingWaitMs = dmvMaxWait; + } + + /* Newest blocking event across both sources (unbounded) → "Last: N ago" when the window is + clear. Stored times are naive UTC; the tick subtraction against UtcNow is a true elapsed. */ + DateTime? lastBlocking = reader.IsDBNull(4) ? null : reader.GetDateTime(4); + var dmvLast = reader.IsDBNull(5) ? (DateTime?)null : reader.GetDateTime(5); + if (dmvLast.HasValue && (!lastBlocking.HasValue || dmvLast.Value > lastBlocking.Value)) + { + lastBlocking = dmvLast; + } + lastBlockingMinutesAgo = MinutesAgo(lastBlocking, nowUtc); + } + } + + /* Deadlock count in the last hour + the newest deadlock ever (for "Last: N ago"). */ + await using (var command = _dataSource.CreateCommand(ServerSummaryDeadlockSql)) + { + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = windowStart }); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (await reader.ReadAsync(cancellationToken)) + { + deadlockCount = reader.IsDBNull(0) ? 0 : Convert.ToInt32(reader.GetValue(0)); + lastDeadlockMinutesAgo = MinutesAgo(reader.IsDBNull(1) ? null : reader.GetDateTime(1), nowUtc); + } + } + + /* Newest collection time across all collectors — drives the freshness status. */ + await using (var command = _dataSource.CreateCommand(ServerSummaryLastCollectionSql)) + { + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + var result = await command.ExecuteScalarAsync(cancellationToken); + if (result is not null && result != DBNull.Value) + { + lastCollection = Convert.ToDateTime(result); + } + } + + /* Collectors row — REUSE the viewer's own 7-day per-collector health banding (the same STALE / + FAILING / NEVER_RUN / HEALTHY logic the Collection Health tab renders), mirroring the Dashboard's + SUM(CASE health_status = 'HEALTHY' / 'FAILING') over report.collection_health. */ + var (healthyCollectors, failingCollectors) = await GetCollectorHealthCountsAsync(serverId, cancellationToken); + + return new ServerSummaryItem + { + DisplayName = displayName, + ServerId = serverId, + CpuPercent = cpuPercent, + OtherProcessCpuPercent = otherProcessCpuPercent, + MemoryMb = memoryMb, + BufferPoolMb = bufferPoolMb, + GrantedMemoryMb = grantedMemoryMb, + MemoryWaiterCount = memoryWaiterCount, + MemoryTimeoutCount = memoryTimeoutCount, + MemoryForcedCount = memoryForcedCount, + BlockingCount = blockingCount, + MaxBlockingWaitMs = maxBlockingWaitMs, + LastBlockingMinutesAgo = lastBlockingMinutesAgo, + DeadlockCount = deadlockCount, + LastDeadlockMinutesAgo = lastDeadlockMinutesAgo, + TotalThreads = totalThreads, + CurrentWorkers = currentWorkers, + ThreadsWaitingForCpu = threadsWaitingForCpu, + RequestsWaitingForThreads = requestsWaitingForThreads, + HealthyCollectorCount = healthyCollectors, + FailedCollectorCount = failingCollectors, + LastCollectionTime = lastCollection, + }; + } + + /// <summary> + /// Healthy / failing collector counts for one server, derived from the viewer's own 7-day collection + /// health banding (<see cref="GetCollectionHealthAsync"/> → <see cref="CollectorHealthRow.HealthStatus"/>). + /// Mirrors the Dashboard's <c>GetCollectorStatusAsync</c> (SUM of HEALTHY / FAILING over + /// <c>report.collection_health</c>) — HEALTHY and FAILING are the two bands the card surfaces; the + /// STALE / WARNING / NO_PERMISSIONS / NEVER_RUN rows count as neither (a failing collector is one the + /// banding calls FAILING: no success in over 24h). + /// </summary> + private async Task<(int Healthy, int Failing)> GetCollectorHealthCountsAsync(int serverId, CancellationToken cancellationToken) + { + var rows = await GetCollectionHealthAsync(serverId, cancellationToken); + var healthy = rows.Count(r => r.HealthStatus == "HEALTHY"); + var failing = rows.Count(r => r.HealthStatus == "FAILING"); + return (healthy, failing); + } + + /// <summary>Whole minutes elapsed from a stored naive-UTC instant to now (UTC), floored at 0, or null + /// when there is no instant. Both sides are UTC instants, so the tick subtraction is a true elapsed + /// regardless of Kind (the same reasoning the freshness classification documents).</summary> + private static int? MinutesAgo(DateTime? instantUtc, DateTime nowUtc) => + instantUtc.HasValue ? Math.Max(0, (int)(nowUtc - instantUtc.Value).TotalMinutes) : null; +} + +/// <summary> +/// One Overview server card's view-model — copied from Lite's <c>ServerSummaryItem</c> +/// (Lite/Services/LocalDataService.Overview.cs) and enriched toward the Dashboard's +/// <c>ServerHealthStatus</c> (Threads / Collectors rows, the resource-semaphore Memory signal, blocking +/// duration, and a per-metric severity band for each row's dot). Two viewer adaptations carry over from +/// the headless plan (#1262): <see cref="CpuPercentForAlert"/> is always total non-idle CPU (the viewer +/// has no per-app <c>CpuAlertMode</c> preference — total is what the alert engine evaluates by default), +/// and the connection status is derived from collection freshness rather than a live ping (see +/// <see cref="ClassifyFreshness"/> / <see cref="ApplyFreshness"/>). Every severity BAND reproduces +/// <c>ServerHealthStatus</c>'s deterministic CASE logic so a viewer card colours a metric exactly as the +/// Dashboard would; every display / brush is otherwise a pure format of the stored values. +/// </summary> +public sealed class ServerSummaryItem +{ + /* Freshness + per-metric severity thresholds live once in PerformanceMonitor.Common's + ServerHealthThresholds / ServerHealthClassifier (#1562); this card keeps only the brush mapping. */ + + /* Per-severity dot / value brushes, frozen once (the viewer's dark-theme palette). */ + private static readonly SolidColorBrush s_criticalBrush = MakeBrush("#E57373"); + private static readonly SolidColorBrush s_warningBrush = MakeBrush("#FFD54F"); + private static readonly SolidColorBrush s_healthyBrush = MakeBrush("#81C784"); + private static readonly SolidColorBrush s_unknownBrush = MakeBrush("#888888"); + + public string DisplayName { get; set; } = ""; + public string ServerName { get; set; } = ""; + public int ServerId { get; set; } + public bool? IsOnline { get; set; } + + /// <summary>Warning (amber) state — in the viewer this means the collection has gone stale.</summary> + public bool HasCollectorErrors { get; set; } + + /// <summary> + /// True when no collection has EVER landed for this server (<see cref="ServerFreshness.NeverCollected"/>): + /// the service hasn't reached it yet — a registered-but-queued server during bootstrap, not a dead one. + /// Drives the amber "Awaiting first collection" status instead of the red Offline overlay. + /// </summary> + public bool AwaitingFirstCollection { get; set; } + + /// <summary>SQL Server scheduler ProcessUtilization from sys.dm_os_ring_buffers. NULL on Azure SQL DB.</summary> + public double? CpuPercent { get; set; } + + /// <summary>Non-SQL-Server CPU on the host (100 - SystemIdle - ProcessUtilization). NULL on Azure SQL DB.</summary> + public double? OtherProcessCpuPercent { get; set; } + + /// <summary>Total non-idle CPU on the host = sql_server + other_process. Tracks OS user+system counters.</summary> + public double? TotalCpuPercent => + CpuPercent.HasValue ? CpuPercent.Value + (OtherProcessCpuPercent ?? 0) : null; + + /// <summary> + /// The CPU value the headline display / colour band uses. The viewer has no per-app CpuAlertMode, so + /// it always uses total non-idle CPU (falling back to SQL-only when other-process is unavailable) — + /// what the alert engine evaluates by default. + /// </summary> + public double? CpuPercentForAlert => TotalCpuPercent ?? CpuPercent; + + public double? MemoryMb { get; set; } + + /// <summary>Latest buffer-pool MB (v_memory_stats.buffer_pool_mb) — the "BP" figure in the Memory detail.</summary> + public double? BufferPoolMb { get; set; } + + /// <summary>Total granted query-memory MB across all pools at the newest grant snapshot — the "QMG" figure.</summary> + public double? GrantedMemoryMb { get; set; } + + /// <summary>Grant waiters at the newest snapshot — the primary resource-semaphore pressure signal.</summary> + public long MemoryWaiterCount { get; set; } + + /// <summary>Grant timeout-error delta at the newest snapshot (a query gave up waiting for memory).</summary> + public long MemoryTimeoutCount { get; set; } + + /// <summary>Forced-grant delta at the newest snapshot (a grant was forced through under pressure).</summary> + public long MemoryForcedCount { get; set; } + + public int BlockingCount { get; set; } + + /// <summary>The worst blocking wait (ms) observed in the window — the "max: Ns" detail + Critical band input.</summary> + public long MaxBlockingWaitMs { get; set; } + + /// <summary>Minutes since the most recent blocking event ever — the "Last: N ago" detail when the window is clear.</summary> + public int? LastBlockingMinutesAgo { get; set; } + + public int DeadlockCount { get; set; } + + /// <summary>Minutes since the most recent deadlock ever — the "Last: N ago" deadlock detail.</summary> + public int? LastDeadlockMinutesAgo { get; set; } + + /// <summary>Worker-thread ceiling (max_workers_count). NULL = no scheduler snapshot (e.g. Azure SQL DB).</summary> + public int? TotalThreads { get; set; } + + /// <summary>Workers in use (total_current_workers_count); available = ceiling − in-use.</summary> + public int? CurrentWorkers { get; set; } + + /// <summary>Runnable tasks waiting for a CPU (total_runnable_tasks_count).</summary> + public int ThreadsWaitingForCpu { get; set; } + + /// <summary>Requests starved of a worker thread (total_work_queue_count) — thread-pool starvation.</summary> + public long RequestsWaitingForThreads { get; set; } + + /// <summary>Available worker threads = ceiling − in-use, or NULL when there is no scheduler snapshot.</summary> + public int? AvailableThreads => + TotalThreads.HasValue ? TotalThreads.Value - (CurrentWorkers ?? 0) : null; + + /// <summary>Collectors whose 7-day band is HEALTHY (mirrors report.collection_health).</summary> + public int HealthyCollectorCount { get; set; } + + /// <summary>Collectors whose 7-day band is FAILING (no success in over 24h).</summary> + public int FailedCollectorCount { get; set; } + + public DateTime? LastCollectionTime { get; set; } + + /// <summary> + /// Headline CPU display: total non-idle CPU prominently with the SQL-only number alongside, e.g. + /// "64% (SQL 60%)". Falls back to a single number when only one value is available. + /// </summary> + public string CpuDisplay + { + get + { + if (!CpuPercent.HasValue) return "--"; + if (!OtherProcessCpuPercent.HasValue) return $"{CpuPercent:F0}%"; + return $"{TotalCpuPercent:F0}% (SQL {CpuPercent:F0}%)"; + } + } + + /// <summary>The non-SQL host CPU alongside the headline (the Dashboard's CPU detail), when known.</summary> + public string CpuDetail => OtherProcessCpuPercent.HasValue ? $"Other: {OtherProcessCpuPercent:F0}%" : ""; + + public string MemoryDisplay => MemoryMb.HasValue ? $"{MemoryMb / 1024.0:F1} GB" : "--"; + + /// <summary> + /// The Memory detail: under resource-semaphore pressure it names the pressure (grant waiters, then + /// timeouts / forced grants when present) — mirroring the Dashboard's "N waiting"; when calm it shows + /// the buffer-pool and query-memory-grant sizes (the Dashboard's "BP: x, QMG: y"). + /// </summary> + public string MemoryDetail + { + get + { + if (HasMemoryPressure) + { + var parts = new List<string>(); + if (MemoryWaiterCount > 0) parts.Add($"{MemoryWaiterCount} grant waiter{(MemoryWaiterCount == 1 ? "" : "s")}"); + if (MemoryTimeoutCount > 0) parts.Add($"{MemoryTimeoutCount} timeout{(MemoryTimeoutCount == 1 ? "" : "s")}"); + if (MemoryForcedCount > 0) parts.Add($"{MemoryForcedCount} forced"); + return string.Join(", ", parts); + } + + var sizes = new List<string>(); + if (BufferPoolMb.HasValue) sizes.Add($"BP {BufferPoolMb.Value / 1024.0:F1}"); + if (GrantedMemoryMb is > 0) sizes.Add($"QMG {GrantedMemoryMb.Value / 1024.0:F1}"); + return sizes.Count > 0 ? string.Join(", ", sizes) + " GB" : ""; + } + } + + public string BlockingDisplay => BlockingCount > 0 ? BlockingCount.ToString() : "0"; + + /// <summary> + /// The blocking detail (Dashboard's BlockingDetailText): the worst wait while blocking is present in + /// the window ("max: 42s"), else how long since the last blocking event ever ("Last: 3h ago"), else blank. + /// </summary> + public string BlockingDetail + { + get + { + if (BlockingCount > 0) return $"max: {MaxBlockedSeconds:F0}s"; + if (LastBlockingMinutesAgo.HasValue) return $"Last: {FormatMinutesAgo(LastBlockingMinutesAgo.Value)}"; + return ""; + } + } + + /// <summary>The worst blocking wait in the window, in seconds.</summary> + public double MaxBlockedSeconds => MaxBlockingWaitMs / 1000.0; + + public string DeadlockDisplay => DeadlockCount > 0 ? DeadlockCount.ToString() : "0"; + + /// <summary>The deadlock detail — how long since the last deadlock ever ("Last: N ago"), else blank.</summary> + public string DeadlockDetail => + LastDeadlockMinutesAgo.HasValue ? $"Last: {FormatMinutesAgo(LastDeadlockMinutesAgo.Value)}" : ""; + + /// <summary>Threads value — the pressure headline (Dashboard's ThreadsDisplayText), or "--" with no snapshot.</summary> + public string ThreadsDisplay + { + get + { + if (!TotalThreads.HasValue) return "--"; + if (RequestsWaitingForThreads > 0) return $"{RequestsWaitingForThreads} starved"; + if (ThreadsWaitingForCpu >= 20) return $"{ThreadsWaitingForCpu} runnable"; + if (TotalThreads.Value > 0 && AvailableThreads < TotalThreads.Value * 0.10) return "Low"; + return "OK"; + } + } + + /// <summary>Threads detail — "Available: in/ceiling" (Dashboard's ThreadsDetailText); blank with no snapshot.</summary> + public string ThreadsDetail => + TotalThreads is > 0 ? $"Available: {AvailableThreads}/{TotalThreads}" : ""; + + /// <summary>Collectors value — "N failed" or "OK" (Dashboard's CollectorDisplayText).</summary> + public string CollectorDisplay => FailedCollectorCount > 0 ? $"{FailedCollectorCount} failed" : "OK"; + + /// <summary>Collectors detail — "Healthy: N, Failing: M" (Dashboard's CollectorDetailText).</summary> + public string CollectorDetail => $"Healthy: {HealthyCollectorCount}, Failing: {FailedCollectorCount}"; + + /// <summary> + /// The stored collection_time is naive UTC; the viewer shows it in the viewer machine's local time + /// (the viewer convention — Lite used its per-server offset helper instead). + /// </summary> + public string LastCollectionDisplay => LastCollectionTime.HasValue + ? ViewerTimeHelper.ForDisplay(LastCollectionTime.Value).ToString("HH:mm:ss") + : "Never"; + + /* Connection status — verbatim from Lite; in the viewer the inputs come from ApplyFreshness. + The null arm distinguishes "not reached yet" (bootstrap) from a legacy unknown. */ + public string StatusDisplay => IsOnline switch + { + true when HasCollectorErrors => "Warning", + true => "Online", + false => "Offline", + _ => AwaitingFirstCollection ? "Awaiting first collection" : "Unknown" + }; + + public SolidColorBrush StatusBrush => MakeBrush(IsOnline switch + { + true when HasCollectorErrors => "#FFD54F", // amber — stale collection + true => "#81C784", + false => "#E57373", + _ => AwaitingFirstCollection ? "#FFD54F" : "#888888" // amber — queued, not dead + }); + + public bool IsOffline => IsOnline == false; + + // ── Per-metric severity bands (delegated to the SHARED ServerHealthClassifier — one place for the + // thresholds; this card keeps only the brush mapping) ──────────────────────────────────────────── + + /// <summary>CPU band — total non-idle CPU: >= 95% Critical, >= 80% Warning.</summary> + public HealthSeverity CpuSeverity => ServerHealthClassifier.CpuSeverity(CpuPercentForAlert); + + /// <summary>True when the resource semaphore shows grant waiters, timeouts, or forced grants.</summary> + public bool HasMemoryPressure => MemoryWaiterCount > 0 || MemoryTimeoutCount > 0 || MemoryForcedCount > 0; + + /// <summary>Memory band — Critical on any resource-semaphore pressure, else Healthy.</summary> + public HealthSeverity MemorySeverity => ServerHealthClassifier.MemorySeverity(HasMemoryPressure); + + /// <summary>Blocking band — >= 60s max wait or >= 5 events Critical; >= 10s, >= 2 events, or any blocking Warning.</summary> + public HealthSeverity BlockingSeverity => ServerHealthClassifier.BlockingSeverity(BlockingCount, MaxBlockedSeconds); + + /// <summary>Deadlock band — any deadlock in the window is Critical.</summary> + public HealthSeverity DeadlockSeverity => ServerHealthClassifier.DeadlockSeverity(DeadlockCount); + + /// <summary>Threads band — work-queue starvation Critical; >= 20 runnable-waiting or under 10% available Warning; no snapshot Unknown.</summary> + public HealthSeverity ThreadsSeverity => + ServerHealthClassifier.ThreadsSeverity(TotalThreads, AvailableThreads, ThreadsWaitingForCpu, RequestsWaitingForThreads); + + /// <summary>Collectors band — any FAILING collector is Warning.</summary> + public HealthSeverity CollectorSeverity => ServerHealthClassifier.CollectorSeverity(FailedCollectorCount); + + /// <summary>The card's worst metric band (offline handled separately by the border / overlay).</summary> + public HealthSeverity OverallMetricSeverity => ServerHealthClassifier.OverallMetricSeverity(ToHealthMetrics()); + + /// <summary>The card's raw per-metric inputs, for the shared classifier (banding + fleet score).</summary> + public ServerHealthMetrics ToHealthMetrics() => new() + { + CpuPercentForAlert = CpuPercentForAlert, + HasMemoryPressure = HasMemoryPressure, + BlockingCount = BlockingCount, + MaxBlockedSeconds = MaxBlockedSeconds, + DeadlockCount = DeadlockCount, + TotalThreads = TotalThreads, + AvailableThreads = AvailableThreads, + ThreadsWaitingForCpu = ThreadsWaitingForCpu, + RequestsWaitingForThreads = RequestsWaitingForThreads, + FailedCollectorCount = FailedCollectorCount, + }; + + // ── Per-metric dot / value brushes ─────────────────────────────────────────────────────────────── + + public SolidColorBrush CpuSeverityBrush => SeverityBrush(CpuSeverity); + public SolidColorBrush MemorySeverityBrush => SeverityBrush(MemorySeverity); + public SolidColorBrush BlockingSeverityBrush => SeverityBrush(BlockingSeverity); + public SolidColorBrush DeadlockSeverityBrush => SeverityBrush(DeadlockSeverity); + public SolidColorBrush ThreadsSeverityBrush => SeverityBrush(ThreadsSeverity); + public SolidColorBrush CollectorSeverityBrush => SeverityBrush(CollectorSeverity); + + public bool HasAlerts => BlockingCount > 0 || DeadlockCount > 0; + + /// <summary> + /// The card border reflects the worst signal: offline (red) > a Critical metric (red) > a Warning + /// metric (amber-orange) > a stale collection (amber) > calm (dark). Enriches Lite's border (which + /// only knew CPU / blocking / deadlock) with the added Threads / Memory / Collectors bands via + /// <see cref="OverallMetricSeverity"/>. + /// </summary> + public SolidColorBrush CardBorderBrush + { + get + { + if (IsOnline == false) return s_criticalBrush; + return OverallMetricSeverity switch + { + HealthSeverity.Critical => s_criticalBrush, + HealthSeverity.Warning => s_warningBrush, + _ => HasCollectorErrors || AwaitingFirstCollection ? MakeBrush("#FFD54F") : MakeBrush("#2a2d35"), + }; + } + } + + /// <summary> + /// The viewer's status derivation (#1262): classify how fresh the newest collection is. Pure over + /// (last-collection, now) so it can be pinned without a store. Both instants are UTC (the store is + /// naive UTC; <paramref name="nowUtc"/> is <see cref="DateTime.UtcNow"/>), so the subtraction is a + /// true elapsed-time regardless of Kind. + /// </summary> + public static ServerFreshness ClassifyFreshness(DateTime? lastCollectionUtc, DateTime nowUtc) => + ServerHealthClassifier.ClassifyFreshness(lastCollectionUtc, nowUtc); + + /// <summary> + /// Maps the freshness band onto Lite's card inputs, taking the live-ping's place: Fresh → Online, + /// Stale → the amber Warning state, Offline → the red Offline overlay, NeverCollected → the amber + /// "Awaiting first collection" state (IsOnline stays null: the truth is "unknown, not reached yet", + /// not "was up and died"). + /// </summary> + public void ApplyFreshness(DateTime nowUtc) + { + var freshness = ClassifyFreshness(LastCollectionTime, nowUtc); + if (freshness == ServerFreshness.NeverCollected) + { + IsOnline = null; + HasCollectorErrors = false; + AwaitingFirstCollection = true; + return; + } + + AwaitingFirstCollection = false; + IsOnline = freshness != ServerFreshness.Offline; + HasCollectorErrors = freshness == ServerFreshness.Stale; + } + + private static SolidColorBrush SeverityBrush(HealthSeverity severity) => severity switch + { + HealthSeverity.Critical => s_criticalBrush, + HealthSeverity.Warning => s_warningBrush, + HealthSeverity.Healthy => s_healthyBrush, + _ => s_unknownBrush, + }; + + /// <summary>Human "N ago" rendering of an elapsed minutes count — verbatim from ServerHealthStatus.</summary> + private static string FormatMinutesAgo(int minutes) + { + if (minutes < 1) return "just now"; + if (minutes < 60) return $"{minutes}m ago"; + if (minutes < 1440) return $"{minutes / 60}h ago"; // 24 hours + if (minutes < 10080) return $"{minutes / 1440}d ago"; // 7 days + return $"{minutes / 10080}w ago"; + } + + private static SolidColorBrush MakeBrush(string hex) + { + var color = (Color)ColorConverter.ConvertFromString(hex); + var brush = new SolidColorBrush(color); + brush.Freeze(); + return brush; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.OverviewLanes.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.OverviewLanes.cs new file mode 100644 index 000000000..58b33cfed --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.OverviewLanes.cs @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Analysis.Baselines; +using PerformanceMonitor.Darling.Analysis; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary>One memory-stats trend point — a mirror of Lite's <c>MemoryTrendPoint</c> +/// (<c>LocalDataService.Memory.cs</c>). The Overview lanes plot only <see cref="BufferPoolMb"/>, but the +/// full four-metric shape is carried for parity with Lite's row (the future Memory tab port reuses it), +/// exactly as the wait picker's <see cref="WaitStatsTrendPoint"/> carries its signal-wait field.</summary> +public sealed record MemoryTrendPoint( + DateTime CollectionTime, + double TotalServerMemoryMb, + double TargetServerMemoryMb, + double BufferPoolMb, + double PlanCacheMb); + +/// <summary> +/// The Overview inner tab's correlated-timeline-lanes reads (W1d viewer copy-parity) that don't already +/// exist from earlier waves: the single-line TOTAL wait trend (Lite's <c>GetTotalWaitTrendAsync</c>) and +/// the memory trend (Lite's <c>GetMemoryTrendAsync</c>), plus the per-lane baseline lookup that wraps the +/// shared <see cref="PgBaselineProvider"/>. The lanes' other four feeds are REUSED as-is: +/// <c>GetCpuUtilizationAsync</c> (W1a, raw CPU samples), and <c>GetBlockingTrendAsync</c> / +/// <c>GetDeadlockTrendAsync</c> / <c>GetFileIoLatencyTrendAsync</c> (W1c). Both new reads run against the +/// <c>v_wait_stats</c> / <c>v_memory_stats</c> passthrough views (the Darling-analysis convention) with +/// the naive-UTC window bound Kind-Unspecified, like every other viewer read. +/// </summary> +public sealed partial class ViewerDataService +{ + /// <summary> + /// Total wait time across ALL wait types as one per-second series — Lite's <c>GetTotalWaitTrendAsync</c> + /// ported to Postgres. A <c>per_collection</c> CTE SUMs <c>delta_wait_time_ms</c> per collection and + /// derives the collection interval from the LAG of the prior collection_time (the truncate-then-diff + /// epoch idiom proven value-identical DuckDB↔Postgres); the outer query divides the delta by the + /// interval for a per-second rate (delta CAST to double precision for the typed reader). Lite's + /// per-user <c>IgnoredWaitTypes</c> exclusion clause is deliberately DROPPED — the viewer has no + /// per-user ignore config, matching the W1b wait-picker read. $1 server_id, $2 window start, $3 window + /// end (naive UTC). + /// </summary> + public const string TotalWaitTrendSql = """ + WITH per_collection AS + ( + SELECT + collection_time, + SUM(delta_wait_time_ms) AS total_delta_ms, + extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (ORDER BY collection_time)))) AS interval_seconds + FROM v_wait_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + GROUP BY collection_time + ) + SELECT + collection_time, + CASE WHEN interval_seconds > 0 THEN CAST(total_delta_ms AS double precision) / interval_seconds ELSE 0 END AS wait_time_ms_per_second + FROM per_collection + ORDER BY collection_time + """; + + /// <summary> + /// The memory trend — Lite's <c>GetMemoryTrendAsync</c> ported to Postgres. Reads the four MB metrics + /// from <c>v_memory_stats</c>; each is <c>numeric(18,2)</c> in the store, so all four are CAST to + /// double precision for the typed GetDouble reader (the same numeric→double adaptation the CPU/File-IO + /// reads make). $1 server_id, $2 window start, $3 window end (naive UTC). + /// </summary> + public const string MemoryTrendSql = """ + SELECT + collection_time, + CAST(total_server_memory_mb AS double precision) AS total_server_memory_mb, + CAST(target_server_memory_mb AS double precision) AS target_server_memory_mb, + CAST(buffer_pool_mb AS double precision) AS buffer_pool_mb, + CAST(plan_cache_mb AS double precision) AS plan_cache_mb + FROM v_memory_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + ORDER BY collection_time + """; + + /// <summary> + /// Total wait ms/sec across all types for one server over the window (the Overview's wait lane). The + /// read produces only <c>CollectionTime</c> + <c>WaitTimeMsPerSecond</c>; the reused + /// <see cref="WaitStatsTrendPoint"/>'s signal-wait / avg-per-wait fields are left 0 (mirroring Lite, + /// whose <c>GetTotalWaitTrendAsync</c> returns the same record shape with those two unset). + /// </summary> + public async Task<List<WaitStatsTrendPoint>> GetTotalWaitTrendAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var items = new List<WaitStatsTrendPoint>(); + + await using var command = _dataSource.CreateCommand(TotalWaitTrendSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified), + }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified), + }); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new WaitStatsTrendPoint( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : reader.GetDouble(1), + 0, + 0)); + } + + return items; + } + + /// <summary> + /// The memory trend (four MB metrics per collection) for one server over the window; the Overview's + /// buffer-pool lane uses <see cref="MemoryTrendPoint.BufferPoolMb"/>. + /// </summary> + public async Task<List<MemoryTrendPoint>> GetMemoryTrendAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var items = new List<MemoryTrendPoint>(); + + await using var command = _dataSource.CreateCommand(MemoryTrendSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified), + }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified), + }); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new MemoryTrendPoint( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : reader.GetDouble(1), + reader.IsDBNull(2) ? 0 : reader.GetDouble(2), + reader.IsDBNull(3) ? 0 : reader.GetDouble(3), + reader.IsDBNull(4) ? 0 : reader.GetDouble(4))); + } + + return items; + } + + /// <summary>Lazily-built baseline provider over this service's pooled data source (Lite's lazy + /// <c>GetBaselineProvider</c> mirrored). The provider holds only an in-memory cache and a borrowed + /// reference to <c>_dataSource</c> — it does not own the data source, so it needs no disposal.</summary> + private PgBaselineProvider? _baselineProvider; + + private PgBaselineProvider GetBaselineProvider() + => _baselineProvider ??= new PgBaselineProvider(_dataSource); + + /// <summary> + /// The baseline (mean ± stddev bucket) for a chart-unit metric at a reference time, for the lanes' + /// baseline bands + anomaly markers — Lite's <c>GetBaselineForLaneAsync</c> mirrored over the shared + /// <see cref="PgBaselineProvider"/> (in-memory compute from 30-day rolling history, no persistence). + /// Returns <see cref="BaselineBucket.Empty"/> when the bucket has no samples, so the lane renderers' + /// <c>SampleCount > 0</c> guard simply skips the band. <paramref name="referenceTime"/> is naive UTC + /// (the baseline buckets key on hour-of-day / day-of-week of the store's naive-UTC timestamps). + /// </summary> + public async Task<BaselineBucket> GetBaselineForLaneAsync( + int serverId, string metricName, DateTime referenceTime) + { + var baseline = await GetBaselineProvider().GetBaselineAsync(serverId, metricName, referenceTime); + return baseline.SampleCount > 0 ? baseline : BaselineBucket.Empty; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Perfmon.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Perfmon.cs new file mode 100644 index 000000000..74872186c --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Perfmon.cs @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// One point on a selected perfmon counter's trend line: the raw counter value and the per-interval +/// delta, both summed across the counter's instances at each collection_time. Copied from Lite's +/// <c>PerfmonTrendPoint</c> (LocalDataService.Perfmon.cs). The picker plots <see cref="DeltaValue"/>; +/// <see cref="Value"/> rides along for parity with Lite's row shape. +/// </summary> +public sealed record PerfmonTrendPoint( + DateTime CollectionTime, + long Value, + long DeltaValue); + +public sealed partial class ViewerDataService +{ + /// <summary> + /// The perfmon picker's population read — Lite's <c>GetDistinctPerfmonCountersAsync</c> ported to + /// Postgres verbatim (runs against the <c>v_perfmon_stats</c> passthrough view, the Darling-analysis + /// convention): every counter_name collected for the server over the window, ordered by name so the + /// picker + pack fills see a stable list. + /// $1 server_id, $2 window start, $3 window end (all naive UTC). + /// </summary> + public const string DistinctPerfmonCountersSql = """ + SELECT DISTINCT counter_name + FROM v_perfmon_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + ORDER BY counter_name + """; + + /// <summary> + /// Lite's batched <c>GetPerfmonTrendsByCountersAsync</c> body, ported to Postgres: the trend for + /// ALL selected counters in ONE query, grouped by counter name and collection_time. The + /// <c>counter_name IN (...)</c> list is built dynamically from <c>$4</c> onward + /// (server_id/start/end take <c>$1</c>/<c>$2</c>/<c>$3</c>), exactly like Lite's numbering. + /// Deviation from Lite: the two <c>SUM</c> aggregates are wrapped in <c>CAST(... AS bigint)</c> + /// because Postgres' <c>SUM(bigint)</c> returns <c>numeric</c> (DuckDB kept it integral), and the + /// typed <c>GetInt64</c> reader would throw against a numeric — the same typed-reader reason the + /// tempdb/CPU trends CAST their aggregates. A caller passing <paramref name="counterCount"/> = 0 is + /// a bug the <see cref="GetPerfmonTrendsByCountersAsync"/> guard prevents. + /// </summary> + public static string PerfmonTrendsSql(int counterCount) + { + var nameParams = string.Join(", ", Enumerable.Range(0, counterCount).Select(i => "$" + (i + 4))); + return $""" + SELECT + counter_name, + collection_time, + CAST(SUM(cntr_value) AS bigint) AS cntr_value, + CAST(SUM(delta_cntr_value) AS bigint) AS delta_cntr_value + FROM v_perfmon_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND counter_name IN ({nameParams}) + GROUP BY counter_name, collection_time + ORDER BY counter_name, collection_time + """; + } + + /// <summary> + /// The distinct counter names collected for one server in the window, ordered by name — feeds the + /// picker's population + pack fills. + /// </summary> + public async Task<List<string>> GetDistinctPerfmonCountersAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var items = new List<string>(); + + await using var command = _dataSource.CreateCommand(DistinctPerfmonCountersSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified), + }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified), + }); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(reader.GetString(0)); + } + + return items; + } + + /// <summary> + /// The value + delta trend for every selected counter in one query, grouped by counter name. + /// Empty selection returns an empty map without touching the store. + /// </summary> + public async Task<Dictionary<string, List<PerfmonTrendPoint>>> GetPerfmonTrendsByCountersAsync( + int serverId, List<string> counterNames, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var result = new Dictionary<string, List<PerfmonTrendPoint>>(); + if (counterNames.Count == 0) + { + return result; + } + + await using var command = _dataSource.CreateCommand(PerfmonTrendsSql(counterNames.Count)); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified), + }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified), + }); + foreach (var counterName in counterNames) + { + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = counterName }); + } + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var counterName = reader.GetString(0); + if (!result.TryGetValue(counterName, out var list)) + { + list = new List<PerfmonTrendPoint>(); + result[counterName] = list; + } + + list.Add(new PerfmonTrendPoint( + reader.GetDateTime(1), + reader.IsDBNull(2) ? 0 : reader.GetInt64(2), + reader.IsDBNull(3) ? 0 : reader.GetInt64(3))); + } + + return result; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.PlanCache.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.PlanCache.cs new file mode 100644 index 000000000..9835d9558 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.PlanCache.cs @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary>One point on the plan-cache size trend: single-use vs multi-use plan cache size (MB) at a +/// collection instant, summed across every (cacheobjtype, objtype) group — the single-use bloat signal +/// the Dashboard's Plan Cache chart plots.</summary> +public sealed record PlanCacheTrendPoint(DateTime CollectionTime, double SingleUseSizeMb, double MultiUseSizeMb); + +/// <summary>One row of the Plan Cache latest-snapshot composition grid: one (cacheobjtype, objtype) +/// group's plan/size breakdown at the most recent collection in the window. <c>OldestPlanCreateTime</c> +/// is the store-wide MIN(creation_time) the collector stamps on every group row identically, so the +/// summary can read it off any row.</summary> +public sealed record PlanCacheSnapshotRow( + string Cacheobjtype, + string Objtype, + int TotalPlans, + int TotalSizeMb, + int SingleUsePlans, + int SingleUseSizeMb, + int MultiUsePlans, + int MultiUseSizeMb, + decimal AvgUseCount, + int AvgSizeKb, + DateTime? OldestPlanCreateTime); + +/// <summary>The Plan Cache summary strip's figures for the latest snapshot in the window: the TRUE total +/// plan count and single-use plan count (both summed across every group, not just the displayed top-N) and +/// the oldest cached plan's create time — matching the Dashboard, which computes these over the full latest +/// snapshot. <see cref="SingleUsePlans"/> feeds the derived bloat-level badge +/// (<see cref="ViewerDataService.ClassifyPlanCacheBloat"/>).</summary> +public sealed record PlanCacheSummary(long TotalPlans, long SingleUsePlans, DateTime? OldestPlanCreateTime); + +/// <summary>The plan-cache bloat classification for the summary badge: the banded severity level plus the +/// paired recommendation, mirroring install/47's report.plan_cache_bloat.</summary> +public sealed record PlanCacheBloat(string Level, string Recommendation); + +public sealed partial class ViewerDataService +{ + /// <summary> + /// The Plan Cache size trend read: single-use vs multi-use cache size (MB) per collection, summed + /// across every (cacheobjtype, objtype) group, from the <c>v_plan_cache_stats</c> passthrough view — + /// the Dashboard's Plan Cache chart shape (single-use vs multi-use bloat), windowed on the settable + /// range. The integer MB sums are CAST to double precision for the typed reader. $1 server_id, + /// $2 window start, $3 window end (all naive UTC). + /// </summary> + public const string PlanCacheTrendSql = """ + SELECT + collection_time, + CAST(SUM(single_use_size_mb) AS double precision) AS single_use_mb, + CAST(SUM(multi_use_size_mb) AS double precision) AS multi_use_mb + FROM v_plan_cache_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + GROUP BY collection_time + ORDER BY collection_time + """; + + /// <summary> + /// The Plan Cache latest-snapshot composition read: every (cacheobjtype, objtype) group captured at + /// the most recent collection in the window, ordered by total cache size then plan count, capped at + /// 30 rows. $1 server_id, $2 start, $3 end (naive UTC). + /// </summary> + public const string PlanCacheSnapshotSql = """ + WITH latest AS + ( + SELECT MAX(collection_time) AS mx + FROM v_plan_cache_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + ) + SELECT + cacheobjtype, + objtype, + total_plans, + total_size_mb, + single_use_plans, + single_use_size_mb, + multi_use_plans, + multi_use_size_mb, + avg_use_count, + avg_size_kb, + oldest_plan_create_time + FROM v_plan_cache_stats + WHERE server_id = $1 + AND collection_time = (SELECT mx FROM latest) + ORDER BY total_size_mb DESC, total_plans DESC + LIMIT 30 + """; + + /// <summary> + /// The Plan Cache summary read: the TRUE total plan count (SUM over EVERY group, uncapped) and the + /// oldest cached plan's create time (MIN) at the most recent collection in the window — the summary + /// strip's two figures, computed independently of the top-30 grid read so "Total Plans" is exact even + /// when the composition grid is capped (matching the Dashboard, which sums the full latest snapshot). + /// An aggregate with no GROUP BY always returns one row, so an empty window yields (0, NULL). + /// $1 server_id, $2 start, $3 end (naive UTC). + /// </summary> + public const string PlanCacheSummarySql = """ + WITH latest AS + ( + SELECT MAX(collection_time) AS mx + FROM v_plan_cache_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + ) + SELECT + COALESCE(SUM(total_plans), 0) AS total_plans, + COALESCE(SUM(single_use_plans), 0) AS single_use_plans, + MIN(oldest_plan_create_time) AS oldest_plan_create_time + FROM v_plan_cache_stats + WHERE server_id = $1 + AND collection_time = (SELECT mx FROM latest) + """; + + /// <summary> + /// The plan-cache bloat classification (single-use plan bloat), a pure client-side derivation mirroring + /// install/47's <c>report.plan_cache_bloat</c> (bloat_level + recommendation) and the Dashboard's + /// <c>PlanCacheStatsItem.BloatLevel/Recommendation</c>. The ratio is single-use plans / total plans: + /// > 50% CRITICAL, > 30% HIGH, > 20% MEDIUM, else NORMAL; the recommendation flips to the + /// "unparameterized queries / Forced Parameterization" hint at the same > 20% threshold. Static + pure + /// so it is unit-testable without Postgres. An empty cache (<paramref name="totalPlans"/> == 0) is NORMAL. + /// </summary> + public static PlanCacheBloat ClassifyPlanCacheBloat(long totalPlans, long singleUsePlans) + { + double singleUsePercent = totalPlans > 0 ? singleUsePlans * 100.0 / totalPlans : 0.0; + + var level = + singleUsePercent > 50 ? "CRITICAL" : + singleUsePercent > 30 ? "HIGH" : + singleUsePercent > 20 ? "MEDIUM" : + "NORMAL"; + + var recommendation = singleUsePercent > 20 + ? "Check for unparameterized queries / Consider Forced Parameterization" + : "Plan cache composition is healthy"; + + return new PlanCacheBloat(level, recommendation); + } + + /// <summary>The single-use vs multi-use plan-cache size trend over the window (empty when none).</summary> + public async Task<List<PlanCacheTrendPoint>> GetPlanCacheTrendAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var result = new List<PlanCacheTrendPoint>(); + + await using var command = _dataSource.CreateCommand(PlanCacheTrendSql); + AddWindowParameters(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + result.Add(new PlanCacheTrendPoint( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : reader.GetDouble(1), + reader.IsDBNull(2) ? 0 : reader.GetDouble(2))); + } + + return result; + } + + /// <summary>The latest-snapshot plan-cache composition rows (top 30 by size) for the window.</summary> + public async Task<List<PlanCacheSnapshotRow>> GetPlanCacheSnapshotAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var result = new List<PlanCacheSnapshotRow>(); + + await using var command = _dataSource.CreateCommand(PlanCacheSnapshotSql); + AddWindowParameters(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + result.Add(new PlanCacheSnapshotRow( + Cacheobjtype: reader.IsDBNull(0) ? string.Empty : reader.GetString(0), + Objtype: reader.IsDBNull(1) ? string.Empty : reader.GetString(1), + TotalPlans: reader.IsDBNull(2) ? 0 : reader.GetInt32(2), + TotalSizeMb: reader.IsDBNull(3) ? 0 : reader.GetInt32(3), + SingleUsePlans: reader.IsDBNull(4) ? 0 : reader.GetInt32(4), + SingleUseSizeMb: reader.IsDBNull(5) ? 0 : reader.GetInt32(5), + MultiUsePlans: reader.IsDBNull(6) ? 0 : reader.GetInt32(6), + MultiUseSizeMb: reader.IsDBNull(7) ? 0 : reader.GetInt32(7), + AvgUseCount: reader.IsDBNull(8) ? 0m : reader.GetDecimal(8), + AvgSizeKb: reader.IsDBNull(9) ? 0 : reader.GetInt32(9), + OldestPlanCreateTime: reader.IsDBNull(10) ? null : reader.GetDateTime(10))); + } + + return result; + } + + /// <summary>The exact total-plans + oldest-plan figures for the summary strip (uncapped), or + /// (0, null) when the window holds no snapshot.</summary> + public async Task<PlanCacheSummary> GetPlanCacheSummaryAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(PlanCacheSummarySql); + AddWindowParameters(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + { + return new PlanCacheSummary(0, 0, null); + } + + return new PlanCacheSummary( + reader.IsDBNull(0) ? 0 : reader.GetInt64(0), + reader.IsDBNull(1) ? 0 : reader.GetInt64(1), + reader.IsDBNull(2) ? null : reader.GetDateTime(2)); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Plans.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Plans.cs new file mode 100644 index 000000000..a052458bc --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Plans.cs @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// On-demand execution-plan reads for the Plan Viewer host (headless-plan wave): the plan XML the +/// Darling service captures alongside the query/query-store/procedure stats (PR #1349 for query & +/// query-store, #1368 / V7 for procedure_stats, gated on <c>CollectorContext.CapturePlanXml</c> — Darling +/// sets it true, TOAST compresses the text). Unlike Lite — which fetches plans live from the monitored +/// server's plan cache — the viewer never touches SQL Server, so it reads the stored plan text straight out +/// of Postgres by the grid row's key columns. The keyed grids (Top Queries / Query Store / Top Procedures) +/// carry only a cheap presence flag or fetch on click (no multi-KB XML per row); the blocked-process / +/// deadlock grids instead carry their best-effort plan XML in-row (see ViewerDataService.Blocking.cs / +/// .Deadlock.cs), like they already carry the report / graph XML. These keyed lookups are keyed (server + +/// object identity), not windowed, so there is no timestamp parameter and no naive-UTC concern — +/// <c>ORDER BY collection_time DESC LIMIT 1</c> returns the most recently captured plan for that key. Plans +/// can be large; they are read as text with no size games (the task's rule: TOAST handles storage, reading +/// is fine). +/// </summary> +public sealed partial class ViewerDataService +{ + /// <summary> + /// The latest captured query_stats plan for a Top-Queries row, keyed by (server, database, query_hash) — + /// the grid groups by (database_name, query_hash), so the same key fetches the plan the row represents. + /// $1 server_id, $2 database_name, $3 query_hash. + /// </summary> + public const string QueryStatsPlanXmlSql = """ + SELECT query_plan_xml + FROM query_stats + WHERE server_id = $1 + AND database_name = $2 + AND query_hash = $3 + AND query_plan_xml IS NOT NULL + ORDER BY collection_time DESC + LIMIT 1 + """; + + /// <summary> + /// The stored execution plan for a Top-Queries grid row, or null when no plan was captured for that + /// (database, query_hash). Read as text — no length cap (the collector stored the whole plan). + /// </summary> + public async Task<string?> GetQueryStatsPlanXmlAsync( + int serverId, string databaseName, string queryHash, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(queryHash)) + { + return null; + } + + await using var command = _dataSource.CreateCommand(QueryStatsPlanXmlSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = databaseName ?? "" }); + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = queryHash }); + var result = await command.ExecuteScalarAsync(cancellationToken); + return result is string s ? s : null; + } + + /// <summary> + /// The latest captured query_store_stats plan for a Query-Store row, keyed by (server, database, + /// query_id, plan_id) — a Query Store plan_id names one specific compiled plan, so (query_id, plan_id) + /// uniquely identifies it. $1 server_id, $2 database_name, $3 query_id, $4 plan_id. + /// </summary> + public const string QueryStorePlanTextSql = """ + SELECT query_plan_text + FROM query_store_stats + WHERE server_id = $1 + AND database_name = $2 + AND query_id = $3 + AND plan_id = $4 + AND query_plan_text IS NOT NULL + ORDER BY collection_time DESC + LIMIT 1 + """; + + /// <summary> + /// The stored Query Store execution plan for a grid row, or null when no plan text was captured for + /// that (database, query_id, plan_id). Query Store already stores plans as ShowPlanXML text. + /// </summary> + public async Task<string?> GetQueryStorePlanTextAsync( + int serverId, string databaseName, long queryId, long planId, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(QueryStorePlanTextSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = databaseName ?? "" }); + command.Parameters.Add(new NpgsqlParameter<long> { TypedValue = queryId }); + command.Parameters.Add(new NpgsqlParameter<long> { TypedValue = planId }); + var result = await command.ExecuteScalarAsync(cancellationToken); + return result is string s ? s : null; + } + + /// <summary> + /// The latest captured procedure_stats plan for a Top-Procedures row, keyed by (server, database, + /// schema, object) — the grid groups by (database_name, schema_name, object_name, object_type), so the + /// same object identity fetches the plan the row represents (Top Queries' keyed-fetch pattern, not the + /// in-row carry the aggregate grid can't do). procedure_stats.query_plan_xml is captured CLEANLY from the + /// cached plan (dm_exec_procedure_stats.plan_handle → dm_exec_text_query_plan) whenever the host sets + /// CapturePlanXml — Darling does, Lite always writes NULL. Read from the base <c>procedure_stats</c> + /// table (there is no v_procedure_stats view; TopProceduresSql already reads the base table), which also + /// avoids the V7-column / pinned-view issue the blocked/deadlock reads sidestep. $1 server_id, $2 + /// database_name, $3 schema_name, $4 object_name. + /// </summary> + public const string ProcedureStatsPlanXmlSql = """ + SELECT query_plan_xml + FROM procedure_stats + WHERE server_id = $1 + AND database_name = $2 + AND schema_name = $3 + AND object_name = $4 + AND query_plan_xml IS NOT NULL + ORDER BY collection_time DESC + LIMIT 1 + """; + + /// <summary> + /// The stored execution plan for a Top-Procedures grid row, or null when no plan was captured for that + /// (database, schema, object). Read as text — no length cap (the collector stored the whole plan). + /// </summary> + public async Task<string?> GetProcedureStatsPlanXmlAsync( + int serverId, string databaseName, string schemaName, string objectName, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(objectName)) + { + return null; + } + + await using var command = _dataSource.CreateCommand(ProcedureStatsPlanXmlSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = databaseName ?? "" }); + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = schemaName ?? "" }); + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = objectName }); + var result = await command.ExecuteScalarAsync(cancellationToken); + return result is string s ? s : null; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ProcedureStats.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ProcedureStats.cs new file mode 100644 index 000000000..8b650b5ab --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ProcedureStats.cs @@ -0,0 +1,333 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// One Top-Procedures-by-Duration grid row — the viewer copy of Lite's <c>ProcedureStatsRow</c> +/// (LocalDataService.QueryStats.cs). A (database, schema, object) group's summed deltas + min/max +/// spreads over the window. <see cref="FullName"/> composes schema.object like Lite; +/// <see cref="CachedTime"/>/<see cref="LastExecutionTime"/> are the server's local wall clock +/// (dm_exec_procedure_stats), shown raw. Procedure stats carry no DOP / rows columns (the DMV does +/// not expose them), matching Lite's row. +/// </summary> +public sealed class ViewerProcedureStatsRow +{ + public string DatabaseName { get; set; } = ""; + public string SchemaName { get; set; } = ""; + public string ObjectName { get; set; } = ""; + public string ObjectType { get; set; } = ""; + public long TotalExecutions { get; set; } + public long TotalCpuUs { get; set; } + public long TotalElapsedUs { get; set; } + public long TotalLogicalReads { get; set; } + public long TotalLogicalWrites { get; set; } + public long TotalPhysicalReads { get; set; } + public long MinWorkerTimeUs { get; set; } + public long MaxWorkerTimeUs { get; set; } + public long MinElapsedTimeUs { get; set; } + public long MaxElapsedTimeUs { get; set; } + public long MinLogicalReads { get; set; } + public long MaxLogicalReads { get; set; } + public long MinPhysicalReads { get; set; } + public long MaxPhysicalReads { get; set; } + public long MinLogicalWrites { get; set; } + public long MaxLogicalWrites { get; set; } + public long TotalSpills { get; set; } + public double AvgSpills { get; set; } + public long MinSpills { get; set; } + public long MaxSpills { get; set; } + public DateTime? CachedTime { get; set; } + public DateTime? LastExecutionTime { get; set; } + public string SqlHandle { get; set; } = ""; + public string PlanHandle { get; set; } = ""; + /// <summary>Whether the collector stored an execution plan for this object in the window — gates the + /// grid's per-row "Query Plan" Download button (mirrors Lite's Top Procedures HasQueryPlan gating).</summary> + public bool HasQueryPlan { get; set; } + + /// <summary>True when this row carries a plan_handle — gates the grid's "Fetch Live Plan" context item.</summary> + public bool HasLivePlanHandle => !string.IsNullOrEmpty(PlanHandle); + public string FullName => string.IsNullOrEmpty(SchemaName) ? ObjectName : $"{SchemaName}.{ObjectName}"; + public double TotalCpuMs => TotalCpuUs / 1000.0; + public double TotalElapsedMs => TotalElapsedUs / 1000.0; + public double AvgCpuMs => TotalExecutions > 0 ? TotalCpuMs / TotalExecutions : 0; + public double AvgElapsedMs => TotalExecutions > 0 ? TotalElapsedMs / TotalExecutions : 0; + public double AvgReads => TotalExecutions > 0 ? (double)TotalLogicalReads / TotalExecutions : 0; + public double MinCpuMs => MinWorkerTimeUs / 1000.0; + public double MaxCpuMs => MaxWorkerTimeUs / 1000.0; + public double MinElapsedMs => MinElapsedTimeUs / 1000.0; + public double MaxElapsedMs => MaxElapsedTimeUs / 1000.0; + public string CachedTimeFormatted => ViewerDataService.FormatServerClock(CachedTime); + public string LastExecutionTimeLocal => ViewerDataService.FormatServerClock(LastExecutionTime); +} + +public sealed partial class ViewerDataService +{ + /// <summary> + /// The Top-Procedures-by-Duration read — Lite's <c>GetTopProceduresByCpuAsync</c> ported to Postgres + /// against the base <c>procedure_stats</c> table (there is no <c>v_procedure_stats</c> view; the + /// existing viewer reads use base tables anyway). Group by (database, schema, object, type), sum the + /// deltas + carry min/max spreads, rank by summed <c>delta_elapsed_time</c> descending (Lite's grid + /// default), and cap at top — no LATERAL / WAITFOR trim (procedures aren't ad-hoc text). Viewer + /// deviations: summed bigints CAST back to bigint for GetInt64; Lite's staleness filter + + /// utcOffsetMinutes param dropped. + /// $1 server_id, $2 window start, $3 window end (naive UTC), $4 top. + /// </summary> + public const string TopProceduresSql = """ + SELECT + database_name, + schema_name, + object_name, + object_type, + CAST(SUM(delta_execution_count) AS bigint) AS total_executions, + CAST(SUM(delta_worker_time) AS bigint) AS total_cpu_us, + CAST(SUM(delta_elapsed_time) AS bigint) AS total_elapsed_us, + CAST(SUM(delta_logical_reads) AS bigint) AS total_reads, + CAST(SUM(delta_logical_writes) AS bigint) AS total_writes, + CAST(SUM(delta_physical_reads) AS bigint) AS total_physical_reads, + MIN(min_worker_time) AS min_worker_time, + MAX(max_worker_time) AS max_worker_time, + MIN(min_elapsed_time) AS min_elapsed_time, + MAX(max_elapsed_time) AS max_elapsed_time, + MIN(min_logical_reads) AS min_logical_reads, + MAX(max_logical_reads) AS max_logical_reads, + MIN(min_physical_reads) AS min_physical_reads, + MAX(max_physical_reads) AS max_physical_reads, + MIN(min_logical_writes) AS min_logical_writes, + MAX(max_logical_writes) AS max_logical_writes, + CAST(SUM(delta_spills) AS bigint) AS total_spills, + MIN(min_spills) AS min_spills, + MAX(max_spills) AS max_spills, + MAX(cached_time) AS cached_time, + MAX(last_execution_time) AS last_execution_time, + MAX(sql_handle) AS sql_handle, + MAX(plan_handle) AS plan_handle, + CAST(SUM(delta_spills) AS double precision) / NULLIF(SUM(delta_execution_count), 0) AS avg_spills, + /* Whether the collector captured a plan for this object anywhere in the window — gates the grid's + per-row "Query Plan" Download button, matching the same query_plan_xml IS NOT NULL filter + GetProcedureStatsPlanXmlAsync fetches on. */ + bool_or(query_plan_xml IS NOT NULL) AS has_query_plan + FROM procedure_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND ($5::text[] IS NULL OR database_name = ANY($5)) + GROUP BY database_name, schema_name, object_name, object_type + HAVING SUM(delta_execution_count) > 0 OR SUM(delta_elapsed_time) > 0 + ORDER BY SUM(delta_elapsed_time) DESC + LIMIT $4 + """; + + /// <summary> + /// The top procedure-stats groups for one server over [<paramref name="startUtc"/>, + /// <paramref name="endUtc"/>], pre-sorted by total elapsed time descending (the grid's default sort). + /// </summary> + public async Task<List<ViewerProcedureStatsRow>> GetTopProceduresByCpuAsync( + int serverId, DateTime startUtc, DateTime endUtc, int top = TopQueriesPageSize, IReadOnlyList<string>? databaseNames = null, CancellationToken cancellationToken = default) + { + var rows = new List<ViewerProcedureStatsRow>(); + + await using var command = _dataSource.CreateCommand(TopProceduresSql); + AddServerWindowParameters(command, serverId, startUtc, endUtc); + command.Parameters.Add(new Npgsql.NpgsqlParameter<int> { TypedValue = top }); + command.Parameters.Add(DatabaseFilterParameter(databaseNames)); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new ViewerProcedureStatsRow + { + DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0), + SchemaName = reader.IsDBNull(1) ? "" : reader.GetString(1), + ObjectName = reader.IsDBNull(2) ? "" : reader.GetString(2), + ObjectType = reader.IsDBNull(3) ? "" : reader.GetString(3), + TotalExecutions = reader.IsDBNull(4) ? 0 : reader.GetInt64(4), + TotalCpuUs = reader.IsDBNull(5) ? 0 : reader.GetInt64(5), + TotalElapsedUs = reader.IsDBNull(6) ? 0 : reader.GetInt64(6), + TotalLogicalReads = reader.IsDBNull(7) ? 0 : reader.GetInt64(7), + TotalLogicalWrites = reader.IsDBNull(8) ? 0 : reader.GetInt64(8), + TotalPhysicalReads = reader.IsDBNull(9) ? 0 : reader.GetInt64(9), + MinWorkerTimeUs = reader.IsDBNull(10) ? 0 : reader.GetInt64(10), + MaxWorkerTimeUs = reader.IsDBNull(11) ? 0 : reader.GetInt64(11), + MinElapsedTimeUs = reader.IsDBNull(12) ? 0 : reader.GetInt64(12), + MaxElapsedTimeUs = reader.IsDBNull(13) ? 0 : reader.GetInt64(13), + MinLogicalReads = reader.IsDBNull(14) ? 0 : reader.GetInt64(14), + MaxLogicalReads = reader.IsDBNull(15) ? 0 : reader.GetInt64(15), + MinPhysicalReads = reader.IsDBNull(16) ? 0 : reader.GetInt64(16), + MaxPhysicalReads = reader.IsDBNull(17) ? 0 : reader.GetInt64(17), + MinLogicalWrites = reader.IsDBNull(18) ? 0 : reader.GetInt64(18), + MaxLogicalWrites = reader.IsDBNull(19) ? 0 : reader.GetInt64(19), + TotalSpills = reader.IsDBNull(20) ? 0 : reader.GetInt64(20), + MinSpills = reader.IsDBNull(21) ? 0 : reader.GetInt64(21), + MaxSpills = reader.IsDBNull(22) ? 0 : reader.GetInt64(22), + CachedTime = reader.IsDBNull(23) ? null : reader.GetDateTime(23), + LastExecutionTime = reader.IsDBNull(24) ? null : reader.GetDateTime(24), + SqlHandle = reader.IsDBNull(25) ? "" : reader.GetString(25), + PlanHandle = reader.IsDBNull(26) ? "" : reader.GetString(26), + AvgSpills = reader.IsDBNull(27) ? 0 : Convert.ToDouble(reader.GetValue(27)), + HasQueryPlan = !reader.IsDBNull(28) && reader.GetBoolean(28), + }); + } + + return rows; + } + + /// <summary> + /// Top-Procedures comparison — Lite's <c>GetProcedureStatsComparisonAsync</c> ported. Same + /// top-100-union / FULL OUTER JOIN shape as the query-stats comparison, keyed on + /// (database, schema, object). Returns the shared <see cref="ProcedureStatsComparisonItem"/>. + /// $1 server_id, $2/$3 current window, $4/$5 baseline window (naive UTC). + /// </summary> + public const string ProcedureStatsComparisonSql = """ + WITH top_current AS ( + SELECT database_name, schema_name, object_name + FROM procedure_stats + WHERE server_id = $1 + AND collection_time >= $2 AND collection_time <= $3 + AND ($6::text[] IS NULL OR database_name = ANY($6)) + AND delta_execution_count > 0 + GROUP BY database_name, schema_name, object_name + ORDER BY SUM(delta_execution_count) DESC + LIMIT 100 + ), + top_baseline AS ( + SELECT database_name, schema_name, object_name + FROM procedure_stats + WHERE server_id = $1 + AND collection_time >= $4 AND collection_time <= $5 + AND ($6::text[] IS NULL OR database_name = ANY($6)) + AND delta_execution_count > 0 + GROUP BY database_name, schema_name, object_name + ORDER BY SUM(delta_execution_count) DESC + LIMIT 100 + ), + top_procs AS ( + SELECT DISTINCT database_name, schema_name, object_name + FROM ( + SELECT * FROM top_current + UNION ALL + SELECT * FROM top_baseline + ) AS combined + ), + current_period AS ( + SELECT tp.database_name, tp.schema_name, tp.object_name, + SUM(ps.delta_execution_count) AS exec_count, + SUM(ps.delta_elapsed_time)::double precision / NULLIF(SUM(ps.delta_execution_count), 0) / 1000.0 AS avg_duration_ms, + SUM(ps.delta_worker_time)::double precision / NULLIF(SUM(ps.delta_execution_count), 0) / 1000.0 AS avg_cpu_ms, + SUM(ps.delta_physical_reads)::double precision / NULLIF(SUM(ps.delta_execution_count), 0) AS avg_reads + FROM top_procs tp + INNER JOIN procedure_stats ps + ON ps.database_name IS NOT DISTINCT FROM tp.database_name + AND ps.schema_name IS NOT DISTINCT FROM tp.schema_name + AND ps.object_name IS NOT DISTINCT FROM tp.object_name + WHERE ps.server_id = $1 + AND ps.collection_time >= $2 AND ps.collection_time <= $3 + AND ps.delta_execution_count > 0 + GROUP BY tp.database_name, tp.schema_name, tp.object_name + ), + baseline_period AS ( + SELECT tp.database_name, tp.schema_name, tp.object_name, + SUM(ps.delta_execution_count) AS exec_count, + SUM(ps.delta_elapsed_time)::double precision / NULLIF(SUM(ps.delta_execution_count), 0) / 1000.0 AS avg_duration_ms, + SUM(ps.delta_worker_time)::double precision / NULLIF(SUM(ps.delta_execution_count), 0) / 1000.0 AS avg_cpu_ms, + SUM(ps.delta_physical_reads)::double precision / NULLIF(SUM(ps.delta_execution_count), 0) AS avg_reads + FROM top_procs tp + INNER JOIN procedure_stats ps + ON ps.database_name IS NOT DISTINCT FROM tp.database_name + AND ps.schema_name IS NOT DISTINCT FROM tp.schema_name + AND ps.object_name IS NOT DISTINCT FROM tp.object_name + WHERE ps.server_id = $1 + AND ps.collection_time >= $4 AND ps.collection_time <= $5 + AND ps.delta_execution_count > 0 + GROUP BY tp.database_name, tp.schema_name, tp.object_name + ) + SELECT COALESCE(c.database_name, b.database_name) AS database_name, + COALESCE(c.schema_name, b.schema_name) AS schema_name, + COALESCE(c.object_name, b.object_name) AS object_name, + c.exec_count, c.avg_duration_ms, c.avg_cpu_ms, c.avg_reads, + b.exec_count AS baseline_exec_count, + b.avg_duration_ms AS baseline_avg_duration_ms, + b.avg_cpu_ms AS baseline_avg_cpu_ms, + b.avg_reads AS baseline_avg_reads + FROM current_period c + FULL OUTER JOIN baseline_period b + ON COALESCE(c.database_name, '') = COALESCE(b.database_name, '') + AND COALESCE(c.schema_name, '') = COALESCE(b.schema_name, '') + AND COALESCE(c.object_name, '') = COALESCE(b.object_name, '') + """; + + /// <summary>Top-Procedures current-vs-baseline comparison rows (shared .Ui item; delta % + NEW/GONE badges).</summary> + public async Task<List<ProcedureStatsComparisonItem>> GetProcedureStatsComparisonAsync( + int serverId, + DateTime currentStart, DateTime currentEnd, + DateTime baselineStart, DateTime baselineEnd, + IReadOnlyList<string>? databaseNames = null, + CancellationToken cancellationToken = default) + { + var items = new List<ProcedureStatsComparisonItem>(); + + await using var command = _dataSource.CreateCommand(ProcedureStatsComparisonSql); + AddComparisonParameters(command, serverId, currentStart, currentEnd, baselineStart, baselineEnd); + command.Parameters.Add(DatabaseFilterParameter(databaseNames)); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new ProcedureStatsComparisonItem + { + DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0), + SchemaName = reader.IsDBNull(1) ? "" : reader.GetString(1), + ObjectName = reader.IsDBNull(2) ? "" : reader.GetString(2), + ExecutionCount = reader.IsDBNull(3) ? 0 : Convert.ToInt64(reader.GetValue(3)), + AvgDurationMs = reader.IsDBNull(4) ? 0 : Convert.ToDouble(reader.GetValue(4)), + AvgCpuMs = reader.IsDBNull(5) ? 0 : Convert.ToDouble(reader.GetValue(5)), + AvgReads = reader.IsDBNull(6) ? 0 : Convert.ToDouble(reader.GetValue(6)), + BaselineExecutionCount = reader.IsDBNull(7) ? 0 : Convert.ToInt64(reader.GetValue(7)), + BaselineAvgDurationMs = reader.IsDBNull(8) ? 0 : Convert.ToDouble(reader.GetValue(8)), + BaselineAvgCpuMs = reader.IsDBNull(9) ? 0 : Convert.ToDouble(reader.GetValue(9)), + BaselineAvgReads = reader.IsDBNull(10) ? 0 : Convert.ToDouble(reader.GetValue(10)), + }); + } + + return items; + } + + /// <summary> + /// Hourly procedure-stats buckets for the Top-Procedures slicer — Lite's + /// <c>GetProcStatsSlicerDataAsync</c> ported (same bucket shape as the query-stats slicer, so it + /// shares <see cref="ReadQueryStatsSlicerAsync"/>). The distinct-count is over object_name. + /// $1 server_id, $2 window start, $3 window end (naive UTC). + /// </summary> + public const string ProcStatsSlicerSql = """ + SELECT + date_trunc('hour', collection_time) AS bucket, + COUNT(DISTINCT object_name) AS proc_count, + COALESCE(SUM(delta_worker_time), 0) / 1000.0 AS total_cpu_ms, + COALESCE(SUM(delta_elapsed_time), 0) / 1000.0 AS total_elapsed_ms, + COALESCE(SUM(delta_logical_reads), 0) AS total_reads, + COALESCE(SUM(delta_logical_writes), 0) AS total_writes, + COALESCE(SUM(delta_physical_reads), 0) AS total_physical_reads + FROM procedure_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND ($4::text[] IS NULL OR database_name = ANY($4)) + GROUP BY date_trunc('hour', collection_time) + ORDER BY bucket + """; + + /// <summary>Hourly procedure-stats slicer buckets over [<paramref name="startUtc"/>, <paramref name="endUtc"/>].</summary> + public async Task<List<TimeSliceBucket>> GetProcStatsSlicerDataAsync( + int serverId, DateTime startUtc, DateTime endUtc, IReadOnlyList<string>? databaseNames = null, CancellationToken cancellationToken = default) + => await ReadQueryStatsSlicerAsync(ProcStatsSlicerSql, serverId, startUtc, endUtc, databaseNames, cancellationToken); +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryHeatmap.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryHeatmap.cs new file mode 100644 index 000000000..c53f545ca --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryHeatmap.cs @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary>Which per-execution metric the Query Heatmap buckets rows by (Lite's <c>HeatmapMetric</c>).</summary> +public enum HeatmapMetric +{ + Duration, + Cpu, + LogicalReads, + LogicalWrites, + ExecutionCount, +} + +/// <summary>One heatmap cell — the query count in a (5-minute bin × log-magnitude bucket) plus the +/// hottest query (most-executed) in that cell, for the hover tooltip. Viewer copy of Lite's +/// <c>HeatmapCell</c>.</summary> +public sealed class HeatmapCell +{ + public DateTime TimeBucket { get; set; } + public int BucketIndex { get; set; } + public long Count { get; set; } + public string TopQueryHash { get; set; } = ""; + public string TopQueryText { get; set; } = ""; +} + +/// <summary>The assembled heatmap grid the chart renders — intensities[bucket, timeBin], the time +/// axis, the magnitude-bucket labels, and the per-cell detail for hover. Viewer copy of Lite's +/// <c>HeatmapResult</c>.</summary> +public sealed class HeatmapResult +{ + public double[,] Intensities { get; set; } = new double[0, 0]; + public DateTime[] TimeBuckets { get; set; } = Array.Empty<DateTime>(); + public string[] BucketLabels { get; set; } = Array.Empty<string>(); + public HeatmapCell[,] CellDetails { get; set; } = new HeatmapCell[0, 0]; +} + +public sealed partial class ViewerDataService +{ + private static readonly Dictionary<HeatmapMetric, string[]> HeatmapBucketLabels = new() + { + [HeatmapMetric.Duration] = new[] { "0-1ms", "1-10ms", "10-100ms", "100ms-1s", "1-10s", "10-100s", ">100s" }, + [HeatmapMetric.Cpu] = new[] { "0-1ms", "1-10ms", "10-100ms", "100ms-1s", "1-10s", "10-100s", ">100s" }, + [HeatmapMetric.LogicalReads] = new[] { "0-1", "1-10", "10-100", "100-1K", "1K-10K", "10K-100K", ">100K" }, + [HeatmapMetric.LogicalWrites] = new[] { "0-1", "1-10", "10-100", "100-1K", "1K-10K", "10K-100K", ">100K" }, + [HeatmapMetric.ExecutionCount] = new[] { "0-1", "1-10", "10-100", "100-1K", "1K-10K", "10K-100K", ">100K" }, + }; + + /// <summary> + /// The per-execution metric expression Lite's <c>GetMetricColumn</c> uses — byte-identical, and all + /// native Postgres (the <c>/ 1000.0</c> µs→ms scaling, <c>NULLIF(delta_execution_count, 0)</c> + /// per-execution average, <c>CAST(… AS DOUBLE PRECISION)</c>). Internal constants only (no user + /// input), so string-composed into the heatmap SQL is injection-safe. + /// </summary> + internal static string HeatmapMetricExpr(HeatmapMetric metric) => metric switch + { + HeatmapMetric.Duration => "(delta_elapsed_time / 1000.0) / NULLIF(delta_execution_count, 0)", + HeatmapMetric.Cpu => "(delta_worker_time / 1000.0) / NULLIF(delta_execution_count, 0)", + HeatmapMetric.LogicalReads => "CAST(delta_logical_reads AS DOUBLE PRECISION) / NULLIF(delta_execution_count, 0)", + HeatmapMetric.LogicalWrites => "CAST(delta_logical_writes AS DOUBLE PRECISION) / NULLIF(delta_execution_count, 0)", + HeatmapMetric.ExecutionCount => "CAST(delta_execution_count AS DOUBLE PRECISION)", + _ => "(delta_elapsed_time / 1000.0) / NULLIF(delta_execution_count, 0)", + }; + + /// <summary> + /// Builds the Query-Heatmap SQL for one metric — Lite's <c>GetQueryHeatmapAsync</c> query ported + /// from DuckDB to Postgres. Two dialect rewrites the task called out (the rest is verbatim): + /// (1) DuckDB <c>time_bucket(INTERVAL '5 minutes', collection_time)</c> → Postgres + /// <c>date_bin(INTERVAL '5 minutes', collection_time, TIMESTAMP '1970-01-01 00:00:00')</c>. + /// date_bin needs an explicit origin; the Unix epoch aligns 5-minute bins to :00/:05/:10…, + /// matching time_bucket's default epoch alignment (both stride evenly into the hour). + /// (2) DuckDB <c>ARG_MAX(query_hash, delta_execution_count)</c> (the hottest query per cell) has + /// no Postgres equivalent, so it becomes a window: <c>ROW_NUMBER() OVER (PARTITION BY the + /// cell ORDER BY delta_execution_count DESC)</c> filtered to rn = 1, with the cell's + /// <c>COUNT(*) OVER</c> the same partition carried alongside so one pass yields both the + /// count and the top row. Ties resolve arbitrarily, exactly as ARG_MAX did. + /// The magnitude CASE, the <c>delta_execution_count > 0</c> / <c>metric IS NOT NULL</c> filters, + /// the <c>LEFT(query_text, 120)</c> preview, and the $1/$2/$3 placeholders are Lite's verbatim. + /// </summary> + public static string BuildQueryHeatmapSql(HeatmapMetric metric) + { + var metricExpr = HeatmapMetricExpr(metric); + return $""" + WITH base AS ( + SELECT + date_bin(INTERVAL '5 minutes', collection_time, TIMESTAMP '1970-01-01 00:00:00') AS time_bin, + {metricExpr} AS metric_value, + query_hash, + LEFT(query_text, 120) AS query_preview, + delta_execution_count + FROM query_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND ($4::text[] IS NULL OR database_name = ANY($4)) + AND delta_execution_count > 0 + AND {metricExpr} IS NOT NULL + ), + binned AS ( + SELECT + time_bin, + CASE + WHEN metric_value < 1 THEN 0 + WHEN metric_value < 10 THEN 1 + WHEN metric_value < 100 THEN 2 + WHEN metric_value < 1000 THEN 3 + WHEN metric_value < 10000 THEN 4 + WHEN metric_value < 100000 THEN 5 + ELSE 6 + END AS bucket_index, + query_hash, + query_preview, + delta_execution_count + FROM base + ), + ranked AS ( + SELECT + time_bin, + bucket_index, + query_hash, + query_preview, + COUNT(*) OVER (PARTITION BY time_bin, bucket_index) AS query_count, + ROW_NUMBER() OVER (PARTITION BY time_bin, bucket_index ORDER BY delta_execution_count DESC) AS rn + FROM binned + ) + SELECT + time_bin, + bucket_index, + query_count, + query_hash AS top_query_hash, + query_preview AS top_query_text + FROM ranked + WHERE rn = 1 + ORDER BY time_bin, bucket_index + """; + } + + private const int HeatmapBucketCount = 7; + + /// <summary> + /// The Query Heatmap grid for one metric over [<paramref name="startUtc"/>, <paramref name="endUtc"/>]: + /// query counts per (5-minute bin × log-magnitude bucket), with the hottest query per cell for hover. + /// The row read is the PG rewrite (<see cref="BuildQueryHeatmapSql"/>); the grid assembly (distinct + /// time axis, clamp into the 7 buckets, empty <see cref="HeatmapResult"/> on no rows) is Lite's + /// post-processing verbatim. + /// </summary> + public async Task<HeatmapResult> GetQueryHeatmapAsync( + int serverId, HeatmapMetric metric, DateTime startUtc, DateTime endUtc, IReadOnlyList<string>? databaseNames = null, CancellationToken cancellationToken = default) + { + var rawCells = new List<HeatmapCell>(); + + await using var command = _dataSource.CreateCommand(BuildQueryHeatmapSql(metric)); + AddServerWindowParameters(command, serverId, startUtc, endUtc); + command.Parameters.Add(DatabaseFilterParameter(databaseNames)); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rawCells.Add(new HeatmapCell + { + TimeBucket = reader.GetDateTime(0), + BucketIndex = reader.IsDBNull(1) ? 0 : Convert.ToInt32(reader.GetValue(1)), + Count = reader.IsDBNull(2) ? 0 : Convert.ToInt64(reader.GetValue(2)), + TopQueryHash = reader.IsDBNull(3) ? "" : reader.GetString(3), + TopQueryText = reader.IsDBNull(4) ? "" : reader.GetString(4), + }); + } + + if (rawCells.Count == 0) + { + return new HeatmapResult(); + } + + var times = rawCells.Select(c => c.TimeBucket).Distinct().OrderBy(t => t).ToArray(); + var timeIndex = new Dictionary<DateTime, int>(); + for (int i = 0; i < times.Length; i++) + { + timeIndex[times[i]] = i; + } + + var intensities = new double[HeatmapBucketCount, times.Length]; + var cellDetails = new HeatmapCell[HeatmapBucketCount, times.Length]; + + foreach (var cell in rawCells) + { + if (!timeIndex.TryGetValue(cell.TimeBucket, out int col)) + { + continue; + } + + int row = Math.Clamp(cell.BucketIndex, 0, HeatmapBucketCount - 1); + intensities[row, col] = cell.Count; + cellDetails[row, col] = cell; + } + + return new HeatmapResult + { + Intensities = intensities, + TimeBuckets = times, + BucketLabels = HeatmapBucketLabels[metric], + CellDetails = cellDetails, + }; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QuerySnapshots.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QuerySnapshots.cs new file mode 100644 index 000000000..f09552b8e --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QuerySnapshots.cs @@ -0,0 +1,326 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// One Active-Queries snapshot row — the viewer copy of Lite's <c>QuerySnapshotRow</c> +/// (LocalDataService.Blocking.cs). Carries the full column set Lite's two grids bind: the Active Queries +/// grid's columns plus the #1286 memory-grant / tempdb / transaction triage columns Lite's WaitDrillDownWindow +/// adds (all verified present in the query_snapshots store). A captured +/// running request from one collection cycle. <see cref="CollectionTime"/> is the collector's naive-UTC +/// capture time, so <see cref="CollectionTimeLocal"/> converts through <see cref="ViewerTimeHelper.ForDisplay"/> +/// (Lite's <c>ServerTimeHelper.FormatServerTime</c>). <see cref="QueryPlan"/> / <see cref="LiveQueryPlan"/> +/// are the stored estimated / live plan XML the collector now captures inline; <see cref="HasQueryPlan"/> / +/// <see cref="HasLiveQueryPlan"/> gate the grid's Estimated / Actual plan buttons. +/// </summary> +public sealed class ViewerQuerySnapshotRow +{ + public int SessionId { get; set; } + public string DatabaseName { get; set; } = ""; + public string ElapsedTimeFormatted { get; set; } = ""; + public string QueryText { get; set; } = ""; + public string Status { get; set; } = ""; + public int BlockingSessionId { get; set; } + public string WaitType { get; set; } = ""; + public long WaitTimeMs { get; set; } + public string WaitResource { get; set; } = ""; + public long CpuTimeMs { get; set; } + public long TotalElapsedTimeMs { get; set; } + public long Reads { get; set; } + public long Writes { get; set; } + public long LogicalReads { get; set; } + public double GrantedQueryMemoryGb { get; set; } + public string TransactionIsolationLevel { get; set; } = ""; + public int Dop { get; set; } + public int ParallelWorkerCount { get; set; } + public DateTime CollectionTime { get; set; } + public string? QueryPlan { get; set; } + public string? LiveQueryPlan { get; set; } + public string LoginName { get; set; } = ""; + public string HostName { get; set; } = ""; + public string ProgramName { get; set; } = ""; + public int OpenTransactionCount { get; set; } + public decimal PercentComplete { get; set; } + public string QueryHash { get; set; } = ""; + + /* #1286 memory-grant / tempdb / transaction triage columns — collected into query_snapshots (verified + present) and surfaced by Lite's WaitDrillDownWindow grid (not its leaner Active Queries grid). The + Darling WaitDrillDownWindow mirrors that grid, so these bind there. */ + public double RequestedMemoryMb { get; set; } + public double UsedMemoryMb { get; set; } + public double MaxUsedMemoryMb { get; set; } + public double TempdbCurrentMb { get; set; } + public double TempdbAllocationsMb { get; set; } + public double TranLogUsedMb { get; set; } + public DateTime? TranStartTime { get; set; } + public int RequestId { get; set; } + + /* Chain-view metadata (Wait Stats "Show Queries With This Wait" drill-down, LCK_M_* chain branch): + set only when WaitDrillDownWindow walks blocking chains — the transitive blocked-session count and + the "Head SPID X blocking N session(s)" path. Default 0 / empty for every other read. Mirrors Lite's + QuerySnapshotRow.BlockedSessionCount / ChainBlockingPath. */ + public int BlockedSessionCount { get; set; } + public string ChainBlockingPath { get; set; } = ""; + + public bool HasQueryPlan => !string.IsNullOrEmpty(QueryPlan); + public bool HasLiveQueryPlan => !string.IsNullOrEmpty(LiveQueryPlan); + public string CollectionTimeLocal => + CollectionTime == DateTime.MinValue ? "" : ViewerTimeHelper.ForDisplay(CollectionTime).ToString("yyyy-MM-dd HH:mm:ss"); + + /// <summary>The transaction begin time, empty when the request has no open transaction. Mirrors Lite's + /// <c>QuerySnapshotRow.TranStartTimeLocal</c> (raw server-clock <c>FormatServerTime</c>): + /// transaction_begin_time from sys.dm_tran_active_transactions is a SQL-server-local wall-clock time (NOT + /// naive UTC), so it renders raw via <see cref="ViewerDataService.FormatServerClock"/> like the other + /// dm_exec_* times (last_execution_time / cached_time), NOT through the UTC-converting ForDisplay.</summary> + public string TranStartTimeLocal => ViewerDataService.FormatServerClock(TranStartTime); +} + +public sealed partial class ViewerDataService +{ + /* The Active-Queries column list Lite's GetLatestQuerySnapshotsAsync selects (Blocking.cs:131), + shared by the window read and the latest-batch read so both materialize the same row shape. */ + private const string QuerySnapshotColumns = """ + session_id, + database_name, + elapsed_time_formatted, + query_text, + status, + blocking_session_id, + wait_type, + wait_time_ms, + wait_resource, + cpu_time_ms, + total_elapsed_time_ms, + reads, + writes, + logical_reads, + granted_query_memory_gb, + transaction_isolation_level, + dop, + parallel_worker_count, + query_plan, + live_query_plan, + collection_time, + login_name, + host_name, + program_name, + open_transaction_count, + percent_complete, + query_hash, + requested_memory_mb, + used_memory_mb, + max_used_memory_mb, + tempdb_current_mb, + tempdb_allocations_mb, + tran_log_used_mb, + tran_start_time, + request_id + """; + + /// <summary> + /// The Active-Queries grid read — Lite's <c>GetLatestQuerySnapshotsAsync</c> (v_query_snapshots, + /// ORDER BY collection_time DESC, cpu_time_ms DESC, WAITFOR shells dropped) against the base + /// <c>query_snapshots</c> table. $1 server_id, $2 window start, $3 window end (naive UTC). + /// </summary> + public static readonly string LatestQuerySnapshotsSql = $""" + SELECT + {QuerySnapshotColumns} + FROM query_snapshots + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND ($4::text[] IS NULL OR database_name = ANY($4)) + AND query_text NOT LIKE 'WAITFOR%' + ORDER BY collection_time DESC, cpu_time_ms DESC + """; + + /// <summary> + /// The "Latest Snapshot" button read — the newest STORED capture only (all rows sharing + /// MAX(collection_time) for the server). Erik's decision: the viewer reads only what the collector + /// stored, so this replaces Lite's live-server query with the newest persisted snapshot batch — + /// the closest stored analogue of Lite's point-in-time "currently running" view. $1 server_id. + /// </summary> + public static readonly string LatestQuerySnapshotBatchSql = $""" + SELECT + {QuerySnapshotColumns} + FROM query_snapshots + WHERE server_id = $1 + AND collection_time = (SELECT MAX(collection_time) FROM query_snapshots WHERE server_id = $1) + AND ($2::text[] IS NULL OR database_name = ANY($2)) + AND query_text NOT LIKE 'WAITFOR%' + ORDER BY cpu_time_ms DESC + """; + + /// <summary>Active-Queries grid rows over [<paramref name="startUtc"/>, <paramref name="endUtc"/>].</summary> + public async Task<List<ViewerQuerySnapshotRow>> GetLatestQuerySnapshotsAsync( + int serverId, DateTime startUtc, DateTime endUtc, IReadOnlyList<string>? databaseNames = null, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(LatestQuerySnapshotsSql); + AddServerWindowParameters(command, serverId, startUtc, endUtc); + command.Parameters.Add(DatabaseFilterParameter(databaseNames)); + return await ReadQuerySnapshotsAsync(command, cancellationToken); + } + + /// <summary> + /// The "Show Queries With This Wait" drill-down read (Wait Stats chart right-click) — Lite's + /// <c>GetQuerySnapshotsByWaitTypeAsync</c>: the captured running-query snapshots whose <c>wait_type</c> + /// exactly matches, over the drill window. Same row shape / column list as the Active Queries grid. + /// $1 server_id, $2 window start, $3 window end (naive UTC), $4 wait_type. + /// </summary> + public static readonly string QuerySnapshotsByWaitTypeSql = $""" + SELECT + {QuerySnapshotColumns} + FROM query_snapshots + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND ($5::text[] IS NULL OR database_name = ANY($5)) + AND wait_type = $4 + AND query_text NOT LIKE 'WAITFOR%' + ORDER BY collection_time DESC, cpu_time_ms DESC + """; + + /// <summary>Active-Queries snapshot rows filtered to one <paramref name="waitType"/> over the window.</summary> + public async Task<List<ViewerQuerySnapshotRow>> GetQuerySnapshotsByWaitTypeAsync( + int serverId, string waitType, DateTime startUtc, DateTime endUtc, IReadOnlyList<string>? databaseNames = null, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(QuerySnapshotsByWaitTypeSql); + AddServerWindowParameters(command, serverId, startUtc, endUtc); + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = waitType }); + command.Parameters.Add(DatabaseFilterParameter(databaseNames)); + return await ReadQuerySnapshotsAsync(command, cancellationToken); + } + + /// <summary> + /// The newest stored snapshot batch (rows at MAX(collection_time)) plus that batch's time (null when + /// no snapshots are stored) — backs the "Latest Snapshot" button. The batch time is taken from the + /// returned rows (all share one collection_time). + /// </summary> + public async Task<(DateTime? BatchTime, List<ViewerQuerySnapshotRow> Rows)> GetLatestQuerySnapshotBatchAsync( + int serverId, IReadOnlyList<string>? databaseNames = null, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(LatestQuerySnapshotBatchSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(DatabaseFilterParameter(databaseNames)); + var rows = await ReadQuerySnapshotsAsync(command, cancellationToken); + DateTime? batchTime = rows.Count > 0 ? rows[0].CollectionTime : null; + return (batchTime, rows); + } + + private static async Task<List<ViewerQuerySnapshotRow>> ReadQuerySnapshotsAsync( + NpgsqlCommand command, CancellationToken cancellationToken) + { + var rows = new List<ViewerQuerySnapshotRow>(); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new ViewerQuerySnapshotRow + { + SessionId = reader.IsDBNull(0) ? 0 : reader.GetInt32(0), + DatabaseName = reader.IsDBNull(1) ? "" : reader.GetString(1), + ElapsedTimeFormatted = reader.IsDBNull(2) ? "" : reader.GetString(2), + QueryText = reader.IsDBNull(3) ? "" : reader.GetString(3), + Status = reader.IsDBNull(4) ? "" : reader.GetString(4), + BlockingSessionId = reader.IsDBNull(5) ? 0 : reader.GetInt32(5), + WaitType = reader.IsDBNull(6) ? "" : reader.GetString(6), + WaitTimeMs = reader.IsDBNull(7) ? 0 : reader.GetInt64(7), + WaitResource = reader.IsDBNull(8) ? "" : reader.GetString(8), + CpuTimeMs = reader.IsDBNull(9) ? 0 : reader.GetInt64(9), + TotalElapsedTimeMs = reader.IsDBNull(10) ? 0 : reader.GetInt64(10), + Reads = reader.IsDBNull(11) ? 0 : reader.GetInt64(11), + Writes = reader.IsDBNull(12) ? 0 : reader.GetInt64(12), + LogicalReads = reader.IsDBNull(13) ? 0 : reader.GetInt64(13), + GrantedQueryMemoryGb = reader.IsDBNull(14) ? 0 : Convert.ToDouble(reader.GetValue(14)), + TransactionIsolationLevel = reader.IsDBNull(15) ? "" : reader.GetString(15), + Dop = reader.IsDBNull(16) ? 0 : reader.GetInt32(16), + ParallelWorkerCount = reader.IsDBNull(17) ? 0 : reader.GetInt32(17), + QueryPlan = reader.IsDBNull(18) ? null : reader.GetString(18), + LiveQueryPlan = reader.IsDBNull(19) ? null : reader.GetString(19), + CollectionTime = reader.IsDBNull(20) ? DateTime.MinValue : reader.GetDateTime(20), + LoginName = reader.IsDBNull(21) ? "" : reader.GetString(21), + HostName = reader.IsDBNull(22) ? "" : reader.GetString(22), + ProgramName = reader.IsDBNull(23) ? "" : reader.GetString(23), + OpenTransactionCount = reader.IsDBNull(24) ? 0 : reader.GetInt32(24), + PercentComplete = reader.IsDBNull(25) ? 0m : Convert.ToDecimal(reader.GetValue(25)), + QueryHash = reader.IsDBNull(26) ? "" : reader.GetString(26), + RequestedMemoryMb = reader.IsDBNull(27) ? 0 : Convert.ToDouble(reader.GetValue(27)), + UsedMemoryMb = reader.IsDBNull(28) ? 0 : Convert.ToDouble(reader.GetValue(28)), + MaxUsedMemoryMb = reader.IsDBNull(29) ? 0 : Convert.ToDouble(reader.GetValue(29)), + TempdbCurrentMb = reader.IsDBNull(30) ? 0 : Convert.ToDouble(reader.GetValue(30)), + TempdbAllocationsMb = reader.IsDBNull(31) ? 0 : Convert.ToDouble(reader.GetValue(31)), + TranLogUsedMb = reader.IsDBNull(32) ? 0 : Convert.ToDouble(reader.GetValue(32)), + TranStartTime = reader.IsDBNull(33) ? null : reader.GetDateTime(33), + RequestId = reader.IsDBNull(34) ? 0 : reader.GetInt32(34), + }); + } + + return rows; + } + + /// <summary> + /// Hourly Active-Queries buckets for the slicer — Lite's <c>GetActiveQuerySlicerDataAsync</c> + /// (Blocking.cs:72) ported. Buckets by <c>date_trunc('hour', collection_time)</c>; the default + /// metric is session count (Value = SessionCount), matching Lite's "Sessions" slicer label. + /// $1 server_id, $2 window start, $3 window end (naive UTC). + /// </summary> + public const string ActiveQuerySlicerSql = """ + SELECT + date_trunc('hour', collection_time) AS bucket, + COUNT(*) AS session_count, + COALESCE(SUM(cpu_time_ms), 0) AS total_cpu, + COALESCE(SUM(total_elapsed_time_ms), 0) AS total_elapsed, + COALESCE(SUM(reads), 0) AS total_reads, + COALESCE(SUM(logical_reads), 0) AS total_logical_reads, + COALESCE(SUM(writes), 0) AS total_writes + FROM query_snapshots + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND ($4::text[] IS NULL OR database_name = ANY($4)) + GROUP BY date_trunc('hour', collection_time) + ORDER BY bucket + """; + + /// <summary>Hourly Active-Queries slicer buckets over the window (Value = session count).</summary> + public async Task<List<TimeSliceBucket>> GetActiveQuerySlicerDataAsync( + int serverId, DateTime startUtc, DateTime endUtc, IReadOnlyList<string>? databaseNames = null, CancellationToken cancellationToken = default) + { + var items = new List<TimeSliceBucket>(); + + await using var command = _dataSource.CreateCommand(ActiveQuerySlicerSql); + AddServerWindowParameters(command, serverId, startUtc, endUtc); + command.Parameters.Add(DatabaseFilterParameter(databaseNames)); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var sessions = reader.IsDBNull(1) ? 0 : Convert.ToInt64(reader.GetValue(1)); + items.Add(new TimeSliceBucket + { + BucketTime = reader.GetDateTime(0), + SessionCount = sessions, + TotalCpu = reader.IsDBNull(2) ? 0 : Convert.ToDouble(reader.GetValue(2)), + TotalElapsed = reader.IsDBNull(3) ? 0 : Convert.ToDouble(reader.GetValue(3)), + TotalReads = reader.IsDBNull(4) ? 0 : Convert.ToDouble(reader.GetValue(4)), + TotalLogicalReads = reader.IsDBNull(5) ? 0 : Convert.ToDouble(reader.GetValue(5)), + TotalWrites = reader.IsDBNull(6) ? 0 : Convert.ToDouble(reader.GetValue(6)), + Value = sessions, + }); + } + + return items; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStats.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStats.cs new file mode 100644 index 000000000..6578e0baa --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStats.cs @@ -0,0 +1,622 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// One Top-Queries-by-Duration grid row — the viewer copy of Lite's <c>QueryStatsRow</c> +/// (LocalDataService.QueryStats.cs). A (database, query_hash) group's summed deltas + min/max +/// spreads over the window, with the latest captured query text. Numeric members stay in the +/// store's units (microseconds for CPU/duration, KB for grants); the display properties convert, +/// mirroring Lite's grid. <see cref="LastExecutionTime"/>/<see cref="CreationTime"/> are the SQL +/// SERVER's local wall clock (dm_exec_query_stats), NOT UTC — shown raw (no naive-UTC-to-local +/// conversion, matching the absorbed top-50 grid this replaces). <see cref="HasQueryPlan"/> is a cheap +/// presence flag from the read (bool_or over the group) that gates the grid's Query Plan column; the full +/// query_plan_xml is fetched on demand by <see cref="ViewerDataService.GetQueryStatsPlanXmlAsync"/>, so a +/// multi-KB plan never rides in every grid row (Lite's HasQueryPlan gating pattern, sourced from the store). +/// </summary> +public sealed class ViewerQueryStatsRow +{ + public string DatabaseName { get; set; } = ""; + public string QueryHash { get; set; } = ""; + public DateTime? LastExecutionTime { get; set; } + public DateTime? CreationTime { get; set; } + public string LastExecutionTimeLocal => ViewerDataService.FormatServerClock(LastExecutionTime); + public string CreationTimeLocal => ViewerDataService.FormatServerClock(CreationTime); + public long TotalExecutions { get; set; } + public long TotalCpuUs { get; set; } + public long TotalElapsedUs { get; set; } + public long TotalLogicalReads { get; set; } + public long TotalRows { get; set; } + public long TotalLogicalWrites { get; set; } + public long TotalPhysicalReads { get; set; } + public long TotalSpills { get; set; } + public int MinDop { get; set; } + public int MaxDop { get; set; } + public long MinCpuUs { get; set; } + public long MaxCpuUs { get; set; } + public long MinElapsedUs { get; set; } + public long MaxElapsedUs { get; set; } + public long MinPhysicalReads { get; set; } + public long MaxPhysicalReads { get; set; } + public long MinRows { get; set; } + public long MaxRows { get; set; } + public long MinGrantKb { get; set; } + public long MaxGrantKb { get; set; } + public long MinUsedGrantKb { get; set; } + public long MaxUsedGrantKb { get; set; } + public long MinIdealGrantKb { get; set; } + public long MaxIdealGrantKb { get; set; } + public long MinReservedThreads { get; set; } + public long MaxReservedThreads { get; set; } + public long MinUsedThreads { get; set; } + public long MaxUsedThreads { get; set; } + public long TotalClrUs { get; set; } + public long MinSpills { get; set; } + public long MaxSpills { get; set; } + public long PlanGenerationNum { get; set; } + public double WorkerTimePerSecond { get; set; } + public string QueryPlanHash { get; set; } = ""; + public string SqlHandle { get; set; } = ""; + public string PlanHandle { get; set; } = ""; + public string QueryText { get; set; } = ""; + /// <summary>True when the collector captured a query_plan_xml for this (database, query_hash) — + /// gates the grid's Query Plan column (the full plan is fetched on demand, not carried here).</summary> + public bool HasQueryPlan { get; set; } + + /* #1568 module attribution (read-time stitch of query_stats.sql_handle -> procedure_stats): the + matched procedure/function/trigger's identity, or empty when this statement is ad hoc. */ + public string ModuleObjectName { get; set; } = ""; + public string ModuleSchemaName { get; set; } = ""; + public string ModuleDatabaseName { get; set; } = ""; + + /// <summary>Grid "Module" column: <c>database.schema.object</c> when this statement's sql_handle matched a + /// cached module (procedure/function/trigger), else the literal <c>ad hoc</c> (#1568).</summary> + public string ModuleName => + string.IsNullOrEmpty(ModuleObjectName) + ? "ad hoc" + : $"{ModuleDatabaseName}.{ModuleSchemaName}.{ModuleObjectName}"; + + /// <summary>True when this row carries a plan_handle — gates the grid's "Fetch Live Plan" context item (the + /// live-cache fetch is keyed on plan_handle, distinct from the stored query_plan_xml "View Plan" opens).</summary> + public bool HasLivePlanHandle => !string.IsNullOrEmpty(PlanHandle); + + /// <summary>True when this row can have its ACTUAL plan captured — it carries the query text plus the + /// stored-row key (query_hash) the service re-executes by. Gates the grid's "Get Actual Plan" context item; + /// only Top Queries rows define it, so the Query Store / Top Procedures rows that SHARE this menu fall back + /// to disabled (their FallbackValue). Mirrors Lite, whose "Get Actual Plan" lives on the query-stats grid.</summary> + public bool CanGetActualPlan => !string.IsNullOrEmpty(QueryText) && !string.IsNullOrEmpty(QueryHash); + public double TotalCpuMs => TotalCpuUs / 1000.0; + public double TotalElapsedMs => TotalElapsedUs / 1000.0; + public double AvgCpuMs => TotalExecutions > 0 ? TotalCpuMs / TotalExecutions : 0; + public double AvgElapsedMs => TotalExecutions > 0 ? TotalElapsedMs / TotalExecutions : 0; + public double AvgReads => TotalExecutions > 0 ? (double)TotalLogicalReads / TotalExecutions : 0; + public double MinCpuMs => MinCpuUs / 1000.0; + public double MaxCpuMs => MaxCpuUs / 1000.0; + public double MinElapsedMs => MinElapsedUs / 1000.0; + public double MaxElapsedMs => MaxElapsedUs / 1000.0; + // total_clr_time is stored in microseconds (like worker/elapsed time) + public double TotalClrMs => TotalClrUs / 1000.0; +} + +public sealed partial class ViewerDataService +{ + /// <summary> + /// The Top-Queries-by-Duration read — Lite's <c>GetTopQueriesByCpuAsync</c> (despite the name it + /// ranks by summed <c>delta_elapsed_time</c>, matching the grid's default TotalElapsedMs-descending + /// sort) ported to Postgres against the base <c>query_stats</c> table. Group by + /// (database_name, query_hash) over the window, sum the deltas + carry the min/max spreads, fetch + /// each group's latest non-null query text via a LATERAL join, drop WAITFOR shells (over-fetch by 5 + /// exactly like Lite's <c>LIMIT top + 5</c> → filter → <c>LIMIT top</c>), and cap at top. + /// Viewer deviations from Lite: (1) Postgres <c>SUM(bigint)</c> is numeric, so each summed aggregate + /// is CAST back to bigint for the typed GetInt64 readers (MIN/MAX of bigint stay bigint, no cast); + /// (2) Lite's server-local <c>last_execution_time</c> staleness filter and its utcOffsetMinutes + /// parameter are dropped (the viewer has no per-server UTC offset; the delta HAVING already excludes + /// plans that never ran in the window); (3) the LATERAL fetches only query_text; the collected + /// query_plan_xml is not carried per row (it can be multi-KB) — instead bool_or(query_plan_xml IS NOT + /// NULL) rides back as has_query_plan to gate the grid's Query Plan column, and the plan itself is + /// fetched on demand by <see cref="GetQueryStatsPlanXmlAsync"/>. + /// $1 server_id, $2 window start, $3 window end (naive UTC), $4 top. + /// </summary> + public const string TopQueriesSql = """ + WITH ranked AS ( + SELECT + database_name, + query_hash, + MAX(last_execution_time) AS last_execution_time, + MAX(creation_time) AS creation_time, + CAST(SUM(delta_execution_count) AS bigint) AS total_executions, + CAST(SUM(delta_worker_time) AS bigint) AS total_cpu_us, + CAST(SUM(delta_elapsed_time) AS bigint) AS total_elapsed_us, + CAST(SUM(delta_logical_reads) AS bigint) AS total_reads, + CAST(SUM(delta_rows) AS bigint) AS total_rows, + CAST(SUM(delta_logical_writes) AS bigint) AS total_writes, + CAST(SUM(delta_physical_reads) AS bigint) AS total_physical_reads, + CAST(SUM(delta_spills) AS bigint) AS total_spills, + MIN(min_dop) AS min_dop, + MAX(max_dop) AS max_dop, + MIN(min_worker_time) AS min_worker_time, + MAX(max_worker_time) AS max_worker_time, + MIN(min_elapsed_time) AS min_elapsed_time, + MAX(max_elapsed_time) AS max_elapsed_time, + MIN(min_physical_reads) AS min_physical_reads, + MAX(max_physical_reads) AS max_physical_reads, + MIN(min_rows) AS min_rows, + MAX(max_rows) AS max_rows, + MIN(min_grant_kb) AS min_grant_kb, + MAX(max_grant_kb) AS max_grant_kb, + MIN(min_spills) AS min_spills, + MAX(max_spills) AS max_spills, + MAX(query_plan_hash) AS query_plan_hash, + MAX(sql_handle) AS sql_handle, + MAX(plan_handle) AS plan_handle, + MIN(min_used_grant_kb) AS min_used_grant_kb, + MAX(max_used_grant_kb) AS max_used_grant_kb, + MIN(min_ideal_grant_kb) AS min_ideal_grant_kb, + MAX(max_ideal_grant_kb) AS max_ideal_grant_kb, + MIN(min_reserved_threads) AS min_reserved_threads, + MAX(max_reserved_threads) AS max_reserved_threads, + MIN(min_used_threads) AS min_used_threads, + MAX(max_used_threads) AS max_used_threads, + MAX(total_clr_time) AS total_clr_time, + MAX(plan_generation_num) AS plan_generation_num, + MAX(CAST(delta_worker_time AS double precision) / NULLIF(sample_interval_seconds, 0) / 1000.0) AS worker_time_per_second, + bool_or(query_plan_xml IS NOT NULL) AS has_query_plan + FROM query_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND ($5::text[] IS NULL OR database_name = ANY($5)) + GROUP BY database_name, query_hash + HAVING SUM(delta_execution_count) > 0 OR SUM(delta_elapsed_time) > 0 + ORDER BY SUM(delta_elapsed_time) DESC + LIMIT $4 + 5 + ), + module AS ( + /* #1568 module attribution: one procedure_stats identity per sql_handle (latest + collection_time wins) so a statement whose sql_handle matches a cached + procedure/function/trigger inherits its db.schema.object and the query_stats row never + fans out. Both stores persist the SAME normalized CONVERT(varchar(130), ..., 1) handle + text, so the join key lines up; unmatched -> ad hoc. */ + SELECT + sql_handle, + object_name, + schema_name, + database_name + FROM + ( + SELECT + sql_handle, + object_name, + schema_name, + database_name, + ROW_NUMBER() OVER (PARTITION BY sql_handle ORDER BY collection_time DESC) AS rn + FROM procedure_stats + WHERE server_id = $1 + AND sql_handle IS NOT NULL + AND sql_handle <> '' + ) AS ranked_modules + WHERE rn = 1 + ) + SELECT + r.database_name, + r.query_hash, + r.last_execution_time, + r.creation_time, + r.total_executions, + r.total_cpu_us, + r.total_elapsed_us, + r.total_reads, + r.total_rows, + r.total_writes, + r.total_physical_reads, + r.total_spills, + r.min_dop, + r.max_dop, + r.min_worker_time, + r.max_worker_time, + r.min_elapsed_time, + r.max_elapsed_time, + r.min_physical_reads, + r.max_physical_reads, + r.min_rows, + r.max_rows, + r.min_grant_kb, + r.max_grant_kb, + r.min_spills, + r.max_spills, + r.query_plan_hash, + r.sql_handle, + r.plan_handle, + r.min_used_grant_kb, + r.max_used_grant_kb, + r.min_ideal_grant_kb, + r.max_ideal_grant_kb, + r.min_reserved_threads, + r.max_reserved_threads, + r.min_used_threads, + r.max_used_threads, + r.total_clr_time, + r.plan_generation_num, + r.worker_time_per_second, + t.query_text, + r.has_query_plan, + m.object_name AS module_object_name, + m.schema_name AS module_schema_name, + m.database_name AS module_database_name + FROM ranked AS r + LEFT JOIN LATERAL ( + SELECT query_text + FROM query_stats + WHERE server_id = $1 + AND query_hash = r.query_hash + AND database_name = r.database_name + AND query_text IS NOT NULL + ORDER BY collection_time DESC + LIMIT 1 + ) AS t ON TRUE + LEFT JOIN module AS m ON m.sql_handle = r.sql_handle + WHERE t.query_text IS NULL OR t.query_text NOT LIKE 'WAITFOR%' + ORDER BY r.total_elapsed_us DESC + LIMIT $4 + """; + + /// <summary>Grid cell preview cap for query/SQL text (single line).</summary> + public const int CellTextCap = 160; + + /// <summary>Tooltip cap for query/SQL text — enough to read, not a 40 KB balloon.</summary> + public const int TooltipTextCap = 4000; + + /// <summary>Default page size for the Top-Queries / Top-Procedures / Query-Store grids (Lite's top-50).</summary> + public const int TopQueriesPageSize = 50; + + /// <summary> + /// The top query-stats groups for one server over [<paramref name="startUtc"/>, + /// <paramref name="endUtc"/>], pre-sorted by total elapsed time descending (the grid's default sort). + /// </summary> + public async Task<List<ViewerQueryStatsRow>> GetTopQueriesByCpuAsync( + int serverId, DateTime startUtc, DateTime endUtc, int top = TopQueriesPageSize, IReadOnlyList<string>? databaseNames = null, CancellationToken cancellationToken = default) + { + var rows = new List<ViewerQueryStatsRow>(); + + await using var command = _dataSource.CreateCommand(TopQueriesSql); + AddServerWindowParameters(command, serverId, startUtc, endUtc); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = top }); + command.Parameters.Add(DatabaseFilterParameter(databaseNames)); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new ViewerQueryStatsRow + { + DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0), + QueryHash = reader.IsDBNull(1) ? "" : reader.GetString(1), + LastExecutionTime = reader.IsDBNull(2) ? null : reader.GetDateTime(2), + CreationTime = reader.IsDBNull(3) ? null : reader.GetDateTime(3), + TotalExecutions = reader.IsDBNull(4) ? 0 : reader.GetInt64(4), + TotalCpuUs = reader.IsDBNull(5) ? 0 : reader.GetInt64(5), + TotalElapsedUs = reader.IsDBNull(6) ? 0 : reader.GetInt64(6), + TotalLogicalReads = reader.IsDBNull(7) ? 0 : reader.GetInt64(7), + TotalRows = reader.IsDBNull(8) ? 0 : reader.GetInt64(8), + TotalLogicalWrites = reader.IsDBNull(9) ? 0 : reader.GetInt64(9), + TotalPhysicalReads = reader.IsDBNull(10) ? 0 : reader.GetInt64(10), + TotalSpills = reader.IsDBNull(11) ? 0 : reader.GetInt64(11), + MinDop = reader.IsDBNull(12) ? 0 : (int)reader.GetInt64(12), + MaxDop = reader.IsDBNull(13) ? 0 : (int)reader.GetInt64(13), + MinCpuUs = reader.IsDBNull(14) ? 0 : reader.GetInt64(14), + MaxCpuUs = reader.IsDBNull(15) ? 0 : reader.GetInt64(15), + MinElapsedUs = reader.IsDBNull(16) ? 0 : reader.GetInt64(16), + MaxElapsedUs = reader.IsDBNull(17) ? 0 : reader.GetInt64(17), + MinPhysicalReads = reader.IsDBNull(18) ? 0 : reader.GetInt64(18), + MaxPhysicalReads = reader.IsDBNull(19) ? 0 : reader.GetInt64(19), + MinRows = reader.IsDBNull(20) ? 0 : reader.GetInt64(20), + MaxRows = reader.IsDBNull(21) ? 0 : reader.GetInt64(21), + MinGrantKb = reader.IsDBNull(22) ? 0 : reader.GetInt64(22), + MaxGrantKb = reader.IsDBNull(23) ? 0 : reader.GetInt64(23), + MinSpills = reader.IsDBNull(24) ? 0 : reader.GetInt64(24), + MaxSpills = reader.IsDBNull(25) ? 0 : reader.GetInt64(25), + QueryPlanHash = reader.IsDBNull(26) ? "" : reader.GetString(26), + SqlHandle = reader.IsDBNull(27) ? "" : reader.GetString(27), + PlanHandle = reader.IsDBNull(28) ? "" : reader.GetString(28), + MinUsedGrantKb = reader.IsDBNull(29) ? 0 : reader.GetInt64(29), + MaxUsedGrantKb = reader.IsDBNull(30) ? 0 : reader.GetInt64(30), + MinIdealGrantKb = reader.IsDBNull(31) ? 0 : reader.GetInt64(31), + MaxIdealGrantKb = reader.IsDBNull(32) ? 0 : reader.GetInt64(32), + MinReservedThreads = reader.IsDBNull(33) ? 0 : reader.GetInt64(33), + MaxReservedThreads = reader.IsDBNull(34) ? 0 : reader.GetInt64(34), + MinUsedThreads = reader.IsDBNull(35) ? 0 : reader.GetInt64(35), + MaxUsedThreads = reader.IsDBNull(36) ? 0 : reader.GetInt64(36), + TotalClrUs = reader.IsDBNull(37) ? 0 : reader.GetInt64(37), + PlanGenerationNum = reader.IsDBNull(38) ? 0 : reader.GetInt64(38), + WorkerTimePerSecond = reader.IsDBNull(39) ? 0 : reader.GetDouble(39), + QueryText = reader.IsDBNull(40) ? "" : reader.GetString(40), + HasQueryPlan = !reader.IsDBNull(41) && reader.GetBoolean(41), + ModuleObjectName = reader.IsDBNull(42) ? "" : reader.GetString(42), + ModuleSchemaName = reader.IsDBNull(43) ? "" : reader.GetString(43), + ModuleDatabaseName = reader.IsDBNull(44) ? "" : reader.GetString(44), + }); + } + + return rows; + } + + /// <summary> + /// Top-Queries comparison — Lite's <c>GetQueryStatsComparisonAsync</c> ported. Unions the top-100 + /// (by summed executions) hashes of the current and baseline windows, aggregates each period's + /// per-execution averages, and FULL OUTER JOINs the two so NEW / GONE queries surface. Returns the + /// shared <see cref="QueryStatsComparisonItem"/> the .Ui comparison grid binds (delta % + badges). + /// PG dialect deviations from Lite's DuckDB: <c>::double precision</c> casts on the summed bigints + /// before the per-execution division; the CTE INNER JOINs keep Lite's null-safe + /// <c>IS NOT DISTINCT FROM</c>, but the outer FULL JOIN uses <c>COALESCE(key,'')</c> equality — + /// Postgres only FULL-JOINs on merge/hash-joinable conditions and <c>IS NOT DISTINCT FROM</c> is + /// neither (query_hash / database_name are never the empty string, so the sentinel is collision-free). + /// $1 server_id, $2/$3 current window, $4/$5 baseline window (naive UTC). + /// </summary> + public const string QueryStatsComparisonSql = """ + WITH top_current AS ( + SELECT query_hash, database_name + FROM query_stats + WHERE server_id = $1 + AND collection_time >= $2 AND collection_time <= $3 + AND ($6::text[] IS NULL OR database_name = ANY($6)) + AND delta_execution_count > 0 + GROUP BY query_hash, database_name + ORDER BY SUM(delta_execution_count) DESC + LIMIT 100 + ), + top_baseline AS ( + SELECT query_hash, database_name + FROM query_stats + WHERE server_id = $1 + AND collection_time >= $4 AND collection_time <= $5 + AND ($6::text[] IS NULL OR database_name = ANY($6)) + AND delta_execution_count > 0 + GROUP BY query_hash, database_name + ORDER BY SUM(delta_execution_count) DESC + LIMIT 100 + ), + top_hashes AS ( + SELECT DISTINCT query_hash, database_name + FROM ( + SELECT * FROM top_current + UNION ALL + SELECT * FROM top_baseline + ) AS combined + ), + current_period AS ( + SELECT th.database_name, th.query_hash, + SUM(qs.delta_execution_count) AS exec_count, + SUM(qs.delta_elapsed_time)::double precision / NULLIF(SUM(qs.delta_execution_count), 0) / 1000.0 AS avg_duration_ms, + SUM(qs.delta_worker_time)::double precision / NULLIF(SUM(qs.delta_execution_count), 0) / 1000.0 AS avg_cpu_ms, + SUM(qs.delta_physical_reads)::double precision / NULLIF(SUM(qs.delta_execution_count), 0) AS avg_reads, + MAX(qs.query_text) AS query_text + FROM top_hashes th + INNER JOIN query_stats qs + ON qs.query_hash IS NOT DISTINCT FROM th.query_hash + AND qs.database_name IS NOT DISTINCT FROM th.database_name + WHERE qs.server_id = $1 + AND qs.collection_time >= $2 AND qs.collection_time <= $3 + AND qs.delta_execution_count > 0 + GROUP BY th.database_name, th.query_hash + ), + baseline_period AS ( + SELECT th.database_name, th.query_hash, + SUM(qs.delta_execution_count) AS exec_count, + SUM(qs.delta_elapsed_time)::double precision / NULLIF(SUM(qs.delta_execution_count), 0) / 1000.0 AS avg_duration_ms, + SUM(qs.delta_worker_time)::double precision / NULLIF(SUM(qs.delta_execution_count), 0) / 1000.0 AS avg_cpu_ms, + SUM(qs.delta_physical_reads)::double precision / NULLIF(SUM(qs.delta_execution_count), 0) AS avg_reads, + MAX(qs.query_text) AS query_text + FROM top_hashes th + INNER JOIN query_stats qs + ON qs.query_hash IS NOT DISTINCT FROM th.query_hash + AND qs.database_name IS NOT DISTINCT FROM th.database_name + WHERE qs.server_id = $1 + AND qs.collection_time >= $4 AND qs.collection_time <= $5 + AND qs.delta_execution_count > 0 + GROUP BY th.database_name, th.query_hash + ) + SELECT COALESCE(c.database_name, b.database_name) AS database_name, + COALESCE(c.query_hash, b.query_hash) AS query_hash, + COALESCE(c.query_text, b.query_text) AS query_text, + c.exec_count, c.avg_duration_ms, c.avg_cpu_ms, c.avg_reads, + b.exec_count AS baseline_exec_count, + b.avg_duration_ms AS baseline_avg_duration_ms, + b.avg_cpu_ms AS baseline_avg_cpu_ms, + b.avg_reads AS baseline_avg_reads + FROM current_period c + FULL OUTER JOIN baseline_period b + ON COALESCE(c.database_name, '') = COALESCE(b.database_name, '') + AND COALESCE(c.query_hash, '') = COALESCE(b.query_hash, '') + """; + + /// <summary>Top-Queries current-vs-baseline comparison rows (shared .Ui item; delta % + NEW/GONE badges).</summary> + public async Task<List<QueryStatsComparisonItem>> GetQueryStatsComparisonAsync( + int serverId, + DateTime currentStart, DateTime currentEnd, + DateTime baselineStart, DateTime baselineEnd, + IReadOnlyList<string>? databaseNames = null, + CancellationToken cancellationToken = default) + { + var items = new List<QueryStatsComparisonItem>(); + + await using var command = _dataSource.CreateCommand(QueryStatsComparisonSql); + AddComparisonParameters(command, serverId, currentStart, currentEnd, baselineStart, baselineEnd); + command.Parameters.Add(DatabaseFilterParameter(databaseNames)); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new QueryStatsComparisonItem + { + DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0), + QueryHash = reader.IsDBNull(1) ? "" : reader.GetString(1), + QueryText = reader.IsDBNull(2) ? "" : reader.GetString(2), + ExecutionCount = reader.IsDBNull(3) ? 0 : Convert.ToInt64(reader.GetValue(3)), + AvgDurationMs = reader.IsDBNull(4) ? 0 : Convert.ToDouble(reader.GetValue(4)), + AvgCpuMs = reader.IsDBNull(5) ? 0 : Convert.ToDouble(reader.GetValue(5)), + AvgReads = reader.IsDBNull(6) ? 0 : Convert.ToDouble(reader.GetValue(6)), + BaselineExecutionCount = reader.IsDBNull(7) ? 0 : Convert.ToInt64(reader.GetValue(7)), + BaselineAvgDurationMs = reader.IsDBNull(8) ? 0 : Convert.ToDouble(reader.GetValue(8)), + BaselineAvgCpuMs = reader.IsDBNull(9) ? 0 : Convert.ToDouble(reader.GetValue(9)), + BaselineAvgReads = reader.IsDBNull(10) ? 0 : Convert.ToDouble(reader.GetValue(10)), + }); + } + + return items; + } + + /// <summary> + /// Hourly query-stats buckets for the Top-Queries slicer — Lite's <c>GetQueryStatsSlicerDataAsync</c> + /// ported. Buckets by <c>date_trunc('hour', collection_time)</c> into the shared + /// <see cref="TimeSliceBucket"/>; the columns feed the slicer's sort-driven metric overlay + /// (CPU / duration / reads / writes / physical reads). PG dialect: <c>COUNT(DISTINCT …)</c> is bigint + /// and <c>SUM(…)/1000.0</c> is numeric, so the reads go through <c>Convert</c> tolerantly. + /// $1 server_id, $2 window start, $3 window end (naive UTC). + /// </summary> + public const string QueryStatsSlicerSql = """ + SELECT + date_trunc('hour', collection_time) AS bucket, + COUNT(DISTINCT query_hash) AS query_count, + COALESCE(SUM(delta_worker_time), 0) / 1000.0 AS total_cpu_ms, + COALESCE(SUM(delta_elapsed_time), 0) / 1000.0 AS total_elapsed_ms, + COALESCE(SUM(delta_logical_reads), 0) AS total_reads, + COALESCE(SUM(delta_logical_writes), 0) AS total_writes, + COALESCE(SUM(delta_physical_reads), 0) AS total_physical_reads + FROM query_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND ($4::text[] IS NULL OR database_name = ANY($4)) + GROUP BY date_trunc('hour', collection_time) + ORDER BY bucket + """; + + /// <summary>Hourly query-stats slicer buckets over [<paramref name="startUtc"/>, <paramref name="endUtc"/>].</summary> + public async Task<List<TimeSliceBucket>> GetQueryStatsSlicerDataAsync( + int serverId, DateTime startUtc, DateTime endUtc, IReadOnlyList<string>? databaseNames = null, CancellationToken cancellationToken = default) + => await ReadQueryStatsSlicerAsync(QueryStatsSlicerSql, serverId, startUtc, endUtc, databaseNames, cancellationToken); + + /// <summary> + /// Shared reader for the query-stats / procedure-stats hourly slicer buckets (same column shape: + /// bucket, count, cpu_ms, elapsed_ms, reads, writes, physical_reads). The default slicer metric is + /// total CPU (Value = TotalCpu), matching Lite's initial "Total CPU (ms)" label. + /// </summary> + internal async Task<List<TimeSliceBucket>> ReadQueryStatsSlicerAsync( + string sql, int serverId, DateTime startUtc, DateTime endUtc, IReadOnlyList<string>? databaseNames, CancellationToken cancellationToken) + { + var items = new List<TimeSliceBucket>(); + + await using var command = _dataSource.CreateCommand(sql); + AddServerWindowParameters(command, serverId, startUtc, endUtc); + command.Parameters.Add(DatabaseFilterParameter(databaseNames)); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var cpu = reader.IsDBNull(2) ? 0 : Convert.ToDouble(reader.GetValue(2)); + items.Add(new TimeSliceBucket + { + BucketTime = reader.GetDateTime(0), + SessionCount = reader.IsDBNull(1) ? 0 : Convert.ToInt64(reader.GetValue(1)), + TotalCpu = cpu, + TotalElapsed = reader.IsDBNull(3) ? 0 : Convert.ToDouble(reader.GetValue(3)), + TotalReads = reader.IsDBNull(4) ? 0 : Convert.ToDouble(reader.GetValue(4)), + TotalLogicalReads = reader.IsDBNull(4) ? 0 : Convert.ToDouble(reader.GetValue(4)), + TotalWrites = reader.IsDBNull(5) ? 0 : Convert.ToDouble(reader.GetValue(5)), + TotalPhysicalReads = reader.IsDBNull(6) ? 0 : Convert.ToDouble(reader.GetValue(6)), + Value = cpu, + }); + } + + return items; + } + + // ── Shared parameter helpers for the Queries reads (serverId + naive-UTC window) ── + + /// <summary>$1 server_id, $2 start, $3 end (naive UTC — Kind=Unspecified maps to the timestamp columns).</summary> + internal static void AddServerWindowParameters(NpgsqlCommand command, int serverId, DateTime startUtc, DateTime endUtc) + { + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified) }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified) }); + } + + /// <summary>$1 server_id, $2/$3 current window, $4/$5 baseline window (all naive UTC).</summary> + internal static void AddComparisonParameters( + NpgsqlCommand command, int serverId, + DateTime currentStart, DateTime currentEnd, DateTime baselineStart, DateTime baselineEnd) + { + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(currentStart, DateTimeKind.Unspecified) }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(currentEnd, DateTimeKind.Unspecified) }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(baselineStart, DateTimeKind.Unspecified) }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(baselineEnd, DateTimeKind.Unspecified) }); + } + + // ── Query / SQL text display helpers (shared by the Queries + Blocking grids) ── + + /// <summary> + /// Formats a SQL-server-local wall-clock time (dm_exec_* last_execution_time / creation_time / + /// cached_time — NOT naive UTC) for the grid: shown raw, no timezone conversion. Empty when null. + /// </summary> + public static string FormatServerClock(DateTime? serverLocal) + => serverLocal.HasValue ? serverLocal.Value.ToString("yyyy-MM-dd HH:mm:ss") : ""; + + /// <summary> + /// Collapses whitespace runs (query text arrives with its original formatting) to a single + /// space and caps at <see cref="CellTextCap"/> with an ellipsis — the one-line grid preview. + /// </summary> + public static string TruncateForCell(string? text) + => Truncate(CollapseWhitespace(text), CellTextCap); + + /// <summary>Caps the raw text at <see cref="TooltipTextCap"/> with an ellipsis, keeping line breaks.</summary> + public static string TruncateForTooltip(string? text) + => Truncate(text ?? "", TooltipTextCap); + + private static string Truncate(string text, int cap) + => text.Length <= cap ? text : text.Substring(0, cap) + "…"; + + private static string CollapseWhitespace(string? text) + { + if (string.IsNullOrEmpty(text)) + { + return ""; + } + + var sb = new System.Text.StringBuilder(Math.Min(text.Length, CellTextCap + 16)); + var inWhitespace = false; + foreach (var c in text) + { + if (char.IsWhiteSpace(c)) + { + inWhitespace = true; + continue; + } + + if (inWhitespace && sb.Length > 0) + { + sb.Append(' '); + } + + inWhitespace = false; + sb.Append(c); + + if (sb.Length > CellTextCap) + { + break; /* preview cap — no need to walk a multi-KB statement */ + } + } + + return sb.ToString(); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStore.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStore.cs new file mode 100644 index 000000000..d30598a20 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStore.cs @@ -0,0 +1,480 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// One Query-Store-by-Duration grid row — the viewer copy of Lite's <c>QueryStoreRow</c> +/// (LocalDataService.QueryStore.cs). A (database, query_id, plan_id, query_hash) group's interval +/// averages + min/max spreads over the window, with the latest captured query text. +/// Duration / CPU / CLR are converted to ms (from us) in SQL; memory-grant pages are converted to MB +/// in SQL; reads/writes/rows/tempdb pages / log bytes stay raw. The View-Plan surface is deferred, so +/// unlike Lite there is no QueryPlanText/HasQueryPlan (the collected query_plan_text is not read). +/// </summary> +public sealed class ViewerQueryStoreRow +{ + public string DatabaseName { get; set; } = ""; + public long QueryId { get; set; } + public long PlanId { get; set; } + + /// <summary> + /// The replica role SQL Server 2022+ attributed these runtime stats to ('Primary', 'Secondary', + /// 'Geo Secondary', 'Geo HA Secondary'), a GROUP BY key so an AG's shared Query Store does not + /// blend replicas into one row. NULL/blank = the server did not attribute it (pre-2022, or a 2022 + /// standalone) — expected, and deliberately not coalesced. + /// </summary> + public string? ReplicaRole { get; set; } + public string QueryHash { get; set; } = ""; + public string QueryText { get; set; } = ""; + public string ModuleName { get; set; } = ""; + + /// <summary>Gates "View Stored Plan" in the QueryStorePlanContextMenu — Query Store rows carry a stored plan.</summary> + public bool HasStoredPlan => true; + + /// <summary>Gates "Get Actual Plan (re-run)" — the service re-executes by QueryId (BuildActualPlanArgsForQueryStore).</summary> + public bool CanGetActualPlan => QueryId != 0; + public long TotalExecutions { get; set; } + public double AvgDurationMs { get; set; } + public double AvgCpuTimeMs { get; set; } + public double AvgLogicalReads { get; set; } + public double AvgLogicalWrites { get; set; } + public double AvgPhysicalReads { get; set; } + public double AvgRowcount { get; set; } + public long MinDop { get; set; } + public long MaxDop { get; set; } + public DateTime? LastExecutionTime { get; set; } + public string QueryPlanHash { get; set; } = ""; + public bool IsForcedPlan { get; set; } + public string PlanForcingType { get; set; } = ""; + public string ExecutionTypeDesc { get; set; } = ""; + public DateTime? FirstExecutionTime { get; set; } + public double AvgClrTimeMs { get; set; } + public double AvgTempdbSpaceUsed { get; set; } + public double AvgLogBytesUsed { get; set; } + public string PlanType { get; set; } = ""; + public long ForceFailureCount { get; set; } + public string LastForceFailureReason { get; set; } = ""; + public int CompatibilityLevel { get; set; } + public double MinDurationMs { get; set; } + public double MaxDurationMs { get; set; } + public double MinCpuTimeMs { get; set; } + public double MaxCpuTimeMs { get; set; } + public double MinLogicalReads { get; set; } + public double MaxLogicalReads { get; set; } + public double MinLogicalWrites { get; set; } + public double MaxLogicalWrites { get; set; } + public double MinPhysicalReads { get; set; } + public double MaxPhysicalReads { get; set; } + public double MinClrTimeMs { get; set; } + public double MaxClrTimeMs { get; set; } + public double MinRowcount { get; set; } + public double MaxRowcount { get; set; } + public double MinLogBytesUsed { get; set; } + public double MaxLogBytesUsed { get; set; } + public double MinTempdbSpaceUsed { get; set; } + public double MaxTempdbSpaceUsed { get; set; } + public double AvgMemoryMb { get; set; } + public double MinMemoryMb { get; set; } + public double MaxMemoryMb { get; set; } + public double AvgNumPhysicalIoReads { get; set; } + public double MinNumPhysicalIoReads { get; set; } + public double MaxNumPhysicalIoReads { get; set; } + public string FirstExecutionTimeLocal => ViewerDataService.FormatServerClock(FirstExecutionTime); + public string LastExecutionTimeLocal => ViewerDataService.FormatServerClock(LastExecutionTime); + public double TotalCpuMs => TotalExecutions * AvgCpuTimeMs; + public double TotalDurationMs => TotalExecutions * AvgDurationMs; +} + +public sealed partial class ViewerDataService +{ + /// <summary> + /// The Query-Store-by-Duration read — Lite's <c>GetQueryStoreTopQueriesAsync</c> ported to Postgres + /// against the base <c>query_store_stats</c> table (the store name; Lite reads its + /// <c>v_query_store_stats</c> view). Group by (database, query_id, plan_id, query_hash), average the + /// per-interval metrics + carry min/max spreads, rank by <c>SUM(execution_count) * + /// AVG(avg_duration_us)</c> (total duration) descending, over-fetch by 5 for the WAITFOR trim, and cap + /// at top. Viewer deviations: <c>SUM(execution_count)</c> CAST to bigint; the query_plan_text column + /// is not selected (plan surfacing deferred); the forced-plan flag folds through Postgres + /// <c>bool_or(is_forced_plan)</c> (Postgres has no <c>MAX(boolean)</c> aggregate, unlike DuckDB). + /// Every avg/min/max metric column is stored as bigint, so each is CAST to double precision before + /// the AVG/scale arithmetic (matching Lite's DuckDB casts). + /// $1 server_id, $2 window start, $3 window end (naive UTC), $4 top. + /// </summary> + public const string QueryStoreTopSql = """ + WITH ranked AS ( + SELECT + database_name, + query_id, + plan_id, + query_hash, + /* A GROUP BY key, not MAX(): with Query Store for secondary replicas (2022+) the primary + holds ONE shared Query Store carrying every replica's rows, so grouping without it would + average primary and secondary workload into a single blended row — the conflation this + column exists to expose. Grouping splits them into one row per replica role instead. On a + standalone/non-AG server every row shares one value (NULL, or 'Primary' on 2025), so the + grouping is a no-op and the grid is unchanged. Twins Lite's QueryStore reader. */ + replica_role, + MAX(module_name) AS module_name, + CAST(SUM(execution_count) AS bigint) AS total_executions, + AVG(CAST(avg_duration_us AS double precision)) / 1000.0 AS avg_duration_ms, + AVG(CAST(avg_cpu_time_us AS double precision)) / 1000.0 AS avg_cpu_time_ms, + AVG(CAST(avg_logical_io_reads AS double precision)) AS avg_logical_reads, + AVG(CAST(avg_logical_io_writes AS double precision)) AS avg_logical_writes, + AVG(CAST(avg_physical_io_reads AS double precision)) AS avg_physical_reads, + AVG(CAST(avg_rowcount AS double precision)) AS avg_rowcount, + MIN(min_dop) AS min_dop, + MAX(max_dop) AS max_dop, + MAX(last_execution_time) AS last_execution_time, + MAX(query_plan_hash) AS query_plan_hash, + bool_or(is_forced_plan) AS is_forced_plan, + MAX(plan_forcing_type) AS plan_forcing_type, + MAX(execution_type_desc) AS execution_type_desc, + MIN(first_execution_time) AS first_execution_time, + AVG(CAST(avg_clr_time_us AS double precision)) / 1000.0 AS avg_clr_time_ms, + AVG(CAST(avg_tempdb_space_used AS double precision)) AS avg_tempdb_space_used, + AVG(CAST(avg_log_bytes_used AS double precision)) AS avg_log_bytes_used, + MAX(plan_type) AS plan_type, + MAX(force_failure_count) AS force_failure_count, + MAX(last_force_failure_reason) AS last_force_failure_reason, + MAX(compatibility_level) AS compatibility_level, + MIN(CAST(min_duration_us AS double precision)) / 1000.0 AS min_duration_ms, + MAX(CAST(max_duration_us AS double precision)) / 1000.0 AS max_duration_ms, + MIN(CAST(min_cpu_time_us AS double precision)) / 1000.0 AS min_cpu_time_ms, + MAX(CAST(max_cpu_time_us AS double precision)) / 1000.0 AS max_cpu_time_ms, + MIN(CAST(min_logical_io_reads AS double precision)) AS min_logical_reads, + MAX(CAST(max_logical_io_reads AS double precision)) AS max_logical_reads, + MIN(CAST(min_logical_io_writes AS double precision)) AS min_logical_writes, + MAX(CAST(max_logical_io_writes AS double precision)) AS max_logical_writes, + MIN(CAST(min_physical_io_reads AS double precision)) AS min_physical_reads, + MAX(CAST(max_physical_io_reads AS double precision)) AS max_physical_reads, + MIN(CAST(min_clr_time_us AS double precision)) / 1000.0 AS min_clr_time_ms, + MAX(CAST(max_clr_time_us AS double precision)) / 1000.0 AS max_clr_time_ms, + MIN(CAST(min_rowcount AS double precision)) AS min_rowcount, + MAX(CAST(max_rowcount AS double precision)) AS max_rowcount, + MIN(CAST(min_log_bytes_used AS double precision)) AS min_log_bytes_used, + MAX(CAST(max_log_bytes_used AS double precision)) AS max_log_bytes_used, + MIN(CAST(min_tempdb_space_used AS double precision)) AS min_tempdb_space_used, + MAX(CAST(max_tempdb_space_used AS double precision)) AS max_tempdb_space_used, + AVG(CAST(avg_query_max_used_memory AS double precision)) * 8.0 / 1024.0 AS avg_memory_mb, + MIN(CAST(min_query_max_used_memory AS double precision)) * 8.0 / 1024.0 AS min_memory_mb, + MAX(CAST(max_query_max_used_memory AS double precision)) * 8.0 / 1024.0 AS max_memory_mb, + AVG(CAST(avg_num_physical_io_reads AS double precision)) AS avg_num_physical_io_reads, + MIN(CAST(min_num_physical_io_reads AS double precision)) AS min_num_physical_io_reads, + MAX(CAST(max_num_physical_io_reads AS double precision)) AS max_num_physical_io_reads + FROM query_store_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND ($5::text[] IS NULL OR database_name = ANY($5)) + GROUP BY database_name, query_id, plan_id, query_hash, replica_role + ORDER BY SUM(execution_count) * AVG(CAST(avg_duration_us AS double precision)) DESC + LIMIT $4 + 5 + ) + SELECT + r.database_name, + r.query_id, + r.plan_id, + r.query_hash, + t.query_text, + r.module_name, + r.total_executions, + r.avg_duration_ms, + r.avg_cpu_time_ms, + r.avg_logical_reads, + r.avg_logical_writes, + r.avg_physical_reads, + r.avg_rowcount, + r.min_dop, + r.max_dop, + r.last_execution_time, + r.query_plan_hash, + r.is_forced_plan, + r.plan_forcing_type, + r.execution_type_desc, + r.first_execution_time, + r.avg_clr_time_ms, + r.avg_tempdb_space_used, + r.avg_log_bytes_used, + r.plan_type, + r.force_failure_count, + r.last_force_failure_reason, + r.compatibility_level, + r.min_duration_ms, + r.max_duration_ms, + r.min_cpu_time_ms, + r.max_cpu_time_ms, + r.min_logical_reads, + r.max_logical_reads, + r.min_logical_writes, + r.max_logical_writes, + r.min_physical_reads, + r.max_physical_reads, + r.min_clr_time_ms, + r.max_clr_time_ms, + r.min_rowcount, + r.max_rowcount, + r.min_log_bytes_used, + r.max_log_bytes_used, + r.min_tempdb_space_used, + r.max_tempdb_space_used, + r.avg_memory_mb, + r.min_memory_mb, + r.max_memory_mb, + r.avg_num_physical_io_reads, + r.min_num_physical_io_reads, + r.max_num_physical_io_reads, + r.replica_role + FROM ranked AS r + LEFT JOIN LATERAL ( + SELECT query_text + FROM query_store_stats + WHERE server_id = $1 + AND query_id = r.query_id + AND database_name = r.database_name + AND query_text IS NOT NULL + ORDER BY collection_time DESC + LIMIT 1 + ) AS t ON TRUE + WHERE t.query_text IS NULL OR t.query_text NOT LIKE 'WAITFOR%' + ORDER BY r.total_executions * r.avg_duration_ms DESC + LIMIT $4 + """; + + /// <summary> + /// The top Query Store queries for one server over [<paramref name="startUtc"/>, + /// <paramref name="endUtc"/>], pre-sorted by total duration descending (the grid's default sort). + /// </summary> + public async Task<List<ViewerQueryStoreRow>> GetQueryStoreTopQueriesAsync( + int serverId, DateTime startUtc, DateTime endUtc, int top = TopQueriesPageSize, IReadOnlyList<string>? databaseNames = null, CancellationToken cancellationToken = default) + { + var rows = new List<ViewerQueryStoreRow>(); + + await using var command = _dataSource.CreateCommand(QueryStoreTopSql); + AddServerWindowParameters(command, serverId, startUtc, endUtc); + command.Parameters.Add(new Npgsql.NpgsqlParameter<int> { TypedValue = top }); + command.Parameters.Add(DatabaseFilterParameter(databaseNames)); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new ViewerQueryStoreRow + { + DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0), + QueryId = reader.IsDBNull(1) ? 0 : reader.GetInt64(1), + PlanId = reader.IsDBNull(2) ? 0 : reader.GetInt64(2), + QueryHash = reader.IsDBNull(3) ? "" : reader.GetString(3), + QueryText = reader.IsDBNull(4) ? "" : reader.GetString(4), + ModuleName = reader.IsDBNull(5) ? "" : reader.GetString(5), + TotalExecutions = reader.IsDBNull(6) ? 0 : reader.GetInt64(6), + AvgDurationMs = reader.IsDBNull(7) ? 0 : Convert.ToDouble(reader.GetValue(7)), + AvgCpuTimeMs = reader.IsDBNull(8) ? 0 : Convert.ToDouble(reader.GetValue(8)), + AvgLogicalReads = reader.IsDBNull(9) ? 0 : Convert.ToDouble(reader.GetValue(9)), + AvgLogicalWrites = reader.IsDBNull(10) ? 0 : Convert.ToDouble(reader.GetValue(10)), + AvgPhysicalReads = reader.IsDBNull(11) ? 0 : Convert.ToDouble(reader.GetValue(11)), + AvgRowcount = reader.IsDBNull(12) ? 0 : Convert.ToDouble(reader.GetValue(12)), + MinDop = reader.IsDBNull(13) ? 0 : Convert.ToInt64(reader.GetValue(13)), + MaxDop = reader.IsDBNull(14) ? 0 : Convert.ToInt64(reader.GetValue(14)), + LastExecutionTime = reader.IsDBNull(15) ? null : reader.GetDateTime(15), + QueryPlanHash = reader.IsDBNull(16) ? "" : reader.GetString(16), + IsForcedPlan = !reader.IsDBNull(17) && reader.GetBoolean(17), + PlanForcingType = reader.IsDBNull(18) ? "" : reader.GetString(18), + ExecutionTypeDesc = reader.IsDBNull(19) ? "" : reader.GetString(19), + FirstExecutionTime = reader.IsDBNull(20) ? null : reader.GetDateTime(20), + AvgClrTimeMs = reader.IsDBNull(21) ? 0 : Convert.ToDouble(reader.GetValue(21)), + AvgTempdbSpaceUsed = reader.IsDBNull(22) ? 0 : Convert.ToDouble(reader.GetValue(22)), + AvgLogBytesUsed = reader.IsDBNull(23) ? 0 : Convert.ToDouble(reader.GetValue(23)), + PlanType = reader.IsDBNull(24) ? "" : reader.GetString(24), + ForceFailureCount = reader.IsDBNull(25) ? 0 : Convert.ToInt64(reader.GetValue(25)), + LastForceFailureReason = reader.IsDBNull(26) ? "" : reader.GetString(26), + CompatibilityLevel = reader.IsDBNull(27) ? 0 : Convert.ToInt32(reader.GetValue(27)), + MinDurationMs = reader.IsDBNull(28) ? 0 : Convert.ToDouble(reader.GetValue(28)), + MaxDurationMs = reader.IsDBNull(29) ? 0 : Convert.ToDouble(reader.GetValue(29)), + MinCpuTimeMs = reader.IsDBNull(30) ? 0 : Convert.ToDouble(reader.GetValue(30)), + MaxCpuTimeMs = reader.IsDBNull(31) ? 0 : Convert.ToDouble(reader.GetValue(31)), + MinLogicalReads = reader.IsDBNull(32) ? 0 : Convert.ToDouble(reader.GetValue(32)), + MaxLogicalReads = reader.IsDBNull(33) ? 0 : Convert.ToDouble(reader.GetValue(33)), + MinLogicalWrites = reader.IsDBNull(34) ? 0 : Convert.ToDouble(reader.GetValue(34)), + MaxLogicalWrites = reader.IsDBNull(35) ? 0 : Convert.ToDouble(reader.GetValue(35)), + MinPhysicalReads = reader.IsDBNull(36) ? 0 : Convert.ToDouble(reader.GetValue(36)), + MaxPhysicalReads = reader.IsDBNull(37) ? 0 : Convert.ToDouble(reader.GetValue(37)), + MinClrTimeMs = reader.IsDBNull(38) ? 0 : Convert.ToDouble(reader.GetValue(38)), + MaxClrTimeMs = reader.IsDBNull(39) ? 0 : Convert.ToDouble(reader.GetValue(39)), + MinRowcount = reader.IsDBNull(40) ? 0 : Convert.ToDouble(reader.GetValue(40)), + MaxRowcount = reader.IsDBNull(41) ? 0 : Convert.ToDouble(reader.GetValue(41)), + MinLogBytesUsed = reader.IsDBNull(42) ? 0 : Convert.ToDouble(reader.GetValue(42)), + MaxLogBytesUsed = reader.IsDBNull(43) ? 0 : Convert.ToDouble(reader.GetValue(43)), + MinTempdbSpaceUsed = reader.IsDBNull(44) ? 0 : Convert.ToDouble(reader.GetValue(44)), + MaxTempdbSpaceUsed = reader.IsDBNull(45) ? 0 : Convert.ToDouble(reader.GetValue(45)), + AvgMemoryMb = reader.IsDBNull(46) ? 0 : Convert.ToDouble(reader.GetValue(46)), + MinMemoryMb = reader.IsDBNull(47) ? 0 : Convert.ToDouble(reader.GetValue(47)), + MaxMemoryMb = reader.IsDBNull(48) ? 0 : Convert.ToDouble(reader.GetValue(48)), + AvgNumPhysicalIoReads = reader.IsDBNull(49) ? 0 : Convert.ToDouble(reader.GetValue(49)), + MinNumPhysicalIoReads = reader.IsDBNull(50) ? 0 : Convert.ToDouble(reader.GetValue(50)), + MaxNumPhysicalIoReads = reader.IsDBNull(51) ? 0 : Convert.ToDouble(reader.GetValue(51)), + ReplicaRole = reader.IsDBNull(52) ? null : reader.GetString(52), + }); + } + + return rows; + } + + /// <summary> + /// Query-Store comparison — Lite's <c>GetQueryStoreComparisonAsync</c> ported. Uses execution-count + /// weighted averages (<c>SUM(execution_count * avg_metric) / SUM(execution_count)</c>) so periods with + /// uneven interval counts aggregate correctly, top-100-union + FULL OUTER JOIN keyed on + /// (database, query_hash). Returns the shared <see cref="QueryStatsComparisonItem"/>. + /// $1 server_id, $2/$3 current window, $4/$5 baseline window (naive UTC). + /// </summary> + public const string QueryStoreComparisonSql = """ + WITH top_current AS ( + SELECT database_name, query_hash + FROM query_store_stats + WHERE server_id = $1 + AND collection_time >= $2 AND collection_time <= $3 + AND ($6::text[] IS NULL OR database_name = ANY($6)) + AND execution_count > 0 + GROUP BY database_name, query_hash + ORDER BY SUM(execution_count) DESC + LIMIT 100 + ), + top_baseline AS ( + SELECT database_name, query_hash + FROM query_store_stats + WHERE server_id = $1 + AND collection_time >= $4 AND collection_time <= $5 + AND ($6::text[] IS NULL OR database_name = ANY($6)) + AND execution_count > 0 + GROUP BY database_name, query_hash + ORDER BY SUM(execution_count) DESC + LIMIT 100 + ), + top_hashes AS ( + SELECT DISTINCT database_name, query_hash + FROM ( + SELECT * FROM top_current + UNION ALL + SELECT * FROM top_baseline + ) AS combined + ), + current_period AS ( + SELECT th.database_name, th.query_hash, + SUM(qs.execution_count) AS exec_count, + SUM(qs.execution_count * qs.avg_duration_us::double precision) / NULLIF(SUM(qs.execution_count), 0) / 1000.0 AS avg_duration_ms, + SUM(qs.execution_count * qs.avg_cpu_time_us::double precision) / NULLIF(SUM(qs.execution_count), 0) / 1000.0 AS avg_cpu_ms, + SUM(qs.execution_count * qs.avg_logical_io_reads::double precision) / NULLIF(SUM(qs.execution_count), 0) AS avg_reads, + MAX(qs.query_text) AS query_text + FROM top_hashes th + INNER JOIN query_store_stats qs + ON qs.query_hash IS NOT DISTINCT FROM th.query_hash + AND qs.database_name IS NOT DISTINCT FROM th.database_name + WHERE qs.server_id = $1 + AND qs.collection_time >= $2 AND qs.collection_time <= $3 + AND qs.execution_count > 0 + GROUP BY th.database_name, th.query_hash + ), + baseline_period AS ( + SELECT th.database_name, th.query_hash, + SUM(qs.execution_count) AS exec_count, + SUM(qs.execution_count * qs.avg_duration_us::double precision) / NULLIF(SUM(qs.execution_count), 0) / 1000.0 AS avg_duration_ms, + SUM(qs.execution_count * qs.avg_cpu_time_us::double precision) / NULLIF(SUM(qs.execution_count), 0) / 1000.0 AS avg_cpu_ms, + SUM(qs.execution_count * qs.avg_logical_io_reads::double precision) / NULLIF(SUM(qs.execution_count), 0) AS avg_reads, + MAX(qs.query_text) AS query_text + FROM top_hashes th + INNER JOIN query_store_stats qs + ON qs.query_hash IS NOT DISTINCT FROM th.query_hash + AND qs.database_name IS NOT DISTINCT FROM th.database_name + WHERE qs.server_id = $1 + AND qs.collection_time >= $4 AND qs.collection_time <= $5 + AND qs.execution_count > 0 + GROUP BY th.database_name, th.query_hash + ) + SELECT COALESCE(c.database_name, b.database_name) AS database_name, + COALESCE(c.query_hash, b.query_hash) AS query_hash, + COALESCE(c.query_text, b.query_text) AS query_text, + c.exec_count, c.avg_duration_ms, c.avg_cpu_ms, c.avg_reads, + b.exec_count AS baseline_exec_count, + b.avg_duration_ms AS baseline_avg_duration_ms, + b.avg_cpu_ms AS baseline_avg_cpu_ms, + b.avg_reads AS baseline_avg_reads + FROM current_period c + FULL OUTER JOIN baseline_period b + ON COALESCE(c.database_name, '') = COALESCE(b.database_name, '') + AND COALESCE(c.query_hash, '') = COALESCE(b.query_hash, '') + """; + + /// <summary>Query-Store current-vs-baseline comparison rows (shared .Ui item; delta % + NEW/GONE badges).</summary> + public async Task<List<QueryStatsComparisonItem>> GetQueryStoreComparisonAsync( + int serverId, + DateTime currentStart, DateTime currentEnd, + DateTime baselineStart, DateTime baselineEnd, + IReadOnlyList<string>? databaseNames = null, + CancellationToken cancellationToken = default) + { + var items = new List<QueryStatsComparisonItem>(); + + await using var command = _dataSource.CreateCommand(QueryStoreComparisonSql); + AddComparisonParameters(command, serverId, currentStart, currentEnd, baselineStart, baselineEnd); + command.Parameters.Add(DatabaseFilterParameter(databaseNames)); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new QueryStatsComparisonItem + { + DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0), + QueryHash = reader.IsDBNull(1) ? "" : reader.GetString(1), + QueryText = reader.IsDBNull(2) ? "" : reader.GetString(2), + ExecutionCount = reader.IsDBNull(3) ? 0 : Convert.ToInt64(reader.GetValue(3)), + AvgDurationMs = reader.IsDBNull(4) ? 0 : Convert.ToDouble(reader.GetValue(4)), + AvgCpuMs = reader.IsDBNull(5) ? 0 : Convert.ToDouble(reader.GetValue(5)), + AvgReads = reader.IsDBNull(6) ? 0 : Convert.ToDouble(reader.GetValue(6)), + BaselineExecutionCount = reader.IsDBNull(7) ? 0 : Convert.ToInt64(reader.GetValue(7)), + BaselineAvgDurationMs = reader.IsDBNull(8) ? 0 : Convert.ToDouble(reader.GetValue(8)), + BaselineAvgCpuMs = reader.IsDBNull(9) ? 0 : Convert.ToDouble(reader.GetValue(9)), + BaselineAvgReads = reader.IsDBNull(10) ? 0 : Convert.ToDouble(reader.GetValue(10)), + }); + } + + return items; + } + + /// <summary> + /// Hourly Query Store buckets for the slicer — Lite's <c>GetQueryStoreSlicerDataAsync</c> ported. + /// Query Store rows are already per-interval averages, so the bucket totals weight each by + /// <c>execution_count</c> (<c>SUM(avg_metric * execution_count)</c>). Same 7-column shape as the + /// query/procedure slicers, so it shares <see cref="ReadQueryStatsSlicerAsync"/>. + /// $1 server_id, $2 window start, $3 window end (naive UTC). + /// </summary> + public const string QueryStoreSlicerSql = """ + SELECT + date_trunc('hour', collection_time) AS bucket, + COUNT(DISTINCT query_id) AS query_count, + COALESCE(SUM(CAST(avg_cpu_time_us AS double precision) * execution_count), 0) / 1000.0 AS total_cpu_ms, + COALESCE(SUM(CAST(avg_duration_us AS double precision) * execution_count), 0) / 1000.0 AS total_duration_ms, + COALESCE(SUM(CAST(avg_logical_io_reads AS double precision) * execution_count), 0) AS total_reads, + COALESCE(SUM(CAST(avg_logical_io_writes AS double precision) * execution_count), 0) AS total_writes, + COALESCE(SUM(CAST(avg_physical_io_reads AS double precision) * execution_count), 0) AS total_physical_reads + FROM query_store_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND ($4::text[] IS NULL OR database_name = ANY($4)) + GROUP BY date_trunc('hour', collection_time) + ORDER BY bucket + """; + + /// <summary>Hourly Query Store slicer buckets over [<paramref name="startUtc"/>, <paramref name="endUtc"/>].</summary> + public async Task<List<TimeSliceBucket>> GetQueryStoreSlicerDataAsync( + int serverId, DateTime startUtc, DateTime endUtc, IReadOnlyList<string>? databaseNames = null, CancellationToken cancellationToken = default) + => await ReadQueryStatsSlicerAsync(QueryStoreSlicerSql, serverId, startUtc, endUtc, databaseNames, cancellationToken); +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStoreRegressions.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStoreRegressions.cs new file mode 100644 index 000000000..e9072515b --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStoreRegressions.cs @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// One Query-Store regression row — the viewer copy of the Dashboard's +/// <c>QueryStoreRegressionItem</c> (Dashboard/Models/QueryStoreRegressionItem.cs), fed by the Postgres +/// port of the Dashboard's <c>report.query_store_regressions</c> inline TVF +/// (install/47_create_reporting_views.sql). A (database, query_id) group's RECENT-window averages vs. its +/// BASELINE (everything captured before the window), with the per-metric regression percents, the +/// execution-count-weighted extra duration, the plan-count deltas, a duration-driven severity band, and +/// the latest captured query-text sample. +/// +/// <para>Metric values are ms (duration/CPU, converted from µs in SQL) and raw pages (reads); the percents +/// are plain deltas the grid formats to N2%. <see cref="LastExecutionTime"/> is the Query Store row's +/// last-exec column (the SQL server's local wall clock in Darling's store), shown RAW via +/// <see cref="ViewerDataService.FormatServerClock"/> exactly like the sibling Query Store tab — NOT run +/// through the naive-UTC→local conversion the collection_time columns get. The regression read carries no +/// plan XML (the Dashboard TVF selects none either), so — like the sibling Query Store tab (View-Plan +/// deferred) — there is no plan surface; double-clicking a row opens that query's history window instead +/// (mirroring the Dashboard grid's double-click).</para> +/// </summary> +public sealed class ViewerQueryStoreRegressionRow +{ + public string DatabaseName { get; set; } = ""; + public long QueryId { get; set; } + public double BaselineDurationMs { get; set; } + public double RecentDurationMs { get; set; } + public double DurationRegressionPercent { get; set; } + public double BaselineCpuMs { get; set; } + public double RecentCpuMs { get; set; } + public double CpuRegressionPercent { get; set; } + public double BaselineReads { get; set; } + public double RecentReads { get; set; } + public double IoRegressionPercent { get; set; } + public double AdditionalDurationMs { get; set; } + public long BaselineExecCount { get; set; } + public long RecentExecCount { get; set; } + public int BaselinePlanCount { get; set; } + public int RecentPlanCount { get; set; } + public string Severity { get; set; } = ""; + public string QueryTextSample { get; set; } = ""; + + /// <summary>Regression rows aggregate across plans, so there is no single stored plan to view (View Stored Plan hidden).</summary> + public bool HasStoredPlan => false; + + /// <summary>Gates "Get Actual Plan (re-run)" — the service re-executes by QueryId (BuildActualPlanArgsForQueryStore).</summary> + public bool CanGetActualPlan => QueryId != 0; + public DateTime? LastExecutionTime { get; set; } + + /// <summary>The last-execution wall clock shown raw (the sibling Query Store tab's convention).</summary> + public string LastExecutionTimeLocal => ViewerDataService.FormatServerClock(LastExecutionTime); +} + +public sealed partial class ViewerDataService +{ + /// <summary> + /// The Query-Store regressions read — the Dashboard's <c>report.query_store_regressions(@start,@end)</c> + /// inline TVF ported to Postgres against Darling's <c>query_store_stats</c> store. Two windowed CTEs over + /// the SAME base table: BASELINE = every capture BEFORE the window start; RECENT = the [start, end] + /// window. Each averages the per-interval µs metrics to ms (reads stay raw pages), SUMs execution_count, + /// and COUNTs DISTINCT plan_id; the outer projection joins them per (database, query_id), computes the + /// duration / CPU / IO regression percents, the execution-count-weighted <c>additional_duration_ms</c> + /// (extra total time = per-exec delta × recent exec count), and a DURATION-driven severity band, keeping + /// only rows whose CPU regressed > 25% (the TVF's single-metric gate), ranked by additional_duration_ms + /// and capped at the TVF's TOP (50). + /// + /// <para>Faithful adaptations to Darling's store (all reported, none silently changed): + /// (1) The Dashboard TVF windows on <c>server_last_execution_time</c>; Darling windows the baseline/recent + /// split on <c>collection_time</c> (naive UTC) — the column EVERY other Darling Query Store read windows + /// on, and the frame the toolbar's [start,end] bounds are in (Darling's <c>last_execution_time</c> is the + /// server's LOCAL wall clock, so windowing on it against UTC bounds would be a timezone bug). + /// (2) The recent query-text sample is <c>MAX(query_text)</c> from Darling's already-decompressed + /// <c>query_text</c> column (the TVF <c>DECOMPRESS()</c>es a compressed <c>query_sql_text</c>). + /// (3) The stale INTENT comment on the Dashboard's C# caller (bounded/mirrored baseline, weighted averages, + /// multi-metric, absolute minimums) does NOT match what the TVF actually runs — this ports the ACTUAL TVF + /// (unbounded baseline, plain AVG, CPU-only > 25% gate, no minimums), which is what the Dashboard grid shows.</para> + /// + /// <para>$1 server_id, $2 window start (baseline is < $2), $3 window end, $4 database filter (text[]).</para> + /// </summary> + public const string QueryStoreRegressionsSql = """ + WITH baseline_performance AS ( + SELECT + database_name, + query_id, + AVG(CAST(avg_duration_us AS double precision)) / 1000.0 AS avg_duration_ms, + AVG(CAST(avg_cpu_time_us AS double precision)) / 1000.0 AS avg_cpu_time_ms, + AVG(CAST(avg_logical_io_reads AS double precision)) AS avg_logical_io_reads, + CAST(SUM(execution_count) AS bigint) AS exec_count, + CAST(COUNT(DISTINCT plan_id) AS integer) AS plan_count + FROM query_store_stats + WHERE server_id = $1 + AND collection_time < $2 + AND ($4::text[] IS NULL OR database_name = ANY($4)) + GROUP BY database_name, query_id + ), + recent_performance AS ( + SELECT + database_name, + query_id, + MAX(query_text) AS query_text_sample, + AVG(CAST(avg_duration_us AS double precision)) / 1000.0 AS avg_duration_ms, + AVG(CAST(avg_cpu_time_us AS double precision)) / 1000.0 AS avg_cpu_time_ms, + AVG(CAST(avg_logical_io_reads AS double precision)) AS avg_logical_io_reads, + CAST(SUM(execution_count) AS bigint) AS exec_count, + CAST(COUNT(DISTINCT plan_id) AS integer) AS plan_count, + MAX(last_execution_time) AS last_execution_time + FROM query_store_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND ($4::text[] IS NULL OR database_name = ANY($4)) + GROUP BY database_name, query_id + ) + SELECT + r.database_name, + r.query_id, + b.avg_duration_ms AS baseline_duration_ms, + r.avg_duration_ms AS recent_duration_ms, + (r.avg_duration_ms - b.avg_duration_ms) * 100.0 / NULLIF(b.avg_duration_ms, 0) AS duration_regression_percent, + b.avg_cpu_time_ms AS baseline_cpu_ms, + r.avg_cpu_time_ms AS recent_cpu_ms, + (r.avg_cpu_time_ms - b.avg_cpu_time_ms) * 100.0 / NULLIF(b.avg_cpu_time_ms, 0) AS cpu_regression_percent, + b.avg_logical_io_reads AS baseline_reads, + r.avg_logical_io_reads AS recent_reads, + (r.avg_logical_io_reads - b.avg_logical_io_reads) * 100.0 / NULLIF(b.avg_logical_io_reads, 0) AS io_regression_percent, + (r.avg_duration_ms - b.avg_duration_ms) * r.exec_count AS additional_duration_ms, + b.exec_count AS baseline_exec_count, + r.exec_count AS recent_exec_count, + b.plan_count AS baseline_plan_count, + r.plan_count AS recent_plan_count, + CASE + WHEN (r.avg_duration_ms - b.avg_duration_ms) * 100.0 / NULLIF(b.avg_duration_ms, 0) > 100 THEN 'CRITICAL' + WHEN (r.avg_duration_ms - b.avg_duration_ms) * 100.0 / NULLIF(b.avg_duration_ms, 0) > 50 THEN 'HIGH' + WHEN (r.avg_duration_ms - b.avg_duration_ms) * 100.0 / NULLIF(b.avg_duration_ms, 0) > 25 THEN 'MEDIUM' + ELSE 'LOW' + END AS severity, + r.query_text_sample, + r.last_execution_time + FROM recent_performance AS r + JOIN baseline_performance AS b + ON b.database_name = r.database_name + AND b.query_id = r.query_id + WHERE (r.avg_cpu_time_ms - b.avg_cpu_time_ms) * 100.0 / NULLIF(b.avg_cpu_time_ms, 0) > 25 + ORDER BY additional_duration_ms DESC + LIMIT 50 + """; + + /// <summary> + /// The Query Store regressions for one server over the RECENT window [<paramref name="startUtc"/>, + /// <paramref name="endUtc"/>] vs. the baseline before it, ranked by execution-count-weighted extra + /// duration descending (the TVF's ORDER BY; the grid then default-sorts by duration % like the Dashboard). + /// </summary> + public async Task<List<ViewerQueryStoreRegressionRow>> GetQueryStoreRegressionsAsync( + int serverId, DateTime startUtc, DateTime endUtc, IReadOnlyList<string>? databaseNames = null, CancellationToken cancellationToken = default) + { + var rows = new List<ViewerQueryStoreRegressionRow>(); + + await using var command = _dataSource.CreateCommand(QueryStoreRegressionsSql); + AddServerWindowParameters(command, serverId, startUtc, endUtc); + command.Parameters.Add(DatabaseFilterParameter(databaseNames)); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new ViewerQueryStoreRegressionRow + { + DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0), + QueryId = reader.IsDBNull(1) ? 0 : reader.GetInt64(1), + BaselineDurationMs = reader.IsDBNull(2) ? 0 : Convert.ToDouble(reader.GetValue(2)), + RecentDurationMs = reader.IsDBNull(3) ? 0 : Convert.ToDouble(reader.GetValue(3)), + DurationRegressionPercent = reader.IsDBNull(4) ? 0 : Convert.ToDouble(reader.GetValue(4)), + BaselineCpuMs = reader.IsDBNull(5) ? 0 : Convert.ToDouble(reader.GetValue(5)), + RecentCpuMs = reader.IsDBNull(6) ? 0 : Convert.ToDouble(reader.GetValue(6)), + CpuRegressionPercent = reader.IsDBNull(7) ? 0 : Convert.ToDouble(reader.GetValue(7)), + BaselineReads = reader.IsDBNull(8) ? 0 : Convert.ToDouble(reader.GetValue(8)), + RecentReads = reader.IsDBNull(9) ? 0 : Convert.ToDouble(reader.GetValue(9)), + IoRegressionPercent = reader.IsDBNull(10) ? 0 : Convert.ToDouble(reader.GetValue(10)), + AdditionalDurationMs = reader.IsDBNull(11) ? 0 : Convert.ToDouble(reader.GetValue(11)), + BaselineExecCount = reader.IsDBNull(12) ? 0 : reader.GetInt64(12), + RecentExecCount = reader.IsDBNull(13) ? 0 : reader.GetInt64(13), + BaselinePlanCount = reader.IsDBNull(14) ? 0 : reader.GetInt32(14), + RecentPlanCount = reader.IsDBNull(15) ? 0 : reader.GetInt32(15), + Severity = reader.IsDBNull(16) ? "" : reader.GetString(16), + QueryTextSample = reader.IsDBNull(17) ? "" : reader.GetString(17), + LastExecutionTime = reader.IsDBNull(18) ? null : reader.GetDateTime(18), + }); + } + + return rows; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryTrends.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryTrends.cs new file mode 100644 index 000000000..4fa61fef1 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryTrends.cs @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// One point on a Performance-Trends chart — the viewer copy of Lite's <c>QueryTrendPoint</c> +/// (LocalDataService.QueryStats.cs). <see cref="Value"/> is the per-second rate the chart plots +/// (elapsed ms/sec for the duration trends, executions/sec for the execution-count trend); +/// <see cref="ExecutionCount"/> carries the executions/sec rate the duration trends also compute +/// (unused by the execution-count trend). CollectionTime is naive UTC — the chart converts it +/// through <see cref="ViewerTimeHelper.ForDisplay"/>. +/// </summary> +public sealed class QueryTrendPoint +{ + public DateTime CollectionTime { get; set; } + public double Value { get; set; } + public long ExecutionCount { get; set; } +} + +public sealed partial class ViewerDataService +{ + /* The four Performance-Trends reads — Lite's Get{Query,Procedure,ExecutionCount}DurationTrendAsync + (LocalDataService.QueryStats.cs:1029-1168) + GetQueryStoreDurationTrendAsync + (LocalDataService.QueryStore.cs:582) ported to Postgres. The SQL is byte-identical to Lite's + apart from the table name (Lite reads the v_* views; the viewer reads the base tables, matching + W1f-1's Top-Queries read) — every operator Lite uses (date_trunc('second', …), the + LAG() OVER (ORDER BY collection_time) per-interval rate, extract(epoch FROM interval), + CAST(… AS double precision), the positional $1/$2/$3 placeholders) is native Postgres, so no + dialect rewrite is needed here (unlike the heatmap's time_bucket/ARG_MAX). The per-snapshot + rate = summed delta over the seconds since the previous snapshot; the first row's LAG is NULL, + so interval_seconds is NULL and the CASE yields 0 (Lite's behaviour). Summed bigint deltas come + back as Postgres numeric, so the reads go through Convert tolerantly. + $1 server_id, $2 window start, $3 window end (naive UTC). */ + + /// <summary>Query-stats duration trend: elapsed ms/sec + executions/sec per collection snapshot.</summary> + public const string QueryDurationTrendSql = """ + WITH raw AS + ( + SELECT + collection_time, + SUM(delta_elapsed_time) / 1000.0 AS total_elapsed_ms, + SUM(delta_execution_count) AS total_executions, + extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (ORDER BY collection_time)))) AS interval_seconds + FROM query_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND ($4::text[] IS NULL OR database_name = ANY($4)) + GROUP BY collection_time + ) + SELECT + collection_time, + CASE WHEN interval_seconds > 0 THEN total_elapsed_ms / interval_seconds ELSE 0 END AS elapsed_ms_per_second, + CASE WHEN interval_seconds > 0 THEN CAST(total_executions AS DOUBLE PRECISION) / interval_seconds ELSE 0 END AS executions_per_second + FROM raw + ORDER BY collection_time + """; + + /// <summary>Procedure-stats duration trend: elapsed ms/sec + executions/sec per collection snapshot.</summary> + public const string ProcedureDurationTrendSql = """ + WITH raw AS + ( + SELECT + collection_time, + SUM(delta_elapsed_time) / 1000.0 AS total_elapsed_ms, + SUM(delta_execution_count) AS total_executions, + extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (ORDER BY collection_time)))) AS interval_seconds + FROM procedure_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND ($4::text[] IS NULL OR database_name = ANY($4)) + GROUP BY collection_time + ) + SELECT + collection_time, + CASE WHEN interval_seconds > 0 THEN total_elapsed_ms / interval_seconds ELSE 0 END AS elapsed_ms_per_second, + CASE WHEN interval_seconds > 0 THEN CAST(total_executions AS DOUBLE PRECISION) / interval_seconds ELSE 0 END AS executions_per_second + FROM raw + ORDER BY collection_time + """; + + /// <summary>Query Store duration trend: execution_count·avg_duration_us → ms/sec + executions/sec.</summary> + public const string QueryStoreDurationTrendSql = """ + WITH raw AS + ( + SELECT + collection_time, + SUM(execution_count * avg_duration_us / 1000.0) AS total_duration_ms, + SUM(execution_count) AS total_executions, + extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (ORDER BY collection_time)))) AS interval_seconds + FROM query_store_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND ($4::text[] IS NULL OR database_name = ANY($4)) + GROUP BY collection_time + ) + SELECT + collection_time, + CASE WHEN interval_seconds > 0 THEN total_duration_ms / interval_seconds ELSE 0 END AS duration_ms_per_second, + CASE WHEN interval_seconds > 0 THEN CAST(total_executions AS DOUBLE PRECISION) / interval_seconds ELSE 0 END AS executions_per_second + FROM raw + ORDER BY collection_time + """; + + /// <summary>Execution-count trend: executions/sec per collection snapshot from query_stats.</summary> + public const string ExecutionCountTrendSql = """ + WITH raw AS + ( + SELECT + collection_time, + SUM(delta_execution_count) AS total_executions, + extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (ORDER BY collection_time)))) AS interval_seconds + FROM query_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND ($4::text[] IS NULL OR database_name = ANY($4)) + GROUP BY collection_time + ) + SELECT + collection_time, + CASE WHEN interval_seconds > 0 THEN CAST(total_executions AS DOUBLE PRECISION) / interval_seconds ELSE 0 END AS executions_per_second + FROM raw + ORDER BY collection_time + """; + + /// <summary>Query-stats duration trend over [<paramref name="startUtc"/>, <paramref name="endUtc"/>].</summary> + public Task<List<QueryTrendPoint>> GetQueryDurationTrendAsync( + int serverId, DateTime startUtc, DateTime endUtc, IReadOnlyList<string>? databaseNames = null, CancellationToken cancellationToken = default) + => ReadDurationTrendAsync(QueryDurationTrendSql, serverId, startUtc, endUtc, databaseNames, cancellationToken); + + /// <summary>Procedure-stats duration trend over the window.</summary> + public Task<List<QueryTrendPoint>> GetProcedureDurationTrendAsync( + int serverId, DateTime startUtc, DateTime endUtc, IReadOnlyList<string>? databaseNames = null, CancellationToken cancellationToken = default) + => ReadDurationTrendAsync(ProcedureDurationTrendSql, serverId, startUtc, endUtc, databaseNames, cancellationToken); + + /// <summary>Query Store duration trend over the window.</summary> + public Task<List<QueryTrendPoint>> GetQueryStoreDurationTrendAsync( + int serverId, DateTime startUtc, DateTime endUtc, IReadOnlyList<string>? databaseNames = null, CancellationToken cancellationToken = default) + => ReadDurationTrendAsync(QueryStoreDurationTrendSql, serverId, startUtc, endUtc, databaseNames, cancellationToken); + + /// <summary> + /// Shared reader for the three duration trends (same column shape: collection_time, + /// value/sec, executions/sec). Value = column 1; ExecutionCount = column 2 truncated to long + /// (Lite's <c>(long)ToDouble(…)</c>). + /// </summary> + private async Task<List<QueryTrendPoint>> ReadDurationTrendAsync( + string sql, int serverId, DateTime startUtc, DateTime endUtc, IReadOnlyList<string>? databaseNames, CancellationToken cancellationToken) + { + var items = new List<QueryTrendPoint>(); + + await using var command = _dataSource.CreateCommand(sql); + AddServerWindowParameters(command, serverId, startUtc, endUtc); + command.Parameters.Add(DatabaseFilterParameter(databaseNames)); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new QueryTrendPoint + { + CollectionTime = reader.GetDateTime(0), + Value = reader.IsDBNull(1) ? 0 : Convert.ToDouble(reader.GetValue(1)), + ExecutionCount = reader.IsDBNull(2) ? 0 : (long)Convert.ToDouble(reader.GetValue(2)), + }); + } + + return items; + } + + /// <summary>Execution-count trend over the window (single value column; no ExecutionCount).</summary> + public async Task<List<QueryTrendPoint>> GetExecutionCountTrendAsync( + int serverId, DateTime startUtc, DateTime endUtc, IReadOnlyList<string>? databaseNames = null, CancellationToken cancellationToken = default) + { + var items = new List<QueryTrendPoint>(); + + await using var command = _dataSource.CreateCommand(ExecutionCountTrendSql); + AddServerWindowParameters(command, serverId, startUtc, endUtc); + command.Parameters.Add(DatabaseFilterParameter(databaseNames)); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new QueryTrendPoint + { + CollectionTime = reader.GetDateTime(0), + Value = reader.IsDBNull(1) ? 0 : Convert.ToDouble(reader.GetValue(1)), + }); + } + + return items; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.RunningJobs.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.RunningJobs.cs new file mode 100644 index 000000000..0e615bfb0 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.RunningJobs.cs @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Viewer; + +public sealed partial class ViewerDataService +{ + /// <summary> + /// The Running Jobs grid's read — Lite's <c>GetRunningJobsAsync</c> ported to Postgres verbatim + /// (runs against the <c>v_running_jobs</c> passthrough view): the latest snapshot only (the + /// <c>collection_time = MAX(...)</c> self-subquery), ordered by current duration descending. All the + /// derived columns (avg/p95/percent-of-average/is-running-long) are collector-side, so the read is + /// a plain projection. + /// $1 server_id. + /// </summary> + public const string RunningJobsSql = """ + SELECT + collection_time, + job_name, + job_id, + job_enabled, + start_time, + current_duration_seconds, + avg_duration_seconds, + p95_duration_seconds, + successful_run_count, + is_running_long, + percent_of_average + FROM v_running_jobs + WHERE server_id = $1 + AND collection_time = ( + SELECT MAX(collection_time) + FROM v_running_jobs + WHERE server_id = $1 + ) + ORDER BY current_duration_seconds DESC + """; + + /// <summary> + /// The msdb-access warning derivation. Lite drives its "Running Jobs requires msdb access" banner + /// from a live per-connection probe (<c>_hasMsdbAccess</c>) the viewer has no equivalent for — the + /// viewer never touches the monitored SQL Server. Instead we read the running_jobs collector's most + /// recent run outcome from the store: a login lacking msdb access makes the collector fail, which + /// the Darling service records with Lite's status vocabulary (SUCCESS / PERMISSIONS / ERROR). The + /// UI shows the banner when this latest status is PERMISSIONS or ERROR. collector_name is the + /// literal 'running_jobs' (RunningJobsCollector.Name). + /// $1 server_id. + /// </summary> + public const string RunningJobsCollectorStatusSql = """ + SELECT status + FROM collection_log + WHERE server_id = $1 + AND collector_name = 'running_jobs' + ORDER BY collection_time DESC + LIMIT 1 + """; + + /// <summary>The latest snapshot of running jobs for one server (latest collection_time only).</summary> + public async Task<List<RunningJobRow>> GetRunningJobsAsync(int serverId, CancellationToken cancellationToken = default) + { + var items = new List<RunningJobRow>(); + + await using var command = _dataSource.CreateCommand(RunningJobsSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new RunningJobRow + { + CollectionTime = reader.GetDateTime(0), + JobName = reader.GetString(1), + JobId = reader.GetString(2), + JobEnabled = reader.GetBoolean(3), + StartTime = reader.GetDateTime(4), + CurrentDurationSeconds = reader.GetInt64(5), + AvgDurationSeconds = reader.GetInt64(6), + P95DurationSeconds = reader.GetInt64(7), + SuccessfulRunCount = reader.GetInt64(8), + IsRunningLong = reader.GetBoolean(9), + PercentOfAverage = reader.IsDBNull(10) ? null : reader.GetDecimal(10) + }); + } + + return items; + } + + /// <summary> + /// The running_jobs collector's most recent run status (SUCCESS / PERMISSIONS / ERROR), or null + /// when it has never run for this server. Feeds the Running Jobs tab's msdb-access banner. + /// </summary> + public async Task<string?> GetLatestRunningJobsCollectorStatusAsync(int serverId, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(RunningJobsCollectorStatusSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + var result = await command.ExecuteScalarAsync(cancellationToken); + return result as string; + } +} + +/// <summary> +/// One row of the Running Jobs grid — a currently-running SQL Agent job with its historical duration +/// comparison. Copied VERBATIM from Lite's <c>RunningJobRow</c> (LocalDataService.RunningJobs.cs): the +/// duration/percent/running-long display columns are all collector-side computations, and +/// <see cref="StartTimeLocal"/> routes the store's naive-UTC start_time through +/// <see cref="ViewerTimeHelper.ForDisplay"/> — the viewer's mode-aware Server/Local/UTC conversion every +/// other Darling timestamp also uses. +/// </summary> +public class RunningJobRow +{ + public DateTime CollectionTime { get; set; } + public string JobName { get; set; } = ""; + public string JobId { get; set; } = ""; + public bool JobEnabled { get; set; } + public DateTime StartTime { get; set; } + public long CurrentDurationSeconds { get; set; } + public long AvgDurationSeconds { get; set; } + public long P95DurationSeconds { get; set; } + public long SuccessfulRunCount { get; set; } + public bool IsRunningLong { get; set; } + public decimal? PercentOfAverage { get; set; } + + public string StartTimeLocal => ViewerTimeHelper.ForDisplay(StartTime).ToString("yyyy-MM-dd HH:mm:ss"); + + public string CurrentDurationFormatted => FormatDuration(CurrentDurationSeconds); + public string AvgDurationFormatted => FormatDuration(AvgDurationSeconds); + public string P95DurationFormatted => FormatDuration(P95DurationSeconds); + + public string PercentOfAverageFormatted => PercentOfAverage.HasValue + ? $"{PercentOfAverage:F0}%" + : "N/A"; + + public string RunningLongDisplay => IsRunningLong ? "Yes" : ""; + + private static string FormatDuration(long seconds) + { + if (seconds < 60) return $"{seconds}s"; + if (seconds < 3600) return $"{seconds / 60}m {seconds % 60}s"; + return $"{seconds / 3600}h {(seconds % 3600) / 60}m"; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ServerStatus.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ServerStatus.cs new file mode 100644 index 000000000..137fa0066 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ServerStatus.cs @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The lightweight store reads that drive the shell chrome ported from Lite's MainWindow: the sidebar +/// status dot (one <c>v_collection_log</c> freshness query for every server at once) and the status bar's +/// database-size field (<c>pg_database_size</c>). Both are single round-trips so they can run on the +/// refresh timers without weighing anything down; the SQL lives in public constants so tests can pin the +/// load-bearing clauses without a live Postgres. +/// </summary> +public sealed partial class ViewerDataService +{ + /// <summary> + /// Newest collection time per server across all collectors, in one pass — the sidebar dots and the + /// status bar's collection field derive freshness from this (the same <c>MAX(collection_time)</c> the + /// Overview cards use per server, so a dot and its card agree). Timestamps are the store's naive UTC. + /// Excludes <c>server_id = 0</c>, the fleet-level retention run-record sentinel + /// (<c>DarlingObservability.FleetServerId</c>) — it is not a real server, so it must not appear as a + /// phantom key a future key-iterating consumer could render as "server 0". + /// </summary> + public const string ServerFreshnessSql = @" +SELECT server_id, MAX(collection_time) +FROM v_collection_log +WHERE server_id <> 0 +GROUP BY server_id"; + + /// <summary>The store's on-disk size in bytes (status-bar Database field). No parameters.</summary> + public const string StoreSizeSql = "SELECT pg_database_size(current_database())"; + + /// <summary> + /// Reads MAX(collection_time) for every server in a single query, keyed by server_id. A server with no + /// collection rows simply isn't in the dictionary (the caller treats a miss as "no collection" → Offline). + /// </summary> + public async Task<Dictionary<int, DateTime>> GetServerFreshnessAsync(CancellationToken cancellationToken = default) + { + var result = new Dictionary<int, DateTime>(); + + await using var command = _dataSource.CreateCommand(ServerFreshnessSql); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + if (!reader.IsDBNull(1)) + { + result[reader.GetInt32(0)] = reader.GetDateTime(1); + } + } + + return result; + } + + /// <summary>The store database's size in bytes, or null when it can't be read.</summary> + public async Task<long?> GetStoreSizeBytesAsync(CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(StoreSizeSql); + var result = await command.ExecuteScalarAsync(cancellationToken); + return result is null || result == DBNull.Value ? null : Convert.ToInt64(result); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ServiceConfig.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ServiceConfig.cs new file mode 100644 index 000000000..585da6cee --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ServiceConfig.cs @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The viewer's control-plane reads + writes of <c>config.config_service</c> — the single-row (id=1) +/// service-wide flags the Stage-1 <c>StoreConfigProvider</c> honors: <c>capture_plans</c> (global plan +/// capture), <c>mcp_enabled</c>/<c>mcp_port</c> (the service's embedded MCP), <c>web_enabled</c>/<c>web_port</c> +/// (the read-only web dashboard, #1562), and the observed <c>paused</c> state. Saving the Settings window's +/// Data Collection + MCP + Web sections now drives the running service. +/// +/// <para><b>Write shape is UPDATE-only, never INSERT.</b> The service SEEDS this row LAST in its startup +/// (its presence marks the seed complete — the reload gate keys on <c>config_version</c>); if the viewer +/// INSERTed it early, the service would skip seeding the OTHER desired-state sections from darling.json. So +/// the viewer only ever <c>UPDATE ... WHERE id = 1</c> (a no-op affecting zero rows on an unseeded store — +/// correct: nothing to change until the service has seeded). The BEFORE-UPDATE self-bump trigger increments +/// <c>config_version</c> so the service reloads on its next sweep. <c>paused</c> is NOT written here — it is a +/// <c>pause</c>/<c>resume</c> command (see <c>ViewerDataService.ControlCommands.cs</c>) so the imperative +/// intent flows through the command plane; the viewer only READS it here to reflect the toggle's state.</para> +/// +/// <para>Same discipline as the sibling write partials: public-const SQL (Darling.Tests pin the shape + +/// parity with <c>StoreConfigProvider.ReadServiceRowAsync</c>), bound <c>$N</c> parameters, routed through +/// <see cref="ExecuteWriteAsync"/> so a read-only seat degrades to <see cref="ViewerReadOnlyException"/>.</para> +/// </summary> +public sealed partial class ViewerDataService +{ + /// <summary>The service-wide flags (id=1): paused + the three viewer-owned toggles. Column order matches + /// the service's <c>ReadServiceRowAsync</c> prefix.</summary> + public const string ServiceConfigSelectSql = + "SELECT paused, capture_plans, mcp_enabled, mcp_port, web_enabled, web_port FROM config_service WHERE id = 1"; + + /// <summary>Updates ONLY the viewer-owned service flags on the seeded row (never <c>paused</c> — a + /// command). The self-bump trigger fires <c>config_version</c>. $1 capture_plans, $2 mcp_enabled, $3 mcp_port, + /// $4 web_enabled, $5 web_port.</summary> + public const string ServiceConfigUpdateFlagsSql = @" +UPDATE config_service +SET capture_plans = $1, mcp_enabled = $2, mcp_port = $3, web_enabled = $4, web_port = $5 +WHERE id = 1"; + + /// <summary>Reads the service-wide flags, or null when the store has not seeded <c>config_service</c> yet + /// (a pre-Stage-1 store, or the service has not started) — the Settings window then shows viewer defaults + /// and the Pause toggle reflects "unknown/not paused".</summary> + public async Task<ServiceConfigRow?> GetServiceConfigAsync(CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(ServiceConfigSelectSql); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + { + return null; + } + + return new ServiceConfigRow + { + Paused = reader.GetBoolean(0), + CapturePlans = reader.GetBoolean(1), + McpEnabled = reader.GetBoolean(2), + McpPort = reader.GetInt32(3), + WebEnabled = reader.GetBoolean(4), + WebPort = reader.GetInt32(5), + }; + } + + /// <summary> + /// Whether the service reports itself paused right now (reflected on the Pause/Resume toggle). Fails safe + /// to <c>false</c> on ANY non-cancellation error (a pre-V17 store, a transient blip) — mirrors + /// <see cref="IsConfigSeededAsync"/>'s posture so the toggle degrades to "Running" rather than throwing. + /// </summary> + public async Task<bool> IsServicePausedAsync(CancellationToken cancellationToken = default) + { + try + { + var row = await GetServiceConfigAsync(cancellationToken); + return row?.Paused ?? false; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return false; + } + } + + /// <summary>Updates the viewer-owned service flags (Settings window Save — MCP + web dashboard + global plan + /// capture). A no-op on an unseeded store (zero rows). Read-only seats throw <see cref="ViewerReadOnlyException"/>.</summary> + public async Task UpdateServiceFlagsAsync( + bool capturePlans, bool mcpEnabled, int mcpPort, bool webEnabled, int webPort, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(ServiceConfigUpdateFlagsSql); + command.Parameters.Add(new NpgsqlParameter<bool> { TypedValue = capturePlans }); + command.Parameters.Add(new NpgsqlParameter<bool> { TypedValue = mcpEnabled }); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = mcpPort }); + command.Parameters.Add(new NpgsqlParameter<bool> { TypedValue = webEnabled }); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = webPort }); + await ExecuteWriteAsync(command, cancellationToken); + } + + /// <summary> + /// Touches <c>config_service</c> to bump the <c>config_version</c> reload beacon WITHOUT changing any flag + /// (mirrors the service's <c>reload</c> command SQL) — the self-bump trigger increments the beacon, so the + /// worker re-reads the store and re-<c>LoadAsync()</c>es its caches on the next sweep. This is how a write + /// to a <c>config.*</c> table that carries NO statement-level bump trigger — notably + /// <c>config_mute_rules</c> (F16): its rules live in the service's in-memory <c>MuteRuleService</c> cache, + /// reloaded only on the beacon — still takes effect live. A no-op on an unseeded store (zero rows). + /// </summary> + public const string ConfigReloadSignalSql = + "UPDATE config_service SET updated_at = (now() AT TIME ZONE 'UTC') WHERE id = 1"; + + /// <summary>Bumps the reload beacon (see <see cref="ConfigReloadSignalSql"/>) so the service re-reads the + /// store on its next sweep — used after a write to a config table without its own bump trigger.</summary> + public async Task SignalConfigReloadAsync(CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(ConfigReloadSignalSql); + await ExecuteWriteAsync(command, cancellationToken); + } +} + +/// <summary>The viewer-visible <c>config_service</c> flags: the observed <see cref="Paused"/> state plus the +/// viewer-owned toggles (<see cref="CapturePlans"/>, <see cref="McpEnabled"/>/<see cref="McpPort"/>, and the +/// web dashboard <see cref="WebEnabled"/>/<see cref="WebPort"/>). Defaults mirror the V17/V30 DDL.</summary> +public sealed class ServiceConfigRow +{ + public bool Paused { get; set; } + public bool CapturePlans { get; set; } = true; + public bool McpEnabled { get; set; } + public int McpPort { get; set; } = 5152; + public bool WebEnabled { get; set; } + public int WebPort { get; set; } = 5153; +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.SessionStats.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.SessionStats.cs new file mode 100644 index 000000000..bd19333b0 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.SessionStats.cs @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// One point on the Session Stats trend: a single server-wide session-summary snapshot from +/// <c>session_summary_stats</c> — the status-count breakdown that drives the chart's seven series plus the +/// attribution columns (top application / top host / distinct databases) the summary panel shows. +/// </summary> +/// <remarks> +/// <b>Source note (verified).</b> This is the server-wide <c>session_summary_stats</c> table (view +/// <c>v_session_summary_stats</c>), the <see cref="PerformanceMonitor.Collectors.SessionSummaryStatsCollector"/> +/// that is the 1:1 Dashboard-parity port of <c>install/36_collect_session_stats.sql</c> — one aggregate row +/// per collection with all twelve columns the Dashboard's <c>SessionStatsItem</c> carries. It is deliberately +/// NOT the per-application <c>session_stats</c> table (view <c>v_session_stats</c>, one row per +/// <c>program_name</c>) that FinOps' Application Connections read uses — that table has no +/// background/idle/waiting-for-memory/host/database columns, so it cannot feed the Dashboard's chart or +/// summary. The names are intentionally distinct so the two never collide. +/// </remarks> +public sealed record SessionStatsPoint( + DateTime CollectionTime, + int TotalSessions, + int RunningSessions, + int SleepingSessions, + int BackgroundSessions, + int DormantSessions, + int IdleSessionsOver30Min, + int SessionsWaitingForMemory, + int DatabasesWithConnections, + string? TopApplicationName, + int? TopApplicationConnections, + string? TopHostName, + int? TopHostConnections) : ISessionStatsPoint; + +public sealed partial class ViewerDataService +{ + /// <summary> + /// The Session Stats trend read: every server-wide session-summary snapshot in the settable window, + /// ordered oldest-first, so the chart plots the seven status counts over time and the summary panel + /// reads the latest point's attribution columns (the same shape the Dashboard's + /// <c>GetSessionStatsAsync</c> returns). Runs on the <c>v_session_summary_stats</c> passthrough view; + /// both bounds are inclusive (<c>>= $2 AND <= $3</c>, naive UTC via + /// <see cref="AddWindowParameters"/>). $1 server_id, $2 window start, $3 window end. + /// </summary> + public const string SessionStatsSql = """ + SELECT + collection_time, + total_sessions, + running_sessions, + sleeping_sessions, + background_sessions, + dormant_sessions, + idle_sessions_over_30min, + sessions_waiting_for_memory, + databases_with_connections, + top_application_name, + top_application_connections, + top_host_name, + top_host_connections + FROM v_session_summary_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + ORDER BY collection_time + """; + + /// <summary>The server-wide session-summary snapshots over the window, oldest-first (empty when none).</summary> + public async Task<List<SessionStatsPoint>> GetSessionStatsAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var result = new List<SessionStatsPoint>(); + + await using var command = _dataSource.CreateCommand(SessionStatsSql); + AddWindowParameters(command, serverId, startUtc, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + result.Add(new SessionStatsPoint( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : reader.GetInt32(1), + reader.IsDBNull(2) ? 0 : reader.GetInt32(2), + reader.IsDBNull(3) ? 0 : reader.GetInt32(3), + reader.IsDBNull(4) ? 0 : reader.GetInt32(4), + reader.IsDBNull(5) ? 0 : reader.GetInt32(5), + reader.IsDBNull(6) ? 0 : reader.GetInt32(6), + reader.IsDBNull(7) ? 0 : reader.GetInt32(7), + reader.IsDBNull(8) ? 0 : reader.GetInt32(8), + reader.IsDBNull(9) ? null : reader.GetString(9), + reader.IsDBNull(10) ? null : reader.GetInt32(10), + reader.IsDBNull(11) ? null : reader.GetString(11), + reader.IsDBNull(12) ? null : reader.GetInt32(12))); + } + + return result; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.SystemEvents.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.SystemEvents.cs new file mode 100644 index 000000000..cb1287f7d --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.SystemEvents.cs @@ -0,0 +1,688 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/* + * System Events tab reads (system_health parity, Stage 2b). Each warning category is a parse-on-read + * shred: the data service fetches the raw event_xml for one XE event type over the tab window from + * v_system_health_events, hands each blob to the Common SystemHealthParser (Stage 2a) to shred into the + * category record, then keeps only the SIGNIFICANT rows via SystemEventSignificance (Stage 2b, + * sp_HealthParser's per-category WHERE predicates). Three categories (Memory Conditions, CPU Tasks, I/O + * Issues) share the single sp_server_diagnostics_component_result feed and split on the event's component. + * No persisted parsed tables — the raw table + the parser are the whole pipeline. The XML shred is + * CPU-bound, so it runs off the UI thread (Task.Run), mirroring the deadlock-graph parse. + * + * The row view-models below are flat projections of the Common records (all shredded columns preserved, + * so the grids faithfully match sp_HealthParser's per-category table shape) plus an EventTimeLocal + * display string — the machine-local render of the event's naive-UTC XE @timestamp, via the same + * ViewerTimeHelper.ForDisplay the deadlock / blocked-process grids use. Only SevereError adds a + * resolved DatabaseName (see ResolveDatabaseName). + */ + +/// <summary>Shared machine-local render of a naive-UTC event timestamp for the System Events grids.</summary> +internal static class SystemEventRowFormat +{ + public static string Local(DateTime? utc) => + utc is { } t ? ViewerTimeHelper.ForDisplay(t).ToString("yyyy-MM-dd HH:mm:ss") : ""; +} + +/// <summary>One scheduler-monitor WARNING row (Scheduler Issues sub-tab). Mirrors <c>*_SchedulerIssues</c>.</summary> +public sealed class SchedulerIssueRow(SchedulerIssueRecord record) +{ + public string EventTimeLocal => SystemEventRowFormat.Local(record.EventTime); + public int? SchedulerId => record.SchedulerId; + public int? CpuId => record.CpuId; + public string? Status => record.Status; + public bool? IsOnline => record.IsOnline; + public bool? IsRunnable => record.IsRunnable; + public bool? IsRunning => record.IsRunning; + public long? NonYieldingTimeMs => record.NonYieldingTimeMs; + public long? ThreadQuantumMs => record.ThreadQuantumMs; +} + +/// <summary> +/// One severe-error row (Severe Errors sub-tab). Mirrors <c>*_SevereErrors</c>. <see cref="DatabaseName"/> +/// is resolved from the store's collected database_id↔database_name mapping (see +/// <see cref="ViewerDataService.ResolveDatabaseName"/>) — Stage 2a's DB-free shred left it null. +/// </summary> +public sealed class SevereErrorRow(SevereErrorRecord record, string databaseName) +{ + public string EventTimeLocal => SystemEventRowFormat.Local(record.EventTime); + public int? ErrorNumber => record.ErrorNumber; + public int? Severity => record.Severity; + public int? State => record.State; + public int? DatabaseId => record.DatabaseId; + public string DatabaseName => databaseName; + public string? Message => record.Message; +} + +/// <summary>One RESOURCE_MEMPHYSICAL_LOW memory-conditions snapshot (Memory Conditions sub-tab). Mirrors <c>*_MemoryConditions</c>.</summary> +public sealed class MemoryConditionsRow(MemoryConditionsRecord record) +{ + public string EventTimeLocal => SystemEventRowFormat.Local(record.EventTime); + public string? LastNotification => record.LastNotification; + public long? OutOfMemoryExceptions => record.OutOfMemoryExceptions; + public bool? IsAnyPoolOutOfMemory => record.IsAnyPoolOutOfMemory; + public long? ProcessOutOfMemoryPeriod => record.ProcessOutOfMemoryPeriod; + public string? Name => record.Name; + public long? AvailablePhysicalMemoryGb => record.AvailablePhysicalMemoryGb; + public long? AvailableVirtualMemoryGb => record.AvailableVirtualMemoryGb; + public long? AvailablePagingFileGb => record.AvailablePagingFileGb; + public long? WorkingSetGb => record.WorkingSetGb; + public long? PercentOfCommittedMemoryInWs => record.PercentOfCommittedMemoryInWs; + public long? PageFaults => record.PageFaults; + public long? SystemPhysicalMemoryHigh => record.SystemPhysicalMemoryHigh; + public long? SystemPhysicalMemoryLow => record.SystemPhysicalMemoryLow; + public long? ProcessPhysicalMemoryLow => record.ProcessPhysicalMemoryLow; + public long? ProcessVirtualMemoryLow => record.ProcessVirtualMemoryLow; + public long? VmReservedGb => record.VmReservedGb; + public long? VmCommittedGb => record.VmCommittedGb; + public long? LockedPagesAllocated => record.LockedPagesAllocated; + public long? LargePagesAllocated => record.LargePagesAllocated; + public long? EmergencyMemoryGb => record.EmergencyMemoryGb; + public long? EmergencyMemoryInUseGb => record.EmergencyMemoryInUseGb; + public long? TargetCommittedGb => record.TargetCommittedGb; + public long? CurrentCommittedGb => record.CurrentCommittedGb; + public long? PagesAllocated => record.PagesAllocated; + public long? PagesReserved => record.PagesReserved; + public long? PagesFree => record.PagesFree; + public long? PagesInUse => record.PagesInUse; + public long? PageAllocPotential => record.PageAllocPotential; + public long? NumaGrowthPhase => record.NumaGrowthPhase; + public long? LastOomFactor => record.LastOomFactor; + public long? LastOsError => record.LastOsError; +} + +/// <summary>One RESOURCE_MEMPHYSICAL_LOW memory-broker ratio change (Memory Broker sub-tab). Mirrors <c>*_MemoryBroker</c>.</summary> +public sealed class MemoryBrokerRow(MemoryBrokerRecord record) +{ + public string EventTimeLocal => SystemEventRowFormat.Local(record.EventTime); + public long? BrokerId => record.BrokerId; + public long? PoolMetadataId => record.PoolMetadataId; + public long? DeltaTime => record.DeltaTime; + public long? MemoryRatio => record.MemoryRatio; + public long? NewTarget => record.NewTarget; + public long? Overall => record.Overall; + public long? Rate => record.Rate; + public long? CurrentlyPredicated => record.CurrentlyPredicated; + public long? CurrentlyAllocated => record.CurrentlyAllocated; + public long? PreviouslyAllocated => record.PreviouslyAllocated; + public string? Broker => record.Broker; + public string? Notification => record.Notification; +} + +/// <summary>One memory-node OOM row (Memory Node OOM sub-tab). Mirrors <c>*_MemoryNodeOOM</c> (never gated — every OOM shows).</summary> +public sealed class MemoryNodeOomRow(MemoryNodeOomRecord record) +{ + public string EventTimeLocal => SystemEventRowFormat.Local(record.EventTime); + public long? NodeId => record.NodeId; + public long? MemoryNodeId => record.MemoryNodeId; + public long? MemoryUtilizationPct => record.MemoryUtilizationPct; + public long? TotalPhysicalMemoryKb => record.TotalPhysicalMemoryKb; + public long? AvailablePhysicalMemoryKb => record.AvailablePhysicalMemoryKb; + public long? TotalPageFileKb => record.TotalPageFileKb; + public long? AvailablePageFileKb => record.AvailablePageFileKb; + public long? TotalVirtualAddressSpaceKb => record.TotalVirtualAddressSpaceKb; + public long? AvailableVirtualAddressSpaceKb => record.AvailableVirtualAddressSpaceKb; + public long? TargetKb => record.TargetKb; + public long? ReservedKb => record.ReservedKb; + public long? CommittedKb => record.CommittedKb; + public decimal? SharedCommittedKb => record.SharedCommittedKb; + public long? AweKb => record.AweKb; + public long? PagesKb => record.PagesKb; + public string? FailureType => record.FailureType; + public long? FailureValue => record.FailureValue; + public long? Resources => record.Resources; + public string? FactorText => record.FactorText; + public long? FactorValue => record.FactorValue; + public long? LastError => record.LastError; + public long? PoolMetadataId => record.PoolMetadataId; + public string? IsProcessInJob => record.IsProcessInJob; + public string? IsSystemPhysicalMemoryHigh => record.IsSystemPhysicalMemoryHigh; + public string? IsSystemPhysicalMemoryLow => record.IsSystemPhysicalMemoryLow; + public string? IsProcessPhysicalMemoryLow => record.IsProcessPhysicalMemoryLow; + public string? IsProcessVirtualMemoryLow => record.IsProcessVirtualMemoryLow; +} + +/// <summary>One significant-wait row (Significant Waits sub-tab). Mirrors <c>*_SignificantWaits</c>.</summary> +public sealed class SignificantWaitRow(SignificantWaitRecord record) +{ + public string EventTimeLocal => SystemEventRowFormat.Local(record.EventTime); + public string? WaitType => record.WaitType; + public long? DurationMs => record.DurationMs; + public long? SignalDurationMs => record.SignalDurationMs; + public string? WaitResource => record.WaitResource; + public int? SessionId => record.SessionId; + public string? QueryText => record.QueryText; +} + +/// <summary>One CPU-task-details row (CPU Tasks sub-tab). Mirrors <c>*_CPUTasks</c> (QUERY_PROCESSING component).</summary> +public sealed class CpuTasksRow(CpuTasksRecord record) +{ + public string EventTimeLocal => SystemEventRowFormat.Local(record.EventTime); + public string? State => record.State; + public long? MaxWorkers => record.MaxWorkers; + public long? WorkersCreated => record.WorkersCreated; + public long? WorkersIdle => record.WorkersIdle; + public long? TasksCompletedWithinInterval => record.TasksCompletedWithinInterval; + public long? PendingTasks => record.PendingTasks; + public long? OldestPendingTaskWaitingTime => record.OldestPendingTaskWaitingTime; + public bool? HasUnresolvableDeadlockOccurred => record.HasUnresolvableDeadlockOccurred; + public bool? HasDeadlockedSchedulersOccurred => record.HasDeadlockedSchedulersOccurred; + public bool? DidBlockingOccur => record.DidBlockingOccur; +} + +/// <summary>One potential-IO-issue row (I/O Issues sub-tab). Mirrors <c>*_IOIssues</c> (IO_SUBSYSTEM component).</summary> +public sealed class IoIssuesRow(IoIssuesRecord record) +{ + public string EventTimeLocal => SystemEventRowFormat.Local(record.EventTime); + public string? State => record.State; + public long? IoLatchTimeouts => record.IoLatchTimeouts; + public long? IntervalLongIos => record.IntervalLongIos; + public long? TotalLongIos => record.TotalLongIos; + public long? LongestPendingRequestsDurationMs => record.LongestPendingRequestsDurationMs; + public string? LongestPendingRequestsFilePath => record.LongestPendingRequestsFilePath; +} + +/// <summary> +/// One significant Default Trace event (the Default Trace sub-tab) — the always-on server events no DMV +/// captures (file auto-grow/shrink stalls, severe ErrorLog writes, schema DDL, security audits, Server +/// Memory Change), classified via <see cref="SystemEventSignificance.ClassifyDefaultTraceEvent"/>. Unlike the +/// system_health rows, whose <c>event_time</c> is the UTC XE @timestamp, the Default Trace StartTime is the +/// monitored server's LOCAL wall clock — <see cref="ViewerDataService.GetDefaultTraceEventsAsync"/> de-skews +/// it to naive-UTC in SQL (via <c>server_properties.utc_offset_minutes</c>) BEFORE it reaches this row, so +/// <see cref="EventTimeLocal"/> renders through the same <see cref="SystemEventRowFormat.Local"/> as every +/// other System Events grid and sorts consistently with them (the inverse of the MCP reader's bounds +/// conversion; mirrors CorrelatedTimelineLanesControl's CPU-lane de-skew). +/// </summary> +public sealed class DefaultTraceEventRow +{ + public DefaultTraceEventRow( + DateTime? eventTimeUtc, + DefaultTraceEventCategory category, + string? eventName, + string? databaseName, + string? objectName, + string? loginName, + string? hostName, + string? applicationName, + int? spid, + long? durationUs, + long? integerData, + int? severity, + int? errorNumber, + string? textData) + { + EventTimeLocal = SystemEventRowFormat.Local(eventTimeUtc); + Category = category.ToString(); + EventName = eventName; + DatabaseName = databaseName; + ObjectName = objectName; + LoginName = loginName; + HostName = hostName; + ApplicationName = applicationName; + Spid = spid; + /* Duration (ms) is meaningful for the auto-grow/shrink stalls; growth (MB) is the 8-KB page count on + those (IntegerData) converted, and only meaningful for that category. */ + DurationMs = durationUs.HasValue ? durationUs.Value / 1000.0m : (decimal?)null; + GrowthMb = category == DefaultTraceEventCategory.AutoGrowShrink && integerData.HasValue + ? integerData.Value * 8.0m / 1024.0m + : (decimal?)null; + Severity = severity; + ErrorNumber = errorNumber; + TextData = textData; + } + + public string EventTimeLocal { get; } + public string Category { get; } + public string? EventName { get; } + public string? DatabaseName { get; } + public string? ObjectName { get; } + public string? LoginName { get; } + public string? HostName { get; } + public string? ApplicationName { get; } + public int? Spid { get; } + public decimal? DurationMs { get; } + public decimal? GrowthMb { get; } + public int? Severity { get; } + public int? ErrorNumber { get; } + public string? TextData { get; } +} + +public sealed partial class ViewerDataService +{ + /// <summary> + /// Raw event_xml for one XE event type over the tab window, newest first. The System Events reads + /// window on <c>event_time</c> (the XE <c>@timestamp</c> — the event's real time, which for the ring- + /// buffer categories can lag when it was collected), not <c>collection_time</c>, so "last 24 hours" + /// means events that happened in the last 24 hours. Naive-UTC bounds (the store's timestamps are + /// <c>timestamp without time zone</c>). $1 server_id, $2/$3 window (naive UTC), $4 event_type. + /// </summary> + public const string SystemHealthEventsByTypeSql = """ + SELECT + event_xml + FROM v_system_health_events + WHERE server_id = $1 + AND event_time >= $2 + AND event_time <= $3 + AND event_type = $4 + AND event_xml IS NOT NULL + ORDER BY event_time DESC + """; + + /// <summary> + /// The server's latest database_id↔database_name mapping from the collected size-stats + /// (<c>DISTINCT ON (database_id)</c> keeps the most-recently-collected name for each id — handles a + /// dropped-and-recreated id). Feeds <see cref="ResolveDatabaseName"/> for the Severe Errors tab. + /// <c>database_size_stats</c> is the mapping source because it is the only collected table carrying + /// BOTH database_id and database_name for every online DB (system + user); <c>database_config</c> + /// carries the name but no id. $1 server_id. + /// </summary> + public const string DatabaseNameMapSql = """ + SELECT DISTINCT ON (database_id) + database_id, + database_name + FROM v_database_size_stats + WHERE server_id = $1 + ORDER BY database_id, collection_time DESC + """; + + /// <summary> + /// Resolves a severe-error <c>database_id</c> to a display name using the collected mapping. A null or + /// 0 id means "no database context" (error_reported often carries database_id 0; <c>DB_NAME(0)</c> is + /// NULL server-side too) → empty. A real id absent from the map (a database dropped before the latest + /// size-stats snapshot, or one never captured) is surfaced as its raw id rather than silently blanked. + /// </summary> + public static string ResolveDatabaseName(int? databaseId, IReadOnlyDictionary<int, string> databaseNameMap) + { + if (databaseId is not { } id || id == 0) + return string.Empty; + if (databaseNameMap.TryGetValue(id, out var name)) + return name; + return $"database_id {id}"; + } + + /// <summary>Loads the server's latest database_id → database_name map for Severe Errors DB resolution.</summary> + public async Task<Dictionary<int, string>> GetDatabaseNameMapAsync(int serverId, CancellationToken cancellationToken = default) + { + var map = new Dictionary<int, string>(); + + await using var command = _dataSource.CreateCommand(DatabaseNameMapSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + if (reader.IsDBNull(0) || reader.IsDBNull(1)) + continue; + map[reader.GetInt32(0)] = reader.GetString(1); + } + + return map; + } + + /// <summary>The four System Events parameters ($1 server_id, $2/$3 naive-UTC window, $4 XE event_type).</summary> + private static void AddSystemEventParameters(NpgsqlCommand command, int serverId, DateTime startUtc, DateTime endUtc, string eventType) + { + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified) }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified) }); + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = eventType }); + } + + /// <summary>Reads the raw event_xml blobs for one XE event type over the window.</summary> + private async Task<List<string>> ReadSystemHealthEventXmlAsync( + int serverId, DateTime startUtc, DateTime endUtc, string eventType, CancellationToken cancellationToken) + { + var xmls = new List<string>(); + + await using var command = _dataSource.CreateCommand(SystemHealthEventsByTypeSql); + AddSystemEventParameters(command, serverId, startUtc, endUtc, eventType); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + xmls.Add(reader.GetString(0)); + + return xmls; + } + + // ── Scheduler Issues ── + + /// <summary>Significant scheduler-monitor warnings (WARNING status) for the window, newest first.</summary> + public async Task<List<SchedulerIssueRow>> GetSchedulerIssuesAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var xmls = await ReadSystemHealthEventXmlAsync( + serverId, startUtc, endUtc, SystemHealthParser.SchedulerMonitorEvent, cancellationToken); + + return await Task.Run(() => + { + var rows = new List<SchedulerIssueRow>(); + foreach (var xml in xmls) + { + var record = SystemHealthParser.ParseSchedulerIssue(xml); + if (record != null && SystemEventSignificance.IsSignificant(record)) + rows.Add(new SchedulerIssueRow(record)); + } + return rows; + }, cancellationToken); + } + + // ── Severe Errors ── + + /// <summary> + /// Significant severe errors (severity >= 19, excluding the benign connection-reset numbers) for + /// the window, newest first, with database_id resolved to a name from the collected mapping. + /// </summary> + public async Task<List<SevereErrorRow>> GetSevereErrorsAsync( + int serverId, DateTime startUtc, DateTime endUtc, IReadOnlyList<string>? databaseNames = null, CancellationToken cancellationToken = default) + { + var mapTask = GetDatabaseNameMapAsync(serverId, cancellationToken); + var xmls = await ReadSystemHealthEventXmlAsync( + serverId, startUtc, endUtc, SystemHealthParser.ErrorReportedEvent, cancellationToken); + var map = await mapTask; + + /* #1319 database filter: severe_errors has no database_name column (the DB is resolved in C# from the + event's database_id via the collected id↔name map), so the filter is applied client-side on the + resolved name. A null/empty selection leaves every row (today's behavior). */ + var filter = databaseNames is { Count: > 0 } ? new HashSet<string>(databaseNames, StringComparer.OrdinalIgnoreCase) : null; + + return await Task.Run(() => + { + var rows = new List<SevereErrorRow>(); + foreach (var xml in xmls) + { + var record = SystemHealthParser.ParseSevereError(xml); + if (record != null && SystemEventSignificance.IsSignificant(record)) + { + var databaseName = ResolveDatabaseName(record.DatabaseId, map); + if (filter == null || filter.Contains(databaseName)) + rows.Add(new SevereErrorRow(record, databaseName)); + } + } + return rows; + }, cancellationToken); + } + + // ── Memory Conditions ── + + /// <summary>Significant memory-conditions snapshots (RESOURCE_MEMPHYSICAL_LOW) for the window, newest first.</summary> + public async Task<List<MemoryConditionsRow>> GetMemoryConditionsAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var xmls = await ReadSystemHealthEventXmlAsync( + serverId, startUtc, endUtc, SystemHealthParser.SpServerDiagnosticsEvent, cancellationToken); + + return await Task.Run(() => + { + var rows = new List<MemoryConditionsRow>(); + foreach (var xml in xmls) + { + // Only the RESOURCE component yields a record; other sp_server_diagnostics components parse to null. + var record = SystemHealthParser.ParseMemoryConditions(xml); + if (record != null && SystemEventSignificance.IsSignificant(record)) + rows.Add(new MemoryConditionsRow(record)); + } + return rows; + }, cancellationToken); + } + + // ── Memory Broker ── + + /// <summary>Significant memory-broker ratio changes (RESOURCE_MEMPHYSICAL_LOW) for the window, newest first.</summary> + public async Task<List<MemoryBrokerRow>> GetMemoryBrokerAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var xmls = await ReadSystemHealthEventXmlAsync( + serverId, startUtc, endUtc, SystemHealthParser.MemoryBrokerEvent, cancellationToken); + + return await Task.Run(() => + { + var rows = new List<MemoryBrokerRow>(); + foreach (var xml in xmls) + { + var record = SystemHealthParser.ParseMemoryBroker(xml); + if (record != null && SystemEventSignificance.IsSignificant(record)) + rows.Add(new MemoryBrokerRow(record)); + } + return rows; + }, cancellationToken); + } + + // ── Memory Node OOM ── + + /// <summary>Every memory-node OOM for the window, newest first (this category is never filtered).</summary> + public async Task<List<MemoryNodeOomRow>> GetMemoryNodeOomAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var xmls = await ReadSystemHealthEventXmlAsync( + serverId, startUtc, endUtc, SystemHealthParser.MemoryNodeOomEvent, cancellationToken); + + return await Task.Run(() => + { + var rows = new List<MemoryNodeOomRow>(); + foreach (var xml in xmls) + { + var record = SystemHealthParser.ParseMemoryNodeOom(xml); + if (record != null && SystemEventSignificance.IsSignificant(record)) + rows.Add(new MemoryNodeOomRow(record)); + } + return rows; + }, cancellationToken); + } + + // ── Significant Waits ── + + /// <summary> + /// Significant individual waits (real session, non-BACKUP statement, duration >= 500 ms, wait type + /// not on the idle-wait ignore list) for the window, newest first. + /// </summary> + public async Task<List<SignificantWaitRow>> GetSignificantWaitsAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var xmls = await ReadSystemHealthEventXmlAsync( + serverId, startUtc, endUtc, SystemHealthParser.WaitInfoEvent, cancellationToken); + + return await Task.Run(() => + { + var rows = new List<SignificantWaitRow>(); + foreach (var xml in xmls) + { + var record = SystemHealthParser.ParseSignificantWait(xml); + if (record != null && SystemEventSignificance.IsSignificant(record)) + rows.Add(new SignificantWaitRow(record)); + } + return rows; + }, cancellationToken); + } + + // ── CPU Tasks ── + + /// <summary> + /// Significant CPU-task warnings (QUERY_PROCESSING state WARNING with pendingTasks >= 10) for the + /// window, newest first. Reads the same sp_server_diagnostics feed as Memory Conditions / I/O Issues; + /// only the QUERY_PROCESSING component yields a record. + /// </summary> + public async Task<List<CpuTasksRow>> GetCpuTasksAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var xmls = await ReadSystemHealthEventXmlAsync( + serverId, startUtc, endUtc, SystemHealthParser.SpServerDiagnosticsEvent, cancellationToken); + + return await Task.Run(() => + { + var rows = new List<CpuTasksRow>(); + foreach (var xml in xmls) + { + // Only the QUERY_PROCESSING component yields a record; other sp_server_diagnostics components parse to null. + var record = SystemHealthParser.ParseCpuTasks(xml); + if (record != null && SystemEventSignificance.IsSignificant(record)) + rows.Add(new CpuTasksRow(record)); + } + return rows; + }, cancellationToken); + } + + // ── I/O Issues ── + + /// <summary> + /// Significant potential-IO issues (IO_SUBSYSTEM state WARNING) for the window, newest first. Each event + /// can carry several pending-request files, so the shred fans out to one row per file (durations summed); + /// only the IO_SUBSYSTEM component yields records. + /// </summary> + public async Task<List<IoIssuesRow>> GetIoIssuesAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var xmls = await ReadSystemHealthEventXmlAsync( + serverId, startUtc, endUtc, SystemHealthParser.SpServerDiagnosticsEvent, cancellationToken); + + return await Task.Run(() => + { + var rows = new List<IoIssuesRow>(); + foreach (var xml in xmls) + { + // A non-IO_SUBSYSTEM component (or an event with no pending request) shreds to no records. + foreach (var record in SystemHealthParser.ParseIoIssues(xml)) + { + if (SystemEventSignificance.IsSignificant(record)) + rows.Add(new IoIssuesRow(record)); + } + } + return rows; + }, cancellationToken); + } + + // ── System Health (Corruption + Contention counter charts) ── + + /// <summary> + /// Every SYSTEM-component health snapshot for the window, oldest first (time-series order for the + /// charts). Reads the same sp_server_diagnostics feed as Memory Conditions / CPU Tasks / I/O Issues; + /// only the SYSTEM component yields a record. Unlike the grid categories this applies NO significance + /// filter — the Dashboard's Corruption Events + Contention Events tabs plot every collected snapshot's + /// counters over time, so the viewer returns them all (records with no event time can't sit on a time + /// axis and are dropped). Both chart sub-tabs read this one list and select their own columns. + /// </summary> + public async Task<List<SystemHealthRecord>> GetSystemHealthAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var xmls = await ReadSystemHealthEventXmlAsync( + serverId, startUtc, endUtc, SystemHealthParser.SpServerDiagnosticsEvent, cancellationToken); + + return await Task.Run(() => + { + var records = new List<SystemHealthRecord>(); + foreach (var xml in xmls) + { + // Only the SYSTEM component yields a record; other sp_server_diagnostics components parse to null. + var record = SystemHealthParser.ParseSystemHealth(xml); + if (record != null && record.EventTime.HasValue) + records.Add(record); + } + records.Sort((a, b) => Nullable.Compare(a.EventTime, b.EventTime)); + return records; + }, cancellationToken); + } + + // ── Default Trace (always-on server events; the Default Trace sub-tab) ── + + /// <summary> + /// Significant Default Trace events for the window, newest first. Reads the BASE <c>default_trace_events</c> + /// table (no <c>v_*</c> view, like server_properties). The Default Trace StartTime is the monitored + /// server's LOCAL wall clock, so this DE-SKEWS <c>event_time</c> to naive-UTC in SQL — subtracting the + /// collected <c>server_properties.utc_offset_minutes</c> (single-row COALESCE CTE, 0 when none yet) — and + /// windows on that de-skewed UTC value against the naive-UTC bounds ($2/$3), so the returned timestamps + /// share the same UTC frame as the system_health rows and render/sort consistently on the tab. $1 + /// server_id, $2/$3 window (naive UTC). The ErrorLog severity gate is applied on read + /// (<see cref="SystemEventSignificance.IsSignificantDefaultTraceEvent"/>), the same significance surface + /// the tab uses everywhere else. + /// </summary> + public const string DefaultTraceEventsByWindowSql = """ + WITH svr AS ( + SELECT COALESCE(( + SELECT sp.utc_offset_minutes + FROM server_properties AS sp + WHERE sp.server_id = $1 + AND sp.utc_offset_minutes IS NOT NULL + ORDER BY sp.collection_time DESC + LIMIT 1), 0) AS offset_minutes + ) + SELECT + dte.event_time - make_interval(mins => svr.offset_minutes) AS event_time_utc, + dte.event_name, + dte.database_name, + dte.object_name, + dte.login_name, + dte.host_name, + dte.application_name, + dte.spid, + dte.duration_us, + dte.integer_data, + dte.severity, + dte.error_number, + dte.text_data + FROM default_trace_events AS dte, svr + WHERE dte.server_id = $1 + AND dte.event_time - make_interval(mins => svr.offset_minutes) >= $2 + AND dte.event_time - make_interval(mins => svr.offset_minutes) <= $3 + AND ($4::text[] IS NULL OR dte.database_name = ANY($4)) + ORDER BY event_time_utc DESC + """; + + /// <summary> + /// Significant Default Trace events for the window (server-local StartTime de-skewed to UTC in SQL), + /// gated by <see cref="SystemEventSignificance.IsSignificantDefaultTraceEvent"/> and tagged with their + /// category. The gate is cheap (a category + severity check), so it runs inline on the read rather than + /// off-thread like the XML-shredding system_health categories. + /// </summary> + public async Task<List<DefaultTraceEventRow>> GetDefaultTraceEventsAsync( + int serverId, DateTime startUtc, DateTime endUtc, IReadOnlyList<string>? databaseNames = null, CancellationToken cancellationToken = default) + { + var rows = new List<DefaultTraceEventRow>(); + + await using var command = _dataSource.CreateCommand(DefaultTraceEventsByWindowSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified) }); + command.Parameters.Add(new NpgsqlParameter<DateTime> { TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified) }); + command.Parameters.Add(DatabaseFilterParameter(databaseNames)); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var eventTimeUtc = reader.IsDBNull(0) ? (DateTime?)null : reader.GetDateTime(0); + var eventName = reader.IsDBNull(1) ? null : reader.GetString(1); + var severity = reader.IsDBNull(10) ? (int?)null : reader.GetInt32(10); + + if (!SystemEventSignificance.IsSignificantDefaultTraceEvent(eventName, severity)) + continue; + + rows.Add(new DefaultTraceEventRow( + eventTimeUtc, + SystemEventSignificance.ClassifyDefaultTraceEvent(eventName), + eventName, + reader.IsDBNull(2) ? null : reader.GetString(2), /* database_name */ + reader.IsDBNull(3) ? null : reader.GetString(3), /* object_name */ + reader.IsDBNull(4) ? null : reader.GetString(4), /* login_name */ + reader.IsDBNull(5) ? null : reader.GetString(5), /* host_name */ + reader.IsDBNull(6) ? null : reader.GetString(6), /* application_name */ + reader.IsDBNull(7) ? (int?)null : reader.GetInt32(7), /* spid */ + reader.IsDBNull(8) ? (long?)null : reader.GetInt64(8), /* duration_us */ + reader.IsDBNull(9) ? (long?)null : reader.GetInt64(9), /* integer_data */ + severity, + reader.IsDBNull(11) ? (int?)null : reader.GetInt32(11), /* error_number */ + reader.IsDBNull(12) ? null : reader.GetString(12))); /* text_data */ + } + + return rows; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.TempDb.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.TempDb.cs new file mode 100644 index 000000000..c67f3f1c4 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.TempDb.cs @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// One tempdb space-usage sample for the tempdb tab's usage and allocated-size charts — a mirror of +/// Lite's <c>TempDbRow</c> (<c>LocalDataService.TempDb.cs</c>). The usage chart plots the three +/// reserved-space series; the allocated chart plots <c>TotalReservedMb + UnallocatedMb</c>. The +/// session columns (<see cref="TotalSessionsUsingTempDb"/>/<see cref="TopSessionId"/>/ +/// <see cref="TopSessionTempDbMb"/>) are carried to keep the port of Lite's query column-for-column, +/// even though the three charts don't render them yet. +/// </summary> +public sealed record TempDbSample( + DateTime CollectionTime, + double UserObjectReservedMb, + double InternalObjectReservedMb, + double VersionStoreReservedMb, + double TotalReservedMb, + double UnallocatedMb, + long TotalSessionsUsingTempDb, + int TopSessionId, + double TopSessionTempDbMb); + +/// <summary> +/// One tempdb file's I/O-latency point — a collection time, the file name, and the read/write average +/// latencies (ms) for that collection. Mirror of Lite's <c>FileIoTrendPoint</c> as the tempdb file-I/O +/// chart consumes it; the chart sums read+write latency per file and cycles a color per file. +/// </summary> +public sealed record TempDbFileIoSample( + DateTime CollectionTime, + string FileName, + double AvgReadLatencyMs, + double AvgWriteLatencyMs); + +public sealed partial class ViewerDataService +{ + /// <summary> + /// The tempdb usage/size read — Lite's <c>GetTempDbTrendAsync</c> ported to Postgres. Reads + /// <c>v_tempdb_stats</c> (a <c>SELECT *</c> passthrough over <c>tempdb_stats</c> in the Darling + /// store) to mirror Lite's view-based query verbatim. The MB columns are <c>numeric(18,2)</c>, so + /// each is CAST to double precision for the typed GetDouble reader (the established viewer + /// convention); <c>total_sessions_using_tempdb</c> is <c>bigint</c> (GetInt64 — GetInt32 would + /// throw against a bigint), <c>top_session_id</c> is <c>integer</c>. + /// $1 server_id, $2 window start (naive UTC). + /// </summary> + public const string TempDbTrendSql = """ + SELECT + collection_time, + CAST(user_object_reserved_mb AS double precision) AS user_object_reserved_mb, + CAST(internal_object_reserved_mb AS double precision) AS internal_object_reserved_mb, + CAST(version_store_reserved_mb AS double precision) AS version_store_reserved_mb, + CAST(total_reserved_mb AS double precision) AS total_reserved_mb, + CAST(unallocated_mb AS double precision) AS unallocated_mb, + total_sessions_using_tempdb, + top_session_id, + CAST(top_session_tempdb_mb AS double precision) AS top_session_tempdb_mb + FROM v_tempdb_stats + WHERE server_id = $1 + AND collection_time >= $2 + ORDER BY collection_time + """; + + /// <summary> + /// The tempdb per-file I/O-latency read — Lite's <c>GetTempDbFileIoTrendAsync</c> ported to + /// Postgres. Reads <c>v_file_io_stats</c> (a <c>SELECT *</c> passthrough over <c>file_io_stats</c>) + /// to mirror Lite's view-based query, filtered to tempdb, and computes per-collection average + /// read/write latency (stall ms / operation count) with the delta-stall sums CAST to double + /// precision before division. Grouped by (collection_time, file_name) so each tempdb data file is + /// its own series. + /// $1 server_id, $2 window start (naive UTC). + /// </summary> + public const string TempDbFileIoTrendSql = """ + SELECT + collection_time, + file_name, + CASE WHEN SUM(delta_reads) > 0 THEN SUM(CAST(delta_stall_read_ms AS double precision)) / SUM(delta_reads) ELSE 0 END AS avg_read_latency_ms, + CASE WHEN SUM(delta_writes) > 0 THEN SUM(CAST(delta_stall_write_ms AS double precision)) / SUM(delta_writes) ELSE 0 END AS avg_write_latency_ms + FROM v_file_io_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND database_name = 'tempdb' + GROUP BY collection_time, file_name + ORDER BY collection_time, file_name + """; + + /// <summary> + /// tempdb space-usage samples for one server since <paramref name="sinceUtc"/>, time-ordered, for + /// the tempdb tab's usage and allocated-size charts. + /// </summary> + public async Task<List<TempDbSample>> GetTempDbTrendAsync(int serverId, DateTime sinceUtc, CancellationToken cancellationToken = default) + { + var samples = new List<TempDbSample>(); + + await using var command = _dataSource.CreateCommand(TempDbTrendSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(sinceUtc, DateTimeKind.Unspecified), + }); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + samples.Add(new TempDbSample( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : reader.GetDouble(1), + reader.IsDBNull(2) ? 0 : reader.GetDouble(2), + reader.IsDBNull(3) ? 0 : reader.GetDouble(3), + reader.IsDBNull(4) ? 0 : reader.GetDouble(4), + reader.IsDBNull(5) ? 0 : reader.GetDouble(5), + /* bigint — GetInt64; GetInt32 would throw against the bigint column. */ + reader.IsDBNull(6) ? 0 : reader.GetInt64(6), + reader.IsDBNull(7) ? 0 : reader.GetInt32(7), + reader.IsDBNull(8) ? 0 : reader.GetDouble(8))); + } + + return samples; + } + + /// <summary> + /// tempdb per-file I/O-latency points for one server since <paramref name="sinceUtc"/>, + /// time- then file-ordered, for the tempdb tab's file-I/O chart. + /// </summary> + public async Task<List<TempDbFileIoSample>> GetTempDbFileIoTrendAsync(int serverId, DateTime sinceUtc, CancellationToken cancellationToken = default) + { + var samples = new List<TempDbFileIoSample>(); + + await using var command = _dataSource.CreateCommand(TempDbFileIoTrendSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(sinceUtc, DateTimeKind.Unspecified), + }); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + samples.Add(new TempDbFileIoSample( + reader.GetDateTime(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + reader.IsDBNull(2) ? 0 : reader.GetDouble(2), + reader.IsDBNull(3) ? 0 : reader.GetDouble(3))); + } + + return samples; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Waits.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Waits.cs new file mode 100644 index 000000000..cd7b447b1 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Waits.cs @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; + +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// One point on a selected wait type's trend line, with both picker metrics computed IN SQL: the +/// per-second wait rate (this interval's <c>delta_wait_time_ms</c> divided by the seconds since the +/// previous collection via a per-type <c>LAG</c> window) and the average ms per wait (delta wait +/// time divided by delta waiting tasks). Signal-wait per second rides along for parity with Lite's +/// row shape though the picker's metric combo exposes only the other two. Copied from Lite's +/// <c>WaitStatsTrendPoint</c> (LocalDataService.WaitStats.cs). +/// </summary> +public sealed record WaitStatsTrendPoint( + DateTime CollectionTime, + double WaitTimeMsPerSecond, + double SignalWaitTimeMsPerSecond, + double AvgMsPerWait); + +public sealed partial class ViewerDataService +{ + /// <summary> + /// The wait-type picker's population read — Lite's <c>GetDistinctWaitTypesAsync</c> ported to + /// Postgres verbatim (runs against the <c>v_wait_stats</c> passthrough view, the Darling-analysis + /// convention): every wait_type collected for the server over the window, ranked by total delta + /// wait time descending so the picker sees the heaviest types first (the checked-to-top order and + /// the "Top Waits" fill both lean on it). Lite's per-user IgnoredWaitTypes exclusion clause is + /// deliberately DROPPED — the viewer has no per-user ignore config to share. + /// $1 server_id, $2 window start, $3 window end (all naive UTC). + /// </summary> + public const string DistinctWaitTypesSql = """ + SELECT + wait_type, + SUM(delta_wait_time_ms) AS total_delta + FROM v_wait_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + GROUP BY wait_type + ORDER BY SUM(delta_wait_time_ms) DESC + """; + + /// <summary> + /// Lite's batched <c>GetWaitStatsTrendsByTypesAsync</c> body, ported to Postgres verbatim: the + /// per-second trend for ALL selected wait types in ONE query, grouped by type. The <c>LAG</c> + /// window is partitioned by wait_type so each type's per-second rate is computed independently, + /// and the interval seconds come from the truncate-then-diff epoch idiom already proven + /// value-identical between DuckDB and Postgres. The <c>wait_type IN (...)</c> list is built + /// dynamically from <c>$4</c> onward (server_id/start/end take <c>$1</c>/<c>$2</c>/<c>$3</c>), + /// exactly like Lite; a caller passing <paramref name="waitTypeCount"/> = 0 is a bug the + /// <see cref="GetWaitStatsTrendsByTypesAsync"/> guard prevents. + /// </summary> + public static string WaitTrendsSql(int waitTypeCount) + { + var typeParams = string.Join(", ", Enumerable.Range(0, waitTypeCount).Select(i => "$" + (i + 4))); + return $$""" + WITH raw AS + ( + SELECT + wait_type, + collection_time, + delta_wait_time_ms, + delta_signal_wait_time_ms, + delta_waiting_tasks, + extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (PARTITION BY wait_type ORDER BY collection_time)))) AS interval_seconds + FROM v_wait_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND wait_type IN ({{typeParams}}) + ) + SELECT + wait_type, + collection_time, + CASE WHEN interval_seconds > 0 THEN CAST(delta_wait_time_ms AS DOUBLE PRECISION) / interval_seconds ELSE 0 END AS wait_time_ms_per_second, + CASE WHEN interval_seconds > 0 THEN CAST(delta_signal_wait_time_ms AS DOUBLE PRECISION) / interval_seconds ELSE 0 END AS signal_wait_time_ms_per_second, + CASE WHEN delta_waiting_tasks > 0 THEN CAST(delta_wait_time_ms AS DOUBLE PRECISION) / delta_waiting_tasks ELSE 0 END AS avg_ms_per_wait + FROM raw + ORDER BY wait_type, collection_time + """; + } + + /// <summary> + /// The distinct wait types collected for one server in the window, ranked by total delta wait + /// time descending — feeds the picker's population + default-selection. + /// </summary> + public async Task<List<string>> GetDistinctWaitTypesAsync( + int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var items = new List<string>(); + + await using var command = _dataSource.CreateCommand(DistinctWaitTypesSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified), + }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified), + }); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(reader.GetString(0)); + } + + return items; + } + + /// <summary> + /// The per-second (and avg-per-wait) trend for every selected wait type in one query, grouped by + /// type. Empty selection returns an empty map without touching the store. + /// </summary> + public async Task<Dictionary<string, List<WaitStatsTrendPoint>>> GetWaitStatsTrendsByTypesAsync( + int serverId, List<string> waitTypes, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + var result = new Dictionary<string, List<WaitStatsTrendPoint>>(); + if (waitTypes.Count == 0) + { + return result; + } + + await using var command = _dataSource.CreateCommand(WaitTrendsSql(waitTypes.Count)); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified), + }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified), + }); + foreach (var waitType in waitTypes) + { + command.Parameters.Add(new NpgsqlParameter<string> { TypedValue = waitType }); + } + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var waitType = reader.GetString(0); + if (!result.TryGetValue(waitType, out var list)) + { + list = new List<WaitStatsTrendPoint>(); + result[waitType] = list; + } + + list.Add(new WaitStatsTrendPoint( + reader.GetDateTime(1), + reader.IsDBNull(2) ? 0 : reader.GetDouble(2), + reader.IsDBNull(3) ? 0 : reader.GetDouble(3), + reader.IsDBNull(4) ? 0 : reader.GetDouble(4))); + } + + return result; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs new file mode 100644 index 000000000..d02431ec9 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs @@ -0,0 +1,735 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Data.Common; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Storage; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// One row of the servers table, as the viewer's server list shows it. Was a positional record; it is now +/// a class because the ported Lite server-row chrome needs mutable, change-notifying runtime state on each +/// row — the favorite star (<see cref="IsFavorite"/>, matched from the viewer's registry) and the +/// collection-freshness status dot (<see cref="IsOnline"/> / <see cref="HasCollectorErrors"/> → +/// <see cref="DotStatus"/>) update in place on the refresh timers without resetting the list's selection. +/// The Postgres-sourced fields stay immutable (get-only); only the sidebar overlay state is settable. +/// Equality is now reference-based, which every consumer already relies on (they key on +/// <see cref="ServerId"/> or hold the list's own instances). +/// </summary> +public sealed class DarlingServer : INotifyPropertyChanged +{ + public DarlingServer( + int serverId, + string serverName, + string displayName, + bool isEnabled, + int? sqlMajorVersion, + decimal monthlyCostUsd = 0) + { + ServerId = serverId; + ServerName = serverName; + DisplayName = displayName; + IsEnabled = isEnabled; + SqlMajorVersion = sqlMajorVersion; + MonthlyCostUsd = monthlyCostUsd; + } + + public int ServerId { get; } + public string ServerName { get; } + public string DisplayName { get; } + public bool IsEnabled { get; } + public int? SqlMajorVersion { get; } + public decimal MonthlyCostUsd { get; } + + /// <summary>"SQL Server 2022"-style label for the server list; empty when the version is unknown.</summary> + public string VersionLabel => ViewerDataService.SqlVersionLabel(SqlMajorVersion); + + // ── Runtime-only sidebar state (not from Postgres; drives the ported Lite server-row chrome) ── + + private bool _isFavorite; + + /// <summary>Whether the user pinned this server (from the viewer's registry, matched by name). Drives the star.</summary> + public bool IsFavorite + { + get => _isFavorite; + set + { + if (_isFavorite != value) + { + _isFavorite = value; + OnPropertyChanged(nameof(IsFavorite)); + } + } + } + + private bool? _isOnline; + + /// <summary>Freshness "reachability": true = fresh/stale (dot green/amber), false = offline (red), null = unknown.</summary> + public bool? IsOnline + { + get => _isOnline; + set + { + if (_isOnline != value) + { + _isOnline = value; + OnPropertyChanged(nameof(IsOnline)); + OnPropertyChanged(nameof(DotStatus)); + } + } + } + + private bool _hasCollectorErrors; + + /// <summary>Warning (amber) state — in the viewer this means the collection has gone stale.</summary> + public bool HasCollectorErrors + { + get => _hasCollectorErrors; + set + { + if (_hasCollectorErrors != value) + { + _hasCollectorErrors = value; + OnPropertyChanged(nameof(HasCollectorErrors)); + OnPropertyChanged(nameof(DotStatus)); + } + } + } + + /// <summary> + /// Sidebar status-dot vocabulary — "Online"/"Warning"/"Offline"/"Unknown", identical to Lite's + /// <c>ServerConnection.DotStatus</c> so the ported server-row DataTriggers colour the Ellipse the same way. + /// </summary> + public string DotStatus => IsOnline switch + { + true => HasCollectorErrors ? "Warning" : "Online", + false => "Offline", + _ => "Unknown" + }; + + /// <summary> + /// Sets the dot from the same collection-freshness classification the Overview cards use + /// (<see cref="ServerSummaryItem.ClassifyFreshness"/>): Fresh → Online, Stale → the amber Warning, + /// Offline → red, NeverCollected → the grey Unknown dot (the service hasn't reached the server yet — + /// during a fleet bootstrap that is "queued", not "dead"). Both instants are UTC (the store is naive + /// UTC; nowUtc is <see cref="DateTime.UtcNow"/>). + /// </summary> + public void ApplyFreshness(DateTime? lastCollectionUtc, DateTime nowUtc) + { + var freshness = ServerSummaryItem.ClassifyFreshness(lastCollectionUtc, nowUtc); + IsOnline = freshness == ServerFreshness.NeverCollected ? null : freshness != ServerFreshness.Offline; + HasCollectorErrors = freshness == ServerFreshness.Stale; + } + + // ── Per-server alert "needs attention" badge state (from the polled alert history, ack-aware) ── + // Set by MainWindow.UpdateServerAttention from the ServerAttentionDeriver output, gated through the + // viewer-local ViewerAlertStateService (ack-until-worse). Drives the sidebar row badge, mirroring + // Lite's per-tab alert badge. + + private int _attentionCount; + private bool _attentionIsCritical; + private string? _attentionTooltip; + + /// <summary>Active (unacknowledged, un-muted) alert count for this server; 0 hides the badge.</summary> + public int AttentionCount => _attentionCount; + + /// <summary>True when the badge should show (there is at least one active alert to surface).</summary> + public bool HasAttention => _attentionCount > 0; + + /// <summary>The badge count text, capped like Lite's tab badge ("99+" past 99).</summary> + public string AttentionText => _attentionCount > 99 + ? "99+" + : _attentionCount.ToString(CultureInfo.InvariantCulture); + + /// <summary> + /// Badge severity vocabulary — "Critical" / "Warning" / "None" — so the sidebar row's DataTriggers colour + /// the badge (ErrorBrush / WarningBrush) the same way Lite maps deadlock→red, else→orange-red. + /// </summary> + public string AttentionSeverity => _attentionCount <= 0 + ? "None" + : (_attentionIsCritical ? "Critical" : "Warning"); + + /// <summary>The badge tooltip: the metric breakdown for this server (empty when no attention).</summary> + public string? AttentionTooltip => _attentionTooltip; + + /// <summary> + /// Updates the sidebar alert badge in place (no list reset). Pass <paramref name="count"/> 0 to clear it + /// (acknowledged / no alerts). Raises change notifications only when something actually changed. + /// </summary> + public void SetAttention(int count, bool isCritical, string? tooltip) + { + if (_attentionCount == count + && _attentionIsCritical == isCritical + && string.Equals(_attentionTooltip, tooltip, StringComparison.Ordinal)) + { + return; + } + + _attentionCount = count; + _attentionIsCritical = isCritical; + _attentionTooltip = tooltip; + + OnPropertyChanged(nameof(AttentionCount)); + OnPropertyChanged(nameof(HasAttention)); + OnPropertyChanged(nameof(AttentionText)); + OnPropertyChanged(nameof(AttentionSeverity)); + OnPropertyChanged(nameof(AttentionTooltip)); + } + + public event PropertyChangedEventHandler? PropertyChanged; + + private void OnPropertyChanged(string name) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); +} + +/// <summary> +/// The viewer's reads of the Darling Postgres store — the server list, plus the surfaces in the +/// partials (the Overview lanes' total-wait + memory trends and per-lane baselines in +/// <c>ViewerDataService.OverviewLanes.cs</c>, the per-tab reads in <c>.Cpu.cs</c>, <c>.Waits.cs</c>, +/// <c>.BlockingTrends.cs</c>, <c>.FileIo.cs</c>, <c>.TempDb.cs</c>, <c>.Config.cs</c>, and +/// <c>.RunningJobs.cs</c>, the Daily Summary + Collection Health reads in <c>.DailySummary.cs</c> / +/// <c>.CollectionHealth.cs</c>, and the wave-2/3 reads in <c>.QueryStats.cs</c>, <c>.Blocking.cs</c>, +/// <c>.Findings.cs</c>, <c>.AlertHistory.cs</c>, and <c>.MuteRules.cs</c>). +/// Connections come from a pooled <see cref="NpgsqlDataSource"/>, so the window can run its +/// per-tab queries concurrently. The SQL lives in public constants so tests can pin the +/// load-bearing clauses without a live Postgres. +/// All timestamps in the store are naive UTC (`timestamp without time zone`), so DateTime +/// parameters are sent with DateTimeKind.Unspecified — since Npgsql 6.0 a Kind=Utc DateTime +/// maps strictly to timestamptz and throws against naive columns. +/// </summary> +public sealed partial class ViewerDataService : IAsyncDisposable +{ + public const string ServersSql = + "SELECT server_id, server_name, display_name, is_enabled, sql_major_version, COALESCE(monthly_cost_usd, 0) FROM servers ORDER BY display_name"; + + /// <summary> + /// The authoritative read-only probe (V8 security hardening): does the connected role hold INSERT + /// on a <c>config</c> table? True → the admin role (or an owner) — the mute / alert-dismiss / + /// analysis-mute writes are available. False → the read-only viewer role — those surfaces degrade. + /// This is the source of truth over <c>connectAs</c> (which only picks a credential and doesn't + /// apply in BYO mode), because it reflects the connection's ACTUAL privileges. The bare table name + /// resolves through search_path to <c>config.config_mute_rules</c>. + /// </summary> + public const string ReadOnlyProbeSql = "SELECT has_table_privilege('config_mute_rules', 'INSERT')"; + + private readonly NpgsqlDataSource _dataSource; + + /// <param name="connectionString">The Postgres connection string (managed-derived or BYO from darling.json).</param> + /// <param name="connectionTimeoutSeconds"> + /// The viewer's "Connection timeout" preference (<see cref="ViewerAppSettings.ConnectionTimeoutSeconds"/>, + /// Lite's "increase for VPN/remote" knob). When supplied it is applied to the pooled store connections as + /// Npgsql's connect <c>Timeout</c> — set on the managed-derived string (which carries none) and appended to a + /// BYO string only when the operator did not already specify one (their explicit Timeout wins). Null leaves + /// the string untouched (Npgsql's 15s default). This restores the setting the Settings window has always + /// saved but nothing consumed. + /// </param> + public ViewerDataService(string connectionString, int? connectionTimeoutSeconds = null) + { + var effectiveConnectionString = connectionTimeoutSeconds is int seconds + ? ApplyConnectionTimeout(connectionString, seconds) + : connectionString; + _dataSource = NpgsqlDataSource.Create(effectiveConnectionString); + } + + /// <summary> + /// Applies the viewer's connect-timeout preference to a connection string, but ONLY when the string does not + /// already specify a <c>Timeout</c> — so an operator's explicit Timeout in a BYO <c>postgres.connectionString</c> + /// always wins, while the managed-derived string (which never sets one) and a BYO string that omits it both + /// pick up the preference. <paramref name="timeoutSeconds"/> is Npgsql's connect timeout in seconds (the + /// caller clamps it to 5–60). Pure + string-only, so it is unit-tested without a live Postgres. Detection + /// uses the base <see cref="DbConnectionStringBuilder"/>, whose <c>ContainsKey</c> reflects exactly the keys + /// present in the string (NpgsqlConnectionStringBuilder overrides ContainsKey to answer for every KNOWN + /// keyword, which can't tell "set" from "settable"). + /// </summary> + internal static string ApplyConnectionTimeout(string connectionString, int timeoutSeconds) + { + var builder = new DbConnectionStringBuilder { ConnectionString = connectionString }; + if (!builder.ContainsKey("Timeout")) + { + builder["Timeout"] = timeoutSeconds; + } + + return builder.ConnectionString; + } + + /// <summary> + /// True when the connected role cannot write the operator-config tables (the read-only + /// <c>viewer</c> role, or any connection lacking config INSERT). Set by + /// <see cref="DetectReadOnlyAsync"/>; the write surfaces gate on it. Defaults false (writable) until + /// probed, then fails safe to true if the probe cannot run. + /// </summary> + public bool IsReadOnly { get; private set; } + + /// <summary> + /// Runs the <see cref="ReadOnlyProbeSql"/> capability probe and records <see cref="IsReadOnly"/>. + /// Called once after the service connects, before the write affordances are shown. A probe that + /// throws (table missing on a mis-provisioned store, permission quirk, transient error) fails safe + /// to read-only, so the UI hides writes rather than dead-clicking into a permission error; the + /// reactive 42501 catch in the write paths is the backstop if the probe and reality ever disagree. + /// </summary> + public async Task<bool> DetectReadOnlyAsync(CancellationToken cancellationToken = default) + { + try + { + await using var command = _dataSource.CreateCommand(ReadOnlyProbeSql); + var canInsert = await command.ExecuteScalarAsync(cancellationToken); + IsReadOnly = canInsert is not true; + } + catch (Exception ex) when (IsConnectionFailure(ex)) + { + /* Finding B3: a failure to REACH the store is not a read-only seat — surface it as unreachable so + the shell shows the "is the service running?" message instead of hiding every write affordance + behind a false read-only verdict. */ + throw new ViewerStoreUnreachableException(ex); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + /* A server RESPONSE we couldn't interpret (a permission quirk, a missing table on a + mis-provisioned store, a transient error) fails safe to read-only, so the UI hides writes + rather than dead-clicking into a permission error. */ + IsReadOnly = true; + } + + return IsReadOnly; + } + + /// <summary> + /// Opens one real connection to prove the store is reachable, so the shell can tell an UNREACHABLE store + /// (the service down, or a wrong host/port/database in darling.json's postgres section) apart from a legit + /// read-only seat — the two the old connect path collapsed into a false "read-only" verdict (finding B3). + /// A connection-level failure is rethrown as <see cref="ViewerStoreUnreachableException"/>; a server that + /// RESPONDS (even to refuse authentication) is "reachable" and any such error propagates for the caller's + /// generic handling. The <see cref="NpgsqlDataSource"/> is lazy, so this is the first live connect. + /// </summary> + public async Task EnsureStoreReachableAsync(CancellationToken cancellationToken = default) + { + try + { + await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); + } + catch (Exception ex) when (IsConnectionFailure(ex)) + { + throw new ViewerStoreUnreachableException(ex); + } + } + + /// <summary> + /// True when <paramref name="ex"/> is a failure to TALK to Postgres (socket refused, host not found, + /// connect timeout) rather than a server error RESPONSE. Npgsql surfaces a server response as a + /// <see cref="PostgresException"/> (it carries a SQLSTATE); any other <see cref="NpgsqlException"/> — or a + /// bare socket / timeout — means the server was never reached. Drives the unreachable-vs-read-only split + /// (finding B3), so a down store shows "is the service running?" not a false read-only verdict. + /// </summary> + internal static bool IsConnectionFailure(Exception ex) => + (ex is NpgsqlException and not PostgresException) + || ex is System.Net.Sockets.SocketException + || ex is TimeoutException; + + /// <summary> + /// Probes the store's effective schema version through <c>information_schema</c> (plus <c>pg_indexes</c> + /// for the one index sentinel) — the version the viewer gates on at connect (finding B1). The authoritative + /// <c>darling_schema_version</c> table is owner-only (a viewer/admin role can't read it, and even + /// <c>MAX(version)</c> can itself throw 42501), so rather than read the version number this checks whether + /// the store carries the schema OBJECTS the recent migrations added — objects any role can see. Newest + /// first: V27's <c>deadlocks.database_name</c> column (the Azure per-database watermark key, #1535), + /// V26's <c>config_notification.generic_url</c> column (the generic webhook channel, #1506), V25's + /// <c>agent_status</c> table, V24's <c>job_history</c> table, V22's <c>idx_index_object_stats_latest</c> index (the FinOps Index Analysis + /// supporting index — indexes are not listed in <c>information_schema</c>, so this one reads the + /// world-readable <c>pg_indexes</c> catalog), V21's <c>default_trace_events</c> table (the shared Default + /// Trace collector), V20's <c>notify_connection_changes</c> column (config_alert_settings), V19's + /// <c>analysis_state</c> table, V18's <c>alert_delivery_mode_override</c> column (the very column whose + /// absence throws the raw 42703 the finding reproduces on Add/Edit Server), and V17's + /// <c>config_monitored_servers</c> table (the config control plane). <see cref="MapProbedSchemaVersion"/> + /// reduces the flags to the highest satisfied version. Each sentinel appears only in a store at its + /// migration or later: a fresh store gets them all at once (it runs straight through to the latest + /// version), an upgraded store gets each exactly at its migration. + /// + /// <para>The seventh sentinel (V23) is different in kind: V23 makes <c>collection_log</c> a TimescaleDB + /// hypertable, and its only schema effect is engine-dependent. So this checks <c>collection_log</c> IS a + /// hypertable OR the store has no <c>timescaledb</c> extension — because on plain PostgreSQL V23 is a no-op + /// (the guarded migration skips the conversion), so a plain-PG store at V23 is object-identical to V22 and + /// must not be gated. <b>Crucially it is written PLAIN-POSTGRESQL-SAFE:</b> it reads the core <c>pg_inherits</c> + /// catalog for collection_log's chunk children (TimescaleDB 2.x links chunks to the hypertable root via + /// inheritance — note a freshly-converted, still-empty hypertable has no chunks yet, a negligible window since + /// collection_log gets a row every collection cycle) rather than <c>timescaledb_information.hypertables</c> — that view does not exist without the + /// extension, and referencing it here would make the WHOLE probe throw on plain PG (returning null → the + /// gate fails open → EVERY plain-PG store, even genuinely-behind ones, loses connect-time gating). This + /// composite is only meaningful once V22's index is present (its <c>NOT EXISTS timescaledb</c> arm is true + /// for ALL plain-PG stores regardless of version), so <see cref="MapProbedSchemaVersion"/> gates it behind + /// the V22 sentinel rather than treating it as a standalone newest-first arm. + /// </summary> + public const string StoreSchemaProbeSql = @" +SELECT + EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'config_monitored_servers'), + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'config_monitored_servers' AND column_name = 'alert_delivery_mode_override'), + EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'analysis_state'), + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'config_alert_settings' AND column_name = 'notify_connection_changes'), + EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'default_trace_events'), + EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_index_object_stats_latest'), + ( + EXISTS (SELECT 1 FROM pg_inherits i + JOIN pg_class c ON c.oid = i.inhparent + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = 'collection_log' AND n.nspname = 'collect') + OR NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') + ), + EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'job_history'), + EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'agent_status'), + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'config_notification' AND column_name = 'generic_url'), + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'deadlocks' AND column_name = 'database_name'), + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'query_store_stats' AND column_name = 'replica_role'), + EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'long_query_completions'), + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'config_service' AND column_name = 'web_enabled'), + EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'custom_views')"; + + /// <summary>The store schema version this viewer build requires — the highest migration it knows + /// (<see cref="StorageVersion.SchemaVersion"/>). The connect-time gate blocks a store below this.</summary> + public static int RequiredStoreSchemaVersion => StorageVersion.SchemaVersion; + + /// <summary> + /// Reads the store's effective schema version via <see cref="StoreSchemaProbeSql"/>. Returns null when it + /// can't be determined (any non-cancellation error — an unreachable store, an <c>information_schema</c> + /// quirk) so the connect-time gate FAILS OPEN: a possibly-healthy store is never blocked by a probe + /// hiccup. A truly-down store is caught first by <see cref="EnsureStoreReachableAsync"/> (finding B3), and + /// the executor 42703/42P01 translation (finding B2) is the write-path backstop. + /// </summary> + public async Task<int?> GetStoreSchemaVersionAsync(CancellationToken cancellationToken = default) + { + try + { + await using var command = _dataSource.CreateCommand(StoreSchemaProbeSql); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (await reader.ReadAsync(cancellationToken)) + { + return MapProbedSchemaVersion(reader.GetBoolean(0), reader.GetBoolean(1), reader.GetBoolean(2), reader.GetBoolean(3), reader.GetBoolean(4), reader.GetBoolean(5), reader.GetBoolean(6), reader.GetBoolean(7), reader.GetBoolean(8), reader.GetBoolean(9), reader.GetBoolean(10), reader.GetBoolean(11), reader.GetBoolean(12), reader.GetBoolean(13), reader.GetBoolean(14)); + } + + return null; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return null; + } + } + + /// <summary> + /// Maps the <see cref="StoreSchemaProbeSql"/> sentinels to the store's effective version, newest present + /// wins. V23 is folded INTO the V22 arm rather than being a standalone newest-first arm: once the V22 index + /// is present, <paramref name="hasCollectionLogHypertableOrPlainPg"/> (collection_log is a hypertable, OR + /// the store is plain PostgreSQL where V23 is an object-invisible no-op) → 23, else → 22. It cannot be a + /// top arm because its plain-PG side is true for EVERY plain-PG store regardless of version, so it must be + /// gated behind V22's engine-agnostic index. Below V22: <paramref name="hasDefaultTraceEvents"/> (V21) → 21, + /// else <paramref name="hasAlertTuningKnobs"/> (V20) → 20, else <paramref name="hasAnalysisState"/> (V19) → + /// 19, else <paramref name="hasAlertDeliveryOverride"/> (V18) → 18, else + /// <paramref name="hasConfigControlPlane"/> (V17) → 17, else 16 — the "older than the V17 config control + /// plane" floor (the exact pre-17 version isn't probed, but it is below what the viewer needs). Pure, so it + /// is unit-tested without a live store; a schema bump past 29 trips the pinning test that keeps this in step + /// with <see cref="StorageVersion.SchemaVersion"/>. + /// </summary> + internal static int MapProbedSchemaVersion(bool hasConfigControlPlane, bool hasAlertDeliveryOverride, bool hasAnalysisState, bool hasAlertTuningKnobs, bool hasDefaultTraceEvents, bool hasIndexObjectStatsLatestIndex, bool hasCollectionLogHypertableOrPlainPg, bool hasJobHistory, bool hasAgentStatus, bool hasGenericWebhook, bool hasDeadlocksDatabaseName, bool hasQueryStoreReplicaRole, bool hasLongQueryCompletions, bool hasWebDashboardConfig, bool hasCustomViews) + { + /* V31 (#1563 custom views): engine-agnostic table-existence sentinel, newest-first arm. + config.custom_views exists only at V31 or later. */ + if (hasCustomViews) + { + return 31; + } + + /* V30 (#1562 web dashboard toggle): engine-agnostic column-existence sentinel, newest-first arm. + config_service.web_enabled exists only at V30 or later. */ + if (hasWebDashboardConfig) + { + return 30; + } + + /* V29 (#1496 long-query completion trace): engine-agnostic table-existence sentinel, newest-first + arm. The long_query_completions table exists only at V29 or later. */ + if (hasLongQueryCompletions) + { + return 29; + } + + /* V28 (#1546 Query Store replica attribution): engine-agnostic column-existence sentinel, + newest-first arm. query_store_stats.replica_role exists only at V28 or later. */ + if (hasQueryStoreReplicaRole) + { + return 28; + } + + /* V27 (#1535 Azure per-database deadlock capture): engine-agnostic column-existence sentinel, + newest-first arm. deadlocks.database_name exists only at V27 or later. */ + if (hasDeadlocksDatabaseName) + { + return 27; + } + + /* V26 (#1506 generic webhook): another engine-agnostic column-existence sentinel. + config_notification.generic_url exists only at V26 or later. */ + if (hasGenericWebhook) + { + return 26; + } + + /* V24/V25 (#1433 Job History tab) are engine-agnostic table-existence sentinels, so they ARE + standalone newest-first arms (unlike the V23 hypertable composite): agent_status (V25) present → + 25, else job_history (V24) present → 24, else fall through to the V22/V23 composite below. A + fully-migrated store has both. */ + if (hasAgentStatus) + { + return 25; + } + + if (hasJobHistory) + { + return 24; + } + + if (hasIndexObjectStatsLatestIndex) + { + /* V22's index is the newest ENGINE-AGNOSTIC sentinel and the floor for V23. V23's only schema + effect — collection_log becoming a hypertable — is visible ONLY on a TimescaleDB store; on plain + PostgreSQL V23 is a no-op, so a plain-PG store at V23 is object-identical to V22 and the composite + is true via its no-extension arm (correctly reporting 23 — nothing for the viewer to gate on). On + a Timescale store the hypertable distinguishes a real V23 from a store still at V22. */ + return hasCollectionLogHypertableOrPlainPg ? 23 : 22; + } + + if (hasDefaultTraceEvents) + { + return 21; + } + + if (hasAlertTuningKnobs) + { + return 20; + } + + if (hasAnalysisState) + { + return 19; + } + + if (hasAlertDeliveryOverride) + { + return 18; + } + + if (hasConfigControlPlane) + { + return 17; + } + + return 16; + } + + /// <summary>All registered servers, ordered as the server list displays them.</summary> + public async Task<List<DarlingServer>> GetServersAsync(CancellationToken cancellationToken = default) + { + var servers = new List<DarlingServer>(); + + await using var command = _dataSource.CreateCommand(ServersSql); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var serverName = reader.GetString(1); + servers.Add(new DarlingServer( + reader.GetInt32(0), + serverName, + reader.IsDBNull(2) ? serverName : reader.GetString(2), + !reader.IsDBNull(3) && reader.GetBoolean(3), + reader.IsDBNull(4) ? null : reader.GetInt32(4), + reader.IsDBNull(5) ? 0m : Convert.ToDecimal(reader.GetValue(5)))); + } + + return servers; + } + + /// <summary> + /// Product-name label for a sql_major_version (2016+ is what the product supports; older or + /// unknown majors fall back to a bare version tag, null to empty). + /// </summary> + public static string SqlVersionLabel(int? sqlMajorVersion) => sqlMajorVersion switch + { + null => "", + 11 => "SQL Server 2012", + 12 => "SQL Server 2014", + 13 => "SQL Server 2016", + 14 => "SQL Server 2017", + 15 => "SQL Server 2019", + 16 => "SQL Server 2022", + 17 => "SQL Server 2025", + _ => $"SQL Server v{sqlMajorVersion}", + }; + + /// <summary> + /// The active server's UTC offset in minutes from its most recent <c>server_properties</c> row — the + /// value the Server-time display mode adds to the naive-UTC store (UTC + offset = server local). The + /// naive-UTC → display conversion every rendered timestamp uses lives in <see cref="ViewerTimeHelper"/> + /// (it replaced this class's former fixed <c>ToLocalTime</c>); this only fetches the per-server offset + /// that mode feeds into it. Returns null when the column has not been collected yet (an older store + /// just migrated, or a server whose properties collector has not re-run since the V16 upgrade); the + /// caller then keeps the viewer's machine-local offset so Server mode degrades to ~Local until then. + /// </summary> + public const string ServerUtcOffsetSql = @" +SELECT utc_offset_minutes +FROM server_properties +WHERE server_id = $1 +AND utc_offset_minutes IS NOT NULL +ORDER BY collection_time DESC +LIMIT 1"; + + public async Task<int?> GetServerUtcOffsetMinutesAsync(int serverId, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(ServerUtcOffsetSql); + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + var result = await command.ExecuteScalarAsync(cancellationToken); + return result is null or DBNull + ? null + : Convert.ToInt32(result, System.Globalization.CultureInfo.InvariantCulture); + } + + /// <summary> + /// Binds the standard per-server window parameters shared by the settable-window tab reads: + /// $1 server_id, $2 window start, $3 window end. Both bounds are sent Kind=Unspecified because the + /// store's columns are naive UTC (<c>timestamp without time zone</c>) — a Kind=Utc DateTime would + /// map to timestamptz and throw since Npgsql 6.0 (see the class remarks). + /// </summary> + internal static void AddWindowParameters(NpgsqlCommand command, int serverId, DateTime startUtc, DateTime endUtc) + { + command.Parameters.Add(new NpgsqlParameter<int> { TypedValue = serverId }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified), + }); + command.Parameters.Add(new NpgsqlParameter<DateTime> + { + TypedValue = DateTime.SpecifyKind(endUtc, DateTimeKind.Unspecified), + }); + } + + /// <summary>Postgres SQLSTATE 42501 (insufficient_privilege) — a write refused on a read-only connection.</summary> + internal const string InsufficientPrivilegeSqlState = "42501"; + + /// <summary>Postgres SQLSTATE 42703 (undefined_column) — a write referenced a column a lagging store + /// has not migrated in yet (schema skew).</summary> + internal const string UndefinedColumnSqlState = "42703"; + + /// <summary>Postgres SQLSTATE 42P01 (undefined_table) — a write referenced a table a lagging store has + /// not migrated in yet (schema skew).</summary> + internal const string UndefinedTableSqlState = "42P01"; + + /// <summary> + /// Executes a write command, translating a permission-denied failure — a write attempted on the + /// read-only viewer role, or after grants changed under a running app — into a + /// <see cref="ViewerReadOnlyException"/> the UI shows as a clear "read-only connection" message + /// instead of a raw Postgres error. The reactive backstop to the proactive hide/disable; any other + /// failure propagates unchanged. + /// </summary> + internal async Task<int> ExecuteWriteAsync(NpgsqlCommand command, CancellationToken cancellationToken) + { + try + { + return await command.ExecuteNonQueryAsync(cancellationToken); + } + catch (PostgresException ex) when (ex.SqlState == InsufficientPrivilegeSqlState) + { + throw new ViewerReadOnlyException(ex); + } + catch (PostgresException ex) when (ex.SqlState is UndefinedColumnSqlState or UndefinedTableSqlState) + { + throw new ViewerSchemaSkewException(ex); + } + } + + /// <summary> + /// The <c>RETURNING</c>-shaped sibling of <see cref="ExecuteWriteAsync"/>: runs a write that yields a + /// scalar (e.g. the <c>config_command.command_id</c> a command enqueue returns), translating a 42501 on a + /// read-only seat into <see cref="ViewerReadOnlyException"/> the same way. Any other failure propagates. + /// </summary> + internal async Task<object?> ExecuteWriteScalarAsync(NpgsqlCommand command, CancellationToken cancellationToken) + { + try + { + return await command.ExecuteScalarAsync(cancellationToken); + } + catch (PostgresException ex) when (ex.SqlState == InsufficientPrivilegeSqlState) + { + throw new ViewerReadOnlyException(ex); + } + catch (PostgresException ex) when (ex.SqlState is UndefinedColumnSqlState or UndefinedTableSqlState) + { + throw new ViewerSchemaSkewException(ex); + } + } + + public ValueTask DisposeAsync() => _dataSource.DisposeAsync(); +} + +/// <summary> +/// A write was attempted on a read-only Darling connection (the least-privilege <c>viewer</c> role, +/// or any connection lacking <c>config</c> INSERT). Thrown by the write paths when Postgres returns +/// 42501 so the UI shows a clear, actionable message rather than a raw permission error. The proactive +/// <see cref="ViewerDataService.IsReadOnly"/> gating normally hides the affordances first; this covers +/// the race where the probe and the live grants disagree. +/// </summary> +public sealed class ViewerReadOnlyException : Exception +{ + public ViewerReadOnlyException(Exception innerException) + : base( + "This viewer is connected with a read-only role, so it can't change mute rules, dismiss alerts, " + + "or mute findings. Set postgres.connectAs to \"admin\" in darling.json and restart the viewer to " + + "enable these actions.", + innerException) + { + } +} + +/// <summary> +/// The Darling store could not be reached at connect — the service is down, or the postgres section of +/// darling.json points at the wrong host/port/database (a connection-level failure, not a server error +/// response). Thrown by <see cref="ViewerDataService.EnsureStoreReachableAsync"/> and, as a backstop, by +/// <see cref="ViewerDataService.DetectReadOnlyAsync"/> so the shell shows a dedicated "is the service +/// running?" message instead of misreading an unreachable store as a read-only seat (finding B3). +/// </summary> +public sealed class ViewerStoreUnreachableException : Exception +{ + public ViewerStoreUnreachableException(Exception innerException) + : base( + "Can't reach the Darling store — is the Darling service running? Check the postgres section of " + + "darling.json (the host, port, and database must point at the running service's store).", + innerException) + { + } +} + +/// <summary> +/// A control-plane write referenced a column or table the connected store has not migrated in yet — the +/// store's schema is BEHIND the version this viewer build needs (Postgres 42703 undefined_column / 42P01 +/// undefined_table). Thrown by the write executors so the UI shows a clear "update the service" message +/// instead of a raw "column ... does not exist" error (finding B2). The connect-time gate +/// (<see cref="ViewerDataService.GetStoreSchemaVersionAsync"/>, finding B1) normally blocks a skewed store +/// before any write; this is the write-path backstop for a column referenced ahead of a lagging store. +/// </summary> +public sealed class ViewerSchemaSkewException : Exception +{ + public ViewerSchemaSkewException(Exception innerException) + : base( + $"This viewer needs Darling store schema v{ViewerDataService.RequiredStoreSchemaVersion}, but the " + + "store is missing an expected column or table. Update or restart the Darling service so it " + + "migrates the store, then reopen the viewer.", + innerException) + { + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerHistoryRows.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerHistoryRows.cs new file mode 100644 index 000000000..1b1d10bf1 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerHistoryRows.cs @@ -0,0 +1,229 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// Row models for the per-item execution-history drill-down windows — the viewer copies of Lite's +/// <c>QueryStatsHistoryRow</c> / <c>ProcedureStatsHistoryRow</c> / <c>QueryStoreHistoryRow</c> +/// (LocalDataService.QueryStats.cs / .QueryStore.cs). Numeric members stay in the store's units (microseconds +/// for CPU/duration, KB for grants); the display properties convert, mirroring Lite's grid + metric chart. +/// The one viewer adaptation is time display: <see cref="CollectionTime"/> is the collector's naive-UTC +/// capture time and routes through <see cref="ViewerTimeHelper.ForDisplay"/>, while the DMV / Query Store +/// wall-clock stamps (creation / last-execution / cached / first-execution) are the SQL server's own local +/// time and render raw through <see cref="ViewerDataService.FormatServerClock"/> — the same split every other +/// viewer query row uses (see <see cref="ViewerQueryStatsRow"/>). +/// </summary> +internal static class HistoryTime +{ + /// <summary>Formats a naive-UTC collection timestamp in the current display mode (Server / Local / UTC).</summary> + public static string CollectionLocal(DateTime collectionTimeUtc) + => ViewerTimeHelper.ForDisplay(collectionTimeUtc).ToString("yyyy-MM-dd HH:mm:ss"); +} + +/// <summary>One collected query_stats snapshot for a single (database, query_hash) — Lite's +/// <c>QueryStatsHistoryRow</c>. The <c>delta_*</c> members are the collector's per-cycle deltas (no C# +/// differencing); the Avg* properties divide the cycle delta by that cycle's execution delta, matching Lite.</summary> +public sealed class ViewerQueryStatsHistoryRow +{ + public DateTime CollectionTime { get; set; } + public long DeltaExecutions { get; set; } + public long DeltaCpuUs { get; set; } + public long DeltaElapsedUs { get; set; } + public long DeltaLogicalReads { get; set; } + public long DeltaLogicalWrites { get; set; } + public long DeltaPhysicalReads { get; set; } + public long DeltaRows { get; set; } + public long DeltaSpills { get; set; } + public int MinDop { get; set; } + public int MaxDop { get; set; } + public long MinCpuUs { get; set; } + public long MaxCpuUs { get; set; } + public long MinElapsedUs { get; set; } + public long MaxElapsedUs { get; set; } + public long MinGrantKb { get; set; } + public long MaxGrantKb { get; set; } + public long MinUsedGrantKb { get; set; } + public long MaxUsedGrantKb { get; set; } + public long MinIdealGrantKb { get; set; } + public long MaxIdealGrantKb { get; set; } + public long MinReservedThreads { get; set; } + public long MaxReservedThreads { get; set; } + public long MinUsedThreads { get; set; } + public long MaxUsedThreads { get; set; } + public long MinPhysicalReads { get; set; } + public long MaxPhysicalReads { get; set; } + public long MinRows { get; set; } + public long MaxRows { get; set; } + public long MinSpills { get; set; } + public long MaxSpills { get; set; } + public long TotalClrTimeUs { get; set; } + public string QueryPlanHash { get; set; } = ""; + public DateTime? CreationTime { get; set; } + public DateTime? LastExecutionTime { get; set; } + public long TotalExecutions { get; set; } + public long TotalCpuUs { get; set; } + public long TotalElapsedUs { get; set; } + public long TotalLogicalReads { get; set; } + public long TotalLogicalWrites { get; set; } + public long TotalPhysicalReads { get; set; } + public long TotalRows { get; set; } + public long TotalSpills { get; set; } + public string SqlHandle { get; set; } = ""; + public string PlanHandle { get; set; } = ""; + public string QueryHash { get; set; } = ""; + public int? SampleIntervalSeconds { get; set; } + public double DeltaCpuMs => DeltaCpuUs / 1000.0; + public double DeltaElapsedMs => DeltaElapsedUs / 1000.0; + public double AvgCpuMs => DeltaExecutions > 0 ? DeltaCpuMs / DeltaExecutions : 0; + public double AvgElapsedMs => DeltaExecutions > 0 ? DeltaElapsedMs / DeltaExecutions : 0; + public double AvgReads => DeltaExecutions > 0 ? (double)DeltaLogicalReads / DeltaExecutions : 0; + public double AvgPhysicalReads => DeltaExecutions > 0 ? (double)DeltaPhysicalReads / DeltaExecutions : 0; + public double AvgWrites => DeltaExecutions > 0 ? (double)DeltaLogicalWrites / DeltaExecutions : 0; + public double AvgRows => DeltaExecutions > 0 ? (double)DeltaRows / DeltaExecutions : 0; + public double MinCpuMs => MinCpuUs / 1000.0; + public double MaxCpuMs => MaxCpuUs / 1000.0; + public double MinElapsedMs => MinElapsedUs / 1000.0; + public double MaxElapsedMs => MaxElapsedUs / 1000.0; + public double TotalClrMs => TotalClrTimeUs / 1000.0; + public double TotalCpuMs => TotalCpuUs / 1000.0; + public double TotalElapsedMs => TotalElapsedUs / 1000.0; + public string CollectionTimeLocal => HistoryTime.CollectionLocal(CollectionTime); + public string CreationTimeLocal => ViewerDataService.FormatServerClock(CreationTime); + public string LastExecutionTimeLocal => ViewerDataService.FormatServerClock(LastExecutionTime); + + /// <summary>Whether this snapshot carries a plan_handle — gates the history grid's "Fetch Live Plan" item + /// (the live-cache fetch is keyed on plan_handle, distinct from the stored plan "View Plan" opens).</summary> + public bool HasLivePlanHandle => !string.IsNullOrEmpty(PlanHandle); +} + +/// <summary>One collected procedure_stats snapshot for a single (database, schema, object) — Lite's +/// <c>ProcedureStatsHistoryRow</c>.</summary> +public sealed class ViewerProcedureStatsHistoryRow +{ + public DateTime CollectionTime { get; set; } + public long DeltaExecutions { get; set; } + public long DeltaCpuUs { get; set; } + public long DeltaElapsedUs { get; set; } + public long DeltaLogicalReads { get; set; } + public long DeltaLogicalWrites { get; set; } + public long DeltaPhysicalReads { get; set; } + public long MinWorkerTimeUs { get; set; } + public long MaxWorkerTimeUs { get; set; } + public long MinElapsedTimeUs { get; set; } + public long MaxElapsedTimeUs { get; set; } + public long TotalSpills { get; set; } + public long MinLogicalReads { get; set; } + public long MaxLogicalReads { get; set; } + public long MinPhysicalReads { get; set; } + public long MaxPhysicalReads { get; set; } + public long MinLogicalWrites { get; set; } + public long MaxLogicalWrites { get; set; } + public long MinSpills { get; set; } + public long MaxSpills { get; set; } + public string SqlHandle { get; set; } = ""; + public string PlanHandle { get; set; } = ""; + public DateTime? CachedTime { get; set; } + public DateTime? LastExecutionTime { get; set; } + public string ObjectType { get; set; } = ""; + public long TotalExecutions { get; set; } + public long TotalCpuUs { get; set; } + public long TotalElapsedUs { get; set; } + public long TotalLogicalReads { get; set; } + public long TotalPhysicalReads { get; set; } + public long TotalLogicalWrites { get; set; } + public long DeltaSpills { get; set; } + public int? SampleIntervalSeconds { get; set; } + public double DeltaCpuMs => DeltaCpuUs / 1000.0; + public double DeltaElapsedMs => DeltaElapsedUs / 1000.0; + public double AvgCpuMs => DeltaExecutions > 0 ? DeltaCpuMs / DeltaExecutions : 0; + public double AvgElapsedMs => DeltaExecutions > 0 ? DeltaElapsedMs / DeltaExecutions : 0; + public double AvgReads => DeltaExecutions > 0 ? (double)DeltaLogicalReads / DeltaExecutions : 0; + public double AvgPhysicalReads => DeltaExecutions > 0 ? (double)DeltaPhysicalReads / DeltaExecutions : 0; + public double AvgWrites => DeltaExecutions > 0 ? (double)DeltaLogicalWrites / DeltaExecutions : 0; + public double AvgSpills => DeltaExecutions > 0 ? (double)DeltaSpills / DeltaExecutions : 0; + public double MinCpuMs => MinWorkerTimeUs / 1000.0; + public double MaxCpuMs => MaxWorkerTimeUs / 1000.0; + public double MinElapsedMs => MinElapsedTimeUs / 1000.0; + public double MaxElapsedMs => MaxElapsedTimeUs / 1000.0; + public double TotalCpuMs => TotalCpuUs / 1000.0; + public double TotalElapsedMs => TotalElapsedUs / 1000.0; + public string CollectionTimeLocal => HistoryTime.CollectionLocal(CollectionTime); + public string CachedTimeLocal => ViewerDataService.FormatServerClock(CachedTime); + public string LastExecutionTimeLocal => ViewerDataService.FormatServerClock(LastExecutionTime); + + /// <summary>Whether this snapshot carries a plan_handle — gates the history grid's "Fetch Live Plan" item + /// (the live-cache fetch is keyed on plan_handle, distinct from the stored plan "View Plan" opens).</summary> + public bool HasLivePlanHandle => !string.IsNullOrEmpty(PlanHandle); +} + +/// <summary>One collected query_store_stats snapshot for a single (database, query_id) plan — Lite's +/// <c>QueryStoreHistoryRow</c>. The avg / min / max metric members are already ms (duration/CPU/CLR) or MB +/// (memory / tempdb / log) — converted in SQL. A query can span several plans over the window; the drill-down +/// chart draws one series per <see cref="PlanId"/>.</summary> +public sealed class ViewerQueryStoreHistoryRow +{ + public DateTime CollectionTime { get; set; } + public long ExecutionCount { get; set; } + public double AvgDurationMs { get; set; } + public double AvgCpuTimeMs { get; set; } + public double AvgLogicalReads { get; set; } + public double AvgLogicalWrites { get; set; } + public double AvgPhysicalReads { get; set; } + public double AvgRowcount { get; set; } + public DateTime? LastExecutionTime { get; set; } + public long MinDop { get; set; } + public long MaxDop { get; set; } + public string ExecutionTypeDesc { get; set; } = ""; + public DateTime? FirstExecutionTime { get; set; } + public string ModuleName { get; set; } = ""; + public double AvgNumPhysicalReads { get; set; } + public double MinNumPhysicalReads { get; set; } + public double MaxNumPhysicalReads { get; set; } + public double AvgClrTimeMs { get; set; } + public double MinClrTimeMs { get; set; } + public double MaxClrTimeMs { get; set; } + public double AvgLogMb { get; set; } + public double MinLogMb { get; set; } + public double MaxLogMb { get; set; } + public long PlanId { get; set; } + public double MinDurationMs { get; set; } + public double MaxDurationMs { get; set; } + public double MinCpuTimeMs { get; set; } + public double MaxCpuTimeMs { get; set; } + public double MinLogicalReads { get; set; } + public double MaxLogicalReads { get; set; } + public double MinLogicalWrites { get; set; } + public double MaxLogicalWrites { get; set; } + public double MinPhysicalReads { get; set; } + public double MaxPhysicalReads { get; set; } + public double MinRowcount { get; set; } + public double MaxRowcount { get; set; } + public double AvgMemoryMb { get; set; } + public double MinMemoryMb { get; set; } + public double MaxMemoryMb { get; set; } + public double AvgTempdbMb { get; set; } + public double MinTempdbMb { get; set; } + public double MaxTempdbMb { get; set; } + public string PlanType { get; set; } = ""; + public bool IsForcedPlan { get; set; } + public long ForceFailureCount { get; set; } + public string LastForceFailureReason { get; set; } = ""; + public string PlanForcingType { get; set; } = ""; + public int CompatibilityLevel { get; set; } + public string QueryHash { get; set; } = ""; + public string QueryPlanHash { get; set; } = ""; + + public double TotalDurationMs => ExecutionCount * AvgDurationMs; + public double TotalCpuMs => ExecutionCount * AvgCpuTimeMs; + public string CollectionTimeLocal => HistoryTime.CollectionLocal(CollectionTime); + public string FirstExecutionTimeLocal => ViewerDataService.FormatServerClock(FirstExecutionTime); + public string LastExecutionTimeLocal => ViewerDataService.FormatServerClock(LastExecutionTime); +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerLogger.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerLogger.cs new file mode 100644 index 000000000..7ad1a9037 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerLogger.cs @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Text; +using System.Threading; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The viewer's minimal file-based application logger — a faithful port of Lite's front-end +/// <c>AppLogger</c> (buffered, thread-safe, daily-rotated), added so the sidebar's "View Log" / +/// "Open Log Folder" buttons have a real target. Before this the viewer wrote no application log of +/// its own (diagnostics went to <see cref="System.Diagnostics.Debug"/> output), which is invisible to +/// an operator collecting logs for a bug report. Writes one file per day under the viewer's per-user +/// directory (%APPDATA%\PerformanceMonitorDarling\logs\darling-viewer_yyyyMMdd.log) — the same +/// Darling-branded Roaming folder <see cref="ViewerPreferencesStore"/> and +/// <see cref="ViewerAppSettingsStore"/> already use — so all viewer state sits in one place. +/// </summary> +public static class ViewerLogger +{ + private static readonly ConcurrentQueue<string> s_buffer = new(); + private static readonly Timer s_flushTimer; + private static string s_logDirectory = ""; + private static bool s_initialized; + private static readonly object s_flushLock = new(); + + static ViewerLogger() + { + s_flushTimer = new Timer(_ => Flush(), null, Timeout.Infinite, Timeout.Infinite); + } + + /// <summary>%APPDATA%\PerformanceMonitorDarling\logs — the viewer's per-user log directory.</summary> + public static string DefaultLogDirectory() => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "PerformanceMonitorDarling", + "logs"); + + /// <summary> + /// Points the logger at a directory (default <see cref="DefaultLogDirectory"/>) and starts the 5-second + /// flush timer. Safe to call once on startup; a failure to create the directory leaves the logger a + /// silent no-op rather than crashing the app. + /// </summary> + public static void Initialize(string? logDirectory = null) + { + try + { + s_logDirectory = logDirectory ?? DefaultLogDirectory(); + if (!Directory.Exists(s_logDirectory)) + { + Directory.CreateDirectory(s_logDirectory); + } + s_initialized = true; + s_flushTimer.Change(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5)); + Info("ViewerLogger", "Logging initialized"); + } + catch + { + /* A logging directory we cannot create must never take down the viewer. */ + s_initialized = false; + } + } + + public static void Info(string source, string message) => Log("INFO", source, message); + + public static void Warn(string source, string message) => Log("WARN", source, message); + + public static void Error(string source, string message, Exception? ex = null) + { + if (ex is not null) + { + Log("ERROR", source, $"{message} | {ex.GetType().Name}: {ex.Message}"); + Log("ERROR", source, $"Stack: {ex.StackTrace}"); + + var inner = ex.InnerException; + var depth = 1; + while (inner is not null) + { + Log("ERROR", source, $"Inner[{depth}]: {inner.GetType().Name}: {inner.Message}"); + inner = inner.InnerException; + depth++; + } + } + else + { + Log("ERROR", source, message); + } + } + + private static void Log(string level, string source, string message) + { + if (!s_initialized) + { + return; + } + + s_buffer.Enqueue($"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{level,-5}] [{source}] {message}"); + } + + /// <summary>Drains the buffer to today's log file. Serialized so the timer and shutdown never collide.</summary> + public static void Flush() + { + if (!s_initialized || s_buffer.IsEmpty) + { + return; + } + + lock (s_flushLock) + { + try + { + var sb = new StringBuilder(); + while (s_buffer.TryDequeue(out var line)) + { + sb.AppendLine(line); + } + + if (sb.Length > 0) + { + File.AppendAllText(GetCurrentLogFile(), sb.ToString()); + } + } + catch + { + /* Never let a logging failure crash the app. */ + } + } + } + + public static void Shutdown() + { + s_flushTimer.Change(Timeout.Infinite, Timeout.Infinite); + Flush(); + s_flushTimer.Dispose(); + } + + /// <summary>The resolved log directory (empty until <see cref="Initialize"/> succeeds).</summary> + public static string GetLogDirectory() => s_logDirectory; + + /// <summary>Today's log file path (darling-viewer_yyyyMMdd.log under the log directory).</summary> + public static string GetCurrentLogFile() => + Path.Combine(s_logDirectory, $"darling-viewer_{DateTime.Now:yyyyMMdd}.log"); +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerPreferences.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerPreferences.cs new file mode 100644 index 000000000..0b923ee50 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerPreferences.cs @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Diagnostics; +using System.IO; +using System.Text.Json; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The viewer's per-USER preferences — the persisted DEFAULTS that seed a newly-opened server tab's +/// toolbar (see ViewerServerTab.TimeRange.cs). This is deliberately distinct from <see cref="ViewerSettings"/>, +/// which reads the SERVICE's darling.json (the Postgres connection): those are the operator's collection +/// config; these are the viewer's own tiny UI state, written when the user changes them in the Settings +/// window and read when each server tab opens. Two settings, three values: the default time-range preset, +/// and the default auto-refresh state (on/off + interval). +/// The values are stored as the toolbar combos' own indices — the toolbar is the only consumer and is +/// index-native (TimeRangeCombo.SelectedIndex, AutoRefreshIntervalCombo.SelectedIndex), so seeding a tab +/// is a direct assignment with no second mapping layer. The Settings window mirrors the same item order, +/// so an index means the same thing in both. Out-of-range values are clamped to the historical defaults +/// on load (see <see cref="Normalize"/>), so a hand-edited or forward-version file never leaves a combo +/// unselected. The mutable properties are what <see cref="System.Text.Json.JsonSerializer"/> round-trips. +/// </summary> +public sealed class ViewerPreferences +{ + /* The historical toolbar defaults (the XAML SelectedIndex / IsChecked values these settings replace): + time-range index 3 = "Last 24 hours", auto-refresh ON, interval index 1 = "1m". A default-constructed + ViewerPreferences therefore reproduces the pre-settings behavior exactly. */ + internal const int DefaultTimeRangeIndexValue = 3; + internal const int DefaultAutoRefreshIntervalIndexValue = 1; + + /* The toolbar's time-range combo lists 1h / 4h / 12h / 24h / 7d / Custom; a persisted DEFAULT only + covers the five presets (0..4) — "Custom Range" needs concrete From/To dates that make no sense as a + standing default, so it is intentionally not offered here. The interval combo lists 30s / 1m / 5m (0..2). */ + internal const int MaxTimeRangeIndex = 4; + internal const int MaxAutoRefreshIntervalIndex = 2; + + /// <summary>Default time window for a newly-opened server tab: 0=1h, 1=4h, 2=12h, 3=24h, 4=7d.</summary> + public int DefaultTimeRangeIndex { get; set; } = DefaultTimeRangeIndexValue; + + /// <summary>Whether a newly-opened server tab starts with auto-refresh running.</summary> + public bool AutoRefreshEnabled { get; set; } = true; + + /// <summary>Default auto-refresh cadence for a newly-opened server tab: 0=30s, 1=1m, 2=5m.</summary> + public int AutoRefreshIntervalIndex { get; set; } = DefaultAutoRefreshIntervalIndexValue; + + /// <summary> + /// Clamps the two index values back to their defaults when they fall outside the combos' valid ranges + /// (a corrupt, hand-edited, or forward-version file), then returns this same instance for chaining. + /// Pure and side-effect-free beyond the clamp, so it is unit-testable without any file I/O. + /// </summary> + internal ViewerPreferences Normalize() + { + if (DefaultTimeRangeIndex < 0 || DefaultTimeRangeIndex > MaxTimeRangeIndex) + { + DefaultTimeRangeIndex = DefaultTimeRangeIndexValue; + } + + if (AutoRefreshIntervalIndex < 0 || AutoRefreshIntervalIndex > MaxAutoRefreshIntervalIndex) + { + AutoRefreshIntervalIndex = DefaultAutoRefreshIntervalIndexValue; + } + + return this; + } +} + +/// <summary> +/// Loads and saves <see cref="ViewerPreferences"/> as a small JSON file in the viewer's per-user app-data +/// directory (mirroring the Dashboard's <c>UserPreferencesService</c> idiom — %APPDATA%\{app}\*.json, +/// indented JSON, defaults on a missing or corrupt file). The default path is +/// %APPDATA%\PerformanceMonitorDarling\viewer-preferences.json: the Darling-branded per-user folder (the +/// Roaming root keeps it clear of the service's machine-wide %ProgramData%\PerformanceMonitorDarling data), +/// with the file namespaced to the viewer. The file path is injectable so tests round-trip against a temp +/// file instead of the real profile. +/// </summary> +public sealed class ViewerPreferencesStore +{ + private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true }; + + private readonly string _filePath; + + /// <param name="filePath">Override the on-disk location (tests pass a temp file); null uses <see cref="DefaultFilePath"/>.</param> + public ViewerPreferencesStore(string? filePath = null) + { + _filePath = filePath ?? DefaultFilePath(); + } + + /// <summary>The resolved settings file path (surfaced mainly so tests and diagnostics can name it).</summary> + public string FilePath => _filePath; + + /// <summary>%APPDATA%\PerformanceMonitorDarling\viewer-preferences.json.</summary> + public static string DefaultFilePath() + { + var directory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "PerformanceMonitorDarling"); + return Path.Combine(directory, "viewer-preferences.json"); + } + + /// <summary> + /// Reads the settings, returning defaults when the file does not exist yet or cannot be read/parsed — + /// the viewer never blocks on a first run or a corrupt file. Loaded values are clamped to valid ranges. + /// </summary> + public ViewerPreferences Load() + { + try + { + if (!File.Exists(_filePath)) + { + return new ViewerPreferences(); + } + + var json = File.ReadAllText(_filePath); + var preferences = JsonSerializer.Deserialize<ViewerPreferences>(json, s_jsonOptions); + return (preferences ?? new ViewerPreferences()).Normalize(); + } + catch (Exception ex) + { + /* The viewer writes no application log of its own (its diagnostics go to Debug output), so a + corrupt-file fallback is a Debug trace, not a log entry — and never a crash on startup. */ + Debug.WriteLine($"ViewerPreferencesStore: failed to load '{_filePath}', using defaults: {ex.Message}"); + return new ViewerPreferences(); + } + } + + /// <summary>Writes the settings as indented JSON, creating the app-data directory on first save.</summary> + public void Save(ViewerPreferences preferences) + { + ArgumentNullException.ThrowIfNull(preferences); + + var directory = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + File.WriteAllText(_filePath, JsonSerializer.Serialize(preferences, s_jsonOptions)); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerProfileStore.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerProfileStore.cs new file mode 100644 index 000000000..aad079639 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerProfileStore.cs @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The viewer's registry of named, reusable <see cref="ViewerCredentialProfile"/> records — the Darling +/// port of Lite's <c>ProfileManager</c>, backing the Add / Edit / Delete surfaces launched from +/// <see cref="ManageServersWindow"/>. Persists a bare list as indented JSON in the viewer's per-user +/// directory (%APPDATA%\PerformanceMonitorDarling\viewer-profiles.json), alongside +/// <see cref="ViewerServerStore"/>'s registry, and stores each profile's secret in Windows Credential +/// Manager keyed by <c>profile_{id}</c> (never in the JSON) through the SAME +/// <see cref="IViewerServerSecretStore"/> the server store uses — the <c>profile_</c> prefix keeps +/// profile secrets from colliding with per-server secrets (bare GUID keys). +/// +/// <para>Holds a ONE-WAY reference to <see cref="ViewerServerStore"/> for the referential-integrity +/// (in-use) query only: a profile can't be deleted while a server still references it. Both the on-disk +/// path and the secret store are injectable so tests round-trip against a temp file and an in-memory +/// secret fake without touching the real registry or vault. As with the rest of the Lite→Darling port, +/// this persists faithfully; the running SERVICE resolving a profile-backed connection is separate wiring +/// flagged in the PR (the viewer itself never opens SqlClient — only credential MANAGEMENT lives here).</para> +/// </summary> +public sealed class ViewerProfileStore +{ + /// <summary>Credential Manager key prefix for profile secrets, combined with the bare id → <c>profile_{id}</c>.</summary> + public const string ProfileKeyPrefix = "profile_"; + + private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true }; + + private readonly string _filePath; + private readonly IViewerServerSecretStore _secrets; + private readonly ViewerServerStore _serverStore; + private readonly List<ViewerCredentialProfile> _profiles; + + /// <summary>Builds the Credential Manager key for a profile's secret (<c>profile_{id}</c>).</summary> + public static string ProfileCredentialId(string profileId) => $"{ProfileKeyPrefix}{profileId}"; + + /// <param name="serverStore">The server registry, queried for the referential-integrity (in-use) check.</param> + /// <param name="filePath">Override the on-disk location (tests pass a temp file); null uses <see cref="DefaultFilePath"/>.</param> + /// <param name="secretStore">Override the credential vault (tests pass an in-memory fake); null uses the real Credential Manager.</param> + public ViewerProfileStore(ViewerServerStore serverStore, string? filePath = null, IViewerServerSecretStore? secretStore = null) + { + _serverStore = serverStore ?? throw new ArgumentNullException(nameof(serverStore)); + _filePath = filePath ?? DefaultFilePath(); + _secrets = secretStore ?? new WindowsCredentialServerSecretStore(); + _profiles = LoadFromDisk(); + } + + /// <summary>The resolved registry file path (surfaced for tests and diagnostics).</summary> + public string FilePath => _filePath; + + /// <summary>%APPDATA%\PerformanceMonitorDarling\viewer-profiles.json.</summary> + public static string DefaultFilePath() + { + var directory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "PerformanceMonitorDarling"); + return Path.Combine(directory, "viewer-profiles.json"); + } + + /// <summary>All profiles, sorted by display name (case-insensitive). Fresh snapshot; safe to mutate.</summary> + public List<ViewerCredentialProfile> GetAll() => + _profiles.OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase).ToList(); + + /// <summary>The profile with this id, or null.</summary> + public ViewerCredentialProfile? GetProfile(string id) + { + if (string.IsNullOrEmpty(id)) + { + return null; + } + + return _profiles.FirstOrDefault(p => p.Id == id); + } + + /// <summary> + /// Adds a new profile and stores its secret. For SqlServer/ServicePrincipal the secret goes to + /// Credential Manager under <c>profile_{id}</c>; ManagedIdentity stores none. Rejects a duplicate id + /// or (per-process) a duplicate display name. + /// </summary> + public void AddProfile(ViewerCredentialProfile profile, string? username = null, string? secret = null) + { + ArgumentNullException.ThrowIfNull(profile); + + if (_profiles.Any(p => p.Id == profile.Id)) + { + throw new InvalidOperationException($"Profile with ID {profile.Id} already exists"); + } + if (_profiles.Any(p => string.Equals(p.Name, profile.Name, StringComparison.OrdinalIgnoreCase))) + { + throw new InvalidOperationException($"A profile named '{profile.Name}' already exists"); + } + + _profiles.Add(profile); + StoreSecretIfNeeded(profile, username, secret); + Save(); + } + + /// <summary> + /// Updates an existing profile (and re-stores its secret when a fresh one is supplied). Editing a + /// profile's secret intentionally affects ALL servers that reference it. A blank secret on edit leaves + /// the stored secret unchanged; switching to ManagedIdentity clears any stale stored secret. + /// </summary> + public void UpdateProfile(ViewerCredentialProfile profile, string? username = null, string? secret = null) + { + ArgumentNullException.ThrowIfNull(profile); + + var existing = _profiles.FirstOrDefault(p => p.Id == profile.Id) + ?? throw new InvalidOperationException($"Profile with ID {profile.Id} not found"); + if (_profiles.Any(p => p.Id != profile.Id && + string.Equals(p.Name, profile.Name, StringComparison.OrdinalIgnoreCase))) + { + throw new InvalidOperationException($"A profile named '{profile.Name}' already exists"); + } + + _profiles[_profiles.IndexOf(existing)] = profile; + + if (profile.AuthType == AuthenticationTypes.ManagedIdentity) + { + /* MI never has a secret; clear any stale one (e.g. profile retyped from SP -> MI). */ + _secrets.Delete(ProfileCredentialId(profile.Id)); + } + else if (!string.IsNullOrEmpty(username) && secret is not null) + { + /* Only re-store when a fresh secret was supplied (a blank PasswordBox on edit means "leave it"). */ + _secrets.Save(ProfileCredentialId(profile.Id), username, secret); + } + + Save(); + } + + /// <summary> + /// Deletes a profile and its stored secret. Blocked (throws) while ANY server references it — + /// referential integrity is a delete-constraint, not a cascade. + /// </summary> + public void DeleteProfile(string id) + { + ArgumentException.ThrowIfNullOrEmpty(id); + + var inUse = CountServersUsingProfile(id); + if (inUse > 0) + { + throw new InvalidOperationException( + $"This profile is used by {inUse} server(s). Reassign or remove them first."); + } + + var profile = _profiles.FirstOrDefault(p => p.Id == id); + if (profile is not null) + { + _profiles.Remove(profile); + _secrets.Delete(ProfileCredentialId(id)); + Save(); + } + } + + /// <summary>The servers (in the registry) that reference the given profile id.</summary> + public int CountServersUsingProfile(string profileId) + { + if (string.IsNullOrEmpty(profileId)) + { + return 0; + } + + return _serverStore.GetAllServers().Count(s => s.CredentialProfileId == profileId); + } + + /// <summary> + /// Whether a profile's secret is present in Credential Manager. SqlServer/ServicePrincipal require + /// one; ManagedIdentity needs none (returns true). + /// </summary> + public bool HasStoredSecret(ViewerCredentialProfile profile) + { + if (profile.AuthType == AuthenticationTypes.ManagedIdentity) + { + return true; + } + + return _secrets.Get(ProfileCredentialId(profile.Id)) is not null; + } + + /// <summary>The stored (username, secret) for a profile, or null — used to pre-fill the profile editor on edit.</summary> + public (string Username, string Password)? GetSecret(string profileId) => + _secrets.Get(ProfileCredentialId(profileId)); + + private void StoreSecretIfNeeded(ViewerCredentialProfile profile, string? username, string? secret) + { + if (profile.AuthType == AuthenticationTypes.ManagedIdentity) + { + return; // no secret + } + + if (!string.IsNullOrEmpty(username) && secret is not null) + { + _secrets.Save(ProfileCredentialId(profile.Id), username, secret); + } + } + + private List<ViewerCredentialProfile> LoadFromDisk() + { + try + { + if (!File.Exists(_filePath)) + { + return new List<ViewerCredentialProfile>(); + } + + var json = File.ReadAllText(_filePath); + return JsonSerializer.Deserialize<List<ViewerCredentialProfile>>(json, s_jsonOptions) + ?? new List<ViewerCredentialProfile>(); + } + catch (Exception ex) + { + /* A corrupt or unreadable registry must never block startup — begin empty and log. */ + ViewerLogger.Warn("ViewerProfileStore", $"Failed to load '{_filePath}': {ex.Message}"); + return new List<ViewerCredentialProfile>(); + } + } + + private void Save() + { + var directory = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + File.WriteAllText(_filePath, JsonSerializer.Serialize(_profiles, s_jsonOptions)); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerEntry.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerEntry.cs new file mode 100644 index 000000000..9d462713f --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerEntry.cs @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Text.Json.Serialization; +using PerformanceMonitor.Common; +using PerformanceMonitor.Notifications; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// One monitored-server DEFINITION in the viewer's own registry — the Darling analog of Lite's +/// <see cref="T:PerformanceMonitorLite.Models.ServerConnection"/>, ported for the viewer's Add / Manage / +/// Edit surfaces (persisted by <see cref="ViewerServerStore"/> to viewer-servers.json). +/// +/// <para><b>Persistence adaptation (matching the #1402 Settings port).</b> In Lite a +/// <c>ServerConnection</c> both configures the app's in-process collector AND builds the live SqlClient +/// connection string. The Darling viewer never connects to monitored SQL Servers (it reads only the +/// Postgres store — see the README), so this is a pure DECLARATIVE record: the same operator-facing +/// fields, but with no connection-string builder and no SqlClient dependency. The viewer persists these +/// faithfully so the Add/Manage/Edit UI is complete and survives a restart; making the running SERVICE +/// honor an added/edited server (it reads darling.json and registers servers into the store) is a +/// separate wiring concern flagged in the PR. Favorites are the one field the viewer acts on today — +/// the sidebar stars + pins them.</para> +/// +/// <para>Secrets (the SQL password and the service-principal client secret) are NOT stored here — like +/// Lite, they go to Windows Credential Manager, keyed by <see cref="Id"/>, via +/// <see cref="ViewerServerStore"/>. The optional Entra UPN is a non-secret hint and IS persisted here +/// (a small viewer adaptation of Lite, which parked it in the credential store).</para> +/// </summary> +public sealed class ViewerServerEntry : INotifyPropertyChanged +{ + public string Id { get; set; } = Guid.NewGuid().ToString(); + public string ServerName { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + + /// <summary>Windows, SqlServer, EntraMFA, ServicePrincipal, or ManagedIdentity (see <see cref="AuthenticationTypes"/>).</summary> + public string AuthenticationType { get; set; } = AuthenticationTypes.Windows; + + /// <summary> + /// Id of a shared <see cref="ViewerCredentialProfile"/> (from <see cref="ViewerProfileStore"/>) this + /// server references, or null for inline per-server auth. When set, the profile supplies the identity + /// (the Darling service resolves it at connect time) and no per-server secret is stored — mirroring + /// Lite's <c>ServerConnection.CredentialProfileId</c>. + /// </summary> + public string? CredentialProfileId { get; set; } + + /// <summary>Service-principal application/client id (non-secret; the SP secret lives in Credential Manager).</summary> + public string? AzureClientId { get; set; } + + /// <summary>Optional Entra tenant id (stored for reference only).</summary> + public string? AzureTenantId { get; set; } + + /// <summary>Optional user-assigned managed-identity client id (blank = system-assigned).</summary> + public string? ManagedIdentityClientId { get; set; } + + /// <summary>Optional Entra MFA UPN hint (non-secret). Viewer adaptation — Lite kept this in the credential store.</summary> + public string? EntraUsername { get; set; } + + public string? Description { get; set; } + public DateTime CreatedDate { get; set; } = DateTime.Now; + public DateTime LastConnected { get; set; } = DateTime.Now; + public bool IsFavorite { get; set; } + public bool IsEnabled { get; set; } = true; + + /// <summary>Encryption mode: Optional, Mandatory (default), or Strict.</summary> + public string EncryptMode { get; set; } = "Mandatory"; + + public bool TrustServerCertificate { get; set; } + + /// <summary>Monthly cost in USD for FinOps cost attribution; 0 hides cost columns.</summary> + public decimal MonthlyCostUsd { get; set; } + + /// <summary>Per-server override of the deadlock/blocking alert delivery mode; null = inherit the global setting.</summary> + public AlertNotificationMode? AlertDeliveryModeOverride { get; set; } + + /// <summary>Optional initial-connection database (required for Azure SQL Database).</summary> + public string? DatabaseName { get; set; } + + /// <summary>Optional database where community stored procedures (sp_IndexCleanup) are installed.</summary> + public string? UtilityDatabase { get; set; } + + /// <summary>Sets ApplicationIntent=ReadOnly on the eventual connection (AG readable replicas).</summary> + public bool ReadOnlyIntent { get; set; } + + /// <summary>Sets MultiSubnetFailover=true (AG listeners and FCIs spanning subnets).</summary> + public bool MultiSubnetFailover { get; set; } + + /// <summary>User databases to skip in per-database collectors.</summary> + public List<string> ExcludedDatabases { get; set; } = new(); + + /// <summary> + /// #1319: the per-server global database FILTER (display-only). When non-empty, database-scoped views + /// (query grids, blocking, per-DB config, trends/slicers/heatmap) show only these databases; empty = + /// "All". Distinct from <see cref="ExcludedDatabases"/> (collector-side "don't COLLECT"); this changes + /// only what the viewer displays. Persisted per-server in viewer-servers.json so a subset stays sticky. + /// </summary> + public List<string> ViewFilterDatabases { get; set; } = new(); + + /// <summary>Server name with "(Read-Only)" suffix when <see cref="ReadOnlyIntent"/> is set (grid + status text).</summary> + [JsonIgnore] + public string ServerNameDisplay => ReadOnlyIntent ? $"{ServerName} (Read-Only)" : ServerName; + + /// <summary>Display name with "(Read-Only)" suffix when <see cref="ReadOnlyIntent"/> is set.</summary> + [JsonIgnore] + public string DisplayNameWithIntent => ReadOnlyIntent ? $"{DisplayName} (Read-Only)" : DisplayName; + + /// <summary>Human-readable authentication label for the Manage Servers grid.</summary> + [JsonIgnore] + public string AuthenticationDisplay => AuthenticationType switch + { + AuthenticationTypes.EntraMFA => "Microsoft Entra MFA", + AuthenticationTypes.SqlServer => "SQL Server", + AuthenticationTypes.ServicePrincipal => "Azure — Service Principal", + AuthenticationTypes.ManagedIdentity => "Azure — Managed Identity", + _ => "Windows" + }; + + /// <summary>"Enabled"/"Disabled" for the Manage Servers grid.</summary> + [JsonIgnore] + public string StatusDisplay => IsEnabled ? "Enabled" : "Disabled"; + + private string? _installedVersion; + + /// <summary> + /// Viewer version populated by the Manage Servers window (runtime-only display field, not persisted). + /// Same value for every row since one viewer shows all servers. + /// </summary> + [JsonIgnore] + public string? InstalledVersion + { + get => _installedVersion; + set + { + if (_installedVersion == value) + { + return; + } + + _installedVersion = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(InstalledVersion))); + } + } + + public event PropertyChangedEventHandler? PropertyChanged; +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerMigration.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerMigration.cs new file mode 100644 index 000000000..10d2f2d03 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerMigration.cs @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.Versioning; +using System.Threading; +using System.Threading.Tasks; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The one-time import of the (now severed) <c>viewer-servers.json</c> server DEFINITIONS into the +/// control-plane <c>config.config_monitored_servers</c>. Stage 3 makes the store the source of truth for +/// what the service collects; this carries a pre-Stage-3 viewer's registered servers across so they keep +/// being collected without re-adding them. +/// +/// <para><b>No double-seed.</b> Each row is written with <c>INSERT … ON CONFLICT (server_id) DO NOTHING</c> +/// (<see cref="ViewerDataService.InsertMonitoredServerIfAbsentAsync"/>), so a server the service already +/// seeded from darling.json (same shared <c>server_id</c>) is left untouched — the migrate imports only the +/// entries the store LACKS. <b>Runs once.</b> A marker file guards it so a later run cannot RESURRECT a +/// server the user has since removed from the store (the marker is written only after a clean pass). +/// Read-only seats and a disconnected viewer skip it (nothing to write).</para> +/// +/// <para><b>Secrets.</b> An integrated-auth entry migrates with no secret. A SQL-auth entry (inline or via a +/// SQL credential profile) has its password read from Windows Credential Manager and re-sealed as the +/// service-decryptable DPAPI-LocalMachine blob (<see cref="ViewerServerSecret"/>); a SQL entry whose secret +/// cannot be resolved is skipped rather than imported broken. Azure/Entra auth modes the service can't honor +/// (<see cref="ServerStoreCredential"/>) are skipped. Favorites are NOT migrated — they stay viewer-local in +/// <see cref="ViewerServerStore"/>.</para> +/// </summary> +public sealed class ViewerServerMigration +{ + private readonly ViewerServerStore _serverStore; + private readonly ViewerProfileStore _profileStore; + private readonly string _markerPath; + + /// <param name="markerPath">Override the once-marker path (tests pass a temp file); null uses <see cref="DefaultMarkerPath"/>.</param> + public ViewerServerMigration(ViewerServerStore serverStore, ViewerProfileStore profileStore, string? markerPath = null) + { + _serverStore = serverStore ?? throw new ArgumentNullException(nameof(serverStore)); + _profileStore = profileStore ?? throw new ArgumentNullException(nameof(profileStore)); + _markerPath = markerPath ?? DefaultMarkerPath(); + } + + /// <summary>%APPDATA%\PerformanceMonitorDarling\viewer-servers-migrated.marker.</summary> + public static string DefaultMarkerPath() + { + var directory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "PerformanceMonitorDarling"); + return Path.Combine(directory, "viewer-servers-migrated.marker"); + } + + /// <summary>True once the migrate-in has completed a clean pass (the marker exists).</summary> + public bool AlreadyMigrated => File.Exists(_markerPath); + + /// <summary> + /// Imports every migratable <c>viewer-servers.json</c> entry the store lacks, then writes the once-marker. + /// A no-op (returns 0) when the viewer is disconnected, connected read-only, or already migrated. A + /// <see cref="ViewerReadOnlyException"/> mid-pass (grants changed under us) stops without marking done, so + /// a later run can retry. Returns the number of servers actually imported. + /// </summary> + [SupportedOSPlatform("windows")] + public async Task<int> MigrateAsync(ViewerDataService? dataService, CancellationToken cancellationToken = default) + { + if (dataService is null || dataService.IsReadOnly || AlreadyMigrated) + { + return 0; + } + + var imported = 0; + try + { + foreach (var entry in _serverStore.GetAllServers()) + { + var (row, _) = TryProjectEntry(entry); + if (row is null) + { + continue; + } + + if (await dataService.InsertMonitoredServerIfAbsentAsync(row, cancellationToken)) + { + imported++; + } + } + } + catch (ViewerReadOnlyException) + { + /* Lost the write race (grants tightened under us) — leave the marker unwritten so a later, + writable run finishes the import. */ + ViewerLogger.Warn("ViewerServerMigration", "Store went read-only mid-migrate; will retry next run."); + return imported; + } + + WriteMarker(); + return imported; + } + + /// <summary> + /// Imports every migratable entry of an EXTERNAL registry (a folder chosen in Import Settings) into the + /// store — the same projection + <c>ON CONFLICT DO NOTHING</c> as <see cref="MigrateAsync"/>, but ungated + /// by the once-marker (an explicit user action) and against a caller-supplied source store. Returns the + /// number imported. Cross-machine secrets don't travel (they live in the source machine's Credential + /// Manager), so SQL servers with no locally-resolvable secret are skipped — matching Import's existing + /// "credentials are not importable" caveat; integrated servers import fully. + /// </summary> + [SupportedOSPlatform("windows")] + public static async Task<int> ImportFromStoreAsync( + ViewerServerStore sourceStore, + ViewerProfileStore sourceProfiles, + ViewerDataService dataService, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(sourceStore); + ArgumentNullException.ThrowIfNull(sourceProfiles); + ArgumentNullException.ThrowIfNull(dataService); + + var projector = new ViewerServerMigration(sourceStore, sourceProfiles); + var imported = 0; + foreach (var entry in sourceStore.GetAllServers()) + { + var (row, _) = projector.TryProjectEntry(entry); + if (row is not null && await dataService.InsertMonitoredServerIfAbsentAsync(row, cancellationToken)) + { + imported++; + } + } + + return imported; + } + + /// <summary> + /// Projects a viewer registry entry to the store row the migrate writes, or returns a skip reason. Public + /// so Darling.Tests can pin the auth mapping + secret resolution without a live store. Reads Windows + /// Credential Manager (through the injected stores) and DPAPI-seals a SQL secret, so the SQL path is + /// Windows-only; the integrated + unsupported-auth + missing-secret branches are platform-independent. + /// </summary> + public (MonitoredServerRow? Row, string? SkipReason) TryProjectEntry(ViewerServerEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + + if (string.IsNullOrWhiteSpace(entry.ServerName)) + { + return (null, "no server name"); + } + + /* A profile-backed entry resolves to the PROFILE's auth type + concrete secret; otherwise the + entry's own inline auth. */ + var profile = string.IsNullOrEmpty(entry.CredentialProfileId) + ? null + : _profileStore.GetProfile(entry.CredentialProfileId); + + var effectiveAuthType = profile?.AuthType ?? entry.AuthenticationType; + var storeAuth = ServerStoreCredential.MapAuth(effectiveAuthType); + if (storeAuth is null) + { + /* Entra MFA / Service Principal / Managed Identity — no Darling service connect path. */ + return (null, $"auth '{effectiveAuthType}' is not supported by the service"); + } + + var row = BuildBaseRow(entry, storeAuth); + + if (storeAuth != ServerStoreCredential.Sql) + { + /* Integrated — no secret. */ + return (row, null); + } + + var credential = profile is not null + ? _profileStore.GetSecret(profile.Id) + : _serverStore.GetCredential(entry.Id); + + if (credential is null || string.IsNullOrEmpty(credential.Value.Password)) + { + return (null, "SQL auth with no stored password"); + } + + row.Username = string.IsNullOrWhiteSpace(credential.Value.Username) ? row.Username : credential.Value.Username; + row.EncryptedPassword = ViewerServerSecret.Protect(credential.Value.Password); + return (row, null); + } + + /// <summary>Assembles the non-secret fields of the store row from a registry entry (server_id + connection options).</summary> + private static MonitoredServerRow BuildBaseRow(ViewerServerEntry entry, string storeAuth) => new() + { + ServerId = ViewerDataService.ComputeServerId(entry.ServerName, entry.DatabaseName, entry.ReadOnlyIntent), + Name = string.IsNullOrWhiteSpace(entry.DisplayName) ? entry.ServerName : entry.DisplayName, + Host = entry.ServerName, + Database = string.IsNullOrWhiteSpace(entry.DatabaseName) ? null : entry.DatabaseName, + Auth = storeAuth, + EncryptMode = entry.EncryptMode, + TrustServerCertificate = entry.TrustServerCertificate, + ReadOnlyIntent = entry.ReadOnlyIntent, + MultiSubnetFailover = entry.MultiSubnetFailover, + ExcludedDatabases = entry.ExcludedDatabases ?? new List<string>(), + MonthlyCostUsd = entry.MonthlyCostUsd, + /* #1236: carry the per-server delivery override into the store (was viewer-local / dead pre-V18). */ + AlertDeliveryModeOverride = entry.AlertDeliveryModeOverride, + IsEnabled = entry.IsEnabled, + }; + + private void WriteMarker() + { + try + { + var directory = Path.GetDirectoryName(_markerPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + File.WriteAllText(_markerPath, $"migrated {DateTime.UtcNow:O}"); + } + catch (Exception ex) + { + /* A failed marker write only means the migrate re-runs next launch; the ON CONFLICT DO NOTHING + writes are idempotent, so at worst it re-attempts (and skips) the same imports. Don't crash. */ + ViewerLogger.Warn("ViewerServerMigration", $"Could not write the migrate marker '{_markerPath}': {ex.Message}"); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerSecret.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerSecret.cs new file mode 100644 index 000000000..a26d7ec75 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerSecret.cs @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Text; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// Produces (and reads back) the DPAPI-LocalMachine password blob the control-plane writes into +/// <c>config.config_monitored_servers.encrypted_password</c> — the SAME format the service's +/// <c>PerformanceMonitor.Darling.Service.DarlingSecrets</c> reads with <c>--encrypt-password</c>. +/// +/// <para>This is DELIBERATELY distinct from <see cref="ViewerSecretStore"/> / +/// <see cref="WindowsCredentialServerSecretStore"/>, which keep viewer-local secrets in the per-USER +/// Windows Credential Manager. A monitored-server secret has to be decryptable by the SERVICE account, +/// so it must be a DPAPI-<b>LocalMachine</b> blob with the service's entropy, not a per-user vault entry. +/// LocalMachine scope is exactly why the service uses it (its comment: an administrator can encrypt a +/// password interactively that the service account later decrypts on the same machine). This works for +/// the managed single-box deploy (viewer + service on one host); a remote viewer producing a blob a +/// different service host can't decrypt is the documented service-side-provision path.</para> +/// +/// <para>The entropy string is duplicated from the service's <c>DarlingSecrets.s_entropy</c> byte-for-byte +/// under the SAME "sliver rule" <see cref="ViewerSettings"/> already follows for the managed-Postgres +/// credential (the viewer does not reference the Service project); a Darling.Tests round-trip pins them in +/// lockstep so a drift is caught.</para> +/// </summary> +[SupportedOSPlatform("windows")] +public static class ViewerServerSecret +{ + /* Must equal PerformanceMonitor.Darling.Service.DarlingSecrets.s_entropy byte-for-byte, or a + blob this viewer writes cannot be decrypted by the service (pinned by ViewerServerSecretTests). */ + private static readonly byte[] s_entropy = Encoding.UTF8.GetBytes("PerformanceMonitor.Darling.v1"); + + /// <summary>DPAPI-LocalMachine-protects a plaintext password and returns the base64 blob for the store.</summary> + public static string Protect(string plaintext) + { + ArgumentNullException.ThrowIfNull(plaintext); + + var protectedBytes = ProtectedData.Protect( + Encoding.UTF8.GetBytes(plaintext), s_entropy, DataProtectionScope.LocalMachine); + return Convert.ToBase64String(protectedBytes); + } + + /// <summary> + /// Reverses <see cref="Protect"/> for the Edit dialog's password pre-fill, returning null (never throwing) + /// when the blob is empty, malformed, or was produced on a different machine (a remote-viewer blob this + /// host's DPAPI key can't open) — the dialog then shows a blank password with a "re-enter to change" hint + /// rather than dead-clicking into a cryptographic error. + /// </summary> + public static string? TryUnprotect(string? base64Blob) + { + if (string.IsNullOrWhiteSpace(base64Blob)) + { + return null; + } + + try + { + var plainBytes = ProtectedData.Unprotect( + Convert.FromBase64String(base64Blob), s_entropy, DataProtectionScope.LocalMachine); + return Encoding.UTF8.GetString(plainBytes); + } + catch (Exception ex) when (ex is FormatException or CryptographicException) + { + return null; + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerStore.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerStore.cs new file mode 100644 index 000000000..540ed8c59 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerStore.cs @@ -0,0 +1,384 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// Windows Credential Manager access for a server entry's secret (SQL password / SP client secret), +/// abstracted so tests can substitute an in-memory fake and never touch the real per-user vault. +/// </summary> +public interface IViewerServerSecretStore +{ + void Save(string id, string username, string password); + (string Username, string Password)? Get(string id); + void Delete(string id); +} + +/// <summary> +/// Production secret store: the shared <see cref="CredentialStore"/> with the Darling-specific prefix, so +/// viewer per-server secrets never collide with Lite's, the Dashboard's, or the viewer's SMTP/webhook +/// secrets (<see cref="ViewerSecretStore"/>). Failures fail safe to no-op/null — a server-management +/// action must never crash because the credential vault is unavailable. +/// </summary> +public sealed class WindowsCredentialServerSecretStore : IViewerServerSecretStore +{ + private readonly CredentialStore _store = new("PerformanceMonitorDarling_"); + + public void Save(string id, string username, string password) + { + try + { + _store.SaveCredential(id, username, password); + } + catch + { + /* Never let a vault write crash server management. */ + } + } + + public (string Username, string Password)? Get(string id) + { + try + { + return _store.GetCredential(id); + } + catch + { + return null; + } + } + + public void Delete(string id) + { + try + { + _store.DeleteCredential(id); + } + catch + { + /* No-op on a missing key or vault error. */ + } + } +} + +/// <summary> +/// The viewer's own registry of monitored-server DEFINITIONS — the Darling analog of Lite's +/// <c>ServerManager</c>, backing the Add / Manage / Edit / Remove surfaces. Persists a list of +/// <see cref="ViewerServerEntry"/> as indented JSON in the viewer's per-user directory +/// (%APPDATA%\PerformanceMonitorDarling\viewer-servers.json), mirroring +/// <see cref="ViewerPreferencesStore"/> / <see cref="ViewerAppSettingsStore"/>. Secrets go to Windows +/// Credential Manager (keyed by <see cref="ViewerServerEntry.Id"/>), never to the JSON file. +/// +/// <para>Both the on-disk path and the secret store are injectable so tests round-trip against a temp +/// file and an in-memory secret fake without touching the real profile or vault. As with the rest of the +/// Lite→Darling port, this persists faithfully; the running SERVICE honoring these definitions is a +/// separate wiring concern (see the PR).</para> +/// </summary> +public sealed class ViewerServerStore +{ + private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true }; + + private readonly string _filePath; + private readonly IViewerServerSecretStore _secrets; + private readonly List<ViewerServerEntry> _servers; + + /// <param name="filePath">Override the on-disk location (tests pass a temp file); null uses <see cref="DefaultFilePath"/>.</param> + /// <param name="secretStore">Override the credential vault (tests pass an in-memory fake); null uses the real Credential Manager.</param> + public ViewerServerStore(string? filePath = null, IViewerServerSecretStore? secretStore = null) + { + _filePath = filePath ?? DefaultFilePath(); + _secrets = secretStore ?? new WindowsCredentialServerSecretStore(); + _servers = LoadFromDisk(); + } + + /// <summary>The resolved registry file path (surfaced for tests and diagnostics).</summary> + public string FilePath => _filePath; + + /// <summary>%APPDATA%\PerformanceMonitorDarling\viewer-servers.json.</summary> + public static string DefaultFilePath() + { + var directory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "PerformanceMonitorDarling"); + return Path.Combine(directory, "viewer-servers.json"); + } + + /// <summary> + /// All registered server definitions, favorites first (Lite's pin rule), then by display name + /// (case-insensitive). Returns a fresh sorted snapshot; callers may safely mutate the list. + /// </summary> + public List<ViewerServerEntry> GetAllServers() => + _servers + .OrderByDescending(s => s.IsFavorite) + .ThenBy(s => s.DisplayName, StringComparer.OrdinalIgnoreCase) + .ToList(); + + /// <summary>The first registered entry whose server name matches (case-insensitive), or null.</summary> + public ViewerServerEntry? GetByServerName(string serverName) => + _servers.FirstOrDefault(s => string.Equals(s.ServerName, serverName, StringComparison.OrdinalIgnoreCase)); + + /// <summary>Whether a server with this name is marked favorite in the registry.</summary> + public bool IsFavorite(string serverName) => GetByServerName(serverName)?.IsFavorite == true; + + /// <summary>Adds a new server definition, persists it, and stores its secret when one was supplied.</summary> + public void AddServer(ViewerServerEntry entry, string? username, string? password) + { + ArgumentNullException.ThrowIfNull(entry); + _servers.Add(entry); + ApplySecret(entry.Id, username, password); + Save(); + } + + /// <summary> + /// Replaces the registered entry with the same <see cref="ViewerServerEntry.Id"/> (adding it if new), + /// updates its secret, and persists. Mirrors Lite's <c>ServerManager.UpdateServer</c>. + /// </summary> + public void UpdateServer(ViewerServerEntry entry, string? username, string? password) + { + ArgumentNullException.ThrowIfNull(entry); + ReplaceOrAdd(entry); + ApplySecret(entry.Id, username, password); + Save(); + } + + /// <summary>Persists an in-place edit of an existing entry (e.g. excluded databases) without touching its secret.</summary> + public void UpdateServer(ViewerServerEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + ReplaceOrAdd(entry); + Save(); + } + + /// <summary>Removes the server with this id, deletes its stored secret, and persists.</summary> + public void DeleteServer(string id) + { + var removed = _servers.RemoveAll(s => s.Id == id) > 0; + if (removed) + { + _secrets.Delete(id); + Save(); + } + } + + /// <summary> + /// Imports server definitions from another viewer's registry file, upserting by server name — an + /// existing name is skipped (Lite's "skipped duplicate" behavior). Each imported entry gets a fresh id + /// because secrets never cross machines (they live in the source machine's Credential Manager). Returns + /// the imported and skipped counts. + /// </summary> + public (int Imported, int Skipped) ImportServersFromFile(string path) + { + ArgumentException.ThrowIfNullOrEmpty(path); + + var incoming = JsonSerializer.Deserialize<List<ViewerServerEntry>>(File.ReadAllText(path), s_jsonOptions) + ?? new List<ViewerServerEntry>(); + + var imported = 0; + var skipped = 0; + foreach (var entry in incoming) + { + if (string.IsNullOrWhiteSpace(entry.ServerName) || GetByServerName(entry.ServerName) is not null) + { + skipped++; + continue; + } + + entry.Id = Guid.NewGuid().ToString(); + _servers.Add(entry); + imported++; + } + + if (imported > 0) + { + Save(); + } + + return (imported, skipped); + } + + /// <summary> + /// Flips the favorite flag for the server with this name and persists, returning the new state. When no + /// registry entry exists yet (the sidebar server came straight from the Postgres store), a minimal entry + /// is created and marked favorite — "adopting" the collected server into the registry so the one favorites + /// source of truth stays in the registry, exactly like Lite pins on <c>ServerConnection.IsFavorite</c>. + /// </summary> + public bool ToggleFavorite(string serverName) + { + var entry = GetByServerName(serverName); + if (entry is null) + { + entry = new ViewerServerEntry + { + ServerName = serverName, + DisplayName = serverName, + IsFavorite = true + }; + _servers.Add(entry); + } + else + { + entry.IsFavorite = !entry.IsFavorite; + } + + Save(); + return entry.IsFavorite; + } + + /// <summary> + /// Sets (not toggles) the favorite flag for a server by name, creating a minimal viewer-local entry when + /// none exists, and persists. Favorites are the one thing this store keeps after Stage 3 moved server + /// DEFINITIONS to <c>config.config_monitored_servers</c> — they are viewer-local (the service never reads + /// them), so they legitimately stay in viewer-servers.json. Returns the resulting state. + /// </summary> + public bool SetFavorite(string serverName, bool isFavorite) + { + if (string.IsNullOrWhiteSpace(serverName)) + { + return false; + } + + var entry = GetByServerName(serverName); + if (entry is null) + { + if (!isFavorite) + { + /* Nothing pinned and nothing to pin — don't write an empty registry entry. */ + return false; + } + + _servers.Add(new ViewerServerEntry + { + ServerName = serverName, + DisplayName = serverName, + IsFavorite = true + }); + } + else + { + if (entry.IsFavorite == isFavorite) + { + return isFavorite; + } + + entry.IsFavorite = isFavorite; + } + + Save(); + return isFavorite; + } + + /// <summary>#1319: the persisted per-server display database filter (empty list = All / not yet set).</summary> + public List<string> GetViewFilterDatabases(string serverName) + => GetByServerName(serverName)?.ViewFilterDatabases ?? new List<string>(); + + /// <summary> + /// #1319: persists the per-server display database filter, adopting a minimal viewer-local registry + /// entry when the server has none yet (same adopt-on-write rule as <see cref="SetFavorite"/>). An empty + /// list clears the filter (and writes no new entry for a server that had none). + /// </summary> + public void SetViewFilterDatabases(string serverName, List<string> databases) + { + if (string.IsNullOrWhiteSpace(serverName)) + { + return; + } + + var entry = GetByServerName(serverName); + if (entry is null) + { + if (databases.Count == 0) + { + return; + } + + _servers.Add(new ViewerServerEntry + { + ServerName = serverName, + DisplayName = serverName, + ViewFilterDatabases = databases + }); + } + else + { + entry.ViewFilterDatabases = databases; + } + + Save(); + } + + /// <summary>The stored (username, password) for a server id, or null — used to pre-fill the Edit dialog.</summary> + public (string Username, string Password)? GetCredential(string id) => _secrets.Get(id); + + private void ApplySecret(string id, string? username, string? password) + { + if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password)) + { + _secrets.Save(id, username, password); + } + else + { + /* Windows / Entra / Managed Identity carry no per-server secret — clear any stale one so a + later auth-mode switch can't resurrect it (mirrors Lite's scrub-on-zero-touch behavior). */ + _secrets.Delete(id); + } + } + + private void ReplaceOrAdd(ViewerServerEntry entry) + { + var index = _servers.FindIndex(s => s.Id == entry.Id); + if (index >= 0) + { + _servers[index] = entry; + } + else + { + _servers.Add(entry); + } + } + + private List<ViewerServerEntry> LoadFromDisk() + { + try + { + if (!File.Exists(_filePath)) + { + return new List<ViewerServerEntry>(); + } + + var json = File.ReadAllText(_filePath); + return JsonSerializer.Deserialize<List<ViewerServerEntry>>(json, s_jsonOptions) + ?? new List<ViewerServerEntry>(); + } + catch (Exception ex) + { + /* A corrupt or unreadable registry must never block startup — begin empty and log. */ + Debug.WriteLine($"ViewerServerStore: failed to load '{_filePath}', starting empty: {ex.Message}"); + ViewerLogger.Warn("ViewerServerStore", $"Failed to load '{_filePath}': {ex.Message}"); + return new List<ViewerServerEntry>(); + } + } + + private void Save() + { + var directory = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + File.WriteAllText(_filePath, JsonSerializer.Serialize(_servers, s_jsonOptions)); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ActiveQueries.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ActiveQueries.cs new file mode 100644 index 000000000..2bba14704 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ActiveQueries.cs @@ -0,0 +1,298 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using PerformanceMonitor.Common; +using static PerformanceMonitor.Ui.DataGridHelpers; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Queries → Active Queries sub-tab (W1f-2): the <c>ActiveQueriesSlicer</c> + <c>QuerySnapshotsGrid</c> +/// of captured running-query snapshots, copied from Lite's <c>ServerTab</c> (Slicers / Grids / Refresh +/// partials) with reads rewired to <see cref="ViewerDataService"/> Postgres. The grid loads every stored +/// snapshot over the toolbar's settable window (newest first); dragging the slicer re-reads the grid over the +/// selection, and sorting the grid re-labels the slicer's aggregate curve (Lite's <c>QuerySnapshotsGrid_Sorting</c>). +/// Two deliberate deviations from Lite: +/// (1) The "Latest Snapshot" button (Lite's "Live Snapshot") no longer queries the monitored server — +/// per Erik's decision the viewer reads only stored data, so it re-reads the newest persisted +/// snapshot batch (<see cref="ViewerDataService.GetLatestQuerySnapshotBatchAsync"/>). +/// (2) The Estimated / Actual plan buttons open the stored plan through the <see cref="OpenPlanTab"/> +/// partial hook (the plan-host wave supplies the body) rather than Lite's save-to-file. +/// </summary> +public partial class ViewerServerTab +{ + private string _activeQueriesSlicerMetric = "Sessions"; + private List<TimeSliceBucket>? _activeQueriesSlicerData; + + /* Set around a programmatic drill-down navigation (heatmap / per-chart "Show Active Queries at This + Time" / Overview lane) so the inner-tab AND sub-tab SelectionChanged auto-refreshes don't clobber the + drill-down's own filtered read via an async race — Lite's _suppressActiveQueriesAutoRefresh. Gated in + InnerTabs_SelectionChanged, QueriesSubTabs_SelectionChanged, and BlockingSubTabs_SelectionChanged + (the Blocking/Deadlock chart drills target the Blocking tab). */ + private bool _suppressDrillDownAutoRefresh; + + /* A one-shot Active Queries window "shield" for the aggregate-tab deep-link (the "Open in Active + Queries" button on a Recommendations card). Unlike the within-tab drills (heatmap / per-chart / + Overview lane), the deep-link opens/focuses THIS server tab from the aggregate Recommendations tab, + which triggers MainWindow's tab-activation refresh — and that path (RefreshActiveInnerTabAsync -> + LoadQueriesAsync -> LoadActiveQueriesAsync) is NOT gated by _suppressDrillDownAutoRefresh, so it + would reload the grid over the toolbar window and clobber the targeted read via an async race. While + this is set, the next Active Queries load (whichever path wins) uses THIS narrow window + indicator + instead; it is consumed once, so a later auto-refresh reverts to the toolbar window like the other + drills. Set by DeepLinkToActiveQueriesAsync, consumed by LoadActiveQueriesAsync. */ + private (DateTime FromUtc, DateTime ToUtc, string Indicator)? _pendingActiveQueriesWindow; + /* OpenPlanTab is implemented by the plan-host partial (ViewerServerTab.Plans.cs). */ + + /// <summary>Wires the Active Queries slicer's RangeChanged (drag re-reads the grid). Called from + /// <see cref="InitializeQueriesTab"/> after InitializeComponent so the named slicer exists.</summary> + private void InitializeActiveQueriesTab() + { + ActiveQueriesSlicer.RangeChanged += OnActiveQueriesSlicerChanged; + } + + /// <summary>Loads the Active Queries sub-tab: every stored snapshot over the window (newest first) + /// plus the hourly slicer. Mirrors Lite's Queries sub-tab-1 refresh.</summary> + private async Task LoadActiveQueriesAsync(DateTime startUtc, DateTime endUtc) + { + /* Deep-link shield (aggregate-tab "Open in Active Queries"): opening this server tab triggers the + tab-activation refresh, which lands here over the toolbar window and would clobber the targeted + read. Honor a pending deep-link window instead so a concurrent activation load uses the SAME + narrow window + indicator; consumed once (a later refresh reverts to the toolbar window, matching + the heatmap / per-chart drills). The ±1h slicer padding mirrors NavigateToActiveQueriesForWindowAsync. */ + if (_pendingActiveQueriesWindow is { } pending) + { + _pendingActiveQueriesWindow = null; + var pendingSnapshots = await _dataService.GetLatestQuerySnapshotsAsync(_server.ServerId, pending.FromUtc, pending.ToUtc, databaseNames: SelectedDatabaseFilter); + _querySnapshotsFilterMgr!.UpdateData(pendingSnapshots); + LatestSnapshotIndicator.Text = pending.Indicator; + await LoadActiveQueriesSlicerAsync(pending.FromUtc.AddHours(-1), pending.ToUtc.AddHours(1)); + return; + } + + var snapshots = await _dataService.GetLatestQuerySnapshotsAsync(_server.ServerId, startUtc, endUtc, databaseNames: SelectedDatabaseFilter); + _querySnapshotsFilterMgr!.UpdateData(snapshots); + LatestSnapshotIndicator.Text = ""; + await LoadActiveQueriesSlicerAsync(startUtc, endUtc); + } + + private async Task LoadActiveQueriesSlicerAsync(DateTime startUtc, DateTime endUtc) + { + var data = await _dataService.GetActiveQuerySlicerDataAsync(_server.ServerId, startUtc, endUtc, databaseNames: SelectedDatabaseFilter); + _activeQueriesSlicerData = data; + _activeQueriesSlicerMetric = "Sessions"; + if (data.Count > 0) + ActiveQueriesSlicer.LoadData(data, "Sessions", startUtc, endUtc); + } + + private async void OnActiveQueriesSlicerChanged(object? sender, SlicerRangeEventArgs e) + { + try + { + var snapshots = await _dataService.GetLatestQuerySnapshotsAsync(_server.ServerId, e.StartUtc, e.EndUtc, databaseNames: SelectedDatabaseFilter); + _querySnapshotsFilterMgr!.UpdateData(snapshots); + LatestSnapshotIndicator.Text = ""; + } + catch (Exception ex) + { + StatusChanged?.Invoke($"active-queries slicer failed: {ex.Message}"); + } + } + + /// <summary>Sorting the snapshot grid swaps the slicer's aggregate curve to match the sorted column + /// (Lite's <c>QuerySnapshotsGrid_Sorting</c>).</summary> + private void QuerySnapshotsGrid_Sorting(object sender, DataGridSortingEventArgs e) + { + if (_activeQueriesSlicerData == null || _activeQueriesSlicerData.Count == 0) return; + + var col = SortColumnPath(e.Column); + var (metric, label) = col switch + { + "CpuTimeMs" => ("TotalCpu", "Total CPU (ms)"), + "TotalElapsedTimeMs" => ("TotalElapsed", "Total Elapsed (ms)"), + "Reads" => ("TotalReads", "Total Reads"), + "LogicalReads" => ("TotalLogicalReads", "Total Logical Reads"), + "Writes" => ("TotalWrites", "Total Writes"), + _ => ("Sessions", "Sessions"), + }; + + if (metric == _activeQueriesSlicerMetric) return; + _activeQueriesSlicerMetric = metric; + + foreach (var bucket in _activeQueriesSlicerData) + { + bucket.Value = metric switch + { + "TotalCpu" => bucket.TotalCpu, + "TotalElapsed" => bucket.TotalElapsed, + "TotalReads" => bucket.TotalReads, + "TotalLogicalReads" => bucket.TotalLogicalReads, + "TotalWrites" => bucket.TotalWrites, + _ => bucket.SessionCount, + }; + } + + ActiveQueriesSlicer.UpdateMetric(label); + } + + /// <summary> + /// "Latest Snapshot" button (Stage 3b) — asks the Darling service to CAPTURE a fresh snapshot NOW via a + /// <c>snapshot_now</c> command, then re-reads the newest stored batch to show it. This restores Lite's + /// "Live Snapshot" intent (a capture on demand) through the command plane rather than a direct hit on the + /// monitored server. <c>snapshot_now</c> runs even while the service is paused (explicit operator intent — + /// the service honors it), so the button stays enabled while paused. A read-only seat cannot command the + /// service, so it degrades to simply re-reading the most recent stored batch (the pre-3b behavior). + /// </summary> + private async void LatestSnapshot_Click(object sender, RoutedEventArgs e) + { + LatestSnapshotButton.IsEnabled = false; + try + { + if (!_dataService.IsReadOnly) + { + LatestSnapshotIndicator.Text = "Capturing..."; + try + { + var result = await _dataService.RequestSnapshotNowAsync(_server.ServerId); + if (result is null) + { + StatusChanged?.Invoke("snapshot: still running — showing the latest stored capture"); + } + else if (result.Status != ViewerDataService.StatusSucceeded) + { + StatusChanged?.Invoke($"snapshot: the service reported {result.ResultStatus} — showing the latest stored capture"); + } + } + catch (ViewerReadOnlyException) + { + /* Grants changed under us — fall back to reading whatever is stored. */ + } + } + + LatestSnapshotIndicator.Text = "Loading..."; + var (batchTime, rows) = await _dataService.GetLatestQuerySnapshotBatchAsync(_server.ServerId, databaseNames: SelectedDatabaseFilter); + _querySnapshotsFilterMgr!.UpdateData(rows); + LatestSnapshotIndicator.Text = batchTime.HasValue + ? $"Latest snapshot: {ViewerTimeHelper.ForDisplay(batchTime.Value):yyyy-MM-dd HH:mm:ss}" + : "No snapshots stored"; + } + catch (Exception ex) + { + LatestSnapshotIndicator.Text = ""; + StatusChanged?.Invoke($"latest snapshot failed: {ex.Message}"); + } + finally + { + LatestSnapshotButton.IsEnabled = true; + } + } + + /// <summary>Opens the snapshot's stored ESTIMATED plan in the Plan Viewer (gated on HasQueryPlan). Wired to + /// the in-row "Estimated" button (DataContext is the row).</summary> + private void OpenSnapshotEstimatedPlan_Click(object sender, RoutedEventArgs e) + { + if (sender is FrameworkElement { DataContext: ViewerQuerySnapshotRow row }) + OpenSnapshotEstimatedPlan(row); + } + + /// <summary>Opens the snapshot's stored ACTUAL (live) plan in the Plan Viewer (gated on HasLiveQueryPlan). + /// Wired to the in-row "Actual" button (DataContext is the row).</summary> + private void OpenSnapshotActualPlan_Click(object sender, RoutedEventArgs e) + { + if (sender is FrameworkElement { DataContext: ViewerQuerySnapshotRow row }) + OpenSnapshotActualPlan(row); + } + + /// <summary>Right-click "View Estimated Plan" on the snapshot grid — resolves the right-clicked row from the + /// grid (menu items are outside the visual tree, so DataContext doesn't flow) and opens its stored plan, + /// mirroring the in-row button (Lite parity).</summary> + private void ViewSnapshotEstimatedPlan_Click(object sender, RoutedEventArgs e) + { + if (sender is MenuItem menuItem && FindParentDataGrid(menuItem)?.CurrentItem is ViewerQuerySnapshotRow row) + OpenSnapshotEstimatedPlan(row); + } + + /// <summary>Right-click "View Actual Plan" on the snapshot grid (see <see cref="ViewSnapshotEstimatedPlan_Click"/>).</summary> + private void ViewSnapshotActualPlan_Click(object sender, RoutedEventArgs e) + { + if (sender is MenuItem menuItem && FindParentDataGrid(menuItem)?.CurrentItem is ViewerQuerySnapshotRow row) + OpenSnapshotActualPlan(row); + } + + /// <summary>Opens the snapshot row's stored estimated plan (shared by the button + context-menu handlers).</summary> + private void OpenSnapshotEstimatedPlan(ViewerQuerySnapshotRow row) + { + if (string.IsNullOrEmpty(row.QueryPlan)) return; + OpenPlanTab(row.QueryPlan, $"Estimated Plan — Session {row.SessionId}", row.QueryText); + } + + /// <summary>Opens the snapshot row's stored actual plan (shared by the button + context-menu handlers).</summary> + private void OpenSnapshotActualPlan(ViewerQuerySnapshotRow row) + { + if (string.IsNullOrEmpty(row.LiveQueryPlan)) return; + OpenPlanTab(row.LiveQueryPlan, $"Live Plan — Session {row.SessionId}", row.QueryText); + } + + /// <summary> + /// Deep-link entry from the aggregate Recommendations tab's "Open in Active Queries" button: navigate + /// this (possibly freshly-opened) server tab straight to Active Queries scoped to a finding's window. + /// The shell opens/focuses the tab first (its existing per-server-tab path); this then points the + /// process-wide time helper at THIS server's offset (so the grid/indicator render in the tab's own + /// time), sets a one-shot window <see cref="_pendingActiveQueriesWindow">shield</see> so the concurrent + /// tab-activation refresh loads the SAME narrow window (race-free — see the field comment), builds the + /// indicator, then reuses the shared <see cref="NavigateToActiveQueriesForWindowAsync"/> the within-tab + /// drills use. The raw UTC window comes from the card (the finding's own range, or #1409's ±30-minute + /// band around a degenerate point). + /// </summary> + internal async Task DeepLinkToActiveQueriesAsync(DateTime fromUtc, DateTime toUtc) + { + /* Load + apply this server's UTC offset before rendering (the tab may be freshly opened and not yet + refreshed). EnsureServerOffsetLoadedAsync caches its Task, so this shares the one load with any + concurrent activation refresh. */ + await EnsureServerOffsetLoadedAsync(); + ApplyServerOffsetToHelper(); + + var indicator = $"Finding window: {ViewerTimeHelper.ForDisplay(fromUtc):yyyy-MM-dd HH:mm} → {ViewerTimeHelper.ForDisplay(toUtc):HH:mm}"; + _pendingActiveQueriesWindow = (fromUtc, toUtc, indicator); + await NavigateToActiveQueriesForWindowAsync(fromUtc, toUtc, indicator); + } + + /// <summary> + /// Switches to the Active Queries sub-tab and loads the grid + slicer for a narrow window — the target + /// of the heatmap drill-down. The suppress flag skips the sub-tab auto-refresh so it doesn't clobber + /// this filtered read (Lite's SelectActiveQueriesForDrillDown). The slicer window is padded ±1h so the + /// hourly buckets still overlap a narrow drill window (Lite's LoadActiveQueriesSlicerAsync padding). + /// </summary> + private async Task NavigateToActiveQueriesForWindowAsync(DateTime fromUtc, DateTime toUtc, string indicator) + { + _suppressDrillDownAutoRefresh = true; + try + { + /* Switch to the Queries inner tab first (a no-op when the caller is already there — the heatmap + sub-tab lives ON the Queries tab; a CPU/Memory/tempdb/Perfmon chart or an Overview lane does + not), then to the Active Queries sub-tab. Both switches are suppressed so the shell's generic + inner-tab loader and the sub-tab loader don't race this targeted read. */ + InnerTabs.SelectedIndex = QueriesInnerTabIndex; + QueriesSubTabControl.SelectedIndex = ActiveQueriesSubTabIndex; + } + finally + { + _suppressDrillDownAutoRefresh = false; + } + + var snapshots = await _dataService.GetLatestQuerySnapshotsAsync(_server.ServerId, fromUtc, toUtc, databaseNames: SelectedDatabaseFilter); + _querySnapshotsFilterMgr!.UpdateData(snapshots); + LatestSnapshotIndicator.Text = indicator; + + await LoadActiveQueriesSlicerAsync(fromUtc.AddHours(-1), toUtc.AddHours(1)); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ActualPlan.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ActualPlan.cs new file mode 100644 index 000000000..3cb52caa8 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ActualPlan.cs @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading; +using System.Windows; +using System.Windows.Controls; +using static PerformanceMonitor.Ui.DataGridHelpers; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Top Queries "Get Actual Plan" entry point — the side-effecting sibling of "Fetch Live Plan" (see +/// <see cref="ViewerServerTab.LivePlan.cs"/>). Where Fetch Live Plan READS a cached plan, this RE-EXECUTES the +/// stored query on the target so the plan comes back with runtime statistics. The viewer never touches SQL +/// Server: it enqueues an <c>execute_actual_plan</c> command (identifier only — +/// <see cref="ViewerDataService.BuildActualPlanArgs"/>) and the SERVICE resolves the text from its store and +/// re-executes. All the shared behavior — the read-only guard, the data-modification consent flag, and the +/// permission/timeout/no-plan messaging — lives in <see cref="ViewerActualPlanFlow"/>, reused by every surface +/// that hosts "Get Actual Plan"; this method only supplies the Top-Queries identifier + the tab's loading/host. +/// </summary> +public partial class ViewerServerTab +{ + /// <summary> + /// "Get Actual Plan" on a Top Queries row — re-executes the stored query on the target (via the service) to + /// capture its ACTUAL plan, opening it in the Plan Viewer tab. Gated in XAML on the row's + /// <c>CanGetActualPlan</c>, so it only fires for a Top Queries (<see cref="ViewerQueryStatsRow"/>) row. + /// </summary> + private async void GetActualQueryPlan_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem) return; + var grid = FindParentDataGrid(menuItem); + if (grid?.CurrentItem is not ViewerQueryStatsRow stats || string.IsNullOrEmpty(stats.QueryHash)) return; + + var label = $"Actual Plan - {stats.QueryHash}"; + + /* Fetch the stored ESTIMATED plan for modification detection — the viewer reads its own store (the + SERVICE re-resolves the query text by identifier when it executes). */ + string? estimatedPlanXml = null; + try + { + estimatedPlanXml = await _dataService.GetQueryStatsPlanXmlAsync(_server.ServerId, stats.DatabaseName, stats.QueryHash); + } + catch + { + /* Detection degrades to the fail-safe uncertain path — no plan means "treat as modifying". */ + } + + var argsJson = ViewerDataService.BuildActualPlanArgs(stats.QueryHash, stats.DatabaseName); + + /* The Plan Viewer overlay's Cancel button cancels this token (shared with Fetch Live Plan). */ + _planLoadCts?.Dispose(); + _planLoadCts = new CancellationTokenSource(); + + var planXml = await ViewerActualPlanFlow.RequestActualPlanAsync( + Window.GetWindow(this)!, _dataService, _server.ServerId, _server.DisplayName, stats.DatabaseName, + stats.QueryText, estimatedPlanXml, argsJson, + onStarted: () => + { + ShowPlanLoading(label); + PlanLoadingLabel.Text = $"Capturing actual plan (re-executing): {label}"; + }, + onFinished: null, + _planLoadCts.Token); + + if (planXml != null) + { + OpenPlanTab(planXml, label, stats.QueryText); /* HidePlanLoading runs inside OpenPlanTab */ + } + else + { + HidePlanLoading(); + } + } + + /// <summary> + /// "Get Actual Plan (re-run)" for the Query Store + Query Store Regressions grids. Both rows carry a + /// QueryId; Regression rows aggregate across plans so they have no single PlanId or stored plan to view, + /// so this RE-EXECUTES by QueryId via the same service path the Query Store history window uses + /// (<see cref="ViewerDataService.BuildActualPlanArgsForQueryStore"/>). Gated per-row on CanGetActualPlan. + /// </summary> + private async void GetActualQueryStorePlan_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem) return; + var grid = FindParentDataGrid(menuItem); + + long queryId; + string databaseName; + string queryText; + long planId; + switch (grid?.CurrentItem) + { + case ViewerQueryStoreRow qs when qs.QueryId != 0: + queryId = qs.QueryId; databaseName = qs.DatabaseName; queryText = qs.QueryText; planId = qs.PlanId; + break; + case ViewerQueryStoreRegressionRow reg when reg.QueryId != 0: + queryId = reg.QueryId; databaseName = reg.DatabaseName; queryText = reg.QueryTextSample; planId = 0; + break; + default: + return; + } + + var label = $"Actual Plan - QS {queryId}"; + + /* Best-effort stored ESTIMATED plan for modification detection; null degrades to the fail-safe + "treat as modifying" consent (Regression rows have no single PlanId, so they take that path). */ + string? estimatedPlanXml = null; + try + { + estimatedPlanXml = await _dataService.GetQueryStorePlanTextAsync(_server.ServerId, databaseName, queryId, planId); + } + catch + { + /* detection degrades to the fail-safe uncertain path */ + } + + var argsJson = ViewerDataService.BuildActualPlanArgsForQueryStore(queryId, databaseName); + + /* The Plan Viewer overlay's Cancel button cancels this token (shared with Fetch Live Plan). */ + _planLoadCts?.Dispose(); + _planLoadCts = new CancellationTokenSource(); + + var planXml = await ViewerActualPlanFlow.RequestActualPlanAsync( + Window.GetWindow(this)!, _dataService, _server.ServerId, _server.DisplayName, databaseName, + queryText, estimatedPlanXml, argsJson, + onStarted: () => + { + ShowPlanLoading(label); + PlanLoadingLabel.Text = $"Capturing actual plan (re-executing): {label}"; + }, + onFinished: null, + _planLoadCts.Token); + + if (planXml != null) + { + OpenPlanTab(planXml, label, queryText); /* HidePlanLoading runs inside OpenPlanTab */ + } + else + { + HidePlanLoading(); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.BlockChain.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.BlockChain.cs new file mode 100644 index 000000000..75032e5c6 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.BlockChain.cs @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using PerformanceMonitor.Analysis; +using PerformanceMonitor.Ui; +using static PerformanceMonitor.Ui.DataGridHelpers; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Blocked Process Reports block-chain viewer (W1e) — copied from Lite's <c>ServerTab.BlockChain.cs</c> +/// with the store rewired to Postgres. Fetch + reconstruct + tree-build run off the UI thread; only the +/// render touches the UI. The window is derived straight from the clicked row's event_time with NO clock +/// conversion (the reconstruction query filters on the same naive-UTC column). Reuses the shared .Ui +/// <see cref="BlockingChainControl"/> + <see cref="GraphViewerWindow"/> and the shared +/// <see cref="BlockingChainReconstructor"/>, bridged to the Common model by the viewer's copied +/// <see cref="BlockingChainViewerProjection"/>. +/// </summary> +public partial class ViewerServerTab +{ + // Right-click "View Block Chain" on a blocked-process-report row. + private async void ViewBlockChain_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem) return; + var grid = FindParentDataGrid(menuItem); + if (grid?.CurrentItem is ViewerBlockedProcessRow row) + await OpenBlockChainForRowAsync(row); + } + + // Double-click a blocked-process-report row. + private async void BlockedProcessReportGrid_MouseDoubleClick(object sender, MouseButtonEventArgs e) + { + if (BlockedProcessReportGrid.SelectedItem is ViewerBlockedProcessRow row) + await OpenBlockChainForRowAsync(row); + } + + // Reconstruct around the clicked row's own event time (+/- this many minutes) rather than the slicer + // selection, so double-clicking any visible row reliably captures that event's chain. Wide enough to + // span a blocking episode's report re-fires; SessionKey + edge-precise selection guard against merging. + private const int ChainWindowMinutes = 5; + + /// <summary> + /// Opens the block-chain viewer scoped to ONE chain — the chain the clicked session belongs to, rooted + /// at its lead blocker, with the clicked session highlighted. Fetch + reconstruct + tree-build run off + /// the UI thread; only the render touches the UI. + /// </summary> + private async Task OpenBlockChainForRowAsync(ViewerBlockedProcessRow row) + { + var spid = row.BlockedSpid; + if (spid <= 0) return; + var ecid = row.BlockedEcid; + var monitorLoop = row.MonitorLoop; // the clicked event's episode; scopes the reconstruction match + + try + { + // row.EventTime is read from the same event_time column the reconstruction query filters on, so + // derive the window straight from it — NO clock conversion (the store is naive UTC and the pair-row + // query filters on event_time). Fall back to the slicer selection / tab window only when a row has + // no event_time. The slicer emits UTC, which the reads take directly. + DateTime start, end; + if (row.EventTime.HasValue) + { + start = row.EventTime.Value.AddMinutes(-ChainWindowMinutes); + end = row.EventTime.Value.AddMinutes(ChainWindowMinutes); + } + else + { + var startUtc = BlockingSlicer.SelectionStartUtc; + var endUtc = BlockingSlicer.SelectionEndUtc; + if (startUtc.HasValue && endUtc.HasValue) + { + start = startUtc.Value; + end = endUtc.Value; + } + else + { + (start, end) = GetWindowUtc(); + } + } + + var model = await Task.Run(async () => + { + var rows = await _dataService.GetBlockingPairRowsAsync(_server.ServerId, start, end); + var reconstruction = BlockingChainReconstructor.Reconstruct( + rows, maxDepth: 50, maxPairs: 5000, stepBudget: 100_000, scopeByMonitorLoop: true); + return BlockingChainViewerProjection.BuildModelForSession( + reconstruction, monitorLoop, spid, ecid); + }); + + if (model == null) + { + MessageBox.Show( + Window.GetWindow(this)!, + $"No reconstructable blocking chain for SPID {spid} in the selected range.\n\n" + + "The session may not have been blocked (or blocking) in any captured blocked-process " + + "report or DMV blocking snapshot in this window.", + "No Block Chain", + MessageBoxButton.OK, + MessageBoxImage.Information); + return; + } + + var control = new BlockingChainControl(); + control.LoadModel(model, spid, ecid, BlockingChainViewerProjection.EmptyStateDetail); + GraphViewerWindow.ShowGraph( + Window.GetWindow(this), + control, + $"Block Chain — SPID {spid} on {_server.DisplayName}"); + } + catch (Exception ex) + { + MessageBox.Show( + Window.GetWindow(this)!, + $"Failed to build the blocking-chain view:\n\n{ex.Message}", + "Block Chain Error", + MessageBoxButton.OK, + MessageBoxImage.Warning); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Blocking.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Blocking.cs new file mode 100644 index 000000000..c8be5ca6c --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Blocking.cs @@ -0,0 +1,946 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; +using static PerformanceMonitor.Ui.DataGridHelpers; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Blocking inner tab — upgraded to Lite's sub-tab structure (ServerTab.xaml:1435-1469): "Trends" +/// (lock-wait rate, blocking incidents, deadlocks) and "Current Waits" (waiting-task duration by wait +/// type, blocked sessions by database) precede the existing "Blocked Process Reports" grid, which is +/// re-hosted unchanged as the third sub-tab. The five trend-chart bodies are COPIES of Lite's +/// <c>ServerTab.Charts.cs</c> Update* methods, reads rewired to <see cref="ViewerDataService"/> Postgres. +/// Two render-body deviations from Lite: (1) the time axis runs through +/// <see cref="ViewerTimeHelper.ForDisplay"/> instead of Lite's per-server <c>UtcOffsetMinutes</c> shift; +/// (2) the per-server toolbar's settable window supplies the X-axis range. The spike-plot / +/// zero-line-when-empty shapes, the fixed +/// <see cref="ChartPalette.SeriesColor"/> identities for the single-series charts, and the cycling +/// <see cref="ChartPalette"/> colors for the multi-series charts are preserved. W1e adds Lite's full +/// Blocked Process Reports grid (widened + slicer + block-chain viewer) and the Deadlocks sub-tab +/// (deadlock-graph viewer); chart drill-downs remain deferred (no Active Queries surface yet). +/// </summary> +public partial class ViewerServerTab +{ + private ChartHoverHelper? _lockWaitTrendHover; + private ChartHoverHelper? _blockingTrendHover; + private ChartHoverHelper? _deadlockTrendHover; + private ChartHoverHelper? _blockingDurationHover; + private ChartHoverHelper? _blockingTotalDurationHover; + private ChartHoverHelper? _deadlockWaitHover; + private ChartHoverHelper? _deadlockTotalWaitHover; + private ChartHoverHelper? _currentWaitsDurationHover; + private ChartHoverHelper? _currentWaitsBlockedHover; + + /* Blocking sub-tab order (mirrors LoadBlockingAsync's switch + Lite's BlockingSubTabControl): Trends, + Blocking Stats, Current Waits, Blocked Process Reports, Deadlocks. Named so the chart drill-downs + (OnBlockingDrillDown / OnDeadlockDrillDown) target the right sub-tab without a magic literal; the + Blocking Stats severity sub-tab sits right after Trends (its count-only sibling). */ + private const int BlockingTrendsSubTabIndex = 0; + private const int BlockingStatsSubTabIndex = 1; + private const int BlockingCurrentWaitsSubTabIndex = 2; + private const int BlockedProcessReportsSubTabIndex = 3; + private const int DeadlocksSubTabIndex = 4; + + /// <summary> + /// Applies the shared chrome to the five Blocking trend charts and wires their hover tooltips + /// (Lite's per-chart units). Called from the constructor after <c>InitializeComponent</c> so the + /// charts don't flash white before the tab's first load. + /// </summary> + private void InitializeBlockingCharts() + { + ApplyTheme(LockWaitTrendChart); + LockWaitTrendChart.Refresh(); + ApplyTheme(BlockingTrendChart); + BlockingTrendChart.Refresh(); + ApplyTheme(DeadlockTrendChart); + DeadlockTrendChart.Refresh(); + ApplyTheme(BlockingDurationChart); + BlockingDurationChart.Refresh(); + ApplyTheme(BlockingTotalDurationChart); + BlockingTotalDurationChart.Refresh(); + ApplyTheme(DeadlockWaitChart); + DeadlockWaitChart.Refresh(); + ApplyTheme(DeadlockTotalWaitChart); + DeadlockTotalWaitChart.Refresh(); + ApplyTheme(CurrentWaitsDurationChart); + CurrentWaitsDurationChart.Refresh(); + ApplyTheme(CurrentWaitsBlockedChart); + CurrentWaitsBlockedChart.Refresh(); + + _lockWaitTrendHover = new ChartHoverHelper(LockWaitTrendChart, "ms/sec"); + _blockingTrendHover = new ChartHoverHelper(BlockingTrendChart, "incidents"); + _deadlockTrendHover = new ChartHoverHelper(DeadlockTrendChart, "deadlocks"); + _blockingDurationHover = new ChartHoverHelper(BlockingDurationChart, "ms"); + _blockingTotalDurationHover = new ChartHoverHelper(BlockingTotalDurationChart, "ms"); + _deadlockWaitHover = new ChartHoverHelper(DeadlockWaitChart, "ms"); + _deadlockTotalWaitHover = new ChartHoverHelper(DeadlockTotalWaitChart, "ms"); + _currentWaitsDurationHover = new ChartHoverHelper(CurrentWaitsDurationChart, "ms"); + _currentWaitsBlockedHover = new ChartHoverHelper(CurrentWaitsBlockedChart, "sessions"); + + /* The Blocked Process Reports + Deadlocks sub-tabs each carry a UTC slicer; dragging it re-reads + its grid over the selection (Lite's OnBlockingSlicerChanged / OnDeadlockSlicerChanged). */ + BlockingSlicer.RangeChanged += OnBlockingSlicerChanged; + DeadlockSlicer.RangeChanged += OnDeadlockSlicerChanged; + } + + /// <summary> + /// A Blocking sub-tab switch reloads through the shell's overlap-guarded + /// <see cref="RefreshActiveInnerTabAsync"/> (the Blocking tab is the active inner tab whenever its + /// sub-tabs are visible, so the loader dispatches to <see cref="LoadBlockingAsync"/> and loads only + /// the newly-visible sub-tab). Gated on <see cref="System.Windows.FrameworkElement.IsLoaded"/> and + /// the sub-TabControl's own selection so build-time and bubbled selections are ignored. + /// </summary> + private async void BlockingSubTabs_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (!ReferenceEquals(e.OriginalSource, BlockingSubTabs) || !IsLoaded) + { + return; + } + + /* A chart drill-down (Blocking / Deadlocks trend charts) switches this sub-tab programmatically and + runs its own targeted read; skip the generic loader so it doesn't race that. */ + if (_suppressDrillDownAutoRefresh) + { + return; + } + + await RefreshActiveInnerTabAsync(); + } + + /// <summary> + /// Loads the Blocking tab's ACTIVE sub-tab only (mirrors Lite's subTabOnly gating): Trends reads the + /// three trend series concurrently, Current Waits reads the two current-waits series concurrently, + /// and Blocked Process Reports reads the XE-with-DMV-fallback grid. All window on the toolbar's + /// settable range. + /// </summary> + private async Task LoadBlockingAsync() + { + var (startUtc, endUtc) = GetWindowUtc(); + + switch (BlockingSubTabs.SelectedIndex) + { + case BlockingTrendsSubTabIndex: + var lockWaitTask = _dataService.GetLockWaitTrendAsync(_server.ServerId, startUtc, endUtc); + var blockingTask = _dataService.GetBlockingTrendAsync(_server.ServerId, startUtc, endUtc, databaseNames: SelectedDatabaseFilter); + var deadlockTask = _dataService.GetDeadlockTrendAsync(_server.ServerId, startUtc, endUtc); + var lockWaits = await lockWaitTask; + var blocking = await blockingTask; + var deadlocks = await deadlockTask; + RenderLockWaitTrendChart(lockWaits); + RenderBlockingTrendChart(blocking); + RenderDeadlockTrendChart(deadlocks); + break; + case BlockingStatsSubTabIndex: + /* Blocking SEVERITY: the duration aggregate reconciles with the count trend (same XE→DMV + source selection); the deadlock COUNT is the cheap sibling of the Trends tab's deadlock + trend, summed here for the summary strip. The deadlock SEVERITY aggregate (victim_count + + total/max/avg wait, parsed on-the-fly from deadlock_graph_xml) is drawn from the SAME + v_deadlocks/collection_time window as the count, so the two reconcile in period. */ + var durationStatsTask = _dataService.GetBlockingDurationStatsAsync(_server.ServerId, startUtc, endUtc); + var deadlockCountTask = _dataService.GetDeadlockTrendAsync(_server.ServerId, startUtc, endUtc); + var deadlockSeverityTask = _dataService.GetDeadlockSeverityStatsAsync(_server.ServerId, startUtc, endUtc); + var durationStats = await durationStatsTask; + var deadlockCounts = await deadlockCountTask; + var deadlockSeverity = await deadlockSeverityTask; + RenderBlockingDurationChart(durationStats); + RenderBlockingTotalDurationChart(durationStats); + RenderDeadlockWaitChart(deadlockSeverity); + RenderDeadlockTotalWaitChart(deadlockSeverity); + UpdateBlockingStatsSummary(durationStats, deadlockCounts, deadlockSeverity); + break; + case BlockingCurrentWaitsSubTabIndex: + var durationTask = _dataService.GetWaitingTaskTrendAsync(_server.ServerId, startUtc, endUtc); + var blockedTask = _dataService.GetBlockedSessionTrendAsync(_server.ServerId, startUtc, endUtc, databaseNames: SelectedDatabaseFilter); + var duration = await durationTask; + var blocked = await blockedTask; + RenderCurrentWaitsDurationChart(duration); + RenderCurrentWaitsBlockedChart(blocked); + break; + case BlockedProcessReportsSubTabIndex: + await LoadBlockedProcessReportsAsync(startUtc, endUtc); + break; + case DeadlocksSubTabIndex: + default: + await LoadDeadlocksAsync(startUtc, endUtc); + break; + } + } + + /// <summary> + /// Loads the Blocked Process Reports sub-tab (W1e Lite parity): the widened XE-preferred / DMV-fallback + /// grid bound through the filter manager (so active column filters survive the refresh) plus the UTC + /// slicer over the same window. The grid reads the full window; dragging the slicer re-reads it + /// over the selection (<see cref="OnBlockingSlicerChanged"/>). The Npgsql reads are genuinely async, so + /// unlike Lite's DuckDB path there is no Task.Run wrap. + /// </summary> + private async Task LoadBlockedProcessReportsAsync(DateTime startUtc, DateTime endUtc) + { + var rows = await _dataService.GetRecentBlockedProcessReportsAsync(_server.ServerId, startUtc, endUtc, databaseNames: SelectedDatabaseFilter); + _blockedProcessFilterMgr!.UpdateData(rows); + await LoadBlockingSlicerAsync(startUtc, endUtc); + } + + /// <summary> + /// Loads the Deadlocks sub-tab (W1e Lite parity): reads the recent deadlock events, parses each graph + /// into per-process detail rows OFF the UI thread (Lite's #1193 fix — the graph walk is CPU-bound XML + /// work), binds them through the filter manager, and loads the UTC slicer. + /// </summary> + private async Task LoadDeadlocksAsync(DateTime startUtc, DateTime endUtc) + { + var rows = await _dataService.GetRecentDeadlocksAsync(_server.ServerId, startUtc, endUtc); + var details = await ParseDeadlocksOffUiThreadAsync(rows); + _deadlockFilterMgr!.UpdateData(details); + await LoadDeadlockSlicerAsync(startUtc, endUtc); + } + + // ── Blocking / Deadlock slicers (W1e) ── + + private string _blockingSlicerMetric = "Events"; + private List<TimeSliceBucket>? _blockingSlicerData; + private List<TimeSliceBucket>? _deadlockSlicerData; + + private async Task LoadBlockingSlicerAsync(DateTime startUtc, DateTime endUtc) + { + var data = await _dataService.GetBlockingSlicerDataAsync(_server.ServerId, startUtc, endUtc, databaseNames: SelectedDatabaseFilter); + _blockingSlicerData = data; + _blockingSlicerMetric = "Events"; + if (data.Count > 0) + BlockingSlicer.LoadData(data, "Blocking Events", startUtc, endUtc); + } + + private async Task LoadDeadlockSlicerAsync(DateTime startUtc, DateTime endUtc) + { + var data = await _dataService.GetDeadlockSlicerDataAsync(_server.ServerId, startUtc, endUtc); + _deadlockSlicerData = data; + if (data.Count > 0) + DeadlockSlicer.LoadData(data, "Deadlocks", startUtc, endUtc); + } + + /// <summary>The slicer sends UTC bounds; the viewer's reads take naive UTC directly (no clock shift).</summary> + private async void OnBlockingSlicerChanged(object? sender, SlicerRangeEventArgs e) + { + try + { + var rows = await _dataService.GetRecentBlockedProcessReportsAsync(_server.ServerId, e.StartUtc, e.EndUtc, databaseNames: SelectedDatabaseFilter); + _blockedProcessFilterMgr!.UpdateData(rows); + } + catch (Exception ex) + { + StatusChanged?.Invoke($"blocking slicer failed: {ex.Message}"); + } + } + + private async void OnDeadlockSlicerChanged(object? sender, SlicerRangeEventArgs e) + { + try + { + var rows = await _dataService.GetRecentDeadlocksAsync(_server.ServerId, e.StartUtc, e.EndUtc); + _deadlockFilterMgr!.UpdateData(await ParseDeadlocksOffUiThreadAsync(rows)); + } + catch (Exception ex) + { + StatusChanged?.Invoke($"deadlock slicer failed: {ex.Message}"); + } + } + + /// <summary> + /// Re-metrics the blocking slicer overlay to the sorted column (Lite's BlockedProcessReportGrid_Sorting): + /// sorting the grid by wait / blocker / blocked / database swaps the slicer's aggregate curve to match. + /// </summary> + private void BlockedProcessReportGrid_Sorting(object sender, DataGridSortingEventArgs e) + { + if (_blockingSlicerData == null || _blockingSlicerData.Count == 0) return; + + var col = e.Column.SortMemberPath ?? ""; + if (string.IsNullOrEmpty(col)) + { + if (e.Column is DataGridBoundColumn bc && bc.Binding is System.Windows.Data.Binding b) + col = b.Path.Path; + } + var (metric, label) = col switch + { + "WaitTimeMs" => ("TotalCpu", "Total Wait (sec)"), + "BlockingSpid" => ("TotalElapsed", "Distinct Blockers"), + "BlockedSpid" => ("TotalReads", "Distinct Blocked"), + "DatabaseName" => ("TotalLogicalReads", "Distinct Databases"), + _ => ("Events", "Blocking Events"), + }; + + if (metric == _blockingSlicerMetric) return; + _blockingSlicerMetric = metric; + + foreach (var bucket in _blockingSlicerData) + { + bucket.Value = metric switch + { + "TotalCpu" => bucket.TotalCpu, + "TotalElapsed" => bucket.TotalElapsed, + "TotalReads" => bucket.TotalReads, + "TotalLogicalReads" => bucket.TotalLogicalReads, + _ => bucket.SessionCount, + }; + } + + BlockingSlicer.UpdateMetric(label); + } + + /* Parse each deadlock graph into its per-process rows on the thread pool — CPU-bound XML work that + would hitch the dispatcher on the Blocking tab (Lite's #1193 fix). Only the grid bind stays on the UI. */ + private Task<List<DeadlockProcessDetail>> ParseDeadlocksOffUiThreadAsync(List<ViewerDeadlockRow> rows) + { + /* #1319: deadlocks have no database_name column (the per-process DB is inside the graph XML), so + the global database filter is applied client-side on the parsed per-process rows. Snapshot the + selection on the UI thread; empty = All (unfiltered). */ + var selected = _selectedDatabases.Count == 0 + ? null + : new HashSet<string>(_selectedDatabases, StringComparer.OrdinalIgnoreCase); + return Task.Run(() => + { + var details = DeadlockProcessDetail.ParseFromRows(rows); + return selected == null + ? details + : details.Where(d => selected.Contains(d.DatabaseName)).ToList(); + }); + } + + /// <summary> + /// "View Blocked Query Plan" / "View Blocking Query Plan" for a blocked-process report row: opens the + /// BEST-EFFORT plan the row already carries (blocked_process_reports.blocked_query_plan_xml / + /// blocking_query_plan_xml, #1368 / V7) in the shared Plan Viewer host — NO live SQL, the same + /// stored-plan surface Top Queries uses. The context items are gated per row on Has*QueryPlan (a NULL + /// plan shows them disabled), so these only fire with a captured plan; the guard here is belt-and-braces + /// (and covers the DMV-snapshot fallback rows, which never carry a plan). Lite recovered these plans + /// live from the monitored server's cache — the headless viewer can't, so only the stored plan shows, + /// and Lite's "Get Actual Plan" has no viewer equivalent (omitted everywhere). + /// </summary> + private void ViewBlockedQueryPlan_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem) return; + if (FindParentDataGrid(menuItem)?.CurrentItem is not ViewerBlockedProcessRow row || !row.HasBlockedQueryPlan) return; + OpenPlanTab(row.BlockedQueryPlanXml!, $"Blocked Plan - SPID {row.BlockedSpid}", row.BlockedSqlText); + } + + private void ViewBlockingQueryPlan_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem) return; + if (FindParentDataGrid(menuItem)?.CurrentItem is not ViewerBlockedProcessRow row || !row.HasBlockingQueryPlan) return; + OpenPlanTab(row.BlockingQueryPlanXml!, $"Blocking Plan - SPID {row.BlockingSpid}", row.BlockingSqlText); + } + + private void RenderLockWaitTrendChart(List<LockWaitTrendPoint> data) + { + ClearChart(LockWaitTrendChart); + ApplyTheme(LockWaitTrendChart); + + var (winStartUtc, winEndUtc) = GetWindowUtc(); + var rangeStart = ViewerTimeHelper.ForDisplay(winStartUtc); + var rangeEnd = ViewerTimeHelper.ForDisplay(winEndUtc); + + _lockWaitTrendHover?.Clear(); + if (data.Count == 0) + { + var zeroLine = LockWaitTrendChart.Plot.Add.Scatter( + new[] { rangeStart.ToOADate(), rangeEnd.ToOADate() }, + new[] { 0.0, 0.0 }); + zeroLine.LegendText = "Lock Waits"; + zeroLine.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("LockWaits")); + zeroLine.MarkerSize = 0; + LockWaitTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); + LockWaitTrendChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(LockWaitTrendChart); + LockWaitTrendChart.Plot.YLabel("Lock Wait Time (ms/sec)"); + SetChartYLimitsWithLegendPadding(LockWaitTrendChart, 0, 1); + ShowChartLegend(LockWaitTrendChart); + LockWaitTrendChart.Refresh(); + return; + } + + var grouped = data.GroupBy(d => d.WaitType).ToList(); + double globalMax = 0; + + for (int i = 0; i < grouped.Count; i++) + { + var group = grouped[i]; + var times = group.Select(t => ViewerTimeHelper.ForDisplay(t.CollectionTime).ToOADate()).ToArray(); + var values = group.Select(t => t.WaitTimeMsPerSecond).ToArray(); + + var plot = LockWaitTrendChart.Plot.Add.Scatter(times, values); + plot.LegendText = group.Key; + plot.Color = ScottPlot.Color.FromHex(SeriesColors[i % SeriesColors.Length]); + ChartStyle.StyleScatter(plot); + _lockWaitTrendHover?.Add(plot, group.Key); + + if (values.Length > 0) globalMax = Math.Max(globalMax, values.Max()); + } + + LockWaitTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); + LockWaitTrendChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(LockWaitTrendChart); + LockWaitTrendChart.Plot.YLabel("Lock Wait Time (ms/sec)"); + SetChartYLimitsWithLegendPadding(LockWaitTrendChart, 0, globalMax > 0 ? globalMax : 1); + ShowChartLegend(LockWaitTrendChart); + LockWaitTrendChart.Refresh(); + } + + private void RenderBlockingTrendChart(List<BlockingTrendPoint> data) + { + ClearChart(BlockingTrendChart); + ApplyTheme(BlockingTrendChart); + + var (winStartUtc, winEndUtc) = GetWindowUtc(); + var rangeStart = ViewerTimeHelper.ForDisplay(winStartUtc); + var rangeEnd = ViewerTimeHelper.ForDisplay(winEndUtc); + + _blockingTrendHover?.Clear(); + if (data.Count == 0) + { + /* No blocking events — show a flat line at zero so the chart looks active */ + var zeroLine = BlockingTrendChart.Plot.Add.Scatter( + new[] { rangeStart.ToOADate(), rangeEnd.ToOADate() }, + new[] { 0.0, 0.0 }); + zeroLine.LegendText = "Blocking Incidents"; + zeroLine.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("Blocking")); + zeroLine.MarkerSize = 0; + BlockingTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); + BlockingTrendChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(BlockingTrendChart); + BlockingTrendChart.Plot.YLabel("Blocking Incidents"); + SetChartYLimitsWithLegendPadding(BlockingTrendChart, 0, 1); + ShowChartLegend(BlockingTrendChart); + BlockingTrendChart.Refresh(); + return; + } + + /* Build arrays with zero baseline between data points for spike effect */ + var expandedTimes = new List<double>(); + var expandedCounts = new List<double>(); + + /* Add zero at start */ + expandedTimes.Add(rangeStart.ToOADate()); + expandedCounts.Add(0); + + foreach (var point in data.OrderBy(d => d.Time)) + { + var time = ViewerTimeHelper.ForDisplay(point.Time).ToOADate(); + /* Go to zero just before the spike */ + expandedTimes.Add(time - 0.0001); + expandedCounts.Add(0); + /* Spike up */ + expandedTimes.Add(time); + expandedCounts.Add(point.Count); + /* Back to zero just after */ + expandedTimes.Add(time + 0.0001); + expandedCounts.Add(0); + } + + /* Add zero at end */ + expandedTimes.Add(rangeEnd.ToOADate()); + expandedCounts.Add(0); + + var plot = BlockingTrendChart.Plot.Add.Scatter(expandedTimes.ToArray(), expandedCounts.ToArray()); + plot.LegendText = "Blocking Incidents"; + plot.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("Blocking")); + plot.MarkerSize = 0; /* No markers, just lines */ + _blockingTrendHover?.Add(plot, "Blocking Incidents"); + + BlockingTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); + BlockingTrendChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(BlockingTrendChart); + BlockingTrendChart.Plot.YLabel("Blocking Incidents"); + SetChartYLimitsWithLegendPadding(BlockingTrendChart, 0, data.Max(d => d.Count)); + ShowChartLegend(BlockingTrendChart); + BlockingTrendChart.Refresh(); + } + + private void RenderDeadlockTrendChart(List<BlockingTrendPoint> data) + { + ClearChart(DeadlockTrendChart); + ApplyTheme(DeadlockTrendChart); + + var (winStartUtc, winEndUtc) = GetWindowUtc(); + var rangeStart = ViewerTimeHelper.ForDisplay(winStartUtc); + var rangeEnd = ViewerTimeHelper.ForDisplay(winEndUtc); + + _deadlockTrendHover?.Clear(); + if (data.Count == 0) + { + /* No deadlocks — show a flat line at zero so the chart looks active */ + var zeroLine = DeadlockTrendChart.Plot.Add.Scatter( + new[] { rangeStart.ToOADate(), rangeEnd.ToOADate() }, + new[] { 0.0, 0.0 }); + zeroLine.LegendText = "Deadlocks"; + zeroLine.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("Deadlocks")); + zeroLine.MarkerSize = 0; + DeadlockTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); + DeadlockTrendChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(DeadlockTrendChart); + DeadlockTrendChart.Plot.YLabel("Deadlocks"); + SetChartYLimitsWithLegendPadding(DeadlockTrendChart, 0, 1); + ShowChartLegend(DeadlockTrendChart); + DeadlockTrendChart.Refresh(); + return; + } + + /* Build arrays with zero baseline between data points for spike effect */ + var expandedTimes = new List<double>(); + var expandedCounts = new List<double>(); + + /* Add zero at start */ + expandedTimes.Add(rangeStart.ToOADate()); + expandedCounts.Add(0); + + foreach (var point in data.OrderBy(d => d.Time)) + { + var time = ViewerTimeHelper.ForDisplay(point.Time).ToOADate(); + /* Go to zero just before the spike */ + expandedTimes.Add(time - 0.0001); + expandedCounts.Add(0); + /* Spike up */ + expandedTimes.Add(time); + expandedCounts.Add(point.Count); + /* Back to zero just after */ + expandedTimes.Add(time + 0.0001); + expandedCounts.Add(0); + } + + /* Add zero at end */ + expandedTimes.Add(rangeEnd.ToOADate()); + expandedCounts.Add(0); + + var plot = DeadlockTrendChart.Plot.Add.Scatter(expandedTimes.ToArray(), expandedCounts.ToArray()); + plot.LegendText = "Deadlocks"; + plot.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("Deadlocks")); + plot.MarkerSize = 0; /* No markers, just lines */ + _deadlockTrendHover?.Add(plot, "Deadlocks"); + + DeadlockTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); + DeadlockTrendChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(DeadlockTrendChart); + DeadlockTrendChart.Plot.YLabel("Deadlocks"); + SetChartYLimitsWithLegendPadding(DeadlockTrendChart, 0, data.Max(d => d.Count)); + ShowChartLegend(DeadlockTrendChart); + DeadlockTrendChart.Refresh(); + } + + /// <summary> + /// Max + Avg block duration (ms) per minute — the per-incident severity signal (how long an individual + /// block lasts). Two connected-scatter series on one shared ms axis (Max ≥ Avg, comparable magnitudes), + /// mirroring the Current Waits duration chart's render idiom. Total duration lands on its own chart + /// (<see cref="RenderBlockingTotalDurationChart"/>) because its aggregate magnitude dwarfs these. + /// </summary> + private void RenderBlockingDurationChart(List<BlockingDurationStatsPoint> data) + { + ClearChart(BlockingDurationChart); + ApplyTheme(BlockingDurationChart); + + var (winStartUtc, winEndUtc) = GetWindowUtc(); + var rangeStart = ViewerTimeHelper.ForDisplay(winStartUtc); + var rangeEnd = ViewerTimeHelper.ForDisplay(winEndUtc); + + _blockingDurationHover?.Clear(); + if (data.Count == 0) + { + var zeroLine = BlockingDurationChart.Plot.Add.Scatter( + new[] { rangeStart.ToOADate(), rangeEnd.ToOADate() }, + new[] { 0.0, 0.0 }); + zeroLine.LegendText = "Max Block Duration"; + zeroLine.Color = ScottPlot.Color.FromHex(SeriesColors[0]); + zeroLine.MarkerSize = 0; + BlockingDurationChart.Plot.Axes.DateTimeTicksBottomDateChange(); + BlockingDurationChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(BlockingDurationChart); + BlockingDurationChart.Plot.YLabel("Block Duration (ms)"); + SetChartYLimitsWithLegendPadding(BlockingDurationChart, 0, 1); + ShowChartLegend(BlockingDurationChart); + BlockingDurationChart.Refresh(); + return; + } + + var ordered = data.OrderBy(d => d.Time).ToList(); + var times = PadEnds(ordered.Select(d => ViewerTimeHelper.ForDisplay(d.Time).ToOADate()).ToArray(), rangeStart.ToOADate(), rangeEnd.ToOADate()); + var maxValues = PadEnds(ordered.Select(d => (double)d.MaxDurationMs).ToArray(), 0, 0); + var avgValues = PadEnds(ordered.Select(d => d.AvgDurationMs).ToArray(), 0, 0); + + var maxPlot = BlockingDurationChart.Plot.Add.Scatter(times, maxValues); + maxPlot.LegendText = "Max Block Duration"; + maxPlot.Color = ScottPlot.Color.FromHex(SeriesColors[0]); + ChartStyle.StyleScatter(maxPlot); + _blockingDurationHover?.Add(maxPlot, "Max Block Duration"); + + var avgPlot = BlockingDurationChart.Plot.Add.Scatter(times, avgValues); + avgPlot.LegendText = "Avg Block Duration"; + avgPlot.Color = ScottPlot.Color.FromHex(SeriesColors[1]); + ChartStyle.StyleScatter(avgPlot); + _blockingDurationHover?.Add(avgPlot, "Avg Block Duration"); + + double globalMax = maxValues.Length > 0 ? maxValues.Max() : 0; + + BlockingDurationChart.Plot.Axes.DateTimeTicksBottomDateChange(); + BlockingDurationChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(BlockingDurationChart); + BlockingDurationChart.Plot.YLabel("Block Duration (ms)"); + SetChartYLimitsWithLegendPadding(BlockingDurationChart, 0, globalMax > 0 ? globalMax : 1); + ShowChartLegend(BlockingDurationChart); + BlockingDurationChart.Refresh(); + } + + /// <summary> + /// Total block duration (ms) per minute — the aggregate volume×severity signal (the sum of every block's + /// wait time in the bucket). Single connected-scatter series on its own chart/axis so its magnitude + /// doesn't swamp the per-incident Max/Avg chart; colored with the blocking identity so it reads as a + /// sibling of the Trends tab's blocking-incident chart. + /// </summary> + private void RenderBlockingTotalDurationChart(List<BlockingDurationStatsPoint> data) + { + ClearChart(BlockingTotalDurationChart); + ApplyTheme(BlockingTotalDurationChart); + + var (winStartUtc, winEndUtc) = GetWindowUtc(); + var rangeStart = ViewerTimeHelper.ForDisplay(winStartUtc); + var rangeEnd = ViewerTimeHelper.ForDisplay(winEndUtc); + + _blockingTotalDurationHover?.Clear(); + if (data.Count == 0) + { + var zeroLine = BlockingTotalDurationChart.Plot.Add.Scatter( + new[] { rangeStart.ToOADate(), rangeEnd.ToOADate() }, + new[] { 0.0, 0.0 }); + zeroLine.LegendText = "Total Block Duration"; + zeroLine.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("Blocking")); + zeroLine.MarkerSize = 0; + BlockingTotalDurationChart.Plot.Axes.DateTimeTicksBottomDateChange(); + BlockingTotalDurationChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(BlockingTotalDurationChart); + BlockingTotalDurationChart.Plot.YLabel("Total Block Duration (ms)"); + SetChartYLimitsWithLegendPadding(BlockingTotalDurationChart, 0, 1); + ShowChartLegend(BlockingTotalDurationChart); + BlockingTotalDurationChart.Refresh(); + return; + } + + var ordered = data.OrderBy(d => d.Time).ToList(); + var times = PadEnds(ordered.Select(d => ViewerTimeHelper.ForDisplay(d.Time).ToOADate()).ToArray(), rangeStart.ToOADate(), rangeEnd.ToOADate()); + var totals = PadEnds(ordered.Select(d => (double)d.TotalDurationMs).ToArray(), 0, 0); + + var plot = BlockingTotalDurationChart.Plot.Add.Scatter(times, totals); + plot.LegendText = "Total Block Duration"; + plot.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("Blocking")); + ChartStyle.StyleScatter(plot); + _blockingTotalDurationHover?.Add(plot, "Total Block Duration"); + + double globalMax = totals.Length > 0 ? totals.Max() : 0; + + BlockingTotalDurationChart.Plot.Axes.DateTimeTicksBottomDateChange(); + BlockingTotalDurationChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(BlockingTotalDurationChart); + BlockingTotalDurationChart.Plot.YLabel("Total Block Duration (ms)"); + SetChartYLimitsWithLegendPadding(BlockingTotalDurationChart, 0, globalMax > 0 ? globalMax : 1); + ShowChartLegend(BlockingTotalDurationChart); + BlockingTotalDurationChart.Refresh(); + } + + /// <summary> + /// Max + Avg deadlock wait (ms) per minute — the per-process deadlock severity signal (how long the + /// deadlocked processes had waited before the monitor broke the cycle). Two connected-scatter series on one + /// shared ms axis (Max ≥ Avg, comparable magnitudes), the deadlock analog of + /// <see cref="RenderBlockingDurationChart"/>. Total deadlock wait lands on its own chart + /// (<see cref="RenderDeadlockTotalWaitChart"/>) because its aggregate magnitude dwarfs these. + /// </summary> + private void RenderDeadlockWaitChart(List<DeadlockSeverityStatsPoint> data) + { + ClearChart(DeadlockWaitChart); + ApplyTheme(DeadlockWaitChart); + + var (winStartUtc, winEndUtc) = GetWindowUtc(); + var rangeStart = ViewerTimeHelper.ForDisplay(winStartUtc); + var rangeEnd = ViewerTimeHelper.ForDisplay(winEndUtc); + + _deadlockWaitHover?.Clear(); + if (data.Count == 0) + { + var zeroLine = DeadlockWaitChart.Plot.Add.Scatter( + new[] { rangeStart.ToOADate(), rangeEnd.ToOADate() }, + new[] { 0.0, 0.0 }); + zeroLine.LegendText = "Max Deadlock Wait"; + zeroLine.Color = ScottPlot.Color.FromHex(SeriesColors[0]); + zeroLine.MarkerSize = 0; + DeadlockWaitChart.Plot.Axes.DateTimeTicksBottomDateChange(); + DeadlockWaitChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(DeadlockWaitChart); + DeadlockWaitChart.Plot.YLabel("Deadlock Wait (ms)"); + SetChartYLimitsWithLegendPadding(DeadlockWaitChart, 0, 1); + ShowChartLegend(DeadlockWaitChart); + DeadlockWaitChart.Refresh(); + return; + } + + var ordered = data.OrderBy(d => d.Time).ToList(); + var times = PadEnds(ordered.Select(d => ViewerTimeHelper.ForDisplay(d.Time).ToOADate()).ToArray(), rangeStart.ToOADate(), rangeEnd.ToOADate()); + var maxValues = PadEnds(ordered.Select(d => (double)d.MaxWaitMs).ToArray(), 0, 0); + var avgValues = PadEnds(ordered.Select(d => d.AvgWaitMs).ToArray(), 0, 0); + + var maxPlot = DeadlockWaitChart.Plot.Add.Scatter(times, maxValues); + maxPlot.LegendText = "Max Deadlock Wait"; + maxPlot.Color = ScottPlot.Color.FromHex(SeriesColors[0]); + ChartStyle.StyleScatter(maxPlot); + _deadlockWaitHover?.Add(maxPlot, "Max Deadlock Wait"); + + var avgPlot = DeadlockWaitChart.Plot.Add.Scatter(times, avgValues); + avgPlot.LegendText = "Avg Deadlock Wait"; + avgPlot.Color = ScottPlot.Color.FromHex(SeriesColors[1]); + ChartStyle.StyleScatter(avgPlot); + _deadlockWaitHover?.Add(avgPlot, "Avg Deadlock Wait"); + + double globalMax = maxValues.Length > 0 ? maxValues.Max() : 0; + + DeadlockWaitChart.Plot.Axes.DateTimeTicksBottomDateChange(); + DeadlockWaitChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(DeadlockWaitChart); + DeadlockWaitChart.Plot.YLabel("Deadlock Wait (ms)"); + SetChartYLimitsWithLegendPadding(DeadlockWaitChart, 0, globalMax > 0 ? globalMax : 1); + ShowChartLegend(DeadlockWaitChart); + DeadlockWaitChart.Refresh(); + } + + /// <summary> + /// Total deadlock wait (ms) per minute — the aggregate signal (the sum of every deadlocked process's wait + /// time in the bucket). Single connected-scatter series on its own chart/axis so its magnitude doesn't + /// swamp the per-process Max/Avg chart; colored with the Deadlocks identity so it reads as a sibling of the + /// Trends tab's deadlock-count chart. The deadlock analog of <see cref="RenderBlockingTotalDurationChart"/>. + /// </summary> + private void RenderDeadlockTotalWaitChart(List<DeadlockSeverityStatsPoint> data) + { + ClearChart(DeadlockTotalWaitChart); + ApplyTheme(DeadlockTotalWaitChart); + + var (winStartUtc, winEndUtc) = GetWindowUtc(); + var rangeStart = ViewerTimeHelper.ForDisplay(winStartUtc); + var rangeEnd = ViewerTimeHelper.ForDisplay(winEndUtc); + + _deadlockTotalWaitHover?.Clear(); + if (data.Count == 0) + { + var zeroLine = DeadlockTotalWaitChart.Plot.Add.Scatter( + new[] { rangeStart.ToOADate(), rangeEnd.ToOADate() }, + new[] { 0.0, 0.0 }); + zeroLine.LegendText = "Total Deadlock Wait"; + zeroLine.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("Deadlocks")); + zeroLine.MarkerSize = 0; + DeadlockTotalWaitChart.Plot.Axes.DateTimeTicksBottomDateChange(); + DeadlockTotalWaitChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(DeadlockTotalWaitChart); + DeadlockTotalWaitChart.Plot.YLabel("Total Deadlock Wait (ms)"); + SetChartYLimitsWithLegendPadding(DeadlockTotalWaitChart, 0, 1); + ShowChartLegend(DeadlockTotalWaitChart); + DeadlockTotalWaitChart.Refresh(); + return; + } + + var ordered = data.OrderBy(d => d.Time).ToList(); + var times = PadEnds(ordered.Select(d => ViewerTimeHelper.ForDisplay(d.Time).ToOADate()).ToArray(), rangeStart.ToOADate(), rangeEnd.ToOADate()); + var totals = PadEnds(ordered.Select(d => (double)d.TotalWaitMs).ToArray(), 0, 0); + + var plot = DeadlockTotalWaitChart.Plot.Add.Scatter(times, totals); + plot.LegendText = "Total Deadlock Wait"; + plot.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("Deadlocks")); + ChartStyle.StyleScatter(plot); + _deadlockTotalWaitHover?.Add(plot, "Total Deadlock Wait"); + + double globalMax = totals.Length > 0 ? totals.Max() : 0; + + DeadlockTotalWaitChart.Plot.Axes.DateTimeTicksBottomDateChange(); + DeadlockTotalWaitChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(DeadlockTotalWaitChart); + DeadlockTotalWaitChart.Plot.YLabel("Total Deadlock Wait (ms)"); + SetChartYLimitsWithLegendPadding(DeadlockTotalWaitChart, 0, globalMax > 0 ? globalMax : 1); + ShowChartLegend(DeadlockTotalWaitChart); + DeadlockTotalWaitChart.Refresh(); + } + + /// <summary> + /// Prepends <paramref name="lead"/> and appends <paramref name="trail"/> so a sparse connected-scatter + /// series spans the whole selected window instead of ending at its last event. Pass the window's + /// rangeStart/rangeEnd for the times array and 0/0 for value arrays (no activity = zero duration). The + /// window-pinned analog of the Trends charts' zero-baseline expansion — keeps all four Blocking-Stats + /// charts aligned to the shared window rather than each ending at its own last data point. + /// </summary> + private static double[] PadEnds(double[] values, double lead, double trail) + { + var result = new double[values.Length + 2]; + result[0] = lead; + Array.Copy(values, 0, result, 1, values.Length); + result[^1] = trail; + return result; + } + + /// <summary> + /// Window-rollup summary strip for the Blocking Stats sub-tab: total blocking events, total / max / avg + /// block duration (the avg is EVENT-weighted — total ÷ events — not a mean of the per-minute averages), + /// the deadlock COUNT, and the deadlock SEVERITY rollup (total victim processes + total deadlock wait) over + /// the window. The deadlock count is the cheap incident signal; the victim_count / total-deadlock-wait are + /// parsed on-the-fly from <c>deadlock_graph_xml</c> over the SAME v_deadlocks window (so they reconcile in + /// period), the Darling equivalent of the Dashboard's <c>victim_count</c> / <c>total_deadlock_wait_time_ms</c>. + /// Durations render with the viewer's blocking wait-time format (ms under a second, else sec). + /// </summary> + private void UpdateBlockingStatsSummary( + List<BlockingDurationStatsPoint> stats, + List<BlockingTrendPoint> deadlocks, + List<DeadlockSeverityStatsPoint> deadlockSeverity) + { + long totalEvents = stats.Sum(s => (long)s.EventCount); + long totalDuration = stats.Sum(s => s.TotalDurationMs); + long maxDuration = stats.Count > 0 ? stats.Max(s => s.MaxDurationMs) : 0; + long totalDeadlocks = deadlocks.Sum(d => (long)d.Count); + long avgDuration = totalEvents > 0 ? (long)Math.Round((double)totalDuration / totalEvents) : 0; + long totalVictims = deadlockSeverity.Sum(d => (long)d.VictimCount); + long totalDeadlockWait = deadlockSeverity.Sum(d => d.TotalWaitMs); + + BlockingStatsEventCountText.Text = totalEvents.ToString("N0"); + BlockingStatsTotalDurationText.Text = totalEvents > 0 ? ViewerDataService.FormatWaitTime(totalDuration) : "--"; + BlockingStatsMaxDurationText.Text = totalEvents > 0 ? ViewerDataService.FormatWaitTime(maxDuration) : "--"; + BlockingStatsAvgDurationText.Text = totalEvents > 0 ? ViewerDataService.FormatWaitTime(avgDuration) : "--"; + BlockingStatsDeadlockCountText.Text = totalDeadlocks.ToString("N0"); + BlockingStatsDeadlockVictimsText.Text = totalVictims.ToString("N0"); + BlockingStatsDeadlockWaitText.Text = totalDeadlocks > 0 ? ViewerDataService.FormatWaitTime(totalDeadlockWait) : "--"; + } + + private void RenderCurrentWaitsDurationChart(List<WaitingTaskTrendPoint> data) + { + ClearChart(CurrentWaitsDurationChart); + ApplyTheme(CurrentWaitsDurationChart); + + var (winStartUtc, winEndUtc) = GetWindowUtc(); + var rangeStart = ViewerTimeHelper.ForDisplay(winStartUtc); + var rangeEnd = ViewerTimeHelper.ForDisplay(winEndUtc); + + _currentWaitsDurationHover?.Clear(); + if (data.Count == 0) + { + var zeroLine = CurrentWaitsDurationChart.Plot.Add.Scatter( + new[] { rangeStart.ToOADate(), rangeEnd.ToOADate() }, + new[] { 0.0, 0.0 }); + zeroLine.LegendText = "Current Waits"; + zeroLine.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("CurrentWaits")); + zeroLine.MarkerSize = 0; + CurrentWaitsDurationChart.Plot.Axes.DateTimeTicksBottomDateChange(); + CurrentWaitsDurationChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(CurrentWaitsDurationChart); + CurrentWaitsDurationChart.Plot.YLabel("Total Wait Duration (ms)"); + SetChartYLimitsWithLegendPadding(CurrentWaitsDurationChart, 0, 1); + ShowChartLegend(CurrentWaitsDurationChart); + CurrentWaitsDurationChart.Refresh(); + return; + } + + var grouped = data.GroupBy(d => d.WaitType).OrderBy(g => g.Key).ToList(); + double globalMax = 0; + + for (int i = 0; i < grouped.Count; i++) + { + var group = grouped[i]; + var ordered = group.OrderBy(t => t.CollectionTime).ToList(); + var times = ordered.Select(t => ViewerTimeHelper.ForDisplay(t.CollectionTime).ToOADate()).ToArray(); + var values = ordered.Select(t => (double)t.TotalWaitMs).ToArray(); + + var plot = CurrentWaitsDurationChart.Plot.Add.Scatter(times, values); + plot.LegendText = group.Key; + plot.Color = ScottPlot.Color.FromHex(SeriesColors[i % SeriesColors.Length]); + ChartStyle.StyleScatter(plot); + _currentWaitsDurationHover?.Add(plot, group.Key); + + if (values.Length > 0) globalMax = Math.Max(globalMax, values.Max()); + } + + CurrentWaitsDurationChart.Plot.Axes.DateTimeTicksBottomDateChange(); + CurrentWaitsDurationChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(CurrentWaitsDurationChart); + CurrentWaitsDurationChart.Plot.YLabel("Total Wait Duration (ms)"); + SetChartYLimitsWithLegendPadding(CurrentWaitsDurationChart, 0, globalMax > 0 ? globalMax : 1); + ShowChartLegend(CurrentWaitsDurationChart); + CurrentWaitsDurationChart.Refresh(); + } + + private void RenderCurrentWaitsBlockedChart(List<BlockedSessionTrendPoint> data) + { + ClearChart(CurrentWaitsBlockedChart); + ApplyTheme(CurrentWaitsBlockedChart); + + var (winStartUtc, winEndUtc) = GetWindowUtc(); + var rangeStart = ViewerTimeHelper.ForDisplay(winStartUtc); + var rangeEnd = ViewerTimeHelper.ForDisplay(winEndUtc); + + _currentWaitsBlockedHover?.Clear(); + if (data.Count == 0) + { + var zeroLine = CurrentWaitsBlockedChart.Plot.Add.Scatter( + new[] { rangeStart.ToOADate(), rangeEnd.ToOADate() }, + new[] { 0.0, 0.0 }); + zeroLine.LegendText = "Blocked Sessions"; + zeroLine.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("BlockedSessions")); + zeroLine.MarkerSize = 0; + CurrentWaitsBlockedChart.Plot.Axes.DateTimeTicksBottomDateChange(); + CurrentWaitsBlockedChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(CurrentWaitsBlockedChart); + CurrentWaitsBlockedChart.Plot.YLabel("Blocked Sessions"); + SetChartYLimitsWithLegendPadding(CurrentWaitsBlockedChart, 0, 1); + ShowChartLegend(CurrentWaitsBlockedChart); + CurrentWaitsBlockedChart.Refresh(); + return; + } + + var grouped = data.GroupBy(d => d.DatabaseName).OrderBy(g => g.Key).ToList(); + double globalMax = 0; + + for (int i = 0; i < grouped.Count; i++) + { + var group = grouped[i]; + var ordered = group.OrderBy(t => t.CollectionTime).ToList(); + var times = ordered.Select(t => ViewerTimeHelper.ForDisplay(t.CollectionTime).ToOADate()).ToArray(); + var values = ordered.Select(t => (double)t.BlockedCount).ToArray(); + + var plot = CurrentWaitsBlockedChart.Plot.Add.Scatter(times, values); + plot.LegendText = group.Key; + plot.Color = ScottPlot.Color.FromHex(SeriesColors[i % SeriesColors.Length]); + ChartStyle.StyleScatter(plot); + _currentWaitsBlockedHover?.Add(plot, group.Key); + + if (values.Length > 0) globalMax = Math.Max(globalMax, values.Max()); + } + + CurrentWaitsBlockedChart.Plot.Axes.DateTimeTicksBottomDateChange(); + CurrentWaitsBlockedChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(CurrentWaitsBlockedChart); + CurrentWaitsBlockedChart.Plot.YLabel("Blocked Sessions"); + SetChartYLimitsWithLegendPadding(CurrentWaitsBlockedChart, 0, globalMax > 0 ? globalMax : 1); + ShowChartLegend(CurrentWaitsBlockedChart); + CurrentWaitsBlockedChart.Refresh(); + } + + /// <summary>Tears down the Blocking trend hover helpers; called from the single Dispose() in Charts.cs.</summary> + private void DisposeBlockingHelpers() + { + _lockWaitTrendHover?.Dispose(); + _blockingTrendHover?.Dispose(); + _deadlockTrendHover?.Dispose(); + _blockingDurationHover?.Dispose(); + _blockingTotalDurationHover?.Dispose(); + _deadlockWaitHover?.Dispose(); + _deadlockTotalWaitHover?.Dispose(); + _currentWaitsDurationHover?.Dispose(); + _currentWaitsBlockedHover?.Dispose(); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ChartContextMenu.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ChartContextMenu.cs new file mode 100644 index 000000000..7154717a1 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ChartContextMenu.cs @@ -0,0 +1,322 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media.Imaging; +using Microsoft.Win32; +using ScottPlot.WPF; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The per-chart right-click Copy / Save / Export menu — a faithful <b>Darling-local</b> port of Lite's +/// <c>ContextMenuHelper.SetupChartContextMenu</c> (Lite/Helpers/ContextMenuHelper.cs:185). Lite's helper is a +/// Lite-only static class that reads Lite's <c>App.CsvSeparator</c>, so it is ported here rather than shared — +/// the same choice #1409 made for the drill-down menus, and consistent with the viewer being a Lite front-end +/// copy. The single behavioral difference from Lite is inherent to the viewer: it never re-executes a query, +/// so there is no live-plan surface here (the menu is pure chart chrome). +/// +/// <para><b>Coexistence with the B1 drill-downs (#1409).</b> Each drill-down chart's menu is ONE +/// <see cref="ContextMenu"/> = [drill-down item] + [separator] + [these copy/save/export items], exactly like +/// Lite: <see cref="BuildChartContextMenu"/> builds the copy/export items and takes over right-click (via the +/// shared <c>AttachChartContextMenu</c> in ViewerServerTab.DrillDown.cs), then the drill-down wiring +/// <c>Insert</c>s its item at the top of the SAME menu. The only chart with no drill-down (collector duration, +/// which Lite also leaves drill-less) gets this menu on its own via <see cref="WireChartContextMenus"/>.</para> +/// </summary> +public partial class ViewerServerTab +{ + /* The menu-item headers — the single source of truth shared by the real WPF construction below and the + pure ChartMenuHeaderOrder contract the tests pin. */ + internal const string CopyImageHeader = "Copy Image"; + internal const string SaveImageHeader = "Save Image As..."; + internal const string OpenInNewWindowHeader = "Open in New Window"; + internal const string RevertHeader = "Revert (or double-click)"; + internal const string ExportDataToCsvHeader = "Export Data to CSV..."; + internal const string ShowDataSourceHeader = "Show Data Source"; + + /// <summary>Sentinel for a <see cref="Separator"/> in the pure <see cref="ChartMenuHeaderOrder"/> contract.</summary> + internal const string MenuSeparatorMarker = "---"; + + /// <summary> + /// The canonical ordered header list of a per-chart menu (pure, unit-tested): the drill-down item first + /// (when the chart has one) followed by a separator, then the copy/save/export block, and the optional + /// Show Data Source tail. This is the contract <see cref="BuildChartContextMenu"/> + + /// <c>AddChartDrillDownMenuItem</c> compose — the test pins that a drill-down chart's menu carries BOTH the + /// drill-down item AND the copy/export items, drill-down first (so #1409's drill-downs still resolve). + /// </summary> + internal static IReadOnlyList<string> ChartMenuHeaderOrder(string? drillDownLabel, bool includeDataSource) + { + var items = new List<string>(); + if (drillDownLabel is not null) + { + items.Add(drillDownLabel); + items.Add(MenuSeparatorMarker); + } + + items.Add(CopyImageHeader); + items.Add(SaveImageHeader); + items.Add(OpenInNewWindowHeader); + items.Add(MenuSeparatorMarker); + items.Add(RevertHeader); + items.Add(MenuSeparatorMarker); + items.Add(ExportDataToCsvHeader); + if (includeDataSource) + { + items.Add(MenuSeparatorMarker); + items.Add(ShowDataSourceHeader); + } + + return items; + } + + /* The chart-data CSV shape: DateTime,Series,Value (Lite's ContextMenuHelper CSV export verbatim). Pure so + the tests pin the header + row format without a WpfPlot. */ + private static readonly string[] s_chartCsvColumns = { "DateTime", "Series", "Value" }; + + /// <summary>The chart-data CSV header line ("DateTime{sep}Series{sep}Value").</summary> + internal static string ChartCsvHeaderLine(string separator) => string.Join(separator, s_chartCsvColumns); + + /// <summary>One chart-data CSV row: an invariant <c>yyyy-MM-dd HH:mm:ss</c> timestamp, the escaped series + /// name, and the invariant value (Lite's exact row shape).</summary> + internal static string FormatChartCsvLine(DateTime dateTime, string series, double value, string separator) => + string.Join(separator, new[] + { + dateTime.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), + CsvEscape(series, separator), + value.ToString(CultureInfo.InvariantCulture), + }); + + /// <summary>RFC-4180 CSV quoting (Lite's ContextMenuHelper.CsvEscape).</summary> + internal static string CsvEscape(string value, string separator) + { + if (value.Contains(separator, StringComparison.Ordinal) || value.Contains('"') || value.Contains('\n') || value.Contains('\r')) + { + return "\"" + value.Replace("\"", "\"\"", StringComparison.Ordinal) + "\""; + } + + return value; + } + + /// <summary> + /// Builds the copy/save/export <see cref="ContextMenu"/> for a chart and takes right-click over from + /// ScottPlot (Lite's <c>SetupChartContextMenu</c>). Returns the menu so a drill-down caller can + /// <c>Insert</c> its item at the top of the SAME instance; charts without a drill-down get the returned + /// menu as-is. Also wires double-click → revert/autoscale (Lite parity). + /// </summary> + private ContextMenu BuildChartContextMenu(WpfPlot chart, string chartName, string? dataSource = null) + { + var menu = new ContextMenu(); + + var copyItem = new MenuItem { Header = CopyImageHeader, Icon = new TextBlock { Text = "\U0001f4cb" } }; + copyItem.Click += (_, _) => CopyChartImage(chart); + menu.Items.Add(copyItem); + + var saveItem = new MenuItem { Header = SaveImageHeader, Icon = new TextBlock { Text = "\U0001f4be" } }; + saveItem.Click += (_, _) => SaveChartImage(chart, chartName); + menu.Items.Add(saveItem); + + var openWindowItem = new MenuItem { Header = OpenInNewWindowHeader, Icon = new TextBlock { Text = "\U0001f5d7" } }; + openWindowItem.Click += (_, _) => OpenChartInNewWindow(chart, chartName); + menu.Items.Add(openWindowItem); + + menu.Items.Add(new Separator()); + + var autoscaleItem = new MenuItem { Header = RevertHeader, Icon = new TextBlock { Text = "↩" } }; + autoscaleItem.Click += (_, _) => RevertChartAxes(chart); + menu.Items.Add(autoscaleItem); + + menu.Items.Add(new Separator()); + + var exportCsvItem = new MenuItem { Header = ExportDataToCsvHeader, Icon = new TextBlock { Text = "\U0001f4ca" } }; + exportCsvItem.Click += (_, _) => ExportChartDataToCsv(chart, chartName); + menu.Items.Add(exportCsvItem); + + /* Show Data Source only when a source string is supplied — matches Lite (no ServerTab chart wires one, + so like Lite the item is absent by default; the capability is ported for parity). */ + if (!string.IsNullOrEmpty(dataSource)) + { + menu.Items.Add(new Separator()); + var dataSourceItem = new MenuItem { Header = ShowDataSourceHeader, Icon = new TextBlock { Text = "ℹ" } }; + dataSourceItem.Click += (_, _) => MessageBox.Show( + $"Data Source:\n\n{dataSource}", "Chart Data Source", MessageBoxButton.OK, MessageBoxImage.Information); + menu.Items.Add(dataSourceItem); + } + + /* Own right-click (removes ScottPlot's context/pan responses + PreviewMouseRightButtonDown) — the + shared path with the drill-down charts and the query heatmap (ViewerServerTab.DrillDown.cs). */ + AttachChartContextMenu(chart, menu); + + /* Revert on double-click too (Lite parity): drop ScottPlot's own double-click autoscale so ours (which + also clears an active click-isolate) runs instead. */ + chart.UserInputProcessor.UserActionResponses.RemoveAll(r => + r.GetType().Name.Contains("DoubleClick", StringComparison.Ordinal)); + chart.PreviewMouseDoubleClick += (_, e) => + { + e.Handled = true; + RevertChartAxes(chart); + }; + + return menu; + } + + /// <summary> + /// Wires the copy/save/export menu onto every chart that has NO drill-down (Lite gives every chart the + /// menu; the drill-down charts get theirs in <c>WireChartDrillDowns</c>, the heatmap in + /// <c>WireHeatmapDrillDownMenu</c>). Called from the constructor after the drill-downs. + /// </summary> + private void WireChartContextMenus() + { + /* Only the collector-duration chart is menu-only here — Lite leaves its collector-duration chart + drill-less too. The other Darling-only charts (latch/spinlock, CPU scheduler, plan cache, session + counts, the eight System Events charts) now carry a "Show Active Queries at This Time" drill-down, + so they get their copy/export menu from WireChartDrillDowns instead (one menu per chart — no + double-menuing). */ + BuildChartContextMenu(CollectorDurationChart, "Collector_Duration"); + } + + private static void CopyChartImage(WpfPlot chart) + { + var tempFile = Path.Combine(Path.GetTempPath(), $"chart_copy_{Guid.NewGuid()}.png"); + try + { + chart.Plot.SavePng(tempFile, (int)chart.ActualWidth, (int)chart.ActualHeight); + var bitmap = new BitmapImage(); + bitmap.BeginInit(); + bitmap.CacheOption = BitmapCacheOption.OnLoad; + bitmap.UriSource = new Uri(tempFile); + bitmap.EndInit(); + bitmap.Freeze(); + Clipboard.SetDataObject(new DataObject(DataFormats.Bitmap, bitmap), false); + } + finally + { + if (File.Exists(tempFile)) File.Delete(tempFile); + } + } + + private static void SaveChartImage(WpfPlot chart, string chartName) + { + var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss", CultureInfo.InvariantCulture); + var saveDialog = new SaveFileDialog + { + Filter = "PNG Image|*.png|JPEG Image|*.jpg|BMP Image|*.bmp", + FileName = $"{chartName}_{timestamp}.png", + DefaultExt = ".png", + }; + + if (saveDialog.ShowDialog() == true) + { + chart.Plot.SavePng(saveDialog.FileName, (int)chart.ActualWidth, (int)chart.ActualHeight); + } + } + + private static void OpenChartInNewWindow(WpfPlot chart, string chartName) + { + var newWindow = new Window + { + Title = chartName.Replace("_", " ", StringComparison.Ordinal), + Width = 800, + Height = 600, + }; + + var tempFile = Path.Combine(Path.GetTempPath(), $"chart_temp_{Guid.NewGuid()}.png"); + try + { + chart.Plot.SavePng(tempFile, 800, 600); + var image = new Image(); + var bitmap = new BitmapImage(); + bitmap.BeginInit(); + bitmap.CacheOption = BitmapCacheOption.OnLoad; + bitmap.UriSource = new Uri(tempFile); + bitmap.EndInit(); + bitmap.Freeze(); + image.Source = bitmap; + newWindow.Content = image; + } + finally + { + if (File.Exists(tempFile)) File.Delete(tempFile); + } + + newWindow.Show(); + } + + private void RevertChartAxes(WpfPlot chart) + { + /* Clear an active click-isolate first so it doesn't leave series dimmed / state stale (Lite parity). */ + if (ChartHoverHelper.TryGetForChart(chart, out var h)) h.Restore(); + + /* Revert re-pins X to the toolbar's settable window (+ auto-fit Y) instead of AutoScale()'ing to the + data range — a bare AutoScale fits X to the data plus ScottPlot's ~10% side margins, which re-creates + the symmetric dead-space the window-pin campaign removed, on every Revert / double-click. The Query + Heatmap is the one exception: its X axis is categorical (bucket columns), so it keeps AutoScale. */ + if (ReferenceEquals(chart, QueryHeatmapChart)) + { + chart.Plot.Axes.AutoScale(); + } + else + { + var (startUtc, endUtc) = GetWindowUtc(); + chart.Plot.Axes.SetLimitsX( + ViewerTimeHelper.ForDisplay(startUtc).ToOADate(), + ViewerTimeHelper.ForDisplay(endUtc).ToOADate()); + chart.Plot.Axes.AutoScaleY(); + } + + chart.Refresh(); + } + + private static void ExportChartDataToCsv(WpfPlot chart, string chartName) + { + var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss", CultureInfo.InvariantCulture); + var saveDialog = new SaveFileDialog + { + Filter = "CSV Files|*.csv|All Files|*.*", + FileName = $"{chartName}_data_{timestamp}.csv", + DefaultExt = ".csv", + }; + + if (saveDialog.ShowDialog() != true) return; + + try + { + var sep = ViewerExportSettings.CsvSeparator; + var sb = new StringBuilder(); + sb.AppendLine(ChartCsvHeaderLine(sep)); + + var seriesIndex = 1; + foreach (var plottable in chart.Plot.GetPlottables()) + { + if (plottable is ScottPlot.Plottables.Scatter scatter) + { + var seriesName = scatter.LegendText ?? $"Series{seriesIndex}"; + foreach (var point in scatter.Data.GetScatterPoints()) + { + sb.AppendLine(FormatChartCsvLine(DateTime.FromOADate(point.X), seriesName, point.Y, sep)); + } + + seriesIndex++; + } + } + + File.WriteAllText(saveDialog.FileName, sb.ToString(), Encoding.UTF8); + MessageBox.Show($"Data exported to:\n{saveDialog.FileName}", "Export Complete", MessageBoxButton.OK, MessageBoxImage.Information); + } + catch (Exception ex) + { + MessageBox.Show($"Error exporting data:\n\n{ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ChartHelpers.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ChartHelpers.cs new file mode 100644 index 000000000..80811caa8 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ChartHelpers.cs @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// Per-tab chart-helper forwarders. The STATEFUL shapes (legend-panel tracking + the empty-state chart) live +/// in the shared <see cref="ChartRenderHelper"/>; these thin instance forwarders keep the many Render* call +/// sites unchanged. The stateless theme / axis-color / y-limit shapes delegate to the shared +/// <see cref="ChartStyle"/>. (Formerly held a verbatim copy of Lite's ServerTab.Charts.cs helpers.) +/// </summary> +public partial class ViewerServerTab +{ + private readonly ChartRenderHelper _chartHelper = new(); + + private void ClearChart(ScottPlot.WPF.WpfPlot chart) => _chartHelper.ClearChart(chart); + + private void ShowChartLegend(ScottPlot.WPF.WpfPlot chart) => _chartHelper.ShowChartLegend(chart); + + private void RefreshEmptyChart(ScottPlot.WPF.WpfPlot chart, string legendText, string yAxisLabel) + => _chartHelper.RefreshEmptyChart(chart, legendText, yAxisLabel); + + private static void ApplyTheme(ScottPlot.WPF.WpfPlot chart) => ChartStyle.ApplyThemeToChart(chart); + + private static void ReapplyAxisColors(ScottPlot.WPF.WpfPlot chart) => ChartStyle.ReapplyAxisColors(chart); + + private static void SetChartYLimitsWithLegendPadding(ScottPlot.WPF.WpfPlot chart, double dataYMin = 0, double dataYMax = 0) + => ChartStyle.SetChartYLimitsWithLegendPadding(chart, dataYMin, dataYMax); +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Charts.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Charts.cs new file mode 100644 index 000000000..eb1d3ca0e --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Charts.cs @@ -0,0 +1,334 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The CPU and tempdb inner-tab charts, copied from Lite's <c>ServerTab.Charts.cs</c> +/// (<c>UpdateCpuChart</c> and the three <c>UpdateTempDb*Chart</c> bodies) with the data layer rewired +/// to <see cref="ViewerDataService"/> Postgres reads. The only render-body change is the time axis: +/// where Lite shifts by its per-server <c>UtcOffsetMinutes</c> (CPU plots the raw server-local +/// sample_time directly), the viewer has no server-offset concept, so every point runs through +/// <see cref="ViewerTimeHelper.ForDisplay"/> — the naive-UTC-to-viewer-local convention the shell's +/// Overview charts already use. The hover tooltips are kept, and Lite's per-chart "Show Active Queries at +/// This Time" drill-down IS now ported (the right-click menu is wired in <c>ViewerServerTab.DrillDown.cs</c>; +/// Lite's ContextMenuHelper save/export chrome remains Lite-only). Chart chrome/legend/line polish flow +/// through the shared <see cref="ChartStyle"/> +/// / <see cref="ChartPalette"/> and the <c>ViewerServerTab.ChartHelpers.cs</c> bridge, exactly as in +/// Lite. +/// </summary> +public partial class ViewerServerTab : IDisposable +{ + private ChartHoverHelper? _cpuHover; + private ChartHoverHelper? _tempDbHover; + private ChartHoverHelper? _tempDbSizeHover; + private ChartHoverHelper? _tempDbFileIoHover; + + /* Cycling palette sourced from the shared ChartPalette so the per-file tempdb I/O series cycle the + SAME colors in the same order as Lite and Dashboard (mirrors Lite's ServerTab.SeriesColors). */ + private static readonly string[] SeriesColors = ChartPalette.CyclingPalette.ToArray(); + + /// <summary> + /// Applies the shared chrome to the CPU/tempdb charts and wires their hover tooltips. Called from + /// the constructor after <c>InitializeComponent</c>; the theme is applied up front so the charts + /// don't flash white before their tab's first load, matching Lite's ServerTab. + /// </summary> + private void InitializeCpuTempDbCharts() + { + ApplyTheme(CpuChart); + CpuChart.Refresh(); + ApplyTheme(TempDbChart); + TempDbChart.Refresh(); + ApplyTheme(TempDbSizeChart); + TempDbSizeChart.Refresh(); + ApplyTheme(TempDbFileIoChart); + TempDbFileIoChart.Refresh(); + + /* Hover tooltips with Lite's per-chart units ("%", "MB", "MB", "ms"). */ + _cpuHover = new ChartHoverHelper(CpuChart, "%"); + _tempDbHover = new ChartHoverHelper(TempDbChart, "MB"); + _tempDbSizeHover = new ChartHoverHelper(TempDbSizeChart, "MB"); + _tempDbFileIoHover = new ChartHoverHelper(TempDbFileIoChart, "ms"); + } + + /// <summary>Tears down the hover helpers (unhooks their chart event handlers). Called from + /// MainWindow when the server tab closes.</summary> + public void Dispose() + { + /* Stop the toolbar's auto-refresh timer so a closed tab's timer can't fire against a torn-down UI. */ + if (_autoRefreshTimer != null) + { + _autoRefreshTimer.Stop(); + _autoRefreshTimer.Tick -= OnAutoRefreshTick; + } + + _cpuHover?.Dispose(); + _tempDbHover?.Dispose(); + _tempDbSizeHover?.Dispose(); + _tempDbFileIoHover?.Dispose(); + /* Each partial owns its own hover fields; the single Dispose() forwards to their teardowns + so the whole tab tears down through one path. */ + DisposeChartHelpers(); + DisposeMemoryHelpers(); + DisposePlanCacheHelpers(); + DisposeCpuSchedulerHelpers(); + DisposeLatchSpinlockHelpers(); + DisposeSessionStatsHelpers(); + DisposeFileIoHelpers(); + DisposeBlockingHelpers(); + DisposePerfmonHelpers(); + DisposeCollectionHealthHelpers(); + DisposeQueriesTabHelpers(); + DisposePlanHelpers(); + DisposeSystemHealthChartHelpers(); + } + + /// <summary>Loads the CPU tab: raw per-sample CPU utilization over the window.</summary> + private async Task LoadCpuAsync() + { + var (startUtc, endUtc) = GetWindowUtc(); + var samples = await _dataService.GetCpuUtilizationAsync(_server.ServerId, startUtc); + /* The read is start-only server-side; bound the end for a custom range so a window that ends in + the past doesn't trail to the newest sample. sample_time is de-skewed to naive UTC (matching endUtc). */ + if (IsCustomRange) + { + samples = samples.Where(s => s.SampleTime <= endUtc).ToList(); + } + RenderCpuChart(samples, startUtc, endUtc); + + /* The CPU tab is a sub-TabControl (CPU Utilization + CPU Scheduler); load the scheduler sub-tab in + the same pass — the CPU tab's full-refresh branch, mirroring the Memory tab's all-sub-tabs load. */ + await LoadCpuSchedulerAsync(); + } + + /// <summary>Loads the tempdb tab: usage/size trend and per-file I/O latency, read concurrently.</summary> + private async Task LoadTempDbAsync() + { + var (startUtc, endUtc) = GetWindowUtc(); + + /* Both reads run concurrently — NpgsqlDataSource pools a connection for each. */ + var trendTask = _dataService.GetTempDbTrendAsync(_server.ServerId, startUtc); + var fileIoTask = _dataService.GetTempDbFileIoTrendAsync(_server.ServerId, startUtc); + var trend = await trendTask; + var fileIo = await fileIoTask; + + /* Start-only reads; bound the end for a custom range (collection_time is naive UTC). */ + if (IsCustomRange) + { + trend = trend.Where(t => t.CollectionTime <= endUtc).ToList(); + fileIo = fileIo.Where(f => f.CollectionTime <= endUtc).ToList(); + } + + RenderTempDbUsageChart(trend, startUtc, endUtc); + RenderTempDbSizeChart(trend, startUtc, endUtc); + RenderTempDbFileIoChart(fileIo, startUtc, endUtc); + } + + private void RenderCpuChart(List<CpuUtilizationSample> data, DateTime startUtc, DateTime endUtc) + { + ClearChart(CpuChart); + _cpuHover?.Clear(); + ApplyTheme(CpuChart); + + var rangeStart = ViewerTimeHelper.ForDisplay(startUtc).ToOADate(); + var rangeEnd = ViewerTimeHelper.ForDisplay(endUtc).ToOADate(); + + if (data.Count == 0) + { + CpuChart.Plot.Axes.DateTimeTicksBottomDateChange(); + CpuChart.Plot.Axes.SetLimitsX(rangeStart, rangeEnd); + ReapplyAxisColors(CpuChart); + CpuChart.Refresh(); + return; + } + + /* sample_time is the monitored server's LOCAL wall clock in the store, so GetCpuUtilizationAsync + de-skews it to naive UTC in SQL (#1262); it then runs through ViewerTimeHelper.ForDisplay like + every other Darling chart. (The SQL de-skew — recovering the offset from the collection batch — + is a data correction independent of the Server/Local/UTC display mode ForDisplay then applies; + Lite instead shifts the raw server-local sample_time by its per-server ServerTimeHelper offset.) */ + var times = data.Select(d => ViewerTimeHelper.ForDisplay(d.SampleTime).ToOADate()).ToArray(); + var sqlCpu = data.Select(d => (double)d.SqlServerCpu).ToArray(); + var otherCpu = data.Select(d => (double)d.OtherProcessCpu).ToArray(); + + var sqlPlot = CpuChart.Plot.Add.Scatter(times, sqlCpu); + sqlPlot.LegendText = "SQL Server"; + sqlPlot.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("SqlCpu")); + ChartStyle.StyleScatter(sqlPlot); + _cpuHover?.Add(sqlPlot, "SQL Server"); + + var otherPlot = CpuChart.Plot.Add.Scatter(times, otherCpu); + otherPlot.LegendText = "Other"; + otherPlot.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("OtherCpu")); + ChartStyle.StyleScatter(otherPlot); + _cpuHover?.Add(otherPlot, "Other"); + + CpuChart.Plot.Axes.DateTimeTicksBottomDateChange(); + CpuChart.Plot.Axes.SetLimitsX(rangeStart, rangeEnd); + ReapplyAxisColors(CpuChart); + CpuChart.Plot.YLabel("CPU %"); + CpuChart.Plot.Axes.SetLimitsY(0, 105); + + ShowChartLegend(CpuChart); + CpuChart.Refresh(); + } + + private void RenderTempDbUsageChart(List<TempDbSample> data, DateTime startUtc, DateTime endUtc) + { + ClearChart(TempDbChart); + _tempDbHover?.Clear(); + ApplyTheme(TempDbChart); + + var rangeStart = ViewerTimeHelper.ForDisplay(startUtc).ToOADate(); + var rangeEnd = ViewerTimeHelper.ForDisplay(endUtc).ToOADate(); + + if (data.Count == 0) + { + TempDbChart.Plot.Axes.DateTimeTicksBottomDateChange(); + TempDbChart.Plot.Axes.SetLimitsX(rangeStart, rangeEnd); + ReapplyAxisColors(TempDbChart); + TempDbChart.Refresh(); + return; + } + + var times = data.Select(d => ViewerTimeHelper.ForDisplay(d.CollectionTime).ToOADate()).ToArray(); + var userObj = data.Select(d => d.UserObjectReservedMb).ToArray(); + var internalObj = data.Select(d => d.InternalObjectReservedMb).ToArray(); + var versionStore = data.Select(d => d.VersionStoreReservedMb).ToArray(); + + var userPlot = TempDbChart.Plot.Add.Scatter(times, userObj); + userPlot.LegendText = "User Objects"; + userPlot.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("UserObjects")); + ChartStyle.StyleScatter(userPlot); + _tempDbHover?.Add(userPlot, "User Objects"); + + var internalPlot = TempDbChart.Plot.Add.Scatter(times, internalObj); + internalPlot.LegendText = "Internal Objects"; + internalPlot.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("InternalObjects")); + ChartStyle.StyleScatter(internalPlot); + _tempDbHover?.Add(internalPlot, "Internal Objects"); + + var vsPlot = TempDbChart.Plot.Add.Scatter(times, versionStore); + vsPlot.LegendText = "Version Store"; + vsPlot.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("VersionStore")); + ChartStyle.StyleScatter(vsPlot); + _tempDbHover?.Add(vsPlot, "Version Store"); + + TempDbChart.Plot.Axes.DateTimeTicksBottomDateChange(); + TempDbChart.Plot.Axes.SetLimitsX(rangeStart, rangeEnd); + ReapplyAxisColors(TempDbChart); + TempDbChart.Plot.YLabel("MB"); + + var maxVal = new[] { userObj.Max(), internalObj.Max(), versionStore.Max() }.Max(); + SetChartYLimitsWithLegendPadding(TempDbChart, 0, maxVal); + + ShowChartLegend(TempDbChart); + TempDbChart.Refresh(); + } + + // Dedicated chart for tempdb TOTAL allocated size (used + unallocated free space) over time — the + // growth trend, on its own scale (AutoScaleY) so it doesn't flatten the usage series above. Mirror + // of Lite's UpdateTempDbSizeChart (single series, no legend). + private void RenderTempDbSizeChart(List<TempDbSample> data, DateTime startUtc, DateTime endUtc) + { + ClearChart(TempDbSizeChart); + ApplyTheme(TempDbSizeChart); + _tempDbSizeHover?.Clear(); + + var rangeStart = ViewerTimeHelper.ForDisplay(startUtc).ToOADate(); + var rangeEnd = ViewerTimeHelper.ForDisplay(endUtc).ToOADate(); + + if (data.Count == 0) + { + TempDbSizeChart.Plot.Axes.DateTimeTicksBottomDateChange(); + TempDbSizeChart.Plot.Axes.SetLimitsX(rangeStart, rangeEnd); + ReapplyAxisColors(TempDbSizeChart); + TempDbSizeChart.Refresh(); + return; + } + + var sorted = data.OrderBy(d => d.CollectionTime).ToList(); + var times = sorted.Select(d => ViewerTimeHelper.ForDisplay(d.CollectionTime).ToOADate()).ToArray(); + var totals = sorted.Select(d => d.TotalReservedMb + d.UnallocatedMb).ToArray(); + + var sizePlot = TempDbSizeChart.Plot.Add.Scatter(times, totals); + sizePlot.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("UnallocatedTempdb")); + ChartStyle.StyleScatter(sizePlot); + _tempDbSizeHover?.Add(sizePlot, "Allocated MB"); + + TempDbSizeChart.Plot.Axes.DateTimeTicksBottomDateChange(); + ReapplyAxisColors(TempDbSizeChart); + TempDbSizeChart.Plot.YLabel("Allocated MB"); + TempDbSizeChart.Plot.Axes.AutoScaleY(); + TempDbSizeChart.Plot.Axes.SetLimitsX(rangeStart, rangeEnd); + TempDbSizeChart.Refresh(); + } + + private void RenderTempDbFileIoChart(List<TempDbFileIoSample> data, DateTime startUtc, DateTime endUtc) + { + ClearChart(TempDbFileIoChart); + _tempDbFileIoHover?.Clear(); + ApplyTheme(TempDbFileIoChart); + + var rangeStart = ViewerTimeHelper.ForDisplay(startUtc).ToOADate(); + var rangeEnd = ViewerTimeHelper.ForDisplay(endUtc).ToOADate(); + + if (data.Count == 0) + { + TempDbFileIoChart.Plot.Axes.DateTimeTicksBottomDateChange(); + TempDbFileIoChart.Plot.Axes.SetLimitsX(rangeStart, rangeEnd); + ReapplyAxisColors(TempDbFileIoChart); + TempDbFileIoChart.Refresh(); + return; + } + + /* One series per tempdb data file (Lite groups on the file-name column). */ + var files = data + .GroupBy(d => d.FileName) + .OrderByDescending(g => g.Sum(d => d.AvgReadLatencyMs + d.AvgWriteLatencyMs)) + .Take(12) + .ToList(); + + double maxLatency = 0; + int colorIdx = 0; + + foreach (var fileGroup in files) + { + var points = fileGroup.OrderBy(d => d.CollectionTime).ToList(); + var times = points.Select(d => ViewerTimeHelper.ForDisplay(d.CollectionTime).ToOADate()).ToArray(); + var latency = points.Select(d => d.AvgReadLatencyMs + d.AvgWriteLatencyMs).ToArray(); + var color = ScottPlot.Color.FromHex(SeriesColors[colorIdx % SeriesColors.Length]); + colorIdx++; + + if (latency.Length > 0) + { + var plot = TempDbFileIoChart.Plot.Add.Scatter(times, latency); + plot.LegendText = fileGroup.Key; + plot.Color = color; + ChartStyle.StyleScatter(plot); + _tempDbFileIoHover?.Add(plot, fileGroup.Key); + maxLatency = Math.Max(maxLatency, latency.Max()); + } + } + + TempDbFileIoChart.Plot.Axes.DateTimeTicksBottomDateChange(); + TempDbFileIoChart.Plot.Axes.SetLimitsX(rangeStart, rangeEnd); + ReapplyAxisColors(TempDbFileIoChart); + TempDbFileIoChart.Plot.YLabel("tempdb File I/O Latency (ms)"); + SetChartYLimitsWithLegendPadding(TempDbFileIoChart, 0, maxLatency > 0 ? maxLatency : 10); + ShowChartLegend(TempDbFileIoChart); + TempDbFileIoChart.Refresh(); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CollectionHealth.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CollectionHealth.cs new file mode 100644 index 000000000..0685fd84c --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CollectionHealth.cs @@ -0,0 +1,247 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Input; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Collection Health inner tab — a COPY of Lite's 3-sub-tab Collection Health surface +/// (ServerTab.xaml + the <c>RefreshCollectionHealthAsync</c> load, the +/// <c>CollectionHealthGrid_MouseDoubleClick</c> drill, and <c>UpdateCollectorDurationChart</c>), reads +/// rewired to Postgres. It REPLACES the shell's single latest-run-per-collector grid. Health Summary = +/// the 7-day per-collector aggregate (double-click opens the per-collector CollectionLogWindow drill); +/// Collection Log = the recent run log; Duration Trends = the per-collector success-duration scatter. +/// The chart's only render-body change from Lite is the time axis: where Lite shifts the raw stored +/// time by its per-server <c>UtcOffsetMinutes</c>, the viewer runs every point through +/// <see cref="ViewerTimeHelper.ForDisplay"/> (the naive-UTC-to-viewer-local convention every Darling +/// chart uses), and line polish flows through the shared <see cref="ChartStyle"/> like the other viewer +/// charts. Lite's per-chart context menu / "Open Log File" button are intentionally not ported. +/// </summary> +public partial class ViewerServerTab +{ + private ChartHoverHelper? _collectorDurationHover; + + /// <summary> + /// Applies the shared chrome to the Duration Trends chart and wires its hover tooltip. Called from + /// the constructor after <c>InitializeComponent</c> so it doesn't flash white before its first load, + /// matching Lite's ServerTab (and the viewer's other chart inits). + /// </summary> + private void InitializeCollectionHealthChart() + { + ApplyTheme(CollectorDurationChart); + CollectorDurationChart.Refresh(); + _collectorDurationHover = new ChartHoverHelper(CollectorDurationChart, "ms"); + } + + /// <summary> + /// Collection Health tab load: the 7-day per-collector health aggregate and the recent collection + /// log read concurrently (NpgsqlDataSource pools a connection for each), then each grid goes through + /// its filter manager's UpdateData so active column filters survive the refresh, and the log also + /// feeds the Duration Trends chart. Mirrors Lite's <c>RefreshCollectionHealthAsync</c> — but the + /// reads are genuinely async, so there is no Task.Run wrap. LoadInnerTabAsync owns the try/catch that + /// surfaces failures on the status bar. + /// </summary> + private async Task LoadHealthAsync() + { + /* Health Summary stays Lite's fixed 7-day per-collector rollup (its staleness banding needs a + stable horizon regardless of the toolbar window). The Collection Log + Duration Trends honor the + settable window EXACTLY — a preset or a custom From/To — via GetWindowUtc(), matching the Wait + Stats / Blocking tabs (the old GetWindowHoursBack() rounded a custom range to a now-relative span). */ + var (startUtc, endUtc) = GetWindowUtc(); + var healthTask = _dataService.GetCollectionHealthAsync(_server.ServerId); + var logTask = _dataService.GetRecentCollectionLogAsync(_server.ServerId, startUtc, endUtc); + await Task.WhenAll(healthTask, logTask); + + _collectionHealthFilterMgr!.UpdateData(healthTask.Result); + _collectionLogFilterMgr!.UpdateData(logTask.Result); + RenderCollectorDurationChart(logTask.Result); + } + + /// <summary> + /// "Purge Now" (Collection Health): runs the daily retention purge on demand via the fleet-wide + /// <c>purge_now</c> control command, after a confirm — it permanently deletes collected data older than the + /// configured retention horizons across ALL monitored servers (the purge is fleet-wide over the shared + /// store). A read-only viewer seat can't enqueue commands, so it shows an explanation instead (same rule as + /// Pause / live-plan fetch). On success it shows the purged summary and reloads the tab so the grids/chart + /// reflect the purge. + /// </summary> + private async void PurgeNow_Click(object sender, RoutedEventArgs e) + { + if (_dataService.IsReadOnly) + { + MessageBox.Show( + "Purging asks the service to run the retention purge, which it does by running a command — a " + + "read-only viewer seat can't enqueue commands. Reconnect with a read-write profile to purge.", + "Read-Only Viewer", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + var confirm = MessageBox.Show( + "Run the retention purge now?\n\n" + + "This permanently deletes collected data older than the configured retention horizons across ALL " + + "monitored servers (the purge is fleet-wide over the shared store). It is exactly what the service " + + "does automatically once a day — running it now just does it immediately. This cannot be undone.", + "Purge Now", MessageBoxButton.YesNo, MessageBoxImage.Warning); + if (confirm != MessageBoxResult.Yes) + { + return; + } + + PurgeNowButton.IsEnabled = false; + PurgeNowIndicator.Text = "Purging..."; + try + { + var result = await _dataService.RequestPurgeNowAsync(); + if (result is null) + { + PurgeNowIndicator.Text = "Purge still running — re-open Collection Health to see the result"; + } + else if (result.Status != ViewerDataService.StatusSucceeded) + { + PurgeNowIndicator.Text = $"Purge failed: {result.ResultStatus ?? "unknown error"}"; + } + else + { + PurgeNowIndicator.Text = FormatPurgeSummary(result.ResultJson); + /* Reflect the purge in the grids + duration chart. */ + await LoadHealthAsync(); + } + } + catch (ViewerReadOnlyException) + { + /* Grants changed under us (the enqueue already threw). */ + PurgeNowIndicator.Text = "Read-only viewer — cannot purge"; + } + catch (Exception ex) + { + PurgeNowIndicator.Text = ""; + StatusChanged?.Invoke($"purge failed: {ex.Message}"); + } + finally + { + PurgeNowButton.IsEnabled = true; + } + } + + /// <summary> + /// Formats the <c>purge_now</c> result_json (<c>{ tablesPurged, rowsPurged, ... }</c>) into the one-line + /// summary the indicator shows. Degrades to a plain "Purge complete" if the JSON is missing/unparseable. + /// </summary> + private static string FormatPurgeSummary(string? resultJson) + { + if (string.IsNullOrWhiteSpace(resultJson)) + { + return "Purge complete"; + } + + try + { + using var doc = JsonDocument.Parse(resultJson); + var root = doc.RootElement; + var tables = root.TryGetProperty("tablesPurged", out var t) && t.TryGetInt32(out var ti) ? ti : 0; + var rows = root.TryGetProperty("rowsPurged", out var r) && r.TryGetInt32(out var ri) ? ri : 0; + return $"Purged {rows:N0} row(s)/chunk(s) across {tables:N0} table(s)"; + } + catch (JsonException) + { + return "Purge complete"; + } + } + + /// <summary> + /// Double-click a Health Summary row to open that collector's full collection history. Copied from + /// Lite's <c>CollectionHealthGrid_MouseDoubleClick</c>, repointed to the viewer's CollectionLogWindow. + /// </summary> + private void CollectionHealthGrid_MouseDoubleClick(object sender, MouseButtonEventArgs e) + { + if (CollectionHealthGrid.SelectedItem is not CollectorHealthRow item) return; + + var window = new CollectionLogWindow(_dataService, _server.ServerId, item.CollectorName) + { + Owner = Window.GetWindow(this) + }; + window.ShowDialog(); + } + + /// <summary> + /// Per-collector success-duration scatter over the window. Copied from Lite's + /// <c>UpdateCollectorDurationChart</c>: one line per collector (SUCCESS runs with a duration, needing + /// at least two points), cycling the shared palette. The one change is the time axis — every point + /// runs through <see cref="ViewerTimeHelper.ForDisplay"/> (Lite shifts by its per-server + /// UtcOffsetMinutes) — and line polish uses the shared <see cref="ChartStyle.StyleScatter"/>. + /// </summary> + private void RenderCollectorDurationChart(List<CollectionLogRow> data) + { + ClearChart(CollectorDurationChart); + ApplyTheme(CollectorDurationChart); + + /* Pin the X axis to the toolbar's settable window (the same idiom as the wait / tempdb-size charts) + rather than AutoScale()'ing to the data — an AutoScale fits X to the data plus ScottPlot's ~10% + side margins, which reads as symmetric dead space. This is the one chart the #1483/#1484/#1487 + window-pin campaign missed. The store is naive-UTC; display converts through ViewerTimeHelper.ForDisplay. */ + var (startUtc, endUtc) = GetWindowUtc(); + var rangeStart = ViewerTimeHelper.ForDisplay(startUtc).ToOADate(); + var rangeEnd = ViewerTimeHelper.ForDisplay(endUtc).ToOADate(); + + if (data.Count == 0) + { + CollectorDurationChart.Plot.Axes.DateTimeTicksBottomDateChange(); + CollectorDurationChart.Plot.Axes.SetLimitsX(rangeStart, rangeEnd); + ReapplyAxisColors(CollectorDurationChart); + CollectorDurationChart.Refresh(); + return; + } + + /* Group by collector, plot each as a separate series */ + var groups = data + .Where(d => d.DurationMs.HasValue && d.Status == "SUCCESS") + .GroupBy(d => d.CollectorName) + .OrderBy(g => g.Key) + .ToList(); + + _collectorDurationHover?.Clear(); + int colorIdx = 0; + foreach (var group in groups) + { + var points = group.OrderBy(d => d.CollectionTime).ToList(); + if (points.Count < 2) continue; + + var times = points.Select(d => ViewerTimeHelper.ForDisplay(d.CollectionTime).ToOADate()).ToArray(); + var durations = points.Select(d => (double)d.DurationMs!.Value).ToArray(); + + var scatter = CollectorDurationChart.Plot.Add.Scatter(times, durations); + scatter.LegendText = group.Key; + scatter.Color = ScottPlot.Color.FromHex(SeriesColors[colorIdx % SeriesColors.Length]); + ChartStyle.StyleScatter(scatter); + _collectorDurationHover?.Add(scatter, group.Key); + colorIdx++; + } + + CollectorDurationChart.Plot.Axes.DateTimeTicksBottomDateChange(); + ReapplyAxisColors(CollectorDurationChart); + CollectorDurationChart.Plot.YLabel("Duration (ms)"); + CollectorDurationChart.Plot.Axes.AutoScaleY(); + CollectorDurationChart.Plot.Axes.SetLimitsX(rangeStart, rangeEnd); + ShowChartLegend(CollectorDurationChart); + CollectorDurationChart.Refresh(); + } + + /// <summary>Tears down the Duration Trends hover helper. Forwarded to from the tab's single Dispose().</summary> + private void DisposeCollectionHealthHelpers() + { + _collectorDurationHover?.Dispose(); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ConfigChanges.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ConfigChanges.cs new file mode 100644 index 000000000..0d1cd65d1 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ConfigChanges.cs @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Configuration Changes inner tab — the Dashboard's dedicated <c>ConfigChangesContent</c> ported as its +/// own per-server tab (three grids: server-config, database-config, trace-flag DRIFT over the window). The +/// read + C# snapshot-diff lives in <see cref="ViewerDataService"/> (ViewerDataService.ConfigChanges.cs, +/// mirroring the service's DarlingConfigHistoryReader / #1421); this loader just windows on the per-server +/// toolbar's settable range (so "Apply to All" flows through unchanged) and hands each result to its column +/// filter manager (registered in ViewerServerTab.Filters.cs) so active filters survive a refresh. Each grid's +/// EmptyHint overlay shows the honest "needs >= 2 captures" state when the diff yields nothing (config is +/// captured on connect/restart, not on a timer, so change history is inherently sparse). LoadInnerTabAsync +/// owns the try/catch that surfaces failures on the status bar. +/// </summary> +public partial class ViewerServerTab : UserControl +{ + private async Task LoadConfigChangesAsync() + { + var (startUtc, endUtc) = GetWindowUtc(); + + var serverTask = _dataService.GetServerConfigChangesAsync(_server.ServerId, startUtc, endUtc); + var databaseTask = _dataService.GetDatabaseConfigChangesAsync(_server.ServerId, startUtc, endUtc, databaseNames: SelectedDatabaseFilter); + var traceFlagTask = _dataService.GetTraceFlagChangesAsync(_server.ServerId, startUtc, endUtc); + + await Task.WhenAll(serverTask, databaseTask, traceFlagTask); + + var serverChanges = serverTask.Result; + var databaseChanges = databaseTask.Result; + var traceFlagChanges = traceFlagTask.Result; + + _serverConfigChangesFilterMgr!.UpdateData(serverChanges); + _databaseConfigChangesFilterMgr!.UpdateData(databaseChanges); + _traceFlagChangesFilterMgr!.UpdateData(traceFlagChanges); + + /* Empty state keyed on the unfiltered change count (a real "no drift in this window" signal), matching + the Dashboard's data.Count == 0 check — independent of any active column filter. */ + ServerConfigChangesNoDataMessage.Visibility = serverChanges.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + DatabaseConfigChangesNoDataMessage.Visibility = databaseChanges.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + TraceFlagChangesNoDataMessage.Visibility = traceFlagChanges.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CopyExport.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CopyExport.cs new file mode 100644 index 000000000..b079c7c07 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CopyExport.cs @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using Microsoft.Win32; +using PerformanceMonitor.PlanAnalysis; +using PerformanceMonitor.Ui; +using static PerformanceMonitor.Ui.FileSaveHelper; +using static PerformanceMonitor.Ui.DataGridHelpers; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The grid copy/export + Copy-Repro-Script + XML-save handlers (W1e / W1f), copied from Lite's +/// <c>ServerTab.CopyExport.cs</c> / <c>ServerTab.Plans.cs</c>. Copy/Export delegate to the shared +/// PerformanceMonitor.Ui <see cref="DataGridExport"/>; the XML-save buttons write the row's stored graph / +/// report XML to a file. <see cref="CopyReproScript_Click"/> builds a paste-ready T-SQL repro from the query +/// grids' STORED row fields (no live connection — Active Queries carries its plan in-row; Top Queries / Query +/// Store best-effort read the collector's STORED plan from Postgres to enrich parameters). Only Lite's +/// "Get Actual Plan" (a LIVE re-execution) stays omitted; the stored-plan "View Plan" host lives in +/// ViewerServerTab.Plans.cs. +/// </summary> +public partial class ViewerServerTab +{ + /* The product tag ReproScriptBuilder stamps in the repro-script header comment. */ + private const string ReproProductName = "SQL Server Performance Monitor"; + + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); + + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); + + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); + + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, _server.DisplayName, ViewerExportSettings.CsvSeparator); + + /// <summary> + /// "Copy Repro Script" for the query grids — builds a paste-ready T-SQL reproduction from the row's STORED + /// fields and copies it to the clipboard. NO live SQL connection: Active Queries carries its query text / + /// plan / isolation in-row; Top Queries / Query Store best-effort read their STORED plan from Postgres (a + /// read via <see cref="ViewerDataService"/>, not a live re-exec) to enrich the extracted parameters, then + /// fall back to a plan-less repro. (Lite fetches these plans live from the monitored server — the viewer + /// reads the collector's stored copy, its only seam-difference; Lite's "Get Actual Plan" has no equivalent.) + /// </summary> + private async void CopyReproScript_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem) return; + var grid = FindParentDataGrid(menuItem); + if (grid?.CurrentItem is not { } row) return; + + /* Best-effort STORED-plan enrichment for the aggregate grids (a Postgres read, never a live SQL + connection). Active Queries needs none — its plan rides in-row. Failures fall through to a + plan-less repro. */ + string? enrichedPlanXml = null; + try + { + switch (row) + { + case ViewerQueryStatsRow stats when !string.IsNullOrEmpty(stats.QueryHash): + enrichedPlanXml = await _dataService.GetQueryStatsPlanXmlAsync(_server.ServerId, stats.DatabaseName, stats.QueryHash); + break; + case ViewerQueryStoreRow qs: + enrichedPlanXml = await _dataService.GetQueryStorePlanTextAsync(_server.ServerId, qs.DatabaseName, qs.QueryId, qs.PlanId); + break; + } + } + catch + { + /* Stored-plan enrichment is best-effort — build the repro from the row's other stored fields. */ + } + + var script = BuildReproScriptForRow(row, enrichedPlanXml, ReproProductName); + if (string.IsNullOrEmpty(script)) return; + + /* SetDataObject(copy=false) avoids WPF's problematic Clipboard.Flush(); see dotnet/wpf#9901. */ + Clipboard.SetDataObject(script, false); + } + + /// <summary> + /// Maps a query-grid row to a repro script built from its STORED fields (pure, unit-tested). Active Queries + /// uses its in-row plan + isolation (<paramref name="enrichedPlanXml"/> ignored); Top Queries / Query Store + /// use the caller's best-effort stored <paramref name="enrichedPlanXml"/> (null → a plan-less repro, which + /// ReproScriptBuilder still produces with a "plan not available" note). Returns null for a row with no query + /// text (comparison aggregates / procedures) or an unsupported row type — the caller then no-ops, exactly + /// like Lite's default branch. NO row type here reads a live connection. + /// </summary> + internal static string? BuildReproScriptForRow(object row, string? enrichedPlanXml, string productName) + { + switch (row) + { + case ViewerQuerySnapshotRow snapshot: + if (string.IsNullOrEmpty(snapshot.QueryText)) return null; + return ReproScriptBuilder.BuildReproScript( + snapshot.QueryText, snapshot.DatabaseName, snapshot.QueryPlan, snapshot.TransactionIsolationLevel, + "Active Queries", productName: productName); + + case ViewerQueryStatsRow stats: + if (string.IsNullOrEmpty(stats.QueryText)) return null; + return ReproScriptBuilder.BuildReproScript( + stats.QueryText, stats.DatabaseName, enrichedPlanXml, null, + "Top Queries (dm_exec_query_stats)", productName: productName); + + case ViewerQueryStoreRow qs: + if (string.IsNullOrEmpty(qs.QueryText)) return null; + return ReproScriptBuilder.BuildReproScript( + qs.QueryText, qs.DatabaseName, enrichedPlanXml, null, + "Query Store", productName: productName); + + /* Query Store Regressions carries the query-text sample but no plan (the regression aggregates + OVER plans — plan_count deltas, not a single plan_id — so any one plan would mislead; the + plan-change story is in the double-click history window). Build a plan-less repro from the + sample, exactly what ReproScriptBuilder produces when the plan is unavailable. */ + case ViewerQueryStoreRegressionRow reg: + if (string.IsNullOrEmpty(reg.QueryTextSample)) return null; + return ReproScriptBuilder.BuildReproScript( + reg.QueryTextSample, reg.DatabaseName, null, null, + "Query Store Regressions", productName: productName); + + default: + return null; + } + } + + private void DownloadBlockedProcessXml_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button btn || btn.DataContext is not ViewerBlockedProcessRow row || string.IsNullOrEmpty(row.BlockedProcessReportXml)) return; + SaveXmlToFile(row.BlockedProcessReportXml, $"blocked_process_{row.EventTime:yyyyMMdd_HHmmss}.xml", "blocked process XML"); + } + + private void DownloadDeadlockXml_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button btn || btn.DataContext is not DeadlockProcessDetail row || string.IsNullOrEmpty(row.DeadlockGraphXml)) return; + SaveXmlToFile(row.DeadlockGraphXml, $"deadlock_{row.DeadlockTime:yyyyMMdd_HHmmss}.xml", "deadlock XML"); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CpuScheduler.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CpuScheduler.cs new file mode 100644 index 000000000..ce7c1bfef --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CpuScheduler.cs @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The CPU Scheduler sub-tab (under the CPU tab, alongside CPU Utilization) — the Darling-viewer surface +/// for the cpu_scheduler_stats collector (scheduler / worker-thread / runnable-task / NUMA / OS-memory +/// pressure), mirroring the Dashboard's CPU-pressure reporting. A point-in-time collector, so the trend +/// chart plots the runnable / blocked / queued task counts directly over the settable window and the +/// latest snapshot renders as a metric/value grid whose warning rows highlight (the collector's own +/// CASE-computed pressure flags). Loaded from <c>LoadCpuAsync</c> alongside CPU Utilization (the CPU +/// tab's full-refresh branch), so the CPU sub-TabControl needs no SelectionChanged handler. Chart chrome +/// flows through the shared <see cref="ChartStyle"/> / <see cref="ChartPalette"/> and the ChartHelpers +/// bridge, so the Y-floor-at-0 fix applies. The metric/value projection + pressure classification are the +/// shared <see cref="CpuSchedulerMetrics"/> (Common); this file keeps only the per-app data read and the +/// chart render. +/// </summary> +public partial class ViewerServerTab +{ + private ChartHoverHelper? _cpuSchedulerHover; + + /// <summary>Applies the shared chrome + hover to the scheduler chart up front (constructor).</summary> + private void InitializeCpuSchedulerChart() + { + ApplyTheme(CpuSchedulerChart); + CpuSchedulerChart.Refresh(); + _cpuSchedulerHover = new ChartHoverHelper(CpuSchedulerChart, "tasks"); + } + + /// <summary> + /// Loads the CPU Scheduler sub-tab over the toolbar's settable window: the pressure trend and the + /// latest-snapshot metric read fire concurrently, then the chart and metric grid render. + /// </summary> + private async Task LoadCpuSchedulerAsync() + { + var (startUtc, endUtc) = GetWindowUtc(); + + var trendTask = _dataService.GetCpuSchedulerTrendAsync(_server.ServerId, startUtc, endUtc); + var snapshotTask = _dataService.GetCpuSchedulerSnapshotAsync(_server.ServerId, startUtc, endUtc); + await Task.WhenAll(trendTask, snapshotTask); + + RenderCpuSchedulerChart(trendTask.Result); + CpuSchedulerGrid.ItemsSource = CpuSchedulerMetrics.BuildMetrics(snapshotTask.Result); + } + + private CpuSchedulerChartRenderer? _cpuSchedRendererField; + private CpuSchedulerChartRenderer CpuSchedRenderer => + _cpuSchedRendererField ??= new CpuSchedulerChartRenderer(_chartHelper, ViewerTimeHelper.ForDisplay); + + private void RenderCpuSchedulerChart(List<CpuSchedulerTrendPoint> data) + { + var (startUtc, endUtc) = GetWindowUtc(); + CpuSchedRenderer.Render(CpuSchedulerChart, _cpuSchedulerHover, data, + ViewerTimeHelper.ForDisplay(startUtc).ToOADate(), ViewerTimeHelper.ForDisplay(endUtc).ToOADate()); + } + + /// <summary>Tears down the scheduler hover helper (mirrors the other tabs' dispose).</summary> + public void DisposeCpuSchedulerHelpers() + { + _cpuSchedulerHover?.Dispose(); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CurrentActiveQueries.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CurrentActiveQueries.cs new file mode 100644 index 000000000..903c848fe --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CurrentActiveQueries.cs @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading.Tasks; +using System.Windows; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Queries → Current Active Queries sub-tab (headless-plan live-snapshot wave): a LIVE, on-demand DMV +/// snapshot of what is running on the monitored server RIGHT NOW, distinct from the stored "Active Queries" +/// sub-tab (the collector's hourly snapshots). The store-only viewer cannot hit the server itself, so the +/// Refresh button drives the SERVICE via the Stage-2 <c>fetch_active_queries</c> command +/// (<see cref="ViewerDataService.RequestActiveQueriesLiveAsync"/>); the service runs the shared Active Queries +/// collector's query on the target and returns the rows, which render in a grid mirroring the stored one. +/// +/// <para>The fetch is ON-DEMAND only — it is NOT auto-loaded on tab selection or the toolbar-range refresh (a +/// live server hit should be an explicit operator action), so <c>LoadQueriesAsync</c> leaves this sub-tab to its +/// Refresh button. Enqueuing a command is a config write, so a read-only <c>viewer</c> seat cannot do it: the +/// handler short-circuits with an explanation, and the enqueue's <see cref="ViewerReadOnlyException"/> is the +/// belt-and-braces backstop. A timeout / unreachable-server / service failure each degrade to a friendly status +/// message (mirroring how the live-plan fetch surfaces those). The Estimated / Actual plan buttons reuse the +/// stored grid's handlers (the row is a <see cref="ViewerQuerySnapshotRow"/> either way), opening the plan the +/// live DMV read carried inline.</para> +/// </summary> +public partial class ViewerServerTab +{ + /// <summary>Refresh button — fires the live fetch (never auto-fires; a live server hit is explicit).</summary> + private async void RefreshCurrentActiveQueries_Click(object sender, RoutedEventArgs e) + => await LoadCurrentActiveQueriesAsync(); + + private async Task LoadCurrentActiveQueriesAsync() + { + /* A read-only seat cannot command the service (same rule as Pause / Snapshot / fetch_plan) — surface it + rather than attempting an enqueue that would throw. */ + if (_dataService.IsReadOnly) + { + CurrentActiveQueriesStatus.Text = + "Read-only viewer — reconnect with a read-write profile to fetch live active queries."; + return; + } + + RefreshCurrentActiveQueriesButton.IsEnabled = false; + CurrentActiveQueriesStatus.Text = "Fetching what is running on the server right now..."; + try + { + var result = await _dataService.RequestActiveQueriesLiveAsync(_server.ServerId); + switch (result.Status) + { + case ActiveQueriesLiveStatus.Fetched: + CurrentActiveQueriesGrid.ItemsSource = result.Rows; + var stamp = ViewerTimeHelper.ForDisplay(DateTime.UtcNow).ToString("yyyy-MM-dd HH:mm:ss"); + CurrentActiveQueriesStatus.Text = result.Rows.Count == 0 + ? $"No user requests running as of {stamp}." + : $"{result.Rows.Count} running request(s) as of {stamp}."; + break; + + case ActiveQueriesLiveStatus.TimedOut: + CurrentActiveQueriesStatus.Text = result.Message + ?? "The service did not return the live active queries in time — try again."; + break; + + case ActiveQueriesLiveStatus.Failed: + CurrentActiveQueriesStatus.Text = result.Message + ?? "The service could not read the live active queries."; + break; + } + } + catch (ViewerReadOnlyException) + { + /* Grants changed under us between the IsReadOnly check and the enqueue. */ + CurrentActiveQueriesStatus.Text = + "Read-only viewer — reconnect with a read-write profile to fetch live active queries."; + } + catch (Exception ex) + { + CurrentActiveQueriesStatus.Text = $"Failed to fetch live active queries: {ex.Message}"; + StatusChanged?.Invoke($"live active queries failed: {ex.Message}"); + } + finally + { + RefreshCurrentActiveQueriesButton.IsEnabled = true; + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.DailySummary.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.DailySummary.cs new file mode 100644 index 000000000..5083f12f6 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.DailySummary.cs @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Daily Summary inner tab — a COPY of Lite's Performance Calendar wiring, reads rewired to Postgres. +/// A month heatmap of composite daily health (the shared <see cref="PerformanceCalendar"/>); clicking a day +/// opens the shared day-detail panel (band header, "why this day is <band>" reasons, key metrics, and +/// drill buttons). The tab-refresh loop (auto-refresh timer / tab switch, via <see cref="LoadDailySummaryAsync"/>) +/// reloads the currently displayed month; the panel re-shows itself for the selected day when it stays in view. +/// Clicking a drill button (<see cref="DailyCalendar_DayDrillRequested"/>) scopes the toolbar to that day and +/// jumps to the target grid, reusing the same mechanism as the per-chart "Show Active Queries at This Time" drill. +/// </summary> +public partial class ViewerServerTab +{ + /// <summary> + /// Entry point from LoadInnerTabAsync (index 14) and the auto-refresh loop: (re)load the calendar's + /// currently displayed month. LoadInnerTabAsync owns this path's try/catch. + /// </summary> + private async Task LoadDailySummaryAsync() + { + await LoadCalendarMonthAsync(DailyCalendar.DisplayMonth); + } + + /// <summary> + /// Loads a month's daily summaries into the Performance Calendar. One banded cell per collected day; + /// days with no collection are absent (the calendar renders them No-Data grey). Each day carries its + /// signals + key metrics so the shared day-detail panel can explain the day and offer the right drills. + /// The calendar re-shows the day-detail panel for the selected day itself when it stays in view. + /// </summary> + private async Task LoadCalendarMonthAsync(DateTime month) + { + var start = new DateTime(month.Year, month.Month, 1); + var end = start.AddMonths(1); + + var rows = await _dataService.GetDailySummaryRangeAsync(_server.ServerId, start, end); + + DailyCalendar.Days = rows.Select(r => new PerformanceCalendarDay + { + Date = r.SummaryDate.Date, + Band = r.HealthBand, + Tooltip = $"{r.SummaryDate:MMM d, yyyy} - {r.OverallHealth}{Environment.NewLine}{r.SignalsTooltip}", + Signals = r.ToSignals(), + TopWaitType = r.TopWaitType, + TotalWaitSeconds = r.TotalWaitTimeSec, + UniqueQueries = r.UniqueQueries, + PeakBlockMs = r.MaxBlockDurationMs, + }).ToList(); + } + + private async void DailyCalendar_MonthChanged(object? sender, PerformanceCalendarMonthEventArgs e) + { + try + { + await LoadCalendarMonthAsync(e.Month); + } + catch (Exception ex) + { + StatusChanged?.Invoke($"Daily Summary load failed: {ex.Message}"); + } + } + + private async void DailySummaryRefresh_Click(object sender, RoutedEventArgs e) + { + try + { + await LoadCalendarMonthAsync(DailyCalendar.DisplayMonth); + } + catch (Exception ex) + { + StatusChanged?.Invoke($"Daily Summary refresh failed: {ex.Message}"); + } + } + + /// <summary> + /// A day-detail drill button (View Deadlocks / Blocking / Top Queries): scope the toolbar to the + /// clicked day's [00:00, next-day 00:00) UTC window and jump to the target inner tab, then load that grid + /// over the day. This reuses the exact mechanism the per-chart drills use — set the toolbar's custom + /// window, switch the inner tab under <see cref="_suppressDrillDownAutoRefresh"/> (so the tab-switch + /// auto-refresh doesn't race the targeted read), then call the same loader the tab uses. + /// </summary> + private async void DailyCalendar_DayDrillRequested(object? sender, PerformanceCalendarDrillEventArgs e) + { + try + { + var (startUtc, endUtc) = DailyHealthBandCalculator.DayWindowUtc(e.Date); + + // Server-mode picker conversion needs this server's offset applied (cached; a no-op once loaded). + await EnsureServerOffsetLoadedAsync(); + ApplyServerOffsetToHelper(); + + // Scope the toolbar to the whole day so the grid the user lands on — and anywhere they navigate + // next — stays on that day. Suppressed so it drives no reload of its own; we load the target below. + SetToolbarWindowUtc(startUtc, endUtc); + + _suppressDrillDownAutoRefresh = true; + try + { + switch (e.Target) + { + case DayDrillTarget.Deadlocks: + InnerTabs.SelectedIndex = BlockingInnerTabIndex; + BlockingSubTabs.SelectedIndex = DeadlocksSubTabIndex; + break; + case DayDrillTarget.Blocking: + InnerTabs.SelectedIndex = BlockingInnerTabIndex; + BlockingSubTabs.SelectedIndex = BlockedProcessReportsSubTabIndex; + break; + case DayDrillTarget.TopQueries: + InnerTabs.SelectedIndex = QueriesInnerTabIndex; + QueriesSubTabControl.SelectedIndex = TopQueriesSubTabIndex; + break; + } + } + finally + { + _suppressDrillDownAutoRefresh = false; + } + + switch (e.Target) + { + case DayDrillTarget.Deadlocks: + await LoadDeadlocksAsync(startUtc, endUtc); + break; + case DayDrillTarget.Blocking: + await LoadBlockedProcessReportsAsync(startUtc, endUtc); + break; + case DayDrillTarget.TopQueries: + await LoadTopQueriesAsync(startUtc, endUtc); + break; + } + } + catch (Exception ex) + { + StatusChanged?.Invoke($"day drill failed: {ex.Message}"); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.DatabaseFilter.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.DatabaseFilter.cs new file mode 100644 index 000000000..f3d6d3731 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.DatabaseFilter.cs @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; + +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// #1319: the per-server global database filter (display-only), the Darling analog of Lite's +/// <c>ServerTab.DatabaseFilter</c>. A toolbar button opens a popup of collected-database checkboxes +/// (search + Select/Clear All + count). Selection is applied when the popup closes — persisted per-server +/// (viewer-servers.json via <see cref="ViewerServerStore"/>) and threaded into every database-scoped +/// reader. Empty selection = "All" (today's unfiltered behavior). +/// </summary> +public partial class ViewerServerTab : UserControl +{ + private async void DatabaseFilterButton_Click(object sender, RoutedEventArgs e) + { + if (DatabaseFilterPopup.IsOpen) + { + DatabaseFilterPopup.IsOpen = false; + return; + } + + await PopulateDatabaseFilterPickerAsync(); + DatabaseFilterPopup.IsOpen = true; + } + + /// <summary> + /// (Re)loads the collected user-database list on each open so newly-collected databases appear. Any + /// currently-selected database missing from the collected list is kept (still checked) so a sticky + /// filter for a not-yet-collected database survives. + /// </summary> + private async Task PopulateDatabaseFilterPickerAsync() + { + List<string> names; + try + { + names = await _dataService.GetCollectedDatabaseNamesAsync(_server.ServerId); + } + catch (Exception ex) + { + Debug.WriteLine($"ViewerServerTab: GetCollectedDatabaseNamesAsync failed for '{_server.DisplayName}': {ex.Message}"); + names = new List<string>(); + } + + _databaseFilterTotalCount = names.Count; + + var union = new List<string>(names); + foreach (var sel in _selectedDatabases) + { + if (!union.Contains(sel, StringComparer.OrdinalIgnoreCase)) + { + union.Add(sel); + } + } + union.Sort(StringComparer.OrdinalIgnoreCase); + + _databaseFilterItems = union + .Select(n => new SelectableItem { DisplayName = n, IsSelected = _selectedDatabases.Contains(n) }) + .ToList(); + + if (DatabaseFilterSearchBox != null) + { + DatabaseFilterSearchBox.Text = ""; + } + ApplyDatabaseFilterSearch(); + UpdateDatabaseFilterLabel(); + } + + private void ApplyDatabaseFilterSearch() + { + var search = DatabaseFilterSearchBox?.Text?.Trim() ?? ""; + DatabaseFilterList.ItemsSource = null; + DatabaseFilterList.ItemsSource = string.IsNullOrEmpty(search) + ? _databaseFilterItems + : _databaseFilterItems.Where(i => i.DisplayName.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + private void DatabaseFilterSearch_TextChanged(object sender, TextChangedEventArgs e) => ApplyDatabaseFilterSearch(); + + private void DatabaseFilter_CheckChanged(object sender, RoutedEventArgs e) + { + if (_isUpdatingDatabaseFilterSelection) + { + return; + } + SyncSelectedDatabasesFromItems(); + } + + private void DatabaseFilterSelectAll_Click(object sender, RoutedEventArgs e) + { + _isUpdatingDatabaseFilterSelection = true; + foreach (var item in _databaseFilterItems) + { + item.IsSelected = true; + } + _isUpdatingDatabaseFilterSelection = false; + ApplyDatabaseFilterSearch(); + SyncSelectedDatabasesFromItems(); + } + + private void DatabaseFilterClearAll_Click(object sender, RoutedEventArgs e) + { + _isUpdatingDatabaseFilterSelection = true; + foreach (var item in _databaseFilterItems) + { + item.IsSelected = false; + } + _isUpdatingDatabaseFilterSelection = false; + ApplyDatabaseFilterSearch(); + SyncSelectedDatabasesFromItems(); + } + + private void SyncSelectedDatabasesFromItems() + { + _selectedDatabases.Clear(); + foreach (var item in _databaseFilterItems) + { + if (item.IsSelected) + { + _selectedDatabases.Add(item.DisplayName); + } + } + _databaseFilterDirty = true; + UpdateDatabaseFilterLabel(); + } + + private void UpdateDatabaseFilterLabel() + { + if (DatabaseFilterButton == null) + { + return; + } + + int selected = _selectedDatabases.Count; + DatabaseFilterButton.Content = selected == 0 + ? "Databases: All" + : $"Databases: {selected} of {Math.Max(selected, _databaseFilterTotalCount)}"; + + if (DatabaseFilterCountText != null) + { + DatabaseFilterCountText.Text = selected == 0 ? "All databases" : $"{selected} selected"; + } + } + + /// <summary> + /// Applies the selection when the popup closes (rather than on every checkbox toggle) so a + /// multi-database change triggers a single reload. Persists per-server, then reloads the active inner tab. + /// </summary> + private async void DatabaseFilterPopup_Closed(object sender, EventArgs e) + { + if (!_databaseFilterDirty) + { + return; + } + _databaseFilterDirty = false; + + _serverStore?.SetViewFilterDatabases(_server.ServerName, _selectedDatabases.ToList()); + + await RefreshActiveInnerTabAsync(); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Deadlock.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Deadlock.cs new file mode 100644 index 000000000..f0ffaaf2d --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Deadlock.cs @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; +using static PerformanceMonitor.Ui.DataGridHelpers; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Deadlocks deadlock-graph viewer (W1e) — copied from Lite's <c>ServerTab.Deadlock.cs</c>. The row +/// already carries the graph XML (fetched with the grid), so there is no second fetch; the shared +/// <see cref="DeadlockGraphParser"/> parse runs off the UI thread (CPU-bound XML walk) and only the render +/// touches the UI. Reuses the shared .Ui <see cref="DeadlockGraphControl"/> + <see cref="GraphViewerWindow"/>. +/// </summary> +public partial class ViewerServerTab +{ + /// <summary>Right-click "View Deadlock Graph" on a deadlock row.</summary> + private async void ViewDeadlockGraph_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem) return; + var grid = FindParentDataGrid(menuItem); + if (grid?.CurrentItem is not DeadlockProcessDetail row) return; + await OpenDeadlockGraphAsync(row); + } + + /// <summary> + /// "View Victim Plan" on a deadlock row: opens the BEST-EFFORT victim plan the deadlock carries + /// (deadlocks.victim_query_plan_xml, #1368 / V7 — one plan per deadlock, threaded onto every process + /// row) in the shared Plan Viewer host — NO live SQL, the same stored-plan surface Top Queries uses. + /// The context item is gated per row on <see cref="DeadlockProcessDetail.HasVictimQueryPlan"/> (a NULL + /// plan — the common case, and always so under Lite — shows it disabled), so this only fires with a + /// captured plan; the guard here is belt-and-braces. Lite's "Get Actual Plan" (live) has no viewer + /// equivalent and is omitted everywhere. + /// </summary> + private void ViewVictimQueryPlan_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem) return; + if (FindParentDataGrid(menuItem)?.CurrentItem is not DeadlockProcessDetail row || !row.HasVictimQueryPlan) return; + /* queryText is null, not row.SqlText: the victim plan rides on every process row of the deadlock, so + the right-clicked row's own statement text would mis-pair on a non-victim row. The plan XML carries + its own statement text; the side panel simply stays empty (unlike the blocking rows, which each own + their blocked/blocking SQL+plan pair and so pass it). */ + OpenPlanTab(row.VictimQueryPlanXml!, "Victim Plan", null); + } + + /// <summary>Double-clicking a deadlock row opens the same graph viewer.</summary> + private async void DeadlockGrid_MouseDoubleClick(object sender, MouseButtonEventArgs e) + { + if (DeadlockGrid.SelectedItem is not DeadlockProcessDetail row) return; + await OpenDeadlockGraphAsync(row); + } + + private async Task OpenDeadlockGraphAsync(DeadlockProcessDetail row) + { + try + { + // Parse off the UI thread (CPU-bound XML walk); the control renders the finished model. An + // empty/unparseable graph yields an empty model and the control shows its empty state — same as + // the Lite/Dashboard path, so all three hosts behave identically. + var model = await Task.Run(() => DeadlockGraphParser.Parse(row.DeadlockGraphXml)); + + var control = new DeadlockGraphControl(); + control.LoadModel(model); + GraphViewerWindow.ShowGraph( + Window.GetWindow(this), + control, + $"Deadlock Graph — {_server.DisplayName}"); + } + catch (Exception ex) + { + MessageBox.Show( + Window.GetWindow(this)!, + $"Failed to build the deadlock graph view:\n\n{ex.Message}", + "Deadlock Graph Error", MessageBoxButton.OK, MessageBoxImage.Warning); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.DrillDown.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.DrillDown.cs new file mode 100644 index 000000000..724257d37 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.DrillDown.cs @@ -0,0 +1,334 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The per-chart right-click drill-downs — a faithful port of Lite's <c>ServerTab.DrillDown.cs</c> + +/// <c>AddChartDrillDownMenuItem</c> / <c>AddWaitDrillDownMenuItem</c> (ServerTab.xaml.cs:319-365), adapted to +/// the viewer's already-shipped drill-down surfaces (the query heatmap already navigates through +/// <see cref="NavigateToActiveQueriesForWindowAsync"/>). Every resource / trend chart gets a +/// "Show Active Queries at This Time"; the Blocking / Deadlocks trend charts get +/// "Show Blocking / Deadlocks at This Time"; the Wait Stats chart gets a wait-specific +/// "Show Queries With <wait>" that opens the <see cref="WaitDrillDownWindow"/>. +/// +/// <para>Two notes, both inherent to the viewer: (1) Like Lite, each drill-down item is Inserted at the top of +/// the chart's copy/save/export <see cref="ContextMenu"/> (now a Darling-local port of Lite's +/// <c>ContextMenuHelper</c>, in ViewerServerTab.ChartContextMenu.cs) — <see cref="BuildChartContextMenu"/> +/// builds that menu and takes right-click over from ScottPlot (<see cref="RemoveScottPlotContextMenuResponses"/>), +/// then <see cref="AddChartDrillDownMenuItem"/> adds the drill-down item to the SAME menu, so both coexist. +/// (2) The chart X axis is display-time (every viewer chart plots through +/// <see cref="ViewerTimeHelper.ForDisplay"/>), so the nearest-series time the <see cref="ChartHoverHelper"/> +/// returns is converted back to the store's naive UTC via <see cref="ViewerTimeHelper.DisplayToNaiveUtc"/> +/// before the +/-30-minute window is read.</para> +/// </summary> +public partial class ViewerServerTab +{ + /// <summary>The drill-down half-window: a click resolves to a +/-30-minute read around the point + /// (Lite's OnActiveQueriesDrillDown / OnBlockingDrillDown / OnDeadlockDrillDown all use 30).</summary> + internal const int DrillDownHalfWindowMinutes = 30; + + /// <summary> + /// Wires the right-click drill-down menus onto every chart that has one (Items 2-4). Called from the + /// constructor after the Initialize*Charts methods, so each chart's <see cref="ChartHoverHelper"/> exists. + /// </summary> + private void WireChartDrillDowns() + { + /* Each drill-down chart's menu is ONE ContextMenu = [drill-down item] + [separator] + [copy/save/ + export items], exactly like Lite (ServerTab.xaml.cs:318-365 pairs SetupChartContextMenu with + AddChartDrillDownMenuItem on the SAME menu). BuildChartContextMenu builds the copy/export items and + takes right-click over from ScottPlot; AddChart/WaitDrillDownMenuItem then Insert their item at the + top of that returned menu — one right-click owner, no competing handler. + + The hover accessors are lambdas, not captured values: WaitStats + Perfmon create their + ChartHoverHelper LAZILY on the tab's first load (not in an Initialize*Charts call), so their fields + are still null here in the constructor. Reading the field at menu-Opened time (by which point the + tab has loaded and the hover exists) is correct for both the eager and the lazy charts. */ + + /* Wait Stats — the specialized "Show Queries With <wait>" drill-down (opens WaitDrillDownWindow). */ + AddWaitDrillDownMenuItem(WaitStatsChart, BuildChartContextMenu(WaitStatsChart, "Wait_Stats"), () => _waitStatsHover); + + /* CPU / Memory / tempdb / Perfmon — "Show Active Queries at This Time" (Item 2, 10 charts). */ + AddChartDrillDownMenuItem(CpuChart, BuildChartContextMenu(CpuChart, "CPU_Usage"), () => _cpuHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + AddChartDrillDownMenuItem(MemoryChart, BuildChartContextMenu(MemoryChart, "Memory_Usage"), () => _memoryHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + AddChartDrillDownMenuItem(MemoryClerksChart, BuildChartContextMenu(MemoryClerksChart, "Memory_Clerks"), () => _memoryClerksHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + AddChartDrillDownMenuItem(MemoryGrantSizingChart, BuildChartContextMenu(MemoryGrantSizingChart, "Memory_Grant_Sizing"), () => _memoryGrantSizingHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + AddChartDrillDownMenuItem(MemoryGrantActivityChart, BuildChartContextMenu(MemoryGrantActivityChart, "Memory_Grant_Activity"), () => _memoryGrantActivityHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + AddChartDrillDownMenuItem(MemoryPressureEventsChart, BuildChartContextMenu(MemoryPressureEventsChart, "Memory_Pressure_Events"), () => _memoryPressureEventsHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + AddChartDrillDownMenuItem(TempDbChart, BuildChartContextMenu(TempDbChart, "TempDB_Stats"), () => _tempDbHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + AddChartDrillDownMenuItem(TempDbSizeChart, BuildChartContextMenu(TempDbSizeChart, "TempDB_Allocated_Size"), () => _tempDbSizeHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + AddChartDrillDownMenuItem(TempDbFileIoChart, BuildChartContextMenu(TempDbFileIoChart, "TempDB_File_IO"), () => _tempDbFileIoHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + AddChartDrillDownMenuItem(PerfmonChart, BuildChartContextMenu(PerfmonChart, "Perfmon_Counters"), () => _perfmonHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + + /* Performance Trends — "Show Active Queries at This Time" (Item 3, 4 charts). */ + AddChartDrillDownMenuItem(QueryDurationTrendChart, BuildChartContextMenu(QueryDurationTrendChart, "Query_Duration_Trends"), () => _queryDurationTrendHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + AddChartDrillDownMenuItem(ProcDurationTrendChart, BuildChartContextMenu(ProcDurationTrendChart, "Procedure_Duration_Trends"), () => _procDurationTrendHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + AddChartDrillDownMenuItem(QueryStoreDurationTrendChart, BuildChartContextMenu(QueryStoreDurationTrendChart, "QueryStore_Duration_Trends"), () => _queryStoreDurationTrendHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + AddChartDrillDownMenuItem(ExecutionCountTrendChart, BuildChartContextMenu(ExecutionCountTrendChart, "Execution_Count_Trends"), () => _executionCountTrendHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + + /* Blocking Trends — Lock Wait + Blocking -> "Show Blocking at This Time"; Deadlock -> + "Show Deadlocks at This Time" (Item 3, 3 charts). */ + AddChartDrillDownMenuItem(LockWaitTrendChart, BuildChartContextMenu(LockWaitTrendChart, "Lock_Wait_Trends"), () => _lockWaitTrendHover, "Show Blocking at This Time", OnBlockingDrillDown); + AddChartDrillDownMenuItem(BlockingTrendChart, BuildChartContextMenu(BlockingTrendChart, "Blocking_Trends"), () => _blockingTrendHover, "Show Blocking at This Time", OnBlockingDrillDown); + AddChartDrillDownMenuItem(DeadlockTrendChart, BuildChartContextMenu(DeadlockTrendChart, "Deadlock_Trends"), () => _deadlockTrendHover, "Show Deadlocks at This Time", OnDeadlockDrillDown); + + /* Blocking Stats (this feature) — the severity duration charts drill to the same blocked-process + reports as their count-trend siblings, so right-clicking a duration spike opens the blocks at that + time ("Show Blocking at This Time" -> OnBlockingDrillDown). The deadlock-severity charts drill to + the Deadlocks grid instead ("Show Deadlocks at This Time" -> OnDeadlockDrillDown), matching the + Trends tab's deadlock-count chart. */ + AddChartDrillDownMenuItem(BlockingDurationChart, BuildChartContextMenu(BlockingDurationChart, "Blocking_Duration"), () => _blockingDurationHover, "Show Blocking at This Time", OnBlockingDrillDown); + AddChartDrillDownMenuItem(BlockingTotalDurationChart, BuildChartContextMenu(BlockingTotalDurationChart, "Blocking_Total_Duration"), () => _blockingTotalDurationHover, "Show Blocking at This Time", OnBlockingDrillDown); + AddChartDrillDownMenuItem(DeadlockWaitChart, BuildChartContextMenu(DeadlockWaitChart, "Deadlock_Wait"), () => _deadlockWaitHover, "Show Deadlocks at This Time", OnDeadlockDrillDown); + AddChartDrillDownMenuItem(DeadlockTotalWaitChart, BuildChartContextMenu(DeadlockTotalWaitChart, "Deadlock_Total_Wait"), () => _deadlockTotalWaitHover, "Show Deadlocks at This Time", OnDeadlockDrillDown); + + /* File I/O (4) + Blocking > Current Waits (2) — the rest of Lite's "Show Active Queries at This Time" + set (ServerTab.xaml.cs:340-363, in the same referenced block). Charts in this same viewer surface, + same drill target; wired for faithful parity so no sibling chart is left without the drill-down. */ + AddChartDrillDownMenuItem(FileIoReadChart, BuildChartContextMenu(FileIoReadChart, "File_IO_Read_Latency"), () => _fileIoReadHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + AddChartDrillDownMenuItem(FileIoWriteChart, BuildChartContextMenu(FileIoWriteChart, "File_IO_Write_Latency"), () => _fileIoWriteHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + AddChartDrillDownMenuItem(FileIoReadThroughputChart, BuildChartContextMenu(FileIoReadThroughputChart, "File_IO_Read_Throughput"), () => _fileIoReadThroughputHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + AddChartDrillDownMenuItem(FileIoWriteThroughputChart, BuildChartContextMenu(FileIoWriteThroughputChart, "File_IO_Write_Throughput"), () => _fileIoWriteThroughputHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + AddChartDrillDownMenuItem(CurrentWaitsDurationChart, BuildChartContextMenu(CurrentWaitsDurationChart, "Current_Waits_Duration"), () => _currentWaitsDurationHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + AddChartDrillDownMenuItem(CurrentWaitsBlockedChart, BuildChartContextMenu(CurrentWaitsBlockedChart, "Current_Waits_Blocked"), () => _currentWaitsBlockedHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + + /* Darling-only time-series charts Lite lacks — CPU Scheduler, Latch/Spinlock contention, Plan Cache, + Session Counts, and the eight System Events (system_health XE) counter charts. All plot display-time + on X, so "Show Active Queries at This Time" navigates to the correlated +/-30-minute active-queries + window exactly like their sibling resource/trend charts. Peer-consistency (these previously had only + the copy/export menu); no per-latch/per-spinlock-type drill (no correlation data captured). Only + CollectorDuration stays menu-only, matching Lite's drill-less collector-duration chart. */ + AddChartDrillDownMenuItem(CpuSchedulerChart, BuildChartContextMenu(CpuSchedulerChart, "CPU_Scheduler"), () => _cpuSchedulerHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + AddChartDrillDownMenuItem(LatchStatsChart, BuildChartContextMenu(LatchStatsChart, "Latch_Stats"), () => _latchStatsHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + AddChartDrillDownMenuItem(SpinlockStatsChart, BuildChartContextMenu(SpinlockStatsChart, "Spinlock_Stats"), () => _spinlockStatsHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + AddChartDrillDownMenuItem(SessionStatsChart, BuildChartContextMenu(SessionStatsChart, "Session_Stats"), () => _sessionStatsHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + AddChartDrillDownMenuItem(PlanCacheChart, BuildChartContextMenu(PlanCacheChart, "Plan_Cache"), () => _planCacheHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + + AddChartDrillDownMenuItem(BadPagesChart, BuildChartContextMenu(BadPagesChart, "Bad_Pages"), () => _badPagesHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + AddChartDrillDownMenuItem(DumpRequestsChart, BuildChartContextMenu(DumpRequestsChart, "Dump_Requests"), () => _dumpRequestsHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + AddChartDrillDownMenuItem(AccessViolationsChart, BuildChartContextMenu(AccessViolationsChart, "Access_Violations"), () => _accessViolationsHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + AddChartDrillDownMenuItem(WriteAccessViolationsChart, BuildChartContextMenu(WriteAccessViolationsChart, "Write_Access_Violations"), () => _writeAccessViolationsHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + AddChartDrillDownMenuItem(NonYieldingTasksChart, BuildChartContextMenu(NonYieldingTasksChart, "Non_Yielding_Tasks"), () => _nonYieldingTasksHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + AddChartDrillDownMenuItem(LatchWarningsChart, BuildChartContextMenu(LatchWarningsChart, "Latch_Warnings"), () => _latchWarningsHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + AddChartDrillDownMenuItem(SickSpinlocksChart, BuildChartContextMenu(SickSpinlocksChart, "Sick_Spinlocks"), () => _sickSpinlocksHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + AddChartDrillDownMenuItem(CpuComparisonChart, BuildChartContextMenu(CpuComparisonChart, "CPU_Comparison"), () => _cpuComparisonHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + } + + /// <summary> + /// Inserts a right-click drill-down item at the TOP of a chart's existing copy/export + /// <paramref name="menu"/> (Lite's <c>AddChartDrillDownMenuItem</c>: <c>Insert(0, item)</c> + + /// <c>Insert(0, separator)</c>, so the menu reads [drill-down] [separator] [copy/save/export]). The menu's + /// right-click was already taken over by <see cref="BuildChartContextMenu"/>; this only adds the item + + /// its Opened/Click behavior, so the two coexist in one menu. The Opened handler resolves the + /// nearest-series display-time under the cursor via the chart's hover helper (disabling the item off any + /// series); the Click routes that time to <paramref name="handler"/>, which converts it back to UTC and + /// reads the window. <paramref name="hoverAccessor"/> is read at Opened time (not captured) so + /// lazily-created hovers resolve. + /// </summary> + private void AddChartDrillDownMenuItem( + ScottPlot.WPF.WpfPlot chart, ContextMenu menu, Func<ChartHoverHelper?> hoverAccessor, string label, Action<DateTime> handler) + { + menu.Items.Insert(0, new Separator()); + var item = new MenuItem { Header = label }; + menu.Items.Insert(0, item); + + menu.Opened += (_, _) => + { + var nearest = hoverAccessor()?.GetNearestSeries(Mouse.GetPosition(chart)); + if (nearest.HasValue) + { + item.Tag = nearest.Value.Time; /* display-time (the chart X runs through ForDisplay) */ + item.IsEnabled = true; + } + else + { + item.Tag = null; + item.IsEnabled = false; + } + }; + + item.Click += (_, _) => + { + if (item.Tag is DateTime displayTime) + handler(displayTime); + }; + } + + /// <summary> + /// The Wait Stats chart's specialized right-click "Show Queries With <wait>" (Lite's + /// <c>AddWaitDrillDownMenuItem</c>), inserted at the top of the chart's copy/export <paramref name="menu"/> + /// (same coexistence as <see cref="AddChartDrillDownMenuItem"/>). The hover's nearest series is a wait TYPE + /// (the series label), so the item header names it and the Click opens the <see cref="WaitDrillDownWindow"/> + /// for that wait over the drill window. Underscores in the wait type are doubled so WPF doesn't treat them + /// as an access key. + /// </summary> + private void AddWaitDrillDownMenuItem(ScottPlot.WPF.WpfPlot chart, ContextMenu menu, Func<ChartHoverHelper?> hoverAccessor) + { + menu.Items.Insert(0, new Separator()); + var item = new MenuItem { Header = "Show Queries With This Wait" }; + menu.Items.Insert(0, item); + + menu.Opened += (_, _) => + { + var nearest = hoverAccessor()?.GetNearestSeries(Mouse.GetPosition(chart)); + if (nearest.HasValue) + { + item.Tag = (nearest.Value.Label, nearest.Value.Time); + item.Header = $"Show Queries With {nearest.Value.Label.Replace("_", "__")}"; + item.IsEnabled = true; + } + else + { + item.Tag = null; + item.Header = "Show Queries With This Wait"; + item.IsEnabled = false; + } + }; + + item.Click += (_, _) => + { + if (item.Tag is ValueTuple<string, DateTime> tag) + ShowQueriesForWaitType(tag.Item1, tag.Item2); + }; + } + + /// <summary> + /// Takes right-click over from ScottPlot and shows <paramref name="menu"/> at the cursor — the ported + /// heatmap drill-down's exact approach (<see cref="RemoveScottPlotContextMenuResponses"/> + + /// PreviewMouseRightButtonDown), factored so every drill-down chart and the heatmap share one path. + /// </summary> + private static void AttachChartContextMenu(ScottPlot.WPF.WpfPlot chart, ContextMenu menu) + { + RemoveScottPlotContextMenuResponses(chart); + chart.PreviewMouseRightButtonDown += (_, e) => + { + e.Handled = true; + menu.PlacementTarget = chart; + menu.Placement = PlacementMode.MousePoint; + menu.IsOpen = true; + }; + } + + /// <summary> + /// Removes ScottPlot's built-in right-click responses (its default context menu / pan) so a WPF + /// <see cref="ContextMenu"/> can own right-click. Shared by every drill-down chart and the query heatmap. + /// </summary> + internal static void RemoveScottPlotContextMenuResponses(ScottPlot.WPF.WpfPlot chart) + { + chart.UserInputProcessor.UserActionResponses.RemoveAll(r => + r.GetType().Name.Contains("Context", StringComparison.Ordinal) || + r.GetType().Name.Contains("RightClick", StringComparison.Ordinal) || + r.GetType().Name.Contains("Menu", StringComparison.Ordinal)); + } + + /// <summary>The store's naive-UTC +/-30-minute window around a chart display-time (pure, unit-tested).</summary> + internal static (DateTime FromUtc, DateTime ToUtc) DrillWindowUtc(DateTime displayTime) + { + var utc = ViewerTimeHelper.DisplayToNaiveUtc(displayTime); + return (utc.AddMinutes(-DrillDownHalfWindowMinutes), utc.AddMinutes(DrillDownHalfWindowMinutes)); + } + + /// <summary> + /// "Show Active Queries at This Time" — navigates to Queries -> Active Queries filtered to the +/-30-minute + /// window around the clicked point (Lite's OnActiveQueriesDrillDown). Also the target of the Overview + /// lanes' <c>ShowActiveQueriesRequested</c> event. + /// </summary> + private async void OnActiveQueriesDrillDown(DateTime displayTime) + { + try + { + var (fromUtc, toUtc) = DrillWindowUtc(displayTime); + var indicator = $"Drill-down: {ViewerTimeHelper.ForDisplay(fromUtc):HH:mm} → {ViewerTimeHelper.ForDisplay(toUtc):HH:mm}"; + await NavigateToActiveQueriesForWindowAsync(fromUtc, toUtc, indicator); + } + catch (Exception ex) + { + StatusChanged?.Invoke($"active-queries drill-down failed: {ex.Message}"); + } + } + + /// <summary> + /// "Show Blocking at This Time" — navigates to Blocking -> Blocked Process Reports filtered to the + /// +/-30-minute window (Lite's OnBlockingDrillDown, targeting Darling's existing + /// <see cref="LoadBlockedProcessReportsAsync"/> loader). + /// </summary> + private async void OnBlockingDrillDown(DateTime displayTime) + { + var (fromUtc, toUtc) = DrillWindowUtc(displayTime); + _suppressDrillDownAutoRefresh = true; + try + { + InnerTabs.SelectedIndex = BlockingInnerTabIndex; + BlockingSubTabs.SelectedIndex = BlockedProcessReportsSubTabIndex; + } + finally + { + _suppressDrillDownAutoRefresh = false; + } + + try + { + await LoadBlockedProcessReportsAsync(fromUtc, toUtc); + } + catch (Exception ex) + { + StatusChanged?.Invoke($"blocking drill-down failed: {ex.Message}"); + } + } + + /// <summary> + /// "Show Deadlocks at This Time" — navigates to Blocking -> Deadlocks filtered to the +/-30-minute window + /// (Lite's OnDeadlockDrillDown, targeting Darling's existing <see cref="LoadDeadlocksAsync"/> loader). + /// </summary> + private async void OnDeadlockDrillDown(DateTime displayTime) + { + var (fromUtc, toUtc) = DrillWindowUtc(displayTime); + _suppressDrillDownAutoRefresh = true; + try + { + InnerTabs.SelectedIndex = BlockingInnerTabIndex; + BlockingSubTabs.SelectedIndex = DeadlocksSubTabIndex; + } + finally + { + _suppressDrillDownAutoRefresh = false; + } + + try + { + await LoadDeadlocksAsync(fromUtc, toUtc); + } + catch (Exception ex) + { + StatusChanged?.Invoke($"deadlock drill-down failed: {ex.Message}"); + } + } + + /// <summary> + /// Opens the <see cref="WaitDrillDownWindow"/> for a wait type over the +/-30-minute drill window (Lite's + /// ShowQueriesForWaitType_Click). Owned by this tab's window so the shared plan window it spawns floats + /// above it; the drill window reads correlated snapshots from Postgres (no live SQL). + /// </summary> + private void ShowQueriesForWaitType(string waitType, DateTime displayTime) + { + var (fromUtc, toUtc) = DrillWindowUtc(displayTime); + var window = new WaitDrillDownWindow(_dataService, _server.ServerId, waitType, fromUtc, toUtc) + { + Owner = Window.GetWindow(this), + }; + window.ShowDialog(); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.FileIo.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.FileIo.cs new file mode 100644 index 000000000..35af99b6d --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.FileIo.cs @@ -0,0 +1,298 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Controls; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The File I/O inner tab — a COPY of Lite's File I/O tab (ServerTab.xaml:1373-1409 + the +/// <c>UpdateFileIoCharts</c> / <c>UpdateFileIoThroughputCharts</c> bodies in ServerTab.Charts.cs), +/// reads rewired to <see cref="ViewerDataService"/> Postgres. Two nested sub-tabs — "File I/O Latency" +/// (stacked read + write latency charts, with the queued-I/O dashed overlay) and "File I/O Throughput" +/// (stacked read + write MB/s charts) — each plotting the top 10 files as its own cycling-colored +/// series. The only render-body change is the time axis: Lite shifts by its per-server +/// <c>UtcOffsetMinutes</c>, the viewer runs every point through <see cref="ViewerTimeHelper.ForDisplay"/> +/// (the naive-UTC-to-viewer-local convention every Darling chart uses). Series colors ride the shared +/// cycling <see cref="ChartPalette"/> and <see cref="ChartStyle.StyleScatter"/> line polish; hover +/// tooltips carry the per-chart unit. Lite's per-chart context menu / save-image are NOT ported. +/// </summary> +public partial class ViewerServerTab +{ + private ChartHoverHelper? _fileIoReadHover; + private ChartHoverHelper? _fileIoWriteHover; + private ChartHoverHelper? _fileIoReadThroughputHover; + private ChartHoverHelper? _fileIoWriteThroughputHover; + + /// <summary> + /// Applies the shared chrome to the four File I/O charts and wires their hover tooltips (Lite's + /// per-chart units: "ms" for latency, "MB/s" for throughput). Called from the constructor after + /// <c>InitializeComponent</c> so the charts don't flash white before the tab's first load. + /// </summary> + private void InitializeFileIoCharts() + { + ApplyTheme(FileIoReadChart); + FileIoReadChart.Refresh(); + ApplyTheme(FileIoWriteChart); + FileIoWriteChart.Refresh(); + ApplyTheme(FileIoReadThroughputChart); + FileIoReadThroughputChart.Refresh(); + ApplyTheme(FileIoWriteThroughputChart); + FileIoWriteThroughputChart.Refresh(); + + _fileIoReadHover = new ChartHoverHelper(FileIoReadChart, "ms"); + _fileIoWriteHover = new ChartHoverHelper(FileIoWriteChart, "ms"); + _fileIoReadThroughputHover = new ChartHoverHelper(FileIoReadThroughputChart, "MB/s"); + _fileIoWriteThroughputHover = new ChartHoverHelper(FileIoWriteThroughputChart, "MB/s"); + } + + /// <summary> + /// A File I/O sub-tab switch reloads through the shell's overlap-guarded + /// <see cref="RefreshActiveInnerTabAsync"/> (the File I/O tab is the active inner tab whenever its + /// sub-tabs are visible, so the loader dispatches to <see cref="LoadFileIoAsync"/> and loads only the + /// newly-visible sub-tab). Gated on <see cref="System.Windows.FrameworkElement.IsLoaded"/> and the + /// sub-TabControl's own selection so the build-time selection and bubbled inner selections are ignored. + /// </summary> + private async void FileIoSubTabs_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (!ReferenceEquals(e.OriginalSource, FileIoSubTabs) || !IsLoaded) + { + return; + } + + await RefreshActiveInnerTabAsync(); + } + + /// <summary> + /// Loads the File I/O tab's ACTIVE sub-tab only (mirrors Lite's subTabOnly gating): the Latency + /// sub-tab reads the per-file latency trend, the Throughput sub-tab reads the per-file MB/s trend. + /// Both window on the toolbar's settable range like every other viewer surface. + /// </summary> + private async Task LoadFileIoAsync() + { + var (startUtc, endUtc) = GetWindowUtc(); + + switch (FileIoSubTabs.SelectedIndex) + { + case 1: // File I/O Throughput + var throughput = await _dataService.GetFileIoThroughputTrendAsync(_server.ServerId, startUtc, endUtc); + RenderFileIoThroughputCharts(throughput, startUtc, endUtc); + break; + case 0: // File I/O Latency + default: + var latency = await _dataService.GetFileIoLatencyTrendAsync(_server.ServerId, startUtc, endUtc); + RenderFileIoLatencyCharts(latency, startUtc, endUtc); + break; + } + } + + private void RenderFileIoLatencyCharts(List<FileIoLatencyPoint> data, DateTime startUtc, DateTime endUtc) + { + ClearChart(FileIoReadChart); + ClearChart(FileIoWriteChart); + _fileIoReadHover?.Clear(); + _fileIoWriteHover?.Clear(); + ApplyTheme(FileIoReadChart); + ApplyTheme(FileIoWriteChart); + + var rangeStart = ViewerTimeHelper.ForDisplay(startUtc).ToOADate(); + var rangeEnd = ViewerTimeHelper.ForDisplay(endUtc).ToOADate(); + + if (data.Count == 0) + { + foreach (var c in new[] { FileIoReadChart, FileIoWriteChart }) + { + c.Plot.Axes.DateTimeTicksBottomDateChange(); + c.Plot.Axes.SetLimitsX(rangeStart, rangeEnd); + ReapplyAxisColors(c); + c.Refresh(); + } + return; + } + + /* Group by file, limit to top 10 by total stall */ + var databases = data + .GroupBy(d => $"{d.DatabaseName}.{d.FileName}") + .OrderByDescending(g => g.Sum(d => d.AvgReadLatencyMs + d.AvgWriteLatencyMs)) + .Take(10) + .ToList(); + + double readMax = 0, writeMax = 0; + int colorIdx = 0; + + bool hasQueuedData = data.Any(d => d.AvgQueuedReadLatencyMs > 0 || d.AvgQueuedWriteLatencyMs > 0); + + foreach (var dbGroup in databases) + { + var points = dbGroup.OrderBy(d => d.CollectionTime).ToList(); + var times = points.Select(d => ViewerTimeHelper.ForDisplay(d.CollectionTime).ToOADate()).ToArray(); + var readLatency = points.Select(d => d.AvgReadLatencyMs).ToArray(); + var writeLatency = points.Select(d => d.AvgWriteLatencyMs).ToArray(); + var color = ScottPlot.Color.FromHex(SeriesColors[colorIdx % SeriesColors.Length]); + colorIdx++; + + if (readLatency.Length > 0) + { + var readPlot = FileIoReadChart.Plot.Add.Scatter(times, readLatency); + readPlot.LegendText = dbGroup.Key; + readPlot.Color = color; + ChartStyle.StyleScatter(readPlot); + _fileIoReadHover?.Add(readPlot, dbGroup.Key); + readMax = Math.Max(readMax, readLatency.Max()); + } + + if (writeLatency.Length > 0) + { + var writePlot = FileIoWriteChart.Plot.Add.Scatter(times, writeLatency); + writePlot.LegendText = dbGroup.Key; + writePlot.Color = color; + ChartStyle.StyleScatter(writePlot); + _fileIoWriteHover?.Add(writePlot, dbGroup.Key); + writeMax = Math.Max(writeMax, writeLatency.Max()); + } + + /* Queued I/O overlay — dashed lines showing queue wait portion of latency */ + if (hasQueuedData) + { + var queuedReadLatency = points.Select(d => d.AvgQueuedReadLatencyMs).ToArray(); + var queuedWriteLatency = points.Select(d => d.AvgQueuedWriteLatencyMs).ToArray(); + + if (queuedReadLatency.Any(v => v > 0)) + { + var qReadPlot = FileIoReadChart.Plot.Add.Scatter(times, queuedReadLatency); + qReadPlot.LegendText = $"{dbGroup.Key} (queued)"; + qReadPlot.Color = color; + ChartStyle.StyleScatter(qReadPlot); + qReadPlot.LinePattern = ScottPlot.LinePattern.Dashed; + _fileIoReadHover?.Add(qReadPlot, $"{dbGroup.Key} (queued)"); + } + + if (queuedWriteLatency.Any(v => v > 0)) + { + var qWritePlot = FileIoWriteChart.Plot.Add.Scatter(times, queuedWriteLatency); + qWritePlot.LegendText = $"{dbGroup.Key} (queued)"; + qWritePlot.Color = color; + ChartStyle.StyleScatter(qWritePlot); + qWritePlot.LinePattern = ScottPlot.LinePattern.Dashed; + _fileIoWriteHover?.Add(qWritePlot, $"{dbGroup.Key} (queued)"); + } + } + } + + FileIoReadChart.Plot.Axes.DateTimeTicksBottomDateChange(); + FileIoReadChart.Plot.Axes.SetLimitsX(rangeStart, rangeEnd); + ReapplyAxisColors(FileIoReadChart); + FileIoReadChart.Plot.YLabel("Read Latency (ms)"); + SetChartYLimitsWithLegendPadding(FileIoReadChart, 0, readMax > 0 ? readMax : 10); + ShowChartLegend(FileIoReadChart); + FileIoReadChart.Refresh(); + + FileIoWriteChart.Plot.Axes.DateTimeTicksBottomDateChange(); + FileIoWriteChart.Plot.Axes.SetLimitsX(rangeStart, rangeEnd); + ReapplyAxisColors(FileIoWriteChart); + FileIoWriteChart.Plot.YLabel("Write Latency (ms)"); + SetChartYLimitsWithLegendPadding(FileIoWriteChart, 0, writeMax > 0 ? writeMax : 10); + ShowChartLegend(FileIoWriteChart); + FileIoWriteChart.Refresh(); + } + + private void RenderFileIoThroughputCharts(List<FileIoThroughputPoint> data, DateTime startUtc, DateTime endUtc) + { + ClearChart(FileIoReadThroughputChart); + ClearChart(FileIoWriteThroughputChart); + _fileIoReadThroughputHover?.Clear(); + _fileIoWriteThroughputHover?.Clear(); + ApplyTheme(FileIoReadThroughputChart); + ApplyTheme(FileIoWriteThroughputChart); + + var rangeStart = ViewerTimeHelper.ForDisplay(startUtc).ToOADate(); + var rangeEnd = ViewerTimeHelper.ForDisplay(endUtc).ToOADate(); + + if (data.Count == 0) + { + foreach (var c in new[] { FileIoReadThroughputChart, FileIoWriteThroughputChart }) + { + c.Plot.Axes.DateTimeTicksBottomDateChange(); + c.Plot.Axes.SetLimitsX(rangeStart, rangeEnd); + ReapplyAxisColors(c); + c.Refresh(); + } + return; + } + + /* Group by file label, limit to top 10 by total throughput */ + var files = data + .GroupBy(d => d.FileLabel) + .OrderByDescending(g => g.Sum(d => d.ReadMbPerSec + d.WriteMbPerSec)) + .Take(10) + .ToList(); + + double readMax = 0, writeMax = 0; + int colorIdx = 0; + + foreach (var fileGroup in files) + { + var points = fileGroup.OrderBy(d => d.CollectionTime).ToList(); + var times = points.Select(d => ViewerTimeHelper.ForDisplay(d.CollectionTime).ToOADate()).ToArray(); + var readThroughput = points.Select(d => d.ReadMbPerSec).ToArray(); + var writeThroughput = points.Select(d => d.WriteMbPerSec).ToArray(); + var color = ScottPlot.Color.FromHex(SeriesColors[colorIdx % SeriesColors.Length]); + colorIdx++; + + if (readThroughput.Length > 0) + { + var readPlot = FileIoReadThroughputChart.Plot.Add.Scatter(times, readThroughput); + readPlot.LegendText = fileGroup.Key; + readPlot.Color = color; + ChartStyle.StyleScatter(readPlot); + _fileIoReadThroughputHover?.Add(readPlot, fileGroup.Key); + readMax = Math.Max(readMax, readThroughput.Max()); + } + + if (writeThroughput.Length > 0) + { + var writePlot = FileIoWriteThroughputChart.Plot.Add.Scatter(times, writeThroughput); + writePlot.LegendText = fileGroup.Key; + writePlot.Color = color; + ChartStyle.StyleScatter(writePlot); + _fileIoWriteThroughputHover?.Add(writePlot, fileGroup.Key); + writeMax = Math.Max(writeMax, writeThroughput.Max()); + } + } + + FileIoReadThroughputChart.Plot.Axes.DateTimeTicksBottomDateChange(); + FileIoReadThroughputChart.Plot.Axes.SetLimitsX(rangeStart, rangeEnd); + ReapplyAxisColors(FileIoReadThroughputChart); + FileIoReadThroughputChart.Plot.YLabel("Read Throughput (MB/s)"); + SetChartYLimitsWithLegendPadding(FileIoReadThroughputChart, 0, readMax > 0 ? readMax : 1); + ShowChartLegend(FileIoReadThroughputChart); + FileIoReadThroughputChart.Refresh(); + + FileIoWriteThroughputChart.Plot.Axes.DateTimeTicksBottomDateChange(); + FileIoWriteThroughputChart.Plot.Axes.SetLimitsX(rangeStart, rangeEnd); + ReapplyAxisColors(FileIoWriteThroughputChart); + FileIoWriteThroughputChart.Plot.YLabel("Write Throughput (MB/s)"); + SetChartYLimitsWithLegendPadding(FileIoWriteThroughputChart, 0, writeMax > 0 ? writeMax : 1); + ShowChartLegend(FileIoWriteThroughputChart); + FileIoWriteThroughputChart.Refresh(); + } + + /// <summary>Tears down the File I/O hover helpers; called from the single Dispose() in Charts.cs.</summary> + private void DisposeFileIoHelpers() + { + _fileIoReadHover?.Dispose(); + _fileIoWriteHover?.Dispose(); + _fileIoReadThroughputHover?.Dispose(); + _fileIoWriteThroughputHover?.Dispose(); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Filters.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Filters.cs new file mode 100644 index 000000000..aa9f7b466 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Filters.cs @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +// Column-filter wiring copied from Lite/Controls/ServerTab.Filters.cs for the copy-parity program +// (copy-don't-promote: the Darling viewer owns its own copy; Lite/Dashboard are untouched). Byte- +// identical popup plumbing; the manager set covers the four Configuration sub-grids, the Running Jobs +// grid (W1h), and the Collection Health + Collection Log grids (W1i) — the filterable viewer grids +// ported so far — and grows as later waves add their own grids. Also holds LoadConfigurationAsync, +// the Configuration tab's parallel latest-snapshot load +// that feeds the config managers (the Running Jobs load lives in ViewerServerTab.RunningJobs.cs). + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Media; +using PerformanceMonitor.Ui; +using static PerformanceMonitor.Ui.DataGridHelpers; + +namespace PerformanceMonitor.Darling.Viewer; + +public partial class ViewerServerTab : UserControl +{ + /* Grid -> its filter manager, keyed for the FilterButton_Click visual-tree walk. */ + private readonly Dictionary<DataGrid, IDataGridFilterManager> _filterManagers = new(); + + private DataGridFilterManager<ServerConfigRow>? _serverConfigFilterMgr; + private DataGridFilterManager<DatabaseConfigRow>? _databaseConfigFilterMgr; + private DataGridFilterManager<DatabaseScopedConfigRow>? _dbScopedConfigFilterMgr; + private DataGridFilterManager<TraceFlagRow>? _traceFlagsFilterMgr; + private DataGridFilterManager<RunningJobRow>? _runningJobsFilterMgr; + private DataGridFilterManager<ViewerLongQueryRow>? _longQueryFilterMgr; + private DataGridFilterManager<ViewerBlockedProcessRow>? _blockedProcessFilterMgr; + private DataGridFilterManager<DeadlockProcessDetail>? _deadlockFilterMgr; + private DataGridFilterManager<ViewerQuerySnapshotRow>? _querySnapshotsFilterMgr; + private DataGridFilterManager<ViewerQueryStatsRow>? _queryStatsFilterMgr; + private DataGridFilterManager<ViewerProcedureStatsRow>? _procStatsFilterMgr; + private DataGridFilterManager<ViewerQueryStoreRow>? _queryStoreFilterMgr; + private DataGridFilterManager<ViewerQueryStoreRegressionRow>? _queryStoreRegressionsFilterMgr; + private DataGridFilterManager<CollectorHealthRow>? _collectionHealthFilterMgr; + private DataGridFilterManager<CollectionLogRow>? _collectionLogFilterMgr; + private DataGridFilterManager<ServerConfigChangeRow>? _serverConfigChangesFilterMgr; + private DataGridFilterManager<DatabaseConfigChangeRow>? _databaseConfigChangesFilterMgr; + private DataGridFilterManager<TraceFlagChangeRow>? _traceFlagChangesFilterMgr; + + /* ========== Column Filtering ========== */ + + private void InitializeFilterManagers() + { + _serverConfigFilterMgr = new DataGridFilterManager<ServerConfigRow>(ServerConfigGrid); + _databaseConfigFilterMgr = new DataGridFilterManager<DatabaseConfigRow>(DatabaseConfigGrid); + _dbScopedConfigFilterMgr = new DataGridFilterManager<DatabaseScopedConfigRow>(DatabaseScopedConfigGrid); + _traceFlagsFilterMgr = new DataGridFilterManager<TraceFlagRow>(TraceFlagsGrid); + _runningJobsFilterMgr = new DataGridFilterManager<RunningJobRow>(RunningJobsGrid); + _longQueryFilterMgr = new DataGridFilterManager<ViewerLongQueryRow>(LongQueryCompletionsGrid); + _blockedProcessFilterMgr = new DataGridFilterManager<ViewerBlockedProcessRow>(BlockedProcessReportGrid); + _deadlockFilterMgr = new DataGridFilterManager<DeadlockProcessDetail>(DeadlockGrid); + _querySnapshotsFilterMgr = new DataGridFilterManager<ViewerQuerySnapshotRow>(QuerySnapshotsGrid); + _queryStatsFilterMgr = new DataGridFilterManager<ViewerQueryStatsRow>(QueryStatsGrid); + _procStatsFilterMgr = new DataGridFilterManager<ViewerProcedureStatsRow>(ProcedureStatsGrid); + _queryStoreFilterMgr = new DataGridFilterManager<ViewerQueryStoreRow>(QueryStoreGrid); + _queryStoreRegressionsFilterMgr = new DataGridFilterManager<ViewerQueryStoreRegressionRow>(QueryStoreRegressionsGrid); + _collectionHealthFilterMgr = new DataGridFilterManager<CollectorHealthRow>(CollectionHealthGrid); + _collectionLogFilterMgr = new DataGridFilterManager<CollectionLogRow>(CollectionLogGrid); + _serverConfigChangesFilterMgr = new DataGridFilterManager<ServerConfigChangeRow>(ServerConfigChangesGrid); + _databaseConfigChangesFilterMgr = new DataGridFilterManager<DatabaseConfigChangeRow>(DatabaseConfigChangesGrid); + _traceFlagChangesFilterMgr = new DataGridFilterManager<TraceFlagChangeRow>(TraceFlagChangesGrid); + + _filterManagers[ServerConfigGrid] = _serverConfigFilterMgr; + _filterManagers[DatabaseConfigGrid] = _databaseConfigFilterMgr; + _filterManagers[DatabaseScopedConfigGrid] = _dbScopedConfigFilterMgr; + _filterManagers[TraceFlagsGrid] = _traceFlagsFilterMgr; + _filterManagers[RunningJobsGrid] = _runningJobsFilterMgr; + _filterManagers[LongQueryCompletionsGrid] = _longQueryFilterMgr; + _filterManagers[BlockedProcessReportGrid] = _blockedProcessFilterMgr; + _filterManagers[DeadlockGrid] = _deadlockFilterMgr; + _filterManagers[QuerySnapshotsGrid] = _querySnapshotsFilterMgr; + _filterManagers[QueryStatsGrid] = _queryStatsFilterMgr; + _filterManagers[ProcedureStatsGrid] = _procStatsFilterMgr; + _filterManagers[QueryStoreGrid] = _queryStoreFilterMgr; + _filterManagers[QueryStoreRegressionsGrid] = _queryStoreRegressionsFilterMgr; + _filterManagers[CollectionHealthGrid] = _collectionHealthFilterMgr; + _filterManagers[CollectionLogGrid] = _collectionLogFilterMgr; + _filterManagers[ServerConfigChangesGrid] = _serverConfigChangesFilterMgr; + _filterManagers[DatabaseConfigChangesGrid] = _databaseConfigChangesFilterMgr; + _filterManagers[TraceFlagChangesGrid] = _traceFlagChangesFilterMgr; + } + + /// <summary> + /// Configuration tab load: fires all four latest-snapshot reads concurrently (NpgsqlDataSource + /// pools a connection for each), then hands each result to its filter manager's UpdateData so + /// active column filters survive the refresh. Mirrors Lite's RefreshConfigurationAsync + /// (Task.WhenAll of the four) — but the reads are genuinely async, so there is no Task.Run wrap. + /// LoadInnerTabAsync owns the try/catch that surfaces failures on the status bar. + /// </summary> + private async Task LoadConfigurationAsync() + { + var serverConfigTask = _dataService.GetLatestServerConfigAsync(_server.ServerId); + var databaseConfigTask = _dataService.GetLatestDatabaseConfigAsync(_server.ServerId, databaseNames: SelectedDatabaseFilter); + var databaseScopedConfigTask = _dataService.GetLatestDatabaseScopedConfigAsync(_server.ServerId, databaseNames: SelectedDatabaseFilter); + var traceFlagsTask = _dataService.GetLatestTraceFlagsAsync(_server.ServerId); + + await Task.WhenAll(serverConfigTask, databaseConfigTask, databaseScopedConfigTask, traceFlagsTask); + + _serverConfigFilterMgr!.UpdateData(serverConfigTask.Result); + _databaseConfigFilterMgr!.UpdateData(databaseConfigTask.Result); + _dbScopedConfigFilterMgr!.UpdateData(databaseScopedConfigTask.Result); + _traceFlagsFilterMgr!.UpdateData(traceFlagsTask.Result); + } + + /* Host/apply plumbing lives in the shared Ui controller. Lazy (a field initializer can't reference the + instance field _filterManagers); the XAML-wired FilterButton_Click forwards to it. */ + private ColumnFilterPopupController? _filterPopupControllerField; + private ColumnFilterPopupController FilterPopupController => _filterPopupControllerField ??= new ColumnFilterPopupController(_filterManagers); + + private void FilterButton_Click(object sender, RoutedEventArgs e) => FilterPopupController.HandleFilterButtonClick(sender); +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.History.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.History.cs new file mode 100644 index 000000000..04500dc57 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.History.cs @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The per-row double-click execution-history drill-downs — a port of Lite's <c>ServerTab.Grids.cs</c> +/// <c>*Grid_MouseDoubleClick</c> handlers: double-clicking a Top Queries / Top Procedures / Query Store row +/// opens that item's history window (<see cref="QueryStatsHistoryWindow"/> / <see cref="ProcedureHistoryWindow"/> +/// / <see cref="QueryStoreHistoryWindow"/>), showing its metric trend + per-collection snapshot grid over the +/// toolbar's current window (<see cref="GetWindowUtc"/>). This is a DIFFERENT event from #1409's grid +/// SelectionChanged slicer overlay (ViewerServerTab.SlicerOverlay.cs) — both are wired on the same three grids +/// and coexist (a double-click also selects the row, so the overlay updates too). The row → window-parameter +/// mapping (which fields identify the double-clicked item, and when a row can't open a window) is factored into +/// pure static <c>Map*HistoryKey</c> methods so it is unit-testable without WPF. +/// </summary> +public partial class ViewerServerTab +{ + /// <summary>The fields that identify a double-clicked Top-Queries row's history window (Lite gates on a + /// non-empty database + query_hash).</summary> + public sealed record QueryStatsHistoryKey(string DatabaseName, string QueryHash, string QueryText); + + /// <summary>The fields that identify a double-clicked Top-Procedures row's history window (Lite gates on a + /// non-empty database + object name).</summary> + public sealed record ProcedureHistoryKey(string DatabaseName, string SchemaName, string ObjectName); + + /// <summary>The fields that identify a double-clicked Query Store row's history window (Lite gates on a + /// non-empty database + a non-zero query_id; the window is query-scoped, plan_id seeds the launched row).</summary> + public sealed record QueryStoreHistoryKey(string DatabaseName, long QueryId, long PlanId, string QueryText); + + /// <summary> + /// Wires the three query grids' MouseDoubleClick to their history windows. Called from the constructor + /// after InitializeComponent, so the named grids exist. Subscribed for the life of the tab (the grids are + /// never re-created); no unsubscribe needed — the same lifetime rule <see cref="WireSlicerOverlays"/> uses. + /// </summary> + private void WireHistoryDrillDowns() + { + QueryStatsGrid.MouseDoubleClick += QueryStatsGrid_MouseDoubleClick; + ProcedureStatsGrid.MouseDoubleClick += ProcedureStatsGrid_MouseDoubleClick; + QueryStoreGrid.MouseDoubleClick += QueryStoreGrid_MouseDoubleClick; + QueryStoreRegressionsGrid.MouseDoubleClick += QueryStoreRegressionsGrid_MouseDoubleClick; + } + + /// <summary>Maps a Top-Queries grid row to its history-window key, or null when the row can't open one + /// (missing database or query_hash) — Lite's <c>QueryStatsGrid_MouseDoubleClick</c> guard. Pure + static.</summary> + internal static QueryStatsHistoryKey? MapQueryStatsHistoryKey(ViewerQueryStatsRow? row) + => row is null || string.IsNullOrEmpty(row.DatabaseName) || string.IsNullOrEmpty(row.QueryHash) + ? null + : new QueryStatsHistoryKey(row.DatabaseName, row.QueryHash, row.QueryText); + + /// <summary>Maps a Top-Procedures grid row to its history-window key, or null when the row can't open one + /// (missing database or object name) — Lite's <c>ProcedureStatsGrid_MouseDoubleClick</c> guard. Pure + static.</summary> + internal static ProcedureHistoryKey? MapProcedureHistoryKey(ViewerProcedureStatsRow? row) + => row is null || string.IsNullOrEmpty(row.DatabaseName) || string.IsNullOrEmpty(row.ObjectName) + ? null + : new ProcedureHistoryKey(row.DatabaseName, row.SchemaName, row.ObjectName); + + /// <summary>Maps a Query Store grid row to its history-window key, or null when the row can't open one + /// (missing database or a zero query_id) — Lite's <c>QueryStoreGrid_MouseDoubleClick</c> guard. Pure + static.</summary> + internal static QueryStoreHistoryKey? MapQueryStoreHistoryKey(ViewerQueryStoreRow? row) + => row is null || string.IsNullOrEmpty(row.DatabaseName) || row.QueryId == 0 + ? null + : new QueryStoreHistoryKey(row.DatabaseName, row.QueryId, row.PlanId, row.QueryText); + + /// <summary>Maps a Query Store Regressions grid row to a Query Store history-window key (the window is + /// query-scoped), or null when the row can't open one (missing database or a zero query_id) — mirrors the + /// Dashboard's <c>QueryStoreRegressionsDataGrid_MouseDoubleClick</c> guard. The regression row aggregates + /// over plans (it carries baseline/recent plan COUNTS, not a single plan_id), so plan_id is 0 — the same + /// "no specific plan to seed" value the Dashboard passes. Pure + static.</summary> + internal static QueryStoreHistoryKey? MapQueryStoreRegressionHistoryKey(ViewerQueryStoreRegressionRow? row) + => row is null || string.IsNullOrEmpty(row.DatabaseName) || row.QueryId == 0 + ? null + : new QueryStoreHistoryKey(row.DatabaseName, row.QueryId, 0, row.QueryTextSample); + + private void QueryStatsGrid_MouseDoubleClick(object sender, MouseButtonEventArgs e) + { + if (MapQueryStatsHistoryKey(QueryStatsGrid.SelectedItem as ViewerQueryStatsRow) is not { } key) return; + + var (startUtc, endUtc) = GetWindowUtc(); + var window = new QueryStatsHistoryWindow( + _dataService, _server.ServerId, key.DatabaseName, key.QueryHash, key.QueryText, startUtc, endUtc) + { + Owner = Window.GetWindow(this), + }; + window.ShowDialog(); + } + + private void ProcedureStatsGrid_MouseDoubleClick(object sender, MouseButtonEventArgs e) + { + if (MapProcedureHistoryKey(ProcedureStatsGrid.SelectedItem as ViewerProcedureStatsRow) is not { } key) return; + + var (startUtc, endUtc) = GetWindowUtc(); + var window = new ProcedureHistoryWindow( + _dataService, _server.ServerId, key.DatabaseName, key.SchemaName, key.ObjectName, startUtc, endUtc) + { + Owner = Window.GetWindow(this), + }; + window.ShowDialog(); + } + + private void QueryStoreGrid_MouseDoubleClick(object sender, MouseButtonEventArgs e) + { + if (MapQueryStoreHistoryKey(QueryStoreGrid.SelectedItem as ViewerQueryStoreRow) is not { } key) return; + + var (startUtc, endUtc) = GetWindowUtc(); + var window = new QueryStoreHistoryWindow( + _dataService, _server.ServerId, key.DatabaseName, key.QueryId, key.PlanId, key.QueryText, startUtc, endUtc) + { + Owner = Window.GetWindow(this), + }; + window.ShowDialog(); + } + + private void QueryStoreRegressionsGrid_MouseDoubleClick(object sender, MouseButtonEventArgs e) + { + if (MapQueryStoreRegressionHistoryKey(QueryStoreRegressionsGrid.SelectedItem as ViewerQueryStoreRegressionRow) is not { } key) return; + + var (startUtc, endUtc) = GetWindowUtc(); + var window = new QueryStoreHistoryWindow( + _dataService, _server.ServerId, key.DatabaseName, key.QueryId, key.PlanId, key.QueryText, startUtc, endUtc) + { + Owner = Window.GetWindow(this), + }; + window.ShowDialog(); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.LatchSpinlock.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.LatchSpinlock.cs new file mode 100644 index 000000000..91ef7554a --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.LatchSpinlock.cs @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Collections.Generic; +using System.Threading.Tasks; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Latches & Spinlocks inner tab — the Dashboard-parity port of ResourceMetricsContent's Latch +/// Stats / Spinlock Stats into the Darling viewer, consolidated into ONE tab: the latch and spinlock +/// per-second trend charts for the TOP 5 contenders (latch classes by delta wait time, spinlocks by delta +/// collisions) stack vertically, each above a collapsed Expander holding its latest-snapshot grid of the +/// most recent collection in the settable window. The Darling cumulative-delta tables carry no stored +/// <c>sample_interval_seconds</c> (unlike the Dashboard's), so the ms/sec and collisions/sec rates are +/// computed in SQL from the per-contender <c>LAG</c> interval — the same idiom the Wait Stats trend uses. +/// Both charts and grids (re)load together on the parent tab's activation (mirroring the Memory tab's +/// full-refresh branch), so the tab needs no SelectionChanged handler. Chart chrome / legend / line polish +/// flow through the shared <see cref="ChartStyle"/> / <see cref="ChartPalette"/> and the +/// <c>ViewerServerTab.ChartHelpers.cs</c> bridge, so the Y-floor-at-0 fix applies; series ride the cycling +/// <c>SeriesColors</c> (declared in ViewerServerTab.Charts.cs). +/// </summary> +public partial class ViewerServerTab +{ + private ChartHoverHelper? _latchStatsHover; + private ChartHoverHelper? _spinlockStatsHover; + + /* One shared grouped-trend renderer serves BOTH the latch and spinlock charts; built lazily with the + viewer's ForDisplay projection (mirrors the CpuScheduler renderer's per-app wiring). */ + private GroupedTrendChartRenderer? _latchSpinlockRendererField; + private GroupedTrendChartRenderer LatchSpinlockRenderer => + _latchSpinlockRendererField ??= new GroupedTrendChartRenderer(_chartHelper, ViewerTimeHelper.ForDisplay); + + /// <summary>Applies the shared chrome + hover to the latch/spinlock charts up front (constructor), + /// so they don't flash white before the tab's first load — matching the CPU/Memory charts.</summary> + private void InitializeLatchSpinlockCharts() + { + ApplyTheme(LatchStatsChart); + LatchStatsChart.Refresh(); + ApplyTheme(SpinlockStatsChart); + SpinlockStatsChart.Refresh(); + + _latchStatsHover = new ChartHoverHelper(LatchStatsChart, "ms/sec"); + _spinlockStatsHover = new ChartHoverHelper(SpinlockStatsChart, "/sec"); + } + + /// <summary> + /// Loads the consolidated tab (latch trend + snapshot, spinlock trend + snapshot) over the toolbar's + /// settable window: the four reads (two trends, two snapshots) fire concurrently — NpgsqlDataSource + /// pools a connection each — then the two charts and their two Expander grids render. + /// </summary> + private async Task LoadLatchSpinlockAsync() + { + var (startUtc, endUtc) = GetWindowUtc(); + + var latchTrendTask = _dataService.GetLatchStatsTrendAsync(_server.ServerId, startUtc, endUtc); + var latchSnapshotTask = _dataService.GetLatchStatsSnapshotAsync(_server.ServerId, startUtc, endUtc); + var spinlockTrendTask = _dataService.GetSpinlockStatsTrendAsync(_server.ServerId, startUtc, endUtc); + var spinlockSnapshotTask = _dataService.GetSpinlockStatsSnapshotAsync(_server.ServerId, startUtc, endUtc); + + await Task.WhenAll(latchTrendTask, latchSnapshotTask, spinlockTrendTask, spinlockSnapshotTask); + + RenderLatchStatsChart(latchTrendTask.Result); + LatchStatsGrid.ItemsSource = latchSnapshotTask.Result; + RenderSpinlockStatsChart(spinlockTrendTask.Result); + SpinlockStatsGrid.ItemsSource = spinlockSnapshotTask.Result; + } + + private void RenderLatchStatsChart(List<LatchStatsTrendPoint> data) + { + var (startUtc, endUtc) = GetWindowUtc(); + LatchSpinlockRenderer.Render(LatchStatsChart, _latchStatsHover, data, + d => d.CollectionTime, d => d.LatchClass, d => d.WaitTimeMsPerSecond, + "Latch Waits", "Wait Time (ms/sec)", + ViewerTimeHelper.ForDisplay(startUtc).ToOADate(), ViewerTimeHelper.ForDisplay(endUtc).ToOADate()); + } + + private void RenderSpinlockStatsChart(List<SpinlockStatsTrendPoint> data) + { + var (startUtc, endUtc) = GetWindowUtc(); + LatchSpinlockRenderer.Render(SpinlockStatsChart, _spinlockStatsHover, data, + d => d.CollectionTime, d => d.SpinlockName, d => d.CollisionsPerSecond, + "Spinlock Collisions", "Collisions/sec", + ViewerTimeHelper.ForDisplay(startUtc).ToOADate(), ViewerTimeHelper.ForDisplay(endUtc).ToOADate()); + } + + /// <summary>Tears down the latch/spinlock hover helpers (mirrors the other tabs' dispose) so their + /// tooltip popups + chart event handlers don't outlive a closed server tab.</summary> + public void DisposeLatchSpinlockHelpers() + { + _latchStatsHover?.Dispose(); + _spinlockStatsHover?.Dispose(); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.LivePlan.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.LivePlan.cs new file mode 100644 index 000000000..000756478 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.LivePlan.cs @@ -0,0 +1,293 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using static PerformanceMonitor.Ui.DataGridHelpers; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The "View Cached Plan" surfaces (headless-plan live-plan wave): the piece Darling's stored-plan-only viewer +/// had to drop when it lost Lite's direct SqlClient path. The viewer never touches SQL Server, but the SERVICE +/// holds a live runtime connection to every monitored server, so these hand a handle to the service over the +/// Stage-2 command channel (<see cref="ViewerDataService.FetchLivePlanAsync"/> → a <c>fetch_plan</c> command) +/// and display the plan the service reads back from the target's live plan cache in the SAME +/// <see cref="OpenPlanTab"/> host the stored plans use. Two keys, matching what the store rows carry: +/// <list type="bullet"> +/// <item>the query / procedure grids' <c>plan_handle</c> — the currently-cached plan for a row, DISTINCT from +/// the (possibly older) plan the collector stored;</item> +/// <item>a deadlock-graph process's or a blocked-process-report frame's <c>sql_handle</c> + statement offsets +/// — this is what lets a user open the plan for ANY process in the graph (the deadlocker, a blocker), not +/// just the one best-effort victim plan the collector happened to stash.</item> +/// </list> +/// +/// <para>Each fetch enqueues a command, which is a config write, so a read-only <c>viewer</c> seat cannot do it +/// (consistent with Pause / Snapshot) — the handlers short-circuit with an explanation, and the enqueue's +/// <see cref="ViewerReadOnlyException"/> is the belt-and-braces backstop. A spinner ("Fetching live plan…") runs +/// in the Plan Viewer while the command is polled; the loading overlay's Cancel button cancels the poll. The +/// "get the ACTUAL plan by re-executing the query" (SET STATISTICS XML) variant is deliberately NOT here — it +/// has side effects and belongs behind Apply Fix's informed-consent gate.</para> +/// </summary> +public partial class ViewerServerTab +{ + // ── Query / Procedure grids: fetch the currently-cached plan by plan_handle ── + + /// <summary> + /// "View Cached Plan" on a Top Queries / Top Procedures row — fetches the plan CURRENTLY in the server's plan + /// cache by the row's plan_handle (distinct from the collector's stored plan, which "View Plan" opens). Gated + /// in XAML on the row's <c>HasLivePlanHandle</c>, so it only fires for a row that carries a plan_handle. + /// </summary> + private async void FetchLiveQueryPlan_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem) return; + var grid = FindParentDataGrid(menuItem); + if (grid?.CurrentItem is null) return; + + string planHandle; + string? databaseName; + string label; + string? queryText; + + switch (grid.CurrentItem) + { + case ViewerQueryStatsRow stats when !string.IsNullOrEmpty(stats.PlanHandle): + planHandle = stats.PlanHandle; + databaseName = stats.DatabaseName; + label = $"Cached Plan - {stats.QueryHash}"; + queryText = stats.QueryText; + break; + case ViewerProcedureStatsRow proc when !string.IsNullOrEmpty(proc.PlanHandle): + planHandle = proc.PlanHandle; + databaseName = proc.DatabaseName; + label = $"Cached Plan - {proc.FullName}"; + queryText = null; /* the procedure grid carries no statement text; the plan XML holds its own */ + break; + default: + return; + } + + var argsJson = ViewerDataService.BuildPlanFetchArgsByPlanHandle(planHandle, databaseName); + await FetchAndOpenLivePlanAsync(argsJson, label, queryText); + } + + // ── Deadlock grid: fetch the live plan for THIS process (any process in the graph) by its sql_handle ── + + /// <summary> + /// "View Cached Plan (This Process)" on a deadlock row — fetches the live plan for the RIGHT-CLICKED process + /// (victim OR deadlocker) by the sql_handle in its deadlock-graph executionStack, closing Lite's "any + /// process, not just the stored victim" gap. Tries the process-level handle then each frame top-down (Lite's + /// order), first cached plan wins. + /// </summary> + private async void FetchDeadlockProcessLivePlan_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem) return; + var grid = FindParentDataGrid(menuItem); + if (grid?.CurrentItem is not DeadlockProcessDetail row) return; + + var sideLabel = row.IsVictim ? "Victim" : "Deadlocker"; + var frames = PlanFrameExtractor.ExtractDeadlockProcessFrames(row.DeadlockGraphXml, row.ProcessId); + if (frames.Count == 0) + { + ShowNoResolvableHandle(sideLabel.ToLowerInvariant() + " process"); + return; + } + + await FetchAndOpenBySqlHandleFramesAsync(frames, row.DatabaseName, $"Cached Plan - {sideLabel} SPID {row.Spid}", row.SqlText); + } + + // ── Blocked Process Reports grid: fetch the live plan for the blocked / blocking process by its sql_handle ── + + private async void FetchBlockedSideLivePlan_Click(object sender, RoutedEventArgs e) + => await FetchBlockedProcessLivePlanAsync(sender, blockingSide: false); + + private async void FetchBlockingSideLivePlan_Click(object sender, RoutedEventArgs e) + => await FetchBlockedProcessLivePlanAsync(sender, blockingSide: true); + + /// <summary> + /// "View Blocked/Blocking Cached Plan" on a blocked-process-report row — fetches the live plan for the blocked + /// or blocking process by the sql_handle in its executionStack frames. Gated in XAML on the row's + /// <c>HasReportXml</c>, so a DMV-snapshot fallback row (no report XML → no sql_handle) shows the items + /// disabled rather than shown-and-empty. + /// </summary> + private async Task FetchBlockedProcessLivePlanAsync(object sender, bool blockingSide) + { + if (sender is not MenuItem menuItem) return; + var grid = FindParentDataGrid(menuItem); + if (grid?.CurrentItem is not ViewerBlockedProcessRow row) return; + + var sideLabel = blockingSide ? "Blocking" : "Blocked"; + var spid = blockingSide ? row.BlockingSpid : row.BlockedSpid; + var queryText = blockingSide ? row.BlockingSqlText : row.BlockedSqlText; + + var frames = PlanFrameExtractor.ExtractBlockedProcessFrames(row.BlockedProcessReportXml, blockingSide); + if (frames.Count == 0) + { + ShowNoResolvableHandle(sideLabel.ToLowerInvariant() + " process"); + return; + } + + await FetchAndOpenBySqlHandleFramesAsync(frames, row.DatabaseName, $"Cached Plan - {sideLabel} SPID {spid}", queryText); + } + + // ── Shared fetch/display flow ── + + /// <summary> + /// Fetches ONE plan by plan_handle and opens it. Gates read-only, shows the "Fetching live plan…" spinner, + /// polls the command (cancelable via the overlay's Cancel button), and either opens the plan or shows the + /// service's not-in-cache / failure / timeout message. + /// </summary> + private async Task FetchAndOpenLivePlanAsync(string argsJson, string label, string? queryText) + { + if (BlockedByReadOnlySeat()) return; + + BeginLivePlanFetch(label); + try + { + var result = await _dataService.FetchLivePlanAsync(_server.ServerId, argsJson, _planLoadCts!.Token); + if (result.Status == LivePlanFetchStatus.Fetched) + { + OpenPlanTab(result.PlanXml!, label, queryText); /* HidePlanLoading runs inside OpenPlanTab */ + } + else + { + HidePlanLoading(); + ShowLivePlanOutcome(result); + } + } + catch (OperationCanceledException) + { + HidePlanLoading(); + } + catch (ViewerReadOnlyException) + { + HidePlanLoading(); + BlockedByReadOnlySeat(force: true); + } + catch (Exception ex) + { + HidePlanLoading(); + MessageBox.Show($"Failed to fetch the live plan:\n\n{ex.Message}", "Cached Plan Error", + MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + /// <summary> + /// Fetches by sql_handle across a process's candidate frames (process-level handle, then executionStack + /// frames top-down), opening the FIRST that is still cached — mirroring Lite's per-frame walk, but each + /// frame is one <c>fetch_plan</c> command round-trip. A "not connected" hard failure stops the walk; an + /// evicted frame moves on to the next. + /// </summary> + private async Task FetchAndOpenBySqlHandleFramesAsync( + IReadOnlyList<(string SqlHandle, int StmtStart, int StmtEnd)> frames, + string databaseName, string label, string? queryText) + { + if (BlockedByReadOnlySeat()) return; + + BeginLivePlanFetch(label); + try + { + LivePlanFetchResult? last = null; + foreach (var frame in frames) + { + _planLoadCts!.Token.ThrowIfCancellationRequested(); + + var argsJson = ViewerDataService.BuildPlanFetchArgsBySqlHandle( + frame.SqlHandle, frame.StmtStart, frame.StmtEnd, databaseName); + var result = await _dataService.FetchLivePlanAsync(_server.ServerId, argsJson, _planLoadCts.Token); + last = result; + + if (result.Status == LivePlanFetchStatus.Fetched) + { + OpenPlanTab(result.PlanXml!, label, queryText); /* HidePlanLoading runs inside OpenPlanTab */ + return; + } + + /* A hard failure (server not connected) won't improve on the next frame — stop and report it. + An evicted plan (NotInCache) might be a different frame's, so keep walking. */ + if (result.Status is LivePlanFetchStatus.Failed or LivePlanFetchStatus.TimedOut) + { + break; + } + } + + HidePlanLoading(); + if (last is not null && last.Status != LivePlanFetchStatus.NotInCache) + { + ShowLivePlanOutcome(last); + } + else + { + MessageBox.Show( + $"The plan for this process is no longer in the plan cache on {_server.DisplayName}. " + + "A deadlock graph / blocked-process report only carries a sql_handle — if that plan has been " + + "evicted or recompiled, it can't be recovered.", + "Plan Not In Cache", MessageBoxButton.OK, MessageBoxImage.Information); + } + } + catch (OperationCanceledException) + { + HidePlanLoading(); + } + catch (ViewerReadOnlyException) + { + HidePlanLoading(); + BlockedByReadOnlySeat(force: true); + } + catch (Exception ex) + { + HidePlanLoading(); + MessageBox.Show($"Failed to fetch the live plan:\n\n{ex.Message}", "Cached Plan Error", + MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + /// <summary>Shows the Plan Viewer's loading overlay with a fetch-specific label + a fresh cancellation source.</summary> + private void BeginLivePlanFetch(string label) + { + ShowPlanLoading(label); + PlanLoadingLabel.Text = $"Fetching live plan: {label}"; + _planLoadCts?.Dispose(); + _planLoadCts = new CancellationTokenSource(); + } + + /// <summary>Surfaces a non-fetched outcome (not-in-cache info, or a failure/timeout warning).</summary> + private void ShowLivePlanOutcome(LivePlanFetchResult result) + { + var (caption, icon) = result.Status == LivePlanFetchStatus.NotInCache + ? ("Plan Not In Cache", MessageBoxImage.Information) + : ("Cached Plan Fetch Failed", MessageBoxImage.Warning); + MessageBox.Show(result.Message ?? "The plan could not be fetched.", caption, MessageBoxButton.OK, icon); + } + + private void ShowNoResolvableHandle(string what) + => MessageBox.Show( + $"The {what} has no resolvable sql_handle in its captured XML. This usually means the query ran as " + + "dynamic SQL or a system context — SQL Server records a zero handle there and the plan can't be recovered.", + "No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information); + + /// <summary> + /// True (and shows an explanation) when the current seat is read-only: fetching a live plan enqueues a + /// command, which a read-only <c>viewer</c> seat cannot do (same rule as Pause / per-server toggles). + /// <paramref name="force"/> shows the message even if IsReadOnly went stale (the enqueue already threw). + /// </summary> + private bool BlockedByReadOnlySeat(bool force = false) + { + if (!force && !_dataService.IsReadOnly) return false; + MessageBox.Show( + "Fetching a live plan asks the service to read the target server's plan cache, which it does by " + + "running a command — a read-only viewer seat can't enqueue commands. Reconnect with a read-write " + + "profile to fetch live plans.", + "Read-Only Viewer", MessageBoxButton.OK, MessageBoxImage.Information); + return true; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.LongQueries.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.LongQueries.cs new file mode 100644 index 000000000..598bde493 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.LongQueries.cs @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.ComponentModel; +using System.Threading.Tasks; +using System.Windows; + +namespace PerformanceMonitor.Darling.Viewer; + +public partial class ViewerServerTab +{ + /// <summary> + /// Long Queries inner tab (#1496): completed long-running queries (rpc/batch over the duration + /// threshold) plus attentions (cancels/timeouts) from the opt-in XE session, windowed on the toolbar's + /// range and bound through the filter manager. Because the collector is default OFF, an explicit + /// empty-state banner is shown while it is disabled, pointing the operator at the exact switch + /// (Settings → Collection Schedule → Edit Collector Schedules… → the Enabled box). The grid applies a + /// view-only DESCENDING-by-duration sort, never touching the reader's chronological ORDER BY. + /// </summary> + private async Task LoadLongQueriesAsync() + { + LongQueriesDisabledWarning.Visibility = await _dataService.GetLongQueryTraceEnabledAsync(_server.ServerId) + ? Visibility.Collapsed + : Visibility.Visible; + + var (startUtc, endUtc) = GetWindowUtc(); + var rows = await _dataService.GetRecentLongQueryCompletionsAsync(_server.ServerId, startUtc, endUtc, databaseNames: SelectedDatabaseFilter); + _longQueryFilterMgr!.UpdateData(rows); + + SetDefaultSortIfNone(LongQueryCompletionsGrid, "DurationMicroseconds", ListSortDirection.Descending); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Memory.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Memory.cs new file mode 100644 index 000000000..0369cb7c7 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Memory.cs @@ -0,0 +1,647 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Memory inner tab — a COPY of Lite's Memory tab (four sub-tabs: Overview, Memory Clerks, Memory +/// Grants, Memory Pressure Events), with the summary + chart bodies from Lite's <c>ServerTab.Charts.cs</c> +/// (<c>UpdateMemorySummary</c> / <c>UpdateMemoryChart</c> / <c>UpdateMemoryGrantCharts</c> / +/// <c>UpdateMemoryPressureEventsChart</c>) and the clerk picker from Lite's <c>ServerTab.Pickers.cs</c> +/// (<c>PopulateMemoryClerkPicker</c> … <c>UpdateMemoryClerksChartFromPickerAsync</c>), reads rewired to +/// <see cref="ViewerDataService"/> Postgres. The only render-body changes: the time axis runs every point +/// through <see cref="ViewerTimeHelper.ForDisplay"/> (where Lite shifts by its per-server +/// <c>UtcOffsetMinutes</c>), and Lite's per-chart context menu / "Show Active Queries at This Time" +/// drill-down + <c>Task.Run</c> query wrappers are dropped (the viewer's reads are genuinely async and it +/// has no drill-down surfaces yet); the hover tooltips are kept. The clerk picker mirrors the wait/perfmon +/// pickers' reentrancy guard + generation-guarded chart update. Chart chrome/legend/line polish flow +/// through the shared <see cref="ChartStyle"/> / <see cref="ChartPalette"/> and the +/// <c>ViewerServerTab.ChartHelpers.cs</c> bridge, and the per-pool / per-clerk series ride the shared +/// cycling <c>SeriesColors</c> (declared in ViewerServerTab.Charts.cs). +/// +/// <para>First cut: all four sub-tabs (re)load on Memory-tab activation (Lite's full-refresh branch); +/// Lite's sub-tab-only timer optimization is deferred, so the sub-TabControl has no SelectionChanged +/// handler. <b>Pressure-event time:</b> like CPU's <c>sample_time</c>, the pressure <c>sample_time</c> is +/// the monitored server's LOCAL wall clock, but the per-batch de-skew the CPU read uses (#1262) assumes the +/// newest sample is ~1 minute old — true for the dense CPU ring buffer, NOT for the sparse resource-monitor +/// ring buffer — so it does not transfer. The pressure chart therefore windows AND plots on raw +/// <c>sample_time</c> through <see cref="ViewerTimeHelper.ForDisplay"/>: bars stay inside the X range and +/// internally consistent (both bounds and bars share the one clock); for a non-UTC server the whole +/// standalone chart shifts by the server's UTC offset. A reliable pressure-time de-skew is deferred.</para> +/// </summary> +public partial class ViewerServerTab +{ + private ChartHoverHelper? _memoryHover; + private ChartHoverHelper? _memoryClerksHover; + private ChartHoverHelper? _memoryGrantSizingHover; + private ChartHoverHelper? _memoryGrantActivityHover; + private ChartHoverHelper? _memoryPressureEventsHover; + + /* Clerk picker state (mirrors the perfmon picker in ViewerServerTab.Perfmon.cs): the checkbox-list + items, the reentrancy guard on programmatic checkbox writes, and the generation guard the batched + chart update bumps on entry to discard a stale in-flight run. */ + private List<SelectableItem> _memoryClerkItems = new(); + private bool _isUpdatingMemoryClerkSelection; + private int _memoryClerksPickerGen; + + /// <summary> + /// Applies the shared chrome to the five Memory charts and wires their hover tooltips (Lite's units). + /// Called from the constructor after <c>InitializeComponent</c>; the theme is applied up front so the + /// charts don't flash white before the Memory tab's first load, matching Lite's ServerTab. + /// </summary> + private void InitializeMemoryCharts() + { + ApplyTheme(MemoryChart); + MemoryChart.Refresh(); + ApplyTheme(MemoryClerksChart); + MemoryClerksChart.Refresh(); + ApplyTheme(MemoryGrantSizingChart); + MemoryGrantSizingChart.Refresh(); + ApplyTheme(MemoryGrantActivityChart); + MemoryGrantActivityChart.Refresh(); + ApplyTheme(MemoryPressureEventsChart); + MemoryPressureEventsChart.Refresh(); + + _memoryHover = new ChartHoverHelper(MemoryChart, "GB"); + _memoryClerksHover = new ChartHoverHelper(MemoryClerksChart, "MB"); + _memoryGrantSizingHover = new ChartHoverHelper(MemoryGrantSizingChart, "MB"); + _memoryGrantActivityHover = new ChartHoverHelper(MemoryGrantActivityChart, ""); + _memoryPressureEventsHover = new ChartHoverHelper(MemoryPressureEventsChart, "events"); + } + + /// <summary> + /// Loads the Memory tab (Lite's <c>RefreshMemoryAsync</c> full-refresh branch): the six feeds read + /// concurrently over the toolbar's settable window, then the summary + four charts render and the clerk + /// picker is populated + drawn. The reads are genuinely async (no <c>Task.Run</c>), so a single + /// <see cref="Task.WhenAll"/> pools a connection per read. + /// </summary> + private async Task LoadMemoryAsync() + { + var (startUtc, endUtc) = GetWindowUtc(); + + var latestTask = _dataService.GetLatestMemoryStatsAsync(_server.ServerId); + var trendTask = _dataService.GetMemoryTrendAsync(_server.ServerId, startUtc, endUtc); + var grantTrendTask = _dataService.GetMemoryGrantTrendAsync(_server.ServerId, startUtc, endUtc); + var clerkTypesTask = _dataService.GetDistinctMemoryClerkTypesAsync(_server.ServerId, startUtc, endUtc); + var grantChartTask = _dataService.GetMemoryGrantChartDataAsync(_server.ServerId, startUtc, endUtc); + var pressureTask = _dataService.GetMemoryPressureEventsAsync(_server.ServerId, startUtc, endUtc); + + await Task.WhenAll(latestTask, trendTask, grantTrendTask, clerkTypesTask, grantChartTask, pressureTask); + + RenderMemorySummary(latestTask.Result); + RenderMemoryChart(trendTask.Result, grantTrendTask.Result, startUtc, endUtc); + RenderMemoryGrantCharts(grantChartTask.Result, startUtc, endUtc); + RenderMemoryPressureEventsChart(pressureTask.Result); + PopulateMemoryClerkPicker(clerkTypesTask.Result); + await UpdateMemoryClerksChartFromPickerAsync(); + + /* Plan Cache sub-tab (under Memory, matching the Dashboard's Memory > Plan Cache): loaded in the + same pass as the other Memory sub-tabs (the Memory tab's full-refresh branch). */ + await LoadPlanCacheAsync(); + } + + /// <summary>The Overview summary strip — Lite's <c>UpdateMemorySummary</c> verbatim.</summary> + private void RenderMemorySummary(MemoryStatsRow? stats) + { + if (stats == null) + { + PhysicalMemoryText.Text = "--"; + AvailablePhysicalMemoryText.Text = "--"; + TotalServerMemoryText.Text = "--"; + TargetServerMemoryText.Text = "--"; + BufferPoolText.Text = "--"; + PlanCacheText.Text = "--"; + TotalPageFileText.Text = "--"; + AvailablePageFileText.Text = "--"; + MemoryStateText.Text = "--"; + SqlMemoryModelText.Text = "--"; + return; + } + + PhysicalMemoryText.Text = FormatMb(stats.TotalPhysicalMemoryMb); + AvailablePhysicalMemoryText.Text = FormatMb(stats.AvailablePhysicalMemoryMb); + TotalServerMemoryText.Text = FormatMb(stats.TotalServerMemoryMb); + TargetServerMemoryText.Text = FormatMb(stats.TargetServerMemoryMb); + BufferPoolText.Text = FormatMb(stats.BufferPoolMb); + PlanCacheText.Text = FormatMb(stats.PlanCacheMb); + TotalPageFileText.Text = FormatMb(stats.TotalPageFileMb); + AvailablePageFileText.Text = FormatMb(stats.AvailablePageFileMb); + MemoryStateText.Text = stats.SystemMemoryState; + SqlMemoryModelText.Text = stats.SqlMemoryModel; + } + + private static string FormatMb(double mb) + { + return mb >= 1024 ? $"{mb / 1024:F1} GB" : $"{mb:F0} MB"; + } + + /// <summary> + /// The Overview memory trend — Lite's <c>UpdateMemoryChart</c>: Total Server Memory, Target Memory + /// (dashed), Buffer Pool, and the Memory Grants overlay, all MB→GB. The grant overlay draws a flat + /// zero line when no grant data exists. Times run through <see cref="ViewerTimeHelper.ForDisplay"/>. + /// </summary> + private void RenderMemoryChart(List<MemoryTrendPoint> data, List<MemoryGrantTrendPoint> grantData, DateTime startUtc, DateTime endUtc) + { + ClearChart(MemoryChart); + _memoryHover?.Clear(); + ApplyTheme(MemoryChart); + + var rangeStart = ViewerTimeHelper.ForDisplay(startUtc).ToOADate(); + var rangeEnd = ViewerTimeHelper.ForDisplay(endUtc).ToOADate(); + + if (data.Count == 0) + { + MemoryChart.Plot.Axes.DateTimeTicksBottomDateChange(); + MemoryChart.Plot.Axes.SetLimitsX(rangeStart, rangeEnd); + ReapplyAxisColors(MemoryChart); + MemoryChart.Refresh(); + return; + } + + var times = data.Select(d => ViewerTimeHelper.ForDisplay(d.CollectionTime).ToOADate()).ToArray(); + var totalMem = data.Select(d => d.TotalServerMemoryMb / 1024.0).ToArray(); + var targetMem = data.Select(d => d.TargetServerMemoryMb / 1024.0).ToArray(); + var bufferPool = data.Select(d => d.BufferPoolMb / 1024.0).ToArray(); + + var totalPlot = MemoryChart.Plot.Add.Scatter(times, totalMem); + totalPlot.LegendText = "Total Server Memory"; + totalPlot.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("TotalServerMemory")); + ChartStyle.StyleScatter(totalPlot); + _memoryHover?.Add(totalPlot, "Total Server Memory"); + + var targetPlot = MemoryChart.Plot.Add.Scatter(times, targetMem); + targetPlot.LegendText = "Target Memory"; + targetPlot.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("TargetMemory")); + ChartStyle.StyleScatter(targetPlot); + targetPlot.LineStyle.Pattern = ScottPlot.LinePattern.Dashed; + _memoryHover?.Add(targetPlot, "Target Memory"); + + var bpPlot = MemoryChart.Plot.Add.Scatter(times, bufferPool); + bpPlot.LegendText = "Buffer Pool"; + bpPlot.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("BufferPool")); + ChartStyle.StyleScatter(bpPlot); + _memoryHover?.Add(bpPlot, "Buffer Pool"); + + /* Memory grants trend line — show zero line when no grant data */ + double[] grantTimes, grantMb; + if (grantData.Count > 0) + { + grantTimes = grantData.Select(d => ViewerTimeHelper.ForDisplay(d.CollectionTime).ToOADate()).ToArray(); + grantMb = grantData.Select(d => d.TotalGrantedMb / 1024.0).ToArray(); + } + else + { + grantTimes = new[] { times.First(), times.Last() }; + grantMb = new[] { 0.0, 0.0 }; + } + + var grantPlot = MemoryChart.Plot.Add.Scatter(grantTimes, grantMb); + grantPlot.LegendText = "Memory Grants"; + grantPlot.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("MemoryGrants")); + ChartStyle.StyleScatter(grantPlot); + _memoryHover?.Add(grantPlot, "Memory Grants"); + + MemoryChart.Plot.Axes.DateTimeTicksBottomDateChange(); + MemoryChart.Plot.Axes.SetLimitsX(rangeStart, rangeEnd); + ReapplyAxisColors(MemoryChart); + MemoryChart.Plot.YLabel("Memory (GB)"); + + var maxVal = totalMem.Max(); + SetChartYLimitsWithLegendPadding(MemoryChart, 0, maxVal); + + ShowChartLegend(MemoryChart); + MemoryChart.Refresh(); + } + + /// <summary> + /// The Memory Grants sub-tab's two charts — Lite's <c>UpdateMemoryGrantCharts</c>: sizing + /// (available/granted/used MB) and activity (grantees/waiters/timeouts/forced grants) per resource + /// pool, each pool×metric on a cycling <c>SeriesColors</c> color. Times run through + /// <see cref="ViewerTimeHelper.ForDisplay"/>. + /// </summary> + private void RenderMemoryGrantCharts(List<MemoryGrantChartPoint> data, DateTime startUtc, DateTime endUtc) + { + ClearChart(MemoryGrantSizingChart); + ClearChart(MemoryGrantActivityChart); + _memoryGrantSizingHover?.Clear(); + _memoryGrantActivityHover?.Clear(); + ApplyTheme(MemoryGrantSizingChart); + ApplyTheme(MemoryGrantActivityChart); + + var rangeStart = ViewerTimeHelper.ForDisplay(startUtc).ToOADate(); + var rangeEnd = ViewerTimeHelper.ForDisplay(endUtc).ToOADate(); + + if (data.Count == 0) + { + foreach (var c in new[] { MemoryGrantSizingChart, MemoryGrantActivityChart }) + { + c.Plot.Axes.DateTimeTicksBottomDateChange(); + c.Plot.Axes.SetLimitsX(rangeStart, rangeEnd); + ReapplyAxisColors(c); + c.Refresh(); + } + return; + } + + var poolIds = data.Select(d => d.PoolId).Distinct().OrderBy(p => p).ToList(); + int colorIndex = 0; + + /* Chart 1: Memory Grant Sizing — Available, Granted, Used MB per pool, plus the workspace-memory + ceiling (Target / Max Target MB) as dashed lines so the granted-vs-ceiling headroom is visible + (the resource-semaphore signal the Dashboard's get_resource_semaphore exposes). IsCeiling marks + the two dashed ceiling series. */ + double sizingMax = 0; + var sizingMetrics = new (string Name, Func<MemoryGrantChartPoint, double> Selector, bool IsCeiling)[] + { + ("Available MB", d => d.AvailableMemoryMb, false), + ("Granted MB", d => d.GrantedMemoryMb, false), + ("Used MB", d => d.UsedMemoryMb, false), + ("Target MB", d => d.TargetMemoryMb, true), + ("Max Target MB", d => d.MaxTargetMemoryMb, true) + }; + + foreach (var poolId in poolIds) + { + var poolData = data.Where(d => d.PoolId == poolId).OrderBy(d => d.CollectionTime).ToList(); + var times = poolData.Select(d => ViewerTimeHelper.ForDisplay(d.CollectionTime).ToOADate()).ToArray(); + + foreach (var metric in sizingMetrics) + { + var values = poolData.Select(d => metric.Selector(d)).ToArray(); + var plot = MemoryGrantSizingChart.Plot.Add.Scatter(times, values); + var label = $"Pool {poolId}: {metric.Name}"; + plot.LegendText = label; + plot.Color = ScottPlot.Color.FromHex(SeriesColors[colorIndex % SeriesColors.Length]); + ChartStyle.StyleScatter(plot); + /* Ceiling lines dashed so they read as a limit, not another measured series (mirrors the + Memory Overview chart's dashed Target Memory line). */ + if (metric.IsCeiling) plot.LineStyle.Pattern = ScottPlot.LinePattern.Dashed; + _memoryGrantSizingHover?.Add(plot, label); + if (values.Length > 0) sizingMax = Math.Max(sizingMax, values.Max()); + colorIndex++; + } + } + + MemoryGrantSizingChart.Plot.Axes.DateTimeTicksBottomDateChange(); + MemoryGrantSizingChart.Plot.Axes.SetLimitsX(rangeStart, rangeEnd); + ReapplyAxisColors(MemoryGrantSizingChart); + MemoryGrantSizingChart.Plot.YLabel("Memory (MB)"); + SetChartYLimitsWithLegendPadding(MemoryGrantSizingChart, 0, sizingMax > 0 ? sizingMax : 100); + ShowChartLegend(MemoryGrantSizingChart); + MemoryGrantSizingChart.Refresh(); + + /* Chart 2: Memory Grant Activity — Grantees, Waiters, Timeouts, Forced per pool */ + double activityMax = 0; + colorIndex = 0; + var activityMetrics = new (string Name, Func<MemoryGrantChartPoint, double> Selector)[] + { + ("Grantees", d => d.GranteeCount), + ("Waiters", d => d.WaiterCount), + ("Timeouts", d => d.TimeoutErrorCountDelta), + ("Forced Grants", d => d.ForcedGrantCountDelta) + }; + + foreach (var poolId in poolIds) + { + var poolData = data.Where(d => d.PoolId == poolId).OrderBy(d => d.CollectionTime).ToList(); + var times = poolData.Select(d => ViewerTimeHelper.ForDisplay(d.CollectionTime).ToOADate()).ToArray(); + + foreach (var metric in activityMetrics) + { + var values = poolData.Select(d => metric.Selector(d)).ToArray(); + var plot = MemoryGrantActivityChart.Plot.Add.Scatter(times, values); + var label = $"Pool {poolId}: {metric.Name}"; + plot.LegendText = label; + plot.Color = ScottPlot.Color.FromHex(SeriesColors[colorIndex % SeriesColors.Length]); + ChartStyle.StyleScatter(plot); + _memoryGrantActivityHover?.Add(plot, label); + if (values.Length > 0) activityMax = Math.Max(activityMax, values.Max()); + colorIndex++; + } + } + + MemoryGrantActivityChart.Plot.Axes.DateTimeTicksBottomDateChange(); + MemoryGrantActivityChart.Plot.Axes.SetLimitsX(rangeStart, rangeEnd); + ReapplyAxisColors(MemoryGrantActivityChart); + MemoryGrantActivityChart.Plot.YLabel("Count"); + SetChartYLimitsWithLegendPadding(MemoryGrantActivityChart, 0, activityMax > 0 ? activityMax : 10); + ShowChartLegend(MemoryGrantActivityChart); + MemoryGrantActivityChart.Refresh(); + } + + /// <summary> + /// The Memory Pressure Events sub-tab — Lite's <c>UpdateMemoryPressureEventsChart</c>: hour-bucketed + /// stacked bars of pressure events, SQL Server (process) vs OS (system) side-by-side and stacked by + /// severity (medium = indicator 2, severe = indicator ≥ 3). Bar geometry copied verbatim from Lite; + /// the X range is the toolbar's settable window and every bar/bound runs through + /// <see cref="ViewerTimeHelper.ForDisplay"/> (see the class remarks on pressure sample_time). + /// </summary> + private void RenderMemoryPressureEventsChart(List<MemoryPressureEventRow> data) + { + ClearChart(MemoryPressureEventsChart); + _memoryPressureEventsHover?.Clear(); + ApplyTheme(MemoryPressureEventsChart); + + var (startUtc, endUtc) = GetWindowUtc(); + double xMin = ViewerTimeHelper.ForDisplay(startUtc).ToOADate(); + double xMax = ViewerTimeHelper.ForDisplay(endUtc).ToOADate(); + + /* Only count rows where SQL Server reported actual pressure (indicator >= 2 matches sp_pressuredetector). */ + var pressureRows = data + .Where(d => d.MemoryIndicatorsProcess >= 2 || d.MemoryIndicatorsSystem >= 2) + .OrderBy(d => d.SampleTime) + .ToList(); + + bool hasData = false; + int maxBarCount = 0; + + if (pressureRows.Count > 0) + { + var grouped = pressureRows + .GroupBy(d => new DateTime(d.SampleTime.Year, d.SampleTime.Month, d.SampleTime.Day, d.SampleTime.Hour, 0, 0)) + .OrderBy(g => g.Key) + .ToList(); + + double hourWidth = 1.0 / 24.0; + double barSize = hourWidth * 0.4; + double barOffset = hourWidth * 0.22; + + var sqlMediumColor = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("SqlPressureMedium")); + var sqlSevereColor = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("SqlPressureSevere")); + var osMediumColor = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("OsPressureMedium")); + var osSevereColor = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("OsPressureSevere")); + + var sqlMediumBars = new List<ScottPlot.Bar>(); + var sqlSevereBars = new List<ScottPlot.Bar>(); + var osMediumBars = new List<ScottPlot.Bar>(); + var osSevereBars = new List<ScottPlot.Bar>(); + + foreach (var g in grouped) + { + int sqlMedium = g.Count(d => d.MemoryIndicatorsProcess == 2); + int sqlSevere = g.Count(d => d.MemoryIndicatorsProcess >= 3); + int osMedium = g.Count(d => d.MemoryIndicatorsSystem == 2); + int osSevere = g.Count(d => d.MemoryIndicatorsSystem >= 3); + double x = ViewerTimeHelper.ForDisplay(g.Key).ToOADate(); + + if (sqlMedium > 0) + sqlMediumBars.Add(new ScottPlot.Bar { Position = x - barOffset, ValueBase = 0, Value = sqlMedium, Size = barSize, FillColor = sqlMediumColor, LineWidth = 0 }); + if (sqlSevere > 0) + sqlSevereBars.Add(new ScottPlot.Bar { Position = x - barOffset, ValueBase = sqlMedium, Value = sqlMedium + sqlSevere, Size = barSize, FillColor = sqlSevereColor, LineWidth = 0 }); + if (osMedium > 0) + osMediumBars.Add(new ScottPlot.Bar { Position = x + barOffset, ValueBase = 0, Value = osMedium, Size = barSize, FillColor = osMediumColor, LineWidth = 0 }); + if (osSevere > 0) + osSevereBars.Add(new ScottPlot.Bar { Position = x + barOffset, ValueBase = osMedium, Value = osMedium + osSevere, Size = barSize, FillColor = osSevereColor, LineWidth = 0 }); + + int sqlTotal = sqlMedium + sqlSevere; + int osTotal = osMedium + osSevere; + if (sqlTotal > maxBarCount) maxBarCount = sqlTotal; + if (osTotal > maxBarCount) maxBarCount = osTotal; + } + + if (sqlMediumBars.Count > 0 || sqlSevereBars.Count > 0 || osMediumBars.Count > 0 || osSevereBars.Count > 0) + { + hasData = true; + + if (sqlMediumBars.Count > 0) + { + var bp = MemoryPressureEventsChart.Plot.Add.Bars(sqlMediumBars); + bp.LegendText = "SQL Server (medium)"; + _memoryPressureEventsHover?.Add(bp, "SQL Server (medium)"); + } + if (sqlSevereBars.Count > 0) + { + var bp = MemoryPressureEventsChart.Plot.Add.Bars(sqlSevereBars); + bp.LegendText = "SQL Server (severe)"; + _memoryPressureEventsHover?.Add(bp, "SQL Server (severe)"); + } + if (osMediumBars.Count > 0) + { + var bp = MemoryPressureEventsChart.Plot.Add.Bars(osMediumBars); + bp.LegendText = "Operating System (medium)"; + _memoryPressureEventsHover?.Add(bp, "Operating System (medium)"); + } + if (osSevereBars.Count > 0) + { + var bp = MemoryPressureEventsChart.Plot.Add.Bars(osSevereBars); + bp.LegendText = "Operating System (severe)"; + _memoryPressureEventsHover?.Add(bp, "Operating System (severe)"); + } + } + } + + MemoryPressureEventsChart.Plot.Axes.DateTimeTicksBottomDateChange(); + MemoryPressureEventsChart.Plot.Axes.SetLimitsX(xMin, xMax); + ReapplyAxisColors(MemoryPressureEventsChart); + MemoryPressureEventsChart.Plot.YLabel("Pressure Events per Hour"); + SetChartYLimitsWithLegendPadding(MemoryPressureEventsChart, 0, Math.Max(maxBarCount, 5)); + + if (hasData) + { + ShowChartLegend(MemoryPressureEventsChart); + } + + MemoryPressureEventsChart.Refresh(); + } + + /* ========== Memory Clerks Picker (mirrors ViewerServerTab.Perfmon.cs) ========== */ + + private void PopulateMemoryClerkPicker(List<string> clerkTypes) + { + var previouslySelected = new HashSet<string>(_memoryClerkItems.Where(i => i.IsSelected).Select(i => i.DisplayName)); + var topClerks = previouslySelected.Count == 0 ? new HashSet<string>(clerkTypes.Take(5)) : null; + _memoryClerkItems = clerkTypes.Select(c => new SelectableItem + { + DisplayName = c, + IsSelected = previouslySelected.Contains(c) || (topClerks != null && topClerks.Contains(c)) + }).ToList(); + RefreshMemoryClerkListOrder(); + } + + private void RefreshMemoryClerkListOrder() + { + if (_memoryClerkItems == null) return; + _memoryClerkItems = _memoryClerkItems + .OrderByDescending(x => x.IsSelected) + .ThenBy(x => x.DisplayName) + .ToList(); + ApplyMemoryClerkFilter(); + UpdateMemoryClerkCount(); + } + + private void UpdateMemoryClerkCount() + { + if (_memoryClerkItems == null || MemoryClerkCountText == null) return; + int count = _memoryClerkItems.Count(x => x.IsSelected); + MemoryClerkCountText.Text = $"{count} selected"; + } + + private void ApplyMemoryClerkFilter() + { + var search = MemoryClerkSearchBox?.Text?.Trim() ?? ""; + MemoryClerksList.ItemsSource = null; + if (string.IsNullOrEmpty(search)) + MemoryClerksList.ItemsSource = _memoryClerkItems; + else + MemoryClerksList.ItemsSource = _memoryClerkItems.Where(i => i.DisplayName.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + private void MemoryClerkSearch_TextChanged(object sender, TextChangedEventArgs e) => ApplyMemoryClerkFilter(); + + private void MemoryClerkSelectTop_Click(object sender, RoutedEventArgs e) + { + _isUpdatingMemoryClerkSelection = true; + var topClerks = new HashSet<string>(_memoryClerkItems.Take(5).Select(x => x.DisplayName)); + foreach (var item in _memoryClerkItems) + { + item.IsSelected = topClerks.Contains(item.DisplayName); + } + _isUpdatingMemoryClerkSelection = false; + RefreshMemoryClerkListOrder(); + _ = UpdateMemoryClerksChartFromPickerAsync(); + } + + private void MemoryClerkClearAll_Click(object sender, RoutedEventArgs e) + { + _isUpdatingMemoryClerkSelection = true; + var visible = (MemoryClerksList.ItemsSource as IEnumerable<SelectableItem>)?.ToList() ?? _memoryClerkItems; + foreach (var item in visible) item.IsSelected = false; + _isUpdatingMemoryClerkSelection = false; + RefreshMemoryClerkListOrder(); + _ = UpdateMemoryClerksChartFromPickerAsync(); + } + + private void MemoryClerk_CheckChanged(object sender, RoutedEventArgs e) + { + if (_isUpdatingMemoryClerkSelection) return; + RefreshMemoryClerkListOrder(); + _ = UpdateMemoryClerksChartFromPickerAsync(); + } + + /// <summary> + /// Redraws the Memory Clerks chart from the picker selection (Lite's + /// <c>UpdateMemoryClerksChartFromPickerAsync</c>): the batched trend for up to 20 selected clerks in + /// one query, each on a cycling <c>SeriesColors</c> color, plus the non-buffer-pool total + top-clerk + /// summary. The generation guard discards a stale run when the selection changes mid-query; the + /// toolbar's settable window matches the tab load (Lite's custom-range branch is dropped). + /// </summary> + private async Task UpdateMemoryClerksChartFromPickerAsync() + { + var gen = ++_memoryClerksPickerGen; + try + { + var selected = _memoryClerkItems.Where(i => i.IsSelected).Take(20).ToList(); + + ClearChart(MemoryClerksChart); + ApplyTheme(MemoryClerksChart); + _memoryClerksHover?.Clear(); + + var (startUtc, endUtc) = GetWindowUtc(); + double xMin = ViewerTimeHelper.ForDisplay(startUtc).ToOADate(); + double xMax = ViewerTimeHelper.ForDisplay(endUtc).ToOADate(); + + if (selected.Count == 0) + { + MemoryClerksTotalText.Text = "--"; + MemoryClerksTopText.Text = "--"; + MemoryClerksChart.Plot.Axes.DateTimeTicksBottomDateChange(); + MemoryClerksChart.Plot.Axes.SetLimitsX(xMin, xMax); + ReapplyAxisColors(MemoryClerksChart); + MemoryClerksChart.Refresh(); + return; + } + + double globalMax = 0; + double nonBpTotal = 0; + string topNonBpClerk = ""; + double topNonBpMb = 0; + + // Batched fetch: one query for all selected clerk types (was an N+1 query-per-clerk loop). + var trendsByType = await _dataService.GetMemoryClerkTrendsByTypesAsync( + _server.ServerId, selected.Select(s => s.DisplayName).ToList(), startUtc, endUtc); + if (gen != _memoryClerksPickerGen) return; + + for (int i = 0; i < selected.Count; i++) + { + if (!trendsByType.TryGetValue(selected[i].DisplayName, out var trend) || trend.Count == 0) continue; + + var times = trend.Select(t => ViewerTimeHelper.ForDisplay(t.CollectionTime).ToOADate()).ToArray(); + var values = trend.Select(t => t.MemoryMb).ToArray(); + + var plot = MemoryClerksChart.Plot.Add.Scatter(times, values); + plot.LegendText = selected[i].DisplayName; + plot.Color = ScottPlot.Color.FromHex(SeriesColors[i % SeriesColors.Length]); + ChartStyle.StyleScatter(plot); + _memoryClerksHover?.Add(plot, selected[i].DisplayName); + + if (values.Length > 0) globalMax = Math.Max(globalMax, values.Max()); + + /* Summary: use latest value, exclude buffer pool */ + var latestMb = values.Last(); + if (!selected[i].DisplayName.Contains("BUFFERPOOL", StringComparison.OrdinalIgnoreCase)) + { + nonBpTotal += latestMb; + if (latestMb > topNonBpMb) + { + topNonBpMb = latestMb; + topNonBpClerk = selected[i].DisplayName; + } + } + } + + MemoryClerksChart.Plot.Axes.DateTimeTicksBottomDateChange(); + MemoryClerksChart.Plot.Axes.SetLimitsX(xMin, xMax); + ReapplyAxisColors(MemoryClerksChart); + MemoryClerksChart.Plot.YLabel("Memory (MB)"); + SetChartYLimitsWithLegendPadding(MemoryClerksChart, 0, globalMax > 0 ? globalMax : 100); + ShowChartLegend(MemoryClerksChart); + MemoryClerksChart.Refresh(); + + /* Update summary panel */ + MemoryClerksTotalText.Text = nonBpTotal >= 1024 ? $"{nonBpTotal / 1024:F1} GB" : $"{nonBpTotal:N0} MB"; + if (!string.IsNullOrEmpty(topNonBpClerk)) + { + var name = topNonBpClerk; + if (name.StartsWith("MEMORYCLERK_", StringComparison.OrdinalIgnoreCase)) + name = name.Substring(12); + MemoryClerksTopText.Text = topNonBpMb >= 1024 ? $"{name} ({topNonBpMb / 1024:F1} GB)" : $"{name} ({topNonBpMb:N0} MB)"; + } + else + { + MemoryClerksTopText.Text = "--"; + } + } + catch + { + /* Ignore chart update errors */ + } + } + + /// <summary> + /// Tears down the five Memory hover helpers (mirrors Lite's dispose) so the tooltip popups and their + /// chart event handlers don't outlive a closed server tab. Called from the tab's single Dispose path + /// in ViewerServerTab.Charts.cs. + /// </summary> + public void DisposeMemoryHelpers() + { + _memoryHover?.Dispose(); + _memoryClerksHover?.Dispose(); + _memoryGrantSizingHover?.Dispose(); + _memoryGrantActivityHover?.Dispose(); + _memoryPressureEventsHover?.Dispose(); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Perfmon.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Perfmon.cs new file mode 100644 index 000000000..90453d372 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Perfmon.cs @@ -0,0 +1,249 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Perfmon inner tab — a COPY of Lite's perfmon picker + chart (ServerTab.Pickers.cs lines +/// 396-593 plus the <c>_defaultPerfmonCounters</c> seed at line 26), reads rewired to Postgres and +/// windowed on the per-server toolbar's settable range (preset or custom From/To). The picker behaviors are +/// preserved verbatim: the pack ComboBox sourced from the SHARED <see cref="PerfmonPacks"/> (the same +/// packs Lite/Dashboard use), the General-Throughput default selection, the 12-counter cap on pack +/// fills / "Select All", the checked-to-top ordering, the search filter, "Clear All" clearing only the +/// FILTERED visible set, the reentrancy guard on programmatic checkbox writes, and the +/// generation-guarded batched chart update with its 12-series cap plotting <c>DeltaValue</c>. Series +/// ride the shared cycling <see cref="ChartPalette"/> colors (via <c>SeriesColors</c>, declared in +/// ViewerServerTab.Charts.cs) and the <see cref="ChartStyle.StyleScatter"/> line polish, and the hover +/// helper carries Lite's empty unit. Lite's per-chart drill-down context menu is intentionally NOT +/// ported (the viewer has no drill-down surfaces yet). +/// </summary> +public partial class ViewerServerTab +{ + /* The default counter set is the shared "General Throughput" pack (mirrors Lite's + _defaultPerfmonCounters). */ + private static readonly HashSet<string> _defaultPerfmonCounters = new( + PerfmonPacks.Packs["General Throughput"], + StringComparer.OrdinalIgnoreCase); + + private List<SelectableItem> _perfmonCounterItems = new(); + private bool _isUpdatingPerfmonSelection; + private int _perfmonPickerGen; + private ChartHoverHelper? _perfmonHover; + + /// <summary> + /// The Perfmon tab load (mirrors Lite's <c>RefreshPerfmonAsync</c>): fetch the distinct counters + /// over the toolbar's settable window, (re)populate the picker preserving the current selection, and + /// redraw. The hover helper is created lazily on first load so the picker's chart carries the same + /// tooltip behavior Lite's does. + /// </summary> + private async Task LoadPerfmonAsync() + { + _perfmonHover ??= new ChartHoverHelper(PerfmonChart, ""); + + var (startUtc, endUtc) = GetWindowUtc(); + var counters = await _dataService.GetDistinctPerfmonCountersAsync(_server.ServerId, startUtc, endUtc); + PopulatePerfmonPicker(counters); + await UpdatePerfmonChartFromPickerAsync(); + } + + private void PopulatePerfmonPicker(List<string> counters) + { + /* Initialize pack ComboBox once */ + if (PerfmonPackCombo.Items.Count == 0) + { + PerfmonPackCombo.ItemsSource = PerfmonPacks.PackNames; + PerfmonPackCombo.SelectedItem = "General Throughput"; + } + + var previouslySelected = new HashSet<string>(_perfmonCounterItems.Where(i => i.IsSelected).Select(i => i.DisplayName)); + _perfmonCounterItems = counters.Select(c => new SelectableItem + { + DisplayName = c, + IsSelected = previouslySelected.Contains(c) + || (previouslySelected.Count == 0 && _defaultPerfmonCounters.Contains(c)) + }).ToList(); + RefreshPerfmonListOrder(); + } + + private void PerfmonPack_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_perfmonCounterItems == null || _perfmonCounterItems.Count == 0) return; + if (PerfmonPackCombo.SelectedItem is not string pack) return; + + _isUpdatingPerfmonSelection = true; + + /* Clear search so all counters are visible */ + if (PerfmonSearchBox != null) + PerfmonSearchBox.Text = ""; + + /* Uncheck everything first */ + foreach (var item in _perfmonCounterItems) + item.IsSelected = false; + + if (pack == PerfmonPacks.AllCounters) + { + /* "All Counters" selects the General Throughput defaults */ + foreach (var item in _perfmonCounterItems) + { + if (_defaultPerfmonCounters.Contains(item.DisplayName)) + item.IsSelected = true; + } + } + else if (PerfmonPacks.Packs.TryGetValue(pack, out var packCounters)) + { + var packSet = new HashSet<string>(packCounters, StringComparer.OrdinalIgnoreCase); + int count = 0; + foreach (var item in _perfmonCounterItems) + { + if (count >= 12) break; + if (packSet.Contains(item.DisplayName)) + { + item.IsSelected = true; + count++; + } + } + } + + _isUpdatingPerfmonSelection = false; + RefreshPerfmonListOrder(); + _ = UpdatePerfmonChartFromPickerAsync(); + } + + private void RefreshPerfmonListOrder() + { + if (_perfmonCounterItems == null) return; + _perfmonCounterItems = _perfmonCounterItems + .OrderByDescending(x => x.IsSelected) + .ThenBy(x => _perfmonCounterItems.IndexOf(x)) + .ToList(); + ApplyPerfmonFilter(); + } + + private void ApplyPerfmonFilter() + { + var search = PerfmonSearchBox?.Text?.Trim() ?? ""; + PerfmonCountersList.ItemsSource = null; + if (string.IsNullOrEmpty(search)) + PerfmonCountersList.ItemsSource = _perfmonCounterItems; + else + PerfmonCountersList.ItemsSource = _perfmonCounterItems.Where(i => i.DisplayName.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + private void PerfmonSearch_TextChanged(object sender, TextChangedEventArgs e) => ApplyPerfmonFilter(); + + private void PerfmonSelectAll_Click(object sender, RoutedEventArgs e) + { + _isUpdatingPerfmonSelection = true; + var visible = (PerfmonCountersList.ItemsSource as IEnumerable<SelectableItem>)?.ToList() ?? _perfmonCounterItems; + int count = visible.Count(i => i.IsSelected); + foreach (var item in visible) + { + if (!item.IsSelected && count < 12) + { + item.IsSelected = true; + count++; + } + } + _isUpdatingPerfmonSelection = false; + RefreshPerfmonListOrder(); + _ = UpdatePerfmonChartFromPickerAsync(); + } + + private void PerfmonClearAll_Click(object sender, RoutedEventArgs e) + { + _isUpdatingPerfmonSelection = true; + var visible = (PerfmonCountersList.ItemsSource as IEnumerable<SelectableItem>)?.ToList() ?? _perfmonCounterItems; + foreach (var item in visible) item.IsSelected = false; + _isUpdatingPerfmonSelection = false; + RefreshPerfmonListOrder(); + _ = UpdatePerfmonChartFromPickerAsync(); + } + + private void PerfmonCounter_CheckChanged(object sender, RoutedEventArgs e) + { + if (_isUpdatingPerfmonSelection) return; + RefreshPerfmonListOrder(); + _ = UpdatePerfmonChartFromPickerAsync(); + } + + private async Task UpdatePerfmonChartFromPickerAsync() + { + /* Bump a generation on entry; after each (now genuinely async) query, bail if a newer + invocation has started — rapid checkbox toggling otherwise interleaves two runs and + double-plots the series. */ + var gen = ++_perfmonPickerGen; + try + { + var selected = _perfmonCounterItems.Where(i => i.IsSelected).Take(12).ToList(); + + ClearChart(PerfmonChart); + _perfmonHover?.Clear(); + ApplyTheme(PerfmonChart); + + if (selected.Count == 0) { PerfmonChart.Refresh(); return; } + + /* The per-server toolbar's settable window (preset or custom From/To). The store is naive-UTC; + display converts via ViewerTimeHelper.ForDisplay. */ + var (startUtc, endUtc) = GetWindowUtc(); + double globalMax = 0; + + // Batched fetch: one query for all selected counters (mirrors Lite's batched read). + var trendsByCounter = await _dataService.GetPerfmonTrendsByCountersAsync( + _server.ServerId, selected.Select(s => s.DisplayName).ToList(), startUtc, endUtc); + if (gen != _perfmonPickerGen) return; + + for (int i = 0; i < selected.Count; i++) + { + if (!trendsByCounter.TryGetValue(selected[i].DisplayName, out var trend) || trend.Count == 0) continue; + + var times = trend.Select(t => ViewerTimeHelper.ForDisplay(t.CollectionTime).ToOADate()).ToArray(); + var values = trend.Select(t => (double)t.DeltaValue).ToArray(); + + var plot = PerfmonChart.Plot.Add.Scatter(times, values); + plot.LegendText = selected[i].DisplayName; + plot.Color = ScottPlot.Color.FromHex(SeriesColors[i % SeriesColors.Length]); + ChartStyle.StyleScatter(plot); + _perfmonHover?.Add(plot, selected[i].DisplayName); + + if (values.Length > 0) globalMax = Math.Max(globalMax, values.Max()); + } + + PerfmonChart.Plot.Axes.DateTimeTicksBottomDateChange(); + var rangeStart = ViewerTimeHelper.ForDisplay(startUtc); + var rangeEnd = ViewerTimeHelper.ForDisplay(endUtc); + PerfmonChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(PerfmonChart); + PerfmonChart.Plot.YLabel("Value"); + SetChartYLimitsWithLegendPadding(PerfmonChart, 0, globalMax > 0 ? globalMax : 100); + ShowChartLegend(PerfmonChart); + PerfmonChart.Refresh(); + } + catch + { + /* Ignore chart update errors */ + } + } + + /// <summary> + /// Tears down the perfmon hover helper (mirrors Lite's <c>DisposeChartHelpers</c>) so the tooltip + /// popup and its chart event handlers don't outlive a closed server tab. Called from the tab's + /// single Dispose path in ViewerServerTab.Charts.cs. + /// </summary> + public void DisposePerfmonHelpers() + { + _perfmonHover?.Dispose(); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.PlanCache.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.PlanCache.cs new file mode 100644 index 000000000..63db1fa2a --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.PlanCache.cs @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Plan Cache sub-tab (under the Memory tab, matching the Dashboard's Memory > Plan Cache) — the +/// Darling-viewer surface for the plan_cache_stats collector. The trend chart plots single-use vs +/// multi-use plan-cache size (MB) over the settable window (the single-use bloat signal, the Dashboard's +/// Plan Cache chart shape), and the summary strip shows total plans + oldest-plan age. Loaded from +/// <c>LoadMemoryAsync</c> alongside the other Memory sub-tabs (the Memory tab's full-refresh branch). Chart +/// chrome flows through +/// the shared <see cref="ChartStyle"/> / <see cref="ChartPalette"/> and the ChartHelpers bridge, so the +/// Y-floor-at-0 fix applies, and the two series ride the same palette keys the Dashboard uses for cross-app +/// color identity. +/// </summary> +public partial class ViewerServerTab +{ + private ChartHoverHelper? _planCacheHover; + + /// <summary>Applies the shared chrome + hover to the plan-cache chart up front (constructor).</summary> + private void InitializePlanCacheChart() + { + ApplyTheme(PlanCacheChart); + PlanCacheChart.Refresh(); + _planCacheHover = new ChartHoverHelper(PlanCacheChart, "MB"); + } + + /// <summary> + /// Loads the Plan Cache sub-tab over the toolbar's settable window: the size trend and the summary + /// totals read concurrently, then the chart and summary strip render. Called from + /// <see cref="LoadMemoryAsync"/> (the Memory tab loads all its sub-tabs on activation). + /// </summary> + private async Task LoadPlanCacheAsync() + { + var (startUtc, endUtc) = GetWindowUtc(); + + var trendTask = _dataService.GetPlanCacheTrendAsync(_server.ServerId, startUtc, endUtc); + /* The summary strip's totals come from a dedicated uncapped aggregate (not a capped grid), + so "Total Plans" is exact (Dashboard parity). */ + var summaryTask = _dataService.GetPlanCacheSummaryAsync(_server.ServerId, startUtc, endUtc); + await Task.WhenAll(trendTask, summaryTask); + + RenderPlanCacheChart(trendTask.Result); + RenderPlanCacheSummary(summaryTask.Result); + } + + private void RenderPlanCacheChart(List<PlanCacheTrendPoint> data) + { + ClearChart(PlanCacheChart); + _planCacheHover?.Clear(); + ApplyTheme(PlanCacheChart); + + var (startUtc, endUtc) = GetWindowUtc(); + PlanCacheChart.Plot.YLabel("Plan Cache Size (MB)"); + + double globalMax = 0; + if (data.Count > 0) + { + var times = data.Select(d => ViewerTimeHelper.ForDisplay(d.CollectionTime).ToOADate()).ToArray(); + + var singleUse = data.Select(d => d.SingleUseSizeMb).ToArray(); + var singlePlot = PlanCacheChart.Plot.Add.Scatter(times, singleUse); + singlePlot.LegendText = "Single-Use"; + singlePlot.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("SinglePagePlans")); + ChartStyle.StyleScatter(singlePlot); + _planCacheHover?.Add(singlePlot, "Single-Use"); + + var multiUse = data.Select(d => d.MultiUseSizeMb).ToArray(); + var multiPlot = PlanCacheChart.Plot.Add.Scatter(times, multiUse); + multiPlot.LegendText = "Multi-Use"; + multiPlot.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("MultiPagePlans")); + ChartStyle.StyleScatter(multiPlot); + _planCacheHover?.Add(multiPlot, "Multi-Use"); + + globalMax = Math.Max(singleUse.DefaultIfEmpty(0).Max(), multiUse.DefaultIfEmpty(0).Max()); + } + + PlanCacheChart.Plot.Axes.DateTimeTicksBottomDateChange(); + var rangeStart = ViewerTimeHelper.ForDisplay(startUtc); + var rangeEnd = ViewerTimeHelper.ForDisplay(endUtc); + PlanCacheChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(PlanCacheChart); + SetChartYLimitsWithLegendPadding(PlanCacheChart, 0, globalMax > 0 ? globalMax : 10); + ShowChartLegend(PlanCacheChart); + PlanCacheChart.Refresh(); + } + + /// <summary>The summary strip: TRUE total plans at the latest snapshot (uncapped, from the dedicated + /// aggregate) + the oldest cached plan's age (a plan-cache-stability signal — older = more stable), + /// mirroring the Dashboard's Plan Cache summary.</summary> + private void RenderPlanCacheSummary(PlanCacheSummary summary) + { + if (summary.TotalPlans <= 0 && summary.OldestPlanCreateTime is null) + { + PlanCacheTotalPlansText.Text = "--"; + PlanCacheOldestPlanText.Text = "--"; + PlanCacheBloatLevelText.Text = "--"; + PlanCacheBloatLevelText.Foreground = System.Windows.Media.Brushes.Gray; + PlanCacheRecommendationText.Text = ""; + return; + } + + /* Derived single-use bloat badge + forced-parameterization hint (install/47 report.plan_cache_bloat + parity): a pure client-side CASE on the single-use / total ratio Darling already reads. */ + var bloat = ViewerDataService.ClassifyPlanCacheBloat(summary.TotalPlans, summary.SingleUsePlans); + PlanCacheBloatLevelText.Text = bloat.Level; + PlanCacheBloatLevelText.Foreground = BloatLevelBrush(bloat.Level); + PlanCacheRecommendationText.Text = bloat.Recommendation; + + PlanCacheTotalPlansText.Text = summary.TotalPlans.ToString("N0", CultureInfo.CurrentCulture); + + /* oldest_plan_create_time comes from a DMV (sys.dm_exec_query_stats.creation_time) in the monitored + server's local clock, which the viewer — having no per-server offset — measures against UtcNow: + exact for a UTC server, off by the server's offset otherwise (the same approximation the viewer + already accepts for server-local sample_time; a reliable de-skew is deferred). Age is a coarse + d/h/m stability bucket, so a few hours' offset rarely changes the qualitative read. */ + if (summary.OldestPlanCreateTime is not { } oldest) + { + PlanCacheOldestPlanText.Text = "--"; + return; + } + + var age = DateTime.UtcNow - DateTime.SpecifyKind(oldest, DateTimeKind.Utc); + if (age < TimeSpan.Zero) age = TimeSpan.Zero; + PlanCacheOldestPlanText.Text = age.TotalDays >= 1 + ? $"{(int)age.TotalDays}d {age.Hours}h" + : age.TotalHours >= 1 + ? $"{age.Hours}h {age.Minutes}m" + : $"{age.Minutes}m"; + } + + /// <summary>Maps a plan-cache bloat level to its badge colour (CRITICAL red → HIGH orange → MEDIUM + /// goldenrod → NORMAL green), mirroring the severity palette the warning highlights use elsewhere.</summary> + private static System.Windows.Media.SolidColorBrush BloatLevelBrush(string level) + { + var hex = level switch + { + "CRITICAL" => "#FF6B6B", + "HIGH" => "#FFA94D", + "MEDIUM" => "#E0C341", + _ => "#4CAF50", + }; + var brush = new System.Windows.Media.SolidColorBrush( + (System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(hex)); + brush.Freeze(); + return brush; + } + + /// <summary>Tears down the plan-cache hover helper (mirrors the other tabs' dispose).</summary> + public void DisposePlanCacheHelpers() + { + _planCacheHover?.Dispose(); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Plans.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Plans.cs new file mode 100644 index 000000000..4b3089787 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Plans.cs @@ -0,0 +1,308 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using Microsoft.Win32; +using PerformanceMonitor.Ui; +using static PerformanceMonitor.Ui.FileSaveHelper; +using static PerformanceMonitor.Ui.DataGridHelpers; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Plan Viewer host (headless-plan wave), copied from Lite's <c>ServerTab.Plans.cs</c>: hosts the +/// shared <see cref="PlanViewerControl"/> as closable sub-tabs inside the "Plan Viewer" inner tab. The +/// single seam-difference from Lite is the plan SOURCE — Lite fetches plans live from the monitored +/// server's plan cache (or its DuckDB cache); the viewer never touches SQL Server, so it reads the plan +/// text the Darling service already stored in Postgres (<see cref="ViewerDataService.GetQueryStatsPlanXmlAsync"/> +/// / <see cref="ViewerDataService.GetQueryStorePlanTextAsync"/>). Consequently Lite's "Get Actual Plan" +/// (live execution) has no viewer equivalent and is omitted everywhere; only stored plans are shown. +/// +/// <para><see cref="OpenPlanTab"/> is the shared entry point every "View Plan" surface calls: the keyed +/// Top Queries / Query Store / Top Procedures context menus here (fetched on click by +/// <see cref="ViewPlan_Click"/>), the FinOps High Impact / Expensive Queries menus, the Active Queries +/// snapshot grid, and the blocked-process / deadlock grids — the last three already carry their plan XML +/// in-row. It parses the plan off the UI thread, adds a closable sub-tab, and focuses the host.</para> +/// </summary> +public partial class ViewerServerTab +{ + /* Cancels a slow stored-plan fetch/parse (a large TOASTed plan over the wire) — the CancelPlanButton + in the loading overlay drives it. There is no live-execution path to cancel, unlike Lite. */ + private CancellationTokenSource? _planLoadCts; + + /// <summary> + /// Opens a plan as a closable sub-tab in the Plan Viewer host (Lite's <c>OpenPlanTab</c> verbatim, plus + /// focusing the host tab so callers don't each have to). <see cref="PlanViewerControl.LoadPlan"/> parses + /// + analyzes off the UI thread and throws <see cref="System.Xml.XmlException"/> for malformed plan XML. + /// This is the hook the parallel Queries wave calls for its Active Queries snapshots + /// (<c>OpenPlanTab(planXml, label, queryText)</c>). + /// </summary> + private async void OpenPlanTab(string planXml, string label, string? queryText = null) + { + HidePlanLoading(); + var viewer = new PlanViewerControl(); + try + { + await viewer.LoadPlan(planXml, label, queryText); + } + catch (System.Xml.XmlException ex) + { + viewer.Cleanup(); + MessageBox.Show( + $"The plan XML is not valid:\n\n{ex.Message}", + "Invalid Plan XML", + MessageBoxButton.OK, + MessageBoxImage.Warning); + return; + } + catch (Exception ex) + { + viewer.Cleanup(); + MessageBox.Show( + $"Failed to load the execution plan:\n\n{ex.Message}", + "Plan Load Error", + MessageBoxButton.OK, + MessageBoxImage.Warning); + return; + } + + var header = new StackPanel { Orientation = System.Windows.Controls.Orientation.Horizontal }; + header.Children.Add(new TextBlock + { + Text = label.Length > 30 ? label[..30] + "…" : label, + VerticalAlignment = System.Windows.VerticalAlignment.Center, + ToolTip = label + }); + var closeBtn = new Button + { + Style = (Style)FindResource("TabCloseButton") + }; + header.Children.Add(closeBtn); + + var tab = new TabItem { Header = header, Content = viewer }; + closeBtn.Tag = tab; + closeBtn.Click += ClosePlanTab_Click; + + PlanTabControl.Items.Add(tab); + PlanTabControl.SelectedItem = tab; + PlanEmptyState.Visibility = Visibility.Collapsed; + PlanTabControl.Visibility = Visibility.Visible; + PlanViewerTabItem.IsSelected = true; + } + + private void ClosePlanTab_Click(object sender, RoutedEventArgs e) + { + if (sender is Button btn && btn.Tag is TabItem tab) + { + (tab.Content as PlanViewerControl)?.Cleanup(); + PlanTabControl.Items.Remove(tab); + if (PlanTabControl.Items.Count == 0) + { + PlanTabControl.Visibility = Visibility.Collapsed; + PlanEmptyState.Visibility = Visibility.Visible; + } + } + } + + private void ShowPlanLoading(string label) + { + PlanLoadingLabel.Text = $"Loading plan: {label}"; + PlanEmptyState.Visibility = Visibility.Collapsed; + PlanTabControl.Visibility = Visibility.Collapsed; + PlanLoadingState.Visibility = Visibility.Visible; + PlanViewerTabItem.IsSelected = true; + } + + private void HidePlanLoading() + { + PlanLoadingState.Visibility = Visibility.Collapsed; + if (PlanTabControl.Items.Count > 0) + PlanTabControl.Visibility = Visibility.Visible; + else + PlanEmptyState.Visibility = Visibility.Visible; + } + + private void CancelPlanButton_Click(object sender, RoutedEventArgs e) => _planLoadCts?.Cancel(); + + /// <summary> + /// The "View Plan" context-menu handler for the Top Queries + Query Store + Top Procedures grids: + /// fetches the stored plan by the row's key columns (behind the loading overlay), then opens it via + /// <see cref="OpenPlanTab"/>. Top Procedures now reaches here too — the procedure_stats collector DOES + /// store the plan (query_plan_xml, cleanly captured from the cached plan since #1368 / migration V7, + /// gated on the host's CapturePlanXml — Darling sets it, Lite writes NULL); it is fetched by object + /// identity, exactly like the query-stats key. Comparison grids stay plan-less (a current-vs-baseline + /// aggregate has no single stored plan keyed on the row). + /// </summary> + private async void ViewPlan_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem) return; + var grid = FindParentDataGrid(menuItem); + if (grid?.CurrentItem == null) return; + + string label; + string? queryText; + Func<CancellationToken, Task<string?>> fetch; + + switch (grid.CurrentItem) + { + case ViewerQueryStatsRow stats: + if (string.IsNullOrEmpty(stats.QueryHash)) return; + label = $"Stored Plan - {stats.QueryHash}"; + queryText = stats.QueryText; + fetch = ct => _dataService.GetQueryStatsPlanXmlAsync(_server.ServerId, stats.DatabaseName, stats.QueryHash, ct); + break; + case ViewerQueryStoreRow qs: + label = $"Stored Plan - QS {qs.QueryId}"; + queryText = qs.QueryText; + fetch = ct => _dataService.GetQueryStorePlanTextAsync(_server.ServerId, qs.DatabaseName, qs.QueryId, qs.PlanId, ct); + break; + case ViewerProcedureStatsRow proc: + if (string.IsNullOrEmpty(proc.ObjectName)) return; + label = $"Stored Plan - {proc.FullName}"; + queryText = null; /* the procedure grid carries no statement text; the plan XML holds its own */ + fetch = ct => _dataService.GetProcedureStatsPlanXmlAsync(_server.ServerId, proc.DatabaseName, proc.SchemaName, proc.ObjectName, ct); + break; + default: + /* Comparison items / anything else: no stored plan keyed on the row — deferred. */ + return; + } + + ShowPlanLoading(label); + _planLoadCts?.Dispose(); + _planLoadCts = new CancellationTokenSource(); + + try + { + var planXml = await fetch(_planLoadCts.Token); + if (string.IsNullOrEmpty(planXml)) + { + HidePlanLoading(); + MessageBox.Show( + "No execution plan was captured for this row. The plan may not have been collected yet, " + + "or it aged out of the store's retention window.", + "No Plan Available", + MessageBoxButton.OK, + MessageBoxImage.Information); + return; + } + + OpenPlanTab(planXml, label, queryText); + } + catch (OperationCanceledException) + { + HidePlanLoading(); + } + catch (Exception ex) + { + HidePlanLoading(); + MessageBox.Show( + $"Failed to load the execution plan:\n\n{ex.Message}", + "Plan Load Error", + MessageBoxButton.OK, + MessageBoxImage.Error); + } + } + + /// <summary> + /// The Top Queries grid's "Query Plan" Download button (mirrors Lite's <c>DownloadQueryStatsPlan_Click</c>): + /// reads the stored query_plan_xml for the row and saves it as a .sqlplan file. The button is gated on + /// <see cref="ViewerQueryStatsRow.HasQueryPlan"/> in XAML, so it only fires when a plan was captured. + /// </summary> + private async void DownloadQueryStatsPlan_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button btn || btn.DataContext is not ViewerQueryStatsRow row) return; + if (string.IsNullOrEmpty(row.QueryHash)) return; + + btn.Content = "..."; + try + { + var plan = await _dataService.GetQueryStatsPlanXmlAsync(_server.ServerId, row.DatabaseName, row.QueryHash); + if (string.IsNullOrEmpty(plan)) + { + MessageBox.Show( + "No execution plan was captured for this query.", + "Plan Not Found", + MessageBoxButton.OK, + MessageBoxImage.Information); + return; + } + + SavePlanFile(plan, $"QueryPlan_{row.QueryHash}"); + } + catch (Exception ex) + { + MessageBox.Show($"Failed to retrieve plan: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + btn.Content = "Download"; + } + } + + /// <summary> + /// The Top Procedures grid's "Query Plan" Download button (mirrors <see cref="DownloadQueryStatsPlan_Click"/> + /// and Lite's <c>DownloadProcedurePlan_Click</c>): reads the stored procedure_stats.query_plan_xml for the + /// row's object identity and saves it as a .sqlplan file. Gated on + /// <see cref="ViewerProcedureStatsRow.HasQueryPlan"/> in XAML, so it only fires when a plan was captured. + /// </summary> + private async void DownloadProcedureStatsPlan_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button btn || btn.DataContext is not ViewerProcedureStatsRow row) return; + if (string.IsNullOrEmpty(row.ObjectName)) return; + + btn.Content = "..."; + try + { + var plan = await _dataService.GetProcedureStatsPlanXmlAsync(_server.ServerId, row.DatabaseName, row.SchemaName, row.ObjectName); + if (string.IsNullOrEmpty(plan)) + { + MessageBox.Show( + "No execution plan was captured for this procedure.", + "Plan Not Found", + MessageBoxButton.OK, + MessageBoxImage.Information); + return; + } + + SavePlanFile(plan, $"ProcedurePlan_{row.FullName}"); + } + catch (Exception ex) + { + MessageBox.Show($"Failed to retrieve plan: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + btn.Content = "Download"; + } + } + + /// <summary> + /// Cleans up every open PlanViewerControl when the server tab closes (mirrors Lite's DisposeChartHelpers + /// plan cleanup): each PlanViewerControl subscribes the static ThemeManager.ThemeChanged event, so a + /// closed server tab with plan tabs still open would leak them — <see cref="ClosePlanTab_Click"/> only + /// covers the per-tab close-button path. Forwarded from <c>Dispose()</c>. + /// </summary> + private void DisposePlanHelpers() + { + _planLoadCts?.Cancel(); + _planLoadCts?.Dispose(); + foreach (var item in PlanTabControl.Items) + { + if (item is TabItem { Content: PlanViewerControl pv }) + { + pv.Cleanup(); + } + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs new file mode 100644 index 000000000..b851c346a --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs @@ -0,0 +1,427 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Threading.Tasks; +using System.Windows.Controls; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Queries inner tab's sub-tab dispatch — the six-sub-tab group matching Lite's +/// <c>QueriesSubTabControl</c> exactly: Performance Trends and Active Queries (W1f-2), the Top Queries / +/// Top Procedures / Query Store grids (W1f-1), then Query Heatmap last (W1f-2). Copied from Lite's +/// <c>ServerTab</c> (Refresh / Slicers / Grids partials) with reads rewired to the +/// <see cref="ViewerDataService"/> Postgres reads. A sub-tab switch reloads through the shell's +/// overlap-guarded <see cref="RefreshActiveInnerTabAsync"/> (the Queries tab is the active inner tab +/// whenever its sub-tabs are visible), and <see cref="LoadQueriesAsync"/> loads only the newly-visible +/// sub-tab (Lite's subTabOnly gating) over the toolbar's settable window. The three grids each carry a UTC +/// time-range slicer whose drag re-reads over the selection; sorting a grid re-labels the slicer's +/// aggregate curve (Lite's *Grid_Sorting). The Performance Trends charts / Active Queries grid+slicer / +/// Query Heatmap live in their own partials (<c>ViewerServerTab.QueryTrends.cs</c> / +/// <c>.ActiveQueries.cs</c> / <c>.QueryHeatmap.cs</c>). The grids also carry the slicer overlay-on-select +/// (#1409, <c>ViewerServerTab.SlicerOverlay.cs</c>) and the per-row double-click history windows +/// (<c>ViewerServerTab.History.cs</c>), so all four Lite interactions — drag-to-narrow, sort-driven metric +/// re-labeling, overlay-on-select, and double-click-for-history — are wired. +/// </summary> +public partial class ViewerServerTab +{ + /* Queries sub-tab order — matches Lite's QueriesSubTabControl (W1f-2), keeping Query Heatmap last, the + Darling-only LIVE "Current Active Queries" tab inserted right after the stored "Active Queries" tab, + and the Query Store Regressions grid (Dashboard parity) inserted right after Query Store — matching + the Dashboard's Query Store → Query Store Regressions adjacency: Performance Trends, Active Queries, + Current Active Queries (live), the three grids, Query Store Regressions, Query Heatmap. Every + reference below uses the NAMED constant, so inserting a tab only shifts these values — no literal-index + caller needs touching. */ + private const int PerformanceTrendsSubTabIndex = 0; + private const int ActiveQueriesSubTabIndex = 1; + private const int CurrentActiveQueriesSubTabIndex = 2; + private const int TopQueriesSubTabIndex = 3; + private const int TopProceduresSubTabIndex = 4; + private const int QueryStoreSubTabIndex = 5; + private const int QueryStoreRegressionsSubTabIndex = 6; + private const int QueryHeatmapSubTabIndex = 7; + + private string _queryStatsSlicerMetric = "TotalCpu"; + private List<TimeSliceBucket>? _queryStatsSlicerData; + private string _procStatsSlicerMetric = "TotalCpu"; + private List<TimeSliceBucket>? _procStatsSlicerData; + private string _queryStoreSlicerMetric = "TotalCpu"; + private List<TimeSliceBucket>? _queryStoreSlicerData; + + /// <summary> + /// Wires the Queries tab up-front (from the constructor, after InitializeComponent): the three grids' + /// in-grid bar-cell maxima recompute hook (<see cref="SetupBarCellMaxes"/>) and each slicer's + /// RangeChanged handler (dragging a slicer re-reads its grid over the selection). + /// </summary> + private void InitializeQueriesTab() + { + SetupBarCellMaxes(); + + QueryStatsSlicer.RangeChanged += OnQueryStatsSlicerChanged; + ProcStatsSlicer.RangeChanged += OnProcStatsSlicerChanged; + QueryStoreSlicer.RangeChanged += OnQueryStoreSlicerChanged; + + /* W1f-2 sub-tabs: theme the trend + heatmap charts up front, build the heatmap hover popup + + drill-down menu, and wire the Active Queries slicer (each in its own partial). */ + InitializeQueryTrendCharts(); + InitializeActiveQueriesTab(); + InitializeQueryHeatmap(); + + /* Default sub-tab is Performance Trends (no comparison), so start the Compare combo disabled. */ + UpdateCompareDropdownState(); + } + + /// <summary>Tears down the W1f-2 sub-tabs' chart hover helpers — forwarded from the tab's single + /// <see cref="Dispose"/> so the whole tab tears down through one path.</summary> + private void DisposeQueriesTabHelpers() + { + DisposeQueryTrendHelpers(); + } + + /// <summary> + /// A Queries sub-tab switch reloads through the shell's overlap-guarded + /// <see cref="RefreshActiveInnerTabAsync"/> (mirrors <see cref="BlockingSubTabs_SelectionChanged"/>). + /// Gated on <see cref="System.Windows.FrameworkElement.IsLoaded"/> and the sub-TabControl's own + /// selection so build-time and bubbled selections (the child grids, the Compare combo) are ignored. + /// </summary> + private async void QueriesSubTabs_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (!ReferenceEquals(e.OriginalSource, QueriesSubTabControl) || !IsLoaded) + { + return; + } + + /* Comparison applies only to the three grid sub-tabs; disable the Compare combo elsewhere. */ + UpdateCompareDropdownState(); + + /* A drill-down (heatmap / per-chart / Overview lane) switches to Active Queries programmatically and + loads its own filtered snapshot; skip the auto-refresh so it doesn't clobber that via an async race + (the guard is set/cleared around the tab switches in NavigateToActiveQueriesForWindowAsync). */ + if (_suppressDrillDownAutoRefresh) + { + return; + } + + await RefreshActiveInnerTabAsync(); + } + + /// <summary> + /// Loads the Queries tab's ACTIVE sub-tab only (Lite's subTabOnly gating): Top Queries / Top + /// Procedures / Query Store each read their grid + slicer + comparison over the toolbar's settable window. + /// The shell's <see cref="LoadInnerTabAsync"/> owns the try/catch that surfaces failures. + /// </summary> + private async Task LoadQueriesAsync() + { + var (startUtc, endUtc) = GetWindowUtc(); + + switch (QueriesSubTabControl.SelectedIndex) + { + case PerformanceTrendsSubTabIndex: + await LoadPerformanceTrendsAsync(startUtc, endUtc); + break; + case ActiveQueriesSubTabIndex: + await LoadActiveQueriesAsync(startUtc, endUtc); + break; + case CurrentActiveQueriesSubTabIndex: + /* LIVE, on-demand only — never auto-fetch on tab selection or a toolbar-range refresh (a live + server hit is an explicit operator action). The Refresh button drives the fetch; the tab keeps + its last snapshot / hint until then. */ + break; + case TopProceduresSubTabIndex: + await LoadTopProceduresAsync(startUtc, endUtc); + break; + case QueryStoreSubTabIndex: + await LoadQueryStoreAsync(startUtc, endUtc); + break; + case QueryStoreRegressionsSubTabIndex: + await LoadQueryStoreRegressionsAsync(startUtc, endUtc); + break; + case QueryHeatmapSubTabIndex: + await LoadQueryHeatmapAsync(startUtc, endUtc); + break; + case TopQueriesSubTabIndex: + default: + await LoadTopQueriesAsync(startUtc, endUtc); + break; + } + } + + private async Task LoadTopQueriesAsync(DateTime startUtc, DateTime endUtc) + { + var rows = await _dataService.GetTopQueriesByCpuAsync(_server.ServerId, startUtc, endUtc, databaseNames: SelectedDatabaseFilter); + _queryStatsFilterMgr!.UpdateData(rows); + SetDefaultSortIfNone(QueryStatsGrid, "TotalElapsedMs", ListSortDirection.Descending); + await LoadQueryStatsSlicerAsync(startUtc, endUtc); + await RefreshQueryStatsComparisonAsync(startUtc, endUtc); + } + + private async Task LoadTopProceduresAsync(DateTime startUtc, DateTime endUtc) + { + var rows = await _dataService.GetTopProceduresByCpuAsync(_server.ServerId, startUtc, endUtc, databaseNames: SelectedDatabaseFilter); + _procStatsFilterMgr!.UpdateData(rows); + SetDefaultSortIfNone(ProcedureStatsGrid, "TotalElapsedMs", ListSortDirection.Descending); + await LoadProcStatsSlicerAsync(startUtc, endUtc); + await RefreshProcStatsComparisonAsync(startUtc, endUtc); + } + + private async Task LoadQueryStoreAsync(DateTime startUtc, DateTime endUtc) + { + var rows = await _dataService.GetQueryStoreTopQueriesAsync(_server.ServerId, startUtc, endUtc, databaseNames: SelectedDatabaseFilter); + _queryStoreFilterMgr!.UpdateData(rows); + SetDefaultSortIfNone(QueryStoreGrid, "TotalDurationMs", ListSortDirection.Descending); + await LoadQueryStoreSlicerAsync(startUtc, endUtc); + await RefreshQueryStoreComparisonAsync(startUtc, endUtc); + } + + /// <summary> + /// Loads the Query Store Regressions grid — the Dashboard's regressions view (baseline-vs-recent Query + /// Store performance) ported to Darling's store, over the toolbar's settable window. Like the Dashboard + /// grid (and unlike the three per-source grids) it has no slicer / comparison / slicer-overlay: the read + /// is already a baseline-vs-recent contrast, not a single time series. The default sort matches the + /// Dashboard grid's (duration regression % descending) — a grid-view-only SortDescription that does NOT + /// touch the read's additional-duration ORDER BY (the TVF's ranking). + /// </summary> + private async Task LoadQueryStoreRegressionsAsync(DateTime startUtc, DateTime endUtc) + { + var rows = await _dataService.GetQueryStoreRegressionsAsync(_server.ServerId, startUtc, endUtc, databaseNames: SelectedDatabaseFilter); + _queryStoreRegressionsFilterMgr!.UpdateData(rows); + SetDefaultSortIfNone(QueryStoreRegressionsGrid, "DurationRegressionPercent", ListSortDirection.Descending); + } + + // ── Slicers (Lite's ServerTab.Slicers.cs; the slicer sends UTC bounds, the viewer reads take naive UTC) ── + + private async Task LoadQueryStatsSlicerAsync(DateTime startUtc, DateTime endUtc) + { + var data = await _dataService.GetQueryStatsSlicerDataAsync(_server.ServerId, startUtc, endUtc, databaseNames: SelectedDatabaseFilter); + _queryStatsSlicerData = data; + _queryStatsSlicerMetric = "TotalCpu"; + if (data.Count > 0) + QueryStatsSlicer.LoadData(data, "Total CPU (ms)", startUtc, endUtc); + } + + private async void OnQueryStatsSlicerChanged(object? sender, SlicerRangeEventArgs e) + { + try + { + var rows = await _dataService.GetTopQueriesByCpuAsync(_server.ServerId, e.StartUtc, e.EndUtc, databaseNames: SelectedDatabaseFilter); + _queryStatsFilterMgr!.UpdateData(rows); + await RefreshQueryStatsComparisonAsync(e.StartUtc, e.EndUtc); + } + catch (Exception ex) + { + StatusChanged?.Invoke($"query-stats slicer failed: {ex.Message}"); + } + } + + private async Task LoadProcStatsSlicerAsync(DateTime startUtc, DateTime endUtc) + { + var data = await _dataService.GetProcStatsSlicerDataAsync(_server.ServerId, startUtc, endUtc, databaseNames: SelectedDatabaseFilter); + _procStatsSlicerData = data; + _procStatsSlicerMetric = "TotalCpu"; + if (data.Count > 0) + ProcStatsSlicer.LoadData(data, "Total CPU (ms)", startUtc, endUtc); + } + + private async void OnProcStatsSlicerChanged(object? sender, SlicerRangeEventArgs e) + { + try + { + var rows = await _dataService.GetTopProceduresByCpuAsync(_server.ServerId, e.StartUtc, e.EndUtc, databaseNames: SelectedDatabaseFilter); + _procStatsFilterMgr!.UpdateData(rows); + await RefreshProcStatsComparisonAsync(e.StartUtc, e.EndUtc); + } + catch (Exception ex) + { + StatusChanged?.Invoke($"procedure-stats slicer failed: {ex.Message}"); + } + } + + private async Task LoadQueryStoreSlicerAsync(DateTime startUtc, DateTime endUtc) + { + var data = await _dataService.GetQueryStoreSlicerDataAsync(_server.ServerId, startUtc, endUtc, databaseNames: SelectedDatabaseFilter); + _queryStoreSlicerData = data; + _queryStoreSlicerMetric = "TotalCpu"; + if (data.Count > 0) + QueryStoreSlicer.LoadData(data, "Total CPU (ms)", startUtc, endUtc); + } + + private async void OnQueryStoreSlicerChanged(object? sender, SlicerRangeEventArgs e) + { + try + { + var rows = await _dataService.GetQueryStoreTopQueriesAsync(_server.ServerId, e.StartUtc, e.EndUtc, databaseNames: SelectedDatabaseFilter); + _queryStoreFilterMgr!.UpdateData(rows); + await RefreshQueryStoreComparisonAsync(e.StartUtc, e.EndUtc); + } + catch (Exception ex) + { + StatusChanged?.Invoke($"query-store slicer failed: {ex.Message}"); + } + } + + // ── Sort-driven slicer metric re-labeling (Lite's ServerTab.Grids.cs *Grid_Sorting) ── + + /// <summary>Sorting the Top Queries grid swaps the slicer's aggregate curve to match the sorted column.</summary> + private void QueryStatsGrid_Sorting(object sender, DataGridSortingEventArgs e) + { + if (_queryStatsSlicerData == null || _queryStatsSlicerData.Count == 0) return; + + var col = SortColumnPath(e.Column); + var (metric, label) = col switch + { + "TotalCpuMs" => ("TotalCpu", "Total CPU (ms)"), + "AvgCpuMs" => ("AvgCpu", "Avg CPU (ms)"), + "TotalElapsedMs" => ("TotalElapsed", "Total Duration (ms)"), + "AvgElapsedMs" => ("AvgElapsed", "Avg Duration (ms)"), + "TotalLogicalReads" => ("TotalReads", "Total Reads"), + "AvgReads" => ("AvgReads", "Avg Reads"), + "TotalLogicalWrites" => ("TotalWrites", "Total Writes"), + "TotalPhysicalReads" => ("TotalPhysReads", "Total Physical Reads"), + _ => ("TotalCpu", "Total CPU (ms)"), + }; + + if (metric == _queryStatsSlicerMetric) return; + _queryStatsSlicerMetric = metric; + + foreach (var bucket in _queryStatsSlicerData) + { + var n = bucket.SessionCount > 0 ? bucket.SessionCount : 1; + bucket.Value = metric switch + { + "TotalCpu" => bucket.TotalCpu, + "AvgCpu" => bucket.TotalCpu / n, + "TotalElapsed" => bucket.TotalElapsed, + "AvgElapsed" => bucket.TotalElapsed / n, + "TotalReads" => bucket.TotalReads, + "AvgReads" => bucket.TotalReads / n, + "TotalWrites" => bucket.TotalWrites, + "TotalPhysReads" => bucket.TotalPhysicalReads, + _ => bucket.TotalCpu, + }; + } + + QueryStatsSlicer.UpdateMetric(label); + } + + /// <summary>Sorting the Top Procedures grid swaps the slicer's aggregate curve to match the sorted column.</summary> + private void ProcedureStatsGrid_Sorting(object sender, DataGridSortingEventArgs e) + { + if (_procStatsSlicerData == null || _procStatsSlicerData.Count == 0) return; + + var col = SortColumnPath(e.Column); + var (metric, label) = col switch + { + "TotalCpuMs" => ("TotalCpu", "Total CPU (ms)"), + "AvgCpuMs" => ("AvgCpu", "Avg CPU (ms)"), + "TotalElapsedMs" => ("TotalElapsed", "Total Duration (ms)"), + "AvgElapsedMs" => ("AvgElapsed", "Avg Duration (ms)"), + "TotalLogicalReads" or "AvgReads" => ("TotalReads", "Total Reads"), + "TotalLogicalWrites" => ("TotalWrites", "Total Writes"), + "TotalPhysicalReads" => ("TotalReads", "Total Physical Reads"), + _ => ("TotalCpu", "Total CPU (ms)"), + }; + + if (metric == _procStatsSlicerMetric) return; + _procStatsSlicerMetric = metric; + + foreach (var bucket in _procStatsSlicerData) + { + var n = bucket.SessionCount > 0 ? bucket.SessionCount : 1; + bucket.Value = metric switch + { + "TotalCpu" => bucket.TotalCpu, + "AvgCpu" => bucket.TotalCpu / n, + "TotalElapsed" => bucket.TotalElapsed, + "AvgElapsed" => bucket.TotalElapsed / n, + "TotalReads" => bucket.TotalReads, + "TotalWrites" => bucket.TotalWrites, + _ => bucket.TotalCpu, + }; + } + + ProcStatsSlicer.UpdateMetric(label); + } + + /// <summary>Sorting the Query Store grid swaps the slicer's aggregate curve to match the sorted column.</summary> + private void QueryStoreGrid_Sorting(object sender, DataGridSortingEventArgs e) + { + if (_queryStoreSlicerData == null || _queryStoreSlicerData.Count == 0) return; + + var col = SortColumnPath(e.Column); + var (metric, label) = col switch + { + "TotalCpuMs" => ("TotalCpu", "Total CPU (ms)"), + "AvgCpuTimeMs" => ("AvgCpu", "Avg CPU (ms)"), + "TotalDurationMs" => ("TotalElapsed", "Total Duration (ms)"), + "AvgDurationMs" => ("AvgElapsed", "Avg Duration (ms)"), + "AvgLogicalReads" => ("TotalReads", "Avg Reads"), + "AvgLogicalWrites" => ("TotalWrites", "Avg Writes"), + "AvgPhysicalReads" => ("TotalReads", "Avg Physical Reads"), + "TotalExecutions" => ("Sessions", "Executions"), + _ => ("TotalCpu", "Total CPU (ms)"), + }; + + if (metric == _queryStoreSlicerMetric) return; + _queryStoreSlicerMetric = metric; + + foreach (var bucket in _queryStoreSlicerData) + { + var n = bucket.SessionCount > 0 ? bucket.SessionCount : 1; + bucket.Value = metric switch + { + "TotalCpu" => bucket.TotalCpu, + "AvgCpu" => bucket.TotalCpu / n, + "TotalElapsed" => bucket.TotalElapsed, + "AvgElapsed" => bucket.TotalElapsed / n, + "TotalReads" => bucket.TotalReads, + "TotalWrites" => bucket.TotalWrites, + "Sessions" => bucket.SessionCount, + _ => bucket.TotalCpu, + }; + } + + QueryStoreSlicer.UpdateMetric(label); + } + + /// <summary>The sorted column's member path (SortMemberPath, else the bound Binding path) — Lite's fallback.</summary> + private static string SortColumnPath(DataGridColumn column) + { + var col = column.SortMemberPath ?? ""; + if (string.IsNullOrEmpty(col) && column is DataGridBoundColumn bc && + bc.Binding is System.Windows.Data.Binding b) + { + col = b.Path.Path; + } + return col; + } + + /// <summary> + /// Sets the grid's default sort when the user has not sorted it yet (Lite's SetDefaultSortIfNone) — + /// so the first render matches the read's ORDER BY. The filter manager preserves sort across reloads, + /// so this only fires on the initial load. + /// </summary> + private static void SetDefaultSortIfNone(DataGrid grid, string bindingPath, ListSortDirection direction) + { + if (grid.Items.SortDescriptions.Count > 0) return; + + grid.Items.SortDescriptions.Add(new SortDescription(bindingPath, direction)); + foreach (var column in grid.Columns) + { + if (column.SortMemberPath == bindingPath || + (column is DataGridBoundColumn bc && bc.Binding is System.Windows.Data.Binding b && b.Path.Path == bindingPath)) + { + column.SortDirection = direction; + return; + } + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueriesBarCells.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueriesBarCells.cs new file mode 100644 index 000000000..ea651a1ca --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueriesBarCells.cs @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// Column maxima for the in-grid <see cref="PerformanceMonitor.Ui.BarChartCell"/>s on the Top Queries +/// grid + the CPU-by-database bar-cards — the viewer copy of Lite's <c>ServerTab.BarCells.cs</c>. Each +/// bar fills to <c>value / max</c> for its column; recomputed whenever the grid's ItemsSource changes +/// (initial load + every filter, since the <see cref="DataGridFilterManager{T}"/> re-assigns +/// ItemsSource) via a DependencyPropertyDescriptor so we don't hook each setter. The cells bind these +/// by RelativeSource to the host ViewerServerTab. +/// </summary> +public partial class ViewerServerTab +{ + public static readonly DependencyProperty BarMaxExecutionsProperty = DependencyProperty.Register( + nameof(BarMaxExecutions), typeof(double), typeof(ViewerServerTab), new PropertyMetadata(1.0)); + public double BarMaxExecutions + { + get => (double)GetValue(BarMaxExecutionsProperty); + set => SetValue(BarMaxExecutionsProperty, value); + } + + public static readonly DependencyProperty BarMaxTotalCpuProperty = DependencyProperty.Register( + nameof(BarMaxTotalCpu), typeof(double), typeof(ViewerServerTab), new PropertyMetadata(1.0)); + public double BarMaxTotalCpu + { + get => (double)GetValue(BarMaxTotalCpuProperty); + set => SetValue(BarMaxTotalCpuProperty, value); + } + + public static readonly DependencyProperty BarMaxTotalElapsedProperty = DependencyProperty.Register( + nameof(BarMaxTotalElapsed), typeof(double), typeof(ViewerServerTab), new PropertyMetadata(1.0)); + public double BarMaxTotalElapsed + { + get => (double)GetValue(BarMaxTotalElapsedProperty); + set => SetValue(BarMaxTotalElapsedProperty, value); + } + + public static readonly DependencyProperty BarMaxTotalReadsProperty = DependencyProperty.Register( + nameof(BarMaxTotalReads), typeof(double), typeof(ViewerServerTab), new PropertyMetadata(1.0)); + public double BarMaxTotalReads + { + get => (double)GetValue(BarMaxTotalReadsProperty); + set => SetValue(BarMaxTotalReadsProperty, value); + } + + private void SetupBarCellMaxes() + { + var dpd = DependencyPropertyDescriptor.FromProperty(ItemsControl.ItemsSourceProperty, typeof(DataGrid)); + dpd?.AddValueChanged(QueryStatsGrid, (_, _) => RecomputeBarCellMaxes()); + } + + private void RecomputeBarCellMaxes() + { + var items = (QueryStatsGrid.ItemsSource as IEnumerable)?.OfType<ViewerQueryStatsRow>().ToList(); + if (items == null || items.Count == 0) + { + BarMaxExecutions = BarMaxTotalCpu = BarMaxTotalElapsed = BarMaxTotalReads = 1.0; + if (QueryStatsByDbCards != null) QueryStatsByDbCards.Items = null; + return; + } + + // Max(1,...) keeps the bar denominator positive even for an all-zero column. + BarMaxExecutions = Math.Max(1.0, items.Max(i => (double)i.TotalExecutions)); + BarMaxTotalCpu = Math.Max(1.0, items.Max(i => i.TotalCpuMs)); + BarMaxTotalElapsed = Math.Max(1.0, items.Max(i => i.TotalElapsedMs)); + BarMaxTotalReads = Math.Max(1.0, items.Max(i => (double)i.TotalLogicalReads)); + + RefreshByDbCards(items); + } + + /// <summary> + /// Builds the per-database CPU bar-cards from the currently displayed query rows — no new query, just + /// an aggregation of what the grid already holds. A database's color is a STABLE alphabetical index, + /// so it keeps its color no matter how the bars are ranked; the bars are ordered by value (largest + /// first). Mirror of Lite's RefreshByDbCards. + /// </summary> + private void RefreshByDbCards(List<ViewerQueryStatsRow> items) + { + if (QueryStatsByDbCards == null) return; + + var byDb = items + .GroupBy(i => i.DatabaseName ?? string.Empty) + .Select(g => new { Db = g.Key, Cpu = g.Sum(x => x.TotalCpuMs) }) + .Where(x => x.Cpu > 0) + .ToList(); + + if (byDb.Count == 0) + { + QueryStatsByDbCards.Items = null; + return; + } + + var sortedNames = byDb.Select(x => x.Db) + .OrderBy(n => n, StringComparer.OrdinalIgnoreCase) + .ToList(); + double max = byDb.Max(x => x.Cpu); + + QueryStatsByDbCards.Items = byDb + .OrderByDescending(x => x.Cpu) + .Select(x => new MetricBarItem + { + Label = string.IsNullOrEmpty(x.Db) ? "(unknown)" : x.Db, + Value = x.Cpu, + Maximum = max, + ValueText = x.Cpu.ToString("N0"), + BarBrush = BrushFromHex(ChartPalette.CyclingColor(sortedNames.IndexOf(x.Db))), + }) + .ToList(); + } + + private static Brush BrushFromHex(string hex) + { + var b = new SolidColorBrush((Color)ColorConverter.ConvertFromString(hex)); + b.Freeze(); + return b; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueriesComparison.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueriesComparison.cs new file mode 100644 index 000000000..83638ef49 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueriesComparison.cs @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Queries-tab comparison overlay (W1f-1) — Lite's <c>ServerTab.Comparison.cs</c> ported. The shared +/// "Compare:" combo above the sub-tabs picks a baseline period (Yesterday / Last week / Same day last +/// week); when active, each sub-tab hides its normal grid and shows a collapsed comparison grid bound to +/// the shared <see cref="PerformanceMonitor.Ui.ComparisonItemBase"/> derivatives (delta % + NEW/GONE +/// badges), sorted NEW-first then by duration-delta descending. Changing the combo reloads the active +/// sub-tab (which re-applies the comparison over the toolbar's current window). The baseline is a +/// straight shift of the current window (no Lite display-mode conversion), and the baseline banner +/// renders the window in the viewer machine's local time. +/// </summary> +public partial class ViewerServerTab +{ + /// <summary> + /// Changing the Compare combo reloads the active Queries sub-tab through the shell's overlap guard, + /// so <see cref="LoadQueriesAsync"/> re-applies the comparison mode over the current window. Gated on + /// <see cref="System.Windows.FrameworkElement.IsLoaded"/> so the SelectedIndex="0" set at build time + /// is ignored. + /// </summary> + private async void CompareToCombo_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (!IsLoaded) return; + + await RefreshActiveInnerTabAsync(); + } + + /// <summary> + /// Comparison overlays only the three grid sub-tabs (Top Queries / Top Procedures / Query Store); the + /// Performance Trends / Active Queries / Query Heatmap sub-tabs (W1f-2) have no baseline overlay, so the + /// Compare combo is reset to None and disabled there (Lite's <c>UpdateCompareDropdownState</c>, Queries + /// slice). Called on every sub-tab change and once at init for the default (Performance Trends) state. + /// </summary> + private void UpdateCompareDropdownState() + { + var supported = QueriesSubTabControl.SelectedIndex + is TopQueriesSubTabIndex or TopProceduresSubTabIndex or QueryStoreSubTabIndex; + + if (supported) + { + CompareToCombo.IsEnabled = true; + CompareToCombo.Opacity = 1.0; + CompareToCombo.ToolTip = "Compare the current window against a baseline period"; + } + else + { + CompareToCombo.SelectedIndex = 0; /* None — no comparison on the non-grid sub-tabs */ + CompareToCombo.IsEnabled = false; + CompareToCombo.Opacity = 0.5; + CompareToCombo.ToolTip = "Comparison is not available for this sub-tab"; + } + } + + /// <summary> + /// The baseline window for the current Compare selection — a straight shift of the current window + /// (Yesterday = -1 day, Last week / Same day last week = -7 days). Null when Compare is "None". + /// </summary> + private (DateTime From, DateTime To)? GetComparisonRange(DateTime currentStart, DateTime currentEnd) + { + if (CompareToCombo == null || CompareToCombo.SelectedIndex <= 0) return null; + + return CompareToCombo.SelectedIndex switch + { + 1 => (currentStart.AddDays(-1), currentEnd.AddDays(-1)), // Yesterday + 2 => (currentStart.AddDays(-7), currentEnd.AddDays(-7)), // Last week + 3 => (currentStart.AddDays(-7), currentEnd.AddDays(-7)), // Same day last week + _ => null, + }; + } + + private void SetComparisonMode( + DataGrid normalGrid, DataGrid comparisonGrid, TextBlock banner, + bool active, (DateTime From, DateTime To)? baselineRange) + { + normalGrid.Visibility = active ? Visibility.Collapsed : Visibility.Visible; + comparisonGrid.Visibility = active ? Visibility.Visible : Visibility.Collapsed; + banner.Visibility = active ? Visibility.Visible : Visibility.Collapsed; + + if (active && baselineRange.HasValue) + { + var from = ViewerTimeHelper.ForDisplay(baselineRange.Value.From).ToString("yyyy-MM-dd HH:mm"); + var to = ViewerTimeHelper.ForDisplay(baselineRange.Value.To).ToString("yyyy-MM-dd HH:mm"); + banner.Text = $"Comparing against baseline: {from} → {to}"; + } + } + + private async Task RefreshQueryStatsComparisonAsync(DateTime currentStart, DateTime currentEnd) + { + var baseline = GetComparisonRange(currentStart, currentEnd); + if (baseline == null) + { + SetComparisonMode(QueryStatsGrid, QueryStatsComparisonGrid, QueryStatsComparisonBanner, active: false, null); + return; + } + + SetComparisonMode(QueryStatsGrid, QueryStatsComparisonGrid, QueryStatsComparisonBanner, active: true, baseline); + + var items = await _dataService.GetQueryStatsComparisonAsync( + _server.ServerId, currentStart, currentEnd, baseline.Value.From, baseline.Value.To, databaseNames: SelectedDatabaseFilter); + QueryStatsComparisonGrid.ItemsSource = items + .OrderBy(x => x.SortGroup) + .ThenByDescending(x => x.SortableDurationDelta) + .ToList(); + } + + private async Task RefreshProcStatsComparisonAsync(DateTime currentStart, DateTime currentEnd) + { + var baseline = GetComparisonRange(currentStart, currentEnd); + if (baseline == null) + { + SetComparisonMode(ProcedureStatsGrid, ProcStatsComparisonGrid, ProcStatsComparisonBanner, active: false, null); + return; + } + + SetComparisonMode(ProcedureStatsGrid, ProcStatsComparisonGrid, ProcStatsComparisonBanner, active: true, baseline); + + var items = await _dataService.GetProcedureStatsComparisonAsync( + _server.ServerId, currentStart, currentEnd, baseline.Value.From, baseline.Value.To, databaseNames: SelectedDatabaseFilter); + ProcStatsComparisonGrid.ItemsSource = items + .OrderBy(x => x.SortGroup) + .ThenByDescending(x => x.SortableDurationDelta) + .ToList(); + } + + private async Task RefreshQueryStoreComparisonAsync(DateTime currentStart, DateTime currentEnd) + { + var baseline = GetComparisonRange(currentStart, currentEnd); + if (baseline == null) + { + SetComparisonMode(QueryStoreGrid, QueryStoreComparisonGrid, QueryStoreComparisonBanner, active: false, null); + return; + } + + SetComparisonMode(QueryStoreGrid, QueryStoreComparisonGrid, QueryStoreComparisonBanner, active: true, baseline); + + var items = await _dataService.GetQueryStoreComparisonAsync( + _server.ServerId, currentStart, currentEnd, baseline.Value.From, baseline.Value.To, databaseNames: SelectedDatabaseFilter); + QueryStoreComparisonGrid.ItemsSource = items + .OrderBy(x => x.SortGroup) + .ThenByDescending(x => x.SortableDurationDelta) + .ToList(); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueryHeatmap.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueryHeatmap.cs new file mode 100644 index 000000000..aa436cf6e --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueryHeatmap.cs @@ -0,0 +1,311 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Queries → Query Heatmap sub-tab (W1f-2): the metric combo + heatmap of query counts per +/// (5-minute bin × per-execution magnitude bucket), copied from Lite's <c>ServerTab.Charts.cs</c> +/// (<c>UpdateQueryHeatmapChart</c> + the hover, :1095-1252) with the read rewired to +/// <see cref="ViewerDataService.GetQueryHeatmapAsync"/> Postgres. The only render-body change is the +/// time axis (Lite's per-server <c>UtcOffsetMinutes</c> shift → <see cref="ViewerTimeHelper.ForDisplay"/>). +/// The right-click "Show Active Queries at This Time" drill-down IS wired here (the task calls for it, and +/// Active Queries is a sibling sub-tab of this one), built inline (Lite's <c>ContextMenuHelper</c> is +/// Lite-only and can't be referenced): ScottPlot's default right-click responses are removed +/// (<see cref="RemoveScottPlotContextMenuResponses"/>) and a single-item WPF menu takes over, dropping Lite's +/// save/export items (the viewer never ported chart save/export). The other charts' drill-downs share this +/// path — see <c>ViewerServerTab.DrillDown.cs</c>. +/// </summary> +public partial class ViewerServerTab +{ + private HeatmapResult? _lastHeatmapResult; + private ScottPlot.Plottables.Heatmap? _heatmapPlottable; + private Popup? _heatmapPopup; + private TextBlock? _heatmapPopupText; + private DateTime _lastHeatmapHoverUpdate; + + /// <summary>Themes the heatmap chart up front, builds the hover popup, and wires the drill-down + /// context menu. Called from <see cref="InitializeQueriesTab"/> after InitializeComponent.</summary> + private void InitializeQueryHeatmap() + { + ApplyTheme(QueryHeatmapChart); + QueryHeatmapChart.Refresh(); + + /* Query heatmap hover popup (copied from Lite's ServerTab.xaml.cs). */ + _heatmapPopupText = new TextBlock + { + Foreground = new SolidColorBrush(Color.FromRgb(0xE0, 0xE0, 0xE0)), + FontSize = 13, + MaxWidth = 450, + TextTrimming = TextTrimming.CharacterEllipsis, + }; + _heatmapPopup = new Popup + { + PlacementTarget = QueryHeatmapChart, + Placement = PlacementMode.Relative, + IsHitTestVisible = false, + AllowsTransparency = true, + Child = new Border + { + Background = new SolidColorBrush(Color.FromRgb(0x33, 0x33, 0x33)), + BorderBrush = new SolidColorBrush(Color.FromRgb(0x55, 0x55, 0x55)), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(3), + Padding = new Thickness(8, 4, 8, 4), + Child = _heatmapPopupText, + }, + }; + + WireHeatmapDrillDownMenu(); + } + + /// <summary> + /// Builds the heatmap's right-click menu — Lite's heatmap drill-down (ServerTab.xaml.cs:285-315): the + /// "Show Active Queries at This Time" item Inserted at the TOP of the chart's copy/save/export menu + /// (<see cref="BuildChartContextMenu"/>), so it reads [drill-down] [separator] [Copy/Save/Export] like + /// every other drill-down chart. BuildChartContextMenu already takes right-click over from ScottPlot; the + /// Opened handler resolves the time bin under the cursor and the Click routes it to + /// <see cref="OnHeatmapDrillDown"/>. + /// </summary> + private void WireHeatmapDrillDownMenu() + { + var menu = BuildChartContextMenu(QueryHeatmapChart, "Query_Heatmap"); + var drillItem = new MenuItem { Header = "Show Active Queries at This Time" }; + menu.Items.Insert(0, drillItem); + menu.Items.Insert(1, new Separator()); + + menu.Opened += (_, _) => + { + if (_lastHeatmapResult == null || _heatmapPlottable == null || _lastHeatmapResult.TimeBuckets.Length == 0) + { + drillItem.IsEnabled = false; + return; + } + + var pos = Mouse.GetPosition(QueryHeatmapChart); + var dpi = VisualTreeHelper.GetDpi(QueryHeatmapChart); + var pixel = new ScottPlot.Pixel((float)(pos.X * dpi.DpiScaleX), (float)(pos.Y * dpi.DpiScaleY)); + var coords = QueryHeatmapChart.Plot.GetCoordinates(pixel); + var (col, _) = _heatmapPlottable.GetIndexes(coords); + if (col >= 0 && col < _lastHeatmapResult.TimeBuckets.Length) + { + drillItem.Tag = _lastHeatmapResult.TimeBuckets[col]; + drillItem.IsEnabled = true; + } + else + { + drillItem.IsEnabled = false; + } + }; + + drillItem.Click += (_, _) => + { + if (drillItem.Tag is DateTime bucketTime) + _ = OnHeatmapDrillDown(bucketTime); + }; + } + + /// <summary>Loads the Query Heatmap sub-tab for the combo's current metric over the window.</summary> + private async Task LoadQueryHeatmapAsync(DateTime startUtc, DateTime endUtc) + { + var metric = (HeatmapMetric)HeatmapMetricCombo.SelectedIndex; + var result = await _dataService.GetQueryHeatmapAsync(_server.ServerId, metric, startUtc, endUtc, databaseNames: SelectedDatabaseFilter); + UpdateQueryHeatmapChart(result); + } + + private async void HeatmapMetric_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (!IsLoaded) return; + try + { + var (startUtc, endUtc) = GetWindowUtc(); + var metric = (HeatmapMetric)HeatmapMetricCombo.SelectedIndex; + var result = await _dataService.GetQueryHeatmapAsync(_server.ServerId, metric, startUtc, endUtc, databaseNames: SelectedDatabaseFilter); + UpdateQueryHeatmapChart(result); + } + catch (Exception ex) + { + StatusChanged?.Invoke($"heatmap metric change failed: {ex.Message}"); + } + } + + private void UpdateQueryHeatmapChart(HeatmapResult result) + { + ClearChart(QueryHeatmapChart); + ApplyTheme(QueryHeatmapChart); + + _lastHeatmapResult = result; + + if (result.TimeBuckets.Length == 0 || result.BucketLabels.Length == 0) + { + RefreshEmptyChart(QueryHeatmapChart, "Query Heatmap", ""); + return; + } + + int numRows = result.Intensities.GetLength(0); + int numCols = result.Intensities.GetLength(1); + + // Log1p scaling; NaN for empty cells so they render as background. + var scaled = new double[numRows, numCols]; + for (int r = 0; r < numRows; r++) + { + for (int c = 0; c < numCols; c++) + { + scaled[r, c] = result.Intensities[r, c] > 0 + ? Math.Log(1 + result.Intensities[r, c]) + : double.NaN; + } + } + + var heatmap = QueryHeatmapChart.Plot.Add.Heatmap(scaled); + _heatmapPlottable = heatmap; + heatmap.FlipVertically = true; // row 0 ("0-1ms") at bottom, row 6 (">100s") at top + heatmap.Colormap = new ScottPlot.Colormaps.Viridis(); + heatmap.NaNCellColor = QueryHeatmapChart.Plot.DataBackground.Color; + + // Let ScottPlot use default extent (0..numCols, 0..numRows). + // Use manual tick labels for both axes instead. + ReapplyAxisColors(QueryHeatmapChart); + + // X-axis: time labels at column positions + var xTicks = new ScottPlot.TickGenerators.NumericManual(); + int xStep = Math.Max(1, numCols / 12); // ~12 labels max + for (int i = 0; i < numCols; i += xStep) + { + var t = ViewerTimeHelper.ForDisplay(result.TimeBuckets[i]); + xTicks.AddMajor(i, t.ToString("M/d\nHH:mm")); + } + QueryHeatmapChart.Plot.Axes.Bottom.TickGenerator = xTicks; + QueryHeatmapChart.Plot.Axes.Bottom.TickLabelStyle.ForeColor = QueryHeatmapChart.Plot.Axes.Left.TickLabelStyle.ForeColor; + + // Y-axis: bucket labels + var yTicks = new ScottPlot.TickGenerators.NumericManual(); + for (int i = 0; i < result.BucketLabels.Length; i++) + { + yTicks.AddMajor(i, result.BucketLabels[i]); + } + QueryHeatmapChart.Plot.Axes.Left.TickGenerator = yTicks; + + // Axis limits match default heatmap extent + QueryHeatmapChart.Plot.Axes.SetLimitsX(-0.5, numCols - 0.5); + QueryHeatmapChart.Plot.Axes.SetLimitsY(-0.5, numRows - 0.5); + + // Colorbar with real query counts (undo log1p for tick labels) + var colorBar = new ScottPlot.Panels.ColorBar(heatmap, ScottPlot.Edge.Right); + colorBar.Label = "Query Count"; + colorBar.LabelStyle.ForeColor = QueryHeatmapChart.Plot.Axes.Bottom.TickLabelStyle.ForeColor; + colorBar.Axis.TickLabelStyle.ForeColor = QueryHeatmapChart.Plot.Axes.Bottom.TickLabelStyle.ForeColor; + double maxRaw = 0; + for (int r = 0; r < numRows; r++) + for (int c = 0; c < numCols; c++) + if (result.Intensities[r, c] > maxRaw) maxRaw = result.Intensities[r, c]; + var cbTicks = new ScottPlot.TickGenerators.NumericManual(); + cbTicks.AddMajor(0, "0"); + int[] niceValues = { 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000 }; + foreach (var n in niceValues) + { + if (n > maxRaw) break; + cbTicks.AddMajor(Math.Log(1 + n), n.ToString("N0")); + } + cbTicks.AddMajor(Math.Log(1 + maxRaw), ((int)maxRaw).ToString("N0")); + colorBar.Axis.TickGenerator = cbTicks; + QueryHeatmapChart.Plot.Axes.AddPanel(colorBar); + _chartHelper.SetLegendPanel(QueryHeatmapChart, colorBar); + + var metricName = ((ComboBoxItem)HeatmapMetricCombo.SelectedItem).Content?.ToString() ?? "Duration (ms)"; + QueryHeatmapChart.Plot.Title($"Query Distribution by {metricName}"); + QueryHeatmapChart.Plot.Axes.Title.Label.ForeColor = QueryHeatmapChart.Plot.Axes.Bottom.TickLabelStyle.ForeColor; + + QueryHeatmapChart.Refresh(); + } + + private void HeatmapChart_MouseLeave(object sender, MouseEventArgs e) + { + if (_heatmapPopup != null) _heatmapPopup.IsOpen = false; + } + + private void HeatmapChart_MouseMove(object sender, MouseEventArgs e) + { + if (_heatmapPopup == null || _heatmapPopupText == null || _heatmapPlottable == null) return; + if (_lastHeatmapResult == null || _lastHeatmapResult.TimeBuckets.Length == 0) return; + + var now = DateTime.UtcNow; + if ((now - _lastHeatmapHoverUpdate).TotalMilliseconds < 50) return; + _lastHeatmapHoverUpdate = now; + + var pos = e.GetPosition(QueryHeatmapChart); + var dpi = VisualTreeHelper.GetDpi(QueryHeatmapChart); + var pixel = new ScottPlot.Pixel( + (float)(pos.X * dpi.DpiScaleX), + (float)(pos.Y * dpi.DpiScaleY)); + var coords = QueryHeatmapChart.Plot.GetCoordinates(pixel); + + int numRows = _lastHeatmapResult.Intensities.GetLength(0); + int numCols = _lastHeatmapResult.Intensities.GetLength(1); + + // Default heatmap extent (no custom Position): cols = 0..numCols, rows = 0..numRows. + // GetIndexes returns bitmap indices. With FlipVertically=true, flip row for data index. + var (col, rowIdx) = _heatmapPlottable.GetIndexes(coords); + int row = (numRows - 1) - rowIdx; + + if (row < 0 || row >= numRows || col < 0 || col >= numCols) + { + _heatmapPopup.IsOpen = false; + return; + } + + long count = (long)_lastHeatmapResult.Intensities[row, col]; + if (count == 0) + { + _heatmapPopup.IsOpen = false; + return; + } + + var cell = _lastHeatmapResult.CellDetails[row, col]; + var time = ViewerTimeHelper.ForDisplay(_lastHeatmapResult.TimeBuckets[col]); + var bucketLabel = row < _lastHeatmapResult.BucketLabels.Length + ? _lastHeatmapResult.BucketLabels[row] + : "?"; + + var tipText = $"{time:HH:mm:ss} | {bucketLabel} | {count:N0} queries"; + if (cell != null && !string.IsNullOrEmpty(cell.TopQueryText)) + { + // Single line, collapse whitespace, truncate + var flat = System.Text.RegularExpressions.Regex.Replace(cell.TopQueryText, @"\s+", " ").Trim(); + if (flat.Length > 60) flat = flat[..60] + "..."; + tipText += $"\n{flat}"; + } + _heatmapPopupText.Text = tipText; + + _heatmapPopup.HorizontalOffset = pos.X + 15; + _heatmapPopup.VerticalOffset = pos.Y + 15; + _heatmapPopup.IsOpen = true; + } + + /// <summary> + /// Heatmap → Active Queries drill-down: opens the Active Queries sub-tab filtered to a window around + /// the clicked 5-minute bin (-5/+10 min, covering the bin and its neighbours — Lite's OnHeatmapDrillDown). + /// The bin time is naive UTC (date_bin over collection_time), which the Active Queries read takes + /// directly, so no offset conversion is needed (the viewer has no per-server UTC offset). + /// </summary> + private async Task OnHeatmapDrillDown(DateTime bucketTimeUtc) + { + var fromUtc = bucketTimeUtc.AddMinutes(-5); + var toUtc = bucketTimeUtc.AddMinutes(10); + var indicator = $"Drill-down: {ViewerTimeHelper.ForDisplay(fromUtc):HH:mm} → {ViewerTimeHelper.ForDisplay(toUtc):HH:mm}"; + await NavigateToActiveQueriesForWindowAsync(fromUtc, toUtc, indicator); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueryTrends.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueryTrends.cs new file mode 100644 index 000000000..dd6a1bf6c --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueryTrends.cs @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Queries → Performance Trends sub-tab (W1f-2): the 2×2 grid of per-second trend charts (query +/// duration, procedure duration, Query Store duration, execution count), copied from Lite's +/// <c>ServerTab.Charts.cs</c> (<c>Update{Query,Proc,QueryStore}DurationTrendChart</c> + +/// <c>UpdateExecutionCountTrendChart</c>, :993-1091) with the data layer rewired to +/// <see cref="ViewerDataService"/> Postgres reads. The only render-body change is the time axis: where +/// Lite shifts each point by its per-server <c>UtcOffsetMinutes</c>, the viewer runs the naive-UTC +/// <c>collection_time</c> through <see cref="ViewerTimeHelper.ForDisplay"/> — the same convention the +/// shell's other copied charts use. Hover tooltips are kept; Lite's per-chart "Show Active Queries at +/// This Time" context-menu drill-down is NOT ported (matching every other viewer chart — the viewer has +/// no chart context menus), so these stay hover-only. +/// </summary> +public partial class ViewerServerTab +{ + private ChartHoverHelper? _queryDurationTrendHover; + private ChartHoverHelper? _procDurationTrendHover; + private ChartHoverHelper? _queryStoreDurationTrendHover; + private ChartHoverHelper? _executionCountTrendHover; + + /// <summary>Themes the four trend charts up front and wires their hover tooltips (Lite's per-chart + /// units). Called from <see cref="InitializeQueriesTab"/> so the charts don't flash white before the + /// Performance Trends sub-tab's first load.</summary> + private void InitializeQueryTrendCharts() + { + ApplyTheme(QueryDurationTrendChart); + QueryDurationTrendChart.Refresh(); + ApplyTheme(ProcDurationTrendChart); + ProcDurationTrendChart.Refresh(); + ApplyTheme(QueryStoreDurationTrendChart); + QueryStoreDurationTrendChart.Refresh(); + ApplyTheme(ExecutionCountTrendChart); + ExecutionCountTrendChart.Refresh(); + + _queryDurationTrendHover = new ChartHoverHelper(QueryDurationTrendChart, "ms/sec"); + _procDurationTrendHover = new ChartHoverHelper(ProcDurationTrendChart, "ms/sec"); + _queryStoreDurationTrendHover = new ChartHoverHelper(QueryStoreDurationTrendChart, "ms/sec"); + _executionCountTrendHover = new ChartHoverHelper(ExecutionCountTrendChart, "/sec"); + } + + private void DisposeQueryTrendHelpers() + { + _queryDurationTrendHover?.Dispose(); + _procDurationTrendHover?.Dispose(); + _queryStoreDurationTrendHover?.Dispose(); + _executionCountTrendHover?.Dispose(); + } + + /// <summary>Loads the Performance Trends sub-tab: the four per-second trend reads run concurrently + /// (NpgsqlDataSource pools a connection each), then each result renders into its chart.</summary> + private async Task LoadPerformanceTrendsAsync(DateTime startUtc, DateTime endUtc) + { + var queryDurationTask = _dataService.GetQueryDurationTrendAsync(_server.ServerId, startUtc, endUtc, databaseNames: SelectedDatabaseFilter); + var procDurationTask = _dataService.GetProcedureDurationTrendAsync(_server.ServerId, startUtc, endUtc, databaseNames: SelectedDatabaseFilter); + var queryStoreDurationTask = _dataService.GetQueryStoreDurationTrendAsync(_server.ServerId, startUtc, endUtc, databaseNames: SelectedDatabaseFilter); + var executionCountTask = _dataService.GetExecutionCountTrendAsync(_server.ServerId, startUtc, endUtc, databaseNames: SelectedDatabaseFilter); + + await Task.WhenAll(queryDurationTask, procDurationTask, queryStoreDurationTask, executionCountTask); + + UpdateQueryDurationTrendChart(queryDurationTask.Result, startUtc, endUtc); + UpdateProcDurationTrendChart(procDurationTask.Result, startUtc, endUtc); + UpdateQueryStoreDurationTrendChart(queryStoreDurationTask.Result, startUtc, endUtc); + UpdateExecutionCountTrendChart(executionCountTask.Result, startUtc, endUtc); + } + + private void UpdateQueryDurationTrendChart(List<QueryTrendPoint> data, DateTime startUtc, DateTime endUtc) + { + ClearChart(QueryDurationTrendChart); + ApplyTheme(QueryDurationTrendChart); + + if (data.Count == 0) { RefreshEmptyChart(QueryDurationTrendChart, "Query Duration", "Duration (ms/sec)"); return; } + + var rangeStart = ViewerTimeHelper.ForDisplay(startUtc).ToOADate(); + var rangeEnd = ViewerTimeHelper.ForDisplay(endUtc).ToOADate(); + var times = data.Select(d => ViewerTimeHelper.ForDisplay(d.CollectionTime).ToOADate()).ToArray(); + var values = data.Select(d => d.Value).ToArray(); + + _queryDurationTrendHover?.Clear(); + var plot = QueryDurationTrendChart.Plot.Add.Scatter(times, values); + plot.LegendText = "Query Duration"; + plot.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("QueryDuration")); + ChartStyle.StyleScatter(plot); + _queryDurationTrendHover?.Add(plot, "Query Duration"); + + QueryDurationTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); + QueryDurationTrendChart.Plot.Axes.SetLimitsX(rangeStart, rangeEnd); + ReapplyAxisColors(QueryDurationTrendChart); + QueryDurationTrendChart.Plot.YLabel("Duration (ms/sec)"); + SetChartYLimitsWithLegendPadding(QueryDurationTrendChart, 0, values.Max()); + ShowChartLegend(QueryDurationTrendChart); + QueryDurationTrendChart.Refresh(); + } + + private void UpdateProcDurationTrendChart(List<QueryTrendPoint> data, DateTime startUtc, DateTime endUtc) + { + ClearChart(ProcDurationTrendChart); + ApplyTheme(ProcDurationTrendChart); + + if (data.Count == 0) { RefreshEmptyChart(ProcDurationTrendChart, "Procedure Duration", "Duration (ms/sec)"); return; } + + var rangeStart = ViewerTimeHelper.ForDisplay(startUtc).ToOADate(); + var rangeEnd = ViewerTimeHelper.ForDisplay(endUtc).ToOADate(); + var times = data.Select(d => ViewerTimeHelper.ForDisplay(d.CollectionTime).ToOADate()).ToArray(); + var values = data.Select(d => d.Value).ToArray(); + + _procDurationTrendHover?.Clear(); + var plot = ProcDurationTrendChart.Plot.Add.Scatter(times, values); + plot.LegendText = "Procedure Duration"; + plot.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("ProcedureDuration")); + ChartStyle.StyleScatter(plot); + _procDurationTrendHover?.Add(plot, "Procedure Duration"); + + ProcDurationTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); + ProcDurationTrendChart.Plot.Axes.SetLimitsX(rangeStart, rangeEnd); + ReapplyAxisColors(ProcDurationTrendChart); + ProcDurationTrendChart.Plot.YLabel("Duration (ms/sec)"); + SetChartYLimitsWithLegendPadding(ProcDurationTrendChart, 0, values.Max()); + ShowChartLegend(ProcDurationTrendChart); + ProcDurationTrendChart.Refresh(); + } + + private void UpdateQueryStoreDurationTrendChart(List<QueryTrendPoint> data, DateTime startUtc, DateTime endUtc) + { + ClearChart(QueryStoreDurationTrendChart); + ApplyTheme(QueryStoreDurationTrendChart); + + if (data.Count == 0) { RefreshEmptyChart(QueryStoreDurationTrendChart, "Query Store Duration", "Duration (ms/sec)"); return; } + + var rangeStart = ViewerTimeHelper.ForDisplay(startUtc).ToOADate(); + var rangeEnd = ViewerTimeHelper.ForDisplay(endUtc).ToOADate(); + var times = data.Select(d => ViewerTimeHelper.ForDisplay(d.CollectionTime).ToOADate()).ToArray(); + var values = data.Select(d => d.Value).ToArray(); + + _queryStoreDurationTrendHover?.Clear(); + var plot = QueryStoreDurationTrendChart.Plot.Add.Scatter(times, values); + plot.LegendText = "Query Store Duration"; + plot.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("QueryStoreDuration")); + ChartStyle.StyleScatter(plot); + _queryStoreDurationTrendHover?.Add(plot, "Query Store Duration"); + + QueryStoreDurationTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); + QueryStoreDurationTrendChart.Plot.Axes.SetLimitsX(rangeStart, rangeEnd); + ReapplyAxisColors(QueryStoreDurationTrendChart); + QueryStoreDurationTrendChart.Plot.YLabel("Duration (ms/sec)"); + SetChartYLimitsWithLegendPadding(QueryStoreDurationTrendChart, 0, values.Max()); + ShowChartLegend(QueryStoreDurationTrendChart); + QueryStoreDurationTrendChart.Refresh(); + } + + private void UpdateExecutionCountTrendChart(List<QueryTrendPoint> data, DateTime startUtc, DateTime endUtc) + { + ClearChart(ExecutionCountTrendChart); + ApplyTheme(ExecutionCountTrendChart); + + if (data.Count == 0) { RefreshEmptyChart(ExecutionCountTrendChart, "Executions", "Executions/sec"); return; } + + var rangeStart = ViewerTimeHelper.ForDisplay(startUtc).ToOADate(); + var rangeEnd = ViewerTimeHelper.ForDisplay(endUtc).ToOADate(); + var times = data.Select(d => ViewerTimeHelper.ForDisplay(d.CollectionTime).ToOADate()).ToArray(); + var values = data.Select(d => d.Value).ToArray(); + + _executionCountTrendHover?.Clear(); + var plot = ExecutionCountTrendChart.Plot.Add.Scatter(times, values); + plot.LegendText = "Executions"; + plot.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("Executions")); + ChartStyle.StyleScatter(plot); + _executionCountTrendHover?.Add(plot, "Executions"); + + ExecutionCountTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); + ExecutionCountTrendChart.Plot.Axes.SetLimitsX(rangeStart, rangeEnd); + ReapplyAxisColors(ExecutionCountTrendChart); + ExecutionCountTrendChart.Plot.YLabel("Executions/sec"); + SetChartYLimitsWithLegendPadding(ExecutionCountTrendChart, 0, values.Max()); + ShowChartLegend(ExecutionCountTrendChart); + ExecutionCountTrendChart.Refresh(); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.RunningJobs.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.RunningJobs.cs new file mode 100644 index 000000000..0097d3283 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.RunningJobs.cs @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading.Tasks; +using System.Windows; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Running Jobs inner tab — a COPY of Lite's Running Jobs grid (ServerTab.xaml + the +/// <c>RefreshRunningJobsAsync</c> load), reads rewired to Postgres. The grid shows the latest snapshot +/// of currently-running SQL Agent jobs with their historical duration comparison; the column-filter +/// headers ride the shared W1g filter manager (<c>_runningJobsFilterMgr</c>, registered in +/// ViewerServerTab.Filters.cs), and the <c>IsRunningLong</c> row highlight lives in the XAML row style. +/// The one behavioral deviation from Lite is the msdb-access banner: Lite drives it from a live +/// per-connection probe; the viewer derives it from the store (see <see cref="LoadRunningJobsAsync"/>). +/// </summary> +public partial class ViewerServerTab +{ + /// <summary> + /// Running Jobs tab load: the latest-snapshot grid read and the collector-status read (for the + /// msdb banner) fire concurrently — NpgsqlDataSource pools a connection for each — then the grid + /// goes through the filter manager's UpdateData so any active column filter survives the refresh. + /// </summary> + private async Task LoadRunningJobsAsync() + { + var jobsTask = _dataService.GetRunningJobsAsync(_server.ServerId); + var statusTask = _dataService.GetLatestRunningJobsCollectorStatusAsync(_server.ServerId); + var jobs = await jobsTask; + var status = await statusTask; + + _runningJobsFilterMgr!.UpdateData(jobs); + + RunningJobsMsdbWarning.Visibility = ShouldShowMsdbBanner(status) ? Visibility.Visible : Visibility.Collapsed; + } + + /// <summary> + /// The msdb-access banner rule, derived from the store (the viewer has no live msdb probe): Lite shows + /// the "grant the login msdb access" banner strictly from its real <c>_hasMsdbAccess</c> probe, so the + /// store-side stand-in is ONLY the running_jobs collector's <c>PERMISSIONS</c> outcome — the sole signal + /// that the service was denied msdb. A transient <c>ERROR</c> (timeout / network blip) is NOT a + /// permission problem and must not raise the misleading "grant access" guidance; <c>SUCCESS</c> / null + /// (never run) also hides it. Static + internal so the PERMISSIONS-only condition is unit-testable. + /// </summary> + internal static bool ShouldShowMsdbBanner(string? runningJobsCollectorStatus) => + string.Equals(runningJobsCollectorStatus, "PERMISSIONS", StringComparison.OrdinalIgnoreCase); +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.SessionStats.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.SessionStats.cs new file mode 100644 index 000000000..3a5a80cee --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.SessionStats.cs @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Session Stats inner tab — the Dashboard-parity port of ResourceMetricsContent's Session Stats +/// sub-tab (Dashboard/Controls/ResourceMetricsContent.SessionStats.cs) into the Darling viewer. A single +/// trend chart plots the server-wide session-status counts over the settable window — Total / Running / +/// Sleeping / Background / Dormant / Idle >30m / Waiting for Memory — and a summary strip below it shows +/// the non-chartable attribution the latest snapshot carries: the top application and top host by +/// connection count, and the number of distinct databases with connections. +/// <para> +/// The feed is <see cref="ViewerDataService.GetSessionStatsAsync"/> over <c>v_session_summary_stats</c> +/// (the server-wide summary collector, 1:1 with the Dashboard's <c>session_stats</c> table) — NOT the +/// per-application <c>v_session_stats</c> FinOps' Application Connections read uses. The chart BODY is the +/// shared <see cref="SessionStatsChartRenderer"/> (byte-identical to Lite's, each status series on its fixed +/// <see cref="ChartPalette.SeriesColor"/> "Session*" key, all-zero series skipped, Y-floored at 0 with a +/// bottom legend); this partial keeps the per-app data read, the hover wiring, the display-time projection +/// (<c>ViewerTimeHelper.ForDisplay</c>) it hands the renderer, and the summary strip (shaped by the shared +/// <see cref="SessionStatsSummary"/>). The chart's Copy/Save/CSV right-click menu is wired in +/// <c>ViewerServerTab.ChartContextMenu.cs</c>. +/// </para> +/// </summary> +public partial class ViewerServerTab +{ + private ChartHoverHelper? _sessionStatsHover; + + private SessionStatsChartRenderer? _sessionStatsRendererField; + /// <summary>The shared Session Stats trend-chart renderer, bound to the viewer's display-time projection.</summary> + private SessionStatsChartRenderer SessionStatsRenderer => + _sessionStatsRendererField ??= new SessionStatsChartRenderer(_chartHelper, ViewerTimeHelper.ForDisplay); + + /// <summary>Applies the shared chrome + hover to the Session Stats chart up front (constructor), so it + /// doesn't flash white before the tab's first load — matching the CPU/Memory/latch charts.</summary> + private void InitializeSessionStatsChart() + { + ApplyTheme(SessionStatsChart); + SessionStatsChart.Refresh(); + _sessionStatsHover = new ChartHoverHelper(SessionStatsChart, "sessions"); + } + + /// <summary>Loads the Session Stats tab: the session-summary snapshots over the toolbar's settable + /// window, then renders the status-count chart and the top-app / top-host / databases summary.</summary> + private async Task LoadSessionStatsAsync() + { + var (startUtc, endUtc) = GetWindowUtc(); + var data = await _dataService.GetSessionStatsAsync(_server.ServerId, startUtc, endUtc); + RenderSessionStatsChart(data); + } + + /// <summary>The session-status trend: hands the settable-window bounds + snapshots to the shared + /// <see cref="SessionStatsChartRenderer"/>, then updates the summary strip from the latest snapshot.</summary> + private void RenderSessionStatsChart(List<SessionStatsPoint> data) + { + var (startUtc, endUtc) = GetWindowUtc(); + double xMin = ViewerTimeHelper.ForDisplay(startUtc).ToOADate(); + double xMax = ViewerTimeHelper.ForDisplay(endUtc).ToOADate(); + + SessionStatsRenderer.Render(SessionStatsChart, _sessionStatsHover, data, xMin, xMax); + + /* Summary panel reflects the latest snapshot in the window (Dashboard uses the last data point). */ + var ordered = data.OrderBy(d => d.CollectionTime).ToList(); + UpdateSessionStatsSummary(ordered.Count > 0 ? ordered[^1] : null); + } + + /// <summary>Writes the latest snapshot's attribution into the summary strip (Dashboard parity: the + /// non-chartable Top Application / Top Host / Databases values), delegating the formatting to the shared + /// <see cref="SessionStatsSummary.Format"/> so the "name (count)" / N/A shaping is unit-tested.</summary> + private void UpdateSessionStatsSummary(SessionStatsPoint? data) + { + var (topApp, topHost, databases) = SessionStatsSummary.Format(data); + SessionStatsTopAppText.Text = topApp; + SessionStatsTopHostText.Text = topHost; + SessionStatsDatabasesText.Text = databases; + } + + /// <summary>Tears down the Session Stats hover helper (mirrors the other tabs' dispose) so its tooltip + /// popup + chart event handlers don't outlive a closed server tab.</summary> + public void DisposeSessionStatsHelpers() + { + _sessionStatsHover?.Dispose(); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.SlicerOverlay.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.SlicerOverlay.cs new file mode 100644 index 000000000..de241b18e --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.SlicerOverlay.cs @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Windows.Controls; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The slicer overlay-on-select (#683) — a port of Lite's <c>ServerTab.Grids.cs</c> grid→slicer overlay: +/// selecting a Top Queries / Top Procedures / Query Store row overlays THAT item's execution timeline as a +/// curve on the sub-tab's slicer (<see cref="TimeRangeSlicerControl.SetOverlay"/> / <c>ClearOverlay</c>, which +/// were ported earlier but left dead). The per-item timeline comes from +/// <see cref="ViewerDataService"/> (<c>ItemTimeline</c> partial); the metric shown tracks the slicer's current +/// sort metric via <see cref="ComputeOverlayPoints"/>. One deviation from Lite: Darling's <c>delta_*</c> reads +/// are already per-interval, so no C# row-over-row differencing is needed (Lite diffs cumulative history rows). +/// </summary> +public partial class ViewerServerTab +{ + /// <summary> + /// Wires the three query grids' SelectionChanged to their slicer overlay. Called from the constructor + /// after InitializeComponent, so the named grids exist. Subscribed for the life of the tab (the grids + /// are never re-created); no unsubscribe needed. + /// </summary> + private void WireSlicerOverlays() + { + QueryStatsGrid.SelectionChanged += QueryStatsGrid_SelectionChanged; + ProcedureStatsGrid.SelectionChanged += ProcedureStatsGrid_SelectionChanged; + QueryStoreGrid.SelectionChanged += QueryStoreGrid_SelectionChanged; + } + + private async void QueryStatsGrid_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (QueryStatsGrid.SelectedItem is not ViewerQueryStatsRow row || string.IsNullOrEmpty(row.QueryHash)) + { + /* A refresh momentarily clears the selection (UpdateData rebinds the grid); don't wipe the + overlay then — only when the user actually deselects. Mirrors Lite's _isRefreshing guard. */ + if (!_refreshInFlight) QueryStatsSlicer.ClearOverlay(); + return; + } + + try + { + var (startUtc, endUtc) = GetWindowUtc(); + var timeline = await _dataService.GetQueryStatsItemTimelineAsync( + _server.ServerId, row.DatabaseName, row.QueryHash, startUtc, endUtc); + QueryStatsSlicer.SetOverlay(ComputeOverlayPoints(timeline, _queryStatsSlicerMetric), row.QueryHash); + } + catch + { + QueryStatsSlicer.ClearOverlay(); + } + } + + private async void ProcedureStatsGrid_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (ProcedureStatsGrid.SelectedItem is not ViewerProcedureStatsRow row || string.IsNullOrEmpty(row.ObjectName)) + { + if (!_refreshInFlight) ProcStatsSlicer.ClearOverlay(); + return; + } + + try + { + var (startUtc, endUtc) = GetWindowUtc(); + var timeline = await _dataService.GetProcStatsItemTimelineAsync( + _server.ServerId, row.DatabaseName, row.SchemaName, row.ObjectName, startUtc, endUtc); + var label = row.ObjectName.Length > 30 ? row.ObjectName[..30] + "..." : row.ObjectName; + ProcStatsSlicer.SetOverlay(ComputeOverlayPoints(timeline, _procStatsSlicerMetric), label); + } + catch + { + ProcStatsSlicer.ClearOverlay(); + } + } + + private async void QueryStoreGrid_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (QueryStoreGrid.SelectedItem is not ViewerQueryStoreRow row) + { + if (!_refreshInFlight) QueryStoreSlicer.ClearOverlay(); + return; + } + + try + { + var (startUtc, endUtc) = GetWindowUtc(); + var timeline = await _dataService.GetQueryStoreItemTimelineAsync( + _server.ServerId, row.DatabaseName, row.QueryId, row.PlanId, startUtc, endUtc); + var label = !string.IsNullOrWhiteSpace(row.ModuleName) + ? row.ModuleName + : $"Query {row.QueryId} / Plan {row.PlanId}"; + QueryStoreSlicer.SetOverlay(ComputeOverlayPoints(timeline, _queryStoreSlicerMetric), label); + } + catch + { + QueryStoreSlicer.ClearOverlay(); + } + } + + /// <summary> + /// Projects an item's timeline to the (naive-UTC time, value) points the slicer overlays, picking the + /// magnitude field that matches the slicer's current sort metric (Lite's ComputeQueryOverlayPoints + /// selector). Zero-value cycles are dropped so an idle stretch doesn't draw a flat baseline. Pure + + /// static for unit testing. + /// </summary> + internal static List<(DateTime TimeUtc, double Value)> ComputeOverlayPoints( + IReadOnlyList<ViewerDataService.ItemTimelinePoint> timeline, string slicerMetric) + { + Func<ViewerDataService.ItemTimelinePoint, double> selector = slicerMetric switch + { + "TotalCpu" or "AvgCpu" => p => p.CpuMs, + "TotalReads" or "AvgReads" => p => p.Reads, + "TotalWrites" => p => p.Writes, + "TotalPhysReads" => p => p.PhysicalReads, + _ => p => p.ElapsedMs, /* TotalElapsed / AvgElapsed / Sessions / default */ + }; + + var points = new List<(DateTime TimeUtc, double Value)>(); + foreach (var p in timeline) + { + var value = selector(p); + if (value > 0) + points.Add((p.CollectionTime, value)); + } + return points; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.SystemEvents.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.SystemEvents.cs new file mode 100644 index 000000000..c0b0eac10 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.SystemEvents.cs @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The System Events inner tab (system_health parity, Stage 2b) — a faithful reproduction of the full +/// Dashboard's System Events, covering all nine Dashboard categories. Two lead sub-tabs (Corruption Events +/// + Contention Events) are the SYSTEM-component counter CHARTS — the Dashboard's presentation for those +/// two — rendered from a single sp_server_diagnostics SYSTEM feed with no significance filter (every +/// snapshot plotted over time), wired in <see cref="ViewerServerTab.SystemHealthCharts"/>. The remaining +/// sub-tabs are parse-on-read GRIDS, one per unique warning category (plus Darling's extra Significant +/// Waits): <see cref="ViewerDataService"/> fetches the raw event_xml for the category over the toolbar's +/// settable window, the Common <c>SystemHealthParser</c> shreds it, and <c>SystemEventSignificance</c> +/// keeps the sp_HealthParser-significant rows. Mirrors the FinOps tab's inner sub-tab-strip + DataGrid +/// conventions (visible-only load, shared column filters, per-sub-tab count indicator + refresh). +/// Timestamps render machine-local like the deadlock / blocked-process grids (the event's naive-UTC XE +/// @timestamp via <see cref="ViewerTimeHelper.ForDisplay"/>). +/// </summary> +public partial class ViewerServerTab +{ + /* System Events sub-tab order (matches the XAML TabItem order). Corruption + Contention lead — the + two SYSTEM-component counter-chart tabs, mirroring the Dashboard's System Events order (its + Corruption Events tab is the default). Both read the one SYSTEM-component feed and select their own + columns, so they share a single load (LoadSystemHealthChartsAsync). The rest keep Darling's existing + relative order. */ + private const int SystemEventsCorruptionSubTabIndex = 0; + private const int SystemEventsContentionSubTabIndex = 1; + private const int SystemEventsSchedulerIssuesSubTabIndex = 2; + private const int SystemEventsSevereErrorsSubTabIndex = 3; + private const int SystemEventsMemoryConditionsSubTabIndex = 4; + private const int SystemEventsMemoryBrokerSubTabIndex = 5; + private const int SystemEventsMemoryNodeOomSubTabIndex = 6; + private const int SystemEventsSignificantWaitsSubTabIndex = 7; + private const int SystemEventsCpuTasksSubTabIndex = 8; + private const int SystemEventsIoIssuesSubTabIndex = 9; + private const int SystemEventsDefaultTraceSubTabIndex = 10; + + private DataGridFilterManager<SchedulerIssueRow>? _seSchedulerFilterMgr; + private DataGridFilterManager<SevereErrorRow>? _seSevereErrorFilterMgr; + private DataGridFilterManager<MemoryConditionsRow>? _seMemoryConditionsFilterMgr; + private DataGridFilterManager<MemoryBrokerRow>? _seMemoryBrokerFilterMgr; + private DataGridFilterManager<MemoryNodeOomRow>? _seMemoryNodeOomFilterMgr; + private DataGridFilterManager<SignificantWaitRow>? _seSignificantWaitsFilterMgr; + private DataGridFilterManager<CpuTasksRow>? _seCpuTasksFilterMgr; + private DataGridFilterManager<IoIssuesRow>? _seIoIssuesFilterMgr; + private DataGridFilterManager<DefaultTraceEventRow>? _seDefaultTraceFilterMgr; + + /// <summary> + /// Registers the eight System Events grids' column-filter managers into the shared + /// <c>_filterManagers</c> map (defined in <c>ViewerServerTab.Filters.cs</c>). Called from the + /// constructor after InitializeComponent, alongside the other Initialize* hooks. The Corruption / + /// Contention chart sub-tabs have no grids, so they register no filter manager here. + /// </summary> + private void InitializeSystemEventsTab() + { + _seSchedulerFilterMgr = new DataGridFilterManager<SchedulerIssueRow>(SchedulerIssuesGrid); + _seSevereErrorFilterMgr = new DataGridFilterManager<SevereErrorRow>(SevereErrorsGrid); + _seMemoryConditionsFilterMgr = new DataGridFilterManager<MemoryConditionsRow>(MemoryConditionsGrid); + _seMemoryBrokerFilterMgr = new DataGridFilterManager<MemoryBrokerRow>(MemoryBrokerGrid); + _seMemoryNodeOomFilterMgr = new DataGridFilterManager<MemoryNodeOomRow>(MemoryNodeOomGrid); + _seSignificantWaitsFilterMgr = new DataGridFilterManager<SignificantWaitRow>(SignificantWaitsGrid); + _seCpuTasksFilterMgr = new DataGridFilterManager<CpuTasksRow>(CpuTasksGrid); + _seIoIssuesFilterMgr = new DataGridFilterManager<IoIssuesRow>(IoIssuesGrid); + _seDefaultTraceFilterMgr = new DataGridFilterManager<DefaultTraceEventRow>(DefaultTraceGrid); + + _filterManagers[SchedulerIssuesGrid] = _seSchedulerFilterMgr; + _filterManagers[SevereErrorsGrid] = _seSevereErrorFilterMgr; + _filterManagers[MemoryConditionsGrid] = _seMemoryConditionsFilterMgr; + _filterManagers[MemoryBrokerGrid] = _seMemoryBrokerFilterMgr; + _filterManagers[MemoryNodeOomGrid] = _seMemoryNodeOomFilterMgr; + _filterManagers[SignificantWaitsGrid] = _seSignificantWaitsFilterMgr; + _filterManagers[CpuTasksGrid] = _seCpuTasksFilterMgr; + _filterManagers[IoIssuesGrid] = _seIoIssuesFilterMgr; + _filterManagers[DefaultTraceGrid] = _seDefaultTraceFilterMgr; + } + + /// <summary> + /// A System Events sub-tab switch reloads through the shell's overlap-guarded + /// <see cref="RefreshActiveInnerTabAsync"/> (mirrors the FinOps/Queries sub-tab handlers). Gated on + /// <see cref="FrameworkElement.IsLoaded"/> and the sub-TabControl's own selection so build-time and + /// bubbled child selections are ignored. + /// </summary> + private async void SystemEventsSubTabs_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (!ReferenceEquals(e.OriginalSource, SystemEventsSubTabControl) || !IsLoaded) + { + return; + } + + await RefreshActiveInnerTabAsync(); + } + + /// <summary> + /// Loads the System Events tab's ACTIVE sub-tab only (Lite's visible-only rule). The shell's + /// <see cref="LoadInnerTabAsync"/> owns the try/catch that surfaces failures on the status bar. + /// </summary> + private async Task LoadSystemEventsAsync() + { + switch (SystemEventsSubTabControl.SelectedIndex) + { + case SystemEventsSchedulerIssuesSubTabIndex: + await LoadSchedulerIssuesAsync(); + break; + case SystemEventsSevereErrorsSubTabIndex: + await LoadSevereErrorsAsync(); + break; + case SystemEventsMemoryConditionsSubTabIndex: + await LoadMemoryConditionsAsync(); + break; + case SystemEventsMemoryBrokerSubTabIndex: + await LoadMemoryBrokerAsync(); + break; + case SystemEventsMemoryNodeOomSubTabIndex: + await LoadMemoryNodeOomAsync(); + break; + case SystemEventsSignificantWaitsSubTabIndex: + await LoadSignificantWaitsAsync(); + break; + case SystemEventsCpuTasksSubTabIndex: + await LoadCpuTasksAsync(); + break; + case SystemEventsIoIssuesSubTabIndex: + await LoadIoIssuesAsync(); + break; + case SystemEventsDefaultTraceSubTabIndex: + await LoadDefaultTraceEventsAsync(); + break; + // Corruption Events (0) and Contention Events (1) share the one SYSTEM-component feed, so both + // render from a single load — mirroring the Dashboard's case 0/case 1 → RefreshSystemHealthAsync. + // This is also the default, so the first-shown sub-tab (Corruption Events) loads on activation. + case SystemEventsCorruptionSubTabIndex: + case SystemEventsContentionSubTabIndex: + default: + await LoadSystemHealthChartsAsync(); + break; + } + } + + private async Task LoadSchedulerIssuesAsync() + { + var (startUtc, endUtc) = GetWindowUtc(); + var data = await _dataService.GetSchedulerIssuesAsync(_server.ServerId, startUtc, endUtc); + _seSchedulerFilterMgr!.UpdateData(data); + SchedulerIssuesNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + SchedulerIssuesCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : ""; + } + + private async Task LoadSevereErrorsAsync() + { + var (startUtc, endUtc) = GetWindowUtc(); + var data = await _dataService.GetSevereErrorsAsync(_server.ServerId, startUtc, endUtc, databaseNames: SelectedDatabaseFilter); + _seSevereErrorFilterMgr!.UpdateData(data); + SevereErrorsNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + SevereErrorsCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : ""; + } + + private async Task LoadMemoryConditionsAsync() + { + var (startUtc, endUtc) = GetWindowUtc(); + var data = await _dataService.GetMemoryConditionsAsync(_server.ServerId, startUtc, endUtc); + _seMemoryConditionsFilterMgr!.UpdateData(data); + MemoryConditionsNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + MemoryConditionsCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : ""; + } + + private async Task LoadMemoryBrokerAsync() + { + var (startUtc, endUtc) = GetWindowUtc(); + var data = await _dataService.GetMemoryBrokerAsync(_server.ServerId, startUtc, endUtc); + _seMemoryBrokerFilterMgr!.UpdateData(data); + MemoryBrokerNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + MemoryBrokerCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : ""; + } + + private async Task LoadMemoryNodeOomAsync() + { + var (startUtc, endUtc) = GetWindowUtc(); + var data = await _dataService.GetMemoryNodeOomAsync(_server.ServerId, startUtc, endUtc); + _seMemoryNodeOomFilterMgr!.UpdateData(data); + MemoryNodeOomNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + MemoryNodeOomCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : ""; + } + + private async Task LoadSignificantWaitsAsync() + { + var (startUtc, endUtc) = GetWindowUtc(); + var data = await _dataService.GetSignificantWaitsAsync(_server.ServerId, startUtc, endUtc); + _seSignificantWaitsFilterMgr!.UpdateData(data); + SignificantWaitsNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + SignificantWaitsCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : ""; + } + + private async Task LoadCpuTasksAsync() + { + var (startUtc, endUtc) = GetWindowUtc(); + var data = await _dataService.GetCpuTasksAsync(_server.ServerId, startUtc, endUtc); + _seCpuTasksFilterMgr!.UpdateData(data); + CpuTasksNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + CpuTasksCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : ""; + } + + private async Task LoadIoIssuesAsync() + { + var (startUtc, endUtc) = GetWindowUtc(); + var data = await _dataService.GetIoIssuesAsync(_server.ServerId, startUtc, endUtc); + _seIoIssuesFilterMgr!.UpdateData(data); + IoIssuesNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + IoIssuesCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : ""; + } + + private async Task LoadDefaultTraceEventsAsync() + { + var (startUtc, endUtc) = GetWindowUtc(); + var data = await _dataService.GetDefaultTraceEventsAsync(_server.ServerId, startUtc, endUtc, databaseNames: SelectedDatabaseFilter); + _seDefaultTraceFilterMgr!.UpdateData(data); + DefaultTraceNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + DefaultTraceCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : ""; + } + + /// <summary> + /// The per-sub-tab Refresh button reloads the active System Events sub-tab through the same status-bar + /// error surfacing the shell's <see cref="LoadInnerTabAsync"/> uses (mirrors FinOps' RunFinOpsLoad). + /// </summary> + private async void SystemEventsRefresh_Click(object sender, RoutedEventArgs e) + { + try + { + await LoadSystemEventsAsync(); + StatusChanged?.Invoke($"{_server.DisplayName} — refreshed {DateTime.Now:HH:mm:ss}"); + } + catch (Exception ex) + { + StatusChanged?.Invoke($"refresh failed: {ex.Message}"); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.SystemHealthCharts.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.SystemHealthCharts.cs new file mode 100644 index 000000000..288749961 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.SystemHealthCharts.cs @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The System Events tab's two SYSTEM-component counter-chart sub-tabs — Corruption Events and Contention +/// Events. Both sub-tabs read the ONE SYSTEM-component feed (<see cref="ViewerDataService.GetSystemHealthAsync"/>) +/// and select their own columns, so a single load renders all eight charts — exactly as the Dashboard's +/// <c>RefreshSystemHealthAsync</c> feeds both tabs. Unlike the grid categories these plot EVERY collected +/// snapshot over time (no significance filter); counts floor at 0 and the time axis is bound to the toolbar's +/// settable window (<see cref="ViewerServerTab.GetWindowUtc"/>). The chart BODIES are the shared +/// <see cref="SystemHealthChartRenderer"/> (byte-identical to Lite's); this partial keeps the per-app data +/// read, the hover wiring, and the display-time projection it hands the renderer. +/// </summary> +public partial class ViewerServerTab +{ + private ChartHoverHelper? _badPagesHover; + private ChartHoverHelper? _dumpRequestsHover; + private ChartHoverHelper? _accessViolationsHover; + private ChartHoverHelper? _writeAccessViolationsHover; + private ChartHoverHelper? _nonYieldingTasksHover; + private ChartHoverHelper? _latchWarningsHover; + private ChartHoverHelper? _sickSpinlocksHover; + private ChartHoverHelper? _cpuComparisonHover; + + private SystemHealthChartRenderer? _sysHealthRendererField; + /// <summary>The shared System Events counter-chart renderer, bound to the viewer's display-time projection.</summary> + private SystemHealthChartRenderer SysHealthRenderer => + _sysHealthRendererField ??= new SystemHealthChartRenderer(_chartHelper, ViewerTimeHelper.ForDisplay); + + /// <summary>Applies the shared chrome + hover to the eight Corruption/Contention charts up front + /// (constructor), so they don't flash white before the tab's first load — matching the CPU/Latch charts. + /// Hover units mirror the Dashboard's (events / backoffs / %).</summary> + private void InitializeSystemHealthCharts() + { + foreach (var chart in new[] + { + BadPagesChart, DumpRequestsChart, AccessViolationsChart, WriteAccessViolationsChart, + NonYieldingTasksChart, LatchWarningsChart, SickSpinlocksChart, CpuComparisonChart, + }) + { + ApplyTheme(chart); + chart.Refresh(); + } + + _badPagesHover = new ChartHoverHelper(BadPagesChart, "events"); + _dumpRequestsHover = new ChartHoverHelper(DumpRequestsChart, "events"); + _accessViolationsHover = new ChartHoverHelper(AccessViolationsChart, "events"); + _writeAccessViolationsHover = new ChartHoverHelper(WriteAccessViolationsChart, "events"); + _nonYieldingTasksHover = new ChartHoverHelper(NonYieldingTasksChart, "events"); + _latchWarningsHover = new ChartHoverHelper(LatchWarningsChart, "events"); + _sickSpinlocksHover = new ChartHoverHelper(SickSpinlocksChart, "backoffs"); + _cpuComparisonHover = new ChartHoverHelper(CpuComparisonChart, "%"); + } + + /// <summary> + /// Loads both Corruption + Contention chart sub-tabs from a single SYSTEM-component read over the + /// toolbar's settable window (the reads are already bounded on both ends, so no client-side end-trim + /// is needed unlike the start-only CPU/tempdb reads). Mirrors the Dashboard's one-refresh-feeds-both. + /// </summary> + private async Task LoadSystemHealthChartsAsync() + { + var (startUtc, endUtc) = GetWindowUtc(); + var data = await _dataService.GetSystemHealthAsync(_server.ServerId, startUtc, endUtc); + + double xMin = ViewerTimeHelper.ForDisplay(startUtc).ToOADate(); + double xMax = ViewerTimeHelper.ForDisplay(endUtc).ToOADate(); + + // Corruption Events (2x2). + SysHealthRenderer.RenderCounterChart(BadPagesChart, _badPagesHover, data, d => d.BadPagesDetected, "Bad Pages", + ChartPalette.CyclingColor(3), xMin, xMax); + SysHealthRenderer.RenderCounterChart(DumpRequestsChart, _dumpRequestsHover, data, d => d.IntervalDumpRequests, "Dump Requests", + ChartPalette.CyclingColor(2), xMin, xMax); + SysHealthRenderer.RenderCounterChart(AccessViolationsChart, _accessViolationsHover, data, d => d.IsAccessViolationOccurred, "Access Violations", + ChartPalette.CyclingColor(4), xMin, xMax); + SysHealthRenderer.RenderCounterChart(WriteAccessViolationsChart, _writeAccessViolationsHover, data, d => d.WriteAccessViolationCount, "Write Access Violations", + ChartPalette.CyclingColor(0), xMin, xMax); + + // Contention Events (2x2). + SysHealthRenderer.RenderCounterChart(NonYieldingTasksChart, _nonYieldingTasksHover, data, d => d.NonYieldingTasksReported, "Non-Yielding Tasks", + ChartPalette.CyclingColor(3), xMin, xMax); + SysHealthRenderer.RenderCounterChart(LatchWarningsChart, _latchWarningsHover, data, d => d.LatchWarnings, "Latch Warnings", + ChartPalette.CyclingColor(2), xMin, xMax); + SysHealthRenderer.RenderSickSpinlocksChart(SickSpinlocksChart, _sickSpinlocksHover, data, xMin, xMax); + SysHealthRenderer.RenderCpuComparisonChart(CpuComparisonChart, _cpuComparisonHover, data, xMin, xMax); + } + + /// <summary>Tears down the eight Corruption/Contention hover helpers (mirrors the other tabs' dispose) + /// so their tooltip popups + chart event handlers don't outlive a closed server tab.</summary> + public void DisposeSystemHealthChartHelpers() + { + _badPagesHover?.Dispose(); + _dumpRequestsHover?.Dispose(); + _accessViolationsHover?.Dispose(); + _writeAccessViolationsHover?.Dispose(); + _nonYieldingTasksHover?.Dispose(); + _latchWarningsHover?.Dispose(); + _sickSpinlocksHover?.Dispose(); + _cpuComparisonHover?.Dispose(); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.TimeRange.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.TimeRange.cs new file mode 100644 index 000000000..ec86947db --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.TimeRange.cs @@ -0,0 +1,632 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Media; +using System.Windows.Threading; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The per-server toolbar's time-window + auto-refresh + time-display state (the header row added above +/// the inner tab strip), mirroring Lite's <c>ServerTab.TimeRange.cs</c>: the custom-range pickers are read +/// in the CURRENT display mode (Server/Local/UTC — <see cref="ViewerTimeHelper"/>, ported from Lite's +/// <c>TimeDisplayModeBox</c>) and inverted back to the store's naive-UTC bounds here +/// (<see cref="ViewerTimeHelper.DisplayToNaiveUtc(System.DateTime)"/>). This replaces the old hardcoded +/// 24-hour <c>s_dataWindow</c>: every inner-tab load reads <see cref="GetWindowUtc"/> (preset +/// 1h/4h/12h/24h/7d or a custom From/To), the auto-refresh cadence comes from the toolbar instead of +/// MainWindow's fleet-refresh timer, and the display-mode picker re-renders the visible tab so every +/// timestamp honors the chosen mode. +/// </summary> +public partial class ViewerServerTab +{ + /// <summary>The "Custom Range" slot in <c>TimeRangeCombo</c> (after 1h/4h/12h/24h/7d).</summary> + private const int CustomRangeIndex = 5; + + /// <summary>Guards the range handlers while pickers/combo are set programmatically (Apply-to-all, + /// custom-range defaults) so one external change drives exactly one reload, not a cascade.</summary> + private bool _suppressRangeEvents; + + private DispatcherTimer? _autoRefreshTimer; + + /// <summary> + /// Raised when the user clicks "Apply to All": MainWindow broadcasts the selected range (index plus, + /// for a custom range, the From/To in the CURRENT display-mode wall clock) to every other open server + /// tab. The source tab is carried so the broadcast can skip it (it already holds the range). + /// </summary> + public event Action<ViewerServerTab, int, DateTime?, DateTime?>? ApplyTimeRangeRequested; + + /// <summary> + /// Raised when the user changes the toolbar's Server/Local/UTC display picker. MainWindow persists the + /// new global mode to <see cref="ViewerAppSettings.TimeDisplayMode"/> and syncs every other open tab's + /// picker (the mode is process-wide). The raising tab has already updated + /// <see cref="ViewerTimeHelper.CurrentDisplayMode"/> and reloaded itself. + /// </summary> + public event Action<TimeDisplayMode>? DisplayModeChanged; + + /// <summary>This server's UTC offset in minutes (from <c>server_properties.utc_offset_minutes</c>), + /// applied to <see cref="ViewerTimeHelper.UtcOffsetMinutes"/> before this tab renders so Server-time + /// mode shows the monitored server's own local time. Seeds to the viewer machine's offset until the + /// per-server value is loaded (so Server mode degrades gracefully to ~Local meanwhile).</summary> + private int _serverUtcOffsetMinutes = (int)TimeZoneInfo.Local.GetUtcOffset(DateTime.UtcNow).TotalMinutes; + + /// <summary>Caches the one-shot offset load so repeated refreshes don't re-query it.</summary> + private Task? _serverOffsetLoad; + + /// <summary>Preset combo index → hours back. Index 5 (custom) and any stray value fall to the + /// viewer's historical 24-hour default. Pure + static so the mapping is unit-testable.</summary> + internal static int TimeRangeIndexToHours(int index) => index switch + { + 0 => 1, + 1 => 4, + 2 => 12, + 3 => 24, + 4 => 168, + _ => 24, + }; + + /// <summary>Populates the custom-range hour (00:00–23:00) and minute (:00/:15/:30/:45) combos, + /// matching Lite's <c>InitializeTimeComboBoxes</c>. Called from the constructor after + /// <c>InitializeComponent</c>.</summary> + private void InitializeTimeComboBoxes() + { + var hours = new List<string>(); + for (int h = 0; h < 24; h++) + { + hours.Add(DateTime.Today.AddHours(h).ToString("HH:00")); + } + + FromHourCombo.ItemsSource = hours; + ToHourCombo.ItemsSource = hours; + FromHourCombo.SelectedIndex = 0; // 00:00 + ToHourCombo.SelectedIndex = 23; // 23:00 + + var minutes = new List<string> { ":00", ":15", ":30", ":45" }; + FromMinuteCombo.ItemsSource = minutes; + ToMinuteCombo.ItemsSource = minutes; + FromMinuteCombo.SelectedIndex = 0; // :00 + ToMinuteCombo.SelectedIndex = 3; // :45 + } + + /// <summary>Wires the auto-refresh timer (default 1 minute, matching the old fixed 60-second cadence) + /// and starts it when the toolbar's checkbox is checked. Called from the constructor.</summary> + private void InitializeAutoRefreshTimer() + { + _autoRefreshTimer = new DispatcherTimer(); + UpdateAutoRefreshInterval(); + _autoRefreshTimer.Tick += OnAutoRefreshTick; + if (AutoRefreshCheckBox.IsChecked == true) + { + _autoRefreshTimer.Start(); + } + } + + private async void OnAutoRefreshTick(object? sender, EventArgs e) + { + /* Only the VISIBLE server tab auto-refreshes (the viewer's visible-only rule); a background tab's + timer no-ops until the user switches to it, at which point MainWindow reloads it on activation. */ + if (!IsVisible) + { + return; + } + + await RefreshActiveInnerTabAsync(); + } + + // ── Window computation ─────────────────────────────────────────────────────────── + + /// <summary>True when the user picked "Custom Range" AND both date pickers hold a value.</summary> + private bool IsCustomRange => TimeRangeCombo.SelectedIndex == CustomRangeIndex + && FromDatePicker?.SelectedDate != null + && ToDatePicker?.SelectedDate != null; + + /// <summary> + /// The current window as store-native naive-UTC bounds, driving every inner-tab load (it replaces the + /// old fixed 24-hour <c>s_dataWindow</c>). A preset ends "now"; a valid custom range uses its From/To. + /// </summary> + private (DateTime startUtc, DateTime endUtc) GetWindowUtc() + { + if (IsCustomRange) + { + var (fromUtc, toUtc) = GetCustomRangeUtc(); + if (fromUtc.HasValue && toUtc.HasValue) + { + return (fromUtc.Value, toUtc.Value); + } + } + + var endUtc = DateTime.UtcNow; + return (endUtc.AddHours(-TimeRangeIndexToHours(TimeRangeCombo.SelectedIndex)), endUtc); + } + + /// <summary>Hours-back for the reads that take an integer window (the Overview lanes + Collection + /// Health log). A custom range collapses to its rounded-up span so those surfaces still track it.</summary> + private int GetWindowHoursBack() + { + if (IsCustomRange) + { + var (fromUtc, toUtc) = GetCustomRangeUtc(); + if (fromUtc.HasValue && toUtc.HasValue) + { + var hours = (int)Math.Ceiling((toUtc.Value - fromUtc.Value).TotalHours); + return hours < 1 ? 1 : hours; + } + } + + return TimeRangeIndexToHours(TimeRangeCombo.SelectedIndex); + } + + /// <summary>Custom From/To as naive-UTC bounds for the Overview lanes (null,null when not a valid + /// custom range — the lanes then fall back to their hours-back window).</summary> + private (DateTime? fromUtc, DateTime? toUtc) GetOverviewCustomRange() + => IsCustomRange ? GetCustomRangeUtc() : (null, null); + + /// <summary>Reads the custom From/To pickers as naive-UTC bounds, or (null,null) when either is unset.</summary> + private (DateTime? fromUtc, DateTime? toUtc) GetCustomRangeUtc() + { + var fromDisplay = GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo); + var toDisplay = GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo); + if (!fromDisplay.HasValue || !toDisplay.HasValue) + { + return (null, null); + } + + /* The pickers hold wall-clock time in the CURRENT display mode; invert to the store's naive-UTC + window bounds (the reads re-stamp them Unspecified). ViewerTimeHelper.DisplayToNaiveUtc uses the + active server offset applied before each load, so a Server-mode window maps to the right UTC. */ + return (ViewerTimeHelper.DisplayToNaiveUtc(fromDisplay.Value), ViewerTimeHelper.DisplayToNaiveUtc(toDisplay.Value)); + } + + private static DateTime? GetDateTimeFromPickers(DatePicker datePicker, ComboBox hourCombo, ComboBox minuteCombo) + { + if (datePicker?.SelectedDate is not { } date) + { + return null; + } + + int hour = hourCombo.SelectedIndex >= 0 ? hourCombo.SelectedIndex : 0; + int minute = minuteCombo.SelectedIndex >= 0 ? minuteCombo.SelectedIndex * 15 : 0; + return date.Date.AddHours(hour).AddMinutes(minute); + } + + // ── Toolbar handlers ───────────────────────────────────────────────────────────── + + private async void TimeRangeCombo_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (!IsLoaded || _suppressRangeEvents) + { + return; + } + + var isCustom = TimeRangeCombo.SelectedIndex == CustomRangeIndex; + var visibility = isCustom ? Visibility.Visible : Visibility.Collapsed; + + if (FromDatePicker != null) + { + FromDatePicker.Visibility = visibility; + FromHourCombo.Visibility = visibility; + FromMinuteCombo.Visibility = visibility; + ToLabel.Visibility = visibility; + ToDatePicker.Visibility = visibility; + ToHourCombo.Visibility = visibility; + ToMinuteCombo.Visibility = visibility; + + if (isCustom && FromDatePicker.SelectedDate == null) + { + /* Seed a sensible default so switching to Custom shows a real window; the picker changes + below drive the reload. Suppress so the two picker writes coalesce into one load. */ + _suppressRangeEvents = true; + FromDatePicker.SelectedDate = DateTime.Today.AddDays(-1); + ToDatePicker.SelectedDate = DateTime.Today; + _suppressRangeEvents = false; + } + } + + /* Presets reload here; a custom range reloads off the picker changes (below), except the seeded + default above which is suppressed — so drive that one reload explicitly. */ + if (!isCustom || FromDatePicker?.SelectedDate != null) + { + await RefreshActiveInnerTabAsync(); + } + } + + private async void CustomDateRange_Changed(object sender, SelectionChangedEventArgs e) + { + if (!IsLoaded || _suppressRangeEvents) + { + return; + } + + if (FromDatePicker?.SelectedDate != null && ToDatePicker?.SelectedDate != null) + { + await RefreshActiveInnerTabAsync(); + } + } + + private async void CustomTimeCombo_Changed(object sender, SelectionChangedEventArgs e) + { + if (!IsLoaded || _suppressRangeEvents) + { + return; + } + + if (FromDatePicker?.SelectedDate != null && ToDatePicker?.SelectedDate != null) + { + await RefreshActiveInnerTabAsync(); + } + } + + private async void RefreshDataButton_Click(object sender, RoutedEventArgs e) + { + RefreshDataButton.IsEnabled = false; + try + { + await RefreshActiveInnerTabAsync(); + } + finally + { + RefreshDataButton.IsEnabled = true; + } + } + + private void AutoRefreshCheckBox_Changed(object sender, RoutedEventArgs e) + { + if (_autoRefreshTimer == null) + { + return; + } + + if (AutoRefreshCheckBox.IsChecked == true) + { + UpdateAutoRefreshInterval(); + _autoRefreshTimer.Start(); + } + else + { + _autoRefreshTimer.Stop(); + } + } + + private void AutoRefreshInterval_Changed(object sender, SelectionChangedEventArgs e) + { + if (_autoRefreshTimer == null) + { + return; + } + + UpdateAutoRefreshInterval(); + } + + private void UpdateAutoRefreshInterval() + { + if (_autoRefreshTimer == null || AutoRefreshIntervalCombo == null) + { + return; + } + + _autoRefreshTimer.Interval = AutoRefreshIntervalCombo.SelectedIndex switch + { + 0 => TimeSpan.FromSeconds(30), + 1 => TimeSpan.FromMinutes(1), + 2 => TimeSpan.FromMinutes(5), + _ => TimeSpan.FromMinutes(1), + }; + } + + private void ApplyTimeRangeToAll_Click(object sender, RoutedEventArgs e) + { + DateTime? fromLocal = null; + DateTime? toLocal = null; + if (TimeRangeCombo.SelectedIndex == CustomRangeIndex) + { + fromLocal = GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo); + toLocal = GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo); + } + + ApplyTimeRangeRequested?.Invoke(this, TimeRangeCombo.SelectedIndex, fromLocal, toLocal); + } + + // ── Time-display mode (Server / Local / UTC) ───────────────────────────────────── + + /// <summary>Maps a picker item's Tag to the mode; unknown/absent falls to Server-time (Lite's default).</summary> + private static TimeDisplayMode ParseDisplayMode(string? tag) => tag switch + { + "LocalTime" => TimeDisplayMode.LocalTime, + "UTC" => TimeDisplayMode.UTC, + _ => TimeDisplayMode.ServerTime, + }; + + /// <summary> + /// Loads this server's UTC offset once (from <c>server_properties</c>) and caches it. Awaited before + /// each render (see <c>RefreshActiveInnerTabAsync</c>) so the visible tab's timestamps use ITS server's + /// offset; a failure keeps the machine-local seed. Idempotent — the cached Task means repeated + /// refreshes don't re-query. + /// </summary> + internal Task EnsureServerOffsetLoadedAsync() => _serverOffsetLoad ??= LoadServerOffsetAsync(); + + private async Task LoadServerOffsetAsync() + { + try + { + var offset = await _dataService.GetServerUtcOffsetMinutesAsync(_server.ServerId); + if (offset.HasValue) + { + _serverUtcOffsetMinutes = offset.Value; + } + } + catch + { + /* No collected offset yet (or a read hiccup): keep the viewer machine's offset so Server mode + degrades gracefully to ~Local until server_properties.utc_offset_minutes is populated. */ + } + } + + /// <summary>Pushes this tab's server offset onto the process-wide helper. Called before every render so + /// the visible tab (only it renders) drives the conversions with its own server's offset.</summary> + internal void ApplyServerOffsetToHelper() => ViewerTimeHelper.UtcOffsetMinutes = _serverUtcOffsetMinutes; + + /// <summary> + /// The Server/Local/UTC picker changed: apply this tab's offset, re-express the custom-range pickers + /// from the old mode into the new one (same absolute window, mirroring Lite's + /// <c>TimeDisplayMode_SelectionChanged</c>), set the new global mode, then persist (via + /// <see cref="DisplayModeChanged"/>) and reload the visible tab so every timestamp and chart re-renders + /// in the new mode. No-op while loading/suppressed or when the mode is unchanged. + /// </summary> + private async void TimeDisplayMode_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (!IsLoaded || _suppressRangeEvents) + { + return; + } + if (TimeDisplayModeBox.SelectedItem is not ComboBoxItem item) + { + return; + } + + var mode = ParseDisplayMode(item.Tag?.ToString()); + if (mode == ViewerTimeHelper.CurrentDisplayMode) + { + return; + } + + var oldMode = ViewerTimeHelper.CurrentDisplayMode; + + /* Server-mode conversions (and the picker re-conversion below) need this server's offset. */ + await EnsureServerOffsetLoadedAsync(); + ApplyServerOffsetToHelper(); + + /* Re-express the custom-range pickers so the same absolute window stays selected across the switch. + Suppress range events while rewriting them so this drives exactly one reload (below), not a cascade. */ + _suppressRangeEvents = true; + try + { + var fromPicked = IsCustomRange ? GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo) : null; + var toPicked = IsCustomRange ? GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo) : null; + if (fromPicked.HasValue && toPicked.HasValue) + { + var fromUtc = ViewerTimeHelper.DisplayToNaiveUtc(fromPicked.Value, oldMode); + var toUtc = ViewerTimeHelper.DisplayToNaiveUtc(toPicked.Value, oldMode); + ViewerTimeHelper.CurrentDisplayMode = mode; + var fromNew = ViewerTimeHelper.ForDisplay(fromUtc); + var toNew = ViewerTimeHelper.ForDisplay(toUtc); + FromDatePicker!.SelectedDate = fromNew.Date; + FromHourCombo.SelectedIndex = fromNew.Hour; + FromMinuteCombo.SelectedIndex = fromNew.Minute / 15; + ToDatePicker!.SelectedDate = toNew.Date; + ToHourCombo.SelectedIndex = toNew.Hour; + ToMinuteCombo.SelectedIndex = toNew.Minute / 15; + } + else + { + ViewerTimeHelper.CurrentDisplayMode = mode; + } + } + finally + { + _suppressRangeEvents = false; + } + + DisplayModeChanged?.Invoke(mode); + await RefreshActiveInnerTabAsync(); + } + + /// <summary> + /// Sets the toolbar's display-mode picker WITHOUT raising a change — used by MainWindow to keep every + /// open tab's picker in sync when the global mode changes elsewhere (another tab's picker, or the + /// Settings window). Suppressed so it drives no reload/persist. Also used to seed the picker on + /// construction from the persisted mode. + /// </summary> + public void SetDisplayModeSelection(TimeDisplayMode mode) + { + if (TimeDisplayModeBox is null) + { + return; + } + + var tag = mode.ToString(); + _suppressRangeEvents = true; + try + { + foreach (var candidate in TimeDisplayModeBox.Items) + { + if (candidate is ComboBoxItem item && item.Tag?.ToString() == tag) + { + TimeDisplayModeBox.SelectedItem = item; + break; + } + } + } + finally + { + _suppressRangeEvents = false; + } + } + + /// <summary> + /// Applies a range chosen on another server tab (the "Apply to All" broadcast). Sets the pickers and + /// combo under the suppress guard so the copy doesn't cascade multiple reloads, then drives exactly + /// one reload of this tab's active inner tab. + /// </summary> + public void ApplyExternalTimeRange(int index, DateTime? customFromLocal, DateTime? customToLocal) + { + _suppressRangeEvents = true; + try + { + var isCustom = index == CustomRangeIndex && customFromLocal.HasValue && customToLocal.HasValue; + var visibility = isCustom ? Visibility.Visible : Visibility.Collapsed; + + if (FromDatePicker != null) + { + FromDatePicker.Visibility = visibility; + FromHourCombo.Visibility = visibility; + FromMinuteCombo.Visibility = visibility; + ToLabel.Visibility = visibility; + ToDatePicker.Visibility = visibility; + ToHourCombo.Visibility = visibility; + ToMinuteCombo.Visibility = visibility; + } + + if (isCustom) + { + FromDatePicker!.SelectedDate = customFromLocal!.Value.Date; + FromHourCombo.SelectedIndex = customFromLocal.Value.Hour; + FromMinuteCombo.SelectedIndex = customFromLocal.Value.Minute / 15; + ToDatePicker!.SelectedDate = customToLocal!.Value.Date; + ToHourCombo.SelectedIndex = customToLocal.Value.Hour; + ToMinuteCombo.SelectedIndex = customToLocal.Value.Minute / 15; + } + + TimeRangeCombo.SelectedIndex = index; + } + finally + { + _suppressRangeEvents = false; + } + + _ = RefreshActiveInnerTabAsync(); + } + + /// <summary> + /// Scopes the toolbar to an explicit naive-UTC <c>[from, to)</c> window — the Performance Calendar day + /// drill's "set the time window to that day" step. Selects "Custom Range", reveals the pickers, and writes + /// them in the active display mode (<see cref="ViewerTimeHelper.ForDisplay(System.DateTime)"/> inverts the + /// naive-UTC bounds to the picker wall-clock, so <see cref="GetWindowUtc"/> round-trips back to the same + /// window). Runs under <see cref="_suppressRangeEvents"/> so it drives no reload of its own — the caller + /// (the day drill) loads the target inner tab itself. Mirrors <see cref="ApplyExternalTimeRange"/>'s picker + /// manipulation, but from UTC and without the reload. + /// </summary> + internal void SetToolbarWindowUtc(DateTime fromUtc, DateTime toUtc) + { + var fromDisplay = ViewerTimeHelper.ForDisplay(fromUtc); + var toDisplay = ViewerTimeHelper.ForDisplay(toUtc); + + _suppressRangeEvents = true; + try + { + if (FromDatePicker != null) + { + FromDatePicker.Visibility = Visibility.Visible; + FromHourCombo.Visibility = Visibility.Visible; + FromMinuteCombo.Visibility = Visibility.Visible; + ToLabel.Visibility = Visibility.Visible; + ToDatePicker.Visibility = Visibility.Visible; + ToHourCombo.Visibility = Visibility.Visible; + ToMinuteCombo.Visibility = Visibility.Visible; + + FromDatePicker.SelectedDate = fromDisplay.Date; + FromHourCombo.SelectedIndex = fromDisplay.Hour; + FromMinuteCombo.SelectedIndex = fromDisplay.Minute / 15; + ToDatePicker.SelectedDate = toDisplay.Date; + ToHourCombo.SelectedIndex = toDisplay.Hour; + ToMinuteCombo.SelectedIndex = toDisplay.Minute / 15; + } + + TimeRangeCombo.SelectedIndex = CustomRangeIndex; + } + finally + { + _suppressRangeEvents = false; + } + } + + // ── Custom-range DatePicker calendar theming (viewer is fixed Dark) ─────────────── + + private void DatePicker_CalendarOpened(object sender, RoutedEventArgs e) + { + if (sender is not DatePicker datePicker) + { + return; + } + + /* Defer so the popup's visual tree exists before we re-color it. */ + Dispatcher.BeginInvoke(new Action(() => + { + if (datePicker.Template.FindName("PART_Popup", datePicker) is Popup { Child: Calendar calendar }) + { + ApplyDarkThemeToCalendar(calendar); + } + })); + } + + private static void ApplyDarkThemeToCalendar(Calendar calendar) + { + var primaryBg = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#111217")!); + var fg = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#E4E6EB")!); + var borderBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#2a2d35")!); + + calendar.Background = primaryBg; + calendar.Foreground = fg; + calendar.BorderBrush = borderBrush; + ApplyDarkThemeRecursively(calendar, primaryBg, fg); + } + + private static void ApplyDarkThemeRecursively(DependencyObject parent, Brush primaryBg, Brush fg) + { + for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++) + { + var child = VisualTreeHelper.GetChild(parent, i); + + switch (child) + { + case CalendarItem calendarItem: + calendarItem.Background = primaryBg; + calendarItem.Foreground = fg; + break; + case CalendarDayButton dayButton: + dayButton.Background = Brushes.Transparent; + dayButton.Foreground = fg; + break; + case CalendarButton calButton: + calButton.Background = Brushes.Transparent; + calButton.Foreground = fg; + break; + case Button button: + button.Background = Brushes.Transparent; + button.Foreground = fg; + break; + case TextBlock textBlock: + textBlock.Foreground = fg; + break; + case Border { Background: SolidColorBrush bg } border when bg.Color is { R: > 200, G: > 200, B: > 200 }: + border.Background = primaryBg; + break; + case Grid { Background: SolidColorBrush gridBg } grid when gridBg.Color is { R: > 200, G: > 200, B: > 200 }: + grid.Background = primaryBg; + break; + } + + ApplyDarkThemeRecursively(child, primaryBg, fg); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Waits.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Waits.cs new file mode 100644 index 000000000..fb3195d96 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Waits.cs @@ -0,0 +1,237 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Wait Stats inner tab — a COPY of Lite's wait-stats picker + chart (ServerTab.Pickers.cs +/// lines 30-221), reads rewired to Postgres and windowed on the per-server toolbar's settable range +/// (preset or custom From/To). The picker behaviors are preserved verbatim: the poison / +/// usual-suspect / PAGELATCH_ default selection with a 30-type cap, checked-to-top ordering, the +/// search filter, "Top Waits" / "Clear All" (Clear All clears only the FILTERED visible set), the +/// reentrancy guard on programmatic checkbox writes, and the generation-guarded batched chart +/// update with its 20-series cap and ms/sec ↔ ms/wait metric swap. Series ride the shared cycling +/// <see cref="ChartPalette"/> colors and <see cref="ChartStyle.StyleScatter"/> line polish, and the +/// hover helper carries the current unit. +/// </summary> +public partial class ViewerServerTab +{ + /* ========== Wait Stats Picker ========== */ + + /* SeriesColors (the shared cycling palette) is declared once in ViewerServerTab.Charts.cs. */ + + private static readonly string[] PoisonWaits = { "THREADPOOL", "RESOURCE_SEMAPHORE", "RESOURCE_SEMAPHORE_QUERY_COMPILE" }; + private static readonly string[] UsualSuspectWaits = { "SOS_SCHEDULER_YIELD", "CXPACKET", "CXCONSUMER", "PAGEIOLATCH_SH", "PAGEIOLATCH_EX", "WRITELOG" }; + private static readonly string[] UsualSuspectPrefixes = { "PAGELATCH_" }; + + private List<SelectableItem> _waitTypeItems = new(); + private bool _isUpdatingWaitTypeSelection; + private int _waitStatsPickerGen; + private ChartHoverHelper? _waitStatsHover; + + internal static HashSet<string> GetDefaultWaitTypes(List<string> availableWaitTypes) + { + var defaults = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + foreach (var w in PoisonWaits) + if (availableWaitTypes.Contains(w)) defaults.Add(w); + foreach (var w in UsualSuspectWaits) + if (availableWaitTypes.Contains(w)) defaults.Add(w); + foreach (var prefix in UsualSuspectPrefixes) + foreach (var w in availableWaitTypes) + if (w.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + defaults.Add(w); + int added = 0; + foreach (var w in availableWaitTypes) + { + if (defaults.Count >= 30) break; + if (added >= 10) break; + if (defaults.Add(w)) { added++; } + } + return defaults; + } + + /// <summary> + /// The Wait Stats tab load (mirrors Lite's <c>RefreshWaitStatsAsync</c>): fetch the distinct wait + /// types over the toolbar's settable window, (re)populate the picker preserving the current selection, + /// and redraw. The hover helper is created lazily on first load so the picker's chart carries the + /// same tooltip + click-to-isolate behavior Lite's does. + /// </summary> + private async Task LoadWaitStatsAsync() + { + _waitStatsHover ??= new ChartHoverHelper(WaitStatsChart, "ms/sec"); + + var (startUtc, endUtc) = GetWindowUtc(); + var waitTypes = await _dataService.GetDistinctWaitTypesAsync(_server.ServerId, startUtc, endUtc); + PopulateWaitTypePicker(waitTypes); + await UpdateWaitStatsChartFromPickerAsync(); + } + + private void PopulateWaitTypePicker(List<string> waitTypes) + { + var previouslySelected = new HashSet<string>(_waitTypeItems.Where(i => i.IsSelected).Select(i => i.DisplayName)); + var topWaits = previouslySelected.Count == 0 ? GetDefaultWaitTypes(waitTypes) : null; + _waitTypeItems = waitTypes.Select(w => new SelectableItem + { + DisplayName = w, + IsSelected = previouslySelected.Contains(w) || (topWaits != null && topWaits.Contains(w)) + }).ToList(); + /* Sort checked items to top, then preserve original order (by total wait time desc) */ + RefreshWaitTypeListOrder(); + } + + private void RefreshWaitTypeListOrder() + { + if (_waitTypeItems == null) return; + _waitTypeItems = _waitTypeItems + .OrderByDescending(x => x.IsSelected) + .ThenBy(x => x.DisplayName) + .ToList(); + ApplyWaitTypeFilter(); + UpdateWaitTypeCount(); + } + + private void UpdateWaitTypeCount() + { + if (_waitTypeItems == null || WaitTypeCountText == null) return; + int count = _waitTypeItems.Count(x => x.IsSelected); + WaitTypeCountText.Text = $"{count} / 30 selected"; + WaitTypeCountText.Foreground = count >= 30 + ? new SolidColorBrush((System.Windows.Media.Color)ColorConverter.ConvertFromString("#E57373")!) + : (System.Windows.Media.Brush)FindResource("ForegroundBrush"); + } + + private void ApplyWaitTypeFilter() + { + var search = WaitTypeSearchBox?.Text?.Trim() ?? ""; + WaitTypesList.ItemsSource = null; + if (string.IsNullOrEmpty(search)) + WaitTypesList.ItemsSource = _waitTypeItems; + else + WaitTypesList.ItemsSource = _waitTypeItems.Where(i => i.DisplayName.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + private void WaitTypeSearch_TextChanged(object sender, TextChangedEventArgs e) => ApplyWaitTypeFilter(); + + private void WaitTypeSelectAll_Click(object sender, RoutedEventArgs e) + { + _isUpdatingWaitTypeSelection = true; + var topWaits = GetDefaultWaitTypes(_waitTypeItems.Select(x => x.DisplayName).ToList()); + foreach (var item in _waitTypeItems) + { + item.IsSelected = topWaits.Contains(item.DisplayName); + } + _isUpdatingWaitTypeSelection = false; + RefreshWaitTypeListOrder(); + _ = UpdateWaitStatsChartFromPickerAsync(); + } + + private void WaitTypeClearAll_Click(object sender, RoutedEventArgs e) + { + _isUpdatingWaitTypeSelection = true; + var visible = (WaitTypesList.ItemsSource as IEnumerable<SelectableItem>)?.ToList() ?? _waitTypeItems; + foreach (var item in visible) item.IsSelected = false; + _isUpdatingWaitTypeSelection = false; + RefreshWaitTypeListOrder(); + _ = UpdateWaitStatsChartFromPickerAsync(); + } + + private void WaitStatsMetric_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + _ = UpdateWaitStatsChartFromPickerAsync(); + } + + private void WaitType_CheckChanged(object sender, RoutedEventArgs e) + { + if (_isUpdatingWaitTypeSelection) return; + RefreshWaitTypeListOrder(); + _ = UpdateWaitStatsChartFromPickerAsync(); + } + + private async Task UpdateWaitStatsChartFromPickerAsync() + { + /* Bump a generation on entry; after each (now genuinely async) query, bail if a newer + invocation has started — rapid checkbox toggling otherwise interleaves two runs and + double-plots the series. */ + var gen = ++_waitStatsPickerGen; + try + { + var selected = _waitTypeItems.Where(i => i.IsSelected).Take(20).ToList(); + + ClearChart(WaitStatsChart); + ApplyTheme(WaitStatsChart); + _waitStatsHover?.Clear(); + + if (selected.Count == 0) { WaitStatsChart.Refresh(); return; } + + bool useAvgPerWait = WaitStatsMetricCombo?.SelectedIndex == 1; + if (_waitStatsHover != null) _waitStatsHover.Unit = useAvgPerWait ? "ms/wait" : "ms/sec"; + + /* The per-server toolbar's settable window (preset or custom From/To). The store is naive-UTC; + display converts via ViewerTimeHelper.ForDisplay. */ + var (startUtc, endUtc) = GetWindowUtc(); + double globalMax = 0; + + // Batched fetch: one query for all selected wait types (mirrors Lite's batched read). + var trendsByType = await _dataService.GetWaitStatsTrendsByTypesAsync( + _server.ServerId, selected.Select(s => s.DisplayName).ToList(), startUtc, endUtc); + if (gen != _waitStatsPickerGen) return; + + for (int i = 0; i < selected.Count; i++) + { + if (!trendsByType.TryGetValue(selected[i].DisplayName, out var trend) || trend.Count == 0) continue; + + var times = trend.Select(t => ViewerTimeHelper.ForDisplay(t.CollectionTime).ToOADate()).ToArray(); + var values = useAvgPerWait + ? trend.Select(t => t.AvgMsPerWait).ToArray() + : trend.Select(t => t.WaitTimeMsPerSecond).ToArray(); + + var plot = WaitStatsChart.Plot.Add.Scatter(times, values); + plot.LegendText = selected[i].DisplayName; + plot.Color = ScottPlot.Color.FromHex(SeriesColors[i % SeriesColors.Length]); + ChartStyle.StyleScatter(plot); + _waitStatsHover?.Add(plot, selected[i].DisplayName); + + if (values.Length > 0) globalMax = Math.Max(globalMax, values.Max()); + } + + WaitStatsChart.Plot.Axes.DateTimeTicksBottomDateChange(); + var rangeStart = ViewerTimeHelper.ForDisplay(startUtc); + var rangeEnd = ViewerTimeHelper.ForDisplay(endUtc); + WaitStatsChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(WaitStatsChart); + WaitStatsChart.Plot.YLabel(useAvgPerWait ? "Avg Wait Time (ms/wait)" : "Wait Time (ms/sec)"); + SetChartYLimitsWithLegendPadding(WaitStatsChart, 0, globalMax > 0 ? globalMax : 100); + ShowChartLegend(WaitStatsChart); + WaitStatsChart.Refresh(); + } + catch + { + /* Ignore chart update errors */ + } + } + + /// <summary> + /// Tears down the wait-stats hover helper (mirrors Lite's <c>DisposeChartHelpers</c>) so the + /// tooltip popup and its chart event handlers don't outlive a closed server tab. Called from + /// MainWindow's tab-close path. + /// </summary> + public void DisposeChartHelpers() + { + _waitStatsHover?.Dispose(); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml new file mode 100644 index 000000000..afb00a53c --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml @@ -0,0 +1,3394 @@ +<UserControl x:Class="PerformanceMonitor.Darling.Viewer.ViewerServerTab" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:local="clr-namespace:PerformanceMonitor.Darling.Viewer" + xmlns:scottplot="clr-namespace:ScottPlot.WPF;assembly=ScottPlot.WPF" + xmlns:ui="clr-namespace:PerformanceMonitor.Ui;assembly=PerformanceMonitor.Ui" + Background="Transparent"> + <!-- + One monitored server's detail surface (headless-plan W0 IA inversion): an inner tab strip + holding the per-server surfaces relocated out of MainWindow's old flat tab list — Overview + (the CPU-utilization and wait-time-by-category trend charts), Queries (top-50 query stats), + Blocking (XE blocked-process reports with the DMV-snapshot fallback), and Collection Health + (per-collector last-run status). The grids keep MainWindow's hardcoded Dark styling verbatim + (pure relocation — no visual change); the inner tab chrome uses the shared SubTabItemStyle so + the sub-tab hierarchy reads the same as Lite's ServerTab. Loads are lazy per inner tab (Lite's + visible-only rule): only the active inner tab reads, and the per-server toolbar's auto-refresh + timer refreshes only the active inner tab of the visible server tab. + --> + <UserControl.Resources> + <BooleanToVisibilityConverter x:Key="BoolToVis"/> + <Style x:Key="SectionHeader" TargetType="TextBlock"> + <Setter Property="FontSize" Value="14"/> + <Setter Property="FontWeight" Value="SemiBold"/> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Setter Property="Margin" Value="0,0,0,6"/> + </Style> + + <Style x:Key="EmptyHint" TargetType="TextBlock"> + <Setter Property="Foreground" Value="{DynamicResource ForegroundMutedBrush}"/> + <Setter Property="HorizontalAlignment" Value="Center"/> + <Setter Property="VerticalAlignment" Value="Center"/> + <Setter Property="TextWrapping" Value="Wrap"/> + <Setter Property="TextAlignment" Value="Center"/> + <Setter Property="MaxWidth" Value="480"/> + <Setter Property="IsHitTestVisible" Value="False"/> + <Setter Property="Visibility" Value="Collapsed"/> + </Style> + + <!-- Dark grid chrome shared by the per-server grids (relocated from MainWindow's + Window.Resources — same setters, zero visual change). --> + <Style x:Key="DarkGridHeader" TargetType="DataGridColumnHeader"> + <Setter Property="Background" Value="{DynamicResource ViewerGridHeaderBrush}"/> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="0,0,1,1"/> + <Setter Property="Padding" Value="8,5"/> + <Setter Property="FontWeight" Value="SemiBold"/> + </Style> + + <!-- Copy / Export menu for every plain grid (W1f cluster), copied from Lite's ServerTab.xaml + DataGridContextMenu (its copy/export half — the query grids get the plan-aware variants below). + Set on the base DarkGridRow so every DarkGrid grid that doesn't override RowStyle (RunningJobs, + ServerConfig, DatabaseConfig, DatabaseScopedConfig, TraceFlags, DailySummary, CollectionHealth, + latch/spinlock, CPU scheduler, plan cache, System Events, …) gets it. Copy/Export delegate to the + shared PerformanceMonitor.Ui DataGridExport (ViewerServerTab.CopyExport.cs). Defined before + DarkGridRow so the StaticResource resolves. --> + <ContextMenu x:Key="DataGridContextMenu"> + <MenuItem Header="Copy Cell" Click="CopyCell_Click"> + <MenuItem.Icon><TextBlock Text="📋"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy Row" Click="CopyRow_Click"> + <MenuItem.Icon><TextBlock Text="📄"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy All Rows" Click="CopyAllRows_Click"> + <MenuItem.Icon><TextBlock Text="📑"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <MenuItem Header="Export to CSV..." Click="ExportToCsv_Click"> + <MenuItem.Icon><TextBlock Text="📊"/></MenuItem.Icon> + </MenuItem> + </ContextMenu> + + <Style x:Key="DarkGridRow" TargetType="DataGridRow"> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="ContextMenu" Value="{StaticResource DataGridContextMenu}"/> + <Style.Triggers> + <Trigger Property="IsMouseOver" Value="True"> + <Setter Property="Background" Value="{DynamicResource ViewerHoverBrush}"/> + </Trigger> + </Style.Triggers> + </Style> + + <Style x:Key="DarkGridCell" TargetType="DataGridCell"> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="Background" Value="Transparent"/> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Style.Triggers> + <Trigger Property="IsSelected" Value="True"> + <Setter Property="Background" Value="{DynamicResource ViewerSelectionBrush}"/> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + </Trigger> + </Style.Triggers> + </Style> + + <Style x:Key="DarkGrid" TargetType="DataGrid"> + <!-- DarkGrid has no BasedOn, so it must re-declare the implicit DataGrid style's behaviors or grids using it silently lose them (this is why the query grids were sorting ascending-first on the first header click). --> + <Setter Property="ui:DataGridClipboardBehavior.FixHeaderCopy" Value="True"/> + <Setter Property="ui:DataGridSortBehavior.SortDescendingFirst" Value="True"/> + <Setter Property="ui:ScrollPanBehavior.EnableMiddleClickPanning" Value="True"/> + <Setter Property="AutoGenerateColumns" Value="False"/> + <Setter Property="IsReadOnly" Value="True"/> + <Setter Property="CanUserAddRows" Value="False"/> + <Setter Property="HeadersVisibility" Value="Column"/> + <Setter Property="SelectionMode" Value="Single"/> + <Setter Property="SelectionUnit" Value="FullRow"/> + <Setter Property="GridLinesVisibility" Value="Horizontal"/> + <Setter Property="HorizontalGridLinesBrush" Value="{DynamicResource BorderBrush}"/> + <Setter Property="Background" Value="{DynamicResource BackgroundDarkBrush}"/> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="RowBackground" Value="{DynamicResource BackgroundDarkBrush}"/> + <Setter Property="AlternatingRowBackground" Value="{DynamicResource ViewerAltRowBrush}"/> + <Setter Property="ColumnHeaderStyle" Value="{StaticResource DarkGridHeader}"/> + <Setter Property="RowStyle" Value="{StaticResource DarkGridRow}"/> + <Setter Property="CellStyle" Value="{StaticResource DarkGridCell}"/> + </Style> + + <!-- Blocking-tab context menus (W1e), copied from Lite's ServerTab.xaml. The stored-plan "View Plan" + items ARE wired now (#1368 / V7): the Darling collector captures the blocked / blocking / victim + query plans BEST-EFFORT alongside the report and deadlock graph (blocked_process_reports. + blocked_query_plan_xml / blocking_query_plan_xml, deadlocks.victim_query_plan_xml), so these open + the stored plan in the Plan Viewer host — NO live SQL. The plans are often NULL (best-effort, and + always NULL on the DMV-snapshot fallback rows / under Lite), so each item is gated per row on its + Has*QueryPlan flag and shows disabled rather than shown-and-failed. Lite's "Get Actual Plan" (a + LIVE re-execution the headless viewer can't run) stays omitted everywhere. Copy/Export delegate + to the shared PerformanceMonitor.Ui DataGridExport; the graph actions open the shared .Ui + BlockingChainControl / DeadlockGraphControl in a GraphViewerWindow. --> + <ContextMenu x:Key="BlockedProcessContextMenu"> + <MenuItem Header="Copy Cell" Click="CopyCell_Click"> + <MenuItem.Icon><TextBlock Text="📋"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy Row" Click="CopyRow_Click"> + <MenuItem.Icon><TextBlock Text="📄"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy All Rows" Click="CopyAllRows_Click"> + <MenuItem.Icon><TextBlock Text="📑"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <MenuItem Header="Export to CSV..." Click="ExportToCsv_Click"> + <MenuItem.Icon><TextBlock Text="📊"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <!-- Stored blocked / blocking query plans (#1368 / V7), gated per row on the row's Has*QueryPlan + flag via the ContextMenu's PlacementTarget so a plan-less row (best-effort NULL, or a DMV + fallback row) shows the item disabled instead of shown-and-failed. --> + <MenuItem Header="View Blocked Query Plan" Click="ViewBlockedQueryPlan_Click" + IsEnabled="{Binding PlacementTarget.DataContext.HasBlockedQueryPlan, RelativeSource={RelativeSource AncestorType=ContextMenu}, FallbackValue=False}"> + <MenuItem.Icon><TextBlock Text="🔍"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="View Blocking Query Plan" Click="ViewBlockingQueryPlan_Click" + IsEnabled="{Binding PlacementTarget.DataContext.HasBlockingQueryPlan, RelativeSource={RelativeSource AncestorType=ContextMenu}, FallbackValue=False}"> + <MenuItem.Icon><TextBlock Text="🔍"/></MenuItem.Icon> + </MenuItem> + <!-- Live plan fetch (headless-plan live-plan wave): asks the service to read the process's plan from + the target's LIVE cache by the sql_handle in its report XML — works for a process whose plan the + collector never stashed. Gated on HasReportXml so a DMV-snapshot fallback row (no XML) disables it. --> + <MenuItem Header="View Blocked Cached Plan" Click="FetchBlockedSideLivePlan_Click" + IsEnabled="{Binding PlacementTarget.DataContext.HasReportXml, RelativeSource={RelativeSource AncestorType=ContextMenu}, FallbackValue=False}"> + <MenuItem.Icon><TextBlock Text="🌐"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="View Blocking Cached Plan" Click="FetchBlockingSideLivePlan_Click" + IsEnabled="{Binding PlacementTarget.DataContext.HasReportXml, RelativeSource={RelativeSource AncestorType=ContextMenu}, FallbackValue=False}"> + <MenuItem.Icon><TextBlock Text="🌐"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="View Block Chain" Click="ViewBlockChain_Click"> + <MenuItem.Icon><TextBlock Text="🔗"/></MenuItem.Icon> + </MenuItem> + </ContextMenu> + + <ContextMenu x:Key="DeadlockContextMenu"> + <MenuItem Header="Copy Cell" Click="CopyCell_Click"> + <MenuItem.Icon><TextBlock Text="📋"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy Row" Click="CopyRow_Click"> + <MenuItem.Icon><TextBlock Text="📄"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy All Rows" Click="CopyAllRows_Click"> + <MenuItem.Icon><TextBlock Text="📑"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <MenuItem Header="Export to CSV..." Click="ExportToCsv_Click"> + <MenuItem.Icon><TextBlock Text="📊"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <!-- Stored victim query plan (#1368 / V7), gated per row on CanViewVictimPlan (IsVictim AND a plan) + via the ContextMenu's PlacementTarget. The single per-deadlock victim plan is copied onto every + process row, so gating on IsVictim keeps the item from reading as active on the deadlocker rows + (where it is NOT that process's plan); a plan-less deadlock (best-effort NULL, the common case) + still shows it disabled. --> + <MenuItem Header="View Victim Plan" Click="ViewVictimQueryPlan_Click" + IsEnabled="{Binding PlacementTarget.DataContext.CanViewVictimPlan, RelativeSource={RelativeSource AncestorType=ContextMenu}, FallbackValue=False}"> + <MenuItem.Icon><TextBlock Text="🔍"/></MenuItem.Icon> + </MenuItem> + <!-- Live plan fetch for ANY process in the graph (headless-plan live-plan wave): asks the service to + read the right-clicked process's plan from the target's LIVE cache by the sql_handle in the + deadlock graph — not limited to the single best-effort victim plan the collector stashed. Gated + on HasDeadlockXml (the graph is where the sql_handle is resolved from) for consistency with the + query-grid + blocked-process Fetch items, which disable rather than error when there's no source. --> + <MenuItem Header="View Cached Plan (This Process)" Click="FetchDeadlockProcessLivePlan_Click" + IsEnabled="{Binding PlacementTarget.DataContext.HasDeadlockXml, RelativeSource={RelativeSource AncestorType=ContextMenu}, FallbackValue=False}"> + <MenuItem.Icon><TextBlock Text="🌐"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="View Deadlock Graph" Click="ViewDeadlockGraph_Click"> + <MenuItem.Icon><TextBlock Text="🕸"/></MenuItem.Icon> + </MenuItem> + </ContextMenu> + + <!-- Row style for the blocked-process grid: Lite's BlockedProcessContextMenu + long-block tint, + layered on the viewer's DarkGridRow hover chrome. --> + <Style x:Key="BlockingRowStyle" TargetType="DataGridRow" BasedOn="{StaticResource DarkGridRow}"> + <Setter Property="ContextMenu" Value="{StaticResource BlockedProcessContextMenu}"/> + <Style.Triggers> + <DataTrigger Binding="{Binding IsLongBlock}" Value="True"> + <Setter Property="Background" Value="#33FF6B6B"/> + </DataTrigger> + </Style.Triggers> + </Style> + + <!-- Row style for the deadlock grid: Lite's DeadlockContextMenu on the DarkGridRow hover chrome. --> + <Style x:Key="DeadlockRowStyle" TargetType="DataGridRow" BasedOn="{StaticResource DarkGridRow}"> + <Setter Property="ContextMenu" Value="{StaticResource DeadlockContextMenu}"/> + </Style> + + <!-- Queries-tab grid context menu (W1f-1), copied from Lite's ServerTab.xaml DataGridContextMenu. + This is the PLAN-LESS variant used by the comparison grids + the Active Queries snapshot grid: + a comparison row is a current-vs-baseline aggregate with no single stored plan keyed on it, and + the snapshot grid has its own dedicated Estimated / Actual plan buttons — so neither needs a + "View Plan" context item. (Top Procedures moved OFF this menu to the plan-enabled variant below: + procedure_stats DOES store the plan since #1368 / V7.) "Copy Repro Script" builds from STORED row + fields — Active Queries carries QueryText/DatabaseName/QueryPlan/TransactionIsolationLevel in-row, + so no live connection is needed (the comparison rows have no query text and no-op). Only Lite's + "Get Actual Plan" (a LIVE re-execution) stays omitted. Copy/Export/Repro delegate to + ViewerServerTab.CopyExport.cs (Copy/Export via the shared PerformanceMonitor.Ui DataGridExport). --> + <ContextMenu x:Key="QueryGridContextMenu"> + <MenuItem Header="Copy Cell" Click="CopyCell_Click"> + <MenuItem.Icon><TextBlock Text="📋"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy Row" Click="CopyRow_Click"> + <MenuItem.Icon><TextBlock Text="📄"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy All Rows" Click="CopyAllRows_Click"> + <MenuItem.Icon><TextBlock Text="📑"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy Repro Script" Click="CopyReproScript_Click"> + <MenuItem.Icon><TextBlock Text="📝"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <MenuItem Header="Export to CSV..." Click="ExportToCsv_Click"> + <MenuItem.Icon><TextBlock Text="📊"/></MenuItem.Icon> + </MenuItem> + </ContextMenu> + + <!-- PLAN-enabled variant for Top Queries + Query Store + Top Procedures, whose plans the collector + stores (query_stats.query_plan_xml / query_store_stats.query_plan_text, PR #1349; + procedure_stats.query_plan_xml, #1368 / V7). "View Plan" opens the stored plan in the Plan + Viewer tab via ViewPlan_Click (ViewerServerTab.Plans.cs), which fetches by the row's key. + "Copy Repro Script" builds from stored fields; for these aggregate rows it best-effort reads the + STORED plan from Postgres (not a live connection) to enrich the extracted parameters, then falls + back to a plan-less repro (CopyReproScript_Click). Lite's "Get Actual Plan" (a LIVE re-execution) + is enabled here on Top Queries rows only, gated per-row on CanGetActualPlan (the service re-executes + via the execute_actual_plan command — ViewerServerTab.ActualPlan.cs). --> + <ContextMenu x:Key="QueryPlanGridContextMenu"> + <MenuItem Header="Copy Cell" Click="CopyCell_Click"> + <MenuItem.Icon><TextBlock Text="📋"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy Row" Click="CopyRow_Click"> + <MenuItem.Icon><TextBlock Text="📄"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy All Rows" Click="CopyAllRows_Click"> + <MenuItem.Icon><TextBlock Text="📑"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy Repro Script" Click="CopyReproScript_Click"> + <MenuItem.Icon><TextBlock Text="📝"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <MenuItem Header="Export to CSV..." Click="ExportToCsv_Click"> + <MenuItem.Icon><TextBlock Text="📊"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <MenuItem Header="View Stored Plan" Click="ViewPlan_Click"> + <MenuItem.Icon><TextBlock Text="🔍"/></MenuItem.Icon> + </MenuItem> + <!-- Live plan fetch (headless-plan live-plan wave): asks the service to read the plan CURRENTLY in + the target's cache by this row's plan_handle — distinct from the stored plan "View Plan" opens. + Gated on HasLivePlanHandle so rows without a plan_handle (Query Store) disable it. --> + <MenuItem Header="View Cached Plan" Click="FetchLiveQueryPlan_Click" + Visibility="{Binding PlacementTarget.DataContext.HasLivePlanHandle, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVis}, FallbackValue=Collapsed}"> + <MenuItem.Icon><TextBlock Text="🌐"/></MenuItem.Icon> + </MenuItem> + <!-- Get Actual Plan (Lite parity): asks the service to RE-EXECUTE the stored query (SET STATISTICS + XML) so the plan comes back with runtime stats. Gated on CanGetActualPlan so ONLY Top Queries + rows (query text + query_hash) enable it — Query Store / Top Procedures rows (no CanGetActualPlan) + fall back to disabled. Side-effecting: the click runs an informed-consent dialog that flags + data-modifying queries prominently before enqueuing. --> + <MenuItem Header="Get Actual Plan (re-run)" Click="GetActualQueryPlan_Click" + Visibility="{Binding PlacementTarget.DataContext.CanGetActualPlan, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVis}, FallbackValue=Collapsed}"> + <MenuItem.Icon><TextBlock Text="▶"/></MenuItem.Icon> + </MenuItem> + </ContextMenu> + + <!-- Row style for the comparison grids + the Active Queries snapshot grid: the plan-less copy/export menu on the DarkGridRow hover chrome. --> + <Style x:Key="QueryGridRowStyle" TargetType="DataGridRow" BasedOn="{StaticResource DarkGridRow}"> + <Setter Property="ContextMenu" Value="{StaticResource QueryGridContextMenu}"/> + </Style> + + <!-- Row style for Top Queries + Query Store + Top Procedures: adds "View Plan" (their plans are stored). --> + <Style x:Key="QueryPlanGridRowStyle" TargetType="DataGridRow" BasedOn="{StaticResource DarkGridRow}"> + <Setter Property="ContextMenu" Value="{StaticResource QueryPlanGridContextMenu}"/> + </Style> + + <!-- PLAN variant for the Active Queries snapshot grid: its plans are the snapshot's OWN captured XML + (estimated always; actual only when the snapshot caught a live plan), opened via the OpenSnapshot* + handlers — a different mechanism from the plan_handle live-fetch the aggregate grids use, so it + gets its own menu rather than reusing QueryPlanGridContextMenu. Mirrors the in-row Estimated/Actual + buttons so plan access is available by right-click too (Lite parity). Each item is gated on the + row's HasQueryPlan / HasLiveQueryPlan exactly like the buttons. --> + <ContextMenu x:Key="QuerySnapshotGridContextMenu"> + <MenuItem Header="Copy Cell" Click="CopyCell_Click"> + <MenuItem.Icon><TextBlock Text="📋"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy Row" Click="CopyRow_Click"> + <MenuItem.Icon><TextBlock Text="📄"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy All Rows" Click="CopyAllRows_Click"> + <MenuItem.Icon><TextBlock Text="📑"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy Repro Script" Click="CopyReproScript_Click"> + <MenuItem.Icon><TextBlock Text="📝"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <MenuItem Header="Export to CSV..." Click="ExportToCsv_Click"> + <MenuItem.Icon><TextBlock Text="📊"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <MenuItem Header="View Estimated Plan" Click="ViewSnapshotEstimatedPlan_Click" + IsEnabled="{Binding PlacementTarget.DataContext.HasQueryPlan, RelativeSource={RelativeSource AncestorType=ContextMenu}, FallbackValue=False}"> + <MenuItem.Icon><TextBlock Text="🔍"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="View Live Plan" Click="ViewSnapshotActualPlan_Click" + IsEnabled="{Binding PlacementTarget.DataContext.HasLiveQueryPlan, RelativeSource={RelativeSource AncestorType=ContextMenu}, FallbackValue=False}"> + <MenuItem.Icon><TextBlock Text="🌐"/></MenuItem.Icon> + </MenuItem> + </ContextMenu> + + <Style x:Key="QuerySnapshotGridRowStyle" TargetType="DataGridRow" BasedOn="{StaticResource DarkGridRow}"> + <Setter Property="ContextMenu" Value="{StaticResource QuerySnapshotGridContextMenu}"/> + </Style> + + <!-- PLAN variant for Query Store + Query Store Regressions. Plan access is by QueryId: the read carries + no plan_handle (so no "View Cached Plan"), and Regression rows aggregate across plans so they have + no single stored plan. "View Stored Plan" is gated HasStoredPlan (Query Store yes, Regressions no); + "Get Actual Plan (re-run)" RE-EXECUTES by QueryId via GetActualQueryStorePlan_Click, the same + service path the Query Store history window uses. Non-applicable verbs are HIDDEN (BoolToVis), not + greyed, so neither grid shows a disabled item. --> + <ContextMenu x:Key="QueryStorePlanContextMenu"> + <MenuItem Header="Copy Cell" Click="CopyCell_Click"> + <MenuItem.Icon><TextBlock Text="📋"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy Row" Click="CopyRow_Click"> + <MenuItem.Icon><TextBlock Text="📄"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy All Rows" Click="CopyAllRows_Click"> + <MenuItem.Icon><TextBlock Text="📑"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy Repro Script" Click="CopyReproScript_Click"> + <MenuItem.Icon><TextBlock Text="📝"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <MenuItem Header="Export to CSV..." Click="ExportToCsv_Click"> + <MenuItem.Icon><TextBlock Text="📊"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <MenuItem Header="View Stored Plan" Click="ViewPlan_Click" + Visibility="{Binding PlacementTarget.DataContext.HasStoredPlan, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVis}, FallbackValue=Collapsed}"> + <MenuItem.Icon><TextBlock Text="🔍"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Get Actual Plan (re-run)" Click="GetActualQueryStorePlan_Click" + Visibility="{Binding PlacementTarget.DataContext.CanGetActualPlan, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVis}, FallbackValue=Collapsed}"> + <MenuItem.Icon><TextBlock Text="▶"/></MenuItem.Icon> + </MenuItem> + </ContextMenu> + + <Style x:Key="QueryStorePlanGridRowStyle" TargetType="DataGridRow" BasedOn="{StaticResource DarkGridRow}"> + <Setter Property="ContextMenu" Value="{StaticResource QueryStorePlanContextMenu}"/> + </Style> + + </UserControl.Resources> + + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + + <!-- Per-server toolbar (mirrors Lite's ServerTab.xaml header): the time-range dropdown + + custom From/To, Apply-to-all, the shared Compare baseline (moved up from the Queries tab), + Refresh, the auto-refresh toggle + interval, and the Server/Local/UTC time-display picker. + The time controls drive a SETTABLE window (replacing the old fixed 24-hour s_dataWindow) that + every inner tab reads via GetWindowUtc; the display picker (ported from Lite's + TimeDisplayModeBox) drives how every timestamp renders via ViewerTimeHelper — Server Time now + works because the server's UTC offset is collected in server_properties.utc_offset_minutes. --> + <Border Grid.Row="0" Background="{DynamicResource BackgroundDarkBrush}" Padding="12,8"> + <WrapPanel Orientation="Horizontal"> + <!-- Leading identity + freshness readout (Lite's header shows the server name + a live status; + the headless-appropriate equivalent is the display name + the newest collection time, since + the SERVICE collects and the viewer only reads). Populated from the shared MAX(collection_time) + freshness read, rendered in the toolbar's Server/Local/UTC display mode. --> + <StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,10,4"> + <TextBlock x:Name="ServerNameText" FontSize="14" FontWeight="SemiBold" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBlock x:Name="ServerFreshnessText" FontSize="12" VerticalAlignment="Center" Margin="8,0,0,0" + Foreground="{DynamicResource ForegroundMutedBrush}" + ToolTip="Newest collection time across all collectors — the service collects, the viewer reads"/> + </StackPanel> + <Separator Style="{StaticResource {x:Static ToolBar.SeparatorStyleKey}}" Margin="8,2"/> + <!-- #1319: per-server global database filter (display-only) --> + <Button x:Name="DatabaseFilterButton" Content="Databases: All" Click="DatabaseFilterButton_Click" + Padding="8,4" Margin="0,0,8,4" + ToolTip="Filter database-scoped views (queries, blocking, per-database config, trends) to selected databases. No effect on server-wide views such as CPU, memory, waits, or jobs."/> + <Popup x:Name="DatabaseFilterPopup" PlacementTarget="{Binding ElementName=DatabaseFilterButton}" + Placement="Bottom" StaysOpen="False" AllowsTransparency="True" + Closed="DatabaseFilterPopup_Closed"> + <Border Background="{DynamicResource BackgroundDarkBrush}" BorderBrush="{DynamicResource BorderBrush}" + BorderThickness="1" CornerRadius="4" Padding="8" Width="300"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + <TextBlock Grid.Row="0" Text="Filter Databases" FontWeight="SemiBold" Margin="0,0,0,4" + Foreground="{DynamicResource ForegroundBrush}"/> + <StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,0,0,4"> + <Button Content="Select All" Click="DatabaseFilterSelectAll_Click" Padding="6,2" Margin="0,0,4,0" FontSize="11"/> + <Button Content="Clear All" Click="DatabaseFilterClearAll_Click" Padding="6,2" FontSize="11"/> + <TextBlock x:Name="DatabaseFilterCountText" Text="All databases" FontSize="10" + Foreground="{DynamicResource ForegroundMutedBrush}" VerticalAlignment="Center" Margin="8,0,0,0"/> + </StackPanel> + <TextBox Grid.Row="2" x:Name="DatabaseFilterSearchBox" TextChanged="DatabaseFilterSearch_TextChanged" + Margin="0,0,0,4" Padding="4,2"/> + <ListBox Grid.Row="3" x:Name="DatabaseFilterList" Background="Transparent" BorderThickness="0" + MaxHeight="320" ScrollViewer.VerticalScrollBarVisibility="Auto"> + <ListBox.ItemTemplate> + <DataTemplate> + <CheckBox IsChecked="{Binding IsSelected, Mode=TwoWay}" + Checked="DatabaseFilter_CheckChanged" Unchecked="DatabaseFilter_CheckChanged" + Foreground="{DynamicResource ForegroundBrush}" FontSize="11"> + <TextBlock Text="{Binding DisplayName}"/> + </CheckBox> + </DataTemplate> + </ListBox.ItemTemplate> + </ListBox> + </Grid> + </Border> + </Popup> + <ComboBox x:Name="TimeRangeCombo" SelectedIndex="3" Width="120" Margin="0,0,8,4" + SelectionChanged="TimeRangeCombo_SelectionChanged" + ToolTip="Time window every inner tab reads"> + <ComboBoxItem Content="Last 1 hour"/> + <ComboBoxItem Content="Last 4 hours"/> + <ComboBoxItem Content="Last 12 hours"/> + <ComboBoxItem Content="Last 24 hours"/> + <ComboBoxItem Content="Last 7 days"/> + <ComboBoxItem Content="Custom Range"/> + </ComboBox> + <DatePicker x:Name="FromDatePicker" Width="120" Margin="0,0,2,4" Visibility="Collapsed" + SelectedDateChanged="CustomDateRange_Changed" CalendarOpened="DatePicker_CalendarOpened"/> + <ComboBox x:Name="FromHourCombo" Width="70" Margin="0,0,2,4" Visibility="Collapsed" + SelectionChanged="CustomTimeCombo_Changed" ToolTip="Hour"/> + <ComboBox x:Name="FromMinuteCombo" Width="60" Margin="0,0,2,4" Visibility="Collapsed" + SelectionChanged="CustomTimeCombo_Changed" ToolTip="Minute"/> + <TextBlock x:Name="ToLabel" Text="to" VerticalAlignment="Center" Margin="4,0,4,0" Visibility="Collapsed" + Foreground="{DynamicResource ForegroundBrush}"/> + <DatePicker x:Name="ToDatePicker" Width="120" Margin="0,0,2,4" Visibility="Collapsed" + SelectedDateChanged="CustomDateRange_Changed" CalendarOpened="DatePicker_CalendarOpened"/> + <ComboBox x:Name="ToHourCombo" Width="70" Margin="0,0,2,4" Visibility="Collapsed" + SelectionChanged="CustomTimeCombo_Changed" ToolTip="Hour"/> + <ComboBox x:Name="ToMinuteCombo" Width="60" Margin="0,0,8,4" Visibility="Collapsed" + SelectionChanged="CustomTimeCombo_Changed" ToolTip="Minute"/> + <Button Content="Apply to All" Click="ApplyTimeRangeToAll_Click" Padding="8,4" Margin="0,0,8,4" + ToolTip="Apply this time range to all open server tabs"/> + <TextBlock Text="Compare:" VerticalAlignment="Center" Margin="0,0,4,0" + Foreground="{DynamicResource ForegroundBrush}"/> + <ComboBox x:Name="CompareToCombo" SelectedIndex="0" Width="150" Margin="0,0,8,4" + SelectionChanged="CompareToCombo_SelectionChanged" + ToolTip="Compare the current window against a baseline period"> + <ComboBoxItem Content="None"/> + <ComboBoxItem Content="Yesterday"/> + <ComboBoxItem Content="Last week"/> + <ComboBoxItem Content="Same day last week"/> + </ComboBox> + <Button x:Name="RefreshDataButton" Content="Refresh" Click="RefreshDataButton_Click" + Padding="12,4" Margin="0,0,8,4"/> + <Separator Style="{StaticResource {x:Static ToolBar.SeparatorStyleKey}}" Margin="8,2"/> + <CheckBox x:Name="AutoRefreshCheckBox" Content="Auto-refresh" VerticalAlignment="Center" + Margin="0,0,4,4" Foreground="{DynamicResource ForegroundBrush}" + IsChecked="True" Checked="AutoRefreshCheckBox_Changed" Unchecked="AutoRefreshCheckBox_Changed"/> + <ComboBox x:Name="AutoRefreshIntervalCombo" SelectedIndex="1" Width="70" Margin="0,0,0,4" + SelectionChanged="AutoRefreshInterval_Changed" + ToolTip="Auto-refresh interval"> + <ComboBoxItem Content="30s"/> + <ComboBoxItem Content="1m"/> + <ComboBoxItem Content="5m"/> + </ComboBox> + <Separator Style="{StaticResource {x:Static ToolBar.SeparatorStyleKey}}" Margin="8,2"/> + <TextBlock Text="Times:" VerticalAlignment="Center" Margin="0,0,4,0" + Foreground="{DynamicResource ForegroundBrush}"/> + <ComboBox x:Name="TimeDisplayModeBox" Width="120" Margin="0,0,0,4" + SelectionChanged="TimeDisplayMode_SelectionChanged" + ToolTip="How timestamps are displayed"> + <ComboBoxItem Content="Server Time" Tag="ServerTime"/> + <ComboBoxItem Content="Local Time" Tag="LocalTime"/> + <ComboBoxItem Content="UTC" Tag="UTC"/> + </ComboBox> + </WrapPanel> + </Border> + + <!-- Inner tab strip. Only the ACTIVE inner tab loads (Lite's visible-only rule); the toolbar's + auto-refresh timer refreshes only the active inner tab of the visible server tab. --> + <TabControl x:Name="InnerTabs" Grid.Row="1" + Background="Transparent" + BorderThickness="0" + Padding="0" + ItemContainerStyle="{DynamicResource SubTabItemStyle}" + SelectionChanged="InnerTabs_SelectionChanged"> + + <TabItem Header="Overview"> + <!-- Correlated timeline lanes (copied from Lite's Overview): five stacked, X-axis-synced + lanes — CPU %, total wait ms/sec, blocking + deadlocking, buffer pool MB, and file-I/O + latency — with baseline bands + anomaly markers and a shared correlated crosshair. --> + <local:CorrelatedTimelineLanesControl x:Name="OverviewLanes" Margin="8"/> + </TabItem> + + <!-- Wait Stats Tab — copied from Lite's ServerTab.xaml (the 220px wait-type picker + metric + combo + chart); reads rewired to Postgres, windowed on the per-server toolbar's settable range. --> + <TabItem Header="Wait Stats"> + <Grid Margin="8"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="220"/> + <ColumnDefinition Width="*"/> + </Grid.ColumnDefinitions> + + <Border Grid.Column="0" Background="{DynamicResource BackgroundDarkBrush}" CornerRadius="4" Padding="8" Margin="0,0,4,0"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + <TextBlock Grid.Row="0" Text="Select Wait Types" FontWeight="SemiBold" Margin="0,0,0,4"/> + <StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,0,0,4"> + <Button Content="Top Waits" Click="WaitTypeSelectAll_Click" Padding="6,2" Margin="0,0,4,0" FontSize="11"/> + <Button Content="Clear All" Click="WaitTypeClearAll_Click" Padding="6,2" FontSize="11"/> + <TextBlock x:Name="WaitTypeCountText" Text="0 / 30 selected" FontSize="10" Foreground="{DynamicResource ForegroundMutedBrush}" VerticalAlignment="Center" Margin="8,0,0,0"/> + </StackPanel> + <TextBox Grid.Row="2" x:Name="WaitTypeSearchBox" TextChanged="WaitTypeSearch_TextChanged" + Margin="0,0,0,4" Padding="4,2"/> + <ListBox Grid.Row="3" x:Name="WaitTypesList" Background="Transparent" BorderThickness="0"> + <ListBox.ItemTemplate> + <DataTemplate> + <CheckBox IsChecked="{Binding IsSelected, Mode=TwoWay}" + Checked="WaitType_CheckChanged" Unchecked="WaitType_CheckChanged" + Foreground="{DynamicResource ForegroundBrush}" FontSize="11"> + <TextBlock Text="{Binding DisplayName}"/> + </CheckBox> + </DataTemplate> + </ListBox.ItemTemplate> + </ListBox> + </Grid> + </Border> + + <Grid Grid.Column="1"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="8,4,0,0"> + <TextBlock Text="Metric:" VerticalAlignment="Center" Margin="0,0,6,0" FontSize="11"/> + <ComboBox x:Name="WaitStatsMetricCombo" SelectedIndex="0" SelectionChanged="WaitStatsMetric_SelectionChanged" FontSize="11" Padding="4,2"> + <ComboBoxItem Content="Wait Time (ms/sec)"/> + <ComboBoxItem Content="Avg Wait Time (ms/wait)"/> + </ComboBox> + </StackPanel> + <scottplot:WpfPlot Grid.Row="1" x:Name="WaitStatsChart"/> + </Grid> + </Grid> + </TabItem> + + <!-- Queries Tab — the full six-sub-tab group matching Lite's ServerTab.xaml QueriesSubTabControl + exactly: Performance Trends, Active Queries (W1f-2), Top Queries / Top Procedures / Query + Store by Duration (W1f-1), Query Heatmap (W1f-2). A shared "Compare:" combo above the + sub-tabs drives the three grids' collapsed comparison grids (shared .Ui ComparisonItemBase + derivatives; the non-grid sub-tabs ignore it). Reads rewired to Postgres over the toolbar's + settable window, which supplies the slicer range. The Active Queries plan buttons open the + stored plan through the OpenPlanTab hook the plan-host wave implements. Deferred within the + three grids (no live server): the per-row double-click history windows and the slicer + overlay-on-select — only the slicer's drag-to-narrow re-read and its sort-driven metric + re-labeling are wired here. --> + <!-- Queries Tab — W1f-1 nested sub-tab group (Lite's ServerTab.xaml QueriesSubTabControl). The + absorbed top-50 grid becomes the first sub-tab, replaced by Lite's full ~44-column Top + Queries by Duration grid; Top Procedures and Query Store join it. A shared "Compare:" combo + above the sub-tabs drives the collapsed comparison grids (shared .Ui ComparisonItemBase + derivatives). Reads rewired to Postgres over the toolbar's settable window, which supplies the + slicer range. Performance Trends / Active Queries / Query Heatmap sub-tabs are deferred to + W1f-2, so their slots are ABSENT. Now wired (headless-plan wave): Top Queries' "Query Plan" + download column + a "View Plan" context item on Top Queries, Query Store, AND Top Procedures, + which open the stored query_stats.query_plan_xml / query_store_stats.query_plan_text / + procedure_stats.query_plan_xml (procedure plans ARE stored since #1368 / V7) in the Plan Viewer + tab. Still deferred: Lite's "Get Actual Plan" (needs a live execution), the per-row double-click + history windows, and the slicer overlay-on-select — only the slicer's drag-to-narrow re-read and + its sort-driven metric re-labeling are wired here. --> + <TabItem Header="Queries"> + <!-- The shared "Compare:" baseline combo lives in the per-server header now (it drives the + whole tab's window, not just Queries); this tab is just the sub-tab group. --> + <Grid Margin="8"> + <TabControl x:Name="QueriesSubTabControl" + Background="Transparent" BorderThickness="0" + ItemContainerStyle="{DynamicResource SubTabItemStyle}" + SelectionChanged="QueriesSubTabs_SelectionChanged"> + <!-- Performance Trends (Lite ServerTab.xaml:259-282): 2x2 grid of per-second trend + charts (query / procedure / Query Store duration + execution count). --> + <TabItem Header="Performance Trends"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="*"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*"/> + <ColumnDefinition Width="*"/> + </Grid.ColumnDefinitions> + <Border Grid.Row="0" Grid.Column="0" Margin="0,0,4,4"> + <scottplot:WpfPlot x:Name="QueryDurationTrendChart"/> + </Border> + <Border Grid.Row="0" Grid.Column="1" Margin="4,0,0,4"> + <scottplot:WpfPlot x:Name="ProcDurationTrendChart"/> + </Border> + <Border Grid.Row="1" Grid.Column="0" Margin="0,4,4,0"> + <scottplot:WpfPlot x:Name="QueryStoreDurationTrendChart"/> + </Border> + <Border Grid.Row="1" Grid.Column="1" Margin="4,4,0,0"> + <scottplot:WpfPlot x:Name="ExecutionCountTrendChart"/> + </Border> + </Grid> + </TabItem> + <!-- Active Queries (Lite ServerTab.xaml:283-403): the hourly slicer + ~26-column + snapshot grid. "Latest Snapshot" re-reads the newest STORED batch (no live SQL — + Erik's decision); the Query Plan buttons open the stored plan through the + OpenPlanTab partial hook the plan-host wave implements. --> + <TabItem Header="Active Queries"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + <local:TimeRangeSlicerControl x:Name="ActiveQueriesSlicer" Grid.Row="0"/> + <StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,0,0,8"> + <TextBlock Text="Currently Running Queries" Style="{StaticResource SectionHeader}" VerticalAlignment="Center" Margin="0"/> + <Button x:Name="LatestSnapshotButton" Content="Latest Snapshot" + Click="LatestSnapshot_Click" Padding="12,4" Margin="16,0,8,0" + VerticalAlignment="Center"/> + <TextBlock x:Name="LatestSnapshotIndicator" Text="" FontSize="12" + Foreground="DarkGoldenrod" FontWeight="SemiBold" VerticalAlignment="Center"/> + </StackPanel> + <DataGrid Grid.Row="2" x:Name="QuerySnapshotsGrid" Style="{StaticResource DarkGrid}" + RowStyle="{StaticResource QuerySnapshotGridRowStyle}" + HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" + Sorting="QuerySnapshotsGrid_Sorting"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding SessionId}" ElementStyle="{StaticResource NumericCell}" Width="55"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SessionId" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="SPID" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CollectionTimeLocal}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CollectionTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Collected" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DatabaseName}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DatabaseName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Database" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LoginName}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LoginName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Login" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding HostName}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="HostName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Host" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ProgramName}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ProgramName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Program" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Status}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Status" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Status" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ElapsedTimeFormatted}" Width="120" SortMemberPath="TotalElapsedTimeMs"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ElapsedTimeFormatted" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Elapsed" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CpuTimeMs, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CpuTimeMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="95"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LogicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Logical Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Reads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="70"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Reads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Writes, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="70"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Writes" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Writes" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding WaitType}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="WaitType" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Wait Type" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding WaitTimeMs, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="WaitTimeMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Wait (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding WaitResource}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="WaitResource" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Wait Resource" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BlockingSessionId}" ElementStyle="{StaticResource NumericCell}" Width="70"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BlockingSessionId" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Blocking" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Dop}" ElementStyle="{StaticResource NumericCell}" Width="45"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Dop" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="DOP" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ParallelWorkerCount}" ElementStyle="{StaticResource NumericCell}" Width="60"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ParallelWorkerCount" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Workers" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding GrantedQueryMemoryGb, StringFormat=F2}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="GrantedQueryMemoryGb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Memory (GB)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TransactionIsolationLevel}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TransactionIsolationLevel" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Isolation" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding OpenTransactionCount}" ElementStyle="{StaticResource NumericCell}" Width="70"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="OpenTransactionCount" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Open Tran" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PercentComplete, StringFormat=F1}" ElementStyle="{StaticResource NumericCell}" Width="70"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PercentComplete" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="% Done" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding QueryHash}" Width="150"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="QueryHash" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Query Hash" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTemplateColumn Width="500"> + <DataGridTemplateColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="QueryText" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Query Text" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTemplateColumn.Header> + <DataGridTemplateColumn.CellTemplate> + <DataTemplate> + <TextBlock Text="{Binding QueryText}" TextWrapping="Wrap" MaxHeight="90" Margin="5,2" + ToolTip="{Binding QueryText}"/> + </DataTemplate> + </DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + <DataGridTemplateColumn Header="Query Plan" Width="Auto" MinWidth="160"> + <DataGridTemplateColumn.CellTemplate> + <DataTemplate> + <StackPanel Orientation="Horizontal" HorizontalAlignment="Center"> + <Button Content="Estimated" Click="OpenSnapshotEstimatedPlan_Click" + IsEnabled="{Binding HasQueryPlan}" + VerticalAlignment="Center" + Padding="6,4" Margin="0,0,4,0"/> + <Button Content="Live" Click="OpenSnapshotActualPlan_Click" + IsEnabled="{Binding HasLiveQueryPlan}" + VerticalAlignment="Center" + Padding="6,4"/> + </StackPanel> + </DataTemplate> + </DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + </DataGrid.Columns> + </DataGrid> + </Grid> + </TabItem> + <!-- Current Active Queries (LIVE, on-demand): a service-mediated live DMV snapshot of what is + running on the monitored server RIGHT NOW (the fetch_active_queries command), distinct from + the stored "Active Queries" tab above (the collector's hourly snapshots). Refresh fires the + command; a read-only seat / timeout / unreachable server degrade to a status message. The + grid mirrors the stored one's columns (no per-column filters — the live grid is a + point-in-time read, not a windowed store read); the Estimated / Actual plan buttons reuse + the stored grid's handlers on the plan the live DMV read carried inline. --> + <TabItem Header="Current Active Queries"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,8"> + <TextBlock Text="Currently Running (Live)" Style="{StaticResource SectionHeader}" VerticalAlignment="Center" Margin="0"/> + <Button x:Name="RefreshCurrentActiveQueriesButton" Content="Refresh" + Click="RefreshCurrentActiveQueries_Click" Padding="12,4" Margin="16,0,8,0" + VerticalAlignment="Center"/> + <TextBlock x:Name="CurrentActiveQueriesStatus" + Text="Click Refresh to fetch what is running on the server right now." + FontSize="12" Foreground="DarkGoldenrod" FontWeight="SemiBold" VerticalAlignment="Center"/> + </StackPanel> + <DataGrid Grid.Row="1" x:Name="CurrentActiveQueriesGrid" Style="{StaticResource DarkGrid}" + RowStyle="{StaticResource QuerySnapshotGridRowStyle}" + HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"> + <DataGrid.Columns> + <DataGridTextColumn Header="SPID" Binding="{Binding SessionId}" ElementStyle="{StaticResource NumericCell}" Width="55"/> + <DataGridTextColumn Header="Collected" Binding="{Binding CollectionTimeLocal}" Width="140"/> + <DataGridTextColumn Header="Database" Binding="{Binding DatabaseName}" Width="120"/> + <DataGridTextColumn Header="Login" Binding="{Binding LoginName}" Width="110"/> + <DataGridTextColumn Header="Host" Binding="{Binding HostName}" Width="110"/> + <DataGridTextColumn Header="Program" Binding="{Binding ProgramName}" Width="140"/> + <DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="80"/> + <DataGridTextColumn Header="Elapsed" Binding="{Binding ElapsedTimeFormatted}" Width="120" SortMemberPath="TotalElapsedTimeMs"/> + <DataGridTextColumn Header="CPU (ms)" Binding="{Binding CpuTimeMs, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="80"/> + <DataGridTextColumn Header="Logical Reads" Binding="{Binding LogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="95"/> + <DataGridTextColumn Header="Reads" Binding="{Binding Reads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="70"/> + <DataGridTextColumn Header="Writes" Binding="{Binding Writes, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="70"/> + <DataGridTextColumn Header="Wait Type" Binding="{Binding WaitType}" Width="120"/> + <DataGridTextColumn Header="Wait (ms)" Binding="{Binding WaitTimeMs, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="80"/> + <DataGridTextColumn Header="Wait Resource" Binding="{Binding WaitResource}" Width="140"/> + <DataGridTextColumn Header="Blocking" Binding="{Binding BlockingSessionId}" ElementStyle="{StaticResource NumericCell}" Width="70"/> + <DataGridTextColumn Header="DOP" Binding="{Binding Dop}" ElementStyle="{StaticResource NumericCell}" Width="45"/> + <DataGridTextColumn Header="Workers" Binding="{Binding ParallelWorkerCount}" ElementStyle="{StaticResource NumericCell}" Width="60"/> + <DataGridTextColumn Header="Memory (GB)" Binding="{Binding GrantedQueryMemoryGb, StringFormat=F2}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Isolation" Binding="{Binding TransactionIsolationLevel}" Width="140"/> + <DataGridTextColumn Header="Open Tran" Binding="{Binding OpenTransactionCount}" ElementStyle="{StaticResource NumericCell}" Width="70"/> + <DataGridTextColumn Header="% Done" Binding="{Binding PercentComplete, StringFormat=F1}" ElementStyle="{StaticResource NumericCell}" Width="70"/> + <DataGridTextColumn Header="Query Hash" Binding="{Binding QueryHash}" Width="150"/> + <DataGridTemplateColumn Header="Query Text" Width="500"> + <DataGridTemplateColumn.CellTemplate> + <DataTemplate> + <TextBlock Text="{Binding QueryText}" TextWrapping="Wrap" MaxHeight="90" Margin="5,2" + ToolTip="{Binding QueryText}"/> + </DataTemplate> + </DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + <DataGridTemplateColumn Header="Query Plan" Width="Auto" MinWidth="160"> + <DataGridTemplateColumn.CellTemplate> + <DataTemplate> + <StackPanel Orientation="Horizontal" HorizontalAlignment="Center"> + <Button Content="Estimated" Click="OpenSnapshotEstimatedPlan_Click" + IsEnabled="{Binding HasQueryPlan}" + VerticalAlignment="Center" + Padding="6,4" Margin="0,0,4,0"/> + <Button Content="Live" Click="OpenSnapshotActualPlan_Click" + IsEnabled="{Binding HasLiveQueryPlan}" + VerticalAlignment="Center" + Padding="6,4"/> + </StackPanel> + </DataTemplate> + </DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + </DataGrid.Columns> + </DataGrid> + </Grid> + </TabItem> + <!-- Top Queries by Duration (Lite ServerTab.xaml:404-702): ~44 columns, 4 BarChartCell + columns (Executions / Total CPU / Total Duration / Total Reads) with BarMax* bound + by RelativeSource to the host ViewerServerTab, plus the CPU-by-database expander. --> + <TabItem Header="Top Queries by Duration"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + <local:TimeRangeSlicerControl x:Name="QueryStatsSlicer" Grid.Row="0"/> + <StackPanel Grid.Row="1"> + <TextBlock x:Name="QueryStatsComparisonBanner" Visibility="Collapsed" + Padding="8,4" FontWeight="Bold" + Foreground="{DynamicResource ForegroundBrush}" + Background="#2244AAFF"/> + <Expander x:Name="QueryStatsByDbExpander" Header="CPU by database" IsExpanded="False" Margin="4,2,4,0"> + <ScrollViewer MaxHeight="220" VerticalScrollBarVisibility="Auto"> + <ui:MetricBarCards x:Name="QueryStatsByDbCards" Margin="4,4,4,8"/> + </ScrollViewer> + </Expander> + </StackPanel> + <DataGrid Grid.Row="2" x:Name="QueryStatsGrid" Style="{StaticResource DarkGrid}" + RowStyle="{StaticResource QueryPlanGridRowStyle}" + HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" + Sorting="QueryStatsGrid_Sorting"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding DatabaseName}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DatabaseName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Database" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ModuleName}" Width="200"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ModuleName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Module" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LastExecutionTimeLocal}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LastExecutionTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Last Execution" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CreationTimeLocal}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CreationTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Creation Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTemplateColumn SortMemberPath="TotalExecutions" Width="90"> + <DataGridTemplateColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalExecutions" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Executions" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTemplateColumn.Header> + <DataGridTemplateColumn.CellTemplate><DataTemplate> + <ui:BarChartCell Value="{Binding TotalExecutions}" Maximum="{Binding BarMaxExecutions, RelativeSource={RelativeSource AncestorType={x:Type local:ViewerServerTab}}}" Text="{Binding TotalExecutions, StringFormat=N0}"/> + </DataTemplate></DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + <DataGridTemplateColumn SortMemberPath="TotalCpuMs" Width="110"> + <DataGridTemplateColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalCpuMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTemplateColumn.Header> + <DataGridTemplateColumn.CellTemplate><DataTemplate> + <ui:BarChartCell Value="{Binding TotalCpuMs}" Maximum="{Binding BarMaxTotalCpu, RelativeSource={RelativeSource AncestorType={x:Type local:ViewerServerTab}}}" Text="{Binding TotalCpuMs, StringFormat=N1}"/> + </DataTemplate></DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + <DataGridTextColumn Binding="{Binding AvgCpuMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgCpuMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding WorkerTimePerSecond, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="WorkerTimePerSecond" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Peak CPU (ms/s)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PlanGenerationNum, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PlanGenerationNum" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Plan Gen" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalClrMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalClrMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total CLR (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTemplateColumn SortMemberPath="TotalElapsedMs" Width="130"> + <DataGridTemplateColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalElapsedMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total Duration (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTemplateColumn.Header> + <DataGridTemplateColumn.CellTemplate><DataTemplate> + <ui:BarChartCell Value="{Binding TotalElapsedMs}" Maximum="{Binding BarMaxTotalElapsed, RelativeSource={RelativeSource AncestorType={x:Type local:ViewerServerTab}}}" Text="{Binding TotalElapsedMs, StringFormat=N1}"/> + </DataTemplate></DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + <DataGridTextColumn Binding="{Binding AvgElapsedMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgElapsedMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Duration (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTemplateColumn SortMemberPath="TotalLogicalReads" Width="100"> + <DataGridTemplateColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalLogicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTemplateColumn.Header> + <DataGridTemplateColumn.CellTemplate><DataTemplate> + <ui:BarChartCell Value="{Binding TotalLogicalReads}" Maximum="{Binding BarMaxTotalReads, RelativeSource={RelativeSource AncestorType={x:Type local:ViewerServerTab}}}" Text="{Binding TotalLogicalReads, StringFormat=N0}"/> + </DataTemplate></DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + <DataGridTextColumn Binding="{Binding AvgReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalLogicalWrites" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total Writes" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalPhysicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Physical Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalRows, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalRows" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total Rows" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalSpills" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total Spills" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinCpuMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinCpuMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxCpuMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxCpuMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinElapsedMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinElapsedMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Duration (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxElapsedMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxElapsedMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Duration (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinPhysicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Phys Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxPhysicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Phys Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinRows, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinRows" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Rows" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxRows, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxRows" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Rows" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinGrantKb, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinGrantKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Grant KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxGrantKb, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxGrantKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Grant KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinUsedGrantKb, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinUsedGrantKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Used Grant KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxUsedGrantKb, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxUsedGrantKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Used Grant KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinIdealGrantKb, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinIdealGrantKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Ideal Grant KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxIdealGrantKb, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxIdealGrantKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Ideal Grant KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <!-- Min/Max Spills + Min/Max DOP (Lite ServerTab.xaml:537-547): ViewerQueryStatsRow + already maps these; they were the only Top Queries columns the viewer dropped. --> + <DataGridTextColumn Binding="{Binding MinSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinSpills" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Spills" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxSpills" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Spills" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinDop}" ElementStyle="{StaticResource NumericCell}" Width="70"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinDop" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min DOP" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxDop}" ElementStyle="{StaticResource NumericCell}" Width="70"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxDop" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max DOP" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinReservedThreads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinReservedThreads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Rsvd Threads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxReservedThreads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxReservedThreads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Rsvd Threads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinUsedThreads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinUsedThreads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Used Threads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxUsedThreads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxUsedThreads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Used Threads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding QueryHash}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="QueryHash" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Query Hash" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding QueryPlanHash}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="QueryPlanHash" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Plan Hash" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding SqlHandle}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SqlHandle" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="SQL Handle" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PlanHandle}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PlanHandle" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Plan Handle" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTemplateColumn Width="500"> + <DataGridTemplateColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="QueryText" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Query Text" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTemplateColumn.Header> + <DataGridTemplateColumn.CellTemplate> + <DataTemplate> + <TextBlock Text="{Binding QueryText}" TextWrapping="Wrap" MaxHeight="90" Margin="5,2" + ToolTip="{Binding QueryText}"/> + </DataTemplate> + </DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + <!-- Query Plan download column (mirrors Lite's Top Queries "Download" button), gated on + HasQueryPlan so it's only actionable when the collector stored a plan for this row. + The button saves the stored query_plan_xml to a .sqlplan file; "View Plan" (context + menu) opens it in the Plan Viewer tab instead. --> + <DataGridTemplateColumn Header="Query Plan" Width="Auto" MinWidth="90"> + <DataGridTemplateColumn.CellTemplate> + <DataTemplate> + <Button Content="Download" Click="DownloadQueryStatsPlan_Click" + IsEnabled="{Binding HasQueryPlan}" + HorizontalAlignment="Center" VerticalAlignment="Center" + Padding="8,4"/> + </DataTemplate> + </DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + </DataGrid.Columns> + </DataGrid> + <DataGrid Grid.Row="2" x:Name="QueryStatsComparisonGrid" Visibility="Collapsed" + Style="{StaticResource DarkGrid}" RowStyle="{StaticResource QueryGridRowStyle}" + HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding StatusBadge}" Width="55"> + <DataGridTextColumn.Header><TextBlock Text="" FontWeight="Bold" VerticalAlignment="Center"/></DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle> + <Style TargetType="TextBlock"> + <Setter Property="FontWeight" Value="Bold"/> + <Setter Property="HorizontalAlignment" Value="Center"/> + <Style.Triggers> + <DataTrigger Binding="{Binding IsNew}" Value="True"> + <Setter Property="Foreground" Value="#FF6B6B"/> + </DataTrigger> + <DataTrigger Binding="{Binding IsGone}" Value="True"> + <Setter Property="Foreground" Value="#888888"/> + </DataTrigger> + </Style.Triggers> + </Style> + </DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DatabaseName}" Width="120"> + <DataGridTextColumn.Header><TextBlock Text="Database" FontWeight="Bold" VerticalAlignment="Center"/></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ExecutionCount, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><TextBlock Text="Executions" FontWeight="Bold" VerticalAlignment="Center"/></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BaselineExecutionCount, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><TextBlock Text="Base Execs" FontWeight="Bold" VerticalAlignment="Center"/></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ExecutionDeltaDisplay}" Width="80"> + <DataGridTextColumn.Header><TextBlock Text="Exec Δ" FontWeight="Bold" VerticalAlignment="Center"/></DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle> + <Style TargetType="TextBlock"> + <Setter Property="HorizontalAlignment" Value="Right"/> + <Setter Property="Padding" Value="0,0,5,0"/> + <Setter Property="FontWeight" Value="SemiBold"/> + <Setter Property="Foreground" Value="{Binding ExecutionDeltaBrush}"/> + </Style> + </DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgDurationMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><TextBlock Text="Avg Dur (ms)" FontWeight="Bold" VerticalAlignment="Center"/></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BaselineAvgDurationMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><TextBlock Text="Base Dur (ms)" FontWeight="Bold" VerticalAlignment="Center"/></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DurationDeltaDisplay}" Width="80"> + <DataGridTextColumn.Header><TextBlock Text="Dur Δ" FontWeight="Bold" VerticalAlignment="Center"/></DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle> + <Style TargetType="TextBlock"> + <Setter Property="HorizontalAlignment" Value="Right"/> + <Setter Property="Padding" Value="0,0,5,0"/> + <Setter Property="FontWeight" Value="SemiBold"/> + <Setter Property="Foreground" Value="{Binding DurationDeltaBrush}"/> + </Style> + </DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgCpuMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="95"> + <DataGridTextColumn.Header><TextBlock Text="Avg CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BaselineAvgCpuMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><TextBlock Text="Base CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CpuDeltaDisplay}" Width="80"> + <DataGridTextColumn.Header><TextBlock Text="CPU Δ" FontWeight="Bold" VerticalAlignment="Center"/></DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle> + <Style TargetType="TextBlock"> + <Setter Property="HorizontalAlignment" Value="Right"/> + <Setter Property="Padding" Value="0,0,5,0"/> + <Setter Property="FontWeight" Value="SemiBold"/> + <Setter Property="Foreground" Value="{Binding CpuDeltaBrush}"/> + </Style> + </DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="85"> + <DataGridTextColumn.Header><TextBlock Text="Avg Reads" FontWeight="Bold" VerticalAlignment="Center"/></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BaselineAvgReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="95"> + <DataGridTextColumn.Header><TextBlock Text="Base Reads" FontWeight="Bold" VerticalAlignment="Center"/></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ReadsDeltaDisplay}" Width="80"> + <DataGridTextColumn.Header><TextBlock Text="Reads Δ" FontWeight="Bold" VerticalAlignment="Center"/></DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle> + <Style TargetType="TextBlock"> + <Setter Property="HorizontalAlignment" Value="Right"/> + <Setter Property="Padding" Value="0,0,5,0"/> + <Setter Property="FontWeight" Value="SemiBold"/> + <Setter Property="Foreground" Value="{Binding ReadsDeltaBrush}"/> + </Style> + </DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding QueryHash}" Width="140"> + <DataGridTextColumn.Header><TextBlock Text="Query Hash" FontWeight="Bold" VerticalAlignment="Center"/></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTemplateColumn Width="500"> + <DataGridTemplateColumn.Header><TextBlock Text="Query Text" FontWeight="Bold" VerticalAlignment="Center"/></DataGridTemplateColumn.Header> + <DataGridTemplateColumn.CellTemplate> + <DataTemplate> + <TextBlock Text="{Binding QueryText}" TextWrapping="Wrap" MaxHeight="90" Margin="5,2" + ToolTip="{Binding QueryText}"/> + </DataTemplate> + </DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + </DataGrid.Columns> + </DataGrid> + </Grid> + </TabItem> + <!-- Top Procedures by Duration (Lite ServerTab.xaml:703-890): ~30 plain-text columns. --> + <TabItem Header="Top Procedures by Duration"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + <local:TimeRangeSlicerControl x:Name="ProcStatsSlicer" Grid.Row="0"/> + <TextBlock x:Name="ProcStatsComparisonBanner" Grid.Row="1" Visibility="Collapsed" + Padding="8,4" FontWeight="Bold" + Foreground="{DynamicResource ForegroundBrush}" + Background="#2244AAFF"/> + <DataGrid Grid.Row="2" x:Name="ProcedureStatsGrid" Style="{StaticResource DarkGrid}" + RowStyle="{StaticResource QueryPlanGridRowStyle}" + HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" + Sorting="ProcedureStatsGrid_Sorting"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding DatabaseName}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DatabaseName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Database" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding FullName}" Width="250"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="FullName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Procedure" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ObjectType}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ObjectType" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Type" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LastExecutionTimeLocal}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LastExecutionTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Last Execution" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CachedTimeFormatted}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CachedTimeFormatted" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Cached Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalExecutions, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalExecutions" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Executions" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalCpuMs, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalCpuMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgCpuMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgCpuMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalElapsedMs, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="130"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalElapsedMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total Duration (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgElapsedMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgElapsedMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Duration (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalLogicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalLogicalWrites" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total Writes" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalPhysicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Physical Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalSpills" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total Spills" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgSpills, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgSpills" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Spills" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinCpuMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinCpuMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxCpuMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxCpuMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinElapsedMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinElapsedMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Duration (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxElapsedMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxElapsedMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Duration (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinLogicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxLogicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinPhysicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Phys Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxPhysicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Phys Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinLogicalWrites" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Writes" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxLogicalWrites" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Writes" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinSpills" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Spills" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxSpills" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Spills" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PlanHandle}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PlanHandle" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Plan Handle" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <!-- Query Plan download column (mirrors Lite's Top Procedures "Download" button + the + Top Queries column above), gated on HasQueryPlan so it's only actionable when the + collector stored a plan for this object. Saves procedure_stats.query_plan_xml to a + .sqlplan file; "View Plan" (context menu) opens it in the Plan Viewer tab instead. --> + <DataGridTemplateColumn Header="Query Plan" Width="Auto" MinWidth="90"> + <DataGridTemplateColumn.CellTemplate> + <DataTemplate> + <Button Content="Download" Click="DownloadProcedureStatsPlan_Click" + IsEnabled="{Binding HasQueryPlan}" + HorizontalAlignment="Center" VerticalAlignment="Center" + Padding="8,4"/> + </DataTemplate> + </DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + </DataGrid.Columns> + </DataGrid> + <DataGrid Grid.Row="2" x:Name="ProcStatsComparisonGrid" Visibility="Collapsed" + Style="{StaticResource DarkGrid}" RowStyle="{StaticResource QueryGridRowStyle}" + HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding StatusBadge}" Width="55"> + <DataGridTextColumn.Header><TextBlock Text="" FontWeight="Bold"/></DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle> + <Style TargetType="TextBlock"> + <Setter Property="FontWeight" Value="Bold"/> + <Setter Property="HorizontalAlignment" Value="Center"/> + <Style.Triggers> + <DataTrigger Binding="{Binding IsNew}" Value="True"><Setter Property="Foreground" Value="#FF6B6B"/></DataTrigger> + <DataTrigger Binding="{Binding IsGone}" Value="True"><Setter Property="Foreground" Value="#888888"/></DataTrigger> + </Style.Triggers> + </Style> + </DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DatabaseName}" Width="120"> + <DataGridTextColumn.Header><TextBlock Text="Database" FontWeight="Bold"/></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding FullName}" Width="200"> + <DataGridTextColumn.Header><TextBlock Text="Procedure" FontWeight="Bold"/></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ExecutionCount, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><TextBlock Text="Executions" FontWeight="Bold"/></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BaselineExecutionCount, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><TextBlock Text="Base Execs" FontWeight="Bold"/></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ExecutionDeltaDisplay}" Width="80"> + <DataGridTextColumn.Header><TextBlock Text="Exec Δ" FontWeight="Bold"/></DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle><Style TargetType="TextBlock"><Setter Property="HorizontalAlignment" Value="Right"/><Setter Property="Padding" Value="0,0,5,0"/><Setter Property="FontWeight" Value="SemiBold"/><Setter Property="Foreground" Value="{Binding ExecutionDeltaBrush}"/></Style></DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgDurationMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><TextBlock Text="Avg Dur (ms)" FontWeight="Bold"/></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BaselineAvgDurationMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><TextBlock Text="Base Dur (ms)" FontWeight="Bold"/></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DurationDeltaDisplay}" Width="80"> + <DataGridTextColumn.Header><TextBlock Text="Dur Δ" FontWeight="Bold"/></DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle><Style TargetType="TextBlock"><Setter Property="HorizontalAlignment" Value="Right"/><Setter Property="Padding" Value="0,0,5,0"/><Setter Property="FontWeight" Value="SemiBold"/><Setter Property="Foreground" Value="{Binding DurationDeltaBrush}"/></Style></DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgCpuMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="95"> + <DataGridTextColumn.Header><TextBlock Text="Avg CPU (ms)" FontWeight="Bold"/></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BaselineAvgCpuMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><TextBlock Text="Base CPU (ms)" FontWeight="Bold"/></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CpuDeltaDisplay}" Width="80"> + <DataGridTextColumn.Header><TextBlock Text="CPU Δ" FontWeight="Bold"/></DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle><Style TargetType="TextBlock"><Setter Property="HorizontalAlignment" Value="Right"/><Setter Property="Padding" Value="0,0,5,0"/><Setter Property="FontWeight" Value="SemiBold"/><Setter Property="Foreground" Value="{Binding CpuDeltaBrush}"/></Style></DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="85"> + <DataGridTextColumn.Header><TextBlock Text="Avg Reads" FontWeight="Bold"/></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BaselineAvgReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="95"> + <DataGridTextColumn.Header><TextBlock Text="Base Reads" FontWeight="Bold"/></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ReadsDeltaDisplay}" Width="80"> + <DataGridTextColumn.Header><TextBlock Text="Reads Δ" FontWeight="Bold"/></DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle><Style TargetType="TextBlock"><Setter Property="HorizontalAlignment" Value="Right"/><Setter Property="Padding" Value="0,0,5,0"/><Setter Property="FontWeight" Value="SemiBold"/><Setter Property="Foreground" Value="{Binding ReadsDeltaBrush}"/></Style></DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + </Grid> + </TabItem> + <!-- Query Store by Duration (Lite ServerTab.xaml:891-1159): ~52 columns + the IsForcedPlan + checkbox column; table is query_store_stats in the Darling store. --> + <TabItem Header="Query Store by Duration"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + <local:TimeRangeSlicerControl x:Name="QueryStoreSlicer" Grid.Row="0"/> + <TextBlock x:Name="QueryStoreComparisonBanner" Grid.Row="1" Visibility="Collapsed" + Padding="8,4" FontWeight="Bold" + Foreground="{DynamicResource ForegroundBrush}" + Background="#2244AAFF"/> + <DataGrid Grid.Row="2" x:Name="QueryStoreGrid" Style="{StaticResource DarkGrid}" + RowStyle="{StaticResource QueryStorePlanGridRowStyle}" + HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" + Sorting="QueryStoreGrid_Sorting"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding DatabaseName}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DatabaseName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Database" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding QueryId}" ElementStyle="{StaticResource NumericCell}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="QueryId" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Query ID" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PlanId}" ElementStyle="{StaticResource NumericCell}" Width="70"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PlanId" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Plan ID" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ReplicaRole}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ReplicaRole" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Replica" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LastExecutionTimeLocal}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LastExecutionTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Last Execution" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding FirstExecutionTimeLocal}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="FirstExecutionTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="First Execution" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalExecutions, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalExecutions" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Executions" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalDurationMs, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="130"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalDurationMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total Duration (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgDurationMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgDurationMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Duration (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinDurationMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinDurationMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Duration (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxDurationMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxDurationMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Duration (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalCpuMs, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalCpuMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgCpuTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgCpuTimeMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinCpuTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinCpuTimeMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxCpuTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxCpuTimeMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgLogicalReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgLogicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinLogicalReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinLogicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxLogicalReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxLogicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgLogicalWrites, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgLogicalWrites" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Writes" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinLogicalWrites, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinLogicalWrites" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Writes" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxLogicalWrites, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxLogicalWrites" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Writes" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgPhysicalReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgPhysicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Physical Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinPhysicalReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinPhysicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Physical Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxPhysicalReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxPhysicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Physical Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgRowcount, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgRowcount" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Rows" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinRowcount, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinRowcount" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Rows" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxRowcount, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxRowcount" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Rows" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinDop}" ElementStyle="{StaticResource NumericCell}" Width="70"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinDop" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min DOP" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxDop}" ElementStyle="{StaticResource NumericCell}" Width="70"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxDop" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max DOP" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgMemoryMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgMemoryMb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Mem (MB)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinMemoryMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinMemoryMb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Mem (MB)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxMemoryMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxMemoryMb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Mem (MB)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ModuleName}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ModuleName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Module" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding QueryHash}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="QueryHash" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Query Hash" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding QueryPlanHash}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="QueryPlanHash" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Plan Hash" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridCheckBoxColumn Binding="{Binding IsForcedPlan, Mode=OneWay}" Width="80" IsReadOnly="True"> + <DataGridCheckBoxColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IsForcedPlan" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Forced" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridCheckBoxColumn.Header> + </DataGridCheckBoxColumn> + <DataGridTextColumn Binding="{Binding PlanForcingType}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PlanForcingType" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Force Type" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ExecutionTypeDesc}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ExecutionTypeDesc" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Exec Type" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgClrTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgClrTimeMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg CLR (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinClrTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinClrTimeMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min CLR (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxClrTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxClrTimeMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max CLR (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgNumPhysicalIoReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgNumPhysicalIoReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Phys IO Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinNumPhysicalIoReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinNumPhysicalIoReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Phys IO Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxNumPhysicalIoReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxNumPhysicalIoReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Phys IO Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgTempdbSpaceUsed, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgTempdbSpaceUsed" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg tempdb Pages" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinTempdbSpaceUsed, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinTempdbSpaceUsed" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min tempdb Pages" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxTempdbSpaceUsed, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxTempdbSpaceUsed" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max tempdb Pages" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgLogBytesUsed, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgLogBytesUsed" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Log Bytes" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinLogBytesUsed, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinLogBytesUsed" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Log Bytes" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxLogBytesUsed, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxLogBytesUsed" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Log Bytes" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PlanType}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PlanType" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Plan Type" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ForceFailureCount}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ForceFailureCount" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Force Failures" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LastForceFailureReason}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LastForceFailureReason" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Force Failure Reason" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CompatibilityLevel}" ElementStyle="{StaticResource NumericCell}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CompatibilityLevel" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Compat" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTemplateColumn Width="500"> + <DataGridTemplateColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="QueryText" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Query Text" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTemplateColumn.Header> + <DataGridTemplateColumn.CellTemplate> + <DataTemplate> + <TextBlock Text="{Binding QueryText}" TextWrapping="Wrap" MaxHeight="90" Margin="5,2" + ToolTip="{Binding QueryText}"/> + </DataTemplate> + </DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + </DataGrid.Columns> + </DataGrid> + <DataGrid Grid.Row="2" x:Name="QueryStoreComparisonGrid" Visibility="Collapsed" + Style="{StaticResource DarkGrid}" RowStyle="{StaticResource QueryGridRowStyle}" + HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding StatusBadge}" Width="55"> + <DataGridTextColumn.Header><TextBlock Text="" FontWeight="Bold"/></DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle> + <Style TargetType="TextBlock"> + <Setter Property="FontWeight" Value="Bold"/> + <Setter Property="HorizontalAlignment" Value="Center"/> + <Style.Triggers> + <DataTrigger Binding="{Binding IsNew}" Value="True"><Setter Property="Foreground" Value="#FF6B6B"/></DataTrigger> + <DataTrigger Binding="{Binding IsGone}" Value="True"><Setter Property="Foreground" Value="#888888"/></DataTrigger> + </Style.Triggers> + </Style> + </DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DatabaseName}" Width="120"> + <DataGridTextColumn.Header><TextBlock Text="Database" FontWeight="Bold"/></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ExecutionCount, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><TextBlock Text="Executions" FontWeight="Bold"/></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BaselineExecutionCount, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><TextBlock Text="Base Execs" FontWeight="Bold"/></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ExecutionDeltaDisplay}" Width="80"> + <DataGridTextColumn.Header><TextBlock Text="Exec Δ" FontWeight="Bold"/></DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle><Style TargetType="TextBlock"><Setter Property="HorizontalAlignment" Value="Right"/><Setter Property="Padding" Value="0,0,5,0"/><Setter Property="FontWeight" Value="SemiBold"/><Setter Property="Foreground" Value="{Binding ExecutionDeltaBrush}"/></Style></DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgDurationMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><TextBlock Text="Avg Dur (ms)" FontWeight="Bold"/></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BaselineAvgDurationMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><TextBlock Text="Base Dur (ms)" FontWeight="Bold"/></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DurationDeltaDisplay}" Width="80"> + <DataGridTextColumn.Header><TextBlock Text="Dur Δ" FontWeight="Bold"/></DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle><Style TargetType="TextBlock"><Setter Property="HorizontalAlignment" Value="Right"/><Setter Property="Padding" Value="0,0,5,0"/><Setter Property="FontWeight" Value="SemiBold"/><Setter Property="Foreground" Value="{Binding DurationDeltaBrush}"/></Style></DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgCpuMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="95"> + <DataGridTextColumn.Header><TextBlock Text="Avg CPU (ms)" FontWeight="Bold"/></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BaselineAvgCpuMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><TextBlock Text="Base CPU (ms)" FontWeight="Bold"/></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CpuDeltaDisplay}" Width="80"> + <DataGridTextColumn.Header><TextBlock Text="CPU Δ" FontWeight="Bold"/></DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle><Style TargetType="TextBlock"><Setter Property="HorizontalAlignment" Value="Right"/><Setter Property="Padding" Value="0,0,5,0"/><Setter Property="FontWeight" Value="SemiBold"/><Setter Property="Foreground" Value="{Binding CpuDeltaBrush}"/></Style></DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="85"> + <DataGridTextColumn.Header><TextBlock Text="Avg Reads" FontWeight="Bold"/></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BaselineAvgReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="95"> + <DataGridTextColumn.Header><TextBlock Text="Base Reads" FontWeight="Bold"/></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ReadsDeltaDisplay}" Width="80"> + <DataGridTextColumn.Header><TextBlock Text="Reads Δ" FontWeight="Bold"/></DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle><Style TargetType="TextBlock"><Setter Property="HorizontalAlignment" Value="Right"/><Setter Property="Padding" Value="0,0,5,0"/><Setter Property="FontWeight" Value="SemiBold"/><Setter Property="Foreground" Value="{Binding ReadsDeltaBrush}"/></Style></DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding QueryHash}" Width="140"> + <DataGridTextColumn.Header><TextBlock Text="Query Hash" FontWeight="Bold"/></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTemplateColumn Width="500"> + <DataGridTemplateColumn.Header><TextBlock Text="Query Text" FontWeight="Bold"/></DataGridTemplateColumn.Header> + <DataGridTemplateColumn.CellTemplate> + <DataTemplate> + <TextBlock Text="{Binding QueryText}" TextWrapping="Wrap" MaxHeight="90" Margin="5,2" + ToolTip="{Binding QueryText}"/> + </DataTemplate> + </DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + </DataGrid.Columns> + </DataGrid> + </Grid> + </TabItem> + <!-- Query Store Regressions (Dashboard parity — report.query_store_regressions): the + regressed-query grid comparing each (database, query_id) group's RECENT-window + Query Store averages to its BASELINE (everything captured before the window), kept + when CPU regressed > 25% and ranked by execution-count-weighted added duration. + Inserted right after Query Store by Duration, matching the Dashboard's Query Store -> + Query Store Regressions adjacency. No slicer / comparison. The read aggregates across + plans and carries no plan XML, so there is no single stored plan to VIEW — but each row's + QueryId lets "Get Actual Plan (re-run)" RE-EXECUTE via the service (QueryStorePlanGridRowStyle + -> QueryStorePlanContextMenu, shared with the Query Store grid: "View Stored Plan" is hidden + here, "Get Actual Plan (re-run)" shown). Double-clicking a row opens that query's history + window (ViewerServerTab.History.cs); the menu also offers Copy Repro + copy/export. --> + <TabItem Header="Query Store Regressions"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + <TextBlock Grid.Row="0" Margin="8,4,0,4" FontSize="11" + Foreground="{DynamicResource ForegroundMutedBrush}" + Text="Queries whose Query Store performance regressed — recent-window averages vs. the baseline captured before the window, kept when CPU regressed more than 25%, ranked by total added duration. Double-click a row for its execution history."/> + <DataGrid Grid.Row="1" x:Name="QueryStoreRegressionsGrid" Style="{StaticResource DarkGrid}" + RowStyle="{StaticResource QueryStorePlanGridRowStyle}" + HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding LastExecutionTimeLocal}" Width="150"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LastExecutionTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Last Execution" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Severity}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Severity" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Severity" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DatabaseName}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DatabaseName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Database" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding QueryId}" ElementStyle="{StaticResource NumericCell}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="QueryId" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Query ID" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AdditionalDurationMs, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AdditionalDurationMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total Impact (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BaselineExecCount, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BaselineExecCount" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Base Execs" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding RecentExecCount, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="RecentExecCount" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Recent Execs" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BaselinePlanCount}" ElementStyle="{StaticResource NumericCell}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BaselinePlanCount" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Base Plans" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding RecentPlanCount}" ElementStyle="{StaticResource NumericCell}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="RecentPlanCount" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Recent Plans" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DurationRegressionPercent, StringFormat='{}{0:N2}%'}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DurationRegressionPercent" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Duration Δ%" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BaselineDurationMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BaselineDurationMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Base Dur (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding RecentDurationMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="RecentDurationMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Recent Dur (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CpuRegressionPercent, StringFormat='{}{0:N2}%'}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CpuRegressionPercent" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="CPU Δ%" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BaselineCpuMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BaselineCpuMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Base CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding RecentCpuMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="RecentCpuMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Recent CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding IoRegressionPercent, StringFormat='{}{0:N2}%'}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IoRegressionPercent" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="I/O Δ%" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BaselineReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BaselineReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Base Reads (pages)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding RecentReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="RecentReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Recent Reads (pages)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTemplateColumn Width="*" MinWidth="300" SortMemberPath="QueryTextSample"> + <DataGridTemplateColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="QueryTextSample" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Query Text" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTemplateColumn.Header> + <DataGridTemplateColumn.CellTemplate> + <DataTemplate> + <TextBlock Text="{Binding QueryTextSample}" TextWrapping="Wrap" MaxHeight="90" Margin="5,2" ToolTip="{Binding QueryTextSample}"/> + </DataTemplate> + </DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + </DataGrid.Columns> + </DataGrid> + </Grid> + </TabItem> + <!-- Query Heatmap (Lite ServerTab.xaml:1160-1180): metric combo + heatmap of query + counts per (5-min bin x per-execution magnitude bucket). Right-click drills into + Active Queries for the clicked bin's window. --> + <TabItem Header="Query Heatmap"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="8,4,0,4"> + <TextBlock Text="Metric:" VerticalAlignment="Center" Margin="0,0,6,0" FontSize="11"/> + <ComboBox x:Name="HeatmapMetricCombo" SelectedIndex="0" SelectionChanged="HeatmapMetric_SelectionChanged" FontSize="11" Padding="4,2"> + <ComboBoxItem Content="Duration (ms)"/> + <ComboBoxItem Content="CPU (ms)"/> + <ComboBoxItem Content="Logical Reads"/> + <ComboBoxItem Content="Logical Writes"/> + <ComboBoxItem Content="Execution Count"/> + </ComboBox> + </StackPanel> + <scottplot:WpfPlot Grid.Row="1" x:Name="QueryHeatmapChart" + MouseMove="HeatmapChart_MouseMove" + MouseLeave="HeatmapChart_MouseLeave"/> + </Grid> + </TabItem> + </TabControl> + </Grid> + </TabItem> + + <!-- Plan Viewer Tab — copied from Lite's ServerTab.xaml Plan Viewer tab (its inner tab index 3, + between Queries and CPU). Hosts the shared PerformanceMonitor.Ui PlanViewerControl as closable + sub-tabs; a "View Plan" on a Top Queries / Query Store row (or the sibling's Active Queries + snapshot grid) opens a plan here via OpenPlanTab (ViewerServerTab.Plans.cs). The loading overlay + shows while a stored plan parses AND while "Get Actual Plan" re-executes a Top Queries row through + the service (ViewerServerTab.ActualPlan.cs) — the overlay's Cancel button cancels that poll. --> + <TabItem Header="Plan Viewer" x:Name="PlanViewerTabItem"> + <Grid Background="Transparent"> + <Grid x:Name="PlanEmptyState"> + <!-- Dashed drop-zone border fills the whole tab --> + <Rectangle Margin="24" + Stroke="{DynamicResource ForegroundMutedBrush}" + StrokeThickness="1.5" + StrokeDashArray="6,4" + RadiusX="10" RadiusY="10" + Opacity="0.25"/> + <!-- Centered content --> + <StackPanel HorizontalAlignment="Center" VerticalAlignment="Center"> + <TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="52" + HorizontalAlignment="Center" + Foreground="{DynamicResource ForegroundMutedBrush}" + Margin="0,0,0,12"/> + <TextBlock Text="No Plan Loaded" FontSize="20" FontWeight="Light" + Foreground="{DynamicResource ForegroundMutedBrush}" HorizontalAlignment="Center"/> + <TextBlock Text="Right-click a Top Queries or Query Store row and choose 'View Plan'" + FontSize="13" Foreground="{DynamicResource ForegroundMutedBrush}" + HorizontalAlignment="Center" Margin="0,8,0,0"/> + </StackPanel> + </Grid> + <StackPanel x:Name="PlanLoadingState" HorizontalAlignment="Center" + VerticalAlignment="Center" Visibility="Collapsed"> + <ProgressBar IsIndeterminate="True" Width="200" Height="4" Margin="0,0,0,12"/> + <TextBlock x:Name="PlanLoadingLabel" Text="Loading plan..." + FontSize="14" Foreground="{DynamicResource ForegroundMutedBrush}" + HorizontalAlignment="Center"/> + <Button x:Name="CancelPlanButton" Content="Cancel" Click="CancelPlanButton_Click" + HorizontalAlignment="Center" Margin="0,12,0,0" Padding="16,4"/> + </StackPanel> + <TabControl x:Name="PlanTabControl" Visibility="Collapsed" + Background="Transparent"/> + </Grid> + </TabItem> + + <!-- CPU Tab — copied from Lite's ServerTab.xaml CPU tab: one full-bleed chart of raw + per-sample CPU (SQL Server vs other processes), distinct from the Overview's + averaged-per-collection CPU trend. --> + <!-- CPU Tab — a sub-TabControl: "CPU Utilization" (the existing raw per-sample CPU chart) and + "CPU Scheduler" (the cpu_scheduler_stats parity surface: runnable/blocked/queued pressure + trend + a latest-snapshot metric grid whose warning rows highlight). Both sub-tabs load on + CPU-tab activation (LoadCpuAsync), so this sub-TabControl needs no SelectionChanged handler. --> + <TabItem Header="CPU"> + <TabControl x:Name="CpuSubTabControl" Background="Transparent" BorderThickness="0" ItemContainerStyle="{DynamicResource SubTabItemStyle}"> + <TabItem Header="CPU Utilization"> + <Grid Margin="8"> + <scottplot:WpfPlot x:Name="CpuChart"/> + </Grid> + </TabItem> + <TabItem Header="CPU Scheduler"> + <Grid Margin="8"> + <Grid.RowDefinitions> + <RowDefinition Height="2*"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + <scottplot:WpfPlot Grid.Row="0" x:Name="CpuSchedulerChart"/> + <DataGrid Grid.Row="1" x:Name="CpuSchedulerGrid" Style="{StaticResource DarkGrid}" Margin="0,8,0,0" + HeadersVisibility="Column" AutoGenerateColumns="False"> + <DataGrid.RowStyle> + <Style TargetType="DataGridRow" BasedOn="{StaticResource DarkGridRow}"> + <Style.Triggers> + <DataTrigger Binding="{Binding IsWarning}" Value="True"> + <Setter Property="Background" Value="#33FF6B6B"/> + </DataTrigger> + </Style.Triggers> + </Style> + </DataGrid.RowStyle> + <DataGrid.Columns> + <DataGridTextColumn Header="Metric" Binding="{Binding Metric}" Width="2*"/> + <DataGridTextColumn Header="Value" Binding="{Binding Value}" Width="*"/> + </DataGrid.Columns> + </DataGrid> + </Grid> + </TabItem> + </TabControl> + </TabItem> + + <!-- Memory Tab — copied from Lite's ServerTab.xaml Memory tab (control names verbatim): a nested + sub-TabControl with Overview (10-tile summary strip over the memory trend chart), Memory + Clerks (checkbox picker + trend chart + non-BP summary), Memory Grants (stacked sizing + + activity charts), and Memory Pressure Events (hour-bucketed stacked pressure bars). Placed + BETWEEN CPU and File I/O, matching Lite's own tab order. Reads rewired to Postgres; the + sub-tabs all (re)load on Memory-tab activation (Lite's full-refresh branch — the sub-tab-only + optimization is deferred), so this sub-TabControl needs no SelectionChanged handler. --> + <TabItem Header="Memory"> + <TabControl x:Name="MemorySubTabControl" Background="Transparent" BorderThickness="0" ItemContainerStyle="{DynamicResource SubTabItemStyle}"> + <!-- Memory Overview Sub-Tab --> + <TabItem Header="Overview"> + <Grid Margin="8"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + + <!-- Memory Summary Panel --> + <Border Grid.Row="0" Background="{DynamicResource BackgroundDarkBrush}" CornerRadius="4" Padding="12" Margin="0,0,0,8"> + <WrapPanel> + <StackPanel Margin="0,0,24,0"> + <TextBlock Text="Physical Memory" Foreground="{DynamicResource ForegroundDimBrush}" FontSize="11"/> + <TextBlock x:Name="PhysicalMemoryText" Text="--" FontSize="18" FontWeight="SemiBold"/> + </StackPanel> + <StackPanel Margin="0,0,24,0"> + <TextBlock Text="SQL Server Memory" Foreground="{DynamicResource ForegroundDimBrush}" FontSize="11"/> + <TextBlock x:Name="TotalServerMemoryText" Text="--" FontSize="18" FontWeight="SemiBold"/> + </StackPanel> + <StackPanel Margin="0,0,24,0"> + <TextBlock Text="Target Memory" Foreground="{DynamicResource ForegroundDimBrush}" FontSize="11"/> + <TextBlock x:Name="TargetServerMemoryText" Text="--" FontSize="18" FontWeight="SemiBold"/> + </StackPanel> + <StackPanel Margin="0,0,24,0"> + <TextBlock Text="Buffer Pool" Foreground="{DynamicResource ForegroundDimBrush}" FontSize="11"/> + <TextBlock x:Name="BufferPoolText" Text="--" FontSize="18" FontWeight="SemiBold"/> + </StackPanel> + <StackPanel Margin="0,0,24,0"> + <TextBlock Text="Plan Cache" Foreground="{DynamicResource ForegroundDimBrush}" FontSize="11"/> + <TextBlock x:Name="PlanCacheText" Text="--" FontSize="18" FontWeight="SemiBold"/> + </StackPanel> + <StackPanel Margin="0,0,24,0"> + <TextBlock Text="Available Physical" Foreground="{DynamicResource ForegroundDimBrush}" FontSize="11"/> + <TextBlock x:Name="AvailablePhysicalMemoryText" Text="--" FontSize="18" FontWeight="SemiBold"/> + </StackPanel> + <StackPanel Margin="0,0,24,0"> + <TextBlock Text="Total Page File" Foreground="{DynamicResource ForegroundDimBrush}" FontSize="11"/> + <TextBlock x:Name="TotalPageFileText" Text="--" FontSize="18" FontWeight="SemiBold"/> + </StackPanel> + <StackPanel Margin="0,0,24,0"> + <TextBlock Text="Available Page File" Foreground="{DynamicResource ForegroundDimBrush}" FontSize="11"/> + <TextBlock x:Name="AvailablePageFileText" Text="--" FontSize="18" FontWeight="SemiBold"/> + </StackPanel> + <StackPanel Margin="0,0,24,0"> + <TextBlock Text="System Memory State" Foreground="{DynamicResource ForegroundDimBrush}" FontSize="11"/> + <TextBlock x:Name="MemoryStateText" Text="--" FontSize="18" FontWeight="SemiBold"/> + </StackPanel> + <StackPanel Margin="0,0,24,0"> + <TextBlock Text="Memory Model" Foreground="{DynamicResource ForegroundDimBrush}" FontSize="11"/> + <TextBlock x:Name="SqlMemoryModelText" Text="--" FontSize="18" FontWeight="SemiBold"/> + </StackPanel> + </WrapPanel> + </Border> + + <!-- Memory Trend Chart --> + <scottplot:WpfPlot Grid.Row="1" x:Name="MemoryChart"/> + </Grid> + </TabItem> + + <!-- Memory Clerks Sub-Tab --> + <TabItem Header="Memory Clerks"> + <Grid Margin="8"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="220"/> + <ColumnDefinition Width="*"/> + </Grid.ColumnDefinitions> + + <!-- Clerk Type Picker --> + <Border Grid.Column="0" Background="{DynamicResource BackgroundDarkBrush}" CornerRadius="4" Padding="8" Margin="0,0,4,0"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + <TextBlock Grid.Row="0" Text="Select Memory Clerks" FontWeight="SemiBold" Margin="0,0,0,4"/> + <StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,0,0,4"> + <Button Content="Top Clerks" Click="MemoryClerkSelectTop_Click" Padding="6,2" Margin="0,0,4,0" FontSize="11"/> + <Button Content="Clear All" Click="MemoryClerkClearAll_Click" Padding="6,2" FontSize="11"/> + <TextBlock x:Name="MemoryClerkCountText" Text="0 selected" FontSize="10" Foreground="{DynamicResource ForegroundMutedBrush}" VerticalAlignment="Center" Margin="8,0,0,0"/> + </StackPanel> + <TextBox Grid.Row="2" x:Name="MemoryClerkSearchBox" TextChanged="MemoryClerkSearch_TextChanged" + Margin="0,0,0,4" Padding="4,2"/> + <ListBox Grid.Row="3" x:Name="MemoryClerksList" Background="Transparent" BorderThickness="0"> + <ListBox.ItemTemplate> + <DataTemplate> + <CheckBox IsChecked="{Binding IsSelected, Mode=TwoWay}" + Checked="MemoryClerk_CheckChanged" Unchecked="MemoryClerk_CheckChanged" + Foreground="{DynamicResource ForegroundBrush}" FontSize="11"> + <TextBlock Text="{Binding DisplayName}"/> + </CheckBox> + </DataTemplate> + </ListBox.ItemTemplate> + </ListBox> + </Grid> + </Border> + + <!-- Chart + Summary --> + <Grid Grid.Column="1"> + <Grid.RowDefinitions> + <RowDefinition Height="*"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + <scottplot:WpfPlot Grid.Row="0" x:Name="MemoryClerksChart"/> + <Border Grid.Row="1" Background="{DynamicResource BackgroundDarkBrush}" CornerRadius="4" Padding="12,8" Margin="0,8,0,0"> + <StackPanel Orientation="Horizontal" HorizontalAlignment="Center"> + <TextBlock Text="Non-BP Total:" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" Margin="0,0,5,0"/> + <TextBlock x:Name="MemoryClerksTotalText" Text="--" VerticalAlignment="Center" Margin="0,0,20,0"/> + <TextBlock Text="Top Non-BP Clerk:" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" Margin="0,0,5,0"/> + <TextBlock x:Name="MemoryClerksTopText" Text="--" VerticalAlignment="Center"/> + </StackPanel> + </Border> + </Grid> + </Grid> + </TabItem> + + <!-- Memory Grants Sub-Tab --> + <TabItem Header="Memory Grants"> + <Grid Margin="8"> + <Grid.RowDefinitions> + <RowDefinition Height="*"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + <scottplot:WpfPlot Grid.Row="0" x:Name="MemoryGrantSizingChart" Margin="0,0,0,4"/> + <scottplot:WpfPlot Grid.Row="1" x:Name="MemoryGrantActivityChart" Margin="0,4,0,0"/> + </Grid> + </TabItem> + + <!-- Plan Cache Sub-Tab (plan_cache_stats parity, matching the Dashboard's Memory > Plan + Cache): single-use vs multi-use size trend over the total-plans / oldest-plan + summary strip. --> + <TabItem Header="Plan Cache"> + <Grid Margin="8"> + <Grid.RowDefinitions> + <RowDefinition Height="*"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + <scottplot:WpfPlot Grid.Row="0" x:Name="PlanCacheChart"/> + <Border Grid.Row="1" Background="{DynamicResource BackgroundDarkBrush}" CornerRadius="4" Padding="12,8" Margin="0,8,0,0"> + <StackPanel Orientation="Horizontal" HorizontalAlignment="Center"> + <TextBlock Text="Total Plans:" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" Margin="0,0,5,0"/> + <TextBlock x:Name="PlanCacheTotalPlansText" Text="--" VerticalAlignment="Center" Margin="0,0,20,0"/> + <TextBlock Text="Oldest Plan Age:" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" Margin="0,0,5,0"/> + <TextBlock x:Name="PlanCacheOldestPlanText" Text="--" VerticalAlignment="Center" Margin="0,0,20,0"/> + <!-- Derived single-use bloat badge + forced-parameterization hint (install/47 + report.plan_cache_bloat parity): a client-side CASE on single-use/total plans. --> + <TextBlock Text="Bloat Level:" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" Margin="0,0,5,0"/> + <TextBlock x:Name="PlanCacheBloatLevelText" Text="--" FontWeight="Bold" VerticalAlignment="Center" Margin="0,0,20,0"/> + <TextBlock x:Name="PlanCacheRecommendationText" Text="" FontStyle="Italic" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" TextWrapping="Wrap"/> + </StackPanel> + </Border> + </Grid> + </TabItem> + + <!-- Memory Pressure Events Sub-Tab --> + <TabItem Header="Memory Pressure Events"> + <Grid Margin="8"> + <scottplot:WpfPlot x:Name="MemoryPressureEventsChart"/> + </Grid> + </TabItem> + + </TabControl> + </TabItem> + + <!-- File I/O Tab — copied from Lite's ServerTab.xaml File I/O tab: a nested sub-TabControl + with "File I/O Latency" (stacked read + write latency charts, queued-I/O dashed overlay) + and "File I/O Throughput" (stacked read + write MB/s charts). Placed BEFORE tempdb, + matching Lite's own tab order. --> + <TabItem Header="File I/O"> + <Grid Margin="8"> + <TabControl x:Name="FileIoSubTabs" + Background="Transparent" + BorderThickness="0" + ItemContainerStyle="{DynamicResource SubTabItemStyle}" + SelectionChanged="FileIoSubTabs_SelectionChanged"> + <TabItem Header="File I/O Latency"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="*"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + <Border Grid.Row="0" Margin="0,0,0,4"> + <scottplot:WpfPlot x:Name="FileIoReadChart"/> + </Border> + <Border Grid.Row="1" Margin="0,4,0,0"> + <scottplot:WpfPlot x:Name="FileIoWriteChart"/> + </Border> + </Grid> + </TabItem> + <TabItem Header="File I/O Throughput"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="*"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + <Border Grid.Row="0" Margin="0,0,0,4"> + <scottplot:WpfPlot x:Name="FileIoReadThroughputChart"/> + </Border> + <Border Grid.Row="1" Margin="0,4,0,0"> + <scottplot:WpfPlot x:Name="FileIoWriteThroughputChart"/> + </Border> + </Grid> + </TabItem> + </TabControl> + </Grid> + </TabItem> + <!-- tempdb Tab — copied from Lite's ServerTab.xaml tempdb tab: three stacked charts — + space usage (user/internal/version-store), total allocated size, and per-file I/O + latency. --> + <TabItem Header="tempdb"> + <Grid Margin="8"> + <Grid.RowDefinitions> + <RowDefinition Height="*"/> + <RowDefinition Height="*"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + + <Border Grid.Row="0" Margin="0,0,0,4"> + <scottplot:WpfPlot x:Name="TempDbChart"/> + </Border> + + <Border Grid.Row="1" Margin="0,4,0,4"> + <scottplot:WpfPlot x:Name="TempDbSizeChart"/> + </Border> + + <Border Grid.Row="2" Margin="0,4,0,0"> + <scottplot:WpfPlot x:Name="TempDbFileIoChart"/> + </Border> + </Grid> + </TabItem> + + <!-- Blocking Tab — Lite's full sub-tab structure (ServerTab.xaml:1435-1672): "Trends" (lock-wait + rate, blocking incidents, deadlocks), "Current Waits" (waiting-task duration by wait type, + blocked sessions by database), "Blocked Process Reports" (W1e widened ~25-column filterable + grid + UTC slicer + block-chain viewer), and "Deadlocks" (W1e per-process grid + UTC slicer + + deadlock-graph viewer). Reads rewired to Postgres; the blocked / blocking / victim "View Plan" + context items ARE wired now (#1368 / V7 store the plans BEST-EFFORT alongside the report / + deadlock graph, gated per row on presence), opening the stored plan in the Plan Viewer host — + NO live SQL. Only Lite's "Get Actual Plan" (a live re-execution) stays omitted. --> + <TabItem Header="Blocking"> + <Grid Margin="8"> + <TabControl x:Name="BlockingSubTabs" + Background="Transparent" + BorderThickness="0" + ItemContainerStyle="{DynamicResource SubTabItemStyle}" + SelectionChanged="BlockingSubTabs_SelectionChanged"> + <TabItem Header="Trends"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="*"/> + <RowDefinition Height="*"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + <Border Grid.Row="0" Margin="0,0,0,4"> + <scottplot:WpfPlot x:Name="LockWaitTrendChart"/> + </Border> + <Border Grid.Row="1" Margin="0,4,0,4"> + <scottplot:WpfPlot x:Name="BlockingTrendChart"/> + </Border> + <Border Grid.Row="2" Margin="0,4,0,0"> + <scottplot:WpfPlot x:Name="DeadlockTrendChart"/> + </Border> + </Grid> + </TabItem> + <!-- Blocking Stats — the blocking- AND deadlock-SEVERITY companion to the count-only + "Trends" tab (the "are blocks/deadlocks getting WORSE, not just more frequent" signal). + Everything is computed ON-THE-FLY from rows the store already keeps, so there is no + collector or migration — the Darling equivalent of the Dashboard's pre-aggregated + collect.blocking_deadlock_stats. Blocking severity comes from the same + blocked_process_reports (XE) rows the count trend uses, with the identical + v_dmv_blocking_snapshots fallback (WHERE NOT EXISTS) so the two reconcile. Deadlock + severity is parsed from deadlock_graph_xml over the SAME v_deadlocks window as the + deadlock count (so those reconcile too), via the shared DeadlockGraphParser + (IsVictim / WaitTimeMs). Four charts, each splitting Total from per-incident Max/Avg so + the aggregate magnitude doesn't swamp the per-incident axis: block Max/Avg + block Total, + then deadlock Max/Avg + deadlock Total. A window summary strip carries events / total / + max / avg block duration, the deadlock COUNT, and the deadlock victim count + total + deadlock wait (the Dashboard's victim_count / total_deadlock_wait_time_ms). --> + <TabItem Header="Blocking Stats"> + <!-- 2x2 grid (matching the Performance Trends layout): the charts are natural pairs — + row 0 = blocking Max/Avg | blocking Total, row 1 = deadlock Max/Avg | deadlock Total — + so a 2x2 reads better AND keeps each plot area tall enough that the X-axis time labels + don't collide on a short (768px) laptop. The summary strip spans both columns below. --> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="*"/> + <RowDefinition Height="*"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*"/> + <ColumnDefinition Width="*"/> + </Grid.ColumnDefinitions> + <Border Grid.Row="0" Grid.Column="0" Margin="0,0,4,4"> + <scottplot:WpfPlot x:Name="BlockingDurationChart"/> + </Border> + <Border Grid.Row="0" Grid.Column="1" Margin="4,0,0,4"> + <scottplot:WpfPlot x:Name="BlockingTotalDurationChart"/> + </Border> + <Border Grid.Row="1" Grid.Column="0" Margin="0,4,4,0"> + <scottplot:WpfPlot x:Name="DeadlockWaitChart"/> + </Border> + <Border Grid.Row="1" Grid.Column="1" Margin="4,4,0,0"> + <scottplot:WpfPlot x:Name="DeadlockTotalWaitChart"/> + </Border> + <Border Grid.Row="2" Grid.ColumnSpan="2" Background="{DynamicResource BackgroundDarkBrush}" CornerRadius="4" Padding="12,8" Margin="0,4,0,0"> + <StackPanel Orientation="Horizontal" HorizontalAlignment="Center"> + <TextBlock Text="Blocking Events:" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" Margin="0,0,5,0"/> + <TextBlock x:Name="BlockingStatsEventCountText" Text="--" VerticalAlignment="Center" Margin="0,0,20,0"/> + <TextBlock Text="Total Duration:" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" Margin="0,0,5,0"/> + <TextBlock x:Name="BlockingStatsTotalDurationText" Text="--" VerticalAlignment="Center" Margin="0,0,20,0"/> + <TextBlock Text="Max Duration:" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" Margin="0,0,5,0"/> + <TextBlock x:Name="BlockingStatsMaxDurationText" Text="--" VerticalAlignment="Center" Margin="0,0,20,0"/> + <TextBlock Text="Avg Duration:" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" Margin="0,0,5,0"/> + <TextBlock x:Name="BlockingStatsAvgDurationText" Text="--" VerticalAlignment="Center" Margin="0,0,20,0"/> + <TextBlock Text="Deadlocks:" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" Margin="0,0,5,0"/> + <TextBlock x:Name="BlockingStatsDeadlockCountText" Text="--" VerticalAlignment="Center" Margin="0,0,20,0"/> + <TextBlock Text="Deadlock Victims:" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" Margin="0,0,5,0"/> + <TextBlock x:Name="BlockingStatsDeadlockVictimsText" Text="--" VerticalAlignment="Center" Margin="0,0,20,0"/> + <TextBlock Text="Deadlock Wait:" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" Margin="0,0,5,0"/> + <TextBlock x:Name="BlockingStatsDeadlockWaitText" Text="--" VerticalAlignment="Center"/> + </StackPanel> + </Border> + </Grid> + </TabItem> + <TabItem Header="Current Waits"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="*"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + <Border Grid.Row="0" Margin="0,0,0,4"> + <scottplot:WpfPlot x:Name="CurrentWaitsDurationChart"/> + </Border> + <Border Grid.Row="1" Margin="0,4,0,0"> + <scottplot:WpfPlot x:Name="CurrentWaitsBlockedChart"/> + </Border> + </Grid> + </TabItem> + <!-- Blocked Process Reports — widened to Lite's full grid (W1e): UTC slicer + ~25 filterable + columns (Source badge, per-column filter headers, per-row XML Save gated on HasReportXml, + BlockingRowStyle long-block tinting, right-click / double-click View Block Chain). --> + <TabItem Header="Blocked Process Reports"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + <local:TimeRangeSlicerControl x:Name="BlockingSlicer" Grid.Row="0"/> + <DataGrid x:Name="BlockedProcessReportGrid" Grid.Row="1" + Style="{StaticResource DarkGrid}" + RowStyle="{StaticResource BlockingRowStyle}" + HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" + MouseDoubleClick="BlockedProcessReportGrid_MouseDoubleClick" + Sorting="BlockedProcessReportGrid_Sorting"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding EventTimeLocal}" Width="130"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EventTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Event Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Source}" Width="130"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Source" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Source" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DatabaseName}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DatabaseName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Database" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BlockedSpid}" ElementStyle="{StaticResource NumericCell}" Width="70"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BlockedSpid" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Blocked SPID" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BlockingSpid}" ElementStyle="{StaticResource NumericCell}" Width="70"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BlockingSpid" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Blocking SPID" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding WaitTimeFormatted}" Width="80" SortMemberPath="WaitTimeMs"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="WaitTimeFormatted" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Wait Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding WaitResource}" Width="200"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="WaitResource" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Wait Resource" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LockMode}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LockMode" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Lock Mode" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BlockedStatus}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BlockedStatus" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Blocked Status" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BlockingStatus}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BlockingStatus" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Blocking Status" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BlockedIsolationLevel}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BlockedIsolationLevel" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Blocked Isolation" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BlockingIsolationLevel}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BlockingIsolationLevel" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Blocking Isolation" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BlockedTransactionName}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BlockedTransactionName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Blocked Tran" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BlockedPriority}" ElementStyle="{StaticResource NumericCell}" Width="55"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BlockedPriority" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Blocked Priority" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BlockedTransactionCount}" ElementStyle="{StaticResource NumericCell}" Width="55"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BlockedTransactionCount" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Blocked Tran Count" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BlockedLogUsed}" ElementStyle="{StaticResource NumericCell}" Width="70"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BlockedLogUsed" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Blocked Log Used" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BlockedLoginName}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BlockedLoginName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Blocked Login" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BlockedHostName}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BlockedHostName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Blocked Host" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BlockedClientApp}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BlockedClientApp" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Blocked App" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTemplateColumn Width="300"> + <DataGridTemplateColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BlockedSqlText" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Blocked SQL" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTemplateColumn.Header> + <DataGridTemplateColumn.CellTemplate> + <DataTemplate> + <TextBlock Text="{Binding BlockedSqlText}" TextWrapping="Wrap" MaxHeight="90" Margin="5,2" + ToolTip="{Binding BlockedSqlText}"/> + </DataTemplate> + </DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + <DataGridTextColumn Binding="{Binding BlockingLoginName}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BlockingLoginName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Blocking Login" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BlockingHostName}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BlockingHostName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Blocking Host" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BlockingClientApp}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BlockingClientApp" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Blocking App" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTemplateColumn Width="300"> + <DataGridTemplateColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BlockingSqlText" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Blocking SQL" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTemplateColumn.Header> + <DataGridTemplateColumn.CellTemplate> + <DataTemplate> + <TextBlock Text="{Binding BlockingSqlText}" TextWrapping="Wrap" MaxHeight="90" Margin="5,2" + ToolTip="{Binding BlockingSqlText}"/> + </DataTemplate> + </DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + <DataGridTemplateColumn Header="XML" Width="60"> + <DataGridTemplateColumn.CellTemplate> + <DataTemplate> + <Button Content="Save" Padding="6,2" Click="DownloadBlockedProcessXml_Click" + IsEnabled="{Binding HasReportXml}"/> + </DataTemplate> + </DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + </DataGrid.Columns> + </DataGrid> + </Grid> + </TabItem> + <!-- Deadlocks — Lite's deadlock sub-tab (W1e): UTC slicer + ~20 filterable columns + (parsed per-process from each deadlock graph), per-row XML Save gated on + HasDeadlockXml, right-click / double-click View Deadlock Graph. --> + <TabItem Header="Deadlocks"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + <local:TimeRangeSlicerControl x:Name="DeadlockSlicer" Grid.Row="0"/> + <DataGrid x:Name="DeadlockGrid" Grid.Row="1" + Style="{StaticResource DarkGrid}" + RowStyle="{StaticResource DeadlockRowStyle}" + HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" + MouseDoubleClick="DeadlockGrid_MouseDoubleClick"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding DeadlockTimeLocal}" Width="130"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DeadlockTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DeadlockType}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DeadlockType" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Type" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding VictimDisplay}" Width="55"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="VictimDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Victim" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Spid}" ElementStyle="{StaticResource NumericCell}" Width="55"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Spid" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="SPID" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DatabaseName}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DatabaseName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Database" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ObjectNames}" Width="150"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ObjectNames" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Object(s)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ProcName}" Width="150"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ProcName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Procedure" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LockMode}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LockMode" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Lock Mode" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding OwnerMode}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="OwnerMode" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Owner Mode" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding WaiterMode}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="WaiterMode" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Waiter Mode" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding WaitResource}" Width="200"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="WaitResource" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Wait Resource" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding WaitTimeFormatted}" Width="80" SortMemberPath="WaitTime"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="WaitTimeFormatted" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Wait (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding IsolationLevel}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IsolationLevel" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Isolation" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TransactionName}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TransactionName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Tran Name" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TransactionCount}" ElementStyle="{StaticResource NumericCell}" Width="55"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TransactionCount" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Tran Count" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Priority}" ElementStyle="{StaticResource NumericCell}" Width="55"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Priority" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Priority" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LoginName}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LoginName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Login" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding HostName}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="HostName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Host" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ClientApp}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ClientApp" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="App" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTemplateColumn Width="300"> + <DataGridTemplateColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SqlText" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="SQL Text" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTemplateColumn.Header> + <DataGridTemplateColumn.CellTemplate> + <DataTemplate> + <TextBlock Text="{Binding SqlText}" TextWrapping="Wrap" MaxHeight="90" Margin="5,2" + ToolTip="{Binding SqlText}"/> + </DataTemplate> + </DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + <DataGridTemplateColumn Header="XML" Width="60"> + <DataGridTemplateColumn.CellTemplate> + <DataTemplate> + <Button Content="Save" Padding="6,2" Click="DownloadDeadlockXml_Click" + IsEnabled="{Binding HasDeadlockXml}"/> + </DataTemplate> + </DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + </DataGrid.Columns> + </DataGrid> + </Grid> + </TabItem> + </TabControl> + </Grid> + </TabItem> + + <!-- Perfmon Tab — copied from Lite's ServerTab.xaml Perfmon tab: a 220px counter picker (pack + ComboBox sourced from the SHARED PerformanceMonitor.Ui PerfmonPacks + Select All / Clear All + + search + checkbox ListBox) and a full-bleed chart plotting each selected counter's delta + value (max 12 series). Reads rewired to Postgres; windowed on the per-server toolbar's + settable range. Placed BEFORE Running Jobs, matching Lite's own tab order. --> + <TabItem Header="Perfmon"> + <Grid Margin="8"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="220"/> + <ColumnDefinition Width="*"/> + </Grid.ColumnDefinitions> + + <Border Grid.Column="0" Background="{DynamicResource BackgroundDarkBrush}" CornerRadius="4" Padding="8" Margin="0,0,4,0"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + <TextBlock Grid.Row="0" Text="Select Counters" FontWeight="SemiBold" Margin="0,0,0,4"/> + <ComboBox Grid.Row="1" x:Name="PerfmonPackCombo" + SelectionChanged="PerfmonPack_SelectionChanged" + Margin="0,0,0,4" FontSize="11"/> + <StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,0,0,4"> + <Button Content="Select All" Click="PerfmonSelectAll_Click" Padding="6,2" Margin="0,0,4,0" FontSize="11"/> + <Button Content="Clear All" Click="PerfmonClearAll_Click" Padding="6,2" FontSize="11"/> + </StackPanel> + <TextBox Grid.Row="3" x:Name="PerfmonSearchBox" TextChanged="PerfmonSearch_TextChanged" + Margin="0,0,0,4" Padding="4,2"/> + <ListBox Grid.Row="4" x:Name="PerfmonCountersList" Background="Transparent" BorderThickness="0"> + <ListBox.ItemTemplate> + <DataTemplate> + <CheckBox IsChecked="{Binding IsSelected, Mode=TwoWay}" + Checked="PerfmonCounter_CheckChanged" Unchecked="PerfmonCounter_CheckChanged" + Foreground="{DynamicResource ForegroundBrush}" FontSize="11"> + <TextBlock Text="{Binding DisplayName}"/> + </CheckBox> + </DataTemplate> + </ListBox.ItemTemplate> + </ListBox> + </Grid> + </Border> + + <scottplot:WpfPlot Grid.Column="1" x:Name="PerfmonChart"/> + </Grid> + </TabItem> + + <!-- Session Stats Tab — the Dashboard-parity port of ResourceMetricsContent's Session Stats sub-tab. + A single server-wide session-count trend chart (Total / Running / Sleeping / Background / Dormant / + Idle >30m / Waiting for Memory) over the settable window, with a summary strip below for the + non-chartable attribution (top application / top host by connections + distinct databases with + connections). Feed is v_session_summary_stats (the server-wide SUMMARY collector = 1:1 with the + Dashboard's session_stats table), NOT the per-application v_session_stats FinOps uses. Placed + after Perfmon, mirroring the Dashboard's Perfmon -> Session Stats adjacency. --> + <TabItem Header="Session Stats"> + <Grid Margin="8"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + <TextBlock Grid.Row="0" Text="Session Counts Over Time" Style="{StaticResource SectionHeader}"/> + <scottplot:WpfPlot Grid.Row="1" x:Name="SessionStatsChart"/> + <Border Grid.Row="2" Background="{DynamicResource BackgroundDarkBrush}" CornerRadius="4" Padding="12,8" Margin="0,4,0,0"> + <StackPanel Orientation="Horizontal" HorizontalAlignment="Center"> + <TextBlock Text="Top Application:" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" Margin="0,0,5,0"/> + <TextBlock x:Name="SessionStatsTopAppText" Text="N/A" Foreground="{DynamicResource ForegroundBrush}" VerticalAlignment="Center" Margin="0,0,20,0"/> + <TextBlock Text="Top Host:" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" Margin="0,0,5,0"/> + <TextBlock x:Name="SessionStatsTopHostText" Text="N/A" Foreground="{DynamicResource ForegroundBrush}" VerticalAlignment="Center" Margin="0,0,20,0"/> + <TextBlock Text="Databases:" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" Margin="0,0,5,0"/> + <TextBlock x:Name="SessionStatsDatabasesText" Text="N/A" Foreground="{DynamicResource ForegroundBrush}" VerticalAlignment="Center"/> + </StackPanel> + </Border> + </Grid> + </TabItem> + + <!-- Running Jobs Tab — copied from Lite's ServerTab.xaml Running Jobs tab: the latest-snapshot + grid of currently-running SQL Agent jobs (8 columns, IsRunningLong row highlight) with the + W1g column-filter headers. Reads rewired to Postgres; the msdb-access banner is derived + from the store (the running_jobs collector's latest PERMISSIONS/ERROR collection_log status) + since the viewer has no live msdb probe. --> + <TabItem Header="Running Jobs"> + <Grid Margin="8"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + <TextBlock Grid.Row="0" Text="Currently Running SQL Agent Jobs" Style="{StaticResource SectionHeader}"/> + <TextBlock Grid.Row="1" x:Name="RunningJobsMsdbWarning" + Text="Running Jobs requires msdb access. Grant the login access to msdb to enable this feature." + Foreground="{DynamicResource WarningTextBrush}" FontSize="12" Margin="0,0,0,8" Visibility="Collapsed" + TextWrapping="Wrap"/> + <DataGrid Grid.Row="2" x:Name="RunningJobsGrid" Style="{StaticResource DarkGrid}"> + <DataGrid.RowStyle> + <Style TargetType="DataGridRow" BasedOn="{StaticResource DarkGridRow}"> + <Style.Triggers> + <DataTrigger Binding="{Binding IsRunningLong}" Value="True"> + <Setter Property="Background" Value="#33FF6B6B"/> + </DataTrigger> + </Style.Triggers> + </Style> + </DataGrid.RowStyle> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding JobName}" Width="250"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="JobName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Job Name" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding StartTimeLocal}" Width="150"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="StartTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Start Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CurrentDurationFormatted}" Width="120" SortMemberPath="CurrentDurationSeconds"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CurrentDurationFormatted" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Current Duration" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgDurationFormatted}" Width="110" SortMemberPath="AvgDurationSeconds"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgDurationFormatted" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Duration" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding P95DurationFormatted}" Width="110" SortMemberPath="P95DurationSeconds"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="P95DurationFormatted" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="P95 Duration" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PercentOfAverageFormatted}" Width="100" SortMemberPath="PercentOfAverage"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PercentOfAverageFormatted" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="% of Average" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding RunningLongDisplay}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="RunningLongDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Running Long" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding SuccessfulRunCount}" ElementStyle="{StaticResource NumericCell}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SuccessfulRunCount" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Successful Runs (30d)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + </Grid> + </TabItem> + + <!-- Configuration Tab — copied from Lite's ServerTab.xaml Configuration tab: a nested tab strip + of four read-only, column-filterable snapshot grids (Server / Database / Scoped configuration + + Trace Flags), each showing the latest capture (capture_time = MAX). Reads rewired to + Postgres; grids keep MainWindow's DarkGrid chrome + Lite's ColumnFilterButtonStyle headers. --> + <TabItem Header="Configuration"> + <Grid Margin="8"> + <TabControl Background="Transparent" BorderThickness="0" ItemContainerStyle="{DynamicResource SubTabItemStyle}"> + <TabItem Header="Server Configuration"> + <DataGrid x:Name="ServerConfigGrid" Style="{StaticResource DarkGrid}"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding ConfigurationName}" Width="300"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ConfigurationName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Setting" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ValueConfigured}" Width="130"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ValueConfigured" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Configured Value" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ValueInUse}" Width="130"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ValueInUse" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Value In Use" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DynamicDisplay}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DynamicDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Dynamic" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AdvancedDisplay}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AdvancedDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Advanced" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + </TabItem> + <TabItem Header="Database Configuration"> + <DataGrid x:Name="DatabaseConfigGrid" Style="{StaticResource DarkGrid}"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding DatabaseName}" Width="200"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DatabaseName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Database" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding StateDesc}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="StateDesc" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="State" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CompatibilityLevel}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CompatibilityLevel" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Compat Level" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CollationName}" Width="180"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CollationName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Collation" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding RecoveryModel}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="RecoveryModel" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Recovery Model" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ReadOnlyDisplay}" Width="85"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ReadOnlyDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Read Only" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding RcsiDisplay}" Width="65"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="RcsiDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="RCSI" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding SnapshotIsolationState}" Width="130"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SnapshotIsolationState" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Snapshot Isolation" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AutoCreateStatsDisplay}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AutoCreateStatsDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Auto Create Stats" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AutoUpdateStatsDisplay}" Width="115"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AutoUpdateStatsDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Auto Update Stats" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AutoUpdateStatsAsyncDisplay}" Width="135"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AutoUpdateStatsAsyncDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Async Stats Update" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ParameterizationForcedDisplay}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ParameterizationForcedDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Forced Parameterization" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding QueryStoreDisplay}" Width="95"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="QueryStoreDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Query Store" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding EncryptedDisplay}" Width="85"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EncryptedDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Encrypted" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TrustworthyDisplay}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TrustworthyDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Trustworthy" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DbChainingDisplay}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DbChainingDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="DB Chaining" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BrokerEnabledDisplay}" Width="95"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BrokerEnabledDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Broker" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CdcEnabledDisplay}" Width="70"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CdcEnabledDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="CDC" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MixedPageAllocationDisplay}" Width="115"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MixedPageAllocationDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Mixed Pages" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LogReuseWaitDesc}" Width="130"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LogReuseWaitDesc" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Log Reuse Wait" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AutoCloseDisplay}" Width="85"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AutoCloseDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Auto Close" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AutoShrinkDisplay}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AutoShrinkDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Auto Shrink" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PageVerifyOption}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PageVerifyOption" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Page Verify" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TargetRecoveryTimeSeconds}" ElementStyle="{StaticResource NumericCell}" Width="135"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TargetRecoveryTimeSeconds" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Target Recovery (s)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DelayedDurability}" Width="130"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DelayedDurability" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Delayed Durability" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AdrDisplay}" Width="65"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AdrDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="ADR" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MemoryOptimizedDisplay}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MemoryOptimizedDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Memory Optimized" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding OptimizedLockingDisplay}" Width="125"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="OptimizedLockingDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Optimized Locking" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + </TabItem> + <TabItem Header="Scoped Configuration"> + <DataGrid x:Name="DatabaseScopedConfigGrid" Style="{StaticResource DarkGrid}"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding DatabaseName}" Width="200"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DatabaseName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Database" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ConfigurationName}" Width="300"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ConfigurationName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Setting" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Value}" Width="200"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Value" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Value" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ValueForSecondary}" Width="200"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ValueForSecondary" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Value for Secondary" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + </TabItem> + <TabItem Header="Trace Flags"> + <DataGrid x:Name="TraceFlagsGrid" Style="{StaticResource DarkGrid}"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding TraceFlag}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TraceFlag" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Trace Flag" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding StatusDisplay}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="StatusDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Status" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding GlobalDisplay}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="GlobalDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Global" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding SessionDisplay}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SessionDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Session" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + </TabItem> + </TabControl> + </Grid> + </TabItem> + + <!-- Configuration Changes Tab — the Dashboard's dedicated ConfigChangesContent ported as its own tab + (sits immediately after the latest-snapshot Configuration tab). Three grids showing server-config / + database-config / trace-flag DRIFT over the selected window, computed in C# by diffing the store's + APPEND-ONLY config snapshots (ViewerDataService.ConfigChanges.cs), mirroring the service's + DarlingConfigHistoryReader (#1421). Dashboard enrichment Darling does not collect is OMITTED (not + fabricated): server-config Setting Description, database-config Setting Type. requires_restart is + KEPT (it is derivable: is_dynamic = 0 AND configured != in-use). Grids use the shared DarkGrid chrome + (its DarkGridRow carries the #1419 copy/export context menu) + the column-filter headers; change + times render through ViewerTimeHelper. --> + <TabItem Header="Configuration Changes"> + <Grid Margin="8"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + <TextBlock Grid.Row="0" Margin="2,0,2,8" TextWrapping="Wrap" Foreground="{DynamicResource ForegroundMutedBrush}" FontSize="12" + Text="Configuration is captured on connect/restart (the config collectors run on connect, not on a timer), so these histories reflect that cadence — a setting appears here only when at least two captures bracket the change within the selected time window."/> + <TabControl Grid.Row="1" Background="Transparent" BorderThickness="0" ItemContainerStyle="{DynamicResource SubTabItemStyle}"> + <TabItem Header="Server Config Changes"> + <Grid> + <DataGrid x:Name="ServerConfigChangesGrid" Style="{StaticResource DarkGrid}" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding ChangeTimeDisplay}" Width="150"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ChangeTimeDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Change Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ConfigurationName}" Width="260"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ConfigurationName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Configuration" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding OldValueConfigured}" ElementStyle="{StaticResource NumericCell}" Width="110"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="OldValueConfigured" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Old Configured" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding NewValueConfigured}" ElementStyle="{StaticResource NumericCell}" Width="110"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="NewValueConfigured" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="New Configured" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding OldValueInUse}" ElementStyle="{StaticResource NumericCell}" Width="100"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="OldValueInUse" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Old In Use" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding NewValueInUse}" ElementStyle="{StaticResource NumericCell}" Width="100"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="NewValueInUse" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="New In Use" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding RequiresRestartDisplay}" Width="80"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="RequiresRestartDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Restart?" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DynamicDisplay}" Width="80"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DynamicDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Dynamic" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AdvancedDisplay}" Width="80"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AdvancedDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Advanced" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ChangeDescription}" Width="*" MinWidth="250"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ChangeDescription" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Change Description" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="ServerConfigChangesNoDataMessage" Style="{StaticResource EmptyHint}" Text="No server configuration changes in the selected time window (needs at least two config captures bracketing a change)."/> + </Grid> + </TabItem> + <TabItem Header="Database Config Changes"> + <Grid> + <DataGrid x:Name="DatabaseConfigChangesGrid" Style="{StaticResource DarkGrid}" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding ChangeTimeDisplay}" Width="150"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ChangeTimeDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Change Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DatabaseName}" Width="180"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DatabaseName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Database" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding SettingName}" Width="230"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SettingName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Setting Name" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding OldValue}" Width="160"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="OldValue" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Old Value" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding NewValue}" Width="160"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="NewValue" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="New Value" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ChangeDescription}" Width="*" MinWidth="250"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ChangeDescription" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Change Description" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="DatabaseConfigChangesNoDataMessage" Style="{StaticResource EmptyHint}" Text="No database configuration changes in the selected time window (needs at least two config captures bracketing a change)."/> + </Grid> + </TabItem> + <TabItem Header="Trace Flag Changes"> + <Grid> + <DataGrid x:Name="TraceFlagChangesGrid" Style="{StaticResource DarkGrid}" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding ChangeTimeDisplay}" Width="150"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ChangeTimeDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Change Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TraceFlag}" ElementStyle="{StaticResource NumericCell}" Width="100"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TraceFlag" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Trace Flag" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PreviousStatusDisplay}" Width="110"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PreviousStatusDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Previous Status" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding NewStatusDisplay}" Width="110"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="NewStatusDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="New Status" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Scope}" Width="90"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Scope" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Scope" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding GlobalDisplay}" Width="80"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="GlobalDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Global" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding SessionDisplay}" Width="80"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SessionDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Session" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ChangeDescription}" Width="*" MinWidth="230"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ChangeDescription" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Change Description" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="TraceFlagChangesNoDataMessage" Style="{StaticResource EmptyHint}" Text="No trace flag changes in the selected time window (needs at least two config captures bracketing an enable/disable)."/> + </Grid> + </TabItem> + </TabControl> + </Grid> + </TabItem> + + <!-- Daily Summary Tab: a month heatmap of composite daily health (the shared PerformanceCalendar, + identical to Lite's), replacing the old single-day date-picker roll-up. Each cell is banded by + the shared DailyHealthBandCalculator over that day's Postgres aggregate (GetDailySummaryRangeAsync); + clicking a day drills into its single-day roll-up in the detail grid below (the viewer's DarkGrid + chrome, keeping the Memory Pressure column). Leads the diagnostics tail (Daily Summary -> System + Events -> Latches & Spinlocks -> Collection Health). --> + <TabItem Header="Daily Summary"> + <Grid Margin="8"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + + <!-- Toolbar --> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,6"> + <Button Content="Refresh" Click="DailySummaryRefresh_Click" Margin="0,0,10,0" Padding="8,4" MinWidth="60"/> + <TextBlock x:Name="DailySummaryIndicator" Text="Composite daily health - click a day for detail and drill-in (times in UTC)" VerticalAlignment="Center" + FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + </StackPanel> + + <!-- Month heatmap + the shared day-detail panel (click a day: band header, why-this-day + reasons, key metrics, and drill buttons that scope to that day and jump to the grid). --> + <ui:PerformanceCalendar Grid.Row="1" x:Name="DailyCalendar" + MonthChanged="DailyCalendar_MonthChanged" + DayDrillRequested="DailyCalendar_DayDrillRequested"/> + </Grid> + </TabItem> + + <!-- System Events Tab (system_health parity, Stage 2b): six parse-on-read sub-tabs, one per unique + system_health warning category. Each reads the raw event_xml over the toolbar's settable window, + shreds it with the Common SystemHealthParser, and shows only the sp_HealthParser-significant + rows (SystemEventSignificance). Event times render machine-local (the XE @timestamp). --> + <TabItem Header="System Events"> + <TabControl x:Name="SystemEventsSubTabControl" Background="Transparent" BorderThickness="0" + ItemContainerStyle="{DynamicResource SubTabItemStyle}" + SelectionChanged="SystemEventsSubTabs_SelectionChanged"> + + <!-- Corruption Events Sub-Tab (sp_server_diagnostics SYSTEM component counter charts). Faithful + reproduction of the Dashboard's Corruption Events 2x2 chart grid; every collected snapshot + plotted over the settable window (no significance filter). --> + <TabItem Header="Corruption Events"> + <Grid> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="8,8" Height="36"> + <TextBlock Text="Corruption Events (bad pages, dump requests, access violations)" VerticalAlignment="Center" Margin="0,0,10,0" FontWeight="SemiBold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="SystemEventsRefresh_Click" Margin="12,0,0,0" Padding="10,4" MinWidth="70"/> + </StackPanel> + <Grid Grid.Row="1" Margin="8,0,8,8"> + <Grid.RowDefinitions><RowDefinition Height="*"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="*"/></Grid.ColumnDefinitions> + + <!-- Borderless tiles matching every other chart tab; each chart identifies itself + via its descriptive Y-axis label (like File I/O's "Read Latency (ms)"). --> + <Border Grid.Row="0" Grid.Column="0" Margin="2"> + <scottplot:WpfPlot x:Name="BadPagesChart"/> + </Border> + <Border Grid.Row="0" Grid.Column="1" Margin="2"> + <scottplot:WpfPlot x:Name="DumpRequestsChart"/> + </Border> + <Border Grid.Row="1" Grid.Column="0" Margin="2"> + <scottplot:WpfPlot x:Name="AccessViolationsChart"/> + </Border> + <Border Grid.Row="1" Grid.Column="1" Margin="2"> + <scottplot:WpfPlot x:Name="WriteAccessViolationsChart"/> + </Border> + </Grid> + </Grid> + </TabItem> + + <!-- Contention Events Sub-Tab (sp_server_diagnostics SYSTEM component counter charts). Faithful + reproduction of the Dashboard's Contention Events 2x2 chart grid. --> + <TabItem Header="Contention Events"> + <Grid> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="8,8" Height="36"> + <TextBlock Text="Contention Events (non-yielding tasks, latches, spinlocks, CPU)" VerticalAlignment="Center" Margin="0,0,10,0" FontWeight="SemiBold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="SystemEventsRefresh_Click" Margin="12,0,0,0" Padding="10,4" MinWidth="70"/> + </StackPanel> + <Grid Grid.Row="1" Margin="8,0,8,8"> + <Grid.RowDefinitions><RowDefinition Height="*"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="*"/></Grid.ColumnDefinitions> + + <!-- Borderless tiles matching every other chart tab; each chart identifies itself + via its descriptive Y-axis label / legend (like File I/O's "Read Latency (ms)"). --> + <Border Grid.Row="0" Grid.Column="0" Margin="2"> + <scottplot:WpfPlot x:Name="NonYieldingTasksChart"/> + </Border> + <Border Grid.Row="0" Grid.Column="1" Margin="2"> + <scottplot:WpfPlot x:Name="LatchWarningsChart"/> + </Border> + <Border Grid.Row="1" Grid.Column="0" Margin="2"> + <scottplot:WpfPlot x:Name="SickSpinlocksChart"/> + </Border> + <Border Grid.Row="1" Grid.Column="1" Margin="2"> + <scottplot:WpfPlot x:Name="CpuComparisonChart"/> + </Border> + </Grid> + </Grid> + </TabItem> + + <!-- Scheduler Issues Sub-Tab --> + <TabItem Header="Scheduler Issues"> + <Grid> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="8,8" Height="36"> + <TextBlock Text="Scheduler Issues" VerticalAlignment="Center" Margin="0,0,10,0" FontWeight="SemiBold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="SystemEventsRefresh_Click" Margin="12,0,0,0" Padding="10,4" MinWidth="70"/> + <TextBlock x:Name="SchedulerIssuesCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + </StackPanel> + <Grid Grid.Row="1" Margin="8,0,8,8"> + <DataGrid x:Name="SchedulerIssuesGrid" Style="{StaticResource DarkGrid}" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" IsReadOnly="True" AutoGenerateColumns="False"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding EventTimeLocal}" Width="145"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EventTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Event Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding SchedulerId}" Width="95" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SchedulerId" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Scheduler ID" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CpuId}" Width="70" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CpuId" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="CPU ID" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Status}" Width="90"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Status" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Status" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding IsOnline}" Width="70"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IsOnline" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Online" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding IsRunnable}" Width="75"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IsRunnable" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Runnable" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding IsRunning}" Width="70"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IsRunning" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Running" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding NonYieldingTimeMs, StringFormat=N0}" Width="135" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="NonYieldingTimeMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Non-Yielding ms" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ThreadQuantumMs, StringFormat=N0}" Width="135" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ThreadQuantumMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Thread Quantum ms" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="SchedulerIssuesNoDataMessage" Style="{StaticResource EmptyHint}" Text="No scheduler warnings (non-yielding / offline schedulers) in the selected time window."/> + </Grid> + </Grid> + </TabItem> + + <!-- Severe Errors Sub-Tab --> + <TabItem Header="Severe Errors"> + <Grid> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="8,8" Height="36"> + <TextBlock Text="Severe Errors (severity ≥ 19)" VerticalAlignment="Center" Margin="0,0,10,0" FontWeight="SemiBold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="SystemEventsRefresh_Click" Margin="12,0,0,0" Padding="10,4" MinWidth="70"/> + <TextBlock x:Name="SevereErrorsCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + </StackPanel> + <Grid Grid.Row="1" Margin="8,0,8,8"> + <DataGrid x:Name="SevereErrorsGrid" Style="{StaticResource DarkGrid}" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" IsReadOnly="True" AutoGenerateColumns="False"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding EventTimeLocal}" Width="145"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EventTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Event Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ErrorNumber}" Width="80" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ErrorNumber" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Error #" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Severity}" Width="75" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Severity" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Severity" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding State}" Width="60" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="State" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="State" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DatabaseName}" Width="150"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DatabaseName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Database" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DatabaseId}" Width="65" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DatabaseId" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="DB ID" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTemplateColumn Width="560"> + <DataGridTemplateColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Message" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Message" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTemplateColumn.Header> + <DataGridTemplateColumn.CellTemplate><DataTemplate><TextBlock Text="{Binding Message}" TextWrapping="Wrap" MaxHeight="90" Margin="5,2" ToolTip="{Binding Message}"/></DataTemplate></DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="SevereErrorsNoDataMessage" Style="{StaticResource EmptyHint}" Text="No severe errors (severity 19+) in the selected time window."/> + </Grid> + </Grid> + </TabItem> + + <!-- Memory Conditions Sub-Tab --> + <TabItem Header="Memory Conditions"> + <Grid> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="8,8" Height="36"> + <TextBlock Text="Memory Conditions (low physical memory)" VerticalAlignment="Center" Margin="0,0,10,0" FontWeight="SemiBold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="SystemEventsRefresh_Click" Margin="12,0,0,0" Padding="10,4" MinWidth="70"/> + <TextBlock x:Name="MemoryConditionsCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + </StackPanel> + <Grid Grid.Row="1" Margin="8,0,8,8"> + <DataGrid x:Name="MemoryConditionsGrid" Style="{StaticResource DarkGrid}" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" IsReadOnly="True" AutoGenerateColumns="False"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding EventTimeLocal}" Width="145"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EventTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Event Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LastNotification}" Width="210"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LastNotification" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Last Notification" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Name}" Width="170"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Name" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Report" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding OutOfMemoryExceptions, StringFormat=N0}" Width="115" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="OutOfMemoryExceptions" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="OOM Exceptions" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding IsAnyPoolOutOfMemory}" Width="80"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IsAnyPoolOutOfMemory" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Pool OOM" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ProcessOutOfMemoryPeriod, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ProcessOutOfMemoryPeriod" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="OOM Period" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvailablePhysicalMemoryGb, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvailablePhysicalMemoryGb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avail Phys GB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvailableVirtualMemoryGb, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvailableVirtualMemoryGb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avail Virt GB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvailablePagingFileGb, StringFormat=N0}" Width="115" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvailablePagingFileGb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avail Paging GB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding WorkingSetGb, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="WorkingSetGb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Working Set GB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PercentOfCommittedMemoryInWs, StringFormat=N0}" Width="130" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PercentOfCommittedMemoryInWs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="% Committed in WS" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PageFaults, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PageFaults" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Page Faults" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding SystemPhysicalMemoryHigh, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SystemPhysicalMemoryHigh" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Sys Phys High" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding SystemPhysicalMemoryLow, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SystemPhysicalMemoryLow" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Sys Phys Low" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ProcessPhysicalMemoryLow, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ProcessPhysicalMemoryLow" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Proc Phys Low" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ProcessVirtualMemoryLow, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ProcessVirtualMemoryLow" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Proc Virt Low" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding VmReservedGb, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="VmReservedGb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="VM Reserved GB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding VmCommittedGb, StringFormat=N0}" Width="115" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="VmCommittedGb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="VM Committed GB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LockedPagesAllocated, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LockedPagesAllocated" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Locked Pages" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LargePagesAllocated, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LargePagesAllocated" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Large Pages" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding EmergencyMemoryGb, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EmergencyMemoryGb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Emergency GB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding EmergencyMemoryInUseGb, StringFormat=N0}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EmergencyMemoryInUseGb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Emerg In Use GB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TargetCommittedGb, StringFormat=N0}" Width="125" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TargetCommittedGb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Target Committed GB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CurrentCommittedGb, StringFormat=N0}" Width="130" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CurrentCommittedGb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Current Committed GB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PagesAllocated, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PagesAllocated" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Pages Alloc" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PagesReserved, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PagesReserved" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Pages Reserved" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PagesFree, StringFormat=N0}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PagesFree" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Pages Free" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PagesInUse, StringFormat=N0}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PagesInUse" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Pages In Use" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PageAllocPotential, StringFormat=N0}" Width="115" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PageAllocPotential" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Alloc Potential" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding NumaGrowthPhase, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="NumaGrowthPhase" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="NUMA Growth" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LastOomFactor, StringFormat=N0}" Width="115" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LastOomFactor" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Last OOM Factor" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LastOsError, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LastOsError" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Last OS Error" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="MemoryConditionsNoDataMessage" Style="{StaticResource EmptyHint}" Text="No low-physical-memory conditions (RESOURCE_MEMPHYSICAL_LOW) in the selected time window."/> + </Grid> + </Grid> + </TabItem> + + <!-- Memory Broker Sub-Tab --> + <TabItem Header="Memory Broker"> + <Grid> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="8,8" Height="36"> + <TextBlock Text="Memory Broker (low physical memory)" VerticalAlignment="Center" Margin="0,0,10,0" FontWeight="SemiBold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="SystemEventsRefresh_Click" Margin="12,0,0,0" Padding="10,4" MinWidth="70"/> + <TextBlock x:Name="MemoryBrokerCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + </StackPanel> + <Grid Grid.Row="1" Margin="8,0,8,8"> + <DataGrid x:Name="MemoryBrokerGrid" Style="{StaticResource DarkGrid}" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" IsReadOnly="True" AutoGenerateColumns="False"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding EventTimeLocal}" Width="145"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EventTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Event Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BrokerId}" Width="80" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BrokerId" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Broker ID" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Broker}" Width="210"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Broker" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Broker" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Notification}" Width="210"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Notification" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Notification" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PoolMetadataId}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PoolMetadataId" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Pool Meta ID" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DeltaTime, StringFormat=N0}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DeltaTime" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Delta Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MemoryRatio, StringFormat=N0}" Width="90" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MemoryRatio" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Mem Ratio" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding NewTarget, StringFormat=N0}" Width="115" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="NewTarget" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="New Target" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Overall, StringFormat=N0}" Width="115" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Overall" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Overall" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Rate, StringFormat=N0}" Width="90" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Rate" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Rate" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CurrentlyPredicated, StringFormat=N0}" Width="115" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CurrentlyPredicated" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Predicated" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CurrentlyAllocated, StringFormat=N0}" Width="115" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CurrentlyAllocated" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Allocated" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PreviouslyAllocated, StringFormat=N0}" Width="125" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PreviouslyAllocated" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Prev Allocated" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="MemoryBrokerNoDataMessage" Style="{StaticResource EmptyHint}" Text="No low-physical-memory broker notifications (RESOURCE_MEMPHYSICAL_LOW) in the selected time window."/> + </Grid> + </Grid> + </TabItem> + + <!-- Memory Node OOM Sub-Tab --> + <TabItem Header="Memory Node OOM"> + <Grid> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="8,8" Height="36"> + <TextBlock Text="Memory Node OOM" VerticalAlignment="Center" Margin="0,0,10,0" FontWeight="SemiBold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="SystemEventsRefresh_Click" Margin="12,0,0,0" Padding="10,4" MinWidth="70"/> + <TextBlock x:Name="MemoryNodeOomCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + </StackPanel> + <Grid Grid.Row="1" Margin="8,0,8,8"> + <DataGrid x:Name="MemoryNodeOomGrid" Style="{StaticResource DarkGrid}" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" IsReadOnly="True" AutoGenerateColumns="False"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding EventTimeLocal}" Width="145"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EventTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Event Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding NodeId}" Width="70" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="NodeId" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Node ID" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MemoryNodeId}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MemoryNodeId" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Mem Node ID" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MemoryUtilizationPct, StringFormat=N0}" Width="90" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MemoryUtilizationPct" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Mem Util %" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalPhysicalMemoryKb, StringFormat=N0}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalPhysicalMemoryKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total Phys KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvailablePhysicalMemoryKb, StringFormat=N0}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvailablePhysicalMemoryKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avail Phys KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalPageFileKb, StringFormat=N0}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalPageFileKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total Page KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvailablePageFileKb, StringFormat=N0}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvailablePageFileKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avail Page KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalVirtualAddressSpaceKb, StringFormat=N0}" Width="130" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalVirtualAddressSpaceKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total VAS KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvailableVirtualAddressSpaceKb, StringFormat=N0}" Width="130" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvailableVirtualAddressSpaceKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avail VAS KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TargetKb, StringFormat=N0}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TargetKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Target KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ReservedKb, StringFormat=N0}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ReservedKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Reserved KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CommittedKb, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CommittedKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Committed KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding SharedCommittedKb, StringFormat=N0}" Width="140" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SharedCommittedKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Shared Committed KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AweKb, StringFormat=N0}" Width="90" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AweKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="AWE KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PagesKb, StringFormat=N0}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PagesKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Pages KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding FailureType}" Width="180"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="FailureType" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Failure" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding FailureValue}" Width="90" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="FailureValue" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Failure Val" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Resources}" Width="90" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Resources" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Resources" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding FactorText}" Width="160"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="FactorText" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Factor" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding FactorValue}" Width="90" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="FactorValue" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Factor Val" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LastError}" Width="90" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LastError" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Last Error" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PoolMetadataId}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PoolMetadataId" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Pool Meta ID" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding IsProcessInJob}" Width="70"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IsProcessInJob" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="In Job" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding IsSystemPhysicalMemoryHigh}" Width="105"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IsSystemPhysicalMemoryHigh" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Sys Phys High" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding IsSystemPhysicalMemoryLow}" Width="105"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IsSystemPhysicalMemoryLow" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Sys Phys Low" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding IsProcessPhysicalMemoryLow}" Width="105"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IsProcessPhysicalMemoryLow" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Proc Phys Low" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding IsProcessVirtualMemoryLow}" Width="105"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IsProcessVirtualMemoryLow" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Proc Virt Low" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="MemoryNodeOomNoDataMessage" Style="{StaticResource EmptyHint}" Text="No memory-node out-of-memory events in the selected time window."/> + </Grid> + </Grid> + </TabItem> + + <!-- Significant Waits Sub-Tab --> + <TabItem Header="Significant Waits"> + <Grid> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="8,8" Height="36"> + <TextBlock Text="Significant Waits (duration ≥ 500 ms)" VerticalAlignment="Center" Margin="0,0,10,0" FontWeight="SemiBold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="SystemEventsRefresh_Click" Margin="12,0,0,0" Padding="10,4" MinWidth="70"/> + <TextBlock x:Name="SignificantWaitsCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + </StackPanel> + <Grid Grid.Row="1" Margin="8,0,8,8"> + <DataGrid x:Name="SignificantWaitsGrid" Style="{StaticResource DarkGrid}" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" IsReadOnly="True" AutoGenerateColumns="False"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding EventTimeLocal}" Width="145"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EventTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Event Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding WaitType}" Width="190"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="WaitType" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Wait Type" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DurationMs, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DurationMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Duration ms" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding SignalDurationMs, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SignalDurationMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Signal ms" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding WaitResource}" Width="220"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="WaitResource" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Wait Resource" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding SessionId}" Width="70" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SessionId" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Session" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTemplateColumn Width="560"> + <DataGridTemplateColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="QueryText" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Query Text" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTemplateColumn.Header> + <DataGridTemplateColumn.CellTemplate><DataTemplate><TextBlock Text="{Binding QueryText}" TextWrapping="Wrap" MaxHeight="90" Margin="5,2" ToolTip="{Binding QueryText}"/></DataTemplate></DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="SignificantWaitsNoDataMessage" Style="{StaticResource EmptyHint}" Text="No significant waits (session queries waiting 500 ms+) in the selected time window."/> + </Grid> + </Grid> + </TabItem> + + <!-- CPU Tasks Sub-Tab (sp_server_diagnostics QUERY_PROCESSING component) --> + <TabItem Header="CPU Tasks"> + <Grid> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="8,8" Height="36"> + <TextBlock Text="CPU Tasks (WARNING with 10+ pending tasks)" VerticalAlignment="Center" Margin="0,0,10,0" FontWeight="SemiBold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="SystemEventsRefresh_Click" Margin="12,0,0,0" Padding="10,4" MinWidth="70"/> + <TextBlock x:Name="CpuTasksCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + </StackPanel> + <Grid Grid.Row="1" Margin="8,0,8,8"> + <DataGrid x:Name="CpuTasksGrid" Style="{StaticResource DarkGrid}" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" IsReadOnly="True" AutoGenerateColumns="False"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding EventTimeLocal}" Width="145"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EventTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Event Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding State}" Width="90"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="State" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="State" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxWorkers, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxWorkers" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Workers" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding WorkersCreated, StringFormat=N0}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="WorkersCreated" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Workers Created" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding WorkersIdle, StringFormat=N0}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="WorkersIdle" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Workers Idle" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TasksCompletedWithinInterval, StringFormat=N0}" Width="130" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TasksCompletedWithinInterval" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Tasks Completed" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PendingTasks, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PendingTasks" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Pending Tasks" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding OldestPendingTaskWaitingTime, StringFormat=N0}" Width="150" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="OldestPendingTaskWaitingTime" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Oldest Pending ms" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding HasUnresolvableDeadlockOccurred}" Width="120"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="HasUnresolvableDeadlockOccurred" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Unresolvable DL" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding HasDeadlockedSchedulersOccurred}" Width="125"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="HasDeadlockedSchedulersOccurred" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Sched Deadlock" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DidBlockingOccur}" Width="90"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DidBlockingOccur" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Blocking" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="CpuTasksNoDataMessage" Style="{StaticResource EmptyHint}" Text="No CPU-task warnings (WARNING state with 10+ pending tasks) in the selected time window."/> + </Grid> + </Grid> + </TabItem> + + <!-- I/O Issues Sub-Tab (sp_server_diagnostics IO_SUBSYSTEM component) --> + <TabItem Header="I/O Issues"> + <Grid> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="8,8" Height="36"> + <TextBlock Text="I/O Issues (WARNING with pending I/O)" VerticalAlignment="Center" Margin="0,0,10,0" FontWeight="SemiBold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="SystemEventsRefresh_Click" Margin="12,0,0,0" Padding="10,4" MinWidth="70"/> + <TextBlock x:Name="IoIssuesCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + </StackPanel> + <Grid Grid.Row="1" Margin="8,0,8,8"> + <DataGrid x:Name="IoIssuesGrid" Style="{StaticResource DarkGrid}" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" IsReadOnly="True" AutoGenerateColumns="False"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding EventTimeLocal}" Width="145"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EventTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Event Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding State}" Width="90"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="State" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="State" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding IoLatchTimeouts, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IoLatchTimeouts" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Latch Timeouts" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding IntervalLongIos, StringFormat=N0}" Width="130" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IntervalLongIos" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Interval Long IOs" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalLongIos, StringFormat=N0}" Width="115" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalLongIos" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total Long IOs" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LongestPendingRequestsDurationMs, StringFormat=N0}" Width="160" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LongestPendingRequestsDurationMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Pending Duration ms" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LongestPendingRequestsFilePath}" Width="360"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LongestPendingRequestsFilePath" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="File Path" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="IoIssuesNoDataMessage" Style="{StaticResource EmptyHint}" Text="No potential I/O issues (WARNING state with pending I/O) in the selected time window."/> + </Grid> + </Grid> + </TabItem> + + <!-- Default Trace Sub-Tab (built-in Default Trace via sys.fn_trace_gettable) --> + <TabItem Header="Default Trace"> + <Grid> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="8,8" Height="36"> + <TextBlock Text="Default Trace (auto-grow/shrink stalls, severe ErrorLog, schema DDL, security audits, memory change)" VerticalAlignment="Center" Margin="0,0,10,0" FontWeight="SemiBold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="SystemEventsRefresh_Click" Margin="12,0,0,0" Padding="10,4" MinWidth="70"/> + <TextBlock x:Name="DefaultTraceCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + </StackPanel> + <Grid Grid.Row="1" Margin="8,0,8,8"> + <DataGrid x:Name="DefaultTraceGrid" Style="{StaticResource DarkGrid}" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" IsReadOnly="True" AutoGenerateColumns="False"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding EventTimeLocal}" Width="145"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EventTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Event Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Category}" Width="115"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Category" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Category" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding EventName}" Width="150"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EventName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Event" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DatabaseName}" Width="130"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DatabaseName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Database" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ObjectName}" Width="170"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ObjectName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Object" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LoginName}" Width="120"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LoginName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Login" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding HostName}" Width="120"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="HostName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Host" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ApplicationName}" Width="140"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ApplicationName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Application" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Spid, StringFormat=N0}" Width="70" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Spid" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="SPID" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DurationMs, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DurationMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Duration ms" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding GrowthMb, StringFormat=N1}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="GrowthMb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Growth MB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Severity, StringFormat=N0}" Width="80" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Severity" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Severity" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ErrorNumber, StringFormat=N0}" Width="90" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ErrorNumber" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Error #" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TextData}" Width="400"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TextData" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Details" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="DefaultTraceNoDataMessage" Style="{StaticResource EmptyHint}" Text="No significant Default Trace events (auto-grow/shrink stalls, severe ErrorLog, schema DDL, security audits, memory change) in the selected time window."/> + </Grid> + </Grid> + </TabItem> + </TabControl> + </TabItem> + + <!-- Latches & Spinlocks Tab — the Dashboard-parity port of ResourceMetricsContent's Latch Stats / + Spinlock Stats. CONSOLIDATED from two sub-tabs into ONE tab (Erik's "consolidation, not + removal"): the latch wait-time trend and the spinlock collisions trend stack vertically + (full-width so the time axis reads), each with its per-class latest-snapshot grid tucked into a + collapsed Expander directly beneath it — decluttered by default, the per-class detail one click + away. Every grid column / binding is preserved verbatim from the former sub-tabs. Grouped into + the diagnostics tail (after System Events, before Collection Health). Loads on parent-tab + activation (mirrors the Memory tab), so no SelectionChanged handler is needed. --> + <TabItem Header="Latches & Spinlocks"> + <Grid Margin="8"> + <Grid.RowDefinitions> + <RowDefinition Height="*"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + + <!-- Latch wait-time trend (top 5 classes by delta wait) + per-class latest-snapshot grid. --> + <scottplot:WpfPlot Grid.Row="0" x:Name="LatchStatsChart"/> + <Expander Grid.Row="1" Header="Latch classes (latest snapshot)" IsExpanded="False" Margin="0,4,0,0"> + <DataGrid x:Name="LatchStatsGrid" Style="{StaticResource DarkGrid}" MaxHeight="220" Margin="0,4,0,0"> + <DataGrid.Columns> + <DataGridTextColumn Header="Latch Class" Binding="{Binding LatchClass}" Width="*"/> + <DataGridTextColumn Header="Δ Wait (ms)" Binding="{Binding DeltaWaitTimeMs, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="Δ Waiting Reqs" Binding="{Binding DeltaWaitingRequestsCount, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Wait Time (ms)" Binding="{Binding WaitTimeMs, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Max Wait (ms)" Binding="{Binding MaxWaitTimeMs, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="Waiting Reqs (total)" Binding="{Binding WaitingRequestsCount, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="140"/> + </DataGrid.Columns> + </DataGrid> + </Expander> + + <!-- Spinlock collisions trend (top 5 spinlocks by delta collisions) + per-spinlock snapshot grid. --> + <scottplot:WpfPlot Grid.Row="2" x:Name="SpinlockStatsChart" Margin="0,8,0,0"/> + <Expander Grid.Row="3" Header="Spinlocks (latest snapshot)" IsExpanded="False" Margin="0,4,0,0"> + <DataGrid x:Name="SpinlockStatsGrid" Style="{StaticResource DarkGrid}" MaxHeight="220" Margin="0,4,0,0"> + <DataGrid.Columns> + <DataGridTextColumn Header="Spinlock" Binding="{Binding SpinlockName}" Width="*"/> + <DataGridTextColumn Header="Δ Collisions" Binding="{Binding DeltaCollisions, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="Δ Spins" Binding="{Binding DeltaSpins, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Spins/Collision" Binding="{Binding SpinsPerCollision, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Collisions (total)" Binding="{Binding Collisions, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="130"/> + <DataGridTextColumn Header="Spins (total)" Binding="{Binding Spins, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Sleep Time" Binding="{Binding SleepTime, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Backoffs" Binding="{Binding Backoffs, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + </DataGrid.Columns> + </DataGrid> + </Expander> + </Grid> + </TabItem> + + <!-- Collection Health Tab — upgraded from the shell's single latest-run grid to Lite's rich + 3-sub-tab shape (copied from Lite's ServerTab.xaml Collection Health tab). Reads rewired to + Postgres; grids keep the viewer's DarkGrid chrome + Lite's ColumnFilterButtonStyle headers. + Health Summary = the 7-day per-collector aggregate with the HealthStatus band (double-click + opens the per-collector CollectionLogWindow drill). Collection Log = the recent run log. + Duration Trends = the per-collector success-duration scatter. Two deviations from Lite: the + HealthStatus cell is plain text (Lite doesn't color it — the shell's StatusToBrush stays on + the Findings grid only), and Lite's "Open Log File" button is dropped (it opens Lite's local + log file, which the viewer has no equivalent of). --> + <TabItem Header="Collection Health"> + <Grid Margin="8"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + <!-- Fleet-wide "Purge Now": runs the daily retention purge on demand via the purge_now + control command. Placed on Collection Health (the store-health tab) rather than a + per-server toolbar because the purge is fleet-wide over the shared store tables. A + read-only viewer seat can't enqueue commands, so the handler shows an explanation. --> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,8"> + <Button x:Name="PurgeNowButton" Content="Purge Now..." Click="PurgeNow_Click" + Padding="12,4" Margin="0,0,8,0" VerticalAlignment="Center"/> + <TextBlock x:Name="PurgeNowIndicator" Text="" FontSize="12" + Foreground="DarkGoldenrod" FontWeight="SemiBold" VerticalAlignment="Center"/> + </StackPanel> + <TabControl Grid.Row="1" Background="Transparent" BorderThickness="0" ItemContainerStyle="{DynamicResource SubTabItemStyle}"> + <TabItem Header="Health Summary"> + <DataGrid x:Name="CollectionHealthGrid" Style="{StaticResource DarkGrid}" + MouseDoubleClick="CollectionHealthGrid_MouseDoubleClick"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding CollectorName}" Width="180"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CollectorName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Collector" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding HealthStatus}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="HealthStatus" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Status" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalRuns, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalRuns" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Runs" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding SuccessCount, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SuccessCount" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Success" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ErrorCount, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ErrorCount" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Errors" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding FailureRatePercent, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="85"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="FailureRatePercent" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Fail %" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgDurationFormatted}" Width="120" SortMemberPath="AvgDurationMs"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgDurationFormatted" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Duration" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LastSuccessFormatted}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LastSuccessFormatted" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Last Success" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LastRunFormatted}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LastRunFormatted" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Last Run" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LastErrorFormatted}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LastErrorFormatted" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Last Error At" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LastError}" Width="*"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LastError" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Last Error" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + </TabItem> + <TabItem Header="Collection Log"> + <!-- Lite's "Open Log File" button dropped (it opens Lite's local log file; the + viewer has no equivalent), so the grid fills the whole sub-tab. --> + <DataGrid x:Name="CollectionLogGrid" Style="{StaticResource DarkGrid}"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding CollectionTimeFormatted}" Width="130"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CollectionTimeFormatted" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CollectorName}" Width="180"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CollectorName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Collector" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Status}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Status" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Status" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DurationFormatted}" Width="90" SortMemberPath="DurationMs"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DurationFormatted" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Duration" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding SqlDurationFormatted}" Width="90" SortMemberPath="SqlDurationMs"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SqlDurationFormatted" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="SQL (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <!-- Store phase: the store column is duckdb_duration_ms, but on the Darling + side it records the Postgres write time — labelled "Store (ms)". --> + <DataGridTextColumn Binding="{Binding DuckDbDurationFormatted}" Width="120" SortMemberPath="DuckDbDurationMs"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DuckDbDurationFormatted" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Store (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding RowsCollected, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="RowsCollected" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Rows" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ErrorMessage}" Width="*"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ErrorMessage" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Error" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + </TabItem> + <TabItem Header="Duration Trends"> + <scottplot:WpfPlot x:Name="CollectorDurationChart" Margin="4"/> + </TabItem> + </TabControl> + </Grid> + </TabItem> + + <!-- Long Queries Tab (#1496) — completed long-running queries + attentions (cancels/timeouts) from + the opt-in PerformanceMonitor_LongQueryCompletions XE session. Default OFF; the banner points to + where the switch lives when the collector is disabled. MUST remain the last inner tab (index 18). --> + <TabItem Header="Long Queries"> + <Grid Margin="8"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + <TextBlock Grid.Row="0" Text="Long-Running Query Completions (RPC / batch completions over the duration threshold, plus attentions / cancels)" FontSize="14" FontWeight="SemiBold" Margin="0,0,0,8" TextWrapping="Wrap"/> + <TextBlock Grid.Row="1" x:Name="LongQueriesDisabledWarning" Visibility="Collapsed" + Foreground="{DynamicResource WarningTextBrush}" FontSize="12" Margin="0,0,0,8" TextWrapping="Wrap" + Text="The long-query completion trace is OFF for this server. It is opt-in because a completion trace adds overhead on busy servers. Turn it on in Settings → Collection Schedule → Edit Collector Schedules…, then tick the 'long_query_completions' Enabled box. Enabling it creates the Extended Events session on this server; disabling it drops the session."/> + <DataGrid Grid.Row="2" x:Name="LongQueryCompletionsGrid" + Style="{StaticResource DarkGrid}" + HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" + CanUserSortColumns="True"> + <DataGrid.RowStyle> + <Style TargetType="DataGridRow" BasedOn="{StaticResource DarkGridRow}"> + <Style.Triggers> + <DataTrigger Binding="{Binding IsAborted}" Value="True"> + <Setter Property="Background" Value="#33FF6B6B"/> + </DataTrigger> + <DataTrigger Binding="{Binding IsAttention}" Value="True"> + <Setter Property="Background" Value="#33FFB86B"/> + </DataTrigger> + </Style.Triggers> + </Style> + </DataGrid.RowStyle> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding EventTimeLocal}" Width="150" SortMemberPath="EventTime"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EventTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Event Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding EventType}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EventType" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Event Type" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DurationFormatted}" Width="100" SortMemberPath="DurationMicroseconds"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DurationFormatted" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Duration" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CpuFormatted}" Width="100" SortMemberPath="CpuTimeMicroseconds"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CpuFormatted" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="CPU" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LogicalReads}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LogicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Logical Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PhysicalReads}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PhysicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Physical Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Writes}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Writes" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Writes" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding RowCountValue}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="RowCountValue" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Rows" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Result}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Result" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Result" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DatabaseName}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DatabaseName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Database" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ObjectName}" Width="180"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ObjectName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Object" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTemplateColumn Header="Statement" Width="360"> + <DataGridTemplateColumn.CellTemplate> + <DataTemplate> + <TextBlock Text="{Binding StatementText}" TextTrimming="CharacterEllipsis" ToolTip="{Binding StatementText}"/> + </DataTemplate> + </DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + <DataGridTextColumn Binding="{Binding SessionId}" ElementStyle="{StaticResource NumericCell}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SessionId" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Session" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ClientAppName}" Width="180"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ClientAppName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Application" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ServerPrincipalName}" Width="150"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ServerPrincipalName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Login" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding QueryHash}" Width="150"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="QueryHash" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Query Hash" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + </Grid> + </TabItem> + </TabControl> + </Grid> +</UserControl> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml.cs new file mode 100644 index 000000000..65f64744b --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml.cs @@ -0,0 +1,406 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// One monitored server's detail surface (headless-plan W0 IA inversion): a closable per-server tab +/// hosting an inner tab strip — Overview (CPU-utilization + wait-time-by-category trend charts), +/// Queries (top-50 query stats), Blocking (XE blocked-process reports with the DMV-snapshot fallback), +/// and Collection Health (per-collector last-run status). This is the per-server content relocated +/// out of MainWindow's old flat tab list; the load/render bodies are unchanged (pure re-hosting). +/// Loads are lazy per inner tab (Lite's visible-only rule): switching inner tabs loads the newly +/// visible one, and <see cref="RefreshActiveInnerTabAsync"/> — driven by the per-server toolbar's +/// auto-refresh timer (see ViewerServerTab.TimeRange.cs) only when this server tab is the visible +/// top-level tab, plus MainWindow's tab-activation reload — reloads just the active inner tab. +/// </summary> +public partial class ViewerServerTab : UserControl +{ + /* The per-server data window is no longer a fixed 24-hour constant: it comes from the toolbar's + time-range dropdown / custom From-To (see ViewerServerTab.TimeRange.cs). Every inner-tab load + reads GetWindowUtc() / GetWindowHoursBack() instead of the removed s_dataWindow. */ + + /* Inner-tab order: the main resource run mirrors Lite's ServerTab relative order (Overview, Wait Stats, + Queries, Plan Viewer, CPU, Memory, File I/O, tempdb, Blocking, Perfmon, Session Stats, Running Jobs, + Configuration, Configuration Changes), followed by a Darling-specific DIAGNOSTICS TAIL: + Daily Summary -> System Events -> Latches & Spinlocks -> Collection Health. Latches & Spinlocks and + System Events are Darling-only tabs grouped into that tail rather than the resource run. These index + constants are the source of truth for SelectedIndex navigation (drill-downs), so they MUST match the + <TabItem> order in ViewerServerTab.xaml. */ + internal const int OverviewInnerTabIndex = 0; + internal const int WaitStatsInnerTabIndex = 1; + internal const int QueriesInnerTabIndex = 2; + internal const int PlanViewerInnerTabIndex = 3; + internal const int CpuInnerTabIndex = 4; + internal const int MemoryInnerTabIndex = 5; + internal const int FileIoInnerTabIndex = 6; + internal const int TempDbInnerTabIndex = 7; + internal const int BlockingInnerTabIndex = 8; + internal const int PerfmonInnerTabIndex = 9; + internal const int SessionStatsInnerTabIndex = 10; + internal const int RunningJobsInnerTabIndex = 11; + internal const int ConfigurationInnerTabIndex = 12; + internal const int ConfigChangesInnerTabIndex = 13; + + /* Diagnostics tail (Daily Summary -> System Events -> Latches & Spinlocks -> Collection Health): the + reworked per-server tab order groups the two Darling-only diagnostic tabs (System Events, Latches & + Spinlocks) with Daily Summary and Collection Health at the end, instead of scattering them through the + resource run (Latches & Spinlocks was formerly at position 3, System Events dead last). */ + internal const int DailySummaryInnerTabIndex = 14; + internal const int SystemEventsInnerTabIndex = 15; + internal const int LatchSpinlockInnerTabIndex = 16; + internal const int HealthInnerTabIndex = 17; + + /* #1496 Long Queries — the opt-in completion trace surface, appended after the diagnostics tail so no + existing inner-tab index shifts (drill-down navigation keys on these constants). MUST remain the last + <TabItem> in ViewerServerTab.xaml's InnerTabs. */ + internal const int LongQueriesInnerTabIndex = 18; + + private readonly ViewerDataService _dataService; + private readonly DarlingServer _server; + private readonly ViewerServerStore? _serverStore; + + private bool _refreshInFlight; + private bool _refreshRequested; + + /* #1319: per-server global database filter (display-only). Empty set = "All" (unfiltered). */ + private readonly HashSet<string> _selectedDatabases = new(StringComparer.OrdinalIgnoreCase); + private List<SelectableItem> _databaseFilterItems = new(); + private bool _isUpdatingDatabaseFilterSelection; + private int _databaseFilterTotalCount; + private bool _databaseFilterDirty; + + /// <summary>The database filter as a reader argument: null (= All, unfiltered) when nothing is selected.</summary> + private IReadOnlyList<string>? SelectedDatabaseFilter => _selectedDatabases.Count == 0 ? null : _selectedDatabases.ToList(); + + /// <summary>Raised after a load so MainWindow can surface progress/errors in its status bar.</summary> + public event Action<string>? StatusChanged; + + /// <param name="preferences"> + /// The user's persisted viewer defaults (Settings window) that seed this tab's toolbar — default time + /// window and auto-refresh on/off + interval. Null (or omitted) uses a default-constructed + /// <see cref="ViewerPreferences"/>, which reproduces the toolbar's historical XAML defaults (24h / on / 1m) + /// exactly, so the tab is unchanged when no settings are supplied. + /// </param> + public ViewerServerTab(ViewerDataService dataService, DarlingServer server, ViewerPreferences? preferences = null, ViewerServerStore? serverStore = null) + { + _dataService = dataService; + _server = server; + _serverStore = serverStore; + /* #1319: seed the per-server database filter from the viewer registry (viewer-servers.json). */ + if (_serverStore != null) + { + foreach (var db in _serverStore.GetViewFilterDatabases(_server.ServerName)) + { + _selectedDatabases.Add(db); + } + } + InitializeComponent(); + + /* Per-server toolbar identity label: the display name is static (it also heads the tab), while the + freshness readout to its right is filled on the first (and every) refresh from the shared + collection-time read (UpdateServerFreshnessLabelAsync). */ + ServerNameText.Text = _server.DisplayName; + UpdateDatabaseFilterLabel(); + + /* Column-filter managers for the Configuration sub-grids (copied from Lite's ServerTab filter + wiring) — after InitializeComponent so the named grids exist. */ + InitializeFilterManagers(); + + /* Overview lanes (copied from Lite): init the data service + server up front so the lanes theme + their chrome and wire the correlated crosshair before the first load. Wire the lanes' own + right-click "Show Active Queries at This Time" drill-down (every lane: CPU / Wait Stats / + Blocking / Buffer Pool / I/O Latency) to the Active Queries loader — the lanes plot X through + ViewerTimeHelper.ForDisplay, so the event's argument is display-time, exactly what + OnActiveQueriesDrillDown converts back to naive UTC. ThemeManager is fixed to Dark, so the shared + chart chrome applies without any per-control theme plumbing. */ + OverviewLanes.Initialize(_dataService, _server.ServerId); + OverviewLanes.ShowActiveQueriesRequested += OnActiveQueriesDrillDown; + + /* CPU + tempdb inner-tab charts (copied from Lite): theme up front + wire hover. */ + InitializeCpuTempDbCharts(); + + /* CPU Scheduler sub-tab chart (cpu_scheduler_stats parity): pressure trend + latest-snapshot grid. */ + InitializeCpuSchedulerChart(); + + /* Memory inner-tab charts (copied from Lite): same up-front theme + hover for the five Memory + charts (Overview trend, Clerks, Grant sizing/activity, Pressure events). */ + InitializeMemoryCharts(); + + /* Plan Cache sub-tab chart (plan_cache_stats parity, under Memory): single-use vs multi-use trend. */ + InitializePlanCacheChart(); + + /* Latches & Spinlocks inner-tab charts (latch_stats/spinlock_stats parity): the two per-second + contention trend charts, themed up front + hover. */ + InitializeLatchSpinlockCharts(); + + /* Session Stats inner-tab chart (session_summary_stats parity): the server-wide session-count trend, + themed up front + hover. */ + InitializeSessionStatsChart(); + + /* File I/O + Blocking-trend inner-tab charts (copied from Lite): same up-front theme + hover. */ + InitializeFileIoCharts(); + InitializeBlockingCharts(); + + /* Queries tab (W1f-1): the three grids' bar-cell maxima hook + slicer RangeChanged wiring + (copied from Lite's ServerTab). After InitializeComponent so the named grids/slicers exist. */ + InitializeQueriesTab(); + + /* Collection Health's Duration Trends chart (copied from Lite): up-front theme + hover. */ + InitializeCollectionHealthChart(); + + /* System Events inner tab (system_health parity): register the category grids' filter managers. */ + InitializeSystemEventsTab(); + + /* System Events' Corruption + Contention counter charts (SYSTEM-component parity): theme the eight + charts up front + wire hover, mirroring the Dashboard's SystemEventsContent. */ + InitializeSystemHealthCharts(); + + /* Seed the toolbar's initial time window + auto-refresh state from the user's persisted defaults + (the Settings window), replacing the XAML's hardcoded 24h / on / 1m. Set BEFORE + InitializeAutoRefreshTimer, which reads AutoRefreshCheckBox + AutoRefreshIntervalCombo to decide + whether and how fast to start the timer. Assigning SelectedIndex/IsChecked here is inert: the + range handler no-ops while IsLoaded is false, and the checkbox handler no-ops while + _autoRefreshTimer is still null — so seeding drives no premature reload or timer churn. */ + var toolbarDefaults = preferences ?? new ViewerPreferences(); + TimeRangeCombo.SelectedIndex = toolbarDefaults.DefaultTimeRangeIndex; + AutoRefreshCheckBox.IsChecked = toolbarDefaults.AutoRefreshEnabled; + AutoRefreshIntervalCombo.SelectedIndex = toolbarDefaults.AutoRefreshIntervalIndex; + + /* Per-server toolbar (this wave): populate the custom-range hour/minute combos and start the + auto-refresh timer at the toolbar's interval (seeded above). The settable window these controls + drive replaces the removed 24-hour s_dataWindow constant. */ + InitializeTimeComboBoxes(); + InitializeAutoRefreshTimer(); + + /* Seed the toolbar's Server/Local/UTC picker from the persisted global mode (MainWindow set + ViewerTimeHelper.CurrentDisplayMode from ViewerAppSettings on startup). Suppressed inside + SetDisplayModeSelection, and the handler no-ops before IsLoaded anyway, so this drives no reload. */ + SetDisplayModeSelection(ViewerTimeHelper.CurrentDisplayMode); + + /* Right-click chart drill-downs (ViewerServerTab.DrillDown.cs) + slicer overlay-on-select + (ViewerServerTab.SlicerOverlay.cs). Wired LAST — after every Initialize*Charts has created its + hover helpers, which the drill-down menus read for the nearest-series time. WireChartDrillDowns + builds each drill-down chart's copy/save/export menu (BuildChartContextMenu) and adds the drill-down + to it; WireChartContextMenus gives the same menu to every chart that has NO drill-down. */ + WireChartDrillDowns(); + WireChartContextMenus(); + WireSlicerOverlays(); + + /* Per-row double-click history windows (ViewerServerTab.History.cs) on the three query grids — a + different event from the SelectionChanged slicer overlay wired just above; both coexist. */ + WireHistoryDrillDowns(); + } + + /// <summary>The server this tab is bound to; MainWindow keys open tabs by this for dedupe/close.</summary> + public int ServerId => _server.ServerId; + + /// <summary>The server record, for the tab header label and status text.</summary> + public DarlingServer Server => _server; + + /// <summary> + /// Lazy per-inner-tab load: switching inner tabs loads the newly visible one. SelectionChanged is + /// a bubbling routed event, so selections inside the tab content (a grid) reach here too — only + /// react to the inner TabControl's own. Gated on <see cref="System.Windows.FrameworkElement.IsLoaded"/> + /// so the selection raised while the TabControl is first built (before this tab is shown) is ignored: + /// MainWindow drives the initial load when it makes this server tab visible, and the toolbar's + /// auto-refresh timer keeps it fresh — so the control loads exactly once per event, not on construction too. + /// </summary> + private async void InnerTabs_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (!ReferenceEquals(e.OriginalSource, InnerTabs) || !IsLoaded) + { + return; + } + + /* A drill-down navigation switches the inner tab programmatically and runs its own targeted read; + skip the generic loader so it doesn't race that (mirrors the sub-tab handlers' guard). */ + if (_suppressDrillDownAutoRefresh) + { + return; + } + + await RefreshActiveInnerTabAsync(); + } + + /// <summary> + /// Loads the ACTIVE inner tab, with the overlap guard mirroring MainWindow's aggregate-tab loop. + /// If the inner tab switches while a load is in flight, the triggering event bounces off the guard, + /// so after each load we loop when the selection moved on and load again — leaving no inner tab + /// stranded. A trigger that arrives mid-load (the auto-refresh tick, an inner-tab switch) sets + /// <see cref="_refreshRequested"/> instead of being dropped, so the running loop reloads once more. + /// </summary> + public async Task RefreshActiveInnerTabAsync() + { + if (_refreshInFlight) + { + _refreshRequested = true; + return; + } + + _refreshInFlight = true; + try + { + /* Point the process-wide time helper at THIS server's UTC offset before rendering — only the + visible tab renders (the viewer's visible-only rule), so its offset wins. Loaded once, cached. */ + await EnsureServerOffsetLoadedAsync(); + ApplyServerOffsetToHelper(); + + /* Refresh the toolbar freshness readout in the same pass (after the offset is applied so it renders + in the active display mode). Non-critical chrome — its own try/catch keeps it off the load path. */ + await UpdateServerFreshnessLabelAsync(); + + do + { + _refreshRequested = false; + + int loadedTab; + do + { + loadedTab = InnerTabs.SelectedIndex; + await LoadInnerTabAsync(loadedTab); + } + while (InnerTabs.SelectedIndex != loadedTab); + } + while (_refreshRequested); + } + finally + { + _refreshInFlight = false; + } + } + + /// <summary> + /// Fills the toolbar's freshness readout with this server's newest collection time (the shared + /// MAX(collection_time) read the sidebar dots use), rendered in the active Server/Local/UTC display mode + /// exactly like the Manage Servers "Last Collected" column. A server the service hasn't collected yet is + /// absent from the freshness map, shown as "no data collected yet". Best-effort chrome: a read failure + /// blanks the readout rather than disturbing the tab load. + /// </summary> + private async Task UpdateServerFreshnessLabelAsync() + { + try + { + var freshness = await _dataService.GetServerFreshnessAsync(); + ServerFreshnessText.Text = freshness.TryGetValue(_server.ServerId, out var lastUtc) + ? $"collected {ViewerTimeHelper.ForDisplay(lastUtc):yyyy-MM-dd HH:mm}" + : "no data collected yet"; + } + catch + { + ServerFreshnessText.Text = string.Empty; + } + } + + private async Task LoadInnerTabAsync(int tabIndex) + { + try + { + switch (tabIndex) + { + case WaitStatsInnerTabIndex: + await LoadWaitStatsAsync(); + break; + case LatchSpinlockInnerTabIndex: + await LoadLatchSpinlockAsync(); + break; + case QueriesInnerTabIndex: + await LoadQueriesAsync(); + break; + case PlanViewerInnerTabIndex: + /* No data feed: plans are pushed into the host by OpenPlanTab (a "View Plan" click), + not loaded on tab-switch. Explicit no-op so selecting it doesn't fall through to the + default Overview reload, and so the auto-refresh timer leaves any open plan tabs alone. */ + break; + case BlockingInnerTabIndex: + await LoadBlockingAsync(); + break; + case PerfmonInnerTabIndex: + await LoadPerfmonAsync(); + break; + case SessionStatsInnerTabIndex: + await LoadSessionStatsAsync(); + break; + case RunningJobsInnerTabIndex: + await LoadRunningJobsAsync(); + break; + case ConfigurationInnerTabIndex: + await LoadConfigurationAsync(); + break; + case ConfigChangesInnerTabIndex: + await LoadConfigChangesAsync(); + break; + case DailySummaryInnerTabIndex: + await LoadDailySummaryAsync(); + break; + case HealthInnerTabIndex: + await LoadHealthAsync(); + break; + case CpuInnerTabIndex: + await LoadCpuAsync(); + break; + case MemoryInnerTabIndex: + await LoadMemoryAsync(); + break; + case TempDbInnerTabIndex: + await LoadTempDbAsync(); + break; + case FileIoInnerTabIndex: + await LoadFileIoAsync(); + break; + case SystemEventsInnerTabIndex: + await LoadSystemEventsAsync(); + break; + case LongQueriesInnerTabIndex: + await LoadLongQueriesAsync(); + break; + case OverviewInnerTabIndex: + default: + await LoadOverviewChartsAsync(); + break; + } + + StatusChanged?.Invoke($"{_server.DisplayName} — refreshed {DateTime.Now:HH:mm:ss}"); + } + catch (Exception ex) + { + StatusChanged?.Invoke($"refresh failed: {ex.Message}"); + } + } + + private async Task LoadOverviewChartsAsync() + { + /* The lanes control reads its own six feeds + four baselines concurrently over the toolbar's + window: hours-back for a preset, or the custom From/To when the user picked a custom range + (the lanes bound their own X-axis to it). Replaces wave-4's CPU-trend + wait-category charts. */ + var (fromDate, toDate) = GetOverviewCustomRange(); + await OverviewLanes.RefreshAsync(GetWindowHoursBack(), fromDate, toDate); + } + + /* LoadHealthAsync now lives in ViewerServerTab.CollectionHealth.cs (W1i moved it there with the + Collection Health sub-tabs), and LoadQueriesAsync now lives in ViewerServerTab.Queries.cs — it + dispatches to the Queries tab's active sub-tab (Top Queries / Top Procedures / Query Store), + loading that grid + its slicer + (when Compare is active) its comparison grid. */ + + /* LoadBlockingAsync now lives in ViewerServerTab.Blocking.cs — it dispatches to the Blocking tab's + active sub-tab (Trends / Current Waits / Blocked Process Reports) instead of loading the grid + directly. */ + + /* The Overview's rendering now lives entirely in the copied CorrelatedTimelineLanesControl + (OverviewLanes); wave-4's RenderCpuTrend / RenderWaitCategoryTrend and their reads + (ViewerDataService.Trends.cs) were superseded by the lanes and removed. */ +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerSettings.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerSettings.cs new file mode 100644 index 000000000..6769c6fac --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerSettings.cs @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Npgsql; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The viewer's read of darling.json — the SAME file the Darling service uses, but the viewer +/// only needs the postgres section (it talks to the central store, never to monitored +/// SQL Servers). This intentionally duplicates a sliver of the service's DarlingConfig — the +/// resolution order and the postgres section — rather than referencing the service project; +/// a shared config library is not warranted for one section yet. +/// Resolution order matches the service — explicit path → DARLING_CONFIG environment +/// variable → darling.json next to the binary — plus one viewer-only fallback: the parent +/// directory, because the release zip puts the viewer in a viewer\ subfolder under the +/// service root where the operator's darling.json lives. Comments and trailing commas are +/// allowed, property names are case-insensitive. +/// Managed mode (<c>postgres.managed = true</c>, the shipped default): the SERVICE bootstraps +/// and owns the bundled Postgres; the viewer only derives the same connection string — +/// 127.0.0.1 + port + the darling user with the password the service generated on first run, +/// unprotected from the DPAPI-LocalMachine credential file beside the data directory. The +/// derivation constants (user/database name, credential path convention, DPAPI entropy) are +/// the service's <c>DarlingManagedPostgres</c>/<c>DarlingSecrets</c> values, duplicated under +/// the same sliver rule and pinned against the service by a Darling.Tests round-trip test. +/// </summary> +public sealed class ViewerSettings +{ + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + /* The service's DarlingSecrets entropy — must match byte-for-byte or the viewer cannot + read the managed credential (pinned by ViewerSettingsTests). */ + private static readonly byte[] s_dpapiEntropy = Encoding.UTF8.GetBytes("PerformanceMonitor.Darling.v1"); + + /* The service's DarlingManagedPostgres least-privilege role + credential constants, duplicated + under the same sliver rule as the entropy above (the viewer does not reference the Service + project) and pinned against it by ViewerSettingsTests. The Viewer drops from the superuser to + one of these roles: admin (the default — reads both schemas + writes the config tables the mute + / dismiss / analysis-mute surfaces need) or viewer (read-only). SearchPath resolves the bare + table names to the V8 collect/config schemas on every connection. */ + private const string AdminRoleName = "admin"; + private const string ViewerRoleName = "viewer"; + private const string AdminCredentialFileName = "pg-admin-credential.dpapi"; + private const string ViewerCredentialFileName = "pg-viewer-credential.dpapi"; + private const string ManagedSearchPath = "collect,config,public"; + + public string ConnectionString { get; } + + private ViewerSettings(string connectionString) + { + ConnectionString = connectionString; + } + + /// <param name="explicitPath">A path handed on the command line; wins outright.</param> + /// <param name="baseDirectory">The viewer binary's directory; null means AppContext.BaseDirectory (tests pass a temp directory).</param> + public static string ResolveConfigPath(string? explicitPath = null, string? baseDirectory = null) + { + if (!string.IsNullOrWhiteSpace(explicitPath)) + { + return explicitPath; + } + + var fromEnvironment = Environment.GetEnvironmentVariable("DARLING_CONFIG"); + if (!string.IsNullOrWhiteSpace(fromEnvironment)) + { + return fromEnvironment; + } + + baseDirectory ??= AppContext.BaseDirectory; + var besideBinary = Path.Combine(baseDirectory, "darling.json"); + if (File.Exists(besideBinary)) + { + return besideBinary; + } + + /* The release zip ships the viewer in a viewer\ subfolder under the service root, and + the operator's darling.json lives beside the SERVICE exe — so when there is nothing + beside the viewer, probe one level up. Miss both and we still return the + beside-binary path, so the not-found hint names the viewer's own directory. */ + var parent = Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(Path.GetFullPath(baseDirectory))); + if (!string.IsNullOrEmpty(parent)) + { + var besideService = Path.Combine(parent, "darling.json"); + if (File.Exists(besideService)) + { + return besideService; + } + } + + return besideBinary; + } + + /// <summary> + /// Loads the resolved config file, or returns null when it does not exist (the window shows + /// the copy-the-sample hint instead of crashing). Malformed content still throws — the + /// caller turns that into a readable message too. + /// </summary> + public static ViewerSettings? TryLoad(string? explicitPath = null) + { + var path = ResolveConfigPath(explicitPath); + if (!File.Exists(path)) + { + return null; + } + + return Parse(File.ReadAllText(path)); + } + + public static ViewerSettings Parse(string json) + { + var config = JsonSerializer.Deserialize<ConfigDto>(json, s_jsonOptions); + + if (config?.Postgres?.Managed == true) + { + return new ViewerSettings(DeriveManagedConnectionString(config.Postgres)); + } + + var connectionString = config?.Postgres?.ConnectionString; + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new InvalidDataException("darling.json has no postgres.connectionString (and postgres.managed is not true)."); + } + + return new ViewerSettings(connectionString); + } + + /// <summary> + /// The managed-mode derivation, mirroring the service: data directory = configured or + /// %ProgramData%\PerformanceMonitorDarling\pg, and connection = 127.0.0.1 + port + the + /// least-privilege role <c>postgres.connectAs</c> selects (admin by default, or viewer), + /// authenticated with that role's DPAPI credential file beside the data directory and carrying + /// the collect/config search path. The Viewer never connects as the superuser <c>darling</c> — + /// the service provisions and owns that. A missing credential means the service has not completed + /// its first run (which provisions the roles + writes their credentials) yet — a readable message, + /// because the main window shows parse failures to the user. + /// </summary> + private static string DeriveManagedConnectionString(PostgresDto postgres) + { + var dataDirectory = string.IsNullOrWhiteSpace(postgres.DataDirectory) + ? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "PerformanceMonitorDarling", "pg") + : Path.GetFullPath(postgres.DataDirectory); + + /* connectAs picks the role + its credential; unknown values fall back to admin (the service's + DarlingConfig default), so a typo degrades to the writable role, not to a crash. */ + var wantsViewer = string.Equals(postgres.ConnectAs?.Trim(), ViewerRoleName, StringComparison.OrdinalIgnoreCase); + var role = wantsViewer ? ViewerRoleName : AdminRoleName; + var credentialFileName = wantsViewer ? ViewerCredentialFileName : AdminCredentialFileName; + + var parent = Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(dataDirectory)); + var credentialPath = string.IsNullOrEmpty(parent) ? null : Path.Combine(parent, credentialFileName); + if (credentialPath is null || !File.Exists(credentialPath)) + { + throw new InvalidDataException( + "darling.json uses the managed Postgres (postgres.managed = true), but the credential file for the " + + $"'{role}' role ({credentialPath ?? "beside " + dataDirectory}) does not exist yet. Start the " + + "PerformanceMonitor Darling service once — its first run initializes the bundled Postgres and " + + "provisions the least-privilege roles and their credentials."); + } + + var protectedBytes = Convert.FromBase64String(File.ReadAllText(credentialPath).Trim()); + var password = Encoding.UTF8.GetString( + ProtectedData.Unprotect(protectedBytes, s_dpapiEntropy, DataProtectionScope.LocalMachine)); + + var builder = new NpgsqlConnectionStringBuilder + { + /* 127.0.0.1, not "localhost", mirroring the service's DarlingManagedPostgres.BuildConnectionString + (the parity half of the same pair): a literal IPv4 loopback avoids a localhost->::1 resolution + that would miss the managed cluster's IPv4-only listen_addresses (it binds 127.0.0.1, not ::1; + initdb's pg_hba ships a ::1 rule but there is no ::1 listener). */ + Host = "127.0.0.1", + Port = postgres.Port, + Username = role, + Password = password, + Database = "darling", + SearchPath = ManagedSearchPath, + /* #1566: bound the viewer seat's backend count (the service's pools were capped at 24 in + #1559, but this string was built independently and rode Npgsql's default of 100). Every + pooled connection is a live postgres.exe on Windows; a read-only UI seat polling on 30/60s + timers needs a handful, not a hundred. */ + MaxPoolSize = 10, + }; + return builder.ConnectionString; + } + + private sealed class ConfigDto + { + [JsonPropertyName("postgres")] + public PostgresDto? Postgres { get; set; } + } + + private sealed class PostgresDto + { + [JsonPropertyName("connectionString")] + public string? ConnectionString { get; set; } + + [JsonPropertyName("managed")] + public bool Managed { get; set; } + + [JsonPropertyName("port")] + public int Port { get; set; } = 5641; + + [JsonPropertyName("dataDirectory")] + public string? DataDirectory { get; set; } + + /// <summary>Which least-privilege role to connect as: "admin" (default) or "viewer" (read-only).</summary> + [JsonPropertyName("connectAs")] + public string? ConnectAs { get; set; } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerTimeHelper.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerTimeHelper.cs new file mode 100644 index 000000000..f31fa88a0 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerTimeHelper.cs @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The viewer's port of Lite's <c>ServerTimeHelper</c>: the single chokepoint every rendered timestamp +/// routes through, converting a stored value to the user's chosen <see cref="TimeDisplayMode"/>. It +/// replaces the viewer's old fixed machine-local conversion (the former <c>ViewerDataService.ToLocalTime</c>, +/// which every call site now reaches as <see cref="ForDisplay"/>). +/// +/// <para> +/// The Darling store is naive-UTC (every collected <c>timestamp</c> column is UTC with +/// <see cref="DateTimeKind.Unspecified"/>), so the three modes map on as: +/// <list type="bullet"> +/// <item><b>UTC</b> — the raw stored value.</item> +/// <item><b>Local</b> — the viewer machine's local time (<c>SpecifyKind(Utc).ToLocalTime()</c>, the +/// viewer's historical one-and-only convention).</item> +/// <item><b>Server</b> — the monitored server's own local time = UTC + the server's UTC offset +/// (<c>server_properties.utc_offset_minutes</c>, collected because a headless viewer can't query the +/// target live the way Lite does at connect).</item> +/// </list> +/// </para> +/// +/// <para> +/// Process-wide statics mirroring <c>ServerTimeHelper</c>: <see cref="CurrentDisplayMode"/> is the global +/// user preference (persisted in <see cref="ViewerAppSettings.TimeDisplayMode"/>), and +/// <see cref="UtcOffsetMinutes"/> is set by the active <see cref="ViewerServerTab"/> to ITS server's +/// offset before that tab renders — only the visible tab renders (the viewer's visible-only rule), so the +/// visible tab's offset always wins. Charts pre-convert their X values through <see cref="ForDisplay"/>, +/// so <see cref="UiTimeContext.ConvertForDisplay"/> is deliberately left at its identity default in the +/// viewer (wiring it would double-convert the already-display-time chart X on hover/crosshair). +/// </para> +/// </summary> +public static class ViewerTimeHelper +{ + /// <summary>Seeds to the viewer machine's offset so Server mode degrades gracefully to ~Local until a + /// server's <c>utc_offset_minutes</c> has been collected.</summary> + private static int _utcOffsetMinutes = (int)TimeZoneInfo.Local.GetUtcOffset(DateTime.UtcNow).TotalMinutes; + + /// <summary>The active server's UTC offset in minutes (UTC + this = the server's local time).</summary> + public static int UtcOffsetMinutes + { + get => _utcOffsetMinutes; + set => _utcOffsetMinutes = value; + } + + /// <summary>The global timestamp display mode; the default (Server-time) mirrors Lite's default.</summary> + public static TimeDisplayMode CurrentDisplayMode { get; set; } = TimeDisplayMode.ServerTime; + + /// <summary> + /// Converts a stored naive-UTC timestamp to the current display mode + active server offset — the one + /// method every timestamp render routes through. Single overload on purpose so the many + /// <c><see cref="ViewerTimeHelper.ForDisplay"/></c> doc references stay unambiguous. + /// </summary> + public static DateTime ForDisplay(DateTime naiveUtc) => ConvertToDisplay(naiveUtc, CurrentDisplayMode, _utcOffsetMinutes); + + /// <summary> + /// Pure (static-free) naive-UTC → display conversion for an explicit mode + offset. The testable core + /// of <see cref="ForDisplay"/>; unit tests exercise it without mutating the process-wide statics. + /// </summary> + public static DateTime ConvertToDisplay(DateTime naiveUtc, TimeDisplayMode mode, int utcOffsetMinutes) => mode switch + { + TimeDisplayMode.LocalTime => DateTime.SpecifyKind(naiveUtc, DateTimeKind.Utc).ToLocalTime(), + TimeDisplayMode.ServerTime => naiveUtc.AddMinutes(utcOffsetMinutes), + _ => naiveUtc, /* UTC — the store is already naive UTC */ + }; + + /// <summary> + /// Inverse of <see cref="ForDisplay"/> for the custom-range pickers: a wall-clock value the user typed + /// IN THE CURRENT display mode, back to the store's naive-UTC window bound. + /// </summary> + public static DateTime DisplayToNaiveUtc(DateTime display) => ConvertFromDisplay(display, CurrentDisplayMode, _utcOffsetMinutes); + + /// <summary>As <see cref="DisplayToNaiveUtc(DateTime)"/> but for an explicit mode (the active offset) — + /// used when re-reading the pickers in their OLD mode during a mode switch.</summary> + public static DateTime DisplayToNaiveUtc(DateTime display, TimeDisplayMode mode) => ConvertFromDisplay(display, mode, _utcOffsetMinutes); + + /// <summary>Pure (static-free) display → naive-UTC inverse for an explicit mode + offset.</summary> + public static DateTime ConvertFromDisplay(DateTime display, TimeDisplayMode mode, int utcOffsetMinutes) => mode switch + { + TimeDisplayMode.LocalTime => + DateTime.SpecifyKind(DateTime.SpecifyKind(display, DateTimeKind.Local).ToUniversalTime(), DateTimeKind.Unspecified), + TimeDisplayMode.ServerTime => + DateTime.SpecifyKind(display.AddMinutes(-utcOffsetMinutes), DateTimeKind.Unspecified), + _ => DateTime.SpecifyKind(display, DateTimeKind.Unspecified), /* UTC — the picker IS naive UTC */ + }; +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/WaitDrillDownWindow.xaml b/Darling/PerformanceMonitor.Darling.Viewer/WaitDrillDownWindow.xaml new file mode 100644 index 000000000..d1f5b76b5 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/WaitDrillDownWindow.xaml @@ -0,0 +1,198 @@ +<Window x:Class="PerformanceMonitor.Darling.Viewer.WaitDrillDownWindow" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + Title="Wait Drill-Down" Width="1400" Height="700" + WindowStartupLocation="CenterOwner" + Icon="EDD.ico" + Background="{DynamicResource BackgroundLightBrush}" + Foreground="{DynamicResource ForegroundBrush}"> + <!-- Ported from Lite's Windows/WaitDrillDownWindow.xaml (the Wait Stats "Show Queries With <wait>" + drill-down). Same three classified views (correlated / uncapturable / chain / filtered) driving the + shared PerformanceMonitor.Ui WaitDrillDownHelper + blocking-chain walker; the read is repointed to + ViewerDataService Postgres (correlated query snapshots). Columns mirror Lite's WaitDrillDownWindow + grid in full, including the #1286 memory-grant / tempdb / transaction triage columns (all verified + present in query_snapshots). One viewer adaptation: Lite's live "Get Actual Plan" is dropped (the + viewer never opens a SqlClient) — "View Plan" shows the stored plan. Dark-styled via the shared + ViewerDarkTheme dictionary with a local NumericCell, mirroring CollectionLogWindow. --> + <Window.Resources> + <ResourceDictionary> + <ResourceDictionary.MergedDictionaries> + <ResourceDictionary Source="ViewerDarkTheme.xaml"/> + </ResourceDictionary.MergedDictionaries> + + <Style x:Key="NumericCell" TargetType="TextBlock"> + <Setter Property="TextAlignment" Value="Right"/> + </Style> + + <ContextMenu x:Key="DataGridContextMenu"> + <MenuItem Header="Copy Cell" Click="CopyCell_Click"> + <MenuItem.Icon><TextBlock Text="📋"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy Row" Click="CopyRow_Click"> + <MenuItem.Icon><TextBlock Text="📄"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy All Rows" Click="CopyAllRows_Click"> + <MenuItem.Icon><TextBlock Text="📑"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <MenuItem Header="Export to CSV..." Click="ExportToCsv_Click"> + <MenuItem.Icon><TextBlock Text="📊"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <MenuItem Header="View Stored Plan" Click="ViewPlan_Click"> + <MenuItem.Icon><TextBlock Text="🔍"/></MenuItem.Icon> + </MenuItem> + <!-- Get Actual Plan (Dashboard parity): asks the SERVICE to RE-EXECUTE the right-clicked snapshot's + query (identifier-only: collection_time + session_id) and float the actual plan. The + informed-consent + data-modification flag runs before it executes. --> + <MenuItem Header="Get Actual Plan (re-run)" Click="GetActualPlan_Click"> + <MenuItem.Icon><TextBlock Text="▶"/></MenuItem.Icon> + </MenuItem> + </ContextMenu> + </ResourceDictionary> + </Window.Resources> + + <Grid Margin="10"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + + <!-- Header --> + <StackPanel Grid.Row="0" Margin="0,0,0,6"> + <TextBlock x:Name="HeaderText" FontSize="16" FontWeight="Bold"/> + <TextBlock x:Name="SummaryText" FontSize="12" Margin="0,4,0,0" Foreground="{DynamicResource ForegroundDimBrush}"/> + </StackPanel> + + <!-- Warning banner (hidden by default) --> + <Border x:Name="WarningBanner" Grid.Row="1" Background="#3D332200" + BorderBrush="#665C3300" BorderThickness="1" CornerRadius="3" + Padding="8,4" Margin="0,0,0,6" Visibility="Collapsed"> + <TextBlock x:Name="WarningText" FontSize="11" Foreground="#FFCC66" TextWrapping="Wrap"/> + </Border> + + <!-- Data Grid --> + <DataGrid Grid.Row="2" x:Name="ResultsDataGrid" + Style="{StaticResource DarkGrid}" + AutoGenerateColumns="False" IsReadOnly="True" + CanUserSortColumns="True" CanUserReorderColumns="True" CanUserResizeColumns="True" + HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" + ContextMenu="{StaticResource DataGridContextMenu}"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding SessionId}" ElementStyle="{StaticResource NumericCell}" Width="55"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SessionId" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="SPID" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CollectionTimeLocal}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CollectionTimeLocal" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Collected" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DatabaseName}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DatabaseName" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Database" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LoginName}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LoginName" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Login" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding HostName}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="HostName" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Host" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ProgramName}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ProgramName" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Program" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Status}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Status" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Status" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ElapsedTimeFormatted}" Width="120" SortMemberPath="TotalElapsedTimeMs"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ElapsedTimeFormatted" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Elapsed" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CpuTimeMs, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CpuTimeMs" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="95"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LogicalReads" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Logical Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Reads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="70"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Reads" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Writes, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="70"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Writes" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Writes" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding WaitType}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="WaitType" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Wait Type" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding WaitTimeMs, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="WaitTimeMs" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Wait (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding WaitResource}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="WaitResource" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Wait Resource" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BlockingSessionId}" ElementStyle="{StaticResource NumericCell}" Width="70"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BlockingSessionId" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Blocking" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BlockedSessionCount}" ElementStyle="{StaticResource NumericCell}" Width="70"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BlockedSessionCount" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Blocked" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Dop}" ElementStyle="{StaticResource NumericCell}" Width="45"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Dop" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="DOP" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ParallelWorkerCount}" ElementStyle="{StaticResource NumericCell}" Width="60"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ParallelWorkerCount" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Workers" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding GrantedQueryMemoryGb, StringFormat=F2}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="GrantedQueryMemoryGb" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Memory (GB)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <!-- #1286 memory-grant / tempdb / transaction triage columns (mirrors Lite's WaitDrillDownWindow) --> + <DataGridTextColumn Binding="{Binding RequestedMemoryMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="RequestedMemoryMb" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Req Mem (MB)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding UsedMemoryMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="UsedMemoryMb" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Used Mem (MB)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxUsedMemoryMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="130"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxUsedMemoryMb" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Max Used Mem (MB)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TempdbCurrentMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TempdbCurrentMb" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="tempdb Cur (MB)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TempdbAllocationsMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TempdbAllocationsMb" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="tempdb Alloc (MB)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TranLogUsedMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="105"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TranLogUsedMb" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Tran Log (MB)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TranStartTimeLocal}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TranStartTimeLocal" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Tran Start" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding RequestId}" ElementStyle="{StaticResource NumericCell}" Width="70"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="RequestId" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Req ID" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TransactionIsolationLevel}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TransactionIsolationLevel" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Isolation" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding OpenTransactionCount}" ElementStyle="{StaticResource NumericCell}" Width="70"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="OpenTransactionCount" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Open Tran" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PercentComplete, StringFormat=F1}" ElementStyle="{StaticResource NumericCell}" Width="70"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PercentComplete" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="% Done" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding QueryHash}" Width="150"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="QueryHash" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Query Hash" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTemplateColumn Width="500"> + <DataGridTemplateColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="QueryText" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Query Text" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTemplateColumn.Header> + <DataGridTemplateColumn.CellTemplate> + <DataTemplate> + <TextBlock Text="{Binding QueryText}" TextWrapping="Wrap" MaxHeight="90" Margin="5,2" + ToolTip="{Binding QueryText}"/> + </DataTemplate> + </DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + </DataGrid.Columns> + </DataGrid> + + <!-- Footer --> + <StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0"> + <Button Content="Close" Padding="20,6" Click="Close_Click"/> + </StackPanel> + </Grid> +</Window> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/WaitDrillDownWindow.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/WaitDrillDownWindow.xaml.cs new file mode 100644 index 000000000..211ce5d22 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/WaitDrillDownWindow.xaml.cs @@ -0,0 +1,424 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Media; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; +using static PerformanceMonitor.Ui.WaitDrillDownHelper; + +namespace PerformanceMonitor.Darling.Viewer; + +/// <summary> +/// The Wait Stats "Show Queries With <wait>" drill-down window — ported from Lite's +/// <c>Windows/WaitDrillDownWindow.xaml.cs</c>. It classifies the wait via the shared +/// <see cref="WaitDrillDownHelper.Classify"/> into one of: a correlated / uncapturable view (all queries +/// active in the window, sorted by the most correlated metric), a lock-chain view (head blockers, via the +/// shared <see cref="WaitDrillDownHelper.WalkBlockingChains"/>), or a direct wait-type filter. The rows are +/// correlated query snapshots read from Postgres (<see cref="ViewerDataService.GetQuerySnapshotsByWaitTypeAsync"/> +/// / <see cref="ViewerDataService.GetLatestQuerySnapshotsAsync"/>). The only Lite affordance dropped is the +/// live "Get Actual Plan" (the viewer never opens a SqlClient); "View Plan" shows the stored plan in a shared +/// <see cref="PlanViewerControl"/> floated above this window. +/// </summary> +public partial class WaitDrillDownWindow : Window +{ + private readonly ViewerDataService _dataService; + private readonly int _serverId; + private readonly string _waitType; + private readonly DateTime _fromUtc; + private readonly DateTime _toUtc; + + private readonly DataGridFilterManager<ViewerQuerySnapshotRow> _filterManager; + private Popup? _filterPopup; + private ColumnFilterPopup? _filterPopupContent; + private System.Threading.CancellationTokenSource? _actualPlanCts; + + public WaitDrillDownWindow( + ViewerDataService dataService, int serverId, string waitType, DateTime fromUtc, DateTime toUtc) + { + InitializeComponent(); + _dataService = dataService; + _serverId = serverId; + _waitType = waitType; + _fromUtc = fromUtc; + _toUtc = toUtc; + + _filterManager = new DataGridFilterManager<ViewerQuerySnapshotRow>(ResultsDataGrid); + + Title = $"Wait Drill-Down: {waitType}"; + + var classification = Classify(waitType); + HeaderText.Text = classification.Category == WaitCategory.Correlated + ? $"Queries active during {waitType} spike" + : $"Queries experiencing {waitType}"; + + Loaded += async (_, _) => await LoadDataAsync(); + ThemeManager.ThemeChanged += OnThemeChanged; + Closed += (_, _) => { ThemeManager.ThemeChanged -= OnThemeChanged; _actualPlanCts?.Cancel(); }; + } + + private async Task LoadDataAsync() + { + SummaryText.Text = "Loading..."; + try + { + var classification = Classify(_waitType); + SetWarningBanner(classification); + + if (classification.Category == WaitCategory.Chain) + { + await LoadChainDataAsync(classification); + } + else if (classification.Category is WaitCategory.Correlated or WaitCategory.Uncapturable) + { + await LoadCorrelatedDataAsync(classification); + } + else + { + await LoadDirectDataAsync(classification); + } + } + catch (Exception ex) + { + SummaryText.Text = $"Error: {ex.Message}"; + } + } + + private async Task LoadDirectDataAsync(WaitClassification classification) + { + var data = await _dataService.GetQuerySnapshotsByWaitTypeAsync(_serverId, _waitType, _fromUtc, _toUtc); + if (data.Count == 0) + { + SummaryText.Text = $"No query-level data found for {_waitType} in the selected time range."; + return; + } + + data = SortByProperty(data, classification.SortProperty); + _filterManager.UpdateData(data); + SummaryText.Text = $"{data.Count} snapshot(s) | {classification.Description} | {GetTimeRangeDescription(data)}"; + ApplyInitialSort(classification.SortProperty); + } + + private async Task LoadCorrelatedDataAsync(WaitClassification classification) + { + /* Fetch ALL queries in the range (no wait-type filter) — the correlated waits are too brief to + show as the waiter's own wait_type. */ + var data = await _dataService.GetLatestQuerySnapshotsAsync(_serverId, _fromUtc, _toUtc); + if (data.Count == 0) + { + SummaryText.Text = "No query snapshots found in the selected time range."; + return; + } + + data = SortByProperty(data, classification.SortProperty); + _filterManager.UpdateData(data); + SummaryText.Text = $"{data.Count} snapshot(s) | {classification.Description} | {GetTimeRangeDescription(data)}"; + ApplyInitialSort(classification.SortProperty); + } + + private async Task LoadChainDataAsync(WaitClassification classification) + { + var waiters = await _dataService.GetQuerySnapshotsByWaitTypeAsync(_serverId, _waitType, _fromUtc, _toUtc); + if (waiters.Count == 0) + { + SummaryText.Text = $"No query-level data found for {_waitType} in the selected time range."; + return; + } + + var allSnapshots = await _dataService.GetLatestQuerySnapshotsAsync(_serverId, _fromUtc, _toUtc); + + var headBlockerInfos = WalkBlockingChains(waiters.Select(ToSnapshotInfo), allSnapshots.Select(ToSnapshotInfo)); + + if (headBlockerInfos.Count == 0) + { + _filterManager.UpdateData(waiters); + SummaryText.Text = $"{waiters.Count} snapshot(s) | {classification.Description} | {GetTimeRangeDescription(waiters)} | No blocking chains found, showing waiters"; + return; + } + + /* Look up the original full rows for each head blocker and stamp the chain metadata. */ + var snapshotLookup = allSnapshots + .GroupBy(s => (s.CollectionTime, s.SessionId)) + .ToDictionary(g => g.Key, g => g.First()); + + var headBlockerRows = new List<ViewerQuerySnapshotRow>(); + foreach (var hb in headBlockerInfos) + { + if (snapshotLookup.TryGetValue((hb.CollectionTime, hb.SessionId), out var row)) + { + row.BlockedSessionCount = hb.BlockedSessionCount; + row.ChainBlockingPath = hb.BlockingPath; + headBlockerRows.Add(row); + } + } + + if (headBlockerRows.Count == 0) + { + _filterManager.UpdateData(waiters); + SummaryText.Text = $"{waiters.Count} snapshot(s) | {classification.Description} | {GetTimeRangeDescription(waiters)} | Head blockers not in snapshots, showing waiters"; + return; + } + + InsertChainColumn(); + _filterManager.UpdateData(headBlockerRows); + SummaryText.Text = $"{headBlockerRows.Count} head blocker(s) from {waiters.Count} waiting session(s) | {classification.Description} | {GetTimeRangeDescription(headBlockerRows)}"; + } + + /// <summary>Inserts the "Blocking Path" chain column at the front (Lite's InsertChainColumns — + /// "Blocked" is already a static column here).</summary> + private void InsertChainColumn() + { + var filterButton = new Button { Tag = "ChainBlockingPath", Margin = new Thickness(0, 0, 4, 0) }; + filterButton.SetResourceReference(StyleProperty, "ColumnFilterButtonStyle"); + filterButton.Click += Filter_Click; + + var header = new StackPanel { Orientation = Orientation.Horizontal }; + header.Children.Add(filterButton); + header.Children.Add(new TextBlock { Text = "Blocking Path", FontWeight = FontWeights.Bold, VerticalAlignment = VerticalAlignment.Center }); + + ResultsDataGrid.Columns.Insert(0, new DataGridTextColumn + { + Header = header, + Binding = new System.Windows.Data.Binding("ChainBlockingPath"), + Width = new DataGridLength(250), + }); + } + + private void SetWarningBanner(WaitClassification classification) + { + if (classification.Category == WaitCategory.Uncapturable) + { + WarningText.Text = $"Sessions experiencing {_waitType} waits may not be captured in query snapshots " + + "because they may lack assigned worker threads. Showing all queries in this time range."; + WarningBanner.Visibility = Visibility.Visible; + } + else if (classification.Category == WaitCategory.Correlated) + { + WarningText.Text = $"{_waitType} waits are too brief to appear in query snapshots. " + + "Showing all queries active during this period, sorted by the most correlated metric."; + ShowInfoBanner(); + } + else if (classification.Category == WaitCategory.Chain) + { + WarningText.Text = $"Showing head blockers (the cause of {_waitType} waits), not the waiting sessions themselves."; + ShowInfoBanner(); + } + } + + private void ShowInfoBanner() + { + WarningBanner.Visibility = Visibility.Visible; + WarningBanner.Background = new SolidColorBrush(Color.FromArgb(0x3D, 0x00, 0x33, 0x66)); + WarningBanner.BorderBrush = new SolidColorBrush(Color.FromArgb(0x66, 0x00, 0x55, 0x99)); + WarningText.Foreground = new SolidColorBrush(Color.FromRgb(0x66, 0xBB, 0xFF)); + } + + private static SnapshotInfo ToSnapshotInfo(ViewerQuerySnapshotRow row) => new() + { + SessionId = row.SessionId, + BlockingSessionId = row.BlockingSessionId, + CollectionTime = row.CollectionTime, + DatabaseName = row.DatabaseName, + Status = row.Status, + QueryText = row.QueryText, + WaitType = row.WaitType, + WaitTimeMs = row.WaitTimeMs, + CpuTimeMs = row.CpuTimeMs, + Reads = row.Reads, + Writes = row.Writes, + LogicalReads = row.LogicalReads, + }; + + /// <summary>Sorts the rows by the classified metric (Lite's SortByProperty). Pure, unit-tested.</summary> + internal static List<ViewerQuerySnapshotRow> SortByProperty(List<ViewerQuerySnapshotRow> data, string property) => + property switch + { + "CpuTimeMs" => data.OrderByDescending(r => r.CpuTimeMs).ToList(), + "Reads" => data.OrderByDescending(r => r.Reads).ToList(), + "Writes" => data.OrderByDescending(r => r.Writes).ToList(), + "Dop" => data.OrderByDescending(r => r.Dop).ToList(), + "GrantedQueryMemoryGb" => data.OrderByDescending(r => r.GrantedQueryMemoryGb).ToList(), + "WaitTimeMs" => data.OrderByDescending(r => r.WaitTimeMs).ToList(), + _ => data, + }; + + private void ApplyInitialSort(string property) + { + var columnHeader = property switch + { + "CpuTimeMs" => "CPU (ms)", + "Reads" => "Reads", + "Writes" => "Writes", + "Dop" => "DOP", + "GrantedQueryMemoryGb" => "Memory (GB)", + "WaitTimeMs" => "Wait (ms)", + _ => null, + }; + if (columnHeader == null) return; + + foreach (var column in ResultsDataGrid.Columns) + { + if (column.Header is StackPanel sp && + sp.Children.OfType<TextBlock>().FirstOrDefault()?.Text == columnHeader) + { + column.SortDirection = ListSortDirection.Descending; + break; + } + } + } + + private string GetTimeRangeDescription(List<ViewerQuerySnapshotRow> data) + { + if (data.Count == 0) return ""; + var first = ViewerTimeHelper.ForDisplay(data.Min(r => r.CollectionTime)); + var last = ViewerTimeHelper.ForDisplay(data.Max(r => r.CollectionTime)); + return $"{first:yyyy-MM-dd HH:mm:ss} to {last:yyyy-MM-dd HH:mm:ss}"; + } + + private void OnThemeChanged(string _) => _filterManager.UpdateFilterButtonStyles(); + + // ── Column Filter Popup (mirrors ViewerServerTab.Filters.cs) ── + + private void EnsureFilterPopup() + { + if (_filterPopup == null) + { + _filterPopupContent = new ColumnFilterPopup(); + _filterPopup = new Popup + { + Child = _filterPopupContent, + StaysOpen = false, + Placement = PlacementMode.Bottom, + AllowsTransparency = true, + }; + } + } + + private void Filter_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button button || button.Tag is not string columnName) return; + + EnsureFilterPopup(); + + _filterPopupContent!.FilterApplied -= FilterPopup_FilterApplied; + _filterPopupContent.FilterCleared -= FilterPopup_FilterCleared; + _filterPopupContent.FilterApplied += FilterPopup_FilterApplied; + _filterPopupContent.FilterCleared += FilterPopup_FilterCleared; + + _filterManager.Filters.TryGetValue(columnName, out var existingFilter); + _filterPopupContent.Initialize(columnName, existingFilter); + + _filterPopup!.PlacementTarget = button; + _filterPopup.IsOpen = true; + } + + private void FilterPopup_FilterApplied(object? sender, FilterAppliedEventArgs e) + { + if (_filterPopup != null) _filterPopup.IsOpen = false; + _filterManager.SetFilter(e.FilterState); + } + + private void FilterPopup_FilterCleared(object? sender, EventArgs e) + { + if (_filterPopup != null) _filterPopup.IsOpen = false; + } + + // ── Copy / Export / Plan ── + + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "wait_drill_down", ViewerExportSettings.CsvSeparator); + + /// <summary> + /// "View Plan" — shows the row's stored estimated/live plan in a shared <see cref="PlanViewerControl"/> + /// floated above this window (Lite showed a PlanViewerWindow; Darling reuses the shared + /// <see cref="GraphViewerWindow"/> host). No live "Get Actual Plan" — the viewer never re-executes. + /// </summary> + private async void ViewPlan_Click(object sender, RoutedEventArgs e) + { + if ((ResultsDataGrid.CurrentItem ?? ResultsDataGrid.SelectedItem) is not ViewerQuerySnapshotRow row) return; + + /* Prefer the stored ACTUAL (live) plan; fall through to the estimated plan on null OR empty (an empty + live_query_plan is "not captured", not a plan). Label names whichever one is actually shown — + mirrors the Active Queries grid's separate Estimated / Live buttons. */ + var isActual = !string.IsNullOrEmpty(row.LiveQueryPlan); + var planXml = isActual ? row.LiveQueryPlan : row.QueryPlan; + if (string.IsNullOrEmpty(planXml)) + { + MessageBox.Show(this, + "No execution plan was captured for this snapshot row.", + "No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + var label = $"{(isActual ? "Live" : "Est")} Plan - SPID {row.SessionId}"; + var viewer = new PlanViewerControl(); + try + { + await viewer.LoadPlan(planXml, label, row.QueryText); + } + catch (Exception ex) + { + viewer.Cleanup(); + MessageBox.Show(this, + $"Failed to load the execution plan:\n\n{ex.Message}", + "Plan Load Error", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + /* GraphViewerWindow only auto-Cleanup()s IGraphViewer content; PlanViewerControl is not one, so + unsubscribe it from ThemeManager on close explicitly. */ + var window = GraphViewerWindow.ShowGraph(this, viewer, label); + window.Closed += (_, _) => viewer.Cleanup(); + } + + /// <summary>"Get Actual Plan" — asks the SERVICE to RE-EXECUTE the RIGHT-CLICKED snapshot's captured query + /// (SET STATISTICS XML) and floats the captured actual plan. Identifier-only: the service resolves the text + /// from query_snapshots by (collection_time, session_id). The snapshot carries its plan in-row, used for the + /// data-modification detection. Shared consent + flag + read-only guard via <see cref="ViewerActualPlanFlow"/>. + /// Re-executing a snapshot's mid-flight statement carries the usual side-effect risk — the consent gate owns it.</summary> + private async void GetActualPlan_Click(object sender, RoutedEventArgs e) + { + if ((ResultsDataGrid.CurrentItem ?? ResultsDataGrid.SelectedItem) is not ViewerQuerySnapshotRow row) return; + if (string.IsNullOrWhiteSpace(row.QueryText)) + { + MessageBox.Show(this, "No query text was captured for this snapshot, so its actual plan cannot be re-executed.", + "No Query Text", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + /* The snapshot carries its plan in-row — use it for modification detection (the service re-resolves the + query text by the (collection_time, session_id) key when it executes). */ + var estimatedPlanXml = row.LiveQueryPlan ?? row.QueryPlan; + var argsJson = ViewerDataService.BuildActualPlanArgsForSnapshot(row.CollectionTime, row.SessionId, row.DatabaseName); + var label = $"Actual Plan - SPID {row.SessionId}"; + + _actualPlanCts?.Dispose(); + _actualPlanCts = new System.Threading.CancellationTokenSource(); + + var planXml = await ViewerActualPlanFlow.RequestActualPlanAsync( + this, _dataService, _serverId, "the monitored server", row.DatabaseName, row.QueryText, estimatedPlanXml, argsJson, + onStarted: () => System.Windows.Input.Mouse.OverrideCursor = System.Windows.Input.Cursors.Wait, + onFinished: () => System.Windows.Input.Mouse.OverrideCursor = null, + _actualPlanCts.Token); + + if (planXml != null) + await ViewerActualPlanFlow.OpenFloatingPlanAsync(this, planXml, label, row.QueryText); + } + + private void Close_Click(object sender, RoutedEventArgs e) => Close(); +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/app.manifest b/Darling/PerformanceMonitor.Darling.Viewer/app.manifest new file mode 100644 index 000000000..a3dc09222 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/app.manifest @@ -0,0 +1,49 @@ +<?xml version="1.0" encoding="utf-8"?> +<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1"> + <assemblyIdentity version="1.0.0.0" name="PerformanceMonitorLite.app"/> + + <trustInfo xmlns="urn:schemas-microsoft-com:asm.v2"> + <security> + <requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3"> + <!-- UAC Manifest Options + If you want to change the Windows User Account Control level replace the + requestedExecutionLevel node with one of the following. + + <requestedExecutionLevel level="asInvoker" uiAccess="false" /> + <requestedExecutionLevel level="requireAdministrator" uiAccess="false" /> + <requestedExecutionLevel level="highestAvailable" uiAccess="false" /> + + Specifying requestedExecutionLevel element will disable file and registry virtualization. + Remove this element if your application requires this virtualization for backwards + compatibility. + --> + <requestedExecutionLevel level="asInvoker" uiAccess="false" /> + </requestedPrivileges> + </security> + </trustInfo> + + <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"> + <application> + <!-- A list of the Windows versions that this application has been tested on + and is designed to work with. Uncomment the appropriate elements + and Windows will automatically select the most compatible environment. --> + + <!-- Windows 10 and Windows 11 --> + <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" /> + </application> + </compatibility> + + <!-- Indicates that the application is DPI-aware and will not be automatically scaled by Windows at higher + DPIs. Windows Presentation Foundation (WPF) applications are automatically DPI-aware and do not need + to opt in. Windows Forms applications targeting .NET Framework 4.6 that opt into this setting, should + also set the 'EnableWindowsFormsHighDpiAutoResizing' setting to 'true' in their app.config. + + Makes the application long-path aware. See https://docs.microsoft.com/windows/win32/fileio/maximum-file-path-limitation --> + <application xmlns="urn:schemas-microsoft-com:asm.v3"> + <windowsSettings> + <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware> + <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> + <longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware> + </windowsSettings> + </application> +</assembly> diff --git a/Darling/PerformanceMonitor.Darling.Viewer/packages.lock.json b/Darling/PerformanceMonitor.Darling.Viewer/packages.lock.json new file mode 100644 index 000000000..b124b019f --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/packages.lock.json @@ -0,0 +1,494 @@ +{ + "version": 1, + "dependencies": { + "net10.0-windows7.0": { + "Hardcodet.NotifyIcon.Wpf": { + "type": "Direct", + "requested": "[2.0.1, )", + "resolved": "2.0.1", + "contentHash": "dtxmeZXzV2GzSm91aZ3hqzgoeVoARSkDPVCYfhVUNyyKBWYxMgNC0EcLiSYxD4Uc4alq/2qb3SmV8DgAENLRLQ==" + }, + "ScottPlot.WPF": { + "type": "Direct", + "requested": "[5.1.58, )", + "resolved": "5.1.58", + "contentHash": "BL6MecJPyhllqSYsM5N9SfrrVEdgkRSyDu3lYSeiFq4dGgS0obuoGEbRiXgJq5PAZWixo+6Hcxaz5OEwElC8aQ==", + "dependencies": { + "OpenTK": "4.9.4", + "OpenTK.GLWpfControl": "4.3.3", + "ScottPlot": "5.1.58", + "SkiaSharp.Views.WPF": "3.119.0" + } + }, + "Velopack": { + "type": "Direct", + "requested": "[1.2.0, )", + "resolved": "1.2.0", + "contentHash": "Rz67gJL619fSBS6omaSINUxyDuwhIxkm5mmubf7uLd5Qgi6LLKaKCha+QFP6n+Bw/UjA0vutnH4JQfYzn6ANtw==" + }, + "CredentialManagement": { + "type": "Transitive", + "resolved": "1.0.2", + "contentHash": "VkP04/jFXaxT3TkcRhzETYtOrznQxRmQ2J1XJdbXz47Bir7hIzPR7mFZk4GJQ4An4gozW+vonpf+iqTHomAkQw==" + }, + "HarfBuzzSharp": { + "type": "Transitive", + "resolved": "8.3.1.1", + "contentHash": "tLZN66oe/uiRPTZfrCU4i8ScVGwqHNh5MHrXj0yVf4l7Mz0FhTGnQ71RGySROTmdognAs0JtluHkL41pIabWuQ==", + "dependencies": { + "HarfBuzzSharp.NativeAssets.Win32": "8.3.1.1", + "HarfBuzzSharp.NativeAssets.macOS": "8.3.1.1" + } + }, + "HarfBuzzSharp.NativeAssets.Linux": { + "type": "Transitive", + "resolved": "8.3.1.1", + "contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg==" + }, + "HarfBuzzSharp.NativeAssets.macOS": { + "type": "Transitive", + "resolved": "8.3.1.1", + "contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ==" + }, + "HarfBuzzSharp.NativeAssets.Win32": { + "type": "Transitive", + "resolved": "8.3.1.1", + "contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA==" + }, + "Microsoft.Bcl.Cryptography": { + "type": "Transitive", + "resolved": "9.0.13", + "contentHash": "5T+bH3Lb1nEe8Hf/ixMxLmhlrx5wRi53wv7OhVwG2F1ZviW1ejFRS1NHur3uqPpJRGtkQwUchtY6zhVK2R+v+w==" + }, + "Microsoft.Data.SqlClient": { + "type": "Transitive", + "resolved": "7.0.1", + "contentHash": "9jZFXAJ2ThNYK7lhj2RhH7klXVNaWSvZpQncq3bPIOjmHBrdjwgeO4c8wucUVxQwFT8rAA13Z2F2jzoYR7ICDw==", + "dependencies": { + "Microsoft.Bcl.Cryptography": "9.0.13", + "Microsoft.Data.SqlClient.Extensions.Abstractions": "1.0.0", + "Microsoft.Data.SqlClient.Internal.Logging": "1.0.0", + "Microsoft.Data.SqlClient.SNI.runtime": "6.0.2", + "Microsoft.Extensions.Caching.Memory": "9.0.13", + "Microsoft.IdentityModel.JsonWebTokens": "8.16.0", + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.16.0", + "Microsoft.SqlServer.Server": "1.0.0" + } + }, + "Microsoft.Data.SqlClient.Extensions.Abstractions": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "rlnxc0KfwDSbE8ZHntFnl8SCgOa9QtJZblMv2zXLhRwl1Je7fsdsVzxSjzzC4JMsfAK+jXJWyezRB8SxUY4BdA==", + "dependencies": { + "Microsoft.Data.SqlClient.Internal.Logging": "1.0.0" + } + }, + "Microsoft.Data.SqlClient.Internal.Logging": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "Kue/7CF8KNT9zozfr30C94dMZVZml3atqWZvQemSXvTau76tRdypzeKiBKXadqgbOME0UiQIyVTNo5WxCRNVNg==" + }, + "Microsoft.Data.SqlClient.SNI.runtime": { + "type": "Transitive", + "resolved": "6.0.2", + "contentHash": "f+pRODTWX7Y67jXO3T5S2dIPZ9qMJNySjlZT/TKmWVNWe19N8jcWmHaqHnnchaq3gxEKv1SWVY5EFzOD06l41w==" + }, + "Microsoft.Extensions.AI.Abstractions": { + "type": "Transitive", + "resolved": "10.5.2", + "contentHash": "Ei+YWV9Ybnps7pR1dgjlG29gelXEwZkhLVAcWmKe6HvXS6LNBYgSdWiY3Hk9OZXYtK34rv/NtLWBQYQGOBQYPQ==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "pUDgQKEqNUFlerDIFRg7zzoDVRPEWIG7nR40h8Gzg8RXza4Ry0lWZ7u91bmwu3iUDCxw3Dv6TLHVFoAgY0gy7Q==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.7" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "9.0.13", + "contentHash": "OdQmN8LYcUEu20Fxii9mk68nHJGL+JPXF3w0+hxenf0oDDdDBA+ZV/S92FmIgAWAElowIiFA/g0x+8YB1g80Hg==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "9.0.13", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.13", + "Microsoft.Extensions.Logging.Abstractions": "9.0.13", + "Microsoft.Extensions.Options": "9.0.13", + "Microsoft.Extensions.Primitives": "9.0.13" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "t56nEgvECcyLPojZIUFWJknQQDAbgfTf9J+QMYJE1YYvVgz69vN6B/AKL8Grvj3Lcnp8kTpNqwmwFhb3YLJmtQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.7" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "g41l/30G3K4B/d/L8kjux0+30e27c8D0FVQ/PFCpbekgfDpj9mnDhieP67EqXWvl1EWNeZh2rpR4F5B/jcDOHA==" + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "uJ9JP677y+uy+C0vtaSfi7XXgFAdz8DhU3M9lwwIXDfQKcyQ0yxM9DVYa0NXDtdVTYA2eBUtVFZ8LY0GCdeE/w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", + "Microsoft.Extensions.Options": "10.0.7" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "teioDgVpi8L186wUfrXQV1YuBt6lCSPmFZiMZo53+FZxHFjOV+f4GXo4LXgJ273Mku9//AdXWVjk9J7eJP6inw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.7" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "5s8d6qC6EA8UOI4wR/+zlsq7SXttJMRb9d7zvVZ7+bE3CQEfVtC9ITUDCommm87R1zzj6WJBbCnztuIJXnP3DA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.7", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.7", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.7", + "Microsoft.Extensions.Logging.Abstractions": "10.0.7" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "9S/DFt4cohlMPpzIxjG6kk0L8MuN2vDm9pbMCulxtJzzk82oJHVLBd8vuQxaPskaYQwKqmFmbannf5eoChgjYg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "00SHUGTh2jSMvIr6x9Xwd2nE+B5/qFCO/9hDwUDhJsjYRDlADmaBZ7tqehXzBDsfjHSXJzuRHJzPYPPjphBQ7Q==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.7", + "Microsoft.Extensions.Primitives": "10.0.7" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.7", + "contentHash": "D5M0Jr551iTgwkZMN9rm0pSkgNLj5quUWQUmQPMZh7k/bnvZTnXRGfE2KuvXf1EEjt/ofD9yw9IumpgdP9QCnw==" + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "gSxKLWRZzBpIsEoeUPkxfywNCCvRvl7hkq146XHPk5vOQc9izSf1I+uL1vh4y2U19QPxd9Z8K/8AdWyxYz2lSg==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "prBU72cIP4V8E9fhN+o/YdskTsLeIcnKPbhZf0X6mD7fdxoZqnS/NdEkSr+9Zp+2q7OZBOMfNBKGbTbhXODO4w==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.16.0" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "MTzXmETkNQPACR7/XCXM1OGM6oU9RkyibqeJRtO9Ndew2LnGjMf9Atqj2VSf4XC27X0FQycUAlzxxEgQMWn2xQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.16.0" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "UFrU7d46UTsPQTa2HIEIpB9H1uJe1BW9FLw5uhEJ2ZuKdur8bcUA/bO5caq5dlBt5gNJeRIB3QQXYNs5fCQCZA==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.16.0" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "h4yVXyJsEBBX5lg2G5ftMsi5JzcNEGAzrNphA6DQ6eOd8P0s+cDCOyPwVTYLePZvJL5unbPvYIvzrbTXzFjXnQ==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "8.16.0", + "System.IdentityModel.Tokens.Jwt": "8.16.0" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "rtViGJcGsN7WcfUNErwNeQgjuU5cJNl6FDQsfi9TncwO+Epzn0FTfBsg3YuFW1Q0Ch/KPxaVdjLw3/+5Z5ceFQ==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.IdentityModel.Logging": "8.16.0" + } + }, + "Microsoft.SqlServer.Server": { + "type": "Transitive", + "resolved": "1.0.0", + "contentHash": "N4KeF3cpcm1PUHym1RmakkzfkEv3GRMyofVv40uXsQhCQeglr2OHNcUk2WOG51AKpGO8ynGpo9M/kFXSzghwug==" + }, + "ModelContextProtocol": { + "type": "Transitive", + "resolved": "1.4.0", + "contentHash": "CAmnAMQIMax2t9naUgyDAkVBj329EhQCiHcbfirMFNgP6ShQ8hJdJb0OLoaBpeGjkunH3IOjRXLdwXGKKwMlLA==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.7", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.7", + "ModelContextProtocol.Core": "1.4.0" + } + }, + "ModelContextProtocol.Core": { + "type": "Transitive", + "resolved": "1.4.0", + "contentHash": "6ZJFQTgYwdu0IacUUTNExAY2Z+JFuTd10CPeORalQtuGPY4hdUqq0BXxTpNEP0n7ajruNsGXyCKQLP0erIZmog==", + "dependencies": { + "Microsoft.Extensions.AI.Abstractions": "10.5.2", + "Microsoft.Extensions.Logging.Abstractions": "10.0.7" + } + }, + "Npgsql": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "OpenTK": { + "type": "Transitive", + "resolved": "4.9.4", + "contentHash": "fp9ZI6akO4ZuIGzuUE0QRU5zRJhXbSXa5racehFoTdvodhRDecXCOcH68mRsZaMwnxLO9ECnf2LGM9BmiJKvhw==", + "dependencies": { + "OpenTK.Audio.OpenAL": "4.9.4", + "OpenTK.Compute": "4.9.4", + "OpenTK.Core": "4.9.4", + "OpenTK.Graphics": "4.9.4", + "OpenTK.Input": "4.9.4", + "OpenTK.Mathematics": "4.9.4", + "OpenTK.Windowing.Common": "4.9.4", + "OpenTK.Windowing.Desktop": "4.9.4", + "OpenTK.Windowing.GraphicsLibraryFramework": "4.9.4" + } + }, + "OpenTK.Audio.OpenAL": { + "type": "Transitive", + "resolved": "4.9.4", + "contentHash": "N/SCeFrLJ3ckUbshyfsonbtjmDxh55urGiR+Fe1iPkTWyEW5OKfQRoIOZyjEva/4eRqOXdt3bV2ka4IJ2+JvZw==", + "dependencies": { + "OpenTK.Core": "[4.9.4, 4.10.0)", + "OpenTK.Mathematics": "[4.9.4, 4.10.0)" + } + }, + "OpenTK.Compute": { + "type": "Transitive", + "resolved": "4.9.4", + "contentHash": "30G+/9mtrUJwX5xcRyz2qcujQ2s+wDm+C7qg6jDsLyGWUdND3j/vD+nlQ6tuKuAow51zWduFVujPkaS8VrBinQ==" + }, + "OpenTK.Core": { + "type": "Transitive", + "resolved": "4.9.4", + "contentHash": "/4Mn3ABf1xNfHMzDFUapVY2K4PG3oP/oWTQJlK0JacT20HwsNe/HPIAhnAJWz27EM3ukOJDtqyQRukvQKqkcLw==" + }, + "OpenTK.GLWpfControl": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "mbKt+ZApqYk/Ym4Ph0dnI+hfSnyXn/gWW6Ys6l3C+fMlgXLYaWpbQ+/3yNm6G1lJ3vteXGxtoMJPFYlaXi/tQw==", + "dependencies": { + "OpenTK": "[4.8.2, 5.0.0)" + } + }, + "OpenTK.Graphics": { + "type": "Transitive", + "resolved": "4.9.4", + "contentHash": "yZeq5swIsRnNzifVA9fLtl+qMAQvmYiF1b7U/XQLARmmU4PYQ23lPNahqAdD22XzA21ew7eG3yDyPLkYw+hDBA==", + "dependencies": { + "OpenTK.Core": "[4.9.4, 4.10.0)", + "OpenTK.Mathematics": "[4.9.4, 4.10.0)" + } + }, + "OpenTK.Input": { + "type": "Transitive", + "resolved": "4.9.4", + "contentHash": "njbC7fySQEK30rjOQ1uRjlycEkLw+UVoPT3nRQ8uBPNLotDgr47NEcPEjmJyX1ll+pm/wmtcvJIYHBRi1HbOqg==" + }, + "OpenTK.Mathematics": { + "type": "Transitive", + "resolved": "4.9.4", + "contentHash": "2ucF25RVJzdSLXUHgjAgB058gVfSgerrR5pLCn9J+Bnyqi45NrBfPu9wyTT35ZCjYwLJCu/HJMnzKFLjq+uFIA==" + }, + "OpenTK.redist.glfw": { + "type": "Transitive", + "resolved": "3.4.0.44", + "contentHash": "HEjbdk0wWxSRrXHl3DSmrA8trecndgJpAHHloJJ1vPseNfeu/ynmrH/LturU1KguRn4r3IzZ81UIp5xRjnyahg==" + }, + "OpenTK.Windowing.Common": { + "type": "Transitive", + "resolved": "4.9.4", + "contentHash": "xNjvKjoptAh6w5/YYIx/9sNv3/A2VVlTahlekTlLP+qfNyig+0jBuLHO4bWELWZ7Ta3Y1m/2Pvk0Z1KCG8VZOA==", + "dependencies": { + "OpenTK.Core": "[4.9.4, 4.10.0)", + "OpenTK.Mathematics": "[4.9.4, 4.10.0)" + } + }, + "OpenTK.Windowing.Desktop": { + "type": "Transitive", + "resolved": "4.9.4", + "contentHash": "LBX4jwCneISIBST2vWYLI8cdR8tLwHNcswK1decElbTAXMB+H6uFIoF41ld+af4q3TsHpjR5fyP2ilbTK3T0rA==", + "dependencies": { + "OpenTK.Core": "[4.9.4, 4.10.0)", + "OpenTK.Mathematics": "[4.9.4, 4.10.0)", + "OpenTK.Windowing.Common": "[4.9.4, 4.10.0)", + "OpenTK.Windowing.GraphicsLibraryFramework": "[4.9.4, 4.10.0)" + } + }, + "OpenTK.Windowing.GraphicsLibraryFramework": { + "type": "Transitive", + "resolved": "4.9.4", + "contentHash": "wNlxnPrD7veMV8EGTTi/sCDeg5V8w/18vC0aw0Ao69eNneD7YSq9Ad+C389rVA957eHNR6Xieu/5qzHQeEOyxQ==", + "dependencies": { + "OpenTK.Core": "[4.9.4, 4.10.0)", + "OpenTK.redist.glfw": "3.4.0.44" + } + }, + "ScottPlot": { + "type": "Transitive", + "resolved": "5.1.58", + "contentHash": "prikNmFbV+z2ruXYmE06yYlnHQfuYzhPrrj30SvcUkRXfdiC07A+2LBSYhuSMaRFCHvgRKo6gXjfwHzhOW8ASg==", + "dependencies": { + "HarfBuzzSharp.NativeAssets.Linux": "8.3.1.1", + "SkiaSharp": "3.119.0", + "SkiaSharp.HarfBuzz": "3.119.0", + "SkiaSharp.NativeAssets.Linux.NoDependencies": "3.119.0" + } + }, + "SkiaSharp": { + "type": "Transitive", + "resolved": "3.119.0", + "contentHash": "gR9yVoOta2Mc1Rxt15LD65AckfHMfwjIs/3kkD59C9bT2nYYISsE6uz3t4aMPNHA6CgsIL0Ssn+jE5OVilZ1yw==", + "dependencies": { + "SkiaSharp.NativeAssets.Win32": "3.119.0", + "SkiaSharp.NativeAssets.macOS": "3.119.0" + } + }, + "SkiaSharp.HarfBuzz": { + "type": "Transitive", + "resolved": "3.119.0", + "contentHash": "5ocGzl+RYEZFnUPUbModshnEFvKTku4hTtOBsLvy5MCK2KAHjq5Nw2+uAVUx79Rjh0z8fTojT6ZVav8MJgW0ug==", + "dependencies": { + "HarfBuzzSharp": "8.3.1.1", + "SkiaSharp": "3.119.0" + } + }, + "SkiaSharp.NativeAssets.Linux.NoDependencies": { + "type": "Transitive", + "resolved": "3.119.0", + "contentHash": "e92vdqf1VOETPjy1T67Fs1zPxfGMM1nbrpt69GM5foXSI/iIbq6L9avPz/bl/DbWtb81D0yF/NKjRmXuOZoLcg==" + }, + "SkiaSharp.NativeAssets.macOS": { + "type": "Transitive", + "resolved": "3.119.0", + "contentHash": "YE1vNn0Nyw2PWtv7hw1PYkKJO0itFiQp9vSqGppZUKzQJqwp28a2jgdCMPfYtOiR8KCnDgZqQoynqJRRaE2ZVg==" + }, + "SkiaSharp.NativeAssets.Win32": { + "type": "Transitive", + "resolved": "3.119.0", + "contentHash": "IwC9yx36lOdXVT2DjgmWHl1qkVspfj8ctd4+li8CNnvqdfaTolXCOh6TLznURcPAvzatx9K/tLOB7zT6T8EA9w==" + }, + "SkiaSharp.Views.Desktop.Common": { + "type": "Transitive", + "resolved": "3.119.0", + "contentHash": "KST7Q3n+X5BhDj1aKTJXk9tITWWznveka9n+pBjOtE53Omc+wlZR0s4fQStybWCVVaWG9eaeOMaw93J3zEMklA==", + "dependencies": { + "SkiaSharp": "3.119.0" + } + }, + "SkiaSharp.Views.WPF": { + "type": "Transitive", + "resolved": "3.119.0", + "contentHash": "1Dmj9RlBW/gvmJwSUER+N9plA/kcWOgiL5g3q2DB+USVQav8rQtfOZIQZ1Wt/AqhwepOacv1M6pZKDWtXmwDAg==", + "dependencies": { + "OpenTK": "3.3.1", + "OpenTK.GLWpfControl": "3.3.0", + "SkiaSharp": "3.119.0", + "SkiaSharp.Views.Desktop.Common": "3.119.0" + } + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "rrs2u7DRMXQG2yh0oVyF/vLwosfRv20Ld2iEpYcKwQWXHjfV+gFXNQsQ9p008kR9Ou4pxBs68Q6/9zC8Gi1wjg==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "8.16.0", + "Microsoft.IdentityModel.Tokens": "8.16.0" + } + }, + "performancemonitor.alerting": { + "type": "Project", + "dependencies": { + "PerformanceMonitor.Notifications": "[1.0.0, )" + } + }, + "performancemonitor.analysis": { + "type": "Project" + }, + "performancemonitor.collectors": { + "type": "Project" + }, + "performancemonitor.common": { + "type": "Project", + "dependencies": { + "CredentialManagement": "[1.0.2, )", + "Microsoft.Extensions.Logging.Abstractions": "[10.0.9, )", + "ModelContextProtocol": "[1.4.0, )" + } + }, + "performancemonitor.darling.analysis": { + "type": "Project", + "dependencies": { + "Microsoft.Data.SqlClient": "[7.0.1, )", + "PerformanceMonitor.Analysis": "[1.0.0, )", + "PerformanceMonitor.Collectors": "[1.0.0, )", + "PerformanceMonitor.Darling.Storage": "[1.0.0, )", + "PerformanceMonitor.Notifications": "[1.0.0, )", + "PerformanceMonitor.PlanAnalysis": "[1.0.0, )" + } + }, + "performancemonitor.darling.storage": { + "type": "Project", + "dependencies": { + "Npgsql": "[10.0.3, )", + "PerformanceMonitor.Collectors": "[1.0.0, )" + } + }, + "performancemonitor.notifications": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "[10.0.9, )", + "PerformanceMonitor.Analysis": "[1.0.0, )" + } + }, + "performancemonitor.plananalysis": { + "type": "Project", + "dependencies": { + "Microsoft.Data.SqlClient": "[7.0.1, )", + "PerformanceMonitor.Common": "[1.0.0, )" + } + }, + "performancemonitor.ui": { + "type": "Project", + "dependencies": { + "PerformanceMonitor.Common": "[1.0.0, )", + "PerformanceMonitor.PlanAnalysis": "[1.0.0, )", + "ScottPlot.WPF": "[5.1.58, )" + } + } + } + } +} \ No newline at end of file diff --git a/Darling/README.md b/Darling/README.md new file mode 100644 index 000000000..238b67be0 --- /dev/null +++ b/Darling/README.md @@ -0,0 +1,728 @@ +# Performance Monitor Darling — Headless Edition + +Darling is the headless, centralized edition of Performance Monitor: a 24/7 Windows service that collects from your SQL Servers into a central PostgreSQL (optionally TimescaleDB) store, plus a detached desktop viewer that reads that store. No desktop app has to stay open for collection to happen, and every viewer seat reads the same central data. + +It runs the **same monitoring brain as the Lite edition** — one shared codebase, two storage engines: + +- `PerformanceMonitor.Collectors` owns all 32 collector definitions: the exact T-SQL sent to monitored servers, the result-row mappings, the delta rules, the default cadences and retention horizons, and the ignored-wait-types list. Lite writes those rows to DuckDB; Darling writes the same rows to PostgreSQL via binary COPY. +- `PerformanceMonitor.Alerting` owns the shared alert engine — the same thresholds, edge-trigger gates, cooldowns, and dedup fingerprints Lite uses. +- The analysis/recommendations pipeline (the same inference engine behind both apps' Recommendations tabs and the `analyze_server` MCP tool) runs on a schedule inside the service. + +A collector, alert, or analysis change lands once in the shared libraries and both editions get it. A Darling install monitoring a server even derives the **same `server_id`** Lite would for that server, because the identity rule (`host[:database][:RO]`, hashed) is shared too. + +> **Status: in development.** Darling builds and runs from source (it is wired into the solution and CI), but is not yet packaged into the signed release artifacts. Expect the surface documented here to grow. + +--- + +## When to Choose Darling vs. Lite + +| | **Lite** | **Darling** | +|---|---|---| +| Collection runs | While the desktop app is open (or in the tray) | 24/7 as a Windows service | +| Data lives | Locally per seat (DuckDB + Parquet) | Centrally (PostgreSQL / TimescaleDB) | +| Execution plans | Not stored (fetched live when you view a query) | Captured and stored, TOAST-compressed (`capturePlans`, default on) | +| Viewers | The app is the viewer | Any number of viewer seats read the central store | +| Setup | Download and run | Provision PostgreSQL, edit `darling.json`, install the service | +| Best for | Quick triage, consultants, a handful of servers | Always-on team monitoring, larger estates, one shared store | +| Configuration | Settings UI | One JSON file (no UI) | + +Nothing is installed on the monitored SQL Servers by either edition beyond two lightweight Extended Events ring-buffer sessions and, when it is unset, a one-time `blocked process threshold` bootstrap (see [What the Service Does on Monitored Servers](#what-the-service-does-on-monitored-servers)). + +--- + +## Quick Start + +### Prerequisites + +- **Windows** for the service host (Windows-service lifetime, DPAPI password protection) and for the viewer (WPF). Monitored servers can be SQL Server 2016–2025, Azure SQL Managed Instance, AWS RDS for SQL Server, or Azure SQL Database. +- **A PostgreSQL store — bundled or your own.** In managed mode (the shipped default, see [Managed Bundled PostgreSQL](#managed-bundled-postgresql)) the service runs its own bundled PostgreSQL 18 + TimescaleDB and no database provisioning is needed. To bring your own instead, PostgreSQL 16 or newer is recommended (developed and validated against PostgreSQL 18) with a database and a login the service can create tables in. +- **TimescaleDB is optional and auto-adopted.** If the extension is installed (or pre-created by an administrator) in the store database, the service detects it at startup and automatically converts the collector tables to hypertables with compression; without it, the service runs in plain-PostgreSQL mode, which is fully supported. No configuration flag either way. +- **.NET 10** to build and run. + +Build from the repository root: + +``` +dotnet build Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj -c Release +``` + +``` +dotnet build Darling/PerformanceMonitor.Darling.Viewer/PerformanceMonitor.Darling.Viewer.csproj -c Release +``` + +### Configure darling.json + +The service reads one JSON file. It resolves the path in this order: + +1. An explicit path (when a component is handed one) +2. The `DARLING_CONFIG` environment variable +3. `darling.json` next to the service binary + +Copy the shipped `darling.sample.json` (it lands next to the built binary) to `darling.json` and edit. Comments and trailing commas are allowed; property names are case-insensitive. + +Minimal working example — one server, integrated auth, bring-your-own PostgreSQL. (With the bundled store instead, replace the `postgres` block with `"postgres": { "managed": true }` and skip provisioning entirely — see [Managed Bundled PostgreSQL](#managed-bundled-postgresql).) + +```json +{ + "postgres": { + "connectionString": "Host=localhost;Port=5432;Username=darling;Database=darling" + }, + "servers": [ + { + "name": "SQL2022", + "host": "SQL2022", + "auth": "integrated", + "excludedDatabases": [] + } + ] +} +``` + +**Integrated auth (recommended).** The service connects to monitored servers as the Windows account the service runs under. Grant that account the [permissions below](#permissions-on-monitored-servers). + +**SQL auth.** Set `"auth": "sql"`, a `username`, and an `encryptedPassword` produced by the `--encrypt-password` verb: + +``` +PerformanceMonitor.Darling.Service.exe --encrypt-password +``` + +It prompts for the password on stdin (so the plaintext never lands in your shell history) and prints a base64 DPAPI blob. Paste that blob into the server's `"encryptedPassword"`. The blob is protected with **DPAPI LocalMachine scope**, so an administrator can encrypt it interactively and the service account can decrypt it later on the same machine — but it is machine-bound: run `--encrypt-password` **on the machine that will run the service**, and re-encrypt if you move `darling.json` to another machine. A plaintext `"password"` also works as a dev convenience, but the service logs a warning every time it is used. + +**excludedDatabases** (per server) removes databases from collection: per-database collectors skip them and the exclusion is spliced into the collector queries — the same filter Lite applies. There is a second, separate `alerts.excludedDatabases` list that excludes databases from blocking/deadlock/long-running-query **alert evaluation** without affecting collection. + +### Validate the Config (Pre-flight) + +Before installing the service, check that `darling.json` is well-formed and that every monitored server is reachable with the configured credentials: + +``` +PerformanceMonitor.Darling.Service.exe --test-connection +``` + +(`--validate-config` is an alias.) It validates the file, then connects to and probes each server, printing a `[PASS]`/`[FAIL]` line per server (SQL major version, engine edition, and whether the account has msdb access for failed-job alerts). It exits `0` only when the file is valid **and** every server is reachable, so it doubles as a deployment gate. Add an explicit config path as a second argument if `darling.json` is not next to the exe and `DARLING_CONFIG` is not set. This is the same probe the Viewer's **Test Connection** button runs through the service. + +### Run It — Console Mode + +The same executable serves interactive debugging and service installation; the Windows-service lifetime is a no-op when run from a console. + +``` +Darling\PerformanceMonitor.Darling.Service\bin\Release\net10.0\PerformanceMonitor.Darling.Service.exe +``` + +Watch the log output: you should see the config load (`Loaded configuration from ...`), the store migrate (`Postgres store ready (schema v20, ...)`), the TimescaleDB detection result, per-server connects, and then per-collector run lines with row counts. + +### Install as a Windows Service + +**Scripted (recommended):** the packaged zips ship `install-darling.ps1` beside the service exe. Extract the zip to its final location (e.g. `C:\PerformanceMonitorDarling`), then from an elevated PowerShell in that folder run `.\install-darling.ps1`. It checks for `darling.json` (copying the sample and stopping for you to edit it on first run), runs the `--test-connection` pre-flight, registers the Event Log source, creates the service under the virtual account (or upgrades an existing install's binPath in place, preserving config/store/credentials), starts it, and creates Desktop + Start Menu **Darling Viewer** shortcuts (pin to taskbar from the Start Menu entry — Windows does not allow programmatic pinning). `uninstall-darling.ps1` reverses it, deliberately leaving the store/config in place unless you pass `-PurgeData`. + +**Manual:** publish (or copy the build output) to a stable path, put `darling.json` next to the exe (or set `DARLING_CONFIG` as a machine environment variable), then register it: + +``` +dotnet publish Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj -c Release -o C:\PerformanceMonitorDarling +``` + +``` +sc create "PerformanceMonitor Darling" binPath= "C:\PerformanceMonitorDarling\PerformanceMonitor.Darling.Service.exe" start= auto obj= "NT SERVICE\PerformanceMonitor Darling" +``` + +``` +sc start "PerformanceMonitor Darling" +``` + +Also register the service's Windows event source once, from the same elevated shell — event-source registration requires elevation, and the virtual service account cannot do it itself (without this, Event Log diagnostics are silently dropped; the file log under `%ProgramData%\PerformanceMonitorDarling\logs` works regardless): + +``` +powershell -NoProfile -Command "New-EventLog -LogName Application -Source 'PerformanceMonitor Darling' -ErrorAction SilentlyContinue" +``` + +The `obj=` clause runs the service under a **virtual service account** (`NT SERVICE\<service name>` — password-less, per-service SID, unprivileged; the same convention SQL Server itself uses). That is the right account for SQL-auth monitoring, and with `postgres.managed = true` it is more than a preference: PostgreSQL refuses to execute with administrative privileges, so don't run the service as LocalSystem — a least-privilege account keeps the bundled store's initdb/start path on ground PostgreSQL supports. For integrated auth to monitored servers, set a domain account (or gMSA) holding the SQL-side grants below instead, via **Services.msc → Log On** or `sc config ... obj=`. Note the space after `binPath=`, `start=`, and `obj=` — `sc` requires it. + +One managed-mode handoff gotcha: if you test-drove the service from a console first, the bundled store's data directory belongs to *your* account, and the service account may not be able to write it. Point the service at a fresh `postgres.dataDirectory` (or delete the test directory) rather than fighting ACLs. + +### What the Service Does on Monitored Servers + +On each successful connect, the service: + +1. **Probes the server** — one query against `sys.dm_os_sys_info` / `SERVERPROPERTY()` for version, engine edition (box / Managed Instance / Azure SQL DB), AWS RDS detection, and msdb access. It is the same detection query Lite runs, so both editions classify a server identically. +2. **Ensures two Extended Events ring-buffer sessions** (created if missing, started if stopped; ~4 MB ring buffer each, no files written on the server): + - `PerformanceMonitor_Deadlock` — `xml_deadlock_report`, server-scoped on on-prem/Managed Instance/RDS; `database_xml_deadlock_report`, database-scoped on Azure SQL Database. + - `PerformanceMonitor_BlockedProcess` — `blocked_process_report`, server-scoped (database-scoped on Azure SQL Database). +3. **Bootstraps the blocked-process threshold** — if `blocked process threshold (s)` is `0`, the service sets it to `5` via `sp_configure`. On AWS RDS `sp_configure` is unavailable; the attempt is tolerated and logged, and you set the threshold through an RDS Parameter Group instead (Azure SQL Database has a fixed 20-second threshold). +4. **Runs the on-connect config snapshots once** (`server_config`, `database_config`, `database_scoped_config`, `trace_flags`, `server_properties`), then runs all scheduled collectors on the shared default cadences. + +Every failure in steps 2–3 is tolerated and logged: the deadlock/blocked-process collectors simply read zero rows until the sessions exist (and blocked-process reports only start arriving once the threshold is set). Monitoring queries connect with a 15-second connect budget and an application name of `PerformanceMonitorDarling`; connection encryption fails closed to `Mandatory` when the configured mode is unrecognized. + +### Permissions on Monitored Servers + +```sql +USE [master]; +CREATE LOGIN [DarlingMonitor] WITH PASSWORD = N'YourStrongPassword'; +GRANT VIEW SERVER STATE TO [DarlingMonitor]; +GRANT ALTER ANY EVENT SESSION TO [DarlingMonitor]; + +-- Optional: SQL Agent job monitoring + failed-job alerts +USE [msdb]; +CREATE USER [DarlingMonitor] FOR LOGIN [DarlingMonitor]; +ALTER ROLE [SQLAgentReaderRole] ADD MEMBER [DarlingMonitor]; +``` + +| Grant | Why | If missing | +|---|---|---| +| `VIEW SERVER STATE` | All DMV collectors (wait stats, query stats, memory, CPU, file I/O, sessions, etc.) and the connect probe | Collection fails — this one is required | +| `ALTER ANY EVENT SESSION` | Create/start the two XE sessions | Logged; deadlock and blocked-process collectors read zero rows (an admin can pre-create the sessions instead) | +| `ALTER SETTINGS` | The `sp_configure` blocked-process-threshold bootstrap | Logged; set the threshold yourself (or via RDS Parameter Group) | +| `SQLAgentReaderRole` on msdb | `running_jobs` collector and the failed/long-running-job alerts | Skipped gracefully — logged as a permissions skip, alerts return no jobs | +| `DBCC TRACESTATUS` permission | `trace_flags` snapshot | Degrades to zero rows with a warning | + +**Azure SQL Database:** connect to the one database you monitor (set the server entry's `"database"`), using a contained user with `VIEW DATABASE STATE`, matching the product's existing Azure guidance. The XE sessions are created database-scoped there (`ALTER ANY DATABASE EVENT SESSION`); SQL Agent collectors are skipped automatically. + +Collectors that hit a permission error (SQL errors 229/297/300) log a `PERMISSIONS` row in `collection_log` and retry on their next scheduled run — one denied collector never stops the rest. + +--- + +## Configuration Reference + +All sections except `postgres` and `servers` are optional — omit a section (or any key) to get the defaults listed here. Defaults deliberately mirror a fresh Lite install. + +### postgres + +Two mutually exclusive modes — setting both `managed: true` and `connectionString` is a validation error: + +| Key | Default | Notes | +|---|---|---| +| `managed` | `false` | `true` runs the bundled PostgreSQL + TimescaleDB (Windows only; see [Managed Bundled PostgreSQL](#managed-bundled-postgresql)). The connection string is derived, never configured. | +| `port` | `5641` | Managed mode only: the loopback port the bundled server listens on. Deliberately uncommon so it coexists with any PostgreSQL (5432) already on the machine. | +| `dataDirectory` | *(null)* | Managed mode only: the cluster's data directory. `null` means `%ProgramData%\PerformanceMonitorDarling\pg`. | +| `connectAs` | `"admin"` | Managed mode only: which least-privilege role the Viewer connects as — `"admin"` (reads everything + manages mute rules and dismisses alerts) or `"viewer"` (read-only; those write actions are hidden/disabled). See [Security & Least-Privilege Roles](#security--least-privilege-roles). Ignored in bring-your-own mode (the connection string picks the role). | +| `connectionString` | *(required unless managed)* | Npgsql connection string for a store you provision yourself, e.g. `Host=localhost;Port=5432;Username=darling;Password=...;Database=darling` | + +### servers (array, at least one entry) + +| Key | Default | Notes | +|---|---|---| +| `name` | `""` | Display name; falls back to `host` | +| `host` | *(required)* | Server/instance to monitor | +| `database` | *(none)* | Azure SQL Database only: the one database this entry monitors (also part of the server's storage identity) | +| `auth` | `"integrated"` | `"integrated"` or `"sql"` | +| `username` | *(none)* | Required for `"sql"` | +| `encryptedPassword` | *(none)* | DPAPI blob from `--encrypt-password` (preferred) | +| `password` | *(none)* | Plaintext fallback — dev only, warned on every use | +| `readOnlyIntent` | `false` | Route to a readable AG secondary (`ApplicationIntent=ReadOnly`) | +| `trustServerCertificate` | `false` | | +| `encryptMode` | `"Mandatory"` | `Mandatory` / `Strict` / `Optional`; unknown values fail closed to `Mandatory` | +| `multiSubnetFailover` | `false` | | +| `excludedDatabases` | `[]` | Databases excluded from collection | + +### capturePlans (boolean, optional) + +| Key | Default | Notes | +|---|---|---| +| `capturePlans` | `true` | Capture execution plans into `query_stats.query_plan_xml` and `query_store_stats.query_plan_text`. PostgreSQL TOAST compresses the plan text transparently (LZ4 on the managed store) and TimescaleDB chunk compression squeezes it further, so plans are cheap to keep — unlike Lite, which stores to DuckDB/Parquet and deliberately never captures them. Set `false` to skip plan capture (e.g. to shave storage across a very large fleet). | + +### collectSchemaChangeEvents (boolean, optional) + +| Key | Default | Notes | +|---|---|---| +| `collectSchemaChangeEvents` | `true` | Record `Object:Created` / `Object:Altered` / `Object:Deleted` schema-change (DDL) events in the built-in default-trace collector. Set `false` on a noisy or benchmark box where a create/drop-happy workload floods the viewer's **System Events > Default Trace** tab — e.g. HammerDB's TPC-H Query 15 creates and drops a `revenue` view thousands of times, and the collector faithfully records every create/delete. Only the Object DDL slice is suppressed; file auto-grow/shrink, ErrorLog, and security-audit events are still collected. The shared collector's equivalent of the full Dashboard's `@include_object_events`. A file-only knob (not stored in the control plane): edit and restart. | + +### alerts + +The shared alert engine's switches and thresholds. Every default mirrors Lite's alert defaults exactly, so an empty section alerts like a fresh Lite install. `enabled: false` turns off all alert evaluation **and** scheduled-analysis finding notifications (the analysis itself still runs and persists findings). + +| Key | Default | Meaning | +|---|---|---| +| `enabled` | `true` | Master switch for alert evaluation + finding notifications | +| `cpuEnabled` | `true` | | +| `cpuThresholdPercent` | `80` | | +| `cpuMode` | `"total"` | `"total"` = SQL + other processes; `"sql"` = SQL process only | +| `blockingEnabled` | `true` | | +| `blockingCountThreshold` | `1` | Blocked-process count (rolling window) that trips the alert | +| `deadlockEnabled` | `true` | | +| `deadlockCountThreshold` | `1` | Deadlock count (rolling window) that trips the alert | +| `poisonWaitEnabled` | `true` | THREADPOOL / RESOURCE_SEMAPHORE / RESOURCE_SEMAPHORE_QUERY_COMPILE | +| `poisonWaitThresholdMs` | `500` | Average ms per wait | +| `longRunningQueryEnabled` | `true` | | +| `longRunningQueryThresholdMinutes` | `30` | | +| `tempDbSpaceEnabled` | `true` | | +| `tempDbSpaceThresholdPercent` | `80` | | +| `lowDiskEnabled` | `true` | Volume free space; graded CRITICAL when critically low | +| `lowDiskThresholdPercent` | `10` | Fire below X% free; `0` disables this dimension (clamped 0–100) | +| `lowDiskThresholdGb` | `5` | Fire below X GB free; `0` disables this dimension | +| `longRunningJobEnabled` | `true` | SQL Agent job running long vs. its history | +| `longRunningJobMultiplier` | `3` | Fires at 3x the job's historical average | +| `failedJobEnabled` | `true` | Live msdb check for recently failed jobs | +| `failedJobLookbackMinutes` | `60` | Clamped 1–1440 | +| `cooldownMinutes` | `5` | Minimum minutes between repeats of the same alert condition (clamped 1–120) | +| `excludedDatabases` | `[]` | Excluded from blocking/deadlock/long-running-query **alert evaluation** (collection unaffected) | + +Not configurable (hardcoded to Lite's defaults until someone needs a knob): the long-running-query read shape (top 5 results; the five noise filters — sp_server_diagnostics, WAITFOR, backups, misc waits, CDC — all on) and the analysis-finding notification policy (notify at severity >= 1.5, 6-hour per-finding cooldown). + +### smtp + +Email delivery is enabled when `host`, `from`, and `to` are all set — there is no separate enable flag. + +| Key | Default | Notes | +|---|---|---| +| `host` | `""` | | +| `port` | `587` | | +| `useSsl` | `true` | | +| `username` | *(none)* | For authenticated relays | +| `encryptedPassword` | *(none)* | Same `--encrypt-password` DPAPI pattern as SQL auth | +| `from` | `""` | | +| `to` | `""` | Comma-separated recipients | +| `emailCooldownMinutes` | `15` | Email/webhook channel cooldown (clamped 1–120) | + +### webhooks + +A channel is enabled by a non-empty URL. + +| Key | Default | Notes | +|---|---|---| +| `teamsUrl` | `""` | Teams incoming webhook | +| `teamsProxy` | `""` | Optional proxy address | +| `slackUrl` | `""` | Slack incoming webhook | +| `slackProxy` | `""` | Optional proxy address | + +### mcp + +The embedded MCP server, over Streamable HTTP bound to `localhost` by default (see [Opt-in Network Endpoints (LAN)](#opt-in-network-endpoints-lan) to reach it — and the store — from the LAN). It exposes the same tool names Lite and the Dashboard expose, plus small Darling-only WRITE surfaces — Custom Views management, alert tuning, and server onboarding (see the last three bullets): + +- **Six diagnostic-analysis tools** — `analyze_server`, `get_analysis_facts`, `compare_analysis`, `audit_config`, `get_analysis_findings`, `mute_analysis_finding`. +- **Five plan-analysis tools** — `analyze_query_plan` (by `query_hash`), `analyze_procedure_plan` (by `sql_handle`), `analyze_query_store_plan` (by `database_name` + `query_id`), `analyze_plan_xml` (raw showplan XML, no fetch), and `get_plan_xml` (raw stored plan XML by `query_hash`). These run the shared execution-plan analyzer over the plan XML the collectors already captured into the store — a stored-plan read, never a live query against the monitored server. `analyze_query_plan`/`get_plan_xml` accept an optional `database_name`, and `analyze_query_store_plan` an optional `plan_id`, to pin the exact stored plan when the caller knows it. +- **Fifteen core data-read tools** — the diagnostic reads an assistant needs to investigate a server, each a stored read of the collected data (never a live query against the monitored server): + - *Resource metrics* — `get_cpu_utilization`, `get_wait_stats`, `get_wait_trend`, `get_wait_types` (the distinct observed wait types, to pick one for `get_wait_trend`), `get_memory_stats`, `get_memory_clerks`, `get_file_io_stats`, `get_tempdb_trend`, `get_perfmon_stats`. + - *Query performance* — `get_top_queries_by_cpu`, `get_top_procedures_by_cpu`, `get_query_store_top` (these hand back the `query_hash` / `sql_handle` / `query_id` + `plan_id` keys the plan-analysis tools consume). + - *Discovery / health* — `list_servers` (with collection-freshness status), `get_collection_health`, `get_server_properties`. + + These are the tools the analysis findings' `next_tools` recommendations point at, so a client following a finding's advice resolves them on this same server. Result shapes match Lite's (the store is Lite's collector schema); where Lite and the Dashboard's shapes diverge, Darling follows Lite — the shape its collector-mirror store can serve faithfully. +- **Twenty diagnostic-depth data-read tools** — deeper reads for a blocking / deadlock / session / configuration / storage investigation, each a stored read: + - *Blocking / deadlocks* — `get_blocking` (blocked/blocking pairs from the blocked-process-report XE + the always-on DMV fallback), `get_deadlocks`, `get_deadlock_detail` (raw graph XML), `get_blocked_process_xml` (raw report XML), and the per-minute count series `get_blocking_trend` / `get_deadlock_trend`. + - *Sessions* — `get_session_stats` (latest per-application connection counts), `get_active_queries` (captured running-query snapshots), `get_waiting_tasks`. + - *Config* — the change history `get_server_config_changes`, `get_database_config_changes`, `get_trace_flag_changes`, plus `get_database_scoped_config` (latest scoped snapshot) and the current-config snapshots `get_server_config` / `get_database_config` / `get_trace_flags` (what sp_configure / sys.databases / the active trace flags are set to **right now** — the companion to the `*_changes` diffs, which are empty on a stable server). + - *Index / object* — `get_table_index_sizes` (size + growth), `get_index_usage` (Unused / Write-only / Active), `get_object_locking` (lock/latch contention), `get_database_sizes`. + + The three config-change tools diff the store's config snapshots. This edition captures configuration **when the service connects** to a server (not on a fixed schedule), so a change is detected between two connect snapshots and at least two are needed — a stable, always-connected deployment may show no changes until the next connect. They emit only the values the collectors capture; the Dashboard's `requires_restart` / setting `description` / `setting_type` / generated change-narrative enrichment is not collected here and is omitted. The Dashboard's `get_blocking_deadlock_stats` aggregate is **not** hosted (Darling has no blocking/deadlock rollup table — use `get_blocking` / `get_deadlocks` for the raw events). + +- **Eight resource-contention + jobs data-read tools** — deeper reads for an internal-contention / worker-thread / plan-cache / SQL Agent investigation, each a stored read of the latest collected snapshot: + - *Latch / spinlock* — `get_latch_stats` (top latch classes by wait time, per-second rates), `get_spinlock_stats` (top spinlocks by collisions). + - *Memory grants* — `get_resource_semaphore` (workspace-memory target / max-target ceiling vs granted / used), `get_memory_grants` (per-pool grant detail), `get_memory_pressure_events` (RING_BUFFER_RESOURCE_MONITOR notifications — the process/system pressure indicators, not on Azure SQL DB). + - *Plan cache / scheduler* — `get_plan_cache_bloat` (single-use vs multi-use + bloat level), `get_cpu_scheduler_pressure` (runnable queue, worker utilization, pressure level). + - *Jobs* — `get_running_jobs` (running SQL Agent jobs vs historical average / p95). + + The Dashboard's per-class latch `severity` / `description` / `recommendation`, spinlock `description`, plan-cache `bloat_level`, and CPU-scheduler `pressure_level` / `recommendation` are the Dashboard / reporting-view CASE derivations (not collected columns), reproduced service-side so the full result shape is served. Darling's delta collectors store no `sample_interval_seconds`, so per-second latch/spinlock rates are derived from the collection interval, and the Dashboard's `get_resource_semaphore` `sample_interval_seconds` is not emitted for the same reason (`max_target_memory_mb`, the workspace-memory ceiling, is added since the store carries it). + +- **Five trend data-read tools** — windowed time-series siblings of the core reads, each a stored read of the collected series over the window (BOTH-sides, naive-UTC): + - `get_memory_trend` (total / target server memory, buffer pool, plan cache over time), `get_perfmon_trend` (a single counter's value + delta, `counter_name` required), `get_file_io_trend` (per-database read/write latency, top-10 busiest files), `get_query_trend` (one query's per-collection history by `query_hash` + `database_name`), `get_query_duration_trend` (overall elapsed-ms/sec + executions/sec). + + Each mirrors the viewer's proven chart read (byte-identical Postgres SQL); the shape follows Lite where the SKUs diverge. `get_perfmon_trend` reproduces Lite's miss vocabulary (Page Life Expectancy is intentionally not collected; an unknown counter hands back the collected names). `get_memory_trend` carries a `total_granted_mb` field for field-for-field parity with Lite, where its memory_stats-only read leaves it 0 (the grant overlay is a separate chart series). + +- **Eight system-health parse-on-read tools** — the Dashboard's `get_health_parser_*` family, over Darling's raw `system_health_events`: + - `get_health_parser_system_health` (corruption + contention counters), `get_health_parser_severe_errors` (severity ≥ 19, with `database_id` resolved to a name), `get_health_parser_scheduler_issues`, `get_health_parser_memory_conditions`, `get_health_parser_memory_broker`, `get_health_parser_memory_node_oom`, `get_health_parser_cpu_tasks`, `get_health_parser_io_issues`. + + Where the Dashboard reads its server-side-parsed `collect.HealthParser_*` tables, these shred the raw extended-event XML **on read** with the shared `SystemHealthParser` (the same parser the viewer's System Events tab uses) and gate with the service-side twin of the viewer's `SystemEventSignificance` — returning the same SIGNIFICANT warning set the Dashboard surfaces (sp_HealthParser at `@warnings_only = 1`). `get_health_parser_system_health` is the one UNGATED category (its counter series plots every snapshot). Each row carries the full sp_HealthParser column set keyed on the event's `event_time`; the tools window on `event_time` (the event's real time), so "last 24 hours" means events that happened in the last 24 hours. + +- **Five alert + health-overview tools** — the fleet-triage reads the fleet edition previously lacked, each a stored read over the monitoring store (no live hit): + - *Alerts* — `get_alert_history` (what fired, value vs threshold, delivery success/failure, muted — fleet-wide by default, or scoped to a server), `get_alert_settings` (the current alert config the service is using — per-alert enable/thresholds, cooldown, excluded databases, delivery mode, analysis cadence), `get_mute_rules` (the alert mute rules in force, so a suppressed server is distinguishable from a healthy-quiet one). + - *Health overview* — `get_server_summary` (one-shot per-server CPU / memory / recent blocking / recent deadlocks), `get_daily_summary` (a day's composite health band — Healthy / Warning / Critical — folded through the shared `DailyHealthBandCalculator`, plus the signals behind it). + +- **Eight Custom Views tools (Darling-only)** — discover, create, and manage the saved dashboards/notebooks a user composes from the curated measure catalog (the same views the web viewer's editor builds), stored in `config.custom_views`. None touches a monitored SQL Server or the collected performance data — the write tools write only view definitions to the monitoring store. + - *Discover* — `describe_custom_view_catalog` (the compose vocabulary — measures with their source/kind/valid-aggregates/allowed-dimensions/units/per-server-type availability, dimensions, unit families, aggregates, time buckets, filter ops, and viz types). An MCP client calls this FIRST so a composed panel uses only legal identifiers instead of guessing at names; it returns the SAME `/api/catalog` vocabulary the web composer's picker binds to. Read-only static reference — no store, no server. + - *Read* — `list_custom_views` (summaries: id, name, description, kind, version), `get_custom_view` (one view's full definition + version). + - *Author* — `validate_custom_view` (dry-run a definition against the catalog + composer rules, no save), `create_custom_view` (validate then save), `update_custom_view` (validate then replace in place, optimistic-concurrency on `version`), `delete_custom_view`. + - *Self-test* — `run_custom_view_panel` (compile + run a single composed panel and return `{sql, rows, annotations}` — the composer's live preview, for checking a generated panel's data before saving). + + The create/update/delete tools are the one view-authoring **write** surface; create/update run the SAME `ValidateDefinition` authority as `validate_custom_view`, so an invalid definition is rejected before it stores; every tool routes through the SAME store + validator + compile-and-run + catalog the web viewer's editor uses (no divergent second implementation). This write surface widens what an MCP token can do — see [Blast radius](#opt-in-network-endpoints-lan) below. + +- **Three alert-tuning write tools (Darling-only)** — `update_alert_settings`, `create_mute_rule`, and `delete_mute_rule` let an MCP client TUNE the alert engine the fleet shares — the SAME config `get_alert_settings` / `get_mute_rules` read and the Viewer's Settings window writes. `update_alert_settings` is a PARTIAL update of the single global settings row: read via `get_alert_settings`, change fields, and send only those back in the same nested shape; every field is validated against the SAME ranges/enums the Settings window enforces BEFORE any write, an out-of-range or unknown field returns `{status:"invalid"}` and writes nothing, and the write self-bumps `config_version` so the running service hot-reloads within one collection sweep. `create_mute_rule` / `delete_mute_rule` reuse the SAME `PgMuteRuleStore` `get_mute_rules` reads through (and the same GUID id-generation the Viewer's mute-create path uses). None touches a monitored SQL Server or the collected data — only the shared alert configuration; SMTP/webhook delivery credentials are out of scope (the `mcp` role cannot read or write the secret columns). This widens what an MCP token can do — see [Blast radius](#opt-in-network-endpoints-lan) below. + +- **Two server-onboarding write tools (Darling-only)** — `add_servers` (BULK) and `remove_server` let an MCP client stand up or tear down FLEET monitoring conversationally ("monitor these twenty servers with this login"), the service-side twin of the Viewer's Add / Manage Servers dialogs. `add_servers` takes a JSON **array** of server objects (`host` required; optional `display_name` / `database` / `read_only_intent` / `multi_subnet_failover`; `auth` `Windows`/`SQL` with `username`+`password` for SQL; and the exposed TLS options `encrypt_mode` `Optional`/`Mandatory`/`Strict` + `trust_server_certificate`) and processes them **in order**: it validates each entry, PROBES the connection in-process (reusing the same `DarlingServerConnector.ProbeAsync` the `--test-connection` verb runs — the service holds the network path + credentials, so no `test_connect` command plane is needed), skips a case-folded duplicate (`duplicate`) of an already-monitored server or an earlier entry, DPAPI-encrypts the SQL password (the service identity, so it round-trips at collection time), and INSERTs the row mirroring the service's own seed shape. A server that fails to connect is `connection_failed` and the batch continues; Entra/MFA/Service-Principal/Managed-Identity auth is `invalid` (the service connects with Windows or SQL only). `remove_server` DELETEs a monitored server by name (resolved the same way every `server_name` is) — already-collected history is kept. Both write only the monitoring store's `config.config_monitored_servers` registry; neither runs anything on a monitored server beyond the one-time probe. **The SQL password travels to the endpoint inside `add_servers`' request** and is DPAPI-encrypted at rest (never returned) — this widens what an MCP token can do and puts a credential on the wire; see [Blast radius](#opt-in-network-endpoints-lan) below. + +| Key | Default | Notes | +|---|---|---| +| `enabled` | `false` | **Off by default** — a headless service does not open a local port unless you ask | +| `port` | `5152` | Chosen so all three editions coexist on one machine (Dashboard 5150, Lite 5151) | + +Register with Claude Code: + +``` +claude mcp add --transport http --scope user sql-monitor-darling http://localhost:5152/ +``` + +If the port is already in use at startup, the MCP server logs an error and does not start; collection is unaffected. + +### web + +The embedded read-only **web dashboard** — a browser view of the monitoring store, served over HTTP on its OWN port (default **5153**), separate from the MCP server. It is a distinct surface from [`### mcp`](#mcp): its own enable flag, port, token, and exposure block, because the two gate different blast radii (the MCP token guards `analyze_server`'s **live outbound** connections to your monitored SQL Servers; the web dashboard is **read-only over the collected store**). It connects to the store as the least-privilege `viewer` role. Loopback-only by default; see [Opt-in Network Endpoints (LAN)](#opt-in-network-endpoints-lan) to reach it from the LAN. + +| Key | Default | Notes | +|---|---|---| +| `enabled` | `false` | **Off by default** — a headless service does not open a local port unless you ask | +| `port` | `5153` | Chosen so all four local surfaces coexist on one machine (Dashboard 5150, Lite 5151, Darling MCP 5152) | + +Once enabled, open `http://localhost:5153/` in a browser on the service host. Like the MCP server, `enabled`/`port` here are the file SEED; after first start they live in the control plane and the Viewer's Settings toggles them LIVE (the service starts/stops/rebinds the dashboard within seconds — no restart). If the port is already in use at startup, the web host logs an error and retries on a calm cadence; collection is unaffected. + +**What you see.** The dashboard opens on a **Fleet Overview**: a card per enabled server with a status dot, six per-metric health bands (CPU, threads, memory, blocking, deadlocks, collectors), and its last collection time — all banded server-side, so the browser only renders (a server that has never reported shows an amber "Awaiting first collection", never a red offline). Above the cards a worst-first "Needs attention" list surfaces the servers to look at, or an all-healthy line when there is nothing to chase. Click a card to **drill into one server**: an overview, wait stats with a trend for the heaviest wait, active queries, a CPU chart, memory and file-I/O trends, and collection health — the same collected data the viewer shows, over inline charts. A fleet-wide **Alert History** page (with a server filter box) rounds out phase 1. It is a read-only view — no settings, no write paths, no live-server queries — and refreshes every 60 seconds (pausing while the tab is hidden). The frontend ships fully self-contained (no CDN, no fonts, no remote anything), so it works on an air-gapped host with no internet access. + +### No Schedule Knobs, by Design + +There are deliberately **no collection-schedule or retention settings** in `darling.json`. The service consumes the shared per-collector defaults (`CollectorScheduleDefaults`) — the same cadences and retention horizons a fresh Lite install uses, identity-pinned by tests so the two editions cannot drift. If a schedule knob is ever genuinely needed, it will be added then, not speculatively. + +--- + +## Operations + +### The Store + +The service migrates the store itself at startup — plain versioned SQL scripts, each applied once inside its own transaction, tracked in `darling_schema_version`, safe under concurrent starters (advisory-locked). Current schema is **v29**: + +| Version | Contents | +|---|---| +| **V1** — collector tables | One table per collector, all 32, generated from the shared collector definitions (column-for-column identical to Lite's DuckDB schema): `wait_stats`, `latch_stats`, `spinlock_stats`, `query_stats`, `procedure_stats`, `query_store_stats`, `query_snapshots`, `plan_cache_stats`, `cpu_utilization_stats`, `cpu_scheduler_stats`, `file_io_stats`, `memory_stats`, `memory_clerks`, `memory_pressure_events`, `tempdb_stats`, `perfmon_stats`, `deadlocks`, `blocked_process_reports`, `dmv_blocking_snapshots`, `memory_grant_stats`, `waiting_tasks`, `session_stats`, `session_summary_stats`, `running_jobs`, `database_size_stats`, `index_object_stats`, `server_properties`, `system_health_events`, and the four config snapshots (`server_config`, `database_config`, `database_scoped_config`, `trace_flags`) | +| **V2** — observability | `servers` (registry, upserted on every successful connect: identity, display name, engine edition, major version) and `collection_log` (one row per collector run: SUCCESS / PERMISSIONS / ERROR, row count, SQL-phase and storage-phase timings) | +| **V3** — alerting | `config_alert_log` (one history row per fired alert), `config_edge_trigger_watermarks` (restart-surviving edge-trigger and failed-job watermarks), `config_mute_rules` (alert mute rules; starts empty) | +| **V4** — analysis | `analysis_findings` (persisted findings incl. the stored remediation action), `analysis_muted` (muted finding patterns), and 17 `v_<table>` passthrough views so the shared analysis SQL runs verbatim against this store | +| **V5** — viewer passthrough views | The five remaining `v_*` passthrough views (`v_running_jobs`, `v_server_config`, `v_database_scoped_config`, `v_trace_flags`, `v_collection_log`) that complete the viewer's read layer | +| **V6** — memory passthrough views | `v_memory_clerks` and `v_memory_pressure_events`, the two views the Memory tab reads | +| **V7** — plan-capture columns | Nullable plan-XML columns for the viewer's View Plan surfaces: `procedure_stats.query_plan_xml`, `blocked_process_reports.blocked_query_plan_xml` / `blocking_query_plan_xml`, `deadlocks.victim_query_plan_xml` | +| **V8** — schema split (collect/config) | Moves the tables into the `collect` and `config` schemas (least-privilege security split); the shared SQL keeps using bare names, resolved via `search_path = collect, config, public` | +| **V9** — inventory + cost fields | `server_properties` inventory columns (`sqlserver_start_time`, `host_os_version`, `ag_replica_role`) and `servers.monthly_cost_usd` (the FinOps per-server budget) | +| **V10** — latch + spinlock collectors | `latch_stats` and `spinlock_stats` tables plus their `v_*` views | +| **V11** — CPU scheduler + plan cache collectors | `cpu_scheduler_stats` and `plan_cache_stats` tables plus their `v_*` views | +| **V12** — session summary collector | `session_summary_stats` (server-wide connection-leak / idle signal) table plus its `v_*` view | +| **V13** — system health events collector | `system_health_events` (raw `system_health` Extended Events capture) table plus its `v_*` view | +| **V14** — refresh passthrough views | `CREATE OR REPLACE` on every `v_*` view so a store upgraded across a column-adding migration picks up the new columns (Postgres freezes a view's `SELECT *` expansion at create time) | +| **V15** — index metadata columns | Per-index definition columns on `index_object_stats` (ordered key/included column lists, filter, uniqueness/constraint/FK flags, `is_disabled`, and the reconstruct-a-CREATE options — compression, fill factor, page/row locks, etc.) for monitor-side UNUSED/DUPLICATE index analysis, and refreshes `v_index_object_stats` | +| **V16** — server UTC offset | Nullable UTC-offset column on `server_properties` so the viewer can render timestamps in the monitored server's own local time (the Server-time display mode ported from Lite; Server-time = stored naive-UTC + this offset) | +| **V17** — config control plane | The viewer-writable DESIRED-state tables (`config_service`, `config_monitored_servers`, `config_alert_settings`, `config_collector_schedules`) plus a `config_version` reload beacon — statement-level bump triggers increment it on any write, and the service polls that one integer each sweep and reloads only when it changes. Server secrets are DPAPI blobs, never plaintext | +| **V18** — alert delivery mode | Global `delivery_mode` (Summary / PerEvent) + `per_event_max` on `config_alert_settings`, plus a nullable per-server `alert_delivery_mode_override` on `config_monitored_servers` (null = inherit the global), resolved through the shared `AlertDeliveryModeResolver` (#1236 / #1141) | +| **V19** — analysis state marker | `collect.analysis_state` — the service-produced per-server "insufficient data" marker (with message + time) the viewer reads, so a not-enough-history analysis pass surfaces a reason instead of a blank | +| **V20** — alert tuning knobs | The previously-hardcoded alert tuning the viewer now customizes on `config_alert_settings`: the long-running-query read shape (`long_running_query_max_results` + five noise-filter opt-outs the shared `AlertEngine` forwards) and `notify_connection_changes` (the Server-Unreachable / Restored connect-edge gate) | +| **V21** — default trace events collector | `default_trace_events` table + its `v_*` view — the significant Default Trace events (file growth, ErrorLog, security audit, optional Object DDL) the viewer's System Events tab reads | +| **V22** — index-object latest index | The engine-agnostic `idx_index_object_stats_latest` partial index backing the latest-capture-per-index reads | +| **V23** — collection-log hypertable | Converts `collection_log` to a TimescaleDB hypertable (an object-invisible no-op on plain PostgreSQL) | +| **V24** — job history collector | `job_history` table + its `v_*` view — the SQL Agent Job History surface (#1433) | +| **V25** — agent status collector | `agent_status` table + its `v_*` view — SQL Agent up/down status (#1433) | +| **V26** — generic webhook channel | The generic-webhook columns on `config_notification` (`generic_url`, `generic_headers`, `generic_body_template`, `generic_proxy`) for POSTing alerts to any endpoint (#1506) | +| **V27** — deadlocks database name | `deadlocks.database_name` (the Azure SQL DB per-database deadlock-capture watermark key, #1535) and a refreshed `v_deadlocks` | +| **V28** — Query Store replica role | `query_store_stats.replica_role` (SQL Server 2022+ AG secondary-replica attribution, #1546) and a refreshed `v_query_store_stats` | +| **V29** — long-query completions collector | `collect.long_query_completions` + its index — the opt-in long-running-query completion trace's store table (#1496) | +| **V30** — web dashboard config | `config_service.web_enabled` + `web_port` — the read-only web dashboard's live enable/port toggle, the twin of `mcp_enabled`/`mcp_port` (#1562) | + +All timestamps in the store are **naive-UTC** `timestamp` columns — the product-wide cross-store contract (Lite's DuckDB does the same). + +### TimescaleDB (Optional, Auto-Adopted) + +At startup, right after migration, the service attempts `CREATE EXTENSION IF NOT EXISTS timescaledb` and checks `pg_extension`: + +- **Present** — every collector table is converted to a hypertable (partitioned on its own time column into **1-day chunks**, existing rows migrated) and gets a compression policy: chunks older than **1 day** compress automatically (segmented by `server_id`). The short intervals matter at the 1-minute collection cadence — a chunk cannot compress until it closes and then ages, so TimescaleDB's 7-day default left the store fully uncompressed for ~2 weeks (a near-idle 5-server fleet still reached ~1 GB in a couple of days); 1-day chunks + 1-day compress keep it compact (measured ~16.7x on perfmon, ~6.4x on the plan-XML-heavy query_stats). Compressed chunks stay fully queryable — this is Darling's archival tier, the centralized-store answer to Lite's Parquet archive. Everything is idempotent and re-converges on every service start; a table that fails conversion stays a plain table and keeps working. +- **Absent** — the service logs one Information line and runs in plain-PostgreSQL mode, which is a fully supported configuration, not a degraded one. + +`IF NOT EXISTS` short-circuits before privilege checks, so a store whose administrator pre-created the extension works for a service login that could never create it. + +### Retention + +A purge runs on the first sweep after startup and then daily, driven by the same shared per-collector horizons Lite uses: + +| Horizon | Tables | +|---|---| +| 7 days | `query_snapshots`, `waiting_tasks`, `running_jobs` | +| 30 days | Most collector tables (wait/query/procedure/Query Store stats, CPU, memory, file I/O, tempdb, perfmon, deadlocks, blocking, sessions, config snapshots), plus `collection_log` and `analysis_findings` | +| 90 days | `database_size_stats`, `index_object_stats` | +| 365 days | `server_properties` | + +On plain PostgreSQL the purge is DELETE-based. With TimescaleDB it switches to `drop_chunks` — a metadata-only detach of whole expired chunks (rows inside a partially-expired chunk survive until the whole chunk ages out; up to ~1 day of grace at the 1-day chunk width), with a per-table DELETE fallback for any table that is not a hypertable. Failure-isolated per table: one stuck purge is logged and retried the next day without stopping the sweep. + +### Logs + +The service's PRIMARY log is a **rolling file** under `%ProgramData%\PerformanceMonitorDarling\logs\darling-service_yyyyMMdd.log` — every collector run line, connect edge, reload notice, warning, and error lands there (buffered writes, one file per day, 14-day retention, and a logging failure can never crash the service). Console runs write the same file plus console output. + +Warnings and errors also go to the **Windows Application event log** (source `PerformanceMonitor Darling`) — but only if that event source exists. Registering an event source requires elevation, and the recommended `NT SERVICE` virtual account cannot do it, so run the `New-EventLog` line in the install steps above (or any elevated run of the exe) once; without it, Windows silently drops the events and the file log is your only surface. Collection outcomes are also queryable in the store itself — `collection_log` records every collector run per server with status and timings, and the viewer's Collection Health tab renders exactly that. + +### The Viewer + +`PerformanceMonitor.Darling.Viewer.exe` is a WPF app that talks **only to the PostgreSQL store** — it never connects to your monitored SQL Servers. It reads the same `darling.json` the service uses, but only the `postgres` section, resolved in the same order (explicit path, then `DARLING_CONFIG`, then `darling.json` next to the binary) plus one viewer-only fallback: the parent directory, so the release zip's layout — viewer in a `viewer\` subfolder, `darling.json` beside the service exe — works with no setup. A viewer seat on another machine needs only a minimal `darling.json` containing the `postgres.connectionString`. If the file is missing it shows a hint instead of crashing. + +The layout mirrors the Lite desktop app: a left sidebar lists the servers from the `servers` registry the service maintains, and the top tab strip holds three fixed **aggregate tabs** — Overview, Recommendations, and Alerts — alongside a closable **per-server tab** for each server you open. Overview (the all-servers server-cards grid) and Alerts (the all-servers alert history) span every server; Recommendations has its own server selector, independent of the sidebar. **Double-click a server** in the sidebar — or **double-click its Overview card** — to open (or focus) its tab, and close it with the × on the tab header; an empty-state panel is shown until the store has at least one server. + +Each per-server tab has fourteen inner tabs: + +| Inner tab | Contents | +|---|---| +| **Overview** | Five correlated, X-axis-synced timeline lanes over the last 24 hours — CPU % (SQL Server vs SQL+other Total), total wait ms/sec, blocking + deadlocking, buffer pool MB, and file-I/O latency — each with a ±2σ baseline band and anomaly markers, all sharing one crosshair so a spike in one lane lines up against the others | +| **Wait Stats** | A searchable wait-type picker (poison + usual-suspect + `PAGELATCH_` defaults, checked-to-top, a 30-type selection guide) beside a per-**type** trend chart for the checked types over the last 24 hours, with a Wait Time (ms/sec) ↔ Avg Wait Time (ms/wait) metric toggle — the per-type companion to the Overview's single total-wait lane | +| **Queries** | Six sub-tabs over the last 24 hours — **Performance Trends** (a 2×2 of per-second trend charts: query duration, procedure duration, Query Store duration, execution count), **Active Queries** (the ~26-column filterable snapshot grid of captured running queries with a time-range slicer, a **Latest Snapshot** button that re-reads the newest stored capture, and per-row Estimated / Actual plan buttons that open the stored plan in the Plan Viewer), **Top Queries by Duration** (the full query-stats grid with in-grid bar cells for executions/CPU/duration/reads and a CPU-by-database breakdown), **Top Procedures by Duration**, **Query Store by Duration**, and **Query Heatmap** (query counts per 5-minute bin × per-execution magnitude bucket, by a chosen metric; right-click a cell to drill into Active Queries for that window) — the three grids each carry a time-range slicer (drag to narrow the window) and a shared **Compare** control that overlays the current window against a baseline period (yesterday, last week, or same day last week), flagging new and vanished queries | +| **Plan Viewer** | Hosts execution plans as closable sub-tabs (the shared plan-viewer control, the same one Lite and the Dashboard use). Right-click a **Top Queries** or **Query Store** row and choose **View Plan** to open the plan the service captured for it (`query_stats.query_plan_xml` / `query_store_stats.query_plan_text`); Top Queries rows also carry a **Query Plan** column whose Download button saves the stored plan as a `.sqlplan` file (enabled only when a plan was captured). Top Procedures and the blocking / deadlock reports deliberately do **not** surface a plan here — procedure plans aren't stored, and blocked-process / deadlock rows carry only a `sql_handle` (not plan XML); resolving either to a plan needs a live SQL connection the viewer never makes. "Get Actual Plan" (a live re-execution) is likewise out | +| **CPU** | Raw per-sample CPU utilization (SQL Server vs other processes) over the last 24 hours — every ring-buffer sample, full-bleed as two series; the Overview's CPU lane plots the same raw samples compactly (SQL vs SQL+other Total) with a baseline | +| **Memory** | Four sub-tabs over the last 24 hours — **Overview** (a summary strip of physical / SQL Server / target / buffer pool / plan cache / page-file memory plus the system memory state and model, over a Total-vs-Target-vs-Buffer-Pool memory trend with a memory-grants overlay), **Memory Clerks** (a searchable clerk-type picker — top-5 default, checked-to-top, clear-only-the-filtered — beside a per-clerk memory trend for the checked clerks with a non-buffer-pool total and top-clerk summary), **Memory Grants** (per-resource-pool grant sizing — available / granted / used MB — and activity — grantees / waiters / timeouts / forced grants), and **Memory Pressure Events** (hour-bucketed stacked bars of `RING_BUFFER_RESOURCE_MONITOR` pressure, SQL Server vs OS, medium vs severe) | +| **File I/O** | Two sub-tabs over the last 24 hours — **Latency** (per-file read and write latency, with a dashed queued-I/O overlay) and **Throughput** (per-file read and write MB/s) — the top 10 files by activity | +| **tempdb** | Three stacked charts over the last 24 hours — space usage (user / internal objects / version store), total allocated size, and per-file I/O latency | +| **Blocking** | Four sub-tabs over the last 24 hours — **Trends** (lock-wait rate, blocking incidents, deadlocks), **Current Waits** (waiting-task duration by wait type, blocked sessions by database), **Blocked Process Reports** (the full ~25-column filterable grid — XE reports preferred with the always-on DMV blocking snapshot merged in as fallback, each row badged with its source, a time-range slicer, per-row report-XML save, and long-block highlighting; double-click or right-click **View Block Chain** to reconstruct and draw the blocking chain the row belongs to), and **Deadlocks** (one filterable row per process parsed from each deadlock graph, a slicer, per-row graph-XML save; double-click or right-click **View Deadlock Graph** to draw the deadlock graph) | +| **Perfmon** | A searchable counter picker with the shared counter packs (General Throughput, Memory Pressure, CPU / Compilation, I/O Pressure, TempDB Pressure, Lock / Blocking) beside a per-counter delta trend for the checked counters (up to 12) over the last 24 hours | +| **Running Jobs** | Latest snapshot of currently-running SQL Agent jobs — start time, current vs average vs p95 duration, % of average, and a highlighted row when a job is running past its p95 (a store-derived banner appears when the service's login lacks msdb access) | +| **Configuration** | Four column-filterable snapshot grids of the server's latest capture — server configuration (`sys.configurations`), database configuration (28 columns of `sys.databases`), database-scoped configuration, and trace flags | +| **Daily Summary** | A one-row roll-up of the selected day (default today, UTC, with a date picker) — total wait time, the top wait type, distinct query count, deadlock / blocking-event / high-CPU-sample counts, collector errors, and an overall health band | +| **Collection Health** | Three sub-tabs — **Health Summary** (a 7-day per-collector roll-up: run / success / error counts, failure rate, average duration, last success / run / error, and a health band of HEALTHY / WARNING / STALE / FAILING / NEVER_RUN / NO_PERMISSIONS — double-click a collector to open its full run history), **Collection Log** (the recent run log with per-run SQL and store-write timings and row counts), and **Duration Trends** (a per-collector success-duration scatter) | + +The three aggregate tabs — **Overview** and **Alerts** span every server; **Recommendations** has its own server selector, independent of the sidebar: + +| Tab | Contents | +|---|---| +| **Overview** | A card per registered server (all servers, not the sidebar selection): server name + status dot, CPU (total non-idle with the SQL-only number alongside), memory, blocking and deadlock counts over the last hour, and last-collection time, each colour-banded (CPU ≥ 80% red / ≥ 50% amber / green; blocking and deadlocks red-or-amber when present) with a red **Offline** overlay. Status is derived from **collection freshness** — the newest `collection_log` age — rather than a live ping (the viewer never connects to the monitored servers): fresh is Online, older than twice the fastest collector's one-minute cadence is a Warning, and no recent collection is Offline. **Double-click a card** to open that server's tab. Refreshes every 30 seconds | +| **Recommendations** | The latest analysis run's findings for the tab's **own selected server** — a server selector independent of the sidebar, a Refresh button, and a status line showing the last analysis time — re-skinned to Lite's advise-only **card** design: a scrollable list of collapsible **incident** sections, each holding severity-banded cards (a severity badge, the affected `[database]`, the title, and the advice). Every card offers **Ask AI** (copies an MCP investigation prompt referencing `analyze_server` / `get_analysis_findings`); a card whose stored remediation carries a copy-paste statement also offers **Copy fix** (copies the suggested T-SQL). Advise-only — the viewer never applies anything, and there is no mute affordance here (alert muting lives on the Alerts surface). There is no in-app "Generate now": the service runs analysis on its own 30-minute cadence, so the status line surfaces the last analysis time instead | +| **Alerts** | The full alert history from `config_alert_log` across **all servers** (newest first, selectable time range), with a Server column and a Server filter. Double-click a row (or **View Details**) for a modal detail window showing the alert's stored detail and structured advice / remediation / drill-down from its dedup-fingerprint context. **Dismiss Selected / Dismiss All** hide alerts from the view (a durable `dismissed` flag on `config_alert_log`); column filters, Copy Cell/Row/All, and Export to CSV match Lite's grid. Right-click to **Mute This Alert** or **Mute Similar** (metric-only), and a **Manage Mute Rules** button opens the mute-rule editor | + +Only the visible tab loads (Lite's visible-only rule). The Alerts tab and the visible server tab's active inner tab refresh every 60 seconds; the Overview refreshes on its own faster 30-second timer (Lite's Overview cadence); and **Recommendations** refreshes on tab activation, its Refresh button, and its own server-selector change only, never on the timer — its findings change on the service's 30-minute analysis cadence, so a 60-second auto-refresh would be pointless churn (and would reset the incident expanders under the reader), matching Lite. + +The viewer is read-only over collected data, but it does perform a small set of **user-initiated writes** — and those go straight to the PostgreSQL store, which is the coordination point (the service honors them on its next read; there is no viewer-to-service channel). From the Alerts tab, creating a mute rule from an alert (**Mute This Alert** / **Mute Similar**) or adding, editing, toggling, deleting, or purging one via **Manage Mute Rules** writes `config_mute_rules` (a rule scopes to a server by name, exactly as Lite's mute rules do); and **dismissing alerts** sets the `dismissed` flag on `config_alert_log` so they drop out of the Alert History view (a single atomic UPDATE — Darling has no parquet archive tier, so there is no dismissed-archive sidecar). The viewer never writes collector data. + +### Restart Semantics + +The service is built to restart cleanly, any time: + +- **Delta continuity** — delta-based collectors (wait stats, file I/O, perfmon, memory grants) re-seed their baselines from the store at startup, so the first cycle after a restart produces real deltas instead of zeroes. +- **Alert no-re-fire** — edge-trigger watermarks and the failed-job watermark persist in `config_edge_trigger_watermarks`, and per-alert cooldowns re-seed from `config_alert_log`, so a restart does not replay alerts you already received. +- **Idempotent store setup** — migrations are versioned and skip what is already applied; TimescaleDB conversion and compression policies re-converge as no-ops. +- **Per-connect snapshots** — the on-connect config snapshot collectors run once per (re)connect, mirroring Lite's server-open behavior. +- Mute rules (`config_mute_rules`) load once at service startup — restart the service after adding rows. + +A monitored server that is down is retried every 60 seconds forever; a collector that errors is logged and retried at its next scheduled time; a mid-cycle connection-level failure forces a clean reconnect and re-probe. The loop never dies for one bad cycle. + +--- + +## Troubleshooting + +**"Cannot load configuration"** (critical, service idles) — no `darling.json` was found at the resolved path. The message names the path it tried; copy `darling.sample.json` there or point `DARLING_CONFIG` at your file. + +**"Configuration problem: ..."** (critical, service idles) — validation failed. The messages are literal and per-field, e.g. `postgres.connectionString is required.`, `servers must contain at least one entry.`, `server 'X': host is required.`, `server 'X': sql auth requires username.`, `server 'X': sql auth requires encryptedPassword (preferred; see --encrypt-password) or password.`, `server 'X': auth must be 'integrated' or 'sql'`. Fix the file and restart the service. + +**"Cannot reach or migrate the Postgres store"** (critical, service idles) — the store connection string is wrong, PostgreSQL is down/unreachable, or the login cannot create tables. Collection does not start until this succeeds; fix and restart. + +**"uses a plaintext password in darling.json"** (warning, every connect) — you set `"password"` instead of `"encryptedPassword"`. It works, but run `--encrypt-password` on the service machine and switch. + +**DPAPI decrypt fails after moving darling.json** — `encryptedPassword` blobs are machine-bound (DPAPI LocalMachine). Re-run `--encrypt-password` on the new machine. + +**"Failed to ensure XE sessions"** — the login lacks `ALTER ANY EVENT SESSION` (or the database-scoped equivalent on Azure SQL Database). Deadlock and blocked-process collection read zero rows until the sessions exist; grant the permission or have an administrator create/start `PerformanceMonitor_Deadlock` and `PerformanceMonitor_BlockedProcess`. "Already exists / already started" XE errors are logged as benign and mean the sessions are up. + +**Blocked-process reports empty** — the blocked-process threshold may still be 0. On AWS RDS set `blocked process threshold (s)` via a Parameter Group (the `sp_configure` bootstrap cannot run there); on Azure SQL Database the threshold is fixed at 20 seconds. Blocking stays visible either way through the always-on DMV blocking snapshot. + +**`PERMISSIONS` rows in `collection_log`** — that collector's reads were denied (SQL errors 229/297/300). Check the [permissions](#permissions-on-monitored-servers); the collector retries every cycle and recovers as soon as the grant lands. + +**"Skipping recently-failed-job check"** (info) — the login has no msdb / `SQLAgentReaderRole` access, so failed-job alerts are skipped. Expected for minimal-privilege monitoring logins; grant the role if you want job alerts. + +**"TimescaleDB setup failed — continuing in plain-PostgreSQL mode"** (warning) — the extension exists but conversion hit a problem. Everything still works (DELETE-based retention, plain tables); conversion is retried on the next service start. + +**"Why are there 40+ postgres.exe processes?"** — the count is three populations, and only one is client connections: (1) PostgreSQL's own system processes (postmaster, checkpointer, WAL/background writers, autovacuum, stats); (2) **TimescaleDB background workers** — the managed conf sizes `timescaledb.max_background_workers` to the hypertable count + 2 (≈38), and every RUNNING compression/retention policy job is its own process, so the count legitimately surges during checkpoint/compression waves and falls back when they finish; (3) client backends — the service's pools are capped at 24, the co-located viewer's at 10. Decompose it live with: `SELECT backend_type, count(*) FROM pg_stat_activity GROUP BY backend_type ORDER BY 2 DESC;` — and remember Windows charges the shared buffer segment to every attached process's working set, so per-process memory numbers cannot be summed. + +**query_store bursts every ~15 minutes** — two or three near-empty cycles, then one large one, is Query Store's own behavior, not a collector bug: the engine buffers in memory and flushes to its persisted tables on `DATA_FLUSH_INTERVAL_SECONDS` (default 900s), so the collector genuinely sees nothing new between flushes. Narrowing the collection interval will not smooth it. The per-database log lines show which database drove a burst. + +**MCP client cannot connect** — MCP defaults to off. Enable it live from the Viewer's Settings (the checkbox writes the control plane; the service starts the endpoint within seconds, no restart), or set `mcp.enabled: true` in `darling.json` for a file-seeded install. If the log says `Port 5152 is already in use — MCP server not started`, change `mcp.port`. The MCP server binds to `localhost` only unless you opt into a LAN endpoint (see [Opt-in Network Endpoints (LAN)](#opt-in-network-endpoints-lan)); a remote client that gets 401 is missing or mismatching the required bearer token, and one that is refused before any response is outside the configured `allowFrom` CIDR. + +**Recommendations tab says no findings** — analysis runs every 30 minutes per server but only once the store holds at least 24 hours of collected data for that server; a fresh install simply has not earned findings yet. + +--- + +## How It Runs (Reference) + +Fixed cadences, hardcoded on purpose: + +| What | Cadence | +|---|---| +| Collector sweep loop | Every 15 seconds (each collector runs when its own shared schedule is due — most every 1 minute, some every 5, sizes hourly, index stats daily) | +| Alert evaluation | Every 30 seconds per connected server (Lite's overview cadence) | +| Scheduled analysis | Every 30 minutes per server, 120-second budget, analyzing the last 4 hours; findings persist to `analysis_findings` and high-severity ones notify through the configured channels | +| Retention purge | First sweep after startup, then daily | +| Reconnect attempts | Every 60 seconds while a server is unreachable | + +--- + +## Managed Bundled PostgreSQL + +With `postgres.managed = true` (the sample's default), the service runs its own bundled PostgreSQL 18 + TimescaleDB and a from-zero install needs no database provisioning at all. Windows only, like every DPAPI surface here. + +```json +{ + "postgres": { + "managed": true, + "port": 5641, + "dataDirectory": null + } +} +``` + +**What first run does.** The service looks for `pg-runtime\pgsql\` beside its binary, extracting it from `pg-runtime.zip` when only the zip is present (deleting the extracted directory is therefore always safe — it self-heals). If the data directory has no cluster, it generates a 32-character random password, protects it with DPAPI LocalMachine into `pg-credential.dpapi` beside the data directory (credential first, so a crash mid-initdb never strands a cluster nobody can log into), then runs `initdb` with `scram-sha-256` auth, data checksums, and UTF8/C locale. A marker-guarded block appended to `postgresql.conf` preloads TimescaleDB, sets the port, and restricts listening to `127.0.0.1`; a second versioned block sizes background workers up for the per-hypertable compression jobs (`timescaledb.max_background_workers = 28`, `max_worker_processes = 40` — PostgreSQL's default of 8 workers cannot launch them); a third versioned block sizes memory from the host's physical RAM for the up-to-500-servers case (`shared_buffers = min(25% RAM, 8GB)`, `effective_cache_size = 75% RAM`, `maintenance_work_mem = min(5% RAM, 1GB)`, and a deliberately-modest per-connection `work_mem = clamp(RAM/512, 16MB, 64MB)` — on an 8 GB box that is `shared_buffers 2048MB` / `work_mem 16MB`; the stock 128 MB / 4 MB defaults are fine at small scale but bottleneck at fleet scale). All three appends are re-checked on every start, so a crash between initdb and the append heals itself instead of silently degrading — and clusters initialized before the worker or memory sizing existed gain the missing block on their next start (effective at the next PostgreSQL restart). Then `pg_ctl start`, `CREATE DATABASE darling`, and the normal startup path (migrations, TimescaleDB adoption — you should see `32/32 collector table(s) are hypertables`) continues exactly as in bring-your-own mode. The connection string is derived from the stored credential; the Viewer and the MCP host on the same machine derive it the same way, so nothing needs configuring there either. + +**Why scram and not trust, even loopback-only.** Trust auth would hand superuser to any local code that can open a loopback socket — every other local user, and network-capable-but-not-filesystem-capable attack primitives like SSRF from a co-hosted app. With scram the credential travels on the wire, failed attempts are auditable, and access is confined to what can read the DPAPI-protected credential file. `listen_addresses = '127.0.0.1'` keeps the server unreachable off the machine on top — unless you deliberately opt into a LAN endpoint (see [Opt-in Network Endpoints (LAN)](#opt-in-network-endpoints-lan)), which reconciles `listen_addresses`, a `hostssl` pg_hba rule, and TLS on every start and is otherwise off. + +**Lifecycle.** On shutdown the service stops the server (`pg_ctl stop -m fast`) **only when it started it**. A server that was already running — an operator's own `pg_ctl`, or a postmaster that survived a service crash — is adopted for connections but never stopped: you'll see `already running … will not stop it` in the log, and the service keeps collecting into it. + +**The runtime zip.** `pg-runtime.zip` ships beside the service binary in packaged releases. Building from source, produce it once with `Darling\tools\fetch-pg-runtime.ps1` — it downloads the pinned EDB PostgreSQL 18 binaries and TimescaleDB, verifies their SHA256, prunes what the service doesn't need, and writes the zip to `Darling\artifacts\`; copy it next to the built service exe. + +**Server log.** The bundled server's own log is `pg.log` beside the data directory — that's where PostgreSQL explains a refused start; bootstrap errors in the service log quote its tail. + +## Security & Least-Privilege Roles + +The store is split into two schemas so that no consumer connects with more privilege than it needs: + +- **`collect`** — the 32 collector hypertables plus the service-written, user-read metadata (`servers`, `collection_log`, `analysis_findings`, the `v_*` views). Read-only to everyone but the service. +- **`config`** — exactly the tables a human operator changes through the Viewer or MCP: `config_mute_rules`, `config_alert_log` (alert dismissals), `config_edge_trigger_watermarks`, and `analysis_muted`. + +Table names are unchanged — only their schema moved — and the shared SQL keeps using the bare, unqualified names, resolved through `search_path = collect, config, public` (set as the database default and carried on the managed connection strings). This is deliberate: Darling's SQL is byte-identical to Lite's DuckDB SQL, and re-qualifying it would fork that twin. + +**The roles.** The service still owns the store as the `darling` superuser (it does the DDL — migrations, hypertable conversion, retention). On top of that, **managed mode provisions three least-privilege login roles** (BYO provisions two — see below): + +| Role | Privileges | Used by | +|---|---|---| +| `darling` | superuser / owner | the service (collection, migration, provisioning) | +| `admin` | SELECT on both schemas + INSERT/UPDATE/DELETE on `config` only | the Viewer, by default (`connectAs: "admin"`) | +| `viewer` | SELECT on both schemas + INSERT/UPDATE/DELETE on `config.custom_views` only (the web composer's saved views) | a locked-down Viewer (`connectAs: "viewer"`) | +| `mcp` | `viewer`'s exact read surface + INSERT on `collect.analysis_findings` / `config.analysis_muted` + INSERT/UPDATE/DELETE on `config.custom_views` (the custom-view tools) + the alert-tuning writes (INSERT/UPDATE/DELETE on `config.config_mute_rules`, UPDATE on `config.config_alert_settings`, and the `config_service` reload-beacon columns) + the server-onboarding writes (INSERT/UPDATE/DELETE on `config.config_monitored_servers` — the credential column stays SELECT-carved, so it can WRITE a password blob but never READ one back) | the store identity the opt-in MCP **network** endpoint connects as (managed only); dormant until MCP is exposed on the LAN | + +`admin` cannot `DROP`, alter schema, touch `collect` data, or create objects — it can only do what the Viewer's mute-rule / alert-dismiss surfaces need. The `mcp` role is narrower still: it reads exactly what `viewer` reads (the secret config columns are carved out identically) and its writes are a small, enumerated set — the two analysis-table INSERTs (`analyze_server` + `mute_analysis_finding`), the single-table `config.custom_views` CRUD (the custom-view tools), the alert-tuning writes (`config.config_mute_rules` CRUD + a single-row `config.config_alert_settings` UPDATE, plus the two `config_service` beacon columns so a settings write's self-bump trigger can fire), and the server-onboarding writes (`config.config_monitored_servers` CRUD for `add_servers` / `remove_server` — its `config_monitored_servers` write fires the SAME `config_service` beacon trigger, already covered by that column grant) — so a token-holder on the network MCP endpoint can never reach the `config`-table service-credential pivot, the secret columns, or a service flag like `paused`. Even on `config_monitored_servers`, which it may write, the `encrypted_password` column stays in the fail-closed secret carve, so `mcp` can WRITE a credential blob (onboarding) but can never READ one back. `ALTER DEFAULT PRIVILEGES` means new collector tables auto-inherit SELECT for `admin`/`viewer`, so the model never drifts as collectors are added (every `mcp` write is an explicit single-table/single-column grant, deliberately not schema-wide). + +**Managed mode** provisions all of this automatically on every start (idempotent and self-healing), generating a per-role DPAPI-LocalMachine credential — `pg-admin-credential.dpapi`, `pg-viewer-credential.dpapi`, and `pg-mcp-credential.dpapi` beside the data directory, same posture as the owner's `pg-credential.dpapi`. Nothing to configure beyond `connectAs`. + +**Credential file protection.** DPAPI LocalMachine scope is deliberate (the service writes the credential, a *different* interactive user's Viewer reads it), which means the machine-bound blob is decryptable by anything that can *read* the file. So the credential files are locked down with an NTFS ACL that strips the inherited world-read `%ProgramData%` would give them: + +| File(s) | Readable by | +|---|---| +| `pg-credential.dpapi` (superuser), `pg-mcp-credential.dpapi` (the network MCP role) + the transient init pwfile | SYSTEM, Administrators, the service account — **not** interactive users | +| `pg-admin-credential.dpapi`, `pg-viewer-credential.dpapi` | the above **+ `NT AUTHORITY\INTERACTIVE`** (the operator's Viewer) | + +`pg-mcp-credential.dpapi` sits with the superuser (non-interactive) rather than with the Viewer's credentials because only the in-service MCP host reads it — never an interactive Viewer. + +The principal model assumes the **single-operator VM** this edition targets: `INTERACTIVE` == the operator, so the admin/viewer credentials are readable by the Viewer with zero configuration, while non-interactive local code (other services, sandboxed/SSRF socket primitives, scheduled tasks) and the superuser credential are excluded outright. On a shared machine where untrusted users log on interactively, tighten those two files to the specific operator account by hand. The service also refuses to trust a credential file that isn't owned by SYSTEM/Administrators/itself (closing a pre-plant attack), and regenerates an untrusted role credential. + +**A read-only (`viewer`) Viewer degrades gracefully.** It probes its own privileges on connect (`has_table_privilege`), so the mute-rule Add/Edit/Toggle/Delete/Purge buttons and the alert Dismiss / Dismiss All buttons are hidden or disabled, and any write that still slips through returns a clear "read-only connection" message instead of an error. + +**Bring-your-own PostgreSQL.** The schema split runs everywhere (it's a migration — the service applies it on startup and best-effort sets the database `search_path`; if your collection login can't `ALTER DATABASE`, run that one statement yourself as the owner). Role provisioning is managed-only, so for BYO you create the roles yourself, once, with the shipped script: + +``` +psql -h <host> -U <owner> -d darling -f Darling/tools/provision-roles.sql +``` + +Edit the two password placeholders (and the database/owner names if yours differ) first. Then point a read-only Viewer's `connectionString` at the `viewer` role. **`provision-roles.sql` creates two login roles — `admin` and `viewer`** — the two the Viewer connects as. Managed mode creates a third, `mcp`, but BYO deliberately does not: the MCP **network** endpoint (the only consumer of the `mcp` role) is managed-mode-only, and a BYO operator governs their own PostgreSQL's network exposure. If you expose MCP through your own reverse proxy against a BYO store, point it at whichever least-privilege role you choose (the `viewer` role covers the read tools; `analyze_server`'s finding persistence and `mute_analysis_finding` need INSERT on `collect.analysis_findings` / `config.analysis_muted`). + +## Opt-in Network Endpoints (LAN) + +By default both network surfaces bind **loopback only** — the store to `127.0.0.1`, the MCP server to `localhost` — exactly as they always have. Two optional, independent opt-ins let a remote viewer or MCP client on your **trusted LAN** reach them. This is a home-lab / trusted-subnet feature: **never expose either endpoint to the internet.** Both are **managed-mode only** (in bring-your-own mode your own PostgreSQL / reverse proxy governs exposure, and the config is ignored with a warning), and both are **fail-closed** — any invalid or incomplete field degrades that endpoint back to loopback and logs a critical line rather than exposing it. Removing the config on the next restart closes the box again. + +### Guided setup (`--configure-network`) + +The fastest path is the interactive wizard — run it on the **service host**: + +``` +PerformanceMonitor.Darling.Service.exe --configure-network +``` + +It shows the current exposure (read from the service's own resolvers), then walks you through the **store**, **MCP**, or **both** (or a **disable** that removes exposure). Every answer is validated **by delegation to the exact checks the running service fail-closes on**, so the wizard can never write a config the service would refuse — it re-prompts with the resolver's own reason. It generates the MCP bearer token for you (DPAPI-protected; the plaintext is printed once, so save it then), edits `darling.json` **in place preserving every comment** behind a timestamped `darling.json.bak-<timestamp>` backup, prints the scoped firewall command(s) and the `--print-viewer-connection` handoff, and offers to restart the service to apply. `install-darling.ps1 -Network` runs it automatically right after the install reaches Running. The manual field reference below documents exactly what it writes. + +### Headless enable/disable + firewall (`--enable-mcp` / `--enable-web`) + +On a box with no Viewer, two things are otherwise awkward: the `enabled` flags in the `mcp` / `web` blocks below are only a **first-run seed** — after the first run the store (`config.config_service.mcp_enabled` / `web_enabled`) is authoritative and is normally flipped only from the Viewer's Settings — and the service account (`NT SERVICE\PerformanceMonitor Darling`) **cannot open the firewall itself**. Four verbs, run on the **service host**, close both in one elevated action: + +``` +PerformanceMonitor.Darling.Service.exe --enable-mcp +PerformanceMonitor.Darling.Service.exe --disable-mcp +PerformanceMonitor.Darling.Service.exe --enable-web +PerformanceMonitor.Darling.Service.exe --disable-web +``` + +Each flips only its endpoint's **live store flag** with a targeted `config_service` write; the service **hot-reloads within one collection sweep — no restart.** If that endpoint's `network` block opts into LAN exposure (a non-loopback `listen`), the verb also reconciles the **same scoped, idempotent-by-name firewall rule the service would**: **run elevated**, it opens (or, on `--disable-*`, removes) the rule; **run non-elevated**, the store toggle still succeeds and it prints the exact elevated firewall command to run by hand (a loopback-only endpoint needs no rule and says so). Managed-mode only, Windows only. So the headless bring-up is: write the `network` block (the wizard above or the manual reference below), then `--enable-mcp` / `--enable-web` from an **elevated** shell. + +### Store endpoint (viewer over the LAN) + +Add a `network` block to `postgres` (managed mode): + +```json +"postgres": { + "managed": true, + "port": 5641, + "network": { + "listen": "192.168.1.205", + "allowFrom": "192.168.1.0/24", + "role": "viewer" + } +} +``` + +On every start the service reconciles this against the live cluster: it adds the bind IP to `listen_addresses`, generates a self-signed TLS certificate (`server.crt` / `server.key` beside the data directory, with both an IP SAN for `listen` and a DNS SAN for the machine hostname), writes a marked `hostssl darling <role> <allowFrom> scram-sha-256` rule into `pg_hba.conf` and reloads, and best-effort adds a firewall rule. + +- **`role`** — the pg_hba login role the rule names: `"viewer"` (default, **read-only** — the secure default, covering a laptop reading every dashboard, chart, and finding) or `"admin"` (full remote **writes**; the service logs a warning because `admin` holds the `config_command` / `config_monitored_servers` / `config_notification` service-credential pivot). Never the superuser. This is **distinct from `postgres.connectAs`** (the *local* VM viewer's loopback role, default `admin`): `network.role` is the *remote* role and defaults to `viewer`, so the two have opposite defaults — the local seat is writable, the remote seat is read-only, unless you say otherwise. +- **TLS is verify-full, not `require`.** Because Darling generates the cert, the client can pin it, so the connection string below uses `SSL Mode=VerifyFull` — which actually defends against an on-path MITM (`require` verifies nothing). The store's network pg_hba line is `hostssl`, so a non-TLS network client is refused. +- **The firewall is defense-in-depth, and you should set the scoped rule yourself** (the service's best-effort rule is a convenience). The store's real boundary is pg_hba + TLS: + + ``` + New-NetFirewallRule -DisplayName "Darling store (Postgres)" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 5641 -RemoteAddress 192.168.1.0/24 + ``` + +**Remote-viewer handoff.** On the **service host**, run: + +``` +PerformanceMonitor.Darling.Service.exe --print-viewer-connection +``` + +It decrypts the `network.role` credential and prints a paste-ready connection string plus the server certificate PEM. It prints a **live database password to STDOUT** — redirect it to an ACL'd file or pipe it to the clipboard (`... --print-viewer-connection | clip`); do not leave it in shell scrollback, CI logs, or a screenshare. On the **viewer machine**, set a minimal `darling.json` to bring-your-own mode and paste the string in verbatim (no viewer code path changes — the string is consumed as-is), then save the emitted PEM where `Root Certificate` points: + +```json +{ + "postgres": { + "managed": false, + "connectionString": "Host=192.168.1.205;Port=5641;Username=viewer;Password=...;Database=darling;Search Path=collect,config,public;SSL Mode=VerifyFull;Root Certificate=server.crt" + } +} +``` + +- **Certificate placement + rotation.** Save the emitted PEM at the `Root Certificate` path on the viewer machine (a bare `server.crt` resolves beside the viewer's working directory; an absolute path also works). The cert **auto-regenerates if the bind IP changes** (so verify-full keeps working after a `listen` change) — when that happens, clients must re-run `--print-viewer-connection` and replace their saved cert. To rotate on demand, **delete `server.crt` and `server.key`** beside the data directory; the service regenerates the pair on its next start. +- **Plaintext at rest on the viewer machine.** The pasted connection string holds the role password in cleartext in the laptop's `darling.json` (there is no client-side secret store yet). That is acceptable for the read-only `viewer` credential on a single-operator, ACL'd profile; if you use `role: "admin"`, treat that file as a secret and NTFS-ACL it to your account. DPAPI-encrypting the viewer's BYO connection string is future hardening, out of scope today. + +### MCP endpoint (assistant over the LAN) + +Add a `network` block to `mcp` (managed mode; `mcp.enabled` must be `true`): + +```json +"mcp": { + "enabled": true, + "port": 5152, + "network": { + "listen": "192.168.1.205", + "allowFrom": "192.168.1.0/24", + "encryptedToken": "<output of --encrypt-password>" + } +} +``` + +When `listen` is a network address **and** a token is present **and** `allowFrom` is a valid CIDR, the MCP host binds that interface behind two gates: a **required bearer token** (checked first, constant-time, no loopback exemption) and an **in-app CIDR check** on the remote address (loopback is always allowed, so local clients keep working). Any missing precondition keeps MCP loopback-only. Prefer `encryptedToken` (a DPAPI blob from `--encrypt-password`); a plaintext `token` works for dev but is warned. Set the same scoped firewall rule for the MCP port: + +``` +New-NetFirewallRule -DisplayName "Darling MCP" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 5152 -RemoteAddress 192.168.1.0/24 +``` + +**Blast radius, stated honestly.** The token gates the entire read surface, `analyze_server` (which opens **live outbound connections to your monitored SQL Servers** — the plan-fetcher), the Custom Views management tools, which **create / modify / delete** the saved dashboards and notebooks in `config.custom_views`, the alert-tuning tools (`update_alert_settings` / `create_mute_rule` / `delete_mute_rule`), which change the shared alert configuration the service delivers on, *and* the server-onboarding tools (`add_servers` / `remove_server`), which **add or remove the monitored servers themselves** in `config.config_monitored_servers` — including storing a SQL-auth **credential** for a server it adds. Treat the token as a high-value secret. The store-side identity is still the least-privilege `mcp` role: read, the two analysis-table INSERTs, INSERT/UPDATE/DELETE on the single `config.custom_views` table (the same narrow write the web composer's `viewer` role has), the narrow alert-config writes (`config.config_mute_rules` CRUD + a single-row `config.config_alert_settings` UPDATE, plus the `config_service` reload-beacon columns), and the single-table `config.config_monitored_servers` CRUD. So a token-holder can read everything collected, trigger analysis, author custom views, tune alerting, and onboard/offboard servers — but can never reach the `config_command` service-credential pivot, the carved secret columns (SMTP/webhook credentials, and the monitored-server `encrypted_password` blob it can WRITE during onboarding but never READ back, all included), or a service flag like `paused`, and a stored view is structurally incapable of reading a config control-plane table (a composed query names only `collect.*` collector tables). Custom-view JSON and alert config carry no secrets. + +**A monitored-server credential rides `add_servers` — mind the wire.** `add_servers` accepts a SQL-auth `password` in its request JSON; the service DPAPI-encrypts it at rest (never returns it), but it travels to the MCP endpoint in the clear on the same plaintext HTTP the token does. That is one more reason to **front the MCP port with the TLS reverse proxy below** on any segment you do not fully trust — the password, like the token, is captured by an on-path attacker otherwise. Prefer Windows/integrated auth for onboarded servers where you can (no per-server secret crosses the wire at all). + +**MCP has no TLS — the MITM control is a TLS reverse proxy.** A self-signed cert breaks real MCP clients, so the MCP endpoint is plain HTTP and the bearer token travels **cleartext on the segment**; an active on-path attacker (ARP spoof, rogue DHCP, compromised switch) could capture and replay it. The in-app CIDR bounds *who can route to* the port; it does **not** protect the wire. If your segment is not fully trusted, put a **TLS-terminating reverse proxy** in front of the MCP port and point clients at that — the named MITM control for this endpoint. (The store endpoint needs no such proxy: it has verify-full TLS built in.) + +### Web endpoint (browser over the LAN) + +Add a `network` block to `web` (managed mode; `web.enabled` must be `true`): + +```json +"web": { + "enabled": true, + "port": 5153, + "network": { + "listen": "192.168.1.205", + "allowFrom": "192.168.1.0/24", + "encryptedToken": "<output of --encrypt-password>" + } +} +``` + +When `listen` is a network address **and** a token is present **and** `allowFrom` is a valid CIDR, the web host binds that interface behind two gates: an **in-app CIDR check** on the remote address and an **access token**. A browser presents the token ONCE via `?token=` (open `http://192.168.1.205:5153/`, then paste it into the minimal login form, or append `?token=...` directly); the host validates it constant-time, sets an **HMAC-signed, HttpOnly, SameSite=Strict session cookie**, and 302-redirects to strip the token from the URL so it never lingers in history or a Referer header. Subsequent requests ride the cookie. **Loopback is always allowed tokenless, even while exposed** — unlike MCP, the read-only dashboard has no loopback-token requirement. An out-of-CIDR request is refused with **403**. The cookie signing key is per-process, so a service restart invalidates open sessions (just re-present the token). Prefer `encryptedToken` (a DPAPI blob from `--encrypt-password`); a plaintext `token` works for dev but is warned. Set the same scoped firewall rule for the web port: + +``` +New-NetFirewallRule -DisplayName "Darling Web" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 5153 -RemoteAddress 192.168.1.0/24 +``` + +**Blast radius.** The web dashboard is **read-only over the collected store** — it connects as the least-privilege `viewer` role and hosts no write paths and no live-server queries (no `analyze_server`, no plan re-execution), so a token-holder can view everything collected but change nothing and reach no monitored server. That smaller blast radius is why loopback stays tokenless here while MCP's does not. + +**Web has no TLS either — same reverse-proxy control as MCP.** The token/cookie travels cleartext on the segment, so the in-app CIDR bounds *who can route to* the port but does not protect the wire. On an untrusted segment, put a TLS-terminating reverse proxy in front of the web port. diff --git a/Darling/tools/fetch-pg-runtime.ps1 b/Darling/tools/fetch-pg-runtime.ps1 new file mode 100644 index 000000000..a177eb8bf --- /dev/null +++ b/Darling/tools/fetch-pg-runtime.ps1 @@ -0,0 +1,241 @@ +<# +.SYNOPSIS + Builds pg-runtime.zip - the bundled PostgreSQL 18 + TimescaleDB runtime that the Darling + service unpacks, initializes, and manages on first run (see DarlingManagedPostgres). + +.DESCRIPTION + Invoked by PACKAGING/CI before shipping the Darling service. It is NOT run on user machines + and NOT run at install time: users receive the finished pg-runtime.zip beside + PerformanceMonitor.Darling.Service.exe, and the service's first start extracts it to + pg-runtime\pgsql\ and runs initdb/pg_ctl from there (postgres.managed = true in darling.json). + The binaries are deliberately NOT committed to the repository. + + Steps: + 1. Download the pinned EDB PostgreSQL Windows x64 BINARIES zip (the no-installer, no-admin + archive) and the pinned TimescaleDB Windows release zip (the GitHub release asset - the + 'gh release download timescale/timescaledb' artifact, fetched by direct URL so the script + has no gh/auth dependency). + 2. Verify both SHA256 pins. A mismatch deletes the download and fails hard. + 3. Assemble pg-runtime\pgsql\ = EDB's pgsql\{bin,lib,share} only (pgAdmin, StackBuilder, + doc, include and the extra license baggage are deliberately dropped) with the + TimescaleDB files merged in per the proven recipe: + timescaledb*.dll -> pgsql\lib\ + timescaledb.control + timescaledb--*.sql -> pgsql\share\extension\ + 4. Zip the assembly to <OutputDirectory>\pg-runtime.zip (archive root = pgsql\, which is + exactly what DarlingManagedPostgres expects when it extracts beside the service binary). + + Downloads are cached in <WorkDirectory>\downloads and re-verified on every run, so re-runs + are cheap and a corrupted cache self-heals. Extract/assemble directories are rebuilt from + scratch every run. + + HASH PROVENANCE: the SHA256 pins below were computed 2026-07-08 from artifacts downloaded + from the exact URLs pinned here (EDB PG18.4 zip 337,444,127 bytes; TimescaleDB 2.28.1-for-PG18 + zip 7,794,443 bytes) - the same artifacts that built the DARLING01 managed-Postgres deployment. + Bumping a version means updating URL + hash TOGETHER, from a fresh download you hashed yourself. + +.PARAMETER OutputDirectory + Where pg-runtime.zip lands. Defaults to Darling\artifacts (gitignored). Packaging copies or + points the zip beside the service's published output. + +.PARAMETER WorkDirectory + Scratch space for downloads (cached) and extraction (rebuilt). Defaults to + <OutputDirectory>\pg-runtime-work. + +.PARAMETER KeepWork + Keep the extract/assemble directories after a successful run (the downloads cache is always + kept) - useful for inspecting the assembled tree or pointing DARLING_TEST_PGRUNTIME at + <WorkDirectory>\assemble\pg-runtime for the gated bootstrap E2E test. + +.EXAMPLE + pwsh -File Darling\tools\fetch-pg-runtime.ps1 +#> + +# pwsh 7+ only, and not for style: under Windows PowerShell 5.1 this script runs on .NET +# Framework, whose ZipFile.CreateFromDirectory writes BACKSLASH entry separators — a +# non-conformant zip that Info-ZIP/unzip mangles. CI runs this step under `shell: pwsh`; +# requiring 7 here makes a local build byte-behave like the shipped one. +#Requires -Version 7.0 +[CmdletBinding()] +param( + # Defaults resolved in the body rather than in param() so they key off $PSScriptRoot. + [string]$OutputDirectory = "", + [string]$WorkDirectory = "", + [switch]$KeepWork +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { + $OutputDirectory = Join-Path $PSScriptRoot '..\artifacts' +} + +# ---- Pinned artifacts (update URL + SHA256 together; see HASH PROVENANCE above) ------------- +$pgVersion = '18.4' +$pgUrl = 'https://get.enterprisedb.com/postgresql/postgresql-18.4-1-windows-x64-binaries.zip' +$pgSha256 = '7EFFE34C0BF89027B3F171447D351CBC460F4566C8D0F643DAEC67F140787858' + +$tsVersion = '2.28.1' +$tsUrl = 'https://github.com/timescale/timescaledb/releases/download/2.28.1/timescaledb-postgresql-18-windows-amd64.zip' +$tsSha256 = '533D1554F3EDFF1E0E86087E8A76A9EEFF253A9FEE17392FC17FAC1C85F7CF0F' +# ---------------------------------------------------------------------------------------------- + +Add-Type -AssemblyName System.IO.Compression.FileSystem +# PS 5.1 defaults to TLS 1.0/1.1 on some machines; both hosts require TLS 1.2+. +[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12 + +$OutputDirectory = [System.IO.Path]::GetFullPath($OutputDirectory) +if ([string]::IsNullOrWhiteSpace($WorkDirectory)) { + $WorkDirectory = Join-Path $OutputDirectory 'pg-runtime-work' +} +$WorkDirectory = [System.IO.Path]::GetFullPath($WorkDirectory) + +$downloadDirectory = Join-Path $WorkDirectory 'downloads' +$extractDirectory = Join-Path $WorkDirectory 'extract' +$assembleDirectory = Join-Path $WorkDirectory 'assemble' + +New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null +New-Item -ItemType Directory -Force -Path $downloadDirectory | Out-Null + +foreach ($rebuilt in @($extractDirectory, $assembleDirectory)) { + if (Test-Path $rebuilt) { + Remove-Item -Recurse -Force $rebuilt + } + New-Item -ItemType Directory -Force -Path $rebuilt | Out-Null +} + +function Get-VerifiedDownload { + param( + [string]$Url, + [string]$ExpectedSha256, + [string]$Destination + ) + + if (Test-Path $Destination) { + $cachedHash = (Get-FileHash -Algorithm SHA256 -Path $Destination).Hash + if ($cachedHash -eq $ExpectedSha256) { + Write-Host " cached + verified: $Destination" + return + } + Write-Warning " cached file hash mismatch - deleting and re-downloading: $Destination" + Remove-Item -Force $Destination + } + + Write-Host " downloading $Url" + $temporary = "$Destination.partial" + if (Test-Path $temporary) { + Remove-Item -Force $temporary + } + Invoke-WebRequest -Uri $Url -OutFile $temporary -UseBasicParsing + + $actualHash = (Get-FileHash -Algorithm SHA256 -Path $temporary).Hash + if ($actualHash -ne $ExpectedSha256) { + Remove-Item -Force $temporary + throw "SHA256 mismatch for $Url : expected $ExpectedSha256, got $actualHash. Refusing to package an unverified runtime." + } + + Move-Item -Force $temporary $Destination + Write-Host " verified SHA256 $actualHash" +} + +Write-Host "PostgreSQL $pgVersion binaries:" +$pgZip = Join-Path $downloadDirectory "postgresql-$pgVersion-windows-x64-binaries.zip" +Get-VerifiedDownload -Url $pgUrl -ExpectedSha256 $pgSha256 -Destination $pgZip + +Write-Host "TimescaleDB $tsVersion (PG18, Windows x64):" +$tsZip = Join-Path $downloadDirectory "timescaledb-$tsVersion-postgresql-18-windows-amd64.zip" +Get-VerifiedDownload -Url $tsUrl -ExpectedSha256 $tsSha256 -Destination $tsZip + +Write-Host "Extracting..." +$pgExtract = Join-Path $extractDirectory 'pg' +$tsExtract = Join-Path $extractDirectory 'ts' +[System.IO.Compression.ZipFile]::ExtractToDirectory($pgZip, $pgExtract) +[System.IO.Compression.ZipFile]::ExtractToDirectory($tsZip, $tsExtract) + +$pgSource = Join-Path $pgExtract 'pgsql' +if (-not (Test-Path (Join-Path $pgSource 'bin\initdb.exe'))) { + throw "The EDB archive did not contain pgsql\bin\initdb.exe - its layout changed; update this script." +} +$tsSource = Join-Path $tsExtract 'timescaledb' +if (-not (Test-Path (Join-Path $tsSource 'timescaledb.control'))) { + throw "The TimescaleDB archive did not contain timescaledb\timescaledb.control - its layout changed; update this script." +} + +Write-Host "Assembling pg-runtime\pgsql (bin + lib + share, TimescaleDB merged)..." +$runtimeRoot = Join-Path $assembleDirectory 'pg-runtime' +$pgsqlTarget = Join-Path $runtimeRoot 'pgsql' +New-Item -ItemType Directory -Force -Path $pgsqlTarget | Out-Null + +foreach ($keep in @('bin', 'lib', 'share')) { + Copy-Item -Recurse -Path (Join-Path $pgSource $keep) -Destination (Join-Path $pgsqlTarget $keep) +} + +# Bundle the Microsoft Visual C++ runtime beside the PG binaries. EDB's PostgreSQL build links the +# MSVC runtime, which a clean Windows Server does NOT ship - without these, postgres.exe/initdb.exe +# fail to launch with 0xC0000135 (STATUS_DLL_NOT_FOUND) and managed mode never starts (verified on a +# fresh Windows Server 2022). App-local deployment (DLLs next to postgres.exe) keeps pg-runtime.zip +# self-sufficient on a bare OS, matching Darling's zero-download deploy model. Sourced from the build +# machine's System32 (the redistributable runtime; CI runners and dev boxes both have it) - fail hard +# if absent so a broken bundle never ships. +$vcRuntimeDlls = @('vcruntime140.dll', 'vcruntime140_1.dll', 'msvcp140.dll') +$binTarget = Join-Path $pgsqlTarget 'bin' +foreach ($dll in $vcRuntimeDlls) { + $vcSource = Join-Path $env:SystemRoot "System32\$dll" + if (-not (Test-Path $vcSource)) { + throw "MSVC runtime $dll not found at $vcSource - install the Visual C++ Redistributable on the build machine; pg-runtime.zip needs it beside postgres.exe." + } + Copy-Item -Path $vcSource -Destination $binTarget +} + +$extensionTarget = Join-Path $pgsqlTarget 'share\extension' +New-Item -ItemType Directory -Force -Path $extensionTarget | Out-Null +Copy-Item -Path (Join-Path $tsSource 'timescaledb*.dll') -Destination (Join-Path $pgsqlTarget 'lib') +Copy-Item -Path (Join-Path $tsSource 'timescaledb.control') -Destination $extensionTarget +Copy-Item -Path (Join-Path $tsSource 'timescaledb--*.sql') -Destination $extensionTarget + +# Sanity: everything DarlingManagedPostgres and TimescaleSupport depend on must be present. +$requiredFiles = @( + 'bin\initdb.exe', + 'bin\pg_ctl.exe', + 'bin\postgres.exe', + 'bin\libpq.dll', + 'bin\vcruntime140.dll', + 'bin\vcruntime140_1.dll', + 'bin\msvcp140.dll', + 'lib\timescaledb.dll', + "lib\timescaledb-$tsVersion.dll", + "lib\timescaledb-tsl-$tsVersion.dll", + 'share\extension\timescaledb.control', + "share\extension\timescaledb--$tsVersion.sql" +) +foreach ($required in $requiredFiles) { + if (-not (Test-Path (Join-Path $pgsqlTarget $required))) { + throw "Assembled runtime is missing $required - refusing to package." + } +} + +$zipPath = Join-Path $OutputDirectory 'pg-runtime.zip' +Write-Host "Zipping to $zipPath ..." +if (Test-Path $zipPath) { + Remove-Item -Force $zipPath +} +[System.IO.Compression.ZipFile]::CreateFromDirectory( + $runtimeRoot, $zipPath, [System.IO.Compression.CompressionLevel]::Optimal, $false) + +$zipInfo = Get-Item $zipPath +$zipHash = (Get-FileHash -Algorithm SHA256 -Path $zipPath).Hash +Write-Host "" +Write-Host "pg-runtime.zip ready:" +Write-Host " path : $($zipInfo.FullName)" +Write-Host (" size : {0:N1} MB" -f ($zipInfo.Length / 1MB)) +Write-Host " sha256 : $zipHash" +Write-Host " ship it beside PerformanceMonitor.Darling.Service.exe (postgres.managed = true unpacks it on first run)" + +if (-not $KeepWork) { + Remove-Item -Recurse -Force $extractDirectory + Remove-Item -Recurse -Force $assembleDirectory +} +else { + Write-Host " kept work tree: $runtimeRoot (DARLING_TEST_PGRUNTIME candidate for the gated bootstrap E2E)" +} diff --git a/Darling/tools/install-darling.ps1 b/Darling/tools/install-darling.ps1 new file mode 100644 index 000000000..7ad248131 --- /dev/null +++ b/Darling/tools/install-darling.ps1 @@ -0,0 +1,163 @@ +<# +.SYNOPSIS +Installs (or upgrades) the PerformanceMonitor Darling service from the folder this script sits in, +and creates Desktop + Start Menu shortcuts for the bundled viewer. + +.DESCRIPTION +Run from an ELEVATED PowerShell, from the folder you extracted the Darling zip into (the script +installs the service pointing at THAT folder — extract to the final location first, e.g. +C:\PerformanceMonitorDarling). What it does, in order: + + 1. Verifies elevation, the service exe, and darling.json (offers to copy darling.sample.json + and stops so you can edit it — the service is not installed with an unedited sample). + 2. Optional pre-flight: runs `--test-connection` and shows the per-server PASS/FAIL lines + (continue-or-abort prompt on failure; -SkipPreflight to skip). + 3. Registers the Windows Event Log source 'PerformanceMonitor Darling' (requires elevation — + the service's own virtual account cannot; without it Event Log diagnostics are silently + dropped. The file log under %ProgramData%\PerformanceMonitorDarling\logs works regardless). + 4. Creates the service under the NT SERVICE virtual account (NEVER LocalSystem — the bundled + PostgreSQL refuses to run with administrative privileges), start=auto. If the service + already exists this is an UPGRADE: it is stopped and its binPath updated in place; your + darling.json, store data, and credentials are untouched. + 5. Starts the service and confirms it reaches Running. + 6. Creates 'Darling Viewer' shortcuts on the Desktop and in the Start Menu pointing at + viewer\PerformanceMonitor.Darling.Viewer.exe. (Taskbar pinning is deliberately not + attempted — Windows blocks programmatic pinning by design; pin from the Start Menu entry.) + +Uninstall with uninstall-darling.ps1 (same folder). + +.PARAMETER SkipPreflight +Skip the --test-connection pre-flight gate. + +.PARAMETER NoShortcuts +Do not create the viewer shortcuts. + +.PARAMETER Network +After the service reaches Running, launch the interactive --configure-network wizard to opt into the +store / MCP LAN endpoints (guided, delegated validation, comment-preserving darling.json edit + backup). +Off by default; the endpoints stay loopback-only unless you pass this or edit darling.json by hand. +#> +[CmdletBinding()] +param( + [switch]$SkipPreflight, + [switch]$NoShortcuts, + [switch]$Network +) + +$ErrorActionPreference = 'Stop' +$serviceName = 'PerformanceMonitor Darling' +$root = $PSScriptRoot +$serviceExe = Join-Path $root 'PerformanceMonitor.Darling.Service.exe' +$viewerExe = Join-Path $root 'viewer\PerformanceMonitor.Darling.Viewer.exe' +$configPath = Join-Path $root 'darling.json' +$samplePath = Join-Path $root 'darling.sample.json' + +function Fail([string]$message) { Write-Host "ERROR: $message" -ForegroundColor Red; exit 1 } + +# -- 1. Environment checks ------------------------------------------------------------------------ +$identity = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() +if (-not $identity.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + Fail 'Run this script from an ELEVATED PowerShell (service creation and Event Log registration require it).' +} + +if (-not (Test-Path $serviceExe)) { + Fail "PerformanceMonitor.Darling.Service.exe not found beside this script. Extract the full Darling zip and run install-darling.ps1 from the extracted folder." +} + +if (-not (Test-Path $configPath)) { + if (Test-Path $samplePath) { + Copy-Item $samplePath $configPath + Write-Host '' + Write-Host 'darling.json did not exist, so darling.sample.json was copied to darling.json.' -ForegroundColor Yellow + Write-Host 'EDIT IT NOW (servers to monitor, auth) and re-run this script. The sample is heavily commented.' -ForegroundColor Yellow + Write-Host " notepad `"$configPath`"" -ForegroundColor Yellow + exit 2 + } + + Fail 'Neither darling.json nor darling.sample.json found beside this script - the zip looks incomplete.' +} + +# -- 2. Pre-flight -------------------------------------------------------------------------------- +if (-not $SkipPreflight) { + Write-Host 'Pre-flight: validating darling.json and probing every configured server (--test-connection)...' + & $serviceExe --test-connection + if ($LASTEXITCODE -ne 0) { + Write-Host '' + Write-Host "Pre-flight FAILED (exit $LASTEXITCODE) - the config is invalid or a server is unreachable." -ForegroundColor Yellow + $answer = Read-Host 'Install anyway? A monitoring service may legitimately be installed while a target is down. [y/N]' + if ($answer -notmatch '^[Yy]') { exit 3 } + } + else { + Write-Host 'Pre-flight passed.' -ForegroundColor Green + } +} + +# -- 3. Event Log source -------------------------------------------------------------------------- +try { + New-EventLog -LogName Application -Source $serviceName -ErrorAction Stop + Write-Host "Registered Event Log source '$serviceName'." +} +catch [System.InvalidOperationException] { + Write-Host "Event Log source '$serviceName' already registered." +} + +# -- 4. Create or upgrade the service ------------------------------------------------------------- +$existing = Get-Service -Name $serviceName -ErrorAction SilentlyContinue +if ($existing) { + Write-Host "Service already exists - upgrading its binPath in place (config, store data, and credentials untouched)." + if ($existing.Status -ne 'Stopped') { + Stop-Service -Name $serviceName -Force + (Get-Service -Name $serviceName).WaitForStatus('Stopped', [TimeSpan]::FromSeconds(60)) + } + + & sc.exe config $serviceName binPath= "`"$serviceExe`"" | Out-Null + if ($LASTEXITCODE -ne 0) { Fail "sc config failed ($LASTEXITCODE)." } +} +else { + # The space after binPath=/start=/obj= is sc.exe syntax. The virtual service account is + # password-less, per-service, unprivileged - and mandatory-in-practice for the bundled + # PostgreSQL, which refuses to run with administrative privileges (LocalSystem bricks it). + & sc.exe create $serviceName binPath= "`"$serviceExe`"" start= auto obj= "NT SERVICE\$serviceName" | Out-Null + if ($LASTEXITCODE -ne 0) { Fail "sc create failed ($LASTEXITCODE)." } + Write-Host "Created service '$serviceName' (NT SERVICE virtual account, automatic start)." +} + +# -- 5. Start + confirm --------------------------------------------------------------------------- +Start-Service -Name $serviceName +(Get-Service -Name $serviceName).WaitForStatus('Running', [TimeSpan]::FromSeconds(60)) +Write-Host "Service is Running. First start does real work (unpack pg-runtime, initdb, store migration, first collection cycle) - give it ~2 minutes." -ForegroundColor Green +Write-Host "Primary log: %ProgramData%\PerformanceMonitorDarling\logs\darling-service_yyyyMMdd.log" + +# -- 5b. Optional guided network setup ------------------------------------------------------------ +# Runs elevated (this whole script is), so the wizard's restart-to-apply works, and its restart is what +# generates the store TLS cert on the first exposed start. Loopback-only stays the default without -Network. +if ($Network) { + Write-Host '' + Write-Host 'Launching the guided network-exposure wizard (--configure-network)...' -ForegroundColor Cyan + & $serviceExe --configure-network +} + +# -- 6. Viewer shortcuts -------------------------------------------------------------------------- +if (-not $NoShortcuts) { + if (Test-Path $viewerExe) { + $shell = New-Object -ComObject WScript.Shell + $targets = @( + (Join-Path ([Environment]::GetFolderPath('Desktop')) 'Darling Viewer.lnk'), + (Join-Path ([Environment]::GetFolderPath('StartMenu')) 'Programs\Darling Viewer.lnk') + ) + foreach ($lnkPath in $targets) { + $lnk = $shell.CreateShortcut($lnkPath) + $lnk.TargetPath = $viewerExe + $lnk.WorkingDirectory = Split-Path $viewerExe + $lnk.Description = 'PerformanceMonitor Darling Viewer' + $lnk.Save() + } + Write-Host "Created 'Darling Viewer' shortcuts (Desktop + Start Menu). Pin to taskbar from the Start Menu entry if wanted." + } + else { + Write-Host 'viewer\PerformanceMonitor.Darling.Viewer.exe not found - skipping shortcuts.' -ForegroundColor Yellow + } +} + +Write-Host '' +Write-Host 'Done.' -ForegroundColor Green diff --git a/Darling/tools/provision-roles.sql b/Darling/tools/provision-roles.sql new file mode 100644 index 000000000..99a6cc3c1 --- /dev/null +++ b/Darling/tools/provision-roles.sql @@ -0,0 +1,143 @@ +-- ============================================================================================ +-- Darling security hardening (#1262) — least-privilege role provisioning for BRING-YOUR-OWN +-- PostgreSQL (postgres.managed = false). +-- +-- In managed mode the service provisions these roles automatically on every start (see +-- DarlingManagedRoles) and generates their DPAPI credentials. In BYO mode YOU run this script +-- once, as the database OWNER (the role that owns the Darling store — the one your +-- postgres.connectionString connects as for collection). It creates the two least-privilege +-- login roles the Viewer connects as instead of the owner: +-- +-- admin -- reads both schemas + writes the operator-config tables (mute rules, alert +-- dismissals, analysis mutes). The Viewer's default identity (darling.json +-- postgres.connectAs = "admin"). +-- viewer -- reads both schemas; the ONLY write it gets is INSERT/UPDATE/DELETE on config.custom_views +-- (the web dashboard's user-authored view definitions, #1563 -- non-secret JSON; edited by any +-- authenticated seat, gated server-side by the host's token+CIDR auth -- not loopback-only). All +-- other write actions degrade gracefully. Point a locked-down Viewer at this with +-- postgres.connectAs = "viewer". +-- +-- PREREQUISITE: the V8 migration (which the service applies on startup) must already have created +-- the collect and config schemas and moved the tables into them. Run this AFTER the service has +-- started at least once against your store. Re-running after a later schema upgrade is safe and +-- idempotent: the GRANT ... ON ALL TABLES statements below re-cover every table that now exists +-- (including the V17 control-plane tables config_monitored_servers / config_alert_settings / +-- config_notification / config_collector_schedules / config_service / config_command), and the +-- ALTER DEFAULT PRIVILEGES already auto-grant any config table the owner creates after this runs. The one +-- write that is NOT covered by re-running blindly is the V31 config.custom_views viewer grant in step 3b: +-- that table only exists once the service has migrated your store to V31, so re-run this script after upgrading. +-- +-- BEFORE RUNNING: +-- 1. Replace CHANGE_ME_ADMIN_PASSWORD and CHANGE_ME_VIEWER_PASSWORD with strong passwords. +-- 2. If your database is not named "darling", change it in the REVOKE/GRANT ... ON DATABASE +-- lines and in the ALTER DATABASE note at the bottom. +-- 3. If the owner role is not "darling", change it in the ALTER DEFAULT PRIVILEGES FOR ROLE +-- lines (it must be the role that CREATEs the tables — your collection connection's role). +-- +-- psql -h <host> -U <owner> -d darling -f provision-roles.sql +-- +-- NAME-COLLISION SAFETY: the roles are the bare, un-prefixed names "admin" and "viewer". If your +-- cluster ALREADY has a role by either name that this script did not create, it will NOT be silently +-- repurposed: fresh roles are stamped with a marker comment ('darling-managed'), and an existing +-- same-named role without that marker makes this script FAIL LOUD (rename or drop the other role +-- first, or use a dedicated cluster/database for the Darling store). +-- ============================================================================================ + +-- 1. Roles (CREATE ROLE has no IF NOT EXISTS -> guard with a DO block). Idempotent: re-running +-- this script re-asserts the password below, so it doubles as a password rotation. A fresh role +-- is stamped 'darling-managed'; an unmarked same-named role fails loud (never repurposed). +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'admin') THEN + CREATE ROLE admin LOGIN NOSUPERUSER PASSWORD 'CHANGE_ME_ADMIN_PASSWORD'; + COMMENT ON ROLE admin IS 'darling-managed'; + ELSIF shobj_description((SELECT oid FROM pg_roles WHERE rolname = 'admin'), 'pg_authid') IS DISTINCT FROM 'darling-managed' THEN + RAISE EXCEPTION 'Role "admin" already exists and was not created by Darling (missing the ''darling-managed'' marker comment). Rename or drop it before provisioning so Darling does not repurpose an unrelated login.'; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'viewer') THEN + CREATE ROLE viewer LOGIN NOSUPERUSER PASSWORD 'CHANGE_ME_VIEWER_PASSWORD'; + COMMENT ON ROLE viewer IS 'darling-managed'; + ELSIF shobj_description((SELECT oid FROM pg_roles WHERE rolname = 'viewer'), 'pg_authid') IS DISTINCT FROM 'darling-managed' THEN + RAISE EXCEPTION 'Role "viewer" already exists and was not created by Darling (missing the ''darling-managed'' marker comment). Rename or drop it before provisioning so Darling does not repurpose an unrelated login.'; + END IF; +END $$; + +ALTER ROLE admin LOGIN NOSUPERUSER PASSWORD 'CHANGE_ME_ADMIN_PASSWORD'; +ALTER ROLE viewer LOGIN NOSUPERUSER PASSWORD 'CHANGE_ME_VIEWER_PASSWORD'; + +-- 1a. statement_timeout backstop on viewer (Custom Views v2, #1563): the web dashboard's compose surface +-- connects as viewer and runs network-reachable aggregations over a raw, no-rollup store, so this caps a +-- runaway query at the database (a LIMIT bounds output, not work). Keep this value in step with +-- ComposeLimits.StatementTimeout in the service. (Managed mode also sets this on the network 'mcp' role, +-- which a BYO deployment does not create -- your own PostgreSQL governs any MCP-role exposure it wants.) +ALTER ROLE viewer SET statement_timeout = '15s'; + +-- 2. Schema usage + SELECT on everything that exists now (ALL TABLES covers tables AND views). collect +-- holds no secrets, so admin+viewer read all of it. config: admin (the writer, and the Settings +-- window's identity) reads every column; viewer reads all config tables too -- MINUS the secret columns +-- carved in step 2b. +GRANT USAGE ON SCHEMA collect, config TO admin, viewer; +GRANT SELECT ON ALL TABLES IN SCHEMA collect TO admin, viewer; +GRANT SELECT ON ALL TABLES IN SCHEMA config TO admin, viewer; + +-- 2b. Credential-column fail-closed ACLs (#1262). The read-only viewer must NOT read the secret columns of +-- the three credential-bearing config tables: config_monitored_servers.encrypted_password (a DPAPI +-- password blob), config_command.args_json (the inline test_connect credential blob), and +-- config_notification's SMTP password/username + Teams/Slack webhook URLs (a webhook URL is a bearer +-- secret). Instead of GRANT-ALL-then-REVOKE-each-secret (fail-OPEN -- a future secret column leaks until +-- someone revokes it), DROP viewer's table-wide SELECT on each and re-grant ONLY the non-secret columns +-- (fail-CLOSED -- a column added later is invisible to viewer until you add it below). admin keeps its +-- table-wide SELECT from step 2. Re-running this whole script re-asserts the carve idempotently. If a +-- later schema upgrade ADDS a column to any of these three tables, add it to the matching GRANT list +-- below (or, if it is itself secret, deliberately leave it out). +REVOKE SELECT ON config.config_monitored_servers FROM viewer; +GRANT SELECT (server_id, name, host, database, auth, username, encrypt_mode, trust_server_certificate, + read_only_intent, multi_subnet_failover, excluded_databases, monthly_cost_usd, capture_plans, + is_enabled, created_at, modified_at) + ON config.config_monitored_servers TO viewer; +REVOKE SELECT ON config.config_command FROM viewer; +GRANT SELECT (command_id, created_at, requested_by, command_type, target_server_id, status, claimed_at, + completed_at, result_status, result_json, service_instance) + ON config.config_command TO viewer; +REVOKE SELECT ON config.config_notification FROM viewer; +GRANT SELECT (id, smtp_host, smtp_port, smtp_use_ssl, smtp_from_address, smtp_recipients, + email_cooldown_minutes, teams_proxy, slack_proxy, modified_at) + ON config.config_notification TO viewer; + +-- 3. config writes -- admin only. +GRANT INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA config TO admin; + +-- 3b. Custom views (#1563): the SINGLE viewer write. The web dashboard connects as the least-privilege +-- viewer role and its custom-view composer writes exactly this one config table (non-secret dashboard +-- JSON). Editing is any authenticated seat -- the web surface's normal networked mode, gated server-side +-- by the host's token+CIDR auth (not loopback-only); this narrow grant is only the floor beneath that +-- gate. EXPLICIT single-table statement, NO ALTER DEFAULT PRIVILEGES (ADP has no per-table +-- form -> it would broaden viewer to ALL of config). config.custom_views is created by the V31 migration, +-- so re-run this script AFTER the service has migrated your store to V31 (else this line errors: no table). +GRANT INSERT, UPDATE, DELETE ON config.custom_views TO viewer; + +-- 4. Default privileges so NEW tables/views (future collectors, created bare into collect via +-- search_path) auto-inherit SELECT. FOR ROLE <owner> must name the role that creates them. +ALTER DEFAULT PRIVILEGES FOR ROLE darling IN SCHEMA collect + GRANT SELECT ON TABLES TO admin, viewer; +ALTER DEFAULT PRIVILEGES FOR ROLE darling IN SCHEMA config + GRANT SELECT ON TABLES TO admin, viewer; +ALTER DEFAULT PRIVILEGES FOR ROLE darling IN SCHEMA config + GRANT INSERT, UPDATE, DELETE ON TABLES TO admin; +-- Sequence USAGE for admin: config_command (V17) is GENERATED ALWAYS AS IDENTITY, which needs NO +-- sequence USAGE to INSERT (unlike serial) -- but keep this fail-closed grant so a future serial +-- config column would still work without a follow-up migration. +ALTER DEFAULT PRIVILEGES FOR ROLE darling IN SCHEMA config + GRANT USAGE, SELECT ON SEQUENCES TO admin; + +-- 5. Public hardening: no world-writable public schema, no anonymous connect. REVOKE ALL drops +-- PUBLIC's implicit CONNECT, so admin/viewer are re-granted CONNECT explicitly. +REVOKE CREATE ON SCHEMA public FROM PUBLIC; +REVOKE ALL ON DATABASE darling FROM PUBLIC; +GRANT CONNECT ON DATABASE darling TO admin, viewer; + +-- 6. Search path. The service best-effort runs this on every start, but if your collection login +-- lacks the database-owner privilege it needs, run it once yourself as the owner so every +-- connection (the Viewer, psql, pg_dump) resolves the bare table names to collect/config: +-- +-- ALTER DATABASE darling SET search_path = collect, config, public; diff --git a/Darling/tools/uninstall-darling.ps1 b/Darling/tools/uninstall-darling.ps1 new file mode 100644 index 000000000..cac066147 --- /dev/null +++ b/Darling/tools/uninstall-darling.ps1 @@ -0,0 +1,78 @@ +<# +.SYNOPSIS +Uninstalls the PerformanceMonitor Darling service and removes the viewer shortcuts. + +.DESCRIPTION +Run from an ELEVATED PowerShell. Stops and deletes the Windows service and removes the Desktop / +Start Menu 'Darling Viewer' shortcuts. DELIBERATELY LEFT IN PLACE: the extracted program folder, +darling.json, and everything under %ProgramData%\PerformanceMonitorDarling (the PostgreSQL store +with all collected history, the DPAPI credential files, and the service logs) - so an uninstall +is reversible by simply re-running install-darling.ps1. To destroy the collected data too, pass +-PurgeData and answer the confirmation. + +.PARAMETER PurgeData +ALSO delete %ProgramData%\PerformanceMonitorDarling - the entire PostgreSQL store (all collected +history), the generated store credentials, and the service logs. Irreversible; prompts. +#> +[CmdletBinding()] +param( + [switch]$PurgeData +) + +$ErrorActionPreference = 'Stop' +$serviceName = 'PerformanceMonitor Darling' + +$identity = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() +if (-not $identity.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + Write-Host 'ERROR: Run this script from an ELEVATED PowerShell.' -ForegroundColor Red + exit 1 +} + +$existing = Get-Service -Name $serviceName -ErrorAction SilentlyContinue +if ($existing) { + if ($existing.Status -ne 'Stopped') { + Write-Host 'Stopping the service (a clean stop checkpoints the bundled store)...' + Stop-Service -Name $serviceName -Force + (Get-Service -Name $serviceName).WaitForStatus('Stopped', [TimeSpan]::FromSeconds(90)) + } + + & sc.exe delete $serviceName | Out-Null + if ($LASTEXITCODE -ne 0) { Write-Host "sc delete failed ($LASTEXITCODE)." -ForegroundColor Red; exit 1 } + Write-Host "Service '$serviceName' removed." +} +else { + Write-Host "Service '$serviceName' is not installed - nothing to remove." +} + +foreach ($lnkPath in @( + (Join-Path ([Environment]::GetFolderPath('Desktop')) 'Darling Viewer.lnk'), + (Join-Path ([Environment]::GetFolderPath('StartMenu')) 'Programs\Darling Viewer.lnk') +)) { + if (Test-Path $lnkPath) { + Remove-Item $lnkPath -Force + Write-Host "Removed shortcut $lnkPath" + } +} + +$dataRoot = Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) 'PerformanceMonitorDarling' +if ($PurgeData) { + if (Test-Path $dataRoot) { + Write-Host '' + Write-Host "-PurgeData will DELETE $dataRoot - the ENTIRE PostgreSQL store (all collected history)," -ForegroundColor Yellow + Write-Host 'the generated store credentials, and the service logs. This cannot be undone.' -ForegroundColor Yellow + $answer = Read-Host "Type the word DELETE to confirm" + if ($answer -ceq 'DELETE') { + Remove-Item $dataRoot -Recurse -Force + Write-Host 'Store, credentials, and logs deleted.' + } + else { + Write-Host 'Purge NOT confirmed - data left in place.' + } + } +} +else { + Write-Host '' + Write-Host "Left in place (re-running install-darling.ps1 fully restores the install):" + Write-Host " - this program folder and its darling.json" + Write-Host " - $dataRoot (the store with all collected history, credentials, logs)" +} diff --git a/Dashboard.Tests/AlertContextBuildersTests.cs b/Dashboard.Tests/AlertContextBuildersTests.cs new file mode 100644 index 000000000..d178a5173 --- /dev/null +++ b/Dashboard.Tests/AlertContextBuildersTests.cs @@ -0,0 +1,440 @@ +/* + * Performance Monitor Dashboard + * Copyright (c) 2026 Darling Data, LLC + * Licensed under the MIT License - see LICENSE file for details + */ + +using System; +using System.Collections.Generic; +using PerformanceMonitor.Alerting; +using PerformanceMonitor.Notifications; +using PerformanceMonitorDashboard.Models; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// <summary> +/// Identity pins for the Phase-5 slice-A extraction: the six pure alert-context builders (and their +/// pure helpers) moved verbatim from this app's <c>MainWindow.AlertEngine.cs</c> into the shared +/// <see cref="AlertContextBuilders"/>. Every expectation here is derived from the pre-extraction +/// code, so a behavioral drift in the shared copy fails a pin. The shared +/// <see cref="AlertContextBuilders.BuildLongRunningQueryContext"/> is the DASHBOARD's version +/// (including the ("Program", ...) item this app already rendered — Lite gained it as the slice's +/// one documented reconciliation), so for this app every pin is pre-extraction behavior. +/// </summary> +public class AlertContextBuildersTests +{ + private const string Server = "SQL2022"; + + /* ---------------- poison waits ---------------- */ + + [Fact] + public void BuildPoisonWaitContext_EmptyList_ReturnsNull() + { + Assert.Null(AlertContextBuilders.BuildPoisonWaitContext(new List<PoisonWaitDelta>())); + } + + [Fact] + public void BuildPoisonWaitContext_RendersOneDetailPerWait_NoIncidents() + { + var context = AlertContextBuilders.BuildPoisonWaitContext(new List<PoisonWaitDelta> + { + new() { WaitType = "RESOURCE_SEMAPHORE", AvgMsPerWait = 2000.5, DeltaMs = 100000, DeltaTasks = 50 }, + new() { WaitType = "THREADPOOL", AvgMsPerWait = 750.0, DeltaMs = 1500, DeltaTasks = 2 } + }); + + Assert.NotNull(context); + Assert.Equal(2, context!.Details.Count); + Assert.Equal("RESOURCE_SEMAPHORE", context.Details[0].Heading); + Assert.Equal( + new List<(string, string)> + { + ("Avg ms/wait", $"{2000.5:F1}"), + ("Delta wait ms", $"{100000L:N0}"), + ("Delta tasks", $"{50L:N0}") + }, + context.Details[0].Fields); + Assert.Equal("THREADPOOL", context.Details[1].Heading); + /* The poison-wait builder never attached #1140 incidents pre-extraction. */ + Assert.Null(context.Incidents); + } + + /* ---------------- long-running queries ---------------- */ + + private static LongRunningQueryInfo Lrq( + int sessionId, long elapsedSeconds, string db = "", string program = "", string query = "", + string? waitType = null, int? blockedBy = null, string? hash = null) => new() + { + SessionId = sessionId, + ElapsedSeconds = elapsedSeconds, + DatabaseName = db, + ProgramName = program, + QueryText = query, + CpuTimeMs = 1234, + Reads = 10, + Writes = 2, + WaitType = waitType, + BlockingSessionId = blockedBy, + QueryHash = hash + }; + + [Fact] + public void BuildLongRunningQueryContext_EmptyList_ReturnsNull() + { + Assert.Null(AlertContextBuilders.BuildLongRunningQueryContext(Server, new List<LongRunningQueryInfo>())); + } + + [Fact] + public void BuildLongRunningQueryContext_CapsAtThreeQueries_AppendsIncidents() + { + var queries = new List<LongRunningQueryInfo> + { + Lrq(71, 314, db: "StackOverflow", query: "SELECT 1", hash: "0x9AAF0129E4E9AD07"), + Lrq(72, 200, db: "StackOverflow", query: "SELECT 2", hash: "0x1111111111111111"), + Lrq(73, 150, db: "StackOverflow", query: "SELECT 3", hash: "0x2222222222222222"), + Lrq(74, 100, db: "StackOverflow", query: "SELECT 4", hash: "0x3333333333333333") + }; + + var context = AlertContextBuilders.BuildLongRunningQueryContext(Server, queries); + + Assert.NotNull(context); + /* 3 session items (capped) + 3 appended incident items (one per hashed query). */ + Assert.Equal(6, context!.Details.Count); + Assert.Equal("Session #71 — 5m 14s", context.Details[0].Heading); + Assert.Equal("Incident 1 of 3", context.Details[3].Heading); + Assert.NotNull(context.Incidents); + Assert.Equal(3, context.Incidents!.Count); + /* Dedup key = query_hash fingerprint, scoped by server + incident type (#1140). */ + var expected = AlertFingerprint.ForKey(Server, AlertFingerprint.Query, "0x9AAF0129E4E9AD07", new[] { "StackOverflow" }); + Assert.Equal(expected!.DedupKey, context.Incidents[0].DedupKey); + } + + [Fact] + public void BuildLongRunningQueryContext_RendersProgramField() + { + /* Pre-extraction Dashboard behavior — the ("Program", ...) item Lite gained in this slice. */ + var context = AlertContextBuilders.BuildLongRunningQueryContext(Server, new List<LongRunningQueryInfo> + { + Lrq(71, 314, db: "StackOverflow", program: "HammerDB", query: "SELECT 1", waitType: "CXPACKET", blockedBy: 55) + }); + + Assert.NotNull(context); + Assert.Equal( + new List<(string, string)> + { + ("Database", "StackOverflow"), + ("Program", "HammerDB"), + ("Query", "SELECT 1"), + ("CPU Time", $"{1234L:N0} ms"), + ("Reads", $"{10L:N0}"), + ("Writes", $"{2L:N0}"), + ("Wait Type", "CXPACKET"), + ("Blocked By", "Session #55") + }, + context!.Details[0].Fields); + } + + [Fact] + public void BuildLongRunningQueryContext_NullQueryHash_EmitsNoIncident() + { + var context = AlertContextBuilders.BuildLongRunningQueryContext(Server, new List<LongRunningQueryInfo> + { + Lrq(71, 90, db: "StackOverflow", query: "SELECT 1", hash: null) + }); + + Assert.NotNull(context); + Assert.Single(context!.Details); + Assert.Null(context.Incidents); + } + + /* ---------------- low disk ---------------- */ + + private static VolumeFreeSpaceInfo Volume(string mount, double totalMb, double freeMb) => + new() { MountPoint = mount, TotalMb = totalMb, FreeMb = freeMb }; + + [Fact] + public void GetBreachedVolumes_FiltersOnEitherDimension_WorstFirst() + { + var volumes = new List<VolumeFreeSpaceInfo> + { + Volume("C:\\", 102400, 8192), /* 8% free, 8 GB -> breaches percent */ + Volume("D:\\", 1048576, 524288), /* 50% free, 512 GB -> healthy */ + Volume("E:\\", 10240, 3072) /* 30% free, 3 GB -> breaches GB */ + }; + + var breached = AlertContextBuilders.GetBreachedVolumes(volumes, thresholdPercent: 10, thresholdGb: 5); + + Assert.Equal(2, breached.Count); + /* Ordered by free percent ascending — the tightest volume first. */ + Assert.Equal("C:\\", breached[0].MountPoint); + Assert.Equal("E:\\", breached[1].MountPoint); + } + + [Fact] + public void GetBreachedVolumes_ZeroThresholdDisablesThatDimension() + { + var lowPercent = new List<VolumeFreeSpaceInfo> { Volume("C:\\", 102400, 8192) }; /* 8%, 8 GB */ + var lowGb = new List<VolumeFreeSpaceInfo> { Volume("E:\\", 10240, 3072) }; /* 30%, 3 GB */ + + Assert.Empty(AlertContextBuilders.GetBreachedVolumes(lowPercent, thresholdPercent: 0, thresholdGb: 5)); + Assert.Empty(AlertContextBuilders.GetBreachedVolumes(lowGb, thresholdPercent: 10, thresholdGb: 0)); + Assert.Empty(AlertContextBuilders.GetBreachedVolumes(lowPercent, thresholdPercent: 0, thresholdGb: 0)); + } + + [Theory] + [InlineData(10, 5, "10% / 5 GB")] + [InlineData(10, 0, "10%")] + [InlineData(0, 5, "5 GB")] + [InlineData(0, 0, "—")] + public void FormatLowDiskThreshold_RendersConfiguredDimensions(double pct, double gb, string expected) + { + Assert.Equal(expected, AlertContextBuilders.FormatLowDiskThreshold(pct, gb)); + } + + [Fact] + public void BuildVolumeFreeSpaceContext_RendersVolume_WithPerVolumeIncident() + { + var context = AlertContextBuilders.BuildVolumeFreeSpaceContext(Server, new List<VolumeFreeSpaceInfo> + { + Volume("C:\\", 102400, 5120) /* 5% free, 5 GB free, 100 GB total */ + }); + + Assert.NotNull(context); + /* 1 volume item + 1 appended incident item. */ + Assert.Equal(2, context!.Details.Count); + Assert.Equal("C:\\ — 5% Free", context.Details[0].Heading); + Assert.Equal( + new List<(string, string)> + { + ("Free Space", $"{5.0:F1} GB"), + ("Total Size", $"{100.0:F1} GB"), + ("Used", $"{95.0:F1} GB") + }, + context.Details[0].Fields); + + Assert.NotNull(context.Incidents); + var expected = AlertFingerprint.ForKey(Server, AlertFingerprint.Disk, "C:\\", new[] { "C:\\" }); + Assert.Equal(expected!.DedupKey, Assert.Single(context.Incidents!).DedupKey); + } + + [Fact] + public void BuildVolumeFreeSpaceContext_CapsAtFiveVolumes() + { + var volumes = new List<VolumeFreeSpaceInfo>(); + for (int i = 0; i < 6; i++) + volumes.Add(Volume($"{(char)('C' + i)}:\\", 102400, 1024 * (i + 1))); + + var context = AlertContextBuilders.BuildVolumeFreeSpaceContext(Server, volumes); + + Assert.NotNull(context); + /* 5 shown volumes + 5 incident items. */ + Assert.Equal(10, context!.Details.Count); + Assert.Equal(5, context.Incidents!.Count); + } + + /* ---------------- tempdb ---------------- */ + + [Fact] + public void BuildTempDbSpaceContext_RendersAllFields_WithTopConsumer() + { + var context = AlertContextBuilders.BuildTempDbSpaceContext(new TempDbSpaceInfo + { + TotalReservedMb = 800, + UnallocatedMb = 200, + UserObjectReservedMb = 500, + InternalObjectReservedMb = 250, + VersionStoreReservedMb = 50, + TopConsumerSessionId = 55, + TopConsumerMb = 123.4 + }); + + Assert.NotNull(context); + var item = Assert.Single(context!.Details); + Assert.Equal("tempdb — 80% Used", item.Heading); + Assert.Equal( + new List<(string, string)> + { + ("Total Reserved", $"{800d:F0} MB"), + ("Unallocated", $"{200d:F0} MB"), + ("User Objects", $"{500d:F0} MB"), + ("Internal Objects", $"{250d:F0} MB"), + ("Version Store", $"{50d:F0} MB"), + ("Top Consumer", $"Session #55 ({123.4:F0} MB)") + }, + item.Fields); + } + + [Fact] + public void BuildTempDbSpaceContext_NoTopConsumer_RendersNone() + { + var context = AlertContextBuilders.BuildTempDbSpaceContext(new TempDbSpaceInfo + { + TotalReservedMb = 100, + UnallocatedMb = 900, + TopConsumerSessionId = 0 + }); + + Assert.Equal(("Top Consumer", "None"), context!.Details[0].Fields[5]); + } + + /* ---------------- anomalous jobs ---------------- */ + + [Fact] + public void BuildAnomalousJobContext_RendersDurationsAndIncidents() + { + var jobs = new List<AnomalousJobInfo> + { + new() + { + JobName = "Nightly ETL", + CurrentDurationSeconds = 3661, /* -> 1h 1m */ + AvgDurationSeconds = 90, /* -> 1m 30s */ + P95DurationSeconds = 45, /* -> 45s */ + PercentOfAverage = 350, + StartTime = new DateTime(2026, 6, 19, 14, 30, 15) + } + }; + + var context = AlertContextBuilders.BuildAnomalousJobContext(Server, jobs); + + Assert.NotNull(context); + /* 1 job item + 1 appended incident item. */ + Assert.Equal(2, context!.Details.Count); + Assert.Equal("Nightly ETL", context.Details[0].Heading); + Assert.Equal( + new List<(string, string)> + { + ("Current Duration", "1h 1m"), + ("Avg Duration", "1m 30s"), + ("P95 Duration", "45s"), + ("% of Average", $"{350m:F0}%"), + ("Started", "2026-06-19 14:30:15") + }, + context.Details[0].Fields); + + var expected = AlertFingerprint.ForKey(Server, AlertFingerprint.Job, "Nightly ETL", new[] { "Nightly ETL" }); + Assert.Equal(expected!.DedupKey, Assert.Single(context.Incidents!).DedupKey); + } + + [Fact] + public void BuildAnomalousJobContext_NullPercentOfAverage_RendersNA_AndCapsAtThree() + { + var jobs = new List<AnomalousJobInfo>(); + for (int i = 0; i < 4; i++) + jobs.Add(new AnomalousJobInfo { JobName = $"Job {i}", PercentOfAverage = null, StartTime = DateTime.UtcNow }); + + var context = AlertContextBuilders.BuildAnomalousJobContext(Server, jobs); + + Assert.NotNull(context); + /* 3 shown jobs (capped) + 3 incident items. */ + Assert.Equal(6, context!.Details.Count); + Assert.Equal(("% of Average", "N/A"), context.Details[0].Fields[3]); + Assert.Null(AlertContextBuilders.BuildAnomalousJobContext(Server, new List<AnomalousJobInfo>())); + } + + /* ---------------- failed jobs ---------------- */ + + [Fact] + public void BuildFailedJobContext_RendersStepAndTruncatedMessage() + { + var runTime = new DateTime(2026, 6, 19, 3, 15, 0); + var context = AlertContextBuilders.BuildFailedJobContext(Server, new List<FailedJobInfo> + { + new() + { + JobName = "Backup Job", + RunDateTime = runTime, + StepId = 2, + StepName = "Backup databases", + Message = new string('x', 350) + } + }); + + Assert.NotNull(context); + Assert.Equal(2, context!.Details.Count); /* 1 job item + 1 incident item */ + Assert.Equal("Backup Job", context.Details[0].Heading); + Assert.Equal( + new List<(string, string)> + { + ("Job", "Backup Job"), + ("Failed At", runTime.ToString("yyyy-MM-dd HH:mm:ss")), + ("Step", "2 — Backup databases"), + ("Message", new string('x', 300) + "...") + }, + context.Details[0].Fields); + + var expected = AlertFingerprint.ForKey(Server, AlertFingerprint.Job, "Backup Job", new[] { "Backup Job" }); + Assert.Equal(expected!.DedupKey, Assert.Single(context.Incidents!).DedupKey); + } + + [Fact] + public void BuildFailedJobContext_OutcomeRowWithoutStep_OmitsStepField_AndCapsAtFive() + { + var jobs = new List<FailedJobInfo>(); + for (int i = 0; i < 6; i++) + jobs.Add(new FailedJobInfo { JobName = $"Job {i}", RunDateTime = DateTime.UtcNow, StepId = 0 }); + + var context = AlertContextBuilders.BuildFailedJobContext(Server, jobs); + + Assert.NotNull(context); + /* 5 shown jobs (capped) + 5 incident items. */ + Assert.Equal(10, context!.Details.Count); + Assert.DoesNotContain(context.Details[0].Fields, f => f.Label == "Step"); + Assert.Null(AlertContextBuilders.BuildFailedJobContext(Server, new List<FailedJobInfo>())); + } + + /* ---------------- helpers ---------------- */ + + [Fact] + public void ContextToDetailText_NullOrEmptyContext_ReturnsNull() + { + Assert.Null(AlertContextBuilders.ContextToDetailText(null)); + Assert.Null(AlertContextBuilders.ContextToDetailText(new AlertContext())); + } + + [Fact] + public void ContextToDetailText_RendersHeadingsAndIndentedFields() + { + var context = new AlertContext(); + context.Details.Add(new AlertDetailItem { Heading = "H1", Fields = new() { ("A", "1") } }); + context.Details.Add(new AlertDetailItem { Heading = "H2", Fields = new() { ("B", "2") } }); + + var expected = string.Join(Environment.NewLine, "H1", " A: 1", "", "H2", " B: 2"); + Assert.Equal(expected, AlertContextBuilders.ContextToDetailText(context)); + } + + [Fact] + public void TruncateText_CollapsesNewlines_TruncatesWithEllipsis() + { + Assert.Equal("", AlertContextBuilders.TruncateText("")); + /* '\r' and '\n' each become a space (historical behavior — not collapsed to one). */ + Assert.Equal("a b", AlertContextBuilders.TruncateText("a\r\nb")); + Assert.Equal("abc", AlertContextBuilders.TruncateText(" abc ")); + Assert.Equal(new string('x', 300) + "...", AlertContextBuilders.TruncateText(new string('x', 350))); + Assert.Equal("ab...", AlertContextBuilders.TruncateText("abcdef", 2)); + } + + /* ---------------- A0: the shared row types are the app's row types ---------------- */ + + [Fact] + public void RowTypes_ResolveToAlertingNamespace_ThroughAppApiSurface() + { + /* The global using aliases in the Dashboard's GlobalUsings.cs point the old bare type names + at PerformanceMonitor.Alerting — so AlertHealthResult (the alert engine's input carrier) + must now surface the SHARED types on all six alert row members. */ + Assert.Equal("PerformanceMonitor.Alerting", typeof(FailedJobInfo).Namespace); + + Assert.Equal(typeof(List<PoisonWaitDelta>), + typeof(AlertHealthResult).GetProperty("PoisonWaits")!.PropertyType); + Assert.Equal(typeof(List<LongRunningQueryInfo>), + typeof(AlertHealthResult).GetProperty("LongRunningQueries")!.PropertyType); + Assert.Equal(typeof(TempDbSpaceInfo), + typeof(AlertHealthResult).GetProperty("TempDbSpace")!.PropertyType); + Assert.Equal(typeof(List<VolumeFreeSpaceInfo>), + typeof(AlertHealthResult).GetProperty("Volumes")!.PropertyType); + Assert.Equal(typeof(List<AnomalousJobInfo>), + typeof(AlertHealthResult).GetProperty("AnomalousJobs")!.PropertyType); + Assert.Equal(typeof(List<FailedJobInfo>), + typeof(AlertHealthResult).GetProperty("RecentlyFailedJobs")!.PropertyType); + } +} diff --git a/Dashboard.Tests/AnomalyIncidentReconcilerTests.cs b/Dashboard.Tests/AnomalyIncidentReconcilerTests.cs new file mode 100644 index 000000000..90743c2a9 --- /dev/null +++ b/Dashboard.Tests/AnomalyIncidentReconcilerTests.cs @@ -0,0 +1,257 @@ +using System.Collections.Generic; +using PerformanceMonitor.Analysis; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// <summary> +/// Tests the shared <see cref="AnomalyIncidentReconciler"/> (Stage B, change 3): after clustering +/// stamps each story's incident id, an ANOMALY_* story folds into the REGULAR finding that describes +/// the same symptom in the same run and same database, by rewriting its incident id onto the parent's. +/// Fold-don't-suppress, database-aware, and only when a real parent exists. Lives in both Lite.Tests +/// and Dashboard.Tests because the reconciler is shared and each app references the assembly. +/// </summary> +public class AnomalyIncidentReconcilerTests +{ + private static AnalysisStory Story( + string rootKey, string incidentId, string? db = null, + Dictionary<string, double>? metadata = null, double severity = 1.0, + IEnumerable<string>? path = null) => + new() + { + RootFactKey = rootKey, + IncidentId = incidentId, + DatabaseName = db, + Severity = severity, + RootFactMetadata = metadata, + // Real stories carry their full traversal path (root + members). Default to the single-key + // path the earlier tests used; pass `path` to place a family fact as a NON-ROOT member. + Path = path is null ? [rootKey] : new List<string>(path), + }; + + [Fact] + public void CxDominantWaitProfile_FoldsIntoCxpacketIncident() + { + // Dominant contrib_ is a CX* type -> family CXPACKET (mirrors GroupParallelismWaits). + var regular = Story("CXPACKET", "cx-incident", severity: 1.4); + var anomaly = Story("ANOMALY_WAIT_PROFILE", "anomaly-solo", + metadata: new() { ["contrib_CXPACKET"] = 5000, ["contrib_SOS_SCHEDULER_YIELD"] = 900 }); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { regular, anomaly }); + + Assert.Equal("cx-incident", anomaly.IncidentId); // folded into the CXPACKET incident + Assert.Equal("cx-incident", regular.IncidentId); // parent id untouched + } + + [Fact] + public void GeneralLockDominantWaitProfile_FoldsIntoLckIncident() + { + // LCK_M_X is a general lock mode -> family LCK (mirrors IsGeneralLockWait grouping). + var regular = Story("LCK", "lck-incident"); + var anomaly = Story("ANOMALY_WAIT_PROFILE", "anomaly-solo", + metadata: new() { ["contrib_LCK_M_X"] = 8000, ["contrib_CXPACKET"] = 100 }); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { regular, anomaly }); + + Assert.Equal("lck-incident", anomaly.IncidentId); + } + + [Fact] + public void ReaderLockDominantWaitProfile_FoldsIntoOwnKey_NotLck() + { + // LCK_M_S is kept separate from the LCK family (RCSI signal) -> family LCK_M_S, so it must + // fold into the LCK_M_S finding, NOT the general LCK one. + var lck = Story("LCK", "lck-incident"); + var readerLock = Story("LCK_M_S", "readerlock-incident"); + var anomaly = Story("ANOMALY_WAIT_PROFILE", "anomaly-solo", + metadata: new() { ["contrib_LCK_M_S"] = 8000, ["contrib_LCK_M_X"] = 10 }); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { lck, readerLock, anomaly }); + + Assert.Equal("readerlock-incident", anomaly.IncidentId); + } + + [Fact] + public void CpuSpike_FoldsIntoCpuIncident() + { + var regular = Story("CPU_SQL_PERCENT", "cpu-incident"); + var anomaly = Story("ANOMALY_CPU_SPIKE", "anomaly-solo"); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { regular, anomaly }); + + Assert.Equal("cpu-incident", anomaly.IncidentId); + } + + [Fact] + public void NoRegularParent_AnomalyStaysSolo() + { + // ANOMALY_CPU_SPIKE with no CPU_SQL_PERCENT parent (only an unrelated regular story) stays solo. + var unrelated = Story("CXPACKET", "cx-incident"); + var anomaly = Story("ANOMALY_CPU_SPIKE", "anomaly-solo"); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { unrelated, anomaly }); + + Assert.Equal("anomaly-solo", anomaly.IncidentId); + } + + [Fact] + public void DifferentDatabase_DoesNotFold() + { + // The anomaly is in db1, the only regular parent is in db2 -> DB-aware, no fold. + var regular = Story("CPU_SQL_PERCENT", "cpu-db2", db: "db2"); + var anomaly = Story("ANOMALY_CPU_SPIKE", "anomaly-db1", db: "db1"); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { regular, anomaly }); + + Assert.Equal("anomaly-db1", anomaly.IncidentId); + } + + [Fact] + public void SameDatabase_Folds() + { + var regular = Story("CPU_SQL_PERCENT", "cpu-db1", db: "db1"); + var anomaly = Story("ANOMALY_CPU_SPIKE", "anomaly-db1", db: "db1"); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { regular, anomaly }); + + Assert.Equal("cpu-db1", anomaly.IncidentId); + } + + [Fact] + public void AnomalyObject_NeverCrossDbFolds() + { + // ANOMALY_OBJECT_* is unmapped AND db-scoped: it never folds, and certainly not into a + // regular finding in a different database. + var regularDb2 = Story("BLOCKING_EVENTS", "blk-db2", db: "db2"); + var objectAnomaly = Story("ANOMALY_OBJECT_CONTENTION", "object-db1", db: "db1"); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { regularDb2, objectAnomaly }); + + Assert.Equal("object-db1", objectAnomaly.IncidentId); + } + + [Fact] + public void UnmappedAnomaly_StaysSolo() + { + // Batch/session/query-duration anomalies have no single regular counterpart. + var regular = Story("CPU_SQL_PERCENT", "cpu-incident"); + var anomaly = Story("ANOMALY_BATCH_REQUESTS", "anomaly-solo"); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { regular, anomaly }); + + Assert.Equal("anomaly-solo", anomaly.IncidentId); + } + + [Fact] + public void AbsolutionParent_IsIgnored() + { + // An absolution story is never a fold target. + var absolution = new AnalysisStory { RootFactKey = "server_health", IsAbsolution = true, IncidentId = "" }; + var anomaly = Story("ANOMALY_CPU_SPIKE", "anomaly-solo"); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { absolution, anomaly }); + + Assert.Equal("anomaly-solo", anomaly.IncidentId); + } + + [Fact] + public void FewerThanTwoStories_NoOp() + { + AnomalyIncidentReconciler.Reconcile(null!); // no throw + var solo = new List<AnalysisStory> { Story("ANOMALY_CPU_SPIKE", "anomaly-solo") }; + AnomalyIncidentReconciler.Reconcile(solo); + Assert.Equal("anomaly-solo", solo[0].IncidentId); + } + + /* ── Path-based fold-target indexing (review finding 2): a mapped family fact is frequently a + NON-ROOT member of a larger story, so fold targets are indexed by every key in a regular story's + PATH, not just its root — the case the earlier tests (family fact always AS the root) never hit. ── */ + + [Fact] + public void CpuSpike_FoldsIntoStory_WhereCpuSqlPercentIsNonRootMember() + { + // Canonical correlated run: a higher-severity SOS_SCHEDULER_YIELD roots the story and consumes + // CPU_SQL_PERCENT as a member (SOS -> CPU_SQL_PERCENT). No story is ROOTED on CPU_SQL_PERCENT, so + // root-only indexing missed this exact case; path indexing folds the anomaly in. + var cpuStory = Story("SOS_SCHEDULER_YIELD", "cpu-incident", severity: 1.7, + path: new[] { "SOS_SCHEDULER_YIELD", "CPU_SQL_PERCENT" }); + var anomaly = Story("ANOMALY_CPU_SPIKE", "anomaly-solo"); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { cpuStory, anomaly }); + + Assert.Equal("cpu-incident", anomaly.IncidentId); + } + + [Fact] + public void BlockingSpike_FoldsIntoStory_WhereBlockingIsNonRootMember() + { + // Generality beyond CPU: BLOCKING_EVENTS consumed as a member of a THREADPOOL-rooted story + // (THREADPOOL/LCK -> BLOCKING_EVENTS). The root key is the THREADPOOL relabel (not a fold + // target itself), but its BLOCKING_EVENTS member is indexed and captures the anomaly. + var threadpool = Story("THREADPOOL_BLOCKING", "blk-incident", severity: 1.8, + path: new[] { "THREADPOOL_BLOCKING", "BLOCKING_EVENTS" }); + var anomaly = Story("ANOMALY_BLOCKING_SPIKE", "anomaly-solo"); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { threadpool, anomaly }); + + Assert.Equal("blk-incident", anomaly.IncidentId); + } + + [Fact] + public void CpuSpike_FoldsIntoCpuSpikeRootedStory() + { + // Sub-note: a run whose CPU story is keyed on the BURST detector (CPU_SPIKE) rather than the + // sustained CPU_SQL_PERCENT is still a valid parent for ANOMALY_CPU_SPIKE. + var burst = Story("CPU_SPIKE", "burst-incident"); + var anomaly = Story("ANOMALY_CPU_SPIKE", "anomaly-solo"); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { burst, anomaly }); + + Assert.Equal("burst-incident", anomaly.IncidentId); + } + + [Fact] + public void CpuSpike_FoldsIntoStory_WhereCpuSpikeIsNonRootMember() + { + // Sub-note, non-root form: CPU_SPIKE consumed as a member of a plan-regression story + // (PLAN_REGRESSION -> CPU_SPIKE) is still a valid CPU parent for the anomaly. + var planStory = Story("PLAN_REGRESSION", "plan-incident", severity: 1.5, + path: new[] { "PLAN_REGRESSION", "CPU_SPIKE" }); + var anomaly = Story("ANOMALY_CPU_SPIKE", "anomaly-solo"); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { planStory, anomaly }); + + Assert.Equal("plan-incident", anomaly.IncidentId); + } + + [Fact] + public void PathIndexing_DoesNotFoldIntoUnrelatedMultiKeyStory() + { + // No over-fold: a multi-key story that does NOT contain a CPU family key must not capture + // ANOMALY_CPU_SPIKE, even though path indexing now considers every member. + var unrelated = Story("ASYNC_NETWORK_IO", "net-incident", severity: 1.3, + path: new[] { "ASYNC_NETWORK_IO", "PAGELATCH_EX" }); + var anomaly = Story("ANOMALY_CPU_SPIKE", "anomaly-solo"); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { unrelated, anomaly }); + + Assert.Equal("anomaly-solo", anomaly.IncidentId); + } + + [Fact] + public void MultiKeyIndexing_FoldsIntoOwningStory_LeavesOthersUntouched() + { + // With two unrelated regular stories, the anomaly folds into the ONE whose path owns the CPU + // family (as a non-root member) and leaves the other incident id untouched — no over-fold. + var ioStory = Story("IO_READ_LATENCY_MS", "io-incident", severity: 1.2); + var cpuStory = Story("SOS_SCHEDULER_YIELD", "cpu-incident", severity: 1.7, + path: new[] { "SOS_SCHEDULER_YIELD", "CPU_SQL_PERCENT" }); + var anomaly = Story("ANOMALY_CPU_SPIKE", "anomaly-solo"); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { ioStory, cpuStory, anomaly }); + + Assert.Equal("cpu-incident", anomaly.IncidentId); + Assert.Equal("io-incident", ioStory.IncidentId); // unrelated story untouched + Assert.Equal("cpu-incident", cpuStory.IncidentId); // parent id untouched + } +} diff --git a/Dashboard.Tests/AzureAuthConnectionStringTests.cs b/Dashboard.Tests/AzureAuthConnectionStringTests.cs new file mode 100644 index 000000000..e31d004fe --- /dev/null +++ b/Dashboard.Tests/AzureAuthConnectionStringTests.cs @@ -0,0 +1,321 @@ +/* + * Performance Monitor Dashboard + * Copyright (c) 2026 Darling Data, LLC + * Licensed under the MIT License - see LICENSE file for details + */ + +using System.Collections.Generic; +using Microsoft.Data.SqlClient; +using PerformanceMonitor.Common; +using PerformanceMonitorDashboard.Interfaces; +using PerformanceMonitorDashboard.Models; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// <summary> +/// Connection-string SHAPE tests for the non-interactive Azure auth modes: service principal and +/// managed identity. Mirrors Lite.Tests/AzureAuthConnectionStringTests so the two apps' SP/MI keyword +/// mapping can never silently drift apart. The testable surface is the keywords emitted by +/// ServerConnection.ApplyAuthentication (shared by the production GetConnectionString builder and the +/// Add/Edit dialog's Test-Connection builder). Real Azure auth (token acquisition) is a maintainer +/// E2E step and is NOT exercised here. +/// </summary> +public class AzureAuthConnectionStringTests +{ + /// <summary>In-memory ICredentialService so the credential-fetch paths can be exercised without + /// touching the real Windows Credential Manager.</summary> + private sealed class FakeCredentialService : ICredentialService + { + private readonly Dictionary<string, (string Username, string Password)> _store = new(); + + public bool SaveCredential(string serverId, string username, string password) + { + _store[serverId] = (username, password); + return true; + } + + public (string Username, string Password)? GetCredential(string serverId) + => _store.TryGetValue(serverId, out var c) ? c : null; + + public bool DeleteCredential(string serverId) + { + _store.Remove(serverId); + return true; + } + + public bool CredentialExists(string serverId) => _store.ContainsKey(serverId); + + public bool UpdateCredential(string serverId, string username, string password) + { + _store[serverId] = (username, password); + return true; + } + } + + // ---- ApplyAuthentication keyword mapping ---------------------------------------------- + // Tests the shared helper directly (internal, visible via InternalsVisibleTo=Dashboard.Tests). + + private static (SqlAuthenticationMethod Auth, string? User, string? Pwd, bool Integrated) AuthShape( + string authType, string? user, string? pwd, string? azureClientId, string? miClientId) + { + var b = new SqlConnectionStringBuilder(); + ServerConnection.ApplyAuthentication(b, authType, user, pwd, azureClientId, miClientId); + return (b.Authentication, b.UserID, b.Password, b.IntegratedSecurity); + } + + [Fact] + public void ServicePrincipal_SetsActiveDirectoryServicePrincipal_WithClientIdAndSecret() + { + var shape = AuthShape(AuthenticationTypes.ServicePrincipal, "client-id-123", "the-secret", + azureClientId: null, miClientId: null); + + Assert.Equal(SqlAuthenticationMethod.ActiveDirectoryServicePrincipal, shape.Auth); + Assert.Equal("client-id-123", shape.User); + Assert.Equal("the-secret", shape.Pwd); + Assert.False(shape.Integrated); + } + + [Fact] + public void ServicePrincipal_FallsBackToAzureClientId_WhenUsernameNull() + { + var shape = AuthShape(AuthenticationTypes.ServicePrincipal, null, "secret", + azureClientId: "model-client-id", miClientId: null); + + Assert.Equal("model-client-id", shape.User); + Assert.Equal("secret", shape.Pwd); + } + + [Fact] + public void ManagedIdentity_SystemAssigned_NoUserIdNoPassword() + { + var shape = AuthShape(AuthenticationTypes.ManagedIdentity, null, null, + azureClientId: null, miClientId: null); + + Assert.Equal(SqlAuthenticationMethod.ActiveDirectoryManagedIdentity, shape.Auth); + Assert.True(string.IsNullOrEmpty(shape.User)); + Assert.True(string.IsNullOrEmpty(shape.Pwd)); + Assert.False(shape.Integrated); + } + + [Fact] + public void ManagedIdentity_UserAssigned_SetsUserIdToClientId() + { + var shape = AuthShape(AuthenticationTypes.ManagedIdentity, null, null, + azureClientId: null, miClientId: "uami-client-id"); + + Assert.Equal(SqlAuthenticationMethod.ActiveDirectoryManagedIdentity, shape.Auth); + Assert.Equal("uami-client-id", shape.User); + Assert.True(string.IsNullOrEmpty(shape.Pwd)); + } + + // ---- Regression: existing modes unchanged --------------------------------------------- + + [Fact] + public void Windows_UsesIntegratedSecurity_NoAuthenticationKeyword() + { + var shape = AuthShape(AuthenticationTypes.Windows, null, null, null, null); + + Assert.True(shape.Integrated); + Assert.Equal(SqlAuthenticationMethod.NotSpecified, shape.Auth); + } + + [Fact] + public void SqlServer_SetsUserIdAndPassword_NoAuthenticationKeyword() + { + var shape = AuthShape(AuthenticationTypes.SqlServer, "sa", "pw", null, null); + + Assert.False(shape.Integrated); + Assert.Equal("sa", shape.User); + Assert.Equal("pw", shape.Pwd); + Assert.Equal(SqlAuthenticationMethod.NotSpecified, shape.Auth); + } + + [Fact] + public void EntraMfa_SetsActiveDirectoryInteractive() + { + var shape = AuthShape(AuthenticationTypes.EntraMFA, "user@tenant.com", null, null, null); + + Assert.Equal(SqlAuthenticationMethod.ActiveDirectoryInteractive, shape.Auth); + Assert.Equal("user@tenant.com", shape.User); + } + + // ---- AuthenticationDisplay switch (label parity with Lite) ---------------------------- + + [Theory] + [InlineData(AuthenticationTypes.ServicePrincipal, "Azure — Service Principal")] + [InlineData(AuthenticationTypes.ManagedIdentity, "Azure — Managed Identity")] + [InlineData(AuthenticationTypes.EntraMFA, "Microsoft Entra MFA")] + [InlineData(AuthenticationTypes.SqlServer, "SQL Server")] + [InlineData(AuthenticationTypes.Windows, "Windows")] + public void AuthenticationDisplay_MapsEachMode(string authType, string expected) + { + var server = new ServerConnection { AuthenticationType = authType }; + Assert.Equal(expected, server.AuthenticationDisplay); + } + + // ---- Full GetConnectionString path (unified builder + credential resolution) ---------- + + private static SqlConnectionStringBuilder Build(ServerConnection server, ICredentialService cs) + => new(server.GetConnectionString(cs)); + + [Fact] + public void GetConnectionString_ServicePrincipal_FetchesStoredSecret() + { + var cs = new FakeCredentialService(); + var server = new ServerConnection { ServerName = "s", AuthenticationType = AuthenticationTypes.ServicePrincipal }; + cs.SaveCredential(server.Id, "stored-client-id", "stored-secret"); + + var conn = Build(server, cs); + + Assert.Equal(SqlAuthenticationMethod.ActiveDirectoryServicePrincipal, conn.Authentication); + Assert.Equal("stored-client-id", conn.UserID); + Assert.Equal("stored-secret", conn.Password); + // Dashboard always targets the PerformanceMonitor database. + Assert.Equal("PerformanceMonitor", conn.InitialCatalog); + } + + [Fact] + public void GetConnectionString_ServicePrincipal_FallsBackToAzureClientId_WhenNoStoredUsername() + { + // No stored credential — the model's AzureClientId supplies the UserID. + var cs = new FakeCredentialService(); + var server = new ServerConnection + { + ServerName = "s", + AuthenticationType = AuthenticationTypes.ServicePrincipal, + AzureClientId = "model-client-id" + }; + + var conn = Build(server, cs); + + Assert.Equal(SqlAuthenticationMethod.ActiveDirectoryServicePrincipal, conn.Authentication); + Assert.Equal("model-client-id", conn.UserID); + } + + [Fact] + public void GetConnectionString_ManagedIdentity_FetchesNoSecret() + { + var cs = new FakeCredentialService(); + var server = new ServerConnection { ServerName = "s", AuthenticationType = AuthenticationTypes.ManagedIdentity }; + // Even if a stale credential somehow existed, MI must not pull it into the string. + cs.SaveCredential(server.Id, "leftover", "leftover-secret"); + + var conn = Build(server, cs); + + Assert.Equal(SqlAuthenticationMethod.ActiveDirectoryManagedIdentity, conn.Authentication); + Assert.True(string.IsNullOrEmpty(conn.Password)); + Assert.NotEqual("leftover", conn.UserID); + } + + [Fact] + public void GetConnectionString_ManagedIdentity_UserAssigned_SetsUserId() + { + var cs = new FakeCredentialService(); + var server = new ServerConnection + { + ServerName = "s", + AuthenticationType = AuthenticationTypes.ManagedIdentity, + ManagedIdentityClientId = "uami-client-id" + }; + + var conn = Build(server, cs); + + Assert.Equal("uami-client-id", conn.UserID); + Assert.True(string.IsNullOrEmpty(conn.Password)); + } + + [Fact] + public void GetConnectionString_Windows_UsesIntegratedSecurity() + { + var cs = new FakeCredentialService(); + var server = new ServerConnection { ServerName = "s", AuthenticationType = AuthenticationTypes.Windows }; + + var conn = Build(server, cs); + + Assert.True(conn.IntegratedSecurity); + Assert.Equal(SqlAuthenticationMethod.NotSpecified, conn.Authentication); + } + + [Fact] + public void GetConnectionString_SqlServer_FetchesStoredUsernameAndPassword() + { + var cs = new FakeCredentialService(); + var server = new ServerConnection { ServerName = "s", AuthenticationType = AuthenticationTypes.SqlServer }; + cs.SaveCredential(server.Id, "sa", "pw"); + + var conn = Build(server, cs); + + Assert.False(conn.IntegratedSecurity); + Assert.Equal("sa", conn.UserID); + Assert.Equal("pw", conn.Password); + Assert.Equal(SqlAuthenticationMethod.NotSpecified, conn.Authentication); + } + + [Fact] + public void GetConnectionString_EntraMfa_SetsInteractive_AndUsernameHint() + { + var cs = new FakeCredentialService(); + var server = new ServerConnection { ServerName = "s", AuthenticationType = AuthenticationTypes.EntraMFA }; + cs.SaveCredential(server.Id, "user@tenant.com", string.Empty); + + var conn = Build(server, cs); + + Assert.Equal(SqlAuthenticationMethod.ActiveDirectoryInteractive, conn.Authentication); + Assert.Equal("user@tenant.com", conn.UserID); + } + + // ---- HasStoredCredentials -------------------------------------------------------------- + + [Fact] + public void HasStoredCredentials_ManagedIdentity_IsTrue_WithoutCredential() + { + var cs = new FakeCredentialService(); + var server = new ServerConnection { AuthenticationType = AuthenticationTypes.ManagedIdentity }; + + Assert.True(server.HasStoredCredentials(cs)); + } + + [Fact] + public void HasStoredCredentials_ServicePrincipal_ReflectsCredentialStore() + { + var cs = new FakeCredentialService(); + var server = new ServerConnection { AuthenticationType = AuthenticationTypes.ServicePrincipal }; + + Assert.False(server.HasStoredCredentials(cs)); + + cs.SaveCredential(server.Id, "client", "secret"); + Assert.True(server.HasStoredCredentials(cs)); + } + + // ---- Two-build-site PARITY ------------------------------------------------------------ + // The dialog's Test-Connection builder and ServerConnection's production builder both route + // their auth keywords through the shared ServerConnection.ApplyAuthentication helper, so they + // emit identical Authentication / UserID / Password / IntegratedSecurity for each mode. + + [Theory] + [InlineData(AuthenticationTypes.Windows)] + [InlineData(AuthenticationTypes.SqlServer)] + [InlineData(AuthenticationTypes.EntraMFA)] + [InlineData(AuthenticationTypes.ServicePrincipal)] + [InlineData(AuthenticationTypes.ManagedIdentity)] + public void BuildSites_ProduceIdenticalAuthShape_PerMode(string authType) + { + string? user = authType switch + { + AuthenticationTypes.SqlServer => "sa", + AuthenticationTypes.EntraMFA => "user@tenant.com", + AuthenticationTypes.ServicePrincipal => "client-id", + _ => null + }; + string? pwd = authType is AuthenticationTypes.SqlServer or AuthenticationTypes.ServicePrincipal ? "secret" : null; + string? mi = authType == AuthenticationTypes.ManagedIdentity ? "uami-id" : null; + + // Production builder passes the model's AzureClientId; the dialog builder passes null (it + // surfaces the SP client id as the username). Both must reduce to the same auth shape. + var production = AuthShape(authType, user, pwd, azureClientId: "client-id", miClientId: mi); + var dialog = AuthShape(authType, user, pwd, azureClientId: null, miClientId: mi); + + Assert.Equal(production, dialog); + } +} diff --git a/Dashboard.Tests/ChartStyleYLimitsTests.cs b/Dashboard.Tests/ChartStyleYLimitsTests.cs new file mode 100644 index 000000000..3613ca56e --- /dev/null +++ b/Dashboard.Tests/ChartStyleYLimitsTests.cs @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using PerformanceMonitor.Ui; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// <summary> +/// Regression tests for <see cref="ChartStyle.ComputeYLimitsWithLegendPadding"/> — the pure Y-limit math +/// shared by every trend chart in all three apps (Lite, Dashboard, and the Darling viewer). Pins the fix +/// for the magnitude-scaled negative floor bug: a non-negative series (dataYMin >= 0) must floor the +/// axis at 0, NOT at -5% of the data max. The old middle branch put a big dead-band under high-value +/// charts (e.g. Wait Stats peaking ~6000 ms/sec dropped to a -300 floor). Only genuinely-negative data +/// keeps a below-zero margin. Mirror of Lite.Tests for parity. +/// </summary> +public class ChartStyleYLimitsTests +{ + [Fact] + public void ZeroDataMin_FloorsAtZero_NoNegativeDeadBand() + { + // The exact bug: callers pass an explicit dataYMin == 0, which used to hit the middle branch + // and produce yMin = -(range * 0.05) = -300 for a 0..6000 chart. It must now floor at 0. + var (yMin, _) = ChartStyle.ComputeYLimitsWithLegendPadding(0, 6000); + Assert.Equal(0.0, yMin); + } + + [Fact] + public void PositiveDataMin_FloorsAtZero() + { + var (yMin, _) = ChartStyle.ComputeYLimitsWithLegendPadding(50, 6000); + Assert.Equal(0.0, yMin); + } + + [Fact] + public void NegativeDataMin_KeepsTenPercentBelowZeroMargin() + { + // range = 100; genuinely-negative data keeps its 10%-of-range breathing room below the min. + var (yMin, _) = ChartStyle.ComputeYLimitsWithLegendPadding(-10, 90); + Assert.Equal(-20.0, yMin, 6); // -10 - (100 * 0.10) + } + + [Fact] + public void TopPadding_IsFifteenPercentOfRange() + { + var (_, yMax) = ChartStyle.ComputeYLimitsWithLegendPadding(0, 6000); + Assert.Equal(6900.0, yMax, 6); // 6000 + (6000 * 0.15) + } + + [Fact] + public void FlatData_ExpandsRangeAndStillFloorsAtZero() + { + // dataYMax <= dataYMin forces range to 1 so a flat non-negative series still gets a visible band. + var (yMin, yMax) = ChartStyle.ComputeYLimitsWithLegendPadding(5, 5); + Assert.Equal(0.0, yMin); + Assert.Equal(6.15, yMax, 6); // dataYMax -> 6, + (1 * 0.15) + } +} diff --git a/Dashboard.Tests/Dashboard.Tests.csproj b/Dashboard.Tests/Dashboard.Tests.csproj index f77205704..ef80ea238 100644 --- a/Dashboard.Tests/Dashboard.Tests.csproj +++ b/Dashboard.Tests/Dashboard.Tests.csproj @@ -20,9 +20,4 @@ <ItemGroup> <ProjectReference Include="..\Dashboard\Dashboard.csproj" /> </ItemGroup> - - <ItemGroup> - <!-- Deadlock graph parser/layout golden samples (real sql2025 captures + synthetic edge cases). --> - <None Include="Fixtures\Deadlocks\*.xml" CopyToOutputDirectory="PreserveNewest" /> - </ItemGroup> </Project> diff --git a/Dashboard.Tests/DataGridExportTests.cs b/Dashboard.Tests/DataGridExportTests.cs new file mode 100644 index 000000000..6cc38d7cc --- /dev/null +++ b/Dashboard.Tests/DataGridExportTests.cs @@ -0,0 +1,157 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Windows.Controls; +using System.Windows.Data; +using PerformanceMonitor.Ui; +using Xunit; + +namespace Dashboard.Tests; + +/// <summary> +/// Tests for the shared DataGridExport (PerformanceMonitor.Ui) used by every copy/export +/// context menu in both apps. Because all ~29 sites delegate here, these cover their behavior. +/// The pure formatter/escaper tests run anywhere; the grid tests run on an STA thread because +/// WPF objects require it. +/// </summary> +public class DataGridExportTests +{ + private sealed class Row + { + public string Name { get; set; } = ""; + public int Value { get; set; } + public string Note { get; set; } = ""; + } + + private static T RunSta<T>(Func<T> body) + { + T result = default!; + Exception? error = null; + var thread = new Thread(() => + { + try { result = body(); } + catch (Exception ex) { error = ex; } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + if (error != null) throw error; + return result; + } + + private static DataGrid BuildGrid() + { + var grid = new DataGrid(); + grid.Columns.Add(new DataGridTextColumn { Header = "Name", Binding = new Binding(nameof(Row.Name)) }); + grid.Columns.Add(new DataGridTextColumn { Header = "Value", Binding = new Binding(nameof(Row.Value)) }); + + // A template column with a TextBlock bound to Note -- the case the old Dashboard + // handlers mishandled (header filtered out, value kept). + var template = new System.Windows.DataTemplate(); + var tb = new System.Windows.FrameworkElementFactory(typeof(TextBlock)); + tb.SetBinding(TextBlock.TextProperty, new Binding(nameof(Row.Note))); + template.VisualTree = tb; + template.Seal(); + grid.Columns.Add(new DataGridTemplateColumn { Header = "Note", CellTemplate = template }); + + grid.ItemsSource = new[] + { + new Row { Name = "a,b", Value = 42, Note = "hi" }, + new Row { Name = "plain", Value = 7, Note = "there" } + }; + return grid; + } + + [Theory] + [InlineData(null, "")] + [InlineData("text", "text")] + public void FormatForExport_HandlesStringsAndNull(string? input, string expected) + { + Assert.Equal(expected, DataGridExport.FormatForExport(input)); + } + + [Fact] + public void FormatForExport_UsesInvariantCultureForNumbers() + { + var prior = CultureInfo.CurrentCulture; + try + { + // A culture whose decimal separator is a comma -- proves we don't emit "1,5". + CultureInfo.CurrentCulture = new CultureInfo("de-DE"); + Assert.Equal("1.5", DataGridExport.FormatForExport(1.5)); + Assert.Equal("42", DataGridExport.FormatForExport(42)); + } + finally + { + CultureInfo.CurrentCulture = prior; + } + } + + [Theory] + [InlineData("plain", "plain")] + [InlineData("", "")] + [InlineData("a,b", "\"a,b\"")] + [InlineData("a\"b", "\"a\"\"b\"")] + [InlineData("a\nb", "\"a\nb\"")] + public void CsvEscape_QuotesWhenNeeded(string input, string expected) + { + Assert.Equal(expected, DataGridExport.CsvEscape(input, ",")); + } + + [Fact] + public void CsvEscape_QuotesWhenValueContainsSeparator() + { + // With a semicolon separator a comma is NOT special, but a semicolon is. + Assert.Equal("a,b", DataGridExport.CsvEscape("a,b", ";")); + Assert.Equal("\"a;b\"", DataGridExport.CsvEscape("a;b", ";")); + } + + [Fact] + public void GetRowValues_ReturnsOneValuePerColumn_IncludingTemplate() + { + var values = RunSta(() => + { + var grid = BuildGrid(); + var item = grid.Items.Cast<object>().First(); + return DataGridExport.GetRowValues(grid, item); + }); + + // Bound, bound, template -> three values, in column order. + Assert.Equal(new[] { "a,b", "42", "hi" }, values); + } + + [Fact] + public void BuildClipboardText_KeepsHeadersAndRowsAligned() + { + var (headerFields, rowFieldCounts, columnCount) = RunSta(() => + { + var grid = BuildGrid(); + var text = DataGridExport.BuildClipboardText(grid); + var lines = text.Replace("\r\n", "\n").TrimEnd('\n').Split('\n'); + var headers = lines[0].Split('\t'); + var counts = lines.Skip(1).Select(l => l.Split('\t').Length).ToArray(); + return (headers, counts, grid.Columns.Count); + }); + + Assert.Equal(new[] { "Name", "Value", "Note" }, headerFields); + Assert.Equal(3, columnCount); + // The regression guard: every row has exactly as many fields as there are columns/headers. + Assert.All(rowFieldCounts, count => Assert.Equal(columnCount, count)); + } + + [Fact] + public void BuildCsv_EscapesAndIncludesAllColumns() + { + var csv = RunSta(() => + { + var grid = BuildGrid(); + return DataGridExport.BuildCsv(grid, ",").Replace("\r\n", "\n"); + }); + + var lines = csv.TrimEnd('\n').Split('\n'); + Assert.Equal("Name,Value,Note", lines[0]); + Assert.Equal("\"a,b\",42,hi", lines[1]); // comma value quoted, number invariant, template value present + Assert.Equal("plain,7,there", lines[2]); + } +} diff --git a/Dashboard.Tests/FactScorerTests.cs b/Dashboard.Tests/FactScorerTests.cs index 1afd50583..01d075ac6 100644 --- a/Dashboard.Tests/FactScorerTests.cs +++ b/Dashboard.Tests/FactScorerTests.cs @@ -47,6 +47,109 @@ public void Score_UnknownWaitType_GetsSeverityZero() Assert.Equal(0.0, facts[0].BaseSeverity); } + /* ── Wait-profile anomaly scoring (change 1) ── */ + + // The shared ANOMALY_WAIT_PROFILE arm scores on the HONEST per-second ratio: below 4x → 0, 4x → + // 0.5, saturating to 1.0 at 12x. Same shared FactScorer as Lite — pinned here for parity. + [Theory] + [InlineData(3.0, 0.0)] + [InlineData(4.0, 0.5)] + [InlineData(12.0, 1.0)] + [InlineData(100.0, 1.0)] + public void Score_WaitProfile_RampsFromHonestRatio(double ratio, double expected) + { + var fact = new Fact + { + Source = "anomaly", + Key = "ANOMALY_WAIT_PROFILE", + Metadata = new() { ["ratio"] = ratio } + }; + new FactScorer().ScoreAll(new List<Fact> { fact }); + Assert.Equal(expected, fact.BaseSeverity, precision: 4); + } + + [Fact] + public void WaitProfile_HasAdviceBlock() + { + var advice = FactAdvice.GetForFactKey("ANOMALY_WAIT_PROFILE"); + Assert.NotNull(advice); + Assert.False(string.IsNullOrWhiteSpace(advice!.Headline)); + Assert.False(string.IsNullOrWhiteSpace(advice.Investigation)); + Assert.False(string.IsNullOrWhiteSpace(advice.Remediation)); + } + + /* ── Bounded-metric low-quality (thin-baseline) fallback scoring (finding 1) ── */ + + // When the quality gate fires on a thin/untrustworthy baseline (baseline_low_quality=1) the stored + // deviation_sigma is the real (small) z — the 2σ gate would zero it and InferenceEngine would drop the + // finding. The scorer grades off the absolute exceedance (peak ÷ the fallback bar) instead: floor 0.5 + // AT the bar, ramping to 1.0 at 2× the bar. Same shared FactScorer as Lite — pinned here for parity. + [Theory] + [InlineData("ANOMALY_CPU_SPIKE", 1.0, 0.5)] // exactly at the bar → the 0.5 floor + [InlineData("ANOMALY_MEMORY_PRESSURE", 1.25, 0.625)] + [InlineData("ANOMALY_READ_LATENCY", 1.5, 0.75)] // 1.5× the bar → midway + [InlineData("ANOMALY_WRITE_LATENCY", 2.0, 1.0)] // 2× the bar → saturated + [InlineData("ANOMALY_BATCH_REQUESTS", 5.0, 1.0)] // far past → clamped to 1.0 + public void Score_LowQualityFallback_GradesOffExceedance_NotSigma( + string key, double exceedance, double expected) + { + var fact = new Fact + { + Source = "anomaly", + Key = key, + Metadata = new() + { + ["deviation_sigma"] = 0.7, // a real, sub-2σ z the gate must ignore + ["baseline_low_quality"] = 1.0, + ["fallback_exceedance"] = exceedance, + ["confidence"] = 1.0 + } + }; + new FactScorer().ScoreAll(new List<Fact> { fact }); + Assert.Equal(expected, fact.BaseSeverity, precision: 4); + } + + // The finding's exact regression: memory 96% on a thin baseline (95% fallback bar) used to score 0 + // (small z → 2σ gate → 0 → InferenceEngine drops it → NO finding). It must now clear the 0.5 entry. + [Fact] + public void Score_Memory96PercentOnThinBaseline_ClearsInferenceEntryPoint() + { + var fact = new Fact + { + Source = "anomaly", + Key = "ANOMALY_MEMORY_PRESSURE", + Metadata = new() + { + ["deviation_sigma"] = 0.4, + ["baseline_low_quality"] = 1.0, + ["fallback_exceedance"] = 96.0 / 95.0, + ["confidence"] = 1.0 + } + }; + new FactScorer().ScoreAll(new List<Fact> { fact }); + Assert.True(fact.BaseSeverity >= 0.5, "a thin-baseline memory-96% fallback must surface, not vanish"); + } + + // The trustworthy z-path is unchanged: with baseline_low_quality=0 a sub-2σ deviation still scores 0. + [Fact] + public void Score_TrustworthyBaseline_SubTwoSigma_StillScoresZero() + { + var fact = new Fact + { + Source = "anomaly", + Key = "ANOMALY_MEMORY_PRESSURE", + Metadata = new() + { + ["deviation_sigma"] = 1.5, + ["baseline_low_quality"] = 0.0, + ["fallback_exceedance"] = 0.0, + ["confidence"] = 1.0 + } + }; + new FactScorer().ScoreAll(new List<Fact> { fact }); + Assert.Equal(0.0, fact.BaseSeverity, precision: 4); + } + /* ── Layer 2: Amplifier tests ── */ [Fact] @@ -68,11 +171,682 @@ public void Amplifier_SeverityCappedAt2() var cx = facts.First(f => f.Key == "CXPACKET"); - // base 1.0 * (1.0 + 0.3 SOS + 0.4 THREADPOOL + 0.3 CTFP + 0.2 MAXDOP) = 2.2 → capped at 2.0 + // base 1.0 * (1.0 + 0.3 SOS + 0.4 THREADPOOL + 0.3 CTFP + 0.2 MAXDOP) = 2.2 → capped at 2.0. + // (SOS + THREADPOOL are impact peers, so the Layer-3 tuning cap is released here.) Assert.True(cx.Severity <= 2.0, "Severity should never exceed 2.0"); Assert.Equal(2.0, cx.Severity); } + /* ── Layer 3: tuning-class severity cap + impact escalation (change 4) ── */ + + // Parallelism is a tuning opportunity, not an outage. CXPACKET saturates its base to 1.0 and + // non-impact amplifiers (CTFP + MAXDOP + high-DOP queries) push the Layer-2 product to 1.7, which + // WOULD land in the CRITICAL band (>= 1.5). With no impact peer co-firing, Layer 3 caps it at the + // 1.49 WARNING ceiling. Same shared FactScorer as Lite — pinned here for parity. + [Fact] + public void Score_CxPacketAlone_CappedAtWarningCeiling() + { + var facts = new List<Fact> + { + new() { Source = "waits", Key = "CXPACKET", Value = 0.80 }, // base 1.0 + new() { Source = "config", Key = "CONFIG_CTFP", Value = 5 }, // +0.3 (not an impact peer) + new() { Source = "config", Key = "CONFIG_MAXDOP", Value = 0 }, // +0.2 (not an impact peer) + new() { Source = "queries", Key = "QUERY_HIGH_DOP", Value = 10 }, // +0.2 (not an impact peer) + }; + + new FactScorer().ScoreAll(facts); + + var cx = facts.First(f => f.Key == "CXPACKET"); + // Layer-2 product = 1.0 * (1 + 0.3 + 0.2 + 0.2) = 1.7; Layer 3 caps it to 1.49. + Assert.Equal(1.49, cx.Severity, precision: 4); + Assert.True(cx.Severity < 1.5, "parallelism alone must stay in the WARNING band, never CRITICAL"); + } + + // Escape hatch: when an impact-bearing peer co-fires (THREADPOOL = real thread exhaustion) the cap + // is released and CXPACKET keeps its full amplified severity into the CRITICAL band. + [Fact] + public void Score_CxPacketWithThreadpool_CapReleased() + { + var facts = new List<Fact> + { + new() { Source = "waits", Key = "CXPACKET", Value = 0.80 }, // base 1.0 + new() { Source = "waits", Key = "THREADPOOL", Value = 0.05, // real thread exhaustion (+0.4 on CXPACKET) + Metadata = new() { ["wait_time_ms"] = 7_200_000, ["avg_ms_per_wait"] = 3_600 } }, + new() { Source = "config", Key = "CONFIG_CTFP", Value = 5 }, // +0.3 + }; + + new FactScorer().ScoreAll(facts); + + var cx = facts.First(f => f.Key == "CXPACKET"); + // 1.0 * (1 + 0.4 THREADPOOL + 0.3 CTFP) = 1.7, uncapped because an impact peer co-fired. + Assert.Equal(1.7, cx.Severity, precision: 4); + Assert.True(cx.Severity >= 1.5, "an impact peer co-firing releases the cap into CRITICAL"); + } + + // REGRESSION (escape-hatch significance gate): the cap-release peers SOS_SCHEDULER_YIELD and + // RESOURCE_SEMAPHORE must be SIGNIFICANT to release the cap, not merely present. Neither has a + // minimum guard in ScoreWaitFact, so BaseSeverity > 0 fires on any trace of the wait — and SOS + // physically co-occurs with high CXPACKET (parallel workers yield -> SOS). A trivial SOS used to + // release the cap on exactly the busy servers the cap targets, re-admitting CXPACKET=CRITICAL + // noise. These pin the significance bars (SOS 0.25, RS 0.10) the escape hatch now uses. Same shared + // FactScorer as Lite — pinned here for parity. + + // A TRIVIAL SOS (0.1% of period, far below the 0.25 bar) must NOT release the cap: CXPACKET stays + // capped at the WARNING ceiling even though a sub-threshold SOS wait is present. + [Fact] + public void Score_CxPacketWithTrivialSos_CapHolds() + { + var facts = new List<Fact> + { + new() { Source = "waits", Key = "CXPACKET", Value = 0.80 }, // base 1.0 + new() { Source = "config", Key = "CONFIG_CTFP", Value = 5 }, // +0.3 + new() { Source = "config", Key = "CONFIG_MAXDOP", Value = 0 }, // +0.2 + new() { Source = "queries", Key = "QUERY_HIGH_DOP", Value = 10 }, // +0.2 + new() { Source = "waits", Key = "SOS_SCHEDULER_YIELD", Value = 0.001 }, // TRIVIAL — 0.1%, < 0.25 bar + }; + + new FactScorer().ScoreAll(facts); + + var cx = facts.First(f => f.Key == "CXPACKET"); + // Trivial SOS neither amplifies CXPACKET (amp bar is 0.25) nor releases the cap: 1.7 -> 1.49. + Assert.Equal(1.49, cx.Severity, precision: 4); + Assert.True(cx.Severity <= 1.49, "a trivial SOS must NOT release the tuning cap"); + } + + // A SIGNIFICANT SOS (30% of period, >= the 0.25 bar) DOES release the cap: CXPACKET reaches CRITICAL. + [Fact] + public void Score_CxPacketWithSignificantSos_CapReleased() + { + var facts = new List<Fact> + { + new() { Source = "waits", Key = "CXPACKET", Value = 0.80 }, // base 1.0 + new() { Source = "config", Key = "CONFIG_CTFP", Value = 5 }, // +0.3 + new() { Source = "config", Key = "CONFIG_MAXDOP", Value = 0 }, // +0.2 + new() { Source = "queries", Key = "QUERY_HIGH_DOP", Value = 10 }, // +0.2 + new() { Source = "waits", Key = "SOS_SCHEDULER_YIELD", Value = 0.30 }, // SIGNIFICANT — >= 0.25 bar + }; + + new FactScorer().ScoreAll(facts); + + var cx = facts.First(f => f.Key == "CXPACKET"); + // Significant SOS also fires the CXPACKET SOS amplifier (+0.3): 1.0*(1+0.3+0.3+0.2+0.2)=2.0, uncapped. + Assert.Equal(2.0, cx.Severity, precision: 4); + Assert.True(cx.Severity >= 1.5, "a significant SOS releases the cap into CRITICAL"); + } + + // A TRIVIAL RESOURCE_SEMAPHORE (0.1% of period, below the 0.10 bar) must NOT release the cap. RS + // never amplifies CXPACKET, so pre-cap CXPACKET is 1.7 either way — this isolates the escape hatch. + [Fact] + public void Score_CxPacketWithTrivialResourceSemaphore_CapHolds() + { + var facts = new List<Fact> + { + new() { Source = "waits", Key = "CXPACKET", Value = 0.80 }, // base 1.0 + new() { Source = "config", Key = "CONFIG_CTFP", Value = 5 }, // +0.3 + new() { Source = "config", Key = "CONFIG_MAXDOP", Value = 0 }, // +0.2 + new() { Source = "queries", Key = "QUERY_HIGH_DOP", Value = 10 }, // +0.2 + new() { Source = "waits", Key = "RESOURCE_SEMAPHORE", Value = 0.001 }, // TRIVIAL — 0.1%, < 0.10 bar + }; + + new FactScorer().ScoreAll(facts); + + var cx = facts.First(f => f.Key == "CXPACKET"); + Assert.Equal(1.49, cx.Severity, precision: 4); + Assert.True(cx.Severity <= 1.49, "a trivial RESOURCE_SEMAPHORE must NOT release the tuning cap"); + } + + // A SIGNIFICANT RESOURCE_SEMAPHORE (15% of period, >= the 0.10 bar) DOES release the cap. RS does + // not amplify CXPACKET, so the pre-cap 1.7 passes through uncapped — the escape hatch in isolation. + [Fact] + public void Score_CxPacketWithSignificantResourceSemaphore_CapReleased() + { + var facts = new List<Fact> + { + new() { Source = "waits", Key = "CXPACKET", Value = 0.80 }, // base 1.0 + new() { Source = "config", Key = "CONFIG_CTFP", Value = 5 }, // +0.3 + new() { Source = "config", Key = "CONFIG_MAXDOP", Value = 0 }, // +0.2 + new() { Source = "queries", Key = "QUERY_HIGH_DOP", Value = 10 }, // +0.2 + new() { Source = "waits", Key = "RESOURCE_SEMAPHORE", Value = 0.15 }, // SIGNIFICANT — >= 0.10 bar + }; + + new FactScorer().ScoreAll(facts); + + var cx = facts.First(f => f.Key == "CXPACKET"); + // RS does not amplify CXPACKET; the pre-cap 1.7 is released unchanged. + Assert.Equal(1.7, cx.Severity, precision: 4); + Assert.True(cx.Severity >= 1.5, "a significant RESOURCE_SEMAPHORE releases the cap into CRITICAL"); + } + + // THREADPOOL is the impact-bearing escalation path and is NEVER capped. The raised CXPACKET + // amplifier (+0.5) and the new runnable-queue amplifier (+0.5, off the RUNNABLE_TASKS context fact) + // drive a genuine parallelism -> thread-exhaustion meltdown to CRITICAL through THREADPOOL. + [Fact] + public void Score_Threadpool_ReachesCritical_ViaCxPacketAndRunnableQueue() + { + var facts = new List<Fact> + { + new() { Source = "waits", Key = "THREADPOOL", Value = 0.05, // base 1.0 + Metadata = new() { ["wait_time_ms"] = 7_200_000, ["avg_ms_per_wait"] = 3_600 } }, + new() { Source = "waits", Key = "CXPACKET", Value = 0.40 }, // >= 0.10 -> raised amp +0.5 + new() { Source = "cpu", Key = "RUNNABLE_TASKS", Value = 48, // runnable_tasks_warning set -> amp +0.5 + Metadata = new() { ["total_runnable_tasks"] = 48, ["runnable_tasks_warning"] = 1 } }, + }; + + new FactScorer().ScoreAll(facts); + + var tp = facts.First(f => f.Key == "THREADPOOL"); + // 1.0 * (1 + 0.5 CXPACKET + 0.5 runnable-queue) = 2.0, uncapped (THREADPOOL is an impact key). + Assert.Equal(2.0, tp.Severity, precision: 4); + Assert.True(tp.Severity >= 1.5, "THREADPOOL must reach CRITICAL on a real meltdown"); + + var cxAmp = tp.AmplifierResults.First(a => a.Description.Contains("CXPACKET")); + Assert.True(cxAmp.Matched); + Assert.Equal(0.5, cxAmp.Boost); + + var rqAmp = tp.AmplifierResults.First(a => a.Description.Contains("Runnable-task queue")); + Assert.True(rqAmp.Matched); + Assert.Equal(0.5, rqAmp.Boost); + } + + // The runnable-queue amplifier only fires on genuine scheduler pressure: a shallow runnable queue + // (below the trip level) does not boost THREADPOOL. + [Fact] + public void Score_Threadpool_RunnableQueueAmplifier_DoesNotFireBelowThreshold() + { + var facts = new List<Fact> + { + new() { Source = "waits", Key = "THREADPOOL", Value = 0.05, + Metadata = new() { ["wait_time_ms"] = 7_200_000, ["avg_ms_per_wait"] = 3_600 } }, + new() { Source = "cpu", Key = "RUNNABLE_TASKS", Value = 4, // warning clear -> no boost + Metadata = new() { ["total_runnable_tasks"] = 4, ["runnable_tasks_warning"] = 0 } }, + }; + + new FactScorer().ScoreAll(facts); + + var tp = facts.First(f => f.Key == "THREADPOOL"); + var rqAmp = tp.AmplifierResults.First(a => a.Description.Contains("Runnable-task queue")); + Assert.False(rqAmp.Matched); + } + + // ── Tier-2 parity arms (batch 1): runnable-queue / security-cache / QS-off ────────────────── + // All three are additions to the SHARED FactScorer, so they fire identically for Lite, Dashboard, + // and Darling findings. Sourced thresholds are cited per arm against the deprecated Dashboard SQL. + + // ARM 3 — runnable-task-queue depth roots a STANDALONE finding off the collected cpu_scheduler_stats + // snapshot, tiered to the Dashboard's report.cpu_scheduler_pressure CASE (install/47:1839-1841): + // > 50 CRITICAL-tier (base 1.0), > 20 HIGH (0.75), > 10 MEDIUM (0.5 — the 0.5 root entry point). + // Base maxes at 1.0 (top of WARNING) like every base fact; CRITICAL band is reached only via the + // #1494 runnable-queue -> THREADPOOL amplifier, which still fires independently. + [Theory] + [InlineData(60, 1.0)] // > 50 -> CRITICAL tier (install/47:1839) + [InlineData(30, 0.75)] // > 20 -> HIGH tier (install/47:1840) + [InlineData(15, 0.5)] // > 10 -> MEDIUM tier (install/47:1841) + public void Score_RunnableTasks_TiersMatchDashboard(double total, double expected) + { + var facts = new List<Fact> + { + new() { Source = "cpu", Key = "RUNNABLE_TASKS", Value = total, + Metadata = new() { ["total_runnable_tasks"] = total, ["runnable_tasks_warning"] = 1 } }, + }; + + new FactScorer().ScoreAll(facts); + + var rt = facts.First(f => f.Key == "RUNNABLE_TASKS"); + // No amplifiers on RUNNABLE_TASKS and it is not tuning-class, so Severity == base. + Assert.Equal(expected, rt.Severity, precision: 4); + Assert.True(rt.Severity >= 0.5, "a non-NORMAL runnable queue must clear the 0.5 root threshold"); + } + + // A shallow queue below the > 10 bar with the warning flag CLEAR does not score (context-only). + [Fact] + public void Score_RunnableTasks_BelowThreshold_DoesNotScore() + { + var facts = new List<Fact> + { + new() { Source = "cpu", Key = "RUNNABLE_TASKS", Value = 5, + Metadata = new() { ["total_runnable_tasks"] = 5, ["runnable_tasks_warning"] = 0 } }, + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.0, facts.First(f => f.Key == "RUNNABLE_TASKS").Severity, precision: 4); + } + + // Small-box fallback: below the absolute > 10 bar, the collector's runnable_tasks_warning flag + // (SUM(runnable_tasks_count) >= cpu_count) still scores HIGH (install/47:1844). + [Fact] + public void Score_RunnableTasks_WarningFlagOnSmallBox_ScoresHigh() + { + var facts = new List<Fact> + { + new() { Source = "cpu", Key = "RUNNABLE_TASKS", Value = 6, + Metadata = new() { ["total_runnable_tasks"] = 6, ["runnable_tasks_warning"] = 1 } }, + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.75, facts.First(f => f.Key == "RUNNABLE_TASKS").Severity, precision: 4); + } + + // ARM 2 — TokenAndPermUserStore (security cache) growth scores a flat WARNING at >= 1 GB off the + // otherwise context-only MEMORY_CLERKS fact (install/50:562 severity=WARNING, :583 threshold 1 GB). + // The clerk size rides in metadata keyed by clerk_type (= sys.dm_os_memory_clerks.type). + [Fact] + public void Score_MemoryClerks_SecurityCacheOver1Gb_ScoresWarning() + { + var facts = new List<Fact> + { + new() { Source = "memory", Key = "MEMORY_CLERKS", Value = 52_048, + Metadata = new() + { + ["MEMORYCLERK_SQLBUFFERPOOL"] = 50_000, + ["USERSTORE_TOKENPERM"] = 2_048, // 2 GB + ["total_top_clerks_mb"] = 52_048, + ["clerk_count"] = 2 + } }, + }; + + new FactScorer().ScoreAll(facts); + + var mc = facts.First(f => f.Key == "MEMORY_CLERKS"); + Assert.True(mc.Severity >= 0.75 && mc.Severity < 1.5, "security-cache growth bands WARNING"); + } + + // Under 1 GB does not score; MEMORY_CLERKS stays context-only. + [Fact] + public void Score_MemoryClerks_SecurityCacheUnder1Gb_DoesNotScore() + { + var facts = new List<Fact> + { + new() { Source = "memory", Key = "MEMORY_CLERKS", Value = 50_512, + Metadata = new() + { + ["MEMORYCLERK_SQLBUFFERPOOL"] = 50_000, + ["USERSTORE_TOKENPERM"] = 512, // 0.5 GB — below the 1 GB bar + } }, + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.0, facts.First(f => f.Key == "MEMORY_CLERKS").Severity, precision: 4); + } + + // A normal clerk set with no TokenAndPermUserStore entry scores 0 (context-only preserved). + [Fact] + public void Score_MemoryClerks_NoSecurityCacheClerk_DoesNotScore() + { + var facts = new List<Fact> + { + new() { Source = "memory", Key = "MEMORY_CLERKS", Value = 50_000, + Metadata = new() { ["MEMORYCLERK_SQLBUFFERPOOL"] = 50_000, ["clerk_count"] = 1 } }, + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.0, facts.First(f => f.Key == "MEMORY_CLERKS").Severity, precision: 4); + } + + // ── Tier-2 ARM: plan-cache single-use bloat scores off PLAN_CACHE_BLOAT (Value = single_use_percent), + // tiered > 50/30/20% (install/47:1485-1487) behind a single_use_size_mb >= 100 noise guard. ── + [Fact] + public void Score_PlanCacheBloat_Over50Percent_BandsWarningCeiling() + { + var facts = new List<Fact> + { + new() { Source = "memory", Key = "PLAN_CACHE_BLOAT", Value = 62, + Metadata = new() + { + ["single_use_plans"] = 6_200, + ["total_plans"] = 10_000, + ["single_use_size_mb"] = 800, // >= 100 MB — real bloat + ["total_size_mb"] = 1_300, + ["single_use_percent"] = 62 + } }, + }; + + new FactScorer().ScoreAll(facts); + + // > 50% is the Dashboard's CRITICAL tier, but base caps at the WARNING ceiling (1.0); CRITICAL is + // earned only via corroboration. + Assert.Equal(1.0, facts.First(f => f.Key == "PLAN_CACHE_BLOAT").Severity, precision: 4); + } + + [Fact] + public void Score_PlanCacheBloat_Between30And50_ScoresHigh() + { + var facts = new List<Fact> + { + new() { Source = "memory", Key = "PLAN_CACHE_BLOAT", Value = 38, + Metadata = new() { ["single_use_size_mb"] = 400, ["single_use_percent"] = 38 } }, + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.75, facts.First(f => f.Key == "PLAN_CACHE_BLOAT").Severity, precision: 4); + } + + [Fact] + public void Score_PlanCacheBloat_Between20And30_ScoresMedium() + { + var facts = new List<Fact> + { + new() { Source = "memory", Key = "PLAN_CACHE_BLOAT", Value = 24, + Metadata = new() { ["single_use_size_mb"] = 250, ["single_use_percent"] = 24 } }, + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.5, facts.First(f => f.Key == "PLAN_CACHE_BLOAT").Severity, precision: 4); + } + + // Under 20% single-use is healthy plan-cache composition — dormant. + [Fact] + public void Score_PlanCacheBloat_Under20Percent_DoesNotScore() + { + var facts = new List<Fact> + { + new() { Source = "memory", Key = "PLAN_CACHE_BLOAT", Value = 12, + Metadata = new() { ["single_use_size_mb"] = 400, ["single_use_percent"] = 12 } }, + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.0, facts.First(f => f.Key == "PLAN_CACHE_BLOAT").Severity, precision: 4); + } + + // A high single-use % on a tiny footprint (< 100 MB) is noise — the size guard keeps it dormant even + // above the 20% bar. + [Fact] + public void Score_PlanCacheBloat_HighPercentButTinyFootprint_DoesNotScore() + { + var facts = new List<Fact> + { + new() { Source = "memory", Key = "PLAN_CACHE_BLOAT", Value = 70, + Metadata = new() { ["single_use_size_mb"] = 40, ["single_use_percent"] = 70 } }, + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.0, facts.First(f => f.Key == "PLAN_CACHE_BLOAT").Severity, precision: 4); + } + + // ── Tier-2 ARM: ring-buffer physical-memory-pressure scores off MEMORY_PRESSURE_EVENTS (Value = the + // max indicator), banded >= 3 HIGH (0.9) / >= 2 MEDIUM (0.5) per report.memory_pressure_events + // (install/47:229-236). ── + [Fact] + public void Score_MemoryPressureEvents_Indicator3_BandsWarning() + { + var facts = new List<Fact> + { + new() { Source = "memory", Key = "MEMORY_PRESSURE_EVENTS", Value = 3, + Metadata = new() { ["event_count"] = 12, ["max_process_indicator"] = 3, ["max_system_indicator"] = 1 } }, + }; + + new FactScorer().ScoreAll(facts); + + var mp = facts.First(f => f.Key == "MEMORY_PRESSURE_EVENTS"); + Assert.Equal(0.9, mp.Severity, precision: 4); + Assert.True(mp.Severity >= 0.75 && mp.Severity < 1.5, "HIGH pressure bands WARNING"); + } + + [Fact] + public void Score_MemoryPressureEvents_Indicator2_ScoresMedium() + { + var facts = new List<Fact> + { + new() { Source = "memory", Key = "MEMORY_PRESSURE_EVENTS", Value = 2, + Metadata = new() { ["event_count"] = 4, ["max_process_indicator"] = 2, ["max_system_indicator"] = 0 } }, + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.5, facts.First(f => f.Key == "MEMORY_PRESSURE_EVENTS").Severity, precision: 4); + } + + // Indicator 1 (steady / LOW) does not score — a defensive floor (the collector already gates < 2 out). + [Fact] + public void Score_MemoryPressureEvents_Indicator1_DoesNotScore() + { + var facts = new List<Fact> + { + new() { Source = "memory", Key = "MEMORY_PRESSURE_EVENTS", Value = 1, + Metadata = new() { ["event_count"] = 0, ["max_process_indicator"] = 1, ["max_system_indicator"] = 1 } }, + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.0, facts.First(f => f.Key == "MEMORY_PRESSURE_EVENTS").Severity, precision: 4); + } + + // batch-2b — priority boost enabled is a Dashboard WARNING (install/50:368). CONFIG_PRIORITY_BOOST + // bands WARNING (0.9) when value_in_use == 1, and scores 0 when disabled. + [Fact] + public void Score_ConfigPriorityBoost_Enabled_BandsWarning() + { + var facts = new List<Fact> + { + new() { Source = "config", Key = "CONFIG_PRIORITY_BOOST", Value = 1, Metadata = new() { ["value_in_use"] = 1 } }, + }; + new FactScorer().ScoreAll(facts); + var f = facts.First(f => f.Key == "CONFIG_PRIORITY_BOOST"); + Assert.True(f.Severity >= 0.75 && f.Severity < 1.5, "priority boost bands WARNING when enabled"); + } + + [Fact] + public void Score_ConfigPriorityBoost_Disabled_DoesNotScore() + { + var facts = new List<Fact> + { + new() { Source = "config", Key = "CONFIG_PRIORITY_BOOST", Value = 0, Metadata = new() { ["value_in_use"] = 0 } }, + }; + new FactScorer().ScoreAll(facts); + Assert.Equal(0.0, facts.First(f => f.Key == "CONFIG_PRIORITY_BOOST").Severity, precision: 4); + } + + // Lightweight pooling enabled is a Dashboard WARNING (install/50:401) — same shape. + [Fact] + public void Score_ConfigLightweightPooling_Enabled_BandsWarning() + { + var facts = new List<Fact> + { + new() { Source = "config", Key = "CONFIG_LIGHTWEIGHT_POOLING", Value = 1, Metadata = new() { ["value_in_use"] = 1 } }, + }; + new FactScorer().ScoreAll(facts); + var f = facts.First(f => f.Key == "CONFIG_LIGHTWEIGHT_POOLING"); + Assert.True(f.Severity >= 0.75 && f.Severity < 1.5, "lightweight pooling bands WARNING when enabled"); + } + + [Fact] + public void Score_ConfigLightweightPooling_Disabled_DoesNotScore() + { + var facts = new List<Fact> + { + new() { Source = "config", Key = "CONFIG_LIGHTWEIGHT_POOLING", Value = 0, Metadata = new() { ["value_in_use"] = 0 } }, + }; + new FactScorer().ScoreAll(facts); + Assert.Equal(0.0, facts.First(f => f.Key == "CONFIG_LIGHTWEIGHT_POOLING").Severity, precision: 4); + } + + // ARM 1 — Query Store off on a user database is an INFO advisory carried by the DB_CONFIG fact, + // detected as query_store_on_count < database_count (install/50:83 severity=INFO). Low 0.3 base = + // INFO band, and DB_CONFIG roots at any positive severity (a ConfigAdvisoryRootKey). + [Fact] + public void Score_DbConfig_QueryStoreOff_ScoresInfoAdvisory() + { + var facts = new List<Fact> + { + new() { Source = "database_config", Key = "DB_CONFIG", Value = 3, + Metadata = new() { ["database_count"] = 3, ["query_store_on_count"] = 1 } }, + }; + + new FactScorer().ScoreAll(facts); + + var db = facts.First(f => f.Key == "DB_CONFIG"); + Assert.Equal(0.3, db.Severity, precision: 4); + Assert.True(db.Severity > 0 && db.Severity < 0.75, "QS-off is an INFO-band advisory"); + } + + // Every user DB has Query Store on and nothing else drifted -> DB_CONFIG does not score. + [Fact] + public void Score_DbConfig_QueryStoreAllOn_NoContribution() + { + var facts = new List<Fact> + { + new() { Source = "database_config", Key = "DB_CONFIG", Value = 3, + Metadata = new() { ["database_count"] = 3, ["query_store_on_count"] = 3 } }, + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.0, facts.First(f => f.Key == "DB_CONFIG").Severity, precision: 4); + } + + // QS-off never lowers a worse co-present DB_CONFIG issue (existing arms unchanged): AUTO_SHRINK on + // 3 DBs stays 0.9 even with Query Store also off on all of them. + [Fact] + public void Score_DbConfig_QueryStoreOff_DoesNotLowerWorseIssue() + { + var facts = new List<Fact> + { + new() { Source = "database_config", Key = "DB_CONFIG", Value = 3, + Metadata = new() + { + ["database_count"] = 3, + ["query_store_on_count"] = 0, // all off + ["auto_shrink_on_count"] = 3 // worse issue -> 3 * 0.3 = 0.9 + } }, + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.9, facts.First(f => f.Key == "DB_CONFIG").Severity, precision: 4); + } + + // ── Tier-2 parity arms (batch 2): tempdb-deep — version-store size + PAGELATCH_UP allocation ───── + // Both are additions to the SHARED FactScorer, so they fire identically for Lite, Dashboard, and + // Darling findings. Every trip point is cited to the deprecated Dashboard's install SQL. + + // ARM 1 — tempdb VERSION-STORE pressure by ABSOLUTE size (max_version_store_mb in the TEMPDB_USAGE + // fact metadata), scored as the WORSE of space-fraction and version-store size. Tiers mirror + // report.tempdb_pressure's pressure_level CASE (install/47:1431-1433): > 5000 MB CRITICAL-tier + // (base 1.0), > 2000 MB HIGH (0.75), > 1000 MB MEDIUM (0.5 — the 0.5 root entry point). Space is only + // 20% full, whose sub-0.5 fraction score is dominated by the version-store arm via Math.Max, so the + // asserted severity IS the version-store tier — proving the arm scores independently of fill. + [Theory] + [InlineData(6000, 1.0)] // > 5000 -> CRITICAL tier (install/47:1431) + [InlineData(3000, 0.75)] // > 2000 -> HIGH tier (install/47:1432) + [InlineData(1500, 0.5)] // > 1000 -> MEDIUM tier (install/47:1433) + public void Score_TempDbVersionStore_TiersMatchDashboard(double versionMb, double expected) + { + var facts = new List<Fact> + { + new() { Source = "tempdb", Key = "TEMPDB_USAGE", Value = 0.20, // space only 20% full + Metadata = new() { ["max_version_store_mb"] = versionMb } }, + }; + + new FactScorer().ScoreAll(facts); + + var t = facts.First(f => f.Key == "TEMPDB_USAGE"); + Assert.Equal(expected, t.Severity, precision: 4); + Assert.True(t.Severity >= 0.5, "a version store over 1 GB must clear the 0.5 root threshold"); + } + + // A version store below the > 1000 MB bar does not score. Space fraction 0 isolates the version-store + // arm (the space-fraction arm yields a small sub-0.5 base for any positive fill), proving a 900 MB + // store trips nothing on its own. + [Fact] + public void Score_TempDbVersionStore_BelowThreshold_DoesNotScore() + { + var facts = new List<Fact> + { + new() { Source = "tempdb", Key = "TEMPDB_USAGE", Value = 0.0, + Metadata = new() { ["max_version_store_mb"] = 900 } }, // 900 MB — below the 1 GB bar + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.0, facts.First(f => f.Key == "TEMPDB_USAGE").Severity, precision: 4); + } + + // WORSE-of: the version-store arm never LOWERS a higher space-fraction score. Space 95% full (the + // fraction arm hits its 0.90 critical -> 1.0) with a small version store stays 1.0. + [Fact] + public void Score_TempDbVersionStore_DoesNotLowerWorseSpaceFraction() + { + var facts = new List<Fact> + { + new() { Source = "tempdb", Key = "TEMPDB_USAGE", Value = 0.95, + Metadata = new() { ["max_version_store_mb"] = 200 } }, // small VS, but space is critical + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(1.0, facts.First(f => f.Key == "TEMPDB_USAGE").Severity, precision: 4); + } + + // ARM 2 — tempdb allocation / PFS-GAM-SGAM contention scored off the PAGELATCH_UP wait fact by + // ABSOLUTE wait_time_ms (server-wide wait_stats, the SAME data the source view reads), tripping at + // > 10000 ms -> MEDIUM (install/47:2515: pagelatch_up_ms > 10000 -> "MEDIUM - PAGELATCH_UP + // contention"). Flat 0.5 — the view has no higher PAGELATCH_UP band. Value (fraction-of-period) only + // has to be > 0 to clear the wait guard; the absolute wait_time_ms is what scores. + [Fact] + public void Score_PageLatchUp_Over10Sec_ScoresMedium() + { + var facts = new List<Fact> + { + new() { Source = "waits", Key = "PAGELATCH_UP", Value = 0.01, + Metadata = new() { ["wait_time_ms"] = 15_000, ["waiting_tasks_count"] = 800 } }, + }; + + new FactScorer().ScoreAll(facts); + + var p = facts.First(f => f.Key == "PAGELATCH_UP"); + Assert.Equal(0.5, p.Severity, precision: 4); + Assert.True(p.Severity >= 0.5, "PAGELATCH_UP over the 10s bar must clear the 0.5 root threshold"); + } + + // At/under the 10000 ms bar PAGELATCH_UP stays context-only (a little allocation latching is normal). + [Fact] + public void Score_PageLatchUp_Under10Sec_DoesNotScore() + { + var facts = new List<Fact> + { + new() { Source = "waits", Key = "PAGELATCH_UP", Value = 0.01, + Metadata = new() { ["wait_time_ms"] = 8_000, ["waiting_tasks_count"] = 300 } }, + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.0, facts.First(f => f.Key == "PAGELATCH_UP").Severity, precision: 4); + } + + // Confidence-hardening: the low-quality fallback floors at 0.5 AFTER the confidence multiply, so a + // fired thin-baseline anomaly stays >= InferenceEngine's 0.5 root entry-point even if confidence + // ever drops below 1.0 (it is a hardcoded 1.0 today; this guards the future). Same shared scorer. + [Theory] + [InlineData(1.0, 0.8, 0.5)] // at the bar, conf 0.8: 0.5*0.8 = 0.4 -> floored to 0.5 + [InlineData(1.5, 0.6, 0.5)] // 0.75*0.6 = 0.45 -> floored to 0.5 + [InlineData(2.0, 0.6, 0.6)] // 1.0*0.6 = 0.6 -> above the floor, kept + [InlineData(2.0, 1.0, 1.0)] // conf 1.0 unchanged + public void Score_LowQualityFallback_FloorsAtHalf_EvenWithSubUnitConfidence( + double exceedance, double confidence, double expected) + { + var fact = new Fact + { + Source = "anomaly", + Key = "ANOMALY_CPU_SPIKE", + Metadata = new() + { + ["deviation_sigma"] = 0.5, + ["baseline_low_quality"] = 1.0, + ["fallback_exceedance"] = exceedance, + ["confidence"] = confidence + } + }; + new FactScorer().ScoreAll(new List<Fact> { fact }); + Assert.Equal(expected, fact.BaseSeverity, precision: 4); + } + /* ── Regression: duplicate fact keys must not crash scoring ── */ // A duplicated fact key (e.g. a config collector returning two rows for the same setting) diff --git a/Dashboard.Tests/FailedJobsQueryTests.cs b/Dashboard.Tests/FailedJobsQueryTests.cs new file mode 100644 index 000000000..83bce2442 --- /dev/null +++ b/Dashboard.Tests/FailedJobsQueryTests.cs @@ -0,0 +1,193 @@ +/* + * Performance Monitor Dashboard + * Copyright (c) 2026 Darling Data, LLC + * Licensed under the MIT License - see LICENSE file for details + */ + +using System; +using System.Collections; +using System.Data.Common; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using PerformanceMonitor.Alerting; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// <summary> +/// Identity pins for the Phase-5 slice-E extraction: the live msdb failed-jobs alert query moved +/// verbatim from this app's <c>DatabaseService.NocHealth.cs</c> (whitespace-normalized identical +/// to Lite's <c>RemoteCollectorService.RunningJobs.cs</c> twin) into the shared +/// <see cref="FailedJobsQuery"/>. The clause pins are derived from the pre-extraction SQL, so a +/// drift in the shared copy fails a pin; the ReadAsync pins reproduce the pre-extraction row +/// mapping including its null handling (for this app that includes the explicit +/// InvariantCulture step_id conversion the shared copy adopted). +/// </summary> +public class FailedJobsQueryTests +{ + /* ---------------- the SQL contract ---------------- */ + + [Fact] + public void Sql_ContainsLoadBearingClauses() + { + var sql = FailedJobsQuery.Sql; + + /* Dirty read — never block a monitored server's Agent from an alert check. */ + Assert.Contains("SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;", sql); + + /* The sources: job history joined to job names. */ + Assert.Contains("FROM msdb.dbo.sysjobhistory AS jh", sql); + Assert.Contains("JOIN msdb.dbo.sysjobs AS j", sql); + + /* Bounded row count, newest failures first. */ + Assert.Contains("SELECT TOP (50)", sql); + Assert.Contains("run_datetime DESC", sql); + Assert.Contains("OPTION(RECOMPILE);", sql); + + /* The outcome-row filter: step_id = 0 is the per-run outcome row, run_status = 0 = FAILED. */ + Assert.Contains("WHERE jh.step_id = 0", sql); + Assert.Contains("AND jh.run_status = 0", sql); + + /* The failing-step resolution: OUTER APPLY correlates the actual failed step (step_id > 0, + run_status = 0) from THIS run via instance_id, bounded after the previous outcome row. */ + Assert.Contains("OUTER APPLY", sql); + Assert.Contains("AND s.step_id > 0", sql); + Assert.Contains("AND s.run_status = 0", sql); + Assert.Contains("AND s.instance_id < jh.instance_id", sql); + Assert.Contains("AND p.step_id = 0", sql); + + /* The outcome-row fallback when a job-level failure has no failed step row. */ + Assert.Contains("step_id = ISNULL(fs.step_id, jh.step_id)", sql); + Assert.Contains("step_name = ISNULL(fs.step_name, jh.step_name)", sql); + Assert.Contains("message = ISNULL(fs.message, jh.message)", sql); + } + + [Fact] + public void Sql_LookbackParameter_AppearsAtBothFilterSites() + { + Assert.Equal("@lookback_minutes", FailedJobsQuery.LookbackMinutesParameter); + + /* The lookback binds twice: the coarse run_date integer filter and the exact + server-local run_datetime filter. Both must survive — dropping the coarse one + makes the query scan all of sysjobhistory. */ + Assert.Equal(2, Regex.Matches(FailedJobsQuery.Sql, Regex.Escape(FailedJobsQuery.LookbackMinutesParameter)).Count); + Assert.Contains("jh.run_date >= CONVERT(integer, CONVERT(varchar(8), DATEADD(MINUTE, -@lookback_minutes, GETDATE()), 112))", FailedJobsQuery.Sql); + Assert.Contains(") >= DATEADD(MINUTE, -@lookback_minutes, GETDATE())", FailedJobsQuery.Sql); + } + + /* ---------------- the row mapping ---------------- */ + + [Fact] + public async Task ReadAsync_MapsRepresentativeRow() + { + var runTime = new DateTime(2026, 6, 30, 23, 45, 12); + var reader = new FakeFailedJobsDataReader(new object[] + { + "Nightly ETL", + "3f2504e0-4f89-11d3-9a0c-0305e82c3301", + runTime, + 3, + "Load fact table", + "Executed as user: NT SERVICE\\SQLSERVERAGENT. The step failed." + }); + + var items = await FailedJobsQuery.ReadAsync(reader, CancellationToken.None); + + var job = Assert.Single(items); + Assert.Equal("Nightly ETL", job.JobName); + Assert.Equal("3f2504e0-4f89-11d3-9a0c-0305e82c3301", job.JobId); + Assert.Equal(runTime, job.RunDateTime); + Assert.Equal(3, job.StepId); + Assert.Equal("Load fact table", job.StepName); + Assert.Equal("Executed as user: NT SERVICE\\SQLSERVERAGENT. The step failed.", job.Message); + } + + [Fact] + public async Task ReadAsync_NullColumns_MapToEmptyStringAndZero() + { + /* job_name is never null (sysjobs.name); every other column degrades like the + pre-extraction mapping: "" for strings, 0 for step_id. */ + var reader = new FakeFailedJobsDataReader(new object[] + { + "Nightly ETL", + DBNull.Value, + new DateTime(2026, 6, 30, 23, 45, 12), + DBNull.Value, + DBNull.Value, + DBNull.Value + }); + + var items = await FailedJobsQuery.ReadAsync(reader, CancellationToken.None); + + var job = Assert.Single(items); + Assert.Equal("", job.JobId); + Assert.Equal(0, job.StepId); + Assert.Equal("", job.StepName); + Assert.Equal("", job.Message); + } + + [Fact] + public async Task ReadAsync_EmptyReader_ReturnsEmptyList() + { + var items = await FailedJobsQuery.ReadAsync(new FakeFailedJobsDataReader(), CancellationToken.None); + Assert.Empty(items); + } + + /// <summary> + /// Minimal in-memory DbDataReader for the ReadAsync pins (single result set) — the Dashboard + /// twin of Lite.Tests' <c>CollectorDefinitionTestFakes.FakeCollectorDataReader</c>. + /// </summary> + private sealed class FakeFailedJobsDataReader : DbDataReader + { + private readonly object[][] _rows; + private int _rowIndex = -1; + + public FakeFailedJobsDataReader(params object[][] rows) => _rows = rows; + + public override bool Read() => ++_rowIndex < _rows.Length; + + public override bool NextResult() => false; + + public override string GetString(int ordinal) => (string)_rows[_rowIndex][ordinal]; + + public override DateTime GetDateTime(int ordinal) => (DateTime)_rows[_rowIndex][ordinal]; + + public override bool IsDBNull(int ordinal) => _rows[_rowIndex][ordinal] is DBNull; + + public override object GetValue(int ordinal) => _rows[_rowIndex][ordinal]; + + public override int FieldCount => _rows.Length == 0 ? 0 : _rows[0].Length; + + public override bool HasRows => _rows.Length > 0; + + public override bool IsClosed => false; + + public override int Depth => 0; + + public override int RecordsAffected => -1; + + public override object this[int ordinal] => _rows[_rowIndex][ordinal]; + + public override object this[string name] => throw new NotSupportedException(); + + public override bool GetBoolean(int ordinal) => (bool)_rows[_rowIndex][ordinal]; + public override byte GetByte(int ordinal) => (byte)_rows[_rowIndex][ordinal]; + public override long GetBytes(int ordinal, long dataOffset, byte[]? buffer, int bufferOffset, int length) => throw new NotSupportedException(); + public override char GetChar(int ordinal) => (char)_rows[_rowIndex][ordinal]; + public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int bufferOffset, int length) => throw new NotSupportedException(); + public override string GetDataTypeName(int ordinal) => throw new NotSupportedException(); + public override decimal GetDecimal(int ordinal) => (decimal)_rows[_rowIndex][ordinal]; + public override double GetDouble(int ordinal) => (double)_rows[_rowIndex][ordinal]; + public override IEnumerator GetEnumerator() => _rows.GetEnumerator(); + public override Type GetFieldType(int ordinal) => _rows[_rowIndex][ordinal].GetType(); + public override float GetFloat(int ordinal) => (float)_rows[_rowIndex][ordinal]; + public override Guid GetGuid(int ordinal) => (Guid)_rows[_rowIndex][ordinal]; + public override short GetInt16(int ordinal) => (short)_rows[_rowIndex][ordinal]; + public override int GetInt32(int ordinal) => (int)_rows[_rowIndex][ordinal]; + public override long GetInt64(int ordinal) => (long)_rows[_rowIndex][ordinal]; + public override string GetName(int ordinal) => throw new NotSupportedException(); + public override int GetOrdinal(string name) => throw new NotSupportedException(); + public override int GetValues(object[] values) => throw new NotSupportedException(); + } +} diff --git a/Dashboard.Tests/FinOpsInventoryQueryTests.cs b/Dashboard.Tests/FinOpsInventoryQueryTests.cs new file mode 100644 index 000000000..98f7a5d7e --- /dev/null +++ b/Dashboard.Tests/FinOpsInventoryQueryTests.cs @@ -0,0 +1,41 @@ +using System; +using PerformanceMonitorDashboard.Services; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// <summary> +/// #1535 regression guard (Dashboard mirror of Lite's FinOpsInventoryQueryTests): the FinOps live +/// server-inventory query must read edition/version/storage from permission-free scalars and dynamic +/// SQL only — never <c>sys.dm_os_sys_info</c> (VIEW DATABASE STATE on Azure SQL DB), and never a +/// hard-coded <c>sys.master_files</c> (absent on Azure SQL DB) without an engine-edition branch. The +/// hardware read that genuinely needs the DMV stays isolated in its own best-effort query so a +/// permission gap loses only CPU/memory/socket facts, not the whole inventory row. +/// </summary> +public class FinOpsInventoryQueryTests +{ + [Fact] + public void InventoryQuery_DoesNotDependOnDmOsSysInfo() + { + Assert.DoesNotContain("dm_os_sys_info", DatabaseService.InventoryQueryText, StringComparison.Ordinal); + } + + [Fact] + public void HardwareQuery_IsolatesTheDmvRead() + { + Assert.Contains("sys.dm_os_sys_info", DatabaseService.HardwareQueryText, StringComparison.Ordinal); + // The hardware-only columns must ride the isolated read, not the inventory query. + Assert.Contains("cpu_count", DatabaseService.HardwareQueryText, StringComparison.Ordinal); + Assert.Contains("sqlserver_start_time", DatabaseService.HardwareQueryText, StringComparison.Ordinal); + } + + [Fact] + public void InventoryQuery_StillDetectsEditionAndStaysAzureSafeForStorage() + { + // Edition/version classification survives on permission-free scalars. + Assert.Contains("SERVERPROPERTY('EngineEdition')", DatabaseService.InventoryQueryText, StringComparison.Ordinal); + // Storage no longer hard-codes sys.master_files (absent on Azure SQL DB); the engine-edition + // branch reads sys.database_files there. + Assert.Contains("sys.database_files", DatabaseService.InventoryQueryText, StringComparison.Ordinal); + } +} diff --git a/Dashboard.Tests/InferenceEngineTests.cs b/Dashboard.Tests/InferenceEngineTests.cs index f817de6a8..794ff887f 100644 --- a/Dashboard.Tests/InferenceEngineTests.cs +++ b/Dashboard.Tests/InferenceEngineTests.cs @@ -344,4 +344,67 @@ public void ClusterIntoIncidents_NormalizesThreadpoolRelabel_AndBridgesToBlockin // THREADPOOL->LCK bridge (LCK severity >= 0.5) merges it with the blocking story. Assert.Single(engine.ClusterIntoIncidents(stories, facts)); } + + // ── correlate-and-focus: DB-aware incident clustering (Stage B change 3, option i) ── + + // A CPU_SQL_PERCENT->PLAN_REGRESSION edge fires, but the two stories name DIFFERENT databases — + // the DB-aware union refuses to merge them, so a db1 problem and a db2 problem stay two incidents + // (was one cross-database incident before the fix). + [Fact] + public void ClusterIntoIncidents_DifferentDatabases_DoNotMerge() + { + var engine = new InferenceEngine(new RelationshipGraph()); + var stories = new List<AnalysisStory> + { + new() { RootFactKey = "CPU_SQL_PERCENT", Severity = 1.6, Path = ["CPU_SQL_PERCENT"], DatabaseName = "db1" }, + new() { RootFactKey = "PLAN_REGRESSION", Severity = 0.6, Path = ["PLAN_REGRESSION"], DatabaseName = "db2" }, + }; + var facts = new List<Fact> + { + new() { Key = "CPU_SQL_PERCENT", Source = "cpu", Value = 90, Severity = 1.6 }, + new() { Key = "PLAN_REGRESSION", Source = "queries", Value = 1, Severity = 0.6, BaseSeverity = 0.6 }, + }; + + Assert.Equal(2, engine.ClusterIntoIncidents(stories, facts).Count); + } + + // The same active edge, but the two stories name the SAME database -> still one incident. Guards + // against over-fragmentation: DB-awareness must not split a genuine same-database incident. + [Fact] + public void ClusterIntoIncidents_SameDatabase_StillMerge() + { + var engine = new InferenceEngine(new RelationshipGraph()); + var stories = new List<AnalysisStory> + { + new() { RootFactKey = "CPU_SQL_PERCENT", Severity = 1.6, Path = ["CPU_SQL_PERCENT"], DatabaseName = "db1" }, + new() { RootFactKey = "PLAN_REGRESSION", Severity = 0.6, Path = ["PLAN_REGRESSION"], DatabaseName = "db1" }, + }; + var facts = new List<Fact> + { + new() { Key = "CPU_SQL_PERCENT", Source = "cpu", Value = 90, Severity = 1.6 }, + new() { Key = "PLAN_REGRESSION", Source = "queries", Value = 1, Severity = 0.6, BaseSeverity = 0.6 }, + }; + + Assert.Single(engine.ClusterIntoIncidents(stories, facts)); + } + + // A server-scoped story (no DatabaseName) still bridges freely to a db-scoped one, so a server-wide + // symptom (CPU) correlates with a db-scoped cause (a regressed plan on db1) -> one incident. + [Fact] + public void ClusterIntoIncidents_ServerScopedStory_BridgesToDbScoped() + { + var engine = new InferenceEngine(new RelationshipGraph()); + var stories = new List<AnalysisStory> + { + new() { RootFactKey = "CPU_SQL_PERCENT", Severity = 1.6, Path = ["CPU_SQL_PERCENT"], DatabaseName = null }, + new() { RootFactKey = "PLAN_REGRESSION", Severity = 0.6, Path = ["PLAN_REGRESSION"], DatabaseName = "db1" }, + }; + var facts = new List<Fact> + { + new() { Key = "CPU_SQL_PERCENT", Source = "cpu", Value = 90, Severity = 1.6 }, + new() { Key = "PLAN_REGRESSION", Source = "queries", Value = 1, Severity = 0.6, BaseSeverity = 0.6 }, + }; + + Assert.Single(engine.ClusterIntoIncidents(stories, facts)); + } } diff --git a/Dashboard.Tests/JsonAlertHistoryStoreMutedFilterTests.cs b/Dashboard.Tests/JsonAlertHistoryStoreMutedFilterTests.cs index fe5c8f285..36c322acb 100644 --- a/Dashboard.Tests/JsonAlertHistoryStoreMutedFilterTests.cs +++ b/Dashboard.Tests/JsonAlertHistoryStoreMutedFilterTests.cs @@ -98,7 +98,7 @@ public async Task GetAlertHistory_ExcludeResolved_DropsResolutionRows_FiltersBef // Cleared/Resolved, so those "Restored" rows would have slipped into the badge count. await RecordAsync(store, srv, "Deadlocks Detected", muted: false); await Task.Delay(10, TestContext.Current.CancellationToken); - foreach (var resolved in new[] { "Blocking Cleared", "CPU Resolved", "Capture Restored", "TempDB Space Resolved", "Server Restored" }) + foreach (var resolved in new[] { "Blocking Cleared", "CPU Resolved", "Capture Restored", "tempdb Space Resolved", "Server Restored" }) await RecordAsync(store, srv, resolved, muted: false); // Default includes resolution rows for audit (history grid / MCP); a small limit fills with them. diff --git a/Dashboard.Tests/ServerManagerDetectionQueryTests.cs b/Dashboard.Tests/ServerManagerDetectionQueryTests.cs new file mode 100644 index 000000000..2a32528db --- /dev/null +++ b/Dashboard.Tests/ServerManagerDetectionQueryTests.cs @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using PerformanceMonitorDashboard.Services; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// <summary> +/// #1535 regression guard (Full Dashboard): the connectivity probe's edition-detection query must +/// read engine edition from permission-free scalars ONLY, never from <c>sys.dm_os_sys_info</c>. The +/// Dashboard rejects Azure SQL DB (EngineEdition 5) as unsupported; before the split, an Azure login +/// lacking VIEW DATABASE STATE threw the combined query, so the edition never registered and the +/// server was mis-handled instead of cleanly rejected with the "use the Lite edition" message. This +/// pins the decoupling; <c>sqlserver_start_time</c> (the one column that needs the DMV) lives in its +/// own best-effort read. +/// </summary> +public class ServerManagerDetectionQueryTests +{ + [Fact] + public void DetectionQuery_DoesNotDependOnDmOsSysInfo() + { + Assert.DoesNotContain("dm_os_sys_info", ServerManager.DetectionQueryText, StringComparison.Ordinal); + Assert.DoesNotContain("sqlserver_start_time", ServerManager.DetectionQueryText, StringComparison.Ordinal); + } + + [Fact] + public void DetectionQuery_DetectsEngineEditionViaServerProperty() + { + Assert.Contains("SERVERPROPERTY('EngineEdition')", ServerManager.DetectionQueryText, StringComparison.Ordinal); + } + + [Fact] + public void StartTimeQuery_IsolatesTheOnlyReadThatNeedsDmOsSysInfo() + { + Assert.Contains("sqlserver_start_time", ServerManager.ServerStartTimeQueryText, StringComparison.Ordinal); + Assert.Contains("sys.dm_os_sys_info", ServerManager.ServerStartTimeQueryText, StringComparison.Ordinal); + } +} diff --git a/Dashboard.Tests/SqlServerFindingStoreMappingTests.cs b/Dashboard.Tests/SqlServerFindingStoreMappingTests.cs new file mode 100644 index 000000000..1479f8e77 --- /dev/null +++ b/Dashboard.Tests/SqlServerFindingStoreMappingTests.cs @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using PerformanceMonitor.Analysis; +using PerformanceMonitorDashboard.Analysis; +using Xunit; + +namespace Dashboard.Tests; + +/// <summary> +/// Pins the story→finding mapping the Dashboard finding store persists — extracted (and made +/// testable) after the Darling PgFindingStore port surfaced that this mapping dropped +/// <c>DatabaseName</c>, storing NULL database_name on every finding since the store shipped. +/// </summary> +public sealed class SqlServerFindingStoreMappingTests +{ + [Fact] + public void MapStoryToFinding_PreservesDatabaseName_AndConvertsWindowToUtc() + { + var story = new AnalysisStory + { + DatabaseName = "StackOverflow2010", + Severity = 70, + Confidence = 0.9, + Category = "Concurrency", + StoryPath = "BLOCKING_STORM", + StoryPathHash = "abc123", + IncidentId = "inc-1", + StoryText = "story", + RootFactKey = "BLOCKING", + RootFactValue = 42, + FactCount = 3, + }; + var context = new AnalysisContext + { + ServerId = 7, + ServerName = "SQL2022", + TimeRangeStart = new DateTime(2026, 7, 2, 10, 0, 0), + TimeRangeEnd = new DateTime(2026, 7, 2, 14, 0, 0), + ServerUtcOffset = TimeSpan.FromHours(-4), + }; + var analysisTime = new DateTime(2026, 7, 2, 14, 5, 0, DateTimeKind.Utc); + + var finding = SqlServerFindingStore.MapStoryToFinding(story, context, analysisTime, 99); + + Assert.Equal("StackOverflow2010", finding.DatabaseName); /* the fixed drop */ + Assert.Equal(99, finding.FindingId); + Assert.Equal(7, finding.ServerId); + Assert.Equal("SQL2022", finding.ServerName); + /* Server-local window − offset = UTC (offset here is −4h, so UTC = local + 4h). */ + Assert.Equal(new DateTime(2026, 7, 2, 14, 0, 0), finding.TimeRangeStart); + Assert.Equal(new DateTime(2026, 7, 2, 18, 0, 0), finding.TimeRangeEnd); + Assert.Equal(70, finding.Severity); + Assert.Equal("BLOCKING_STORM", finding.StoryPath); + Assert.Equal("inc-1", finding.IncidentId); + Assert.Equal(3, finding.FactCount); + } + + [Fact] + public void GetMutedHashesSql_HonorsGlobalNull_AndLegacyZeroServerId() + { + /* An all-servers mute persists server_id = NULL (the canonical global marker); legacy rows + written by the pre-fix MCP "all servers" tool path used server_id = 0. The reader must honor + both, plus the per-server id, so an all-servers mute actually mutes everywhere. */ + Assert.Contains("server_id = @serverId", SqlServerFindingStore.GetMutedHashesSql, StringComparison.Ordinal); + Assert.Contains("server_id IS NULL", SqlServerFindingStore.GetMutedHashesSql, StringComparison.Ordinal); + Assert.Contains("server_id = 0", SqlServerFindingStore.GetMutedHashesSql, StringComparison.Ordinal); + } + + [Fact] + public void CleanupOldFindingsSql_PurgesByAnalysisTime_FromFindingsTable() + { + /* Findings retention (scheduled once per 24h by AnalysisScheduler — the store declared this + cleanup but nothing called it, so config.analysis_findings grew unbounded). Pin the purge + target + predicate: it must DELETE from the findings table, keyed on analysis_time against + the @cutoff parameter — never the muted registry. */ + Assert.Contains("DELETE FROM config.analysis_findings", SqlServerFindingStore.CleanupOldFindingsSql, StringComparison.Ordinal); + Assert.Contains("analysis_time < @cutoff", SqlServerFindingStore.CleanupOldFindingsSql, StringComparison.Ordinal); + Assert.DoesNotContain("analysis_muted", SqlServerFindingStore.CleanupOldFindingsSql, StringComparison.Ordinal); + } +} diff --git a/Dashboard.Tests/ThemeParityTests.cs b/Dashboard.Tests/ThemeParityTests.cs new file mode 100644 index 000000000..026b7992d --- /dev/null +++ b/Dashboard.Tests/ThemeParityTests.cs @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// <summary> +/// Cross-app theme-parity guard. The WPF brush/color dictionaries are NOT shared: Dashboard and Lite +/// each ship their own Themes/{Dark,Light,CoolBreeze}Theme.xaml — six separate files, hand-synced with +/// no enforcement. Their shared palette keys currently match, but nothing keeps them in sync, so a color +/// tweak applied to one app can silently drift the two apart. +/// +/// For each theme this parses the x:Key -> color map from both apps' same-theme file and asserts that +/// every key present in BOTH (the intersection) resolves to the same color. Keys that exist in only one +/// app (e.g. Dashboard's TimeRangeSlicer-only brushes) are allowed and ignored — only the overlap is +/// asserted, so either app may add app-specific resources without tripping the guard. +/// </summary> +public class ThemeParityTests +{ + private static readonly XNamespace X = "http://schemas.microsoft.com/winfx/2006/xaml"; + + [Theory] + [InlineData("Dark")] + [InlineData("Light")] + [InlineData("CoolBreeze")] + public void SharedThemeKeys_HaveIdenticalColors_AcrossApps(string theme) + { + var repoRoot = FindRepoRoot(); + Assert.True(repoRoot is not null, + "Could not locate the repo root (a directory containing both Dashboard\\Themes and Lite\\Themes) " + + $"by walking up from {AppContext.BaseDirectory}."); + + var dashboardFile = Path.Combine(repoRoot!, "Dashboard", "Themes", $"{theme}Theme.xaml"); + var liteFile = Path.Combine(repoRoot!, "Lite", "Themes", $"{theme}Theme.xaml"); + Assert.True(File.Exists(dashboardFile), $"Dashboard theme file not found: {dashboardFile}"); + Assert.True(File.Exists(liteFile), $"Lite theme file not found: {liteFile}"); + + var dashboard = LoadColorMap(dashboardFile); + var lite = LoadColorMap(liteFile); + + var shared = dashboard.Keys + .Intersect(lite.Keys) + .OrderBy(k => k, StringComparer.Ordinal) + .ToList(); + + // Guard against a parser regression (e.g. a XAML namespace change) silently turning this into a + // vacuous pass: the two apps share dozens of palette keys today, so an empty intersection is a bug. + Assert.True(shared.Count > 0, + $"No shared color/brush keys parsed for the {theme} theme — the parser or the theme files changed shape."); + + var mismatches = shared + .Where(k => !string.Equals(dashboard[k], lite[k], StringComparison.Ordinal)) + .Select(k => $" {k}: Dashboard={dashboard[k]} Lite={lite[k]}") + .ToList(); + + Assert.True(mismatches.Count == 0, + $"{theme} theme: {mismatches.Count} shared color/brush key(s) differ between Dashboard\\Themes\\{theme}Theme.xaml " + + $"and Lite\\Themes\\{theme}Theme.xaml. Re-sync the two palettes:\n" + string.Join("\n", mismatches)); + } + + /// <summary> + /// Parses a theme ResourceDictionary into a map of x:Key -> normalized #AARRGGBB color. Handles the two + /// resource forms used in these files: standalone <Color x:Key="...">#hex</Color> and + /// <SolidColorBrush x:Key="..." Color="#hex | {StaticResource SomeColor}"/>, resolving a brush's + /// {StaticResource} reference back to the underlying <Color> hex. Entries whose color cannot be + /// resolved to a concrete value (e.g. a reference to a non-Color resource) are omitted from the map. + /// First definition wins, so the top-of-file palette is preferred over any template-local re-key. + /// </summary> + private static Dictionary<string, string> LoadColorMap(string xamlPath) + { + var doc = XDocument.Load(xamlPath); + + // Pass 1: literal <Color> resources. + var colors = new Dictionary<string, string>(StringComparer.Ordinal); + foreach (var el in doc.Descendants().Where(e => e.Name.LocalName == "Color")) + { + var key = (string?)el.Attribute(X + "Key"); + if (key is null || colors.ContainsKey(key)) + { + continue; + } + colors[key] = el.Value.Trim(); + } + + // Pass 2: <SolidColorBrush> resources (Color is an unprefixed attribute, so no namespace). + var brushes = new Dictionary<string, string>(StringComparer.Ordinal); + foreach (var el in doc.Descendants().Where(e => e.Name.LocalName == "SolidColorBrush")) + { + var key = (string?)el.Attribute(X + "Key"); + var color = (string?)el.Attribute("Color"); + if (key is null || color is null || brushes.ContainsKey(key)) + { + continue; + } + brushes[key] = color.Trim(); + } + + var map = new Dictionary<string, string>(StringComparer.Ordinal); + foreach (var pair in colors) + { + var norm = NormalizeColor(pair.Value); + if (norm is not null) + { + map[pair.Key] = norm; + } + } + foreach (var pair in brushes) + { + var resolved = ResolveColor(pair.Value, colors); + var norm = resolved is null ? null : NormalizeColor(resolved); + if (norm is not null) + { + map[pair.Key] = norm; + } + } + return map; + } + + private static readonly Regex StaticResourceRef = + new(@"^\{StaticResource\s+([^}]+)\}$", RegexOptions.Compiled); + + /// <summary> + /// Resolves a brush Color value to a literal color token, following one or more {StaticResource X} hops + /// into the <Color> table. Returns null when the chain dead-ends at a key that is not a <Color>. + /// </summary> + private static string? ResolveColor(string value, Dictionary<string, string> colors, int depth = 0) + { + if (depth > 10) + { + return null; + } + + var trimmed = value.Trim(); + var match = StaticResourceRef.Match(trimmed); + if (!match.Success) + { + return trimmed; + } + + var refKey = match.Groups[1].Value.Trim(); + return colors.TryGetValue(refKey, out var inner) + ? ResolveColor(inner, colors, depth + 1) + : null; + } + + /// <summary> + /// Normalizes a color token to canonical upper-case #AARRGGBB so equivalent spellings (#RGB, #ARGB, + /// #RRGGBB, #AARRGGBB — WPF treats a missing alpha as fully opaque) compare equal. Non-hex named colors + /// (e.g. White, Transparent) are upper-cased as-is so they still compare. Returns null for empty input. + /// </summary> + private static string? NormalizeColor(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return null; + } + + var c = raw.Trim(); + var match = Regex.Match(c, "^#([0-9A-Fa-f]+)$"); + if (!match.Success) + { + return c.ToUpperInvariant(); + } + + var h = match.Groups[1].Value.ToUpperInvariant(); + h = h.Length switch + { + 3 => "FF" + string.Concat(h.Select(ch => $"{ch}{ch}")), // #RGB -> #FFRRGGBB + 4 => string.Concat(h.Select(ch => $"{ch}{ch}")), // #ARGB -> #AARRGGBB + 6 => "FF" + h, // #RRGGBB -> #FFRRGGBB + _ => h, + }; + + // Anything that did not land on a clean 8-digit ARGB (e.g. a malformed 5/7-digit token) falls back + // to the upper-cased original so it still compares deterministically rather than being dropped. + return h.Length == 8 ? "#" + h : c.ToUpperInvariant(); + } + + /// <summary> + /// Walks up from the test output directory to the repo root — the directory that contains both + /// Dashboard\Themes and Lite\Themes. Mirrors Installer.Tests' FindInstallDirectory walk-up. + /// </summary> + private static string? FindRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + for (int i = 0; i < 10 && dir is not null; i++) + { + if (Directory.Exists(Path.Combine(dir.FullName, "Dashboard", "Themes")) && + Directory.Exists(Path.Combine(dir.FullName, "Lite", "Themes"))) + { + return dir.FullName; + } + dir = dir.Parent; + } + return null; + } +} diff --git a/Dashboard/AboutWindow.xaml.cs b/Dashboard/AboutWindow.xaml.cs index 7cdc6c9c4..b77f845a7 100644 --- a/Dashboard/AboutWindow.xaml.cs +++ b/Dashboard/AboutWindow.xaml.cs @@ -18,7 +18,6 @@ public partial class AboutWindow : Window { private const string GitHubUrl = "https://github.com/erikdarlingdata/PerformanceMonitor"; private const string IssuesUrl = "https://github.com/erikdarlingdata/PerformanceMonitor/issues"; - private const string ReleasesUrl = "https://github.com/erikdarlingdata/PerformanceMonitor/releases"; private const string DarlingDataUrl = "https://www.erikdarling.com"; private string? _updateReleaseUrl; diff --git a/Dashboard/AddServerDialog.xaml b/Dashboard/AddServerDialog.xaml index 3f4a8c358..84f74c462 100644 --- a/Dashboard/AddServerDialog.xaml +++ b/Dashboard/AddServerDialog.xaml @@ -34,6 +34,7 @@ <TextBlock Text="Server Name/Address:" FontWeight="Bold" Margin="0,0,0,4" Foreground="{DynamicResource ForegroundBrush}"/> <TextBox x:Name="ServerNameTextBox" Margin="0,0,0,12" + TextChanged="ServerNameTextBox_TextChanged" ToolTip="e.g., localhost, server.domain.com, 192.168.1.100, server\instance"/> <!-- Display Name --> @@ -59,6 +60,16 @@ Foreground="{DynamicResource ForegroundBrush}" Checked="AuthType_Changed" ToolTip="Interactive authentication with MFA for Azure SQL Database."/> + <RadioButton x:Name="ServicePrincipalAuthRadio" Content="Azure — Service Principal" + GroupName="Auth" + Foreground="{DynamicResource ForegroundBrush}" + Checked="AuthType_Changed" Margin="0,4,0,0" + ToolTip="Non-interactive Azure SQL auth using an Entra app registration (client id + secret)."/> + <RadioButton x:Name="ManagedIdentityAuthRadio" Content="Azure — Managed Identity" + GroupName="Auth" + Foreground="{DynamicResource ForegroundBrush}" + Checked="AuthType_Changed" Margin="0,4,0,0" + ToolTip="Non-interactive Azure SQL auth using the managed identity of the Azure resource running the Dashboard."/> </StackPanel> <!-- SQL Authentication Fields --> @@ -80,6 +91,31 @@ ToolTip="Optional: Pre-populate the authentication dialog with this email address"/> </StackPanel> + <!-- Azure Service Principal Fields --> + <StackPanel x:Name="ServicePrincipalPanel" Visibility="Collapsed" Margin="0,0,0,12"> + <TextBlock Text="Application (Client) ID:" Margin="0,0,0,4" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBox x:Name="ServicePrincipalClientIdBox" Margin="0,0,0,8" + ToolTip="The Application (client) ID of the Entra app registration / service principal."/> + <TextBlock Text="Client Secret:" Margin="0,0,0,4" + Foreground="{DynamicResource ForegroundBrush}"/> + <PasswordBox x:Name="ServicePrincipalSecretBox" Margin="0,0,0,4"/> + <TextBlock TextWrapping="Wrap" FontSize="11" + Foreground="{DynamicResource ForegroundMutedBrush}" + Text="The client secret is stored securely in Windows Credential Manager, never in servers.json."/> + </StackPanel> + + <!-- Azure Managed Identity Fields --> + <StackPanel x:Name="ManagedIdentityPanel" Visibility="Collapsed" Margin="0,0,0,12"> + <TextBlock Text="User-Assigned Identity Client ID (optional):" Margin="0,0,0,4" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBox x:Name="ManagedIdentityClientIdBox" Margin="0,0,0,4" + ToolTip="Leave blank for the system-assigned identity. Set to a client id to use a specific user-assigned identity."/> + <TextBlock TextWrapping="Wrap" FontSize="11" + Foreground="{DynamicResource ForegroundMutedBrush}" + Text="Leave blank for the system-assigned identity. Managed Identity only works when the Dashboard runs on an Azure resource that has a managed identity assigned."/> + </StackPanel> + <!-- Connection Options --> <TextBlock Text="Connection Options:" FontWeight="Bold" Margin="0,0,0,4" Foreground="{DynamicResource ForegroundBrush}"/> @@ -183,6 +219,12 @@ <StackPanel Margin="16,8,0,0"> <CheckBox x:Name="CleanInstallCheckBox" Content="Perform clean install (drops existing database)" + Checked="CleanInstallCheckBox_Checked" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <CheckBox x:Name="RepairCheckBox" + Content="Repair: reinstall objects, skip upgrade scripts" + Checked="RepairCheckBox_Checked" + ToolTip="Use when an upgrade fails because a table or object is missing or damaged. Reinstalls the schema objects without running migrations and without dropping the database. The pending upgrade still needs to run afterwards." Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> <CheckBox x:Name="ResetScheduleCheckBox" Content="Reset collection schedule to defaults" diff --git a/Dashboard/AddServerDialog.xaml.cs b/Dashboard/AddServerDialog.xaml.cs index 0f17a0b4a..b02e34cb8 100644 --- a/Dashboard/AddServerDialog.xaml.cs +++ b/Dashboard/AddServerDialog.xaml.cs @@ -9,14 +9,19 @@ using System; using System.Diagnostics; using System.IO; +using System.Linq; using System.Reflection; using System.Threading; +using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using System.Windows.Input; using Installer.Core; using Installer.Core.Models; using Microsoft.Data.SqlClient; +// The state machine and its pure predicates live in Installer.Core (ServerSetupState / ServerSetupPolicy) +// so CI can test them; the dialog is not CI-wired. Aliased so the flow reads as "DialogState" in situ. +using DialogState = Installer.Core.ServerSetupState; using PerformanceMonitorDashboard.Helpers; using PerformanceMonitorDashboard.Models; using PerformanceMonitorDashboard.Services; @@ -27,16 +32,33 @@ namespace PerformanceMonitorDashboard { public partial class AddServerDialog : Window { - private enum DialogState - { - Initial, - Connected_NoDatabase, - Connected_NeedsUpgrade, - Connected_Current, - Installing, - InstallComplete, - MonitoringCredentials - } + /* + Non-null when installing would be unsafe, and why. Four causes, all of which would otherwise act + on a database whose real state we do not know: + - discovery threw (a transient error is indistinguishable from "no database"), + - the installed version is NEWER than this Dashboard (installing would downgrade it), + - the installed version will not parse at all, + - THIS BUILD's own version will not parse (a fresh install would write it to the ledger). + Guards the install path structurally rather than relying on the button being hidden. + */ + private string? _installBlockedReason; + + /* + The block's REAL reason, as it applies to the stamped server -- kept so a retarget can substitute the + generic notice and a retarget BACK can put the truth in again. Without it, typing a character into + the server box and deleting it replaced "SQL Server 2014 is not supported" with "the server name + changed" permanently, and only Test Connection could ever get the real answer back. + */ + private string? _serverBlockReason; + + /* + Said in one voice from every place that can withdraw something: the demotion in TransitionToState, + the post-run claims, and a real block whose server is no longer in the box. It was three + hand-copied strings that had already drifted apart. + */ + private const string StaleServerNotice = + "The server name changed after this server's status was checked, so what is on screen may " + + "describe a different server.\n\nClick Test Connection to check the server now in the box."; public ServerConnection ServerConnection { get; private set; } public string? Username { get; private set; } @@ -51,10 +73,30 @@ private enum DialogState private DialogState _currentState = DialogState.Initial; private string? _serverVersion; + /* + Cancel the install on ANY close. Cancel_Click covers the Cancel button and ESC (IsCancel), but the + title-bar X and Alt+F4 bypass it entirely -- without this the install keeps running against the + database, history write included, with the dialog already destroyed and nothing left to stop it. + */ + private void CancelInstallOnClose(object? sender, System.ComponentModel.CancelEventArgs e) + { + _dialogClosed = true; + _installCts?.Cancel(); + } + + /* + Setting DialogResult on a window that has already closed throws. A save in flight is the way in: + press ESC (or Cancel) while its connection test is running -- which is a plain 10-second wait, and + unbounded for Entra MFA -- and the close lands first, then the test completes and SaveAsync walks on + into DialogResult = true. The re-entrancy flag does not cover this: it is one Save, not two. + */ + private bool _dialogClosed; + public AddServerDialog() { InitializeComponent(); SizeToWorkArea(); + Closing += CancelInstallOnClose; _isEditMode = false; ServerConnection = new ServerConnection(); Title = "Add SQL Server"; @@ -64,6 +106,7 @@ public AddServerDialog(ServerConnection existingServer) { InitializeComponent(); SizeToWorkArea(); + Closing += CancelInstallOnClose; _isEditMode = true; ServerConnection = existingServer; Title = "Edit SQL Server"; @@ -114,6 +157,32 @@ public AddServerDialog(ServerConnection existingServer) PasswordBox.Password = cred.Value.Password; } } + else if (existingServer.AuthenticationType == AuthenticationTypes.ServicePrincipal) + { + ServicePrincipalAuthRadio.IsChecked = true; + ServicePrincipalClientIdBox.Text = existingServer.AzureClientId ?? string.Empty; + + // Pre-fill the client id and the client secret from Credential Manager, matching how SQL + // Server auth pre-fills its password above (and how Lite pre-fills the SP secret). The + // model's AzureClientId takes precedence for the client id, falling back to the stored + // credential username. The user can leave the secret untouched to keep it, or overwrite to + // rotate it — the save path re-persists whatever is in the box. + var credentialService = new CredentialService(); + var cred = credentialService.GetCredential(existingServer.Id); + if (cred.HasValue) + { + if (string.IsNullOrEmpty(ServicePrincipalClientIdBox.Text) && !string.IsNullOrEmpty(cred.Value.Username)) + { + ServicePrincipalClientIdBox.Text = cred.Value.Username; + } + ServicePrincipalSecretBox.Password = cred.Value.Password; + } + } + else if (existingServer.AuthenticationType == AuthenticationTypes.ManagedIdentity) + { + ManagedIdentityAuthRadio.IsChecked = true; + ManagedIdentityClientIdBox.Text = existingServer.ManagedIdentityClientId ?? string.Empty; + } else { WindowsAuthRadio.IsChecked = true; @@ -130,7 +199,8 @@ private void SizeToWorkArea() private void AuthType_Changed(object sender, RoutedEventArgs e) { - if (SqlAuthPanel != null && EntraMfaPanel != null) + if (SqlAuthPanel != null && EntraMfaPanel != null && + ServicePrincipalPanel != null && ManagedIdentityPanel != null) { SqlAuthPanel.Visibility = SqlAuthRadio.IsChecked == true ? System.Windows.Visibility.Visible @@ -139,6 +209,14 @@ private void AuthType_Changed(object sender, RoutedEventArgs e) EntraMfaPanel.Visibility = EntraMfaAuthRadio.IsChecked == true ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed; + + ServicePrincipalPanel.Visibility = ServicePrincipalAuthRadio.IsChecked == true + ? System.Windows.Visibility.Visible + : System.Windows.Visibility.Collapsed; + + ManagedIdentityPanel.Visibility = ManagedIdentityAuthRadio.IsChecked == true + ? System.Windows.Visibility.Visible + : System.Windows.Visibility.Collapsed; } } @@ -185,24 +263,46 @@ private SqlConnectionStringBuilder BuildConnectionBuilder() MultiSubnetFailover = MultiSubnetFailoverCheckBox.IsChecked == true }; + // Resolve auth type + per-mode credentials from the live controls, then apply via the SHARED + // helper so this Test-Connection builder can never diverge from ServerConnection's production + // builder (the two-bodies trap). See ServerConnection.ApplyAuthentication. + string authType; + string? userId = null; + string? secret = null; + string? managedIdentityClientId = null; + if (WindowsAuthRadio.IsChecked == true) { - builder.IntegratedSecurity = true; + authType = AuthenticationTypes.Windows; } else if (SqlAuthRadio.IsChecked == true) { - builder.IntegratedSecurity = false; - builder.UserID = UsernameTextBox.Text.Trim(); - builder.Password = PasswordBox.Password; + authType = AuthenticationTypes.SqlServer; + userId = UsernameTextBox.Text.Trim(); + secret = PasswordBox.Password; } else if (EntraMfaAuthRadio.IsChecked == true) { - builder.IntegratedSecurity = false; - builder.Authentication = SqlAuthenticationMethod.ActiveDirectoryInteractive; - var mfaUsername = EntraMfaUsernameBox.Text.Trim(); - if (!string.IsNullOrEmpty(mfaUsername)) - builder.UserID = mfaUsername; + authType = AuthenticationTypes.EntraMFA; + userId = EntraMfaUsernameBox.Text.Trim(); } + else if (ServicePrincipalAuthRadio.IsChecked == true) + { + authType = AuthenticationTypes.ServicePrincipal; + userId = ServicePrincipalClientIdBox.Text.Trim(); + secret = ServicePrincipalSecretBox.Password; + } + else if (ManagedIdentityAuthRadio.IsChecked == true) + { + authType = AuthenticationTypes.ManagedIdentity; + managedIdentityClientId = ManagedIdentityClientIdBox.Text.Trim(); + } + else + { + authType = AuthenticationTypes.Windows; + } + + ServerConnection.ApplyAuthentication(builder, authType, userId, secret, azureClientId: null, managedIdentityClientId); return builder; } @@ -214,6 +314,9 @@ private string BuildInstallerConnectionString() bool useEntraAuth = EntraMfaAuthRadio.IsChecked == true; string? username = null; string? password = null; + string? authenticationType = null; + string? azureClientId = null; + string? managedIdentityClientId = null; if (SqlAuthRadio.IsChecked == true) { @@ -224,6 +327,18 @@ private string BuildInstallerConnectionString() { username = EntraMfaUsernameBox.Text.Trim(); } + else if (ServicePrincipalAuthRadio.IsChecked == true) + { + authenticationType = AuthenticationTypes.ServicePrincipal; + azureClientId = ServicePrincipalClientIdBox.Text.Trim(); + username = azureClientId; + password = ServicePrincipalSecretBox.Password; + } + else if (ManagedIdentityAuthRadio.IsChecked == true) + { + authenticationType = AuthenticationTypes.ManagedIdentity; + managedIdentityClientId = ManagedIdentityClientIdBox.Text.Trim(); + } return InstallationService.BuildConnectionString( server, @@ -232,7 +347,10 @@ private string BuildInstallerConnectionString() password, GetSelectedEncryptMode(), TrustServerCertificateCheckBox.IsChecked == true, - useEntraAuth); + useEntraAuth, + authenticationType, + azureClientId, + managedIdentityClientId); } private static string GetAppVersion() @@ -247,32 +365,44 @@ private static string GetAppVersion() } var version = assembly.GetName().Version; - if (version != null) + + /* + An assembly with no AssemblyVersion reports 0.0.0.0 -- which IS the unknown sentinel. Falling + through to it would write a parseable "0.0.0" into a server's ledger, and every later read + would then take that server's real version as "unknown" and replay every migration. So a + version-less build must land on something UNPARSEABLE, which InstallGuard blocks + (UnreadableBuildVersion). Checked here, not one branch later, because this branch is the one + that can produce it. The CLI's Main has the identical guard, for the identical reason -- both + surfaces write to the same ledger, so the hole had to be closed on both. + */ + if (version != null && (version.Major | version.Minor | version.Build) != 0) { /* Normalize 4-part to 3-part: "2.4.1.0" -> "2.4.1" */ return $"{version.Major}.{version.Minor}.{version.Build}"; } - return "0.0.0"; + return "Unknown"; } /// <summary> /// Normalize a version string to 3-part for comparison (e.g., "2.4.1.0" -> "2.4.1"). /// </summary> - private static string NormalizeVersion(string version) - { - if (Version.TryParse(version, out var parsed)) - { - return $"{parsed.Major}.{parsed.Minor}.{parsed.Build}"; - } - return version; - } + /* + Reduce a version to its three-part numeric core, stripping any SemVer build (+sha) or + pre-release (-rc1) suffix. Without the strip, a "3.2.0-rc1" InformationalVersion fails to + parse, and the install/upgrade routing below then treats EVERY server as up to date. The + Build clamp matters for the same reason: TryParse("3.1") succeeds with Build == -1, which + would otherwise format as "3.1.-1" and fail to re-parse. Matches ScriptProvider.ParseVersionCore. + */ + private static string NormalizeVersion(string version) => + ScriptProvider.TryParseVersionCore(version)?.ToString() ?? version; private bool ValidateInputs() { if (string.IsNullOrWhiteSpace(ServerNameTextBox.Text)) { MessageBox.Show( + this, "Please enter a server name or address.", "Validation Error", MessageBoxButton.OK, @@ -284,6 +414,7 @@ private bool ValidateInputs() if (SqlAuthRadio.IsChecked == true && string.IsNullOrWhiteSpace(UsernameTextBox.Text)) { MessageBox.Show( + this, "Please enter a username for SQL Server authentication.", "Validation Error", MessageBoxButton.OK, @@ -292,13 +423,68 @@ private bool ValidateInputs() return false; } + if (ServicePrincipalAuthRadio.IsChecked == true) + { + if (string.IsNullOrWhiteSpace(ServicePrincipalClientIdBox.Text)) + { + MessageBox.Show( + this, + "Please enter the Application (Client) ID for service principal authentication.", + "Validation Error", + MessageBoxButton.OK, + MessageBoxImage.Warning + ); + return false; + } + + if (string.IsNullOrEmpty(ServicePrincipalSecretBox.Password)) + { + MessageBox.Show( + this, + "Please enter the client secret for service principal authentication.", + "Validation Error", + MessageBoxButton.OK, + MessageBoxImage.Warning + ); + return false; + } + } + return true; } - private async System.Threading.Tasks.Task<(bool Connected, string? ErrorMessage, bool MfaCancelled, string? ServerVersion)> RunConnectionTestAsync(Button triggerButton) + private async System.Threading.Tasks.Task<(bool Connected, string? ErrorMessage, bool MfaCancelled)> RunConnectionTestAsync(Button triggerButton) { + /* + Disable BOTH entry points, not just the one that was clicked. Test Connection stayed live during + a SAVE's test, so two of these could overlap -- and then the second one borrowed the FIRST one's + "Testing connection..." label and dutifully restored it at the end, leaving that message on + screen forever with nothing running behind it. (It is also what re-armed Save mid-save and gave + the double-DialogResult its way in; _saveInProgress catches that, but two concurrent connection + tests are nonsense in the first place.) One test at a time; the finally re-arms both. + */ triggerButton.IsEnabled = false; SaveButton.IsEnabled = false; + TestConnectionButton.IsEnabled = false; + CheckForUpdatesButton.IsEnabled = false; + + /* + StatusText may already be carrying something the user still needs -- most importantly the + "the server name changed, what is on screen may describe a different server" notice, which is + the ONLY thing on screen after a retarget withdraws the post-run panels. Blanking it in the + finally below left a dialog with no panels and no explanation whenever the test then failed. + Borrow it, and give it back. + + Epoch-stamped, because the borrow spans the awaits and the give-back happens after them -- the + same read-before / write-after shape this whole branch exists to kill, and giving it back + unconditionally resurrected a notice that had already stopped being true. (Retype away, click + Test Connection, retype BACK during the connect: the screen correctly restores itself, then the + failed test's finally pastes the stale "server name changed" notice back over it.) If anything + bumped the epoch while we were away, the label is no longer ours to restore. + */ + int statusEpoch = _detectEpoch; + string priorStatus = StatusText.Text; + var priorStatusVisibility = StatusText.Visibility; StatusText.Text = EntraMfaAuthRadio.IsChecked == true ? "Testing connection — please complete authentication in the popup window..." @@ -308,7 +494,6 @@ private bool ValidateInputs() bool connected = false; string? errorMessage = null; bool mfaCancelled = false; - string? serverVersion = null; try { /* Connect to master (not PerformanceMonitor) so the test succeeds @@ -318,9 +503,16 @@ happens after the connection test in DetectDatabaseStatusAsync() */ builder.InitialCatalog = "master"; await using var connection = new SqlConnection(builder.ConnectionString); await connection.OpenAsync(); + + /* + Kept as a round-trip liveness check -- Open() alone can be satisfied from the pool -- but the + version it returns is deliberately DISCARDED. It used to be handed back and assigned straight + to _serverVersion, which is one of the four facts that may only be committed together, by + PublishProbedServer. DetectDatabaseStatusAsync re-probes and publishes them as a set. + */ using var cmd = new SqlCommand("SELECT @@VERSION", connection); - var version = await cmd.ExecuteScalarAsync() as string; - serverVersion = version?.Split('\n')[0]?.Trim(); + _ = await cmd.ExecuteScalarAsync(); + connected = true; } catch (Exception ex) @@ -332,13 +524,28 @@ happens after the connection test in DetectDatabaseStatusAsync() */ } finally { - triggerButton.IsEnabled = true; - SaveButton.IsEnabled = true; - StatusText.Text = string.Empty; - StatusText.Visibility = System.Windows.Visibility.Collapsed; + /* Do not re-arm over a running install: SetFormEnabled(false) disabled these deliberately. */ + if (!InstallInProgress) + { + triggerButton.IsEnabled = true; + SaveButton.IsEnabled = true; + TestConnectionButton.IsEnabled = true; + CheckForUpdatesButton.IsEnabled = true; + + /* + Give back what we borrowed -- but only if it is still ours. A retype or a newer detection + during the await means the label has moved on, and restoring it would be pasting a stale + notice over a fresher one. On success the detection repaints it anyway. + */ + if (statusEpoch == _detectEpoch) + { + StatusText.Text = priorStatus; + StatusText.Visibility = priorStatusVisibility; + } + } } - return (connected, errorMessage, mfaCancelled, serverVersion); + return (connected, errorMessage, mfaCancelled); } private async void CheckForUpdates_Click(object sender, RoutedEventArgs e) @@ -354,8 +561,12 @@ private async void CheckForUpdates_Click(object sender, RoutedEventArgs e) } finally { - CheckForUpdatesButton.IsEnabled = true; CheckForUpdatesButton.Content = "Check for Updates"; + /* Do not re-arm over a running install: SetFormEnabled(false) disabled this deliberately. */ + if (!InstallInProgress) + { + CheckForUpdatesButton.IsEnabled = true; + } } } @@ -363,18 +574,42 @@ private async void TestConnection_Click(object sender, RoutedEventArgs e) { if (!ValidateInputs()) return; - var (connected, errorMessage, mfaCancelled, serverVersion) = await RunConnectionTestAsync(TestConnectionButton); + /* + The server this test is actually about, captured with the connection string built from it. The + box is editable for the whole ~10s test (unbounded on Entra MFA), and the failure message below + used to re-read it AFTER the await -- so retyping during the connect produced "Could not connect + to A" about a server that is fine, for a failure that belonged to B. The last read-the-box-after- + the-await in this file. + */ + string testedServerName = ServerNameTextBox.Text.Trim(); + + var (connected, errorMessage, mfaCancelled) = await RunConnectionTestAsync(TestConnectionButton); + + /* + The dialog was closed while this test ran. Round 19 swept exactly this out of SaveAsync and left + the same shape sitting in its two siblings: without it, the failure and MFA-cancel boxes below + pop up with no dialog behind them -- one of them helpfully telling the user to "Click Test to try + again" on a window that no longer exists -- and the success path runs a full two-round-trip + detection against a destroyed one. + */ + if (_dialogClosed) return; if (connected) { - _serverVersion = serverVersion; - - /* Show connection + database status inline instead of a popup */ + /* + Deliberately does NOT write _serverVersion. It is one of the four facts that may only ever be + committed together, by PublishProbedServer -- and this write was the last place that set one + of them on its own, post-await, with no stamp beside it. The box is editable during that + await, so it could stamp the PREVIOUS server's engine version into place while the verdict + and the state still described someone else. DetectDatabaseStatusAsync re-probes and publishes + them as a set, which is where it belongs. + */ await DetectDatabaseStatusAsync(); } else if (mfaCancelled) { MessageBox.Show( + this, "Authentication was cancelled. Click Test to try again.", "Authentication Cancelled", MessageBoxButton.OK, @@ -385,7 +620,8 @@ private async void TestConnection_Click(object sender, RoutedEventArgs e) { var detail = errorMessage != null ? $"\n\nError: {errorMessage}" : string.Empty; MessageBox.Show( - $"Could not connect to {ServerNameTextBox.Text}.{detail}\n\nPlease check:\n" + + this, + $"Could not connect to {testedServerName}.{detail}\n\nPlease check:\n" + "• Server name/address is correct\n" + "• Server is accessible from this machine\n" + "• Firewall allows SQL Server connections\n" + @@ -398,80 +634,791 @@ private async void TestConnection_Click(object sender, RoutedEventArgs e) } } + /* + True once an install has started. Detection runs are async and can still be in flight when the + user starts an install, and their continuation would otherwise transition back to a Connected_* + state -- un-collapsing the panel over a RUNNING install, re-showing the install button, and + re-enabling Advanced Options. From there a second click starts a concurrent install (and a + newly-reachable "Clean install" tick drops the database out from under the first one). + Disabling the buttons is not enough: the detection paths re-enable them in their finally blocks. + */ + private bool InstallInProgress => ServerSetupPolicy.IsInstalling(_currentState); + + /* + Two detections can be in flight at once (Test Connection re-arms its button in a finally before + the detection it triggered has finished, and Check for Updates disables only its own). They then + race to write _installedVersion / _coreServerInfo / _currentState, and a late-landing one renders + the FIRST server's verdict under the SECOND server's name. Each run takes an epoch; a superseded + continuation discards itself instead of publishing a verdict nobody asked for. + */ + private int _detectEpoch; + + /* + TWO different things, deliberately two fields. Conflating them is what let a button reading + "Upgrade Now" perform a full fresh install on a retargeted bare server: + + _launchState -- where the click came FROM, i.e. what the button PROMISED. Fixed at click + time and never re-derived. Only meaningful for "did the user ask us to act + on an existing installation?". + _preInstallState -- where a failure should RESTORE to, i.e. what the FACTS say. Always a + Connected_* state, and refreshed from the install-time re-read, because the + server box stays editable and the facts may belong to a different server. + + One field served both, was consumed for the promise BEFORE being re-derived for the restore, and + so answered the promise question with a value that could be InstallComplete -- exactly the state + the repair handoff puts a live "Upgrade Now" button in. + */ + private DialogState _launchState = DialogState.Initial; + + /* Null until THIS server's version has actually been read. See RestoreAfterInstall. */ + private DialogState? _preInstallState; + + /* + Which of the two reasons nulled _preInstallState: a clean install throwing the verdict away on + purpose, or the run ending before the version was ever read. They get different words, and reset + per run -- a flag that outlives its run is how the repair handoff ended up describing an upgrade + that had already been applied. + */ + private bool _verdictDiscardedByCleanInstall; + + /* + THE structural guard. Every critical found in rounds 6-10 was one bug wearing different clothes: + the server box stays editable, so _installedVersion / _coreServerInfo / _currentState can all + describe a server that is no longer the one in the box -- and some consumer renders or acts on + them anyway. Nine rounds of patching each consumer in turn left the next one uncovered. + + So the verdict is stamped with the server it was read FROM, and TransitionToState refuses to + render a Connected_* verdict whose stamp no longer matches the box. One check, one place, + structurally uncheatable: a stale verdict cannot be displayed, whoever forgets to re-check. + */ + private string? _verdictServerName; + + private void RecordVerdictFor(string serverName) => _verdictServerName = serverName.Trim(); + + /* + The probed server's three facts are committed TOGETHER, and only where a verdict about it is + actually published -- never in the prologue, and never before a guard that can still abandon the + publish. + + They have to move as one because the stamp is what every consumer tests before rendering the other + two. Hoisting the stamp to the top of the probe (so that a BLOCK would be attributed) looked + harmless -- "re-stamped on the success path with the same value" -- but it is only re-stamped if + that path is REACHED. A detection superseded at the version read returned having already moved the + stamp and the engine version to the new server, while _installedVersion and _currentState still + described the old one. VerdictMatchesServerBox() then answered TRUE, so the guard did not merely + miss the lie, it AFFIRMED it: "PerformanceMonitor v3.1.0 is up to date", with Save enabled, over a + server that had no PerformanceMonitor database at all. + + A half-replaced set of facts is the one thing the guard cannot catch. Stale-but-consistent it + catches every time. + + _installedVersion is in here for that reason and no other. It is the FOURTH fact, and leaving it + behind at the two block sites -- where we either never read it (unsupported version) or could not + (the read threw) -- left them describing the PREVIOUS server. That is sealed today only because a + block lands in Connected_StatusUnknown, which happens to collapse both buttons: a rendering + accident standing in for an invariant, which is the exact thing this file has been bitten by over + and over. A block passes null, because null is the truth there: we do not know. + */ + private void PublishProbedServer( + ServerInfo info, + string serverVersion, + string serverName, + string? installedVersion) + { + _coreServerInfo = info; + _serverVersion = serverVersion; + _installedVersion = installedVersion; + RecordVerdictFor(serverName); + } + + /* + The previous run's install log is discarded WITH the verdict that replaces it, never ahead of it. + + Clearing it in the detection's prologue looked equivalent and was not: a probe that publishes NOTHING + -- superseded, or the not-connected branch that deliberately refuses to move the four facts -- left + the screen still in InstallComplete with the panel visible, now showing a blank log, an empty status + line and a 0% bar. On a FAILED install that destroys the only on-screen copy of the SQL error text, + and nothing repopulates it: RenderPostRunClaims only shows and hides the panel. + + Same rule as _installBlockedReason, for the same reason: state that describes a verdict is committed + with the verdict. Called only from the detection -- the install path clears the log itself, at the + top of the run, and must not have it wiped from under the run it is recording. + */ + private void DiscardPreviousRunLog() + { + InstallLogTextBox.Clear(); + InstallStatusText.Text = string.Empty; + InstallProgressBar.Value = 0; + } + + /* + The repair handoff's version claim, kept so the handoff can be RE-RENDERED rather than painted + once. Painting it once made withdrawing it one-way: a retarget correctly hid the claim and the + "Upgrade Now" button, but typing the server name back -- or typing one character and deleting it -- + never brought them back, so the only button that applies the pending upgrade was gone for good + while the install log still said to click it. Repair writes no history row, so the upgrade was + still offered on the next dialog open; nothing on screen said so. + */ + private string? _handoffStatusText; + + /* + The verdict state a stale-name demotion came FROM, so retyping the name back can undo it. Set only + by that demotion and cleared by every other transition, so a REAL block -- unsupported SQL version, + unreadable version, database newer than the binary -- can never be typed away. + */ + private DialogState? _demotedFromState; + + /* + Idempotent, and safe to call on every keystroke. No-ops unless a repair actually left an upgrade + pending -- for an ordinary install, upgrade or reinstall there is no handoff to render. The reason + the panels went away is stated once, by RenderPostRunClaims; this just shows or hides them. + MonitoringCredsPanel is a separate grid row and is deliberately untouched: the SQL-auth user still + needs it either way. + */ + private void RenderRepairHandoff(bool addressed) + { + if (_handoffStatusText == null) return; + + if (!addressed) + { + DatabaseStatusPanel.Visibility = Visibility.Collapsed; + InstallUpgradeButton.Visibility = Visibility.Collapsed; + return; + } + + ConnectionInfoText.Text = $"Connected to {_verdictServerName}"; + DatabaseStatusText.Text = _handoffStatusText; + DatabaseStatusPanel.Visibility = Visibility.Visible; + InstallUpgradeButton.Content = "Upgrade Now"; + InstallUpgradeButton.Visibility = Visibility.Visible; + + /* + That Border also holds the "Skip, just add server" link, which would otherwise become clickable + for the first time in MonitoringCredentials -- an abandon-the-upgrade action sitting inches from + the Upgrade Now button we just put there. + */ + SkipInstallText.Visibility = Visibility.Collapsed; + } + + /* + Editing the server name invalidates everything on screen: the verdict, the connection info, and any + detection still in flight all describe the server that WAS in the box. Bumping the epoch makes an + in-flight detection discard itself instead of publishing a verdict for the old server under the new + name, and re-entering the current state lets the stamp guard demote a now-stale verdict to + "re-check me". Without this the box could be retyped mid-detect and the guard would never notice. + */ + private void ServerNameTextBox_TextChanged(object sender, TextChangedEventArgs e) + { + if (InstallInProgress) return; + + /* + Any detection in flight was probing the PREVIOUS name -- discard it. It painted "Checking + database status..." before it awaited, and its Superseded() early-outs return without clearing + that, so the label would sit there indefinitely announcing a check nobody is running any more. + */ + _detectEpoch++; + StatusText.Text = string.Empty; + StatusText.Visibility = Visibility.Collapsed; + + /* + The completion states are not verdict states, so the guard below never sees them -- but they + assert plenty about a specific server: "Installation completed successfully!", the install log, + and (after a repair) a live "Upgrade Now" with a version claim. All of it describes the server + the run went to. Withdraw it when the box no longer names that server, and RESTORE it when the + box names it again: typing one character and deleting it must not permanently destroy the only + button that applies a pending upgrade, nor permanently hide the result of an install that did + happen. + */ + if (_currentState is DialogState.InstallComplete or DialogState.MonitoringCredentials) + { + RenderPostRunClaims(); + return; + } + + if (_currentState == DialogState.Connected_StatusUnknown) + { + /* + Connected_StatusUnknown is where a demotion LANDS, so the guard in TransitionToState -- which + only demotes -- can never cover it. It is also where a real block sits, and a real block is a + fact about a specific server: "SQL Server 2014 is not supported" is not true of the instance + now in the box, and we have not looked at it. + */ + if (VerdictMatchesServerBox()) + { + /* + Undo a stale-name demotion when the name comes back. The verdict was never WRONG, only + mis-addressed. _demotedFromState is set ONLY by that demotion, so a real block -- an + unsupported version, an unreadable version, a database newer than the binary -- cannot be + typed away here. + */ + if (_demotedFromState != null) + { + DialogState restored = _demotedFromState.Value; + _installBlockedReason = null; + _serverBlockReason = null; + TransitionToState(restored); + return; + } + + /* + A real block, about the server now back in the box. Put its ACTUAL reason back. A retype + and a retype-back had swapped it for the generic notice below and never swapped it + return -- so "SQL Server 2014 is not supported" became "the server name changed", which by + then was not even true, and only Test Connection could get the real answer back. + */ + if (_serverBlockReason != null && _installBlockedReason != _serverBlockReason) + { + _installBlockedReason = _serverBlockReason; + DatabaseStatusText.Text = _serverBlockReason; + } + + return; + } + + /* + The box names someone else. A demotion's message already says exactly that, and its + _demotedFromState must survive so typing the name back can still undo it -- so only a REAL + block needs restating, and it must lose the previous server's specifics. + + _verdictServerName != null is what makes this correct rather than merely plausible. A clean + install deliberately forgets the stamp (it is about to drop the database), so + VerdictMatchesServerBox() is false for the rest of that dialog's life -- and without this + test, the FIRST keystroke in the box, including one that changes nothing, replaced "a clean + install was requested, which drops the database" with "the server name changed after this + server's status was checked". That is the one sentence telling the user their database is + gone, and it was being overwritten with a sentence that is not even true. No stamp means + nothing to compare the box against, so there is nothing to restate. + */ + if (_demotedFromState == null && + _installBlockedReason != null && + _verdictServerName != null) + { + _installBlockedReason = StaleServerNotice; + DatabaseStatusText.Text = StaleServerNotice; + } + + return; + } + + if (ServerSetupPolicy.IsServerVerdictState(_currentState) && !VerdictMatchesServerBox()) + { + /* Re-entering the state lets the stamp guard demote it. */ + TransitionToState(_currentState); + } + } + + /* + Everything a completed run leaves on screen describes the server the run actually went to: the + install log and its "Installation completed successfully!" headline, the View Report link, and after + a repair the "Upgrade Now" handoff with its version claim. Retyping the box left all of it standing + under the new name -- so a user could retarget after an install and click Save believing + PerformanceMonitor had been installed on the server now in the box. + + Only the repair handoff was withdrawn before, because that was the path the previous round happened + to be looking at; the ordinary install, upgrade and reinstall are the COMMON case and had no + withdrawal at all. Withdraw all of it together, restore all of it together, and say once why it went + away -- idempotent, so it is safe to call on every keystroke. + */ + private void RenderPostRunClaims() + { + bool addressed = VerdictMatchesServerBox(); + + InstallationPanel.Visibility = addressed ? Visibility.Visible : Visibility.Collapsed; + + if (_reportPath != null) + { + ViewReportButton.Visibility = addressed ? Visibility.Visible : Visibility.Collapsed; + } + + RenderRepairHandoff(addressed); + + if (addressed) + { + StatusText.Text = string.Empty; + StatusText.Visibility = Visibility.Collapsed; + return; + } + + /* The panels are gone; without this the dialog would simply look empty for no stated reason. */ + StatusText.Text = StaleServerNotice; + StatusText.Visibility = Visibility.Visible; + } + + private bool VerdictMatchesServerBox() => + _verdictServerName != null && + string.Equals(_verdictServerName, ServerNameTextBox.Text.Trim(), StringComparison.OrdinalIgnoreCase); + + /* + Return to the state the install was launched from -- NOT Initial. + + Initial does not reset InstallUpgradeButton.Content or DatabaseStatusText, and the exit paths + used to force-show InstallationPanel on top of it. Launched from Connected_Current that produced + a genuinely dangerous screen: the button still read "Repair", the text still read "is up to + date", and Advanced Options -- holding "Perform clean install (drops existing database)" -- was + now visible behind it. Ticking that and clicking [Repair] dropped the database. Connected_Current + keeps its options panel collapsed precisely so that cannot happen, so restoring it honestly is + the fix; there, the failure is surfaced in the status panel that IS visible. + */ + private void RestoreAfterInstall(string message) + { + /* + REDUNDANT, and deliberately kept. TransitionToState clears these on every non-Installing + transition, and every path out of here goes through it -- that choke point is the guarantee, + not this. An earlier comment here claimed this was "the only exit path from a run", which was + wrong twice over (BlockInstall and the stale-name demotion are exits too, and the demotion + never reaches either) and would have sent the next reader hunting the wrong mechanism on the + code path that runs DROP DATABASE. Belt and braces on that path is worth three lines. + */ + RepairCheckBox.IsChecked = false; + CleanInstallCheckBox.IsChecked = false; + ResetScheduleCheckBox.IsChecked = false; + + /* + We have no verdict to restore, for one of two reasons -- and they need DIFFERENT words. Either + the run ended before this server's version was read (a cancel or a connect failure during the + install-time re-read), or a clean install deliberately discarded it because it was about to drop + the database. Telling a user whose database we just dropped that "the installed version was not + read" is simply false, and it is the wrong thing to be reading at that moment. + + Either way, do not restore a Connected_* state: that renders the previous server's verdict under + this one's name. Say we do not know. + */ + if (_preInstallState == null) + { + string reason = + _verdictDiscardedByCleanInstall + ? "A clean install was requested, which drops the database, so this server's status " + + "is unknown until it is re-checked." + : "This server's installed version was not read, so its status is unknown."; + + BlockInstall($"{message}\n\n{reason} Click Test Connection to check it."); + + /* + And SHOW THE LOG. BlockInstall lands in Connected_StatusUnknown, which collapses + InstallationPanel -- so this branch used to return having hidden InstallLogTextBox, which is + the only place the actual SQL error text lives. On the clean-install path that is the worst + possible moment to hide it: the database has already been dropped (CleanInstallAsync runs + before the file loop, so any cancel or failure after that point arrives here with the + database gone), and the user was left with a single exception message to work out why. The + branch below has said "show the log either way" since it was written; this one just didn't. + + Advanced Options stays disabled: the install button is collapsed in this state anyway, so + the options are not actionable, and the ticks were cleared on the way in. + */ + InstallationPanel.Visibility = Visibility.Visible; + InstallStatusText.Text = message; + AdvancedOptionsExpander.IsEnabled = false; + return; + } + + TransitionToState(_preInstallState.Value); + + DatabaseStatusPanel.Visibility = Visibility.Visible; + InstallationPanel.Visibility = Visibility.Visible; + InstallStatusText.Text = message; + + /* + Show the log either way -- it is the only place the actual SQL error text lives, and hiding it + leaves the user one exception message to work with. But when the run was launched from + Connected_Current the button still reads "Reinstall Objects" and the clean-install checkbox + must stay out of reach, so re-show the panel with Advanced Options DISABLED rather than + collapsed: WPF propagates IsEnabled to children, so the checkbox cannot be ticked. + */ + AdvancedOptionsExpander.IsEnabled = _preInstallState.Value != DialogState.Connected_Current; + } + private async System.Threading.Tasks.Task DetectDatabaseStatusAsync() { + if (InstallInProgress) return; + + /* + One probe at a time. A detection left Test Connection and Save armed, so a connection test could + start on top of a running detection -- and that test captured the DETECTION's "Checking database + status..." label as the thing to restore when it finished, then faithfully put it back over a + screen where no detection was running. The epoch does not catch it: a detection bumps the epoch + when it STARTS, not when it finishes, so the borrow still looked current. If the test then failed + it showed a MessageBox and never repainted, and the label stayed there for good. + + The finally re-arms only if an install did not start meanwhile -- SetFormEnabled(false) disabled + them deliberately in that case, and every non-Installing state re-arms them on the way out. + */ + DialogState stateBeforeProbe = _currentState; + bool saveWasEnabled = SaveButton.IsEnabled; + + TestConnectionButton.IsEnabled = false; + SaveButton.IsEnabled = false; + CheckForUpdatesButton.IsEnabled = false; + + try + { + await DetectDatabaseStatusCoreAsync(); + } + finally + { + if (!InstallInProgress) + { + /* Nothing owns these two: they are always available whenever the form is live. */ + TestConnectionButton.IsEnabled = true; + CheckForUpdatesButton.IsEnabled = true; + + /* + Save is OWNED BY THE STATE, and this finally must not overrule it. Connected_NoDatabase + turns Save OFF on purpose -- the user has to click Install Now, or take the explicit + "Skip, just add server" link, which is the CONSENTED way to add a server with no + PerformanceMonitor database (SkipInstall_Click re-enables Save and relabels it). Blindly + re-arming it here handed that consent away on the most ordinary path in the dialog: point + at a bare instance, click Check for Updates, read "No PerformanceMonitor database found" + -- and Save is live, and it is IsDefault, so ENTER commits a server the Dashboard can + read nothing from. + + If the probe published a verdict, the state it landed in has already set Save correctly. + Only put back what we borrowed when the probe changed nothing -- superseded, or one that + deliberately published nothing. + */ + if (_currentState == stateBeforeProbe) + { + SaveButton.IsEnabled = saveWasEnabled; + } + } + } + } + + private async System.Threading.Tasks.Task DetectDatabaseStatusCoreAsync() + { + int epoch = ++_detectEpoch; + + /* + _dialogClosed belongs in here, not at the call sites. "The dialog is gone" is just another reason + this detection's answer is no longer wanted, and Superseded() is already the one question every + publish point in this method asks before committing anything -- so putting it here covers all of + them at once, including the second DB round-trip, instead of a guard per caller that the next + caller forgets. + */ + bool Superseded() => epoch != _detectEpoch || InstallInProgress || _dialogClosed; + try { StatusText.Text = "Checking database status..."; StatusText.Visibility = Visibility.Visible; + /* + A fresh detection means a fresh server/state, so the destructive and mode-changing + options start clear. Without this they persist across a retarget of the server box. + */ + RepairCheckBox.IsChecked = false; + CleanInstallCheckBox.IsChecked = false; + /* Sticky and INVISIBLE behind a collapsed panel otherwise -- it TRUNCATEs config.collection_schedule, + so a tick left over from another server would wipe this one's tuned intervals with no consent. */ + ResetScheduleCheckBox.IsChecked = false; + + /* + Capture the server name HERE, alongside the connection string built from it -- not after + the awaits below. The box stays editable throughout, so re-reading it later would stamp + the verdict with whatever is in the box NOW rather than the server we actually read, and + the guard would then be comparing the box to itself: it could never fail. + */ + string probedServerName = ServerNameTextBox.Text; string installerConnStr = BuildInstallerConnectionString(); string appVersion = GetAppVersion(); - /* Test connection via Installer.Core to get ServerInfo */ - _coreServerInfo = await InstallationService.TestConnectionAsync(installerConnStr); + /* + Land in a LOCAL, guard, and only then commit to the shared field. An install may have + started while this was in flight (against a different server -- the box stays editable), + and it reads _coreServerInfo. Guarding after the field write would already have clobbered + it with the previous server's ServerInfo. + */ + var probedServerInfo = await InstallationService.TestConnectionAsync(installerConnStr); + + /* An install started, or a newer detection superseded us: publish nothing. */ + if (Superseded()) return; - if (_coreServerInfo == null || !_coreServerInfo.IsConnected) + if (probedServerInfo == null || !probedServerInfo.IsConnected) { - StatusText.Text = string.Empty; - StatusText.Visibility = Visibility.Collapsed; + /* + Publish NOTHING -- deliberately. We established no facts, so the previous server's four + facts and its stamp stay together and stale, which the guard catches. Stamping this + server's NAME here without the facts to go with it is the half-replaced state the guard + structurally cannot catch, and it is what made it affirm a lie once already. + + But SAY something. This used to blank the label and return, which turned the one + instruction on screen after a demotion -- "Click Test Connection to check the server now + in the box" -- into a dead end: the installer-side probe can fail where the plain + connection test just succeeded (different connection string, different database), and + the user clicked, and nothing happened. No message, no state change. Forever. + + A STATUS LINE, not a BlockInstall. A block is a fact about a specific server, and the + guard attributes it by the stamp -- which we just refused to move, correctly. Blocking + here would leave the block attributed to the PREVIOUS server, so the next keystroke would + restate it as "the server name changed", which is not what happened. And the only way to + attribute it would be to stamp without the facts, which is the half-replaced state that + made the guard affirm a lie in the first place. So: no new claim about any server, just + a plain report of what failed. The install stays blocked by whatever already blocked it. + */ + string detail = + string.IsNullOrWhiteSpace(probedServerInfo?.ErrorMessage) + ? "." + : $": {probedServerInfo.ErrorMessage}"; + + StatusText.Text = $"Could not read this server's PerformanceMonitor status{detail}"; + StatusText.Visibility = Visibility.Visible; return; } - if (!_coreServerInfo.IsSupportedVersion) + string probedServerVersion = probedServerInfo.SqlServerVersion.Split('\n')[0].Trim(); + + if (!probedServerInfo.IsSupportedVersion) { - string serverName = ServerNameTextBox.Text; - ConnectionInfoText.Text = _serverVersion != null - ? $"Connected to {serverName} ({_serverVersion})" - : $"Connected to {serverName}"; - DatabaseStatusText.Text = $"Warning: {_coreServerInfo.ProductMajorVersionName} is not supported. SQL Server 2016+ is required."; - DatabaseStatusPanel.Visibility = Visibility.Visible; - InstallUpgradeButton.Visibility = Visibility.Collapsed; - SkipInstallText.Visibility = Visibility.Collapsed; + /* + A publish: "SQL Server 2014 is not supported" is a fact ABOUT this server, so it is + stamped with it -- otherwise retyping past an unsupported 2014 instance to a 2022 one + left the panel still saying 2014, with no stamp for the guard to catch it on. Safe to + commit here: no await stands between the guard above and this line, so the publish + cannot be abandoned after the fields are written. + + Routed through BlockInstall rather than hand-poked. Writing the panels directly here + bypassed the stale-verdict guard entirely and left _currentState pointing at whatever + the previous server's state was -- so an unsupported server could be described using + the previous one's facts, under this one's name. + */ + /* The verdict that replaces the previous run's log arrives WITH it, not before it. */ + DiscardPreviousRunLog(); + + PublishProbedServer( + probedServerInfo, + probedServerVersion, + probedServerName, + /* Never read on this path -- we bail before the version probe. */ + installedVersion: null); + StatusText.Text = string.Empty; StatusText.Visibility = Visibility.Collapsed; + BlockInstall( + $"{probedServerInfo.ProductMajorVersionName} is not supported.\n\n" + + "PerformanceMonitor requires SQL Server 2016 or later."); return; } - /* Check installed version */ - _installedVersion = await InstallationService.GetInstalledVersionAsync(installerConnStr); - - if (_installedVersion == null) + /* + Check installed version. throwOnError matters: with the soft overload a transient + SqlException -- a timeout, the database OFFLINE/RESTORING, a permissions blip -- comes + back as null, which is indistinguishable from "no database". That drops us into the + fresh-install path, which skips every migration, reinstalls over the existing + database, and then stamps installation_history SUCCESS at the target version -- + stranding every pending hop permanently. The CLI passes throwOnError: true for + exactly this reason; the Dashboard's install path must too. + */ + try { - TransitionToState(DialogState.Connected_NoDatabase); + /* Local first, guard, then commit -- a running install reads _installedVersion. */ + var probedVersion = await InstallationService.GetInstalledVersionAsync( + installerConnStr, + throwOnError: true); + + /* An install started, or a newer detection superseded us: publish nothing. */ + if (Superseded()) return; + + /* + Cleared HERE, not before the await. Clearing it in the prologue makes the clear + unconditional while the publish it belongs to stays guarded: an install that blocked + itself mid-flight (a cancel, a fatal) would be retroactively un-blocked by a detection + that then declines to publish anything -- leaving a live button whose every click is a + "Blocked" dialog. State that describes a verdict is committed with the verdict. + */ + _installBlockedReason = null; + _serverBlockReason = null; + + /* The verdict that replaces the previous run's log arrives WITH it, not before it. */ + DiscardPreviousRunLog(); + + PublishProbedServer( + probedServerInfo, + probedServerVersion, + probedServerName, + probedVersion); } - else + catch (Exception ex) { - string normalizedInstalled = NormalizeVersion(_installedVersion); - string normalizedApp = NormalizeVersion(appVersion); + if (Superseded()) return; + + /* + A publish: the block names THIS server's failure, so it is stamped with it -- and the + installed version is null, because that is exactly what just failed to be read. Carrying + the previous server's version through here is how a block ends up describing the wrong + database. + */ + /* The verdict that replaces the previous run's log arrives WITH it, not before it. */ + DiscardPreviousRunLog(); + + PublishProbedServer( + probedServerInfo, + probedServerVersion, + probedServerName, + installedVersion: null); + + BlockInstall( + $"Could not determine the installed PerformanceMonitor version: {ex.Message}\n\n" + + "Install and upgrade are blocked until this resolves. Proceeding could reinstall over an " + + "existing database, skip its pending migrations, and record it as up to date."); + return; + } - if (Version.TryParse(normalizedInstalled, out var installedVer) && - Version.TryParse(normalizedApp, out var appVer)) - { - if (installedVer < appVer) - { - TransitionToState(DialogState.Connected_NeedsUpgrade); - } - else - { - TransitionToState(DialogState.Connected_Current); - } - } - else - { - TransitionToState(DialogState.Connected_Current); - } + string? blockReason = GetInstallBlockReason(_installedVersion, appVersion); + if (blockReason != null) + { + BlockInstall(blockReason); + return; } + + TransitionToState(ServerSetupPolicy.DeriveConnectedState(_installedVersion, appVersion)); } catch (Exception ex) { + /* + The one continuation in this method that was not guarded. A stale detection whose connection + test throws would paint its error over a running install's screen, or over a newer + detection's result -- reporting a failure against a server nobody is looking at any more. + */ + if (Superseded()) return; + StatusText.Text = $"Could not check database status: {ex.Message}"; StatusText.Visibility = Visibility.Visible; } } + /* Record why installing is unsafe and drop into the state that hides the install button. */ + private void BlockInstall(string reason) + { + _installBlockedReason = reason; + + /* Kept so a retarget can swap in the generic notice and a retarget BACK can restore the truth. */ + _serverBlockReason = reason; + + /* + The destructive/mode ticks are cleared by TransitionToState, for every state but Installing -- + deliberately NOT here. Clearing them at each exit is what kept failing: the stale-name demotion + rewrites the state in place and never reaches this method, so a clear written here would have + missed it exactly as the three before it did. + */ + TransitionToState(DialogState.Connected_StatusUnknown); + } + + /* + Decide whether installing against this installed version is safe, and if not, why. Returns null + when it is safe (including "no database", which is a fresh install). + + Both the Test Connection path and the install click run this. It cannot be computed once and + cached: the server box stays editable and BuildInstallerConnectionString() rebuilds the + connection string live, so a verdict from the last detection may belong to a different server. + Refreshing the version alone is not enough either -- the "installed is NEWER than this build" + case produces zero hops and zero failures, so nothing downstream catches it, and the install + would silently revert a newer database to this binary's older object definitions and record + the lower version as SUCCESS. + */ + private static string? GetInstallBlockReason(string? installedVersion, string appVersion) + { + /* + The DECISION lives in Installer.Core/InstallGuard, shared with the CLI and pinned by tests + that actually run in CI. Only the wording is ours. Two of these cases are invisible to every + other guard -- they produce zero upgrade hops AND zero failures, so the migration-failure + abort never fires and nothing downstream notices. + */ + switch (InstallGuard.Check(installedVersion, appVersion)) + { + case InstallBlock.UnreadableBuildVersion: + return $"This Dashboard reports its own version as '{appVersion}', which is not a valid version.\n\n" + + "Install and upgrade are blocked: without a comparable version we cannot tell which " + + "migrations still need to run, and a fresh install would record that value as the " + + "server's version. This is a build problem, not a problem with the server."; + + case InstallBlock.UnreadableInstalledVersion: + return $"Could not interpret the installed PerformanceMonitor version ('{installedVersion}').\n\n" + + "Install and upgrade are blocked: without a comparable version we cannot tell which " + + "migrations still need to run. Correct the most recent SUCCESS row in " + + "PerformanceMonitor.config.installation_history, or rebuild the database with the " + + "CLI installer's --reinstall (destructive)."; + + case InstallBlock.InstalledIsNewerThanBuild: + /* + Everything is blocked here, clean install included -- Connected_StatusUnknown collapses + the panel the checkbox lives in. So this message has to carry the whole escape route, or + the user is simply stuck. + */ + return $"PerformanceMonitor v{NormalizeVersion(installedVersion!)} is installed, which is newer than this " + + $"Dashboard (v{NormalizeVersion(appVersion)}).\n\n" + + "Install, upgrade and repair are blocked: running the older installer would revert this " + + "server's objects to the older definitions and record it at the lower version.\n\n" + + "Update this Dashboard to v" + NormalizeVersion(installedVersion!) + " or later. If you " + + "genuinely need to move this server BACK to an older version, the CLI installer's " + + "--reinstall does that (it drops the database first, so there is nothing left to downgrade)."; + + case InstallBlock.None: + return null; + + default: + /* + Never default to "safe to install". A new InstallBlock member silently becoming an + allow is the one failure mode this whole guard exists to prevent. + */ + throw new NotSupportedException( + $"Unhandled {nameof(InstallBlock)}: {InstallGuard.Check(installedVersion, appVersion)}"); + } + } + private void TransitionToState(DialogState newState) { + /* + Refuse to render a verdict that belongs to a different server. The states below assert facts + about "this server" -- its installed version, whether it needs an upgrade, whether it is up to + date -- and every one of those facts came from a read of some server. If the box has since + been retyped, rendering them puts the OLD server's verdict under the NEW server's name: + "PerformanceMonitor v3.1.0 is up to date" over a database four migrations behind, with Save + enabled. That exact lie has been reached three different ways across nine review rounds, so it + is stopped here, once, rather than at each consumer. + */ + if (ServerSetupPolicy.IsServerVerdictState(newState) && !VerdictMatchesServerBox()) + { + /* + Remembered so the demotion can be UNDONE. The verdict was never wrong -- it was + mis-addressed -- so if the box comes back to the name it was read from, it is valid again. + Null on every other transition, which is what keeps a REAL block (unsupported version, + unreadable version, newer-than-binary) from being undone by typing. + */ + _demotedFromState = newState; + + _installBlockedReason = StaleServerNotice; + newState = DialogState.Connected_StatusUnknown; + } + else + { + _demotedFromState = null; + } + + /* + Consent for a destructive option is per-run and per-SERVER. These three ticks authorize + DROP DATABASE (clean install), skipping the migrations (repair), and TRUNCATE + config.collection_schedule (reset schedule) -- against the server that was on screen when they + were ticked. ANY transition other than starting the run invalidates that: the run ended, or the + verdict was demoted because the box now names someone else. + + Cleared HERE, at the one choke point every state change passes through, because clearing it at + each exit is precisely what kept failing. It was patched into RestoreAfterInstall, then the + detection prologue, then BlockInstall -- and the stale-name demotion, which rewrites newState in + place a dozen lines above and never calls BlockInstall at all, still slipped past every one of + them. That path is unreachable today only because the state it lands in happens to collapse the + panel holding these checkboxes: a rendering accident standing in for a consent check, on the + code path that drops a database. + + Installing is the sole exception -- the run is about to READ these ticks. That "sole exception" + is StateConsumesModeSelections, in ServerSetupPolicy, pinned by a test over every state so a new + state cannot silently join the exception and carry a live tick onto a server. + */ + if (!ServerSetupPolicy.StateConsumesModeSelections(newState)) + { + CleanInstallCheckBox.IsChecked = false; + RepairCheckBox.IsChecked = false; + ResetScheduleCheckBox.IsChecked = false; + } + _currentState = newState; string appVersion = GetAppVersion(); @@ -480,17 +1427,38 @@ private void TransitionToState(DialogState newState) InstallationPanel.Visibility = Visibility.Collapsed; MonitoringCredsPanel.Visibility = Visibility.Collapsed; ViewReportButton.Visibility = Visibility.Collapsed; + /* + Unfreeze Advanced Options. The install states below re-disable it immediately, but without + this it was only ever re-enabled on the abort/cancel/fatal paths -- so after a completed + install it stayed disabled for the rest of the dialog's life, stranding whatever mode + checkboxes were ticked inside it with no way to untick them. + */ + AdvancedOptionsExpander.IsEnabled = true; + /* Only the Installing state arms Cancel; otherwise it paints "Cancelling..." over nothing. */ + CancelInstallButton.IsEnabled = false; StatusText.Text = string.Empty; StatusText.Visibility = Visibility.Collapsed; ConnectionInfoText.Text = string.Empty; InstallUpgradeButton.Visibility = Visibility.Visible; SkipInstallText.Visibility = Visibility.Visible; - /* Build the connection header shown for all connected states */ - string serverName = ServerNameTextBox.Text; - string connectionHeader = _serverVersion != null - ? $"Connected to {serverName} ({_serverVersion})" - : $"Connected to {serverName}"; + /* + Built from the STAMPED server, never the live box, and only when the stamp still matches it. + Reading the box here rendered "Connected to S (Microsoft SQL Server 2019 ...)" mid-keystroke: + a connection that was never made, to a half-typed name, carrying the PREVIOUS server's engine + version. The three verdict states below reach this line only with a matching stamp (the guard + demotes them otherwise), but Connected_StatusUnknown is not a verdict state -- it is where a + demotion LANDS -- so it was never covered. Asserting "Connected to" a server we did not + connect to is the same lie the guard exists to stop; say nothing instead. + */ + string connectionHeader = string.Empty; + + if (VerdictMatchesServerBox()) + { + connectionHeader = _serverVersion != null + ? $"Connected to {_verdictServerName} ({_serverVersion})" + : $"Connected to {_verdictServerName}"; + } switch (newState) { @@ -501,27 +1469,65 @@ private void TransitionToState(DialogState newState) InstallUpgradeButton.Content = "Install Now"; DatabaseStatusPanel.Visibility = Visibility.Visible; InstallationPanel.Visibility = Visibility.Visible; + /* Reachable from Installing (the "nothing to repair" refusal), which disabled the form. */ + SetFormEnabled(true); SaveButton.IsEnabled = false; break; case DialogState.Connected_NeedsUpgrade: - string normalizedInstalled = NormalizeVersion(_installedVersion!); + string normalizedInstalled = NormalizeVersion(_installedVersion ?? "unknown"); ConnectionInfoText.Text = connectionHeader; - DatabaseStatusText.Text = $"PerformanceMonitor v{normalizedInstalled} is installed. " + - $"v{appVersion} is available — click Upgrade Now to apply the update."; + /* Never state the sentinel as a fact: it means "installed, version unreadable". */ + DatabaseStatusText.Text = RepairOutcome.IsVersionUnknown(_installedVersion) + ? "PerformanceMonitor is installed, but its recorded version could not be read, so it is " + + $"unknown which migrations have run. Click Upgrade Now to attempt every upgrade and bring it to v{appVersion}." + : $"PerformanceMonitor v{normalizedInstalled} is installed. " + + $"v{appVersion} is available — click Upgrade Now to apply the update."; InstallUpgradeButton.Content = "Upgrade Now"; DatabaseStatusPanel.Visibility = Visibility.Visible; InstallationPanel.Visibility = Visibility.Visible; + /* Reachable from Installing (every upgrade abort restores here), which disabled the form. */ + SetFormEnabled(true); SaveButton.IsEnabled = true; break; case DialogState.Connected_Current: - string normalizedCurrent = NormalizeVersion(_installedVersion!); + string normalizedCurrent = NormalizeVersion(_installedVersion ?? "unknown"); + ConnectionInfoText.Text = connectionHeader; + DatabaseStatusText.Text = $"PerformanceMonitor v{normalizedCurrent} is up to date. " + + "If objects are missing or damaged, click Reinstall Objects to restore them."; + /* + Repair stays reachable at the current version: the install scripts are idempotent, + so re-running them restores missing objects. This state now means installed == + target exactly, so there are no migrations to skip and this is a plain reinstall at + the same version. The CLI can already do this with --repair; without it the + Dashboard had no non-destructive recovery for a healthy-version-but-damaged database. + + InstallationPanel stays collapsed on purpose: it holds the "drops existing database" + clean-install checkbox, which must never sit behind this button. + + Named "Reinstall Objects", NOT "Repair": the Repair CHECKBOX means something different + (skip the migrations, write no history row), and it is deliberately unreachable here. + This button runs the ordinary install path -- with installed == target there are no + migrations to skip, so it is a same-version REINSTALL and correctly records one. + */ + InstallUpgradeButton.Content = "Reinstall Objects"; + SkipInstallText.Visibility = Visibility.Collapsed; + DatabaseStatusPanel.Visibility = Visibility.Visible; + /* Reachable from Installing (a failed reinstall restores here), which disabled the form. */ + SetFormEnabled(true); + SaveButton.IsEnabled = true; + break; + + case DialogState.Connected_StatusUnknown: ConnectionInfoText.Text = connectionHeader; - DatabaseStatusText.Text = $"PerformanceMonitor v{normalizedCurrent} is up to date."; + DatabaseStatusText.Text = _installBlockedReason ?? "The database status could not be determined."; InstallUpgradeButton.Visibility = Visibility.Collapsed; SkipInstallText.Visibility = Visibility.Collapsed; DatabaseStatusPanel.Visibility = Visibility.Visible; + /* Reachable from Installing (the install-time re-check blocks), which disabled the form. */ + CancelInstallButton.IsEnabled = false; + SetFormEnabled(true); SaveButton.IsEnabled = true; break; @@ -535,6 +1541,17 @@ private void TransitionToState(DialogState newState) case DialogState.InstallComplete: InstallationPanel.Visibility = Visibility.Visible; + /* + Clear both mode checkboxes once a run completes, on EVERY outcome. They are sticky + otherwise: the form is re-enabled here, so the user can retype the server box and + click again -- carrying "Repair" (skip every migration) or, worse, "Clean install" + (drop the database) onto a server that was never consented to. + */ + RepairCheckBox.IsChecked = false; + CleanInstallCheckBox.IsChecked = false; + /* Sticky and INVISIBLE behind a collapsed panel otherwise -- it TRUNCATEs config.collection_schedule, + so a tick left over from another server would wipe this one's tuned intervals with no consent. */ + ResetScheduleCheckBox.IsChecked = false; AdvancedOptionsExpander.IsEnabled = false; CancelInstallButton.IsEnabled = false; SetFormEnabled(true); @@ -568,6 +1585,18 @@ private void TransitionToState(DialogState newState) case DialogState.Initial: default: + /* + The cancel and fatal-error paths land here, and both re-enable the form and re-show + the panels -- so a ticked "Clean install (drops existing database)" would survive a + cancelled run, and the server box could then be retargeted and clicked, dropping a + database that was never consented to. Clear the mode flags here as well as at + InstallComplete, so every exit from a run clears them. + */ + RepairCheckBox.IsChecked = false; + CleanInstallCheckBox.IsChecked = false; + /* Sticky and INVISIBLE behind a collapsed panel otherwise -- it TRUNCATEs config.collection_schedule, + so a tick left over from another server would wipe this one's tuned intervals with no consent. */ + ResetScheduleCheckBox.IsChecked = false; SetFormEnabled(true); SaveButton.IsEnabled = true; SaveButton.Content = "Save"; @@ -577,20 +1606,268 @@ private void TransitionToState(DialogState newState) private async void InstallOrUpgrade_Click(object sender, RoutedEventArgs e) { + /* + Never install when the detected state said it is unsafe: we cannot tell an absent database + from an existing one we simply could not read, and guessing "absent" reinstalls over it with + the migrations skipped and then records it as up to date. + */ + /* Structural backstop: never start a second install over a running one. */ + if (InstallInProgress) return; + + /* + Permanently supersede any detection still in flight. Superseded() also tests InstallInProgress, + but that is only read when the continuation RESUMES -- so a detection that spans the whole run + wakes up AFTER it, sees the install finished, and publishes its pre-install verdict over the + result: the "Upgrade Now" button returns on a server that was just upgraded, or comes back live + while _installBlockedReason is still set, so every click is a "Blocked" box. The epoch is the + only part of that test which cannot un-fire. + */ + _detectEpoch++; + + /* + Every run invalidates the previous run's handoff. Without this, the handoff text outlived the + repair that produced it: repair, then click the "Upgrade Now" it hands you, and the upgrade + succeeds -- but this field still held "PerformanceMonitor is still at v3.0.0, the pending + upgrade has not been applied". Any later keystroke in the server box re-rendered that, with a + live Upgrade Now button, on a server that had just been upgraded. The completion block below + re-sets it only when THIS run was a repair that left an upgrade pending. + */ + _handoffStatusText = null; + + if (_installBlockedReason != null) + { + MessageBox.Show( + this,_installBlockedReason, "Install Blocked", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + /* + Freeze the UI and arm cancellation BEFORE the first await. TransitionToState(Installing) + collapses DatabaseStatusPanel, which is the Border InstallUpgradeButton lives in -- and that + collapse is the ONLY thing that makes the button unclickable, since SetFormEnabled never + touches it. Awaiting the version re-read in front of it would leave the button live for the + whole connect timeout (unbounded for interactive Entra auth), and this handler is async void: + a second click starts a second concurrent install that clobbers _installCts and + _installResult, races two clean installs, and leaves Cancel pointing at the wrong run. + Creating the CTS first also means Cancel and ESC work DURING the probe rather than no-oping + on a null and letting the install continue after the dialog has closed. + */ + /* + The promise is fixed here and never re-derived. The restore target starts from the best facts + we currently have -- and is refreshed from the install-time re-read below -- but it is a + Connected_* state from the outset, so a cancel DURING the re-read can never restore into + InstallComplete and unfreeze the clean-install checkbox the repair handoff had frozen. + */ + _launchState = _currentState; + _preInstallState = null; + + /* Per-run, like _handoffStatusText. A stale one would misexplain the NEXT run's failure. */ + _verdictDiscardedByCleanInstall = false; + + /* + Per-run too, and it was the one that got missed. GenerateSummaryReport is inside a try whose + catch only logs -- so if it throws on THIS run (long path, disk full, permissions), _reportPath + still holds the LAST run's file, for what may be a different server, and InstallComplete shows + "View Report" because it only tests for null. RenderPostRunClaims will even bring the link back + on a retype-and-back. + */ + _reportPath = null; + TransitionToState(DialogState.Installing); InstallLogTextBox.Clear(); InstallProgressBar.Value = 0; - InstallStatusText.Text = "Preparing installation..."; + InstallStatusText.Text = "Checking installed version..."; + + _installCts?.Dispose(); - _installCts = new CancellationTokenSource(); - var cancellationToken = _installCts.Token; + /* + Held locally as well as in the field, so the finally can tear down the token IT created and + nothing else. Disposing the field unconditionally meant a run that unwound LATE could dispose + and null a token belonging to a run that had started since -- leaving that one uncancellable by + the Cancel button, by Cancel_Click, and by CancelInstallOnClose, so closing the dialog would no + longer stop an install running against the database. + */ + var installCts = new CancellationTokenSource(); + _installCts = installCts; + var cancellationToken = installCts.Token; try { var provider = ScriptProvider.FromEmbeddedResources(); + /* Captured with the connection string built from it -- the box is frozen here, but stamp + what we read, never what the box happens to say later. */ + string installTimeServerName = ServerNameTextBox.Text; string installerConnStr = BuildInstallerConnectionString(); string appVersion = GetAppVersion(); + + /* + Re-read the installed version against the connection we are about to WRITE to, and + re-run the safety verdict on the result. The cached _installedVersion and + _installBlockedReason come from the last Test Connection, but the server box stays + editable and the connection string is rebuilt live -- so retyping it and clicking + straight through would install into a different server while still reasoning about the + previous one. Refreshing the version alone is not enough: the "installed is NEWER than + this build" verdict produces zero hops and zero failures, so nothing downstream catches + it, and the install would silently downgrade the server and record the lower version. + */ + try + { + /* + Refresh the server facts too, not just the version. The cached ones came from the last + Test Connection, and after a retarget they describe a different box -- so the summary + report would name THIS server with the previous one's SQL version and edition. + + Landed in LOCALS and published as a SET, exactly as the detect path does. This method + used to write _coreServerInfo, then await, then write _installedVersion, then stamp -- + three of the four coupled facts, committed separately across an await, and never the + fourth (_serverVersion kept whatever the last detection left). It is not exploitable, + because the box is frozen for the whole run and every other handler bails on + InstallInProgress. But "not exploitable because something else happens to be disabled" + is enablement standing in for an invariant, and that is the exact substitution this file + has been bitten by over and over. Publish them together and it is true by construction. + */ + var installTimeInfo = await InstallationService.TestConnectionAsync( + installerConnStr, + cancellationToken: cancellationToken); + + /* + And ACT on those facts. The SQL-2016+ gate was detect-time only, so retargeting the box + to an older server and clicking through installed 2016+ syntax onto it -- creating a + junk database and a FAILED history row on a server the gate exists to refuse. Re-reading + the fact and then not using it was the whole hole. + */ + if (installTimeInfo is not { IsConnected: true }) + { + throw new InvalidOperationException("Could not connect to this server."); + } + + if (!installTimeInfo.IsSupportedVersion) + { + throw new InvalidOperationException( + $"{installTimeInfo.ProductMajorVersionName} is not supported. PerformanceMonitor requires SQL Server 2016 or later."); + } + + string? installTimeVersion = await InstallationService.GetInstalledVersionAsync( + installerConnStr, + throwOnError: true, + cancellationToken: cancellationToken); + + /* Stamped with the server we just read -- this is the connection we will write to. */ + PublishProbedServer( + installTimeInfo, + installTimeInfo.SqlServerVersion.Split('\n')[0].Trim(), + installTimeServerName, + installTimeVersion); + } + catch (Exception) when (cancellationToken.IsCancellationRequested) + { + /* + SqlCommand does NOT surface cancellation as an OperationCanceledException: it cancels + the in-flight command and the task faults with a SqlException ("Operation cancelled by + user."). Without this, the user's own Cancel would be reported as an + unknown-database-state block, hiding the install button until they re-tested. + */ + throw new OperationCanceledException(cancellationToken); + } + catch (Exception ex) + { + await Dispatcher.InvokeAsync(() => BlockInstall( + $"Could not determine the installed PerformanceMonitor version on this server: {ex.Message}\n\n" + + "Install and upgrade are blocked: proceeding could reinstall over an existing database " + + "and skip its pending migrations.")); + return; + } + + /* + Refuse to reinstall what is not there. Falling through would silently run a FULL fresh + install and stamp the target version -- a whole new database, Agent jobs and XE sessions on + a mistyped server. Both buttons that promise to act on an EXISTING install are covered: + "Upgrade Now" (Connected_NeedsUpgrade) and "Reinstall Objects" (Connected_Current), plus the + Repair checkbox. The server box stays editable and the re-read above runs against whatever + is in it NOW, so a retarget to a bare instance reaches all three. + */ + bool promisedAnExistingInstall = ServerSetupPolicy.PromisesExistingInstall(_launchState); + + if ((RepairCheckBox.IsChecked == true || promisedAnExistingInstall) && + CleanInstallCheckBox.IsChecked != true && + _installedVersion == null) + { + /* + The button arms matter as much as the checkbox: those states' buttons say "Upgrade Now" + and "Reinstall Objects", the server box stays editable, and the install-time re-read runs + against whatever is in it now. Retype it to a bare instance and the button would + silently perform a FULL fresh install -- new database, jobs, XE sessions -- from a + button that promised to reinstall existing objects. + */ + await Dispatcher.InvokeAsync(() => + { + /* Every other exit clears all three; this one must too. ResetSchedule TRUNCATEs + config.collection_schedule, so a tick surviving a retarget wipes the wrong server. */ + RepairCheckBox.IsChecked = false; + CleanInstallCheckBox.IsChecked = false; + ResetScheduleCheckBox.IsChecked = false; + InstallStatusText.Text = string.Empty; + + /* + The box goes up BEFORE the state is unfrozen, and the order is load-bearing. + + MessageBox.Show pumps a nested message loop. TransitionToState(Connected_NoDatabase) + re-enables the form and puts a live "Install Now" back on screen, and it clears + Installing -- so with the transition first, the user could click Install Now while + this box was still standing, and the InstallInProgress backstop was already false and + would not stop them. That starts a SECOND install, which allocates its own + CancellationTokenSource -- and then this run unwinds into its finally, which used to + dispose and null _installCts unconditionally: the second install's token. It would + then be uncancellable by anything, including CLOSING THE DIALOG, which is the exact + hazard CancelInstallOnClose exists to prevent. + + Modal first, while the form is still frozen. The transition happens after it is + dismissed, when nothing can be clicked underneath. + */ + MessageBox.Show( + this, + "There is no existing PerformanceMonitor installation on this server to reinstall.\n\n" + + "Repair, Upgrade and Reinstall Objects restore or update the objects of an EXISTING " + + "installation; none of them creates one. Click Install Now to perform a fresh install instead.", + "Nothing to Reinstall", + MessageBoxButton.OK, + MessageBoxImage.Information); + + TransitionToState(DialogState.Connected_NoDatabase); + }); + return; + } + + string? blockReason = GetInstallBlockReason(_installedVersion, appVersion); + if (blockReason != null) + { + await Dispatcher.InvokeAsync(() => BlockInstall(blockReason)); + return; + } + + /* + Re-derive where a failure should land us from what we just READ, not from where we came + from. Those differ whenever the server box was retyped: restoring the old state would + render the old server's verdict over the new server's version -- "PerformanceMonitor v2.5.0 + is up to date" on a database four migrations behind, with Save enabled. It also keeps + _preInstallState out of InstallComplete/MonitoringCredentials (reachable via the repair + handoff), which RestoreAfterInstall would otherwise re-enter with Advanced Options unfrozen. + */ + _preInstallState = ServerSetupPolicy.DeriveConnectedState(_installedVersion, appVersion); + + InstallStatusText.Text = "Preparing installation..."; + bool cleanInstall = CleanInstallCheckBox.IsChecked == true; + + /* + Repair only means anything for an existing installation we are not about to drop. + Resolving it to the version we repair FROM (rather than a bare bool) keeps the + history write below from ever recording the old version after a clean install. + */ + string? repairFromVersion = RepairCheckBox.IsChecked == true && !cleanInstall + ? _installedVersion + : null; + bool resetSchedule = ResetScheduleCheckBox.IsChecked == true; bool runValidation = ValidationCheckBox.IsChecked == true; bool installDeps = InstallDepsCheckBox.IsChecked == true; @@ -620,11 +1897,34 @@ private async void InstallOrUpgrade_Click(object sender, RoutedEventArgs e) }); /* Run upgrades if applicable (existing database, not clean install) */ - if (!cleanInstall && _installedVersion != null) - { - AppendInstallLog($"Checking for upgrades from v{NormalizeVersion(_installedVersion)} to v{appVersion}...", "Info"); + int upgradeSuccess = 0; + int upgradeFailure = 0; - var (upgradeSuccess, upgradeFailure, upgradeCount) = await InstallationService.ExecuteAllUpgradesAsync( + if (repairFromVersion != null) + { + /* + Repair reinstalls the schema objects (install scripts are idempotent) without + running migrations, so a hop that failed on a missing or damaged object can be + recovered without dropping the database. The pending upgrade runs afterwards. + */ + AppendInstallLog( + "Repair mode: skipping upgrade scripts. Objects will be reinstalled at their current definitions; the pending upgrade still needs to run afterwards.", + "Warning"); + } + else if (!cleanInstall && _installedVersion != null) + { + /* + The sentinel parses, so interpolating it logged "Checking for upgrades from v0.0.0" -- + stating the "I could not read this server's version" guess as a fact. Every other + consumer in this file guards it with IsVersionUnknown; this line did not. + */ + AppendInstallLog( + RepairOutcome.IsVersionUnknown(_installedVersion) + ? $"This server's recorded version could not be read. Attempting every upgrade up to v{appVersion}..." + : $"Checking for upgrades from v{NormalizeVersion(_installedVersion)} to v{appVersion}...", + "Info"); + + var upgradeResult = await InstallationService.ExecuteAllUpgradesAsync( provider, installerConnStr, _installedVersion, @@ -632,17 +1932,59 @@ private async void InstallOrUpgrade_Click(object sender, RoutedEventArgs e) progress, cancellationToken); - if (upgradeCount > 0) + upgradeSuccess = upgradeResult.totalSuccessCount; + upgradeFailure = upgradeResult.totalFailureCount; + + if (upgradeResult.upgradeCount > 0) { AppendInstallLog($"Upgrades complete: {upgradeSuccess} succeeded, {upgradeFailure} failed", upgradeFailure == 0 ? "Success" : "Warning"); } + /* + Abort when an upgrade script fails, matching the CLI installer. + Reinstalling over a partially-upgraded database compounds the damage, and + recording a successful install at the target version would strand the failed + migration permanently: version detection reads the most recent SUCCESS row, so + the hop would never be offered again. Writing no history row leaves the server + at its current version, and upgrade scripts are idempotent, so re-running after + fixing the error resumes cleanly. + */ if (upgradeFailure > 0) { - AppendInstallLog("Upgrade failures detected. Continuing with full installation to ensure consistency...", "Warning"); + await Dispatcher.InvokeAsync(() => + { + InstallProgressBar.Value = 0; + AppendInstallLog( + $"Installation aborted: {upgradeFailure} upgrade script(s) failed. Upgrade scripts must succeed before installation can proceed.", + "Error"); + AppendInstallLog( + "Fix the errors above and run the upgrade again. The server remains at its current version.", + "Info"); + AppendInstallLog( + "If the failure is a missing or damaged object, tick 'Repair' in Advanced Options to reinstall the schema objects without running migrations, then run the upgrade again.", + "Info"); + RestoreAfterInstall($"Upgrade aborted: {upgradeFailure} upgrade script(s) failed."); + }); + + return; } } + /* + A repair reinstalls objects without running migrations, so it must NOT record the + target version -- that would strand every pending hop, which is exactly the bug the + abort above exists to prevent. + + So a repair writes NO history row at all. It does not change the version, and + installation_history is the version ledger -- writing back a version we merely READ is + how a guess becomes a fact. Concretely: GetInstalledVersionAsync returns the unknown sentinel as a + #538 fallback when the database exists but has no SUCCESS row, meaning "unknown, try + every upgrade". Echoing that back as a SUCCESS row would persist the guess as truth. + Writing nothing leaves the previous row as the version of record, which is exactly + right -- the pending upgrade is still offered afterwards. + */ + bool isRepair = repairFromVersion != null; + /* Run main installation */ AppendInstallLog("Starting main installation...", "Info"); @@ -658,6 +2000,26 @@ private async void InstallOrUpgrade_Click(object sender, RoutedEventArgs e) }; } + if (cleanInstall) + { + /* + A clean install DROPS the database. From here the version we read is about to stop being + true, so a cancel or a failure must not restore a verdict describing it -- that renders + "PerformanceMonitor v3.0.0 is installed" over a server whose database no longer exists, + with Save enabled. Forget the verdict; RestoreAfterInstall will say "status unknown". + */ + _preInstallState = null; + _verdictServerName = null; + + /* + Remember WHY we forgot it. Both causes of a null _preInstallState land in the same + branch of RestoreAfterInstall, which told every one of them "this server's installed + version was not read" -- which on THIS path is simply false. It was read; we discarded + it on purpose, because we are about to drop the database out from under it. + */ + _verdictDiscardedByCleanInstall = true; + } + _installResult = await InstallationService.ExecuteInstallationAsync( installerConnStr, provider, @@ -667,24 +2029,62 @@ private async void InstallOrUpgrade_Click(object sender, RoutedEventArgs e) preValidationAction, cancellationToken); - /* Log installation history */ - try + /* + A clean install that failed is not "completed with N error(s)". CleanInstallAsync drops the + Agent jobs, the XE sessions and then the DATABASE before a single install file runs -- so if + it failed, the database may be gone, half-gone, or stuck in SINGLE_USER, and we do not know + which. ExecuteInstallationAsync returns early in that case with no files attempted. + + Route it exactly where a cancel goes: no history row, no verdict, the log left on screen, and + install blocked until the server is re-checked. The completion path below would instead have + announced a completion, restored the discarded verdict, and armed a Save that skips the + connection test -- on a server whose database we may have just destroyed. + */ + if (cleanInstall && !_installResult.Success && _installResult.FilesSucceeded == 0) { - AppendInstallLog("Recording installation history...", "Info"); - await InstallationService.LogInstallationHistoryAsync( - installerConnStr, - appVersion, - appVersion, - _installResult.StartTime, - _installResult.FilesSucceeded, - _installResult.FilesFailed, - _installResult.Success, - progress); - AppendInstallLog("Installation history recorded", "Success"); + string cleanInstallError = + _installResult.Errors.Count > 0 + ? _installResult.Errors[0].ErrorMessage + : "the database could not be recreated."; + + await Dispatcher.InvokeAsync(() => + RestoreAfterInstall($"Clean install failed: {cleanInstallError}")); + return; } - catch (Exception ex) + + /* Log installation history -- but never for a repair, which changes no version (see above). */ + if (isRepair) + { + AppendInstallLog( + "Repair does not change the recorded version, so no installation history row was written.", + "Info"); + } + else { - AppendInstallLog($"Could not record installation history: {ex.Message}", "Warning"); + try + { + AppendInstallLog("Recording installation history...", "Info"); + + /* + Fold the upgrade script counts in. InstallationResult only covers the install + files, so passing it alone would under-report files_executed and could record a + SUCCESS at the target version even when a migration had failed. + */ + await InstallationService.LogInstallationHistoryAsync( + installerConnStr, + appVersion, + appVersion, + _installResult.StartTime, + upgradeSuccess + _installResult.FilesSucceeded, + upgradeFailure + _installResult.FilesFailed, + upgradeFailure == 0 && _installResult.Success, + progress); + AppendInstallLog("Installation history recorded", "Success"); + } + catch (Exception ex) + { + AppendInstallLog($"Could not record installation history: {ex.Message}", "Warning"); + } } /* Run validation if requested */ @@ -709,8 +2109,16 @@ await InstallationService.LogInstallationHistoryAsync( /* Generate summary report */ try { + /* This parameter is printed as "Installer Version" -- it names the BINARY that ran, not the database. */ + /* + installTimeServerName, not the live box. This was the last read-the-box-after-the-await + in the file: the report names the server the install actually went to, and it is written + after every await in the run. Un-exploitable today only because the box is disabled for + the duration -- which is enablement standing in for an invariant, and the report would + have carried the wrong server's name the moment that stopped being true. + */ _reportPath = InstallationService.GenerateSummaryReport( - ServerNameTextBox.Text.Trim(), + installTimeServerName.Trim(), _coreServerInfo?.SqlServerVersion ?? "", _coreServerInfo?.SqlServerEdition ?? "", appVersion, @@ -722,11 +2130,82 @@ await InstallationService.LogInstallationHistoryAsync( AppendInstallLog($"Could not generate report: {ex.Message}", "Warning"); } + /* + Did a CRITICAL file (01_/02_/03_) fail? ExecuteInstallationAsync aborts the whole pass on + those, so the repair reinstalled almost nothing. That is NOT the expected Msg 207 outcome + below, and must not be dressed up as one. + */ + bool criticalFileFailed = _installResult.Errors.Any(e => Patterns.IsCriticalFile(e.FileName)); + + /* Shared with the CLI so the two cannot drift -- see RepairOutcome. */ + /* + THREE different questions, and they disagree for the unknown sentinel. Answering all of + them with one flag told the operator "already at the current version, so there is no + upgrade to apply" for a server with every hop pending, and withheld the button that would + have applied them. + */ + bool failuresExcused = RepairOutcome.FailuresAreExpected( + isRepair, repairFromVersion, appVersion, criticalFileFailed); + bool hasPendingUpgrade = RepairOutcome.HasPendingUpgrade(repairFromVersion, appVersion); + bool versionUnknown = RepairOutcome.IsVersionUnknown(repairFromVersion); + + /* The repair ran and was not aborted by a critical file, so its handoff is still owed. */ + bool repairCompleted = isRepair && !criticalFileFailed; + /* Update final status */ await Dispatcher.InvokeAsync(() => { InstallProgressBar.Value = 100; - if (_installResult.Success) + if (repairCompleted) + { + /* + A repair on a database with pending migrations is EXPECTED to report file errors, + and that is not a failed repair. The install scripts compile against the CURRENT + schema -- e.g. install/23_process_blocked_process_xml.sql reads + collect.blocking_BlockedProcessReport.monitor_loop, a column the 3.0.0-to-3.1.0 + migration adds -- and ALTER PROCEDURE binds columns at compile time, so those + procedures cannot compile until the upgrade runs (Msg 207, "Invalid column name"). + A failed CREATE OR ALTER leaves the old body intact, so nothing is damaged; the + upgrade's own install pass recompiles them once the schema is current. + + So the completion block is NOT gated on _installResult.Success: gating it would + leave the user staring at "completed with N error(s)" and no next step, which is + how the half-migrated database this feature exists to escape gets left behind. + */ + InstallStatusText.Text = + _installResult.Success ? "Repair completed." + : failuresExcused ? $"Repair completed with {_installResult.FilesFailed} expected error(s)." + : $"Repair completed with {_installResult.FilesFailed} error(s)."; + + if (versionUnknown) + { + AppendInstallLog( + "Repair complete. No version was recorded, and this server's recorded version could not be read -- " + + "it is unknown which migrations have run. Click Upgrade Now to attempt every upgrade, then re-check.", + "Warning"); + } + else if (hasPendingUpgrade) + { + AppendInstallLog( + $"Repair complete. The server is still at v{NormalizeVersion(repairFromVersion!)} and no version was recorded -- click Upgrade Now to apply the pending migrations.", + "Success"); + + if (!_installResult.Success && failuresExcused) + { + AppendInstallLog( + $"{_installResult.FilesFailed} object(s) could not be compiled because the pending upgrade has not run yet. " + + "This is expected -- they reference columns the upgrade adds, and the upgrade will recompile them.", + "Info"); + } + } + else + { + AppendInstallLog( + "Repair complete. This server was already at the current version, so no version was recorded and there is no upgrade to apply.", + "Success"); + } + } + else if (_installResult.Success) { InstallStatusText.Text = "Installation completed successfully!"; AppendInstallLog("Installation completed successfully!", "Success"); @@ -737,38 +2216,100 @@ await Dispatcher.InvokeAsync(() => AppendInstallLog($"Installation completed with {_installResult.FilesFailed} error(s).", "Error"); } + /* + Re-publish the four facts for the server the run actually went to. A clean install forgets + the verdict on the way in (it is about to DROP the database, so a cancel must not restore + a verdict describing it) -- and if the run SUCCEEDED we now know exactly which server this + screen describes: the one we just wrote to. Without this the stamp stays null and + everything keyed on it treats a completed clean install as un-addressed: Save re-runs the + connection test (a second interactive MFA prompt on Entra), and the first keystroke in the + server box withdraws a success message that was perfectly true. + + GATED on success, which it was not. The comment used to assert "but the run SUCCEEDED" + while the code never checked -- so a clean install that FAILED restored the very verdict + it had thrown away for safety, over a database DROP DATABASE may have already taken. And + skipConnectionTest keys on this stamp: Save would then have persisted that server without + ever reconnecting to it. A failed run knows nothing. Leave the stamp null and make Save + prove the connection. + + A PUBLISH, not a lone re-stamp. Moving the stamp by itself put it back while + _installedVersion still held the version read BEFORE the run -- the pre-drop version, on + the clean-install path -- so VerdictMatchesServerBox() went true over a half-replaced fact + set, which is the one shape the guard structurally cannot catch. It is unexploitable today + only because nothing reads _installedVersion from InstallComplete, and reader-absence + standing in for an invariant is exactly the substitution that produced the worst bug in + this file. The database now holds appVersion -- except after a repair, which changes no + version at all. + */ + if (_installResult.Success && _coreServerInfo != null) + { + PublishProbedServer( + _coreServerInfo, + _serverVersion ?? string.Empty, + installTimeServerName, + isRepair ? _installedVersion : appVersion); + } + TransitionToState(DialogState.InstallComplete); + + /* + A successful repair leaves the migrations UNAPPLIED by design, so the user has to run + the upgrade next. InstallComplete collapses DatabaseStatusPanel, which is the Border + InstallUpgradeButton lives in -- so without re-showing it, the completion message + points at a button that is not on screen and the obvious next click is Save & Connect, + leaving exactly the half-migrated database this feature exists to escape. + + MonitoringCredentials is included: InstallComplete chains straight into it for SQL + auth, and that state does not re-show the panel either -- so without it, every SQL-auth + user (the ones most likely to hit this) would get the dead end. The three panels live in + separate grid rows and coexist fine. + + Not gated on _installResult.Success, for the reason above: a repair with the expected + compile errors still needs the handoff. It IS gated on no critical file having failed, + because that repair reinstalled nothing -- and on there actually being an upgrade to + hand off TO, which for the unknown sentinel means yes (every hop may be pending). + */ + if (repairCompleted && hasPendingUpgrade && + (_currentState == DialogState.InstallComplete || + _currentState == DialogState.MonitoringCredentials)) + { + _handoffStatusText = versionUnknown + ? "Objects were reinstalled. This server's recorded version could not be read, so it is " + + "unknown which migrations have run. Click Upgrade Now to attempt every upgrade." + : $"Objects were reinstalled. PerformanceMonitor is still at v{NormalizeVersion(repairFromVersion!)} " + + "-- the pending upgrade has not been applied. Click Upgrade Now to apply it."; + + /* Addressed by construction: the box was frozen for the run and just re-stamped. */ + RenderRepairHandoff(addressed: true); + } }); } catch (OperationCanceledException) { Dispatcher.Invoke(() => { - InstallStatusText.Text = "Installation cancelled."; AppendInstallLog("Installation was cancelled by user.", "Warning"); InstallProgressBar.Value = 0; - TransitionToState(DialogState.Initial); - DatabaseStatusPanel.Visibility = Visibility.Visible; - InstallationPanel.Visibility = Visibility.Visible; - AdvancedOptionsExpander.IsEnabled = true; + RestoreAfterInstall("Installation cancelled."); }); } catch (Exception ex) { Dispatcher.Invoke(() => { - InstallStatusText.Text = $"Installation failed: {ex.Message}"; AppendInstallLog($"Fatal error: {ex.Message}", "Error"); - TransitionToState(DialogState.Initial); - DatabaseStatusPanel.Visibility = Visibility.Visible; - InstallationPanel.Visibility = Visibility.Visible; - AdvancedOptionsExpander.IsEnabled = true; + RestoreAfterInstall($"Installation failed: {ex.Message}"); }); } finally { - _installCts?.Dispose(); - _installCts = null; + /* Only clear the field if it still points at OUR token -- see where it was created. */ + if (ReferenceEquals(_installCts, installCts)) + { + _installCts = null; + } + + installCts.Dispose(); } } @@ -779,6 +2320,14 @@ private void CancelInstall_Click(object sender, RoutedEventArgs e) InstallStatusText.Text = "Cancelling..."; } + /* A clean install drops and recreates the database, so there is nothing left to repair. */ + private void CleanInstallCheckBox_Checked(object sender, RoutedEventArgs e) => + RepairCheckBox.IsChecked = false; + + /* Repair is the non-destructive recovery path, so it cannot mean "drop the database". */ + private void RepairCheckBox_Checked(object sender, RoutedEventArgs e) => + CleanInstallCheckBox.IsChecked = false; + private void AppendInstallLog(string message, string status) { if (string.IsNullOrEmpty(message)) @@ -804,6 +2353,26 @@ private void AppendInstallLog(string message, string status) private void SkipInstall_Click(object sender, MouseButtonEventArgs e) { + /* + Supersede any detection in flight. "Skip, just add server" is live while one is running, and + this method writes _currentState directly rather than transitioning -- so the detection landed + afterwards, published its verdict, and put the install panel and the install button straight + back on screen, silently undoing the skip the user had just chosen. + + Clearing StatusText is part of superseding, not decoration: the detection painted "Checking + database status..." before it awaited, and its Superseded() early-outs return without clearing + it. Every other epoch-bumper does this; this one didn't, so the label sat there for the rest of + the dialog's life announcing a check nobody was running. + */ + _detectEpoch++; + StatusText.Text = string.Empty; + StatusText.Visibility = Visibility.Collapsed; + + /* This bypasses TransitionToState, so it is the one exit that never cleared these. */ + RepairCheckBox.IsChecked = false; + CleanInstallCheckBox.IsChecked = false; + ResetScheduleCheckBox.IsChecked = false; + DatabaseStatusPanel.Visibility = Visibility.Collapsed; InstallationPanel.Visibility = Visibility.Collapsed; SaveButton.IsEnabled = true; @@ -826,6 +2395,7 @@ private void ViewReport_Click(object sender, RoutedEventArgs e) catch (Exception ex) { MessageBox.Show( + this, $"Could not open report: {ex.Message}", "Error", MessageBoxButton.OK, @@ -852,9 +2422,14 @@ private void SetFormEnabled(bool enabled) WindowsAuthRadio.IsEnabled = enabled; SqlAuthRadio.IsEnabled = enabled; EntraMfaAuthRadio.IsEnabled = enabled; + ServicePrincipalAuthRadio.IsEnabled = enabled; + ManagedIdentityAuthRadio.IsEnabled = enabled; UsernameTextBox.IsEnabled = enabled; PasswordBox.IsEnabled = enabled; EntraMfaUsernameBox.IsEnabled = enabled; + ServicePrincipalClientIdBox.IsEnabled = enabled; + ServicePrincipalSecretBox.IsEnabled = enabled; + ManagedIdentityClientIdBox.IsEnabled = enabled; EncryptModeComboBox.IsEnabled = enabled; TrustServerCertificateCheckBox.IsEnabled = enabled; ReadOnlyIntentCheckBox.IsEnabled = enabled; @@ -864,26 +2439,130 @@ private void SetFormEnabled(bool enabled) AlertDeliveryOverrideComboBox.IsEnabled = enabled; DescriptionTextBox.IsEnabled = enabled; TestConnectionButton.IsEnabled = enabled; + /* + Check for Updates re-runs DetectDatabaseStatusAsync, which transitions back to a Connected_* + state -- re-showing the install button and collapsing the running install's progress panel. + Leaving it live during Installing let a second click start a concurrent install against the + same database, clobbering _installResult and leaving Cancel pointing at the wrong run. + */ + CheckForUpdatesButton.IsEnabled = enabled; SaveButton.IsEnabled = enabled; } private async void Save_Click(object sender, RoutedEventArgs e) + { + /* + Save is re-enterable. RunConnectionTestAsync(SaveButton) disables Save for the duration -- but + Test Connection stays live, and ITS finally re-enables Save (it re-enables the form wholesale, + not just the button it took). Click Save, click Test Connection, click Save again: both tests + complete, the first sets DialogResult and closes the window, and the second assigns DialogResult + to a CLOSED window, which throws. + + It does NOT take the app down -- App.OnDispatcherUnhandledException handles it (App.xaml.cs:173) + -- so an earlier version of this comment overstated it. What the user gets is an alarming + "An error occurred" box and a server that was silently not saved. Pre-existing; the epoch guard + below happens to close every variant where a detect or install intervenes, but not this one. + (The close-during-save variant is handled separately, by _dialogClosed.) + */ + if (_saveInProgress) return; + _saveInProgress = true; + + try + { + await SaveAsync(); + } + finally + { + _saveInProgress = false; + } + } + + private bool _saveInProgress; + + private async Task SaveAsync() { if (!ValidateInputs()) return; - /* If we just finished installing, skip re-testing the connection */ - bool skipConnectionTest = _currentState == DialogState.InstallComplete || - _currentState == DialogState.MonitoringCredentials; + /* + "We just installed, so the connection is proven" is only true if the box still names the server + we installed to. Keyed on _currentState alone, retyping the box after an install skipped the + test entirely: the new name was persisted un-contacted, and under SQL auth the INSTALLER's + credentials -- routinely sa -- were saved as the ongoing monitoring account for a server nobody + had ever connected to. The stamp is what makes the claim true, so test the stamp. + */ + bool skipConnectionTest = + (_currentState == DialogState.InstallComplete || + _currentState == DialogState.MonitoringCredentials) && + VerdictMatchesServerBox(); if (!skipConnectionTest) { - var (connected, errorMessage, mfaCancelled, _) = await RunConnectionTestAsync(SaveButton); + /* + An install can start AND FINISH while this connection test is in flight (InstallUpgradeButton + stays hit-testable -- SetFormEnabled does not touch it). InstallInProgress alone is the + half of that test that un-fires: it is only read when this continuation resumes, so a run + that completed in the meantime reads as "no install", and we fall through to DialogResult + and Close() -- destroying the dialog the instant the install lands, before the SQL-auth user + is ever shown the monitoring-credentials panel, and persisting the installer account in its + place. The epoch cannot un-fire; InstallOrUpgrade_Click bumps it on every start. + + It also covers the retype: TextChanged bumps the epoch too, so a box edited while this test + was in flight abandons the save rather than persisting the new name on the strength of a + connection proven against the old one. + */ + int epoch = _detectEpoch; + + var (connected, errorMessage, mfaCancelled) = await RunConnectionTestAsync(SaveButton); + + if (InstallInProgress) return; + + /* + The dialog was CLOSED while this test was running -- ESC, Cancel, the X. Bail here, before + anything is written, because in edit mode ServerConnection is not a copy: it is the very + object ServerManager holds in its list (MainWindow hands us item.Server directly). The + assignments below mutate it IN PLACE, so a save the user cancelled still renames their + monitored server -- and it reaches disk on its own, the next time UpdateLastConnected fires + for ANY server. PROD01 quietly becomes TEST99, keeps PROD01's id and credential, and PROD01 + stops being monitored. + + This guard used to sit 47 lines lower, next to the DialogResult write. That stopped the + THROW and not the WRITE -- and the throw was the only thing the user ever saw, so moving it + down there turned a loud bug into a silent one. Guard before you commit the facts; it is the + same rule the detection path already follows. + + One check covers everything: RunConnectionTestAsync is the only await in this method (the + skipConnectionTest path has none), so this is the only window in which _dialogClosed can + flip. It also kills the orphaned "Do you still want to save this connection?" box below, + which was popping up with no dialog left behind it. + */ + if (_dialogClosed) return; + + if (epoch != _detectEpoch) + { + /* + SAY SO. An earlier version of this comment claimed every epoch bump has a visible cause, + so the abandoned save could not be silent. That was wrong: from Initial -- a fresh dialog, + which is the most common way to reach Save -- a retype renders nothing at all, and this + return then swallowed the click. The user fixes a typo while the 10-second connection test + is running, clicks nothing else, and believes the server was added. + + Worded for all four bumpers, not just the retype. Check for Updates and "Skip, just add + server" bump the epoch too, and blaming "the server details changed" for a change the + user never made is its own small lie. + */ + StatusText.Text = + "Nothing was saved -- something changed while the connection was being tested. " + + "Click Save again."; + StatusText.Visibility = Visibility.Visible; + return; + } if (!connected) { if (mfaCancelled) { MessageBox.Show( + this, "Authentication was cancelled. Click Save to try again, or Cancel to abort.", "Authentication Cancelled", MessageBoxButton.OK, @@ -894,6 +2573,7 @@ private async void Save_Click(object sender, RoutedEventArgs e) var detail = errorMessage != null ? $"\n\nError: {errorMessage}" : string.Empty; var result = MessageBox.Show( + this, $"Could not connect to {ServerNameTextBox.Text}.{detail}\n\n" + "Do you still want to save this connection?", "Connection Failed", @@ -908,6 +2588,8 @@ private async void Save_Click(object sender, RoutedEventArgs e) // Determine authentication type and credentials string authenticationType; + string? azureClientId = null; + string? managedIdentityClientId = null; if (WindowsAuthRadio.IsChecked == true) { authenticationType = AuthenticationTypes.Windows; @@ -920,13 +2602,40 @@ private async void Save_Click(object sender, RoutedEventArgs e) Username = EntraMfaUsernameBox.Text.Trim(); Password = null; } + else if (ServicePrincipalAuthRadio.IsChecked == true) + { + authenticationType = AuthenticationTypes.ServicePrincipal; + // Surface the client id as the Username and the secret as the Password so the existing + // ServerManager credential-save path persists them (client id + client secret). + azureClientId = ServicePrincipalClientIdBox.Text.Trim(); + Username = azureClientId; + Password = ServicePrincipalSecretBox.Password; + } + else if (ManagedIdentityAuthRadio.IsChecked == true) + { + authenticationType = AuthenticationTypes.ManagedIdentity; + managedIdentityClientId = string.IsNullOrWhiteSpace(ManagedIdentityClientIdBox.Text) + ? null + : ManagedIdentityClientIdBox.Text.Trim(); + // No credential to store for managed identity. + Username = null; + Password = null; + } else { authenticationType = AuthenticationTypes.SqlServer; - /* Use monitoring credentials if provided */ - if (_currentState == DialogState.MonitoringCredentials && - UseSameCredsCheckBox.IsChecked == false && + /* + Use the monitoring credentials if the user actually entered them. + + Deliberately NOT gated on _currentState: the repair handoff puts a live "Upgrade Now" + button in front of MonitoringCredentials, which used to be terminal. Any exit from the + install that button starts (abort, cancel, fatal, version block) transitions away, and + the typed monitoring login would then be silently discarded and the INSTALLER account -- + typically sysadmin -- persisted as the ongoing monitoring credential instead. Keying on + what was entered rather than on a transient state cannot drift that way. + */ + if (UseSameCredsCheckBox.IsChecked == false && !string.IsNullOrWhiteSpace(MonitorUsernameTextBox.Text)) { Username = MonitorUsernameTextBox.Text.Trim(); @@ -944,11 +2653,39 @@ private async void Save_Click(object sender, RoutedEventArgs e) ? ServerNameTextBox.Text.Trim() : DisplayNameTextBox.Text.Trim(); + /* + THE guard, immediately before the commit, with nothing between. + + There is one further up, straight after the await, and it is not enough -- because "await is the + only place this method yields" is FALSE. MessageBox.Show pumps a nested Win32 message loop, and + the "Do you still want to save this connection?" box above sits between that guard and these + assignments. + + Every box in this file now passes `this` as its owner, so Win32 disables the dialog while one is + up -- which is what closes the concrete route. This guard stays anyway, and not out of caution: + a dispatcher work item or an application shutdown can still close a window during a nested pump, + and an already-closed window has no HWND to own the next box with. The whole reason the previous + fix was wrong is that it reasoned about which re-entrancy windows exist instead of guarding where + the damage happens. Do not repeat that by deleting this. + + And these are not writes to a copy. In edit mode ServerConnection IS the object ServerManager + holds -- MainWindow hands us item.Server -- so they rename the user's live, monitored server in + place, and it reaches disk on its own the next time UpdateLastConnected fires for anyone. + + The previous fix put the guard after the await and reasoned that nothing could re-enter before + the commit. That reasoning leaned on a modal box happening to disable the window, which is the + same "unreachable because something else happens to be disabled" substitution this file has been + bitten by over and over. Do not reason about re-entrancy windows. Guard where the damage is. + */ + if (_dialogClosed) return; + if (_isEditMode) { ServerConnection.DisplayName = displayName; ServerConnection.ServerName = ServerNameTextBox.Text.Trim(); ServerConnection.AuthenticationType = authenticationType; + ServerConnection.AzureClientId = azureClientId; + ServerConnection.ManagedIdentityClientId = managedIdentityClientId; ServerConnection.Description = DescriptionTextBox.Text.Trim(); ServerConnection.IsFavorite = IsFavoriteCheckBox.IsChecked == true; ServerConnection.EncryptMode = GetSelectedEncryptMode(); @@ -970,6 +2707,8 @@ private async void Save_Click(object sender, RoutedEventArgs e) DisplayName = displayName, ServerName = ServerNameTextBox.Text.Trim(), AuthenticationType = authenticationType, + AzureClientId = azureClientId, + ManagedIdentityClientId = managedIdentityClientId, Description = DescriptionTextBox.Text.Trim(), IsFavorite = IsFavoriteCheckBox.IsChecked == true, CreatedDate = DateTime.Now, @@ -983,6 +2722,12 @@ private async void Save_Click(object sender, RoutedEventArgs e) }; } + /* + The user closed the dialog while this save's connection test was still running -- ESC, Cancel, + the X, Alt+F4. The window is gone; assigning DialogResult to it throws. Nothing to save to. + */ + if (_dialogClosed) return; + DialogResult = true; Close(); } diff --git a/Dashboard/Analysis/AnalysisService.cs b/Dashboard/Analysis/AnalysisService.cs index 2558ffca9..5661ee05c 100644 --- a/Dashboard/Analysis/AnalysisService.cs +++ b/Dashboard/Analysis/AnalysisService.cs @@ -197,6 +197,13 @@ public async Task<List<AnalysisFinding>> AnalyzeAsync(AnalysisContext context) var incidents = _engine.ClusterIntoIncidents(stories, facts); IncidentId.StampClusters(context.ServerName, incidents); + // 3.7. Fold each ANOMALY_* story into the REGULAR finding that describes the same symptom + // (same run, same database) by rewriting its stamped incident id onto that parent's — so + // the anomaly stops rendering as its own card / its own email. No-parent anomalies stay + // solo; db-scoped object anomalies never cross databases. Presentation-only: nothing is + // dropped, only the incident tag is reconciled. + AnomalyIncidentReconciler.Reconcile(stories); + // 4. Mute-filter the stories into the surviving findings (P2 reorder) — WITHOUT // inserting yet, so enrichment + action-build happen on the survivors first // and the BUILT RemediationAction is persisted on each row (D2). Muted/ diff --git a/Dashboard/Analysis/SqlServerAnomalyDetector.cs b/Dashboard/Analysis/SqlServerAnomalyDetector.cs index 78d5493dd..b0bfc0076 100644 --- a/Dashboard/Analysis/SqlServerAnomalyDetector.cs +++ b/Dashboard/Analysis/SqlServerAnomalyDetector.cs @@ -33,15 +33,48 @@ public class SqlServerAnomalyDetector private const double DefaultDeviationThreshold = 2.0; /// <summary> - /// Default ratio threshold for rate-based anomaly detection (wait stats). + /// Default ratio threshold for the wait-profile detector (peak window all-types ms/sec ÷ baseline + /// mean). On the HONEST per-second scale now, so far below the old 5.0 that assumed a ~240x-inflated + /// input; matches the FactScorer WaitProfileRatioFloor. CALIBRATE ON THE SQL2025/HAMMERDB BOX. /// </summary> - private const double DefaultRatioThreshold = 5.0; + private const double DefaultRatioThreshold = 4.0; /// <summary> /// Default ratio threshold for event-based anomaly detection (blocking/deadlocks). /// </summary> private const double DefaultEventRatioThreshold = 3.0; + // #1486 absolute-magnitude floors (the z-path sanity ceiling) so a z-score against a thin + // baseline can't surface a trivial value; sigma display cap so a variance-collapsed baseline + // can't render millions-of-sigma. + private const double CpuFloorPct = 50.0; // % + private const double ReadLatencyFloorMs = 10.0; // ms + private const double BatchRequestFloor = 500.0; // requests/sec + private const double SessionCountFloor = 50.0; // connections + private const double QueryDurationFloorUs = 1_000_000; // total elapsed us = 1 second + private const double MemoryPressureFloorPct = 90.0; // total/target % + private const double WriteLatencyFloorMs = 20.0; // ms, was 5 + private const double SigmaDisplayCap = 25.0; + + // Low-quality-baseline ABSOLUTE-FALLBACK bars: when the baseline is too thin to trust a z-score + // (BaselineBucket.IsTrustworthy false), the detector fires on these instead of going silent. + // Each is deliberately HIGHER than the matching #1486 magnitude floor above (the interaction + // trap: a young store fires only on the higher bar, never on both-AND-ed into blindness). + private const double CpuFallbackPct = 90.0; // % + private const double MemoryPressureFallbackPct = 95.0; // total/target % + private const double BatchRequestFallback = 5000.0; // requests/sec + private const double SessionCountFallback = 500.0; // connections + private const double QueryDurationFallbackUs = 5_000_000; // total elapsed us = 5 seconds + private const double IoLatencyFallbackMs = 50.0; // ms (read and write) + + // Wait-profile detector (DetectWaitAnomalies → one ANOMALY_WAIT_PROFILE): the current window's + // all-types wait ms/sec (PEAK across collections, matching the z-detectors) is compared to the + // WaitMsPerSec baseline. DefaultRatioThreshold and the FactScorer wait slope are on the HONEST + // per-second scale now (the old 5×/20× was calibrated to a ~240×-inflated per-hour-vs-per-interval + // input) — a sensible starting point; CALIBRATE ON THE SQL2025/HAMMERDB BOX. + private const double WaitProfileFallbackMsPerSec = 250.0; // untrustworthy-baseline absolute bar + private const double NoBaselineRatio = 100.0; // scoring sentinel for a first-occurrence (is_new) + /// <summary> /// Per-metric deviation thresholds. Metrics not listed use DefaultDeviationThreshold. /// </summary> @@ -399,8 +432,9 @@ private async Task DetectCpuAnomalies(AnalysisContext context, List<Fact> anomal SqlServerMetricNames.Cpu, context.TimeRangeStart); if (baseline.SampleCount == 0) return; + // No effectiveStdDev<=0 early return — an untrustworthy/zero-dispersion baseline falls + // back to the absolute bar (below) rather than going silent. var effectiveStdDev = baseline.EffectiveStdDev; - if (effectiveStdDev <= 0) return; using var connection = new SqlConnection(_connectionString); await connection.OpenAsync(); @@ -433,8 +467,10 @@ FROM collect.cpu_utilization_stats if (windowSamples == 0) return; - var deviation = (peakCpu - baseline.Mean) / effectiveStdDev; - if (deviation < GetDeviationThreshold(SqlServerMetricNames.Cpu) || peakCpu < 50) return; + var decision = AnomalyGate.EvaluateZScore( + baseline.Mean, effectiveStdDev, baseline.IsTrustworthy, peakCpu, + GetDeviationThreshold(SqlServerMetricNames.Cpu), CpuFloorPct, CpuFallbackPct, SigmaDisplayCap); + if (!decision.Fire) return; var metadata = new Dictionary<string, double> { @@ -442,7 +478,9 @@ FROM collect.cpu_utilization_stats ["avg_cpu_in_window"] = avgCpu, ["baseline_mean"] = baseline.Mean, ["baseline_stddev"] = effectiveStdDev, - ["deviation_sigma"] = deviation, + ["deviation_sigma"] = decision.Sigma, + ["baseline_low_quality"] = decision.LowQualityBaseline ? 1 : 0, + ["fallback_exceedance"] = decision.FallbackExceedance, ["baseline_samples"] = baseline.SampleCount, ["window_samples"] = windowSamples, ["confidence"] = 1.0, @@ -466,84 +504,120 @@ FROM collect.cpu_utilization_stats } /// <summary> - /// Detects wait stat anomalies — total wait time significantly above - /// baseline rate for this time bucket. Uses ratio-based scoring. + /// Detects a shift in the wait PROFILE — the whole-server all-types wait rate (ms/sec) running + /// significantly above its time-bucketed baseline — and emits ONE ANOMALY_WAIT_PROFILE fact with + /// the top wait types as contrib_<TYPE> metadata. Replaces the old per-type + /// ANOMALY_WAIT_<type> facts, which compared a per-hour per-type value to a per-interval + /// all-types baseline (a ~240x unit inflation) and missed a minority-but-real wait. Comparing + /// all-types-vs-all-types on the honest per-second scale fixes units, aggregation, and the + /// per-type cascade together. /// </summary> private async Task DetectWaitAnomalies(AnalysisContext context, List<Fact> anomalies) { try { var baseline = await _baselineProvider.GetBaselineAsync( - SqlServerMetricNames.WaitStats, context.TimeRangeStart); - - if (baseline.SampleCount == 0) return; + SqlServerMetricNames.WaitMsPerSec, context.TimeRangeStart); using var connection = new SqlConnection(_connectionString); await connection.OpenAsync(); - using var cmd = connection.CreateCommand(); - cmd.CommandText = @" + // Current window: all-types wait ms/sec per collection (interval via LAG, never an assumed + // cadence — mirrors the WaitMsPerSec baseline), then PEAK across collections. + double peakRate; + double totalWaitMs; + long collectionCount; + using (var rateCmd = connection.CreateCommand()) + { + rateCmd.CommandText = @" SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; -SELECT TOP 10 - wait_type, - CAST(SUM(wait_time_ms_delta) AS BIGINT) AS total_ms -FROM collect.wait_stats -WHERE collection_time >= @windowStart AND collection_time <= @windowEnd -AND wait_time_ms_delta > 0 -GROUP BY wait_type -HAVING SUM(wait_time_ms_delta) > 10000 -ORDER BY total_ms DESC;"; - - cmd.Parameters.Add(new SqlParameter("@windowStart", context.TimeRangeStart)); - cmd.Parameters.Add(new SqlParameter("@windowEnd", context.TimeRangeEnd)); - - var currentHours = (context.TimeRangeEnd - context.TimeRangeStart).TotalHours; - if (currentHours <= 0) currentHours = 1; +;WITH per_collection AS ( + SELECT collection_time, + CAST(SUM(wait_time_ms_delta) AS FLOAT) AS total_wait_ms, + DATEDIFF(SECOND, LAG(collection_time) OVER (ORDER BY collection_time), collection_time) AS interval_sec + FROM collect.wait_stats + WHERE collection_time >= @windowStart AND collection_time < @windowEnd + AND wait_time_ms_delta >= 0 + GROUP BY collection_time +) +SELECT MAX(CASE WHEN interval_sec > 0 THEN total_wait_ms / interval_sec ELSE 0 END) AS peak_ms_per_sec, + SUM(total_wait_ms) AS total_wait_ms, + SUM(CASE WHEN interval_sec IS NOT NULL THEN 1 ELSE 0 END) AS sample_count +FROM per_collection;"; + rateCmd.Parameters.Add(new SqlParameter("@windowStart", context.TimeRangeStart)); + rateCmd.Parameters.Add(new SqlParameter("@windowEnd", context.TimeRangeEnd)); + + using var rateReader = await rateCmd.ExecuteReaderAsync(); + if (!await rateReader.ReadAsync()) return; + peakRate = rateReader.IsDBNull(0) ? 0.0 : Convert.ToDouble(rateReader.GetValue(0)); + totalWaitMs = rateReader.IsDBNull(1) ? 0.0 : Convert.ToDouble(rateReader.GetValue(1)); + collectionCount = rateReader.IsDBNull(2) ? 0L : Convert.ToInt64(rateReader.GetValue(2)); + } - var baselineRate = baseline.SampleCount > 0 ? baseline.Mean : 0; + if (collectionCount == 0) return; // no rated collection in the window - using var reader = await cmd.ExecuteReaderAsync(); - while (await reader.ReadAsync()) + // Trustworthy baseline → honest per-second ratio; else fall back to the absolute peak-rate + // bar (NOT silence) so a genuinely heavy profile still surfaces on a young store (is_new). + bool isNew; + double ratio; + if (baseline.IsTrustworthy && baseline.Mean > 0) { - var waitType = reader.GetString(0); - var currentMs = Convert.ToInt64(reader.GetValue(1)); - var currentRate = currentMs / currentHours; + isNew = false; + ratio = peakRate / baseline.Mean; + } + else + { + isNew = true; + ratio = peakRate >= WaitProfileFallbackMsPerSec ? NoBaselineRatio : 0; + } - double ratio; - string anomalyType; + if (ratio < DefaultRatioThreshold) return; - if (baselineRate <= 0 || baseline.SampleCount == 0) - { - ratio = currentMs > 60_000 ? 100.0 : 0; - anomalyType = "new"; - } - else - { - ratio = currentRate / baselineRate; - anomalyType = "spike"; - } + var metadata = new Dictionary<string, double> + { + ["current_ms_per_sec"] = peakRate, + ["baseline_mean"] = baseline.Mean, + ["total_wait_ms"] = totalWaitMs, + ["ratio"] = ratio, + ["is_new"] = isNew ? 1 : 0 + }; + AddBaselineContext(metadata, baseline); - if (ratio < DefaultRatioThreshold) continue; + // Top 6 contributors — named in the metadata KEY (a Dictionary<string,double> can't hold + // the type name in the value), value = the type's total wait ms in the window. + using (var contribCmd = connection.CreateCommand()) + { + contribCmd.CommandText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - var metadata = new Dictionary<string, double> - { - ["current_ms"] = currentMs, - ["baseline_mean"] = baseline.Mean, - ["ratio"] = ratio, - ["is_new"] = anomalyType == "new" ? 1 : 0 - }; - AddBaselineContext(metadata, baseline); +SELECT TOP 6 + wait_type, + CAST(SUM(wait_time_ms_delta) AS BIGINT) AS total_ms +FROM collect.wait_stats +WHERE collection_time >= @windowStart AND collection_time < @windowEnd +AND wait_time_ms_delta > 0 +GROUP BY wait_type +ORDER BY total_ms DESC;"; + contribCmd.Parameters.Add(new SqlParameter("@windowStart", context.TimeRangeStart)); + contribCmd.Parameters.Add(new SqlParameter("@windowEnd", context.TimeRangeEnd)); - anomalies.Add(new Fact + using var contribReader = await contribCmd.ExecuteReaderAsync(); + while (await contribReader.ReadAsync()) { - Source = "anomaly", - Key = $"ANOMALY_WAIT_{waitType}", - Value = currentMs, - ServerId = context.ServerId, - Metadata = metadata - }); + var waitType = contribReader.GetString(0); + metadata[$"contrib_{waitType}"] = Convert.ToDouble(contribReader.GetValue(1)); + } } + + anomalies.Add(new Fact + { + Source = "anomaly", + Key = "ANOMALY_WAIT_PROFILE", + Value = totalWaitMs, + ServerId = context.ServerId, + Metadata = metadata + }); } catch (Exception ex) { @@ -595,18 +669,26 @@ so normalize them to per-hour before the ratio — otherwise the ratio scales wi var currentBlockingPerHour = currentBlocking / windowHours; var currentDeadlocksPerHour = currentDeadlocks / windowHours; - // Baseline mean = events per hour for this hour+dow bucket + // Baseline mean = events per hour for this hour+dow bucket. Gate on IsTrustworthy (not just + // SampleCount>0): a thin/zero-history baseline falls back to the absolute event count rather + // than an inflated ratio. is_new marks that fallback so the composer renders it honestly as + // a first occurrence — never the dishonest "spiked to 100×" the sentinel used to render. + var blockingTrust = blockingBaseline.IsTrustworthy; + var deadlockTrust = deadlockBaseline.IsTrustworthy; var baselineBlockingRate = blockingBaseline.SampleCount > 0 ? blockingBaseline.Mean : 0; var baselineDeadlockRate = deadlockBaseline.SampleCount > 0 ? deadlockBaseline.Mean : 0; - // Blocking spike: at least 5 events in the window AND per-hour rate >= 3x baseline (or no baseline) - if (currentBlocking >= 5 && (baselineBlockingRate <= 0 || currentBlockingPerHour / Math.Max(baselineBlockingRate, 1) >= DefaultEventRatioThreshold)) + // Blocking spike: at least 5 events in the window AND (trustworthy → per-hour rate >= 3x + // baseline; untrustworthy → fire on the count alone). + if (currentBlocking >= 5 && (!blockingTrust || currentBlockingPerHour / Math.Max(baselineBlockingRate, 1) >= DefaultEventRatioThreshold)) { + var isNew = !blockingTrust; var metadata = new Dictionary<string, double> { ["current_count"] = currentBlocking, ["baseline_rate"] = baselineBlockingRate, - ["ratio"] = baselineBlockingRate > 0 ? currentBlockingPerHour / baselineBlockingRate : 100.0 + ["ratio"] = isNew ? NoBaselineRatio : currentBlockingPerHour / baselineBlockingRate, + ["is_new"] = isNew ? 1 : 0 }; AddBaselineContext(metadata, blockingBaseline); @@ -620,14 +702,17 @@ so normalize them to per-hour before the ratio — otherwise the ratio scales wi }); } - // Deadlock spike: at least 3 events in the window AND per-hour rate >= 3x baseline (or no baseline) - if (currentDeadlocks >= 3 && (baselineDeadlockRate <= 0 || currentDeadlocksPerHour / Math.Max(baselineDeadlockRate, 1) >= DefaultEventRatioThreshold)) + // Deadlock spike: at least 3 events in the window AND (trustworthy → per-hour rate >= 3x + // baseline; untrustworthy → fire on the count alone). + if (currentDeadlocks >= 3 && (!deadlockTrust || currentDeadlocksPerHour / Math.Max(baselineDeadlockRate, 1) >= DefaultEventRatioThreshold)) { + var isNew = !deadlockTrust; var metadata = new Dictionary<string, double> { ["current_count"] = currentDeadlocks, ["baseline_rate"] = baselineDeadlockRate, - ["ratio"] = baselineDeadlockRate > 0 ? currentDeadlocksPerHour / baselineDeadlockRate : 100.0 + ["ratio"] = isNew ? NoBaselineRatio : currentDeadlocksPerHour / baselineDeadlockRate, + ["is_new"] = isNew ? 1 : 0 }; AddBaselineContext(metadata, deadlockBaseline); @@ -659,7 +744,6 @@ private async Task DetectIoAnomalies(AnalysisContext context, List<Fact> anomali if (baseline.SampleCount == 0) return; var effectiveStdDev = baseline.EffectiveStdDev; - if (effectiveStdDev <= 0) return; using var connection = new SqlConnection(_connectionString); await connection.OpenAsync(); @@ -687,57 +771,59 @@ FROM collect.file_io_stats var ioThreshold = GetDeviationThreshold(SqlServerMetricNames.IoLatency); // Read latency anomaly - if (currentReadLat > 10) + var readDecision = AnomalyGate.EvaluateZScore( + baseline.Mean, effectiveStdDev, baseline.IsTrustworthy, currentReadLat, + ioThreshold, ReadLatencyFloorMs, IoLatencyFallbackMs, SigmaDisplayCap); + if (readDecision.Fire) { - var readDeviation = (currentReadLat - baseline.Mean) / effectiveStdDev; - if (readDeviation >= ioThreshold) + var metadata = new Dictionary<string, double> { - var metadata = new Dictionary<string, double> - { - ["current_latency_ms"] = currentReadLat, - ["baseline_mean_ms"] = baseline.Mean, - ["baseline_stddev_ms"] = effectiveStdDev, - ["deviation_sigma"] = readDeviation, - ["baseline_samples"] = baseline.SampleCount - }; - AddBaselineContext(metadata, baseline); + ["current_latency_ms"] = currentReadLat, + ["baseline_mean_ms"] = baseline.Mean, + ["baseline_stddev_ms"] = effectiveStdDev, + ["deviation_sigma"] = readDecision.Sigma, + ["baseline_low_quality"] = readDecision.LowQualityBaseline ? 1 : 0, + ["fallback_exceedance"] = readDecision.FallbackExceedance, + ["baseline_samples"] = baseline.SampleCount + }; + AddBaselineContext(metadata, baseline); - anomalies.Add(new Fact - { - Source = "anomaly", - Key = "ANOMALY_READ_LATENCY", - Value = currentReadLat, - ServerId = context.ServerId, - Metadata = metadata - }); - } + anomalies.Add(new Fact + { + Source = "anomaly", + Key = "ANOMALY_READ_LATENCY", + Value = currentReadLat, + ServerId = context.ServerId, + Metadata = metadata + }); } // Write latency anomaly - if (currentWriteLat > 5) + var writeDecision = AnomalyGate.EvaluateZScore( + baseline.Mean, effectiveStdDev, baseline.IsTrustworthy, currentWriteLat, + ioThreshold, WriteLatencyFloorMs, IoLatencyFallbackMs, SigmaDisplayCap); + if (writeDecision.Fire) { - var writeDeviation = (currentWriteLat - baseline.Mean) / effectiveStdDev; - if (writeDeviation >= ioThreshold) + var metadata = new Dictionary<string, double> { - var metadata = new Dictionary<string, double> - { - ["current_latency_ms"] = currentWriteLat, - ["baseline_mean_ms"] = baseline.Mean, - ["baseline_stddev_ms"] = effectiveStdDev, - ["deviation_sigma"] = writeDeviation, - ["baseline_samples"] = baseline.SampleCount - }; - AddBaselineContext(metadata, baseline); + ["current_latency_ms"] = currentWriteLat, + ["baseline_mean_ms"] = baseline.Mean, + ["baseline_stddev_ms"] = effectiveStdDev, + ["deviation_sigma"] = writeDecision.Sigma, + ["baseline_low_quality"] = writeDecision.LowQualityBaseline ? 1 : 0, + ["fallback_exceedance"] = writeDecision.FallbackExceedance, + ["baseline_samples"] = baseline.SampleCount + }; + AddBaselineContext(metadata, baseline); - anomalies.Add(new Fact - { - Source = "anomaly", - Key = "ANOMALY_WRITE_LATENCY", - Value = currentWriteLat, - ServerId = context.ServerId, - Metadata = metadata - }); - } + anomalies.Add(new Fact + { + Source = "anomaly", + Key = "ANOMALY_WRITE_LATENCY", + Value = currentWriteLat, + ServerId = context.ServerId, + Metadata = metadata + }); } } catch (Exception ex) @@ -758,7 +844,6 @@ private async Task DetectBatchRequestAnomalies(AnalysisContext context, List<Fac if (baseline.SampleCount == 0) return; var effectiveStdDev = baseline.EffectiveStdDev; - if (effectiveStdDev <= 0) return; using var connection = new SqlConnection(_connectionString); await connection.OpenAsync(); @@ -788,8 +873,10 @@ FROM collect.perfmon_stats if (windowSamples == 0) return; - var deviation = (peakBatch - baseline.Mean) / effectiveStdDev; - if (deviation < GetDeviationThreshold(SqlServerMetricNames.BatchRequests)) return; + var decision = AnomalyGate.EvaluateZScore( + baseline.Mean, effectiveStdDev, baseline.IsTrustworthy, peakBatch, + GetDeviationThreshold(SqlServerMetricNames.BatchRequests), BatchRequestFloor, BatchRequestFallback, SigmaDisplayCap); + if (!decision.Fire) return; var metadata = new Dictionary<string, double> { @@ -797,7 +884,9 @@ FROM collect.perfmon_stats ["avg_batch_requests"] = avgBatch, ["baseline_mean"] = baseline.Mean, ["baseline_stddev"] = effectiveStdDev, - ["deviation_sigma"] = deviation, + ["deviation_sigma"] = decision.Sigma, + ["baseline_low_quality"] = decision.LowQualityBaseline ? 1 : 0, + ["fallback_exceedance"] = decision.FallbackExceedance, ["baseline_samples"] = baseline.SampleCount, ["window_samples"] = windowSamples }; @@ -830,7 +919,6 @@ private async Task DetectSessionAnomalies(AnalysisContext context, List<Fact> an if (baseline.SampleCount == 0) return; var effectiveStdDev = baseline.EffectiveStdDev; - if (effectiveStdDev <= 0) return; using var connection = new SqlConnection(_connectionString); await connection.OpenAsync(); @@ -863,8 +951,10 @@ SELECT AVG(CAST(total_connections AS FLOAT)) AS avg_connections, if (windowSamples == 0) return; - var deviation = (peakConnections - baseline.Mean) / effectiveStdDev; - if (deviation < GetDeviationThreshold(SqlServerMetricNames.SessionCount)) return; + var decision = AnomalyGate.EvaluateZScore( + baseline.Mean, effectiveStdDev, baseline.IsTrustworthy, peakConnections, + GetDeviationThreshold(SqlServerMetricNames.SessionCount), SessionCountFloor, SessionCountFallback, SigmaDisplayCap); + if (!decision.Fire) return; var metadata = new Dictionary<string, double> { @@ -872,7 +962,9 @@ SELECT AVG(CAST(total_connections AS FLOAT)) AS avg_connections, ["avg_connections"] = avgConnections, ["baseline_mean"] = baseline.Mean, ["baseline_stddev"] = effectiveStdDev, - ["deviation_sigma"] = deviation, + ["deviation_sigma"] = decision.Sigma, + ["baseline_low_quality"] = decision.LowQualityBaseline ? 1 : 0, + ["fallback_exceedance"] = decision.FallbackExceedance, ["baseline_samples"] = baseline.SampleCount, ["window_samples"] = windowSamples }; @@ -906,7 +998,6 @@ private async Task DetectQueryDurationAnomalies(AnalysisContext context, List<Fa if (baseline.SampleCount == 0) return; var effectiveStdDev = baseline.EffectiveStdDev; - if (effectiveStdDev <= 0) return; using var connection = new SqlConnection(_connectionString); await connection.OpenAsync(); @@ -941,8 +1032,10 @@ SELECT AVG(CAST(total_elapsed AS FLOAT)) AS avg_elapsed, if (windowSamples == 0) return; - var deviation = (peakElapsed - baseline.Mean) / effectiveStdDev; - if (deviation < GetDeviationThreshold(SqlServerMetricNames.QueryDuration)) return; + var decision = AnomalyGate.EvaluateZScore( + baseline.Mean, effectiveStdDev, baseline.IsTrustworthy, peakElapsed, + GetDeviationThreshold(SqlServerMetricNames.QueryDuration), QueryDurationFloorUs, QueryDurationFallbackUs, SigmaDisplayCap); + if (!decision.Fire) return; var metadata = new Dictionary<string, double> { @@ -950,7 +1043,9 @@ SELECT AVG(CAST(total_elapsed AS FLOAT)) AS avg_elapsed, ["avg_total_elapsed_us"] = avgElapsed, ["baseline_mean"] = baseline.Mean, ["baseline_stddev"] = effectiveStdDev, - ["deviation_sigma"] = deviation, + ["deviation_sigma"] = decision.Sigma, + ["baseline_low_quality"] = decision.LowQualityBaseline ? 1 : 0, + ["fallback_exceedance"] = decision.FallbackExceedance, ["baseline_samples"] = baseline.SampleCount, ["window_samples"] = windowSamples }; @@ -985,7 +1080,6 @@ private async Task DetectMemoryAnomalies(AnalysisContext context, List<Fact> ano if (baseline.SampleCount == 0) return; var effectiveStdDev = baseline.EffectiveStdDev; - if (effectiveStdDev <= 0) return; using var connection = new SqlConnection(_connectionString); await connection.OpenAsync(); @@ -1014,8 +1108,10 @@ FROM collect.memory_stats if (windowSamples == 0) return; - var deviation = (peakPressure - baseline.Mean) / effectiveStdDev; - if (deviation < GetDeviationThreshold(SqlServerMetricNames.Memory)) return; + var decision = AnomalyGate.EvaluateZScore( + baseline.Mean, effectiveStdDev, baseline.IsTrustworthy, peakPressure, + GetDeviationThreshold(SqlServerMetricNames.Memory), MemoryPressureFloorPct, MemoryPressureFallbackPct, SigmaDisplayCap); + if (!decision.Fire) return; var metadata = new Dictionary<string, double> { @@ -1023,7 +1119,9 @@ FROM collect.memory_stats ["avg_memory_pressure_pct"] = avgPressure, ["baseline_mean"] = baseline.Mean, ["baseline_stddev"] = effectiveStdDev, - ["deviation_sigma"] = deviation, + ["deviation_sigma"] = decision.Sigma, + ["baseline_low_quality"] = decision.LowQualityBaseline ? 1 : 0, + ["fallback_exceedance"] = decision.FallbackExceedance, ["baseline_samples"] = baseline.SampleCount, ["window_samples"] = windowSamples }; diff --git a/Dashboard/Analysis/SqlServerBaselineProvider.cs b/Dashboard/Analysis/SqlServerBaselineProvider.cs index dcecdad35..5ba2c1c20 100644 --- a/Dashboard/Analysis/SqlServerBaselineProvider.cs +++ b/Dashboard/Analysis/SqlServerBaselineProvider.cs @@ -139,6 +139,7 @@ public async Task<BaselineBucket> GetBaselineAsync(string metricName, DateTime a var query = GetBaselineQuery(metricName); if (query == null) return null; + var absStdDevFloor = AbsStdDevFloorFor(metricName); var windowStart = analysisTime.AddDays(-BaselineWindowDays); try @@ -161,6 +162,7 @@ public async Task<BaselineBucket> GetBaselineAsync(string metricName, DateTime a var mean = reader.IsDBNull(2) ? 0.0 : Convert.ToDouble(reader.GetValue(2)); var stddev = reader.IsDBNull(3) ? 0.0 : Convert.ToDouble(reader.GetValue(3)); var count = reader.IsDBNull(4) ? 0L : Convert.ToInt64(reader.GetValue(4)); + var distinctDays = reader.IsDBNull(5) ? 0L : Convert.ToInt64(reader.GetValue(5)); buckets[(hour, dow)] = new BaselineBucket { @@ -169,6 +171,8 @@ public async Task<BaselineBucket> GetBaselineAsync(string metricName, DateTime a Mean = mean, StdDev = stddev, SampleCount = count, + DistinctDays = distinctDays, + AbsStdDevFloor = absStdDevFloor, // Every bucket here is a full (hour, day-of-week) bucket; the HourOnly/Flat // tiers are assigned only on the collapse/flat paths below. A sparse full // bucket is still Full, just low-sample. (Was a copy-paste of two identical @@ -203,7 +207,8 @@ SELECT DATEPART(HOUR, collection_time) AS hour_of_day, (DATEPART(WEEKDAY, collection_time) + @@DATEFIRST - 1) % 7 AS day_of_week, AVG(CAST(sqlserver_cpu_utilization AS FLOAT)) AS mean_val, STDEV(CAST(sqlserver_cpu_utilization AS FLOAT)) AS stddev_val, - COUNT(*) AS sample_count + COUNT(*) AS sample_count, + COUNT(DISTINCT CAST(collection_time AS DATE)) AS distinct_days FROM collect.cpu_utilization_stats WHERE collection_time >= @windowStart AND collection_time < @windowEnd GROUP BY DATEPART(HOUR, collection_time), @@ -227,7 +232,8 @@ SELECT DATEPART(HOUR, collection_time) AS hour_of_day, (DATEPART(WEEKDAY, collection_time) + @@DATEFIRST - 1) % 7 AS day_of_week, AVG(cntr_value_delta) AS mean_val, STDEV(cntr_value_delta) AS stddev_val, - COUNT(*) AS sample_count + COUNT(*) AS sample_count, + COUNT(DISTINCT CAST(collection_time AS DATE)) AS distinct_days FROM filtered WHERE NOT (cntr_value_delta = 0 AND ISNULL(prev_value, 0) > 1000) GROUP BY DATEPART(HOUR, collection_time), @@ -255,7 +261,8 @@ SELECT DATEPART(HOUR, collection_time) AS hour_of_day, (DATEPART(WEEKDAY, collection_time) + @@DATEFIRST - 1) % 7 AS day_of_week, AVG(CAST(total_wait_ms AS FLOAT)) AS mean_val, STDEV(CAST(total_wait_ms AS FLOAT)) AS stddev_val, - COUNT(*) AS sample_count + COUNT(*) AS sample_count, + COUNT(DISTINCT CAST(collection_time AS DATE)) AS distinct_days FROM with_lag WHERE NOT (total_wait_ms = 0 AND ISNULL(prev_value, 0) > 10000) GROUP BY DATEPART(HOUR, collection_time), @@ -278,7 +285,8 @@ SELECT DATEPART(HOUR, collection_time) AS hour_of_day, (DATEPART(WEEKDAY, collection_time) + @@DATEFIRST - 1) % 7 AS day_of_week, AVG(CAST(total_connections AS FLOAT)) AS mean_val, STDEV(CAST(total_connections AS FLOAT)) AS stddev_val, - COUNT(*) AS sample_count + COUNT(*) AS sample_count, + COUNT(DISTINCT CAST(collection_time AS DATE)) AS distinct_days FROM per_collection GROUP BY DATEPART(HOUR, collection_time), (DATEPART(WEEKDAY, collection_time) + @@DATEFIRST - 1) % 7;", @@ -307,7 +315,8 @@ SELECT DATEPART(HOUR, collection_time) AS hour_of_day, (DATEPART(WEEKDAY, collection_time) + @@DATEFIRST - 1) % 7 AS day_of_week, AVG(CAST(total_elapsed AS FLOAT)) AS mean_val, STDEV(CAST(total_elapsed AS FLOAT)) AS stddev_val, - COUNT(*) AS sample_count + COUNT(*) AS sample_count, + COUNT(DISTINCT CAST(collection_time AS DATE)) AS distinct_days FROM with_lag WHERE NOT (total_elapsed = 0 AND ISNULL(prev_value, 0) > 100000) GROUP BY DATEPART(HOUR, collection_time), @@ -321,7 +330,8 @@ SELECT DATEPART(HOUR, collection_time) AS hour_of_day, (DATEPART(WEEKDAY, collection_time) + @@DATEFIRST - 1) % 7 AS day_of_week, AVG(io_stall_read_ms_delta * 1.0 / NULLIF(num_of_reads_delta, 0)) AS mean_val, STDEV(io_stall_read_ms_delta * 1.0 / NULLIF(num_of_reads_delta, 0)) AS stddev_val, - COUNT(*) AS sample_count + COUNT(*) AS sample_count, + COUNT(DISTINCT CAST(collection_time AS DATE)) AS distinct_days FROM collect.file_io_stats WHERE collection_time >= @windowStart AND collection_time < @windowEnd AND (num_of_reads_delta > 0 OR num_of_writes_delta > 0) @@ -355,7 +365,8 @@ SELECT DATEPART(HOUR, minute_bucket) AS hour_of_day, (DATEPART(WEEKDAY, minute_bucket) + @@DATEFIRST - 1) % 7 AS day_of_week, AVG(event_count) AS mean_val, STDEV(event_count) AS stddev_val, - COUNT(*) AS sample_count + COUNT(*) AS sample_count, + COUNT(DISTINCT CAST(minute_bucket AS DATE)) AS distinct_days FROM per_interval GROUP BY DATEPART(HOUR, minute_bucket), (DATEPART(WEEKDAY, minute_bucket) + @@DATEFIRST - 1) % 7;", @@ -385,7 +396,8 @@ SELECT DATEPART(HOUR, minute_bucket) AS hour_of_day, (DATEPART(WEEKDAY, minute_bucket) + @@DATEFIRST - 1) % 7 AS day_of_week, AVG(event_count) AS mean_val, STDEV(event_count) AS stddev_val, - COUNT(*) AS sample_count + COUNT(*) AS sample_count, + COUNT(DISTINCT CAST(minute_bucket AS DATE)) AS distinct_days FROM per_interval GROUP BY DATEPART(HOUR, minute_bucket), (DATEPART(WEEKDAY, minute_bucket) + @@DATEFIRST - 1) % 7;", @@ -402,10 +414,50 @@ SELECT DATEPART(HOUR, collection_time) AS hour_of_day, (DATEPART(WEEKDAY, collection_time) + @@DATEFIRST - 1) % 7 AS day_of_week, AVG(CAST(total_memory_mb AS FLOAT) / NULLIF(committed_target_memory_mb, 0) * 100) AS mean_val, STDEV(CAST(total_memory_mb AS FLOAT) / NULLIF(committed_target_memory_mb, 0) * 100) AS stddev_val, - COUNT(*) AS sample_count + COUNT(*) AS sample_count, + COUNT(DISTINCT CAST(collection_time AS DATE)) AS distinct_days FROM collect.memory_stats WHERE collection_time >= @windowStart AND collection_time < @windowEnd AND committed_target_memory_mb > 0 +GROUP BY DATEPART(HOUR, collection_time), + (DATEPART(WEEKDAY, collection_time) + @@DATEFIRST - 1) % 7;", + + // ── Chart-unit / all-types wait rate (for the wait-profile anomaly detector) ── + // All-types wait ms per SECOND, the T-SQL analog of Lite/Darling's LAG-interval per-second + // query: aggregate to total wait ms per collection, derive the per-second rate from the + // ACTUAL inter-collection interval via LAG (never an assumed cadence), then exclude the + // restart-signature zero (a 0-rate right after a >100 ms/sec sample) while keeping idle zeros. + SqlServerMetricNames.WaitMsPerSec => @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +;WITH per_collection AS ( + SELECT collection_time, + CAST(SUM(wait_time_ms_delta) AS FLOAT) AS total_wait_ms, + DATEDIFF(SECOND, LAG(collection_time) OVER (ORDER BY collection_time), collection_time) AS interval_sec + FROM collect.wait_stats + WHERE collection_time >= @windowStart AND collection_time < @windowEnd + AND wait_time_ms_delta >= 0 + GROUP BY collection_time +), +with_rate AS ( + SELECT collection_time, + CASE WHEN interval_sec > 0 THEN total_wait_ms / interval_sec ELSE 0 END AS ms_per_sec + FROM per_collection + WHERE interval_sec IS NOT NULL +), +with_lag AS ( + SELECT collection_time, ms_per_sec, + LAG(ms_per_sec) OVER (ORDER BY collection_time) AS prev_rate + FROM with_rate +) +SELECT DATEPART(HOUR, collection_time) AS hour_of_day, + (DATEPART(WEEKDAY, collection_time) + @@DATEFIRST - 1) % 7 AS day_of_week, + AVG(ms_per_sec) AS mean_val, + STDEV(ms_per_sec) AS stddev_val, + COUNT(*) AS sample_count, + COUNT(DISTINCT CAST(collection_time AS DATE)) AS distinct_days +FROM with_lag +WHERE NOT (ms_per_sec = 0 AND ISNULL(prev_rate, 0) > 100) GROUP BY DATEPART(HOUR, collection_time), (DATEPART(WEEKDAY, collection_time) + @@DATEFIRST - 1) % 7;", @@ -413,6 +465,18 @@ GROUP BY DATEPART(HOUR, collection_time), }; } + /// <summary> + /// Bounded-metric absolute dispersion floor (see BaselineBucket.AbsStdDevFloor). Server-relative + /// metrics have no universal floor and return 0. Tunable — calibrate on the SQL2025/HammerDB box. + /// </summary> + private static double AbsStdDevFloorFor(string metricName) => metricName switch + { + SqlServerMetricNames.Cpu => 5.0, // CPU utilization % + SqlServerMetricNames.Memory => 4.0, // memory pressure % + SqlServerMetricNames.IoLatency => 2.5, // I/O latency ms + _ => 0.0, // batch/query-duration/sessions/waits/blocking/deadlock — server-relative + }; + /// <summary> /// Collapses multiple day-of-week buckets for the same hour into a single /// hour-only bucket using pooled statistics. @@ -436,6 +500,10 @@ private static BaselineBucket CollapseToHourOnly(List<BaselineBucket> hourBucket Mean = weightedMean, StdDev = Math.Sqrt(pooledVariance), SampleCount = totalSamples, + // Each calendar day lands in exactly one day-of-week bucket, so summing distinct-days + // across the dow buckets for this hour is exact (no double-count). + DistinctDays = hourBuckets.Sum(b => b.DistinctDays), + AbsStdDevFloor = hourBuckets[0].AbsStdDevFloor, Tier = BaselineTier.HourOnly }; } @@ -459,6 +527,11 @@ private static BaselineBucket CollapseToFlat(List<BaselineBucket> allBuckets) Mean = weightedMean, StdDev = Math.Sqrt(pooledVariance), SampleCount = totalSamples, + // A calendar day recurs across the 24 hour buckets, so summing would double-count; + // MAX is a ~5 ceiling (each (hour, dow) bucket holds at most ~5 same-weekday dates in a + // 30-day window) — a cheap proxy that avoids an extra global DISTINCT-days query. + DistinctDays = allBuckets.Max(b => b.DistinctDays), + AbsStdDevFloor = allBuckets[0].AbsStdDevFloor, Tier = BaselineTier.Flat }; } @@ -505,23 +578,78 @@ public class BaselineBucket public long SampleCount { get; init; } public BaselineTier Tier { get; init; } + /// <summary> + /// Distinct calendar days observed in this bucket — the baseline-QUALITY signal the old + /// quantity-only warmup gate lacked (a bucket with many samples but few distinct days is one + /// busy day, not a trend). Carried through collapse by CollapseToHourOnly (SUM — each calendar + /// day lands in exactly one day-of-week bucket, so the sum is exact) and CollapseToFlat (MAX — + /// a calendar day recurs across the 24 hour buckets; MAX is a ~5 ceiling, not the true pooled + /// distinct-day count, but a cheap proxy that avoids a second global query). + /// </summary> + public long DistinctDays { get; init; } + + /// <summary> + /// Absolute dispersion floor for BOUNDED metrics (CPU %, memory %, I/O ms) so a + /// variance-collapsed baseline can't manufacture a giant z-score. 0 for server-relative + /// metrics (batch/query-duration/sessions/waits/blocking), which have no universal floor and + /// rely on the detector magnitude floors + the quality gate instead. Set per metric by the provider. + /// </summary> + public double AbsStdDevFloor { get; init; } + + // Baseline-quality tier gates (see IsTrustworthy). Day-mins are tier-aware: a Full (hour × dow) + // bucket only sees ~5 same-weekday dates in a 30-day window, so its day-min is a modest 3. The Flat + // tier's DistinctDays is a MAX-over-hour-buckets proxy (CollapseToFlat) capped at that SAME ~5 + // ceiling, so it can't demand more than a Full bucket — a >=15 floor was structurally unreachable and + // left the Flat trust branch permanently dead, so match Full at 3. Sample-mins mirror the provider's + // per-tier selection floors. + private const long FullSampleMin = 10; + private const long FullDayMin = 3; + private const long HourOnlySampleMin = 10; + private const long HourOnlyDayMin = 10; + private const long FlatSampleMin = 3; + private const long FlatDayMin = 3; + public static BaselineBucket Empty => new() { HourOfDay = -1, DayOfWeek = -1, Mean = 0, StdDev = 0, - SampleCount = 0, Tier = BaselineTier.Flat + SampleCount = 0, DistinctDays = 0, Tier = BaselineTier.Flat }; /// <summary> - /// Returns the effective stddev with a proportional minimum floor to prevent - /// division-by-zero in z-score calculations. When both mean and stddev are 0 - /// (zero activity), returns 0 — callers should skip scoring. + /// Returns the effective stddev with a proportional minimum floor plus, for bounded metrics, + /// an absolute floor — both prevent division-by-zero AND a variance-collapsed baseline from + /// producing a giant z-score. When both mean and stddev are 0 (zero activity), returns 0 — + /// callers should skip scoring (or fall back to the absolute-threshold path). /// </summary> public double EffectiveStdDev { get { if (Mean == 0 && StdDev <= 0) return 0; // Zero activity — skip scoring - return Math.Max(StdDev, Mean * 0.01); + return Math.Max(Math.Max(StdDev, Mean * 0.01), AbsStdDevFloor); + } + } + + /// <summary> + /// Whether this baseline is dense enough to trust a z-score / ratio against. Requires real + /// dispersion, the tier's sample floor, AND enough DISTINCT days. A low-quality baseline is NOT + /// silenced — the detector falls back to an absolute-threshold bar instead. This gate and the + /// #1486 magnitude floors are COMPLEMENTARY, not both-mandatory: a trustworthy baseline trusts z + /// with the magnitude floor as a sanity ceiling; an untrustworthy one fires only on the higher + /// absolute bar — they must never AND into blindness on a young store. + /// </summary> + public bool IsTrustworthy + { + get + { + if (EffectiveStdDev <= 0) return false; + var (sampleMin, dayMin) = Tier switch + { + BaselineTier.Full => (FullSampleMin, FullDayMin), + BaselineTier.HourOnly => (HourOnlySampleMin, HourOnlyDayMin), + _ => (FlatSampleMin, FlatDayMin), + }; + return SampleCount >= sampleMin && DistinctDays >= dayMin; } } } @@ -545,4 +673,9 @@ public static class SqlServerMetricNames public const string Blocking = "blocking"; public const string Deadlock = "deadlock"; public const string Memory = "memory"; + + // All-types wait ms/sec — the wait-profile anomaly baseline (the Dashboard analog of + // Lite/Darling MetricNames.WaitMsPerSec). (Dashboard also lacks BlockingPerMinute; add if a + // blocking-per-minute band/detector is ever ported here.) + public const string WaitMsPerSec = "wait_ms_per_sec"; } diff --git a/Dashboard/Analysis/SqlServerFactCollector.cs b/Dashboard/Analysis/SqlServerFactCollector.cs index f9bd9af59..f25aeae4a 100644 --- a/Dashboard/Analysis/SqlServerFactCollector.cs +++ b/Dashboard/Analysis/SqlServerFactCollector.cs @@ -61,15 +61,6 @@ public async Task<List<Fact>> CollectFactsAsync(AnalysisContext context) return facts; } - /// <summary> - /// Collects server hardware properties: CPU count, cores, sockets, memory. - /// Critical context for MAXDOP and memory recommendations. - /// </summary> - /// <summary> - /// Builds the latest-row server_properties SELECT, including the WS5 server-health columns only - /// when the DB has them. Extracted for testability and so a not-yet-upgraded server (no WS5 - /// columns) reads the core hardware columns without a bind error. - /// </summary> // The WS5 server-health column names (compile-time literals — never built from data, so the // probe + SELECT-list construction below are injection-safe). Each is referenced only when it // actually exists on the target DB. diff --git a/Dashboard/Analysis/SqlServerFindingStore.cs b/Dashboard/Analysis/SqlServerFindingStore.cs index 360c11f73..a6867ce9c 100644 --- a/Dashboard/Analysis/SqlServerFindingStore.cs +++ b/Dashboard/Analysis/SqlServerFindingStore.cs @@ -140,34 +140,7 @@ public async Task<List<AnalysisFinding>> FilterMutedFindingsAsync( if (mutedHashes.Contains(story.StoryPathHash)) continue; - survivors.Add(new AnalysisFinding - { - FindingId = _nextId++, - AnalysisTime = analysisTime, - ServerId = context.ServerId, - ServerName = context.ServerName, - // The context window is in the SERVER's local clock (so windowed reads match - // the collectors' SYSDATETIME rows); convert back to UTC for persistence so the - // stored time_range_* stay UTC — the reader's AsUtc, the deep-link offset math, - // and the retention purge all continue to assume UTC. (offset = SYSDATETIME − - // SYSUTCDATETIME, so local − offset = UTC.) - TimeRangeStart = context.TimeRangeStart - context.ServerUtcOffset, - TimeRangeEnd = context.TimeRangeEnd - context.ServerUtcOffset, - Severity = story.Severity, - Confidence = story.Confidence, - Category = story.Category, - StoryPath = story.StoryPath, - StoryPathHash = story.StoryPathHash, - IncidentId = story.IncidentId, - StoryText = story.StoryText, - RootFactKey = story.RootFactKey, - RootFactValue = story.RootFactValue, - LeafFactKey = story.LeafFactKey, - LeafFactValue = story.LeafFactValue, - FactCount = story.FactCount, - // Carried in-memory only; no analysis_findings column for it. - RootFactMetadata = story.RootFactMetadata - }); + survivors.Add(MapStoryToFinding(story, context, analysisTime, _nextId++)); } } catch (Exception ex) @@ -178,6 +151,48 @@ public async Task<List<AnalysisFinding>> FilterMutedFindingsAsync( return survivors; } + /// <summary> + /// Maps a scored story to the finding shape this store persists. Internal so the mapping is + /// unit-testable — the DatabaseName drop this extraction fixed survived unnoticed because the + /// mapping was buried in a connection-opening method. + /// </summary> + internal static AnalysisFinding MapStoryToFinding(AnalysisStory story, AnalysisContext context, DateTime analysisTime, long findingId) + { + return new AnalysisFinding + { + FindingId = findingId, + AnalysisTime = analysisTime, + ServerId = context.ServerId, + ServerName = context.ServerName, + // Lite's FindingStore has always persisted the story's database; the Dashboard twin + // dropped it here and stored NULL database_name on every finding (surfaced by the + // Darling PgFindingStore port), breaking per-database display and the database-scoped + // mute path for stored rows. + DatabaseName = story.DatabaseName, + // The context window is in the SERVER's local clock (so windowed reads match + // the collectors' SYSDATETIME rows); convert back to UTC for persistence so the + // stored time_range_* stay UTC — the reader's AsUtc, the deep-link offset math, + // and the retention purge all continue to assume UTC. (offset = SYSDATETIME − + // SYSUTCDATETIME, so local − offset = UTC.) + TimeRangeStart = context.TimeRangeStart - context.ServerUtcOffset, + TimeRangeEnd = context.TimeRangeEnd - context.ServerUtcOffset, + Severity = story.Severity, + Confidence = story.Confidence, + Category = story.Category, + StoryPath = story.StoryPath, + StoryPathHash = story.StoryPathHash, + IncidentId = story.IncidentId, + StoryText = story.StoryText, + RootFactKey = story.RootFactKey, + RootFactValue = story.RootFactValue, + LeafFactKey = story.LeafFactKey, + LeafFactValue = story.LeafFactValue, + FactCount = story.FactCount, + // Carried in-memory only; no analysis_findings column for it. + RootFactMetadata = story.RootFactMetadata + }; + } + /// <summary> /// Inserts the (already mute-filtered, enriched, and action-attached) findings in one /// batched pass (recommendations rebuild D2 / P2 reorder). Each row persists its BUILT @@ -278,7 +293,7 @@ public async Task<List<AnalysisFinding>> GetLatestFindingsAsync(int serverId) time_range_start, time_range_end, severity, confidence, category, story_path, story_path_hash, story_text, root_fact_key, root_fact_value, leaf_fact_key, leaf_fact_value, fact_count, - incident_id + incident_id, remediation_action_json FROM config.analysis_findings WHERE server_id = @serverId AND analysis_time = ( @@ -367,7 +382,7 @@ public async Task CleanupOldFindingsAsync(int retentionDays = 30) await EnsureTablesExistAsync(connection); using var cmd = connection.CreateCommand(); - cmd.CommandText = "DELETE FROM config.analysis_findings WHERE analysis_time < @cutoff;"; + cmd.CommandText = CleanupOldFindingsSql; cmd.Parameters.Add(new SqlParameter("@cutoff", DateTime.UtcNow.AddDays(-retentionDays))); await cmd.ExecuteNonQueryAsync(); } @@ -377,6 +392,27 @@ public async Task CleanupOldFindingsAsync(int retentionDays = 30) } } + /// <summary> + /// Muted-hash reader SQL. server_id = 0 rows are legacy all-servers mutes written by the pre-fix + /// MCP tool path (no real server has id 0); the reader honors them as global, alongside the + /// canonical NULL. Exposed as a const so Dashboard.Tests can pin the compat without a live SQL + /// Server, mirroring the Darling twin's <c>PgFindingStore.GetMutedHashesSql</c>. + /// </summary> + public const string GetMutedHashesSql = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT story_path_hash FROM config.analysis_muted +WHERE server_id = @serverId OR server_id IS NULL OR server_id = 0;"; + + /// <summary> + /// The findings-retention purge: deletes findings older than the caller's cutoff. Exposed as a + /// const so Dashboard.Tests can pin the retention predicate (by analysis_time, correct table) + /// without a live SQL Server, mirroring the Darling twin's <c>PgFindingStore.CleanupOldFindingsSql</c>. + /// Scheduled once per 24h by <c>AnalysisScheduler</c> — the store declared this cleanup but nothing + /// invoked it, so config.analysis_findings previously grew unbounded. + /// </summary> + public const string CleanupOldFindingsSql = "DELETE FROM config.analysis_findings WHERE analysis_time < @cutoff;"; + /// <summary> /// Reads muted story hashes for a server on an already-open connection. The caller /// owns the connection and is responsible for EnsureTablesExistAsync. Used by @@ -389,11 +425,7 @@ private static async Task<HashSet<string>> GetMutedHashesAsync(SqlConnection con try { using var cmd = connection.CreateCommand(); - cmd.CommandText = @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -SELECT story_path_hash FROM config.analysis_muted -WHERE server_id = @serverId OR server_id IS NULL;"; + cmd.CommandText = GetMutedHashesSql; cmd.Parameters.Add(new SqlParameter("@serverId", serverId)); @@ -495,10 +527,12 @@ private static AnalysisFinding ReadFinding(SqlDataReader reader) IncidentId = reader.FieldCount > 18 && !reader.IsDBNull(18) ? reader.GetString(18) : string.Empty }; - // D2: GetRecentFindingsAsync selects remediation_action_json (now ordinal 19, after - // incident_id); GetLatestFindingsAsync omits it, so guard by field count before reading. The - // BUILT action is deserialized via the SAME serializer the alert path uses, so the - // Recommendations surface can drive Apply + the two-sided consent gate from storage. + // D2: both GetRecentFindingsAsync and GetLatestFindingsAsync now select + // remediation_action_json at ordinal 19 (GetLatest previously omitted it, forcing the + // Recommendations reader onto GetRecent + a manual latest-run trim); the field-count guard + // stays for safety. The BUILT action is deserialized via the SAME serializer the alert + // path uses, so the Recommendations surface can drive Apply + the two-sided consent gate + // from storage. if (reader.FieldCount > 19 && !reader.IsDBNull(19)) finding.Remediation = AlertContextSerializer.DeserializeAction(reader.GetString(19)); diff --git a/Dashboard/CollectionLogWindow.xaml.cs b/Dashboard/CollectionLogWindow.xaml.cs index 659036cdf..f6be3fc79 100644 --- a/Dashboard/CollectionLogWindow.xaml.cs +++ b/Dashboard/CollectionLogWindow.xaml.cs @@ -191,79 +191,14 @@ private void UpdateLogFilterButtonStyles() } } - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.CurrentCell.Item != null) - { - var cellContent = Helpers.TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - Clipboard.SetDataObject(cellContent, false); - } - } - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid?.SelectedItem != null) - Clipboard.SetDataObject(Helpers.TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem), false); - } - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new System.Text.StringBuilder(); - var headers = new List<string>(); - foreach (var column in dataGrid.Columns) - headers.Add(DataGridClipboardBehavior.GetHeaderText(column)); - sb.AppendLine(string.Join("\t", headers)); - foreach (var item in dataGrid.Items) - sb.AppendLine(Helpers.TabHelpers.GetRowAsText(dataGrid, item)); - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var dialog = new Microsoft.Win32.SaveFileDialog - { - FileName = $"collection_log_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; - if (dialog.ShowDialog() == true) - { - var sb = new System.Text.StringBuilder(); - var headers = new List<string>(); - foreach (var column in dataGrid.Columns) - headers.Add(Helpers.TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(column))); - sb.AppendLine(string.Join(",", headers)); - foreach (var item in dataGrid.Items) - { - var values = Helpers.TabHelpers.GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(",", values.Select(v => Helpers.TabHelpers.EscapeCsvField(v)))); - } - System.IO.File.WriteAllText(dialog.FileName, sb.ToString()); - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "collection_log", Helpers.TabHelpers.CsvSeparator); private void Close_Click(object sender, RoutedEventArgs e) { diff --git a/Dashboard/CollectorScheduleWindow.xaml.cs b/Dashboard/CollectorScheduleWindow.xaml.cs index 0dda90b50..ea44a5771 100644 --- a/Dashboard/CollectorScheduleWindow.xaml.cs +++ b/Dashboard/CollectorScheduleWindow.xaml.cs @@ -325,79 +325,14 @@ private async void Refresh_Click(object sender, RoutedEventArgs e) await LoadSchedulesAsync(); } - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is System.Windows.Controls.MenuItem menuItem && menuItem.Parent is System.Windows.Controls.ContextMenu contextMenu) - { - var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.CurrentCell.Item != null) - { - var cellContent = Helpers.TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - Clipboard.SetDataObject(cellContent, false); - } - } - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is System.Windows.Controls.MenuItem menuItem && menuItem.Parent is System.Windows.Controls.ContextMenu contextMenu) - { - var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid?.SelectedItem != null) - Clipboard.SetDataObject(Helpers.TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem), false); - } - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is System.Windows.Controls.MenuItem menuItem && menuItem.Parent is System.Windows.Controls.ContextMenu contextMenu) - { - var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new System.Text.StringBuilder(); - var headers = new List<string>(); - foreach (var column in dataGrid.Columns) - headers.Add(DataGridClipboardBehavior.GetHeaderText(column)); - sb.AppendLine(string.Join("\t", headers)); - foreach (var item in dataGrid.Items) - sb.AppendLine(Helpers.TabHelpers.GetRowAsText(dataGrid, item)); - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is System.Windows.Controls.MenuItem menuItem && menuItem.Parent is System.Windows.Controls.ContextMenu contextMenu) - { - var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var dialog = new Microsoft.Win32.SaveFileDialog - { - FileName = $"collector_schedules_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; - if (dialog.ShowDialog() == true) - { - var sb = new System.Text.StringBuilder(); - var headers = new List<string>(); - foreach (var column in dataGrid.Columns) - headers.Add(Helpers.TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(column))); - sb.AppendLine(string.Join(",", headers)); - foreach (var item in dataGrid.Items) - { - var values = Helpers.TabHelpers.GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(",", values.Select(v => Helpers.TabHelpers.EscapeCsvField(v)))); - } - System.IO.File.WriteAllText(dialog.FileName, sb.ToString()); - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "collector_schedules", Helpers.TabHelpers.CsvSeparator); private void Close_Click(object sender, RoutedEventArgs e) { diff --git a/Dashboard/Controls/AlertsHistoryContent.xaml.cs b/Dashboard/Controls/AlertsHistoryContent.xaml.cs index aec46c8d0..0fd923aeb 100644 --- a/Dashboard/Controls/AlertsHistoryContent.xaml.cs +++ b/Dashboard/Controls/AlertsHistoryContent.xaml.cs @@ -400,97 +400,14 @@ private void UpdateFilterButtonStyles() #region Context Menu Handlers - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.CurrentCell.Item != null) - { - var cellContent = TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - Clipboard.SetDataObject(cellContent, false); - } - } - } - - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid?.SelectedItem != null) - Clipboard.SetDataObject(TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem), false); - } - } - - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new StringBuilder(); - var headers = dataGrid.Columns - .OfType<DataGridBoundColumn>() - .Select(c => DataGridClipboardBehavior.GetHeaderText(c)) - .ToList(); - sb.AppendLine(string.Join("\t", headers)); + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - foreach (var item in dataGrid.Items) - sb.AppendLine(TabHelpers.GetRowAsText(dataGrid, item)); + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } - - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var saveFileDialog = new SaveFileDialog - { - FileName = $"alert_history_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - if (saveFileDialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); - var sep = TabHelpers.CsvSeparator; - var headers = dataGrid.Columns - .OfType<DataGridBoundColumn>() - .Select(c => TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(c), sep)) - .ToList(); - sb.AppendLine(string.Join(sep, headers)); - - foreach (var item in dataGrid.Items) - { - var values = TabHelpers.GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(sep, values.Select(v => TabHelpers.EscapeCsvField(v, sep)))); - } - - File.WriteAllText(saveFileDialog.FileName, sb.ToString()); - MessageBox.Show($"Data exported successfully to:\n{saveFileDialog.FileName}", - "Export Complete", MessageBoxButton.OK, MessageBoxImage.Information); - } - catch (Exception ex) - { - MessageBox.Show($"Error exporting data:\n\n{ex.Message}", - "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "alert_history", TabHelpers.CsvSeparator); #endregion diff --git a/Dashboard/Controls/ConfigChangesContent.xaml.cs b/Dashboard/Controls/ConfigChangesContent.xaml.cs index dc4b82c7a..a9f7acc4a 100644 --- a/Dashboard/Controls/ConfigChangesContent.xaml.cs +++ b/Dashboard/Controls/ConfigChangesContent.xaml.cs @@ -385,79 +385,14 @@ private void UpdateFilterButtonStyles(DataGrid grid, Dictionary<string, ColumnFi #region Context Menu Handlers - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.CurrentCell.Item != null) - { - var cellContent = TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - Clipboard.SetDataObject(cellContent, false); - } - } - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid?.SelectedItem != null) - Clipboard.SetDataObject(TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem), false); - } - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new System.Text.StringBuilder(); - var headers = new List<string>(); - foreach (var column in dataGrid.Columns) - headers.Add(DataGridClipboardBehavior.GetHeaderText(column)); - sb.AppendLine(string.Join("\t", headers)); - foreach (var item in dataGrid.Items) - sb.AppendLine(TabHelpers.GetRowAsText(dataGrid, item)); - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var dialog = new Microsoft.Win32.SaveFileDialog - { - FileName = $"config_changes_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; - if (dialog.ShowDialog() == true) - { - var sb = new System.Text.StringBuilder(); - var headers = new List<string>(); - foreach (var column in dataGrid.Columns) - headers.Add(TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(column))); - sb.AppendLine(string.Join(",", headers)); - foreach (var item in dataGrid.Items) - { - var values = TabHelpers.GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(",", values.Select(v => TabHelpers.EscapeCsvField(v)))); - } - System.IO.File.WriteAllText(dialog.FileName, sb.ToString()); - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "config_changes", TabHelpers.CsvSeparator); #endregion } diff --git a/Dashboard/Controls/CriticalIssuesContent.xaml.cs b/Dashboard/Controls/CriticalIssuesContent.xaml.cs index 32eb57e13..90f387c78 100644 --- a/Dashboard/Controls/CriticalIssuesContent.xaml.cs +++ b/Dashboard/Controls/CriticalIssuesContent.xaml.cs @@ -280,118 +280,14 @@ private void UpdateFilterButtonStyle(Button? button, string columnName, Style? a #region Context Menu Handlers - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.CurrentCell.Item != null) - { - var cellContent = TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - { - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(cellContent, false); - } - } - } - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.SelectedItem != null) - { - var rowText = TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem); - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(rowText, false); - } - } - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new StringBuilder(); + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - // Add headers - var headers = new List<string>(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - { - headers.Add(DataGridClipboardBehavior.GetHeaderText(column)); - } - } - sb.AppendLine(string.Join("\t", headers)); - - // Add all rows - foreach (var item in dataGrid.Items) - { - sb.AppendLine(TabHelpers.GetRowAsText(dataGrid, item)); - } - - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } - - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var saveFileDialog = new SaveFileDialog - { - FileName = $"critical_issues_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; - - if (saveFileDialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); - - // Add headers - var headers = new List<string>(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - { - headers.Add(TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(column), TabHelpers.CsvSeparator)); - } - } - sb.AppendLine(string.Join(TabHelpers.CsvSeparator, headers)); - - // Add all rows - foreach (var item in dataGrid.Items) - { - var values = TabHelpers.GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(TabHelpers.CsvSeparator, values.Select(v => TabHelpers.EscapeCsvField(v, TabHelpers.CsvSeparator)))); - } - - File.WriteAllText(saveFileDialog.FileName, sb.ToString()); - MessageBox.Show($"Data exported successfully to:\n{saveFileDialog.FileName}", "Export Complete", MessageBoxButton.OK, MessageBoxImage.Information); - } - catch (Exception ex) - { - MessageBox.Show($"Error exporting data:\n\n{ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "critical_issues", TabHelpers.CsvSeparator); #endregion } diff --git a/Dashboard/Controls/CurrentConfigContent.xaml.cs b/Dashboard/Controls/CurrentConfigContent.xaml.cs index 1ab56b60a..f5102a92b 100644 --- a/Dashboard/Controls/CurrentConfigContent.xaml.cs +++ b/Dashboard/Controls/CurrentConfigContent.xaml.cs @@ -283,79 +283,14 @@ private void UpdateFilterButtonStyles(DataGrid grid, Dictionary<string, ColumnFi #region Context Menu Handlers - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.CurrentCell.Item != null) - { - var cellContent = TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - Clipboard.SetDataObject(cellContent, false); - } - } - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid?.SelectedItem != null) - Clipboard.SetDataObject(TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem), false); - } - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new System.Text.StringBuilder(); - var headers = new List<string>(); - foreach (var column in dataGrid.Columns) - headers.Add(DataGridClipboardBehavior.GetHeaderText(column)); - sb.AppendLine(string.Join("\t", headers)); - foreach (var item in dataGrid.Items) - sb.AppendLine(TabHelpers.GetRowAsText(dataGrid, item)); - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var dialog = new Microsoft.Win32.SaveFileDialog - { - FileName = $"config_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; - if (dialog.ShowDialog() == true) - { - var sb = new System.Text.StringBuilder(); - var headers = new List<string>(); - foreach (var column in dataGrid.Columns) - headers.Add(TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(column))); - sb.AppendLine(string.Join(",", headers)); - foreach (var item in dataGrid.Items) - { - var values = TabHelpers.GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(",", values.Select(v => TabHelpers.EscapeCsvField(v)))); - } - System.IO.File.WriteAllText(dialog.FileName, sb.ToString()); - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "config", TabHelpers.CsvSeparator); #endregion } diff --git a/Dashboard/Controls/DailySummaryContent.xaml.cs b/Dashboard/Controls/DailySummaryContent.xaml.cs index c394b4059..4a4906c18 100644 --- a/Dashboard/Controls/DailySummaryContent.xaml.cs +++ b/Dashboard/Controls/DailySummaryContent.xaml.cs @@ -178,16 +178,6 @@ private void DatePicker_CalendarOpened(object sender, RoutedEventArgs e) return null; } - private void DailySummaryFilterTextBox_TextChanged(object sender, TextChangedEventArgs e) - { - DataGridFilterService.ApplyFilter(DailySummaryDataGrid, sender as TextBox); - } - - private void DailySummaryNumericFilterTextBox_TextChanged(object sender, TextChangedEventArgs e) - { - DataGridFilterService.ApplyFilter(DailySummaryDataGrid, sender as TextBox); - } - private async void DailySummary_Refresh_Click(object sender, RoutedEventArgs e) { try @@ -343,121 +333,15 @@ private void UpdateFilterButtonStyle(Button? button, string columnName, Style? a #region Context Menu Handlers - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.CurrentCell.Item != null) - { - var cellContent = TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - { - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(cellContent, false); - } - } - } - } - - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.SelectedItem != null) - { - var rowText = TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem); - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(rowText, false); - } - } - } - - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new StringBuilder(); - - // Add headers - var headers = new List<string>(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - { - headers.Add(DataGridClipboardBehavior.GetHeaderText(column)); - } - } - sb.AppendLine(string.Join("\t", headers)); + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - // Add all rows - foreach (var item in dataGrid.Items) - { - sb.AppendLine(TabHelpers.GetRowAsText(dataGrid, item)); - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var saveFileDialog = new SaveFileDialog - { - FileName = $"daily_summary_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; - - if (saveFileDialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); - - // Add headers - var headers = new List<string>(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - { - headers.Add(TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(column), TabHelpers.CsvSeparator)); - } - } - sb.AppendLine(string.Join(TabHelpers.CsvSeparator, headers)); - - // Add all rows - foreach (var item in dataGrid.Items) - { - var values = TabHelpers.GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(TabHelpers.CsvSeparator, values.Select(v => TabHelpers.EscapeCsvField(v, TabHelpers.CsvSeparator)))); - } - - File.WriteAllText(saveFileDialog.FileName, sb.ToString()); - MessageBox.Show($"Data exported successfully to:\n{saveFileDialog.FileName}", "Export Complete", MessageBoxButton.OK, MessageBoxImage.Information); - } - catch (Exception ex) - { - MessageBox.Show($"Error exporting data:\n\n{ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "daily_summary", TabHelpers.CsvSeparator); #endregion - - // Filtering logic moved to DataGridFilterService.ApplyFilter() } } diff --git a/Dashboard/Controls/FinOpsContent.CopyExport.cs b/Dashboard/Controls/FinOpsContent.CopyExport.cs index 14b05a871..065f2b7c8 100644 --- a/Dashboard/Controls/FinOpsContent.CopyExport.cs +++ b/Dashboard/Controls/FinOpsContent.CopyExport.cs @@ -33,141 +33,14 @@ public partial class FinOpsContent : UserControl // Copy / Export Context Menu Handlers // ============================================ - private static DataGrid? FindParentDataGrid(DependencyObject? element) - { - while (element != null) - { - if (element is DataGrid dg) return dg; - element = VisualTreeHelper.GetParent(element); - } - return null; - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var grid = FindParentDataGrid(contextMenu.PlacementTarget); - if (grid != null && grid.CurrentCell.Column != null) - { - var cellContent = TabHelpers.GetCellContent(grid, grid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - { - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(cellContent, false); - } - } - } - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var grid = FindParentDataGrid(contextMenu.PlacementTarget); - if (grid != null && grid.SelectedItem != null) - { - var rowText = TabHelpers.GetRowAsText(grid, grid.SelectedItem); - if (!string.IsNullOrEmpty(rowText)) - { - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(rowText, false); - } - } - } - } + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var grid = FindParentDataGrid(contextMenu.PlacementTarget); - if (grid != null) - { - var sb = new StringBuilder(); - - // Header row - var headers = grid.Columns.Select(c => DataGridClipboardBehavior.GetHeaderText(c)); - sb.AppendLine(string.Join("\t", headers)); - - // Data rows - foreach (var item in grid.Items) - { - var values = new List<string>(); - foreach (var column in grid.Columns) - { - var binding = (column as DataGridBoundColumn)?.Binding as Binding; - if (binding != null) - { - var prop = item.GetType().GetProperty(binding.Path.Path); - var value = prop?.GetValue(item)?.ToString() ?? string.Empty; - values.Add(value); - } - } - sb.AppendLine(string.Join("\t", values)); - } - - if (sb.Length > 0) - { - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } - } - - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var grid = FindParentDataGrid(contextMenu.PlacementTarget); - if (grid != null) - { - var dialog = new SaveFileDialog - { - Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*", - DefaultExt = ".csv", - FileName = $"FinOps_Export_{DateTime.Now:yyyyMMdd_HHmmss}.csv" - }; - - if (dialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); - - // Header row - var sep = TabHelpers.CsvSeparator; - var headers = grid.Columns.Select(c => TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(c), sep)); - sb.AppendLine(string.Join(sep, headers)); - - // Data rows - foreach (var item in grid.Items) - { - var values = new List<string>(); - foreach (var column in grid.Columns) - { - var binding = (column as DataGridBoundColumn)?.Binding as Binding; - if (binding != null) - { - var prop = item.GetType().GetProperty(binding.Path.Path); - values.Add(TabHelpers.EscapeCsvField(TabHelpers.FormatForExport(prop?.GetValue(item)), sep)); - } - } - sb.AppendLine(string.Join(sep, values)); - } - - File.WriteAllText(dialog.FileName, sb.ToString()); - } - catch (Exception ex) - { - Logger.Error($"Error exporting to CSV: {ex.Message}", ex); - MessageBox.Show($"Error exporting to CSV: {ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "FinOps_Export", TabHelpers.CsvSeparator); } } diff --git a/Dashboard/Controls/FinOpsContent.xaml b/Dashboard/Controls/FinOpsContent.xaml index f90014130..d52d1c44d 100644 --- a/Dashboard/Controls/FinOpsContent.xaml +++ b/Dashboard/Controls/FinOpsContent.xaml @@ -1926,7 +1926,7 @@ </Expander> <!-- Tempdb Pressure --> - <Expander Header="Tempdb Pressure" IsExpanded="True" Margin="0,0,0,8"> + <Expander Header="tempdb Pressure" IsExpanded="True" Margin="0,0,0,8"> <StackPanel> <DataGrid x:Name="TempdbPressureDataGrid" ScrollViewer.HorizontalScrollBarVisibility="Disabled" AutoGenerateColumns="False" @@ -2773,6 +2773,24 @@ </Style> </DataGridTextColumn.ElementStyle> </DataGridTextColumn> + <!-- Non-alarming note when the sys.dm_os_sys_info hardware read was denied (e.g. Azure SQL DB + login without VIEW DATABASE STATE). Empty for servers whose hardware inventory is available. (#1535) --> + <DataGridTextColumn Binding="{Binding HardwareUnavailableReason}" Width="220"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="HardwareUnavailableReason" Click="FinOpsFilter_Click" Margin="0,0,4,0"/> + <TextBlock Text="Hardware Note" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle> + <Style TargetType="TextBlock"> + <Setter Property="Foreground" Value="#F39C12"/> + <Setter Property="FontWeight" Value="SemiBold"/> + <Setter Property="TextTrimming" Value="CharacterEllipsis"/> + <Setter Property="ToolTip" Value="{Binding HardwareUnavailableReason}"/> + </Style> + </DataGridTextColumn.ElementStyle> + </DataGridTextColumn> <DataGridTextColumn Binding="{Binding ProvisioningDisplay}" Width="130"> <DataGridTextColumn.Header> <StackPanel Orientation="Horizontal"> diff --git a/Dashboard/Controls/FinOpsContent.xaml.cs b/Dashboard/Controls/FinOpsContent.xaml.cs index 805b29fa0..1d6e9e6a7 100644 --- a/Dashboard/Controls/FinOpsContent.xaml.cs +++ b/Dashboard/Controls/FinOpsContent.xaml.cs @@ -22,6 +22,7 @@ using PerformanceMonitorDashboard.Models; using PerformanceMonitorDashboard.Services; using PerformanceMonitor.Ui; +using PerformanceMonitor.PlanAnalysis; using PerformanceMonitor.Common; namespace PerformanceMonitorDashboard.Controls diff --git a/Dashboard/Controls/LandingPage.xaml.cs b/Dashboard/Controls/LandingPage.xaml.cs index d7046377f..20f9fe9c1 100644 --- a/Dashboard/Controls/LandingPage.xaml.cs +++ b/Dashboard/Controls/LandingPage.xaml.cs @@ -12,6 +12,7 @@ using System.Linq; using System.Reflection; using System.Threading.Tasks; +using PerformanceMonitor.Ui; using System.Windows; using System.Windows.Controls; using System.Windows.Threading; @@ -279,18 +280,13 @@ private async void ServerHealthCard_CheckVersionRequested(object? sender, Server string appVersion = Assembly.GetExecutingAssembly() .GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0"; - int plusIndex = appVersion.IndexOf('+'); - if (plusIndex >= 0) appVersion = appVersion[..plusIndex]; - static string Normalize(string v) => - Version.TryParse(v, out var p) ? new Version(p.Major, p.Minor, p.Build).ToString() : v; + string normalizedInstalled = VersionText.Normalize(installedVersion); + string normalizedApp = VersionText.Normalize(appVersion); - string normalizedInstalled = Normalize(installedVersion); - string normalizedApp = Normalize(appVersion); - - if (Version.TryParse(normalizedInstalled, out var installed) && - Version.TryParse(normalizedApp, out var app) && - installed < app) + Version? installed = VersionText.Parse(installedVersion); + Version? app = VersionText.Parse(appVersion); + if (installed != null && app != null && installed < app) { var result = MessageBox.Show( $"'{server.DisplayNameWithIntent}' has v{normalizedInstalled} installed.\n\nv{normalizedApp} is available. Open the server editor to upgrade?", diff --git a/Dashboard/Controls/LoadingOverlay.xaml b/Dashboard/Controls/LoadingOverlay.xaml index eced2acbd..34acf9728 100644 --- a/Dashboard/Controls/LoadingOverlay.xaml +++ b/Dashboard/Controls/LoadingOverlay.xaml @@ -15,8 +15,7 @@ </UserControl.Resources> <Grid Background="{DynamicResource BackgroundBrush}"> <StackPanel HorizontalAlignment="Center" VerticalAlignment="Center"> - <TextBlock x:Name="SpinnerText" - Text="⟳" + <TextBlock Text="⟳" FontSize="24" Foreground="{DynamicResource AccentBrush}" HorizontalAlignment="Center" diff --git a/Dashboard/Controls/MemoryContent.CopyExport.cs b/Dashboard/Controls/MemoryContent.CopyExport.cs index 7f940a7d8..c9f7e61c0 100644 --- a/Dashboard/Controls/MemoryContent.CopyExport.cs +++ b/Dashboard/Controls/MemoryContent.CopyExport.cs @@ -29,121 +29,14 @@ public partial class MemoryContent : UserControl { #region Context Menu Handlers - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.CurrentCell.Item != null) - { - var cellContent = TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - { - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(cellContent, false); - } - } - } - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.SelectedItem != null) - { - var rowText = TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem); - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(rowText, false); - } - } - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new StringBuilder(); + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - // Add headers - var headers = new List<string>(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - { - headers.Add(DataGridClipboardBehavior.GetHeaderText(column)); - } - } - sb.AppendLine(string.Join("\t", headers)); - - // Add all rows - foreach (var item in dataGrid.Items) - { - sb.AppendLine(TabHelpers.GetRowAsText(dataGrid, item)); - } - - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } - - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - string prefix = "memory"; - - - var saveFileDialog = new SaveFileDialog - { - FileName = $"{prefix}_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; - - if (saveFileDialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); - - // Add headers - var headers = new List<string>(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - { - headers.Add(TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(column), TabHelpers.CsvSeparator)); - } - } - sb.AppendLine(string.Join(TabHelpers.CsvSeparator, headers)); - - // Add all rows - foreach (var item in dataGrid.Items) - { - var values = TabHelpers.GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(TabHelpers.CsvSeparator, values.Select(v => TabHelpers.EscapeCsvField(v, TabHelpers.CsvSeparator)))); - } - - File.WriteAllText(saveFileDialog.FileName, sb.ToString()); - MessageBox.Show($"Data exported successfully to:\n{saveFileDialog.FileName}", "Export Complete", MessageBoxButton.OK, MessageBoxImage.Information); - } - catch (Exception ex) - { - MessageBox.Show($"Error exporting data:\n\n{ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "memory", TabHelpers.CsvSeparator); #endregion } diff --git a/Dashboard/Controls/MemoryContent.MemoryClerks.cs b/Dashboard/Controls/MemoryContent.MemoryClerks.cs index 972359e9f..2d97ab5e6 100644 --- a/Dashboard/Controls/MemoryContent.MemoryClerks.cs +++ b/Dashboard/Controls/MemoryContent.MemoryClerks.cs @@ -22,6 +22,9 @@ using PerformanceMonitorDashboard.Models; using PerformanceMonitorDashboard.Services; using PerformanceMonitor.Ui; +// Ui now also defines SelectableItem (the Lite/Darling picker collapse). This deprecated Dashboard keeps its +// own copy, so alias the unqualified name to it to avoid a CS0104 ambiguity with the shared type. +using SelectableItem = PerformanceMonitorDashboard.Models.SelectableItem; namespace PerformanceMonitorDashboard.Controls { diff --git a/Dashboard/Controls/MemoryContent.xaml.cs b/Dashboard/Controls/MemoryContent.xaml.cs index 85ab4c42c..bd087a88c 100644 --- a/Dashboard/Controls/MemoryContent.xaml.cs +++ b/Dashboard/Controls/MemoryContent.xaml.cs @@ -22,6 +22,9 @@ using PerformanceMonitorDashboard.Models; using PerformanceMonitorDashboard.Services; using PerformanceMonitor.Ui; +// Ui now also defines SelectableItem (the Lite/Darling picker collapse). This deprecated Dashboard keeps its +// own copy, so alias the unqualified name to it to avoid a CS0104 ambiguity with the shared type. +using SelectableItem = PerformanceMonitorDashboard.Models.SelectableItem; namespace PerformanceMonitorDashboard.Controls { diff --git a/Dashboard/Controls/QueryPerformanceContent.CopyExport.cs b/Dashboard/Controls/QueryPerformanceContent.CopyExport.cs index f640d488c..5a5de3f04 100644 --- a/Dashboard/Controls/QueryPerformanceContent.CopyExport.cs +++ b/Dashboard/Controls/QueryPerformanceContent.CopyExport.cs @@ -24,63 +24,11 @@ namespace PerformanceMonitorDashboard.Controls { public partial class QueryPerformanceContent : UserControl { - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.CurrentCell.Item != null) - { - var cellContent = TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - { - Clipboard.SetDataObject(cellContent, false); - } - } - } - } - - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.SelectedItem != null) - { - var rowText = TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem); - Clipboard.SetDataObject(rowText, false); - } - } - } - - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new StringBuilder(); + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - var headers = new List<string>(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - { - headers.Add(DataGridClipboardBehavior.GetHeaderText(column)); - } - } - sb.AppendLine(string.Join("\t", headers)); + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - foreach (var item in dataGrid.Items) - { - sb.AppendLine(TabHelpers.GetRowAsText(dataGrid, item)); - } - - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); private void CopyReproScript_Click(object sender, RoutedEventArgs e) { @@ -145,52 +93,7 @@ private void CopyReproScript_Click(object sender, RoutedEventArgs e) } } - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var saveFileDialog = new SaveFileDialog - { - FileName = $"query_performance_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; - - if (saveFileDialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); - - var headers = new List<string>(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - { - headers.Add(TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(column), TabHelpers.CsvSeparator)); - } - } - sb.AppendLine(string.Join(TabHelpers.CsvSeparator, headers)); - - foreach (var item in dataGrid.Items) - { - var values = TabHelpers.GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(TabHelpers.CsvSeparator, values.Select(v => TabHelpers.EscapeCsvField(v, TabHelpers.CsvSeparator)))); - } - - File.WriteAllText(saveFileDialog.FileName, sb.ToString()); - MessageBox.Show($"Data exported successfully to:\n{saveFileDialog.FileName}", "Export Complete", MessageBoxButton.OK, MessageBoxImage.Information); - } - catch (Exception ex) - { - MessageBox.Show($"Error exporting data:\n\n{ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "query_performance", TabHelpers.CsvSeparator); } } diff --git a/Dashboard/Controls/QueryPerformanceContent.Plans.cs b/Dashboard/Controls/QueryPerformanceContent.Plans.cs index 4231e4d47..4a2701ca6 100644 --- a/Dashboard/Controls/QueryPerformanceContent.Plans.cs +++ b/Dashboard/Controls/QueryPerformanceContent.Plans.cs @@ -17,6 +17,7 @@ using PerformanceMonitorDashboard.Models; using PerformanceMonitorDashboard.Services; using PerformanceMonitor.Ui; +using PerformanceMonitor.PlanAnalysis; namespace PerformanceMonitorDashboard.Controls { diff --git a/Dashboard/Controls/QueryPerformanceContent.xaml b/Dashboard/Controls/QueryPerformanceContent.xaml index 0b3e76cb0..c563d751d 100644 --- a/Dashboard/Controls/QueryPerformanceContent.xaml +++ b/Dashboard/Controls/QueryPerformanceContent.xaml @@ -179,6 +179,14 @@ </StackPanel> </DataGridTextColumn.Header> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PhysicalIo, StringFormat='{}{0:N0}'}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PhysicalIo" Click="ActiveQueriesFilter_Click" Margin="0,0,4,0"/> + <TextBlock Text="Physical I/O" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> <DataGridTextColumn Binding="{Binding ContextSwitches}" ElementStyle="{StaticResource NumericCell}" Width="100"> <DataGridTextColumn.Header> <StackPanel Orientation="Horizontal"> @@ -187,6 +195,14 @@ </StackPanel> </DataGridTextColumn.Header> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Tasks, StringFormat='{}{0:N0}'}" ElementStyle="{StaticResource NumericCell}" Width="70"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Tasks" Click="ActiveQueriesFilter_Click" Margin="0,0,4,0"/> + <TextBlock Text="Tasks" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> <DataGridTextColumn Binding="{Binding UsedMemoryMb, StringFormat='{}{0:N2}'}" ElementStyle="{StaticResource NumericCell}" Width="100"> <DataGridTextColumn.Header> <StackPanel Orientation="Horizontal"> @@ -195,11 +211,35 @@ </StackPanel> </DataGridTextColumn.Header> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding RequestedMemoryMb, StringFormat='{}{0:N2}'}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="RequestedMemoryMb" Click="ActiveQueriesFilter_Click" Margin="0,0,4,0"/> + <TextBlock Text="Req Mem (MB)" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding GrantedMemoryMb, StringFormat='{}{0:N2}'}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="GrantedMemoryMb" Click="ActiveQueriesFilter_Click" Margin="0,0,4,0"/> + <TextBlock Text="Granted Mem (MB)" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxUsedMemoryMb, StringFormat='{}{0:N2}'}" ElementStyle="{StaticResource NumericCell}" Width="130"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxUsedMemoryMb" Click="ActiveQueriesFilter_Click" Margin="0,0,4,0"/> + <TextBlock Text="Max Used Mem (MB)" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> <DataGridTextColumn Binding="{Binding TempdbCurrentMb, StringFormat='{}{0:N2}'}" ElementStyle="{StaticResource NumericCell}" Width="110"> <DataGridTextColumn.Header> <StackPanel Orientation="Horizontal"> <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TempdbCurrentMb" Click="ActiveQueriesFilter_Click" Margin="0,0,4,0"/> - <TextBlock Text="TempDB Cur (MB)" FontWeight="Bold" VerticalAlignment="Center"/> + <TextBlock Text="tempdb Cur (MB)" FontWeight="Bold" VerticalAlignment="Center"/> </StackPanel> </DataGridTextColumn.Header> </DataGridTextColumn> @@ -207,7 +247,7 @@ <DataGridTextColumn.Header> <StackPanel Orientation="Horizontal"> <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TempdbAllocations" Click="ActiveQueriesFilter_Click" Margin="0,0,4,0"/> - <TextBlock Text="TempDB Alloc (pages)" FontWeight="Bold" VerticalAlignment="Center"/> + <TextBlock Text="tempdb Alloc (MB)" FontWeight="Bold" VerticalAlignment="Center"/> </StackPanel> </DataGridTextColumn.Header> </DataGridTextColumn> @@ -690,6 +730,22 @@ </StackPanel> </DataGridTextColumn.Header> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding WorkerTimePerSecond, StringFormat='{}{0:N1}'}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="WorkerTimePerSecond" Click="QueryStatsFilter_Click" Margin="0,0,4,0"/> + <TextBlock Text="Peak CPU (ms/s)" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PlanGenerationNum, StringFormat='{}{0:N0}'}" ElementStyle="{StaticResource NumericCell}" Width="85"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PlanGenerationNum" Click="QueryStatsFilter_Click" Margin="0,0,4,0"/> + <TextBlock Text="Plan Gen" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> <DataGridTextColumn Binding="{Binding MinWorkerTime}" ElementStyle="{StaticResource NumericCell}" Width="100"> <DataGridTextColumn.Header> <StackPanel Orientation="Horizontal"> @@ -706,6 +762,14 @@ </StackPanel> </DataGridTextColumn.Header> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalClrTimeMs, StringFormat='{}{0:N2}'}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalClrTimeMs" Click="QueryStatsFilter_Click" Margin="0,0,4,0"/> + <TextBlock Text="Total CLR (ms)" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> <DataGridTemplateColumn SortMemberPath="TotalElapsedTimeMs" Width="120"> <DataGridTemplateColumn.Header> <StackPanel Orientation="Horizontal"> @@ -808,6 +872,14 @@ </StackPanel> </DataGridTextColumn.Header> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgLogicalWrites, StringFormat='{}{0:N2}'}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgLogicalWrites" Click="QueryStatsFilter_Click" Margin="0,0,4,0"/> + <TextBlock Text="Avg Writes (pages)" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> <DataGridTextColumn Binding="{Binding TotalRows, StringFormat='{}{0:N0}'}" ElementStyle="{StaticResource NumericCell}" Width="90"> <DataGridTextColumn.Header> <StackPanel Orientation="Horizontal"> @@ -856,6 +928,46 @@ </StackPanel> </DataGridTextColumn.Header> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinReservedThreads, StringFormat='{}{0:N0}'}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinReservedThreads" Click="QueryStatsFilter_Click" Margin="0,0,4,0"/> + <TextBlock Text="Min Rsvd Threads" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxReservedThreads, StringFormat='{}{0:N0}'}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxReservedThreads" Click="QueryStatsFilter_Click" Margin="0,0,4,0"/> + <TextBlock Text="Max Rsvd Threads" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinUsedThreads, StringFormat='{}{0:N0}'}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinUsedThreads" Click="QueryStatsFilter_Click" Margin="0,0,4,0"/> + <TextBlock Text="Min Used Threads" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxUsedThreads, StringFormat='{}{0:N0}'}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxUsedThreads" Click="QueryStatsFilter_Click" Margin="0,0,4,0"/> + <TextBlock Text="Max Used Threads" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinGrantMb, StringFormat='{}{0:N2}'}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinGrantMb" Click="QueryStatsFilter_Click" Margin="0,0,4,0"/> + <TextBlock Text="Min Grant (MB)" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> <DataGridTextColumn Binding="{Binding MaxGrantMb, StringFormat='{}{0:N2}'}" ElementStyle="{StaticResource NumericCell}" Width="100"> <DataGridTextColumn.Header> <StackPanel Orientation="Horizontal"> @@ -864,6 +976,38 @@ </StackPanel> </DataGridTextColumn.Header> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinUsedGrantMb, StringFormat='{}{0:N2}'}" ElementStyle="{StaticResource NumericCell}" Width="115"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinUsedGrantMb" Click="QueryStatsFilter_Click" Margin="0,0,4,0"/> + <TextBlock Text="Min Used Grant (MB)" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxUsedGrantMb, StringFormat='{}{0:N2}'}" ElementStyle="{StaticResource NumericCell}" Width="115"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxUsedGrantMb" Click="QueryStatsFilter_Click" Margin="0,0,4,0"/> + <TextBlock Text="Max Used Grant (MB)" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinIdealGrantMb, StringFormat='{}{0:N2}'}" ElementStyle="{StaticResource NumericCell}" Width="115"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinIdealGrantMb" Click="QueryStatsFilter_Click" Margin="0,0,4,0"/> + <TextBlock Text="Min Ideal Grant (MB)" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxIdealGrantMb, StringFormat='{}{0:N2}'}" ElementStyle="{StaticResource NumericCell}" Width="115"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxIdealGrantMb" Click="QueryStatsFilter_Click" Margin="0,0,4,0"/> + <TextBlock Text="Max Ideal Grant (MB)" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> <DataGridTextColumn Binding="{Binding TotalSpills, StringFormat='{}{0:N0}'}" ElementStyle="{StaticResource NumericCell}" Width="90"> <DataGridTextColumn.Header> <StackPanel Orientation="Horizontal"> @@ -1201,6 +1345,14 @@ </StackPanel> </DataGridTextColumn.Header> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgSpills, StringFormat='{}{0:N2}'}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgSpills" Click="ProcStatsFilter_Click" Margin="0,0,4,0"/> + <TextBlock Text="Avg Spills (pages)" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> <DataGridTextColumn Binding="{Binding MinSpills, StringFormat='{}{0:N0}'}" ElementStyle="{StaticResource NumericCell}" Width="80"> <DataGridTextColumn.Header> <StackPanel Orientation="Horizontal"> @@ -1541,7 +1693,7 @@ <DataGridTextColumn.Header> <StackPanel Orientation="Horizontal"> <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgTempdbMb" Click="QueryStoreFilter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Avg TempDB (MB)" FontWeight="Bold" VerticalAlignment="Center"/> + <TextBlock Text="Avg tempdb (MB)" FontWeight="Bold" VerticalAlignment="Center"/> </StackPanel> </DataGridTextColumn.Header> </DataGridTextColumn> @@ -1549,7 +1701,7 @@ <DataGridTextColumn.Header> <StackPanel Orientation="Horizontal"> <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinTempdbMb" Click="QueryStoreFilter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Min TempDB (MB)" FontWeight="Bold" VerticalAlignment="Center"/> + <TextBlock Text="Min tempdb (MB)" FontWeight="Bold" VerticalAlignment="Center"/> </StackPanel> </DataGridTextColumn.Header> </DataGridTextColumn> @@ -1557,7 +1709,7 @@ <DataGridTextColumn.Header> <StackPanel Orientation="Horizontal"> <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxTempdbMb" Click="QueryStoreFilter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Max TempDB (MB)" FontWeight="Bold" VerticalAlignment="Center"/> + <TextBlock Text="Max tempdb (MB)" FontWeight="Bold" VerticalAlignment="Center"/> </StackPanel> </DataGridTextColumn.Header> </DataGridTextColumn> @@ -1601,6 +1753,14 @@ </StackPanel> </DataGridTextColumn.Header> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgClrTimeMs, StringFormat='{}{0:N2}'}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgClrTimeMs" Click="QueryStoreFilter_Click" Margin="0,0,4,0"/> + <TextBlock Text="Avg CLR (ms)" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> <DataGridTextColumn Binding="{Binding MinClrTimeMs, StringFormat='{}{0:N2}'}" ElementStyle="{StaticResource NumericCell}" Width="100"> <DataGridTextColumn.Header> <StackPanel Orientation="Horizontal"> @@ -1617,6 +1777,14 @@ </StackPanel> </DataGridTextColumn.Header> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgNumPhysicalIoReads, StringFormat='{}{0:N0}'}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgNumPhysicalIoReads" Click="QueryStoreFilter_Click" Margin="0,0,4,0"/> + <TextBlock Text="Avg Phys IO Reads" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> <DataGridTextColumn Binding="{Binding MinNumPhysicalIoReads, StringFormat='{}{0:N0}'}" ElementStyle="{StaticResource NumericCell}" Width="110"> <DataGridTextColumn.Header> <StackPanel Orientation="Horizontal"> @@ -1633,6 +1801,14 @@ </StackPanel> </DataGridTextColumn.Header> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgLogBytesUsed, StringFormat='{}{0:N0}'}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgLogBytesUsed" Click="QueryStoreFilter_Click" Margin="0,0,4,0"/> + <TextBlock Text="Avg Log Bytes" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> <DataGridTextColumn Binding="{Binding MinLogBytesUsed, StringFormat='{}{0:N0}'}" ElementStyle="{StaticResource NumericCell}" Width="100"> <DataGridTextColumn.Header> <StackPanel Orientation="Horizontal"> diff --git a/Dashboard/Controls/QueryPerformanceContent.xaml.cs b/Dashboard/Controls/QueryPerformanceContent.xaml.cs index 58426fec7..1f194bcde 100644 --- a/Dashboard/Controls/QueryPerformanceContent.xaml.cs +++ b/Dashboard/Controls/QueryPerformanceContent.xaml.cs @@ -61,6 +61,9 @@ private static (DateTime start, DateTime end) GetSlicerTimeRange( /// <summary>Fired when the Queries sub-tab changes, so the global Compare dropdown can update.</summary> public event Action? SubTabChanged; + /// <summary>Raised when user drills down on a chart point. Args: (chartType, serverLocalTime)</summary> + public event Action<string, DateTime>? ChartDrillDownRequested; + private CancellationTokenSource? _actualPlanCts; /// <summary>Cancels the in-flight actual plan execution, if any.</summary> @@ -873,12 +876,41 @@ bc.Binding is Binding b && } } + private void AddDrillDown(ScottPlot.WPF.WpfPlot chart, ContextMenu menu, + Func<ChartHoverHelper?> hoverGetter, string label, string chartType) + { + menu.Items.Insert(0, new Separator()); + var item = new MenuItem { Header = label }; + menu.Items.Insert(0, item); + + menu.Opened += (s, _) => + { + var pos = System.Windows.Input.Mouse.GetPosition(chart); + var nearest = hoverGetter()?.GetNearestSeries(pos); + item.Tag = nearest?.Time; + item.IsEnabled = nearest.HasValue; + }; + + item.Click += (s, _) => + { + if (item.Tag is DateTime time) + ChartDrillDownRequested?.Invoke(chartType, time); + }; + } + private void SetupChartSaveMenus() { - TabHelpers.SetupChartContextMenu(QueryPerfTrendsQueryChart, "Query_Durations", "report.query_stats_summary"); - TabHelpers.SetupChartContextMenu(QueryPerfTrendsProcChart, "Procedure_Durations", "report.procedure_stats_summary"); - TabHelpers.SetupChartContextMenu(QueryPerfTrendsQsChart, "QueryStore_Durations", "report.query_store_summary"); - TabHelpers.SetupChartContextMenu(QueryPerfTrendsExecChart, "Execution_Counts", "collect.query_stats"); + var queryMenu = TabHelpers.SetupChartContextMenu(QueryPerfTrendsQueryChart, "Query_Durations", "report.query_stats_summary"); + AddDrillDown(QueryPerfTrendsQueryChart, queryMenu, () => _queryDurationHover, "Show Active Queries at This Time", "QueryPerfTrendsQuery"); + + var procMenu = TabHelpers.SetupChartContextMenu(QueryPerfTrendsProcChart, "Procedure_Durations", "report.procedure_stats_summary"); + AddDrillDown(QueryPerfTrendsProcChart, procMenu, () => _procDurationHover, "Show Active Queries at This Time", "QueryPerfTrendsProc"); + + var qsMenu = TabHelpers.SetupChartContextMenu(QueryPerfTrendsQsChart, "QueryStore_Durations", "report.query_store_summary"); + AddDrillDown(QueryPerfTrendsQsChart, qsMenu, () => _qsDurationHover, "Show Active Queries at This Time", "QueryPerfTrendsQs"); + + var execMenu = TabHelpers.SetupChartContextMenu(QueryPerfTrendsExecChart, "Execution_Counts", "collect.query_stats"); + AddDrillDown(QueryPerfTrendsExecChart, execMenu, () => _execTrendsHover, "Show Active Queries at This Time", "QueryPerfTrendsExec"); } // ── Active Queries refresh ── diff --git a/Dashboard/Controls/ResourceMetricsContent.CopyExport.cs b/Dashboard/Controls/ResourceMetricsContent.CopyExport.cs index 55b190dc7..f5718c76c 100644 --- a/Dashboard/Controls/ResourceMetricsContent.CopyExport.cs +++ b/Dashboard/Controls/ResourceMetricsContent.CopyExport.cs @@ -32,123 +32,14 @@ public partial class ResourceMetricsContent : UserControl { #region Context Menu Handlers - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - if (contextMenu.PlacementTarget is DataGrid grid && grid.CurrentCell.Column != null) - { - var cellContent = TabHelpers.GetCellContent(grid, grid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - { - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(cellContent, false); - } - } - } - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - if (contextMenu.PlacementTarget is DataGrid grid && grid.SelectedItem != null) - { - var rowText = TabHelpers.GetRowAsText(grid, grid.SelectedItem); - if (!string.IsNullOrEmpty(rowText)) - { - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(rowText, false); - } - } - } - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - if (contextMenu.PlacementTarget is DataGrid grid) - { - var sb = new StringBuilder(); + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - var headers = grid.Columns.Select(c => DataGridClipboardBehavior.GetHeaderText(c)); - sb.AppendLine(string.Join("\t", headers)); - - foreach (var item in grid.Items) - { - var values = new List<string>(); - foreach (var column in grid.Columns) - { - var binding = (column as DataGridBoundColumn)?.Binding as System.Windows.Data.Binding; - if (binding != null) - { - var prop = item.GetType().GetProperty(binding.Path.Path); - var value = prop?.GetValue(item)?.ToString() ?? string.Empty; - values.Add(value); - } - } - sb.AppendLine(string.Join("\t", values)); - } - - if (sb.Length > 0) - { - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } - } - - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - if (contextMenu.PlacementTarget is DataGrid grid) - { - var dialog = new SaveFileDialog - { - Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*", - DefaultExt = ".csv", - FileName = $"ResourceMetrics_Export_{DateTime.Now:yyyyMMdd_HHmmss}.csv" - }; - - if (dialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); - - var sep = TabHelpers.CsvSeparator; - var headers = grid.Columns.Select(c => TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(c), sep)); - sb.AppendLine(string.Join(sep, headers)); - - foreach (var item in grid.Items) - { - var values = new List<string>(); - foreach (var column in grid.Columns) - { - var binding = (column as DataGridBoundColumn)?.Binding as System.Windows.Data.Binding; - if (binding != null) - { - var prop = item.GetType().GetProperty(binding.Path.Path); - values.Add(TabHelpers.EscapeCsvField(TabHelpers.FormatForExport(prop?.GetValue(item)), sep)); - } - } - sb.AppendLine(string.Join(sep, values)); - } - - File.WriteAllText(dialog.FileName, sb.ToString()); - } - catch (Exception ex) - { - Logger.Error($"Error exporting to CSV: {ex.Message}", ex); - MessageBox.Show($"Error exporting to CSV: {ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "ResourceMetrics_Export", TabHelpers.CsvSeparator); #endregion } diff --git a/Dashboard/Controls/ResourceMetricsContent.xaml b/Dashboard/Controls/ResourceMetricsContent.xaml index 755dddb81..3e2172645 100644 --- a/Dashboard/Controls/ResourceMetricsContent.xaml +++ b/Dashboard/Controls/ResourceMetricsContent.xaml @@ -100,9 +100,9 @@ </Border> </Grid> </TabItem> - <!-- TempDB Stats Sub-Tab --> - <TabItem Header="TempDB Stats"> - <!-- TempDB Stats - usage / size / latency charts + summary --> + <!-- tempdb Stats Sub-Tab --> + <TabItem Header="tempdb"> + <!-- tempdb Stats - usage / size / latency charts + summary --> <Grid Margin="5"> <Grid.RowDefinitions> <RowDefinition Height="*"/> @@ -111,38 +111,38 @@ <RowDefinition Height="Auto"/> </Grid.RowDefinitions> - <!-- TempDB Usage Chart --> + <!-- tempdb Usage Chart --> <Border Grid.Row="0" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Margin="0,0,0,2"> <Grid> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> - <TextBlock Grid.Row="0" Text="TempDB Usage Over Time" FontWeight="Bold" FontSize="11" Margin="5,3" Foreground="{DynamicResource ForegroundBrush}"/> + <TextBlock Grid.Row="0" Text="tempdb Usage Over Time" FontWeight="Bold" FontSize="11" Margin="5,3" Foreground="{DynamicResource ForegroundBrush}"/> <ScottPlot:WpfPlot Grid.Row="1" x:Name="TempdbStatsChart"/> </Grid> </Border> - <!-- TempDB Allocated Size Chart (growth trend, own scale) --> + <!-- tempdb Allocated Size Chart (growth trend, own scale) --> <Border Grid.Row="1" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Margin="0,2,0,2"> <Grid> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> - <TextBlock Grid.Row="0" Text="TempDB Allocated Size Over Time (MB)" FontWeight="Bold" FontSize="11" Margin="5,3" Foreground="{DynamicResource ForegroundBrush}"/> + <TextBlock Grid.Row="0" Text="tempdb Allocated Size Over Time (MB)" FontWeight="Bold" FontSize="11" Margin="5,3" Foreground="{DynamicResource ForegroundBrush}"/> <ScottPlot:WpfPlot Grid.Row="1" x:Name="TempdbSizeChart"/> </Grid> </Border> - <!-- TempDB Latency Chart (Read + Write combined) --> + <!-- tempdb Latency Chart (Read + Write combined) --> <Border Grid.Row="2" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Margin="0,2,0,0"> <Grid> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> - <TextBlock Grid.Row="0" Text="TempDB Latency (ms)" FontWeight="Bold" FontSize="11" Margin="5,3" Foreground="{DynamicResource ForegroundBrush}"/> + <TextBlock Grid.Row="0" Text="tempdb Latency (ms)" FontWeight="Bold" FontSize="11" Margin="5,3" Foreground="{DynamicResource ForegroundBrush}"/> <ScottPlot:WpfPlot Grid.Row="1" x:Name="TempDbLatencyChart"/> </Grid> </Border> @@ -150,7 +150,7 @@ <!-- Summary Panel --> <Border Grid.Row="3" Background="{DynamicResource BackgroundBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="0,1,0,0" Padding="10,8"> <StackPanel Orientation="Horizontal" HorizontalAlignment="Center"> - <TextBlock Text="Sessions Using TempDB:" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" Margin="0,0,5,0"/> + <TextBlock Text="Sessions Using tempdb:" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" Margin="0,0,5,0"/> <TextBlock x:Name="TempdbSessionsText" Text="N/A" Foreground="{DynamicResource ForegroundBrush}" VerticalAlignment="Center" Margin="0,0,20,0"/> <TextBlock Text="Warnings:" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" Margin="0,0,5,0"/> <TextBlock x:Name="TempdbWarningsText" Text="None" Foreground="{DynamicResource ForegroundBrush}" VerticalAlignment="Center"/> diff --git a/Dashboard/Controls/SystemEventsContent.CPUTasks.cs b/Dashboard/Controls/SystemEventsContent.CPUTasks.cs index 8ed5c1097..5dc0a2386 100644 --- a/Dashboard/Controls/SystemEventsContent.CPUTasks.cs +++ b/Dashboard/Controls/SystemEventsContent.CPUTasks.cs @@ -38,7 +38,6 @@ private async System.Threading.Tasks.Task RefreshCPUTasksAsync() try { var data = await _databaseService.GetHealthParserCPUTasksAsync(_cpuTasksHoursBack, _cpuTasksFromDate, _cpuTasksToDate); - // Grid removed per todo.md #15 - chart + summary only LoadCPUTasksChart(data, _cpuTasksHoursBack, _cpuTasksFromDate, _cpuTasksToDate); UpdateCPUTasksSummaryPanel(data); } @@ -223,20 +222,6 @@ private void UpdateCPUTasksSummaryPanel(List<HealthParserCPUTasksItem> dataList) CPUTasksPendingNoBlockText.Text = pendingNoBlockCount.ToString("N0", CultureInfo.CurrentCulture); } - // CPUTasksFilter_Click removed - grid removed per todo.md #15 - - // ApplyCPUTasksFilters removed - grid removed per todo.md #15 - - // UpdateCPUTasksFilterButtonStyles removed - grid removed per todo.md #15 - - // CPUTasksFilterTextBox_TextChanged removed - grid removed per todo.md #15 - - // CPUTasksNumericFilterTextBox_TextChanged removed - grid removed per todo.md #15 - - // CPUTasksBoolFilter_Changed removed - grid removed per todo.md #15 - - // ApplyCPUTasksFilter removed - grid removed per todo.md #15 - #endregion } } diff --git a/Dashboard/Controls/SystemEventsContent.CopyExport.cs b/Dashboard/Controls/SystemEventsContent.CopyExport.cs index 093897ca5..7b61a0183 100644 --- a/Dashboard/Controls/SystemEventsContent.CopyExport.cs +++ b/Dashboard/Controls/SystemEventsContent.CopyExport.cs @@ -30,127 +30,14 @@ public partial class SystemEventsContent : UserControl { #region Context Menu Handlers - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - if (contextMenu.PlacementTarget is DataGrid grid && grid.CurrentCell.Column != null) - { - var cellContent = TabHelpers.GetCellContent(grid, grid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - { - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(cellContent, false); - } - } - } - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - if (contextMenu.PlacementTarget is DataGrid grid && grid.SelectedItem != null) - { - var rowText = TabHelpers.GetRowAsText(grid, grid.SelectedItem); - if (!string.IsNullOrEmpty(rowText)) - { - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(rowText, false); - } - } - } - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - if (contextMenu.PlacementTarget is DataGrid grid) - { - var sb = new StringBuilder(); + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - // Header row - var headers = grid.Columns.Select(c => DataGridClipboardBehavior.GetHeaderText(c)); - sb.AppendLine(string.Join("\t", headers)); - - // Data rows - foreach (var item in grid.Items) - { - var values = new List<string>(); - foreach (var column in grid.Columns) - { - var binding = (column as DataGridBoundColumn)?.Binding as System.Windows.Data.Binding; - if (binding != null) - { - var prop = item.GetType().GetProperty(binding.Path.Path); - var value = prop?.GetValue(item)?.ToString() ?? string.Empty; - values.Add(value); - } - } - sb.AppendLine(string.Join("\t", values)); - } - - if (sb.Length > 0) - { - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } - } - - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - if (contextMenu.PlacementTarget is DataGrid grid) - { - var dialog = new SaveFileDialog - { - Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*", - DefaultExt = ".csv", - FileName = $"SystemEvents_Export_{DateTime.Now:yyyyMMdd_HHmmss}.csv" - }; - - if (dialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); - - // Header row - var sep = TabHelpers.CsvSeparator; - var headers = grid.Columns.Select(c => TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(c), sep)); - sb.AppendLine(string.Join(sep, headers)); - - // Data rows - foreach (var item in grid.Items) - { - var values = new List<string>(); - foreach (var column in grid.Columns) - { - var binding = (column as DataGridBoundColumn)?.Binding as System.Windows.Data.Binding; - if (binding != null) - { - var prop = item.GetType().GetProperty(binding.Path.Path); - values.Add(TabHelpers.EscapeCsvField(TabHelpers.FormatForExport(prop?.GetValue(item)), sep)); - } - } - sb.AppendLine(string.Join(sep, values)); - } - - File.WriteAllText(dialog.FileName, sb.ToString()); - } - catch (Exception ex) - { - Logger.Error($"Error exporting to CSV: {ex.Message}", ex); - MessageBox.Show($"Error exporting to CSV: {ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "SystemEvents_Export", TabHelpers.CsvSeparator); #endregion } diff --git a/Dashboard/Controls/SystemEventsContent.FilterPopup.cs b/Dashboard/Controls/SystemEventsContent.FilterPopup.cs index ca9aa62f6..02e2e3d89 100644 --- a/Dashboard/Controls/SystemEventsContent.FilterPopup.cs +++ b/Dashboard/Controls/SystemEventsContent.FilterPopup.cs @@ -73,22 +73,11 @@ private void FilterPopup_FilterApplied(object? sender, FilterAppliedEventArgs e) switch (_currentFilterTarget) { - // SystemHealth case removed - grid removed per todo.md #18 case "SevereErrors": UpdateFilterState(_severeErrorsFilters, e.FilterState); ApplySevereErrorsFilters(); UpdateSevereErrorsFilterButtonStyles(); break; - // IOIssues case removed - grid removed per todo.md #19 - // SchedulerIssues case removed - grid removed per todo.md #13 - // MemoryConditions case removed - grid removed per todo.md #14 - // CPUTasks case removed - grid removed per todo.md #15 - case "MemoryBroker": - UpdateFilterState(_memoryBrokerFilters, e.FilterState); - ApplyMemoryBrokerFilters(); - UpdateMemoryBrokerFilterButtonStyles(); - break; - // MemoryNodeOOM case removed - DataGrid removed per GitHub issue #13 } } diff --git a/Dashboard/Controls/SystemEventsContent.IOIssues.cs b/Dashboard/Controls/SystemEventsContent.IOIssues.cs index fa2a01340..83b02fbea 100644 --- a/Dashboard/Controls/SystemEventsContent.IOIssues.cs +++ b/Dashboard/Controls/SystemEventsContent.IOIssues.cs @@ -38,8 +38,6 @@ private async System.Threading.Tasks.Task RefreshIOIssuesAsync() try { var data = await _databaseService.GetHealthParserIOIssuesAsync(_ioIssuesHoursBack, _ioIssuesFromDate, _ioIssuesToDate); - _ioIssuesUnfilteredData = data; - // IOIssuesDataGrid removed - chart only per todo.md #19 LoadIOIssuesChart(data, _ioIssuesHoursBack, _ioIssuesFromDate, _ioIssuesToDate); LoadLongestPendingIOChart(data, _ioIssuesHoursBack, _ioIssuesFromDate, _ioIssuesToDate); } @@ -123,7 +121,6 @@ private void LoadIOIssuesChart(IEnumerable<HealthParserIOIssueItem> data, int ho IOIssuesChart.Refresh(); } - // IOIssuesFilter_Click removed - grid removed per todo.md #19 private void LoadLongestPendingIOChart(IEnumerable<HealthParserIOIssueItem> data, int hoursBack, DateTime? fromDate, DateTime? toDate) { DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; @@ -216,15 +213,6 @@ private void LoadLongestPendingIOChart(IEnumerable<HealthParserIOIssueItem> data LongestPendingIOChart.Refresh(); } - - - // ApplyIOIssuesFilters removed - grid removed per todo.md #19 - - // UpdateIOIssuesFilterButtonStyles removed - grid removed per todo.md #19 - - // IOIssuesFilterTextBox_TextChanged removed - grid removed per todo.md #19 - // IOIssuesNumericFilterTextBox_TextChanged removed - grid removed per todo.md #19 - #endregion } } diff --git a/Dashboard/Controls/SystemEventsContent.MemoryBroker.cs b/Dashboard/Controls/SystemEventsContent.MemoryBroker.cs index a612bf899..8e30a2fef 100644 --- a/Dashboard/Controls/SystemEventsContent.MemoryBroker.cs +++ b/Dashboard/Controls/SystemEventsContent.MemoryBroker.cs @@ -38,7 +38,6 @@ private async System.Threading.Tasks.Task RefreshMemoryBrokerAsync() try { var data = await _databaseService.GetHealthParserMemoryBrokerAsync(_memoryBrokerHoursBack, _memoryBrokerFromDate, _memoryBrokerToDate); - _memoryBrokerUnfilteredData = data; MemoryBrokerDataGrid.ItemsSource = data; MemoryBrokerNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; LoadMemoryBrokerChart(data, _memoryBrokerHoursBack, _memoryBrokerFromDate, _memoryBrokerToDate); @@ -181,68 +180,6 @@ private void LoadMemoryBrokerChart(IEnumerable<HealthParserMemoryBrokerItem> dat MemoryBrokerRatioChart.Plot.YLabel("Value"); } - private void MemoryBrokerFilter_Click(object sender, RoutedEventArgs e) - { - if (sender is not Button button || button.Tag is not string columnName) return; - - ShowFilterPopup(button, columnName, "MemoryBroker", _memoryBrokerFilters, - args => { }, - () => { }); - } - - private void ApplyMemoryBrokerFilters() - { - if (_memoryBrokerUnfilteredData == null) - { - _memoryBrokerUnfilteredData = MemoryBrokerDataGrid.ItemsSource as List<HealthParserMemoryBrokerItem>; - if (_memoryBrokerUnfilteredData == null && MemoryBrokerDataGrid.ItemsSource != null) - { - _memoryBrokerUnfilteredData = (MemoryBrokerDataGrid.ItemsSource as IEnumerable<HealthParserMemoryBrokerItem>)?.ToList(); - } - } - - if (_memoryBrokerUnfilteredData == null) return; - - if (_memoryBrokerFilters.Count == 0) - { - MemoryBrokerDataGrid.ItemsSource = _memoryBrokerUnfilteredData; - return; - } - - var filteredData = _memoryBrokerUnfilteredData.Where(item => - { - foreach (var filter in _memoryBrokerFilters.Values) - { - if (filter.IsActive && !DataGridFilterService.MatchesFilter(item, filter)) - { - return false; - } - } - return true; - }).ToList(); - - MemoryBrokerDataGrid.ItemsSource = filteredData; - } - - private void UpdateMemoryBrokerFilterButtonStyles() - { - foreach (var columnName in new[] { "CollectionTime", "Broker", "Notification", "MemoryRatio", - "CurrentlyAllocated", "PreviouslyAllocated", "NewTarget", "Overall", "Rate", "DeltaTime", "BrokerId" }) - { - UpdateFilterButtonStyle(MemoryBrokerDataGrid, columnName, _memoryBrokerFilters); - } - } - - private void MemoryBrokerFilterTextBox_TextChanged(object sender, TextChangedEventArgs e) - { - DataGridFilterService.ApplyFilter(MemoryBrokerDataGrid, sender as TextBox); - } - - private void MemoryBrokerNumericFilterTextBox_TextChanged(object sender, TextChangedEventArgs e) - { - DataGridFilterService.ApplyFilter(MemoryBrokerDataGrid, sender as TextBox); - } - #endregion } } diff --git a/Dashboard/Controls/SystemEventsContent.MemoryConditions.cs b/Dashboard/Controls/SystemEventsContent.MemoryConditions.cs index 5af006791..eee0be8bc 100644 --- a/Dashboard/Controls/SystemEventsContent.MemoryConditions.cs +++ b/Dashboard/Controls/SystemEventsContent.MemoryConditions.cs @@ -38,7 +38,6 @@ private async System.Threading.Tasks.Task RefreshMemoryConditionsAsync() try { var data = await _databaseService.GetHealthParserMemoryConditionsAsync(_memoryConditionsHoursBack, _memoryConditionsFromDate, _memoryConditionsToDate); - // Grid removed per todo.md #14 - chart only LoadMemoryConditionsChart(data, _memoryConditionsHoursBack, _memoryConditionsFromDate, _memoryConditionsToDate); } catch (Exception ex) @@ -111,20 +110,6 @@ private void LoadMemoryConditionsChart(IEnumerable<HealthParserMemoryConditionIt MemoryConditionsChart.Refresh(); } - // MemoryConditionsFilter_Click removed - grid removed per todo.md #14 - - // ApplyMemoryConditionsFilters removed - grid removed per todo.md #14 - - // UpdateMemoryConditionsFilterButtonStyles removed - grid removed per todo.md #14 - - // MemoryConditionsFilterTextBox_TextChanged removed - grid removed per todo.md #14 - - - - - - - #endregion } } diff --git a/Dashboard/Controls/SystemEventsContent.MemoryNodeOOM.cs b/Dashboard/Controls/SystemEventsContent.MemoryNodeOOM.cs index bf99ad887..22f2e6ec6 100644 --- a/Dashboard/Controls/SystemEventsContent.MemoryNodeOOM.cs +++ b/Dashboard/Controls/SystemEventsContent.MemoryNodeOOM.cs @@ -38,7 +38,6 @@ private async System.Threading.Tasks.Task RefreshMemoryNodeOOMAsync() try { var data = await _databaseService.GetHealthParserMemoryNodeOOMAsync(_memoryNodeOOMHoursBack, _memoryNodeOOMFromDate, _memoryNodeOOMToDate); - _memoryNodeOOMUnfilteredData = data; // Load charts LoadMemoryNodeOOMUtilChart(data, _memoryNodeOOMHoursBack, _memoryNodeOOMFromDate, _memoryNodeOOMToDate); @@ -297,8 +296,6 @@ private void UpdateMemoryStateIndicators(IEnumerable<HealthParserMemoryNodeOOMIt ProcVirtLowIndicator.Fill = procVirtLowCount > 0 ? warningBrush : unknownBrush; } - // MemoryNodeOOM filter methods removed - DataGrid removed per GitHub issue #13 - #endregion } } diff --git a/Dashboard/Controls/SystemEventsContent.SchedulerIssues.cs b/Dashboard/Controls/SystemEventsContent.SchedulerIssues.cs index 06dbff794..e9a17a4e9 100644 --- a/Dashboard/Controls/SystemEventsContent.SchedulerIssues.cs +++ b/Dashboard/Controls/SystemEventsContent.SchedulerIssues.cs @@ -38,7 +38,6 @@ private async System.Threading.Tasks.Task RefreshSchedulerIssuesAsync() try { var data = await _databaseService.GetHealthParserSchedulerIssuesAsync(_schedulerIssuesHoursBack, _schedulerIssuesFromDate, _schedulerIssuesToDate); - // Grid removed per todo.md #13 - chart + summary only LoadSchedulerIssuesChart(data, _schedulerIssuesHoursBack, _schedulerIssuesFromDate, _schedulerIssuesToDate); UpdateSchedulerIssuesSummaryPanel(data); } @@ -167,20 +166,6 @@ long ParseNonYieldTime(string? value) } } - // SchedulerIssuesFilter_Click removed - grid removed per todo.md #13 - - // ApplySchedulerIssuesFilters removed - grid removed per todo.md #13 - - // UpdateSchedulerIssuesFilterButtonStyles removed - grid removed per todo.md #13 - - // SchedulerIssuesFilterTextBox_TextChanged removed - grid removed per todo.md #13 - - // SchedulerIssuesNumericFilterTextBox_TextChanged removed - grid removed per todo.md #13 - - // SchedulerIssuesBoolFilter_Changed removed - grid removed per todo.md #13 - - // ApplySchedulerIssuesFilter removed - grid removed per todo.md #13 - #endregion } } diff --git a/Dashboard/Controls/SystemEventsContent.SevereErrors.cs b/Dashboard/Controls/SystemEventsContent.SevereErrors.cs index 0e4151348..6bde5de23 100644 --- a/Dashboard/Controls/SystemEventsContent.SevereErrors.cs +++ b/Dashboard/Controls/SystemEventsContent.SevereErrors.cs @@ -161,16 +161,6 @@ private void UpdateSevereErrorsFilterButtonStyles() } } - private void SevereErrorsFilterTextBox_TextChanged(object sender, TextChangedEventArgs e) - { - DataGridFilterService.ApplyFilter(SevereErrorsDataGrid, sender as TextBox); - } - - private void SevereErrorsNumericFilterTextBox_TextChanged(object sender, TextChangedEventArgs e) - { - DataGridFilterService.ApplyFilter(SevereErrorsDataGrid, sender as TextBox); - } - #endregion } } diff --git a/Dashboard/Controls/SystemEventsContent.SystemHealth.cs b/Dashboard/Controls/SystemEventsContent.SystemHealth.cs index 8bc1e0a5e..47ab4cd9a 100644 --- a/Dashboard/Controls/SystemEventsContent.SystemHealth.cs +++ b/Dashboard/Controls/SystemEventsContent.SystemHealth.cs @@ -38,8 +38,6 @@ private async System.Threading.Tasks.Task RefreshSystemHealthAsync() try { var data = await _databaseService.GetHealthParserSystemHealthAsync(_systemHealthHoursBack, _systemHealthFromDate, _systemHealthToDate); - _systemHealthUnfilteredData = data; - // SystemHealthDataGrid removed - chart only per todo.md #18 LoadCorruptionEventsCharts(data, _systemHealthHoursBack, _systemHealthFromDate, _systemHealthToDate); LoadContentionEventsCharts(data, _systemHealthHoursBack, _systemHealthFromDate, _systemHealthToDate); } @@ -350,22 +348,6 @@ private void LoadContentionEventsCharts(List<HealthParserSystemHealthItem> data, TabHelpers.LockChartVerticalAxis(CpuComparisonChart); CpuComparisonChart.Refresh(); } - private void SystemHealthFilter_Click(object sender, RoutedEventArgs e) - { - if (sender is not Button button || button.Tag is not string columnName) return; - - ShowFilterPopup(button, columnName, "SystemHealth", _systemHealthFilters, - args => { }, // Handled in FilterPopup_FilterApplied - () => { }); // Handled in FilterPopup_FilterCleared - } - - // ApplySystemHealthFilters removed - grid removed per todo.md #18 - - // UpdateSystemHealthFilterButtonStyles removed - grid removed per todo.md #18 - - // SystemHealthFilterTextBox_TextChanged removed - grid removed per todo.md #18 - - // SystemHealthNumericFilterTextBox_TextChanged removed - grid removed per todo.md #18 #endregion } diff --git a/Dashboard/Controls/SystemEventsContent.xaml.cs b/Dashboard/Controls/SystemEventsContent.xaml.cs index 2798e1bc2..58bf73c16 100644 --- a/Dashboard/Controls/SystemEventsContent.xaml.cs +++ b/Dashboard/Controls/SystemEventsContent.xaml.cs @@ -74,25 +74,11 @@ public partial class SystemEventsContent : UserControl private DateTime? _memoryNodeOOMFromDate; private DateTime? _memoryNodeOOMToDate; - // Filter state dictionaries for each DataGrid - private Dictionary<string, ColumnFilterState> _systemHealthFilters = new(); + // Filter state dictionary for the SevereErrors DataGrid private Dictionary<string, ColumnFilterState> _severeErrorsFilters = new(); - private Dictionary<string, ColumnFilterState> _ioIssuesFilters = new(); - // Scheduler Issues filter removed - grid removed per todo.md #13 - // Memory Conditions filter removed - grid removed per todo.md #14 - // CPU Tasks filter removed - grid removed per todo.md #15 - private Dictionary<string, ColumnFilterState> _memoryBrokerFilters = new(); - private Dictionary<string, ColumnFilterState> _memoryNodeOOMFilters = new(); - - // Unfiltered data caches - private List<HealthParserSystemHealthItem>? _systemHealthUnfilteredData; + + // Unfiltered data cache private List<HealthParserSevereErrorItem>? _severeErrorsUnfilteredData; - private List<HealthParserIOIssueItem>? _ioIssuesUnfilteredData; - // Scheduler Issues unfiltered data cache removed - grid removed per todo.md #13 - // Memory Conditions unfiltered data cache removed - grid removed per todo.md #14 - // CPU Tasks unfiltered data cache removed - grid removed per todo.md #15 - private List<HealthParserMemoryBrokerItem>? _memoryBrokerUnfilteredData; - private List<HealthParserMemoryNodeOOMItem>? _memoryNodeOOMUnfilteredData; // Shared popup controls private Popup? _filterPopup; @@ -230,24 +216,12 @@ private void OnThemeChanged(string _) private void OnLoaded(object sender, RoutedEventArgs e) { // Apply minimum column widths based on header text - // SystemHealthDataGrid removed - chart only per todo.md #18 TabHelpers.AutoSizeColumnMinWidths(SevereErrorsDataGrid); - // IOIssuesDataGrid removed - chart only per todo.md #19 - // SchedulerIssuesDataGrid removed - chart + summary only per todo.md #13 - // MemoryConditionsDataGrid removed - chart only per todo.md #14 - // CPUTasksDataGrid AutoSizeColumnMinWidths removed - chart + summary only per todo.md #15 TabHelpers.AutoSizeColumnMinWidths(MemoryBrokerDataGrid); - // MemoryNodeOOMDataGrid removed - chart only per GitHub issue #13 // Freeze time column for easier horizontal scrolling - // SystemHealthDataGrid FreezeColumns removed - chart only per todo.md #18 TabHelpers.FreezeColumns(SevereErrorsDataGrid, 1); - // IOIssuesDataGrid FreezeColumns removed - chart only per todo.md #19 - // SchedulerIssuesDataGrid FreezeColumns removed - chart + summary only per todo.md #13 - - // CPUTasksDataGrid FreezeColumns removed - chart + summary only per todo.md #15 TabHelpers.FreezeColumns(MemoryBrokerDataGrid, 1); - // MemoryNodeOOMDataGrid FreezeColumns removed - chart only per GitHub issue #13 } private void SetupChartContextMenus() diff --git a/Dashboard/Dashboard.csproj b/Dashboard/Dashboard.csproj index 93b571732..3602c00df 100644 --- a/Dashboard/Dashboard.csproj +++ b/Dashboard/Dashboard.csproj @@ -7,10 +7,10 @@ <StartupObject>PerformanceMonitorDashboard.Program</StartupObject> <AssemblyName>PerformanceMonitorDashboard</AssemblyName> <Product>SQL Server Performance Monitor Dashboard</Product> - <Version>3.1.0</Version> - <AssemblyVersion>3.1.0.0</AssemblyVersion> - <FileVersion>3.1.0.0</FileVersion> - <InformationalVersion>3.1.0</InformationalVersion> + <Version>3.2.0</Version> + <AssemblyVersion>3.2.0.0</AssemblyVersion> + <FileVersion>3.2.0.0</FileVersion> + <InformationalVersion>3.2.0</InformationalVersion> <Company>Darling Data, LLC</Company> <Copyright>Copyright © 2026 Darling Data, LLC</Copyright> <ApplicationIcon>EDD.ico</ApplicationIcon> @@ -55,6 +55,7 @@ <ItemGroup> <ProjectReference Include="..\Installer.Core\Installer.Core.csproj" /> + <ProjectReference Include="..\PerformanceMonitor.Alerting\PerformanceMonitor.Alerting.csproj" /> <ProjectReference Include="..\PerformanceMonitor.Analysis\PerformanceMonitor.Analysis.csproj" /> <ProjectReference Include="..\PerformanceMonitor.Common\PerformanceMonitor.Common.csproj" /> <ProjectReference Include="..\PerformanceMonitor.Notifications\PerformanceMonitor.Notifications.csproj" /> diff --git a/Dashboard/GlobalUsings.cs b/Dashboard/GlobalUsings.cs new file mode 100644 index 000000000..cbb6dd2a6 --- /dev/null +++ b/Dashboard/GlobalUsings.cs @@ -0,0 +1,15 @@ +/* + * Performance Monitor Dashboard + * Copyright (c) 2026 Darling Data, LLC + * Licensed under the MIT License - see LICENSE file for details + */ + +/* Phase-5 A0: the six alert row types moved to the shared PerformanceMonitor.Alerting library + (one canonical copy instead of member-identical Lite/Dashboard twins). These aliases keep every + existing call site compiling unchanged against the shared types. */ +global using AnomalousJobInfo = PerformanceMonitor.Alerting.AnomalousJobInfo; +global using FailedJobInfo = PerformanceMonitor.Alerting.FailedJobInfo; +global using LongRunningQueryInfo = PerformanceMonitor.Alerting.LongRunningQueryInfo; +global using PoisonWaitDelta = PerformanceMonitor.Alerting.PoisonWaitDelta; +global using TempDbSpaceInfo = PerformanceMonitor.Alerting.TempDbSpaceInfo; +global using VolumeFreeSpaceInfo = PerformanceMonitor.Alerting.VolumeFreeSpaceInfo; diff --git a/Dashboard/MainWindow.AlertEngine.cs b/Dashboard/MainWindow.AlertEngine.cs index 9145e3d25..2eba27b04 100644 --- a/Dashboard/MainWindow.AlertEngine.cs +++ b/Dashboard/MainWindow.AlertEngine.cs @@ -32,6 +32,9 @@ using System.Xml.Linq; using PerformanceMonitor.Ui; using PerformanceMonitor.Common; +/* Type alias (not a namespace import) so PerformanceMonitor.Alerting's CpuAlertMode enum can never + collide with Models.CpuAlertMode used below. */ +using AlertContextBuilders = PerformanceMonitor.Alerting.AlertContextBuilders; namespace PerformanceMonitorDashboard { @@ -182,7 +185,7 @@ either suppresses alerts (offset went back) or bypasses the cooldown (offset wen _lastBlockingAlert[serverId] = now; var blockingContext = await BuildBlockingContextAsync(serverName, databaseService, prefs.AlertExcludedDatabases); - var detailText = ContextToDetailText(blockingContext) + var detailText = AlertContextBuilders.ContextToDetailText(blockingContext) ?? $"Blocked Sessions: {(int)health.TotalBlocked}\nLongest Wait: {(int)health.LongestBlockedSeconds}s"; if (!isMuted) @@ -248,7 +251,7 @@ Falls back to the raw delta when no databases are excluded. */ _lastDeadlockAlert[serverId] = now; var deadlockContext = await BuildDeadlockContextAsync(serverName, databaseService, prefs.AlertExcludedDatabases); - var detailText = ContextToDetailText(deadlockContext) + var detailText = AlertContextBuilders.ContextToDetailText(deadlockContext) ?? $"New Deadlocks: {effectiveDeadlockDelta}"; if (!isMuted) @@ -482,8 +485,8 @@ await _emailAlertService.TrySendAlertEmailAsync( var muteCtx = new AlertMuteContext { ServerName = serverName, MetricName = "Poison Wait", WaitType = worst.WaitType }; bool isMuted = _muteRuleService.IsAlertMuted(muteCtx); _lastPoisonWaitAlert[serverId] = now; - var poisonContext = BuildPoisonWaitContext(triggeredWaits); - var detailText = ContextToDetailText(poisonContext); + var poisonContext = AlertContextBuilders.BuildPoisonWaitContext(triggeredWaits); + var detailText = AlertContextBuilders.ContextToDetailText(poisonContext); if (!isMuted) { @@ -539,7 +542,7 @@ await _emailAlertService.TrySendAlertEmailAsync( { var worst = lrqList[0]; var elapsedMinutes = worst.ElapsedSeconds / 60; - var preview = Truncate(worst.QueryText, 80); + var preview = AlertContextBuilders.TruncateText(worst.QueryText, 80); var muteCtx = new AlertMuteContext { @@ -550,8 +553,8 @@ await _emailAlertService.TrySendAlertEmailAsync( }; bool isMuted = _muteRuleService.IsAlertMuted(muteCtx); _lastLongRunningQueryAlert[serverId] = now; - var lrqContext = BuildLongRunningQueryContext(serverName, lrqList); - var detailText = ContextToDetailText(lrqContext); + var lrqContext = AlertContextBuilders.BuildLongRunningQueryContext(serverName, lrqList); + var detailText = AlertContextBuilders.ContextToDetailText(lrqContext); if (!isMuted) { @@ -600,31 +603,31 @@ await _emailAlertService.TrySendAlertEmailAsync( _activeTempDbSpaceAlert[serverId] = true; if (!_lastTempDbSpaceAlert.TryGetValue(serverId, out var lastAlert) || (now - lastAlert) >= alertCooldown) { - var muteCtx = new AlertMuteContext { ServerName = serverName, MetricName = "TempDB Space" }; + var muteCtx = new AlertMuteContext { ServerName = serverName, MetricName = "tempdb Space" }; bool isMuted = _muteRuleService.IsAlertMuted(muteCtx); _lastTempDbSpaceAlert[serverId] = now; - var tempDbContext = BuildTempDbSpaceContext(tempDb); - var detailText = ContextToDetailText(tempDbContext); + var tempDbContext = AlertContextBuilders.BuildTempDbSpaceContext(tempDb); + var detailText = AlertContextBuilders.ContextToDetailText(tempDbContext); if (!isMuted) { _notificationService?.ShowSnoozableNotification( - "TempDB Space", - $"{serverName}: TempDB {tempDb.UsedPercent:F0}% used", + "tempdb Space", + $"{serverName}: tempdb {tempDb.UsedPercent:F0}% used", NotificationType.Warning, serverName, - "TempDB Space", + "tempdb Space", _muteRuleService); } - _emailAlertService.RecordAlert(serverId, serverName, "TempDB Space", + _emailAlertService.RecordAlert(serverId, serverName, "tempdb Space", $"{tempDb.UsedPercent:F0}% used ({tempDb.TotalReservedMb:F0} MB)", $"{prefs.TempDbSpaceThresholdPercent}%", !isMuted, isMuted ? "muted" : "tray", muted: isMuted, detailText: detailText); if (!isMuted) { await _emailAlertService.TrySendAlertEmailAsync( - "TempDB Space", + "tempdb Space", serverName, $"{tempDb.UsedPercent:F0}% used ({tempDb.TotalReservedMb:F0} MB)", $"{prefs.TempDbSpaceThresholdPercent}%", @@ -636,15 +639,15 @@ await _emailAlertService.TrySendAlertEmailAsync( else if (_activeTempDbSpaceAlert.TryRemove(serverId, out var wasTempDb) && wasTempDb) { var pct = health.TempDbSpace != null ? $"{health.TempDbSpace.UsedPercent:F0}%" : "N/A"; - _notificationService?.ShowStyledNotification("TempDB Space Resolved", - $"{serverName}: TempDB usage back to {pct}", ToastSeverity.Success); - _emailAlertService.RecordAlert(serverId, serverName, "TempDB Space Resolved", + _notificationService?.ShowStyledNotification("tempdb Space Resolved", + $"{serverName}: tempdb usage back to {pct}", ToastSeverity.Success); + _emailAlertService.RecordAlert(serverId, serverName, "tempdb Space Resolved", pct, $"{prefs.TempDbSpaceThresholdPercent}%", true, "tray"); } /* Low volume free space alerts — not applicable to Azure SQL DB (health.Volumes is empty there) */ var breachedVolumes = prefs.NotifyOnLowDisk - ? GetBreachedVolumes(health.Volumes, prefs) + ? AlertContextBuilders.GetBreachedVolumes(health.Volumes, prefs.LowDiskThresholdPercent, prefs.LowDiskThresholdGb) : new List<VolumeFreeSpaceInfo>(); if (breachedVolumes.Count > 0) @@ -662,7 +665,7 @@ standing full volume (which also re-recorded a history row and made Dismiss feel bool isMuted = _muteRuleService.IsAlertMuted(muteCtx); _lastLowDiskAlert[serverId] = now; _lastAlertedLowDiskPercent[serverId] = worst.FreePercent; - var lowDiskContext = BuildVolumeFreeSpaceContext(serverName, breachedVolumes); + var lowDiskContext = AlertContextBuilders.BuildVolumeFreeSpaceContext(serverName, breachedVolumes); /* #1136: grade the alert — WARNING normally, CRITICAL when the worst volume is critically low — so the email/webhook badge reflects how dire the breach is. (lowDiskContext is non-null here — breachedVolumes.Count > 0 — but typed nullable.) */ @@ -670,9 +673,9 @@ critically low — so the email/webhook badge reflects how dire the breach is. { lowDiskContext.SeverityOverride = AlertSeverityLevel.Critical; } - var detailText = ContextToDetailText(lowDiskContext); + var detailText = AlertContextBuilders.ContextToDetailText(lowDiskContext); var currentValue = $"{worst.MountPoint} {worst.FreePercent:F0}% free ({worst.FreeGb:F1} GB)"; - var thresholdValue = FormatLowDiskThreshold(prefs); + var thresholdValue = AlertContextBuilders.FormatLowDiskThreshold(prefs.LowDiskThresholdPercent, prefs.LowDiskThresholdGb); if (!isMuted) { @@ -706,7 +709,7 @@ await _emailAlertService.TrySendAlertEmailAsync( _notificationService?.ShowStyledNotification("Volume Free Space Resolved", $"{serverName}: All volumes back above threshold", ToastSeverity.Success); _emailAlertService.RecordAlert(serverId, serverName, "Volume Free Space Resolved", - "OK", FormatLowDiskThreshold(prefs), true, "tray"); + "OK", AlertContextBuilders.FormatLowDiskThreshold(prefs.LowDiskThresholdPercent, prefs.LowDiskThresholdGb), true, "tray"); } /* Anomalous Agent job alerts */ @@ -735,8 +738,8 @@ await _emailAlertService.TrySendAlertEmailAsync( var muteCtx = new AlertMuteContext { ServerName = serverName, MetricName = "Long-Running Job", JobName = worst.JobName }; bool isMuted = _muteRuleService.IsAlertMuted(muteCtx); _lastLongRunningJobAlert[jobKey] = now; - var jobContext = BuildAnomalousJobContext(serverName, health.AnomalousJobs); - var detailText = ContextToDetailText(jobContext); + var jobContext = AlertContextBuilders.BuildAnomalousJobContext(serverName, health.AnomalousJobs); + var detailText = AlertContextBuilders.ContextToDetailText(jobContext); if (!isMuted) { @@ -809,8 +812,8 @@ dismissed. Seed once per server from the persisted watermark (server-local basis On-change only — only when a failed-job toast actually fires. */ prefs.FailedJobAlertWatermarkTicks[serverId] = newestFailure.Ticks; _preferencesService.SavePreferences(prefs); - var jobContext = BuildFailedJobContext(serverName, health.RecentlyFailedJobs); - var detailText = ContextToDetailText(jobContext); + var jobContext = AlertContextBuilders.BuildFailedJobContext(serverName, health.RecentlyFailedJobs); + var detailText = AlertContextBuilders.ContextToDetailText(jobContext); if (!isMuted) { @@ -841,13 +844,6 @@ await _emailAlertService.TrySendAlertEmailAsync( } } - private static string Truncate(string text, int maxLength = 300) - { - if (string.IsNullOrEmpty(text)) return ""; - text = text.Replace('\r', ' ').Replace('\n', ' ').Trim(); - return text.Length <= maxLength ? text : text.Substring(0, maxLength) + "..."; - } - /* #1141: in Per-event mode, deliver one notification per distinct incident (capped at AlertPerEventMaxPerCycle, with a trailing "+N more" carrying the remaining fingerprints) instead of one batched summary card. Falls back to the single summary send in Summary mode @@ -875,20 +871,6 @@ await _emailAlertService.TrySendAlertEmailAsync( metricName, serverName, summaryCurrentValue, thresholdValue, serverId, context); } - private static string? ContextToDetailText(AlertContext? context) - { - if (context == null || context.Details.Count == 0) return null; - var sb = new System.Text.StringBuilder(); - foreach (var detail in context.Details) - { - if (sb.Length > 0) sb.AppendLine(); - sb.AppendLine(detail.Heading); - foreach (var (label, value) in detail.Fields) - sb.AppendLine($" {label}: {value}"); - } - return sb.ToString().TrimEnd(); - } - private static async Task<AlertContext?> BuildBlockingContextAsync(string serverName, DatabaseService databaseService, List<string>? excludedDatabases = null) { try @@ -920,7 +902,7 @@ await _emailAlertService.TrySendAlertEmailAsync( if (!string.IsNullOrEmpty(e.DatabaseName)) item.Fields.Add(("Database", e.DatabaseName)); if (!string.IsNullOrEmpty(e.QueryText)) - item.Fields.Add(("Query", Truncate(e.QueryText))); + item.Fields.Add(("Query", AlertContextBuilders.TruncateText(e.QueryText))); if (e.WaitTimeMs.HasValue) item.Fields.Add(("Wait Time", $"{e.WaitTimeMs:N0} ms")); if (!string.IsNullOrEmpty(e.LockMode)) @@ -996,7 +978,7 @@ await _emailAlertService.TrySendAlertEmailAsync( if (!string.IsNullOrEmpty(d.DatabaseName)) item.Fields.Add(("Database", d.DatabaseName)); if (!string.IsNullOrEmpty(d.Query)) - item.Fields.Add(("Query", Truncate(d.Query))); + item.Fields.Add(("Query", AlertContextBuilders.TruncateText(d.Query))); if (!string.IsNullOrEmpty(d.WaitResource)) item.Fields.Add(("Wait Resource", d.WaitResource)); if (!string.IsNullOrEmpty(d.LockMode)) @@ -1048,7 +1030,7 @@ deadlock event across ALL events in the window. */ if (rep is null) return null; var f = new List<AlertIncidentField>(); if (!string.IsNullOrWhiteSpace(rep.DatabaseName)) f.Add(new AlertIncidentField("Database", rep.DatabaseName)); - if (!string.IsNullOrWhiteSpace(rep.Query)) f.Add(new AlertIncidentField("Query", Truncate(rep.Query))); + if (!string.IsNullOrWhiteSpace(rep.Query)) f.Add(new AlertIncidentField("Query", AlertContextBuilders.TruncateText(rep.Query))); if (!string.IsNullOrWhiteSpace(rep.WaitResource)) f.Add(new AlertIncidentField("Wait Resource", rep.WaitResource)); if (!string.IsNullOrWhiteSpace(rep.LockMode)) f.Add(new AlertIncidentField("Lock Mode", rep.LockMode)); return f.Count > 0 ? f : null; @@ -1072,194 +1054,11 @@ private static bool IsDeadlockExcluded(DeadlockItem deadlock, List<string> exclu catch { return false; } } - private static AlertContext? BuildPoisonWaitContext(List<PoisonWaitDelta> triggeredWaits) - { - if (triggeredWaits.Count == 0) return null; - - var context = new AlertContext(); - foreach (var w in triggeredWaits) - { - context.Details.Add(new AlertDetailItem - { - Heading = w.WaitType, - Fields = new() - { - ("Avg ms/wait", $"{w.AvgMsPerWait:F1}"), - ("Delta wait ms", $"{w.DeltaMs:N0}"), - ("Delta tasks", $"{w.DeltaTasks:N0}") - } - }); - } - return context; - } - - private static AlertContext? BuildLongRunningQueryContext(string serverName, List<LongRunningQueryInfo> queries) - { - if (queries.Count == 0) return null; - - var context = new AlertContext(); - var shown = queries.GetRange(0, Math.Min(3, queries.Count)); - foreach (var q in shown) - { - var item = new AlertDetailItem - { - Heading = $"Session #{q.SessionId} — {q.ElapsedSeconds / 60}m {q.ElapsedSeconds % 60}s", - Fields = new() - }; - - if (!string.IsNullOrEmpty(q.DatabaseName)) - item.Fields.Add(("Database", q.DatabaseName)); - if (!string.IsNullOrEmpty(q.ProgramName)) - item.Fields.Add(("Program", q.ProgramName)); - if (!string.IsNullOrEmpty(q.QueryText)) - item.Fields.Add(("Query", Truncate(q.QueryText))); - item.Fields.Add(("CPU Time", $"{q.CpuTimeMs:N0} ms")); - item.Fields.Add(("Reads", $"{q.Reads:N0}")); - item.Fields.Add(("Writes", $"{q.Writes:N0}")); - if (!string.IsNullOrEmpty(q.WaitType)) - item.Fields.Add(("Wait Type", q.WaitType)); - if (q.BlockingSessionId.HasValue && q.BlockingSessionId.Value > 0) - item.Fields.Add(("Blocked By", $"Session #{q.BlockingSessionId.Value}")); - - context.Details.Add(item); - } - - /* #1140: dedup key = query_hash (stable across literals/plans). Null hash -> no incident. */ - AlertIncidentRenderer.Apply(context, shown - .Select(q => AlertFingerprint.ForKey(serverName, AlertFingerprint.Query, q.QueryHash ?? "", - string.IsNullOrEmpty(q.DatabaseName) ? System.Array.Empty<string>() : new[] { q.DatabaseName })) - .Where(i => i is not null).Select(i => i!).ToList()); - return context; - } - - private static AlertContext? BuildAnomalousJobContext(string serverName, List<AnomalousJobInfo> jobs) - { - if (jobs.Count == 0) return null; - - var context = new AlertContext(); - var shown = jobs.GetRange(0, Math.Min(3, jobs.Count)); - foreach (var j in shown) - { - context.Details.Add(new AlertDetailItem - { - Heading = j.JobName, - Fields = new() - { - ("Current Duration", FormatDuration(j.CurrentDurationSeconds)), - ("Avg Duration", FormatDuration(j.AvgDurationSeconds)), - ("P95 Duration", FormatDuration(j.P95DurationSeconds)), - ("% of Average", j.PercentOfAverage.HasValue ? $"{j.PercentOfAverage:F0}%" : "N/A"), - ("Started", j.StartTime.ToString("yyyy-MM-dd HH:mm:ss")) - } - }); - } - - /* #1140: dedup key per job (job name, scoped to the instance via serverName). */ - AlertIncidentRenderer.Apply(context, shown - .Select(j => AlertFingerprint.ForKey(serverName, AlertFingerprint.Job, j.JobName, new[] { j.JobName })) - .Where(i => i is not null).Select(i => i!).ToList()); - return context; - } - - private static AlertContext? BuildFailedJobContext(string serverName, List<FailedJobInfo> jobs) - { - if (jobs.Count == 0) return null; - - var context = new AlertContext(); - var shown = jobs.GetRange(0, Math.Min(5, jobs.Count)); - foreach (var j in shown) - { - var item = new AlertDetailItem { Heading = j.JobName, Fields = new() }; - item.Fields.Add(("Job", j.JobName)); - item.Fields.Add(("Failed At", j.RunDateTimeFormatted)); - if (!string.IsNullOrEmpty(j.Message)) - item.Fields.Add(("Message", Truncate(j.Message, 300))); - context.Details.Add(item); - } - - /* #1140: dedup key per job (job name, scoped to the instance via serverName) — mirrors - BuildAnomalousJobContext so two distinct failed jobs are distinct incidents under the - #1154 per-fingerprint cooldown instead of coalescing on the metric key. */ - AlertIncidentRenderer.Apply(context, shown - .Select(j => AlertFingerprint.ForKey(serverName, AlertFingerprint.Job, j.JobName, new[] { j.JobName })) - .Where(i => i is not null).Select(i => i!).ToList()); - return context; - } - - private static string FormatDuration(long seconds) - { - if (seconds < 60) return $"{seconds}s"; - if (seconds < 3600) return $"{seconds / 60}m {seconds % 60}s"; - return $"{seconds / 3600}h {(seconds % 3600) / 60}m"; - } - - /* Returns the volumes whose free space is under the configured % or GB threshold (a 0 threshold - disables that dimension), worst (lowest free %) first, so the alert names the tightest volume. */ - private static List<VolumeFreeSpaceInfo> GetBreachedVolumes(List<VolumeFreeSpaceInfo> volumes, UserPreferences prefs) - { - int pct = prefs.LowDiskThresholdPercent; - int gb = prefs.LowDiskThresholdGb; - return volumes - .Where(v => (pct > 0 && v.FreePercent < pct) || (gb > 0 && v.FreeGb < gb)) - .OrderBy(v => v.FreePercent) - .ToList(); - } - - private static string FormatLowDiskThreshold(UserPreferences prefs) - { - var parts = new List<string>(); - if (prefs.LowDiskThresholdPercent > 0) parts.Add($"{prefs.LowDiskThresholdPercent}%"); - if (prefs.LowDiskThresholdGb > 0) parts.Add($"{prefs.LowDiskThresholdGb} GB"); - return parts.Count > 0 ? string.Join(" / ", parts) : "—"; - } - - private static AlertContext? BuildVolumeFreeSpaceContext(string serverName, List<VolumeFreeSpaceInfo> volumes) - { - if (volumes.Count == 0) return null; - - var context = new AlertContext(); - var shown = volumes.GetRange(0, Math.Min(5, volumes.Count)); - foreach (var v in shown) - { - context.Details.Add(new AlertDetailItem - { - Heading = $"{v.MountPoint} — {v.FreePercent:F0}% Free", - Fields = new() - { - ("Free Space", $"{v.FreeGb:F1} GB"), - ("Total Size", $"{v.TotalMb / 1024.0:F1} GB"), - ("Used", $"{(v.TotalMb - v.FreeMb) / 1024.0:F1} GB") - } - }); - } - - /* #1140: dedup key per volume (the drive/mount point). */ - AlertIncidentRenderer.Apply(context, shown - .Select(v => AlertFingerprint.ForKey(serverName, AlertFingerprint.Disk, v.MountPoint, new[] { v.MountPoint })) - .Where(i => i is not null).Select(i => i!).ToList()); - return context; - } - - private static AlertContext? BuildTempDbSpaceContext(TempDbSpaceInfo tempDb) - { - var context = new AlertContext(); - context.Details.Add(new AlertDetailItem - { - Heading = $"TempDB — {tempDb.UsedPercent:F0}% Used", - Fields = new() - { - ("Total Reserved", $"{tempDb.TotalReservedMb:F0} MB"), - ("Unallocated", $"{tempDb.UnallocatedMb:F0} MB"), - ("User Objects", $"{tempDb.UserObjectReservedMb:F0} MB"), - ("Internal Objects", $"{tempDb.InternalObjectReservedMb:F0} MB"), - ("Version Store", $"{tempDb.VersionStoreReservedMb:F0} MB"), - ("Top Consumer", tempDb.TopConsumerSessionId > 0 - ? $"Session #{tempDb.TopConsumerSessionId} ({tempDb.TopConsumerMb:F0} MB)" - : "None") - } - }); - return context; - } + /* The six pure alert-context builders (poison wait, long-running query, volume free space, + tempdb space, anomalous job, failed job) and their pure helpers (ContextToDetailText, + Truncate/TruncateText, GetBreachedVolumes, FormatLowDiskThreshold) moved to the shared + PerformanceMonitor.Alerting.AlertContextBuilders (Phase-5 slice A). The async + blocking/deadlock builders above stay app-side — they query the Dashboard's own store. */ #endregion } diff --git a/Dashboard/MainWindow.xaml.cs b/Dashboard/MainWindow.xaml.cs index 3fbe11499..05ab3917b 100644 --- a/Dashboard/MainWindow.xaml.cs +++ b/Dashboard/MainWindow.xaml.cs @@ -1172,18 +1172,13 @@ private async void CheckServerVersion_Click(object sender, RoutedEventArgs e) string appVersion = Assembly.GetExecutingAssembly() .GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0"; - int plusIndex = appVersion.IndexOf('+'); - if (plusIndex >= 0) appVersion = appVersion[..plusIndex]; - static string Normalize(string v) => - Version.TryParse(v, out var p) ? new Version(p.Major, p.Minor, p.Build).ToString() : v; + string normalizedInstalled = VersionText.Normalize(installedVersion); + string normalizedApp = VersionText.Normalize(appVersion); - string normalizedInstalled = Normalize(installedVersion); - string normalizedApp = Normalize(appVersion); - - if (Version.TryParse(normalizedInstalled, out var installed) && - Version.TryParse(normalizedApp, out var app) && - installed < app) + Version? installed = VersionText.Parse(installedVersion); + Version? app = VersionText.Parse(appVersion); + if (installed != null && app != null && installed < app) { var result = MessageBox.Show( $"'{server.DisplayNameWithIntent}' has v{normalizedInstalled} installed.\n\nv{normalizedApp} is available. Open the server editor to upgrade?", diff --git a/Dashboard/ManageServersWindow.xaml.cs b/Dashboard/ManageServersWindow.xaml.cs index a58f4fe66..57389be9e 100644 --- a/Dashboard/ManageServersWindow.xaml.cs +++ b/Dashboard/ManageServersWindow.xaml.cs @@ -63,14 +63,7 @@ private async Task LoadInstalledVersionsAsync() await Task.WhenAll(tasks); } - private static string NormalizeVersion(string version) - { - int plusIndex = version.IndexOf('+'); - string trimmed = plusIndex >= 0 ? version[..plusIndex] : version; - return Version.TryParse(trimmed, out var v) - ? new Version(v.Major, v.Minor, v.Build).ToString() - : trimmed; - } + private static string NormalizeVersion(string version) => VersionText.Normalize(version); private void ServersDataGrid_MouseDoubleClick(object sender, MouseButtonEventArgs e) { @@ -260,24 +253,13 @@ private async void CheckForUpdates_Click(object sender, RoutedEventArgs e) string appVersion = Assembly.GetExecutingAssembly() .GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0"; - /* Strip git hash suffix if present (e.g., "2.5.0+abc123" → "2.5.0") */ - int plusIndex = appVersion.IndexOf('+'); - if (plusIndex >= 0) appVersion = appVersion[..plusIndex]; - - /* Normalize both to 3-part for comparison */ - string Normalize(string v) - { - if (Version.TryParse(v, out var parsed)) - return new Version(parsed.Major, parsed.Minor, parsed.Build).ToString(); - return v; - } - string normalizedInstalled = Normalize(installedVersion); - string normalizedApp = Normalize(appVersion); + string normalizedInstalled = VersionText.Normalize(installedVersion); + string normalizedApp = VersionText.Normalize(appVersion); - if (Version.TryParse(normalizedInstalled, out var installed) && - Version.TryParse(normalizedApp, out var app) && - installed < app) + Version? installed = VersionText.Parse(installedVersion); + Version? app = VersionText.Parse(appVersion); + if (installed != null && app != null && installed < app) { var result = MessageBox.Show( $"'{server.DisplayNameWithIntent}' has v{normalizedInstalled} installed.\n\nv{normalizedApp} is available. Open the server editor to upgrade?", @@ -419,79 +401,14 @@ private async void PurgeNow_Click(object sender, RoutedEventArgs e) } } - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.CurrentCell.Item != null) - { - var cellContent = Helpers.TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - Clipboard.SetDataObject(cellContent, false); - } - } - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid?.SelectedItem != null) - Clipboard.SetDataObject(Helpers.TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem), false); - } - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new System.Text.StringBuilder(); - var headers = new System.Collections.Generic.List<string>(); - foreach (var column in dataGrid.Columns) - headers.Add(DataGridClipboardBehavior.GetHeaderText(column)); - sb.AppendLine(string.Join("\t", headers)); - foreach (var item in dataGrid.Items) - sb.AppendLine(Helpers.TabHelpers.GetRowAsText(dataGrid, item)); - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var dialog = new Microsoft.Win32.SaveFileDialog - { - FileName = $"servers_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; - if (dialog.ShowDialog() == true) - { - var sb = new System.Text.StringBuilder(); - var headers = new System.Collections.Generic.List<string>(); - foreach (var column in dataGrid.Columns) - headers.Add(Helpers.TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(column))); - sb.AppendLine(string.Join(",", headers)); - foreach (var item in dataGrid.Items) - { - var values = Helpers.TabHelpers.GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(",", values.Select(v => Helpers.TabHelpers.EscapeCsvField(v)))); - } - System.IO.File.WriteAllText(dialog.FileName, sb.ToString()); - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "servers", Helpers.TabHelpers.CsvSeparator); private void Close_Click(object sender, RoutedEventArgs e) { diff --git a/Dashboard/Mcp/DatabaseServiceRegistry.cs b/Dashboard/Mcp/DatabaseServiceRegistry.cs index 1df8b7287..835c3441a 100644 --- a/Dashboard/Mcp/DatabaseServiceRegistry.cs +++ b/Dashboard/Mcp/DatabaseServiceRegistry.cs @@ -16,10 +16,8 @@ public sealed class DatabaseServiceRegistry : IAsyncDisposable private readonly ConcurrentDictionary<string, DatabaseService> _services = new(StringComparer.OrdinalIgnoreCase); private readonly ICredentialService _credentialService; - public DatabaseServiceRegistry(ServerManager serverManager, ICredentialService credentialService) + public DatabaseServiceRegistry(ICredentialService credentialService) { - /* serverManager passed for future use but not currently needed */ - _ = serverManager; _credentialService = credentialService; } @@ -35,15 +33,9 @@ public DatabaseService GetOrCreate(ServerConnection server) }); } - public async ValueTask DisposeAsync() + public ValueTask DisposeAsync() { - foreach (var kvp in _services) - { - if (kvp.Value is IAsyncDisposable disposable) - { - await disposable.DisposeAsync(); - } - } _services.Clear(); + return ValueTask.CompletedTask; } } diff --git a/Dashboard/Mcp/McpActiveQueryTools.cs b/Dashboard/Mcp/McpActiveQueryTools.cs index 371102442..79ee87e8d 100644 --- a/Dashboard/Mcp/McpActiveQueryTools.cs +++ b/Dashboard/Mcp/McpActiveQueryTools.cs @@ -20,9 +20,8 @@ public static async Task<string> GetActiveQueries( [Description("Hours of data to retrieve. Default 1.")] int hours_back = 1, [Description("Maximum number of rows to return. Default 50.")] int limit = 50) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; diff --git a/Dashboard/Mcp/McpAnalysisTools.cs b/Dashboard/Mcp/McpAnalysisTools.cs index 098820fbb..9e0c784ec 100644 --- a/Dashboard/Mcp/McpAnalysisTools.cs +++ b/Dashboard/Mcp/McpAnalysisTools.cs @@ -32,9 +32,8 @@ public static async Task<string> AnalyzeServer( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of data to analyze. Default 4.")] int hours_back = 4) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; @@ -134,9 +133,8 @@ public static async Task<string> GetAnalysisFacts( [Description("Filter to a specific source category. Omit for all.")] string? source = null, [Description("Minimum severity to include. Default 0.")] double min_severity = 0) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; @@ -206,9 +204,8 @@ public static async Task<string> CompareAnalysis( [Description("Hours back for the comparison period. Default 4.")] int hours_back = 4, [Description("Hours back for the baseline period start. Default 28.")] int baseline_hours_back = 28) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; @@ -281,9 +278,8 @@ public static async Task<string> AuditConfig( DatabaseServiceRegistry registry, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -352,9 +348,8 @@ public static async Task<string> GetAnalysisFindings( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of finding history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -423,9 +418,8 @@ public static async Task<string> MuteAnalysisFinding( [Description("Server name.")] string? server_name = null, [Description("Optional reason for muting.")] string? reason = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -445,47 +439,237 @@ public static async Task<string> MuteAnalysisFinding( /// <summary> /// Maps fact keys to recommended MCP tools for further investigation. -/// Shared between Lite and Dashboard — same recommendations. +/// Used by analyze_server to tell the AI client what to call next. Lite keeps its own +/// copy (PerformanceMonitorLite.Mcp) because the two apps expose different MCP tool +/// sets, so the recommendations are app-specific -- NOT a shared/duplicated list. /// </summary> internal static class ToolRecommendations { private static readonly System.Collections.Generic.Dictionary<string, System.Collections.Generic.List<ToolRecommendation>> ByFactKey = new() { - ["SOS_SCHEDULER_YIELD"] = [new("get_cpu_utilization", "Check CPU usage over time"), new("get_top_queries_by_cpu", "Find CPU-expensive queries")], - ["CXPACKET"] = [new("get_top_queries_by_cpu", "Find parallel queries", new() { ["parallel_only"] = "true" }), new("audit_config", "Check CTFP and MAXDOP")], - ["THREADPOOL"] = [new("get_top_queries_by_cpu", "Find resource-consuming queries"), new("get_blocking", "Check if blocking is holding threads")], - ["PAGEIOLATCH_SH"] = [new("get_file_io_stats", "Check I/O latency"), new("get_memory_stats", "Check buffer pool")], - ["PAGEIOLATCH_EX"] = [new("get_file_io_stats", "Check I/O latency"), new("get_memory_stats", "Check buffer pool"), new("get_tempdb_trend", "Check whether tempdb I/O is driving EX-mode waits")], - ["RESOURCE_SEMAPHORE"] = [new("get_resource_semaphore", "Check memory grants")], - ["WRITELOG"] = [new("get_file_io_stats", "Check log file latency"), new("get_perfmon_trend", "Check Transactions/sec commit rate driving log flush pressure", new() { ["counter_name"] = "Transactions/sec" })], - ["LCK"] = [new("get_blocking", "Get blocking details"), new("get_deadlocks", "Check for deadlocks")], - ["LCK_M_S"] = [new("get_blocking", "Get reader/writer blocking details")], - ["SCH_M"] = [new("get_blocking", "Check if DDL is causing blocking"), new("get_running_jobs", "See if maintenance jobs are taking schema-modification locks")], - ["BLOCKING_EVENTS"] = [new("get_blocking", "Get detailed blocking reports"), new("get_deadlocks", "Check for deadlocks")], - ["DEADLOCKS"] = [new("get_deadlocks", "Get deadlock events"), new("get_deadlock_detail", "Get full deadlock XML")], - ["CPU_SQL_PERCENT"] = [new("get_cpu_utilization", "See CPU trend"), new("get_top_queries_by_cpu", "Find CPU queries")], - ["CPU_SPIKE"] = [new("get_cpu_utilization", "See when spike occurred"), new("get_top_queries_by_cpu", "Find queries that drove the spike")], - ["IO_READ_LATENCY_MS"] = [new("get_file_io_stats", "Check per-file latency"), new("get_memory_stats", "Check buffer pool")], - ["IO_WRITE_LATENCY_MS"] = [new("get_file_io_stats", "Check per-file latency")], - ["TEMPDB_USAGE"] = [new("get_tempdb_trend", "Track TempDB usage")], - ["MEMORY_GRANT_PENDING"] = [new("get_resource_semaphore", "Check memory grants")], - ["QUERY_SPILLS"] = [new("get_top_queries_by_cpu", "Find queries with spills")], - ["QUERY_HIGH_DOP"] = [new("get_top_queries_by_cpu", "Find high-DOP queries", new() { ["parallel_only"] = "true" })], - ["PARAMETER_SENSITIVITY"] = [new("get_top_queries_by_cpu", "Find the sensitive query and see its cached parameters"), new("analyze_query_plan", "Examine the plan for operators driving the runtime variance"), new("get_query_trend", "Confirm the bimodal duration pattern over time"), new("get_resource_semaphore", "Check whether bad-parameter executions blow up memory grants")], - ["PLAN_REGRESSION"] = [new("analyze_query_store_plan", "Compare the regressed plan against the prior plan"), new("get_query_trend", "Confirm the regression timing and that the new plan is consistently worse"), new("get_query_store_top", "Pull the full Query Store entry and forced-plan history before forcing")], - ["DB_CONFIG"] = [new("audit_config", "Check configuration")], - ["FILE_AUTOGROWTH_PERCENT"] = [new("get_database_sizes", "See per-file sizes and autogrowth settings"), new("get_file_io_stats", "Check per-file growth/latency")], - ["DISK_SPACE"] = [new("get_file_io_stats", "Check per-file sizes")], - ["LATCH_EX"] = [new("get_latch_stats", "Check latch contention"), new("get_tempdb_trend", "Check TempDB")], - ["BAD_ACTOR"] = [new("get_top_queries_by_cpu", "See full query stats"), new("analyze_query_plan", "Analyze the execution plan")], - ["ANOMALY_CPU"] = [new("get_cpu_utilization", "See CPU trend"), new("get_active_queries", "Find what ran during spike")], - ["ANOMALY_WAIT"] = [new("get_wait_stats", "See wait breakdown"), new("compare_analysis", "Compare current vs baseline")], - ["ANOMALY_BLOCKING"] = [new("get_blocking", "Get blocking details"), new("get_deadlocks", "Get deadlock events")], - ["ANOMALY_IO"] = [new("get_file_io_stats", "Check I/O latency"), new("get_memory_stats", "Check buffer pool")], - ["ANOMALY_SESSION_SPIKE"] = [new("get_session_stats", "See which application is driving the session-count spike"), new("get_active_queries", "Find what those sessions were doing at the spike")], - ["ANOMALY_QUERY_DURATION"] = [new("get_top_queries_by_cpu", "Find the queries whose runtime moved the average"), new("analyze_query_plan", "Examine the plan for the queries that slowed down"), new("get_query_trend", "Track the regressed query across executions")], - ["ANOMALY_MEMORY_PRESSURE"] = [new("get_memory_stats", "See current memory allocation"), new("get_memory_clerks", "Find which clerks are growing"), new("get_memory_pressure_events", "Pull the RING_BUFFER_RESOURCE_MONITOR notifications driving the anomaly"), new("get_resource_semaphore", "Check whether query grants are competing with buffer pool")], - ["ANOMALY_BATCH_REQUESTS"] = [new("get_perfmon_trend", "Confirm the batch-rate change across the window", new() { ["counter_name"] = "Batch Requests/sec" }), new("get_top_queries_by_cpu", "Find which queries account for the new batch volume"), new("get_active_queries", "See what's actually running at the elevated rate")] + ["SOS_SCHEDULER_YIELD"] = + [ + new("get_cpu_utilization", "Check SQL Server vs other-process CPU usage over time"), + new("get_top_queries_by_cpu", "Find the most CPU-expensive queries"), + new("get_perfmon_trend", "Check batch requests/sec for throughput context", new() { ["counter_name"] = "Batch Requests/sec" }) + ], + ["CXPACKET"] = + [ + new("get_top_queries_by_cpu", "Find parallel queries consuming CPU", new() { ["parallel_only"] = "true" }), + new("get_wait_trend", "Track the parallelism wait trend over time", new() { ["wait_type"] = "CXPACKET" }), + new("audit_config", "Check CTFP and MAXDOP settings") + ], + ["THREADPOOL"] = + [ + new("get_cpu_scheduler_pressure", "Check runnable-queue depth and worker-thread exhaustion"), + new("get_top_queries_by_cpu", "Find queries consuming the most resources"), + new("get_blocking", "Check whether blocking is holding worker threads") + ], + ["PAGEIOLATCH_SH"] = + [ + new("get_file_io_stats", "Check I/O latency per database file"), + new("get_file_io_trend", "Track I/O latency over time"), + new("get_memory_stats", "Check buffer pool and memory pressure"), + new("get_resource_semaphore", "Check for memory grant pressure competing with the buffer pool") + ], + ["PAGEIOLATCH_EX"] = + [ + new("get_file_io_stats", "Check I/O latency per database file"), + new("get_file_io_trend", "Track I/O latency over time"), + new("get_memory_stats", "Check buffer pool and memory pressure"), + new("get_tempdb_trend", "Check whether tempdb I/O is driving the EX-mode waits") + ], + ["RESOURCE_SEMAPHORE"] = + [ + new("get_resource_semaphore", "Check granted vs available workspace memory and waiter counts"), + new("get_memory_stats", "Check overall memory allocation"), + new("get_top_queries_by_cpu", "Find queries requesting large memory grants") + ], + ["WRITELOG"] = + [ + new("get_file_io_stats", "Check transaction log file latency"), + new("get_file_io_trend", "Track log I/O latency over time"), + new("get_perfmon_trend", "Check Transactions/sec to see the commit rate driving log flush pressure", new() { ["counter_name"] = "Transactions/sec" }) + ], + ["LCK"] = + [ + new("get_blocking", "Get detailed blocking event reports"), + new("get_blocking_deadlock_stats", "Track blocking frequency over time"), + new("get_active_queries", "See currently running sessions with their lock/wait detail") + ], + ["LCK_M_S"] = + [ + new("get_blocking", "Get reader/writer blocking details"), + new("get_blocking_deadlock_stats", "Track blocking frequency over time") + ], + ["LCK_M_IS"] = + [ + new("get_blocking", "Get reader/writer blocking details"), + new("get_blocking_deadlock_stats", "Track blocking frequency over time") + ], + ["BLOCKING_EVENTS"] = + [ + new("get_blocking", "Get detailed blocking reports with full query text"), + new("get_blocking_deadlock_stats", "Track blocking event frequency over time"), + new("get_deadlocks", "Check whether blocking is escalating to deadlocks") + ], + ["DEADLOCKS"] = + [ + new("get_deadlocks", "Get recent deadlock events with victim info"), + new("get_deadlock_detail", "Get the full deadlock graph XML for deep analysis"), + new("get_blocking_deadlock_stats", "Track deadlock frequency over time") + ], + ["SCH_M"] = + [ + new("get_active_queries", "See what's waiting on schema-modification locks"), + new("get_blocking", "Check whether DDL operations are causing blocking"), + new("get_running_jobs", "See whether maintenance jobs (index rebuilds, stats updates) are taking schema-modification locks") + ], + ["CPU_SQL_PERCENT"] = + [ + new("get_cpu_utilization", "See the CPU trend over time"), + new("get_top_queries_by_cpu", "Find queries consuming the most CPU"), + new("get_perfmon_trend", "Check batch requests/sec for throughput context", new() { ["counter_name"] = "Batch Requests/sec" }) + ], + ["CPU_SPIKE"] = + [ + new("get_cpu_utilization", "See the CPU trend to identify when the spike occurred"), + new("get_top_queries_by_cpu", "Find the queries that drove the CPU spike"), + new("get_active_queries", "Find what was actually running during the spike") + ], + ["IO_READ_LATENCY_MS"] = + [ + new("get_file_io_stats", "Check per-file read latency"), + new("get_file_io_trend", "Track read latency over time"), + new("get_memory_stats", "Check whether the buffer pool is undersized") + ], + ["IO_WRITE_LATENCY_MS"] = + [ + new("get_file_io_stats", "Check per-file write latency"), + new("get_file_io_trend", "Track write latency over time") + ], + ["TEMPDB_USAGE"] = + [ + new("get_tempdb_trend", "Track TempDB usage over time"), + new("get_top_queries_by_cpu", "Find queries that may be spilling to TempDB") + ], + ["MEMORY_GRANT_PENDING"] = + [ + new("get_resource_semaphore", "Check active vs pending memory grants and waiter counts"), + new("get_memory_stats", "Check overall memory allocation"), + new("get_top_queries_by_cpu", "Find queries requesting large grants") + ], + ["QUERY_SPILLS"] = + [ + new("get_top_queries_by_cpu", "Find queries with spills"), + new("get_resource_semaphore", "Check memory grant pressure"), + new("get_tempdb_trend", "Check the TempDB impact from spills") + ], + ["QUERY_HIGH_DOP"] = + [ + new("get_top_queries_by_cpu", "Find high-DOP queries", new() { ["parallel_only"] = "true" }), + new("audit_config", "Check CTFP and MAXDOP settings") + ], + ["PARAMETER_SENSITIVITY"] = + [ + new("get_top_queries_by_cpu", "Find the sensitive query in the plan cache and see its current cached parameters"), + new("analyze_query_plan", "Examine the plan for the operators driving the runtime variance (seek vs scan, grant size, join type)"), + new("get_query_trend", "Confirm the bimodal duration pattern across executions over time"), + new("get_resource_semaphore", "Check whether the bad-parameter executions are also blowing up memory grants") + ], + ["PLAN_REGRESSION"] = + [ + new("analyze_query_store_plan", "Compare the regressed plan against the prior plan to see what the optimizer changed"), + new("get_query_trend", "Confirm the regression timing and that the new plan is consistently worse"), + new("get_query_store_top", "Pull the full Query Store entry including plan_id and forced-plan history before considering a force") + ], + ["LATCH_EX"] = + [ + new("get_latch_stats", "Check latch contention by class (ACCESS_METHODS_DATASET_PARENT / FGCB_ADD_REMOVE point at TempDB allocation contention)"), + new("get_tempdb_trend", "Check TempDB for allocation contention"), + new("get_wait_trend", "Track the latch contention trend", new() { ["wait_type"] = "LATCH_EX" }) + ], + ["LATCH_SH"] = + [ + new("get_latch_stats", "Check latch contention by class"), + new("get_tempdb_trend", "Check TempDB for allocation contention"), + new("get_wait_trend", "Track the latch contention trend", new() { ["wait_type"] = "LATCH_SH" }) + ], + ["DB_CONFIG"] = + [ + new("audit_config", "Check server-level configuration against best practices"), + new("get_blocking", "Check whether RCSI-off databases are seeing blocking") + ], + ["FILE_AUTOGROWTH_PERCENT"] = + [ + new("get_database_sizes", "See per-file sizes and autogrowth settings"), + new("get_file_io_stats", "Check per-file growth and latency") + ], + ["RUNNING_JOBS"] = + [ + new("get_running_jobs", "See currently running jobs with duration vs historical average and p95"), + new("get_cpu_utilization", "Check whether long-running jobs are consuming CPU") + ], + ["DISK_SPACE"] = + [ + new("get_database_sizes", "See per-file sizes and underlying volume free space"), + new("get_tempdb_trend", "Check TempDB growth on the volume") + ], + ["BAD_ACTOR"] = + [ + new("get_top_queries_by_cpu", "See full query stats for this query"), + new("analyze_query_plan", "Analyze the execution plan for optimization opportunities"), + new("get_query_trend", "Track this query's performance over time") + ], + ["ANOMALY_CPU"] = + [ + new("get_cpu_utilization", "See the CPU trend to identify when the spike occurred"), + new("get_active_queries", "Find what queries were running during the spike"), + new("get_top_queries_by_cpu", "Find the most CPU-expensive queries in the period") + ], + ["ANOMALY_WAIT"] = + [ + new("get_wait_stats", "See the full wait stats breakdown"), + new("get_wait_trend", "Track the anomalous wait type over time"), + new("compare_analysis", "Compare current vs baseline to see what changed") + ], + ["ANOMALY_BLOCKING"] = + [ + new("get_blocking", "Get detailed blocking event reports"), + new("get_deadlocks", "Get recent deadlock events"), + new("get_blocking_deadlock_stats", "Track blocking frequency over time") + ], + ["ANOMALY_IO"] = + [ + new("get_file_io_stats", "Check per-file I/O latency"), + new("get_file_io_trend", "Track I/O latency over time"), + new("get_memory_stats", "Check whether the buffer pool is undersized") + ], + ["ANOMALY_SESSION_SPIKE"] = + [ + new("get_session_stats", "See which application is driving the session-count spike"), + new("get_active_queries", "Find what those sessions were doing at the spike"), + new("get_cpu_scheduler_pressure", "Check whether the new sessions are exhausting worker threads") + ], + ["ANOMALY_QUERY_DURATION"] = + [ + new("get_query_trend", "Confirm the duration shift across the analysis window"), + new("get_top_queries_by_cpu", "Find the queries whose runtime moved the average"), + new("analyze_query_plan", "Examine the plan for the queries that slowed down") + ], + ["ANOMALY_MEMORY_PRESSURE"] = + [ + new("get_memory_stats", "See current memory allocation and target vs total"), + new("get_memory_clerks", "Find which clerks are growing"), + new("get_memory_pressure_events", "Pull the RING_BUFFER_RESOURCE_MONITOR notifications driving the anomaly"), + new("get_resource_semaphore", "Check whether query grants are competing with the buffer pool") + ], + ["ANOMALY_BATCH_REQUESTS"] = + [ + new("get_perfmon_trend", "Confirm the batch-rate change across the window", new() { ["counter_name"] = "Batch Requests/sec" }), + new("get_top_queries_by_cpu", "Find which queries account for the new batch volume"), + new("get_active_queries", "See what's actually running at the elevated rate") + ] }; public static System.Collections.Generic.List<object> GetForStoryPath(string storyPath) diff --git a/Dashboard/Mcp/McpBlockingTools.cs b/Dashboard/Mcp/McpBlockingTools.cs index 9f7f747aa..ebc92fbc0 100644 --- a/Dashboard/Mcp/McpBlockingTools.cs +++ b/Dashboard/Mcp/McpBlockingTools.cs @@ -20,11 +20,8 @@ public static async Task<string> GetBlocking( [Description("Hours of history. Default 24.")] int hours_back = 24, [Description("Maximum rows. Default 30.")] int limit = 30) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -86,11 +83,8 @@ public static async Task<string> GetDeadlocks( [Description("Hours of history. Default 24.")] int hours_back = 24, [Description("Maximum rows. Default 20.")] int limit = 20) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -154,11 +148,8 @@ public static async Task<string> GetDeadlockDetail( [Description("Hours of history. Default 24.")] int hours_back = 24, [Description("Maximum deadlocks to return. Default 5.")] int limit = 5) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -204,11 +195,8 @@ public static async Task<string> GetBlockedProcessXml( [Description("Hours of history. Default 24.")] int hours_back = 24, [Description("Maximum reports to return. Default 5.")] int limit = 5) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -254,11 +242,8 @@ public static async Task<string> GetBlockingDeadlockStats( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { diff --git a/Dashboard/Mcp/McpConfigHistoryTools.cs b/Dashboard/Mcp/McpConfigHistoryTools.cs index 3c8816ac8..28a35ec6e 100644 --- a/Dashboard/Mcp/McpConfigHistoryTools.cs +++ b/Dashboard/Mcp/McpConfigHistoryTools.cs @@ -19,9 +19,8 @@ public static async Task<string> GetServerConfigChanges( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 168 (7 days).")] int hours_back = 168) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; @@ -64,9 +63,8 @@ public static async Task<string> GetDatabaseConfigChanges( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 168 (7 days).")] int hours_back = 168) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; @@ -107,9 +105,8 @@ public static async Task<string> GetTraceFlagChanges( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 168 (7 days).")] int hours_back = 168) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; diff --git a/Dashboard/Mcp/McpCpuTools.cs b/Dashboard/Mcp/McpCpuTools.cs index 344fadaf2..661836f33 100644 --- a/Dashboard/Mcp/McpCpuTools.cs +++ b/Dashboard/Mcp/McpCpuTools.cs @@ -19,11 +19,8 @@ public static async Task<string> GetCpuUtilization( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 4.")] int hours_back = 4) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { diff --git a/Dashboard/Mcp/McpDiagnosticTools.cs b/Dashboard/Mcp/McpDiagnosticTools.cs index 84495dee5..690368a3a 100644 --- a/Dashboard/Mcp/McpDiagnosticTools.cs +++ b/Dashboard/Mcp/McpDiagnosticTools.cs @@ -19,9 +19,8 @@ public static async Task<string> GetPlanCacheBloat( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of data to analyze. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; @@ -82,9 +81,8 @@ public static async Task<string> GetCriticalIssues( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; @@ -130,9 +128,8 @@ public static async Task<string> GetSessionStats( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of data to analyze. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; diff --git a/Dashboard/Mcp/McpHealthParserTools.cs b/Dashboard/Mcp/McpHealthParserTools.cs index dfab7cfba..1fef49270 100644 --- a/Dashboard/Mcp/McpHealthParserTools.cs +++ b/Dashboard/Mcp/McpHealthParserTools.cs @@ -20,9 +20,8 @@ public static async Task<string> GetSystemHealth( [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, [Description("Maximum number of entries. Default 50.")] int limit = 50) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -35,7 +34,7 @@ public static async Task<string> GetSystemHealth( hours_back, total_entries = rows.Count, shown = Math.Min(rows.Count, limit), - entries = rows.Take(limit).Select(r => SerializeHealthItem(r)) + entries = rows.Take(limit) }, McpHelpers.JsonOptions); } catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_system_health", ex); } @@ -49,9 +48,8 @@ public static async Task<string> GetSevereErrors( [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, [Description("Maximum number of entries. Default 50.")] int limit = 50) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -64,7 +62,7 @@ public static async Task<string> GetSevereErrors( hours_back, error_count = rows.Count, shown = Math.Min(rows.Count, limit), - errors = rows.Take(limit).Select(r => SerializeHealthItem(r)) + errors = rows.Take(limit) }, McpHelpers.JsonOptions); } catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_severe_errors", ex); } @@ -78,9 +76,8 @@ public static async Task<string> GetIOIssues( [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, [Description("Maximum number of entries. Default 50.")] int limit = 50) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -93,7 +90,7 @@ public static async Task<string> GetIOIssues( hours_back, issue_count = rows.Count, shown = Math.Min(rows.Count, limit), - issues = rows.Take(limit).Select(r => SerializeHealthItem(r)) + issues = rows.Take(limit) }, McpHelpers.JsonOptions); } catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_io_issues", ex); } @@ -107,9 +104,8 @@ public static async Task<string> GetSchedulerIssues( [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, [Description("Maximum number of entries. Default 50.")] int limit = 50) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -122,7 +118,7 @@ public static async Task<string> GetSchedulerIssues( hours_back, issue_count = rows.Count, shown = Math.Min(rows.Count, limit), - issues = rows.Take(limit).Select(r => SerializeHealthItem(r)) + issues = rows.Take(limit) }, McpHelpers.JsonOptions); } catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_scheduler_issues", ex); } @@ -136,9 +132,8 @@ public static async Task<string> GetMemoryConditions( [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, [Description("Maximum number of entries. Default 50.")] int limit = 50) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -151,7 +146,7 @@ public static async Task<string> GetMemoryConditions( hours_back, event_count = rows.Count, shown = Math.Min(rows.Count, limit), - events = rows.Take(limit).Select(r => SerializeHealthItem(r)) + events = rows.Take(limit) }, McpHelpers.JsonOptions); } catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_memory_conditions", ex); } @@ -165,9 +160,8 @@ public static async Task<string> GetCPUTasks( [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, [Description("Maximum number of entries. Default 50.")] int limit = 50) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -180,7 +174,7 @@ public static async Task<string> GetCPUTasks( hours_back, event_count = rows.Count, shown = Math.Min(rows.Count, limit), - events = rows.Take(limit).Select(r => SerializeHealthItem(r)) + events = rows.Take(limit) }, McpHelpers.JsonOptions); } catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_cpu_tasks", ex); } @@ -194,9 +188,8 @@ public static async Task<string> GetMemoryBroker( [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, [Description("Maximum number of entries. Default 50.")] int limit = 50) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -209,7 +202,7 @@ public static async Task<string> GetMemoryBroker( hours_back, event_count = rows.Count, shown = Math.Min(rows.Count, limit), - events = rows.Take(limit).Select(r => SerializeHealthItem(r)) + events = rows.Take(limit) }, McpHelpers.JsonOptions); } catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_memory_broker", ex); } @@ -223,9 +216,8 @@ public static async Task<string> GetMemoryNodeOOM( [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, [Description("Maximum number of entries. Default 50.")] int limit = 50) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -238,20 +230,9 @@ public static async Task<string> GetMemoryNodeOOM( hours_back, event_count = rows.Count, shown = Math.Min(rows.Count, limit), - events = rows.Take(limit).Select(r => SerializeHealthItem(r)) + events = rows.Take(limit) }, McpHelpers.JsonOptions); } catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_memory_node_oom", ex); } } - - /// <summary> - /// Generic serializer for HealthParser items. All HealthParser models share - /// similar structure — uses reflection-free duck typing via dynamic. - /// </summary> - private static object SerializeHealthItem(object item) - { - // All HealthParser items share a CollectionTime property and varying detail columns. - // Serialize the full object and let JSON handle the properties. - return item; - } } diff --git a/Dashboard/Mcp/McpHealthTools.cs b/Dashboard/Mcp/McpHealthTools.cs index b68fdd899..4b7b72d80 100644 --- a/Dashboard/Mcp/McpHealthTools.cs +++ b/Dashboard/Mcp/McpHealthTools.cs @@ -18,11 +18,8 @@ public static async Task<string> GetCollectionHealth( DatabaseServiceRegistry registry, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -64,11 +61,8 @@ public static async Task<string> GetDailySummary( [Description("Server name or display name.")] string? server_name = null, [Description("Summary date (yyyy-MM-dd). Default is today.")] string? summary_date = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { diff --git a/Dashboard/Mcp/McpHostService.cs b/Dashboard/Mcp/McpHostService.cs index ac8150cd7..b97b9c356 100644 --- a/Dashboard/Mcp/McpHostService.cs +++ b/Dashboard/Mcp/McpHostService.cs @@ -53,7 +53,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) builder.Logging.SetMinimumLevel(LogLevel.Warning); /* Register services that MCP tools need via dependency injection */ - _registry = new DatabaseServiceRegistry(_serverManager, _credentialService); + _registry = new DatabaseServiceRegistry(_credentialService); builder.Services.AddSingleton(_serverManager); builder.Services.AddSingleton(_registry); builder.Services.AddSingleton(_muteRuleService); diff --git a/Dashboard/Mcp/McpIoTools.cs b/Dashboard/Mcp/McpIoTools.cs index 779b3fea6..7334c25dd 100644 --- a/Dashboard/Mcp/McpIoTools.cs +++ b/Dashboard/Mcp/McpIoTools.cs @@ -18,11 +18,8 @@ public static async Task<string> GetFileIoStats( DatabaseServiceRegistry registry, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -65,11 +62,8 @@ public static async Task<string> GetFileIoTrend( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { diff --git a/Dashboard/Mcp/McpJobTools.cs b/Dashboard/Mcp/McpJobTools.cs index 273fc0c08..5611e16d3 100644 --- a/Dashboard/Mcp/McpJobTools.cs +++ b/Dashboard/Mcp/McpJobTools.cs @@ -26,11 +26,8 @@ public static async Task<string> GetRunningJobs( DatabaseServiceRegistry registry, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { diff --git a/Dashboard/Mcp/McpLatchSpinlockTools.cs b/Dashboard/Mcp/McpLatchSpinlockTools.cs index 544f8f46a..16f6defcd 100644 --- a/Dashboard/Mcp/McpLatchSpinlockTools.cs +++ b/Dashboard/Mcp/McpLatchSpinlockTools.cs @@ -20,9 +20,8 @@ public static async Task<string> GetLatchStats( [Description("Hours of data to analyze. Default 24.")] int hours_back = 24, [Description("Number of top latch classes to return. Default 10.")] int top = 10) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; @@ -83,9 +82,8 @@ public static async Task<string> GetSpinlockStats( [Description("Hours of data to analyze. Default 24.")] int hours_back = 24, [Description("Number of top spinlocks to return. Default 10.")] int top = 10) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; diff --git a/Dashboard/Mcp/McpMemoryTools.cs b/Dashboard/Mcp/McpMemoryTools.cs index 0434344b3..e70805a39 100644 --- a/Dashboard/Mcp/McpMemoryTools.cs +++ b/Dashboard/Mcp/McpMemoryTools.cs @@ -19,11 +19,8 @@ public static async Task<string> GetMemoryStats( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -71,11 +68,8 @@ public static async Task<string> GetMemoryTrend( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -117,11 +111,8 @@ public static async Task<string> GetMemoryClerks( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -166,11 +157,8 @@ public static async Task<string> GetMemoryGrants( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { diff --git a/Dashboard/Mcp/McpObjectStatsTools.cs b/Dashboard/Mcp/McpObjectStatsTools.cs index db5ca5320..47a361bfb 100644 --- a/Dashboard/Mcp/McpObjectStatsTools.cs +++ b/Dashboard/Mcp/McpObjectStatsTools.cs @@ -18,11 +18,8 @@ public static async Task<string> GetTableIndexSizes( DatabaseServiceRegistry registry, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -65,11 +62,8 @@ public static async Task<string> GetIndexUsage( DatabaseServiceRegistry registry, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -115,11 +109,8 @@ public static async Task<string> GetObjectLocking( DatabaseServiceRegistry registry, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { diff --git a/Dashboard/Mcp/McpPerfmonTools.cs b/Dashboard/Mcp/McpPerfmonTools.cs index 01eb5f88a..5e45f90d1 100644 --- a/Dashboard/Mcp/McpPerfmonTools.cs +++ b/Dashboard/Mcp/McpPerfmonTools.cs @@ -23,11 +23,8 @@ public static async Task<string> GetPerfmonStats( [Description("Filter to a specific counter name, e.g. 'Batch Requests/sec'. Partial match.")] string? counter_name = null, [Description("Filter to a specific instance name, e.g. a database name. Partial match.")] string? instance_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -80,11 +77,8 @@ public static async Task<string> GetPerfmonTrend( [Description("Filter to a specific counter name, e.g. 'Batch Requests/sec'. Partial match.")] string? counter_name = null, [Description("Filter to a specific instance name, e.g. a database name. Partial match.")] string? instance_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { diff --git a/Dashboard/Mcp/McpPlanTools.cs b/Dashboard/Mcp/McpPlanTools.cs index 850985e1e..f7d6b8332 100644 --- a/Dashboard/Mcp/McpPlanTools.cs +++ b/Dashboard/Mcp/McpPlanTools.cs @@ -24,9 +24,8 @@ public static async Task<string> AnalyzeQueryPlan( [Description("The query_hash value from get_top_queries_by_cpu.")] string query_hash, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -54,9 +53,8 @@ public static async Task<string> AnalyzeProcedurePlan( [Description("The sql_handle value from get_top_procedures_by_cpu.")] string sql_handle, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -85,9 +83,8 @@ public static async Task<string> AnalyzeQueryStorePlan( [Description("The query_id from get_query_store_top.")] long query_id, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -135,9 +132,8 @@ public static async Task<string> GetPlanXml( [Description("The query_hash value from get_top_queries_by_cpu.")] string query_hash, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { diff --git a/Dashboard/Mcp/McpQueryTools.cs b/Dashboard/Mcp/McpQueryTools.cs index 65b53ecc2..d76f687a8 100644 --- a/Dashboard/Mcp/McpQueryTools.cs +++ b/Dashboard/Mcp/McpQueryTools.cs @@ -25,11 +25,8 @@ public static async Task<string> GetTopQueriesByCpu( [Description("If true, only return queries that used parallelism (max_dop > 1).")] bool parallel_only = false, [Description("Minimum DOP to filter on. Implies parallel filtering.")] int min_dop = 0) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -97,11 +94,8 @@ public static async Task<string> GetTopProceduresByCpu( [Description("Number of top procedures. Default 20.")] int top = 20, [Description("Filter to a specific database.")] string? database_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -164,11 +158,8 @@ public static async Task<string> GetQueryStoreTop( [Description("If true, only return queries that used parallelism (max_dop > 1).")] bool parallel_only = false, [Description("Minimum DOP to filter on. Implies parallel filtering.")] int min_dop = 0) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -228,11 +219,8 @@ public static async Task<string> GetExpensiveQueries( [Description("Number of top queries. Default 20.")] int top = 20, [Description("Filter to a specific database.")] string? database_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -293,11 +281,8 @@ public static async Task<string> GetQueryTrend( [Description("The database name the query belongs to.")] string database_name, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { diff --git a/Dashboard/Mcp/McpSchedulerTools.cs b/Dashboard/Mcp/McpSchedulerTools.cs index ffefa38b1..9c19bd11c 100644 --- a/Dashboard/Mcp/McpSchedulerTools.cs +++ b/Dashboard/Mcp/McpSchedulerTools.cs @@ -17,9 +17,8 @@ public static async Task<string> GetCpuSchedulerPressure( DatabaseServiceRegistry registry, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { diff --git a/Dashboard/Mcp/McpServerInventoryTools.cs b/Dashboard/Mcp/McpServerInventoryTools.cs index 38b1b84e6..4ddb8e48e 100644 --- a/Dashboard/Mcp/McpServerInventoryTools.cs +++ b/Dashboard/Mcp/McpServerInventoryTools.cs @@ -18,9 +18,8 @@ public static async Task<string> GetDatabaseSizes( DatabaseServiceRegistry registry, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -69,9 +68,8 @@ public static async Task<string> GetServerProperties( DatabaseServiceRegistry registry, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -89,7 +87,8 @@ public static async Task<string> GetServerProperties( socket_count = row.SocketCount, cores_per_socket = row.CoresPerSocket, is_hadr_enabled = row.IsHadrEnabled, - is_clustered = row.IsClustered + is_clustered = row.IsClustered, + hardware_unavailable_reason = row.HardwareUnavailableReason }, McpHelpers.JsonOptions); } catch (Exception ex) diff --git a/Dashboard/Mcp/McpSystemEventTools.cs b/Dashboard/Mcp/McpSystemEventTools.cs index bb28242fb..ff807900b 100644 --- a/Dashboard/Mcp/McpSystemEventTools.cs +++ b/Dashboard/Mcp/McpSystemEventTools.cs @@ -20,9 +20,8 @@ public static async Task<string> GetDefaultTraceEvents( [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, [Description("Maximum number of events to return. Default 100.")] int limit = 100) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; @@ -72,9 +71,8 @@ public static async Task<string> GetTraceAnalysis( [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, [Description("Maximum number of entries to return. Default 50.")] int limit = 50) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; @@ -135,9 +133,8 @@ public static async Task<string> GetMemoryPressureEvents( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; diff --git a/Dashboard/Mcp/McpTempDbTools.cs b/Dashboard/Mcp/McpTempDbTools.cs index a624d6e68..9b97d35dd 100644 --- a/Dashboard/Mcp/McpTempDbTools.cs +++ b/Dashboard/Mcp/McpTempDbTools.cs @@ -19,11 +19,8 @@ public static async Task<string> GetTempDbTrend( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { diff --git a/Dashboard/Mcp/McpWaitTools.cs b/Dashboard/Mcp/McpWaitTools.cs index c810ec7b0..ffcc6a93e 100644 --- a/Dashboard/Mcp/McpWaitTools.cs +++ b/Dashboard/Mcp/McpWaitTools.cs @@ -19,11 +19,8 @@ public static async Task<string> GetWaitStats( [Description("Server name or display name.")] string? server_name = null, [Description("Maximum rows to return. Default 20.")] int limit = 20) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var limitError = McpHelpers.ValidateTop(limit); if (limitError != null) return limitError; @@ -68,11 +65,8 @@ public static async Task<string> GetWaitTrend( [Description("Hours of history. Default 24.")] int hours_back = 24, [Description("Top N wait types to include. Default 5.")] int top_wait_types = 5) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var hoursError = McpHelpers.ValidateHoursBack(hours_back); if (hoursError != null) return hoursError; diff --git a/Dashboard/Mcp/ServerResolver.cs b/Dashboard/Mcp/ServerResolver.cs index c236fb1c3..ffa9dcde5 100644 --- a/Dashboard/Mcp/ServerResolver.cs +++ b/Dashboard/Mcp/ServerResolver.cs @@ -62,6 +62,22 @@ internal static class ServerResolver return new ResolvedServer(displayName, match, service); } + /// <summary> + /// Resolves a server name, returning either the resolved server or a ready-to-return error + /// string listing the available servers. Lets MCP tools collapse the repeated resolve-and-bail + /// block to: var (resolved, error) = ResolveOrError(...); if (error != null) return error; + /// </summary> + public static (ResolvedServer? resolved, string? error) ResolveOrError( + ServerManager serverManager, + DatabaseServiceRegistry registry, + string? serverName) + { + var resolved = Resolve(serverManager, registry, serverName); + return resolved is null + ? (null, $"Could not resolve server. Available servers:\n{ListAvailableServers(serverManager)}") + : (resolved, null); + } + public static string ListAvailableServers(ServerManager serverManager) { var servers = serverManager.GetAllServers(); diff --git a/Dashboard/Models/AnomalousJobInfo.cs b/Dashboard/Models/AnomalousJobInfo.cs deleted file mode 100644 index 61080558a..000000000 --- a/Dashboard/Models/AnomalousJobInfo.cs +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Performance Monitor Dashboard - * Copyright (c) 2026 Darling Data, LLC - * Licensed under the MIT License - see LICENSE file for details - */ - -using System; - -namespace PerformanceMonitorDashboard.Models -{ - public class AnomalousJobInfo - { - public string JobName { get; set; } = ""; - public string JobId { get; set; } = ""; - public long CurrentDurationSeconds { get; set; } - public long AvgDurationSeconds { get; set; } - public long P95DurationSeconds { get; set; } - public decimal? PercentOfAverage { get; set; } - public DateTime StartTime { get; set; } - } -} diff --git a/Dashboard/Models/FailedJobInfo.cs b/Dashboard/Models/FailedJobInfo.cs deleted file mode 100644 index f2d68e7ff..000000000 --- a/Dashboard/Models/FailedJobInfo.cs +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Performance Monitor Dashboard - * Copyright (c) 2026 Darling Data, LLC - * Licensed under the MIT License - see LICENSE file for details - */ - -using System; - -namespace PerformanceMonitorDashboard.Models -{ - /// <summary> - /// A SQL Agent job run (step_id = 0 outcome row) that FAILED within the alert - /// lookback window. Sourced from a live msdb.dbo.sysjobhistory query at alert-check - /// time — failure outcomes are not part of the collected running_jobs snapshot. - /// </summary> - public class FailedJobInfo - { - public string JobName { get; set; } = ""; - public string JobId { get; set; } = ""; - - /// <summary>Server-local time the failed run started (from run_date/run_time).</summary> - public DateTime RunDateTime { get; set; } - public int StepId { get; set; } - public string StepName { get; set; } = ""; - public string Message { get; set; } = ""; - - public string RunDateTimeFormatted => RunDateTime.ToString("yyyy-MM-dd HH:mm:ss"); - } -} diff --git a/Dashboard/Models/LongRunningQueryInfo.cs b/Dashboard/Models/LongRunningQueryInfo.cs deleted file mode 100644 index 4d9ee84c8..000000000 --- a/Dashboard/Models/LongRunningQueryInfo.cs +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Performance Monitor Dashboard - * Copyright (c) 2026 Darling Data, LLC - * Licensed under the MIT License - see LICENSE file for details - */ - -namespace PerformanceMonitorDashboard.Models -{ - public class LongRunningQueryInfo - { - public int SessionId { get; set; } - public string DatabaseName { get; set; } = ""; - public string QueryText { get; set; } = ""; - public string ProgramName { get; set; } = ""; - public long ElapsedSeconds { get; set; } - public long CpuTimeMs { get; set; } - public long Reads { get; set; } - public long Writes { get; set; } - public string? WaitType { get; set; } - public int? BlockingSessionId { get; set; } - public string? QueryHash { get; set; } - } -} diff --git a/Dashboard/Models/PoisonWaitDelta.cs b/Dashboard/Models/PoisonWaitDelta.cs deleted file mode 100644 index 662c00491..000000000 --- a/Dashboard/Models/PoisonWaitDelta.cs +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Performance Monitor Dashboard - * Copyright (c) 2026 Darling Data, LLC - * Licensed under the MIT License - see LICENSE file for details - */ - -namespace PerformanceMonitorDashboard.Models -{ - public class PoisonWaitDelta - { - public string WaitType { get; set; } = ""; - public long DeltaMs { get; set; } - public long DeltaTasks { get; set; } - public double AvgMsPerWait { get; set; } - } -} diff --git a/Dashboard/Models/ProcedureExecutionHistoryItem.cs b/Dashboard/Models/ProcedureExecutionHistoryItem.cs index 121308f80..b7162eff9 100644 --- a/Dashboard/Models/ProcedureExecutionHistoryItem.cs +++ b/Dashboard/Models/ProcedureExecutionHistoryItem.cs @@ -14,7 +14,6 @@ public class ProcedureExecutionHistoryItem { public long CollectionId { get; set; } public DateTime CollectionTime { get; set; } - public DateTime ServerStartTime { get; set; } public string ObjectType { get; set; } = string.Empty; public string? TypeDesc { get; set; } public DateTime CachedTime { get; set; } @@ -23,6 +22,10 @@ public class ProcedureExecutionHistoryItem // Cumulative values public long ExecutionCount { get; set; } public long IntervalExecutions { get; set; } + + // Per-interval spills, computed at load time by the same CachedTime-lifetime walk + // that produces IntervalExecutions (no SQL-side spills delta exists). + public long IntervalSpills { get; set; } public long TotalWorkerTime { get; set; } public long MinWorkerTime { get; set; } public long MaxWorkerTime { get; set; } @@ -58,13 +61,20 @@ public class ProcedureExecutionHistoryItem // Query plan public string? QueryPlanXml { get; set; } - // Computed/display helpers - public double? AvgWorkerTimeMs => ExecutionCount > 0 ? (double)TotalWorkerTime / ExecutionCount / 1000.0 : null; - public double? AvgElapsedTimeMs => ExecutionCount > 0 ? (double)TotalElapsedTime / ExecutionCount / 1000.0 : null; - public double? AvgLogicalReads => ExecutionCount > 0 ? (double)TotalLogicalReads / ExecutionCount : null; - public double? AvgPhysicalReads => ExecutionCount > 0 ? (double)TotalPhysicalReads / ExecutionCount : null; - public double? AvgLogicalWrites => ExecutionCount > 0 ? (double)TotalLogicalWrites / ExecutionCount : null; - public double? AvgSpills => ExecutionCount > 0 && TotalSpills.HasValue ? (double)TotalSpills.Value / ExecutionCount : null; + /* Averages are PER-INTERVAL (delta / delta executions), matching Lite. Lifetime-cumulative + averages (total / execution_count since cache) flatten out over time and hide recent + regressions; the Total* columns still expose the raw cumulative counters. */ + public double? AvgWorkerTimeMs => ExecutionCountDelta > 0 && TotalWorkerTimeDelta.HasValue + ? TotalWorkerTimeDelta.Value / (double)ExecutionCountDelta!.Value / 1000.0 : null; + public double? AvgElapsedTimeMs => ExecutionCountDelta > 0 && TotalElapsedTimeDelta.HasValue + ? TotalElapsedTimeDelta.Value / (double)ExecutionCountDelta!.Value / 1000.0 : null; + public double? AvgLogicalReads => ExecutionCountDelta > 0 && TotalLogicalReadsDelta.HasValue + ? TotalLogicalReadsDelta.Value / (double)ExecutionCountDelta!.Value : null; + public double? AvgPhysicalReads => ExecutionCountDelta > 0 && TotalPhysicalReadsDelta.HasValue + ? TotalPhysicalReadsDelta.Value / (double)ExecutionCountDelta!.Value : null; + public double? AvgLogicalWrites => ExecutionCountDelta > 0 && TotalLogicalWritesDelta.HasValue + ? TotalLogicalWritesDelta.Value / (double)ExecutionCountDelta!.Value : null; + public double? AvgSpills => IntervalExecutions > 0 ? IntervalSpills / (double)IntervalExecutions : null; // Worker time = CPU time in SQL Server (microseconds to ms) public double MinWorkerTimeMs => MinWorkerTime / 1000.0; diff --git a/Dashboard/Models/QueryExecutionHistoryItem.cs b/Dashboard/Models/QueryExecutionHistoryItem.cs index baf7c04bb..9559ded91 100644 --- a/Dashboard/Models/QueryExecutionHistoryItem.cs +++ b/Dashboard/Models/QueryExecutionHistoryItem.cs @@ -17,6 +17,16 @@ public class QueryExecutionHistoryItem public long PlanId { get; set; } public long CountExecutions { get; set; } + // Execution type (Regular / Aborted / Exception) - group key + public string? ExecutionTypeDesc { get; set; } + + // First/last execution time in the interval (server local time) + public DateTime? ServerFirstExecutionTime { get; set; } + public DateTime? ServerLastExecutionTime { get; set; } + + // Module (proc/function) the query came from + public string? ModuleName { get; set; } + // Duration metrics (microseconds stored, convert to ms for display) public long AvgDuration { get; set; } public long MinDuration { get; set; } @@ -27,6 +37,11 @@ public class QueryExecutionHistoryItem public long MinCpuTime { get; set; } public long MaxCpuTime { get; set; } + // CLR time metrics (microseconds stored) + public long AvgClrTime { get; set; } + public long MinClrTime { get; set; } + public long MaxClrTime { get; set; } + // Logical IO Reads public long AvgLogicalReads { get; set; } public long MinLogicalReads { get; set; } @@ -42,6 +57,11 @@ public class QueryExecutionHistoryItem public long MinPhysicalReads { get; set; } public long MaxPhysicalReads { get; set; } + // Number of physical IO reads - nullable (NULL on SQL 2016) + public long? AvgNumPhysicalReads { get; set; } + public long? MinNumPhysicalReads { get; set; } + public long? MaxNumPhysicalReads { get; set; } + // DOP (degree of parallelism) public long MinDop { get; set; } public long MaxDop { get; set; } @@ -61,6 +81,11 @@ public class QueryExecutionHistoryItem public long? MinTempdbSpaceUsed { get; set; } public long? MaxTempdbSpaceUsed { get; set; } + // Log bytes used - nullable for SQL 2016 + public long? AvgLogBytesUsed { get; set; } + public long? MinLogBytesUsed { get; set; } + public long? MaxLogBytesUsed { get; set; } + // Query identifiers (hex strings from binary columns) public string? QueryHash { get; set; } public string? QueryPlanHash { get; set; } @@ -78,9 +103,15 @@ public class QueryExecutionHistoryItem public double AvgDurationMs => AvgDuration / 1000.0; public double MinDurationMs => MinDuration / 1000.0; public double MaxDurationMs => MaxDuration / 1000.0; + // Per-interval workload weight (Query Store stats are already per-interval averages) + public double TotalDurationMs => CountExecutions * AvgDurationMs; + public double TotalCpuMs => CountExecutions * AvgCpuTimeMs; public double AvgCpuTimeMs => AvgCpuTime / 1000.0; public double MinCpuTimeMs => MinCpuTime / 1000.0; public double MaxCpuTimeMs => MaxCpuTime / 1000.0; + public double AvgClrTimeMs => AvgClrTime / 1000.0; + public double MinClrTimeMs => MinClrTime / 1000.0; + public double MaxClrTimeMs => MaxClrTime / 1000.0; // Memory in MB (8KB pages * 8 / 1024) public double AvgMemoryMb => AvgMemoryPages * 8.0 / 1024.0; @@ -91,5 +122,10 @@ public class QueryExecutionHistoryItem public double? AvgTempdbMb => AvgTempdbSpaceUsed.HasValue ? AvgTempdbSpaceUsed.Value * 8.0 / 1024.0 : null; public double? MinTempdbMb => MinTempdbSpaceUsed.HasValue ? MinTempdbSpaceUsed.Value * 8.0 / 1024.0 : null; public double? MaxTempdbMb => MaxTempdbSpaceUsed.HasValue ? MaxTempdbSpaceUsed.Value * 8.0 / 1024.0 : null; + + // Log bytes in MB (1 MB = 1,048,576 bytes) + public double? AvgLogMb => AvgLogBytesUsed.HasValue ? AvgLogBytesUsed.Value / 1048576.0 : null; + public double? MinLogMb => MinLogBytesUsed.HasValue ? MinLogBytesUsed.Value / 1048576.0 : null; + public double? MaxLogMb => MaxLogBytesUsed.HasValue ? MaxLogBytesUsed.Value / 1048576.0 : null; } } diff --git a/Dashboard/Models/QuerySnapshotItem.cs b/Dashboard/Models/QuerySnapshotItem.cs index cf79e91ae..d9f1930f0 100644 --- a/Dashboard/Models/QuerySnapshotItem.cs +++ b/Dashboard/Models/QuerySnapshotItem.cs @@ -31,7 +31,15 @@ public class QuerySnapshotItem public long? Writes { get; set; } public long? PhysicalReads { get; set; } public long? ContextSwitches { get; set; } + public long? Tasks { get; set; } + public long? PhysicalIo { get; set; } + + // Query-grant memory in MB. sp_WhoIsActive reports these as 8KB-page counts; + // the reader converts to MB (1 MB = 128 pages). public decimal? UsedMemoryMb { get; set; } + public decimal? RequestedMemoryMb { get; set; } + public decimal? GrantedMemoryMb { get; set; } + public decimal? MaxUsedMemoryMb { get; set; } public decimal? TempdbCurrentMb { get; set; } public decimal? TempdbAllocations { get; set; } public string? TranLogWrites { get; set; } @@ -41,7 +49,8 @@ public class QuerySnapshotItem public DateTime? TranStartTime { get; set; } public short? RequestId { get; set; } public string? AdditionalInfo { get; set; } - public string? Locks { get; set; } + // Shredded from the additional_info XML in the snapshot SELECTs (sp_WhoIsActive @get_additional_info) + public string? IsolationLevel { get; set; } public string? QueryPlan { get; set; } // Property alias for XAML binding compatibility diff --git a/Dashboard/Models/QueryStatsHistoryItem.cs b/Dashboard/Models/QueryStatsHistoryItem.cs index 012c46a64..1e611f055 100644 --- a/Dashboard/Models/QueryStatsHistoryItem.cs +++ b/Dashboard/Models/QueryStatsHistoryItem.cs @@ -14,7 +14,6 @@ public class QueryStatsHistoryItem { public long CollectionId { get; set; } public DateTime CollectionTime { get; set; } - public DateTime ServerStartTime { get; set; } public string ObjectType { get; set; } = string.Empty; public DateTime CreationTime { get; set; } public DateTime LastExecutionTime { get; set; } @@ -24,6 +23,11 @@ public class QueryStatsHistoryItem public long? ExecutionCountDelta { get; set; } public long IntervalExecutions { get; set; } + // Per-interval rows/spills, computed at load time by the same CreationTime-lifetime + // walk that produces IntervalExecutions (no SQL-side delta exists for these two). + public long IntervalRows { get; set; } + public long IntervalSpills { get; set; } + // Worker time (CPU) - microseconds in database public long TotalWorkerTime { get; set; } public long MinWorkerTime { get; set; } @@ -98,22 +102,34 @@ public class QueryStatsHistoryItem public double MinWorkerTimeMs => MinWorkerTime / 1000.0; public double MaxWorkerTimeMs => MaxWorkerTime / 1000.0; public double? TotalWorkerTimeDeltaMs => TotalWorkerTimeDelta / 1000.0; - public double? AvgWorkerTimeMs => ExecutionCount > 0 ? TotalWorkerTime / (double)ExecutionCount / 1000.0 : null; + + /* Averages are PER-INTERVAL (delta / delta executions), matching Lite. Lifetime-cumulative + averages (total / execution_count since plan creation) flatten out over time and hide + recent regressions; the Total* columns still expose the raw cumulative counters. */ + public double? AvgWorkerTimeMs => ExecutionCountDelta > 0 && TotalWorkerTimeDelta.HasValue + ? TotalWorkerTimeDelta.Value / (double)ExecutionCountDelta!.Value / 1000.0 : null; public double TotalElapsedTimeMs => TotalElapsedTime / 1000.0; public double MinElapsedTimeMs => MinElapsedTime / 1000.0; public double MaxElapsedTimeMs => MaxElapsedTime / 1000.0; public double? TotalElapsedTimeDeltaMs => TotalElapsedTimeDelta / 1000.0; - public double? AvgElapsedTimeMs => ExecutionCount > 0 ? TotalElapsedTime / (double)ExecutionCount / 1000.0 : null; + public double? AvgElapsedTimeMs => ExecutionCountDelta > 0 && TotalElapsedTimeDelta.HasValue + ? TotalElapsedTimeDelta.Value / (double)ExecutionCountDelta!.Value / 1000.0 : null; - public double? AvgLogicalReads => ExecutionCount > 0 ? TotalLogicalReads / (double)ExecutionCount : null; - public double? AvgPhysicalReads => ExecutionCount > 0 ? TotalPhysicalReads / (double)ExecutionCount : null; - public double? AvgLogicalWrites => ExecutionCount > 0 ? TotalLogicalWrites / (double)ExecutionCount : null; - public double? AvgRows => ExecutionCount > 0 ? TotalRows / (double)ExecutionCount : null; + public double? AvgLogicalReads => ExecutionCountDelta > 0 && TotalLogicalReadsDelta.HasValue + ? TotalLogicalReadsDelta.Value / (double)ExecutionCountDelta!.Value : null; + public double? AvgPhysicalReads => ExecutionCountDelta > 0 && TotalPhysicalReadsDelta.HasValue + ? TotalPhysicalReadsDelta.Value / (double)ExecutionCountDelta!.Value : null; + public double? AvgLogicalWrites => ExecutionCountDelta > 0 && TotalLogicalWritesDelta.HasValue + ? TotalLogicalWritesDelta.Value / (double)ExecutionCountDelta!.Value : null; + public double? AvgRows => IntervalExecutions > 0 ? IntervalRows / (double)IntervalExecutions : null; // Memory in MB + public double MinGrantMb => MinGrantKb / 1024.0; public double MaxGrantMb => MaxGrantKb / 1024.0; + public double MinUsedGrantMb => MinUsedGrantKb / 1024.0; public double MaxUsedGrantMb => MaxUsedGrantKb / 1024.0; + public double MinIdealGrantMb => MinIdealGrantKb / 1024.0; public double MaxIdealGrantMb => MaxIdealGrantKb / 1024.0; } } diff --git a/Dashboard/Models/QueryStatsItem.cs b/Dashboard/Models/QueryStatsItem.cs index 3e28a914c..60b2ccb24 100644 --- a/Dashboard/Models/QueryStatsItem.cs +++ b/Dashboard/Models/QueryStatsItem.cs @@ -45,13 +45,34 @@ public class QueryStatsItem public short? MaxDop { get; set; } public long? MinGrantKb { get; set; } public long? MaxGrantKb { get; set; } + public long? MinUsedGrantKb { get; set; } + public long? MaxUsedGrantKb { get; set; } + public long? MinIdealGrantKb { get; set; } + public long? MaxIdealGrantKb { get; set; } + public long? MinReservedThreads { get; set; } + public long? MaxReservedThreads { get; set; } + public long? MinUsedThreads { get; set; } + public long? MaxUsedThreads { get; set; } + public long TotalClrTime { get; set; } public long? MinSpills { get; set; } public long? MaxSpills { get; set; } + // Signals: plan stability (plan_generation_num — higher = more plan churn) and peak CPU + // rate (worker_time_per_second — ms of CPU per wall-clock second; 1000 ≈ one full core) + public long? PlanGenerationNum { get; set; } + public double? WorkerTimePerSecond { get; set; } + // Display helpers public double TotalWorkerTimeSec => TotalWorkerTime / 1000000.0; public double TotalElapsedTimeSec => TotalElapsedTime / 1000000.0; public double? MaxGrantMb => MaxGrantKb.HasValue ? MaxGrantKb.Value / 1024.0 : null; + public double? MinGrantMb => MinGrantKb.HasValue ? MinGrantKb.Value / 1024.0 : null; + public double? MinUsedGrantMb => MinUsedGrantKb.HasValue ? MinUsedGrantKb.Value / 1024.0 : null; + public double? MaxUsedGrantMb => MaxUsedGrantKb.HasValue ? MaxUsedGrantKb.Value / 1024.0 : null; + public double? MinIdealGrantMb => MinIdealGrantKb.HasValue ? MinIdealGrantKb.Value / 1024.0 : null; + public double? MaxIdealGrantMb => MaxIdealGrantKb.HasValue ? MaxIdealGrantKb.Value / 1024.0 : null; + // total_clr_time is stored in microseconds (like worker/elapsed time) + public double TotalClrTimeMs => TotalClrTime / 1000.0; // CPU time aliases (Worker time = CPU time in SQL Server) public double TotalCpuTimeMs => TotalWorkerTime / 1000.0; diff --git a/Dashboard/Models/QueryStoreItem.cs b/Dashboard/Models/QueryStoreItem.cs index a1c871ee9..cb98de46f 100644 --- a/Dashboard/Models/QueryStoreItem.cs +++ b/Dashboard/Models/QueryStoreItem.cs @@ -70,14 +70,17 @@ public class QueryStoreItem public string? PlanForcingType { get; set; } // CLR time (pre-calculated in ms) + public double? AvgClrTimeMs { get; set; } public double? MinClrTimeMs { get; set; } public double? MaxClrTimeMs { get; set; } // Physical IO reads (memory-optimized tables, SQL 2017+) + public long? AvgNumPhysicalIoReads { get; set; } public long? MinNumPhysicalIoReads { get; set; } public long? MaxNumPhysicalIoReads { get; set; } // Log bytes used (SQL 2017+) + public long? AvgLogBytesUsed { get; set; } public long? MinLogBytesUsed { get; set; } public long? MaxLogBytesUsed { get; set; } diff --git a/Dashboard/Models/ServerConnection.cs b/Dashboard/Models/ServerConnection.cs index 2c0fcbeb6..c50bfac72 100644 --- a/Dashboard/Models/ServerConnection.cs +++ b/Dashboard/Models/ServerConnection.cs @@ -45,9 +45,23 @@ public bool UseWindowsAuth } /// <summary> - /// Authentication type: Windows, SqlServer, or EntraMFA. + /// Authentication type: Windows, SqlServer, EntraMFA, ServicePrincipal, or ManagedIdentity. /// </summary> public string AuthenticationType { get; set; } = AuthenticationTypes.Windows; + + /// <summary> + /// Service-principal application/client id (used as UserID for ServicePrincipal auth). + /// Non-secret; safe to persist in servers.json. The SP secret is NEVER stored here — + /// it lives only in Windows Credential Manager (DPAPI) via CredentialService. + /// </summary> + public string? AzureClientId { get; set; } + + /// <summary> + /// Optional user-assigned managed identity client id. Blank = system-assigned identity. + /// Non-secret; safe to persist in servers.json. + /// </summary> + public string? ManagedIdentityClientId { get; set; } + public string? Description { get; set; } public DateTime CreatedDate { get; set; } = DateTime.Now; public DateTime LastConnected { get; set; } = DateTime.Now; @@ -129,6 +143,8 @@ public string? InstalledVersion { AuthenticationTypes.EntraMFA => "Microsoft Entra MFA", AuthenticationTypes.SqlServer => "SQL Server", + AuthenticationTypes.ServicePrincipal => "Azure — Service Principal", + AuthenticationTypes.ManagedIdentity => "Azure — Managed Identity", _ => "Windows" }; @@ -141,40 +157,36 @@ public string? InstalledVersion /// <returns>Connection string for SQL Server</returns> public string GetConnectionString(ICredentialService credentialService) { - if (AuthenticationType == AuthenticationTypes.EntraMFA) + // Single builder for every auth mode; the auth-specific keywords are applied by the shared + // ApplyAuthentication helper so this production path can never drift from the Add/Edit + // dialog's Test-Connection builder. These non-auth settings match the prior EntraMFA builder. + var builder = new SqlConnectionStringBuilder { - // Build MFA connection string with ActiveDirectoryInteractive - var mfaBuilder = new SqlConnectionStringBuilder + DataSource = ServerName, + InitialCatalog = "PerformanceMonitor", + ApplicationName = "PerformanceMonitorDashboard", + ConnectTimeout = 15, + MultipleActiveResultSets = true, + TrustServerCertificate = TrustServerCertificate, + Encrypt = EncryptMode switch { - DataSource = ServerName, - InitialCatalog = "PerformanceMonitor", - ApplicationName = "PerformanceMonitorDashboard", - ConnectTimeout = 15, - MultipleActiveResultSets = true, - TrustServerCertificate = TrustServerCertificate, - Encrypt = EncryptMode switch - { - "Optional" => SqlConnectionEncryptOption.Optional, - "Strict" => SqlConnectionEncryptOption.Strict, - _ => SqlConnectionEncryptOption.Mandatory - }, - ApplicationIntent = ReadOnlyIntent ? ApplicationIntent.ReadOnly : ApplicationIntent.ReadWrite, - MultiSubnetFailover = MultiSubnetFailover, - Authentication = SqlAuthenticationMethod.ActiveDirectoryInteractive - }; - - // Optionally pre-populate username from credential store - var mfaCred = credentialService.GetCredential(Id); - if (mfaCred.HasValue && !string.IsNullOrEmpty(mfaCred.Value.Username)) - mfaBuilder.UserID = mfaCred.Value.Username; - - return mfaBuilder.ConnectionString; - } + "Optional" => SqlConnectionEncryptOption.Optional, + "Strict" => SqlConnectionEncryptOption.Strict, + _ => SqlConnectionEncryptOption.Mandatory + }, + ApplicationIntent = ReadOnlyIntent ? ApplicationIntent.ReadOnly : ApplicationIntent.ReadWrite, + MultiSubnetFailover = MultiSubnetFailover + }; + // Resolve credentials from secure storage per auth mode (mirrors Lite's inline resolution): + // SqlServer / ServicePrincipal -> stored username + password (SP: client id + client secret) + // EntraMFA -> stored username hint only (pre-populates the interactive prompt) + // ManagedIdentity / Windows -> no stored secret string? username = null; string? password = null; - if (AuthenticationType == AuthenticationTypes.SqlServer) + if (AuthenticationType == AuthenticationTypes.SqlServer || + AuthenticationType == AuthenticationTypes.ServicePrincipal) { var cred = credentialService.GetCredential(Id); if (cred.HasValue) @@ -183,17 +195,81 @@ public string GetConnectionString(ICredentialService credentialService) password = cred.Value.Password; } } + else if (AuthenticationType == AuthenticationTypes.EntraMFA) + { + // Preserve the existing behavior: pre-populate UserID from the stored username hint. + var cred = credentialService.GetCredential(Id); + if (cred.HasValue && !string.IsNullOrEmpty(cred.Value.Username)) + { + username = cred.Value.Username; + } + } + + ApplyAuthentication(builder, AuthenticationType, username, password, AzureClientId, ManagedIdentityClientId); + + return builder.ConnectionString; + } - return DatabaseService.BuildConnectionString( - ServerName, - UseWindowsAuth, - username, - password, - EncryptMode, - TrustServerCertificate, - ReadOnlyIntent, - MultiSubnetFailover - ).ConnectionString; + // SYNC: verbatim mirror of Lite ServerConnection.ApplyAuthentication — keep the two in lockstep. + // Guarded by Dashboard.Tests/AzureAuthConnectionStringTests + Lite.Tests/AzureAuthConnectionStringTests. + /// <summary> + /// Applies the authentication-mode keywords (IntegratedSecurity / Authentication / UserID / + /// Password) to a connection-string builder. Shared by <see cref="GetConnectionString"/> and + /// the Add/Edit dialog's Test-Connection builder so the two build sites never diverge. + /// </summary> + /// <param name="builder">The builder to mutate.</param> + /// <param name="authenticationType">One of the <see cref="AuthenticationTypes"/> constants.</param> + /// <param name="username">SQL username, Entra UPN, or SP client id (depending on mode).</param> + /// <param name="password">SQL password or SP client secret (depending on mode). Never logged.</param> + /// <param name="azureClientId">SP client id fallback when <paramref name="username"/> is null.</param> + /// <param name="managedIdentityClientId">User-assigned MI client id; blank = system-assigned.</param> + internal static void ApplyAuthentication( + SqlConnectionStringBuilder builder, + string authenticationType, + string? username, + string? password, + string? azureClientId, + string? managedIdentityClientId) + { + if (authenticationType == AuthenticationTypes.Windows) + { + builder.IntegratedSecurity = true; + } + else if (authenticationType == AuthenticationTypes.SqlServer) + { + builder.IntegratedSecurity = false; + builder.UserID = username ?? string.Empty; + builder.Password = password ?? string.Empty; + } + else if (authenticationType == AuthenticationTypes.EntraMFA) + { + // Microsoft Entra MFA (Azure AD Interactive) + builder.IntegratedSecurity = false; + builder.Authentication = SqlAuthenticationMethod.ActiveDirectoryInteractive; + // Optionally set UserID (email/UPN) + if (!string.IsNullOrWhiteSpace(username)) + { + builder.UserID = username; + } + } + else if (authenticationType == AuthenticationTypes.ServicePrincipal) + { + // Microsoft Entra service principal (non-interactive): client id + secret. + builder.IntegratedSecurity = false; + builder.Authentication = SqlAuthenticationMethod.ActiveDirectoryServicePrincipal; + builder.UserID = username ?? azureClientId ?? string.Empty; // client/app id + builder.Password = password ?? string.Empty; // client secret (from Credential Manager) + } + else if (authenticationType == AuthenticationTypes.ManagedIdentity) + { + // Azure managed identity (non-interactive): no secret. + builder.IntegratedSecurity = false; + builder.Authentication = SqlAuthenticationMethod.ActiveDirectoryManagedIdentity; + if (!string.IsNullOrWhiteSpace(managedIdentityClientId)) + { + builder.UserID = managedIdentityClientId; // user-assigned MI; omit for system-assigned + } + } } /// <summary> @@ -201,14 +277,18 @@ public string GetConnectionString(ICredentialService credentialService) /// Used to validate that SQL auth servers have credentials available. /// </summary> /// <param name="credentialService">The credential service to use for checking credentials</param> - /// <returns>True if Windows auth or MFA is used, or if credentials exist in credential manager</returns> + /// <returns>True for zero-touch modes (Windows, MFA, Managed Identity), or if a credential exists in credential manager</returns> public bool HasStoredCredentials(ICredentialService credentialService) { - if (AuthenticationType == AuthenticationTypes.Windows || AuthenticationType == AuthenticationTypes.EntraMFA) + // Zero-touch auth modes need no stored secret. + if (AuthenticationType == AuthenticationTypes.Windows || + AuthenticationType == AuthenticationTypes.EntraMFA || + AuthenticationType == AuthenticationTypes.ManagedIdentity) { return true; } + // SqlServer (password) and ServicePrincipal (client secret) require a stored credential. return credentialService.CredentialExists(Id); } } diff --git a/Dashboard/Models/ServerHealthStatus.cs b/Dashboard/Models/ServerHealthStatus.cs index 6be8a9a18..52afd15ea 100644 --- a/Dashboard/Models/ServerHealthStatus.cs +++ b/Dashboard/Models/ServerHealthStatus.cs @@ -10,6 +10,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.Runtime.CompilerServices; +using PerformanceMonitor.Ui; namespace PerformanceMonitorDashboard.Models { @@ -568,12 +569,7 @@ public string ConnectionStatusText } } - private static string NormalizeVersion(string version) - { - if (Version.TryParse(version, out var parsed)) - return new Version(parsed.Major, parsed.Minor, parsed.Build).ToString(); - return version; - } + private static string NormalizeVersion(string version) => VersionText.Normalize(version); // Overall health - worst severity across all metrics public HealthSeverity OverallSeverity diff --git a/Dashboard/Models/ServerListItem.cs b/Dashboard/Models/ServerListItem.cs index 23f73a9dc..add8bf600 100644 --- a/Dashboard/Models/ServerListItem.cs +++ b/Dashboard/Models/ServerListItem.cs @@ -9,6 +9,7 @@ using System.ComponentModel; using System.Runtime.CompilerServices; using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; namespace PerformanceMonitorDashboard.Models { @@ -130,9 +131,7 @@ public string MonitorVersionDisplay { if (string.IsNullOrEmpty(_status.InstalledMonitorVersion)) return string.Empty; - if (System.Version.TryParse(_status.InstalledMonitorVersion, out var v)) - return $"Monitor v{new System.Version(v.Major, v.Minor, v.Build)}"; - return $"Monitor v{_status.InstalledMonitorVersion}"; + return $"Monitor v{VersionText.Normalize(_status.InstalledMonitorVersion)}"; } } diff --git a/Dashboard/Models/TempDbSpaceInfo.cs b/Dashboard/Models/TempDbSpaceInfo.cs deleted file mode 100644 index 26afd4a77..000000000 --- a/Dashboard/Models/TempDbSpaceInfo.cs +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Performance Monitor Dashboard - * Copyright (c) 2026 Darling Data, LLC - * Licensed under the MIT License - see LICENSE file for details - */ - -namespace PerformanceMonitorDashboard.Models -{ - public class TempDbSpaceInfo - { - public double TotalReservedMb { get; set; } - public double UnallocatedMb { get; set; } - public double UserObjectReservedMb { get; set; } - public double InternalObjectReservedMb { get; set; } - public double VersionStoreReservedMb { get; set; } - public int TopConsumerSessionId { get; set; } - public double TopConsumerMb { get; set; } - - public double UsedPercent => TotalReservedMb + UnallocatedMb > 0 - ? TotalReservedMb / (TotalReservedMb + UnallocatedMb) * 100 - : 0; - } -} diff --git a/Dashboard/Models/TracePatternDetailItem.cs b/Dashboard/Models/TracePatternDetailItem.cs index 2f374f718..e13dad784 100644 --- a/Dashboard/Models/TracePatternDetailItem.cs +++ b/Dashboard/Models/TracePatternDetailItem.cs @@ -7,7 +7,6 @@ namespace PerformanceMonitorDashboard.Models { public class TracePatternDetailItem { - public long AnalysisId { get; set; } public DateTime CollectionTime { get; set; } public string EventName { get; set; } = string.Empty; public string? DatabaseName { get; set; } @@ -16,6 +15,7 @@ public class TracePatternDetailItem public string? ApplicationName { get; set; } public string? HostName { get; set; } public int? Spid { get; set; } + public int? ClientProcessId { get; set; } public long? DurationMs { get; set; } public long? CpuMs { get; set; } public long? Reads { get; set; } diff --git a/Dashboard/Models/VolumeFreeSpaceInfo.cs b/Dashboard/Models/VolumeFreeSpaceInfo.cs deleted file mode 100644 index b01ec4120..000000000 --- a/Dashboard/Models/VolumeFreeSpaceInfo.cs +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Performance Monitor Dashboard - * Copyright (c) 2026 Darling Data, LLC - * Licensed under the MIT License - see LICENSE file for details - */ - -namespace PerformanceMonitorDashboard.Models -{ - /// <summary> - /// Free space for a single distinct volume (mount point) on a monitored server, - /// used by the low-disk alert. Azure SQL DB has no volume stats, so no rows are - /// produced there and the alert never fires. - /// </summary> - public class VolumeFreeSpaceInfo - { - public string MountPoint { get; set; } = ""; - public double TotalMb { get; set; } - public double FreeMb { get; set; } - - public double FreePercent => TotalMb > 0 ? FreeMb / TotalMb * 100 : 0; - public double FreeGb => FreeMb / 1024.0; - } -} diff --git a/Dashboard/MuteRuleDialog.xaml b/Dashboard/MuteRuleDialog.xaml index 26461b344..17c21d4bb 100644 --- a/Dashboard/MuteRuleDialog.xaml +++ b/Dashboard/MuteRuleDialog.xaml @@ -71,7 +71,7 @@ <ComboBoxItem Content="High CPU"/> <ComboBoxItem Content="Poison Wait"/> <ComboBoxItem Content="Long-Running Query"/> - <ComboBoxItem Content="TempDB Space"/> + <ComboBoxItem Content="tempdb Space"/> <ComboBoxItem Content="Volume Free Space"/> <ComboBoxItem Content="Long-Running Job"/> </ComboBox> diff --git a/Dashboard/ProcedureHistoryWindow.xaml b/Dashboard/ProcedureHistoryWindow.xaml index 42e4d64c2..bb896033d 100644 --- a/Dashboard/ProcedureHistoryWindow.xaml +++ b/Dashboard/ProcedureHistoryWindow.xaml @@ -90,336 +90,64 @@ Margin="10"> <DataGrid.Columns> <!-- Collection Info --> - <DataGridTextColumn Binding="{Binding CollectionTime, Converter={StaticResource ServerTimeConverter}}" Width="150"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CollectionTime" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Collection Time" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding LastExecutionTime, Converter={StaticResource ServerTimeConverter}}" Width="150"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LastExecutionTime" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Last Execution" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding CachedTime, Converter={StaticResource ServerTimeConverter}}" Width="150"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CachedTime" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Cached Time" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding ObjectType}" Width="90"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ObjectType" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Object Type" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding TypeDesc}" Width="120"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TypeDesc" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Type Desc" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Collection Time" Binding="{Binding CollectionTime, Converter={StaticResource ServerTimeConverter}}" Width="150"/> + <DataGridTextColumn Header="Last Execution" Binding="{Binding LastExecutionTime, Converter={StaticResource ServerTimeConverter}}" Width="150"/> + <DataGridTextColumn Header="Cached Time" Binding="{Binding CachedTime, Converter={StaticResource ServerTimeConverter}}" Width="150"/> + <DataGridTextColumn Header="Object Type" Binding="{Binding ObjectType}" Width="90"/> + <DataGridTextColumn Header="Type Desc" Binding="{Binding TypeDesc}" Width="120"/> <!-- Execution Count (per-interval, computed from cumulative counter) --> - <DataGridTextColumn Binding="{Binding IntervalExecutions, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IntervalExecutions" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Exec Count" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding ExecutionCountDelta, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="85"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ExecutionCountDelta" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Exec Delta" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Exec Count" Binding="{Binding IntervalExecutions, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Exec Delta" Binding="{Binding ExecutionCountDelta, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="85"/> <!-- Worker Time (CPU) --> - <DataGridTextColumn Binding="{Binding TotalWorkerTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="110"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalWorkerTimeMs" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Total CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding AvgWorkerTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgWorkerTimeMs" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Avg CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MinWorkerTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinWorkerTimeMs" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Min CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MaxWorkerTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxWorkerTimeMs" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Max CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding TotalWorkerTimeDeltaMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="105"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalWorkerTimeDeltaMs" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="CPU Delta (ms)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Total CPU (ms)" Binding="{Binding TotalWorkerTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="Avg CPU (ms)" Binding="{Binding AvgWorkerTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Min CPU (ms)" Binding="{Binding MinWorkerTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Max CPU (ms)" Binding="{Binding MaxWorkerTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="CPU Delta (ms)" Binding="{Binding TotalWorkerTimeDeltaMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="105"/> <!-- Elapsed Time --> - <DataGridTextColumn Binding="{Binding TotalElapsedTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="125"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalElapsedTimeMs" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Total Duration (ms)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding AvgElapsedTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgElapsedTimeMs" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Avg Duration (ms)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MinElapsedTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinElapsedTimeMs" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Min Duration (ms)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MaxElapsedTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxElapsedTimeMs" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Max Duration (ms)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding TotalElapsedTimeDeltaMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="125"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalElapsedTimeDeltaMs" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Duration Delta (ms)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Total Duration (ms)" Binding="{Binding TotalElapsedTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="125"/> + <DataGridTextColumn Header="Avg Duration (ms)" Binding="{Binding AvgElapsedTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Min Duration (ms)" Binding="{Binding MinElapsedTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Max Duration (ms)" Binding="{Binding MaxElapsedTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Duration Delta (ms)" Binding="{Binding TotalElapsedTimeDeltaMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="125"/> <!-- Logical Reads --> - <DataGridTextColumn Binding="{Binding TotalLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="125"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalLogicalReads" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Total Logical Reads" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding AvgLogicalReads, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgLogicalReads" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Avg Logical Reads" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MinLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="115"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinLogicalReads" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Min Logical Reads" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MaxLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="115"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxLogicalReads" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Max Logical Reads" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding TotalLogicalReadsDelta, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="95"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalLogicalReadsDelta" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Reads Delta" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Total Logical Reads" Binding="{Binding TotalLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="125"/> + <DataGridTextColumn Header="Avg Logical Reads" Binding="{Binding AvgLogicalReads, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Min Logical Reads" Binding="{Binding MinLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Max Logical Reads" Binding="{Binding MaxLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Reads Delta" Binding="{Binding TotalLogicalReadsDelta, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="95"/> <!-- Physical Reads --> - <DataGridTextColumn Binding="{Binding TotalPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="130"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalPhysicalReads" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Total Physical Reads" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding AvgPhysicalReads, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgPhysicalReads" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Avg Physical Reads" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MinPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinPhysicalReads" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Min Physical Reads" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MaxPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxPhysicalReads" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Max Physical Reads" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding TotalPhysicalReadsDelta, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="115"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalPhysicalReadsDelta" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Phys Reads Delta" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Total Physical Reads" Binding="{Binding TotalPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="130"/> + <DataGridTextColumn Header="Avg Physical Reads" Binding="{Binding AvgPhysicalReads, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Min Physical Reads" Binding="{Binding MinPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Max Physical Reads" Binding="{Binding MaxPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Phys Reads Delta" Binding="{Binding TotalPhysicalReadsDelta, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="115"/> <!-- Logical Writes --> - <DataGridTextColumn Binding="{Binding TotalLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="125"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalLogicalWrites" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Total Logical Writes" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding AvgLogicalWrites, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgLogicalWrites" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Avg Logical Writes" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MinLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="115"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinLogicalWrites" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Min Logical Writes" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MaxLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="115"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxLogicalWrites" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Max Logical Writes" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding TotalLogicalWritesDelta, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="95"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalLogicalWritesDelta" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Writes Delta" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Total Logical Writes" Binding="{Binding TotalLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="125"/> + <DataGridTextColumn Header="Avg Logical Writes" Binding="{Binding AvgLogicalWrites, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Min Logical Writes" Binding="{Binding MinLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Max Logical Writes" Binding="{Binding MaxLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Writes Delta" Binding="{Binding TotalLogicalWritesDelta, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="95"/> <!-- Spills --> - <DataGridTextColumn Binding="{Binding TotalSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalSpills" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Total Spills" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding AvgSpills, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="85"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgSpills" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Avg Spills" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MinSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="85"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinSpills" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Min Spills" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MaxSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="85"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxSpills" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Max Spills" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Total Spills" Binding="{Binding TotalSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Avg Spills" Binding="{Binding AvgSpills, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="85"/> + <DataGridTextColumn Header="Min Spills" Binding="{Binding MinSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="85"/> + <DataGridTextColumn Header="Max Spills" Binding="{Binding MaxSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="85"/> + <DataGridTextColumn Header="Spills Delta" Binding="{Binding IntervalSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"/> <!-- Sample Interval --> - <DataGridTextColumn Binding="{Binding SampleIntervalSeconds}" ElementStyle="{StaticResource NumericCell}" Width="85"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SampleIntervalSeconds" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Interval (sec)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Interval (sec)" Binding="{Binding SampleIntervalSeconds}" ElementStyle="{StaticResource NumericCell}" Width="85"/> <!-- Query Identifiers --> - <DataGridTextColumn Binding="{Binding SqlHandle}" Width="160"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SqlHandle" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="SQL Handle" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding PlanHandle}" Width="160"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PlanHandle" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Plan Handle" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="SQL Handle" Binding="{Binding SqlHandle}" Width="160"/> + <DataGridTextColumn Header="Plan Handle" Binding="{Binding PlanHandle}" Width="160"/> <!-- Download Plan Button --> <DataGridTemplateColumn Header="Query Plan" Width="Auto" MinWidth="90"> diff --git a/Dashboard/ProcedureHistoryWindow.xaml.cs b/Dashboard/ProcedureHistoryWindow.xaml.cs index efed59eff..e65ed4eb0 100644 --- a/Dashboard/ProcedureHistoryWindow.xaml.cs +++ b/Dashboard/ProcedureHistoryWindow.xaml.cs @@ -22,6 +22,7 @@ using PerformanceMonitorDashboard.Services; using ScottPlot; using PerformanceMonitor.Ui; +using PerformanceMonitor.PlanAnalysis; using PerformanceMonitor.Common; namespace PerformanceMonitorDashboard @@ -42,8 +43,7 @@ public partial class ProcedureHistoryWindow : Window private ChartHoverHelper? _chartHover; // Filter state - private Dictionary<string, ColumnFilterState> _filters = new(); - private List<ProcedureExecutionHistoryItem>? _unfilteredData; + private readonly DataGridFilterManager<ProcedureExecutionHistoryItem> _filterManager; private Popup? _filterPopup; private ColumnFilterPopup? _filterPopupContent; @@ -77,6 +77,10 @@ public ProcedureHistoryWindow( _databaseService.ConnectionString, db, qt, est, iso, isAzureSqlDb: false, timeoutSeconds: 0, ct), "the monitored server"); + _filterManager = new DataGridFilterManager<ProcedureExecutionHistoryItem>(HistoryDataGrid); + DataGridFilterColumns.AddFilterButtons(HistoryDataGrid, Filter_Click); + _filterManager.UpdateFilterButtonStyles(); + ProcedureIdentifierText.Text = $"Procedure Execution History: {objectName} in [{databaseName}]"; ApplyThemeToChart(); @@ -121,34 +125,27 @@ private async Task LoadHistoryAsync() ? await _databaseService.GetProcedureStatsHistoryAsync(_databaseName, _schemaName, _procedureName, _hoursBack, _fromDate, _toDate) : await _databaseService.GetProcedureStatsHistoryAsync(_databaseName, _objectId, _hoursBack, _fromDate, _toDate); - // Compute per-interval executions. DMV counters are cumulative and reset on plan - // eviction, so we walk oldest→newest, detecting lifetime boundaries by CachedTime. + // Compute per-interval executions/spills. DMV counters are cumulative and reset on + // plan eviction, so we walk oldest→newest, detecting lifetime boundaries by CachedTime. // Data arrives sorted by CollectionTime DESC, so walk from end to start. + // (Spills have no SQL-side delta column, hence the client-side walk.) for (int i = _historyData.Count - 1; i >= 0; i--) { var item = _historyData[i]; - if (i == _historyData.Count - 1) + var olderItem = i == _historyData.Count - 1 ? null : _historyData[i + 1]; + if (olderItem == null || item.CachedTime != olderItem.CachedTime) { item.IntervalExecutions = item.ExecutionCount; + item.IntervalSpills = item.TotalSpills ?? 0; } else { - var olderItem = _historyData[i + 1]; - if (item.CachedTime != olderItem.CachedTime) - { - item.IntervalExecutions = item.ExecutionCount; - } - else - { - item.IntervalExecutions = Math.Max(0, item.ExecutionCount - olderItem.ExecutionCount); - } + item.IntervalExecutions = Math.Max(0, item.ExecutionCount - olderItem.ExecutionCount); + item.IntervalSpills = Math.Max(0, (item.TotalSpills ?? 0) - (olderItem.TotalSpills ?? 0)); } } - _unfilteredData = _historyData; - _filters.Clear(); - HistoryDataGrid.ItemsSource = _historyData; - UpdateFilterButtonStyles(); + _filterManager.UpdateData(_historyData); if (_historyData.Count > 0) { @@ -313,7 +310,7 @@ private void Filter_Click(object sender, RoutedEventArgs e) }; } - _filters.TryGetValue(columnName, out var existingFilter); + _filterManager.Filters.TryGetValue(columnName, out var existingFilter); _filterPopupContent!.Initialize(columnName, existingFilter); _filterPopup.PlacementTarget = button; @@ -323,14 +320,7 @@ private void Filter_Click(object sender, RoutedEventArgs e) private void FilterPopup_FilterApplied(object? sender, FilterAppliedEventArgs e) { if (_filterPopup != null) _filterPopup.IsOpen = false; - - if (e.FilterState.IsActive) - _filters[e.FilterState.ColumnName] = e.FilterState; - else - _filters.Remove(e.FilterState.ColumnName); - - ApplyFilters(); - UpdateFilterButtonStyles(); + _filterManager.SetFilter(e.FilterState); } private void FilterPopup_FilterCleared(object? sender, EventArgs e) @@ -338,147 +328,18 @@ private void FilterPopup_FilterCleared(object? sender, EventArgs e) if (_filterPopup != null) _filterPopup.IsOpen = false; } - private void ApplyFilters() - { - if (_unfilteredData == null) return; - - if (_filters.Count == 0) - { - HistoryDataGrid.ItemsSource = _unfilteredData; - return; - } - - var filtered = _unfilteredData.Where(item => - { - foreach (var filter in _filters.Values) - { - if (filter.IsActive && !DataGridFilterService.MatchesFilter(item, filter)) - return false; - } - return true; - }).ToList(); - - HistoryDataGrid.ItemsSource = filtered; - } - - private void UpdateFilterButtonStyles() - { - foreach (var column in HistoryDataGrid.Columns) - { - if (column.Header is StackPanel stackPanel) - { - var filterButton = stackPanel.Children.OfType<Button>().FirstOrDefault(); - if (filterButton?.Tag is string columnName) - { - bool hasActive = _filters.TryGetValue(columnName, out var filter) && filter.IsActive; - filterButton.Content = new System.Windows.Controls.TextBlock - { - Text = "\uE71C", - FontFamily = new System.Windows.Media.FontFamily("Segoe MDL2 Assets"), - Foreground = hasActive - ? new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0xFF, 0xD7, 0x00)) - : new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0xFF, 0xFF, 0xFF)) - }; - filterButton.ToolTip = hasActive && filter != null - ? $"Filter: {filter.DisplayText}\n(Click to modify)" - : "Click to filter"; - } - } - } - } - #endregion #region Context Menu Handlers - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.CurrentCell.Item != null) - { - var cellContent = TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - Clipboard.SetDataObject(cellContent, false); - } - } - } - - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid?.SelectedItem != null) - Clipboard.SetDataObject(TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem), false); - } - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new StringBuilder(); - var headers = new List<string>(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - headers.Add(DataGridClipboardBehavior.GetHeaderText(column)); - } - sb.AppendLine(string.Join("\t", headers)); - foreach (var item in dataGrid.Items) - sb.AppendLine(TabHelpers.GetRowAsText(dataGrid, item)); - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var saveFileDialog = new SaveFileDialog - { - FileName = $"procedure_history_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - if (saveFileDialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); - var headers = new List<string>(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - headers.Add(TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(column))); - } - sb.AppendLine(string.Join(",", headers)); - foreach (var item in dataGrid.Items) - { - var values = TabHelpers.GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(",", values.Select(v => TabHelpers.EscapeCsvField(v)))); - } - File.WriteAllText(saveFileDialog.FileName, sb.ToString()); - MessageBox.Show($"Data exported successfully to:\n{saveFileDialog.FileName}", "Export Complete", MessageBoxButton.OK, MessageBoxImage.Information); - } - catch (Exception ex) - { - MessageBox.Show($"Error exporting data:\n\n{ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "procedure_history", TabHelpers.CsvSeparator); #endregion diff --git a/Dashboard/QueryExecutionHistoryWindow.xaml b/Dashboard/QueryExecutionHistoryWindow.xaml index a92f8b352..b93c981ee 100644 --- a/Dashboard/QueryExecutionHistoryWindow.xaml +++ b/Dashboard/QueryExecutionHistoryWindow.xaml @@ -95,324 +95,87 @@ Margin="10"> <DataGrid.Columns> <!-- Collection Info --> - <DataGridTextColumn Binding="{Binding CollectionTime, Converter={StaticResource ServerTimeConverter}}" Width="150"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CollectionTime" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Collection Time" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding PlanId}" ElementStyle="{StaticResource NumericCell}" Width="70"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PlanId" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Plan ID" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding CountExecutions, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CountExecutions" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Executions" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Collection Time" Binding="{Binding CollectionTime, Converter={StaticResource ServerTimeConverter}}" Width="150"/> + <DataGridTextColumn Header="Plan ID" Binding="{Binding PlanId}" ElementStyle="{StaticResource NumericCell}" Width="70"/> + <!-- Execution Type / First & Last Exec / Module --> + <DataGridTextColumn Header="Exec Type" Binding="{Binding ExecutionTypeDesc}" Width="100"/> + <DataGridTextColumn Header="First Exec" Binding="{Binding ServerFirstExecutionTime, Converter={StaticResource ServerTimeConverter}}" Width="150"/> + <DataGridTextColumn Header="Last Exec" Binding="{Binding ServerLastExecutionTime, Converter={StaticResource ServerTimeConverter}}" Width="150"/> + <DataGridTextColumn Header="Module" Binding="{Binding ModuleName}" Width="160"/> + <DataGridTextColumn Header="Executions" Binding="{Binding CountExecutions, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"/> <!-- Duration (ms) --> - <DataGridTextColumn Binding="{Binding AvgDurationMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgDurationMs" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Avg Duration (ms)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MinDurationMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinDurationMs" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Min Duration (ms)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MaxDurationMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxDurationMs" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Max Duration (ms)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Total Duration (ms)" Binding="{Binding TotalDurationMs, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="130"/> + <DataGridTextColumn Header="Avg Duration (ms)" Binding="{Binding AvgDurationMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Min Duration (ms)" Binding="{Binding MinDurationMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Max Duration (ms)" Binding="{Binding MaxDurationMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"/> <!-- CPU (ms) --> - <DataGridTextColumn Binding="{Binding AvgCpuTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgCpuTimeMs" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Avg CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MinCpuTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinCpuTimeMs" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Min CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MaxCpuTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxCpuTimeMs" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Max CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Total CPU (ms)" Binding="{Binding TotalCpuMs, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="Avg CPU (ms)" Binding="{Binding AvgCpuTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Min CPU (ms)" Binding="{Binding MinCpuTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Max CPU (ms)" Binding="{Binding MaxCpuTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + + <!-- CLR (ms) --> + <DataGridTextColumn Header="Avg CLR (ms)" Binding="{Binding AvgClrTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Min CLR (ms)" Binding="{Binding MinClrTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Max CLR (ms)" Binding="{Binding MaxClrTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> <!-- Logical Reads --> - <DataGridTextColumn Binding="{Binding AvgLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="115"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgLogicalReads" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Avg Logical Reads" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MinLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="115"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinLogicalReads" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Min Logical Reads" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MaxLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="115"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxLogicalReads" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Max Logical Reads" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Avg Logical Reads" Binding="{Binding AvgLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Min Logical Reads" Binding="{Binding MinLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Max Logical Reads" Binding="{Binding MaxLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="115"/> <!-- Logical Writes --> - <DataGridTextColumn Binding="{Binding AvgLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="115"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgLogicalWrites" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Avg Logical Writes" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MinLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="115"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinLogicalWrites" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Min Logical Writes" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MaxLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="115"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxLogicalWrites" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Max Logical Writes" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Avg Logical Writes" Binding="{Binding AvgLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Min Logical Writes" Binding="{Binding MinLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Max Logical Writes" Binding="{Binding MaxLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + + <!-- Log (MB) --> + <DataGridTextColumn Header="Avg Log (MB)" Binding="{Binding AvgLogMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Min Log (MB)" Binding="{Binding MinLogMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Max Log (MB)" Binding="{Binding MaxLogMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> <!-- Physical Reads --> - <DataGridTextColumn Binding="{Binding AvgPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgPhysicalReads" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Avg Physical Reads" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MinPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinPhysicalReads" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Min Physical Reads" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MaxPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxPhysicalReads" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Max Physical Reads" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Avg Physical Reads" Binding="{Binding AvgPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Min Physical Reads" Binding="{Binding MinPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Max Physical Reads" Binding="{Binding MaxPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + + <!-- Num Physical Reads --> + <DataGridTextColumn Header="Avg Num Physical Reads" Binding="{Binding AvgNumPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="150"/> + <DataGridTextColumn Header="Min Num Physical Reads" Binding="{Binding MinNumPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="150"/> + <DataGridTextColumn Header="Max Num Physical Reads" Binding="{Binding MaxNumPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="150"/> <!-- DOP --> - <DataGridTextColumn Binding="{Binding MinDop}" ElementStyle="{StaticResource NumericCell}" Width="70"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinDop" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Min DOP" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MaxDop}" ElementStyle="{StaticResource NumericCell}" Width="70"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxDop" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Max DOP" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Min DOP" Binding="{Binding MinDop}" ElementStyle="{StaticResource NumericCell}" Width="70"/> + <DataGridTextColumn Header="Max DOP" Binding="{Binding MaxDop}" ElementStyle="{StaticResource NumericCell}" Width="70"/> <!-- Memory (MB) --> - <DataGridTextColumn Binding="{Binding AvgMemoryMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgMemoryMb" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Avg Memory (MB)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MinMemoryMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinMemoryMb" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Min Memory (MB)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MaxMemoryMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxMemoryMb" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Max Memory (MB)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Avg Memory (MB)" Binding="{Binding AvgMemoryMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Min Memory (MB)" Binding="{Binding MinMemoryMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Max Memory (MB)" Binding="{Binding MaxMemoryMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"/> <!-- Row Count --> - <DataGridTextColumn Binding="{Binding AvgRowcount, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgRowcount" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Avg Rows" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MinRowcount, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinRowcount" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Min Rows" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MaxRowcount, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxRowcount" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Max Rows" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Avg Rows" Binding="{Binding AvgRowcount, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Min Rows" Binding="{Binding MinRowcount, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Max Rows" Binding="{Binding MaxRowcount, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"/> - <!-- TempDB (MB) --> - <DataGridTextColumn Binding="{Binding AvgTempdbMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgTempdbMb" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Avg TempDB (MB)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MinTempdbMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinTempdbMb" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Min TempDB (MB)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MaxTempdbMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxTempdbMb" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Max TempDB (MB)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <!-- tempdb (MB) --> + <DataGridTextColumn Header="Avg tempdb (MB)" Binding="{Binding AvgTempdbMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Min tempdb (MB)" Binding="{Binding MinTempdbMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Max tempdb (MB)" Binding="{Binding MaxTempdbMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"/> <!-- Plan Info --> - <DataGridTextColumn Binding="{Binding PlanType}" Width="100"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PlanType" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Plan Type" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridCheckBoxColumn Binding="{Binding IsForcedPlan}" Width="60"> - <DataGridCheckBoxColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IsForcedPlan" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Forced" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridCheckBoxColumn.Header> - </DataGridCheckBoxColumn> - <DataGridTextColumn Binding="{Binding ForceFailureCount}" ElementStyle="{StaticResource NumericCell}" Width="100"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ForceFailureCount" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Force Failures" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding LastForceFailureReasonDesc}" Width="150"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LastForceFailureReasonDesc" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Last Failure Reason" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding PlanForcingType}" Width="100"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PlanForcingType" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Forcing Type" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding CompatibilityLevel}" ElementStyle="{StaticResource NumericCell}" Width="90"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CompatibilityLevel" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Compat Level" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Plan Type" Binding="{Binding PlanType}" Width="100"/> + <DataGridCheckBoxColumn Header="Forced" Binding="{Binding IsForcedPlan}" Width="60"/> + <DataGridTextColumn Header="Force Failures" Binding="{Binding ForceFailureCount}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Last Failure Reason" Binding="{Binding LastForceFailureReasonDesc}" Width="150"/> + <DataGridTextColumn Header="Forcing Type" Binding="{Binding PlanForcingType}" Width="100"/> + <DataGridTextColumn Header="Compat Level" Binding="{Binding CompatibilityLevel}" ElementStyle="{StaticResource NumericCell}" Width="90"/> <!-- Query Identifiers --> - <DataGridTextColumn Binding="{Binding QueryHash}" Width="160"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="QueryHash" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Query Hash" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding QueryPlanHash}" Width="160"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="QueryPlanHash" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Query Plan Hash" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Query Hash" Binding="{Binding QueryHash}" Width="160"/> + <DataGridTextColumn Header="Query Plan Hash" Binding="{Binding QueryPlanHash}" Width="160"/> <!-- Download Plan Button --> <DataGridTemplateColumn Header="Query Plan" Width="Auto" MinWidth="90"> diff --git a/Dashboard/QueryExecutionHistoryWindow.xaml.cs b/Dashboard/QueryExecutionHistoryWindow.xaml.cs index d1583f2da..d00aa662c 100644 --- a/Dashboard/QueryExecutionHistoryWindow.xaml.cs +++ b/Dashboard/QueryExecutionHistoryWindow.xaml.cs @@ -22,6 +22,7 @@ using PerformanceMonitorDashboard.Services; using ScottPlot; using PerformanceMonitor.Ui; +using PerformanceMonitor.PlanAnalysis; using PerformanceMonitor.Common; namespace PerformanceMonitorDashboard @@ -42,8 +43,7 @@ public partial class QueryExecutionHistoryWindow : Window private ChartHoverHelper? _chartHover; // Filter state - private Dictionary<string, ColumnFilterState> _filters = new(); - private List<QueryExecutionHistoryItem>? _unfilteredData; + private readonly DataGridFilterManager<QueryExecutionHistoryItem> _filterManager; private Popup? _filterPopup; private ColumnFilterPopup? _filterPopupContent; @@ -75,6 +75,10 @@ public QueryExecutionHistoryWindow( _databaseService.ConnectionString, db, qt, est, iso, isAzureSqlDb: false, timeoutSeconds: 0, ct), "the monitored server"); + _filterManager = new DataGridFilterManager<QueryExecutionHistoryItem>(HistoryDataGrid); + DataGridFilterColumns.AddFilterButtons(HistoryDataGrid, Filter_Click); + _filterManager.UpdateFilterButtonStyles(); + QueryIdentifierText.Text = $"Query Execution History: Query {queryId} in [{databaseName}]"; ApplyThemeToChart(); @@ -124,10 +128,7 @@ private async Task LoadHistoryAsync() _historyData = new List<QueryExecutionHistoryItem>(); } - _unfilteredData = _historyData; - _filters.Clear(); - HistoryDataGrid.ItemsSource = _historyData; - UpdateFilterButtonStyles(); + _filterManager.UpdateData(_historyData); if (_historyData.Count > 0) { @@ -329,7 +330,7 @@ private void Filter_Click(object sender, RoutedEventArgs e) }; } - _filters.TryGetValue(columnName, out var existingFilter); + _filterManager.Filters.TryGetValue(columnName, out var existingFilter); _filterPopupContent!.Initialize(columnName, existingFilter); _filterPopup.PlacementTarget = button; @@ -339,14 +340,7 @@ private void Filter_Click(object sender, RoutedEventArgs e) private void FilterPopup_FilterApplied(object? sender, FilterAppliedEventArgs e) { if (_filterPopup != null) _filterPopup.IsOpen = false; - - if (e.FilterState.IsActive) - _filters[e.FilterState.ColumnName] = e.FilterState; - else - _filters.Remove(e.FilterState.ColumnName); - - ApplyFilters(); - UpdateFilterButtonStyles(); + _filterManager.SetFilter(e.FilterState); } private void FilterPopup_FilterCleared(object? sender, EventArgs e) @@ -354,147 +348,18 @@ private void FilterPopup_FilterCleared(object? sender, EventArgs e) if (_filterPopup != null) _filterPopup.IsOpen = false; } - private void ApplyFilters() - { - if (_unfilteredData == null) return; - - if (_filters.Count == 0) - { - HistoryDataGrid.ItemsSource = _unfilteredData; - return; - } - - var filtered = _unfilteredData.Where(item => - { - foreach (var filter in _filters.Values) - { - if (filter.IsActive && !DataGridFilterService.MatchesFilter(item, filter)) - return false; - } - return true; - }).ToList(); - - HistoryDataGrid.ItemsSource = filtered; - } - - private void UpdateFilterButtonStyles() - { - foreach (var column in HistoryDataGrid.Columns) - { - if (column.Header is StackPanel stackPanel) - { - var filterButton = stackPanel.Children.OfType<Button>().FirstOrDefault(); - if (filterButton?.Tag is string columnName) - { - bool hasActive = _filters.TryGetValue(columnName, out var filter) && filter.IsActive; - filterButton.Content = new System.Windows.Controls.TextBlock - { - Text = "\uE71C", - FontFamily = new System.Windows.Media.FontFamily("Segoe MDL2 Assets"), - Foreground = hasActive - ? new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0xFF, 0xD7, 0x00)) - : new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0xFF, 0xFF, 0xFF)) - }; - filterButton.ToolTip = hasActive && filter != null - ? $"Filter: {filter.DisplayText}\n(Click to modify)" - : "Click to filter"; - } - } - } - } - #endregion #region Context Menu Handlers - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.CurrentCell.Item != null) - { - var cellContent = TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - Clipboard.SetDataObject(cellContent, false); - } - } - } - - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid?.SelectedItem != null) - Clipboard.SetDataObject(TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem), false); - } - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new StringBuilder(); - var headers = new List<string>(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - headers.Add(DataGridClipboardBehavior.GetHeaderText(column)); - } - sb.AppendLine(string.Join("\t", headers)); - foreach (var item in dataGrid.Items) - sb.AppendLine(TabHelpers.GetRowAsText(dataGrid, item)); - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var saveFileDialog = new SaveFileDialog - { - FileName = $"query_execution_history_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - if (saveFileDialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); - var headers = new List<string>(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - headers.Add(TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(column))); - } - sb.AppendLine(string.Join(",", headers)); - foreach (var item in dataGrid.Items) - { - var values = TabHelpers.GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(",", values.Select(v => TabHelpers.EscapeCsvField(v)))); - } - File.WriteAllText(saveFileDialog.FileName, sb.ToString()); - MessageBox.Show($"Data exported successfully to:\n{saveFileDialog.FileName}", "Export Complete", MessageBoxButton.OK, MessageBoxImage.Information); - } - catch (Exception ex) - { - MessageBox.Show($"Error exporting data:\n\n{ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "query_execution_history", TabHelpers.CsvSeparator); #endregion diff --git a/Dashboard/QueryStatsHistoryWindow.xaml b/Dashboard/QueryStatsHistoryWindow.xaml index 815eced49..25a76f5d6 100644 --- a/Dashboard/QueryStatsHistoryWindow.xaml +++ b/Dashboard/QueryStatsHistoryWindow.xaml @@ -93,410 +93,88 @@ Margin="10"> <DataGrid.Columns> <!-- Collection Info --> - <DataGridTextColumn Binding="{Binding CollectionTime, Converter={StaticResource ServerTimeConverter}}" Width="150"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CollectionTime" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Collection Time" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding LastExecutionTime, Converter={StaticResource ServerTimeConverter}}" Width="150"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LastExecutionTime" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Last Execution" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding CreationTime, Converter={StaticResource ServerTimeConverter}}" Width="150"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CreationTime" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Creation Time" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding ObjectType}" Width="90"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ObjectType" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Object Type" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Collection Time" Binding="{Binding CollectionTime, Converter={StaticResource ServerTimeConverter}}" Width="150"/> + <DataGridTextColumn Header="Last Execution" Binding="{Binding LastExecutionTime, Converter={StaticResource ServerTimeConverter}}" Width="150"/> + <DataGridTextColumn Header="Creation Time" Binding="{Binding CreationTime, Converter={StaticResource ServerTimeConverter}}" Width="150"/> + <DataGridTextColumn Header="Object Type" Binding="{Binding ObjectType}" Width="90"/> <!-- Execution Count (per-interval, computed from cumulative counter) --> - <DataGridTextColumn Binding="{Binding IntervalExecutions, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IntervalExecutions" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Exec Count" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding ExecutionCountDelta, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="85"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ExecutionCountDelta" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Exec Delta" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Exec Count" Binding="{Binding IntervalExecutions, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Exec Delta" Binding="{Binding ExecutionCountDelta, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="85"/> <!-- Worker Time (CPU) - Total/Avg/Min/Max --> - <DataGridTextColumn Binding="{Binding TotalWorkerTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="110"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalWorkerTimeMs" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Total CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding AvgWorkerTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgWorkerTimeMs" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Avg CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MinWorkerTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinWorkerTimeMs" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Min CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MaxWorkerTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxWorkerTimeMs" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Max CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding TotalWorkerTimeDeltaMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="105"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalWorkerTimeDeltaMs" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="CPU Delta (ms)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Total CPU (ms)" Binding="{Binding TotalWorkerTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="Avg CPU (ms)" Binding="{Binding AvgWorkerTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Min CPU (ms)" Binding="{Binding MinWorkerTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Max CPU (ms)" Binding="{Binding MaxWorkerTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="CPU Delta (ms)" Binding="{Binding TotalWorkerTimeDeltaMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="105"/> <!-- Elapsed Time - Total/Avg/Min/Max --> - <DataGridTextColumn Binding="{Binding TotalElapsedTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="125"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalElapsedTimeMs" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Total Duration (ms)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding AvgElapsedTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgElapsedTimeMs" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Avg Duration (ms)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MinElapsedTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinElapsedTimeMs" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Min Duration (ms)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MaxElapsedTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxElapsedTimeMs" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Max Duration (ms)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding TotalElapsedTimeDeltaMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="125"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalElapsedTimeDeltaMs" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Duration Delta (ms)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Total Duration (ms)" Binding="{Binding TotalElapsedTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="125"/> + <DataGridTextColumn Header="Avg Duration (ms)" Binding="{Binding AvgElapsedTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Min Duration (ms)" Binding="{Binding MinElapsedTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Max Duration (ms)" Binding="{Binding MaxElapsedTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Duration Delta (ms)" Binding="{Binding TotalElapsedTimeDeltaMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="125"/> <!-- Logical Reads --> - <DataGridTextColumn Binding="{Binding TotalLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="125"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalLogicalReads" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Total Logical Reads" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding AvgLogicalReads, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgLogicalReads" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Avg Logical Reads" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding TotalLogicalReadsDelta, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="95"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalLogicalReadsDelta" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Reads Delta" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Total Logical Reads" Binding="{Binding TotalLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="125"/> + <DataGridTextColumn Header="Avg Logical Reads" Binding="{Binding AvgLogicalReads, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Reads Delta" Binding="{Binding TotalLogicalReadsDelta, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="95"/> <!-- Physical Reads --> - <DataGridTextColumn Binding="{Binding TotalPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="130"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalPhysicalReads" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Total Physical Reads" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding AvgPhysicalReads, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgPhysicalReads" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Avg Physical Reads" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MinPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinPhysicalReads" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Min Physical Reads" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MaxPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxPhysicalReads" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Max Physical Reads" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding TotalPhysicalReadsDelta, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="115"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalPhysicalReadsDelta" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Phys Reads Delta" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Total Physical Reads" Binding="{Binding TotalPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="130"/> + <DataGridTextColumn Header="Avg Physical Reads" Binding="{Binding AvgPhysicalReads, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Min Physical Reads" Binding="{Binding MinPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Max Physical Reads" Binding="{Binding MaxPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Phys Reads Delta" Binding="{Binding TotalPhysicalReadsDelta, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="115"/> <!-- Logical Writes --> - <DataGridTextColumn Binding="{Binding TotalLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="125"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalLogicalWrites" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Total Logical Writes" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding AvgLogicalWrites, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgLogicalWrites" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Avg Logical Writes" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding TotalLogicalWritesDelta, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="95"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalLogicalWritesDelta" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Writes Delta" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Total Logical Writes" Binding="{Binding TotalLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="125"/> + <DataGridTextColumn Header="Avg Logical Writes" Binding="{Binding AvgLogicalWrites, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Writes Delta" Binding="{Binding TotalLogicalWritesDelta, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="95"/> <!-- Rows --> - <DataGridTextColumn Binding="{Binding TotalRows, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalRows" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Total Rows" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding AvgRows, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="90"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgRows" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Avg Rows" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MinRows, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="85"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinRows" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Min Rows" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MaxRows, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="85"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxRows" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Max Rows" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Total Rows" Binding="{Binding TotalRows, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Avg Rows" Binding="{Binding AvgRows, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Min Rows" Binding="{Binding MinRows, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="85"/> + <DataGridTextColumn Header="Max Rows" Binding="{Binding MaxRows, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="85"/> + <DataGridTextColumn Header="Rows Delta" Binding="{Binding IntervalRows, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"/> <!-- Parallelism --> - <DataGridTextColumn Binding="{Binding MinDop}" ElementStyle="{StaticResource NumericCell}" Width="70"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinDop" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Min DOP" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MaxDop}" ElementStyle="{StaticResource NumericCell}" Width="70"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxDop" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Max DOP" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Min DOP" Binding="{Binding MinDop}" ElementStyle="{StaticResource NumericCell}" Width="70"/> + <DataGridTextColumn Header="Max DOP" Binding="{Binding MaxDop}" ElementStyle="{StaticResource NumericCell}" Width="70"/> <!-- Memory Grants --> - <DataGridTextColumn Binding="{Binding MaxGrantMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="105"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxGrantMb" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Max Grant (MB)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MaxUsedGrantMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="105"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxUsedGrantMb" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Max Used (MB)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MaxIdealGrantMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="105"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxIdealGrantMb" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Max Ideal (MB)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Min Grant (MB)" Binding="{Binding MinGrantMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="105"/> + <DataGridTextColumn Header="Max Grant (MB)" Binding="{Binding MaxGrantMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="105"/> + <DataGridTextColumn Header="Min Used (MB)" Binding="{Binding MinUsedGrantMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="105"/> + <DataGridTextColumn Header="Max Used (MB)" Binding="{Binding MaxUsedGrantMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="105"/> + <DataGridTextColumn Header="Min Ideal (MB)" Binding="{Binding MinIdealGrantMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="105"/> + <DataGridTextColumn Header="Max Ideal (MB)" Binding="{Binding MaxIdealGrantMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="105"/> <!-- Thread Usage --> - <DataGridTextColumn Binding="{Binding MinUsedThreads}" ElementStyle="{StaticResource NumericCell}" Width="85"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinUsedThreads" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Min Threads" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MaxUsedThreads}" ElementStyle="{StaticResource NumericCell}" Width="85"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxUsedThreads" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Max Threads" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Min Reserved Threads" Binding="{Binding MinReservedThreads}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="Max Reserved Threads" Binding="{Binding MaxReservedThreads}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="Min Used Threads" Binding="{Binding MinUsedThreads}" ElementStyle="{StaticResource NumericCell}" Width="95"/> + <DataGridTextColumn Header="Max Used Threads" Binding="{Binding MaxUsedThreads}" ElementStyle="{StaticResource NumericCell}" Width="95"/> <!-- Spills --> - <DataGridTextColumn Binding="{Binding TotalSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalSpills" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Total Spills" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MinSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="85"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinSpills" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Min Spills" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding MaxSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="85"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxSpills" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Max Spills" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Total Spills" Binding="{Binding TotalSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Min Spills" Binding="{Binding MinSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="85"/> + <DataGridTextColumn Header="Max Spills" Binding="{Binding MaxSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="85"/> + <DataGridTextColumn Header="Spills Delta" Binding="{Binding IntervalSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"/> <!-- CLR Time --> - <DataGridTextColumn Binding="{Binding TotalClrTime, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalClrTime" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Total CLR Time" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Total CLR Time" Binding="{Binding TotalClrTime, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"/> <!-- Sample Interval --> - <DataGridTextColumn Binding="{Binding SampleIntervalSeconds}" ElementStyle="{StaticResource NumericCell}" Width="85"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SampleIntervalSeconds" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Interval (sec)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Interval (sec)" Binding="{Binding SampleIntervalSeconds}" ElementStyle="{StaticResource NumericCell}" Width="85"/> <!-- Query Identifiers --> - <DataGridTextColumn Binding="{Binding SqlHandle}" Width="160"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SqlHandle" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="SQL Handle" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding PlanHandle}" Width="160"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PlanHandle" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Plan Handle" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding QueryHash}" Width="160"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="QueryHash" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Query Hash" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding QueryPlanHash}" Width="160"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="QueryPlanHash" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Query Plan Hash" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="SQL Handle" Binding="{Binding SqlHandle}" Width="160"/> + <DataGridTextColumn Header="Plan Handle" Binding="{Binding PlanHandle}" Width="160"/> + <DataGridTextColumn Header="Query Hash" Binding="{Binding QueryHash}" Width="160"/> + <DataGridTextColumn Header="Query Plan Hash" Binding="{Binding QueryPlanHash}" Width="160"/> <!-- Download Plan Button --> <DataGridTemplateColumn Header="Query Plan" Width="Auto" MinWidth="90"> diff --git a/Dashboard/QueryStatsHistoryWindow.xaml.cs b/Dashboard/QueryStatsHistoryWindow.xaml.cs index 56606911e..837e284fc 100644 --- a/Dashboard/QueryStatsHistoryWindow.xaml.cs +++ b/Dashboard/QueryStatsHistoryWindow.xaml.cs @@ -22,6 +22,7 @@ using PerformanceMonitorDashboard.Services; using ScottPlot; using PerformanceMonitor.Ui; +using PerformanceMonitor.PlanAnalysis; using PerformanceMonitor.Common; namespace PerformanceMonitorDashboard @@ -40,8 +41,7 @@ public partial class QueryStatsHistoryWindow : Window private ChartHoverHelper? _chartHover; // Filter state - private Dictionary<string, ColumnFilterState> _filters = new(); - private List<QueryStatsHistoryItem>? _unfilteredData; + private readonly DataGridFilterManager<QueryStatsHistoryItem> _filterManager; private Popup? _filterPopup; private ColumnFilterPopup? _filterPopupContent; @@ -71,6 +71,10 @@ public QueryStatsHistoryWindow( _databaseService.ConnectionString, db, qt, est, iso, isAzureSqlDb: false, timeoutSeconds: 0, ct), "the monitored server"); + _filterManager = new DataGridFilterManager<QueryStatsHistoryItem>(HistoryDataGrid); + DataGridFilterColumns.AddFilterButtons(HistoryDataGrid, Filter_Click); + _filterManager.UpdateFilterButtonStyles(); + QueryIdentifierText.Text = $"Query Stats History: {queryHash} in [{databaseName}]"; ApplyThemeToChart(); @@ -113,37 +117,32 @@ private async Task LoadHistoryAsync() { _historyData = await _databaseService.GetQueryStatsHistoryAsync(_databaseName, _queryHash, _hoursBack, _fromDate, _toDate); - // Compute per-interval executions. DMV counters are cumulative and reset on plan - // eviction, so we walk oldest→newest, detecting lifetime boundaries by CreationTime. - // Data arrives sorted by CollectionTime DESC, so walk from end to start. + // Compute per-interval executions/rows/spills. DMV counters are cumulative and reset + // on plan eviction, so we walk oldest→newest, detecting lifetime boundaries by + // CreationTime. Data arrives sorted by CollectionTime DESC, so walk from end to start. + // (Rows and spills have no SQL-side delta column, hence the client-side walk.) for (int i = _historyData.Count - 1; i >= 0; i--) { var item = _historyData[i]; - if (i == _historyData.Count - 1) + var olderItem = i == _historyData.Count - 1 ? null : _historyData[i + 1]; + if (olderItem == null || item.CreationTime != olderItem.CreationTime) { - // Oldest row in window: credit all executions (no prior reference) + // Oldest row in window, or a new plan lifetime (eviction + re-cache): + // credit the full cumulative counters (no prior reference in this lifetime) item.IntervalExecutions = item.ExecutionCount; + item.IntervalRows = item.TotalRows; + item.IntervalSpills = item.TotalSpills; } else { - var olderItem = _historyData[i + 1]; - if (item.CreationTime != olderItem.CreationTime) - { - // New plan lifetime (eviction + re-cache): credit all executions - item.IntervalExecutions = item.ExecutionCount; - } - else - { - // Same lifetime: delta from previous snapshot - item.IntervalExecutions = Math.Max(0, item.ExecutionCount - olderItem.ExecutionCount); - } + // Same lifetime: delta from previous snapshot + item.IntervalExecutions = Math.Max(0, item.ExecutionCount - olderItem.ExecutionCount); + item.IntervalRows = Math.Max(0, item.TotalRows - olderItem.TotalRows); + item.IntervalSpills = Math.Max(0, item.TotalSpills - olderItem.TotalSpills); } } - _unfilteredData = _historyData; - _filters.Clear(); - HistoryDataGrid.ItemsSource = _historyData; - UpdateFilterButtonStyles(); + _filterManager.UpdateData(_historyData); if (_historyData.Count > 0) { @@ -308,7 +307,7 @@ private void Filter_Click(object sender, RoutedEventArgs e) }; } - _filters.TryGetValue(columnName, out var existingFilter); + _filterManager.Filters.TryGetValue(columnName, out var existingFilter); _filterPopupContent!.Initialize(columnName, existingFilter); _filterPopup.PlacementTarget = button; @@ -318,14 +317,7 @@ private void Filter_Click(object sender, RoutedEventArgs e) private void FilterPopup_FilterApplied(object? sender, FilterAppliedEventArgs e) { if (_filterPopup != null) _filterPopup.IsOpen = false; - - if (e.FilterState.IsActive) - _filters[e.FilterState.ColumnName] = e.FilterState; - else - _filters.Remove(e.FilterState.ColumnName); - - ApplyFilters(); - UpdateFilterButtonStyles(); + _filterManager.SetFilter(e.FilterState); } private void FilterPopup_FilterCleared(object? sender, EventArgs e) @@ -333,147 +325,18 @@ private void FilterPopup_FilterCleared(object? sender, EventArgs e) if (_filterPopup != null) _filterPopup.IsOpen = false; } - private void ApplyFilters() - { - if (_unfilteredData == null) return; - - if (_filters.Count == 0) - { - HistoryDataGrid.ItemsSource = _unfilteredData; - return; - } - - var filtered = _unfilteredData.Where(item => - { - foreach (var filter in _filters.Values) - { - if (filter.IsActive && !DataGridFilterService.MatchesFilter(item, filter)) - return false; - } - return true; - }).ToList(); - - HistoryDataGrid.ItemsSource = filtered; - } - - private void UpdateFilterButtonStyles() - { - foreach (var column in HistoryDataGrid.Columns) - { - if (column.Header is StackPanel stackPanel) - { - var filterButton = stackPanel.Children.OfType<Button>().FirstOrDefault(); - if (filterButton?.Tag is string columnName) - { - bool hasActive = _filters.TryGetValue(columnName, out var filter) && filter.IsActive; - filterButton.Content = new System.Windows.Controls.TextBlock - { - Text = "\uE71C", - FontFamily = new System.Windows.Media.FontFamily("Segoe MDL2 Assets"), - Foreground = hasActive - ? new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0xFF, 0xD7, 0x00)) - : new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0xFF, 0xFF, 0xFF)) - }; - filterButton.ToolTip = hasActive && filter != null - ? $"Filter: {filter.DisplayText}\n(Click to modify)" - : "Click to filter"; - } - } - } - } - #endregion #region Context Menu Handlers - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.CurrentCell.Item != null) - { - var cellContent = TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - Clipboard.SetDataObject(cellContent, false); - } - } - } - - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid?.SelectedItem != null) - Clipboard.SetDataObject(TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem), false); - } - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new StringBuilder(); - var headers = new List<string>(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - headers.Add(DataGridClipboardBehavior.GetHeaderText(column)); - } - sb.AppendLine(string.Join("\t", headers)); - foreach (var item in dataGrid.Items) - sb.AppendLine(TabHelpers.GetRowAsText(dataGrid, item)); - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var saveFileDialog = new SaveFileDialog - { - FileName = $"query_stats_history_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - if (saveFileDialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); - var headers = new List<string>(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - headers.Add(TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(column))); - } - sb.AppendLine(string.Join(",", headers)); - foreach (var item in dataGrid.Items) - { - var values = TabHelpers.GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(",", values.Select(v => TabHelpers.EscapeCsvField(v)))); - } - File.WriteAllText(saveFileDialog.FileName, sb.ToString()); - MessageBox.Show($"Data exported successfully to:\n{saveFileDialog.FileName}", "Export Complete", MessageBoxButton.OK, MessageBoxImage.Information); - } - catch (Exception ex) - { - MessageBox.Show($"Error exporting data:\n\n{ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "query_stats_history", TabHelpers.CsvSeparator); #endregion diff --git a/Dashboard/README.md b/Dashboard/README.md index e8880249b..691159a8c 100644 --- a/Dashboard/README.md +++ b/Dashboard/README.md @@ -1,9 +1,122 @@ -# Performance Monitor Dashboard +# Performance Monitor Dashboard (Full Edition) -WPF desktop application for the Full Edition. Connects to SQL Server instances running the PerformanceMonitor database and visualizes collected performance data across six tab groups: Overview, Performance, Resource Metrics, Memory, Locking, and System Events. +> **The Full edition is deprecated.** It installs a `PerformanceMonitor` database on the target SQL Server with T-SQL collectors running via SQL Agent, and this WPF app connects to view the data. It still ships and is supported for existing users, but new deployments should use **[Lite](../Lite/README.md)** (portable desktop app, nothing installed on the server) or **[Darling](../Darling/README.md)** (headless service + viewer). See the [root README](../README.md) for the current editions. -Includes a NOC-style landing page with server health cards, interactive trend charts (ScottPlot), and an embedded MCP server for exposing monitoring data to LLM clients. +The Dashboard connects to SQL Server instances running the `PerformanceMonitor` database and visualizes collected performance data across six tab groups, with a NOC-style landing page of green/yellow/red server health cards, interactive ScottPlot trend charts, and an embedded MCP server. Requires the [.NET 10 Desktop Runtime](https://dotnet.microsoft.com/en-us/download/dotnet/10.0). -Requires .NET 10.0 Runtime and network connectivity to a SQL Server with the PerformanceMonitor database installed. +## Install -See the [root README](../README.md) for full documentation. +The database is installed by the **[CLI installer](../Installer/README.md)** or directly from the Dashboard's **Add Server** dialog (which embeds the same installer). Then install the Dashboard app: + +Download **[`PerformanceMonitorDashboard-win-Setup.exe`](https://github.com/erikdarlingdata/PerformanceMonitor/releases/latest)**. Setup.exe installs to `%LocalAppData%\PerformanceMonitorDashboard`, adds Start Menu and Desktop shortcuts, registers under Apps & Features, and wires up auto-update. Launch, add your server, enter credentials, and data appears immediately. + +## What Gets Installed + +- **PerformanceMonitor database** with collection tables and reporting views +- **Collector stored procedures** for gathering metrics (including SQL Agent job monitoring) +- **Configurable collection** — query text and execution plan capture can be disabled per-collector via `config.collection_schedule` (`collect_query`, `collect_plan` columns) for sensitive or high-volume environments +- **Delta framework** for calculating per-second rates from cumulative DMVs +- **Community dependencies:** sp_WhoIsActive, sp_HealthParser, sp_HumanEventsBlockViewer, sp_BlitzLock +- **SQL Agent jobs:** Collection (every 1 minute), Data Retention (daily at 2:00 AM), and Hung Job Monitor (collection job watchdog, every 5 minutes) +- **Version tracking** in `config.installation_history` + +## Data Retention + +Default: 30 days (configurable per collector via the `retention_days` column in `config.collection_schedule`). + +Storage estimates: 5–10 GB per week, 20–40 GB per month. + +## Managed Platform Support + +The Full Edition supports Azure SQL Managed Instance and AWS RDS for SQL Server with some limitations (Azure SQL Database is not supported by the Full edition — use Lite there): + +| Feature | On-Premises | Azure SQL MI | AWS RDS | +|---|---|---|---| +| All core collectors | Yes | Yes | Yes | +| Default trace collectors | Yes | Disabled automatically | Yes | +| System health XE (file target) | Yes | Disabled automatically | Yes | +| SQL Trace collectors | Yes | Disabled automatically | Yes | +| SQL Agent jobs | Yes | Yes | Yes | +| Running jobs collector | Yes | Yes | Disabled automatically | +| Blocked process threshold | Auto-configured | Auto-configured | Configure via RDS parameter group | +| sp_configure | Yes | Yes | Not available | + +**Azure SQL MI:** The installer automatically detects Engine Edition 8 and disables 4 collectors that require file system access or SQL Trace (default_trace, trace_management, trace_analysis, system_health). All other collectors work normally. + +**AWS RDS:** The installer automatically detects the `rdsadmin` database and disables the `running_jobs_collector` (requires `msdb.dbo.syssessions` which is restricted on RDS). It also gracefully handles restricted `sp_configure` and limited `msdb` permissions. SQL Agent jobs are created and owned by the installing login. The RDS master user is automatically enrolled in `SQLAgentUserRole`; for other logins, add them to `SQLAgentUserRole` in msdb before running the installer. See the root README's platform notes for RDS Parameter Group configuration of the blocked process threshold. + +## Dashboard Tabs + +| Tab | Contents | +|---|---| +| **Overview** | Resource overview, daily summary, critical issues, recommendations, server config changes, database config changes, trace flag changes, collection health | +| **Performance** | Performance trends, expensive queries, active queries, query stats, procedure stats, Query Store, Query Store regressions, query trace patterns, query heatmap | +| **Resource Metrics** | Server trends, wait stats, TempDB, file I/O latency, perfmon counters, default trace events, trace analysis, session stats, latch stats, spinlock stats | +| **Memory** | Memory overview, grants, clerks, plan cache, memory pressure events | +| **Locking** | Blocking chains, deadlocks, blocking/deadlock trends, visual block-chain & deadlock-graph viewers | +| **System Events** | Corruption events, contention, errors, I/O issues, scheduler issues, memory conditions | + +Plus a NOC-style landing page with server health cards (green/yellow/red severity indicators). Auto-refresh, configurable time ranges, chart drill-down to Active Queries, right-click CSV export, system tray integration, dark and light themes, and timezone display options. + +The Dashboard's embedded MCP server exposes 66 read-only tools. See the root README's MCP section for the shared tool surface. + +## Troubleshooting + +Two diagnostic scripts in the `install/` folder: + +| Script | Purpose | +|---|---| +| `99_installer_troubleshooting.sql` | Quick health checks: collection log errors, schedule status, Agent job status, table row counts | +| `99_user_troubleshooting.sql` | Comprehensive diagnostics: runs collectors with `@debug = 1`, detailed timing and row counts | + +```sql +SELECT + collection_time, + collector_name, + error_message +FROM PerformanceMonitor.config.collection_log +WHERE collection_status = 'ERROR' +ORDER BY collection_time DESC; +``` + +**Orphaned `Monitor_LongQueries_*.trc` files (issue #972)** — versions through 2.11.0 accumulated stale SQL Trace files in the SQL Server error log directory. Newer versions bound the long-query trace with a rollover file-count cap, so SQL Server prunes its own files going forward — but trace files already on disk are not removed automatically (`xp_delete_file` cannot delete `.trc` files). Sweep them once with `tools/Remove-OrphanedTraceFiles.ps1`, run **on the SQL Server host** as a local Administrator or the SQL Server service account: + +```powershell +.\Remove-OrphanedTraceFiles.ps1 -WhatIf # preview what would be deleted +.\Remove-OrphanedTraceFiles.ps1 # delete +``` + +It skips files belonging to a running trace and files that are in use. + +## Permissions (On-Premises) + +The installer needs `sysadmin` to create the database, Agent jobs, and configure `sp_configure` settings. After installation, the collection jobs can run under a **least-privilege login** with these grants: + +```sql +USE [master]; +CREATE LOGIN [SQLServerPerfMon] WITH PASSWORD = N'YourStrongPassword'; +GRANT VIEW SERVER STATE TO [SQLServerPerfMon]; + +USE [PerformanceMonitor]; +CREATE USER [SQLServerPerfMon] FOR LOGIN [SQLServerPerfMon]; +ALTER ROLE [db_owner] ADD MEMBER [SQLServerPerfMon]; + +USE [msdb]; +CREATE USER [SQLServerPerfMon] FOR LOGIN [SQLServerPerfMon]; +ALTER ROLE [SQLAgentReaderRole] ADD MEMBER [SQLServerPerfMon]; +``` + +| Grant | Why | +|---|---| +| `VIEW SERVER STATE` | All DMV access (wait stats, query stats, memory, CPU, file I/O, etc.) | +| `db_owner` on PerformanceMonitor | Collectors insert data, create/alter tables, execute procedures. Scoped to just this database — not sysadmin. | +| `SQLAgentReaderRole` on msdb | Read `sysjobs`, `sysjobactivity`, `sysjobhistory` for the running jobs collector | + +**Optional** (gracefully skipped if missing): +- `ALTER SETTINGS` — installer sets `blocked process threshold` via `sp_configure`. Skipped with a warning if unavailable. +- `ALTER TRACE` — default trace collector. Skipped if denied. +- `DBCC TRACESTATUS` — server config collector skips trace flag detection if denied. + +Change the SQL Agent job owner to the new login after installation if you want to run under least privilege end-to-end. + +The FinOps Index Analysis tab needs additional per-database grants — see the root README's [FinOps Index Analysis](../README.md#finops-index-analysis-per-database-grants) section (it applies to Full, Lite, and Darling alike). diff --git a/Dashboard/ServerTab.CopyExport.cs b/Dashboard/ServerTab.CopyExport.cs index 765ae784c..9583802bd 100644 --- a/Dashboard/ServerTab.CopyExport.cs +++ b/Dashboard/ServerTab.CopyExport.cs @@ -26,118 +26,14 @@ public partial class ServerTab : UserControl // Context Menu Event Handlers // ==================================================================== - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.CurrentCell.Item != null) - { - var cellContent = GetCellContent(dataGrid, dataGrid.CurrentCell); - if (cellContent != null) - { - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(cellContent, false); - } - } - } - } - - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.SelectedItem != null) - { - var rowText = GetRowAsText(dataGrid, dataGrid.SelectedItem); - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(rowText, false); - } - } - } - - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new StringBuilder(); - - // Add headers - var headers = new List<string>(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn boundColumn) - { - headers.Add(DataGridClipboardBehavior.GetHeaderText(column)); - } - } - sb.AppendLine(string.Join(" ", headers)); - - // Add all rows - foreach (var item in dataGrid.Items) - { - sb.AppendLine(GetRowAsText(dataGrid, item)); - } - - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } - - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var saveFileDialog = new SaveFileDialog - { - FileName = $"export_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; - - if (saveFileDialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - // Add headers - var headers = new List<string>(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - { - headers.Add(EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(column))); - } - } - sb.AppendLine(string.Join(",", headers)); + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - // Add all rows - foreach (var item in dataGrid.Items) - { - var values = GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(",", values.Select(v => EscapeCsvField(v)))); - } + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - File.WriteAllText(saveFileDialog.FileName, sb.ToString()); - MessageBox.Show($"Data exported successfully to:\n{saveFileDialog.FileName}", "Export Complete", MessageBoxButton.OK, MessageBoxImage.Information); - } - catch (Exception ex) - { - MessageBox.Show($"Error exporting data:\n\n{ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "export", TabHelpers.CsvSeparator); private DataGrid? FindDataGridFromContextMenu(ContextMenu contextMenu) { @@ -152,56 +48,5 @@ private void ExportToCsv_Click(object sender, RoutedEventArgs e) { return TabHelpers.FindParent<T>(child); } - - private string GetCellContent(DataGrid dataGrid, DataGridCellInfo cellInfo) - { - var column = cellInfo.Column as DataGridBoundColumn; - if (column?.Binding is Binding binding && binding.Path != null) - { - var propertyName = binding.Path.Path; - var property = cellInfo.Item.GetType().GetProperty(propertyName); - if (property != null) - { - var value = property.GetValue(cellInfo.Item); - return value?.ToString() ?? string.Empty; - } - } - return string.Empty; - } - - private string GetRowAsText(DataGrid dataGrid, object item) - { - var values = GetRowValues(dataGrid, item); - return string.Join(" ", values); - } - - private List<string> GetRowValues(DataGrid dataGrid, object item) - { - var values = new List<string>(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn boundColumn && boundColumn.Binding is Binding binding) - { - var propertyName = binding.Path.Path; - var property = item.GetType().GetProperty(propertyName); - if (property != null) - { - var value = property.GetValue(item); - values.Add(value?.ToString() ?? string.Empty); - } - } - } - return values; - } - - private string GetColumnHeader(DataGridColumn column) - { - return TabHelpers.GetColumnHeader(column); - } - - private string EscapeCsvField(string field) - { - return TabHelpers.EscapeCsvField(field); - } } } diff --git a/Dashboard/ServerTab.xaml.cs b/Dashboard/ServerTab.xaml.cs index 1a33bd23d..ad836353a 100644 --- a/Dashboard/ServerTab.xaml.cs +++ b/Dashboard/ServerTab.xaml.cs @@ -130,6 +130,7 @@ public ServerTab(ServerConnection serverConnection, int utcOffsetMinutes = 0) PerformanceTab.ActualPlanFinished += _actualPlanFinishedHandler; PerformanceTab.DrillDownTimeRangeRequested += _drillDownTimeRangeHandler; PerformanceTab.SubTabChanged += _subTabChangedHandler; + PerformanceTab.ChartDrillDownRequested += OnChildChartDrillDown; SystemEventsContent.Initialize(_databaseService); _baselineProvider = new Analysis.SqlServerBaselineProvider(_databaseService.ConnectionString); ResourceMetricsContent.Initialize(_databaseService, _baselineProvider); @@ -251,6 +252,7 @@ public void CleanupOnClose() MemoryTab.ChartDrillDownRequested -= OnChildChartDrillDown; ResourceMetricsContent.ChartDrillDownRequested -= OnChildChartDrillDown; + PerformanceTab.ChartDrillDownRequested -= OnChildChartDrillDown; if (_viewPlanHandler != null) PerformanceTab.ViewPlanRequested -= _viewPlanHandler; if (_actualPlanStartedHandler != null) PerformanceTab.ActualPlanStarted -= _actualPlanStartedHandler; diff --git a/Dashboard/Services/ActualPlanExecutor.cs b/Dashboard/Services/ActualPlanExecutor.cs deleted file mode 100644 index bb84e0f43..000000000 --- a/Dashboard/Services/ActualPlanExecutor.cs +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright (c) 2026 Erik Darling, Darling Data LLC - * - * This file is part of the SQL Server Performance Monitor. - * - * Licensed under the MIT License. See LICENSE file in the project root for full license information. - */ - -using System; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Data.SqlClient; -using PerformanceMonitor.PlanAnalysis; - -namespace PerformanceMonitorDashboard.Services; - -/// <summary> -/// Executes a query against SQL Server with SET STATISTICS XML ON to capture -/// the actual execution plan with runtime statistics. All data result sets -/// are consumed and discarded — only the plan XML is returned. -/// </summary> -public static class ActualPlanExecutor -{ - /// <summary> - /// Executes the given query text and captures the actual execution plan XML. - /// </summary> - /// <param name="connectionString">Connection string to the target server.</param> - /// <param name="databaseName">Database context for execution.</param> - /// <param name="queryText">The query text to execute.</param> - /// <param name="planXml">Optional estimated plan XML (used to extract SET options and parameters).</param> - /// <param name="isolationLevel">Optional transaction isolation level.</param> - /// <param name="isAzureSqlDb">If true, skips USE [database] in the repro script.</param> - /// <param name="timeoutSeconds">Command timeout in seconds.</param> - /// <param name="cancellationToken">Cancellation token for user abort.</param> - /// <returns>The actual execution plan XML, or null if no plan was captured.</returns> - public static async Task<string?> ExecuteForActualPlanAsync( - string connectionString, - string databaseName, - string queryText, - string? planXml, - string? isolationLevel, - bool isAzureSqlDb, - int timeoutSeconds, - CancellationToken cancellationToken) - { - /* Build the repro script (includes SET options from plan XML via #233) */ - var reproScript = ReproScriptBuilder.BuildReproScript( - queryText, databaseName, planXml, isolationLevel, - source: "Actual Plan Capture", isAzureSqlDb: isAzureSqlDb); - - /* Wrap with SET STATISTICS XML ON/OFF */ - var sb = new StringBuilder(); - sb.AppendLine("SET STATISTICS XML ON;"); - sb.AppendLine(reproScript); - sb.AppendLine("SET STATISTICS XML OFF;"); - - var fullScript = sb.ToString(); - string? capturedPlanXml = null; - - /* Override database in connection string */ - var builder = new SqlConnectionStringBuilder(connectionString); - if (!string.IsNullOrEmpty(databaseName) && !isAzureSqlDb) - { - builder.InitialCatalog = databaseName; - } - - await using var connection = new SqlConnection(builder.ConnectionString); - await connection.OpenAsync(cancellationToken); - - using var command = new SqlCommand(fullScript, connection); - command.CommandTimeout = timeoutSeconds; - - /* Wire cancellation token to SqlCommand.Cancel() for clean abort */ - await using var registration = cancellationToken.Register(() => - { - try { command.Cancel(); } catch { /* best effort */ } - }); - - using var reader = await command.ExecuteReaderAsync(cancellationToken); - - /* Iterate all result sets. SET STATISTICS XML ON causes SQL Server to - append a plan XML result set after each statement's data result set. - The plan result set has a single row with a single XML column. */ - do - { - if (reader.FieldCount == 1 && await reader.ReadAsync(cancellationToken)) - { - var value = reader.GetValue(0)?.ToString(); - if (value != null && value.TrimStart().StartsWith("<ShowPlanXML", StringComparison.Ordinal)) - { - /* This is a plan XML result set — capture it */ - capturedPlanXml = value; - } - else - { - /* Data result set — consume and discard remaining rows */ - while (await reader.ReadAsync(cancellationToken)) { } - } - } - else - { - /* Multi-column data result set — consume and discard all rows */ - while (await reader.ReadAsync(cancellationToken)) { } - } - } - while (await reader.NextResultAsync(cancellationToken)); - - return capturedPlanXml; - } -} diff --git a/Dashboard/Services/AnalysisScheduler.cs b/Dashboard/Services/AnalysisScheduler.cs index 4e70e20af..1a00878a8 100644 --- a/Dashboard/Services/AnalysisScheduler.cs +++ b/Dashboard/Services/AnalysisScheduler.cs @@ -53,6 +53,16 @@ public sealed class AnalysisScheduler private readonly CancellationTokenSource _cts = new(); private bool _cycleRunning; + /// <summary> + /// When findings retention last ran. The <see cref="SqlServerFindingStore"/> declares a + /// 30-day cleanup that nothing scheduled, so each monitored server's + /// config.analysis_findings grew unbounded. The purge rides this analysis timer but fires + /// only once per 24h (a DELETE per server, not per 5-minute cycle). Seeded to now so the + /// first purge is a day out rather than on the first tick — matching the maintenance-warmup + /// rationale of the Lite twin's _last*Time seeds. + /// </summary> + private DateTime _lastCleanupUtc = DateTime.UtcNow; + public AnalysisScheduler( IServerManager serverManager, ICredentialService credentialService, @@ -143,6 +153,13 @@ private async Task RunCycleAsync(CancellationToken cancellationToken) var timeoutSeconds = Math.Clamp(prefs.AnalysisTimeoutSeconds, 30, 600); var timeout = TimeSpan.FromSeconds(timeoutSeconds); + /* Findings retention rides this analysis cycle but fires only once per 24h. Each + monitored server owns its config.analysis_findings (per-server connection), so the + purge runs per server inside the loop below, reusing that server's AnalysisService. */ + var cleanupDue = DateTime.UtcNow - _lastCleanupUtc >= TimeSpan.FromHours(24); + if (cleanupDue) + _lastCleanupUtc = DateTime.UtcNow; + foreach (var server in _serverManager.GetAllServers()) { if (cancellationToken.IsCancellationRequested) @@ -166,6 +183,13 @@ so persisted analysis_findings/analysis_muted rows align. */ block analysis for every other server. */ var planFetcher = new SqlServerPlanFetcher(connectionString); var analysisService = new AnalysisService(connectionString, planFetcher); + + /* Daily findings retention: the store's declared 30-day cleanup was never + scheduled, so config.analysis_findings grew unbounded. Reuses this server's + AnalysisService; CleanupOldFindingsAsync logs and never throws. */ + if (cleanupDue) + await analysisService.CleanupAsync(retentionDays: 30); + var analyzeTask = analysisService.AnalyzeAsync(serverId, displayName, hoursBack: 4); /* Clear the in-flight marker only when the task truly finishes — not diff --git a/Dashboard/Services/CredentialService.cs b/Dashboard/Services/CredentialService.cs index c63e9b5cb..87d056db6 100644 --- a/Dashboard/Services/CredentialService.cs +++ b/Dashboard/Services/CredentialService.cs @@ -6,9 +6,7 @@ * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ -using System; -using System.Security; -using CredentialManagement; +using PerformanceMonitor.Common; using PerformanceMonitorDashboard.Helpers; using PerformanceMonitorDashboard.Interfaces; @@ -18,174 +16,40 @@ namespace PerformanceMonitorDashboard.Services /// Secure credential storage service using Windows Credential Manager. /// Credentials are encrypted by Windows using DPAPI and stored per-user. /// NEVER stores passwords in plain text. + /// + /// Thin adapter over the shared <see cref="CredentialStore"/>. The + /// <c>"PerformanceMonitor_"</c> prefix keeps Dashboard credentials isolated from + /// Lite's (<c>"PerformanceMonitorLite_"</c>) in Windows Credential Manager — it MUST + /// stay distinct. Warnings are routed to the app's static logger via + /// <see cref="LoggerAdapter{T}"/>. /// </summary> public class CredentialService : ICredentialService { private const string CredentialPrefix = "PerformanceMonitor_"; - /// <summary> - /// Saves SQL Server credentials to Windows Credential Manager. - /// Credentials are encrypted automatically by Windows. - /// </summary> - /// <param name="serverId">Unique server identifier</param> - /// <param name="username">SQL Server username</param> - /// <param name="password">SQL Server password (will be encrypted)</param> - /// <returns>True if successful, false otherwise</returns> - public bool SaveCredential(string serverId, string username, string password) - { - if (string.IsNullOrWhiteSpace(serverId)) - { - throw new ArgumentException("Server ID cannot be null or empty", nameof(serverId)); - } - - if (string.IsNullOrWhiteSpace(username)) - { - throw new ArgumentException("Username cannot be null or empty", nameof(username)); - } - - if (password == null) - { - throw new ArgumentNullException(nameof(password)); - } + private readonly CredentialStore _store = new CredentialStore( + CredentialPrefix, + "SQL Server credentials for Performance Monitor Dashboard", + new LoggerAdapter<CredentialService>()); - try - { - using (var credential = new Credential - { - Target = GetCredentialTarget(serverId), - Username = username, - Password = password, - Type = CredentialType.Generic, - PersistanceType = PersistanceType.LocalComputer, - Description = "SQL Server credentials for Performance Monitor Dashboard" - }) - { - return credential.Save(); - } - } - catch (Exception ex) - { - Logger.Warning($"Failed to save credential for server {serverId}: {ex.Message}"); - return false; - } - } + /// <inheritdoc /> + public bool SaveCredential(string serverId, string username, string password) + => _store.SaveCredential(serverId, username, password); - /// <summary> - /// Retrieves SQL Server credentials from Windows Credential Manager. - /// </summary> - /// <param name="serverId">Unique server identifier</param> - /// <returns>Tuple of (username, password) or null if not found</returns> + /// <inheritdoc /> public (string Username, string Password)? GetCredential(string serverId) - { - if (string.IsNullOrWhiteSpace(serverId)) - { - throw new ArgumentException("Server ID cannot be null or empty", nameof(serverId)); - } - - try - { - using (var credential = new Credential - { - Target = GetCredentialTarget(serverId), - Type = CredentialType.Generic - }) - { - if (credential.Load()) - { - return (credential.Username, credential.Password); - } - } - } - catch (Exception ex) - { - Logger.Warning($"Failed to load credential for server {serverId}: {ex.Message}"); - } + => _store.GetCredential(serverId); - return null; - } - - /// <summary> - /// Deletes SQL Server credentials from Windows Credential Manager. - /// Should be called when a server is removed. - /// </summary> - /// <param name="serverId">Unique server identifier</param> - /// <returns>True if deleted or didn't exist, false on error</returns> + /// <inheritdoc /> public bool DeleteCredential(string serverId) - { - if (string.IsNullOrWhiteSpace(serverId)) - { - throw new ArgumentException("Server ID cannot be null or empty", nameof(serverId)); - } - - try - { - using (var credential = new Credential - { - Target = GetCredentialTarget(serverId), - Type = CredentialType.Generic - }) - { - return credential.Delete(); - } - } - catch (Exception ex) - { - Logger.Warning($"Failed to delete credential for server {serverId}: {ex.Message}"); - return false; - } - } + => _store.DeleteCredential(serverId); - /// <summary> - /// Checks if credentials exist for a server. - /// </summary> - /// <param name="serverId">Unique server identifier</param> - /// <returns>True if credentials exist in Credential Manager</returns> + /// <inheritdoc /> public bool CredentialExists(string serverId) - { - if (string.IsNullOrWhiteSpace(serverId)) - { - return false; - } - - try - { - using (var credential = new Credential - { - Target = GetCredentialTarget(serverId), - Type = CredentialType.Generic - }) - { - return credential.Exists(); - } - } - catch (Exception ex) - { - Logger.Warning($"Failed to check credential existence for server {serverId}: {ex.Message}"); - return false; - } - } + => _store.CredentialExists(serverId); - /// <summary> - /// Updates existing credentials. If credentials don't exist, creates them. - /// </summary> - /// <param name="serverId">Unique server identifier</param> - /// <param name="username">SQL Server username</param> - /// <param name="password">SQL Server password (will be encrypted)</param> - /// <returns>True if successful, false otherwise</returns> + /// <inheritdoc /> public bool UpdateCredential(string serverId, string username, string password) - { - DeleteCredential(serverId); - return SaveCredential(serverId, username, password); - } - - /// <summary> - /// Generates the credential target name for Windows Credential Manager. - /// Format: PerformanceMonitor_{serverId} - /// </summary> - private string GetCredentialTarget(string serverId) - { - return $"{CredentialPrefix}{serverId}"; - } - + => _store.UpdateCredential(serverId, username, password); } } diff --git a/Dashboard/Services/DashboardAlertSettings.cs b/Dashboard/Services/DashboardAlertSettings.cs index 2c210f58e..d1ce9d1cc 100644 --- a/Dashboard/Services/DashboardAlertSettings.cs +++ b/Dashboard/Services/DashboardAlertSettings.cs @@ -62,6 +62,15 @@ public DashboardAlertSettings(IUserPreferencesService preferencesService) public string SlackWebhookUrl => DashboardAlertCredentials.GetSlackWebhookUrl(); public string SlackProxyAddress => Prefs.SlackProxyAddress; + /* The generic webhook channel (#1506) ships to Lite and Darling only. The Dashboard is deprecated + (bug fixes only), so it gains no new settings surface to author these from — the channel stays + off, and the shared WebhookAlertService skips it on the disabled flag. */ + public bool GenericWebhookEnabled => false; + public string GenericWebhookUrl => ""; + public string GenericWebhookHeadersJson => ""; + public string GenericWebhookBodyTemplate => ""; + public string GenericWebhookProxyAddress => ""; + public double AnalysisNotifySeverity => Math.Clamp(Prefs.AnalysisNotifySeverity, 0.0, 2.0); public int AnalysisNotifyCooldownMinutes => Math.Clamp(Prefs.AnalysisNotifyCooldownMinutes, 30, 10080); } diff --git a/Dashboard/Services/DataGridFilterService.cs b/Dashboard/Services/DataGridFilterService.cs index c441f8a13..118b59eae 100644 --- a/Dashboard/Services/DataGridFilterService.cs +++ b/Dashboard/Services/DataGridFilterService.cs @@ -6,181 +6,21 @@ * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Windows.Controls; -using System.Windows.Data; -using PerformanceMonitorDashboard.Helpers; -using PerformanceMonitorDashboard.Models; using PerformanceMonitor.Common; namespace PerformanceMonitorDashboard.Services { /// <summary> - /// Provides shared filtering functionality for DataGrid controls. + /// Provides operator-based filtering for DataGrid column filters. Forwards to the shared + /// <see cref="ColumnFilterMatcher"/> so Lite and Dashboard share one matching implementation. /// </summary> public static class DataGridFilterService { - /* Numeric types that should use NumericFilterHelper */ - private static readonly HashSet<Type> NumericTypes = new HashSet<Type> - { - typeof(int), typeof(int?), - typeof(long), typeof(long?), - typeof(short), typeof(short?), - typeof(decimal), typeof(decimal?), - typeof(double), typeof(double?), - typeof(float), typeof(float?) - }; - - /* Date types that should use DateFilterHelper */ - private static readonly HashSet<Type> DateTypes = new HashSet<Type> - { - typeof(DateTime), typeof(DateTime?) - }; - - /* Cache PropertyInfo lookups to avoid repeated reflection */ - private static readonly ConcurrentDictionary<(Type, string), PropertyInfo?> s_propertyCache = new(); - - /// <summary> - /// Gets a cached PropertyInfo for the given type and property name. - /// </summary> - private static PropertyInfo? GetCachedProperty(Type type, string propertyName) - { - return s_propertyCache.GetOrAdd((type, propertyName), key => key.Item1.GetProperty(key.Item2)); - } - - /// <summary> - /// Checks if a single property value matches a filter string using type-aware filtering. - /// Use this in custom filter implementations that need additional logic (e.g., ComboBox filters). - /// </summary> - /// <param name="item">The data item to check</param> - /// <param name="propertyName">The property name to filter on</param> - /// <param name="filterText">The filter text (supports operators for numeric/date types)</param> - /// <returns>True if the value matches the filter, false otherwise</returns> - public static bool MatchesFilter(object item, string propertyName, string filterText) - { - if (item == null || string.IsNullOrWhiteSpace(filterText)) - return true; - - var property = GetCachedProperty(item.GetType(), propertyName); - if (property == null) - return true; - - var propertyType = property.PropertyType; - var rawValue = property.GetValue(item); - - // Use appropriate filter helper based on property type - if (NumericTypes.Contains(propertyType)) - { - return NumericFilterHelper.MatchesFilter(rawValue, filterText); - } - else if (DateTypes.Contains(propertyType)) - { - return DateFilterHelper.MatchesFilter(rawValue, filterText); - } - else - { - // String filtering: case-insensitive contains with comma-separated OR - var value = rawValue?.ToString()?.ToLowerInvariant() ?? string.Empty; - var filterLower = filterText.ToLowerInvariant(); - var filterTerms = filterLower.Split(',').Select(t => t.Trim()).Where(t => !string.IsNullOrEmpty(t)); - return filterTerms.Any(term => value.Contains(term, StringComparison.Ordinal)); - } - } - /// <summary> /// Checks if an item matches a ColumnFilterState using operator-based filtering. - /// Used by the new popup-based column filter UI. + /// Used by the popup-based column filter UI. /// </summary> - /// <param name="item">The data item to check</param> - /// <param name="filter">The filter state containing operator and value</param> - /// <returns>True if the value matches the filter, false otherwise</returns> public static bool MatchesFilter(object item, ColumnFilterState filter) => ColumnFilterMatcher.MatchesFilter(item, filter); - - /// <summary> - /// Applies a column-based filter to a DataGrid. - /// Filter TextBoxes in column headers (inside StackPanels) with Tag set to property names - /// are used to filter the data. Multiple terms separated by commas are OR-combined, - /// while multiple columns are AND-combined. - /// Numeric columns support operators: >, <, >=, <=, and ranges (10-20 or 10..20). - /// Date columns support operators, relative dates (today, yesterday), and ranges. - /// </summary> - /// <param name="dataGrid">The DataGrid to filter</param> - /// <param name="filterTextBox">The TextBox that triggered the filter (can be null)</param> - public static void ApplyFilter(DataGrid? dataGrid, TextBox? filterTextBox) - { - if (dataGrid?.ItemsSource == null || filterTextBox == null) - return; - - var view = CollectionViewSource.GetDefaultView(dataGrid.ItemsSource); - if (view == null) - return; - - view.Filter = item => - { - if (item == null) - return false; - - // Get all filter TextBoxes for this DataGrid - var filterBoxes = new List<(string PropertyName, string FilterText)>(); - foreach (var column in dataGrid.Columns) - { - if (column.Header is StackPanel stackPanel) - { - var textBox = stackPanel.Children.OfType<TextBox>().FirstOrDefault(); - if (textBox != null && !string.IsNullOrWhiteSpace(textBox.Text) && textBox.Tag is string propName) - { - filterBoxes.Add((propName, textBox.Text)); - } - } - } - - // If no filters, show all items - if (filterBoxes.Count == 0) - return true; - - // Check all filters (AND logic) - foreach (var (propertyName, filterText) in filterBoxes) - { - var property = GetCachedProperty(item.GetType(), propertyName); - if (property != null) - { - var propertyType = property.PropertyType; - var rawValue = property.GetValue(item); - - // Use appropriate filter helper based on property type - if (NumericTypes.Contains(propertyType)) - { - // Numeric filtering: supports >, <, >=, <=, ranges - if (!NumericFilterHelper.MatchesFilter(rawValue, filterText)) - return false; - } - else if (DateTypes.Contains(propertyType)) - { - // Date filtering: supports operators, relative dates, ranges - if (!DateFilterHelper.MatchesFilter(rawValue, filterText)) - return false; - } - else - { - // String filtering: case-insensitive contains with comma-separated OR - var value = rawValue?.ToString()?.ToLowerInvariant() ?? string.Empty; - var filterLower = filterText.ToLowerInvariant(); - var filterTerms = filterLower.Split(',').Select(t => t.Trim()).Where(t => !string.IsNullOrEmpty(t)); - var matchesAnyTerm = filterTerms.Any(term => value.Contains(term, StringComparison.Ordinal)); - - if (!matchesAnyTerm) - return false; - } - } - } - - return true; - }; - } } } diff --git a/Dashboard/Services/DatabaseService.FinOps.Inventory.cs b/Dashboard/Services/DatabaseService.FinOps.Inventory.cs index d1d26cf1c..93534cf06 100644 --- a/Dashboard/Services/DatabaseService.FinOps.Inventory.cs +++ b/Dashboard/Services/DatabaseService.FinOps.Inventory.cs @@ -9,6 +9,7 @@ using System; using System.Threading.Tasks; using Microsoft.Data.SqlClient; +using PerformanceMonitorDashboard.Helpers; namespace PerformanceMonitorDashboard.Services { @@ -98,19 +99,31 @@ FROM sys.dm_os_performance_counters AS pc } /// <summary> - /// Fetches server inventory from config.server_info. + /// Inventory facts: edition, product version/level, storage, HADR/cluster flags, and host OS. + /// Every value comes from SERVERPROPERTY scalars, OBJECT_ID-guarded dynamic SQL, or an + /// engine-edition-aware storage read that needs NO special permission, so this succeeds for any + /// connected login — even an Azure SQL DB login lacking VIEW DATABASE STATE. The five hardware + /// columns (reader indices 4, 5, 6, 8, 9) are typed-NULL placeholders here, filled by a separate + /// <see cref="HardwareQueryText"/> read; keeping them preserves the reader column order. Neither + /// sys.dm_os_sys_info nor the Azure-absent sys.master_files is referenced (#1535). Mirrors Lite's + /// LocalDataService.FinOps.ServerProperties. Columns: 0 edition, 1 product_version, + /// 2 product_level, 3 product_update_level, 4 cpu_count, 5 physical_memory_mb, + /// 6 sqlserver_start_time, 7 total_storage_gb, 8 socket_count, 9 cores_per_socket, + /// 10 engine_edition, 11 is_hadr_enabled, 12 is_clustered, 13 host_os. /// </summary> - /// <summary> - /// Queries a SQL Server directly for its properties via SERVERPROPERTY + sys.dm_os_sys_info. - /// Works from any database context — no PerformanceMonitor DB required. - /// </summary> - public static async Task<FinOpsServerInventory> GetServerPropertiesLiveAsync(string connectionString) - { - await using var connection = new SqlConnection(connectionString); - await connection.OpenAsync(); + internal const string InventoryQueryText = @" +DECLARE + @storage_sql nvarchar(MAX) = + CASE + WHEN CONVERT(int, SERVERPROPERTY('EngineEdition')) = 5 + THEN N'SELECT @gb = SUM(CAST(size AS bigint)) * 8.0 / 1024.0 / 1024.0 FROM sys.database_files' + ELSE N'SELECT @gb = SUM(CAST(size AS bigint)) * 8.0 / 1024.0 / 1024.0 FROM sys.master_files' + END, + @storage_gb decimal(19,2), + @host_os nvarchar(256); + +EXEC sys.sp_executesql @storage_sql, N'@gb decimal(19,2) OUTPUT', @gb = @storage_gb OUTPUT; - const string query = @" -DECLARE @host_os nvarchar(256); IF OBJECT_ID(N'sys.dm_os_host_info', N'V') IS NOT NULL EXEC sys.sp_executesql N'SELECT @os = host_distribution FROM sys.dm_os_host_info', N'@os nvarchar(256) OUTPUT', @os = @host_os OUTPUT; @@ -147,17 +160,17 @@ ELSE CONVERT(nvarchar(256), SERVERPROPERTY('Edition')) product_update_level = CONVERT(nvarchar(128), SERVERPROPERTY('ProductUpdateLevel')), cpu_count = - si.cpu_count, + CONVERT(int, NULL), physical_memory_mb = - si.physical_memory_kb / 1024, + CONVERT(bigint, NULL), sqlserver_start_time = - si.sqlserver_start_time, + CONVERT(datetime, NULL), total_storage_gb = - (SELECT SUM(CAST(size AS bigint)) * 8.0 / 1024.0 / 1024.0 FROM sys.master_files), + @storage_gb, socket_count = - si.socket_count, + CONVERT(int, NULL), cores_per_socket = - si.cores_per_socket, + CONVERT(int, NULL), engine_edition = CONVERT(int, SERVERPROPERTY('EngineEdition')), is_hadr_enabled = @@ -165,15 +178,41 @@ ELSE CONVERT(nvarchar(256), SERVERPROPERTY('Edition')) is_clustered = CONVERT(int, SERVERPROPERTY('IsClustered')), host_os = - @host_os -FROM sys.dm_os_sys_info AS si;"; + @host_os;"; - using var command = new SqlCommand(query, connection); - command.CommandTimeout = 30; + /// <summary> + /// Hardware facts from sys.dm_os_sys_info: CPU count, physical memory (MB), start time, and + /// socket/core topology. That DMV needs VIEW SERVER STATE (VIEW DATABASE STATE on Azure SQL DB), + /// so it runs in its OWN best-effort read — a permission gap loses only these fields, never the + /// inventory row above (#1535). Columns: 0 cpu_count, 1 physical_memory_mb, 2 sqlserver_start_time, + /// 3 socket_count, 4 cores_per_socket. + /// </summary> + internal const string HardwareQueryText = + "SELECT cpu_count, physical_memory_kb / 1024, sqlserver_start_time, socket_count, cores_per_socket FROM sys.dm_os_sys_info;"; + + /// <summary> + /// Queries a SQL Server directly for its properties. Inventory facts (edition, version, storage) + /// come from a permission-free query; hardware facts (CPU/memory/sockets) come from a separate + /// best-effort read of sys.dm_os_sys_info, so a login without VIEW DATABASE STATE still gets the + /// inventory row with <see cref="FinOpsServerInventory.HardwareUnavailableReason"/> set. + /// Works from any database context — no PerformanceMonitor DB required. + /// </summary> + public static async Task<FinOpsServerInventory> GetServerPropertiesLiveAsync(string connectionString) + { + await using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(); + + FinOpsServerInventory result; - using var reader = await command.ExecuteReaderAsync(); - if (await reader.ReadAsync()) + // Inventory read. Scoped closed in a using(...) block BEFORE the hardware read opens: the + // Dashboard connection string has no MARS, so an overlapping second reader would throw + // (#1535/#1589). + using (var command = new SqlCommand(InventoryQueryText, connection) { CommandTimeout = 30 }) + using (var reader = await command.ExecuteReaderAsync()) { + if (!await reader.ReadAsync()) + return new FinOpsServerInventory(); + var version = reader.IsDBNull(1) ? "" : reader.GetString(1); var level = reader.IsDBNull(2) ? "" : reader.GetString(2); var updateLevel = reader.IsDBNull(3) ? null : reader.GetString(3); @@ -181,7 +220,7 @@ ELSE CONVERT(nvarchar(256), SERVERPROPERTY('Edition')) ? $"{version} - {updateLevel}" : $"{version} - {level}"; - return new FinOpsServerInventory + result = new FinOpsServerInventory { Edition = reader.IsDBNull(0) ? "" : reader.GetString(0), SqlVersion = versionDisplay, @@ -199,7 +238,30 @@ ELSE CONVERT(nvarchar(256), SERVERPROPERTY('Edition')) }; } - return new FinOpsServerInventory(); + // Hardware read: best-effort in its OWN try/catch. sys.dm_os_sys_info needs VIEW SERVER STATE + // (VIEW DATABASE STATE on Azure SQL DB); a login without it loses only these fields, and the + // inventory row above still renders edition/version/storage (#1535). + try + { + using var hardwareCommand = new SqlCommand(HardwareQueryText, connection) { CommandTimeout = 30 }; + using var hardwareReader = await hardwareCommand.ExecuteReaderAsync(); + if (await hardwareReader.ReadAsync()) + { + result.CpuCount = hardwareReader.IsDBNull(0) ? 0 : Convert.ToInt32(hardwareReader.GetValue(0)); + result.PhysicalMemoryMb = hardwareReader.IsDBNull(1) ? 0L : Convert.ToInt64(hardwareReader.GetValue(1)); + result.SqlServerStartTime = hardwareReader.IsDBNull(2) ? null : hardwareReader.GetDateTime(2); + result.SocketCount = hardwareReader.IsDBNull(3) ? null : Convert.ToInt32(hardwareReader.GetValue(3)); + result.CoresPerSocket = hardwareReader.IsDBNull(4) ? null : Convert.ToInt32(hardwareReader.GetValue(4)); + } + } + catch (SqlException ex) + { + result.HardwareUnavailableReason = + "Hardware inventory (CPU, memory, sockets) unavailable - the monitoring login likely lacks VIEW DATABASE STATE on this database."; + Logger.Warning($"FinOps: hardware inventory unavailable for '{connection.DataSource}': {ex.Message}"); + } + + return result; } /// <summary> diff --git a/Dashboard/Services/DatabaseService.FinOps.Models.cs b/Dashboard/Services/DatabaseService.FinOps.Models.cs index 553c9875c..0ebfda4c9 100644 --- a/Dashboard/Services/DatabaseService.FinOps.Models.cs +++ b/Dashboard/Services/DatabaseService.FinOps.Models.cs @@ -84,6 +84,14 @@ public class FinOpsServerInventory public DateTime? LastUpdated { get; set; } public bool? IsHadrEnabled { get; set; } public bool? IsClustered { get; set; } + + /// <summary> + /// Set when the sys.dm_os_sys_info hardware read failed (e.g. an Azure SQL DB monitoring login + /// without VIEW DATABASE STATE). Null when hardware inventory is available. Surfaced as a + /// non-alarming note in the FinOps Server Inventory grid (#1535). + /// </summary> + public string? HardwareUnavailableReason { get; set; } + public decimal? AvgCpuPct { get; set; } public decimal? StorageTotalGb { get; set; } public int? IdleDbCount { get; set; } diff --git a/Dashboard/Services/DatabaseService.FinOps.cs b/Dashboard/Services/DatabaseService.FinOps.cs deleted file mode 100644 index 3cb3ccdbe..000000000 --- a/Dashboard/Services/DatabaseService.FinOps.cs +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (c) 2026 Erik Darling, Darling Data LLC - * - * This file is part of the SQL Server Performance Monitor. - * - * Licensed under the MIT License. See LICENSE file in the project root for full license information. - */ - -namespace PerformanceMonitorDashboard.Services -{ - /// <summary> - /// FinOps Tab data access — see sub-partials: - /// - DatabaseService.FinOps.Inventory.cs (server-level utilization, properties, metrics) - /// - DatabaseService.FinOps.Workload.cs (database/application resource usage, top consumers) - /// - DatabaseService.FinOps.Storage.cs (database sizes, growth, idle DBs, tempdb) - /// - DatabaseService.FinOps.Queries.cs (waits, expensive/high-impact queries, memory grants, provisioning trend) - /// - DatabaseService.FinOps.IndexAnalysis.cs (sp_IndexCleanup integration) - /// - DatabaseService.FinOps.Recommendations.cs (FinOps recommendation engine) - /// - DatabaseService.FinOps.Models.cs (FinOps DTO/model types) - /// </summary> - public partial class DatabaseService - { - } -} diff --git a/Dashboard/Services/DatabaseService.NocHealth.cs b/Dashboard/Services/DatabaseService.NocHealth.cs index 150fd672d..ac8cb1f3d 100644 --- a/Dashboard/Services/DatabaseService.NocHealth.cs +++ b/Dashboard/Services/DatabaseService.NocHealth.cs @@ -10,8 +10,10 @@ using System.Collections.Generic; using System.Data; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.Data.SqlClient; +using PerformanceMonitor.Alerting; using PerformanceMonitorDashboard.Helpers; using PerformanceMonitorDashboard.Models; @@ -587,41 +589,17 @@ WHERE deqrs.max_target_memory_kb IS NOT NULL private async Task GetBlockingStatusAsync(SqlConnection connection, ServerHealthStatus status) { - const string query = @"SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + // Delegates to GetBlockingValuesAsync, the single source of the sys.sysprocesses LCK + // blocking query, so the health-status and alert paths can never drift. Empty exclusions + // reproduces this path's historical no-database-filter behavior (the query is then + // byte-identical to the old inline one). GetBlockingValuesAsync already swallows failures + // and returns (0, 0), so no try/catch is needed here. + var (totalBlocked, longestSeconds) = await GetBlockingValuesAsync(connection, Array.Empty<string>()); - SELECT - total_blocked = COUNT_BIG(*), - longest_blocked_seconds = ISNULL(MAX(s.waittime), 0) / 1000.0 - FROM sys.sysprocesses AS s - WHERE s.blocked <> 0 - AND s.lastwaittype LIKE N'LCK%' - OPTION(MAXDOP 1, RECOMPILE);"; + status.TotalBlocked = totalBlocked; + status.LongestBlockedSeconds = longestSeconds; - try - { - using var cmd = new SqlCommand(query, connection); - cmd.CommandTimeout = 10; - using var reader = await cmd.ExecuteReaderAsync(); - - if (await reader.ReadAsync()) - { - // Use GetValue + Convert for safety with varying SQL types - var totalBlockedValue = reader.GetValue(0); - var longestSecondsValue = reader.GetValue(1); - - var totalBlocked = Convert.ToInt64(totalBlockedValue, System.Globalization.CultureInfo.InvariantCulture); - var longestSeconds = Convert.ToDecimal(longestSecondsValue, System.Globalization.CultureInfo.InvariantCulture); - - status.TotalBlocked = totalBlocked; - status.LongestBlockedSeconds = longestSeconds; - - Logger.Info($"Blocking status: {totalBlocked} blocked, longest {longestSeconds}s"); - } - } - catch (Exception ex) - { - Logger.Warning($"Failed to get blocking status: {ex.Message}"); - } + Logger.Info($"Blocking status: {totalBlocked} blocked, longest {longestSeconds}s"); } private async Task GetThreadStatusAsync(SqlConnection connection, ServerHealthStatus status) @@ -1088,71 +1066,27 @@ ORDER BY percent_of_average DESC } /// <summary> - /// Live query against the monitored server's msdb for SQL Agent job runs (step_id = 0 - /// outcome row, run_status = 0 = Failed) that failed within the lookback window. - /// run_date/run_time integers are converted to a server-local datetime and filtered to the - /// last N minutes. Degrades gracefully: a login without msdb / SQLAgentReaderRole access - /// raises a catchable SqlException (916/229/297/300) that returns an empty list rather than - /// failing the alert cycle. Azure SQL DB (no Agent) is skipped by the caller. + /// Live query against the monitored server's msdb for SQL Agent job runs that FAILED within + /// the lookback window — the SQL text and row mapping live in the shared + /// <see cref="FailedJobsQuery"/> (Phase-5 slice E; see its doc for the query semantics and + /// the server-local RunDateTime note). Runs at alert-check time — failure outcomes are not + /// part of the collected running_jobs snapshot. Degrades gracefully: a login without msdb / + /// SQLAgentReaderRole access raises a catchable SqlException (916/229/297/300) that returns + /// an empty list rather than failing the alert cycle. Azure SQL DB (no Agent) is skipped by + /// the caller. /// </summary> private async Task<List<FailedJobInfo>> GetRecentlyFailedJobsAsync(SqlConnection connection, int lookbackMinutes) { var results = new List<FailedJobInfo>(); - const string query = @"SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - - SELECT TOP (50) - job_name = j.name, - job_id = CONVERT(varchar(36), j.job_id), - run_datetime = - DATEADD - ( - SECOND, - (jh.run_time / 10000) * 3600 + - ((jh.run_time / 100) % 100) * 60 + - (jh.run_time % 100), - CONVERT(datetime, CONVERT(varchar(8), jh.run_date)) - ), - step_id = jh.step_id, - step_name = jh.step_name, - message = jh.message - FROM msdb.dbo.sysjobhistory AS jh - JOIN msdb.dbo.sysjobs AS j - ON j.job_id = jh.job_id - WHERE jh.step_id = 0 - AND jh.run_status = 0 - AND jh.run_date >= CONVERT(integer, CONVERT(varchar(8), DATEADD(MINUTE, -@lookback_minutes, GETDATE()), 112)) - AND DATEADD - ( - SECOND, - (jh.run_time / 10000) * 3600 + - ((jh.run_time / 100) % 100) * 60 + - (jh.run_time % 100), - CONVERT(datetime, CONVERT(varchar(8), jh.run_date)) - ) >= DATEADD(MINUTE, -@lookback_minutes, GETDATE()) - ORDER BY - run_datetime DESC - OPTION(RECOMPILE);"; - try { - using var cmd = new SqlCommand(query, connection); + using var cmd = new SqlCommand(FailedJobsQuery.Sql, connection); cmd.CommandTimeout = 10; - cmd.Parameters.Add(new SqlParameter("@lookback_minutes", SqlDbType.Int) { Value = lookbackMinutes }); + cmd.Parameters.Add(new SqlParameter(FailedJobsQuery.LookbackMinutesParameter, SqlDbType.Int) { Value = lookbackMinutes }); using var reader = await cmd.ExecuteReaderAsync(); - while (await reader.ReadAsync()) - { - results.Add(new FailedJobInfo - { - JobName = reader.GetString(0), - JobId = reader.IsDBNull(1) ? "" : reader.GetString(1), - RunDateTime = reader.GetDateTime(2), - StepId = reader.IsDBNull(3) ? 0 : Convert.ToInt32(reader.GetValue(3), System.Globalization.CultureInfo.InvariantCulture), - StepName = reader.IsDBNull(4) ? "" : reader.GetString(4), - Message = reader.IsDBNull(5) ? "" : reader.GetString(5) - }); - } + results = await FailedJobsQuery.ReadAsync(reader, CancellationToken.None); } catch (Microsoft.Data.SqlClient.SqlException ex) when (ex.Number is 229 or 297 or 300 or 916) { diff --git a/Dashboard/Services/DatabaseService.QueryPerformance.History.cs b/Dashboard/Services/DatabaseService.QueryPerformance.History.cs index 2eaf87e10..39c47effc 100644 --- a/Dashboard/Services/DatabaseService.QueryPerformance.History.cs +++ b/Dashboard/Services/DatabaseService.QueryPerformance.History.cs @@ -75,14 +75,28 @@ public async Task<List<QueryExecutionHistoryItem>> GetQueryStoreHistoryAsync(str force_failure_count = MAX(qsd.force_failure_count), last_force_failure_reason_desc = MAX(qsd.last_force_failure_reason_desc), plan_forcing_type = MAX(qsd.plan_forcing_type), - compatibility_level = MAX(qsd.compatibility_level) + compatibility_level = MAX(qsd.compatibility_level), + qsd.execution_type_desc, + server_first_execution_time = MIN(qsd.server_first_execution_time), + server_last_execution_time = MAX(qsd.server_last_execution_time), + module_name = MAX(qsd.module_name), + avg_num_physical_io_reads = SUM(qsd.avg_num_physical_io_reads * qsd.count_executions) / NULLIF(SUM(qsd.count_executions), 0), + min_num_physical_io_reads = MIN(qsd.min_num_physical_io_reads), + max_num_physical_io_reads = MAX(qsd.max_num_physical_io_reads), + avg_clr_time = SUM(qsd.avg_clr_time * qsd.count_executions) / NULLIF(SUM(qsd.count_executions), 0), + min_clr_time = MIN(qsd.min_clr_time), + max_clr_time = MAX(qsd.max_clr_time), + avg_log_bytes_used = SUM(qsd.avg_log_bytes_used * qsd.count_executions) / NULLIF(SUM(qsd.count_executions), 0), + min_log_bytes_used = MIN(qsd.min_log_bytes_used), + max_log_bytes_used = MAX(qsd.max_log_bytes_used) FROM collect.query_store_data AS qsd WHERE qsd.database_name = @database_name AND qsd.query_id = @query_id {timeFilter} GROUP BY qsd.collection_time, - qsd.plan_id + qsd.plan_id, + qsd.execution_type_desc ORDER BY qsd.collection_time DESC;"; @@ -144,7 +158,20 @@ ORDER BY ForceFailureCount = reader.IsDBNull(34) ? null : reader.GetInt64(34), LastForceFailureReasonDesc = reader.IsDBNull(35) ? null : reader.GetString(35), PlanForcingType = reader.IsDBNull(36) ? null : reader.GetString(36), - CompatibilityLevel = reader.IsDBNull(37) ? null : reader.GetInt16(37) + CompatibilityLevel = reader.IsDBNull(37) ? null : reader.GetInt16(37), + ExecutionTypeDesc = reader.IsDBNull(38) ? null : reader.GetString(38), + ServerFirstExecutionTime = reader.IsDBNull(39) ? null : reader.GetDateTime(39), + ServerLastExecutionTime = reader.IsDBNull(40) ? null : reader.GetDateTime(40), + ModuleName = reader.IsDBNull(41) ? null : reader.GetString(41), + AvgNumPhysicalReads = reader.IsDBNull(42) ? null : reader.GetInt64(42), + MinNumPhysicalReads = reader.IsDBNull(43) ? null : reader.GetInt64(43), + MaxNumPhysicalReads = reader.IsDBNull(44) ? null : reader.GetInt64(44), + AvgClrTime = reader.GetInt64(45), + MinClrTime = reader.GetInt64(46), + MaxClrTime = reader.GetInt64(47), + AvgLogBytesUsed = reader.IsDBNull(48) ? null : reader.GetInt64(48), + MinLogBytesUsed = reader.IsDBNull(49) ? null : reader.GetInt64(49), + MaxLogBytesUsed = reader.IsDBNull(50) ? null : reader.GetInt64(50) }); } @@ -168,7 +195,6 @@ public async Task<List<ProcedureExecutionHistoryItem>> GetProcedureStatsHistoryA SELECT ps.collection_id, ps.collection_time, - ps.server_start_time, ps.object_type, ps.type_desc, ps.cached_time, @@ -231,39 +257,38 @@ ORDER BY { CollectionId = reader.GetInt64(0), CollectionTime = reader.GetDateTime(1), - ServerStartTime = reader.GetDateTime(2), - ObjectType = reader.GetString(3), - TypeDesc = reader.IsDBNull(4) ? null : reader.GetString(4), - CachedTime = reader.GetDateTime(5), - LastExecutionTime = reader.GetDateTime(6), - ExecutionCount = reader.GetInt64(7), - TotalWorkerTime = reader.GetInt64(8), - MinWorkerTime = reader.GetInt64(9), - MaxWorkerTime = reader.GetInt64(10), - TotalElapsedTime = reader.GetInt64(11), - MinElapsedTime = reader.GetInt64(12), - MaxElapsedTime = reader.GetInt64(13), - TotalLogicalReads = reader.GetInt64(14), - MinLogicalReads = reader.GetInt64(15), - MaxLogicalReads = reader.GetInt64(16), - TotalPhysicalReads = reader.GetInt64(17), - MinPhysicalReads = reader.GetInt64(18), - MaxPhysicalReads = reader.GetInt64(19), - TotalLogicalWrites = reader.GetInt64(20), - MinLogicalWrites = reader.GetInt64(21), - MaxLogicalWrites = reader.GetInt64(22), - TotalSpills = reader.IsDBNull(23) ? null : reader.GetInt64(23), - MinSpills = reader.IsDBNull(24) ? null : reader.GetInt64(24), - MaxSpills = reader.IsDBNull(25) ? null : reader.GetInt64(25), - ExecutionCountDelta = reader.IsDBNull(26) ? null : reader.GetInt64(26), - TotalWorkerTimeDelta = reader.IsDBNull(27) ? null : reader.GetInt64(27), - TotalElapsedTimeDelta = reader.IsDBNull(28) ? null : reader.GetInt64(28), - TotalLogicalReadsDelta = reader.IsDBNull(29) ? null : reader.GetInt64(29), - TotalPhysicalReadsDelta = reader.IsDBNull(30) ? null : reader.GetInt64(30), - TotalLogicalWritesDelta = reader.IsDBNull(31) ? null : reader.GetInt64(31), - SampleIntervalSeconds = reader.IsDBNull(32) ? null : reader.GetInt32(32), - SqlHandle = reader.IsDBNull(33) ? null : reader.GetString(33), - PlanHandle = reader.IsDBNull(34) ? null : reader.GetString(34) + ObjectType = reader.GetString(2), + TypeDesc = reader.IsDBNull(3) ? null : reader.GetString(3), + CachedTime = reader.GetDateTime(4), + LastExecutionTime = reader.GetDateTime(5), + ExecutionCount = reader.GetInt64(6), + TotalWorkerTime = reader.GetInt64(7), + MinWorkerTime = reader.GetInt64(8), + MaxWorkerTime = reader.GetInt64(9), + TotalElapsedTime = reader.GetInt64(10), + MinElapsedTime = reader.GetInt64(11), + MaxElapsedTime = reader.GetInt64(12), + TotalLogicalReads = reader.GetInt64(13), + MinLogicalReads = reader.GetInt64(14), + MaxLogicalReads = reader.GetInt64(15), + TotalPhysicalReads = reader.GetInt64(16), + MinPhysicalReads = reader.GetInt64(17), + MaxPhysicalReads = reader.GetInt64(18), + TotalLogicalWrites = reader.GetInt64(19), + MinLogicalWrites = reader.GetInt64(20), + MaxLogicalWrites = reader.GetInt64(21), + TotalSpills = reader.IsDBNull(22) ? null : reader.GetInt64(22), + MinSpills = reader.IsDBNull(23) ? null : reader.GetInt64(23), + MaxSpills = reader.IsDBNull(24) ? null : reader.GetInt64(24), + ExecutionCountDelta = reader.IsDBNull(25) ? null : reader.GetInt64(25), + TotalWorkerTimeDelta = reader.IsDBNull(26) ? null : reader.GetInt64(26), + TotalElapsedTimeDelta = reader.IsDBNull(27) ? null : reader.GetInt64(27), + TotalLogicalReadsDelta = reader.IsDBNull(28) ? null : reader.GetInt64(28), + TotalPhysicalReadsDelta = reader.IsDBNull(29) ? null : reader.GetInt64(29), + TotalLogicalWritesDelta = reader.IsDBNull(30) ? null : reader.GetInt64(30), + SampleIntervalSeconds = reader.IsDBNull(31) ? null : reader.GetInt32(31), + SqlHandle = reader.IsDBNull(32) ? null : reader.GetString(32), + PlanHandle = reader.IsDBNull(33) ? null : reader.GetString(33) }); } @@ -287,7 +312,6 @@ public async Task<List<ProcedureExecutionHistoryItem>> GetProcedureStatsHistoryA SELECT ps.collection_id, ps.collection_time, - ps.server_start_time, ps.object_type, ps.type_desc, ps.cached_time, @@ -352,39 +376,38 @@ ORDER BY { CollectionId = reader.GetInt64(0), CollectionTime = reader.GetDateTime(1), - ServerStartTime = reader.GetDateTime(2), - ObjectType = reader.GetString(3), - TypeDesc = reader.IsDBNull(4) ? null : reader.GetString(4), - CachedTime = reader.GetDateTime(5), - LastExecutionTime = reader.GetDateTime(6), - ExecutionCount = reader.GetInt64(7), - TotalWorkerTime = reader.GetInt64(8), - MinWorkerTime = reader.GetInt64(9), - MaxWorkerTime = reader.GetInt64(10), - TotalElapsedTime = reader.GetInt64(11), - MinElapsedTime = reader.GetInt64(12), - MaxElapsedTime = reader.GetInt64(13), - TotalLogicalReads = reader.GetInt64(14), - MinLogicalReads = reader.GetInt64(15), - MaxLogicalReads = reader.GetInt64(16), - TotalPhysicalReads = reader.GetInt64(17), - MinPhysicalReads = reader.GetInt64(18), - MaxPhysicalReads = reader.GetInt64(19), - TotalLogicalWrites = reader.GetInt64(20), - MinLogicalWrites = reader.GetInt64(21), - MaxLogicalWrites = reader.GetInt64(22), - TotalSpills = reader.IsDBNull(23) ? null : reader.GetInt64(23), - MinSpills = reader.IsDBNull(24) ? null : reader.GetInt64(24), - MaxSpills = reader.IsDBNull(25) ? null : reader.GetInt64(25), - ExecutionCountDelta = reader.IsDBNull(26) ? null : reader.GetInt64(26), - TotalWorkerTimeDelta = reader.IsDBNull(27) ? null : reader.GetInt64(27), - TotalElapsedTimeDelta = reader.IsDBNull(28) ? null : reader.GetInt64(28), - TotalLogicalReadsDelta = reader.IsDBNull(29) ? null : reader.GetInt64(29), - TotalPhysicalReadsDelta = reader.IsDBNull(30) ? null : reader.GetInt64(30), - TotalLogicalWritesDelta = reader.IsDBNull(31) ? null : reader.GetInt64(31), - SampleIntervalSeconds = reader.IsDBNull(32) ? null : reader.GetInt32(32), - SqlHandle = reader.IsDBNull(33) ? null : reader.GetString(33), - PlanHandle = reader.IsDBNull(34) ? null : reader.GetString(34) + ObjectType = reader.GetString(2), + TypeDesc = reader.IsDBNull(3) ? null : reader.GetString(3), + CachedTime = reader.GetDateTime(4), + LastExecutionTime = reader.GetDateTime(5), + ExecutionCount = reader.GetInt64(6), + TotalWorkerTime = reader.GetInt64(7), + MinWorkerTime = reader.GetInt64(8), + MaxWorkerTime = reader.GetInt64(9), + TotalElapsedTime = reader.GetInt64(10), + MinElapsedTime = reader.GetInt64(11), + MaxElapsedTime = reader.GetInt64(12), + TotalLogicalReads = reader.GetInt64(13), + MinLogicalReads = reader.GetInt64(14), + MaxLogicalReads = reader.GetInt64(15), + TotalPhysicalReads = reader.GetInt64(16), + MinPhysicalReads = reader.GetInt64(17), + MaxPhysicalReads = reader.GetInt64(18), + TotalLogicalWrites = reader.GetInt64(19), + MinLogicalWrites = reader.GetInt64(20), + MaxLogicalWrites = reader.GetInt64(21), + TotalSpills = reader.IsDBNull(22) ? null : reader.GetInt64(22), + MinSpills = reader.IsDBNull(23) ? null : reader.GetInt64(23), + MaxSpills = reader.IsDBNull(24) ? null : reader.GetInt64(24), + ExecutionCountDelta = reader.IsDBNull(25) ? null : reader.GetInt64(25), + TotalWorkerTimeDelta = reader.IsDBNull(26) ? null : reader.GetInt64(26), + TotalElapsedTimeDelta = reader.IsDBNull(27) ? null : reader.GetInt64(27), + TotalLogicalReadsDelta = reader.IsDBNull(28) ? null : reader.GetInt64(28), + TotalPhysicalReadsDelta = reader.IsDBNull(29) ? null : reader.GetInt64(29), + TotalLogicalWritesDelta = reader.IsDBNull(30) ? null : reader.GetInt64(30), + SampleIntervalSeconds = reader.IsDBNull(31) ? null : reader.GetInt32(31), + SqlHandle = reader.IsDBNull(32) ? null : reader.GetString(32), + PlanHandle = reader.IsDBNull(33) ? null : reader.GetString(33) }); } @@ -408,7 +431,6 @@ public async Task<List<QueryStatsHistoryItem>> GetQueryStatsHistoryAsync(string SELECT collection_id = MAX(qs.collection_id), qs.collection_time, - server_start_time = MAX(qs.server_start_time), object_type = MAX(qs.object_type), creation_time = MIN(qs.creation_time), last_execution_time = MAX(qs.last_execution_time), @@ -486,52 +508,51 @@ ORDER BY { CollectionId = reader.GetInt64(0), CollectionTime = reader.GetDateTime(1), - ServerStartTime = reader.GetDateTime(2), - ObjectType = reader.GetString(3), - CreationTime = reader.GetDateTime(4), - LastExecutionTime = reader.GetDateTime(5), - ExecutionCount = reader.GetInt64(6), - TotalWorkerTime = reader.GetInt64(7), - MinWorkerTime = reader.GetInt64(8), - MaxWorkerTime = reader.GetInt64(9), - TotalElapsedTime = reader.GetInt64(10), - MinElapsedTime = reader.GetInt64(11), - MaxElapsedTime = reader.GetInt64(12), - TotalLogicalReads = reader.GetInt64(13), - TotalPhysicalReads = reader.GetInt64(14), - MinPhysicalReads = reader.GetInt64(15), - MaxPhysicalReads = reader.GetInt64(16), - TotalLogicalWrites = reader.GetInt64(17), - TotalClrTime = reader.GetInt64(18), - TotalRows = reader.GetInt64(19), - MinRows = reader.GetInt64(20), - MaxRows = reader.GetInt64(21), - MinDop = Convert.ToInt16(reader.GetValue(22)), - MaxDop = Convert.ToInt16(reader.GetValue(23)), - MinGrantKb = reader.GetInt64(24), - MaxGrantKb = reader.GetInt64(25), - MinUsedGrantKb = reader.GetInt64(26), - MaxUsedGrantKb = reader.GetInt64(27), - MinIdealGrantKb = reader.GetInt64(28), - MaxIdealGrantKb = reader.GetInt64(29), - MinReservedThreads = Convert.ToInt32(reader.GetValue(30)), - MaxReservedThreads = Convert.ToInt32(reader.GetValue(31)), - MinUsedThreads = Convert.ToInt32(reader.GetValue(32)), - MaxUsedThreads = Convert.ToInt32(reader.GetValue(33)), - TotalSpills = reader.GetInt64(34), - MinSpills = reader.GetInt64(35), - MaxSpills = reader.GetInt64(36), - ExecutionCountDelta = reader.IsDBNull(37) ? null : reader.GetInt64(37), - TotalWorkerTimeDelta = reader.IsDBNull(38) ? null : reader.GetInt64(38), - TotalElapsedTimeDelta = reader.IsDBNull(39) ? null : reader.GetInt64(39), - TotalLogicalReadsDelta = reader.IsDBNull(40) ? null : reader.GetInt64(40), - TotalPhysicalReadsDelta = reader.IsDBNull(41) ? null : reader.GetInt64(41), - TotalLogicalWritesDelta = reader.IsDBNull(42) ? null : reader.GetInt64(42), - SampleIntervalSeconds = reader.IsDBNull(43) ? null : reader.GetInt32(43), - SqlHandle = reader.IsDBNull(44) ? null : reader.GetString(44), - PlanHandle = reader.IsDBNull(45) ? null : reader.GetString(45), - QueryHash = reader.IsDBNull(46) ? null : reader.GetString(46), - QueryPlanHash = reader.IsDBNull(47) ? null : reader.GetString(47) + ObjectType = reader.GetString(2), + CreationTime = reader.GetDateTime(3), + LastExecutionTime = reader.GetDateTime(4), + ExecutionCount = reader.GetInt64(5), + TotalWorkerTime = reader.GetInt64(6), + MinWorkerTime = reader.GetInt64(7), + MaxWorkerTime = reader.GetInt64(8), + TotalElapsedTime = reader.GetInt64(9), + MinElapsedTime = reader.GetInt64(10), + MaxElapsedTime = reader.GetInt64(11), + TotalLogicalReads = reader.GetInt64(12), + TotalPhysicalReads = reader.GetInt64(13), + MinPhysicalReads = reader.GetInt64(14), + MaxPhysicalReads = reader.GetInt64(15), + TotalLogicalWrites = reader.GetInt64(16), + TotalClrTime = reader.GetInt64(17), + TotalRows = reader.GetInt64(18), + MinRows = reader.GetInt64(19), + MaxRows = reader.GetInt64(20), + MinDop = Convert.ToInt16(reader.GetValue(21)), + MaxDop = Convert.ToInt16(reader.GetValue(22)), + MinGrantKb = reader.GetInt64(23), + MaxGrantKb = reader.GetInt64(24), + MinUsedGrantKb = reader.GetInt64(25), + MaxUsedGrantKb = reader.GetInt64(26), + MinIdealGrantKb = reader.GetInt64(27), + MaxIdealGrantKb = reader.GetInt64(28), + MinReservedThreads = Convert.ToInt32(reader.GetValue(29)), + MaxReservedThreads = Convert.ToInt32(reader.GetValue(30)), + MinUsedThreads = Convert.ToInt32(reader.GetValue(31)), + MaxUsedThreads = Convert.ToInt32(reader.GetValue(32)), + TotalSpills = reader.GetInt64(33), + MinSpills = reader.GetInt64(34), + MaxSpills = reader.GetInt64(35), + ExecutionCountDelta = reader.IsDBNull(36) ? null : reader.GetInt64(36), + TotalWorkerTimeDelta = reader.IsDBNull(37) ? null : reader.GetInt64(37), + TotalElapsedTimeDelta = reader.IsDBNull(38) ? null : reader.GetInt64(38), + TotalLogicalReadsDelta = reader.IsDBNull(39) ? null : reader.GetInt64(39), + TotalPhysicalReadsDelta = reader.IsDBNull(40) ? null : reader.GetInt64(40), + TotalLogicalWritesDelta = reader.IsDBNull(41) ? null : reader.GetInt64(41), + SampleIntervalSeconds = reader.IsDBNull(42) ? null : reader.GetInt32(42), + SqlHandle = reader.IsDBNull(43) ? null : reader.GetString(43), + PlanHandle = reader.IsDBNull(44) ? null : reader.GetString(44), + QueryHash = reader.IsDBNull(45) ? null : reader.GetString(45), + QueryPlanHash = reader.IsDBNull(46) ? null : reader.GetString(46) }); } @@ -559,7 +580,6 @@ natural key (end_time + duration + cpu + reads) and keeping only the first row. numbered AS ( SELECT - ta.analysis_id, ta.collection_time, ta.event_name, ta.database_name, @@ -568,6 +588,7 @@ numbered AS ta.application_name, ta.host_name, ta.spid, + ta.client_process_id, ta.duration_ms, ta.cpu_ms, ta.reads, @@ -594,7 +615,6 @@ AND LEFT(ta.sql_text, 200) = @query_pattern AND {timeFilter} ) SELECT - analysis_id, collection_time, event_name, database_name, @@ -603,6 +623,7 @@ AND LEFT(ta.sql_text, 200) = @query_pattern application_name, host_name, spid, + client_process_id, duration_ms, cpu_ms, reads, @@ -630,15 +651,15 @@ ORDER BY { items.Add(new TracePatternDetailItem { - AnalysisId = reader.GetInt64(0), - CollectionTime = reader.GetDateTime(1), - EventName = reader.IsDBNull(2) ? string.Empty : reader.GetString(2), - DatabaseName = reader.IsDBNull(3) ? null : reader.GetString(3), - LoginName = reader.IsDBNull(4) ? null : reader.GetString(4), - NtUserName = reader.IsDBNull(5) ? null : reader.GetString(5), - ApplicationName = reader.IsDBNull(6) ? null : reader.GetString(6), - HostName = reader.IsDBNull(7) ? null : reader.GetString(7), - Spid = reader.IsDBNull(8) ? null : reader.GetInt32(8), + CollectionTime = reader.GetDateTime(0), + EventName = reader.IsDBNull(1) ? string.Empty : reader.GetString(1), + DatabaseName = reader.IsDBNull(2) ? null : reader.GetString(2), + LoginName = reader.IsDBNull(3) ? null : reader.GetString(3), + NtUserName = reader.IsDBNull(4) ? null : reader.GetString(4), + ApplicationName = reader.IsDBNull(5) ? null : reader.GetString(5), + HostName = reader.IsDBNull(6) ? null : reader.GetString(6), + Spid = reader.IsDBNull(7) ? null : reader.GetInt32(7), + ClientProcessId = reader.IsDBNull(8) ? null : reader.GetInt32(8), DurationMs = reader.IsDBNull(9) ? null : reader.GetInt64(9), CpuMs = reader.IsDBNull(10) ? null : reader.GetInt64(10), Reads = reader.IsDBNull(11) ? null : reader.GetInt64(11), diff --git a/Dashboard/Services/DatabaseService.QueryPerformance.Snapshots.cs b/Dashboard/Services/DatabaseService.QueryPerformance.Snapshots.cs index 6a8654442..2c5f22eb2 100644 --- a/Dashboard/Services/DatabaseService.QueryPerformance.Snapshots.cs +++ b/Dashboard/Services/DatabaseService.QueryPerformance.Snapshots.cs @@ -81,7 +81,13 @@ SELECT TOP (500) qs.start_time, qs.tran_start_time, qs.request_id, - additional_info = CONVERT(nvarchar(max), qs.additional_info) + additional_info = CONVERT(nvarchar(max), qs.additional_info), + qs.requested_memory, + qs.granted_memory, + qs.max_used_memory, + qs.tasks, + qs.physical_io, + isolation_level = qs.additional_info.value('(additional_info/transaction_isolation_level/text())[1]', 'nvarchar(40)') /* query_plan fetched on-demand via GetQuerySnapshotPlanAsync */ FROM report.query_snapshots AS qs WHERE qs.collection_time >= @from_date @@ -124,7 +130,13 @@ SELECT TOP (500) qs.start_time, qs.tran_start_time, qs.request_id, - additional_info = CONVERT(nvarchar(max), qs.additional_info) + additional_info = CONVERT(nvarchar(max), qs.additional_info), + qs.requested_memory, + qs.granted_memory, + qs.max_used_memory, + qs.tasks, + qs.physical_io, + isolation_level = qs.additional_info.value('(additional_info/transaction_isolation_level/text())[1]', 'nvarchar(40)') /* query_plan fetched on-demand via GetQuerySnapshotPlanAsync */ FROM report.query_snapshots AS qs WHERE qs.collection_time >= DATEADD(HOUR, @hours_back, SYSDATETIME()) @@ -163,16 +175,23 @@ ORDER BY Writes = SafeToInt64(reader.GetValue(15), "writes"), PhysicalReads = SafeToInt64(reader.GetValue(16), "physical_reads"), ContextSwitches = SafeToInt64(reader.GetValue(17), "context_switches"), - UsedMemoryMb = SafeToDecimal(reader.GetValue(18), "used_memory"), - TempdbCurrentMb = SafeToDecimal(reader.GetValue(19), "tempdb_current"), - TempdbAllocations = SafeToDecimal(reader.GetValue(20), "tempdb_allocations"), + UsedMemoryMb = SafeToDecimal(reader.GetValue(18), "used_memory") / 128m, + /* sp_WhoIsActive tempdb counters are 8KB-page counts — /128 to MB (same fix as the memory columns, #1274) */ + TempdbCurrentMb = SafeToDecimal(reader.GetValue(19), "tempdb_current") / 128m, + TempdbAllocations = SafeToDecimal(reader.GetValue(20), "tempdb_allocations") / 128m, TranLogWrites = reader.IsDBNull(21) ? null : reader.GetValue(21)?.ToString(), OpenTranCount = SafeToInt16(reader.GetValue(22), "open_tran_count"), PercentComplete = SafeToDecimal(reader.GetValue(23), "percent_complete"), StartTime = reader.IsDBNull(24) ? null : reader.GetDateTime(24), TranStartTime = reader.IsDBNull(25) ? null : reader.GetDateTime(25), RequestId = SafeToInt16(reader.GetValue(26), "request_id"), - AdditionalInfo = reader.IsDBNull(27) ? null : reader.GetValue(27)?.ToString() + AdditionalInfo = reader.IsDBNull(27) ? null : reader.GetValue(27)?.ToString(), + RequestedMemoryMb = SafeToDecimal(reader.GetValue(28), "requested_memory") / 128m, + GrantedMemoryMb = SafeToDecimal(reader.GetValue(29), "granted_memory") / 128m, + MaxUsedMemoryMb = SafeToDecimal(reader.GetValue(30), "max_used_memory") / 128m, + Tasks = SafeToInt64(reader.GetValue(31), "tasks"), + PhysicalIo = SafeToInt64(reader.GetValue(32), "physical_io"), + IsolationLevel = reader.IsDBNull(33) ? null : reader.GetValue(33)?.ToString() // QueryPlan fetched on-demand via GetQuerySnapshotPlanAsync }); @@ -274,7 +293,13 @@ SELECT TOP (500) qs.start_time, qs.tran_start_time, qs.request_id, - additional_info = CONVERT(nvarchar(max), qs.additional_info) + additional_info = CONVERT(nvarchar(max), qs.additional_info), + qs.requested_memory, + qs.granted_memory, + qs.max_used_memory, + qs.tasks, + qs.physical_io, + isolation_level = qs.additional_info.value('(additional_info/transaction_isolation_level/text())[1]', 'nvarchar(40)') FROM report.query_snapshots AS qs WHERE qs.collection_time >= @from_date AND qs.collection_time <= @to_date @@ -313,7 +338,13 @@ SELECT TOP (500) qs.start_time, qs.tran_start_time, qs.request_id, - additional_info = CONVERT(nvarchar(max), qs.additional_info) + additional_info = CONVERT(nvarchar(max), qs.additional_info), + qs.requested_memory, + qs.granted_memory, + qs.max_used_memory, + qs.tasks, + qs.physical_io, + isolation_level = qs.additional_info.value('(additional_info/transaction_isolation_level/text())[1]', 'nvarchar(40)') FROM report.query_snapshots AS qs WHERE qs.collection_time >= DATEADD(HOUR, @hours_back, SYSDATETIME()) AND CONVERT(nvarchar(max), qs.wait_info) LIKE N'%)' + @wait_type + N'%' @@ -351,16 +382,23 @@ ORDER BY Writes = SafeToInt64(reader.GetValue(15), "writes"), PhysicalReads = SafeToInt64(reader.GetValue(16), "physical_reads"), ContextSwitches = SafeToInt64(reader.GetValue(17), "context_switches"), - UsedMemoryMb = SafeToDecimal(reader.GetValue(18), "used_memory"), - TempdbCurrentMb = SafeToDecimal(reader.GetValue(19), "tempdb_current"), - TempdbAllocations = SafeToDecimal(reader.GetValue(20), "tempdb_allocations"), + UsedMemoryMb = SafeToDecimal(reader.GetValue(18), "used_memory") / 128m, + /* sp_WhoIsActive tempdb counters are 8KB-page counts — /128 to MB (same fix as the memory columns, #1274) */ + TempdbCurrentMb = SafeToDecimal(reader.GetValue(19), "tempdb_current") / 128m, + TempdbAllocations = SafeToDecimal(reader.GetValue(20), "tempdb_allocations") / 128m, TranLogWrites = reader.IsDBNull(21) ? null : reader.GetValue(21)?.ToString(), OpenTranCount = SafeToInt16(reader.GetValue(22), "open_tran_count"), PercentComplete = SafeToDecimal(reader.GetValue(23), "percent_complete"), StartTime = reader.IsDBNull(24) ? null : reader.GetDateTime(24), TranStartTime = reader.IsDBNull(25) ? null : reader.GetDateTime(25), RequestId = SafeToInt16(reader.GetValue(26), "request_id"), - AdditionalInfo = reader.IsDBNull(27) ? null : reader.GetValue(27)?.ToString() + AdditionalInfo = reader.IsDBNull(27) ? null : reader.GetValue(27)?.ToString(), + RequestedMemoryMb = SafeToDecimal(reader.GetValue(28), "requested_memory") / 128m, + GrantedMemoryMb = SafeToDecimal(reader.GetValue(29), "granted_memory") / 128m, + MaxUsedMemoryMb = SafeToDecimal(reader.GetValue(30), "max_used_memory") / 128m, + Tasks = SafeToInt64(reader.GetValue(31), "tasks"), + PhysicalIo = SafeToInt64(reader.GetValue(32), "physical_io"), + IsolationLevel = reader.IsDBNull(33) ? null : reader.GetValue(33)?.ToString() }); } diff --git a/Dashboard/Services/DatabaseService.QueryPerformance.Stats.cs b/Dashboard/Services/DatabaseService.QueryPerformance.Stats.cs index bd75fa223..1472061e4 100644 --- a/Dashboard/Services/DatabaseService.QueryPerformance.Stats.cs +++ b/Dashboard/Services/DatabaseService.QueryPerformance.Stats.cs @@ -73,7 +73,18 @@ public async Task<List<QueryStatsItem>> GetQueryStatsAsync(int hoursBack = 24, D max_spills = MAX(qs.max_spills), query_plan_hash = MAX(qs.query_plan_hash), sql_handle = MAX(qs.sql_handle), - plan_handle = MAX(qs.plan_handle) + plan_handle = MAX(qs.plan_handle), + min_used_grant_kb = MIN(qs.min_used_grant_kb), + max_used_grant_kb = MAX(qs.max_used_grant_kb), + min_ideal_grant_kb = MIN(qs.min_ideal_grant_kb), + max_ideal_grant_kb = MAX(qs.max_ideal_grant_kb), + min_reserved_threads = MIN(qs.min_reserved_threads), + max_reserved_threads = MAX(qs.max_reserved_threads), + min_used_threads = MIN(qs.min_used_threads), + max_used_threads = MAX(qs.max_used_threads), + total_clr_time = MAX(qs.total_clr_time), + plan_generation_num = MAX(qs.plan_generation_num), + worker_time_per_second = MAX(qs.worker_time_per_second) INTO #per_lifetime FROM collect.query_stats AS qs WHERE ( @@ -143,7 +154,18 @@ ELSE QUOTENAME(MAX(pl.schema_name)) + N'.' + QUOTENAME(MAX(pl.object_name)) max_spills = MAX(pl.max_spills), query_plan_hash = CONVERT(nvarchar(20), MAX(pl.query_plan_hash), 1), sql_handle = CONVERT(nvarchar(130), MAX(pl.sql_handle), 1), - plan_handle = CONVERT(nvarchar(130), MAX(pl.plan_handle), 1) + plan_handle = CONVERT(nvarchar(130), MAX(pl.plan_handle), 1), + min_used_grant_kb = MIN(pl.min_used_grant_kb), + max_used_grant_kb = MAX(pl.max_used_grant_kb), + min_ideal_grant_kb = MIN(pl.min_ideal_grant_kb), + max_ideal_grant_kb = MAX(pl.max_ideal_grant_kb), + min_reserved_threads = MIN(pl.min_reserved_threads), + max_reserved_threads = MAX(pl.max_reserved_threads), + min_used_threads = MIN(pl.min_used_threads), + max_used_threads = MAX(pl.max_used_threads), + total_clr_time = SUM(pl.total_clr_time), + plan_generation_num = MAX(pl.plan_generation_num), + worker_time_per_second = MAX(pl.worker_time_per_second) INTO #top_ranked FROM #per_lifetime AS pl GROUP BY @@ -200,7 +222,18 @@ never displays it. */ query_plan_xml = CONVERT(nvarchar(max), NULL), tr.query_plan_hash, tr.sql_handle, - tr.plan_handle + tr.plan_handle, + tr.min_used_grant_kb, + tr.max_used_grant_kb, + tr.min_ideal_grant_kb, + tr.max_ideal_grant_kb, + tr.min_reserved_threads, + tr.max_reserved_threads, + tr.min_used_threads, + tr.max_used_threads, + tr.total_clr_time, + tr.plan_generation_num, + tr.worker_time_per_second FROM #top_ranked AS tr OUTER APPLY ( @@ -266,7 +299,18 @@ ORDER BY QueryPlanXml = reader.IsDBNull(35) ? null : reader.GetString(35), QueryPlanHash = reader.IsDBNull(36) ? null : reader.GetString(36), SqlHandle = reader.IsDBNull(37) ? null : reader.GetString(37), - PlanHandle = reader.IsDBNull(38) ? null : reader.GetString(38) + PlanHandle = reader.IsDBNull(38) ? null : reader.GetString(38), + MinUsedGrantKb = reader.IsDBNull(39) ? null : reader.GetInt64(39), + MaxUsedGrantKb = reader.IsDBNull(40) ? null : reader.GetInt64(40), + MinIdealGrantKb = reader.IsDBNull(41) ? null : reader.GetInt64(41), + MaxIdealGrantKb = reader.IsDBNull(42) ? null : reader.GetInt64(42), + MinReservedThreads = reader.IsDBNull(43) ? null : reader.GetInt64(43), + MaxReservedThreads = reader.IsDBNull(44) ? null : reader.GetInt64(44), + MinUsedThreads = reader.IsDBNull(45) ? null : reader.GetInt64(45), + MaxUsedThreads = reader.IsDBNull(46) ? null : reader.GetInt64(46), + TotalClrTime = reader.IsDBNull(47) ? 0 : reader.GetInt64(47), + PlanGenerationNum = reader.IsDBNull(48) ? null : reader.GetInt64(48), + WorkerTimePerSecond = reader.IsDBNull(49) ? null : Convert.ToDouble(reader.GetValue(49), CultureInfo.InvariantCulture) }); } @@ -570,10 +614,13 @@ SELECT TOP (50) force_failure_count = SUM(qsd.force_failure_count), last_force_failure_reason_desc = MAX(qsd.last_force_failure_reason_desc), plan_forcing_type = MAX(qsd.plan_forcing_type), + avg_clr_time_ms = SUM(qsd.avg_clr_time * qsd.count_executions) / 1000.0 / NULLIF(SUM(qsd.count_executions), 0), min_clr_time_ms = MIN(qsd.min_clr_time) / 1000.0, max_clr_time_ms = MAX(qsd.max_clr_time) / 1000.0, + avg_num_physical_io_reads = SUM(qsd.avg_num_physical_io_reads * qsd.count_executions) / NULLIF(SUM(qsd.count_executions), 0), min_num_physical_io_reads = MIN(qsd.min_num_physical_io_reads), max_num_physical_io_reads = MAX(qsd.max_num_physical_io_reads), + avg_log_bytes_used = SUM(qsd.avg_log_bytes_used * qsd.count_executions) / NULLIF(SUM(qsd.count_executions), 0), min_log_bytes_used = MIN(qsd.min_log_bytes_used), max_log_bytes_used = MAX(qsd.max_log_bytes_used) INTO #qs_top_ranked @@ -673,13 +720,16 @@ ORDER BY ForceFailureCount = reader.IsDBNull(38) ? null : reader.GetInt64(38), LastForceFailureReasonDesc = reader.IsDBNull(39) ? null : reader.GetString(39), PlanForcingType = reader.IsDBNull(40) ? null : reader.GetString(40), - MinClrTimeMs = reader.IsDBNull(41) ? null : Convert.ToDouble(reader.GetValue(41), CultureInfo.InvariantCulture), - MaxClrTimeMs = reader.IsDBNull(42) ? null : Convert.ToDouble(reader.GetValue(42), CultureInfo.InvariantCulture), - MinNumPhysicalIoReads = reader.IsDBNull(43) ? null : reader.GetInt64(43), - MaxNumPhysicalIoReads = reader.IsDBNull(44) ? null : reader.GetInt64(44), - MinLogBytesUsed = reader.IsDBNull(45) ? null : reader.GetInt64(45), - MaxLogBytesUsed = reader.IsDBNull(46) ? null : reader.GetInt64(46), - QuerySqlText = reader.IsDBNull(47) ? null : reader.GetString(47) + AvgClrTimeMs = reader.IsDBNull(41) ? null : Convert.ToDouble(reader.GetValue(41), CultureInfo.InvariantCulture), + MinClrTimeMs = reader.IsDBNull(42) ? null : Convert.ToDouble(reader.GetValue(42), CultureInfo.InvariantCulture), + MaxClrTimeMs = reader.IsDBNull(43) ? null : Convert.ToDouble(reader.GetValue(43), CultureInfo.InvariantCulture), + AvgNumPhysicalIoReads = reader.IsDBNull(44) ? null : reader.GetInt64(44), + MinNumPhysicalIoReads = reader.IsDBNull(45) ? null : reader.GetInt64(45), + MaxNumPhysicalIoReads = reader.IsDBNull(46) ? null : reader.GetInt64(46), + AvgLogBytesUsed = reader.IsDBNull(47) ? null : reader.GetInt64(47), + MinLogBytesUsed = reader.IsDBNull(48) ? null : reader.GetInt64(48), + MaxLogBytesUsed = reader.IsDBNull(49) ? null : reader.GetInt64(49), + QuerySqlText = reader.IsDBNull(50) ? null : reader.GetString(50) // QueryPlanXml is fetched on-demand via GetQueryStorePlanXmlAsync }); } diff --git a/Dashboard/Services/DatabaseService.ResourceMetrics.cs b/Dashboard/Services/DatabaseService.ResourceMetrics.cs index cf87f40c6..3dab33f5c 100644 --- a/Dashboard/Services/DatabaseService.ResourceMetrics.cs +++ b/Dashboard/Services/DatabaseService.ResourceMetrics.cs @@ -73,9 +73,9 @@ ELSE N'NORMAL' recommendation = CASE WHEN ts.version_store_reserved_page_count * 8 / 1024 > 1000 THEN N'Check for long-running transactions, snapshot isolation' - WHEN ts.unallocated_extent_page_count * 8 / 1024 < 100 THEN N'Consider increasing TempDB file sizes' + WHEN ts.unallocated_extent_page_count * 8 / 1024 < 100 THEN N'Consider increasing tempdb file sizes' WHEN ts.internal_object_reserved_page_count > ts.user_object_reserved_page_count * 2 THEN N'High internal usage - check sorts/hash ops' - ELSE N'TempDB usage is within normal range' + ELSE N'tempdb usage is within normal range' END FROM collect.tempdb_stats AS ts {dateFilter} diff --git a/Dashboard/Services/DatabaseService.cs b/Dashboard/Services/DatabaseService.cs index d7c2825da..b1f60154e 100644 --- a/Dashboard/Services/DatabaseService.cs +++ b/Dashboard/Services/DatabaseService.cs @@ -128,7 +128,6 @@ public async Task<bool> TestConnectionAsync() try { await using var tc = await OpenThrottledConnectionAsync(); - var connection = tc.Connection; return true; } catch (Exception ex) diff --git a/Dashboard/Services/Remediation/RemediationHandlerRegistry.cs b/Dashboard/Services/Remediation/RemediationHandlerRegistry.cs index 98033c569..7c736f362 100644 --- a/Dashboard/Services/Remediation/RemediationHandlerRegistry.cs +++ b/Dashboard/Services/Remediation/RemediationHandlerRegistry.cs @@ -14,8 +14,8 @@ namespace PerformanceMonitorDashboard.Services.Remediation /// <summary> /// Maps a finding's fact key to its remediation handler. A fact key with no /// registered handler yields no Apply affordance (mirrors - /// <c>FactRemediation.BuildAction</c> returning null). v1 registers exactly - /// one handler: <see cref="ForcePlanHandler"/> for PLAN_REGRESSION. + /// <c>FactRemediation.BuildAction</c> returning null). The handler set is + /// supplied at construction (see RemediationApplyService.CreateDefaultHandlers). /// </summary> public sealed class RemediationHandlerRegistry { diff --git a/Dashboard/Services/ServerManager.cs b/Dashboard/Services/ServerManager.cs index cbaed0672..955a32895 100644 --- a/Dashboard/Services/ServerManager.cs +++ b/Dashboard/Services/ServerManager.cs @@ -39,6 +39,28 @@ public class ServerManager : IServerManager /// </summary> private const int ConnectionCheckTimeoutSeconds = 5; + /// <summary> + /// Detection query for the connectivity check - UTC offset, engine edition and RDS flag from + /// scalar functions that need NO special permission. Deliberately carries NO + /// <c>FROM sys.dm_os_sys_info</c>: that DMV requires VIEW DATABASE STATE, which an Azure SQL + /// DB login often lacks, so coupling edition detection to it let a permission-starved Azure + /// DB throw the probe and mis-detect as on-prem instead of being correctly rejected as + /// unsupported (#1535). Columns: 0 utc_offset, 1 engine_edition, 2 is_aws_rds. + /// </summary> + internal const string DetectionQueryText = @" + SELECT + DATEDIFF(MINUTE, GETUTCDATE(), GETDATE()) AS utc_offset_minutes, + CONVERT(integer, SERVERPROPERTY('EngineEdition')) AS engine_edition, + CASE WHEN DB_ID('rdsadmin') IS NOT NULL THEN 1 ELSE 0 END AS is_aws_rds"; + + /// <summary> + /// Best-effort start-time read - <c>sqlserver_start_time</c> is the only probe fact that + /// needs <c>sys.dm_os_sys_info</c>. Run in its OWN try/catch so a permission failure leaves + /// ServerStartTime unset without flipping IsOnline. + /// </summary> + internal const string ServerStartTimeQueryText = + "SELECT sqlserver_start_time FROM sys.dm_os_sys_info"; + public ServerManager() { _configFilePath = ResolveSharedServersJsonPath(); @@ -153,9 +175,12 @@ public void AddServer(ServerConnection server, string? username = null, string? SaveServersInternal(); } - if (server.AuthenticationType == AuthenticationTypes.SqlServer && !string.IsNullOrEmpty(username) && password != null) + if ((server.AuthenticationType == AuthenticationTypes.SqlServer || + server.AuthenticationType == AuthenticationTypes.ServicePrincipal) && + !string.IsNullOrEmpty(username) && password != null) { - // For SQL Server auth, save both username and password + // SQL Server auth (username + password) and Service Principal (client id + client secret) + // both persist a secret in Windows Credential Manager. if (!_credentialService.SaveCredential(server.Id, username, password)) { throw new InvalidOperationException("Failed to save credentials to Windows Credential Manager"); @@ -169,6 +194,7 @@ public void AddServer(ServerConnection server, string? username = null, string? throw new InvalidOperationException("Failed to save username to Windows Credential Manager"); } } + // ManagedIdentity stores no credential. // Initialize status as unknown for new server _connectionStatuses[server.Id] = new ServerConnectionStatus { ServerId = server.Id }; @@ -189,25 +215,40 @@ public void UpdateServer(ServerConnection server, string? username = null, strin SaveServersInternal(); } - if (server.AuthenticationType == AuthenticationTypes.SqlServer && !string.IsNullOrEmpty(username) && password != null) + if ((server.AuthenticationType == AuthenticationTypes.SqlServer || + server.AuthenticationType == AuthenticationTypes.ServicePrincipal) && + !string.IsNullOrEmpty(username) && password != null) { - // For SQL Server auth, update both username and password + // SQL Server auth (username + password) and Service Principal (client id + client secret) + // both persist a secret in Windows Credential Manager. if (!_credentialService.UpdateCredential(server.Id, username, password)) { throw new InvalidOperationException("Failed to update credentials in Windows Credential Manager"); } } - else if (server.AuthenticationType == AuthenticationTypes.EntraMFA && !string.IsNullOrEmpty(username)) + else if (server.AuthenticationType == AuthenticationTypes.EntraMFA) { - // For MFA auth, update username hint only (no password needed) - if (!_credentialService.UpdateCredential(server.Id, username, string.Empty)) + if (!string.IsNullOrEmpty(username)) + { + // For MFA auth, update the username hint only (no password needed). + if (!_credentialService.UpdateCredential(server.Id, username, string.Empty)) + { + throw new InvalidOperationException("Failed to update username in Windows Credential Manager"); + } + } + else { - throw new InvalidOperationException("Failed to update username in Windows Credential Manager"); + // No username hint to store. Remove any credential left over from a previous + // secret-bearing mode (SqlServer/ServicePrincipal) so the old secret can't linger + // orphaned when a server is switched to MFA with a blank username. + _credentialService.DeleteCredential(server.Id); } } - else if (server.AuthenticationType == AuthenticationTypes.Windows) + else if (server.AuthenticationType == AuthenticationTypes.Windows || + server.AuthenticationType == AuthenticationTypes.ManagedIdentity) { - // For Windows auth, remove any stored credentials + // Windows and Managed Identity use no stored secret — remove any credential left over + // from a previous auth mode so it can't linger orphaned in Credential Manager. _credentialService.DeleteCredential(server.Id); } } @@ -253,15 +294,35 @@ IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Hu jobCmd.CommandTimeout = 30; await jobCmd.ExecuteNonQueryAsync(); - // Close active connections before dropping + // Close active connections before dropping. + // EngineEdition 8 = Azure SQL Managed Instance, where SINGLE_USER is non-modifiable (the batch + // would die on that line); gate it to box SQL Server and let MI DROP directly. Mirrors + // InstallationService.CleanInstallAsync. using var killCmd = new SqlCommand(@" IF DB_ID('PerformanceMonitor') IS NOT NULL BEGIN - ALTER DATABASE [PerformanceMonitor] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + IF SERVERPROPERTY('EngineEdition') <> 8 + ALTER DATABASE [PerformanceMonitor] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [PerformanceMonitor]; END", connection); killCmd.CommandTimeout = 30; - await killCmd.ExecuteNonQueryAsync(); + try + { + await killCmd.ExecuteNonQueryAsync(); + } + catch + { + // A DROP that fails or times out after SINGLE_USER committed strands the database + // semi-bricked. Un-brick best-effort on a fresh connection, then re-raise the original error. + connection.Close(); + bool restored = await InstallationService.TryRestoreDatabaseAccessAsync(builder.ConnectionString); + if (!restored) + { + Logger.Warning($"Drop failed on '{server.DisplayName}' and the database may be left in " + + "SINGLE_USER mode; re-run the clean install to recover, or ALTER DATABASE ... SET MULTI_USER manually."); + } + throw; + } Logger.Info($"Dropped PerformanceMonitor database and Agent jobs on '{server.DisplayName}'"); } @@ -546,38 +607,55 @@ public async Task<ServerConnectionStatus> CheckConnectionAsync(string serverId, using var connection = new SqlConnection(builder.ConnectionString); await connection.OpenAsync(); - // Query server start time and UTC offset to verify connectivity - using var command = new SqlCommand(@" - SELECT - sqlserver_start_time, - DATEDIFF(MINUTE, GETUTCDATE(), GETDATE()) AS utc_offset_minutes, - CONVERT(integer, SERVERPROPERTY('EngineEdition')) AS engine_edition, - CASE WHEN DB_ID('rdsadmin') IS NOT NULL THEN 1 ELSE 0 END AS is_aws_rds - FROM sys.dm_os_sys_info", connection); - command.CommandTimeout = ConnectionCheckTimeoutSeconds; - - using var reader = await command.ExecuteReaderAsync(); - status.IsOnline = true; - status.ErrorMessage = null; - if (await reader.ReadAsync()) + // Detection query (UTC offset, engine edition, RDS) - permission-free scalars, NO + // sys.dm_os_sys_info, so a permission-starved Azure SQL DB login still returns + // EngineEdition 5 and is correctly rejected below instead of mis-detected as + // on-prem (#1535). + // Scope the detection command + reader in their own block so BOTH are disposed before the + // best-effort start-time read below opens a second reader on this connection - Dashboard + // connection strings do not enable MARS, so an overlapping reader would throw (#1535). + using (var command = new SqlCommand(DetectionQueryText, connection)) { - if (!reader.IsDBNull(0)) - { - status.ServerStartTime = reader.GetDateTime(0); - } - if (!reader.IsDBNull(1)) - { - status.UtcOffsetMinutes = Convert.ToInt32(reader.GetValue(1)); - } - if (!reader.IsDBNull(2)) + command.CommandTimeout = ConnectionCheckTimeoutSeconds; + + using var reader = await command.ExecuteReaderAsync(); + status.IsOnline = true; + status.ErrorMessage = null; + if (await reader.ReadAsync()) { - status.SqlEngineEdition = Convert.ToInt32(reader.GetValue(2)); + if (!reader.IsDBNull(0)) + { + status.UtcOffsetMinutes = Convert.ToInt32(reader.GetValue(0)); + } + if (!reader.IsDBNull(1)) + { + status.SqlEngineEdition = Convert.ToInt32(reader.GetValue(1)); + } + if (!reader.IsDBNull(2)) + { + status.IsAwsRds = Convert.ToInt32(reader.GetValue(2)) == 1; + } } - if (!reader.IsDBNull(3)) + } + + // Best-effort start-time read (its OWN try/catch): sqlserver_start_time is the only + // fact that needs sys.dm_os_sys_info, so a permission gap here leaves ServerStartTime + // unset without flipping IsOnline or failing the detection above (#1535). + try + { + using var startCommand = new SqlCommand(ServerStartTimeQueryText, connection); + startCommand.CommandTimeout = ConnectionCheckTimeoutSeconds; + + using var startReader = await startCommand.ExecuteReaderAsync(); + if (await startReader.ReadAsync() && !startReader.IsDBNull(0)) { - status.IsAwsRds = Convert.ToInt32(reader.GetValue(3)) == 1; + status.ServerStartTime = startReader.GetDateTime(0); } } + catch (SqlException startEx) + { + Logger.Info($"Server '{server.DisplayName}' start time unavailable (server is still online): {startEx.Message}"); + } /* Azure SQL DB not supported for Full Dashboard (no full system catalog access) Engine Edition 5 = Azure SQL Database, 8 = Azure SQL Managed Instance (supported) */ diff --git a/Dashboard/Services/UserPreferencesAlertSettings.cs b/Dashboard/Services/UserPreferencesAlertSettings.cs index 6e5776c86..68f619883 100644 --- a/Dashboard/Services/UserPreferencesAlertSettings.cs +++ b/Dashboard/Services/UserPreferencesAlertSettings.cs @@ -54,6 +54,14 @@ public UserPreferencesAlertSettings(UserPreferences prefs) public string SlackWebhookUrl => DashboardAlertCredentials.GetSlackWebhookUrl(); public string SlackProxyAddress => _prefs.SlackProxyAddress; + /* Off for the Dashboard — see DashboardAlertSettings: the generic channel (#1506) is a Lite/Darling + feature, and the deprecated Dashboard grows no settings surface to author it from. */ + public bool GenericWebhookEnabled => false; + public string GenericWebhookUrl => ""; + public string GenericWebhookHeadersJson => ""; + public string GenericWebhookBodyTemplate => ""; + public string GenericWebhookProxyAddress => ""; + public double AnalysisNotifySeverity => Math.Clamp(_prefs.AnalysisNotifySeverity, 0.0, 2.0); public int AnalysisNotifyCooldownMinutes => Math.Clamp(_prefs.AnalysisNotifyCooldownMinutes, 30, 10080); } diff --git a/Dashboard/SettingsWindow.xaml b/Dashboard/SettingsWindow.xaml index 5ef7811c6..93d367958 100644 --- a/Dashboard/SettingsWindow.xaml +++ b/Dashboard/SettingsWindow.xaml @@ -252,7 +252,7 @@ Margin="0,0,0,0"/> <StackPanel Orientation="Horizontal" Margin="0,8,0,0"> <CheckBox x:Name="NotifyOnTempDbSpaceCheckBox" - Content="TempDB space usage over" + Content="tempdb space usage over" VerticalAlignment="Center" Checked="NotifyOnTempDbSpaceCheckBox_Changed" Unchecked="NotifyOnTempDbSpaceCheckBox_Changed"/> @@ -571,7 +571,7 @@ <Run Foreground="#DC2626" FontWeight="Bold" Text="■ Red" /> — Critical (Deadlocks, Poison Waits, Server Unreachable) </TextBlock> <TextBlock TextWrapping="Wrap" FontSize="11" Foreground="Gray" Margin="0,0,0,2"> - <Run Foreground="#D97706" FontWeight="Bold" Text="■ Orange" /> — Alert / Warning (Blocking, Long-Running Queries, TempDB, Jobs) + <Run Foreground="#D97706" FontWeight="Bold" Text="■ Orange" /> — Alert / Warning (Blocking, Long-Running Queries, tempdb, Jobs) </TextBlock> <TextBlock TextWrapping="Wrap" FontSize="11" Foreground="Gray" Margin="0,0,0,2"> <Run Foreground="#F59E0B" FontWeight="Bold" Text="■ Yellow" /> — Warning (High CPU) diff --git a/Dashboard/SettingsWindow.xaml.cs b/Dashboard/SettingsWindow.xaml.cs index 3b422201d..31583fe49 100644 --- a/Dashboard/SettingsWindow.xaml.cs +++ b/Dashboard/SettingsWindow.xaml.cs @@ -429,7 +429,7 @@ private void UpdateAlertPreviewText() if (NotifyOnLongRunningQueriesCheckBox.IsChecked == true) parts.Add($"queries > {LongRunningQueryThresholdTextBox.Text}min"); if (NotifyOnTempDbSpaceCheckBox.IsChecked == true) - parts.Add($"TempDB > {TempDbSpaceThresholdTextBox.Text}%"); + parts.Add($"tempdb > {TempDbSpaceThresholdTextBox.Text}%"); if (NotifyOnLowDiskCheckBox.IsChecked == true) parts.Add($"disk free < {LowDiskThresholdPercentTextBox.Text}% or {LowDiskThresholdGbTextBox.Text}GB"); if (NotifyOnLongRunningJobsCheckBox.IsChecked == true) @@ -738,7 +738,7 @@ private async void OkButton_Click(object sender, RoutedEventArgs e) if (int.TryParse(TempDbSpaceThresholdTextBox.Text, out int tempDbThreshold) && tempDbThreshold > 0 && tempDbThreshold <= 100) prefs.TempDbSpaceThresholdPercent = tempDbThreshold; else if (prefs.NotifyOnTempDbSpace) - validationErrors.Add("TempDB space threshold must be between 1 and 100"); + validationErrors.Add("tempdb space threshold must be between 1 and 100"); prefs.NotifyOnLowDisk = NotifyOnLowDiskCheckBox.IsChecked == true; if (int.TryParse(LowDiskThresholdPercentTextBox.Text, out int lowDiskPct) && lowDiskPct >= 0 && lowDiskPct <= 100) diff --git a/Dashboard/Themes/CoolBreezeTheme.xaml b/Dashboard/Themes/CoolBreezeTheme.xaml index 0ac8d376d..b3a60ce97 100644 --- a/Dashboard/Themes/CoolBreezeTheme.xaml +++ b/Dashboard/Themes/CoolBreezeTheme.xaml @@ -23,7 +23,7 @@ <!-- Text Colors (dark on light backgrounds) --> <Color x:Key="ForegroundColor">#1A2A3A</Color> <Color x:Key="ForegroundDimColor">#1A2A3A</Color> - <Color x:Key="ForegroundMutedColor">#5B7A90</Color> + <Color x:Key="ForegroundMutedColor">#4A5D72</Color> <!-- Border Colors (subtle, low-contrast) --> <Color x:Key="BorderColor">#A8BDD0</Color> @@ -60,7 +60,7 @@ <SolidColorBrush x:Key="ForegroundBrush" Color="{StaticResource ForegroundColor}"/> <SolidColorBrush x:Key="ForegroundDimBrush" Color="{StaticResource ForegroundDimColor}"/> - <SolidColorBrush x:Key="ForegroundMutedBrush" Color="{StaticResource ForegroundColor}"/> + <SolidColorBrush x:Key="ForegroundMutedBrush" Color="{StaticResource ForegroundMutedColor}"/> <SolidColorBrush x:Key="BorderBrush" Color="{StaticResource BorderColor}"/> <SolidColorBrush x:Key="BorderLightBrush" Color="{StaticResource BorderLightColor}"/> @@ -719,6 +719,7 @@ <!-- ============================================ --> <Style TargetType="DataGrid"> <Setter Property="helpers:DataGridClipboardBehavior.FixHeaderCopy" Value="True"/> + <Setter Property="helpers:DataGridSortBehavior.SortDescendingFirst" Value="True"/> <Setter Property="helpers:ScrollPanBehavior.EnableMiddleClickPanning" Value="True"/> <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> @@ -1332,16 +1333,16 @@ <Style TargetType="ToolTip" BasedOn="{StaticResource DarkToolTip}"/> <!-- Time Range Slicer --> - <SolidColorBrush x:Key="SlicerBackgroundBrush" Color="#DAE6F0"/> + <SolidColorBrush x:Key="SlicerBackgroundBrush" Color="{StaticResource BackgroundDarkColor}"/> <SolidColorBrush x:Key="SlicerChartLineBrush" Color="#1E6FA8"/> <SolidColorBrush x:Key="SlicerChartFillBrush" Color="#331E6FA8"/> - <SolidColorBrush x:Key="SlicerOverlayBrush" Color="#55FFFFFF"/> - <SolidColorBrush x:Key="SlicerSelectedBrush" Color="#11000000"/> - <SolidColorBrush x:Key="SlicerHandleBrush" Color="#364D61"/> + <SolidColorBrush x:Key="SlicerOverlayBrush" Color="#40000000"/> + <SolidColorBrush x:Key="SlicerSelectedBrush" Color="#221E6FA8"/> + <SolidColorBrush x:Key="SlicerHandleBrush" Color="{StaticResource ForegroundColor}"/> <SolidColorBrush x:Key="SlicerHandleHoverBrush" Color="#1E6FA8"/> - <SolidColorBrush x:Key="SlicerBorderBrush" Color="#B0C4D8"/> - <SolidColorBrush x:Key="SlicerLabelBrush" Color="#5A7A94"/> - <SolidColorBrush x:Key="SlicerToggleBrush" Color="#364D61"/> + <SolidColorBrush x:Key="SlicerBorderBrush" Color="{StaticResource BorderColor}"/> + <SolidColorBrush x:Key="SlicerLabelBrush" Color="{StaticResource ForegroundColor}"/> + <SolidColorBrush x:Key="SlicerToggleBrush" Color="{StaticResource ForegroundColor}"/> <!-- ============================================ --> <!-- Plan Viewer Resources --> diff --git a/Dashboard/Themes/DarkTheme.xaml b/Dashboard/Themes/DarkTheme.xaml index 469fe3a02..94cdc6151 100644 --- a/Dashboard/Themes/DarkTheme.xaml +++ b/Dashboard/Themes/DarkTheme.xaml @@ -22,8 +22,8 @@ <!-- Text Colors (off-white to reduce eye strain) --> <Color x:Key="ForegroundColor">#E4E6EB</Color> - <Color x:Key="ForegroundDimColor">#E4E6EB</Color> - <Color x:Key="ForegroundMutedColor">#6B7280</Color> + <Color x:Key="ForegroundDimColor">#C7CBD4</Color> + <Color x:Key="ForegroundMutedColor">#A8AEBA</Color> <!-- Border Colors (subtle, low-contrast) --> <Color x:Key="BorderColor">#2a2d35</Color> @@ -60,7 +60,7 @@ <SolidColorBrush x:Key="ForegroundBrush" Color="{StaticResource ForegroundColor}"/> <SolidColorBrush x:Key="ForegroundDimBrush" Color="{StaticResource ForegroundDimColor}"/> - <SolidColorBrush x:Key="ForegroundMutedBrush" Color="{StaticResource ForegroundColor}"/> + <SolidColorBrush x:Key="ForegroundMutedBrush" Color="{StaticResource ForegroundMutedColor}"/> <SolidColorBrush x:Key="BorderBrush" Color="{StaticResource BorderColor}"/> <SolidColorBrush x:Key="BorderLightBrush" Color="{StaticResource BorderLightColor}"/> @@ -718,6 +718,7 @@ <!-- ============================================ --> <Style TargetType="DataGrid"> <Setter Property="helpers:DataGridClipboardBehavior.FixHeaderCopy" Value="True"/> + <Setter Property="helpers:DataGridSortBehavior.SortDescendingFirst" Value="True"/> <Setter Property="helpers:ScrollPanBehavior.EnableMiddleClickPanning" Value="True"/> <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> diff --git a/Dashboard/Themes/LightTheme.xaml b/Dashboard/Themes/LightTheme.xaml index de499f223..e73f94409 100644 --- a/Dashboard/Themes/LightTheme.xaml +++ b/Dashboard/Themes/LightTheme.xaml @@ -23,7 +23,7 @@ <!-- Text Colors (dark on light backgrounds) --> <Color x:Key="ForegroundColor">#1A1D23</Color> <Color x:Key="ForegroundDimColor">#1A1D23</Color> - <Color x:Key="ForegroundMutedColor">#718096</Color> + <Color x:Key="ForegroundMutedColor">#5A6472</Color> <!-- Border Colors (subtle, low-contrast) --> <Color x:Key="BorderColor">#DEE2E6</Color> @@ -60,7 +60,7 @@ <SolidColorBrush x:Key="ForegroundBrush" Color="{StaticResource ForegroundColor}"/> <SolidColorBrush x:Key="ForegroundDimBrush" Color="{StaticResource ForegroundDimColor}"/> - <SolidColorBrush x:Key="ForegroundMutedBrush" Color="{StaticResource ForegroundColor}"/> + <SolidColorBrush x:Key="ForegroundMutedBrush" Color="{StaticResource ForegroundMutedColor}"/> <SolidColorBrush x:Key="BorderBrush" Color="{StaticResource BorderColor}"/> <SolidColorBrush x:Key="BorderLightBrush" Color="{StaticResource BorderLightColor}"/> @@ -719,6 +719,7 @@ <!-- ============================================ --> <Style TargetType="DataGrid"> <Setter Property="helpers:DataGridClipboardBehavior.FixHeaderCopy" Value="True"/> + <Setter Property="helpers:DataGridSortBehavior.SortDescendingFirst" Value="True"/> <Setter Property="helpers:ScrollPanBehavior.EnableMiddleClickPanning" Value="True"/> <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> @@ -1333,15 +1334,15 @@ <!-- Time Range Slicer --> <SolidColorBrush x:Key="SlicerBackgroundBrush" Color="#F0F2F5"/> - <SolidColorBrush x:Key="SlicerChartLineBrush" Color="#2196F3"/> - <SolidColorBrush x:Key="SlicerChartFillBrush" Color="#332196F3"/> - <SolidColorBrush x:Key="SlicerOverlayBrush" Color="#55FFFFFF"/> - <SolidColorBrush x:Key="SlicerSelectedBrush" Color="#11000000"/> - <SolidColorBrush x:Key="SlicerHandleBrush" Color="#4A5568"/> - <SolidColorBrush x:Key="SlicerHandleHoverBrush" Color="#2196F3"/> - <SolidColorBrush x:Key="SlicerBorderBrush" Color="#CBD5E0"/> - <SolidColorBrush x:Key="SlicerLabelBrush" Color="#718096"/> - <SolidColorBrush x:Key="SlicerToggleBrush" Color="#4A5568"/> + <SolidColorBrush x:Key="SlicerChartLineBrush" Color="{StaticResource AccentColor}"/> + <SolidColorBrush x:Key="SlicerChartFillBrush" Color="#332EAEF1"/> + <SolidColorBrush x:Key="SlicerOverlayBrush" Color="#40000000"/> + <SolidColorBrush x:Key="SlicerSelectedBrush" Color="#222EAEF1"/> + <SolidColorBrush x:Key="SlicerHandleBrush" Color="{StaticResource ForegroundColor}"/> + <SolidColorBrush x:Key="SlicerHandleHoverBrush" Color="{StaticResource AccentColor}"/> + <SolidColorBrush x:Key="SlicerBorderBrush" Color="{StaticResource BorderColor}"/> + <SolidColorBrush x:Key="SlicerLabelBrush" Color="{StaticResource ForegroundColor}"/> + <SolidColorBrush x:Key="SlicerToggleBrush" Color="{StaticResource ForegroundColor}"/> <!-- ============================================ --> <!-- Plan Viewer Resources --> diff --git a/Dashboard/TracePatternHistoryWindow.xaml b/Dashboard/TracePatternHistoryWindow.xaml index fa076e56d..3d2dc5610 100644 --- a/Dashboard/TracePatternHistoryWindow.xaml +++ b/Dashboard/TracePatternHistoryWindow.xaml @@ -83,134 +83,23 @@ ContextMenu="{DynamicResource DataGridContextMenu}" Margin="10"> <DataGrid.Columns> - <DataGridTextColumn Binding="{Binding EndTime, Converter={StaticResource ServerTimeConverter}}" Width="150"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EndTime" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="End Time" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding CollectionTime, Converter={StaticResource ServerTimeConverter}}" Width="150"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CollectionTime" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Collection Time" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding StartTime, Converter={StaticResource ServerTimeConverter}}" Width="150"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="StartTime" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Start Time" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding EventName}" Width="120"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EventName" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Event" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding DatabaseName}" Width="120"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DatabaseName" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Database" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding DurationMs, StringFormat='{}{0:N0}'}" ElementStyle="{StaticResource NumericCell}" Width="100"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DurationMs" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Duration (ms)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding CpuMs, StringFormat='{}{0:N0}'}" ElementStyle="{StaticResource NumericCell}" Width="90"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CpuMs" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding Reads, StringFormat='{}{0:N0}'}" ElementStyle="{StaticResource NumericCell}" Width="100"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Reads" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Reads (pages)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding Writes, StringFormat='{}{0:N0}'}" ElementStyle="{StaticResource NumericCell}" Width="100"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Writes" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Writes (pages)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding RowCounts, StringFormat='{}{0:N0}'}" ElementStyle="{StaticResource NumericCell}" Width="100"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="RowCounts" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Row Counts" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding LoginName}" Width="120"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LoginName" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Login" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding NtUserName}" Width="120"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="NtUserName" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="NT User" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding ApplicationName}" Width="150"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ApplicationName" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Application" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding HostName}" Width="120"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="HostName" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Host" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding Spid}" ElementStyle="{StaticResource NumericCell}" Width="60"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Spid" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="SPID" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding ObjectId}" ElementStyle="{StaticResource NumericCell}" Width="90"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ObjectId" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Object ID" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="End Time" Binding="{Binding EndTime, Converter={StaticResource ServerTimeConverter}}" Width="150"/> + <DataGridTextColumn Header="Collection Time" Binding="{Binding CollectionTime, Converter={StaticResource ServerTimeConverter}}" Width="150"/> + <DataGridTextColumn Header="Start Time" Binding="{Binding StartTime, Converter={StaticResource ServerTimeConverter}}" Width="150"/> + <DataGridTextColumn Header="Event" Binding="{Binding EventName}" Width="120"/> + <DataGridTextColumn Header="Database" Binding="{Binding DatabaseName}" Width="120"/> + <DataGridTextColumn Header="Duration (ms)" Binding="{Binding DurationMs, StringFormat='{}{0:N0}'}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="CPU (ms)" Binding="{Binding CpuMs, StringFormat='{}{0:N0}'}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Reads (pages)" Binding="{Binding Reads, StringFormat='{}{0:N0}'}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Writes (pages)" Binding="{Binding Writes, StringFormat='{}{0:N0}'}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Row Counts" Binding="{Binding RowCounts, StringFormat='{}{0:N0}'}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Login" Binding="{Binding LoginName}" Width="120"/> + <DataGridTextColumn Header="NT User" Binding="{Binding NtUserName}" Width="120"/> + <DataGridTextColumn Header="Application" Binding="{Binding ApplicationName}" Width="150"/> + <DataGridTextColumn Header="Host" Binding="{Binding HostName}" Width="120"/> + <DataGridTextColumn Header="SPID" Binding="{Binding Spid}" ElementStyle="{StaticResource NumericCell}" Width="60"/> + <DataGridTextColumn Header="Client PID" Binding="{Binding ClientProcessId}" ElementStyle="{StaticResource NumericCell}" Width="80"/> + <DataGridTextColumn Header="Object ID" Binding="{Binding ObjectId}" ElementStyle="{StaticResource NumericCell}" Width="90"/> <DataGridTemplateColumn Width="*" MinWidth="300"> <DataGridTemplateColumn.Header> <StackPanel Orientation="Horizontal"> diff --git a/Dashboard/TracePatternHistoryWindow.xaml.cs b/Dashboard/TracePatternHistoryWindow.xaml.cs index 20d6cf22c..41951939e 100644 --- a/Dashboard/TracePatternHistoryWindow.xaml.cs +++ b/Dashboard/TracePatternHistoryWindow.xaml.cs @@ -22,6 +22,7 @@ using PerformanceMonitorDashboard.Services; using ScottPlot; using PerformanceMonitor.Ui; +using PerformanceMonitor.PlanAnalysis; using PerformanceMonitor.Common; namespace PerformanceMonitorDashboard @@ -39,8 +40,7 @@ public partial class TracePatternHistoryWindow : Window private ChartHoverHelper? _chartHover; // Filter state - private Dictionary<string, ColumnFilterState> _filters = new(); - private List<TracePatternDetailItem>? _unfilteredData; + private readonly DataGridFilterManager<TracePatternDetailItem> _filterManager; private Popup? _filterPopup; private ColumnFilterPopup? _filterPopupContent; @@ -74,6 +74,10 @@ public TracePatternHistoryWindow( displayPattern = displayPattern.Substring(0, 120) + "..."; QueryIdentifierText.Text = $"Trace Pattern History: [{databaseName}] — {displayPattern}"; + _filterManager = new DataGridFilterManager<TracePatternDetailItem>(HistoryDataGrid); + DataGridFilterColumns.AddFilterButtons(HistoryDataGrid, Filter_Click); + _filterManager.UpdateFilterButtonStyles(); + ApplyThemeToChart(); Loaded += TracePatternHistoryWindow_Loaded; ThemeManager.ThemeChanged += OnThemeChanged; @@ -114,10 +118,7 @@ private async Task LoadHistoryAsync() { _historyData = await _databaseService.GetTracePatternHistoryAsync(_databaseName, _queryPattern, _hoursBack, _fromDate, _toDate); - _unfilteredData = _historyData; - _filters.Clear(); - HistoryDataGrid.ItemsSource = _historyData; - UpdateFilterButtonStyles(); + _filterManager.UpdateData(_historyData); if (_historyData.Count > 0) { @@ -238,7 +239,7 @@ private void Filter_Click(object sender, RoutedEventArgs e) }; } - _filters.TryGetValue(columnName, out var existingFilter); + _filterManager.Filters.TryGetValue(columnName, out var existingFilter); _filterPopupContent!.Initialize(columnName, existingFilter); _filterPopup.PlacementTarget = button; @@ -248,14 +249,7 @@ private void Filter_Click(object sender, RoutedEventArgs e) private void FilterPopup_FilterApplied(object? sender, FilterAppliedEventArgs e) { if (_filterPopup != null) _filterPopup.IsOpen = false; - - if (e.FilterState.IsActive) - _filters[e.FilterState.ColumnName] = e.FilterState; - else - _filters.Remove(e.FilterState.ColumnName); - - ApplyFilters(); - UpdateFilterButtonStyles(); + _filterManager.SetFilter(e.FilterState); } private void FilterPopup_FilterCleared(object? sender, EventArgs e) @@ -263,147 +257,18 @@ private void FilterPopup_FilterCleared(object? sender, EventArgs e) if (_filterPopup != null) _filterPopup.IsOpen = false; } - private void ApplyFilters() - { - if (_unfilteredData == null) return; - - if (_filters.Count == 0) - { - HistoryDataGrid.ItemsSource = _unfilteredData; - return; - } - - var filtered = _unfilteredData.Where(item => - { - foreach (var filter in _filters.Values) - { - if (filter.IsActive && !DataGridFilterService.MatchesFilter(item, filter)) - return false; - } - return true; - }).ToList(); - - HistoryDataGrid.ItemsSource = filtered; - } - - private void UpdateFilterButtonStyles() - { - foreach (var column in HistoryDataGrid.Columns) - { - if (column.Header is StackPanel stackPanel) - { - var filterButton = stackPanel.Children.OfType<Button>().FirstOrDefault(); - if (filterButton?.Tag is string columnName) - { - bool hasActive = _filters.TryGetValue(columnName, out var filter) && filter.IsActive; - filterButton.Content = new System.Windows.Controls.TextBlock - { - Text = "\uE71C", - FontFamily = new System.Windows.Media.FontFamily("Segoe MDL2 Assets"), - Foreground = hasActive - ? new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0xFF, 0xD7, 0x00)) - : new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0xFF, 0xFF, 0xFF)) - }; - filterButton.ToolTip = hasActive && filter != null - ? $"Filter: {filter.DisplayText}\n(Click to modify)" - : "Click to filter"; - } - } - } - } - #endregion #region Context Menu Handlers - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.CurrentCell.Item != null) - { - var cellContent = TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - Clipboard.SetDataObject(cellContent, false); - } - } - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid?.SelectedItem != null) - Clipboard.SetDataObject(TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem), false); - } - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new StringBuilder(); - var headers = new List<string>(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - headers.Add(DataGridClipboardBehavior.GetHeaderText(column)); - } - sb.AppendLine(string.Join("\t", headers)); - foreach (var item in dataGrid.Items) - sb.AppendLine(TabHelpers.GetRowAsText(dataGrid, item)); - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var saveFileDialog = new SaveFileDialog - { - FileName = $"trace_pattern_history_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; - - if (saveFileDialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); - var headers = new List<string>(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - headers.Add(TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(column))); - } - sb.AppendLine(string.Join(",", headers)); - foreach (var item in dataGrid.Items) - { - var values = TabHelpers.GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(",", values.Select(v => TabHelpers.EscapeCsvField(v)))); - } - File.WriteAllText(saveFileDialog.FileName, sb.ToString()); - MessageBox.Show($"Data exported successfully to:\n{saveFileDialog.FileName}", "Export Complete", MessageBoxButton.OK, MessageBoxImage.Information); - } - catch (Exception ex) - { - MessageBox.Show($"Error exporting data:\n\n{ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "trace_pattern_history", TabHelpers.CsvSeparator); [System.Text.RegularExpressions.GeneratedRegex(@"\s+")] private static partial System.Text.RegularExpressions.Regex MultipleSpacesRegExp(); diff --git a/Dashboard/WaitDrillDownWindow.xaml b/Dashboard/WaitDrillDownWindow.xaml index e5604b7fa..f306f3370 100644 --- a/Dashboard/WaitDrillDownWindow.xaml +++ b/Dashboard/WaitDrillDownWindow.xaml @@ -71,206 +71,37 @@ RowStyle="{DynamicResource DefaultRowStyle}" ContextMenu="{StaticResource DataGridContextMenu}"> <DataGrid.Columns> - <DataGridTextColumn Binding="{Binding CollectionTime, StringFormat='MM/dd HH:mm:ss'}" Width="140"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CollectionTime" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Collected" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding SessionId}" ElementStyle="{StaticResource NumericCell}" Width="60"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SessionId" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="SPID" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding Duration}" Width="100" SortMemberPath="DurationSort"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Duration" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Duration" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding Status}" Width="80"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Status" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Status" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding WaitInfo}" Width="180"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="WaitInfo" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Wait Info" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding BlockingSessionId}" ElementStyle="{StaticResource NumericCell}" Width="80"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BlockingSessionId" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Blocking" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding BlockedSessionCount}" ElementStyle="{StaticResource NumericCell}" Width="80"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BlockedSessionCount" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Blocked" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding Cpu}" ElementStyle="{StaticResource NumericCell}" Width="80"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Cpu" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding Reads, StringFormat='{}{0:N0}'}" ElementStyle="{StaticResource NumericCell}" Width="100"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Reads" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Reads (pages)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding Writes, StringFormat='{}{0:N0}'}" ElementStyle="{StaticResource NumericCell}" Width="100"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Writes" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Writes (pages)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding PhysicalReads}" ElementStyle="{StaticResource NumericCell}" Width="100"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PhysicalReads" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Physical Reads" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding ContextSwitches}" ElementStyle="{StaticResource NumericCell}" Width="100"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ContextSwitches" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Ctx Switches" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding UsedMemoryMb, StringFormat='{}{0:N2}'}" ElementStyle="{StaticResource NumericCell}" Width="100"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="UsedMemoryMb" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Used Mem (MB)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding TempdbCurrentMb, StringFormat='{}{0:N2}'}" ElementStyle="{StaticResource NumericCell}" Width="110"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TempdbCurrentMb" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="TempDB Cur (MB)" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding TempdbAllocations, StringFormat='{}{0:N2}'}" ElementStyle="{StaticResource NumericCell}" Width="110"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TempdbAllocations" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="TempDB Alloc" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding TranLogWrites}" ElementStyle="{StaticResource NumericCell}" Width="100" SortMemberPath="TranLogWritesSort"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TranLogWrites" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Tran Log" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding OpenTranCount}" ElementStyle="{StaticResource NumericCell}" Width="80"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="OpenTranCount" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Open Tran" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding PercentComplete, StringFormat='{}{0:N1}%'}" ElementStyle="{StaticResource NumericCell}" Width="80"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PercentComplete" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="% Complete" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding StartTime, StringFormat='MM/dd HH:mm:ss'}" Width="140"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="StartTime" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Start Time" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding TranStartTime, StringFormat='MM/dd HH:mm:ss'}" Width="140"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TranStartTime" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Tran Start" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding RequestId}" ElementStyle="{StaticResource NumericCell}" Width="70"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="RequestId" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Req ID" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding DatabaseName}" Width="120"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DatabaseName" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Database" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding LoginName}" Width="120"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LoginName" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Login" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding HostName}" Width="120"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="HostName" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Host" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding ProgramName}" Width="140"> - <DataGridTextColumn.Header> - <StackPanel Orientation="Horizontal"> - <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ProgramName" Click="Filter_Click" Margin="0,0,4,0"/> - <TextBlock Text="Program" FontWeight="Bold" VerticalAlignment="Center"/> - </StackPanel> - </DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Collected" Binding="{Binding CollectionTime, StringFormat='MM/dd HH:mm:ss'}" Width="140"/> + <DataGridTextColumn Header="SPID" Binding="{Binding SessionId}" ElementStyle="{StaticResource NumericCell}" Width="60"/> + <DataGridTextColumn Header="Duration" Binding="{Binding Duration}" Width="100" SortMemberPath="DurationSort"/> + <DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="80"/> + <DataGridTextColumn Header="Wait Info" Binding="{Binding WaitInfo}" Width="180"/> + <DataGridTextColumn Header="Blocking" Binding="{Binding BlockingSessionId}" ElementStyle="{StaticResource NumericCell}" Width="80"/> + <DataGridTextColumn Header="Blocked" Binding="{Binding BlockedSessionCount}" ElementStyle="{StaticResource NumericCell}" Width="80"/> + <DataGridTextColumn Header="CPU (ms)" Binding="{Binding Cpu}" ElementStyle="{StaticResource NumericCell}" Width="80"/> + <DataGridTextColumn Header="Reads (pages)" Binding="{Binding Reads, StringFormat='{}{0:N0}'}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Writes (pages)" Binding="{Binding Writes, StringFormat='{}{0:N0}'}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Physical Reads" Binding="{Binding PhysicalReads}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Physical I/O" Binding="{Binding PhysicalIo, StringFormat='{}{0:N0}'}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Ctx Switches" Binding="{Binding ContextSwitches}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Tasks" Binding="{Binding Tasks, StringFormat='{}{0:N0}'}" ElementStyle="{StaticResource NumericCell}" Width="70"/> + <DataGridTextColumn Header="Used Mem (MB)" Binding="{Binding UsedMemoryMb, StringFormat='{}{0:N2}'}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Req Mem (MB)" Binding="{Binding RequestedMemoryMb, StringFormat='{}{0:N2}'}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="Granted Mem (MB)" Binding="{Binding GrantedMemoryMb, StringFormat='{}{0:N2}'}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Max Used Mem (MB)" Binding="{Binding MaxUsedMemoryMb, StringFormat='{}{0:N2}'}" ElementStyle="{StaticResource NumericCell}" Width="130"/> + <DataGridTextColumn Header="tempdb Cur (MB)" Binding="{Binding TempdbCurrentMb, StringFormat='{}{0:N2}'}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="tempdb Alloc (MB)" Binding="{Binding TempdbAllocations, StringFormat='{}{0:N2}'}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Tran Log" Binding="{Binding TranLogWrites}" ElementStyle="{StaticResource NumericCell}" Width="100" SortMemberPath="TranLogWritesSort"/> + <DataGridTextColumn Header="Open Tran" Binding="{Binding OpenTranCount}" ElementStyle="{StaticResource NumericCell}" Width="80"/> + <DataGridTextColumn Header="Isolation" Binding="{Binding IsolationLevel}" Width="110"/> + <DataGridTextColumn Header="% Complete" Binding="{Binding PercentComplete, StringFormat='{}{0:N1}%'}" ElementStyle="{StaticResource NumericCell}" Width="80"/> + <DataGridTextColumn Header="Start Time" Binding="{Binding StartTime, StringFormat='MM/dd HH:mm:ss'}" Width="140"/> + <DataGridTextColumn Header="Tran Start" Binding="{Binding TranStartTime, StringFormat='MM/dd HH:mm:ss'}" Width="140"/> + <DataGridTextColumn Header="Req ID" Binding="{Binding RequestId}" ElementStyle="{StaticResource NumericCell}" Width="70"/> + <DataGridTextColumn Header="Database" Binding="{Binding DatabaseName}" Width="120"/> + <DataGridTextColumn Header="Login" Binding="{Binding LoginName}" Width="120"/> + <DataGridTextColumn Header="Host" Binding="{Binding HostName}" Width="120"/> + <DataGridTextColumn Header="Program" Binding="{Binding ProgramName}" Width="140"/> <DataGridTemplateColumn Width="500"> <DataGridTemplateColumn.Header> <StackPanel Orientation="Horizontal"> @@ -299,6 +130,29 @@ </DataTemplate> </DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn> + <DataGridTemplateColumn Width="400"> + <DataGridTemplateColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AdditionalInfo" Click="Filter_Click" Margin="0,0,4,0"/> + <TextBlock Text="Additional Info" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTemplateColumn.Header> + <DataGridTemplateColumn.CellTemplate> + <DataTemplate> + <TextBlock Text="{Binding AdditionalInfo}" TextWrapping="Wrap" MaxHeight="90" Margin="5,2" + ToolTip="{Binding AdditionalInfo}"/> + </DataTemplate> + </DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + <DataGridTemplateColumn Header="Query Plan" Width="Auto" MinWidth="90"> + <DataGridTemplateColumn.CellTemplate> + <DataTemplate> + <Button Content="Download" Click="DownloadWaitQueryPlan_Click" + HorizontalAlignment="Center" VerticalAlignment="Center" + Padding="8,4"/> + </DataTemplate> + </DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> </DataGrid.Columns> </DataGrid> diff --git a/Dashboard/WaitDrillDownWindow.xaml.cs b/Dashboard/WaitDrillDownWindow.xaml.cs index ea380c81d..1c6958c2b 100644 --- a/Dashboard/WaitDrillDownWindow.xaml.cs +++ b/Dashboard/WaitDrillDownWindow.xaml.cs @@ -20,6 +20,7 @@ using PerformanceMonitorDashboard.Services; using static PerformanceMonitor.Ui.WaitDrillDownHelper; using PerformanceMonitor.Ui; +using PerformanceMonitor.PlanAnalysis; using PerformanceMonitor.Common; namespace PerformanceMonitorDashboard; @@ -33,8 +34,7 @@ public partial class WaitDrillDownWindow : Window private readonly DateTime? _toDate; // Filter state - private Dictionary<string, ColumnFilterState> _filters = new(); - private List<QuerySnapshotItem>? _unfilteredData; + private readonly DataGridFilterManager<QuerySnapshotItem> _filterManager; private Popup? _filterPopup; private ColumnFilterPopup? _filterPopupContent; private readonly PlanNavigationController _planActions; @@ -60,6 +60,10 @@ public WaitDrillDownWindow( _databaseService.ConnectionString, db, qt, est, iso, isAzureSqlDb: false, timeoutSeconds: 0, ct), "the monitored server"); + _filterManager = new DataGridFilterManager<QuerySnapshotItem>(ResultsDataGrid); + DataGridFilterColumns.AddFilterButtons(ResultsDataGrid, Filter_Click); + _filterManager.UpdateFilterButtonStyles(); + Title = $"Wait Drill-Down: {waitType}"; var classification = Classify(waitType); @@ -119,10 +123,7 @@ private async System.Threading.Tasks.Task LoadDataAsync() private void LoadDirectData(List<QuerySnapshotItem> data, WaitClassification classification) { data = SortByProperty(data, classification.SortProperty); - _unfilteredData = data; - _filters.Clear(); - ResultsDataGrid.ItemsSource = data; - UpdateFilterButtonStyles(); + _filterManager.UpdateData(data); var timeRange = GetTimeRangeDescription(data); var truncated = data.Count >= 500 ? " (limited to 500 rows)" : ""; @@ -143,10 +144,7 @@ private void LoadChainData(List<QuerySnapshotItem> data, WaitClassification clas if (headBlockerInfos.Count == 0) { // No chain found — fall back to showing direct data - _unfilteredData = data; - _filters.Clear(); - ResultsDataGrid.ItemsSource = data; - UpdateFilterButtonStyles(); + _filterManager.UpdateData(data); var timeRange = GetTimeRangeDescription(data); SummaryText.Text = $"{data.Count} snapshot(s) | {classification.Description} | {timeRange} | No blocking chains found, showing waiters"; return; @@ -172,10 +170,7 @@ private void LoadChainData(List<QuerySnapshotItem> data, WaitClassification clas if (headBlockerRows.Count == 0) { // Head blockers not in data — show original data - _unfilteredData = data; - _filters.Clear(); - ResultsDataGrid.ItemsSource = data; - UpdateFilterButtonStyles(); + _filterManager.UpdateData(data); var timeRange = GetTimeRangeDescription(data); SummaryText.Text = $"{data.Count} snapshot(s) | {classification.Description} | {timeRange} | Head blockers not in snapshots, showing waiters"; return; @@ -183,11 +178,10 @@ private void LoadChainData(List<QuerySnapshotItem> data, WaitClassification clas // Insert chain-specific columns into the existing XAML columns InsertChainColumns(); + // The inserted column's filter button gets its glyph from UpdateFilterButtonStyles + _filterManager.UpdateFilterButtonStyles(); - _unfilteredData = headBlockerRows; - _filters.Clear(); - ResultsDataGrid.ItemsSource = headBlockerRows; - UpdateFilterButtonStyles(); + _filterManager.UpdateData(headBlockerRows); var timeRangeDesc = GetTimeRangeDescription(headBlockerRows); SummaryText.Text = $"{headBlockerRows.Count} head blocker(s) from {data.Count} waiting session(s) | " + @@ -335,7 +329,7 @@ private static string GetTimeRangeDescription(List<QuerySnapshotItem> data) private void OnThemeChanged(string _) { - UpdateFilterButtonStyles(); + _filterManager.UpdateFilterButtonStyles(); } #region Column Filter Popup @@ -359,7 +353,7 @@ private void Filter_Click(object sender, RoutedEventArgs e) }; } - _filters.TryGetValue(columnName, out var existingFilter); + _filterManager.Filters.TryGetValue(columnName, out var existingFilter); _filterPopupContent!.Initialize(columnName, existingFilter); _filterPopup.PlacementTarget = button; @@ -369,14 +363,7 @@ private void Filter_Click(object sender, RoutedEventArgs e) private void FilterPopup_FilterApplied(object? sender, FilterAppliedEventArgs e) { if (_filterPopup != null) _filterPopup.IsOpen = false; - - if (e.FilterState.IsActive) - _filters[e.FilterState.ColumnName] = e.FilterState; - else - _filters.Remove(e.FilterState.ColumnName); - - ApplyFilters(); - UpdateFilterButtonStyles(); + _filterManager.SetFilter(e.FilterState); } private void FilterPopup_FilterCleared(object? sender, EventArgs e) @@ -384,146 +371,18 @@ private void FilterPopup_FilterCleared(object? sender, EventArgs e) if (_filterPopup != null) _filterPopup.IsOpen = false; } - private void ApplyFilters() - { - if (_unfilteredData == null) return; - - if (_filters.Count == 0) - { - ResultsDataGrid.ItemsSource = _unfilteredData; - return; - } - - var filtered = _unfilteredData.Where(item => - { - foreach (var filter in _filters.Values) - { - if (filter.IsActive && !DataGridFilterService.MatchesFilter(item, filter)) - return false; - } - return true; - }).ToList(); - - ResultsDataGrid.ItemsSource = filtered; - } - - private void UpdateFilterButtonStyles() - { - foreach (var column in ResultsDataGrid.Columns) - { - if (column.Header is StackPanel stackPanel) - { - var filterButton = stackPanel.Children.OfType<Button>().FirstOrDefault(); - if (filterButton?.Tag is string columnName) - { - bool hasActive = _filters.TryGetValue(columnName, out var filter) && filter.IsActive; - filterButton.Content = new System.Windows.Controls.TextBlock - { - Text = "\uE71C", - FontFamily = new System.Windows.Media.FontFamily("Segoe MDL2 Assets"), - Foreground = hasActive - ? new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0xFF, 0xD7, 0x00)) - : new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0xFF, 0xFF, 0xFF)) - }; - filterButton.ToolTip = hasActive && filter != null - ? $"Filter: {filter.DisplayText}\n(Click to modify)" - : "Click to filter"; - } - } - } - } - #endregion #region Context Menu Handlers - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.CurrentCell.Item != null) - { - var cellContent = TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - Clipboard.SetDataObject(cellContent, false); - } - } - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid?.SelectedItem != null) - Clipboard.SetDataObject(TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem), false); - } - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new StringBuilder(); - var headers = new List<string>(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - headers.Add(DataGridClipboardBehavior.GetHeaderText(column)); - } - sb.AppendLine(string.Join("\t", headers)); - foreach (var item in dataGrid.Items) - sb.AppendLine(TabHelpers.GetRowAsText(dataGrid, item)); - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } - - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var dialog = new SaveFileDialog - { - FileName = $"wait_drill_down_{_waitType}_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - if (dialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); - var headers = new List<string>(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - headers.Add(TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(column))); - } - sb.AppendLine(string.Join(",", headers)); - foreach (var item in dataGrid.Items) - { - var values = TabHelpers.GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(",", values.Select(v => TabHelpers.EscapeCsvField(v)))); - } - System.IO.File.WriteAllText(dialog.FileName, sb.ToString()); - } - catch (Exception ex) - { - MessageBox.Show($"Export failed: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, $"wait_drill_down_{_waitType}", TabHelpers.CsvSeparator); #endregion @@ -544,6 +403,42 @@ await _planActions.GetActualPlanAsync(item.QueryText, item.DatabaseName ?? "", $"Actual Plan - SPID {item.SessionId}"); } + private async void DownloadWaitQueryPlan_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button button || button.DataContext is not QuerySnapshotItem item) return; + + try + { + // The query plan is fetched on demand (not loaded with the grid) — same as the Active Queries grid. + var queryPlan = await _databaseService.GetQuerySnapshotPlanAsync(item.CollectionTime, item.SessionId); + + if (string.IsNullOrWhiteSpace(queryPlan)) + { + MessageBox.Show("No query plan available.", "No Plan", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss", System.Globalization.CultureInfo.InvariantCulture); + var saveFileDialog = new Microsoft.Win32.SaveFileDialog + { + FileName = $"wait_query_plan_{item.SessionId}_{timestamp}.sqlplan", + DefaultExt = ".sqlplan", + Filter = "SQL Plan (*.sqlplan)|*.sqlplan|XML Files (*.xml)|*.xml|All Files (*.*)|*.*", + Title = "Save Query Plan" + }; + + if (saveFileDialog.ShowDialog() == true) + { + System.IO.File.WriteAllText(saveFileDialog.FileName, queryPlan); + MessageBox.Show($"Query plan saved to:\n{saveFileDialog.FileName}", "Success", MessageBoxButton.OK, MessageBoxImage.Information); + } + } + catch (Exception ex) + { + MessageBox.Show($"Error fetching/saving query plan:\n{ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + private static QuerySnapshotItem? GetSnapshotItem(object sender) { if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) diff --git a/Dashboard/packages.lock.json b/Dashboard/packages.lock.json index 762e42124..1f46da4b0 100644 --- a/Dashboard/packages.lock.json +++ b/Dashboard/packages.lock.json @@ -730,12 +730,20 @@ "Microsoft.Data.SqlClient.Extensions.Azure": "[1.0.0, )" } }, + "performancemonitor.alerting": { + "type": "Project", + "dependencies": { + "PerformanceMonitor.Notifications": "[1.0.0, )" + } + }, "performancemonitor.analysis": { "type": "Project" }, "performancemonitor.common": { "type": "Project", "dependencies": { + "CredentialManagement": "[1.0.2, )", + "Microsoft.Extensions.Logging.Abstractions": "[10.0.9, )", "ModelContextProtocol": "[1.4.0, )" } }, @@ -749,6 +757,7 @@ "performancemonitor.plananalysis": { "type": "Project", "dependencies": { + "Microsoft.Data.SqlClient": "[7.0.1, )", "PerformanceMonitor.Common": "[1.0.0, )" } }, diff --git a/Installer.Core/InstallGuard.cs b/Installer.Core/InstallGuard.cs new file mode 100644 index 000000000..bec503ec9 --- /dev/null +++ b/Installer.Core/InstallGuard.cs @@ -0,0 +1,74 @@ +namespace Installer.Core; + +/// <summary>Why installing against a server would be unsafe.</summary> +public enum InstallBlock +{ + /// <summary>Safe to install.</summary> + None, + + /// <summary> + /// This binary's own version will not parse. It is what a FRESH install writes to + /// <c>installation_history.installer_version</c>, so letting it through poisons a brand-new server's + /// ledger at birth — after which every surface refuses to touch that server again. + /// </summary> + UnreadableBuildVersion, + + /// <summary> + /// The version recorded on the server will not parse, so we cannot tell which migrations still apply. + /// </summary> + UnreadableInstalledVersion, + + /// <summary> + /// The server is on a NEWER build than this binary. Installing would run our older scripts over it — + /// reverting every <c>CREATE OR ALTER</c> procedure and view to their older definitions — and then + /// record the LOWER version as SUCCESS. A silent downgrade. + /// </summary> + InstalledIsNewerThanBuild, +} + +/// <summary> +/// The decision behind both installers' pre-install blocks. +/// +/// Two of these cases are invisible to every other guard: they produce ZERO upgrade hops AND ZERO +/// failures, so the migration-failure abort never fires and nothing downstream notices. That is what +/// makes them dangerous, and why the decision lives here — shared and pinned — rather than hand-copied +/// into a WPF code-behind and a CLI Main. +/// </summary> +public static class InstallGuard +{ + /// <param name="installedVersion">The version recorded on the server; null when nothing is installed.</param> + /// <param name="buildVersion">The version of the binary about to run.</param> + public static InstallBlock Check(string? installedVersion, string? buildVersion) + { + var build = ScriptProvider.TryParseVersionCore(buildVersion); + + /* + Checked first, and independently of whether anything is installed: a fresh install WRITES this + value, so an unreadable one is fatal even on an empty server. + */ + if (build == null) + { + return InstallBlock.UnreadableBuildVersion; + } + + if (installedVersion == null) + { + /* Nothing installed: a fresh install is safe. */ + return InstallBlock.None; + } + + var installed = ScriptProvider.TryParseVersionCore(installedVersion); + + if (installed == null) + { + return InstallBlock.UnreadableInstalledVersion; + } + + if (installed > build) + { + return InstallBlock.InstalledIsNewerThanBuild; + } + + return InstallBlock.None; + } +} diff --git a/Installer.Core/InstallationService.cs b/Installer.Core/InstallationService.cs index 258dd13d8..750094e4e 100644 --- a/Installer.Core/InstallationService.cs +++ b/Installer.Core/InstallationService.cs @@ -20,6 +20,24 @@ namespace Installer.Core; /// </summary> public static class InstallationService { + /// <summary> + /// Returned by <see cref="GetInstalledVersionAsync"/> when the database is clearly a + /// PerformanceMonitor install but its recorded version cannot be read — no SUCCESS row, or no + /// history table at all. It is a GUESS meaning "unknown, attempt every upgrade", chosen because it + /// sorts below every real version so <c>FilterUpgrades</c> offers all of them: the safe direction. + /// + /// It is NOT a fact, and callers that DECIDE something from a version must not treat it as one — + /// it compares less than any target, so anything keyed on "is an upgrade pending?" would answer + /// yes unconditionally. See <see cref="RepairOutcome"/>. + /// + /// Deliberately OUTSIDE the real version space. It used to be "1.0.0", which is a shipped tag — and + /// the "your recorded version is unreadable" message tells operators to correct the SUCCESS row by + /// hand, where "1.0.0" is exactly the value someone would type to force a full re-run. They would + /// then be told their version could not be read. No release is 0.0.0, and it still sorts below every + /// real version, so upgrade discovery still offers every hop. + /// </summary> + public const string UnknownVersionSentinel = "0.0.0"; + private static readonly char[] NewLineChars = ['\r', '\n']; /// <summary> @@ -54,6 +72,16 @@ private static void LogDebug(IProgress<InstallationProgress>? progress, string m /// <summary> /// Build a connection string from the provided parameters. /// </summary> + /// <param name="authenticationType"> + /// Optional explicit auth type for the non-interactive Entra modes. When set to the + /// PerformanceMonitor.Common.AuthenticationTypes values "ServicePrincipal" or "ManagedIdentity" + /// it takes precedence over <paramref name="useWindowsAuth"/>/<paramref name="useEntraAuth"/>. + /// Left null by the CLI installer, which only offers Windows/SQL/Entra-interactive — those paths + /// are unchanged. (Installer.Core intentionally has no reference to PerformanceMonitor.Common, so + /// the values are matched as string literals here; they must stay in sync with that enum.) + /// </param> + /// <param name="azureClientId">Service-principal client id fallback when <paramref name="username"/> is null.</param> + /// <param name="managedIdentityClientId">User-assigned managed identity client id; blank = system-assigned.</param> public static string BuildConnectionString( string server, bool useWindowsAuth, @@ -61,7 +89,10 @@ public static string BuildConnectionString( string? password = null, string encryption = "Mandatory", bool trustCertificate = false, - bool useEntraAuth = false) + bool useEntraAuth = false, + string? authenticationType = null, + string? azureClientId = null, + string? managedIdentityClientId = null) { var builder = new SqlConnectionStringBuilder { @@ -78,7 +109,24 @@ public static string BuildConnectionString( _ => SqlConnectionEncryptOption.Mandatory }; - if (useEntraAuth) + // Non-interactive Entra modes (service principal / managed identity) take precedence when an + // explicit authentication type is supplied. The useWindowsAuth/useEntraAuth path below is + // preserved unchanged for callers that leave authenticationType null (e.g. the CLI installer). + if (authenticationType == "ServicePrincipal") + { + builder.Authentication = SqlAuthenticationMethod.ActiveDirectoryServicePrincipal; + builder.UserID = username ?? azureClientId ?? string.Empty; // client/app id + builder.Password = password ?? string.Empty; // client secret + } + else if (authenticationType == "ManagedIdentity") + { + builder.Authentication = SqlAuthenticationMethod.ActiveDirectoryManagedIdentity; + if (!string.IsNullOrWhiteSpace(managedIdentityClientId)) + { + builder.UserID = managedIdentityClientId; // user-assigned MI; omit for system-assigned + } + } + else if (useEntraAuth) { builder.Authentication = SqlAuthenticationMethod.ActiveDirectoryInteractive; builder.UserID = username; @@ -230,13 +278,40 @@ IF EXISTS (SELECT 1 FROM sys.dm_xe_sessions WHERE name = N'PerformanceMonitor_De IF EXISTS (SELECT 1 FROM sys.databases WHERE name = N'PerformanceMonitor') BEGIN - ALTER DATABASE PerformanceMonitor SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + /* + SINGLE_USER evicts other connections so the DROP can proceed -- but it is non-modifiable on Azure SQL + Managed Instance (EngineEdition 8), where the whole batch would die on this line before the DROP. MI + has no force-disconnect; an installer teardown relies on there being no other sessions, which is + normally true. So gate the eviction to box SQL Server and let MI DROP directly. + */ + IF SERVERPROPERTY('EngineEdition') <> 8 + ALTER DATABASE PerformanceMonitor SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE PerformanceMonitor; END;"; using var command = new SqlCommand(cleanupSql, connection); command.CommandTimeout = ShortTimeoutSeconds; - await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + try + { + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + /* + SET SINGLE_USER and DROP are separate autocommitted statements. If the DROP then fails (another + session racing into the single-user slot, Msg 3702) or the client CommandTimeout / a cancel + fires an attention AFTER SINGLE_USER committed, the database is left online but stranded in + SINGLE_USER -- semi-bricked. Un-brick before propagating. It must run on a FRESH connection: a + client-attention timeout is invisible to a server-side TRY/CATCH, so this cannot be a + BEGIN CATCH in the batch above. Close this connection first to release the single-user slot the + failed run may still hold, so the restore is not blocked by it. The bare throw preserves the + original outcome -- a cancel's OperationCanceledException (converted upstream) or the failure's + SqlException. + */ + connection.Close(); + await TryRestoreDatabaseAccessAsync(connectionString, progress).ConfigureAwait(false); + throw; + } progress?.Report(new InstallationProgress { @@ -245,6 +320,55 @@ IF EXISTS (SELECT 1 FROM sys.databases WHERE name = N'PerformanceMonitor') }); } + /// <summary> + /// Best-effort recovery for a clean install whose DROP DATABASE failed or timed out after + /// SET SINGLE_USER had already committed, leaving the database stranded in SINGLE_USER. Puts it back to + /// MULTI_USER so it is usable rather than semi-bricked. + /// + /// Swallows its own errors -- it must never mask the failure that triggered it -- and runs uncancellably + /// on a fresh master connection, so it still executes after a client-attention timeout or a cancel + /// (both of which a server-side TRY/CATCH cannot see). No-op on Azure SQL Managed Instance, where + /// SINGLE_USER cannot be set in the first place, and a no-op when the database no longer exists (the + /// DROP actually succeeded) or is already MULTI_USER (SET MULTI_USER over MULTI_USER is harmless). + /// </summary> + /// <returns> + /// true if the restore ran without error (either it set MULTI_USER, or it was a no-op because the drop + /// actually succeeded / this is Managed Instance) — i.e. the database is not left stranded as far as we + /// can tell. false if the restore attempt itself failed, so the caller can warn that the database may + /// still be in SINGLE_USER. Never throws. + /// </returns> + public static async Task<bool> TryRestoreDatabaseAccessAsync( + string connectionString, + IProgress<InstallationProgress>? progress = null) + { + try + { + var builder = new SqlConnectionStringBuilder(connectionString) { InitialCatalog = "master" }; + using var connection = new SqlConnection(builder.ConnectionString); + await connection.OpenAsync().ConfigureAwait(false); + + /* + Plain SET MULTI_USER, deliberately NOT WITH ROLLBACK IMMEDIATE. In the one race where another + session has grabbed the single-user slot, this blocks on the exclusive lock (up to the timeout) + and then fails best-effort rather than force-killing whoever raced in. The common case -- our own + timed-out session in master context, nobody in the slot -- is restored cleanly. + */ + using var cmd = new SqlCommand( + @"IF SERVERPROPERTY('EngineEdition') <> 8 AND DB_ID(N'PerformanceMonitor') IS NOT NULL + ALTER DATABASE PerformanceMonitor SET MULTI_USER;", + connection); + cmd.CommandTimeout = ShortTimeoutSeconds; + await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); + return true; + } + catch (Exception ex) + { + /* Best-effort: the original failure is what the caller must see, never this one. */ + LogDebug(progress, $"TryRestoreDatabaseAccessAsync: MULTI_USER restore failed (ignored) — {ex.Message}"); + return false; + } + } + /// <summary> /// Perform complete uninstall (remove database, jobs, XE sessions, and traces). /// </summary> @@ -349,6 +473,9 @@ public static async Task<InstallationResult> ExecuteInstallationAsync( StartTime = DateTime.Now }; + /* Cancelled before we have touched anything: a plain cancel, not a failure. */ + cancellationToken.ThrowIfCancellationRequested(); + /*Perform clean install if requested*/ if (cleanInstall) { @@ -356,6 +483,23 @@ public static async Task<InstallationResult> ExecuteInstallationAsync( { await CleanInstallAsync(connectionString, progress, cancellationToken).ConfigureAwait(false); } + catch (Exception) when (cancellationToken.IsCancellationRequested) + { + /* + A CANCEL, surfaced as one. SqlCommand does not fault with OperationCanceledException when its + token trips -- it throws SqlException -- so the catch below used to swallow a cancelled clean + install as an ordinary "failure" and return NORMALLY. The caller's cancellation path never + ran. What the user got instead was "Installation completed with 1 error(s)" over a database + that CleanInstallAsync may have already dropped (it drops the Agent jobs, the XE sessions, + then SET SINGLE_USER WITH ROLLBACK IMMEDIATE and DROP DATABASE, all before a single install + file runs) -- and the dialog then re-stamped the verdict it had deliberately discarded and + armed a Save that skips the connection test entirely. + + The first ThrowIfCancellationRequested in this method is AFTER the clean install, which is + exactly the window where cancelling is most destructive and least recoverable. + */ + throw new OperationCanceledException(cancellationToken); + } catch (Exception ex) { progress?.Report(new InstallationProgress @@ -385,8 +529,6 @@ Files execute without transaction wrapping because many contain DDL. { Message = "Starting installation...", Status = "Info", - CurrentStep = 0, - TotalSteps = scriptFiles.Count, ProgressPercent = 0 }); @@ -413,8 +555,6 @@ Files execute without transaction wrapping because many contain DDL. { Message = $"Executing {fileName}...", Status = "Info", - CurrentStep = i + 1, - TotalSteps = scriptFiles.Count, ProgressPercent = (int)(((i + 1) / (double)scriptFiles.Count) * 100) }); @@ -496,13 +636,26 @@ Files execute without transaction wrapping because many contain DDL. { Message = $"{fileName} - Success", Status = "Success", - CurrentStep = i + 1, - TotalSteps = scriptFiles.Count, ProgressPercent = (int)(((i + 1) / (double)scriptFiles.Count) * 100) }); result.FilesSucceeded++; } + catch (Exception) when (cancellationToken.IsCancellationRequested) + { + /* + A cancel, surfaced as one -- the same pattern as the clean-install branch above, and here for + the same reason. A cancelled SqlCommand faults with SqlException, not + OperationCanceledException, so the general catch below counted a Cancel mid-file as a file + FAILURE and the run returned Success=false. The loop-top ThrowIfCancellationRequested only + re-surfaces the cancel if control reaches the top of the loop again -- which it does NOT for a + critical file (it `break`s) or for the LAST file (the loop just ends), so those two cancels + fell through to "completed with N error(s)" and the caller's FAILURE path instead of its + CANCELLATION path. Converting here, ahead of the general catch, keeps the failure accounting + honest: a cancel is not a file failure, and it is not recorded as one. + */ + throw new OperationCanceledException(cancellationToken); + } catch (Exception ex) { LogDebug(progress, $" {fileName}: FAILED — {ex.GetType().Name}: {ex.Message}"); @@ -512,9 +665,7 @@ Files execute without transaction wrapping because many contain DDL. progress?.Report(new InstallationProgress { Message = $"{fileName} - FAILED: {ex.Message}", - Status = "Error", - CurrentStep = i + 1, - TotalSteps = scriptFiles.Count + Status = "Error" }); result.FilesFailed++; @@ -814,6 +965,23 @@ public static async Task<bool> RunTroubleshootingAsync( /// Generate installation summary report file. /// </summary> /// <param name="outputDirectory">Directory to write the report. Null defaults to user profile.</param> + /* + Every character Windows forbids in a filename -> underscore, including the separators and ".." pieces + that a path-traversal string is built from. Mirrors Program.SanitizeFilename; Installer.Core cannot + reach that private CLI helper, and this is the shared writer both front ends call. + */ + private static string SanitizeForFileName(string value) + { + char[] invalid = Path.GetInvalidFileNameChars(); + var sb = new StringBuilder(value.Length); + foreach (char c in value) + { + sb.Append(Array.IndexOf(invalid, c) >= 0 ? '_' : c); + } + + return sb.ToString(); + } + public static string GenerateSummaryReport( string serverName, string sqlServerVersion, @@ -828,10 +996,34 @@ public static string GenerateSummaryReport( var duration = result.EndTime - result.StartTime; string timestamp = result.StartTime.ToString("yyyyMMdd_HHmmss"); - string fileName = $"PerformanceMonitor_Install_{serverName.Replace("\\", "_", StringComparison.Ordinal)}_{timestamp}.txt"; + + /* + Strip EVERY invalid filename char, not just the backslash. The server name is user-typed, and a + value like "x/../../Users/Public/foo" turned this Path.Combine into a write OUTSIDE reportDir -- + forward slash is a separator, ".." is parent, ":" opens an NTFS alternate data stream, all of which + the old .Replace("\\","_") let straight through. The "PerformanceMonitor_Install_" prefix does not + contain it; ".." just escapes past it. The CLI's own error-log writer already sanitizes the same + input with GetInvalidFileNameChars (Program.SanitizeFilename); this report writer -- called by BOTH + the CLI and the Dashboard -- was the one that was missed. + */ + string fileName = $"PerformanceMonitor_Install_{SanitizeForFileName(serverName)}_{timestamp}.txt"; string reportDir = outputDirectory ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); string reportPath = Path.Combine(reportDir, fileName); + /* + Belt and braces: even sanitized, refuse to write outside the intended directory. A future change to + the filename builder cannot turn this into an arbitrary-write without tripping here first. + */ + string fullReportDir = Path.GetFullPath(reportDir); + string fullReportPath = Path.GetFullPath(reportPath); + if (!fullReportPath.StartsWith( + fullReportDir.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "Refusing to write the installation report outside the report directory."); + } + var sb = new StringBuilder(); sb.AppendLine("================================================================================"); @@ -890,24 +1082,6 @@ public static string GenerateSummaryReport( } } - if (result.LogMessages.Count > 0) - { - sb.AppendLine("DETAILED INSTALLATION LOG"); - sb.AppendLine("--------------------------------------------------------------------------------"); - foreach (var (message, status) in result.LogMessages) - { - string prefix = status switch - { - "Success" => "[OK] ", - "Error" => "[ERROR] ", - "Warning" => "[WARN] ", - _ => "" - }; - sb.AppendLine($"{prefix}{message}"); - } - sb.AppendLine(); - } - sb.AppendLine("================================================================================"); sb.AppendLine("Generated by Performance Monitor Installer"); sb.AppendLine($"Copyright (c) {DateTime.Now.Year} Darling Data, LLC"); @@ -941,47 +1115,81 @@ FROM sys.databases WHERE name = N'PerformanceMonitor';", connection); var dbExists = await dbCheckCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); - if (dbExists == null || dbExists == DBNull.Value) + bool databaseExists = dbExists != null && dbExists != DBNull.Value; + + if (!databaseExists) { LogDebug(progress, "GetInstalledVersionAsync: database does not exist → clean install"); - return null; + return InstalledVersionClassifier.Classify(false, false, 0L, null); } - LogDebug(progress, "GetInstalledVersionAsync: database exists, checking installation_history table"); /*Check if installation_history table exists*/ using var tableCheckCmd = new SqlCommand(@" USE PerformanceMonitor; SELECT OBJECT_ID(N'config.installation_history', N'U');", connection); - var tableExists = await tableCheckCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); - if (tableExists == null || tableExists == DBNull.Value) + var tableOid = await tableCheckCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + bool historyTableExists = tableOid != null && tableOid != DBNull.Value; + + long collectTableCount = 0L; + if (!historyTableExists) { - LogDebug(progress, "GetInstalledVersionAsync: installation_history table does not exist → old or corrupted install"); - return null; + /* + No ledger. Count the collect objects so the classifier can tell a PerformanceMonitor + database that LOST its history apart from an empty database someone pre-created for us. + Answering "no version" for the first is the stranding bug this whole path exists to + prevent; answering "installed" for the second would run migrations against tables that do + not exist. See InstalledVersionClassifier. + */ + using var collectCheckCmd = new SqlCommand(@" + SELECT + COUNT_BIG(*) + FROM PerformanceMonitor.sys.tables AS t + INNER JOIN PerformanceMonitor.sys.schemas AS s + ON s.schema_id = t.schema_id + WHERE s.name = N'collect';", connection); + + var collectTables = await collectCheckCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + collectTableCount = + collectTables == null || collectTables == DBNull.Value + ? 0L + : Convert.ToInt64(collectTables); } - /*Get most recent successful installation version*/ - using var versionCmd = new SqlCommand(@" - SELECT TOP 1 installer_version - FROM PerformanceMonitor.config.installation_history - WHERE installation_status = 'SUCCESS' - ORDER BY installation_date DESC;", connection); - - var version = await versionCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); - if (version != null && version != DBNull.Value) + string? latestSuccessVersion = null; + if (historyTableExists) { - LogDebug(progress, $"GetInstalledVersionAsync: found installed version {version}"); - return version.ToString(); + /*Get most recent successful installation version*/ + using var versionCmd = new SqlCommand(@" + SELECT TOP (1) + installer_version + FROM PerformanceMonitor.config.installation_history + WHERE installation_status = 'SUCCESS' + ORDER BY installation_date DESC;", connection); + + var version = await versionCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + if (version != null && version != DBNull.Value) + { + latestSuccessVersion = version.ToString(); + } } /* - Fallback: database and history table exist but no SUCCESS rows. - This can happen if a prior install didn't write history (#538/#539). - Return "1.0.0" so all idempotent upgrade scripts are attempted - rather than treating this as a fresh install (which would drop the database). + The DECISION lives in InstalledVersionClassifier so it can be pinned by tests CI actually + runs -- this method needs a live SQL Server, and that suite is excluded from CI. */ - LogDebug(progress, "GetInstalledVersionAsync: no SUCCESS rows — fallback to 1.0.0 (#538 guard)"); - return "1.0.0"; + string? resolved = InstalledVersionClassifier.Classify( + databaseExists, + historyTableExists, + collectTableCount, + latestSuccessVersion); + + LogDebug( + progress, + $"GetInstalledVersionAsync: dbExists={databaseExists}, historyTable={historyTableExists}, " + + $"collectTables={collectTableCount}, successRow={latestSuccessVersion ?? "(none)"} → {resolved ?? "(clean install)"}"); + + return resolved; } catch (SqlException ex) { @@ -1122,8 +1330,27 @@ existing database (no upgrades, then logged as SUCCESS — the #538 hazard). Sof int totalSuccessCount = 0; int totalFailureCount = 0; - var upgrades = provider.GetApplicableUpgrades(currentVersion, targetVersion, - warning => progress?.Report(new InstallationProgress { Message = warning, Status = "Warning" })); + List<UpgradeInfo> upgrades; + try + { + upgrades = provider.GetApplicableUpgrades(currentVersion, targetVersion, + warning => progress?.Report(new InstallationProgress { Message = warning, Status = "Warning" })); + } + catch (ArgumentException ex) + { + /* + Report as a failure rather than letting it surface as "no upgrades needed". Callers abort + when totalFailureCount > 0, so this keeps an unreadable version from silently skipping + every migration and then stamping the database as current. + */ + progress?.Report(new InstallationProgress + { + Message = $"Cannot determine which upgrades to apply: {ex.Message}", + Status = "Error" + }); + + return (0, 1, 0); + } if (upgrades.Count == 0) { diff --git a/Installer.Core/InstalledVersionClassifier.cs b/Installer.Core/InstalledVersionClassifier.cs new file mode 100644 index 000000000..848300d07 --- /dev/null +++ b/Installer.Core/InstalledVersionClassifier.cs @@ -0,0 +1,76 @@ +namespace Installer.Core; + +/// <summary> +/// Turns what we can SEE on a server into the version we act on. This is the single most consequential +/// decision in the installer: it is what separates "nothing is installed, do a clean install" from +/// "something is installed, work out which migrations still need to run". +/// +/// Getting it wrong in one direction strands migrations forever. A PerformanceMonitor database whose +/// ledger was lost — someone dropped <c>config.installation_history</c>, or a restore left it behind — +/// used to answer "no version" and therefore read as a FRESH INSTALL: the install scripts ran over the +/// live schema, every migration was skipped, and the target version was stamped SUCCESS. Version +/// detection reads back the most recent SUCCESS row, so those hops were never offered again. +/// +/// Getting it wrong in the other direction breaks a legitimate install: an EMPTY database that someone +/// pre-created (commonly to control the data/log file paths) genuinely has nothing installed, and +/// attempting migrations against tables that do not exist would fail. +/// +/// Split out of <see cref="InstallationService.GetInstalledVersionAsync"/> so it can be pinned by unit +/// tests that CI actually runs — the SQL that feeds it needs a live SQL Server, and that test suite is +/// excluded from CI. +/// </summary> +public static class InstalledVersionClassifier +{ + /// <param name="databaseExists">The PerformanceMonitor database exists on the server.</param> + /// <param name="historyTableExists"><c>config.installation_history</c> exists.</param> + /// <param name="collectTableCount"> + /// How many tables live in the <c>collect</c> schema. The discriminator between a PerformanceMonitor + /// database that lost its ledger and an empty database someone pre-created for us. + /// </param> + /// <param name="latestSuccessVersion"> + /// <c>installer_version</c> of the most recent SUCCESS row, or null when there is none. + /// </param> + /// <returns> + /// null when nothing is installed (do a clean install), a real version when we know it, or + /// <see cref="InstallationService.UnknownVersionSentinel"/> when something IS installed but its + /// version cannot be read — meaning "attempt every upgrade", the safe direction, since every + /// upgrade script is <c>IF NOT EXISTS</c>-guarded and replays cleanly. + /// </returns> + public static string? Classify( + bool databaseExists, + bool historyTableExists, + long collectTableCount, + string? latestSuccessVersion) + { + if (!databaseExists) + { + /* Nothing there at all. */ + return null; + } + + if (!historyTableExists) + { + /* + No ledger. If the database holds collect objects it is a PerformanceMonitor database that lost + its history -- answering null here is the stranding bug above. If it holds none, it is an empty + database someone pre-created, and a clean install is right. + */ + return collectTableCount > 0 ? InstallationService.UnknownVersionSentinel : null; + } + + /* + The ledger exists. A SUCCESS row is the answer; without one we know something was installed but + not what, so fall back to "attempt every upgrade" rather than treating it as a fresh install and + dropping the database (#538). + + Blank counts as absent, not as an answer. installer_version is NOT NULL, so a row hand-edited to '' + -- and the block message we show an operator literally invites them to edit that row -- came back as + "" rather than null, and "" is not a version: FilterUpgrades turns it into ZERO hops, which reads as + "nothing to do" and strands every migration. InstallGuard rejects "" on every path today, so this is + the second lock on the same door, and the ledger is exactly where a second lock is worth having. + */ + return string.IsNullOrWhiteSpace(latestSuccessVersion) + ? InstallationService.UnknownVersionSentinel + : latestSuccessVersion; + } +} diff --git a/Installer.Core/Installer.Core.csproj b/Installer.Core/Installer.Core.csproj index 1b9759866..882fe52ef 100644 --- a/Installer.Core/Installer.Core.csproj +++ b/Installer.Core/Installer.Core.csproj @@ -7,10 +7,10 @@ <RootNamespace>Installer.Core</RootNamespace> <AssemblyName>Installer.Core</AssemblyName> <Product>SQL Server Performance Monitor Installer Core</Product> - <Version>3.1.0</Version> - <AssemblyVersion>3.1.0.0</AssemblyVersion> - <FileVersion>3.1.0.0</FileVersion> - <InformationalVersion>3.1.0</InformationalVersion> + <Version>3.2.0</Version> + <AssemblyVersion>3.2.0.0</AssemblyVersion> + <FileVersion>3.2.0.0</FileVersion> + <InformationalVersion>3.2.0</InformationalVersion> <Company>Darling Data, LLC</Company> <Copyright>Copyright (c) 2026 Darling Data, LLC</Copyright> <EnableNETAnalyzers>true</EnableNETAnalyzers> diff --git a/Installer.Core/Models/InstallationProgress.cs b/Installer.Core/Models/InstallationProgress.cs index df97902a6..0d8634536 100644 --- a/Installer.Core/Models/InstallationProgress.cs +++ b/Installer.Core/Models/InstallationProgress.cs @@ -7,7 +7,5 @@ public class InstallationProgress { public string Message { get; set; } = string.Empty; public string Status { get; set; } = "Info"; // Info, Success, Error, Warning - public int? CurrentStep { get; set; } - public int? TotalSteps { get; set; } public int? ProgressPercent { get; set; } } diff --git a/Installer.Core/Models/InstallationResult.cs b/Installer.Core/Models/InstallationResult.cs index ee154e6f9..1d1eee092 100644 --- a/Installer.Core/Models/InstallationResult.cs +++ b/Installer.Core/Models/InstallationResult.cs @@ -9,8 +9,6 @@ public class InstallationResult public int FilesSucceeded { get; set; } public int FilesFailed { get; set; } public List<(string FileName, string ErrorMessage)> Errors { get; } = new(); - public List<(string Message, string Status)> LogMessages { get; } = new(); public DateTime StartTime { get; set; } public DateTime EndTime { get; set; } - public string? ReportPath { get; set; } } diff --git a/Installer.Core/Models/InstallationResultCode.cs b/Installer.Core/Models/InstallationResultCode.cs index 457877282..7e26eb2fd 100644 --- a/Installer.Core/Models/InstallationResultCode.cs +++ b/Installer.Core/Models/InstallationResultCode.cs @@ -15,5 +15,6 @@ public enum InstallationResultCode SqlFilesNotFound = 6, UninstallFailed = 7, UpgradesFailed = 8, - CleanInstallFailed = 9 + CleanInstallFailed = 9, + DiagnosticsFailed = 10 } diff --git a/Installer.Core/RepairOutcome.cs b/Installer.Core/RepairOutcome.cs new file mode 100644 index 000000000..8c2f18ec8 --- /dev/null +++ b/Installer.Core/RepairOutcome.cs @@ -0,0 +1,103 @@ +namespace Installer.Core; + +/// <summary> +/// Decides whether a repair's failed install files are the EXPECTED kind. +/// +/// A repair runs the install scripts without the migrations. Those scripts compile against the CURRENT +/// schema, and <c>ALTER PROCEDURE</c> binds columns at compile time -- e.g. +/// <c>install/23_process_blocked_process_xml.sql</c> reads +/// <c>collect.blocking_BlockedProcessReport.monitor_loop</c>, a column the 3.0.0-to-3.1.0 migration adds. +/// So on a database with a PENDING upgrade, some procedures simply cannot compile until it runs +/// (Msg 207, "Invalid column name"). A failed <c>CREATE OR ALTER</c> leaves the old body intact, nothing +/// is damaged, and the upgrade's own install pass recompiles them. +/// +/// Both conditions matter, and getting either wrong is dangerous in a different direction: +/// +/// <list type="bullet"> +/// <item>Treating those failures as a FAILURE sends the operator reaching for a destructive reinstall, +/// and makes a %ERRORLEVEL% gate reject a good repair.</item> +/// <item>Treating them as EXPECTED when there is NO pending upgrade reports SUCCESS over genuinely +/// broken objects, and blames a migration that does not exist.</item> +/// </list> +/// +/// Shared so the Dashboard and the CLI cannot drift: this is the contract the CHANGELOG states. +/// </summary> +public static class RepairOutcome +{ + /// <summary> + /// True when a repair's install-file failures are the expected uncompilable-until-upgraded kind, and + /// the run should therefore be reported as a success with a "now run the upgrade" handoff. + /// </summary> + /// <param name="repairRan">A repair actually ran against an existing installation.</param> + /// <param name="installedVersion">The version recorded on the server.</param> + /// <param name="targetVersion">This binary's version.</param> + /// <param name="criticalFileFailed"> + /// A critical file (01_/02_/03_) failed, which aborts the whole pass -- so the repair reinstalled + /// nothing and genuinely failed, whatever the version situation. + /// </param> + public static bool FailuresAreExpected( + bool repairRan, + string? installedVersion, + string? targetVersion, + bool criticalFileFailed) + { + if (!repairRan || criticalFileFailed) + { + return false; + } + + /* + The "unknown" sentinel is a GUESS, not a version -- GetInstalledVersionAsync returns it when the + database is clearly installed but its recorded version cannot be read. It sorts below every real + version, so trusting it here would answer "yes, an upgrade is pending" unconditionally, and every + REAL repair failure on such a server would be reported as expected and exit 0. Concretely: a + schema-current 3.1.0 server whose history rows are all FAILED, with four genuinely broken + procedures, would pass a %ERRORLEVEL% gate while telling the operator to ignore the errors. + */ + if (string.Equals(installedVersion?.Trim(), InstallationService.UnknownVersionSentinel, StringComparison.Ordinal)) + { + return false; + } + + var installed = ScriptProvider.TryParseVersionCore(installedVersion); + var target = ScriptProvider.TryParseVersionCore(targetVersion); + + /* No comparable versions means no basis for the "expected" story. */ + if (installed == null || target == null) + { + return false; + } + + /* The expected failures only exist when there is a migration still to run. */ + return installed < target; + } + + /// <summary> + /// True when there is an upgrade to run AFTER this repair — i.e. what the "now run the upgrade" + /// handoff should key on. + /// + /// This is a DIFFERENT question from <see cref="FailuresAreExpected"/>, and the difference is the + /// unknown sentinel. "May I excuse these file failures?" must answer NO for an unreadable version + /// (we cannot certify a success we cannot explain). "Is there an upgrade to run next?" must answer + /// YES for the same input — the version is unknown, so every hop may be pending. Reusing one boolean + /// for both told the operator "already at the current version, so there is no upgrade to apply" for + /// a server with eleven pending hops, and withheld the handoff button that would have applied them. + /// </summary> + public static bool HasPendingUpgrade(string? installedVersion, string? targetVersion) + { + var installed = ScriptProvider.TryParseVersionCore(installedVersion); + var target = ScriptProvider.TryParseVersionCore(targetVersion); + + return installed != null && target != null && installed < target; + } + + /// <summary> + /// True when the recorded version could not be read, so it is unknown which migrations have run. + /// Callers must say so rather than asserting the server is current. + /// </summary> + public static bool IsVersionUnknown(string? installedVersion) => + string.Equals( + installedVersion?.Trim(), + InstallationService.UnknownVersionSentinel, + StringComparison.Ordinal); +} diff --git a/Installer.Core/ScriptProvider.cs b/Installer.Core/ScriptProvider.cs index 40f1823ed..7b893c7da 100644 --- a/Installer.Core/ScriptProvider.cs +++ b/Installer.Core/ScriptProvider.cs @@ -28,65 +28,11 @@ public static ScriptProvider FromDirectory(string monitorRootDirectory) public static ScriptProvider FromEmbeddedResources(Assembly? assembly = null) => new EmbeddedResourceScriptProvider(assembly ?? typeof(ScriptProvider).Assembly); - /// <summary> - /// Auto-discover: search filesystem starting from CWD and executable directory, - /// walking up to 5 parent directories. Falls back to embedded resources. - /// </summary> - /// <param name="log">Optional logging callback for diagnostics.</param> - public static ScriptProvider AutoDiscover(Action<string>? log = null) - { - var startDirs = new[] { Directory.GetCurrentDirectory(), AppDomain.CurrentDomain.BaseDirectory } - .Distinct() - .ToList(); - - log?.Invoke($"AutoDiscover: searching from [{string.Join(", ", startDirs)}]"); - - foreach (string startDir in startDirs) - { - DirectoryInfo? searchDir = new DirectoryInfo(startDir); - for (int i = 0; i < 6 && searchDir != null; i++) - { - string installFolder = Path.Combine(searchDir.FullName, "install"); - if (Directory.Exists(installFolder)) - { - var sqlFiles = Directory.GetFiles(installFolder, "*.sql") - .Where(f => Patterns.SqlFilePattern().IsMatch(Path.GetFileName(f))) - .ToList(); - if (sqlFiles.Count > 0) - { - log?.Invoke($"AutoDiscover: found {sqlFiles.Count} scripts in {installFolder}"); - return new FileSystemScriptProvider(searchDir.FullName); - } - } - - var rootFiles = Directory.GetFiles(searchDir.FullName, "*.sql") - .Where(f => Patterns.SqlFilePattern().IsMatch(Path.GetFileName(f))) - .ToList(); - if (rootFiles.Count > 0) - { - log?.Invoke($"AutoDiscover: found {rootFiles.Count} scripts in {searchDir.FullName}"); - return new FileSystemScriptProvider(searchDir.FullName); - } - - log?.Invoke($"AutoDiscover: no scripts in {searchDir.FullName}, trying parent"); - searchDir = searchDir.Parent; - } - } - - log?.Invoke("AutoDiscover: no filesystem scripts found, falling back to embedded resources"); - return FromEmbeddedResources(); - } - /// <summary> /// Returns the filtered, sorted list of install scripts (excludes 00_/97_/99_). /// </summary> public abstract List<ScriptFile> GetInstallFiles(); - /// <summary> - /// Reads the content of an install script. - /// </summary> - public abstract string ReadScript(ScriptFile file); - /// <summary> /// Reads the content of an install script asynchronously. /// </summary> @@ -134,21 +80,21 @@ public abstract Task<string> ReadUpgradeScriptAsync( /// <summary> /// Core upgrade-discovery logic shared by both providers. /// </summary> + /// <exception cref="ArgumentException"> + /// Thrown when a version is present but has no parseable numeric core. An empty result means + /// "no upgrades needed", which must never be the answer to "I could not read the version you gave me". + /// </exception> protected static List<UpgradeInfo> FilterUpgrades( IEnumerable<UpgradeInfo> candidates, string? currentVersion, string targetVersion) { - if (currentVersion == null) + /* No recorded version at all means a fresh install: there is nothing to upgrade from. */ + if (string.IsNullOrWhiteSpace(currentVersion)) return []; - if (!Version.TryParse(currentVersion, out var currentRaw)) - return []; - var current = new Version(currentRaw.Major, currentRaw.Minor, currentRaw.Build); - - if (!Version.TryParse(targetVersion, out var targetRaw)) - return []; - var target = new Version(targetRaw.Major, targetRaw.Minor, targetRaw.Build); + var current = ParseVersionCore(currentVersion, nameof(currentVersion)); + var target = ParseVersionCore(targetVersion, nameof(targetVersion)); return candidates .Where(x => x.FromVersion != null && x.ToVersion != null) @@ -158,6 +104,50 @@ protected static List<UpgradeInfo> FilterUpgrades( .ToList(); } + /// <summary> + /// Parses a version to its three-part numeric core, stripping any SemVer build (<c>+sha</c>) or + /// pre-release (<c>-rc1</c>) suffix. Returns null when there is no parseable core, rather than + /// throwing — for callers that need to *decide* something about a version rather than use it. + /// Mirrors <c>SingleInstanceDecision.ParseProductVersion</c>, re-stated here to keep + /// Installer.Core dependency-free. + /// </summary> + public static Version? TryParseVersionCore(string? version) + { + if (string.IsNullOrWhiteSpace(version)) + return null; + + string core = version.Trim(); + + int plus = core.IndexOf('+', StringComparison.Ordinal); + if (plus >= 0) + core = core[..plus]; + + int dash = core.IndexOf('-', StringComparison.Ordinal); + if (dash >= 0) + core = core[..dash]; + + if (!Version.TryParse(core, out var parsed)) + return null; + + /* TryParse leaves Build at -1 for a two-part version like "3.1", which the Version ctor rejects. */ + return new Version(parsed.Major, parsed.Minor, Math.Max(parsed.Build, 0)); + } + + private static Version ParseVersionCore(string version, string paramName) + { + /* + A version that is present but has no numeric core is a caller bug -- a status string like + "Unreachable" reaching us instead of a version. Returning an empty list would run zero + migrations and report zero failures, and the caller would then stamp installation_history as + SUCCESS at the target version. Version detection only reads the most recent SUCCESS row, so + every skipped hop would be stranded permanently. Fail loudly instead. + */ + return TryParseVersionCore(version) + ?? throw new ArgumentException( + $"'{version}' is not a valid version. Upgrade discovery needs a version, not a status string.", + paramName); + } + /// <summary> /// Parses an upgrade folder name like "1.2.0-to-1.3.0" into an UpgradeInfo. /// Returns null if the name doesn't match the expected pattern. @@ -224,9 +214,6 @@ public override List<ScriptFile> GetInstallFiles() .ToList(); } - public override string ReadScript(ScriptFile file) => - File.ReadAllText(file.Identifier); - public override Task<string> ReadScriptAsync(ScriptFile file, CancellationToken cancellationToken = default) => File.ReadAllTextAsync(file.Identifier, cancellationToken); @@ -317,9 +304,6 @@ public override List<ScriptFile> GetInstallFiles() .ToList(); } - public override string ReadScript(ScriptFile file) => - ReadResource(file.Identifier); - public override Task<string> ReadScriptAsync(ScriptFile file, CancellationToken cancellationToken = default) => Task.FromResult(ReadResource(file.Identifier)); diff --git a/Installer.Core/ServerSetupPolicy.cs b/Installer.Core/ServerSetupPolicy.cs new file mode 100644 index 000000000..e1418053e --- /dev/null +++ b/Installer.Core/ServerSetupPolicy.cs @@ -0,0 +1,80 @@ +namespace Installer.Core; + +/// <summary> +/// The pure decisions of the add/edit-server flow, extracted out of the (untested) WPF dialog so CI can +/// pin them. Every method here is a total function of <see cref="ServerSetupState"/> (plus, for +/// <see cref="DeriveConnectedState"/>, two version strings) with no UI dependency — which is exactly what +/// lets <c>ServerSetupPolicyTests</c> assert the full state×predicate matrix. +/// +/// The bug class this closes: a predicate over the state that silently omits a state. It happened more than +/// once — <see cref="IsServerVerdictState"/> listing three states while <see cref="PromisesExistingInstall"/> +/// listed five and the two disagreeing about the same server; the destructive-checkbox rule missing an exit. +/// A switch that forgets a state compiles fine and fails only in the UI; a matrix test over the enum does not. +/// </summary> +public static class ServerSetupPolicy +{ + /// <summary> + /// Does this state assert a discovered fact ABOUT the server currently on screen — its installed + /// version, whether it needs an upgrade, whether it is current? Those verdicts must not be rendered + /// once the server-name box no longer matches the server they were read from. This is the set the + /// stale-verdict guard demotes. + /// </summary> + public static bool IsServerVerdictState(ServerSetupState state) => + state is ServerSetupState.Connected_NoDatabase + or ServerSetupState.Connected_NeedsUpgrade + or ServerSetupState.Connected_Current; + + /// <summary> + /// Did the button the user actually clicked promise to act on an EXISTING installation? Only + /// <see cref="ServerSetupState.Connected_NoDatabase"/> says "Install Now"; every other launchable state + /// promises otherwise — including <see cref="ServerSetupState.InstallComplete"/> and + /// <see cref="ServerSetupState.MonitoringCredentials"/>, where the repair handoff deliberately puts a + /// live "Upgrade Now" in front of the user. Those two are NOT verdict states, so they are the pair + /// <see cref="IsServerVerdictState"/> and this predicate legitimately disagree on — a real bug came from + /// making them agree. + /// </summary> + public static bool PromisesExistingInstall(ServerSetupState launchState) => launchState switch + { + ServerSetupState.Connected_NeedsUpgrade => true, // "Upgrade Now" + ServerSetupState.Connected_Current => true, // "Reinstall Objects" + ServerSetupState.InstallComplete => true, // repair handoff: "Upgrade Now" + ServerSetupState.MonitoringCredentials => true, // repair handoff, SQL auth: "Upgrade Now" + _ => false, // Connected_NoDatabase: "Install Now"; and the non-launchable states + }; + + /// <summary> + /// The ONE state that consumes the destructive checkboxes (clean install → DROP DATABASE, repair, reset + /// schedule → TRUNCATE): <see cref="ServerSetupState.Installing"/>, which is about to read them. Every + /// other transition must clear them, because consent for a destructive option is per-run and per-server + /// and a tick must never survive onto a server it was not set for. Keeping this as a single predicate — + /// rather than clearing the ticks at each exit — is what stops the next exit from being the one that + /// forgets. It reopened as a bug more than once when it was enforced exit-by-exit. + /// </summary> + public static bool StateConsumesModeSelections(ServerSetupState state) => + state == ServerSetupState.Installing; + + /// <summary>True only while a run is in progress. The backstop against starting a second install.</summary> + public static bool IsInstalling(ServerSetupState state) => + state == ServerSetupState.Installing; + + /// <summary> + /// Which connected state the FACTS imply. Used by detection and — crucially — re-derived from the + /// install-time re-read before the install begins, never restored from "the state we came from" (the + /// box stays editable, so the state we came from may describe a different server). + /// + /// <paramref name="installedVersion"/> null means no database (fresh install). Otherwise the choice is + /// pending-upgrade vs current, decided by <see cref="RepairOutcome.HasPendingUpgrade"/> so the unknown + /// sentinel (which sorts below every real version) correctly reads as "an upgrade is pending". + /// </summary> + public static ServerSetupState DeriveConnectedState(string? installedVersion, string appVersion) + { + if (installedVersion == null) + { + return ServerSetupState.Connected_NoDatabase; + } + + return RepairOutcome.HasPendingUpgrade(installedVersion, appVersion) + ? ServerSetupState.Connected_NeedsUpgrade + : ServerSetupState.Connected_Current; + } +} diff --git a/Installer.Core/ServerSetupState.cs b/Installer.Core/ServerSetupState.cs new file mode 100644 index 000000000..1409cb049 --- /dev/null +++ b/Installer.Core/ServerSetupState.cs @@ -0,0 +1,49 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Installer.Core; + +/// <summary> +/// The state of the "add / edit a monitored server" flow: connect to a server, discover whether +/// PerformanceMonitor is installed on it, and install or upgrade. The Dashboard's AddServerDialog aliases +/// this as <c>DialogState</c> and drives its UI from it. +/// +/// This enum and the predicates over it (<see cref="ServerSetupPolicy"/>) live here, in Installer.Core, +/// for one reason: they are pure decisions, and Installer.Core is CI-tested while the WPF dialog is not. +/// Every bug in this flow that survived review came in one of two shapes, and one of them was "a predicate +/// over the state that failed to enumerate every state" — a class the matrix tests over this enum make +/// impossible to reintroduce silently. +/// </summary> +[SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", + Justification = "Connected_* is a deliberate, readable grouping convention for this flow's states; it " + + "carried over verbatim from the dialog's original private enum and reads better than " + + "the un-underscored form.")] +public enum ServerSetupState +{ + /// <summary>Nothing checked yet — a fresh dialog, or one whose fields were just edited.</summary> + Initial, + + /// <summary>Connected; no PerformanceMonitor database present. The offer is "Install Now" (or Skip).</summary> + Connected_NoDatabase, + + /// <summary>Connected; PerformanceMonitor installed but behind this build. The offer is "Upgrade Now".</summary> + Connected_NeedsUpgrade, + + /// <summary>Connected; PerformanceMonitor installed and current. The offer is "Reinstall Objects".</summary> + Connected_Current, + + /// <summary> + /// Connected, but the installed status could not be trusted — a failed re-read, an unsupported or + /// unreadable version, a database newer than the binary, or a verdict demoted because the server-name + /// box changed after it was checked. Installing is blocked; the install button is hidden. + /// </summary> + Connected_StatusUnknown, + + /// <summary>An install or upgrade is running. The ONLY state that consumes the destructive checkboxes.</summary> + Installing, + + /// <summary>A run finished. May carry a repair handoff ("Upgrade Now") for the still-pending upgrade.</summary> + InstallComplete, + + /// <summary>Post-install, SQL auth: capturing the ongoing monitoring credentials. May also carry the handoff.</summary> + MonitoringCredentials +} diff --git a/Installer.Tests/BuildConnectionStringTests.cs b/Installer.Tests/BuildConnectionStringTests.cs new file mode 100644 index 000000000..05924be9a --- /dev/null +++ b/Installer.Tests/BuildConnectionStringTests.cs @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using Installer.Core; +using Microsoft.Data.SqlClient; + +namespace Installer.Tests; + +/// <summary> +/// Unit tests for InstallationService.BuildConnectionString — the auth-mode mapping shared by the +/// CLI installer flags (Windows / SQL / --entra / --managed-identity / --service-principal, #1325). +/// Assertions round-trip the produced string through SqlConnectionStringBuilder so they check the +/// typed Authentication/UserID/Password/IntegratedSecurity properties rather than brittle text. +/// These run without a database. +/// </summary> +public class BuildConnectionStringTests +{ + private static SqlConnectionStringBuilder Parse(string connectionString) => new(connectionString); + + [Fact] + public void WindowsAuth_UsesIntegratedSecurity_NoUserId() + { + var b = Parse(InstallationService.BuildConnectionString("SQL2022", useWindowsAuth: true)); + + Assert.True(b.IntegratedSecurity); + Assert.Equal(SqlAuthenticationMethod.NotSpecified, b.Authentication); + Assert.True(string.IsNullOrEmpty(b.UserID)); + Assert.Equal("master", b.InitialCatalog); + } + + [Fact] + public void SqlAuth_SetsUserIdAndPassword_NoEntraAuth() + { + var b = Parse(InstallationService.BuildConnectionString( + "SQL2022", useWindowsAuth: false, username: "sa", password: "p@ss!")); + + Assert.False(b.IntegratedSecurity); + Assert.Equal(SqlAuthenticationMethod.NotSpecified, b.Authentication); + Assert.Equal("sa", b.UserID); + Assert.Equal("p@ss!", b.Password); + } + + [Fact] + public void EntraInteractive_SetsInteractiveAuth_WithUpn() + { + var b = Parse(InstallationService.BuildConnectionString( + "SQL2022", useWindowsAuth: false, username: "me@corp.com", useEntraAuth: true)); + + Assert.Equal(SqlAuthenticationMethod.ActiveDirectoryInteractive, b.Authentication); + Assert.Equal("me@corp.com", b.UserID); + } + + [Fact] + public void ServicePrincipal_SetsSpAuth_WithClientIdAndSecret() + { + var b = Parse(InstallationService.BuildConnectionString( + "mi.database.windows.net", useWindowsAuth: false, + username: "client-id", password: "the-secret", authenticationType: "ServicePrincipal")); + + Assert.Equal(SqlAuthenticationMethod.ActiveDirectoryServicePrincipal, b.Authentication); + Assert.Equal("client-id", b.UserID); + Assert.Equal("the-secret", b.Password); + } + + [Fact] + public void ServicePrincipal_FallsBackToAzureClientId_WhenUsernameNull() + { + var b = Parse(InstallationService.BuildConnectionString( + "mi.database.windows.net", useWindowsAuth: false, + username: null, password: "the-secret", + authenticationType: "ServicePrincipal", azureClientId: "app-id")); + + Assert.Equal(SqlAuthenticationMethod.ActiveDirectoryServicePrincipal, b.Authentication); + Assert.Equal("app-id", b.UserID); + } + + [Fact] + public void ManagedIdentity_SystemAssigned_OmitsUserId() + { + var b = Parse(InstallationService.BuildConnectionString( + "mi.database.windows.net", useWindowsAuth: false, authenticationType: "ManagedIdentity")); + + Assert.Equal(SqlAuthenticationMethod.ActiveDirectoryManagedIdentity, b.Authentication); + Assert.True(string.IsNullOrEmpty(b.UserID)); + } + + [Fact] + public void ManagedIdentity_UserAssigned_SetsClientIdAsUserId() + { + var b = Parse(InstallationService.BuildConnectionString( + "mi.database.windows.net", useWindowsAuth: false, + authenticationType: "ManagedIdentity", managedIdentityClientId: "mi-client-id")); + + Assert.Equal(SqlAuthenticationMethod.ActiveDirectoryManagedIdentity, b.Authentication); + Assert.Equal("mi-client-id", b.UserID); + } + + [Fact] + public void AuthenticationType_TakesPrecedence_OverWindowsAndEntraInteractiveFlags() + { + /* An explicit non-interactive Entra type must win even if the older + useWindowsAuth / useEntraAuth flags are also set by the caller. */ + var b = Parse(InstallationService.BuildConnectionString( + "mi.database.windows.net", useWindowsAuth: true, + username: "client-id", password: "the-secret", + useEntraAuth: true, authenticationType: "ServicePrincipal")); + + Assert.Equal(SqlAuthenticationMethod.ActiveDirectoryServicePrincipal, b.Authentication); + Assert.False(b.IntegratedSecurity); + } + + [Theory] + [InlineData("Optional")] + [InlineData("Mandatory")] + [InlineData("Strict")] + public void Encryption_MapsToBuilderOption(string level) + { + var b = Parse(InstallationService.BuildConnectionString( + "SQL2022", useWindowsAuth: true, encryption: level)); + + var expected = level switch + { + "Optional" => SqlConnectionEncryptOption.Optional, + "Strict" => SqlConnectionEncryptOption.Strict, + _ => SqlConnectionEncryptOption.Mandatory + }; + Assert.Equal(expected, b.Encrypt); + } +} diff --git a/Installer.Tests/CleanInstallCancellationTests.cs b/Installer.Tests/CleanInstallCancellationTests.cs new file mode 100644 index 000000000..c20b3b99c --- /dev/null +++ b/Installer.Tests/CleanInstallCancellationTests.cs @@ -0,0 +1,73 @@ +using Installer.Core; + +namespace Installer.Tests; + +/// <summary> +/// Pins the one thing a cancelled clean install must never do: come back looking like a completed run. +/// +/// CleanInstallAsync drops the three Agent jobs, both Extended Events sessions, and then the DATABASE +/// (SET SINGLE_USER WITH ROLLBACK IMMEDIATE, DROP DATABASE) -- all of it BEFORE a single install file +/// runs. Cancelling in that window is the most destructive and least recoverable moment in the whole +/// installer, and it was the one window with no cancellation check in front of it. +/// +/// A cancelled SqlCommand faults with SqlException, not OperationCanceledException, so the clean-install +/// catch swallowed the cancel as an ordinary "failure" and RETURNED NORMALLY. The Dashboard's cancel path +/// never ran. The user was told "Installation completed with 1 error(s)" over a database that may already +/// have been dropped; the dialog restored the verdict it had deliberately discarded for safety; and +/// because Save skips the connection test once that verdict is stamped, the server could then be saved +/// without ever being reconnected to. +/// +/// These need no database precisely because the guard must fire BEFORE anything is contacted -- the +/// connection string points at a host that must never be reached. If the guard regresses, the test stops +/// seeing OperationCanceledException and starts seeing a SQL error or a timeout, which is exactly the +/// failure it exists to catch. +/// </summary> +public class CleanInstallCancellationTests +{ + /* Deliberately unreachable. Contacting it at all is the bug. */ + private const string UnreachableServer = + "Server=this-host-must-never-be-contacted.invalid;Database=master;" + + "User Id=nobody;Password=nobody;Connect Timeout=1;TrustServerCertificate=true"; + + [Fact] + public async Task CancelledBeforeStart_CleanInstall_Cancels_AndNeverContactsTheServer() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync<OperationCanceledException>(() => + InstallationService.ExecuteInstallationAsync( + UnreachableServer, + ScriptProvider.FromEmbeddedResources(), + cleanInstall: true, + cancellationToken: cts.Token)); + } + + [Fact] + public async Task CancelledBeforeStart_OrdinaryInstall_CancelsToo() + { + /* The guard sits ahead of the clean-install branch, so it covers both shapes of run. */ + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync<OperationCanceledException>(() => + InstallationService.ExecuteInstallationAsync( + UnreachableServer, + ScriptProvider.FromEmbeddedResources(), + cleanInstall: false, + cancellationToken: cts.Token)); + } + + /* + COVERAGE BOUNDARY, stated honestly. These pin the PRE-file windows -- a token already cancelled trips + the guard at the top of ExecuteInstallationAsync (and the loop-top guard) before any command runs, so + they never exercise a cancel that lands INSIDE a running SqlCommand. + + That third window -- a cancel mid-file, which faults as SqlException rather than OperationCanceledException + and used to be miscounted as a file failure -- is handled by a dedicated `catch when + (cancellationToken.IsCancellationRequested)` that mirrors the clean-install branch. It can only be + exercised end to end against a live server (a connection that opens, then a cancel during execution), + which these DB-free tests must not do. It is covered by click-through item: cancel a normal install while + a file is executing and confirm the dialog shows "cancelled", not "completed with N error(s)". + */ +} diff --git a/Installer.Tests/Helpers/TestDatabaseHelper.cs b/Installer.Tests/Helpers/TestDatabaseHelper.cs index 05bdb6494..281e3ff93 100644 --- a/Installer.Tests/Helpers/TestDatabaseHelper.cs +++ b/Installer.Tests/Helpers/TestDatabaseHelper.cs @@ -32,10 +32,13 @@ public static async Task DropTestDatabaseAsync() using var connection = new SqlConnection(GetConnectionString()); await connection.OpenAsync(TestContext.Current.CancellationToken); + // EngineEdition 8 = Azure SQL Managed Instance, where SINGLE_USER is non-modifiable. These tests + // target box SQL Server, but the gate keeps the pattern correct so it is safe to copy from. using var cmd = new SqlCommand($@" IF DB_ID(N'{TestDatabaseName}') IS NOT NULL BEGIN - ALTER DATABASE [{TestDatabaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + IF SERVERPROPERTY('EngineEdition') <> 8 + ALTER DATABASE [{TestDatabaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{TestDatabaseName}]; END;", connection); await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); diff --git a/Installer.Tests/InstallGuardTests.cs b/Installer.Tests/InstallGuardTests.cs new file mode 100644 index 000000000..82cad2da2 --- /dev/null +++ b/Installer.Tests/InstallGuardTests.cs @@ -0,0 +1,78 @@ +using Installer.Core; + +namespace Installer.Tests; + +/// <summary> +/// Pins InstallGuard, the decision behind both installers' pre-install blocks. +/// +/// Both of the worst defects found while building this feature lived here, and BOTH are invisible to +/// every other guard: a version we cannot compare, or a server NEWER than this build, produces ZERO +/// upgrade hops and ZERO failures -- so the migration-failure abort never fires and nothing downstream +/// notices. The install then runs this binary's older scripts over the newer database, reverting every +/// CREATE OR ALTER procedure and view, and records the LOWER version as SUCCESS. Version detection reads +/// back the most recent SUCCESS row, so that silently strands every migration in between. +/// +/// Lives in Installer.Tests, not Dashboard.Tests, because Dashboard.Tests is not wired into CI -- tests +/// that cannot fail a PR are decoration. +/// </summary> +public class InstallGuardTests +{ + [Fact] + public void NoDatabase_IsAllowed() + { + /* Nothing installed: a fresh install is safe. */ + Assert.Equal(InstallBlock.None, InstallGuard.Check(null, "3.1.0")); + } + + [Theory] + [InlineData("3.1.0", "3.1.0")] + [InlineData("3.0.0", "3.1.0")] + [InlineData("3.1.0.0", "3.1.0")] + [InlineData("2.9", "3.1.0")] + [InlineData("3.0.0", "3.1.0+abc123")] + [InlineData("3.0.0", "3.2.0-rc1")] + public void SameOrOlderThanThisBuild_IsAllowed(string installed, string build) + { + Assert.Equal(InstallBlock.None, InstallGuard.Check(installed, build)); + } + + [Theory] + [InlineData("3.2.0", "3.1.0")] + [InlineData("3.1.1", "3.1.0")] + [InlineData("4.0.0", "3.1.0.0")] + [InlineData("3.2.0", "3.1.0+abc123")] + public void NewerThanThisBuild_IsBlocked(string installed, string build) + { + /* The silent downgrade: older scripts over a newer schema, then the LOWER version recorded. */ + Assert.Equal(InstallBlock.InstalledIsNewerThanBuild, InstallGuard.Check(installed, build)); + } + + [Theory] + [InlineData("Unreachable")] + [InlineData("Not installed")] + [InlineData("")] + [InlineData(" ")] + public void UnreadableInstalledVersion_IsBlocked(string installed) + { + Assert.Equal(InstallBlock.UnreadableInstalledVersion, InstallGuard.Check(installed, "3.1.0")); + } + + [Fact] + public void UnreadableBuildVersion_IsBlocked_AndBlamesTheBuild() + { + /* Nothing the user does to the database fixes a malformed InformationalVersion. */ + Assert.Equal(InstallBlock.UnreadableBuildVersion, InstallGuard.Check("3.0.0", "not-a-version")); + } + + [Fact] + public void UnreadableBuildVersion_IsBlocked_EvenOnAFreshServer() + { + /* + Regression: the build-version check used to sit BELOW the no-database early return, so a fresh + server sailed through -- and the build version is exactly what a fresh install writes to + installation_history.installer_version. That poisoned the ledger at birth, after which every + surface refuses to touch the server ever again. + */ + Assert.Equal(InstallBlock.UnreadableBuildVersion, InstallGuard.Check(null, "not-a-version")); + } +} diff --git a/Installer.Tests/InstalledVersionClassifierTests.cs b/Installer.Tests/InstalledVersionClassifierTests.cs new file mode 100644 index 000000000..64bdbc779 --- /dev/null +++ b/Installer.Tests/InstalledVersionClassifierTests.cs @@ -0,0 +1,114 @@ +using Installer.Core; + +namespace Installer.Tests; + +/// <summary> +/// Pins InstalledVersionClassifier — the decision that separates "nothing is installed, do a clean +/// install" from "something is installed, work out which migrations still need to run". +/// +/// It exists as a pure function precisely so these can run in CI. The SQL that feeds it needs a live +/// SQL Server, and that suite (VersionDetectionTests) is excluded from the CI filter — so before this +/// split, the single most consequential branch in the installer had no test that could fail a PR. +/// </summary> +public class InstalledVersionClassifierTests +{ + [Fact] + public void NoDatabase_IsACleanInstall() + { + Assert.Null(InstalledVersionClassifier.Classify( + databaseExists: false, historyTableExists: false, collectTableCount: 0, latestSuccessVersion: null)); + } + + [Fact] + public void EmptyPreCreatedDatabase_IsACleanInstall() + { + /* + Someone created the database themselves (commonly to control the data/log file paths) and then ran + the installer. Nothing is installed. Answering "installed" here would attempt migrations against + tables that do not exist, and every one of them would fail. + */ + Assert.Null(InstalledVersionClassifier.Classify( + databaseExists: true, historyTableExists: false, collectTableCount: 0, latestSuccessVersion: null)); + } + + [Fact] + public void LedgerLostButCollectObjectsPresent_IsUnknown_NotAFreshInstall() + { + /* + THE STRANDING BUG. A PerformanceMonitor database whose config.installation_history was dropped. + Answering "clean install" made the install scripts run over the LIVE schema, skip every migration, + and stamp the target version SUCCESS -- and since version detection reads back the most recent + SUCCESS row, those hops were never offered again. Permanently stranded, with no failed migration + anywhere in sight. + */ + Assert.Equal( + InstallationService.UnknownVersionSentinel, + InstalledVersionClassifier.Classify( + databaseExists: true, historyTableExists: false, collectTableCount: 62, latestSuccessVersion: null)); + } + + [Fact] + public void LedgerPresentWithSuccessRow_IsThatVersion() + { + Assert.Equal( + "3.0.0", + InstalledVersionClassifier.Classify( + databaseExists: true, historyTableExists: true, collectTableCount: 62, latestSuccessVersion: "3.0.0")); + } + + [Fact] + public void LedgerPresentButNoSuccessRow_IsUnknown_Regression538() + { + /* + #538: a prior install that never wrote a SUCCESS row. Treating it as a fresh install would drop + the database. "Unknown" means attempt every upgrade, which is safe -- every upgrade script is + IF NOT EXISTS-guarded and replays cleanly. + */ + Assert.Equal( + InstallationService.UnknownVersionSentinel, + InstalledVersionClassifier.Classify( + databaseExists: true, historyTableExists: true, collectTableCount: 62, latestSuccessVersion: null)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void LedgerPresentButVersionIsBlank_IsUnknown_NotAnAnswer(string blank) + { + /* + installer_version is NOT NULL, so a row hand-edited to '' comes back as "" rather than null -- and + the "unreadable version" message we show an operator invites exactly that edit. "" is not a version: + FilterUpgrades resolves it to ZERO applicable hops, which reads as "already current" and strands + every pending migration, silently and permanently. Blank is absence of an answer, not an answer. + + InstallGuard rejects "" on every path today, so this is the second lock on the same door. The ledger + is where a second lock earns its keep. + */ + Assert.Equal( + InstallationService.UnknownVersionSentinel, + InstalledVersionClassifier.Classify( + databaseExists: true, historyTableExists: true, collectTableCount: 62, latestSuccessVersion: blank)); + } + + [Fact] + public void TheSentinelSortsBelowEveryRealVersion_SoEveryHopIsOffered() + { + /* Its whole job. If it ever stopped sorting below a real version, hops would be skipped silently. */ + var sentinel = ScriptProvider.TryParseVersionCore(InstallationService.UnknownVersionSentinel); + + Assert.NotNull(sentinel); + Assert.True(sentinel < ScriptProvider.TryParseVersionCore("1.2.0")); + Assert.True(sentinel < ScriptProvider.TryParseVersionCore("3.1.0")); + } + + [Fact] + public void TheSentinelIsNotARealShippedVersion() + { + /* + It used to be "1.0.0" -- a shipped tag. The "your recorded version is unreadable" message tells + operators to correct the SUCCESS row by hand, and "1.0.0" is exactly what someone would type to + force a full re-run; they would then be told their version could not be read. + */ + Assert.Equal("0.0.0", InstallationService.UnknownVersionSentinel); + } +} diff --git a/Installer.Tests/RepairOutcomeTests.cs b/Installer.Tests/RepairOutcomeTests.cs new file mode 100644 index 000000000..adae8b0c6 --- /dev/null +++ b/Installer.Tests/RepairOutcomeTests.cs @@ -0,0 +1,132 @@ +using Installer.Core; + +namespace Installer.Tests; + +/// <summary> +/// Pins RepairOutcome, which both the Dashboard and the CLI depend on and which the CHANGELOG states as +/// a contract: a repair with a pending upgrade exits 0 even with failed files; a repair without one does +/// not. Getting it wrong is dangerous in BOTH directions -- reporting a good repair as a failure sends +/// the operator reaching for a destructive reinstall, and reporting a broken one as a success ships +/// genuinely uncompiled procedures past a CI gate. +/// </summary> +public class RepairOutcomeTests +{ + [Fact] + public void PendingUpgrade_FailuresAreExpected() + { + // The install scripts cannot compile against a schema the pending migration has not touched yet. + Assert.True(RepairOutcome.FailuresAreExpected( + repairRan: true, installedVersion: "3.0.0", targetVersion: "3.1.0", criticalFileFailed: false)); + } + + [Fact] + public void NoPendingUpgrade_FailuresAreReal() + { + // Nothing to blame: there is no migration-added column to be missing, so these are real errors. + Assert.False(RepairOutcome.FailuresAreExpected( + repairRan: true, installedVersion: "3.1.0", targetVersion: "3.1.0", criticalFileFailed: false)); + } + + [Fact] + public void ServerNewerThanBinary_FailuresAreReal() + { + Assert.False(RepairOutcome.FailuresAreExpected( + repairRan: true, installedVersion: "3.2.0", targetVersion: "3.1.0", criticalFileFailed: false)); + } + + [Fact] + public void CriticalFileFailed_IsNeverExpected() + { + // A critical file aborts the whole pass, so the repair reinstalled nothing and really did fail -- + // regardless of whether an upgrade is pending. + Assert.False(RepairOutcome.FailuresAreExpected( + repairRan: true, installedVersion: "3.0.0", targetVersion: "3.1.0", criticalFileFailed: true)); + } + + [Fact] + public void NotARepair_IsNeverExpected() + { + Assert.False(RepairOutcome.FailuresAreExpected( + repairRan: false, installedVersion: "3.0.0", targetVersion: "3.1.0", criticalFileFailed: false)); + } + + [Theory] + [InlineData(null, "3.1.0")] + [InlineData("3.0.0", null)] + [InlineData("Unreachable", "3.1.0")] + [InlineData("3.0.0", "not-a-version")] + public void UncomparableVersions_AreNeverExpected(string? installed, string? target) + { + // No comparable versions means no basis for the "expected failure" story. + Assert.False(RepairOutcome.FailuresAreExpected( + repairRan: true, installedVersion: installed, targetVersion: target, criticalFileFailed: false)); + } + + [Fact] + public void UnknownVersionSentinel_IsNeverExpected() + { + /* + The sentinel is GetInstalledVersionAsync's guess for "installed, but I cannot read the version" -- not + a fact. It sorts below every real version, so trusting it would answer "an upgrade is pending" + unconditionally and report every REAL repair failure as expected, exiting 0. A schema-current + 3.1.0 server whose history rows are all FAILED, with genuinely broken procedures, must not pass. + */ + Assert.False(RepairOutcome.FailuresAreExpected( + repairRan: true, + installedVersion: InstallationService.UnknownVersionSentinel, + targetVersion: "3.1.0", + criticalFileFailed: false)); + } + + [Fact] + public void UnknownSentinel_AnswersTheTwoQuestionsDIFFERENTLY() + { + /* + The whole point of splitting them. For an unreadable version: + - "may I excuse these file failures?" -> NO. We cannot certify a success we cannot explain. + - "is there an upgrade to run next?" -> YES. Every hop may be pending. + + Answering both with one flag printed "already at the current version, so there is no upgrade to + apply" for a server with eleven migrations waiting, and withheld the button that would have + applied them. + */ + const string sentinel = InstallationService.UnknownVersionSentinel; + + Assert.False(RepairOutcome.FailuresAreExpected( + repairRan: true, installedVersion: sentinel, targetVersion: "3.1.0", criticalFileFailed: false)); + + Assert.True(RepairOutcome.HasPendingUpgrade(sentinel, "3.1.0")); + Assert.True(RepairOutcome.IsVersionUnknown(sentinel)); + } + + [Theory] + [InlineData("3.1.0", "3.1.0", false)] + [InlineData("3.0.0", "3.1.0", true)] + [InlineData("3.2.0", "3.1.0", false)] + [InlineData("Unreachable", "3.1.0", false)] + [InlineData(null, "3.1.0", false)] + public void HasPendingUpgrade_KeysOnlyOnVersionOrder(string? installed, string target, bool expected) + { + Assert.Equal(expected, RepairOutcome.HasPendingUpgrade(installed, target)); + } + + [Theory] + [InlineData("3.1.0")] + [InlineData("Unreachable")] + [InlineData(null)] + public void IsVersionUnknown_OnlyTheSentinel(string? installed) + { + Assert.False(RepairOutcome.IsVersionUnknown(installed)); + } + + [Theory] + [InlineData("3.0.0", "3.1.0.0")] + [InlineData("2.9", "3.1.0")] + [InlineData("3.0.0", "3.1.0+abc123")] + [InlineData("3.0.0-rc1", "3.1.0")] + public void SemVerSuffixesAndPartLengths_StillCompare(string installed, string target) + { + Assert.True(RepairOutcome.FailuresAreExpected( + repairRan: true, installedVersion: installed, targetVersion: target, criticalFileFailed: false)); + } +} diff --git a/Installer.Tests/ServerSetupPolicyTests.cs b/Installer.Tests/ServerSetupPolicyTests.cs new file mode 100644 index 000000000..1bfc8bf1f --- /dev/null +++ b/Installer.Tests/ServerSetupPolicyTests.cs @@ -0,0 +1,154 @@ +using Installer.Core; + +namespace Installer.Tests; + +/// <summary> +/// The state×predicate matrix for the add/edit-server flow. This is the test that pays for extracting +/// ServerSetupState / ServerSetupPolicy out of the (un-CI-tested) WPF dialog: every predicate is asserted +/// for EVERY state, so a predicate that silently omits a state — the exact bug that recurred in the dialog +/// (IsServerVerdictState listing three states while PromisesExistingInstall listed five; the destructive- +/// checkbox rule missing an exit) — now fails a test instead of only misbehaving in the UI. +/// +/// Each predicate has one [InlineData] per enum member, and a guard test asserts the enum still has exactly +/// the eight members these tables were written against, so adding a ninth forces this file to be updated. +/// </summary> +public class ServerSetupPolicyTests +{ + [Fact] + public void TheEnumHasExactlyTheEightStatesTheseTablesCover() + { + // If this fails, a state was added or removed: extend every [Theory] below to cover it, on purpose. + Assert.Equal(8, Enum.GetValues<ServerSetupState>().Length); + } + + // ---- IsServerVerdictState: states that assert a discovered fact about the server on screen ---- + + [Theory] + [InlineData(ServerSetupState.Connected_NoDatabase, true)] + [InlineData(ServerSetupState.Connected_NeedsUpgrade, true)] + [InlineData(ServerSetupState.Connected_Current, true)] + [InlineData(ServerSetupState.Initial, false)] + [InlineData(ServerSetupState.Connected_StatusUnknown, false)] // where a demotion LANDS -- not a verdict + [InlineData(ServerSetupState.Installing, false)] + [InlineData(ServerSetupState.InstallComplete, false)] + [InlineData(ServerSetupState.MonitoringCredentials, false)] + public void IsServerVerdictState_IsExactlyTheThreeConnectedVerdicts(ServerSetupState state, bool expected) + { + Assert.Equal(expected, ServerSetupPolicy.IsServerVerdictState(state)); + } + + // ---- PromisesExistingInstall: did the launched button act on an EXISTING install? ---- + + [Theory] + [InlineData(ServerSetupState.Connected_NeedsUpgrade, true)] // "Upgrade Now" + [InlineData(ServerSetupState.Connected_Current, true)] // "Reinstall Objects" + [InlineData(ServerSetupState.InstallComplete, true)] // repair handoff: "Upgrade Now" + [InlineData(ServerSetupState.MonitoringCredentials, true)] // repair handoff, SQL auth: "Upgrade Now" + [InlineData(ServerSetupState.Connected_NoDatabase, false)] // "Install Now" + [InlineData(ServerSetupState.Initial, false)] + [InlineData(ServerSetupState.Connected_StatusUnknown, false)] + [InlineData(ServerSetupState.Installing, false)] + public void PromisesExistingInstall_IncludesTheTwoRepairHandoffStates(ServerSetupState state, bool expected) + { + Assert.Equal(expected, ServerSetupPolicy.PromisesExistingInstall(state)); + } + + // ---- StateConsumesModeSelections: the ONLY state allowed to keep the destructive checkboxes ---- + + [Theory] + [InlineData(ServerSetupState.Installing, true)] + [InlineData(ServerSetupState.Initial, false)] + [InlineData(ServerSetupState.Connected_NoDatabase, false)] + [InlineData(ServerSetupState.Connected_NeedsUpgrade, false)] + [InlineData(ServerSetupState.Connected_Current, false)] + [InlineData(ServerSetupState.Connected_StatusUnknown, false)] + [InlineData(ServerSetupState.InstallComplete, false)] + [InlineData(ServerSetupState.MonitoringCredentials, false)] + public void StateConsumesModeSelections_IsInstallingAndNothingElse(ServerSetupState state, bool expected) + { + // Every state but Installing must clear clean-install (DROP DATABASE) / repair / reset-schedule + // (TRUNCATE). A regression here is how a live destructive tick survived onto a server that never + // consented -- the bug that reopened repeatedly when the rule was enforced exit-by-exit. + Assert.Equal(expected, ServerSetupPolicy.StateConsumesModeSelections(state)); + } + + [Fact] + public void IsInstalling_MatchesTheInstallingState() + { + foreach (var state in Enum.GetValues<ServerSetupState>()) + { + Assert.Equal(state == ServerSetupState.Installing, ServerSetupPolicy.IsInstalling(state)); + } + } + + // ---- The relationships between the predicates, pinned because getting them "consistent" was a bug ---- + + [Fact] + public void RepairHandoffStates_PromiseAnExistingInstall_ButAreNotVerdictStates() + { + // The deliberate disagreement. InstallComplete / MonitoringCredentials carry a live "Upgrade Now" + // (so they promise an existing install) but assert no fresh verdict about the box (so they are not + // verdict states). A round of review "fixed" this by making them agree and reintroduced a bug. + foreach (var handoff in new[] { ServerSetupState.InstallComplete, ServerSetupState.MonitoringCredentials }) + { + Assert.True(ServerSetupPolicy.PromisesExistingInstall(handoff)); + Assert.False(ServerSetupPolicy.IsServerVerdictState(handoff)); + } + } + + [Fact] + public void Installing_IsNeitherAVerdictNorAPromiseState() + { + // It consumes the ticks; it must not also be treated as a server verdict or a launched promise. + Assert.True(ServerSetupPolicy.StateConsumesModeSelections(ServerSetupState.Installing)); + Assert.False(ServerSetupPolicy.IsServerVerdictState(ServerSetupState.Installing)); + Assert.False(ServerSetupPolicy.PromisesExistingInstall(ServerSetupState.Installing)); + } + + // ---- DeriveConnectedState: which connected state the FACTS imply ---- + + [Fact] + public void DeriveConnectedState_NullVersion_IsNoDatabase() + { + Assert.Equal( + ServerSetupState.Connected_NoDatabase, + ServerSetupPolicy.DeriveConnectedState(installedVersion: null, appVersion: "3.1.0")); + } + + [Fact] + public void DeriveConnectedState_OlderVersion_NeedsUpgrade() + { + Assert.Equal( + ServerSetupState.Connected_NeedsUpgrade, + ServerSetupPolicy.DeriveConnectedState("3.0.0", "3.1.0")); + } + + [Fact] + public void DeriveConnectedState_SameVersion_IsCurrent() + { + Assert.Equal( + ServerSetupState.Connected_Current, + ServerSetupPolicy.DeriveConnectedState("3.1.0", "3.1.0")); + } + + [Fact] + public void DeriveConnectedState_UnknownSentinel_NeedsUpgrade() + { + // The sentinel sorts below every real version, so "unknown installed version" correctly reads as + // "an upgrade is pending" -- attempt every hop, the safe direction. + Assert.Equal( + ServerSetupState.Connected_NeedsUpgrade, + ServerSetupPolicy.DeriveConnectedState(InstallationService.UnknownVersionSentinel, "3.1.0")); + } + + [Fact] + public void DeriveConnectedState_NewerThanBinary_IsCurrent_BecauseBlockingItIsInstallGuardsJob() + { + // Not "NeedsUpgrade" -- there is no hop to a lower version. Classifying a newer-than-binary database + // as Current is correct HERE; refusing to install over it is a separate decision (InstallGuard), + // made before this state is ever rendered. DeriveConnectedState only answers "is an upgrade pending". + Assert.Equal( + ServerSetupState.Connected_Current, + ServerSetupPolicy.DeriveConnectedState("3.2.0", "3.1.0")); + } +} diff --git a/Installer.Tests/SummaryReportPathTests.cs b/Installer.Tests/SummaryReportPathTests.cs new file mode 100644 index 000000000..d39661f13 --- /dev/null +++ b/Installer.Tests/SummaryReportPathTests.cs @@ -0,0 +1,56 @@ +using Installer.Core; +using Installer.Core.Models; + +namespace Installer.Tests; + +/// <summary> +/// GenerateSummaryReport builds a filename out of the user-typed server name, and both front ends call it. +/// It used to strip only the backslash, so a server name like "x/../../foo" turned Path.Combine into a +/// write OUTSIDE the report directory -- forward slash is a separator and ".." is parent. These pin that +/// the report always lands inside the directory it was given, whatever the server name contains. +/// </summary> +public class SummaryReportPathTests +{ + private static InstallationResult AResult() => new() + { + Success = true, + FilesSucceeded = 1, + StartTime = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + EndTime = new DateTime(2026, 1, 1, 0, 0, 1, DateTimeKind.Utc) + }; + + [Theory] + [InlineData(@"x/../../../../Windows/Temp/pwn")] // forward-slash traversal + [InlineData(@"..\..\..\..\Users\Public\pwn")] // backslash traversal + [InlineData(@"SQL01:evil")] // NTFS alternate data stream + [InlineData(@"a/b/c")] // plain separators + [InlineData(@"SQL01\INSTANCE")] // ordinary, legitimate instance name + public void Report_AlwaysLandsInsideTheGivenDirectory(string serverName) + { + string dir = Path.Combine(Path.GetTempPath(), "pm_report_test_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + string written = InstallationService.GenerateSummaryReport( + serverName, "v", "Edition", "3.1.0", AResult(), dir); + + string fullDir = Path.GetFullPath(dir); + string fullWritten = Path.GetFullPath(written); + + Assert.StartsWith( + fullDir.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, + fullWritten, + StringComparison.OrdinalIgnoreCase); + + // And it is directly in the directory -- one segment, no traversal survived. + Assert.Equal(fullDir.TrimEnd(Path.DirectorySeparatorChar), + Path.GetDirectoryName(fullWritten)!.TrimEnd(Path.DirectorySeparatorChar)); + + Assert.True(File.Exists(written)); + } + finally + { + try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ } + } + } +} diff --git a/Installer.Tests/UpgradeOrderingTests.cs b/Installer.Tests/UpgradeOrderingTests.cs index 1346dfe1d..66d9b887f 100644 --- a/Installer.Tests/UpgradeOrderingTests.cs +++ b/Installer.Tests/UpgradeOrderingTests.cs @@ -116,6 +116,145 @@ public void NullCurrentVersion_ReturnsEmpty() Assert.Empty(upgrades); } + [Theory] + [InlineData("")] + [InlineData(" ")] + public void BlankCurrentVersion_ReturnsEmpty(string currentVersion) + { + using var dir = new TempDirectoryBuilder() + .WithUpgrade("2.0.0", "2.1.0", "01_columns.sql"); + + var upgrades = ScriptProvider.FromDirectory(dir.RootPath).GetApplicableUpgrades(currentVersion, "2.2.0"); + + Assert.Empty(upgrades); + } + + [Theory] + [InlineData("Unreachable")] + [InlineData("Not installed")] + [InlineData("2.x")] + public void UnparseableCurrentVersion_Throws(string currentVersion) + { + using var dir = new TempDirectoryBuilder() + .WithUpgrade("2.0.0", "2.1.0", "01_columns.sql"); + + var provider = ScriptProvider.FromDirectory(dir.RootPath); + + // An empty result means "no upgrades needed". If a status string silently produced one, + // every migration would be skipped and the caller would still stamp the DB as current. + var ex = Assert.Throws<ArgumentException>( + () => provider.GetApplicableUpgrades(currentVersion, "2.2.0")); + + Assert.Equal("currentVersion", ex.ParamName); + } + + [Theory] + [InlineData("2.2.0-rc1")] + [InlineData("2.2.0+abc123")] + [InlineData("2.2.0-rc1+abc123")] + public void SemVerSuffixOnTargetVersion_IsStripped(string targetVersion) + { + using var dir = new TempDirectoryBuilder() + .WithUpgrade("2.0.0", "2.1.0", "01_columns.sql") + .WithUpgrade("2.1.0", "2.2.0", "01_compress.sql"); + + // A pre-release <InformationalVersion> must not make every upgrade abort. Without the + // suffix strip, "2.2.0-rc1" fails to parse and throws. + var upgrades = ScriptProvider.FromDirectory(dir.RootPath).GetApplicableUpgrades("2.0.0", targetVersion); + + Assert.Equal(2, upgrades.Count); + } + + [Fact] + public void TwoPartVersion_IsTreatedAsThreePart() + { + using var dir = new TempDirectoryBuilder() + .WithUpgrade("2.0.0", "2.1.0", "01_columns.sql") + .WithUpgrade("2.1.0", "2.2.0", "01_compress.sql"); + + // Version.TryParse("2.0") leaves Build == -1, which the Version(int,int,int) ctor rejects. + var upgrades = ScriptProvider.FromDirectory(dir.RootPath).GetApplicableUpgrades("2.0", "2.2"); + + Assert.Equal(2, upgrades.Count); + } + + [Fact] + public void UnparseableTargetVersion_Throws() + { + using var dir = new TempDirectoryBuilder() + .WithUpgrade("2.0.0", "2.1.0", "01_columns.sql"); + + var provider = ScriptProvider.FromDirectory(dir.RootPath); + + var ex = Assert.Throws<ArgumentException>( + () => provider.GetApplicableUpgrades("2.0.0", "not-a-version")); + + Assert.Equal("targetVersion", ex.ParamName); + } + + [Theory] + [InlineData("3.1.0", 3, 1, 0)] + [InlineData("3.1.0.4", 3, 1, 0)] + [InlineData("3.1", 3, 1, 0)] + [InlineData("3.2.0-rc1", 3, 2, 0)] + [InlineData("3.2.0+abc123", 3, 2, 0)] + [InlineData("3.2.0-rc1+abc123", 3, 2, 0)] + [InlineData(" 3.2.0 ", 3, 2, 0)] + public void TryParseVersionCore_ParsesRealVersions(string input, int major, int minor, int build) + { + Assert.Equal(new Version(major, minor, build), ScriptProvider.TryParseVersionCore(input)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("Unreachable")] + [InlineData("Not installed")] + [InlineData("v1.2.3")] + [InlineData("-rc1")] + [InlineData("+sha")] + public void TryParseVersionCore_ReturnsNullRatherThanThrowing(string? input) + { + // Callers that need to DECIDE something about a version (is it newer than me? is it + // readable?) must get null, not an exception -- unlike FilterUpgrades, which throws. + Assert.Null(ScriptProvider.TryParseVersionCore(input)); + } + + [Fact] + public void TryParseVersionCore_OrdersNewerAboveOlder() + { + // This comparison is what stops an older installer from running over a newer database and + // recording the lower version as SUCCESS -- a silent downgrade that produces zero hops and + // zero failures, so nothing else catches it. + var installed = ScriptProvider.TryParseVersionCore("3.2.0"); + var binary = ScriptProvider.TryParseVersionCore("3.1.0.0"); + + Assert.True(installed > binary); + } + + [Fact] + public async Task ExecuteAllUpgrades_UnreadableVersion_ReportsFailureRatherThanSkipping() + { + using var dir = new TempDirectoryBuilder() + .WithUpgrade("2.0.0", "2.1.0", "01_columns.sql"); + + // Callers abort when totalFailureCount > 0. Returning (0, 0, 0) here would read as + // "nothing to upgrade", and the caller would go on to stamp the database as current at + // the target version -- stranding every skipped hop. The connection string is never used: + // discovery fails before any database work. + var (successCount, failureCount, upgradeCount) = await InstallationService.ExecuteAllUpgradesAsync( + ScriptProvider.FromDirectory(dir.RootPath), + "Server=unused;Database=unused;", + "Unreachable", + "2.2.0", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(0, successCount); + Assert.Equal(1, failureCount); + Assert.Equal(0, upgradeCount); + } + [Fact] public void OrderedByFromVersion() { diff --git a/Installer.Tests/VersionDetectionTests.cs b/Installer.Tests/VersionDetectionTests.cs index 12837ac3c..241ecc141 100644 --- a/Installer.Tests/VersionDetectionTests.cs +++ b/Installer.Tests/VersionDetectionTests.cs @@ -1,3 +1,4 @@ +using Installer.Core; using Installer.Tests.Helpers; using Microsoft.Data.SqlClient; @@ -42,26 +43,57 @@ public async Task DatabaseExists_WithSuccessRow_ReturnsVersion() } [Fact] - public async Task DatabaseExists_NoHistoryTable_ReturnsNull() + public async Task DatabaseExists_NoHistoryTable_NoCollectObjects_ReturnsNull() { - // Create database but don't create the installation_history table + // An EMPTY database (someone pre-created it, e.g. to control the file paths). Nothing is + // installed, so a clean install is correct -- attempting migrations here would fail against + // tables that do not exist. await TestDatabaseHelper.CreateTestDatabaseAsync(); var version = await GetInstalledVersionFromTestDbAsync(); Assert.Null(version); } + [Fact] + public async Task DatabaseExists_NoHistoryTable_WithCollectObjects_ReturnsFallback() + { + // A PerformanceMonitor database whose LEDGER WAS LOST (someone dropped config.installation_history). + // Answering null here would read as "fresh install": the install scripts run over the live schema, + // every migration is skipped, and the target version is stamped SUCCESS -- stranding them + // permanently. It must fall back to the "attempt every upgrade" sentinel instead. + await TestDatabaseHelper.CreateTestDatabaseAsync(); + + using (var connection = new SqlConnection(TestDatabaseHelper.GetConnectionString())) + { + await connection.OpenAsync(TestContext.Current.CancellationToken); + using var cmd = new SqlCommand(@" + USE PerformanceMonitor_Test; + IF SCHEMA_ID(N'collect') IS NULL + BEGIN + EXECUTE(N'CREATE SCHEMA collect;'); + END; + IF OBJECT_ID(N'collect.wait_stats', N'U') IS NULL + BEGIN + CREATE TABLE collect.wait_stats (id integer NOT NULL); + END;", connection); + await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + var version = await GetInstalledVersionFromTestDbAsync(); + Assert.Equal(InstallationService.UnknownVersionSentinel, version); + } + [Fact] public async Task DatabaseExists_EmptyHistoryTable_ReturnsFallback_Regression538() { // This is the #538 regression test. // When installation_history exists but has NO SUCCESS rows, - // the installer must return "1.0.0" (not null), so it attempts + // the installer must return the unknown sentinel (not null), so it attempts // upgrades rather than treating the existing database as a fresh install. await TestDatabaseHelper.CreateInstallationWithNoSuccessRowsAsync(); var version = await GetInstalledVersionFromTestDbAsync(); - Assert.Equal("1.0.0", version); + Assert.Equal(InstallationService.UnknownVersionSentinel, version); } [Fact] @@ -71,7 +103,7 @@ public async Task DatabaseExists_OnlyFailedRows_ReturnsFallback() await TestDatabaseHelper.CreateInstallationWithOnlyFailedRowsAsync(); var version = await GetInstalledVersionFromTestDbAsync(); - Assert.Equal("1.0.0", version); + Assert.Equal(InstallationService.UnknownVersionSentinel, version); } [Fact] @@ -96,8 +128,13 @@ INSERT INTO config.installation_history } /// <summary> - /// Replicates the same SQL logic as InstallationService.GetInstalledVersionAsync - /// but queries PerformanceMonitor_Test instead of the hardcoded PerformanceMonitor. + /// Gathers the same FACTS as InstallationService.GetInstalledVersionAsync against the test database + /// (production hardcodes the "PerformanceMonitor" name), then hands them to the SAME decision — + /// InstalledVersionClassifier — that production uses. + /// + /// The decision is deliberately not re-implemented here. It used to be, and the copy had already + /// drifted: only the queries are test-local now, so a change to what the facts MEAN cannot pass this + /// suite while breaking production. /// </summary> private static async Task<string?> GetInstalledVersionFromTestDbAsync() { @@ -108,32 +145,66 @@ INSERT INTO config.installation_history using var connection = new SqlConnection(TestDatabaseHelper.GetConnectionString()); await connection.OpenAsync(TestContext.Current.CancellationToken); - // Check if database exists + // Does the database exist? using var dbCheckCmd = new SqlCommand($@" SELECT database_id FROM sys.databases WHERE name = N'{testDbName}';", connection); var dbExists = await dbCheckCmd.ExecuteScalarAsync(TestContext.Current.CancellationToken); - if (dbExists == null || dbExists == DBNull.Value) - return null; + bool databaseExists = dbExists != null && dbExists != DBNull.Value; + + if (!databaseExists) + { + return InstalledVersionClassifier.Classify(false, false, 0L, null); + } - // Check if installation_history table exists + // Does config.installation_history exist? using var tableCheckCmd = new SqlCommand($@" - SELECT OBJECT_ID(N'{testDbName}.config.installation_history', N'U');", connection); - var tableExists = await tableCheckCmd.ExecuteScalarAsync(TestContext.Current.CancellationToken); - if (tableExists == null || tableExists == DBNull.Value) - return null; - - // Get most recent successful version - using var versionCmd = new SqlCommand($@" - SELECT TOP 1 installer_version - FROM {testDbName}.config.installation_history - WHERE installation_status = 'SUCCESS' - ORDER BY installation_date DESC;", connection); - var version = await versionCmd.ExecuteScalarAsync(TestContext.Current.CancellationToken); - if (version != null && version != DBNull.Value) - return version.ToString(); - - // Fallback: database + table exist but no SUCCESS rows → return "1.0.0" - return "1.0.0"; + USE {testDbName}; + SELECT OBJECT_ID(N'config.installation_history', N'U');", connection); + var tableOid = await tableCheckCmd.ExecuteScalarAsync(TestContext.Current.CancellationToken); + bool historyTableExists = tableOid != null && tableOid != DBNull.Value; + + // How many collect objects? (Tells a ledger-lost install apart from an empty database.) + long collectTableCount = 0L; + if (!historyTableExists) + { + using var collectCheckCmd = new SqlCommand($@" + SELECT + COUNT_BIG(*) + FROM {testDbName}.sys.tables AS t + INNER JOIN {testDbName}.sys.schemas AS s + ON s.schema_id = t.schema_id + WHERE s.name = N'collect';", connection); + + var collectTables = await collectCheckCmd.ExecuteScalarAsync(TestContext.Current.CancellationToken); + collectTableCount = + collectTables == null || collectTables == DBNull.Value + ? 0L + : Convert.ToInt64(collectTables); + } + + // Most recent SUCCESS row, if any. + string? latestSuccessVersion = null; + if (historyTableExists) + { + using var versionCmd = new SqlCommand($@" + SELECT TOP (1) + installer_version + FROM {testDbName}.config.installation_history + WHERE installation_status = 'SUCCESS' + ORDER BY installation_date DESC;", connection); + + var version = await versionCmd.ExecuteScalarAsync(TestContext.Current.CancellationToken); + if (version != null && version != DBNull.Value) + { + latestSuccessVersion = version.ToString(); + } + } + + return InstalledVersionClassifier.Classify( + databaseExists, + historyTableExists, + collectTableCount, + latestSuccessVersion); } catch { diff --git a/Installer/PerformanceMonitorInstaller.csproj b/Installer/PerformanceMonitorInstaller.csproj index c9ae6b707..088eae9cb 100644 --- a/Installer/PerformanceMonitorInstaller.csproj +++ b/Installer/PerformanceMonitorInstaller.csproj @@ -20,10 +20,10 @@ <!-- Application metadata --> <AssemblyName>PerformanceMonitorInstaller</AssemblyName> <Product>SQL Server Performance Monitor Installer</Product> - <Version>3.1.0</Version> - <AssemblyVersion>3.1.0.0</AssemblyVersion> - <FileVersion>3.1.0.0</FileVersion> - <InformationalVersion>3.1.0</InformationalVersion> + <Version>3.2.0</Version> + <AssemblyVersion>3.2.0.0</AssemblyVersion> + <FileVersion>3.2.0.0</FileVersion> + <InformationalVersion>3.2.0</InformationalVersion> <Company>Darling Data, LLC</Company> <Copyright>Copyright © 2026 Darling Data, LLC</Copyright> <Description>Installation utility for SQL Server Performance Monitor - Supports SQL Server 2016-2025</Description> diff --git a/Installer/Program.cs b/Installer/Program.cs index e7f8db49d..dfe0b3653 100644 --- a/Installer/Program.cs +++ b/Installer/Program.cs @@ -20,9 +20,33 @@ namespace PerformanceMonitorInstaller { class Program { + /* + The unknown sentinel PARSES, so interpolating it printed "Existing installation detected: v0.0.0" + -- a guess stated as a fact, and one the same run then contradicts a few lines later with "this + server's recorded version could not be read". It is not a version; do not print it as one. + */ + static string DescribeInstalledVersion(string? version) => + RepairOutcome.IsVersionUnknown(version) + ? "Existing installation detected, but its recorded version could not be read." + : $"Existing installation detected: v{version}"; + static async Task<int> Main(string[] args) { - var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "Unknown"; + /* + GetName().Version is never null for a loaded assembly, so the old "?? Unknown" could not fire: + a build with no AssemblyVersion reports 0.0.0.0, which PARSES -- InstallGuard waves it through, + and a fresh install writes it into installation_history as the version of record. It then reads + back as version 0.0.0, which is UnknownVersionSentinel, so every later run takes that server's + real version as unreadable and replays every migration. A version-less build must land on + something UNPARSEABLE so InstallGuard blocks it (UnreadableBuildVersion). Same hole, same fix, + as the Dashboard's GetAppVersion -- the two surfaces write to the same ledger. + */ + var asmVersion = Assembly.GetExecutingAssembly().GetName().Version; + var version = + asmVersion != null && (asmVersion.Major | asmVersion.Minor | asmVersion.Build) != 0 + ? asmVersion.ToString() + : "Unknown"; + var infoVersion = Assembly.GetExecutingAssembly() .GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? version; @@ -44,6 +68,9 @@ static async Task<int> Main(string[] args) Options: --reinstall Drop existing database and perform clean install + --repair Reinstall schema objects without running upgrade scripts. Use when an + upgrade fails on a missing or damaged object; non-destructive, and the + pending upgrade still needs to run afterwards --encrypt=X Connection encryption: mandatory (default), optional, strict --trust-cert Trust server certificate without validation (default: require valid cert) --data-path DIR Server-side directory for the data (.mdf) file (first install only) @@ -58,20 +85,27 @@ static async Task<int> Main(string[] args) Console.WriteLine(" PerformanceMonitorInstaller.exe <server> <username> <password> SQL Auth"); Console.WriteLine(" PerformanceMonitorInstaller.exe <server> <username> SQL Auth (password via env var)"); Console.WriteLine(" PerformanceMonitorInstaller.exe <server> --entra <email> Entra ID (MFA)"); + Console.WriteLine(" PerformanceMonitorInstaller.exe <server> --managed-identity Entra managed identity"); + Console.WriteLine(" PerformanceMonitorInstaller.exe <server> --service-principal <id> Entra service principal"); Console.WriteLine(); Console.WriteLine("Options:"); Console.WriteLine(" -h, --help Show this help message"); Console.WriteLine(" --reinstall Drop existing database and perform clean install"); + Console.WriteLine(" --repair Reinstall schema objects, skipping upgrade scripts (non-destructive)"); Console.WriteLine(" --uninstall Remove database, Agent jobs, and XE sessions"); Console.WriteLine(" --reset-schedule Reset collection schedule to recommended defaults"); + Console.WriteLine(" --troubleshoot Run installation diagnostics (99_installer_troubleshooting.sql)"); Console.WriteLine(" --encrypt=<level> Connection encryption: mandatory (default), optional, strict"); Console.WriteLine(" --trust-cert Trust server certificate without validation"); Console.WriteLine(" --entra <email> Use Microsoft Entra ID interactive authentication (MFA)"); + Console.WriteLine(" --managed-identity[=<clientId>] Entra managed identity: system-assigned, or user-assigned via clientId"); + Console.WriteLine(" --service-principal <clientId> Entra service principal (secret via PM_AZURE_CLIENT_SECRET)"); Console.WriteLine(" --data-path <dir> Server-side directory for the data (.mdf) file (first install only)"); Console.WriteLine(" --log-path <dir> Server-side directory for the log (.ldf) file (first install only)"); Console.WriteLine(); Console.WriteLine("Environment Variables:"); - Console.WriteLine(" PM_SQL_PASSWORD SQL Auth password (avoids passing on command line)"); + Console.WriteLine(" PM_SQL_PASSWORD SQL Auth password (avoids passing on command line)"); + Console.WriteLine(" PM_AZURE_CLIENT_SECRET Service principal client secret (for --service-principal)"); Console.WriteLine(); Console.WriteLine("Exit Codes:"); Console.WriteLine(" 0 Success"); @@ -83,13 +117,35 @@ static async Task<int> Main(string[] args) Console.WriteLine(" 6 SQL files not found"); Console.WriteLine(" 7 Uninstall failed"); Console.WriteLine(" 8 Upgrade failed"); + Console.WriteLine(" 9 Clean install failed"); + Console.WriteLine(" 10 Diagnostics found errors or failed to run"); return 0; } bool automatedMode = args.Length > 0; bool reinstallMode = args.Any(a => a.Equals("--reinstall", StringComparison.OrdinalIgnoreCase)); + bool repairMode = args.Any(a => a.Equals("--repair", StringComparison.OrdinalIgnoreCase)); bool uninstallMode = args.Any(a => a.Equals("--uninstall", StringComparison.OrdinalIgnoreCase)); + + /* + --repair means "restore the objects, destroy nothing". Pairing it with a mode that drops the + database is a contradiction, and the destructive mode would otherwise win silently: --uninstall + is dispatched further down BEFORE any repair logic, and in automated mode it skips its own + confirmation. So both destructive companions are rejected here, not just --reinstall -- the + --uninstall gap was the parity hole in only guarding one of them. + */ + if (repairMode && reinstallMode) + { + WriteError("--repair and --reinstall are mutually exclusive: --reinstall drops the database, leaving nothing to repair."); + return (int)InstallationResultCode.InvalidArguments; + } + if (repairMode && uninstallMode) + { + WriteError("--repair and --uninstall are mutually exclusive: --uninstall drops the database, leaving nothing to repair."); + return (int)InstallationResultCode.InvalidArguments; + } bool resetSchedule = args.Any(a => a.Equals("--reset-schedule", StringComparison.OrdinalIgnoreCase)); + bool troubleshootMode = args.Any(a => a.Equals("--troubleshoot", StringComparison.OrdinalIgnoreCase)); bool trustCert = args.Any(a => a.Equals("--trust-cert", StringComparison.OrdinalIgnoreCase)); bool entraMode = args.Any(a => a.Equals("--entra", StringComparison.OrdinalIgnoreCase)); @@ -104,6 +160,20 @@ static async Task<int> Main(string[] args) } } + /*#1325: non-interactive Entra auth for Managed Instance / AAD-enabled targets. + --managed-identity -> system-assigned managed identity (no value) + --managed-identity=<id> / --managed-identity <id> -> user-assigned MI (client id) + --service-principal <id> -> service principal; client secret via PM_AZURE_CLIENT_SECRET. + The service layer (InstallationService.BuildConnectionString) already maps these to + SqlAuthenticationMethod.ActiveDirectoryManagedIdentity / ActiveDirectoryServicePrincipal; + this only wires the CLI surface to it.*/ + bool managedIdentityMode = args.Any(a => a.Equals("--managed-identity", StringComparison.OrdinalIgnoreCase) + || a.StartsWith("--managed-identity=", StringComparison.OrdinalIgnoreCase)); + bool servicePrincipalMode = args.Any(a => a.Equals("--service-principal", StringComparison.OrdinalIgnoreCase) + || a.StartsWith("--service-principal=", StringComparison.OrdinalIgnoreCase)); + string? managedIdentityClientId = GetOptionValue(args, "--managed-identity"); + string? servicePrincipalClientId = GetOptionValue(args, "--service-principal"); + /*Parse encryption option (default: Mandatory) Supports both --encrypt=optional and --encrypt optional */ string encryptionLevel = "Mandatory"; @@ -171,8 +241,10 @@ PerformanceMonitor data/log files on first creation.*/ /*Filter out all --flags and their trailing values to get positional arguments (server, username, password). Flags like --entra <email>, --encrypt <level>, - --data-path <dir>, and --log-path <dir> have a following value that must also - be removed.*/ + --data-path <dir>, --log-path <dir>, --service-principal <clientId>, and the + space form of --managed-identity <clientId> have a following value that must + also be removed. (The --flag=value forms are single tokens handled by the + continue below.)*/ var filteredArgsList = new List<string>(); for (int i = 0; i < args.Length; i++) { @@ -182,7 +254,9 @@ be removed.*/ if ((args[i].Equals("--entra", StringComparison.OrdinalIgnoreCase) || args[i].Equals("--encrypt", StringComparison.OrdinalIgnoreCase) || args[i].Equals("--data-path", StringComparison.OrdinalIgnoreCase) - || args[i].Equals("--log-path", StringComparison.OrdinalIgnoreCase)) + || args[i].Equals("--log-path", StringComparison.OrdinalIgnoreCase) + || args[i].Equals("--service-principal", StringComparison.OrdinalIgnoreCase) + || args[i].Equals("--managed-identity", StringComparison.OrdinalIgnoreCase)) && i + 1 < args.Length && !args[i + 1].StartsWith("--", StringComparison.Ordinal)) { i++; /*skip the value too*/ @@ -198,6 +272,10 @@ be removed.*/ string? password = null; bool useWindowsAuth; bool useEntraAuth = false; + /*#1325: "ServicePrincipal" / "ManagedIdentity" for the non-interactive Entra modes; null + leaves the Windows/SQL/Entra-interactive paths unchanged. Matched as string literals by + BuildConnectionString (Installer.Core has no reference to PerformanceMonitor.Common).*/ + string? authenticationType = null; if (automatedMode) { @@ -224,6 +302,44 @@ Automated mode with command-line arguments Console.WriteLine($"Authentication: Microsoft Entra ID ({username})"); Console.WriteLine("A browser window will open for interactive authentication..."); } + else if (servicePrincipalMode) + { + /*Microsoft Entra service principal (client id + secret, non-interactive)*/ + useWindowsAuth = false; + authenticationType = "ServicePrincipal"; + username = servicePrincipalClientId; /*application (client) id*/ + password = Environment.GetEnvironmentVariable("PM_AZURE_CLIENT_SECRET"); + + if (string.IsNullOrWhiteSpace(username)) + { + Console.WriteLine("Error: Client (application) ID is required for service principal authentication."); + Console.WriteLine("Usage: PerformanceMonitorInstaller.exe <server> --service-principal <clientId>"); + Console.WriteLine(" (client secret via the PM_AZURE_CLIENT_SECRET environment variable)"); + return (int)InstallationResultCode.InvalidArguments; + } + + if (string.IsNullOrWhiteSpace(password)) + { + Console.WriteLine("Error: Client secret is required for service principal authentication."); + Console.WriteLine("Set the PM_AZURE_CLIENT_SECRET environment variable to the application's client secret."); + return (int)InstallationResultCode.InvalidArguments; + } + + Console.WriteLine($"Server: {serverName}"); + Console.WriteLine($"Authentication: Microsoft Entra service principal ({username})"); + } + else if (managedIdentityMode) + { + /*Microsoft Entra managed identity (no secret). Blank client id = system-assigned; + a client id (--managed-identity=<id> or --managed-identity <id>) = user-assigned.*/ + useWindowsAuth = false; + authenticationType = "ManagedIdentity"; + + Console.WriteLine($"Server: {serverName}"); + Console.WriteLine(string.IsNullOrWhiteSpace(managedIdentityClientId) + ? "Authentication: Microsoft Entra managed identity (system-assigned)" + : $"Authentication: Microsoft Entra managed identity (user-assigned: {managedIdentityClientId})"); + } else if (filteredArgs.Length >= 2) { /*SQL Authentication - password from env var or command-line*/ @@ -249,6 +365,20 @@ Automated mode with command-line arguments { Console.WriteLine("Error: Password is required for SQL Server Authentication."); Console.WriteLine("Provide password as third argument or set PM_SQL_PASSWORD environment variable."); + + /* + The common trap: a password that begins with "--" (e.g. "--h7!x") is indistinguishable + from a flag, so the positional filter above dropped it and we landed here reporting + "no password" for a password that WAS supplied. Only say so when a "--"-token that is + not a recognized flag actually appeared -- otherwise this is just a forgotten password. + */ + if (HasUnrecognizedDoubleDashArg(args)) + { + Console.WriteLine(); + Console.WriteLine("Note: an argument beginning with \"--\" was treated as a flag and ignored. A password"); + Console.WriteLine(" cannot be passed on the command line if it starts with \"--\"; set PM_SQL_PASSWORD."); + } + return (int)InstallationResultCode.InvalidArguments; } @@ -270,6 +400,10 @@ Automated mode with command-line arguments Console.WriteLine(" SQL Auth: PerformanceMonitorInstaller.exe <server> <username> <password> [options]"); Console.WriteLine(" SQL Auth: PerformanceMonitorInstaller.exe <server> <username> [options]"); Console.WriteLine(" (with PM_SQL_PASSWORD environment variable set)"); + Console.WriteLine(" Entra ID: PerformanceMonitorInstaller.exe <server> --entra <email>"); + Console.WriteLine(" Managed ID: PerformanceMonitorInstaller.exe <server> --managed-identity[=<clientId>]"); + Console.WriteLine(" Service Prin.: PerformanceMonitorInstaller.exe <server> --service-principal <clientId>"); + Console.WriteLine(" (with PM_AZURE_CLIENT_SECRET environment variable set)"); Console.WriteLine(); Console.WriteLine("Options:"); Console.WriteLine(" --reinstall Drop existing database and perform clean install"); @@ -295,22 +429,21 @@ Automated mode with command-line arguments return (int)InstallationResultCode.InvalidArguments; } - Console.Write("Trust server certificate? (Y/N, default Y): "); + Console.Write("Trust server certificate? (Y/N, default N): "); string? trustResponse = Console.ReadLine()?.Trim(); - trustCert = string.IsNullOrWhiteSpace(trustResponse) - || trustResponse.Equals("Y", StringComparison.OrdinalIgnoreCase); + trustCert = trustResponse?.Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false; Console.WriteLine("Encryption level:"); - Console.WriteLine(" [O] Optional (default)"); - Console.WriteLine(" [M] Mandatory"); + Console.WriteLine(" [M] Mandatory (default)"); + Console.WriteLine(" [O] Optional"); Console.WriteLine(" [S] Strict"); - Console.Write("Choice (O/M/S, default O): "); + Console.Write("Choice (M/O/S, default M): "); string? encryptResponse = Console.ReadLine()?.Trim(); encryptionLevel = encryptResponse?.ToUpperInvariant() switch { - "M" => "Mandatory", + "O" => "Optional", "S" => "Strict", - _ => "Optional" + _ => "Mandatory" }; Console.WriteLine("Authentication type:"); @@ -376,7 +509,9 @@ Automated mode with command-line arguments password, encryptionLevel, trustCert, - useEntraAuth); + useEntraAuth, + authenticationType: authenticationType, + managedIdentityClientId: managedIdentityClientId); /* Test connection and get SQL Server version @@ -485,6 +620,12 @@ Search current directory and up to 5 parent directories } scriptProvider = ScriptProvider.FromDirectory(monitorRootDirectory); + + if (troubleshootMode) + { + return await PerformTroubleshootAsync(connectionString, scriptProvider, automatedMode); + } + var sqlFiles = scriptProvider.GetInstallFiles(); Console.WriteLine(); @@ -532,6 +673,8 @@ Main installation loop - allows retry on failure bool installationSuccessful = false; bool retry; DateTime installationStartTime = DateTime.Now; + /* Declared out here so the history write below can tell what version the database is still at. */ + string? currentVersion = null; do { retry = false; @@ -542,6 +685,8 @@ Main installation loop - allows retry on failure installationErrors.Clear(); installationSuccessful = false; installationStartTime = DateTime.Now; + /* Reset with its siblings so a clean-install iteration can never reuse the prior one's version. */ + currentVersion = null; /* Ask about clean install (automated mode preserves database unless --reinstall flag is used) @@ -568,6 +713,26 @@ Ask about clean install (automated mode preserves database unless --reinstall fl dropExisting = cleanInstall?.Trim().Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false; } + /* + Validate this binary's OWN version BEFORE the clean-install branch, not inside the upgrade + one. It is what a FRESH install writes to installation_history.installer_version, so + letting an unparseable value through poisons a brand-new server's ledger at birth -- after + which both this installer and the Dashboard refuse to touch it ever again. --reinstall IS + a fresh install, so scoping this to the upgrade path missed the exact case it names. + */ + if (ScriptProvider.TryParseVersionCore(version) == null) + { + Console.WriteLine(); + WriteError($"This installer reports its own version as '{version}', which is not a valid version."); + Console.WriteLine("Aborting: an install would record that value as the server's version."); + Console.WriteLine("This is a build problem, not a problem with the server."); + if (!automatedMode) + { + WaitForExit(); + } + return (int)InstallationResultCode.VersionCheckFailed; + } + if (dropExisting) { Console.WriteLine(); @@ -602,7 +767,6 @@ Ask about clean install (automated mode preserves database unless --reinstall fl /* Upgrade mode - check for existing installation and apply upgrades */ - string? currentVersion = null; try { currentVersion = await InstallationService.GetInstalledVersionAsync(connectionString, throwOnError: true).ConfigureAwait(false); @@ -634,10 +798,100 @@ Ask about clean install (automated mode preserves database unless --reinstall fl return (int)InstallationResultCode.VersionCheckFailed; } - if (currentVersion != null) + /* + Same decision the Dashboard makes -- shared via InstallGuard so the two cannot drift, and + pinned by tests that actually run in CI. --repair skips ExecuteAllUpgradesAsync, which is + the only thing that would otherwise parse the recorded version before writing it straight + back, and the newer-than-us case produces zero hops AND zero failures, so nothing + downstream catches it. Repair is no exception -- it runs the same install scripts. + */ + switch (InstallGuard.Check(currentVersion, version)) + { + case InstallBlock.UnreadableBuildVersion: + /* Also checked above, before the clean-install branch. Handled here so the switch + is exhaustive and cannot silently fall through if that check ever moves. */ + Console.WriteLine(); + WriteError($"This installer reports its own version as '{version}', which is not a valid version."); + Console.WriteLine("Aborting: an install would record that value as the server's version."); + if (!automatedMode) + { + WaitForExit(); + } + return (int)InstallationResultCode.VersionCheckFailed; + + case InstallBlock.UnreadableInstalledVersion: + Console.WriteLine(); + WriteError($"The version recorded on this server ('{currentVersion}') is not a valid version."); + Console.WriteLine("Aborting: without a comparable version we cannot tell which migrations still need to run."); + if (!automatedMode) + { + WaitForExit(); + } + return (int)InstallationResultCode.VersionCheckFailed; + + case InstallBlock.InstalledIsNewerThanBuild: + Console.WriteLine(); + WriteError($"Installed version v{currentVersion} is newer than this installer (v{version})."); + Console.WriteLine("Aborting: running an older installer over a newer database would revert its objects to"); + Console.WriteLine("the older definitions and record it at the lower version. Use a matching or newer installer."); + if (!automatedMode) + { + WaitForExit(); + } + return (int)InstallationResultCode.VersionCheckFailed; + + case InstallBlock.None: + break; + + default: + /* + Never default to "safe to install". A new InstallBlock member silently becoming an + allow is the one failure mode this whole guard exists to prevent. + */ + Console.WriteLine(); + WriteError($"Unhandled install-guard result. Aborting rather than assuming it is safe."); + if (!automatedMode) + { + WaitForExit(); + } + return (int)InstallationResultCode.VersionCheckFailed; + } + + /* + Refuse to repair what is not there. Falling through would run a FULL fresh install and + stamp the target version -- a whole new database and Agent jobs on a mistyped server, and, + if the database exists but its history table does not, a target-version stamp that strands + every migration in between. An operator who typed --repair asked for the opposite. + */ + if (repairMode && currentVersion == null) + { + Console.WriteLine(); + WriteError("--repair found no existing PerformanceMonitor installation to repair on this server."); + Console.WriteLine("Repair reinstalls the objects of an existing installation; it will not create one."); + Console.WriteLine("Re-run without --repair to install, or check the server name."); + if (!automatedMode) + { + WaitForExit(); + } + return (int)InstallationResultCode.VersionCheckFailed; + } + + if (currentVersion != null && repairMode) + { + /* + Repair reinstalls the schema objects (install scripts are idempotent) without + running migrations, so a hop that failed on a missing or damaged object can be + recovered without dropping the database. The pending upgrade runs afterwards. + */ + Console.WriteLine(); + Console.WriteLine(DescribeInstalledVersion(currentVersion)); + Console.WriteLine("Repair mode: skipping upgrade scripts. Objects will be reinstalled at their"); + Console.WriteLine("current definitions; the pending upgrade still needs to run afterwards."); + } + else if (currentVersion != null) { Console.WriteLine(); - Console.WriteLine($"Existing installation detected: v{currentVersion}"); + Console.WriteLine(DescribeInstalledVersion(currentVersion)); Console.WriteLine("Checking for applicable upgrades..."); var (upgSuccessCount, upgFailureCount, upgradeCount) = @@ -655,26 +909,31 @@ await InstallationService.ExecuteAllUpgradesAsync( { Console.WriteLine(); Console.WriteLine($"Upgrades complete: {upgradeSuccessCount} succeeded, {upgradeFailureCount} failed"); - - /*Abort if any upgrade scripts failed -- proceeding would reinstall over a partially-upgraded database*/ - if (upgradeFailureCount > 0) - { - Console.WriteLine(); - Console.WriteLine("================================================================================"); - WriteError("Installation aborted: upgrade scripts must succeed before installation can proceed."); - Console.WriteLine("Fix the errors above and re-run the installer."); - Console.WriteLine("================================================================================"); - if (!automatedMode) - { - WaitForExit(); - } - return (int)InstallationResultCode.UpgradesFailed; - } } - else + else if (upgradeFailureCount == 0) { Console.WriteLine("No pending upgrades found."); } + + /* + Abort if any upgrade failed -- proceeding would reinstall over a partially-upgraded + database. Checked outside the upgradeCount block because discovery itself can fail + before any hop runs (an unreadable installed version reports a failure with an + upgrade count of zero), and that must not read as "no pending upgrades". + */ + if (upgradeFailureCount > 0) + { + Console.WriteLine(); + Console.WriteLine("================================================================================"); + WriteError("Installation aborted: upgrade scripts must succeed before installation can proceed."); + Console.WriteLine("Fix the errors above and re-run the installer."); + Console.WriteLine("================================================================================"); + if (!automatedMode) + { + WaitForExit(); + } + return (int)InstallationResultCode.UpgradesFailed; + } } else { @@ -711,8 +970,6 @@ Execute SQL files in order case "Success": if (p.Message.EndsWith(" - Success", StringComparison.Ordinal)) { - /*File success: replicate the original "Executing <file>... Success" format*/ - string fileName = p.Message.Replace(" - Success", "", StringComparison.Ordinal); /*The "Executing..." was already printed by the Info message*/ WriteSuccess("Success"); } @@ -745,7 +1002,7 @@ Execute SQL files in order if (p.Message.StartsWith("Executing ", StringComparison.Ordinal) && p.Message.EndsWith("...", StringComparison.Ordinal)) { /*Replicate "Executing <file>... " format (no newline yet)*/ - Console.Write(p.Message.Replace("Executing ", "Executing ", StringComparison.Ordinal) + " "); + Console.Write(p.Message + " "); } else if (p.Message == "Resetting schedule to recommended defaults...") { @@ -886,26 +1143,74 @@ Calculate totals and determine success */ totalSuccessCount = upgradeSuccessCount + installSuccessCount; totalFailureCount = upgradeFailureCount + installFailureCount; - installationSuccessful = totalFailureCount == 0; + + /* + A repair on a database with pending migrations is EXPECTED to fail some install files, and + that is not a failed repair. The install scripts compile against the CURRENT schema -- e.g. + install/23_process_blocked_process_xml.sql reads collect.blocking_BlockedProcessReport.monitor_loop, + a column the 3.0.0-to-3.1.0 migration adds -- and ALTER PROCEDURE binds columns at compile + time, so those procedures cannot compile until the upgrade runs (Msg 207). A failed + CREATE OR ALTER leaves the old body intact, so nothing is damaged, and the upgrade's own + install pass recompiles them. + + Reporting that as PartialInstallation (exit 4) is actively dangerous: a script gating on + %ERRORLEVEL% treats a good repair as a failure, and the operator reading "repair failed" + reaches for --reinstall, which DROPS the database -- the exact destructive outcome this + feature exists to avoid. A critical file (01_/02_/03_) failing is different: that aborts the + whole pass, so the repair reinstalled nothing and really did fail. + */ + bool repairRan = repairMode && currentVersion != null; + + /* + Shared with the Dashboard so the two cannot drift -- see RepairOutcome. A critical file + failing already returned CriticalScriptFailed above, so it cannot reach here; pass false. + */ + bool repairFailuresExcused = RepairOutcome.FailuresAreExpected( + repairRan, + currentVersion, + version, + criticalFileFailed: false); + + installationSuccessful = totalFailureCount == 0 || repairFailuresExcused; /* Log installation history to database */ - try + /* + A repair reinstalls objects without running migrations, so it must NOT record the target + version -- that would strand every pending hop, which is exactly what the upgrade abort + above exists to prevent. + + It writes NO history row at all. A repair changes no version, and installation_history is + the version ledger -- echoing back a version we merely READ is how a guess becomes a fact. + Concretely: GetInstalledVersionAsync returns the unknown sentinel as a #538 fallback when the database + exists but has no SUCCESS row, meaning "unknown, try every upgrade". Persisting that as a + SUCCESS row would turn the guess into truth. Writing nothing leaves the previous row as the + version of record, so the pending upgrade is still offered afterwards. + */ + if (repairMode && currentVersion != null) { - await InstallationService.LogInstallationHistoryAsync( - connectionString, - version, - infoVersion, - installationStartTime, - totalSuccessCount, - totalFailureCount, - installationSuccessful - ).ConfigureAwait(false); + Console.WriteLine(); + Console.WriteLine("Repair does not change the recorded version, so no installation history row was written."); } - catch (Exception ex) + else { - Console.WriteLine($"Warning: Could not log installation history: {ex.Message}"); + try + { + await InstallationService.LogInstallationHistoryAsync( + connectionString, + version, + infoVersion, + installationStartTime, + totalSuccessCount, + totalFailureCount, + installationSuccessful + ).ConfigureAwait(false); + } + catch (Exception ex) + { + Console.WriteLine($"Warning: Could not log installation history: {ex.Message}"); + } } Console.WriteLine(); @@ -913,7 +1218,25 @@ await InstallationService.LogInstallationHistoryAsync( Console.WriteLine("Installation Summary"); Console.WriteLine("================================================================================"); - if (installationSuccessful) + if (repairFailuresExcused && totalFailureCount > 0) + { + /* + A repair with a pending upgrade exits 0 -- but it did NOT install cleanly, and saying + "Installation completed successfully! / All collector stored procedures" over N procedures + that demonstrably failed to compile is a lie the very next paragraph contradicts. The exit + code is the contract for scripts; the banner is for the human, and the human needs the truth. + + Gated on there BEING failures: repairFailuresExcused only means "if it failed, that is + expected", so a clean repair is still a clean install and gets the ordinary banner. + */ + WriteWarning($"Repair completed with {totalFailureCount} expected error(s)."); + Console.WriteLine(); + Console.WriteLine("Those errors are expected: the install scripts compile against the CURRENT"); + Console.WriteLine("schema, and this server's pending upgrade has not run yet, so a few procedures"); + Console.WriteLine("cannot compile until it does. A failed CREATE OR ALTER leaves the previous"); + Console.WriteLine("definition intact -- nothing was damaged, and the upgrade recompiles them."); + } + else if (installationSuccessful) { WriteSuccess("Installation completed successfully!"); Console.WriteLine(); @@ -939,6 +1262,58 @@ await InstallationService.LogInstallationHistoryAsync( Console.WriteLine("Review errors above and check PerformanceMonitor.config.collection_log for details."); } + /* + The repair handoff. Without it the operator is told "N error(s), review errors above" with no + next step and reaches for --reinstall, which drops the database. + */ + if (repairRan) + { + /* + Keyed on HasPendingUpgrade, NOT on FailuresAreExpected. They answer different questions, + and they disagree for the unknown sentinel: an unreadable version cannot excuse a file + failure, but it absolutely can have every hop pending. Reusing the one flag printed + "already at the current version, so there is no upgrade to apply" two lines under + "still at v1.0.0" -- for a server with eleven migrations waiting. + */ + bool versionUnknown = RepairOutcome.IsVersionUnknown(currentVersion); + bool pendingUpgrade = RepairOutcome.HasPendingUpgrade(currentVersion, version); + + Console.WriteLine(); + Console.WriteLine("================================================================================"); + + if (versionUnknown) + { + Console.WriteLine("Repair complete. No version was recorded."); + Console.WriteLine("This server's recorded version could not be read, so it is unknown which migrations"); + Console.WriteLine("have run."); + if (totalFailureCount > 0) + { + Console.WriteLine($"{totalFailureCount} object(s) failed. Some may simply be waiting on a migration."); + } + Console.WriteLine(); + Console.WriteLine("Next: re-run WITHOUT --repair to attempt every upgrade, then re-check."); + } + else if (pendingUpgrade) + { + Console.WriteLine($"Repair complete. This server is still at v{currentVersion} and no version was recorded."); + if (totalFailureCount > 0) + { + Console.WriteLine($"{totalFailureCount} object(s) could not be compiled because the pending upgrade has not"); + Console.WriteLine("run yet. This is expected -- they reference columns the upgrade adds, and the"); + Console.WriteLine("upgrade will recompile them."); + } + Console.WriteLine(); + Console.WriteLine("Next: re-run WITHOUT --repair to apply the pending upgrade."); + } + else + { + Console.WriteLine($"Repair complete. This server is at v{currentVersion} and no version was recorded."); + Console.WriteLine("It was already at the current version, so there is no upgrade to apply."); + } + + Console.WriteLine("================================================================================"); + } + /* Ask if user wants to retry or exit (skip in automated mode) */ @@ -1095,6 +1470,78 @@ await InstallationService.ExecuteUninstallAsync( return (int)InstallationResultCode.Success; } + /// <summary> + /// Runs installation diagnostics (99_installer_troubleshooting.sql) against the server and + /// prints the [OK]/[WARN]/[ERROR] results. Exit code 0 when no errors are found, otherwise 10. + /// </summary> + private static async Task<int> PerformTroubleshootAsync(string connectionString, ScriptProvider scriptProvider, bool automatedMode) + { + Console.WriteLine(); + Console.WriteLine("================================================================================"); + Console.WriteLine("TROUBLESHOOT MODE"); + Console.WriteLine("================================================================================"); + Console.WriteLine(); + Console.WriteLine("Running installation diagnostics (99_installer_troubleshooting.sql)..."); + Console.WriteLine(); + + bool noErrors; + try + { + noErrors = await InstallationService.RunTroubleshootingAsync( + connectionString, + scriptProvider, + new Progress<InstallationProgress>(p => + { + switch (p.Status) + { + case "Success": + WriteSuccess(p.Message); + break; + case "Error": + WriteError(p.Message); + break; + case "Warning": + WriteWarning(p.Message); + break; + case "Info": + Console.WriteLine(p.Message); + break; + case "Debug": + break; + default: + Console.WriteLine(p.Message); + break; + } + })).ConfigureAwait(false); + } + catch (Exception ex) + { + Console.WriteLine(); + Console.WriteLine($"Diagnostics failed: {ex.Message}"); + if (!automatedMode) + { + WaitForExit(); + } + return (int)InstallationResultCode.DiagnosticsFailed; + } + + Console.WriteLine(); + if (noErrors) + { + WriteSuccess("Diagnostics completed: no errors found."); + } + else + { + WriteWarning("Diagnostics completed: issues were reported above ([WARN]/[ERROR])."); + } + + if (!automatedMode) + { + WaitForExit(); + } + return noErrors ? (int)InstallationResultCode.Success : (int)InstallationResultCode.DiagnosticsFailed; + } + /* Write error log file for bug reporting Returns the path to the log file @@ -1189,6 +1636,40 @@ private static string SanitizeFilename(string input) return string.Concat(input.Select(c => invalid.Contains(c) ? '_' : c)); } + /* + The flags the positional-argument filter knows to skip. Kept only to DIAGNOSE the dropped-password + trap: a "--"-token that is not one of these was almost certainly a value the filter silently ate. + If a new flag is added and not listed here, the only cost is a spurious hint in an error path -- so + this is a diagnostic aid, not a parsing authority (the actual flag handling matches each flag by + name inline in Main). + */ + private static readonly string[] RecognizedFlags = + { + "--data-path", "--encrypt", "--entra", "--help", "--log-path", "--managed-identity", + "--reinstall", "--repair", "--reset-schedule", "--service-principal", "--troubleshoot", + "--trust-cert", "--uninstall" + }; + + private static bool HasUnrecognizedDoubleDashArg(string[] args) + { + foreach (string arg in args) + { + if (!arg.StartsWith("--", StringComparison.Ordinal)) + { + continue; + } + + /* Compare the flag name only, so the --flag=value form is matched too. */ + string flagName = arg.Split('=')[0]; + if (!RecognizedFlags.Contains(flagName, StringComparer.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + /* Wait for user input before exiting (prevents window from closing) Used for fatal errors where retry doesn't make sense diff --git a/Installer/README.md b/Installer/README.md index af80d2459..971787aea 100644 --- a/Installer/README.md +++ b/Installer/README.md @@ -1,7 +1,115 @@ -# Performance Monitor CLI Installer +# Performance Monitor CLI Installer (Full Edition) -Self-contained console application that installs the PerformanceMonitor database, 29 collector stored procedures, reporting views, and SQL Agent jobs on a target SQL Server instance. Supports both interactive prompts and command-line arguments for automated deployment. +> **The "Full" edition — a `PerformanceMonitor` database installed on the target SQL Server with T-SQL collectors running via SQL Agent — is deprecated.** It still ships and is supported for existing users, but new deployments should use **[Lite](../Lite/README.md)** (portable desktop app, nothing installed on the server) or **[Darling](../Darling/README.md)** (headless service + viewer). This CLI installer remains for those who still run the Full edition. See the [root README](../README.md) for the current editions. -No .NET installation required on the target machine — the executable includes the full .NET 10.0 runtime. +Self-contained console application that installs the `PerformanceMonitor` database, collector stored procedures, reporting views, and SQL Agent jobs on a target SQL Server instance. The executable bundles the .NET 10.0 runtime — nothing to install on the machine running it. -See the [root README](../README.md) for usage, command-line options, and troubleshooting. +Run it from the repository root (the directory containing `install/`); the installer searches for `install/` from the current directory. + +## Install + +Windows Authentication: + +``` +PerformanceMonitorInstaller.exe YourServerName +``` + +SQL Authentication: + +``` +PerformanceMonitorInstaller.exe YourServerName sa YourPassword +``` + +Entra ID (MFA) Authentication: + +``` +PerformanceMonitorInstaller.exe YourServerName --entra user@domain.com +``` + +Entra managed identity / service principal (non-interactive, for Azure SQL Managed Instance or other AAD-enabled targets): + +``` +PerformanceMonitorInstaller.exe YourMI.database.windows.net --managed-identity +PerformanceMonitorInstaller.exe YourMI.database.windows.net --managed-identity=MI_CLIENT_ID +PerformanceMonitorInstaller.exe YourMI.database.windows.net --service-principal APP_CLIENT_ID +``` + +Bare `--managed-identity` uses the system-assigned identity; pass a client id (`--managed-identity=ID` or `--managed-identity ID`) for a user-assigned one. `--service-principal` reads the client secret from the `PM_AZURE_CLIENT_SECRET` environment variable (never the command line). + +Clean reinstall (drops existing database and all collected data): + +``` +PerformanceMonitorInstaller.exe YourServerName --reinstall +PerformanceMonitorInstaller.exe YourServerName sa YourPassword --reinstall +``` + +Custom data/log file locations (applied only when the database is first created): + +``` +PerformanceMonitorInstaller.exe YourServerName --data-path D:\SQLData --log-path E:\SQLLogs +``` + +Uninstall (removes database, Agent jobs, and XE sessions): + +``` +PerformanceMonitorInstaller.exe YourServerName --uninstall +PerformanceMonitorInstaller.exe YourServerName sa YourPassword --uninstall +``` + +The installer automatically tests the connection, checks the SQL Server version (2016+ required), executes SQL scripts, downloads community dependencies, creates SQL Agent jobs, and runs initial data collection. You can also install directly from the Dashboard's Add Server dialog. + +**Air-gapped environments?** Place pre-downloaded community scripts (`sp_WhoIsActive.sql`, `DarlingData.sql`, `Install-All-Scripts.sql`) in a `community/` directory next to the installer. The installer uses local files when present and falls back to GitHub downloads otherwise. + +## CLI Installer Options + +| Option | Description | +|---|---| +| `SERVER` | SQL Server instance name (positional, required) | +| `USERNAME PASSWORD` | SQL Authentication credentials (positional, optional) | +| `--entra EMAIL` | Microsoft Entra ID interactive authentication (MFA) | +| `--managed-identity[=CLIENT_ID]` | Microsoft Entra managed identity (system-assigned, or user-assigned via `CLIENT_ID`) | +| `--service-principal CLIENT_ID` | Microsoft Entra service principal (client secret via `PM_AZURE_CLIENT_SECRET`) | +| `--reinstall` | Drop existing database and perform clean install | +| `--uninstall` | Remove database, Agent jobs, and XE sessions | +| `--reset-schedule` | Reset collection schedule to recommended defaults | +| `--preserve-jobs` | Keep existing SQL Agent job schedules during upgrade | +| `--encrypt=optional\|mandatory\|strict` | Connection encryption level (default: mandatory) | +| `--trust-cert` | Trust server certificate without validation (default: require valid cert) | +| `--data-path DIR` | Server-side directory for the data (`.mdf`) file (used only on first install) | +| `--log-path DIR` | Server-side directory for the log (`.ldf`) file (used only on first install) | +| `--help` | Show usage information and exit | + +> **Custom file locations:** `--data-path` / `--log-path` set where SQL Server places the PerformanceMonitor data and log files. They take effect **only when the database is first created** — if the database already exists they are ignored. Either flag may be supplied independently; an omitted one falls back to the instance default (`SERVERPROPERTY('InstanceDefaultDataPath')` / `InstanceDefaultLogPath`). The directory is a path **on the SQL Server host** and must already exist, with the SQL Server service account holding write permission. Both `--data-path D:\SQLData` and `--data-path=D:\SQLData` forms are accepted; quote paths containing spaces. Not applicable to Azure SQL Managed Instance, which always uses its managed file layout. + +**Environment variables:** Set `PM_SQL_PASSWORD` to avoid passing the SQL Auth password on the command line, and `PM_AZURE_CLIENT_SECRET` to supply the `--service-principal` client secret. + +## Exit Codes + +| Code | Meaning | +|---|---| +| `0` | Success | +| `1` | Invalid arguments | +| `2` | Connection failed | +| `3` | Critical file failed (scripts 01–03) | +| `4` | Partial installation (non-critical failures) | +| `5` | Version check failed (SQL Server 2014 or earlier) | +| `6` | SQL files not found | +| `7` | Uninstall failed | +| `8` | Upgrade script failed | + +## Post-Installation + +1. Ensure SQL Server Agent is running — the collection job executes every minute. +2. Verify installation: + +```sql +SELECT * FROM PerformanceMonitor.config.current_version; + +SELECT TOP (20) * +FROM PerformanceMonitor.config.collection_log +ORDER BY collection_time DESC; +``` + +3. Install the Dashboard to view the data — see [`Dashboard/README.md`](../Dashboard/README.md). + +For what gets installed, data retention, managed-platform behavior, tabs, troubleshooting, and permissions, see [`Dashboard/README.md`](../Dashboard/README.md). diff --git a/Lite.Tests/AgentStatusCollectorDefinitionTests.cs b/Lite.Tests/AgentStatusCollectorDefinitionTests.cs new file mode 100644 index 000000000..4cc16cf60 --- /dev/null +++ b/Lite.Tests/AgentStatusCollectorDefinitionTests.cs @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Pins the cross-SKU parity contract of the shared agent_status collector definition (issue #1433 +/// Phase 2): the sys.dm_server_services Agent-service read + the msdb.dbo.sysjobschedules next-run decode +/// (via msdb.dbo.agent_datetime, NOT the syssessions path — so it does not inherit running_jobs' RDS gate), +/// the snapshot shape (no watermark), the collect-everywhere-but-Azure-SQL-DB AppliesTo, the 4-column +/// payload, and the null-tolerant ReadAsync/WritePayload. +/// </summary> +public sealed class AgentStatusCollectorDefinitionTests +{ + private static readonly RecordingCollectorDeltaCalculator s_deltas = new(); + + private static CollectorContext MakeContext(bool isAzureSqlDb = false, bool isAzureManagedInstance = false) + => new() + { + ServerId = 42, + ServerName = "test-server", + CollectionTime = new DateTime(2026, 7, 11, 12, 0, 0, DateTimeKind.Utc), + Deltas = s_deltas, + Target = new CollectorTargetInfo { IsAzureSqlDb = isAzureSqlDb, IsAzureManagedInstance = isAzureManagedInstance }, + }; + + [Fact] + public void Identity_Pinned() + { + Assert.Equal("agent_status", AgentStatusCollector.Instance.Name); + Assert.Equal("agent_status", AgentStatusCollector.Instance.TargetTable); + /* Snapshot collector — no watermark of either kind. */ + Assert.Null(AgentStatusCollector.Instance.WatermarkColumn); + Assert.Null(AgentStatusCollector.Instance.NumericWatermarkColumn); + } + + [Fact] + public void BuildQuery_ReadsAgentServiceAndNextScheduledRun() + { + var text = AgentStatusCollector.Instance.BuildQuery(MakeContext()).Text; + + Assert.Contains("sys.dm_server_services", text, StringComparison.Ordinal); + Assert.Contains("N'SQL Server Agent%'", text, StringComparison.Ordinal); + Assert.Contains("status_desc = N'Running'", text, StringComparison.Ordinal); + /* Next-run via sysjobschedules + agent_datetime (msdb-readable), NOT the syssessions path. */ + Assert.Contains("msdb.dbo.sysjobschedules", text, StringComparison.Ordinal); + Assert.Contains("msdb.dbo.agent_datetime", text, StringComparison.Ordinal); + Assert.DoesNotContain("syssessions", text, StringComparison.Ordinal); + Assert.Contains("SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED", text, StringComparison.Ordinal); + Assert.Contains("OPTION(RECOMPILE)", text, StringComparison.Ordinal); + } + + [Fact] + public void AppliesTo_CollectsEverywhereExceptAzureSqlDbRdsAndNoMsdb() + { + /* Azure SQL DB has no Agent / no sys.dm_server_services; AWS RDS is a managed service that doesn't + expose the OS/service-control state sys.dm_server_services reads — skip both rather than return an + empty snapshot that would drive a false "Agent Not Running" alert. */ + Assert.False(AgentStatusCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureSqlDb = true })); + Assert.False(AgentStatusCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAwsRds = true })); + /* No msdb access → the sysjobschedules next-run decode can't run; skip (gate collapsed from + IsCollectorSupported into the shared AppliesTo — #1: one authoritative gate surface). */ + Assert.False(AgentStatusCollector.Instance.AppliesTo(new CollectorTargetInfo { HasMsdbAccess = false })); + /* Managed Instance has Agent; on-prem collects too. Bare target = msdb assumed accessible. */ + Assert.True(AgentStatusCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureManagedInstance = true })); + Assert.True(AgentStatusCollector.Instance.AppliesTo(new CollectorTargetInfo())); + } + + [Fact] + public void PayloadColumns_FourColumns_InAppendOrder() + { + var columns = AgentStatusCollector.Instance.PayloadColumns; + + Assert.Equal(new[] + { + "agent_running", "agent_status_desc", "agent_startup_desc", "next_scheduled_run", + }, columns.Select(c => c.Name).ToArray()); + + Assert.Equal(CollectorColumnType.Boolean, columns.Single(c => c.Name == "agent_running").Type); + Assert.Equal(CollectorColumnType.Varchar, columns.Single(c => c.Name == "agent_status_desc").Type); + Assert.Equal(CollectorColumnType.Timestamp, columns.Single(c => c.Name == "next_scheduled_run").Type); + } + + [Fact] + public async Task ReadAsync_WritePayload_PinsFourColumnOrder_AndToleratesNulls() + { + var context = MakeContext(); + var nextRun = new DateTime(2026, 7, 12, 1, 0, 0, DateTimeKind.Unspecified); + + var runningRow = new object[] { true, "Running", "Automatic", nextRun }; + /* Agent stopped, no schedule → status/startup present, next_scheduled_run NULL. */ + var stoppedRow = new object[] { false, "Stopped", "Manual", (object)DBNull.Value }; + + using var reader = new FakeCollectorDataReader(runningRow, stoppedRow); + var rows = await AgentStatusCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + Assert.Equal(2, rows.Count); + + var writer = new RecordingCollectorRowWriter(); + AgentStatusCollector.Instance.WritePayload(rows[0], writer, context); + Assert.Equal(4, writer.Values.Count); + Assert.Equal(true, writer.Values[0]); /* agent_running */ + Assert.Equal("Running", writer.Values[1]); /* agent_status_desc */ + Assert.Equal("Automatic", writer.Values[2]);/* agent_startup_desc */ + Assert.Equal(nextRun, writer.Values[3]); /* next_scheduled_run */ + + var stoppedWriter = new RecordingCollectorRowWriter(); + AgentStatusCollector.Instance.WritePayload(rows[1], stoppedWriter, context); + Assert.Equal(4, stoppedWriter.Values.Count); + Assert.Equal(false, stoppedWriter.Values[0]); + Assert.Null(stoppedWriter.Values[3]); /* next_scheduled_run NULL */ + + Assert.Empty(s_deltas.Calls); + } +} diff --git a/Lite.Tests/AlertContextBuildersTests.cs b/Lite.Tests/AlertContextBuildersTests.cs new file mode 100644 index 000000000..823a79511 --- /dev/null +++ b/Lite.Tests/AlertContextBuildersTests.cs @@ -0,0 +1,459 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using PerformanceMonitor.Alerting; +using PerformanceMonitor.Notifications; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// <summary> +/// Identity pins for the Phase-5 slice-A extraction: the six pure alert-context builders (and their +/// pure helpers) moved verbatim from this app's <c>MainWindow.AlertEngine.cs</c> into the shared +/// <see cref="AlertContextBuilders"/>. Every expectation here is derived from the pre-extraction +/// code, so a behavioral drift in the shared copy fails a pin. The ONE documented reconciliation: +/// <see cref="AlertContextBuilders.BuildLongRunningQueryContext"/> adopts the Dashboard's version, +/// so Lite's long-running-query alerts now render the ("Program", ...) field — pinned as NEW +/// behavior in <see cref="BuildLongRunningQueryContext_RendersProgramField_TheDocumentedChange"/>. +/// </summary> +public class AlertContextBuildersTests +{ + private const string Server = "SQL2022"; + + /* ---------------- poison waits ---------------- */ + + [Fact] + public void BuildPoisonWaitContext_EmptyList_ReturnsNull() + { + Assert.Null(AlertContextBuilders.BuildPoisonWaitContext(new List<PoisonWaitDelta>())); + } + + [Fact] + public void BuildPoisonWaitContext_RendersOneDetailPerWait_NoIncidents() + { + var context = AlertContextBuilders.BuildPoisonWaitContext(new List<PoisonWaitDelta> + { + new() { WaitType = "RESOURCE_SEMAPHORE", AvgMsPerWait = 2000.5, DeltaMs = 100000, DeltaTasks = 50 }, + new() { WaitType = "THREADPOOL", AvgMsPerWait = 750.0, DeltaMs = 1500, DeltaTasks = 2 } + }); + + Assert.NotNull(context); + Assert.Equal(2, context!.Details.Count); + Assert.Equal("RESOURCE_SEMAPHORE", context.Details[0].Heading); + Assert.Equal( + new List<(string, string)> + { + ("Avg ms/wait", $"{2000.5:F1}"), + ("Delta wait ms", $"{100000L:N0}"), + ("Delta tasks", $"{50L:N0}") + }, + context.Details[0].Fields); + Assert.Equal("THREADPOOL", context.Details[1].Heading); + /* The poison-wait builder never attached #1140 incidents pre-extraction. */ + Assert.Null(context.Incidents); + } + + /* ---------------- long-running queries ---------------- */ + + private static LongRunningQueryInfo Lrq( + int sessionId, long elapsedSeconds, string db = "", string program = "", string query = "", + string? waitType = null, int? blockedBy = null, string? hash = null) => new() + { + SessionId = sessionId, + ElapsedSeconds = elapsedSeconds, + DatabaseName = db, + ProgramName = program, + QueryText = query, + CpuTimeMs = 1234, + Reads = 10, + Writes = 2, + WaitType = waitType, + BlockingSessionId = blockedBy, + QueryHash = hash + }; + + [Fact] + public void BuildLongRunningQueryContext_EmptyList_ReturnsNull() + { + Assert.Null(AlertContextBuilders.BuildLongRunningQueryContext(Server, new List<LongRunningQueryInfo>())); + } + + [Fact] + public void BuildLongRunningQueryContext_CapsAtThreeQueries_AppendsIncidents() + { + var queries = new List<LongRunningQueryInfo> + { + Lrq(71, 314, db: "StackOverflow", query: "SELECT 1", hash: "0x9AAF0129E4E9AD07"), + Lrq(72, 200, db: "StackOverflow", query: "SELECT 2", hash: "0x1111111111111111"), + Lrq(73, 150, db: "StackOverflow", query: "SELECT 3", hash: "0x2222222222222222"), + Lrq(74, 100, db: "StackOverflow", query: "SELECT 4", hash: "0x3333333333333333") + }; + + var context = AlertContextBuilders.BuildLongRunningQueryContext(Server, queries); + + Assert.NotNull(context); + /* 3 session items (capped) + 3 appended incident items (one per hashed query). */ + Assert.Equal(6, context!.Details.Count); + Assert.Equal("Session #71 — 5m 14s", context.Details[0].Heading); + Assert.Equal("Incident 1 of 3", context.Details[3].Heading); + Assert.NotNull(context.Incidents); + Assert.Equal(3, context.Incidents!.Count); + /* Dedup key = query_hash fingerprint, scoped by server + incident type (#1140). */ + var expected = AlertFingerprint.ForKey(Server, AlertFingerprint.Query, "0x9AAF0129E4E9AD07", new[] { "StackOverflow" }); + Assert.Equal(expected!.DedupKey, context.Incidents[0].DedupKey); + } + + [Fact] + public void BuildLongRunningQueryContext_RendersProgramField_TheDocumentedChange() + { + /* Phase-5 slice A's ONE behavior reconciliation: Lite's pre-extraction builder had no + ("Program", ...) item; the shared builder adopts the Dashboard's version, so Lite's + long-running-query alerts GAIN this field (Lite's LRQ read now populates ProgramName). */ + var context = AlertContextBuilders.BuildLongRunningQueryContext(Server, new List<LongRunningQueryInfo> + { + Lrq(71, 314, db: "StackOverflow", program: "HammerDB", query: "SELECT 1", waitType: "CXPACKET", blockedBy: 55) + }); + + Assert.NotNull(context); + Assert.Equal( + new List<(string, string)> + { + ("Database", "StackOverflow"), + ("Program", "HammerDB"), + ("Query", "SELECT 1"), + ("CPU Time", $"{1234L:N0} ms"), + ("Reads", $"{10L:N0}"), + ("Writes", $"{2L:N0}"), + ("Wait Type", "CXPACKET"), + ("Blocked By", "Session #55") + }, + context!.Details[0].Fields); + } + + [Fact] + public void BuildLongRunningQueryContext_EmptyProgramName_OmitsProgramField() + { + /* Rows without a program (or pre-upgrade Lite archive rows where the column is NULL) + render exactly as before the extraction — the field is conditional. */ + var context = AlertContextBuilders.BuildLongRunningQueryContext(Server, new List<LongRunningQueryInfo> + { + Lrq(71, 61, db: "StackOverflow", query: "SELECT 1") + }); + + Assert.NotNull(context); + Assert.Equal("Session #71 — 1m 1s", context!.Details[0].Heading); + Assert.DoesNotContain(context.Details[0].Fields, f => f.Label == "Program"); + } + + [Fact] + public void BuildLongRunningQueryContext_NullQueryHash_EmitsNoIncident() + { + var context = AlertContextBuilders.BuildLongRunningQueryContext(Server, new List<LongRunningQueryInfo> + { + Lrq(71, 90, db: "StackOverflow", query: "SELECT 1", hash: null) + }); + + Assert.NotNull(context); + Assert.Single(context!.Details); + Assert.Null(context.Incidents); + } + + /* ---------------- low disk ---------------- */ + + private static VolumeFreeSpaceInfo Volume(string mount, double totalMb, double freeMb) => + new() { MountPoint = mount, TotalMb = totalMb, FreeMb = freeMb }; + + [Fact] + public void GetBreachedVolumes_FiltersOnEitherDimension_WorstFirst() + { + var volumes = new List<VolumeFreeSpaceInfo> + { + Volume("C:\\", 102400, 8192), /* 8% free, 8 GB -> breaches percent */ + Volume("D:\\", 1048576, 524288), /* 50% free, 512 GB -> healthy */ + Volume("E:\\", 10240, 3072) /* 30% free, 3 GB -> breaches GB */ + }; + + var breached = AlertContextBuilders.GetBreachedVolumes(volumes, thresholdPercent: 10, thresholdGb: 5); + + Assert.Equal(2, breached.Count); + /* Ordered by free percent ascending — the tightest volume first. */ + Assert.Equal("C:\\", breached[0].MountPoint); + Assert.Equal("E:\\", breached[1].MountPoint); + } + + [Fact] + public void GetBreachedVolumes_ZeroThresholdDisablesThatDimension() + { + var lowPercent = new List<VolumeFreeSpaceInfo> { Volume("C:\\", 102400, 8192) }; /* 8%, 8 GB */ + var lowGb = new List<VolumeFreeSpaceInfo> { Volume("E:\\", 10240, 3072) }; /* 30%, 3 GB */ + + Assert.Empty(AlertContextBuilders.GetBreachedVolumes(lowPercent, thresholdPercent: 0, thresholdGb: 5)); + Assert.Empty(AlertContextBuilders.GetBreachedVolumes(lowGb, thresholdPercent: 10, thresholdGb: 0)); + Assert.Empty(AlertContextBuilders.GetBreachedVolumes(lowPercent, thresholdPercent: 0, thresholdGb: 0)); + } + + [Theory] + [InlineData(10, 5, "10% / 5 GB")] + [InlineData(10, 0, "10%")] + [InlineData(0, 5, "5 GB")] + [InlineData(0, 0, "—")] + public void FormatLowDiskThreshold_RendersConfiguredDimensions(double pct, double gb, string expected) + { + Assert.Equal(expected, AlertContextBuilders.FormatLowDiskThreshold(pct, gb)); + } + + [Fact] + public void BuildVolumeFreeSpaceContext_RendersVolume_WithPerVolumeIncident() + { + var context = AlertContextBuilders.BuildVolumeFreeSpaceContext(Server, new List<VolumeFreeSpaceInfo> + { + Volume("C:\\", 102400, 5120) /* 5% free, 5 GB free, 100 GB total */ + }); + + Assert.NotNull(context); + /* 1 volume item + 1 appended incident item. */ + Assert.Equal(2, context!.Details.Count); + Assert.Equal("C:\\ — 5% Free", context.Details[0].Heading); + Assert.Equal( + new List<(string, string)> + { + ("Free Space", $"{5.0:F1} GB"), + ("Total Size", $"{100.0:F1} GB"), + ("Used", $"{95.0:F1} GB") + }, + context.Details[0].Fields); + + Assert.NotNull(context.Incidents); + var expected = AlertFingerprint.ForKey(Server, AlertFingerprint.Disk, "C:\\", new[] { "C:\\" }); + Assert.Equal(expected!.DedupKey, Assert.Single(context.Incidents!).DedupKey); + } + + [Fact] + public void BuildVolumeFreeSpaceContext_CapsAtFiveVolumes() + { + var volumes = new List<VolumeFreeSpaceInfo>(); + for (int i = 0; i < 6; i++) + volumes.Add(Volume($"{(char)('C' + i)}:\\", 102400, 1024 * (i + 1))); + + var context = AlertContextBuilders.BuildVolumeFreeSpaceContext(Server, volumes); + + Assert.NotNull(context); + /* 5 shown volumes + 5 incident items. */ + Assert.Equal(10, context!.Details.Count); + Assert.Equal(5, context.Incidents!.Count); + } + + /* ---------------- tempdb ---------------- */ + + [Fact] + public void BuildTempDbSpaceContext_RendersAllFields_WithTopConsumer() + { + var context = AlertContextBuilders.BuildTempDbSpaceContext(new TempDbSpaceInfo + { + TotalReservedMb = 800, + UnallocatedMb = 200, + UserObjectReservedMb = 500, + InternalObjectReservedMb = 250, + VersionStoreReservedMb = 50, + TopConsumerSessionId = 55, + TopConsumerMb = 123.4 + }); + + Assert.NotNull(context); + var item = Assert.Single(context!.Details); + Assert.Equal("tempdb — 80% Used", item.Heading); + Assert.Equal( + new List<(string, string)> + { + ("Total Reserved", $"{800d:F0} MB"), + ("Unallocated", $"{200d:F0} MB"), + ("User Objects", $"{500d:F0} MB"), + ("Internal Objects", $"{250d:F0} MB"), + ("Version Store", $"{50d:F0} MB"), + ("Top Consumer", $"Session #55 ({123.4:F0} MB)") + }, + item.Fields); + } + + [Fact] + public void BuildTempDbSpaceContext_NoTopConsumer_RendersNone() + { + var context = AlertContextBuilders.BuildTempDbSpaceContext(new TempDbSpaceInfo + { + TotalReservedMb = 100, + UnallocatedMb = 900, + TopConsumerSessionId = 0 + }); + + Assert.Equal(("Top Consumer", "None"), context!.Details[0].Fields[5]); + } + + /* ---------------- anomalous jobs ---------------- */ + + [Fact] + public void BuildAnomalousJobContext_RendersDurationsAndIncidents() + { + var jobs = new List<AnomalousJobInfo> + { + new() + { + JobName = "Nightly ETL", + CurrentDurationSeconds = 3661, /* -> 1h 1m */ + AvgDurationSeconds = 90, /* -> 1m 30s */ + P95DurationSeconds = 45, /* -> 45s */ + PercentOfAverage = 350, + StartTime = new DateTime(2026, 6, 19, 14, 30, 15) + } + }; + + var context = AlertContextBuilders.BuildAnomalousJobContext(Server, jobs); + + Assert.NotNull(context); + /* 1 job item + 1 appended incident item. */ + Assert.Equal(2, context!.Details.Count); + Assert.Equal("Nightly ETL", context.Details[0].Heading); + Assert.Equal( + new List<(string, string)> + { + ("Current Duration", "1h 1m"), + ("Avg Duration", "1m 30s"), + ("P95 Duration", "45s"), + ("% of Average", $"{350m:F0}%"), + ("Started", "2026-06-19 14:30:15") + }, + context.Details[0].Fields); + + var expected = AlertFingerprint.ForKey(Server, AlertFingerprint.Job, "Nightly ETL", new[] { "Nightly ETL" }); + Assert.Equal(expected!.DedupKey, Assert.Single(context.Incidents!).DedupKey); + } + + [Fact] + public void BuildAnomalousJobContext_NullPercentOfAverage_RendersNA_AndCapsAtThree() + { + var jobs = new List<AnomalousJobInfo>(); + for (int i = 0; i < 4; i++) + jobs.Add(new AnomalousJobInfo { JobName = $"Job {i}", PercentOfAverage = null, StartTime = DateTime.UtcNow }); + + var context = AlertContextBuilders.BuildAnomalousJobContext(Server, jobs); + + Assert.NotNull(context); + /* 3 shown jobs (capped) + 3 incident items. */ + Assert.Equal(6, context!.Details.Count); + Assert.Equal(("% of Average", "N/A"), context.Details[0].Fields[3]); + Assert.Null(AlertContextBuilders.BuildAnomalousJobContext(Server, new List<AnomalousJobInfo>())); + } + + /* ---------------- failed jobs ---------------- */ + + [Fact] + public void BuildFailedJobContext_RendersStepAndTruncatedMessage() + { + var runTime = new DateTime(2026, 6, 19, 3, 15, 0); + var context = AlertContextBuilders.BuildFailedJobContext(Server, new List<FailedJobInfo> + { + new() + { + JobName = "Backup Job", + RunDateTime = runTime, + StepId = 2, + StepName = "Backup databases", + Message = new string('x', 350) + } + }); + + Assert.NotNull(context); + Assert.Equal(2, context!.Details.Count); /* 1 job item + 1 incident item */ + Assert.Equal("Backup Job", context.Details[0].Heading); + Assert.Equal( + new List<(string, string)> + { + ("Job", "Backup Job"), + ("Failed At", runTime.ToString("yyyy-MM-dd HH:mm:ss")), + ("Step", "2 — Backup databases"), + ("Message", new string('x', 300) + "...") + }, + context.Details[0].Fields); + + var expected = AlertFingerprint.ForKey(Server, AlertFingerprint.Job, "Backup Job", new[] { "Backup Job" }); + Assert.Equal(expected!.DedupKey, Assert.Single(context.Incidents!).DedupKey); + } + + [Fact] + public void BuildFailedJobContext_OutcomeRowWithoutStep_OmitsStepField_AndCapsAtFive() + { + var jobs = new List<FailedJobInfo>(); + for (int i = 0; i < 6; i++) + jobs.Add(new FailedJobInfo { JobName = $"Job {i}", RunDateTime = DateTime.UtcNow, StepId = 0 }); + + var context = AlertContextBuilders.BuildFailedJobContext(Server, jobs); + + Assert.NotNull(context); + /* 5 shown jobs (capped) + 5 incident items. */ + Assert.Equal(10, context!.Details.Count); + Assert.DoesNotContain(context.Details[0].Fields, f => f.Label == "Step"); + Assert.Null(AlertContextBuilders.BuildFailedJobContext(Server, new List<FailedJobInfo>())); + } + + /* ---------------- helpers ---------------- */ + + [Fact] + public void ContextToDetailText_NullOrEmptyContext_ReturnsNull() + { + Assert.Null(AlertContextBuilders.ContextToDetailText(null)); + Assert.Null(AlertContextBuilders.ContextToDetailText(new AlertContext())); + } + + [Fact] + public void ContextToDetailText_RendersHeadingsAndIndentedFields() + { + var context = new AlertContext(); + context.Details.Add(new AlertDetailItem { Heading = "H1", Fields = new() { ("A", "1") } }); + context.Details.Add(new AlertDetailItem { Heading = "H2", Fields = new() { ("B", "2") } }); + + var expected = string.Join(Environment.NewLine, "H1", " A: 1", "", "H2", " B: 2"); + Assert.Equal(expected, AlertContextBuilders.ContextToDetailText(context)); + } + + [Fact] + public void TruncateText_CollapsesNewlines_TruncatesWithEllipsis() + { + Assert.Equal("", AlertContextBuilders.TruncateText("")); + /* '\r' and '\n' each become a space (historical behavior — not collapsed to one). */ + Assert.Equal("a b", AlertContextBuilders.TruncateText("a\r\nb")); + Assert.Equal("abc", AlertContextBuilders.TruncateText(" abc ")); + Assert.Equal(new string('x', 300) + "...", AlertContextBuilders.TruncateText(new string('x', 350))); + Assert.Equal("ab...", AlertContextBuilders.TruncateText("abcdef", 2)); + } + + /* ---------------- A0: the shared row types are the app's row types ---------------- */ + + [Fact] + public void RowTypes_ResolveToAlertingNamespace_ThroughAppApiSurface() + { + /* The global using aliases in Lite's GlobalUsings.cs point the old bare type names at + PerformanceMonitor.Alerting — so the app's public API must now surface the SHARED types. */ + Assert.Equal("PerformanceMonitor.Alerting", typeof(FailedJobInfo).Namespace); + + Assert.Equal(typeof(Task<List<PoisonWaitDelta>>), + typeof(LocalDataService).GetMethod("GetLatestPoisonWaitAvgsAsync")!.ReturnType); + Assert.Equal(typeof(Task<List<LongRunningQueryInfo>>), + typeof(LocalDataService).GetMethod("GetLongRunningQueriesAsync")!.ReturnType); + Assert.Equal(typeof(Task<List<VolumeFreeSpaceInfo>>), + typeof(LocalDataService).GetMethod("GetVolumeFreeSpaceAsync")!.ReturnType); + Assert.Equal(typeof(Task<TempDbSpaceInfo?>), + typeof(LocalDataService).GetMethod("GetLatestTempDbSpaceAsync")!.ReturnType); + Assert.Equal(typeof(Task<List<AnomalousJobInfo>>), + typeof(LocalDataService).GetMethod("GetAnomalousJobsAsync")!.ReturnType); + Assert.Equal(typeof(Task<List<FailedJobInfo>>), + typeof(RemoteCollectorService).GetMethod("GetRecentlyFailedJobsAsync")!.ReturnType); + } +} diff --git a/Lite.Tests/AlertHistorySourceTests.cs b/Lite.Tests/AlertHistorySourceTests.cs index bd9dca602..82b1a9df8 100644 --- a/Lite.Tests/AlertHistorySourceTests.cs +++ b/Lite.Tests/AlertHistorySourceTests.cs @@ -194,7 +194,7 @@ public async Task ViewWithNoParquet_AllRowsAreLive() await _helper.InsertLiveAlertAsync( connection, DateTime.UtcNow.AddHours(-1), 1, "Server1", "High CPU"); await _helper.InsertLiveAlertAsync( - connection, DateTime.UtcNow.AddHours(-2), 2, "Server2", "TempDB Space"); + connection, DateTime.UtcNow.AddHours(-2), 2, "Server2", "tempdb Space"); await _helper.RefreshArchiveViewsAsync(); diff --git a/Lite.Tests/AlertHistoryValueFormatTests.cs b/Lite.Tests/AlertHistoryValueFormatTests.cs index e95d78a9c..e26c93b95 100644 --- a/Lite.Tests/AlertHistoryValueFormatTests.cs +++ b/Lite.Tests/AlertHistoryValueFormatTests.cs @@ -28,7 +28,7 @@ public void VolumeFreeSpace_RawFloat_RendersAsOneDecimalPercent() [Theory] [InlineData("High CPU", 45.0, "45.0%")] - [InlineData("TempDB Space", 87.3, "87.3%")] + [InlineData("tempdb Space", 87.3, "87.3%")] [InlineData("Volume Free Space", 9.74, "9.7%")] [InlineData("Long-Running Job", 247.8, "247.8%")] // job's "% of average" public void PercentMetrics_RenderOneDecimalPercent(string metric, double value, string expected) diff --git a/Lite.Tests/AnalysisNotificationTests.cs b/Lite.Tests/AnalysisNotificationTests.cs index a73eaa19f..ba41433fd 100644 --- a/Lite.Tests/AnalysisNotificationTests.cs +++ b/Lite.Tests/AnalysisNotificationTests.cs @@ -63,7 +63,8 @@ private static AnalysisFinding MakeFinding( double? rootValue = 92.5, Dictionary<string, double>? metadata = null, Dictionary<string, object>? drillDown = null, - string rootFactKey = "CPU_SPIKE") + string rootFactKey = "CPU_SPIKE", + string incidentId = "") { return new AnalysisFinding { @@ -72,6 +73,7 @@ private static AnalysisFinding MakeFinding( Category = category, StoryPath = "CPU_SPIKE → PLAN_REGRESSION", StoryPathHash = hash, + IncidentId = incidentId, Severity = severity, Confidence = 0.67, FactCount = 2, @@ -84,6 +86,30 @@ private static AnalysisFinding MakeFinding( }; } + /// <summary> + /// Captures dispatched <see cref="FindingAlert"/>s so the incident-dedup tests can assert on how + /// many alerts fired and what each one carried, without going through the DuckDB alert store. + /// </summary> + private sealed class CapturingSender : IFindingAlertSender + { + public List<FindingAlert> Sent { get; } = new(); + public Task<DateTime?> GetLastAlertTimeAsync(string serverId, string metricName) => + Task.FromResult<DateTime?>(null); + public Task SendFindingAlertAsync(FindingAlert alert) + { + Sent.Add(alert); + return Task.CompletedTask; + } + } + + private (AnalysisNotificationService Notifier, CapturingSender Sender) MakeCapturingNotifier() + { + var sender = new CapturingSender(); + var notifier = new AnalysisNotificationService( + sender, _settings, f => f.ServerId.ToString(), new AppLoggerAdapter<AnalysisNotificationService>()); + return (notifier, sender); + } + /* ── #1140: finding-path dedup incidents (BuildContext derives them from the drill-down) ── */ [Fact] @@ -513,6 +539,225 @@ await notifier.NotifyAsync(new[] Assert.Equal(1, await CountAlertLogRowsAsync()); } + /* ── Stage B change 3: incident-level e-mail dedup ── */ + + [Fact] + public async Task NotifyAsync_FindingsSharingIncident_SendOneAlert_NamingCoFired() + { + // Two findings in the SAME incident collapse to ONE alert led by the higher-severity primary; + // the other is named in the co-fired line of that one message. + App.AnalysisNotifySeverity = 1.5; + App.AnalysisNotifyCooldownMinutes = 360; + var (notifier, sender) = MakeCapturingNotifier(); + + var primary = MakeFinding("aaaa000000000001", severity: 2.0, rootFactKey: "CPU_SPIKE", + category: "cpu_pressure", incidentId: "incident-1"); + var secondary = MakeFinding("bbbb000000000002", severity: 1.6, rootFactKey: "BLOCKING_EVENTS", + category: "blocking", incidentId: "incident-1"); + + // Deliberately pass the lower-severity finding first — primary selection is by severity, not order. + await notifier.NotifyAsync(new[] { secondary, primary }); + + var alert = Assert.Single(sender.Sent); + Assert.Equal(2.0, alert.Severity); // the CPU_SPIKE primary leads the incident + Assert.Contains("aaaa0000", alert.MetricName); // metric name embeds the primary's hash + var coFired = alert.Context.Details.Single(d => d.Heading == "Co-fired in this incident"); + Assert.NotNull(coFired.Body); + // Incident-scoped wording (matches the heading) — NOT the window-scoped reader wording. + Assert.StartsWith("Also fired in this incident:", coFired.Body); + } + + [Fact] + public async Task NotifyAsync_LoneFinding_HasNoCoFiredItem() + { + // A single-finding incident sends the same message as before — no co-fired item appended. + App.AnalysisNotifySeverity = 1.5; + App.AnalysisNotifyCooldownMinutes = 360; + var (notifier, sender) = MakeCapturingNotifier(); + + await notifier.NotifyAsync(new[] { MakeFinding("solo000000000001", severity: 2.0, incidentId: "incident-1") }); + + var alert = Assert.Single(sender.Sent); + Assert.DoesNotContain(alert.Context.Details, d => d.Heading == "Co-fired in this incident"); + } + + [Fact] + public async Task NotifyAsync_BelowCriticalCooldownKeyedByIncident_SuppressesSecondFindingOfSameIncident() + { + // A DIFFERENT BELOW-CRITICAL finding of the SAME incident in a later cycle is suppressed by the + // incident cooldown — below-critical co-fired symptoms stay one-e-mail-per-incident. + // + // Re-scoped from the original all-severities test: with escalate-on-CRITICAL, the incident-key + // dedup now applies only to below-critical members (a CRITICAL member escalates past it — see + // NotifyAsync_NewCriticalJoiningAlertedIncident_SendsFreshEmailNamingIt). The notify threshold + // is lowered below the 1.5 CRITICAL cutoff so severity-1.0 findings are notify-worthy yet + // below-critical; both key on "1:inc-9", so the second is held. + App.AnalysisNotifySeverity = 0.75; + App.AnalysisNotifyCooldownMinutes = 360; + var (notifier, sender) = MakeCapturingNotifier(); + + await notifier.NotifyAsync(new[] { MakeFinding("aaaa000000000001", severity: 1.0, incidentId: "inc-9") }); + await notifier.NotifyAsync(new[] { MakeFinding("bbbb000000000002", severity: 1.0, incidentId: "inc-9") }); + + Assert.Single(sender.Sent); // one alert for the incident; the second below-critical finding is cooled down + } + + [Fact] + public async Task NotifyAsync_NewCriticalJoiningAlertedIncident_SendsFreshEmailNamingIt() + { + // Escalate-on-CRITICAL (guarantee b): an incident that already e-mailed on a below-critical + // primary, then gains a CRITICAL member in a later cycle, sends a FRESH e-mail led by that + // critical — the new critical is never silently held under the incident cooldown. + App.AnalysisNotifySeverity = 0.75; // notify below the 1.5 CRITICAL cutoff + App.AnalysisNotifyCooldownMinutes = 360; + var (notifier, sender) = MakeCapturingNotifier(); + + // Cycle 1: incident inc-esc e-mails on a below-critical primary (keyed on the incident). + await notifier.NotifyAsync(new[] + { + MakeFinding("dddd000000000001", severity: 1.0, rootFactKey: "BLOCKING_EVENTS", + category: "blocking", incidentId: "inc-esc") + }); + + // Cycle 2: a CRITICAL symptom joins the SAME incident (its own per-hash bucket is fresh). + await notifier.NotifyAsync(new[] + { + MakeFinding("dddd000000000001", severity: 1.0, rootFactKey: "BLOCKING_EVENTS", + category: "blocking", incidentId: "inc-esc"), + MakeFinding("cccc000000000002", severity: 2.0, rootFactKey: "CPU_SPIKE", + category: "cpu_pressure", incidentId: "inc-esc") + }); + + Assert.Equal(2, sender.Sent.Count); + Assert.Equal(2.0, sender.Sent[1].Severity); // the fresh e-mail is led by the critical + Assert.Contains("cccc0000", sender.Sent[1].MetricName); // metric name embeds the critical's hash + } + + [Fact] + public async Task NotifyAsync_NewLowerCriticalJoiningCriticalIncident_SendsFreshEmail() + { + // Escalate-on-CRITICAL (guarantee b, harder case): the incident already e-mailed on a HIGHER + // critical; a strictly-LOWER but still critical symptom joins later. It has its own per-hash + // bucket, so it e-mails fresh (led by it, since the higher critical is still cooling) — a new + // critical is never held even when its incident already alerted on a more severe critical. + App.AnalysisNotifySeverity = 1.5; // default; both findings are CRITICAL-band + App.AnalysisNotifyCooldownMinutes = 360; + var (notifier, sender) = MakeCapturingNotifier(); + + await notifier.NotifyAsync(new[] + { + MakeFinding("aaaa000000000001", severity: 2.0, rootFactKey: "CPU_SPIKE", + category: "cpu_pressure", incidentId: "inc-2crit") + }); + await notifier.NotifyAsync(new[] + { + MakeFinding("aaaa000000000001", severity: 2.0, rootFactKey: "CPU_SPIKE", + category: "cpu_pressure", incidentId: "inc-2crit"), + MakeFinding("bbbb000000000002", severity: 1.7, rootFactKey: "BLOCKING_EVENTS", + category: "blocking", incidentId: "inc-2crit") + }); + + Assert.Equal(2, sender.Sent.Count); + Assert.Equal(1.7, sender.Sent[1].Severity); // led by the newly-joined critical + Assert.Contains("bbbb0000", sender.Sent[1].MetricName); + } + + [Fact] + public async Task NotifyAsync_BelowCriticalCoFiredWithCritical_StaysHeldUnderIncidentBucket() + { + // Guarantee c: a below-critical symptom co-firing with a critical is named as co-fired in the + // critical's one e-mail and does NOT get its own — and on the next identical cycle nothing + // re-fires (the critical cools on its hash bucket, the below-critical on the incident bucket). + App.AnalysisNotifySeverity = 0.75; + App.AnalysisNotifyCooldownMinutes = 360; + var (notifier, sender) = MakeCapturingNotifier(); + + var critical = new Func<AnalysisFinding>(() => MakeFinding("aaaa000000000001", severity: 2.0, + rootFactKey: "CPU_SPIKE", category: "cpu_pressure", incidentId: "inc-mix")); + var belowCritical = new Func<AnalysisFinding>(() => MakeFinding("dddd000000000002", severity: 1.0, + rootFactKey: "BLOCKING_EVENTS", category: "blocking", incidentId: "inc-mix")); + + await notifier.NotifyAsync(new[] { critical(), belowCritical() }); + await notifier.NotifyAsync(new[] { critical(), belowCritical() }); + + var only = Assert.Single(sender.Sent); // one e-mail total across both cycles + Assert.Equal(2.0, only.Severity); // led by the critical + var coFired = only.Context.Details.Single(d => d.Heading == "Co-fired in this incident"); + Assert.StartsWith("Also fired in this incident:", coFired.Body); // the below-critical rides along + } + + [Fact] + public async Task NotifyAsync_LoneCritical_DoesNotRespamWithinCooldown() + { + // Guarantee a: a lone CRITICAL e-mails once and respects its own per-hash cooldown — repeated + // notify cycles inside the window do not re-spam. + App.AnalysisNotifySeverity = 1.5; + App.AnalysisNotifyCooldownMinutes = 360; + var (notifier, sender) = MakeCapturingNotifier(); + + var crit = MakeFinding("aaaa000000000001", severity: 2.0, incidentId: "inc-lone"); + await notifier.NotifyAsync(new[] { crit }); + await notifier.NotifyAsync(new[] { crit }); + await notifier.NotifyAsync(new[] { crit }); + + Assert.Single(sender.Sent); + } + + [Fact] + public async Task NotifyAsync_DistinctCriticalIncidents_EachSend() + { + // Guarantee e: two genuinely distinct problems (different incident ids) are never collapsed — + // each critical incident sends its own e-mail. + App.AnalysisNotifySeverity = 1.5; + App.AnalysisNotifyCooldownMinutes = 360; + var (notifier, sender) = MakeCapturingNotifier(); + + await notifier.NotifyAsync(new[] + { + MakeFinding("aaaa000000000001", severity: 2.0, incidentId: "inc-A"), + MakeFinding("bbbb000000000002", severity: 2.0, incidentId: "inc-B") + }); + + Assert.Equal(2, sender.Sent.Count); + } + + [Fact] + public async Task NotifyAsync_BelowCriticalEmptyIncidentId_KeepsPerHashBuckets() + { + // Guarantee d (below-critical branch): two below-critical findings with NO incident id must not + // collapse — each keys on "{serverId}:{StoryPathHash}" via the IncidentToken fallback, so two + // distinct empty-id below-critical findings still send two alerts. + App.AnalysisNotifySeverity = 0.75; + App.AnalysisNotifyCooldownMinutes = 360; + var (notifier, sender) = MakeCapturingNotifier(); + + await notifier.NotifyAsync(new[] + { + MakeFinding("aaaa000000000001", severity: 1.0), // empty IncidentId, below-critical + MakeFinding("bbbb000000000002", severity: 1.0) // empty IncidentId, different hash + }); + + Assert.Equal(2, sender.Sent.Count); + } + + [Fact] + public async Task NotifyAsync_LegacyEmptyIncidentId_KeepsPerHashBuckets() + { + // Regression guard: findings with NO incident id must NOT collapse into one bucket — each keys + // by its StoryPathHash, so two distinct empty-id findings still send two alerts. + App.AnalysisNotifySeverity = 1.5; + App.AnalysisNotifyCooldownMinutes = 360; + var (notifier, sender) = MakeCapturingNotifier(); + + await notifier.NotifyAsync(new[] + { + MakeFinding("aaaa000000000001", severity: 2.0), // empty IncidentId + MakeFinding("bbbb000000000002", severity: 2.0) // empty IncidentId, different hash + }); + + Assert.Equal(2, sender.Sent.Count); + } + /* ── WS2: always raise a tray balloon for a notify-worthy finding ── */ [Fact] diff --git a/Lite.Tests/AnomalyDetectorTests.cs b/Lite.Tests/AnomalyDetectorTests.cs index d1627704f..f87480f99 100644 --- a/Lite.Tests/AnomalyDetectorTests.cs +++ b/Lite.Tests/AnomalyDetectorTests.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using DuckDB.NET.Data; using PerformanceMonitor.Analysis; +using PerformanceMonitor.Analysis.Baselines; using PerformanceMonitorLite.Analysis; using PerformanceMonitorLite.Database; using Xunit; @@ -107,6 +108,24 @@ public async Task DetectBatchRequestAnomalies_Normal_NoAnomaly() Assert.DoesNotContain(anomalies, f => f.Key == "ANOMALY_BATCH_REQUESTS"); } + [Fact] + public async Task DetectBatchRequestAnomalies_LowVolumeSpike_NoAnomaly() + { + await _duckDb.InitializeAsync(); + + // Low-throughput server: a 6x relative spike but the peak stays at 300/sec + // — below the 500/sec BatchRequestFloor — must NOT be flagged. + await SeedBaselinePerfmon("Batch Requests/sec", 50, variance: 5); + for (int i = 0; i < 16; i++) + await SeedPerfmonAsync(_analysisStart.AddMinutes(i * 15), "Batch Requests/sec", 300); + + await SeedBaselineCpu(10, variance: 2); + + var anomalies = await _detector.DetectAnomaliesAsync(CreateContext()); + + Assert.DoesNotContain(anomalies, f => f.Key == "ANOMALY_BATCH_REQUESTS"); + } + // ── Session Count ── [Fact] @@ -157,6 +176,30 @@ public async Task DetectSessionAnomalies_Normal_NoAnomaly() Assert.DoesNotContain(anomalies, f => f.Key == "ANOMALY_SESSION_SPIKE"); } + [Fact] + public async Task DetectSessionAnomalies_LowCountSpike_NoAnomaly() + { + await _duckDb.InitializeAsync(); + + // Low-connection server: an 8x relative spike but the peak stays at 40 + // connections — below the 50 SessionCountFloor — must NOT be flagged. + await SeedBaselineSessions(5, variance: 1); + for (int i = 0; i < 16; i++) + { + var t = _analysisStart.AddMinutes(i * 15); + await SeedSessionStatAsync(t, "App1", 30); + await SeedSessionStatAsync(t, "App2", 10); + } + + await SeedBaselineCpu(10, variance: 2); + for (int i = 0; i < 4; i++) + await SeedCpuAsync(_analysisStart.AddMinutes(i * 15), 10); + + var anomalies = await _detector.DetectAnomaliesAsync(CreateContext()); + + Assert.DoesNotContain(anomalies, f => f.Key == "ANOMALY_SESSION_SPIKE"); + } + // ── Query Duration ── [Fact] @@ -167,9 +210,10 @@ public async Task DetectQueryDurationAnomalies_Spike_DetectsAnomaly() // Baseline: ~10000 microseconds total elapsed per collection await SeedBaselineQueryStats(10_000, variance: 1000); - // Analysis window: spike to 500000 microseconds + // Analysis window: spike to 5,000,000 microseconds (5s) — above the 1s + // QueryDurationFloorUs so a genuinely slow query is still flagged for (int i = 0; i < 16; i++) - await SeedQueryStatAsync(_analysisStart.AddMinutes(i * 15), 500_000, 100); + await SeedQueryStatAsync(_analysisStart.AddMinutes(i * 15), 5_000_000, 100); await SeedBaselineCpu(10, variance: 2); await SeedBaselineWaits(); @@ -179,6 +223,27 @@ public async Task DetectQueryDurationAnomalies_Spike_DetectsAnomaly() Assert.Contains(anomalies, f => f.Key == "ANOMALY_QUERY_DURATION"); } + [Fact] + public async Task DetectQueryDurationAnomalies_SubFloorSpike_NoAnomaly() + { + await _duckDb.InitializeAsync(); + + // A huge *relative* spike (50x baseline) whose peak stays at 500,000 us + // (0.5s) — below the 1s QueryDurationFloorUs — must NOT be flagged. Guards + // against alarming on trivially small absolute query durations, the noise + // this floor was added to suppress. + await SeedBaselineQueryStats(10_000, variance: 1000); + for (int i = 0; i < 16; i++) + await SeedQueryStatAsync(_analysisStart.AddMinutes(i * 15), 500_000, 100); + + await SeedBaselineCpu(10, variance: 2); + await SeedBaselineWaits(); + + var anomalies = await _detector.DetectAnomaliesAsync(CreateContext()); + + Assert.DoesNotContain(anomalies, f => f.Key == "ANOMALY_QUERY_DURATION"); + } + // ── Memory Pressure ── [Fact] @@ -200,6 +265,24 @@ public async Task DetectMemoryAnomalies_HighPressure_DetectsAnomaly() Assert.Contains(anomalies, f => f.Key == "ANOMALY_MEMORY_PRESSURE"); } + [Fact] + public async Task DetectMemoryAnomalies_SubFloorSpike_NoAnomaly() + { + await _duckDb.InitializeAsync(); + + // Pressure climbs sharply (40% -> 85%) but the peak stays below the 90% + // MemoryPressureFloorPct — must NOT be flagged. + await SeedBaselineMemory(40_000, 100_000); + for (int i = 0; i < 16; i++) + await SeedMemoryStatAsync(_analysisStart.AddMinutes(i * 15), 85_000, 100_000); + + await SeedBaselineCpu(10, variance: 2); + + var anomalies = await _detector.DetectAnomaliesAsync(CreateContext()); + + Assert.DoesNotContain(anomalies, f => f.Key == "ANOMALY_MEMORY_PRESSURE"); + } + [Fact] public async Task DetectMemoryAnomalies_Normal_NoAnomaly() { @@ -274,6 +357,99 @@ public async Task AnomalyFacts_ContainBaselineContextMetadata() } } + // ── Baseline quality gate + interaction trap (change 2) ── + + [Fact] + public async Task DetectCpuAnomalies_ThinBaseline_AbsoluteFallbackFires_NotSilent() + { + await _duckDb.InitializeAsync(); + + // Thin baseline: only 2 distinct days (below the Full-tier 3-day minimum) → NOT trustworthy, + // so the z-path is suppressed. But 95% CPU clears the absolute-fallback bar (90%) — the + // detector must still fire (the interaction trap: a young store fires on the higher bar, not + // silence). + await SeedThinBaselineCpu(avgCpu: 10, variance: 2); + for (int i = 0; i < 16; i++) + await SeedCpuAsync(_analysisStart.AddMinutes(i * 15), 95); + + var anomalies = await _detector.DetectAnomaliesAsync(CreateContext()); + + var cpu = anomalies.FirstOrDefault(f => f.Key == "ANOMALY_CPU_SPIKE"); + Assert.NotNull(cpu); + Assert.Equal(1.0, cpu!.Metadata["baseline_low_quality"]); // fired via the absolute fallback + // The exceedance the scorer grades off (finding 1): peak 95% ÷ the 90% CpuFallbackPct bar (>= 1.0 + // on a fire). Without this, the scorer's 2σ gate would zero the small fallback z and drop the finding. + Assert.Equal(95.0 / 90.0, cpu.Metadata["fallback_exceedance"], precision: 4); + } + + [Fact] + public async Task DetectCpuAnomalies_ThinBaseline_SuppressesPureZSpikeBelowAbsoluteBar() + { + await _duckDb.InitializeAsync(); + + // Same thin (untrustworthy) baseline. 60% CPU is a large z-spike over a 10% baseline, but it is + // BELOW the 90% absolute-fallback bar — a thin baseline must NOT let a pure z-spike fire. + await SeedThinBaselineCpu(avgCpu: 10, variance: 2); + for (int i = 0; i < 16; i++) + await SeedCpuAsync(_analysisStart.AddMinutes(i * 15), 60); + + var anomalies = await _detector.DetectAnomaliesAsync(CreateContext()); + + Assert.DoesNotContain(anomalies, f => f.Key == "ANOMALY_CPU_SPIKE"); + } + + [Fact] + public async Task DetectCpuAnomalies_HealthyBaseline_TrustsZPath_NotFallback() + { + await _duckDb.InitializeAsync(); + + // Healthy baseline: 14 distinct days → trustworthy. A 70% spike over a 10% baseline clears the + // 50% magnitude floor and fires on the TRUSTED z-path (not the fallback), with a real sigma. + await SeedBaselineCpu(10, variance: 2); + for (int i = 0; i < 16; i++) + await SeedCpuAsync(_analysisStart.AddMinutes(i * 15), 70); + + var anomalies = await _detector.DetectAnomaliesAsync(CreateContext()); + + var cpu = anomalies.FirstOrDefault(f => f.Key == "ANOMALY_CPU_SPIKE"); + Assert.NotNull(cpu); + Assert.Equal(0.0, cpu!.Metadata["baseline_low_quality"]); // trusted z-path + Assert.True(cpu.Metadata["deviation_sigma"] >= 2.0); + Assert.Equal(0.0, cpu.Metadata["fallback_exceedance"]); // trusted path carries no fallback exceedance + } + + // ── Wait profile (change 1): one ANOMALY_WAIT_PROFILE, minority-but-real wait captured ── + + [Fact] + public async Task DetectWaitAnomalies_EmitsOneProfile_CapturingMinorityButRealWait() + { + await _duckDb.InitializeAsync(); + + // Trustworthy WaitMsPerSec baseline: 14 distinct days of modest SOS. + await SeedBaselineWaits(); + await SeedBaselineCpu(10, variance: 2); // HasBaselineData canary + + // Window: SOS dominates the totals, but a real RESOURCE_SEMAPHORE (the kind of minority wait + // the old per-type detector would compare to the all-types baseline and MISS) is also present. + // The all-types profile fires once and names BOTH as contributors. + for (int i = 0; i < 16; i++) + { + var t = _analysisStart.AddMinutes(i * 15); + await SeedWaitStatAsync(t, "SOS_SCHEDULER_YIELD", 300_000); + await SeedWaitStatAsync(t, "RESOURCE_SEMAPHORE", 50_000); + } + + var anomalies = await _detector.DetectAnomaliesAsync(CreateContext()); + + var profiles = anomalies.Where(f => f.Key == "ANOMALY_WAIT_PROFILE").ToList(); + var profile = Assert.Single(profiles); // ONE fact, not one-per-type + Assert.Equal(0.0, profile.Metadata["is_new"]); // trustworthy baseline → real ratio path + Assert.True(profile.Metadata.ContainsKey("contrib_SOS_SCHEDULER_YIELD")); + Assert.True(profile.Metadata.ContainsKey("contrib_RESOURCE_SEMAPHORE"), + "the minority-but-real RESOURCE_SEMAPHORE must be captured as a contributor"); + Assert.True(profile.Metadata["ratio"] >= 4.0); + } + // ── No baseline = no anomalies ── [Fact] @@ -313,6 +489,26 @@ private async Task SeedBaselineCpu(int avgCpu, int variance) } } + /// <summary> + /// Seeds a THIN CPU baseline: many samples but only 2 distinct calendar days (7 and 14 days back, + /// the same day-of-week as the analysis start, so they land in the analysis-hour's full bucket). + /// Enough samples to be selected as a Full-tier baseline, but below the 3-distinct-day trust floor + /// — exercises the low-quality-baseline path. + /// </summary> + private async Task SeedThinBaselineCpu(int avgCpu, int variance) + { + var rng = new Random(42); + foreach (var day in new[] { 7, 14 }) + { + var baseDay = _analysisStart.AddDays(-day); + for (int i = 0; i < 8; i++) + { + var cpu = Math.Clamp(avgCpu + rng.Next(-variance, variance + 1), 0, 100); + await SeedCpuAsync(baseDay.AddMinutes(i * 3), cpu); + } + } + } + private async Task SeedBaselinePerfmon(string counterName, long avgValue, int variance) { var rng = new Random(42); diff --git a/Lite.Tests/AnomalyIncidentReconcilerTests.cs b/Lite.Tests/AnomalyIncidentReconcilerTests.cs new file mode 100644 index 000000000..6b9686815 --- /dev/null +++ b/Lite.Tests/AnomalyIncidentReconcilerTests.cs @@ -0,0 +1,257 @@ +using System.Collections.Generic; +using PerformanceMonitor.Analysis; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// <summary> +/// Tests the shared <see cref="AnomalyIncidentReconciler"/> (Stage B, change 3): after clustering +/// stamps each story's incident id, an ANOMALY_* story folds into the REGULAR finding that describes +/// the same symptom in the same run and same database, by rewriting its incident id onto the parent's. +/// Fold-don't-suppress, database-aware, and only when a real parent exists. Lives in both Lite.Tests +/// and Dashboard.Tests because the reconciler is shared and each app references the assembly. +/// </summary> +public class AnomalyIncidentReconcilerTests +{ + private static AnalysisStory Story( + string rootKey, string incidentId, string? db = null, + Dictionary<string, double>? metadata = null, double severity = 1.0, + IEnumerable<string>? path = null) => + new() + { + RootFactKey = rootKey, + IncidentId = incidentId, + DatabaseName = db, + Severity = severity, + RootFactMetadata = metadata, + // Real stories carry their full traversal path (root + members). Default to the single-key + // path the earlier tests used; pass `path` to place a family fact as a NON-ROOT member. + Path = path is null ? [rootKey] : new List<string>(path), + }; + + [Fact] + public void CxDominantWaitProfile_FoldsIntoCxpacketIncident() + { + // Dominant contrib_ is a CX* type -> family CXPACKET (mirrors GroupParallelismWaits). + var regular = Story("CXPACKET", "cx-incident", severity: 1.4); + var anomaly = Story("ANOMALY_WAIT_PROFILE", "anomaly-solo", + metadata: new() { ["contrib_CXPACKET"] = 5000, ["contrib_SOS_SCHEDULER_YIELD"] = 900 }); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { regular, anomaly }); + + Assert.Equal("cx-incident", anomaly.IncidentId); // folded into the CXPACKET incident + Assert.Equal("cx-incident", regular.IncidentId); // parent id untouched + } + + [Fact] + public void GeneralLockDominantWaitProfile_FoldsIntoLckIncident() + { + // LCK_M_X is a general lock mode -> family LCK (mirrors IsGeneralLockWait grouping). + var regular = Story("LCK", "lck-incident"); + var anomaly = Story("ANOMALY_WAIT_PROFILE", "anomaly-solo", + metadata: new() { ["contrib_LCK_M_X"] = 8000, ["contrib_CXPACKET"] = 100 }); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { regular, anomaly }); + + Assert.Equal("lck-incident", anomaly.IncidentId); + } + + [Fact] + public void ReaderLockDominantWaitProfile_FoldsIntoOwnKey_NotLck() + { + // LCK_M_S is kept separate from the LCK family (RCSI signal) -> family LCK_M_S, so it must + // fold into the LCK_M_S finding, NOT the general LCK one. + var lck = Story("LCK", "lck-incident"); + var readerLock = Story("LCK_M_S", "readerlock-incident"); + var anomaly = Story("ANOMALY_WAIT_PROFILE", "anomaly-solo", + metadata: new() { ["contrib_LCK_M_S"] = 8000, ["contrib_LCK_M_X"] = 10 }); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { lck, readerLock, anomaly }); + + Assert.Equal("readerlock-incident", anomaly.IncidentId); + } + + [Fact] + public void CpuSpike_FoldsIntoCpuIncident() + { + var regular = Story("CPU_SQL_PERCENT", "cpu-incident"); + var anomaly = Story("ANOMALY_CPU_SPIKE", "anomaly-solo"); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { regular, anomaly }); + + Assert.Equal("cpu-incident", anomaly.IncidentId); + } + + [Fact] + public void NoRegularParent_AnomalyStaysSolo() + { + // ANOMALY_CPU_SPIKE with no CPU_SQL_PERCENT parent (only an unrelated regular story) stays solo. + var unrelated = Story("CXPACKET", "cx-incident"); + var anomaly = Story("ANOMALY_CPU_SPIKE", "anomaly-solo"); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { unrelated, anomaly }); + + Assert.Equal("anomaly-solo", anomaly.IncidentId); + } + + [Fact] + public void DifferentDatabase_DoesNotFold() + { + // The anomaly is in db1, the only regular parent is in db2 -> DB-aware, no fold. + var regular = Story("CPU_SQL_PERCENT", "cpu-db2", db: "db2"); + var anomaly = Story("ANOMALY_CPU_SPIKE", "anomaly-db1", db: "db1"); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { regular, anomaly }); + + Assert.Equal("anomaly-db1", anomaly.IncidentId); + } + + [Fact] + public void SameDatabase_Folds() + { + var regular = Story("CPU_SQL_PERCENT", "cpu-db1", db: "db1"); + var anomaly = Story("ANOMALY_CPU_SPIKE", "anomaly-db1", db: "db1"); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { regular, anomaly }); + + Assert.Equal("cpu-db1", anomaly.IncidentId); + } + + [Fact] + public void AnomalyObject_NeverCrossDbFolds() + { + // ANOMALY_OBJECT_* is unmapped AND db-scoped: it never folds, and certainly not into a + // regular finding in a different database. + var regularDb2 = Story("BLOCKING_EVENTS", "blk-db2", db: "db2"); + var objectAnomaly = Story("ANOMALY_OBJECT_CONTENTION", "object-db1", db: "db1"); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { regularDb2, objectAnomaly }); + + Assert.Equal("object-db1", objectAnomaly.IncidentId); + } + + [Fact] + public void UnmappedAnomaly_StaysSolo() + { + // Batch/session/query-duration anomalies have no single regular counterpart. + var regular = Story("CPU_SQL_PERCENT", "cpu-incident"); + var anomaly = Story("ANOMALY_BATCH_REQUESTS", "anomaly-solo"); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { regular, anomaly }); + + Assert.Equal("anomaly-solo", anomaly.IncidentId); + } + + [Fact] + public void AbsolutionParent_IsIgnored() + { + // An absolution story is never a fold target. + var absolution = new AnalysisStory { RootFactKey = "server_health", IsAbsolution = true, IncidentId = "" }; + var anomaly = Story("ANOMALY_CPU_SPIKE", "anomaly-solo"); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { absolution, anomaly }); + + Assert.Equal("anomaly-solo", anomaly.IncidentId); + } + + [Fact] + public void FewerThanTwoStories_NoOp() + { + AnomalyIncidentReconciler.Reconcile(null!); // no throw + var solo = new List<AnalysisStory> { Story("ANOMALY_CPU_SPIKE", "anomaly-solo") }; + AnomalyIncidentReconciler.Reconcile(solo); + Assert.Equal("anomaly-solo", solo[0].IncidentId); + } + + /* ── Path-based fold-target indexing (review finding 2): a mapped family fact is frequently a + NON-ROOT member of a larger story, so fold targets are indexed by every key in a regular story's + PATH, not just its root — the case the earlier tests (family fact always AS the root) never hit. ── */ + + [Fact] + public void CpuSpike_FoldsIntoStory_WhereCpuSqlPercentIsNonRootMember() + { + // Canonical correlated run: a higher-severity SOS_SCHEDULER_YIELD roots the story and consumes + // CPU_SQL_PERCENT as a member (SOS -> CPU_SQL_PERCENT). No story is ROOTED on CPU_SQL_PERCENT, so + // root-only indexing missed this exact case; path indexing folds the anomaly in. + var cpuStory = Story("SOS_SCHEDULER_YIELD", "cpu-incident", severity: 1.7, + path: new[] { "SOS_SCHEDULER_YIELD", "CPU_SQL_PERCENT" }); + var anomaly = Story("ANOMALY_CPU_SPIKE", "anomaly-solo"); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { cpuStory, anomaly }); + + Assert.Equal("cpu-incident", anomaly.IncidentId); + } + + [Fact] + public void BlockingSpike_FoldsIntoStory_WhereBlockingIsNonRootMember() + { + // Generality beyond CPU: BLOCKING_EVENTS consumed as a member of a THREADPOOL-rooted story + // (THREADPOOL/LCK -> BLOCKING_EVENTS). The root key is the THREADPOOL relabel (not a fold + // target itself), but its BLOCKING_EVENTS member is indexed and captures the anomaly. + var threadpool = Story("THREADPOOL_BLOCKING", "blk-incident", severity: 1.8, + path: new[] { "THREADPOOL_BLOCKING", "BLOCKING_EVENTS" }); + var anomaly = Story("ANOMALY_BLOCKING_SPIKE", "anomaly-solo"); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { threadpool, anomaly }); + + Assert.Equal("blk-incident", anomaly.IncidentId); + } + + [Fact] + public void CpuSpike_FoldsIntoCpuSpikeRootedStory() + { + // Sub-note: a run whose CPU story is keyed on the BURST detector (CPU_SPIKE) rather than the + // sustained CPU_SQL_PERCENT is still a valid parent for ANOMALY_CPU_SPIKE. + var burst = Story("CPU_SPIKE", "burst-incident"); + var anomaly = Story("ANOMALY_CPU_SPIKE", "anomaly-solo"); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { burst, anomaly }); + + Assert.Equal("burst-incident", anomaly.IncidentId); + } + + [Fact] + public void CpuSpike_FoldsIntoStory_WhereCpuSpikeIsNonRootMember() + { + // Sub-note, non-root form: CPU_SPIKE consumed as a member of a plan-regression story + // (PLAN_REGRESSION -> CPU_SPIKE) is still a valid CPU parent for the anomaly. + var planStory = Story("PLAN_REGRESSION", "plan-incident", severity: 1.5, + path: new[] { "PLAN_REGRESSION", "CPU_SPIKE" }); + var anomaly = Story("ANOMALY_CPU_SPIKE", "anomaly-solo"); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { planStory, anomaly }); + + Assert.Equal("plan-incident", anomaly.IncidentId); + } + + [Fact] + public void PathIndexing_DoesNotFoldIntoUnrelatedMultiKeyStory() + { + // No over-fold: a multi-key story that does NOT contain a CPU family key must not capture + // ANOMALY_CPU_SPIKE, even though path indexing now considers every member. + var unrelated = Story("ASYNC_NETWORK_IO", "net-incident", severity: 1.3, + path: new[] { "ASYNC_NETWORK_IO", "PAGELATCH_EX" }); + var anomaly = Story("ANOMALY_CPU_SPIKE", "anomaly-solo"); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { unrelated, anomaly }); + + Assert.Equal("anomaly-solo", anomaly.IncidentId); + } + + [Fact] + public void MultiKeyIndexing_FoldsIntoOwningStory_LeavesOthersUntouched() + { + // With two unrelated regular stories, the anomaly folds into the ONE whose path owns the CPU + // family (as a non-root member) and leaves the other incident id untouched — no over-fold. + var ioStory = Story("IO_READ_LATENCY_MS", "io-incident", severity: 1.2); + var cpuStory = Story("SOS_SCHEDULER_YIELD", "cpu-incident", severity: 1.7, + path: new[] { "SOS_SCHEDULER_YIELD", "CPU_SQL_PERCENT" }); + var anomaly = Story("ANOMALY_CPU_SPIKE", "anomaly-solo"); + + AnomalyIncidentReconciler.Reconcile(new List<AnalysisStory> { ioStory, cpuStory, anomaly }); + + Assert.Equal("cpu-incident", anomaly.IncidentId); + Assert.Equal("io-incident", ioStory.IncidentId); // unrelated story untouched + Assert.Equal("cpu-incident", cpuStory.IncidentId); // parent id untouched + } +} diff --git a/Lite.Tests/ArchiveViewDedupTests.cs b/Lite.Tests/ArchiveViewDedupTests.cs new file mode 100644 index 000000000..be6f1c9a2 --- /dev/null +++ b/Lite.Tests/ArchiveViewDedupTests.cs @@ -0,0 +1,175 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using PerformanceMonitorLite.Database; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// <summary> +/// Guards the natural-key dedup on the archive views that UNION the hot table with the parquet archive. +/// The 512MB emergency reset (ArchiveService.ArchiveAllAndResetAsync) archives all hot data to parquet and +/// wipes collection_log, so the next cycle re-collects recent history into the hot store while the parquet +/// tier still holds it. The local surrogate prefix id is a per-process counter, so the re-collected rows get +/// brand-new ids — only the SQL-Server-side natural key (job_history: server_id+instance_id; +/// default_trace_events: server_id+event_time+event_sequence) identifies a logical event. Without the QUALIFY +/// dedup those events would appear twice in v_job_history / v_default_trace_events. +/// </summary> +public class ArchiveViewDedupTests : IDisposable +{ + private readonly string _tempDir; + private readonly string _dbPath; + private readonly string _archivePath; + + public ArchiveViewDedupTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "LiteTests_" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(_tempDir); + _dbPath = Path.Combine(_tempDir, "test.duckdb"); + _archivePath = Path.Combine(_tempDir, "archive"); + Directory.CreateDirectory(_archivePath); + } + + public void Dispose() + { + try + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + catch + { + /* Best-effort cleanup */ + } + } + + private async Task<DuckDBConnection> OpenAsync() + { + var connection = new DuckDBConnection($"Data Source={_dbPath}"); + await connection.OpenAsync(TestContext.Current.CancellationToken); + return connection; + } + + private async Task ExecuteAsync(DuckDBConnection connection, string sql) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private async Task<T> ScalarAsync<T>(DuckDBConnection connection, string sql) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + var result = await cmd.ExecuteScalarAsync(TestContext.Current.CancellationToken); + return (T)Convert.ChangeType(result!, typeof(T)); + } + + /// <summary> + /// Simulates archive → reset → re-collect: writes an "archived" set to parquet, empties the hot table, + /// then inserts a "re-collected" set that overlaps the archived set on the natural key (with new surrogate + /// ids and a newer collection_time). Copies the whole hot table to a *_{table}.parquet file so the archive + /// view's glob picks it up, exactly like ArchiveService does. + /// </summary> + private async Task StageArchiveAndRecollectAsync( + DuckDBConnection connection, string table, string archivedInsert, string recollectedInsert) + { + await ExecuteAsync(connection, archivedInsert); + var parquetPath = Path.Combine(_archivePath, $"20260101_0000_{table}.parquet").Replace("\\", "/"); + await ExecuteAsync(connection, $"COPY {table} TO '{parquetPath}' (FORMAT PARQUET)"); + await ExecuteAsync(connection, $"DELETE FROM {table}"); + await ExecuteAsync(connection, recollectedInsert); + } + + [Fact] + public async Task VJobHistory_DedupsReCollectedRowsOnServerAndInstanceId() + { + var initializer = new DuckDbInitializer(_dbPath); + await initializer.InitializeAsync(); + + using (var connection = await OpenAsync()) + { + /* Archived (parquet) copy, collected before the reset. */ + var archived = @" +INSERT INTO job_history + (job_history_id, collection_time, server_id, server_name, instance_id, job_id, job_name, + job_enabled, step_id, run_status, run_duration_seconds, retries_attempted, message) +VALUES + (1, TIMESTAMP '2026-01-01 00:00:00', 1, 'S1', 100, 'j', 'Job', true, 0, 1, 10, 0, 'archived-A'), + (2, TIMESTAMP '2026-01-01 00:00:00', 1, 'S1', 200, 'j', 'Job', true, 0, 1, 10, 0, 'archived-B'), + (3, TIMESTAMP '2026-01-01 00:00:00', 1, 'S1', 300, 'j', 'Job', true, 0, 1, 10, 0, 'archived-C')"; + /* Re-collected (hot) copy after the reset: instance_id 200/300 reappear with new surrogate ids and + a newer collection_time; 400 is genuinely new. */ + var recollected = @" +INSERT INTO job_history + (job_history_id, collection_time, server_id, server_name, instance_id, job_id, job_name, + job_enabled, step_id, run_status, run_duration_seconds, retries_attempted, message) +VALUES + (11, TIMESTAMP '2026-06-01 00:00:00', 1, 'S1', 200, 'j', 'Job', true, 0, 1, 10, 0, 'hot-B'), + (12, TIMESTAMP '2026-06-01 00:00:00', 1, 'S1', 300, 'j', 'Job', true, 0, 1, 10, 0, 'hot-C'), + (13, TIMESTAMP '2026-06-01 00:00:00', 1, 'S1', 400, 'j', 'Job', true, 0, 1, 10, 0, 'hot-D')"; + await StageArchiveAndRecollectAsync(connection, "job_history", archived, recollected); + } + + await initializer.CreateArchiveViewsAsync(); + + using (var connection = await OpenAsync()) + { + /* Four distinct logical events, not six: the two re-collected duplicates collapse. */ + Assert.Equal(4, await ScalarAsync<int>(connection, "SELECT COUNT(*) FROM v_job_history")); + /* No instance_id survives twice. */ + Assert.Equal(1, await ScalarAsync<int>(connection, + "SELECT MAX(c) FROM (SELECT COUNT(*) AS c FROM v_job_history GROUP BY server_id, instance_id)")); + /* The archive-only row is still present (the union really does reach the parquet tier). */ + Assert.Equal("archived-A", await ScalarAsync<string>(connection, + "SELECT message FROM v_job_history WHERE instance_id = 100")); + /* For the overlapping keys the newest-collected (hot) copy wins. */ + Assert.Equal("hot-B", await ScalarAsync<string>(connection, + "SELECT message FROM v_job_history WHERE instance_id = 200")); + Assert.Equal("hot-C", await ScalarAsync<string>(connection, + "SELECT message FROM v_job_history WHERE instance_id = 300")); + } + } + + [Fact] + public async Task VDefaultTraceEvents_DedupsReCollectedRowsOnEventTimeAndSequence() + { + var initializer = new DuckDbInitializer(_dbPath); + await initializer.InitializeAsync(); + + using (var connection = await OpenAsync()) + { + var archived = @" +INSERT INTO default_trace_events + (default_trace_event_id, collection_time, server_id, server_name, event_time, event_name, event_sequence) +VALUES + (1, TIMESTAMP '2026-01-01 00:00:00', 1, 'S1', TIMESTAMP '2026-01-01 10:00:01', 'archived-A', 1), + (2, TIMESTAMP '2026-01-01 00:00:00', 1, 'S1', TIMESTAMP '2026-01-01 10:00:02', 'archived-B', 2), + (3, TIMESTAMP '2026-01-01 00:00:00', 1, 'S1', TIMESTAMP '2026-01-01 10:00:03', 'archived-C', 3)"; + var recollected = @" +INSERT INTO default_trace_events + (default_trace_event_id, collection_time, server_id, server_name, event_time, event_name, event_sequence) +VALUES + (11, TIMESTAMP '2026-06-01 00:00:00', 1, 'S1', TIMESTAMP '2026-01-01 10:00:02', 'hot-B', 2), + (12, TIMESTAMP '2026-06-01 00:00:00', 1, 'S1', TIMESTAMP '2026-01-01 10:00:03', 'hot-C', 3), + (13, TIMESTAMP '2026-06-01 00:00:00', 1, 'S1', TIMESTAMP '2026-01-01 10:00:04', 'hot-D', 4)"; + await StageArchiveAndRecollectAsync(connection, "default_trace_events", archived, recollected); + } + + await initializer.CreateArchiveViewsAsync(); + + using (var connection = await OpenAsync()) + { + Assert.Equal(4, await ScalarAsync<int>(connection, "SELECT COUNT(*) FROM v_default_trace_events")); + Assert.Equal(1, await ScalarAsync<int>(connection, + "SELECT MAX(c) FROM (SELECT COUNT(*) AS c FROM v_default_trace_events GROUP BY server_id, event_time, event_sequence)")); + Assert.Equal("archived-A", await ScalarAsync<string>(connection, + "SELECT event_name FROM v_default_trace_events WHERE event_sequence = 1")); + Assert.Equal("hot-B", await ScalarAsync<string>(connection, + "SELECT event_name FROM v_default_trace_events WHERE event_sequence = 2")); + Assert.Equal("hot-C", await ScalarAsync<string>(connection, + "SELECT event_name FROM v_default_trace_events WHERE event_sequence = 3")); + } + } +} diff --git a/Lite.Tests/AzureMasterFallbackTests.cs b/Lite.Tests/AzureMasterFallbackTests.cs new file mode 100644 index 000000000..77fd187fe --- /dev/null +++ b/Lite.Tests/AzureMasterFallbackTests.cs @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using PerformanceMonitorLite.Models; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// <summary> +/// Regression tests for #1506. +/// +/// Reported by a user monitoring Azure SQL DB whose public IP rotates daily, so the server's +/// firewall rule stops matching and connections are rejected with error 40615. Restoring the rule +/// brought the server back, but collection stayed broken until Lite was restarted. +/// +/// The cause: 40615 (firewall) and 40613 (database temporarily unavailable) were classified as +/// "this login cannot read master" — a verdict that was cached per server and never expired. One +/// unlucky moment during an outage permanently demoted a healthy server to single-database +/// collection, or, when the connection had no explicit database, to collecting nothing at all. +/// +/// The fixes pinned here: reachability errors are not permission errors; the verdict expires; and a +/// server coming back from an outage discards it immediately. +/// +/// Only in-memory state is touched, so the service is constructed with null dependencies — the same +/// approach as <see cref="XeSessionHealthTests"/>. +/// </summary> +public class AzureMasterFallbackTests +{ + private const int ServerId = 7; + + private static RemoteCollectorService CreateService() => + new(duckDb: null!, serverManager: null!, scheduleManager: null!); + + private static ServerConnection Server(string name = "azure-logical-server") => + new() { ServerName = name, DisplayName = name }; + + /// <summary> + /// The bug itself. 40615 is "client with IP address ... is not allowed to access the server": + /// a firewall rejection at the logical server, which says nothing about master, and which no + /// fallback can route around because the same rule blocks every other database too. + /// 40613 is "not currently available, please retry later" — transient on its face. + /// </summary> + [Theory] + [InlineData(40615)] // Cannot open server — client IP not allowed (firewall) + [InlineData(40613)] // Database not currently available — retry later + public void Reachability_Errors_Are_Not_Master_Access_Denied(int errorNumber) + { + Assert.False(RemoteCollectorService.IsMasterAccessDeniedError(errorNumber)); + } + + /// <summary> + /// #857 must keep working: a contained user that exists only in a user database really does get + /// these when it opens master, and single-database fallback is the right answer for them. + /// </summary> + [Theory] + [InlineData(229)] // Permission denied on object + [InlineData(230)] // Permission denied on column + [InlineData(916)] // Principal cannot access the database under the current security context + [InlineData(4060)] // Cannot open database requested by the login + [InlineData(18456)] // Login failed for user + public void Permission_Errors_Are_Still_Master_Access_Denied(int errorNumber) + { + Assert.True(RemoteCollectorService.IsMasterAccessDeniedError(errorNumber)); + } + + /// <summary> + /// The invariant that would have caught this at authoring time: no error number may be both + /// "retry, this is temporary" and "stop, this is permanent". 40613 was on both lists. + /// </summary> + [Fact] + public void No_Error_Is_Both_Transient_And_Permanently_Fatal() + { + var contradictory = RetryHelper.TransientErrorNumbers + .Where(RemoteCollectorService.IsMasterAccessDeniedError) + .ToList(); + + Assert.True( + contradictory.Count == 0, + $"Error(s) {string.Join(", ", contradictory)} are treated as retryable-transient AND as a " + + "permanent master-access verdict. Pick one — see #1506."); + } + + /// <summary> + /// The silent half of the bug. On Azure SQL DB with no explicit database, InitialCatalog resolves + /// to master, and a single-database fallback list of "master" is empty — so every database-scoped + /// collector iterated nothing, wrote zero rows, and reported SUCCESS. The status bar read + /// "Collection: Running / Collectors: N OK" while absolutely nothing was being collected. + /// </summary> + [Theory] + [InlineData("master")] // the InitialCatalog default when no database is configured + [InlineData("")] + [InlineData(null)] + public void No_Database_To_Fall_Back_To_Throws_Rather_Than_Collecting_Nothing(string? targetDb) + { + var ex = Assert.Throws<InvalidOperationException>( + () => RemoteCollectorService.FallbackDatabaseList(Server(), targetDb, reason: "master DB inaccessible")); + + /* The message has to tell them what to actually do about it. */ + Assert.Contains("Set a Database", ex.Message); + } + + /// <summary> + /// RunCollectorAsync catches InvalidOperationException TWICE: once filtered on + /// `ex.Message.Contains("MFA authentication cancelled")` — which records the benign status + /// SKIPPED — and once in the general handler, which records ERROR. If the new throw's wording + /// ever drifted into matching that filter, the failure would be logged as a routine skip and the + /// silence this fix exists to break would come straight back. + /// </summary> + [Fact] + public void Throw_Is_Not_Mistaken_For_An_Mfa_Cancellation() + { + var ex = Assert.Throws<InvalidOperationException>( + () => RemoteCollectorService.FallbackDatabaseList(Server(), "master", reason: "master DB inaccessible")); + + Assert.DoesNotContain("MFA authentication cancelled", ex.Message); + } + + [Fact] + public void A_Configured_Database_Is_Used_As_The_Fallback() + { + var databases = RemoteCollectorService.FallbackDatabaseList( + Server(), "AdventureWorks", reason: "master DB inaccessible"); + + /* The #857 path: master is unreadable, but the connection names a database, so collect there. */ + Assert.Equal(new[] { "AdventureWorks" }, databases); + } + + [Fact] + public void Fresh_Verdict_Suppresses_Re_Probing_Master() + { + var service = CreateService(); + + service.MarkMasterInaccessible(ServerId); + + /* #857: a login that genuinely cannot read master shouldn't retry it every cycle. */ + Assert.True(service.IsMasterProbeThrottled(ServerId)); + } + + [Fact] + public void Verdict_Expires_So_A_Server_Recovers_Without_A_Restart() + { + var service = CreateService(); + + service.MarkMasterInaccessible(ServerId, DateTime.UtcNow.AddMinutes(-16)); + + /* Past the 15-minute recheck window: probe master again rather than trusting a stale verdict. + This is the backstop for an outage that never produced a clean offline->online transition. */ + Assert.False(service.IsMasterProbeThrottled(ServerId)); + } + + [Fact] + public void Server_Returning_From_An_Outage_Discards_The_Verdict_Immediately() + { + var service = CreateService(); + var server = Server(); + var serverId = RemoteCollectorService.GetServerId(server); + + service.MarkMasterInaccessible(serverId); + service.NoteServerOffline(server); + service.NoteServerOnline(server); + + /* Firewall rule restored, server answers again, verdict dropped — so database-scoped collection + resumes on the next cycle instead of waiting for an app restart. + + This pins the offline->online RULE, not the wiring: RunDueCollectorsAsync is what calls these + two on the real edge, and it needs a live ServerManager + ScheduleManager to drive. */ + Assert.False(service.IsMasterProbeThrottled(serverId)); + } + + [Fact] + public void Verdict_Survives_A_Server_That_Was_Never_Offline() + { + var service = CreateService(); + var server = Server(); + var serverId = RemoteCollectorService.GetServerId(server); + + service.MarkMasterInaccessible(serverId); + service.NoteServerOnline(server); + + /* The #857 steady state: reachable server, login simply lacks master rights. There was no + outage to invalidate the verdict, so it stands and master is not re-probed every minute. */ + Assert.True(service.IsMasterProbeThrottled(serverId)); + } + + [Fact] + public void Offline_Tracking_Is_Per_Server() + { + var service = CreateService(); + var recovered = Server("recovered-server"); + var stillDenied = Server("still-denied-server"); + + var recoveredId = RemoteCollectorService.GetServerId(recovered); + var stillDeniedId = RemoteCollectorService.GetServerId(stillDenied); + + service.MarkMasterInaccessible(recoveredId); + service.MarkMasterInaccessible(stillDeniedId); + + service.NoteServerOffline(recovered); + service.NoteServerOnline(recovered); + + Assert.False(service.IsMasterProbeThrottled(recoveredId)); + Assert.True(service.IsMasterProbeThrottled(stillDeniedId)); + } +} diff --git a/Lite.Tests/BaselineProviderTests.cs b/Lite.Tests/BaselineProviderTests.cs index 3a465cf4d..085d4ac5a 100644 --- a/Lite.Tests/BaselineProviderTests.cs +++ b/Lite.Tests/BaselineProviderTests.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using DuckDB.NET.Data; using PerformanceMonitor.Analysis; +using PerformanceMonitor.Analysis.Baselines; using PerformanceMonitorLite.Analysis; using PerformanceMonitorLite.Database; using Xunit; @@ -126,6 +127,71 @@ public async Task GetBaseline_VerySparseBucket_CollapsesToFlat() Assert.Equal(BaselineTier.Flat, baseline.Tier); } + // ── DistinctDays (baseline quality gate, change 2) ── + + [Fact] + public async Task GetBaseline_FullBucket_PopulatesDistinctDaysAndIsTrustworthy() + { + await _duckDb.InitializeAsync(); + + // 20 samples across 4 distinct Wednesdays at 14:xx. + for (int week = 0; week < 4; week++) + { + var wednesday = AnalysisTime.AddDays(-7 * (week + 1)); + for (int i = 0; i < 5; i++) + await SeedCpuAsync(wednesday.AddMinutes(i * 10), 50 + i * 2); + } + + var baseline = await _provider.GetBaselineAsync(ServerId, MetricNames.Cpu, AnalysisTime); + + Assert.Equal(BaselineTier.Full, baseline.Tier); + Assert.Equal(4, baseline.DistinctDays); // 4 distinct Wednesdays + Assert.True(baseline.IsTrustworthy); // Full needs >= 3 distinct days; 4 clears it + } + + [Fact] + public async Task GetBaseline_HourOnly_SumsDistinctDaysAcrossDowBuckets() + { + await _duckDb.InitializeAsync(); + + // Same hour (14:00) across 4 distinct calendar days of DIFFERENT days-of-week, 4 samples each + // (below Full's restore) → collapses to hour-only. Each calendar day lands in exactly one + // day-of-week bucket, so summing distinct-days across those buckets is exact (= 4). + for (int d = 0; d < 4; d++) + { + var day = AnalysisTime.AddDays(-7 - d); + for (int i = 0; i < 4; i++) + await SeedCpuAsync(day.AddMinutes(i * 10), 40 + i); + } + + _provider.ClearCache(); + var baseline = await _provider.GetBaselineAsync(ServerId, MetricNames.Cpu, AnalysisTime); + + Assert.Equal(BaselineTier.HourOnly, baseline.Tier); + Assert.Equal(4, baseline.DistinctDays); // SUM across the 4 dow buckets + } + + [Fact] + public async Task GetBaseline_Flat_TakesMaxDistinctDaysAcrossBuckets() + { + await _duckDb.InitializeAsync(); + + // Everything on ONE calendar day (2 samples at 14:xx + 5 samples at other hours) → collapses to + // flat. A calendar day recurs across hour buckets, so DistinctDays is the conservative MAX (= 1), + // not the sum. + var day = AnalysisTime.AddDays(-7); + await SeedCpuAsync(day.AddMinutes(0), 30); + await SeedCpuAsync(day.AddMinutes(15), 35); + for (int h = 0; h < 5; h++) + await SeedCpuAsync(day.AddHours(-h - 1), 50 + h); + + _provider.ClearCache(); + var baseline = await _provider.GetBaselineAsync(ServerId, MetricNames.Cpu, AnalysisTime); + + Assert.Equal(BaselineTier.Flat, baseline.Tier); + Assert.Equal(1, baseline.DistinctDays); + } + // ── Empty baseline ── [Fact] diff --git a/Lite.Tests/BlockedProcessReportCollectorDefinitionTests.cs b/Lite.Tests/BlockedProcessReportCollectorDefinitionTests.cs new file mode 100644 index 000000000..d10c24fc4 --- /dev/null +++ b/Lite.Tests/BlockedProcessReportCollectorDefinitionTests.cs @@ -0,0 +1,289 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Pins the parity contract of the extracted blocked_process_report definition: the server- vs +/// database-scoped ring-buffer reads, the sp_HumanEventsBlockViewer-mirroring wait_resource → +/// contentious-object resolution batch, the event_time watermark with its 10-minute fallback, +/// the 40-column payload, the report-XML parse (blocked + blocking process attributes, +/// monitorLoop root attribute, null on garbage/missing blocked-process), and the #1262 gated +/// best-effort plan resolution (off = byte-identical; on = frame → dm_exec_query_stats → +/// dm_exec_text_query_plan for both contending sides). Third Name≠TargetTable case +/// (blocked_process_report → blocked_process_reports). +/// </summary> +public sealed class BlockedProcessReportCollectorDefinitionTests +{ + private static readonly RecordingCollectorDeltaCalculator s_deltas = new(); + + private static string Collapse(string sql) => Regex.Replace(sql, @"\s+", ""); + + private static CollectorContext MakeContext( + bool isAzureSqlDb = false, + DateTime? watermark = null, + DateTime? collectionTime = null, + bool capturePlanXml = false) + => new() + { + ServerId = 42, + ServerName = "test-server", + CollectionTime = collectionTime ?? new DateTime(2026, 7, 2, 12, 0, 0, DateTimeKind.Utc), + Deltas = s_deltas, + Target = new CollectorTargetInfo { IsAzureSqlDb = isAzureSqlDb }, + Watermark = watermark, + CapturePlanXml = capturePlanXml, + }; + + [Fact] + public void Identity_ThirdNameTargetTableSplit_WithWatermark() + { + Assert.Equal("blocked_process_report", BlockedProcessReportCollector.Instance.Name); + Assert.Equal("blocked_process_reports", BlockedProcessReportCollector.Instance.TargetTable); + Assert.Equal("event_time", BlockedProcessReportCollector.Instance.WatermarkColumn); + Assert.Equal("PerformanceMonitor_BlockedProcess", BlockedProcessReportCollector.XeSessionName); + } + + [Fact] + public void RunsPerDatabase_OnAzureOnly_WithPerDatabaseWatermark() + { + /* #1535: Azure capture is one database-scoped session per monitored database, so the read + loops databases and each database dedups against its own watermark (database_name is the + blocked process's currentdbname the report parse has always extracted). Server-scoped + platforms keep the single run + server-wide watermark. */ + Assert.True(BlockedProcessReportCollector.Instance.RunsPerDatabase(new CollectorTargetInfo { IsAzureSqlDb = true })); + Assert.False(BlockedProcessReportCollector.Instance.RunsPerDatabase(new CollectorTargetInfo())); + Assert.False(BlockedProcessReportCollector.Instance.RunsPerDatabase(new CollectorTargetInfo { IsAzureManagedInstance = true })); + Assert.Equal("database_name", BlockedProcessReportCollector.Instance.PerDatabaseWatermarkColumn); + } + + [Fact] + public void BuildQuery_ServerScoped_PinsResolutionBatch() + { + var plan = BlockedProcessReportCollector.Instance.BuildQuery(MakeContext()); + + Assert.Contains("sys.dm_xe_session_targets AS xet", plan.Text, StringComparison.Ordinal); + Assert.DoesNotContain("dm_xe_database_session_targets", plan.Text, StringComparison.Ordinal); + Assert.Contains("N'PerformanceMonitor_BlockedProcess'", plan.Text, StringComparison.Ordinal); + Assert.Contains("event[@name=\"blocked_process_report\"]", plan.Text, StringComparison.Ordinal); + Assert.Contains("INTO #bpr", plan.Text, StringComparison.Ordinal); + Assert.Contains(".sys.partitions AS p", plan.Text, StringComparison.Ordinal); + Assert.Contains("IF @product_version >= 15", plan.Text, StringComparison.Ordinal); + Assert.Contains("sys.dm_db_page_info(b.resource_database_id, b.resource_file_id, b.resource_page_id, ''LIMITED'')", plan.Text, StringComparison.Ordinal); + Assert.Contains("N'Unresolved: ' +", plan.Text, StringComparison.Ordinal); + Assert.Contains("> @cutoff_time", plan.Text, StringComparison.Ordinal); + /* The final projection keeps the original 5 reader columns. */ + Assert.Contains("b.contentious_object\nFROM #bpr AS b", plan.Text.Replace("\r\n", "\n", StringComparison.Ordinal), StringComparison.Ordinal); + } + + [Fact] + public void BuildQuery_Azure_UsesDatabaseScopedDmvs() + { + var plan = BlockedProcessReportCollector.Instance.BuildQuery(MakeContext(isAzureSqlDb: true)); + + Assert.Contains("sys.dm_xe_database_session_targets AS xet", plan.Text, StringComparison.Ordinal); + Assert.Contains("JOIN sys.dm_xe_database_sessions AS xes", plan.Text, StringComparison.Ordinal); + Assert.Contains("event[@name=\"blocked_process_report\"]", plan.Text, StringComparison.Ordinal); + } + + [Fact] + public void BuildQuery_PinsWatermarkCutoff_AndTenMinuteFallback() + { + var watermark = new DateTime(2026, 7, 2, 11, 45, 0, DateTimeKind.Utc); + var withWatermark = BlockedProcessReportCollector.Instance.BuildQuery(MakeContext(watermark: watermark)); + var parameter = Assert.Single(withWatermark.Parameters); + Assert.Equal("@cutoff_time", parameter.Name); + Assert.Equal(watermark, parameter.Value); + Assert.Equal(CollectorParameterType.DateTime2, parameter.Type); + + var collectionTime = new DateTime(2026, 7, 2, 12, 0, 0, DateTimeKind.Utc); + var fallback = BlockedProcessReportCollector.Instance.BuildQuery(MakeContext(collectionTime: collectionTime)); + Assert.Equal(collectionTime.AddMinutes(-10), Assert.Single(fallback.Parameters).Value); + } + + [Fact] + public void PayloadColumns_MatchSchemaOrder_40Columns() + { + var names = BlockedProcessReportCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray(); + + Assert.Equal(40, names.Length); + Assert.Equal("event_time", names[0]); + Assert.Equal("blocked_spid", names[2]); + Assert.Equal("wait_time_ms", names[6]); + Assert.Equal("blocked_sql_text", names[16]); + Assert.Equal("blocking_sql_text", names[22]); + Assert.Equal("blocked_process_report_xml", names[33]); + Assert.Equal("object_id", names[34]); + Assert.Equal("database_id", names[35]); + Assert.Equal("contentious_object", names[36]); + Assert.Equal("monitor_loop", names[37]); + Assert.Equal("blocked_query_plan_xml", names[38]); /* trailing best-effort plans (#1262) */ + Assert.Equal("blocking_query_plan_xml", names[39]); + } + + [Fact] + public void BuildQuery_PlanCaptureOffByDefault_NoResolution_LiteParity() + { + /* Lite never sets CapturePlanXml: no plan columns, no frame shred, no resolution join — + byte-identical to the no-plan form (server-scoped AND Azure). */ + var serverScoped = BlockedProcessReportCollector.Instance.BuildQuery(MakeContext()); + var azure = BlockedProcessReportCollector.Instance.BuildQuery(MakeContext(isAzureSqlDb: true)); + + foreach (var text in new[] { serverScoped.Text, azure.Text }) + { + Assert.DoesNotContain("query_plan", text, StringComparison.Ordinal); + Assert.DoesNotContain("dm_exec_text_query_plan", text, StringComparison.Ordinal); + Assert.DoesNotContain("executionStack/frame", text, StringComparison.Ordinal); + } + } + + [Fact] + public void BuildQuery_PlanCaptureOn_ResolvesBothSidesFromFrameHandles() + { + var plan = BlockedProcessReportCollector.Instance.BuildQuery(MakeContext(capturePlanXml: true)); + var collapsed = Collapse(plan.Text); + + /* Two projected plan columns and two frame-shredding APPLYs (blocked + blocking). */ + Assert.Contains("blocked_query_plan_xml = bqp.query_plan", plan.Text, StringComparison.Ordinal); + Assert.Contains("blocking_query_plan_xml = kqp.query_plan", plan.Text, StringComparison.Ordinal); + Assert.Contains("/blocked-process-report/blocked-process/process/executionStack/frame", plan.Text, StringComparison.Ordinal); + Assert.Contains("/blocked-process-report/blocking-process/process/executionStack/frame", plan.Text, StringComparison.Ordinal); + /* The resolution reuses the proven #880 sql_handle + offsets → text-plan lookup. */ + Assert.Equal(2, Regex.Matches(collapsed, Regex.Escape("JOINsys.dm_exec_query_statsASqsONqs.sql_handle=frames.frame_handle")).Count); + Assert.Contains("sys.dm_exec_text_query_plan", plan.Text, StringComparison.Ordinal); + } + + [Fact] + public async Task ReadAsync_PlanCaptureOn_CapturesBothTrailingPlanColumns() + { + var context = MakeContext(capturePlanXml: true); + var eventTime = new DateTime(2026, 7, 2, 11, 59, 30, DateTimeKind.Utc); + + /* Flag on = the projection carries the two plan columns at ordinals 5/6. */ + using var reader = new FakeCollectorDataReader( + new object[] { eventTime, SampleReportXml, 1234, 7, "dbo.t", "<ShowPlanXML>blocked</ShowPlanXML>", "<ShowPlanXML>blocking</ShowPlanXML>" }); + var rows = await BlockedProcessReportCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + + var writer = new RecordingCollectorRowWriter(); + BlockedProcessReportCollector.Instance.WritePayload(Assert.Single(rows), writer, context); + + Assert.Equal(40, writer.Values.Count); + Assert.Equal("<ShowPlanXML>blocked</ShowPlanXML>", writer.Values[38]); + Assert.Equal("<ShowPlanXML>blocking</ShowPlanXML>", writer.Values[39]); + } + + private const string SampleReportXml = @"<blocked-process-report monitorLoop=""332""> + <blocked-process> + <process id=""process1"" spid=""55"" ecid=""0"" waittime=""12345"" waitresource=""KEY: 7:72057594046185472 (abc123)"" lockMode=""U"" status=""suspended"" isolationlevel=""read committed (2)"" logused=""100"" trancount=""2"" clientapp=""app1"" hostname=""host1"" loginname=""login1"" transactionname=""UPDATE"" lasttranstarted=""2026-07-02T11:59:00"" lastbatchstarted=""2026-07-02T11:59:01"" lastbatchcompleted=""2026-07-02T11:58:59"" priority=""0"" currentdbname=""SO""> + <inputbuf> UPDATE t SET x = 1; </inputbuf> + </process> + </blocked-process> + <blocking-process> + <process spid=""66"" ecid=""0"" status=""sleeping"" isolationlevel=""read committed (2)"" clientapp=""app2"" hostname=""host2"" loginname=""login2"" transactionname=""user_transaction"" lasttranstarted=""2026-07-02T11:58:00"" lastbatchstarted=""2026-07-02T11:58:01"" lastbatchcompleted=""2026-07-02T11:58:02"" priority=""0""> + <inputbuf> BEGIN TRAN; </inputbuf> + </process> + </blocking-process> +</blocked-process-report>"; + + [Fact] + public void ParseReportXml_MapsBothProcesses_AndMonitorLoop() + { + var eventTime = new DateTime(2026, 7, 2, 11, 59, 30, DateTimeKind.Utc); + var row = BlockedProcessReportCollector.ParseReportXml(SampleReportXml, eventTime); + + Assert.NotNull(row); + Assert.Equal(eventTime, row!.EventTime); + Assert.Equal(332, row.MonitorLoop); + Assert.Equal("SO", row.DatabaseName); + Assert.Equal(55, row.BlockedSpid); + Assert.Equal(66, row.BlockingSpid); + Assert.Equal(12345L, row.WaitTimeMs); + Assert.Equal("KEY: 7:72057594046185472 (abc123)", row.WaitResource); + Assert.Equal("U", row.LockMode); + Assert.Equal(100L, row.BlockedLogUsed); + Assert.Equal(2, row.BlockedTransactionCount); + Assert.Equal("UPDATE t SET x = 1;", row.BlockedSqlText); + Assert.Equal("BEGIN TRAN;", row.BlockingSqlText); + Assert.Equal("UPDATE", row.BlockedTransactionName); + Assert.Equal("user_transaction", row.BlockingTransactionName); + Assert.Equal(new DateTime(2026, 7, 2, 11, 59, 0), row.BlockedLastTranStarted); + Assert.Equal(new DateTime(2026, 7, 2, 11, 58, 2), row.BlockingLastBatchCompleted); + } + + [Fact] + public async Task ReadAsync_PerDatabasePath_CaptureDatabaseWinsOverParsedValue() + { + /* On the Azure per-database read path the host stamps context.CurrentDatabaseName per + iteration, and it is authoritative for the per-database watermark key — beating the + report XML's currentdbname (here "SO") and covering reports that carry none, whose null + database_name could never advance that database's watermark. Server-scoped platforms + leave CurrentDatabaseName null, keeping the parsed value (pinned above). */ + var context = MakeContext(isAzureSqlDb: true); + context.CurrentDatabaseName = "ringdb"; + + using var reader = new FakeCollectorDataReader( + new object[] { new DateTime(2026, 7, 2, 11, 59, 30, DateTimeKind.Utc), SampleReportXml, 1234, 7, "dbo.t" }); + var rows = await BlockedProcessReportCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + + Assert.Equal("ringdb", Assert.Single(rows).DatabaseName); + } + + [Fact] + public void ParseReportXml_NullOnGarbage_NullWithoutBlockedProcess() + { + Assert.Null(BlockedProcessReportCollector.ParseReportXml("<not-xml", null)); + Assert.Null(BlockedProcessReportCollector.ParseReportXml("<blocked-process-report/>", null)); + + /* A report with only a blocking process is unusable — blocked side drives the row. */ + Assert.Null(BlockedProcessReportCollector.ParseReportXml( + "<blocked-process-report><blocking-process><process spid=\"66\"/></blocking-process></blocked-process-report>", null)); + } + + [Fact] + public async Task ReadAsync_SkipsEmptyAndUnparseable_WritePayloadPins40ColumnOrder() + { + var context = MakeContext(); + var eventTime = new DateTime(2026, 7, 2, 11, 59, 30, DateTimeKind.Utc); + + using var reader = new FakeCollectorDataReader( + new object[] { eventTime, SampleReportXml, 1234, 7, "dbo.t" }, + new object[] { eventTime, DBNull.Value, DBNull.Value, DBNull.Value, DBNull.Value }, /* empty xml -> skipped */ + new object[] { eventTime, "<garbage", DBNull.Value, DBNull.Value, DBNull.Value }); /* unparseable -> skipped */ + var rows = await BlockedProcessReportCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + + var row = Assert.Single(rows); + var writer = new RecordingCollectorRowWriter(); + BlockedProcessReportCollector.Instance.WritePayload(row, writer, context); + + Assert.Equal(40, writer.Values.Count); + Assert.Equal(eventTime, writer.Values[0]); + Assert.Equal("SO", writer.Values[1]); + Assert.Equal(55, writer.Values[2]); + Assert.Equal(66, writer.Values[4]); + Assert.Equal(12345L, writer.Values[6]); + Assert.Equal("UPDATE t SET x = 1;", writer.Values[16]); + Assert.Equal("BEGIN TRAN;", writer.Values[22]); + Assert.Equal(SampleReportXml, writer.Values[33]); /* raw XML rides along */ + Assert.Equal(1234, writer.Values[34]); /* event object_id (#1140) */ + Assert.Equal(7, writer.Values[35]); + Assert.Equal("dbo.t", writer.Values[36]); /* server-side resolved object */ + Assert.Equal(332, writer.Values[37]); /* monitorLoop from the root attribute */ + Assert.Null(writer.Values[38]); /* plan columns null when flag off */ + Assert.Null(writer.Values[39]); + Assert.Empty(s_deltas.Calls); + } +} diff --git a/Lite.Tests/BlockingDeadlockContextBuilderTests.cs b/Lite.Tests/BlockingDeadlockContextBuilderTests.cs new file mode 100644 index 000000000..eea457a0a --- /dev/null +++ b/Lite.Tests/BlockingDeadlockContextBuilderTests.cs @@ -0,0 +1,379 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using PerformanceMonitor.Alerting; +using PerformanceMonitor.Notifications; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// <summary> +/// Identity pins for Phase-5 slice B: the LAST two alert-context builders — blocking and deadlock — +/// moved verbatim from this app's <c>MainWindow.AlertEngine.cs</c> async wrappers into the shared +/// <see cref="AlertContextBuilders"/> (Lite's grouped rendering is canonical), with the store fetch +/// behind <see cref="IAlertReadAdapter"/>. Every expectation here is derived from the +/// pre-extraction code, so a behavioral drift in the shared copy fails a pin: the #1140/#1141 +/// chain grouping with occurrence counts and wait ranges, the 10-group cap with the "+N more" +/// trailer (gotqn's report), the deadlock victim rendering with the parsed process summary, the +/// XML attachments, and the excluded-database semantics (including +/// <see cref="AlertContextBuilders.IsDeadlockExcluded"/>'s all-processes rule). Also pins the +/// XE→DMV fallback merge (<see cref="BlockedProcessReportMerge"/>) both store adapters share, and +/// that Lite's row types ARE the shared alert row shapes (inheritance, no mapping copy). +/// </summary> +public class BlockingDeadlockContextBuilderTests +{ + private const string Server = "SQL2022"; + private static readonly List<string> NoExclusions = new(); + + /* ---------------- blocking builder ---------------- */ + + private static BlockedProcessAlertRow Blocked( + string db = "StackOverflow", string obj = "", string blockedSql = "", string blockingSql = "", + long waitMs = 5000, string lockMode = "", string xml = "", DateTime? eventTime = null) => new() + { + EventTime = eventTime ?? new DateTime(2026, 6, 19, 12, 0, 0), + DatabaseName = db, + ContentiousObject = obj, + BlockedSqlText = blockedSql, + BlockingSqlText = blockingSql, + WaitTimeMs = waitMs, + LockMode = lockMode, + BlockedProcessReportXml = xml + }; + + [Fact] + public void BuildBlockingContext_EmptyOrNull_ReturnsNull() + { + Assert.Null(AlertContextBuilders.BuildBlockingContext(Server, null, NoExclusions)); + Assert.Null(AlertContextBuilders.BuildBlockingContext(Server, new List<BlockedProcessAlertRow>(), NoExclusions)); + } + + [Fact] + public void BuildBlockingContext_GroupsSamplesOfTheSameChain_WithCountAndWaitRange() + { + /* Two samples of the same chain (same contentious object) differing only by wait time — + pre-extraction behavior: ONE group with the true occurrence count and wait range. */ + var rows = new List<BlockedProcessAlertRow> + { + Blocked(obj: "StackOverflow.dbo.Users", blockedSql: "UPDATE Users SET x = 1", + blockingSql: "BEGIN TRAN UPDATE Users", waitMs: 5000, lockMode: "X"), + Blocked(obj: "StackOverflow.dbo.Users", blockedSql: "UPDATE Users SET x = 1", + blockingSql: "BEGIN TRAN UPDATE Users", waitMs: 12000, lockMode: "X") + }; + + var context = AlertContextBuilders.BuildBlockingContext(Server, rows, NoExclusions); + + Assert.NotNull(context); + /* 1 grouped chain item + 1 appended incident item. */ + Assert.Equal(2, context!.Details.Count); + Assert.Equal("Blocking chain (x2)", context.Details[0].Heading); + Assert.Equal( + new List<(string, string)> + { + ("Database", "StackOverflow"), + ("Blocked Query", "UPDATE Users SET x = 1"), + ("Blocking Query", "BEGIN TRAN UPDATE Users"), + ("Wait Range", "5.0s-12.0s") + }, + context.Details[0].Fields); + + /* #1140: object-identity fingerprint (stable across index rebuilds). */ + var incident = Assert.Single(context.Incidents!); + Assert.Equal(2, incident.OccurrenceCount); + var expected = AlertFingerprint.ForObjects(Server, AlertFingerprint.Blocking, new[] { "StackOverflow.dbo.Users" }); + Assert.Equal(expected!.DedupKey, incident.DedupKey); + } + + [Fact] + public void BuildBlockingContext_SingleSample_HeadingHasNoCount() + { + var context = AlertContextBuilders.BuildBlockingContext( + Server, new List<BlockedProcessAlertRow> { Blocked(obj: "db.dbo.T") }, NoExclusions); + + Assert.Equal("Blocking chain", context!.Details[0].Heading); + } + + [Fact] + public void BuildBlockingContext_CapsAtTenGroups_WithTrueTotalTrailer() + { + /* 12 distinct chains -> 10 shown + "+2 more" trailer (gotqn's report: never silently drop). */ + var rows = new List<BlockedProcessAlertRow>(); + for (int i = 0; i < 12; i++) + rows.Add(Blocked(obj: $"db.dbo.Table{i}", waitMs: 1000 + i)); + + var context = AlertContextBuilders.BuildBlockingContext(Server, rows, NoExclusions); + + Assert.NotNull(context); + /* 10 chain items + 1 trailer + 10 appended incident items. */ + Assert.Equal(21, context!.Details.Count); + Assert.Equal("+2 more distinct blocking incident(s)", context.Details[10].Heading); + Assert.Empty(context.Details[10].Fields); + Assert.Equal(10, context.Incidents!.Count); + } + + [Fact] + public void BuildBlockingContext_AttachesFirstReportXml() + { + var rows = new List<BlockedProcessAlertRow> + { + Blocked(obj: "db.dbo.A"), /* DMV-style row: no XML */ + Blocked(obj: "db.dbo.B", xml: "<blocked-process-report first/>"), + Blocked(obj: "db.dbo.C", xml: "<blocked-process-report second/>") + }; + + var context = AlertContextBuilders.BuildBlockingContext(Server, rows, NoExclusions); + + Assert.Equal("<blocked-process-report first/>", context!.AttachmentXml); + Assert.Equal("blocked_process_report.xml", context.AttachmentFileName); + } + + [Fact] + public void BuildBlockingContext_NoReportXmlAnywhere_NoAttachment() + { + var context = AlertContextBuilders.BuildBlockingContext( + Server, new List<BlockedProcessAlertRow> { Blocked(obj: "db.dbo.A") }, NoExclusions); + + Assert.Null(context!.AttachmentXml); + Assert.Null(context.AttachmentFileName); + } + + [Fact] + public void BuildBlockingContext_ExcludedDatabases_DropRows_CaseInsensitive_NoDbAlwaysPasses() + { + var rows = new List<BlockedProcessAlertRow> + { + Blocked(db: "ExcludedDb", obj: "ExcludedDb.dbo.T"), + Blocked(db: "", obj: "unknown.dbo.T") + }; + + var context = AlertContextBuilders.BuildBlockingContext(Server, rows, new List<string> { "excludeddb" }); + + Assert.NotNull(context); + /* Only the database-less row survives (1 chain + 1 incident item). */ + Assert.Equal(2, context!.Details.Count); + + /* All rows excluded -> null, exactly like the pre-extraction early return. */ + Assert.Null(AlertContextBuilders.BuildBlockingContext( + Server, new List<BlockedProcessAlertRow> { Blocked(db: "ExcludedDb") }, new List<string> { "ExcludedDb" })); + } + + /* ---------------- deadlock builder ---------------- */ + + private const string DeadlockGraph = @"<deadlock> + <victim-list><victimProcess id=""process1""/></victim-list> + <process-list> + <process id=""process1"" spid=""55"" currentdbname=""StackOverflow""><inputbuf>UPDATE Users SET Reputation = 1</inputbuf></process> + <process id=""process2"" spid=""60"" currentdbname=""StackOverflow""><inputbuf>UPDATE Badges SET Name = 'x'</inputbuf></process> + </process-list> + <resource-list> + <keylock objectname=""StackOverflow.dbo.Users""><owner id=""process2"" mode=""X""/><waiter id=""process1"" mode=""U""/></keylock> + </resource-list> +</deadlock>"; + + private static DeadlockAlertRow Deadlock(string xml = DeadlockGraph, string victim = "process1", string victimSql = "UPDATE Users SET Reputation = 1") => + new() { VictimProcessId = victim, VictimSqlText = victimSql, DeadlockGraphXml = xml }; + + [Fact] + public void BuildDeadlockContext_EmptyOrNull_ReturnsNull() + { + Assert.Null(AlertContextBuilders.BuildDeadlockContext(Server, null, NoExclusions)); + Assert.Null(AlertContextBuilders.BuildDeadlockContext(Server, new List<DeadlockAlertRow>(), NoExclusions)); + } + + [Fact] + public void BuildDeadlockContext_RendersVictim_WithParsedProcessSummary_AndAttachment() + { + var context = AlertContextBuilders.BuildDeadlockContext( + Server, new List<DeadlockAlertRow> { Deadlock() }, NoExclusions); + + Assert.NotNull(context); + /* 1 victim item + 1 appended incident item. */ + Assert.Equal(2, context!.Details.Count); + Assert.Equal("Deadlock Victim", context.Details[0].Heading); + Assert.Equal( + new List<(string, string)> + { + ("Victim SQL", "UPDATE Users SET Reputation = 1"), + ("Processes", "SPID 55 (victim) vs SPID 60") + }, + context.Details[0].Fields); + + Assert.Equal(DeadlockGraph, context.AttachmentXml); + Assert.Equal("deadlock_graph.xml", context.AttachmentFileName); + + /* #1140: involved-object fingerprint from the graph's resource list. */ + var incident = Assert.Single(context.Incidents!); + var expected = AlertFingerprint.ForObjects(Server, AlertFingerprint.Deadlock, new[] { "StackOverflow.dbo.Users" }); + Assert.Equal(expected!.DedupKey, incident.DedupKey); + } + + [Fact] + public void BuildDeadlockContext_ShowsThreeVictims_ButFingerprintsAllDeadlocks() + { + /* 4 deadlocks over the same object set: 3 rendered (cap), ONE incident carrying the + occurrence count across ALL of them — the pre-extraction #1140 semantics. */ + var rows = new List<DeadlockAlertRow> { Deadlock(), Deadlock(), Deadlock(), Deadlock() }; + + var context = AlertContextBuilders.BuildDeadlockContext(Server, rows, NoExclusions); + + Assert.NotNull(context); + /* 3 victim items + 1 incident item. */ + Assert.Equal(4, context!.Details.Count); + Assert.Equal("Deadlock Victim", context.Details[2].Heading); + var incident = Assert.Single(context.Incidents!); + Assert.Equal(4, incident.OccurrenceCount); + } + + [Fact] + public void BuildDeadlockContext_AttachmentComesFromFirstRowWithXml() + { + var noXml = new DeadlockAlertRow { VictimSqlText = "SELECT 1", DeadlockGraphXml = "" }; + var context = AlertContextBuilders.BuildDeadlockContext( + Server, new List<DeadlockAlertRow> { noXml, Deadlock() }, NoExclusions); + + Assert.Equal(DeadlockGraph, context!.AttachmentXml); + Assert.Equal("deadlock_graph.xml", context.AttachmentFileName); + } + + [Fact] + public void IsDeadlockExcluded_TrueOnlyWhenEveryProcessDatabaseIsExcluded() + { + var excluded = new List<string> { "stackoverflow" }; + + /* All processes in StackOverflow (case-insensitive) -> excluded. */ + Assert.True(AlertContextBuilders.IsDeadlockExcluded(Deadlock(), excluded)); + + /* One process in another database -> NOT excluded. */ + var mixed = DeadlockGraph.Replace(@"spid=""60"" currentdbname=""StackOverflow""", @"spid=""60"" currentdbname=""OtherDb"""); + Assert.False(AlertContextBuilders.IsDeadlockExcluded(Deadlock(xml: mixed), excluded)); + + /* No graph, unparseable graph, or no currentdbname attributes -> never excluded. */ + Assert.False(AlertContextBuilders.IsDeadlockExcluded(Deadlock(xml: ""), excluded)); + Assert.False(AlertContextBuilders.IsDeadlockExcluded(Deadlock(xml: "<not-xml"), excluded)); + Assert.False(AlertContextBuilders.IsDeadlockExcluded(Deadlock(xml: "<deadlock><process-list><process id=\"p1\" spid=\"5\"/></process-list></deadlock>"), excluded)); + } + + [Fact] + public void BuildDeadlockContext_AllDeadlocksExcluded_ReturnsNull() + { + Assert.Null(AlertContextBuilders.BuildDeadlockContext( + Server, new List<DeadlockAlertRow> { Deadlock() }, new List<string> { "StackOverflow" })); + } + + [Fact] + public void DeadlockGraphSummary_UnparseableOrEmpty_ReturnsEmpty() + { + Assert.Equal("", DeadlockGraphSummary.Summarize("", "process1")); + Assert.Equal("", DeadlockGraphSummary.Summarize(null, "process1")); + Assert.Equal("", DeadlockGraphSummary.Summarize("<not-xml", "process1")); + } + + /* ---------------- the shared XE→DMV fallback merge ---------------- */ + + [Fact] + public void BlockedProcessMerge_DmvRowDropped_WhenBprCoversSameSpidPairInSameMinute() + { + var bpr = Blocked(obj: "db.dbo.T", xml: "<bpr/>", eventTime: new DateTime(2026, 6, 19, 12, 0, 10)); + bpr.BlockedSpid = 55; bpr.BlockingSpid = 66; + var dmvDupe = Blocked(obj: "db.dbo.T", eventTime: new DateTime(2026, 6, 19, 12, 0, 40)); + dmvDupe.BlockedSpid = 55; dmvDupe.BlockingSpid = 66; dmvDupe.Source = BlockedProcessAlertRow.DmvSnapshotSource; + var dmvNew = Blocked(obj: "db.dbo.U", eventTime: new DateTime(2026, 6, 19, 12, 2, 0)); + dmvNew.BlockedSpid = 77; dmvNew.BlockingSpid = 88; dmvNew.Source = BlockedProcessAlertRow.DmvSnapshotSource; + + var items = new List<BlockedProcessAlertRow> { bpr }; + BlockedProcessReportMerge.AppendDmvFallbackRows(items, new List<BlockedProcessAlertRow> { dmvDupe, dmvNew }); + + /* The same-minute same-SPID-pair DMV row is dropped (BPR preferred); the other appended; + result re-sorted newest first. */ + Assert.Equal(2, items.Count); + Assert.Equal(BlockedProcessAlertRow.DmvSnapshotSource, items[0].Source); /* 12:02 DMV row */ + Assert.Equal(BlockedProcessAlertRow.XeReportSource, items[1].Source); /* 12:00 BPR row */ + } + + [Fact] + public void BlockedProcessMerge_RecapsToCap_NewestFirst_BprWinsTies() + { + var items = new List<BlockedProcessAlertRow>(); + var dmv = new List<BlockedProcessAlertRow>(); + var t = new DateTime(2026, 6, 19, 12, 0, 0); + for (int i = 0; i < 3; i++) + { + var b = Blocked(eventTime: t); b.BlockedSpid = i; b.BlockingSpid = 100; items.Add(b); + /* Same event time, different SPID pair -> not deduped; competes on the cap. */ + var d = Blocked(eventTime: t); d.BlockedSpid = i + 50; d.BlockingSpid = 200; d.Source = BlockedProcessAlertRow.DmvSnapshotSource; dmv.Add(d); + } + + BlockedProcessReportMerge.AppendDmvFallbackRows(items, dmv, cap: 4); + + /* Cap re-applied after the merge; the stable sort keeps all BPR rows (added first) ahead + of DMV rows at the same event time — so the one surviving DMV row is last. */ + Assert.Equal(4, items.Count); + Assert.Equal(3, items.Count(i => i.Source == BlockedProcessAlertRow.XeReportSource)); + Assert.Equal(BlockedProcessAlertRow.DmvSnapshotSource, items[3].Source); + } + + /* ---------------- slice B seams: Lite's rows ARE the shared shapes ---------------- */ + + [Fact] + public void LiteRowTypes_DeriveFromSharedAlertRows_SoNoMappingCopyCanDrift() + { + Assert.True(typeof(BlockedProcessReportRow).IsSubclassOf(typeof(BlockedProcessAlertRow))); + Assert.True(typeof(DeadlockRow).IsSubclassOf(typeof(DeadlockAlertRow))); + Assert.True(typeof(IAlertReadAdapter).IsAssignableFrom(typeof(LiteAlertReadAdapter))); + } + + [Fact] + public void LiteAlertReadAdapter_ExposesAllSevenCollectedFeeds() + { + /* The engine (slice D) consumes exactly this surface; failed jobs stay out (live msdb via + FailedJobsQuery — not a collected feed). */ + Assert.Equal(typeof(Task<List<BlockedProcessAlertRow>>), + typeof(LiteAlertReadAdapter).GetMethod("GetRecentBlockedProcessReportsAsync")!.ReturnType); + Assert.Equal(typeof(Task<List<DeadlockAlertRow>>), + typeof(LiteAlertReadAdapter).GetMethod("GetRecentDeadlocksAsync")!.ReturnType); + Assert.Equal(typeof(Task<List<PoisonWaitDelta>>), + typeof(LiteAlertReadAdapter).GetMethod("GetPoisonWaitDeltasAsync")!.ReturnType); + Assert.Equal(typeof(Task<List<LongRunningQueryInfo>>), + typeof(LiteAlertReadAdapter).GetMethod("GetLongRunningQueriesAsync")!.ReturnType); + Assert.Equal(typeof(Task<List<VolumeFreeSpaceInfo>>), + typeof(LiteAlertReadAdapter).GetMethod("GetVolumeFreeSpaceAsync")!.ReturnType); + Assert.Equal(typeof(Task<TempDbSpaceInfo?>), + typeof(LiteAlertReadAdapter).GetMethod("GetTempDbSpaceAsync")!.ReturnType); + Assert.Equal(typeof(Task<List<AnomalousJobInfo>>), + typeof(LiteAlertReadAdapter).GetMethod("GetAnomalousJobsAsync")!.ReturnType); + Assert.Null(typeof(IAlertReadAdapter).GetMethod("GetRecentlyFailedJobsAsync")); + } + + [Fact] + public async Task LiteBuilderWrappers_PassLiteRowsCovariantly_IntoTheSharedBuilders() + { + /* Lite's store returns List<BlockedProcessReportRow>/List<DeadlockRow>; IReadOnlyList<T> + covariance feeds them to the shared builders with zero mapping — pin that the derived + lists ARE accepted and render identically to base-typed rows. */ + var liteRows = new List<BlockedProcessReportRow> + { + new() { DatabaseName = "db", ContentiousObject = "db.dbo.T", WaitTimeMs = 1000, BlockedProcessReportXml = "<bpr/>" } + }; + var context = AlertContextBuilders.BuildBlockingContext(Server, liteRows, NoExclusions); + Assert.NotNull(context); + Assert.Equal("<bpr/>", context!.AttachmentXml); + + var liteDeadlocks = new List<DeadlockRow> { new() { VictimProcessId = "process1", VictimSqlText = "SELECT 1", DeadlockGraphXml = DeadlockGraph } }; + var deadlockContext = AlertContextBuilders.BuildDeadlockContext(Server, liteDeadlocks, NoExclusions); + Assert.NotNull(deadlockContext); + Assert.Equal("SPID 55 (victim) vs SPID 60", liteDeadlocks[0].ProcessSummary); + + await Task.CompletedTask; + } +} diff --git a/Lite.Tests/BlockingStatsReaderTests.cs b/Lite.Tests/BlockingStatsReaderTests.cs new file mode 100644 index 000000000..ced9e1de8 --- /dev/null +++ b/Lite.Tests/BlockingStatsReaderTests.cs @@ -0,0 +1,309 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using PerformanceMonitorLite.Database; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Real-DuckDB round-trip pins for the Blocking Stats sub-tab readers +/// (<see cref="LocalDataService.GetBlockingDurationStatsAsync"/> + +/// <see cref="LocalDataService.GetDeadlockSeverityStatsAsync"/>) — the Lite port of Darling's Blocking Stats +/// severity aggregates, themselves the on-the-fly equivalent of the Dashboard's pre-aggregated +/// <c>collect.blocking_deadlock_stats</c>. These plant blocked_process_reports / dmv_blocking_snapshots / +/// deadlocks rows into the store, read back through the reader, and assert the per-minute duration aggregate +/// (event count + total/max/avg block duration), the XE-preferred / DMV-fallback union (so the severity +/// reconciles with the blocking-incident count trend), the #1319 database filter, window bounding, and the +/// deadlock-severity aggregate parsed on-the-fly from the graph XML (victim_count + total/max/avg wait, +/// reconciling in period with the deadlock count over the same v_deadlocks window). Mirrors the Darling +/// Blocking Stats scenarios (<c>ViewerBlockingStatsTests</c> / <c>ViewerDeadlockSeverityStatsTests</c>). +/// </summary> +public sealed class BlockingStatsReaderTests : IDisposable +{ + private const int ServerId = 9922; + + private readonly string _tempDir; + private readonly DuckDbInitializer _duckDb; + private long _nextId = 1; + + public BlockingStatsReaderTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "LiteBlkStatsRt_" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(_tempDir); + _duckDb = new DuckDbInitializer(Path.Combine(_tempDir, "test.duckdb")); + } + + public void Dispose() + { + try + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + catch { /* Best-effort cleanup */ } + } + + /* Anchor the window in the recent past so the default 24h read includes it; truncate to the minute so the + DATE_TRUNC('minute', …) bucket key is exactly predictable. Naive (Unspecified) kind matches what DuckDB + returns for a stored TIMESTAMP, so bucket-time equality holds. */ + private static readonly DateTime MinuteA = MinuteFloor(DateTime.UtcNow.AddMinutes(-90)); + private static readonly DateTime MinuteB = MinuteA.AddMinutes(3); + + private static DateTime MinuteFloor(DateTime t) => + DateTime.SpecifyKind(new DateTime(t.Ticks - (t.Ticks % TimeSpan.TicksPerMinute)), DateTimeKind.Unspecified); + + // ── Blocking duration aggregate: per-minute total / max / avg / event count ── + + [Fact] + public async Task BlockingDurationStats_BucketsPerMinute_TotalMaxAvgEventCount() + { + await _duckDb.InitializeAsync(); + var service = new LocalDataService(_duckDb); + + /* Minute A: two blocks (1000 + 3000 ms) → total 4000, max 3000, avg 2000, count 2. + Minute B: one block (2000 ms) → total 2000, max 2000, avg 2000, count 1. */ + await SeedBlockedProcessReportAsync(MinuteA.AddSeconds(10), "DB1", 1000); + await SeedBlockedProcessReportAsync(MinuteA.AddSeconds(40), "DB1", 3000); + await SeedBlockedProcessReportAsync(MinuteB.AddSeconds(20), "DB1", 2000); + + var points = await service.GetBlockingDurationStatsAsync(ServerId); + Assert.Equal(2, points.Count); + + var a = points[0]; + Assert.Equal(MinuteA, a.Time); + Assert.Equal(2, a.EventCount); + Assert.Equal(4000, a.TotalDurationMs); + Assert.Equal(3000, a.MaxDurationMs); + Assert.Equal(2000.0, a.AvgDurationMs, 3); + + var b = points[1]; + Assert.Equal(MinuteB, b.Time); + Assert.Equal(1, b.EventCount); + Assert.Equal(2000, b.TotalDurationMs); + Assert.Equal(2000, b.MaxDurationMs); + Assert.Equal(2000.0, b.AvgDurationMs, 3); + } + + [Fact] + public async Task BlockingDurationStats_ExcludesRowsOutsideTheWindow() + { + await _duckDb.InitializeAsync(); + var service = new LocalDataService(_duckDb); + + /* Only event is 48h ago — outside a 24h window (the read windows on event_time, like the count trend). */ + await SeedBlockedProcessReportAsync(MinuteFloor(DateTime.UtcNow.AddHours(-48)).AddSeconds(5), "DB1", 5000); + + Assert.Empty(await service.GetBlockingDurationStatsAsync(ServerId, hoursBack: 24)); + } + + [Fact] + public async Task BlockingDurationStats_HonorsDatabaseFilter_1319() + { + await _duckDb.InitializeAsync(); + var service = new LocalDataService(_duckDb); + + await SeedBlockedProcessReportAsync(MinuteA.AddSeconds(10), "DbA", 1000); + await SeedBlockedProcessReportAsync(MinuteA.AddSeconds(20), "DbB", 8000); + + /* Unfiltered: both in one bucket → total 9000. */ + var all = Assert.Single(await service.GetBlockingDurationStatsAsync(ServerId)); + Assert.Equal(9000, all.TotalDurationMs); + Assert.Equal(2, all.EventCount); + + /* Filtered to DbA: only the 1000 ms block. */ + var filtered = Assert.Single(await service.GetBlockingDurationStatsAsync(ServerId, databaseNames: new[] { "DbA" })); + Assert.Equal(1000, filtered.TotalDurationMs); + Assert.Equal(1, filtered.EventCount); + } + + [Fact] + public async Task BlockingDurationStats_FallsBackToDmvSnapshot_OnlyWhenNoXeInWindow() + { + await _duckDb.InitializeAsync(); + var service = new LocalDataService(_duckDb); + + /* No blocked_process_reports at all → the DMV snapshot arm contributes (WHERE NOT EXISTS). */ + await SeedDmvBlockingSnapshotAsync(MinuteA.AddSeconds(15), "DB1", 6000); + var dmvOnly = Assert.Single(await service.GetBlockingDurationStatsAsync(ServerId)); + Assert.Equal(MinuteA, dmvOnly.Time); + Assert.Equal(6000, dmvOnly.TotalDurationMs); + + /* Now add an XE report: the XE arm is preferred and the DMV arm is fully excluded (no double-count). */ + await SeedBlockedProcessReportAsync(MinuteB.AddSeconds(15), "DB1", 1000); + var withXe = await service.GetBlockingDurationStatsAsync(ServerId); + var bucket = Assert.Single(withXe); + Assert.Equal(MinuteB, bucket.Time); // the XE minute only — the DMV minute is dropped + Assert.Equal(1000, bucket.TotalDurationMs); + } + + // ── Deadlock severity aggregate: victim_count + total / max / avg wait, reconciling with the count ── + + [Fact] + public async Task DeadlockSeverity_BucketsPerMinute_VictimTotalMaxAvg() + { + await _duckDb.InitializeAsync(); + var service = new LocalDataService(_duckDb); + + /* Minute A: two deadlocks — (p1 victim 1000, p2 3000) then (p3 victim 2000) → 2 victims, total 6000, + max 3000, 3 processes → avg 2000. Minute B: one deadlock (p4 victim 500, p5 700) → 1 victim, + total 1200, max 700, 2 processes → avg 600. */ + await SeedDeadlockAsync(MinuteA.AddSeconds(10), BuildGraph(("p1", 55, 1000, true), ("p2", 66, 3000, false))); + await SeedDeadlockAsync(MinuteA.AddSeconds(40), BuildGraph(("p3", 77, 2000, true))); + await SeedDeadlockAsync(MinuteB.AddSeconds(20), BuildGraph(("p4", 88, 500, true), ("p5", 99, 700, false))); + + var points = await service.GetDeadlockSeverityStatsAsync(ServerId); + Assert.Equal(2, points.Count); + + var a = points[0]; + Assert.Equal(MinuteA, a.Time); + Assert.Equal(2, a.VictimCount); + Assert.Equal(6000, a.TotalWaitMs); + Assert.Equal(3000, a.MaxWaitMs); + Assert.Equal(2000.0, a.AvgWaitMs, 3); + + var b = points[1]; + Assert.Equal(MinuteB, b.Time); + Assert.Equal(1, b.VictimCount); + Assert.Equal(1200, b.TotalWaitMs); + Assert.Equal(700, b.MaxWaitMs); + Assert.Equal(600.0, b.AvgWaitMs, 3); + } + + [Fact] + public async Task DeadlockSeverity_ReconcilesWithDeadlockCountTrend_SameWindow() + { + await _duckDb.InitializeAsync(); + var service = new LocalDataService(_duckDb); + + await SeedDeadlockAsync(MinuteA.AddSeconds(10), BuildGraph(("a1", 55, 1000, true), ("a2", 66, 3000, false))); + await SeedDeadlockAsync(MinuteA.AddSeconds(40), BuildGraph(("b1", 77, 2000, true))); + await SeedDeadlockAsync(MinuteB.AddSeconds(20), BuildGraph(("c1", 88, 500, true))); + + /* The severity aggregate (windowed on collection_time, bucketed on deadlock_time) draws from the SAME + deadlock rows as the count trend, so the total victim count (>= one per real deadlock) reconciles + with the deadlock count over the identical window. */ + var severity = await service.GetDeadlockSeverityStatsAsync(ServerId); + var counts = await service.GetDeadlockTrendAsync(ServerId); + + Assert.Equal(3, counts.Sum(c => c.Count)); // 3 deadlocks + Assert.Equal(3, severity.Sum(s => s.VictimCount)); // one victim per graph here + Assert.True(severity.Sum(s => s.VictimCount) >= 1); + } + + [Fact] + public async Task DeadlockSeverity_ExcludesRowsOutsideTheWindow() + { + await _duckDb.InitializeAsync(); + var service = new LocalDataService(_duckDb); + + /* Collected 48h ago — outside a 24h window (the read windows on collection_time). */ + await SeedDeadlockAsync(MinuteFloor(DateTime.UtcNow.AddHours(-48)).AddSeconds(5), + BuildGraph(("z1", 55, 1000, true)), collectionTimeOverride: MinuteFloor(DateTime.UtcNow.AddHours(-48))); + + Assert.Empty(await service.GetDeadlockSeverityStatsAsync(ServerId, hoursBack: 24)); + } + + // ── Seeding helpers (raw INSERT via the shared initializer's connection) ── + + private async Task SeedBlockedProcessReportAsync(DateTime eventTime, string databaseName, long waitTimeMs) + { + using var readLock = _duckDb.AcquireReadLock(); + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +INSERT INTO blocked_process_reports + (blocked_report_id, collection_time, server_id, server_name, event_time, database_name, + blocked_spid, blocked_last_tran_started, blocking_spid, blocking_last_tran_started, + wait_time_ms, lock_mode, blocked_status, blocking_status) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)"; + cmd.Parameters.Add(new DuckDBParameter { Value = _nextId++ }); + cmd.Parameters.Add(new DuckDBParameter { Value = eventTime }); + cmd.Parameters.Add(new DuckDBParameter { Value = ServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = "TestSrv" }); + cmd.Parameters.Add(new DuckDBParameter { Value = eventTime }); + cmd.Parameters.Add(new DuckDBParameter { Value = databaseName }); + cmd.Parameters.Add(new DuckDBParameter { Value = 55 }); + cmd.Parameters.Add(new DuckDBParameter { Value = eventTime }); + cmd.Parameters.Add(new DuckDBParameter { Value = 66 }); + cmd.Parameters.Add(new DuckDBParameter { Value = eventTime }); + cmd.Parameters.Add(new DuckDBParameter { Value = waitTimeMs }); + cmd.Parameters.Add(new DuckDBParameter { Value = "X" }); + cmd.Parameters.Add(new DuckDBParameter { Value = "suspended" }); + cmd.Parameters.Add(new DuckDBParameter { Value = "running" }); + await cmd.ExecuteNonQueryAsync(); + } + + private async Task SeedDmvBlockingSnapshotAsync(DateTime eventTime, string databaseName, long waitTimeMs) + { + using var readLock = _duckDb.AcquireReadLock(); + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +INSERT INTO dmv_blocking_snapshots + (collection_id, collection_time, server_id, server_name, event_time, database_name, + blocked_spid, blocking_spid, wait_time_ms, lock_mode, monitor_loop) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)"; + cmd.Parameters.Add(new DuckDBParameter { Value = _nextId++ }); + cmd.Parameters.Add(new DuckDBParameter { Value = eventTime }); + cmd.Parameters.Add(new DuckDBParameter { Value = ServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = "TestSrv" }); + cmd.Parameters.Add(new DuckDBParameter { Value = eventTime }); + cmd.Parameters.Add(new DuckDBParameter { Value = databaseName }); + cmd.Parameters.Add(new DuckDBParameter { Value = 55 }); + cmd.Parameters.Add(new DuckDBParameter { Value = 66 }); + cmd.Parameters.Add(new DuckDBParameter { Value = waitTimeMs }); + cmd.Parameters.Add(new DuckDBParameter { Value = "X" }); + cmd.Parameters.Add(new DuckDBParameter { Value = 1 }); + await cmd.ExecuteNonQueryAsync(); + } + + private async Task SeedDeadlockAsync(DateTime deadlockTime, string graphXml, DateTime? collectionTimeOverride = null) + { + using var readLock = _duckDb.AcquireReadLock(); + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +INSERT INTO deadlocks + (deadlock_id, collection_time, server_id, server_name, deadlock_time, deadlock_graph_xml) +VALUES ($1, $2, $3, $4, $5, $6)"; + cmd.Parameters.Add(new DuckDBParameter { Value = _nextId++ }); + cmd.Parameters.Add(new DuckDBParameter { Value = collectionTimeOverride ?? deadlockTime }); + cmd.Parameters.Add(new DuckDBParameter { Value = ServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = "TestSrv" }); + cmd.Parameters.Add(new DuckDBParameter { Value = deadlockTime }); + cmd.Parameters.Add(new DuckDBParameter { Value = graphXml }); + await cmd.ExecuteNonQueryAsync(); + } + + /// <summary>Builds a bare <deadlock> graph (what the store keeps) from known processes, so a test can + /// assert exact victim_count / total / max / avg without depending on a captured fixture's wait times.</summary> + private static string BuildGraph(params (string Id, int Spid, long WaitTime, bool IsVictim)[] processes) + { + var sb = new StringBuilder(); + sb.Append("<deadlock><victim-list>"); + foreach (var p in processes.Where(p => p.IsVictim)) + sb.Append($"<victimProcess id=\"{p.Id}\"/>"); + sb.Append("</victim-list><process-list>"); + foreach (var p in processes) + sb.Append($"<process id=\"{p.Id}\" spid=\"{p.Spid}\" waittime=\"{p.WaitTime}\"><inputbuf>x</inputbuf></process>"); + sb.Append("</process-list></deadlock>"); + return sb.ToString(); + } +} diff --git a/Lite.Tests/BulkServerListParserTests.cs b/Lite.Tests/BulkServerListParserTests.cs new file mode 100644 index 000000000..65052c9f7 --- /dev/null +++ b/Lite.Tests/BulkServerListParserTests.cs @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Linq; +using PerformanceMonitor.Common; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// The shared "Add Multiple Servers" paste-box grammar (<see cref="BulkServerListParser"/>). This test file +/// is MIRRORED byte-for-byte in Darling.Tests so the two bulk dialogs are pinned to the identical grammar — +/// separators, trailing-field tolerance, comment/blank skipping with correct line numbers, the field-count +/// rules, and the null-safe empty-input contract. +/// </summary> +public sealed class BulkServerListParserTests +{ + [Fact] + public void BareHostnames_ParseAsServerNameOnly() + { + var result = BulkServerListParser.Parse("SQL2022\nSQL2019"); + + Assert.Empty(result.Errors); + Assert.Equal(2, result.Servers.Count); + Assert.Equal("SQL2022", result.Servers[0].ServerName); + Assert.Null(result.Servers[0].DisplayName); + Assert.Null(result.Servers[0].DatabaseName); + Assert.Equal(1, result.Servers[0].LineNumber); + Assert.Equal("SQL2019", result.Servers[1].ServerName); + Assert.Equal(2, result.Servers[1].LineNumber); + } + + [Fact] + public void CommaForm_TwoFields_IsServerAndDisplay() + { + var result = BulkServerListParser.Parse("SQL2022, Production"); + + var line = Assert.Single(result.Servers); + Assert.Equal("SQL2022", line.ServerName); + Assert.Equal("Production", line.DisplayName); + Assert.Null(line.DatabaseName); + } + + [Fact] + public void CommaForm_ThreeFields_IsServerDisplayDatabase() + { + var result = BulkServerListParser.Parse("myazure.database.windows.net, Azure Prod, AdventureWorks"); + + var line = Assert.Single(result.Servers); + Assert.Equal("myazure.database.windows.net", line.ServerName); + Assert.Equal("Azure Prod", line.DisplayName); + Assert.Equal("AdventureWorks", line.DatabaseName); + } + + [Fact] + public void TabForm_TwoAndThreeFields_SplitOnTab() + { + var two = BulkServerListParser.Parse("SQL2022\tProduction"); + var lineTwo = Assert.Single(two.Servers); + Assert.Equal("SQL2022", lineTwo.ServerName); + Assert.Equal("Production", lineTwo.DisplayName); + Assert.Null(lineTwo.DatabaseName); + + var three = BulkServerListParser.Parse("host\tName\tdb1"); + var lineThree = Assert.Single(three.Servers); + Assert.Equal("host", lineThree.ServerName); + Assert.Equal("Name", lineThree.DisplayName); + Assert.Equal("db1", lineThree.DatabaseName); + } + + [Fact] + public void TabTakesPrecedence_CommaStaysInsideTheField() + { + // A line containing BOTH a tab and a comma splits on the tab; the comma is part of the field. + var result = BulkServerListParser.Parse("SQL2022\tContoso, Inc."); + + var line = Assert.Single(result.Servers); + Assert.Equal("SQL2022", line.ServerName); + Assert.Equal("Contoso, Inc.", line.DisplayName); + } + + [Fact] + public void InteriorEmptyField_IsNull_NotAnError() + { + var result = BulkServerListParser.Parse("host,,db1"); + + Assert.Empty(result.Errors); + var line = Assert.Single(result.Servers); + Assert.Equal("host", line.ServerName); + Assert.Null(line.DisplayName); + Assert.Equal("db1", line.DatabaseName); + } + + [Fact] + public void TrailingSeparators_AreDropped_CommaAndTab() + { + var comma = BulkServerListParser.Parse("SQL2022, Prod,"); + var commaLine = Assert.Single(comma.Servers); + Assert.Equal("SQL2022", commaLine.ServerName); + Assert.Equal("Prod", commaLine.DisplayName); + Assert.Null(commaLine.DatabaseName); + Assert.Empty(comma.Errors); + + var tab = BulkServerListParser.Parse("SQL2022\tProd\t"); + var tabLine = Assert.Single(tab.Servers); + Assert.Equal("SQL2022", tabLine.ServerName); + Assert.Equal("Prod", tabLine.DisplayName); + Assert.Null(tabLine.DatabaseName); + Assert.Empty(tab.Errors); + } + + [Fact] + public void CommentAndBlankLines_AreSkipped_WithLineNumbersStillCorrect() + { + var result = BulkServerListParser.Parse("# header comment\n\nSQL2022\n \n# another\nSQL2019"); + + Assert.Empty(result.Errors); + Assert.Equal(2, result.Servers.Count); + Assert.Equal("SQL2022", result.Servers[0].ServerName); + Assert.Equal(3, result.Servers[0].LineNumber); + Assert.Equal("SQL2019", result.Servers[1].ServerName); + Assert.Equal(6, result.Servers[1].LineNumber); + } + + [Fact] + public void MoreThanThreeFields_IsAnError_WithTheRightLineNumber() + { + var result = BulkServerListParser.Parse("SQL2022\nhost, a, b, c"); + + Assert.Single(result.Servers); + var error = Assert.Single(result.Errors); + Assert.Equal(2, error.LineNumber); + Assert.Contains("too many fields", error.Message, System.StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void EmptyServerToken_IsAnError() + { + var result = BulkServerListParser.Parse(", Production, db"); + + Assert.Empty(result.Servers); + var error = Assert.Single(result.Errors); + Assert.Equal(1, error.LineNumber); + Assert.Contains("server name is empty", error.Message, System.StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void CrlfAndLf_AreEquivalent() + { + var lf = BulkServerListParser.Parse("SQL2022\nSQL2019\nSQL2016"); + var crlf = BulkServerListParser.Parse("SQL2022\r\nSQL2019\r\nSQL2016"); + + Assert.Equal( + lf.Servers.Select(s => (s.LineNumber, s.ServerName)), + crlf.Servers.Select(s => (s.LineNumber, s.ServerName))); + Assert.Equal(3, crlf.Servers.Count); + // A CRLF line leaves no stray carriage return on the trailing field. + Assert.Equal("SQL2016", crlf.Servers[2].ServerName); + } + + [Fact] + public void ExcelPaste_TabsWithTrailingEmptyCells_ParseCleanly() + { + // Spreadsheet copy: tab-separated with a trailing empty cell (and a trailing tab on the row). + var result = BulkServerListParser.Parse("SQL2022\tProduction\t\nSQL2019\tStaging\tReportServer\t"); + + Assert.Empty(result.Errors); + Assert.Equal(2, result.Servers.Count); + Assert.Equal("SQL2022", result.Servers[0].ServerName); + Assert.Equal("Production", result.Servers[0].DisplayName); + Assert.Null(result.Servers[0].DatabaseName); + Assert.Equal("SQL2019", result.Servers[1].ServerName); + Assert.Equal("Staging", result.Servers[1].DisplayName); + Assert.Equal("ReportServer", result.Servers[1].DatabaseName); + } + + [Fact] + public void RawText_IsPreservedPreTrim() + { + var result = BulkServerListParser.Parse(" SQL2022 , Production "); + + var line = Assert.Single(result.Servers); + Assert.Equal(" SQL2022 , Production ", line.RawText); + Assert.Equal("SQL2022", line.ServerName); + Assert.Equal("Production", line.DisplayName); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("\n\n")] + [InlineData("# only a comment")] + public void NullEmptyOrWhitespaceInput_YieldsEmptyResult_NoThrow(string? text) + { + var result = BulkServerListParser.Parse(text); + + Assert.Empty(result.Servers); + Assert.Empty(result.Errors); + } +} diff --git a/Lite.Tests/BulkServerOnboardingTests.cs b/Lite.Tests/BulkServerOnboardingTests.cs new file mode 100644 index 000000000..6b1e62a63 --- /dev/null +++ b/Lite.Tests/BulkServerOnboardingTests.cs @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Collections.Generic; +using PerformanceMonitor.Common; +using PerformanceMonitorLite.Models; +using PerformanceMonitorLite.Windows; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// The Lite "Add Multiple Servers" pure mapping + gate helpers (WPF-free statics on +/// <see cref="AddMultipleServersDialog"/>): the row → <see cref="ServerConnection"/> projection that feeds +/// both Test All and Add, the MFA rejection belt, the unique-profile-name suffixer, and the ratified +/// OrdinalIgnoreCase dedupe gate over the shared <see cref="ServerIdHelper"/> storage name. +/// </summary> +public sealed class BulkServerOnboardingTests +{ + private static BulkServerParseLine Line(string server, string? display = null, string? database = null) => + new(1, server, server, display, database); + + [Fact] + public void BuildServerConnection_DisplayName_FallsBackToServerName() + { + var (server, error) = AddMultipleServersDialog.BuildServerConnection( + Line("SQL2022"), + new BulkSharedSettings { AuthType = AuthenticationTypes.Windows }); + + Assert.Null(error); + Assert.NotNull(server); + Assert.Equal("SQL2022", server!.DisplayName); + + var (withDisplay, _) = AddMultipleServersDialog.BuildServerConnection( + Line("SQL2022", "Production"), + new BulkSharedSettings { AuthType = AuthenticationTypes.Windows }); + Assert.Equal("Production", withDisplay!.DisplayName); + } + + [Fact] + public void BuildServerConnection_Database_IsNullWhenBlank_TrimmedWhenSet() + { + var (blank, _) = AddMultipleServersDialog.BuildServerConnection( + Line("SQL2022", null, null), + new BulkSharedSettings { AuthType = AuthenticationTypes.Windows }); + Assert.Null(blank!.DatabaseName); + + var (set, _) = AddMultipleServersDialog.BuildServerConnection( + Line("azure.database.windows.net", null, " AdventureWorks "), + new BulkSharedSettings { AuthType = AuthenticationTypes.Windows }); + Assert.Equal("AdventureWorks", set!.DatabaseName); + } + + [Fact] + public void BuildServerConnection_WindowsAuth_HasNoProfileAndNoSecret() + { + var (server, _) = AddMultipleServersDialog.BuildServerConnection( + Line("SQL2022"), + new BulkSharedSettings { AuthType = AuthenticationTypes.Windows }); + + Assert.Equal(AuthenticationTypes.Windows, server!.AuthenticationType); + Assert.Null(server.CredentialProfileId); + Assert.Null(server.ManagedIdentityClientId); + Assert.True(server.IsEnabled); + } + + [Fact] + public void BuildServerConnection_SqlProfileBacked_CarriesProfileIdAndAuth() + { + var (server, _) = AddMultipleServersDialog.BuildServerConnection( + Line("SQL2022"), + new BulkSharedSettings { AuthType = AuthenticationTypes.SqlServer, ProfileId = "profile-123" }); + + Assert.Equal(AuthenticationTypes.SqlServer, server!.AuthenticationType); + Assert.Equal("profile-123", server.CredentialProfileId); + // A profile-backed row keeps no inline Azure identity (M-3 hygiene). + Assert.Null(server.AzureClientId); + } + + [Fact] + public void BuildServerConnection_InlineManagedIdentity_CarriesClientId_NoProfile() + { + var (server, _) = AddMultipleServersDialog.BuildServerConnection( + Line("azure.database.windows.net"), + new BulkSharedSettings { AuthType = AuthenticationTypes.ManagedIdentity, ManagedIdentityClientId = "mi-abc" }); + + Assert.Equal(AuthenticationTypes.ManagedIdentity, server!.AuthenticationType); + Assert.Equal("mi-abc", server.ManagedIdentityClientId); + Assert.Null(server.CredentialProfileId); + } + + [Fact] + public void BuildServerConnection_EncryptAndTrust_PassThrough() + { + var (server, _) = AddMultipleServersDialog.BuildServerConnection( + Line("SQL2022"), + new BulkSharedSettings { AuthType = AuthenticationTypes.Windows, EncryptMode = "Strict", TrustServerCertificate = true }); + + Assert.Equal("Strict", server!.EncryptMode); + Assert.True(server.TrustServerCertificate); + } + + [Fact] + public void BuildServerConnection_ResolvedEntraMfa_IsRejected_TheBelt() + { + // No supported bulk path mints MFA (no MFA radio; the editor never creates an MFA profile), but the + // mapping helper still rejects a resolved EntraMFA at the single choke point covering Test All + Add. + var (server, error) = AddMultipleServersDialog.BuildServerConnection( + Line("azure.database.windows.net"), + new BulkSharedSettings { AuthType = AuthenticationTypes.EntraMFA }); + + Assert.Null(server); + Assert.Contains("Entra MFA", error, System.StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void MakeUniqueProfileName_SuffixesOnCollision_StartingAtTwo() + { + Assert.Equal("SQL — sa", AddMultipleServersDialog.MakeUniqueProfileName("SQL — sa", new List<string>())); + + Assert.Equal("SQL — sa (2)", + AddMultipleServersDialog.MakeUniqueProfileName("SQL — sa", new[] { "SQL — sa" })); + + // Case-insensitive collision detection; the next free suffix. + Assert.Equal("SQL — sa (3)", + AddMultipleServersDialog.MakeUniqueProfileName("SQL — sa", new[] { "sql — sa", "SQL — SA (2)" })); + } + + [Fact] + public void DedupeGate_CaseVariant_IsSkipped_EvenThoughStoredHashesDiffer() + { + // The raison d'être: hostnames are case-insensitive in reality, so an existing SQL01 must block a + // bulk 'sql01' even though ServerIdHelper's stored hash differs by case. Pins against a future + // "simplify back to the raw hash" regression. + var existing = new[] { new ServerConnection { ServerName = "SQL01" } }; + var storedDiffer = + RemoteCollectorServiceIdOf("SQL01") != RemoteCollectorServiceIdOf("sql01"); + Assert.True(storedDiffer); + + var seen = AddMultipleServersDialog.SeedGate(existing); + var candidate = new ServerConnection { ServerName = "sql01" }; + Assert.False(seen.Add(AddMultipleServersDialog.GateKey(candidate))); + } + + [Fact] + public void DedupeGate_ReadOnlyIntent_SeedDoesNotBlockReadWrite_ButReadWriteDoes() + { + // An existing read-only SQL01:RO does NOT block a bulk read-write SQL01 (bulk rows are read-write)… + var seenRo = AddMultipleServersDialog.SeedGate(new[] { new ServerConnection { ServerName = "SQL01", ReadOnlyIntent = true } }); + Assert.True(seenRo.Add(AddMultipleServersDialog.GateKey(new ServerConnection { ServerName = "SQL01" }))); + + // …but an existing read-write SQL01 DOES. + var seenRw = AddMultipleServersDialog.SeedGate(new[] { new ServerConnection { ServerName = "SQL01" } }); + Assert.False(seenRw.Add(AddMultipleServersDialog.GateKey(new ServerConnection { ServerName = "SQL01" }))); + } + + [Fact] + public void DedupeGate_DifferentDatabases_StayDistinct() + { + var seen = AddMultipleServersDialog.SeedGate(new[] { new ServerConnection { ServerName = "host", DatabaseName = "db1" } }); + Assert.True(seen.Add(AddMultipleServersDialog.GateKey(new ServerConnection { ServerName = "host", DatabaseName = "db2" }))); + } + + private static int RemoteCollectorServiceIdOf(string serverName) => + ServerIdHelper.GetDeterministicHashCode(ServerIdHelper.BuildStorageName(serverName, null, false)); +} diff --git a/Lite.Tests/CapabilityPins/planviewer.baseline.txt b/Lite.Tests/CapabilityPins/planviewer.baseline.txt new file mode 100644 index 000000000..2d5b8777e --- /dev/null +++ b/Lite.Tests/CapabilityPins/planviewer.baseline.txt @@ -0,0 +1,23 @@ +add-tab-sentinel +clipboard-paste +ctrl-v-paste +drag-copy-effect +drag-filedrop +empty-state-icon +empty-state-label +entry-close +entry-open +file-dialog-filter +multi-file-open +open-file-button +open-file-dialog +paste-button +render-loadplan +shared-plan-viewer-control +unique-sub-tab-label +viewer-cleanup +wire-close +wire-dragover +wire-drop +wire-keydown +wire-open diff --git a/Lite.Tests/CapabilityPins/servertab-chartwiring.baseline.txt b/Lite.Tests/CapabilityPins/servertab-chartwiring.baseline.txt new file mode 100644 index 000000000..f35b93c3a --- /dev/null +++ b/Lite.Tests/CapabilityPins/servertab-chartwiring.baseline.txt @@ -0,0 +1,26 @@ +BlockingTrendChart +CollectorDurationChart +CpuChart +CurrentWaitsBlockedChart +CurrentWaitsDurationChart +DeadlockTrendChart +ExecutionCountTrendChart +FileIoReadChart +FileIoReadThroughputChart +FileIoWriteChart +FileIoWriteThroughputChart +LockWaitTrendChart +MemoryChart +MemoryClerksChart +MemoryGrantActivityChart +MemoryGrantSizingChart +MemoryPressureEventsChart +PerfmonChart +ProcDurationTrendChart +QueryDurationTrendChart +QueryHeatmapChart +QueryStoreDurationTrendChart +TempDbChart +TempDbFileIoChart +TempDbSizeChart +WaitStatsChart diff --git a/Lite.Tests/CapabilityPins/servertab-eventwires.baseline.txt b/Lite.Tests/CapabilityPins/servertab-eventwires.baseline.txt new file mode 100644 index 000000000..d43b9b422 --- /dev/null +++ b/Lite.Tests/CapabilityPins/servertab-eventwires.baseline.txt @@ -0,0 +1,18 @@ +MouseDoubleClick=>BlockedProcessReportGrid_MouseDoubleClick +MouseDoubleClick=>CollectionHealthGrid_MouseDoubleClick +MouseDoubleClick=>DeadlockGrid_MouseDoubleClick +MouseDoubleClick=>ProcedureStatsGrid_MouseDoubleClick +MouseDoubleClick=>QueryStatsGrid_MouseDoubleClick +MouseDoubleClick=>QueryStoreGrid_MouseDoubleClick +SelectionChanged=>AutoRefreshInterval_Changed +SelectionChanged=>CompareToCombo_SelectionChanged +SelectionChanged=>CustomTimeCombo_Changed +SelectionChanged=>HeatmapMetric_SelectionChanged +SelectionChanged=>MainTabControl_SelectionChanged +SelectionChanged=>PerfmonPack_SelectionChanged +SelectionChanged=>ProcedureStatsGrid_SelectionChanged +SelectionChanged=>QueryStatsGrid_SelectionChanged +SelectionChanged=>QueryStoreGrid_SelectionChanged +SelectionChanged=>TimeDisplayMode_SelectionChanged +SelectionChanged=>TimeRangeCombo_SelectionChanged +SelectionChanged=>WaitStatsMetric_SelectionChanged diff --git a/Lite.Tests/CapabilityPins/servertab-filtermgrs.baseline.txt b/Lite.Tests/CapabilityPins/servertab-filtermgrs.baseline.txt new file mode 100644 index 000000000..06642e625 --- /dev/null +++ b/Lite.Tests/CapabilityPins/servertab-filtermgrs.baseline.txt @@ -0,0 +1,31 @@ +ProcedureHistoryWindow.xaml.cs:HistoryDataGrid +QueryStatsHistoryWindow.xaml.cs:HistoryDataGrid +QueryStoreHistoryWindow.xaml.cs:HistoryDataGrid +ServerTab.Filters.cs:BlockedProcessReportGrid +ServerTab.Filters.cs:CollectionHealthGrid +ServerTab.Filters.cs:CollectionLogGrid +ServerTab.Filters.cs:CpuTasksGrid +ServerTab.Filters.cs:DatabaseConfigChangesGrid +ServerTab.Filters.cs:DatabaseConfigGrid +ServerTab.Filters.cs:DatabaseScopedConfigGrid +ServerTab.Filters.cs:DeadlockGrid +ServerTab.Filters.cs:DefaultTraceGrid +ServerTab.Filters.cs:IoIssuesGrid +ServerTab.Filters.cs:LatchStatsGrid +ServerTab.Filters.cs:MemoryBrokerGrid +ServerTab.Filters.cs:MemoryConditionsGrid +ServerTab.Filters.cs:MemoryNodeOomGrid +ServerTab.Filters.cs:ProcedureStatsGrid +ServerTab.Filters.cs:QuerySnapshotsGrid +ServerTab.Filters.cs:QueryStatsGrid +ServerTab.Filters.cs:QueryStoreGrid +ServerTab.Filters.cs:RunningJobsGrid +ServerTab.Filters.cs:SchedulerIssuesGrid +ServerTab.Filters.cs:ServerConfigChangesGrid +ServerTab.Filters.cs:ServerConfigGrid +ServerTab.Filters.cs:SevereErrorsGrid +ServerTab.Filters.cs:SignificantWaitsGrid +ServerTab.Filters.cs:SpinlockStatsGrid +ServerTab.Filters.cs:TraceFlagChangesGrid +ServerTab.Filters.cs:TraceFlagsGrid +WaitDrillDownWindow.xaml.cs:ResultsDataGrid diff --git a/Lite.Tests/CapabilityPins/servertab-gridstyle.baseline.txt b/Lite.Tests/CapabilityPins/servertab-gridstyle.baseline.txt new file mode 100644 index 000000000..15b39e9c9 --- /dev/null +++ b/Lite.Tests/CapabilityPins/servertab-gridstyle.baseline.txt @@ -0,0 +1,31 @@ +BlockedProcessReportGrid=>BlockingRowStyle +CollectionHealthGrid=>GridRowStyle +CollectionLogGrid=>GridRowStyle +CpuSchedulerGrid=>(none) +CpuTasksGrid=>GridRowStyle +DatabaseConfigChangesGrid=>GridRowStyle +DatabaseConfigGrid=>GridRowStyle +DatabaseScopedConfigGrid=>GridRowStyle +DeadlockGrid=>DeadlockRowStyle +DefaultTraceGrid=>GridRowStyle +IoIssuesGrid=>GridRowStyle +LatchStatsGrid=>GridRowStyle +MemoryBrokerGrid=>GridRowStyle +MemoryConditionsGrid=>GridRowStyle +MemoryNodeOomGrid=>GridRowStyle +ProcStatsComparisonGrid=>GridPlanRowStyle +ProcedureStatsGrid=>GridPlanRowStyle +QuerySnapshotsGrid=>GridPlanRowStyle +QueryStatsComparisonGrid=>GridPlanRowStyle +QueryStatsGrid=>GridPlanRowStyle +QueryStoreComparisonGrid=>GridRowStyle +QueryStoreGrid=>GridPlanRowStyle +RunningJobsGrid=>(none) +SchedulerIssuesGrid=>GridRowStyle +ServerConfigChangesGrid=>GridRowStyle +ServerConfigGrid=>GridRowStyle +SevereErrorsGrid=>GridRowStyle +SignificantWaitsGrid=>GridRowStyle +SpinlockStatsGrid=>GridRowStyle +TraceFlagChangesGrid=>GridRowStyle +TraceFlagsGrid=>GridRowStyle diff --git a/Lite.Tests/CapabilityPins/servertab-menucmds.baseline.txt b/Lite.Tests/CapabilityPins/servertab-menucmds.baseline.txt new file mode 100644 index 000000000..4f82625b5 --- /dev/null +++ b/Lite.Tests/CapabilityPins/servertab-menucmds.baseline.txt @@ -0,0 +1,25 @@ +BlockedProcessContextMenu:Copy All Rows=>CopyAllRows_Click +BlockedProcessContextMenu:Copy Cell=>CopyCell_Click +BlockedProcessContextMenu:Copy Row=>CopyRow_Click +BlockedProcessContextMenu:Export to CSV...=>ExportToCsv_Click +BlockedProcessContextMenu:View Block Chain=>ViewBlockChain_Click +BlockedProcessContextMenu:View Blocked Plan=>ViewBlockedSidePlan_Click +BlockedProcessContextMenu:View Blocking Plan=>ViewBlockingSidePlan_Click +DataGridContextMenu:Copy All Rows=>CopyAllRows_Click +DataGridContextMenu:Copy Cell=>CopyCell_Click +DataGridContextMenu:Copy Repro Script=>CopyReproScript_Click +DataGridContextMenu:Copy Row=>CopyRow_Click +DataGridContextMenu:Export to CSV...=>ExportToCsv_Click +DataGridPlanContextMenu:Get Actual Plan (re-run)=>GetActualPlan_Click +DataGridPlanContextMenu:View Stored Plan=>ViewEstimatedPlan_Click +DataGridPlanContextMenu:Copy Cell=>CopyCell_Click +DataGridPlanContextMenu:Copy Row=>CopyRow_Click +DataGridPlanContextMenu:Copy All Rows=>CopyAllRows_Click +DataGridPlanContextMenu:Copy Repro Script=>CopyReproScript_Click +DataGridPlanContextMenu:Export to CSV...=>ExportToCsv_Click +DeadlockContextMenu:Copy All Rows=>CopyAllRows_Click +DeadlockContextMenu:Copy Cell=>CopyCell_Click +DeadlockContextMenu:Copy Row=>CopyRow_Click +DeadlockContextMenu:Export to CSV...=>ExportToCsv_Click +DeadlockContextMenu:View Deadlock Graph=>ViewDeadlockGraph_Click +DeadlockContextMenu:View Stored Plan=>ViewDeadlockProcessPlan_Click diff --git a/Lite.Tests/CapabilityPins/servertab-tabs.baseline.txt b/Lite.Tests/CapabilityPins/servertab-tabs.baseline.txt new file mode 100644 index 000000000..1ddd5b131 --- /dev/null +++ b/Lite.Tests/CapabilityPins/servertab-tabs.baseline.txt @@ -0,0 +1,57 @@ +Active Queries +Blocked Process Reports +Blocking +Blocking Stats +CPU +CPU Scheduler +CPU Tasks +Collection Health +Collection Log +Configuration +Configuration Changes +Contention Events +Corruption Events +Current Waits +Daily Summary +Database Config Changes +Database Configuration +Deadlocks +Default Trace +Duration Trends +File I/O +File I/O Latency +File I/O Throughput +Health Summary +I/O Issues +Latches & Spinlocks +Memory +Memory Broker +Memory Clerks +Memory Conditions +Memory Grants +Memory Node OOM +Memory Pressure Events +Overview +Perfmon +Performance Trends +Plan Cache +Plan Viewer +Queries +Query Heatmap +Query Store by Duration +Running Jobs +Scheduler Issues +Scoped Configuration +Server Config Changes +Server Configuration +Session Stats +Severe Errors +Significant Waits +System Events +Top Procedures by Duration +Top Queries by Duration +Trace Flag Changes +Trace Flags +Trends +Wait Stats +tempdb diff --git a/Lite.Tests/ChartStyleYLimitsTests.cs b/Lite.Tests/ChartStyleYLimitsTests.cs new file mode 100644 index 000000000..1f40e22f3 --- /dev/null +++ b/Lite.Tests/ChartStyleYLimitsTests.cs @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using PerformanceMonitor.Ui; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// <summary> +/// Regression tests for <see cref="ChartStyle.ComputeYLimitsWithLegendPadding"/> — the pure Y-limit math +/// shared by every trend chart in all three apps (Lite, Dashboard, and the Darling viewer). Pins the fix +/// for the magnitude-scaled negative floor bug: a non-negative series (dataYMin >= 0) must floor the +/// axis at 0, NOT at -5% of the data max. The old middle branch put a big dead-band under high-value +/// charts (e.g. Wait Stats peaking ~6000 ms/sec dropped to a -300 floor). Only genuinely-negative data +/// keeps a below-zero margin. Mirror of Dashboard.Tests for parity. +/// </summary> +public class ChartStyleYLimitsTests +{ + [Fact] + public void ZeroDataMin_FloorsAtZero_NoNegativeDeadBand() + { + // The exact bug: callers pass an explicit dataYMin == 0, which used to hit the middle branch + // and produce yMin = -(range * 0.05) = -300 for a 0..6000 chart. It must now floor at 0. + var (yMin, _) = ChartStyle.ComputeYLimitsWithLegendPadding(0, 6000); + Assert.Equal(0.0, yMin); + } + + [Fact] + public void PositiveDataMin_FloorsAtZero() + { + var (yMin, _) = ChartStyle.ComputeYLimitsWithLegendPadding(50, 6000); + Assert.Equal(0.0, yMin); + } + + [Fact] + public void NegativeDataMin_KeepsTenPercentBelowZeroMargin() + { + // range = 100; genuinely-negative data keeps its 10%-of-range breathing room below the min. + var (yMin, _) = ChartStyle.ComputeYLimitsWithLegendPadding(-10, 90); + Assert.Equal(-20.0, yMin, 6); // -10 - (100 * 0.10) + } + + [Fact] + public void TopPadding_IsFifteenPercentOfRange() + { + var (_, yMax) = ChartStyle.ComputeYLimitsWithLegendPadding(0, 6000); + Assert.Equal(6900.0, yMax, 6); // 6000 + (6000 * 0.15) + } + + [Fact] + public void FlatData_ExpandsRangeAndStillFloorsAtZero() + { + // dataYMax <= dataYMin forces range to 1 so a flat non-negative series still gets a visible band. + var (yMin, yMax) = ChartStyle.ComputeYLimitsWithLegendPadding(5, 5); + Assert.Equal(0.0, yMin); + Assert.Equal(6.15, yMax, 6); // dataYMax -> 6, + (1 * 0.15) + } +} diff --git a/Lite.Tests/CollectionHealthWindowTests.cs b/Lite.Tests/CollectionHealthWindowTests.cs new file mode 100644 index 000000000..892d959df --- /dev/null +++ b/Lite.Tests/CollectionHealthWindowTests.cs @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.IO; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using PerformanceMonitorLite.Database; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// <summary> +/// Pins Lite's Collection Log window bounding (parity with the Darling #1406 fix): a custom +/// [fromDate,toDate] range set entirely in the past must bound <c>collection_time</c> on BOTH sides — +/// a row after the To bound is EXCLUDED, which the old hours-back-only read (a single now-relative +/// lower bound that ignored fromDate/toDate) could not express. The preset test proves a plain +/// hoursBack read still lower-bounds "now". +/// </summary> +public sealed class CollectionHealthWindowTests : IDisposable +{ + private const int ServerId = 4242; + + private readonly string _tempDir; + private readonly DuckDbInitializer _duckDb; + private long _nextId = 1; + + public CollectionHealthWindowTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "LiteCollHealthTests_" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(_tempDir); + _duckDb = new DuckDbInitializer(Path.Combine(_tempDir, "test.duckdb")); + } + + public void Dispose() + { + try + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + catch { /* Best-effort cleanup */ } + } + + [Fact] + public async Task GetRecentCollectionLog_CustomRange_BoundsBothSides_ExcludingRowsOutsideEitherBound() + { + await _duckDb.InitializeAsync(); + var service = new LocalDataService(_duckDb); + + /* A three-hour custom window [start, end] entirely in the past (end an hour ago). A row 30 min + BEFORE start and a row 30 min AFTER end must BOTH be excluded. The after-end exclusion is the + behavior the old read (a single now-relative lower bound) could not express — it would have + swept everything up to "now", pulling in the after-end row. collection_time is stored UTC. */ + var end = Truncate(DateTime.UtcNow.AddHours(-1)); + var start = end.AddHours(-3); + var beforeStart = start.AddMinutes(-30); + var inWindowEarly = start.AddMinutes(15); + var inWindowLate = end.AddMinutes(-15); + var afterEnd = end.AddMinutes(30); + + await SeedLogAsync("wait_stats", beforeStart, "SUCCESS"); + await SeedLogAsync("wait_stats", inWindowEarly, "SUCCESS"); + await SeedLogAsync("cpu_utilization", inWindowLate, "SUCCESS"); + await SeedLogAsync("wait_stats", afterEnd, "SUCCESS"); + + /* GetTimeRange subtracts UtcOffsetMinutes from a custom from/to (server-time -> UTC); feed + server-time bounds so the read lands exactly on [start,end] UTC regardless of the machine tz. */ + var offset = ServerTimeHelper.UtcOffsetMinutes; + var fromDate = start.AddMinutes(offset); + var toDate = end.AddMinutes(offset); + + /* hoursBack=4 would (old bug) sweep everything >= now-4h, pulling in afterEnd (now-30m); the + custom To bound must exclude it. */ + var logs = await service.GetRecentCollectionLogAsync(ServerId, hoursBack: 4, fromDate: fromDate, toDate: toDate); + + Assert.Equal(2, logs.Count); + Assert.All(logs, l => Assert.InRange(l.CollectionTime.Ticks, start.Ticks, end.Ticks)); + Assert.Equal(inWindowLate.Ticks, logs[0].CollectionTime.Ticks); // newest first + Assert.Equal(inWindowEarly.Ticks, logs[1].CollectionTime.Ticks); + } + + [Fact] + public async Task GetRecentCollectionLog_PresetHoursBack_LowerBoundsFromNow() + { + await _duckDb.InitializeAsync(); + var service = new LocalDataService(_duckDb); + + var recent = Truncate(DateTime.UtcNow.AddMinutes(-30)); + var old = Truncate(DateTime.UtcNow.AddHours(-10)); + await SeedLogAsync("wait_stats", recent, "SUCCESS"); + await SeedLogAsync("wait_stats", old, "SUCCESS"); + + /* No custom range → hoursBack lower-bounds "now"; the 10h-old row is excluded, the 30m one kept. */ + var logs = await service.GetRecentCollectionLogAsync(ServerId, hoursBack: 4); + + Assert.Single(logs); + Assert.Equal(recent.Ticks, logs[0].CollectionTime.Ticks); + } + + private static DateTime Truncate(DateTime t) => + new(t.Year, t.Month, t.Day, t.Hour, t.Minute, t.Second, DateTimeKind.Unspecified); + + private async Task SeedLogAsync(string collector, DateTime collectionTimeUtc, string status) + { + using var readLock = _duckDb.AcquireReadLock(); + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +INSERT INTO collection_log + (log_id, server_id, server_name, collector_name, collection_time, + duration_ms, status, error_message, rows_collected, sql_duration_ms, duckdb_duration_ms) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)"; + cmd.Parameters.Add(new DuckDBParameter { Value = _nextId++ }); + cmd.Parameters.Add(new DuckDBParameter { Value = ServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = "TestSrv" }); + cmd.Parameters.Add(new DuckDBParameter { Value = collector }); + cmd.Parameters.Add(new DuckDBParameter { Value = collectionTimeUtc }); + cmd.Parameters.Add(new DuckDBParameter { Value = 100 }); + cmd.Parameters.Add(new DuckDBParameter { Value = status }); + cmd.Parameters.Add(new DuckDBParameter { Value = DBNull.Value }); + cmd.Parameters.Add(new DuckDBParameter { Value = 10 }); + cmd.Parameters.Add(new DuckDBParameter { Value = 80 }); + cmd.Parameters.Add(new DuckDBParameter { Value = 20 }); + await cmd.ExecuteNonQueryAsync(); + } +} diff --git a/Lite.Tests/CollectorGateSurfacePinTests.cs b/Lite.Tests/CollectorGateSurfacePinTests.cs new file mode 100644 index 000000000..08d7f7f53 --- /dev/null +++ b/Lite.Tests/CollectorGateSurfacePinTests.cs @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// THE forcing function for the collector gate-surface collapse (parity audit §01, Tier 0). Before this, +/// Lite gated in two places — the shared <see cref="ICollectorSchemaInfo.AppliesTo"/> AND its own +/// <c>IsCollectorSupported</c> — while Darling ran only <c>AppliesTo</c>, so every gate Lite kept in the +/// second layer (server_config/trace_flags on Azure SQL DB, the query_stats/query_store version gate, and +/// the running_jobs/job_history/agent_status msdb gate) was silently ignored by Darling. That layer is gone. +/// These pins assert: +/// <list type="number"> +/// <item>each moved gate's <c>AppliesTo</c> truth table (Azure SQL DB / AWS RDS / no-msdb / pre-2016), so a +/// gate can't silently regress;</item> +/// <item>that the by-name surface Lite dispatches through (<see cref="CollectorCatalog.AppliesTo(string, CollectorTargetInfo)"/>) +/// agrees with the definition's own <c>AppliesTo</c> that Darling's runner calls — the proof both SKUs now +/// gate identically off ONE surface;</item> +/// <item>the <see cref="CollectorTargetInfo.HasMsdbAccess"/> default (unknown ⇒ assume access) and the +/// unknown-collector-name fall-through.</item> +/// <item>(round 2, from the #1500 review) that every collector name Lite's <c>RunCollectorAsync</c> dispatch +/// switch handles is a real <see cref="CollectorCatalog"/> name — because the pre-dispatch +/// <see cref="CollectorCatalog.AppliesTo(string, CollectorTargetInfo)"/> returns true-on-miss for an unknown +/// name, a switch string that drifted from its definition's <c>Name</c> would make the gate silently no-op.</item> +/// </list> +/// </summary> +public sealed class CollectorGateSurfacePinTests +{ + /* Representative targets spanning every gate dimension. */ + private static readonly CollectorTargetInfo OnPrem2016 = new() { SqlMajorVersion = 13 }; + private static readonly CollectorTargetInfo OnPrem2014 = new() { SqlMajorVersion = 12 }; + private static readonly CollectorTargetInfo AzureSqlDb = new() { IsAzureSqlDb = true, SqlMajorVersion = 12 }; + private static readonly CollectorTargetInfo AzureMi = new() { IsAzureManagedInstance = true, SqlMajorVersion = 12 }; + private static readonly CollectorTargetInfo AwsRds = new() { IsAwsRds = true, SqlMajorVersion = 15 }; + private static readonly CollectorTargetInfo NoMsdb = new() { SqlMajorVersion = 15, HasMsdbAccess = false }; + private static readonly CollectorTargetInfo Unknown = new(); + + private static readonly CollectorTargetInfo[] AllTargets = + { OnPrem2016, OnPrem2014, AzureSqlDb, AzureMi, AwsRds, NoMsdb, Unknown }; + + [Fact] + public void ServerConfig_AppliesTo_SkipsOnlyAzureSqlDb() + { + Assert.False(ServerConfigCollector.Instance.AppliesTo(AzureSqlDb)); /* no sys.configurations */ + Assert.True(ServerConfigCollector.Instance.AppliesTo(AzureMi)); + Assert.True(ServerConfigCollector.Instance.AppliesTo(AwsRds)); + Assert.True(ServerConfigCollector.Instance.AppliesTo(OnPrem2016)); + Assert.True(ServerConfigCollector.Instance.AppliesTo(NoMsdb)); + Assert.True(ServerConfigCollector.Instance.AppliesTo(Unknown)); + } + + [Fact] + public void TraceFlags_AppliesTo_SkipsOnlyAzureSqlDb() + { + Assert.False(TraceFlagsCollector.Instance.AppliesTo(AzureSqlDb)); /* no DBCC TRACESTATUS */ + Assert.True(TraceFlagsCollector.Instance.AppliesTo(AzureMi)); + Assert.True(TraceFlagsCollector.Instance.AppliesTo(AwsRds)); + Assert.True(TraceFlagsCollector.Instance.AppliesTo(OnPrem2016)); + Assert.True(TraceFlagsCollector.Instance.AppliesTo(Unknown)); + } + + [Fact] + public void QueryStats_AppliesTo_SkipsOnlyPreSql2016OnPrem() + { + Assert.False(QueryStatsCollector.Instance.AppliesTo(OnPrem2014)); /* pre-2016 lacks columns read */ + Assert.True(QueryStatsCollector.Instance.AppliesTo(OnPrem2016)); + Assert.True(QueryStatsCollector.Instance.AppliesTo(AzureSqlDb)); /* low ProductMajorVersion, DMV OK */ + Assert.True(QueryStatsCollector.Instance.AppliesTo(AzureMi)); + Assert.True(QueryStatsCollector.Instance.AppliesTo(Unknown)); /* 0 ⇒ assume newest */ + } + + [Fact] + public void QueryStore_AppliesTo_SkipsOnlyPreSql2016OnPrem() + { + Assert.False(QueryStoreCollector.Instance.AppliesTo(OnPrem2014)); /* Query Store shipped in 2016 */ + Assert.True(QueryStoreCollector.Instance.AppliesTo(OnPrem2016)); + Assert.True(QueryStoreCollector.Instance.AppliesTo(AzureSqlDb)); + Assert.True(QueryStoreCollector.Instance.AppliesTo(AzureMi)); + Assert.True(QueryStoreCollector.Instance.AppliesTo(Unknown)); + } + + [Fact] + public void RunningJobs_AppliesTo_SkipsAzureSqlDbRdsAndNoMsdb() + { + Assert.False(RunningJobsCollector.Instance.AppliesTo(AzureSqlDb)); + Assert.False(RunningJobsCollector.Instance.AppliesTo(AwsRds)); /* joins msdb.dbo.syssessions */ + Assert.False(RunningJobsCollector.Instance.AppliesTo(NoMsdb)); + Assert.True(RunningJobsCollector.Instance.AppliesTo(AzureMi)); + Assert.True(RunningJobsCollector.Instance.AppliesTo(OnPrem2016)); + Assert.True(RunningJobsCollector.Instance.AppliesTo(Unknown)); + } + + [Fact] + public void JobHistory_AppliesTo_SkipsAzureSqlDbAndNoMsdb_ButNotRds() + { + Assert.False(JobHistoryCollector.Instance.AppliesTo(AzureSqlDb)); + Assert.False(JobHistoryCollector.Instance.AppliesTo(NoMsdb)); + Assert.True(JobHistoryCollector.Instance.AppliesTo(AwsRds)); /* never touches syssessions */ + Assert.True(JobHistoryCollector.Instance.AppliesTo(AzureMi)); + Assert.True(JobHistoryCollector.Instance.AppliesTo(OnPrem2016)); + Assert.True(JobHistoryCollector.Instance.AppliesTo(Unknown)); + } + + [Fact] + public void AgentStatus_AppliesTo_SkipsAzureSqlDbRdsAndNoMsdb() + { + Assert.False(AgentStatusCollector.Instance.AppliesTo(AzureSqlDb)); + Assert.False(AgentStatusCollector.Instance.AppliesTo(AwsRds)); /* no sys.dm_server_services */ + Assert.False(AgentStatusCollector.Instance.AppliesTo(NoMsdb)); + Assert.True(AgentStatusCollector.Instance.AppliesTo(AzureMi)); + Assert.True(AgentStatusCollector.Instance.AppliesTo(OnPrem2016)); + Assert.True(AgentStatusCollector.Instance.AppliesTo(Unknown)); + } + + [Fact] + public void CatalogByNameGate_AgreesWithDefinitionAppliesTo_ForEveryCollectorAndTarget() + { + /* The parity crux: Lite consults CollectorCatalog.AppliesTo(name, target) pre-dispatch; Darling's + runner calls definition.AppliesTo(target). If those ever disagreed the two SKUs would gate + differently — exactly the drift this collapse removes. Pin that they are identical for every + catalog collector across every target dimension. */ + foreach (var definition in CollectorCatalog.All) + { + foreach (var target in AllTargets) + { + Assert.Equal( + definition.AppliesTo(target), + CollectorCatalog.AppliesTo(definition.Name, target)); + } + } + } + + [Fact] + public void CatalogGate_UnknownCollectorName_IsNotGated() + { + /* A typo'd/renamed name is not silently skipped — it falls through to true so the host's dispatch + switch raises its "Unknown collector" error instead of the gate masking it. */ + Assert.True(CollectorCatalog.AppliesTo("no_such_collector", AzureSqlDb)); + Assert.True(CollectorCatalog.AppliesTo("no_such_collector", NoMsdb)); + } + + [Fact] + public void HasMsdbAccess_DefaultsToTrue_SoUnknownTargetsStillAttemptAgentCollectors() + { + /* The probe returns NULL ⇒ assume access; every bare CollectorTargetInfo must mirror that, or the + three Agent collectors would silently gate off on the unknown path. */ + Assert.True(new CollectorTargetInfo().HasMsdbAccess); + Assert.True(RunningJobsCollector.Instance.AppliesTo(new CollectorTargetInfo())); + Assert.True(JobHistoryCollector.Instance.AppliesTo(new CollectorTargetInfo())); + Assert.True(AgentStatusCollector.Instance.AppliesTo(new CollectorTargetInfo())); + } + + // ── PIN C (round 2, from the #1500 review): dispatch-name hardening ────────────────────────────────── + // The pins above prove Lite's by-name gate (CollectorCatalog.AppliesTo(name, target)) agrees with each + // definition's AppliesTo FOR CATALOG NAMES. They can't catch a switch string in RunCollectorAsync drifting + // from its definition's Name (a rename, a typo): the pre-dispatch AppliesTo(name) returns true-on-miss for + // the unknown string, so the gate silently no-ops and the collector runs where it should have been skipped. + // The dispatch string list has no canonical export, so it is scanned from RemoteCollectorService.cs source. + + private const string RemoteCollectorServicePath = "Lite/Services/RemoteCollectorService.cs"; + + [Fact] + public void RunCollectorDispatchNames_AreAllRealCatalogCollectors() + { + var dispatchNames = ExtractRunCollectorDispatchNames(); + + /* Non-vacuous floor: the real switch has ~35 arms — a scan that found a handful is broken, not passing. */ + Assert.True(dispatchNames.Count >= 30, + $"the RunCollectorAsync dispatch-switch scan found only {dispatchNames.Count} arms — the parse is likely broken"); + + var catalog = CollectorCatalog.All.Select(d => d.Name).ToHashSet(StringComparer.Ordinal); + var orphans = FindOrphanDispatchNames(dispatchNames, catalog); + + Assert.True(orphans.Count == 0, + "RunCollectorAsync dispatches collector name(s) absent from CollectorCatalog.All — " + + "CollectorCatalog.AppliesTo(name) returns true-on-miss for these, so their target gate silently " + + "no-ops (they run even where a definition's AppliesTo would skip them): " + string.Join(", ", orphans)); + } + + [Fact] + public void DispatchNameCatalogCheck_IsNonVacuous() + { + /* Prove the check has teeth so a future extraction/regex change can't make it vacuously green: a real + dispatch name yields no orphan, and a fake one that is NOT a catalog collector is flagged. */ + var catalog = CollectorCatalog.All.Select(d => d.Name).ToHashSet(StringComparer.Ordinal); + Assert.Contains("wait_stats", catalog); // sanity: the catalog side of the check is populated + + var real = ExtractRunCollectorDispatchNames(); + Assert.Empty(FindOrphanDispatchNames(real, catalog)); + + var withFake = real.Append("totally_not_a_real_collector").ToList(); + Assert.Equal(new[] { "totally_not_a_real_collector" }, FindOrphanDispatchNames(withFake, catalog)); + } + + private static List<string> FindOrphanDispatchNames(IEnumerable<string> dispatchNames, ISet<string> catalogNames) => + dispatchNames.Where(n => !catalogNames.Contains(n)).Distinct(StringComparer.Ordinal) + .OrderBy(n => n, StringComparer.Ordinal).ToList(); + + /// <summary> + /// Scans the collector-name string literals from <c>RunCollectorAsync</c>'s dispatch switch expression in + /// <c>RemoteCollectorService.cs</c>: the region from <c>collectorName switch</c> to its <c>_ =></c> + /// default arm, matching each <c>"name" =></c> arm. The default arm (no quotes) and the two upstream + /// <c>collectorName == "…"</c> XE-ensure guards (before the switch) are excluded by construction. + /// </summary> + private static List<string> ExtractRunCollectorDispatchNames() + { + var src = ParitySource.ReadFile(RemoteCollectorServicePath); + + var switchIdx = src.IndexOf("collectorName switch", StringComparison.Ordinal); + Assert.True(switchIdx > 0, "could not find the 'collectorName switch' dispatch in RemoteCollectorService.cs"); + + var defaultArmIdx = src.IndexOf("_ =>", switchIdx, StringComparison.Ordinal); + Assert.True(defaultArmIdx > switchIdx, "could not find the dispatch switch's default '_ =>' arm"); + + var switchBody = src.Substring(switchIdx, defaultArmIdx - switchIdx); + + return Regex.Matches(switchBody, @"^\s*""([a-z0-9_]+)""\s*=>", RegexOptions.Multiline) + .Select(m => m.Groups[1].Value) + .ToList(); + } +} diff --git a/Lite.Tests/CollectorHealthClassifierTests.cs b/Lite.Tests/CollectorHealthClassifierTests.cs new file mode 100644 index 000000000..db7ece182 --- /dev/null +++ b/Lite.Tests/CollectorHealthClassifierTests.cs @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Decision-table pins for the shared <see cref="CollectorHealthClassifier"/> (#1573) - the one banding +/// the Lite grid, the Darling viewer's grid + Overview "collectors failing" count, and the service's +/// get_collection_health / web fleet reader all delegate to. This SAME table is pinned identically in +/// Darling.Tests so the two SKUs' collector-health banding cannot drift. +/// +/// <para> +/// The load-bearing case is the motivating bug: index_object_stats is a DAILY collector +/// (<see cref="CollectorScheduleDefaults"/> cadence 1440 min) that succeeds every run, yet the old flat +/// 4h-STALE / 24h-FAILING thresholds flagged it STALE past 4h and FAILING past 24h. The thresholds are +/// now relative to each collector's cadence (FAILING = max(24, 2 x freqHours); STALE = max(4, 1.5 x +/// freqHours)), with floors that keep every FREQUENT collector byte-for-byte identical. +/// </para> +/// </summary> +public sealed class CollectorHealthClassifierTests +{ + private const int OneMinute = 1; // wait_stats, cpu_utilization, etc. - the fastest cadence. + private const int Hourly = 60; // database_size_stats - still floor-bound (1.5h/2h < 4h/24h). + private const int Daily = 1440; // index_object_stats - the collector that relaxes. + private const int OnLoadFreq = 0; // config snapshots run on connect, not on the loop. + + [Theory] + /* NEVER_RUN wins first, whatever else is set (including on-load). */ + [InlineData(0, 0, 0, 0, 999, OneMinute, false, CollectorHealthClassifier.NeverRun)] + [InlineData(0, 0, 0, 0, 0, OnLoadFreq, true, CollectorHealthClassifier.NeverRun)] + + /* NO_PERMISSIONS (only permission denials) is checked before the on-load branch. */ + [InlineData(5, 0, 0, 5, 999, OneMinute, false, CollectorHealthClassifier.NoPermissions)] + [InlineData(5, 0, 0, 5, 999, OnLoadFreq, true, CollectorHealthClassifier.NoPermissions)] + + /* On-load collectors are staleness-exempt: banded by failure rate only, never STALE/FAILING. */ + [InlineData(10, 10, 0, 0, 500, OnLoadFreq, true, CollectorHealthClassifier.Healthy)] + [InlineData(10, 7, 3, 0, 500, OnLoadFreq, true, CollectorHealthClassifier.Warning)] // 30% > 20% + + /* Frequent (1-min) collector - the floors mean these are IDENTICAL to the old flat thresholds. */ + [InlineData(10, 10, 0, 0, 2, OneMinute, false, CollectorHealthClassifier.Healthy)] // < 4h + [InlineData(10, 7, 3, 0, 2, OneMinute, false, CollectorHealthClassifier.Warning)] // recent, 30% fail + [InlineData(10, 10, 0, 0, 5, OneMinute, false, CollectorHealthClassifier.Stale)] // > 4h, < 24h + [InlineData(10, 10, 0, 0, 25, OneMinute, false, CollectorHealthClassifier.Failing)] // > 24h + [InlineData(5, 0, 5, 0, 999, OneMinute, false, CollectorHealthClassifier.Failing)] // ran, never a success (999 sentinel) + + /* Hourly (60-min) collector - 1.5h/2h are both under the 4h/24h floors, so still floor-bound. */ + [InlineData(10, 10, 0, 0, 5, Hourly, false, CollectorHealthClassifier.Stale)] // > 4h floor + [InlineData(10, 10, 0, 0, 25, Hourly, false, CollectorHealthClassifier.Failing)] // > 24h floor + + /* Daily (1440-min) collector - the FIX. STALE line 36h, FAILING line 48h. */ + [InlineData(30, 30, 0, 0, 27, Daily, false, CollectorHealthClassifier.Healthy)] // THE BUG: was FAILING, now HEALTHY + [InlineData(30, 30, 0, 0, 35, Daily, false, CollectorHealthClassifier.Healthy)] // still under the 36h stale line + [InlineData(30, 30, 0, 0, 37, Daily, false, CollectorHealthClassifier.Stale)] // > 36h, < 48h + [InlineData(30, 30, 0, 0, 47, Daily, false, CollectorHealthClassifier.Stale)] // still stale, not failing + [InlineData(30, 30, 0, 0, 49, Daily, false, CollectorHealthClassifier.Failing)] // > 48h + [InlineData(10, 7, 3, 0, 1, Daily, false, CollectorHealthClassifier.Warning)] // recent, 30% fail -> WARNING not STALE + public void Classify_BandsTheDecisionTable( + long totalRuns, long successCount, long errorCount, long permissionDeniedCount, + double hoursSinceLastSuccess, int frequencyMinutes, bool isOnLoad, string expected) + { + Assert.Equal(expected, CollectorHealthClassifier.Classify( + totalRuns, successCount, errorCount, permissionDeniedCount, + hoursSinceLastSuccess, frequencyMinutes, isOnLoad)); + } + + [Theory] + [InlineData(OneMinute, 24.0)] // floor + [InlineData(Hourly, 24.0)] // 2 x 1h = 2h < 24h floor + [InlineData(720, 24.0)] // 2 x 12h = 24h, exactly the floor + [InlineData(Daily, 48.0)] // 2 x 24h = 48h relaxes past the floor + public void FailingThresholdHours_IsMaxOfFloorAndTwiceCadence(int frequencyMinutes, double expected) => + Assert.Equal(expected, CollectorHealthClassifier.FailingThresholdHours(frequencyMinutes), 3); + + [Theory] + [InlineData(OneMinute, 4.0)] // floor + [InlineData(Hourly, 4.0)] // 1.5 x 1h = 1.5h < 4h floor + [InlineData(Daily, 36.0)] // 1.5 x 24h = 36h relaxes past the floor + public void StaleThresholdHours_IsMaxOfFloorAndOnePointFiveCadence(int frequencyMinutes, double expected) => + Assert.Equal(expected, CollectorHealthClassifier.StaleThresholdHours(frequencyMinutes), 3); + + [Theory] + [InlineData("server_config", true)] + [InlineData("database_config", true)] + [InlineData("database_scoped_config", true)] + [InlineData("trace_flags", true)] + [InlineData("server_properties", true)] + [InlineData("SERVER_CONFIG", true)] // case-insensitive + [InlineData("wait_stats", false)] + [InlineData("index_object_stats", false)] + public void IsOnLoadCollector_MatchesTheFreqZeroSet(string collectorName, bool expected) => + Assert.Equal(expected, CollectorHealthClassifier.IsOnLoadCollector(collectorName)); + + /// <summary>The on-load set is exactly the FrequencyMinutes == 0 entries in the shared schedule table - + /// the invariant that lets a caller resolve cadence and on-load-ness from the same source.</summary> + [Fact] + public void OnLoadSet_EqualsTheFreqZeroScheduleEntries() + { + foreach (var (name, entry) in CollectorScheduleDefaults.All) + { + Assert.Equal(entry.FrequencyMinutes == 0, CollectorHealthClassifier.IsOnLoadCollector(name)); + } + } + + /* --- The bug, reproduced through Lite's real CollectorHealthRow (it resolves the cadence from + CollectorScheduleDefaults by name). --- */ + + [Fact] + public void IndexObjectStats_DefaultCadence_IsDaily() + { + // Pins the exact input that produced the field false-positive: a 1440-min (daily) collector. + Assert.Equal(1440, CollectorScheduleDefaults.All["index_object_stats"].FrequencyMinutes); + } + + [Fact] + public void CollectorHealthRow_IndexObjectStats_At27Hours_IsHealthyNotFailing() + { + /* The motivating bug: index_object_stats succeeds daily; 27h since last success is well within a + daily cadence. Under the old flat 24h-FAILING threshold this read FAILING; it now reads HEALTHY. */ + var row = new CollectorHealthRow + { + CollectorName = "index_object_stats", + TotalRuns = 7, + SuccessCount = 7, + LastSuccessTime = DateTime.UtcNow.AddHours(-27), + }; + Assert.Equal("HEALTHY", row.HealthStatus); + } + + [Fact] + public void CollectorHealthRow_IndexObjectStats_At37Hours_IsStale() + { + var row = new CollectorHealthRow + { + CollectorName = "index_object_stats", + TotalRuns = 7, + SuccessCount = 7, + LastSuccessTime = DateTime.UtcNow.AddHours(-37), + }; + Assert.Equal("STALE", row.HealthStatus); + } + + [Fact] + public void CollectorHealthRow_IndexObjectStats_At49Hours_IsFailing() + { + var row = new CollectorHealthRow + { + CollectorName = "index_object_stats", + TotalRuns = 7, + SuccessCount = 7, + LastSuccessTime = DateTime.UtcNow.AddHours(-49), + }; + Assert.Equal("FAILING", row.HealthStatus); + } + + [Fact] + public void CollectorHealthRow_FrequentCollector_BandsUnchanged() + { + /* A 1-min collector is unaffected by the relative thresholds (the floors dominate): 30h -> FAILING, + exactly as before the #1573 change. */ + var row = new CollectorHealthRow + { + CollectorName = "wait_stats", + TotalRuns = 100, + SuccessCount = 100, + LastSuccessTime = DateTime.UtcNow.AddHours(-30), + }; + Assert.Equal("FAILING", row.HealthStatus); + } +} diff --git a/Lite.Tests/CollectorPlanColumnRoundTripTests.cs b/Lite.Tests/CollectorPlanColumnRoundTripTests.cs new file mode 100644 index 000000000..d791c008c --- /dev/null +++ b/Lite.Tests/CollectorPlanColumnRoundTripTests.cs @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.IO; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using PerformanceMonitorLite.Database; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Real-DuckDB round-trip pins for the #1262 plan columns on procedure_stats, blocked_process_reports +/// and deadlocks. The collector-definition tests pin PayloadColumns/WritePayload against a recording +/// writer, but nothing else proves the Lite DuckDB table's physical column layout stays aligned with +/// WritePayload — the DuckDB appender writes BY POSITION, so a table whose column count/order drifts +/// from (4 prefix + WritePayload) fails at EndRow(). This exercises the exact append path +/// RemoteCollectorService.RunCollectorDefinitionAsync uses (CreateAppender → prefix → WritePayload → +/// EndRow) against a freshly initialized schema, then reads the trailing plan column back. It also +/// guards the append itself: an un-migrated legacy DB (fewer columns) would throw here. +/// </summary> +public sealed class CollectorPlanColumnRoundTripTests : IDisposable +{ + private readonly string _tempDir; + private readonly string _dbPath; + + public CollectorPlanColumnRoundTripTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "LitePlanRt_" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(_tempDir); + _dbPath = Path.Combine(_tempDir, "test.duckdb"); + } + + public void Dispose() + { + try + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, recursive: true); + } + } + catch + { + /* Best-effort cleanup */ + } + } + + [Fact] + public async Task ProcedureStats_AppendWithPlan_RoundTripsTrailingColumn() + { + await AssertRoundTripsAsync( + ProcedureStatsCollector.Instance, + new ProcedureStatsCollector.Row( + "DB", "dbo", "usp_X", "PROCEDURE", + new DateTime(2026, 7, 4, 1, 0, 0, DateTimeKind.Utc), new DateTime(2026, 7, 4, 2, 0, 0, DateTimeKind.Utc), + 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, + "0xSH", "0xPH", "<ShowPlanXML>proc</ShowPlanXML>"), + ("query_plan_xml", "<ShowPlanXML>proc</ShowPlanXML>")); + } + + [Fact] + public async Task BlockedProcessReports_AppendWithPlans_RoundTripsBothTrailingColumns() + { + var row = new BlockedProcessReportCollector.Row + { + EventTime = new DateTime(2026, 7, 4, 3, 0, 0, DateTimeKind.Utc), + BlockedQueryPlanXml = "<ShowPlanXML>blocked</ShowPlanXML>", + BlockingQueryPlanXml = "<ShowPlanXML>blocking</ShowPlanXML>", + }; + + await AssertRoundTripsAsync(BlockedProcessReportCollector.Instance, row, + ("blocked_query_plan_xml", "<ShowPlanXML>blocked</ShowPlanXML>"), + ("blocking_query_plan_xml", "<ShowPlanXML>blocking</ShowPlanXML>")); + } + + [Fact] + public async Task Deadlocks_AppendWithVictimPlan_RoundTripsTrailingColumn() + { + var row = new DeadlocksCollector.Row + { + DeadlockTime = new DateTime(2026, 7, 4, 4, 0, 0, DateTimeKind.Utc), + VictimProcessId = "pv", + VictimQueryPlanXml = "<ShowPlanXML>victim</ShowPlanXML>", + }; + + await AssertRoundTripsAsync(DeadlocksCollector.Instance, row, + ("victim_query_plan_xml", "<ShowPlanXML>victim</ShowPlanXML>")); + } + + /// <summary> + /// Appends one row through the exact runner path (prefix columns + WritePayload + EndRow) against a + /// freshly initialized DuckDB, then asserts each named column read back its expected plan text. EndRow + /// throwing here would mean the Schema.cs table column count/order drifted from WritePayload. + /// </summary> + private async Task AssertRoundTripsAsync<TRow>( + ICollectorDefinition<TRow> definition, TRow row, params (string Column, string Expected)[] columns) + { + var dbPath = Path.Combine(_tempDir, $"{definition.TargetTable}.duckdb"); + await new DuckDbInitializer(dbPath).InitializeAsync(); + + var context = CollectorTestContext.Make(new RecordingCollectorDeltaCalculator()); + + using var connection = new DuckDBConnection($"Data Source={dbPath}"); + await connection.OpenAsync(TestContext.Current.CancellationToken); + + using (var appender = connection.CreateAppender(definition.TargetTable)) + { + var appenderRow = appender.CreateRow(); + if (definition.IncludesCollectionId) + { + appenderRow.AppendValue(1L); + } + appenderRow.AppendValue(context.CollectionTime).AppendValue(42).AppendValue("s"); + + var writer = new AppenderCollectorRowWriter { CurrentRow = appenderRow }; + definition.WritePayload(row, writer, context); + appenderRow.EndRow(); /* throws if the appended column count != table column count */ + } + + foreach (var (column, expected) in columns) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = $"SELECT {column} FROM {definition.TargetTable} LIMIT 1"; + var value = await cmd.ExecuteScalarAsync(TestContext.Current.CancellationToken); + Assert.Equal(expected, value); + } + } +} diff --git a/Lite.Tests/CollectorViewerCoverageTests.cs b/Lite.Tests/CollectorViewerCoverageTests.cs new file mode 100644 index 000000000..6736b5e94 --- /dev/null +++ b/Lite.Tests/CollectorViewerCoverageTests.cs @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// THE COVERAGE PIN (parity board Tier 0), Lite side. Every collector table in +/// <see cref="CollectorCatalog"/> must have a consumer in Lite's reader layer +/// (<c>Lite/Services/LocalDataService*.cs</c>) — referenced directly by table name or via its +/// <c>v_</c> view — OR be listed in <see cref="KnownStoreOnlyOrUnbuiltTables"/> as tracked debt. +/// +/// This turns "collect-but-don't-show" from a silently-green build into a red one: today a collector +/// plus a DuckDB table can land with zero UI ever reading it and nothing complains (the parity audit +/// found several such tables). With this pin, a NEW collector table that no reader touches — and that +/// nobody deliberately allow-lists — fails immediately. +/// +/// RATCHET: the allow-list is a visible, shrinking to-do. When a tab/reader ships for one of these +/// tables the reader starts referencing it, <see cref="AllowList_HasNoEntryThatIsActuallyRead"/> goes +/// red, and the entry MUST be deleted. The list can only get smaller; it can never quietly absorb a +/// regression, because <see cref="AllowList_NamesOnlyRealCatalogTables"/> rejects anything that is not +/// a real catalog table. +/// +/// Text-scans the reader source (no DuckDB / WPF needed) and locates Lite via +/// <see cref="CallerFilePathAttribute"/>, exactly like <c>ThemeCompletenessTests</c>. +/// </summary> +public sealed class CollectorViewerCoverageTests +{ + /// <summary> + /// Collector tables that Lite collects and stores but whose data no reader/viewer surfaces yet. + /// Every entry is Tier-1 unbuilt UI: Lite already collects the data, the Darling viewer already has + /// the tab, and the Lite port is pending. + /// + /// EMPTY (fully drained). The last two entries — <c>system_health_events</c> and + /// <c>default_trace_events</c> — shipped as the System Events tab (its nine parse-on-read grids read + /// <c>v_system_health_events</c>; the Default Trace lane reads <c>v_default_trace_events</c>), so the + /// ratchet retired them. Every collector table now has a Lite reader; a NEW collect-but-don't-show + /// table must add a reader/tab or re-open this list with a Tier-1 comment. + /// </summary> + private static readonly HashSet<string> KnownStoreOnlyOrUnbuiltTables = new(StringComparer.OrdinalIgnoreCase); + + [Fact] + public void EveryCollectorTable_HasALiteReader_OrIsAllowListed() + { + var readerText = ReaderLayerText(); + + var uncovered = new List<string>(); + foreach (var definition in CollectorCatalog.All) + { + var table = definition.TargetTable; + if (ReferencedIn(readerText, table)) + { + continue; // read directly or via its v_ view + } + if (KnownStoreOnlyOrUnbuiltTables.Contains(table)) + { + continue; // tracked debt — the ratchet keeps this list shrinking + } + uncovered.Add(table); + } + + Assert.True(uncovered.Count == 0, + "Collector table(s) have no Lite reader and are not allow-listed. Add a reader/tab in " + + "Lite/Services/LocalDataService*.cs, or — if the UI is intentionally not built yet — add the " + + "table to KnownStoreOnlyOrUnbuiltTables with a Tier-1 comment:\n " + + string.Join("\n ", uncovered.OrderBy(t => t, StringComparer.Ordinal))); + } + + [Fact] + public void AllowList_NamesOnlyRealCatalogTables() + { + var catalog = CollectorCatalog.All + .Select(d => d.TargetTable) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var unknown = KnownStoreOnlyOrUnbuiltTables + .Where(t => !catalog.Contains(t)) + .OrderBy(t => t, StringComparer.Ordinal) + .ToList(); + + Assert.True(unknown.Count == 0, + "Allow-list names table(s) that are not in CollectorCatalog (typo, or a removed/renamed " + + "collector): " + string.Join(", ", unknown)); + } + + [Fact] + public void AllowList_HasNoEntryThatIsActuallyRead() + { + // The ratchet enforcement: once a table gains a reader, its allow-list entry is stale debt and + // must be deleted. This fails the build until it is, so the list can only shrink. + var readerText = ReaderLayerText(); + + var stale = KnownStoreOnlyOrUnbuiltTables + .Where(t => ReferencedIn(readerText, t)) + .OrderBy(t => t, StringComparer.Ordinal) + .ToList(); + + Assert.True(stale.Count == 0, + "Allow-list entr(y/ies) now HAVE a Lite reader — the tab/reader shipped, so delete them from " + + "KnownStoreOnlyOrUnbuiltTables: " + string.Join(", ", stale)); + } + + /// <summary>A table is "referenced" if its name appears anywhere in the reader source — this matches + /// both a direct <c>FROM table</c> and the usual <c>FROM v_table</c> view form (the view name contains + /// the table name as a substring). No catalog table name is a substring of another, so this is + /// collision-free across the catalog.</summary> + private static bool ReferencedIn(string readerText, string table) => + readerText.Contains(table, StringComparison.OrdinalIgnoreCase); + + /// <summary>Concatenated text of Lite's reader layer (<c>Lite/Services/LocalDataService*.cs</c>), + /// located from this test file's compile-time path (Lite.Tests is a sibling of the Lite project).</summary> + private static string ReaderLayerText([CallerFilePath] string thisFile = "") + { + var testDir = Path.GetDirectoryName(thisFile)!; + var readerDir = Path.GetFullPath(Path.Combine(testDir, "..", "Lite", "Services")); + + // Enumerate all *.cs and filter by filename prefix rather than passing "LocalDataService*.cs" as the + // search pattern: Directory.EnumerateFiles matches the pattern against BOTH the long name and the 8.3 + // short name, which makes multi-dot names (e.g. LocalDataService.FinOps.IndexObjects.cs) match + // unreliably. A plain StartsWith is unambiguous. The obj/bin exclusion checks whole path SEGMENTS, + // not a substring — a substring "obj" would wrongly match LocalDataService.FinOps.Index*Obj*ects.cs. + var files = Directory + .EnumerateFiles(readerDir, "*.cs", SearchOption.AllDirectories) + .Where(f => Path.GetFileName(f).StartsWith("LocalDataService", StringComparison.OrdinalIgnoreCase)) + .Where(f => !HasBuildOutputSegment(f)) + .ToList(); + + Assert.False(files.Count == 0, + $"No LocalDataService*.cs files found under {readerDir} — the coverage pin cannot read Lite's " + + "reader layer (did the reader layer move?)."); + + return string.Join("\n", files.Select(File.ReadAllText)); + } + + /// <summary>True if any whole path segment is a build-output directory (obj/bin). Segment-based, not a + /// substring test — a plain Contains("obj") would false-positive on a source file such as + /// LocalDataService.FinOps.IndexObjects.cs.</summary> + private static bool HasBuildOutputSegment(string path) => + path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + .Any(seg => seg.Equals("obj", StringComparison.OrdinalIgnoreCase) || + seg.Equals("bin", StringComparison.OrdinalIgnoreCase)); +} diff --git a/Lite.Tests/ConfigChangesReaderTests.cs b/Lite.Tests/ConfigChangesReaderTests.cs new file mode 100644 index 000000000..c43c1770f --- /dev/null +++ b/Lite.Tests/ConfigChangesReaderTests.cs @@ -0,0 +1,302 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.IO; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; +using PerformanceMonitorLite.Database; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Real-DuckDB round-trip pins for the Configuration Changes tab's readers +/// (LocalDataService.ConfigChanges). There is NO change table — the pure diff + the change/snapshot records +/// live once in the Common <see cref="ConfigChangeDiff"/> (already unit-tested there and in the Darling +/// config-history tests); these prove the LITE glue end-to-end against a freshly initialized store: seed the +/// v_ snapshot views' base tables with two captures that encode a KNOWN change, read back through the reader, +/// and assert the diff surfaces the expected change rows with the derived columns, the pre-window baseline is +/// kept (upper-bound-only read), the window excludes out-of-window changes, the #1319 database filter, and the +/// row VM's server-time render. The scenarios mirror the Darling config-history tests exactly. +/// </summary> +public sealed class ConfigChangesReaderTests : IDisposable +{ + private const int ServerId = 8888; + + private readonly string _tempDir; + private readonly DuckDbInitializer _duckDb; + private long _nextId = 1; + + public ConfigChangesReaderTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "LiteConfigChgRt_" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(_tempDir); + _duckDb = new DuckDbInitializer(Path.Combine(_tempDir, "test.duckdb")); + } + + public void Dispose() + { + try + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + catch { /* Best-effort cleanup */ } + } + + private static DateTime Truncate(DateTime t) => + new(t.Year, t.Month, t.Day, t.Hour, t.Minute, t.Second, DateTimeKind.Unspecified); + + // ── Server config ── + + [Fact] + public async Task ServerConfigChanges_DetectsValueMove_NewestFirst_WithDerivedColumns() + { + await _duckDb.InitializeAsync(); + var service = new LocalDataService(_duckDb); + + var older = Truncate(DateTime.UtcNow.AddHours(-3)); + var newer = Truncate(DateTime.UtcNow.AddHours(-2)); + + /* max degree of parallelism 0 -> 4 (both configured and in-use move together). An unchanged setting is + also planted to prove only the moved one surfaces. */ + await SeedServerConfigAsync(older, "max degree of parallelism", 0, 0, isDynamic: true, isAdvanced: true); + await SeedServerConfigAsync(newer, "max degree of parallelism", 4, 4, isDynamic: true, isAdvanced: true); + await SeedServerConfigAsync(older, "cost threshold for parallelism", 5, 5, isDynamic: true, isAdvanced: true); + await SeedServerConfigAsync(newer, "cost threshold for parallelism", 5, 5, isDynamic: true, isAdvanced: true); + + var change = Assert.Single(await service.GetServerConfigChangesAsync(ServerId)); + Assert.Equal("max degree of parallelism", change.ConfigurationName); + Assert.Equal(0L, change.OldValueConfigured); + Assert.Equal(4L, change.NewValueConfigured); + Assert.Equal("Configured value changed from 0 to 4", change.ChangeDescription); + } + + [Fact] + public async Task ServerConfigChanges_RequiresRestart_DerivedFromDynamicAndValueMismatch() + { + await _duckDb.InitializeAsync(); + var service = new LocalDataService(_duckDb); + + var older = Truncate(DateTime.UtcNow.AddHours(-3)); + var newer = Truncate(DateTime.UtcNow.AddHours(-2)); + + /* Non-dynamic, new configured (16000) != new in-use (8000) => requires restart. */ + await SeedServerConfigAsync(older, "max server memory (MB)", 8000, 8000, isDynamic: false, isAdvanced: false); + await SeedServerConfigAsync(newer, "max server memory (MB)", 16000, 8000, isDynamic: false, isAdvanced: false); + + var change = Assert.Single(await service.GetServerConfigChangesAsync(ServerId)); + Assert.Equal("Yes", change.RequiresRestartDisplay); + Assert.Equal("No", change.DynamicDisplay); + Assert.Equal("Configured value changed from 8000 to 16000", change.ChangeDescription); + } + + [Fact] + public async Task ServerConfigChanges_SingleSnapshot_YieldsNothing() + { + await _duckDb.InitializeAsync(); + var service = new LocalDataService(_duckDb); + + await SeedServerConfigAsync(Truncate(DateTime.UtcNow.AddHours(-2)), "max server memory (MB)", 8000, 8000, true, true); + + Assert.Empty(await service.GetServerConfigChangesAsync(ServerId)); + } + + [Fact] + public async Task ServerConfigChanges_PreWindowBaselineKept_ChangeOnFirstInWindowSnapshotIsDetected() + { + await _duckDb.InitializeAsync(); + var service = new LocalDataService(_duckDb); + + /* Baseline BEFORE the 24h window (30h ago); the value moves to the in-window capture (1h ago). Because + the read upper-bounds only, the pre-window baseline is kept and the change is still detected. */ + await SeedServerConfigAsync(Truncate(DateTime.UtcNow.AddHours(-30)), "max degree of parallelism", 0, 0, true, true); + await SeedServerConfigAsync(Truncate(DateTime.UtcNow.AddHours(-1)), "max degree of parallelism", 8, 8, true, true); + + var change = Assert.Single(await service.GetServerConfigChangesAsync(ServerId, hoursBack: 24)); + Assert.Equal(0L, change.OldValueConfigured); + Assert.Equal(8L, change.NewValueConfigured); + } + + [Fact] + public async Task ServerConfigChanges_WindowExcludesChangeOlderThanTheWindow() + { + await _duckDb.InitializeAsync(); + var service = new LocalDataService(_duckDb); + + /* Both captures are OLDER than a 24h window (50h and 48h ago), so the 48h-ago change is excluded. */ + await SeedServerConfigAsync(Truncate(DateTime.UtcNow.AddHours(-50)), "max degree of parallelism", 0, 0, true, true); + await SeedServerConfigAsync(Truncate(DateTime.UtcNow.AddHours(-48)), "max degree of parallelism", 8, 8, true, true); + + Assert.Empty(await service.GetServerConfigChangesAsync(ServerId, hoursBack: 24)); + } + + // ── Database config (wide-row unpivot) ── + + [Fact] + public async Task DatabaseConfigChanges_UnpivotsWideRow_NamesTheChangedColumn_WithDescription() + { + await _duckDb.InitializeAsync(); + var service = new LocalDataService(_duckDb); + + var older = Truncate(DateTime.UtcNow.AddHours(-3)); + var newer = Truncate(DateTime.UtcNow.AddHours(-2)); + + await SeedDatabaseConfigRecoveryModelAsync(older, "StackOverflow", "FULL"); + await SeedDatabaseConfigRecoveryModelAsync(newer, "StackOverflow", "SIMPLE"); + + var change = Assert.Single(await service.GetDatabaseConfigChangesAsync(ServerId)); + Assert.Equal("StackOverflow", change.DatabaseName); + Assert.Equal("recovery_model", change.SettingName); + Assert.Equal("FULL", change.OldValue); + Assert.Equal("SIMPLE", change.NewValue); + Assert.Equal("Changed from FULL to SIMPLE", change.ChangeDescription); + } + + [Fact] + public async Task DatabaseConfigChanges_DatabaseFilter_1319_FiltersInSql() + { + await _duckDb.InitializeAsync(); + var service = new LocalDataService(_duckDb); + + var older = Truncate(DateTime.UtcNow.AddHours(-3)); + var newer = Truncate(DateTime.UtcNow.AddHours(-2)); + + await SeedDatabaseConfigRecoveryModelAsync(older, "DbA", "FULL"); + await SeedDatabaseConfigRecoveryModelAsync(newer, "DbA", "SIMPLE"); + await SeedDatabaseConfigRecoveryModelAsync(older, "DbB", "FULL"); + await SeedDatabaseConfigRecoveryModelAsync(newer, "DbB", "SIMPLE"); + + Assert.Equal(2, (await service.GetDatabaseConfigChangesAsync(ServerId)).Count); + var filtered = await service.GetDatabaseConfigChangesAsync(ServerId, databaseNames: new[] { "DbA" }); + var change = Assert.Single(filtered); + Assert.Equal("DbA", change.DatabaseName); + } + + // ── Trace flags (set-diff) ── + + [Fact] + public async Task TraceFlagChanges_DetectsEnableDisableModify() + { + await _duckDb.InitializeAsync(); + var service = new LocalDataService(_duckDb); + + var t0 = Truncate(DateTime.UtcNow.AddHours(-4)); + var t1 = Truncate(DateTime.UtcNow.AddHours(-3)); + var t2 = Truncate(DateTime.UtcNow.AddHours(-2)); + + /* T0: 3226 global on. */ + await SeedTraceFlagAsync(t0, 3226, status: true, isGlobal: true, isSession: false); + /* T1: 3226 still on, 1117 appears (enabled). */ + await SeedTraceFlagAsync(t1, 3226, status: true, isGlobal: true, isSession: false); + await SeedTraceFlagAsync(t1, 1117, status: true, isGlobal: true, isSession: false); + /* T2: 1117 disappears (disabled), 3226 becomes session-scoped (modified). */ + await SeedTraceFlagAsync(t2, 3226, status: true, isGlobal: false, isSession: true); + + var changes = await service.GetTraceFlagChangesAsync(ServerId); + Assert.Equal(3, changes.Count); + + var enabled = Assert.Single(changes, c => c.TraceFlag == 1117 && c.ChangeDescription == "Trace flag 1117 ENABLED"); + Assert.Equal("", enabled.PreviousStatusDisplay); + Assert.Equal("ON", enabled.NewStatusDisplay); + Assert.Equal("GLOBAL", enabled.Scope); + + Assert.Single(changes, c => c.TraceFlag == 1117 && c.ChangeDescription == "Trace flag 1117 DISABLED"); + + var modified = Assert.Single(changes, c => c.TraceFlag == 3226 && c.ChangeDescription == "Trace flag 3226 scope changed"); + Assert.Equal("SESSION", modified.Scope); + Assert.Equal("Yes", modified.SessionDisplay); + Assert.Equal("No", modified.GlobalDisplay); + } + + // ── Row VM projection (server-time render, shared with the other grids) ── + + [Fact] + public void ServerConfigChangeRow_ChangeTimeLocal_RendersThroughServerTimeHelper() + { + var utc = new DateTime(2026, 7, 12, 9, 30, 0, DateTimeKind.Utc); + var expected = ServerTimeHelper.FormatServerTime(utc, "yyyy-MM-dd HH:mm:ss"); + + var row = new ServerConfigChangeRow(new ConfigChangeDiff.ServerConfigChange( + utc, "max degree of parallelism", 0, 4, 0, 4, true, true)); + + Assert.Equal(expected, row.ChangeTimeLocal); + } + + // ── Seeding helpers (raw INSERT via the shared initializer's connection) ── + + private async Task SeedServerConfigAsync( + DateTime captureTimeUtc, string configurationName, long valueConfigured, long valueInUse, bool isDynamic, bool isAdvanced) + { + using var readLock = _duckDb.AcquireReadLock(); + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +INSERT INTO server_config + (config_id, capture_time, server_id, server_name, configuration_name, + value_configured, value_in_use, is_dynamic, is_advanced) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)"; + cmd.Parameters.Add(new DuckDBParameter { Value = _nextId++ }); + cmd.Parameters.Add(new DuckDBParameter { Value = captureTimeUtc }); + cmd.Parameters.Add(new DuckDBParameter { Value = ServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = "TestSrv" }); + cmd.Parameters.Add(new DuckDBParameter { Value = configurationName }); + cmd.Parameters.Add(new DuckDBParameter { Value = valueConfigured }); + cmd.Parameters.Add(new DuckDBParameter { Value = valueInUse }); + cmd.Parameters.Add(new DuckDBParameter { Value = isDynamic }); + cmd.Parameters.Add(new DuckDBParameter { Value = isAdvanced }); + await cmd.ExecuteNonQueryAsync(); + } + + private async Task SeedDatabaseConfigRecoveryModelAsync(DateTime captureTimeUtc, string databaseName, string recoveryModel) + { + using var readLock = _duckDb.AcquireReadLock(); + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + using var cmd = connection.CreateCommand(); + /* Only the prefix + database_name + recovery_model are set; the other 26 setting columns stay NULL in + both captures, so the wide-row diff surfaces ONLY the recovery_model move. */ + cmd.CommandText = @" +INSERT INTO database_config + (config_id, capture_time, server_id, server_name, database_name, recovery_model) +VALUES ($1, $2, $3, $4, $5, $6)"; + cmd.Parameters.Add(new DuckDBParameter { Value = _nextId++ }); + cmd.Parameters.Add(new DuckDBParameter { Value = captureTimeUtc }); + cmd.Parameters.Add(new DuckDBParameter { Value = ServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = "TestSrv" }); + cmd.Parameters.Add(new DuckDBParameter { Value = databaseName }); + cmd.Parameters.Add(new DuckDBParameter { Value = recoveryModel }); + await cmd.ExecuteNonQueryAsync(); + } + + private async Task SeedTraceFlagAsync(DateTime captureTimeUtc, int traceFlag, bool status, bool isGlobal, bool isSession) + { + using var readLock = _duckDb.AcquireReadLock(); + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +INSERT INTO trace_flags + (config_id, capture_time, server_id, server_name, trace_flag, status, is_global, is_session) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8)"; + cmd.Parameters.Add(new DuckDBParameter { Value = _nextId++ }); + cmd.Parameters.Add(new DuckDBParameter { Value = captureTimeUtc }); + cmd.Parameters.Add(new DuckDBParameter { Value = ServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = "TestSrv" }); + cmd.Parameters.Add(new DuckDBParameter { Value = traceFlag }); + cmd.Parameters.Add(new DuckDBParameter { Value = status }); + cmd.Parameters.Add(new DuckDBParameter { Value = isGlobal }); + cmd.Parameters.Add(new DuckDBParameter { Value = isSession }); + await cmd.ExecuteNonQueryAsync(); + } +} diff --git a/Lite.Tests/ConfigCollectorsDefinitionTests.cs b/Lite.Tests/ConfigCollectorsDefinitionTests.cs new file mode 100644 index 000000000..0de34d220 --- /dev/null +++ b/Lite.Tests/ConfigCollectorsDefinitionTests.cs @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary>Pins the parity contracts of the config-family definitions (batch 7).</summary> +public sealed class ServerConfigCollectorDefinitionTests +{ + private static readonly RecordingCollectorDeltaCalculator s_deltas = new(); + + [Fact] + public void NamesColumnsAndQuery_MatchSchema() + { + Assert.Equal("server_config", ServerConfigCollector.Instance.Name); + Assert.Equal("server_config", ServerConfigCollector.Instance.TargetTable); + Assert.Contains("sys.configurations", ServerConfigCollector.Instance.BuildQuery(CollectorTestContext.Make(s_deltas)).Text, StringComparison.Ordinal); + Assert.Equal( + new[] { "configuration_name", "value_configured", "value_in_use", "is_dynamic", "is_advanced" }, + ServerConfigCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray()); + } + + [Fact] + public void AppliesTo_SkipsAzureSqlDb_CollectsElsewhere() + { + /* sys.configurations is not exposed on Azure SQL DB (edition 5). Gate collapsed from Lite's + IsCollectorSupported into the shared AppliesTo so Darling skips it too (before, Darling threw + on connect). Managed Instance and on-prem have the view. */ + Assert.False(ServerConfigCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureSqlDb = true })); + Assert.True(ServerConfigCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureManagedInstance = true })); + Assert.True(ServerConfigCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAwsRds = true })); + Assert.True(ServerConfigCollector.Instance.AppliesTo(new CollectorTargetInfo())); + } + + [Fact] + public async Task ReadAndWrite_RoundTrip() + { + using var reader = new FakeCollectorDataReader( + new object[] { "max degree of parallelism", 8L, 8L, true, true }); + + var rows = await ServerConfigCollector.Instance.ReadAsync(reader, CollectorTestContext.Make(s_deltas), CancellationToken.None); + + var writer = new RecordingCollectorRowWriter(); + ServerConfigCollector.Instance.WritePayload(Assert.Single(rows), writer, CollectorTestContext.Make(s_deltas)); + Assert.Equal(new object?[] { "max degree of parallelism", 8L, 8L, true, true }, writer.Values); + } +} + +public sealed class TraceFlagsCollectorDefinitionTests +{ + private static readonly RecordingCollectorDeltaCalculator s_deltas = new(); + + [Fact] + public void NamesColumnsAndQuery_MatchSchema() + { + Assert.Equal("trace_flags", TraceFlagsCollector.Instance.Name); + var query = TraceFlagsCollector.Instance.BuildQuery(CollectorTestContext.Make(s_deltas)).Text; + Assert.Contains("DBCC TRACESTATUS(-1)", query, StringComparison.Ordinal); + Assert.Contains("#trace_flags", query, StringComparison.Ordinal); + Assert.Equal( + new[] { "trace_flag", "status", "is_global", "is_session" }, + TraceFlagsCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray()); + } + + [Fact] + public void AppliesTo_SkipsAzureSqlDb_CollectsElsewhere() + { + /* DBCC TRACESTATUS is unsupported on Azure SQL DB (edition 5). Gate collapsed from Lite's + IsCollectorSupported into the shared AppliesTo so Darling records an explicit skip rather than a + tolerated 0-row SUCCESS. Managed Instance and on-prem support DBCC. */ + Assert.False(TraceFlagsCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureSqlDb = true })); + Assert.True(TraceFlagsCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureManagedInstance = true })); + Assert.True(TraceFlagsCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAwsRds = true })); + Assert.True(TraceFlagsCollector.Instance.AppliesTo(new CollectorTargetInfo())); + } + + [Fact] + public async Task ReadAndWrite_RoundTrip() + { + using var reader = new FakeCollectorDataReader(new object[] { 8048, true, true, false }); + + var rows = await TraceFlagsCollector.Instance.ReadAsync(reader, CollectorTestContext.Make(s_deltas), CancellationToken.None); + + var writer = new RecordingCollectorRowWriter(); + TraceFlagsCollector.Instance.WritePayload(Assert.Single(rows), writer, CollectorTestContext.Make(s_deltas)); + Assert.Equal(new object?[] { 8048, true, true, false }, writer.Values); + } +} + +public sealed class DatabaseConfigCollectorDefinitionTests +{ + private static readonly RecordingCollectorDeltaCalculator s_deltas = new(); + + private static CollectorContext Ctx(int major, bool azure = false, string[]? excluded = null) => new() + { + ServerId = 42, + ServerName = "test-server", + CollectionTime = new DateTime(2026, 7, 1, 12, 0, 0, DateTimeKind.Utc), + Deltas = s_deltas, + Target = new CollectorTargetInfo { IsAzureSqlDb = azure, SqlMajorVersion = major }, + ExcludedDatabases = excluded ?? Array.Empty<string>(), + }; + + [Theory] + [InlineData(13, false, false, false)] /* 2016: base only */ + [InlineData(15, false, true, false)] /* 2019: +ADR/memopt */ + [InlineData(17, false, true, true)] /* 2025: +optimized locking */ + [InlineData(0, false, true, false)] /* unknown: assume newest 2019 gate, not 2025 */ + [InlineData(13, true, true, false)] /* Azure: 2019 gate regardless of version */ + public void BuildQuery_VersionGates_MatchOriginalRules(int major, bool azure, bool expect2019, bool expect2025) + { + var text = DatabaseConfigCollector.Instance.BuildQuery(Ctx(major, azure)).Text; + + Assert.Equal(expect2019, text.Contains("is_accelerated_database_recovery_on", StringComparison.Ordinal)); + Assert.Equal(expect2019, text.Contains("is_memory_optimized_enabled", StringComparison.Ordinal)); + Assert.Equal(expect2025, text.Contains("is_optimized_locking_on", StringComparison.Ordinal)); + Assert.Contains("FROM sys.databases AS d", text, StringComparison.Ordinal); + } + + [Fact] + public void BuildQuery_SplicesExclusionFilter() + { + var plan = DatabaseConfigCollector.Instance.BuildQuery(Ctx(16, excluded: new[] { "SO" })); + + Assert.Contains("AND d.name NOT IN (@excl_db_0)", plan.Text, StringComparison.Ordinal); + Assert.Equal("SO", Assert.Single(plan.Parameters).Value); + } + + [Fact] + public void PayloadColumns_MatchSchemaOrder_28Columns() + { + Assert.Equal(28, DatabaseConfigCollector.Instance.PayloadColumns.Count); + Assert.Equal("database_name", DatabaseConfigCollector.Instance.PayloadColumns[0].Name); + Assert.Equal("delayed_durability", DatabaseConfigCollector.Instance.PayloadColumns[24].Name); + Assert.Equal("is_optimized_locking_on", DatabaseConfigCollector.Instance.PayloadColumns[27].Name); + } + + [Fact] + public async Task ReadAsync_2016Shape_LeavesGatedFieldsNull_AndWritesNulls() + { + using var reader = new FakeCollectorDataReader(BaseRow()); + + var rows = await DatabaseConfigCollector.Instance.ReadAsync(reader, Ctx(13), CancellationToken.None); + + var row = Assert.Single(rows); + Assert.Equal("SO", row.DbName); + Assert.Equal(160, row.CompatLevel); + Assert.True(row.Rcsi); + Assert.Null(row.AcceleratedDatabaseRecovery); + Assert.Null(row.OptimizedLocking); + + var writer = new RecordingCollectorRowWriter(); + DatabaseConfigCollector.Instance.WritePayload(row, writer, Ctx(13)); + Assert.Equal(28, writer.Values.Count); + Assert.Null(writer.Values[25]); + Assert.Null(writer.Values[26]); + Assert.Null(writer.Values[27]); + } + + [Fact] + public async Task ReadAsync_2025Shape_ReadsAllGatedColumns() + { + var full = BaseRow().Concat(new object[] { true, false, true }).ToArray(); + using var reader = new FakeCollectorDataReader(full); + + var rows = await DatabaseConfigCollector.Instance.ReadAsync(reader, Ctx(17), CancellationToken.None); + + var row = Assert.Single(rows); + Assert.True(row.AcceleratedDatabaseRecovery); + Assert.False(row.MemoryOptimized); + Assert.True(row.OptimizedLocking); + + var writer = new RecordingCollectorRowWriter(); + DatabaseConfigCollector.Instance.WritePayload(row, writer, Ctx(17)); + Assert.Equal(true, writer.Values[25]); + Assert.Equal(false, writer.Values[26]); + Assert.Equal(true, writer.Values[27]); + } + + /// <summary>The 25 base (2016) columns in select order.</summary> + private static object[] BaseRow() => new object[] + { + "SO", "ONLINE", 160, "SQL_Latin1_General_CP1_CI_AS", "FULL", + false, false, false, true, true, false, true, "OFF", false, true, + false, false, false, true, false, false, "NOTHING", "CHECKSUM", 60, "DISABLED", + }; +} diff --git a/Lite.Tests/ConnectionEdgeDetectorTests.cs b/Lite.Tests/ConnectionEdgeDetectorTests.cs new file mode 100644 index 000000000..476cff649 --- /dev/null +++ b/Lite.Tests/ConnectionEdgeDetectorTests.cs @@ -0,0 +1,105 @@ +using System.Collections.Generic; +using System.Linq; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// <summary> +/// #1506: Lite's connection-lost / connection-restored alerts are EDGE-triggered. The property that matters +/// is that a transition alerts exactly once — a server that is unreachable for hours is polled hundreds of +/// times and must produce ONE "Server Unreachable", not one per poll. These pin that, plus the first-sighting +/// baseline (an app that starts with a server already down must not alert about a change it never observed — +/// the Dashboard's skip-first-check and Darling's Unknown-state rule). +/// </summary> +public class ConnectionEdgeDetectorTests +{ + [Fact] + public void FirstObservation_IsASilentBaseline() + { + Assert.Equal(ConnectionAlertEdge.None, ConnectionEdgeDetector.Detect(previous: null, current: false)); + Assert.Equal(ConnectionAlertEdge.None, ConnectionEdgeDetector.Detect(previous: null, current: true)); + } + + [Fact] + public void OnlineToOffline_IsLost() + => Assert.Equal(ConnectionAlertEdge.Lost, ConnectionEdgeDetector.Detect(previous: true, current: false)); + + [Fact] + public void OfflineToOnline_IsRestored() + => Assert.Equal(ConnectionAlertEdge.Restored, ConnectionEdgeDetector.Detect(previous: false, current: true)); + + [Fact] + public void SteadyStates_DoNotReFire() + { + Assert.Equal(ConnectionAlertEdge.None, ConnectionEdgeDetector.Detect(previous: false, current: false)); + Assert.Equal(ConnectionAlertEdge.None, ConnectionEdgeDetector.Detect(previous: true, current: true)); + } + + /// <summary> + /// The anti-nag guarantee, stated the way the bug would be reported: a server offline across an 8-hour + /// outage polled every minute must yield exactly ONE "Server Unreachable" — not 480. + /// </summary> + [Fact] + public void EightHourOutage_PolledEveryMinute_FiresExactlyOnce() + { + var observations = new List<bool> { true }; // online at first sighting + observations.AddRange(Enumerable.Repeat(false, 8 * 60)); // then offline for 480 polls + + var edges = ReplayPolls(observations); + + Assert.Equal(1, edges.Count(e => e == ConnectionAlertEdge.Lost)); + Assert.Empty(edges.Where(e => e == ConnectionAlertEdge.Restored)); + } + + /// <summary>An outage that ends: exactly one Lost and exactly one Restored, in that order.</summary> + [Fact] + public void OutageThenRecovery_FiresOneLostAndOneRestored() + { + var observations = new List<bool> { true, true }; + observations.AddRange(Enumerable.Repeat(false, 100)); // the outage + observations.AddRange(Enumerable.Repeat(true, 100)); // recovered, stays up + + var edges = ReplayPolls(observations).Where(e => e != ConnectionAlertEdge.None).ToList(); + + Assert.Equal(new[] { ConnectionAlertEdge.Lost, ConnectionAlertEdge.Restored }, edges); + } + + /// <summary> + /// The reporter's actual pattern (#1506): an Azure SQL firewall rule that stops matching when their public + /// IP rotates, breaking the connection 1-2x/day and recovering when re-authorized. Each break/recover cycle + /// is one alert and one restore — the webhook that re-runs their GitHub Action fires once per genuine break. + /// </summary> + [Fact] + public void RepeatedDailyBreaks_EachCycleAlertsOnce() + { + var observations = new List<bool> { true }; + + for (int day = 0; day < 5; day++) + { + observations.AddRange(Enumerable.Repeat(false, 30)); // connection broken, polled 30x + observations.AddRange(Enumerable.Repeat(true, 30)); // access restored + } + + var edges = ReplayPolls(observations); + + Assert.Equal(5, edges.Count(e => e == ConnectionAlertEdge.Lost)); + Assert.Equal(5, edges.Count(e => e == ConnectionAlertEdge.Restored)); + } + + /// <summary>Drives the detector exactly as MainWindow.CheckConnectionsAndNotify does: carry the previous + /// state forward across polls, starting from "never observed".</summary> + private static List<ConnectionAlertEdge> ReplayPolls(IEnumerable<bool> observations) + { + var edges = new List<ConnectionAlertEdge>(); + bool? previous = null; + + foreach (var isOnline in observations) + { + edges.Add(ConnectionEdgeDetector.Detect(previous, isOnline)); + previous = isOnline; + } + + return edges; + } +} diff --git a/Lite.Tests/CpuSchedulerMetricsTests.cs b/Lite.Tests/CpuSchedulerMetricsTests.cs new file mode 100644 index 000000000..cb0b297dc --- /dev/null +++ b/Lite.Tests/CpuSchedulerMetricsTests.cs @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Linq; +using PerformanceMonitor.Common; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Behavioral pin for the shared <see cref="CpuSchedulerMetrics"/> — the CPU Scheduler latest-snapshot metric +/// projection + install/47 <c>report.cpu_scheduler_pressure</c> banding that used to be duplicated as +/// <c>BuildCpuSchedulerMetrics</c> / <c>ClassifyCpuPressure</c> in Lite (<c>ServerTab.CpuScheduler.cs</c>) and +/// the Darling viewer (<c>ViewerServerTab.CpuScheduler.cs</c>). Drives the shared entry points directly over a +/// stub <see cref="ICpuSchedulerSnapshot"/> — proving the collapsed logic still lands warning flags on the +/// right rows, computes worker-utilization, degrades nullable columns to "N/A", and bands pressure. Pure — no +/// DuckDB, no WPF. +/// </summary> +public class CpuSchedulerMetricsTests +{ + /// <summary>A calm-by-default snapshot; each test overrides only the fields it exercises.</summary> + private sealed class StubSnapshot : ICpuSchedulerSnapshot + { + public int MaxWorkersCount { get; init; } = 512; + public int SchedulerCount { get; init; } = 8; + public int CpuCount { get; init; } = 8; + public int TotalRunnableTasksCount { get; init; } + public long TotalWorkQueueCount { get; init; } + public int TotalCurrentWorkersCount { get; init; } = 128; + public double AvgRunnableTasksCount { get; init; } + public int TotalActiveRequestCount { get; init; } + public int TotalQueuedRequestCount { get; init; } + public int TotalBlockedTaskCount { get; init; } + public long TotalActiveParallelThreadCount { get; init; } + public int? RunnableRequestCount { get; init; } = 3; + public int? TotalRequestCount { get; init; } = 20; + public double? RunnablePercent { get; init; } = 12.5; + public bool WorkerThreadExhaustionWarning { get; init; } + public bool RunnableTasksWarning { get; init; } + public bool BlockedTasksWarning { get; init; } + public bool QueuedRequestsWarning { get; init; } + public long TotalPhysicalMemoryKb { get; init; } = 67_108_864; // 64 GB + public long AvailablePhysicalMemoryKb { get; init; } = 8_388_608; // 8 GB + public string? SystemMemoryStateDesc { get; init; } = "Available physical memory is high"; + public bool PhysicalMemoryPressureWarning { get; init; } + public int TotalNodeCount { get; init; } = 2; + public int NodesOnlineCount { get; init; } = 2; + public int OfflineCpuCount { get; init; } + public bool OfflineCpuWarning { get; init; } + } + + [Fact] + public void BuildMetrics_NullSnapshot_YieldsEmptyGrid() + => Assert.Empty(CpuSchedulerMetrics.BuildMetrics(null)); + + [Fact] + public void BuildMetrics_LeadsWithPressureRows_AndProjectsScalars() + { + var rows = CpuSchedulerMetrics.BuildMetrics(new StubSnapshot + { + MaxWorkersCount = 400, + TotalCurrentWorkersCount = 100, + AvgRunnableTasksCount = 0.5, + RunnablePercent = 12.5, + RunnableTasksWarning = true, + }); + + /* The rolled-up pressure badge + recommendation lead the grid. */ + Assert.Equal("Pressure Level", rows[0].Metric); + Assert.Equal("Recommendation", rows[1].Metric); + + /* 100 / 400 * 100 = 25.0 %, and the two averaged/percentage columns format to F2. */ + Assert.Equal("25.0", rows.Single(r => r.Metric == "Worker Utilization %").Value); + Assert.Equal("0.50", rows.Single(r => r.Metric == "Avg Runnable / Scheduler").Value); + Assert.Equal("12.50", rows.Single(r => r.Metric == "Runnable %").Value); + + /* A collector warning flag highlights its own row; a flag that is off does not. */ + Assert.True(rows.Single(r => r.Metric == "Runnable Tasks").IsWarning); + Assert.False(rows.Single(r => r.Metric == "Blocked Tasks").IsWarning); + } + + [Fact] + public void BuildMetrics_NullableColumns_DegradeToNa() + { + var rows = CpuSchedulerMetrics.BuildMetrics(new StubSnapshot + { + RunnableRequestCount = null, + TotalRequestCount = null, + RunnablePercent = null, + }); + + Assert.Equal("N/A", rows.Single(r => r.Metric == "Runnable Requests").Value); + Assert.Equal("N/A", rows.Single(r => r.Metric == "Total Requests").Value); + Assert.Equal("N/A", rows.Single(r => r.Metric == "Runnable %").Value); + } + + [Theory] + [InlineData(60, "CRITICAL - High runnable task queue")] + [InlineData(25, "HIGH - Moderate runnable task queue")] + [InlineData(15, "MEDIUM - Some runnable tasks queued")] + [InlineData(5, "NORMAL")] + public void ClassifyCpuPressure_BandsOnRunnableTaskQueue(int runnable, string expectedLevel) + => Assert.Equal(expectedLevel, + CpuSchedulerMetrics.ClassifyCpuPressure(new StubSnapshot { TotalRunnableTasksCount = runnable }).Level); + + [Fact] + public void ClassifyCpuPressure_WorkerUtilizationOver90_IsHigh_WhenRunnableIsLow() + => Assert.Equal("HIGH - Worker thread exhaustion", + CpuSchedulerMetrics.ClassifyCpuPressure( + new StubSnapshot { MaxWorkersCount = 100, TotalCurrentWorkersCount = 95 }).Level); + + [Fact] + public void ClassifyCpuPressure_ZeroMaxWorkers_DoesNotDivideByZero_StaysNormal() + => Assert.Equal("NORMAL", + CpuSchedulerMetrics.ClassifyCpuPressure( + new StubSnapshot { MaxWorkersCount = 0, TotalCurrentWorkersCount = 0 }).Level); +} diff --git a/Lite.Tests/CpuSchedulerPlanCacheRoundTripTests.cs b/Lite.Tests/CpuSchedulerPlanCacheRoundTripTests.cs new file mode 100644 index 000000000..3612b0508 --- /dev/null +++ b/Lite.Tests/CpuSchedulerPlanCacheRoundTripTests.cs @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.IO; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using PerformanceMonitorLite.Database; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Real-DuckDB round-trip pins for the #1262 cpu_scheduler_stats + plan_cache_stats collectors. The +/// collector-definition tests pin PayloadColumns/WritePayload against a recording writer, but nothing +/// else proves the Lite DuckDB table's physical column layout stays aligned with WritePayload — the +/// appender writes BY POSITION, so a table whose column count/order drifts from (4 prefix + +/// WritePayload) fails at EndRow(). This exercises the exact append path +/// RemoteCollectorService.RunCollectorDefinitionAsync uses (CreateAppender → prefix → WritePayload → +/// EndRow) against a freshly initialized schema, then reads representative columns back. It also +/// specifically proves the DuckDB appender round-trips the DECIMAL(38,2) averages/percent these two +/// tables introduce (new to Lite's schema, HUGEINT-backed) plus the BOOLEAN pressure warnings and a +/// nullable column left NULL. +/// </summary> +public sealed class CpuSchedulerPlanCacheRoundTripTests : IDisposable +{ + private readonly string _tempDir; + + public CpuSchedulerPlanCacheRoundTripTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "LiteCpuPcRt_" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + try + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, recursive: true); + } + } + catch + { + /* Best-effort cleanup */ + } + } + + [Fact] + public async Task CpuSchedulerStats_AppendRoundTrips_Decimal38_Boolean_AndNullableColumns() + { + /* runnable_request_count / total_request_count / runnable_percent left NULL (empty-request + snapshot) to prove the nullable appends round-trip too. */ + var row = new CpuSchedulerStatsCollector.Row(512, 8, 8, 3, 0, 40, 1.50m, 5, 0, 0, 0, null, null, null, + true, false, false, false, 16777216, 8388608, "Available physical memory is high", + false, 1, 1, 0, false); + + using var connection = await AppendOneRowAsync(CpuSchedulerStatsCollector.Instance, row); + + Assert.Equal(1.50m, (decimal)await ScalarAsync(connection, "cpu_scheduler_stats", "avg_runnable_tasks_count")); + Assert.Equal(0L, (long)await ScalarAsync(connection, "cpu_scheduler_stats", "total_work_queue_count")); + Assert.Equal(true, (bool)await ScalarAsync(connection, "cpu_scheduler_stats", "worker_thread_exhaustion_warning")); + Assert.Equal("Available physical memory is high", (string)await ScalarAsync(connection, "cpu_scheduler_stats", "system_memory_state_desc")); + Assert.Equal(DBNull.Value, await ScalarAsync(connection, "cpu_scheduler_stats", "runnable_percent")); + Assert.Equal(DBNull.Value, await ScalarAsync(connection, "cpu_scheduler_stats", "runnable_request_count")); + } + + [Fact] + public async Task PlanCacheStats_AppendRoundTrips_Decimal38_AndNullableTimestamp() + { + var row = new PlanCacheStatsCollector.Row("Compiled Plan", "Adhoc", 1000, 50, 800, 40, 200, 10, 42.75m, 51, null); + + using var connection = await AppendOneRowAsync(PlanCacheStatsCollector.Instance, row); + + Assert.Equal(42.75m, (decimal)await ScalarAsync(connection, "plan_cache_stats", "avg_use_count")); + Assert.Equal("Compiled Plan", (string)await ScalarAsync(connection, "plan_cache_stats", "cacheobjtype")); + Assert.Equal(1000, (int)await ScalarAsync(connection, "plan_cache_stats", "total_plans")); + Assert.Equal(DBNull.Value, await ScalarAsync(connection, "plan_cache_stats", "oldest_plan_create_time")); + } + + /// <summary> + /// Appends one row through the exact runner path (prefix columns + WritePayload + EndRow) against a + /// freshly initialized DuckDB and returns the open connection. EndRow throwing here would mean the + /// Schema.cs table column count/order drifted from WritePayload; an appender that rejected the + /// DECIMAL(38,2) value would throw at AppendValue. + /// </summary> + private async Task<DuckDBConnection> AppendOneRowAsync<TRow>(ICollectorDefinition<TRow> definition, TRow row) + { + var dbPath = Path.Combine(_tempDir, $"{definition.TargetTable}.duckdb"); + await new DuckDbInitializer(dbPath).InitializeAsync(); + + var context = CollectorTestContext.Make(new RecordingCollectorDeltaCalculator()); + + var connection = new DuckDBConnection($"Data Source={dbPath}"); + await connection.OpenAsync(TestContext.Current.CancellationToken); + + using (var appender = connection.CreateAppender(definition.TargetTable)) + { + var appenderRow = appender.CreateRow(); + if (definition.IncludesCollectionId) + { + appenderRow.AppendValue(1L); + } + appenderRow.AppendValue(context.CollectionTime).AppendValue(42).AppendValue("s"); + + var writer = new AppenderCollectorRowWriter { CurrentRow = appenderRow }; + definition.WritePayload(row, writer, context); + appenderRow.EndRow(); /* throws if the appended column count != table column count */ + } + + return connection; + } + + private static async Task<object> ScalarAsync(DuckDBConnection connection, string table, string column) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = $"SELECT {column} FROM {table} LIMIT 1"; + return (await cmd.ExecuteScalarAsync(TestContext.Current.CancellationToken))!; + } +} diff --git a/Lite.Tests/CpuSchedulerStatsCollectorDefinitionTests.cs b/Lite.Tests/CpuSchedulerStatsCollectorDefinitionTests.cs new file mode 100644 index 000000000..75f5989b8 --- /dev/null +++ b/Lite.Tests/CpuSchedulerStatsCollectorDefinitionTests.cs @@ -0,0 +1,330 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Pins the cross-SKU parity contract of the shared cpu_scheduler_stats collector definition (the +/// Dashboard-parity port of install/17_collect_cpu_scheduler_stats.sql): verbatim query text, the +/// Azure SQL DB skip (sys.dm_os_sys_memory / connection-scoped sys.dm_exec_requests), payload column +/// order, ReadAsync mapping (including the nullable request columns), and the point-in-time no-delta +/// WritePayload. An intentional change to any of these must consciously update these tests. +/// </summary> +public sealed class CpuSchedulerStatsCollectorDefinitionTests +{ + private const string ExpectedQuery = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + max_workers_count = osi.max_workers_count, + scheduler_count = osi.scheduler_count, + cpu_count = osi.cpu_count, + total_runnable_tasks_count = SUM(dos.runnable_tasks_count), + total_work_queue_count = SUM(dos.work_queue_count), + total_current_workers_count = SUM(dos.current_workers_count), + avg_runnable_tasks_count = CONVERT(decimal(38,2), AVG(CONVERT(decimal(38,2), dos.runnable_tasks_count))), + total_active_request_count = MAX(wg.active_request_count), + total_queued_request_count = MAX(wg.queued_request_count), + total_blocked_task_count = MAX(wg.blocked_task_count), + total_active_parallel_thread_count = MAX(wg.active_parallel_thread_count), + runnable_request_count = r.runnable_count, + total_request_count = CONVERT(integer, r.total_count), + runnable_percent = r.runnable_pct, + worker_thread_exhaustion_warning = + CONVERT + ( + bit, + CASE + WHEN SUM(dos.current_workers_count) >= (osi.max_workers_count * 0.90) + THEN 1 + ELSE 0 + END + ), + runnable_tasks_warning = + CONVERT + ( + bit, + CASE + WHEN SUM(dos.runnable_tasks_count) >= osi.cpu_count + THEN 1 + ELSE 0 + END + ), + blocked_tasks_warning = + CONVERT + ( + bit, + CASE + WHEN MAX(wg.blocked_task_count) >= 10 + THEN 1 + ELSE 0 + END + ), + queued_requests_warning = + CONVERT + ( + bit, + CASE + WHEN MAX(wg.queued_request_count) >= osi.cpu_count + THEN 1 + ELSE 0 + END + ), + total_physical_memory_kb = osm.total_physical_memory_kb, + available_physical_memory_kb = osm.available_physical_memory_kb, + system_memory_state_desc = osm.system_memory_state_desc, + physical_memory_pressure_warning = + CONVERT + ( + bit, + CASE + WHEN osm.available_physical_memory_kb < (osm.total_physical_memory_kb * 0.10) + THEN 1 + ELSE 0 + END + ), + total_node_count = CONVERT(integer, nodes.total_nodes), + nodes_online_count = nodes.online_nodes, + offline_cpu_count = nodes.offline_cpus, + offline_cpu_warning = + CONVERT + ( + bit, + CASE + WHEN + ( + SELECT COUNT_BIG(*) + FROM sys.dm_os_schedulers AS dos_offline + WHERE dos_offline.is_online = 0 + ) > 0 + THEN 1 + ELSE 0 + END + ) +FROM sys.dm_os_schedulers AS dos +CROSS JOIN sys.dm_os_sys_info AS osi +CROSS JOIN sys.dm_os_sys_memory AS osm +CROSS JOIN +( + SELECT + total_nodes = COUNT_BIG(*), + online_nodes = + SUM + ( + CASE + WHEN n.node_state_desc = N'ONLINE' + THEN 1 + ELSE 0 + END + ), + offline_cpus = + SUM + ( + CASE + WHEN n.node_state_desc <> N'ONLINE' + THEN 1 + ELSE 0 + END + ) + FROM sys.dm_os_nodes AS n + WHERE n.node_id <> 32767 /*Exclude DAC node*/ +) AS nodes +CROSS JOIN +( + SELECT + active_request_count = SUM(wg.active_request_count), + queued_request_count = SUM(wg.queued_request_count), + blocked_task_count = SUM(wg.blocked_task_count), + active_parallel_thread_count = SUM(wg.active_parallel_thread_count) + FROM sys.dm_resource_governor_workload_groups AS wg +) AS wg +OUTER APPLY +( + SELECT + total_count = COUNT_BIG(*), + runnable_count = + SUM + ( + CASE + WHEN der.status = N'runnable' + THEN 1 + ELSE 0 + END + ), + runnable_pct = + CONVERT + ( + decimal(38,2), + ( + SUM + ( + CASE + WHEN der.status = N'runnable' + THEN 1.0 + ELSE 0.0 + END + ) / + NULLIF(CONVERT(decimal(38,2), COUNT_BIG(*)), 0) + ) * 100.0 + ) + FROM sys.dm_exec_requests AS der + WHERE der.session_id > 50 + AND der.session_id <> @@SPID + AND der.status NOT IN (N'background', N'sleeping') +) AS r +WHERE dos.status = N'VISIBLE ONLINE' +GROUP BY + osi.max_workers_count, + osi.scheduler_count, + osi.cpu_count, + r.runnable_count, + r.total_count, + r.runnable_pct, + osm.total_physical_memory_kb, + osm.available_physical_memory_kb, + osm.system_memory_state_desc, + nodes.total_nodes, + nodes.online_nodes, + nodes.offline_cpus +OPTION(RECOMPILE);"; + + [Fact] + public void Query_IsTheVerbatimParityContract() + { + var context = CollectorTestContext.Make(new RecordingCollectorDeltaCalculator()); + Assert.Equal(ExpectedQuery, CpuSchedulerStatsCollector.Instance.BuildQuery(context).Text); + Assert.Empty(CpuSchedulerStatsCollector.Instance.BuildQuery(context).Parameters); + Assert.Null(CpuSchedulerStatsCollector.Instance.WatermarkColumn); + Assert.Equal("cpu_scheduler_stats", CpuSchedulerStatsCollector.Instance.Name); + Assert.Equal("cpu_scheduler_stats", CpuSchedulerStatsCollector.Instance.TargetTable); + } + + [Fact] + public void AppliesTo_SkipsAzureSqlDb_ButCollectsOnPremAndManagedInstance() + { + /* sys.dm_os_sys_memory is not exposed on Azure SQL DB and sys.dm_exec_requests is + connection-scoped there — skip the cycle, mirroring memory_pressure_events. */ + Assert.False(CpuSchedulerStatsCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureSqlDb = true })); + Assert.True(CpuSchedulerStatsCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureManagedInstance = true })); + Assert.True(CpuSchedulerStatsCollector.Instance.AppliesTo(new CollectorTargetInfo())); + } + + [Fact] + public void PayloadColumns_AreInAppendOrder() + { + var names = CpuSchedulerStatsCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray(); + + Assert.Equal( + new[] + { + "max_workers_count", + "scheduler_count", + "cpu_count", + "total_runnable_tasks_count", + "total_work_queue_count", + "total_current_workers_count", + "avg_runnable_tasks_count", + "total_active_request_count", + "total_queued_request_count", + "total_blocked_task_count", + "total_active_parallel_thread_count", + "runnable_request_count", + "total_request_count", + "runnable_percent", + "worker_thread_exhaustion_warning", + "runnable_tasks_warning", + "blocked_tasks_warning", + "queued_requests_warning", + "total_physical_memory_kb", + "available_physical_memory_kb", + "system_memory_state_desc", + "physical_memory_pressure_warning", + "total_node_count", + "nodes_online_count", + "offline_cpu_count", + "offline_cpu_warning", + }, + names); + + /* The two decimal(38,2) averages/percent carry explicit precision so Darling's numeric(38,2) + matches the Dashboard column; the four warning flags are booleans. */ + Assert.Equal(CollectorColumnType.Decimal, CpuSchedulerStatsCollector.Instance.PayloadColumns.Single(c => c.Name == "avg_runnable_tasks_count").Type); + Assert.Equal(38, CpuSchedulerStatsCollector.Instance.PayloadColumns.Single(c => c.Name == "runnable_percent").Precision); + Assert.Equal(CollectorColumnType.Boolean, CpuSchedulerStatsCollector.Instance.PayloadColumns.Single(c => c.Name == "offline_cpu_warning").Type); + } + + [Fact] + public async Task ReadAsync_MapsColumns_IncludingNullableRequestColumns() + { + using var reader = new FakeCollectorDataReader( + /* A healthy row: request columns populated, no warnings. */ + new object[] + { + 512, 8, 8, 3, 0L, 40, 1.50m, 5, 0, 0, 0L, 2, 10, 20.00m, + false, false, false, false, 16777216L, 8388608L, "Available physical memory is high", + false, 1, 1, 0, false, + }, + /* No qualifying requests: runnable_count / runnable_pct come back NULL (SUM/ratio over an + empty set), and a warning is set. */ + new object[] + { + 512, 8, 8, 9, 0L, 60, 9.00m, 0, 0, 0, 0L, DBNull.Value, DBNull.Value, DBNull.Value, + true, true, false, false, 16777216L, 512000L, "Physical memory low", + true, 2, 1, 1, true, + }); + + var context = CollectorTestContext.Make(new RecordingCollectorDeltaCalculator()); + + var rows = await CpuSchedulerStatsCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + + Assert.Equal(2, rows.Count); + Assert.Equal( + new CpuSchedulerStatsCollector.Row(512, 8, 8, 3, 0, 40, 1.50m, 5, 0, 0, 0, 2, 10, 20.00m, + false, false, false, false, 16777216, 8388608, "Available physical memory is high", + false, 1, 1, 0, false), + rows[0]); + Assert.Equal( + new CpuSchedulerStatsCollector.Row(512, 8, 8, 9, 0, 60, 9.00m, 0, 0, 0, 0, null, null, null, + true, true, false, false, 16777216, 512000, "Physical memory low", + true, 2, 1, 1, true), + rows[1]); + } + + [Fact] + public void WritePayload_EmitsPayloadOrder_PointInTime_NoDeltas() + { + var deltas = new RecordingCollectorDeltaCalculator(); + var context = CollectorTestContext.Make(deltas); + var writer = new RecordingCollectorRowWriter(); + var row = new CpuSchedulerStatsCollector.Row(512, 8, 8, 3, 0, 40, 1.50m, 5, 0, 0, 0, 2, 10, 20.00m, + false, false, false, false, 16777216, 8388608, "Available physical memory is high", + false, 1, 1, 0, false); + + CpuSchedulerStatsCollector.Instance.WritePayload(row, writer, context); + + Assert.Equal( + new object?[] + { + 512, 8, 8, 3, 0L, 40, 1.50m, 5, 0, 0, 0L, 2, 10, 20.00m, + false, false, false, false, 16777216L, 8388608L, "Available physical memory is high", + false, 1, 1, 0, false, + }, + writer.Values); + + /* Point-in-time snapshot — no delta calculator interaction at all. */ + Assert.Empty(deltas.Calls); + } +} diff --git a/Lite.Tests/CpuUtilizationCollectorDefinitionTests.cs b/Lite.Tests/CpuUtilizationCollectorDefinitionTests.cs new file mode 100644 index 000000000..0d8a26209 --- /dev/null +++ b/Lite.Tests/CpuUtilizationCollectorDefinitionTests.cs @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Pins the parity contract of the extracted cpu_utilization definition: the three-way query +/// selection (ring buffer vs. Azure vs. Azure+watermark with its bound parameter), the +/// client-side ring-buffer dedup, and the Linux NULL other-process rule (#1048). +/// </summary> +public sealed class CpuUtilizationCollectorDefinitionTests +{ + private static readonly RecordingCollectorDeltaCalculator s_deltas = new(); + + [Fact] + public void BuildQuery_RingBuffer_ForNonAzure_EvenWithWatermark() + { + var plan = CpuUtilizationCollector.Instance.BuildQuery( + CollectorTestContext.Make(s_deltas, isAzureSqlDb: false, watermark: DateTime.UtcNow)); + + Assert.Contains("RING_BUFFER_SCHEDULER_MONITOR", plan.Text, StringComparison.Ordinal); + Assert.Contains("sys.dm_os_host_info", plan.Text, StringComparison.Ordinal); + Assert.Empty(plan.Parameters); + } + + [Fact] + public void BuildQuery_Azure_NoWatermark_UsesTop60() + { + var plan = CpuUtilizationCollector.Instance.BuildQuery( + CollectorTestContext.Make(s_deltas, isAzureSqlDb: true)); + + Assert.Contains("sys.dm_db_resource_stats", plan.Text, StringComparison.Ordinal); + Assert.Contains("TOP (60)", plan.Text, StringComparison.Ordinal); + Assert.Empty(plan.Parameters); + } + + [Fact] + public void BuildQuery_Azure_WithWatermark_FiltersServerSide_AndBindsParameter() + { + var watermark = new DateTime(2026, 7, 1, 11, 55, 0, DateTimeKind.Utc); + var plan = CpuUtilizationCollector.Instance.BuildQuery( + CollectorTestContext.Make(s_deltas, isAzureSqlDb: true, watermark: watermark)); + + Assert.Contains("WHERE drs.end_time > @last_sample_time", plan.Text, StringComparison.Ordinal); + var parameter = Assert.Single(plan.Parameters); + Assert.Equal("@last_sample_time", parameter.Name); + Assert.Equal(watermark, parameter.Value); + Assert.Equal(CollectorParameterType.DateTime2, parameter.Type); + } + + [Fact] + public void WatermarkColumn_IsSampleTime_AndNamesMatchDispatchAndSchema() + { + Assert.Equal("sample_time", CpuUtilizationCollector.Instance.WatermarkColumn); + Assert.Equal("cpu_utilization", CpuUtilizationCollector.Instance.Name); + Assert.Equal("cpu_utilization_stats", CpuUtilizationCollector.Instance.TargetTable); + Assert.Equal( + new[] { "sample_time", "sqlserver_cpu_utilization", "other_process_cpu_utilization" }, + CpuUtilizationCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray()); + } + + [Fact] + public async Task ReadAsync_RingBuffer_DedupsAtOrBelowWatermark() + { + var watermark = new DateTime(2026, 7, 1, 12, 0, 0, DateTimeKind.Utc); + using var reader = new FakeCollectorDataReader( + new object[] { watermark.AddSeconds(60), 50, 10 }, + new object[] { watermark, 40, 5 }, + new object[] { watermark.AddSeconds(-60), 30, 2 }); + + var rows = await CpuUtilizationCollector.Instance.ReadAsync( + reader, + CollectorTestContext.Make(s_deltas, isAzureSqlDb: false, watermark: watermark), + CancellationToken.None); + + var row = Assert.Single(rows); + Assert.Equal(new CpuUtilizationCollector.Row(watermark.AddSeconds(60), 50, 10), row); + } + + [Fact] + public async Task ReadAsync_Azure_DoesNotDedupClientSide() + { + var watermark = new DateTime(2026, 7, 1, 12, 0, 0, DateTimeKind.Utc); + using var reader = new FakeCollectorDataReader( + new object[] { watermark, 40, 0 }); + + var rows = await CpuUtilizationCollector.Instance.ReadAsync( + reader, + CollectorTestContext.Make(s_deltas, isAzureSqlDb: true, watermark: watermark), + CancellationToken.None); + + Assert.Single(rows); + } + + [Fact] + public async Task ReadAsync_NullOtherProcess_StaysNull_LinuxRule() + { + var sample = new DateTime(2026, 7, 1, 12, 0, 0, DateTimeKind.Utc); + using var reader = new FakeCollectorDataReader( + new object[] { sample, 55, DBNull.Value }); + + var rows = await CpuUtilizationCollector.Instance.ReadAsync( + reader, CollectorTestContext.Make(s_deltas), CancellationToken.None); + + var row = Assert.Single(rows); + Assert.Null(row.OtherProcessCpuUtilization); + + var writer = new RecordingCollectorRowWriter(); + CpuUtilizationCollector.Instance.WritePayload(row, writer, CollectorTestContext.Make(s_deltas)); + Assert.Equal(new object?[] { sample, 55, null }, writer.Values); + } +} diff --git a/Lite.Tests/CrossAppMcpToolInventoryPinTests.cs b/Lite.Tests/CrossAppMcpToolInventoryPinTests.cs new file mode 100644 index 000000000..f1ee3ee58 --- /dev/null +++ b/Lite.Tests/CrossAppMcpToolInventoryPinTests.cs @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// PIN B (parity board §05 D3, round 2): the MCP tool-INVENTORY pin. Nothing pins Lite's and Darling's MCP +/// tool sets together — <c>McpSchemaCompatTests</c> guards the Gemini schema SHAPE, not the tool set — so the +/// 55-vs-73 drift is invisible to CI. This enumerates every <c>[McpServerTool(Name="…")]</c> in both apps' MCP +/// servers and asserts Darling's set is a SUPERSET of Lite's, with two allow-lists: +/// <list type="bullet"> +/// <item><see cref="KnownLiteMissingMcpTools"/> — a RATCHET of the Darling-only tools Lite hasn't ported yet; +/// it only ever shrinks, and a NEW Darling tool with no Lite twin must be either ported or added here;</item> +/// <item><see cref="KnownNamingDrift"/> — the one same-capability / different-name pair +/// (Lite <c>get_blocked_process_reports</c> <-> Darling <c>get_blocking</c>).</item> +/// </list> +/// A NEW tool on either side with no twin fails the pin; porting a ratchet tool to Lite forces its removal. +/// +/// <para> +/// Tool names are read from source (the regular <c>[McpServerTool(Name = "…")]</c> attribute) rather than by +/// reflecting the <c>PerformanceMonitor.Darling.Service</c> assembly — this CI-run Lite test project does not +/// (and should not) reference the headless-service / Npgsql stack. <see cref="ExtractToolNames"/> asserts every +/// method-level <c>[McpServerTool(</c> carries an explicit <c>Name =</c>, so the source scan cannot silently +/// miss a tool that relied on framework-derived naming. +/// </para> +/// </summary> +public sealed class CrossAppMcpToolInventoryPinTests +{ + private const string LiteMcpDir = "Lite/Mcp"; + private const string DarlingMcpDir = "Darling/PerformanceMonitor.Darling.Service/Mcp"; + + /* Same capability, different tool name — flagged for a later naming-unification pass (parity board §05 D, + Tier 2). Left AS drift here (not renamed) because renaming a shipped MCP tool is a compatibility change; + this pin documents the pair so it is not mistaken for a missing tool and cannot silently multiply. */ + private static readonly (string Lite, string Darling)[] KnownNamingDrift = + { + ("get_blocked_process_reports", "get_blocking"), + }; + + // Lite-missing MCP tools -- parity board Tier 2; the ratchet only shrinks. All 18 formerly-Darling-only + // tools were ported to Lite (latch/spinlock, plan-cache bloat + cpu-scheduler pressure, resource + // semaphore, default-trace events, daily summary, the three config-change tools, and the eight + // system_health parser tools). A NEW Darling-only tool must be either ported to Lite or added here. + private static readonly HashSet<string> KnownLiteMissingMcpTools = new(StringComparer.Ordinal) + { + /* #1562: the pre-banded fleet-overview read born from the web dashboard's DarlingFleetReader. + Lite twin = a DuckDB fleet reader over the SAME shared ServerHealthClassifier (Common) — tracked + in #1573 alongside unifying Lite's own card banding onto that classifier; port it, then remove + this entry (the ratchet only shrinks). */ + "get_fleet_overview", + + /* #1600 + #1602: the Custom Views (CV2) tools — the Darling MCP server's write surface (the six that + CRUD the user-authored dashboards/notebooks in the central Postgres store's config.custom_views and + run back a composed panel's data), plus describe_custom_view_catalog (#1602, read-only), which serves + the compose vocabulary those authoring tools draw from so an MCP client composes a valid view without + guessing. These are Darling-ONLY by architecture, not a "not ported yet" item: CV2 + config.custom_views + are the central-store web feature, and Lite (a single-instance WPF app over local DuckDB) has neither a + web composer nor that table, so there is no Lite twin to port. */ + "list_custom_views", + "get_custom_view", + "validate_custom_view", + "create_custom_view", + "update_custom_view", + "delete_custom_view", + "run_custom_view_panel", + "describe_custom_view_catalog", + + /* Darling MCP alert-tuning write tools — the write half of the alerts slice (the READ half, + get_alert_history / get_alert_settings / get_mute_rules, IS shared with Lite). These write the + central Postgres alert store: update_alert_settings partial-updates config.config_alert_settings, + create_mute_rule / delete_mute_rule CRUD config.config_mute_rules. Darling-ONLY by architecture, + not "not ported yet": Lite is a single-instance WPF app over local DuckDB with no central, + service-honored alert store the same way, so there is no Lite twin to port (same reasoning as the + Custom Views tools above). */ + "update_alert_settings", + "create_mute_rule", + "delete_mute_rule", + + /* Darling MCP server-onboarding write tools — add/remove the monitored servers in the CENTRAL store the + whole fleet shares (config.config_monitored_servers). add_servers bulk-onboards (validate + in-process + probe + case-folded dedupe + DPAPI-encrypt + INSERT); remove_server DELETEs by the shared resolver. + Darling-ONLY by architecture, not "not ported yet": Lite is a single-instance WPF app that monitors + servers from its own local config + DuckDB, with no central service-honored monitored-server store, so + there is no Lite twin to port (same reasoning as the Custom Views + alert-tuning tools above). */ + "add_servers", + "remove_server", + }; + + [Fact] + public void DarlingMcpTools_AreASupersetOfLite_ExceptKnownNamingDrift() + { + var lite = ExtractToolNames(LiteMcpDir); + var darling = ExtractToolNames(DarlingMcpDir); + + /* Non-vacuous floor: a broken scan returning a handful of tools must not sail through. */ + Assert.True(lite.Count >= 40, $"Lite MCP tool scan returned only {lite.Count} tools — the scan is likely broken"); + Assert.True(darling.Count >= 40, $"Darling MCP tool scan returned only {darling.Count} tools — the scan is likely broken"); + + var driftLite = KnownNamingDrift.Select(d => d.Lite).ToHashSet(StringComparer.Ordinal); + + /* Every Lite tool (minus the registered naming-drift Lite names) must exist in Darling. */ + var missingFromDarling = lite + .Where(t => !driftLite.Contains(t) && !darling.Contains(t)) + .OrderBy(t => t, StringComparer.Ordinal) + .ToList(); + Assert.True(missingFromDarling.Count == 0, + "Darling is missing Lite MCP tool(s) with no registered naming-drift twin: [" + + string.Join(", ", missingFromDarling) + + "]. Add the tool to Darling, or register a KnownNamingDrift pair."); + + /* The naming-drift allow-list must still describe reality — both names present, and it really is a + rename (not a resolved duplicate) — so a fix or a fresh collision forces this list to be updated. */ + foreach (var (liteName, darlingName) in KnownNamingDrift) + { + Assert.True(lite.Contains(liteName), + $"KnownNamingDrift lists Lite tool '{liteName}' that no longer exists — remove the stale pair."); + Assert.True(darling.Contains(darlingName), + $"KnownNamingDrift lists Darling tool '{darlingName}' that no longer exists — remove the stale pair."); + Assert.False(darling.Contains(liteName), + $"Darling now also exposes '{liteName}', so the naming drift is resolved — remove the pair."); + } + } + + [Fact] + public void LiteMissingMcpTools_MatchTheRatchetAllowList() + { + var lite = ExtractToolNames(LiteMcpDir); + var darling = ExtractToolNames(DarlingMcpDir); + + var driftDarling = KnownNamingDrift.Select(d => d.Darling).ToHashSet(StringComparer.Ordinal); + + /* Darling tools with no Lite twin, excluding the naming-drift Darling name (whose capability Lite HAS, + under a different name). This is the shrinking to-do list. */ + var darlingOnly = darling + .Where(t => !lite.Contains(t) && !driftDarling.Contains(t)) + .ToHashSet(StringComparer.Ordinal); + + Assert.True(KnownLiteMissingMcpTools.SetEquals(darlingOnly), + "Darling-only MCP tools drifted from the ratchet allow-list.\n" + + " Added a Darling tool with no Lite twin? Port it to Lite, or add it to KnownLiteMissingMcpTools.\n" + + " Ported a listed tool to Lite? Remove it from KnownLiteMissingMcpTools (the ratchet only shrinks).\n" + + $" Present in Darling but NOT in the allow-list: [{Format(darlingOnly.Except(KnownLiteMissingMcpTools))}]\n" + + $" Listed but no longer Darling-only: [{Format(KnownLiteMissingMcpTools.Except(darlingOnly))}]"); + } + + private static string Format(IEnumerable<string> names) => + string.Join(", ", names.OrderBy(n => n, StringComparer.Ordinal)); + + /// <summary> + /// Reads every distinct MCP tool name from the <c>*.cs</c> files under a repo-relative <c>Mcp</c> directory. + /// Asserts each method-level <c>[McpServerTool(</c> carries an explicit <c>Name = "…"</c> (the convention + /// the scan relies on; <c>[McpServerToolType]</c> is class-level and has no <c>(</c>, so it is not matched) + /// and that no tool name is defined twice. + /// </summary> + private static HashSet<string> ExtractToolNames(string relativeDir) + { + var names = new HashSet<string>(StringComparer.Ordinal); + + foreach (var file in ParitySource.EnumerateCsFiles(relativeDir)) + { + var src = File.ReadAllText(file); + + var toolAttrs = Regex.Matches(src, @"\[McpServerTool\("); + var namedAttrs = Regex.Matches(src, @"\[McpServerTool\(Name\s*=\s*""([a-z0-9_]+)"""); + Assert.True(toolAttrs.Count == namedAttrs.Count, + $"{Path.GetFileName(file)}: {toolAttrs.Count} [McpServerTool(] attribute(s) but {namedAttrs.Count} " + + "carry an explicit Name = \"…\". Every MCP tool must name itself explicitly so the parity scan sees it."); + + foreach (Match m in namedAttrs) + { + var name = m.Groups[1].Value; + Assert.True(names.Add(name), $"duplicate MCP tool name '{name}' under {relativeDir}"); + } + } + + return names; + } +} diff --git a/Lite.Tests/CrossAppPresetValuePinTests.cs b/Lite.Tests/CrossAppPresetValuePinTests.cs new file mode 100644 index 000000000..135155811 --- /dev/null +++ b/Lite.Tests/CrossAppPresetValuePinTests.cs @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// PIN A (parity board §05 A2, round 2): the CROSS-APP schedule-preset VALUE pin. The two per-app preset pins +/// that exist today — Lite's <see cref="SharedCollectorDefaultsPinTests.SchedulePresets_CoverTheSamePinnedCollectorSet"/> +/// and Darling's <c>ViewerControlPlaneStage3bTests.Presets_CoverTheSamePinnedCollectorSet</c> — each check +/// MEMBERSHIP against a LOCAL hardcoded 27-collector set; neither reads the OTHER app's table and neither pins +/// the interval VALUES. So a value edit on one side only (e.g. Darling Aggressive <c>plan_cache_stats</c> 2->3) +/// ships green on both. This closes that hole: per preset (Aggressive / Balanced / Low-Impact) it asserts +/// Lite's <see cref="ScheduleManager.s_presets"/> and Darling's <c>CollectorSchedulePresets.Presets</c> are +/// IDENTICAL — same collector keys AND same minute values. Byte-identical today, so green and guarding forward. +/// +/// <para> +/// Lite's table is the real in-memory object (internal, visible to Lite.Tests). Darling's lives in the +/// <c>PerformanceMonitor.Darling.Viewer</c> WPF assembly, which this CI-run project deliberately does not +/// reference; it is read from source (see <see cref="ParitySource"/>). The source parser is self-validated by +/// <see cref="PresetSourceParser_FaithfullyReproducesLiteRealObject"/> — it must reproduce Lite's real +/// <c>s_presets</c> exactly from <c>ScheduleManager.cs</c>, whose inner-entry format is identical to Darling's — +/// so a parser bug fails that self-test rather than silently passing the cross-app comparison. +/// </para> +/// </summary> +public sealed class CrossAppPresetValuePinTests +{ + private const string LiteSchedulePath = "Lite/Services/ScheduleManager.cs"; + private const string DarlingPresetsPath = "Darling/PerformanceMonitor.Darling.Viewer/CollectorSchedulePresets.cs"; + + private static readonly string[] PresetNames = { "Aggressive", "Balanced", "Low-Impact" }; + + /* The pinned membership set both apps' presets must cover — the same set each app's own membership pin + asserts. Kept here so a collector added to the presets without updating this pin fails loudly. */ + private static readonly HashSet<string> ExpectedCollectors = new(StringComparer.OrdinalIgnoreCase) + { + "wait_stats", "latch_stats", "spinlock_stats", "cpu_scheduler_stats", "plan_cache_stats", + "query_stats", "procedure_stats", "query_store", "query_snapshots", "cpu_utilization", + "file_io_stats", "memory_stats", "memory_clerks", "memory_pressure_events", "tempdb_stats", + "perfmon_stats", "deadlocks", "memory_grant_stats", "waiting_tasks", "dmv_blocking_snapshot", + "blocked_process_report", "running_jobs", "session_summary_stats", "system_health_events", + "default_trace_events", "job_history", "agent_status" + }; + + [Fact] + public void DarlingPresetIntervals_AreIdenticalToLite() + { + var lite = ScheduleManager.s_presets; // real in-memory Lite table + var darling = ParsePresetTable(ParitySource.ReadFile(DarlingPresetsPath)); // Darling source-of-truth + + Assert.Equal(PresetNames.Length, darling.Count); + + foreach (var presetName in PresetNames) + { + Assert.True(lite.ContainsKey(presetName), $"Lite is missing preset '{presetName}'"); + Assert.True(darling.ContainsKey(presetName), $"Darling is missing preset '{presetName}'"); + + var liteIntervals = lite[presetName]; + var darlingIntervals = darling[presetName]; + + /* Membership matches the pinned set on BOTH sides (so the value loop below covers every collector). */ + Assert.True(ExpectedCollectors.SetEquals(liteIntervals.Keys), + $"Lite preset '{presetName}' collector set drifted from the pinned set"); + Assert.True(ExpectedCollectors.SetEquals(darlingIntervals.Keys), + $"Darling preset '{presetName}' collector set drifted from the pinned set"); + + /* Same interval VALUE for every collector — the drift the membership-only per-app pins can't see. */ + foreach (var collector in liteIntervals.Keys) + { + Assert.True(darlingIntervals.TryGetValue(collector, out var darlingMinutes), + $"Darling preset '{presetName}' is missing collector '{collector}'"); + Assert.True(liteIntervals[collector] == darlingMinutes, + $"Preset '{presetName}' interval drift for '{collector}': Lite={liteIntervals[collector]} Darling={darlingMinutes}"); + } + } + } + + [Fact] + public void PresetSourceParser_FaithfullyReproducesLiteRealObject() + { + /* Anti-parser-bug guard. Darling's table is read from source, so the whole cross-app pin is only as + trustworthy as the parser. The parser must reproduce Lite's REAL s_presets object exactly from + ScheduleManager.cs; Darling's CollectorSchedulePresets.cs uses the identical inner-entry format + (["collector"] = minutes), so a parser proven faithful on Lite's file is trusted on Darling's. If + this fails, DarlingPresetIntervals_AreIdenticalToLite's green means nothing — so it is pinned, not + assumed. */ + var real = ScheduleManager.s_presets; + var parsed = ParsePresetTable(ParitySource.ReadFile(LiteSchedulePath)); + + Assert.Equal(real.Count, parsed.Count); + foreach (var (presetName, intervals) in real) + { + Assert.True(parsed.TryGetValue(presetName, out var parsedIntervals), + $"parser dropped preset '{presetName}'"); + Assert.Equal(intervals.Count, parsedIntervals!.Count); + foreach (var (collector, minutes) in intervals) + { + Assert.True(parsedIntervals.TryGetValue(collector, out var parsedMinutes), + $"parser dropped collector '{collector}' from preset '{presetName}'"); + Assert.Equal(minutes, parsedMinutes); + } + } + } + + /// <summary> + /// Parses the three preset tables out of a <c>ScheduleManager.cs</c> / <c>CollectorSchedulePresets.cs</c> + /// source string. Anchors on <c>["PresetName"] = new … {</c> and captures the brace-delimited body — the + /// inner dictionaries hold no nested braces — then reads every <c>["collector"] = minutes</c> entry. Format + /// agnostic to the target-typed <c>new(...)</c> (Lite) vs the explicit + /// <c>new Dictionary<string,int>(...)</c> (Darling); whitespace/newlines/trailing commas are tolerated. + /// </summary> + private static Dictionary<string, Dictionary<string, int>> ParsePresetTable(string source) + { + var result = new Dictionary<string, Dictionary<string, int>>(StringComparer.OrdinalIgnoreCase); + + foreach (var presetName in PresetNames) + { + /* `["<preset>"] = new <anything-but-braces> {` — the opening of this preset's inner dictionary. + [^{}]* between `new` and `{` consumes the (StringComparer…) / Dictionary<…>(…) noise but cannot + cross a brace, so it lands on the inner-dictionary open brace, never a later one. */ + var open = Regex.Match(source, @"\[""" + Regex.Escape(presetName) + @"""\]\s*=\s*new\b[^{}]*\{"); + if (!open.Success) + { + continue; // leave absent → the caller's ContainsKey / count assertions report it + } + + var bodyStart = open.Index + open.Length; + var bodyEnd = source.IndexOf('}', bodyStart); // no nested braces inside → first '}' closes it + Assert.True(bodyEnd > bodyStart, $"unterminated preset body for '{presetName}'"); + var body = source.Substring(bodyStart, bodyEnd - bodyStart); + + var intervals = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase); + foreach (Match entry in Regex.Matches(body, @"\[""([a-z0-9_]+)""\]\s*=\s*(\d+)")) + { + intervals[entry.Groups[1].Value] = int.Parse(entry.Groups[2].Value); + } + + result[presetName] = intervals; + } + + return result; + } +} diff --git a/Lite.Tests/DailyHealthBandTests.cs b/Lite.Tests/DailyHealthBandTests.cs new file mode 100644 index 000000000..74ab2aa39 --- /dev/null +++ b/Lite.Tests/DailyHealthBandTests.cs @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using PerformanceMonitor.Common; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// <summary> +/// Table-driven tests for the shared Performance Calendar day-banding (<see cref="DailyHealthBandCalculator"/>). +/// This is the one classification both apps color their month heatmap by, so it is pinned here (Lite) and, +/// identically, in Darling.Tests. Covers the no-data / healthy / warning / critical tiers, first-match-wins +/// priority, the threshold boundaries, and the label / brush-key / tooltip mappings. +/// </summary> +public class DailyHealthBandTests +{ + private static DailyHealthSignals Signals( + bool hasData = true, long deadlocks = 0, long collectionErrors = 0, long highCpu = 0, + long blocking = 0, long memPressure = 0, long memCritical = 0, long alerts = 0) => new() + { + HasData = hasData, + Deadlocks = deadlocks, + CollectionErrors = collectionErrors, + HighCpuEvents = highCpu, + BlockingEvents = blocking, + MemoryPressureEvents = memPressure, + MemoryCriticalEvents = memCritical, + AlertCount = alerts, + }; + + [Fact] + public void NoCollection_IsNoData_RegardlessOfOtherCounts() + { + // Even absurd counts can't override "we didn't collect that day". + var s = Signals(hasData: false, deadlocks: 99, collectionErrors: 99, highCpu: 99, blocking: 99, memCritical: 99, alerts: 99); + Assert.Equal(DailyHealthBand.NoData, DailyHealthBandCalculator.Classify(s)); + } + + [Fact] + public void Collected_AndNothingElevated_IsHealthy() + { + Assert.Equal(DailyHealthBand.Healthy, DailyHealthBandCalculator.Classify(Signals())); + } + + [Theory] + [InlineData("deadlock", 1, 0, 0, 0, 0, 0)] + [InlineData("collection-error", 0, 1, 0, 0, 0, 0)] + [InlineData("memory-critical", 0, 0, 0, 0, 1, 0)] + [InlineData("sustained-cpu", 0, 0, 6, 0, 0, 0)] + [InlineData("heavy-blocking", 0, 0, 0, 11, 0, 0)] + public void CriticalTriggers_EachAloneIsCritical(string _, long deadlocks, long collErrors, long highCpu, long blocking, long memCritical, long alerts) + { + var s = Signals(deadlocks: deadlocks, collectionErrors: collErrors, highCpu: highCpu, blocking: blocking, memCritical: memCritical, alerts: alerts); + Assert.Equal(DailyHealthBand.Critical, DailyHealthBandCalculator.Classify(s)); + } + + [Theory] + [InlineData("moderate-cpu", 3, 0, 0, 0)] + [InlineData("some-blocking", 0, 5, 0, 0)] + [InlineData("memory-pressure", 0, 0, 1, 0)] + [InlineData("alert", 0, 0, 0, 1)] + public void WarningTriggers_EachAloneIsWarning(string _, long highCpu, long blocking, long memPressure, long alerts) + { + var s = Signals(highCpu: highCpu, blocking: blocking, memPressure: memPressure, alerts: alerts); + Assert.Equal(DailyHealthBand.Warning, DailyHealthBandCalculator.Classify(s)); + } + + [Fact] + public void CriticalBeatsWarning_WhenBothPresent() + { + // A deadlock (critical) plus moderate CPU + alerts (warning) still bands Critical. + var s = Signals(deadlocks: 1, highCpu: 3, alerts: 4); + Assert.Equal(DailyHealthBand.Critical, DailyHealthBandCalculator.Classify(s)); + } + + [Theory] + [InlineData(5, DailyHealthBand.Warning)] // 5 high-CPU samples = moderate + [InlineData(6, DailyHealthBand.Critical)] // 6 = sustained (default threshold) + public void HighCpu_CriticalThreshold_IsSixSamples(long highCpu, DailyHealthBand expected) + { + Assert.Equal(expected, DailyHealthBandCalculator.Classify(Signals(highCpu: highCpu))); + } + + [Theory] + [InlineData(10, DailyHealthBand.Warning)] // 10 blocking events = some + [InlineData(11, DailyHealthBand.Critical)] // 11 = heavy (default threshold) + public void Blocking_CriticalThreshold_IsElevenEvents(long blocking, DailyHealthBand expected) + { + Assert.Equal(expected, DailyHealthBandCalculator.Classify(Signals(blocking: blocking))); + } + + [Fact] + public void Thresholds_AreOverridable() + { + // Same day, stricter warning thresholds: 3 high-CPU samples now clears the Critical bar. + var strict = new DailyHealthThresholds { HighCpuCriticalSamples = 3 }; + Assert.Equal(DailyHealthBand.Critical, DailyHealthBandCalculator.Classify(Signals(highCpu: 3), strict)); + Assert.Equal(DailyHealthBand.Warning, DailyHealthBandCalculator.Classify(Signals(highCpu: 3))); + } + + [Theory] + [InlineData(DailyHealthBand.NoData, "No Data", "ForegroundMutedBrush")] + [InlineData(DailyHealthBand.Healthy, "Healthy", "SuccessBrush")] + [InlineData(DailyHealthBand.Warning, "Warning", "WarningBrush")] + [InlineData(DailyHealthBand.Critical, "Critical", "ErrorBrush")] + public void Label_And_BrushKey_MapEachBand(DailyHealthBand band, string label, string brushKey) + { + Assert.Equal(label, DailyHealthBandCalculator.Label(band)); + Assert.Equal(brushKey, DailyHealthBandCalculator.BrushKey(band)); + } + + [Fact] + public void Describe_NoData_And_NoIssues_And_MultiSignal() + { + Assert.Equal("No data collected.", DailyHealthBandCalculator.Describe(Signals(hasData: false))); + Assert.Equal("No issues detected.", DailyHealthBandCalculator.Describe(Signals())); + + var described = DailyHealthBandCalculator.Describe(Signals(deadlocks: 2, blocking: 1, alerts: 3)); + Assert.Contains("2 deadlocks", described); + Assert.Contains("1 blocking event", described); // singular + Assert.Contains("3 alerts", described); + } + + [Fact] + public void Describe_MemoryPressure_DoesNotDoubleCountSevere() + { + // 3 pressure events, 1 severe -> "1 severe" + "2 (non-severe)", never "3 memory-pressure events". + var described = DailyHealthBandCalculator.Describe(Signals(memPressure: 3, memCritical: 1)); + Assert.Contains("1 severe memory-pressure event", described); + Assert.Contains("2 memory-pressure events", described); + Assert.DoesNotContain("3 memory-pressure events", described); + } + + // ── Day-detail panel logic (the click-a-day panel's reasons / drills / window / metrics line) ── + + [Fact] + public void BuildReasons_TerminalMessages_ForNoData_AndQuietDay() + { + // No-Data uses the day-detail phrasing (distinct from the tooltip's "No data collected."). + Assert.Equal(new[] { "No collection this day." }, DailyHealthBandCalculator.BuildReasons(Signals(hasData: false))); + Assert.Equal(new[] { "No issues detected." }, DailyHealthBandCalculator.BuildReasons(Signals())); + } + + [Fact] + public void BuildReasons_ListsEachNonZeroSignal() + { + var reasons = DailyHealthBandCalculator.BuildReasons( + Signals(deadlocks: 2, collectionErrors: 1, highCpu: 4, blocking: 3, memPressure: 5, memCritical: 2, alerts: 1)); + Assert.Contains("2 deadlocks", reasons); + Assert.Contains("1 collection error", reasons); + Assert.Contains("4 high-CPU samples", reasons); + Assert.Contains("3 blocking events", reasons); + Assert.Contains("2 severe memory-pressure events", reasons); + Assert.Contains("3 memory-pressure events", reasons); // 5 total - 2 severe, not double-counted + Assert.Contains("1 alert", reasons); + } + + [Theory] + [InlineData(800, "4 blocking events (peak block 800 ms)")] + [InlineData(12500, "4 blocking events (peak block 12.5 s)")] + [InlineData(150000, "4 blocking events (peak block 2.5 min)")] + public void BuildReasons_BlockingLine_CarriesPeakBlock_WhenProvided(long peakMs, string expected) + { + Assert.Contains(expected, DailyHealthBandCalculator.BuildReasons(Signals(blocking: 4), peakMs)); + } + + [Fact] + public void BuildReasons_BlockingLine_OmitsPeak_WhenZero() + { + var reasons = DailyHealthBandCalculator.BuildReasons(Signals(blocking: 4), peakBlockMs: 0); + Assert.Contains("4 blocking events", reasons); + Assert.DoesNotContain(reasons, r => r.Contains("peak block", StringComparison.Ordinal)); + } + + [Fact] + public void AvailableDrills_NoData_OffersNothing() + { + Assert.Empty(DailyHealthBandCalculator.AvailableDrills(Signals(hasData: false))); + } + + [Fact] + public void AvailableDrills_CollectedDay_AlwaysOffersTopQueries() + { + Assert.Equal(new[] { DayDrillTarget.TopQueries }, DailyHealthBandCalculator.AvailableDrills(Signals())); + } + + [Fact] + public void AvailableDrills_AddsDeadlocks_And_Blocking_OnlyWhenPresent_InPanelOrder() + { + Assert.Equal( + new[] { DayDrillTarget.Deadlocks, DayDrillTarget.Blocking, DayDrillTarget.TopQueries }, + DailyHealthBandCalculator.AvailableDrills(Signals(deadlocks: 1, blocking: 2))); + Assert.Equal( + new[] { DayDrillTarget.Deadlocks, DayDrillTarget.TopQueries }, + DailyHealthBandCalculator.AvailableDrills(Signals(deadlocks: 3))); + Assert.Equal( + new[] { DayDrillTarget.Blocking, DayDrillTarget.TopQueries }, + DailyHealthBandCalculator.AvailableDrills(Signals(blocking: 7))); + } + + [Fact] + public void DayWindowUtc_IsClickedDay_MidnightToNextMidnight() + { + var (start, end) = DailyHealthBandCalculator.DayWindowUtc(new DateTime(2026, 7, 8, 14, 37, 12)); + Assert.Equal(new DateTime(2026, 7, 8), start); // time component dropped + Assert.Equal(new DateTime(2026, 7, 9), end); + Assert.Equal(TimeSpan.FromDays(1), end - start); + } + + [Fact] + public void BuildKeyMetricsLine_RendersRollup() + { + var line = DailyHealthBandCalculator.BuildKeyMetricsLine("CXPACKET", 4, 150m, 87); + Assert.Contains("Top wait: CXPACKET", line); + Assert.Contains("High-CPU samples: 4", line); + Assert.Contains("Total wait: 150.0 s", line); + Assert.Contains("Unique queries: 87", line); + } + + [Fact] + public void BuildKeyMetricsLine_NoWait_ShowsNone_And_LargeWaitInMinutes() + { + var line = DailyHealthBandCalculator.BuildKeyMetricsLine(null, 0, 1200m, 0); + Assert.Contains("Top wait: none", line); + Assert.Contains("Total wait: 20.0 min", line); // 1200s / 60 + } +} diff --git a/Lite.Tests/DailySummaryHighCpuTests.cs b/Lite.Tests/DailySummaryHighCpuTests.cs new file mode 100644 index 000000000..a055d93cc --- /dev/null +++ b/Lite.Tests/DailySummaryHighCpuTests.cs @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.IO; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using PerformanceMonitorLite.Database; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// <summary> +/// Guards the daily-summary high-CPU count semantics. The count must use TOTAL host CPU +/// (sqlserver + other_process), matching the alert engine (CpuAlertMode.Total defaults on), +/// the Overview headline (TotalCpuPercent = sqlserver + (other_process ?? 0)), and Dashboard's +/// report.daily_summary (switched to total in #1004) -- NOT SQL-only. A sample at +/// sqlserver=79 + other_process=5 (total 84) must count; under the retired SQL-only rule it +/// would not. And other_process NULL (SQL Server on Linux, #1048) must fall back to the SQL-only +/// figure via COALESCE(.,0), mirroring Dashboard's ISNULL(total, sqlserver) fallback. +/// </summary> +public class DailySummaryHighCpuTests : IDisposable +{ + private readonly string _tempDir; + private readonly DuckDbInitializer _duckDb; + private readonly LocalDataService _dataService; + + private const int ServerId = -778; + private static readonly DateTime _day = new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc); + private long _nextId = -1; + + public DailySummaryHighCpuTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "DailyCpuTests_" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(_tempDir); + var dbPath = Path.Combine(_tempDir, "test.duckdb"); + _duckDb = new DuckDbInitializer(dbPath); + _dataService = new LocalDataService(_duckDb); + } + + public void Dispose() + { + try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); } + catch { } + } + + private async Task SeedCpuAsync(DateTime time, int sqlCpu, int? otherCpu) + { + using var readLock = _duckDb.AcquireReadLock(); + using var conn = _duckDb.CreateConnection(); + await conn.OpenAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"INSERT INTO cpu_utilization_stats + (collection_id, collection_time, server_id, server_name, sample_time, sqlserver_cpu_utilization, other_process_cpu_utilization) + VALUES ($1,$2,$3,'TestServer',$2,$4,$5)"; + cmd.Parameters.Add(new DuckDBParameter { Value = _nextId-- }); + cmd.Parameters.Add(new DuckDBParameter { Value = time }); + cmd.Parameters.Add(new DuckDBParameter { Value = ServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = sqlCpu }); + cmd.Parameters.Add(new DuckDBParameter { Value = (object?)otherCpu ?? DBNull.Value }); + await cmd.ExecuteNonQueryAsync(); + } + + [Fact] + public async Task HighCpuEvents_CountsTotalHostCpu_WithLinuxFallback() + { + await _duckDb.InitializeAsync(); + + // total >= 80 => counted + await SeedCpuAsync(_day.AddHours(1), 85, 0); // 85 from SQL alone + await SeedCpuAsync(_day.AddHours(2), 79, 5); // 84 total -- excluded under the old SQL-only rule + await SeedCpuAsync(_day.AddHours(3), 60, 25); // 85 total -- mostly non-SQL load + // total < 80 => excluded + await SeedCpuAsync(_day.AddHours(4), 50, 10); // 60 total + await SeedCpuAsync(_day.AddHours(5), 78, 1); // 79 total -- just under the line + // other_process NULL (Linux): fall back to SQL-only + await SeedCpuAsync(_day.AddHours(6), 82, null); // 82 + COALESCE(NULL,0) = 82 => counted + await SeedCpuAsync(_day.AddHours(7), 70, null); // 70 + COALESCE(NULL,0) = 70 => excluded + + var summary = await _dataService.GetDailySummaryAsync(ServerId, _day); + + Assert.NotNull(summary); + // Total-CPU rows: 85, 84, 85, 82 = 4. (Under the retired SQL-only rule this would be 2: only 85 and 82.) + Assert.True( + summary!.HighCpuEvents == 4, + $"Expected 4 high-CPU events under total-CPU semantics (SQL-only would give 2), got {summary.HighCpuEvents}"); + } +} diff --git a/Lite.Tests/DatabaseFilterTests.cs b/Lite.Tests/DatabaseFilterTests.cs new file mode 100644 index 000000000..3b948ee3b --- /dev/null +++ b/Lite.Tests/DatabaseFilterTests.cs @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using PerformanceMonitorLite.Analysis; +using PerformanceMonitorLite.Database; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// <summary> +/// #1319 global database filter (Lite). Two layers: the pure positional-<c>IN</c> clause builder +/// (<see cref="LocalDataService.BuildDbInClause"/>, whose param-index math is the risky part) and the +/// collected-database picker source (<see cref="LocalDataService.GetCollectedDatabaseNamesAsync"/>) +/// exercised against a temp DuckDB. +/// </summary> +public class DatabaseFilterTests : IDisposable +{ + private readonly string _tempDir; + private readonly DuckDbInitializer _duckDb; + + public DatabaseFilterTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "LiteDbFilterTests_" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(_tempDir); + _duckDb = new DuckDbInitializer(Path.Combine(_tempDir, "test.duckdb")); + } + + public void Dispose() + { + try + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, recursive: true); + } + } + catch { /* Best-effort cleanup */ } + } + + // ── BuildDbInClause: empty short-circuits, N databases → N params at the right positional indices ── + + [Fact] + public void BuildDbInClause_NullSelection_EmitsNoClauseAndNoValues() + { + var clause = LocalDataService.BuildDbInClause(null, "database_name", 4, out var values); + + Assert.Equal("", clause); + Assert.Empty(values); + } + + [Fact] + public void BuildDbInClause_EmptySelection_EmitsNoClauseAndNoValues() + { + var clause = LocalDataService.BuildDbInClause(new List<string>(), "database_name", 4, out var values); + + Assert.Equal("", clause); + Assert.Empty(values); + } + + [Fact] + public void BuildDbInClause_SingleDatabase_EmitsOneParamAtFirstIndex() + { + var clause = LocalDataService.BuildDbInClause(new[] { "AppDB1" }, "database_name", 6, out var values); + + Assert.Equal(" AND database_name IN ($6)", clause); + Assert.Single(values); + Assert.Equal("AppDB1", values[0]); + } + + [Fact] + public void BuildDbInClause_MultipleDatabases_NumbersParamsSequentiallyFromFirstIndex() + { + var clause = LocalDataService.BuildDbInClause(new[] { "A", "B", "C" }, "database_name", 4, out var values); + + Assert.Equal(" AND database_name IN ($4, $5, $6)", clause); + Assert.Equal(3, values.Count); + Assert.Equal("A", values[0]); + Assert.Equal("B", values[1]); + Assert.Equal("C", values[2]); + } + + [Fact] + public void BuildDbInClause_HonorsColumnQualifierAndFirstIndex() + { + var clause = LocalDataService.BuildDbInClause(new[] { "X", "Y" }, "qs.database_name", 2, out var values); + + Assert.Equal(" AND qs.database_name IN ($2, $3)", clause); + Assert.Equal(2, values.Count); + } + + // ── GetCollectedDatabaseNamesAsync ── + + [Fact] + public async Task GetCollectedDatabaseNames_UnionsBothStores_StripsSystemDbs_DistinctAndSorted() + { + await _duckDb.InitializeAsync(); + var seeder = new TestDataSeeder(_duckDb); + await seeder.ClearTestDataAsync(); + await seeder.SeedTestServerAsync(); + + // database_config: two user DBs + a system DB (master) that MUST be stripped. + await seeder.SeedDatabaseConfigAsync( + ("master", true, false, false, "CHECKSUM"), + ("AppDB1", true, false, false, "CHECKSUM"), + ("AppDB2", true, false, false, "CHECKSUM")); + + // database_size_stats: a duplicate (AppDB2 → deduped by UNION), a new user DB (AppDB3), + // and a system DB (tempdb → stripped). + await SeedSizeStatsDatabaseAsync("AppDB2", 10); + await SeedSizeStatsDatabaseAsync("AppDB3", 11); + await SeedSizeStatsDatabaseAsync("tempdb", 2); + + var dataService = new LocalDataService(_duckDb); + var names = await dataService.GetCollectedDatabaseNamesAsync(TestDataSeeder.TestServerId); + + Assert.Equal(new[] { "AppDB1", "AppDB2", "AppDB3" }, names); + } + + [Fact] + public async Task GetCollectedDatabaseNames_NothingCollected_ReturnsEmpty() + { + await _duckDb.InitializeAsync(); + var seeder = new TestDataSeeder(_duckDb); + await seeder.ClearTestDataAsync(); + await seeder.SeedTestServerAsync(); + + var dataService = new LocalDataService(_duckDb); + var names = await dataService.GetCollectedDatabaseNamesAsync(TestDataSeeder.TestServerId); + + Assert.Empty(names); + } + + private async Task SeedSizeStatsDatabaseAsync(string databaseName, int databaseId) + { + using var readLock = _duckDb.AcquireReadLock(); + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +INSERT INTO database_size_stats + (collection_id, collection_time, server_id, server_name, + database_name, database_id, file_id, file_type_desc, file_name, physical_name, + total_size_mb, used_size_mb) +VALUES ($1, $2, $3, $4, $5, $6, 1, 'ROWS', $7, $8, 100, 80)"; + cmd.Parameters.Add(new DuckDBParameter { Value = (long)(-2_000_000 - databaseId) }); + cmd.Parameters.Add(new DuckDBParameter { Value = DateTime.UtcNow }); + cmd.Parameters.Add(new DuckDBParameter { Value = TestDataSeeder.TestServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = TestDataSeeder.TestServerName }); + cmd.Parameters.Add(new DuckDBParameter { Value = databaseName }); + cmd.Parameters.Add(new DuckDBParameter { Value = databaseId }); + cmd.Parameters.Add(new DuckDBParameter { Value = $"{databaseName}.mdf" }); + cmd.Parameters.Add(new DuckDBParameter { Value = $"D:\\Data\\{databaseName}.mdf" }); + await cmd.ExecuteNonQueryAsync(); + } +} diff --git a/Lite.Tests/DatabaseScopedConfigCollectorDefinitionTests.cs b/Lite.Tests/DatabaseScopedConfigCollectorDefinitionTests.cs new file mode 100644 index 000000000..0a43ed4e2 --- /dev/null +++ b/Lite.Tests/DatabaseScopedConfigCollectorDefinitionTests.cs @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Pins the parity contract of the extracted database_scoped_config definition — the first +/// enumeration-shape collector: database-list selection (AG-primary filter on-prem, plain list +/// on Azure), the [db].sys.sp_executesql per-database query with bracket escaping, and the +/// 4-column payload. +/// </summary> +public sealed class DatabaseScopedConfigCollectorDefinitionTests +{ + private static readonly RecordingCollectorDeltaCalculator s_deltas = new(); + + [Fact] + public void EnumerationQuery_OnPrem_FiltersToAgPrimaries_AndSplicesExclusions() + { + var plan = DatabaseScopedConfigCollector.Instance.BuildEnumerationQuery(new CollectorContext + { + ServerId = 42, + ServerName = "test-server", + CollectionTime = DateTime.UtcNow, + Deltas = s_deltas, + ExcludedDatabases = new[] { "SO" }, + }); + + Assert.NotNull(plan); + Assert.Contains("sys.dm_hadr_database_replica_states", plan!.Text, StringComparison.Ordinal); + Assert.Contains("is_primary_replica = 1", plan.Text, StringComparison.Ordinal); + Assert.Contains("AND d.name NOT IN (@excl_db_0)", plan.Text, StringComparison.Ordinal); + Assert.Equal("SO", Assert.Single(plan.Parameters).Value); + } + + [Fact] + public void EnumerationQuery_Azure_ListsAllOnline_NoAgFilter() + { + var plan = DatabaseScopedConfigCollector.Instance.BuildEnumerationQuery( + CollectorTestContext.Make(s_deltas, isAzureSqlDb: true)); + + Assert.NotNull(plan); + Assert.DoesNotContain("dm_hadr_database_replica_states", plan!.Text, StringComparison.Ordinal); + Assert.Contains("state_desc = N'ONLINE'", plan.Text, StringComparison.Ordinal); + } + + [Fact] + public void PerItemQuery_EscapesClosingBrackets_InDatabaseNames() + { + var plan = DatabaseScopedConfigCollector.Instance.BuildPerItemQuery("we]rd db", CollectorTestContext.Make(s_deltas)); + + Assert.Contains("EXECUTE [we]]rd db].sys.sp_executesql", plan.Text, StringComparison.Ordinal); + Assert.Contains("sys.database_scoped_configurations", plan.Text, StringComparison.Ordinal); + Assert.Empty(plan.Parameters); + } + + [Fact] + public async Task ReadItemAsync_AccumulatesAcrossItems_TaggedWithDatabase() + { + var rows = new List<DatabaseScopedConfigCollector.Row>(); + var context = CollectorTestContext.Make(s_deltas); + + using (var reader = new FakeCollectorDataReader(new object[] { "MAXDOP", "8", "0" })) + { + await DatabaseScopedConfigCollector.Instance.ReadItemAsync("db1", reader, rows, context, CancellationToken.None); + } + + using (var reader = new FakeCollectorDataReader(new object[] { "MAXDOP", "4", DBNull.Value })) + { + await DatabaseScopedConfigCollector.Instance.ReadItemAsync("db2", reader, rows, context, CancellationToken.None); + } + + Assert.Equal(2, rows.Count); + Assert.Equal(new DatabaseScopedConfigCollector.Row("db1", "MAXDOP", "8", "0"), rows[0]); + Assert.Equal(new DatabaseScopedConfigCollector.Row("db2", "MAXDOP", "4", null), rows[1]); + } + + [Fact] + public void PayloadColumns_MatchSchema_AndWriteOrder() + { + Assert.Equal( + new[] { "database_name", "configuration_name", "value", "value_for_secondary" }, + DatabaseScopedConfigCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray()); + + var writer = new RecordingCollectorRowWriter(); + DatabaseScopedConfigCollector.Instance.WritePayload( + new DatabaseScopedConfigCollector.Row("db1", "MAXDOP", "8", null), writer, CollectorTestContext.Make(s_deltas)); + Assert.Equal(new object?[] { "db1", "MAXDOP", "8", null }, writer.Values); + } +} diff --git a/Lite.Tests/DatabaseSizeCollectorDefinitionTests.cs b/Lite.Tests/DatabaseSizeCollectorDefinitionTests.cs new file mode 100644 index 000000000..af9ca6cc2 --- /dev/null +++ b/Lite.Tests/DatabaseSizeCollectorDefinitionTests.cs @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Pins the parity contract of the extracted database_size_stats definition: the two-site +/// exclusion splice (cursor + outer SELECT, same parameters referenced twice), the server-side +/// FILEPROPERTY staging batch, the Azure per-database variant, and the 19-column payload. +/// </summary> +public sealed class DatabaseSizeCollectorDefinitionTests +{ + private static readonly RecordingCollectorDeltaCalculator s_deltas = new(); + + [Fact] + public void BuildQuery_OnPrem_SplicesExclusionAtBothSites_ParamsOnce() + { + var plan = DatabaseSizeStatsCollector.Instance.BuildQuery(new CollectorContext + { + ServerId = 42, + ServerName = "test-server", + CollectionTime = DateTime.UtcNow, + Deltas = s_deltas, + ExcludedDatabases = new[] { "SO" }, + }); + + Assert.Equal(2, Regex.Matches(plan.Text, Regex.Escape("AND d.name NOT IN (@excl_db_0)")).Count); + Assert.DoesNotContain("/*EXCLUSION_FILTER", plan.Text, StringComparison.Ordinal); + Assert.Contains("CURSOR LOCAL FAST_FORWARD", plan.Text, StringComparison.Ordinal); + Assert.Contains("FILEPROPERTY(df.name, N''''SpaceUsed'''')", plan.Text, StringComparison.Ordinal); + Assert.Contains("sys.dm_os_volume_stats", plan.Text, StringComparison.Ordinal); + Assert.Equal("SO", Assert.Single(plan.Parameters).Value); + } + + [Fact] + public void BuildQuery_Azure_PerDatabase_NoVolumeStats() + { + var plan = DatabaseSizeStatsCollector.Instance.BuildQuery(CollectorTestContext.Make(s_deltas, isAzureSqlDb: true)); + + Assert.True(DatabaseSizeStatsCollector.Instance.RunsPerDatabase(new CollectorTargetInfo { IsAzureSqlDb = true })); + Assert.False(DatabaseSizeStatsCollector.Instance.RunsPerDatabase(new CollectorTargetInfo())); + Assert.Contains("FROM sys.database_files AS df", plan.Text, StringComparison.Ordinal); + Assert.DoesNotContain("dm_os_volume_stats", plan.Text, StringComparison.Ordinal); + Assert.Empty(plan.Parameters); + } + + [Fact] + public void PayloadColumns_MatchSchemaOrder_19Columns() + { + var names = DatabaseSizeStatsCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray(); + Assert.Equal(19, names.Length); + Assert.Equal("database_name", names[0]); + Assert.Equal("vlf_count", names[18]); + } + + [Fact] + public async Task ReadAndWrite_RoundTrip_WithPercentGrowthBitConversion() + { + using var reader = new FakeCollectorDataReader( + new object[] + { + "SO", 7, 2, "LOG", "SO_log", @"D:\so_log.ldf", 8192.00m, 512.25m, + DBNull.Value, -1m, "FULL", 160, "ONLINE", @"D:\", 476837.12m, 100000.50m, + 1, 10, 42, + }); + + var rows = await DatabaseSizeStatsCollector.Instance.ReadAsync(reader, CollectorTestContext.Make(s_deltas), CancellationToken.None); + + var row = Assert.Single(rows); + Assert.True(row.IsPercentGrowth); /* bit arrives as int 1 → true */ + Assert.Null(row.AutoGrowthMb); /* percent growth → NULL auto_growth_mb */ + Assert.Equal(42, row.VlfCount); + + var writer = new RecordingCollectorRowWriter(); + DatabaseSizeStatsCollector.Instance.WritePayload(row, writer, CollectorTestContext.Make(s_deltas)); + Assert.Equal(19, writer.Values.Count); + Assert.Equal("SO", writer.Values[0]); + Assert.Equal(true, writer.Values[16]); + Assert.Equal(42, writer.Values[18]); + } +} diff --git a/Lite.Tests/DeadlockGraphProcessParserTests.cs b/Lite.Tests/DeadlockGraphProcessParserTests.cs new file mode 100644 index 000000000..ceefdb00a --- /dev/null +++ b/Lite.Tests/DeadlockGraphProcessParserTests.cs @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using PerformanceMonitor.Common; +using Xunit; +using static PerformanceMonitor.Common.DeadlockGraphProcessParser; + +namespace Lite.Tests; + +/// <summary> +/// Behavioral pin for the shared <see cref="DeadlockGraphProcessParser"/> — the sp_BlitzLock-style per-process +/// deadlock-graph walk that used to be duplicated byte-for-byte as <c>DeadlockProcessDetail.ParseFromRows</c> +/// in Lite (<c>LocalDataService.Blocking.cs</c>) and the Darling viewer (<c>ViewerDataService.Deadlock.cs</c>). +/// Drives the parser directly over the WPF-free base <see cref="DeadlockProcessInfo"/>, proving the collapsed +/// walk still resolves victims, per-process owner/waiter modes, contended objects, proc names, the +/// regular-vs-parallel classification, the victim-plan threading, and the malformed-XML fallback. Pure — no +/// database, no WPF. +/// </summary> +public class DeadlockGraphProcessParserTests +{ + /// <summary>A known 2-process graph: process1 (spid 55) is the victim/waiter, process2 (spid 60) the owner.</summary> + private const string TwoProcessDeadlockXml = """ + <deadlock> + <victim-list> + <victimProcess id="process1" /> + </victim-list> + <process-list> + <process id="process1" spid="55" currentdbname="AppDb" waitresource="KEY: 5:72" waittime="1500" lockMode="X" isolationlevel="read committed (2)" logused="200" trancount="1" clientapp="TestApp" hostname="HOST1" loginname="sa" status="suspended" transactionname="user_transaction" priority="0" lasttranstarted="2026-07-01T10:00:00.000"> + <executionStack> + <frame procname="AppDb.dbo.usp_Update" line="1">UPDATE dbo.t SET c = 1</frame> + </executionStack> + <inputbuf>UPDATE dbo.t SET c = 1</inputbuf> + </process> + <process id="process2" spid="60" currentdbname="AppDb" waitresource="KEY: 5:73" waittime="1200" lockMode="X" isolationlevel="read committed (2)" logused="300" trancount="1" clientapp="TestApp2" hostname="HOST2" loginname="sa" status="suspended" transactionname="user_transaction" priority="0"> + <inputbuf>UPDATE dbo.t SET c = 2</inputbuf> + </process> + </process-list> + <resource-list> + <keylock hobtid="72" dbid="5" objectname="AppDb.dbo.t" indexname="pk_t" mode="X"> + <owner-list> + <owner id="process2" mode="X" /> + </owner-list> + <waiter-list> + <waiter id="process1" mode="X" requestType="wait" /> + </waiter-list> + </keylock> + </resource-list> + </deadlock> + """; + + /// <summary>A minimal parallel graph: the resource-list carries an exchangeEvent, so it must classify Parallel.</summary> + private const string ParallelDeadlockXml = """ + <deadlock> + <victim-list> + <victimProcess id="p1" /> + </victim-list> + <process-list> + <process id="p1" spid="70" currentdbname="Db2" waittime="500" /> + <process id="p2" spid="70" currentdbname="Db2" waittime="500" /> + </process-list> + <resource-list> + <exchangeEvent id="Pipe1" WaitType="e_waitPipeGetRow"> + <owner-list><owner id="p2" /></owner-list> + <waiter-list><waiter id="p1" /></waiter-list> + </exchangeEvent> + </resource-list> + </deadlock> + """; + + private static readonly DateTime DeadlockTime = new(2026, 7, 1, 10, 0, 5, DateTimeKind.Unspecified); + + [Fact] + public void Parse_TwoProcessGraph_ResolvesPerProcessFields() + { + var input = new[] + { + new DeadlockGraphInput(TwoProcessDeadlockXml, DeadlockTime, VictimSqlText: null, VictimQueryPlanXml: null), + }; + + var details = DeadlockGraphProcessParser.Parse<DeadlockProcessInfo>(input); + + Assert.Equal(2, details.Count); + Assert.All(details, d => Assert.Equal("Regular", d.DeadlockType)); + Assert.All(details, d => Assert.True(d.HasDeadlockXml)); + Assert.All(details, d => Assert.Equal(DeadlockTime, d.DeadlockTime)); + + var victim = details.Single(d => d.Spid == 55); + Assert.True(victim.IsVictim); + Assert.Equal("process1", victim.ProcessId); + Assert.Equal("AppDb", victim.DatabaseName); + Assert.Equal("UPDATE dbo.t SET c = 1", victim.SqlText); + Assert.Equal("KEY: 5:72", victim.WaitResource); + Assert.Equal(1500, victim.WaitTime); + Assert.Equal("X", victim.LockMode); + Assert.Equal("read committed (2)", victim.IsolationLevel); + Assert.Equal(200, victim.LogUsed); + Assert.Equal(1, victim.TransactionCount); + Assert.Equal("TestApp", victim.ClientApp); + Assert.Equal("HOST1", victim.HostName); + Assert.Equal("sa", victim.LoginName); + Assert.Equal("suspended", victim.Status); + Assert.Equal("user_transaction", victim.TransactionName); + Assert.Equal("AppDb.dbo.usp_Update", victim.ProcName); + Assert.Equal("X", victim.WaiterMode); // process1 waits on the keylock + Assert.Equal("AppDb.dbo.t", victim.ObjectNames); + Assert.Equal(new DateTime(2026, 7, 1, 10, 0, 0), victim.LastTranStarted); + + var owner = details.Single(d => d.Spid == 60); + Assert.False(owner.IsVictim); + Assert.Equal("process2", owner.ProcessId); + Assert.Equal("X", owner.OwnerMode); // process2 owns the keylock + Assert.Equal("AppDb.dbo.t", owner.ObjectNames); + Assert.Equal("", owner.WaiterMode); + } + + [Fact] + public void Parse_ThreadsVictimQueryPlanOntoEveryProcessRow() + { + var input = new[] + { + new DeadlockGraphInput(TwoProcessDeadlockXml, DeadlockTime, VictimSqlText: null, + VictimQueryPlanXml: "<ShowPlanXML>victim</ShowPlanXML>"), + }; + + var details = DeadlockGraphProcessParser.Parse<DeadlockProcessInfo>(input); + + Assert.Equal(2, details.Count); + Assert.All(details, d => Assert.Equal("<ShowPlanXML>victim</ShowPlanXML>", d.VictimQueryPlanXml)); + } + + [Fact] + public void Parse_NullVictimQueryPlan_LeavesItNull() + { + var input = new[] + { + new DeadlockGraphInput(TwoProcessDeadlockXml, DeadlockTime, VictimSqlText: null, VictimQueryPlanXml: null), + }; + + var details = DeadlockGraphProcessParser.Parse<DeadlockProcessInfo>(input); + + Assert.NotEmpty(details); + Assert.All(details, d => Assert.Null(d.VictimQueryPlanXml)); + } + + [Fact] + public void Parse_ExchangeEventGraph_ClassifiesParallel() + { + var input = new[] + { + new DeadlockGraphInput(ParallelDeadlockXml, DeadlockTime, VictimSqlText: null, VictimQueryPlanXml: null), + }; + + var details = DeadlockGraphProcessParser.Parse<DeadlockProcessInfo>(input); + + Assert.Equal(2, details.Count); + Assert.All(details, d => Assert.Equal("Parallel", d.DeadlockType)); + } + + [Fact] + public void Parse_MalformedXml_YieldsSingleVictimFallbackRow() + { + var input = new[] + { + new DeadlockGraphInput("<deadlock", DeadlockTime, VictimSqlText: "UPDATE t SET c = 9", + VictimQueryPlanXml: "<ShowPlanXML>fallback</ShowPlanXML>"), + }; + + var details = DeadlockGraphProcessParser.Parse<DeadlockProcessInfo>(input); + + var only = Assert.Single(details); + Assert.True(only.IsVictim); + Assert.Equal("UPDATE t SET c = 9", only.SqlText); + Assert.True(only.HasDeadlockXml); + Assert.Equal(DeadlockTime, only.DeadlockTime); + Assert.Equal("<ShowPlanXML>fallback</ShowPlanXML>", only.VictimQueryPlanXml); + } + + [Fact] + public void Parse_MalformedXml_NullVictimSql_FallsBackToEmptyString() + { + var input = new[] + { + new DeadlockGraphInput("<deadlock", DeadlockTime, VictimSqlText: null, VictimQueryPlanXml: null), + }; + + var only = Assert.Single(DeadlockGraphProcessParser.Parse<DeadlockProcessInfo>(input)); + Assert.Equal("", only.SqlText); // null VictimSqlText coalesces to "" (the base's non-null default) + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void Parse_EmptyGraphXml_IsSkipped(string? xml) + { + var input = new[] + { + new DeadlockGraphInput(xml, DeadlockTime, VictimSqlText: null, VictimQueryPlanXml: null), + }; + + Assert.Empty(DeadlockGraphProcessParser.Parse<DeadlockProcessInfo>(input)); + } +} diff --git a/Lite.Tests/DeadlocksCollectorDefinitionTests.cs b/Lite.Tests/DeadlocksCollectorDefinitionTests.cs new file mode 100644 index 000000000..65ae41b0e --- /dev/null +++ b/Lite.Tests/DeadlocksCollectorDefinitionTests.cs @@ -0,0 +1,275 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Pins the parity contract of the extracted deadlocks definition: the server-scoped vs +/// database-scoped ring-buffer reads (xml_deadlock_report vs database_xml_deadlock_report — the +/// latter run per database with a per-database watermark, #1535), the deadlock_time watermark with +/// its 10-minute fallback, the 6-column payload, the victim-inputbuf + currentdbname extraction +/// (victim match, first-process fallback, null/garbage tolerance), and the #1262 gated +/// best-effort victim plan resolution (off = byte-identical; on = the victim's frame → +/// dm_exec_query_stats → dm_exec_text_query_plan). +/// </summary> +public sealed class DeadlocksCollectorDefinitionTests +{ + private static readonly RecordingCollectorDeltaCalculator s_deltas = new(); + + private static string Collapse(string sql) => Regex.Replace(sql, @"\s+", ""); + + private static CollectorContext MakeContext( + bool isAzureSqlDb = false, + DateTime? watermark = null, + DateTime? collectionTime = null, + bool capturePlanXml = false) + => new() + { + ServerId = 42, + ServerName = "test-server", + CollectionTime = collectionTime ?? new DateTime(2026, 7, 2, 12, 0, 0, DateTimeKind.Utc), + Deltas = s_deltas, + Target = new CollectorTargetInfo { IsAzureSqlDb = isAzureSqlDb }, + Watermark = watermark, + CapturePlanXml = capturePlanXml, + }; + + [Fact] + public void Identity_AndSessionNamePinned() + { + Assert.Equal("deadlocks", DeadlocksCollector.Instance.Name); + Assert.Equal("deadlocks", DeadlocksCollector.Instance.TargetTable); + Assert.Equal("deadlock_time", DeadlocksCollector.Instance.WatermarkColumn); + Assert.Equal("PerformanceMonitor_Deadlock", DeadlocksCollector.XeSessionName); + } + + [Fact] + public void RunsPerDatabase_OnAzureOnly_WithPerDatabaseWatermark() + { + /* #1535: Azure capture is one database-scoped session per monitored database, so the read + loops databases and each database dedups against its own watermark. Server-scoped + platforms keep the single run + server-wide watermark. */ + Assert.True(DeadlocksCollector.Instance.RunsPerDatabase(new CollectorTargetInfo { IsAzureSqlDb = true })); + Assert.False(DeadlocksCollector.Instance.RunsPerDatabase(new CollectorTargetInfo())); + Assert.False(DeadlocksCollector.Instance.RunsPerDatabase(new CollectorTargetInfo { IsAzureManagedInstance = true })); + Assert.Equal("database_name", DeadlocksCollector.Instance.PerDatabaseWatermarkColumn); + } + + [Fact] + public void BuildQuery_ServerScoped_ReadsXmlDeadlockReport() + { + var plan = DeadlocksCollector.Instance.BuildQuery(MakeContext()); + + Assert.Contains("sys.dm_xe_session_targets", plan.Text, StringComparison.Ordinal); + Assert.Contains("JOIN sys.dm_xe_sessions AS xes", plan.Text, StringComparison.Ordinal); + Assert.Contains("N'PerformanceMonitor_Deadlock'", plan.Text, StringComparison.Ordinal); + Assert.Contains("event[@name=\"xml_deadlock_report\"]", plan.Text, StringComparison.Ordinal); + Assert.DoesNotContain("event[@name=\"database_xml_deadlock_report\"]", plan.Text, StringComparison.Ordinal); + Assert.DoesNotContain("dm_xe_database_session_targets", plan.Text, StringComparison.Ordinal); + Assert.Contains("> @cutoff_time", plan.Text, StringComparison.Ordinal); + } + + [Fact] + public void BuildQuery_Azure_ReadsDatabaseScopedEvent() + { + var plan = DeadlocksCollector.Instance.BuildQuery(MakeContext(isAzureSqlDb: true)); + + Assert.Contains("sys.dm_xe_database_session_targets", plan.Text, StringComparison.Ordinal); + Assert.Contains("JOIN sys.dm_xe_database_sessions AS xes", plan.Text, StringComparison.Ordinal); + Assert.Contains("event[@name=\"database_xml_deadlock_report\"]", plan.Text, StringComparison.Ordinal); + Assert.Contains("N'PerformanceMonitor_Deadlock'", plan.Text, StringComparison.Ordinal); + } + + [Fact] + public void BuildQuery_PinsWatermarkCutoff_AndTenMinuteFallback() + { + var watermark = new DateTime(2026, 7, 2, 11, 55, 0, DateTimeKind.Utc); + var withWatermark = DeadlocksCollector.Instance.BuildQuery(MakeContext(watermark: watermark)); + var parameter = Assert.Single(withWatermark.Parameters); + Assert.Equal("@cutoff_time", parameter.Name); + Assert.Equal(watermark, parameter.Value); + Assert.Equal(CollectorParameterType.DateTime2, parameter.Type); + + var collectionTime = new DateTime(2026, 7, 2, 12, 0, 0, DateTimeKind.Utc); + var fallback = DeadlocksCollector.Instance.BuildQuery(MakeContext(collectionTime: collectionTime)); + Assert.Equal(collectionTime.AddMinutes(-10), Assert.Single(fallback.Parameters).Value); + } + + [Fact] + public void PayloadColumns_SixColumns_MatchSchemaOrder() + { + /* database_name is appended LAST (after the #1262 plan column) so the v46/V27 ADD COLUMN + brings a pre-#1535 store up to shape with the positional appender still aligned. */ + var names = DeadlocksCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray(); + + Assert.Equal( + new[] { "deadlock_time", "victim_process_id", "victim_sql_text", "deadlock_graph_xml", "victim_query_plan_xml", "database_name" }, + names); + } + + [Fact] + public void BuildQuery_PlanCaptureOffByDefault_NoResolution_LiteParity() + { + /* Lite never sets CapturePlanXml: no plan column, no victim frame shred — byte-identical to + the no-plan form (server-scoped AND Azure). */ + var serverScoped = DeadlocksCollector.Instance.BuildQuery(MakeContext()); + var azure = DeadlocksCollector.Instance.BuildQuery(MakeContext(isAzureSqlDb: true)); + + foreach (var text in new[] { serverScoped.Text, azure.Text }) + { + Assert.DoesNotContain("query_plan", text, StringComparison.Ordinal); + Assert.DoesNotContain("dm_exec_text_query_plan", text, StringComparison.Ordinal); + Assert.DoesNotContain("/*DL_PLAN_SELECT*/", text, StringComparison.Ordinal); + Assert.DoesNotContain("/*DL_PLAN_APPLY*/", text, StringComparison.Ordinal); + } + } + + [Fact] + public void BuildQuery_PlanCaptureOn_ResolvesVictimFrameFromPlanCache() + { + /* Both variants share the fragment — the victim shred walks the identical + data[@name="xml_report"]/value/deadlock payload and matches the victim by id (forward + navigation only), then resolves via the proven #880 sql_handle + offsets → text-plan lookup. */ + foreach (var context in new[] { MakeContext(capturePlanXml: true), MakeContext(isAzureSqlDb: true, capturePlanXml: true) }) + { + var plan = DeadlocksCollector.Instance.BuildQuery(context); + + Assert.Contains("victim_query_plan_xml = vqp.query_plan", plan.Text, StringComparison.Ordinal); + Assert.Contains("victim-list/victimProcess/@id", plan.Text, StringComparison.Ordinal); + Assert.Contains( + "JOIN sys.dm_exec_query_stats AS qs\n ON qs.sql_handle = frames.frame_handle", + plan.Text.Replace("\r\n", "\n", StringComparison.Ordinal), StringComparison.Ordinal); + Assert.Contains("sys.dm_exec_text_query_plan", plan.Text, StringComparison.Ordinal); + } + } + + [Fact] + public async Task ReadAsync_PlanCaptureOn_CapturesTrailingVictimPlanColumn() + { + var context = MakeContext(capturePlanXml: true); + var deadlockTime = new DateTime(2026, 7, 2, 11, 58, 0, DateTimeKind.Utc); + + /* Flag on = the projection carries victim_query_plan_xml at ordinal 3. */ + using var reader = new FakeCollectorDataReader( + new object[] { deadlockTime, "process123", SampleGraphXml, "<ShowPlanXML>victim</ShowPlanXML>" }); + var rows = await DeadlocksCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + + var writer = new RecordingCollectorRowWriter(); + DeadlocksCollector.Instance.WritePayload(Assert.Single(rows), writer, context); + + Assert.Equal(6, writer.Values.Count); + Assert.Equal("<ShowPlanXML>victim</ShowPlanXML>", writer.Values[4]); + } + + private const string SampleGraphXml = @"<deadlock> + <victim-list><victimProcess id=""process123""/></victim-list> + <process-list> + <process id=""process123""><inputbuf> UPDATE t SET x = 1; </inputbuf></process> + <process id=""process456""><inputbuf> SELECT 1; </inputbuf></process> + </process-list> +</deadlock>"; + + /* The modern graph shape: process nodes carry currentdbname (SQL 2019+/Azure SQL DB). */ + private const string SampleGraphXmlWithDb = @"<deadlock> + <victim-list><victimProcess id=""process123""/></victim-list> + <process-list> + <process id=""process123"" currentdb=""6"" currentdbname=""salesdb""><inputbuf> UPDATE t SET x = 1; </inputbuf></process> + <process id=""process456"" currentdb=""7"" currentdbname=""ordersdb""><inputbuf> SELECT 1; </inputbuf></process> + </process-list> +</deadlock>"; + + [Fact] + public void ExtractVictimSqlText_MatchesVictim_FallsBackToFirstProcess_ToleratesGarbage() + { + Assert.Equal("UPDATE t SET x = 1;", DeadlocksCollector.ExtractVictimSqlText(SampleGraphXml, "process123")); + Assert.Equal("SELECT 1;", DeadlocksCollector.ExtractVictimSqlText(SampleGraphXml, "PROCESS456")); /* case-insensitive id match */ + Assert.Equal("UPDATE t SET x = 1;", DeadlocksCollector.ExtractVictimSqlText(SampleGraphXml, "no-such-process")); + Assert.Equal("UPDATE t SET x = 1;", DeadlocksCollector.ExtractVictimSqlText(SampleGraphXml, null)); + Assert.Null(DeadlocksCollector.ExtractVictimSqlText(null, "process123")); + Assert.Null(DeadlocksCollector.ExtractVictimSqlText("", "process123")); + Assert.Null(DeadlocksCollector.ExtractVictimSqlText("<not-xml", "process123")); + } + + [Fact] + public void ExtractVictimFields_ReadsCurrentDbName_NullWhenAbsent() + { + /* One parse yields both fields; the victim's OWN database, not the first process's. */ + Assert.Equal(("SELECT 1;", "ordersdb"), DeadlocksCollector.ExtractVictimFields(SampleGraphXmlWithDb, "process456")); + Assert.Equal(("UPDATE t SET x = 1;", "salesdb"), DeadlocksCollector.ExtractVictimFields(SampleGraphXmlWithDb, "process123")); + + /* Fallback-to-first-process carries that process's database too. */ + Assert.Equal(("UPDATE t SET x = 1;", "salesdb"), DeadlocksCollector.ExtractVictimFields(SampleGraphXmlWithDb, "no-such-process")); + + /* Engines that don't emit currentdbname yield null, never a fabricated name. */ + Assert.Equal(("UPDATE t SET x = 1;", null), DeadlocksCollector.ExtractVictimFields(SampleGraphXml, "process123")); + Assert.Equal((null, null), DeadlocksCollector.ExtractVictimFields(null, "process123")); + Assert.Equal((null, null), DeadlocksCollector.ExtractVictimFields("<not-xml", "process123")); + } + + [Fact] + public async Task ReadAsync_PerDatabasePath_CaptureDatabaseWinsOverGraphValue() + { + /* On the Azure per-database read path the host stamps context.CurrentDatabaseName per + iteration, and it is authoritative — a database-scoped session only captures its own + database — beating the graph's currentdbname AND covering graphs that carry none (a + null database_name row could never advance that database's watermark, so the same + ring-buffer event would re-insert every cycle). */ + var context = MakeContext(isAzureSqlDb: true); + context.CurrentDatabaseName = "ringdb"; + + using var reader = new FakeCollectorDataReader( + new object[] { new DateTime(2026, 7, 2, 11, 58, 0, DateTimeKind.Utc), "process123", SampleGraphXmlWithDb }, + new object[] { new DateTime(2026, 7, 2, 11, 59, 0, DateTimeKind.Utc), "process123", SampleGraphXml }); + var rows = await DeadlocksCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + + Assert.Equal(2, rows.Count); + Assert.Equal("ringdb", rows[0].DatabaseName); /* graph said salesdb — capture DB wins */ + Assert.Equal("ringdb", rows[1].DatabaseName); /* graph carried no currentdbname at all */ + } + + [Fact] + public async Task ReadAsync_WritePayload_PinsSixColumnOrder_AndVictimExtraction() + { + var context = MakeContext(); + var deadlockTime = new DateTime(2026, 7, 2, 11, 58, 0, DateTimeKind.Utc); + + using var reader = new FakeCollectorDataReader( + new object[] { deadlockTime, "process123", SampleGraphXmlWithDb }, + new object[] { DBNull.Value, DBNull.Value, DBNull.Value }); + var rows = await DeadlocksCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + + Assert.Equal(2, rows.Count); + + var writer = new RecordingCollectorRowWriter(); + DeadlocksCollector.Instance.WritePayload(rows[0], writer, context); + + Assert.Equal(6, writer.Values.Count); + Assert.Equal(deadlockTime, writer.Values[0]); + Assert.Equal("process123", writer.Values[1]); + Assert.Equal("UPDATE t SET x = 1;", writer.Values[2]); /* extracted in the read phase */ + Assert.Equal(SampleGraphXmlWithDb, writer.Values[3]); + Assert.Null(writer.Values[4]); /* victim_query_plan_xml null when flag off */ + Assert.Equal("salesdb", writer.Values[5]); /* the victim's currentdbname (#1535) */ + + /* An all-NULL event row still writes (nothing filters it), exactly as the original. */ + var nullWriter = new RecordingCollectorRowWriter(); + DeadlocksCollector.Instance.WritePayload(rows[1], nullWriter, context); + Assert.Equal(6, nullWriter.Values.Count); + Assert.All(nullWriter.Values, Assert.Null); + Assert.Empty(s_deltas.Calls); + } +} diff --git a/Lite.Tests/DefaultTraceEventSignificanceTests.cs b/Lite.Tests/DefaultTraceEventSignificanceTests.cs new file mode 100644 index 000000000..7fae6dc57 --- /dev/null +++ b/Lite.Tests/DefaultTraceEventSignificanceTests.cs @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using PerformanceMonitor.Common; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Pins the shared Default Trace significant-set gate (PerformanceMonitor.Common) — the ONE classifier both +/// the viewer's System Events surface and the headless get_default_trace_events MCP tool reference. The +/// collector already curated the event set server-side; the ONE refinement this layer adds is the ErrorLog +/// severity floor, so every non-ErrorLog curated category is significant as collected and only ErrorLog is +/// severity-gated (a null severity kept, not dropped). +/// </summary> +public sealed class DefaultTraceEventSignificanceTests +{ + [Theory] + [InlineData("Server Memory Change", DefaultTraceEventCategory.MemoryChange)] + [InlineData("Data File Auto Grow", DefaultTraceEventCategory.AutoGrowShrink)] + [InlineData("Log File Auto Grow", DefaultTraceEventCategory.AutoGrowShrink)] + [InlineData("Data File Auto Shrink", DefaultTraceEventCategory.AutoGrowShrink)] + [InlineData("Log File Auto Shrink", DefaultTraceEventCategory.AutoGrowShrink)] + [InlineData("ErrorLog", DefaultTraceEventCategory.ErrorLog)] + [InlineData("Object:Created", DefaultTraceEventCategory.SchemaChange)] + [InlineData("Object:Altered", DefaultTraceEventCategory.SchemaChange)] + [InlineData("Object:Deleted", DefaultTraceEventCategory.SchemaChange)] + [InlineData("Audit Change Audit Event", DefaultTraceEventCategory.SecurityAudit)] + [InlineData("Audit DBCC Event", DefaultTraceEventCategory.SecurityAudit)] + [InlineData("Audit Server Alter Trace Event", DefaultTraceEventCategory.SecurityAudit)] + [InlineData("Some Unknown Event", DefaultTraceEventCategory.Other)] + [InlineData("", DefaultTraceEventCategory.Other)] + [InlineData(null, DefaultTraceEventCategory.Other)] + public void Classify_MapsEventNameToCategory(string? eventName, DefaultTraceEventCategory expected) + { + Assert.Equal(expected, DefaultTraceEventSignificance.Classify(eventName)); + } + + [Fact] + public void SevereErrorLogFloor_IsSixteen() + { + Assert.Equal(16, DefaultTraceEventSignificance.SevereErrorLogMinSeverity); + } + + [Theory] + [InlineData(19, true)] /* fatal-range error: significant */ + [InlineData(16, true)] /* exactly the floor: significant */ + [InlineData(15, false)] /* below the floor: routine, dropped */ + [InlineData(0, false)] /* informational log write: dropped */ + [InlineData(null, true)] /* no severity node: kept rather than silently dropped */ + public void IsSignificant_ErrorLog_GatedBySeverity(int? severity, bool expected) + { + Assert.Equal(expected, DefaultTraceEventSignificance.IsSignificant("ErrorLog", severity)); + Assert.Equal(expected, DefaultTraceEventSignificance.IsSignificant(DefaultTraceEventCategory.ErrorLog, severity)); + } + + [Theory] + [InlineData("Data File Auto Grow")] + [InlineData("Object:Altered")] + [InlineData("Audit DBCC Event")] + [InlineData("Server Memory Change")] + public void IsSignificant_NonErrorLogCategories_AlwaysSignificant_RegardlessOfSeverity(string eventName) + { + /* These carry no severity, but even a below-floor value must not gate them — only ErrorLog is gated. */ + Assert.True(DefaultTraceEventSignificance.IsSignificant(eventName, null)); + Assert.True(DefaultTraceEventSignificance.IsSignificant(eventName, 0)); + } +} diff --git a/Lite.Tests/DefaultTraceEventsCollectorDefinitionTests.cs b/Lite.Tests/DefaultTraceEventsCollectorDefinitionTests.cs new file mode 100644 index 000000000..7c432a798 --- /dev/null +++ b/Lite.Tests/DefaultTraceEventsCollectorDefinitionTests.cs @@ -0,0 +1,262 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Pins the cross-SKU parity contract of the shared default_trace_events collector definition (the built-in +/// Default Trace read via sys.fn_trace_gettable): the server-side rollover-file read with the base-path +/// normalization, the CURATED event set and its four must-handles — the config-change slice DROPPED +/// (Darling's config-snapshot diff owns it), the auto-grow/shrink stall gate, the tempdb / _WA_ DDL +/// exclusions — the event_time watermark with its all-history FIRST-run cutoff (NOT the XE collectors' +/// 10-minute fallback), the collect-everywhere-but-Azure-SQL-DB AppliesTo, the per-server excluded-database +/// splice, the 21-column payload, and the null-tolerant ReadAsync/WritePayload. An intentional change to any +/// of these must consciously update this test. +/// </summary> +public sealed class DefaultTraceEventsCollectorDefinitionTests +{ + private static readonly RecordingCollectorDeltaCalculator s_deltas = new(); + + private static CollectorContext MakeContext( + bool isAzureSqlDb = false, + bool isAzureManagedInstance = false, + DateTime? watermark = null, + DateTime? collectionTime = null, + string[]? excludedDatabases = null, + bool hasCollectedBefore = false, + bool collectSchemaChanges = true) + => new() + { + ServerId = 42, + ServerName = "test-server", + CollectionTime = collectionTime ?? new DateTime(2026, 7, 9, 12, 0, 0, DateTimeKind.Utc), + Deltas = s_deltas, + Target = new CollectorTargetInfo { IsAzureSqlDb = isAzureSqlDb, IsAzureManagedInstance = isAzureManagedInstance }, + Watermark = watermark, + HasCollectedBefore = hasCollectedBefore, + ExcludedDatabases = excludedDatabases ?? Array.Empty<string>(), + CollectSchemaChangeEvents = collectSchemaChanges, + }; + + [Fact] + public void Identity_Pinned() + { + Assert.Equal("default_trace_events", DefaultTraceEventsCollector.Instance.Name); + Assert.Equal("default_trace_events", DefaultTraceEventsCollector.Instance.TargetTable); + Assert.Equal("default_trace_event_id", DefaultTraceEventsCollector.Instance.PrefixIdColumnName); + Assert.Equal("event_time", DefaultTraceEventsCollector.Instance.WatermarkColumn); + } + + [Fact] + public void BuildQuery_ReadsDefaultTraceViaFnTraceGettable_ServerSide() + { + var text = DefaultTraceEventsCollector.Instance.BuildQuery(MakeContext()).Text; + + Assert.Contains("sys.traces", text, StringComparison.Ordinal); + Assert.Contains("sys.fn_trace_gettable", text, StringComparison.Ordinal); + Assert.Contains("sys.trace_events", text, StringComparison.Ordinal); + /* Base-path normalization so ALL rollover files are read (strip _NN, re-append the extension). */ + Assert.Contains("REVERSE(t.path)", text, StringComparison.Ordinal); + Assert.Contains("t.max_files", text, StringComparison.Ordinal); + Assert.Contains("t.is_default = 1", text, StringComparison.Ordinal); + Assert.Contains("t.status = 1", text, StringComparison.Ordinal); + Assert.Contains("ft.StartTime > @cutoff_time", text, StringComparison.Ordinal); + Assert.Contains("SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED", text, StringComparison.Ordinal); + Assert.Contains("OPTION(RECOMPILE)", text, StringComparison.Ordinal); + } + + [Fact] + public void BuildQuery_CapturesTheCuratedEventSet() + { + var text = DefaultTraceEventsCollector.Instance.BuildQuery(MakeContext()).Text; + + Assert.Contains("Server Memory Change", text, StringComparison.Ordinal); + Assert.Contains("Data File Auto Grow", text, StringComparison.Ordinal); + Assert.Contains("Log File Auto Grow", text, StringComparison.Ordinal); + Assert.Contains("Data File Auto Shrink", text, StringComparison.Ordinal); + Assert.Contains("Log File Auto Shrink", text, StringComparison.Ordinal); + Assert.Contains("N'ErrorLog'", text, StringComparison.Ordinal); + Assert.Contains("N'Object:Created'", text, StringComparison.Ordinal); + Assert.Contains("N'Object:Altered'", text, StringComparison.Ordinal); + Assert.Contains("N'Object:Deleted'", text, StringComparison.Ordinal); + Assert.Contains("N'Audit Change Audit Event'", text, StringComparison.Ordinal); + Assert.Contains("N'Audit DBCC Event'", text, StringComparison.Ordinal); + Assert.Contains("N'Audit Server Alter Trace Event'", text, StringComparison.Ordinal); + + /* The auto-grow/shrink stall gate (over one second) and the noise exclusions. */ + Assert.Contains("ISNULL(ft.Duration, 0) > 1000000", text, StringComparison.Ordinal); + Assert.Contains("ISNULL(ft.DatabaseID, 0) NOT IN (1, 3, 4)", text, StringComparison.Ordinal); /* master/model/msdb */ + Assert.Contains("< 32761", text, StringComparison.Ordinal); /* AG system DBs */ + Assert.Contains("ISNULL(ft.DatabaseID, 0) <> 2", text, StringComparison.Ordinal); /* tempdb DDL */ + Assert.Contains("NOT LIKE N'[_]WA[_]%'", text, StringComparison.Ordinal); /* auto-stats DDL */ + } + + [Fact] + public void BuildQuery_DropsTheConfigChangeSlice_DarlingSnapshotDiffOwnsIt() + { + /* Must-handle: the config-change events (Server / Database Configuration Change, Alter Database) the + Dashboard collector included are DROPPED here so Darling's server/database/trace-flag change + tracking (the config-snapshot diff) is not double-counted. */ + var text = DefaultTraceEventsCollector.Instance.BuildQuery(MakeContext()).Text; + + Assert.DoesNotContain("Configuration Change", text, StringComparison.Ordinal); + Assert.DoesNotContain("Alter Database", text, StringComparison.Ordinal); + } + + [Fact] + public void BuildQuery_Watermark_AndFirstRunGuard_TrueFirstRunVsArchivalEmpty() + { + /* Steady state: the watermark is used verbatim. */ + var watermark = new DateTime(2026, 7, 9, 11, 55, 0, DateTimeKind.Utc); + var withWatermark = DefaultTraceEventsCollector.Instance.BuildQuery(MakeContext(watermark: watermark)); + var cutoff = withWatermark.Parameters.Single(p => p.Name == "@cutoff_time"); + Assert.Equal(watermark, cutoff.Value); + Assert.Equal(CollectorParameterType.DateTime2, cutoff.Type); + + var collectionTime = new DateTime(2026, 7, 9, 12, 0, 0, DateTimeKind.Utc); + + /* TRUE first run (no watermark, never succeeded): collect EVERYTHING still on disk — a far-past + sentinel, NOT the XE collectors' collectionTime.AddMinutes(-10). */ + var firstRun = DefaultTraceEventsCollector.Instance.BuildQuery( + MakeContext(collectionTime: collectionTime, hasCollectedBefore: false)); + var firstCutoff = (DateTime)firstRun.Parameters.Single(p => p.Name == "@cutoff_time").Value!; + Assert.Equal(new DateTime(1900, 1, 1, 0, 0, 0, DateTimeKind.Utc), firstCutoff); + Assert.NotEqual(collectionTime.AddMinutes(-10), firstCutoff); + + /* Archival-emptied hot store (no watermark, but HAS succeeded before): a BOUNDED recent window + (24 h), NEVER all-history — so we don't re-scan .trc events already in the parquet archive, which + v_default_trace_events (hot UNION parquet, no dedup) would double-count. The Dashboard's guard. */ + var archivalEmpty = DefaultTraceEventsCollector.Instance.BuildQuery( + MakeContext(collectionTime: collectionTime, hasCollectedBefore: true)); + var boundedCutoff = (DateTime)archivalEmpty.Parameters.Single(p => p.Name == "@cutoff_time").Value!; + Assert.Equal(collectionTime.AddHours(-24), boundedCutoff); + Assert.NotEqual(new DateTime(1900, 1, 1, 0, 0, 0, DateTimeKind.Utc), boundedCutoff); + } + + [Fact] + public void BuildQuery_ExcludedDatabases_IsNullSafe_KeepsServerLevelEvents() + { + var noExclusion = DefaultTraceEventsCollector.Instance.BuildQuery(MakeContext()); + Assert.DoesNotContain("@excl_db_0", noExclusion.Text, StringComparison.Ordinal); + Assert.Equal(2, noExclusion.Parameters.Count); /* @cutoff_time + @include_object_ddl */ + + var withExclusion = DefaultTraceEventsCollector.Instance.BuildQuery( + MakeContext(excludedDatabases: new[] { "ReportingDB", "ScratchDB" })); + + /* NULL-safe: a server-level event with a NULL DatabaseName (Server Memory Change, server-scoped + audit/ErrorLog) is KEPT — a bare `NOT IN` would evaluate NULL to UNKNOWN and silently drop the + whole category whenever any database is excluded. */ + Assert.Contains( + "AND (ft.DatabaseName IS NULL OR ft.DatabaseName NOT IN (@excl_db_0, @excl_db_1))", + withExclusion.Text, StringComparison.Ordinal); + Assert.DoesNotContain("AND ft.DatabaseName NOT IN", withExclusion.Text, StringComparison.Ordinal); /* never the bare form */ + Assert.Equal(4, withExclusion.Parameters.Count); /* @cutoff_time + @include_object_ddl + two excluded names */ + Assert.Contains(withExclusion.Parameters, p => p.Name == "@excl_db_0" && (string?)p.Value == "ReportingDB"); + Assert.Contains(withExclusion.Parameters, p => p.Name == "@excl_db_1" && (string?)p.Value == "ScratchDB"); + } + + [Fact] + public void BuildQuery_ObjectDdlGate_OnByDefault_SuppressedWhenFlagOff() + { + /* Default (CollectSchemaChangeEvents = true — Lite's unchanged behavior): the Object DDL slice is + gated ON via a bound @include_object_ddl = 1, so Object:Created/Altered/Deleted events are collected. */ + var on = DefaultTraceEventsCollector.Instance.BuildQuery(MakeContext()); + Assert.Contains("@include_object_ddl = 1", on.Text, StringComparison.Ordinal); + var onParameter = on.Parameters.Single(p => p.Name == "@include_object_ddl"); + Assert.Equal(1, onParameter.Value); + Assert.Equal(CollectorParameterType.Int32, onParameter.Type); + + /* Flag off (Darling's darling.json collectSchemaChangeEvents = false on a noisy/benchmark box, the + shared collector's equivalent of the Dashboard proc's @include_object_events): the SAME gate binds + 0, dropping the schema-DDL category. It is a bound parameter, not a text fork — so the generated SQL + is byte-identical either way and the other curated categories are untouched. */ + var off = DefaultTraceEventsCollector.Instance.BuildQuery(MakeContext(collectSchemaChanges: false)); + Assert.Equal(0, off.Parameters.Single(p => p.Name == "@include_object_ddl").Value); + Assert.Equal(on.Text, off.Text); + } + + [Fact] + public void AppliesTo_CollectsEverywhereExceptAzureSqlDb() + { + /* No default trace on Azure SQL DB; Managed Instance and on-prem / RDS have it. */ + Assert.False(DefaultTraceEventsCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureSqlDb = true })); + Assert.True(DefaultTraceEventsCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureManagedInstance = true })); + Assert.True(DefaultTraceEventsCollector.Instance.AppliesTo(new CollectorTargetInfo())); + } + + [Fact] + public void PayloadColumns_TwentyOneColumns_InAppendOrder() + { + var columns = DefaultTraceEventsCollector.Instance.PayloadColumns; + + Assert.Equal(new[] + { + "event_time", "event_name", "event_class", "spid", "database_name", "database_id", + "login_name", "host_name", "application_name", "object_name", "filename", + "integer_data", "integer_data_2", "text_data", "session_login_name", + "error_number", "severity", "state", "event_sequence", "duration_us", "end_time", + }, columns.Select(c => c.Name).ToArray()); + + Assert.Equal(CollectorColumnType.Timestamp, columns.Single(c => c.Name == "event_time").Type); + Assert.Equal(CollectorColumnType.Varchar, columns.Single(c => c.Name == "event_name").Type); + Assert.Equal(CollectorColumnType.Integer, columns.Single(c => c.Name == "event_class").Type); + Assert.Equal(CollectorColumnType.BigInt, columns.Single(c => c.Name == "integer_data").Type); + Assert.Equal(CollectorColumnType.BigInt, columns.Single(c => c.Name == "duration_us").Type); + Assert.Equal(CollectorColumnType.Timestamp, columns.Single(c => c.Name == "end_time").Type); + } + + [Fact] + public async Task ReadAsync_WritePayload_PinsTwentyOneColumnOrder_AndToleratesNulls() + { + var context = MakeContext(); + var eventTime = new DateTime(2026, 7, 9, 11, 58, 0, DateTimeKind.Utc); + var endTime = new DateTime(2026, 7, 9, 11, 58, 1, DateTimeKind.Utc); + + var fullRow = new object[] + { + eventTime, "Data File Auto Grow", 92, 55, "SalesDB", 7, "app_login", "APPHOST", + "SqlClient", (object)DBNull.Value, "S:\\data\\SalesDB.mdf", 128L, 0L, (object)DBNull.Value, + "app_login", (object)DBNull.Value, (object)DBNull.Value, (object)DBNull.Value, 900001L, 1500000L, endTime, + }; + var nullRow = Enumerable.Repeat((object)DBNull.Value, 21).ToArray(); + + using var reader = new FakeCollectorDataReader(fullRow, nullRow); + var rows = await DefaultTraceEventsCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + Assert.Equal(2, rows.Count); + + var writer = new RecordingCollectorRowWriter(); + DefaultTraceEventsCollector.Instance.WritePayload(rows[0], writer, context); + + Assert.Equal(21, writer.Values.Count); + Assert.Equal(eventTime, writer.Values[0]); + Assert.Equal("Data File Auto Grow", writer.Values[1]); + Assert.Equal(92, writer.Values[2]); + Assert.Equal(55, writer.Values[3]); + Assert.Equal("SalesDB", writer.Values[4]); + Assert.Equal(7, writer.Values[5]); + Assert.Null(writer.Values[9]); /* object_name NULL */ + Assert.Equal(128L, writer.Values[11]); /* integer_data (pages grown) */ + Assert.Equal(1500000L, writer.Values[19]); /* duration_us (a stall > 1 s) */ + Assert.Equal(endTime, writer.Values[20]); + + /* An all-NULL row still writes 21 values; capture is a point-in-time read with no delta interaction. */ + var nullWriter = new RecordingCollectorRowWriter(); + DefaultTraceEventsCollector.Instance.WritePayload(rows[1], nullWriter, context); + Assert.Equal(21, nullWriter.Values.Count); + Assert.All(nullWriter.Values, Assert.Null); + Assert.Empty(s_deltas.Calls); + } +} diff --git a/Lite.Tests/DuckDbSchemaEquivalenceTests.cs b/Lite.Tests/DuckDbSchemaEquivalenceTests.cs new file mode 100644 index 000000000..972136a9b --- /dev/null +++ b/Lite.Tests/DuckDbSchemaEquivalenceTests.cs @@ -0,0 +1,236 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using DuckDB.NET.Data; +using PerformanceMonitor.Collectors; +using PerformanceMonitorLite.Database; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// <summary> +/// THE data-safety proof for the catalog-driven collector schema: for every one of the 35 collector +/// tables, the DDL <see cref="DuckDbSchemaGenerator"/> now generates must produce a DuckDB table that +/// is byte-for-byte STORAGE-equivalent to the hand-written table it replaced — identical columns, +/// DuckDB types, column order, NOT NULL flags, DEFAULT values, and PRIMARY KEY, plus an identical +/// index. Equivalence is demonstrated empirically by executing BOTH the frozen pre-change DDL +/// (<see cref="GoldenCollectorSchema"/>) and the freshly generated DDL against DuckDB and comparing +/// the resulting <c>PRAGMA table_info</c> row-for-row. +/// +/// <para>This is what guarantees an existing DuckDB store (whose tables were created by the old +/// hand-written DDL) stays compatible with the appender the app writes through, and that a fresh +/// store built by the generator is indistinguishable from one built the old way. A single divergence +/// — a dropped column, a widened type, a lost NOT NULL, a reordered column — fails the build with an +/// exact per-column diff. If it ever fails, the generator is wrong and must be fixed to match the +/// golden snapshot; the snapshot is the authority and must never be edited to accept a difference.</para> +/// </summary> +public class DuckDbSchemaEquivalenceTests : IDisposable +{ + private readonly string _tempDir; + private readonly string _dbPath; + + public DuckDbSchemaEquivalenceTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "LiteEquiv_" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(_tempDir); + _dbPath = Path.Combine(_tempDir, "equiv.duckdb"); + } + + public void Dispose() + { + try + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + catch + { + /* Best-effort cleanup */ + } + } + + /// <summary>A single PRAGMA table_info row — everything DuckDB records about a column's storage shape.</summary> + private readonly record struct ColumnInfo(int Cid, string Name, string Type, bool NotNull, string? Default, bool Pk) + { + public override string ToString() => + $"[{Cid}] {Name} {Type}{(NotNull ? " NOT NULL" : "")}{(Default is null ? "" : $" DEFAULT {Default}")}{(Pk ? " PK" : "")}"; + } + + /// <summary>Creates the table from <paramref name="ddl"/> in a clean slate and returns its table_info.</summary> + private static List<ColumnInfo> TableInfo(DuckDBConnection conn, string ddl, string table) + { + using (var drop = conn.CreateCommand()) + { + drop.CommandText = $"DROP TABLE IF EXISTS {table}"; + drop.ExecuteNonQuery(); + } + + using (var create = conn.CreateCommand()) + { + create.CommandText = ddl; + create.ExecuteNonQuery(); + } + + var rows = new List<ColumnInfo>(); + using (var info = conn.CreateCommand()) + { + info.CommandText = $"PRAGMA table_info('{table}')"; + using var reader = info.ExecuteReader(); + while (reader.Read()) + { + rows.Add(new ColumnInfo( + Cid: Convert.ToInt32(reader.GetValue(0)), + Name: reader.GetValue(1).ToString()!, + Type: reader.GetValue(2).ToString()!, + NotNull: Convert.ToBoolean(reader.GetValue(3)), + Default: reader.IsDBNull(4) ? null : reader.GetValue(4).ToString(), + Pk: Convert.ToBoolean(reader.GetValue(5)))); + } + } + + return rows; + } + + /// <summary>Collapses whitespace so two equivalent index statements compare equal regardless of formatting.</summary> + private static string? NormalizeSql(string? sql) => + sql is null ? null : Regex.Replace(sql.Trim(), @"\s+", " "); + + [Fact] + public void Golden_CoversExactlyTheCatalogCollectorTables() + { + var catalogTables = CollectorCatalog.All.Select(c => c.TargetTable).OrderBy(t => t, StringComparer.Ordinal); + var goldenTables = GoldenCollectorSchema.Tables.Keys.OrderBy(t => t, StringComparer.Ordinal); + + /* The frozen oracle must describe exactly the 36 catalog collector tables — no more, no fewer. */ + Assert.Equal(catalogTables, goldenTables); + Assert.Equal(36, GoldenCollectorSchema.Tables.Count); + + /* Only server_config and database_config lack an index (matches DuckDbSchemaGenerator.CreateIndex). */ + var goldenIndexless = CollectorCatalog.All + .Select(c => c.TargetTable) + .Where(t => !GoldenCollectorSchema.Indexes.ContainsKey(t)) + .OrderBy(t => t, StringComparer.Ordinal) + .ToArray(); + Assert.Equal(new[] { "database_config", "server_config" }, goldenIndexless); + } + + [Fact] + public void GeneratedCollectorTables_AreStorageEquivalentToPreChangeHandWritten() + { + var failures = new List<string>(); + + using var conn = new DuckDBConnection($"Data Source={_dbPath}"); + conn.Open(); + + foreach (var schema in CollectorCatalog.All) + { + var table = schema.TargetTable; + + var goldenDdl = GoldenCollectorSchema.Tables[table]; + var generatedDdl = DuckDbSchemaGenerator.CreateTable(schema); + + var goldenInfo = TableInfo(conn, goldenDdl, table); + var generatedInfo = TableInfo(conn, generatedDdl, table); + + if (!goldenInfo.SequenceEqual(generatedInfo)) + { + failures.Add(BuildTableDiff(table, goldenInfo, generatedInfo)); + } + } + + Assert.True( + failures.Count == 0, + "Generated collector table(s) diverge in STORAGE SHAPE from the pre-change hand-written schema. " + + "The generator is wrong — fix it to match the golden snapshot; do NOT edit the snapshot.\n\n" + + string.Join("\n\n", failures)); + } + + [Fact] + public void GeneratedCollectorIndexes_MatchPreChangeHandWritten() + { + var failures = new List<string>(); + + foreach (var schema in CollectorCatalog.All) + { + var table = schema.TargetTable; + var generated = DuckDbSchemaGenerator.CreateIndex(schema); + GoldenCollectorSchema.Indexes.TryGetValue(table, out var golden); + + if (NormalizeSql(golden) != NormalizeSql(generated)) + { + failures.Add($"{table}: index DDL differs.\n golden : {golden ?? "(none)"}\n generated: {generated ?? "(none)"}"); + } + } + + Assert.True( + failures.Count == 0, + "Generated collector index(es) diverge from the pre-change hand-written schema:\n\n" + + string.Join("\n", failures)); + } + + /// <summary> + /// Belt-and-suspenders: executing every generated index against a real table must succeed (proves + /// the reproduced irregular names / columns reference real columns and DuckDB accepts them). + /// </summary> + [Fact] + public void GeneratedSchema_TablesAndIndexes_AllExecuteAgainstDuckDb() + { + using var conn = new DuckDBConnection($"Data Source={_dbPath}"); + conn.Open(); + + foreach (var schema in CollectorCatalog.All) + { + using (var t = conn.CreateCommand()) + { + t.CommandText = DuckDbSchemaGenerator.CreateTable(schema); + t.ExecuteNonQuery(); + } + + var index = DuckDbSchemaGenerator.CreateIndex(schema); + if (index is not null) + { + using var i = conn.CreateCommand(); + i.CommandText = index; + i.ExecuteNonQuery(); + } + } + + /* Every generated table now exists in one database — the same end state InitializeAsync reaches. */ + using var count = conn.CreateCommand(); + count.CommandText = + "SELECT COUNT(*) FROM information_schema.tables WHERE table_name IN (" + + string.Join(",", CollectorCatalog.All.Select(c => $"'{c.TargetTable}'")) + ")"; + Assert.Equal(36, Convert.ToInt32(count.ExecuteScalar())); + } + + private static string BuildTableDiff(string table, List<ColumnInfo> golden, List<ColumnInfo> generated) + { + var sb = new System.Text.StringBuilder(); + sb.Append("TABLE ").Append(table).Append(" — golden(").Append(golden.Count) + .Append(" cols) vs generated(").Append(generated.Count).Append(" cols):"); + + var max = Math.Max(golden.Count, generated.Count); + for (int i = 0; i < max; i++) + { + var g = i < golden.Count ? golden[i] : (ColumnInfo?)null; + var n = i < generated.Count ? generated[i] : (ColumnInfo?)null; + if (!Nullable.Equals(g, n)) + { + sb.Append("\n golden : ").Append(g?.ToString() ?? "(missing)"); + sb.Append("\n generated: ").Append(n?.ToString() ?? "(missing)"); + } + } + + return sb.ToString(); + } +} diff --git a/Lite.Tests/DuckDbSchemaGeneratorTests.cs b/Lite.Tests/DuckDbSchemaGeneratorTests.cs new file mode 100644 index 000000000..694bc5d21 --- /dev/null +++ b/Lite.Tests/DuckDbSchemaGeneratorTests.cs @@ -0,0 +1,269 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using PerformanceMonitor.Collectors; +using PerformanceMonitorLite.Database; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// <summary> +/// Pins Lite's DuckDB collector schema against the shared collector catalog — the DuckDB twin of +/// Darling's <c>PgSchemaGeneratorTests</c>. The type map uses DuckDB spellings, the prefix / index +/// shapes reproduce Lite's historical schema exactly, and the FORCING-FUNCTION test asserts every +/// generated table's columns + types equal the catalog definition, so a catalog change necessarily +/// flows into Lite and no hand-edit can silently diverge. (Storage-equivalence to the former +/// hand-written tables is proven separately by <see cref="DuckDbSchemaEquivalenceTests"/>.) +/// </summary> +public sealed class DuckDbSchemaGeneratorTests +{ + [Fact] + public void TypeFor_MapsEveryColumnTypeToItsDuckDbSpelling() + { + Assert.Equal("BIGINT", DuckDbSchemaGenerator.TypeFor(new CollectorColumn("c", CollectorColumnType.BigInt))); + Assert.Equal("INTEGER", DuckDbSchemaGenerator.TypeFor(new CollectorColumn("c", CollectorColumnType.Integer))); + Assert.Equal("SMALLINT", DuckDbSchemaGenerator.TypeFor(new CollectorColumn("c", CollectorColumnType.SmallInt))); + Assert.Equal("VARCHAR", DuckDbSchemaGenerator.TypeFor(new CollectorColumn("c", CollectorColumnType.Varchar))); + Assert.Equal("TIMESTAMP", DuckDbSchemaGenerator.TypeFor(new CollectorColumn("c", CollectorColumnType.Timestamp))); + Assert.Equal("DOUBLE", DuckDbSchemaGenerator.TypeFor(new CollectorColumn("c", CollectorColumnType.Double))); + Assert.Equal("DECIMAL(18,2)", DuckDbSchemaGenerator.TypeFor(new CollectorColumn("c", CollectorColumnType.Decimal, 18, 2))); + Assert.Equal("DECIMAL(5,2)", DuckDbSchemaGenerator.TypeFor(new CollectorColumn("c", CollectorColumnType.Decimal, 5, 2))); + Assert.Equal("DECIMAL(38,2)", DuckDbSchemaGenerator.TypeFor(new CollectorColumn("c", CollectorColumnType.Decimal, 38, 2))); + Assert.Equal("DECIMAL(10,1)", DuckDbSchemaGenerator.TypeFor(new CollectorColumn("c", CollectorColumnType.Decimal, 10, 1))); + Assert.Equal("BOOLEAN", DuckDbSchemaGenerator.TypeFor(new CollectorColumn("c", CollectorColumnType.Boolean))); + Assert.Throws<InvalidOperationException>( + () => DuckDbSchemaGenerator.TypeFor(new CollectorColumn("c", CollectorColumnType.Decimal))); + } + + [Fact] + public void CreateTable_WaitStats_FullDdlPinned() + { + var ddl = DuckDbSchemaGenerator.CreateTable(WaitStatsCollector.Instance); + + Assert.Equal( + "CREATE TABLE IF NOT EXISTS wait_stats (\n" + + " collection_id BIGINT PRIMARY KEY,\n" + + " collection_time TIMESTAMP NOT NULL,\n" + + " server_id INTEGER NOT NULL,\n" + + " server_name VARCHAR NOT NULL,\n" + + " wait_type VARCHAR NOT NULL,\n" + + " waiting_tasks_count BIGINT,\n" + + " wait_time_ms BIGINT,\n" + + " signal_wait_time_ms BIGINT,\n" + + " delta_waiting_tasks BIGINT,\n" + + " delta_wait_time_ms BIGINT,\n" + + " delta_signal_wait_time_ms BIGINT\n" + + ")", + ddl); + } + + [Fact] + public void CreateTable_RunningJobs_NoIdPrefix_WithNotNullOverlayAndDecimal() + { + /* No prefix id (running_jobs is keyed by (collection_time, server) alone), the NOT NULL overlay + on the always-populated columns, and percent_of_average DECIMAL(10,1). */ + var ddl = DuckDbSchemaGenerator.CreateTable(RunningJobsCollector.Instance); + + Assert.Equal( + "CREATE TABLE IF NOT EXISTS running_jobs (\n" + + " collection_time TIMESTAMP NOT NULL,\n" + + " server_id INTEGER NOT NULL,\n" + + " server_name VARCHAR NOT NULL,\n" + + " job_name VARCHAR NOT NULL,\n" + + " job_id VARCHAR NOT NULL,\n" + + " job_enabled BOOLEAN NOT NULL,\n" + + " start_time TIMESTAMP NOT NULL,\n" + + " current_duration_seconds BIGINT NOT NULL,\n" + + " avg_duration_seconds BIGINT NOT NULL,\n" + + " p95_duration_seconds BIGINT NOT NULL,\n" + + " successful_run_count BIGINT NOT NULL,\n" + + " is_running_long BOOLEAN NOT NULL,\n" + + " percent_of_average DECIMAL(10,1)\n" + + ")", + ddl); + } + + [Fact] + public void CreateTable_ServerConfig_UsesConfigIdAndCaptureTimePrefix() + { + var ddl = DuckDbSchemaGenerator.CreateTable(ServerConfigCollector.Instance); + + Assert.StartsWith( + "CREATE TABLE IF NOT EXISTS server_config (\n" + + " config_id BIGINT PRIMARY KEY,\n" + + " capture_time TIMESTAMP NOT NULL,\n" + + " server_id INTEGER NOT NULL,\n" + + " server_name VARCHAR NOT NULL,\n" + + " configuration_name VARCHAR NOT NULL,", + ddl, StringComparison.Ordinal); + } + + [Fact] + public void CreateTable_SpinlockStats_MapsDoubleAndKeepsNameNotNull() + { + var ddl = DuckDbSchemaGenerator.CreateTable(SpinlockStatsCollector.Instance); + + Assert.Contains("spinlock_name VARCHAR NOT NULL", ddl, StringComparison.Ordinal); + Assert.Contains("spins_per_collision DOUBLE", ddl, StringComparison.Ordinal); + } + + [Fact] + public void CreateTable_QuerySnapshots_PreservesIsCdcCaptureDefault() + { + var ddl = DuckDbSchemaGenerator.CreateTable(QuerySnapshotsCollector.Instance); + + Assert.Contains("is_cdc_capture BOOLEAN DEFAULT false", ddl, StringComparison.Ordinal); + Assert.Contains("granted_query_memory_gb DECIMAL(18,2)", ddl, StringComparison.Ordinal); + Assert.Contains("percent_complete DECIMAL(5,2)", ddl, StringComparison.Ordinal); + Assert.Contains("requested_memory_mb DOUBLE", ddl, StringComparison.Ordinal); + } + + [Fact] + public void CreateIndex_MirrorsLitesIrregularNamesAndColumns() + { + Assert.Equal( + "CREATE INDEX IF NOT EXISTS idx_wait_stats_time ON wait_stats(server_id, collection_time)", + DuckDbSchemaGenerator.CreateIndex(WaitStatsCollector.Instance)); + + /* Irregular short-form names predating the uniform scheme. */ + Assert.Equal( + "CREATE INDEX IF NOT EXISTS idx_cpu_time ON cpu_utilization_stats(server_id, collection_time)", + DuckDbSchemaGenerator.CreateIndex(CpuUtilizationCollector.Instance)); + Assert.Equal( + "CREATE INDEX IF NOT EXISTS idx_query_store_time ON query_store_stats(server_id, collection_time)", + DuckDbSchemaGenerator.CreateIndex(QueryStoreCollector.Instance)); + + /* Irregular COLUMN (sample_time, not the collection_time prefix). */ + Assert.Equal( + "CREATE INDEX IF NOT EXISTS idx_memory_pressure_events_time ON memory_pressure_events(server_id, sample_time)", + DuckDbSchemaGenerator.CreateIndex(MemoryPressureEventsCollector.Instance)); + + /* Composite object-drill index. */ + Assert.Equal( + "CREATE INDEX IF NOT EXISTS idx_index_object_stats_object ON index_object_stats(server_id, database_name, object_id, index_id, collection_time)", + DuckDbSchemaGenerator.CreateIndex(IndexObjectStatsCollector.Instance)); + + /* Config snapshots index on capture_time (handled by the default formula via PrefixTimeColumnName). */ + Assert.Equal( + "CREATE INDEX IF NOT EXISTS idx_trace_flags_time ON trace_flags(server_id, capture_time)", + DuckDbSchemaGenerator.CreateIndex(TraceFlagsCollector.Instance)); + + /* server_config / database_config have no index. */ + Assert.Null(DuckDbSchemaGenerator.CreateIndex(ServerConfigCollector.Instance)); + Assert.Null(DuckDbSchemaGenerator.CreateIndex(DatabaseConfigCollector.Instance)); + } + + [Fact] + public void Generated_EmitsEveryCatalogTable_AndThirtyFourIndexes() + { + Assert.Equal(36, DuckDbSchemaGenerator.CreateTableStatements().Count()); + + /* 36 collectors minus the two index-less config tables = 34 indexes. */ + Assert.Equal(34, DuckDbSchemaGenerator.CreateIndexStatements().Count()); + } + + /// <summary> + /// THE forcing function: for every collector in the catalog, the generated DuckDB table's columns + /// (in order) and their DuckDB types must equal the standard prefix followed by the definition's + /// <see cref="ICollectorSchemaInfo.PayloadColumns"/>. A collector column added/renamed/retyped in + /// the shared catalog therefore MUST appear in Lite's schema, and a generator change that dropped + /// or reordered a catalog column fails here. + /// </summary> + [Fact] + public void GeneratedColumnsAndTypes_MatchTheCatalog_ForEveryCollector() + { + var failures = new List<string>(); + + foreach (var schema in CollectorCatalog.All) + { + var expected = new List<(string Name, string Type)>(); + if (schema.IncludesCollectionId) + { + expected.Add((schema.PrefixIdColumnName, "BIGINT")); + } + expected.Add((schema.PrefixTimeColumnName, "TIMESTAMP")); + expected.Add(("server_id", "INTEGER")); + expected.Add(("server_name", "VARCHAR")); + foreach (var column in schema.PayloadColumns) + { + expected.Add((column.Name, DuckDbSchemaGenerator.TypeFor(column))); + } + + var actual = ParseColumns(DuckDbSchemaGenerator.CreateTable(schema)); + + if (!expected.SequenceEqual(actual)) + { + failures.Add($"{schema.TargetTable}:\n expected: {Render(expected)}\n actual : {Render(actual)}"); + } + } + + Assert.True( + failures.Count == 0, + "Generated Lite DDL no longer matches the collector catalog (columns/types/order):\n\n" + + string.Join("\n\n", failures)); + } + + /// <summary> + /// Pins the "three registrations" collapse: the archive-view list (DuckDbInitializer) and the + /// archival/purge list (ArchiveService) are now BOTH derived from the catalog, so they cover + /// exactly the 35 collector tables plus the two non-collector time-series tables, and can never + /// again fall out of sync by hand. + /// </summary> + [Fact] + public void ArchivableTables_AreCatalogDriven_AndMirrorEachOther() + { + var expected = CollectorCatalog.All.Select(c => c.TargetTable) + .Concat(new[] { "config_alert_log", "collection_log" }) + .OrderBy(t => t, StringComparer.Ordinal) + .ToArray(); + + var initializerList = DuckDbInitializer.ArchivableTables.OrderBy(t => t, StringComparer.Ordinal).ToArray(); + var archiveServiceList = ArchiveService.ArchivableTables.Select(t => t.Table).OrderBy(t => t, StringComparer.Ordinal).ToArray(); + + Assert.Equal(expected, initializerList); + Assert.Equal(expected, archiveServiceList); + + /* The time column for every archivable collector table is its catalog prefix-time column; + the two non-collector tables carry their own. */ + var timeByTable = ArchiveService.ArchivableTables.ToDictionary(t => t.Table, t => t.TimeColumn); + foreach (var schema in CollectorCatalog.All) + { + Assert.Equal(schema.PrefixTimeColumnName, timeByTable[schema.TargetTable]); + } + Assert.Equal("alert_time", timeByTable["config_alert_log"]); + Assert.Equal("collection_time", timeByTable["collection_log"]); + } + + /// <summary>Splits a generated CREATE TABLE into ordered (columnName, typeToken) pairs, stripping constraints.</summary> + private static List<(string Name, string Type)> ParseColumns(string ddl) + { + var columns = new List<(string, string)>(); + foreach (var rawLine in ddl.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n')) + { + var line = rawLine.Trim().TrimEnd(',').Trim(); + if (line.Length == 0) continue; + if (line.StartsWith("CREATE TABLE", StringComparison.Ordinal)) continue; + if (line.StartsWith(")", StringComparison.Ordinal)) continue; + + var firstSpace = line.IndexOf(' '); + var name = line.Substring(0, firstSpace); + /* The type token is the first whitespace-delimited token after the name; DECIMAL(p,s) + contains no space, and constraints (PRIMARY KEY / NOT NULL / DEFAULT …) follow a space. */ + var typeToken = line.Substring(firstSpace + 1).Trim().Split(' ')[0]; + columns.Add((name, typeToken)); + } + return columns; + } + + private static string Render(IEnumerable<(string Name, string Type)> cols) => + string.Join(", ", cols.Select(c => $"{c.Name}:{c.Type}")); +} diff --git a/Lite.Tests/DuckDbSchemaTests.cs b/Lite.Tests/DuckDbSchemaTests.cs index a0aefa074..768bf3e67 100644 --- a/Lite.Tests/DuckDbSchemaTests.cs +++ b/Lite.Tests/DuckDbSchemaTests.cs @@ -50,6 +50,10 @@ public async Task InitializeAsync_CreatesAllTables() "collection_schedule", "collection_log", "wait_stats", + "latch_stats", + "spinlock_stats", + "cpu_scheduler_stats", + "plan_cache_stats", "query_stats", "cpu_utilization_stats", "file_io_stats", @@ -70,7 +74,10 @@ public async Task InitializeAsync_CreatesAllTables() "trace_flags", "running_jobs", "config_alert_log", - "config_mute_rules" + "config_mute_rules", + "default_trace_events", + "job_history", + "agent_status" }; using var connection = new DuckDBConnection($"Data Source={_dbPath}"); @@ -138,10 +145,13 @@ public void SchemaStatements_MatchTableCount() foreach (var _ in Schema.GetAllTableStatements()) tableCount++; - /* 33 tables from Schema (schema_version is created separately by DuckDbInitializer). - Includes config_edge_trigger_watermarks (#1145) and dmv_blocking_snapshots (always-on - blocking fallback). */ - Assert.Equal(33, tableCount); + /* 43 tables from Schema (schema_version is created separately by DuckDbInitializer). + Includes config_edge_trigger_watermarks (#1145), dmv_blocking_snapshots (always-on + blocking fallback), latch_stats/spinlock_stats, cpu_scheduler_stats/plan_cache_stats, + session_summary_stats, system_health_events, default_trace_events (#1262 shared + DMV/XE/Default-Trace collectors), job_history + agent_status (#1433 Job History tab), + and long_query_completions (#1496 long-query trace). */ + Assert.Equal(43, tableCount); } [Fact] diff --git a/Lite.Tests/EnumeratedCollectorDriverTests.cs b/Lite.Tests/EnumeratedCollectorDriverTests.cs new file mode 100644 index 000000000..b7ccf4461 --- /dev/null +++ b/Lite.Tests/EnumeratedCollectorDriverTests.cs @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// #1556: the shared enumeration driver's control flow, via fake delegates (no SQL, no store). Pins the +/// properties the field incident hinged on: each item is FLUSHED separately (no cross-item accumulation), +/// empty batches open no write, a per-item failure is skipped while the rest continue, an OutOfMemoryException +/// is rethrown rather than swallowed, cancellation propagates, and the warn hook sees each item's batch count. +/// </summary> +public sealed class EnumeratedCollectorDriverTests +{ + [Fact] + public async Task RunAsync_FlushesEachItemSeparately_WithNoCrossItemAccumulation() + { + var items = new[] { "a", "b", "c" }; + var writtenBatches = new List<List<int>>(); + + var result = await EnumeratedCollectorDriver.RunAsync<int>( + items, + perItemWatermark: null, + readItem: (item, ct) => Task.FromResult(new List<int> { item[0] }), + writeBatch: (batch, ct) => { writtenBatches.Add(batch.ToList()); return Task.CompletedTask; }, + onItemComplete: (item, count, sqlMs, storageMs) => { }, + onItemError: (item, ex) => { }, + CancellationToken.None); + + /* One flush per item, and each flushed batch is exactly that item's rows — never an accumulation + of the ones before it (the byte-blow-up the driver exists to prevent). */ + Assert.Equal(3, writtenBatches.Count); + Assert.All(writtenBatches, b => Assert.Single(b)); + Assert.Equal(new[] { (int)'a', (int)'b', (int)'c' }, writtenBatches.Select(b => b[0])); + Assert.Equal(3, result.Rows); + } + + [Fact] + public async Task RunAsync_EmptyBatch_OpensNoWrite_ButStillWarns() + { + var items = new[] { "a", "b", "c" }; + var writeCount = 0; + var completed = new List<(string Item, int Count)>(); + + var result = await EnumeratedCollectorDriver.RunAsync<int>( + items, + perItemWatermark: null, + readItem: (item, ct) => Task.FromResult(item == "b" ? new List<int>() : new List<int> { 1 }), + writeBatch: (batch, ct) => { writeCount++; return Task.CompletedTask; }, + onItemComplete: (item, count, sqlMs, storageMs) => completed.Add((item, count)), + onItemError: (item, ex) => { }, + CancellationToken.None); + + /* Empty "b" contributes no COPY/appender and no rows, but the warn hook still ran for it (count 0). */ + Assert.Equal(2, writeCount); + Assert.Equal(2, result.Rows); + Assert.Equal(3, completed.Count); + Assert.Equal(0, completed.Single(c => c.Item == "b").Count); + } + + [Fact] + public async Task RunAsync_PerItemError_IsSkipped_AndTheRestContinue() + { + var items = new[] { "a", "b", "c" }; + var errors = new List<string>(); + var completed = new List<string>(); + var writeCount = 0; + + var result = await EnumeratedCollectorDriver.RunAsync<int>( + items, + perItemWatermark: null, + readItem: (item, ct) => item == "b" + ? throw new InvalidOperationException("boom") + : Task.FromResult(new List<int> { 1 }), + writeBatch: (batch, ct) => { writeCount++; return Task.CompletedTask; }, + onItemComplete: (item, count, sqlMs, storageMs) => completed.Add(item), + onItemError: (item, ex) => errors.Add(item), + CancellationToken.None); + + /* "b" is skipped (logged via onItemError, never onItemComplete, never written); "a"+"c" still land. */ + Assert.Equal(new[] { "b" }, errors); + Assert.Equal(new[] { "a", "c" }, completed); + Assert.Equal(2, writeCount); + Assert.Equal(2, result.Rows); + } + + [Fact] + public async Task RunAsync_OutOfMemory_IsRethrown_NotSwallowedAsASkip() + { + var items = new[] { "a", "b", "c" }; + var errors = new List<string>(); + var writeCount = 0; + + await Assert.ThrowsAsync<OutOfMemoryException>(async () => + await EnumeratedCollectorDriver.RunAsync<int>( + items, + perItemWatermark: null, + readItem: (item, ct) => item == "b" + ? throw new OutOfMemoryException() + : Task.FromResult(new List<int> { 1 }), + writeBatch: (batch, ct) => { writeCount++; return Task.CompletedTask; }, + onItemComplete: (item, count, sqlMs, storageMs) => { }, + onItemError: (item, ex) => errors.Add(item), + CancellationToken.None)); + + /* OOM is fatal, not a per-item skip: it is NOT filed through onItemError, and "c" is never reached. + "a" was flushed before "b" blew up (commit-1..N-1). */ + Assert.Empty(errors); + Assert.Equal(1, writeCount); + } + + [Fact] + public async Task RunAsync_ReadItemThrowsOperationCanceled_Propagates() + { + var items = new[] { "a" }; + var errors = new List<string>(); + + await Assert.ThrowsAsync<OperationCanceledException>(async () => + await EnumeratedCollectorDriver.RunAsync<int>( + items, + perItemWatermark: null, + readItem: (item, ct) => throw new OperationCanceledException(), + writeBatch: (batch, ct) => Task.CompletedTask, + onItemComplete: (item, count, sqlMs, storageMs) => { }, + onItemError: (item, ex) => errors.Add(item), + CancellationToken.None)); + + /* Cancellation is not a per-item skip either — the filtered catch lets it through. */ + Assert.Empty(errors); + } + + [Fact] + public async Task RunAsync_AlreadyCancelledToken_ThrowsBeforeAnyItem() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var reads = 0; + + await Assert.ThrowsAsync<OperationCanceledException>(async () => + await EnumeratedCollectorDriver.RunAsync<int>( + new[] { "a", "b" }, + perItemWatermark: null, + readItem: (item, ct) => { reads++; return Task.FromResult(new List<int> { 1 }); }, + writeBatch: (batch, ct) => Task.CompletedTask, + onItemComplete: (item, count, sqlMs, storageMs) => { }, + onItemError: (item, ex) => { }, + cts.Token)); + + Assert.Equal(0, reads); + } + + [Fact] + public async Task RunAsync_PerItemWatermark_RunsBeforeEachRead() + { + var order = new List<string>(); + + await EnumeratedCollectorDriver.RunAsync<int>( + new[] { "a", "b" }, + perItemWatermark: (item, ct) => { order.Add($"wm:{item}"); return Task.CompletedTask; }, + readItem: (item, ct) => { order.Add($"read:{item}"); return Task.FromResult(new List<int>()); }, + writeBatch: (batch, ct) => Task.CompletedTask, + onItemComplete: (item, count, sqlMs, storageMs) => { }, + onItemError: (item, ex) => { }, + CancellationToken.None); + + /* The per-database cutoff (watermark + clamp) is computed before that database's query is built. */ + Assert.Equal(new[] { "wm:a", "read:a", "wm:b", "read:b" }, order); + } + + [Fact] + public async Task RunAsync_WarnHook_ReceivesEachItemsBatchCount() + { + var completed = new List<(string Item, int Count)>(); + + var result = await EnumeratedCollectorDriver.RunAsync<int>( + new[] { "a", "b" }, + perItemWatermark: null, + readItem: (item, ct) => Task.FromResult(item == "a" + ? new List<int> { 1, 2, 3 } + : new List<int> { 9 }), + writeBatch: (batch, ct) => Task.CompletedTask, + onItemComplete: (item, count, sqlMs, storageMs) => completed.Add((item, count)), + onItemError: (item, ex) => { }, + CancellationToken.None); + + /* The count is the per-database delta the host compares to the row cap; under per-item flush it is + exactly the batch size. */ + Assert.Equal((3, 1), (completed.Single(c => c.Item == "a").Count, completed.Single(c => c.Item == "b").Count)); + Assert.Equal(4, result.Rows); + } +} diff --git a/Lite.Tests/FactCollectorTests.cs b/Lite.Tests/FactCollectorTests.cs index a99c1fadd..625af4305 100644 --- a/Lite.Tests/FactCollectorTests.cs +++ b/Lite.Tests/FactCollectorTests.cs @@ -503,4 +503,64 @@ public async Task CollectFacts_PercentAutogrowthOnLargeFiles_AbsentWhenNoQualify Assert.False(facts.ContainsKey("FILE_AUTOGROWTH_PERCENT"), "FILE_AUTOGROWTH_PERCENT should not be emitted when no large percent-growth file exists"); } + + /* ── Tier-2: plan-cache single-use bloat + ring-buffer memory-pressure collector round-trips ── */ + + // The collector reads the LATEST plan_cache_stats snapshot and SUMs it into a PLAN_CACHE_BLOAT fact: + // 6,000 single-use of 10,000 total = 60%, with the single-use size carried for the scorer's guard. + [Fact] + public async Task CollectFacts_PlanCacheBloat_EmitsPercentAndSizeMetadata() + { + var facts = await SeedAndCollectAsync(s => s.SeedPlanCacheStatsAsync( + totalPlans: 10_000, singleUsePlans: 6_000, totalSizeMb: 1_300, singleUseSizeMb: 800)); + + Assert.True(facts.ContainsKey("PLAN_CACHE_BLOAT"), "PLAN_CACHE_BLOAT should be collected"); + var bloat = facts["PLAN_CACHE_BLOAT"]; + Assert.Equal("memory", bloat.Source); + Assert.Equal(60, bloat.Value, precision: 0); // 6000 * 100 / 10000 + Assert.Equal(6_000, bloat.Metadata["single_use_plans"]); + Assert.Equal(10_000, bloat.Metadata["total_plans"]); + Assert.Equal(800, bloat.Metadata["single_use_size_mb"]); + Assert.Equal(1_300, bloat.Metadata["total_size_mb"]); + } + + // No plan_cache_stats snapshot -> the fact is not emitted (collector returns before Add). + [Fact] + public async Task CollectFacts_PlanCacheBloat_AbsentWhenNoSnapshot() + { + var facts = await SeedAndCollectAsync(s => s.SeedCleanServerAsync()); + + Assert.False(facts.ContainsKey("PLAN_CACHE_BLOAT"), + "PLAN_CACHE_BLOAT should not be emitted when no plan_cache_stats snapshot exists"); + } + + // A genuine HIGH pressure event (indicator 3) is classified: Value = the max indicator, with the + // MEDIUM+ event count carried for the card. + [Fact] + public async Task CollectFacts_MemoryPressureEvents_EmitsMaxIndicator() + { + var facts = await SeedAndCollectAsync(s => s.SeedMemoryPressureEventsAsync( + ("RESOURCE_MEMPHYSICAL_STEADY", 0, 0), // steady — ignored by the MEDIUM+ gate + ("RESOURCE_MEMPHYSICAL_LOW", 3, 1))); // real pressure — process indicator 3 + + Assert.True(facts.ContainsKey("MEMORY_PRESSURE_EVENTS"), "MEMORY_PRESSURE_EVENTS should be collected"); + var mp = facts["MEMORY_PRESSURE_EVENTS"]; + Assert.Equal("memory", mp.Source); + Assert.Equal(3, mp.Value); // max(process=3, system=1) + Assert.Equal(3, mp.Metadata["max_process_indicator"]); + Assert.Equal(1, mp.Metadata["max_system_indicator"]); + Assert.Equal(1, mp.Metadata["event_count"]); // only the indicator-3 row is MEDIUM+ + } + + // A steady ring buffer (indicators 0-1) is the healthy norm — the collector gates it out entirely. + [Fact] + public async Task CollectFacts_MemoryPressureEvents_AbsentWhenOnlySteady() + { + var facts = await SeedAndCollectAsync(s => s.SeedMemoryPressureEventsAsync( + ("RESOURCE_MEMPHYSICAL_STEADY", 0, 0), + ("RESOURCE_MEMPHYSICAL_HIGH", 1, 1))); // indicator 1 — below the MEDIUM (>= 2) floor + + Assert.False(facts.ContainsKey("MEMORY_PRESSURE_EVENTS"), + "MEMORY_PRESSURE_EVENTS should not be emitted when no MEDIUM+ indicator is present"); + } } diff --git a/Lite.Tests/FactScorerTests.cs b/Lite.Tests/FactScorerTests.cs index 29e807ccf..d4db8c928 100644 --- a/Lite.Tests/FactScorerTests.cs +++ b/Lite.Tests/FactScorerTests.cs @@ -150,6 +150,114 @@ public void Score_UnknownWaitType_GetsSeverityZero() Assert.Equal(0.0, facts[0].BaseSeverity); } + /* ── Wait-profile anomaly scoring (change 1) ── */ + + // The ANOMALY_WAIT_PROFILE arm scores on the HONEST per-second ratio: below 4x → 0, 4x → 0.5 + // (floor), saturating to 1.0 at 12x. (Precedes the legacy ANOMALY_WAIT_ branch it prefix-matches.) + [Theory] + [InlineData(3.0, 0.0)] // below the floor + [InlineData(4.0, 0.5)] // at the floor + [InlineData(8.0, 0.75)] // midway up the ramp + [InlineData(12.0, 1.0)] // saturated + [InlineData(100.0, 1.0)] // is_new sentinel — clamped to 1.0 + public void Score_WaitProfile_RampsFromHonestRatio(double ratio, double expected) + { + var fact = new Fact + { + Source = "anomaly", + Key = "ANOMALY_WAIT_PROFILE", + Metadata = new() { ["ratio"] = ratio } + }; + new FactScorer().ScoreAll(new List<Fact> { fact }); + Assert.Equal(expected, fact.BaseSeverity, precision: 4); + } + + // Dead-fact guard: ANOMALY_WAIT_PROFILE must resolve to an advice block (a root fact that renders + // no advice is the P1 dead-fact bug class). + [Fact] + public void WaitProfile_HasAdviceBlock() + { + var advice = FactAdvice.GetForFactKey("ANOMALY_WAIT_PROFILE"); + Assert.NotNull(advice); + Assert.False(string.IsNullOrWhiteSpace(advice!.Headline)); + Assert.False(string.IsNullOrWhiteSpace(advice.Investigation)); + Assert.False(string.IsNullOrWhiteSpace(advice.Remediation)); + } + + /* ── Bounded-metric low-quality (thin-baseline) fallback scoring (finding 1) ── */ + + // When the quality gate fires on a thin/untrustworthy baseline (baseline_low_quality=1) the stored + // deviation_sigma is the real (small) z — the 2σ gate would zero it and InferenceEngine would drop the + // finding. The scorer must instead grade off the absolute exceedance (peak ÷ the fallback bar): floor + // 0.5 AT the bar (clears InferenceEngine's 0.5 entry-point), ramping to 1.0 at 2× the bar. A sub-2σ + // deviation_sigma is planted to prove the 2σ gate is bypassed on this path. + [Theory] + [InlineData("ANOMALY_CPU_SPIKE", 1.0, 0.5)] // exactly at the bar → the 0.5 floor + [InlineData("ANOMALY_MEMORY_PRESSURE", 1.25, 0.625)] + [InlineData("ANOMALY_READ_LATENCY", 1.5, 0.75)] // 1.5× the bar → midway + [InlineData("ANOMALY_WRITE_LATENCY", 2.0, 1.0)] // 2× the bar → saturated + [InlineData("ANOMALY_BATCH_REQUESTS", 5.0, 1.0)] // far past → clamped to 1.0 + public void Score_LowQualityFallback_GradesOffExceedance_NotSigma( + string key, double exceedance, double expected) + { + var fact = new Fact + { + Source = "anomaly", + Key = key, + Metadata = new() + { + ["deviation_sigma"] = 0.7, // a real, sub-2σ z the gate must ignore + ["baseline_low_quality"] = 1.0, + ["fallback_exceedance"] = exceedance, + ["confidence"] = 1.0 + } + }; + new FactScorer().ScoreAll(new List<Fact> { fact }); + Assert.Equal(expected, fact.BaseSeverity, precision: 4); + } + + // The finding's exact regression: memory 96% on a thin baseline (95% fallback bar) used to score 0 + // (small z → 2σ gate → 0 → InferenceEngine drops it → NO finding). It must now clear the 0.5 entry. + [Fact] + public void Score_Memory96PercentOnThinBaseline_ClearsInferenceEntryPoint() + { + var fact = new Fact + { + Source = "anomaly", + Key = "ANOMALY_MEMORY_PRESSURE", + Metadata = new() + { + ["deviation_sigma"] = 0.4, + ["baseline_low_quality"] = 1.0, + ["fallback_exceedance"] = 96.0 / 95.0, + ["confidence"] = 1.0 + } + }; + new FactScorer().ScoreAll(new List<Fact> { fact }); + Assert.True(fact.BaseSeverity >= 0.5, "a thin-baseline memory-96% fallback must surface, not vanish"); + } + + // The trustworthy z-path is unchanged: with baseline_low_quality=0 a sub-2σ deviation still scores 0 + // (the fallback fix must not leak into the trusted path). + [Fact] + public void Score_TrustworthyBaseline_SubTwoSigma_StillScoresZero() + { + var fact = new Fact + { + Source = "anomaly", + Key = "ANOMALY_MEMORY_PRESSURE", + Metadata = new() + { + ["deviation_sigma"] = 1.5, + ["baseline_low_quality"] = 0.0, + ["fallback_exceedance"] = 0.0, + ["confidence"] = 1.0 + } + }; + new FactScorer().ScoreAll(new List<Fact> { fact }); + Assert.Equal(0.0, fact.BaseSeverity, precision: 4); + } + /* ── Layer 2: Amplifier tests ── */ [Fact] @@ -217,11 +325,681 @@ public void Amplifier_SeverityCappedAt2() var cx = facts.First(f => f.Key == "CXPACKET"); - // base 1.0 * (1.0 + 0.3 SOS + 0.4 THREADPOOL + 0.3 CTFP + 0.2 MAXDOP) = 2.2 → capped at 2.0 + // base 1.0 * (1.0 + 0.3 SOS + 0.4 THREADPOOL + 0.3 CTFP + 0.2 MAXDOP) = 2.2 → capped at 2.0. + // (SOS + THREADPOOL are impact peers, so the Layer-3 tuning cap is released here.) Assert.True(cx.Severity <= 2.0, "Severity should never exceed 2.0"); Assert.Equal(2.0, cx.Severity); } + /* ── Layer 3: tuning-class severity cap + impact escalation (change 4) ── */ + + // Parallelism is a tuning opportunity, not an outage. CXPACKET saturates its base to 1.0 and + // non-impact amplifiers (CTFP + MAXDOP + high-DOP queries) push the Layer-2 product to 1.7, which + // WOULD land in the CRITICAL band (>= 1.5). With no impact peer co-firing, Layer 3 caps it at the + // 1.49 WARNING ceiling. + [Fact] + public void Score_CxPacketAlone_CappedAtWarningCeiling() + { + var facts = new List<Fact> + { + new() { Source = "waits", Key = "CXPACKET", Value = 0.80 }, // base 1.0 + new() { Source = "config", Key = "CONFIG_CTFP", Value = 5 }, // +0.3 (not an impact peer) + new() { Source = "config", Key = "CONFIG_MAXDOP", Value = 0 }, // +0.2 (not an impact peer) + new() { Source = "queries", Key = "QUERY_HIGH_DOP", Value = 10 }, // +0.2 (not an impact peer) + }; + + new FactScorer().ScoreAll(facts); + + var cx = facts.First(f => f.Key == "CXPACKET"); + // Layer-2 product = 1.0 * (1 + 0.3 + 0.2 + 0.2) = 1.7; Layer 3 caps it to 1.49. + Assert.Equal(1.49, cx.Severity, precision: 4); + Assert.True(cx.Severity < 1.5, "parallelism alone must stay in the WARNING band, never CRITICAL"); + } + + // Escape hatch: when an impact-bearing peer co-fires (THREADPOOL = real thread exhaustion) the cap + // is released and CXPACKET keeps its full amplified severity into the CRITICAL band. + [Fact] + public void Score_CxPacketWithThreadpool_CapReleased() + { + var facts = new List<Fact> + { + new() { Source = "waits", Key = "CXPACKET", Value = 0.80 }, // base 1.0 + new() { Source = "waits", Key = "THREADPOOL", Value = 0.05, // real thread exhaustion (+0.4 on CXPACKET) + Metadata = new() { ["wait_time_ms"] = 7_200_000, ["avg_ms_per_wait"] = 3_600 } }, + new() { Source = "config", Key = "CONFIG_CTFP", Value = 5 }, // +0.3 + }; + + new FactScorer().ScoreAll(facts); + + var cx = facts.First(f => f.Key == "CXPACKET"); + // 1.0 * (1 + 0.4 THREADPOOL + 0.3 CTFP) = 1.7, uncapped because an impact peer co-fired. + Assert.Equal(1.7, cx.Severity, precision: 4); + Assert.True(cx.Severity >= 1.5, "an impact peer co-firing releases the cap into CRITICAL"); + } + + // REGRESSION (escape-hatch significance gate): the cap-release peers SOS_SCHEDULER_YIELD and + // RESOURCE_SEMAPHORE must be SIGNIFICANT to release the cap, not merely present. Neither has a + // minimum guard in ScoreWaitFact, so BaseSeverity > 0 fires on any trace of the wait — and SOS + // physically co-occurs with high CXPACKET (parallel workers yield -> SOS). A trivial SOS used to + // release the cap on exactly the busy servers the cap targets, re-admitting CXPACKET=CRITICAL + // noise. These pin the significance bars (SOS 0.25, RS 0.10) the escape hatch now uses. + + // A TRIVIAL SOS (0.1% of period, far below the 0.25 bar) must NOT release the cap: CXPACKET stays + // capped at the WARNING ceiling even though a sub-threshold SOS wait is present. + [Fact] + public void Score_CxPacketWithTrivialSos_CapHolds() + { + var facts = new List<Fact> + { + new() { Source = "waits", Key = "CXPACKET", Value = 0.80 }, // base 1.0 + new() { Source = "config", Key = "CONFIG_CTFP", Value = 5 }, // +0.3 + new() { Source = "config", Key = "CONFIG_MAXDOP", Value = 0 }, // +0.2 + new() { Source = "queries", Key = "QUERY_HIGH_DOP", Value = 10 }, // +0.2 + new() { Source = "waits", Key = "SOS_SCHEDULER_YIELD", Value = 0.001 }, // TRIVIAL — 0.1%, < 0.25 bar + }; + + new FactScorer().ScoreAll(facts); + + var cx = facts.First(f => f.Key == "CXPACKET"); + // Trivial SOS neither amplifies CXPACKET (amp bar is 0.25) nor releases the cap: 1.7 -> 1.49. + Assert.Equal(1.49, cx.Severity, precision: 4); + Assert.True(cx.Severity <= 1.49, "a trivial SOS must NOT release the tuning cap"); + } + + // A SIGNIFICANT SOS (30% of period, >= the 0.25 bar) DOES release the cap: CXPACKET reaches CRITICAL. + [Fact] + public void Score_CxPacketWithSignificantSos_CapReleased() + { + var facts = new List<Fact> + { + new() { Source = "waits", Key = "CXPACKET", Value = 0.80 }, // base 1.0 + new() { Source = "config", Key = "CONFIG_CTFP", Value = 5 }, // +0.3 + new() { Source = "config", Key = "CONFIG_MAXDOP", Value = 0 }, // +0.2 + new() { Source = "queries", Key = "QUERY_HIGH_DOP", Value = 10 }, // +0.2 + new() { Source = "waits", Key = "SOS_SCHEDULER_YIELD", Value = 0.30 }, // SIGNIFICANT — >= 0.25 bar + }; + + new FactScorer().ScoreAll(facts); + + var cx = facts.First(f => f.Key == "CXPACKET"); + // Significant SOS also fires the CXPACKET SOS amplifier (+0.3): 1.0*(1+0.3+0.3+0.2+0.2)=2.0, uncapped. + Assert.Equal(2.0, cx.Severity, precision: 4); + Assert.True(cx.Severity >= 1.5, "a significant SOS releases the cap into CRITICAL"); + } + + // A TRIVIAL RESOURCE_SEMAPHORE (0.1% of period, below the 0.10 bar) must NOT release the cap. RS + // never amplifies CXPACKET, so pre-cap CXPACKET is 1.7 either way — this isolates the escape hatch. + [Fact] + public void Score_CxPacketWithTrivialResourceSemaphore_CapHolds() + { + var facts = new List<Fact> + { + new() { Source = "waits", Key = "CXPACKET", Value = 0.80 }, // base 1.0 + new() { Source = "config", Key = "CONFIG_CTFP", Value = 5 }, // +0.3 + new() { Source = "config", Key = "CONFIG_MAXDOP", Value = 0 }, // +0.2 + new() { Source = "queries", Key = "QUERY_HIGH_DOP", Value = 10 }, // +0.2 + new() { Source = "waits", Key = "RESOURCE_SEMAPHORE", Value = 0.001 }, // TRIVIAL — 0.1%, < 0.10 bar + }; + + new FactScorer().ScoreAll(facts); + + var cx = facts.First(f => f.Key == "CXPACKET"); + Assert.Equal(1.49, cx.Severity, precision: 4); + Assert.True(cx.Severity <= 1.49, "a trivial RESOURCE_SEMAPHORE must NOT release the tuning cap"); + } + + // A SIGNIFICANT RESOURCE_SEMAPHORE (15% of period, >= the 0.10 bar) DOES release the cap. RS does + // not amplify CXPACKET, so the pre-cap 1.7 passes through uncapped — the escape hatch in isolation. + [Fact] + public void Score_CxPacketWithSignificantResourceSemaphore_CapReleased() + { + var facts = new List<Fact> + { + new() { Source = "waits", Key = "CXPACKET", Value = 0.80 }, // base 1.0 + new() { Source = "config", Key = "CONFIG_CTFP", Value = 5 }, // +0.3 + new() { Source = "config", Key = "CONFIG_MAXDOP", Value = 0 }, // +0.2 + new() { Source = "queries", Key = "QUERY_HIGH_DOP", Value = 10 }, // +0.2 + new() { Source = "waits", Key = "RESOURCE_SEMAPHORE", Value = 0.15 }, // SIGNIFICANT — >= 0.10 bar + }; + + new FactScorer().ScoreAll(facts); + + var cx = facts.First(f => f.Key == "CXPACKET"); + // RS does not amplify CXPACKET; the pre-cap 1.7 is released unchanged. + Assert.Equal(1.7, cx.Severity, precision: 4); + Assert.True(cx.Severity >= 1.5, "a significant RESOURCE_SEMAPHORE releases the cap into CRITICAL"); + } + + // THREADPOOL is the impact-bearing escalation path and is NEVER capped. The raised CXPACKET + // amplifier (+0.5) and the new runnable-queue amplifier (+0.5, off the RUNNABLE_TASKS context fact) + // drive a genuine parallelism -> thread-exhaustion meltdown to CRITICAL through THREADPOOL. + [Fact] + public void Score_Threadpool_ReachesCritical_ViaCxPacketAndRunnableQueue() + { + var facts = new List<Fact> + { + new() { Source = "waits", Key = "THREADPOOL", Value = 0.05, // base 1.0 + Metadata = new() { ["wait_time_ms"] = 7_200_000, ["avg_ms_per_wait"] = 3_600 } }, + new() { Source = "waits", Key = "CXPACKET", Value = 0.40 }, // >= 0.10 -> raised amp +0.5 + new() { Source = "cpu", Key = "RUNNABLE_TASKS", Value = 48, // runnable_tasks_warning set -> amp +0.5 + Metadata = new() { ["total_runnable_tasks"] = 48, ["runnable_tasks_warning"] = 1 } }, + }; + + new FactScorer().ScoreAll(facts); + + var tp = facts.First(f => f.Key == "THREADPOOL"); + // 1.0 * (1 + 0.5 CXPACKET + 0.5 runnable-queue) = 2.0, uncapped (THREADPOOL is an impact key). + Assert.Equal(2.0, tp.Severity, precision: 4); + Assert.True(tp.Severity >= 1.5, "THREADPOOL must reach CRITICAL on a real meltdown"); + + var cxAmp = tp.AmplifierResults.First(a => a.Description.Contains("CXPACKET")); + Assert.True(cxAmp.Matched); + Assert.Equal(0.5, cxAmp.Boost); + + var rqAmp = tp.AmplifierResults.First(a => a.Description.Contains("Runnable-task queue")); + Assert.True(rqAmp.Matched); + Assert.Equal(0.5, rqAmp.Boost); + } + + // The runnable-queue amplifier only fires on genuine scheduler pressure: a shallow runnable queue + // (below the trip level) does not boost THREADPOOL. + [Fact] + public void Score_Threadpool_RunnableQueueAmplifier_DoesNotFireBelowThreshold() + { + var facts = new List<Fact> + { + new() { Source = "waits", Key = "THREADPOOL", Value = 0.05, + Metadata = new() { ["wait_time_ms"] = 7_200_000, ["avg_ms_per_wait"] = 3_600 } }, + new() { Source = "cpu", Key = "RUNNABLE_TASKS", Value = 4, // warning clear -> no boost + Metadata = new() { ["total_runnable_tasks"] = 4, ["runnable_tasks_warning"] = 0 } }, + }; + + new FactScorer().ScoreAll(facts); + + var tp = facts.First(f => f.Key == "THREADPOOL"); + var rqAmp = tp.AmplifierResults.First(a => a.Description.Contains("Runnable-task queue")); + Assert.False(rqAmp.Matched); + } + + // ── Tier-2 parity arms (batch 1): runnable-queue / security-cache / QS-off ────────────────── + // All three are additions to the SHARED FactScorer, so they fire identically for Lite, Dashboard, + // and Darling findings. Sourced thresholds are cited per arm against the deprecated Dashboard SQL. + + // ARM 3 — runnable-task-queue depth roots a STANDALONE finding off the collected cpu_scheduler_stats + // snapshot, tiered to the Dashboard's report.cpu_scheduler_pressure CASE (install/47:1839-1841): + // > 50 CRITICAL-tier (base 1.0), > 20 HIGH (0.75), > 10 MEDIUM (0.5 — the 0.5 root entry point). + // Base maxes at 1.0 (top of WARNING) like every base fact; CRITICAL band is reached only via the + // #1494 runnable-queue -> THREADPOOL amplifier, which still fires independently. + [Theory] + [InlineData(60, 1.0)] // > 50 -> CRITICAL tier (install/47:1839) + [InlineData(30, 0.75)] // > 20 -> HIGH tier (install/47:1840) + [InlineData(15, 0.5)] // > 10 -> MEDIUM tier (install/47:1841) + public void Score_RunnableTasks_TiersMatchDashboard(double total, double expected) + { + var facts = new List<Fact> + { + new() { Source = "cpu", Key = "RUNNABLE_TASKS", Value = total, + Metadata = new() { ["total_runnable_tasks"] = total, ["runnable_tasks_warning"] = 1 } }, + }; + + new FactScorer().ScoreAll(facts); + + var rt = facts.First(f => f.Key == "RUNNABLE_TASKS"); + // No amplifiers on RUNNABLE_TASKS and it is not tuning-class, so Severity == base. + Assert.Equal(expected, rt.Severity, precision: 4); + Assert.True(rt.Severity >= 0.5, "a non-NORMAL runnable queue must clear the 0.5 root threshold"); + } + + // A shallow queue below the > 10 bar with the warning flag CLEAR does not score (context-only). + [Fact] + public void Score_RunnableTasks_BelowThreshold_DoesNotScore() + { + var facts = new List<Fact> + { + new() { Source = "cpu", Key = "RUNNABLE_TASKS", Value = 5, + Metadata = new() { ["total_runnable_tasks"] = 5, ["runnable_tasks_warning"] = 0 } }, + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.0, facts.First(f => f.Key == "RUNNABLE_TASKS").Severity, precision: 4); + } + + // Small-box fallback: below the absolute > 10 bar, the collector's runnable_tasks_warning flag + // (SUM(runnable_tasks_count) >= cpu_count) still scores HIGH (install/47:1844). + [Fact] + public void Score_RunnableTasks_WarningFlagOnSmallBox_ScoresHigh() + { + var facts = new List<Fact> + { + new() { Source = "cpu", Key = "RUNNABLE_TASKS", Value = 6, + Metadata = new() { ["total_runnable_tasks"] = 6, ["runnable_tasks_warning"] = 1 } }, + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.75, facts.First(f => f.Key == "RUNNABLE_TASKS").Severity, precision: 4); + } + + // ARM 2 — TokenAndPermUserStore (security cache) growth scores a flat WARNING at >= 1 GB off the + // otherwise context-only MEMORY_CLERKS fact (install/50:562 severity=WARNING, :583 threshold 1 GB). + // The clerk size rides in metadata keyed by clerk_type (= sys.dm_os_memory_clerks.type). + [Fact] + public void Score_MemoryClerks_SecurityCacheOver1Gb_ScoresWarning() + { + var facts = new List<Fact> + { + new() { Source = "memory", Key = "MEMORY_CLERKS", Value = 52_048, + Metadata = new() + { + ["MEMORYCLERK_SQLBUFFERPOOL"] = 50_000, + ["USERSTORE_TOKENPERM"] = 2_048, // 2 GB + ["total_top_clerks_mb"] = 52_048, + ["clerk_count"] = 2 + } }, + }; + + new FactScorer().ScoreAll(facts); + + var mc = facts.First(f => f.Key == "MEMORY_CLERKS"); + Assert.True(mc.Severity >= 0.75 && mc.Severity < 1.5, "security-cache growth bands WARNING"); + } + + // Under 1 GB does not score; MEMORY_CLERKS stays context-only. + [Fact] + public void Score_MemoryClerks_SecurityCacheUnder1Gb_DoesNotScore() + { + var facts = new List<Fact> + { + new() { Source = "memory", Key = "MEMORY_CLERKS", Value = 50_512, + Metadata = new() + { + ["MEMORYCLERK_SQLBUFFERPOOL"] = 50_000, + ["USERSTORE_TOKENPERM"] = 512, // 0.5 GB — below the 1 GB bar + } }, + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.0, facts.First(f => f.Key == "MEMORY_CLERKS").Severity, precision: 4); + } + + // A normal clerk set with no TokenAndPermUserStore entry scores 0 (context-only preserved). + [Fact] + public void Score_MemoryClerks_NoSecurityCacheClerk_DoesNotScore() + { + var facts = new List<Fact> + { + new() { Source = "memory", Key = "MEMORY_CLERKS", Value = 50_000, + Metadata = new() { ["MEMORYCLERK_SQLBUFFERPOOL"] = 50_000, ["clerk_count"] = 1 } }, + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.0, facts.First(f => f.Key == "MEMORY_CLERKS").Severity, precision: 4); + } + + // ── Tier-2 ARM: plan-cache single-use bloat scores off PLAN_CACHE_BLOAT (Value = single_use_percent), + // tiered > 50/30/20% (install/47:1485-1487) behind a single_use_size_mb >= 100 noise guard. ── + [Fact] + public void Score_PlanCacheBloat_Over50Percent_BandsWarningCeiling() + { + var facts = new List<Fact> + { + new() { Source = "memory", Key = "PLAN_CACHE_BLOAT", Value = 62, + Metadata = new() + { + ["single_use_plans"] = 6_200, + ["total_plans"] = 10_000, + ["single_use_size_mb"] = 800, // >= 100 MB — real bloat + ["total_size_mb"] = 1_300, + ["single_use_percent"] = 62 + } }, + }; + + new FactScorer().ScoreAll(facts); + + // > 50% is the Dashboard's CRITICAL tier, but base caps at the WARNING ceiling (1.0); CRITICAL is + // earned only via corroboration. + Assert.Equal(1.0, facts.First(f => f.Key == "PLAN_CACHE_BLOAT").Severity, precision: 4); + } + + [Fact] + public void Score_PlanCacheBloat_Between30And50_ScoresHigh() + { + var facts = new List<Fact> + { + new() { Source = "memory", Key = "PLAN_CACHE_BLOAT", Value = 38, + Metadata = new() { ["single_use_size_mb"] = 400, ["single_use_percent"] = 38 } }, + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.75, facts.First(f => f.Key == "PLAN_CACHE_BLOAT").Severity, precision: 4); + } + + [Fact] + public void Score_PlanCacheBloat_Between20And30_ScoresMedium() + { + var facts = new List<Fact> + { + new() { Source = "memory", Key = "PLAN_CACHE_BLOAT", Value = 24, + Metadata = new() { ["single_use_size_mb"] = 250, ["single_use_percent"] = 24 } }, + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.5, facts.First(f => f.Key == "PLAN_CACHE_BLOAT").Severity, precision: 4); + } + + // Under 20% single-use is healthy plan-cache composition — dormant. + [Fact] + public void Score_PlanCacheBloat_Under20Percent_DoesNotScore() + { + var facts = new List<Fact> + { + new() { Source = "memory", Key = "PLAN_CACHE_BLOAT", Value = 12, + Metadata = new() { ["single_use_size_mb"] = 400, ["single_use_percent"] = 12 } }, + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.0, facts.First(f => f.Key == "PLAN_CACHE_BLOAT").Severity, precision: 4); + } + + // A high single-use % on a tiny footprint (< 100 MB) is noise — the size guard keeps it dormant even + // above the 20% bar. + [Fact] + public void Score_PlanCacheBloat_HighPercentButTinyFootprint_DoesNotScore() + { + var facts = new List<Fact> + { + new() { Source = "memory", Key = "PLAN_CACHE_BLOAT", Value = 70, + Metadata = new() { ["single_use_size_mb"] = 40, ["single_use_percent"] = 70 } }, + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.0, facts.First(f => f.Key == "PLAN_CACHE_BLOAT").Severity, precision: 4); + } + + // ── Tier-2 ARM: ring-buffer physical-memory-pressure scores off MEMORY_PRESSURE_EVENTS (Value = the + // max indicator), banded >= 3 HIGH (0.9) / >= 2 MEDIUM (0.5) per report.memory_pressure_events + // (install/47:229-236). ── + [Fact] + public void Score_MemoryPressureEvents_Indicator3_BandsWarning() + { + var facts = new List<Fact> + { + new() { Source = "memory", Key = "MEMORY_PRESSURE_EVENTS", Value = 3, + Metadata = new() { ["event_count"] = 12, ["max_process_indicator"] = 3, ["max_system_indicator"] = 1 } }, + }; + + new FactScorer().ScoreAll(facts); + + var mp = facts.First(f => f.Key == "MEMORY_PRESSURE_EVENTS"); + Assert.Equal(0.9, mp.Severity, precision: 4); + Assert.True(mp.Severity >= 0.75 && mp.Severity < 1.5, "HIGH pressure bands WARNING"); + } + + [Fact] + public void Score_MemoryPressureEvents_Indicator2_ScoresMedium() + { + var facts = new List<Fact> + { + new() { Source = "memory", Key = "MEMORY_PRESSURE_EVENTS", Value = 2, + Metadata = new() { ["event_count"] = 4, ["max_process_indicator"] = 2, ["max_system_indicator"] = 0 } }, + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.5, facts.First(f => f.Key == "MEMORY_PRESSURE_EVENTS").Severity, precision: 4); + } + + // Indicator 1 (steady / LOW) does not score — a defensive floor (the collector already gates < 2 out). + [Fact] + public void Score_MemoryPressureEvents_Indicator1_DoesNotScore() + { + var facts = new List<Fact> + { + new() { Source = "memory", Key = "MEMORY_PRESSURE_EVENTS", Value = 1, + Metadata = new() { ["event_count"] = 0, ["max_process_indicator"] = 1, ["max_system_indicator"] = 1 } }, + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.0, facts.First(f => f.Key == "MEMORY_PRESSURE_EVENTS").Severity, precision: 4); + } + + // batch-2b — priority boost enabled is a Dashboard WARNING (install/50:368). CONFIG_PRIORITY_BOOST + // bands WARNING (0.9) when value_in_use == 1, and scores 0 when disabled. + [Fact] + public void Score_ConfigPriorityBoost_Enabled_BandsWarning() + { + var facts = new List<Fact> + { + new() { Source = "config", Key = "CONFIG_PRIORITY_BOOST", Value = 1, Metadata = new() { ["value_in_use"] = 1 } }, + }; + new FactScorer().ScoreAll(facts); + var f = facts.First(f => f.Key == "CONFIG_PRIORITY_BOOST"); + Assert.True(f.Severity >= 0.75 && f.Severity < 1.5, "priority boost bands WARNING when enabled"); + } + + [Fact] + public void Score_ConfigPriorityBoost_Disabled_DoesNotScore() + { + var facts = new List<Fact> + { + new() { Source = "config", Key = "CONFIG_PRIORITY_BOOST", Value = 0, Metadata = new() { ["value_in_use"] = 0 } }, + }; + new FactScorer().ScoreAll(facts); + Assert.Equal(0.0, facts.First(f => f.Key == "CONFIG_PRIORITY_BOOST").Severity, precision: 4); + } + + // Lightweight pooling enabled is a Dashboard WARNING (install/50:401) — same shape. + [Fact] + public void Score_ConfigLightweightPooling_Enabled_BandsWarning() + { + var facts = new List<Fact> + { + new() { Source = "config", Key = "CONFIG_LIGHTWEIGHT_POOLING", Value = 1, Metadata = new() { ["value_in_use"] = 1 } }, + }; + new FactScorer().ScoreAll(facts); + var f = facts.First(f => f.Key == "CONFIG_LIGHTWEIGHT_POOLING"); + Assert.True(f.Severity >= 0.75 && f.Severity < 1.5, "lightweight pooling bands WARNING when enabled"); + } + + [Fact] + public void Score_ConfigLightweightPooling_Disabled_DoesNotScore() + { + var facts = new List<Fact> + { + new() { Source = "config", Key = "CONFIG_LIGHTWEIGHT_POOLING", Value = 0, Metadata = new() { ["value_in_use"] = 0 } }, + }; + new FactScorer().ScoreAll(facts); + Assert.Equal(0.0, facts.First(f => f.Key == "CONFIG_LIGHTWEIGHT_POOLING").Severity, precision: 4); + } + + // ARM 1 — Query Store off on a user database is an INFO advisory carried by the DB_CONFIG fact, + // detected as query_store_on_count < database_count (install/50:83 severity=INFO). Low 0.3 base = + // INFO band, and DB_CONFIG roots at any positive severity (a ConfigAdvisoryRootKey). + [Fact] + public void Score_DbConfig_QueryStoreOff_ScoresInfoAdvisory() + { + var facts = new List<Fact> + { + new() { Source = "database_config", Key = "DB_CONFIG", Value = 3, + Metadata = new() { ["database_count"] = 3, ["query_store_on_count"] = 1 } }, + }; + + new FactScorer().ScoreAll(facts); + + var db = facts.First(f => f.Key == "DB_CONFIG"); + Assert.Equal(0.3, db.Severity, precision: 4); + Assert.True(db.Severity > 0 && db.Severity < 0.75, "QS-off is an INFO-band advisory"); + } + + // Every user DB has Query Store on and nothing else drifted -> DB_CONFIG does not score. + [Fact] + public void Score_DbConfig_QueryStoreAllOn_NoContribution() + { + var facts = new List<Fact> + { + new() { Source = "database_config", Key = "DB_CONFIG", Value = 3, + Metadata = new() { ["database_count"] = 3, ["query_store_on_count"] = 3 } }, + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.0, facts.First(f => f.Key == "DB_CONFIG").Severity, precision: 4); + } + + // QS-off never lowers a worse co-present DB_CONFIG issue (existing arms unchanged): AUTO_SHRINK on + // 3 DBs stays 0.9 even with Query Store also off on all of them. + [Fact] + public void Score_DbConfig_QueryStoreOff_DoesNotLowerWorseIssue() + { + var facts = new List<Fact> + { + new() { Source = "database_config", Key = "DB_CONFIG", Value = 3, + Metadata = new() + { + ["database_count"] = 3, + ["query_store_on_count"] = 0, // all off + ["auto_shrink_on_count"] = 3 // worse issue -> 3 * 0.3 = 0.9 + } }, + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.9, facts.First(f => f.Key == "DB_CONFIG").Severity, precision: 4); + } + + // ── Tier-2 parity arms (batch 2): tempdb-deep — version-store size + PAGELATCH_UP allocation ───── + // Both are additions to the SHARED FactScorer, so they fire identically for Lite, Dashboard, and + // Darling findings. Every trip point is cited to the deprecated Dashboard's install SQL. + + // ARM 1 — tempdb VERSION-STORE pressure by ABSOLUTE size (max_version_store_mb in the TEMPDB_USAGE + // fact metadata), scored as the WORSE of space-fraction and version-store size. Tiers mirror + // report.tempdb_pressure's pressure_level CASE (install/47:1431-1433): > 5000 MB CRITICAL-tier + // (base 1.0), > 2000 MB HIGH (0.75), > 1000 MB MEDIUM (0.5 — the 0.5 root entry point). Space is only + // 20% full, whose sub-0.5 fraction score is dominated by the version-store arm via Math.Max, so the + // asserted severity IS the version-store tier — proving the arm scores independently of fill. + [Theory] + [InlineData(6000, 1.0)] // > 5000 -> CRITICAL tier (install/47:1431) + [InlineData(3000, 0.75)] // > 2000 -> HIGH tier (install/47:1432) + [InlineData(1500, 0.5)] // > 1000 -> MEDIUM tier (install/47:1433) + public void Score_TempDbVersionStore_TiersMatchDashboard(double versionMb, double expected) + { + var facts = new List<Fact> + { + new() { Source = "tempdb", Key = "TEMPDB_USAGE", Value = 0.20, // space only 20% full + Metadata = new() { ["max_version_store_mb"] = versionMb } }, + }; + + new FactScorer().ScoreAll(facts); + + var t = facts.First(f => f.Key == "TEMPDB_USAGE"); + Assert.Equal(expected, t.Severity, precision: 4); + Assert.True(t.Severity >= 0.5, "a version store over 1 GB must clear the 0.5 root threshold"); + } + + // A version store below the > 1000 MB bar does not score. Space fraction 0 isolates the version-store + // arm (the space-fraction arm yields a small sub-0.5 base for any positive fill), proving a 900 MB + // store trips nothing on its own. + [Fact] + public void Score_TempDbVersionStore_BelowThreshold_DoesNotScore() + { + var facts = new List<Fact> + { + new() { Source = "tempdb", Key = "TEMPDB_USAGE", Value = 0.0, + Metadata = new() { ["max_version_store_mb"] = 900 } }, // 900 MB — below the 1 GB bar + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.0, facts.First(f => f.Key == "TEMPDB_USAGE").Severity, precision: 4); + } + + // WORSE-of: the version-store arm never LOWERS a higher space-fraction score. Space 95% full (the + // fraction arm hits its 0.90 critical -> 1.0) with a small version store stays 1.0. + [Fact] + public void Score_TempDbVersionStore_DoesNotLowerWorseSpaceFraction() + { + var facts = new List<Fact> + { + new() { Source = "tempdb", Key = "TEMPDB_USAGE", Value = 0.95, + Metadata = new() { ["max_version_store_mb"] = 200 } }, // small VS, but space is critical + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(1.0, facts.First(f => f.Key == "TEMPDB_USAGE").Severity, precision: 4); + } + + // ARM 2 — tempdb allocation / PFS-GAM-SGAM contention scored off the PAGELATCH_UP wait fact by + // ABSOLUTE wait_time_ms (server-wide wait_stats, the SAME data the source view reads), tripping at + // > 10000 ms -> MEDIUM (install/47:2515: pagelatch_up_ms > 10000 -> "MEDIUM - PAGELATCH_UP + // contention"). Flat 0.5 — the view has no higher PAGELATCH_UP band. Value (fraction-of-period) only + // has to be > 0 to clear the wait guard; the absolute wait_time_ms is what scores. + [Fact] + public void Score_PageLatchUp_Over10Sec_ScoresMedium() + { + var facts = new List<Fact> + { + new() { Source = "waits", Key = "PAGELATCH_UP", Value = 0.01, + Metadata = new() { ["wait_time_ms"] = 15_000, ["waiting_tasks_count"] = 800 } }, + }; + + new FactScorer().ScoreAll(facts); + + var p = facts.First(f => f.Key == "PAGELATCH_UP"); + Assert.Equal(0.5, p.Severity, precision: 4); + Assert.True(p.Severity >= 0.5, "PAGELATCH_UP over the 10s bar must clear the 0.5 root threshold"); + } + + // At/under the 10000 ms bar PAGELATCH_UP stays context-only (a little allocation latching is normal). + [Fact] + public void Score_PageLatchUp_Under10Sec_DoesNotScore() + { + var facts = new List<Fact> + { + new() { Source = "waits", Key = "PAGELATCH_UP", Value = 0.01, + Metadata = new() { ["wait_time_ms"] = 8_000, ["waiting_tasks_count"] = 300 } }, + }; + + new FactScorer().ScoreAll(facts); + + Assert.Equal(0.0, facts.First(f => f.Key == "PAGELATCH_UP").Severity, precision: 4); + } + + // Confidence-hardening: the low-quality fallback floors at 0.5 AFTER the confidence multiply, so a + // fired thin-baseline anomaly stays >= InferenceEngine's 0.5 root entry-point even if confidence + // ever drops below 1.0 (it is a hardcoded 1.0 today; this guards the future). + [Theory] + [InlineData(1.0, 0.8, 0.5)] // at the bar, conf 0.8: 0.5*0.8 = 0.4 -> floored to 0.5 + [InlineData(1.5, 0.6, 0.5)] // 0.75*0.6 = 0.45 -> floored to 0.5 + [InlineData(2.0, 0.6, 0.6)] // 1.0*0.6 = 0.6 -> above the floor, kept + [InlineData(2.0, 1.0, 1.0)] // conf 1.0 unchanged + public void Score_LowQualityFallback_FloorsAtHalf_EvenWithSubUnitConfidence( + double exceedance, double confidence, double expected) + { + var fact = new Fact + { + Source = "anomaly", + Key = "ANOMALY_CPU_SPIKE", + Metadata = new() + { + ["deviation_sigma"] = 0.5, + ["baseline_low_quality"] = 1.0, + ["fallback_exceedance"] = exceedance, + ["confidence"] = confidence + } + }; + new FactScorer().ScoreAll(new List<Fact> { fact }); + Assert.Equal(expected, fact.BaseSeverity, precision: 4); + } + /* ── Helper ── */ /* ── Blocking-chain & compile-wait scoring ── */ diff --git a/Lite.Tests/FailedJobsQueryTests.cs b/Lite.Tests/FailedJobsQueryTests.cs new file mode 100644 index 000000000..48dffadfd --- /dev/null +++ b/Lite.Tests/FailedJobsQueryTests.cs @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Lite.Tests.Helpers; +using PerformanceMonitor.Alerting; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// <summary> +/// Identity pins for the Phase-5 slice-E extraction: the live msdb failed-jobs alert query moved +/// verbatim from this app's <c>RemoteCollectorService.RunningJobs.cs</c> (whitespace-normalized +/// identical to the Dashboard's <c>DatabaseService.NocHealth.cs</c> twin) into the shared +/// <see cref="FailedJobsQuery"/>. The clause pins are derived from the pre-extraction SQL, so a +/// drift in the shared copy fails a pin; the ReadAsync pins reproduce the pre-extraction row +/// mapping including its null handling. The ONE documented reconciliation: the step_id conversion +/// adopts the Dashboard's explicit InvariantCulture (behaviorally identical for an int column). +/// </summary> +public class FailedJobsQueryTests +{ + /* ---------------- the SQL contract ---------------- */ + + [Fact] + public void Sql_ContainsLoadBearingClauses() + { + var sql = FailedJobsQuery.Sql; + + /* Dirty read — never block a monitored server's Agent from an alert check. */ + Assert.Contains("SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;", sql); + + /* The sources: job history joined to job names. */ + Assert.Contains("FROM msdb.dbo.sysjobhistory AS jh", sql); + Assert.Contains("JOIN msdb.dbo.sysjobs AS j", sql); + + /* Bounded row count, newest failures first. */ + Assert.Contains("SELECT TOP (50)", sql); + Assert.Contains("run_datetime DESC", sql); + Assert.Contains("OPTION(RECOMPILE);", sql); + + /* The outcome-row filter: step_id = 0 is the per-run outcome row, run_status = 0 = FAILED. */ + Assert.Contains("WHERE jh.step_id = 0", sql); + Assert.Contains("AND jh.run_status = 0", sql); + + /* The failing-step resolution: OUTER APPLY correlates the actual failed step (step_id > 0, + run_status = 0) from THIS run via instance_id, bounded after the previous outcome row. */ + Assert.Contains("OUTER APPLY", sql); + Assert.Contains("AND s.step_id > 0", sql); + Assert.Contains("AND s.run_status = 0", sql); + Assert.Contains("AND s.instance_id < jh.instance_id", sql); + Assert.Contains("AND p.step_id = 0", sql); + + /* The outcome-row fallback when a job-level failure has no failed step row. */ + Assert.Contains("step_id = ISNULL(fs.step_id, jh.step_id)", sql); + Assert.Contains("step_name = ISNULL(fs.step_name, jh.step_name)", sql); + Assert.Contains("message = ISNULL(fs.message, jh.message)", sql); + } + + [Fact] + public void Sql_LookbackParameter_AppearsAtBothFilterSites() + { + Assert.Equal("@lookback_minutes", FailedJobsQuery.LookbackMinutesParameter); + + /* The lookback binds twice: the coarse run_date integer filter and the exact + server-local run_datetime filter. Both must survive — dropping the coarse one + makes the query scan all of sysjobhistory. */ + Assert.Equal(2, Regex.Matches(FailedJobsQuery.Sql, Regex.Escape(FailedJobsQuery.LookbackMinutesParameter)).Count); + Assert.Contains("jh.run_date >= CONVERT(integer, CONVERT(varchar(8), DATEADD(MINUTE, -@lookback_minutes, GETDATE()), 112))", FailedJobsQuery.Sql); + Assert.Contains(") >= DATEADD(MINUTE, -@lookback_minutes, GETDATE())", FailedJobsQuery.Sql); + } + + /* ---------------- the row mapping ---------------- */ + + [Fact] + public async Task ReadAsync_MapsRepresentativeRow() + { + var runTime = new DateTime(2026, 6, 30, 23, 45, 12); + var reader = new FakeCollectorDataReader(new object[] + { + "Nightly ETL", + "3f2504e0-4f89-11d3-9a0c-0305e82c3301", + runTime, + 3, + "Load fact table", + "Executed as user: NT SERVICE\\SQLSERVERAGENT. The step failed." + }); + + var items = await FailedJobsQuery.ReadAsync(reader, CancellationToken.None); + + var job = Assert.Single(items); + Assert.Equal("Nightly ETL", job.JobName); + Assert.Equal("3f2504e0-4f89-11d3-9a0c-0305e82c3301", job.JobId); + Assert.Equal(runTime, job.RunDateTime); + Assert.Equal(3, job.StepId); + Assert.Equal("Load fact table", job.StepName); + Assert.Equal("Executed as user: NT SERVICE\\SQLSERVERAGENT. The step failed.", job.Message); + } + + [Fact] + public async Task ReadAsync_NullColumns_MapToEmptyStringAndZero() + { + /* job_name is never null (sysjobs.name); every other column degrades like the + pre-extraction mapping: "" for strings, 0 for step_id. */ + var reader = new FakeCollectorDataReader(new object[] + { + "Nightly ETL", + DBNull.Value, + new DateTime(2026, 6, 30, 23, 45, 12), + DBNull.Value, + DBNull.Value, + DBNull.Value + }); + + var items = await FailedJobsQuery.ReadAsync(reader, CancellationToken.None); + + var job = Assert.Single(items); + Assert.Equal("", job.JobId); + Assert.Equal(0, job.StepId); + Assert.Equal("", job.StepName); + Assert.Equal("", job.Message); + } + + [Fact] + public async Task ReadAsync_EmptyReader_ReturnsEmptyList() + { + var items = await FailedJobsQuery.ReadAsync(new FakeCollectorDataReader(), CancellationToken.None); + Assert.Empty(items); + } +} diff --git a/Lite.Tests/FileIoCollectorDefinitionTests.cs b/Lite.Tests/FileIoCollectorDefinitionTests.cs new file mode 100644 index 000000000..55422be79 --- /dev/null +++ b/Lite.Tests/FileIoCollectorDefinitionTests.cs @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Pins the parity contract of the extracted file_io_stats definition: the two query variants, +/// the exclusion-filter splice, per-database execution on Azure, and the eight +/// "{database}|{file}" delta groups. +/// </summary> +public sealed class FileIoCollectorDefinitionTests +{ + private static readonly RecordingCollectorDeltaCalculator s_deltas = new(); + + [Fact] + public void BuildQuery_OnPrem_SplicesExclusionFilter_WithParameters() + { + var plan = FileIoStatsCollector.Instance.BuildQuery(new CollectorContext + { + ServerId = 42, + ServerName = "test-server", + CollectionTime = DateTime.UtcNow, + Deltas = s_deltas, + ExcludedDatabases = new[] { "AdventureWorks", "StackOverflow" }, + }); + + Assert.Contains("AND d.name NOT IN (@excl_db_0, @excl_db_1)", plan.Text, StringComparison.Ordinal); + Assert.DoesNotContain("/*EXCLUSION_FILTER*/", plan.Text, StringComparison.Ordinal); + Assert.Contains("sys.master_files", plan.Text, StringComparison.Ordinal); + Assert.Equal(2, plan.Parameters.Count); + Assert.Equal(("@excl_db_0", (object?)"AdventureWorks", CollectorParameterType.NVarChar128), + (plan.Parameters[0].Name, plan.Parameters[0].Value, plan.Parameters[0].Type)); + } + + [Fact] + public void BuildQuery_OnPrem_NoExclusions_RemovesToken_NoParameters() + { + var plan = FileIoStatsCollector.Instance.BuildQuery(CollectorTestContext.Make(s_deltas)); + + Assert.DoesNotContain("/*EXCLUSION_FILTER*/", plan.Text, StringComparison.Ordinal); + Assert.DoesNotContain("@excl_db_", plan.Text, StringComparison.Ordinal); + Assert.Empty(plan.Parameters); + } + + [Fact] + public void BuildQuery_Azure_ScopesToConnectedDatabase_AndRunsPerDatabase() + { + var plan = FileIoStatsCollector.Instance.BuildQuery(CollectorTestContext.Make(s_deltas, isAzureSqlDb: true)); + + Assert.Contains("sys.dm_io_virtual_file_stats(DB_ID(), NULL)", plan.Text, StringComparison.Ordinal); + Assert.Contains("sys.database_files", plan.Text, StringComparison.Ordinal); + Assert.Empty(plan.Parameters); + Assert.True(FileIoStatsCollector.Instance.RunsPerDatabase(new CollectorTargetInfo { IsAzureSqlDb = true })); + Assert.False(FileIoStatsCollector.Instance.RunsPerDatabase(new CollectorTargetInfo { IsAzureSqlDb = false })); + } + + [Fact] + public void PayloadColumns_MatchSchemaOrder() + { + Assert.Equal( + new[] + { + "database_name", "file_name", "file_type", "physical_name", "size_mb", + "num_of_reads", "num_of_writes", "read_bytes", "write_bytes", + "io_stall_read_ms", "io_stall_write_ms", "io_stall_queued_read_ms", "io_stall_queued_write_ms", + "delta_reads", "delta_writes", "delta_read_bytes", "delta_write_bytes", + "delta_stall_read_ms", "delta_stall_write_ms", "delta_stall_queued_read_ms", "delta_stall_queued_write_ms", + }, + FileIoStatsCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray()); + } + + [Fact] + public async Task ReadAsync_NullRow_MapsDefaults() + { + using var reader = new FakeCollectorDataReader( + new object[] { DBNull.Value, DBNull.Value, DBNull.Value, DBNull.Value, DBNull.Value, DBNull.Value, DBNull.Value, DBNull.Value, DBNull.Value, DBNull.Value, DBNull.Value, DBNull.Value, DBNull.Value, DBNull.Value, DBNull.Value }); + + var rows = await FileIoStatsCollector.Instance.ReadAsync(reader, CollectorTestContext.Make(s_deltas), CancellationToken.None); + + var row = Assert.Single(rows); + Assert.Equal("Unknown", row.DatabaseName); + Assert.Equal("", row.PhysicalName); + Assert.Equal(0L, row.NumOfReads); + } + + [Fact] + public void WritePayload_EmitsSchemaOrder_AndPinsDeltaKeyAndGroups() + { + var deltas = new RecordingCollectorDeltaCalculator(); + var writer = new RecordingCollectorRowWriter(); + var context = CollectorTestContext.Make(deltas); + var row = new FileIoStatsCollector.Row("SO", "SO_data", "ROWS", @"D:\so.mdf", 100.5m, 1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L, 9, 1); + + FileIoStatsCollector.Instance.WritePayload(row, writer, context); + + Assert.Equal(21, writer.Values.Count); + Assert.Equal("SO", writer.Values[0]); + Assert.Equal(8L, writer.Values[12]); + Assert.Equal(10L, writer.Values[13]); /* delta_reads = 1 * 10 */ + Assert.Equal(80L, writer.Values[20]); /* delta_stall_queued_write_ms = 8 * 10 */ + + Assert.Equal(8, deltas.Calls.Count); + Assert.All(deltas.Calls, c => Assert.Equal("SO|SO_data", c.Key)); + Assert.All(deltas.Calls, c => Assert.Equal(300, c.MaxGap)); + Assert.Equal( + new[] + { + "file_io_reads", "file_io_writes", "file_io_read_bytes", "file_io_write_bytes", + "file_io_stall_read", "file_io_stall_write", "file_io_stall_queued_read", "file_io_stall_queued_write", + }, + deltas.Calls.Select(c => c.Group).ToArray()); + } +} + +/// <summary> +/// Pins that the shared DatabaseExclusionFilter and Lite's original builder (still used by +/// un-migrated collectors) produce identical SQL — the temporary duplication cannot drift. +/// </summary> +public sealed class DatabaseExclusionFilterTests +{ + [Fact] + public void Empty_ReturnsEmptyClause_NoParameters() + { + var (clause, parameters) = DatabaseExclusionFilter.Build(null, "d.name"); + Assert.Equal(string.Empty, clause); + Assert.Empty(parameters); + } + + [Fact] + public void Names_ProduceParameterizedNotIn() + { + var (clause, parameters) = DatabaseExclusionFilter.Build(new[] { "A", "B" }, "d.name"); + + Assert.Equal("AND d.name NOT IN (@excl_db_0, @excl_db_1)", clause); + Assert.Equal(2, parameters.Count); + Assert.Equal("A", parameters[0].Value); + Assert.All(parameters, p => Assert.Equal(CollectorParameterType.NVarChar128, p.Type)); + } + + [Fact] + public void MatchesLiteOriginalBuilder_ClauseAndParameters() + { + var names = new[] { "AdventureWorks", "Stack Overflow" }; + + var (sharedClause, sharedParams) = DatabaseExclusionFilter.Build(names, "d.name"); + var (liteClause, liteParams) = RemoteCollectorService.BuildDatabaseExclusionFilter(names, "d.name"); + + Assert.Equal(liteClause, sharedClause); + Assert.Equal(liteParams.Count, sharedParams.Count); + for (int i = 0; i < liteParams.Count; i++) + { + Assert.Equal(liteParams[i].ParameterName, sharedParams[i].Name); + Assert.Equal(liteParams[i].Value, sharedParams[i].Value); + Assert.Equal(System.Data.SqlDbType.NVarChar, liteParams[i].SqlDbType); + Assert.Equal(128, liteParams[i].Size); + } + } +} diff --git a/Lite.Tests/FinOpsInventoryQueryTests.cs b/Lite.Tests/FinOpsInventoryQueryTests.cs new file mode 100644 index 000000000..3605715f3 --- /dev/null +++ b/Lite.Tests/FinOpsInventoryQueryTests.cs @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// <summary> +/// #1535 regression guard: the FinOps live server-inventory query must read edition/version/storage +/// from permission-free scalars and OBJECT_ID-guarded dynamic SQL only, never from +/// <c>sys.dm_os_sys_info</c>. On Azure SQL DB that DMV requires VIEW DATABASE STATE; when a +/// monitoring login lacked it the old combined query threw and the server lost its ENTIRE inventory +/// row (edition, version, storage — everything). This pins the split so inventory can never +/// re-couple to the DMV, and the hardware facts (the one part that needs it) stay in an isolated +/// best-effort read. +/// </summary> +public class FinOpsInventoryQueryTests +{ + [Fact] + public void InventoryQuery_DoesNotDependOnDmOsSysInfo() + { + Assert.DoesNotContain("dm_os_sys_info", LocalDataService.InventoryQueryText, StringComparison.Ordinal); + } + + [Fact] + public void HardwareQuery_IsolatesTheDmvRead() + { + Assert.Contains("sys.dm_os_sys_info", LocalDataService.HardwareQueryText, StringComparison.Ordinal); + // The hardware-only columns must ride the isolated read, not the inventory query. + Assert.Contains("cpu_count", LocalDataService.HardwareQueryText, StringComparison.Ordinal); + Assert.Contains("sqlserver_start_time", LocalDataService.HardwareQueryText, StringComparison.Ordinal); + } + + [Fact] + public void InventoryQuery_StillDetectsEditionAndStaysAzureSafeForStorage() + { + // Edition/version classification survives on permission-free scalars. + Assert.Contains("SERVERPROPERTY('EngineEdition')", LocalDataService.InventoryQueryText, StringComparison.Ordinal); + // Storage stays Azure-safe: the engine-edition branch reads sys.database_files on Azure SQL DB + // (sys.master_files does not exist there). + Assert.Contains("sys.database_files", LocalDataService.InventoryQueryText, StringComparison.Ordinal); + } +} diff --git a/Lite.Tests/FindingStoreTests.cs b/Lite.Tests/FindingStoreTests.cs index c0915ba26..8b18ea8de 100644 --- a/Lite.Tests/FindingStoreTests.cs +++ b/Lite.Tests/FindingStoreTests.cs @@ -1,9 +1,12 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; +using DuckDB.NET.Data; using PerformanceMonitor.Analysis; using PerformanceMonitorLite.Analysis; +using PerformanceMonitorLite.Analysis.Recommendations; using PerformanceMonitorLite.Database; using Xunit; @@ -150,6 +153,25 @@ public async Task CleanupOldFindings_RemovesExpiredData() Assert.Empty(findings); } + [Fact] + public async Task AnalysisServiceCleanup_RemovesExpiredData() + { + // The collection background service schedules findings retention through + // AnalysisService.CleanupAsync (previously declared but never called — analysis_findings + // grew unbounded until a size-triggered DB reset wiped it). Prove the wrapper the + // scheduler now invokes purges through to the store. + await InitializeWithAnalysisAsync(); + + var store = new FindingStore(_duckDb); + var context = TestDataSeeder.CreateTestContext(); + await store.SaveFindingsAsync(CreateTestStories(), context); + + await new AnalysisService(_duckDb).CleanupAsync(retentionDays: 0); + + var findings = await store.GetLatestFindingsAsync(context.ServerId); + Assert.Empty(findings); + } + [Fact] public async Task FullPipeline_FindingStoreIntegration() { @@ -269,6 +291,299 @@ public async Task SaveFindings_BatchedCall_StillFiltersMutedAndAbsolution() Assert.Equal(stories[1].StoryPathHash, saved[0].StoryPathHash); } + [Fact] + public async Task MuteStory_AllServersSentinel_PersistsNull_AndFiltersEveryServer() + { + // serverId 0 is the MCP "mute across all servers" sentinel. The store must persist it as + // NULL (the canonical global marker), and the mute must then apply to any real server. + await InitializeWithAnalysisAsync(); + + var store = new FindingStore(_duckDb); + var stories = CreateTestStories(); + + await store.MuteStoryAsync(0, stories[0].StoryPathHash, stories[0].StoryPath, "All-servers mute"); + + // Persisted as NULL, not 0. + Assert.Null(await ReadMutedServerIdAsync(stories[0].StoryPathHash)); + + // A save under an arbitrary real server drops the globally-muted story. + var context = TestDataSeeder.CreateTestContext(); + var saved = await store.SaveFindingsAsync(stories, context); + + Assert.Single(saved); + Assert.Equal(stories[1].StoryPathHash, saved[0].StoryPathHash); + } + + [Fact] + public async Task SaveFindings_HonorsLegacyZeroServerIdMute_AsGlobal() + { + // Rows written by the pre-fix all-servers tool path carry a literal server_id = 0. The reader + // must still honor them as global so those legacy mutes keep muting everywhere. + await InitializeWithAnalysisAsync(); + + var store = new FindingStore(_duckDb); + var stories = CreateTestStories(); + + await InsertLegacyMutedRowAsync(serverId: 0, stories[0].StoryPathHash, stories[0].StoryPath); + + var context = TestDataSeeder.CreateTestContext(); + var saved = await store.SaveFindingsAsync(stories, context); + + Assert.Single(saved); + Assert.Equal(stories[1].StoryPathHash, saved[0].StoryPathHash); + } + + [Fact] + public async Task SaveFindings_PerServerMuteForAnotherServer_DoesNotLeak() + { + // A mute scoped to one real server must never filter another server's findings. + await InitializeWithAnalysisAsync(); + + var store = new FindingStore(_duckDb); + var stories = CreateTestStories(); + + // Mute story[0] for a DIFFERENT server (context.ServerId is TestDataSeeder.TestServerId = -999). + await store.MuteStoryAsync(12345, stories[0].StoryPathHash, stories[0].StoryPath, "Other-server mute"); + + var context = TestDataSeeder.CreateTestContext(); + var saved = await store.SaveFindingsAsync(stories, context); + + // Both stories survive here — the other server's mute must not reach this server. + Assert.Equal(2, saved.Count); + } + + // ── Persisted RemediationAction round-trip (the Lite copy-paste command parity fix) ────────── + + [Fact] + public async Task TwoPhaseWrite_PersistsRemediationAction_AndRendersCommandOnReadBack() + { + // Mirrors the AnalysisService pipeline: FilterMutedFindingsAsync -> attach the BUILT action -> + // InsertFindingsAsync persists remediation_action_json. The read-back deserializes it via the + // shared serializer, and the shared renderer turns it into the copy-paste command — proving the + // typed action survives the DuckDB round-trip and Lite produces a runnable command from storage. + await InitializeWithAnalysisAsync(); + + var store = new FindingStore(_duckDb); + var context = TestDataSeeder.CreateTestContext(); + + var stories = new List<AnalysisStory> + { + new AnalysisStory + { + RootFactKey = "DB_CONFIG", + RootFactValue = 1.2, + Severity = 1.2, + Confidence = 0.9, + Category = "config", + Path = ["DB_CONFIG"], + StoryPath = "DB_CONFIG", + StoryPathHash = "reco_action_hash", + StoryText = "auto-shrink is on", + DatabaseName = "StackOverflow", + FactCount = 1 + } + }; + + // Phase 1: filter (no insert yet). + var survivors = await store.FilterMutedFindingsAsync(stories, context); + var survivor = Assert.Single(survivors); + Assert.Null(survivor.Remediation); // not built yet + + // Attach the BUILT action between the phases, then insert. + survivor.Remediation = new RemediationAction( + "DB_CONFIG", "set", Array.Empty<ForcePlanTarget>(), + DbConfigTargets: new[] { new DbConfigTarget("StackOverflow", DbConfigSetting.AutoShrinkOff) }); + await store.InsertFindingsAsync(survivors, context); + + // Read back: the action is hydrated and renders the byte-identical command. + var readBack = await store.GetLatestFindingsAsync(context.ServerId); + var finding = Assert.Single(readBack); + Assert.NotNull(finding.Remediation); + Assert.Equal("DB_CONFIG", finding.Remediation!.FactKey); + Assert.Equal( + "ALTER DATABASE [StackOverflow] SET AUTO_SHRINK OFF;", + LiteRecommendationsReader.BuildCopyPasteSql(finding.Remediation)); + } + + [Fact] + public async Task SaveFindings_WithNoAction_PersistsNullRemediation_ReadsBackNull() + { + // The single-pass SaveFindingsAsync wrapper attaches no action; the column persists NULL and + // reads back as a null Remediation (no command) — the pre-fix behaviour for action-less rows. + await InitializeWithAnalysisAsync(); + + var store = new FindingStore(_duckDb); + var context = TestDataSeeder.CreateTestContext(); + + await store.SaveFindingsAsync(CreateTestStories(), context); + + var readBack = await store.GetLatestFindingsAsync(context.ServerId); + Assert.Equal(2, readBack.Count); + Assert.All(readBack, f => Assert.Null(f.Remediation)); + Assert.All(readBack, f => Assert.Null(LiteRecommendationsReader.BuildCopyPasteSql(f.Remediation))); + } + + // ── Analysis-schema upgrade path (v3 -> v4 adds remediation_action_json without data loss) ────── + + [Fact] + public async Task AnalysisSchemaUpgrade_FromV3_AddsRemediationColumn_PreservingExistingRows() + { + // Simulate an EXISTING Lite DB at analysis-schema v3 (the incident_id era, no + // remediation_action_json). InitializeAnalysisSchemaAsync must ALTER the column in via the + // v4 migration WITHOUT dropping the pre-existing finding row, then a fresh finding with an + // action persists + reads back — the real upgrade path an existing user hits. + await CreateLegacyV3AnalysisSchemaAsync(legacyFindingId: 4242); + + // Run the upgrade. + await _duckDb.InitializeAnalysisSchemaAsync(); + + // The column now exists. + Assert.True(await ColumnExistsAsync("analysis_findings", "remediation_action_json"), + "v4 migration should add remediation_action_json"); + // The schema version advanced to v4. + Assert.Equal(AnalysisSchema.CurrentVersion, await ReadAnalysisSchemaVersionAsync()); + // The legacy row survived (no data loss) and reads back with a null action. + var store = new FindingStore(_duckDb); + var afterUpgrade = await store.GetLatestFindingsAsync(TestDataSeeder.TestServerId); + var legacy = Assert.Single(afterUpgrade); + Assert.Equal(4242, legacy.FindingId); + Assert.Null(legacy.Remediation); + + // Idempotent: running the init again does not throw or regress the version. + await _duckDb.InitializeAnalysisSchemaAsync(); + Assert.Equal(AnalysisSchema.CurrentVersion, await ReadAnalysisSchemaVersionAsync()); + } + + /// <summary> + /// Builds an analysis_findings table in the OLD (v3) shape — no remediation_action_json — with + /// analysis_schema_version = 3 and one pre-existing finding row, exactly what an un-upgraded Lite + /// DB looks like before this change. + /// </summary> + private async Task CreateLegacyV3AnalysisSchemaAsync(long legacyFindingId) + { + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + + // v3 DDL: every column through incident_id, but WITHOUT remediation_action_json. + await ExecAsync(connection, @" +CREATE TABLE analysis_findings ( + finding_id BIGINT PRIMARY KEY, + analysis_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + database_name VARCHAR, + time_range_start TIMESTAMP, + time_range_end TIMESTAMP, + severity DOUBLE PRECISION NOT NULL, + confidence DOUBLE PRECISION NOT NULL, + category VARCHAR NOT NULL, + story_path VARCHAR NOT NULL, + story_path_hash VARCHAR NOT NULL, + story_text VARCHAR NOT NULL, + root_fact_key VARCHAR NOT NULL, + root_fact_value DOUBLE PRECISION, + leaf_fact_key VARCHAR, + leaf_fact_value DOUBLE PRECISION, + fact_count INTEGER NOT NULL, + incident_id VARCHAR +)"); + await ExecAsync(connection, "CREATE TABLE analysis_schema_version (version INTEGER NOT NULL)"); + await ExecAsync(connection, "INSERT INTO analysis_schema_version (version) VALUES (3)"); + + using var insert = connection.CreateCommand(); + insert.CommandText = @" +INSERT INTO analysis_findings + (finding_id, analysis_time, server_id, server_name, database_name, + time_range_start, time_range_end, severity, confidence, category, + story_path, story_path_hash, story_text, + root_fact_key, root_fact_value, leaf_fact_key, leaf_fact_value, fact_count, incident_id) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)"; + insert.Parameters.Add(new DuckDBParameter { Value = legacyFindingId }); + insert.Parameters.Add(new DuckDBParameter { Value = DateTime.UtcNow }); + insert.Parameters.Add(new DuckDBParameter { Value = TestDataSeeder.TestServerId }); + insert.Parameters.Add(new DuckDBParameter { Value = "SQL2022" }); + insert.Parameters.Add(new DuckDBParameter { Value = "StackOverflow" }); + insert.Parameters.Add(new DuckDBParameter { Value = DateTime.UtcNow.AddHours(-4) }); + insert.Parameters.Add(new DuckDBParameter { Value = DateTime.UtcNow }); + insert.Parameters.Add(new DuckDBParameter { Value = 1.0 }); + insert.Parameters.Add(new DuckDBParameter { Value = 0.9 }); + insert.Parameters.Add(new DuckDBParameter { Value = "waits" }); + insert.Parameters.Add(new DuckDBParameter { Value = "LEGACY" }); + insert.Parameters.Add(new DuckDBParameter { Value = "legacy_v3_hash" }); + insert.Parameters.Add(new DuckDBParameter { Value = "legacy story" }); + insert.Parameters.Add(new DuckDBParameter { Value = "LEGACY" }); + insert.Parameters.Add(new DuckDBParameter { Value = 1.0 }); + insert.Parameters.Add(new DuckDBParameter { Value = DBNull.Value }); + insert.Parameters.Add(new DuckDBParameter { Value = DBNull.Value }); + insert.Parameters.Add(new DuckDBParameter { Value = 1 }); + insert.Parameters.Add(new DuckDBParameter { Value = "legacy_incident" }); + await insert.ExecuteNonQueryAsync(); + } + + private async Task<bool> ColumnExistsAsync(string table, string column) + { + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = + "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = $1 AND column_name = $2"; + cmd.Parameters.Add(new DuckDBParameter { Value = table }); + cmd.Parameters.Add(new DuckDBParameter { Value = column }); + return Convert.ToInt32(await cmd.ExecuteScalarAsync()) == 1; + } + + private async Task<int> ReadAnalysisSchemaVersionAsync() + { + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT COALESCE(MAX(version), 0) FROM analysis_schema_version"; + return Convert.ToInt32(await cmd.ExecuteScalarAsync()); + } + + private static async Task ExecAsync(DuckDBConnection connection, string sql) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + await cmd.ExecuteNonQueryAsync(); + } + + /// <summary>Reads the stored server_id for a muted story hash (null when persisted as NULL).</summary> + private async Task<int?> ReadMutedServerIdAsync(string storyPathHash) + { + using var readLock = _duckDb.AcquireReadLock(); + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT server_id FROM analysis_muted WHERE story_path_hash = $1"; + cmd.Parameters.Add(new DuckDBParameter { Value = storyPathHash }); + + var result = await cmd.ExecuteScalarAsync(); + return result is null or DBNull ? null : Convert.ToInt32(result); + } + + /// <summary>Inserts a muted row with a caller-chosen literal server_id, bypassing the store's + /// 0->NULL collapse so a pre-fix legacy row (server_id = 0) can be simulated.</summary> + private async Task InsertLegacyMutedRowAsync(int serverId, string storyPathHash, string storyPath) + { + using var readLock = _duckDb.AcquireReadLock(); + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +INSERT INTO analysis_muted (mute_id, server_id, story_path_hash, story_path, muted_date, reason) +VALUES ($1, $2, $3, $4, $5, $6)"; + cmd.Parameters.Add(new DuckDBParameter { Value = 987654321L }); + cmd.Parameters.Add(new DuckDBParameter { Value = serverId }); + cmd.Parameters.Add(new DuckDBParameter { Value = storyPathHash }); + cmd.Parameters.Add(new DuckDBParameter { Value = storyPath }); + cmd.Parameters.Add(new DuckDBParameter { Value = DateTime.UtcNow }); + cmd.Parameters.Add(new DuckDBParameter { Value = DBNull.Value }); + await cmd.ExecuteNonQueryAsync(); + } + private static System.Collections.Generic.List<AnalysisStory> CreateTestStories() { return diff --git a/Lite.Tests/Fixtures/SystemHealth/error_reported.xml b/Lite.Tests/Fixtures/SystemHealth/error_reported.xml new file mode 100644 index 000000000..0e31a927c --- /dev/null +++ b/Lite.Tests/Fixtures/SystemHealth/error_reported.xml @@ -0,0 +1,11 @@ +<event name="error_reported" package="sqlserver" timestamp="2026-07-05T12:00:05.500Z"> + <data name="error_number"><value>823</value></data> + <data name="severity"><value>24</value></data> + <data name="state"><value>2</value></data> + <data name="user_defined"><value>false</value></data> + <data name="category"><type name="error_reported_category" package="sqlserver" /><value>2</value><text>SERVER</text></data> + <data name="destination"><value>0x00000002</value><text>ERRORLOG</text></data> + <data name="is_intercepted"><value>false</value></data> + <data name="database_id"><value>6</value></data> + <data name="message"><value>The operating system returned error 21 to SQL Server during a read at offset 0x00000c60000 in file 'D:\data\prod.mdf'.</value></data> +</event> diff --git a/Lite.Tests/Fixtures/SystemHealth/memory_broker.xml b/Lite.Tests/Fixtures/SystemHealth/memory_broker.xml new file mode 100644 index 000000000..354927535 --- /dev/null +++ b/Lite.Tests/Fixtures/SystemHealth/memory_broker.xml @@ -0,0 +1,14 @@ +<event name="memory_broker_ring_buffer_recorded" package="sqlserver" timestamp="2026-07-05T12:02:10.250Z"> + <data name="id"><value>1</value></data> + <data name="pool_metadata_id"><value>0</value></data> + <data name="delta_time"><value>30000</value></data> + <data name="memory_ratio"><value>85</value></data> + <data name="new_target"><value>1048576</value></data> + <data name="overall"><value>2097152</value></data> + <data name="rate"><value>512</value></data> + <data name="currently_predicated"><value>900000</value></data> + <data name="currently_allocated"><value>1000000</value></data> + <data name="previously_allocated"><value>1100000</value></data> + <data name="broker"><value>MEMORYBROKER_FOR_CACHE</value></data> + <data name="notification"><value>RESOURCE_MEMPHYSICAL_HIGH</value></data> +</event> diff --git a/Lite.Tests/Fixtures/SystemHealth/memory_node_oom.xml b/Lite.Tests/Fixtures/SystemHealth/memory_node_oom.xml new file mode 100644 index 000000000..7e7797f4b --- /dev/null +++ b/Lite.Tests/Fixtures/SystemHealth/memory_node_oom.xml @@ -0,0 +1,27 @@ +<event name="memory_node_oom_ring_buffer_recorded" package="sqlserver" timestamp="2026-07-05T12:03:20.750Z"> + <data name="id"><value>0</value></data> + <data name="memory_node_id"><value>0</value></data> + <data name="memory_utilization_pct"><value>72</value></data> + <data name="total_physical_memory_kb"><value>16777216</value></data> + <data name="available_physical_memory_kb"><value>4194304</value></data> + <data name="total_page_file_kb"><value>25165824</value></data> + <data name="available_page_file_kb"><value>8388608</value></data> + <data name="total_virtual_address_space_kb"><value>137438953472</value></data> + <data name="available_virtual_address_space_kb"><value>137000000000</value></data> + <data name="target_kb"><value>12582912</value></data> + <data name="reserved_kb"><value>1048576</value></data> + <data name="committed_kb"><value>10485760</value></data> + <data name="shared_committed_kb"><value>2048</value></data> + <data name="awe_kb"><value>0</value></data> + <data name="pages_kb"><value>9437184</value></data> + <data name="failure"><type name="memory_node_oom_failure" package="sqlserver" /><value>2</value><text>NUMA_NODE_UNAVAILABLE</text></data> + <data name="resources"><value>1</value></data> + <data name="factor"><type name="oom_factor" package="sqlserver" /><value>3</value><text>FACTORY_PROCESS</text></data> + <data name="last_error"><value>0</value></data> + <data name="pool_metadata_id"><value>0</value></data> + <data name="is_process_in_job"><value>true</value></data> + <data name="is_system_physical_memory_high"><value>false</value></data> + <data name="is_system_physical_memory_low"><value>true</value></data> + <data name="is_process_physical_memory_low"><value>false</value></data> + <data name="is_process_virtual_memory_low"><value>false</value></data> +</event> diff --git a/Lite.Tests/Fixtures/SystemHealth/scheduler_monitor.xml b/Lite.Tests/Fixtures/SystemHealth/scheduler_monitor.xml new file mode 100644 index 000000000..bae3a49de --- /dev/null +++ b/Lite.Tests/Fixtures/SystemHealth/scheduler_monitor.xml @@ -0,0 +1,10 @@ +<event name="scheduler_monitor_system_health_ring_buffer_recorded" package="sqlserver" timestamp="2026-07-05T11:58:00.123Z"> + <data name="scheduler_id"><value>10</value></data> + <data name="cpu_id"><value>3</value></data> + <data name="status"><type name="scheduler_monitor_status" package="sqlserver" /><value>2</value><text>WARNING</text></data> + <data name="is_online"><value>1</value><text>true</text></data> + <data name="is_runnable"><value>0</value><text>false</text></data> + <data name="is_running"><value>1</value><text>true</text></data> + <data name="non_yielding_time"><value>4500</value></data> + <data name="thread_quantum"><value>4</value></data> +</event> diff --git a/Lite.Tests/Fixtures/SystemHealth/sp_server_diagnostics_io_subsystem.xml b/Lite.Tests/Fixtures/SystemHealth/sp_server_diagnostics_io_subsystem.xml new file mode 100644 index 000000000..5f782644b --- /dev/null +++ b/Lite.Tests/Fixtures/SystemHealth/sp_server_diagnostics_io_subsystem.xml @@ -0,0 +1,16 @@ +<event name="sp_server_diagnostics_component_result" package="sqlserver" timestamp="2026-07-05T12:05:00.000Z"> + <data name="component"><type name="sql_server_diagnostics_component" package="sqlserver" /><value>4</value><text>IO_SUBSYSTEM</text></data> + <data name="state"><value>2</value><text>WARNING</text></data> + <data name="data"> + <type name="xml" package="package0" /> + <value> + <ioSubsystem ioLatchTimeouts="2" intervalLongIos="5" totalLongIos="42"> + <longestPendingRequests> + <pendingRequest duration="1000" filePath="D:\data\prod.mdf" fileId="1" databaseId="6" /> + <pendingRequest duration="500" filePath="D:\data\prod.mdf" fileId="1" databaseId="6" /> + <pendingRequest duration="750" filePath="E:\log\prod.ldf" fileId="2" databaseId="6" /> + </longestPendingRequests> + </ioSubsystem> + </value> + </data> +</event> diff --git a/Lite.Tests/Fixtures/SystemHealth/sp_server_diagnostics_query_processing.xml b/Lite.Tests/Fixtures/SystemHealth/sp_server_diagnostics_query_processing.xml new file mode 100644 index 000000000..9b5c82d16 --- /dev/null +++ b/Lite.Tests/Fixtures/SystemHealth/sp_server_diagnostics_query_processing.xml @@ -0,0 +1,16 @@ +<event name="sp_server_diagnostics_component_result" package="sqlserver" timestamp="2026-07-05T12:01:00.000Z"> + <data name="component"><type name="sql_server_diagnostics_component" package="sqlserver" /><value>1</value><text>QUERY_PROCESSING</text></data> + <data name="state"><value>1</value><text>CLEAN</text></data> + <data name="data"> + <type name="xml" package="package0" /> + <value> + <queryProcessing maxWorkers="512" workersCreated="40" workersIdle="12" tasksCompletedWithinInterval="100" pendingTasks="0" oldestPendingTaskWaitingTime="0" hasUnresolvableDeadlockOccurred="0" hasDeadlockedSchedulersOccurred="0" trackingNonYieldingScheduler="0x0"> + <topWaits> + <nonPreemptive> + <byCount /> + </nonPreemptive> + </topWaits> + </queryProcessing> + </value> + </data> +</event> diff --git a/Lite.Tests/Fixtures/SystemHealth/sp_server_diagnostics_query_processing_warning.xml b/Lite.Tests/Fixtures/SystemHealth/sp_server_diagnostics_query_processing_warning.xml new file mode 100644 index 000000000..a798d27ca --- /dev/null +++ b/Lite.Tests/Fixtures/SystemHealth/sp_server_diagnostics_query_processing_warning.xml @@ -0,0 +1,28 @@ +<event name="sp_server_diagnostics_component_result" package="sqlserver" timestamp="2026-07-05T12:06:00.000Z"> + <data name="component"><type name="sql_server_diagnostics_component" package="sqlserver" /><value>1</value><text>QUERY_PROCESSING</text></data> + <data name="state"><value>2</value><text>WARNING</text></data> + <data name="data"> + <type name="xml" package="package0" /> + <value> + <queryProcessing maxWorkers="960" workersCreated="500" workersIdle="20" tasksCompletedWithinInterval="1234" pendingTasks="15" oldestPendingTaskWaitingTime="8000" hasUnresolvableDeadlockOccurred="1" hasDeadlockedSchedulersOccurred="1" trackingNonYieldingScheduler="0x0"> + <topWaits> + <nonPreemptive> + <byCount> + <wait waitType="LCK_M_X" waits="10" averageWaitTime="500" maxWaitTime="1000" /> + </byCount> + </nonPreemptive> + </topWaits> + <blockingTasks> + <blocked-process-report monitorLoop="1"> + <blocked-process> + <process id="process1" waitresource="KEY: 6:123" /> + </blocked-process> + <blocking-process> + <process id="process2" /> + </blocking-process> + </blocked-process-report> + </blockingTasks> + </queryProcessing> + </value> + </data> +</event> diff --git a/Lite.Tests/Fixtures/SystemHealth/sp_server_diagnostics_resource.xml b/Lite.Tests/Fixtures/SystemHealth/sp_server_diagnostics_resource.xml new file mode 100644 index 000000000..801f55880 --- /dev/null +++ b/Lite.Tests/Fixtures/SystemHealth/sp_server_diagnostics_resource.xml @@ -0,0 +1,41 @@ +<event name="sp_server_diagnostics_component_result" package="sqlserver" timestamp="2026-07-05T12:01:00.000Z"> + <data name="component"><type name="sql_server_diagnostics_component" package="sqlserver" /><value>2</value><text>RESOURCE</text></data> + <data name="state"><value>1</value><text>CLEAN</text></data> + <data name="data"> + <type name="xml" package="package0" /> + <value> + <resource lastNotification="RESOURCE_MEMPHYSICAL_HIGH" outOfMemoryExceptions="0" isAnyPoolOutOfMemory="0" processOutOfMemoryPeriod="0"> + <memoryReport name="Process/System Counts" unit="Value"> + <entry description="Available Physical Memory" value="8589934592" /> + <entry description="Available Virtual Memory" value="17179869184" /> + <entry description="Available Paging File" value="4294967296" /> + <entry description="Working Set" value="2147483648" /> + <entry description="Percent of Committed Memory in WS" value="99" /> + <entry description="Page Faults" value="123456" /> + <entry description="System physical memory high" value="1" /> + <entry description="System physical memory low" value="0" /> + <entry description="Process physical memory low" value="0" /> + <entry description="Process virtual memory low" value="0" /> + </memoryReport> + <memoryReport name="Memory Manager" unit="KB"> + <entry description="VM Reserved" value="8388608" /> + <entry description="VM Committed" value="4194304" /> + <entry description="Locked Pages Allocated" value="0" /> + <entry description="Large Pages Allocated" value="0" /> + <entry description="Emergency Memory" value="1024" /> + <entry description="Emergency Memory In Use" value="16" /> + <entry description="Target Committed" value="6291456" /> + <entry description="Current Committed" value="4194304" /> + <entry description="Pages Allocated" value="524288" /> + <entry description="Pages Reserved" value="0" /> + <entry description="Pages Free" value="1024" /> + <entry description="Pages In Use" value="200000" /> + <entry description="Page Alloc Potential" value="700000" /> + <entry description="NUMA Growth Phase" value="0" /> + <entry description="Last OOM Factor" value="0" /> + <entry description="Last OS Error" value="0" /> + </memoryReport> + </resource> + </value> + </data> +</event> diff --git a/Lite.Tests/Fixtures/SystemHealth/sp_server_diagnostics_system.xml b/Lite.Tests/Fixtures/SystemHealth/sp_server_diagnostics_system.xml new file mode 100644 index 000000000..9b8337a09 --- /dev/null +++ b/Lite.Tests/Fixtures/SystemHealth/sp_server_diagnostics_system.xml @@ -0,0 +1,10 @@ +<event name="sp_server_diagnostics_component_result" package="sqlserver" timestamp="2026-07-05T12:07:00.000Z"> + <data name="component"><type name="sql_server_diagnostics_component" package="sqlserver" /><value>0</value><text>SYSTEM</text></data> + <data name="state"><value>1</value><text>CLEAN</text></data> + <data name="data"> + <type name="xml" package="package0" /> + <value> + <system spinlockBackoffs="12345" sickSpinlockType="SOS_CACHESTORE" sickSpinlockTypeAfterAv="SOS_SUSPEND_QUEUE" latchWarnings="7" isAccessViolationOccurred="1" writeAccessViolationCount="3" totalDumpRequests="9" intervalDumpRequests="2" nonYieldingTasksReported="4" pageFaults="654321" systemCpuUtilization="65" sqlCpuUtilization="40" BadPagesDetected="6" BadPagesFixed="5" LastBadPageAddress="0x0" /> + </value> + </data> +</event> \ No newline at end of file diff --git a/Lite.Tests/Fixtures/SystemHealth/wait_info.xml b/Lite.Tests/Fixtures/SystemHealth/wait_info.xml new file mode 100644 index 000000000..b63364b97 --- /dev/null +++ b/Lite.Tests/Fixtures/SystemHealth/wait_info.xml @@ -0,0 +1,9 @@ +<event name="wait_info" package="sqlserver" timestamp="2026-07-05T12:04:30.900Z"> + <data name="wait_type"><type name="wait_types" package="sqlserver" /><value>66</value><text>PAGEIOLATCH_SH</text></data> + <data name="opcode"><type name="event_opcode" package="package0" /><value>1</value><text>End</text></data> + <data name="duration"><value>1500</value></data> + <data name="signal_duration"><value>12</value></data> + <data name="wait_resource"><value>7:1:12345</value></data> + <action name="session_id" package="sqlserver"><value>57</value></action> + <action name="sql_text" package="sqlserver"><value>SELECT * FROM dbo.big_table WHERE id = @p1;</value></action> +</event> diff --git a/Lite.Tests/GenericWebhookTests.cs b/Lite.Tests/GenericWebhookTests.cs new file mode 100644 index 000000000..f905d0ee7 --- /dev/null +++ b/Lite.Tests/GenericWebhookTests.cs @@ -0,0 +1,269 @@ +using System.Collections.Generic; +using System.Net.Http; +using System.Text.Json; +using PerformanceMonitor.Notifications; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// <summary> +/// #1506: the generic webhook channel. The channel exists so an alert can drive automation we ship no +/// adapter for (PagerDuty, Opsgenie, n8n, a GitHub <c>repository_dispatch</c> that re-runs a workflow) +/// WITHOUT executing a script or an exe — so the two things that must hold are that the operator's JSON +/// body template cannot be broken out of by alert data, and that a malformed config degrades to a clear +/// error instead of an exception in the alert loop. +/// </summary> +public class GenericWebhookTests +{ + private static readonly AlertBranding Branding = new("Performance Monitor Lite", null); + + private static string Build(string metric, string server, string value = "0", string threshold = "0", string? template = null) + => WebhookAlertService.BuildGenericPayload(metric, server, value, threshold, Branding, bodyTemplate: template); + + /* ---------------- JSON escaping (the injection guard) ---------------- */ + + [Fact] + public void ServerNameWithQuote_DoesNotBreakOutOfTheJsonStringLiteral() + { + /* A server name carrying a double quote is the classic break-out: naive substitution into + "server": "{{server}}" would terminate the literal and corrupt — or inject into — the payload. */ + var payload = Build("High CPU", "SRV\"; DROP TABLE x; --"); + + var root = JsonDocument.Parse(payload).RootElement; // must still be valid JSON + Assert.Equal("SRV\"; DROP TABLE x; --", root.GetProperty("server").GetString()); + } + + [Fact] + public void ServerNameWithBackslash_IsEscapedAndRoundTrips() + { + /* A named instance (HOST\INSTANCE) is the everyday case: an unescaped backslash makes "\I" an + invalid JSON escape and the whole body unparseable. */ + var payload = Build("High CPU", @"HOST\SQLEXPRESS"); + + var root = JsonDocument.Parse(payload).RootElement; + Assert.Equal(@"HOST\SQLEXPRESS", root.GetProperty("server").GetString()); + } + + [Fact] + public void LoneSurrogate_DoesNotThrow_AndProducesValidJson() + { + /* SQL Server nvarchar/sysname can hold an unpaired surrogate (NCHAR(0xD800)). JsonEncodedText.Encode + THROWS on that — which would let a low-privileged user drop this channel for the incident they cause + while email/Teams/Slack still deliver. The escape must relax it (U+FFFD), never throw. */ + var payload = Build("High CPU", "SRV\uD800X"); // lone high surrogate + + var root = JsonDocument.Parse(payload).RootElement; // valid JSON, no throw + Assert.StartsWith("SRV", root.GetProperty("server").GetString()); + } + + [Fact] + public void ValueContainingAnotherPlaceholder_IsNotReExpanded() + { + /* Single-pass substitution: a server name that literally contains "{{timestamp}}" must render verbatim, + not pull the timestamp field into itself (chained .Replace would). One field can't bleed into another. */ + var payload = Build("High CPU", "SRV {{timestamp}} {{value}}", value: "42"); + + var root = JsonDocument.Parse(payload).RootElement; + Assert.Equal("SRV {{timestamp}} {{value}}", root.GetProperty("server").GetString()); + Assert.Equal("42", root.GetProperty("value").GetString()); + } + + [Fact] + public void InjectedJsonStructure_StaysDataNotStructure() + { + /* The value must land as a STRING, not as new JSON structure: an attacker-ish value that closes the + literal and opens a new key must round-trip verbatim inside "value", adding no properties. */ + var payload = Build("High CPU", "SRV", value: "99\", \"injected\": \"pwned"); + + var root = JsonDocument.Parse(payload).RootElement; + Assert.Equal("99\", \"injected\": \"pwned", root.GetProperty("value").GetString()); + Assert.False(root.TryGetProperty("injected", out _)); + } + + [Fact] + public void EveryPlaceholderIsSubstituted_AndNoneRemain() + { + var payload = Build("Server Unreachable", "SRV", value: "Login timeout", threshold: "Online"); + + Assert.DoesNotContain("{{", payload, System.StringComparison.Ordinal); + + var root = JsonDocument.Parse(payload).RootElement; + Assert.Equal("Server Unreachable", root.GetProperty("metric").GetString()); + Assert.Equal("SRV", root.GetProperty("server").GetString()); + Assert.Equal("Login timeout", root.GetProperty("value").GetString()); + Assert.Equal("Online", root.GetProperty("threshold").GetString()); + /* Severity comes from the shared AlertSeverity map, which already knows this metric. */ + Assert.Equal("CRITICAL", root.GetProperty("severity").GetString()); + Assert.False(string.IsNullOrWhiteSpace(root.GetProperty("timestamp").GetString())); + } + + [Fact] + public void OperatorTemplate_IsHonored_AndEscapedTheSameWay() + { + /* The motivating use case: a GitHub repository_dispatch body. Placeholders sit inside nested string + literals, and a hostile server name must not escape them. */ + const string template = + """{"event_type": "sql-monitor-alert", "client_payload": {"metric": "{{metric}}", "server": "{{server}}"}}"""; + + var payload = Build("Server Unreachable", "SRV\"evil", template: template); + + var root = JsonDocument.Parse(payload).RootElement; + Assert.Equal("sql-monitor-alert", root.GetProperty("event_type").GetString()); + Assert.Equal("SRV\"evil", root.GetProperty("client_payload").GetProperty("server").GetString()); + } + + [Fact] + public void EmptyTemplate_FallsBackToTheDefault() + { + var payload = Build("High CPU", "SRV", template: " "); + + var root = JsonDocument.Parse(payload).RootElement; + Assert.Equal("SRV", root.GetProperty("server").GetString()); + } + + [Fact] + public void DefaultTemplate_IsItselfValidJson() + { + /* Guards the shipped default: a typo there would break every un-customized deployment. */ + var payload = Build("High CPU", "SRV", template: WebhookAlertService.DefaultGenericBodyTemplate); + JsonDocument.Parse(payload); + } + + /* ---------------- headers parsing (never throw into the alert loop) ---------------- */ + + [Fact] + public void Headers_ParseIntoRequestHeaders() + { + var ok = WebhookAlertService.TryParseHeaders( + """{"Authorization": "Bearer ghp_token", "Accept": "application/vnd.github+json"}""", + out var headers, out var error); + + Assert.True(ok); + Assert.Null(error); + Assert.Equal("Bearer ghp_token", headers["Authorization"]); + Assert.Equal("application/vnd.github+json", headers["Accept"]); + } + + [Fact] + public void Headers_EmptyIsValid_MeaningNoCustomHeaders() + { + Assert.True(WebhookAlertService.TryParseHeaders("", out var headers, out var error)); + Assert.Empty(headers); + Assert.Null(error); + } + + [Fact] + public void Headers_MalformedJson_FailsGracefullyWithAMessage() + { + /* A truncated paste is the realistic operator error. It must come back as an error, NOT an exception. */ + var ok = WebhookAlertService.TryParseHeaders("""{"Authorization": "Bearer x" """, out var headers, out var error); + + Assert.False(ok); + Assert.Empty(headers); + Assert.NotNull(error); + Assert.Contains("not valid JSON", error, System.StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Headers_NonObjectRoot_IsRejected() + { + Assert.False(WebhookAlertService.TryParseHeaders("""["Authorization", "Bearer x"]""", out _, out var error)); + Assert.NotNull(error); + Assert.Contains("JSON object", error, System.StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Headers_NonStringValue_IsRejectedByName() + { + Assert.False(WebhookAlertService.TryParseHeaders("""{"Retry-After": 30}""", out _, out var error)); + Assert.NotNull(error); + Assert.Contains("Retry-After", error, System.StringComparison.Ordinal); + } + + [Fact] + public void Headers_ValueWithCrlf_IsRejected_NoHeaderSmuggling() + { + /* .NET's TryAddWithoutValidation lets a CR/LF in a header VALUE onto the wire, splitting it into a + second header. Reject it at parse time so a token pasted with a stray newline can't smuggle one. */ + var ok = WebhookAlertService.TryParseHeaders( + "{\"Authorization\": \"Bearer good\\r\\nX-Smuggled: injected\"}", out _, out var error); + + Assert.False(ok); + Assert.NotNull(error); + Assert.Contains("control character", error, System.StringComparison.OrdinalIgnoreCase); + } + + /* ---------------- the settings-window validator ---------------- */ + + [Fact] + public void ValidateGenericConfig_AcceptsAGoodConfig() + { + Assert.Null(WebhookAlertService.ValidateGenericConfig( + """{"Authorization": "Bearer x"}""", + """{"metric": "{{metric}}"}""")); + } + + [Fact] + public void ValidateGenericConfig_RejectsMalformedHeaders() + { + Assert.NotNull(WebhookAlertService.ValidateGenericConfig("{not json", null)); + } + + [Fact] + public void ValidateGenericConfig_RejectsATemplateThatCannotProduceValidJson() + { + /* An unquoted placeholder: the substituted text lands outside a string literal, so the body is not + JSON. Caught at Save, rather than silently dropping every alert afterwards. */ + Assert.NotNull(WebhookAlertService.ValidateGenericConfig(null, """{"metric": {{metric}}}""")); + } + + /* ---------------- ApplyHeaders (request/content header routing) ---------------- */ + + private static (HttpRequestMessage Request, HttpContent Content) ApplyHeaders(Dictionary<string, string> headers) + { + var content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json"); + var request = new HttpRequestMessage(HttpMethod.Post, "https://example.test/hook") { Content = content }; + WebhookAlertService.ApplyHeaders(request, content, headers); + return (request, content); + } + + [Fact] + public void ApplyHeaders_DefaultsUserAgent_WhenOperatorSetsNone() + { + /* GitHub's REST API 403s a request with no User-Agent, and repository_dispatch is the motivating + case — so one is defaulted rather than handing the operator a 403 to diagnose. */ + var (request, _) = ApplyHeaders(new Dictionary<string, string> { ["Accept"] = "application/vnd.github+json" }); + + Assert.True(request.Headers.Contains("User-Agent")); + Assert.Equal("PerformanceMonitor", string.Join("", request.Headers.GetValues("User-Agent"))); + } + + [Fact] + public void ApplyHeaders_DoesNotOverrideAnOperatorUserAgent() + { + var (request, _) = ApplyHeaders(new Dictionary<string, string> { ["User-Agent"] = "my-agent/2.0" }); + + Assert.Equal("my-agent/2.0", string.Join("", request.Headers.GetValues("User-Agent"))); + } + + [Fact] + public void ApplyHeaders_RoutesContentTypeOntoTheContent_NotDroppedAsARequestHeader() + { + /* Content-Type is a CONTENT header — TryAddWithoutValidation on request.Headers rejects it, so a naive + add would silently drop the override. ApplyHeaders must move it onto the content, replacing the + default application/json StringContent set. (The framework won't even let you QUERY Content-Type on + request.Headers — it throws "misused header name" — which is the same rule that makes routing it onto + the content necessary; so the content-side assertion is the meaningful proof.) */ + var (_, content) = ApplyHeaders(new Dictionary<string, string> { ["Content-Type"] = "application/vnd.github+json" }); + + Assert.Equal("application/vnd.github+json", content.Headers.ContentType?.ToString()); + } + + [Fact] + public void ApplyHeaders_AddsAnOrdinaryOperatorHeader() + { + var (request, _) = ApplyHeaders(new Dictionary<string, string> { ["Authorization"] = "Bearer ghp_token" }); + + Assert.Equal("Bearer ghp_token", string.Join("", request.Headers.GetValues("Authorization"))); + } +} diff --git a/Lite.Tests/GoldenCollectorSchema.cs b/Lite.Tests/GoldenCollectorSchema.cs new file mode 100644 index 000000000..10ba13203 --- /dev/null +++ b/Lite.Tests/GoldenCollectorSchema.cs @@ -0,0 +1,888 @@ +// <auto-generated> +// FROZEN verbatim snapshot of the 35 hand-written collector CREATE TABLE / CREATE INDEX statements +// exactly as they existed in Lite/Database/Schema.cs immediately BEFORE the catalog-driven +// DuckDbSchemaGenerator replaced them (feature/parity-collapse-lite-schema base = origin/dev). +// +// DO NOT EDIT. This is the equivalence ORACLE: DuckDbSchemaEquivalenceTests executes each of these +// against a fresh DuckDB database and asserts the generated schema produces a byte-identical +// PRAGMA table_info (columns, DuckDB types, order, NOT NULL, DEFAULT, PRIMARY KEY) and matching +// indexes. Editing this file would defeat the proof that existing DuckDB stores are safe. +// Extracted mechanically from the pre-change Schema.cs constants to guarantee byte-exactness. +// </auto-generated> + +using System.Collections.Generic; + +namespace PerformanceMonitorLite.Tests; + +internal static class GoldenCollectorSchema +{ + /// <summary>Pre-change CREATE TABLE DDL, keyed by collector target table (36 entries — the original + /// 35 pre-generation tables plus long_query_completions, #1496, added at its post-collapse migration).</summary> + public static readonly IReadOnlyDictionary<string, string> Tables = new Dictionary<string, string> + { + ["wait_stats"] = @"CREATE TABLE IF NOT EXISTS wait_stats ( + collection_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + wait_type VARCHAR NOT NULL, + waiting_tasks_count BIGINT, + wait_time_ms BIGINT, + signal_wait_time_ms BIGINT, + delta_waiting_tasks BIGINT, + delta_wait_time_ms BIGINT, + delta_signal_wait_time_ms BIGINT +)", + ["latch_stats"] = @"CREATE TABLE IF NOT EXISTS latch_stats ( + collection_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + latch_class VARCHAR NOT NULL, + waiting_requests_count BIGINT, + wait_time_ms BIGINT, + max_wait_time_ms BIGINT, + delta_waiting_requests_count BIGINT, + delta_wait_time_ms BIGINT, + delta_max_wait_time_ms BIGINT +)", + ["spinlock_stats"] = @"CREATE TABLE IF NOT EXISTS spinlock_stats ( + collection_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + spinlock_name VARCHAR NOT NULL, + collisions BIGINT, + spins BIGINT, + spins_per_collision DOUBLE, + sleep_time BIGINT, + backoffs BIGINT, + delta_collisions BIGINT, + delta_spins BIGINT, + delta_sleep_time BIGINT, + delta_backoffs BIGINT +)", + ["cpu_scheduler_stats"] = @"CREATE TABLE IF NOT EXISTS cpu_scheduler_stats ( + collection_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + max_workers_count INTEGER, + scheduler_count INTEGER, + cpu_count INTEGER, + total_runnable_tasks_count INTEGER, + total_work_queue_count BIGINT, + total_current_workers_count INTEGER, + avg_runnable_tasks_count DECIMAL(38,2), + total_active_request_count INTEGER, + total_queued_request_count INTEGER, + total_blocked_task_count INTEGER, + total_active_parallel_thread_count BIGINT, + runnable_request_count INTEGER, + total_request_count INTEGER, + runnable_percent DECIMAL(38,2), + worker_thread_exhaustion_warning BOOLEAN, + runnable_tasks_warning BOOLEAN, + blocked_tasks_warning BOOLEAN, + queued_requests_warning BOOLEAN, + total_physical_memory_kb BIGINT, + available_physical_memory_kb BIGINT, + system_memory_state_desc VARCHAR, + physical_memory_pressure_warning BOOLEAN, + total_node_count INTEGER, + nodes_online_count INTEGER, + offline_cpu_count INTEGER, + offline_cpu_warning BOOLEAN +)", + ["plan_cache_stats"] = @"CREATE TABLE IF NOT EXISTS plan_cache_stats ( + collection_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + cacheobjtype VARCHAR NOT NULL, + objtype VARCHAR NOT NULL, + total_plans INTEGER, + total_size_mb INTEGER, + single_use_plans INTEGER, + single_use_size_mb INTEGER, + multi_use_plans INTEGER, + multi_use_size_mb INTEGER, + avg_use_count DECIMAL(38,2), + avg_size_kb INTEGER, + oldest_plan_create_time TIMESTAMP +)", + ["tempdb_stats"] = @"CREATE TABLE IF NOT EXISTS tempdb_stats ( + collection_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + user_object_reserved_mb DECIMAL(18,2), + internal_object_reserved_mb DECIMAL(18,2), + version_store_reserved_mb DECIMAL(18,2), + total_reserved_mb DECIMAL(18,2), + unallocated_mb DECIMAL(18,2), + total_sessions_using_tempdb BIGINT, + top_session_id INTEGER, + top_session_tempdb_mb DECIMAL(18,2) +)", + ["memory_grant_stats"] = @"CREATE TABLE IF NOT EXISTS memory_grant_stats ( + collection_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + resource_semaphore_id SMALLINT, + pool_id INTEGER, + target_memory_mb DECIMAL(18,2), + max_target_memory_mb DECIMAL(18,2), + total_memory_mb DECIMAL(18,2), + available_memory_mb DECIMAL(18,2), + granted_memory_mb DECIMAL(18,2), + used_memory_mb DECIMAL(18,2), + grantee_count INTEGER, + waiter_count INTEGER, + timeout_error_count BIGINT, + forced_grant_count BIGINT, + timeout_error_count_delta BIGINT, + forced_grant_count_delta BIGINT +)", + ["cpu_utilization_stats"] = @"CREATE TABLE IF NOT EXISTS cpu_utilization_stats ( + collection_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + sample_time TIMESTAMP NOT NULL, + sqlserver_cpu_utilization INTEGER, + other_process_cpu_utilization INTEGER +)", + ["memory_stats"] = @"CREATE TABLE IF NOT EXISTS memory_stats ( + collection_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + total_physical_memory_mb DECIMAL(18,2), + available_physical_memory_mb DECIMAL(18,2), + total_page_file_mb DECIMAL(18,2), + available_page_file_mb DECIMAL(18,2), + system_memory_state VARCHAR, + sql_memory_model VARCHAR, + target_server_memory_mb DECIMAL(18,2), + total_server_memory_mb DECIMAL(18,2), + buffer_pool_mb DECIMAL(18,2), + plan_cache_mb DECIMAL(18,2), + max_workers_count INTEGER, + current_workers_count INTEGER +)", + ["memory_clerks"] = @"CREATE TABLE IF NOT EXISTS memory_clerks ( + collection_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + clerk_type VARCHAR NOT NULL, + memory_mb DECIMAL(18,2) +)", + ["memory_pressure_events"] = @"CREATE TABLE IF NOT EXISTS memory_pressure_events ( + collection_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + sample_time TIMESTAMP NOT NULL, + memory_notification VARCHAR NOT NULL, + memory_indicators_process INTEGER NOT NULL, + memory_indicators_system INTEGER NOT NULL +)", + ["file_io_stats"] = @"CREATE TABLE IF NOT EXISTS file_io_stats ( + collection_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + database_name VARCHAR, + file_name VARCHAR, + file_type VARCHAR, + physical_name VARCHAR, + size_mb DECIMAL(18,2), + num_of_reads BIGINT, + num_of_writes BIGINT, + read_bytes BIGINT, + write_bytes BIGINT, + io_stall_read_ms BIGINT, + io_stall_write_ms BIGINT, + io_stall_queued_read_ms BIGINT, + io_stall_queued_write_ms BIGINT, + delta_reads BIGINT, + delta_writes BIGINT, + delta_read_bytes BIGINT, + delta_write_bytes BIGINT, + delta_stall_read_ms BIGINT, + delta_stall_write_ms BIGINT, + delta_stall_queued_read_ms BIGINT, + delta_stall_queued_write_ms BIGINT +)", + ["server_properties"] = @"CREATE TABLE IF NOT EXISTS server_properties ( + collection_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + edition VARCHAR NOT NULL, + product_version VARCHAR NOT NULL, + product_level VARCHAR NOT NULL, + product_update_level VARCHAR, + engine_edition INTEGER NOT NULL, + cpu_count INTEGER NOT NULL, + hyperthread_ratio INTEGER NOT NULL, + physical_memory_mb BIGINT NOT NULL, + socket_count INTEGER, + cores_per_socket INTEGER, + is_hadr_enabled BOOLEAN, + is_clustered BOOLEAN, + enterprise_features VARCHAR, + service_objective VARCHAR, + vcore_count INTEGER, + lock_pages_in_memory BOOLEAN, + instant_file_initialization_enabled BOOLEAN, + memory_dump_count INTEGER, + sqlserver_start_time TIMESTAMP, + host_os_version VARCHAR, + ag_replica_role VARCHAR, + utc_offset_minutes INTEGER +)", + ["server_config"] = @"CREATE TABLE IF NOT EXISTS server_config ( + config_id BIGINT PRIMARY KEY, + capture_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + configuration_name VARCHAR NOT NULL, + value_configured BIGINT, + value_in_use BIGINT, + is_dynamic BOOLEAN, + is_advanced BOOLEAN +)", + ["database_config"] = @"CREATE TABLE IF NOT EXISTS database_config ( + config_id BIGINT PRIMARY KEY, + capture_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + database_name VARCHAR NOT NULL, + state_desc VARCHAR, + compatibility_level INTEGER, + collation_name VARCHAR, + recovery_model VARCHAR, + is_read_only BOOLEAN, + is_auto_close_on BOOLEAN, + is_auto_shrink_on BOOLEAN, + is_auto_create_stats_on BOOLEAN, + is_auto_update_stats_on BOOLEAN, + is_auto_update_stats_async_on BOOLEAN, + is_read_committed_snapshot_on BOOLEAN, + snapshot_isolation_state VARCHAR, + is_parameterization_forced BOOLEAN, + is_query_store_on BOOLEAN, + is_encrypted BOOLEAN, + is_trustworthy_on BOOLEAN, + is_db_chaining_on BOOLEAN, + is_broker_enabled BOOLEAN, + is_cdc_enabled BOOLEAN, + is_mixed_page_allocation_on BOOLEAN, + log_reuse_wait_desc VARCHAR, + page_verify_option VARCHAR, + target_recovery_time_seconds INTEGER, + delayed_durability VARCHAR, + is_accelerated_database_recovery_on BOOLEAN, + is_memory_optimized_enabled BOOLEAN, + is_optimized_locking_on BOOLEAN +)", + ["trace_flags"] = @"CREATE TABLE IF NOT EXISTS trace_flags ( + config_id BIGINT PRIMARY KEY, + capture_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + trace_flag INTEGER NOT NULL, + status BOOLEAN NOT NULL, + is_global BOOLEAN NOT NULL, + is_session BOOLEAN NOT NULL +)", + ["database_scoped_config"] = @"CREATE TABLE IF NOT EXISTS database_scoped_config ( + config_id BIGINT PRIMARY KEY, + capture_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + database_name VARCHAR NOT NULL, + configuration_name VARCHAR NOT NULL, + value VARCHAR, + value_for_secondary VARCHAR +)", + ["session_stats"] = @"CREATE TABLE IF NOT EXISTS session_stats ( + collection_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + program_name VARCHAR NOT NULL, + connection_count BIGINT NOT NULL, + running_count INTEGER NOT NULL, + sleeping_count INTEGER NOT NULL, + dormant_count INTEGER NOT NULL, + total_cpu_time_ms BIGINT, + total_reads BIGINT, + total_writes BIGINT, + total_logical_reads BIGINT +)", + ["session_summary_stats"] = @"CREATE TABLE IF NOT EXISTS session_summary_stats ( + collection_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + total_sessions INTEGER NOT NULL, + running_sessions INTEGER NOT NULL, + sleeping_sessions INTEGER NOT NULL, + background_sessions INTEGER NOT NULL, + dormant_sessions INTEGER NOT NULL, + idle_sessions_over_30min INTEGER NOT NULL, + sessions_waiting_for_memory INTEGER NOT NULL, + databases_with_connections INTEGER NOT NULL, + top_application_name VARCHAR, + top_application_connections INTEGER, + top_host_name VARCHAR, + top_host_connections INTEGER +)", + ["waiting_tasks"] = @"CREATE TABLE IF NOT EXISTS waiting_tasks ( + collection_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + session_id INTEGER, + wait_type VARCHAR, + wait_duration_ms BIGINT, + blocking_session_id INTEGER, + resource_description VARCHAR, + database_name VARCHAR +)", + ["procedure_stats"] = @"CREATE TABLE IF NOT EXISTS procedure_stats ( + collection_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + database_name VARCHAR, + schema_name VARCHAR, + object_name VARCHAR, + object_type VARCHAR, + cached_time TIMESTAMP, + last_execution_time TIMESTAMP, + execution_count BIGINT, + total_worker_time BIGINT, + total_elapsed_time BIGINT, + total_logical_reads BIGINT, + total_physical_reads BIGINT, + total_logical_writes BIGINT, + min_worker_time BIGINT, + max_worker_time BIGINT, + min_elapsed_time BIGINT, + max_elapsed_time BIGINT, + min_logical_reads BIGINT, + max_logical_reads BIGINT, + min_physical_reads BIGINT, + max_physical_reads BIGINT, + min_logical_writes BIGINT, + max_logical_writes BIGINT, + total_spills BIGINT, + min_spills BIGINT, + max_spills BIGINT, + sql_handle VARCHAR, + plan_handle VARCHAR, + delta_execution_count BIGINT, + delta_worker_time BIGINT, + delta_elapsed_time BIGINT, + delta_logical_reads BIGINT, + delta_logical_writes BIGINT, + delta_physical_reads BIGINT, + delta_spills BIGINT, + query_plan_xml VARCHAR +)", + ["running_jobs"] = @"CREATE TABLE IF NOT EXISTS running_jobs ( + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + job_name VARCHAR NOT NULL, + job_id VARCHAR NOT NULL, + job_enabled BOOLEAN NOT NULL, + start_time TIMESTAMP NOT NULL, + current_duration_seconds BIGINT NOT NULL, + avg_duration_seconds BIGINT NOT NULL, + p95_duration_seconds BIGINT NOT NULL, + successful_run_count BIGINT NOT NULL, + is_running_long BOOLEAN NOT NULL, + percent_of_average DECIMAL(10,1) +)", + ["perfmon_stats"] = @"CREATE TABLE IF NOT EXISTS perfmon_stats ( + collection_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + object_name VARCHAR NOT NULL, + counter_name VARCHAR NOT NULL, + instance_name VARCHAR, + cntr_value BIGINT, + delta_cntr_value BIGINT, + sample_interval_seconds INTEGER +)", + ["dmv_blocking_snapshots"] = @"CREATE TABLE IF NOT EXISTS dmv_blocking_snapshots ( + collection_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + monitor_loop INTEGER NOT NULL, + event_time TIMESTAMP, + database_name VARCHAR, + blocked_spid INTEGER, + blocked_ecid INTEGER, + blocked_last_tran_started TIMESTAMP, + blocking_spid INTEGER, + blocking_ecid INTEGER, + blocking_last_tran_started TIMESTAMP, + wait_time_ms BIGINT, + lock_mode VARCHAR, + blocking_status VARCHAR, + contentious_object VARCHAR, + blocked_sql_text VARCHAR, + blocking_sql_text VARCHAR, + blocked_login_name VARCHAR, + blocked_host_name VARCHAR, + blocked_client_app VARCHAR, + blocking_login_name VARCHAR, + blocking_host_name VARCHAR, + blocking_client_app VARCHAR +)", + ["database_size_stats"] = @"CREATE TABLE IF NOT EXISTS database_size_stats ( + collection_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + database_name VARCHAR NOT NULL, + database_id INTEGER NOT NULL, + file_id INTEGER NOT NULL, + file_type_desc VARCHAR NOT NULL, + file_name VARCHAR NOT NULL, + physical_name VARCHAR NOT NULL, + total_size_mb DECIMAL(19,2) NOT NULL, + used_size_mb DECIMAL(19,2), + auto_growth_mb DECIMAL(19,2), + max_size_mb DECIMAL(19,2), + recovery_model_desc VARCHAR, + compatibility_level INTEGER, + state_desc VARCHAR, + volume_mount_point VARCHAR, + volume_total_mb DECIMAL(19,2), + volume_free_mb DECIMAL(19,2), + is_percent_growth BOOLEAN, + growth_pct INTEGER, + vlf_count INTEGER +)", + ["index_object_stats"] = @"CREATE TABLE IF NOT EXISTS index_object_stats ( + collection_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + sqlserver_start_time TIMESTAMP, + database_name VARCHAR NOT NULL, + database_id INTEGER NOT NULL, + schema_name VARCHAR NOT NULL, + object_id INTEGER NOT NULL, + table_name VARCHAR NOT NULL, + index_id INTEGER NOT NULL, + index_name VARCHAR, + index_type_desc VARCHAR, + is_unique BOOLEAN, + is_primary_key BOOLEAN, + is_filtered BOOLEAN, + partition_count INTEGER, + reserved_mb DECIMAL(19,2), + used_mb DECIMAL(19,2), + in_row_data_mb DECIMAL(19,2), + lob_data_mb DECIMAL(19,2), + row_overflow_mb DECIMAL(19,2), + total_rows BIGINT, + user_seeks BIGINT, + user_scans BIGINT, + user_lookups BIGINT, + user_updates BIGINT, + last_user_seek TIMESTAMP, + last_user_scan TIMESTAMP, + last_user_lookup TIMESTAMP, + last_user_update TIMESTAMP, + leaf_insert_count BIGINT, + leaf_update_count BIGINT, + leaf_delete_count BIGINT, + range_scan_count BIGINT, + singleton_lookup_count BIGINT, + row_lock_count BIGINT, + row_lock_wait_count BIGINT, + row_lock_wait_in_ms BIGINT, + page_lock_count BIGINT, + page_lock_wait_count BIGINT, + page_lock_wait_in_ms BIGINT, + index_lock_promotion_attempt_count BIGINT, + index_lock_promotion_count BIGINT, + page_latch_wait_count BIGINT, + page_latch_wait_in_ms BIGINT, + page_io_latch_wait_count BIGINT, + page_io_latch_wait_in_ms BIGINT, + key_columns VARCHAR, + included_columns VARCHAR, + filter_definition VARCHAR, + is_unique_constraint BOOLEAN, + is_foreign_key BOOLEAN, + is_foreign_key_reference BOOLEAN, + is_disabled BOOLEAN, + data_compression_desc VARCHAR, + optimize_for_sequential_key BOOLEAN, + fill_factor SMALLINT, + is_padded BOOLEAN, + allow_page_locks BOOLEAN, + allow_row_locks BOOLEAN, + is_indexed_view BOOLEAN +)", + ["query_stats"] = @"CREATE TABLE IF NOT EXISTS query_stats ( + collection_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + database_name VARCHAR, + query_hash VARCHAR, + query_plan_hash VARCHAR, + creation_time TIMESTAMP, + last_execution_time TIMESTAMP, + execution_count BIGINT, + total_worker_time BIGINT, + total_elapsed_time BIGINT, + total_logical_reads BIGINT, + total_logical_writes BIGINT, + total_physical_reads BIGINT, + total_clr_time BIGINT, + total_rows BIGINT, + total_spills BIGINT, + min_worker_time BIGINT, + max_worker_time BIGINT, + min_elapsed_time BIGINT, + max_elapsed_time BIGINT, + min_physical_reads BIGINT, + max_physical_reads BIGINT, + min_rows BIGINT, + max_rows BIGINT, + min_dop BIGINT, + max_dop BIGINT, + min_grant_kb BIGINT, + max_grant_kb BIGINT, + min_used_grant_kb BIGINT, + max_used_grant_kb BIGINT, + min_ideal_grant_kb BIGINT, + max_ideal_grant_kb BIGINT, + min_reserved_threads BIGINT, + max_reserved_threads BIGINT, + min_used_threads BIGINT, + max_used_threads BIGINT, + min_spills BIGINT, + max_spills BIGINT, + query_text VARCHAR, + query_plan_xml VARCHAR, + sql_handle VARCHAR, + plan_handle VARCHAR, + delta_execution_count BIGINT, + delta_worker_time BIGINT, + delta_elapsed_time BIGINT, + delta_logical_reads BIGINT, + delta_logical_writes BIGINT, + delta_physical_reads BIGINT, + delta_rows BIGINT, + delta_spills BIGINT, + plan_generation_num BIGINT, + sample_interval_seconds INTEGER +)", + ["query_snapshots"] = @"CREATE TABLE IF NOT EXISTS query_snapshots ( + collection_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + session_id INTEGER, + database_name VARCHAR, + elapsed_time_formatted VARCHAR, + query_text VARCHAR, + query_plan VARCHAR, + live_query_plan VARCHAR, + status VARCHAR, + blocking_session_id INTEGER, + wait_type VARCHAR, + wait_time_ms BIGINT, + wait_resource VARCHAR, + cpu_time_ms BIGINT, + total_elapsed_time_ms BIGINT, + reads BIGINT, + writes BIGINT, + logical_reads BIGINT, + granted_query_memory_gb DECIMAL(18,2), + transaction_isolation_level VARCHAR, + dop INTEGER, + parallel_worker_count INTEGER, + login_name VARCHAR, + host_name VARCHAR, + program_name VARCHAR, + open_transaction_count INTEGER, + percent_complete DECIMAL(5,2), + is_cdc_capture BOOLEAN DEFAULT false, + query_hash VARCHAR, + requested_memory_mb DOUBLE PRECISION, + used_memory_mb DOUBLE PRECISION, + max_used_memory_mb DOUBLE PRECISION, + tempdb_current_mb DOUBLE PRECISION, + tempdb_allocations_mb DOUBLE PRECISION, + tran_log_used_mb DOUBLE PRECISION, + tran_start_time TIMESTAMP, + request_id INTEGER +)", + ["query_store_stats"] = @"CREATE TABLE IF NOT EXISTS query_store_stats ( + collection_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + database_name VARCHAR NOT NULL, + query_id BIGINT, + plan_id BIGINT, + execution_type_desc VARCHAR, + first_execution_time TIMESTAMP, + last_execution_time TIMESTAMP, + module_name VARCHAR, + query_text VARCHAR, + query_hash VARCHAR, + execution_count BIGINT, + avg_duration_us BIGINT, + min_duration_us BIGINT, + max_duration_us BIGINT, + avg_cpu_time_us BIGINT, + min_cpu_time_us BIGINT, + max_cpu_time_us BIGINT, + avg_logical_io_reads BIGINT, + min_logical_io_reads BIGINT, + max_logical_io_reads BIGINT, + avg_logical_io_writes BIGINT, + min_logical_io_writes BIGINT, + max_logical_io_writes BIGINT, + avg_physical_io_reads BIGINT, + min_physical_io_reads BIGINT, + max_physical_io_reads BIGINT, + avg_clr_time_us BIGINT, + min_clr_time_us BIGINT, + max_clr_time_us BIGINT, + min_dop BIGINT, + max_dop BIGINT, + avg_query_max_used_memory BIGINT, + min_query_max_used_memory BIGINT, + max_query_max_used_memory BIGINT, + avg_rowcount BIGINT, + min_rowcount BIGINT, + max_rowcount BIGINT, + avg_num_physical_io_reads BIGINT, + min_num_physical_io_reads BIGINT, + max_num_physical_io_reads BIGINT, + avg_log_bytes_used BIGINT, + min_log_bytes_used BIGINT, + max_log_bytes_used BIGINT, + avg_tempdb_space_used BIGINT, + min_tempdb_space_used BIGINT, + max_tempdb_space_used BIGINT, + plan_type VARCHAR, + plan_forcing_type VARCHAR, + is_forced_plan BOOLEAN, + force_failure_count BIGINT, + last_force_failure_reason VARCHAR, + compatibility_level INTEGER, + query_plan_text VARCHAR, + query_plan_hash VARCHAR, + replica_role VARCHAR +)", + ["deadlocks"] = @"CREATE TABLE IF NOT EXISTS deadlocks ( + deadlock_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + deadlock_time TIMESTAMP, + victim_process_id VARCHAR, + victim_sql_text VARCHAR, + deadlock_graph_xml VARCHAR, + victim_query_plan_xml VARCHAR, + database_name VARCHAR +)", + ["blocked_process_reports"] = @"CREATE TABLE IF NOT EXISTS blocked_process_reports ( + blocked_report_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + event_time TIMESTAMP, + database_name VARCHAR, + blocked_spid INTEGER, + blocked_ecid INTEGER, + blocking_spid INTEGER, + blocking_ecid INTEGER, + wait_time_ms BIGINT, + wait_resource VARCHAR, + lock_mode VARCHAR, + blocked_status VARCHAR, + blocked_isolation_level VARCHAR, + blocked_log_used BIGINT, + blocked_transaction_count INTEGER, + blocked_client_app VARCHAR, + blocked_host_name VARCHAR, + blocked_login_name VARCHAR, + blocked_sql_text VARCHAR, + blocking_status VARCHAR, + blocking_isolation_level VARCHAR, + blocking_client_app VARCHAR, + blocking_host_name VARCHAR, + blocking_login_name VARCHAR, + blocking_sql_text VARCHAR, + blocked_transaction_name VARCHAR, + blocking_transaction_name VARCHAR, + blocked_last_tran_started TIMESTAMP, + blocking_last_tran_started TIMESTAMP, + blocked_last_batch_started TIMESTAMP, + blocking_last_batch_started TIMESTAMP, + blocked_last_batch_completed TIMESTAMP, + blocking_last_batch_completed TIMESTAMP, + blocked_priority INTEGER, + blocking_priority INTEGER, + blocked_process_report_xml VARCHAR, + object_id INTEGER, + database_id INTEGER, + contentious_object VARCHAR, + monitor_loop INTEGER, + blocked_query_plan_xml VARCHAR, + blocking_query_plan_xml VARCHAR +)", + ["long_query_completions"] = @"CREATE TABLE IF NOT EXISTS long_query_completions ( + long_query_completion_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + event_time TIMESTAMP, + event_type VARCHAR, + database_id INTEGER, + database_name VARCHAR, + session_id INTEGER, + client_app_name VARCHAR, + client_pid INTEGER, + nt_username VARCHAR, + server_principal_name VARCHAR, + query_hash VARCHAR, + event_sequence BIGINT, + duration_microseconds BIGINT, + cpu_time_microseconds BIGINT, + physical_reads BIGINT, + logical_reads BIGINT, + writes BIGINT, + row_count BIGINT, + result VARCHAR, + statement_text VARCHAR, + object_name VARCHAR +)", + ["system_health_events"] = @"CREATE TABLE IF NOT EXISTS system_health_events ( + system_health_event_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + event_time TIMESTAMP, + event_type VARCHAR, + event_xml VARCHAR +)", + ["default_trace_events"] = @"CREATE TABLE IF NOT EXISTS default_trace_events ( + default_trace_event_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + event_time TIMESTAMP, + event_name VARCHAR, + event_class INTEGER, + spid INTEGER, + database_name VARCHAR, + database_id INTEGER, + login_name VARCHAR, + host_name VARCHAR, + application_name VARCHAR, + object_name VARCHAR, + filename VARCHAR, + integer_data BIGINT, + integer_data_2 BIGINT, + text_data VARCHAR, + session_login_name VARCHAR, + error_number INTEGER, + severity INTEGER, + state INTEGER, + event_sequence BIGINT, + duration_us BIGINT, + end_time TIMESTAMP +)", + ["job_history"] = @"CREATE TABLE IF NOT EXISTS job_history ( + job_history_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + instance_id BIGINT NOT NULL, + job_id VARCHAR NOT NULL, + job_name VARCHAR NOT NULL, + job_enabled BOOLEAN NOT NULL, + category_name VARCHAR, + step_id INTEGER NOT NULL, + step_name VARCHAR, + run_status INTEGER NOT NULL, + run_status_desc VARCHAR, + run_datetime TIMESTAMP, + run_duration_seconds BIGINT NOT NULL, + retries_attempted INTEGER NOT NULL, + message VARCHAR +)", + ["agent_status"] = @"CREATE TABLE IF NOT EXISTS agent_status ( + collection_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + agent_running BOOLEAN NOT NULL, + agent_status_desc VARCHAR, + agent_startup_desc VARCHAR, + next_scheduled_run TIMESTAMP +)", + }; + + /// <summary>Pre-change CREATE INDEX DDL, keyed by collector target table (33 entries; server_config and database_config have no index).</summary> + public static readonly IReadOnlyDictionary<string, string> Indexes = new Dictionary<string, string> + { + ["wait_stats"] = @"CREATE INDEX IF NOT EXISTS idx_wait_stats_time ON wait_stats(server_id, collection_time)", + ["latch_stats"] = @"CREATE INDEX IF NOT EXISTS idx_latch_stats_time ON latch_stats(server_id, collection_time)", + ["spinlock_stats"] = @"CREATE INDEX IF NOT EXISTS idx_spinlock_stats_time ON spinlock_stats(server_id, collection_time)", + ["cpu_scheduler_stats"] = @"CREATE INDEX IF NOT EXISTS idx_cpu_scheduler_stats_time ON cpu_scheduler_stats(server_id, collection_time)", + ["plan_cache_stats"] = @"CREATE INDEX IF NOT EXISTS idx_plan_cache_stats_time ON plan_cache_stats(server_id, collection_time)", + ["tempdb_stats"] = @"CREATE INDEX IF NOT EXISTS idx_tempdb_time ON tempdb_stats(server_id, collection_time)", + ["memory_grant_stats"] = @"CREATE INDEX IF NOT EXISTS idx_memory_grant_stats_time ON memory_grant_stats(server_id, collection_time)", + ["cpu_utilization_stats"] = @"CREATE INDEX IF NOT EXISTS idx_cpu_time ON cpu_utilization_stats(server_id, collection_time)", + ["memory_stats"] = @"CREATE INDEX IF NOT EXISTS idx_memory_time ON memory_stats(server_id, collection_time)", + ["memory_clerks"] = @"CREATE INDEX IF NOT EXISTS idx_memory_clerks_time ON memory_clerks(server_id, collection_time)", + ["memory_pressure_events"] = @"CREATE INDEX IF NOT EXISTS idx_memory_pressure_events_time ON memory_pressure_events(server_id, sample_time)", + ["file_io_stats"] = @"CREATE INDEX IF NOT EXISTS idx_file_io_time ON file_io_stats(server_id, collection_time)", + ["server_properties"] = @"CREATE INDEX IF NOT EXISTS idx_server_properties_time ON server_properties(server_id, collection_time)", + ["trace_flags"] = @"CREATE INDEX IF NOT EXISTS idx_trace_flags_time ON trace_flags(server_id, capture_time)", + ["database_scoped_config"] = @"CREATE INDEX IF NOT EXISTS idx_database_scoped_config_time ON database_scoped_config(server_id, capture_time)", + ["session_stats"] = @"CREATE INDEX IF NOT EXISTS idx_session_stats_time ON session_stats(server_id, collection_time)", + ["session_summary_stats"] = @"CREATE INDEX IF NOT EXISTS idx_session_summary_stats_time ON session_summary_stats(server_id, collection_time)", + ["waiting_tasks"] = @"CREATE INDEX IF NOT EXISTS idx_waiting_tasks_time ON waiting_tasks(server_id, collection_time)", + ["procedure_stats"] = @"CREATE INDEX IF NOT EXISTS idx_procedure_stats_time ON procedure_stats(server_id, collection_time)", + ["running_jobs"] = @"CREATE INDEX IF NOT EXISTS idx_running_jobs_time ON running_jobs(server_id, collection_time)", + ["perfmon_stats"] = @"CREATE INDEX IF NOT EXISTS idx_perfmon_time ON perfmon_stats(server_id, collection_time)", + ["dmv_blocking_snapshots"] = @"CREATE INDEX IF NOT EXISTS idx_dmv_blocking_snapshots_time ON dmv_blocking_snapshots(server_id, collection_time)", + ["database_size_stats"] = @"CREATE INDEX IF NOT EXISTS idx_database_size_stats_time ON database_size_stats(server_id, collection_time)", + ["index_object_stats"] = @"CREATE INDEX IF NOT EXISTS idx_index_object_stats_object ON index_object_stats(server_id, database_name, object_id, index_id, collection_time)", + ["query_stats"] = @"CREATE INDEX IF NOT EXISTS idx_query_stats_time ON query_stats(server_id, collection_time)", + ["query_snapshots"] = @"CREATE INDEX IF NOT EXISTS idx_query_snapshots_time ON query_snapshots(server_id, collection_time)", + ["query_store_stats"] = @"CREATE INDEX IF NOT EXISTS idx_query_store_time ON query_store_stats(server_id, collection_time)", + ["deadlocks"] = @"CREATE INDEX IF NOT EXISTS idx_deadlocks_time ON deadlocks(server_id, collection_time)", + ["blocked_process_reports"] = @"CREATE INDEX IF NOT EXISTS idx_blocked_process_reports_time ON blocked_process_reports(server_id, collection_time)", + ["long_query_completions"] = @"CREATE INDEX IF NOT EXISTS idx_long_query_completions_time ON long_query_completions(server_id, collection_time)", + ["system_health_events"] = @"CREATE INDEX IF NOT EXISTS idx_system_health_events_time ON system_health_events(server_id, collection_time)", + ["default_trace_events"] = @"CREATE INDEX IF NOT EXISTS idx_default_trace_events_time ON default_trace_events(server_id, collection_time)", + ["job_history"] = @"CREATE INDEX IF NOT EXISTS idx_job_history_time ON job_history(server_id, collection_time)", + ["agent_status"] = @"CREATE INDEX IF NOT EXISTS idx_agent_status_time ON agent_status(server_id, collection_time)", + }; +} diff --git a/Lite.Tests/Helpers/CollectorDefinitionTestFakes.cs b/Lite.Tests/Helpers/CollectorDefinitionTestFakes.cs new file mode 100644 index 000000000..efa469fe9 --- /dev/null +++ b/Lite.Tests/Helpers/CollectorDefinitionTestFakes.cs @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Data.Common; +using PerformanceMonitor.Collectors; + +namespace Lite.Tests.Helpers; + +/// <summary> +/// Shared fakes for collector-definition parity tests (headless plan v5.1): an in-memory +/// DbDataReader (multi-result-set capable), a recording row writer, and a recording delta +/// calculator that returns value × 10 so delta emissions are distinguishable from raw values. +/// </summary> +internal sealed class FakeCollectorDataReader : DbDataReader +{ + private readonly object[][][] _sets; + private int _setIndex; + private int _rowIndex = -1; + + /// <summary>Single result set.</summary> + public FakeCollectorDataReader(params object[][] rows) => _sets = new[] { rows }; + + private FakeCollectorDataReader(object[][][] sets) => _sets = sets; + + /// <summary>Multiple result sets (e.g. the tempdb_stats two-SELECT batch).</summary> + public static FakeCollectorDataReader WithResultSets(params object[][][] sets) => new(sets); + + private object[][] CurrentSet => _sets[_setIndex]; + + public override bool Read() => ++_rowIndex < CurrentSet.Length; + + public override bool NextResult() + { + if (_setIndex + 1 >= _sets.Length) + { + return false; + } + + _setIndex++; + _rowIndex = -1; + return true; + } + + public override string GetString(int ordinal) => (string)CurrentSet[_rowIndex][ordinal]; + + public override long GetInt64(int ordinal) => (long)CurrentSet[_rowIndex][ordinal]; + + public override int GetInt32(int ordinal) => (int)CurrentSet[_rowIndex][ordinal]; + + public override short GetInt16(int ordinal) => (short)CurrentSet[_rowIndex][ordinal]; + + public override decimal GetDecimal(int ordinal) => (decimal)CurrentSet[_rowIndex][ordinal]; + + public override double GetDouble(int ordinal) => (double)CurrentSet[_rowIndex][ordinal]; + + public override DateTime GetDateTime(int ordinal) => (DateTime)CurrentSet[_rowIndex][ordinal]; + + public override bool GetBoolean(int ordinal) => (bool)CurrentSet[_rowIndex][ordinal]; + + public override bool IsDBNull(int ordinal) => CurrentSet[_rowIndex][ordinal] is DBNull; + + public override object GetValue(int ordinal) => CurrentSet[_rowIndex][ordinal]; + + public override int FieldCount => CurrentSet.Length == 0 ? 0 : CurrentSet[0].Length; + + public override bool HasRows => CurrentSet.Length > 0; + + public override bool IsClosed => false; + + public override int Depth => 0; + + public override int RecordsAffected => -1; + + public override object this[int ordinal] => CurrentSet[_rowIndex][ordinal]; + + public override object this[string name] => throw new NotSupportedException(); + + public override byte GetByte(int ordinal) => (byte)CurrentSet[_rowIndex][ordinal]; + public override long GetBytes(int ordinal, long dataOffset, byte[]? buffer, int bufferOffset, int length) => throw new NotSupportedException(); + public override char GetChar(int ordinal) => (char)CurrentSet[_rowIndex][ordinal]; + public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int bufferOffset, int length) => throw new NotSupportedException(); + public override string GetDataTypeName(int ordinal) => throw new NotSupportedException(); + public override IEnumerator GetEnumerator() => CurrentSet.GetEnumerator(); + public override Type GetFieldType(int ordinal) => CurrentSet[_rowIndex][ordinal].GetType(); + public override float GetFloat(int ordinal) => (float)CurrentSet[_rowIndex][ordinal]; + public override Guid GetGuid(int ordinal) => (Guid)CurrentSet[_rowIndex][ordinal]; + public override string GetName(int ordinal) => throw new NotSupportedException(); + public override int GetOrdinal(string name) => throw new NotSupportedException(); + public override int GetValues(object[] values) => throw new NotSupportedException(); +} + +internal sealed class RecordingCollectorRowWriter : ICollectorRowWriter +{ + public List<object?> Values { get; } = new(); + + public ICollectorRowWriter Value(string? value) { Values.Add(value); return this; } + public ICollectorRowWriter Value(long value) { Values.Add(value); return this; } + public ICollectorRowWriter Value(long? value) { Values.Add(value); return this; } + public ICollectorRowWriter Value(int value) { Values.Add(value); return this; } + public ICollectorRowWriter Value(int? value) { Values.Add(value); return this; } + public ICollectorRowWriter Value(short value) { Values.Add(value); return this; } + public ICollectorRowWriter Value(short? value) { Values.Add(value); return this; } + public ICollectorRowWriter Value(double value) { Values.Add(value); return this; } + public ICollectorRowWriter Value(double? value) { Values.Add(value); return this; } + public ICollectorRowWriter Value(decimal value) { Values.Add(value); return this; } + public ICollectorRowWriter Value(decimal? value) { Values.Add(value); return this; } + public ICollectorRowWriter Value(bool value) { Values.Add(value); return this; } + public ICollectorRowWriter Value(bool? value) { Values.Add(value); return this; } + public ICollectorRowWriter Value(DateTime value) { Values.Add(value); return this; } + public ICollectorRowWriter Value(DateTime? value) { Values.Add(value); return this; } + public ICollectorRowWriter NullValue() { Values.Add(null); return this; } +} + +internal sealed class RecordingCollectorDeltaCalculator : ICollectorDeltaCalculator +{ + public List<(string Group, string Key, long Value, DateTime? Time, int MaxGap)> Calls { get; } = new(); + + public int LastServerId { get; private set; } + + public long CalculateDelta(int serverId, string collectorName, string key, long currentValue, + DateTime? collectionTime = null, int maxGapSeconds = 0) + { + LastServerId = serverId; + Calls.Add((collectorName, key, currentValue, collectionTime, maxGapSeconds)); + return currentValue * 10; + } + + public long CalculateDeltaWithInterval(int serverId, string collectorName, string key, long currentValue, + out int intervalSeconds, DateTime? collectionTime = null, int maxGapSeconds = 0) + { + intervalSeconds = 0; + return CalculateDelta(serverId, collectorName, key, currentValue, collectionTime, maxGapSeconds); + } +} diff --git a/Lite.Tests/Helpers/TestAlertDataHelper.cs b/Lite.Tests/Helpers/TestAlertDataHelper.cs index 248c8d16e..159a46453 100644 --- a/Lite.Tests/Helpers/TestAlertDataHelper.cs +++ b/Lite.Tests/Helpers/TestAlertDataHelper.cs @@ -84,8 +84,8 @@ CREATE TEMP TABLE _staging_alerts ( server_id INTEGER NOT NULL, server_name VARCHAR NOT NULL, metric_name VARCHAR NOT NULL, - current_value DOUBLE NOT NULL, - threshold_value DOUBLE NOT NULL, + current_value DOUBLE PRECISION NOT NULL, + threshold_value DOUBLE PRECISION NOT NULL, alert_sent BOOLEAN NOT NULL DEFAULT false, notification_type VARCHAR NOT NULL DEFAULT 'tray', send_error VARCHAR, diff --git a/Lite.Tests/IncidentCooldownTests.cs b/Lite.Tests/IncidentCooldownTests.cs index 024b9c578..df95a1b60 100644 --- a/Lite.Tests/IncidentCooldownTests.cs +++ b/Lite.Tests/IncidentCooldownTests.cs @@ -109,7 +109,7 @@ public async Task NonFingerprinted_FallsBackToMetricKey_LikePre1154() // Same metric within window -> suppressed (today's behavior). Assert.False((await cd.EvaluateAsync("1", "High CPU", null, Window)).ShouldSend); // A different metric is its own key -> fresh. - Assert.True((await cd.EvaluateAsync("1", "TempDB Space", new List<AlertIncident>(), Window)).ShouldSend); + Assert.True((await cd.EvaluateAsync("1", "tempdb Space", new List<AlertIncident>(), Window)).ShouldSend); // A different server is its own key -> fresh. Assert.True((await cd.EvaluateAsync("2", "High CPU", null, Window)).ShouldSend); } diff --git a/Lite.Tests/IndexObjectStatsCollectorDefinitionTests.cs b/Lite.Tests/IndexObjectStatsCollectorDefinitionTests.cs new file mode 100644 index 000000000..430451e8f --- /dev/null +++ b/Lite.Tests/IndexObjectStatsCollectorDefinitionTests.cs @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Pins the parity contract of the extracted index_object_stats definition: dual execution +/// (Azure per-database connections; on-prem enumeration + quote-doubled [db].sys.sp_executesql +/// body), the dedicated 300 s per-database budget (#1135), and the 57-column payload (44 +/// size/usage/locking counters + 13 index-definition columns for monitor-side UNUSED/DUPLICATE +/// analysis, Stage 1). +/// </summary> +public sealed class IndexObjectStatsCollectorDefinitionTests +{ + private static readonly RecordingCollectorDeltaCalculator s_deltas = new(); + + [Fact] + public void DualExecution_AzurePerDatabase_OnPremEnumeration() + { + Assert.True(IndexObjectStatsCollector.Instance.RunsPerDatabase(new CollectorTargetInfo { IsAzureSqlDb = true })); + Assert.False(IndexObjectStatsCollector.Instance.RunsPerDatabase(new CollectorTargetInfo())); + + Assert.Null(IndexObjectStatsCollector.Instance.BuildEnumerationQuery(CollectorTestContext.Make(s_deltas, isAzureSqlDb: true))); + var enumeration = IndexObjectStatsCollector.Instance.BuildEnumerationQuery(CollectorTestContext.Make(s_deltas)); + Assert.NotNull(enumeration); + Assert.Contains("HAS_DBACCESS(d.name) = 1", enumeration!.Text, StringComparison.Ordinal); + } + + [Fact] + public void TimeoutOverride_IsTheDedicated1135Budget() + { + Assert.Equal(300, IndexObjectStatsCollector.Instance.CommandTimeoutSecondsOverride); + } + + [Fact] + public void PerItemQuery_QuoteDoublesTheBody_AndEscapesBrackets() + { + var plan = IndexObjectStatsCollector.Instance.BuildPerItemQuery("we]rd db", CollectorTestContext.Make(s_deltas)); + + Assert.StartsWith("EXECUTE [we]]rd db].sys.sp_executesql N'", plan.Text, StringComparison.Ordinal); + /* The body's own literals must arrive quote-doubled inside the N'...' wrapper. */ + Assert.Contains("o.type IN (N''U'', N''V'')", plan.Text, StringComparison.Ordinal); + Assert.DoesNotContain("o.type IN (N'U', N'V')", plan.Text, StringComparison.Ordinal); + } + + [Fact] + public void AzureQuery_IsTheRawStagedBody() + { + var plan = IndexObjectStatsCollector.Instance.BuildQuery(CollectorTestContext.Make(s_deltas, isAzureSqlDb: true)); + + Assert.Contains("INTO #sizes", plan.Text, StringComparison.Ordinal); + Assert.Contains("sys.dm_db_index_operational_stats(DB_ID(), NULL, NULL, NULL)", plan.Text, StringComparison.Ordinal); + Assert.Contains("o.type IN (N'U', N'V')", plan.Text, StringComparison.Ordinal); + } + + [Fact] + public void PayloadColumns_MatchSchemaOrder_58Columns() + { + var names = IndexObjectStatsCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray(); + Assert.Equal(58, names.Length); + Assert.Equal("sqlserver_start_time", names[0]); + Assert.Equal("page_io_latch_wait_in_ms", names[43]); + /* The Stage-1 index-definition metadata is appended after the counters (ordinals 44..57). */ + Assert.Equal("key_columns", names[44]); + Assert.Equal("included_columns", names[45]); + Assert.Equal("filter_definition", names[46]); + Assert.Equal("is_foreign_key", names[48]); + Assert.Equal("is_foreign_key_reference", names[49]); + Assert.Equal("data_compression_desc", names[51]); + Assert.Equal("optimize_for_sequential_key", names[52]); + Assert.Equal("fill_factor", names[53]); + Assert.Equal("allow_row_locks", names[56]); + Assert.Equal("is_indexed_view", names[57]); + } + + [Fact] + public async Task ReadItem_AndWrite_RoundTrip_WithBitConversions() + { + var start = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc); + var input = new object[58]; + input[0] = start; input[1] = "SO"; input[2] = 7; input[3] = "dbo"; input[4] = 12345; + input[5] = "Posts"; input[6] = 1; input[7] = "PK_Posts"; input[8] = "CLUSTERED"; + input[9] = 1; input[10] = 1; input[11] = 0; input[12] = 4; + input[13] = 1024.50m; input[14] = 1000.25m; input[15] = 900.00m; input[16] = 50.00m; input[17] = 25.00m; + for (int i = 18; i < 44; i++) input[i] = (long)(i * 10); + /* last_user_* are timestamps at 23..26 */ + input[23] = start; input[24] = DBNull.Value; input[25] = start; input[26] = DBNull.Value; + /* Stage-1 index-definition metadata (ordinals 44..57): key/include lists, filter, the + uniqueness/FK/disabled discriminators, the reconstruct-a-CREATE options, and is_indexed_view. */ + input[44] = "[OwnerUserId], [CreationDate] DESC"; input[45] = "[Score]"; input[46] = DBNull.Value; + input[47] = 0; input[48] = 1; input[49] = 0; input[50] = 0; + input[51] = "PAGE"; input[52] = 1; input[53] = 90; input[54] = 0; input[55] = 1; input[56] = 1; input[57] = 0; + + var rows = new List<IndexObjectStatsCollector.Row>(); + using (var reader = new FakeCollectorDataReader(input)) + { + await IndexObjectStatsCollector.Instance.ReadItemAsync("SO", reader, rows, CollectorTestContext.Make(s_deltas), CancellationToken.None); + } + + var row = Assert.Single(rows); + Assert.True(row.IsUnique); + Assert.False(row.IsFiltered); + Assert.Null(row.LastUserScan); + /* Index-definition metadata round-trips (incl. the FK-protection + option flags). */ + Assert.Equal("[OwnerUserId], [CreationDate] DESC", row.KeyColumns); + Assert.Equal("[Score]", row.IncludedColumns); + Assert.Null(row.FilterDefinition); + Assert.False(row.IsUniqueConstraint); + Assert.True(row.IsForeignKey); + Assert.False(row.IsForeignKeyReference); + Assert.False(row.IsDisabled); + Assert.Equal("PAGE", row.DataCompressionDesc); + Assert.True(row.OptimizeForSequentialKey); + Assert.Equal((short)90, row.FillFactor); + Assert.False(row.IsPadded); + Assert.True(row.AllowPageLocks); + Assert.True(row.AllowRowLocks); + Assert.False(row.IsIndexedView); + + var writer = new RecordingCollectorRowWriter(); + IndexObjectStatsCollector.Instance.WritePayload(row, writer, CollectorTestContext.Make(s_deltas)); + Assert.Equal(58, writer.Values.Count); + Assert.Equal(start, writer.Values[0]); + Assert.Equal(true, writer.Values[9]); + Assert.Equal(430L, writer.Values[43]); + Assert.Equal("[OwnerUserId], [CreationDate] DESC", writer.Values[44]); + Assert.Equal((short)90, writer.Values[53]); + Assert.Equal(true, writer.Values[56]); + Assert.Equal(false, writer.Values[57]); + } +} diff --git a/Lite.Tests/JobHistoryCollectorDefinitionTests.cs b/Lite.Tests/JobHistoryCollectorDefinitionTests.cs new file mode 100644 index 000000000..6e58a26b2 --- /dev/null +++ b/Lite.Tests/JobHistoryCollectorDefinitionTests.cs @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Pins the cross-SKU parity contract of the shared job_history collector definition (issue #1433): the +/// msdb.dbo.sysjobhistory read with the sysjobs/syscategories joins and the HHMMSS-integer run_datetime / +/// run_duration decode, the THREE incremental-filter modes (steady instance_id high-water mark; TRUE +/// first-run all-history; Lite archival-emptied bounded run_datetime window), the collect-everywhere-but- +/// Azure-SQL-DB AppliesTo, the dual watermark declaration (numeric instance_id + timestamp run_datetime), +/// the 13-column payload, and the null-tolerant ReadAsync/WritePayload. An intentional change to any of +/// these must consciously update this test. +/// </summary> +public sealed class JobHistoryCollectorDefinitionTests +{ + private static readonly RecordingCollectorDeltaCalculator s_deltas = new(); + + private static CollectorContext MakeContext( + bool isAzureSqlDb = false, + bool isAzureManagedInstance = false, + long? numericWatermark = null, + bool hasCollectedBefore = false, + DateTime? collectionTime = null) + => new() + { + ServerId = 42, + ServerName = "test-server", + CollectionTime = collectionTime ?? new DateTime(2026, 7, 11, 12, 0, 0, DateTimeKind.Utc), + Deltas = s_deltas, + Target = new CollectorTargetInfo { IsAzureSqlDb = isAzureSqlDb, IsAzureManagedInstance = isAzureManagedInstance }, + NumericWatermark = numericWatermark, + HasCollectedBefore = hasCollectedBefore, + }; + + [Fact] + public void Identity_Pinned() + { + Assert.Equal("job_history", JobHistoryCollector.Instance.Name); + Assert.Equal("job_history", JobHistoryCollector.Instance.TargetTable); + Assert.Equal("job_history_id", JobHistoryCollector.Instance.PrefixIdColumnName); + /* Dedup is primarily numeric (instance_id); run_datetime is declared so the host computes + HasCollectedBefore for the Lite archival-emptied fallback. */ + Assert.Equal("instance_id", JobHistoryCollector.Instance.NumericWatermarkColumn); + Assert.Equal("run_datetime", JobHistoryCollector.Instance.WatermarkColumn); + } + + [Fact] + public void BuildQuery_ReadsSysjobhistory_WithDecodeIdioms() + { + var text = JobHistoryCollector.Instance.BuildQuery(MakeContext()).Text; + + Assert.Contains("msdb.dbo.sysjobhistory", text, StringComparison.Ordinal); + Assert.Contains("msdb.dbo.sysjobs", text, StringComparison.Ordinal); + Assert.Contains("msdb.dbo.syscategories", text, StringComparison.Ordinal); + /* run_datetime decode from run_date + run_time; run_duration_seconds decode from run_duration. */ + Assert.Contains("(jh.run_time / 10000) * 3600", text, StringComparison.Ordinal); + Assert.Contains("(jh.run_duration / 10000) * 3600", text, StringComparison.Ordinal); + Assert.Contains("CONVERT(varchar(36), jh.job_id)", text, StringComparison.Ordinal); + /* run_status decode covers every documented outcome. */ + Assert.Contains("N'Failed'", text, StringComparison.Ordinal); + Assert.Contains("N'Succeeded'", text, StringComparison.Ordinal); + Assert.Contains("N'Retry'", text, StringComparison.Ordinal); + Assert.Contains("N'Canceled'", text, StringComparison.Ordinal); + Assert.Contains("SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED", text, StringComparison.Ordinal); + Assert.Contains("OPTION(RECOMPILE)", text, StringComparison.Ordinal); + } + + [Fact] + public void BuildQuery_SteadyState_FiltersOnInstanceIdWatermark_AsBigInt() + { + var query = JobHistoryCollector.Instance.BuildQuery(MakeContext(numericWatermark: 987654)); + + Assert.Contains("jh.instance_id > @last_instance_id", query.Text, StringComparison.Ordinal); + var p = query.Parameters.Single(x => x.Name == "@last_instance_id"); + Assert.Equal(987654L, p.Value); + Assert.Equal(CollectorParameterType.BigInt, p.Type); + } + + [Fact] + public void BuildQuery_TrueFirstRun_CollectsEverything_NoIncrementalFilter() + { + /* No numeric watermark AND never succeeded → collect ALL of sysjobhistory (bounded by msdb's own + history cap), the same first-run-collects-all choice default_trace_events makes. No filter params. */ + var query = JobHistoryCollector.Instance.BuildQuery(MakeContext(numericWatermark: null, hasCollectedBefore: false)); + + Assert.DoesNotContain("jh.instance_id > @last_instance_id", query.Text, StringComparison.Ordinal); + Assert.DoesNotContain("DATEADD(HOUR, -24, GETDATE())", query.Text, StringComparison.Ordinal); + Assert.Empty(query.Parameters); + } + + [Fact] + public void BuildQuery_ArchivalEmptied_UsesBoundedRunDatetimeWindow_ServerSide() + { + /* No numeric watermark but HAS succeeded before (Lite hot store emptied by parquet archival): a + BOUNDED recent run_datetime window computed server-side against GETDATE() (run_datetime is server- + local), so we never re-scan rows already in parquet — which v_job_history would double-count. */ + var query = JobHistoryCollector.Instance.BuildQuery(MakeContext(numericWatermark: null, hasCollectedBefore: true)); + + Assert.DoesNotContain("jh.instance_id > @last_instance_id", query.Text, StringComparison.Ordinal); + Assert.Contains("DATEADD(HOUR, -24, GETDATE())", query.Text, StringComparison.Ordinal); + Assert.Contains("jh.run_date >=", query.Text, StringComparison.Ordinal); + Assert.Empty(query.Parameters); + Assert.Equal(24, JobHistoryCollector.ArchivalEmptyFallbackHours); + } + + [Fact] + public void AppliesTo_CollectsEverywhereExceptAzureSqlDbAndNoMsdb() + { + /* No SQL Agent / sysjobhistory on Azure SQL DB; Managed Instance and on-prem / RDS have it. */ + Assert.False(JobHistoryCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureSqlDb = true })); + /* No msdb access → every table this reads lives in msdb; skip (gate collapsed from + IsCollectorSupported into the shared AppliesTo). */ + Assert.False(JobHistoryCollector.Instance.AppliesTo(new CollectorTargetInfo { HasMsdbAccess = false })); + /* NOT gated on AWS RDS — unlike running_jobs it never touches syssessions, so history reads fine. */ + Assert.True(JobHistoryCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAwsRds = true })); + Assert.True(JobHistoryCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureManagedInstance = true })); + Assert.True(JobHistoryCollector.Instance.AppliesTo(new CollectorTargetInfo())); + } + + [Fact] + public void PayloadColumns_ThirteenColumns_InAppendOrder() + { + var columns = JobHistoryCollector.Instance.PayloadColumns; + + Assert.Equal(new[] + { + "instance_id", "job_id", "job_name", "job_enabled", "category_name", "step_id", "step_name", + "run_status", "run_status_desc", "run_datetime", "run_duration_seconds", "retries_attempted", "message", + }, columns.Select(c => c.Name).ToArray()); + + Assert.Equal(CollectorColumnType.BigInt, columns.Single(c => c.Name == "instance_id").Type); + Assert.Equal(CollectorColumnType.Boolean, columns.Single(c => c.Name == "job_enabled").Type); + Assert.Equal(CollectorColumnType.Integer, columns.Single(c => c.Name == "step_id").Type); + Assert.Equal(CollectorColumnType.Timestamp, columns.Single(c => c.Name == "run_datetime").Type); + Assert.Equal(CollectorColumnType.BigInt, columns.Single(c => c.Name == "run_duration_seconds").Type); + } + + [Fact] + public async Task ReadAsync_WritePayload_PinsThirteenColumnOrder_AndToleratesNulls() + { + var context = MakeContext(); + var runDateTime = new DateTime(2026, 7, 11, 2, 0, 0, DateTimeKind.Unspecified); + + var fullRow = new object[] + { + 98765L, "AAAAAAAA-1111-2222-3333-444444444444", "Nightly Backup", true, "Database Maintenance", + 0, "(Job outcome)", 1, "Succeeded", runDateTime, 3661L, 0, "The job succeeded.", + }; + /* An outcome row whose message/category are NULL still writes 13 values. */ + var nullishRow = new object[] + { + 98766L, "BBBBBBBB-1111-2222-3333-444444444444", "Ad-hoc Job", false, (object)DBNull.Value, + 1, (object)DBNull.Value, 0, "Failed", runDateTime, 5L, 3, (object)DBNull.Value, + }; + + using var reader = new FakeCollectorDataReader(fullRow, nullishRow); + var rows = await JobHistoryCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + Assert.Equal(2, rows.Count); + + var writer = new RecordingCollectorRowWriter(); + JobHistoryCollector.Instance.WritePayload(rows[0], writer, context); + + Assert.Equal(13, writer.Values.Count); + Assert.Equal(98765L, writer.Values[0]); /* instance_id */ + Assert.Equal("Nightly Backup", writer.Values[2]); /* job_name */ + Assert.Equal(true, writer.Values[3]); /* job_enabled */ + Assert.Equal("Database Maintenance", writer.Values[4]); /* category_name */ + Assert.Equal(0, writer.Values[5]); /* step_id (outcome) */ + Assert.Equal(1, writer.Values[7]); /* run_status Succeeded */ + Assert.Equal(runDateTime, writer.Values[9]); /* run_datetime */ + Assert.Equal(3661L, writer.Values[10]); /* run_duration_seconds */ + Assert.Equal(0, writer.Values[11]); /* retries_attempted */ + Assert.Equal("The job succeeded.", writer.Values[12]); /* message */ + + var nullWriter = new RecordingCollectorRowWriter(); + JobHistoryCollector.Instance.WritePayload(rows[1], nullWriter, context); + Assert.Equal(13, nullWriter.Values.Count); + Assert.Null(nullWriter.Values[4]); /* category_name NULL */ + Assert.Null(nullWriter.Values[6]); /* step_name NULL */ + Assert.Null(nullWriter.Values[12]); /* message NULL */ + Assert.Equal(3, nullWriter.Values[11]); /* retries_attempted */ + + /* Capture is a point-in-time read with no delta interaction. */ + Assert.Empty(s_deltas.Calls); + } +} diff --git a/Lite.Tests/LatchStatsCollectorDefinitionTests.cs b/Lite.Tests/LatchStatsCollectorDefinitionTests.cs new file mode 100644 index 000000000..6a21bbf75 --- /dev/null +++ b/Lite.Tests/LatchStatsCollectorDefinitionTests.cs @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Pins the cross-SKU parity contract of the shared latch_stats collector definition (the +/// Dashboard-parity port of install/32_collect_latch_stats.sql): verbatim query text, payload +/// column order, ReadAsync mapping, and the delta groups/keys/gap policy. An intentional change to +/// any of these must consciously update these tests — that is the point. +/// </summary> +public sealed class LatchStatsCollectorDefinitionTests +{ + private const string ExpectedQuery = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + latch_class = ls.latch_class, + waiting_requests_count = ls.waiting_requests_count, + wait_time_ms = ls.wait_time_ms, + max_wait_time_ms = ls.max_wait_time_ms +FROM sys.dm_os_latch_stats AS ls +WHERE ls.wait_time_ms > 0 +OPTION(RECOMPILE);"; + + [Fact] + public void Query_IsTheVerbatimParityContract() + { + var context = CollectorTestContext.Make(new RecordingCollectorDeltaCalculator()); + Assert.Equal(ExpectedQuery, LatchStatsCollector.Instance.BuildQuery(context).Text); + Assert.Empty(LatchStatsCollector.Instance.BuildQuery(context).Parameters); + Assert.Null(LatchStatsCollector.Instance.WatermarkColumn); + Assert.Equal("latch_stats", LatchStatsCollector.Instance.Name); + Assert.Equal("latch_stats", LatchStatsCollector.Instance.TargetTable); + } + + [Fact] + public void AppliesTo_Everywhere_IncludingAzure() + { + Assert.True(LatchStatsCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureSqlDb = true })); + Assert.True(LatchStatsCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureManagedInstance = true })); + Assert.True(LatchStatsCollector.Instance.AppliesTo(new CollectorTargetInfo())); + } + + [Fact] + public void PayloadColumns_AreInAppendOrder() + { + var names = LatchStatsCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray(); + + Assert.Equal( + new[] + { + "latch_class", + "waiting_requests_count", + "wait_time_ms", + "max_wait_time_ms", + "delta_waiting_requests_count", + "delta_wait_time_ms", + "delta_max_wait_time_ms", + }, + names); + } + + [Fact] + public async Task ReadAsync_MapsColumns() + { + using var reader = new FakeCollectorDataReader( + new object[] { "ACCESS_METHODS_HOBT_VIRTUAL_ROOT", 7L, 300L, 20L }, + new object[] { "BUFFER", 12L, 900L, 50L }); + + var context = CollectorTestContext.Make(new RecordingCollectorDeltaCalculator()); + + var rows = await LatchStatsCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + + Assert.Equal(2, rows.Count); + Assert.Equal(new LatchStatsCollector.Row("ACCESS_METHODS_HOBT_VIRTUAL_ROOT", 7, 300, 20), rows[0]); + Assert.Equal(new LatchStatsCollector.Row("BUFFER", 12, 900, 50), rows[1]); + } + + [Fact] + public void WritePayload_EmitsPayloadOrder_AndPinsDeltaGroupsKeysAndGapPolicy() + { + var deltas = new RecordingCollectorDeltaCalculator(); + var context = CollectorTestContext.Make(deltas); + var writer = new RecordingCollectorRowWriter(); + var row = new LatchStatsCollector.Row("BUFFER", 7, 300, 20); + + LatchStatsCollector.Instance.WritePayload(row, writer, context); + + /* Payload order: raw values then the three deltas (recording calculator returns value * 10). */ + Assert.Equal(new object?[] { "BUFFER", 7L, 300L, 20L, 70L, 3000L, 200L }, writer.Values); + + /* Delta contract: group names, key = latch_class, the host collection time, 300 s gap policy. */ + Assert.Equal(3, deltas.Calls.Count); + Assert.Equal(("latch_stats_waiting_requests", "BUFFER", 7L, context.CollectionTime, 300), deltas.Calls[0]); + Assert.Equal(("latch_stats_wait_time", "BUFFER", 300L, context.CollectionTime, 300), deltas.Calls[1]); + Assert.Equal(("latch_stats_max_wait", "BUFFER", 20L, context.CollectionTime, 300), deltas.Calls[2]); + Assert.All(deltas.Calls, _ => Assert.Equal(42, deltas.LastServerId)); + } +} diff --git a/Lite.Tests/Lite.Tests.csproj b/Lite.Tests/Lite.Tests.csproj index 173b25d6e..544065e5c 100644 --- a/Lite.Tests/Lite.Tests.csproj +++ b/Lite.Tests/Lite.Tests.csproj @@ -21,4 +21,10 @@ <ProjectReference Include="..\Lite\PerformanceMonitorLite.csproj" /> <ProjectReference Include="..\PerformanceMonitor.Notifications\PerformanceMonitor.Notifications.csproj" /> </ItemGroup> + + <ItemGroup> + <!-- system_health event samples (one per feeder event; shapes per sp_HealthParser xpath) for the + System Events reader round-trip tests. Mirrors Darling.Tests' Fixtures\SystemHealth set. --> + <None Include="Fixtures\SystemHealth\*.xml" CopyToOutputDirectory="PreserveNewest" /> + </ItemGroup> </Project> diff --git a/Lite.Tests/LiteAlertForwardingTests.cs b/Lite.Tests/LiteAlertForwardingTests.cs new file mode 100644 index 000000000..31d0f8366 --- /dev/null +++ b/Lite.Tests/LiteAlertForwardingTests.cs @@ -0,0 +1,896 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Hardcodet.Wpf.TaskbarNotification; +using PerformanceMonitor.Alerting; +using PerformanceMonitor.Notifications; +using PerformanceMonitorLite.Database; +using PerformanceMonitorLite.Services; +using Xunit; +using EngineCpuAlertMode = PerformanceMonitor.Alerting.CpuAlertMode; + +namespace PerformanceMonitorLite.Tests; + +/// <summary> +/// Phase-5 forwarding equivalence pins: Lite's alert loop now delegates to the shared +/// <see cref="AlertEngine"/> through Lite's REAL seam implementations (the live +/// <see cref="AppAlertEngineSettings"/> over the App.* statics; <see cref="LiteAlertDeliverer"/> +/// through its injected test seams; <see cref="LiteAlertStateStore"/> over a real DuckDB file), +/// and every scenario here is pinned to the PRE-FORWARDING loop's behavior with the old +/// <c>Lite/MainWindow.AlertEngine.cs</c> line cited per pin (the deleted source is at +/// <c>git show 5869ff9:Lite/MainWindow.AlertEngine.cs</c>). ZERO behavior change is the bar: +/// same toast strings/icons, same history sends, same watermark persistence, same suppression +/// and badge semantics. +/// <para> +/// This class is the ONLY test class that mutates the App.Alert* statics (xUnit runs methods +/// within a class serially; no other class reads them — the same isolation argument +/// <see cref="AppAlertSettingsTests"/> documents for the SMTP statics). The constructor resets +/// every alert static to its App.xaml.cs default before each test. +/// </para> +/// </summary> +public class LiteAlertForwardingTests +{ + private const string Key = "101"; + private const string Name = "SRV-A"; + + public LiteAlertForwardingTests() + { + /* Reset the App.Alert* statics to their App.xaml.cs:90-125 defaults so every test starts + from the shipped configuration (xunit news up the class per test method). */ + App.AlertsEnabled = true; + App.AlertCpuEnabled = true; + App.AlertCpuThreshold = 80; + App.AlertCpuMode = CpuAlertMode.Total; + App.AlertBlockingEnabled = true; + App.AlertBlockingThreshold = 1; + App.AlertDeadlockEnabled = true; + App.AlertDeadlockThreshold = 1; + App.AlertPoisonWaitEnabled = true; + App.AlertPoisonWaitThresholdMs = 500; + App.AlertLongRunningQueryEnabled = true; + App.AlertLongRunningQueryThresholdMinutes = 30; + App.AlertLongRunningQueryMaxResults = 5; + App.AlertLongRunningQueryExcludeSpServerDiagnostics = true; + App.AlertLongRunningQueryExcludeWaitFor = true; + App.AlertLongRunningQueryExcludeBackups = true; + App.AlertLongRunningQueryExcludeMiscWaits = true; + App.AlertLongRunningQueryExcludeCdc = true; + App.AlertExcludedDatabases = new List<string>(); + App.AlertTempDbSpaceEnabled = true; + App.AlertTempDbSpaceThresholdPercent = 80; + App.AlertLowDiskEnabled = true; + App.AlertLowDiskThresholdPercent = 10; + App.AlertLowDiskThresholdGb = 5; + App.AlertLongRunningJobEnabled = true; + App.AlertLongRunningJobMultiplier = 3; + App.AlertFailedJobEnabled = true; + App.AlertFailedJobLookbackMinutes = 60; + App.AlertCooldownMinutes = 5; + App.AlertDeliveryMode = AlertNotificationMode.Summary; + App.AlertPerEventMaxPerCycle = 10; + } + + /* ---------------- fakes (adapter/store/deliverer — the engine sees Lite's REAL settings) ---------------- */ + + private sealed class FakeReadAdapter : IAlertReadAdapter + { + public List<BlockedProcessAlertRow> Blocking { get; } = new(); + public List<DeadlockAlertRow> Deadlocks { get; } = new(); + public List<PoisonWaitDelta> PoisonWaits { get; } = new(); + public List<LongRunningQueryInfo> LongRunning { get; } = new(); + public List<VolumeFreeSpaceInfo> Volumes { get; } = new(); + public TempDbSpaceInfo? TempDb { get; set; } + public List<AnomalousJobInfo> AnomalousJobs { get; } = new(); + + public Task<List<BlockedProcessAlertRow>> GetRecentBlockedProcessReportsAsync(string serverKey, int hoursBack, CancellationToken cancellationToken = default) => + Task.FromResult(new List<BlockedProcessAlertRow>(Blocking)); + + public Task<List<DeadlockAlertRow>> GetRecentDeadlocksAsync(string serverKey, int hoursBack, CancellationToken cancellationToken = default) => + Task.FromResult(new List<DeadlockAlertRow>(Deadlocks)); + + public Task<List<PoisonWaitDelta>> GetPoisonWaitDeltasAsync(string serverKey, double thresholdMs, CancellationToken cancellationToken = default) => + Task.FromResult(PoisonWaits.FindAll(w => w.AvgMsPerWait >= thresholdMs)); + + public Task<List<LongRunningQueryInfo>> GetLongRunningQueriesAsync( + string serverKey, int thresholdMinutes, int maxResults, + bool excludeSpServerDiagnostics, bool excludeWaitFor, bool excludeBackups, bool excludeMiscWaits, bool excludeCdc, + IReadOnlyList<string> excludedDatabases, CancellationToken cancellationToken = default) => + Task.FromResult(new List<LongRunningQueryInfo>(LongRunning)); + + public Task<List<VolumeFreeSpaceInfo>> GetVolumeFreeSpaceAsync(string serverKey, CancellationToken cancellationToken = default) => + Task.FromResult(new List<VolumeFreeSpaceInfo>(Volumes)); + + public Task<TempDbSpaceInfo?> GetTempDbSpaceAsync(string serverKey, CancellationToken cancellationToken = default) => + Task.FromResult(TempDb); + + public Task<List<AnomalousJobInfo>> GetAnomalousJobsAsync(string serverKey, int multiplier, CancellationToken cancellationToken = default) => + Task.FromResult(new List<AnomalousJobInfo>(AnomalousJobs)); + } + + private sealed class InMemoryStateStore : IAlertStateStore + { + public Dictionary<(string Key, string Metric), int> EdgeWatermarks { get; } = new(); + public Dictionary<string, DateTime> FailedJobWatermarks { get; } = new(); + public List<(string Key, string Metric, int Watermark)> SavedEdge { get; } = new(); + + public Task<int?> LoadEdgeTriggerWatermarkAsync(string serverKey, string metricName) => + Task.FromResult(EdgeWatermarks.TryGetValue((serverKey, metricName), out var w) ? (int?)w : null); + + public Task SaveEdgeTriggerWatermarkAsync(string serverKey, string metricName, int watermark) + { + EdgeWatermarks[(serverKey, metricName)] = watermark; + SavedEdge.Add((serverKey, metricName, watermark)); + return Task.CompletedTask; + } + + public Task<DateTime?> LoadFailedJobWatermarkAsync(string serverKey) => + Task.FromResult(FailedJobWatermarks.TryGetValue(serverKey, out var w) ? (DateTime?)w : null); + + public Task SaveFailedJobWatermarkAsync(string serverKey, DateTime watermark) + { + FailedJobWatermarks[serverKey] = watermark; + return Task.CompletedTask; + } + } + + private sealed class RecordingDeliverer : IAlertDeliverer + { + public List<AlertOutcome> Outcomes { get; } = new(); + + public Task DeliverAsync(AlertOutcome outcome, CancellationToken cancellationToken = default) + { + Outcomes.Add(outcome); + return Task.CompletedTask; + } + } + + /// <summary>Engine over Lite's REAL live-settings adapter + fakes + a controllable clock.</summary> + private sealed class Harness + { + public FakeReadAdapter Adapter { get; } = new(); + public InMemoryStateStore StateStore { get; } = new(); + public RecordingDeliverer Deliverer { get; } = new(); + public List<AlertResolution> Resolutions { get; } = new(); + public List<FailedJobInfo> FailedJobs { get; } = new(); + public bool Muted { get; set; } + public DateTime Now { get; set; } = new(2026, 7, 1, 12, 0, 0, DateTimeKind.Utc); + + public AlertEngine Build() => new( + new AppAlertEngineSettings(), + Adapter, StateStore, Deliverer, + isAlertMuted: _ => Muted, + failedJobsFetcher: (_, _, _) => Task.FromResult(new List<FailedJobInfo>(FailedJobs)), + resolutionCallback: (r, _) => { Resolutions.Add(r); return Task.CompletedTask; }, + logger: null, + utcNow: () => Now); + + public DateTime? FailedJobWatermark() => + StateStore.FailedJobWatermarks.TryGetValue(Key, out var w) ? w : (DateTime?)null; + + public static AlertServerSnapshot Snapshot( + double? sqlCpu = null, double? totalCpu = null, + bool isOnline = true, bool isAzureSqlDb = false, bool suppressed = false) => + new(Key, Name, isOnline, sqlCpu, totalCpu, isAzureSqlDb, suppressed); + } + + /// <summary>Everything except the named check off, so a scenario pins exactly one alert.</summary> + private static void DisableAllChecks() + { + App.AlertCpuEnabled = false; + App.AlertBlockingEnabled = false; + App.AlertDeadlockEnabled = false; + App.AlertPoisonWaitEnabled = false; + App.AlertLongRunningQueryEnabled = false; + App.AlertTempDbSpaceEnabled = false; + App.AlertLowDiskEnabled = false; + App.AlertLongRunningJobEnabled = false; + App.AlertFailedJobEnabled = false; + } + + private static BlockedProcessAlertRow BlockingRow(int blockedSpid) => new() + { + EventTime = new DateTime(2026, 7, 1, 11, 55, 0), + DatabaseName = "StackOverflow", + BlockedSpid = blockedSpid, + BlockingSpid = blockedSpid + 100, + WaitTimeMs = 12000, + LockMode = "X", + BlockedSqlText = "UPDATE Users SET x = 1", + BlockingSqlText = "BEGIN TRAN UPDATE Users", + ContentiousObject = "StackOverflow.dbo.Users", + Source = BlockedProcessAlertRow.XeReportSource + }; + + private static DeadlockAlertRow DeadlockRow() => new() + { + VictimProcessId = "process1", + VictimSqlText = "UPDATE Users SET Reputation = 1", + DeadlockGraphXml = + @"<deadlock><victim-list><victimProcess id=""process1""/></victim-list><process-list><process id=""process1"" spid=""55"" currentdbname=""StackOverflow""><inputbuf>UPDATE Users SET Reputation = 1</inputbuf></process><process id=""process2"" spid=""60"" currentdbname=""StackOverflow""><inputbuf>UPDATE Badges SET Name = 'x'</inputbuf></process></process-list><resource-list><keylock objectname=""StackOverflow.dbo.Users""><owner id=""process2"" mode=""X""/><waiter id=""process1"" mode=""U""/></keylock></resource-list></deadlock>" + }; + + /* ===================================================================================== + 1. CPU fire → resolve strings (old MainWindow.AlertEngine.cs:64-116) + ===================================================================================== */ + + [Fact] + public async Task Cpu_FireAndResolve_CarriesTheOldLoopsExactStrings() + { + DisableAllChecks(); + App.AlertCpuEnabled = true; + var h = new Harness(); + var engine = h.Build(); + + /* Fire: Total mode uses TotalCpuPercent (:65 CpuPercentForAlert → Total). */ + await engine.EvaluateServerAsync(Harness.Snapshot(sqlCpu: 70, totalCpu: 92)); + + var fired = Assert.Single(h.Deliverer.Outcomes); + Assert.Equal("High CPU", fired.MetricName); + /* :96 — email current value; :97 threshold. */ + Assert.Equal("92% (Total CPU)", fired.CurrentValue); + Assert.Equal("80%", fired.ThresholdValue); + /* :91 — the detail text block. */ + Assert.Equal(" Total CPU: 92%\n Threshold: 80%", fired.DetailText); + /* :84 — the toast body minus the "{server}: " prefix. */ + Assert.Equal("Total CPU at 92% (threshold: 80%)", fired.ShortMessage); + /* :93-100 — CPU passes no context and no numerics. */ + Assert.Null(fired.Context); + Assert.Null(fired.NumericCurrentValue); + Assert.False(fired.Muted); + + /* Resolve: :110-113 — exact title + message strings, Success-severity tray-only toast. */ + h.Now = h.Now.AddMinutes(6); + await engine.EvaluateServerAsync(Harness.Snapshot(sqlCpu: 10, totalCpu: 12)); + + var res = Assert.Single(h.Resolutions); + Assert.Equal("CPU Resolved", res.Title); + Assert.Equal("SRV-A: Total CPU back to 12%", res.Message); + Assert.Single(h.Deliverer.Outcomes); /* no new fire, no history row for the resolve */ + } + + [Fact] + public async Task Cpu_SqlOnlyMode_UsesSqlValueAndLabel() + { + DisableAllChecks(); + App.AlertCpuEnabled = true; + App.AlertCpuMode = CpuAlertMode.SqlOnly; /* :66 — label follows the mode */ + var h = new Harness(); + + /* SqlOnly compares CpuPercent (90), not Total (95) — :65 CpuPercentForAlert → SqlOnly. */ + await h.Build().EvaluateServerAsync(Harness.Snapshot(sqlCpu: 90, totalCpu: 95)); + + var fired = Assert.Single(h.Deliverer.Outcomes); + Assert.Equal("90% (SQL CPU)", fired.CurrentValue); + Assert.Equal("SQL CPU at 90% (threshold: 80%)", fired.ShortMessage); + } + + /* ===================================================================================== + 2. Blocking rolling-count fire + watermark persist (old :118-196 + #1091/#1145) + ===================================================================================== */ + + [Fact] + public async Task Blocking_FiresOnce_PersistsWatermark_AndEdgeTriggerHoldsWithinTheHour() + { + DisableAllChecks(); + App.AlertBlockingEnabled = true; + var h = new Harness(); + var engine = h.Build(); + h.Adapter.Blocking.Add(BlockingRow(51)); + h.Adapter.Blocking.Add(BlockingRow(52)); + + await engine.EvaluateServerAsync(Harness.Snapshot()); + + var fired = Assert.Single(h.Deliverer.Outcomes); + Assert.Equal("Blocking Detected", fired.MetricName); + Assert.Equal("2", fired.CurrentValue); /* :180 */ + Assert.Equal("1", fired.ThresholdValue); /* :181 */ + Assert.Equal("2 blocking session(s)", fired.ShortMessage); /* :167 toast body */ + Assert.NotNull(fired.Context); /* :174 context built from the same rows */ + /* :149-151 — the #1145 watermark persisted on change, keyed by the shared metric row key. */ + Assert.Equal(2, h.StateStore.EdgeWatermarks[(Key, "Blocking Detected")]); + + /* Same 2 lingering reports after the cooldown → no re-fire (#1091, :140-145). */ + h.Now = h.Now.AddMinutes(10); + await engine.EvaluateServerAsync(Harness.Snapshot()); + Assert.Single(h.Deliverer.Outcomes); + + /* A third report climbs past the watermark → fires again (:143 gate), watermark moves. */ + h.Adapter.Blocking.Add(BlockingRow(53)); + h.Now = h.Now.AddMinutes(10); + await engine.EvaluateServerAsync(Harness.Snapshot()); + Assert.Equal(2, h.Deliverer.Outcomes.Count); + Assert.Equal(3, h.StateStore.EdgeWatermarks[(Key, "Blocking Detected")]); + } + + [Fact] + public async Task Blocking_RestartWithPersistedWatermark_DoesNotReAlert() + { + /* Pinned #1145 behavior: the old loop bulk-seeded _lastAlertedBlockingCount at startup + (old MainWindow.xaml.cs:1563-1594); the engine seeds per key from the same store rows + before the first sweep — a restart must not re-alert on already-alerted counts. */ + DisableAllChecks(); + App.AlertBlockingEnabled = true; + var h = new Harness(); + h.Adapter.Blocking.Add(BlockingRow(51)); + h.Adapter.Blocking.Add(BlockingRow(52)); + h.StateStore.EdgeWatermarks[(Key, "Blocking Detected")] = 2; /* persisted pre-restart */ + + await h.Build().EvaluateServerAsync(Harness.Snapshot()); /* fresh engine = restart */ + + Assert.Empty(h.Deliverer.Outcomes); + } + + /* ===================================================================================== + 3. Suppressed sweep: delivers nothing, blocking watermark does not advance + (old :62 suppressPopups; RollingCountAlertGate suppressed semantics) + ===================================================================================== */ + + [Fact] + public async Task SuppressedSweep_DeliversNothing_AndDoesNotAdvanceBlockingWatermark() + { + DisableAllChecks(); + App.AlertCpuEnabled = true; + App.AlertBlockingEnabled = true; + var h = new Harness(); + var engine = h.Build(); + h.Adapter.Blocking.Add(BlockingRow(51)); + + await engine.EvaluateServerAsync(Harness.Snapshot(sqlCpu: 95, totalCpu: 99, suppressed: true)); + + Assert.Empty(h.Deliverer.Outcomes); + Assert.Empty(h.Resolutions); + /* :143 — the gate is told about suppression, so the watermark stays 0 and nothing was + persisted; when the user un-acknowledges, the same lingering report still alerts. */ + Assert.Empty(h.StateStore.SavedEdge); + + await engine.EvaluateServerAsync(Harness.Snapshot(sqlCpu: 95, totalCpu: 99)); + Assert.Equal(2, h.Deliverer.Outcomes.Count); /* CPU + blocking both fire once unsuppressed */ + } + + /* ===================================================================================== + 4. Muted alert → still delivered flagged-muted (history row still written) + (old :76-77,:80,:99 — mute skips the toast, the send still runs with muted:true and + EmailAlertService.TrySendAlertEmailAsync ALWAYS logs the history row) + ===================================================================================== */ + + [Fact] + public async Task MutedAlert_IsDeliveredFlaggedMuted_SoTheHistoryRowIsStillWritten() + { + DisableAllChecks(); + App.AlertCpuEnabled = true; + var h = new Harness { Muted = true }; + var engine = h.Build(); + + await engine.EvaluateServerAsync(Harness.Snapshot(sqlCpu: 70, totalCpu: 92)); + + var fired = Assert.Single(h.Deliverer.Outcomes); + Assert.True(fired.Muted); + /* The cooldown was stamped even when muted (:76-78) — no second delivery inside it. */ + h.Now = h.Now.AddMinutes(2); + await engine.EvaluateServerAsync(Harness.Snapshot(sqlCpu: 70, totalCpu: 92)); + Assert.Single(h.Deliverer.Outcomes); + } + + /* ===================================================================================== + 5. Low-disk worsening + CRITICAL grading (old :477-557, #754 follow-up + #1136) + ===================================================================================== */ + + [Fact] + public async Task LowDisk_FiresOnFreshBreach_OnlyRefiresOnWorsening_AndGradesCritical() + { + DisableAllChecks(); + App.AlertLowDiskEnabled = true; + var h = new Harness(); + var engine = h.Build(); + /* 8% free (64/800 GB) — breaches the 10% threshold, not critically low (>3% and >2GB). */ + var volume = new VolumeFreeSpaceInfo { MountPoint = "D:\\", TotalMb = 819200, FreeMb = 65536 }; + h.Adapter.Volumes.Add(volume); + + await engine.EvaluateServerAsync(Harness.Snapshot()); + + var fired = Assert.Single(h.Deliverer.Outcomes); + Assert.Equal("Volume Free Space", fired.MetricName); + /* :530 current value and :510 toast body share the same string. */ + Assert.Equal("D:\\ 8% free (64.0 GB)", fired.CurrentValue); + Assert.Equal("D:\\ 8% free (64.0 GB)", fired.ShortMessage); + Assert.Equal("10% / 5 GB", fired.ThresholdValue); /* :531 FormatLowDiskThreshold */ + Assert.Null(fired.Severity); /* :521 — not critically low → WARNING default */ + + /* Standing, unchanged breach after the cooldown → NO re-fire (#754 follow-up :497-499). */ + h.Now = h.Now.AddMinutes(10); + await engine.EvaluateServerAsync(Harness.Snapshot()); + Assert.Single(h.Deliverer.Outcomes); + + /* Worsening past the 1% margin AND critically low (2% ≤ 3%) → re-fires graded CRITICAL + (#1136 :521-524). */ + volume.FreeMb = 16384; /* 16 GB = 2% ... also > 2GB so the percent dimension trips it */ + h.Now = h.Now.AddMinutes(10); + await engine.EvaluateServerAsync(Harness.Snapshot()); + Assert.Equal(2, h.Deliverer.Outcomes.Count); + Assert.Equal(AlertSeverityLevel.Critical, h.Deliverer.Outcomes[1].Severity); + + /* Recovery → the exact resolve strings (:545-548) and the worsening watermark clears. */ + h.Adapter.Volumes.Clear(); + h.Now = h.Now.AddMinutes(10); + await engine.EvaluateServerAsync(Harness.Snapshot()); + var res = Assert.Single(h.Resolutions); + Assert.Equal("Volume Free Space Resolved", res.Title); + Assert.Equal("SRV-A: All volumes back above threshold", res.Message); + } + + /* ===================================================================================== + 6. Failed-job watermark dedup + restart survival (old :636-719 + PR #1173/#1145) + ===================================================================================== */ + + [Fact] + public async Task FailedJob_Fires_PersistsWatermark_DedupsSameFailure_AndSurvivesRestart() + { + DisableAllChecks(); + App.AlertFailedJobEnabled = true; + var h = new Harness(); + var engine = h.Build(); + var runTime = new DateTime(2026, 7, 1, 11, 45, 0); /* SERVER-LOCAL, never coerced to UTC */ + h.FailedJobs.Add(new FailedJobInfo { JobName = "NightlyETL", JobId = "job-1", RunDateTime = runTime, StepId = 2, StepName = "Load", Message = "boom" }); + + await engine.EvaluateServerAsync(Harness.Snapshot()); + + var fired = Assert.Single(h.Deliverer.Outcomes); + Assert.Equal("Failed Agent Job", fired.MetricName); + Assert.Equal("1 job failure(s) in last 60m — NightlyETL", fired.CurrentValue); /* :703 */ + Assert.Equal("1 job failure(s) — NightlyETL", fired.ShortMessage); /* :690 toast body */ + /* :684 — the server-local watermark persisted the moment the alert fired. */ + Assert.Equal(runTime, h.FailedJobWatermark()); + + /* The same failure lingers in the lookback window → no re-fire (:667-669). */ + h.Now = h.Now.AddMinutes(10); + await engine.EvaluateServerAsync(Harness.Snapshot()); + Assert.Single(h.Deliverer.Outcomes); + + /* Restart: a FRESH engine over the same store seeds the watermark and stays quiet + (#1145 parity — the old bulk seed at MainWindow.xaml.cs:1584-1588). */ + var restarted = h.Build(); + h.Now = h.Now.AddMinutes(10); + await restarted.EvaluateServerAsync(Harness.Snapshot()); + Assert.Single(h.Deliverer.Outcomes); + + /* A strictly newer failure fires again (:667 hasNewFailure). */ + h.FailedJobs.Add(new FailedJobInfo { JobName = "NightlyETL", JobId = "job-1", RunDateTime = runTime.AddMinutes(30), StepId = 2, StepName = "Load", Message = "boom" }); + h.Now = h.Now.AddMinutes(10); + await restarted.EvaluateServerAsync(Harness.Snapshot()); + Assert.Equal(2, h.Deliverer.Outcomes.Count); + } + + [Fact] + public async Task FailedJob_OfflineOrAzure_SkipsTheCheck() + { + /* :651-655 — only online, non-Azure-SQL-DB servers are queried (the msdb-access half of + the old gate lives in MainWindow.FetchFailedJobsForAlertAsync, which degrades to an + empty list). */ + DisableAllChecks(); + App.AlertFailedJobEnabled = true; + var h = new Harness(); + h.FailedJobs.Add(new FailedJobInfo { JobName = "J", JobId = "1", RunDateTime = DateTime.Now }); + + var offline = await h.Build().EvaluateServerAsync(Harness.Snapshot(isOnline: false)); + var azure = await h.Build().EvaluateServerAsync(Harness.Snapshot(isAzureSqlDb: true)); + + Assert.Empty(h.Deliverer.Outcomes); + Assert.False(offline.FailedJobConditionPresent); + Assert.False(azure.FailedJobConditionPresent); + } + + /* ===================================================================================== + 7. The #754/#749 badge feed: standing conditions on the sweep result + (old :54-57 locals + :721-734 sweep-end write — computed BEFORE suppression/watermark) + ===================================================================================== */ + + [Fact] + public async Task SweepResult_CarriesStandingBadgeConditions_EvenWhenSuppressed() + { + DisableAllChecks(); + App.AlertLowDiskEnabled = true; + App.AlertFailedJobEnabled = true; + var h = new Harness(); + h.Adapter.Volumes.Add(new VolumeFreeSpaceInfo { MountPoint = "D:\\", TotalMb = 819200, FreeMb = 65536 }); + h.FailedJobs.Add(new FailedJobInfo { JobName = "J", JobId = "1", RunDateTime = new DateTime(2026, 7, 1, 11, 45, 0) }); + + /* Suppressed: nothing delivered, but the badge conditions are still observed — + the old loop set curBadgeLowDisk (:487) / curBadgeFailedJob (:663) before the + suppression gates. */ + var sweep = await h.Build().EvaluateServerAsync(Harness.Snapshot(suppressed: true)); + + Assert.True(sweep.Evaluated); + Assert.True(sweep.LowDiskConditionPresent); + Assert.True(sweep.FailedJobConditionPresent); + Assert.Empty(h.Deliverer.Outcomes); + } + + [Fact] + public async Task SweepResult_ClearsConditions_WhenChecksDisabledOrRecovered() + { + /* #1128 — a disabled feature clears a previously-lit badge at the sweep-end write. */ + DisableAllChecks(); + var h = new Harness(); + h.Adapter.Volumes.Add(new VolumeFreeSpaceInfo { MountPoint = "D:\\", TotalMb = 819200, FreeMb = 65536 }); + h.FailedJobs.Add(new FailedJobInfo { JobName = "J", JobId = "1", RunDateTime = new DateTime(2026, 7, 1, 11, 45, 0) }); + + var sweep = await h.Build().EvaluateServerAsync(Harness.Snapshot()); + + Assert.True(sweep.Evaluated); + Assert.False(sweep.LowDiskConditionPresent); + Assert.False(sweep.FailedJobConditionPresent); + } + + [Fact] + public async Task MasterSwitchOff_ReturnsNotEvaluated_SoBadgesStayUntouched() + { + /* :40 — the old loop returned before ANY badge write when alerts were disabled. */ + App.AlertsEnabled = false; + var h = new Harness(); + h.Adapter.Volumes.Add(new VolumeFreeSpaceInfo { MountPoint = "D:\\", TotalMb = 819200, FreeMb = 65536 }); + + var sweep = await h.Build().EvaluateServerAsync(Harness.Snapshot()); + + Assert.False(sweep.Evaluated); + Assert.Empty(h.Deliverer.Outcomes); + } + + /* ===================================================================================== + 8. The remaining per-metric toast bodies (ShortMessage), verbatim from the old loop + ===================================================================================== */ + + [Fact] + public async Task Deadlock_ToastBody_AndValues_MatchTheOldLoop() + { + DisableAllChecks(); + App.AlertDeadlockEnabled = true; + var h = new Harness(); + h.Adapter.Deadlocks.Add(DeadlockRow()); + + await h.Build().EvaluateServerAsync(Harness.Snapshot()); + + var fired = Assert.Single(h.Deliverer.Outcomes); + Assert.Equal("Deadlocks Detected", fired.MetricName); + Assert.Equal("1", fired.CurrentValue); /* :257 */ + Assert.Equal("1 deadlock(s) in the last hour", fired.ShortMessage); /* :244 toast body */ + } + + [Fact] + public async Task PoisonWait_ToastBody_UsesTheWorstWait() + { + DisableAllChecks(); + App.AlertPoisonWaitEnabled = true; + var h = new Harness(); + h.Adapter.PoisonWaits.Add(new PoisonWaitDelta { WaitType = "THREADPOOL", AvgMsPerWait = 750, DeltaMs = 15000, DeltaTasks = 20 }); + h.Adapter.PoisonWaits.Add(new PoisonWaitDelta { WaitType = "RESOURCE_SEMAPHORE", AvgMsPerWait = 600, DeltaMs = 6000, DeltaTasks = 10 }); + + await h.Build().EvaluateServerAsync(Harness.Snapshot()); + + var fired = Assert.Single(h.Deliverer.Outcomes); + Assert.Equal("THREADPOOL (750ms), RESOURCE_SEMAPHORE (600ms)", fired.CurrentValue); /* :288/:315 */ + Assert.Equal("500ms avg", fired.ThresholdValue); /* :316 */ + Assert.Equal("THREADPOOL avg 750ms/wait", fired.ShortMessage); /* :302 toast body */ + Assert.Equal(750, fired.NumericCurrentValue); + } + + [Fact] + public async Task LongRunningQuery_ToastBody_CarriesSessionAndEightyCharPreview() + { + DisableAllChecks(); + App.AlertLongRunningQueryEnabled = true; + var h = new Harness(); + var longText = "SELECT " + new string('x', 100); /* forces the :357 80-char preview cut */ + h.Adapter.LongRunning.Add(new LongRunningQueryInfo + { + SessionId = 55, DatabaseName = "SO", QueryText = longText, ElapsedSeconds = 2750 /* 45m 50s → 45 (integer division :356) */ + }); + + await h.Build().EvaluateServerAsync(Harness.Snapshot()); + + var fired = Assert.Single(h.Deliverer.Outcomes); + Assert.Equal("1 query(s), longest 45m", fired.CurrentValue); /* :387 */ + Assert.Equal("30m", fired.ThresholdValue); /* :388 */ + var expectedPreview = PerformanceMonitor.Alerting.AlertContextBuilders.TruncateText(longText, 80); + Assert.EndsWith("...", expectedPreview); + Assert.Equal($"Session #55 running 45m — {expectedPreview}", fired.ShortMessage); /* :357-358, :374 */ + } + + [Fact] + public async Task TempDb_ToastBody_MatchesTheOldLoop() + { + DisableAllChecks(); + App.AlertTempDbSpaceEnabled = true; + var h = new Harness(); + h.Adapter.TempDb = new TempDbSpaceInfo { TotalReservedMb = 910, UnallocatedMb = 90 }; /* 91% used */ + + await h.Build().EvaluateServerAsync(Harness.Snapshot()); + + var fired = Assert.Single(h.Deliverer.Outcomes); + Assert.Equal("91% used (910 MB)", fired.CurrentValue); /* :448 */ + Assert.Equal("tempdb 91% used", fired.ShortMessage); /* :435 toast body */ + } + + [Fact] + public async Task LongRunningJob_ToastBody_CarriesJobNamePercentAndMinutes() + { + DisableAllChecks(); + App.AlertLongRunningJobEnabled = true; + var h = new Harness(); + h.Adapter.AnomalousJobs.Add(new AnomalousJobInfo + { + JobName = "NightlyETL", JobId = "77", CurrentDurationSeconds = 5430, /* 90m (integer division :585) */ + AvgDurationSeconds = 1200, PercentOfAverage = 450, StartTime = new DateTime(2026, 7, 1, 10, 0, 0) + }); + + await h.Build().EvaluateServerAsync(Harness.Snapshot()); + + var fired = Assert.Single(h.Deliverer.Outcomes); + Assert.Equal("1 job(s) exceeding 3x average", fired.CurrentValue); /* :608 */ + Assert.Equal("3x historical avg", fired.ThresholdValue); /* :609 */ + Assert.Equal("NightlyETL at 450% of avg (90m)", fired.ShortMessage); /* :595 toast body */ + } + + /* ===================================================================================== + 9. LiteAlertDeliverer: the old delivery block, through its injected seams + (old :80-100 toast-then-send order; :742-771 SendDetectedAlertAsync + override) + ===================================================================================== */ + + private sealed record ToastCall(string Title, string Message, BalloonIcon Icon, string ServerName, string MetricName); + private sealed record SendCall( + string MetricName, string ServerName, string CurrentValue, string ThresholdValue, + int ServerId, AlertContext? Context, double? NumericCurrentValue, double? NumericThresholdValue, + bool Muted, string? DetailText); + + private static (LiteAlertDeliverer Deliverer, List<ToastCall> Toasts, List<SendCall> Sends) + BuildDeliverer(AlertNotificationMode? serverOverride = null) + { + var toasts = new List<ToastCall>(); + var sends = new List<SendCall>(); + var deliverer = new LiteAlertDeliverer( + (title, message, icon, serverName, metricName) => toasts.Add(new ToastCall(title, message, icon, serverName, metricName)), + (metricName, serverName, currentValue, thresholdValue, serverId, context, numCur, numThr, muted, detailText) => + { + sends.Add(new SendCall(metricName, serverName, currentValue, thresholdValue, serverId, context, numCur, numThr, muted, detailText)); + return Task.CompletedTask; + }, + _ => serverOverride); + return (deliverer, toasts, sends); + } + + private static AlertOutcome Outcome( + string metric, string current = "cur", string threshold = "thr", AlertContext? context = null, + double? numCur = null, double? numThr = null, bool muted = false, string? shortMessage = "body") + => new(Key, Name, metric, current, threshold, context, "detail", numCur, numThr, muted, null, shortMessage); + + [Fact] + public async Task Deliverer_Toast_TitleIsMetric_MessagePrefixedWithServer_IconTableMatches() + { + var (deliverer, toasts, _) = BuildDeliverer(); + + /* Old loop's icon table: Error for deadlocks (:245) and poison waits (:303), + Warning for CPU (:85), blocking (:168), LRQ (:375), tempdb (:436), volume (:511), + long-running job (:596), failed job (:691). Title == metric name in every branch. */ + await deliverer.DeliverAsync(Outcome("High CPU", shortMessage: "Total CPU at 92% (threshold: 80%)")); + await deliverer.DeliverAsync(Outcome("Deadlocks Detected", shortMessage: "1 deadlock(s) in the last hour")); + await deliverer.DeliverAsync(Outcome("Poison Wait", shortMessage: "THREADPOOL avg 750ms/wait")); + await deliverer.DeliverAsync(Outcome("Volume Free Space", shortMessage: "D:\\ 8% free (64.0 GB)")); + + Assert.Equal(4, toasts.Count); + Assert.Equal(new ToastCall("High CPU", "SRV-A: Total CPU at 92% (threshold: 80%)", BalloonIcon.Warning, Name, "High CPU"), toasts[0]); + Assert.Equal(BalloonIcon.Error, toasts[1].Icon); + Assert.Equal("SRV-A: 1 deadlock(s) in the last hour", toasts[1].Message); /* :244 full toast */ + Assert.Equal(BalloonIcon.Error, toasts[2].Icon); + Assert.Equal(BalloonIcon.Warning, toasts[3].Icon); + } + + [Fact] + public async Task Deliverer_Muted_SkipsTheToast_ButStillSendsFlaggedMuted() + { + /* :80 (if (!isMuted) toast) + :93-99 (send always runs, muted: true → the history row is + still written because TrySendAlertEmailAsync logs unconditionally). */ + var (deliverer, toasts, sends) = BuildDeliverer(); + + await deliverer.DeliverAsync(Outcome("High CPU", muted: true)); + + Assert.Empty(toasts); + var send = Assert.Single(sends); + Assert.True(send.Muted); + } + + [Fact] + public async Task Deliverer_NonBlockingMetrics_SendOnce_WithNumerics() + { + /* The old loop sent every non-blocking/deadlock metric directly (:312-322 poison, + :384-394 LRQ, ...) with its numeric values — never through the per-event split, even + when its context has incidents and the mode is PerEvent. */ + App.AlertDeliveryMode = AlertNotificationMode.PerEvent; + var context = new AlertContext + { + Incidents = new List<AlertIncident> { new("a", new[] { "dbo.Users" }), new("b", new[] { "dbo.Posts" }) } + }; + var (deliverer, _, sends) = BuildDeliverer(); + + await deliverer.DeliverAsync(Outcome("Long-Running Query", current: "1 query(s), longest 45m", threshold: "30m", context: context, numCur: 45, numThr: 30)); + + var send = Assert.Single(sends); + Assert.Equal(45, send.NumericCurrentValue); + Assert.Equal(30, send.NumericThresholdValue); + Assert.Same(context, send.Context); + } + + [Fact] + public async Task Deliverer_Blocking_SummaryMode_OneCombinedSend_NoNumerics() + { + /* :760-762 — the summary fallback carries the batched current value + detail text and, + like the old blocking/deadlock sends (:177-185), no numeric values. */ + var context = new AlertContext + { + Incidents = new List<AlertIncident> { new("a", new[] { "dbo.Users" }), new("b", new[] { "dbo.Posts" }) } + }; + var (deliverer, _, sends) = BuildDeliverer(); + + await deliverer.DeliverAsync(Outcome("Blocking Detected", current: "2", threshold: "1", context: context)); + + var send = Assert.Single(sends); + Assert.Equal("2", send.CurrentValue); + Assert.Equal("detail", send.DetailText); + Assert.Null(send.NumericCurrentValue); + Assert.Null(send.NumericThresholdValue); + Assert.Equal(101, send.ServerId); + } + + [Fact] + public async Task Deliverer_Blocking_PerEventMode_SplitsPerIncident_WithCapOverflow() + { + /* :748-757 — Per-event mode: one send per distinct incident, capped at + AlertPerEventMaxPerCycle with a "+N more" trailer that keeps the remaining + fingerprints; per-message detail text is rendered from the split context. */ + App.AlertDeliveryMode = AlertNotificationMode.PerEvent; + App.AlertPerEventMaxPerCycle = 2; + var context = new AlertContext + { + Incidents = new List<AlertIncident> + { + new("a", new[] { "dbo.Users" }, OccurrenceCount: 3), + new("b", new[] { "dbo.Posts" }, OccurrenceCount: 1), + new("c", new[] { "dbo.Badges" }, OccurrenceCount: 2) + } + }; + var (deliverer, toasts, sends) = BuildDeliverer(); + + await deliverer.DeliverAsync(Outcome("Blocking Detected", current: "6", threshold: "1", context: context)); + + /* One toast (delivery-mode split shapes the SENDS only — the old loop toasted once). */ + Assert.Single(toasts); + Assert.Equal(3, sends.Count); /* 2 per-incident + 1 overflow */ + Assert.Equal("3", sends[0].CurrentValue); /* DescribeIncident = occurrence count */ + Assert.Equal("1", sends[1].CurrentValue); + Assert.Equal("+1 more incident(s) this cycle", sends[2].CurrentValue); + Assert.All(sends, s => Assert.Null(s.NumericCurrentValue)); + var overflowIncident = Assert.Single(sends[2].Context!.Incidents!); + Assert.Equal("c", overflowIncident.DedupKey); + } + + [Fact] + public async Task Deliverer_PerServerOverride_WinsOverGlobalMode() + { + /* #1236 (:748, :768-771) — a per-server PerEvent override beats the global Summary. */ + App.AlertDeliveryMode = AlertNotificationMode.Summary; + var context = new AlertContext + { + Incidents = new List<AlertIncident> { new("a", new[] { "dbo.Users" }, OccurrenceCount: 1) } + }; + var (deliverer, _, sends) = BuildDeliverer(serverOverride: AlertNotificationMode.PerEvent); + + await deliverer.DeliverAsync(Outcome("Deadlocks Detected", current: "1", threshold: "1", context: context)); + + var send = Assert.Single(sends); + Assert.Equal("1", send.CurrentValue); /* the per-incident send (occurrence count), not the summary */ + Assert.NotNull(send.Context); + Assert.Single(send.Context!.Incidents!); + } + + /* ===================================================================================== + 10. AppAlertEngineSettings: live pass-through of every engine member + ===================================================================================== */ + + [Fact] + public void EngineSettings_PassThroughEveryMember_Live() + { + var settings = new AppAlertEngineSettings(); + + App.AlertsEnabled = false; + Assert.False(settings.AlertsEnabled); + App.AlertsEnabled = true; + Assert.True(settings.AlertsEnabled); + + App.AlertCpuEnabled = false; Assert.False(settings.CpuEnabled); + App.AlertBlockingEnabled = false; Assert.False(settings.BlockingEnabled); + App.AlertDeadlockEnabled = false; Assert.False(settings.DeadlockEnabled); + App.AlertPoisonWaitEnabled = false; Assert.False(settings.PoisonWaitEnabled); + App.AlertLongRunningQueryEnabled = false; Assert.False(settings.LongRunningQueryEnabled); + App.AlertTempDbSpaceEnabled = false; Assert.False(settings.TempDbSpaceEnabled); + App.AlertLowDiskEnabled = false; Assert.False(settings.LowDiskEnabled); + App.AlertLongRunningJobEnabled = false; Assert.False(settings.LongRunningJobEnabled); + App.AlertFailedJobEnabled = false; Assert.False(settings.FailedJobEnabled); + + App.AlertCpuThreshold = 91; Assert.Equal(91, settings.CpuThresholdPercent); + App.AlertBlockingThreshold = 7; Assert.Equal(7, settings.BlockingCountThreshold); + App.AlertDeadlockThreshold = 4; Assert.Equal(4, settings.DeadlockCountThreshold); + App.AlertPoisonWaitThresholdMs = 999; Assert.Equal(999, settings.PoisonWaitThresholdMs); + App.AlertLongRunningQueryThresholdMinutes = 15; Assert.Equal(15, settings.LongRunningQueryThresholdMinutes); + App.AlertTempDbSpaceThresholdPercent = 66; Assert.Equal(66, settings.TempDbSpaceThresholdPercent); + App.AlertLowDiskThresholdPercent = 20; Assert.Equal(20, settings.LowDiskThresholdPercent); + App.AlertLowDiskThresholdGb = 9; Assert.Equal(9, settings.LowDiskThresholdGb); + App.AlertLongRunningJobMultiplier = 5; Assert.Equal(5, settings.LongRunningJobMultiplier); + App.AlertFailedJobLookbackMinutes = 120; Assert.Equal(120, settings.FailedJobLookbackMinutes); + App.AlertCooldownMinutes = 30; Assert.Equal(30, settings.CooldownMinutes); + + /* The six LRQ read knobs — Lite exposes all six as real settings. */ + App.AlertLongRunningQueryMaxResults = 42; Assert.Equal(42, settings.LongRunningQueryMaxResults); + App.AlertLongRunningQueryExcludeSpServerDiagnostics = false; Assert.False(settings.LongRunningQueryExcludeSpServerDiagnostics); + App.AlertLongRunningQueryExcludeWaitFor = false; Assert.False(settings.LongRunningQueryExcludeWaitFor); + App.AlertLongRunningQueryExcludeBackups = false; Assert.False(settings.LongRunningQueryExcludeBackups); + App.AlertLongRunningQueryExcludeMiscWaits = false; Assert.False(settings.LongRunningQueryExcludeMiscWaits); + App.AlertLongRunningQueryExcludeCdc = false; Assert.False(settings.LongRunningQueryExcludeCdc); + + App.AlertExcludedDatabases = new List<string> { "tempdb", "model" }; + Assert.Equal(new[] { "tempdb", "model" }, settings.ExcludedDatabases); + + /* The one mapped member: Lite's persisted enum → the engine's enum. */ + App.AlertCpuMode = CpuAlertMode.Total; + Assert.Equal(EngineCpuAlertMode.TotalServer, settings.CpuAlertMode); + App.AlertCpuMode = CpuAlertMode.SqlOnly; + Assert.Equal(EngineCpuAlertMode.SqlProcess, settings.CpuAlertMode); + } + + /* ===================================================================================== + 11. LiteAlertStateStore: round-trip over the REAL DuckDB watermark table + ===================================================================================== */ + + [Fact] + public async Task StateStore_RoundTripsWatermarks_ThroughTheRealDuckDbTable() + { + var tempDir = Path.Combine(Path.GetTempPath(), "LiteFwdTests_" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(tempDir); + try + { + var duckDb = new DuckDbInitializer(Path.Combine(tempDir, "test.duckdb")); + await duckDb.InitializeAsync(); + IAlertStateStore store = new LiteAlertStateStore(new DuckDbAlertHistoryStore(duckDb)); + + /* Nothing persisted yet → null, so the engine starts at watermark 0 like the old loop. */ + Assert.Null(await store.LoadEdgeTriggerWatermarkAsync("101", AlertEngine.BlockingWatermarkMetric)); + Assert.Null(await store.LoadFailedJobWatermarkAsync("101")); + + /* The engine's serverKey string maps to the same server_id int rows the pre-forwarding + loop persisted — so existing watermarks seed the engine unchanged post-upgrade. */ + await store.SaveEdgeTriggerWatermarkAsync("101", AlertEngine.BlockingWatermarkMetric, 3); + await store.SaveEdgeTriggerWatermarkAsync("101", AlertEngine.DeadlockWatermarkMetric, 2); + await store.SaveEdgeTriggerWatermarkAsync("101", AlertEngine.BlockingWatermarkMetric, 5); /* upsert */ + var failedAt = new DateTime(2026, 7, 1, 11, 45, 0); + await store.SaveFailedJobWatermarkAsync("101", failedAt); + + Assert.Equal(5, await store.LoadEdgeTriggerWatermarkAsync("101", AlertEngine.BlockingWatermarkMetric)); + Assert.Equal(2, await store.LoadEdgeTriggerWatermarkAsync("101", AlertEngine.DeadlockWatermarkMetric)); + Assert.Equal(failedAt, await store.LoadFailedJobWatermarkAsync("101")); + /* Per-key isolation: another server's rows don't bleed in. */ + Assert.Null(await store.LoadEdgeTriggerWatermarkAsync("202", AlertEngine.BlockingWatermarkMetric)); + } + finally + { + try { Directory.Delete(tempDir, recursive: true); } catch { /* best-effort */ } + } + } +} diff --git a/Lite.Tests/LiteRecommendationsReaderTests.cs b/Lite.Tests/LiteRecommendationsReaderTests.cs index 76e8eac73..ee1397c8e 100644 --- a/Lite.Tests/LiteRecommendationsReaderTests.cs +++ b/Lite.Tests/LiteRecommendationsReaderTests.cs @@ -93,7 +93,6 @@ public void MapFinding_UsesAdviceHeadlineAsTitle_WhenAdviceExists() Assert.Equal(LiteRecommendationSeverity.Critical, item.Severity); Assert.Equal(1.6, item.RawSeverity); Assert.Equal(ServerName, item.ServerName); - Assert.Equal("SOS_SCHEDULER_YIELD", item.RootFactKey); Assert.False(string.IsNullOrEmpty(item.AdviceText)); } @@ -112,20 +111,53 @@ public void MapFinding_UnknownFactKey_FallsBackToRootFactKeyTitle_NoAdvice() } [Fact] - public void MapFinding_ReadBackFinding_HasNoDrillDown_SoCopyPasteSqlIsNull() + public void MapFinding_ReadBackFinding_NoActionAndNoDrillDown_CopyPasteSqlIsNull() { - // The store read path does not populate DrillDown; the SQL generators need it, so a - // read-back DB_CONFIG finding (which WOULD produce ALTERs from a drill-down) yields no SQL. + // A read-back finding that carries neither a persisted RemediationAction NOR a drill-down + // (e.g. a pure incident) yields no command; the card renders advise-only. var item = LiteRecommendationsReader.MapFinding(Finding("DB_CONFIG", 1.0, database: "MyDb"), ServerName); Assert.Null(item.CopyPasteSql); Assert.Equal("MyDb", item.Database); } + [Fact] + public void MapFinding_ReadBackFinding_WithPersistedAction_RendersTheCopyPasteCommand() + { + // The read-back path (no drill-down) now carries the BUILT action deserialized from + // remediation_action_json; MapFinding renders it via the SHARED renderer — the drift this fix + // kills. A safe DB_CONFIG action renders the bare ALTER, byte-identical to the Darling viewer. + var finding = Finding("DB_CONFIG", 1.0, database: "StackOverflow"); + finding.Remediation = new RemediationAction( + "DB_CONFIG", "set", Array.Empty<ForcePlanTarget>(), + DbConfigTargets: new[] { new DbConfigTarget("StackOverflow", DbConfigSetting.AutoShrinkOff) }); + + var item = LiteRecommendationsReader.MapFinding(finding, ServerName); + + Assert.Equal("ALTER DATABASE [StackOverflow] SET AUTO_SHRINK OFF;", item.CopyPasteSql); + } + + [Fact] + public void MapFinding_PersistedActionWins_OverTheLiveDrillDownFallback() + { + // When BOTH a persisted action and a drill-down are present, the persisted-action render is the + // PRIMARY source (the read-back path) and the drill-down generator is only the fallback. + var finding = Finding("DB_CONFIG", 1.0, database: "StackOverflow"); + finding.Remediation = new RemediationAction( + "DB_CONFIG", "set", Array.Empty<ForcePlanTarget>(), + DbConfigTargets: new[] { new DbConfigTarget("StackOverflow", DbConfigSetting.AutoShrinkOff) }); + finding.DrillDown = new Dictionary<string, object>(); // present but empty + + var item = LiteRecommendationsReader.MapFinding(finding, ServerName); + + Assert.Equal("ALTER DATABASE [StackOverflow] SET AUTO_SHRINK OFF;", item.CopyPasteSql); + } + [Fact] public void MapFinding_WithDrillDown_PopulatesCopyPasteSqlFromSharedHelper() { - // Generate-now path: an enriched finding carries drill-down, so the SHARED FactRemediation - // helper produces the same copy-paste statement the Dashboard would. + // Generate-now fallback path: an enriched finding carries drill-down but NO persisted action, + // so the SHARED FactRemediation generator produces the same copy-paste statement the Dashboard + // would. (When an action IS persisted it wins; see MapFinding_PersistedActionWins.) var finding = Finding("PLAN_REGRESSION", 1.6, database: "MyDb"); finding.DrillDown = new Dictionary<string, object> { @@ -148,6 +180,185 @@ public void MapFinding_WithDrillDown_PopulatesCopyPasteSqlFromSharedHelper() Assert.Equal(expectedSql, item.CopyPasteSql); } + // ── BuildCopyPasteSql: the persisted-action renderer for all seven remediation shapes ────────── + // Lite delegates to the SAME shared FactRemediation.RenderCopyPasteCommand the Darling viewer uses, + // so these mirror Darling's ViewerRecommendationsTests and prove Lite produces byte-identical + // commands (ZERO drift). Lite renders a COPYABLE command only — there is no in-app executor. + + [Fact] + public void BuildCopyPasteSql_NullOrEmptyAction_ReturnsNull() + { + Assert.Null(LiteRecommendationsReader.BuildCopyPasteSql(null)); + + // An action with no renderable target (an empty force-plan list) yields null — nothing to run. + var empty = new RemediationAction("PLAN_REGRESSION", "force", Array.Empty<ForcePlanTarget>()); + Assert.Null(LiteRecommendationsReader.BuildCopyPasteSql(empty)); + } + + [Fact] + public void BuildCopyPasteSql_ForcePlanTargets_RenderUseThenForcePlanPerDatabase() + { + // sp_query_store_force_plan is DATABASE-scoped, so each target is a standalone USE + EXEC block. + var action = new RemediationAction( + "PLAN_REGRESSION", "force", + new[] + { + new ForcePlanTarget("StackOverflow", QueryId: 42, PlanId: 7), + new ForcePlanTarget("Crazy]Name", QueryId: 100, PlanId: 200), + }); + + var sql = LiteRecommendationsReader.BuildCopyPasteSql(action); + + Assert.Equal( + "USE [StackOverflow];" + Environment.NewLine + + "EXEC sys.sp_query_store_force_plan @query_id = 42, @plan_id = 7;" + Environment.NewLine + + Environment.NewLine + + "USE [Crazy]]Name];" + Environment.NewLine + + "EXEC sys.sp_query_store_force_plan @query_id = 100, @plan_id = 200;", + sql); + } + + [Fact] + public void BuildCopyPasteSql_ServerConfigTargets_RenderSpConfigureViaTheSharedBuilder() + { + var action = new RemediationAction( + "SERVER_CONFIG", "set", Array.Empty<ForcePlanTarget>(), + ServerConfigTargets: new[] + { + new ServerConfigTarget(ServerConfigSetting.Maxdop, 0, 8), + new ServerConfigTarget(ServerConfigSetting.MaxServerMemory, 2147483647, 2147483647), + }); + + var sql = LiteRecommendationsReader.BuildCopyPasteSql(action); + + Assert.Equal( + FactRemediation.BuildSpConfigureStatement(ServerConfigSetting.Maxdop, 8) + + Environment.NewLine + Environment.NewLine + + FactRemediation.BuildSpConfigureStatement(ServerConfigSetting.MaxServerMemory, 2147483647), + sql); + } + + [Fact] + public void BuildCopyPasteSql_RcsiTargets_RenderAlterWithTwoSidedDisclosureHeader() + { + // RCSI rides a DB_CONFIG action as a per-database RcsiTarget. It is DESTRUCTIVE, so the + // copy-paste prepends the two-sided risk disclosure as a comment header. + var action = new RemediationAction( + "DB_CONFIG", "set", Array.Empty<ForcePlanTarget>(), + RcsiTargets: new[] { new RcsiTarget("StackOverflow", new RcsiInactionFigures(50, 3, 70)) }); + + var sql = LiteRecommendationsReader.BuildCopyPasteSql(action); + + Assert.NotNull(sql); + Assert.StartsWith("/*", sql, StringComparison.Ordinal); + Assert.Contains("Risks of MAKING this change:", sql!, StringComparison.Ordinal); + Assert.Contains("Risks of NOT making this change:", sql!, StringComparison.Ordinal); + Assert.Contains("50 blocked-process events", sql!, StringComparison.Ordinal); + Assert.Contains("ALTER DATABASE [StackOverflow] SET READ_COMMITTED_SNAPSHOT ON;", sql!, StringComparison.Ordinal); + Assert.True( + sql!.IndexOf("*/", StringComparison.Ordinal) < + sql.IndexOf("ALTER DATABASE", StringComparison.Ordinal), + "the disclosure comment header must precede the ALTER statement"); + } + + [Fact] + public void BuildCopyPasteSql_ClearPlanTargets_RenderResolveAndFreeScriptWithDisclosureHeader() + { + // Clear-plan is DESTRUCTIVE: live-resolve the currently-cached plan handle(s) for the abnormal + // query hash and free each — with the two-sided disclosure header. + var action = new RemediationAction( + "CLEAR_PLAN", "clear", Array.Empty<ForcePlanTarget>(), + ClearPlanTargets: new[] + { + new ClearPlanTarget( + "StackOverflow", "0xABCDEF0123456789", + CurrentCpuPerExecMs: 45.0, BaselineCpuPerExecMs: 9.0, AnomalyRatio: 5.0), + }, + ClearPlanFigures: new ClearPlanFigures(45.0, 9.0, 5.0, 62, false, false)); + + var sql = LiteRecommendationsReader.BuildCopyPasteSql(action); + + Assert.NotNull(sql); + Assert.StartsWith("/*", sql, StringComparison.Ordinal); + Assert.Contains("Risks of MAKING this change:", sql!, StringComparison.Ordinal); + Assert.Contains("Risks of NOT making this change:", sql!, StringComparison.Ordinal); + Assert.Contains("sys.dm_exec_query_stats", sql!, StringComparison.Ordinal); + Assert.Contains("0xABCDEF0123456789", sql!, StringComparison.Ordinal); + Assert.Contains("DBCC FREEPROCCACHE(@plan_handle);", sql!, StringComparison.Ordinal); + } + + [Fact] + public void BuildCopyPasteSql_DbConfigWithRcsi_RendersSafeBareThenRcsiWithHeader() + { + var action = new RemediationAction( + "DB_CONFIG", "set", Array.Empty<ForcePlanTarget>(), + DbConfigTargets: new[] { new DbConfigTarget("StackOverflow", DbConfigSetting.AutoShrinkOff) }, + RcsiTargets: new[] { new RcsiTarget("StackOverflow", new RcsiInactionFigures(50, 3, 70)) }); + + var sql = LiteRecommendationsReader.BuildCopyPasteSql(action); + + Assert.NotNull(sql); + Assert.StartsWith("ALTER DATABASE [StackOverflow] SET AUTO_SHRINK OFF;", sql, StringComparison.Ordinal); + Assert.Contains("Risks of MAKING this change:", sql!, StringComparison.Ordinal); + Assert.Contains("ALTER DATABASE [StackOverflow] SET READ_COMMITTED_SNAPSHOT ON;", sql!, StringComparison.Ordinal); + Assert.True( + sql!.IndexOf("AUTO_SHRINK OFF", StringComparison.Ordinal) < + sql.IndexOf("/*", StringComparison.Ordinal), + "the bare safe fix must precede the RCSI disclosure header"); + } + + [Fact] + public void BuildCopyPasteSql_DbConfigTargets_RenderAlterDatabaseSetPerTarget() + { + var action = new RemediationAction( + "DB_CONFIG", "set", Array.Empty<ForcePlanTarget>(), + DbConfigTargets: new[] + { + new DbConfigTarget("StackOverflow", DbConfigSetting.AutoShrinkOff), + new DbConfigTarget("Crazy]Name", DbConfigSetting.PageVerifyChecksum), + }); + + var sql = LiteRecommendationsReader.BuildCopyPasteSql(action); + + Assert.Equal( + "ALTER DATABASE [StackOverflow] SET AUTO_SHRINK OFF;" + Environment.NewLine + + "ALTER DATABASE [Crazy]]Name] SET PAGE_VERIFY CHECKSUM;", + sql); + } + + [Fact] + public void BuildCopyPasteSql_MissingIndexTargets_RenderTheSuggestedCreateVerbatim() + { + var action = new RemediationAction( + "MISSING_INDEX", "advise", Array.Empty<ForcePlanTarget>(), + MissingIndexTargets: new[] + { + new MissingIndexTarget("dbo.Posts", 92.5, "CREATE INDEX IX_Posts_1 ON dbo.Posts (OwnerUserId);"), + new MissingIndexTarget("dbo.Users", 80.0, "CREATE INDEX IX_Users_1 ON dbo.Users (Reputation);"), + }); + + var sql = LiteRecommendationsReader.BuildCopyPasteSql(action); + + Assert.Equal( + "CREATE INDEX IX_Posts_1 ON dbo.Posts (OwnerUserId);" + Environment.NewLine + + "CREATE INDEX IX_Users_1 ON dbo.Users (Reputation);", + sql); + } + + [Fact] + public void BuildCopyPasteSql_FileGrowthTargets_RenderModifyFileViaTheSharedBuilder() + { + var target = new FileGrowthTarget("StackOverflow", "SO_data", CurrentSizeMb: 90000, CurrentGrowthPercent: 10, RecommendedGrowthMb: 1024); + var action = new RemediationAction( + "FILE_AUTOGROWTH_PERCENT", "set", Array.Empty<ForcePlanTarget>(), + FileGrowthTargets: new[] { target }); + + var sql = LiteRecommendationsReader.BuildCopyPasteSql(action); + + // Byte-identical to the shared renderer the drill-down / Dashboard / Darling reader use. + Assert.Equal(FactRemediation.BuildModifyFileStatement("StackOverflow", "SO_data", 1024), sql); + } + [Fact] public void MapFinding_EmptyDatabase_NormalizesToNull() { @@ -235,7 +446,11 @@ public void MapFindings_AppliesLatestBatchOnly() var items = LiteRecommendationsReader.MapFindings(findings, ServerName); Assert.Single(items); - Assert.Equal("CPU_SQL_PERCENT", items[0].RootFactKey); + // RootFactKey is no longer surfaced on the card; verify the newer batch (CPU_SQL_PERCENT) + // won via its mapped Title (same fallback MapFinding uses: advice headline, else the key). + var cpuAdvice = FactAdvice.GetForFactKey("CPU_SQL_PERCENT"); + var expectedTitle = !string.IsNullOrEmpty(cpuAdvice?.Headline) ? cpuAdvice!.Headline : "CPU_SQL_PERCENT"; + Assert.Equal(expectedTitle, items[0].Title); } [Fact] diff --git a/Lite.Tests/LiteRecommendationsViewModelTests.cs b/Lite.Tests/LiteRecommendationsViewModelTests.cs index 558592590..487652d27 100644 --- a/Lite.Tests/LiteRecommendationsViewModelTests.cs +++ b/Lite.Tests/LiteRecommendationsViewModelTests.cs @@ -26,7 +26,6 @@ private static LiteRecommendationItem Item( Database = database, CopyPasteSql = sql, AdviceText = advice, - RootFactKey = "CPU_SQL_PERCENT", IncidentId = incidentId, ServerName = serverName, WindowStartUtc = new DateTime(2026, 6, 1, 10, 0, 0, DateTimeKind.Utc), diff --git a/Lite.Tests/LongQueryCompletionsCollectorDefinitionTests.cs b/Lite.Tests/LongQueryCompletionsCollectorDefinitionTests.cs new file mode 100644 index 000000000..0cc530318 --- /dev/null +++ b/Lite.Tests/LongQueryCompletionsCollectorDefinitionTests.cs @@ -0,0 +1,310 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Pins the parity + correctness contract of the long_query_completions collector (#1496): identity, the +/// server- vs database-scoped ring-buffer read + event filter, the event_time watermark (10-minute +/// fallback), the 20-column payload and its WritePayload ordinals, the attention handling (its duration is +/// dropped to NULL because the attention event's own duration is cancellation-handling time, not query +/// runtime), and the shared session DDL builders (the duration predicate on the COMPLETED events only, +/// attention unfiltered, the SET collect_* customizable columns, the 9 actions with nt_username omitted on +/// the Azure database-scoped form, and the idempotent guarded DROP). +/// </summary> +public sealed class LongQueryCompletionsCollectorDefinitionTests +{ + private static readonly RecordingCollectorDeltaCalculator s_deltas = new(); + + private static CollectorContext MakeContext( + bool isAzureSqlDb = false, + DateTime? watermark = null, + DateTime? collectionTime = null) + => new() + { + ServerId = 42, + ServerName = "test-server", + CollectionTime = collectionTime ?? new DateTime(2026, 7, 18, 12, 0, 0, DateTimeKind.Utc), + Deltas = s_deltas, + Target = new CollectorTargetInfo { IsAzureSqlDb = isAzureSqlDb }, + Watermark = watermark, + }; + + [Fact] + public void Identity_And_ThresholdDefault() + { + Assert.Equal("long_query_completions", LongQueryCompletionsCollector.Instance.Name); + Assert.Equal("long_query_completions", LongQueryCompletionsCollector.Instance.TargetTable); + Assert.Equal("event_time", LongQueryCompletionsCollector.Instance.WatermarkColumn); + Assert.Equal("long_query_completion_id", LongQueryCompletionsCollector.Instance.PrefixIdColumnName); + Assert.Equal("PerformanceMonitor_LongQueryCompletions", LongQueryCompletionsCollector.XeSessionName); + /* The Dashboard's SQL Trace default (install/30_collect_trace_management.sql): 2 seconds in µs. */ + Assert.Equal(2_000_000, LongQueryCompletionsCollector.DefaultDurationThresholdMicroseconds); + } + + [Fact] + public void RunsPerDatabase_OnAzureOnly_WithPerDatabaseWatermark() + { + Assert.True(LongQueryCompletionsCollector.Instance.RunsPerDatabase(new CollectorTargetInfo { IsAzureSqlDb = true })); + Assert.False(LongQueryCompletionsCollector.Instance.RunsPerDatabase(new CollectorTargetInfo())); + Assert.False(LongQueryCompletionsCollector.Instance.RunsPerDatabase(new CollectorTargetInfo { IsAzureManagedInstance = true })); + Assert.Equal("database_name", LongQueryCompletionsCollector.Instance.PerDatabaseWatermarkColumn); + } + + [Fact] + public void AppliesTo_EverywhereIncludingAzure() + { + /* rpc_completed/sql_batch_completed/attention are all available in an Azure database-scoped session + (verified against MS Learn), so the collector applies on every platform. */ + Assert.True(LongQueryCompletionsCollector.Instance.AppliesTo(new CollectorTargetInfo())); + Assert.True(LongQueryCompletionsCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureSqlDb = true })); + Assert.True(LongQueryCompletionsCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAwsRds = true })); + } + + [Fact] + public void BuildQuery_ServerScoped_UsesServerDmvs_AndEventFilter() + { + var plan = LongQueryCompletionsCollector.Instance.BuildQuery(MakeContext()); + + Assert.Contains("sys.dm_xe_session_targets AS xet", plan.Text, StringComparison.Ordinal); + Assert.DoesNotContain("dm_xe_database_session_targets", plan.Text, StringComparison.Ordinal); + Assert.Contains("N'PerformanceMonitor_LongQueryCompletions'", plan.Text, StringComparison.Ordinal); + Assert.Contains("event[@name=\"rpc_completed\" or @name=\"sql_batch_completed\" or @name=\"attention\"]", plan.Text, StringComparison.Ordinal); + Assert.Contains("> @cutoff_time", plan.Text, StringComparison.Ordinal); + /* statement/batch_text are coalesced into one column; result is projected. */ + Assert.Contains("data[@name=\"statement\"]", plan.Text, StringComparison.Ordinal); + Assert.Contains("data[@name=\"batch_text\"]", plan.Text, StringComparison.Ordinal); + } + + [Fact] + public void BuildQuery_Azure_UsesDatabaseScopedDmvs() + { + var plan = LongQueryCompletionsCollector.Instance.BuildQuery(MakeContext(isAzureSqlDb: true)); + + Assert.Contains("sys.dm_xe_database_session_targets AS xet", plan.Text, StringComparison.Ordinal); + Assert.Contains("JOIN sys.dm_xe_database_sessions AS xes", plan.Text, StringComparison.Ordinal); + } + + [Fact] + public void BuildQuery_PinsWatermarkCutoff_AndTenMinuteFallback() + { + var watermark = new DateTime(2026, 7, 18, 11, 45, 0, DateTimeKind.Utc); + var withWatermark = LongQueryCompletionsCollector.Instance.BuildQuery(MakeContext(watermark: watermark)); + var parameter = Assert.Single(withWatermark.Parameters); + Assert.Equal("@cutoff_time", parameter.Name); + Assert.Equal(watermark, parameter.Value); + Assert.Equal(CollectorParameterType.DateTime2, parameter.Type); + + var collectionTime = new DateTime(2026, 7, 18, 12, 0, 0, DateTimeKind.Utc); + var fallback = LongQueryCompletionsCollector.Instance.BuildQuery(MakeContext(collectionTime: collectionTime)); + Assert.Equal(collectionTime.AddMinutes(-10), Assert.Single(fallback.Parameters).Value); + } + + [Fact] + public void PayloadColumns_20Columns_InTableOrder() + { + var names = LongQueryCompletionsCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray(); + + Assert.Equal(20, names.Length); + Assert.Equal("event_time", names[0]); + Assert.Equal("event_type", names[1]); + Assert.Equal("database_name", names[3]); + Assert.Equal("event_sequence", names[10]); + Assert.Equal("duration_microseconds", names[11]); + Assert.Equal("result", names[17]); + Assert.Equal("statement_text", names[18]); + Assert.Equal("object_name", names[19]); + } + + /* Reader ordinals follow the ShredSelect projection (0-19); the object[] value types must match the + reader's Get* calls (long for the bigint payloads, int for the int actions). */ + private static object[] CompletedRow() => new object[] + { + new DateTime(2026, 7, 18, 11, 59, 30, DateTimeKind.Utc), // 0 event_time + "rpc_completed", // 1 event_type + 5_000_000L, // 2 duration (µs) + 3_000_000L, // 3 cpu_time + 100L, // 4 physical_reads + 2_000L, // 5 logical_reads + 5L, // 6 writes + 42L, // 7 row_count + "OK", // 8 result + "EXEC dbo.myproc @a = 1;", // 9 statement_text + "dbo.myproc", // 10 object_name + "SqlClient", // 11 client_app_name + 1234, // 12 client_pid + 7, // 13 database_id + "SO", // 14 database_name + 999L, // 15 event_sequence + "CORP\\svc", // 16 nt_username + "0xABCD", // 17 query_hash + "sa", // 18 server_principal_name + 55, // 19 session_id + }; + + private static object[] AttentionRow() => new object[] + { + new DateTime(2026, 7, 18, 11, 59, 40, DateTimeKind.Utc), // 0 event_time + "attention", // 1 event_type + 40_000L, // 2 duration = attention-HANDLING time (must be nulled) + DBNull.Value, // 3 cpu_time + DBNull.Value, // 4 physical_reads + DBNull.Value, // 5 logical_reads + DBNull.Value, // 6 writes + DBNull.Value, // 7 row_count + DBNull.Value, // 8 result + DBNull.Value, // 9 statement_text + DBNull.Value, // 10 object_name + "SqlClient", // 11 client_app_name + 1234, // 12 client_pid + 7, // 13 database_id + "SO", // 14 database_name + 1000L, // 15 event_sequence + DBNull.Value, // 16 nt_username + "0xABCD", // 17 query_hash + "sa", // 18 server_principal_name + 55, // 19 session_id + }; + + [Fact] + public async Task ReadAsync_CompletedRow_MapsFields_AndWritePayloadPins20Ordinals() + { + var context = MakeContext(); + using var reader = new FakeCollectorDataReader(CompletedRow()); + var rows = await LongQueryCompletionsCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + + var row = Assert.Single(rows); + Assert.Equal(5_000_000L, row.DurationMicroseconds); + Assert.Equal("OK", row.Result); + Assert.Equal("dbo.myproc", row.ObjectName); + Assert.Equal("EXEC dbo.myproc @a = 1;", row.StatementText); + + var writer = new RecordingCollectorRowWriter(); + LongQueryCompletionsCollector.Instance.WritePayload(row, writer, context); + + Assert.Equal(20, writer.Values.Count); + Assert.Equal(new DateTime(2026, 7, 18, 11, 59, 30, DateTimeKind.Utc), writer.Values[0]); + Assert.Equal("rpc_completed", writer.Values[1]); + Assert.Equal(7, writer.Values[2]); /* database_id */ + Assert.Equal("SO", writer.Values[3]); /* database_name */ + Assert.Equal(55, writer.Values[4]); /* session_id */ + Assert.Equal(999L, writer.Values[10]); /* event_sequence */ + Assert.Equal(5_000_000L, writer.Values[11]); /* duration_microseconds */ + Assert.Equal(42L, writer.Values[16]); /* row_count */ + Assert.Equal("OK", writer.Values[17]); /* result */ + Assert.Equal("EXEC dbo.myproc @a = 1;", writer.Values[18]); /* statement_text */ + Assert.Equal("dbo.myproc", writer.Values[19]); /* object_name */ + } + + [Fact] + public async Task ReadAsync_AttentionRow_DropsDurationToNull() + { + /* The attention event's own duration (40 ms here) is cancellation-handling time, not the query's + runtime, so it must NOT populate the runtime duration column. */ + var context = MakeContext(); + using var reader = new FakeCollectorDataReader(AttentionRow()); + var rows = await LongQueryCompletionsCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + + var row = Assert.Single(rows); + Assert.Equal("attention", row.EventType); + Assert.Null(row.DurationMicroseconds); + Assert.Equal(55, row.SessionId); + Assert.Equal("0xABCD", row.QueryHash); + + var writer = new RecordingCollectorRowWriter(); + LongQueryCompletionsCollector.Instance.WritePayload(row, writer, context); + Assert.Equal(20, writer.Values.Count); + Assert.Null(writer.Values[11]); /* duration_microseconds nulled for attention */ + } + + [Fact] + public async Task ReadAsync_PerDatabasePath_CaptureDatabaseWinsOverParsedValue() + { + var context = MakeContext(isAzureSqlDb: true); + context.CurrentDatabaseName = "ringdb"; + + using var reader = new FakeCollectorDataReader(CompletedRow()); + var rows = await LongQueryCompletionsCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + + Assert.Equal("ringdb", Assert.Single(rows).DatabaseName); + } + + [Fact] + public void BuildCreateSessionSql_ServerScoped_PredicateOnCompletedOnly_AttentionUnfiltered() + { + var sql = LongQueryCompletionsCollector.BuildCreateSessionSql(databaseScoped: false, 2_000_000); + + Assert.Contains("ON SERVER", sql, StringComparison.Ordinal); + Assert.Contains("ADD EVENT sqlserver.rpc_completed", sql, StringComparison.Ordinal); + Assert.Contains("ADD EVENT sqlserver.sql_batch_completed", sql, StringComparison.Ordinal); + Assert.Contains("ADD EVENT sqlserver.attention", sql, StringComparison.Ordinal); + + /* The duration predicate fires on the two COMPLETED events (twice), never on attention. */ + Assert.Equal(2, System.Text.RegularExpressions.Regex.Matches(sql, "WHERE duration >= 2000000").Count); + + /* Customizable text/object columns must be turned on or they arrive NULL. */ + Assert.Contains("collect_statement = 1", sql, StringComparison.Ordinal); + Assert.Contains("collect_object_name = 1", sql, StringComparison.Ordinal); + Assert.Contains("collect_batch_text = 1", sql, StringComparison.Ordinal); + + /* All 9 QuickSessionStandard actions, including nt_username server-scoped + package0.event_sequence. */ + Assert.Contains("sqlserver.nt_username", sql, StringComparison.Ordinal); + Assert.Contains("package0.event_sequence", sql, StringComparison.Ordinal); + Assert.Contains("sqlserver.query_hash", sql, StringComparison.Ordinal); + + Assert.Contains("MEMORY_PARTITION_MODE = NONE", sql, StringComparison.Ordinal); + Assert.Contains("package0.ring_buffer", sql, StringComparison.Ordinal); + } + + [Fact] + public void BuildCreateSessionSql_Azure_DatabaseScoped_OmitsNtUsername_AndPartitionMode() + { + var sql = LongQueryCompletionsCollector.BuildCreateSessionSql(databaseScoped: true, 2_000_000); + + Assert.Contains("ON DATABASE", sql, StringComparison.Ordinal); + Assert.DoesNotContain("ON SERVER", sql, StringComparison.Ordinal); + /* nt_username is Windows-auth-specific and may be absent in an Azure database-scoped session. */ + Assert.DoesNotContain("sqlserver.nt_username", sql, StringComparison.Ordinal); + /* Azure database-scoped sessions do not accept MEMORY_PARTITION_MODE. */ + Assert.DoesNotContain("MEMORY_PARTITION_MODE", sql, StringComparison.Ordinal); + /* The other actions + the predicate + the SET clauses still apply. */ + Assert.Contains("sqlserver.server_principal_name", sql, StringComparison.Ordinal); + Assert.Equal(2, System.Text.RegularExpressions.Regex.Matches(sql, "WHERE duration >= 2000000").Count); + } + + [Fact] + public void BuildStartSessionSql_ScopeSelected() + { + Assert.Equal("ALTER EVENT SESSION [PerformanceMonitor_LongQueryCompletions] ON SERVER STATE = START;", + LongQueryCompletionsCollector.BuildStartSessionSql(databaseScoped: false)); + Assert.Equal("ALTER EVENT SESSION [PerformanceMonitor_LongQueryCompletions] ON DATABASE STATE = START;", + LongQueryCompletionsCollector.BuildStartSessionSql(databaseScoped: true)); + } + + [Fact] + public void BuildDropSessionSql_IsIdempotentlyGuarded_PerScope() + { + var serverDrop = LongQueryCompletionsCollector.BuildDropSessionSql(databaseScoped: false); + Assert.Contains("sys.server_event_sessions", serverDrop, StringComparison.Ordinal); + Assert.Contains("DROP EVENT SESSION [PerformanceMonitor_LongQueryCompletions] ON SERVER;", serverDrop, StringComparison.Ordinal); + Assert.Contains("IF EXISTS", serverDrop, StringComparison.Ordinal); + + var databaseDrop = LongQueryCompletionsCollector.BuildDropSessionSql(databaseScoped: true); + Assert.Contains("sys.database_event_sessions", databaseDrop, StringComparison.Ordinal); + Assert.Contains("DROP EVENT SESSION [PerformanceMonitor_LongQueryCompletions] ON DATABASE;", databaseDrop, StringComparison.Ordinal); + Assert.Contains("IF EXISTS", databaseDrop, StringComparison.Ordinal); + } +} diff --git a/Lite.Tests/McpAnalysisFindingsCommandTests.cs b/Lite.Tests/McpAnalysisFindingsCommandTests.cs new file mode 100644 index 000000000..9e69bcc8a --- /dev/null +++ b/Lite.Tests/McpAnalysisFindingsCommandTests.cs @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using PerformanceMonitor.Analysis; +using PerformanceMonitorLite.Analysis; +using PerformanceMonitorLite.Database; +using PerformanceMonitorLite.Mcp; +using PerformanceMonitorLite.Models; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// <summary> +/// Pins the read-only MCP copy-paste remediation command on Lite's get_analysis_findings: the tool +/// must return <c>remediation_command</c> — the SAME text the viewer cards render via the shared +/// <see cref="FactRemediation.RenderCopyPasteCommand"/> — for a finding whose persisted +/// <see cref="RemediationAction"/> was hydrated from <c>remediation_action_json</c> (the drill-down +/// is GONE on this read path, which is exactly why the older drill-down-sourced +/// <c>suggested_remediation_sql</c> stays omitted here). A DESTRUCTIVE shape (RCSI) proves the +/// two-sided risk-disclosure comment header rides along inside the command; a non-remediable finding +/// proves the field is null. The tool stays STRICTLY read-only — it renders advisory text and never +/// executes anything. The renderer's per-shape output is pinned separately by +/// <c>LiteRecommendationsReaderTests</c>; this pins the MCP ENVELOPE carrying it, mirroring the +/// Darling gated e2e assertion in <c>DarlingMcpToolsTests</c>. +/// </summary> +public sealed class McpAnalysisFindingsCommandTests : IDisposable +{ + private readonly string _tempDir; + private readonly DuckDbInitializer _duckDb; + private readonly ServerManager _serverManager; + private readonly int _serverId; + + public McpAnalysisFindingsCommandTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "McpRemediationTests_" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(_tempDir); + + var configDir = Path.Combine(_tempDir, "config"); + Directory.CreateDirectory(configDir); + + _duckDb = new DuckDbInitializer(Path.Combine(_tempDir, "test.duckdb")); + + /* A real ServerManager with one enabled, Windows-auth server — AddServer never touches the + credential store, so no DPAPI / OS keychain side effects (the McpStatusEnvelopeTests pattern). */ + _serverManager = new ServerManager(configDir); + var server = new ServerConnection { ServerName = "TestServer", DisplayName = "TestServer" }; + _serverManager.AddServer(server); + + /* The server_id the tool resolves to — the SAME derivation ServerResolver uses. */ + _serverId = RemoteCollectorService.GetDeterministicHashCode( + RemoteCollectorService.GetServerNameForStorage(server)); + } + + public void Dispose() + { + try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); } + catch { /* best-effort cleanup */ } + } + + [Fact] + public async Task GetAnalysisFindings_EmitsRemediationCommand_FromPersistedAction_WithRiskHeader_AndNullWhenNone() + { + await _duckDb.InitializeAsync(); + await _duckDb.InitializeAnalysisSchemaAsync(); + + var store = new FindingStore(_duckDb); + var analysisTime = DateTime.UtcNow; + var context = new AnalysisContext + { + ServerId = _serverId, + ServerName = "TestServer", + TimeRangeStart = analysisTime.AddHours(-4), + TimeRangeEnd = analysisTime + }; + + /* A DESTRUCTIVE RCSI finding: the persisted DB_CONFIG action carries a per-database + RcsiTarget, so the shared renderer prepends the two-sided disclosure header above the + enabling ALTER — proving the risk disclosure rides along through the MCP envelope. */ + var destructive = MakeFinding( + findingId: 900001, analysisTime, severity: 2.5, + rootFactKey: "DB_CONFIG", storyPathHash: "reco_rcsi_hash", + remediation: new RemediationAction( + "DB_CONFIG", "set", Array.Empty<ForcePlanTarget>(), + RcsiTargets: new[] { new RcsiTarget("StackOverflow", new RcsiInactionFigures(50, 3, 70)) })); + + /* A non-remediable finding: no persisted action, so remediation_command must be null. */ + var nonRemediable = MakeFinding( + findingId: 900002, analysisTime, severity: 1.0, + rootFactKey: "SOS_SCHEDULER_YIELD", storyPathHash: "reco_none_hash", + remediation: null); + + await store.InsertFindingsAsync( + new List<AnalysisFinding> { destructive, nonRemediable }, context); + + var json = await McpAnalysisTools.GetAnalysisFindings( + new AnalysisService(_duckDb), _serverManager, "TestServer", 24); + + using var doc = JsonDocument.Parse(json); + var findings = doc.RootElement.GetProperty("findings").EnumerateArray().ToList(); + Assert.Equal(2, findings.Count); + + /* The field is present on EVERY finding (JsonOptions does not ignore nulls). */ + Assert.All(findings, f => Assert.True(f.TryGetProperty("remediation_command", out _), + "every finding must expose remediation_command")); + + /* Destructive finding: the full command carries the two-sided risk-disclosure header, + then the enabling ALTER — byte-identical to the shared renderer the viewer cards use. */ + var rcsi = findings.Single(f => f.GetProperty("story_path_hash").GetString() == "reco_rcsi_hash"); + var command = rcsi.GetProperty("remediation_command").GetString(); + Assert.False(string.IsNullOrEmpty(command)); + Assert.StartsWith("/*", command, StringComparison.Ordinal); + Assert.Contains("Risks of MAKING this change:", command!, StringComparison.Ordinal); + Assert.Contains("Risks of NOT making this change:", command!, StringComparison.Ordinal); + Assert.Contains("ALTER DATABASE [StackOverflow] SET READ_COMMITTED_SNAPSHOT ON;", command!, StringComparison.Ordinal); + Assert.True( + command!.IndexOf("*/", StringComparison.Ordinal) < + command.IndexOf("ALTER DATABASE", StringComparison.Ordinal), + "the disclosure comment header must precede the ALTER statement"); + Assert.Equal(FactRemediation.RenderCopyPasteCommand(destructive.Remediation), command); + + /* Non-remediable finding: the field is present but null (no command). */ + var none = findings.Single(f => f.GetProperty("story_path_hash").GetString() == "reco_none_hash"); + Assert.Equal(JsonValueKind.Null, none.GetProperty("remediation_command").ValueKind); + } + + private AnalysisFinding MakeFinding( + long findingId, DateTime analysisTime, double severity, + string rootFactKey, string storyPathHash, RemediationAction? remediation) => + new AnalysisFinding + { + FindingId = findingId, + AnalysisTime = analysisTime, + ServerId = _serverId, + ServerName = "TestServer", + TimeRangeStart = analysisTime.AddHours(-4), + TimeRangeEnd = analysisTime, + Severity = severity, + Confidence = 0.9, + Category = "config", + StoryPath = rootFactKey, + StoryPathHash = storyPathHash, + StoryText = "planted finding", + RootFactKey = rootFactKey, + RootFactValue = 1.0, + FactCount = 1, + IncidentId = "mcp-remediation-test", + Remediation = remediation + }; +} diff --git a/Lite.Tests/MemoryCollectorsDefinitionTests.cs b/Lite.Tests/MemoryCollectorsDefinitionTests.cs new file mode 100644 index 000000000..4c987e276 --- /dev/null +++ b/Lite.Tests/MemoryCollectorsDefinitionTests.cs @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Pins the parity contracts of the three memory collector definitions extracted from +/// RemoteCollectorService.Memory.cs (headless plan v5.1). +/// </summary> +public sealed class MemoryStatsCollectorDefinitionTests +{ + private static readonly RecordingCollectorDeltaCalculator s_deltas = new(); + + [Fact] + public void BuildQuery_SelectsVariantByTarget() + { + var azure = MemoryStatsCollector.Instance.BuildQuery(CollectorTestContext.Make(s_deltas, isAzureSqlDb: true)).Text; + var onPrem = MemoryStatsCollector.Instance.BuildQuery(CollectorTestContext.Make(s_deltas)).Text; + + /* Azure: committed-target approximation + NULL workers (#857 elastic-pool grant wall). */ + Assert.Contains("committed_target_kb", azure, StringComparison.Ordinal); + Assert.Contains("current_workers_count = CONVERT(int, NULL)", azure, StringComparison.Ordinal); + Assert.DoesNotContain("sys.dm_os_schedulers", azure, StringComparison.Ordinal); + + /* On-prem/MI: real memory DMV + live worker count. */ + Assert.Contains("sys.dm_os_sys_memory", onPrem, StringComparison.Ordinal); + Assert.Contains("sys.dm_os_schedulers", onPrem, StringComparison.Ordinal); + } + + [Fact] + public void NamesAndColumns_MatchDispatchAndSchema() + { + Assert.Equal("memory_stats", MemoryStatsCollector.Instance.Name); + Assert.Equal("memory_stats", MemoryStatsCollector.Instance.TargetTable); + Assert.Null(MemoryStatsCollector.Instance.WatermarkColumn); + Assert.True(MemoryStatsCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureSqlDb = true })); + Assert.Equal( + new[] + { + "total_physical_memory_mb", "available_physical_memory_mb", "total_page_file_mb", + "available_page_file_mb", "system_memory_state", "sql_memory_model", + "target_server_memory_mb", "total_server_memory_mb", "buffer_pool_mb", + "plan_cache_mb", "max_workers_count", "current_workers_count", + }, + MemoryStatsCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray()); + } + + [Fact] + public async Task ReadAsync_MapsSingleRow_WithNullDefaults() + { + using var reader = new FakeCollectorDataReader( + new object[] { 64000m, 12000m, 8000m, 6000m, "Available", "Conventional", 48000m, 40000m, 30000m, 2000m, 704, DBNull.Value }); + + var rows = await MemoryStatsCollector.Instance.ReadAsync(reader, CollectorTestContext.Make(s_deltas), CancellationToken.None); + + var row = Assert.Single(rows); + Assert.Equal(0, row.CurrentWorkersCount); + Assert.Equal("Available", row.SystemMemoryState); + + var writer = new RecordingCollectorRowWriter(); + MemoryStatsCollector.Instance.WritePayload(row, writer, CollectorTestContext.Make(s_deltas)); + Assert.Equal(12, writer.Values.Count); + Assert.Equal(64000m, writer.Values[0]); + Assert.Equal(0, writer.Values[11]); + } + + [Fact] + public async Task ReadAsync_NoRow_YieldsNoRows() + { + using var reader = new FakeCollectorDataReader(); + + var rows = await MemoryStatsCollector.Instance.ReadAsync(reader, CollectorTestContext.Make(s_deltas), CancellationToken.None); + + Assert.Empty(rows); + } +} + +public sealed class MemoryClerksCollectorDefinitionTests +{ + private static readonly RecordingCollectorDeltaCalculator s_deltas = new(); + + [Fact] + public void NamesColumnsAndQuery_MatchSchema() + { + Assert.Equal("memory_clerks", MemoryClerksCollector.Instance.Name); + Assert.Equal("memory_clerks", MemoryClerksCollector.Instance.TargetTable); + var query = MemoryClerksCollector.Instance.BuildQuery(CollectorTestContext.Make(s_deltas)).Text; + Assert.Contains("sys.dm_os_memory_clerks", query, StringComparison.Ordinal); + Assert.Contains("TOP (25)", query, StringComparison.Ordinal); + Assert.Equal( + new[] { "clerk_type", "memory_mb" }, + MemoryClerksCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray()); + } + + [Fact] + public async Task ReadAndWrite_RoundTripInOrder() + { + using var reader = new FakeCollectorDataReader( + new object[] { "MEMORYCLERK_SQLBUFFERPOOL", 30000m }, + new object[] { "CACHESTORE_SQLCP", 1500m }); + + var rows = await MemoryClerksCollector.Instance.ReadAsync(reader, CollectorTestContext.Make(s_deltas), CancellationToken.None); + + Assert.Equal(2, rows.Count); + var writer = new RecordingCollectorRowWriter(); + MemoryClerksCollector.Instance.WritePayload(rows[0], writer, CollectorTestContext.Make(s_deltas)); + Assert.Equal(new object?[] { "MEMORYCLERK_SQLBUFFERPOOL", 30000m }, writer.Values); + } +} + +public sealed class MemoryPressureEventsCollectorDefinitionTests +{ + private static readonly RecordingCollectorDeltaCalculator s_deltas = new(); + + [Fact] + public void AppliesTo_SkipsAzureSqlDb() + { + Assert.False(MemoryPressureEventsCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureSqlDb = true })); + Assert.True(MemoryPressureEventsCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureSqlDb = false })); + Assert.Equal("sample_time", MemoryPressureEventsCollector.Instance.WatermarkColumn); + Assert.Equal("memory_pressure_events", MemoryPressureEventsCollector.Instance.Name); + } + + [Fact] + public void PayloadColumns_MatchSchemaOrder() + { + Assert.Equal( + new[] { "sample_time", "memory_notification", "memory_indicators_process", "memory_indicators_system" }, + MemoryPressureEventsCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray()); + } + + [Fact] + public async Task ReadAsync_DedupsAtWatermark_IncludingNullSampleTimes() + { + var watermark = new DateTime(2026, 7, 1, 12, 0, 0, DateTimeKind.Utc); + using var reader = new FakeCollectorDataReader( + new object[] { watermark.AddMinutes(-5), "RESOURCE_MEMPHYSICAL_LOW", 2, 0 }, + new object[] { DBNull.Value, "RESOURCE_MEMPHYSICAL_LOW", 3, 1 }, + new object[] { watermark.AddMinutes(5), "RESOURCE_MEMPHYSICAL_HIGH", 0, 0 }); + + var rows = await MemoryPressureEventsCollector.Instance.ReadAsync( + reader, CollectorTestContext.Make(s_deltas, watermark: watermark), CancellationToken.None); + + /* Older-than-watermark and NULL-sample_time (DateTime.MinValue) rows are both skipped. */ + var row = Assert.Single(rows); + Assert.Equal("RESOURCE_MEMPHYSICAL_HIGH", row.MemoryNotification); + + var writer = new RecordingCollectorRowWriter(); + MemoryPressureEventsCollector.Instance.WritePayload(row, writer, CollectorTestContext.Make(s_deltas)); + Assert.Equal(new object?[] { watermark.AddMinutes(5), "RESOURCE_MEMPHYSICAL_HIGH", 0, 0 }, writer.Values); + } + + [Fact] + public async Task ReadAsync_NoWatermark_KeepsNullSampleTimeRows_AsMinValue() + { + using var reader = new FakeCollectorDataReader( + new object[] { DBNull.Value, "RESOURCE_MEMPHYSICAL_LOW", 2, 0 }); + + var rows = await MemoryPressureEventsCollector.Instance.ReadAsync( + reader, CollectorTestContext.Make(s_deltas), CancellationToken.None); + + var row = Assert.Single(rows); + Assert.Equal(DateTime.MinValue, row.SampleTime); + } +} diff --git a/Lite.Tests/MemoryGrantsCollectorDefinitionTests.cs b/Lite.Tests/MemoryGrantsCollectorDefinitionTests.cs new file mode 100644 index 000000000..904612a69 --- /dev/null +++ b/Lite.Tests/MemoryGrantsCollectorDefinitionTests.cs @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Pins the parity contract of the extracted memory_grant_stats definition: column mapping, +/// the composite "{pool}_{semaphore}" delta key, the two delta groups with the 300 s gap +/// policy, and the payload order matching the memory_grant_stats schema. +/// </summary> +public sealed class MemoryGrantsCollectorDefinitionTests +{ + [Fact] + public void PayloadColumns_MatchSchemaOrder() + { + var names = MemoryGrantsCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray(); + + Assert.Equal( + new[] + { + "resource_semaphore_id", + "pool_id", + "target_memory_mb", + "max_target_memory_mb", + "total_memory_mb", + "available_memory_mb", + "granted_memory_mb", + "used_memory_mb", + "grantee_count", + "waiter_count", + "timeout_error_count", + "forced_grant_count", + "timeout_error_count_delta", + "forced_grant_count_delta", + }, + names); + } + + [Fact] + public void NameAndTable_MatchTheDispatchAndSchema() + { + Assert.Equal("memory_grant_stats", MemoryGrantsCollector.Instance.Name); + Assert.Equal("memory_grant_stats", MemoryGrantsCollector.Instance.TargetTable); + Assert.Contains("sys.dm_exec_query_resource_semaphores", MemoryGrantsCollector.Instance.BuildQuery(CollectorTestContext.Make(new RecordingCollectorDeltaCalculator())).Text, System.StringComparison.Ordinal); + } + + [Fact] + public async Task ReadAsync_MapsAllColumns() + { + using var reader = new FakeCollectorDataReader( + new object[] { (short)0, 2, 100.5m, 200.5m, 90.25m, 80.75m, 10.5m, 8.25m, 3, 1, 5L, 2L }); + + var context = CollectorTestContext.Make(new RecordingCollectorDeltaCalculator()); + + var rows = await MemoryGrantsCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + + var row = Assert.Single(rows); + Assert.Equal(new MemoryGrantsCollector.Row(0, 2, 100.5m, 200.5m, 90.25m, 80.75m, 10.5m, 8.25m, 3, 1, 5L, 2L), row); + } + + [Fact] + public void WritePayload_EmitsSchemaOrder_AndPinsCompositeDeltaKey() + { + var deltas = new RecordingCollectorDeltaCalculator(); + var writer = new RecordingCollectorRowWriter(); + var context = CollectorTestContext.Make(deltas); + var row = new MemoryGrantsCollector.Row(1, 2, 100.5m, 200.5m, 90.25m, 80.75m, 10.5m, 8.25m, 3, 4, 5L, 6L); + + MemoryGrantsCollector.Instance.WritePayload(row, writer, context); + + Assert.Equal( + new object?[] { (short)1, 2, 100.5m, 200.5m, 90.25m, 80.75m, 10.5m, 8.25m, 3, 4, 5L, 6L, 50L, 60L }, + writer.Values); + + /* Delta contract: composite key "{pool}_{semaphore}", both groups, 300 s gap policy. */ + Assert.Equal(2, deltas.Calls.Count); + Assert.Equal(("memory_grants_timeouts", "2_1", 5L, context.CollectionTime, 300), deltas.Calls[0]); + Assert.Equal(("memory_grants_forced", "2_1", 6L, context.CollectionTime, 300), deltas.Calls[1]); + } +} diff --git a/Lite.Tests/ParitySource.cs b/Lite.Tests/ParitySource.cs new file mode 100644 index 000000000..33d20a644 --- /dev/null +++ b/Lite.Tests/ParitySource.cs @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.IO; + +namespace Lite.Tests; + +/// <summary> +/// Repo-source reader shared by the round-2 cross-app parity PIN tests. These pins compare Lite's and +/// Darling's source-of-truth tables/inventories, but no single CI-run test project references BOTH apps' +/// assemblies (<c>Lite.Tests</c> -> Lite only; <c>Darling.Tests</c> -> Darling only). Rather than pull a heavy +/// WPF/service assembly across the SKU boundary — and churn a locked-mode <c>packages.lock.json</c> — the +/// cross-app half is read from the checked-out source tree, the same walk-up-from-<c>bin</c> technique +/// <see cref="SharedCollectorDefaultsPinTests"/> already uses in CI to read +/// <c>Lite/config/ignored_wait_types.json</c>. Each parser is self-validated against the real in-memory object +/// where one is reachable, so a parser bug fails loudly instead of green-washing drift. +/// </summary> +internal static class ParitySource +{ + /// <summary> + /// The repo root: the nearest ancestor of the test <c>bin</c> that holds the <c>Lite</c>, <c>Darling</c>, + /// and <c>PerformanceMonitor.Collectors</c> folders (a signature no other directory in the tree carries). + /// </summary> + internal static string RepoRoot() + { + for (var dir = new DirectoryInfo(System.AppContext.BaseDirectory); dir is not null; dir = dir.Parent) + { + if (Directory.Exists(Path.Combine(dir.FullName, "Lite")) && + Directory.Exists(Path.Combine(dir.FullName, "Darling")) && + Directory.Exists(Path.Combine(dir.FullName, "PerformanceMonitor.Collectors"))) + { + return dir.FullName; + } + } + + throw new DirectoryNotFoundException( + $"Could not locate the PerformanceMonitor repo root walking up from {System.AppContext.BaseDirectory}"); + } + + /// <summary>Reads a source file by its path relative to the repo root (forward slashes are normalized).</summary> + internal static string ReadFile(string relativePath) + { + var full = Path.Combine(RepoRoot(), relativePath.Replace('/', Path.DirectorySeparatorChar)); + if (!File.Exists(full)) + { + throw new FileNotFoundException($"Repo file not found: {relativePath} (resolved to {full})"); + } + + return File.ReadAllText(full); + } + + /// <summary>Returns the full paths of every <c>*.cs</c> file directly under a repo-relative directory.</summary> + internal static string[] EnumerateCsFiles(string relativeDir) + { + var full = Path.Combine(RepoRoot(), relativeDir.Replace('/', Path.DirectorySeparatorChar)); + if (!Directory.Exists(full)) + { + throw new DirectoryNotFoundException($"Repo directory not found: {relativeDir} (resolved to {full})"); + } + + return Directory.GetFiles(full, "*.cs", SearchOption.TopDirectoryOnly); + } +} diff --git a/Lite.Tests/PerfmonAndDmvBlockingCollectorDefinitionTests.cs b/Lite.Tests/PerfmonAndDmvBlockingCollectorDefinitionTests.cs new file mode 100644 index 000000000..7f5321114 --- /dev/null +++ b/Lite.Tests/PerfmonAndDmvBlockingCollectorDefinitionTests.cs @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary>Pins the parity contracts of the batch-12 definitions.</summary> +public sealed class PerfmonStatsCollectorDefinitionTests +{ + private static readonly RecordingCollectorDeltaCalculator s_deltas = new(); + + [Fact] + public void DefaultCounterList_IsTheCuratedParityContract() + { + Assert.Equal(59, PerfmonStatsCollector.DefaultCounters.Count); + Assert.Contains("Batch Requests/sec", PerfmonStatsCollector.DefaultCounters); + Assert.Contains("Number of Deadlocks/sec", PerfmonStatsCollector.DefaultCounters); + Assert.Contains("Wait for the worker", PerfmonStatsCollector.DefaultCounters); + } + + [Fact] + public void BuildQuery_UsesDefaults_EscapedAsLiterals() + { + var text = PerfmonStatsCollector.Instance.BuildQuery(CollectorTestContext.Make(s_deltas)).Text; + + Assert.Contains("N'Batch Requests/sec'", text, StringComparison.Ordinal); + Assert.Contains("sys.dm_os_performance_counters", text, StringComparison.Ordinal); + } + + [Fact] + public void BuildQuery_OverrideWins_AndEscapesQuotes() + { + var context = new CollectorContext + { + ServerId = 42, + ServerName = "test-server", + CollectionTime = DateTime.UtcNow, + Deltas = s_deltas, + PerfmonCounterOverride = new[] { "O'Brien/sec" }, + }; + + var text = PerfmonStatsCollector.Instance.BuildQuery(context).Text; + + Assert.Contains("N'O''Brien/sec'", text, StringComparison.Ordinal); + Assert.DoesNotContain("Batch Requests/sec", text, StringComparison.Ordinal); + } + + [Fact] + public async Task WritePayload_PinsDeltaContract_AndConstantInterval() + { + var deltas = new RecordingCollectorDeltaCalculator(); + var context = CollectorTestContext.Make(deltas); + using var reader = new FakeCollectorDataReader( + new object[] { "SQLServer:SQL Statistics", "Batch Requests/sec", "", 987654L }); + + var rows = await PerfmonStatsCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + var writer = new RecordingCollectorRowWriter(); + PerfmonStatsCollector.Instance.WritePayload(Assert.Single(rows), writer, context); + + Assert.Equal(new object?[] { "SQLServer:SQL Statistics", "Batch Requests/sec", "", 987654L, 9876540L, 60 }, writer.Values); + var call = Assert.Single(deltas.Calls); + Assert.Equal(("perfmon", "SQLServer:SQL Statistics|Batch Requests/sec|", 987654L, context.CollectionTime, 300), call); + } +} + +public sealed class DmvBlockingSnapshotCollectorDefinitionTests +{ + private static readonly RecordingCollectorDeltaCalculator s_deltas = new(); + + [Fact] + public void SyntheticMonitorLoop_IsNegativeSecondsSince2020() + { + var t = new DateTime(2020, 1, 1, 0, 0, 30, DateTimeKind.Utc); + Assert.Equal(-30, DmvBlockingSnapshotCollector.SyntheticMonitorLoop(t)); + Assert.True(DmvBlockingSnapshotCollector.SyntheticMonitorLoop(new DateTime(2026, 7, 2, 0, 0, 0, DateTimeKind.Utc)) < 0); + } + + [Fact] + public void NameIsSingular_TableIsPlural_AndQueryPinsTheWaitFloors() + { + Assert.Equal("dmv_blocking_snapshot", DmvBlockingSnapshotCollector.Instance.Name); + Assert.Equal("dmv_blocking_snapshots", DmvBlockingSnapshotCollector.Instance.TargetTable); + + var query = DmvBlockingSnapshotCollector.Instance.BuildQuery(CollectorTestContext.Make(s_deltas)).Text; + Assert.Contains("WHEN wt.wait_type LIKE N'LCK[_]%' THEN 2000", query, StringComparison.Ordinal); + Assert.Contains("WHEN wt.wait_type LIKE N'PAGELATCH[_]%' THEN 500", query, StringComparison.Ordinal); + Assert.Contains("WHEN wt.wait_type LIKE N'RESOURCE_SEMAPHORE%' THEN 5000", query, StringComparison.Ordinal); + Assert.Contains("sys.dm_os_waiting_tasks", query, StringComparison.Ordinal); + } + + [Fact] + public void PayloadColumns_MatchSchemaOrder_21Columns() + { + var names = DmvBlockingSnapshotCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray(); + Assert.Equal(21, names.Length); + Assert.Equal("monitor_loop", names[0]); + Assert.Equal("event_time", names[1]); + Assert.Equal("blocking_client_app", names[20]); + } + + [Fact] + public async Task WritePayload_EmitsSyntheticLoop_AndEventTime() + { + var context = CollectorTestContext.Make(s_deltas); + using var reader = new FakeCollectorDataReader( + new object[] + { + "StackOverflow", 71, 0, DBNull.Value, 55, 0, DBNull.Value, 4500L, + "X", "sleeping", "[dbo].[Orders]", "UPDATE ...", "BEGIN TRAN ...", + "app_user", "web01", "MyApp", "admin", "jump01", "SSMS", + }); + + var rows = await DmvBlockingSnapshotCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + var writer = new RecordingCollectorRowWriter(); + DmvBlockingSnapshotCollector.Instance.WritePayload(Assert.Single(rows), writer, context); + + Assert.Equal(21, writer.Values.Count); + Assert.Equal(DmvBlockingSnapshotCollector.SyntheticMonitorLoop(context.CollectionTime), writer.Values[0]); + Assert.Equal(context.CollectionTime, writer.Values[1]); + Assert.Equal("[dbo].[Orders]", writer.Values[12]); + Assert.Equal("SSMS", writer.Values[20]); + } +} diff --git a/Lite.Tests/PerformanceCalendarDataTests.cs b/Lite.Tests/PerformanceCalendarDataTests.cs new file mode 100644 index 000000000..bcc05b725 --- /dev/null +++ b/Lite.Tests/PerformanceCalendarDataTests.cs @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using PerformanceMonitor.Common; +using PerformanceMonitorLite.Database; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// <summary> +/// End-to-end validation of the Performance Calendar's per-day-range aggregate against a real DuckDB: +/// that the grouped range SQL (day spine + per-source CTEs, arg_max top wait, FILTER counts, the +/// blocked-process-report -> DMV-snapshot blocking fallback, the actionable-alert filter) buckets each +/// seeded day correctly and that each day's composite band matches the shared calculator. +/// </summary> +public class PerformanceCalendarDataTests : IDisposable +{ + private readonly string _tempDir; + private readonly DuckDbInitializer _duckDb; + private readonly LocalDataService _dataService; + private const int ServerId = -779; + private const string ServerName = "CalTestServer"; + private long _nextId = -1; + + private static readonly DateTime MonthStart = new(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime MonthEnd = new(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc); + + public PerformanceCalendarDataTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "CalTests_" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(_tempDir); + _duckDb = new DuckDbInitializer(Path.Combine(_tempDir, "test.duckdb")); + _dataService = new LocalDataService(_duckDb); + } + + public void Dispose() + { + try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); } + catch { } + GC.SuppressFinalize(this); + } + + private async Task ExecAsync(string sql, params object?[] values) + { + using var readLock = _duckDb.AcquireReadLock(); + using var conn = _duckDb.CreateConnection(); + await conn.OpenAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + foreach (var v in values) + cmd.Parameters.Add(new DuckDBParameter { Value = v ?? DBNull.Value }); + await cmd.ExecuteNonQueryAsync(); + } + + private Task SeedCollectionRunAsync(DateTime day, string status = "SUCCESS") => + ExecAsync( + "INSERT INTO collection_log (log_id, server_id, server_name, collector_name, collection_time, status) VALUES ($1,$2,$3,'wait_stats',$4,$5)", + _nextId--, ServerId, ServerName, day.AddHours(1), status); + + private Task SeedWaitAsync(DateTime day, string waitType, long deltaMs) => + ExecAsync( + "INSERT INTO wait_stats (collection_id, collection_time, server_id, server_name, wait_type, delta_wait_time_ms) VALUES ($1,$2,$3,$4,$5,$6)", + _nextId--, day.AddHours(1), ServerId, ServerName, waitType, deltaMs); + + private Task SeedCpuAsync(DateTime day, int sqlCpu, int otherCpu) => + ExecAsync( + "INSERT INTO cpu_utilization_stats (collection_id, collection_time, server_id, server_name, sample_time, sqlserver_cpu_utilization, other_process_cpu_utilization) VALUES ($1,$2,$3,$4,$2,$5,$6)", + _nextId--, day.AddHours(1), ServerId, ServerName, sqlCpu, otherCpu); + + private Task SeedDeadlockAsync(DateTime day) => + ExecAsync( + "INSERT INTO deadlocks (deadlock_id, collection_time, server_id, server_name) VALUES ($1,$2,$3,$4)", + _nextId--, day.AddHours(1), ServerId, ServerName); + + private Task SeedDmvBlockingAsync(DateTime day) => + ExecAsync( + "INSERT INTO dmv_blocking_snapshots (collection_id, collection_time, server_id, server_name, monitor_loop) VALUES ($1,$2,$3,$4,1)", + _nextId--, day.AddHours(1), ServerId, ServerName); + + private Task SeedBprAsync(DateTime day, long waitTimeMs) => + ExecAsync( + "INSERT INTO blocked_process_reports (blocked_report_id, collection_time, server_id, server_name, wait_time_ms) VALUES ($1,$2,$3,$4,$5)", + _nextId--, day.AddHours(1), ServerId, ServerName, waitTimeMs); + + private Task SeedMemoryAsync(DateTime day, int process, int system) => + ExecAsync( + "INSERT INTO memory_pressure_events (collection_id, collection_time, server_id, server_name, sample_time, memory_notification, memory_indicators_process, memory_indicators_system) VALUES ($1,$2,$3,$4,$2,'RESOURCE_MEMPHYSICAL_LOW',$5,$6)", + _nextId--, day.AddHours(1), ServerId, ServerName, process, system); + + private Task SeedAlertAsync(DateTime day, string metric, bool dismissed) => + ExecAsync( + "INSERT INTO config_alert_log (alert_time, server_id, server_name, metric_name, current_value, threshold_value, dismissed) VALUES ($1,$2,$3,$4,1.0,1.0,$5)", + day.AddHours(1), ServerId, ServerName, metric, dismissed); + + private static DateTime Day(int d) => new(2026, 7, d, 0, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task GetDailySummaryRange_BucketsEachDay_AndBandsViaSharedCalculator() + { + await _duckDb.InitializeAsync(); + + // 07-02 Critical: a deadlock, plus waits (CXPACKET should win the top-wait ranking). + await SeedCollectionRunAsync(Day(2)); + await SeedWaitAsync(Day(2), "CXPACKET", 100_000); + await SeedWaitAsync(Day(2), "PAGEIOLATCH_SH", 50_000); + await SeedDeadlockAsync(Day(2)); + + // 07-05 Critical: 6 sustained high-CPU samples (>= threshold). + await SeedCollectionRunAsync(Day(5)); + for (var i = 0; i < 6; i++) + await SeedCpuAsync(Day(5), 70, 20); // 90 total >= 80 + + // 07-08 Warning: no BPR but 3 DMV blocking snapshots (fallback) + 2 moderate high-CPU samples. + await SeedCollectionRunAsync(Day(8)); + await SeedDmvBlockingAsync(Day(8)); + await SeedDmvBlockingAsync(Day(8)); + await SeedDmvBlockingAsync(Day(8)); + await SeedCpuAsync(Day(8), 85, 0); + await SeedCpuAsync(Day(8), 82, 0); + + // 07-10 Warning: one actionable alert; a dismissed alert and a resolution notice must NOT count. + await SeedCollectionRunAsync(Day(10)); + await SeedAlertAsync(Day(10), "High CPU", dismissed: false); + await SeedAlertAsync(Day(10), "Blocking Detected", dismissed: true); + await SeedAlertAsync(Day(10), "CPU Resolved", dismissed: false); + + // 07-12 Healthy: collected, quiet. + await SeedCollectionRunAsync(Day(12)); + + // 07-15 Critical: a collection ERROR (monitoring blind spot). + await SeedCollectionRunAsync(Day(15), status: "SUCCESS"); + await SeedCollectionRunAsync(Day(15), status: "ERROR"); + + // 07-18 Critical: severe memory pressure (process indicator >= 3). + await SeedCollectionRunAsync(Day(18)); + await SeedMemoryAsync(Day(18), process: 3, system: 1); + + // 07-22 Warning via an alert alone (no collection-log run that day) -- the day must still appear + // (alerts are part of the day spine), banded Warning, not dropped as No-Data. + await SeedAlertAsync(Day(22), "Blocking Detected", dismissed: false); + + var rows = await _dataService.GetDailySummaryRangeAsync(ServerId, MonthStart, MonthEnd); + var byDate = rows.ToDictionary(r => r.SummaryDate.Date); + + // Exactly the eight seeded days appear; unseeded days are absent (calendar renders them No-Data). + Assert.Equal(8, rows.Count); + Assert.False(byDate.ContainsKey(Day(20))); + + Assert.Equal(DailyHealthBand.Warning, byDate[Day(22)].HealthBand); + Assert.Equal(1, byDate[Day(22)].AlertCount); + + Assert.Equal(DailyHealthBand.Critical, byDate[Day(2)].HealthBand); + Assert.Equal(1, byDate[Day(2)].DeadlockCount); + Assert.Equal("CXPACKET", byDate[Day(2)].TopWaitType); + Assert.Equal(150m, byDate[Day(2)].TotalWaitTimeSec); + + Assert.Equal(DailyHealthBand.Critical, byDate[Day(5)].HealthBand); + Assert.Equal(6, byDate[Day(5)].HighCpuEvents); + + Assert.Equal(DailyHealthBand.Warning, byDate[Day(8)].HealthBand); + Assert.Equal(3, byDate[Day(8)].BlockingEvents); // DMV fallback (0 BPR) + Assert.Equal(2, byDate[Day(8)].HighCpuEvents); + + Assert.Equal(DailyHealthBand.Warning, byDate[Day(10)].HealthBand); + Assert.Equal(1, byDate[Day(10)].AlertCount); // dismissed + resolution excluded + + Assert.Equal(DailyHealthBand.Healthy, byDate[Day(12)].HealthBand); + Assert.True(byDate[Day(12)].HasData); + + Assert.Equal(DailyHealthBand.Critical, byDate[Day(15)].HealthBand); + Assert.Equal(1, byDate[Day(15)].CollectionErrors); + + Assert.Equal(DailyHealthBand.Critical, byDate[Day(18)].HealthBand); + Assert.Equal(1, byDate[Day(18)].MemoryCriticalEvents); + Assert.Equal(1, byDate[Day(18)].MemoryPressureEvents); + } + + [Fact] + public async Task GetDailySummaryRange_CarriesPeakBlockDuration_FromBlockedProcessReports() + { + await _duckDb.InitializeAsync(); + + // Two blocked-process reports on 07-14 with different wait times; the day's peak is the max, and the + // BPR-sourced count wins over any DMV fallback (there is none here). Feeds the day-detail blocking + // reason ("N blocking events (peak block X)"). + await SeedCollectionRunAsync(Day(14)); + await SeedBprAsync(Day(14), 4_200); + await SeedBprAsync(Day(14), 12_500); + + var rows = await _dataService.GetDailySummaryRangeAsync(ServerId, MonthStart, MonthEnd); + var row = rows.Single(r => r.SummaryDate.Date == Day(14)); + + Assert.Equal(2, row.BlockingEvents); // BPR-sourced count (preferred over DMV) + Assert.Equal(12_500, row.MaxBlockDurationMs); // peak = MAX(wait_time_ms) + } + + [Fact] + public async Task GetDailySummary_SingleDay_DelegatesToRange_AndReturnsNoDataRowWhenEmpty() + { + await _duckDb.InitializeAsync(); + + await SeedCollectionRunAsync(Day(2)); + await SeedDeadlockAsync(Day(2)); + + var seeded = await _dataService.GetDailySummaryAsync(ServerId, Day(2)); + Assert.NotNull(seeded); + Assert.True(seeded!.HasData); + Assert.Equal(DailyHealthBand.Critical, seeded.HealthBand); + Assert.Equal("Critical", seeded.OverallHealth); + + var empty = await _dataService.GetDailySummaryAsync(ServerId, Day(25)); + Assert.NotNull(empty); + Assert.False(empty!.HasData); + Assert.Equal(DailyHealthBand.NoData, empty.HealthBand); + Assert.Equal("No Data", empty.OverallHealth); + } +} diff --git a/Lite.Tests/PlanCacheStatsCollectorDefinitionTests.cs b/Lite.Tests/PlanCacheStatsCollectorDefinitionTests.cs new file mode 100644 index 000000000..02e8ca3a0 --- /dev/null +++ b/Lite.Tests/PlanCacheStatsCollectorDefinitionTests.cs @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Pins the cross-SKU parity contract of the shared plan_cache_stats collector definition (the +/// Dashboard-parity port of install/35_collect_plan_cache_stats.sql): verbatim query text, payload +/// column order, ReadAsync mapping (including the nullable oldest_plan_create_time), and the +/// point-in-time no-delta WritePayload. Available on every platform (sys.dm_exec_cached_plans). +/// </summary> +public sealed class PlanCacheStatsCollectorDefinitionTests +{ + private const string ExpectedQuery = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + cacheobjtype = cp.cacheobjtype, + objtype = cp.objtype, + total_plans = CONVERT(integer, COUNT_BIG(*)), + total_size_mb = CONVERT(integer, SUM(CONVERT(bigint, cp.size_in_bytes)) / 1024 / 1024), + single_use_plans = + SUM + ( + CASE + WHEN cp.usecounts = 1 + THEN 1 + ELSE 0 + END + ), + single_use_size_mb = + CONVERT + ( + integer, + SUM + ( + CASE + WHEN cp.usecounts = 1 + THEN CONVERT(bigint, cp.size_in_bytes) + ELSE 0 + END + ) / 1024 / 1024 + ), + multi_use_plans = + SUM + ( + CASE + WHEN cp.usecounts > 1 + THEN 1 + ELSE 0 + END + ), + multi_use_size_mb = + CONVERT + ( + integer, + SUM + ( + CASE + WHEN cp.usecounts > 1 + THEN CONVERT(bigint, cp.size_in_bytes) + ELSE 0 + END + ) / 1024 / 1024 + ), + avg_use_count = CONVERT(decimal(38,2), AVG(CONVERT(bigint, cp.usecounts))), + avg_size_kb = + CASE + WHEN AVG(CONVERT(bigint, cp.size_in_bytes)) / 1024 > 2147483647 + THEN 2147483647 + ELSE CONVERT(integer, AVG(CONVERT(bigint, cp.size_in_bytes)) / 1024) + END, + oldest_plan_create_time = + ( + SELECT + MIN(deqs.creation_time) + FROM sys.dm_exec_query_stats AS deqs + ) +FROM sys.dm_exec_cached_plans AS cp +GROUP BY + cp.cacheobjtype, + cp.objtype +OPTION(RECOMPILE);"; + + [Fact] + public void Query_IsTheVerbatimParityContract() + { + var context = CollectorTestContext.Make(new RecordingCollectorDeltaCalculator()); + Assert.Equal(ExpectedQuery, PlanCacheStatsCollector.Instance.BuildQuery(context).Text); + Assert.Empty(PlanCacheStatsCollector.Instance.BuildQuery(context).Parameters); + Assert.Null(PlanCacheStatsCollector.Instance.WatermarkColumn); + Assert.Equal("plan_cache_stats", PlanCacheStatsCollector.Instance.Name); + Assert.Equal("plan_cache_stats", PlanCacheStatsCollector.Instance.TargetTable); + } + + [Fact] + public void AppliesTo_Everywhere_IncludingAzure() + { + Assert.True(PlanCacheStatsCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureSqlDb = true })); + Assert.True(PlanCacheStatsCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureManagedInstance = true })); + Assert.True(PlanCacheStatsCollector.Instance.AppliesTo(new CollectorTargetInfo())); + } + + [Fact] + public void PayloadColumns_AreInAppendOrder() + { + var names = PlanCacheStatsCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray(); + + Assert.Equal( + new[] + { + "cacheobjtype", + "objtype", + "total_plans", + "total_size_mb", + "single_use_plans", + "single_use_size_mb", + "multi_use_plans", + "multi_use_size_mb", + "avg_use_count", + "avg_size_kb", + "oldest_plan_create_time", + }, + names); + + /* avg_use_count carries explicit precision so Darling's numeric(38,2) matches the Dashboard + column; oldest_plan_create_time is a timestamp. */ + var avgUseCount = PlanCacheStatsCollector.Instance.PayloadColumns.Single(c => c.Name == "avg_use_count"); + Assert.Equal(CollectorColumnType.Decimal, avgUseCount.Type); + Assert.Equal(38, avgUseCount.Precision); + Assert.Equal(2, avgUseCount.Scale); + Assert.Equal(CollectorColumnType.Timestamp, PlanCacheStatsCollector.Instance.PayloadColumns.Single(c => c.Name == "oldest_plan_create_time").Type); + } + + [Fact] + public async Task ReadAsync_MapsColumns_IncludingNullableOldestPlanCreateTime() + { + var oldest = new DateTime(2026, 6, 1, 8, 0, 0, DateTimeKind.Unspecified); + + using var reader = new FakeCollectorDataReader( + new object[] { "Compiled Plan", "Adhoc", 1000, 50, 800, 40, 200, 10, 1.50m, 51, oldest }, + /* An empty query-stats DMV yields a NULL oldest_plan_create_time. */ + new object[] { "Compiled Plan", "Proc", 300, 120, 5, 2, 295, 118, 42.00m, 410, DBNull.Value }); + + var context = CollectorTestContext.Make(new RecordingCollectorDeltaCalculator()); + + var rows = await PlanCacheStatsCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + + Assert.Equal(2, rows.Count); + Assert.Equal( + new PlanCacheStatsCollector.Row("Compiled Plan", "Adhoc", 1000, 50, 800, 40, 200, 10, 1.50m, 51, oldest), + rows[0]); + Assert.Equal( + new PlanCacheStatsCollector.Row("Compiled Plan", "Proc", 300, 120, 5, 2, 295, 118, 42.00m, 410, null), + rows[1]); + } + + [Fact] + public void WritePayload_EmitsPayloadOrder_PointInTime_NoDeltas() + { + var deltas = new RecordingCollectorDeltaCalculator(); + var context = CollectorTestContext.Make(deltas); + var writer = new RecordingCollectorRowWriter(); + var oldest = new DateTime(2026, 6, 1, 8, 0, 0, DateTimeKind.Unspecified); + var row = new PlanCacheStatsCollector.Row("Compiled Plan", "Adhoc", 1000, 50, 800, 40, 200, 10, 1.50m, 51, oldest); + + PlanCacheStatsCollector.Instance.WritePayload(row, writer, context); + + Assert.Equal( + new object?[] { "Compiled Plan", "Adhoc", 1000, 50, 800, 40, 200, 10, 1.50m, 51, oldest }, + writer.Values); + + /* Point-in-time snapshot — no delta calculator interaction at all. */ + Assert.Empty(deltas.Calls); + } +} diff --git a/Lite.Tests/PlanViewerCapabilityPinTests.cs b/Lite.Tests/PlanViewerCapabilityPinTests.cs new file mode 100644 index 000000000..55ab4fe15 --- /dev/null +++ b/Lite.Tests/PlanViewerCapabilityPinTests.cs @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// CAPABILITY PIN for the STANDALONE Plan Viewer (front-end SHELL COLLAPSE, Lite side). The collapse hoisted +/// ~300 near-verbatim lines out of <c>Lite/MainWindow.PlanViewer.cs</c> (and the viewer's twin) into the shared +/// <see cref="PerformanceMonitor.Ui.StandalonePlanViewerController"/>; this file keeps only the app-specific +/// outer-container reveal/close plus the XAML-wired drag/drop/key forwarders. The failure modes this guards: +/// +/// - a plan-loading / drag-drop / paste / open-file capability silently dropped while moving code into the +/// controller (WPF's generated <c>Connect()</c> does NOT catch a REMOVED behavior, only a dangling handler); +/// - a per-app shell WIRE (<c>PreviewDrop="MainWindowPlanViewer_Drop"</c> etc.) removed from the app's +/// MainWindow.xaml -- compiles clean, the handler just stops firing; +/// - the thin per-app entry methods (Open / Close) dropped from the code-behind. +/// +/// MECHANISM = RATCHET (mirrors <see cref="ServerTabCapabilityPinTests"/>): a fixed probe table is matched +/// against the COMBINED plan-viewer surface (this app's MainWindow.xaml + MainWindow.PlanViewer.cs + the shared +/// controller), and the committed BASELINE of matched probe names must remain a SUBSET of what currently +/// matches. A capability may never disappear; adding new probes that match is free. Intentionally removing a +/// capability is an explicit baseline edit in the SAME PR (delete the line), never a wholesale regenerate. +/// Regenerate ONLY on a real surface change via <c>PM_PIN_REGEN=1</c>. Text-scans SOURCE, located from this +/// file's compile-time path -- NO WPF / assembly load, like the other parity pins. +/// </summary> +public sealed class PlanViewerCapabilityPinTests +{ + /* Probe = (stable capability name, a substring that proves it is still present). Substrings, not regex, + so parens/asterisks in the file-dialog filter are literal. Wire probes use the ="Handler" form so they + match ONLY the XAML attribute (the code-behind method definition is `Handler(` with a paren). */ + private static readonly (string Name, string Pattern)[] Probes = + { + // Core plan-viewer behavior (now in the shared controller) + ("add-tab-sentinel", "__PLAN_ADD_TAB__"), + ("open-file-button", "Open .sqlplan File"), + ("paste-button", "Paste XML"), + ("empty-state-label", "New Plan"), + ("file-dialog-filter", "SQL Plan Files (*.sqlplan)"), + ("multi-file-open", "Multiselect = true"), + ("drag-filedrop", "DataFormats.FileDrop"), + ("drag-copy-effect", "DragDropEffects.Copy"), + ("clipboard-paste", "Clipboard.GetText"), + ("render-loadplan", ".LoadPlan("), + ("open-file-dialog", "OpenFileDialog"), + ("ctrl-v-paste", "Key.V"), + ("viewer-cleanup", ".Cleanup()"), + ("empty-state-icon", "Segoe MDL2 Assets"), + ("unique-sub-tab-label", "GetUniqueSubTabLabel"), + ("shared-plan-viewer-control", "new PlanViewerControl"), + // Per-app XAML shell wires (removal compiles clean) + ("wire-open", "=\"OpenPlanViewerButton_Click\""), + ("wire-close", "=\"MainWindowPlanViewerClose_Click\""), + ("wire-dragover", "=\"MainWindowPlanViewer_DragOver\""), + ("wire-drop", "=\"MainWindowPlanViewer_Drop\""), + ("wire-keydown", "=\"MainWindowPlanViewer_KeyDown\""), + // Per-app thin entry-method definitions + ("entry-open", "OpenPlanViewerButton_Click(object sender"), + ("entry-close", "MainWindowPlanViewerClose_Click(object sender"), + }; + + [Fact] + public void PlanViewerCapabilities_NoneDisappeared() + { + var matched = ScanCapabilities(); + AssertRatchet("planviewer", matched, floor: 20); + } + + /* ---------------- scan ---------------- */ + + private static ISet<string> ScanCapabilities() + { + var combined = CombinedSource(); + return Probes.Where(p => combined.Contains(p.Pattern, StringComparison.Ordinal)) + .Select(p => p.Name) + .ToHashSet(StringComparer.Ordinal); + } + + private static string CombinedSource() + { + var parts = new List<string> + { + File.ReadAllText(AppXaml()), + File.ReadAllText(AppPlanViewerCs()), + }; + var controller = ControllerCs(); + // Tolerate the controller being absent (pre-hoist baselining): the union still finds the app-side tokens. + if (File.Exists(controller)) + { + parts.Add(File.ReadAllText(controller)); + } + return string.Join("\n", parts); + } + + /* ---------------- ratchet + source location ---------------- */ + + private static void AssertRatchet(string section, ISet<string> current, int floor) + { + Assert.True(current.Count >= floor, + $"plan-viewer capability scan matched only {current.Count} probes (< floor {floor}) -- the scan is " + + "likely broken (source moved / renamed). Fix the scan; do not lower the floor."); + + var file = Path.Combine(BaselineDir(), $"{section}.baseline.txt"); + + if (Regen()) + { + Directory.CreateDirectory(BaselineDir()); + File.WriteAllLines(file, current.OrderBy(x => x, StringComparer.Ordinal)); + return; + } + + Assert.True(File.Exists(file), + $"baseline '{Path.GetFileName(file)}' is missing -- run the pin once with environment variable " + + "PM_PIN_REGEN=1 to generate it, then commit it."); + + var baseline = File.ReadAllLines(file) + .Select(l => l.Trim()) + .Where(l => l.Length > 0 && !l.StartsWith("#", StringComparison.Ordinal)) + .ToHashSet(StringComparer.Ordinal); + + var missing = baseline.Where(b => !current.Contains(b)) + .OrderBy(x => x, StringComparer.Ordinal) + .ToList(); + + Assert.True(missing.Count == 0, + "plan-viewer capability(ies) in the baseline are NO LONGER present in the source -- a capability " + + "silently went missing during the collapse:\n " + + string.Join("\n ", missing) + + "\nIf this removal is INTENTIONAL, delete those lines from Lite.Tests/CapabilityPins/" + + $"{section}.baseline.txt in THIS PR (never regenerate wholesale -- that masks accidental drops)."); + } + + private static bool Regen() => + string.Equals(Environment.GetEnvironmentVariable("PM_PIN_REGEN"), "1", StringComparison.Ordinal); + + private static string AppXaml([CallerFilePath] string thisFile = "") => + Path.GetFullPath(Path.Combine(Path.GetDirectoryName(thisFile)!, "..", "Lite", "MainWindow.xaml")); + + private static string AppPlanViewerCs([CallerFilePath] string thisFile = "") => + Path.GetFullPath(Path.Combine(Path.GetDirectoryName(thisFile)!, "..", "Lite", "MainWindow.PlanViewer.cs")); + + private static string ControllerCs([CallerFilePath] string thisFile = "") => + Path.GetFullPath(Path.Combine(Path.GetDirectoryName(thisFile)!, "..", "PerformanceMonitor.Ui", "StandalonePlanViewerController.cs")); + + private static string BaselineDir([CallerFilePath] string thisFile = "") => + Path.Combine(Path.GetDirectoryName(thisFile)!, "CapabilityPins"); +} diff --git a/Lite.Tests/ProcedureStatsCollectorDefinitionTests.cs b/Lite.Tests/ProcedureStatsCollectorDefinitionTests.cs new file mode 100644 index 000000000..de815e2ee --- /dev/null +++ b/Lite.Tests/ProcedureStatsCollectorDefinitionTests.cs @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Pins the parity contract of the extracted procedure_stats definition: the dynamic-SQL +/// standard variant with double-escaped literal exclusions, the Azure single-database variant +/// (token intentionally left unreplaced, as the original did), the plan_handle delta key with +/// its db.schema.object fallback, the 35-column payload, and the #1262 gated whole-module plan +/// capture (off = byte-identical to the no-plan form; on = the text DMV keyed on 0, -1). +/// </summary> +public sealed class ProcedureStatsCollectorDefinitionTests +{ + private static readonly RecordingCollectorDeltaCalculator s_deltas = new(); + + private static string Collapse(string sql) => Regex.Replace(sql, @"\s+", ""); + + [Fact] + public void BuildQuery_Standard_InterpolatesDoubleEscapedLiterals() + { + var plan = ProcedureStatsCollector.Instance.BuildQuery(new CollectorContext + { + ServerId = 42, + ServerName = "test-server", + CollectionTime = DateTime.UtcNow, + Deltas = s_deltas, + ExcludedDatabases = new[] { "O'Brien" }, + }); + + /* Nested-dynamic-SQL escaping: quote doubled twice → N''O''''Brien'' */ + Assert.Contains("AND d.name NOT IN (N''O''''Brien'')", plan.Text, StringComparison.Ordinal); + Assert.DoesNotContain("/*EXCLUSION_FILTER*/", plan.Text, StringComparison.Ordinal); + Assert.Contains("sys.dm_exec_trigger_stats", plan.Text, StringComparison.Ordinal); + Assert.Contains("sys.dm_exec_function_stats", plan.Text, StringComparison.Ordinal); + Assert.Empty(plan.Parameters); + } + + [Fact] + public void BuildLiteralClause_MatchesLiteOriginal_BothEscapeModes() + { + var names = new[] { "O'Brien", "plain" }; + + Assert.Equal( + RemoteCollectorService.BuildDatabaseExclusionLiteralClause(names, "d.name", forNestedDynamicSql: true), + DatabaseExclusionFilter.BuildLiteralClause(names, "d.name", forNestedDynamicSql: true)); + Assert.Equal( + RemoteCollectorService.BuildDatabaseExclusionLiteralClause(names, "d.name", forNestedDynamicSql: false), + DatabaseExclusionFilter.BuildLiteralClause(names, "d.name", forNestedDynamicSql: false)); + Assert.Equal(string.Empty, DatabaseExclusionFilter.BuildLiteralClause(null, "d.name")); + } + + [Fact] + public void BuildQuery_Azure_SingleDatabase_TokenLeftInPlace() + { + var plan = ProcedureStatsCollector.Instance.BuildQuery(CollectorTestContext.Make(s_deltas, isAzureSqlDb: true)); + + Assert.Contains("WHERE s.database_id = DB_ID()", plan.Text, StringComparison.Ordinal); + Assert.DoesNotContain("dm_exec_trigger_stats", plan.Text, StringComparison.Ordinal); + /* The original never replaced the token on the Azure variant — pinned as-is. */ + Assert.Contains("/*EXCLUSION_FILTER*/", plan.Text, StringComparison.Ordinal); + } + + [Fact] + public void BuildQuery_HandlesConvertToVarchar130_NeverTruncated() + { + /* plan_handle/sql_handle are varbinary(64); style-1 hex is '0x' + 128 = 130 chars. The old + CONVERT(varchar(64), ...) truncated the handle to ~31 bytes, so dm_exec_query_plan rejected the + stored value and Fetch Live Plan failed for every procedure/trigger/function row. Guards every + UNION branch in the standard body and the single-proc Azure variant. */ + foreach (var text in new[] + { + Collapse(ProcedureStatsCollector.Instance.BuildQuery(CollectorTestContext.Make(s_deltas)).Text), + Collapse(ProcedureStatsCollector.Instance.BuildQuery(CollectorTestContext.Make(s_deltas, isAzureSqlDb: true)).Text), + }) + { + Assert.Contains("sql_handle=CONVERT(varchar(130),s.sql_handle,1)", text, StringComparison.Ordinal); + Assert.Contains("plan_handle=CONVERT(varchar(130),s.plan_handle,1)", text, StringComparison.Ordinal); + Assert.DoesNotContain("CONVERT(varchar(64),s.sql_handle,1)", text, StringComparison.Ordinal); + Assert.DoesNotContain("CONVERT(varchar(64),s.plan_handle,1)", text, StringComparison.Ordinal); + } + } + + [Fact] + public void PayloadColumns_MatchSchema_35Columns() + { + var names = ProcedureStatsCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray(); + Assert.Equal(35, names.Length); + Assert.Equal("database_name", names[0]); + Assert.Equal("plan_handle", names[26]); + Assert.Equal("delta_spills", names[33]); + Assert.Equal("query_plan_xml", names[34]); /* trailing plan column (#1262) */ + } + + [Fact] + public void BuildQuery_PlanCaptureOffByDefault_NoPlanClauses_LiteParity() + { + /* Lite never sets CapturePlanXml: the flag defaults false, so no branch gains the plan SELECT + column or the dm_exec_text_query_plan APPLY, and the placeholders erase without a trace — + byte-identical to the no-plan form Lite has always shipped (standard AND Azure). */ + var standard = ProcedureStatsCollector.Instance.BuildQuery(CollectorTestContext.Make(s_deltas)); + var azure = ProcedureStatsCollector.Instance.BuildQuery(CollectorTestContext.Make(s_deltas, isAzureSqlDb: true)); + + foreach (var text in new[] { standard.Text, azure.Text }) + { + Assert.DoesNotContain("query_plan_xml", text, StringComparison.Ordinal); + Assert.DoesNotContain("dm_exec_text_query_plan", text, StringComparison.Ordinal); + Assert.DoesNotContain("/*PLAN_SELECT*/", text, StringComparison.Ordinal); + Assert.DoesNotContain("/*PLAN_APPLY*/", text, StringComparison.Ordinal); + } + } + + [Fact] + public void BuildQuery_PlanCaptureOn_Standard_SplicesWholeModuleTextPlanIntoEveryBranch() + { + /* The three module-level DMVs expose no statement offsets, so the whole-module plan is keyed + on literal 0, -1 (still the text DMV, so oversized plans return). One splice per UNION + branch — procedure, trigger, function — plus the trailing SELECT column. */ + var plan = ProcedureStatsCollector.Instance.BuildQuery(CollectorTestContext.Make(s_deltas, capturePlanXml: true)); + var collapsed = Collapse(plan.Text); + + Assert.Equal(3, Regex.Matches(collapsed, Regex.Escape("query_plan_xml=tqp.query_plan")).Count); + Assert.Equal(3, Regex.Matches(collapsed, Regex.Escape("sys.dm_exec_text_query_plan(s.plan_handle,0,-1)AStqp")).Count); + /* Existing shape untouched: still three DMV branches and the dynamic-SQL body. */ + Assert.Contains("sys.dm_exec_trigger_stats", plan.Text, StringComparison.Ordinal); + Assert.Contains("sys.dm_exec_function_stats", plan.Text, StringComparison.Ordinal); + } + + [Fact] + public void BuildQuery_PlanCaptureOn_Azure_SplicesWholeModuleTextPlan() + { + var plan = ProcedureStatsCollector.Instance.BuildQuery(CollectorTestContext.Make(s_deltas, isAzureSqlDb: true, capturePlanXml: true)); + var collapsed = Collapse(plan.Text); + + Assert.Contains("query_plan_xml=tqp.query_plan", collapsed, StringComparison.Ordinal); + Assert.Contains("sys.dm_exec_text_query_plan(s.plan_handle,0,-1)AStqp", collapsed, StringComparison.Ordinal); + Assert.Contains("WHERE s.database_id = DB_ID()", plan.Text, StringComparison.Ordinal); + /* Azure keeps its single-proc shape — no trigger/function branches. */ + Assert.DoesNotContain("dm_exec_trigger_stats", plan.Text, StringComparison.Ordinal); + } + + [Fact] + public async Task ReadAsync_WritePayload_PlanCaptureOn_CapturesTrailingPlanColumn() + { + var deltas = new RecordingCollectorDeltaCalculator(); + var context = CollectorTestContext.Make(deltas, capturePlanXml: true); + + /* Flag on = the SELECT carries the trailing query_plan_xml column at ordinal 27. */ + var row = MakeSqlRow(planHandle: "0x0600"); + var row28 = row.Append((object)"<ShowPlanXML>proc</ShowPlanXML>").ToArray(); + + using var reader = new FakeCollectorDataReader(row28); + var rows = await ProcedureStatsCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + + var writer = new RecordingCollectorRowWriter(); + ProcedureStatsCollector.Instance.WritePayload(Assert.Single(rows), writer, context); + + Assert.Equal(35, writer.Values.Count); + Assert.Equal("<ShowPlanXML>proc</ShowPlanXML>", writer.Values[34]); /* query_plan_xml payload slot */ + } + + [Fact] + public async Task WritePayload_UsesPlanHandleDeltaKey_WithFallback() + { + var deltas = new RecordingCollectorDeltaCalculator(); + var context = CollectorTestContext.Make(deltas); + + using var reader = new FakeCollectorDataReader( + MakeSqlRow(planHandle: "0x0600"), + MakeSqlRow(planHandle: null)); + var rows = await ProcedureStatsCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + + var writer = new RecordingCollectorRowWriter(); + ProcedureStatsCollector.Instance.WritePayload(rows[0], writer, context); + Assert.Equal(35, writer.Values.Count); + Assert.Null(writer.Values[34]); /* query_plan_xml null when the flag is off */ + Assert.All(deltas.Calls, c => Assert.Equal("0x0600", c.Key)); + Assert.Equal( + new[] { "proc_stats_exec", "proc_stats_worker", "proc_stats_elapsed", "proc_stats_reads", "proc_stats_writes", "proc_stats_phys_reads", "proc_stats_spills" }, + deltas.Calls.Select(c => c.Group).ToArray()); + + deltas.Calls.Clear(); + ProcedureStatsCollector.Instance.WritePayload(rows[1], writer, context); + Assert.All(deltas.Calls, c => Assert.Equal("SO.dbo.usp_GetUser", c.Key)); + } + + private static object[] MakeSqlRow(string? planHandle) => new object[] + { + "SO", "dbo", "usp_GetUser", "PROCEDURE", + new DateTime(2026, 7, 1, 8, 0, 0, DateTimeKind.Utc), new DateTime(2026, 7, 1, 12, 0, 0, DateTimeKind.Utc), + 100L, 5000L, 8000L, 900L, 10L, 5L, + 1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L, 9L, 10L, + 11L, 0L, 4L, + "0x0200", planHandle is null ? DBNull.Value : planHandle, + }; +} diff --git a/Lite.Tests/QuerySnapshotsCollectorDefinitionTests.cs b/Lite.Tests/QuerySnapshotsCollectorDefinitionTests.cs new file mode 100644 index 000000000..fa57a16d4 --- /dev/null +++ b/Lite.Tests/QuerySnapshotsCollectorDefinitionTests.cs @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Pins the parity contract of the extracted query_snapshots definition: the live-query-plan +/// version gate (2016 SP1+/MI, never Azure SQL DB — #857), the Azure #req-staging variant, the +/// CDC-capture detection on boxed vs the constant-0 on Azure, the exclusion clause injected ahead +/// of the ORDER BY, and the 35-column payload with the #1286 triage columns. +/// </summary> +public sealed class QuerySnapshotsCollectorDefinitionTests +{ + private static readonly RecordingCollectorDeltaCalculator s_deltas = new(); + + [Theory] + [InlineData(false, 16, false, true)] /* modern boxed */ + [InlineData(false, 13, false, true)] /* 2016 boundary */ + [InlineData(false, 0, false, true)] /* version unknown — optimistic */ + [InlineData(false, 12, false, false)] /* 2014 boxed — too old */ + [InlineData(false, 12, true, true)] /* Managed Instance overrides version */ + [InlineData(true, 16, false, false)] /* Azure SQL DB — never (#857) */ + public void SupportsLiveQueryPlan_PinsVersionGate(bool isAzureSqlDb, int major, bool isManagedInstance, bool expected) + { + var target = new CollectorTargetInfo + { + IsAzureSqlDb = isAzureSqlDb, + IsAzureManagedInstance = isManagedInstance, + SqlMajorVersion = major, + }; + + Assert.Equal(expected, QuerySnapshotsCollector.SupportsLiveQueryPlan(target)); + } + + [Fact] + public void BuildSnapshotQuery_Standard_WithLivePlan_FillsBothHoles() + { + var sql = QuerySnapshotsCollector.BuildSnapshotQuery(supportsLiveQueryPlan: true, isAzureSqlDatabase: false); + + Assert.Contains("live_query_plan = deqs.query_plan,", sql, StringComparison.Ordinal); + Assert.Contains("OUTER APPLY sys.dm_exec_query_statistics_xml(der.session_id) AS deqs", sql, StringComparison.Ordinal); + Assert.Contains("msdb.dbo.cdc_jobs", sql, StringComparison.Ordinal); + Assert.Contains("sp_MScdc_capture_job", sql, StringComparison.Ordinal); + Assert.DoesNotContain("#req", sql, StringComparison.Ordinal); + Assert.Contains("der.database_id <> ISNULL(DB_ID(N'PerformanceMonitor'), 0)", sql, StringComparison.Ordinal); + Assert.Contains("OPTION(MAXDOP 1, RECOMPILE);", sql, StringComparison.Ordinal); + } + + [Fact] + public void BuildSnapshotQuery_Standard_WithoutLivePlan_NullColumnAndNoApply() + { + var sql = QuerySnapshotsCollector.BuildSnapshotQuery(supportsLiveQueryPlan: false, isAzureSqlDatabase: false); + + Assert.Contains("live_query_plan = CONVERT(xml, NULL),", sql, StringComparison.Ordinal); + Assert.DoesNotContain("dm_exec_query_statistics_xml", sql, StringComparison.Ordinal); + } + + [Fact] + public void BuildSnapshotQuery_Azure_StagesReqTable_NoCdcJobs() + { + var sql = QuerySnapshotsCollector.BuildSnapshotQuery(supportsLiveQueryPlan: false, isAzureSqlDatabase: true); + + Assert.Contains("INTO #req", sql, StringComparison.Ordinal); + Assert.Contains("AND der.database_id = DB_ID()", sql, StringComparison.Ordinal); + Assert.Contains("FROM #req AS der", sql, StringComparison.Ordinal); + Assert.Contains("is_cdc_capture = CONVERT(bit, 0),", sql, StringComparison.Ordinal); + Assert.Contains("DROP TABLE #req;", sql, StringComparison.Ordinal); + /* No CDC job probe on Azure (the template's comment mentions cdc_jobs, so pin the machinery). */ + Assert.DoesNotContain("@cdc_readable", sql, StringComparison.Ordinal); + Assert.DoesNotContain("@cdc_capture_jobs", sql, StringComparison.Ordinal); + Assert.Contains("live_query_plan = CONVERT(xml, NULL),", sql, StringComparison.Ordinal); + } + + [Fact] + public void BuildQuery_InjectsExclusionClause_BeforeOrderBy() + { + var plan = QuerySnapshotsCollector.Instance.BuildQuery(new CollectorContext + { + ServerId = 42, + ServerName = "test-server", + CollectionTime = DateTime.UtcNow, + Deltas = s_deltas, + ExcludedDatabases = new[] { "SO", "AdventureWorks" }, + }); + + Assert.Contains( + "AND d.name NOT IN (@excl_db_0, @excl_db_1)\nORDER BY der.cpu_time DESC, der.parallel_worker_count DESC", + plan.Text, + StringComparison.Ordinal); + Assert.Equal(2, plan.Parameters.Count); + Assert.Equal("SO", plan.Parameters[0].Value); + Assert.Equal("AdventureWorks", plan.Parameters[1].Value); + /* Default target = version-unknown boxed, so the live-plan hole fills optimistically. */ + Assert.Contains("live_query_plan = deqs.query_plan,", plan.Text, StringComparison.Ordinal); + } + + [Fact] + public void BuildQuery_Azure_UsesReqStaging_DisablesLivePlan_SingleConnection() + { + var plan = QuerySnapshotsCollector.Instance.BuildQuery(CollectorTestContext.Make(s_deltas, isAzureSqlDb: true)); + + Assert.Contains("INTO #req", plan.Text, StringComparison.Ordinal); + Assert.Contains("live_query_plan = CONVERT(xml, NULL),", plan.Text, StringComparison.Ordinal); + Assert.Empty(plan.Parameters); + /* Unlike query_stats, snapshots stay single-connection on Azure — the #req staging scopes it. */ + Assert.False(QuerySnapshotsCollector.Instance.RunsPerDatabase(new CollectorTargetInfo { IsAzureSqlDb = true })); + } + + [Fact] + public void PayloadColumns_MatchSchemaOrder_35Columns() + { + var names = QuerySnapshotsCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray(); + + Assert.Equal(35, names.Length); + Assert.Equal("session_id", names[0]); + Assert.Equal("live_query_plan", names[5]); + Assert.Equal("is_cdc_capture", names[25]); + Assert.Equal("query_hash", names[26]); + Assert.Equal("requested_memory_mb", names[27]); + Assert.Equal("tran_start_time", names[33]); + Assert.Equal("request_id", names[34]); + Assert.Equal("query_snapshots", QuerySnapshotsCollector.Instance.Name); + Assert.Equal("query_snapshots", QuerySnapshotsCollector.Instance.TargetTable); + } + + [Fact] + public async Task WritePayload_Pins35ColumnOrder_NullHandling_NoDeltas() + { + var deltas = new RecordingCollectorDeltaCalculator(); + var context = CollectorTestContext.Make(deltas); + var tranStart = new DateTime(2026, 7, 2, 3, 0, 0, DateTimeKind.Utc); + + var row = new object[35]; + row[0] = 55; /* session_id */ + row[1] = "SO"; /* database_name */ + row[2] = "00 00:00:01.000"; /* elapsed_time_formatted */ + row[3] = "SELECT 1"; /* query_text */ + row[4] = "<plan/>"; /* query_plan */ + row[5] = "<live/>"; /* live_query_plan */ + row[6] = "running"; /* status */ + row[7] = DBNull.Value; /* blocking_session_id -> 0 */ + row[8] = "CXPACKET"; /* wait_type */ + row[9] = 123L; /* wait_time_ms */ + row[10] = "PAGE: 1:1:1"; /* wait_resource */ + row[11] = 456L; /* cpu_time_ms */ + row[12] = 789L; /* total_elapsed_time_ms */ + row[13] = 1L; /* reads */ + row[14] = 2L; /* writes */ + row[15] = 3L; /* logical_reads */ + row[16] = 1.50m; /* granted_query_memory_gb (GetDecimal) */ + row[17] = "Read Committed"; /* transaction_isolation_level */ + row[18] = 8; /* dop */ + row[19] = 16; /* parallel_worker_count */ + row[20] = "sa"; /* login_name */ + row[21] = "host"; /* host_name */ + row[22] = "app"; /* program_name */ + row[23] = 2; /* open_transaction_count */ + row[24] = 50.5f; /* percent_complete: DMV real -> Convert.ToDecimal */ + row[25] = true; /* is_cdc_capture */ + row[26] = "0x1234"; /* query_hash */ + row[27] = DBNull.Value; /* requested_memory_mb -> null */ + row[28] = 10.25m; /* used_memory_mb: decimal -> Convert.ToDouble */ + row[29] = 20.50m; /* max_used_memory_mb */ + row[30] = 1.25m; /* tempdb_current_mb */ + row[31] = 2.50m; /* tempdb_allocations_mb */ + row[32] = DBNull.Value; /* tran_log_used_mb -> null */ + row[33] = tranStart; /* tran_start_time */ + row[34] = 0; /* request_id */ + + using var reader = new FakeCollectorDataReader(row); + var rows = await QuerySnapshotsCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + + var writer = new RecordingCollectorRowWriter(); + QuerySnapshotsCollector.Instance.WritePayload(Assert.Single(rows), writer, context); + + Assert.Equal(35, writer.Values.Count); + Assert.Equal(55, writer.Values[0]); + Assert.Equal("<live/>", writer.Values[5]); + Assert.Equal(0, writer.Values[7]); /* NULL blocking_session_id coalesces to 0 */ + Assert.Equal(1.50m, writer.Values[16]); + Assert.Equal(50.5m, writer.Values[24]); /* float converted to decimal */ + Assert.Equal(true, writer.Values[25]); + Assert.Null(writer.Values[27]); /* NULL grant columns stay null */ + Assert.Equal(10.25, writer.Values[28]); /* decimal converted to double */ + Assert.Null(writer.Values[32]); + Assert.Equal(tranStart, writer.Values[33]); + Assert.Equal(0, writer.Values[34]); + Assert.Empty(deltas.Calls); /* point-in-time snapshot — no deltas */ + } +} diff --git a/Lite.Tests/QueryStatsCollectorDefinitionTests.cs b/Lite.Tests/QueryStatsCollectorDefinitionTests.cs new file mode 100644 index 000000000..eaa1dfc70 --- /dev/null +++ b/Lite.Tests/QueryStatsCollectorDefinitionTests.cs @@ -0,0 +1,229 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Pins the parity contract of the extracted query_stats definition: the full row-identity delta +/// key (sql_handle:start:end:plan_handle — the multi-statement cross-contamination fix), the +/// interval-captured worker delta feeding sample_interval_seconds, the two query variants, and +/// the 50-column payload with the query_plan_xml placeholder. +/// </summary> +public sealed class QueryStatsCollectorDefinitionTests +{ + private static readonly RecordingCollectorDeltaCalculator s_deltas = new(); + + [Fact] + public void BuildQuery_Standard_JoinsPlanAttributes_WithExclusions() + { + var plan = QueryStatsCollector.Instance.BuildQuery(new CollectorContext + { + ServerId = 42, + ServerName = "test-server", + CollectionTime = DateTime.UtcNow, + Deltas = s_deltas, + ExcludedDatabases = new[] { "SO" }, + }); + + Assert.Contains("sys.dm_exec_plan_attributes", plan.Text, StringComparison.Ordinal); + Assert.Contains("AND d.name NOT IN (@excl_db_0)", plan.Text, StringComparison.Ordinal); + Assert.Contains("NOT LIKE N'%PerformanceMonitorLite%'", plan.Text, StringComparison.Ordinal); + Assert.Equal("SO", Assert.Single(plan.Parameters).Value); + } + + [Fact] + public void BuildQuery_Azure_SkipsPlanAttributes_RunsPerDatabase() + { + var plan = QueryStatsCollector.Instance.BuildQuery(CollectorTestContext.Make(s_deltas, isAzureSqlDb: true)); + + Assert.DoesNotContain("dm_exec_plan_attributes", plan.Text, StringComparison.Ordinal); + Assert.Contains("database_name = DB_NAME()", plan.Text, StringComparison.Ordinal); + Assert.True(QueryStatsCollector.Instance.RunsPerDatabase(new CollectorTargetInfo { IsAzureSqlDb = true })); + Assert.Empty(plan.Parameters); + } + + [Fact] + public void AppliesTo_VersionGate_SkipsPreSql2016OnPrem_ButNotAzureOrUnknown() + { + /* Version gate collapsed from Lite's IsCollectorSupported into the shared AppliesTo (so Darling gates + too). On-prem/RDS require v13+ (2016); a 2014 box lacks columns this reads. */ + Assert.False(QueryStatsCollector.Instance.AppliesTo(new CollectorTargetInfo { SqlMajorVersion = 12 })); + Assert.True(QueryStatsCollector.Instance.AppliesTo(new CollectorTargetInfo { SqlMajorVersion = 13 })); + Assert.True(QueryStatsCollector.Instance.AppliesTo(new CollectorTargetInfo { SqlMajorVersion = 16 })); + /* Unknown (0) assumes newest; Azure SQL DB / MI report a low ProductMajorVersion but support the DMV. */ + Assert.True(QueryStatsCollector.Instance.AppliesTo(new CollectorTargetInfo { SqlMajorVersion = 0 })); + Assert.True(QueryStatsCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureSqlDb = true, SqlMajorVersion = 12 })); + Assert.True(QueryStatsCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureManagedInstance = true, SqlMajorVersion = 12 })); + } + + [Fact] + public void BuildQuery_PlanCaptureOffByDefault_NoPlanClauses_LiteParity() + { + /* Lite never sets CapturePlanXml: the flag defaults false, so neither the plan SELECT column + nor the dm_exec_text_query_plan APPLY appears and the placeholders erase without a trace — + the SQL is byte-identical to the no-plan form Lite has always shipped. */ + var standard = QueryStatsCollector.Instance.BuildQuery(MakeContext()); + var azure = QueryStatsCollector.Instance.BuildQuery(MakeContext(isAzureSqlDb: true)); + + foreach (var text in new[] { standard.Text, azure.Text }) + { + Assert.DoesNotContain("query_plan_xml", text, StringComparison.Ordinal); + Assert.DoesNotContain("dm_exec_text_query_plan", text, StringComparison.Ordinal); + Assert.DoesNotContain("/*PLAN_SELECT*/", text, StringComparison.Ordinal); + Assert.DoesNotContain("/*PLAN_APPLY*/", text, StringComparison.Ordinal); + } + } + + [Fact] + public void BuildQuery_PlanCaptureOn_Standard_MirrorsDashboardTextQueryPlan() + { + /* Darling: mirrors install/08_collect_query_stats.sql's @collect_plan path — the + statement-level plan from sys.dm_exec_text_query_plan keyed on the same plan_handle + + statement offsets (the text DMV, so large/deep plans still return). */ + var plan = QueryStatsCollector.Instance.BuildQuery(MakeContext(capturePlanXml: true)); + + Assert.Contains("query_plan_xml = tqp.query_plan", plan.Text, StringComparison.Ordinal); + Assert.Contains( + "sys.dm_exec_text_query_plan(qs.plan_handle,qs.statement_start_offset,qs.statement_end_offset)AStqp", + Collapse(plan.Text), StringComparison.Ordinal); + /* The on-prem plan_attributes join and the row-selection semantics are untouched. */ + Assert.Contains("sys.dm_exec_plan_attributes", plan.Text, StringComparison.Ordinal); + Assert.Contains("TOP (200)", plan.Text, StringComparison.Ordinal); + Assert.Contains("ORDER BY", plan.Text, StringComparison.Ordinal); + } + + [Fact] + public void BuildQuery_PlanCaptureOn_Azure_AlsoCapturesPlanWithoutPlanAttributes() + { + var plan = QueryStatsCollector.Instance.BuildQuery(MakeContext(isAzureSqlDb: true, capturePlanXml: true)); + + Assert.Contains("query_plan_xml = tqp.query_plan", plan.Text, StringComparison.Ordinal); + Assert.Contains( + "sys.dm_exec_text_query_plan(qs.plan_handle,qs.statement_start_offset,qs.statement_end_offset)AStqp", + Collapse(plan.Text), StringComparison.Ordinal); + /* Azure SQL DB still skips plan_attributes (dbid=1 for all plans there). */ + Assert.DoesNotContain("dm_exec_plan_attributes", plan.Text, StringComparison.Ordinal); + Assert.Contains("database_name = DB_NAME()", plan.Text, StringComparison.Ordinal); + } + + [Fact] + public async Task ReadAsync_WritePayload_PlanCaptureOn_CapturesTrailingPlanColumn() + { + var deltas = new RecordingCollectorDeltaCalculator(); + var context = MakeContext(deltas: deltas, capturePlanXml: true); + + /* Flag on = the SELECT carries the trailing query_plan_xml column at ordinal 42. */ + var row43 = new object[43]; + row43[0] = "SO"; row43[1] = "0xQH"; row43[2] = "0xQPH"; + row43[3] = new DateTime(2026, 7, 2, 1, 0, 0, DateTimeKind.Utc); + row43[4] = new DateTime(2026, 7, 2, 2, 0, 0, DateTimeKind.Utc); + for (int i = 5; i < 36; i++) row43[i] = (long)i; + row43[22] = 4L; row43[23] = 8L; + row43[36] = "0xSH"; row43[37] = "0xPH"; row43[38] = "SELECT 1"; + row43[39] = 3L; row43[40] = 66; row43[41] = 512; + row43[42] = "<ShowPlanXML>captured</ShowPlanXML>"; + + using var reader = new FakeCollectorDataReader(row43); + var rows = await QueryStatsCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + + var writer = new RecordingCollectorRowWriter(); + QueryStatsCollector.Instance.WritePayload(Assert.Single(rows), writer, context); + + Assert.Equal(50, writer.Values.Count); + Assert.Equal("<ShowPlanXML>captured</ShowPlanXML>", writer.Values[37]); /* query_plan_xml payload slot */ + } + + private static string Collapse(string sql) => Regex.Replace(sql, @"\s+", ""); + + private static CollectorContext MakeContext( + ICollectorDeltaCalculator? deltas = null, + bool isAzureSqlDb = false, + bool capturePlanXml = false) + => new() + { + ServerId = 42, + ServerName = "test-server", + CollectionTime = new DateTime(2026, 7, 2, 12, 0, 0, DateTimeKind.Utc), + Deltas = deltas ?? s_deltas, + Target = new CollectorTargetInfo { IsAzureSqlDb = isAzureSqlDb }, + CapturePlanXml = capturePlanXml, + }; + + [Fact] + public void BuildQuery_HandlesConvertToVarchar130_HashesStayVarchar64() + { + /* plan_handle/sql_handle are varbinary(64); style-1 hex is '0x' + 128 = 130 chars. The old + CONVERT(varchar(64), ...) truncated the handle to ~31 bytes, so dm_exec_query_plan rejected the + stored value and Fetch Live Plan failed for every query-grid row (and the by-sql_handle path). + query_hash/query_plan_hash are binary(8) (18 chars) and correctly stay varchar(64). Both the + on-prem and Azure builds share SelectColumnsText, so this guards both. */ + foreach (var text in new[] + { + Collapse(QueryStatsCollector.Instance.BuildQuery(MakeContext()).Text), + Collapse(QueryStatsCollector.Instance.BuildQuery(MakeContext(isAzureSqlDb: true)).Text), + }) + { + Assert.Contains("sql_handle=CONVERT(varchar(130),qs.sql_handle,1)", text, StringComparison.Ordinal); + Assert.Contains("plan_handle=CONVERT(varchar(130),qs.plan_handle,1)", text, StringComparison.Ordinal); + Assert.DoesNotContain("CONVERT(varchar(64),qs.sql_handle,1)", text, StringComparison.Ordinal); + Assert.DoesNotContain("CONVERT(varchar(64),qs.plan_handle,1)", text, StringComparison.Ordinal); + Assert.Contains("query_hash=CONVERT(varchar(64),qs.query_hash,1)", text, StringComparison.Ordinal); + Assert.Contains("query_plan_hash=CONVERT(varchar(64),qs.query_plan_hash,1)", text, StringComparison.Ordinal); + } + } + + [Fact] + public void PayloadColumns_MatchSchemaOrder_50Columns() + { + var names = QueryStatsCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray(); + Assert.Equal(50, names.Length); + Assert.Equal("database_name", names[0]); + Assert.Equal("query_plan_xml", names[37]); + Assert.Equal("sample_interval_seconds", names[49]); + } + + [Fact] + public async Task WritePayload_PinsFullRowIdentityDeltaKey_AndIntervalCapture() + { + var deltas = new RecordingCollectorDeltaCalculator(); + var context = CollectorTestContext.Make(deltas); + + var row42 = new object[42]; + row42[0] = "SO"; row42[1] = "0xQH"; row42[2] = "0xQPH"; + row42[3] = new DateTime(2026, 7, 2, 1, 0, 0, DateTimeKind.Utc); + row42[4] = new DateTime(2026, 7, 2, 2, 0, 0, DateTimeKind.Utc); + for (int i = 5; i < 36; i++) row42[i] = (long)i; + row42[22] = 4L; row42[23] = 8L; /* dop as long via GetValue */ + row42[36] = "0xSH"; row42[37] = "0xPH"; row42[38] = "SELECT 1"; + row42[39] = 3L; row42[40] = 66; row42[41] = 512; + + using var reader = new FakeCollectorDataReader(row42); + var rows = await QueryStatsCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + + var writer = new RecordingCollectorRowWriter(); + QueryStatsCollector.Instance.WritePayload(Assert.Single(rows), writer, context); + + Assert.Equal(50, writer.Values.Count); + Assert.Null(writer.Values[37]); /* query_plan_xml placeholder */ + Assert.Equal(0, writer.Values[49]); /* interval from recording fake */ + Assert.Equal(8, deltas.Calls.Count); + Assert.All(deltas.Calls, c => Assert.Equal("0xSH:66:512:0xPH", c.Key)); + Assert.Equal( + new[] { "query_stats_exec", "query_stats_worker", "query_stats_elapsed", "query_stats_reads", "query_stats_writes", "query_stats_phys_reads", "query_stats_rows", "query_stats_spills" }, + deltas.Calls.Select(c => c.Group).ToArray()); + } +} diff --git a/Lite.Tests/QueryStatsModuleAttributionReaderTests.cs b/Lite.Tests/QueryStatsModuleAttributionReaderTests.cs new file mode 100644 index 000000000..32e1f388b --- /dev/null +++ b/Lite.Tests/QueryStatsModuleAttributionReaderTests.cs @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using PerformanceMonitorLite.Database; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Real-DuckDB round-trip pins for the #1568 module attribution in Top Queries by Duration +/// (<see cref="LocalDataService.GetTopQueriesByCpuAsync"/>): the read-time LEFT JOIN of +/// <c>query_stats.sql_handle</c> to <c>procedure_stats.sql_handle</c> (both stores persist the SAME +/// normalized <c>CONVERT(varchar(130), ..., 1)</c> handle text). A statement whose handle matches a cached +/// procedure/function/trigger inherits its db.schema.object; an unmatched statement is ad hoc; and the join +/// picks ONE module identity per handle (latest collection_time) so a query row never fans out. Mirrors the +/// Darling <c>ViewerQueriesLivePostgresTests</c> attribution round-trip and the Lite reader-test harness +/// (<see cref="BlockingStatsReaderTests"/>). +/// </summary> +public sealed class QueryStatsModuleAttributionReaderTests : IDisposable +{ + private const int ServerId = 8811; + + private readonly string _tempDir; + private readonly DuckDbInitializer _duckDb; + private long _nextId = 1; + + /* Anchor in the recent past so the default 24h window includes it; last_execution_time must be >= the + window start (the reader's staleness filter, utc offset 0). */ + private static readonly DateTime Collected = MinuteFloor(DateTime.UtcNow.AddMinutes(-90)); + + public QueryStatsModuleAttributionReaderTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "LiteQsModuleRt_" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(_tempDir); + _duckDb = new DuckDbInitializer(Path.Combine(_tempDir, "test.duckdb")); + } + + public void Dispose() + { + try + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + catch { /* Best-effort cleanup */ } + } + + private static DateTime MinuteFloor(DateTime t) => + DateTime.SpecifyKind(new DateTime(t.Ticks - (t.Ticks % TimeSpan.TicksPerMinute)), DateTimeKind.Unspecified); + + [Fact] + public async Task TopQueries_AttributesMatchedHandleToModule_UnmatchedIsAdHoc() + { + await _duckDb.InitializeAsync(); + var service = new LocalDataService(_duckDb); + + /* Attributed query: sql_handle matches a cached procedure — bigger elapsed so it ranks first. */ + await SeedQueryStatsAsync("TestDb", "0xATTRIB", "0xMOD", deltaExec: 5, deltaElapsedUs: 300_000, "SELECT attributed"); + await SeedProcedureStatsAsync("TestDb", "dbo", "usp_Thing", "PROCEDURE", "0xMOD"); + + /* Ad hoc query: its sql_handle matches no cached module. */ + await SeedQueryStatsAsync("TestDb", "0xADHOC", "0xLOOSE", deltaExec: 3, deltaElapsedUs: 100_000, "SELECT adhoc"); + + var rows = await service.GetTopQueriesByCpuAsync(ServerId); + + Assert.Equal(2, rows.Count); + + var attributed = rows.Single(r => r.QueryHash == "0xATTRIB"); + Assert.Equal("usp_Thing", attributed.ModuleObjectName); + Assert.Equal("dbo", attributed.ModuleSchemaName); + Assert.Equal("TestDb", attributed.ModuleDatabaseName); + Assert.Equal("TestDb.dbo.usp_Thing", attributed.ModuleName); + + var adHoc = rows.Single(r => r.QueryHash == "0xADHOC"); + Assert.Equal("", adHoc.ModuleObjectName); + Assert.Equal("ad hoc", adHoc.ModuleName); + } + + [Fact] + public async Task TopQueries_ModuleJoinDoesNotFanOut_LatestCollectionWins() + { + await _duckDb.InitializeAsync(); + var service = new LocalDataService(_duckDb); + + await SeedQueryStatsAsync("TestDb", "0xONE", "0xMOD", deltaExec: 4, deltaElapsedUs: 200_000, "SELECT one"); + + /* Same sql_handle captured twice with a different object identity — the newer capture wins, and the + single query row must not multiply across the two procedure_stats rows. */ + await SeedProcedureStatsAsync("TestDb", "dbo", "usp_Old", "PROCEDURE", "0xMOD", collectedOverride: Collected.AddMinutes(-30)); + await SeedProcedureStatsAsync("TestDb", "dbo", "usp_New", "PROCEDURE", "0xMOD", collectedOverride: Collected); + + var rows = await service.GetTopQueriesByCpuAsync(ServerId); + + var only = Assert.Single(rows); /* exactly one row — the join did not fan out */ + Assert.Equal("TestDb.dbo.usp_New", only.ModuleName); + } + + // ── Seeding helpers (column-list INSERT; unset columns default to NULL) ── + + private async Task SeedQueryStatsAsync( + string databaseName, string queryHash, string sqlHandle, long deltaExec, long deltaElapsedUs, string queryText) + { + using var readLock = _duckDb.AcquireReadLock(); + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +INSERT INTO query_stats + (collection_id, collection_time, server_id, server_name, database_name, + query_hash, sql_handle, last_execution_time, delta_execution_count, delta_elapsed_time, query_text) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)"; + cmd.Parameters.Add(new DuckDBParameter { Value = _nextId++ }); + cmd.Parameters.Add(new DuckDBParameter { Value = Collected }); + cmd.Parameters.Add(new DuckDBParameter { Value = ServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = "TestSrv" }); + cmd.Parameters.Add(new DuckDBParameter { Value = databaseName }); + cmd.Parameters.Add(new DuckDBParameter { Value = queryHash }); + cmd.Parameters.Add(new DuckDBParameter { Value = sqlHandle }); + cmd.Parameters.Add(new DuckDBParameter { Value = Collected }); + cmd.Parameters.Add(new DuckDBParameter { Value = deltaExec }); + cmd.Parameters.Add(new DuckDBParameter { Value = deltaElapsedUs }); + cmd.Parameters.Add(new DuckDBParameter { Value = queryText }); + await cmd.ExecuteNonQueryAsync(); + } + + private async Task SeedProcedureStatsAsync( + string databaseName, string schemaName, string objectName, string objectType, string sqlHandle, + DateTime? collectedOverride = null) + { + using var readLock = _duckDb.AcquireReadLock(); + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +INSERT INTO procedure_stats + (collection_id, collection_time, server_id, server_name, database_name, + schema_name, object_name, object_type, sql_handle) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)"; + cmd.Parameters.Add(new DuckDBParameter { Value = _nextId++ }); + cmd.Parameters.Add(new DuckDBParameter { Value = collectedOverride ?? Collected }); + cmd.Parameters.Add(new DuckDBParameter { Value = ServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = "TestSrv" }); + cmd.Parameters.Add(new DuckDBParameter { Value = databaseName }); + cmd.Parameters.Add(new DuckDBParameter { Value = schemaName }); + cmd.Parameters.Add(new DuckDBParameter { Value = objectName }); + cmd.Parameters.Add(new DuckDBParameter { Value = objectType }); + cmd.Parameters.Add(new DuckDBParameter { Value = sqlHandle }); + await cmd.ExecuteNonQueryAsync(); + } +} + +/// <summary>Pure row-model pin for the composed #1568 "Module" display string (no DuckDB).</summary> +public sealed class QueryStatsRowModuleDisplayTests +{ + [Fact] + public void ModuleName_ComposesDatabaseSchemaObject_WhenAttributed() + { + var row = new QueryStatsRow { ModuleDatabaseName = "StackOverflow", ModuleSchemaName = "dbo", ModuleObjectName = "usp_Get" }; + Assert.Equal("StackOverflow.dbo.usp_Get", row.ModuleName); + } + + [Fact] + public void ModuleName_IsAdHoc_WhenNoObjectName() + { + Assert.Equal("ad hoc", new QueryStatsRow().ModuleName); + Assert.Equal("ad hoc", new QueryStatsRow { ModuleDatabaseName = "StackOverflow", ModuleSchemaName = "dbo" }.ModuleName); + } +} diff --git a/Lite.Tests/QueryStoreCollectorDefinitionTests.cs b/Lite.Tests/QueryStoreCollectorDefinitionTests.cs new file mode 100644 index 000000000..6e64ab64c --- /dev/null +++ b/Lite.Tests/QueryStoreCollectorDefinitionTests.cs @@ -0,0 +1,480 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Pins the parity contract of the extracted query_store definition: the actual_state enumeration +/// (on-prem AG-aware, Azure not), the live PRODUCTVERSION probe deciding the 2017+/2022+ column +/// gates (default 13 when the probe fails), the last_execution_time incremental watermark with its +/// 60-minute fallback, and the 54-column payload. Second Name≠TargetTable case (query_store → +/// query_store_stats). +/// </summary> +public sealed class QueryStoreCollectorDefinitionTests +{ + private static readonly RecordingCollectorDeltaCalculator s_deltas = new(); + + private static CollectorContext MakeContext( + bool isAzureSqlDb = false, + object? probeResult = null, + DateTime? watermark = null, + DateTime? collectionTime = null, + bool capturePlanXml = false) + => new() + { + ServerId = 42, + ServerName = "test-server", + CollectionTime = collectionTime ?? new DateTime(2026, 7, 2, 12, 0, 0, DateTimeKind.Utc), + Deltas = s_deltas, + Target = new CollectorTargetInfo { IsAzureSqlDb = isAzureSqlDb }, + Watermark = watermark, + EnumerationProbeResult = probeResult, + CapturePlanXml = capturePlanXml, + }; + + [Fact] + public void Identity_SecondNameTargetTableSplit_WithWatermark() + { + Assert.Equal("query_store", QueryStoreCollector.Instance.Name); + Assert.Equal("query_store_stats", QueryStoreCollector.Instance.TargetTable); + Assert.Equal("last_execution_time", QueryStoreCollector.Instance.WatermarkColumn); + } + + [Fact] + public void AppliesTo_VersionGate_SkipsPreSql2016OnPrem_ButNotAzureOrUnknown() + { + /* Query Store first shipped in SQL 2016 (v13). Gate collapsed from Lite's IsCollectorSupported into + the shared AppliesTo (so Darling gates too); a pre-2016 box has no Query Store at all. */ + Assert.False(QueryStoreCollector.Instance.AppliesTo(new CollectorTargetInfo { SqlMajorVersion = 12 })); + Assert.True(QueryStoreCollector.Instance.AppliesTo(new CollectorTargetInfo { SqlMajorVersion = 13 })); + Assert.True(QueryStoreCollector.Instance.AppliesTo(new CollectorTargetInfo { SqlMajorVersion = 16 })); + /* Unknown (0) assumes newest; Azure SQL DB / MI report a low ProductMajorVersion but ship Query Store. */ + Assert.True(QueryStoreCollector.Instance.AppliesTo(new CollectorTargetInfo { SqlMajorVersion = 0 })); + Assert.True(QueryStoreCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureSqlDb = true, SqlMajorVersion = 12 })); + Assert.True(QueryStoreCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureManagedInstance = true, SqlMajorVersion = 12 })); + } + + [Fact] + public void BuildEnumerationQuery_OnPrem_AgAware_ProbesActualState_WithExclusions() + { + var plan = QueryStoreCollector.Instance.BuildEnumerationQuery(new CollectorContext + { + ServerId = 42, + ServerName = "test-server", + CollectionTime = DateTime.UtcNow, + Deltas = s_deltas, + ExcludedDatabases = new[] { "SO" }, + }); + + Assert.NotNull(plan); + Assert.Contains("sys.dm_hadr_database_replica_states", plan!.Text, StringComparison.Ordinal); + Assert.Contains("drs.is_primary_replica = 1", plan.Text, StringComparison.Ordinal); + /* #1565: the canonical default screen — vendor management dbs (rdsadmin, gcloud_cloudsqladmin), + SSRS/DW artifacts, the system four as a name belt, and the operator-chosen DBA-convention + names. Spot-pin one from each group; the full list lives in the query. */ + Assert.Contains("N'rdsadmin'", plan.Text, StringComparison.Ordinal); + Assert.Contains("N'gcloud_cloudsqladmin'", plan.Text, StringComparison.Ordinal); + Assert.Contains("N'ReportServerTempDB'", plan.Text, StringComparison.Ordinal); + Assert.Contains("N'DBAUtils'", plan.Text, StringComparison.Ordinal); + /* IN (1, 2, 4) = READ_ONLY/READ_WRITE/READ_CAPTURE_SECONDARY, not "> 0": 3 = ERROR must not + pass the "is QS usable" gate. */ + Assert.Contains("WHERE actual_state IN (1, 2, 4)", plan.Text, StringComparison.Ordinal); + /* #1558: readable-secondary replicas (readonly_reason bit 8 — AG secondaries slip the HADR + join on RDS/geo mechanisms) are excluded: their QS is the primary's replicated content. + Bitmask form, so a combined reason still excludes. */ + Assert.Contains("AND readonly_reason & 8 = 0", plan.Text, StringComparison.Ordinal); + Assert.Contains("AND d.name NOT IN (@excl_db_0)", plan.Text, StringComparison.Ordinal); + Assert.DoesNotContain("/*EXCLUSION_FILTER*/", plan.Text, StringComparison.Ordinal); + Assert.Equal("SO", Assert.Single(plan.Parameters).Value); + } + + [Fact] + public void BuildEnumerationQuery_Azure_NoAgJoin() + { + var plan = QueryStoreCollector.Instance.BuildEnumerationQuery(MakeContext(isAzureSqlDb: true)); + + Assert.NotNull(plan); + Assert.DoesNotContain("dm_hadr_database_replica_states", plan!.Text, StringComparison.Ordinal); + /* The same canonical default screen as the box path (#1565) — one shared list, two paths. */ + Assert.Contains("N'gcloud_cloudsqladmin'", plan.Text, StringComparison.Ordinal); + Assert.Contains("WHERE actual_state IN (1, 2, 4)", plan.Text, StringComparison.Ordinal); + /* #1558: readable-secondary replicas (readonly_reason bit 8 — AG secondaries slip the HADR + join on RDS/geo mechanisms) are excluded: their QS is the primary's replicated content. + Bitmask form, so a combined reason still excludes. */ + Assert.Contains("AND readonly_reason & 8 = 0", plan.Text, StringComparison.Ordinal); + Assert.Empty(plan.Parameters); + } + + [Fact] + public void BuildEnumerationProbe_PinsLiveProductVersionCheck() + { + var probe = QueryStoreCollector.Instance.BuildEnumerationProbe(MakeContext()); + + Assert.NotNull(probe); + Assert.Equal( + "SELECT CONVERT(integer, PARSENAME(CONVERT(sysname, SERVERPROPERTY('PRODUCTVERSION')), 4))", + probe!.Text); + Assert.Empty(probe.Parameters); + } + + [Fact] + public void BuildPerItemQuery_ProbeFailed_DefaultsTo2016_NullGatedColumns() + { + var plan = QueryStoreCollector.Instance.BuildPerItemQuery("SO", MakeContext(probeResult: null)); + + Assert.Contains("avg_num_physical_io_reads = NULL", plan.Text, StringComparison.Ordinal); + Assert.Contains("avg_log_bytes_used = NULL", plan.Text, StringComparison.Ordinal); + Assert.Contains("avg_tempdb_space_used = NULL", plan.Text, StringComparison.Ordinal); + Assert.Contains("plan_forcing_type = NULL,", plan.Text, StringComparison.Ordinal); + Assert.Contains("plan_type = NULL,", plan.Text, StringComparison.Ordinal); + Assert.Contains("replica_role = CONVERT(nvarchar(1), NULL)", plan.Text, StringComparison.Ordinal); + Assert.DoesNotContain("sys.query_store_replicas", plan.Text, StringComparison.Ordinal); + } + + [Fact] + public void BuildPerItemQuery_2017Probe_RealColumns_NoPlanType() + { + var plan = QueryStoreCollector.Instance.BuildPerItemQuery("SO", MakeContext(probeResult: 14)); + + Assert.Contains("qsrs.avg_num_physical_io_reads, qsrs.min_num_physical_io_reads, qsrs.max_num_physical_io_reads,", plan.Text, StringComparison.Ordinal); + Assert.Contains("avg_log_bytes_used = qsrs.avg_log_bytes_used", plan.Text, StringComparison.Ordinal); + Assert.Contains("avg_tempdb_space_used = qsrs.avg_tempdb_space_used", plan.Text, StringComparison.Ordinal); + Assert.Contains("plan_forcing_type = qsp.plan_forcing_type_desc,", plan.Text, StringComparison.Ordinal); + Assert.Contains("plan_type = NULL,", plan.Text, StringComparison.Ordinal); + + /* Replica attribution is 2022+ only — sys.query_store_replicas does not exist on 2017. */ + Assert.Contains("replica_role = CONVERT(nvarchar(1), NULL)", plan.Text, StringComparison.Ordinal); + Assert.DoesNotContain("sys.query_store_replicas", plan.Text, StringComparison.Ordinal); + } + + [Fact] + public void BuildPerItemQuery_2022Probe_PlanTypeColumn() + { + var plan = QueryStoreCollector.Instance.BuildPerItemQuery("SO", MakeContext(probeResult: 16)); + + Assert.Contains("plan_type = qsp.plan_type_desc,", plan.Text, StringComparison.Ordinal); + Assert.Contains("plan_forcing_type = qsp.plan_forcing_type_desc,", plan.Text, StringComparison.Ordinal); + } + + /// <summary> + /// Replica attribution turns on at major 16 — sys.query_store_replicas and + /// sys.query_store_runtime_stats.replica_group_id both exist from SQL 2022, verified live against + /// 16.0.4255.1 (the docs' "2025+" claim for the view is wrong), so the gate is >= 16, not >= 17. + /// </summary> + [Fact] + public void BuildPerItemQuery_2022Probe_ReplicaRole_LeftJoinsReplicas() + { + var plan = QueryStoreCollector.Instance.BuildPerItemQuery("SO", MakeContext(probeResult: 16)); + + /* replica_name read directly: contrary to the docs it IS populated on box SQL Server + ('Primary', 'Secondary', 'Geo Secondary', 'Geo HA Secondary'), so no role_type CASE. */ + Assert.Contains("replica_role = qsr.replica_name", plan.Text, StringComparison.Ordinal); + + /* MUST be a LEFT JOIN. On a 2022 standalone sys.query_store_replicas has ZERO rows while real + runtime-stats rows still carry replica_group_id = 1 — an INNER JOIN would match nothing and + silently delete ALL Query Store collection on every 2022 standalone server. */ + Assert.Contains( + "LEFT JOIN sys.query_store_replicas AS qsr", + plan.Text, + StringComparison.Ordinal); + Assert.Contains("ON qsr.replica_group_id = qsrs.replica_group_id", plan.Text, StringComparison.Ordinal); + + /* No filter to a single role: rows are attributed, never dropped. */ + Assert.DoesNotContain("replica_name = N''Primary''", plan.Text, StringComparison.Ordinal); + } + + /// <summary>SQL 2025 (major 17) keeps the 2022 attribution — the gate is >=, not ==.</summary> + [Fact] + public void BuildPerItemQuery_2025Probe_ReplicaRole_StillAttributed() + { + var plan = QueryStoreCollector.Instance.BuildPerItemQuery("SO", MakeContext(probeResult: 17)); + + Assert.Contains("replica_role = qsr.replica_name", plan.Text, StringComparison.Ordinal); + Assert.Contains("LEFT JOIN sys.query_store_replicas AS qsr", plan.Text, StringComparison.Ordinal); + } + + [Fact] + public void BuildPerItemQuery_AzureWithFailedProbe_StillNewColumns() + { + /* Azure SQL DB reports low PRODUCTVERSION majors historically — the edition overrides + the version gate, exactly as the original's isNew computation did. */ + var plan = QueryStoreCollector.Instance.BuildPerItemQuery("SO", MakeContext(isAzureSqlDb: true, probeResult: null)); + + Assert.Contains("qsrs.avg_num_physical_io_reads", plan.Text, StringComparison.Ordinal); + Assert.Contains("plan_forcing_type = qsp.plan_forcing_type_desc,", plan.Text, StringComparison.Ordinal); + Assert.Contains("plan_type = NULL,", plan.Text, StringComparison.Ordinal); + } + + [Fact] + public void BuildPerItemQuery_PinsWatermarkCutoff_AndSpExecutesqlShape() + { + var watermark = new DateTime(2026, 7, 2, 11, 30, 0, DateTimeKind.Utc); + var plan = QueryStoreCollector.Instance.BuildPerItemQuery("we]ird", MakeContext(watermark: watermark)); + + /* Line-ending agnostic: pin the bracket escaping + the sp_executesql shape. */ + Assert.StartsWith("EXECUTE [we]]ird].sys.sp_executesql", plan.Text.TrimStart('\r', '\n'), StringComparison.Ordinal); + Assert.Contains("WHERE qsrs.last_execution_time > @cutoff_time", plan.Text, StringComparison.Ordinal); + /* #1565: NO SQL-side self-exclusion — the old NOT LIKE was 75% of the read's elapsed time (full + nvarchar(max) scan per row on a column no index can serve; field A/B: 4.3x without it), and no + predicate shape fixes a residual text scan. The exclusion is client-side in ReadItemAsync, + where the text is already materialized (pinned below). The query still CONTAINS the marker — + in its own leading comment. */ + Assert.DoesNotContain("NOT LIKE", plan.Text, StringComparison.Ordinal); + Assert.Contains("OPTION(RECOMPILE, LOOP JOIN);", plan.Text, StringComparison.Ordinal); + Assert.Contains("N'@cutoff_time datetime2(7)',", plan.Text, StringComparison.Ordinal); + + var parameter = Assert.Single(plan.Parameters); + Assert.Equal("@cutoff_time", parameter.Name); + Assert.Equal(watermark, parameter.Value); + Assert.Equal(CollectorParameterType.DateTime2, parameter.Type); + } + + [Fact] + public void BuildPerItemQuery_PlanCapture_OffEmitsNullPlaceholder_OnMirrorsDashboard() + { + /* Lite parity (default off): query_plan_text is the nvarchar(1) NULL placeholder, + byte-identical to the no-plan form. Darling (on): CONVERT(nvarchar(max), qsp.query_plan) + from sys.query_store_plan — install/09_collect_query_store.sql's @collect_plan path. */ + var off = QueryStoreCollector.Instance.BuildPerItemQuery("SO", MakeContext()); + Assert.Contains("query_plan_text = CONVERT(nvarchar(1), NULL),", off.Text, StringComparison.Ordinal); + Assert.DoesNotContain("qsp.query_plan,", off.Text, StringComparison.Ordinal); + + /* #1556 plan-text dedupe (ON branch): the plan lands once per plan_id per cycle — on the newest + runtime-stats interval (rn = 1) — and NULL on the older intervals, instead of the full plan XML + repeating on every interval row. */ + var on = QueryStoreCollector.Instance.BuildPerItemQuery("SO", MakeContext(capturePlanXml: true)); + Assert.Contains( + "query_plan_text = CASE WHEN ROW_NUMBER() OVER (PARTITION BY qsp.plan_id ORDER BY qsrs.last_execution_time DESC) = 1 THEN CONVERT(nvarchar(max), qsp.query_plan) ELSE CONVERT(nvarchar(max), NULL) END,", + on.Text, + StringComparison.Ordinal); + + /* Scoped to query_plan_text rather than the bare placeholder: replica_role shares the same + nvarchar(1) NULL idiom on a pre-2022 target (this context's probe defaults to 13), so an + unqualified DoesNotContain would assert on an unrelated column. */ + Assert.DoesNotContain("query_plan_text = CONVERT(nvarchar(1), NULL)", on.Text, StringComparison.Ordinal); + } + + [Fact] + public void BuildPerItemQuery_RowCapAndOrderByDesc_BoundBothCaptureModes() + { + /* #1556: a per-database server-side backstop — TOP (50000) keeps only the newest rows and + ORDER BY last_execution_time DESC makes "newest" deterministic. Present in BOTH capture modes: + the ORDER BY is load-bearing for the client byte budget's early-stop too (it keeps the newest + rows when the reader is cut short). */ + foreach (var plan in new[] + { + QueryStoreCollector.Instance.BuildPerItemQuery("SO", MakeContext()), + QueryStoreCollector.Instance.BuildPerItemQuery("SO", MakeContext(capturePlanXml: true)), + }) + { + Assert.Contains($"TOP ({QueryStoreCollector.MaxRowsPerDatabase})", plan.Text, StringComparison.Ordinal); + Assert.Contains("ORDER BY qsrs.last_execution_time DESC", plan.Text, StringComparison.Ordinal); + /* The row-bounding ORDER BY sits before the existing query hint, which the OPTION pin still checks. */ + Assert.Contains("OPTION(RECOMPILE, LOOP JOIN);", plan.Text, StringComparison.Ordinal); + } + + Assert.Equal(50_000, QueryStoreCollector.MaxRowsPerDatabase); + } + + [Fact] + public void PerDatabaseBounds_ExposeWatermarkColumnAndBudgets() + { + /* #1556: query_store now flushes per database, so its watermark is per-database (keyed on + database_name — the deadlocks/BPR precedent), and it advertises both the row-cap warn threshold + and the 256MB client byte budget so the host can surface the WARNING and the definition can + enforce the early-stop. */ + Assert.Equal("database_name", QueryStoreCollector.Instance.PerDatabaseWatermarkColumn); + Assert.Equal(QueryStoreCollector.MaxRowsPerDatabase, QueryStoreCollector.Instance.PerItemRowCountWarnThreshold); + Assert.Equal(QueryStoreCollector.MaxTextBytesPerDatabase, QueryStoreCollector.Instance.PerItemTextByteBudget); + Assert.Equal(256 * 1024 * 1024, QueryStoreCollector.MaxTextBytesPerDatabase); + } + + [Fact] + public async Task ReadItemAsync_ResetsTextBudgetSignal_AndNormalRowsDoNotTripIt() + { + /* #1556: ReadItemAsync resets the per-item truncation signal at entry (pre-set here to prove it), + and a normal, small row never trips the 256MB budget — the signal stays false, so the host emits + no spurious WARNING and every row is read. The 256MB early-stop itself is a `>= budget` break + that cannot be sanely driven to a real quarter-gig in a unit test; its threshold is pinned above + and its WARNING wiring is exercised by the shared-driver tests. */ + var context = MakeContext(); + context.PerItemTextBudgetExceeded = true; + + var row = new object[53]; + row[0] = 101L; + row[1] = 202L; + row[2] = "Regular"; + row[3] = new DateTimeOffset(2026, 7, 2, 10, 0, 0, TimeSpan.Zero); + row[4] = new DateTimeOffset(2026, 7, 2, 11, 0, 0, TimeSpan.Zero); + row[5] = "dbo.Proc"; + row[6] = "SELECT 1"; + row[7] = "0xQH"; + row[8] = 33L; + for (int i = 9; i <= 43; i++) row[i] = (long)i; + row[44] = DBNull.Value; + row[45] = "MANUAL"; + row[46] = true; + row[47] = 5L; + row[48] = "NONE"; + row[49] = (short)160; + row[50] = DBNull.Value; + row[51] = "0xPH"; + row[52] = DBNull.Value; + + using var reader = new FakeCollectorDataReader(row); + var rows = new System.Collections.Generic.List<QueryStoreCollector.Row>(); + await QueryStoreCollector.Instance.ReadItemAsync("SO", reader, rows, context, CancellationToken.None); + + Assert.False(context.PerItemTextBudgetExceeded); + Assert.Single(rows); + } + + [Fact] + public async Task ReadItemAsync_DropsSelfMarkerRows_ClientSide() + { + /* #1565: the self-exclusion moved OUT of the SQL (the NOT LIKE was 75% of the read's elapsed + time) and into ReadItemAsync, where the text is already materialized. A row whose query text + carries the marker never enters the batch — not stored, not counted against the byte budget. */ + var context = MakeContext(); + + object[] MakeRow(long queryId, string sqlText) + { + var row = new object[53]; + row[0] = queryId; + row[1] = 202L; + row[2] = "Regular"; + row[3] = new DateTimeOffset(2026, 7, 2, 10, 0, 0, TimeSpan.Zero); + row[4] = new DateTimeOffset(2026, 7, 2, 11, 0, 0, TimeSpan.Zero); + row[5] = "dbo.Proc"; + row[6] = sqlText; + row[7] = "0xQH"; + row[8] = 33L; + for (int i = 9; i <= 43; i++) row[i] = (long)i; + row[44] = DBNull.Value; + row[45] = "MANUAL"; + row[46] = true; + row[47] = 5L; + row[48] = "NONE"; + row[49] = (short)160; + row[50] = DBNull.Value; + row[51] = "0xPH"; + row[52] = DBNull.Value; + return row; + } + + using var reader = new FakeCollectorDataReader( + MakeRow(1, "SELECT 1 FROM UserTable"), + MakeRow(2, "SELECT /* " + QueryStoreCollector.SelfQueryMarker + " */ TOP (50000) query_id = qsq.query_id"), + MakeRow(3, "UPDATE Another SET x = 1")); + var rows = new System.Collections.Generic.List<QueryStoreCollector.Row>(); + await QueryStoreCollector.Instance.ReadItemAsync("SO", reader, rows, context, CancellationToken.None); + + Assert.Equal(2, rows.Count); + Assert.DoesNotContain(rows, r => r.QueryText!.Contains(QueryStoreCollector.SelfQueryMarker, StringComparison.Ordinal)); + Assert.Equal(new[] { 1L, 3L }, System.Linq.Enumerable.Select(rows, r => r.QueryId)); + } + + [Fact] + public void BuildPerItemQuery_NoWatermark_FallsBack60Minutes() + { + var collectionTime = new DateTime(2026, 7, 2, 12, 0, 0, DateTimeKind.Utc); + var plan = QueryStoreCollector.Instance.BuildPerItemQuery("SO", MakeContext(collectionTime: collectionTime)); + + Assert.Equal(collectionTime.AddMinutes(-60), Assert.Single(plan.Parameters).Value); + } + + [Fact] + public void PayloadColumns_MatchSchemaOrder_54Columns() + { + var names = QueryStoreCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray(); + + Assert.Equal(54, names.Length); + Assert.Equal("database_name", names[0]); + Assert.Equal("query_id", names[1]); + Assert.Equal("execution_count", names[9]); + Assert.Equal("avg_num_physical_io_reads", names[36]); + Assert.Equal("plan_type", names[45]); + Assert.Equal("is_forced_plan", names[47]); + Assert.Equal("compatibility_level", names[50]); + Assert.Equal("query_plan_hash", names[52]); + + /* replica_role is pinned LAST deliberately: both hosts' bulk writers are positional, and an + upgraded store receives this column from an ALTER TABLE ADD COLUMN, which can only append. + Moving it earlier would desync a fresh store (DDL generated from this list) from an upgraded + one. See the CollectorColumn comment in QueryStoreCollector. */ + Assert.Equal("replica_role", names[53]); + } + + [Fact] + public async Task ReadItemAsync_WritePayload_Pins54ColumnOrder_AndTypeCoercions() + { + var context = MakeContext(); + var firstExec = new DateTimeOffset(2026, 7, 2, 10, 0, 0, TimeSpan.FromHours(-4)); + var lastExec = new DateTimeOffset(2026, 7, 2, 11, 0, 0, TimeSpan.FromHours(-4)); + + var row = new object[53]; + row[0] = 101L; /* query_id */ + row[1] = 202L; /* plan_id */ + row[2] = "Regular"; /* execution_type_desc */ + row[3] = firstExec; /* first_execution_time (datetimeoffset) */ + row[4] = lastExec; /* last_execution_time (datetimeoffset) */ + row[5] = "dbo.Proc"; /* module_name */ + row[6] = "SELECT 1"; /* query_sql_text */ + row[7] = "0xQH"; /* query_hash */ + row[8] = 33L; /* count_executions */ + row[9] = 123.7d; /* avg_duration: float catalog value -> (long) */ + row[10] = 456.9f; /* min_duration: single -> (long) */ + row[11] = 789m; /* max_duration: decimal -> (long) */ + row[12] = 42; /* avg_cpu_time: int passthrough */ + row[13] = (short)7; /* min_cpu_time: short passthrough */ + row[14] = DBNull.Value; /* max_cpu_time: NULL -> 0 */ + for (int i = 15; i <= 43; i++) row[i] = (long)i; + row[44] = DBNull.Value; /* plan_type (pre-2022) */ + row[45] = "MANUAL"; /* plan_forcing_type */ + row[46] = true; /* is_forced_plan */ + row[47] = 5L; /* force_failure_count */ + row[48] = "NONE"; /* last_force_failure_reason */ + row[49] = (short)160; /* compatibility_level: smallint -> int */ + row[50] = DBNull.Value; /* query_plan_text (always NULL literal) */ + row[51] = "0xPH"; /* query_plan_hash */ + row[52] = "Secondary"; /* replica_role (2022+ attributed the row to a secondary) */ + + using var reader = new FakeCollectorDataReader(row); + var rows = new System.Collections.Generic.List<QueryStoreCollector.Row>(); + await QueryStoreCollector.Instance.ReadItemAsync("SO", reader, rows, context, CancellationToken.None); + + var writer = new RecordingCollectorRowWriter(); + QueryStoreCollector.Instance.WritePayload(Assert.Single(rows), writer, context); + + Assert.Equal(54, writer.Values.Count); + Assert.Equal("SO", writer.Values[0]); /* enumerated item leads the payload */ + Assert.Equal(101L, writer.Values[1]); + Assert.Equal(firstExec.UtcDateTime, writer.Values[4]); /* datetimeoffset -> UTC DateTime */ + Assert.Equal(123L, writer.Values[10]); /* double 123.7 truncated */ + Assert.Equal(456L, writer.Values[11]); /* float 456.9 truncated */ + Assert.Equal(789L, writer.Values[12]); + Assert.Equal(42L, writer.Values[13]); + Assert.Equal(7L, writer.Values[14]); + Assert.Equal(0L, writer.Values[15]); /* NULL stat -> 0 */ + Assert.Null(writer.Values[45]); /* plan_type NULL */ + Assert.Equal(true, writer.Values[47]); + Assert.Equal(160, writer.Values[50]); /* smallint compat -> int */ + Assert.Null(writer.Values[51]); + Assert.Equal("0xPH", writer.Values[52]); + Assert.Equal("Secondary", writer.Values[53]); /* replica_role rides last, after query_plan_hash */ + Assert.Empty(s_deltas.Calls); /* incremental snapshot — no deltas */ + } +} diff --git a/Lite.Tests/RollingCountAlertGateTests.cs b/Lite.Tests/RollingCountAlertGateTests.cs index 45c6ef184..1c605fbfc 100644 --- a/Lite.Tests/RollingCountAlertGateTests.cs +++ b/Lite.Tests/RollingCountAlertGateTests.cs @@ -6,7 +6,7 @@ * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ -using PerformanceMonitorLite.Services; +using PerformanceMonitor.Alerting; using Xunit; namespace PerformanceMonitorLite.Tests diff --git a/Lite.Tests/RunningJobsCollectorDefinitionTests.cs b/Lite.Tests/RunningJobsCollectorDefinitionTests.cs new file mode 100644 index 000000000..660ca4aa7 --- /dev/null +++ b/Lite.Tests/RunningJobsCollectorDefinitionTests.cs @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Pins the parity contract of the extracted running_jobs definition — notably the ONLY collector +/// without a collection_id prefix column, plus the p95/HHMMSS-decode query and 10-column payload. +/// </summary> +public sealed class RunningJobsCollectorDefinitionTests +{ + private static readonly RecordingCollectorDeltaCalculator s_deltas = new(); + + [Fact] + public void NoCollectionId_IsThePinnedException() + { + Assert.False(RunningJobsCollector.Instance.IncludesCollectionId); + /* Every other definition keeps the standard 4-column prefix. */ + Assert.True(WaitStatsCollector.Instance.IncludesCollectionId); + Assert.True(SessionStatsCollector.Instance.IncludesCollectionId); + } + + [Fact] + public void AppliesTo_SkipsAzureSqlDbRdsAndNoMsdb_ButCollectsOnPremAndManagedInstance() + { + /* Azure SQL DB has no SQL Agent (msdb unreachable) — skip rather than log a per-cycle ERROR. */ + Assert.False(RunningJobsCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureSqlDb = true })); + /* AWS RDS blocks msdb.dbo.syssessions (the join needs sysadmin, which RDS never grants) — it would + raise "SELECT permission was denied" every cycle, so skip it there. */ + Assert.False(RunningJobsCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAwsRds = true })); + /* No msdb access → every table this reads lives in msdb; skip (gate collapsed from + IsCollectorSupported into the shared AppliesTo). */ + Assert.False(RunningJobsCollector.Instance.AppliesTo(new CollectorTargetInfo { HasMsdbAccess = false })); + /* Managed Instance has Agent; on-prem collects too. */ + Assert.True(RunningJobsCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureManagedInstance = true })); + Assert.True(RunningJobsCollector.Instance.AppliesTo(new CollectorTargetInfo())); + } + + [Fact] + public void NamesColumnsAndQuery_MatchSchema() + { + Assert.Equal("running_jobs", RunningJobsCollector.Instance.Name); + var query = RunningJobsCollector.Instance.BuildQuery(CollectorTestContext.Make(s_deltas)).Text; + Assert.Contains("msdb.dbo.sysjobactivity", query, StringComparison.Ordinal); + Assert.Contains("PERCENTILE_CONT(0.95)", query, StringComparison.Ordinal); + Assert.Contains("(jh.run_duration / 10000) * 3600", query, StringComparison.Ordinal); + Assert.Equal( + new[] + { + "job_name", "job_id", "job_enabled", "start_time", "current_duration_seconds", + "avg_duration_seconds", "p95_duration_seconds", "successful_run_count", + "is_running_long", "percent_of_average", + }, + RunningJobsCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray()); + } + + [Fact] + public async Task ReadAndWrite_RoundTrip_WithNullPercentOfAverage() + { + var start = new DateTime(2026, 7, 2, 1, 0, 0, DateTimeKind.Utc); + using var reader = new FakeCollectorDataReader( + new object[] { "Nightly ETL", "9F0C", true, start, 4200L, 3600L, 4000L, 30L, true, 116.7m }, + new object[] { "New Job", "AB12", true, start, 60L, 0L, 0L, 0L, false, DBNull.Value }); + + var rows = await RunningJobsCollector.Instance.ReadAsync(reader, CollectorTestContext.Make(s_deltas), CancellationToken.None); + + Assert.Equal(2, rows.Count); + Assert.Null(rows[1].PercentOfAverage); + + var writer = new RecordingCollectorRowWriter(); + RunningJobsCollector.Instance.WritePayload(rows[0], writer, CollectorTestContext.Make(s_deltas)); + Assert.Equal(new object?[] { "Nightly ETL", "9F0C", true, start, 4200L, 3600L, 4000L, 30L, true, 116.7m }, writer.Values); + } +} diff --git a/Lite.Tests/ScenarioTests.cs b/Lite.Tests/ScenarioTests.cs index dac5f5c18..9cefc377d 100644 --- a/Lite.Tests/ScenarioTests.cs +++ b/Lite.Tests/ScenarioTests.cs @@ -517,17 +517,25 @@ public async Task WaitSpikeAnomaly_DetectsPageiolatchFlood() var (stories, facts) = await RunFullPipelineWithAnomaliesAsync(s => s.SeedWaitSpikeAnomalyAsync()); PrintStories("WAIT SPIKE ANOMALY", stories); - Assert.True(facts.ContainsKey("ANOMALY_WAIT_PAGEIOLATCH_SH"), "Should detect PAGEIOLATCH spike"); - Assert.True(facts["ANOMALY_WAIT_PAGEIOLATCH_SH"].Severity >= 0.5, "PAGEIOLATCH anomaly should be significant"); + // Change 1: the per-type ANOMALY_WAIT_<type> facts are replaced by ONE ANOMALY_WAIT_PROFILE, + // with the top wait types named as contrib_<TYPE> metadata. + Assert.True(facts.ContainsKey("ANOMALY_WAIT_PROFILE"), "Should detect a wait-profile shift"); + Assert.True(facts["ANOMALY_WAIT_PROFILE"].Severity >= 0.5, "Wait-profile anomaly should be significant"); + Assert.True(facts["ANOMALY_WAIT_PROFILE"].Metadata.ContainsKey("contrib_PAGEIOLATCH_SH"), + "PAGEIOLATCH_SH should be named as a wait-profile contributor"); } [Fact] - public async Task WaitSpikeAnomaly_HighRatio() + public async Task WaitSpikeAnomaly_NamesPageiolatchAsDominantContributor() { var (_, facts) = await RunFullPipelineWithAnomaliesAsync(s => s.SeedWaitSpikeAnomalyAsync()); - var ratio = facts["ANOMALY_WAIT_PAGEIOLATCH_SH"].Metadata["ratio"]; - Assert.True(ratio >= 5.0, $"Expected >= 5x increase, got {ratio:F1}x"); + var profile = facts["ANOMALY_WAIT_PROFILE"]; + var contribs = profile.Metadata.Where(kvp => kvp.Key.StartsWith("contrib_", StringComparison.Ordinal)).ToList(); + Assert.NotEmpty(contribs); + // The flood is PAGEIOLATCH_SH — it must be the largest contributor in the profile. + var top = contribs.OrderByDescending(kvp => kvp.Value).First(); + Assert.Equal("contrib_PAGEIOLATCH_SH", top.Key); } /* ── Helpers ── */ diff --git a/Lite.Tests/ServerManagerDetectionQueryTests.cs b/Lite.Tests/ServerManagerDetectionQueryTests.cs new file mode 100644 index 000000000..c4199811f --- /dev/null +++ b/Lite.Tests/ServerManagerDetectionQueryTests.cs @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// <summary> +/// #1535 regression guard: the connectivity probe's edition-detection query must classify a server +/// (engine edition, version, RDS, msdb access) from permission-free scalars ONLY, never from +/// <c>sys.dm_os_sys_info</c>. On Azure SQL DB that DMV requires VIEW DATABASE STATE; when a +/// monitoring login lacked it, the old combined query threw and left EngineEdition = 0, so +/// <c>IsAzureSqlDb</c> read false and every collector ran its on-prem shape (erroring on +/// dm_os_sys_memory / sys.traces / dm_xe_session_targets / msdb). This pins the split so detection +/// can never re-couple to the DMV; <c>sqlserver_start_time</c> (the one column that needs the DMV) +/// lives in its own best-effort read. +/// </summary> +public class ServerManagerDetectionQueryTests +{ + [Fact] + public void DetectionQuery_DoesNotDependOnDmOsSysInfo() + { + Assert.DoesNotContain("dm_os_sys_info", ServerManager.DetectionQueryText, StringComparison.Ordinal); + // sqlserver_start_time is a column of that DMV, so it must not ride the detection query either. + Assert.DoesNotContain("sqlserver_start_time", ServerManager.DetectionQueryText, StringComparison.Ordinal); + } + + [Fact] + public void DetectionQuery_DetectsEngineEditionViaServerProperty() + { + Assert.Contains("SERVERPROPERTY('EngineEdition')", ServerManager.DetectionQueryText, StringComparison.Ordinal); + } + + [Fact] + public void StartTimeQuery_IsolatesTheOnlyReadThatNeedsDmOsSysInfo() + { + Assert.Contains("sqlserver_start_time", ServerManager.ServerStartTimeQueryText, StringComparison.Ordinal); + Assert.Contains("sys.dm_os_sys_info", ServerManager.ServerStartTimeQueryText, StringComparison.Ordinal); + } +} diff --git a/Lite.Tests/ServerOverviewSortTests.cs b/Lite.Tests/ServerOverviewSortTests.cs new file mode 100644 index 000000000..04b63151e --- /dev/null +++ b/Lite.Tests/ServerOverviewSortTests.cs @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using PerformanceMonitor.Ui; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// <summary> +/// Pins <see cref="ServerOverviewSort"/> — the pure, total-order comparer that orders the NOC Overview tiles +/// in BOTH apps (Lite and the Darling viewer). Mirror of Darling.Tests' copy (the ChartStyle precedent: the +/// shared pure function is tested identically in each suite). Uses a local <c>Row</c> test-double because +/// each app binds its own unrelated ServerSummaryItem type; only the three sorted members matter. +/// </summary> +public class ServerOverviewSortTests +{ + /// <summary>Stand-in for each app's ServerSummaryItem — CPU% (the alert-band value), display name, id.</summary> + private sealed record Row(double? Cpu, string Name, int Id); + + private static List<Row> Order(IEnumerable<Row> rows, ServerOverviewSortMode mode) => + ServerOverviewSort.Order(rows, mode, r => r.Cpu, r => r.Name, r => r.Id); + + [Fact] + public void Cpu_OrdersByCpuDescending() + { + var rows = new[] + { + new Row(12.0, "b", 1), + new Row(90.0, "a", 2), + new Row(45.0, "c", 3), + }; + + var ordered = Order(rows, ServerOverviewSortMode.Cpu); + + Assert.Equal(new[] { 2, 3, 1 }, ordered.Select(r => r.Id)); + } + + [Fact] + public void Cpu_NoSampleServersSinkLast_ThenOrderedByName() + { + var rows = new[] + { + new Row(null, "zeta", 1), + new Row(5.0, "busy", 2), + new Row(null, "alpha", 3), + }; + + var ordered = Order(rows, ServerOverviewSortMode.Cpu); + + // Has-sample first (busy), then the two null-CPU servers by name (alpha before zeta). + Assert.Equal(new[] { 2, 3, 1 }, ordered.Select(r => r.Id)); + } + + [Fact] + public void Cpu_RealZeroPercent_OutranksNoSample() + { + // A genuine 0.0% has a sample and must sort ABOVE a null (no-sample) server, proving the `?? 0.0` + // fallback only ever bites inside the already-sunk no-sample partition — a real 0.0% is unaffected. + var rows = new[] + { + new Row(null, "nosample", 1), + new Row(0.0, "idle", 2), + }; + + var ordered = Order(rows, ServerOverviewSortMode.Cpu); + + Assert.Equal(new[] { 2, 1 }, ordered.Select(r => r.Id)); + } + + [Fact] + public void Cpu_TiesBreakByName_CaseInsensitive() + { + var rows = new[] + { + new Row(50.0, "Bravo", 1), + new Row(50.0, "alpha", 2), + new Row(50.0, "Charlie", 3), + }; + + var ordered = Order(rows, ServerOverviewSortMode.Cpu); + + // Equal CPU -> case-insensitive name asc: alpha, Bravo, Charlie. + Assert.Equal(new[] { 2, 1, 3 }, ordered.Select(r => r.Id)); + } + + [Fact] + public void Cpu_SameCpuAndName_BreaksByStableId() + { + var rows = new[] + { + new Row(50.0, "same", 30), + new Row(50.0, "same", 10), + new Row(50.0, "SAME", 20), + }; + + var ordered = Order(rows, ServerOverviewSortMode.Cpu); + + // Equal CPU, name equal case-insensitively -> id ascending. + Assert.Equal(new[] { 10, 20, 30 }, ordered.Select(r => r.Id)); + } + + [Fact] + public void Cpu_IsATotalOrder_IndependentOfInputEnumeration() + { + var rows = new[] + { + new Row(50.0, "same", 10), + new Row(50.0, "same", 20), + new Row(null, "x", 30), + new Row(70.0, "hot", 40), + }; + + var forward = Order(rows, ServerOverviewSortMode.Cpu).Select(r => r.Id).ToList(); + var reversed = Order(rows.Reverse(), ServerOverviewSortMode.Cpu).Select(r => r.Id).ToList(); + + Assert.Equal(forward, reversed); + } + + [Fact] + public void Name_OrdersByNameAscending_IgnoringCpu() + { + var rows = new[] + { + new Row(99.0, "charlie", 1), + new Row(1.0, "Alpha", 2), + new Row(null, "bravo", 3), + }; + + var ordered = Order(rows, ServerOverviewSortMode.Name); + + // Name mode: case-insensitive name asc, regardless of CPU value or a null CPU sample. + Assert.Equal(new[] { 2, 3, 1 }, ordered.Select(r => r.Id)); + } + + [Fact] + public void Name_TiesBreakByStableId() + { + var rows = new[] + { + new Row(10.0, "dup", 3), + new Row(20.0, "dup", 1), + new Row(30.0, "dup", 2), + }; + + var ordered = Order(rows, ServerOverviewSortMode.Name); + + Assert.Equal(new[] { 1, 2, 3 }, ordered.Select(r => r.Id)); + } + + [Fact] + public void Order_EmptyInput_ReturnsEmpty() + { + Assert.Empty(Order(Array.Empty<Row>(), ServerOverviewSortMode.Cpu)); + Assert.Empty(Order(Array.Empty<Row>(), ServerOverviewSortMode.Name)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("bogus")] + [InlineData("5")] + public void ParseMode_UnrecognisedOrEmpty_FallsBackToCpu(string? token) + { + Assert.Equal(ServerOverviewSortMode.Cpu, ServerOverviewSort.ParseMode(token)); + } + + [Theory] + [InlineData("Cpu", ServerOverviewSortMode.Cpu)] + [InlineData("cpu", ServerOverviewSortMode.Cpu)] + [InlineData("Name", ServerOverviewSortMode.Name)] + [InlineData("name", ServerOverviewSortMode.Name)] + [InlineData("NAME", ServerOverviewSortMode.Name)] + public void ParseMode_RecognisedTokens_AreCaseInsensitive(string token, ServerOverviewSortMode expected) + { + Assert.Equal(expected, ServerOverviewSort.ParseMode(token)); + } + + [Theory] + [InlineData(ServerOverviewSortMode.Cpu)] + [InlineData(ServerOverviewSortMode.Name)] + public void ToToken_RoundTripsThroughParseMode(ServerOverviewSortMode mode) + { + Assert.Equal(mode, ServerOverviewSort.ParseMode(ServerOverviewSort.ToToken(mode))); + } + + [Fact] + public void Default_IsCpu() + { + Assert.Equal(ServerOverviewSortMode.Cpu, ServerOverviewSort.Default); + } +} diff --git a/Lite.Tests/ServerPropertiesCollectorDefinitionTests.cs b/Lite.Tests/ServerPropertiesCollectorDefinitionTests.cs new file mode 100644 index 000000000..fa77fbde7 --- /dev/null +++ b/Lite.Tests/ServerPropertiesCollectorDefinitionTests.cs @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Pins the parity contract of the extracted server_properties definition: the vCore parse rules, +/// the Azure-only vCore application, the supplemental WS5 health probe (skipped on Azure, +/// merge-by-replacement, failure leaves NULLs), and the 22-column payload incl. the +/// enterprise_features placeholder Lite never collects and the utc_offset_minutes the viewer's +/// Server-time display mode reads. +/// </summary> +public sealed class ServerPropertiesCollectorDefinitionTests +{ + private static readonly RecordingCollectorDeltaCalculator s_deltas = new(); + + [Theory] + [InlineData("HS_Gen5_14", 14)] + [InlineData("GP_Gen5_6", 6)] + [InlineData("BC_Gen5_8", 8)] + [InlineData("GP_S_Gen5_2", 2)] + [InlineData("P1", null)] + [InlineData("S0", null)] + [InlineData("ElasticPool", null)] + public void ParseVcore_FollowsTierPattern(string serviceObjective, int? expected) + { + Assert.Equal(expected, ServerPropertiesCollector.ParseVcoreFromServiceObjective(serviceObjective)); + } + + [Fact] + public void SupplementalQuery_SkippedOnAzure_PresentOnPrem() + { + Assert.Null(ServerPropertiesCollector.Instance.BuildSupplementalQuery(CollectorTestContext.Make(s_deltas, isAzureSqlDb: true))); + + var plan = ServerPropertiesCollector.Instance.BuildSupplementalQuery(CollectorTestContext.Make(s_deltas)); + Assert.NotNull(plan); + Assert.Contains("sys.dm_server_services", plan!.Text, StringComparison.Ordinal); + Assert.Contains("sys.dm_server_memory_dumps", plan.Text, StringComparison.Ordinal); + Assert.Contains("sql_memory_model IN (2, 3)", plan.Text, StringComparison.Ordinal); + } + + [Fact] + public void NamesAndColumns_MatchDispatchAndSchema() + { + Assert.Equal("server_properties", ServerPropertiesCollector.Instance.Name); + Assert.Equal("server_properties", ServerPropertiesCollector.Instance.TargetTable); + Assert.Equal( + new[] + { + "edition", "product_version", "product_level", "product_update_level", + "engine_edition", "cpu_count", "hyperthread_ratio", "physical_memory_mb", + "socket_count", "cores_per_socket", "is_hadr_enabled", "is_clustered", + "enterprise_features", "service_objective", "vcore_count", + "lock_pages_in_memory", "instant_file_initialization_enabled", "memory_dump_count", + "sqlserver_start_time", "host_os_version", "ag_replica_role", "utc_offset_minutes", + }, + ServerPropertiesCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray()); + } + + [Fact] + public async Task ReadAsync_Azure_ParsesVcore_FromServiceObjective() + { + using var reader = new FakeCollectorDataReader(AzureRow("GP_Gen5_6")); + + var rows = await ServerPropertiesCollector.Instance.ReadAsync( + reader, CollectorTestContext.Make(s_deltas, isAzureSqlDb: true), CancellationToken.None); + + var row = Assert.Single(rows); + Assert.Equal(6, row.VcoreCount); + Assert.Equal("Azure SQL Database (General Purpose)", row.Edition); + Assert.Null(row.LockPagesInMemory); + } + + [Fact] + public async Task ReadAsync_NonAzure_NeverParsesVcore_EvenWithObjective() + { + using var reader = new FakeCollectorDataReader(AzureRow("GP_Gen5_6")); + + var rows = await ServerPropertiesCollector.Instance.ReadAsync( + reader, CollectorTestContext.Make(s_deltas, isAzureSqlDb: false), CancellationToken.None); + + Assert.Null(Assert.Single(rows).VcoreCount); + } + + [Fact] + public async Task ApplySupplemental_MergesHealthValues_AndToleratesEmpty() + { + var context = CollectorTestContext.Make(s_deltas); + using var mainReader = new FakeCollectorDataReader(AzureRow(null)); + var rows = await ServerPropertiesCollector.Instance.ReadAsync(mainReader, context, CancellationToken.None); + + using var healthReader = new FakeCollectorDataReader(new object[] { true, false, 3 }); + await ServerPropertiesCollector.Instance.ApplySupplementalAsync(rows, healthReader, context, CancellationToken.None); + + Assert.True(rows[0].LockPagesInMemory); + Assert.False(rows[0].InstantFileInitializationEnabled); + Assert.Equal(3, rows[0].MemoryDumpCount); + + /* Empty supplemental reader: values stay as-is; empty rows: no-op. */ + using var emptyReader = new FakeCollectorDataReader(); + await ServerPropertiesCollector.Instance.ApplySupplementalAsync(rows, emptyReader, context, CancellationToken.None); + Assert.True(rows[0].LockPagesInMemory); + await ServerPropertiesCollector.Instance.ApplySupplementalAsync(new System.Collections.Generic.List<ServerPropertiesCollector.Row>(), emptyReader, context, CancellationToken.None); + } + + [Fact] + public async Task WritePayload_Emits22Columns_WithNullEnterpriseFeatures() + { + using var reader = new FakeCollectorDataReader(AzureRow("HS_Gen5_14")); + var rows = await ServerPropertiesCollector.Instance.ReadAsync( + reader, CollectorTestContext.Make(s_deltas, isAzureSqlDb: true), CancellationToken.None); + + var writer = new RecordingCollectorRowWriter(); + ServerPropertiesCollector.Instance.WritePayload(rows[0], writer, CollectorTestContext.Make(s_deltas)); + + Assert.Equal(22, writer.Values.Count); + Assert.Equal("Azure SQL Database (General Purpose)", writer.Values[0]); + Assert.Null(writer.Values[12]); /* enterprise_features — never collected in Lite */ + Assert.Equal("HS_Gen5_14", writer.Values[13]); + Assert.Equal(14, writer.Values[14]); + /* The three inventory columns appended in v36 (#1372). */ + Assert.Equal(new DateTime(2026, 6, 1), writer.Values[18]); + Assert.Equal("Windows Server 2022", writer.Values[19]); + Assert.Null(writer.Values[20]); /* ag_replica_role — standalone in the fixture */ + Assert.Equal(-300, writer.Values[21]); /* utc_offset_minutes — the viewer's Server-time offset */ + } + + /// <summary>18-column main-query row; index 0 (server_name) is read past by the definition.</summary> + private static object[] AzureRow(string? serviceObjective) => new object[] + { + "myserver", "Azure SQL Database (General Purpose)", "12.0.2000.8", "RTM", DBNull.Value, + 5, 80, 8, 415800L, DBNull.Value, DBNull.Value, false, false, + serviceObjective is null ? DBNull.Value : serviceObjective, + /* v36 inventory columns (#1372): sqlserver_start_time(14), host_os_version(15), ag_replica_role(16). + v42 (#1409): utc_offset_minutes(17). */ + new DateTime(2026, 6, 1), "Windows Server 2022", DBNull.Value, -300, + }; +} diff --git a/Lite.Tests/ServerTabCapabilityPinTests.cs b/Lite.Tests/ServerTabCapabilityPinTests.cs new file mode 100644 index 000000000..efa0ea0ef --- /dev/null +++ b/Lite.Tests/ServerTabCapabilityPinTests.cs @@ -0,0 +1,257 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// THE CAPABILITY-INVENTORY PIN (parity board — front-end SHELL COLLAPSE, Lite side). The upcoming collapse +/// hoists duplicated code-behind out of <c>Lite/Controls/ServerTab.*</c> (and the history/drill windows it +/// opens) into shared <c>Ui</c>/<c>Common</c>. WPF's generated <c>Connect()</c> only errors on a DANGLING +/// handler reference (XAML naming a method that does not exist); it does NOT catch REMOVING the XAML wire, +/// nor a dropped code-behind <c>new DataGridFilterManager<T>()</c> / chart-menu wiring / a grid's +/// <c>RowStyle</c> (how its whole context menu attaches). Those are the "went missing" failure modes. +/// +/// MECHANISM = RATCHET, not golden (mirrors <see cref="CollectorViewerCoverageTests"/> / +/// <see cref="CrossAppMcpToolInventoryPinTests"/> — a subset assertion + a non-vacuous floor + a scan +/// self-check, NOT an exact snapshot). Each section captures a capability SET from the CURRENT source and +/// asserts the committed BASELINE is a SUBSET of it: a captured capability may never disappear, but NEW ones +/// are free (no regen needed as later waves add grids/tabs). Deliberately removing a capability is an +/// explicit baseline edit in the same PR — never a silent wholesale regenerate (the trap an exact golden +/// falls into when a PR both adds and drops). Regenerate the baselines ONLY on a real, intended surface +/// change by running with <c>PM_PIN_REGEN=1</c>. +/// +/// Text-scans SOURCE (XAML + .cs), located from this file's compile-time path — NO WPF / DuckDB / assembly +/// load, exactly like the other parity pins. +/// </summary> +public sealed class ServerTabCapabilityPinTests +{ + /* ---------------- section facts ---------------- */ + + [Fact] + public void Tabs_NoneDisappeared() + { + var tabs = ScanTabs(); + AssertRatchet("tabs", tabs, floor: 15); + } + + [Fact] + public void FilterManagers_NoneDisappeared() + { + var (mgrs, primitives, matches) = ScanFilterManagers(); + Assert.True(matches == primitives, + $"filter-manager scan under-matched: {primitives} `new DataGridFilterManager<` but {matches} carried a " + + "parseable grid arg. A ctor takes a non-identifier first arg — tighten the scan before trusting this pin."); + AssertRatchet("filtermgrs", mgrs, floor: 20); + } + + [Fact] + public void ChartWiring_NoneDisappeared() + { + var charts = ScanChartWiring(); + AssertRatchet("chartwiring", charts, floor: 15); + } + + [Fact] + public void ContextMenuCommands_NoneDisappeared() + { + var cmds = ScanMenuCommands(); + AssertRatchet("menucmds", cmds, floor: 15); + } + + [Fact] + public void GridStyleMenuMap_NoneDisappeared() + { + // Section E: which grid POINTS at which RowStyle (its whole context menu attaches via RowStyle). + // Section D sees the menu resource; only THIS sees the grid->menu binding. Drop a grid's RowStyle and + // its `Grid=>StyleKey` pair flips to `Grid=>(none)` -> the baseline pair vanishes -> red. + var map = ScanGridStyleMap(); + AssertRatchet("gridstyle", map, floor: 20); + } + + [Fact] + public void GridEventWires_NoneDisappeared() + { + // Section F: XAML MouseDoubleClick/SelectionChanged wires (grid double-click opens plan tabs / drill; + // MainTabControl SelectionChanged is the routing wire). Removing the wire compiles clean — not + // compiler-caught in the removal direction. + var wires = ScanEventWires(); + AssertRatchet("eventwires", wires, floor: 6); + } + + /* ---------------- scans ---------------- */ + + private static ISet<string> ScanTabs() + { + var xaml = ServerTabXaml(); + return Regex.Matches(xaml, @"<TabItem\b[^>]*?\bHeader=""([^""]+)""") + .Select(m => m.Groups[1].Value.Trim()) + .Where(s => s.Length > 0) + .ToHashSet(StringComparer.Ordinal); + } + + private static (ISet<string> names, int primitives, int matches) ScanFilterManagers() + { + // File-qualified so a per-site drop is visible even when two files bind the same grid name + // (the history windows all bind "HistoryDataGrid"). ServerTab.* + the windows it opens (P4: the pin's + // protected surface must be a superset of the collapse's edit surface — C1 de-dups the window filter + // plumbing). + var names = new HashSet<string>(StringComparer.Ordinal); + int primitives = 0, matches = 0; + foreach (var file in ServerTabCsFiles().Concat(WindowCsFiles())) + { + var src = File.ReadAllText(file); + primitives += Regex.Matches(src, @"new\s+DataGridFilterManager<").Count; + foreach (Match m in Regex.Matches(src, @"new\s+DataGridFilterManager<[^>]+>\s*\(\s*(\w+)")) + { + matches++; + names.Add($"{Path.GetFileName(file)}:{m.Groups[1].Value}"); + } + } + return (names, primitives, matches); + } + + private static ISet<string> ScanChartWiring() + { + // The chart FIELD NAME as first arg to ANY wiring primitive — anchored on the chart, not a specific + // builder method, so a later rename of the builder does not trip the pin (plan D3/D4). + var src = string.Join("\n", ServerTabCsFiles().Select(File.ReadAllText)); + return Regex.Matches(src, + @"(?:SetupChartContextMenu|BuildChartContextMenu|AddChartDrillDownMenuItem|AddWaitDrillDownMenuItem|WireChartContextMenus|WireChartDrillDowns)\s*\(\s*(\w+)") + .Select(m => m.Groups[1].Value) + .Where(n => n.EndsWith("Chart", StringComparison.Ordinal)) + .ToHashSet(StringComparer.Ordinal); + } + + private static ISet<string> ScanMenuCommands() + { + // Menu-QUALIFIED (key:Header=>Click) so the shared Copy/Export commands — byte-identical across the + // three grid menus — do not dedup into one, AND a command dropped from ONE specific menu is caught. + var xaml = ServerTabXaml(); + var cmds = new HashSet<string>(StringComparer.Ordinal); + foreach (Match cm in Regex.Matches(xaml, @"<ContextMenu\b[^>]*?\bx:Key=""(\w+)""(.*?)</ContextMenu>", RegexOptions.Singleline)) + { + var key = cm.Groups[1].Value; + foreach (Match mi in Regex.Matches(cm.Groups[2].Value, @"<MenuItem\b[^>]*?>", RegexOptions.Singleline)) + { + var tag = mi.Value; + var h = Regex.Match(tag, @"\bHeader=""([^""]+)"""); + var c = Regex.Match(tag, @"\bClick=""(\w+)"""); + if (h.Success && c.Success) + { + cmds.Add($"{key}:{h.Groups[1].Value}=>{c.Groups[1].Value}"); + } + } + } + return cmds; + } + + private static ISet<string> ScanGridStyleMap() + { + var xaml = ServerTabXaml(); + var map = new HashSet<string>(StringComparer.Ordinal); + foreach (Match dg in Regex.Matches(xaml, @"<DataGrid\b[^>]*?>", RegexOptions.Singleline)) + { + var tag = dg.Value; + var name = Regex.Match(tag, @"\bx:Name=""(\w+)"""); + if (!name.Success) + { + continue; + } + var style = Regex.Match(tag, @"\bRowStyle=""\{(?:StaticResource|DynamicResource)\s+(\w+)\}"""); + map.Add($"{name.Groups[1].Value}=>{(style.Success ? style.Groups[1].Value : "(none)")}"); + } + return map; + } + + private static ISet<string> ScanEventWires() + { + // BOTH wiring styles: XAML `Event="Handler"` attributes AND code-behind `element.Event += Handler` + // (Darling wires its history-grid double-clicks in code; catch that style too for parity + robustness). + var wires = new HashSet<string>(StringComparer.Ordinal); + foreach (Match m in Regex.Matches(ServerTabXaml(), @"\b(MouseDoubleClick|SelectionChanged)=""(\w+)""")) + { + wires.Add($"{m.Groups[1].Value}=>{m.Groups[2].Value}"); + } + foreach (var file in ServerTabCsFiles()) + { + foreach (Match m in Regex.Matches(File.ReadAllText(file), @"\.(MouseDoubleClick|SelectionChanged)\s*\+=\s*(\w+)")) + { + wires.Add($"{m.Groups[1].Value}=>{m.Groups[2].Value}"); + } + } + return wires; + } + + /* ---------------- ratchet + source location ---------------- */ + + private static void AssertRatchet(string section, ISet<string> current, int floor) + { + Assert.True(current.Count >= floor, + $"capability section '{section}' scanned only {current.Count} items (< floor {floor}) — the scan is " + + "likely broken (source moved / pattern drift). Fix the scan; do not lower the floor."); + + var file = Path.Combine(BaselineDir(), $"servertab-{section}.baseline.txt"); + + if (Regen()) + { + Directory.CreateDirectory(BaselineDir()); + File.WriteAllLines(file, current.OrderBy(x => x, StringComparer.Ordinal)); + return; + } + + Assert.True(File.Exists(file), + $"baseline '{Path.GetFileName(file)}' is missing — run the pin once with environment variable " + + "PM_PIN_REGEN=1 to generate it, then commit it."); + + var baseline = File.ReadAllLines(file) + .Select(l => l.Trim()) + .Where(l => l.Length > 0 && !l.StartsWith("#", StringComparison.Ordinal)) + .ToHashSet(StringComparer.Ordinal); + + var missing = baseline.Where(b => !current.Contains(b)) + .OrderBy(x => x, StringComparer.Ordinal) + .ToList(); + + Assert.True(missing.Count == 0, + $"capability section '{section}': item(s) in the baseline are NO LONGER present in the source — a " + + "capability silently went missing during the collapse:\n " + + string.Join("\n ", missing) + + $"\nIf this removal is INTENTIONAL, delete those lines from Lite.Tests/CapabilityPins/" + + $"servertab-{section}.baseline.txt in THIS PR (never regenerate wholesale — that masks accidental drops)."); + } + + private static bool Regen() => + string.Equals(Environment.GetEnvironmentVariable("PM_PIN_REGEN"), "1", StringComparison.Ordinal); + + private static string ServerTabXaml() => + File.ReadAllText(Path.Combine(ControlsDir(), "ServerTab.xaml")); + + private static IEnumerable<string> ServerTabCsFiles() => + Directory.EnumerateFiles(ControlsDir(), "*.cs", SearchOption.TopDirectoryOnly) + .Where(f => Path.GetFileName(f).StartsWith("ServerTab", StringComparison.OrdinalIgnoreCase)); + + private static IEnumerable<string> WindowCsFiles() => + Directory.EnumerateFiles(WindowsDir(), "*.xaml.cs", SearchOption.TopDirectoryOnly); + + private static string ControlsDir([CallerFilePath] string thisFile = "") => + Path.GetFullPath(Path.Combine(Path.GetDirectoryName(thisFile)!, "..", "Lite", "Controls")); + + private static string WindowsDir([CallerFilePath] string thisFile = "") => + Path.GetFullPath(Path.Combine(Path.GetDirectoryName(thisFile)!, "..", "Lite", "Windows")); + + private static string BaselineDir([CallerFilePath] string thisFile = "") => + Path.Combine(Path.GetDirectoryName(thisFile)!, "CapabilityPins"); +} diff --git a/Lite.Tests/SessionAndWaitingTasksCollectorDefinitionTests.cs b/Lite.Tests/SessionAndWaitingTasksCollectorDefinitionTests.cs new file mode 100644 index 000000000..92c147f89 --- /dev/null +++ b/Lite.Tests/SessionAndWaitingTasksCollectorDefinitionTests.cs @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary>Pins the parity contracts of the batch-9 definitions.</summary> +public sealed class SessionStatsCollectorDefinitionTests +{ + private static readonly RecordingCollectorDeltaCalculator s_deltas = new(); + + [Fact] + public void NamesColumnsAndQuery_MatchSchema() + { + Assert.Equal("session_stats", SessionStatsCollector.Instance.Name); + var query = SessionStatsCollector.Instance.BuildQuery(CollectorTestContext.Make(s_deltas)).Text; + Assert.Contains("sys.dm_exec_sessions", query, StringComparison.Ordinal); + Assert.Contains("NOT LIKE N'PerformanceMonitor%'", query, StringComparison.Ordinal); + Assert.Equal( + new[] + { + "program_name", "connection_count", "running_count", "sleeping_count", "dormant_count", + "total_cpu_time_ms", "total_reads", "total_writes", "total_logical_reads", + }, + SessionStatsCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray()); + } + + [Fact] + public async Task ReadAndWrite_RoundTrip_WithNullableSums() + { + using var reader = new FakeCollectorDataReader( + new object[] { "HammerDB", 25L, 5, 20, 0, 123456L, 9L, 8L, DBNull.Value }); + + var rows = await SessionStatsCollector.Instance.ReadAsync(reader, CollectorTestContext.Make(s_deltas), CancellationToken.None); + + var row = Assert.Single(rows); + Assert.Null(row.TotalLogicalReads); + + var writer = new RecordingCollectorRowWriter(); + SessionStatsCollector.Instance.WritePayload(row, writer, CollectorTestContext.Make(s_deltas)); + Assert.Equal(new object?[] { "HammerDB", 25L, 5, 20, 0, 123456L, 9L, 8L, null }, writer.Values); + } +} + +public sealed class WaitingTasksCollectorDefinitionTests +{ + private static readonly RecordingCollectorDeltaCalculator s_deltas = new(); + + [Fact] + public void BuildQuery_SplicesExclusions_AndTargetsWaitingTasks() + { + var plan = WaitingTasksCollector.Instance.BuildQuery(new CollectorContext + { + ServerId = 42, + ServerName = "test-server", + CollectionTime = DateTime.UtcNow, + Deltas = s_deltas, + ExcludedDatabases = new[] { "SO" }, + }); + + Assert.Contains("sys.dm_os_waiting_tasks", plan.Text, StringComparison.Ordinal); + Assert.Contains("AND d.name NOT IN (@excl_db_0)", plan.Text, StringComparison.Ordinal); + Assert.Equal("SO", Assert.Single(plan.Parameters).Value); + } + + [Fact] + public void PayloadColumns_MatchSchemaOrder() + { + Assert.Equal( + new[] { "session_id", "wait_type", "wait_duration_ms", "blocking_session_id", "resource_description", "database_name" }, + WaitingTasksCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray()); + } + + [Fact] + public async Task ReadAndWrite_WidensSmallints_AndKeepsNullPlaceholder() + { + using var reader = new FakeCollectorDataReader( + new object[] { (short)71, "LCK_M_X", 4500L, (short)55, "StackOverflow" }, + new object[] { (short)72, "PAGEIOLATCH_SH", 20L, DBNull.Value, DBNull.Value }); + + var rows = await WaitingTasksCollector.Instance.ReadAsync(reader, CollectorTestContext.Make(s_deltas), CancellationToken.None); + + Assert.Equal(2, rows.Count); + + var writer = new RecordingCollectorRowWriter(); + WaitingTasksCollector.Instance.WritePayload(rows[0], writer, CollectorTestContext.Make(s_deltas)); + Assert.Equal(new object?[] { 71, "LCK_M_X", 4500L, 55, null, "StackOverflow" }, writer.Values); + + writer.Values.Clear(); + WaitingTasksCollector.Instance.WritePayload(rows[1], writer, CollectorTestContext.Make(s_deltas)); + Assert.Equal(72, writer.Values[0]); /* smallint widened to int */ + Assert.Null(writer.Values[3]); /* null blocking session survives */ + Assert.Null(writer.Values[4]); /* resource_description placeholder */ + } + + [Fact] + public async Task ReadAsync_FiltersIgnoredWaitTypes() + { + /* WAITFOR is a benign wait that (unlike most of the ignore list) surfaces on user sessions + (session_id >= 50), so it is the poster child for the "Current Waits" leak this filter closes: + the collector now skips ignored types just like WaitStatsCollector, so both wait surfaces honor + the same list (Lite: ignored_wait_types.json, Darling: IgnoredWaitDefaults.All). */ + using var reader = new FakeCollectorDataReader( + new object[] { (short)57, "LCK_M_X", 200L, (short)58, "SalesDb" }, + new object[] { (short)60, "WAITFOR", 5000L, DBNull.Value, "SalesDb" }, + new object[] { (short)62, "SOS_SCHEDULER_YIELD", 15L, DBNull.Value, "SalesDb" }); + + var context = CollectorTestContext.Make(s_deltas, ignored: new[] { "WAITFOR" }); + + var rows = await WaitingTasksCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + + Assert.Equal(2, rows.Count); + Assert.Equal(new WaitingTasksCollector.Row(57, "LCK_M_X", 200L, 58, "SalesDb"), rows[0]); + Assert.Equal(new WaitingTasksCollector.Row(62, "SOS_SCHEDULER_YIELD", 15L, null, "SalesDb"), rows[1]); + } +} diff --git a/Lite.Tests/SessionStatsSummaryTests.cs b/Lite.Tests/SessionStatsSummaryTests.cs new file mode 100644 index 000000000..2fc58d9dd --- /dev/null +++ b/Lite.Tests/SessionStatsSummaryTests.cs @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using PerformanceMonitor.Common; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Behavioral pin for the shared <see cref="SessionStatsSummary"/> — the Session Stats summary-strip projection +/// that used to be the byte-identical <c>FormatSessionSummary</c> in Lite (<c>ServerTab.SessionStats.cs</c>) and +/// the Darling viewer (<c>ViewerServerTab.SessionStats.cs</c>). Drives the shared entry point directly over a +/// stub <see cref="ISessionStatsPoint"/> — proving the collapsed shaping still renders "name (count)" for a +/// named Top Application / Top Host, degrades a missing name (null or empty) to "N/A", keeps the distinct- +/// databases count independently, and falls a null connection count back to 0. Pure — no DuckDB, no WPF. +/// </summary> +public class SessionStatsSummaryTests +{ + /// <summary>A zeroed snapshot; each test sets only the summary fields it exercises (the seven status + /// counts are irrelevant to the summary strip).</summary> + private sealed class StubPoint : ISessionStatsPoint + { + public DateTime CollectionTime { get; init; } + public int TotalSessions { get; init; } + public int RunningSessions { get; init; } + public int SleepingSessions { get; init; } + public int BackgroundSessions { get; init; } + public int DormantSessions { get; init; } + public int IdleSessionsOver30Min { get; init; } + public int SessionsWaitingForMemory { get; init; } + public int DatabasesWithConnections { get; init; } + public string? TopApplicationName { get; init; } + public int? TopApplicationConnections { get; init; } + public string? TopHostName { get; init; } + public int? TopHostConnections { get; init; } + } + + [Fact] + public void Format_Null_YieldsNAForAllThree() + { + var (topApp, topHost, databases) = SessionStatsSummary.Format(null); + + Assert.Equal("N/A", topApp); + Assert.Equal("N/A", topHost); + Assert.Equal("N/A", databases); + } + + [Fact] + public void Format_FullPoint_RendersNameAndConnectionCount() + { + var point = new StubPoint + { + TopApplicationName = "SQLAgent", + TopApplicationConnections = 42, + TopHostName = "APPSRV01", + TopHostConnections = 18, + DatabasesWithConnections = 9, + }; + + var (topApp, topHost, databases) = SessionStatsSummary.Format(point); + + Assert.Equal("SQLAgent (42)", topApp); + Assert.Equal("APPSRV01 (18)", topHost); + Assert.Equal("9", databases); + } + + [Fact] + public void Format_MissingApplicationOrHost_YieldsNA_ButKeepsDatabaseCount() + { + var point = new StubPoint + { + TopApplicationName = null, + TopApplicationConnections = null, + TopHostName = "", + TopHostConnections = null, + DatabasesWithConnections = 3, + }; + + var (topApp, topHost, databases) = SessionStatsSummary.Format(point); + + Assert.Equal("N/A", topApp); + Assert.Equal("N/A", topHost); + Assert.Equal("3", databases); + } + + [Fact] + public void Format_NamedButNullConnectionCount_FallsBackToZero() + { + /* The `?? 0` branch: a named app/host whose connection count is null still renders "name (0)" + (no existing test reaches this — the missing-name case short-circuits to "N/A" before the count). */ + var point = new StubPoint + { + TopApplicationName = "AppOnly", + TopApplicationConnections = null, + TopHostName = "HostOnly", + TopHostConnections = null, + DatabasesWithConnections = 1, + }; + + var (topApp, topHost, _) = SessionStatsSummary.Format(point); + + Assert.Equal("AppOnly (0)", topApp); + Assert.Equal("HostOnly (0)", topHost); + } +} diff --git a/Lite.Tests/SessionSummaryStatsCollectorDefinitionTests.cs b/Lite.Tests/SessionSummaryStatsCollectorDefinitionTests.cs new file mode 100644 index 000000000..8d3bca40a --- /dev/null +++ b/Lite.Tests/SessionSummaryStatsCollectorDefinitionTests.cs @@ -0,0 +1,229 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Pins the cross-SKU parity contract of the shared session_summary_stats collector definition (the +/// Dashboard-parity port of install/36_collect_session_stats.sql — the server-wide session SUMMARY / +/// connection-leak / idle signal): verbatim query text, the collect-everywhere AppliesTo (the DMVs +/// exist on all platforms; Azure SQL DB merely scopes the counts), payload column order, ReadAsync +/// mapping (including the nullable top-application/host columns), and the point-in-time no-delta +/// WritePayload. An intentional change to any of these must consciously update these tests. Distinct +/// from the per-application session_stats collector. +/// </summary> +public sealed class SessionSummaryStatsCollectorDefinitionTests +{ + private const string ExpectedQuery = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + total_sessions = CONVERT(integer, COUNT_BIG(*)), + running_sessions = + SUM + ( + CASE + WHEN des.status = N'running' + THEN 1 + ELSE 0 + END + ), + sleeping_sessions = + SUM + ( + CASE + WHEN des.status = N'sleeping' + THEN 1 + ELSE 0 + END + ), + background_sessions = + SUM + ( + CASE + WHEN des.is_user_process = 0 + THEN 1 + ELSE 0 + END + ), + dormant_sessions = + SUM + ( + CASE + WHEN des.status = N'dormant' + THEN 1 + ELSE 0 + END + ), + idle_sessions_over_30min = + SUM + ( + CASE + WHEN des.status = N'sleeping' + AND des.last_request_end_time < DATEADD(MINUTE, -30, SYSDATETIME()) + AND des.is_user_process = 1 + THEN 1 + ELSE 0 + END + ), + sessions_waiting_for_memory = + SUM + ( + CASE + WHEN der.wait_type LIKE N'%MEMORY%' + THEN 1 + ELSE 0 + END + ), + databases_with_connections = CONVERT(integer, COUNT_BIG(DISTINCT des.database_id)), + top_application_name = MAX(top_app.program_name), + top_application_connections = CONVERT(integer, MAX(top_app.connection_count)), + top_host_name = MAX(top_host.host_name), + top_host_connections = CONVERT(integer, MAX(top_host.connection_count)) +FROM sys.dm_exec_sessions AS des +LEFT JOIN sys.dm_exec_requests AS der + ON der.session_id = des.session_id +OUTER APPLY +( + SELECT TOP (1) + program_name = des2.program_name, + connection_count = COUNT_BIG(*) + FROM sys.dm_exec_sessions AS des2 + WHERE des2.session_id > 50 + AND des2.is_user_process = 1 + AND des2.program_name IS NOT NULL + GROUP BY + des2.program_name + ORDER BY + COUNT_BIG(*) DESC +) AS top_app +OUTER APPLY +( + SELECT TOP (1) + host_name = des3.host_name, + connection_count = COUNT_BIG(*) + FROM sys.dm_exec_sessions AS des3 + WHERE des3.session_id > 50 + AND des3.is_user_process = 1 + AND des3.host_name IS NOT NULL + GROUP BY + des3.host_name + ORDER BY + COUNT_BIG(*) DESC +) AS top_host +WHERE des.session_id > 50 +OPTION(RECOMPILE);"; + + [Fact] + public void Query_IsTheVerbatimParityContract() + { + var context = CollectorTestContext.Make(new RecordingCollectorDeltaCalculator()); + Assert.Equal(ExpectedQuery, SessionSummaryStatsCollector.Instance.BuildQuery(context).Text); + Assert.Empty(SessionSummaryStatsCollector.Instance.BuildQuery(context).Parameters); + Assert.Null(SessionSummaryStatsCollector.Instance.WatermarkColumn); + Assert.Equal("session_summary_stats", SessionSummaryStatsCollector.Instance.Name); + Assert.Equal("session_summary_stats", SessionSummaryStatsCollector.Instance.TargetTable); + } + + [Fact] + public void AppliesTo_CollectsEverywhere_IncludingAzureSqlDb() + { + /* Both DMVs exist on SQL Server, Azure SQL DB, and Managed Instance (MS Learn-verified) and + the query never fails — Azure SQL DB just scopes the counts. Mirrors SessionStatsCollector. */ + Assert.True(SessionSummaryStatsCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureSqlDb = true })); + Assert.True(SessionSummaryStatsCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureManagedInstance = true })); + Assert.True(SessionSummaryStatsCollector.Instance.AppliesTo(new CollectorTargetInfo())); + } + + [Fact] + public void PayloadColumns_AreInAppendOrder() + { + var names = SessionSummaryStatsCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray(); + + Assert.Equal( + new[] + { + "total_sessions", + "running_sessions", + "sleeping_sessions", + "background_sessions", + "dormant_sessions", + "idle_sessions_over_30min", + "sessions_waiting_for_memory", + "databases_with_connections", + "top_application_name", + "top_application_connections", + "top_host_name", + "top_host_connections", + }, + names); + + /* All counts are integer; the two top_* names are varchar (nullable), matching the Dashboard. */ + Assert.Equal(CollectorColumnType.Integer, SessionSummaryStatsCollector.Instance.PayloadColumns.Single(c => c.Name == "idle_sessions_over_30min").Type); + Assert.Equal(CollectorColumnType.Varchar, SessionSummaryStatsCollector.Instance.PayloadColumns.Single(c => c.Name == "top_application_name").Type); + Assert.Equal(CollectorColumnType.Integer, SessionSummaryStatsCollector.Instance.PayloadColumns.Single(c => c.Name == "top_host_connections").Type); + } + + [Fact] + public async Task ReadAsync_MapsColumns_IncludingNullableTopAppHostColumns() + { + using var reader = new FakeCollectorDataReader( + /* A busy row: a top application and host both present. */ + new object[] + { + 42, 3, 30, 5, 1, 8, 0, 4, "MyApp", 20, "WEB01", 15, + }, + /* A quiet row (no qualifying user session for the top-app/host APPLYs): the three top_* + columns come back NULL. */ + new object[] + { + 1, 1, 0, 0, 0, 0, 0, 1, DBNull.Value, DBNull.Value, DBNull.Value, DBNull.Value, + }); + + var context = CollectorTestContext.Make(new RecordingCollectorDeltaCalculator()); + + var rows = await SessionSummaryStatsCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + + Assert.Equal(2, rows.Count); + Assert.Equal( + new SessionSummaryStatsCollector.Row(42, 3, 30, 5, 1, 8, 0, 4, "MyApp", 20, "WEB01", 15), + rows[0]); + Assert.Equal( + new SessionSummaryStatsCollector.Row(1, 1, 0, 0, 0, 0, 0, 1, null, null, null, null), + rows[1]); + } + + [Fact] + public void WritePayload_EmitsPayloadOrder_PointInTime_NoDeltas() + { + var deltas = new RecordingCollectorDeltaCalculator(); + var context = CollectorTestContext.Make(deltas); + var writer = new RecordingCollectorRowWriter(); + var row = new SessionSummaryStatsCollector.Row(42, 3, 30, 5, 1, 8, 0, 4, "MyApp", 20, "WEB01", 15); + + SessionSummaryStatsCollector.Instance.WritePayload(row, writer, context); + + Assert.Equal( + new object?[] + { + 42, 3, 30, 5, 1, 8, 0, 4, "MyApp", 20, "WEB01", 15, + }, + writer.Values); + + /* Point-in-time snapshot — no delta calculator interaction at all. */ + Assert.Empty(deltas.Calls); + } +} diff --git a/Lite.Tests/SessionSummaryStatsRoundTripTests.cs b/Lite.Tests/SessionSummaryStatsRoundTripTests.cs new file mode 100644 index 000000000..2efd328e2 --- /dev/null +++ b/Lite.Tests/SessionSummaryStatsRoundTripTests.cs @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.IO; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using PerformanceMonitorLite.Database; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Real-DuckDB round-trip pin for the #1262 session_summary_stats collector. The definition test pins +/// PayloadColumns/WritePayload against a recording writer, but nothing else proves the Lite DuckDB +/// table's physical column layout stays aligned with WritePayload — the appender writes BY POSITION, +/// so a table whose column count/order drifts from (4 prefix + WritePayload) fails at EndRow(). This +/// exercises the exact append path RemoteCollectorService.RunCollectorDefinitionAsync uses +/// (CreateAppender -> prefix -> WritePayload -> EndRow) against a freshly initialized schema, then +/// reads representative columns back, including the nullable top_* columns left NULL. +/// </summary> +public sealed class SessionSummaryStatsRoundTripTests : IDisposable +{ + private readonly string _tempDir; + + public SessionSummaryStatsRoundTripTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "LiteSessSumRt_" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + try + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, recursive: true); + } + } + catch + { + /* Best-effort cleanup */ + } + } + + [Fact] + public async Task SessionSummaryStats_AppendRoundTrips_Counts_AndNullableTopColumns() + { + /* top_application_name / top_application_connections / top_host_name / top_host_connections + left NULL (a quiet server with no qualifying user session) to prove the nullable appends + round-trip too. */ + var row = new SessionSummaryStatsCollector.Row(42, 3, 30, 5, 1, 8, 2, 4, null, null, null, null); + + using var connection = await AppendOneRowAsync(SessionSummaryStatsCollector.Instance, row); + + Assert.Equal(42, (int)await ScalarAsync(connection, "session_summary_stats", "total_sessions")); + Assert.Equal(8, (int)await ScalarAsync(connection, "session_summary_stats", "idle_sessions_over_30min")); + Assert.Equal(2, (int)await ScalarAsync(connection, "session_summary_stats", "sessions_waiting_for_memory")); + Assert.Equal(4, (int)await ScalarAsync(connection, "session_summary_stats", "databases_with_connections")); + Assert.Equal(DBNull.Value, await ScalarAsync(connection, "session_summary_stats", "top_application_name")); + Assert.Equal(DBNull.Value, await ScalarAsync(connection, "session_summary_stats", "top_host_connections")); + } + + [Fact] + public async Task SessionSummaryStats_AppendRoundTrips_PopulatedTopColumns() + { + var row = new SessionSummaryStatsCollector.Row(100, 5, 90, 3, 2, 12, 0, 6, "MyApp", 20, "WEB01", 15); + + using var connection = await AppendOneRowAsync(SessionSummaryStatsCollector.Instance, row); + + Assert.Equal("MyApp", (string)await ScalarAsync(connection, "session_summary_stats", "top_application_name")); + Assert.Equal(20, (int)await ScalarAsync(connection, "session_summary_stats", "top_application_connections")); + Assert.Equal("WEB01", (string)await ScalarAsync(connection, "session_summary_stats", "top_host_name")); + Assert.Equal(15, (int)await ScalarAsync(connection, "session_summary_stats", "top_host_connections")); + } + + /// <summary> + /// Appends one row through the exact runner path (prefix columns + WritePayload + EndRow) against a + /// freshly initialized DuckDB and returns the open connection. EndRow throwing here would mean the + /// Schema.cs table column count/order drifted from WritePayload. + /// </summary> + private async Task<DuckDBConnection> AppendOneRowAsync<TRow>(ICollectorDefinition<TRow> definition, TRow row) + { + var dbPath = Path.Combine(_tempDir, $"{definition.TargetTable}.duckdb"); + await new DuckDbInitializer(dbPath).InitializeAsync(); + + var context = CollectorTestContext.Make(new RecordingCollectorDeltaCalculator()); + + var connection = new DuckDBConnection($"Data Source={dbPath}"); + await connection.OpenAsync(TestContext.Current.CancellationToken); + + using (var appender = connection.CreateAppender(definition.TargetTable)) + { + var appenderRow = appender.CreateRow(); + if (definition.IncludesCollectionId) + { + appenderRow.AppendValue(1L); + } + appenderRow.AppendValue(context.CollectionTime).AppendValue(42).AppendValue("s"); + + var writer = new AppenderCollectorRowWriter { CurrentRow = appenderRow }; + definition.WritePayload(row, writer, context); + appenderRow.EndRow(); /* throws if the appended column count != table column count */ + } + + return connection; + } + + private static async Task<object> ScalarAsync(DuckDBConnection connection, string table, string column) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = $"SELECT {column} FROM {table} LIMIT 1"; + return (await cmd.ExecuteScalarAsync(TestContext.Current.CancellationToken))!; + } +} diff --git a/Lite.Tests/SharedBaselineModelPinTests.cs b/Lite.Tests/SharedBaselineModelPinTests.cs new file mode 100644 index 000000000..acf5282b8 --- /dev/null +++ b/Lite.Tests/SharedBaselineModelPinTests.cs @@ -0,0 +1,94 @@ +using System.Threading.Tasks; +using PerformanceMonitor.Analysis.Baselines; +using PerformanceMonitorLite.Analysis; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// <summary> +/// Pins the Tier-0 baseline-model collapse (Lite half of the Lite<->Darling lockstep): Lite's +/// baseline/anomaly brain consumes the SHARED model from the PerformanceMonitor.Analysis assembly +/// (namespace <c>PerformanceMonitor.Analysis.Baselines</c>) and keeps NO per-app copy of it, so Lite +/// and Darling can't silently re-fork the once-triplicated model + constants. The deprecated Dashboard +/// keeps its OWN copy on purpose (<c>PerformanceMonitorDashboard.Analysis</c>) — this pin is +/// Lite<->Darling only. Darling.Tests carries the mirror-image pin for the Darling half. +/// </summary> +public class SharedBaselineModelPinTests +{ + private const string SharedAssembly = "PerformanceMonitor.Analysis"; + private const string SharedNamespace = "PerformanceMonitor.Analysis.Baselines"; + + [Fact] + public void BaselineModelTypes_LiveInSharedAssemblyAndNamespace() + { + Assert.Equal(SharedAssembly, typeof(BaselineBucket).Assembly.GetName().Name); + Assert.Equal(SharedAssembly, typeof(BaselineTier).Assembly.GetName().Name); + Assert.Equal(SharedAssembly, typeof(MetricNames).Assembly.GetName().Name); + Assert.Equal(SharedAssembly, typeof(BaselineMath).Assembly.GetName().Name); + Assert.Equal(SharedAssembly, typeof(AnomalyThresholds).Assembly.GetName().Name); + + Assert.Equal(SharedNamespace, typeof(BaselineBucket).Namespace); + Assert.Equal(SharedNamespace, typeof(BaselineTier).Namespace); + Assert.Equal(SharedNamespace, typeof(MetricNames).Namespace); + Assert.Equal(SharedNamespace, typeof(BaselineMath).Namespace); + Assert.Equal(SharedNamespace, typeof(AnomalyThresholds).Namespace); + } + + [Fact] + public void Lite_KeepsNoPrivateCopyOfTheBaselineModel() + { + // If someone re-declares any of these in Lite's own Analysis namespace, this fails — + // the whole point of the collapse is that the model has exactly ONE home. + var liteAssembly = typeof(BaselineProvider).Assembly; + foreach (var forkedType in new[] + { + "PerformanceMonitorLite.Analysis.BaselineBucket", + "PerformanceMonitorLite.Analysis.BaselineTier", + "PerformanceMonitorLite.Analysis.MetricNames", + "PerformanceMonitorLite.Analysis.BaselineMath", + "PerformanceMonitorLite.Analysis.AnomalyThresholds", + }) + { + Assert.Null(liteAssembly.GetType(forkedType)); + } + } + + [Fact] + public void LiteBaselineProvider_BindsToTheSharedBaselineBucket() + { + // GetBaselineAsync returns Task<BaselineBucket>; pin the element type to the shared type. + var method = typeof(BaselineProvider).GetMethod(nameof(BaselineProvider.GetBaselineAsync)); + Assert.NotNull(method); + var bucketType = method!.ReturnType.GetGenericArguments()[0]; + Assert.Same(typeof(BaselineBucket), bucketType); + Assert.Equal(SharedAssembly, bucketType.Assembly.GetName().Name); + } + + [Fact] + public void SharedThresholds_KeepBehaviorPreservingValues() + { + // Behavior-preserving value pin: the collapse must not have altered any threshold. These are + // the tier-selection thresholds and a representative slice of the ~20 detector constants. + Assert.Equal(30, BaselineMath.BaselineWindowDays); + Assert.Equal(10, BaselineMath.CollapseThreshold); + Assert.Equal(15, BaselineMath.RestoreThreshold); + + Assert.Equal(2.0, AnomalyThresholds.DefaultDeviationThreshold); + Assert.Equal(4.0, AnomalyThresholds.DefaultRatioThreshold); + Assert.Equal(3.0, AnomalyThresholds.DefaultEventRatioThreshold); + Assert.Equal(25.0, AnomalyThresholds.SigmaDisplayCap); + Assert.Equal(90.0, AnomalyThresholds.CpuFallbackPct); + Assert.Equal(250.0, AnomalyThresholds.WaitProfileFallbackMsPerSec); + Assert.Equal(100.0, AnomalyThresholds.NoBaselineRatio); + + Assert.Equal(100m, AnomalyThresholds.ObjectGrowthMbThreshold); + Assert.Equal(20.0, AnomalyThresholds.ObjectGrowthPctThreshold); + Assert.Equal(60000L, AnomalyThresholds.ObjectLockWaitMsDeltaThreshold); + + // Bounded-metric dispersion floors (server-relative metrics floor at 0). + Assert.Equal(5.0, BaselineMath.AbsStdDevFloorFor(MetricNames.Cpu)); + Assert.Equal(4.0, BaselineMath.AbsStdDevFloorFor(MetricNames.Memory)); + Assert.Equal(2.5, BaselineMath.AbsStdDevFloorFor(MetricNames.IoLatency)); + Assert.Equal(0.0, BaselineMath.AbsStdDevFloorFor(MetricNames.BatchRequests)); + } +} diff --git a/Lite.Tests/SharedCollectorDefaultsPinTests.cs b/Lite.Tests/SharedCollectorDefaultsPinTests.cs new file mode 100644 index 000000000..6f0651b16 --- /dev/null +++ b/Lite.Tests/SharedCollectorDefaultsPinTests.cs @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using PerformanceMonitor.Collectors; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Identity-pins between Lite's own defaults and the shared constants the Darling service +/// consumes (headless plan M2 slice A): the bundled ignored-wait-types json must equal +/// <see cref="IgnoredWaitDefaults"/>, ScheduleManager's default table must equal +/// <see cref="CollectorScheduleDefaults"/>, and the delta calculator / server-identity idioms +/// must be the shared implementations — so the two SKUs cannot drift apart silently. +/// </summary> +public sealed class SharedCollectorDefaultsPinTests +{ + private static string FindRepoFile(string relativePath) + { + var dir = AppContext.BaseDirectory; + for (int i = 0; i < 8 && dir is not null; i++) + { + var candidate = Path.Combine(dir, relativePath); + if (File.Exists(candidate)) + { + return candidate; + } + dir = Path.GetDirectoryName(dir); + } + throw new FileNotFoundException($"Could not locate {relativePath} walking up from {AppContext.BaseDirectory}"); + } + + [Fact] + public void IgnoredWaitDefaults_MatchLiteBundledJson() + { + var jsonPath = FindRepoFile(Path.Combine("Lite", "config", "ignored_wait_types.json")); + using var doc = JsonDocument.Parse(File.ReadAllText(jsonPath)); + var fromJson = doc.RootElement.GetProperty("ignored_waits") + .EnumerateArray() + .Select(e => e.GetString()!) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + Assert.Equal(124, fromJson.Count); + Assert.Equal(fromJson, IgnoredWaitDefaults.All.ToHashSet(StringComparer.OrdinalIgnoreCase)); + } + + [Fact] + public void CollectorScheduleDefaults_MatchScheduleManagerTable() + { + var liteDefaults = ScheduleManager.GetDefaultSchedules(); + + Assert.Equal(liteDefaults.Count, CollectorScheduleDefaults.All.Count); + foreach (var schedule in liteDefaults) + { + Assert.True(CollectorScheduleDefaults.All.TryGetValue(schedule.Name, out var shared), + $"shared defaults missing collector '{schedule.Name}'"); + Assert.Equal(schedule.FrequencyMinutes, shared!.FrequencyMinutes); + Assert.Equal(schedule.RetentionDays, shared.RetentionDays); + /* The seeded enabled state must match the shared DefaultEnabled — so an opt-in collector + (long_query_completions, #1496) can't ship enabled in one SKU and disabled in the other. */ + Assert.Equal(schedule.Enabled, shared.DefaultEnabled); + } + } + + [Fact] + public void CollectorScheduleDefaults_CoverEveryCatalogCollector() + { + foreach (var definition in CollectorCatalog.All) + { + Assert.True(CollectorScheduleDefaults.All.ContainsKey(definition.Name), + $"no default schedule for catalog collector '{definition.Name}'"); + } + } + + [Fact] + public void SchedulePresets_ReferenceOnlyRealCollectors() + { + /* Every collector named in a schedule preset must be a real collector in the shared defaults — + guards against a preset table naming a renamed, removed, or typo'd collector. */ + foreach (var (presetName, intervals) in ScheduleManager.s_presets) + { + foreach (var collector in intervals.Keys) + { + Assert.True(CollectorScheduleDefaults.All.ContainsKey(collector), + $"preset '{presetName}' references unknown collector '{collector}'"); + } + } + } + + [Fact] + public void SchedulePresets_CoverTheSamePinnedCollectorSet() + { + /* ScheduleManager.s_presets is now the ONE preset table — the collector-schedule editor window + reads/applies it instead of holding its own copy (which had drifted ~8 collectors behind). + Pin the membership: every preset must cover exactly this set, so adding a collector to one + preset but not the others — or letting the set silently fall behind again — fails here. */ + var expected = new HashSet<string>(StringComparer.OrdinalIgnoreCase) + { + "wait_stats", "latch_stats", "spinlock_stats", "cpu_scheduler_stats", "plan_cache_stats", + "query_stats", "procedure_stats", "query_store", "query_snapshots", "cpu_utilization", + "file_io_stats", "memory_stats", "memory_clerks", "memory_pressure_events", "tempdb_stats", + "perfmon_stats", "deadlocks", "memory_grant_stats", "waiting_tasks", "dmv_blocking_snapshot", + "blocked_process_report", "running_jobs", "session_summary_stats", "system_health_events", + "default_trace_events", "job_history", "agent_status" + }; + + Assert.Equal(3, ScheduleManager.s_presets.Count); + foreach (var (presetName, intervals) in ScheduleManager.s_presets) + { + Assert.True(expected.SetEquals(intervals.Keys), + $"preset '{presetName}' collector set drifted from the pinned set"); + } + } + + [Fact] + public void DeltaCalculator_IsTheSharedCoreImplementation() + { + Assert.True(typeof(CollectorDeltaCalculator).IsAssignableFrom(typeof(DeltaCalculator))); + + /* Behavior smoke on the shared core: baseline 0, then delta, counter-reset 0, gap 0. */ + var deltas = new CollectorDeltaCalculator(); + var t0 = new DateTime(2026, 7, 2, 12, 0, 0, DateTimeKind.Utc); + Assert.Equal(0, deltas.CalculateDelta(1, "c", "k", 100, t0, 300)); + Assert.Equal(50, deltas.CalculateDelta(1, "c", "k", 150, t0.AddSeconds(60), 300)); + Assert.Equal(0, deltas.CalculateDelta(1, "c", "k", 10, t0.AddSeconds(120), 300)); /* reset */ + Assert.Equal(0, deltas.CalculateDelta(1, "c", "k", 500, t0.AddSeconds(120 + 3600), 300)); /* gap */ + } + + [Fact] + public void ServerStorageName_ForwardsToSharedBuilder() + { + Assert.Equal("HOST", PerformanceMonitor.Common.ServerIdHelper.BuildStorageName("HOST", null, false)); + Assert.Equal("HOST:db1", PerformanceMonitor.Common.ServerIdHelper.BuildStorageName("HOST", "db1", false)); + Assert.Equal("HOST:db1:RO", PerformanceMonitor.Common.ServerIdHelper.BuildStorageName("HOST", "db1", true)); + Assert.Equal("HOST:RO", PerformanceMonitor.Common.ServerIdHelper.BuildStorageName("HOST", "", true)); + } +} diff --git a/Lite.Tests/SpinlockStatsCollectorDefinitionTests.cs b/Lite.Tests/SpinlockStatsCollectorDefinitionTests.cs new file mode 100644 index 000000000..1a8c0c517 --- /dev/null +++ b/Lite.Tests/SpinlockStatsCollectorDefinitionTests.cs @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Pins the cross-SKU parity contract of the shared spinlock_stats collector definition (the +/// Dashboard-parity port of install/33_collect_spinlock_stats.sql): verbatim query text, payload +/// column order, ReadAsync mapping (spins_per_collision is a real/float read via GetFloat), and the +/// delta groups/keys/gap policy — spins_per_collision is a computed ratio and gets no delta. +/// </summary> +public sealed class SpinlockStatsCollectorDefinitionTests +{ + private const string ExpectedQuery = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + spinlock_name = ss.name, + collisions = ss.collisions, + spins = ss.spins, + spins_per_collision = ss.spins_per_collision, + sleep_time = ss.sleep_time, + /* backoffs is int in sys.dm_os_spinlock_stats on SQL 2016/2017 and was widened to bigint on + 2019+. CONVERT so the wire type is always bigint and ReadAsync's GetInt64 doesn't throw an + Int32->Int64 InvalidCast on the older versions (collisions/spins/sleep_time are bigint on + every supported version, so they need no cast). */ + backoffs = CONVERT(bigint, ss.backoffs) +FROM sys.dm_os_spinlock_stats AS ss +WHERE ss.collisions > 0 +OR ss.spins > 0 +OPTION(RECOMPILE);"; + + [Fact] + public void Query_IsTheVerbatimParityContract() + { + var context = CollectorTestContext.Make(new RecordingCollectorDeltaCalculator()); + Assert.Equal(ExpectedQuery, SpinlockStatsCollector.Instance.BuildQuery(context).Text); + Assert.Empty(SpinlockStatsCollector.Instance.BuildQuery(context).Parameters); + Assert.Null(SpinlockStatsCollector.Instance.WatermarkColumn); + Assert.Equal("spinlock_stats", SpinlockStatsCollector.Instance.Name); + Assert.Equal("spinlock_stats", SpinlockStatsCollector.Instance.TargetTable); + } + + [Fact] + public void AppliesTo_Everywhere_IncludingAzure() + { + Assert.True(SpinlockStatsCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureSqlDb = true })); + Assert.True(SpinlockStatsCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureManagedInstance = true })); + Assert.True(SpinlockStatsCollector.Instance.AppliesTo(new CollectorTargetInfo())); + } + + [Fact] + public void PayloadColumns_AreInAppendOrder() + { + var names = SpinlockStatsCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray(); + + Assert.Equal( + new[] + { + "spinlock_name", + "collisions", + "spins", + "spins_per_collision", + "sleep_time", + "backoffs", + "delta_collisions", + "delta_spins", + "delta_sleep_time", + "delta_backoffs", + }, + names); + + /* spins_per_collision is the DMV's real ratio — mapped to a double, not a delta. */ + var spinsPerCollision = SpinlockStatsCollector.Instance.PayloadColumns.Single(c => c.Name == "spins_per_collision"); + Assert.Equal(CollectorColumnType.Double, spinsPerCollision.Type); + } + + [Fact] + public async Task ReadAsync_MapsColumns_IncludingRealSpinsPerCollision() + { + using var reader = new FakeCollectorDataReader( + new object[] { "SOS_SUSPEND_QUEUE", 100L, 5000L, 2.5f, 10L, 3L }, + new object[] { "LOCK_HASH", 40L, 800L, 20.0f, 0L, 1L }); + + var context = CollectorTestContext.Make(new RecordingCollectorDeltaCalculator()); + + var rows = await SpinlockStatsCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + + Assert.Equal(2, rows.Count); + Assert.Equal(new SpinlockStatsCollector.Row("SOS_SUSPEND_QUEUE", 100, 5000, 2.5, 10, 3), rows[0]); + Assert.Equal(new SpinlockStatsCollector.Row("LOCK_HASH", 40, 800, 20.0, 0, 1), rows[1]); + } + + [Fact] + public void WritePayload_EmitsPayloadOrder_AndPinsDeltaGroupsKeysAndGapPolicy() + { + var deltas = new RecordingCollectorDeltaCalculator(); + var context = CollectorTestContext.Make(deltas); + var writer = new RecordingCollectorRowWriter(); + var row = new SpinlockStatsCollector.Row("SOS_SUSPEND_QUEUE", 100, 5000, 2.5, 10, 3); + + SpinlockStatsCollector.Instance.WritePayload(row, writer, context); + + /* Payload order: raw values (spins_per_collision passed through as a double) then the four + deltas (recording calculator returns value * 10). */ + Assert.Equal( + new object?[] { "SOS_SUSPEND_QUEUE", 100L, 5000L, 2.5, 10L, 3L, 1000L, 50000L, 100L, 30L }, + writer.Values); + + /* Delta contract: group names, key = spinlock_name, host collection time, 300 s gap policy. + No delta call for spins_per_collision — exactly four calls. */ + Assert.Equal(4, deltas.Calls.Count); + Assert.Equal(("spinlock_stats_collisions", "SOS_SUSPEND_QUEUE", 100L, context.CollectionTime, 300), deltas.Calls[0]); + Assert.Equal(("spinlock_stats_spins", "SOS_SUSPEND_QUEUE", 5000L, context.CollectionTime, 300), deltas.Calls[1]); + Assert.Equal(("spinlock_stats_sleep_time", "SOS_SUSPEND_QUEUE", 10L, context.CollectionTime, 300), deltas.Calls[2]); + Assert.Equal(("spinlock_stats_backoffs", "SOS_SUSPEND_QUEUE", 3L, context.CollectionTime, 300), deltas.Calls[3]); + Assert.All(deltas.Calls, _ => Assert.Equal(42, deltas.LastServerId)); + } +} diff --git a/Lite.Tests/SystemEventsReaderTests.cs b/Lite.Tests/SystemEventsReaderTests.cs new file mode 100644 index 000000000..56fc143ea --- /dev/null +++ b/Lite.Tests/SystemEventsReaderTests.cs @@ -0,0 +1,361 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; +using PerformanceMonitorLite.Database; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Real-DuckDB round-trip pins for the System Events tab's readers (LocalDataService.SystemEvents). The +/// Common SystemHealthParser shred and the shared SystemHealthSignificance / DefaultTraceEventSignificance +/// predicates are already unit-tested; these prove the LITE glue end-to-end against a freshly initialized +/// store: seed the v_ archive views' base tables with the same fixture XML shapes Darling uses, read back +/// through the reader, and assert (1) the reader keeps exactly the significant set, (2) the column +/// projection, (3) the Severe-Errors database_id -> name resolution (DuckDB QUALIFY), (4) the #1319 database +/// filter, (5) the event_time window bounding, and (6) the Default Trace server-local -> UTC de-skew. +/// </summary> +public sealed class SystemEventsReaderTests : IDisposable +{ + private const int ServerId = 7777; + + private readonly string _tempDir; + private readonly DuckDbInitializer _duckDb; + private long _nextId = 1; + + public SystemEventsReaderTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "LiteSysEventsRt_" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(_tempDir); + _duckDb = new DuckDbInitializer(Path.Combine(_tempDir, "test.duckdb")); + } + + public void Dispose() + { + try + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + catch { /* Best-effort cleanup */ } + } + + private static string LoadFixture(string name) => + File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "SystemHealth", name)); + + private static DateTime Truncate(DateTime t) => + new(t.Year, t.Month, t.Day, t.Hour, t.Minute, t.Second, DateTimeKind.Unspecified); + + // ── End-to-end significant set across every system_health category ── + + [Fact] + public async Task SystemHealthCategories_ReadThroughVView_KeepOnlyTheSignificantSet() + { + await _duckDb.InitializeAsync(); + var service = new LocalDataService(_duckDb); + + /* Store each fixture at a recent event_time so a 24h window includes it. The WINDOW filters on the + stored event_time column; the ROW's own EventTimeLocal comes from the XML @timestamp (a Stage-2a + parser concern, tested there). */ + var eventTime = Truncate(DateTime.UtcNow.AddHours(-1)); + await SeedHealthEventAsync(SystemHealthParser.SchedulerMonitorEvent, LoadFixture("scheduler_monitor.xml"), eventTime); + await SeedHealthEventAsync(SystemHealthParser.ErrorReportedEvent, LoadFixture("error_reported.xml"), eventTime); + await SeedHealthEventAsync(SystemHealthParser.SpServerDiagnosticsEvent, LoadFixture("sp_server_diagnostics_system.xml"), eventTime); + await SeedHealthEventAsync(SystemHealthParser.SpServerDiagnosticsEvent, LoadFixture("sp_server_diagnostics_resource.xml"), eventTime); + await SeedHealthEventAsync(SystemHealthParser.SpServerDiagnosticsEvent, LoadFixture("sp_server_diagnostics_query_processing.xml"), eventTime); + await SeedHealthEventAsync(SystemHealthParser.SpServerDiagnosticsEvent, LoadFixture("sp_server_diagnostics_query_processing_warning.xml"), eventTime); + await SeedHealthEventAsync(SystemHealthParser.SpServerDiagnosticsEvent, LoadFixture("sp_server_diagnostics_io_subsystem.xml"), eventTime); + await SeedHealthEventAsync(SystemHealthParser.MemoryBrokerEvent, LoadFixture("memory_broker.xml"), eventTime); + await SeedHealthEventAsync(SystemHealthParser.MemoryNodeOomEvent, LoadFixture("memory_node_oom.xml"), eventTime); + await SeedHealthEventAsync(SystemHealthParser.WaitInfoEvent, LoadFixture("wait_info.xml"), eventTime); + + // Scheduler (WARNING), Severe Error (sev 24), Memory Node OOM (ungated), and the wait pass. + Assert.Single(await service.GetSchedulerIssuesAsync(ServerId)); + Assert.Single(await service.GetSevereErrorsAsync(ServerId)); + Assert.Single(await service.GetMemoryNodeOomAsync(ServerId)); + Assert.Single(await service.GetSignificantWaitsAsync(ServerId)); + // The memory-conditions / broker fixtures are RESOURCE_MEMPHYSICAL_HIGH — warnings_only drops them. + Assert.Empty(await service.GetMemoryConditionsAsync(ServerId)); + Assert.Empty(await service.GetMemoryBrokerAsync(ServerId)); + // CPU: the WARNING+15-pending event passes, the CLEAN one is dropped. I/O: both WARNING file rows pass. + Assert.Single(await service.GetCpuTasksAsync(ServerId)); + Assert.Equal(2, (await service.GetIoIssuesAsync(ServerId)).Count); + // System Health (Corruption + Contention charts) has NO significance filter — the CLEAN SYSTEM + // snapshot is kept so the counter charts plot it (contrast with the dropped HIGH memory rows). + Assert.Single(await service.GetSystemHealthAsync(ServerId)); + } + + [Fact] + public async Task SevereErrors_ProjectColumns_AndResolveDatabaseNameFromSizeStats() + { + await _duckDb.InitializeAsync(); + var service = new LocalDataService(_duckDb); + + var eventTime = Truncate(DateTime.UtcNow.AddHours(-1)); + await SeedHealthEventAsync(SystemHealthParser.ErrorReportedEvent, LoadFixture("error_reported.xml"), eventTime); + /* error_reported.xml carries database_id 6; map it via the latest size-stats snapshot (newest name + per id, the QUALIFY ROW_NUMBER dedup). A second, older row for the same id proves "latest wins". */ + await SeedDatabaseSizeAsync(databaseId: 6, databaseName: "OldName", collectionTime: eventTime.AddHours(-2)); + await SeedDatabaseSizeAsync(databaseId: 6, databaseName: "ProdDB", collectionTime: eventTime); + + var rows = await service.GetSevereErrorsAsync(ServerId); + var row = Assert.Single(rows); + Assert.Equal(823, row.ErrorNumber); + Assert.Equal(24, row.Severity); + Assert.Equal(6, row.DatabaseId); + Assert.Equal("ProdDB", row.DatabaseName); + Assert.Contains("operating system returned error", row.Message); + } + + [Fact] + public async Task SevereErrors_UnmappedDatabaseId_SurfacesTheRawId() + { + await _duckDb.InitializeAsync(); + var service = new LocalDataService(_duckDb); + + // No size-stats rows at all — database_id 6 can't resolve, so it surfaces as "database_id 6". + await SeedHealthEventAsync(SystemHealthParser.ErrorReportedEvent, LoadFixture("error_reported.xml"), Truncate(DateTime.UtcNow.AddHours(-1))); + + var row = Assert.Single(await service.GetSevereErrorsAsync(ServerId)); + Assert.Equal("database_id 6", row.DatabaseName); + } + + [Fact] + public async Task SevereErrors_DatabaseFilter_1319_FiltersOnResolvedName() + { + await _duckDb.InitializeAsync(); + var service = new LocalDataService(_duckDb); + + var eventTime = Truncate(DateTime.UtcNow.AddHours(-1)); + await SeedHealthEventAsync(SystemHealthParser.ErrorReportedEvent, LoadFixture("error_reported.xml"), eventTime); + await SeedDatabaseSizeAsync(databaseId: 6, databaseName: "ProdDB", collectionTime: eventTime); + + // Selecting the resolved name keeps the row; selecting a different DB filters it out. + Assert.Single(await service.GetSevereErrorsAsync(ServerId, databaseNames: new[] { "ProdDB" })); + Assert.Empty(await service.GetSevereErrorsAsync(ServerId, databaseNames: new[] { "SomethingElse" })); + } + + [Fact] + public async Task IoIssues_ProjectWarningRows_WithFilePathAndSummedDuration() + { + await _duckDb.InitializeAsync(); + var service = new LocalDataService(_duckDb); + + await SeedHealthEventAsync(SystemHealthParser.SpServerDiagnosticsEvent, LoadFixture("sp_server_diagnostics_io_subsystem.xml"), Truncate(DateTime.UtcNow.AddHours(-1))); + + var rows = await service.GetIoIssuesAsync(ServerId); + Assert.Equal(2, rows.Count); + Assert.All(rows, r => Assert.Equal("WARNING", r.State)); + Assert.All(rows, r => Assert.False(string.IsNullOrEmpty(r.LongestPendingRequestsFilePath))); + } + + [Fact] + public async Task SystemHealthWindow_ExcludesEventsOutsideTheEventTimeBounds() + { + await _duckDb.InitializeAsync(); + var service = new LocalDataService(_duckDb); + + // One in-window scheduler warning, one stored 48h ago — a 24h read must return only the recent one. + await SeedHealthEventAsync(SystemHealthParser.SchedulerMonitorEvent, LoadFixture("scheduler_monitor.xml"), Truncate(DateTime.UtcNow.AddHours(-1))); + await SeedHealthEventAsync(SystemHealthParser.SchedulerMonitorEvent, LoadFixture("scheduler_monitor.xml"), Truncate(DateTime.UtcNow.AddHours(-48))); + + Assert.Single(await service.GetSchedulerIssuesAsync(ServerId, hoursBack: 24)); + } + + // ── Default Trace: significant set, projection, de-skew, DB filter ── + + [Fact] + public async Task DefaultTrace_KeepsSignificantSet_ProjectsCategoryAndGrowth() + { + await _duckDb.InitializeAsync(); + var service = new LocalDataService(_duckDb); + + var offset = ServerTimeHelper.UtcOffsetMinutes; + var serverLocal = Truncate(DateTime.UtcNow.AddMinutes(offset).AddHours(-1)); // in the server-local window + + // Auto-grow stall (256 pages -> 2 MB), severe ErrorLog (sev 20, kept), routine ErrorLog (sev 10, DROPPED). + await SeedDefaultTraceAsync(serverLocal, "Data File Auto Grow", databaseName: "SalesDB", durationUs: 1_500_000, integerData: 256, severity: null, errorNumber: null, textData: null); + await SeedDefaultTraceAsync(serverLocal, "ErrorLog", databaseName: null, durationUs: null, integerData: null, severity: 20, errorNumber: 9002, textData: "severe"); + await SeedDefaultTraceAsync(serverLocal, "ErrorLog", databaseName: null, durationUs: null, integerData: null, severity: 10, errorNumber: 50000, textData: "routine"); + + var rows = await service.GetDefaultTraceEventsAsync(ServerId); + Assert.Equal(2, rows.Count); // the sev-10 ErrorLog is dropped by the severity floor + Assert.DoesNotContain(rows, r => r.Category == "ErrorLog" && r.Severity == 10); + + var grow = Assert.Single(rows, r => r.Category == "AutoGrowShrink"); + Assert.Equal("Data File Auto Grow", grow.EventName); + Assert.Equal("SalesDB", grow.DatabaseName); + Assert.Equal(1500m, grow.DurationMs); // 1_500_000 us / 1000 + Assert.Equal(2.0m, grow.GrowthMb); // 256 pages * 8 KB / 1024 + } + + [Fact] + public async Task DefaultTrace_DeSkewsServerLocalStartTimeToUtc_RendersConsistentlyWithSystemHealth() + { + await _duckDb.InitializeAsync(); + var service = new LocalDataService(_duckDb); + + /* Force a known non-zero offset + server-time display so the de-skew is genuinely exercised: the + stored server-local StartTime is de-skewed to UTC on read, then FormatServerTime renders it back + to the server-local wall clock — so EventTimeLocal must equal the stored server-local time. */ + var savedOffset = ServerTimeHelper.UtcOffsetMinutes; + var savedMode = ServerTimeHelper.CurrentDisplayMode; + try + { + ServerTimeHelper.UtcOffsetMinutes = 330; // UTC+5:30 + ServerTimeHelper.CurrentDisplayMode = TimeDisplayMode.ServerTime; + + var serverLocal = Truncate(DateTime.UtcNow.AddMinutes(330).AddHours(-1)); + await SeedDefaultTraceAsync(serverLocal, "Server Memory Change", databaseName: null, durationUs: null, integerData: null, severity: null, errorNumber: null, textData: "mem"); + + var row = Assert.Single(await service.GetDefaultTraceEventsAsync(ServerId)); + Assert.Equal(serverLocal.ToString("yyyy-MM-dd HH:mm:ss"), row.EventTimeLocal); + Assert.Equal("MemoryChange", row.Category); + } + finally + { + ServerTimeHelper.UtcOffsetMinutes = savedOffset; + ServerTimeHelper.CurrentDisplayMode = savedMode; + } + } + + [Fact] + public async Task DefaultTrace_DatabaseFilter_1319_FiltersOnDatabaseNameInSql() + { + await _duckDb.InitializeAsync(); + var service = new LocalDataService(_duckDb); + + var offset = ServerTimeHelper.UtcOffsetMinutes; + var serverLocal = Truncate(DateTime.UtcNow.AddMinutes(offset).AddHours(-1)); + await SeedDefaultTraceAsync(serverLocal, "Object:Altered", databaseName: "SalesDB", durationUs: null, integerData: null, severity: null, errorNumber: null, textData: "ALTER"); + await SeedDefaultTraceAsync(serverLocal, "Object:Altered", databaseName: "OtherDB", durationUs: null, integerData: null, severity: null, errorNumber: null, textData: "ALTER"); + + Assert.Equal(2, (await service.GetDefaultTraceEventsAsync(ServerId)).Count); + var filtered = await service.GetDefaultTraceEventsAsync(ServerId, databaseNames: new[] { "SalesDB" }); + Assert.All(filtered, r => Assert.Equal("SalesDB", r.DatabaseName)); + Assert.Single(filtered); + } + + // ── Pure helpers: database-name resolution (no store), mirroring the shared contract ── + + [Fact] + public void ResolveDatabaseName_MappedZeroNullAndUnmapped() + { + var map = new Dictionary<int, string> { [5] = "AdventureWorks" }; + Assert.Equal("AdventureWorks", LocalDataService.ResolveDatabaseName(5, map)); + Assert.Equal(string.Empty, LocalDataService.ResolveDatabaseName(0, map)); + Assert.Equal(string.Empty, LocalDataService.ResolveDatabaseName(null, map)); + Assert.Equal("database_id 7", LocalDataService.ResolveDatabaseName(7, map)); + } + + // ── Row VM projection (machine-local time, shared UTC frame) ── + + [Fact] + public void Rows_EventTimeLocal_SharesTheSameUtcFrame_AndNullRendersEmpty() + { + var utc = new DateTime(2026, 7, 9, 12, 0, 5, DateTimeKind.Utc); + var expected = ServerTimeHelper.FormatServerTime(utc, "yyyy-MM-dd HH:mm:ss"); + + var scheduler = new SchedulerIssueRow(new SchedulerIssueRecord { EventTime = utc }); + var defaultTrace = new DefaultTraceEventRow( + utc, DefaultTraceEventCategory.AutoGrowShrink, "Data File Auto Grow", "SalesDB", null, + "app", "APPHOST", "SqlClient", 55, 1_500_000, 256, null, null, null); + + Assert.Equal(expected, scheduler.EventTimeLocal); + Assert.Equal(scheduler.EventTimeLocal, defaultTrace.EventTimeLocal); + + Assert.Equal(string.Empty, new MemoryBrokerRow(new MemoryBrokerRecord { EventTime = null }).EventTimeLocal); + Assert.Equal(string.Empty, new DefaultTraceEventRow( + null, DefaultTraceEventCategory.ErrorLog, "ErrorLog", null, null, null, null, null, null, null, null, 20, 823, "x").EventTimeLocal); + } + + // ── Seeding helpers (raw INSERT via the shared initializer's connection, closed before each read) ── + + private async Task SeedHealthEventAsync(string eventType, string eventXml, DateTime eventTimeUtc) + { + using var readLock = _duckDb.AcquireReadLock(); + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +INSERT INTO system_health_events + (system_health_event_id, collection_time, server_id, server_name, event_time, event_type, event_xml) +VALUES ($1, $2, $3, $4, $5, $6, $7)"; + cmd.Parameters.Add(new DuckDBParameter { Value = _nextId++ }); + cmd.Parameters.Add(new DuckDBParameter { Value = DateTime.UtcNow }); + cmd.Parameters.Add(new DuckDBParameter { Value = ServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = "TestSrv" }); + cmd.Parameters.Add(new DuckDBParameter { Value = eventTimeUtc }); + cmd.Parameters.Add(new DuckDBParameter { Value = eventType }); + cmd.Parameters.Add(new DuckDBParameter { Value = eventXml }); + await cmd.ExecuteNonQueryAsync(); + } + + private async Task SeedDefaultTraceAsync( + DateTime eventTimeServerLocal, string eventName, string? databaseName, long? durationUs, + long? integerData, int? severity, int? errorNumber, string? textData) + { + using var readLock = _duckDb.AcquireReadLock(); + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +INSERT INTO default_trace_events + (default_trace_event_id, collection_time, server_id, server_name, event_time, event_name, + database_name, duration_us, integer_data, severity, error_number, text_data) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)"; + cmd.Parameters.Add(new DuckDBParameter { Value = _nextId++ }); + cmd.Parameters.Add(new DuckDBParameter { Value = DateTime.UtcNow }); + cmd.Parameters.Add(new DuckDBParameter { Value = ServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = "TestSrv" }); + cmd.Parameters.Add(new DuckDBParameter { Value = eventTimeServerLocal }); + cmd.Parameters.Add(new DuckDBParameter { Value = eventName }); + cmd.Parameters.Add(new DuckDBParameter { Value = (object?)databaseName ?? DBNull.Value }); + cmd.Parameters.Add(new DuckDBParameter { Value = (object?)durationUs ?? DBNull.Value }); + cmd.Parameters.Add(new DuckDBParameter { Value = (object?)integerData ?? DBNull.Value }); + cmd.Parameters.Add(new DuckDBParameter { Value = (object?)severity ?? DBNull.Value }); + cmd.Parameters.Add(new DuckDBParameter { Value = (object?)errorNumber ?? DBNull.Value }); + cmd.Parameters.Add(new DuckDBParameter { Value = (object?)textData ?? DBNull.Value }); + await cmd.ExecuteNonQueryAsync(); + } + + private async Task SeedDatabaseSizeAsync(int databaseId, string databaseName, DateTime collectionTime) + { + using var readLock = _duckDb.AcquireReadLock(); + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + using var cmd = connection.CreateCommand(); + /* database_size_stats is the id -> name mapping source; provide its NOT NULL columns. */ + cmd.CommandText = @" +INSERT INTO database_size_stats + (collection_id, collection_time, server_id, server_name, database_name, database_id, + file_id, file_type_desc, file_name, physical_name, total_size_mb) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)"; + cmd.Parameters.Add(new DuckDBParameter { Value = _nextId++ }); + cmd.Parameters.Add(new DuckDBParameter { Value = collectionTime }); + cmd.Parameters.Add(new DuckDBParameter { Value = ServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = "TestSrv" }); + cmd.Parameters.Add(new DuckDBParameter { Value = databaseName }); + cmd.Parameters.Add(new DuckDBParameter { Value = databaseId }); + cmd.Parameters.Add(new DuckDBParameter { Value = 1 }); + cmd.Parameters.Add(new DuckDBParameter { Value = "ROWS" }); + cmd.Parameters.Add(new DuckDBParameter { Value = "data" }); + cmd.Parameters.Add(new DuckDBParameter { Value = @"D:\data\prod.mdf" }); + cmd.Parameters.Add(new DuckDBParameter { Value = 1024.00m }); + await cmd.ExecuteNonQueryAsync(); + } +} diff --git a/Lite.Tests/SystemHealthEventsCollectorDefinitionTests.cs b/Lite.Tests/SystemHealthEventsCollectorDefinitionTests.cs new file mode 100644 index 000000000..bb7008830 --- /dev/null +++ b/Lite.Tests/SystemHealthEventsCollectorDefinitionTests.cs @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Pins the cross-SKU parity contract of the shared system_health_events collector definition (Stage 1 +/// raw capture of the built-in system_health Extended Events session): the server-scoped ring-buffer +/// read, the exact six-event capture filter (and the deliberate exclusion of xml_deadlock_report / +/// security / connectivity), the event_time watermark with its 10-minute fallback, the collect- +/// everywhere-but-Azure-SQL-DB AppliesTo, the three-column raw-XML payload, and the null-tolerant +/// ReadAsync/WritePayload. An intentional change to any of these must consciously update this test. +/// </summary> +public sealed class SystemHealthEventsCollectorDefinitionTests +{ + private static readonly RecordingCollectorDeltaCalculator s_deltas = new(); + + private static CollectorContext MakeContext( + bool isAzureSqlDb = false, + bool isAzureManagedInstance = false, + DateTime? watermark = null, + DateTime? collectionTime = null) + => new() + { + ServerId = 42, + ServerName = "test-server", + CollectionTime = collectionTime ?? new DateTime(2026, 7, 5, 12, 0, 0, DateTimeKind.Utc), + Deltas = s_deltas, + Target = new CollectorTargetInfo { IsAzureSqlDb = isAzureSqlDb, IsAzureManagedInstance = isAzureManagedInstance }, + Watermark = watermark, + }; + + [Fact] + public void Identity_AndSessionNamePinned() + { + Assert.Equal("system_health_events", SystemHealthEventsCollector.Instance.Name); + Assert.Equal("system_health_events", SystemHealthEventsCollector.Instance.TargetTable); + Assert.Equal("system_health_event_id", SystemHealthEventsCollector.Instance.PrefixIdColumnName); + Assert.Equal("event_time", SystemHealthEventsCollector.Instance.WatermarkColumn); + Assert.Equal("system_health", SystemHealthEventsCollector.XeSessionName); + } + + [Fact] + public void BuildQuery_ReadsServerScopedSystemHealthRingBuffer() + { + var text = SystemHealthEventsCollector.Instance.BuildQuery(MakeContext()).Text; + + Assert.Contains("sys.dm_xe_session_targets", text, StringComparison.Ordinal); + Assert.Contains("JOIN sys.dm_xe_sessions AS xes", text, StringComparison.Ordinal); + Assert.Contains("N'system_health'", text, StringComparison.Ordinal); + Assert.Contains("RingBufferTarget/event", text, StringComparison.Ordinal); + Assert.Contains("evt.query('.')", text, StringComparison.Ordinal); + Assert.Contains("> @cutoff_time", text, StringComparison.Ordinal); + + /* Server-scoped only — this collector never reaches Azure SQL DB (gated by AppliesTo), so no + database-scoped DMV variant exists. */ + Assert.DoesNotContain("dm_xe_database_session_targets", text, StringComparison.Ordinal); + } + + [Fact] + public void BuildQuery_CapturesExactlyTheSixFeederEvents_AndExcludesDeadlockSecurityConnectivity() + { + var text = SystemHealthEventsCollector.Instance.BuildQuery(MakeContext()).Text; + + foreach (var captured in new[] + { + "sp_server_diagnostics_component_result", + "error_reported", + "memory_broker_ring_buffer_recorded", + "memory_node_oom_ring_buffer_recorded", + "scheduler_monitor_system_health_ring_buffer_recorded", + "wait_info", + }) + { + Assert.Contains($"N'{captured}'", text, StringComparison.Ordinal); + } + + /* xml_deadlock_report is owned by DeadlocksCollector; security/connectivity are outside the + warning categories this feeds — none may leak into the capture filter. */ + Assert.DoesNotContain("xml_deadlock_report", text, StringComparison.Ordinal); + Assert.DoesNotContain("security_error_ring_buffer_recorded", text, StringComparison.Ordinal); + Assert.DoesNotContain("connectivity_ring_buffer_recorded", text, StringComparison.Ordinal); + } + + [Fact] + public void BuildQuery_PinsWatermarkCutoff_AndTenMinuteFallback() + { + var watermark = new DateTime(2026, 7, 5, 11, 55, 0, DateTimeKind.Utc); + var withWatermark = SystemHealthEventsCollector.Instance.BuildQuery(MakeContext(watermark: watermark)); + var parameter = Assert.Single(withWatermark.Parameters); + Assert.Equal("@cutoff_time", parameter.Name); + Assert.Equal(watermark, parameter.Value); + Assert.Equal(CollectorParameterType.DateTime2, parameter.Type); + + var collectionTime = new DateTime(2026, 7, 5, 12, 0, 0, DateTimeKind.Utc); + var fallback = SystemHealthEventsCollector.Instance.BuildQuery(MakeContext(collectionTime: collectionTime)); + Assert.Equal(collectionTime.AddMinutes(-10), Assert.Single(fallback.Parameters).Value); + } + + [Fact] + public void AppliesTo_CollectsEverywhereExceptAzureSqlDb() + { + /* No built-in system_health session on Azure SQL DB (MS Learn-verified); the captured events + are server-level with no database-scoped equivalent. Managed Instance and on-prem have the + full server-scoped session. */ + Assert.False(SystemHealthEventsCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureSqlDb = true })); + Assert.True(SystemHealthEventsCollector.Instance.AppliesTo(new CollectorTargetInfo { IsAzureManagedInstance = true })); + Assert.True(SystemHealthEventsCollector.Instance.AppliesTo(new CollectorTargetInfo())); + } + + [Fact] + public void PayloadColumns_ThreeRawColumns_InAppendOrder() + { + var columns = SystemHealthEventsCollector.Instance.PayloadColumns; + + Assert.Equal(new[] { "event_time", "event_type", "event_xml" }, columns.Select(c => c.Name).ToArray()); + Assert.Equal(CollectorColumnType.Timestamp, columns.Single(c => c.Name == "event_time").Type); + Assert.Equal(CollectorColumnType.Varchar, columns.Single(c => c.Name == "event_type").Type); + Assert.Equal(CollectorColumnType.Varchar, columns.Single(c => c.Name == "event_xml").Type); + } + + [Fact] + public async Task ReadAsync_WritePayload_PinsThreeColumnOrder_AndToleratesNulls() + { + var context = MakeContext(); + var eventTime = new DateTime(2026, 7, 5, 11, 58, 0, DateTimeKind.Utc); + const string rawXml = "<event name=\"error_reported\" package=\"sqlserver\"><data name=\"error_number\"><value>701</value></data></event>"; + + using var reader = new FakeCollectorDataReader( + new object[] { eventTime, "error_reported", rawXml }, + new object[] { DBNull.Value, DBNull.Value, DBNull.Value }); + var rows = await SystemHealthEventsCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + + Assert.Equal(2, rows.Count); + + var writer = new RecordingCollectorRowWriter(); + SystemHealthEventsCollector.Instance.WritePayload(rows[0], writer, context); + + Assert.Equal(3, writer.Values.Count); + Assert.Equal(eventTime, writer.Values[0]); + Assert.Equal("error_reported", writer.Values[1]); + Assert.Equal(rawXml, writer.Values[2]); /* raw event XML stored verbatim — no shredding */ + + /* An all-NULL row still writes (nothing filters it); capture is a point-in-time event read + with no delta calculator interaction. */ + var nullWriter = new RecordingCollectorRowWriter(); + SystemHealthEventsCollector.Instance.WritePayload(rows[1], nullWriter, context); + Assert.Equal(3, nullWriter.Values.Count); + Assert.All(nullWriter.Values, Assert.Null); + Assert.Empty(s_deltas.Calls); + } +} diff --git a/Lite.Tests/SystemHealthEventsRoundTripTests.cs b/Lite.Tests/SystemHealthEventsRoundTripTests.cs new file mode 100644 index 000000000..4885b8ac4 --- /dev/null +++ b/Lite.Tests/SystemHealthEventsRoundTripTests.cs @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.IO; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using PerformanceMonitorLite.Database; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Real-DuckDB round-trip pin for the #1262 system_health_events collector. The definition test pins +/// PayloadColumns/WritePayload against a recording writer, but nothing else proves the Lite DuckDB +/// table's physical column layout stays aligned with WritePayload — the appender writes BY POSITION, +/// so a table whose column count/order drifts from (4 prefix + WritePayload) fails at EndRow(). This +/// exercises the exact append path RemoteCollectorService.RunCollectorDefinitionAsync uses +/// (CreateAppender -> prefix -> WritePayload -> EndRow) against a freshly initialized schema, then +/// reads the raw event columns back, including a NULL event row. +/// </summary> +public sealed class SystemHealthEventsRoundTripTests : IDisposable +{ + private readonly string _tempDir; + + public SystemHealthEventsRoundTripTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "LiteSysHealthRt_" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + try + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, recursive: true); + } + } + catch + { + /* Best-effort cleanup */ + } + } + + [Fact] + public async Task SystemHealthEvents_AppendRoundTrips_RawXml() + { + var eventTime = new DateTime(2026, 7, 5, 11, 58, 0, DateTimeKind.Utc); + const string rawXml = "<event name=\"memory_broker_ring_buffer_recorded\" package=\"sqlserver\"><data name=\"broker\"><value>1</value></data></event>"; + var row = new SystemHealthEventsCollector.Row + { + EventTime = eventTime, + EventType = "memory_broker_ring_buffer_recorded", + EventXml = rawXml, + }; + + using var connection = await AppendOneRowAsync(SystemHealthEventsCollector.Instance, row); + + Assert.Equal(eventTime, (DateTime)await ScalarAsync(connection, "system_health_events", "event_time")); + Assert.Equal("memory_broker_ring_buffer_recorded", (string)await ScalarAsync(connection, "system_health_events", "event_type")); + Assert.Equal(rawXml, (string)await ScalarAsync(connection, "system_health_events", "event_xml")); + } + + [Fact] + public async Task SystemHealthEvents_AppendRoundTrips_NullEventRow() + { + /* An all-NULL event row (defensive: the shred yielded no timestamp/name/xml) must still + round-trip through the nullable columns. */ + var row = new SystemHealthEventsCollector.Row { EventTime = null, EventType = null, EventXml = null }; + + using var connection = await AppendOneRowAsync(SystemHealthEventsCollector.Instance, row); + + Assert.Equal(DBNull.Value, await ScalarAsync(connection, "system_health_events", "event_type")); + Assert.Equal(DBNull.Value, await ScalarAsync(connection, "system_health_events", "event_xml")); + } + + /// <summary> + /// Appends one row through the exact runner path (prefix columns + WritePayload + EndRow) against a + /// freshly initialized DuckDB and returns the open connection. EndRow throwing here would mean the + /// Schema.cs table column count/order drifted from WritePayload. + /// </summary> + private async Task<DuckDBConnection> AppendOneRowAsync<TRow>(ICollectorDefinition<TRow> definition, TRow row) + { + var dbPath = Path.Combine(_tempDir, $"{definition.TargetTable}.duckdb"); + await new DuckDbInitializer(dbPath).InitializeAsync(); + + var context = CollectorTestContext.Make(new RecordingCollectorDeltaCalculator()); + + var connection = new DuckDBConnection($"Data Source={dbPath}"); + await connection.OpenAsync(TestContext.Current.CancellationToken); + + using (var appender = connection.CreateAppender(definition.TargetTable)) + { + var appenderRow = appender.CreateRow(); + if (definition.IncludesCollectionId) + { + appenderRow.AppendValue(1L); + } + appenderRow.AppendValue(context.CollectionTime).AppendValue(42).AppendValue("s"); + + var writer = new AppenderCollectorRowWriter { CurrentRow = appenderRow }; + definition.WritePayload(row, writer, context); + appenderRow.EndRow(); /* throws if the appended column count != table column count */ + } + + return connection; + } + + private static async Task<object> ScalarAsync(DuckDBConnection connection, string table, string column) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = $"SELECT {column} FROM {table} LIMIT 1"; + return (await cmd.ExecuteScalarAsync(TestContext.Current.CancellationToken))!; + } +} diff --git a/Lite.Tests/TempDbStatsCollectorDefinitionTests.cs b/Lite.Tests/TempDbStatsCollectorDefinitionTests.cs new file mode 100644 index 000000000..0afc93a4e --- /dev/null +++ b/Lite.Tests/TempDbStatsCollectorDefinitionTests.cs @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Pins the parity contract of the extracted tempdb_stats definition: two result sets collapse +/// to exactly one row (zeros when empty — matching the original collector), and the payload +/// order matches the tempdb_stats schema. +/// </summary> +public sealed class TempDbStatsCollectorDefinitionTests +{ + [Fact] + public void PayloadColumns_MatchSchemaOrder() + { + var names = TempDbStatsCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray(); + + Assert.Equal( + new[] + { + "user_object_reserved_mb", + "internal_object_reserved_mb", + "version_store_reserved_mb", + "total_reserved_mb", + "unallocated_mb", + "total_sessions_using_tempdb", + "top_session_id", + "top_session_tempdb_mb", + }, + names); + } + + [Fact] + public void Query_TargetsBothTempDbDmvs() + { + var queryText = TempDbStatsCollector.Instance.BuildQuery(CollectorTestContext.Make(new RecordingCollectorDeltaCalculator())).Text; + Assert.Contains("tempdb.sys.dm_db_file_space_usage", queryText, System.StringComparison.Ordinal); + Assert.Contains("sys.dm_db_session_space_usage", queryText, System.StringComparison.Ordinal); + Assert.Equal("tempdb_stats", TempDbStatsCollector.Instance.Name); + Assert.Equal("tempdb_stats", TempDbStatsCollector.Instance.TargetTable); + } + + [Fact] + public async Task ReadAsync_CombinesTwoResultSets_IntoOneRow() + { + using var reader = FakeCollectorDataReader.WithResultSets( + new[] { new object[] { 1.5m, 2.5m, 3.5m, 7.5m, 10.0m } }, + new[] { new object[] { 55, 12.25m, 9L } }); + + var context = CollectorTestContext.Make(new RecordingCollectorDeltaCalculator()); + + var rows = await TempDbStatsCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + + var row = Assert.Single(rows); + Assert.Equal(new TempDbStatsCollector.Row(1.5m, 2.5m, 3.5m, 7.5m, 10.0m, 9L, 55, 12.25m), row); + } + + [Fact] + public async Task ReadAsync_EmptyResultSets_StillYieldsOneZeroRow() + { + using var reader = FakeCollectorDataReader.WithResultSets( + System.Array.Empty<object[]>(), + System.Array.Empty<object[]>()); + + var context = CollectorTestContext.Make(new RecordingCollectorDeltaCalculator()); + + var rows = await TempDbStatsCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + + var row = Assert.Single(rows); + Assert.Equal(default(TempDbStatsCollector.Row), row); + } + + [Fact] + public void WritePayload_EmitsSchemaOrder_NoDeltas() + { + var deltas = new RecordingCollectorDeltaCalculator(); + var writer = new RecordingCollectorRowWriter(); + var row = new TempDbStatsCollector.Row(1.5m, 2.5m, 3.5m, 7.5m, 10.0m, 9L, 55, 12.25m); + + TempDbStatsCollector.Instance.WritePayload(row, writer, CollectorTestContext.Make(deltas)); + + Assert.Equal(new object?[] { 1.5m, 2.5m, 3.5m, 7.5m, 10.0m, 9L, 55, 12.25m }, writer.Values); + Assert.Empty(deltas.Calls); + } +} diff --git a/Lite/Analysis/TestDataSeeder.cs b/Lite.Tests/TestDataSeeder.cs similarity index 96% rename from Lite/Analysis/TestDataSeeder.cs rename to Lite.Tests/TestDataSeeder.cs index 98e636aeb..84379fd3a 100644 --- a/Lite/Analysis/TestDataSeeder.cs +++ b/Lite.Tests/TestDataSeeder.cs @@ -695,10 +695,11 @@ await SeedDatabaseConfigAsync( /// <summary> /// Wait spike anomaly: normally low waits, then sudden PAGEIOLATCH flood. - /// Baseline: 24h with minimal PAGEIOLATCH. + /// Baseline: 24h with minimal PAGEIOLATCH (one distinct day → untrustworthy WaitMsPerSec baseline). /// Analysis window: 4h with massive PAGEIOLATCH. /// - /// Expected: ANOMALY_WAIT_PAGEIOLATCH_SH with high ratio. + /// Expected: one ANOMALY_WAIT_PROFILE (is_new fallback, since the thin baseline can't be trusted) + /// with PAGEIOLATCH_SH as the dominant contrib_<TYPE>. /// </summary> public async Task SeedWaitSpikeAnomalyAsync() { @@ -827,7 +828,8 @@ internal async Task ClearTestDataAsync() "query_snapshots", "tempdb_stats", "perfmon_stats", "blocked_process_reports", "deadlocks", "memory_grant_stats", "waiting_tasks", "servers", "running_jobs", "session_stats", - "trace_flags", "server_properties", "database_size_stats" + "trace_flags", "server_properties", "database_size_stats", + "plan_cache_stats", "memory_pressure_events" }; using var readLock = _duckDb.AcquireReadLock(); @@ -1528,7 +1530,76 @@ INSERT INTO memory_clerks } /// <summary> - /// Seeds database_config with per-database configuration flags. + /// Seeds one plan_cache_stats snapshot row (a single cacheobjtype/objtype group) with the given + /// plan/size totals, so CollectPlanCacheFactsAsync SUMs it into a PLAN_CACHE_BLOAT fact. Stamped at + /// TestPeriodEnd (the latest-snapshot the collector reads). + /// </summary> + internal async Task SeedPlanCacheStatsAsync(int totalPlans, int singleUsePlans, int totalSizeMb, int singleUseSizeMb) + { + using var readLock = _duckDb.AcquireReadLock(); + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +INSERT INTO plan_cache_stats + (collection_id, collection_time, server_id, server_name, + cacheobjtype, objtype, total_plans, single_use_plans, total_size_mb, single_use_size_mb) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)"; + + cmd.Parameters.Add(new DuckDBParameter { Value = _nextId-- }); + cmd.Parameters.Add(new DuckDBParameter { Value = TestPeriodEnd }); + cmd.Parameters.Add(new DuckDBParameter { Value = TestServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = TestServerName }); + cmd.Parameters.Add(new DuckDBParameter { Value = "Compiled Plan" }); + cmd.Parameters.Add(new DuckDBParameter { Value = "Adhoc" }); + cmd.Parameters.Add(new DuckDBParameter { Value = totalPlans }); + cmd.Parameters.Add(new DuckDBParameter { Value = singleUsePlans }); + cmd.Parameters.Add(new DuckDBParameter { Value = totalSizeMb }); + cmd.Parameters.Add(new DuckDBParameter { Value = singleUseSizeMb }); + + await cmd.ExecuteNonQueryAsync(); + } + + /// <summary> + /// Seeds memory_pressure_events ring-buffer rows (each a notification + process/system indicator), + /// in-window at TestPeriodStart + offset, so CollectMemoryPressureEventFactsAsync classifies them. + /// </summary> + internal async Task SeedMemoryPressureEventsAsync(params (string notification, int processIndicator, int systemIndicator)[] events) + { + using var readLock = _duckDb.AcquireReadLock(); + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + + for (var i = 0; i < events.Length; i++) + { + var (notification, processIndicator, systemIndicator) = events[i]; + var t = TestPeriodStart.AddMinutes(30 + i * 5); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +INSERT INTO memory_pressure_events + (collection_id, collection_time, server_id, server_name, + sample_time, memory_notification, memory_indicators_process, memory_indicators_system) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8)"; + + cmd.Parameters.Add(new DuckDBParameter { Value = _nextId-- }); + cmd.Parameters.Add(new DuckDBParameter { Value = t }); + cmd.Parameters.Add(new DuckDBParameter { Value = TestServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = TestServerName }); + cmd.Parameters.Add(new DuckDBParameter { Value = t }); + cmd.Parameters.Add(new DuckDBParameter { Value = notification }); + cmd.Parameters.Add(new DuckDBParameter { Value = processIndicator }); + cmd.Parameters.Add(new DuckDBParameter { Value = systemIndicator }); + + await cmd.ExecuteNonQueryAsync(); + } + } + + /// <summary> + /// Seeds database_config with per-database configuration flags. Query Store defaults ON (the healthy + /// value, like rcsi-on / CHECKSUM here) so the QS-off advisory arm stays dormant in fixtures not + /// exercising it; the QS-off scorer arm is covered directly in FactScorerTests with hand-built facts. /// </summary> internal async Task SeedDatabaseConfigAsync( params (string dbName, bool rcsiOn, bool autoShrink, bool autoClose, string pageVerify)[] databases) @@ -1546,7 +1617,7 @@ INSERT INTO database_config recovery_model, is_auto_shrink_on, is_auto_close_on, is_read_committed_snapshot_on, is_auto_create_stats_on, is_auto_update_stats_on, page_verify_option, is_query_store_on) -VALUES ($1, $2, $3, $4, $5, 'FULL', $6, $7, $8, true, true, $9, false)"; +VALUES ($1, $2, $3, $4, $5, 'FULL', $6, $7, $8, true, true, $9, true)"; cmd.Parameters.Add(new DuckDBParameter { Value = _nextId-- }); cmd.Parameters.Add(new DuckDBParameter { Value = TestPeriodEnd }); diff --git a/Lite.Tests/ThemeCompletenessTests.cs b/Lite.Tests/ThemeCompletenessTests.cs new file mode 100644 index 000000000..15dfefa6d --- /dev/null +++ b/Lite.Tests/ThemeCompletenessTests.cs @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// <summary> +/// Guards that Lite stays fully theme-able: the Dark, Light, and CoolBreeze dictionaries define the SAME +/// set of resource keys, and every theme token the Lite XAML references resolves in ALL three. This is the +/// automated backstop for "Light / CoolBreeze mode has no missing resources" — e.g. the Slicer keys that +/// once lived only in Dark (#1441) would fail here. Text-parses the XAML (no WPF apartment needed) and +/// locates the Lite project via <see cref="CallerFilePathAttribute"/>. +/// </summary> +public sealed class ThemeCompletenessTests +{ + private static readonly string[] ThemeRelativePaths = + { + "Themes/DarkTheme.xaml", + "Themes/LightTheme.xaml", + "Themes/CoolBreezeTheme.xaml", + }; + + private static readonly Regex KeyDefinition = + new("x:Key=\"([A-Za-z_][A-Za-z0-9_]*)\"", RegexOptions.Compiled); + + private static readonly Regex ResourceReference = + new(@"\{(?:Dynamic|Static)Resource\s+([A-Za-z_][A-Za-z0-9_]*)\s*\}", RegexOptions.Compiled); + + [Fact] + public void Light_and_CoolBreeze_define_the_same_keys_as_Dark() + { + var dark = KeysIn(ThemeRelativePaths[0]); + var light = KeysIn(ThemeRelativePaths[1]); + var coolBreeze = KeysIn(ThemeRelativePaths[2]); + + Assert.False(dark.Count == 0, "Parsed zero keys from DarkTheme.xaml — the test cannot find the theme files."); + + var missingFromLight = dark.Except(light).OrderBy(k => k, StringComparer.Ordinal).ToList(); + var extraInLight = light.Except(dark).OrderBy(k => k, StringComparer.Ordinal).ToList(); + Assert.True( + missingFromLight.Count == 0 && extraInLight.Count == 0, + $"LightTheme.xaml key set differs from DarkTheme.xaml.\n" + + $" Missing from Light (would render unstyled in Light): {Join(missingFromLight)}\n" + + $" Extra in Light (not in Dark): {Join(extraInLight)}"); + + var missingFromCool = dark.Except(coolBreeze).OrderBy(k => k, StringComparer.Ordinal).ToList(); + var extraInCool = coolBreeze.Except(dark).OrderBy(k => k, StringComparer.Ordinal).ToList(); + Assert.True( + missingFromCool.Count == 0 && extraInCool.Count == 0, + $"CoolBreezeTheme.xaml key set differs from DarkTheme.xaml.\n" + + $" Missing from CoolBreeze: {Join(missingFromCool)}\n" + + $" Extra in CoolBreeze: {Join(extraInCool)}"); + } + + [Fact] + public void Every_theme_token_referenced_in_lite_xaml_resolves_in_all_three_themes() + { + var liteDir = LiteDirectory(); + var dark = KeysIn(ThemeRelativePaths[0]); + var light = KeysIn(ThemeRelativePaths[1]); + var coolBreeze = KeysIn(ThemeRelativePaths[2]); + + var offenders = new List<string>(); + foreach (var xaml in Directory.EnumerateFiles(liteDir, "*.xaml", SearchOption.AllDirectories)) + { + // Skip build output by whole path SEGMENT (obj/bin), not a substring: Path.Combine("obj", "") + // yields "obj" with no separator, so a Contains("obj") would also skip a legitimately-named + // XAML file such as ObjectBrowser.xaml. + if (HasBuildOutputSegment(xaml)) + { + continue; + } + + var text = File.ReadAllText(xaml); + foreach (Match m in ResourceReference.Matches(text)) + { + var key = m.Groups[1].Value; + if (!dark.Contains(key)) + { + continue; // not a theme token (local style / system / cross-assembly) + } + + if (!light.Contains(key)) + { + offenders.Add($"{Path.GetFileName(xaml)} references '{key}', which is missing from LightTheme.xaml"); + } + if (!coolBreeze.Contains(key)) + { + offenders.Add($"{Path.GetFileName(xaml)} references '{key}', which is missing from CoolBreezeTheme.xaml"); + } + } + } + + Assert.True(offenders.Count == 0, + "Theme tokens referenced by the Lite XAML do not resolve in every theme:\n " + + string.Join("\n ", offenders.Distinct())); + } + + private static HashSet<string> KeysIn(string relativePath) + { + var path = Path.Combine(LiteDirectory(), relativePath.Replace('/', Path.DirectorySeparatorChar)); + var text = File.ReadAllText(path); + return KeyDefinition.Matches(text).Select(m => m.Groups[1].Value).ToHashSet(StringComparer.Ordinal); + } + + /// <summary>True if any whole path segment is a build-output directory (obj/bin). Segment-based, not a + /// substring test — a plain Contains("obj") would false-positive on a file such as ObjectBrowser.xaml.</summary> + private static bool HasBuildOutputSegment(string path) => + path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + .Any(seg => seg.Equals("obj", StringComparison.OrdinalIgnoreCase) || + seg.Equals("bin", StringComparison.OrdinalIgnoreCase)); + + /// <summary>The Lite project directory, resolved from this test file's compile-time path (Lite.Tests is a + /// sibling of the Lite project).</summary> + private static string LiteDirectory([CallerFilePath] string thisFile = "") + { + var testDir = Path.GetDirectoryName(thisFile)!; + return Path.GetFullPath(Path.Combine(testDir, "..", "Lite")); + } + + private static string Join(IReadOnlyCollection<string> keys) => + keys.Count == 0 ? "(none)" : string.Join(", ", keys); +} diff --git a/Lite.Tests/ThemeParityLiteDarlingTests.cs b/Lite.Tests/ThemeParityLiteDarlingTests.cs new file mode 100644 index 000000000..347626e97 --- /dev/null +++ b/Lite.Tests/ThemeParityLiteDarlingTests.cs @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// <summary> +/// Cross-app theme-parity guard for the two ACTIVE apps: Lite and the Darling viewer. Their WPF +/// brush/color dictionaries are NOT shared — each ships its own <c>Themes/{Dark,Light,CoolBreeze}Theme.xaml</c>, +/// hand-synced with no enforcement. An existing <c>ThemeParityTests</c> guards Dashboard↔Lite, but it lives +/// in <c>Dashboard.Tests</c>, which no CI workflow builds or runs — pure decoration — and it never looks at +/// Darling. Nothing at all pins Lite↔Darling, so a color tweak to one silently drifts the go-forward pair +/// apart. This test closes that hole and lives in Lite.Tests, which BOTH build.yml and nightly.yml run. +/// +/// For each theme it parses the <c>x:Key -> color</c> map from both apps' same-theme file and asserts +/// that every key present in BOTH (the intersection) resolves to the same color. Keys that exist in only +/// one app are allowed and ignored — Darling legitimately adds 8 viewer-only brushes (CardBorder + the +/// Viewer* set), and either app may add app-specific resources without tripping the guard. +/// +/// RATCHET: <see cref="KnownColorDivergences"/> is the escape hatch for an intentional, documented color +/// divergence. It is EMPTY today because the shared palette is byte-for-byte identical across the two apps; +/// if a deliberate divergence is ever introduced, grandfather it there (with a comment) to keep the build +/// green while the drift stays visible — same shrinking-to-do discipline as the coverage pin. +/// +/// Text-parses the XAML (no WPF apartment needed) and locates both projects via +/// <see cref="CallerFilePathAttribute"/>. +/// </summary> +public sealed class ThemeParityLiteDarlingTests +{ + private static readonly XNamespace X = "http://schemas.microsoft.com/winfx/2006/xaml"; + + /// <summary> + /// Intentional, documented color divergences to ignore, keyed as <c>"{Theme}:{Key}"</c> (e.g. + /// <c>"Dark:AccentBrush"</c>). Empty — Lite and the Darling viewer share an identical palette today. + /// Add an entry ONLY for a deliberate divergence, with a comment explaining why the two apps differ. + /// </summary> + private static readonly HashSet<string> KnownColorDivergences = new(StringComparer.Ordinal) + { + // (empty) -- the Lite and Darling shared palette is identical. + }; + + [Theory] + [InlineData("Dark")] + [InlineData("Light")] + [InlineData("CoolBreeze")] + public void SharedThemeKeys_HaveIdenticalColors_AcrossLiteAndDarling(string theme) + { + var liteFile = Path.Combine(LiteThemesDir(), $"{theme}Theme.xaml"); + var darlingFile = Path.Combine(DarlingThemesDir(), $"{theme}Theme.xaml"); + Assert.True(File.Exists(liteFile), $"Lite theme file not found: {liteFile}"); + Assert.True(File.Exists(darlingFile), $"Darling viewer theme file not found: {darlingFile}"); + + var lite = LoadColorMap(liteFile); + var darling = LoadColorMap(darlingFile); + + var shared = lite.Keys + .Intersect(darling.Keys) + .OrderBy(k => k, StringComparer.Ordinal) + .ToList(); + + // Guard against a parser regression (e.g. a XAML namespace change) silently turning this into a + // vacuous pass: the two apps share dozens of palette keys today, so an empty intersection is a bug. + Assert.True(shared.Count > 0, + $"No shared color/brush keys parsed for the {theme} theme — the parser or the theme files changed shape."); + + var mismatches = shared + .Where(k => !KnownColorDivergences.Contains($"{theme}:{k}")) + .Where(k => !string.Equals(lite[k], darling[k], StringComparison.Ordinal)) + .Select(k => $" {k}: Lite={lite[k]} Darling={darling[k]}") + .ToList(); + + Assert.True(mismatches.Count == 0, + $"{theme} theme: {mismatches.Count} shared color/brush key(s) differ between " + + $"Lite\\Themes\\{theme}Theme.xaml and the Darling viewer's Themes\\{theme}Theme.xaml. Re-sync " + + "the two palettes (or, if the divergence is intentional, add \"" + theme + ":<Key>\" to " + + "KnownColorDivergences with a comment):\n" + string.Join("\n", mismatches)); + } + + /// <summary> + /// Parses a theme ResourceDictionary into a map of x:Key -> normalized #AARRGGBB color. Handles the two + /// resource forms used in these files: standalone <Color x:Key="...">#hex</Color> and + /// <SolidColorBrush x:Key="..." Color="#hex | {StaticResource SomeColor}"/>, resolving a brush's + /// {StaticResource} reference back to the underlying <Color> hex. Entries whose color cannot be + /// resolved to a concrete value are omitted. First definition wins. + /// </summary> + private static Dictionary<string, string> LoadColorMap(string xamlPath) + { + var doc = XDocument.Load(xamlPath); + + // Pass 1: literal <Color> resources. + var colors = new Dictionary<string, string>(StringComparer.Ordinal); + foreach (var el in doc.Descendants().Where(e => e.Name.LocalName == "Color")) + { + var key = (string?)el.Attribute(X + "Key"); + if (key is null || colors.ContainsKey(key)) + { + continue; + } + colors[key] = el.Value.Trim(); + } + + // Pass 2: <SolidColorBrush> resources (Color is an unprefixed attribute, so no namespace). + var brushes = new Dictionary<string, string>(StringComparer.Ordinal); + foreach (var el in doc.Descendants().Where(e => e.Name.LocalName == "SolidColorBrush")) + { + var key = (string?)el.Attribute(X + "Key"); + var color = (string?)el.Attribute("Color"); + if (key is null || color is null || brushes.ContainsKey(key)) + { + continue; + } + brushes[key] = color.Trim(); + } + + var map = new Dictionary<string, string>(StringComparer.Ordinal); + foreach (var pair in colors) + { + var norm = NormalizeColor(pair.Value); + if (norm is not null) + { + map[pair.Key] = norm; + } + } + foreach (var pair in brushes) + { + var resolved = ResolveColor(pair.Value, colors); + var norm = resolved is null ? null : NormalizeColor(resolved); + if (norm is not null) + { + map[pair.Key] = norm; + } + } + return map; + } + + private static readonly Regex StaticResourceRef = + new(@"^\{StaticResource\s+([^}]+)\}$", RegexOptions.Compiled); + + /// <summary> + /// Resolves a brush Color value to a literal color token, following one or more {StaticResource X} hops + /// into the <Color> table. Returns null when the chain dead-ends at a key that is not a <Color>. + /// </summary> + private static string? ResolveColor(string value, Dictionary<string, string> colors, int depth = 0) + { + if (depth > 10) + { + return null; + } + + var trimmed = value.Trim(); + var match = StaticResourceRef.Match(trimmed); + if (!match.Success) + { + return trimmed; + } + + var refKey = match.Groups[1].Value.Trim(); + return colors.TryGetValue(refKey, out var inner) + ? ResolveColor(inner, colors, depth + 1) + : null; + } + + /// <summary> + /// Normalizes a color token to canonical upper-case #AARRGGBB so equivalent spellings (#RGB, #ARGB, + /// #RRGGBB, #AARRGGBB — WPF treats a missing alpha as fully opaque) compare equal. Non-hex named colors + /// (e.g. White, Transparent) are upper-cased as-is so they still compare. Returns null for empty input. + /// </summary> + private static string? NormalizeColor(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return null; + } + + var c = raw.Trim(); + var match = Regex.Match(c, "^#([0-9A-Fa-f]+)$"); + if (!match.Success) + { + return c.ToUpperInvariant(); + } + + var h = match.Groups[1].Value.ToUpperInvariant(); + h = h.Length switch + { + 3 => "FF" + string.Concat(h.Select(ch => $"{ch}{ch}")), // #RGB -> #FFRRGGBB + 4 => string.Concat(h.Select(ch => $"{ch}{ch}")), // #ARGB -> #AARRGGBB + 6 => "FF" + h, // #RRGGBB -> #FFRRGGBB + _ => h, + }; + + // Anything that did not land on a clean 8-digit ARGB (e.g. a malformed 5/7-digit token) falls back + // to the upper-cased original so it still compares deterministically rather than being dropped. + return h.Length == 8 ? "#" + h : c.ToUpperInvariant(); + } + + /// <summary>Lite's Themes directory, resolved from this test file's compile-time path (Lite.Tests is a + /// sibling of the Lite project).</summary> + private static string LiteThemesDir([CallerFilePath] string thisFile = "") + { + var testDir = Path.GetDirectoryName(thisFile)!; + return Path.GetFullPath(Path.Combine(testDir, "..", "Lite", "Themes")); + } + + /// <summary>The Darling viewer's Themes directory, resolved from this test file's compile-time path + /// (Lite.Tests and Darling both sit at the repo root).</summary> + private static string DarlingThemesDir([CallerFilePath] string thisFile = "") + { + var testDir = Path.GetDirectoryName(thisFile)!; + return Path.GetFullPath(Path.Combine(testDir, "..", "Darling", "PerformanceMonitor.Darling.Viewer", "Themes")); + } +} diff --git a/Lite.Tests/VersionTextTests.cs b/Lite.Tests/VersionTextTests.cs new file mode 100644 index 000000000..4feb8621c --- /dev/null +++ b/Lite.Tests/VersionTextTests.cs @@ -0,0 +1,98 @@ +using System; +using PerformanceMonitor.Ui; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// <summary> +/// Pins <see cref="VersionText"/>, the shared version parse/normalize the Dashboard and Lite server-list +/// views use. The load-bearing case is the two-part-version clamp: the hand-rolled snippets this replaced +/// did <c>new Version(p.Major, p.Minor, p.Build)</c> after <c>Version.TryParse</c>, which THROWS on a +/// two-part version like "3.1" (TryParse leaves Build == -1, and the Version ctor rejects a negative build). +/// The first test is that regression, as an assertion that it no longer throws. +/// </summary> +public class VersionTextTests +{ + [Fact] + public void TwoPartVersion_ClampsBuildToZero_InsteadOfThrowing() + { + // The whole point: this used to throw ArgumentOutOfRangeException at every call site. + var ex = Record.Exception(() => VersionText.Normalize("3.1")); + Assert.Null(ex); + + Assert.Equal("3.1.0", VersionText.Normalize("3.1")); + Assert.Equal(new Version(3, 1, 0), VersionText.Parse("3.1")); + } + + [Theory] + [InlineData("3.1.0", "3.1.0")] + [InlineData("3.1.0.0", "3.1.0")] // four-part collapses to three + [InlineData("3.1", "3.1.0")] // two-part clamps + [InlineData("3.2.0+abc123", "3.2.0")] // +build metadata stripped + [InlineData("3.2.0-rc1", "3.2.0")] // -prerelease stripped + [InlineData(" 3.1.0 ", "3.1.0")] // trimmed + public void Normalize_ProducesThreePartDisplay(string raw, string expected) + { + Assert.Equal(expected, VersionText.Normalize(raw)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Normalize_NullOrBlank_IsEmptyString(string? raw) + { + Assert.Equal(string.Empty, VersionText.Normalize(raw)); + } + + [Theory] + [InlineData("Unreachable")] + [InlineData("not-a-version")] + [InlineData("v3")] + public void Normalize_Unparseable_PassesTheTrimmedOriginalThrough(string raw) + { + // Better to show a weird value than to lose it — the views fall back to the raw string. + Assert.Equal(raw.Trim(), VersionText.Normalize(raw)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("Unreachable")] + [InlineData("v3")] + public void Parse_NullBlankOrGarbage_IsNull(string? raw) + { + Assert.Null(VersionText.Parse(raw)); + } + + [Theory] + [InlineData("3.0.0", "3.1.0")] + [InlineData("3.1", "3.1.1")] // the two-part clamp on the left still compares correctly + [InlineData("2.9.0", "3.0.0")] + [InlineData("3.1.0", "3.2.0")] + public void Parse_OrdersOlderBeforeNewer(string older, string newer) + { + var a = VersionText.Parse(older); + var b = VersionText.Parse(newer); + Assert.NotNull(a); + Assert.NotNull(b); + Assert.True(a < b, $"{older} should parse older than {newer}"); + } + + [Fact] + public void Parse_PrereleaseAndReleaseOfSameCore_CompareEqual() + { + // "3.0.0-rc1" strips to "3.0.0", so it is NOT treated as older than "3.0.0". This is a known + // limitation of stripping the suffix (fine here: these views compare RELEASED product versions). + Assert.Equal(VersionText.Parse("3.0.0"), VersionText.Parse("3.0.0-rc1")); + } + + [Fact] + public void Parse_DropsTheFourthPart_MatchingTheOriginalThreePartNormalize() + { + // The hand-rolled sites did new Version(p.Major, p.Minor, p.Build) -- three parts, Revision dropped. + // Preserve that: a four-part version collapses to its three-part core, so "3.1.0.7" == "3.1.0". + Assert.Equal(VersionText.Parse("3.1.0"), VersionText.Parse("3.1.0.7")); + Assert.Equal("3.1.0", VersionText.Normalize("3.1.0.7")); + } +} diff --git a/Lite.Tests/WaitStatsCollectorDefinitionTests.cs b/Lite.Tests/WaitStatsCollectorDefinitionTests.cs new file mode 100644 index 000000000..6de20065e --- /dev/null +++ b/Lite.Tests/WaitStatsCollectorDefinitionTests.cs @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Lite.Tests.Helpers; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// Pins the cross-SKU parity contract of the extracted wait_stats collector definition +/// (headless plan v5.1): verbatim query text, ignored-wait filtering, delta groups/keys/gap +/// policy, and payload column order. An intentional change to any of these must consciously +/// update these tests — that is the point. +/// </summary> +public sealed class WaitStatsCollectorDefinitionTests +{ + private const string ExpectedQuery = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + wait_type = ws.wait_type, + waiting_tasks_count = ws.waiting_tasks_count, + wait_time_ms = ws.wait_time_ms, + signal_wait_time_ms = ws.signal_wait_time_ms +FROM sys.dm_os_wait_stats AS ws +WHERE ws.wait_time_ms > 0 +OPTION(RECOMPILE);"; + + [Fact] + public void Query_IsTheVerbatimParityContract() + { + var context = CollectorTestContext.Make(new RecordingCollectorDeltaCalculator()); + Assert.Equal(ExpectedQuery, WaitStatsCollector.Instance.BuildQuery(context).Text); + Assert.Empty(WaitStatsCollector.Instance.BuildQuery(context).Parameters); + Assert.Null(WaitStatsCollector.Instance.WatermarkColumn); + } + + [Fact] + public void PayloadColumns_AreInAppendOrder() + { + var names = WaitStatsCollector.Instance.PayloadColumns.Select(c => c.Name).ToArray(); + + Assert.Equal( + new[] + { + "wait_type", + "waiting_tasks_count", + "wait_time_ms", + "signal_wait_time_ms", + "delta_waiting_tasks", + "delta_wait_time_ms", + "delta_signal_wait_time_ms", + }, + names); + } + + [Fact] + public async Task ReadAsync_MapsColumns_AndFiltersIgnoredWaitTypes() + { + using var reader = new FakeCollectorDataReader( + new object[] { "SOS_SCHEDULER_YIELD", 10L, 200L, 150L }, + new object[] { "SLEEP_TASK", 5L, 100L, 50L }, + new object[] { "PAGEIOLATCH_SH", 7L, 300L, 20L }); + + var context = CollectorTestContext.Make(new RecordingCollectorDeltaCalculator(), ignored: new[] { "SLEEP_TASK" }); + + var rows = await WaitStatsCollector.Instance.ReadAsync(reader, context, CancellationToken.None); + + Assert.Equal(2, rows.Count); + Assert.Equal(new WaitStatsCollector.Row("SOS_SCHEDULER_YIELD", 10, 200, 150), rows[0]); + Assert.Equal(new WaitStatsCollector.Row("PAGEIOLATCH_SH", 7, 300, 20), rows[1]); + } + + [Fact] + public void WritePayload_EmitsPayloadOrder_AndPinsDeltaGroupsKeysAndGapPolicy() + { + var deltas = new RecordingCollectorDeltaCalculator(); + var context = CollectorTestContext.Make(deltas); + var writer = new RecordingCollectorRowWriter(); + var row = new WaitStatsCollector.Row("PAGEIOLATCH_SH", 7, 300, 20); + + WaitStatsCollector.Instance.WritePayload(row, writer, context); + + /* Payload order: raw values then the three deltas (recording calculator returns value * 10). */ + Assert.Equal(new object?[] { "PAGEIOLATCH_SH", 7L, 300L, 20L, 70L, 3000L, 200L }, writer.Values); + + /* Delta contract: group names, key = wait_type, the host collection time, 300 s gap policy. */ + Assert.Equal(3, deltas.Calls.Count); + Assert.Equal(("wait_stats_tasks", "PAGEIOLATCH_SH", 7L, context.CollectionTime, 300), deltas.Calls[0]); + Assert.Equal(("wait_stats_time", "PAGEIOLATCH_SH", 300L, context.CollectionTime, 300), deltas.Calls[1]); + Assert.Equal(("wait_stats_signal", "PAGEIOLATCH_SH", 20L, context.CollectionTime, 300), deltas.Calls[2]); + Assert.All(deltas.Calls, _ => Assert.Equal(42, deltas.LastServerId)); + } +} + +/// <summary>Shared context factory for collector-definition tests.</summary> +internal static class CollectorTestContext +{ + public static CollectorContext Make( + ICollectorDeltaCalculator deltas, + IEnumerable<string>? ignored = null, + bool isAzureSqlDb = false, + DateTime? watermark = null, + bool capturePlanXml = false) + => new() + { + ServerId = 42, + ServerName = "test-server", + CollectionTime = new DateTime(2026, 7, 1, 12, 0, 0, DateTimeKind.Utc), + Deltas = deltas, + Target = new CollectorTargetInfo { IsAzureSqlDb = isAzureSqlDb }, + Watermark = watermark, + IgnoredWaitTypes = new HashSet<string>(ignored ?? Array.Empty<string>(), StringComparer.OrdinalIgnoreCase), + CapturePlanXml = capturePlanXml, + }; +} diff --git a/Lite.Tests/WatermarkPolicyTests.cs b/Lite.Tests/WatermarkPolicyTests.cs new file mode 100644 index 000000000..92ffeb8fe --- /dev/null +++ b/Lite.Tests/WatermarkPolicyTests.cs @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Lite.Tests; + +/// <summary> +/// #1556: the 24h catch-up clamp boundaries. A stale query_store watermark (a service down for days) must +/// not point its cutoff days into the past — that one cycle would try to pull the whole retained backlog and +/// drive the commit-limit blowout. The clamp floors a >24h-stale watermark to now-24h; a fresh watermark +/// and a null watermark pass through untouched. +/// </summary> +public sealed class WatermarkPolicyTests +{ + private static readonly DateTime Now = new(2026, 7, 17, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public void ClampCatchup_Null_StaysNull() + { + /* Null = nothing collected yet; the definition's documented first-run window applies, not a clamp. */ + Assert.Null(WatermarkPolicy.ClampCatchup(null, Now)); + } + + [Fact] + public void ClampCatchup_WithinHorizon_ReturnedUnchanged() + { + /* A routine restart / brief outage: the watermark is minutes-to-hours old and never clamps. */ + var oneHourAgo = Now.AddHours(-1); + Assert.Equal(oneHourAgo, WatermarkPolicy.ClampCatchup(oneHourAgo, Now)); + + var justInside = Now - WatermarkPolicy.MaxCatchup + TimeSpan.FromSeconds(1); + Assert.Equal(justInside, WatermarkPolicy.ClampCatchup(justInside, Now)); + } + + [Fact] + public void ClampCatchup_ExactlyAtHorizon_NotClamped() + { + /* The floor is strict (< floor clamps): a watermark exactly 24h old is at the horizon, not past it. */ + var atHorizon = Now - WatermarkPolicy.MaxCatchup; + Assert.Equal(atHorizon, WatermarkPolicy.ClampCatchup(atHorizon, Now)); + } + + [Fact] + public void ClampCatchup_StalerThanHorizon_FlooredToNowMinus24h() + { + /* The field incident: a multi-day-old watermark is floored to now-24h so catch-up is bounded. */ + var floor = Now - WatermarkPolicy.MaxCatchup; + + Assert.Equal(floor, WatermarkPolicy.ClampCatchup(Now.AddDays(-3), Now)); + Assert.Equal(floor, WatermarkPolicy.ClampCatchup(Now.AddHours(-30), Now)); + + var justPast = Now - WatermarkPolicy.MaxCatchup - TimeSpan.FromSeconds(1); + Assert.Equal(floor, WatermarkPolicy.ClampCatchup(justPast, Now)); + } + + [Fact] + public void ClampCatchup_FutureWatermark_ReturnedUnchanged() + { + /* A watermark ahead of now (clock skew) is not older than the floor, so it is left alone. */ + var future = Now.AddHours(1); + Assert.Equal(future, WatermarkPolicy.ClampCatchup(future, Now)); + } + + [Fact] + public void MaxCatchup_IsTwentyFourHours() + { + /* Drift tripwire: the horizon is a deliberate choice (routine outages never clamp; multi-day ones + survive with a bounded, logged hole). */ + Assert.Equal(TimeSpan.FromHours(24), WatermarkPolicy.MaxCatchup); + } +} diff --git a/Lite.Tests/WebhookCooldownSeedTests.cs b/Lite.Tests/WebhookCooldownSeedTests.cs index 34aebf472..4529aae75 100644 --- a/Lite.Tests/WebhookCooldownSeedTests.cs +++ b/Lite.Tests/WebhookCooldownSeedTests.cs @@ -89,6 +89,11 @@ private sealed class FakeWebhookSettings : IAlertSettings public bool SlackWebhookEnabled { get; set; } public string SlackWebhookUrl { get; set; } = ""; public string SlackProxyAddress => ""; + public bool GenericWebhookEnabled { get; set; } + public string GenericWebhookUrl { get; set; } = ""; + public string GenericWebhookHeadersJson { get; set; } = ""; + public string GenericWebhookBodyTemplate { get; set; } = ""; + public string GenericWebhookProxyAddress => ""; public double AnalysisNotifySeverity => 1.5; public int AnalysisNotifyCooldownMinutes => 360; } diff --git a/Lite.Tests/packages.lock.json b/Lite.Tests/packages.lock.json index 7b34c8ead..82c2e52a5 100644 --- a/Lite.Tests/packages.lock.json +++ b/Lite.Tests/packages.lock.json @@ -886,12 +886,23 @@ "xunit.v3.runner.common": "[3.2.2]" } }, + "performancemonitor.alerting": { + "type": "Project", + "dependencies": { + "PerformanceMonitor.Notifications": "[1.0.0, )" + } + }, "performancemonitor.analysis": { "type": "Project" }, + "performancemonitor.collectors": { + "type": "Project" + }, "performancemonitor.common": { "type": "Project", "dependencies": { + "CredentialManagement": "[1.0.2, )", + "Microsoft.Extensions.Logging.Abstractions": "[10.0.9, )", "ModelContextProtocol": "[1.4.0, )" } }, @@ -905,6 +916,7 @@ "performancemonitor.plananalysis": { "type": "Project", "dependencies": { + "Microsoft.Data.SqlClient": "[7.0.1, )", "PerformanceMonitor.Common": "[1.0.0, )" } }, @@ -929,7 +941,9 @@ "Microsoft.Extensions.Logging": "[10.0.9, )", "ModelContextProtocol": "[1.4.0, )", "ModelContextProtocol.AspNetCore": "[1.4.0, )", + "PerformanceMonitor.Alerting": "[1.0.0, )", "PerformanceMonitor.Analysis": "[1.0.0, )", + "PerformanceMonitor.Collectors": "[1.0.0, )", "PerformanceMonitor.Common": "[1.0.0, )", "PerformanceMonitor.Notifications": "[1.0.0, )", "PerformanceMonitor.PlanAnalysis": "[1.0.0, )", diff --git a/Lite/Analysis/AnalysisService.cs b/Lite/Analysis/AnalysisService.cs index 1e399c019..0a25ba524 100644 --- a/Lite/Analysis/AnalysisService.cs +++ b/Lite/Analysis/AnalysisService.cs @@ -154,15 +154,45 @@ public async Task<List<AnalysisFinding>> AnalyzeAsync(AnalysisContext context) var incidents = _engine.ClusterIntoIncidents(stories, facts); IncidentId.StampClusters(context.ServerName, incidents); - // 4. Persist findings (filtering out muted) - var findings = await _findingStore.SaveFindingsAsync(stories, context); - - // 5. Enrich findings with drill-down data (ephemeral, not persisted) + // 3.7. Fold each ANOMALY_* story into the REGULAR finding that describes the same symptom + // (same run, same database) by rewriting its stamped incident id onto that parent's — so + // the anomaly stops rendering as its own card / its own email. No-parent anomalies stay + // solo; db-scoped object anomalies never cross databases. Presentation-only: nothing is + // dropped, only the incident tag is reconciled. + AnomalyIncidentReconciler.Reconcile(stories); + + // 4. Mute-filter the stories into the surviving findings WITHOUT inserting yet (the + // Darling twin's D2/P2 reorder) — enrichment + action-build happen on the survivors + // first so the BUILT RemediationAction is persisted on each row. + var findings = await _findingStore.FilterMutedFindingsAsync(stories, context); + + // 5. Enrich the survivors with drill-down data (ephemeral except through the built action). await _drillDown.EnrichFindingsAsync(findings, context); + // 6. Build + attach each finding's RemediationAction from the now drill-down-populated + // finding, then persist it as remediation_action_json. The builders REQUIRE + // finding.DrillDown, which the store read-back does NOT return — so the BUILT action is + // persisted, exactly the artifact the read path deserializes and the Recommendations + // reader renders into the copy-paste command. Same shared builders + null-coalescing + // order Darling's DarlingAnalysisService uses, so Lite and Darling produce identical + // commands. Lite has no in-app executor: the action drives a COPYABLE command only. + foreach (var finding in findings) + { + finding.Remediation = + FactRemediation.BuildAction(finding) + ?? FactRemediation.BuildRcsiAction(finding) + ?? FactRemediation.BuildClearPlanAction(finding) + ?? FactRemediation.BuildFileAutogrowthAction(finding) // advisory copy-paste (no handler) + ?? FactRemediation.BuildServerConfigAction(finding) // server-level config — MAXDOP/CTFP/memory + ?? FactRemediation.BuildMissingIndexAction(finding); // missing-index CREATE — copy-paste only + } + + // 7. Insert the survivors in one batched pass, persisting remediation_action_json. + await _findingStore.InsertFindingsAsync(findings, context); + LastAnalysisTime = DateTime.UtcNow; - // 6. Notify listeners + // 8. Notify listeners AnalysisCompleted?.Invoke(this, new AnalysisCompletedEventArgs { ServerId = context.ServerId, diff --git a/Lite/Analysis/AnomalyDetector.cs b/Lite/Analysis/AnomalyDetector.cs index 4a69279d2..17e384f7e 100644 --- a/Lite/Analysis/AnomalyDetector.cs +++ b/Lite/Analysis/AnomalyDetector.cs @@ -4,8 +4,10 @@ using System.Threading.Tasks; using DuckDB.NET.Data; using PerformanceMonitor.Analysis; +using PerformanceMonitor.Analysis.Baselines; using PerformanceMonitorLite.Database; using PerformanceMonitorLite.Services; +using static PerformanceMonitor.Analysis.Baselines.AnomalyThresholds; namespace PerformanceMonitorLite.Analysis; @@ -26,21 +28,6 @@ public class AnomalyDetector private readonly DuckDbInitializer _duckDb; private readonly BaselineProvider _baselineProvider; - /// <summary> - /// Default number of standard deviations above baseline mean to flag as anomalous. - /// </summary> - private const double DefaultDeviationThreshold = 2.0; - - /// <summary> - /// Default ratio threshold for rate-based anomaly detection (wait stats). - /// </summary> - private const double DefaultRatioThreshold = 5.0; - - /// <summary> - /// Default ratio threshold for event-based anomaly detection (blocking/deadlocks). - /// </summary> - private const double DefaultEventRatioThreshold = 3.0; - /// <summary> /// Per-metric deviation thresholds. Metrics not listed use DefaultDeviationThreshold. /// </summary> @@ -111,10 +98,6 @@ public async Task<List<Fact>> DetectAnomaliesAsync(AnalysisContext context) /// Emits ANOMALY_OBJECT_GROWTH for the biggest table grower over threshold and /// ANOMALY_OBJECT_CONTENTION for the index with the largest new lock-wait time. /// </summary> - private const decimal ObjectGrowthMbThreshold = 100m; // ignore tables that grew less than 100 MB - private const double ObjectGrowthPctThreshold = 20.0; // ...and less than 20% day-over-day - private const long ObjectLockWaitMsDeltaThreshold = 60000; // 1 minute of new lock waits - private async Task DetectObjectStatsAnomalies(AnalysisContext context, List<Fact> anomalies) { try @@ -273,8 +256,9 @@ private async Task DetectCpuAnomalies(AnalysisContext context, List<Fact> anomal context.ServerId, MetricNames.Cpu, context.TimeRangeStart); if (baseline.SampleCount == 0) return; + // No effectiveStdDev<=0 early return — an untrustworthy/zero-dispersion baseline falls + // back to the absolute bar (below) rather than going silent. var effectiveStdDev = baseline.EffectiveStdDev; - if (effectiveStdDev <= 0) return; // Zero mean + zero stddev — skip using var readLock = _duckDb.AcquireReadLock(); using var connection = _duckDb.CreateConnection(); @@ -306,8 +290,10 @@ FROM v_cpu_utilization_stats if (windowSamples == 0) return; - var deviation = (peakCpu - baseline.Mean) / effectiveStdDev; - if (deviation < GetDeviationThreshold(MetricNames.Cpu) || peakCpu < 50) return; + var decision = AnomalyGate.EvaluateZScore( + baseline.Mean, effectiveStdDev, baseline.IsTrustworthy, peakCpu, + GetDeviationThreshold(MetricNames.Cpu), CpuFloorPct, CpuFallbackPct, SigmaDisplayCap); + if (!decision.Fire) return; var metadata = new Dictionary<string, double> { @@ -315,7 +301,9 @@ FROM v_cpu_utilization_stats ["avg_cpu_in_window"] = avgCpu, ["baseline_mean"] = baseline.Mean, ["baseline_stddev"] = effectiveStdDev, - ["deviation_sigma"] = deviation, + ["deviation_sigma"] = decision.Sigma, + ["baseline_low_quality"] = decision.LowQualityBaseline ? 1 : 0, + ["fallback_exceedance"] = decision.FallbackExceedance, ["baseline_samples"] = baseline.SampleCount, ["window_samples"] = windowSamples, ["confidence"] = 1.0, @@ -339,89 +327,120 @@ FROM v_cpu_utilization_stats } /// <summary> - /// Detects wait stat anomalies — total wait time significantly above - /// baseline rate for this time bucket. Uses ratio-based scoring. + /// Detects a shift in the wait PROFILE — the whole-server all-types wait rate (ms/sec) running + /// significantly above its time-bucketed baseline — and emits ONE ANOMALY_WAIT_PROFILE fact with + /// the top wait types as contrib_<TYPE> metadata. This replaces the old per-type + /// ANOMALY_WAIT_<type> facts, which (a) compared a per-hour per-type value to a per-interval + /// all-types baseline — a ~240x unit inflation — and (b) missed a minority-but-real wait (e.g. + /// RESOURCE_SEMAPHORE while CX* dominate the summed baseline). Comparing all-types-vs-all-types on + /// the honest per-second scale fixes units, aggregation, and the per-type cascade together. /// </summary> private async Task DetectWaitAnomalies(AnalysisContext context, List<Fact> anomalies) { try { var baseline = await _baselineProvider.GetBaselineAsync( - context.ServerId, MetricNames.WaitStats, context.TimeRangeStart); - - // No baseline data at all — can't distinguish "new" waits from "always present." - // Skip rather than flagging everything as anomalous. - if (baseline.SampleCount == 0) return; + context.ServerId, MetricNames.WaitMsPerSec, context.TimeRangeStart); using var readLock = _duckDb.AcquireReadLock(); using var connection = _duckDb.CreateConnection(); await connection.OpenAsync(); - // Get per-wait-type totals in the analysis window - using var cmd = connection.CreateCommand(); - cmd.CommandText = @" -SELECT wait_type, - SUM(delta_wait_time_ms)::BIGINT AS total_ms -FROM v_wait_stats -WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 -AND delta_wait_time_ms > 0 -GROUP BY wait_type -HAVING SUM(delta_wait_time_ms) > 10000 -ORDER BY total_ms DESC -LIMIT 10"; + // Current window: all-types wait ms/sec per collection (interval via LAG, never an assumed + // cadence — mirrors the WaitMsPerSec baseline), then PEAK across collections (matching the + // z-detectors' peak-representative value). Window bound aligned to the baseline: >= $2 AND < $3. + double peakRate; + double totalWaitMs; + long collectionCount; + using (var rateCmd = connection.CreateCommand()) + { + rateCmd.CommandText = @" +WITH per_collection AS ( + SELECT collection_time, + SUM(delta_wait_time_ms)::DOUBLE PRECISION AS total_wait_ms, + extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (ORDER BY collection_time)))) AS interval_sec + FROM v_wait_stats + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + AND delta_wait_time_ms >= 0 + GROUP BY collection_time +) +SELECT MAX(CASE WHEN interval_sec > 0 THEN total_wait_ms / interval_sec ELSE 0 END) AS peak_ms_per_sec, + SUM(total_wait_ms) AS total_wait_ms, + COUNT(*) FILTER (WHERE interval_sec IS NOT NULL) AS sample_count +FROM per_collection"; + rateCmd.Parameters.Add(new DuckDBParameter { Value = context.ServerId }); + rateCmd.Parameters.Add(new DuckDBParameter { Value = context.TimeRangeStart }); + rateCmd.Parameters.Add(new DuckDBParameter { Value = context.TimeRangeEnd }); + + using var rateReader = await rateCmd.ExecuteReaderAsync(); + if (!await rateReader.ReadAsync()) return; + peakRate = rateReader.IsDBNull(0) ? 0.0 : Convert.ToDouble(rateReader.GetValue(0)); + totalWaitMs = rateReader.IsDBNull(1) ? 0.0 : Convert.ToDouble(rateReader.GetValue(1)); + collectionCount = rateReader.IsDBNull(2) ? 0L : Convert.ToInt64(rateReader.GetValue(2)); + } - cmd.Parameters.Add(new DuckDBParameter { Value = context.ServerId }); - cmd.Parameters.Add(new DuckDBParameter { Value = context.TimeRangeStart }); - cmd.Parameters.Add(new DuckDBParameter { Value = context.TimeRangeEnd }); + if (collectionCount == 0) return; // no rated collection in the window - var currentHours = (context.TimeRangeEnd - context.TimeRangeStart).TotalHours; - if (currentHours <= 0) currentHours = 1; + // Trustworthy baseline → honest per-second ratio; else fall back to the absolute peak-rate + // bar (NOT silence) so a genuinely heavy profile still surfaces on a young store (is_new). + bool isNew; + double ratio; + if (baseline.IsTrustworthy && baseline.Mean > 0) + { + isNew = false; + ratio = peakRate / baseline.Mean; + } + else + { + isNew = true; + ratio = peakRate >= WaitProfileFallbackMsPerSec ? NoBaselineRatio : 0; + } - // Baseline mean is total wait ms per collection interval for this time bucket. - // If no baseline, use ratio=100 for significant new waits. - var baselineRate = baseline.SampleCount > 0 ? baseline.Mean : 0; + if (ratio < DefaultRatioThreshold) return; - using var reader = await cmd.ExecuteReaderAsync(); - while (await reader.ReadAsync()) + var metadata = new Dictionary<string, double> { - var waitType = reader.GetString(0); - var currentMs = Convert.ToInt64(reader.GetValue(1)); - var currentRate = currentMs / currentHours; + ["current_ms_per_sec"] = peakRate, + ["baseline_mean"] = baseline.Mean, + ["total_wait_ms"] = totalWaitMs, + ["ratio"] = ratio, + ["is_new"] = isNew ? 1 : 0 + }; + AddBaselineContext(metadata, baseline); - double ratio; - string anomalyType; + // Top 6 contributors — named in the metadata KEY (a Dictionary<string,double> can't hold + // the type name in the value), value = the type's total wait ms in the window. + using (var contribCmd = connection.CreateCommand()) + { + contribCmd.CommandText = @" +SELECT wait_type, + SUM(delta_wait_time_ms)::BIGINT AS total_ms +FROM v_wait_stats +WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 +AND delta_wait_time_ms > 0 +GROUP BY wait_type +ORDER BY total_ms DESC +LIMIT 6"; + contribCmd.Parameters.Add(new DuckDBParameter { Value = context.ServerId }); + contribCmd.Parameters.Add(new DuckDBParameter { Value = context.TimeRangeStart }); + contribCmd.Parameters.Add(new DuckDBParameter { Value = context.TimeRangeEnd }); - if (baselineRate <= 0 || baseline.SampleCount == 0) - { - ratio = currentMs > 60_000 ? 100.0 : 0; - anomalyType = "new"; - } - else + using var contribReader = await contribCmd.ExecuteReaderAsync(); + while (await contribReader.ReadAsync()) { - ratio = currentRate / baselineRate; - anomalyType = "spike"; + var waitType = contribReader.GetString(0); + metadata[$"contrib_{waitType}"] = Convert.ToDouble(contribReader.GetValue(1)); } - - if (ratio < DefaultRatioThreshold) continue; - - var metadata = new Dictionary<string, double> - { - ["current_ms"] = currentMs, - ["baseline_mean"] = baseline.Mean, - ["ratio"] = ratio, - ["is_new"] = anomalyType == "new" ? 1 : 0 - }; - AddBaselineContext(metadata, baseline); - - anomalies.Add(new Fact - { - Source = "anomaly", - Key = $"ANOMALY_WAIT_{waitType}", - Value = currentMs, - ServerId = context.ServerId, - Metadata = metadata - }); } + + anomalies.Add(new Fact + { + Source = "anomaly", + Key = "ANOMALY_WAIT_PROFILE", + Value = totalWaitMs, + ServerId = context.ServerId, + Metadata = metadata + }); } catch (Exception ex) { @@ -447,10 +466,16 @@ private async Task DetectBlockingAnomalies(AnalysisContext context, List<Fact> a await connection.OpenAsync(); using var cmd = connection.CreateCommand(); + /* current_blocking: prefer the blocked-process-report; fall back to the always-on DMV + snapshot so RDS (where the BPR session is empty) still counts blocking. Mirrors the + overview/alert path (LocalDataService.Overview.cs / LocalDataService.Blocking.cs). */ cmd.CommandText = @" SELECT - (SELECT COUNT(*) FROM v_blocked_process_reports - WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3) AS current_blocking, + COALESCE(NULLIF( + (SELECT COUNT(*) FROM v_blocked_process_reports + WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3), 0), + (SELECT COUNT(*) FROM v_dmv_blocking_snapshots + WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3)) AS current_blocking, (SELECT COUNT(*) FROM v_deadlocks WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3) AS current_deadlocks"; @@ -473,18 +498,26 @@ so normalize them to per-hour before the ratio — otherwise the ratio scales wi var currentBlockingPerHour = currentBlocking / windowHours; var currentDeadlocksPerHour = currentDeadlocks / windowHours; - // Baseline mean = events per hour for this hour+dow bucket + // Baseline mean = events per hour for this hour+dow bucket. Gate on IsTrustworthy (not just + // SampleCount>0): a thin/zero-history baseline falls back to the absolute event count rather + // than an inflated ratio. is_new marks that fallback so the composer renders it honestly as + // a first occurrence — never the dishonest "spiked to 100×" the sentinel used to render. + var blockingTrust = blockingBaseline.IsTrustworthy; + var deadlockTrust = deadlockBaseline.IsTrustworthy; var baselineBlockingRate = blockingBaseline.SampleCount > 0 ? blockingBaseline.Mean : 0; var baselineDeadlockRate = deadlockBaseline.SampleCount > 0 ? deadlockBaseline.Mean : 0; - // Blocking spike: at least 5 events in the window AND per-hour rate >= 3x baseline (or no baseline) - if (currentBlocking >= 5 && (baselineBlockingRate <= 0 || currentBlockingPerHour / Math.Max(baselineBlockingRate, 1) >= DefaultEventRatioThreshold)) + // Blocking spike: at least 5 events in the window AND (trustworthy → per-hour rate >= 3x + // baseline; untrustworthy → fire on the count alone). + if (currentBlocking >= 5 && (!blockingTrust || currentBlockingPerHour / Math.Max(baselineBlockingRate, 1) >= DefaultEventRatioThreshold)) { + var isNew = !blockingTrust; var metadata = new Dictionary<string, double> { ["current_count"] = currentBlocking, ["baseline_rate"] = baselineBlockingRate, - ["ratio"] = baselineBlockingRate > 0 ? currentBlockingPerHour / baselineBlockingRate : 100.0 + ["ratio"] = isNew ? NoBaselineRatio : currentBlockingPerHour / baselineBlockingRate, + ["is_new"] = isNew ? 1 : 0 }; AddBaselineContext(metadata, blockingBaseline); @@ -498,14 +531,17 @@ so normalize them to per-hour before the ratio — otherwise the ratio scales wi }); } - // Deadlock spike: at least 3 events in the window AND per-hour rate >= 3x baseline (or no baseline) - if (currentDeadlocks >= 3 && (baselineDeadlockRate <= 0 || currentDeadlocksPerHour / Math.Max(baselineDeadlockRate, 1) >= DefaultEventRatioThreshold)) + // Deadlock spike: at least 3 events in the window AND (trustworthy → per-hour rate >= 3x + // baseline; untrustworthy → fire on the count alone). + if (currentDeadlocks >= 3 && (!deadlockTrust || currentDeadlocksPerHour / Math.Max(baselineDeadlockRate, 1) >= DefaultEventRatioThreshold)) { + var isNew = !deadlockTrust; var metadata = new Dictionary<string, double> { ["current_count"] = currentDeadlocks, ["baseline_rate"] = baselineDeadlockRate, - ["ratio"] = baselineDeadlockRate > 0 ? currentDeadlocksPerHour / baselineDeadlockRate : 100.0 + ["ratio"] = isNew ? NoBaselineRatio : currentDeadlocksPerHour / baselineDeadlockRate, + ["is_new"] = isNew ? 1 : 0 }; AddBaselineContext(metadata, deadlockBaseline); @@ -537,7 +573,6 @@ private async Task DetectIoAnomalies(AnalysisContext context, List<Fact> anomali if (baseline.SampleCount == 0) return; var effectiveStdDev = baseline.EffectiveStdDev; - if (effectiveStdDev <= 0) return; using var readLock = _duckDb.AcquireReadLock(); using var connection = _duckDb.CreateConnection(); @@ -564,57 +599,59 @@ FROM v_file_io_stats var ioThreshold = GetDeviationThreshold(MetricNames.IoLatency); // Read latency anomaly - if (currentReadLat > 10) + var readDecision = AnomalyGate.EvaluateZScore( + baseline.Mean, effectiveStdDev, baseline.IsTrustworthy, currentReadLat, + ioThreshold, ReadLatencyFloorMs, IoLatencyFallbackMs, SigmaDisplayCap); + if (readDecision.Fire) { - var readDeviation = (currentReadLat - baseline.Mean) / effectiveStdDev; - if (readDeviation >= ioThreshold) + var metadata = new Dictionary<string, double> { - var metadata = new Dictionary<string, double> - { - ["current_latency_ms"] = currentReadLat, - ["baseline_mean_ms"] = baseline.Mean, - ["baseline_stddev_ms"] = effectiveStdDev, - ["deviation_sigma"] = readDeviation, - ["baseline_samples"] = baseline.SampleCount - }; - AddBaselineContext(metadata, baseline); + ["current_latency_ms"] = currentReadLat, + ["baseline_mean_ms"] = baseline.Mean, + ["baseline_stddev_ms"] = effectiveStdDev, + ["deviation_sigma"] = readDecision.Sigma, + ["baseline_low_quality"] = readDecision.LowQualityBaseline ? 1 : 0, + ["fallback_exceedance"] = readDecision.FallbackExceedance, + ["baseline_samples"] = baseline.SampleCount + }; + AddBaselineContext(metadata, baseline); - anomalies.Add(new Fact - { - Source = "anomaly", - Key = "ANOMALY_READ_LATENCY", - Value = currentReadLat, - ServerId = context.ServerId, - Metadata = metadata - }); - } + anomalies.Add(new Fact + { + Source = "anomaly", + Key = "ANOMALY_READ_LATENCY", + Value = currentReadLat, + ServerId = context.ServerId, + Metadata = metadata + }); } // Write latency anomaly - if (currentWriteLat > 5) + var writeDecision = AnomalyGate.EvaluateZScore( + baseline.Mean, effectiveStdDev, baseline.IsTrustworthy, currentWriteLat, + ioThreshold, WriteLatencyFloorMs, IoLatencyFallbackMs, SigmaDisplayCap); + if (writeDecision.Fire) { - var writeDeviation = (currentWriteLat - baseline.Mean) / effectiveStdDev; - if (writeDeviation >= ioThreshold) + var metadata = new Dictionary<string, double> { - var metadata = new Dictionary<string, double> - { - ["current_latency_ms"] = currentWriteLat, - ["baseline_mean_ms"] = baseline.Mean, - ["baseline_stddev_ms"] = effectiveStdDev, - ["deviation_sigma"] = writeDeviation, - ["baseline_samples"] = baseline.SampleCount - }; - AddBaselineContext(metadata, baseline); + ["current_latency_ms"] = currentWriteLat, + ["baseline_mean_ms"] = baseline.Mean, + ["baseline_stddev_ms"] = effectiveStdDev, + ["deviation_sigma"] = writeDecision.Sigma, + ["baseline_low_quality"] = writeDecision.LowQualityBaseline ? 1 : 0, + ["fallback_exceedance"] = writeDecision.FallbackExceedance, + ["baseline_samples"] = baseline.SampleCount + }; + AddBaselineContext(metadata, baseline); - anomalies.Add(new Fact - { - Source = "anomaly", - Key = "ANOMALY_WRITE_LATENCY", - Value = currentWriteLat, - ServerId = context.ServerId, - Metadata = metadata - }); - } + anomalies.Add(new Fact + { + Source = "anomaly", + Key = "ANOMALY_WRITE_LATENCY", + Value = currentWriteLat, + ServerId = context.ServerId, + Metadata = metadata + }); } } catch (Exception ex) @@ -635,7 +672,6 @@ private async Task DetectBatchRequestAnomalies(AnalysisContext context, List<Fac if (baseline.SampleCount == 0) return; var effectiveStdDev = baseline.EffectiveStdDev; - if (effectiveStdDev <= 0) return; using var readLock = _duckDb.AcquireReadLock(); using var connection = _duckDb.CreateConnection(); @@ -664,8 +700,10 @@ FROM v_perfmon_stats if (windowSamples == 0) return; - var deviation = (peakBatch - baseline.Mean) / effectiveStdDev; - if (deviation < GetDeviationThreshold(MetricNames.BatchRequests)) return; + var decision = AnomalyGate.EvaluateZScore( + baseline.Mean, effectiveStdDev, baseline.IsTrustworthy, peakBatch, + GetDeviationThreshold(MetricNames.BatchRequests), BatchRequestFloor, BatchRequestFallback, SigmaDisplayCap); + if (!decision.Fire) return; var metadata = new Dictionary<string, double> { @@ -673,7 +711,9 @@ FROM v_perfmon_stats ["avg_batch_requests"] = avgBatch, ["baseline_mean"] = baseline.Mean, ["baseline_stddev"] = effectiveStdDev, - ["deviation_sigma"] = deviation, + ["deviation_sigma"] = decision.Sigma, + ["baseline_low_quality"] = decision.LowQualityBaseline ? 1 : 0, + ["fallback_exceedance"] = decision.FallbackExceedance, ["baseline_samples"] = baseline.SampleCount, ["window_samples"] = windowSamples }; @@ -706,7 +746,6 @@ private async Task DetectSessionAnomalies(AnalysisContext context, List<Fact> an if (baseline.SampleCount == 0) return; var effectiveStdDev = baseline.EffectiveStdDev; - if (effectiveStdDev <= 0) return; using var readLock = _duckDb.AcquireReadLock(); using var connection = _duckDb.CreateConnection(); @@ -716,7 +755,7 @@ private async Task DetectSessionAnomalies(AnalysisContext context, List<Fact> an cmd.CommandText = @" WITH per_collection AS ( SELECT collection_time, - SUM(connection_count)::DOUBLE AS total_connections + SUM(connection_count)::DOUBLE PRECISION AS total_connections FROM v_session_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 GROUP BY collection_time @@ -739,8 +778,10 @@ SELECT AVG(total_connections) AS avg_connections, if (windowSamples == 0) return; - var deviation = (peakConnections - baseline.Mean) / effectiveStdDev; - if (deviation < GetDeviationThreshold(MetricNames.SessionCount)) return; + var decision = AnomalyGate.EvaluateZScore( + baseline.Mean, effectiveStdDev, baseline.IsTrustworthy, peakConnections, + GetDeviationThreshold(MetricNames.SessionCount), SessionCountFloor, SessionCountFallback, SigmaDisplayCap); + if (!decision.Fire) return; var metadata = new Dictionary<string, double> { @@ -748,7 +789,9 @@ SELECT AVG(total_connections) AS avg_connections, ["avg_connections"] = avgConnections, ["baseline_mean"] = baseline.Mean, ["baseline_stddev"] = effectiveStdDev, - ["deviation_sigma"] = deviation, + ["deviation_sigma"] = decision.Sigma, + ["baseline_low_quality"] = decision.LowQualityBaseline ? 1 : 0, + ["fallback_exceedance"] = decision.FallbackExceedance, ["baseline_samples"] = baseline.SampleCount, ["window_samples"] = windowSamples }; @@ -782,7 +825,6 @@ private async Task DetectQueryDurationAnomalies(AnalysisContext context, List<Fa if (baseline.SampleCount == 0) return; var effectiveStdDev = baseline.EffectiveStdDev; - if (effectiveStdDev <= 0) return; using var readLock = _duckDb.AcquireReadLock(); using var connection = _duckDb.CreateConnection(); @@ -792,7 +834,7 @@ private async Task DetectQueryDurationAnomalies(AnalysisContext context, List<Fa cmd.CommandText = @" WITH per_collection AS ( SELECT collection_time, - SUM(delta_elapsed_time)::DOUBLE AS total_elapsed + SUM(delta_elapsed_time)::DOUBLE PRECISION AS total_elapsed FROM v_query_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 AND delta_execution_count > 0 @@ -817,8 +859,10 @@ SELECT AVG(total_elapsed) AS avg_elapsed, if (windowSamples == 0) return; - var deviation = (peakElapsed - baseline.Mean) / effectiveStdDev; - if (deviation < GetDeviationThreshold(MetricNames.QueryDuration)) return; + var decision = AnomalyGate.EvaluateZScore( + baseline.Mean, effectiveStdDev, baseline.IsTrustworthy, peakElapsed, + GetDeviationThreshold(MetricNames.QueryDuration), QueryDurationFloorUs, QueryDurationFallbackUs, SigmaDisplayCap); + if (!decision.Fire) return; var metadata = new Dictionary<string, double> { @@ -826,7 +870,9 @@ SELECT AVG(total_elapsed) AS avg_elapsed, ["avg_total_elapsed_us"] = avgElapsed, ["baseline_mean"] = baseline.Mean, ["baseline_stddev"] = effectiveStdDev, - ["deviation_sigma"] = deviation, + ["deviation_sigma"] = decision.Sigma, + ["baseline_low_quality"] = decision.LowQualityBaseline ? 1 : 0, + ["fallback_exceedance"] = decision.FallbackExceedance, ["baseline_samples"] = baseline.SampleCount, ["window_samples"] = windowSamples }; @@ -849,7 +895,7 @@ SELECT AVG(total_elapsed) AS avg_elapsed, /// <summary> /// Detects memory utilization anomalies using z-score against time-bucketed baseline. - /// Lite-only — Dashboard does not collect memory metrics. + /// Dashboard and Darling collect memory metrics too; mirror any change across all three. /// Measures total_server_memory_mb / target_server_memory_mb as memory pressure %. /// </summary> private async Task DetectMemoryAnomalies(AnalysisContext context, List<Fact> anomalies) @@ -861,7 +907,6 @@ private async Task DetectMemoryAnomalies(AnalysisContext context, List<Fact> ano if (baseline.SampleCount == 0) return; var effectiveStdDev = baseline.EffectiveStdDev; - if (effectiveStdDev <= 0) return; using var readLock = _duckDb.AcquireReadLock(); using var connection = _duckDb.CreateConnection(); @@ -869,8 +914,8 @@ private async Task DetectMemoryAnomalies(AnalysisContext context, List<Fact> ano using var cmd = connection.CreateCommand(); cmd.CommandText = @" -SELECT AVG(total_server_memory_mb::DOUBLE / NULLIF(target_server_memory_mb::DOUBLE, 0) * 100) AS avg_pressure, - MAX(total_server_memory_mb::DOUBLE / NULLIF(target_server_memory_mb::DOUBLE, 0) * 100) AS peak_pressure, +SELECT AVG(total_server_memory_mb::DOUBLE PRECISION / NULLIF(target_server_memory_mb::DOUBLE PRECISION, 0) * 100) AS avg_pressure, + MAX(total_server_memory_mb::DOUBLE PRECISION / NULLIF(target_server_memory_mb::DOUBLE PRECISION, 0) * 100) AS peak_pressure, COUNT(*) AS sample_count FROM v_memory_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 @@ -889,8 +934,10 @@ FROM v_memory_stats if (windowSamples == 0) return; - var deviation = (peakPressure - baseline.Mean) / effectiveStdDev; - if (deviation < GetDeviationThreshold(MetricNames.Memory)) return; + var decision = AnomalyGate.EvaluateZScore( + baseline.Mean, effectiveStdDev, baseline.IsTrustworthy, peakPressure, + GetDeviationThreshold(MetricNames.Memory), MemoryPressureFloorPct, MemoryPressureFallbackPct, SigmaDisplayCap); + if (!decision.Fire) return; var metadata = new Dictionary<string, double> { @@ -898,7 +945,9 @@ FROM v_memory_stats ["avg_memory_pressure_pct"] = avgPressure, ["baseline_mean"] = baseline.Mean, ["baseline_stddev"] = effectiveStdDev, - ["deviation_sigma"] = deviation, + ["deviation_sigma"] = decision.Sigma, + ["baseline_low_quality"] = decision.LowQualityBaseline ? 1 : 0, + ["fallback_exceedance"] = decision.FallbackExceedance, ["baseline_samples"] = baseline.SampleCount, ["window_samples"] = windowSamples }; diff --git a/Lite/Analysis/BaselineProvider.cs b/Lite/Analysis/BaselineProvider.cs index 5e5c880b6..ef982638e 100644 --- a/Lite/Analysis/BaselineProvider.cs +++ b/Lite/Analysis/BaselineProvider.cs @@ -4,7 +4,7 @@ using System.Linq; using System.Threading.Tasks; using DuckDB.NET.Data; -using PerformanceMonitor.Analysis; +using PerformanceMonitor.Analysis.Baselines; using PerformanceMonitorLite.Database; using PerformanceMonitorLite.Services; @@ -27,15 +27,6 @@ public class BaselineProvider { private readonly DuckDbInitializer _duckDb; - /// <summary>Rolling window for baseline computation.</summary> - private const int BaselineWindowDays = 30; - - /// <summary>Collapse to hour-only when full bucket has fewer than this many samples.</summary> - private const int CollapseThreshold = 10; - - /// <summary>Restore to full bucket when sample count reaches this level (hysteresis).</summary> - private const int RestoreThreshold = 15; - /// <summary>Cache TTL — baselines are recomputed after this interval.</summary> public static TimeSpan CacheTtl { get; set; } = TimeSpan.FromHours(1); @@ -60,49 +51,7 @@ public async Task<BaselineBucket> GetBaselineAsync( if (baselines == null || baselines.Count == 0) return BaselineBucket.Empty; - // Try full bucket (hour + day-of-week) - var fullKey = (hourOfDay, dayOfWeek); - if (baselines.TryGetValue(fullKey, out var fullBucket) && fullBucket.SampleCount >= RestoreThreshold) - return fullBucket; - - // If full bucket exists but below restore threshold, check if it's above collapse threshold - // (hysteresis: don't collapse if we're between 10-14 samples and were previously using full) - if (fullBucket != null && fullBucket.SampleCount >= CollapseThreshold) - return fullBucket; - - // Collapse to hour-only: aggregate all days for this hour - var hourBuckets = baselines - .Where(kvp => kvp.Key.HourOfDay == hourOfDay) - .Select(kvp => kvp.Value) - .ToList(); - - if (hourBuckets.Count > 0) - { - var collapsed = CollapseToHourOnly(hourBuckets); - if (collapsed.SampleCount >= CollapseThreshold) - return collapsed; - } - - // Collapse to flat: aggregate everything - var allBuckets = baselines.Values.ToList(); - if (allBuckets.Count > 0) - { - var flat = CollapseToFlat(allBuckets); - if (flat.SampleCount >= 3) // Minimum viable baseline - return flat; - } - - return BaselineBucket.Empty; - } - - /// <summary> - /// Gets all baseline buckets for a metric/server. Used by UI for rendering - /// expected-range bands across all time slots. - /// </summary> - public async Task<Dictionary<(int HourOfDay, int DayOfWeek), BaselineBucket>?> GetAllBaselinesAsync( - int serverId, string metricName, DateTime analysisTime) - { - return await GetOrComputeBaselinesAsync(serverId, metricName, analysisTime); + return BaselineMath.SelectBucket(baselines, hourOfDay, dayOfWeek); } /// <summary>Forces cache eviction for a server — used during testing.</summary> @@ -147,7 +96,8 @@ public void InvalidateCache(int serverId) var query = GetBaselineQuery(metricName); if (query == null) return null; - var windowStart = analysisTime.AddDays(-BaselineWindowDays); + var absStdDevFloor = BaselineMath.AbsStdDevFloorFor(metricName); + var windowStart = analysisTime.AddDays(-BaselineMath.BaselineWindowDays); try { @@ -171,6 +121,7 @@ public void InvalidateCache(int serverId) var mean = reader.IsDBNull(2) ? 0.0 : Convert.ToDouble(reader.GetValue(2)); var stddev = reader.IsDBNull(3) ? 0.0 : Convert.ToDouble(reader.GetValue(3)); var count = reader.IsDBNull(4) ? 0L : Convert.ToInt64(reader.GetValue(4)); + var distinctDays = reader.IsDBNull(5) ? 0L : Convert.ToInt64(reader.GetValue(5)); buckets[(hour, dow)] = new BaselineBucket { @@ -179,6 +130,8 @@ public void InvalidateCache(int serverId) Mean = mean, StdDev = stddev, SampleCount = count, + DistinctDays = distinctDays, + AbsStdDevFloor = absStdDevFloor, // Every bucket here is a full (hour, day-of-week) bucket; the HourOnly/Flat // tiers are assigned only on the collapse/flat paths below. A sparse full // bucket is still Full, just low-sample. (Was a copy-paste of two identical @@ -212,7 +165,8 @@ SELECT EXTRACT(HOUR FROM collection_time)::INT AS hour_of_day, EXTRACT(DOW FROM collection_time)::INT AS day_of_week, AVG(sqlserver_cpu_utilization) AS mean_val, STDDEV_SAMP(sqlserver_cpu_utilization) AS stddev_val, - COUNT(*) AS sample_count + COUNT(*) AS sample_count, + COUNT(DISTINCT collection_time::DATE) AS distinct_days FROM v_cpu_utilization_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 GROUP BY hour_of_day, day_of_week", @@ -225,7 +179,8 @@ SELECT EXTRACT(HOUR FROM collection_time)::INT AS hour_of_day, EXTRACT(DOW FROM collection_time)::INT AS day_of_week, AVG(delta_cntr_value) AS mean_val, STDDEV_SAMP(delta_cntr_value) AS stddev_val, - COUNT(*) AS sample_count + COUNT(*) AS sample_count, + COUNT(DISTINCT collection_time::DATE) AS distinct_days FROM ( SELECT collection_time, delta_cntr_value FROM v_perfmon_stats @@ -254,7 +209,8 @@ SELECT EXTRACT(HOUR FROM collection_time)::INT AS hour_of_day, EXTRACT(DOW FROM collection_time)::INT AS day_of_week, AVG(total_wait_ms) AS mean_val, STDDEV_SAMP(total_wait_ms) AS stddev_val, - COUNT(*) AS sample_count + COUNT(*) AS sample_count, + COUNT(DISTINCT collection_time::DATE) AS distinct_days FROM per_collection GROUP BY hour_of_day, day_of_week", @@ -272,7 +228,8 @@ SELECT EXTRACT(HOUR FROM collection_time)::INT AS hour_of_day, EXTRACT(DOW FROM collection_time)::INT AS day_of_week, AVG(total_connections) AS mean_val, STDDEV_SAMP(total_connections) AS stddev_val, - COUNT(*) AS sample_count + COUNT(*) AS sample_count, + COUNT(DISTINCT collection_time::DATE) AS distinct_days FROM per_collection GROUP BY hour_of_day, day_of_week", @@ -294,7 +251,8 @@ SELECT EXTRACT(HOUR FROM collection_time)::INT AS hour_of_day, EXTRACT(DOW FROM collection_time)::INT AS day_of_week, AVG(total_elapsed) AS mean_val, STDDEV_SAMP(total_elapsed) AS stddev_val, - COUNT(*) AS sample_count + COUNT(*) AS sample_count, + COUNT(DISTINCT collection_time::DATE) AS distinct_days FROM per_collection GROUP BY hour_of_day, day_of_week", @@ -304,7 +262,8 @@ SELECT EXTRACT(HOUR FROM collection_time)::INT AS hour_of_day, EXTRACT(DOW FROM collection_time)::INT AS day_of_week, AVG(delta_stall_read_ms * 1.0 / NULLIF(delta_reads, 0)) AS mean_val, STDDEV_SAMP(delta_stall_read_ms * 1.0 / NULLIF(delta_reads, 0)) AS stddev_val, - COUNT(*) AS sample_count + COUNT(*) AS sample_count, + COUNT(DISTINCT collection_time::DATE) AS distinct_days FROM v_file_io_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 AND (delta_reads > 0 OR delta_writes > 0) @@ -315,9 +274,10 @@ FROM v_file_io_stats MetricNames.Blocking => @" SELECT EXTRACT(HOUR FROM collection_time)::INT AS hour_of_day, EXTRACT(DOW FROM collection_time)::INT AS day_of_week, - COUNT(*)::DOUBLE / GREATEST(COUNT(DISTINCT collection_time::DATE), 1) AS mean_val, - 0::DOUBLE AS stddev_val, - COUNT(DISTINCT collection_time::DATE) AS sample_count + COUNT(*)::DOUBLE PRECISION / GREATEST(COUNT(DISTINCT collection_time::DATE), 1) AS mean_val, + 0::DOUBLE PRECISION AS stddev_val, + COUNT(DISTINCT collection_time::DATE) AS sample_count, + COUNT(DISTINCT collection_time::DATE) AS distinct_days FROM v_blocked_process_reports WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 GROUP BY hour_of_day, day_of_week", @@ -326,9 +286,10 @@ FROM v_blocked_process_reports MetricNames.Deadlock => @" SELECT EXTRACT(HOUR FROM collection_time)::INT AS hour_of_day, EXTRACT(DOW FROM collection_time)::INT AS day_of_week, - COUNT(*)::DOUBLE / GREATEST(COUNT(DISTINCT collection_time::DATE), 1) AS mean_val, - 0::DOUBLE AS stddev_val, - COUNT(DISTINCT collection_time::DATE) AS sample_count + COUNT(*)::DOUBLE PRECISION / GREATEST(COUNT(DISTINCT collection_time::DATE), 1) AS mean_val, + 0::DOUBLE PRECISION AS stddev_val, + COUNT(DISTINCT collection_time::DATE) AS sample_count, + COUNT(DISTINCT collection_time::DATE) AS distinct_days FROM v_deadlocks WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 GROUP BY hour_of_day, day_of_week", @@ -337,9 +298,10 @@ FROM v_deadlocks MetricNames.Memory => @" SELECT EXTRACT(HOUR FROM collection_time)::INT AS hour_of_day, EXTRACT(DOW FROM collection_time)::INT AS day_of_week, - AVG(total_server_memory_mb::DOUBLE / NULLIF(target_server_memory_mb::DOUBLE, 0) * 100) AS mean_val, - STDDEV_SAMP(total_server_memory_mb::DOUBLE / NULLIF(target_server_memory_mb::DOUBLE, 0) * 100) AS stddev_val, - COUNT(*) AS sample_count + AVG(total_server_memory_mb::DOUBLE PRECISION / NULLIF(target_server_memory_mb::DOUBLE PRECISION, 0) * 100) AS mean_val, + STDDEV_SAMP(total_server_memory_mb::DOUBLE PRECISION / NULLIF(target_server_memory_mb::DOUBLE PRECISION, 0) * 100) AS stddev_val, + COUNT(*) AS sample_count, + COUNT(DISTINCT collection_time::DATE) AS distinct_days FROM v_memory_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 AND target_server_memory_mb > 0 @@ -347,24 +309,12 @@ AND target_server_memory_mb > 0 // ── Chart-unit baselines (for UI bands — units match what the chart displays) ── - // Buffer pool MB (chart shows this, not pressure %) - MetricNames.MemoryBufferPoolMb => @" -SELECT EXTRACT(HOUR FROM collection_time)::INT AS hour_of_day, - EXTRACT(DOW FROM collection_time)::INT AS day_of_week, - AVG(buffer_pool_mb::DOUBLE) AS mean_val, - STDDEV_SAMP(buffer_pool_mb::DOUBLE) AS stddev_val, - COUNT(*) AS sample_count -FROM v_memory_stats -WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 -AND buffer_pool_mb > 0 -GROUP BY hour_of_day, day_of_week", - // Wait ms per second (chart shows this, not total ms per collection) MetricNames.WaitMsPerSec => @" WITH per_collection AS ( SELECT collection_time, - SUM(delta_wait_time_ms)::DOUBLE AS total_wait_ms, - date_diff('second', LAG(collection_time) OVER (ORDER BY collection_time), collection_time) AS interval_sec + SUM(delta_wait_time_ms)::DOUBLE PRECISION AS total_wait_ms, + extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (ORDER BY collection_time)))) AS interval_sec FROM v_wait_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 AND delta_wait_time_ms >= 0 @@ -382,7 +332,8 @@ SELECT EXTRACT(HOUR FROM collection_time)::INT AS hour_of_day, EXTRACT(DOW FROM collection_time)::INT AS day_of_week, AVG(ms_per_sec) AS mean_val, STDDEV_SAMP(ms_per_sec) AS stddev_val, - COUNT(*) AS sample_count + COUNT(*) AS sample_count, + COUNT(DISTINCT collection_time::DATE) AS distinct_days FROM with_rate GROUP BY hour_of_day, day_of_week", @@ -390,7 +341,7 @@ FROM with_rate MetricNames.BlockingPerMinute => @" WITH per_minute AS ( SELECT DATE_TRUNC('minute', collection_time) AS minute_bucket, - COUNT(*)::DOUBLE AS event_count + COUNT(*)::DOUBLE PRECISION AS event_count FROM v_blocked_process_reports WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 GROUP BY minute_bucket @@ -399,7 +350,8 @@ SELECT EXTRACT(HOUR FROM minute_bucket)::INT AS hour_of_day, EXTRACT(DOW FROM minute_bucket)::INT AS day_of_week, AVG(event_count) AS mean_val, STDDEV_SAMP(event_count) AS stddev_val, - COUNT(*) AS sample_count + COUNT(*) AS sample_count, + COUNT(DISTINCT minute_bucket::DATE) AS distinct_days FROM per_minute GROUP BY hour_of_day, day_of_week", @@ -407,78 +359,6 @@ FROM per_minute }; } - /// <summary> - /// Collapses multiple day-of-week buckets for the same hour into a single - /// hour-only bucket using pooled statistics. - /// </summary> - private static BaselineBucket CollapseToHourOnly(List<BaselineBucket> hourBuckets) - { - var totalSamples = hourBuckets.Sum(b => b.SampleCount); - if (totalSamples == 0) - return BaselineBucket.Empty; - - // Weighted mean across all day-of-week buckets for this hour - var weightedMean = hourBuckets.Sum(b => b.Mean * b.SampleCount) / totalSamples; - - // Pooled standard deviation - var pooledVariance = PoolVariance(hourBuckets, weightedMean); - - return new BaselineBucket - { - HourOfDay = hourBuckets[0].HourOfDay, - DayOfWeek = -1, // Indicates hour-only - Mean = weightedMean, - StdDev = Math.Sqrt(pooledVariance), - SampleCount = totalSamples, - Tier = BaselineTier.HourOnly - }; - } - - /// <summary> - /// Collapses all buckets into a single flat baseline (equivalent to old 24h behavior). - /// </summary> - private static BaselineBucket CollapseToFlat(List<BaselineBucket> allBuckets) - { - var totalSamples = allBuckets.Sum(b => b.SampleCount); - if (totalSamples == 0) - return BaselineBucket.Empty; - - var weightedMean = allBuckets.Sum(b => b.Mean * b.SampleCount) / totalSamples; - var pooledVariance = PoolVariance(allBuckets, weightedMean); - - return new BaselineBucket - { - HourOfDay = -1, - DayOfWeek = -1, - Mean = weightedMean, - StdDev = Math.Sqrt(pooledVariance), - SampleCount = totalSamples, - Tier = BaselineTier.Flat - }; - } - - /// <summary> - /// Computes pooled variance from multiple buckets, accounting for both - /// within-bucket variance and between-bucket mean differences. - /// </summary> - private static double PoolVariance(List<BaselineBucket> buckets, double grandMean) - { - var totalSamples = buckets.Sum(b => b.SampleCount); - if (totalSamples <= 1) return 0; - - double totalSumSq = 0; - foreach (var b in buckets) - { - if (b.SampleCount <= 0) continue; - // Within-bucket variance contribution - totalSumSq += (b.StdDev * b.StdDev) * (b.SampleCount - 1); - // Between-bucket mean difference contribution - totalSumSq += b.SampleCount * (b.Mean - grandMean) * (b.Mean - grandMean); - } - - return totalSumSq / (totalSamples - 1); - } - private class CachedBaseline { public DateTime ComputedAt { get; init; } @@ -486,62 +366,3 @@ private class CachedBaseline public Dictionary<(int HourOfDay, int DayOfWeek), BaselineBucket>? Buckets { get; init; } } } - -/// <summary> -/// Represents the computed baseline statistics for a single time bucket. -/// </summary> -public class BaselineBucket -{ - public int HourOfDay { get; init; } - public int DayOfWeek { get; init; } - public double Mean { get; init; } - public double StdDev { get; init; } - public long SampleCount { get; init; } - public BaselineTier Tier { get; init; } - - public static BaselineBucket Empty => new() - { - HourOfDay = -1, DayOfWeek = -1, Mean = 0, StdDev = 0, - SampleCount = 0, Tier = BaselineTier.Flat - }; - - /// <summary> - /// Returns the effective stddev with a proportional minimum floor to prevent - /// division-by-zero in z-score calculations. When both mean and stddev are 0 - /// (zero activity), returns 0 — callers should skip scoring. - /// </summary> - public double EffectiveStdDev - { - get - { - if (Mean == 0 && StdDev <= 0) return 0; // Zero activity — skip scoring - return Math.Max(StdDev, Mean * 0.01); - } - } -} - -public enum BaselineTier -{ - Full, // hour + day-of-week (168 buckets) - HourOnly, // hour only (24 buckets) - Flat // global mean/stddev -} - -/// <summary>Metric name constants used as baseline cache keys.</summary> -public static class MetricNames -{ - public const string Cpu = "cpu"; - public const string BatchRequests = "batch_requests"; - public const string WaitStats = "wait_stats"; - public const string SessionCount = "session_count"; - public const string QueryDuration = "query_duration"; - public const string IoLatency = "io_latency"; - public const string Blocking = "blocking"; - public const string Deadlock = "deadlock"; - public const string Memory = "memory"; - - // Chart-unit metrics (for UI bands — units match what the chart displays) - public const string MemoryBufferPoolMb = "memory_buffer_pool_mb"; - public const string WaitMsPerSec = "wait_ms_per_sec"; - public const string BlockingPerMinute = "blocking_per_minute"; -} diff --git a/Lite/Analysis/DrillDownCollector.Queries.cs b/Lite/Analysis/DrillDownCollector.Queries.cs index 99a5b6c05..7b036baed 100644 --- a/Lite/Analysis/DrillDownCollector.Queries.cs +++ b/Lite/Analysis/DrillDownCollector.Queries.cs @@ -232,8 +232,8 @@ AND delta_execution_count > 0 execution_count, min_worker_time, max_worker_time, - max_worker_time::DOUBLE / NULLIF(min_worker_time, 0) AS worker_ratio, - max_grant_kb::DOUBLE / NULLIF(min_grant_kb, 0) AS grant_ratio, + max_worker_time::DOUBLE PRECISION / NULLIF(min_worker_time, 0) AS worker_ratio, + max_grant_kb::DOUBLE PRECISION / NULLIF(min_grant_kb, 0) AS grant_ratio, CASE WHEN max_spills > 0 AND min_spills = 0 THEN 1 ELSE 0 END AS spill_divergence, LEFT(query_text, 500) AS query_text FROM latest @@ -242,7 +242,7 @@ FROM latest AND max_worker_time >= 250000 AND execution_count >= 20 AND creation_time <= $2 -AND max_worker_time::DOUBLE / NULLIF(min_worker_time, 0) >= 10 +AND max_worker_time::DOUBLE PRECISION / NULLIF(min_worker_time, 0) >= 10 ORDER BY worker_ratio DESC LIMIT 5"; @@ -318,8 +318,8 @@ plan_agg AS any_value(query_plan_hash) AS query_plan_hash, any_value(query_text) AS query_text, SUM(execution_count) AS execs, - SUM(avg_cpu_time_us * execution_count) / NULLIF(SUM(execution_count), 0) AS cpu_per_exec, - SUM(avg_duration_us * execution_count) / NULLIF(SUM(execution_count), 0) AS dur_per_exec, + SUM(avg_cpu_time_us * execution_count)::DOUBLE PRECISION / NULLIF(SUM(execution_count), 0) AS cpu_per_exec, + SUM(avg_duration_us * execution_count)::DOUBLE PRECISION / NULLIF(SUM(execution_count), 0) AS dur_per_exec, MAX(last_execution_time) AS last_exec FROM deduped WHERE rn = 1 @@ -438,13 +438,13 @@ private async Task CollectBadActorDetail(AnalysisFinding finding, AnalysisContex LEFT(MAX(query_text), 500) AS query_text, SUM(delta_execution_count)::BIGINT AS exec_count, CASE WHEN SUM(delta_execution_count) > 0 - THEN SUM(delta_worker_time)::DOUBLE / SUM(delta_execution_count) / 1000.0 + THEN SUM(delta_worker_time)::DOUBLE PRECISION / SUM(delta_execution_count) / 1000.0 ELSE 0 END AS avg_cpu_ms, CASE WHEN SUM(delta_execution_count) > 0 - THEN SUM(delta_elapsed_time)::DOUBLE / SUM(delta_execution_count) / 1000.0 + THEN SUM(delta_elapsed_time)::DOUBLE PRECISION / SUM(delta_execution_count) / 1000.0 ELSE 0 END AS avg_elapsed_ms, CASE WHEN SUM(delta_execution_count) > 0 - THEN SUM(delta_logical_reads)::DOUBLE / SUM(delta_execution_count) + THEN SUM(delta_logical_reads)::DOUBLE PRECISION / SUM(delta_execution_count) ELSE 0 END AS avg_reads, SUM(delta_worker_time)::BIGINT AS total_cpu_us, SUM(delta_logical_reads)::BIGINT AS total_reads, diff --git a/Lite/Analysis/DuckDbFactCollector.Activity.cs b/Lite/Analysis/DuckDbFactCollector.Activity.cs index a9412ee84..bb14ad121 100644 --- a/Lite/Analysis/DuckDbFactCollector.Activity.cs +++ b/Lite/Analysis/DuckDbFactCollector.Activity.cs @@ -33,13 +33,13 @@ private async Task CollectBadActorFactsAsync(AnalysisContext context, List<Fact> query_hash, SUM(delta_execution_count)::BIGINT AS exec_count, CASE WHEN SUM(delta_execution_count) > 0 - THEN SUM(delta_worker_time)::DOUBLE / SUM(delta_execution_count) / 1000.0 + THEN SUM(delta_worker_time)::DOUBLE PRECISION / SUM(delta_execution_count) / 1000.0 ELSE 0 END AS avg_cpu_ms, CASE WHEN SUM(delta_execution_count) > 0 - THEN SUM(delta_elapsed_time)::DOUBLE / SUM(delta_execution_count) / 1000.0 + THEN SUM(delta_elapsed_time)::DOUBLE PRECISION / SUM(delta_execution_count) / 1000.0 ELSE 0 END AS avg_elapsed_ms, CASE WHEN SUM(delta_execution_count) > 0 - THEN SUM(delta_logical_reads)::DOUBLE / SUM(delta_execution_count) + THEN SUM(delta_logical_reads)::DOUBLE PRECISION / SUM(delta_execution_count) ELSE 0 END AS avg_reads, SUM(delta_worker_time)::BIGINT AS total_cpu_us, SUM(delta_logical_reads)::BIGINT AS total_reads, @@ -53,7 +53,7 @@ FROM v_query_stats AND delta_execution_count > 0 GROUP BY database_name, query_hash HAVING SUM(delta_execution_count) >= 100 -ORDER BY SUM(delta_worker_time)::DOUBLE / GREATEST(SUM(delta_execution_count), 1) * +ORDER BY SUM(delta_worker_time)::DOUBLE PRECISION / GREATEST(SUM(delta_execution_count), 1) * LN(GREATEST(SUM(delta_execution_count), 1)) DESC LIMIT 5"; diff --git a/Lite/Analysis/DuckDbFactCollector.Config.cs b/Lite/Analysis/DuckDbFactCollector.Config.cs index bd5fd7aa6..b7c67d574 100644 --- a/Lite/Analysis/DuckDbFactCollector.Config.cs +++ b/Lite/Analysis/DuckDbFactCollector.Config.cs @@ -41,7 +41,9 @@ AND configuration_name IN ( 'max degree of parallelism', 'max server memory (MB)', 'min server memory (MB)', - 'max worker threads' + 'max worker threads', + 'priority boost', + 'lightweight pooling' ) ) SELECT configuration_name, value_in_use @@ -72,6 +74,8 @@ FROM latest "max server memory (MB)" => "CONFIG_MAX_MEMORY_MB", "min server memory (MB)" => "CONFIG_MIN_MEMORY_MB", "max worker threads" => "CONFIG_MAX_WORKER_THREADS", + "priority boost" => "CONFIG_PRIORITY_BOOST", + "lightweight pooling" => "CONFIG_LIGHTWEIGHT_POOLING", _ => null }; diff --git a/Lite/Analysis/DuckDbFactCollector.QueryPerf.cs b/Lite/Analysis/DuckDbFactCollector.QueryPerf.cs index 726eb8a7b..8ce9ee87c 100644 --- a/Lite/Analysis/DuckDbFactCollector.QueryPerf.cs +++ b/Lite/Analysis/DuckDbFactCollector.QueryPerf.cs @@ -133,8 +133,8 @@ AND delta_execution_count > 0 SELECT min_worker_time, max_worker_time, - max_worker_time::DOUBLE / NULLIF(min_worker_time, 0) AS worker_ratio, - max_grant_kb::DOUBLE / NULLIF(min_grant_kb, 0) AS grant_ratio, + max_worker_time::DOUBLE PRECISION / NULLIF(min_worker_time, 0) AS worker_ratio, + max_grant_kb::DOUBLE PRECISION / NULLIF(min_grant_kb, 0) AS grant_ratio, CASE WHEN max_spills > 0 AND min_spills = 0 THEN 1 ELSE 0 END AS spill_divergence FROM latest WHERE rn = 1 @@ -142,7 +142,7 @@ FROM latest AND max_worker_time >= 250000 AND execution_count >= 20 AND creation_time <= $2 -AND max_worker_time::DOUBLE / NULLIF(min_worker_time, 0) >= 10 +AND max_worker_time::DOUBLE PRECISION / NULLIF(min_worker_time, 0) >= 10 ORDER BY worker_ratio DESC LIMIT 20"; @@ -247,8 +247,8 @@ plan_agg AS plan_id, any_value(query_plan_hash) AS query_plan_hash, SUM(execution_count) AS execs, - SUM(avg_cpu_time_us * execution_count) / NULLIF(SUM(execution_count), 0) AS cpu_per_exec, - SUM(avg_duration_us * execution_count) / NULLIF(SUM(execution_count), 0) AS dur_per_exec, + SUM(avg_cpu_time_us * execution_count)::DOUBLE PRECISION / NULLIF(SUM(execution_count), 0) AS cpu_per_exec, + SUM(avg_duration_us * execution_count)::DOUBLE PRECISION / NULLIF(SUM(execution_count), 0) AS dur_per_exec, MAX(last_execution_time) AS last_exec, bool_or(is_forced_plan) AS is_forced_plan, MAX(force_failure_count) AS force_failure_count diff --git a/Lite/Analysis/DuckDbFactCollector.Resources.cs b/Lite/Analysis/DuckDbFactCollector.Resources.cs index 11b10e247..0348f76f7 100644 --- a/Lite/Analysis/DuckDbFactCollector.Resources.cs +++ b/Lite/Analysis/DuckDbFactCollector.Resources.cs @@ -53,6 +53,63 @@ ORDER BY collection_time DESC catch { /* Table may not exist or have no data */ } } + /// <summary> + /// Collects the runnable-task-queue pressure signal from the EXISTING cpu_scheduler_stats snapshot + /// (its total_runnable_tasks_count = SUM(runnable_tasks_count) and its runnable_tasks_warning = + /// SUM(runnable_tasks_count) >= cpu_count heuristic, both already computed by the shared + /// CpuSchedulerStatsCollector) as one RUNNABLE_TASKS context fact (Source "cpu" — carried into the + /// FactScorer amplifier lookup but never scored/rooted, like every Source="cpu" key besides + /// CPU_SQL_PERCENT/CPU_SPIKE). The THREADPOOL runnable-queue amplifier reads the warning flag to + /// confirm real scheduler CPU pressure behind a thread exhaustion. No new collector — reuses the + /// data cpu_scheduler_stats already stores (not collected on Azure SQL DB, where the fact is simply + /// absent and the amplifier no-ops). The read is window-bounded to [TimeRangeStart, TimeRangeEnd] + /// (a lower bound, not just <= end, matching CpuUtilizationSql) so a lapsed collection surfaces + /// no stale snapshot from outside the window — the fact is then absent and the amplifier no-ops. + /// </summary> + private async Task CollectRunnableTaskFactsAsync(AnalysisContext context, List<Fact> facts) + { + try + { + using var readLock = _duckDb.AcquireReadLock(); + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +SELECT total_runnable_tasks_count, runnable_tasks_warning +FROM cpu_scheduler_stats +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3 +ORDER BY collection_time DESC +LIMIT 1"; + + cmd.Parameters.Add(new DuckDBParameter { Value = context.ServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = context.TimeRangeStart }); + cmd.Parameters.Add(new DuckDBParameter { Value = context.TimeRangeEnd }); + + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + var totalRunnable = reader.IsDBNull(0) ? 0.0 : Convert.ToDouble(reader.GetValue(0)); + var runnableWarning = reader.IsDBNull(1) ? 0.0 : Convert.ToDouble(reader.GetValue(1)); + + facts.Add(new Fact + { + Source = "cpu", + Key = "RUNNABLE_TASKS", + Value = totalRunnable, + ServerId = context.ServerId, + Metadata = new Dictionary<string, double> + { + ["total_runnable_tasks"] = totalRunnable, + ["runnable_tasks_warning"] = runnableWarning + } + }); + } + catch { /* Table may not exist or have no data */ } + } + /// <summary> /// Collects memory grant facts from the resource semaphore view. /// Detects grant waiters (sessions waiting for memory) and grant pressure. @@ -316,4 +373,142 @@ AND counter_name IN ('Batch Requests/sec', 'SQL Compilations/sec', 'SQL Re-Com catch { /* Table may not exist or have no data */ } } + /// <summary> + /// Collects the plan-cache single-use bloat signal from the LATEST plan_cache_stats snapshot. Mirrors + /// the Dashboard's report.plan_cache_bloat (install/47_create_reporting_views.sql:1456-1496), which + /// SUMs single_use_plans / total_plans / single_use_size_mb / total_size_mb over the newest + /// collection_time and derives single_use_percent. Read as a point-in-time state (no lower time bound, + /// just <= TimeRangeEnd, like CollectMemoryFactsAsync / the config reads); DENSE_RANK() picks every + /// row of the newest collection (avoiding QUALIFY, which is DuckDB-only and banned in the shared PG + /// dialect). The fact is emitted whenever a real cache exists (total_plans > 0); the FactScorer + /// applies the > 50/30/20% bloat tiers AND the single_use_size_mb >= 100 noise guard, so a + /// healthy or tiny cache stays context-only. Absent on an empty/uncollected cache. + /// </summary> + private async Task CollectPlanCacheFactsAsync(AnalysisContext context, List<Fact> facts) + { + try + { + using var readLock = _duckDb.AcquireReadLock(); + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +WITH ranked AS ( + SELECT total_plans, single_use_plans, total_size_mb, single_use_size_mb, + DENSE_RANK() OVER (ORDER BY collection_time DESC) AS rnk + FROM plan_cache_stats + WHERE server_id = $1 + AND collection_time <= $2 +) +SELECT + COALESCE(SUM(total_plans), 0) AS total_plans, + COALESCE(SUM(single_use_plans), 0) AS single_use_plans, + COALESCE(SUM(total_size_mb), 0) AS total_size_mb, + COALESCE(SUM(single_use_size_mb), 0) AS single_use_size_mb +FROM ranked +WHERE rnk = 1"; + + cmd.Parameters.Add(new DuckDBParameter { Value = context.ServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = context.TimeRangeEnd }); + + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + // SUM(INTEGER) widens to HUGEINT — DuckDB hands it back boxed as BigInteger, which + // Convert.ToDouble cannot take (no IConvertible); read every aggregate through the + // BigInteger-tolerant ToInt64, then widen the MB sizes to double for the metadata. + var totalPlans = reader.IsDBNull(0) ? 0L : ToInt64(reader.GetValue(0)); + var singleUsePlans = reader.IsDBNull(1) ? 0L : ToInt64(reader.GetValue(1)); + var totalSizeMb = reader.IsDBNull(2) ? 0.0 : (double)ToInt64(reader.GetValue(2)); + var singleUseSizeMb = reader.IsDBNull(3) ? 0.0 : (double)ToInt64(reader.GetValue(3)); + + // No cache collected (or an empty cache) — nothing to classify. + if (totalPlans <= 0) return; + + var singleUsePercent = singleUsePlans * 100.0 / totalPlans; + + facts.Add(new Fact + { + Source = "memory", + Key = "PLAN_CACHE_BLOAT", + Value = singleUsePercent, + ServerId = context.ServerId, + Metadata = new Dictionary<string, double> + { + ["single_use_plans"] = singleUsePlans, + ["total_plans"] = totalPlans, + ["single_use_size_mb"] = singleUseSizeMb, + ["total_size_mb"] = totalSizeMb, + ["single_use_percent"] = singleUsePercent + } + }); + } + catch { /* Table may not exist or have no data */ } + } + + /// <summary> + /// Collects ring-buffer physical-memory-pressure notifications from memory_pressure_events + /// (RING_BUFFER_RESOURCE_MONITOR / sp_pressuredetector). Mirrors the Dashboard's + /// report.memory_pressure_events (install/47_create_reporting_views.sql:220-238), whose severity keys + /// purely off memory_indicators_process / memory_indicators_system (>= 3 HIGH, >= 2 MEDIUM, else + /// LOW). Window-bounded to [TimeRangeStart, TimeRangeEnd] like the other window collectors (a distinct + /// signal from the deviation-based ANOMALY_MEMORY_PRESSURE, which watches peak_memory_pressure_pct). + /// NOISE GATE: emit only when a genuine MEDIUM+ indicator occurred (max >= 2) — the ring buffer is + /// full of steady 0-1 samples on every healthy server, which must stay silent (mirrors the MEDIUM + /// severity floor, install/47:232-233). Value = the max overall indicator (drives the scored band); + /// event_count = the count of MEDIUM+ samples for the card. + /// </summary> + private async Task CollectMemoryPressureEventFactsAsync(AnalysisContext context, List<Fact> facts) + { + try + { + using var readLock = _duckDb.AcquireReadLock(); + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +SELECT + SUM(CASE WHEN memory_indicators_process >= 2 OR memory_indicators_system >= 2 THEN 1 ELSE 0 END) AS pressure_event_count, + MAX(memory_indicators_process) AS max_process, + MAX(memory_indicators_system) AS max_system +FROM memory_pressure_events +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3"; + + cmd.Parameters.Add(new DuckDBParameter { Value = context.ServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = context.TimeRangeStart }); + cmd.Parameters.Add(new DuckDBParameter { Value = context.TimeRangeEnd }); + + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + var pressureEventCount = reader.IsDBNull(0) ? 0L : ToInt64(reader.GetValue(0)); + var maxProcess = reader.IsDBNull(1) ? 0.0 : Convert.ToDouble(reader.GetValue(1)); + var maxSystem = reader.IsDBNull(2) ? 0.0 : Convert.ToDouble(reader.GetValue(2)); + var maxIndicator = Math.Max(maxProcess, maxSystem); + + // Steady ring buffer (indicators 0-1) is the healthy norm on every server — only a genuine + // MEDIUM+ pressure indicator is worth a fact (install/47:232-233 MEDIUM floor). + if (maxIndicator < 2) return; + + facts.Add(new Fact + { + Source = "memory", + Key = "MEMORY_PRESSURE_EVENTS", + Value = maxIndicator, + ServerId = context.ServerId, + Metadata = new Dictionary<string, double> + { + ["event_count"] = pressureEventCount, + ["max_process_indicator"] = maxProcess, + ["max_system_indicator"] = maxSystem + } + }); + } + catch { /* Table may not exist or have no data */ } + } + } diff --git a/Lite/Analysis/DuckDbFactCollector.cs b/Lite/Analysis/DuckDbFactCollector.cs index 00d7f1b48..dad6bad12 100644 --- a/Lite/Analysis/DuckDbFactCollector.cs +++ b/Lite/Analysis/DuckDbFactCollector.cs @@ -37,6 +37,7 @@ public async Task<List<Fact>> CollectFactsAsync(AnalysisContext context) await CollectDatabaseSizeFactAsync(context, facts); await CollectServerMetadataFactsAsync(context, facts); await CollectCpuUtilizationFactsAsync(context, facts); + await CollectRunnableTaskFactsAsync(context, facts); await CollectIoLatencyFactsAsync(context, facts); await CollectTempDbFactsAsync(context, facts); await CollectMemoryGrantFactsAsync(context, facts); @@ -46,6 +47,8 @@ public async Task<List<Fact>> CollectFactsAsync(AnalysisContext context) await CollectBadActorFactsAsync(context, facts); await CollectPerfmonFactsAsync(context, facts); await CollectMemoryClerkFactsAsync(context, facts); + await CollectPlanCacheFactsAsync(context, facts); + await CollectMemoryPressureEventFactsAsync(context, facts); await CollectDatabaseConfigFactsAsync(context, facts); await CollectFileAutogrowthFactsAsync(context, facts); await CollectProcedureStatsFactsAsync(context, facts); diff --git a/Lite/Analysis/FindingStore.cs b/Lite/Analysis/FindingStore.cs index 7698033ce..d50c81598 100644 --- a/Lite/Analysis/FindingStore.cs +++ b/Lite/Analysis/FindingStore.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using DuckDB.NET.Data; using PerformanceMonitor.Analysis; +using PerformanceMonitor.Notifications; using PerformanceMonitorLite.Database; namespace PerformanceMonitorLite.Analysis; @@ -11,6 +12,16 @@ namespace PerformanceMonitorLite.Analysis; /// Persists analysis findings to DuckDB and checks for muted story hashes. /// Handles the write side of the analysis pipeline — after the engine produces /// stories, FindingStore saves them and filters out muted patterns. +/// +/// <para> +/// The write is two-phase (mirrors Darling's <c>PgFindingStore</c>): <see cref="FilterMutedFindingsAsync"/> +/// materializes the surviving findings WITHOUT inserting, the orchestrator enriches them with drill-down +/// and builds each finding's <see cref="AnalysisFinding.Remediation"/>, then <see cref="InsertFindingsAsync"/> +/// persists the rows — including the BUILT action serialized as <c>remediation_action_json</c> via the +/// shared <see cref="AlertContextSerializer"/>. The read paths deserialize it back onto each finding so the +/// Recommendations reader can render the copy-paste command byte-identically to the Darling viewer. +/// <see cref="SaveFindingsAsync"/> remains as a single-pass convenience wrapper (no action attached). +/// </para> /// </summary> public class FindingStore { @@ -24,19 +35,23 @@ public FindingStore(DuckDbInitializer duckDb) } /// <summary> - /// Saves analysis stories as findings, filtering out any that match muted hashes. - /// Returns the list of findings that were actually saved (non-muted). + /// Mute-filters the stories and materializes the SURVIVING findings WITHOUT inserting them + /// (mirrors Darling's <c>PgFindingStore.FilterMutedFindingsAsync</c> — the recommendations + /// rebuild D2/P2 reorder). The orchestrator then enriches these survivors with drill-down and + /// builds + attaches each finding's <see cref="AnalysisFinding.Remediation"/> before calling + /// <see cref="InsertFindingsAsync"/>, so the BUILT action is persisted on the row (the builders + /// require a drill-down the store read-back does not return). Absolution stories (severity 0) + /// and muted hashes are dropped here and never enriched. /// </summary> - public async Task<List<AnalysisFinding>> SaveFindingsAsync( + public async Task<List<AnalysisFinding>> FilterMutedFindingsAsync( List<AnalysisStory> stories, AnalysisContext context) { var analysisTime = DateTime.UtcNow; - var saved = new List<AnalysisFinding>(); + var survivors = new List<AnalysisFinding>(); - /* One read lock + one connection per save call, reused for the mute-filter read - and every insert. The previous per-finding pattern acquired the lock and opened - a fresh connection for each row. The lock is NoRecursion, so the helpers below - operate on the passed connection and must NOT re-acquire it. */ + /* One read lock + one connection for the mute-filter read only. Released before the caller + enriches + builds actions; InsertFindingsAsync then re-acquires for the batched insert. + The lock is NoRecursion, so the helper below operates on the passed connection. */ using var readLock = _duckDb.AcquireReadLock(); using var connection = _duckDb.CreateConnection(); await connection.OpenAsync(); @@ -52,7 +67,7 @@ operate on the passed connection and must NOT re-acquire it. */ if (mutedHashes.Contains(story.StoryPathHash)) continue; - var finding = new AnalysisFinding + survivors.Add(new AnalysisFinding { FindingId = _nextId++, AnalysisTime = analysisTime, @@ -74,13 +89,49 @@ operate on the passed connection and must NOT re-acquire it. */ LeafFactValue = story.LeafFactValue, FactCount = story.FactCount, RootFactMetadata = story.RootFactMetadata - }; + }); + } + + return survivors; + } + + /// <summary> + /// Inserts the (already mute-filtered, enriched, and action-attached) findings in one batched + /// pass on a single connection. Each row persists its BUILT <see cref="AnalysisFinding.Remediation"/> + /// as <c>remediation_action_json</c> via the shared <see cref="AlertContextSerializer"/>, so a Lite + /// finding's persisted action round-trips byte-identically to a Darling / Dashboard one. Returns the + /// same list for caller convenience; the in-memory findings are unchanged. + /// </summary> + public async Task<List<AnalysisFinding>> InsertFindingsAsync( + List<AnalysisFinding> findings, AnalysisContext context) + { + if (findings.Count == 0) + return findings; + /* One read lock + one connection for the whole batch, reused for every insert. */ + using var readLock = _duckDb.AcquireReadLock(); + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + + foreach (var finding in findings) await InsertFindingAsync(connection, finding); - saved.Add(finding); - } - return saved; + return findings; + } + + /// <summary> + /// Saves analysis stories as findings in one pass, filtering out any that match muted hashes — + /// the single-pass convenience surface. Implemented as <see cref="FilterMutedFindingsAsync"/> + + /// <see cref="InsertFindingsAsync"/>. NOTE: a caller that wants <c>remediation_action_json</c> + /// persisted must use the two-phase shape and attach actions between the phases (as + /// <c>AnalysisService</c> does); this single pass inserts the findings exactly as filtered, with + /// a null action. + /// </summary> + public async Task<List<AnalysisFinding>> SaveFindingsAsync( + List<AnalysisStory> stories, AnalysisContext context) + { + var survivors = await FilterMutedFindingsAsync(stories, context); + return await InsertFindingsAsync(survivors, context); } /// <summary> @@ -100,7 +151,8 @@ public async Task<List<AnalysisFinding>> GetRecentFindingsAsync( SELECT finding_id, analysis_time, server_id, server_name, database_name, time_range_start, time_range_end, severity, confidence, category, story_path, story_path_hash, story_text, - root_fact_key, root_fact_value, leaf_fact_key, leaf_fact_value, fact_count, incident_id + root_fact_key, root_fact_value, leaf_fact_key, leaf_fact_value, fact_count, incident_id, + remediation_action_json FROM analysis_findings WHERE server_id = $1 AND analysis_time >= $2 @@ -134,7 +186,10 @@ FROM analysis_findings LeafFactKey = reader.IsDBNull(15) ? null : reader.GetString(15), LeafFactValue = reader.IsDBNull(16) ? null : reader.GetDouble(16), FactCount = reader.GetInt32(17), - IncidentId = reader.IsDBNull(18) ? string.Empty : reader.GetString(18) + IncidentId = reader.IsDBNull(18) ? string.Empty : reader.GetString(18), + // Persisted BUILT action (the recommendations copy-paste command) deserialized via the + // SAME shared serializer the alert path uses; null/garbage degrades to "no command". + Remediation = reader.IsDBNull(19) ? null : AlertContextSerializer.DeserializeAction(reader.GetString(19)) }); } @@ -157,7 +212,8 @@ public async Task<List<AnalysisFinding>> GetLatestFindingsAsync(int serverId) SELECT finding_id, analysis_time, server_id, server_name, database_name, time_range_start, time_range_end, severity, confidence, category, story_path, story_path_hash, story_text, - root_fact_key, root_fact_value, leaf_fact_key, leaf_fact_value, fact_count, incident_id + root_fact_key, root_fact_value, leaf_fact_key, leaf_fact_value, fact_count, incident_id, + remediation_action_json FROM analysis_findings WHERE server_id = $1 AND analysis_time = ( @@ -190,7 +246,10 @@ SELECT MAX(analysis_time) FROM analysis_findings WHERE server_id = $1 LeafFactKey = reader.IsDBNull(15) ? null : reader.GetString(15), LeafFactValue = reader.IsDBNull(16) ? null : reader.GetDouble(16), FactCount = reader.GetInt32(17), - IncidentId = reader.IsDBNull(18) ? string.Empty : reader.GetString(18) + IncidentId = reader.IsDBNull(18) ? string.Empty : reader.GetString(18), + // Persisted BUILT action (the recommendations copy-paste command) deserialized via the + // SAME shared serializer the alert path uses; null/garbage degrades to "no command". + Remediation = reader.IsDBNull(19) ? null : AlertContextSerializer.DeserializeAction(reader.GetString(19)) }); } @@ -212,7 +271,9 @@ INSERT INTO analysis_muted (mute_id, server_id, story_path_hash, story_path, mut VALUES ($1, $2, $3, $4, $5, $6)"; cmd.Parameters.Add(new DuckDBParameter { Value = _nextId++ }); - cmd.Parameters.Add(new DuckDBParameter { Value = serverId }); + // serverId 0 is the MCP "mute across all servers" sentinel; persist it as NULL, the + // canonical global marker every reader filters on (legacy 0 rows are still honored). + cmd.Parameters.Add(new DuckDBParameter { Value = serverId == 0 ? (object)DBNull.Value : serverId }); cmd.Parameters.Add(new DuckDBParameter { Value = storyPathHash }); cmd.Parameters.Add(new DuckDBParameter { Value = storyPath }); cmd.Parameters.Add(new DuckDBParameter { Value = DateTime.UtcNow }); @@ -221,21 +282,6 @@ INSERT INTO analysis_muted (mute_id, server_id, story_path_hash, story_path, mut await cmd.ExecuteNonQueryAsync(); } - /// <summary> - /// Unmutes a story pattern. - /// </summary> - public async Task UnmuteStoryAsync(long muteId) - { - using var readLock = _duckDb.AcquireReadLock(); - using var connection = _duckDb.CreateConnection(); - await connection.OpenAsync(); - - using var cmd = connection.CreateCommand(); - cmd.CommandText = "DELETE FROM analysis_muted WHERE mute_id = $1"; - cmd.Parameters.Add(new DuckDBParameter { Value = muteId }); - await cmd.ExecuteNonQueryAsync(); - } - /// <summary> /// Cleans up old findings beyond the retention period. /// </summary> @@ -254,16 +300,18 @@ public async Task CleanupOldFindingsAsync(int retentionDays = 30) /// <summary> /// Reads muted story hashes on an already-open connection. The caller owns the read /// lock and connection (NoRecursion lock — do not re-acquire here). Used by - /// SaveFindingsAsync so the mute-filter read reuses the save connection. + /// FilterMutedFindingsAsync so the mute-filter read reuses that phase's connection. /// </summary> private static async Task<HashSet<string>> GetMutedHashesAsync(DuckDBConnection connection, int serverId) { var hashes = new HashSet<string>(); using var cmd = connection.CreateCommand(); + // server_id = 0 rows are legacy all-servers mutes written by the pre-fix MCP tool path + // (no real server has id 0); honor them as global, alongside the canonical NULL. cmd.CommandText = @" SELECT story_path_hash FROM analysis_muted -WHERE server_id = $1 OR server_id IS NULL"; +WHERE server_id = $1 OR server_id IS NULL OR server_id = 0"; cmd.Parameters.Add(new DuckDBParameter { Value = serverId }); @@ -276,7 +324,7 @@ SELECT story_path_hash FROM analysis_muted /// <summary> /// Inserts one finding on an already-open connection. The caller owns the read lock - /// and connection, so a batch of inserts in one SaveFindingsAsync call shares a + /// and connection, so a batch of inserts in one InsertFindingsAsync call shares a /// single lock acquisition and connection. /// </summary> private static async Task InsertFindingAsync(DuckDBConnection connection, AnalysisFinding finding) @@ -287,8 +335,9 @@ INSERT INTO analysis_findings (finding_id, analysis_time, server_id, server_name, database_name, time_range_start, time_range_end, severity, confidence, category, story_path, story_path_hash, story_text, - root_fact_key, root_fact_value, leaf_fact_key, leaf_fact_value, fact_count, incident_id) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)"; + root_fact_key, root_fact_value, leaf_fact_key, leaf_fact_value, fact_count, incident_id, + remediation_action_json) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)"; cmd.Parameters.Add(new DuckDBParameter { Value = finding.FindingId }); cmd.Parameters.Add(new DuckDBParameter { Value = finding.AnalysisTime }); @@ -309,6 +358,9 @@ INSERT INTO analysis_findings cmd.Parameters.Add(new DuckDBParameter { Value = finding.LeafFactValue ?? (object)DBNull.Value }); cmd.Parameters.Add(new DuckDBParameter { Value = finding.FactCount }); cmd.Parameters.Add(new DuckDBParameter { Value = finding.IncidentId ?? string.Empty }); + // Persist the BUILT action (mirrors the alert path's ContextJson) so the Recommendations + // reader can render the copy-paste command from a stored finding. Null when no shape applies. + cmd.Parameters.Add(new DuckDBParameter { Value = (object?)AlertContextSerializer.SerializeAction(finding.Remediation) ?? DBNull.Value }); await cmd.ExecuteNonQueryAsync(); } diff --git a/Lite/Analysis/Recommendations/LiteRecommendationItem.cs b/Lite/Analysis/Recommendations/LiteRecommendationItem.cs index dfcffb490..9c4a4a8c7 100644 --- a/Lite/Analysis/Recommendations/LiteRecommendationItem.cs +++ b/Lite/Analysis/Recommendations/LiteRecommendationItem.cs @@ -26,15 +26,18 @@ public enum LiteRecommendationSeverity } /// <summary> -/// A single Lite Recommendations row — the advise-only DTO the surface renders. A plain -/// object with NO WPF dependency so the reader's mapping is unit-testable without a UI or a -/// database. Built by <see cref="LiteRecommendationsReader"/> from an +/// A single Lite Recommendations row — the DTO the surface renders. A plain object with NO WPF +/// dependency so the reader's mapping is unit-testable without a UI or a database. Built by +/// <see cref="LiteRecommendationsReader"/> from an /// <see cref="PerformanceMonitor.Analysis.AnalysisFinding"/>. /// /// <para> -/// Lite is ADVISE-ONLY: there is no <c>RemediationAction</c> here and no Apply path. SQL-side -/// remediation is Dashboard-only (per project scope), so a Lite card offers the diagnosis, -/// copy-paste T-SQL when one is derivable, and an "Ask AI" MCP prompt — never an in-app Apply. +/// Lite produces a COPYABLE remediation command but has NO in-app Apply/execute path (SQL-side +/// remediation execution is Dashboard-only, per project scope). A Lite card offers the diagnosis, the +/// copy-paste T-SQL rendered from the finding's persisted +/// <see cref="PerformanceMonitor.Analysis.RemediationAction"/> (the SAME shared renderer the Darling +/// viewer uses, so the commands are byte-identical), and an "Ask AI" MCP prompt — the operator runs the +/// command themselves against their own direct SQL Server connection. /// </para> /// </summary> public sealed class LiteRecommendationItem @@ -54,12 +57,6 @@ public sealed class LiteRecommendationItem /// <summary>The one-line title shown as the card heading.</summary> public string Title { get; set; } = string.Empty; - /// <summary> - /// The problem-area / category grouping (the finding's <c>Category</c>). Kept distinct - /// from <see cref="Title"/> for any future grouping/filtering. - /// </summary> - public string ProblemArea { get; set; } = string.Empty; - /// <summary> /// Operator-facing advice prose, composed from <see cref="PerformanceMonitor.Analysis.FactAdvice"/> /// for the finding's root fact key (remediation line + investigation line). Falls back to the @@ -68,18 +65,14 @@ public sealed class LiteRecommendationItem public string? AdviceText { get; set; } /// <summary> - /// Copy-paste-ready T-SQL the operator can run themselves, generated by the SHARED - /// <see cref="PerformanceMonitor.Analysis.FactRemediation"/> helpers (the same source the - /// Dashboard cards use). Null when the finding carries no derivable statement — which, on - /// Lite's read-back path, is the common case: the generators read the finding's ephemeral - /// drill-down detail, and Lite does not persist that to DuckDB, so a finding read back from - /// storage has no drill-down and yields no SQL. Such cards render advise-only (advice + Ask AI). + /// Copy-paste-ready T-SQL the operator can run themselves, rendered from the finding's persisted + /// <see cref="PerformanceMonitor.Analysis.RemediationAction"/> by the SHARED + /// <see cref="PerformanceMonitor.Analysis.FactRemediation.RenderCopyPasteCommand"/> (the same + /// renderer the Darling viewer's Copy-fix uses, so the commands are byte-identical). Null when the + /// finding carries no execution shape — such cards render advise-only (advice + Ask AI). /// </summary> public string? CopyPasteSql { get; set; } - /// <summary>The finding's root fact key — drives the advice lookup and the Ask-AI prompt.</summary> - public string RootFactKey { get; set; } = string.Empty; - /// <summary> /// The finding's incident id (correlate-and-focus) — the group key the surface collapses cards /// under, so related findings render as one report. Empty for findings analyzed before incident_id diff --git a/Lite/Analysis/Recommendations/LiteRecommendationsReader.cs b/Lite/Analysis/Recommendations/LiteRecommendationsReader.cs index b2d95c861..0b8c93353 100644 --- a/Lite/Analysis/Recommendations/LiteRecommendationsReader.cs +++ b/Lite/Analysis/Recommendations/LiteRecommendationsReader.cs @@ -17,19 +17,23 @@ namespace PerformanceMonitorLite.Analysis.Recommendations; /// <summary> /// The data layer for the Lite Recommendations surface (WS1c). Reads the engine findings a /// server persisted to DuckDB (via the existing <see cref="FindingStore.GetRecentFindingsAsync"/> -/// read path — no new schema, no new SQL) and maps each to an advise-only -/// <see cref="LiteRecommendationItem"/>. This mirrors the Dashboard's +/// read path) and maps each to a <see cref="LiteRecommendationItem"/>. This mirrors the Dashboard's /// <c>RecommendationsReader</c> logic — engine severity banding and latest-batch-only — but is -/// Lite-contained and ADVISE-ONLY: there is no legacy critical_issues store in Lite, no de-dupe -/// across producers, and no <c>RemediationAction</c> / Apply path. +/// Lite-contained: there is no legacy critical_issues store in Lite and no de-dupe across producers. +/// Lite produces a COPYABLE remediation command (no in-app Apply/execute path) — the operator runs it +/// against their own direct SQL Server connection. /// /// <para> /// Advice prose comes from the SHARED <see cref="FactAdvice"/> table (keyed on the finding's root -/// fact key, which IS read back), and copy-paste T-SQL from the SHARED -/// <see cref="FactRemediation"/> helpers — the same sources the Dashboard cards use, so Lite and -/// Dashboard advice stay consistent. Note Lite does not persist a finding's ephemeral drill-down -/// detail, and the SQL generators read that detail, so on the read-back path -/// <see cref="LiteRecommendationItem.CopyPasteSql"/> is typically null and cards render advise-only. +/// fact key, which IS read back). The copy-paste T-SQL comes from the finding's PERSISTED +/// <see cref="PerformanceMonitor.Analysis.RemediationAction"/> (built at analysis time from the +/// drill-down, serialized into <c>remediation_action_json</c>, deserialized on read) rendered by the +/// SHARED <see cref="FactRemediation.RenderCopyPasteCommand"/> — the SAME source the Darling viewer's +/// Copy-fix delegates to, so Lite and Darling produce byte-identical commands for all seven remediation +/// shapes (the two destructive ones carry a two-sided risk-disclosure header). The live +/// <see cref="FactRemediation.GenerateForFinding"/> path is kept only as a fallback for a finding still +/// carrying its ephemeral drill-down (no persisted action). Findings with no execution shape carry a +/// null action and render advise-only. /// </para> /// /// <para> @@ -130,10 +134,13 @@ internal static IReadOnlyList<AnalysisFinding> LatestBatchOnly(IReadOnlyList<Ana } /// <summary> - /// Maps one engine <see cref="AnalysisFinding"/> to a single advise-only - /// <see cref="LiteRecommendationItem"/>. Advice comes from <see cref="FactAdvice"/> for the - /// root fact key; the copy-paste SQL comes from <see cref="FactRemediation.GenerateForFinding"/> - /// (null on Lite's read-back path, where the finding has no drill-down — see the class remarks). + /// Maps one engine <see cref="AnalysisFinding"/> to a single <see cref="LiteRecommendationItem"/>. + /// Advice comes from <see cref="FactAdvice"/> for the root fact key; the copy-paste command comes + /// PRIMARILY from the finding's persisted <see cref="AnalysisFinding.Remediation"/> rendered by the + /// shared <see cref="BuildCopyPasteSql"/> (the read-back path — the finding has no drill-down but + /// carries the built action), falling back to the live <see cref="FactRemediation.GenerateForFinding"/> + /// for a finding still carrying its ephemeral drill-down. Null when neither applies — the card then + /// renders advise-only. /// </summary> internal static LiteRecommendationItem MapFinding(AnalysisFinding finding, string serverName) { @@ -148,10 +155,11 @@ internal static LiteRecommendationItem MapFinding(AnalysisFinding finding, strin RawSeverity = finding.Severity, Database = string.IsNullOrEmpty(finding.DatabaseName) ? null : finding.DatabaseName, Title = !string.IsNullOrEmpty(advice?.Headline) ? advice!.Headline : finding.RootFactKey, - ProblemArea = finding.Category, AdviceText = ComposeAdvice(advice), - CopyPasteSql = NullIfEmpty(FactRemediation.GenerateForFinding(finding)), - RootFactKey = finding.RootFactKey, + // Persisted-action render is the PRIMARY source (mirrors the Darling viewer); the live + // drill-down generator is only a fallback when no action was persisted (see class remarks). + CopyPasteSql = NullIfEmpty(BuildCopyPasteSql(finding.Remediation)) + ?? NullIfEmpty(FactRemediation.GenerateForFinding(finding)), IncidentId = finding.IncidentId, ServerName = serverName ?? string.Empty, WindowStartUtc = AsUtc(finding.TimeRangeStart), @@ -159,6 +167,20 @@ internal static LiteRecommendationItem MapFinding(AnalysisFinding finding, strin }; } + /// <summary> + /// Rebuilds the copy-paste T-SQL for a card from the finding's PERSISTED + /// <see cref="RemediationAction"/> by delegating to the shared + /// <see cref="FactRemediation.RenderCopyPasteCommand"/> — the SAME renderer the Darling viewer's + /// Copy-fix uses, so a Lite card and a Darling card produce byte-identical commands for all seven + /// remediation shapes (force-plan, clear-plan, server-config, RCSI, safe DB-config, file-growth, + /// missing-index; the two destructive shapes carry a two-sided risk-disclosure comment header). + /// Lite has NO in-app executor — this is a copyable command only. A thin <c>internal</c> seam over + /// the shared renderer so the reader can be unit-tested directly and cannot drift from the viewer. + /// Null when the action is null or carries no renderable target. + /// </summary> + internal static string? BuildCopyPasteSql(RemediationAction? action) => + FactRemediation.RenderCopyPasteCommand(action); + /// <summary> /// Maps an engine finding's <c>double</c> severity (0–~2.0) onto the canonical band. Cutoffs /// are identical to the Dashboard reader's: <c>>= 1.5</c> → Critical, <c>>= 0.75</c> → diff --git a/Lite/App.xaml.cs b/Lite/App.xaml.cs index f7f01ee2c..24a8bfb69 100644 --- a/Lite/App.xaml.cs +++ b/Lite/App.xaml.cs @@ -11,6 +11,8 @@ using System.Runtime.InteropServices; using System.Security.AccessControl; using System.Security.Principal; +using System.Text.Json; +using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; using System.Windows; @@ -159,6 +161,9 @@ private static string GetDefaultCsvSeparator() /* Color theme ("Dark" or "Light") */ public static string ColorTheme { get; set; } = "Dark"; + /* NOC Overview tile sort ("Cpu" = CPU% descending default, or "Name") */ + public static ServerOverviewSortMode OverviewSortMode { get; set; } = ServerOverviewSort.Default; + /* Update check settings */ public static bool CheckForUpdatesOnStartup { get; set; } = true; @@ -172,8 +177,21 @@ private static string GetDefaultCsvSeparator() public static string SlackWebhookUrl { get; set; } = ""; public static string SlackProxyAddress { get; set; } = ""; + /* Generic webhook settings (#1506) — POSTs an operator-authored JSON body to any endpoint, so an + alert can drive automation we ship no adapter for (PagerDuty, Opsgenie, n8n, or a GitHub + repository_dispatch that re-runs a workflow). The URL and the headers JSON both carry bearer + tokens, so — like the Teams/Slack URLs — they live in Credential Manager, never in settings.json; + only the enable flag, the proxy, and the body template are plain prefs. */ + public static bool GenericWebhookEnabled { get; set; } = false; + public static string GenericWebhookUrl { get; set; } = ""; + public static string GenericWebhookHeadersJson { get; set; } = ""; + public static string GenericWebhookBodyTemplate { get; set; } = ""; + public static string GenericWebhookProxyAddress { get; set; } = ""; + private const string TeamsWebhookCredentialKey = "TeamsWebhook"; private const string SlackWebhookCredentialKey = "SlackWebhook"; + private const string GenericWebhookCredentialKey = "GenericWebhook"; + private const string GenericWebhookHeadersCredentialKey = "GenericWebhookHeaders"; /// <summary> /// Gets a webhook URL from Windows Credential Manager. @@ -579,6 +597,9 @@ public static void LoadAlertSettings() if (t == "Dark" || t == "Light" || t == "CoolBreeze") ColorTheme = t; } + /* NOC Overview tile sort */ + if (root.TryGetProperty("overview_sort_mode", out v)) OverviewSortMode = ServerOverviewSort.ParseMode(v.GetString()); + /* Update check settings */ if (root.TryGetProperty("check_for_updates_on_startup", out v)) CheckForUpdatesOnStartup = v.GetBoolean(); @@ -590,6 +611,12 @@ public static void LoadAlertSettings() if (root.TryGetProperty("slack_webhook_enabled", out v)) SlackWebhookEnabled = v.GetBoolean(); if (root.TryGetProperty("slack_proxy_address", out v)) SlackProxyAddress = v.GetString() ?? ""; + /* Generic webhook settings (#1506). The URL + headers JSON are secrets and load from Credential + Manager below; only these three are plain prefs. */ + if (root.TryGetProperty("generic_webhook_enabled", out v)) GenericWebhookEnabled = v.GetBoolean(); + if (root.TryGetProperty("generic_proxy_address", out v)) GenericWebhookProxyAddress = v.GetString() ?? ""; + if (root.TryGetProperty("generic_body_template", out v)) GenericWebhookBodyTemplate = v.GetString() ?? ""; + /* Migrate webhook URLs from plaintext settings.json to Credential Manager */ if (root.TryGetProperty("teams_webhook_url", out v)) { @@ -608,9 +635,12 @@ public static void LoadAlertSettings() } } - /* Load webhook URLs from Credential Manager */ + /* Load webhook URLs from Credential Manager. The generic channel's headers JSON rides the same + secure store as a URL — it carries the Authorization bearer token (#1506). */ TeamsWebhookUrl = GetWebhookUrl(TeamsWebhookCredentialKey); SlackWebhookUrl = GetWebhookUrl(SlackWebhookCredentialKey); + GenericWebhookUrl = GetWebhookUrl(GenericWebhookCredentialKey); + GenericWebhookHeadersJson = GetWebhookUrl(GenericWebhookHeadersCredentialKey); /* SMTP settings */ if (root.TryGetProperty("smtp_enabled", out v)) SmtpEnabled = v.GetBoolean(); @@ -631,6 +661,29 @@ public static void LoadAlertSettings() catch { /* Use defaults */ } } + /// <summary> + /// Reads settings.json (or starts fresh), applies <paramref name="mutate"/>, and writes it back + /// indented; logs and swallows any error under <paramref name="what"/>. Shared by the single-value + /// Save* methods (and MainWindow's Overview sort selector) so the read/merge/write/catch boilerplate + /// lives in one place. + /// </summary> + public static void WriteSetting(string what, Action<JsonNode> mutate) + { + var settingsPath = Path.Combine(ConfigDirectory, "settings.json"); + try + { + JsonNode root = File.Exists(settingsPath) + ? JsonNode.Parse(File.ReadAllText(settingsPath)) ?? new JsonObject() + : new JsonObject(); + mutate(root); + File.WriteAllText(settingsPath, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + } + catch (Exception ex) + { + AppLogger.Error("Settings", $"Failed to save {what}: {ex.Message}"); + } + } + private void OnUnhandledException(object sender, UnhandledExceptionEventArgs e) { var exception = e.ExceptionObject as Exception; diff --git a/Lite/Controls/AlertsHistoryTab.xaml.cs b/Lite/Controls/AlertsHistoryTab.xaml.cs index e0526695b..45bd80515 100644 --- a/Lite/Controls/AlertsHistoryTab.xaml.cs +++ b/Lite/Controls/AlertsHistoryTab.xaml.cs @@ -23,6 +23,7 @@ using PerformanceMonitorLite.Helpers; using PerformanceMonitorLite.Services; using PerformanceMonitor.Ui; +using static PerformanceMonitor.Ui.DataGridHelpers; using PerformanceMonitor.Common; namespace PerformanceMonitorLite.Controls; @@ -404,129 +405,14 @@ private async void DismissAll_Click(object sender, RoutedEventArgs e) #region Context Menu Handlers - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.CurrentCell.Column == null || grid.CurrentItem == null) return; - - var value = GetCellValue(grid.CurrentCell.Column, grid.CurrentItem); - if (value.Length > 0) Clipboard.SetDataObject(value, false); - } - - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.CurrentItem == null) return; - - var sb = new StringBuilder(); - foreach (var col in grid.Columns) - { - sb.Append(GetCellValue(col, grid.CurrentItem)); - sb.Append('\t'); - } - Clipboard.SetDataObject(sb.ToString().TrimEnd('\t'), false); - } - - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.Items == null) return; - - var sb = new StringBuilder(); - - foreach (var col in grid.Columns) - { - sb.Append(DataGridClipboardBehavior.GetHeaderText(col)); - sb.Append('\t'); - } - sb.AppendLine(); - - foreach (var item in grid.Items) - { - foreach (var col in grid.Columns) - { - sb.Append(GetCellValue(col, item)); - sb.Append('\t'); - } - sb.AppendLine(); - } - - Clipboard.SetDataObject(sb.ToString(), false); - } - - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.Items == null || grid.Items.Count == 0) return; + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - var dialog = new SaveFileDialog - { - Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*", - DefaultExt = ".csv", - FileName = $"alert_history_{DateTime.Now:yyyyMMdd_HHmmss}.csv" - }; - - if (dialog.ShowDialog() != true) return; - - var sb = new StringBuilder(); - - var headers = new List<string>(); - foreach (var col in grid.Columns) - headers.Add(CsvEscape(DataGridClipboardBehavior.GetHeaderText(col))); - sb.AppendLine(string.Join(",", headers)); - - foreach (var item in grid.Items) - { - var values = new List<string>(); - foreach (var col in grid.Columns) - values.Add(CsvEscape(GetCellValue(col, item))); - sb.AppendLine(string.Join(",", values)); - } - - try - { - File.WriteAllText(dialog.FileName, sb.ToString(), Encoding.UTF8); - } - catch (Exception ex) - { - MessageBox.Show($"Failed to export: {ex.Message}", "Export Error", - MessageBoxButton.OK, MessageBoxImage.Error); - } - } - - #endregion - - #region Helpers - - private static DataGrid? FindParentDataGrid(MenuItem menuItem) - { - var contextMenu = menuItem.Parent as ContextMenu; - var target = contextMenu?.PlacementTarget as FrameworkElement; - while (target != null && target is not DataGrid) - target = System.Windows.Media.VisualTreeHelper.GetParent(target) as FrameworkElement; - return target as DataGrid; - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private static string GetCellValue(DataGridColumn col, object item) - { - if (col is DataGridBoundColumn boundCol && boundCol.Binding is Binding binding) - { - var prop = item.GetType().GetProperty(binding.Path.Path); - return prop?.GetValue(item)?.ToString() ?? ""; - } - return ""; - } + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - private static string CsvEscape(string value) - { - if (value.Contains(',') || value.Contains('"') || value.Contains('\n') || value.Contains('\r')) - return "\"" + value.Replace("\"", "\"\"") + "\""; - return value; - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "alert_history", App.CsvSeparator); #endregion diff --git a/Lite/Controls/CorrelatedTimelineLanesControl.xaml.cs b/Lite/Controls/CorrelatedTimelineLanesControl.xaml.cs index 1a6c668a1..10b3c62e1 100644 --- a/Lite/Controls/CorrelatedTimelineLanesControl.xaml.cs +++ b/Lite/Controls/CorrelatedTimelineLanesControl.xaml.cs @@ -16,6 +16,7 @@ using System.Windows; using System.Windows.Controls; using PerformanceMonitor.Analysis; +using PerformanceMonitor.Analysis.Baselines; using PerformanceMonitorLite.Analysis; using PerformanceMonitorLite.Helpers; using PerformanceMonitorLite.Services; diff --git a/Lite/Controls/FinOpsTab.xaml b/Lite/Controls/FinOpsTab.xaml index 77d25c0b1..6c0b2540b 100644 --- a/Lite/Controls/FinOpsTab.xaml +++ b/Lite/Controls/FinOpsTab.xaml @@ -38,8 +38,8 @@ <Separator/> <MenuItem Header="Export to CSV..." Click="ExportToCsv_Click"><MenuItem.Icon><TextBlock Text="📊"/></MenuItem.Icon></MenuItem> <Separator/> - <MenuItem Header="View Plan" Click="FinOpsViewPlan_Click"><MenuItem.Icon><TextBlock Text="🔍"/></MenuItem.Icon></MenuItem> - <MenuItem Header="Get Actual Plan" Click="FinOpsGetActualPlan_Click"><MenuItem.Icon><TextBlock Text="▶"/></MenuItem.Icon></MenuItem> + <MenuItem Header="View Stored Plan" Click="FinOpsViewPlan_Click"><MenuItem.Icon><TextBlock Text="🔍"/></MenuItem.Icon></MenuItem> + <MenuItem Header="Get Actual Plan (re-run)" Click="FinOpsGetActualPlan_Click"><MenuItem.Icon><TextBlock Text="▶"/></MenuItem.Icon></MenuItem> </ContextMenu> <Style x:Key="HighImpactRowStyle" TargetType="DataGridRow"> <Setter Property="ContextMenu" Value="{StaticResource HighImpactPlanContextMenu}"/> @@ -54,7 +54,7 @@ <Separator/> <MenuItem Header="Export to CSV..." Click="ExportToCsv_Click"><MenuItem.Icon><TextBlock Text="📊"/></MenuItem.Icon></MenuItem> <Separator/> - <MenuItem Header="Get Actual Plan" Click="FinOpsGetActualPlan_Click"><MenuItem.Icon><TextBlock Text="▶"/></MenuItem.Icon></MenuItem> + <MenuItem Header="Get Actual Plan (re-run)" Click="FinOpsGetActualPlan_Click"><MenuItem.Icon><TextBlock Text="▶"/></MenuItem.Icon></MenuItem> </ContextMenu> <Style x:Key="ExpensiveQueryRowStyle" TargetType="DataGridRow"> <Setter Property="ContextMenu" Value="{StaticResource ExpensiveQueryPlanContextMenu}"/> @@ -1868,7 +1868,7 @@ </Expander> <!-- Tempdb Pressure --> - <Expander Header="Tempdb Pressure" IsExpanded="True" Margin="0,0,0,8"> + <Expander Header="tempdb Pressure" IsExpanded="True" Margin="0,0,0,8"> <StackPanel> <DataGrid x:Name="TempdbPressureDataGrid" AutoGenerateColumns="False" @@ -2363,6 +2363,7 @@ CanUserResizeColumns="True" HeadersVisibility="Column" SelectionMode="Extended" + HorizontalScrollBarVisibility="Auto" RowStyle="{StaticResource DefaultRowStyle}"> <DataGrid.Columns> <DataGridTextColumn Binding="{Binding ApplicationName}" Width="300"> @@ -2399,6 +2400,188 @@ </Style> </DataGridTextColumn.ElementStyle> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgRunning, StringFormat='{}{0:N0}'}" Width="110"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgRunning" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Avg Running" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle> + <Style TargetType="TextBlock"> + <Setter Property="HorizontalAlignment" Value="Right"/> + </Style> + </DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxRunning, StringFormat='{}{0:N0}'}" Width="110"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxRunning" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Max Running" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle> + <Style TargetType="TextBlock"> + <Setter Property="HorizontalAlignment" Value="Right"/> + </Style> + </DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgSleeping, StringFormat='{}{0:N0}'}" Width="110"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgSleeping" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Avg Sleeping" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle> + <Style TargetType="TextBlock"> + <Setter Property="HorizontalAlignment" Value="Right"/> + </Style> + </DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxSleeping, StringFormat='{}{0:N0}'}" Width="110"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxSleeping" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Max Sleeping" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle> + <Style TargetType="TextBlock"> + <Setter Property="HorizontalAlignment" Value="Right"/> + </Style> + </DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgDormant, StringFormat='{}{0:N0}'}" Width="110"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgDormant" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Avg Dormant" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle> + <Style TargetType="TextBlock"> + <Setter Property="HorizontalAlignment" Value="Right"/> + </Style> + </DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxDormant, StringFormat='{}{0:N0}'}" Width="110"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxDormant" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Max Dormant" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle> + <Style TargetType="TextBlock"> + <Setter Property="HorizontalAlignment" Value="Right"/> + </Style> + </DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgCpuTimeMs, StringFormat='{}{0:N0}'}" Width="130"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgCpuTimeMs" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Avg CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle> + <Style TargetType="TextBlock"> + <Setter Property="HorizontalAlignment" Value="Right"/> + </Style> + </DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxCpuTimeMs, StringFormat='{}{0:N0}'}" Width="130"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxCpuTimeMs" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Max CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle> + <Style TargetType="TextBlock"> + <Setter Property="HorizontalAlignment" Value="Right"/> + </Style> + </DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgReads, StringFormat='{}{0:N0}'}" Width="120"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgReads" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Avg Reads" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle> + <Style TargetType="TextBlock"> + <Setter Property="HorizontalAlignment" Value="Right"/> + </Style> + </DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxReads, StringFormat='{}{0:N0}'}" Width="120"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxReads" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Max Reads" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle> + <Style TargetType="TextBlock"> + <Setter Property="HorizontalAlignment" Value="Right"/> + </Style> + </DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgWrites, StringFormat='{}{0:N0}'}" Width="120"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgWrites" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Avg Writes" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle> + <Style TargetType="TextBlock"> + <Setter Property="HorizontalAlignment" Value="Right"/> + </Style> + </DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxWrites, StringFormat='{}{0:N0}'}" Width="120"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxWrites" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Max Writes" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle> + <Style TargetType="TextBlock"> + <Setter Property="HorizontalAlignment" Value="Right"/> + </Style> + </DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgLogicalReads, StringFormat='{}{0:N0}'}" Width="140"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgLogicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Avg Logical Reads" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle> + <Style TargetType="TextBlock"> + <Setter Property="HorizontalAlignment" Value="Right"/> + </Style> + </DataGridTextColumn.ElementStyle> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxLogicalReads, StringFormat='{}{0:N0}'}" Width="140"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxLogicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Max Logical Reads" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + <DataGridTextColumn.ElementStyle> + <Style TargetType="TextBlock"> + <Setter Property="HorizontalAlignment" Value="Right"/> + </Style> + </DataGridTextColumn.ElementStyle> + </DataGridTextColumn> <DataGridTextColumn Binding="{Binding SampleCount, StringFormat='{}{0:N0}'}" Width="100"> <DataGridTextColumn.Header> <StackPanel Orientation="Horizontal"> @@ -2557,6 +2740,18 @@ </Style> </DataGridTextColumn.ElementStyle> </DataGridTextColumn> + <!-- Non-alarming note when the sys.dm_os_sys_info hardware read was denied (e.g. Azure SQL DB + login without VIEW DATABASE STATE). Empty for servers whose hardware inventory is available. (#1535) --> + <DataGridTextColumn Header="Hardware Note" Binding="{Binding HardwareUnavailableReason}" Width="220"> + <DataGridTextColumn.ElementStyle> + <Style TargetType="TextBlock"> + <Setter Property="Foreground" Value="#F39C12"/> + <Setter Property="FontWeight" Value="SemiBold"/> + <Setter Property="TextTrimming" Value="CharacterEllipsis"/> + <Setter Property="ToolTip" Value="{Binding HardwareUnavailableReason}"/> + </Style> + </DataGridTextColumn.ElementStyle> + </DataGridTextColumn> <DataGridTextColumn Binding="{Binding ProvisioningDisplay}" Width="130"> <DataGridTextColumn.Header> <StackPanel Orientation="Horizontal"> diff --git a/Lite/Controls/FinOpsTab.xaml.cs b/Lite/Controls/FinOpsTab.xaml.cs index 322e59306..8742759c2 100644 --- a/Lite/Controls/FinOpsTab.xaml.cs +++ b/Lite/Controls/FinOpsTab.xaml.cs @@ -21,6 +21,8 @@ using PerformanceMonitorLite.Helpers; using PerformanceMonitorLite.Services; using PerformanceMonitor.Ui; +using static PerformanceMonitor.Ui.DataGridHelpers; +using PerformanceMonitor.PlanAnalysis; namespace PerformanceMonitorLite.Controls; @@ -33,9 +35,6 @@ public partial class FinOpsTab : UserControl private DateTime _serverInventoryCacheTime; private readonly Dictionary<DataGrid, IDataGridFilterManager> _filterManagers = new(); - private Popup? _filterPopup; - private ColumnFilterPopup? _filterPopupContent; - private DataGrid? _currentFilterGrid; private DataGridFilterManager<DatabaseResourceUsageRow>? _dbResourcesFilterMgr; private DataGridFilterManager<StorageGrowthRow>? _storageGrowthFilterMgr; @@ -123,7 +122,8 @@ private int GetSelectedServerId() Window.GetWindow(this)!, (xml, label, qt) => Windows.PlanViewerWindow.ShowPlanAsync(Window.GetWindow(this)!, xml, label, qt), (db, qt, est, iso, ct) => ActualPlanExecutor.ExecuteForActualPlanAsync( - GetSelectedConnectionString() ?? "", db, qt, est, iso, isAzureSqlDb: false, timeoutSeconds: 0, ct), + GetSelectedConnectionString() ?? "", db, qt, est, iso, isAzureSqlDb: false, timeoutSeconds: 0, ct, + productName: "SQL Server Performance Monitor Lite"), "the monitored server"); private string? GetSelectedConnectionString() @@ -417,18 +417,7 @@ private static void SetBar(Border bar, ColumnDefinition filled, ColumnDefinition empty.Width = new GridLength(Math.Max(100 - clamped, 0.1), GridUnitType.Star); } - private int GetResourceUsageHoursBack() - { - return ResourceUsageTimeRangeCombo.SelectedIndex switch - { - 0 => 1, - 1 => 4, - 2 => 12, - 3 => 24, - 4 => 168, - _ => 24 - }; - } + private int HoursBackFromIndex(System.Windows.Controls.ComboBox combo) => combo.SelectedIndex switch { 0 => 1, 1 => 4, 2 => 12, 3 => 24, 4 => 168, _ => 24 }; private async void ResourceUsageTimeRange_Changed(object sender, System.Windows.Controls.SelectionChangedEventArgs e) { @@ -444,7 +433,7 @@ private async System.Threading.Tasks.Task LoadDatabaseResourcesAsync(int serverI try { - var hoursBack = GetResourceUsageHoursBack(); + var hoursBack = HoursBackFromIndex(ResourceUsageTimeRangeCombo); var data = await Task.Run(() => _dataService.GetDatabaseResourceUsageAsync(serverId, hoursBack)); _dbResourcesFilterMgr!.UpdateData(data); NoDatabaseResourcesMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; @@ -647,26 +636,13 @@ private async System.Threading.Tasks.Task LoadTempdbSummaryAsync(int serverId) } } - private int GetHighImpactHoursBack() - { - return HighImpactTimeRangeCombo.SelectedIndex switch - { - 0 => 1, - 1 => 4, - 2 => 12, - 3 => 24, - 4 => 168, - _ => 24 - }; - } - private async System.Threading.Tasks.Task LoadHighImpactQueriesAsync(int serverId) { if (_dataService == null) return; try { - var hoursBack = GetHighImpactHoursBack(); + var hoursBack = HoursBackFromIndex(HighImpactTimeRangeCombo); var data = await Task.Run(() => _dataService.GetHighImpactQueriesAsync(serverId, hoursBack)); _highImpactFilterMgr!.UpdateData(data); HighImpactNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; @@ -678,39 +654,13 @@ private async System.Threading.Tasks.Task LoadHighImpactQueriesAsync(int serverI } } - private int GetWaitStatsHoursBack() - { - return WaitStatsTimeRangeCombo.SelectedIndex switch - { - 0 => 1, - 1 => 4, - 2 => 12, - 3 => 24, - 4 => 168, - _ => 24 - }; - } - - private int GetExpensiveQueriesHoursBack() - { - return ExpensiveQueriesTimeRangeCombo.SelectedIndex switch - { - 0 => 1, - 1 => 4, - 2 => 12, - 3 => 24, - 4 => 168, - _ => 24 - }; - } - private async System.Threading.Tasks.Task LoadWaitCategorySummaryAsync(int serverId) { if (_dataService == null) return; try { - var hoursBack = GetWaitStatsHoursBack(); + var hoursBack = HoursBackFromIndex(WaitStatsTimeRangeCombo); var data = await Task.Run(() => _dataService.GetWaitCategorySummaryAsync(serverId, hoursBack)); // Compute proportional cost shares — scaled to time window @@ -740,7 +690,7 @@ private async System.Threading.Tasks.Task LoadExpensiveQueriesAsync(int serverId try { - var hoursBack = GetExpensiveQueriesHoursBack(); + var hoursBack = HoursBackFromIndex(ExpensiveQueriesTimeRangeCombo); var data = await Task.Run(() => _dataService.GetExpensiveQueriesAsync(serverId, hoursBack)); // Compute proportional cost shares — scaled to time window @@ -948,66 +898,18 @@ private async void RunIndexAnalysis_Click(object sender, RoutedEventArgs e) #region Context Menu Handlers - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.CurrentCell.Column == null || grid.CurrentItem == null) return; + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - var value = GetCellValue(grid.CurrentCell.Column, grid.CurrentItem); - if (value.Length > 0) Clipboard.SetDataObject(value, false); - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.CurrentItem == null) return; - - var sb = new StringBuilder(); - foreach (var col in grid.Columns) - { - sb.Append(GetCellValue(col, grid.CurrentItem)); - sb.Append('\t'); - } - Clipboard.SetDataObject(sb.ToString().TrimEnd('\t'), false); - } - - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.Items == null) return; - - var sb = new StringBuilder(); - - foreach (var col in grid.Columns) - { - sb.Append(DataGridClipboardBehavior.GetHeaderText(col)); - sb.Append('\t'); - } - sb.AppendLine(); - - foreach (var item in grid.Items) - { - foreach (var col in grid.Columns) - { - sb.Append(GetCellValue(col, item)); - sb.Append('\t'); - } - sb.AppendLine(); - } - - Clipboard.SetDataObject(sb.ToString(), false); - } + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); private void ExportToCsv_Click(object sender, RoutedEventArgs e) { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.Items == null || grid.Items.Count == 0) return; - - var prefix = grid.Name switch + // FinOps shares one handler across several grids, so the file-name prefix is chosen + // from the originating grid's name (resolved the same way the shared exporter does). + var grid = sender is MenuItem menuItem ? FindParentDataGrid(menuItem) : null; + var prefix = grid?.Name switch { nameof(DatabaseSizesDataGrid) => "database_sizes", nameof(ServerInventoryDataGrid) => "server_inventory", @@ -1015,40 +917,7 @@ private void ExportToCsv_Click(object sender, RoutedEventArgs e) nameof(ApplicationConnectionsDataGrid) => "application_connections", _ => "finops_export" }; - - var dialog = new SaveFileDialog - { - Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*", - DefaultExt = ".csv", - FileName = $"{prefix}_{DateTime.Now:yyyyMMdd_HHmmss}.csv" - }; - - if (dialog.ShowDialog() != true) return; - - var sb = new StringBuilder(); - - var headers = new List<string>(); - foreach (var col in grid.Columns) - headers.Add(CsvEscape(DataGridClipboardBehavior.GetHeaderText(col))); - sb.AppendLine(string.Join(",", headers)); - - foreach (var item in grid.Items) - { - var values = new List<string>(); - foreach (var col in grid.Columns) - values.Add(CsvEscape(GetCellValue(col, item))); - sb.AppendLine(string.Join(",", values)); - } - - try - { - File.WriteAllText(dialog.FileName, sb.ToString(), Encoding.UTF8); - } - catch (Exception ex) - { - MessageBox.Show($"Failed to export: {ex.Message}", "Export Error", - MessageBoxButton.OK, MessageBoxImage.Error); - } + DataGridExport.ExportToCsv(sender, prefix, App.CsvSeparator); } #endregion @@ -1088,102 +957,12 @@ private void InitializeFilterManagers() _filterManagers[IndexLockingDataGrid] = _indexLockingFilterMgr; } - private void EnsureFilterPopup() - { - if (_filterPopup == null) - { - _filterPopupContent = new ColumnFilterPopup(); - _filterPopup = new Popup - { - Child = _filterPopupContent, - StaysOpen = false, - Placement = PlacementMode.Bottom, - AllowsTransparency = true - }; - } - } + /* Host/apply plumbing lives in the shared Ui controller. Lazy (a field initializer can't reference the + instance field _filterManagers); the XAML-wired FilterButton_Click forwards to it. */ + private ColumnFilterPopupController? _filterPopupControllerField; + private ColumnFilterPopupController FilterPopupController => _filterPopupControllerField ??= new ColumnFilterPopupController(_filterManagers); - private void FilterButton_Click(object sender, RoutedEventArgs e) - { - if (sender is not Button button || button.Tag is not string columnName) return; - - var dataGrid = FindParentDataGridFromElement(button); - if (dataGrid == null || !_filterManagers.TryGetValue(dataGrid, out var manager)) return; - - _currentFilterGrid = dataGrid; - - EnsureFilterPopup(); - - _filterPopupContent!.FilterApplied -= FilterPopup_FilterApplied; - _filterPopupContent.FilterCleared -= FilterPopup_FilterCleared; - _filterPopupContent.FilterApplied += FilterPopup_FilterApplied; - _filterPopupContent.FilterCleared += FilterPopup_FilterCleared; - - manager.Filters.TryGetValue(columnName, out var existingFilter); - _filterPopupContent.Initialize(columnName, existingFilter); - - _filterPopup!.PlacementTarget = button; - _filterPopup.IsOpen = true; - } - - private void FilterPopup_FilterApplied(object? sender, FilterAppliedEventArgs e) - { - if (_filterPopup != null) - _filterPopup.IsOpen = false; - - if (_currentFilterGrid != null && _filterManagers.TryGetValue(_currentFilterGrid, out var manager)) - { - manager.SetFilter(e.FilterState); - } - } - - private void FilterPopup_FilterCleared(object? sender, EventArgs e) - { - if (_filterPopup != null) - _filterPopup.IsOpen = false; - } - - private static DataGrid? FindParentDataGridFromElement(DependencyObject element) - { - var current = element; - while (current != null) - { - if (current is DataGrid dg) - return dg; - current = VisualTreeHelper.GetParent(current); - } - return null; - } - - #endregion - - #region Helpers - - private static DataGrid? FindParentDataGrid(MenuItem menuItem) - { - var contextMenu = menuItem.Parent as ContextMenu; - var target = contextMenu?.PlacementTarget as FrameworkElement; - while (target != null && target is not DataGrid) - target = VisualTreeHelper.GetParent(target) as FrameworkElement; - return target as DataGrid; - } - - private static string GetCellValue(DataGridColumn col, object item) - { - if (col is DataGridBoundColumn boundCol && boundCol.Binding is Binding binding) - { - var prop = item.GetType().GetProperty(binding.Path.Path); - return prop?.GetValue(item)?.ToString() ?? ""; - } - return ""; - } - - private static string CsvEscape(string value) - { - if (value.Contains(',') || value.Contains('"') || value.Contains('\n') || value.Contains('\r')) - return "\"" + value.Replace("\"", "\"\"") + "\""; - return value; - } + private void FilterButton_Click(object sender, RoutedEventArgs e) => FilterPopupController.HandleFilterButtonClick(sender); #endregion } diff --git a/Lite/Controls/JobHistoryTab.xaml b/Lite/Controls/JobHistoryTab.xaml new file mode 100644 index 000000000..9d6b5945a --- /dev/null +++ b/Lite/Controls/JobHistoryTab.xaml @@ -0,0 +1,211 @@ +<UserControl x:Class="PerformanceMonitorLite.Controls.JobHistoryTab" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + mc:Ignorable="d" + d:DesignHeight="450" d:DesignWidth="900"> + <UserControl.Resources> + <ResourceDictionary> + <ContextMenu x:Key="DataGridContextMenu"> + <MenuItem Header="Copy Cell" Click="CopyCell_Click"> + <MenuItem.Icon><TextBlock Text="📋"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy Row" Click="CopyRow_Click"> + <MenuItem.Icon><TextBlock Text="📄"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy All Rows" Click="CopyAllRows_Click"> + <MenuItem.Icon><TextBlock Text="📑"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <MenuItem Header="Export to CSV..." Click="ExportToCsv_Click"> + <MenuItem.Icon><TextBlock Text="📊"/></MenuItem.Icon> + </MenuItem> + </ContextMenu> + + <Style x:Key="DefaultRowStyle" TargetType="DataGridRow"> + <Setter Property="ContextMenu" Value="{StaticResource DataGridContextMenu}"/> + </Style> + + <!-- Color-coding: canceled (gray), retry (yellow), long-running (amber), failed (red) — later + triggers win, so failed red takes precedence over long-running amber on a failed slow run. + Disabled jobs are dimmed/italic. --> + <Style x:Key="JobHistoryRowStyle" TargetType="DataGridRow" BasedOn="{StaticResource DefaultRowStyle}"> + <Style.Triggers> + <DataTrigger Binding="{Binding JobEnabled}" Value="False"> + <Setter Property="Opacity" Value="0.6"/> + <Setter Property="FontStyle" Value="Italic"/> + </DataTrigger> + <DataTrigger Binding="{Binding IsCanceled}" Value="True"> + <Setter Property="Background" Value="#33808080"/> + </DataTrigger> + <DataTrigger Binding="{Binding IsRetry}" Value="True"> + <Setter Property="Background" Value="#33EAB308"/> + </DataTrigger> + <DataTrigger Binding="{Binding IsLongRunning}" Value="True"> + <Setter Property="Background" Value="#33D97706"/> + </DataTrigger> + <DataTrigger Binding="{Binding IsFailed}" Value="True"> + <Setter Property="Background" Value="#33DC2626"/> + </DataTrigger> + </Style.Triggers> + </Style> + </ResourceDictionary> + </UserControl.Resources> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + + <!-- Header Controls --> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="10,8" Height="36"> + <TextBlock Text="Job History" VerticalAlignment="Center" Margin="0,0,10,0" FontWeight="Bold" FontSize="14" + Foreground="{DynamicResource ForegroundBrush}"/> + <TextBlock Text="Time Range:" VerticalAlignment="Center" Margin="5,0,4,0" + Foreground="{DynamicResource ForegroundMutedBrush}"/> + <ComboBox x:Name="TimeRangeComboBox" Width="100" VerticalAlignment="Center" + SelectionChanged="Filter_SelectionChanged"> + <ComboBoxItem Content="Last 24 Hours" Tag="24" IsSelected="True"/> + <ComboBoxItem Content="Last 7 Days" Tag="168"/> + <ComboBoxItem Content="Last 30 Days" Tag="720"/> + <ComboBoxItem Content="Last 90 Days" Tag="2160"/> + <ComboBoxItem Content="Last Year" Tag="8760"/> + </ComboBox> + <TextBlock Text="Server:" VerticalAlignment="Center" Margin="12,0,4,0" + Foreground="{DynamicResource ForegroundMutedBrush}"/> + <ComboBox x:Name="ServerFilterComboBox" Width="150" VerticalAlignment="Center" + SelectionChanged="Filter_SelectionChanged"> + <ComboBoxItem Content="All Servers" Tag="" IsSelected="True"/> + </ComboBox> + <TextBlock Text="Status:" VerticalAlignment="Center" Margin="12,0,4,0" + Foreground="{DynamicResource ForegroundMutedBrush}"/> + <ComboBox x:Name="StatusFilterComboBox" Width="110" VerticalAlignment="Center" + SelectionChanged="Filter_SelectionChanged"> + <ComboBoxItem Content="All Statuses" Tag="" IsSelected="True"/> + <ComboBoxItem Content="Failed" Tag="0"/> + <ComboBoxItem Content="Succeeded" Tag="1"/> + <ComboBoxItem Content="Retry" Tag="2"/> + <ComboBoxItem Content="Canceled" Tag="3"/> + </ComboBox> + <TextBlock Text="Category:" VerticalAlignment="Center" Margin="12,0,4,0" + Foreground="{DynamicResource ForegroundMutedBrush}"/> + <ComboBox x:Name="CategoryFilterComboBox" Width="150" VerticalAlignment="Center" + SelectionChanged="Filter_SelectionChanged"> + <ComboBoxItem Content="All Categories" Tag="" IsSelected="True"/> + </ComboBox> + <Button Content="Refresh" Click="RefreshButton_Click" Margin="12,0,0,0" Padding="10,4" MinWidth="70"/> + <TextBlock x:Name="JobCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" + FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + <TextBlock x:Name="AgentStatusIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" + FontSize="11" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundMutedBrush}"/> + <TextBlock x:Name="LastRefreshedIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" + FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}"/> + <TextBlock x:Name="ArchivalWarning" Text="" Margin="8,0,0,0" VerticalAlignment="Center" + FontSize="11" Foreground="#FFCC6600" FontWeight="SemiBold" Visibility="Collapsed"/> + </StackPanel> + + <!-- DataGrid --> + <Grid Grid.Row="1" Margin="10,0,10,10"> + <DataGrid x:Name="JobHistoryDataGrid" + AutoGenerateColumns="False" + IsReadOnly="True" + CanUserSortColumns="True" + GridLinesVisibility="Horizontal" + CanUserResizeColumns="True" + HeadersVisibility="Column" + SelectionMode="Extended" + RowStyle="{StaticResource JobHistoryRowStyle}"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding RunTimeLocal}" Width="150"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="RunTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Run Time" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ServerName}" Width="130"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ServerName" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Server" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding JobName}" Width="180"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="JobName" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Job" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CategoryName}" Width="120"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CategoryName" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Category" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding StepDisplay}" Width="150"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="StepDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Step" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding RunStatusDesc}" Width="90"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="RunStatusDesc" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Status" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DurationFormatted}" Width="90"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DurationFormatted" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Duration" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding RetriesDisplay}" Width="65"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="RetriesDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Retries" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LastSuccessfulRunLocal}" Width="150"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LastSuccessfulRunLocal" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Last Success" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Message}" Width="*" MinWidth="200"> + <DataGridTextColumn.Header> + <StackPanel Orientation="Horizontal"> + <Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Message" Click="FilterButton_Click" Margin="0,0,4,0"/> + <TextBlock Text="Message" FontWeight="Bold" VerticalAlignment="Center"/> + </StackPanel> + </DataGridTextColumn.Header> + </DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + + <!-- Empty State --> + <TextBlock x:Name="NoJobsMessage" + Text="No job runs recorded in the selected time range" + FontSize="14" Foreground="{DynamicResource ForegroundMutedBrush}" + HorizontalAlignment="Center" VerticalAlignment="Center" + Visibility="Collapsed"/> + </Grid> + </Grid> +</UserControl> diff --git a/Lite/Controls/JobHistoryTab.xaml.cs b/Lite/Controls/JobHistoryTab.xaml.cs new file mode 100644 index 000000000..c6e4c5482 --- /dev/null +++ b/Lite/Controls/JobHistoryTab.xaml.cs @@ -0,0 +1,399 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Threading; +using PerformanceMonitorLite.Helpers; +using PerformanceMonitorLite.Services; +using PerformanceMonitor.Ui; +using PerformanceMonitor.Common; + +namespace PerformanceMonitorLite.Controls; + +/// <summary> +/// Fleet-wide retained SQL Agent job-run history (issue #1433) — a structural sibling of +/// <see cref="AlertsHistoryTab"/> reading <c>v_job_history</c> via +/// <see cref="LocalDataService.GetJobHistoryAsync"/>. Time-range + Server + Status + Category filters, +/// per-column filter popups, failure / long-runtime / retry row color-coding, and CSV export. Job history +/// is a durable record, so there is no dismiss/mute surface (unlike alerts). +/// </summary> +public partial class JobHistoryTab : UserControl +{ + private LocalDataService? _dataService; + private DataGridFilterManager<JobHistoryRow>? _filterManager; + private Popup? _filterPopup; + private ColumnFilterPopup? _filterPopupContent; + private DateTime? _lastRefreshed; + private readonly DispatcherTimer _staleDataTimer; + + public JobHistoryTab() + { + InitializeComponent(); + _staleDataTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(10) }; + _staleDataTimer.Tick += StaleDataTimer_Tick; + } + + /// <summary>Initializes the control with required dependencies.</summary> + public void Initialize(LocalDataService dataService) + { + _dataService = dataService; + _filterManager = new DataGridFilterManager<JobHistoryRow>(JobHistoryDataGrid); + _staleDataTimer.Start(); + } + + /// <summary>Refreshes the job history data.</summary> + public async void RefreshJobs() + { + await LoadJobsAsync(); + } + + private async System.Threading.Tasks.Task LoadJobsAsync() + { + if (_dataService == null) return; + + try + { + var hoursBack = GetSelectedHoursBack(); + int? serverId = GetSelectedServerId(); + + var all = await System.Threading.Tasks.Task.Run(() => _dataService.GetJobHistoryAsync(hoursBack, 2000, serverId)); + + /* Populate the Server / Category combos from the full (pre status/category) result, then apply + Status + Category client-side — those must NOT go into the reader's window (they'd skew the + per-job long-running / last-success baselines, which are computed over every run in the + window). The per-column filter popups still layer on top. */ + PopulateServerFilter(all); + PopulateCategoryFilter(all); + + var statusFilter = GetSelectedStatus(); + var categoryFilter = GetSelectedCategory(); + + var filtered = all.Where(r => + (statusFilter is null || r.RunStatus == statusFilter.Value) && + (categoryFilter is null || string.Equals(r.CategoryName, categoryFilter, StringComparison.OrdinalIgnoreCase))) + .ToList(); + + if (_filterManager != null) + _filterManager.UpdateData(filtered); + else + JobHistoryDataGrid.ItemsSource = filtered; + + var displayCount = JobHistoryDataGrid.ItemsSource is ICollection<JobHistoryRow> coll ? coll.Count : filtered.Count; + NoJobsMessage.Visibility = displayCount == 0 ? Visibility.Visible : Visibility.Collapsed; + JobCountIndicator.Text = displayCount > 0 ? $"{displayCount} run(s)" : ""; + AppLogger.Debug("JobHistory", $"Loaded {displayCount} job run(s) (query returned {all.Count}, hoursBack={hoursBack}, serverId={serverId?.ToString() ?? "all"})"); + + _lastRefreshed = DateTime.UtcNow; + UpdateStaleDataIndicator(); + + await UpdateAgentStatusAsync(serverId); + } + catch (Exception ex) + { + AppLogger.Error("JobHistory", $"Failed to load job history: {ex.Message}"); + } + } + + /// <summary> + /// Populates the header Agent indicator (issue #1433 Phase 2). With a server selected it shows that + /// server's Agent status + next scheduled run; across all servers it shows a running/stopped roll-up. + /// A stopped Agent is drawn in red. Best-effort — an agent_status read failure just clears the indicator. + /// </summary> + private async System.Threading.Tasks.Task UpdateAgentStatusAsync(int? serverId) + { + if (_dataService == null) return; + + try + { + var statuses = await System.Threading.Tasks.Task.Run(() => _dataService.GetAgentStatusAsync(serverId)); + + var okBrush = TryFindResource("ForegroundMutedBrush") as System.Windows.Media.Brush + ?? System.Windows.Media.Brushes.Gray; + var alertBrush = new System.Windows.Media.SolidColorBrush( + System.Windows.Media.Color.FromRgb(0xDC, 0x26, 0x26)); + + if (statuses.Count == 0) + { + AgentStatusIndicator.Text = ""; + return; + } + + if (serverId.HasValue) + { + var s = statuses[0]; + AgentStatusIndicator.Text = $"Agent: {s.StatusDisplay} · Next run: {s.NextScheduledRunLocal}"; + AgentStatusIndicator.Foreground = s.AgentRunning ? okBrush : alertBrush; + } + else + { + var running = statuses.Count(x => x.AgentRunning); + var stopped = statuses.Count - running; + AgentStatusIndicator.Text = stopped > 0 + ? $"Agents: {running}/{statuses.Count} running, {stopped} stopped" + : $"Agents: {running}/{statuses.Count} running"; + AgentStatusIndicator.Foreground = stopped > 0 ? alertBrush : okBrush; + } + } + catch (Exception ex) + { + AppLogger.Debug("JobHistory", $"Failed to load agent status: {ex.Message}"); + AgentStatusIndicator.Text = ""; + } + } + + private void PopulateServerFilter(List<JobHistoryRow> rows) + { + var servers = rows + .Select(r => (r.ServerId, r.ServerName)) + .Where(s => !string.IsNullOrEmpty(s.ServerName)) + .Distinct() + .OrderBy(s => s.ServerName) + .ToList(); + + var currentSelection = ServerFilterComboBox.SelectedIndex > 0 + ? (ServerFilterComboBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() + : null; + + var existingIds = ServerFilterComboBox.Items + .OfType<ComboBoxItem>() + .Skip(1) + .Select(i => i.Tag?.ToString()) + .ToList(); + + var newIds = servers.Select(s => s.ServerId.ToString()).ToList(); + if (newIds.SequenceEqual(existingIds)) return; + + ServerFilterComboBox.SelectionChanged -= Filter_SelectionChanged; + + while (ServerFilterComboBox.Items.Count > 1) + ServerFilterComboBox.Items.RemoveAt(1); + + foreach (var (serverId, serverName) in servers) + { + ServerFilterComboBox.Items.Add(new ComboBoxItem + { + Content = serverName, + Tag = serverId.ToString() + }); + } + + if (currentSelection != null) + { + for (int i = 1; i < ServerFilterComboBox.Items.Count; i++) + { + if ((ServerFilterComboBox.Items[i] as ComboBoxItem)?.Tag?.ToString() == currentSelection) + { + ServerFilterComboBox.SelectedIndex = i; + break; + } + } + } + + ServerFilterComboBox.SelectionChanged += Filter_SelectionChanged; + } + + private void PopulateCategoryFilter(List<JobHistoryRow> rows) + { + var categories = rows + .Select(r => r.CategoryName) + .Where(c => !string.IsNullOrEmpty(c)) + .Select(c => c!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(c => c, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var currentSelection = CategoryFilterComboBox.SelectedIndex > 0 + ? (CategoryFilterComboBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() + : null; + + var existing = CategoryFilterComboBox.Items + .OfType<ComboBoxItem>() + .Skip(1) + .Select(i => i.Tag?.ToString()) + .ToList(); + + if (categories.SequenceEqual(existing, StringComparer.OrdinalIgnoreCase)) return; + + CategoryFilterComboBox.SelectionChanged -= Filter_SelectionChanged; + + while (CategoryFilterComboBox.Items.Count > 1) + CategoryFilterComboBox.Items.RemoveAt(1); + + foreach (var category in categories) + { + CategoryFilterComboBox.Items.Add(new ComboBoxItem { Content = category, Tag = category }); + } + + if (currentSelection != null) + { + for (int i = 1; i < CategoryFilterComboBox.Items.Count; i++) + { + if ((CategoryFilterComboBox.Items[i] as ComboBoxItem)?.Tag?.ToString() == currentSelection) + { + CategoryFilterComboBox.SelectedIndex = i; + break; + } + } + } + + CategoryFilterComboBox.SelectionChanged += Filter_SelectionChanged; + } + + private int GetSelectedHoursBack() + { + if (TimeRangeComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tagStr) + return int.TryParse(tagStr, out var hours) ? hours : 24; + return 24; + } + + private int? GetSelectedServerId() + { + if (ServerFilterComboBox.SelectedIndex > 0 && + ServerFilterComboBox.SelectedItem is ComboBoxItem item && + item.Tag is string tagStr && + int.TryParse(tagStr, out var serverId)) + { + return serverId; + } + return null; + } + + private int? GetSelectedStatus() + { + if (StatusFilterComboBox.SelectedIndex > 0 && + StatusFilterComboBox.SelectedItem is ComboBoxItem item && + item.Tag is string tagStr && + int.TryParse(tagStr, out var status)) + { + return status; + } + return null; + } + + private string? GetSelectedCategory() + { + if (CategoryFilterComboBox.SelectedIndex > 0 && + CategoryFilterComboBox.SelectedItem is ComboBoxItem item && + item.Tag is string tagStr && + !string.IsNullOrEmpty(tagStr)) + { + return tagStr; + } + return null; + } + + #region Column Filter Handlers + + private void FilterButton_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button button || button.Tag is not string columnName) return; + + if (_filterPopup == null) + { + _filterPopupContent = new ColumnFilterPopup(); + _filterPopupContent.FilterApplied += FilterPopup_FilterApplied; + _filterPopupContent.FilterCleared += FilterPopup_FilterCleared; + + _filterPopup = new Popup + { + Child = _filterPopupContent, + StaysOpen = false, + Placement = PlacementMode.Bottom, + AllowsTransparency = true + }; + } + + ColumnFilterState? existingFilter = null; + _filterManager?.Filters.TryGetValue(columnName, out existingFilter); + _filterPopupContent!.Initialize(columnName, existingFilter); + + _filterPopup.PlacementTarget = button; + _filterPopup.IsOpen = true; + } + + private void FilterPopup_FilterApplied(object? sender, FilterAppliedEventArgs e) + { + if (_filterPopup != null) + _filterPopup.IsOpen = false; + + _filterManager?.SetFilter(e.FilterState); + } + + private void FilterPopup_FilterCleared(object? sender, EventArgs e) + { + if (_filterPopup != null) + _filterPopup.IsOpen = false; + } + + #endregion + + #region Stale Data Indicator + + private void StaleDataTimer_Tick(object? sender, EventArgs e) + { + UpdateStaleDataIndicator(); + } + + private void UpdateStaleDataIndicator() + { + if (_lastRefreshed.HasValue) + { + var elapsed = DateTime.UtcNow - _lastRefreshed.Value; + LastRefreshedIndicator.Text = elapsed.TotalSeconds < 5 + ? "Refreshed just now" + : elapsed.TotalMinutes < 1 + ? $"Refreshed {(int)elapsed.TotalSeconds}s ago" + : $"Refreshed {(int)elapsed.TotalMinutes}m ago"; + } + + if (ArchiveService.IsArchiving) + { + ArchivalWarning.Text = "⚠ Archival in progress"; + ArchivalWarning.Visibility = Visibility.Visible; + } + else + { + ArchivalWarning.Visibility = Visibility.Collapsed; + } + } + + #endregion + + #region Event Handlers + + private async void Filter_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (IsLoaded) + await LoadJobsAsync(); + } + + private async void RefreshButton_Click(object sender, RoutedEventArgs e) + { + await LoadJobsAsync(); + } + + #endregion + + #region Context Menu Handlers + + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); + + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); + + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); + + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "job_history", App.CsvSeparator); + + #endregion +} diff --git a/Lite/Controls/LoadingOverlay.xaml b/Lite/Controls/LoadingOverlay.xaml index 73960ea96..d278b6574 100644 --- a/Lite/Controls/LoadingOverlay.xaml +++ b/Lite/Controls/LoadingOverlay.xaml @@ -8,8 +8,7 @@ d:DesignHeight="200" d:DesignWidth="400"> <Grid Background="{DynamicResource BackgroundBrush}"> <StackPanel HorizontalAlignment="Center" VerticalAlignment="Center"> - <TextBlock x:Name="SpinnerText" - Text="⟳" + <TextBlock Text="⟳" FontSize="24" Foreground="{DynamicResource AccentBrush}" HorizontalAlignment="Center" diff --git a/Lite/Controls/ServerTab.BlockChain.cs b/Lite/Controls/ServerTab.BlockChain.cs index c7a4030c6..959487986 100644 --- a/Lite/Controls/ServerTab.BlockChain.cs +++ b/Lite/Controls/ServerTab.BlockChain.cs @@ -13,6 +13,7 @@ using System.Windows.Input; using PerformanceMonitor.Analysis; using PerformanceMonitor.Ui; +using static PerformanceMonitor.Ui.DataGridHelpers; using PerformanceMonitorLite.Analysis; using PerformanceMonitorLite.Services; diff --git a/Lite/Controls/ServerTab.BlockingStats.cs b/Lite/Controls/ServerTab.BlockingStats.cs new file mode 100644 index 000000000..fc157e71a --- /dev/null +++ b/Lite/Controls/ServerTab.BlockingStats.cs @@ -0,0 +1,356 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows.Controls; +using PerformanceMonitorLite.Services; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitorLite.Controls; + +/// <summary> +/// The Blocking Stats sub-tab — the Lite port of the Darling viewer's Blocking Stats sub-tab: the blocking- +/// AND deadlock-SEVERITY companion to the count-only "Trends" sub-tab (the "are blocks/deadlocks getting +/// WORSE, not just more frequent" signal). A 2x2 chart grid + a window summary strip, everything computed +/// on-the-fly from rows Lite already keeps (no collector, no migration — the Lite equivalent of the +/// Dashboard's pre-aggregated <c>collect.blocking_deadlock_stats</c>). Blocking severity comes from the same +/// <c>blocked_process_reports</c> rows the count trend uses, with the identical <c>v_dmv_blocking_snapshots</c> +/// fallback so the two reconcile; deadlock severity is parsed from <c>deadlock_graph_xml</c> over the SAME +/// <c>v_deadlocks</c> window as the deadlock count. The four charts split Total from per-incident Max/Avg so +/// the aggregate magnitude doesn't swamp the per-incident axis. Rides Lite's shared chart idiom +/// (<see cref="ChartStyle"/> / <see cref="ChartHoverHelper"/>, <c>SeriesColors</c> / <see cref="ChartPalette"/>, +/// <c>UtcOffsetMinutes</c> display conversion, Y-floor-at-0, window-pinned <c>SetLimitsX</c>), so the look +/// matches Lite's Blocking Trends charts exactly. +/// </summary> +public partial class ServerTab : UserControl +{ + private ChartHoverHelper? _blockingDurationHover; + private ChartHoverHelper? _blockingTotalDurationHover; + private ChartHoverHelper? _deadlockWaitHover; + private ChartHoverHelper? _deadlockTotalWaitHover; + + /// <summary>Applies the shared chrome + hover to the four Blocking Stats charts up front (constructor), + /// so they don't flash white before the sub-tab's first load — matching the Blocking trend charts.</summary> + private void InitializeBlockingStatsCharts() + { + ApplyTheme(BlockingDurationChart); + BlockingDurationChart.Refresh(); + ApplyTheme(BlockingTotalDurationChart); + BlockingTotalDurationChart.Refresh(); + ApplyTheme(DeadlockWaitChart); + DeadlockWaitChart.Refresh(); + ApplyTheme(DeadlockTotalWaitChart); + DeadlockTotalWaitChart.Refresh(); + + _blockingDurationHover = new ChartHoverHelper(BlockingDurationChart, "ms"); + _blockingTotalDurationHover = new ChartHoverHelper(BlockingTotalDurationChart, "ms"); + _deadlockWaitHover = new ChartHoverHelper(DeadlockWaitChart, "ms"); + _deadlockTotalWaitHover = new ChartHoverHelper(DeadlockTotalWaitChart, "ms"); + } + + /// <summary> + /// Max + Avg block duration (ms) per minute — the per-incident severity signal (how long an individual + /// block lasts). Two connected-scatter series on one shared ms axis (Max ≥ Avg, comparable magnitudes), + /// mirroring the Current Waits duration chart's render idiom. Total duration lands on its own chart + /// (<see cref="UpdateBlockingTotalDurationChart"/>) because its aggregate magnitude dwarfs these. + /// </summary> + private void UpdateBlockingDurationChart(List<BlockingDurationStatsPoint> data, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + ClearChart(BlockingDurationChart); + ApplyTheme(BlockingDurationChart); + + var (rangeStart, rangeEnd) = StatsChartRange(hoursBack, fromDate, toDate); + + _blockingDurationHover?.Clear(); + if (data.Count == 0) + { + var zeroLine = BlockingDurationChart.Plot.Add.Scatter( + new[] { rangeStart.ToOADate(), rangeEnd.ToOADate() }, + new[] { 0.0, 0.0 }); + zeroLine.LegendText = "Max Block Duration"; + zeroLine.Color = ScottPlot.Color.FromHex(SeriesColors[0]); + zeroLine.MarkerSize = 0; + BlockingDurationChart.Plot.Axes.DateTimeTicksBottomDateChange(); + BlockingDurationChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(BlockingDurationChart); + BlockingDurationChart.Plot.YLabel("Block Duration (ms)"); + SetChartYLimitsWithLegendPadding(BlockingDurationChart, 0, 1); + ShowChartLegend(BlockingDurationChart); + BlockingDurationChart.Refresh(); + return; + } + + var ordered = data.OrderBy(d => d.Time).ToList(); + var times = PadEnds(ordered.Select(d => d.Time.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(), rangeStart.ToOADate(), rangeEnd.ToOADate()); + var maxValues = PadEnds(ordered.Select(d => (double)d.MaxDurationMs).ToArray(), 0, 0); + var avgValues = PadEnds(ordered.Select(d => d.AvgDurationMs).ToArray(), 0, 0); + + var maxPlot = BlockingDurationChart.Plot.Add.Scatter(times, maxValues); + maxPlot.LegendText = "Max Block Duration"; + maxPlot.Color = ScottPlot.Color.FromHex(SeriesColors[0]); + ChartStyle.StyleScatter(maxPlot); + _blockingDurationHover?.Add(maxPlot, "Max Block Duration"); + + var avgPlot = BlockingDurationChart.Plot.Add.Scatter(times, avgValues); + avgPlot.LegendText = "Avg Block Duration"; + avgPlot.Color = ScottPlot.Color.FromHex(SeriesColors[1]); + ChartStyle.StyleScatter(avgPlot); + _blockingDurationHover?.Add(avgPlot, "Avg Block Duration"); + + double globalMax = maxValues.Length > 0 ? maxValues.Max() : 0; + + BlockingDurationChart.Plot.Axes.DateTimeTicksBottomDateChange(); + BlockingDurationChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(BlockingDurationChart); + BlockingDurationChart.Plot.YLabel("Block Duration (ms)"); + SetChartYLimitsWithLegendPadding(BlockingDurationChart, 0, globalMax > 0 ? globalMax : 1); + ShowChartLegend(BlockingDurationChart); + BlockingDurationChart.Refresh(); + } + + /// <summary> + /// Total block duration (ms) per minute — the aggregate volume×severity signal (the sum of every block's + /// wait time in the bucket). Single connected-scatter series on its own chart/axis so its magnitude + /// doesn't swamp the per-incident Max/Avg chart; colored with the blocking identity so it reads as a + /// sibling of the Trends tab's blocking-incident chart. + /// </summary> + private void UpdateBlockingTotalDurationChart(List<BlockingDurationStatsPoint> data, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + ClearChart(BlockingTotalDurationChart); + ApplyTheme(BlockingTotalDurationChart); + + var (rangeStart, rangeEnd) = StatsChartRange(hoursBack, fromDate, toDate); + + _blockingTotalDurationHover?.Clear(); + if (data.Count == 0) + { + var zeroLine = BlockingTotalDurationChart.Plot.Add.Scatter( + new[] { rangeStart.ToOADate(), rangeEnd.ToOADate() }, + new[] { 0.0, 0.0 }); + zeroLine.LegendText = "Total Block Duration"; + zeroLine.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("Blocking")); + zeroLine.MarkerSize = 0; + BlockingTotalDurationChart.Plot.Axes.DateTimeTicksBottomDateChange(); + BlockingTotalDurationChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(BlockingTotalDurationChart); + BlockingTotalDurationChart.Plot.YLabel("Total Block Duration (ms)"); + SetChartYLimitsWithLegendPadding(BlockingTotalDurationChart, 0, 1); + ShowChartLegend(BlockingTotalDurationChart); + BlockingTotalDurationChart.Refresh(); + return; + } + + var ordered = data.OrderBy(d => d.Time).ToList(); + var times = PadEnds(ordered.Select(d => d.Time.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(), rangeStart.ToOADate(), rangeEnd.ToOADate()); + var totals = PadEnds(ordered.Select(d => (double)d.TotalDurationMs).ToArray(), 0, 0); + + var plot = BlockingTotalDurationChart.Plot.Add.Scatter(times, totals); + plot.LegendText = "Total Block Duration"; + plot.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("Blocking")); + ChartStyle.StyleScatter(plot); + _blockingTotalDurationHover?.Add(plot, "Total Block Duration"); + + double globalMax = totals.Length > 0 ? totals.Max() : 0; + + BlockingTotalDurationChart.Plot.Axes.DateTimeTicksBottomDateChange(); + BlockingTotalDurationChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(BlockingTotalDurationChart); + BlockingTotalDurationChart.Plot.YLabel("Total Block Duration (ms)"); + SetChartYLimitsWithLegendPadding(BlockingTotalDurationChart, 0, globalMax > 0 ? globalMax : 1); + ShowChartLegend(BlockingTotalDurationChart); + BlockingTotalDurationChart.Refresh(); + } + + /// <summary> + /// Max + Avg deadlock wait (ms) per minute — the per-process deadlock severity signal (how long the + /// deadlocked processes had waited before the monitor broke the cycle). Two connected-scatter series on one + /// shared ms axis (Max ≥ Avg), the deadlock analog of <see cref="UpdateBlockingDurationChart"/>. Total + /// deadlock wait lands on its own chart (<see cref="UpdateDeadlockTotalWaitChart"/>). + /// </summary> + private void UpdateDeadlockWaitChart(List<DeadlockSeverityStatsPoint> data, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + ClearChart(DeadlockWaitChart); + ApplyTheme(DeadlockWaitChart); + + var (rangeStart, rangeEnd) = StatsChartRange(hoursBack, fromDate, toDate); + + _deadlockWaitHover?.Clear(); + if (data.Count == 0) + { + var zeroLine = DeadlockWaitChart.Plot.Add.Scatter( + new[] { rangeStart.ToOADate(), rangeEnd.ToOADate() }, + new[] { 0.0, 0.0 }); + zeroLine.LegendText = "Max Deadlock Wait"; + zeroLine.Color = ScottPlot.Color.FromHex(SeriesColors[0]); + zeroLine.MarkerSize = 0; + DeadlockWaitChart.Plot.Axes.DateTimeTicksBottomDateChange(); + DeadlockWaitChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(DeadlockWaitChart); + DeadlockWaitChart.Plot.YLabel("Deadlock Wait (ms)"); + SetChartYLimitsWithLegendPadding(DeadlockWaitChart, 0, 1); + ShowChartLegend(DeadlockWaitChart); + DeadlockWaitChart.Refresh(); + return; + } + + var ordered = data.OrderBy(d => d.Time).ToList(); + var times = PadEnds(ordered.Select(d => d.Time.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(), rangeStart.ToOADate(), rangeEnd.ToOADate()); + var maxValues = PadEnds(ordered.Select(d => (double)d.MaxWaitMs).ToArray(), 0, 0); + var avgValues = PadEnds(ordered.Select(d => d.AvgWaitMs).ToArray(), 0, 0); + + var maxPlot = DeadlockWaitChart.Plot.Add.Scatter(times, maxValues); + maxPlot.LegendText = "Max Deadlock Wait"; + maxPlot.Color = ScottPlot.Color.FromHex(SeriesColors[0]); + ChartStyle.StyleScatter(maxPlot); + _deadlockWaitHover?.Add(maxPlot, "Max Deadlock Wait"); + + var avgPlot = DeadlockWaitChart.Plot.Add.Scatter(times, avgValues); + avgPlot.LegendText = "Avg Deadlock Wait"; + avgPlot.Color = ScottPlot.Color.FromHex(SeriesColors[1]); + ChartStyle.StyleScatter(avgPlot); + _deadlockWaitHover?.Add(avgPlot, "Avg Deadlock Wait"); + + double globalMax = maxValues.Length > 0 ? maxValues.Max() : 0; + + DeadlockWaitChart.Plot.Axes.DateTimeTicksBottomDateChange(); + DeadlockWaitChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(DeadlockWaitChart); + DeadlockWaitChart.Plot.YLabel("Deadlock Wait (ms)"); + SetChartYLimitsWithLegendPadding(DeadlockWaitChart, 0, globalMax > 0 ? globalMax : 1); + ShowChartLegend(DeadlockWaitChart); + DeadlockWaitChart.Refresh(); + } + + /// <summary> + /// Total deadlock wait (ms) per minute — the aggregate signal (the sum of every deadlocked process's wait + /// time in the bucket). Single connected-scatter series on its own chart/axis so its magnitude doesn't + /// swamp the per-process Max/Avg chart; colored with the Deadlocks identity so it reads as a sibling of the + /// Trends tab's deadlock-count chart. The deadlock analog of <see cref="UpdateBlockingTotalDurationChart"/>. + /// </summary> + private void UpdateDeadlockTotalWaitChart(List<DeadlockSeverityStatsPoint> data, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + ClearChart(DeadlockTotalWaitChart); + ApplyTheme(DeadlockTotalWaitChart); + + var (rangeStart, rangeEnd) = StatsChartRange(hoursBack, fromDate, toDate); + + _deadlockTotalWaitHover?.Clear(); + if (data.Count == 0) + { + var zeroLine = DeadlockTotalWaitChart.Plot.Add.Scatter( + new[] { rangeStart.ToOADate(), rangeEnd.ToOADate() }, + new[] { 0.0, 0.0 }); + zeroLine.LegendText = "Total Deadlock Wait"; + zeroLine.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("Deadlocks")); + zeroLine.MarkerSize = 0; + DeadlockTotalWaitChart.Plot.Axes.DateTimeTicksBottomDateChange(); + DeadlockTotalWaitChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(DeadlockTotalWaitChart); + DeadlockTotalWaitChart.Plot.YLabel("Total Deadlock Wait (ms)"); + SetChartYLimitsWithLegendPadding(DeadlockTotalWaitChart, 0, 1); + ShowChartLegend(DeadlockTotalWaitChart); + DeadlockTotalWaitChart.Refresh(); + return; + } + + var ordered = data.OrderBy(d => d.Time).ToList(); + var times = PadEnds(ordered.Select(d => d.Time.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(), rangeStart.ToOADate(), rangeEnd.ToOADate()); + var totals = PadEnds(ordered.Select(d => (double)d.TotalWaitMs).ToArray(), 0, 0); + + var plot = DeadlockTotalWaitChart.Plot.Add.Scatter(times, totals); + plot.LegendText = "Total Deadlock Wait"; + plot.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("Deadlocks")); + ChartStyle.StyleScatter(plot); + _deadlockTotalWaitHover?.Add(plot, "Total Deadlock Wait"); + + double globalMax = totals.Length > 0 ? totals.Max() : 0; + + DeadlockTotalWaitChart.Plot.Axes.DateTimeTicksBottomDateChange(); + DeadlockTotalWaitChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(DeadlockTotalWaitChart); + DeadlockTotalWaitChart.Plot.YLabel("Total Deadlock Wait (ms)"); + SetChartYLimitsWithLegendPadding(DeadlockTotalWaitChart, 0, globalMax > 0 ? globalMax : 1); + ShowChartLegend(DeadlockTotalWaitChart); + DeadlockTotalWaitChart.Refresh(); + } + + /// <summary> + /// Window-rollup summary strip for the Blocking Stats sub-tab: total blocking events, total / max / avg + /// block duration (the avg is EVENT-weighted — total ÷ events — not a mean of the per-minute averages), the + /// deadlock COUNT, and the deadlock SEVERITY rollup (total victim processes + total deadlock wait) over the + /// window. The deadlock count is the cheap incident signal; the victim_count / total-deadlock-wait are + /// parsed on-the-fly from <c>deadlock_graph_xml</c> over the SAME v_deadlocks window (so they reconcile in + /// period), the Lite equivalent of the Dashboard's <c>victim_count</c> / <c>total_deadlock_wait_time_ms</c>. + /// Durations render with Lite's blocking wait-time format (ms under a second, else sec). + /// </summary> + private void UpdateBlockingStatsSummary( + List<BlockingDurationStatsPoint> stats, + List<TrendPoint> deadlocks, + List<DeadlockSeverityStatsPoint> deadlockSeverity) + { + long totalEvents = stats.Sum(s => (long)s.EventCount); + long totalDuration = stats.Sum(s => s.TotalDurationMs); + long maxDuration = stats.Count > 0 ? stats.Max(s => s.MaxDurationMs) : 0; + long totalDeadlocks = deadlocks.Sum(d => (long)d.Count); + long avgDuration = totalEvents > 0 ? (long)Math.Round((double)totalDuration / totalEvents) : 0; + long totalVictims = deadlockSeverity.Sum(d => (long)d.VictimCount); + long totalDeadlockWait = deadlockSeverity.Sum(d => d.TotalWaitMs); + + BlockingStatsEventCountText.Text = totalEvents.ToString("N0"); + BlockingStatsTotalDurationText.Text = totalEvents > 0 ? FormatWaitTime(totalDuration) : "--"; + BlockingStatsMaxDurationText.Text = totalEvents > 0 ? FormatWaitTime(maxDuration) : "--"; + BlockingStatsAvgDurationText.Text = totalEvents > 0 ? FormatWaitTime(avgDuration) : "--"; + BlockingStatsDeadlockCountText.Text = totalDeadlocks.ToString("N0"); + BlockingStatsDeadlockVictimsText.Text = totalVictims.ToString("N0"); + BlockingStatsDeadlockWaitText.Text = totalDeadlocks > 0 ? FormatWaitTime(totalDeadlockWait) : "--"; + } + + /// <summary>The Blocking Stats charts' X-axis window in display-local time — the custom from/to range when + /// set, else the last <paramref name="hoursBack"/> hours ending now (both shifted by + /// <c>UtcOffsetMinutes</c>, matching the Blocking trend charts).</summary> + private (DateTime rangeStart, DateTime rangeEnd) StatsChartRange(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + if (fromDate.HasValue && toDate.HasValue) + return (fromDate.Value, toDate.Value); + + var rangeEnd = DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + return (rangeEnd.AddHours(-hoursBack), rangeEnd); + } + + /// <summary> + /// Prepends <paramref name="lead"/> and appends <paramref name="trail"/> so a sparse connected-scatter + /// series spans the whole selected window instead of ending at its last event. Pass the window's + /// rangeStart/rangeEnd for the times array and 0/0 for value arrays (no activity = zero duration). Keeps all + /// four Blocking-Stats charts aligned to the shared window rather than each ending at its own last point. + /// </summary> + private static double[] PadEnds(double[] values, double lead, double trail) + { + var result = new double[values.Length + 2]; + result[0] = lead; + Array.Copy(values, 0, result, 1, values.Length); + result[^1] = trail; + return result; + } + + /// <summary>Lite's blocking wait-time rendering: < 1000 ms as "N ms", else "N.N sec" (mirrors + /// <see cref="BlockedProcessReportRow.WaitTimeFormatted"/> so the strip matches the grid).</summary> + private static string FormatWaitTime(long waitTimeMs) + => waitTimeMs < 1000 ? $"{waitTimeMs} ms" : $"{waitTimeMs / 1000.0:F1} sec"; + + /// <summary>Tears down the Blocking Stats chart hover helpers; called from the single DisposeChartHelpers().</summary> + public void DisposeBlockingStatsHelpers() + { + _blockingDurationHover?.Dispose(); + _blockingTotalDurationHover?.Dispose(); + _deadlockWaitHover?.Dispose(); + _deadlockTotalWaitHover?.Dispose(); + } +} diff --git a/Lite/Controls/ServerTab.Charts.cs b/Lite/Controls/ServerTab.Charts.cs index 7b71b7a9a..366161625 100644 --- a/Lite/Controls/ServerTab.Charts.cs +++ b/Lite/Controls/ServerTab.Charts.cs @@ -62,13 +62,25 @@ private static string FormatMb(double mb) } - private void UpdateCpuChart(List<CpuUtilizationRow> data) + private void UpdateCpuChart(List<CpuUtilizationRow> data, int hoursBack, DateTime? fromDate, DateTime? toDate) { ClearChart(CpuChart); _cpuHover?.Clear(); ApplyTheme(CpuChart); - if (data.Count == 0) { CpuChart.Refresh(); return; } + DateTime rangeEnd = toDate ?? DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + if (data.Count == 0) + { + CpuChart.Plot.Axes.DateTimeTicksBottomDateChange(); + CpuChart.Plot.Axes.SetLimitsX(xMin, xMax); + ReapplyAxisColors(CpuChart); + CpuChart.Refresh(); + return; + } var times = data.Select(d => d.SampleTime.ToOADate()).ToArray(); var sqlCpu = data.Select(d => (double)d.SqlServerCpu).ToArray(); @@ -87,6 +99,7 @@ private void UpdateCpuChart(List<CpuUtilizationRow> data) _cpuHover?.Add(otherPlot, "Other"); CpuChart.Plot.Axes.DateTimeTicksBottomDateChange(); + CpuChart.Plot.Axes.SetLimitsX(xMin, xMax); ReapplyAxisColors(CpuChart); CpuChart.Plot.YLabel("CPU %"); CpuChart.Plot.Axes.SetLimitsY(0, 105); @@ -95,13 +108,25 @@ private void UpdateCpuChart(List<CpuUtilizationRow> data) CpuChart.Refresh(); } - private void UpdateMemoryChart(List<MemoryTrendPoint> data, List<MemoryTrendPoint> grantData) + private void UpdateMemoryChart(List<MemoryTrendPoint> data, List<MemoryTrendPoint> grantData, int hoursBack, DateTime? fromDate, DateTime? toDate) { ClearChart(MemoryChart); _memoryHover?.Clear(); ApplyTheme(MemoryChart); - if (data.Count == 0) { MemoryChart.Refresh(); return; } + DateTime rangeEnd = toDate ?? DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + if (data.Count == 0) + { + MemoryChart.Plot.Axes.DateTimeTicksBottomDateChange(); + MemoryChart.Plot.Axes.SetLimitsX(xMin, xMax); + ReapplyAxisColors(MemoryChart); + MemoryChart.Refresh(); + return; + } var times = data.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); var totalMem = data.Select(d => d.TotalServerMemoryMb / 1024.0).ToArray(); @@ -147,6 +172,7 @@ private void UpdateMemoryChart(List<MemoryTrendPoint> data, List<MemoryTrendPoin _memoryHover?.Add(grantPlot, "Memory Grants"); MemoryChart.Plot.Axes.DateTimeTicksBottomDateChange(); + MemoryChart.Plot.Axes.SetLimitsX(xMin, xMax); ReapplyAxisColors(MemoryChart); MemoryChart.Plot.YLabel("Memory (GB)"); @@ -157,7 +183,7 @@ private void UpdateMemoryChart(List<MemoryTrendPoint> data, List<MemoryTrendPoin MemoryChart.Refresh(); } - private void UpdateMemoryGrantCharts(List<MemoryGrantChartPoint> data) + private void UpdateMemoryGrantCharts(List<MemoryGrantChartPoint> data, int hoursBack, DateTime? fromDate, DateTime? toDate) { ClearChart(MemoryGrantSizingChart); ClearChart(MemoryGrantActivityChart); @@ -166,10 +192,20 @@ private void UpdateMemoryGrantCharts(List<MemoryGrantChartPoint> data) ApplyTheme(MemoryGrantSizingChart); ApplyTheme(MemoryGrantActivityChart); + DateTime rangeEnd = toDate ?? DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + if (data.Count == 0) { - MemoryGrantSizingChart.Refresh(); - MemoryGrantActivityChart.Refresh(); + foreach (var c in new[] { MemoryGrantSizingChart, MemoryGrantActivityChart }) + { + c.Plot.Axes.DateTimeTicksBottomDateChange(); + c.Plot.Axes.SetLimitsX(xMin, xMax); + ReapplyAxisColors(c); + c.Refresh(); + } return; } @@ -205,6 +241,7 @@ private void UpdateMemoryGrantCharts(List<MemoryGrantChartPoint> data) } MemoryGrantSizingChart.Plot.Axes.DateTimeTicksBottomDateChange(); + MemoryGrantSizingChart.Plot.Axes.SetLimitsX(xMin, xMax); ReapplyAxisColors(MemoryGrantSizingChart); MemoryGrantSizingChart.Plot.YLabel("Memory (MB)"); SetChartYLimitsWithLegendPadding(MemoryGrantSizingChart, 0, sizingMax > 0 ? sizingMax : 100); @@ -242,6 +279,7 @@ private void UpdateMemoryGrantCharts(List<MemoryGrantChartPoint> data) } MemoryGrantActivityChart.Plot.Axes.DateTimeTicksBottomDateChange(); + MemoryGrantActivityChart.Plot.Axes.SetLimitsX(xMin, xMax); ReapplyAxisColors(MemoryGrantActivityChart); MemoryGrantActivityChart.Plot.YLabel("Count"); SetChartYLimitsWithLegendPadding(MemoryGrantActivityChart, 0, activityMax > 0 ? activityMax : 10); @@ -362,13 +400,25 @@ private void UpdateMemoryPressureEventsChart(List<MemoryPressureEventRow> data, MemoryPressureEventsChart.Refresh(); } - private void UpdateTempDbChart(List<TempDbRow> data) + private void UpdateTempDbChart(List<TempDbRow> data, int hoursBack, DateTime? fromDate, DateTime? toDate) { ClearChart(TempDbChart); _tempDbHover?.Clear(); ApplyTheme(TempDbChart); - if (data.Count == 0) { TempDbChart.Refresh(); return; } + DateTime rangeEnd = toDate ?? DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + if (data.Count == 0) + { + TempDbChart.Plot.Axes.DateTimeTicksBottomDateChange(); + TempDbChart.Plot.Axes.SetLimitsX(xMin, xMax); + ReapplyAxisColors(TempDbChart); + TempDbChart.Refresh(); + return; + } var times = data.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); var userObj = data.Select(d => d.UserObjectReservedMb).ToArray(); @@ -394,6 +444,7 @@ private void UpdateTempDbChart(List<TempDbRow> data) _tempDbHover?.Add(vsPlot, "Version Store"); TempDbChart.Plot.Axes.DateTimeTicksBottomDateChange(); + TempDbChart.Plot.Axes.SetLimitsX(xMin, xMax); ReapplyAxisColors(TempDbChart); TempDbChart.Plot.YLabel("MB"); @@ -406,13 +457,25 @@ private void UpdateTempDbChart(List<TempDbRow> data) // Dedicated chart for tempdb TOTAL allocated size (used + unallocated free space) over time — the // growth trend, on its own scale so it doesn't flatten the usage series above. Mirror of Dashboard. - private void UpdateTempDbSizeChart(List<TempDbRow> data) + private void UpdateTempDbSizeChart(List<TempDbRow> data, int hoursBack, DateTime? fromDate, DateTime? toDate) { ClearChart(TempDbSizeChart); ApplyTheme(TempDbSizeChart); _tempDbSizeHover?.Clear(); - if (data.Count == 0) { TempDbSizeChart.Refresh(); return; } + DateTime rangeEnd = toDate ?? DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + if (data.Count == 0) + { + TempDbSizeChart.Plot.Axes.DateTimeTicksBottomDateChange(); + TempDbSizeChart.Plot.Axes.SetLimitsX(xMin, xMax); + ReapplyAxisColors(TempDbSizeChart); + TempDbSizeChart.Refresh(); + return; + } var sorted = data.OrderBy(d => d.CollectionTime).ToList(); var times = sorted.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); @@ -427,16 +490,29 @@ private void UpdateTempDbSizeChart(List<TempDbRow> data) ReapplyAxisColors(TempDbSizeChart); TempDbSizeChart.Plot.YLabel("Allocated MB"); TempDbSizeChart.Plot.Axes.AutoScaleY(); + TempDbSizeChart.Plot.Axes.SetLimitsX(xMin, xMax); TempDbSizeChart.Refresh(); } - private void UpdateTempDbFileIoChart(List<FileIoTrendPoint> data) + private void UpdateTempDbFileIoChart(List<FileIoTrendPoint> data, int hoursBack, DateTime? fromDate, DateTime? toDate) { ClearChart(TempDbFileIoChart); _tempDbFileIoHover?.Clear(); ApplyTheme(TempDbFileIoChart); - if (data.Count == 0) { TempDbFileIoChart.Refresh(); return; } + DateTime rangeEnd = toDate ?? DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + if (data.Count == 0) + { + TempDbFileIoChart.Plot.Axes.DateTimeTicksBottomDateChange(); + TempDbFileIoChart.Plot.Axes.SetLimitsX(xMin, xMax); + ReapplyAxisColors(TempDbFileIoChart); + TempDbFileIoChart.Refresh(); + return; + } var files = data .GroupBy(d => d.DatabaseName) @@ -467,14 +543,15 @@ private void UpdateTempDbFileIoChart(List<FileIoTrendPoint> data) } TempDbFileIoChart.Plot.Axes.DateTimeTicksBottomDateChange(); + TempDbFileIoChart.Plot.Axes.SetLimitsX(xMin, xMax); ReapplyAxisColors(TempDbFileIoChart); - TempDbFileIoChart.Plot.YLabel("TempDB File I/O Latency (ms)"); + TempDbFileIoChart.Plot.YLabel("tempdb File I/O Latency (ms)"); SetChartYLimitsWithLegendPadding(TempDbFileIoChart, 0, maxLatency > 0 ? maxLatency : 10); ShowChartLegend(TempDbFileIoChart); TempDbFileIoChart.Refresh(); } - private void UpdateFileIoCharts(List<FileIoTrendPoint> data) + private void UpdateFileIoCharts(List<FileIoTrendPoint> data, int hoursBack, DateTime? fromDate, DateTime? toDate) { ClearChart(FileIoReadChart); ClearChart(FileIoWriteChart); @@ -483,7 +560,22 @@ private void UpdateFileIoCharts(List<FileIoTrendPoint> data) ApplyTheme(FileIoReadChart); ApplyTheme(FileIoWriteChart); - if (data.Count == 0) { FileIoReadChart.Refresh(); FileIoWriteChart.Refresh(); return; } + DateTime rangeEnd = toDate ?? DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + if (data.Count == 0) + { + foreach (var c in new[] { FileIoReadChart, FileIoWriteChart }) + { + c.Plot.Axes.DateTimeTicksBottomDateChange(); + c.Plot.Axes.SetLimitsX(xMin, xMax); + ReapplyAxisColors(c); + c.Refresh(); + } + return; + } /* Group by file, limit to top 10 by total stall */ var databases = data @@ -555,6 +647,7 @@ private void UpdateFileIoCharts(List<FileIoTrendPoint> data) } FileIoReadChart.Plot.Axes.DateTimeTicksBottomDateChange(); + FileIoReadChart.Plot.Axes.SetLimitsX(xMin, xMax); ReapplyAxisColors(FileIoReadChart); FileIoReadChart.Plot.YLabel("Read Latency (ms)"); SetChartYLimitsWithLegendPadding(FileIoReadChart, 0, readMax > 0 ? readMax : 10); @@ -562,6 +655,7 @@ private void UpdateFileIoCharts(List<FileIoTrendPoint> data) FileIoReadChart.Refresh(); FileIoWriteChart.Plot.Axes.DateTimeTicksBottomDateChange(); + FileIoWriteChart.Plot.Axes.SetLimitsX(xMin, xMax); ReapplyAxisColors(FileIoWriteChart); FileIoWriteChart.Plot.YLabel("Write Latency (ms)"); SetChartYLimitsWithLegendPadding(FileIoWriteChart, 0, writeMax > 0 ? writeMax : 10); @@ -569,7 +663,7 @@ private void UpdateFileIoCharts(List<FileIoTrendPoint> data) FileIoWriteChart.Refresh(); } - private void UpdateFileIoThroughputCharts(List<FileIoThroughputPoint> data) + private void UpdateFileIoThroughputCharts(List<FileIoThroughputPoint> data, int hoursBack, DateTime? fromDate, DateTime? toDate) { ClearChart(FileIoReadThroughputChart); ClearChart(FileIoWriteThroughputChart); @@ -578,7 +672,22 @@ private void UpdateFileIoThroughputCharts(List<FileIoThroughputPoint> data) ApplyTheme(FileIoReadThroughputChart); ApplyTheme(FileIoWriteThroughputChart); - if (data.Count == 0) { FileIoReadThroughputChart.Refresh(); FileIoWriteThroughputChart.Refresh(); return; } + DateTime rangeEnd = toDate ?? DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + if (data.Count == 0) + { + foreach (var c in new[] { FileIoReadThroughputChart, FileIoWriteThroughputChart }) + { + c.Plot.Axes.DateTimeTicksBottomDateChange(); + c.Plot.Axes.SetLimitsX(xMin, xMax); + ReapplyAxisColors(c); + c.Refresh(); + } + return; + } /* Group by file label, limit to top 10 by total throughput */ var files = data @@ -621,6 +730,7 @@ private void UpdateFileIoThroughputCharts(List<FileIoThroughputPoint> data) } FileIoReadThroughputChart.Plot.Axes.DateTimeTicksBottomDateChange(); + FileIoReadThroughputChart.Plot.Axes.SetLimitsX(xMin, xMax); ReapplyAxisColors(FileIoReadThroughputChart); FileIoReadThroughputChart.Plot.YLabel("Read Throughput (MB/s)"); SetChartYLimitsWithLegendPadding(FileIoReadThroughputChart, 0, readMax > 0 ? readMax : 1); @@ -628,6 +738,7 @@ private void UpdateFileIoThroughputCharts(List<FileIoThroughputPoint> data) FileIoReadThroughputChart.Refresh(); FileIoWriteThroughputChart.Plot.Axes.DateTimeTicksBottomDateChange(); + FileIoWriteThroughputChart.Plot.Axes.SetLimitsX(xMin, xMax); ReapplyAxisColors(FileIoWriteThroughputChart); FileIoWriteThroughputChart.Plot.YLabel("Write Throughput (MB/s)"); SetChartYLimitsWithLegendPadding(FileIoWriteThroughputChart, 0, writeMax > 0 ? writeMax : 1); @@ -990,13 +1101,18 @@ private void UpdateCurrentWaitsBlockedChart(List<BlockedSessionTrendPoint> data, /* ========== Performance Trend Charts ========== */ - private void UpdateQueryDurationTrendChart(List<QueryTrendPoint> data) + private void UpdateQueryDurationTrendChart(List<QueryTrendPoint> data, int hoursBack, DateTime? fromDate, DateTime? toDate) { ClearChart(QueryDurationTrendChart); ApplyTheme(QueryDurationTrendChart); if (data.Count == 0) { RefreshEmptyChart(QueryDurationTrendChart, "Query Duration", "Duration (ms/sec)"); return; } + DateTime rangeEnd = toDate ?? DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + var times = data.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); var values = data.Select(d => d.Value).ToArray(); @@ -1008,6 +1124,7 @@ private void UpdateQueryDurationTrendChart(List<QueryTrendPoint> data) _queryDurationTrendHover?.Add(plot, "Query Duration"); QueryDurationTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); + QueryDurationTrendChart.Plot.Axes.SetLimitsX(xMin, xMax); ReapplyAxisColors(QueryDurationTrendChart); QueryDurationTrendChart.Plot.YLabel("Duration (ms/sec)"); SetChartYLimitsWithLegendPadding(QueryDurationTrendChart, 0, values.Max()); @@ -1015,13 +1132,18 @@ private void UpdateQueryDurationTrendChart(List<QueryTrendPoint> data) QueryDurationTrendChart.Refresh(); } - private void UpdateProcDurationTrendChart(List<QueryTrendPoint> data) + private void UpdateProcDurationTrendChart(List<QueryTrendPoint> data, int hoursBack, DateTime? fromDate, DateTime? toDate) { ClearChart(ProcDurationTrendChart); ApplyTheme(ProcDurationTrendChart); if (data.Count == 0) { RefreshEmptyChart(ProcDurationTrendChart, "Procedure Duration", "Duration (ms/sec)"); return; } + DateTime rangeEnd = toDate ?? DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + var times = data.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); var values = data.Select(d => d.Value).ToArray(); @@ -1033,6 +1155,7 @@ private void UpdateProcDurationTrendChart(List<QueryTrendPoint> data) _procDurationTrendHover?.Add(plot, "Procedure Duration"); ProcDurationTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); + ProcDurationTrendChart.Plot.Axes.SetLimitsX(xMin, xMax); ReapplyAxisColors(ProcDurationTrendChart); ProcDurationTrendChart.Plot.YLabel("Duration (ms/sec)"); SetChartYLimitsWithLegendPadding(ProcDurationTrendChart, 0, values.Max()); @@ -1040,13 +1163,18 @@ private void UpdateProcDurationTrendChart(List<QueryTrendPoint> data) ProcDurationTrendChart.Refresh(); } - private void UpdateQueryStoreDurationTrendChart(List<QueryTrendPoint> data) + private void UpdateQueryStoreDurationTrendChart(List<QueryTrendPoint> data, int hoursBack, DateTime? fromDate, DateTime? toDate) { ClearChart(QueryStoreDurationTrendChart); ApplyTheme(QueryStoreDurationTrendChart); if (data.Count == 0) { RefreshEmptyChart(QueryStoreDurationTrendChart, "Query Store Duration", "Duration (ms/sec)"); return; } + DateTime rangeEnd = toDate ?? DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + var times = data.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); var values = data.Select(d => d.Value).ToArray(); @@ -1058,6 +1186,7 @@ private void UpdateQueryStoreDurationTrendChart(List<QueryTrendPoint> data) _queryStoreDurationTrendHover?.Add(plot, "Query Store Duration"); QueryStoreDurationTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); + QueryStoreDurationTrendChart.Plot.Axes.SetLimitsX(xMin, xMax); ReapplyAxisColors(QueryStoreDurationTrendChart); QueryStoreDurationTrendChart.Plot.YLabel("Duration (ms/sec)"); SetChartYLimitsWithLegendPadding(QueryStoreDurationTrendChart, 0, values.Max()); @@ -1065,13 +1194,18 @@ private void UpdateQueryStoreDurationTrendChart(List<QueryTrendPoint> data) QueryStoreDurationTrendChart.Refresh(); } - private void UpdateExecutionCountTrendChart(List<QueryTrendPoint> data) + private void UpdateExecutionCountTrendChart(List<QueryTrendPoint> data, int hoursBack, DateTime? fromDate, DateTime? toDate) { ClearChart(ExecutionCountTrendChart); ApplyTheme(ExecutionCountTrendChart); if (data.Count == 0) { RefreshEmptyChart(ExecutionCountTrendChart, "Executions", "Executions/sec"); return; } + DateTime rangeEnd = toDate ?? DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + var times = data.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); var values = data.Select(d => d.Value).ToArray(); @@ -1083,6 +1217,7 @@ private void UpdateExecutionCountTrendChart(List<QueryTrendPoint> data) _executionCountTrendHover?.Add(plot, "Executions"); ExecutionCountTrendChart.Plot.Axes.DateTimeTicksBottomDateChange(); + ExecutionCountTrendChart.Plot.Axes.SetLimitsX(xMin, xMax); ReapplyAxisColors(ExecutionCountTrendChart); ExecutionCountTrendChart.Plot.YLabel("Executions/sec"); SetChartYLimitsWithLegendPadding(ExecutionCountTrendChart, 0, values.Max()); @@ -1175,7 +1310,7 @@ private void UpdateQueryHeatmapChart(HeatmapResult result) cbTicks.AddMajor(Math.Log(1 + maxRaw), ((int)maxRaw).ToString("N0")); colorBar.Axis.TickGenerator = cbTicks; QueryHeatmapChart.Plot.Axes.AddPanel(colorBar); - _legendPanels[QueryHeatmapChart] = colorBar; + _chartHelper.SetLegendPanel(QueryHeatmapChart, colorBar); var metricName = ((ComboBoxItem)HeatmapMetricCombo.SelectedItem).Content?.ToString() ?? "Duration (ms)"; QueryHeatmapChart.Plot.Title($"Query Distribution by {metricName}"); @@ -1269,7 +1404,7 @@ private async void HeatmapMetric_SelectionChanged(object sender, System.Windows. } } var metric = (HeatmapMetric)HeatmapMetricCombo.SelectedIndex; - var result = await System.Threading.Tasks.Task.Run(() => _dataService.GetQueryHeatmapAsync(_serverId, metric, hoursBack, fromDate, toDate)); + var result = await System.Threading.Tasks.Task.Run(() => _dataService.GetQueryHeatmapAsync(_serverId, metric, hoursBack, fromDate, toDate, SelectedDatabaseFilter)); UpdateQueryHeatmapChart(result); } catch (Exception ex) @@ -1281,63 +1416,19 @@ private async void HeatmapMetric_SelectionChanged(object sender, System.Windows. /// <summary> /// Clears a chart and removes any existing legend panel to prevent duplication. /// </summary> - private void ClearChart(ScottPlot.WPF.WpfPlot chart) - { - if (_legendPanels.TryGetValue(chart, out var existingPanel) && existingPanel != null) - { - chart.Plot.Axes.Remove(existingPanel); - _legendPanels[chart] = null; - } - - /* Reset fully — Plot.Clear() leaves stale DateTime axes behind, - and DateTimeTicksBottom() replaces the axis object entirely. - Resetting the plot object avoids tick generator type mismatches. */ - chart.Reset(); - chart.Plot.Clear(); - } + private void ClearChart(ScottPlot.WPF.WpfPlot chart) => _chartHelper.ClearChart(chart); /// <summary> /// Sets up an empty chart with dark theme, Y-axis label, legend, and "No Data" annotation. /// Matches Full Dashboard behavior for consistent UX. /// </summary> private void RefreshEmptyChart(ScottPlot.WPF.WpfPlot chart, string legendText, string yAxisLabel) - { - ReapplyAxisColors(chart); - - /* Add invisible scatter to create legend entry (matches data chart layout) */ - var placeholder = chart.Plot.Add.Scatter(new double[] { 0 }, new double[] { 0 }); - placeholder.LegendText = legendText; - placeholder.Color = ScottPlot.Color.FromHex(ChartPalette.AccentColor("Placeholder")); - placeholder.MarkerSize = 0; - placeholder.LineWidth = 0; - - /* Add centered "No Data" text */ - var text = chart.Plot.Add.Text($"{legendText}\nNo Data", 0, 0); - text.LabelFontColor = ScottPlot.Color.FromHex(ChartPalette.AccentColor("Placeholder")); - text.LabelFontSize = 14; - text.LabelAlignment = ScottPlot.Alignment.MiddleCenter; - - /* Configure axes */ - chart.Plot.HideGrid(); - chart.Plot.Axes.SetLimitsX(-1, 1); - chart.Plot.Axes.SetLimitsY(-1, 1); - chart.Plot.Axes.Bottom.TickGenerator = new ScottPlot.TickGenerators.EmptyTickGenerator(); - chart.Plot.Axes.Left.TickGenerator = new ScottPlot.TickGenerators.EmptyTickGenerator(); - chart.Plot.YLabel(yAxisLabel); - - /* Show legend to match data chart layout */ - ShowChartLegend(chart); - chart.Refresh(); - } + => _chartHelper.RefreshEmptyChart(chart, legendText, yAxisLabel); /// <summary> /// Shows legend on chart and tracks it for proper cleanup on next refresh. /// </summary> - private void ShowChartLegend(ScottPlot.WPF.WpfPlot chart) - { - _legendPanels[chart] = chart.Plot.ShowLegend(ScottPlot.Edge.Bottom); - chart.Plot.Legend.FontSize = 13; - } + private void ShowChartLegend(ScottPlot.WPF.WpfPlot chart) => _chartHelper.ShowChartLegend(chart); /// <summary> /// Applies the chrome theme to a ScottPlot chart. @@ -1360,17 +1451,6 @@ private void OnThemeChanged(string _) CorrelatedLanes.ReapplyTheme(); } - private static IEnumerable<ScottPlot.WPF.WpfPlot> GetAllCharts(DependencyObject root) - { - foreach (var child in LogicalTreeHelper.GetChildren(root).OfType<DependencyObject>()) - { - if (child is ScottPlot.WPF.WpfPlot plot) - yield return plot; - foreach (var nested in GetAllCharts(child)) - yield return nested; - } - } - /// <summary> /// Reapplies theme-appropriate axis text colors/sizes after DateTimeTicksBottom() resets them. /// Delegates to the shared <see cref="ChartStyle"/>. @@ -1384,14 +1464,55 @@ private void OnThemeChanged(string _) private static void SetChartYLimitsWithLegendPadding(ScottPlot.WPF.WpfPlot chart, double dataYMin = 0, double dataYMax = 0) => ChartStyle.SetChartYLimitsWithLegendPadding(chart, dataYMin, dataYMax); + /// <summary> + /// Per-chart Revert / double-click axis reset (passed to <see cref="ContextMenuHelper.SetupChartContextMenu"/> + /// as its revertAction). Re-pins the X axis to the current settable window (+ auto-fit Y) instead of + /// AutoScale()'ing to the data range, which would re-introduce ScottPlot's ~10% side dead-space on every + /// interaction. Clears an active click-isolate first (state parity). The Query Heatmap is the one exception: + /// its X axis is categorical (bucket columns), not a time window, so it keeps AutoScale. + /// </summary> + private void RevertChartAxes(ScottPlot.WPF.WpfPlot chart) + { + if (ChartHoverHelper.TryGetForChart(chart, out var h)) h.Restore(); + + if (ReferenceEquals(chart, QueryHeatmapChart)) + { + chart.Plot.Axes.AutoScale(); + chart.Refresh(); + return; + } + + var (hoursBack, fromDate, toDate) = GetCurrentWindow(); + DateTime rangeEnd = toDate ?? DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + chart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + chart.Plot.Axes.AutoScaleY(); + chart.Refresh(); + } + /* ========== Collection Health ========== */ - private void UpdateCollectorDurationChart(List<CollectionLogRow> data) + private void UpdateCollectorDurationChart(List<CollectionLogRow> data, int hoursBack, DateTime? fromDate, DateTime? toDate) { ClearChart(CollectorDurationChart); ApplyTheme(CollectorDurationChart); - if (data.Count == 0) { CollectorDurationChart.Refresh(); return; } + /* Pin the X axis to the settable window (the same idiom as the CPU / tempdb-size charts) rather than + AutoScale()'ing to the data — a bare AutoScale fits X to the data plus ScottPlot's ~10% side margins, + which reads as symmetric dead space. This is the one chart the window-pin campaign missed. */ + DateTime rangeEnd = toDate ?? DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + if (data.Count == 0) + { + CollectorDurationChart.Plot.Axes.DateTimeTicksBottomDateChange(); + CollectorDurationChart.Plot.Axes.SetLimitsX(xMin, xMax); + ReapplyAxisColors(CollectorDurationChart); + CollectorDurationChart.Refresh(); + return; + } /* Group by collector, plot each as a separate series */ var groups = data @@ -1422,7 +1543,8 @@ private void UpdateCollectorDurationChart(List<CollectionLogRow> data) CollectorDurationChart.Plot.Axes.DateTimeTicksBottomDateChange(); ReapplyAxisColors(CollectorDurationChart); CollectorDurationChart.Plot.YLabel("Duration (ms)"); - CollectorDurationChart.Plot.Axes.AutoScale(); + CollectorDurationChart.Plot.Axes.AutoScaleY(); + CollectorDurationChart.Plot.Axes.SetLimitsX(xMin, xMax); ShowChartLegend(CollectorDurationChart); CollectorDurationChart.Refresh(); } diff --git a/Lite/Controls/ServerTab.Comparison.cs b/Lite/Controls/ServerTab.Comparison.cs index 0e1a63c35..89cc4f292 100644 --- a/Lite/Controls/ServerTab.Comparison.cs +++ b/Lite/Controls/ServerTab.Comparison.cs @@ -114,7 +114,7 @@ private async System.Threading.Tasks.Task RefreshQueryStatsComparisonAsync(DateT var items = await Task.Run(() => _dataService.GetQueryStatsComparisonAsync( _serverId, currentStart, currentEnd, - baselineRange.Value.From, baselineRange.Value.To)); + baselineRange.Value.From, baselineRange.Value.To, SelectedDatabaseFilter)); // Sort: NEW first, then by duration delta descending, GONE last var sorted = items @@ -152,7 +152,7 @@ private async System.Threading.Tasks.Task RefreshProcStatsComparisonAsync(DateTi var items = await Task.Run(() => _dataService.GetProcedureStatsComparisonAsync( _serverId, currentStart, currentEnd, - baselineRange.Value.From, baselineRange.Value.To)); + baselineRange.Value.From, baselineRange.Value.To, SelectedDatabaseFilter)); var sorted = items .OrderBy(x => x.SortGroup) @@ -189,7 +189,7 @@ private async System.Threading.Tasks.Task RefreshQueryStoreComparisonAsync(DateT var items = await Task.Run(() => _dataService.GetQueryStoreComparisonAsync( _serverId, currentStart, currentEnd, - baselineRange.Value.From, baselineRange.Value.To)); + baselineRange.Value.From, baselineRange.Value.To, SelectedDatabaseFilter)); var sorted = items .OrderBy(x => x.SortGroup) diff --git a/Lite/Controls/ServerTab.ConfigChanges.cs b/Lite/Controls/ServerTab.ConfigChanges.cs new file mode 100644 index 000000000..d4fbc478e --- /dev/null +++ b/Lite/Controls/ServerTab.ConfigChanges.cs @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using PerformanceMonitorLite.Helpers; +using PerformanceMonitorLite.Services; + +namespace PerformanceMonitorLite.Controls; + +/// <summary> +/// The Configuration Changes tab (Tier-1 parity) — the Lite port of the Darling viewer's Configuration Changes +/// tab. Three sub-tab GRIDS show server-config / database-config / trace-flag DRIFT over the toolbar window, +/// COMPUTED by diffing the store's append-only config snapshots via the shared +/// <c>PerformanceMonitor.Common.ConfigChangeDiff</c> (never re-implemented here — the reader +/// <see cref="LocalDataService.GetServerConfigChangesAsync"/> etc. fetch the DuckDB snapshots and call it). +/// Loads the ACTIVE sub-tab only (Lite's visible-only rule) via the RefreshVisibleTabAsync switch (case 19); a +/// sub-tab switch reloads through MainTabControl_SelectionChanged (ConfigChangesSubTabControl is in its +/// e.Source allow-list). Config is captured on connect/restart (not a timer), so a category needs >= 2 +/// captures bracketing a change to appear — each grid shows an honest no-data hint otherwise. +/// </summary> +public partial class ServerTab : UserControl +{ + /* Configuration Changes sub-tab order (matches the XAML TabItem order). */ + private const int ConfigChangesServerSubTabIndex = 0; + private const int ConfigChangesDatabaseSubTabIndex = 1; + private const int ConfigChangesTraceFlagSubTabIndex = 2; + + /// <summary> + /// Tab 19 — Configuration Changes. Loads the ACTIVE sub-tab only (Server Config / Database Config / Trace + /// Flag changes), mirroring the System Events tab's visible-only load. + /// </summary> + private async System.Threading.Tasks.Task RefreshConfigChangesAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + try + { + switch (ConfigChangesSubTabControl.SelectedIndex) + { + case ConfigChangesDatabaseSubTabIndex: + await LoadDatabaseConfigChangesAsync(hoursBack, fromDate, toDate); + break; + case ConfigChangesTraceFlagSubTabIndex: + await LoadTraceFlagChangesAsync(hoursBack, fromDate, toDate); + break; + case ConfigChangesServerSubTabIndex: + default: + await LoadServerConfigChangesAsync(hoursBack, fromDate, toDate); + break; + } + } + catch (Exception ex) + { + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshConfigChangesAsync failed: {ex.Message}"); + } + } + + private async System.Threading.Tasks.Task LoadServerConfigChangesAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + var data = await Task.Run(() => _dataService.GetServerConfigChangesAsync(_serverId, hoursBack, fromDate, toDate)); + _serverConfigChangesFilterMgr!.UpdateData(data); + ServerConfigChangesNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + ServerConfigChangesCountIndicator.Text = data.Count > 0 ? $"{data.Count} change(s)" : ""; + } + + private async System.Threading.Tasks.Task LoadDatabaseConfigChangesAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + var data = await Task.Run(() => _dataService.GetDatabaseConfigChangesAsync(_serverId, hoursBack, fromDate, toDate, SelectedDatabaseFilter)); + _databaseConfigChangesFilterMgr!.UpdateData(data); + DatabaseConfigChangesNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + DatabaseConfigChangesCountIndicator.Text = data.Count > 0 ? $"{data.Count} change(s)" : ""; + } + + private async System.Threading.Tasks.Task LoadTraceFlagChangesAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + var data = await Task.Run(() => _dataService.GetTraceFlagChangesAsync(_serverId, hoursBack, fromDate, toDate)); + _traceFlagChangesFilterMgr!.UpdateData(data); + TraceFlagChangesNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + TraceFlagChangesCountIndicator.Text = data.Count > 0 ? $"{data.Count} change(s)" : ""; + } + + /// <summary>The per-sub-tab Refresh button reloads the active Configuration Changes sub-tab over the + /// toolbar's current window (mirrors the other tabs' toolbar-driven refresh).</summary> + private async void ConfigChangesRefresh_Click(object sender, RoutedEventArgs e) + { + var hoursBack = GetHoursBack(); + DateTime? fromDate = null, toDate = null; + if (IsCustomRange) + { + var fromLocal = GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo); + var toLocal = GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo); + if (fromLocal.HasValue && toLocal.HasValue) + { + fromDate = ServerTimeHelper.DisplayTimeToServerTime(fromLocal.Value, ServerTimeHelper.CurrentDisplayMode); + toDate = ServerTimeHelper.DisplayTimeToServerTime(toLocal.Value, ServerTimeHelper.CurrentDisplayMode); + } + } + + await RefreshConfigChangesAsync(hoursBack, fromDate, toDate); + } +} diff --git a/Lite/Controls/ServerTab.CopyExport.cs b/Lite/Controls/ServerTab.CopyExport.cs index 7bf931945..8d6d4e31a 100644 --- a/Lite/Controls/ServerTab.CopyExport.cs +++ b/Lite/Controls/ServerTab.CopyExport.cs @@ -18,120 +18,18 @@ using PerformanceMonitorLite.Services; using PerformanceMonitor.PlanAnalysis; using PerformanceMonitor.Ui; +using static PerformanceMonitor.Ui.DataGridHelpers; namespace PerformanceMonitorLite.Controls; public partial class ServerTab : UserControl { /* DataGrid copy helpers */ - /// <summary> - /// Finds the parent DataGrid from a context menu opened on a DataGridRow. - /// </summary> - private static DataGrid? FindParentDataGrid(MenuItem menuItem) - { - var contextMenu = menuItem.Parent as ContextMenu; - var target = contextMenu?.PlacementTarget as FrameworkElement; - while (target != null && target is not DataGrid) - { - target = System.Windows.Media.VisualTreeHelper.GetParent(target) as FrameworkElement; - } - return target as DataGrid; - } - - /// <summary> - /// Gets a cell value from a row item for any column type (bound or template). - /// Template columns are inspected for a TextBlock binding in their CellTemplate. - /// </summary> - private static string GetCellValue(DataGridColumn col, object item) - { - /* DataGridBoundColumn — binding is directly accessible */ - if (col is DataGridBoundColumn boundCol - && boundCol.Binding is System.Windows.Data.Binding binding) - { - var prop = item.GetType().GetProperty(binding.Path.Path); - return FormatForExport(prop?.GetValue(item)); - } - - /* DataGridTemplateColumn — instantiate the template and find a TextBlock binding */ - if (col is DataGridTemplateColumn templateCol && templateCol.CellTemplate != null) - { - var content = templateCol.CellTemplate.LoadContent(); - if (content is TextBlock textBlock) - { - var textBinding = System.Windows.Data.BindingOperations.GetBinding(textBlock, TextBlock.TextProperty); - if (textBinding != null) - { - var prop = item.GetType().GetProperty(textBinding.Path.Path); - return FormatForExport(prop?.GetValue(item)); - } - } - } - - return ""; - } - - private static string FormatForExport(object? value) - { - if (value == null) return ""; - if (value is IFormattable formattable) - return formattable.ToString(null, System.Globalization.CultureInfo.InvariantCulture); - return value.ToString() ?? ""; - } - - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.CurrentCell.Column == null || grid.CurrentItem == null) return; - - var value = GetCellValue(grid.CurrentCell.Column, grid.CurrentItem); - if (value.Length > 0) Clipboard.SetDataObject(value, false); - } - - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.CurrentItem == null) return; - - var sb = new StringBuilder(); - foreach (var col in grid.Columns) - { - sb.Append(GetCellValue(col, grid.CurrentItem)); - sb.Append('\t'); - } - Clipboard.SetDataObject(sb.ToString().TrimEnd('\t'), false); - } - - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.Items == null) return; + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - var sb = new StringBuilder(); + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - /* Header */ - foreach (var col in grid.Columns) - { - sb.Append(DataGridClipboardBehavior.GetHeaderText(col)); - sb.Append('\t'); - } - sb.AppendLine(); - - /* Rows */ - foreach (var item in grid.Items) - { - foreach (var col in grid.Columns) - { - sb.Append(GetCellValue(col, item)); - sb.Append('\t'); - } - sb.AppendLine(); - } - - Clipboard.SetDataObject(sb.ToString(), false); - } + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); private async void CopyReproScript_Click(object sender, RoutedEventArgs e) { @@ -212,59 +110,6 @@ private async void CopyReproScript_Click(object sender, RoutedEventArgs e) Clipboard.SetDataObject(script, false); } - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.Items == null || grid.Items.Count == 0) return; - - var dialog = new SaveFileDialog - { - Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*", - DefaultExt = ".csv", - FileName = $"{_server.DisplayName}_{DateTime.Now:yyyyMMdd_HHmmss}.csv" - }; - - if (dialog.ShowDialog() != true) return; - - var sb = new StringBuilder(); - var sep = App.CsvSeparator; - - /* Header */ - var headers = new List<string>(); - foreach (var col in grid.Columns) - { - headers.Add(CsvEscape(DataGridClipboardBehavior.GetHeaderText(col), sep)); - } - sb.AppendLine(string.Join(sep, headers)); - - /* Rows */ - foreach (var item in grid.Items) - { - var values = new List<string>(); - foreach (var col in grid.Columns) - { - values.Add(CsvEscape(GetCellValue(col, item), sep)); - } - sb.AppendLine(string.Join(sep, values)); - } - - try - { - File.WriteAllText(dialog.FileName, sb.ToString(), Encoding.UTF8); - } - catch (Exception ex) - { - MessageBox.Show($"Failed to export: {ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - - private static string CsvEscape(string value, string separator) - { - if (value.Contains(separator, StringComparison.Ordinal) || value.Contains('"') || value.Contains('\n') || value.Contains('\r')) - { - return "\"" + value.Replace("\"", "\"\"") + "\""; - } - return value; - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, _server.DisplayName, App.CsvSeparator); } diff --git a/Lite/Controls/ServerTab.CpuScheduler.cs b/Lite/Controls/ServerTab.CpuScheduler.cs new file mode 100644 index 000000000..e5bd66637 --- /dev/null +++ b/Lite/Controls/ServerTab.CpuScheduler.cs @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Controls; +using PerformanceMonitorLite.Services; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitorLite.Controls; + +/// <summary> +/// The CPU Scheduler tab — the Lite port of the Darling viewer's CPU Scheduler surface (itself the +/// Dashboard-parity port of the cpu_scheduler_stats reporting). A point-in-time snapshot collector, so +/// the trend chart plots the runnable / blocked / queued task counts directly over the settable window +/// (no delta math) and the latest snapshot renders as a metric/value grid whose warning rows highlight +/// (the collector's own CASE-computed pressure flags). Loads on tab activation via the +/// RefreshVisibleTabAsync switch (mirroring tempdb / Latches & Spinlocks), so no SelectionChanged +/// handler is needed. The chart rides the shared ChartStyle / ChartHoverHelper + the same SeriesColors + +/// Y-floor-at-0 helpers as Lite's other multi-series trends. The metric/value projection + pressure +/// classification are the shared <see cref="CpuSchedulerMetrics"/> (Common); this file keeps only the +/// per-app data read and the chart render. +/// </summary> +public partial class ServerTab : UserControl +{ + private ChartHoverHelper? _cpuSchedulerHover; + + private CpuSchedulerChartRenderer? _cpuSchedRendererField; + private CpuSchedulerChartRenderer CpuSchedRenderer => + _cpuSchedRendererField ??= new CpuSchedulerChartRenderer(_chartHelper, t => t.AddMinutes(UtcOffsetMinutes)); + + /// <summary>Applies the shared chrome + hover to the scheduler chart up front (constructor), so it + /// doesn't flash white before the tab's first load — matching the CPU/Memory charts.</summary> + private void InitializeCpuSchedulerChart() + { + ApplyTheme(CpuSchedulerChart); + CpuSchedulerChart.Refresh(); + _cpuSchedulerHover = new ChartHoverHelper(CpuSchedulerChart, "tasks"); + } + + /// <summary> + /// Loads the CPU Scheduler tab over the toolbar's settable window: the pressure trend and the + /// latest-snapshot metric read fire concurrently, then the chart and metric grid render. Mirrors the + /// tempdb tab's full-refresh branch. + /// </summary> + private async System.Threading.Tasks.Task RefreshCpuSchedulerAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + try + { + var trendTask = Helpers.MethodProfiler.TimeAsync("CpuScheduler.Trend", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetCpuSchedulerTrendAsync(_serverId, hoursBack, fromDate, toDate)))); + var snapshotTask = Helpers.MethodProfiler.TimeAsync("CpuScheduler.Snapshot", () => Task.Run(() => _dataService.GetCpuSchedulerSnapshotAsync(_serverId, hoursBack, fromDate, toDate))); + + await System.Threading.Tasks.Task.WhenAll(trendTask, snapshotTask); + + UpdateCpuSchedulerChart(trendTask.Result, hoursBack, fromDate, toDate); + CpuSchedulerGrid.ItemsSource = CpuSchedulerMetrics.BuildMetrics(snapshotTask.Result); + } + catch (Exception ex) + { + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshCpuSchedulerAsync failed: {ex.Message}"); + } + } + + /// <summary>The scheduler pressure trend: three fixed series (Runnable / Blocked / Queued task counts) + /// plotted directly (point-in-time collector), a flat labelled zero-line window when the range has no + /// data. Mirrors Darling's RenderCpuSchedulerChart.</summary> + private void UpdateCpuSchedulerChart(List<CpuSchedulerTrendPoint> data, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + DateTime rangeStart, rangeEnd; + if (fromDate.HasValue && toDate.HasValue) + { + rangeStart = fromDate.Value; + rangeEnd = toDate.Value; + } + else + { + rangeEnd = DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + rangeStart = rangeEnd.AddHours(-hoursBack); + } + + CpuSchedRenderer.Render(CpuSchedulerChart, _cpuSchedulerHover, data, rangeStart.ToOADate(), rangeEnd.ToOADate()); + } + + /// <summary>Tears down the scheduler hover helper (mirrors the other tabs' dispose) so its tooltip + /// popup + chart event handlers don't outlive a closed server tab.</summary> + public void DisposeCpuSchedulerHelpers() + { + _cpuSchedulerHover?.Dispose(); + } +} diff --git a/Lite/Controls/ServerTab.DailySummary.cs b/Lite/Controls/ServerTab.DailySummary.cs new file mode 100644 index 000000000..9c732e555 --- /dev/null +++ b/Lite/Controls/ServerTab.DailySummary.cs @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using PerformanceMonitorLite.Helpers; +using PerformanceMonitorLite.Services; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitorLite.Controls; + +public partial class ServerTab : UserControl +{ + /// <summary> + /// Loads a month's daily summaries into the Performance Calendar. One banded cell per collected day; + /// days with no collection are simply absent (the calendar renders them No-Data grey). Each day carries + /// its signals + key metrics so the shared day-detail panel can explain the day and offer the right + /// drills. The heavy DuckDB read runs off the UI thread; the calendar re-shows its day-detail panel for + /// the selected day itself when it stays in view. + /// </summary> + private async Task LoadCalendarMonthAsync(DateTime month) + { + try + { + var start = new DateTime(month.Year, month.Month, 1); + var end = start.AddMonths(1); + + var rows = await Task.Run(() => _dataService.GetDailySummaryRangeAsync(_serverId, start, end)); + + DailyCalendar.Days = rows.Select(r => new PerformanceCalendarDay + { + Date = r.SummaryDate.Date, + Band = r.HealthBand, + Tooltip = $"{r.SummaryDate:MMM d, yyyy} - {r.OverallHealth}{Environment.NewLine}{r.SignalsTooltip}", + Signals = r.ToSignals(), + TopWaitType = r.TopWaitType, + TotalWaitSeconds = r.TotalWaitTimeSec, + UniqueQueries = r.UniqueQueries, + PeakBlockMs = r.MaxBlockDurationMs, + }).ToList(); + } + catch (Exception ex) + { + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] LoadCalendarMonthAsync failed: {ex.Message}"); + } + } + + private async void DailyCalendar_MonthChanged(object? sender, PerformanceCalendarMonthEventArgs e) + => await LoadCalendarMonthAsync(e.Month); + + private async void DailySummaryRefresh_Click(object sender, RoutedEventArgs e) + => await LoadCalendarMonthAsync(DailyCalendar.DisplayMonth); + + /// <summary> + /// A day-detail drill button (View Deadlocks / Blocking / Top Queries): scope the toolbar to the + /// clicked day's [00:00, next-day 00:00) window and jump to the target tab, then load it over the day. + /// This reuses the exact mechanism the per-chart drills use — <see cref="SetDrillDownTimeRange"/> sets the + /// toolbar's custom window, the tab switches run under <see cref="_suppressActiveQueriesAutoRefresh"/> (so + /// the tab-switch auto-refresh doesn't race the targeted read), then the standard per-sub-tab refresh + /// loads that grid over the window. The calendar buckets days in UTC; the reads take server time, so the + /// UTC day is shifted by the server offset (mirroring <see cref="GetTimeRange"/>'s inverse). + /// </summary> + private async void DailyCalendar_DayDrillRequested(object? sender, PerformanceCalendarDrillEventArgs e) + { + try + { + var (startUtc, endUtc) = DailyHealthBandCalculator.DayWindowUtc(e.Date); + var fromServer = startUtc.AddMinutes(ServerTimeHelper.UtcOffsetMinutes); + var toServer = endUtc.AddMinutes(ServerTimeHelper.UtcOffsetMinutes); + + // Scope the toolbar to the whole day so the grid the user lands on — and anywhere they navigate + // next — stays on that day (SetDrillDownTimeRange switches to Custom without triggering a reload). + SetDrillDownTimeRange(fromServer, toServer); + + _suppressActiveQueriesAutoRefresh = true; + try + { + switch (e.Target) + { + case DayDrillTarget.Deadlocks: + MainTabControl.SelectedIndex = 8; // Blocking + BlockingSubTabControl.SelectedIndex = 3; // Deadlocks + break; + case DayDrillTarget.Blocking: + MainTabControl.SelectedIndex = 8; // Blocking + BlockingSubTabControl.SelectedIndex = 2; // Blocked Process Reports + break; + case DayDrillTarget.TopQueries: + MainTabControl.SelectedIndex = 2; // Queries + QueriesSubTabControl.SelectedIndex = 2; // Top Queries by Duration + break; + } + } + finally + { + _suppressActiveQueriesAutoRefresh = false; + } + + // One targeted load of the tab we just switched to, over the day window (grid + slicer + comparison). + await RefreshVisibleTabAsync(GetHoursBack(), fromServer, toServer, subTabOnly: true); + } + catch (Exception ex) + { + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] DailyCalendar_DayDrillRequested failed: {ex.Message}"); + } + } +} diff --git a/Lite/Controls/ServerTab.DatabaseFilter.cs b/Lite/Controls/ServerTab.DatabaseFilter.cs new file mode 100644 index 000000000..90b18a414 --- /dev/null +++ b/Lite/Controls/ServerTab.DatabaseFilter.cs @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using PerformanceMonitorLite.Services; + +using PerformanceMonitor.Ui; + +namespace PerformanceMonitorLite.Controls; + +/// <summary> +/// #1319: the per-server global database filter (display-only). A toolbar button opens a popup of +/// collected-database checkboxes (search + Select/Clear All + count), mirroring the wait-type picker. +/// Selection is applied when the popup closes — persisted per-server (servers.json) and threaded into +/// every database-scoped reader. Empty selection = "All" (today's unfiltered behavior). +/// </summary> +public partial class ServerTab : UserControl +{ + private int _databaseFilterTotalCount; + private bool _databaseFilterDirty; + + private async void DatabaseFilterButton_Click(object sender, RoutedEventArgs e) + { + if (DatabaseFilterPopup.IsOpen) + { + DatabaseFilterPopup.IsOpen = false; + return; + } + + await PopulateDatabaseFilterPickerAsync(); + DatabaseFilterPopup.IsOpen = true; + } + + /// <summary> + /// (Re)loads the collected user-database list on each open so newly-collected databases appear. + /// Any currently-selected database missing from the collected list is kept (still checked) so a + /// sticky filter for a not-yet-collected database survives. + /// </summary> + private async Task PopulateDatabaseFilterPickerAsync() + { + List<string> names; + try + { + names = await Task.Run(() => _dataService.GetCollectedDatabaseNamesAsync(_serverId)); + } + catch (Exception ex) + { + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] GetCollectedDatabaseNamesAsync failed: {ex.Message}"); + names = new List<string>(); + } + + _databaseFilterTotalCount = names.Count; + + var union = new List<string>(names); + foreach (var sel in _selectedDatabases) + { + if (!union.Contains(sel, StringComparer.OrdinalIgnoreCase)) + { + union.Add(sel); + } + } + union.Sort(StringComparer.OrdinalIgnoreCase); + + _databaseFilterItems = union + .Select(n => new SelectableItem { DisplayName = n, IsSelected = _selectedDatabases.Contains(n) }) + .ToList(); + + if (DatabaseFilterSearchBox != null) + { + DatabaseFilterSearchBox.Text = ""; + } + ApplyDatabaseFilterSearch(); + UpdateDatabaseFilterLabel(); + } + + private void ApplyDatabaseFilterSearch() + { + var search = DatabaseFilterSearchBox?.Text?.Trim() ?? ""; + DatabaseFilterList.ItemsSource = null; + DatabaseFilterList.ItemsSource = string.IsNullOrEmpty(search) + ? _databaseFilterItems + : _databaseFilterItems.Where(i => i.DisplayName.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + private void DatabaseFilterSearch_TextChanged(object sender, TextChangedEventArgs e) => ApplyDatabaseFilterSearch(); + + private void DatabaseFilter_CheckChanged(object sender, RoutedEventArgs e) + { + if (_isUpdatingDatabaseFilterSelection) + { + return; + } + SyncSelectedDatabasesFromItems(); + } + + private void DatabaseFilterSelectAll_Click(object sender, RoutedEventArgs e) + { + _isUpdatingDatabaseFilterSelection = true; + foreach (var item in _databaseFilterItems) + { + item.IsSelected = true; + } + _isUpdatingDatabaseFilterSelection = false; + ApplyDatabaseFilterSearch(); + SyncSelectedDatabasesFromItems(); + } + + private void DatabaseFilterClearAll_Click(object sender, RoutedEventArgs e) + { + _isUpdatingDatabaseFilterSelection = true; + foreach (var item in _databaseFilterItems) + { + item.IsSelected = false; + } + _isUpdatingDatabaseFilterSelection = false; + ApplyDatabaseFilterSearch(); + SyncSelectedDatabasesFromItems(); + } + + private void SyncSelectedDatabasesFromItems() + { + _selectedDatabases.Clear(); + foreach (var item in _databaseFilterItems) + { + if (item.IsSelected) + { + _selectedDatabases.Add(item.DisplayName); + } + } + _databaseFilterDirty = true; + UpdateDatabaseFilterLabel(); + } + + private void UpdateDatabaseFilterLabel() + { + if (DatabaseFilterButton == null) + { + return; + } + + int selected = _selectedDatabases.Count; + DatabaseFilterButton.Content = selected == 0 + ? "Databases: All" + : $"Databases: {selected} of {Math.Max(selected, _databaseFilterTotalCount)}"; + + if (DatabaseFilterCountText != null) + { + DatabaseFilterCountText.Text = selected == 0 ? "All databases" : $"{selected} selected"; + } + } + + /// <summary> + /// Applies the selection when the popup closes (rather than on every checkbox toggle) so a + /// multi-database change triggers a single reload. Persists per-server, then reloads the visible tab. + /// </summary> + private async void DatabaseFilterPopup_Closed(object sender, EventArgs e) + { + if (!_databaseFilterDirty) + { + return; + } + _databaseFilterDirty = false; + + _server.ViewFilterDatabases = _selectedDatabases.ToList(); + PersistServerRequested?.Invoke(_server); + + await RefreshAllDataAsync(); + } +} diff --git a/Lite/Controls/ServerTab.Deadlock.cs b/Lite/Controls/ServerTab.Deadlock.cs index 3d55884fd..b5b5f6a62 100644 --- a/Lite/Controls/ServerTab.Deadlock.cs +++ b/Lite/Controls/ServerTab.Deadlock.cs @@ -13,6 +13,7 @@ using System.Windows.Input; using PerformanceMonitor.Common; using PerformanceMonitor.Ui; +using static PerformanceMonitor.Ui.DataGridHelpers; using PerformanceMonitorLite.Services; namespace PerformanceMonitorLite.Controls; diff --git a/Lite/Controls/ServerTab.DrillDown.cs b/Lite/Controls/ServerTab.DrillDown.cs index 398c92dd6..a64810530 100644 --- a/Lite/Controls/ServerTab.DrillDown.cs +++ b/Lite/Controls/ServerTab.DrillDown.cs @@ -116,7 +116,7 @@ private void SelectActiveQueriesForDrillDown() /// <summary> /// Generic "Show Active Queries at This Time" drill-down for resource charts that have no /// more specific target (memory clerks/grants/pressure, tempdb size + file I/O, file I/O - /// latency + throughput, current waits, perfmon). Same behavior as <see cref="OnCpuDrillDown"/>. + /// latency + throughput, current waits, perfmon). /// </summary> private async void OnActiveQueriesDrillDown(DateTime time) { @@ -131,49 +131,6 @@ private async void OnActiveQueriesDrillDown(DateTime time) _ = LoadActiveQueriesSlicerAsync(); } - private async void OnCpuDrillDown(DateTime time) - { - var fromDate = time.AddMinutes(-30); - var toDate = time.AddMinutes(30); - - // Populate custom date pickers so user can explore other tabs - SetDrillDownTimeRange(fromDate, toDate); - - // Navigate to Queries > Active Queries with ±15 min window - SelectActiveQueriesForDrillDown(); - var snapshots = await System.Threading.Tasks.Task.Run(() => _dataService.GetLatestQuerySnapshotsAsync(_serverId, 0, fromDate, toDate)); - _querySnapshotsFilterMgr!.UpdateData(snapshots); - LiveSnapshotIndicator.Text = $"Drill-down: {ServerTimeHelper.FormatServerTime(fromDate.AddMinutes(-UtcOffsetMinutes), "HH:mm")} → {ServerTimeHelper.FormatServerTime(toDate.AddMinutes(-UtcOffsetMinutes), "HH:mm")}"; - _ = LoadActiveQueriesSlicerAsync(); - } - - private async void OnMemoryDrillDown(DateTime time) - { - var fromDate = time.AddMinutes(-30); - var toDate = time.AddMinutes(30); - SetDrillDownTimeRange(fromDate, toDate); - - SelectActiveQueriesForDrillDown(); - var snapshots = await System.Threading.Tasks.Task.Run(() => _dataService.GetLatestQuerySnapshotsAsync(_serverId, 0, fromDate, toDate)); - _querySnapshotsFilterMgr!.UpdateData(snapshots); - LiveSnapshotIndicator.Text = $"Drill-down: {ServerTimeHelper.FormatServerTime(fromDate.AddMinutes(-UtcOffsetMinutes), "HH:mm")} → {ServerTimeHelper.FormatServerTime(toDate.AddMinutes(-UtcOffsetMinutes), "HH:mm")}"; - _ = LoadActiveQueriesSlicerAsync(); - } - - private async void OnTempDbDrillDown(DateTime time) - { - var fromDate = time.AddMinutes(-30); - var toDate = time.AddMinutes(30); - SetDrillDownTimeRange(fromDate, toDate); - - // Navigate to Active Queries — TempDB spills are visible there - SelectActiveQueriesForDrillDown(); - var snapshots = await System.Threading.Tasks.Task.Run(() => _dataService.GetLatestQuerySnapshotsAsync(_serverId, 0, fromDate, toDate)); - _querySnapshotsFilterMgr!.UpdateData(snapshots); - LiveSnapshotIndicator.Text = $"Drill-down: {ServerTimeHelper.FormatServerTime(fromDate.AddMinutes(-UtcOffsetMinutes), "HH:mm")} → {ServerTimeHelper.FormatServerTime(toDate.AddMinutes(-UtcOffsetMinutes), "HH:mm")}"; - _ = LoadActiveQueriesSlicerAsync(); - } - private async void OnBlockingDrillDown(DateTime time) { var fromDate = time.AddMinutes(-30); diff --git a/Lite/Controls/ServerTab.Filters.cs b/Lite/Controls/ServerTab.Filters.cs index e43f68d08..b5898eba4 100644 --- a/Lite/Controls/ServerTab.Filters.cs +++ b/Lite/Controls/ServerTab.Filters.cs @@ -12,6 +12,7 @@ using System.Windows.Controls.Primitives; using System.Windows.Media; using PerformanceMonitor.Ui; +using static PerformanceMonitor.Ui.DataGridHelpers; using PerformanceMonitorLite.Models; using PerformanceMonitorLite.Services; @@ -30,12 +31,29 @@ private void InitializeFilterManagers() _blockedProcessFilterMgr = new DataGridFilterManager<BlockedProcessReportRow>(BlockedProcessReportGrid); _deadlockFilterMgr = new DataGridFilterManager<DeadlockProcessDetail>(DeadlockGrid); _runningJobsFilterMgr = new DataGridFilterManager<RunningJobRow>(RunningJobsGrid); + _longQueryFilterMgr = new DataGridFilterManager<LongQueryCompletionRow>(LongQueryCompletionsGrid); _serverConfigFilterMgr = new DataGridFilterManager<ServerConfigRow>(ServerConfigGrid); _databaseConfigFilterMgr = new DataGridFilterManager<DatabaseConfigRow>(DatabaseConfigGrid); _dbScopedConfigFilterMgr = new DataGridFilterManager<DatabaseScopedConfigRow>(DatabaseScopedConfigGrid); _traceFlagsFilterMgr = new DataGridFilterManager<TraceFlagRow>(TraceFlagsGrid); _collectionHealthFilterMgr = new DataGridFilterManager<CollectorHealthRow>(CollectionHealthGrid); _collectionLogFilterMgr = new DataGridFilterManager<CollectionLogRow>(CollectionLogGrid); + _latchStatsFilterMgr = new DataGridFilterManager<LatchStatsSnapshotRow>(LatchStatsGrid); + _spinlockStatsFilterMgr = new DataGridFilterManager<SpinlockStatsSnapshotRow>(SpinlockStatsGrid); + /* System Events grids (the two chart sub-tabs have no grid, so register no filter manager). */ + _seSchedulerFilterMgr = new DataGridFilterManager<SchedulerIssueRow>(SchedulerIssuesGrid); + _seSevereErrorFilterMgr = new DataGridFilterManager<SevereErrorRow>(SevereErrorsGrid); + _seMemoryConditionsFilterMgr = new DataGridFilterManager<MemoryConditionsRow>(MemoryConditionsGrid); + _seMemoryBrokerFilterMgr = new DataGridFilterManager<MemoryBrokerRow>(MemoryBrokerGrid); + _seMemoryNodeOomFilterMgr = new DataGridFilterManager<MemoryNodeOomRow>(MemoryNodeOomGrid); + _seSignificantWaitsFilterMgr = new DataGridFilterManager<SignificantWaitRow>(SignificantWaitsGrid); + _seCpuTasksFilterMgr = new DataGridFilterManager<CpuTasksRow>(CpuTasksGrid); + _seIoIssuesFilterMgr = new DataGridFilterManager<IoIssuesRow>(IoIssuesGrid); + _seDefaultTraceFilterMgr = new DataGridFilterManager<DefaultTraceEventRow>(DefaultTraceGrid); + /* Configuration Changes grids (one per sub-tab). */ + _serverConfigChangesFilterMgr = new DataGridFilterManager<ServerConfigChangeRow>(ServerConfigChangesGrid); + _databaseConfigChangesFilterMgr = new DataGridFilterManager<DatabaseConfigChangeRow>(DatabaseConfigChangesGrid); + _traceFlagChangesFilterMgr = new DataGridFilterManager<TraceFlagChangeRow>(TraceFlagChangesGrid); _filterManagers[QuerySnapshotsGrid] = _querySnapshotsFilterMgr; _filterManagers[QueryStatsGrid] = _queryStatsFilterMgr; @@ -44,83 +62,33 @@ private void InitializeFilterManagers() _filterManagers[BlockedProcessReportGrid] = _blockedProcessFilterMgr; _filterManagers[DeadlockGrid] = _deadlockFilterMgr; _filterManagers[RunningJobsGrid] = _runningJobsFilterMgr; + _filterManagers[LongQueryCompletionsGrid] = _longQueryFilterMgr; _filterManagers[ServerConfigGrid] = _serverConfigFilterMgr; _filterManagers[DatabaseConfigGrid] = _databaseConfigFilterMgr; _filterManagers[DatabaseScopedConfigGrid] = _dbScopedConfigFilterMgr; _filterManagers[TraceFlagsGrid] = _traceFlagsFilterMgr; _filterManagers[CollectionHealthGrid] = _collectionHealthFilterMgr; _filterManagers[CollectionLogGrid] = _collectionLogFilterMgr; + _filterManagers[LatchStatsGrid] = _latchStatsFilterMgr; + _filterManagers[SpinlockStatsGrid] = _spinlockStatsFilterMgr; + _filterManagers[SchedulerIssuesGrid] = _seSchedulerFilterMgr; + _filterManagers[SevereErrorsGrid] = _seSevereErrorFilterMgr; + _filterManagers[MemoryConditionsGrid] = _seMemoryConditionsFilterMgr; + _filterManagers[MemoryBrokerGrid] = _seMemoryBrokerFilterMgr; + _filterManagers[MemoryNodeOomGrid] = _seMemoryNodeOomFilterMgr; + _filterManagers[SignificantWaitsGrid] = _seSignificantWaitsFilterMgr; + _filterManagers[CpuTasksGrid] = _seCpuTasksFilterMgr; + _filterManagers[IoIssuesGrid] = _seIoIssuesFilterMgr; + _filterManagers[DefaultTraceGrid] = _seDefaultTraceFilterMgr; + _filterManagers[ServerConfigChangesGrid] = _serverConfigChangesFilterMgr; + _filterManagers[DatabaseConfigChangesGrid] = _databaseConfigChangesFilterMgr; + _filterManagers[TraceFlagChangesGrid] = _traceFlagChangesFilterMgr; } - private void EnsureFilterPopup() - { - if (_filterPopup == null) - { - _filterPopupContent = new ColumnFilterPopup(); - _filterPopup = new Popup - { - Child = _filterPopupContent, - StaysOpen = false, - Placement = PlacementMode.Bottom, - AllowsTransparency = true - }; - } - } - - private DataGrid? _currentFilterGrid; - - private void FilterButton_Click(object sender, RoutedEventArgs e) - { - if (sender is not Button button || button.Tag is not string columnName) return; - - /* Walk up visual tree to find the parent DataGrid */ - var dataGrid = FindParentDataGridFromElement(button); - if (dataGrid == null || !_filterManagers.TryGetValue(dataGrid, out var manager)) return; + /* Host/apply plumbing lives in the shared Ui controller. Lazy (a field initializer can't reference the + instance field _filterManagers); the XAML-wired FilterButton_Click forwards to it. */ + private ColumnFilterPopupController? _filterPopupControllerField; + private ColumnFilterPopupController FilterPopupController => _filterPopupControllerField ??= new ColumnFilterPopupController(_filterManagers); - _currentFilterGrid = dataGrid; - - EnsureFilterPopup(); - - /* Rewire events to the current grid */ - _filterPopupContent!.FilterApplied -= FilterPopup_FilterApplied; - _filterPopupContent.FilterCleared -= FilterPopup_FilterCleared; - _filterPopupContent.FilterApplied += FilterPopup_FilterApplied; - _filterPopupContent.FilterCleared += FilterPopup_FilterCleared; - - /* Initialize with existing filter state */ - manager.Filters.TryGetValue(columnName, out var existingFilter); - _filterPopupContent.Initialize(columnName, existingFilter); - - _filterPopup!.PlacementTarget = button; - _filterPopup.IsOpen = true; - } - - private void FilterPopup_FilterApplied(object? sender, FilterAppliedEventArgs e) - { - if (_filterPopup != null) - _filterPopup.IsOpen = false; - - if (_currentFilterGrid != null && _filterManagers.TryGetValue(_currentFilterGrid, out var manager)) - { - manager.SetFilter(e.FilterState); - } - } - - private void FilterPopup_FilterCleared(object? sender, EventArgs e) - { - if (_filterPopup != null) - _filterPopup.IsOpen = false; - } - - private static DataGrid? FindParentDataGridFromElement(DependencyObject element) - { - var current = element; - while (current != null) - { - if (current is DataGrid dg) - return dg; - current = VisualTreeHelper.GetParent(current); - } - return null; - } + private void FilterButton_Click(object sender, RoutedEventArgs e) => FilterPopupController.HandleFilterButtonClick(sender); } diff --git a/Lite/Controls/ServerTab.Grids.cs b/Lite/Controls/ServerTab.Grids.cs index 0f6b854f5..c96b3c158 100644 --- a/Lite/Controls/ServerTab.Grids.cs +++ b/Lite/Controls/ServerTab.Grids.cs @@ -190,7 +190,9 @@ private async void QueryStoreGrid_SelectionChanged(object sender, SelectionChang { var hoursBack = GetHoursBack(); var (fromDate, toDate) = GetCurrentViewDates(); - var history = await Task.Run(() => _dataService.GetQueryStoreHistoryAsync(_serverId, row.DatabaseName, row.QueryId, row.PlanId, hoursBack, fromDate, toDate)); + var allPlans = await Task.Run(() => _dataService.GetQueryStoreHistoryAsync(_serverId, row.DatabaseName, row.QueryId, hoursBack, fromDate, toDate)); + // The overlay traces the SELECTED row's plan; the history query is query-scoped now. + var history = allPlans.Where(h => h.PlanId == row.PlanId).ToList(); // Query Store values are already per-interval averages, not cumulative Func<QueryStoreHistoryRow, double> selector = _queryStoreSlicerMetric switch diff --git a/Lite/Controls/ServerTab.LatchSpinlock.cs b/Lite/Controls/ServerTab.LatchSpinlock.cs new file mode 100644 index 000000000..140a00a3d --- /dev/null +++ b/Lite/Controls/ServerTab.LatchSpinlock.cs @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Windows.Controls; +using PerformanceMonitorLite.Services; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitorLite.Controls; + +/// <summary> +/// The Latches & Spinlocks tab — the Lite port of the Darling viewer's consolidated Latch/Spinlock +/// tab (itself the Dashboard-parity port of ResourceMetricsContent). ONE tab: the latch wait-time trend +/// and the spinlock collisions trend for the TOP 5 contenders stack vertically, each above a collapsed +/// Expander holding its per-class latest-snapshot grid. The cumulative-delta tables carry no stored +/// sample_interval_seconds, so the ms/sec and collisions/sec rates are computed in SQL from the +/// per-contender LAG interval (the same idiom the Wait Stats trend uses). Loads on tab activation via +/// the RefreshVisibleTabAsync switch (mirroring tempdb/CPU), so no SelectionChanged handler is needed. +/// Charts ride the shared ChartStyle/ChartHoverHelper and the same SeriesColors + Y-floor-at-0 helpers +/// as the Blocking trend charts, so the look matches Lite's other multi-series trends exactly. +/// </summary> +public partial class ServerTab : UserControl +{ + private ChartHoverHelper? _latchStatsHover; + private ChartHoverHelper? _spinlockStatsHover; + + /* One shared grouped-trend renderer serves BOTH the latch and spinlock charts; built lazily with + Lite's UTC-offset display projection (mirrors the CpuScheduler renderer's per-app wiring). */ + private GroupedTrendChartRenderer? _latchSpinlockRendererField; + private GroupedTrendChartRenderer LatchSpinlockRenderer => + _latchSpinlockRendererField ??= new GroupedTrendChartRenderer(_chartHelper, t => t.AddMinutes(UtcOffsetMinutes)); + + /// <summary>Applies the shared chrome + hover to the latch/spinlock charts up front (constructor), + /// so they don't flash white before the tab's first load — matching the CPU/Memory charts.</summary> + private void InitializeLatchSpinlockCharts() + { + ApplyTheme(LatchStatsChart); + LatchStatsChart.Refresh(); + ApplyTheme(SpinlockStatsChart); + SpinlockStatsChart.Refresh(); + + _latchStatsHover = new ChartHoverHelper(LatchStatsChart, "ms/sec"); + _spinlockStatsHover = new ChartHoverHelper(SpinlockStatsChart, "/sec"); + } + + /// <summary> + /// Loads the consolidated tab (latch trend + snapshot, spinlock trend + snapshot) over the toolbar's + /// settable window: the four reads fire concurrently, then the two charts and their two Expander + /// grids render. Mirrors the tempdb tab's full-refresh branch. + /// </summary> + private async System.Threading.Tasks.Task RefreshLatchSpinlockAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + try + { + var latchTrendTask = Helpers.MethodProfiler.TimeAsync("LatchSpinlock.LatchTrend", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetLatchStatsTrendAsync(_serverId, hoursBack, fromDate, toDate)))); + var latchSnapshotTask = Helpers.MethodProfiler.TimeAsync("LatchSpinlock.LatchSnapshot", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetLatchStatsSnapshotAsync(_serverId, hoursBack, fromDate, toDate)))); + var spinlockTrendTask = Helpers.MethodProfiler.TimeAsync("LatchSpinlock.SpinlockTrend", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetSpinlockStatsTrendAsync(_serverId, hoursBack, fromDate, toDate)))); + var spinlockSnapshotTask = Helpers.MethodProfiler.TimeAsync("LatchSpinlock.SpinlockSnapshot", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetSpinlockStatsSnapshotAsync(_serverId, hoursBack, fromDate, toDate)))); + + await System.Threading.Tasks.Task.WhenAll(latchTrendTask, latchSnapshotTask, spinlockTrendTask, spinlockSnapshotTask); + + UpdateLatchStatsChart(latchTrendTask.Result, hoursBack, fromDate, toDate); + _latchStatsFilterMgr!.UpdateData(latchSnapshotTask.Result); + UpdateSpinlockStatsChart(spinlockTrendTask.Result, hoursBack, fromDate, toDate); + _spinlockStatsFilterMgr!.UpdateData(spinlockSnapshotTask.Result); + } + catch (Exception ex) + { + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshLatchSpinlockAsync failed: {ex.Message}"); + } + } + + /// <summary>The latch wait-time trend: one ms/sec series per top-5 latch class (heaviest first so it + /// takes the first palette color), a flat labelled zero-line when the window has no data. Mirrors + /// <see cref="UpdateLockWaitTrendChart"/>.</summary> + private void UpdateLatchStatsChart(List<LatchStatsTrendPoint> data, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + DateTime rangeStart, rangeEnd; + if (fromDate.HasValue && toDate.HasValue) + { + rangeStart = fromDate.Value; + rangeEnd = toDate.Value; + } + else + { + rangeEnd = DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + rangeStart = rangeEnd.AddHours(-hoursBack); + } + + LatchSpinlockRenderer.Render(LatchStatsChart, _latchStatsHover, data, + d => d.CollectionTime, d => d.LatchClass, d => d.WaitTimeMsPerSecond, + "Latch Waits", "Wait Time (ms/sec)", rangeStart.ToOADate(), rangeEnd.ToOADate()); + } + + /// <summary>The spinlock collisions trend: one collisions/sec series per top-5 spinlock (heaviest + /// first), a flat labelled zero-line when the window has no data. Collision analog of + /// <see cref="UpdateLatchStatsChart"/>.</summary> + private void UpdateSpinlockStatsChart(List<SpinlockStatsTrendPoint> data, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + DateTime rangeStart, rangeEnd; + if (fromDate.HasValue && toDate.HasValue) + { + rangeStart = fromDate.Value; + rangeEnd = toDate.Value; + } + else + { + rangeEnd = DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + rangeStart = rangeEnd.AddHours(-hoursBack); + } + + LatchSpinlockRenderer.Render(SpinlockStatsChart, _spinlockStatsHover, data, + d => d.CollectionTime, d => d.SpinlockName, d => d.CollisionsPerSecond, + "Spinlock Collisions", "Collisions/sec", rangeStart.ToOADate(), rangeEnd.ToOADate()); + } + + /// <summary>Tears down the latch/spinlock hover helpers (mirrors the other tabs' dispose) so their + /// tooltip popups + chart event handlers don't outlive a closed server tab.</summary> + public void DisposeLatchSpinlockHelpers() + { + _latchStatsHover?.Dispose(); + _spinlockStatsHover?.Dispose(); + } +} diff --git a/Lite/Controls/ServerTab.LongQueries.cs b/Lite/Controls/ServerTab.LongQueries.cs new file mode 100644 index 000000000..bcd74e946 --- /dev/null +++ b/Lite/Controls/ServerTab.LongQueries.cs @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.ComponentModel; +using System.Threading.Tasks; +using System.Windows; +using PerformanceMonitorLite.Services; + +namespace PerformanceMonitorLite.Controls; + +public partial class ServerTab +{ + /// <summary> + /// Tab 20 — Long Queries (#1496): completed long-running queries (rpc/batch over the duration + /// threshold) plus attentions (cancels/timeouts) from the opt-in XE session. Because the collector + /// is default OFF, an explicit empty-state banner is shown while it is disabled, pointing the + /// operator at the exact switch (Settings → Collector Schedules → Edit → the Enabled box). The + /// enabled state is read live each refresh so toggling it updates the banner without reopening. + /// </summary> + private async Task RefreshLongQueriesAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + LongQueriesDisabledWarning.Visibility = _isLongQueryTraceEnabled() + ? Visibility.Collapsed + : Visibility.Visible; + + try + { + var task = Task.Run(() => SafeQueryAsync(() => + _dataService.GetRecentLongQueryCompletionsAsync(_serverId, hoursBack, fromDate, toDate, SelectedDatabaseFilter))); + await task; + _longQueryFilterMgr!.UpdateData(task.Result); + + /* View-only DESCENDING-by-duration sort — never flips the reader's chronological ORDER BY. */ + SetDefaultSortIfNone(LongQueryCompletionsGrid, "DurationMicroseconds", ListSortDirection.Descending); + } + catch (Exception ex) + { + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshLongQueriesAsync failed: {ex.Message}"); + } + } +} diff --git a/Lite/Controls/ServerTab.Pickers.cs b/Lite/Controls/ServerTab.Pickers.cs index 2fb99c67c..1d69a00ba 100644 --- a/Lite/Controls/ServerTab.Pickers.cs +++ b/Lite/Controls/ServerTab.Pickers.cs @@ -307,14 +307,6 @@ private async System.Threading.Tasks.Task UpdateMemoryClerksChartFromPickerAsync ApplyTheme(MemoryClerksChart); _memoryClerksHover?.Clear(); - if (selected.Count == 0) - { - MemoryClerksTotalText.Text = "--"; - MemoryClerksTopText.Text = "--"; - MemoryClerksChart.Refresh(); - return; - } - var hoursBack = GetHoursBack(); DateTime? fromDate = null; DateTime? toDate = null; @@ -328,6 +320,21 @@ private async System.Threading.Tasks.Task UpdateMemoryClerksChartFromPickerAsync toDate = ServerTimeHelper.DisplayTimeToServerTime(toLocal.Value, ServerTimeHelper.CurrentDisplayMode); } } + DateTime rangeEnd = toDate ?? DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + if (selected.Count == 0) + { + MemoryClerksTotalText.Text = "--"; + MemoryClerksTopText.Text = "--"; + MemoryClerksChart.Plot.Axes.DateTimeTicksBottomDateChange(); + MemoryClerksChart.Plot.Axes.SetLimitsX(xMin, xMax); + ReapplyAxisColors(MemoryClerksChart); + MemoryClerksChart.Refresh(); + return; + } double globalMax = 0; double nonBpTotal = 0; @@ -367,6 +374,7 @@ private async System.Threading.Tasks.Task UpdateMemoryClerksChartFromPickerAsync } MemoryClerksChart.Plot.Axes.DateTimeTicksBottomDateChange(); + MemoryClerksChart.Plot.Axes.SetLimitsX(xMin, xMax); ReapplyAxisColors(MemoryClerksChart); MemoryClerksChart.Plot.YLabel("Memory (MB)"); SetChartYLimitsWithLegendPadding(MemoryClerksChart, 0, globalMax > 0 ? globalMax : 100); diff --git a/Lite/Controls/ServerTab.PlanCache.cs b/Lite/Controls/ServerTab.PlanCache.cs new file mode 100644 index 000000000..07a02e080 --- /dev/null +++ b/Lite/Controls/ServerTab.PlanCache.cs @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Controls; +using PerformanceMonitorLite.Services; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitorLite.Controls; + +/// <summary> +/// The Plan Cache tab — the Lite port of the Darling viewer's Plan Cache surface (itself the +/// Dashboard-parity port of Memory > Plan Cache). The trend chart plots single-use vs multi-use +/// plan-cache size (MB) over the settable window (the single-use bloat signal, the Dashboard's Plan Cache +/// chart shape), and the summary strip shows total plans + oldest-plan age + a derived single-use bloat +/// badge. Loads on tab activation via the RefreshVisibleTabAsync switch. The two series ride the same shared +/// <see cref="ChartPalette"/> keys ("SinglePagePlans" / "MultiPagePlans") the Dashboard uses, for +/// cross-app color identity; chrome flows through the shared ChartStyle + Y-floor-at-0 helpers. +/// </summary> +public partial class ServerTab : UserControl +{ + private ChartHoverHelper? _planCacheHover; + + /// <summary>Applies the shared chrome + hover to the plan-cache chart up front (constructor).</summary> + private void InitializePlanCacheChart() + { + ApplyTheme(PlanCacheChart); + PlanCacheChart.Refresh(); + _planCacheHover = new ChartHoverHelper(PlanCacheChart, "MB"); + } + + /// <summary> + /// Loads the Plan Cache tab over the toolbar's settable window: the size trend and the (uncapped) + /// summary totals read concurrently, then the chart and summary strip render. The summary strip's + /// totals come from a dedicated uncapped aggregate, so "Total Plans" is exact. + /// </summary> + private async System.Threading.Tasks.Task RefreshPlanCacheAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + try + { + var trendTask = Helpers.MethodProfiler.TimeAsync("PlanCache.Trend", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetPlanCacheTrendAsync(_serverId, hoursBack, fromDate, toDate)))); + var summaryTask = Helpers.MethodProfiler.TimeAsync("PlanCache.Summary", () => Task.Run(() => _dataService.GetPlanCacheSummaryAsync(_serverId, hoursBack, fromDate, toDate))); + + await System.Threading.Tasks.Task.WhenAll(trendTask, summaryTask); + + UpdatePlanCacheChart(trendTask.Result, hoursBack, fromDate, toDate); + RenderPlanCacheSummary(summaryTask.Result); + } + catch (Exception ex) + { + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshPlanCacheAsync failed: {ex.Message}"); + } + } + + /// <summary>The plan-cache size trend: a single-use series and a multi-use series (MB), on the shared + /// "SinglePagePlans" / "MultiPagePlans" palette keys, a labelled zero-window when there is no data. + /// Mirrors Darling's RenderPlanCacheChart.</summary> + private void UpdatePlanCacheChart(List<PlanCacheTrendPoint> data, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + ClearChart(PlanCacheChart); + ApplyTheme(PlanCacheChart); + _planCacheHover?.Clear(); + + DateTime rangeStart, rangeEnd; + if (fromDate.HasValue && toDate.HasValue) + { + rangeStart = fromDate.Value; + rangeEnd = toDate.Value; + } + else + { + rangeEnd = DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + rangeStart = rangeEnd.AddHours(-hoursBack); + } + + double globalMax = 0; + if (data.Count > 0) + { + var ordered = data.OrderBy(d => d.CollectionTime).ToList(); + var times = ordered.Select(d => d.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); + + var singleUse = ordered.Select(d => d.SingleUseSizeMb).ToArray(); + var singlePlot = PlanCacheChart.Plot.Add.Scatter(times, singleUse); + singlePlot.LegendText = "Single-Use"; + singlePlot.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("SinglePagePlans")); + ChartStyle.StyleScatter(singlePlot); + _planCacheHover?.Add(singlePlot, "Single-Use"); + + var multiUse = ordered.Select(d => d.MultiUseSizeMb).ToArray(); + var multiPlot = PlanCacheChart.Plot.Add.Scatter(times, multiUse); + multiPlot.LegendText = "Multi-Use"; + multiPlot.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("MultiPagePlans")); + ChartStyle.StyleScatter(multiPlot); + _planCacheHover?.Add(multiPlot, "Multi-Use"); + + globalMax = Math.Max(singleUse.DefaultIfEmpty(0).Max(), multiUse.DefaultIfEmpty(0).Max()); + } + + PlanCacheChart.Plot.Axes.DateTimeTicksBottomDateChange(); + PlanCacheChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(PlanCacheChart); + PlanCacheChart.Plot.YLabel("Plan Cache Size (MB)"); + SetChartYLimitsWithLegendPadding(PlanCacheChart, 0, globalMax > 0 ? globalMax : 10); + ShowChartLegend(PlanCacheChart); + PlanCacheChart.Refresh(); + } + + /// <summary>The summary strip: TRUE total plans at the latest snapshot (uncapped, from the dedicated + /// aggregate), the oldest cached plan's age (a plan-cache-stability signal — older = more stable), and + /// the derived single-use bloat badge + forced-parameterization hint. Mirrors Darling's + /// RenderPlanCacheSummary.</summary> + private void RenderPlanCacheSummary(PlanCacheSummary summary) + { + if (summary.TotalPlans <= 0 && summary.OldestPlanCreateTime is null) + { + PlanCacheTotalPlansText.Text = "--"; + PlanCacheOldestPlanText.Text = "--"; + PlanCacheBloatLevelText.Text = "--"; + PlanCacheBloatLevelText.Foreground = System.Windows.Media.Brushes.Gray; + PlanCacheRecommendationText.Text = ""; + return; + } + + /* Derived single-use bloat badge + forced-parameterization hint (install/47 report.plan_cache_bloat + parity): a pure client-side CASE on the single-use / total ratio the reader already returns. */ + var bloat = LocalDataService.ClassifyPlanCacheBloat(summary.TotalPlans, summary.SingleUsePlans); + PlanCacheBloatLevelText.Text = bloat.Level; + PlanCacheBloatLevelText.Foreground = BloatLevelBrush(bloat.Level); + PlanCacheRecommendationText.Text = bloat.Recommendation; + + PlanCacheTotalPlansText.Text = summary.TotalPlans.ToString("N0"); + + /* oldest_plan_create_time comes from a DMV (sys.dm_exec_query_stats.creation_time) in the monitored + server's local clock, which the viewer — having no per-server offset for this DMV column — + measures against UtcNow: exact for a UTC server, off by the server's offset otherwise. Age is a + coarse d/h/m stability bucket, so a few hours' offset rarely changes the qualitative read. */ + if (summary.OldestPlanCreateTime is not { } oldest) + { + PlanCacheOldestPlanText.Text = "--"; + return; + } + + var age = DateTime.UtcNow - DateTime.SpecifyKind(oldest, DateTimeKind.Utc); + if (age < TimeSpan.Zero) age = TimeSpan.Zero; + PlanCacheOldestPlanText.Text = age.TotalDays >= 1 + ? $"{(int)age.TotalDays}d {age.Hours}h" + : age.TotalHours >= 1 + ? $"{age.Hours}h {age.Minutes}m" + : $"{age.Minutes}m"; + } + + /// <summary>Maps a plan-cache bloat level to its badge colour (CRITICAL red → HIGH orange → MEDIUM + /// goldenrod → NORMAL green), mirroring the severity palette the warning highlights use elsewhere.</summary> + private static System.Windows.Media.SolidColorBrush BloatLevelBrush(string level) + { + var hex = level switch + { + "CRITICAL" => "#FF6B6B", + "HIGH" => "#FFA94D", + "MEDIUM" => "#E0C341", + _ => "#4CAF50", + }; + var brush = new System.Windows.Media.SolidColorBrush( + (System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(hex)); + brush.Freeze(); + return brush; + } + + /// <summary>Tears down the plan-cache hover helper (mirrors the other tabs' dispose).</summary> + public void DisposePlanCacheHelpers() + { + _planCacheHover?.Dispose(); + } +} diff --git a/Lite/Controls/ServerTab.Plans.cs b/Lite/Controls/ServerTab.Plans.cs index 14bf694c6..69c77a22f 100644 --- a/Lite/Controls/ServerTab.Plans.cs +++ b/Lite/Controls/ServerTab.Plans.cs @@ -20,6 +20,9 @@ using PerformanceMonitorLite.Models; using PerformanceMonitorLite.Services; using PerformanceMonitor.Ui; +using static PerformanceMonitor.Ui.FileSaveHelper; +using static PerformanceMonitor.Ui.DataGridHelpers; +using PerformanceMonitor.PlanAnalysis; namespace PerformanceMonitorLite.Controls; @@ -265,7 +268,7 @@ private async void ViewEstimatedPlan_Click(object sender, RoutedEventArgs e) string? planXml = null; string? queryText = null; - string label = "Estimated Plan"; + string label = "Stored Plan"; switch (grid.CurrentItem) { @@ -285,8 +288,10 @@ private async void ViewEstimatedPlan_Click(object sender, RoutedEventArgs e) planXml = await FetchPlanByHash(stats.QueryHash); break; case QueryStatsHistoryRow hist: - planXml = hist.QueryPlan; label = "Est Plan - History"; + // History rows no longer carry per-row plan XML — fetch on demand by hash + if (!string.IsNullOrEmpty(hist.QueryHash)) + planXml = await FetchPlanByHash(hist.QueryHash); break; case ProcedureStatsRow proc: label = $"Est Plan - {proc.FullName}"; @@ -409,12 +414,29 @@ private async void GetActualPlan_Click(object sender, RoutedEventArgs e) return; } + /* Data-modification gate: detect from the estimated plan XML (fail-safe to "modifying" when the plan + can't be analyzed) and flag it PROMINENTLY — distinct from the normal warning — since re-executing an + INSERT/UPDATE/DELETE/MERGE RE-APPLIES those writes to the database. FLAG, don't refuse: the operator + decides with eyes open. */ + var modification = QueryModificationDetector.Detect(planXml, queryText); + var modificationWarning = QueryModificationDetector.BuildConsentWarning(modification, databaseName); + + var prompt = new StringBuilder(); + if (modificationWarning.Length > 0) + { + prompt.AppendLine(modificationWarning); + prompt.AppendLine("──────────────────────────────────────────"); + prompt.AppendLine(); + } + prompt.AppendLine($"You are about to execute this query against {_server.ServerName} in database [{databaseName ?? "default"}]."); + prompt.AppendLine(); + prompt.AppendLine("Make sure you understand what the query does before proceeding."); + prompt.AppendLine("The query will execute with SET STATISTICS XML ON to capture the actual plan."); + prompt.AppendLine("All data results will be discarded."); + var result = MessageBox.Show( - $"You are about to execute this query against {_server.ServerName} in database [{databaseName ?? "default"}].\n\n" + - "Make sure you understand what the query does before proceeding.\n" + - "The query will execute with SET STATISTICS XML ON to capture the actual plan.\n" + - "All data results will be discarded.", - "Get Actual Plan", + prompt.ToString(), + modification.ModifiesData ? "Get Actual Plan (re-run) — DATA WILL BE MODIFIED" : "Get Actual Plan (re-run)", MessageBoxButton.OKCancel, MessageBoxImage.Warning); @@ -437,7 +459,8 @@ private async void GetActualPlan_Click(object sender, RoutedEventArgs e) isolationLevel, isAzureSqlDb: false, timeoutSeconds: 0, - _actualPlanCts.Token); + _actualPlanCts.Token, + productName: "SQL Server Performance Monitor Lite"); if (!string.IsNullOrEmpty(actualPlanXml)) { @@ -691,70 +714,15 @@ private async void ViewDeadlockProcessPlan_Click(object sender, RoutedEventArgs } } - private void SavePlanFile(string planXml, string defaultName) - { - var dialog = new SaveFileDialog - { - Filter = "SQL Plan files (*.sqlplan)|*.sqlplan|All files (*.*)|*.*", - DefaultExt = ".sqlplan", - FileName = $"{defaultName}_{DateTime.Now:yyyyMMdd_HHmmss}.sqlplan" - }; - - if (dialog.ShowDialog() != true) return; - - try - { - File.WriteAllText(dialog.FileName, planXml, Encoding.UTF8); - } - catch (Exception ex) - { - MessageBox.Show($"Failed to save plan: {ex.Message}", "Save Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - private void DownloadDeadlockXml_Click(object sender, RoutedEventArgs e) { if (sender is not Button btn || btn.DataContext is not DeadlockProcessDetail row || string.IsNullOrEmpty(row.DeadlockGraphXml)) return; - - var dialog = new SaveFileDialog - { - Filter = "XML files (*.xml)|*.xml|All files (*.*)|*.*", - DefaultExt = ".xml", - FileName = $"deadlock_{row.DeadlockTime:yyyyMMdd_HHmmss}.xml" - }; - - if (dialog.ShowDialog() != true) return; - - try - { - File.WriteAllText(dialog.FileName, row.DeadlockGraphXml, Encoding.UTF8); - } - catch (Exception ex) - { - MessageBox.Show($"Failed to save deadlock XML: {ex.Message}", "Save Error", MessageBoxButton.OK, MessageBoxImage.Error); - } + SaveXmlToFile(row.DeadlockGraphXml, $"deadlock_{row.DeadlockTime:yyyyMMdd_HHmmss}.xml", "deadlock XML"); } private void DownloadBlockedProcessXml_Click(object sender, RoutedEventArgs e) { if (sender is not Button btn || btn.DataContext is not BlockedProcessReportRow row || string.IsNullOrEmpty(row.BlockedProcessReportXml)) return; - - var dialog = new SaveFileDialog - { - Filter = "XML files (*.xml)|*.xml|All files (*.*)|*.*", - DefaultExt = ".xml", - FileName = $"blocked_process_{row.EventTime:yyyyMMdd_HHmmss}.xml" - }; - - if (dialog.ShowDialog() != true) return; - - try - { - File.WriteAllText(dialog.FileName, row.BlockedProcessReportXml, Encoding.UTF8); - } - catch (Exception ex) - { - MessageBox.Show($"Failed to save blocked process XML: {ex.Message}", "Save Error", MessageBoxButton.OK, MessageBoxImage.Error); - } + SaveXmlToFile(row.BlockedProcessReportXml, $"blocked_process_{row.EventTime:yyyyMMdd_HHmmss}.xml", "blocked process XML"); } } diff --git a/Lite/Controls/ServerTab.Refresh.cs b/Lite/Controls/ServerTab.Refresh.cs index 9b2b4ef71..f4a6571c7 100644 --- a/Lite/Controls/ServerTab.Refresh.cs +++ b/Lite/Controls/ServerTab.Refresh.cs @@ -34,17 +34,33 @@ public async void RefreshData() /* Deadlock-graph XML parsing (XElement.Parse + deep Descendants traversal per row) is heavy enough to hitch the dispatcher on the Blocking tab; run it on the thread pool so only the grid bind stays on the UI thread. */ - private static Task<List<DeadlockProcessDetail>> ParseDeadlocksOffUiThreadAsync(List<DeadlockRow> rows) - => Task.Run(() => DeadlockProcessDetail.ParseFromRows(rows)); - - private async System.Threading.Tasks.Task RefreshAllDataAsync() + private Task<List<DeadlockProcessDetail>> ParseDeadlocksOffUiThreadAsync(List<DeadlockRow> rows) { - if (_isRefreshing) return; - _isRefreshing = true; + /* #1319: deadlocks have no database_name column (the per-process DB is inside the graph XML), so + the global database filter is applied client-side on the parsed per-process rows. Snapshot the + selection on the UI thread; empty = All (unfiltered). */ + var selected = _selectedDatabases.Count == 0 + ? null + : new HashSet<string>(_selectedDatabases, StringComparer.OrdinalIgnoreCase); + return Task.Run(() => + { + var details = DeadlockProcessDetail.ParseFromRows(rows); + return selected == null + ? details + : details.Where(d => selected.Contains(d.DatabaseName)).ToList(); + }); + } + /// <summary> + /// The current toolbar window as (hoursBack, fromDate, toDate) in server time — the single derivation + /// shared by the data refresh and the per-chart Revert / double-click axis re-pin, so both read the same + /// window. A preset leaves from/to null (charts fall back to now − hoursBack); a valid custom range + /// converts the local picker dates/times to server time. + /// </summary> + private (int hoursBack, DateTime? fromDate, DateTime? toDate) GetCurrentWindow() + { var hoursBack = GetHoursBack(); - /* Get custom date range if selected, converting local picker dates/times to server time */ DateTime? fromDate = null; DateTime? toDate = null; if (IsCustomRange) @@ -58,6 +74,16 @@ private async System.Threading.Tasks.Task RefreshAllDataAsync() } } + return (hoursBack, fromDate, toDate); + } + + private async System.Threading.Tasks.Task RefreshAllDataAsync() + { + if (_isRefreshing) return; + _isRefreshing = true; + + var (hoursBack, fromDate, toDate) = GetCurrentWindow(); + try { using var _profiler = Helpers.MethodProfiler.StartTiming($"ServerTab-{_server?.DisplayName}"); @@ -108,7 +134,14 @@ private async System.Threading.Tasks.Task RefreshVisibleTabAsync(int hoursBack, case 10: await RefreshRunningJobsAsync(hoursBack, fromDate, toDate); break; case 11: await RefreshConfigurationAsync(hoursBack, fromDate, toDate); break; case 12: await RefreshDailySummaryAsync(hoursBack, fromDate, toDate); break; - case 13: await RefreshCollectionHealthAsync(hoursBack, fromDate, toDate); break; + case 13: await RefreshLatchSpinlockAsync(hoursBack, fromDate, toDate); break; + case 14: await RefreshCpuSchedulerAsync(hoursBack, fromDate, toDate); break; + case 15: await RefreshPlanCacheAsync(hoursBack, fromDate, toDate); break; + case 16: await RefreshSessionStatsAsync(hoursBack, fromDate, toDate); break; + case 17: await RefreshCollectionHealthAsync(hoursBack, fromDate, toDate); break; + case 18: await RefreshSystemEventsAsync(hoursBack, fromDate, toDate); break; + case 19: await RefreshConfigChangesAsync(hoursBack, fromDate, toDate); break; + case 20: await RefreshLongQueriesAsync(hoursBack, fromDate, toDate); break; } } @@ -131,7 +164,7 @@ private async System.Threading.Tasks.Task RefreshAlertCountsAsync(int hoursBack, /* ───────────────────────────── Per-tab refresh methods ───────────────────────────── */ - /// <summary>Tab 0 — Wait Stats</summary> + /// <summary>Tab 1 — Wait Stats</summary> private async System.Threading.Tasks.Task RefreshWaitStatsAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) { try @@ -147,7 +180,7 @@ private async System.Threading.Tasks.Task RefreshWaitStatsAsync(int hoursBack, D } } - /// <summary>Tab 1 — Queries</summary> + /// <summary>Tab 2 — Queries</summary> private async System.Threading.Tasks.Task RefreshQueriesAsync(int hoursBack, DateTime? fromDate, DateTime? toDate, bool subTabOnly = false) { try @@ -158,24 +191,24 @@ private async System.Threading.Tasks.Task RefreshQueriesAsync(int hoursBack, Dat switch (QueriesSubTabControl.SelectedIndex) { case 0: // Performance Trends — 4 trend charts - var qdt = Helpers.MethodProfiler.TimeAsync("QueryPerformance.QueryDurationTrends", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetQueryDurationTrendAsync(_serverId, hoursBack, fromDate, toDate)))); - var pdt = Helpers.MethodProfiler.TimeAsync("QueryPerformance.ProcDurationTrends", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetProcedureDurationTrendAsync(_serverId, hoursBack, fromDate, toDate)))); - var qsdt = Helpers.MethodProfiler.TimeAsync("QueryPerformance.QsDurationTrends", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetQueryStoreDurationTrendAsync(_serverId, hoursBack, fromDate, toDate)))); - var ect = Helpers.MethodProfiler.TimeAsync("QueryPerformance.ExecutionTrends", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetExecutionCountTrendAsync(_serverId, hoursBack, fromDate, toDate)))); + var qdt = Helpers.MethodProfiler.TimeAsync("QueryPerformance.QueryDurationTrends", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetQueryDurationTrendAsync(_serverId, hoursBack, fromDate, toDate, SelectedDatabaseFilter)))); + var pdt = Helpers.MethodProfiler.TimeAsync("QueryPerformance.ProcDurationTrends", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetProcedureDurationTrendAsync(_serverId, hoursBack, fromDate, toDate, SelectedDatabaseFilter)))); + var qsdt = Helpers.MethodProfiler.TimeAsync("QueryPerformance.QsDurationTrends", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetQueryStoreDurationTrendAsync(_serverId, hoursBack, fromDate, toDate, SelectedDatabaseFilter)))); + var ect = Helpers.MethodProfiler.TimeAsync("QueryPerformance.ExecutionTrends", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetExecutionCountTrendAsync(_serverId, hoursBack, fromDate, toDate, SelectedDatabaseFilter)))); await System.Threading.Tasks.Task.WhenAll(qdt, pdt, qsdt, ect); - UpdateQueryDurationTrendChart(qdt.Result); - UpdateProcDurationTrendChart(pdt.Result); - UpdateQueryStoreDurationTrendChart(qsdt.Result); - UpdateExecutionCountTrendChart(ect.Result); + UpdateQueryDurationTrendChart(qdt.Result, hoursBack, fromDate, toDate); + UpdateProcDurationTrendChart(pdt.Result, hoursBack, fromDate, toDate); + UpdateQueryStoreDurationTrendChart(qsdt.Result, hoursBack, fromDate, toDate); + UpdateExecutionCountTrendChart(ect.Result, hoursBack, fromDate, toDate); break; case 1: // Active Queries - var snapshots = await Task.Run(() => _dataService.GetLatestQuerySnapshotsAsync(_serverId, hoursBack, fromDate, toDate)); + var snapshots = await Task.Run(() => _dataService.GetLatestQuerySnapshotsAsync(_serverId, hoursBack, fromDate, toDate, SelectedDatabaseFilter)); _querySnapshotsFilterMgr!.UpdateData(snapshots); LiveSnapshotIndicator.Text = ""; _ = LoadActiveQueriesSlicerAsync(); break; case 2: // Top Queries by Duration - var queryStats = await Task.Run(() => _dataService.GetTopQueriesByCpuAsync(_serverId, hoursBack, 50, fromDate, toDate, UtcOffsetMinutes)); + var queryStats = await Task.Run(() => _dataService.GetTopQueriesByCpuAsync(_serverId, hoursBack, 50, fromDate, toDate, UtcOffsetMinutes, SelectedDatabaseFilter)); _queryStatsFilterMgr!.UpdateData(queryStats); SetDefaultSortIfNone(QueryStatsGrid, "TotalElapsedMs", ListSortDirection.Descending); _ = LoadQueryStatsSlicerAsync(); @@ -186,7 +219,7 @@ private async System.Threading.Tasks.Task RefreshQueriesAsync(int hoursBack, Dat } break; case 3: // Top Procedures by Duration - var procStats = await Task.Run(() => _dataService.GetTopProceduresByCpuAsync(_serverId, hoursBack, 50, fromDate, toDate, UtcOffsetMinutes)); + var procStats = await Task.Run(() => _dataService.GetTopProceduresByCpuAsync(_serverId, hoursBack, 50, fromDate, toDate, UtcOffsetMinutes, SelectedDatabaseFilter)); _procStatsFilterMgr!.UpdateData(procStats); SetDefaultSortIfNone(ProcedureStatsGrid, "TotalElapsedMs", ListSortDirection.Descending); _ = LoadProcStatsSlicerAsync(); @@ -197,7 +230,7 @@ private async System.Threading.Tasks.Task RefreshQueriesAsync(int hoursBack, Dat } break; case 4: // Query Store by Duration - var qsData = await Task.Run(() => _dataService.GetQueryStoreTopQueriesAsync(_serverId, hoursBack, 50, fromDate, toDate)); + var qsData = await Task.Run(() => _dataService.GetQueryStoreTopQueriesAsync(_serverId, hoursBack, 50, fromDate, toDate, SelectedDatabaseFilter)); _queryStoreFilterMgr!.UpdateData(qsData); SetDefaultSortIfNone(QueryStoreGrid, "TotalDurationMs", ListSortDirection.Descending); _ = LoadQueryStoreSlicerAsync(); @@ -209,7 +242,7 @@ private async System.Threading.Tasks.Task RefreshQueriesAsync(int hoursBack, Dat break; case 5: // Query Heatmap var hmMetric = (HeatmapMetric)HeatmapMetricCombo.SelectedIndex; - var hmData = await Task.Run(() => _dataService.GetQueryHeatmapAsync(_serverId, hmMetric, hoursBack, fromDate, toDate)); + var hmData = await Task.Run(() => _dataService.GetQueryHeatmapAsync(_serverId, hmMetric, hoursBack, fromDate, toDate, SelectedDatabaseFilter)); AppLogger.Info("ServerTab", $"[{_server.DisplayName}] Heatmap: {hmData.TimeBuckets.Length} time buckets, {hmData.Intensities.GetLength(0)}x{hmData.Intensities.GetLength(1)} grid"); UpdateQueryHeatmapChart(hmData); break; @@ -218,17 +251,17 @@ private async System.Threading.Tasks.Task RefreshQueriesAsync(int hoursBack, Dat } /* Full refresh: load all sub-tabs */ - var snapshotsTask = Helpers.MethodProfiler.TimeAsync("QueryPerformance.Snapshots", () => Task.Run(() => _dataService.GetLatestQuerySnapshotsAsync(_serverId, hoursBack, fromDate, toDate))); - var queryStatsTask = Helpers.MethodProfiler.TimeAsync("QueryPerformance.QueryStats", () => Task.Run(() => _dataService.GetTopQueriesByCpuAsync(_serverId, hoursBack, 50, fromDate, toDate, UtcOffsetMinutes))); - var procStatsTask = Helpers.MethodProfiler.TimeAsync("QueryPerformance.ProcStats", () => Task.Run(() => _dataService.GetTopProceduresByCpuAsync(_serverId, hoursBack, 50, fromDate, toDate, UtcOffsetMinutes))); - var queryStoreTask = Helpers.MethodProfiler.TimeAsync("QueryPerformance.QueryStore", () => Task.Run(() => _dataService.GetQueryStoreTopQueriesAsync(_serverId, hoursBack, 50, fromDate, toDate))); - var queryDurationTrendTask = Helpers.MethodProfiler.TimeAsync("QueryPerformance.QueryDurationTrends", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetQueryDurationTrendAsync(_serverId, hoursBack, fromDate, toDate)))); - var procDurationTrendTask = Helpers.MethodProfiler.TimeAsync("QueryPerformance.ProcDurationTrends", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetProcedureDurationTrendAsync(_serverId, hoursBack, fromDate, toDate)))); - var queryStoreDurationTrendTask = Helpers.MethodProfiler.TimeAsync("QueryPerformance.QsDurationTrends", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetQueryStoreDurationTrendAsync(_serverId, hoursBack, fromDate, toDate)))); - var executionCountTrendTask = Helpers.MethodProfiler.TimeAsync("QueryPerformance.ExecutionTrends", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetExecutionCountTrendAsync(_serverId, hoursBack, fromDate, toDate)))); + var snapshotsTask = Helpers.MethodProfiler.TimeAsync("QueryPerformance.Snapshots", () => Task.Run(() => _dataService.GetLatestQuerySnapshotsAsync(_serverId, hoursBack, fromDate, toDate, SelectedDatabaseFilter))); + var queryStatsTask = Helpers.MethodProfiler.TimeAsync("QueryPerformance.QueryStats", () => Task.Run(() => _dataService.GetTopQueriesByCpuAsync(_serverId, hoursBack, 50, fromDate, toDate, UtcOffsetMinutes, SelectedDatabaseFilter))); + var procStatsTask = Helpers.MethodProfiler.TimeAsync("QueryPerformance.ProcStats", () => Task.Run(() => _dataService.GetTopProceduresByCpuAsync(_serverId, hoursBack, 50, fromDate, toDate, UtcOffsetMinutes, SelectedDatabaseFilter))); + var queryStoreTask = Helpers.MethodProfiler.TimeAsync("QueryPerformance.QueryStore", () => Task.Run(() => _dataService.GetQueryStoreTopQueriesAsync(_serverId, hoursBack, 50, fromDate, toDate, SelectedDatabaseFilter))); + var queryDurationTrendTask = Helpers.MethodProfiler.TimeAsync("QueryPerformance.QueryDurationTrends", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetQueryDurationTrendAsync(_serverId, hoursBack, fromDate, toDate, SelectedDatabaseFilter)))); + var procDurationTrendTask = Helpers.MethodProfiler.TimeAsync("QueryPerformance.ProcDurationTrends", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetProcedureDurationTrendAsync(_serverId, hoursBack, fromDate, toDate, SelectedDatabaseFilter)))); + var queryStoreDurationTrendTask = Helpers.MethodProfiler.TimeAsync("QueryPerformance.QsDurationTrends", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetQueryStoreDurationTrendAsync(_serverId, hoursBack, fromDate, toDate, SelectedDatabaseFilter)))); + var executionCountTrendTask = Helpers.MethodProfiler.TimeAsync("QueryPerformance.ExecutionTrends", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetExecutionCountTrendAsync(_serverId, hoursBack, fromDate, toDate, SelectedDatabaseFilter)))); var heatmapTask = Helpers.MethodProfiler.TimeAsync("QueryPerformance.Heatmap", () => Task.Run(async () => { - try { return await _dataService.GetQueryHeatmapAsync(_serverId, (HeatmapMetric)Dispatcher.Invoke(() => HeatmapMetricCombo.SelectedIndex), hoursBack, fromDate, toDate); } + try { return await _dataService.GetQueryHeatmapAsync(_serverId, (HeatmapMetric)Dispatcher.Invoke(() => HeatmapMetricCombo.SelectedIndex), hoursBack, fromDate, toDate, SelectedDatabaseFilter); } catch { return new HeatmapResult(); } })); @@ -267,10 +300,10 @@ await System.Threading.Tasks.Task.WhenAll( await RefreshQueryStoreComparisonAsync(cStart3, cEnd3); } - UpdateQueryDurationTrendChart(queryDurationTrendTask.Result); - UpdateProcDurationTrendChart(procDurationTrendTask.Result); - UpdateQueryStoreDurationTrendChart(queryStoreDurationTrendTask.Result); - UpdateExecutionCountTrendChart(executionCountTrendTask.Result); + UpdateQueryDurationTrendChart(queryDurationTrendTask.Result, hoursBack, fromDate, toDate); + UpdateProcDurationTrendChart(procDurationTrendTask.Result, hoursBack, fromDate, toDate); + UpdateQueryStoreDurationTrendChart(queryStoreDurationTrendTask.Result, hoursBack, fromDate, toDate); + UpdateExecutionCountTrendChart(executionCountTrendTask.Result, hoursBack, fromDate, toDate); UpdateQueryHeatmapChart(heatmapTask.Result); } catch (Exception ex) @@ -279,7 +312,6 @@ await System.Threading.Tasks.Task.WhenAll( } } - /// <summary>Tab 3 — CPU</summary> /// <summary>Tab 0 — Overview (Correlated Timeline Lanes)</summary> private async System.Threading.Tasks.Task RefreshOverviewAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) { @@ -294,13 +326,14 @@ private async System.Threading.Tasks.Task RefreshOverviewAsync(int hoursBack, Da } } + /// <summary>Tab 4 — CPU</summary> private async System.Threading.Tasks.Task RefreshCpuAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) { try { var cpuTask = Task.Run(() => _dataService.GetCpuUtilizationAsync(_serverId, hoursBack, fromDate, toDate)); await cpuTask; - UpdateCpuChart(cpuTask.Result); + UpdateCpuChart(cpuTask.Result, hoursBack, fromDate, toDate); } catch (Exception ex) { @@ -308,7 +341,7 @@ private async System.Threading.Tasks.Task RefreshCpuAsync(int hoursBack, DateTim } } - /// <summary>Tab 4 — Memory</summary> + /// <summary>Tab 5 — Memory</summary> private async System.Threading.Tasks.Task RefreshMemoryAsync(int hoursBack, DateTime? fromDate, DateTime? toDate, bool subTabOnly = false) { try @@ -323,7 +356,7 @@ private async System.Threading.Tasks.Task RefreshMemoryAsync(int hoursBack, Date var memTrend = await Task.Run(() => _dataService.GetMemoryTrendAsync(_serverId, hoursBack, fromDate, toDate)); var memGrantTrend = await Task.Run(() => _dataService.GetMemoryGrantTrendAsync(_serverId, hoursBack, fromDate, toDate)); UpdateMemorySummary(memStats); - UpdateMemoryChart(memTrend, memGrantTrend); + UpdateMemoryChart(memTrend, memGrantTrend, hoursBack, fromDate, toDate); break; case 1: // Memory Clerks var clerkTypes = await Task.Run(() => _dataService.GetDistinctMemoryClerkTypesAsync(_serverId, hoursBack, fromDate, toDate)); @@ -332,7 +365,7 @@ private async System.Threading.Tasks.Task RefreshMemoryAsync(int hoursBack, Date break; case 2: // Memory Grants var grantChart = await Task.Run(() => _dataService.GetMemoryGrantChartDataAsync(_serverId, hoursBack, fromDate, toDate)); - UpdateMemoryGrantCharts(grantChart); + UpdateMemoryGrantCharts(grantChart, hoursBack, fromDate, toDate); break; case 3: // Memory Pressure Events var pressureEvents = await Task.Run(() => _dataService.GetMemoryPressureEventsAsync(_serverId, hoursBack, fromDate, toDate)); @@ -353,8 +386,8 @@ private async System.Threading.Tasks.Task RefreshMemoryAsync(int hoursBack, Date await System.Threading.Tasks.Task.WhenAll(memoryTask, memoryTrendTask, memoryClerkTypesTask, memoryGrantTrendTask, memoryGrantChartTask, memoryPressureEventsTask); UpdateMemorySummary(memoryTask.Result); - UpdateMemoryChart(memoryTrendTask.Result, memoryGrantTrendTask.Result); - UpdateMemoryGrantCharts(memoryGrantChartTask.Result); + UpdateMemoryChart(memoryTrendTask.Result, memoryGrantTrendTask.Result, hoursBack, fromDate, toDate); + UpdateMemoryGrantCharts(memoryGrantChartTask.Result, hoursBack, fromDate, toDate); UpdateMemoryPressureEventsChart(memoryPressureEventsTask.Result, hoursBack, fromDate, toDate); PopulateMemoryClerkPicker(memoryClerkTypesTask.Result); await UpdateMemoryClerksChartFromPickerAsync(); @@ -365,7 +398,7 @@ private async System.Threading.Tasks.Task RefreshMemoryAsync(int hoursBack, Date } } - /// <summary>Tab 5 — File I/O</summary> + /// <summary>Tab 6 — File I/O</summary> private async System.Threading.Tasks.Task RefreshFileIoAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) { try @@ -375,8 +408,8 @@ private async System.Threading.Tasks.Task RefreshFileIoAsync(int hoursBack, Date await System.Threading.Tasks.Task.WhenAll(fileIoTrendTask, fileIoThroughputTask); - UpdateFileIoCharts(fileIoTrendTask.Result); - UpdateFileIoThroughputCharts(fileIoThroughputTask.Result); + UpdateFileIoCharts(fileIoTrendTask.Result, hoursBack, fromDate, toDate); + UpdateFileIoThroughputCharts(fileIoThroughputTask.Result, hoursBack, fromDate, toDate); } catch (Exception ex) { @@ -384,7 +417,7 @@ private async System.Threading.Tasks.Task RefreshFileIoAsync(int hoursBack, Date } } - /// <summary>Tab 6 — TempDB</summary> + /// <summary>Tab 7 — TempDB</summary> private async System.Threading.Tasks.Task RefreshTempDbAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) { try @@ -394,9 +427,9 @@ private async System.Threading.Tasks.Task RefreshTempDbAsync(int hoursBack, Date await System.Threading.Tasks.Task.WhenAll(tempDbTask, tempDbFileIoTask); - UpdateTempDbChart(tempDbTask.Result); - UpdateTempDbSizeChart(tempDbTask.Result); - UpdateTempDbFileIoChart(tempDbFileIoTask.Result); + UpdateTempDbChart(tempDbTask.Result, hoursBack, fromDate, toDate); + UpdateTempDbSizeChart(tempDbTask.Result, hoursBack, fromDate, toDate); + UpdateTempDbFileIoChart(tempDbFileIoTask.Result, hoursBack, fromDate, toDate); } catch (Exception ex) { @@ -404,7 +437,7 @@ private async System.Threading.Tasks.Task RefreshTempDbAsync(int hoursBack, Date } } - /// <summary>Tab 7 — Blocking</summary> + /// <summary>Tab 8 — Blocking</summary> private async System.Threading.Tasks.Task RefreshBlockingAsync(int hoursBack, DateTime? fromDate, DateTime? toDate, bool subTabOnly = false) { try @@ -416,7 +449,7 @@ private async System.Threading.Tasks.Task RefreshBlockingAsync(int hoursBack, Da { case 0: // Trends — 3 trend charts var lwt = Helpers.MethodProfiler.TimeAsync("Locking.LockWaitTrend", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetLockWaitTrendAsync(_serverId, hoursBack, fromDate, toDate)))); - var bt = Helpers.MethodProfiler.TimeAsync("Locking.BlockingTrend", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetBlockingTrendAsync(_serverId, hoursBack, fromDate, toDate)))); + var bt = Helpers.MethodProfiler.TimeAsync("Locking.BlockingTrend", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetBlockingTrendAsync(_serverId, hoursBack, fromDate, toDate, SelectedDatabaseFilter)))); var dt = Helpers.MethodProfiler.TimeAsync("Locking.DeadlockTrend", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetDeadlockTrendAsync(_serverId, hoursBack, fromDate, toDate)))); await System.Threading.Tasks.Task.WhenAll(lwt, bt, dt); UpdateLockWaitTrendChart(lwt.Result, hoursBack, fromDate, toDate); @@ -425,13 +458,13 @@ private async System.Threading.Tasks.Task RefreshBlockingAsync(int hoursBack, Da break; case 1: // Current Waits — 2 charts var cwd = Helpers.MethodProfiler.TimeAsync("Locking.WaitingTaskTrend", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetWaitingTaskTrendAsync(_serverId, hoursBack, fromDate, toDate)))); - var cwb = Helpers.MethodProfiler.TimeAsync("Locking.BlockedSessionTrend", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetBlockedSessionTrendAsync(_serverId, hoursBack, fromDate, toDate)))); + var cwb = Helpers.MethodProfiler.TimeAsync("Locking.BlockedSessionTrend", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetBlockedSessionTrendAsync(_serverId, hoursBack, fromDate, toDate, SelectedDatabaseFilter)))); await System.Threading.Tasks.Task.WhenAll(cwd, cwb); UpdateCurrentWaitsDurationChart(cwd.Result, hoursBack, fromDate, toDate); UpdateCurrentWaitsBlockedChart(cwb.Result, hoursBack, fromDate, toDate); break; case 2: // Blocked Process Reports - var bpr = await Task.Run(() => _dataService.GetRecentBlockedProcessReportsAsync(_serverId, hoursBack, fromDate, toDate)); + var bpr = await Task.Run(() => _dataService.GetRecentBlockedProcessReportsAsync(_serverId, hoursBack, fromDate, toDate, SelectedDatabaseFilter)); using (Helpers.MethodProfiler.StartTiming("Locking.BindBlockedGrid")) _blockedProcessFilterMgr!.UpdateData(bpr); await LoadBlockingSlicerAsync(); @@ -443,6 +476,17 @@ private async System.Threading.Tasks.Task RefreshBlockingAsync(int hoursBack, Da _deadlockFilterMgr!.UpdateData(dlrDetails); await LoadDeadlockSlicerAsync(); break; + case 4: // Blocking Stats — blocking + deadlock severity (4 charts + summary strip) + var bdsStats = Helpers.MethodProfiler.TimeAsync("Locking.BlockingDurationStats", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetBlockingDurationStatsAsync(_serverId, hoursBack, fromDate, toDate, SelectedDatabaseFilter)))); + var bdsCount = Helpers.MethodProfiler.TimeAsync("Locking.DeadlockTrend", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetDeadlockTrendAsync(_serverId, hoursBack, fromDate, toDate)))); + var bdsSeverity = Helpers.MethodProfiler.TimeAsync("Locking.DeadlockSeverityStats", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetDeadlockSeverityStatsAsync(_serverId, hoursBack, fromDate, toDate)))); + await System.Threading.Tasks.Task.WhenAll(bdsStats, bdsCount, bdsSeverity); + UpdateBlockingDurationChart(bdsStats.Result, hoursBack, fromDate, toDate); + UpdateBlockingTotalDurationChart(bdsStats.Result, hoursBack, fromDate, toDate); + UpdateDeadlockWaitChart(bdsSeverity.Result, hoursBack, fromDate, toDate); + UpdateDeadlockTotalWaitChart(bdsSeverity.Result, hoursBack, fromDate, toDate); + UpdateBlockingStatsSummary(bdsStats.Result, bdsCount.Result, bdsSeverity.Result); + break; } /* Always keep alert badge current when Blocking tab is visible */ await RefreshAlertCountsAsync(hoursBack, fromDate, toDate); @@ -450,18 +494,21 @@ private async System.Threading.Tasks.Task RefreshBlockingAsync(int hoursBack, Da } /* Full refresh: load all sub-tabs */ - var blockedProcessTask = Helpers.MethodProfiler.TimeAsync("Locking.BlockedProcessReports", () => Task.Run(() => _dataService.GetRecentBlockedProcessReportsAsync(_serverId, hoursBack, fromDate, toDate))); + var blockedProcessTask = Helpers.MethodProfiler.TimeAsync("Locking.BlockedProcessReports", () => Task.Run(() => _dataService.GetRecentBlockedProcessReportsAsync(_serverId, hoursBack, fromDate, toDate, SelectedDatabaseFilter))); var deadlockTask = Helpers.MethodProfiler.TimeAsync("Locking.Deadlocks", () => Task.Run(() => _dataService.GetRecentDeadlocksAsync(_serverId, hoursBack, fromDate, toDate))); var lockWaitTrendTask = Helpers.MethodProfiler.TimeAsync("Locking.LockWaitTrend", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetLockWaitTrendAsync(_serverId, hoursBack, fromDate, toDate)))); - var blockingTrendTask = Helpers.MethodProfiler.TimeAsync("Locking.BlockingTrend", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetBlockingTrendAsync(_serverId, hoursBack, fromDate, toDate)))); + var blockingTrendTask = Helpers.MethodProfiler.TimeAsync("Locking.BlockingTrend", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetBlockingTrendAsync(_serverId, hoursBack, fromDate, toDate, SelectedDatabaseFilter)))); var deadlockTrendTask = Helpers.MethodProfiler.TimeAsync("Locking.DeadlockTrend", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetDeadlockTrendAsync(_serverId, hoursBack, fromDate, toDate)))); var currentWaitsDurationTask = Helpers.MethodProfiler.TimeAsync("Locking.WaitingTaskTrend", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetWaitingTaskTrendAsync(_serverId, hoursBack, fromDate, toDate)))); - var currentWaitsBlockedTask = Helpers.MethodProfiler.TimeAsync("Locking.BlockedSessionTrend", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetBlockedSessionTrendAsync(_serverId, hoursBack, fromDate, toDate)))); + var currentWaitsBlockedTask = Helpers.MethodProfiler.TimeAsync("Locking.BlockedSessionTrend", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetBlockedSessionTrendAsync(_serverId, hoursBack, fromDate, toDate, SelectedDatabaseFilter)))); + var blockingDurationStatsTask = Helpers.MethodProfiler.TimeAsync("Locking.BlockingDurationStats", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetBlockingDurationStatsAsync(_serverId, hoursBack, fromDate, toDate, SelectedDatabaseFilter)))); + var deadlockSeverityStatsTask = Helpers.MethodProfiler.TimeAsync("Locking.DeadlockSeverityStats", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetDeadlockSeverityStatsAsync(_serverId, hoursBack, fromDate, toDate)))); await System.Threading.Tasks.Task.WhenAll( blockedProcessTask, deadlockTask, lockWaitTrendTask, blockingTrendTask, deadlockTrendTask, - currentWaitsDurationTask, currentWaitsBlockedTask); + currentWaitsDurationTask, currentWaitsBlockedTask, + blockingDurationStatsTask, deadlockSeverityStatsTask); /* Parse deadlock graphs off the UI thread (this was the Blocking-tab hitch). Time the remaining UI-thread render steps so any new hot spot is pinpointed (bind vs charts). */ @@ -478,6 +525,14 @@ await System.Threading.Tasks.Task.WhenAll( UpdateDeadlockTrendChart(deadlockTrendTask.Result, hoursBack, fromDate, toDate); UpdateCurrentWaitsDurationChart(currentWaitsDurationTask.Result, hoursBack, fromDate, toDate); UpdateCurrentWaitsBlockedChart(currentWaitsBlockedTask.Result, hoursBack, fromDate, toDate); + /* Blocking Stats severity sub-tab (4 charts + summary strip): the block-duration aggregate + reconciles with the blocking-incident trend (same XE→DMV source), the deadlock severity with + the deadlock count (same v_deadlocks window). */ + UpdateBlockingDurationChart(blockingDurationStatsTask.Result, hoursBack, fromDate, toDate); + UpdateBlockingTotalDurationChart(blockingDurationStatsTask.Result, hoursBack, fromDate, toDate); + UpdateDeadlockWaitChart(deadlockSeverityStatsTask.Result, hoursBack, fromDate, toDate); + UpdateDeadlockTotalWaitChart(deadlockSeverityStatsTask.Result, hoursBack, fromDate, toDate); + UpdateBlockingStatsSummary(blockingDurationStatsTask.Result, deadlockTrendTask.Result, deadlockSeverityStatsTask.Result); } await LoadBlockingSlicerAsync(); @@ -523,7 +578,7 @@ private async System.Threading.Tasks.Task LoadBlockingSlicerAsync() } } - var data = await Task.Run(() => _dataService.GetBlockingSlicerDataAsync(_serverId, hoursBack, fromDate, toDate)); + var data = await Task.Run(() => _dataService.GetBlockingSlicerDataAsync(_serverId, hoursBack, fromDate, toDate, SelectedDatabaseFilter)); _blockingSlicerData = data; _blockingSlicerMetric = "Events"; var (slicerStart, slicerEnd) = GetSlicerTimeRange(hoursBack, fromDate, toDate); @@ -569,7 +624,7 @@ private async System.Threading.Tasks.Task LoadDeadlockSlicerAsync() } } - /// <summary>Tab 8 — Perfmon</summary> + /// <summary>Tab 9 — Perfmon</summary> private async System.Threading.Tasks.Task RefreshPerfmonAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) { try @@ -585,7 +640,7 @@ private async System.Threading.Tasks.Task RefreshPerfmonAsync(int hoursBack, Dat } } - /// <summary>Tab 9 — Running Jobs</summary> + /// <summary>Tab 10 — Running Jobs</summary> private async System.Threading.Tasks.Task RefreshRunningJobsAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) { try @@ -600,14 +655,14 @@ private async System.Threading.Tasks.Task RefreshRunningJobsAsync(int hoursBack, } } - /// <summary>Tab 10 — Configuration</summary> + /// <summary>Tab 11 — Configuration</summary> private async System.Threading.Tasks.Task RefreshConfigurationAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) { try { var serverConfigTask = Helpers.MethodProfiler.TimeAsync("Config.ServerConfig", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetLatestServerConfigAsync(_serverId)))); - var databaseConfigTask = Helpers.MethodProfiler.TimeAsync("Config.DatabaseConfig", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetLatestDatabaseConfigAsync(_serverId)))); - var databaseScopedConfigTask = Helpers.MethodProfiler.TimeAsync("Config.DatabaseScopedConfig", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetLatestDatabaseScopedConfigAsync(_serverId)))); + var databaseConfigTask = Helpers.MethodProfiler.TimeAsync("Config.DatabaseConfig", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetLatestDatabaseConfigAsync(_serverId, SelectedDatabaseFilter)))); + var databaseScopedConfigTask = Helpers.MethodProfiler.TimeAsync("Config.DatabaseScopedConfig", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetLatestDatabaseScopedConfigAsync(_serverId, SelectedDatabaseFilter)))); var traceFlagsTask = Helpers.MethodProfiler.TimeAsync("Config.TraceFlags", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetLatestTraceFlagsAsync(_serverId)))); await System.Threading.Tasks.Task.WhenAll(serverConfigTask, databaseConfigTask, databaseScopedConfigTask, traceFlagsTask); @@ -623,37 +678,25 @@ private async System.Threading.Tasks.Task RefreshConfigurationAsync(int hoursBac } } - /// <summary>Tab 11 — Daily Summary</summary> + /// <summary>Tab 12 — Daily Summary (Performance Calendar month heatmap).</summary> private async System.Threading.Tasks.Task RefreshDailySummaryAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) { - try - { - var dailySummaryTask = Task.Run(() => _dataService.GetDailySummaryAsync(_serverId, _dailySummaryDate)); - var dailySummary = await dailySummaryTask; - DailySummaryGrid.ItemsSource = dailySummary != null - ? new List<DailySummaryRow> { dailySummary } : null; - DailySummaryNoData.Visibility = dailySummary == null - ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed; - } - catch (Exception ex) - { - AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshDailySummaryAsync failed: {ex.Message}"); - } + await LoadCalendarMonthAsync(DailyCalendar.DisplayMonth); } - /// <summary>Tab 12 — Collection Health</summary> + /// <summary>Tab 17 — Collection Health</summary> private async System.Threading.Tasks.Task RefreshCollectionHealthAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) { try { var collectionHealthTask = Helpers.MethodProfiler.TimeAsync("CollectionHealth.Health", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetCollectionHealthAsync(_serverId)))); - var collectionLogTask = Helpers.MethodProfiler.TimeAsync("CollectionHealth.Log", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetRecentCollectionLogAsync(_serverId, hoursBack)))); + var collectionLogTask = Helpers.MethodProfiler.TimeAsync("CollectionHealth.Log", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetRecentCollectionLogAsync(_serverId, hoursBack, fromDate, toDate)))); await System.Threading.Tasks.Task.WhenAll(collectionHealthTask, collectionLogTask); _collectionHealthFilterMgr!.UpdateData(collectionHealthTask.Result); _collectionLogFilterMgr!.UpdateData(collectionLogTask.Result); - UpdateCollectorDurationChart(collectionLogTask.Result); + UpdateCollectorDurationChart(collectionLogTask.Result, hoursBack, fromDate, toDate); } catch (Exception ex) { diff --git a/Lite/Controls/ServerTab.SessionStats.cs b/Lite/Controls/ServerTab.SessionStats.cs new file mode 100644 index 000000000..f5923ed3c --- /dev/null +++ b/Lite/Controls/ServerTab.SessionStats.cs @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Controls; +using PerformanceMonitorLite.Services; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitorLite.Controls; + +/// <summary> +/// The Session Stats tab — the Lite port of the Darling viewer's Session Stats surface (itself the +/// Dashboard-parity port of ResourceMetricsContent's Session Stats sub-tab). A single trend chart plots +/// the server-wide session-status counts over the settable window — Total / Running / Sleeping / +/// Background / Dormant / Idle >30m / Waiting for Memory — and a summary strip below shows the +/// non-chartable attribution the latest snapshot carries: the top application and top host by connection +/// count, and the number of distinct databases with connections. The feed is the server-wide +/// v_session_summary_stats (1:1 with the Dashboard's session_stats table) — NOT the per-application +/// v_session_stats FinOps' Application Connections read uses. The chart BODY is the shared +/// <see cref="SessionStatsChartRenderer"/> (byte-identical to Darling's, each series on its own fixed +/// <see cref="ChartPalette"/> "Session*" key, all-zero series skipped); this partial keeps the per-app data +/// read, the hover wiring, the display-time projection it hands the renderer, and the summary strip (shaped +/// by the shared <see cref="SessionStatsSummary"/>). Loads on tab activation via the RefreshVisibleTabAsync switch. +/// </summary> +public partial class ServerTab : UserControl +{ + private ChartHoverHelper? _sessionStatsHover; + + private SessionStatsChartRenderer? _sessionStatsRendererField; + /// <summary>The shared Session Stats trend-chart renderer, bound to Lite's settable display-time offset.</summary> + private SessionStatsChartRenderer SessionStatsRenderer => + _sessionStatsRendererField ??= new SessionStatsChartRenderer(_chartHelper, t => t.AddMinutes(UtcOffsetMinutes)); + + /// <summary>Applies the shared chrome + hover to the Session Stats chart up front (constructor), so it + /// doesn't flash white before the tab's first load — matching the CPU/Memory/latch charts.</summary> + private void InitializeSessionStatsChart() + { + ApplyTheme(SessionStatsChart); + SessionStatsChart.Refresh(); + _sessionStatsHover = new ChartHoverHelper(SessionStatsChart, "sessions"); + } + + /// <summary>Loads the Session Stats tab over the toolbar's settable window: the session-summary + /// snapshots, then renders the status-count chart and the top-app / top-host / databases summary.</summary> + private async System.Threading.Tasks.Task RefreshSessionStatsAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + try + { + var data = await Helpers.MethodProfiler.TimeAsync("SessionStats.Trend", () => Task.Run(() => SafeQueryAsync(() => _dataService.GetSessionStatsAsync(_serverId, hoursBack, fromDate, toDate)))); + UpdateSessionStatsChart(data, hoursBack, fromDate, toDate); + } + catch (Exception ex) + { + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshSessionStatsAsync failed: {ex.Message}"); + } + } + + /// <summary>The session-status trend: hands the settable-window bounds + snapshots to the shared + /// <see cref="SessionStatsChartRenderer"/>, then updates the summary strip from the latest snapshot.</summary> + private void UpdateSessionStatsChart(List<SessionStatsPoint> data, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + DateTime rangeStart, rangeEnd; + if (fromDate.HasValue && toDate.HasValue) + { + rangeStart = fromDate.Value; + rangeEnd = toDate.Value; + } + else + { + rangeEnd = DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + rangeStart = rangeEnd.AddHours(-hoursBack); + } + + SessionStatsRenderer.Render(SessionStatsChart, _sessionStatsHover, data, rangeStart.ToOADate(), rangeEnd.ToOADate()); + + /* Summary panel reflects the latest snapshot in the window (Dashboard uses the last point). */ + var ordered = data.OrderBy(d => d.CollectionTime).ToList(); + UpdateSessionStatsSummary(ordered.Count > 0 ? ordered[^1] : null); + } + + /// <summary>Writes the latest snapshot's attribution into the summary strip (Dashboard parity: the + /// non-chartable Top Application / Top Host / Databases values), delegating the shaping to the shared + /// <see cref="SessionStatsSummary.Format"/>.</summary> + private void UpdateSessionStatsSummary(SessionStatsPoint? data) + { + var (topApp, topHost, databases) = SessionStatsSummary.Format(data); + SessionStatsTopAppText.Text = topApp; + SessionStatsTopHostText.Text = topHost; + SessionStatsDatabasesText.Text = databases; + } + + /// <summary>Tears down the Session Stats hover helper (mirrors the other tabs' dispose) so its tooltip + /// popup + chart event handlers don't outlive a closed server tab.</summary> + public void DisposeSessionStatsHelpers() + { + _sessionStatsHover?.Dispose(); + } +} diff --git a/Lite/Controls/ServerTab.Slicers.cs b/Lite/Controls/ServerTab.Slicers.cs index 7deef5a54..9181a7bcf 100644 --- a/Lite/Controls/ServerTab.Slicers.cs +++ b/Lite/Controls/ServerTab.Slicers.cs @@ -42,7 +42,7 @@ private async void OnBlockingSlicerChanged(object? sender, Controls.SlicerRangeE var fromServer = ServerTimeHelper.ToServerTime(e.StartUtc); var toServer = ServerTimeHelper.ToServerTime(e.EndUtc); - var bpr = await Task.Run(() => _dataService.GetRecentBlockedProcessReportsAsync(_serverId, 0, fromServer, toServer)); + var bpr = await Task.Run(() => _dataService.GetRecentBlockedProcessReportsAsync(_serverId, 0, fromServer, toServer, SelectedDatabaseFilter)); _blockedProcessFilterMgr!.UpdateData(bpr); } catch (Exception ex) @@ -95,7 +95,7 @@ private async System.Threading.Tasks.Task LoadActiveQueriesSlicerAsync() queryTo = toDate.Value.AddHours(1); } - var data = await Task.Run(() => _dataService.GetActiveQuerySlicerDataAsync(_serverId, hoursBack, queryFrom, queryTo)); + var data = await Task.Run(() => _dataService.GetActiveQuerySlicerDataAsync(_serverId, hoursBack, queryFrom, queryTo, SelectedDatabaseFilter)); _activeQueriesSlicerData = data; _activeQueriesSlicerMetric = "Sessions"; var (slicerStart, slicerEnd) = GetSlicerTimeRange(hoursBack, queryFrom, queryTo); @@ -119,7 +119,7 @@ private async void OnActiveQueriesSlicerChanged(object? sender, Controls.SlicerR var fromServer = ServerTimeHelper.ToServerTime(e.StartUtc); var toServer = ServerTimeHelper.ToServerTime(e.EndUtc); - var snapshots = await Task.Run(() => _dataService.GetLatestQuerySnapshotsAsync(_serverId, 0, fromServer, toServer)); + var snapshots = await Task.Run(() => _dataService.GetLatestQuerySnapshotsAsync(_serverId, 0, fromServer, toServer, SelectedDatabaseFilter)); _querySnapshotsFilterMgr!.UpdateData(snapshots); LiveSnapshotIndicator.Text = ""; } @@ -151,7 +151,7 @@ private async System.Threading.Tasks.Task LoadQueryStatsSlicerAsync() } } - var data = await Task.Run(() => _dataService.GetQueryStatsSlicerDataAsync(_serverId, hoursBack, fromDate, toDate)); + var data = await Task.Run(() => _dataService.GetQueryStatsSlicerDataAsync(_serverId, hoursBack, fromDate, toDate, SelectedDatabaseFilter)); _queryStatsSlicerData = data; _queryStatsSlicerMetric = "TotalCpu"; var (slicerStart, slicerEnd) = GetSlicerTimeRange(hoursBack, fromDate, toDate); @@ -170,7 +170,7 @@ private async void OnQueryStatsSlicerChanged(object? sender, Controls.SlicerRang { var fromServer = ServerTimeHelper.ToServerTime(e.StartUtc); var toServer = ServerTimeHelper.ToServerTime(e.EndUtc); - var queryStats = await Task.Run(() => _dataService.GetTopQueriesByCpuAsync(_serverId, 0, 50, fromServer, toServer, UtcOffsetMinutes)); + var queryStats = await Task.Run(() => _dataService.GetTopQueriesByCpuAsync(_serverId, 0, 50, fromServer, toServer, UtcOffsetMinutes, SelectedDatabaseFilter)); _queryStatsFilterMgr!.UpdateData(queryStats); await RefreshQueryStatsComparisonAsync(fromServer, toServer); } @@ -202,7 +202,7 @@ private async System.Threading.Tasks.Task LoadQueryStoreSlicerAsync() } } - var data = await Task.Run(() => _dataService.GetQueryStoreSlicerDataAsync(_serverId, hoursBack, fromDate, toDate)); + var data = await Task.Run(() => _dataService.GetQueryStoreSlicerDataAsync(_serverId, hoursBack, fromDate, toDate, SelectedDatabaseFilter)); _queryStoreSlicerData = data; _queryStoreSlicerMetric = "TotalCpu"; var (slicerStart, slicerEnd) = GetSlicerTimeRange(hoursBack, fromDate, toDate); @@ -221,7 +221,7 @@ private async void OnQueryStoreSlicerChanged(object? sender, Controls.SlicerRang { var fromServer = ServerTimeHelper.ToServerTime(e.StartUtc); var toServer = ServerTimeHelper.ToServerTime(e.EndUtc); - var qsData = await Task.Run(() => _dataService.GetQueryStoreTopQueriesAsync(_serverId, 0, 50, fromServer, toServer)); + var qsData = await Task.Run(() => _dataService.GetQueryStoreTopQueriesAsync(_serverId, 0, 50, fromServer, toServer, SelectedDatabaseFilter)); _queryStoreFilterMgr!.UpdateData(qsData); await RefreshQueryStoreComparisonAsync(fromServer, toServer); } @@ -253,7 +253,7 @@ private async System.Threading.Tasks.Task LoadProcStatsSlicerAsync() } } - var data = await Task.Run(() => _dataService.GetProcStatsSlicerDataAsync(_serverId, hoursBack, fromDate, toDate)); + var data = await Task.Run(() => _dataService.GetProcStatsSlicerDataAsync(_serverId, hoursBack, fromDate, toDate, SelectedDatabaseFilter)); _procStatsSlicerData = data; _procStatsSlicerMetric = "TotalCpu"; var (slicerStart, slicerEnd) = GetSlicerTimeRange(hoursBack, fromDate, toDate); @@ -272,7 +272,7 @@ private async void OnProcStatsSlicerChanged(object? sender, Controls.SlicerRange { var fromServer = ServerTimeHelper.ToServerTime(e.StartUtc); var toServer = ServerTimeHelper.ToServerTime(e.EndUtc); - var procStats = await Task.Run(() => _dataService.GetTopProceduresByCpuAsync(_serverId, 0, 50, fromServer, toServer, UtcOffsetMinutes)); + var procStats = await Task.Run(() => _dataService.GetTopProceduresByCpuAsync(_serverId, 0, 50, fromServer, toServer, UtcOffsetMinutes, SelectedDatabaseFilter)); _procStatsFilterMgr!.UpdateData(procStats); await RefreshProcStatsComparisonAsync(fromServer, toServer); } diff --git a/Lite/Controls/ServerTab.SystemEvents.cs b/Lite/Controls/ServerTab.SystemEvents.cs new file mode 100644 index 000000000..9f30781ec --- /dev/null +++ b/Lite/Controls/ServerTab.SystemEvents.cs @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using PerformanceMonitorLite.Helpers; +using PerformanceMonitorLite.Services; + +namespace PerformanceMonitorLite.Controls; + +/// <summary> +/// The System Events tab (system_health parity) — the Lite port of the Darling viewer's System Events tab, +/// itself the faithful reproduction of the full Dashboard's System Events. Eleven inner sub-tabs cover all +/// the Dashboard categories. The two lead sub-tabs (Corruption Events + Contention Events) are the +/// SYSTEM-component counter CHARTS rendered from a single sp_server_diagnostics SYSTEM feed with no +/// significance filter (every snapshot plotted over time), wired in <c>ServerTab.SystemHealthCharts.cs</c>. +/// The remaining nine sub-tabs are parse-on-read GRIDS, one per warning category: the reader +/// (<see cref="LocalDataService"/>) fetches the raw event_xml for the category over the toolbar's window +/// from v_system_health_events, the Common <c>SystemHealthParser</c> shreds it, and the shared +/// <c>SystemHealthSignificance</c> keeps the sp_HealthParser-significant rows. Loads the ACTIVE sub-tab only +/// (Lite's visible-only rule) via the RefreshVisibleTabAsync switch (case 18); a sub-tab switch reloads +/// through MainTabControl_SelectionChanged (SystemEventsSubTabControl is in its e.Source allow-list). Reads +/// run off the UI thread (Task.Run), mirroring the deadlock-graph parse. +/// </summary> +public partial class ServerTab : UserControl +{ + /* System Events sub-tab order (matches the XAML TabItem order — Corruption + Contention lead, the two + SYSTEM-component counter-chart tabs sharing one feed, then the grid categories in Darling's order). */ + private const int SystemEventsCorruptionSubTabIndex = 0; + private const int SystemEventsContentionSubTabIndex = 1; + private const int SystemEventsSchedulerIssuesSubTabIndex = 2; + private const int SystemEventsSevereErrorsSubTabIndex = 3; + private const int SystemEventsMemoryConditionsSubTabIndex = 4; + private const int SystemEventsMemoryBrokerSubTabIndex = 5; + private const int SystemEventsMemoryNodeOomSubTabIndex = 6; + private const int SystemEventsSignificantWaitsSubTabIndex = 7; + private const int SystemEventsCpuTasksSubTabIndex = 8; + private const int SystemEventsIoIssuesSubTabIndex = 9; + private const int SystemEventsDefaultTraceSubTabIndex = 10; + + /// <summary> + /// Tab 18 — System Events. Loads the ACTIVE sub-tab only. The chart sub-tabs (Corruption / Contention) + /// share the one SYSTEM feed, so both render from a single load — mirroring the Dashboard's + /// one-refresh-feeds-both. + /// </summary> + private async System.Threading.Tasks.Task RefreshSystemEventsAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + try + { + switch (SystemEventsSubTabControl.SelectedIndex) + { + case SystemEventsSchedulerIssuesSubTabIndex: + await LoadSchedulerIssuesAsync(hoursBack, fromDate, toDate); + break; + case SystemEventsSevereErrorsSubTabIndex: + await LoadSevereErrorsAsync(hoursBack, fromDate, toDate); + break; + case SystemEventsMemoryConditionsSubTabIndex: + await LoadMemoryConditionsAsync(hoursBack, fromDate, toDate); + break; + case SystemEventsMemoryBrokerSubTabIndex: + await LoadMemoryBrokerAsync(hoursBack, fromDate, toDate); + break; + case SystemEventsMemoryNodeOomSubTabIndex: + await LoadMemoryNodeOomAsync(hoursBack, fromDate, toDate); + break; + case SystemEventsSignificantWaitsSubTabIndex: + await LoadSignificantWaitsAsync(hoursBack, fromDate, toDate); + break; + case SystemEventsCpuTasksSubTabIndex: + await LoadCpuTasksAsync(hoursBack, fromDate, toDate); + break; + case SystemEventsIoIssuesSubTabIndex: + await LoadIoIssuesAsync(hoursBack, fromDate, toDate); + break; + case SystemEventsDefaultTraceSubTabIndex: + await LoadDefaultTraceEventsAsync(hoursBack, fromDate, toDate); + break; + // Corruption Events (0) and Contention Events (1) share the one SYSTEM feed, so both render + // from a single load — this is also the default, so the first-shown sub-tab loads on activation. + case SystemEventsCorruptionSubTabIndex: + case SystemEventsContentionSubTabIndex: + default: + await RefreshSystemHealthChartsAsync(hoursBack, fromDate, toDate); + break; + } + } + catch (Exception ex) + { + AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshSystemEventsAsync failed: {ex.Message}"); + } + } + + private async System.Threading.Tasks.Task LoadSchedulerIssuesAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + var data = await Task.Run(() => _dataService.GetSchedulerIssuesAsync(_serverId, hoursBack, fromDate, toDate)); + _seSchedulerFilterMgr!.UpdateData(data); + SchedulerIssuesNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + SchedulerIssuesCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : ""; + } + + private async System.Threading.Tasks.Task LoadSevereErrorsAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + var data = await Task.Run(() => _dataService.GetSevereErrorsAsync(_serverId, hoursBack, fromDate, toDate, SelectedDatabaseFilter)); + _seSevereErrorFilterMgr!.UpdateData(data); + SevereErrorsNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + SevereErrorsCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : ""; + } + + private async System.Threading.Tasks.Task LoadMemoryConditionsAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + var data = await Task.Run(() => _dataService.GetMemoryConditionsAsync(_serverId, hoursBack, fromDate, toDate)); + _seMemoryConditionsFilterMgr!.UpdateData(data); + MemoryConditionsNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + MemoryConditionsCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : ""; + } + + private async System.Threading.Tasks.Task LoadMemoryBrokerAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + var data = await Task.Run(() => _dataService.GetMemoryBrokerAsync(_serverId, hoursBack, fromDate, toDate)); + _seMemoryBrokerFilterMgr!.UpdateData(data); + MemoryBrokerNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + MemoryBrokerCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : ""; + } + + private async System.Threading.Tasks.Task LoadMemoryNodeOomAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + var data = await Task.Run(() => _dataService.GetMemoryNodeOomAsync(_serverId, hoursBack, fromDate, toDate)); + _seMemoryNodeOomFilterMgr!.UpdateData(data); + MemoryNodeOomNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + MemoryNodeOomCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : ""; + } + + private async System.Threading.Tasks.Task LoadSignificantWaitsAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + var data = await Task.Run(() => _dataService.GetSignificantWaitsAsync(_serverId, hoursBack, fromDate, toDate)); + _seSignificantWaitsFilterMgr!.UpdateData(data); + SignificantWaitsNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + SignificantWaitsCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : ""; + } + + private async System.Threading.Tasks.Task LoadCpuTasksAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + var data = await Task.Run(() => _dataService.GetCpuTasksAsync(_serverId, hoursBack, fromDate, toDate)); + _seCpuTasksFilterMgr!.UpdateData(data); + CpuTasksNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + CpuTasksCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : ""; + } + + private async System.Threading.Tasks.Task LoadIoIssuesAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + var data = await Task.Run(() => _dataService.GetIoIssuesAsync(_serverId, hoursBack, fromDate, toDate)); + _seIoIssuesFilterMgr!.UpdateData(data); + IoIssuesNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + IoIssuesCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : ""; + } + + private async System.Threading.Tasks.Task LoadDefaultTraceEventsAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + var data = await Task.Run(() => _dataService.GetDefaultTraceEventsAsync(_serverId, hoursBack, fromDate, toDate, SelectedDatabaseFilter)); + _seDefaultTraceFilterMgr!.UpdateData(data); + DefaultTraceNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + DefaultTraceCountIndicator.Text = data.Count > 0 ? $"{data.Count} event(s)" : ""; + } + + /// <summary>The per-sub-tab Refresh button reloads the active System Events sub-tab over the toolbar's + /// current window (mirrors the other tabs' toolbar-driven refresh).</summary> + private async void SystemEventsRefresh_Click(object sender, RoutedEventArgs e) + { + var hoursBack = GetHoursBack(); + DateTime? fromDate = null, toDate = null; + if (IsCustomRange) + { + var fromLocal = GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo); + var toLocal = GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo); + if (fromLocal.HasValue && toLocal.HasValue) + { + fromDate = ServerTimeHelper.DisplayTimeToServerTime(fromLocal.Value, ServerTimeHelper.CurrentDisplayMode); + toDate = ServerTimeHelper.DisplayTimeToServerTime(toLocal.Value, ServerTimeHelper.CurrentDisplayMode); + } + } + + await RefreshSystemEventsAsync(hoursBack, fromDate, toDate); + } +} diff --git a/Lite/Controls/ServerTab.SystemHealthCharts.cs b/Lite/Controls/ServerTab.SystemHealthCharts.cs new file mode 100644 index 000000000..0af3ab100 --- /dev/null +++ b/Lite/Controls/ServerTab.SystemHealthCharts.cs @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Controls; +using PerformanceMonitorLite.Services; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; + +namespace PerformanceMonitorLite.Controls; + +/// <summary> +/// The System Events tab's two SYSTEM-component counter-chart sub-tabs — Corruption Events and Contention +/// Events — the Lite port of the Darling viewer's SystemHealthCharts (itself the Dashboard's Corruption / +/// Contention 2x2 chart grids). Both sub-tabs read the ONE SYSTEM-component feed +/// (<see cref="LocalDataService.GetSystemHealthAsync"/>) and select their own columns, so a single load +/// renders all eight charts. Unlike the grid categories these plot EVERY collected snapshot over time (no +/// significance filter); counts floor at 0 and the time axis is bound to the toolbar's settable window. The +/// chart BODIES are the shared <see cref="SystemHealthChartRenderer"/> (byte-identical to Darling's); this +/// partial keeps the per-app data read, the hover wiring, and the display-time projection it hands the renderer. +/// </summary> +public partial class ServerTab : UserControl +{ + private ChartHoverHelper? _badPagesHover; + private ChartHoverHelper? _dumpRequestsHover; + private ChartHoverHelper? _accessViolationsHover; + private ChartHoverHelper? _writeAccessViolationsHover; + private ChartHoverHelper? _nonYieldingTasksHover; + private ChartHoverHelper? _latchWarningsHover; + private ChartHoverHelper? _sickSpinlocksHover; + private ChartHoverHelper? _cpuComparisonHover; + + private SystemHealthChartRenderer? _sysHealthRendererField; + /// <summary>The shared System Events counter-chart renderer, bound to Lite's settable display-time offset.</summary> + private SystemHealthChartRenderer SysHealthRenderer => + _sysHealthRendererField ??= new SystemHealthChartRenderer(_chartHelper, t => t.AddMinutes(UtcOffsetMinutes)); + + /// <summary>Applies the shared chrome + hover to the eight Corruption/Contention charts up front + /// (constructor), so they don't flash white before the tab's first load — matching the CPU/Latch charts. + /// Hover units mirror the Dashboard's (events / backoffs / %).</summary> + private void InitializeSystemHealthCharts() + { + foreach (var chart in new[] + { + BadPagesChart, DumpRequestsChart, AccessViolationsChart, WriteAccessViolationsChart, + NonYieldingTasksChart, LatchWarningsChart, SickSpinlocksChart, CpuComparisonChart, + }) + { + ApplyTheme(chart); + chart.Refresh(); + } + + _badPagesHover = new ChartHoverHelper(BadPagesChart, "events"); + _dumpRequestsHover = new ChartHoverHelper(DumpRequestsChart, "events"); + _accessViolationsHover = new ChartHoverHelper(AccessViolationsChart, "events"); + _writeAccessViolationsHover = new ChartHoverHelper(WriteAccessViolationsChart, "events"); + _nonYieldingTasksHover = new ChartHoverHelper(NonYieldingTasksChart, "events"); + _latchWarningsHover = new ChartHoverHelper(LatchWarningsChart, "events"); + _sickSpinlocksHover = new ChartHoverHelper(SickSpinlocksChart, "backoffs"); + _cpuComparisonHover = new ChartHoverHelper(CpuComparisonChart, "%"); + } + + /// <summary> + /// Loads both Corruption + Contention chart sub-tabs from a single SYSTEM-component read over the + /// toolbar's settable window. Mirrors the Dashboard's one-refresh-feeds-both. + /// </summary> + private async System.Threading.Tasks.Task RefreshSystemHealthChartsAsync(int hoursBack, DateTime? fromDate, DateTime? toDate) + { + var data = await Task.Run(() => SafeQueryAsync(() => _dataService.GetSystemHealthAsync(_serverId, hoursBack, fromDate, toDate))); + + DateTime rangeStart, rangeEnd; + if (fromDate.HasValue && toDate.HasValue) + { + rangeStart = fromDate.Value; + rangeEnd = toDate.Value; + } + else + { + rangeEnd = DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + rangeStart = rangeEnd.AddHours(-hoursBack); + } + + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + // Corruption Events (2x2). + SysHealthRenderer.RenderCounterChart(BadPagesChart, _badPagesHover, data, d => d.BadPagesDetected, "Bad Pages", + ChartPalette.CyclingColor(3), xMin, xMax); + SysHealthRenderer.RenderCounterChart(DumpRequestsChart, _dumpRequestsHover, data, d => d.IntervalDumpRequests, "Dump Requests", + ChartPalette.CyclingColor(2), xMin, xMax); + SysHealthRenderer.RenderCounterChart(AccessViolationsChart, _accessViolationsHover, data, d => d.IsAccessViolationOccurred, "Access Violations", + ChartPalette.CyclingColor(4), xMin, xMax); + SysHealthRenderer.RenderCounterChart(WriteAccessViolationsChart, _writeAccessViolationsHover, data, d => d.WriteAccessViolationCount, "Write Access Violations", + ChartPalette.CyclingColor(0), xMin, xMax); + + // Contention Events (2x2). + SysHealthRenderer.RenderCounterChart(NonYieldingTasksChart, _nonYieldingTasksHover, data, d => d.NonYieldingTasksReported, "Non-Yielding Tasks", + ChartPalette.CyclingColor(3), xMin, xMax); + SysHealthRenderer.RenderCounterChart(LatchWarningsChart, _latchWarningsHover, data, d => d.LatchWarnings, "Latch Warnings", + ChartPalette.CyclingColor(2), xMin, xMax); + SysHealthRenderer.RenderSickSpinlocksChart(SickSpinlocksChart, _sickSpinlocksHover, data, xMin, xMax); + SysHealthRenderer.RenderCpuComparisonChart(CpuComparisonChart, _cpuComparisonHover, data, xMin, xMax); + } + + /// <summary>Tears down the eight Corruption/Contention hover helpers (mirrors the other tabs' dispose) + /// so their tooltip popups + chart event handlers don't outlive a closed server tab.</summary> + public void DisposeSystemHealthChartHelpers() + { + _badPagesHover?.Dispose(); + _dumpRequestsHover?.Dispose(); + _accessViolationsHover?.Dispose(); + _writeAccessViolationsHover?.Dispose(); + _nonYieldingTasksHover?.Dispose(); + _latchWarningsHover?.Dispose(); + _sickSpinlocksHover?.Dispose(); + _cpuComparisonHover?.Dispose(); + } +} diff --git a/Lite/Controls/ServerTab.TimeRange.cs b/Lite/Controls/ServerTab.TimeRange.cs index b5066ebf3..50469c934 100644 --- a/Lite/Controls/ServerTab.TimeRange.cs +++ b/Lite/Controls/ServerTab.TimeRange.cs @@ -69,15 +69,6 @@ private void InitializeTimeComboBoxes() return date.AddHours(hour).AddMinutes(minute); } - private void SetPickersFromDateTime(DateTime serverTime, DatePicker datePicker, ComboBox hourCombo, ComboBox minuteCombo) - { - /* Convert server time to the current display mode for UI */ - var displayTime = ServerTimeHelper.ConvertForDisplay(serverTime, ServerTimeHelper.CurrentDisplayMode); - datePicker.SelectedDate = displayTime.Date; - hourCombo.SelectedIndex = displayTime.Hour; - minuteCombo.SelectedIndex = displayTime.Minute / 15; - } - /// <summary> /// Gets the selected time range in hours. /// </summary> diff --git a/Lite/Controls/ServerTab.xaml b/Lite/Controls/ServerTab.xaml index 1234b5e3c..0b540f5db 100644 --- a/Lite/Controls/ServerTab.xaml +++ b/Lite/Controls/ServerTab.xaml @@ -6,6 +6,8 @@ xmlns:controls="clr-namespace:PerformanceMonitorLite.Controls"> <UserControl.Resources> <ResourceDictionary> + <BooleanToVisibilityConverter x:Key="BoolToVis"/> + <!-- Context Menu for DataGrid Copy --> <ContextMenu x:Key="DataGridContextMenu"> <MenuItem Header="Copy Cell" Click="CopyCell_Click"> @@ -24,11 +26,39 @@ <MenuItem Header="Export to CSV..." Click="ExportToCsv_Click"> <MenuItem.Icon><TextBlock Text="📊"/></MenuItem.Icon> </MenuItem> + </ContextMenu> + + <!-- PLAN variant, for the query grids whose row types the plan handlers actually support + (ServerTab.Plans.cs switches on row type). The plain DataGridContextMenu above is the + DEFAULT for the ~21 config / health / non-query grids, which carry no plan at all: the + plan verbs used to sit on EVERY grid via GridRowStyle and silently no-op there (the + handler's switch has no case for a ServerConfig / Latch / Memory row). "Get Actual Plan + (re-run)" is additionally HIDDEN per-row on CanGetActualPlan, because the re-run handler + supports fewer row types than View Stored Plan does - the Procedure rows have no re-run + case, so the binding falls back to Collapsed there. Mirrors the Darling viewer's menus. --> + <ContextMenu x:Key="DataGridPlanContextMenu"> + <MenuItem Header="Copy Cell" Click="CopyCell_Click"> + <MenuItem.Icon><TextBlock Text="📋"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy Row" Click="CopyRow_Click"> + <MenuItem.Icon><TextBlock Text="📄"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy All Rows" Click="CopyAllRows_Click"> + <MenuItem.Icon><TextBlock Text="📑"/></MenuItem.Icon> + </MenuItem> + <MenuItem Header="Copy Repro Script" Click="CopyReproScript_Click"> + <MenuItem.Icon><TextBlock Text="📝"/></MenuItem.Icon> + </MenuItem> + <Separator/> + <MenuItem Header="Export to CSV..." Click="ExportToCsv_Click"> + <MenuItem.Icon><TextBlock Text="📊"/></MenuItem.Icon> + </MenuItem> <Separator/> - <MenuItem Header="View Plan" Click="ViewEstimatedPlan_Click"> + <MenuItem Header="View Stored Plan" Click="ViewEstimatedPlan_Click"> <MenuItem.Icon><TextBlock Text="🔍"/></MenuItem.Icon> </MenuItem> - <MenuItem Header="Get Actual Plan" Click="GetActualPlan_Click"> + <MenuItem Header="Get Actual Plan (re-run)" Click="GetActualPlan_Click" + Visibility="{Binding PlacementTarget.DataContext.CanGetActualPlan, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVis}, FallbackValue=Collapsed}"> <MenuItem.Icon><TextBlock Text="▶"/></MenuItem.Icon> </MenuItem> </ContextMenu> @@ -83,7 +113,7 @@ <MenuItem Header="View Deadlock Graph" Click="ViewDeadlockGraph_Click"> <MenuItem.Icon><TextBlock Text="🕸"/></MenuItem.Icon> </MenuItem> - <MenuItem Header="View Plan" Click="ViewDeadlockProcessPlan_Click"> + <MenuItem Header="View Stored Plan" Click="ViewDeadlockProcessPlan_Click"> <MenuItem.Icon><TextBlock Text="🔍"/></MenuItem.Icon> </MenuItem> </ContextMenu> @@ -92,6 +122,11 @@ <Setter Property="ContextMenu" Value="{StaticResource DataGridContextMenu}"/> </Style> + <!-- Row style for the query grids that actually have plans (see DataGridPlanContextMenu). --> + <Style x:Key="GridPlanRowStyle" TargetType="DataGridRow"> + <Setter Property="ContextMenu" Value="{StaticResource DataGridPlanContextMenu}"/> + </Style> + <!-- Row style for blocking grid - highlight long-duration blocks --> <Style x:Key="BlockingRowStyle" TargetType="DataGridRow"> <Setter Property="ContextMenu" Value="{StaticResource BlockedProcessContextMenu}"/> @@ -106,15 +141,6 @@ <Style x:Key="DeadlockRowStyle" TargetType="DataGridRow"> <Setter Property="ContextMenu" Value="{StaticResource DeadlockContextMenu}"/> </Style> - - <!-- Row style for wait stats - highlight high-wait types --> - <Style x:Key="WaitStatsRowStyle" TargetType="DataGridRow" BasedOn="{StaticResource GridRowStyle}"> - <Style.Triggers> - <DataTrigger Binding="{Binding IsHighWait}" Value="True"> - <Setter Property="Background" Value="#33FFA500"/> - </DataTrigger> - </Style.Triggers> - </Style> </ResourceDictionary> </UserControl.Resources> @@ -140,6 +166,47 @@ </StackPanel> <StackPanel Grid.Column="1" Orientation="Horizontal"> + <!-- #1319: per-server global database filter (display-only) --> + <Button x:Name="DatabaseFilterButton" Content="Databases: All" Click="DatabaseFilterButton_Click" + Padding="8,4" Margin="0,0,8,0" + ToolTip="Filter database-scoped views (queries, blocking, per-database config, trends) to selected databases. No effect on server-wide views such as CPU, memory, waits, or jobs."/> + <Popup x:Name="DatabaseFilterPopup" PlacementTarget="{Binding ElementName=DatabaseFilterButton}" + Placement="Bottom" StaysOpen="False" AllowsTransparency="True" + Closed="DatabaseFilterPopup_Closed"> + <Border Background="{DynamicResource BackgroundDarkBrush}" BorderBrush="{DynamicResource BorderBrush}" + BorderThickness="1" CornerRadius="4" Padding="8" Width="300"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + <TextBlock Grid.Row="0" Text="Filter Databases" FontWeight="SemiBold" Margin="0,0,0,4" + Foreground="{DynamicResource ForegroundBrush}"/> + <StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,0,0,4"> + <Button Content="Select All" Click="DatabaseFilterSelectAll_Click" Padding="6,2" Margin="0,0,4,0" FontSize="11"/> + <Button Content="Clear All" Click="DatabaseFilterClearAll_Click" Padding="6,2" FontSize="11"/> + <TextBlock x:Name="DatabaseFilterCountText" Text="All databases" FontSize="10" + Foreground="{DynamicResource ForegroundMutedBrush}" VerticalAlignment="Center" Margin="8,0,0,0"/> + </StackPanel> + <TextBox Grid.Row="2" x:Name="DatabaseFilterSearchBox" TextChanged="DatabaseFilterSearch_TextChanged" + Margin="0,0,0,4" Padding="4,2"/> + <ListBox Grid.Row="3" x:Name="DatabaseFilterList" Background="Transparent" BorderThickness="0" + MaxHeight="320" ScrollViewer.VerticalScrollBarVisibility="Auto"> + <ListBox.ItemTemplate> + <DataTemplate> + <CheckBox IsChecked="{Binding IsSelected, Mode=TwoWay}" + Checked="DatabaseFilter_CheckChanged" Unchecked="DatabaseFilter_CheckChanged" + Foreground="{DynamicResource ForegroundBrush}" FontSize="11"> + <TextBlock Text="{Binding DisplayName}"/> + </CheckBox> + </DataTemplate> + </ListBox.ItemTemplate> + </ListBox> + </Grid> + </Border> + </Popup> <ComboBox x:Name="TimeRangeCombo" SelectedIndex="1" Width="120" Margin="0,0,8,0" SelectionChanged="TimeRangeCombo_SelectionChanged"> <ComboBoxItem Content="Last 1 hour"/> @@ -307,7 +374,7 @@ </StackPanel> <DataGrid Grid.Row="2" x:Name="QuerySnapshotsGrid" AutoGenerateColumns="False" IsReadOnly="True" - RowStyle="{StaticResource GridRowStyle}" + RowStyle="{StaticResource GridPlanRowStyle}" HeadersVisibility="Column" GridLinesVisibility="Horizontal" CanUserSortColumns="True" Sorting="QuerySnapshotsGrid_Sorting"> @@ -378,6 +445,9 @@ <DataGridTextColumn Binding="{Binding PercentComplete, StringFormat=F1}" ElementStyle="{StaticResource NumericCell}" Width="70"> <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PercentComplete" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="% Done" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding QueryHash}" Width="150"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="QueryHash" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Query Hash" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> <DataGridTemplateColumn Width="500"> <DataGridTemplateColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="QueryText" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Query Text" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTemplateColumn.Header> <DataGridTemplateColumn.CellTemplate> @@ -395,7 +465,7 @@ IsEnabled="{Binding HasQueryPlan}" VerticalAlignment="Center" Padding="6,4" Margin="0,0,4,0"/> - <Button Content="Actual" Click="DownloadSnapshotLivePlan_Click" + <Button Content="Live" Click="DownloadSnapshotLivePlan_Click" IsEnabled="{Binding HasLiveQueryPlan}" VerticalAlignment="Center" Padding="6,4"/> @@ -428,7 +498,7 @@ </StackPanel> <DataGrid Grid.Row="2" x:Name="QueryStatsGrid" AutoGenerateColumns="False" IsReadOnly="True" - RowStyle="{StaticResource GridRowStyle}" + RowStyle="{StaticResource GridPlanRowStyle}" HeadersVisibility="Column" GridLinesVisibility="Horizontal" CanUserSortColumns="True" SelectionChanged="QueryStatsGrid_SelectionChanged" @@ -438,6 +508,9 @@ <DataGridTextColumn Binding="{Binding DatabaseName}" Width="120"> <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DatabaseName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Database" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ModuleName}" Width="200"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ModuleName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Module" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> <DataGridTextColumn Binding="{Binding LastExecutionTimeLocal}" Width="140"> <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LastExecutionTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Last Execution" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> </DataGridTextColumn> @@ -459,6 +532,15 @@ <DataGridTextColumn Binding="{Binding AvgCpuMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgCpuMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding WorkerTimePerSecond, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="WorkerTimePerSecond" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Peak CPU (ms/s)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PlanGenerationNum, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PlanGenerationNum" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Plan Gen" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalClrMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalClrMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total CLR (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> <DataGridTemplateColumn SortMemberPath="TotalElapsedMs" Width="130"> <DataGridTemplateColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalElapsedMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total Duration (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTemplateColumn.Header> <DataGridTemplateColumn.CellTemplate><DataTemplate> @@ -519,6 +601,18 @@ <DataGridTextColumn Binding="{Binding MaxGrantKb, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"> <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxGrantKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Grant KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinUsedGrantKb, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinUsedGrantKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Used Grant KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxUsedGrantKb, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxUsedGrantKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Used Grant KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinIdealGrantKb, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinIdealGrantKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Ideal Grant KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxIdealGrantKb, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxIdealGrantKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Ideal Grant KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> <DataGridTextColumn Binding="{Binding MinSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="80"> <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinSpills" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Spills" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> </DataGridTextColumn> @@ -531,6 +625,18 @@ <DataGridTextColumn Binding="{Binding MaxDop}" ElementStyle="{StaticResource NumericCell}" Width="70"> <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxDop" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max DOP" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinReservedThreads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinReservedThreads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Rsvd Threads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxReservedThreads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxReservedThreads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Rsvd Threads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinUsedThreads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinUsedThreads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Used Threads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxUsedThreads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxUsedThreads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Used Threads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> <DataGridTextColumn Binding="{Binding QueryHash}" Width="140"> <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="QueryHash" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Query Hash" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> </DataGridTextColumn> @@ -565,7 +671,7 @@ </DataGrid> <DataGrid Grid.Row="2" x:Name="QueryStatsComparisonGrid" Visibility="Collapsed" AutoGenerateColumns="False" IsReadOnly="True" - RowStyle="{StaticResource GridRowStyle}" + RowStyle="{StaticResource GridPlanRowStyle}" HeadersVisibility="Column" GridLinesVisibility="Horizontal" CanUserSortColumns="True"> <DataGrid.Columns> @@ -687,7 +793,7 @@ Background="#2244AAFF"/> <DataGrid Grid.Row="2" x:Name="ProcedureStatsGrid" AutoGenerateColumns="False" IsReadOnly="True" - RowStyle="{StaticResource GridRowStyle}" + RowStyle="{StaticResource GridPlanRowStyle}" HeadersVisibility="Column" GridLinesVisibility="Horizontal" CanUserSortColumns="True" SelectionChanged="ProcedureStatsGrid_SelectionChanged" @@ -739,6 +845,9 @@ <DataGridTextColumn Binding="{Binding TotalSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"> <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalSpills" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total Spills" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgSpills, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgSpills" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Spills" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> <DataGridTextColumn Binding="{Binding MinCpuMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinCpuMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> </DataGridTextColumn> @@ -791,7 +900,7 @@ </DataGrid> <DataGrid Grid.Row="2" x:Name="ProcStatsComparisonGrid" Visibility="Collapsed" AutoGenerateColumns="False" IsReadOnly="True" - RowStyle="{StaticResource GridRowStyle}" + RowStyle="{StaticResource GridPlanRowStyle}" HeadersVisibility="Column" GridLinesVisibility="Horizontal" CanUserSortColumns="True"> <DataGrid.Columns> @@ -872,7 +981,7 @@ Background="#2244AAFF"/> <DataGrid Grid.Row="2" x:Name="QueryStoreGrid" AutoGenerateColumns="False" IsReadOnly="True" - RowStyle="{StaticResource GridRowStyle}" + RowStyle="{StaticResource GridPlanRowStyle}" HeadersVisibility="Column" GridLinesVisibility="Horizontal" CanUserSortColumns="True" SelectionChanged="QueryStoreGrid_SelectionChanged" @@ -888,6 +997,9 @@ <DataGridTextColumn Binding="{Binding PlanId}" ElementStyle="{StaticResource NumericCell}" Width="70"> <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PlanId" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Plan ID" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ReplicaRole}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ReplicaRole" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Replica" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> <DataGridTextColumn Binding="{Binding LastExecutionTimeLocal}" Width="140"> <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LastExecutionTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Last Execution" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> </DataGridTextColumn> @@ -903,30 +1015,75 @@ <DataGridTextColumn Binding="{Binding AvgDurationMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"> <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgDurationMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Duration (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinDurationMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinDurationMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Duration (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxDurationMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxDurationMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Duration (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> <DataGridTextColumn Binding="{Binding TotalCpuMs, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="110"> <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalCpuMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> </DataGridTextColumn> <DataGridTextColumn Binding="{Binding AvgCpuTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgCpuTimeMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinCpuTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinCpuTimeMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxCpuTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxCpuTimeMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max CPU (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> <DataGridTextColumn Binding="{Binding AvgLogicalReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"> <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgLogicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinLogicalReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinLogicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxLogicalReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxLogicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> <DataGridTextColumn Binding="{Binding AvgLogicalWrites, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"> <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgLogicalWrites" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Writes" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinLogicalWrites, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinLogicalWrites" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Writes" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxLogicalWrites, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxLogicalWrites" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Writes" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> <DataGridTextColumn Binding="{Binding AvgPhysicalReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="120"> <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgPhysicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Physical Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinPhysicalReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinPhysicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Physical Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxPhysicalReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxPhysicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Physical Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> <DataGridTextColumn Binding="{Binding AvgRowcount, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"> <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgRowcount" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Rows" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinRowcount, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinRowcount" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Rows" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxRowcount, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxRowcount" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Rows" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> <DataGridTextColumn Binding="{Binding MinDop}" ElementStyle="{StaticResource NumericCell}" Width="70"> <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinDop" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min DOP" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> </DataGridTextColumn> <DataGridTextColumn Binding="{Binding MaxDop}" ElementStyle="{StaticResource NumericCell}" Width="70"> <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxDop" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max DOP" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgMemoryMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgMemoryMb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Mem (MB)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinMemoryMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinMemoryMb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Mem (MB)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxMemoryMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxMemoryMb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Mem (MB)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> <DataGridTextColumn Binding="{Binding ModuleName}" Width="120"> <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ModuleName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Module" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> </DataGridTextColumn> @@ -948,12 +1105,39 @@ <DataGridTextColumn Binding="{Binding AvgClrTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgClrTimeMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg CLR (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinClrTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinClrTimeMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min CLR (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxClrTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxClrTimeMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max CLR (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvgNumPhysicalIoReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgNumPhysicalIoReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Phys IO Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinNumPhysicalIoReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinNumPhysicalIoReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Phys IO Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxNumPhysicalIoReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxNumPhysicalIoReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Phys IO Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> <DataGridTextColumn Binding="{Binding AvgTempdbSpaceUsed, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="120"> - <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgTempdbSpaceUsed" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg TempDB Pages" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgTempdbSpaceUsed" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg tempdb Pages" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinTempdbSpaceUsed, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinTempdbSpaceUsed" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min tempdb Pages" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxTempdbSpaceUsed, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxTempdbSpaceUsed" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max tempdb Pages" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> </DataGridTextColumn> <DataGridTextColumn Binding="{Binding AvgLogBytesUsed, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvgLogBytesUsed" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avg Log Bytes" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MinLogBytesUsed, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MinLogBytesUsed" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Min Log Bytes" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxLogBytesUsed, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxLogBytesUsed" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Log Bytes" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> <DataGridTextColumn Binding="{Binding PlanType}" Width="90"> <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PlanType" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Plan Type" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> </DataGridTextColumn> @@ -1247,20 +1431,20 @@ <!-- Memory Grants Sub-Tab --> <TabItem Header="Memory Grants"> - <Grid> + <Grid Margin="8"> <Grid.RowDefinitions> <RowDefinition Height="*"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> - <ScottPlot:WpfPlot Grid.Row="0" x:Name="MemoryGrantSizingChart" Margin="5,5,5,2"/> - <ScottPlot:WpfPlot Grid.Row="1" x:Name="MemoryGrantActivityChart" Margin="5,2,5,5"/> + <ScottPlot:WpfPlot Grid.Row="0" x:Name="MemoryGrantSizingChart" Margin="0,0,0,4"/> + <ScottPlot:WpfPlot Grid.Row="1" x:Name="MemoryGrantActivityChart" Margin="0,4,0,0"/> </Grid> </TabItem> <!-- Memory Pressure Events Sub-Tab --> <TabItem Header="Memory Pressure Events"> - <Grid> - <ScottPlot:WpfPlot x:Name="MemoryPressureEventsChart" Margin="5"/> + <Grid Margin="8"> + <ScottPlot:WpfPlot x:Name="MemoryPressureEventsChart"/> </Grid> </TabItem> @@ -1306,8 +1490,8 @@ </Grid> </TabItem> - <!-- TempDB Tab --> - <TabItem Header="TempDB"> + <!-- tempdb Tab --> + <TabItem Header="tempdb"> <Grid Margin="8"> <Grid.RowDefinitions> <RowDefinition Height="*"/> @@ -1568,6 +1752,64 @@ </DataGrid> </Grid> </TabItem> + <!-- Blocking Stats — the blocking- AND deadlock-SEVERITY companion to the count-only + "Trends" sub-tab (the "are blocks/deadlocks getting WORSE, not just more frequent" + signal). Everything is computed ON-THE-FLY from rows the store already keeps, so + there is no collector or migration — the Lite equivalent of the Dashboard's + pre-aggregated collect.blocking_deadlock_stats. Blocking severity comes from the + same blocked_process_reports rows the count trend uses, with the identical + v_dmv_blocking_snapshots fallback (WHERE NOT EXISTS) so the two reconcile. Deadlock + severity is parsed from deadlock_graph_xml over the SAME v_deadlocks window as the + deadlock count (so those reconcile too), via Lite's own DeadlockProcessDetail parse. + Appended as the LAST Blocking sub-tab (sub-index 4, after Deadlocks) — nothing else + reindexed; RefreshBlockingAsync's subTabOnly switch gains case 4. --> + <TabItem Header="Blocking Stats"> + <!-- 2x2 grid (matching the Performance Trends layout): row 0 = blocking Max/Avg | + blocking Total, row 1 = deadlock Max/Avg | deadlock Total, so each plot area + stays tall enough that the X-axis time labels don't collide. The summary strip + spans both columns below. --> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="*"/> + <RowDefinition Height="*"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*"/> + <ColumnDefinition Width="*"/> + </Grid.ColumnDefinitions> + <Border Grid.Row="0" Grid.Column="0" Margin="0,0,4,4"> + <ScottPlot:WpfPlot x:Name="BlockingDurationChart"/> + </Border> + <Border Grid.Row="0" Grid.Column="1" Margin="4,0,0,4"> + <ScottPlot:WpfPlot x:Name="BlockingTotalDurationChart"/> + </Border> + <Border Grid.Row="1" Grid.Column="0" Margin="0,4,4,0"> + <ScottPlot:WpfPlot x:Name="DeadlockWaitChart"/> + </Border> + <Border Grid.Row="1" Grid.Column="1" Margin="4,4,0,0"> + <ScottPlot:WpfPlot x:Name="DeadlockTotalWaitChart"/> + </Border> + <Border Grid.Row="2" Grid.ColumnSpan="2" Background="{DynamicResource BackgroundDarkBrush}" CornerRadius="4" Padding="12,8" Margin="0,4,0,0"> + <StackPanel Orientation="Horizontal" HorizontalAlignment="Center"> + <TextBlock Text="Blocking Events:" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" Margin="0,0,5,0"/> + <TextBlock x:Name="BlockingStatsEventCountText" Text="--" VerticalAlignment="Center" Margin="0,0,20,0"/> + <TextBlock Text="Total Duration:" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" Margin="0,0,5,0"/> + <TextBlock x:Name="BlockingStatsTotalDurationText" Text="--" VerticalAlignment="Center" Margin="0,0,20,0"/> + <TextBlock Text="Max Duration:" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" Margin="0,0,5,0"/> + <TextBlock x:Name="BlockingStatsMaxDurationText" Text="--" VerticalAlignment="Center" Margin="0,0,20,0"/> + <TextBlock Text="Avg Duration:" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" Margin="0,0,5,0"/> + <TextBlock x:Name="BlockingStatsAvgDurationText" Text="--" VerticalAlignment="Center" Margin="0,0,20,0"/> + <TextBlock Text="Deadlocks:" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" Margin="0,0,5,0"/> + <TextBlock x:Name="BlockingStatsDeadlockCountText" Text="--" VerticalAlignment="Center" Margin="0,0,20,0"/> + <TextBlock Text="Deadlock Victims:" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" Margin="0,0,5,0"/> + <TextBlock x:Name="BlockingStatsDeadlockVictimsText" Text="--" VerticalAlignment="Center" Margin="0,0,20,0"/> + <TextBlock Text="Deadlock Wait:" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" Margin="0,0,5,0"/> + <TextBlock x:Name="BlockingStatsDeadlockWaitText" Text="--" VerticalAlignment="Center"/> + </StackPanel> + </Border> + </Grid> + </TabItem> </TabControl> </Grid> </TabItem> @@ -1628,7 +1870,7 @@ <TextBlock Grid.Row="0" Text="Currently Running SQL Agent Jobs" FontSize="14" FontWeight="SemiBold" Margin="0,0,0,8"/> <TextBlock Grid.Row="1" x:Name="RunningJobsMsdbWarning" Text="Running Jobs requires msdb access. Grant the login access to msdb to enable this feature." - Foreground="#FFD700" FontSize="12" Margin="0,0,0,8" Visibility="Collapsed" + Foreground="{DynamicResource WarningTextBrush}" FontSize="12" Margin="0,0,0,8" Visibility="Collapsed" TextWrapping="Wrap"/> <DataGrid Grid.Row="2" x:Name="RunningJobsGrid" AutoGenerateColumns="False" IsReadOnly="True" @@ -1840,7 +2082,7 @@ </Grid> </TabItem> - <!-- Daily Summary Tab --> + <!-- Daily Summary Tab: a month heatmap of composite daily health; click a day for its roll-up --> <TabItem Header="Daily Summary"> <Grid Margin="8"> <Grid.RowDefinitions> @@ -1848,52 +2090,190 @@ <RowDefinition Height="*"/> </Grid.RowDefinitions> - <!-- Date Selection --> + <!-- Toolbar --> <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,6"> - <TextBlock Text="Summary Date:" VerticalAlignment="Center" Margin="0,0,6,0" FontWeight="Bold"/> - <Button x:Name="DailySummaryTodayButton" Content="Today" Click="DailySummaryToday_Click" Margin="0,0,6,0" Padding="8,4" MinWidth="60" Style="{StaticResource AccentButton}"/> - <DatePicker x:Name="DailySummaryDatePicker" Width="120" Margin="0,0,6,0" SelectedDateChanged="DailySummaryDate_Changed" CalendarOpened="DatePicker_CalendarOpened"/> - <Button Content="Refresh" Click="DailySummaryRefresh_Click" Margin="0,0,6,0" Padding="8,4" MinWidth="60" Style="{StaticResource SuccessButton}"/> - <TextBlock x:Name="DailySummaryIndicator" Text="Showing: Today (UTC)" VerticalAlignment="Center" + <Button Content="Refresh" Click="DailySummaryRefresh_Click" Margin="0,0,10,0" Padding="8,4" MinWidth="60"/> + <TextBlock x:Name="DailySummaryIndicator" Text="Composite daily health - click a day for detail and drill-in (times in UTC)" VerticalAlignment="Center" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> </StackPanel> - <!-- Summary Grid --> - <DataGrid Grid.Row="1" x:Name="DailySummaryGrid" + <!-- Month heatmap + the shared day-detail panel (click a day: band header, why-this-day + reasons, key metrics, and drill buttons that scope to that day and jump to the grid). --> + <ui:PerformanceCalendar Grid.Row="1" x:Name="DailyCalendar" + MonthChanged="DailyCalendar_MonthChanged" + DayDrillRequested="DailyCalendar_DayDrillRequested"/> + </Grid> + </TabItem> + + <!-- Latches & Spinlocks Tab — the Lite port of Darling's consolidated Latch/Spinlock tab + (data already collected into latch_stats / spinlock_stats). The latch wait-time trend and + the spinlock collisions trend for the TOP 5 contenders stack vertically, each above a + collapsed Expander holding its per-class latest-snapshot grid. Loads on tab activation via + the RefreshVisibleTabAsync switch (index 13), mirroring the tempdb tab. --> + <TabItem Header="Latches & Spinlocks"> + <Grid Margin="8"> + <Grid.RowDefinitions> + <RowDefinition Height="*"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + + <!-- Latch wait-time trend (top 5 classes by delta wait) + per-class latest-snapshot grid. --> + <ScottPlot:WpfPlot Grid.Row="0" x:Name="LatchStatsChart"/> + <Expander Grid.Row="1" Header="Latch classes (latest snapshot)" IsExpanded="False" Margin="0,4,0,0"> + <DataGrid x:Name="LatchStatsGrid" + AutoGenerateColumns="False" IsReadOnly="True" + RowStyle="{StaticResource GridRowStyle}" + HeadersVisibility="Column" GridLinesVisibility="Horizontal" + MaxHeight="220" Margin="0,4,0,0"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding LatchClass}" Width="*"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LatchClass" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Latch Class" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DeltaWaitTimeMs, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DeltaWaitTimeMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Δ Wait (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DeltaWaitingRequestsCount, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DeltaWaitingRequestsCount" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Δ Waiting Reqs" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding WaitTimeMs, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="WaitTimeMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Wait Time (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxWaitTimeMs, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxWaitTimeMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Wait (ms)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding WaitingRequestsCount, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="WaitingRequestsCount" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Waiting Reqs (total)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + </Expander> + + <!-- Spinlock collisions trend (top 5 spinlocks by delta collisions) + per-spinlock snapshot grid. --> + <ScottPlot:WpfPlot Grid.Row="2" x:Name="SpinlockStatsChart" Margin="0,8,0,0"/> + <Expander Grid.Row="3" Header="Spinlocks (latest snapshot)" IsExpanded="False" Margin="0,4,0,0"> + <DataGrid x:Name="SpinlockStatsGrid" + AutoGenerateColumns="False" IsReadOnly="True" + RowStyle="{StaticResource GridRowStyle}" + HeadersVisibility="Column" GridLinesVisibility="Horizontal" + MaxHeight="220" Margin="0,4,0,0"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding SpinlockName}" Width="*"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SpinlockName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Spinlock" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DeltaCollisions, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DeltaCollisions" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Δ Collisions" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DeltaSpins, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DeltaSpins" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Δ Spins" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding SpinsPerCollision, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SpinsPerCollision" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Spins/Collision" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Collisions, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="130"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Collisions" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Collisions (total)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Spins, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Spins" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Spins (total)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding SleepTime, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SleepTime" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Sleep Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Backoffs, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Backoffs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Backoffs" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + </Expander> + </Grid> + </TabItem> + + <!-- CPU Scheduler Tab — the Lite port of Darling's CPU Scheduler surface (cpu_scheduler_stats, + a point-in-time collector). The runnable/blocked/queued pressure trend on top; a + latest-snapshot metric/value grid below whose warning rows highlight (the collector's own + CASE-computed pressure flags). Loads on tab activation via RefreshVisibleTabAsync (index 14). --> + <TabItem Header="CPU Scheduler"> + <Grid Margin="8"> + <Grid.RowDefinitions> + <RowDefinition Height="2*"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + <ScottPlot:WpfPlot Grid.Row="0" x:Name="CpuSchedulerChart"/> + <!-- Metric/value latest-snapshot grid: a single-row point-in-time collector has no + natural top-N, so its snapshot is a labelled metric list; warning rows highlight. --> + <DataGrid Grid.Row="1" x:Name="CpuSchedulerGrid" AutoGenerateColumns="False" IsReadOnly="True" - RowStyle="{StaticResource GridRowStyle}" - HeadersVisibility="Column" GridLinesVisibility="Horizontal"> + HeadersVisibility="Column" GridLinesVisibility="Horizontal" + CanUserSortColumns="False" Margin="0,8,0,0"> + <DataGrid.RowStyle> + <Style TargetType="DataGridRow" BasedOn="{StaticResource GridRowStyle}"> + <Style.Triggers> + <DataTrigger Binding="{Binding IsWarning}" Value="True"> + <Setter Property="Background" Value="#33FF6B6B"/> + </DataTrigger> + </Style.Triggers> + </Style> + </DataGrid.RowStyle> <DataGrid.Columns> - <DataGridTextColumn Binding="{Binding SummaryDateFormatted}" Width="110"> - <DataGridTextColumn.Header><TextBlock Text="Date" FontWeight="Bold"/></DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding OverallHealth}" Width="120"> - <DataGridTextColumn.Header><TextBlock Text="Health" FontWeight="Bold"/></DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding TotalWaitFormatted}" ElementStyle="{StaticResource NumericCell}" Width="120" SortMemberPath="TotalWaitTimeSec"> - <DataGridTextColumn.Header><TextBlock Text="Total Wait" FontWeight="Bold"/></DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding TopWaitType}" Width="180"> - <DataGridTextColumn.Header><TextBlock Text="Top Wait Type" FontWeight="Bold"/></DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding UniqueQueries, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"> - <DataGridTextColumn.Header><TextBlock Text="Unique Queries" FontWeight="Bold"/></DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding DeadlockCount, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"> - <DataGridTextColumn.Header><TextBlock Text="Deadlocks" FontWeight="Bold"/></DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding BlockingEvents, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"> - <DataGridTextColumn.Header><TextBlock Text="Blocking Events" FontWeight="Bold"/></DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding HighCpuEvents, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"> - <DataGridTextColumn.Header><TextBlock Text="High CPU" FontWeight="Bold"/></DataGridTextColumn.Header> - </DataGridTextColumn> - <DataGridTextColumn Binding="{Binding CollectionErrors, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"> - <DataGridTextColumn.Header><TextBlock Text="Collect Errors" FontWeight="Bold"/></DataGridTextColumn.Header> - </DataGridTextColumn> + <DataGridTextColumn Header="Metric" Binding="{Binding Metric}" Width="2*"/> + <DataGridTextColumn Header="Value" Binding="{Binding Value}" Width="*"/> </DataGrid.Columns> </DataGrid> - <TextBlock Grid.Row="1" x:Name="DailySummaryNoData" Style="{StaticResource NoDataMessage}"/> + </Grid> + </TabItem> + + <!-- Plan Cache Tab — the Lite port of Darling's Plan Cache surface (plan_cache_stats). The + single-use vs multi-use plan-cache size (MB) trend on top (the single-use bloat signal) + over a summary strip (true total plans + oldest-plan age + derived bloat badge). + RefreshVisibleTabAsync index 15. --> + <TabItem Header="Plan Cache"> + <Grid Margin="8"> + <Grid.RowDefinitions> + <RowDefinition Height="*"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + <ScottPlot:WpfPlot Grid.Row="0" x:Name="PlanCacheChart"/> + <Border Grid.Row="1" Background="{DynamicResource BackgroundDarkBrush}" CornerRadius="4" Padding="12,8" Margin="0,8,0,0"> + <StackPanel Orientation="Horizontal" HorizontalAlignment="Center"> + <TextBlock Text="Total Plans:" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" Margin="0,0,5,0"/> + <TextBlock x:Name="PlanCacheTotalPlansText" Text="--" VerticalAlignment="Center" Margin="0,0,20,0"/> + <TextBlock Text="Oldest Plan Age:" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" Margin="0,0,5,0"/> + <TextBlock x:Name="PlanCacheOldestPlanText" Text="--" VerticalAlignment="Center" Margin="0,0,20,0"/> + <!-- Derived single-use bloat badge + forced-parameterization hint (install/47 + report.plan_cache_bloat parity): a client-side CASE on single-use/total plans. --> + <TextBlock Text="Bloat Level:" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" Margin="0,0,5,0"/> + <TextBlock x:Name="PlanCacheBloatLevelText" Text="--" FontWeight="Bold" VerticalAlignment="Center" Margin="0,0,20,0"/> + <TextBlock x:Name="PlanCacheRecommendationText" Text="" FontStyle="Italic" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" TextWrapping="Wrap"/> + </StackPanel> + </Border> + </Grid> + </TabItem> + + <!-- Session Stats Tab — the Lite port of Darling's Session Stats surface (session_summary_stats, + the server-wide SUMMARY collector). A session-status count trend (Total / Running / Sleeping / + Background / Dormant / Idle >30m / Waiting for Memory) over the window, with a summary strip + for the non-chartable attribution (top application / top host by connections + distinct + databases). RefreshVisibleTabAsync index 16. --> + <TabItem Header="Session Stats"> + <Grid Margin="8"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + <TextBlock Grid.Row="0" Text="Session Counts Over Time" FontSize="14" FontWeight="SemiBold" Margin="0,0,0,8"/> + <ScottPlot:WpfPlot Grid.Row="1" x:Name="SessionStatsChart"/> + <Border Grid.Row="2" Background="{DynamicResource BackgroundDarkBrush}" CornerRadius="4" Padding="12,8" Margin="0,4,0,0"> + <StackPanel Orientation="Horizontal" HorizontalAlignment="Center"> + <TextBlock Text="Top Application:" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" Margin="0,0,5,0"/> + <TextBlock x:Name="SessionStatsTopAppText" Text="N/A" Foreground="{DynamicResource ForegroundBrush}" VerticalAlignment="Center" Margin="0,0,20,0"/> + <TextBlock Text="Top Host:" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" Margin="0,0,5,0"/> + <TextBlock x:Name="SessionStatsTopHostText" Text="N/A" Foreground="{DynamicResource ForegroundBrush}" VerticalAlignment="Center" Margin="0,0,20,0"/> + <TextBlock Text="Databases:" FontWeight="SemiBold" Foreground="{DynamicResource ForegroundDimBrush}" VerticalAlignment="Center" Margin="0,0,5,0"/> + <TextBlock x:Name="SessionStatsDatabasesText" Text="N/A" Foreground="{DynamicResource ForegroundBrush}" VerticalAlignment="Center"/> + </StackPanel> + </Border> </Grid> </TabItem> @@ -1993,6 +2373,546 @@ </Grid> </TabItem> + <!-- System Events Tab (system_health parity) — the Lite port of the Darling viewer's System + Events tab. Corruption + Contention are the SYSTEM-component counter charts (one feed, no + significance filter); the rest are parse-on-read grids over v_system_health_events (shredded + by the Common SystemHealthParser, gated by the shared SystemHealthSignificance) plus the + Default Trace lane. Appended as the LAST top-level tab (index 18); loads via + RefreshVisibleTabAsync case 18, sub-tab switches reload via MainTabControl_SelectionChanged. --> + <TabItem Header="System Events"> + <TabControl x:Name="SystemEventsSubTabControl" Background="Transparent" BorderThickness="0" + ItemContainerStyle="{DynamicResource SubTabItemStyle}"> + + <!-- Corruption Events Sub-Tab (sp_server_diagnostics SYSTEM component counter charts) --> + <TabItem Header="Corruption Events"> + <Grid Margin="8"> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,4,0,8"> + <TextBlock Text="Corruption Events (bad pages, dump requests, access violations)" VerticalAlignment="Center" FontWeight="SemiBold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="SystemEventsRefresh_Click" Margin="12,0,0,0" Padding="12,4" MinWidth="70"/> + </StackPanel> + <Grid Grid.Row="1"> + <Grid.RowDefinitions><RowDefinition Height="*"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="*"/></Grid.ColumnDefinitions> + <Border Grid.Row="0" Grid.Column="0" Margin="2"><ScottPlot:WpfPlot x:Name="BadPagesChart"/></Border> + <Border Grid.Row="0" Grid.Column="1" Margin="2"><ScottPlot:WpfPlot x:Name="DumpRequestsChart"/></Border> + <Border Grid.Row="1" Grid.Column="0" Margin="2"><ScottPlot:WpfPlot x:Name="AccessViolationsChart"/></Border> + <Border Grid.Row="1" Grid.Column="1" Margin="2"><ScottPlot:WpfPlot x:Name="WriteAccessViolationsChart"/></Border> + </Grid> + </Grid> + </TabItem> + + <!-- Contention Events Sub-Tab (sp_server_diagnostics SYSTEM component counter charts) --> + <TabItem Header="Contention Events"> + <Grid Margin="8"> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,4,0,8"> + <TextBlock Text="Contention Events (non-yielding tasks, latches, spinlocks, CPU)" VerticalAlignment="Center" FontWeight="SemiBold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="SystemEventsRefresh_Click" Margin="12,0,0,0" Padding="12,4" MinWidth="70"/> + </StackPanel> + <Grid Grid.Row="1"> + <Grid.RowDefinitions><RowDefinition Height="*"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="*"/></Grid.ColumnDefinitions> + <Border Grid.Row="0" Grid.Column="0" Margin="2"><ScottPlot:WpfPlot x:Name="NonYieldingTasksChart"/></Border> + <Border Grid.Row="0" Grid.Column="1" Margin="2"><ScottPlot:WpfPlot x:Name="LatchWarningsChart"/></Border> + <Border Grid.Row="1" Grid.Column="0" Margin="2"><ScottPlot:WpfPlot x:Name="SickSpinlocksChart"/></Border> + <Border Grid.Row="1" Grid.Column="1" Margin="2"><ScottPlot:WpfPlot x:Name="CpuComparisonChart"/></Border> + </Grid> + </Grid> + </TabItem> + + <!-- Scheduler Issues Sub-Tab --> + <TabItem Header="Scheduler Issues"> + <Grid Margin="8"> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,4,0,8"> + <TextBlock Text="Scheduler Issues" VerticalAlignment="Center" FontWeight="SemiBold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="SystemEventsRefresh_Click" Margin="12,0,0,0" Padding="12,4" MinWidth="70"/> + <TextBlock x:Name="SchedulerIssuesCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + </StackPanel> + <Grid Grid.Row="1"> + <DataGrid x:Name="SchedulerIssuesGrid" AutoGenerateColumns="False" IsReadOnly="True" RowStyle="{StaticResource GridRowStyle}" HeadersVisibility="Column" GridLinesVisibility="Horizontal" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding EventTimeLocal}" Width="145"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EventTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Event Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding SchedulerId}" Width="95" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SchedulerId" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Scheduler ID" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CpuId}" Width="70" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CpuId" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="CPU ID" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Status}" Width="90"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Status" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Status" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding IsOnline}" Width="70"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IsOnline" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Online" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding IsRunnable}" Width="75"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IsRunnable" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Runnable" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding IsRunning}" Width="70"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IsRunning" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Running" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding NonYieldingTimeMs, StringFormat=N0}" Width="135" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="NonYieldingTimeMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Non-Yielding ms" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ThreadQuantumMs, StringFormat=N0}" Width="135" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ThreadQuantumMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Thread Quantum ms" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="SchedulerIssuesNoDataMessage" Visibility="Collapsed" HorizontalAlignment="Center" VerticalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap" MaxWidth="480" FontStyle="Italic" Foreground="{DynamicResource ForegroundDimBrush}" Text="No scheduler warnings (non-yielding / offline schedulers) in the selected time window."/> + </Grid> + </Grid> + </TabItem> + + <!-- Severe Errors Sub-Tab --> + <TabItem Header="Severe Errors"> + <Grid Margin="8"> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,4,0,8"> + <TextBlock Text="Severe Errors (severity 19+)" VerticalAlignment="Center" FontWeight="SemiBold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="SystemEventsRefresh_Click" Margin="12,0,0,0" Padding="12,4" MinWidth="70"/> + <TextBlock x:Name="SevereErrorsCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + </StackPanel> + <Grid Grid.Row="1"> + <DataGrid x:Name="SevereErrorsGrid" AutoGenerateColumns="False" IsReadOnly="True" RowStyle="{StaticResource GridRowStyle}" HeadersVisibility="Column" GridLinesVisibility="Horizontal" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding EventTimeLocal}" Width="145"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EventTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Event Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ErrorNumber}" Width="80" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ErrorNumber" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Error #" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Severity}" Width="75" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Severity" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Severity" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding State}" Width="60" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="State" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="State" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DatabaseName}" Width="150"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DatabaseName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Database" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DatabaseId}" Width="65" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DatabaseId" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="DB ID" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTemplateColumn Width="560"> + <DataGridTemplateColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Message" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Message" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTemplateColumn.Header> + <DataGridTemplateColumn.CellTemplate><DataTemplate><TextBlock Text="{Binding Message}" TextWrapping="Wrap" MaxHeight="90" Margin="5,2" ToolTip="{Binding Message}"/></DataTemplate></DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="SevereErrorsNoDataMessage" Visibility="Collapsed" HorizontalAlignment="Center" VerticalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap" MaxWidth="480" FontStyle="Italic" Foreground="{DynamicResource ForegroundDimBrush}" Text="No severe errors (severity 19+) in the selected time window."/> + </Grid> + </Grid> + </TabItem> + + <!-- Memory Conditions Sub-Tab --> + <TabItem Header="Memory Conditions"> + <Grid Margin="8"> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,4,0,8"> + <TextBlock Text="Memory Conditions (low physical memory)" VerticalAlignment="Center" FontWeight="SemiBold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="SystemEventsRefresh_Click" Margin="12,0,0,0" Padding="12,4" MinWidth="70"/> + <TextBlock x:Name="MemoryConditionsCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + </StackPanel> + <Grid Grid.Row="1"> + <DataGrid x:Name="MemoryConditionsGrid" AutoGenerateColumns="False" IsReadOnly="True" RowStyle="{StaticResource GridRowStyle}" HeadersVisibility="Column" GridLinesVisibility="Horizontal" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding EventTimeLocal}" Width="145"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EventTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Event Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LastNotification}" Width="210"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LastNotification" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Last Notification" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Name}" Width="170"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Name" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Report" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding OutOfMemoryExceptions, StringFormat=N0}" Width="115" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="OutOfMemoryExceptions" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="OOM Exceptions" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding IsAnyPoolOutOfMemory}" Width="80"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IsAnyPoolOutOfMemory" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Pool OOM" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ProcessOutOfMemoryPeriod, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ProcessOutOfMemoryPeriod" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="OOM Period" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvailablePhysicalMemoryGb, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvailablePhysicalMemoryGb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avail Phys GB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvailableVirtualMemoryGb, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvailableVirtualMemoryGb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avail Virt GB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvailablePagingFileGb, StringFormat=N0}" Width="115" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvailablePagingFileGb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avail Paging GB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding WorkingSetGb, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="WorkingSetGb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Working Set GB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PercentOfCommittedMemoryInWs, StringFormat=N0}" Width="130" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PercentOfCommittedMemoryInWs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="% Committed in WS" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PageFaults, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PageFaults" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Page Faults" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding SystemPhysicalMemoryHigh, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SystemPhysicalMemoryHigh" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Sys Phys High" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding SystemPhysicalMemoryLow, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SystemPhysicalMemoryLow" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Sys Phys Low" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ProcessPhysicalMemoryLow, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ProcessPhysicalMemoryLow" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Proc Phys Low" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ProcessVirtualMemoryLow, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ProcessVirtualMemoryLow" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Proc Virt Low" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding VmReservedGb, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="VmReservedGb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="VM Reserved GB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding VmCommittedGb, StringFormat=N0}" Width="115" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="VmCommittedGb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="VM Committed GB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LockedPagesAllocated, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LockedPagesAllocated" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Locked Pages" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LargePagesAllocated, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LargePagesAllocated" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Large Pages" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding EmergencyMemoryGb, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EmergencyMemoryGb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Emergency GB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding EmergencyMemoryInUseGb, StringFormat=N0}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EmergencyMemoryInUseGb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Emerg In Use GB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TargetCommittedGb, StringFormat=N0}" Width="125" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TargetCommittedGb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Target Committed GB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CurrentCommittedGb, StringFormat=N0}" Width="130" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CurrentCommittedGb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Current Committed GB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PagesAllocated, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PagesAllocated" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Pages Alloc" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PagesReserved, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PagesReserved" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Pages Reserved" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PagesFree, StringFormat=N0}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PagesFree" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Pages Free" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PagesInUse, StringFormat=N0}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PagesInUse" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Pages In Use" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PageAllocPotential, StringFormat=N0}" Width="115" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PageAllocPotential" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Alloc Potential" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding NumaGrowthPhase, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="NumaGrowthPhase" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="NUMA Growth" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LastOomFactor, StringFormat=N0}" Width="115" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LastOomFactor" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Last OOM Factor" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LastOsError, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LastOsError" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Last OS Error" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="MemoryConditionsNoDataMessage" Visibility="Collapsed" HorizontalAlignment="Center" VerticalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap" MaxWidth="480" FontStyle="Italic" Foreground="{DynamicResource ForegroundDimBrush}" Text="No low-physical-memory conditions (RESOURCE_MEMPHYSICAL_LOW) in the selected time window."/> + </Grid> + </Grid> + </TabItem> + + <!-- Memory Broker Sub-Tab --> + <TabItem Header="Memory Broker"> + <Grid Margin="8"> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,4,0,8"> + <TextBlock Text="Memory Broker (low physical memory)" VerticalAlignment="Center" FontWeight="SemiBold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="SystemEventsRefresh_Click" Margin="12,0,0,0" Padding="12,4" MinWidth="70"/> + <TextBlock x:Name="MemoryBrokerCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + </StackPanel> + <Grid Grid.Row="1"> + <DataGrid x:Name="MemoryBrokerGrid" AutoGenerateColumns="False" IsReadOnly="True" RowStyle="{StaticResource GridRowStyle}" HeadersVisibility="Column" GridLinesVisibility="Horizontal" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding EventTimeLocal}" Width="145"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EventTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Event Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BrokerId}" Width="80" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BrokerId" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Broker ID" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Broker}" Width="210"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Broker" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Broker" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Notification}" Width="210"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Notification" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Notification" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PoolMetadataId}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PoolMetadataId" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Pool Meta ID" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DeltaTime, StringFormat=N0}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DeltaTime" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Delta Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MemoryRatio, StringFormat=N0}" Width="90" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MemoryRatio" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Mem Ratio" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding NewTarget, StringFormat=N0}" Width="115" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="NewTarget" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="New Target" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Overall, StringFormat=N0}" Width="115" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Overall" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Overall" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Rate, StringFormat=N0}" Width="90" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Rate" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Rate" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CurrentlyPredicated, StringFormat=N0}" Width="115" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CurrentlyPredicated" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Predicated" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CurrentlyAllocated, StringFormat=N0}" Width="115" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CurrentlyAllocated" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Allocated" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PreviouslyAllocated, StringFormat=N0}" Width="125" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PreviouslyAllocated" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Prev Allocated" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="MemoryBrokerNoDataMessage" Visibility="Collapsed" HorizontalAlignment="Center" VerticalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap" MaxWidth="480" FontStyle="Italic" Foreground="{DynamicResource ForegroundDimBrush}" Text="No low-physical-memory broker notifications (RESOURCE_MEMPHYSICAL_LOW) in the selected time window."/> + </Grid> + </Grid> + </TabItem> + + <!-- Memory Node OOM Sub-Tab --> + <TabItem Header="Memory Node OOM"> + <Grid Margin="8"> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,4,0,8"> + <TextBlock Text="Memory Node OOM" VerticalAlignment="Center" FontWeight="SemiBold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="SystemEventsRefresh_Click" Margin="12,0,0,0" Padding="12,4" MinWidth="70"/> + <TextBlock x:Name="MemoryNodeOomCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + </StackPanel> + <Grid Grid.Row="1"> + <DataGrid x:Name="MemoryNodeOomGrid" AutoGenerateColumns="False" IsReadOnly="True" RowStyle="{StaticResource GridRowStyle}" HeadersVisibility="Column" GridLinesVisibility="Horizontal" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding EventTimeLocal}" Width="145"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EventTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Event Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding NodeId}" Width="70" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="NodeId" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Node ID" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MemoryNodeId}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MemoryNodeId" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Mem Node ID" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MemoryUtilizationPct, StringFormat=N0}" Width="90" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MemoryUtilizationPct" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Mem Util %" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalPhysicalMemoryKb, StringFormat=N0}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalPhysicalMemoryKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total Phys KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvailablePhysicalMemoryKb, StringFormat=N0}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvailablePhysicalMemoryKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avail Phys KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalPageFileKb, StringFormat=N0}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalPageFileKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total Page KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvailablePageFileKb, StringFormat=N0}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvailablePageFileKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avail Page KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalVirtualAddressSpaceKb, StringFormat=N0}" Width="130" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalVirtualAddressSpaceKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total VAS KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AvailableVirtualAddressSpaceKb, StringFormat=N0}" Width="130" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AvailableVirtualAddressSpaceKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Avail VAS KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TargetKb, StringFormat=N0}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TargetKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Target KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ReservedKb, StringFormat=N0}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ReservedKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Reserved KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CommittedKb, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CommittedKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Committed KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding SharedCommittedKb, StringFormat=N0}" Width="140" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SharedCommittedKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Shared Committed KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AweKb, StringFormat=N0}" Width="90" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AweKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="AWE KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PagesKb, StringFormat=N0}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PagesKb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Pages KB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding FailureType}" Width="180"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="FailureType" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Failure" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding FailureValue}" Width="90" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="FailureValue" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Failure Val" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Resources}" Width="90" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Resources" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Resources" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding FactorText}" Width="160"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="FactorText" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Factor" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding FactorValue}" Width="90" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="FactorValue" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Factor Val" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LastError}" Width="90" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LastError" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Last Error" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PoolMetadataId}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PoolMetadataId" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Pool Meta ID" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding IsProcessInJob}" Width="70"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IsProcessInJob" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="In Job" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding IsSystemPhysicalMemoryHigh}" Width="105"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IsSystemPhysicalMemoryHigh" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Sys Phys High" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding IsSystemPhysicalMemoryLow}" Width="105"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IsSystemPhysicalMemoryLow" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Sys Phys Low" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding IsProcessPhysicalMemoryLow}" Width="105"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IsProcessPhysicalMemoryLow" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Proc Phys Low" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding IsProcessVirtualMemoryLow}" Width="105"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IsProcessVirtualMemoryLow" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Proc Virt Low" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="MemoryNodeOomNoDataMessage" Visibility="Collapsed" HorizontalAlignment="Center" VerticalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap" MaxWidth="480" FontStyle="Italic" Foreground="{DynamicResource ForegroundDimBrush}" Text="No memory-node out-of-memory events in the selected time window."/> + </Grid> + </Grid> + </TabItem> + + <!-- Significant Waits Sub-Tab --> + <TabItem Header="Significant Waits"> + <Grid Margin="8"> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,4,0,8"> + <TextBlock Text="Significant Waits (duration 500 ms+)" VerticalAlignment="Center" FontWeight="SemiBold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="SystemEventsRefresh_Click" Margin="12,0,0,0" Padding="12,4" MinWidth="70"/> + <TextBlock x:Name="SignificantWaitsCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + </StackPanel> + <Grid Grid.Row="1"> + <DataGrid x:Name="SignificantWaitsGrid" AutoGenerateColumns="False" IsReadOnly="True" RowStyle="{StaticResource GridRowStyle}" HeadersVisibility="Column" GridLinesVisibility="Horizontal" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding EventTimeLocal}" Width="145"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EventTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Event Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding WaitType}" Width="190"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="WaitType" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Wait Type" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DurationMs, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DurationMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Duration ms" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding SignalDurationMs, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SignalDurationMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Signal ms" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding WaitResource}" Width="220"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="WaitResource" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Wait Resource" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding SessionId}" Width="70" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SessionId" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Session" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTemplateColumn Width="560"> + <DataGridTemplateColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="QueryText" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Query Text" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTemplateColumn.Header> + <DataGridTemplateColumn.CellTemplate><DataTemplate><TextBlock Text="{Binding QueryText}" TextWrapping="Wrap" MaxHeight="90" Margin="5,2" ToolTip="{Binding QueryText}"/></DataTemplate></DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="SignificantWaitsNoDataMessage" Visibility="Collapsed" HorizontalAlignment="Center" VerticalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap" MaxWidth="480" FontStyle="Italic" Foreground="{DynamicResource ForegroundDimBrush}" Text="No significant waits (session queries waiting 500 ms+) in the selected time window."/> + </Grid> + </Grid> + </TabItem> + + <!-- CPU Tasks Sub-Tab (sp_server_diagnostics QUERY_PROCESSING component) --> + <TabItem Header="CPU Tasks"> + <Grid Margin="8"> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,4,0,8"> + <TextBlock Text="CPU Tasks (WARNING with 10+ pending tasks)" VerticalAlignment="Center" FontWeight="SemiBold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="SystemEventsRefresh_Click" Margin="12,0,0,0" Padding="12,4" MinWidth="70"/> + <TextBlock x:Name="CpuTasksCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + </StackPanel> + <Grid Grid.Row="1"> + <DataGrid x:Name="CpuTasksGrid" AutoGenerateColumns="False" IsReadOnly="True" RowStyle="{StaticResource GridRowStyle}" HeadersVisibility="Column" GridLinesVisibility="Horizontal" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding EventTimeLocal}" Width="145"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EventTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Event Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding State}" Width="90"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="State" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="State" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxWorkers, StringFormat=N0}" Width="105" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxWorkers" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Max Workers" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding WorkersCreated, StringFormat=N0}" Width="120" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="WorkersCreated" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Workers Created" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding WorkersIdle, StringFormat=N0}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="WorkersIdle" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Workers Idle" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TasksCompletedWithinInterval, StringFormat=N0}" Width="130" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TasksCompletedWithinInterval" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Tasks Completed" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PendingTasks, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PendingTasks" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Pending Tasks" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding OldestPendingTaskWaitingTime, StringFormat=N0}" Width="150" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="OldestPendingTaskWaitingTime" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Oldest Pending ms" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding HasUnresolvableDeadlockOccurred}" Width="120"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="HasUnresolvableDeadlockOccurred" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Unresolvable DL" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding HasDeadlockedSchedulersOccurred}" Width="125"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="HasDeadlockedSchedulersOccurred" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Sched Deadlock" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DidBlockingOccur}" Width="90"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DidBlockingOccur" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Blocking" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="CpuTasksNoDataMessage" Visibility="Collapsed" HorizontalAlignment="Center" VerticalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap" MaxWidth="480" FontStyle="Italic" Foreground="{DynamicResource ForegroundDimBrush}" Text="No CPU-task warnings (WARNING state with 10+ pending tasks) in the selected time window."/> + </Grid> + </Grid> + </TabItem> + + <!-- I/O Issues Sub-Tab (sp_server_diagnostics IO_SUBSYSTEM component) --> + <TabItem Header="I/O Issues"> + <Grid Margin="8"> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,4,0,8"> + <TextBlock Text="I/O Issues (WARNING with pending I/O)" VerticalAlignment="Center" FontWeight="SemiBold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="SystemEventsRefresh_Click" Margin="12,0,0,0" Padding="12,4" MinWidth="70"/> + <TextBlock x:Name="IoIssuesCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + </StackPanel> + <Grid Grid.Row="1"> + <DataGrid x:Name="IoIssuesGrid" AutoGenerateColumns="False" IsReadOnly="True" RowStyle="{StaticResource GridRowStyle}" HeadersVisibility="Column" GridLinesVisibility="Horizontal" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding EventTimeLocal}" Width="145"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EventTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Event Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding State}" Width="90"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="State" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="State" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding IoLatchTimeouts, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IoLatchTimeouts" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Latch Timeouts" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding IntervalLongIos, StringFormat=N0}" Width="130" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="IntervalLongIos" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Interval Long IOs" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TotalLongIos, StringFormat=N0}" Width="115" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TotalLongIos" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Total Long IOs" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LongestPendingRequestsDurationMs, StringFormat=N0}" Width="160" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LongestPendingRequestsDurationMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Pending Duration ms" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LongestPendingRequestsFilePath}" Width="360"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LongestPendingRequestsFilePath" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="File Path" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="IoIssuesNoDataMessage" Visibility="Collapsed" HorizontalAlignment="Center" VerticalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap" MaxWidth="480" FontStyle="Italic" Foreground="{DynamicResource ForegroundDimBrush}" Text="No potential I/O issues (WARNING state with pending I/O) in the selected time window."/> + </Grid> + </Grid> + </TabItem> + + <!-- Default Trace Sub-Tab (built-in Default Trace via sys.fn_trace_gettable) --> + <TabItem Header="Default Trace"> + <Grid Margin="8"> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,4,0,8"> + <TextBlock Text="Default Trace (auto-grow/shrink stalls, severe ErrorLog, schema DDL, security audits, memory change)" VerticalAlignment="Center" FontWeight="SemiBold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="SystemEventsRefresh_Click" Margin="12,0,0,0" Padding="12,4" MinWidth="70"/> + <TextBlock x:Name="DefaultTraceCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + </StackPanel> + <Grid Grid.Row="1"> + <DataGrid x:Name="DefaultTraceGrid" AutoGenerateColumns="False" IsReadOnly="True" RowStyle="{StaticResource GridRowStyle}" HeadersVisibility="Column" GridLinesVisibility="Horizontal" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding EventTimeLocal}" Width="145"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EventTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Event Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Category}" Width="115"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Category" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Category" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding EventName}" Width="150"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EventName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Event" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DatabaseName}" Width="130"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DatabaseName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Database" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ObjectName}" Width="170"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ObjectName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Object" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LoginName}" Width="120"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LoginName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Login" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding HostName}" Width="120"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="HostName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Host" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ApplicationName}" Width="140"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ApplicationName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Application" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Spid, StringFormat=N0}" Width="70" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Spid" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="SPID" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DurationMs, StringFormat=N0}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DurationMs" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Duration ms" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding GrowthMb, StringFormat=N1}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="GrowthMb" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Growth MB" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Severity, StringFormat=N0}" Width="80" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Severity" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Severity" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ErrorNumber, StringFormat=N0}" Width="90" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ErrorNumber" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Error #" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TextData}" Width="400"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TextData" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Details" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="DefaultTraceNoDataMessage" Visibility="Collapsed" HorizontalAlignment="Center" VerticalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap" MaxWidth="520" FontStyle="Italic" Foreground="{DynamicResource ForegroundDimBrush}" Text="No significant Default Trace events (auto-grow/shrink stalls, severe ErrorLog, schema DDL, security audits, memory change) in the selected time window."/> + </Grid> + </Grid> + </TabItem> + </TabControl> + </TabItem> + + <!-- Configuration Changes Tab (Tier-1 parity) — the Lite port of the Darling viewer's Configuration + Changes tab, appended as the new LAST top-level tab (index 19, after System Events — nothing + renumbered, so switch case 19 equals this 0-based XAML index). Three sub-tab GRIDS show + server-config / database-config / trace-flag DRIFT over the selected window, COMPUTED by diffing + the store's APPEND-ONLY config snapshots via the shared PerformanceMonitor.Common.ConfigChangeDiff + (LocalDataService.ConfigChanges.cs). Dashboard enrichment Lite does not collect is OMITTED (not + fabricated): server-config Setting Description, database-config Setting Type. requires_restart is + KEPT (derivable: is_dynamic = 0 AND configured != in-use). Config is captured on connect/restart, + so a change appears only when at least two captures bracket it in the window. --> + <TabItem Header="Configuration Changes"> + <TabControl x:Name="ConfigChangesSubTabControl" Background="Transparent" BorderThickness="0" + ItemContainerStyle="{DynamicResource SubTabItemStyle}"> + + <!-- Server Config Changes Sub-Tab (sys.configurations snapshot diff) --> + <TabItem Header="Server Config Changes"> + <Grid Margin="8"> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,4,0,8"> + <TextBlock Text="Server Configuration Changes (sp_configure drift)" VerticalAlignment="Center" FontWeight="SemiBold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="ConfigChangesRefresh_Click" Margin="12,0,0,0" Padding="12,4" MinWidth="70"/> + <TextBlock x:Name="ServerConfigChangesCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + </StackPanel> + <Grid Grid.Row="1"> + <DataGrid x:Name="ServerConfigChangesGrid" AutoGenerateColumns="False" IsReadOnly="True" RowStyle="{StaticResource GridRowStyle}" HeadersVisibility="Column" GridLinesVisibility="Horizontal" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding ChangeTimeLocal}" Width="150"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ChangeTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Change Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ConfigurationName}" Width="260"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ConfigurationName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Configuration" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding OldValueConfigured}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="OldValueConfigured" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Old Configured" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding NewValueConfigured}" Width="110" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="NewValueConfigured" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="New Configured" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding OldValueInUse}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="OldValueInUse" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Old In Use" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding NewValueInUse}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="NewValueInUse" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="New In Use" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding RequiresRestartDisplay}" Width="80"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="RequiresRestartDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Restart?" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DynamicDisplay}" Width="80"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DynamicDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Dynamic" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding AdvancedDisplay}" Width="80"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="AdvancedDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Advanced" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ChangeDescription}" Width="*" MinWidth="250"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ChangeDescription" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Change Description" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="ServerConfigChangesNoDataMessage" Visibility="Collapsed" HorizontalAlignment="Center" VerticalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap" MaxWidth="480" FontStyle="Italic" Foreground="{DynamicResource ForegroundDimBrush}" Text="No server configuration changes in the selected time window (needs at least two config captures bracketing a change)."/> + </Grid> + </Grid> + </TabItem> + + <!-- Database Config Changes Sub-Tab (sys.databases wide-row snapshot diff) --> + <TabItem Header="Database Config Changes"> + <Grid Margin="8"> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,4,0,8"> + <TextBlock Text="Database Configuration Changes (recovery model, RCSI, compat level, etc.)" VerticalAlignment="Center" FontWeight="SemiBold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="ConfigChangesRefresh_Click" Margin="12,0,0,0" Padding="12,4" MinWidth="70"/> + <TextBlock x:Name="DatabaseConfigChangesCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + </StackPanel> + <Grid Grid.Row="1"> + <DataGrid x:Name="DatabaseConfigChangesGrid" AutoGenerateColumns="False" IsReadOnly="True" RowStyle="{StaticResource GridRowStyle}" HeadersVisibility="Column" GridLinesVisibility="Horizontal" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding ChangeTimeLocal}" Width="150"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ChangeTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Change Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DatabaseName}" Width="180"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DatabaseName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Database" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding SettingName}" Width="230"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SettingName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Setting Name" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding OldValue}" Width="160"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="OldValue" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Old Value" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding NewValue}" Width="160"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="NewValue" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="New Value" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ChangeDescription}" Width="*" MinWidth="250"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ChangeDescription" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Change Description" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="DatabaseConfigChangesNoDataMessage" Visibility="Collapsed" HorizontalAlignment="Center" VerticalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap" MaxWidth="480" FontStyle="Italic" Foreground="{DynamicResource ForegroundDimBrush}" Text="No database configuration changes in the selected time window (needs at least two config captures bracketing a change)."/> + </Grid> + </Grid> + </TabItem> + + <!-- Trace Flag Changes Sub-Tab (DBCC TRACESTATUS set-diff) --> + <TabItem Header="Trace Flag Changes"> + <Grid Margin="8"> + <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/></Grid.RowDefinitions> + <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,4,0,8"> + <TextBlock Text="Trace Flag Changes (enabled / disabled / scope changed)" VerticalAlignment="Center" FontWeight="SemiBold" FontSize="14" Foreground="{DynamicResource ForegroundBrush}"/> + <Button Content="Refresh" Click="ConfigChangesRefresh_Click" Margin="12,0,0,0" Padding="12,4" MinWidth="70"/> + <TextBlock x:Name="TraceFlagChangesCountIndicator" Text="" Margin="12,0,0,0" VerticalAlignment="Center" FontStyle="Italic" Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/> + </StackPanel> + <Grid Grid.Row="1"> + <DataGrid x:Name="TraceFlagChangesGrid" AutoGenerateColumns="False" IsReadOnly="True" RowStyle="{StaticResource GridRowStyle}" HeadersVisibility="Column" GridLinesVisibility="Horizontal" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding ChangeTimeLocal}" Width="150"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ChangeTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Change Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TraceFlag}" Width="100" ElementStyle="{StaticResource NumericCell}"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TraceFlag" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Trace Flag" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PreviousStatusDisplay}" Width="110"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PreviousStatusDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Previous Status" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding NewStatusDisplay}" Width="110"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="NewStatusDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="New Status" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Scope}" Width="90"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Scope" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Scope" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding GlobalDisplay}" Width="80"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="GlobalDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Global" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding SessionDisplay}" Width="80"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SessionDisplay" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Session" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ChangeDescription}" Width="*" MinWidth="230"><DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ChangeDescription" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Change Description" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header></DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + <TextBlock x:Name="TraceFlagChangesNoDataMessage" Visibility="Collapsed" HorizontalAlignment="Center" VerticalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap" MaxWidth="480" FontStyle="Italic" Foreground="{DynamicResource ForegroundDimBrush}" Text="No trace flag changes in the selected time window (needs at least two config captures bracketing an enable/disable)."/> + </Grid> + </Grid> + </TabItem> + </TabControl> + </TabItem> + + <!-- Long Queries Tab (#1496) — completed long-running queries + attentions (cancels/timeouts) from + the opt-in PerformanceMonitor_LongQueryCompletions XE session. Default OFF; the banner points to + where the switch lives when the collector is disabled. --> + <TabItem Header="Long Queries"> + <Grid Margin="8"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + </Grid.RowDefinitions> + <TextBlock Grid.Row="0" Text="Long-Running Query Completions (RPC / batch completions over the duration threshold, plus attentions / cancels)" FontSize="14" FontWeight="SemiBold" Margin="0,0,0,8" TextWrapping="Wrap"/> + <TextBlock Grid.Row="1" x:Name="LongQueriesDisabledWarning" Visibility="Collapsed" + Foreground="{DynamicResource WarningTextBrush}" FontSize="12" Margin="0,0,0,8" TextWrapping="Wrap" + Text="The long-query completion trace is OFF for this server. It is opt-in because a completion trace adds overhead on busy servers. Turn it on in Settings → Collector Schedules → Edit (Default or per-server), then tick the 'long_query_completions' Enabled box. Enabling it creates the Extended Events session on this server; disabling it drops the session."/> + <DataGrid Grid.Row="2" x:Name="LongQueryCompletionsGrid" + AutoGenerateColumns="False" IsReadOnly="True" + HeadersVisibility="Column" GridLinesVisibility="Horizontal" + HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" + CanUserSortColumns="True"> + <DataGrid.RowStyle> + <Style TargetType="DataGridRow" BasedOn="{StaticResource GridRowStyle}"> + <Style.Triggers> + <DataTrigger Binding="{Binding IsAborted}" Value="True"> + <Setter Property="Background" Value="#33FF6B6B"/> + </DataTrigger> + <DataTrigger Binding="{Binding IsAttention}" Value="True"> + <Setter Property="Background" Value="#33FFB86B"/> + </DataTrigger> + </Style.Triggers> + </Style> + </DataGrid.RowStyle> + <DataGrid.Columns> + <DataGridTextColumn Binding="{Binding EventTimeLocal}" Width="150" SortMemberPath="EventTime"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EventTimeLocal" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Event Time" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding EventType}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="EventType" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Event Type" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DurationFormatted}" Width="100" SortMemberPath="DurationMicroseconds"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DurationFormatted" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Duration" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding CpuFormatted}" Width="100" SortMemberPath="CpuTimeMicroseconds"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="CpuFormatted" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="CPU" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding LogicalReads}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="LogicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Logical Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding PhysicalReads}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PhysicalReads" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Physical Reads" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Writes}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Writes" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Writes" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding RowCountValue}" ElementStyle="{StaticResource NumericCell}" Width="90"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="RowCountValue" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Rows" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding Result}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Result" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Result" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding DatabaseName}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="DatabaseName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Database" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ObjectName}" Width="180"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ObjectName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Object" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTemplateColumn Header="Statement" Width="360"> + <DataGridTemplateColumn.CellTemplate> + <DataTemplate> + <TextBlock Text="{Binding StatementText}" TextTrimming="CharacterEllipsis" ToolTip="{Binding StatementText}"/> + </DataTemplate> + </DataGridTemplateColumn.CellTemplate> + </DataGridTemplateColumn> + <DataGridTextColumn Binding="{Binding SessionId}" ElementStyle="{StaticResource NumericCell}" Width="80"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="SessionId" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Session" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ClientAppName}" Width="180"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ClientAppName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Application" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding ServerPrincipalName}" Width="150"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="ServerPrincipalName" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Login" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding QueryHash}" Width="150"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="QueryHash" Click="FilterButton_Click" Margin="0,0,4,0"/><TextBlock Text="Query Hash" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + </Grid> + </TabItem> + </TabControl> </Grid> </UserControl> diff --git a/Lite/Controls/ServerTab.xaml.cs b/Lite/Controls/ServerTab.xaml.cs index 46c47f7ea..994ad36f2 100644 --- a/Lite/Controls/ServerTab.xaml.cs +++ b/Lite/Controls/ServerTab.xaml.cs @@ -49,9 +49,15 @@ public partial class ServerTab : UserControl // MainTabControl_SelectionChanged skips its refresh and doesn't clobber the filtered snapshot // the drill-down loads next (async race). private bool _suppressActiveQueriesAutoRefresh; - private readonly Dictionary<ScottPlot.WPF.WpfPlot, ScottPlot.IPanel?> _legendPanels = new(); + private readonly ChartRenderHelper _chartHelper = new(); private List<SelectableItem> _waitTypeItems = new(); private List<SelectableItem> _perfmonCounterItems = new(); + /* #1319: per-server global database filter (display-only). Empty set = "All" (unfiltered). */ + private readonly HashSet<string> _selectedDatabases = new(StringComparer.OrdinalIgnoreCase); + private List<SelectableItem> _databaseFilterItems = new(); + private bool _isUpdatingDatabaseFilterSelection; + /// <summary>The database filter as a reader argument: null (= All, unfiltered) when nothing is selected.</summary> + private IReadOnlyList<string>? SelectedDatabaseFilter => _selectedDatabases.Count == 0 ? null : _selectedDatabases.ToList(); private ChartHoverHelper? _waitStatsHover; private ChartHoverHelper? _perfmonHover; private ChartHoverHelper? _cpuHover; @@ -89,8 +95,6 @@ public partial class ServerTab : UserControl private bool _isUpdatingMemoryClerkSelection; /* Column filtering */ - private Popup? _filterPopup; - private ColumnFilterPopup? _filterPopupContent; private readonly Dictionary<DataGrid, IDataGridFilterManager> _filterManagers = new(); private DataGridFilterManager<QuerySnapshotRow>? _querySnapshotsFilterMgr; private DataGridFilterManager<QueryStatsRow>? _queryStatsFilterMgr; @@ -99,18 +103,38 @@ public partial class ServerTab : UserControl private DataGridFilterManager<BlockedProcessReportRow>? _blockedProcessFilterMgr; private DataGridFilterManager<DeadlockProcessDetail>? _deadlockFilterMgr; private DataGridFilterManager<RunningJobRow>? _runningJobsFilterMgr; + private DataGridFilterManager<LongQueryCompletionRow>? _longQueryFilterMgr; private DataGridFilterManager<ServerConfigRow>? _serverConfigFilterMgr; private DataGridFilterManager<DatabaseConfigRow>? _databaseConfigFilterMgr; private DataGridFilterManager<DatabaseScopedConfigRow>? _dbScopedConfigFilterMgr; private DataGridFilterManager<TraceFlagRow>? _traceFlagsFilterMgr; private DataGridFilterManager<CollectorHealthRow>? _collectionHealthFilterMgr; private DataGridFilterManager<CollectionLogRow>? _collectionLogFilterMgr; - private DateTime? _dailySummaryDate; // null = today + private DataGridFilterManager<LatchStatsSnapshotRow>? _latchStatsFilterMgr; + private DataGridFilterManager<SpinlockStatsSnapshotRow>? _spinlockStatsFilterMgr; + /* System Events tab: one filter manager per grid category (the two chart sub-tabs have no grid). */ + private DataGridFilterManager<SchedulerIssueRow>? _seSchedulerFilterMgr; + private DataGridFilterManager<SevereErrorRow>? _seSevereErrorFilterMgr; + private DataGridFilterManager<MemoryConditionsRow>? _seMemoryConditionsFilterMgr; + private DataGridFilterManager<MemoryBrokerRow>? _seMemoryBrokerFilterMgr; + private DataGridFilterManager<MemoryNodeOomRow>? _seMemoryNodeOomFilterMgr; + private DataGridFilterManager<SignificantWaitRow>? _seSignificantWaitsFilterMgr; + private DataGridFilterManager<CpuTasksRow>? _seCpuTasksFilterMgr; + private DataGridFilterManager<IoIssuesRow>? _seIoIssuesFilterMgr; + private DataGridFilterManager<DefaultTraceEventRow>? _seDefaultTraceFilterMgr; + /* Configuration Changes tab: one filter manager per sub-tab grid. */ + private DataGridFilterManager<ServerConfigChangeRow>? _serverConfigChangesFilterMgr; + private DataGridFilterManager<DatabaseConfigChangeRow>? _databaseConfigChangesFilterMgr; + private DataGridFilterManager<TraceFlagChangeRow>? _traceFlagChangesFilterMgr; private CancellationTokenSource? _actualPlanCts; public int UtcOffsetMinutes { get; } private readonly bool _hasMsdbAccess; private readonly bool _isAzureSqlDatabase; + /* Live probe of the opt-in long-query completion collector's enabled flag (#1496), so the Long + Queries tab shows an explicit "trace is OFF" empty-state banner when it is disabled — read fresh + each refresh so toggling it in the schedule editor updates the banner without reopening the tab. */ + private readonly Func<bool> _isLongQueryTraceEnabled; /// <summary> /// Raised after each data refresh with alert counts for tab badge display. @@ -118,13 +142,19 @@ public partial class ServerTab : UserControl public event Action<int, int, DateTime?>? AlertCountsChanged; /* blockingCount, deadlockCount, latestEventTimeUtc */ public event Action<int>? ApplyTimeRangeRequested; /* selectedIndex */ public event Func<Task>? ManualRefreshRequested; + public event Action<ServerConnection>? PersistServerRequested; /* #1319: persist ViewFilterDatabases via ServerManager */ - public ServerTab(ServerConnection server, DuckDbInitializer duckDb, CredentialResolver credentialResolver, int utcOffsetMinutes = 0, bool hasMsdbAccess = true, bool isAzureSqlDatabase = false) + public ServerTab(ServerConnection server, DuckDbInitializer duckDb, CredentialResolver credentialResolver, int utcOffsetMinutes = 0, bool hasMsdbAccess = true, bool isAzureSqlDatabase = false, Func<bool>? isLongQueryTraceEnabled = null) { InitializeComponent(); SetupBarCellMaxes(); _server = server; + /* Default to "enabled" when no probe is supplied so the empty-state banner never falsely claims + the trace is off; MainWindow always wires the live schedule probe. */ + _isLongQueryTraceEnabled = isLongQueryTraceEnabled ?? (() => true); + foreach (var db in _server.ViewFilterDatabases) + _selectedDatabases.Add(db); _dataService = new LocalDataService(duckDb); _serverId = RemoteCollectorService.GetDeterministicHashCode(RemoteCollectorService.GetServerNameForStorage(server)); _credentialResolver = credentialResolver; @@ -196,7 +226,10 @@ refresh once when it becomes visible again so it isn't showing stale data on ret foreach (var grid in new DataGrid[] { QuerySnapshotsGrid, QueryStatsGrid, ProcedureStatsGrid, QueryStoreGrid, BlockedProcessReportGrid, DeadlockGrid, RunningJobsGrid, ServerConfigGrid, DatabaseConfigGrid, DatabaseScopedConfigGrid, TraceFlagsGrid, - CollectionHealthGrid, CollectionLogGrid }) + CollectionHealthGrid, CollectionLogGrid, LatchStatsGrid, SpinlockStatsGrid, + SchedulerIssuesGrid, SevereErrorsGrid, MemoryConditionsGrid, + MemoryBrokerGrid, MemoryNodeOomGrid, SignificantWaitsGrid, CpuTasksGrid, IoIssuesGrid, + DefaultTraceGrid, ServerConfigChangesGrid, DatabaseConfigChangesGrid, TraceFlagChangesGrid }) { grid.CopyingRowClipboardContent += DataGridClipboardBehavior.FixHeaderCopy; } @@ -257,6 +290,23 @@ refresh once when it becomes visible again so it isn't showing stale data on ret _currentWaitsDurationHover = new ChartHoverHelper(CurrentWaitsDurationChart, "ms"); _currentWaitsBlockedHover = new ChartHoverHelper(CurrentWaitsBlockedChart, "sessions"); + /* Latch/spinlock charts: theme + hover up front (own partial, mirrors Darling's tab file) */ + InitializeLatchSpinlockCharts(); + + /* CPU Scheduler / Plan Cache / Session Stats charts: theme + hover up front (own partials, + mirror Darling's tab files) so they don't flash white before the tabs' first load. */ + InitializeCpuSchedulerChart(); + InitializePlanCacheChart(); + InitializeSessionStatsChart(); + + /* System Events Corruption/Contention counter charts: theme + hover up front (own partial, + mirrors Darling's SystemHealthCharts). */ + InitializeSystemHealthCharts(); + + /* Blocking Stats severity charts: theme + hover up front (own partial, mirrors Darling's + Blocking Stats sub-tab) so the four charts don't flash white before the sub-tab's first load. */ + InitializeBlockingStatsCharts(); + /* Query heatmap hover popup */ _heatmapPopupText = new TextBlock { @@ -282,7 +332,7 @@ refresh once when it becomes visible again so it isn't showing stale data on ret } }; /* Heatmap mouse events wired up in XAML */ - var heatmapMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(QueryHeatmapChart, "Query_Heatmap"); + var heatmapMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(QueryHeatmapChart, "Query_Heatmap", revertAction: RevertChartAxes); var heatmapDrillDown = new MenuItem { Header = "Show Active Queries at This Time" }; heatmapMenu.Items.Insert(0, heatmapDrillDown); heatmapMenu.Items.Insert(1, new Separator()); @@ -315,51 +365,55 @@ refresh once when it becomes visible again so it isn't showing stale data on ret }; /* Chart context menus (right-click save/export) */ - var waitStatsMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(WaitStatsChart, "Wait_Stats"); + var waitStatsMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(WaitStatsChart, "Wait_Stats", revertAction: RevertChartAxes); AddWaitDrillDownMenuItem(WaitStatsChart, waitStatsMenu); - Helpers.ContextMenuHelper.SetupChartContextMenu(QueryDurationTrendChart, "Query_Duration_Trends"); - Helpers.ContextMenuHelper.SetupChartContextMenu(ProcDurationTrendChart, "Procedure_Duration_Trends"); - Helpers.ContextMenuHelper.SetupChartContextMenu(QueryStoreDurationTrendChart, "QueryStore_Duration_Trends"); - Helpers.ContextMenuHelper.SetupChartContextMenu(ExecutionCountTrendChart, "Execution_Count_Trends"); - var cpuMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(CpuChart, "CPU_Usage"); - AddChartDrillDownMenuItem(CpuChart, cpuMenu, _cpuHover, "Show Active Queries at This Time", OnCpuDrillDown); - var memoryMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(MemoryChart, "Memory_Usage"); - AddChartDrillDownMenuItem(MemoryChart, memoryMenu, _memoryHover, "Show Active Queries at This Time", OnMemoryDrillDown); - var memoryClerksMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(MemoryClerksChart, "Memory_Clerks"); + var queryDurationTrendMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(QueryDurationTrendChart, "Query_Duration_Trends", revertAction: RevertChartAxes); + AddChartDrillDownMenuItem(QueryDurationTrendChart, queryDurationTrendMenu, _queryDurationTrendHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + var procDurationTrendMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(ProcDurationTrendChart, "Procedure_Duration_Trends", revertAction: RevertChartAxes); + AddChartDrillDownMenuItem(ProcDurationTrendChart, procDurationTrendMenu, _procDurationTrendHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + var queryStoreDurationTrendMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(QueryStoreDurationTrendChart, "QueryStore_Duration_Trends", revertAction: RevertChartAxes); + AddChartDrillDownMenuItem(QueryStoreDurationTrendChart, queryStoreDurationTrendMenu, _queryStoreDurationTrendHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + var executionCountTrendMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(ExecutionCountTrendChart, "Execution_Count_Trends", revertAction: RevertChartAxes); + AddChartDrillDownMenuItem(ExecutionCountTrendChart, executionCountTrendMenu, _executionCountTrendHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + var cpuMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(CpuChart, "CPU_Usage", revertAction: RevertChartAxes); + AddChartDrillDownMenuItem(CpuChart, cpuMenu, _cpuHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + var memoryMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(MemoryChart, "Memory_Usage", revertAction: RevertChartAxes); + AddChartDrillDownMenuItem(MemoryChart, memoryMenu, _memoryHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + var memoryClerksMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(MemoryClerksChart, "Memory_Clerks", revertAction: RevertChartAxes); AddChartDrillDownMenuItem(MemoryClerksChart, memoryClerksMenu, _memoryClerksHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); - var memoryGrantSizingMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(MemoryGrantSizingChart, "Memory_Grant_Sizing"); + var memoryGrantSizingMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(MemoryGrantSizingChart, "Memory_Grant_Sizing", revertAction: RevertChartAxes); AddChartDrillDownMenuItem(MemoryGrantSizingChart, memoryGrantSizingMenu, _memoryGrantSizingHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); - var memoryGrantActivityMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(MemoryGrantActivityChart, "Memory_Grant_Activity"); + var memoryGrantActivityMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(MemoryGrantActivityChart, "Memory_Grant_Activity", revertAction: RevertChartAxes); AddChartDrillDownMenuItem(MemoryGrantActivityChart, memoryGrantActivityMenu, _memoryGrantActivityHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); - var memoryPressureEventsMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(MemoryPressureEventsChart, "Memory_Pressure_Events"); + var memoryPressureEventsMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(MemoryPressureEventsChart, "Memory_Pressure_Events", revertAction: RevertChartAxes); AddChartDrillDownMenuItem(MemoryPressureEventsChart, memoryPressureEventsMenu, _memoryPressureEventsHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); - var fileIoReadMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(FileIoReadChart, "File_IO_Read_Latency"); + var fileIoReadMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(FileIoReadChart, "File_IO_Read_Latency", revertAction: RevertChartAxes); AddChartDrillDownMenuItem(FileIoReadChart, fileIoReadMenu, _fileIoReadHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); - var fileIoWriteMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(FileIoWriteChart, "File_IO_Write_Latency"); + var fileIoWriteMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(FileIoWriteChart, "File_IO_Write_Latency", revertAction: RevertChartAxes); AddChartDrillDownMenuItem(FileIoWriteChart, fileIoWriteMenu, _fileIoWriteHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); - var fileIoReadThroughputMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(FileIoReadThroughputChart, "File_IO_Read_Throughput"); + var fileIoReadThroughputMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(FileIoReadThroughputChart, "File_IO_Read_Throughput", revertAction: RevertChartAxes); AddChartDrillDownMenuItem(FileIoReadThroughputChart, fileIoReadThroughputMenu, _fileIoReadThroughputHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); - var fileIoWriteThroughputMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(FileIoWriteThroughputChart, "File_IO_Write_Throughput"); + var fileIoWriteThroughputMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(FileIoWriteThroughputChart, "File_IO_Write_Throughput", revertAction: RevertChartAxes); AddChartDrillDownMenuItem(FileIoWriteThroughputChart, fileIoWriteThroughputMenu, _fileIoWriteThroughputHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); - var tempDbMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(TempDbChart, "TempDB_Stats"); - AddChartDrillDownMenuItem(TempDbChart, tempDbMenu, _tempDbHover, "Show Active Queries at This Time", OnTempDbDrillDown); - var tempDbSizeMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(TempDbSizeChart, "TempDB_Allocated_Size"); + var tempDbMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(TempDbChart, "TempDB_Stats", revertAction: RevertChartAxes); + AddChartDrillDownMenuItem(TempDbChart, tempDbMenu, _tempDbHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); + var tempDbSizeMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(TempDbSizeChart, "TempDB_Allocated_Size", revertAction: RevertChartAxes); AddChartDrillDownMenuItem(TempDbSizeChart, tempDbSizeMenu, _tempDbSizeHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); - var tempDbFileIoMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(TempDbFileIoChart, "TempDB_File_IO"); + var tempDbFileIoMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(TempDbFileIoChart, "TempDB_File_IO", revertAction: RevertChartAxes); AddChartDrillDownMenuItem(TempDbFileIoChart, tempDbFileIoMenu, _tempDbFileIoHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); - var lockWaitMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(LockWaitTrendChart, "Lock_Wait_Trends"); + var lockWaitMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(LockWaitTrendChart, "Lock_Wait_Trends", revertAction: RevertChartAxes); AddChartDrillDownMenuItem(LockWaitTrendChart, lockWaitMenu, _lockWaitTrendHover, "Show Blocking at This Time", OnBlockingDrillDown); - var blockingMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(BlockingTrendChart, "Blocking_Trends"); + var blockingMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(BlockingTrendChart, "Blocking_Trends", revertAction: RevertChartAxes); AddChartDrillDownMenuItem(BlockingTrendChart, blockingMenu, _blockingTrendHover, "Show Blocking at This Time", OnBlockingDrillDown); - var deadlockMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(DeadlockTrendChart, "Deadlock_Trends"); + var deadlockMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(DeadlockTrendChart, "Deadlock_Trends", revertAction: RevertChartAxes); AddChartDrillDownMenuItem(DeadlockTrendChart, deadlockMenu, _deadlockTrendHover, "Show Deadlocks at This Time", OnDeadlockDrillDown); - var currentWaitsDurationMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(CurrentWaitsDurationChart, "Current_Waits_Duration"); + var currentWaitsDurationMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(CurrentWaitsDurationChart, "Current_Waits_Duration", revertAction: RevertChartAxes); AddChartDrillDownMenuItem(CurrentWaitsDurationChart, currentWaitsDurationMenu, _currentWaitsDurationHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); - var currentWaitsBlockedMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(CurrentWaitsBlockedChart, "Current_Waits_Blocked"); + var currentWaitsBlockedMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(CurrentWaitsBlockedChart, "Current_Waits_Blocked", revertAction: RevertChartAxes); AddChartDrillDownMenuItem(CurrentWaitsBlockedChart, currentWaitsBlockedMenu, _currentWaitsBlockedHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); - var perfmonMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(PerfmonChart, "Perfmon_Counters"); + var perfmonMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(PerfmonChart, "Perfmon_Counters", revertAction: RevertChartAxes); AddChartDrillDownMenuItem(PerfmonChart, perfmonMenu, _perfmonHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); - Helpers.ContextMenuHelper.SetupChartContextMenu(CollectorDurationChart, "Collector_Duration"); + Helpers.ContextMenuHelper.SetupChartContextMenu(CollectorDurationChart, "Collector_Duration", revertAction: RevertChartAxes); /* Subscribe for the life of the tab. Do NOT unsubscribe on Unloaded — a TabControl fires Unloaded when you switch to another tab, which would permanently detach this handler so @@ -415,7 +469,8 @@ private async void MainTabControl_SelectionChanged(object sender, SelectionChang if (!IsLoaded || _dataService == null) return; if (_isRefreshing) return; if (e.Source != MainTabControl && e.Source != QueriesSubTabControl - && e.Source != MemorySubTabControl && e.Source != BlockingSubTabControl) return; + && e.Source != MemorySubTabControl && e.Source != BlockingSubTabControl + && e.Source != SystemEventsSubTabControl && e.Source != ConfigChangesSubTabControl) return; UpdateCompareDropdownState(); @@ -443,41 +498,6 @@ private async void MainTabControl_SelectionChanged(object sender, SelectionChang await RefreshVisibleTabAsync(hoursBack, fromDate, toDate, subTabOnly: true); } - private void DailySummaryToday_Click(object sender, RoutedEventArgs e) - { - _dailySummaryDate = null; - DailySummaryDatePicker.SelectedDate = null; - DailySummaryTodayButton.FontWeight = FontWeights.Bold; - DailySummaryIndicator.Text = "Showing: Today (UTC)"; - DailySummaryRefresh_Click(sender, e); - } - - private void DailySummaryDate_Changed(object sender, SelectionChangedEventArgs e) - { - if (DailySummaryDatePicker.SelectedDate.HasValue) - { - _dailySummaryDate = DailySummaryDatePicker.SelectedDate.Value.Date; - DailySummaryTodayButton.FontWeight = FontWeights.Normal; - DailySummaryIndicator.Text = $"Showing: {_dailySummaryDate.Value:MMM d, yyyy}"; - } - } - - private async void DailySummaryRefresh_Click(object sender, RoutedEventArgs e) - { - try - { - var result = await Task.Run(() => _dataService.GetDailySummaryAsync(_serverId, _dailySummaryDate)); - DailySummaryGrid.ItemsSource = result != null - ? new List<DailySummaryRow> { result } : null; - DailySummaryNoData.Visibility = result == null - ? Visibility.Visible : Visibility.Collapsed; - } - catch (Exception ex) - { - AppLogger.Error("DailySummary", $"Error refreshing: {ex.Message}"); - } - } - private async void LiveSnapshot_Click(object sender, RoutedEventArgs e) { LiveSnapshotButton.IsEnabled = false; @@ -608,5 +628,11 @@ public void DisposeChartHelpers() _memoryPressureEventsHover?.Dispose(); _currentWaitsDurationHover?.Dispose(); _currentWaitsBlockedHover?.Dispose(); + DisposeLatchSpinlockHelpers(); + DisposeCpuSchedulerHelpers(); + DisposePlanCacheHelpers(); + DisposeSessionStatsHelpers(); + DisposeSystemHealthChartHelpers(); + DisposeBlockingStatsHelpers(); } } diff --git a/Lite/Database/AnalysisSchema.cs b/Lite/Database/AnalysisSchema.cs index 3e188b20c..726482d59 100644 --- a/Lite/Database/AnalysisSchema.cs +++ b/Lite/Database/AnalysisSchema.cs @@ -11,7 +11,7 @@ public static class AnalysisSchema /// <summary> /// Analysis schema version. Independent of main schema version. /// </summary> - public const int CurrentVersion = 3; + public const int CurrentVersion = 4; public const string CreateAnalysisFindingsTable = @" CREATE TABLE IF NOT EXISTS analysis_findings ( @@ -22,18 +22,19 @@ CREATE TABLE IF NOT EXISTS analysis_findings ( database_name VARCHAR, time_range_start TIMESTAMP, time_range_end TIMESTAMP, - severity DOUBLE NOT NULL, - confidence DOUBLE NOT NULL, + severity DOUBLE PRECISION NOT NULL, + confidence DOUBLE PRECISION NOT NULL, category VARCHAR NOT NULL, story_path VARCHAR NOT NULL, story_path_hash VARCHAR NOT NULL, story_text VARCHAR NOT NULL, root_fact_key VARCHAR NOT NULL, - root_fact_value DOUBLE, + root_fact_value DOUBLE PRECISION, leaf_fact_key VARCHAR, - leaf_fact_value DOUBLE, + leaf_fact_value DOUBLE PRECISION, fact_count INTEGER NOT NULL, - incident_id VARCHAR + incident_id VARCHAR, + remediation_action_json VARCHAR )"; public const string CreateAnalysisMutedTable = @" @@ -47,31 +48,6 @@ CREATE TABLE IF NOT EXISTS analysis_muted ( reason VARCHAR )"; - public const string CreateAnalysisExclusionsTable = @" -CREATE TABLE IF NOT EXISTS analysis_exclusions ( - exclusion_id BIGINT PRIMARY KEY, - exclusion_type VARCHAR NOT NULL, - exclusion_value VARCHAR NOT NULL, - server_id INTEGER, - database_name VARCHAR, - is_enabled BOOLEAN NOT NULL DEFAULT true, - created_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - description VARCHAR -)"; - - public const string CreateAnalysisThresholdsTable = @" -CREATE TABLE IF NOT EXISTS analysis_thresholds ( - threshold_id BIGINT PRIMARY KEY, - category VARCHAR NOT NULL, - fact_key VARCHAR NOT NULL, - threshold_type VARCHAR NOT NULL, - threshold_value DOUBLE NOT NULL, - server_id INTEGER, - database_name VARCHAR, - is_enabled BOOLEAN NOT NULL DEFAULT true, - modified_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -)"; - public const string CreateAnalysisFindingsTimeIndex = @" CREATE INDEX IF NOT EXISTS idx_analysis_findings_time ON analysis_findings(server_id, analysis_time)"; @@ -84,10 +60,6 @@ CREATE INDEX IF NOT EXISTS idx_analysis_findings_hash CREATE INDEX IF NOT EXISTS idx_analysis_muted_hash ON analysis_muted(story_path_hash)"; - public const string CreateAnalysisThresholdsLookupIndex = @" -CREATE INDEX IF NOT EXISTS idx_analysis_thresholds_lookup - ON analysis_thresholds(category, fact_key)"; - /// <summary> /// Returns all analysis table creation statements. /// </summary> @@ -95,8 +67,6 @@ public static IEnumerable<string> GetAllTableStatements() { yield return CreateAnalysisFindingsTable; yield return CreateAnalysisMutedTable; - yield return CreateAnalysisExclusionsTable; - yield return CreateAnalysisThresholdsTable; } /// <summary> @@ -117,6 +87,16 @@ public static IEnumerable<string> GetMigrationStatements(int fromVersion) // cannot be NOT NULL; the writer always supplies a value (empty for healthy runs). yield return "ALTER TABLE analysis_findings ADD COLUMN IF NOT EXISTS incident_id VARCHAR"; } + + if (fromVersion < 4) + { + // v4: the built RemediationAction persisted as JSON (remediation copy-paste command, + // mirroring Darling's remediation_action_json). Nullable — DuckDB ADD COLUMN cannot be + // NOT NULL; the writer supplies NULL for findings that carry no execution shape. On + // read-back the Recommendations reader deserializes it and the shared renderer turns it + // into the copy-paste T-SQL, so a Lite card produces the SAME command a Darling card does. + yield return "ALTER TABLE analysis_findings ADD COLUMN IF NOT EXISTS remediation_action_json VARCHAR"; + } } /// <summary> @@ -127,6 +107,5 @@ public static IEnumerable<string> GetAllIndexStatements() yield return CreateAnalysisFindingsTimeIndex; yield return CreateAnalysisFindingsHashIndex; yield return CreateAnalysisMutedHashIndex; - yield return CreateAnalysisThresholdsLookupIndex; } } diff --git a/Lite/Database/DuckDbInitializer.cs b/Lite/Database/DuckDbInitializer.cs index 27ffbb86c..6f8f651fb 100644 --- a/Lite/Database/DuckDbInitializer.cs +++ b/Lite/Database/DuckDbInitializer.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using DuckDB.NET.Data; using Microsoft.Extensions.Logging; +using PerformanceMonitor.Collectors; namespace PerformanceMonitorLite.Database; @@ -97,7 +98,7 @@ public void Dispose() /// <summary> /// Current schema version. Increment this when schema changes require table rebuilds. /// </summary> - internal const int CurrentSchemaVersion = 32; + internal const int CurrentSchemaVersion = 47; private readonly string _archivePath; @@ -109,19 +110,35 @@ public DuckDbInitializer(string databasePath, ILogger<DuckDbInitializer>? logger } /* Tables that have parquet archives — views are created to UNION hot data with archived parquet files. - IMPORTANT: Must match ArchiveService.ArchivableTables — every archived table needs an archive view. */ - private static readonly string[] ArchivableTables = - [ - "wait_stats", "query_stats", "procedure_stats", "query_store_stats", - "query_snapshots", "cpu_utilization_stats", "file_io_stats", "memory_stats", - "memory_clerks", "memory_pressure_events", "tempdb_stats", "perfmon_stats", - "deadlocks", "blocked_process_reports", "memory_grant_stats", "waiting_tasks", - "dmv_blocking_snapshots", - "running_jobs", "database_size_stats", "index_object_stats", "server_properties", - "session_stats", "server_config", "database_config", - "database_scoped_config", "trace_flags", "config_alert_log", - "collection_log" - ]; + Catalog-driven: every collector table (from CollectorCatalog) plus the two non-collector time-series + tables (config_alert_log, collection_log). Adding a collector to the catalog gives it an archive view + for free — no hand-maintained list to keep in sync. Mirrors ArchiveService.ArchivableTables (same set, + same derivation); a test pins the two against each other and against the catalog. */ + internal static readonly string[] ArchivableTables = + CollectorCatalog.All.Select(c => c.TargetTable) + .Concat(["config_alert_log", "collection_log"]) + .ToArray(); + + /* Archive views for these tables must DEDUP the hot∪parquet union on a server-side natural key. + The 512MB emergency reset (ArchiveService.ArchiveAllAndResetAsync) archives all hot data to parquet + AND wipes collection_log, so the next cycle re-collects recent history into the hot store while the + parquet tier still holds it — the plain UNION ALL would then show each re-collected event twice. + The local surrogate prefix id (job_history_id / default_trace_event_id) is a per-process counter + (CollectionIdGenerator), so it is NOT stable across re-collection and cannot be the key — only the + SQL-Server-side identity is. Other archivable tables can't double up this way (normal archival keeps + hot and parquet disjoint, and their rows aren't re-collected after a reset), so they keep the plain + union. Value = the PARTITION BY column list for the QUALIFY ROW_NUMBER dedup. */ + private static readonly IReadOnlyDictionary<string, string> ArchiveViewDedupKeys = + new Dictionary<string, string>(StringComparer.Ordinal) + { + /* sysjobhistory.instance_id: a unique monotonic IDENTITY per server that survives + sp_purge_jobhistory — JobHistoryCollector's exact-and-complete dedup watermark. */ + ["job_history"] = "server_id, instance_id", + /* The default trace's EventSequence is unique within a trace; pairing it with event_time + (the StartTime watermark) keeps events distinct across the server restarts that reset + EventSequence, and groups identical re-collected rows (NULLs included) for dedup. */ + ["default_trace_events"] = "server_id, event_time, event_sequence", + }; /// <summary> /// Gets the connection string for the DuckDB database. @@ -194,6 +211,15 @@ Just create tables with the current schema and stamp the version. */ await SetSchemaVersionAsync(connection, CurrentSchemaVersion); } + /* Table count on the init connection — makes a failed reset (schema not persisting to the + file for the next connection to see) diagnosable from the log alone. */ + using (var tableCountCmd = connection.CreateCommand()) + { + tableCountCmd.CommandText = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'main'"; + var tableCount = Convert.ToInt64(await tableCountCmd.ExecuteScalarAsync()); + _logger?.LogInformation("Schema initialization created {Count} tables", tableCount); + } + _logger?.LogInformation("Database initialization complete. Schema version: {Version}", CurrentSchemaVersion); } @@ -449,10 +475,20 @@ created with these columns by GetAllTableStatements(). */ /* v5: Added database_scoped_config and trace_flags tables for database-scoped configuration and active trace flag collection. */ _logger?.LogInformation("Running migration to v5: adding database_scoped_config and trace_flags tables"); - await ExecuteNonQueryAsync(connection, Schema.CreateDatabaseScopedConfigTable); - await ExecuteNonQueryAsync(connection, Schema.CreateDatabaseScopedConfigIndex); - await ExecuteNonQueryAsync(connection, Schema.CreateTraceFlagsTable); - await ExecuteNonQueryAsync(connection, Schema.CreateTraceFlagsIndex); + /* Generated from the catalog (same source as GetAllTableStatements, which also recreates these + with IF NOT EXISTS immediately after migrations); byte-equivalent to the former hand-written + Schema constants this migration used before the schema was made catalog-driven. The index is + null-checked (both collectors have one today) rather than asserted, mirroring the generator. */ + foreach (ICollectorSchemaInfo collector in new[] + { (ICollectorSchemaInfo)DatabaseScopedConfigCollector.Instance, TraceFlagsCollector.Instance }) + { + await ExecuteNonQueryAsync(connection, DuckDbSchemaGenerator.CreateTable(collector)); + var collectorIndex = DuckDbSchemaGenerator.CreateIndex(collector); + if (collectorIndex is not null) + { + await ExecuteNonQueryAsync(connection, collectorIndex); + } + } } if (fromVersion < 6) @@ -809,6 +845,262 @@ existing watermark table to hold the newest already-alerted failure's server-loc _logger?.LogWarning("Migration to v32 encountered an error (non-fatal): {Error}", ex.Message); } } + + if (fromVersion < 33) + { + /* v33: procedure_stats gains delta_spills, and query_stats gains plan_generation_num + + sample_interval_seconds. All appended at the end to keep the positional appenders aligned; the + v_ views (SELECT *) surface them; old parquet reads back NULL (union BY NAME). The collectors now + write these columns, so an un-migrated DB would mis-align — these ALTERs are required. + - procedure_stats.delta_spills: spill-delta parity with query_stats (proc Total/Avg Spills now + reflect per-window work, not summed cumulative DMV totals). + - query_stats.plan_generation_num: plan-stability signal. + - query_stats.sample_interval_seconds: lets the display derive worker_time_per_second (CPU-ms/sec). */ + _logger?.LogInformation("Running migration to v33: proc delta_spills + query_stats signal columns"); + try + { + await ExecuteNonQueryAsync(connection, "ALTER TABLE procedure_stats ADD COLUMN IF NOT EXISTS delta_spills BIGINT"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS plan_generation_num BIGINT"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE query_stats ADD COLUMN IF NOT EXISTS sample_interval_seconds INTEGER"); + } + catch (Exception ex) + { + _logger?.LogWarning("Migration to v33 encountered an error (non-fatal): {Error}", ex.Message); + } + } + + if (fromVersion < 34) + { + /* v34: query_snapshots gains the wait-drilldown triage columns Dashboard collects via + sp_WhoIsActive — memory-grant requested/used/max-used (MB), tempdb current/allocations + (MB), transaction log used (MB) + transaction start time, and request_id. All appended + at the end to keep the positional appender aligned; the v_ view (SELECT *) surfaces + them and old parquet reads back NULL (union BY NAME). The snapshot collector writes + these columns, so an un-migrated DB would mis-align — these ALTERs are required. */ + _logger?.LogInformation("Running migration to v34: query_snapshots memory-grant/tempdb/transaction columns"); + try + { + await ExecuteNonQueryAsync(connection, "ALTER TABLE query_snapshots ADD COLUMN IF NOT EXISTS requested_memory_mb DOUBLE PRECISION"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE query_snapshots ADD COLUMN IF NOT EXISTS used_memory_mb DOUBLE PRECISION"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE query_snapshots ADD COLUMN IF NOT EXISTS max_used_memory_mb DOUBLE PRECISION"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE query_snapshots ADD COLUMN IF NOT EXISTS tempdb_current_mb DOUBLE PRECISION"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE query_snapshots ADD COLUMN IF NOT EXISTS tempdb_allocations_mb DOUBLE PRECISION"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE query_snapshots ADD COLUMN IF NOT EXISTS tran_log_used_mb DOUBLE PRECISION"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE query_snapshots ADD COLUMN IF NOT EXISTS tran_start_time TIMESTAMP"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE query_snapshots ADD COLUMN IF NOT EXISTS request_id INTEGER"); + } + catch (Exception ex) + { + _logger?.LogWarning("Migration to v34 encountered an error (non-fatal): {Error}", ex.Message); + } + } + + if (fromVersion < 35) + { + /* v35: deferred execution-plan capture (#1262) — procedure_stats.query_plan_xml, + blocked_process_reports.blocked_query_plan_xml + blocking_query_plan_xml, and + deadlocks.victim_query_plan_xml. All appended at the end to keep the positional appenders + aligned; the v_ views (SELECT *) surface them and old parquet reads back NULL (union BY + NAME). The collectors now write these columns unconditionally — always NULL on Lite, which + never sets CapturePlanXml (Darling-only) — so an un-migrated DB would mis-align on the next + append; these ALTERs are required. */ + _logger?.LogInformation("Running migration to v35: procedure/blocked-process/deadlock plan columns"); + try + { + await ExecuteNonQueryAsync(connection, "ALTER TABLE procedure_stats ADD COLUMN IF NOT EXISTS query_plan_xml VARCHAR"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE blocked_process_reports ADD COLUMN IF NOT EXISTS blocked_query_plan_xml VARCHAR"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE blocked_process_reports ADD COLUMN IF NOT EXISTS blocking_query_plan_xml VARCHAR"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE deadlocks ADD COLUMN IF NOT EXISTS victim_query_plan_xml VARCHAR"); + } + catch (Exception ex) + { + _logger?.LogWarning("Migration to v35 encountered an error (non-fatal): {Error}", ex.Message); + } + } + + if (fromVersion < 36) + { + /* v36: server_properties gains sqlserver_start_time / host_os_version / ag_replica_role — the + three fields the shared ServerPropertiesCollector now SELECTs (previously read only from a + live query in the FinOps Server Inventory). All appended at the end to keep the positional + appender aligned; the collector writes them unconditionally, so an un-migrated DB would + mis-align on the next append — these ALTERs are required. */ + _logger?.LogInformation("Running migration to v36: server_properties start-time / host-OS / AG-role columns"); + try + { + await ExecuteNonQueryAsync(connection, "ALTER TABLE server_properties ADD COLUMN IF NOT EXISTS sqlserver_start_time TIMESTAMP"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE server_properties ADD COLUMN IF NOT EXISTS host_os_version VARCHAR"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE server_properties ADD COLUMN IF NOT EXISTS ag_replica_role VARCHAR"); + } + catch (Exception ex) + { + _logger?.LogWarning("Migration to v36 encountered an error (non-fatal): {Error}", ex.Message); + } + } + + if (fromVersion < 37) + { + /* v37: added latch_stats (sys.dm_os_latch_stats) and spinlock_stats + (sys.dm_os_spinlock_stats) shared collectors for Dashboard->Darling collection + parity. New tables only — created by GetAllTableStatements() below; the v_ views + come from CreateArchiveViewsAsync via ArchivableTables. */ + _logger?.LogInformation("Running migration to v37: adding latch_stats and spinlock_stats tables"); + } + + if (fromVersion < 38) + { + /* v38: added cpu_scheduler_stats (sys.dm_os_schedulers + workload groups + NUMA nodes + + OS memory) and plan_cache_stats (sys.dm_exec_cached_plans) shared collectors for + Dashboard->Darling collection parity. New tables only — created by + GetAllTableStatements() below; the v_ views come from CreateArchiveViewsAsync via + ArchivableTables. */ + _logger?.LogInformation("Running migration to v38: adding cpu_scheduler_stats and plan_cache_stats tables"); + } + + if (fromVersion < 39) + { + /* v39: added session_summary_stats (server-wide session SUMMARY from sys.dm_exec_sessions + + sys.dm_exec_requests: total/running/sleeping/background/dormant sessions, idle + sessions over 30 min, memory-wait count, top application/host) — the Dashboard-> + Darling connection-leak / idle parity collector. Distinct from the per-application + session_stats table. New table only — created by GetAllTableStatements() below; the + v_ view comes from CreateArchiveViewsAsync via ArchivableTables. */ + _logger?.LogInformation("Running migration to v39: adding session_summary_stats table"); + } + + if (fromVersion < 40) + { + /* v40: added system_health_events (Stage 1 raw system_health Extended Events capture — + one row per event, raw XML only, no shredding) for Dashboard->Darling health-parser + parity. New table only — created by GetAllTableStatements() below; the v_ view comes + from CreateArchiveViewsAsync via ArchivableTables. */ + _logger?.LogInformation("Running migration to v40: adding system_health_events table"); + } + + if (fromVersion < 41) + { + /* v41: index_object_stats gains the per-index DEFINITION metadata monitor-side + UNUSED/DUPLICATE analysis needs (FinOps Index Analysis, Stage 1): the ordered + key_columns / included_columns lists (sp_IndexCleanup's delimited representation), + filter_definition, the uniqueness/constraint/FK discriminators + is_disabled, and + the reconstruct-a-CREATE options (data_compression_desc, optimize_for_sequential_key, + fill_factor, is_padded, allow_page_locks, allow_row_locks). All appended at the end + to keep the positional appender aligned; the collector now writes them, so an + un-migrated DB would mis-align on the next append — these ALTERs are required. The + v_ view (SELECT *) surfaces them and old parquet reads back NULL (union BY NAME). */ + _logger?.LogInformation("Running migration to v41: adding index_object_stats index-definition columns"); + try + { + await ExecuteNonQueryAsync(connection, "ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS key_columns VARCHAR"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS included_columns VARCHAR"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS filter_definition VARCHAR"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS is_unique_constraint BOOLEAN"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS is_foreign_key BOOLEAN"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS is_foreign_key_reference BOOLEAN"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS is_disabled BOOLEAN"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS data_compression_desc VARCHAR"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS optimize_for_sequential_key BOOLEAN"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS fill_factor SMALLINT"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS is_padded BOOLEAN"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS allow_page_locks BOOLEAN"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS allow_row_locks BOOLEAN"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE index_object_stats ADD COLUMN IF NOT EXISTS is_indexed_view BOOLEAN"); + } + catch (Exception ex) + { + _logger?.LogWarning("Migration to v41 encountered an error (non-fatal): {Error}", ex.Message); + } + } + + if (fromVersion < 42) + { + /* v42: server_properties gains utc_offset_minutes — the monitored server's UTC offset the + shared collector now writes (DATEDIFF(MINUTE, GETUTCDATE(), GETDATE())). Appended at + the end to keep the positional appender aligned; the collector now writes it, so an + un-migrated DB would mis-align on the next append — this ALTER is required. Nullable + (DuckDB has no ADD COLUMN NOT NULL); the offset is a stored fact, not a delta. */ + _logger?.LogInformation("Running migration to v42: adding utc_offset_minutes column to server_properties"); + try + { + await ExecuteNonQueryAsync(connection, "ALTER TABLE server_properties ADD COLUMN IF NOT EXISTS utc_offset_minutes INTEGER"); + } + catch (Exception ex) + { + _logger?.LogWarning("Migration to v42 encountered an error (non-fatal): {Error}", ex.Message); + } + } + + if (fromVersion < 43) + { + /* v43: added default_trace_events (built-in Default Trace read via sys.fn_trace_gettable — + file auto-grow/shrink stalls, severe ErrorLog writes, schema DDL, security audits, + Server Memory Change) for Dashboard->shared parity. New table only — created by + GetAllTableStatements() below; the v_ view comes from CreateArchiveViewsAsync via + ArchivableTables. */ + _logger?.LogInformation("Running migration to v43: adding default_trace_events table"); + } + + if (fromVersion < 44) + { + /* v44: added job_history (retained SQL Agent job-run history from msdb.dbo.sysjobhistory — + every step row + the job-outcome row, deduped on the monotonic instance_id high-water + mark, 365-day retention) for the fleet-wide Job History tab (issue #1433). New table + only — created by GetAllTableStatements() below; the v_ view comes from + CreateArchiveViewsAsync via ArchivableTables. */ + _logger?.LogInformation("Running migration to v44: adding job_history table"); + } + + if (fromVersion < 45) + { + /* v45: added agent_status (SQL Agent service Running/Stopped from sys.dm_server_services + + next scheduled run from msdb.dbo.sysjobschedules) — the current-state snapshot behind the + Job History tab header and the "Agent Not Running" alert (issue #1433 Phase 2). New table + only — created by GetAllTableStatements() below; the v_ view comes from + CreateArchiveViewsAsync via ArchivableTables. */ + _logger?.LogInformation("Running migration to v45: adding agent_status table"); + } + + if (fromVersion < 46) + { + /* v46: deadlocks.database_name — the victim process's currentdbname, keying the Azure SQL DB + per-database watermark (#1535: capture is now one database-scoped session per monitored + database). Appended at the end to keep the positional appender aligned; the collector + writes it unconditionally (null when the graph carries no currentdbname), so an + un-migrated DB would mis-align on the next append — this ALTER is required. The v_ view + (SELECT *) is rebuilt every startup and picks it up; old parquet reads back NULL (union + BY NAME). blocked_process_reports already had database_name. */ + _logger?.LogInformation("Running migration to v46: deadlocks database_name column"); + try + { + await ExecuteNonQueryAsync(connection, "ALTER TABLE deadlocks ADD COLUMN IF NOT EXISTS database_name VARCHAR"); + } + catch (Exception ex) + { + _logger?.LogWarning("Migration to v46 encountered an error (non-fatal): {Error}", ex.Message); + } + } + + if (fromVersion < 47) + { + /* v47: query_store_stats.replica_role — the replica role SQL Server 2022+ attributed each + runtime-stats row to (sys.query_store_replicas.replica_name). With "Query Store for + secondary replicas" on, an AG has ONE shared Query Store living on the primary, so the + primary's rows silently blend in secondary workload unless split by replica. Appended at + the end to keep the positional appender aligned; the collector writes it unconditionally + (NULL pre-2022, and NULL on a 2022 standalone whose sys.query_store_replicas is empty), + so an un-migrated DB would mis-align on the next append — this ALTER is required. The v_ + view (SELECT *) is rebuilt every startup and picks it up; old parquet reads back NULL + (union BY NAME). */ + _logger?.LogInformation("Running migration to v47: query_store_stats replica_role column"); + try + { + await ExecuteNonQueryAsync(connection, "ALTER TABLE query_store_stats ADD COLUMN IF NOT EXISTS replica_role VARCHAR"); + } + catch (Exception ex) + { + _logger?.LogWarning("Migration to v47 encountered an error (non-fatal): {Error}", ex.Message); + } + } } /// <summary> @@ -883,6 +1175,18 @@ public async Task CreateArchiveViewsAsync() using var connection = CreateConnection(); await connection.OpenAsync(); + /* This fresh connection must see every table InitializeAsync just created. If it sees none, + the reset left an empty database — surface it loudly rather than only failing per-table below. */ + using (var tableCountCmd = connection.CreateCommand()) + { + tableCountCmd.CommandText = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'main'"; + var tableCount = Convert.ToInt64(await tableCountCmd.ExecuteScalarAsync()); + if (tableCount == 0) + _logger?.LogError("Archive-view refresh opened a database with no tables — the reset did not persist the schema; collectors will fail until restart"); + else + _logger?.LogInformation("Archive-view refresh sees {Count} tables", tableCount); + } + foreach (var table in ArchivableTables) { try @@ -907,6 +1211,22 @@ SELECT 1 FROM dismissed_archive_alerts d AND d.server_id = p.server_id AND d.metric_name = p.metric_name )"; + } + else if (ArchiveViewDedupKeys.TryGetValue(table, out var dedupKey)) + { + /* Dedup the hot∪parquet union on the server-side natural key so a logical event that was + re-collected after the 512MB emergency reset (still present in parquet) appears exactly + once. QUALIFY keeps the newest-collected copy — the re-collected hot row outranks its + archived parquet twin (identical content either way). */ + viewSql = $@"CREATE OR REPLACE VIEW v_{table} AS +SELECT * +FROM +( + SELECT * FROM {table} + UNION ALL BY NAME + SELECT * FROM read_parquet('{globPath}', union_by_name=true) +) +QUALIFY ROW_NUMBER() OVER (PARTITION BY {dedupKey} ORDER BY collection_time DESC) = 1"; } else { @@ -999,27 +1319,6 @@ await ExecuteNonQueryAsync(connection, } } - /// <summary> - /// Runs a manual WAL checkpoint. Call this between collection cycles - /// to flush the WAL during idle time instead of during collector writes. - /// </summary> - public async Task CheckpointAsync() - { - using var writeLock = AcquireWriteLock(); - try - { - using var connection = CreateConnection(); - await connection.OpenAsync(); - using var command = connection.CreateCommand(); - command.CommandText = "CHECKPOINT"; - await command.ExecuteNonQueryAsync(); - } - catch (Exception ex) - { - _logger?.LogDebug(ex, "Manual checkpoint failed (non-critical)"); - } - } - /// <summary> /// Executes a non-query SQL statement. /// </summary> @@ -1041,7 +1340,7 @@ private async Task ExecuteNonQueryAsync(DuckDBConnection connection, string sql) /// <summary> /// Checks if the database file exists. /// </summary> - public bool DatabaseExists() + private bool DatabaseExists() { return File.Exists(_databasePath); } diff --git a/Lite/Database/DuckDbSchemaGenerator.cs b/Lite/Database/DuckDbSchemaGenerator.cs new file mode 100644 index 000000000..04f484674 --- /dev/null +++ b/Lite/Database/DuckDbSchemaGenerator.cs @@ -0,0 +1,304 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Text; +using PerformanceMonitor.Collectors; + +namespace PerformanceMonitorLite.Database; + +/// <summary> +/// Generates Lite's DuckDB collector-table DDL from the shared collector definitions' +/// engine-neutral column metadata (<see cref="ICollectorSchemaInfo"/>) — the SAME catalog Darling's +/// <c>PgSchemaGenerator</c> builds its Postgres schema from, so a collector column change lands in +/// exactly one place (the definition's <c>PayloadColumns</c>) and can no longer silently drift from +/// what Darling generates. This replaces the 35 hand-written <c>CREATE TABLE</c> constants that used +/// to live in <see cref="Schema"/>. +/// +/// <para>The output is byte-for-byte STORAGE-equivalent to those pre-generation hand-written tables +/// (proven table-by-table by <c>DuckDbSchemaEquivalenceTests</c> against a frozen snapshot): same +/// columns, same DuckDB types, same order, same PRIMARY KEY, same NOT NULL / DEFAULT constraints, +/// same indexes. Existing DuckDB stores are unaffected regardless — every statement is +/// <c>CREATE ... IF NOT EXISTS</c>, so it is a no-op on an already-created table; this generator only +/// shapes FRESH stores, and it shapes them identically to before.</para> +/// +/// <para>Two things the engine-neutral catalog does NOT carry, which Lite's DuckDB schema has always +/// enforced and which are therefore reproduced here as a small, pinned, Lite-local overlay (Darling +/// deliberately drops both — its hypertables can't carry the prefix PRIMARY KEY and its bulk COPY +/// path doesn't want per-column NOT NULL): (1) the prefix <c>collection_id</c>/<c>deadlock_id</c>/… +/// PRIMARY KEY, and (2) NOT NULL on the always-populated key/name/count payload columns plus the one +/// <c>query_snapshots.is_cdc_capture DEFAULT false</c>. See <see cref="PayloadColumnConstraints"/>.</para> +/// +/// <para>Index names mirror Lite's historical (irregular) names exactly — e.g. +/// <c>idx_cpu_time</c> for <c>cpu_utilization_stats</c>, not the uniform <c>idx_<table>_time</c> +/// Darling uses — so on an existing store the <c>CREATE INDEX IF NOT EXISTS</c> matches the index +/// already there rather than adding a redundant second one.</para> +/// </summary> +public static class DuckDbSchemaGenerator +{ + /// <summary>Maps an engine-neutral collector column to its DuckDB storage type.</summary> + public static string TypeFor(CollectorColumn column) + { + if (column is null) + { + throw new ArgumentNullException(nameof(column)); + } + + switch (column.Type) + { + case CollectorColumnType.BigInt: return "BIGINT"; + case CollectorColumnType.Integer: return "INTEGER"; + case CollectorColumnType.SmallInt: return "SMALLINT"; + case CollectorColumnType.Varchar: return "VARCHAR"; + /* UTC by convention across the product; DuckDB TIMESTAMP is tz-naive (matches Darling's). */ + case CollectorColumnType.Timestamp: return "TIMESTAMP"; + /* DuckDB DOUBLE == DOUBLE PRECISION == FLOAT8 (aliases for the same 8-byte type). The + pre-generation schema spelled it both ways (spinlock_stats DOUBLE, query_snapshots + DOUBLE PRECISION); either resolves to the same stored type, proven by the equivalence + test's PRAGMA table_info comparison. */ + case CollectorColumnType.Double: return "DOUBLE"; + case CollectorColumnType.Decimal: + if (column.Precision <= 0) + { + throw new InvalidOperationException( + $"Decimal column '{column.Name}' has no declared precision/scale — mirror the DuckDB DECIMAL(p,s) from Lite's schema."); + } + return $"DECIMAL({column.Precision},{column.Scale})"; + case CollectorColumnType.Boolean: return "BOOLEAN"; + default: + throw new ArgumentOutOfRangeException(nameof(column), column.Type, "Unmapped collector column type"); + } + } + + /// <summary> + /// Emits <c>CREATE TABLE IF NOT EXISTS</c> for one collector's destination table, reproducing + /// Lite's exact pre-generation shape: the prefix id (PRIMARY KEY, when the collector includes + /// one), the prefix timestamp, <c>server_id</c>, <c>server_name</c>, then every payload column in + /// definition order with its DuckDB type and any NOT NULL / DEFAULT overlay. + /// </summary> + public static string CreateTable(ICollectorSchemaInfo schema) + { + if (schema is null) + { + throw new ArgumentNullException(nameof(schema)); + } + + var sb = new StringBuilder(); + sb.Append("CREATE TABLE IF NOT EXISTS ").Append(schema.TargetTable).Append(" (\n"); + + if (schema.IncludesCollectionId) + { + sb.Append(" ").Append(schema.PrefixIdColumnName).Append(" BIGINT PRIMARY KEY,\n"); + } + + sb.Append(" ").Append(schema.PrefixTimeColumnName).Append(" TIMESTAMP NOT NULL,\n"); + sb.Append(" server_id INTEGER NOT NULL,\n"); + sb.Append(" server_name VARCHAR NOT NULL"); + + foreach (var column in schema.PayloadColumns) + { + sb.Append(",\n ").Append(column.Name).Append(' ').Append(TypeFor(column)); + + var constraint = PayloadConstraint(schema.TargetTable, column.Name); + if (constraint is not null) + { + sb.Append(' ').Append(constraint); + } + } + + sb.Append("\n)"); + return sb.ToString(); + } + + /// <summary> + /// Emits the table's retrieval index, mirroring Lite's historical index NAME and COLUMNS exactly. + /// Most tables get <c>idx_<table>_time</c> on <c>(server_id, <prefix time>)</c>; a + /// handful carry irregular names or columns predating the uniform scheme, and + /// <c>server_config</c>/<c>database_config</c> have no index. Returns null when the table has none. + /// </summary> + public static string? CreateIndex(ICollectorSchemaInfo schema) + { + if (schema is null) + { + throw new ArgumentNullException(nameof(schema)); + } + + switch (schema.TargetTable) + { + /* No index in Lite's hand-written schema. */ + case "server_config": + case "database_config": + return null; + + /* Irregular COLUMN: indexed on the payload sample_time, not the collection_time prefix. */ + case "memory_pressure_events": + return "CREATE INDEX IF NOT EXISTS idx_memory_pressure_events_time ON memory_pressure_events(server_id, sample_time)"; + + /* Composite object-drill index for the FinOps Index Analysis reads. */ + case "index_object_stats": + return "CREATE INDEX IF NOT EXISTS idx_index_object_stats_object ON index_object_stats(server_id, database_name, object_id, index_id, collection_time)"; + + /* Irregular NAMES (short-form, predating the uniform idx_<table>_time scheme). */ + case "cpu_utilization_stats": + return "CREATE INDEX IF NOT EXISTS idx_cpu_time ON cpu_utilization_stats(server_id, collection_time)"; + case "file_io_stats": + return "CREATE INDEX IF NOT EXISTS idx_file_io_time ON file_io_stats(server_id, collection_time)"; + case "memory_stats": + return "CREATE INDEX IF NOT EXISTS idx_memory_time ON memory_stats(server_id, collection_time)"; + case "tempdb_stats": + return "CREATE INDEX IF NOT EXISTS idx_tempdb_time ON tempdb_stats(server_id, collection_time)"; + case "perfmon_stats": + return "CREATE INDEX IF NOT EXISTS idx_perfmon_time ON perfmon_stats(server_id, collection_time)"; + case "query_store_stats": + return "CREATE INDEX IF NOT EXISTS idx_query_store_time ON query_store_stats(server_id, collection_time)"; + + /* Default: idx_<table>_time on (server_id, <prefix time>). The prefix-time column is + capture_time for the config snapshots (database_scoped_config, trace_flags) and + collection_time everywhere else — both handled without special-casing. */ + default: + return $"CREATE INDEX IF NOT EXISTS idx_{schema.TargetTable}_time ON {schema.TargetTable}(server_id, {schema.PrefixTimeColumnName})"; + } + } + + /// <summary> + /// The NOT NULL / DEFAULT overlay for PAYLOAD columns — the constraints Lite's hand-written DuckDB + /// schema carried that the engine-neutral <see cref="CollectorColumn"/> does not encode. Keyed by + /// <c>"{table}.{column}"</c>; the value is the exact DDL fragment appended after the column's type + /// (e.g. <c>"NOT NULL"</c>, <c>"DEFAULT false"</c>). Absent = plain nullable column. + /// + /// <para>Frozen to reproduce the pre-generation schema exactly and PINNED by + /// <c>DuckDbSchemaEquivalenceTests</c> (a missing or spurious entry makes a table's + /// <c>PRAGMA table_info</c> diverge from the golden snapshot, failing the build). The prefix + /// columns (id PRIMARY KEY, time / server_id / server_name NOT NULL) are NOT listed here — they + /// are emitted uniformly by <see cref="CreateTable"/>.</para> + /// + /// <para>Only <c>NOT NULL</c> and column <c>DEFAULT</c> fragments belong here — the two things the + /// equivalence test's <c>PRAGMA table_info</c> comparison verifies (its <c>notnull</c> / <c>dflt_value</c> + /// columns). Do NOT add <c>CHECK</c>, <c>UNIQUE</c>, <c>COLLATE</c>, or generated-column expressions: + /// none are needed by any collector table, and <c>table_info</c> would not fully validate them, so the + /// equivalence proof would have a blind spot. (Such shapes are not expressible from the catalog anyway.)</para> + /// </summary> + public static IReadOnlyDictionary<string, string> PayloadColumnConstraints { get; } = + new Dictionary<string, string>(StringComparer.Ordinal) + { + ["wait_stats.wait_type"] = "NOT NULL", + ["latch_stats.latch_class"] = "NOT NULL", + ["spinlock_stats.spinlock_name"] = "NOT NULL", + ["plan_cache_stats.cacheobjtype"] = "NOT NULL", + ["plan_cache_stats.objtype"] = "NOT NULL", + ["cpu_utilization_stats.sample_time"] = "NOT NULL", + ["memory_clerks.clerk_type"] = "NOT NULL", + ["memory_pressure_events.sample_time"] = "NOT NULL", + ["memory_pressure_events.memory_notification"] = "NOT NULL", + ["memory_pressure_events.memory_indicators_process"] = "NOT NULL", + ["memory_pressure_events.memory_indicators_system"] = "NOT NULL", + ["query_store_stats.database_name"] = "NOT NULL", + /* The one payload column DEFAULT (not a NOT NULL): the long-running-query alert reads it. */ + ["query_snapshots.is_cdc_capture"] = "DEFAULT false", + ["perfmon_stats.object_name"] = "NOT NULL", + ["perfmon_stats.counter_name"] = "NOT NULL", + ["server_config.configuration_name"] = "NOT NULL", + ["database_config.database_name"] = "NOT NULL", + ["database_scoped_config.database_name"] = "NOT NULL", + ["database_scoped_config.configuration_name"] = "NOT NULL", + ["trace_flags.trace_flag"] = "NOT NULL", + ["trace_flags.status"] = "NOT NULL", + ["trace_flags.is_global"] = "NOT NULL", + ["trace_flags.is_session"] = "NOT NULL", + ["dmv_blocking_snapshots.monitor_loop"] = "NOT NULL", + ["running_jobs.job_name"] = "NOT NULL", + ["running_jobs.job_id"] = "NOT NULL", + ["running_jobs.job_enabled"] = "NOT NULL", + ["running_jobs.start_time"] = "NOT NULL", + ["running_jobs.current_duration_seconds"] = "NOT NULL", + ["running_jobs.avg_duration_seconds"] = "NOT NULL", + ["running_jobs.p95_duration_seconds"] = "NOT NULL", + ["running_jobs.successful_run_count"] = "NOT NULL", + ["running_jobs.is_running_long"] = "NOT NULL", + ["database_size_stats.database_name"] = "NOT NULL", + ["database_size_stats.database_id"] = "NOT NULL", + ["database_size_stats.file_id"] = "NOT NULL", + ["database_size_stats.file_type_desc"] = "NOT NULL", + ["database_size_stats.file_name"] = "NOT NULL", + ["database_size_stats.physical_name"] = "NOT NULL", + ["database_size_stats.total_size_mb"] = "NOT NULL", + ["index_object_stats.database_name"] = "NOT NULL", + ["index_object_stats.database_id"] = "NOT NULL", + ["index_object_stats.schema_name"] = "NOT NULL", + ["index_object_stats.object_id"] = "NOT NULL", + ["index_object_stats.table_name"] = "NOT NULL", + ["index_object_stats.index_id"] = "NOT NULL", + ["server_properties.edition"] = "NOT NULL", + ["server_properties.product_version"] = "NOT NULL", + ["server_properties.product_level"] = "NOT NULL", + ["server_properties.engine_edition"] = "NOT NULL", + ["server_properties.cpu_count"] = "NOT NULL", + ["server_properties.hyperthread_ratio"] = "NOT NULL", + ["server_properties.physical_memory_mb"] = "NOT NULL", + ["session_stats.program_name"] = "NOT NULL", + ["session_stats.connection_count"] = "NOT NULL", + ["session_stats.running_count"] = "NOT NULL", + ["session_stats.sleeping_count"] = "NOT NULL", + ["session_stats.dormant_count"] = "NOT NULL", + ["session_summary_stats.total_sessions"] = "NOT NULL", + ["session_summary_stats.running_sessions"] = "NOT NULL", + ["session_summary_stats.sleeping_sessions"] = "NOT NULL", + ["session_summary_stats.background_sessions"] = "NOT NULL", + ["session_summary_stats.dormant_sessions"] = "NOT NULL", + ["session_summary_stats.idle_sessions_over_30min"] = "NOT NULL", + ["session_summary_stats.sessions_waiting_for_memory"] = "NOT NULL", + ["session_summary_stats.databases_with_connections"] = "NOT NULL", + ["job_history.instance_id"] = "NOT NULL", + ["job_history.job_id"] = "NOT NULL", + ["job_history.job_name"] = "NOT NULL", + ["job_history.job_enabled"] = "NOT NULL", + ["job_history.step_id"] = "NOT NULL", + ["job_history.run_status"] = "NOT NULL", + ["job_history.run_duration_seconds"] = "NOT NULL", + ["job_history.retries_attempted"] = "NOT NULL", + ["agent_status.agent_running"] = "NOT NULL", + }; + + private static string? PayloadConstraint(string table, string column) => + PayloadColumnConstraints.TryGetValue($"{table}.{column}", out var c) ? c : null; + + /// <summary>Every collector target table, in catalog order (the generated table/index set).</summary> + public static IEnumerable<string> CollectorTableNames() + { + foreach (var schema in CollectorCatalog.All) + { + yield return schema.TargetTable; + } + } + + /// <summary>The generated <c>CREATE TABLE</c> statements for all catalog collectors, in catalog order.</summary> + public static IEnumerable<string> CreateTableStatements() + { + foreach (var schema in CollectorCatalog.All) + { + yield return CreateTable(schema); + } + } + + /// <summary> + /// The generated <c>CREATE INDEX</c> statements for all catalog collectors that have one, in + /// catalog order (33 of 35 — server_config and database_config have none). + /// </summary> + public static IEnumerable<string> CreateIndexStatements() + { + foreach (var schema in CollectorCatalog.All) + { + var index = CreateIndex(schema); + if (index is not null) + { + yield return index; + } + } + } +} diff --git a/Lite/Database/Schema.cs b/Lite/Database/Schema.cs index 595087610..5a92f5506 100644 --- a/Lite/Database/Schema.cs +++ b/Lite/Database/Schema.cs @@ -3,7 +3,17 @@ namespace PerformanceMonitorLite.Database; /// <summary> -/// Contains all DuckDB table schema definitions as SQL constants. +/// DuckDB table schema definitions. +/// +/// <para>The 35 COLLECTOR tables (wait_stats, query_stats, index_object_stats, …) are NOT hand-written +/// here anymore — their DDL is GENERATED from the shared <see cref="PerformanceMonitor.Collectors.CollectorCatalog"/> +/// by <see cref="DuckDbSchemaGenerator"/>, the DuckDB analog of Darling's <c>PgSchemaGenerator</c>, so a +/// collector column change lands in one place and can never silently drift from Darling's store. The +/// generated DDL is proven byte-for-byte storage-equivalent to the former hand-written tables by +/// <c>DuckDbSchemaEquivalenceTests</c>.</para> +/// +/// <para>The tables below are the NON-collector tables (registry, schedule, log, and the alert / +/// coordination surface). They are not in the collector catalog, so they stay hand-written.</para> /// </summary> public static class Schema { @@ -49,752 +59,17 @@ CREATE TABLE IF NOT EXISTS collection_log ( duckdb_duration_ms INTEGER )"; - public const string CreateWaitStatsTable = @" -CREATE TABLE IF NOT EXISTS wait_stats ( - collection_id BIGINT PRIMARY KEY, - collection_time TIMESTAMP NOT NULL, - server_id INTEGER NOT NULL, - server_name VARCHAR NOT NULL, - wait_type VARCHAR NOT NULL, - waiting_tasks_count BIGINT, - wait_time_ms BIGINT, - signal_wait_time_ms BIGINT, - delta_waiting_tasks BIGINT, - delta_wait_time_ms BIGINT, - delta_signal_wait_time_ms BIGINT -)"; - - public const string CreateQueryStatsTable = @" -CREATE TABLE IF NOT EXISTS query_stats ( - collection_id BIGINT PRIMARY KEY, - collection_time TIMESTAMP NOT NULL, - server_id INTEGER NOT NULL, - server_name VARCHAR NOT NULL, - database_name VARCHAR, - query_hash VARCHAR, - query_plan_hash VARCHAR, - creation_time TIMESTAMP, - last_execution_time TIMESTAMP, - execution_count BIGINT, - total_worker_time BIGINT, - total_elapsed_time BIGINT, - total_logical_reads BIGINT, - total_logical_writes BIGINT, - total_physical_reads BIGINT, - total_clr_time BIGINT, - total_rows BIGINT, - total_spills BIGINT, - min_worker_time BIGINT, - max_worker_time BIGINT, - min_elapsed_time BIGINT, - max_elapsed_time BIGINT, - min_physical_reads BIGINT, - max_physical_reads BIGINT, - min_rows BIGINT, - max_rows BIGINT, - min_dop BIGINT, - max_dop BIGINT, - min_grant_kb BIGINT, - max_grant_kb BIGINT, - min_used_grant_kb BIGINT, - max_used_grant_kb BIGINT, - min_ideal_grant_kb BIGINT, - max_ideal_grant_kb BIGINT, - min_reserved_threads BIGINT, - max_reserved_threads BIGINT, - min_used_threads BIGINT, - max_used_threads BIGINT, - min_spills BIGINT, - max_spills BIGINT, - query_text VARCHAR, - query_plan_xml VARCHAR, - sql_handle VARCHAR, - plan_handle VARCHAR, - delta_execution_count BIGINT, - delta_worker_time BIGINT, - delta_elapsed_time BIGINT, - delta_logical_reads BIGINT, - delta_logical_writes BIGINT, - delta_physical_reads BIGINT, - delta_rows BIGINT, - delta_spills BIGINT -)"; - - public const string CreateCpuUtilizationStatsTable = @" -CREATE TABLE IF NOT EXISTS cpu_utilization_stats ( - collection_id BIGINT PRIMARY KEY, - collection_time TIMESTAMP NOT NULL, - server_id INTEGER NOT NULL, - server_name VARCHAR NOT NULL, - sample_time TIMESTAMP NOT NULL, - sqlserver_cpu_utilization INTEGER, - other_process_cpu_utilization INTEGER -)"; - - public const string CreateFileIoStatsTable = @" -CREATE TABLE IF NOT EXISTS file_io_stats ( - collection_id BIGINT PRIMARY KEY, - collection_time TIMESTAMP NOT NULL, - server_id INTEGER NOT NULL, - server_name VARCHAR NOT NULL, - database_name VARCHAR, - file_name VARCHAR, - file_type VARCHAR, - physical_name VARCHAR, - size_mb DECIMAL(18,2), - num_of_reads BIGINT, - num_of_writes BIGINT, - read_bytes BIGINT, - write_bytes BIGINT, - io_stall_read_ms BIGINT, - io_stall_write_ms BIGINT, - io_stall_queued_read_ms BIGINT, - io_stall_queued_write_ms BIGINT, - delta_reads BIGINT, - delta_writes BIGINT, - delta_read_bytes BIGINT, - delta_write_bytes BIGINT, - delta_stall_read_ms BIGINT, - delta_stall_write_ms BIGINT, - delta_stall_queued_read_ms BIGINT, - delta_stall_queued_write_ms BIGINT -)"; - - public const string CreateMemoryStatsTable = @" -CREATE TABLE IF NOT EXISTS memory_stats ( - collection_id BIGINT PRIMARY KEY, - collection_time TIMESTAMP NOT NULL, - server_id INTEGER NOT NULL, - server_name VARCHAR NOT NULL, - total_physical_memory_mb DECIMAL(18,2), - available_physical_memory_mb DECIMAL(18,2), - total_page_file_mb DECIMAL(18,2), - available_page_file_mb DECIMAL(18,2), - system_memory_state VARCHAR, - sql_memory_model VARCHAR, - target_server_memory_mb DECIMAL(18,2), - total_server_memory_mb DECIMAL(18,2), - buffer_pool_mb DECIMAL(18,2), - plan_cache_mb DECIMAL(18,2), - max_workers_count INTEGER, - current_workers_count INTEGER -)"; - - public const string CreateMemoryClerksTable = @" -CREATE TABLE IF NOT EXISTS memory_clerks ( - collection_id BIGINT PRIMARY KEY, - collection_time TIMESTAMP NOT NULL, - server_id INTEGER NOT NULL, - server_name VARCHAR NOT NULL, - clerk_type VARCHAR NOT NULL, - memory_mb DECIMAL(18,2) -)"; - - public const string CreateMemoryPressureEventsTable = @" -CREATE TABLE IF NOT EXISTS memory_pressure_events ( - collection_id BIGINT PRIMARY KEY, - collection_time TIMESTAMP NOT NULL, - server_id INTEGER NOT NULL, - server_name VARCHAR NOT NULL, - sample_time TIMESTAMP NOT NULL, - memory_notification VARCHAR NOT NULL, - memory_indicators_process INTEGER NOT NULL, - memory_indicators_system INTEGER NOT NULL -)"; - - public const string CreateDeadlocksTable = @" -CREATE TABLE IF NOT EXISTS deadlocks ( - deadlock_id BIGINT PRIMARY KEY, - collection_time TIMESTAMP NOT NULL, - server_id INTEGER NOT NULL, - server_name VARCHAR NOT NULL, - deadlock_time TIMESTAMP, - victim_process_id VARCHAR, - victim_sql_text VARCHAR, - deadlock_graph_xml VARCHAR -)"; - - public const string CreateProcedureStatsTable = @" -CREATE TABLE IF NOT EXISTS procedure_stats ( - collection_id BIGINT PRIMARY KEY, - collection_time TIMESTAMP NOT NULL, - server_id INTEGER NOT NULL, - server_name VARCHAR NOT NULL, - database_name VARCHAR, - schema_name VARCHAR, - object_name VARCHAR, - object_type VARCHAR, - cached_time TIMESTAMP, - last_execution_time TIMESTAMP, - execution_count BIGINT, - total_worker_time BIGINT, - total_elapsed_time BIGINT, - total_logical_reads BIGINT, - total_physical_reads BIGINT, - total_logical_writes BIGINT, - min_worker_time BIGINT, - max_worker_time BIGINT, - min_elapsed_time BIGINT, - max_elapsed_time BIGINT, - min_logical_reads BIGINT, - max_logical_reads BIGINT, - min_physical_reads BIGINT, - max_physical_reads BIGINT, - min_logical_writes BIGINT, - max_logical_writes BIGINT, - total_spills BIGINT, - min_spills BIGINT, - max_spills BIGINT, - sql_handle VARCHAR, - plan_handle VARCHAR, - delta_execution_count BIGINT, - delta_worker_time BIGINT, - delta_elapsed_time BIGINT, - delta_logical_reads BIGINT, - delta_logical_writes BIGINT, - delta_physical_reads BIGINT -)"; - - public const string CreateQueryStoreStatsTable = @" -CREATE TABLE IF NOT EXISTS query_store_stats ( - collection_id BIGINT PRIMARY KEY, - collection_time TIMESTAMP NOT NULL, - server_id INTEGER NOT NULL, - server_name VARCHAR NOT NULL, - database_name VARCHAR NOT NULL, - query_id BIGINT, - plan_id BIGINT, - execution_type_desc VARCHAR, - first_execution_time TIMESTAMP, - last_execution_time TIMESTAMP, - module_name VARCHAR, - query_text VARCHAR, - query_hash VARCHAR, - execution_count BIGINT, - avg_duration_us BIGINT, - min_duration_us BIGINT, - max_duration_us BIGINT, - avg_cpu_time_us BIGINT, - min_cpu_time_us BIGINT, - max_cpu_time_us BIGINT, - avg_logical_io_reads BIGINT, - min_logical_io_reads BIGINT, - max_logical_io_reads BIGINT, - avg_logical_io_writes BIGINT, - min_logical_io_writes BIGINT, - max_logical_io_writes BIGINT, - avg_physical_io_reads BIGINT, - min_physical_io_reads BIGINT, - max_physical_io_reads BIGINT, - avg_clr_time_us BIGINT, - min_clr_time_us BIGINT, - max_clr_time_us BIGINT, - min_dop BIGINT, - max_dop BIGINT, - avg_query_max_used_memory BIGINT, - min_query_max_used_memory BIGINT, - max_query_max_used_memory BIGINT, - avg_rowcount BIGINT, - min_rowcount BIGINT, - max_rowcount BIGINT, - avg_num_physical_io_reads BIGINT, - min_num_physical_io_reads BIGINT, - max_num_physical_io_reads BIGINT, - avg_log_bytes_used BIGINT, - min_log_bytes_used BIGINT, - max_log_bytes_used BIGINT, - avg_tempdb_space_used BIGINT, - min_tempdb_space_used BIGINT, - max_tempdb_space_used BIGINT, - plan_type VARCHAR, - plan_forcing_type VARCHAR, - is_forced_plan BOOLEAN, - force_failure_count BIGINT, - last_force_failure_reason VARCHAR, - compatibility_level INTEGER, - query_plan_text VARCHAR, - query_plan_hash VARCHAR -)"; - - public const string CreateQuerySnapshotsTable = @" -CREATE TABLE IF NOT EXISTS query_snapshots ( - collection_id BIGINT PRIMARY KEY, - collection_time TIMESTAMP NOT NULL, - server_id INTEGER NOT NULL, - server_name VARCHAR NOT NULL, - session_id INTEGER, - database_name VARCHAR, - elapsed_time_formatted VARCHAR, - query_text VARCHAR, - query_plan VARCHAR, - live_query_plan VARCHAR, - status VARCHAR, - blocking_session_id INTEGER, - wait_type VARCHAR, - wait_time_ms BIGINT, - wait_resource VARCHAR, - cpu_time_ms BIGINT, - total_elapsed_time_ms BIGINT, - reads BIGINT, - writes BIGINT, - logical_reads BIGINT, - granted_query_memory_gb DECIMAL(18,2), - transaction_isolation_level VARCHAR, - dop INTEGER, - parallel_worker_count INTEGER, - login_name VARCHAR, - host_name VARCHAR, - program_name VARCHAR, - open_transaction_count INTEGER, - percent_complete DECIMAL(5,2), - is_cdc_capture BOOLEAN DEFAULT false, - query_hash VARCHAR -)"; - - public const string CreateTempdbStatsTable = @" -CREATE TABLE IF NOT EXISTS tempdb_stats ( - collection_id BIGINT PRIMARY KEY, - collection_time TIMESTAMP NOT NULL, - server_id INTEGER NOT NULL, - server_name VARCHAR NOT NULL, - user_object_reserved_mb DECIMAL(18,2), - internal_object_reserved_mb DECIMAL(18,2), - version_store_reserved_mb DECIMAL(18,2), - total_reserved_mb DECIMAL(18,2), - unallocated_mb DECIMAL(18,2), - total_sessions_using_tempdb BIGINT, - top_session_id INTEGER, - top_session_tempdb_mb DECIMAL(18,2) -)"; - - public const string CreatePerfmonStatsTable = @" -CREATE TABLE IF NOT EXISTS perfmon_stats ( - collection_id BIGINT PRIMARY KEY, - collection_time TIMESTAMP NOT NULL, - server_id INTEGER NOT NULL, - server_name VARCHAR NOT NULL, - object_name VARCHAR NOT NULL, - counter_name VARCHAR NOT NULL, - instance_name VARCHAR, - cntr_value BIGINT, - delta_cntr_value BIGINT, - sample_interval_seconds INTEGER -)"; - - public const string CreateServerConfigTable = @" -CREATE TABLE IF NOT EXISTS server_config ( - config_id BIGINT PRIMARY KEY, - capture_time TIMESTAMP NOT NULL, - server_id INTEGER NOT NULL, - server_name VARCHAR NOT NULL, - configuration_name VARCHAR NOT NULL, - value_configured BIGINT, - value_in_use BIGINT, - is_dynamic BOOLEAN, - is_advanced BOOLEAN -)"; - - public const string CreateMemoryGrantStatsTable = @" -CREATE TABLE IF NOT EXISTS memory_grant_stats ( - collection_id BIGINT PRIMARY KEY, - collection_time TIMESTAMP NOT NULL, - server_id INTEGER NOT NULL, - server_name VARCHAR NOT NULL, - resource_semaphore_id SMALLINT, - pool_id INTEGER, - target_memory_mb DECIMAL(18,2), - max_target_memory_mb DECIMAL(18,2), - total_memory_mb DECIMAL(18,2), - available_memory_mb DECIMAL(18,2), - granted_memory_mb DECIMAL(18,2), - used_memory_mb DECIMAL(18,2), - grantee_count INTEGER, - waiter_count INTEGER, - timeout_error_count BIGINT, - forced_grant_count BIGINT, - timeout_error_count_delta BIGINT, - forced_grant_count_delta BIGINT -)"; - - public const string CreateWaitingTasksTable = @" -CREATE TABLE IF NOT EXISTS waiting_tasks ( - collection_id BIGINT PRIMARY KEY, - collection_time TIMESTAMP NOT NULL, - server_id INTEGER NOT NULL, - server_name VARCHAR NOT NULL, - session_id INTEGER, - wait_type VARCHAR, - wait_duration_ms BIGINT, - blocking_session_id INTEGER, - resource_description VARCHAR, - database_name VARCHAR -)"; - - public const string CreateBlockedProcessReportsTable = @" -CREATE TABLE IF NOT EXISTS blocked_process_reports ( - blocked_report_id BIGINT PRIMARY KEY, - collection_time TIMESTAMP NOT NULL, - server_id INTEGER NOT NULL, - server_name VARCHAR NOT NULL, - event_time TIMESTAMP, - database_name VARCHAR, - blocked_spid INTEGER, - blocked_ecid INTEGER, - blocking_spid INTEGER, - blocking_ecid INTEGER, - wait_time_ms BIGINT, - wait_resource VARCHAR, - lock_mode VARCHAR, - blocked_status VARCHAR, - blocked_isolation_level VARCHAR, - blocked_log_used BIGINT, - blocked_transaction_count INTEGER, - blocked_client_app VARCHAR, - blocked_host_name VARCHAR, - blocked_login_name VARCHAR, - blocked_sql_text VARCHAR, - blocking_status VARCHAR, - blocking_isolation_level VARCHAR, - blocking_client_app VARCHAR, - blocking_host_name VARCHAR, - blocking_login_name VARCHAR, - blocking_sql_text VARCHAR, - blocked_transaction_name VARCHAR, - blocking_transaction_name VARCHAR, - blocked_last_tran_started TIMESTAMP, - blocking_last_tran_started TIMESTAMP, - blocked_last_batch_started TIMESTAMP, - blocking_last_batch_started TIMESTAMP, - blocked_last_batch_completed TIMESTAMP, - blocking_last_batch_completed TIMESTAMP, - blocked_priority INTEGER, - blocking_priority INTEGER, - blocked_process_report_xml VARCHAR, - object_id INTEGER, - database_id INTEGER, - contentious_object VARCHAR, - monitor_loop INTEGER -)"; - - /* Always-on DMV blocking snapshot (BPR-independent fallback). Rich blocker/blocked pair-row shape so - the shared blocking-chain reconstruction consumes it via v_dmv_blocking_snapshots. Column order MUST - match the appender in RemoteCollectorService.DmvBlockingSnapshot.cs (DuckDB appends by position). */ - public const string CreateDmvBlockingSnapshotsTable = @" -CREATE TABLE IF NOT EXISTS dmv_blocking_snapshots ( - collection_id BIGINT PRIMARY KEY, - collection_time TIMESTAMP NOT NULL, - server_id INTEGER NOT NULL, - server_name VARCHAR NOT NULL, - monitor_loop INTEGER NOT NULL, - event_time TIMESTAMP, - database_name VARCHAR, - blocked_spid INTEGER, - blocked_ecid INTEGER, - blocked_last_tran_started TIMESTAMP, - blocking_spid INTEGER, - blocking_ecid INTEGER, - blocking_last_tran_started TIMESTAMP, - wait_time_ms BIGINT, - lock_mode VARCHAR, - blocking_status VARCHAR, - contentious_object VARCHAR, - blocked_sql_text VARCHAR, - blocking_sql_text VARCHAR, - blocked_login_name VARCHAR, - blocked_host_name VARCHAR, - blocked_client_app VARCHAR, - blocking_login_name VARCHAR, - blocking_host_name VARCHAR, - blocking_client_app VARCHAR -)"; - - public const string CreateDmvBlockingSnapshotsIndex = @" -CREATE INDEX IF NOT EXISTS idx_dmv_blocking_snapshots_time ON dmv_blocking_snapshots(server_id, collection_time)"; - - public const string CreateDatabaseConfigTable = @" -CREATE TABLE IF NOT EXISTS database_config ( - config_id BIGINT PRIMARY KEY, - capture_time TIMESTAMP NOT NULL, - server_id INTEGER NOT NULL, - server_name VARCHAR NOT NULL, - database_name VARCHAR NOT NULL, - state_desc VARCHAR, - compatibility_level INTEGER, - collation_name VARCHAR, - recovery_model VARCHAR, - is_read_only BOOLEAN, - is_auto_close_on BOOLEAN, - is_auto_shrink_on BOOLEAN, - is_auto_create_stats_on BOOLEAN, - is_auto_update_stats_on BOOLEAN, - is_auto_update_stats_async_on BOOLEAN, - is_read_committed_snapshot_on BOOLEAN, - snapshot_isolation_state VARCHAR, - is_parameterization_forced BOOLEAN, - is_query_store_on BOOLEAN, - is_encrypted BOOLEAN, - is_trustworthy_on BOOLEAN, - is_db_chaining_on BOOLEAN, - is_broker_enabled BOOLEAN, - is_cdc_enabled BOOLEAN, - is_mixed_page_allocation_on BOOLEAN, - log_reuse_wait_desc VARCHAR, - page_verify_option VARCHAR, - target_recovery_time_seconds INTEGER, - delayed_durability VARCHAR, - is_accelerated_database_recovery_on BOOLEAN, - is_memory_optimized_enabled BOOLEAN, - is_optimized_locking_on BOOLEAN -)"; - - // Index definitions - public const string CreateWaitStatsIndex = @" -CREATE INDEX IF NOT EXISTS idx_wait_stats_time ON wait_stats(server_id, collection_time)"; - - public const string CreateQueryStatsIndex = @" -CREATE INDEX IF NOT EXISTS idx_query_stats_time ON query_stats(server_id, collection_time)"; - - public const string CreateProcedureStatsIndex = @" -CREATE INDEX IF NOT EXISTS idx_procedure_stats_time ON procedure_stats(server_id, collection_time)"; - - public const string CreateQueryStoreIndex = @" -CREATE INDEX IF NOT EXISTS idx_query_store_time ON query_store_stats(server_id, collection_time)"; - - public const string CreateQuerySnapshotsIndex = @" -CREATE INDEX IF NOT EXISTS idx_query_snapshots_time ON query_snapshots(server_id, collection_time)"; - - public const string CreateCpuIndex = @" -CREATE INDEX IF NOT EXISTS idx_cpu_time ON cpu_utilization_stats(server_id, collection_time)"; - - public const string CreateFileIoIndex = @" -CREATE INDEX IF NOT EXISTS idx_file_io_time ON file_io_stats(server_id, collection_time)"; - - public const string CreateMemoryIndex = @" -CREATE INDEX IF NOT EXISTS idx_memory_time ON memory_stats(server_id, collection_time)"; - - public const string CreateMemoryPressureEventsIndex = @" -CREATE INDEX IF NOT EXISTS idx_memory_pressure_events_time ON memory_pressure_events(server_id, sample_time)"; - - public const string CreateTempdbIndex = @" -CREATE INDEX IF NOT EXISTS idx_tempdb_time ON tempdb_stats(server_id, collection_time)"; - - public const string CreatePerfmonIndex = @" -CREATE INDEX IF NOT EXISTS idx_perfmon_time ON perfmon_stats(server_id, collection_time)"; - - public const string CreateDeadlocksIndex = @" -CREATE INDEX IF NOT EXISTS idx_deadlocks_time ON deadlocks(server_id, collection_time)"; - public const string CreateCollectionLogIndex = @" CREATE INDEX IF NOT EXISTS idx_collection_log_time ON collection_log(server_id, collection_time)"; - public const string CreateMemoryGrantStatsIndex = @" -CREATE INDEX IF NOT EXISTS idx_memory_grant_stats_time ON memory_grant_stats(server_id, collection_time)"; - - public const string CreateWaitingTasksIndex = @" -CREATE INDEX IF NOT EXISTS idx_waiting_tasks_time ON waiting_tasks(server_id, collection_time)"; - - public const string CreateBlockedProcessReportsIndex = @" -CREATE INDEX IF NOT EXISTS idx_blocked_process_reports_time ON blocked_process_reports(server_id, collection_time)"; - - public const string CreateMemoryClerksIndex = @" -CREATE INDEX IF NOT EXISTS idx_memory_clerks_time ON memory_clerks(server_id, collection_time)"; - - public const string CreateDatabaseScopedConfigTable = @" -CREATE TABLE IF NOT EXISTS database_scoped_config ( - config_id BIGINT PRIMARY KEY, - capture_time TIMESTAMP NOT NULL, - server_id INTEGER NOT NULL, - server_name VARCHAR NOT NULL, - database_name VARCHAR NOT NULL, - configuration_name VARCHAR NOT NULL, - value VARCHAR, - value_for_secondary VARCHAR -)"; - - public const string CreateDatabaseScopedConfigIndex = @" -CREATE INDEX IF NOT EXISTS idx_database_scoped_config_time ON database_scoped_config(server_id, capture_time)"; - - public const string CreateTraceFlagsTable = @" -CREATE TABLE IF NOT EXISTS trace_flags ( - config_id BIGINT PRIMARY KEY, - capture_time TIMESTAMP NOT NULL, - server_id INTEGER NOT NULL, - server_name VARCHAR NOT NULL, - trace_flag INTEGER NOT NULL, - status BOOLEAN NOT NULL, - is_global BOOLEAN NOT NULL, - is_session BOOLEAN NOT NULL -)"; - - public const string CreateTraceFlagsIndex = @" -CREATE INDEX IF NOT EXISTS idx_trace_flags_time ON trace_flags(server_id, capture_time)"; - - public const string CreateRunningJobsTable = @" -CREATE TABLE IF NOT EXISTS running_jobs ( - collection_time TIMESTAMP NOT NULL, - server_id INTEGER NOT NULL, - server_name VARCHAR NOT NULL, - job_name VARCHAR NOT NULL, - job_id VARCHAR NOT NULL, - job_enabled BOOLEAN NOT NULL, - start_time TIMESTAMP NOT NULL, - current_duration_seconds BIGINT NOT NULL, - avg_duration_seconds BIGINT NOT NULL, - p95_duration_seconds BIGINT NOT NULL, - successful_run_count BIGINT NOT NULL, - is_running_long BOOLEAN NOT NULL, - percent_of_average DECIMAL(10,1) -)"; - - public const string CreateRunningJobsIndex = @" -CREATE INDEX IF NOT EXISTS idx_running_jobs_time ON running_jobs(server_id, collection_time)"; - - public const string CreateDatabaseSizeStatsTable = @" -CREATE TABLE IF NOT EXISTS database_size_stats ( - collection_id BIGINT PRIMARY KEY, - collection_time TIMESTAMP NOT NULL, - server_id INTEGER NOT NULL, - server_name VARCHAR NOT NULL, - database_name VARCHAR NOT NULL, - database_id INTEGER NOT NULL, - file_id INTEGER NOT NULL, - file_type_desc VARCHAR NOT NULL, - file_name VARCHAR NOT NULL, - physical_name VARCHAR NOT NULL, - total_size_mb DECIMAL(19,2) NOT NULL, - used_size_mb DECIMAL(19,2), - auto_growth_mb DECIMAL(19,2), - max_size_mb DECIMAL(19,2), - recovery_model_desc VARCHAR, - compatibility_level INTEGER, - state_desc VARCHAR, - volume_mount_point VARCHAR, - volume_total_mb DECIMAL(19,2), - volume_free_mb DECIMAL(19,2), - is_percent_growth BOOLEAN, - growth_pct INTEGER, - vlf_count INTEGER -)"; - - public const string CreateDatabaseSizeStatsIndex = @" -CREATE INDEX IF NOT EXISTS idx_database_size_stats_time ON database_size_stats(server_id, collection_time)"; - - // Per-table/per-index size, usage, and locking stats. Sizes are point-in-time; - // usage/locking counters are cumulative (reset boundary in sqlserver_start_time). - // Column order MUST match the appender in RemoteCollectorService.IndexObjectStats.cs - // and the data columns in install/02_create_tables.sql (Dashboard parity). - public const string CreateIndexObjectStatsTable = @" -CREATE TABLE IF NOT EXISTS index_object_stats ( - collection_id BIGINT PRIMARY KEY, - collection_time TIMESTAMP NOT NULL, - server_id INTEGER NOT NULL, - server_name VARCHAR NOT NULL, - sqlserver_start_time TIMESTAMP, - database_name VARCHAR NOT NULL, - database_id INTEGER NOT NULL, - schema_name VARCHAR NOT NULL, - object_id INTEGER NOT NULL, - table_name VARCHAR NOT NULL, - index_id INTEGER NOT NULL, - index_name VARCHAR, - index_type_desc VARCHAR, - is_unique BOOLEAN, - is_primary_key BOOLEAN, - is_filtered BOOLEAN, - partition_count INTEGER, - reserved_mb DECIMAL(19,2), - used_mb DECIMAL(19,2), - in_row_data_mb DECIMAL(19,2), - lob_data_mb DECIMAL(19,2), - row_overflow_mb DECIMAL(19,2), - total_rows BIGINT, - user_seeks BIGINT, - user_scans BIGINT, - user_lookups BIGINT, - user_updates BIGINT, - last_user_seek TIMESTAMP, - last_user_scan TIMESTAMP, - last_user_lookup TIMESTAMP, - last_user_update TIMESTAMP, - leaf_insert_count BIGINT, - leaf_update_count BIGINT, - leaf_delete_count BIGINT, - range_scan_count BIGINT, - singleton_lookup_count BIGINT, - row_lock_count BIGINT, - row_lock_wait_count BIGINT, - row_lock_wait_in_ms BIGINT, - page_lock_count BIGINT, - page_lock_wait_count BIGINT, - page_lock_wait_in_ms BIGINT, - index_lock_promotion_attempt_count BIGINT, - index_lock_promotion_count BIGINT, - page_latch_wait_count BIGINT, - page_latch_wait_in_ms BIGINT, - page_io_latch_wait_count BIGINT, - page_io_latch_wait_in_ms BIGINT -)"; - - public const string CreateIndexObjectStatsIndex = @" -CREATE INDEX IF NOT EXISTS idx_index_object_stats_object ON index_object_stats(server_id, database_name, object_id, index_id, collection_time)"; - - public const string CreateServerPropertiesTable = @" -CREATE TABLE IF NOT EXISTS server_properties ( - collection_id BIGINT PRIMARY KEY, - collection_time TIMESTAMP NOT NULL, - server_id INTEGER NOT NULL, - server_name VARCHAR NOT NULL, - edition VARCHAR NOT NULL, - product_version VARCHAR NOT NULL, - product_level VARCHAR NOT NULL, - product_update_level VARCHAR, - engine_edition INTEGER NOT NULL, - cpu_count INTEGER NOT NULL, - hyperthread_ratio INTEGER NOT NULL, - physical_memory_mb BIGINT NOT NULL, - socket_count INTEGER, - cores_per_socket INTEGER, - is_hadr_enabled BOOLEAN, - is_clustered BOOLEAN, - enterprise_features VARCHAR, - service_objective VARCHAR, - vcore_count INTEGER, - lock_pages_in_memory BOOLEAN, - instant_file_initialization_enabled BOOLEAN, - memory_dump_count INTEGER -)"; - - public const string CreateServerPropertiesIndex = @" -CREATE INDEX IF NOT EXISTS idx_server_properties_time ON server_properties(server_id, collection_time)"; - - public const string CreateSessionStatsTable = @" -CREATE TABLE IF NOT EXISTS session_stats ( - collection_id BIGINT PRIMARY KEY, - collection_time TIMESTAMP NOT NULL, - server_id INTEGER NOT NULL, - server_name VARCHAR NOT NULL, - program_name VARCHAR NOT NULL, - connection_count BIGINT NOT NULL, - running_count INTEGER NOT NULL, - sleeping_count INTEGER NOT NULL, - dormant_count INTEGER NOT NULL, - total_cpu_time_ms BIGINT, - total_reads BIGINT, - total_writes BIGINT, - total_logical_reads BIGINT -)"; - - public const string CreateSessionStatsIndex = @" -CREATE INDEX IF NOT EXISTS idx_session_stats_time ON session_stats(server_id, collection_time)"; - public const string CreateAlertLogTable = @" CREATE TABLE IF NOT EXISTS config_alert_log ( alert_time TIMESTAMP NOT NULL, server_id INTEGER NOT NULL, server_name VARCHAR NOT NULL, metric_name VARCHAR NOT NULL, - current_value DOUBLE NOT NULL, - threshold_value DOUBLE NOT NULL, + current_value DOUBLE PRECISION NOT NULL, + threshold_value DOUBLE PRECISION NOT NULL, alert_sent BOOLEAN NOT NULL DEFAULT false, notification_type VARCHAR NOT NULL DEFAULT 'tray', send_error VARCHAR, @@ -850,75 +125,41 @@ CREATE INDEX IF NOT EXISTS idx_dismissed_archive_alerts ON dismissed_archive_alerts (alert_time, server_id, metric_name)"; /// <summary> - /// Returns all table creation statements in order. + /// All table creation statements: the hand-written non-collector tables, then the 35 collector + /// tables generated from <see cref="PerformanceMonitor.Collectors.CollectorCatalog"/> by + /// <see cref="DuckDbSchemaGenerator"/> (42 total). Order is immaterial — every statement is + /// <c>CREATE TABLE IF NOT EXISTS</c> and the tables have no inter-table dependencies. /// </summary> public static IEnumerable<string> GetAllTableStatements() { yield return CreateServersTable; yield return CreateCollectionScheduleTable; yield return CreateCollectionLogTable; - yield return CreateWaitStatsTable; - yield return CreateQueryStatsTable; - yield return CreateCpuUtilizationStatsTable; - yield return CreateFileIoStatsTable; - yield return CreateMemoryStatsTable; - yield return CreateMemoryClerksTable; - yield return CreateMemoryPressureEventsTable; - yield return CreateDeadlocksTable; - yield return CreateProcedureStatsTable; - yield return CreateQueryStoreStatsTable; - yield return CreateQuerySnapshotsTable; - yield return CreateTempdbStatsTable; - yield return CreatePerfmonStatsTable; - yield return CreateServerConfigTable; - yield return CreateDatabaseConfigTable; - yield return CreateMemoryGrantStatsTable; - yield return CreateWaitingTasksTable; - yield return CreateBlockedProcessReportsTable; - yield return CreateDmvBlockingSnapshotsTable; - yield return CreateDatabaseScopedConfigTable; - yield return CreateTraceFlagsTable; - yield return CreateRunningJobsTable; - yield return CreateDatabaseSizeStatsTable; - yield return CreateIndexObjectStatsTable; - yield return CreateServerPropertiesTable; - yield return CreateSessionStatsTable; yield return CreateAlertLogTable; yield return CreateEdgeTriggerWatermarksTable; yield return CreateMuteRulesTable; yield return CreateDismissedArchiveAlertsTable; + + foreach (var statement in DuckDbSchemaGenerator.CreateTableStatements()) + { + yield return statement; + } } /// <summary> - /// Returns all index creation statements. + /// All index creation statements: the hand-written non-collector indexes (collection_log, + /// dismissed_archive_alerts) plus the 33 generated collector indexes (server_config and + /// database_config have none), 35 total. /// </summary> public static IEnumerable<string> GetAllIndexStatements() { - yield return CreateWaitStatsIndex; - yield return CreateQueryStatsIndex; - yield return CreateProcedureStatsIndex; - yield return CreateQueryStoreIndex; - yield return CreateQuerySnapshotsIndex; - yield return CreateCpuIndex; - yield return CreateFileIoIndex; - yield return CreateMemoryIndex; - yield return CreateTempdbIndex; - yield return CreatePerfmonIndex; - yield return CreateDeadlocksIndex; yield return CreateCollectionLogIndex; - yield return CreateMemoryGrantStatsIndex; - yield return CreateWaitingTasksIndex; - yield return CreateBlockedProcessReportsIndex; - yield return CreateDmvBlockingSnapshotsIndex; - yield return CreateMemoryClerksIndex; - yield return CreateMemoryPressureEventsIndex; - yield return CreateDatabaseScopedConfigIndex; - yield return CreateTraceFlagsIndex; - yield return CreateRunningJobsIndex; - yield return CreateDatabaseSizeStatsIndex; - yield return CreateIndexObjectStatsIndex; - yield return CreateServerPropertiesIndex; - yield return CreateSessionStatsIndex; + + foreach (var statement in DuckDbSchemaGenerator.CreateIndexStatements()) + { + yield return statement; + } + yield return CreateDismissedArchiveAlertsIndex; } } diff --git a/Lite/GlobalUsings.cs b/Lite/GlobalUsings.cs new file mode 100644 index 000000000..8960e4847 --- /dev/null +++ b/Lite/GlobalUsings.cs @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +/* Phase-5 A0: the six alert row types moved to the shared PerformanceMonitor.Alerting library + (one canonical copy instead of member-identical Lite/Dashboard twins). These aliases keep every + existing call site compiling unchanged against the shared types. */ +global using AnomalousJobInfo = PerformanceMonitor.Alerting.AnomalousJobInfo; +global using FailedJobInfo = PerformanceMonitor.Alerting.FailedJobInfo; +global using LongRunningQueryInfo = PerformanceMonitor.Alerting.LongRunningQueryInfo; +global using PoisonWaitDelta = PerformanceMonitor.Alerting.PoisonWaitDelta; +global using TempDbSpaceInfo = PerformanceMonitor.Alerting.TempDbSpaceInfo; +global using VolumeFreeSpaceInfo = PerformanceMonitor.Alerting.VolumeFreeSpaceInfo; diff --git a/Lite/Helpers/ContextMenuHelper.cs b/Lite/Helpers/ContextMenuHelper.cs index ff52381a4..d7c2794be 100644 --- a/Lite/Helpers/ContextMenuHelper.cs +++ b/Lite/Helpers/ContextMenuHelper.cs @@ -19,6 +19,7 @@ using Microsoft.Win32; using ScottPlot.WPF; using PerformanceMonitor.Ui; +using static PerformanceMonitor.Ui.DataGridHelpers; namespace PerformanceMonitorLite.Helpers; @@ -29,18 +30,6 @@ namespace PerformanceMonitorLite.Helpers; /// </summary> public static class ContextMenuHelper { - public static DataGrid? FindParentDataGrid(object sender) - { - if (sender is not MenuItem menuItem) return null; - var contextMenu = menuItem.Parent as ContextMenu; - var target = contextMenu?.PlacementTarget as FrameworkElement; - while (target != null && target is not DataGrid) - { - target = VisualTreeHelper.GetParent(target) as FrameworkElement; - } - return target as DataGrid; - } - public static string GetCellValue(DataGridColumn col, object item) { if (col is DataGridBoundColumn boundCol @@ -181,8 +170,11 @@ private static string CsvEscape(string value, string separator) /// <summary> /// Sets up a context menu for a ScottPlot chart with standard options: /// Copy Image, Save Image As, Open in New Window, Revert, Export Data to CSV. + /// <paramref name="revertAction"/> lets a windowed caller (ServerTab) re-pin the X axis to its current + /// settable time window on Revert / double-click instead of AutoScale()'ing to the data range (which + /// re-introduces ScottPlot's ~10% side dead-space); windowless callers omit it and fall back to AutoScale. /// </summary> - public static ContextMenu SetupChartContextMenu(WpfPlot chart, string chartName, string? dataSource = null) + public static ContextMenu SetupChartContextMenu(WpfPlot chart, string chartName, string? dataSource = null, Action<WpfPlot>? revertAction = null) { var contextMenu = new ContextMenu(); @@ -262,15 +254,9 @@ public static ContextMenu SetupChartContextMenu(WpfPlot chart, string chartName, contextMenu.Items.Add(new Separator()); - // Revert (Autoscale) + // Revert (re-pin to the settable window when a revertAction is supplied; AutoScale otherwise) var autoscaleItem = new MenuItem { Header = "Revert (or double-click)", Icon = new TextBlock { Text = "\u21a9" } }; - autoscaleItem.Click += (s, e) => - { - // Clear an active click-isolate first so it doesn't leave series dimmed / state stale. - if (ChartHoverHelper.TryGetForChart(chart, out var h)) h.Restore(); - chart.Plot.Axes.AutoScale(); - chart.Refresh(); - }; + autoscaleItem.Click += (s, e) => RevertChart(chart, revertAction); contextMenu.Items.Add(autoscaleItem); contextMenu.Items.Add(new Separator()); @@ -369,12 +355,29 @@ public static ContextMenu SetupChartContextMenu(WpfPlot chart, string chartName, chart.PreviewMouseDoubleClick += (s, e) => { e.Handled = true; - // Clear an active click-isolate first so it doesn't leave series dimmed / state stale. - if (ChartHoverHelper.TryGetForChart(chart, out var h)) h.Restore(); - chart.Plot.Axes.AutoScale(); - chart.Refresh(); + RevertChart(chart, revertAction); }; return contextMenu; } + + /// <summary> + /// Shared Revert body for the menu item + double-click. A ServerTab caller supplies + /// <paramref name="revertAction"/> to re-pin X to the current settable window (+ auto-fit Y); it also + /// clears any active click-isolate. Windowless callers (standalone windows) have no window to pin to, so + /// this clears the isolate and falls back to AutoScale. + /// </summary> + private static void RevertChart(WpfPlot chart, Action<WpfPlot>? revertAction) + { + if (revertAction != null) + { + revertAction(chart); + return; + } + + // Clear an active click-isolate first so it doesn't leave series dimmed / state stale. + if (ChartHoverHelper.TryGetForChart(chart, out var h)) h.Restore(); + chart.Plot.Axes.AutoScale(); + chart.Refresh(); + } } diff --git a/Lite/MainWindow.AlertEngine.cs b/Lite/MainWindow.AlertEngine.cs index f1f8b98d0..65e10c67d 100644 --- a/Lite/MainWindow.AlertEngine.cs +++ b/Lite/MainWindow.AlertEngine.cs @@ -8,35 +8,41 @@ using System; using System.Collections.Generic; -using System.Threading.Tasks; using System.Linq; -using System.Xml.Linq; -using System.Net; using System.Threading; +using System.Threading.Tasks; using System.Windows; -using System.Windows.Controls; -using System.Windows.Input; -using System.Windows.Threading; using PerformanceMonitor.Notifications; -using PerformanceMonitorLite.Controls; -using PerformanceMonitorLite.Database; -using PerformanceMonitorLite.Mcp; using PerformanceMonitorLite.Models; using PerformanceMonitorLite.Services; -using PerformanceMonitorLite.Windows; -using PerformanceMonitor.Common; -using PerformanceMonitor.Ui; +/* The whole per-check loop (CPU → blocking → deadlocks → poison waits → long-running queries → + tempdb → volume free space → anomalous jobs → failed jobs) lives in the shared + PerformanceMonitor.Alerting.AlertEngine (Phase-5); this file only builds the snapshot, forwards, + and applies the sweep's badge outcomes. The app's own CpuAlertMode enum is never referenced + here, so the namespace import cannot collide. */ +using PerformanceMonitor.Alerting; namespace PerformanceMonitorLite; public partial class MainWindow : Window { + /// <summary> + /// Phase-5 forwarding: one alert sweep for one overview summary. The evaluation — + /// thresholds, edge triggers, cooldowns, mutes, watermark persistence, resolution + /// transitions — runs in the shared <see cref="AlertEngine"/> (the same code the headless + /// Darling service runs); Lite-specific delivery (tray toasts, the #1141/#1236 per-event + /// split, the one-combined-history-row send) lives behind <see cref="LiteAlertDeliverer"/>, + /// and this method keeps only what never left the app: the suppression input + /// (<see cref="AlertStateService.ShouldShowAlerts"/>) and the #754/#749 server-tab badge + /// writes fed by the engine's sweep result. + /// </summary> private async void CheckPerformanceAlerts(ServerSummaryItem summary) { - if (!App.AlertsEnabled || _trayService == null) return; + /* Same early return as the pre-forwarding loop: alerts off or no tray yet — leave badge + state untouched. (_alertEngine is wired right after the tray in MainWindow_Loaded.) */ + if (!App.AlertsEnabled || _trayService == null || _alertEngine == null) return; var key = summary.ServerId.ToString(); - var now = DateTime.UtcNow; /* Resolve the ServerConnection (the GUID identity used by the tabs/badges) for this summary, which carries the int DuckDB server_id. Drives the server tab badge for the low-disk / @@ -44,692 +50,56 @@ private async void CheckPerformanceAlerts(ServerSummaryItem summary) var badgeServer = _serverManager.GetAllServers().FirstOrDefault(s => RemoteCollectorService.GetDeterministicHashCode(RemoteCollectorService.GetServerNameForStorage(s)).ToString() == key); - /* #1128 review fix: snapshot the prior badge flags, recompute them as locals through the - sweep, and write them ONCE at the end — so a disabled feature / offline server clears a - stale badge (not just the active branch), and a false->true transition clears the ack. */ + /* #1128 review fix: snapshot the prior badge flags, take this sweep's values from the + engine's result, and write them ONCE at the end — so a disabled feature / offline server + clears a stale badge (not just the active branch), and a false->true transition clears + the ack. */ bool prevBadgeLowDisk = badgeServer != null && _badgeLowDisk.TryGetValue(badgeServer.Id, out var _pBadgeLd) && _pBadgeLd; bool prevBadgeFailedJob = badgeServer != null && _badgeFailedJob.TryGetValue(badgeServer.Id, out var _pBadgeFj) && _pBadgeFj; - bool curBadgeLowDisk = false; - bool curBadgeFailedJob = false; - var alertCooldown = TimeSpan.FromMinutes(App.AlertCooldownMinutes); - - /* Skip popup/email alerts if user has acknowledged or silenced this server */ + /* Skip popup/email alerts if user has acknowledged or silenced this server — suppression + is an INPUT to the shared engine (evaluate-but-don't-deliver, gates don't advance). */ bool suppressPopups = !_alertStateService.ShouldShowAlerts(key); - /* CPU alerts — uses the metric the user selected (Total non-idle CPU by default, or SQL Server only). */ - var alertCpuValue = summary.CpuPercentForAlert; - string cpuMetricLabel = App.AlertCpuMode == CpuAlertMode.Total ? "Total CPU" : "SQL CPU"; - bool cpuExceeded = App.AlertCpuEnabled - && alertCpuValue.HasValue - && alertCpuValue.Value >= App.AlertCpuThreshold; - - if (cpuExceeded) - { - _activeCpuAlert[key] = true; - if (!suppressPopups && (!_lastCpuAlert.TryGetValue(key, out var lastCpu) || now - lastCpu >= alertCooldown)) - { - var muteCtx = new AlertMuteContext { ServerName = summary.DisplayName, MetricName = "High CPU" }; - bool isMuted = _muteRuleService.IsAlertMuted(muteCtx); - _lastCpuAlert[key] = now; - - if (!isMuted) - { - _trayService.ShowSnoozableNotification( - "High CPU", - $"{summary.DisplayName}: {cpuMetricLabel} at {alertCpuValue:F0}% (threshold: {App.AlertCpuThreshold}%)", - Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning, - summary.DisplayName, - "High CPU", - _muteRuleService); - } - - var cpuDetailText = $" {cpuMetricLabel}: {alertCpuValue:F0}%\n Threshold: {App.AlertCpuThreshold}%"; - - await _emailAlertService.TrySendAlertEmailAsync( - "High CPU", - summary.DisplayName, - $"{alertCpuValue:F0}% ({cpuMetricLabel})", - $"{App.AlertCpuThreshold}%", - summary.ServerId, - muted: isMuted, - detailText: cpuDetailText); - } - } - else if (_activeCpuAlert.TryGetValue(key, out var wasCpu) && wasCpu) - { - _activeCpuAlert[key] = false; - /* Only announce "resolved" if the user is still watching this alert and the server - isn't silenced. Disabling the alert flips cpuExceeded false (it includes the enabled - flag) and silencing sets suppressPopups — neither means CPU actually recovered. */ - if (!suppressPopups && App.AlertCpuEnabled) - { - _trayService.ShowStyledNotification( - "CPU Resolved", - $"{summary.DisplayName}: {cpuMetricLabel} back to {alertCpuValue:F0}%", - ToastSeverity.Success); - } - } - - /* Blocking alerts */ - var effectiveBlockingCount = summary.BlockingCount; - if (App.AlertBlockingEnabled && App.AlertExcludedDatabases.Count > 0 - && summary.BlockingCount >= App.AlertBlockingThreshold && _dataService != null) - { - try - { - var blockingRows = await Task.Run(() => _dataService.GetRecentBlockedProcessReportsAsync(summary.ServerId, hoursBack: 1)); - effectiveBlockingCount = blockingRows - .Count(r => string.IsNullOrEmpty(r.DatabaseName) || - !App.AlertExcludedDatabases.Any(e => - string.Equals(e, r.DatabaseName, StringComparison.OrdinalIgnoreCase))); - } - catch (Exception ex) - { - AppLogger.Error("Alerts", $"Failed to filter blocking count for {summary.DisplayName}: {ex.Message}"); - } - } - - /* Edge-trigger the rolling 1-hour blocking count so the same blocked-process reports - don't re-alert every cooldown for the whole hour they linger in the window (#1091). - See RollingCountAlertGate for the watermark semantics. */ - int blockingWatermark = _lastAlertedBlockingCount.TryGetValue(key, out var labc) ? labc : 0; - bool blockingCooldownElapsed = !_lastBlockingAlert.TryGetValue(key, out var lastBlocking) || now - lastBlocking >= alertCooldown; - var blockingDecision = App.AlertBlockingEnabled - ? RollingCountAlertGate.Evaluate(effectiveBlockingCount, App.AlertBlockingThreshold, blockingWatermark, blockingCooldownElapsed, suppressPopups) - : new RollingCountAlertGate.Decision(false, false, 0); - _lastAlertedBlockingCount[key] = blockingDecision.Watermark; - /* Persist the watermark across restart so the same blocked-process reports aren't - re-alerted (and re-posted to Teams/Slack) on the first post-restart sweep (#1145). - On-change only — the gate returns the same watermark on most sweeps. */ - if (blockingDecision.Watermark != blockingWatermark) - { - await _alertHistoryStore.SaveEdgeTriggerWatermarkAsync(summary.ServerId, BlockingWatermarkMetric, blockingDecision.Watermark); - } - - bool wasBlockingActive = _activeBlockingAlert.TryGetValue(key, out var wasBlocking) && wasBlocking; - _activeBlockingAlert[key] = blockingDecision.Active; - - if (blockingDecision.Fire) - { - var muteCtx = new AlertMuteContext { ServerName = summary.DisplayName, MetricName = "Blocking Detected" }; - bool isMuted = _muteRuleService.IsAlertMuted(muteCtx); - _lastBlockingAlert[key] = now; - - if (!isMuted) - { - _trayService.ShowSnoozableNotification( - "Blocking Detected", - $"{summary.DisplayName}: {effectiveBlockingCount} blocking session(s)", - Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning, - summary.DisplayName, - "Blocking Detected", - _muteRuleService); - } - - var blockingContext = await BuildBlockingContextAsync(summary.ServerId, summary.DisplayName); - var detailText = ContextToDetailText(blockingContext); - - await SendDetectedAlertAsync( - "Blocking Detected", - summary.DisplayName, - effectiveBlockingCount.ToString(), - App.AlertBlockingThreshold.ToString(), - summary.ServerId, - blockingContext, - isMuted, - detailText); - } - else if (!blockingDecision.Active && wasBlockingActive) - { - if (!suppressPopups && App.AlertBlockingEnabled) - { - _trayService.ShowStyledNotification( - "Blocking Cleared", - $"{summary.DisplayName}: No active blocking", - ToastSeverity.Success); - } - } - - /* Deadlock alerts */ - var effectiveDeadlockCount = summary.DeadlockCount; - if (App.AlertDeadlockEnabled && App.AlertExcludedDatabases.Count > 0 - && summary.DeadlockCount >= App.AlertDeadlockThreshold && _dataService != null) - { - try - { - var deadlockRows = await Task.Run(() => _dataService.GetRecentDeadlocksAsync(summary.ServerId, hoursBack: 1)); - effectiveDeadlockCount = deadlockRows - .Count(r => !IsDeadlockExcluded(r, App.AlertExcludedDatabases)); - } - catch (Exception ex) - { - AppLogger.Error("Alerts", $"Failed to filter deadlock count for {summary.DisplayName}: {ex.Message}"); - } - } - - /* Edge-trigger the rolling 1-hour deadlock count so the same deadlocks don't re-alert - every cooldown for the whole hour they linger in the window (#1091). See - RollingCountAlertGate for the watermark semantics. */ - int deadlockWatermark = _lastAlertedDeadlockCount.TryGetValue(key, out var ladc) ? ladc : 0; - bool deadlockCooldownElapsed = !_lastDeadlockAlert.TryGetValue(key, out var lastDeadlock) || now - lastDeadlock >= alertCooldown; - var deadlockDecision = App.AlertDeadlockEnabled - ? RollingCountAlertGate.Evaluate(effectiveDeadlockCount, App.AlertDeadlockThreshold, deadlockWatermark, deadlockCooldownElapsed, suppressPopups) - : new RollingCountAlertGate.Decision(false, false, 0); - _lastAlertedDeadlockCount[key] = deadlockDecision.Watermark; - /* Persist the watermark across restart so the same deadlocks aren't re-alerted (and - re-posted to Teams/Slack) on the first post-restart sweep (#1145). On-change only. */ - if (deadlockDecision.Watermark != deadlockWatermark) - { - await _alertHistoryStore.SaveEdgeTriggerWatermarkAsync(summary.ServerId, DeadlockWatermarkMetric, deadlockDecision.Watermark); - } - - bool wasDeadlockActive = _activeDeadlockAlert.TryGetValue(key, out var wasDeadlock) && wasDeadlock; - _activeDeadlockAlert[key] = deadlockDecision.Active; - - if (deadlockDecision.Fire) - { - var muteCtx = new AlertMuteContext { ServerName = summary.DisplayName, MetricName = "Deadlocks Detected" }; - bool isMuted = _muteRuleService.IsAlertMuted(muteCtx); - _lastDeadlockAlert[key] = now; - - if (!isMuted) - { - _trayService.ShowSnoozableNotification( - "Deadlocks Detected", - $"{summary.DisplayName}: {effectiveDeadlockCount} deadlock(s) in the last hour", - Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Error, - summary.DisplayName, - "Deadlocks Detected", - _muteRuleService); - } - - var deadlockContext = await BuildDeadlockContextAsync(summary.ServerId, summary.DisplayName); - var detailText = ContextToDetailText(deadlockContext); + /* The failed-jobs check needs the live connection status (online + engine edition); the + fetcher re-checks it fresh at fetch time, exactly where the old loop's gate sat. */ + var connStatus = badgeServer != null ? _serverManager.GetConnectionStatus(badgeServer.Id) : null; - await SendDetectedAlertAsync( - "Deadlocks Detected", - summary.DisplayName, - effectiveDeadlockCount.ToString(), - App.AlertDeadlockThreshold.ToString(), - summary.ServerId, - deadlockContext, - isMuted, - detailText); - } - else if (!deadlockDecision.Active && wasDeadlockActive) - { - if (!suppressPopups && App.AlertDeadlockEnabled) - { - _trayService.ShowStyledNotification( - "Deadlocks Cleared", - $"{summary.DisplayName}: No deadlocks in the last hour", - ToastSeverity.Success); - } - } - - /* Poison wait alerts */ - if (App.AlertPoisonWaitEnabled && _dataService != null) - { - try - { - var poisonWaits = await Task.Run(() => _dataService.GetLatestPoisonWaitAvgsAsync(summary.ServerId)); - var triggered = poisonWaits.FindAll(w => w.AvgMsPerWait >= App.AlertPoisonWaitThresholdMs); - - if (triggered.Count > 0) - { - _activePoisonWaitAlert[key] = true; - if (!suppressPopups && (!_lastPoisonWaitAlert.TryGetValue(key, out var lastPoisonWait) || now - lastPoisonWait >= alertCooldown)) - { - var worst = triggered[0]; - var allWaitNames = string.Join(", ", triggered.ConvertAll(w => $"{w.WaitType} ({w.AvgMsPerWait:F0}ms)")); - - /* Poison wait mute check uses the worst (highest avg ms/wait) triggered wait type. - Limitation: if a user mutes a specific wait type that isn't the worst, the alert - still fires. Conversely, muting the worst type suppresses the entire alert even - if other unmuted poison waits are present. */ - var muteCtx = new AlertMuteContext { ServerName = summary.DisplayName, MetricName = "Poison Wait", WaitType = worst.WaitType }; - bool isMuted = _muteRuleService.IsAlertMuted(muteCtx); - _lastPoisonWaitAlert[key] = now; - - if (!isMuted) - { - _trayService.ShowSnoozableNotification( - "Poison Wait", - $"{summary.DisplayName}: {worst.WaitType} avg {worst.AvgMsPerWait:F0}ms/wait", - Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Error, - summary.DisplayName, - "Poison Wait", - _muteRuleService); - } - - var poisonContext = BuildPoisonWaitContext(triggered); - var detailText = ContextToDetailText(poisonContext); - - await _emailAlertService.TrySendAlertEmailAsync( - "Poison Wait", - summary.DisplayName, - allWaitNames, - $"{App.AlertPoisonWaitThresholdMs}ms avg", - summary.ServerId, - poisonContext, - numericCurrentValue: worst.AvgMsPerWait, - numericThresholdValue: App.AlertPoisonWaitThresholdMs, - muted: isMuted, - detailText: detailText); - } - } - else if (_activePoisonWaitAlert.TryGetValue(key, out var wasPoisonWait) && wasPoisonWait) - { - _activePoisonWaitAlert[key] = false; - if (!suppressPopups) - { - _trayService.ShowStyledNotification( - "Poison Waits Cleared", - $"{summary.DisplayName}: Poison wait avg below threshold", - ToastSeverity.Success); - } - } - } - catch (Exception ex) - { - AppLogger.Error("Alerts", $"Failed to check poison waits for {summary.DisplayName}: {ex.Message}"); - } - } + var snapshot = new AlertServerSnapshot( + key, + summary.DisplayName, + IsOnline: connStatus?.IsOnline == true, + SqlCpuPercent: summary.CpuPercent, + TotalCpuPercent: summary.TotalCpuPercent, + IsAzureSqlDb: connStatus?.SqlEngineEdition == 5, + Suppressed: suppressPopups); - /* Long-running query alerts */ - if (App.AlertLongRunningQueryEnabled && _dataService != null) + AlertSweepResult sweep; + try { - try - { - var longRunning = await Task.Run(() => _dataService.GetLongRunningQueriesAsync(summary.ServerId, App.AlertLongRunningQueryThresholdMinutes, App.AlertLongRunningQueryMaxResults, App.AlertLongRunningQueryExcludeSpServerDiagnostics, App.AlertLongRunningQueryExcludeWaitFor, App.AlertLongRunningQueryExcludeBackups, App.AlertLongRunningQueryExcludeMiscWaits, App.AlertLongRunningQueryExcludeCdc)); - - if (App.AlertExcludedDatabases.Count > 0) - { - longRunning = longRunning - .Where(q => string.IsNullOrEmpty(q.DatabaseName) || - !App.AlertExcludedDatabases.Any(e => - string.Equals(e, q.DatabaseName, StringComparison.OrdinalIgnoreCase))) - .ToList(); - } - - if (longRunning.Count > 0) - { - _activeLongRunningQueryAlert[key] = true; - if (!suppressPopups && (!_lastLongRunningQueryAlert.TryGetValue(key, out var lastLrq) || now - lastLrq >= alertCooldown)) - { - var worst = longRunning[0]; - var elapsedMinutes = worst.ElapsedSeconds / 60; - var preview = TruncateText(worst.QueryText, 80); - var previewSuffix = string.IsNullOrEmpty(preview) ? "" : $" — {preview}"; - - var muteCtx = new AlertMuteContext - { - ServerName = summary.DisplayName, - MetricName = "Long-Running Query", - DatabaseName = worst.DatabaseName, - QueryText = worst.QueryText - }; - bool isMuted = _muteRuleService.IsAlertMuted(muteCtx); - _lastLongRunningQueryAlert[key] = now; - - if (!isMuted) - { - _trayService.ShowSnoozableNotification( - "Long-Running Query", - $"{summary.DisplayName}: Session #{worst.SessionId} running {elapsedMinutes}m{previewSuffix}", - Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning, - summary.DisplayName, - "Long-Running Query", - _muteRuleService); - } - - var lrqContext = BuildLongRunningQueryContext(summary.DisplayName, longRunning); - var detailText = ContextToDetailText(lrqContext); - - await _emailAlertService.TrySendAlertEmailAsync( - "Long-Running Query", - summary.DisplayName, - $"{longRunning.Count} query(s), longest {elapsedMinutes}m", - $"{App.AlertLongRunningQueryThresholdMinutes}m", - summary.ServerId, - lrqContext, - numericCurrentValue: elapsedMinutes, - numericThresholdValue: App.AlertLongRunningQueryThresholdMinutes, - muted: isMuted, - detailText: detailText); - } - } - else if (_activeLongRunningQueryAlert.TryGetValue(key, out var wasLongRunning) && wasLongRunning) - { - _activeLongRunningQueryAlert[key] = false; - if (!suppressPopups) - { - _trayService.ShowStyledNotification( - "Long-Running Queries Cleared", - $"{summary.DisplayName}: No queries over threshold", - ToastSeverity.Success); - } - } - } - catch (Exception ex) - { - AppLogger.Error("Alerts", $"Failed to check long-running queries for {summary.DisplayName}: {ex.Message}"); - } + sweep = await _alertEngine.EvaluateServerAsync(snapshot); } - - /* TempDB space alerts */ - if (App.AlertTempDbSpaceEnabled && _dataService != null) + catch (Exception ex) { - try - { - var tempDb = await Task.Run(() => _dataService.GetLatestTempDbSpaceAsync(summary.ServerId)); - - if (tempDb != null && tempDb.UsedPercent >= App.AlertTempDbSpaceThresholdPercent) - { - _activeTempDbSpaceAlert[key] = true; - if (!suppressPopups && (!_lastTempDbSpaceAlert.TryGetValue(key, out var lastTempDb) || now - lastTempDb >= alertCooldown)) - { - var muteCtx = new AlertMuteContext { ServerName = summary.DisplayName, MetricName = "TempDB Space" }; - bool isMuted = _muteRuleService.IsAlertMuted(muteCtx); - _lastTempDbSpaceAlert[key] = now; - - if (!isMuted) - { - _trayService.ShowSnoozableNotification( - "TempDB Space", - $"{summary.DisplayName}: TempDB {tempDb.UsedPercent:F0}% used", - Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning, - summary.DisplayName, - "TempDB Space", - _muteRuleService); - } - - var tempDbContext = BuildTempDbSpaceContext(tempDb); - var detailText = ContextToDetailText(tempDbContext); - - await _emailAlertService.TrySendAlertEmailAsync( - "TempDB Space", - summary.DisplayName, - $"{tempDb.UsedPercent:F0}% used ({tempDb.TotalReservedMb:F0} MB)", - $"{App.AlertTempDbSpaceThresholdPercent}%", - summary.ServerId, - tempDbContext, - numericCurrentValue: tempDb.UsedPercent, - numericThresholdValue: App.AlertTempDbSpaceThresholdPercent, - muted: isMuted, - detailText: detailText); - } - } - else if (_activeTempDbSpaceAlert.TryGetValue(key, out var wasTempDb) && wasTempDb) - { - _activeTempDbSpaceAlert[key] = false; - if (!suppressPopups) - { - var pct = tempDb != null ? $"{tempDb.UsedPercent:F0}%" : "N/A"; - _trayService.ShowStyledNotification( - "TempDB Space Resolved", - $"{summary.DisplayName}: TempDB usage back to {pct}", - ToastSeverity.Success); - } - } - } - catch (Exception ex) - { - AppLogger.Error("Alerts", $"Failed to check TempDB space for {summary.DisplayName}: {ex.Message}"); - } - } - - /* Low volume free space alerts — not applicable to Azure SQL DB (no volume stats collected) */ - if (App.AlertLowDiskEnabled && _dataService != null) - { - try - { - var volumes = await Task.Run(() => _dataService.GetVolumeFreeSpaceAsync(summary.ServerId)); - var breached = GetBreachedVolumes(volumes); - - /* Drive the server tab badge — a breached volume is a standing condition (#754). - Recorded as a local; the flags are written once at the end of the sweep (#1128 review). */ - curBadgeLowDisk = breached.Count > 0; - - if (breached.Count > 0) - { - var worst = breached[0]; - _activeLowDiskAlert[key] = true; - double? lastLowDiskPercent = - _lastAlertedLowDiskPercent.TryGetValue(key, out var lowDiskPct) ? lowDiskPct : (double?)null; - /* #754 follow-up: notify only on a fresh or worsening breach, not every cooldown for a - standing full volume (which also re-recorded a history row and made Dismiss feel broken). */ - if (!suppressPopups - && LowDiskAlertGate.ShouldAlert(worst.FreePercent, lastLowDiskPercent) - && (!_lastLowDiskAlert.TryGetValue(key, out var lastLowDisk) || now - lastLowDisk >= alertCooldown)) - { - var muteCtx = new AlertMuteContext { ServerName = summary.DisplayName, MetricName = "Volume Free Space" }; - bool isMuted = _muteRuleService.IsAlertMuted(muteCtx); - _lastLowDiskAlert[key] = now; - _lastAlertedLowDiskPercent[key] = worst.FreePercent; - - if (!isMuted) - { - _trayService.ShowSnoozableNotification( - "Volume Free Space", - $"{summary.DisplayName}: {worst.MountPoint} {worst.FreePercent:F0}% free ({worst.FreeGb:F1} GB)", - Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning, - summary.DisplayName, - "Volume Free Space", - _muteRuleService); - } - - var lowDiskContext = BuildVolumeFreeSpaceContext(summary.DisplayName, breached); - /* #1136: grade the alert — WARNING normally, CRITICAL when the worst volume is - critically low — so the email/webhook badge reflects how dire the breach is. - (lowDiskContext is non-null here — breached.Count > 0 — but typed nullable.) */ - if (lowDiskContext is not null && LowDiskAlertGate.IsCriticallyLow(worst.FreePercent, worst.FreeGb)) - { - lowDiskContext.SeverityOverride = AlertSeverityLevel.Critical; - } - var detailText = ContextToDetailText(lowDiskContext); - - await _emailAlertService.TrySendAlertEmailAsync( - "Volume Free Space", - summary.DisplayName, - $"{worst.MountPoint} {worst.FreePercent:F0}% free ({worst.FreeGb:F1} GB)", - FormatLowDiskThreshold(), - summary.ServerId, - lowDiskContext, - numericCurrentValue: worst.FreePercent, - numericThresholdValue: App.AlertLowDiskThresholdPercent, - muted: isMuted, - detailText: detailText); - } - } - else if (_activeLowDiskAlert.TryGetValue(key, out var wasLowDisk) && wasLowDisk) - { - _activeLowDiskAlert[key] = false; - _lastAlertedLowDiskPercent.Remove(key); - if (!suppressPopups) - { - _trayService.ShowStyledNotification( - "Volume Free Space Resolved", - $"{summary.DisplayName}: All volumes back above threshold", - ToastSeverity.Success); - } - } - } - catch (Exception ex) - { - AppLogger.Error("Alerts", $"Failed to check volume free space for {summary.DisplayName}: {ex.Message}"); - } - } - - /* Anomalous Agent job alerts */ - if (App.AlertLongRunningJobEnabled && _dataService != null) - { - try - { - var anomalousJobs = await Task.Run(() => _dataService.GetAnomalousJobsAsync(summary.ServerId, App.AlertLongRunningJobMultiplier)); - - /* _lastLongRunningJobAlert is keyed per job *run* ({server}:{jobId}:{startTime}), - so unlike the per-server cooldown dicts it grows without bound. Drop entries - that have aged past the cooldown each pass. */ - foreach (var staleJobKey in _lastLongRunningJobAlert - .Where(kv => now - kv.Value >= alertCooldown) - .Select(kv => kv.Key) - .ToList()) - { - _lastLongRunningJobAlert.Remove(staleJobKey); - } - - if (anomalousJobs.Count > 0) - { - _activeLongRunningJobAlert[key] = true; - var worst = anomalousJobs[0]; - var jobKey = $"{key}:{worst.JobId}:{worst.StartTime:O}"; - - if (!suppressPopups && (!_lastLongRunningJobAlert.TryGetValue(jobKey, out var lastJob) || now - lastJob >= alertCooldown)) - { - var currentMinutes = worst.CurrentDurationSeconds / 60; - - var muteCtx = new AlertMuteContext { ServerName = summary.DisplayName, MetricName = "Long-Running Job", JobName = worst.JobName }; - bool isMuted = _muteRuleService.IsAlertMuted(muteCtx); - _lastLongRunningJobAlert[jobKey] = now; - - if (!isMuted) - { - _trayService.ShowSnoozableNotification( - "Long-Running Job", - $"{summary.DisplayName}: {worst.JobName} at {worst.PercentOfAverage:F0}% of avg ({currentMinutes}m)", - Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning, - summary.DisplayName, - "Long-Running Job", - _muteRuleService); - } - - var jobContext = BuildAnomalousJobContext(summary.DisplayName, anomalousJobs); - var detailText = ContextToDetailText(jobContext); - - await _emailAlertService.TrySendAlertEmailAsync( - "Long-Running Job", - summary.DisplayName, - $"{anomalousJobs.Count} job(s) exceeding {App.AlertLongRunningJobMultiplier}x average", - $"{App.AlertLongRunningJobMultiplier}x historical avg", - summary.ServerId, - jobContext, - numericCurrentValue: (double)(worst.PercentOfAverage ?? 0), - numericThresholdValue: App.AlertLongRunningJobMultiplier * 100, - muted: isMuted, - detailText: detailText); - } - } - else if (_activeLongRunningJobAlert.TryGetValue(key, out var wasJob) && wasJob) - { - _activeLongRunningJobAlert[key] = false; - if (!suppressPopups) - { - _trayService.ShowStyledNotification( - "Long-Running Jobs Cleared", - $"{summary.DisplayName}: No jobs exceeding threshold", - ToastSeverity.Success); - } - } - } - catch (Exception ex) - { - AppLogger.Error("Alerts", $"Failed to check anomalous jobs for {summary.DisplayName}: {ex.Message}"); - } + /* The engine absorbs per-check failures itself; this catches the truly unexpected so + an async-void sweep can never take down the dispatcher. Badges stay untouched. */ + AppLogger.Error("Alerts", $"Alert sweep failed for {summary.DisplayName}: {ex.Message}"); + return; } - /* Failed Agent job alerts — live msdb query for runs that failed in the lookback window. - Failure outcomes aren't part of the collected running_jobs snapshot, so this queries the - monitored server directly (mirrors the long-running-query live pattern). Failures are - point-in-time events, so there is no "cleared" notification; the per-server watermark - dedups so the same failure never re-fires. */ - if (App.AlertFailedJobEnabled && _collectorService != null) - { - try - { - var server = _serverManager.GetAllServers().FirstOrDefault(s => - RemoteCollectorService.GetDeterministicHashCode(RemoteCollectorService.GetServerNameForStorage(s)).ToString() == key); - var connStatus = server != null ? _serverManager.GetConnectionStatus(server.Id) : null; - - /* Only query online, non-Azure-SQL-DB servers whose login has msdb access. - Azure SQL DB has no SQL Agent; a login without msdb can't read sysjobhistory. */ - if (server != null - && connStatus != null - && connStatus.IsOnline == true - && connStatus.SqlEngineEdition != 5 - && connStatus.HasMsdbAccess) - { - /* Live msdb read via the collector's connection path (async SqlClient, already - off the UI thread; MFA serialization / throttle / retry handled inside). */ - var failedJobs = await _collectorService.GetRecentlyFailedJobsAsync(server, App.AlertFailedJobLookbackMinutes); - - /* Drive the server tab badge — a failure in the lookback window (#749). Recorded - as a local; written once at the end of the sweep (#1128 review). */ - curBadgeFailedJob = failedJobs.Count > 0; - - if (failedJobs.Count > 0) - { - var newestFailure = failedJobs.Max(j => j.RunDateTime); - bool hasWatermark = _lastAlertedFailedJobTime.TryGetValue(key, out var lastFailure); - bool hasNewFailure = !hasWatermark || newestFailure > lastFailure; - - if (hasNewFailure && !suppressPopups && - (!_lastFailedJobAlert.TryGetValue(key, out var lastFailedAlert) || now - lastFailedAlert >= alertCooldown)) - { - var mostRecent = failedJobs[0]; // ORDER BY run_datetime DESC - var jobNames = string.Join(", ", failedJobs.Select(j => j.JobName).Distinct().Take(3)); - - var muteCtx = new AlertMuteContext { ServerName = summary.DisplayName, MetricName = "Failed Agent Job", JobName = mostRecent.JobName }; - bool isMuted = _muteRuleService.IsAlertMuted(muteCtx); - _lastFailedJobAlert[key] = now; - _lastAlertedFailedJobTime[key] = newestFailure; - /* Persist the watermark so the same failures don't re-fire on the first - post-restart sweep while they linger in the lookback window (#1145 - parity). On-change only — only when a failed-job toast actually fires. */ - await _alertHistoryStore.SaveFailedJobWatermarkAsync(summary.ServerId, newestFailure); - - if (!isMuted) - { - _trayService.ShowSnoozableNotification( - "Failed Agent Job", - $"{summary.DisplayName}: {failedJobs.Count} job failure(s) — {jobNames}", - Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning, - summary.DisplayName, - "Failed Agent Job", - _muteRuleService); - } - - var failedJobContext = BuildFailedJobContext(summary.DisplayName, failedJobs); - var detailText = ContextToDetailText(failedJobContext); - - await _emailAlertService.TrySendAlertEmailAsync( - "Failed Agent Job", - summary.DisplayName, - $"{failedJobs.Count} job failure(s) in last {App.AlertFailedJobLookbackMinutes}m — {jobNames}", - $"last {App.AlertFailedJobLookbackMinutes}m", - summary.ServerId, - failedJobContext, - numericCurrentValue: failedJobs.Count, - numericThresholdValue: 0, - muted: isMuted, - detailText: detailText); - } - } - } - } - catch (Exception ex) - { - AppLogger.Error("Alerts", $"Failed to check failed jobs for {summary.DisplayName}: {ex.Message}"); - } - } + /* Master switch flipped off between our gate and the engine's — no sweep ran, so leave + badge state untouched (the old loop's early return). */ + if (!sweep.Evaluated) return; /* #1128 review fix: write the badge's low-disk / failed-job flags ONCE per sweep from the - values computed above. Doing it here (not only inside the feature-enabled / online / msdb - branches) means a disabled feature or an offline server clears a previously-lit badge - instead of leaving it stale. A false->true transition is a genuinely new condition, so it - clears any acknowledgement — matching the Dashboard, whose IsWorseThanBaseline re-shows on - a new disk/job condition. RefreshServerBadgeExtras re-renders once (no-op without a tab). */ + standing conditions the engine observed. Doing it here (not only inside the + feature-enabled / online / msdb branches) means a disabled feature or an offline server + clears a previously-lit badge instead of leaving it stale. A false->true transition is a + genuinely new condition, so it clears any acknowledgement — matching the Dashboard, whose + IsWorseThanBaseline re-shows on a new disk/job condition. RefreshServerBadgeExtras + re-renders once (no-op without a tab). */ + bool curBadgeLowDisk = sweep.LowDiskConditionPresent; + bool curBadgeFailedJob = sweep.FailedJobConditionPresent; if (badgeServer != null) { _badgeLowDisk[badgeServer.Id] = curBadgeLowDisk; @@ -740,415 +110,122 @@ values computed above. Doing it here (not only inside the feature-enabled / onli } } - private static string TruncateText(string text, int maxLength = 300) + /// <summary> + /// Delivers a connection-edge alert — email + webhook + the <c>config_alert_log</c> row — for one server + /// (#1506). Called from <see cref="CheckConnectionsAndNotify"/>'s existing edge-triggered transitions, + /// which keep showing their tray balloon; this is purely ADDITIVE, and closes the gap where Lite was the + /// only app of the three that could not tell you your server had gone down unless you were watching the + /// tray (the Dashboard emails on the same edge, and Darling's <c>DarlingSelfAlertEvaluator</c> emails and + /// webhooks it). + /// + /// <para>The metric names — "Server Unreachable" / "Server Restored" — are the ones the shared severity + /// map and <see cref="PerformanceMonitor.Common.AlertMetricClassifier"/> already understand, so the alert + /// renders CRITICAL-red / RESOLVED-green and the history grid classifies the restore as a resolution + /// without any new naming.</para> + /// + /// <para><b>Fires once per edge.</b> The caller's <c>_previousConnectionStates</c> dictionary is the edge + /// trigger: a server that stays offline is offline→offline and does not re-enter this method, so an + /// 8-hour outage produces ONE "Server Unreachable", not one per poll. There is deliberately no cooldown + /// re-fire here — an edge is a single event.</para> + /// + /// <para>Suppression matches Lite's other alerts: a mute rule flags the row muted and skips the channels + /// (<see cref="EmailAlertService.TrySendAlertEmailAsync"/>'s own contract — the history row is still + /// written), and a silenced server delivers nothing at all, the same "Silence All Alerts covers the + /// connection edge too" rule the Dashboard applies. Never throws: the send is fire-and-forget (SMTP/HTTP + /// off a UI-timer tick) and <c>TrySendAlertEmailAsync</c> absorbs its own failures.</para> + /// </summary> + private void SendConnectionAlert(ServerConnection server, string metricName, string currentValue, string detailText) + { + try { - if (string.IsNullOrEmpty(text)) return ""; - text = text.Replace('\r', ' ').Replace('\n', ' ').Trim(); - return text.Length <= maxLength ? text : text.Substring(0, maxLength) + "..."; - } + /* The name every other Lite alert keys on: the sweep's serverName is summary.DisplayName, which + MainWindow builds from DisplayNameWithIntent — so mute rules and history rows written here + match the ones the engine writes for the same server. */ + var serverName = server.DisplayNameWithIntent; + var serverId = RemoteCollectorService.GetDeterministicHashCode( + RemoteCollectorService.GetServerNameForStorage(server)); - /* #1141: in Per-event mode, deliver one notification per distinct incident (capped at - AlertPerEventMaxPerCycle, with a trailing "+N more" that still carries the remaining - fingerprints) instead of one batched summary card. Falls back to the single summary send in - Summary mode or when there are no incidents. The existing edge-triggered gating still decides - whether to fire; this only shapes delivery. */ - private async Task SendDetectedAlertAsync( - string metricName, string serverName, string summaryCurrentValue, string thresholdValue, - int serverId, AlertContext? context, bool isMuted, string? summaryDetailText) - { - /* #1236: a per-server override (Manage Servers -> Edit) wins over the global delivery mode; - null inherits App.AlertDeliveryMode. */ - var deliveryMode = AlertDeliveryModeResolver.Resolve(ResolveServerDeliveryOverride(serverId), App.AlertDeliveryMode); - if (deliveryMode == AlertNotificationMode.PerEvent && context?.Incidents is { Count: > 0 }) + /* A silenced/acknowledged server suppresses the new channels, exactly as it suppresses the sweep's + (ShouldShowAlerts is the engine's Suppressed input). The tray balloon in the caller is left + alone — it never consulted this, and this change must not regress it. */ + if (!_alertStateService.ShouldShowAlerts(serverId.ToString())) { - foreach (var msg in PerEventNotification.Split(context, App.AlertPerEventMaxPerCycle)) - { - await _emailAlertService.TrySendAlertEmailAsync( - metricName, serverName, msg.CurrentValue, thresholdValue, serverId, - msg.Context, muted: isMuted, detailText: ContextToDetailText(msg.Context)); - } return; } - await _emailAlertService.TrySendAlertEmailAsync( - metricName, serverName, summaryCurrentValue, thresholdValue, serverId, - context, muted: isMuted, detailText: summaryDetailText); - } - - /* #1236: the per-server delivery-mode override for serverId, or null to inherit the global - App.AlertDeliveryMode. serverId is the deterministic hash of the storage name (the same - mapping the alert loop uses), so map it back to the ServerConnection to read its override. */ - private AlertNotificationMode? ResolveServerDeliveryOverride(int serverId) => - _serverManager.GetAllServers().FirstOrDefault(s => - RemoteCollectorService.GetDeterministicHashCode(RemoteCollectorService.GetServerNameForStorage(s)) == serverId) - ?.AlertDeliveryModeOverride; - - private static string? ContextToDetailText(AlertContext? context) - { - if (context == null || context.Details.Count == 0) return null; - var sb = new System.Text.StringBuilder(); - foreach (var detail in context.Details) - { - if (sb.Length > 0) sb.AppendLine(); - sb.AppendLine(detail.Heading); - foreach (var (label, value) in detail.Fields) - sb.AppendLine($" {label}: {value}"); - } - return sb.ToString().TrimEnd(); - } - - private async Task<AlertContext?> BuildBlockingContextAsync(int serverId, string serverName) - { - try - { - if (_dataService == null) return null; - - var events = await Task.Run(() => _dataService.GetRecentBlockedProcessReportsAsync(serverId, hoursBack: 1)); - if (events == null || events.Count == 0) return null; - - if (App.AlertExcludedDatabases.Count > 0) - { - events = events - .Where(e => string.IsNullOrEmpty(e.DatabaseName) || - !App.AlertExcludedDatabases.Any(ex => - string.Equals(ex, e.DatabaseName, StringComparison.OrdinalIgnoreCase))) - .ToList(); - if (events.Count == 0) return null; - } - - /* #1140/#1141: collapse samples of the same chain into one group (true occurrence count - + wait range) instead of listing it once per sample, and attach the dedup fingerprint. - Identity is the resolved contentious object (collected server-side, §5.3), falling back - to database + literal-stripped query pair only when the object did not resolve. */ - var groups = BlockingIncidentGrouper.Group( - serverName, - events.Select(e => new BlockingIncidentGrouper.BlockedEvent( - e.DatabaseName, e.ContentiousObject, e.BlockedSqlText, e.BlockingSqlText, e.WaitTimeMs, e.LockMode))); - - const int maxGroups = 10; - var shown = groups.Take(maxGroups).ToList(); - - var context = new AlertContext(); - foreach (var g in shown) - { - var item = new AlertDetailItem - { - Heading = g.OccurrenceCount > 1 ? $"Blocking chain (x{g.OccurrenceCount})" : "Blocking chain", - Fields = new() - }; - if (!string.IsNullOrEmpty(g.Database)) - item.Fields.Add(("Database", g.Database)); - if (!string.IsNullOrEmpty(g.BlockedQuery)) - item.Fields.Add(("Blocked Query", TruncateText(g.BlockedQuery))); - if (!string.IsNullOrEmpty(g.BlockingQuery)) - item.Fields.Add(("Blocking Query", TruncateText(g.BlockingQuery))); - item.Fields.Add(("Wait Range", g.Incident.WaitRange ?? g.MaxWaitMs.ToString())); - context.Details.Add(item); - } - - /* Surface the true total instead of silently dropping (gotqn's report). */ - if (groups.Count > maxGroups) - { - context.Details.Add(new AlertDetailItem - { - Heading = $"+{groups.Count - maxGroups} more distinct blocking incident(s)", - Fields = new() - }); - } - - var firstXml = events.FirstOrDefault(e => e.HasReportXml)?.BlockedProcessReportXml; - if (!string.IsNullOrEmpty(firstXml)) - { - context.AttachmentXml = firstXml; - context.AttachmentFileName = "blocked_process_report.xml"; - } - - AlertIncidentRenderer.Apply(context, shown.Select(g => g.Incident).ToList()); - - return context.Details.Count == 0 ? null : context; - } - catch (Exception ex) - { - AppLogger.Error("EmailAlert", $"Failed to fetch blocking detail for email: {ex.Message}"); - return null; - } - } - - private async Task<AlertContext?> BuildDeadlockContextAsync(int serverId, string serverName) - { - try - { - if (_dataService == null) return null; - - var deadlocks = await Task.Run(() => _dataService.GetRecentDeadlocksAsync(serverId, hoursBack: 1)); - if (deadlocks == null || deadlocks.Count == 0) return null; - - if (App.AlertExcludedDatabases.Count > 0) - { - deadlocks = deadlocks - .Where(d => !IsDeadlockExcluded(d, App.AlertExcludedDatabases)) - .ToList(); - if (deadlocks.Count == 0) return null; - } - - var context = new AlertContext(); - var firstGraph = (string?)null; - - foreach (var d in deadlocks.Take(3)) - { - var item = new AlertDetailItem - { - Heading = "Deadlock Victim", - Fields = new() - }; - - if (!string.IsNullOrEmpty(d.VictimSqlText)) - item.Fields.Add(("Victim SQL", TruncateText(d.VictimSqlText))); - if (!string.IsNullOrEmpty(d.ProcessSummary)) - item.Fields.Add(("Processes", d.ProcessSummary)); - - context.Details.Add(item); - if (firstGraph == null && d.HasDeadlockXml) - firstGraph = d.DeadlockGraphXml; - } - - if (!string.IsNullOrEmpty(firstGraph)) - { - context.AttachmentXml = firstGraph; - context.AttachmentFileName = "deadlock_graph.xml"; - } - - /* #1140: fingerprint each deadlock by its sorted involved-object set (parsed from the - graph), across ALL deadlocks in the window — not just the 3 displayed — grouped so - recurrences over the same objects collapse to one incident with a count. */ - var groups = DeadlockIncidentGrouper.Group( - serverName, - deadlocks.Select(d => new DeadlockIncidentGrouper.DeadlockEvent( - DeadlockObjectExtractor.FromGraphXml(d.DeadlockGraphXml), - DeadlockDetailFields(d.VictimSqlText, d.ProcessSummary)))); - AlertIncidentRenderer.Apply(context, groups.Select(g => g.Incident).ToList()); - - return context; - } - catch (Exception ex) - { - AppLogger.Error("EmailAlert", $"Failed to fetch deadlock detail for email: {ex.Message}"); - return null; - } - } - - /* #1141: forensic detail carried on a deadlock incident so per-event cards keep the victim SQL - + process summary (Summary mode shows them via the builder's own items). */ - private static List<AlertIncidentField>? DeadlockDetailFields(string? victimSql, string? processes) - { - var f = new List<AlertIncidentField>(); - if (!string.IsNullOrWhiteSpace(victimSql)) f.Add(new AlertIncidentField("Victim SQL", TruncateText(victimSql))); - if (!string.IsNullOrWhiteSpace(processes)) f.Add(new AlertIncidentField("Processes", processes!)); - return f.Count > 0 ? f : null; - } - - private static bool IsDeadlockExcluded(DeadlockRow row, List<string> excludedDatabases) - { - if (string.IsNullOrEmpty(row.DeadlockGraphXml)) return false; - try - { - var doc = XElement.Parse(row.DeadlockGraphXml); - var dbNames = doc.Descendants("process") - .Select(p => p.Attribute("currentdbname")?.Value) - .Where(n => !string.IsNullOrEmpty(n)) - .Cast<string>() - .ToList(); - if (dbNames.Count == 0) return false; - return dbNames.All(db => excludedDatabases.Any(e => - string.Equals(e, db, StringComparison.OrdinalIgnoreCase))); - } - catch { return false; } - } - - private static AlertContext? BuildPoisonWaitContext(List<PoisonWaitDelta> triggeredWaits) - { - if (triggeredWaits.Count == 0) return null; - - var context = new AlertContext(); - foreach (var w in triggeredWaits) - { - context.Details.Add(new AlertDetailItem - { - Heading = w.WaitType, - Fields = new() - { - ("Avg ms/wait", $"{w.AvgMsPerWait:F1}"), - ("Delta wait ms", $"{w.DeltaMs:N0}"), - ("Delta tasks", $"{w.DeltaTasks:N0}") - } - }); - } - return context; - } - - private static AlertContext? BuildLongRunningQueryContext(string serverName, List<LongRunningQueryInfo> queries) - { - if (queries.Count == 0) return null; - - var context = new AlertContext(); - var shown = queries.GetRange(0, Math.Min(3, queries.Count)); - foreach (var q in shown) + bool isMuted = _muteRuleService.IsAlertMuted(new AlertMuteContext { - var item = new AlertDetailItem - { - Heading = $"Session #{q.SessionId} — {q.ElapsedSeconds / 60}m {q.ElapsedSeconds % 60}s", - Fields = new() - }; - - if (!string.IsNullOrEmpty(q.DatabaseName)) - item.Fields.Add(("Database", q.DatabaseName)); - if (!string.IsNullOrEmpty(q.QueryText)) - item.Fields.Add(("Query", TruncateText(q.QueryText))); - item.Fields.Add(("CPU Time", $"{q.CpuTimeMs:N0} ms")); - item.Fields.Add(("Reads", $"{q.Reads:N0}")); - item.Fields.Add(("Writes", $"{q.Writes:N0}")); - if (!string.IsNullOrEmpty(q.WaitType)) - item.Fields.Add(("Wait Type", q.WaitType)); - if (q.BlockingSessionId.HasValue && q.BlockingSessionId.Value > 0) - item.Fields.Add(("Blocked By", $"Session #{q.BlockingSessionId.Value}")); - - context.Details.Add(item); - } + ServerName = serverName, + MetricName = metricName + }); - /* #1140: dedup key = query_hash (stable across literals/plans). Null hash -> no incident. */ - AlertIncidentRenderer.Apply(context, shown - .Select(q => AlertFingerprint.ForKey(serverName, AlertFingerprint.Query, q.QueryHash ?? "", - string.IsNullOrEmpty(q.DatabaseName) ? System.Array.Empty<string>() : new[] { q.DatabaseName })) - .Where(i => i is not null).Select(i => i!).ToList()); - return context; + _ = _emailAlertService.TrySendAlertEmailAsync( + metricName, + serverName, + currentValue, + "Online", + serverId, + context: null, + muted: isMuted, + detailText: detailText); } - - /* Returns the volumes whose free space is under the configured % or GB threshold (a 0 threshold - disables that dimension), worst (lowest free %) first, so the alert names the tightest volume. */ - private static List<VolumeFreeSpaceInfo> GetBreachedVolumes(List<VolumeFreeSpaceInfo> volumes) + catch (Exception ex) { - int pct = App.AlertLowDiskThresholdPercent; - int gb = App.AlertLowDiskThresholdGb; - return volumes - .Where(v => (pct > 0 && v.FreePercent < pct) || (gb > 0 && v.FreeGb < gb)) - .OrderBy(v => v.FreePercent) - .ToList(); + AppLogger.Error("ConnectionAlerts", $"Connection alert delivery failed for {metricName}: {ex.Message}"); } + } - private static string FormatLowDiskThreshold() - { - var parts = new List<string>(); - if (App.AlertLowDiskThresholdPercent > 0) parts.Add($"{App.AlertLowDiskThresholdPercent}%"); - if (App.AlertLowDiskThresholdGb > 0) parts.Add($"{App.AlertLowDiskThresholdGb} GB"); - return parts.Count > 0 ? string.Join(" / ", parts) : "—"; - } + /// <summary> + /// The engine's live-msdb failed-jobs fetcher. Failure outcomes aren't part of the collected + /// running_jobs snapshot, so this queries the monitored server directly at alert-check time via + /// the collector's connection path (async SqlClient, already off the UI thread; MFA + /// serialization / throttle / retry handled inside). The pre-forwarding gate moved here intact: + /// only online, non-Azure-SQL-DB servers whose login has msdb access are queried — Azure SQL DB + /// has no SQL Agent; a login without msdb can't read sysjobhistory — every other case degrades + /// to an empty list (per the engine's fetcher contract), which also keeps the #749 badge dark. + /// </summary> + private async Task<List<FailedJobInfo>> FetchFailedJobsForAlertAsync( + string serverKey, int lookbackMinutes, CancellationToken cancellationToken) + { + var server = _serverManager.GetAllServers().FirstOrDefault(s => + RemoteCollectorService.GetDeterministicHashCode(RemoteCollectorService.GetServerNameForStorage(s)).ToString() == serverKey); + var connStatus = server != null ? _serverManager.GetConnectionStatus(server.Id) : null; - private static AlertContext? BuildVolumeFreeSpaceContext(string serverName, List<VolumeFreeSpaceInfo> volumes) + if (server == null + || _collectorService == null + || connStatus == null + || connStatus.IsOnline != true + || connStatus.SqlEngineEdition == 5 + || !connStatus.HasMsdbAccess) { - if (volumes.Count == 0) return null; - - var context = new AlertContext(); - var shown = volumes.GetRange(0, Math.Min(5, volumes.Count)); - foreach (var v in shown) - { - context.Details.Add(new AlertDetailItem - { - Heading = $"{v.MountPoint} — {v.FreePercent:F0}% Free", - Fields = new() - { - ("Free Space", $"{v.FreeGb:F1} GB"), - ("Total Size", $"{v.TotalMb / 1024.0:F1} GB"), - ("Used", $"{(v.TotalMb - v.FreeMb) / 1024.0:F1} GB") - } - }); - } - - /* #1140: dedup key per volume (the drive/mount point). */ - AlertIncidentRenderer.Apply(context, shown - .Select(v => AlertFingerprint.ForKey(serverName, AlertFingerprint.Disk, v.MountPoint, new[] { v.MountPoint })) - .Where(i => i is not null).Select(i => i!).ToList()); - return context; + return new List<FailedJobInfo>(); } - private static AlertContext? BuildTempDbSpaceContext(TempDbSpaceInfo tempDb) - { - var context = new AlertContext(); - context.Details.Add(new AlertDetailItem - { - Heading = $"TempDB — {tempDb.UsedPercent:F0}% Used", - Fields = new() - { - ("Total Reserved", $"{tempDb.TotalReservedMb:F0} MB"), - ("Unallocated", $"{tempDb.UnallocatedMb:F0} MB"), - ("User Objects", $"{tempDb.UserObjectReservedMb:F0} MB"), - ("Internal Objects", $"{tempDb.InternalObjectReservedMb:F0} MB"), - ("Version Store", $"{tempDb.VersionStoreReservedMb:F0} MB"), - ("Top Consumer", tempDb.TopConsumerSessionId > 0 - ? $"Session #{tempDb.TopConsumerSessionId} ({tempDb.TopConsumerMb:F0} MB)" - : "None") - } - }); - return context; - } + /* GetRecentlyFailedJobsAsync degrades every read failure (permissions, transient) to an + empty list itself, so a broken msdb read can't fail the sweep. */ + return await _collectorService.GetRecentlyFailedJobsAsync(server, lookbackMinutes, cancellationToken); + } - private static AlertContext? BuildAnomalousJobContext(string serverName, List<AnomalousJobInfo> jobs) + /// <summary> + /// The engine's resolution callback: Lite's tray-only "Resolved/Cleared" toasts. The engine + /// supplies the pre-forwarding loop's exact title/message strings and already applied the + /// old gates (only on an active→inactive transition, never suppressed/disabled); every + /// resolution toast was Success severity. No history row is recorded — exactly the old loop. + /// </summary> + private Task ShowAlertResolutionToastAsync(AlertResolution resolution, CancellationToken cancellationToken) + { + var tray = _trayService; + if (tray != null) { - if (jobs.Count == 0) return null; - - var context = new AlertContext(); - var shown = jobs.GetRange(0, Math.Min(3, jobs.Count)); - foreach (var j in shown) + if (Dispatcher.CheckAccess()) { - context.Details.Add(new AlertDetailItem - { - Heading = j.JobName, - Fields = new() - { - ("Current Duration", FormatDuration(j.CurrentDurationSeconds)), - ("Avg Duration", FormatDuration(j.AvgDurationSeconds)), - ("P95 Duration", FormatDuration(j.P95DurationSeconds)), - ("% of Average", j.PercentOfAverage.HasValue ? $"{j.PercentOfAverage:F0}%" : "N/A"), - ("Started", j.StartTime.ToString("yyyy-MM-dd HH:mm:ss")) - } - }); + tray.ShowStyledNotification(resolution.Title, resolution.Message, ToastSeverity.Success); } - - /* #1140: dedup key per job (job name, scoped to the instance via serverName). */ - AlertIncidentRenderer.Apply(context, shown - .Select(j => AlertFingerprint.ForKey(serverName, AlertFingerprint.Job, j.JobName, new[] { j.JobName })) - .Where(i => i is not null).Select(i => i!).ToList()); - return context; - } - - private static AlertContext? BuildFailedJobContext(string serverName, List<FailedJobInfo> jobs) - { - if (jobs.Count == 0) return null; - - var context = new AlertContext(); - var shown = jobs.GetRange(0, Math.Min(5, jobs.Count)); - foreach (var j in shown) + else { - var item = new AlertDetailItem { Heading = j.JobName, Fields = new() }; - item.Fields.Add(("Job", j.JobName)); - item.Fields.Add(("Failed At", j.RunDateTimeFormatted)); - if (!string.IsNullOrEmpty(j.Message)) - item.Fields.Add(("Message", TruncateText(j.Message, 300))); - context.Details.Add(item); + Dispatcher.Invoke(() => tray.ShowStyledNotification(resolution.Title, resolution.Message, ToastSeverity.Success)); } - - /* #1140: dedup key per job (job name, scoped to the instance via serverName) — mirrors - BuildAnomalousJobContext so two distinct failed jobs are distinct incidents under the - #1154 per-fingerprint cooldown instead of coalescing on the metric key. */ - AlertIncidentRenderer.Apply(context, shown - .Select(j => AlertFingerprint.ForKey(serverName, AlertFingerprint.Job, j.JobName, new[] { j.JobName })) - .Where(i => i is not null).Select(i => i!).ToList()); - return context; - } - - private static string FormatDuration(long seconds) - { - if (seconds < 60) return $"{seconds}s"; - if (seconds < 3600) return $"{seconds / 60}m {seconds % 60}s"; - return $"{seconds / 3600}h {(seconds % 3600) / 60}m"; } + return Task.CompletedTask; + } } diff --git a/Lite/MainWindow.PlanViewer.cs b/Lite/MainWindow.PlanViewer.cs index d14f3b2cd..4ab35a5d8 100644 --- a/Lite/MainWindow.PlanViewer.cs +++ b/Lite/MainWindow.PlanViewer.cs @@ -7,8 +7,6 @@ */ using System; -using System.Collections.Generic; -using System.Linq; using System.Windows; using System.Windows.Controls; using System.Windows.Threading; @@ -18,36 +16,39 @@ namespace PerformanceMonitorLite; public partial class MainWindow : Window { + /* The standalone Plan Viewer's tab management (empty "New Plan" sub-tabs, open/paste/drag-drop, plan + rendering, unique labelling, teardown) is the shared PerformanceMonitor.Ui.StandalonePlanViewerController + -- ~300 lines that used to be duplicated near-verbatim between this file and the Darling viewer. This + file keeps only what is app-specific: revealing/hiding the outer ServerTabControl on open/close, and + forwarding the XAML-wired drag-over / drop / key-down handlers to the controller. Lazy (a field + initializer can't reference the instance-named MainWindowPlanTabControl). */ + private StandalonePlanViewerController? _planViewerControllerField; + private StandalonePlanViewerController PlanViewerController => + _planViewerControllerField ??= new StandalonePlanViewerController(MainWindowPlanTabControl); + private void OpenPlanViewerButton_Click(object sender, RoutedEventArgs e) { - EnsurePlanTabControlInitialized(); + PlanViewerController.EnsureInitialized(); EmptyStatePanel.Visibility = Visibility.Collapsed; ServerTabControl.Visibility = Visibility.Visible; MainWindowPlanViewerTab.Visibility = Visibility.Visible; if (MainWindowPlanViewerTab.IsSelected) { - // Already on Plan Viewer — just add a new empty sub-tab - AddNewEmptyPlanSubTab(); + // Already on Plan Viewer -- just add a new empty sub-tab + PlanViewerController.AddNewEmptyPlanSubTab(); } else { MainWindowPlanViewerTab.IsSelected = true; - AddNewEmptyPlanSubTab(); + PlanViewerController.AddNewEmptyPlanSubTab(); } Dispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(() => MainWindowPlanTabControl.Focus())); } private void MainWindowPlanViewerClose_Click(object sender, RoutedEventArgs e) { - // Each plan sub-tab's viewer is rooted by the static ThemeChanged event — - // Cleanup() each before clearing the tab control so none leak. - foreach (var item in MainWindowPlanTabControl.Items) - if (item is TabItem { Content: Grid g } && g.Children.Count > 1 - && g.Children[1] is PlanViewerControl pv) - pv.Cleanup(); - // Reset inner tab control so next open starts fresh - MainWindowPlanTabControl.Items.Clear(); - _planTabControlInitialized = false; + // Cleanup each sub-tab's viewer + reset the inner tab control (shared), then restore this app's shell. + PlanViewerController.Reset(); MainWindowPlanViewerTab.Visibility = Visibility.Collapsed; // Select first visible tab foreach (TabItem item in ServerTabControl.Items) @@ -60,366 +61,12 @@ private void MainWindowPlanViewerClose_Click(object sender, RoutedEventArgs e) } } - #region Main Window Plan Viewer - - private const string LitePlanAddTabId = "__PLAN_ADD_TAB__"; - private bool _planTabControlInitialized; - - private void EnsurePlanTabControlInitialized() - { - if (_planTabControlInitialized) return; - _planTabControlInitialized = true; - - // "+" tab at the end - var addTabHeader = new TextBlock - { - Text = "+", - FontSize = 14, - FontWeight = FontWeights.Bold, - VerticalAlignment = VerticalAlignment.Center, - ToolTip = "Open a new plan sub-tab" - }; - var addTab = new TabItem - { - Header = addTabHeader, - Tag = LitePlanAddTabId, - Content = new Grid() - }; - MainWindowPlanTabControl.Items.Add(addTab); - - MainWindowPlanTabControl.SelectionChanged += (_, _) => - { - if (MainWindowPlanTabControl.SelectedItem is TabItem { Tag: string t } && t == LitePlanAddTabId) - { - var newSub = AddNewEmptyPlanSubTab(); - MainWindowPlanTabControl.SelectedItem = newSub; - } - }; - } - - /// <summary> - /// Adds a new empty "New Plan" sub-tab to the inner plan TabControl and selects it. - /// Returns the newly created sub-tab. - /// </summary> - private TabItem AddNewEmptyPlanSubTab() - { - EnsurePlanTabControlInitialized(); + private void MainWindowPlanViewer_DragOver(object sender, DragEventArgs e) => + PlanViewerController.HandleDragOver(e); - // --- Empty state layer --- - var emptyState = new Grid(); - var dashedRect = new System.Windows.Shapes.Rectangle - { - Margin = new Thickness(24), - Stroke = (System.Windows.Media.Brush)FindResource("ForegroundMutedBrush"), - StrokeThickness = 1.5, - StrokeDashArray = new System.Windows.Media.DoubleCollection { 6, 4 }, - RadiusX = 10, RadiusY = 10, - Opacity = 0.25 - }; - var emptyStack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; - emptyStack.Children.Add(new TextBlock - { - Text = "\uE896", - FontFamily = new System.Windows.Media.FontFamily("Segoe MDL2 Assets"), - FontSize = 52, - HorizontalAlignment = HorizontalAlignment.Center, - Foreground = (System.Windows.Media.Brush)FindResource("ForegroundMutedBrush"), - Opacity = 0.45, - Margin = new Thickness(0, 0, 0, 12) - }); - emptyStack.Children.Add(new TextBlock - { - Text = "New Plan", - FontSize = 20, - FontWeight = FontWeights.Light, - Foreground = (System.Windows.Media.Brush)FindResource("ForegroundMutedBrush"), - HorizontalAlignment = HorizontalAlignment.Center - }); - emptyStack.Children.Add(new TextBlock - { - Text = "Open or paste execution plan XML to render it", - FontSize = 13, - Foreground = (System.Windows.Media.Brush)FindResource("ForegroundMutedBrush"), - HorizontalAlignment = HorizontalAlignment.Center, - Margin = new Thickness(0, 8, 0, 0) - }); - var btnPanel = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(0, 20, 0, 0) }; - var openBtn = new Button { Content = "Open .sqlplan File", Height = 28, Padding = new Thickness(12, 0, 12, 0), ToolTip = "Open a .sqlplan or .xml file from disk" }; - var pasteBtn = new Button { Content = "Paste XML", Height = 28, Padding = new Thickness(12, 0, 12, 0), Margin = new Thickness(8, 0, 0, 0), ToolTip = "Paste execution plan XML to render it (or use Ctrl+V)" }; - btnPanel.Children.Add(openBtn); - btnPanel.Children.Add(pasteBtn); - emptyStack.Children.Add(btnPanel); - emptyStack.Children.Add(new TextBlock - { - Text = "or drag & drop a .sqlplan file anywhere in this area", - FontSize = 11, - Foreground = (System.Windows.Media.Brush)FindResource("ForegroundMutedBrush"), - HorizontalAlignment = HorizontalAlignment.Center, - Margin = new Thickness(0, 12, 0, 0) - }); - emptyState.Children.Add(dashedRect); - emptyState.Children.Add(emptyStack); - - // --- Viewer layer (hidden until a plan is loaded) --- - var viewer = new PlanViewerControl - { - Visibility = Visibility.Collapsed - }; - - // Sub-tab content grid: index 0 = emptyState, index 1 = viewer - var subTabContent = new Grid(); - subTabContent.Children.Add(emptyState); - subTabContent.Children.Add(viewer); - - // --- Sub-tab header: label + close button --- - var initialLabel = GetUniqueSubTabLabel("New Plan"); - var labelBlock = new TextBlock - { - Text = initialLabel, - VerticalAlignment = VerticalAlignment.Center, - ToolTip = initialLabel - }; - var subCloseBtn = new Button { Style = (Style)FindResource("TabCloseButton") }; - var subTabHeader = new StackPanel { Orientation = Orientation.Horizontal }; - subTabHeader.Children.Add(labelBlock); - subTabHeader.Children.Add(subCloseBtn); - - var subTab = new TabItem { Header = subTabHeader, Content = subTabContent }; - - subCloseBtn.Tag = subTab; - subCloseBtn.Click += (_, _) => - { - (subTabContent.Children[1] as PlanViewerControl)?.Cleanup(); - MainWindowPlanTabControl.Items.Remove(subTab); - // If only the "+" tab remains, open a fresh empty sub-tab - if (MainWindowPlanTabControl.Items.Count == 1 && - MainWindowPlanTabControl.Items[0] is TabItem { Tag: string t2 } && t2 == LitePlanAddTabId) - { - AddNewEmptyPlanSubTab(); - } - }; - - // Wire per-sub-tab buttons - openBtn.Click += (_, _) => - { - var dialog = new Microsoft.Win32.OpenFileDialog - { - Filter = "SQL Plan Files (*.sqlplan)|*.sqlplan|XML Files (*.xml)|*.xml|All Files (*.*)|*.*", - DefaultExt = ".sqlplan", - Multiselect = true - }; - if (dialog.ShowDialog() != true) return; - var isFirst = true; - foreach (var fileName in dialog.FileNames) - { - try - { - var xml = System.IO.File.ReadAllText(fileName); - var targetTab = isFirst ? subTab : AddNewEmptyPlanSubTab(); - LoadPlanIntoSubTab(targetTab, xml, System.IO.Path.GetFileName(fileName)); - } - catch (Exception ex) - { - MessageBox.Show($"Failed to open file:\n\n{ex.Message}", "Error", - MessageBoxButton.OK, MessageBoxImage.Error); - } - isFirst = false; - } - }; - - pasteBtn.Click += (_, _) => - { - var xml = Clipboard.GetText(); - if (!string.IsNullOrWhiteSpace(xml)) - { - LoadPlanIntoSubTab(subTab, xml, "Pasted Plan"); - return; - } - MessageBox.Show("The clipboard does not contain any text.", "Paste Plan XML", - MessageBoxButton.OK, MessageBoxImage.Information); - }; - - // Insert before "+" tab - var addTabIndex = -1; - for (var i = 0; i < MainWindowPlanTabControl.Items.Count; i++) - { - if (MainWindowPlanTabControl.Items[i] is TabItem { Tag: string t3 } && t3 == LitePlanAddTabId) - { - addTabIndex = i; - break; - } - } - if (addTabIndex >= 0) - MainWindowPlanTabControl.Items.Insert(addTabIndex, subTab); - else - MainWindowPlanTabControl.Items.Add(subTab); - - MainWindowPlanTabControl.SelectedItem = subTab; - return subTab; - } - - /// <summary> - /// Loads plan XML into an existing sub-tab (replacing whatever was there before). - /// </summary> - private async void LoadPlanIntoSubTab(TabItem subTab, string planXml, string label, string? queryText = null) - { - if (subTab.Content is not Grid subTabContent) return; - if (subTabContent.Children.Count < 2) return; - - var emptyState = subTabContent.Children[0] as FrameworkElement; - var viewer = subTabContent.Children[1] as PlanViewerControl; - if (viewer == null) return; - - try - { - /* LoadPlan parses+analyzes off the UI thread; XmlException replaces the redundant - up-front XDocument.Parse validation. */ - await viewer.LoadPlan(planXml, label, queryText); - } - catch (System.Xml.XmlException ex) - { - MessageBox.Show( - $"The plan XML is not valid:\n\n{ex.Message}", - "Invalid Plan XML", - MessageBoxButton.OK, - MessageBoxImage.Warning); - return; - } - catch (Exception ex) - { - MessageBox.Show( - $"Failed to load the execution plan:\n\n{ex.Message}", - "Plan Load Error", - MessageBoxButton.OK, - MessageBoxImage.Warning); - return; - } - emptyState!.Visibility = Visibility.Collapsed; - viewer.Visibility = Visibility.Visible; - - var uniqueLabel = GetUniqueSubTabLabel(label); - if (subTab.Header is StackPanel headerPanel && - headerPanel.Children[0] is TextBlock headerLabel) - { - headerLabel.Text = uniqueLabel.Length > 30 ? uniqueLabel[..30] + "\u2026" : uniqueLabel; - headerLabel.ToolTip = uniqueLabel; - } - } - - /// <summary> - /// Returns a label that is unique among current inner plan sub-tab headers. - /// If <paramref name="baseLabel"/> is already taken, appends " (1)", " (2)", \u2026 - /// </summary> - private string GetUniqueSubTabLabel(string baseLabel) - { - var existing = new HashSet<string>(StringComparer.OrdinalIgnoreCase); - foreach (var item in MainWindowPlanTabControl.Items) - { - if (item is TabItem { Tag: string t } && t == LitePlanAddTabId) continue; - if (item is TabItem subTab && - subTab.Header is StackPanel sp && - sp.Children[0] is TextBlock tb) - existing.Add(tb.ToolTip as string ?? tb.Text); - } - if (!existing.Contains(baseLabel)) return baseLabel; - var counter = 1; - string candidate; - do { candidate = $"{baseLabel} ({counter++})"; } - while (existing.Contains(candidate)); - return candidate; - } - - /// <summary> - /// Returns the currently active real plan sub-tab (skips the "+" tab). - /// </summary> - private TabItem? GetActivePlanSubTab() - { - if (MainWindowPlanTabControl.SelectedItem is TabItem { Tag: string t } && t == LitePlanAddTabId) - return null; - return MainWindowPlanTabControl.SelectedItem as TabItem; - } - - private void MainWindowPlanViewer_DragOver(object sender, DragEventArgs e) - { - if (e.Data.GetDataPresent(DataFormats.FileDrop)) - { - var files = e.Data.GetData(DataFormats.FileDrop) as string[]; - if (files?.Any(IsPlanFile) == true) - { - e.Effects = DragDropEffects.Copy; - e.Handled = true; - return; - } - } - e.Effects = DragDropEffects.None; - e.Handled = true; - } - - private void MainWindowPlanViewer_Drop(object sender, DragEventArgs e) - { - if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return; - var planFiles = (e.Data.GetData(DataFormats.FileDrop) as string[]) - ?.Where(IsPlanFile).ToArray(); - if (planFiles == null || planFiles.Length == 0) return; - LoadMainWindowPlanFromFileIntoActiveTab(planFiles[0]); - for (var i = 1; i < planFiles.Length; i++) - { - var newTab = AddNewEmptyPlanSubTab(); - try - { - var xml = System.IO.File.ReadAllText(planFiles[i]); - LoadPlanIntoSubTab(newTab, xml, System.IO.Path.GetFileName(planFiles[i])); - } - catch (Exception ex) - { - MessageBox.Show($"Failed to load plan file:\n{ex.Message}", "Load Error", - MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - - private void MainWindowPlanViewer_KeyDown(object sender, System.Windows.Input.KeyEventArgs e) - { - if (e.Key == System.Windows.Input.Key.V && - System.Windows.Input.Keyboard.Modifiers == System.Windows.Input.ModifierKeys.Control && - e.OriginalSource is not System.Windows.Controls.TextBox) - { - var xml = Clipboard.GetText(); - if (!string.IsNullOrWhiteSpace(xml)) - { - e.Handled = true; - LoadPlanIntoActivePlanSubTab(xml, "Pasted Plan"); - } - } - } - - private void LoadMainWindowPlanFromFileIntoActiveTab(string path) - { - try - { - var xml = System.IO.File.ReadAllText(path); - LoadPlanIntoActivePlanSubTab(xml, System.IO.Path.GetFileName(path)); - } - catch (Exception ex) - { - MessageBox.Show($"Failed to load plan file:\n{ex.Message}", "Load Error", - MessageBoxButton.OK, MessageBoxImage.Error); - } - } - - private void LoadPlanIntoActivePlanSubTab(string planXml, string label) - { - var activeSubTab = GetActivePlanSubTab(); - if (activeSubTab != null) - LoadPlanIntoSubTab(activeSubTab, planXml, label); - } - - private static bool IsPlanFile(string path) - { - var ext = System.IO.Path.GetExtension(path); - return string.Equals(ext, ".sqlplan", StringComparison.OrdinalIgnoreCase) - || string.Equals(ext, ".xml", StringComparison.OrdinalIgnoreCase); - } + private void MainWindowPlanViewer_Drop(object sender, DragEventArgs e) => + PlanViewerController.HandleDrop(e); - #endregion + private void MainWindowPlanViewer_KeyDown(object sender, System.Windows.Input.KeyEventArgs e) => + PlanViewerController.HandleKeyDown(e); } diff --git a/Lite/MainWindow.xaml b/Lite/MainWindow.xaml index 29b3e3bfe..5cabe0c81 100644 --- a/Lite/MainWindow.xaml +++ b/Lite/MainWindow.xaml @@ -162,6 +162,8 @@ BorderBrush="{DynamicResource BorderBrush}" BorderThickness="0,1,0,0"> <StackPanel> <Button x:Name="AddServerButton" Content="+ Add Server" Click="AddServerButton_Click"/> + <Button x:Name="AddMultipleServersButton" Content="Add Multiple Servers…" Click="AddMultipleServersButton_Click" Margin="0,8,0,0" + ToolTip="Paste a list of servers and add them all at once with shared credentials"/> <Button x:Name="ManageServersButton" Content="Manage Servers" Click="ManageServersButton_Click" Margin="0,8,0,0"/> <Button Click="OpenPlanViewerButton_Click" Margin="0,8,0,0" ToolTip="Open or paste an execution plan (independent of any server)"> @@ -239,6 +241,17 @@ <!-- Server Tab Control (hidden until servers are connected) --> <TabControl x:Name="ServerTabControl" Visibility="Collapsed" Background="Transparent" BorderThickness="0"> <TabItem Header="Overview" x:Name="OverviewTab"> + <DockPanel LastChildFill="True"> + <StackPanel x:Name="OverviewSortToolbar" DockPanel.Dock="Top" Orientation="Horizontal" + Margin="14,10,14,0" Visibility="Collapsed"> + <TextBlock Text="Sort:" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,0,8,0"/> + <ComboBox x:Name="OverviewSortCombo" Width="120" VerticalAlignment="Center" + SelectionChanged="OverviewSortCombo_SelectionChanged"> + <ComboBoxItem Content="CPU %" Tag="Cpu"/> + <ComboBoxItem Content="Name" Tag="Name"/> + </ComboBox> + </StackPanel> <ScrollViewer VerticalScrollBarVisibility="Auto" Margin="8"> <ItemsControl x:Name="OverviewItemsControl"> <ItemsControl.ItemsPanel> @@ -293,9 +306,9 @@ <Border Background="#80000000" CornerRadius="6" Visibility="{Binding IsOffline, Converter={StaticResource BoolToVisibility}}"> <StackPanel VerticalAlignment="Center" HorizontalAlignment="Center"> - <TextBlock Text="✗" FontSize="28" Foreground="#E57373" + <TextBlock Text="✗" FontSize="28" Foreground="{DynamicResource ErrorBrush}" HorizontalAlignment="Center"/> - <TextBlock Text="Offline" FontSize="13" Foreground="#E57373" + <TextBlock Text="Offline" FontSize="13" Foreground="{DynamicResource ErrorBrush}" FontWeight="SemiBold" HorizontalAlignment="Center" Margin="0,4,0,0"/> </StackPanel> </Border> @@ -305,10 +318,14 @@ </ItemsControl.ItemTemplate> </ItemsControl> </ScrollViewer> + </DockPanel> </TabItem> <TabItem Header="Alert History" x:Name="AlertsTab"> <controls:AlertsHistoryTab x:Name="AlertsHistoryContent"/> </TabItem> + <TabItem Header="Job History" x:Name="JobHistoryTabItem"> + <controls:JobHistoryTab x:Name="JobHistoryContent"/> + </TabItem> <TabItem Header="FinOps" x:Name="FinOpsTab"> <controls:FinOpsTab x:Name="FinOpsContent"/> </TabItem> diff --git a/Lite/MainWindow.xaml.cs b/Lite/MainWindow.xaml.cs index 972300b45..007ad05ff 100644 --- a/Lite/MainWindow.xaml.cs +++ b/Lite/MainWindow.xaml.cs @@ -26,6 +26,9 @@ using PerformanceMonitorLite.Windows; using PerformanceMonitor.Common; using PerformanceMonitor.Ui; +/* Type alias (not a namespace import) so PerformanceMonitor.Alerting's CpuAlertMode enum can never + collide with this app's own CpuAlertMode. */ +using AlertEngine = PerformanceMonitor.Alerting.AlertEngine; namespace PerformanceMonitorLite; @@ -53,64 +56,30 @@ reflects all conditions. _lastBadgeCounts lets the sweep re-render with the last private readonly Dictionary<string, bool> _previousConnectionStates = new(); private readonly Dictionary<string, bool> _previousCollectorErrorStates = new(); private readonly Dictionary<string, bool> _previousXeSessionFailureStates = new(); - private readonly Dictionary<string, DateTime> _lastCpuAlert = new(); - private readonly Dictionary<string, DateTime> _lastBlockingAlert = new(); - private readonly Dictionary<string, DateTime> _lastDeadlockAlert = new(); - private readonly Dictionary<string, DateTime> _lastPoisonWaitAlert = new(); - private readonly Dictionary<string, DateTime> _lastLongRunningQueryAlert = new(); - private readonly Dictionary<string, DateTime> _lastTempDbSpaceAlert = new(); - private readonly Dictionary<string, DateTime> _lastLowDiskAlert = new(); - private readonly Dictionary<string, DateTime> _lastLongRunningJobAlert = new(); private readonly DispatcherTimer _statusTimer; private LocalDataService? _dataService; + /* Phase-5 slice B: the alert loop's collected-store reads go through this seam (see + LiteAlertReadAdapter); the shared engine (slice D) consumes the same surface. */ + private LiteAlertReadAdapter? _alertReadAdapter; + /* Phase-5 forwarding: the shared alert engine — the same evaluation code the headless Darling + service runs. It owns ALL per-check state the old loop kept in dictionaries here (cooldown + stamps, active-condition flags, #1091 edge-trigger watermarks, the #754 low-disk worsening + gate) and the #1145 restart-surviving watermark seed/persist through LiteAlertStateStore; + Lite-specific delivery (tray/badges/#1141 split) lives behind LiteAlertDeliverer. */ + private AlertEngine? _alertEngine; private McpHostService? _mcpService; private readonly AlertStateService _alertStateService = new(); private readonly IAlertSettings _alertSettings = new AppAlertSettings(); private readonly MuteRuleService _muteRuleService; private EmailAlertService _emailAlertService; - /* Held so the edge-trigger watermark seed/persist (#1145) can reach the store directly. */ + /* Held so the engine's edge-trigger watermark seed/persist (#1145, via LiteAlertStateStore) + shares one store with the webhook cooldown seeding below. */ private readonly DuckDbAlertHistoryStore _alertHistoryStore; - /* Track active alert states for resolved notifications */ - private readonly Dictionary<string, bool> _activeCpuAlert = new(); - private readonly Dictionary<string, bool> _activeBlockingAlert = new(); - private readonly Dictionary<string, bool> _activeDeadlockAlert = new(); - private readonly Dictionary<string, bool> _activePoisonWaitAlert = new(); - private readonly Dictionary<string, bool> _activeLongRunningQueryAlert = new(); - private readonly Dictionary<string, bool> _activeTempDbSpaceAlert = new(); - private readonly Dictionary<string, bool> _activeLowDiskAlert = new(); - /* Worst free-% captured at the last low-disk alert per server (#754 follow-up): see the - Dashboard counterpart. Without it a standing full volume re-fired — and re-recorded an - alert-history row, defeating Dismiss — every cooldown. Gated by LowDiskAlertGate; removed on resolve. */ - private readonly Dictionary<string, double> _lastAlertedLowDiskPercent = new(); - private readonly Dictionary<string, bool> _activeLongRunningJobAlert = new(); - private readonly Dictionary<string, DateTime> _lastFailedJobAlert = new(); - /* Watermark of the most-recent failed-job run time already alerted per server. A failed run - lingers in the lookback window for the whole window, so a plain level check would re-fire - every cooldown; we only notify when a strictly newer failure appears. Bounded by server - count, so no pruning needed. (Server-local run times mean a fall-back DST hour / NTP step - could let one new failure tie the watermark and be skipped — a once-a-year, one-hour edge.) */ - private readonly Dictionary<string, DateTime> _lastAlertedFailedJobTime = new(); - - /* Edge-trigger watermarks (#1091): the rolling 1-hour blocking/deadlock counts stay - above the threshold for the whole hour an event lingers in the window, so a plain - level check re-fires the same alert every cooldown. These hold the count at the last - fired alert; we only re-notify when the count climbs past it (a genuinely new event), - and reset to 0 when the window empties so the next event alerts again. */ - private readonly Dictionary<string, int> _lastAlertedBlockingCount = new(); - private readonly Dictionary<string, int> _lastAlertedDeadlockCount = new(); - - /* Persistence for the two watermarks above (#1145): seeded from the alert store at - startup (SeedEdgeTriggerWatermarksAsync) and upserted on change, so a restart does - not reset the watermark to 0 and re-fire / re-post a webhook for events still - lingering in the rolling 1-hour lookback window. The metric_name values are the - persisted-row keys; they need not match the alert "Detected" metric names. */ - private const string BlockingWatermarkMetric = "Blocking Detected"; - private const string DeadlockWatermarkMetric = "Deadlocks Detected"; - public MainWindow() { InitializeComponent(); + LoadOverviewSortMode(); // Initialize services (with loggers wired to AppLogger) _databaseInitializer = new DuckDbInitializer(App.DatabasePath, new AppLoggerAdapter<DuckDbInitializer>()); @@ -148,6 +117,10 @@ by both so the webhook service can seed its cooldown across restart (#1145). */ /* Auto-refresh alert history if the tab is active */ if (ServerTabControl.SelectedItem == AlertsTab) AlertsHistoryContent.RefreshAlerts(); + + /* Auto-refresh job history if the tab is active */ + if (ServerTabControl.SelectedItem == JobHistoryTabItem) + JobHistoryContent.RefreshJobs(); }; // Initialize database and UI @@ -181,13 +154,10 @@ private async void MainWindow_Loaded(object sender, RoutedEventArgs e) // Initialize the DuckDB database await _databaseInitializer.InitializeAsync(); - /* Restore edge-trigger watermarks now — after the DB (and the watermark table) - exist, but before ANY alert sweep can read the watermark dicts. RefreshServerList() - below ends in a fire-and-forget RefreshOverviewAsync() → CheckPerformanceAlerts, so - seeding here (not just before the explicit RefreshOverviewAsync later) keeps a - restart from re-firing / re-posting alerts for events still in the lookback - window (#1145), independent of whether the DuckDB reads ever yield. */ - await SeedEdgeTriggerWatermarksAsync(); + /* Edge-trigger watermark restore (#1145) now happens inside the shared AlertEngine: + it seeds each server's watermarks from LiteAlertStateStore before that server's + FIRST sweep (per-key twin of the old bulk SeedEdgeTriggerWatermarksAsync), so a + restart still cannot re-fire / re-post alerts for events lingering in the window. */ // Initialize the collection engine (with loggers wired to AppLogger) _collectorService = new RemoteCollectorService( @@ -231,6 +201,23 @@ seeding here (not just before the explicit RefreshOverviewAsync later) keeps a // Initialize data service for overview _dataService = new LocalDataService(_databaseInitializer); + _alertReadAdapter = new LiteAlertReadAdapter(_dataService); + + /* Phase-5 forwarding: construct the shared alert engine once, over Lite's five seam + implementations — live App.* thresholds, the DuckDB read adapter, the DuckDB + watermark store (#1145), the tray/email/history deliverer (#1141/#1236 split + inside), and the mute check — plus the live-msdb failed-jobs fetcher and the + tray-only resolution toast. Wired before RefreshServerList() below can trigger the + first fire-and-forget sweep. */ + _alertEngine = new AlertEngine( + new AppAlertEngineSettings(), + _alertReadAdapter, + new LiteAlertStateStore(_alertHistoryStore), + new LiteAlertDeliverer(_emailAlertService, _muteRuleService, _serverManager, () => _trayService, Dispatcher), + _muteRuleService.IsAlertMuted, + failedJobsFetcher: FetchFailedJobsForAlertAsync, + resolutionCallback: ShowAlertResolutionToastAsync, + logger: new AppLoggerAdapter<AlertEngine>()); // Load mute rules from database await _muteRuleService.LoadAsync(); @@ -240,6 +227,9 @@ seeding here (not just before the explicit RefreshOverviewAsync later) keeps a AlertsHistoryContent.MuteRuleService = _muteRuleService; AlertsHistoryContent.AlertsDismissed += OnAlertHistoryDismissed; + // Initialize job history tab + JobHistoryContent.Initialize(_dataService); + // Initialize FinOps tab FinOpsContent.Initialize(_dataService, _serverManager); @@ -400,6 +390,12 @@ private void ServerTabControl_SelectionChanged(object sender, SelectionChangedEv AlertsHistoryContent.RefreshAlerts(); } + /* Refresh job history tab when selected */ + if (ServerTabControl.SelectedItem == JobHistoryTabItem) + { + JobHistoryContent.RefreshJobs(); + } + /* Refresh recommendations tab when selected (picks up newly-collected findings) */ if (ServerTabControl.SelectedItem == RecommendationsTab) { @@ -590,6 +586,7 @@ private async Task RefreshOverviewAsync() if (_dataService == null) return; var servers = _serverManager.GetAllServers(); + OverviewSortToolbar.Visibility = servers.Count == 0 ? Visibility.Collapsed : Visibility.Visible; if (servers.Count == 0) return; try @@ -617,9 +614,12 @@ private async Task RefreshOverviewAsync() } } - OverviewItemsControl.ItemsSource = summaries; + var sorted = ServerOverviewSort.Order( + summaries, App.OverviewSortMode, + s => s.CpuPercentForAlert, s => s.DisplayName, s => s.ServerId); + OverviewItemsControl.ItemsSource = sorted; - foreach (var summary in summaries) + foreach (var summary in sorted) { CheckPerformanceAlerts(summary); } @@ -651,6 +651,44 @@ private void OverviewCard_MouseLeftButtonDown(object sender, MouseButtonEventArg } } + /// <summary>Guards <see cref="OverviewSortCombo_SelectionChanged"/> against the programmatic selection + /// <see cref="LoadOverviewSortMode"/> makes while seeding the combo at startup.</summary> + private bool _isLoadingSortMode; + + /// <summary>Selects the Overview sort-selector item whose Tag matches the persisted + /// <see cref="App.OverviewSortMode"/>; called once after InitializeComponent (mirrors the Settings + /// window's LoadColorTheme idiom). The guard suppresses the resulting SelectionChanged.</summary> + private void LoadOverviewSortMode() + { + _isLoadingSortMode = true; + foreach (ComboBoxItem item in OverviewSortCombo.Items) + { + if (item.Tag?.ToString() == ServerOverviewSort.ToToken(App.OverviewSortMode)) + { + OverviewSortCombo.SelectedItem = item; + break; + } + } + if (OverviewSortCombo.SelectedItem == null) + OverviewSortCombo.SelectedIndex = 0; + _isLoadingSortMode = false; + } + + private void OverviewSortCombo_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isLoadingSortMode) return; + if (OverviewSortCombo.SelectedItem is ComboBoxItem it && it.Tag is string tag) + { + var mode = ServerOverviewSort.ParseMode(tag); + App.OverviewSortMode = mode; + App.WriteSetting("overview sort", + root => root["overview_sort_mode"] = ServerOverviewSort.ToToken(mode)); + if (OverviewItemsControl.ItemsSource is IEnumerable<ServerSummaryItem> items) + OverviewItemsControl.ItemsSource = ServerOverviewSort.Order( + items, mode, s => s.CpuPercentForAlert, s => s.DisplayName, s => s.ServerId); + } + } + private async void ConnectToServer(ServerConnection server) { // Check if tab already open @@ -680,7 +718,8 @@ private async void ConnectToServer(ServerConnection server) } var utcOffset = status.UtcOffsetMinutes ?? 0; - var serverTab = new ServerTab(server, _databaseInitializer, _serverManager.CredentialResolver, utcOffset, status.HasMsdbAccess, status.SqlEngineEdition == 5); + var serverTab = new ServerTab(server, _databaseInitializer, _serverManager.CredentialResolver, utcOffset, status.HasMsdbAccess, status.SqlEngineEdition == 5, + isLongQueryTraceEnabled: () => _scheduleManager.GetScheduleForServer(server.Id, "long_query_completions")?.Enabled ?? false); var tabHeader = CreateTabHeader(server); var tabItem = new TabItem { @@ -729,6 +768,9 @@ private async void ConnectToServer(ServerConnection server) serverTab.AlertCountsChanged += alertHandler; serverTab.ApplyTimeRangeRequested += timeRangeHandler; serverTab.ManualRefreshRequested += refreshHandler; + /* #1319: persist the per-server view database filter (no credential side effects). The handler + captures only the long-lived _serverManager, so it needs no explicit unsubscribe. */ + serverTab.PersistServerRequested += s => _serverManager.UpdateServerSettings(s); _tabEventHandlers[server.Id] = (alertHandler, timeRangeHandler, refreshHandler); _openServerTabs[server.Id] = tabItem; @@ -998,18 +1040,8 @@ private void SilenceServer_Click(object sender, RoutedEventArgs e) { _alertStateService.SilenceServer(serverId); - /* Find and hide the badge for this server */ - if (_openServerTabs.TryGetValue(serverId, out var tab) && tab.Header is StackPanel panel) - { - foreach (var child in panel.Children) - { - if (child is System.Windows.Controls.Border border && border.Tag as string == "AlertBadge") - { - border.Visibility = Visibility.Collapsed; - break; - } - } - } + /* Hide the badge for this server (same loop as the acknowledge path). */ + HideServerBadge(serverId); } } @@ -1080,6 +1112,19 @@ private void AddServerButton_Click(object sender, RoutedEventArgs e) } } + private void AddMultipleServersButton_Click(object sender, RoutedEventArgs e) + { + var dialog = new AddMultipleServersDialog(_serverManager, _profileManager) { Owner = this }; + if (dialog.ShowDialog() == true && dialog.AddedCount > 0) + { + RefreshServerList(); + var msg = $"Added {dialog.AddedCount} server(s)"; + if (dialog.SkippedCount > 0) msg += $", skipped {dialog.SkippedCount} duplicate(s)"; + if (dialog.FailedCount > 0) msg += $", {dialog.FailedCount} failed"; + StatusText.Text = msg + "."; + } + } + private void ManageServersButton_Click(object sender, RoutedEventArgs e) { var window = new ManageServersWindow(_serverManager, _profileManager) { Owner = this }; @@ -1492,34 +1537,41 @@ private void CheckConnectionsAndNotify() bool hasErrors = healthSummary?.ErroringCollectors > 0; server.HasCollectorErrors = hasErrors; - if (_previousConnectionStates.TryGetValue(server.Id, out var wasOnline)) + /* null = never observed: a silent baseline (no edge, but a refresh). */ + bool? previouslyOnline = _previousConnectionStates.TryGetValue(server.Id, out var prev) ? prev : null; + var connectionEdge = ConnectionEdgeDetector.Detect(previouslyOnline, isOnline); + + if (App.AlertsEnabled && App.NotifyConnectionChanges) { - if (App.AlertsEnabled && App.NotifyConnectionChanges) + /* Each edge now ALSO routes through Lite's alert path — email + webhook + a + config_alert_log row (#1506) — alongside the tray balloon it always showed. The + detector is the edge trigger: a server that stays down is offline→offline, so an + outage produces ONE "Server Unreachable", not one per poll. */ + if (connectionEdge == ConnectionAlertEdge.Lost) { - if (wasOnline && !isOnline) - { - _trayService?.ShowNotification( - "Server Offline", - $"{server.DisplayNameWithIntent} is unreachable: {status.ErrorMessage ?? "unknown error"}", - Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Error); - } - else if (!wasOnline && isOnline) - { - _trayService?.ShowNotification( - "Server Online", - $"{server.DisplayNameWithIntent} is back online", - Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Info); - } - } + var reason = status.ErrorMessage ?? "unknown error"; - if (wasOnline != isOnline) + _trayService?.ShowNotification( + "Server Offline", + $"{server.DisplayNameWithIntent} is unreachable: {reason}", + Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Error); + + SendConnectionAlert(server, "Server Unreachable", reason, reason); + } + else if (connectionEdge == ConnectionAlertEdge.Restored) { - needsRefresh = true; + _trayService?.ShowNotification( + "Server Online", + $"{server.DisplayNameWithIntent} is back online", + Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Info); + + SendConnectionAlert(server, "Server Restored", "Online", "Connection restored"); } } - else + + /* Refresh on a genuine change, and on the first sighting of this server. */ + if (previouslyOnline is null || previouslyOnline != isOnline) { - /* First time seeing this server's status — need to refresh */ needsRefresh = true; } @@ -1560,43 +1612,4 @@ private void CheckConnectionsAndNotify() } } - /// <summary> - /// Seeds the in-memory edge-trigger watermarks from the persisted store (#1145) so a - /// restart does not reset them to 0 and re-fire / re-post a webhook for blocking/deadlock - /// events still lingering in the rolling 1-hour lookback window. Runs once at startup, - /// after the DB is initialized and before the first alert sweep. - /// </summary> - private async Task SeedEdgeTriggerWatermarksAsync() - { - try - { - var rows = await _alertHistoryStore.LoadEdgeTriggerWatermarksAsync(); - foreach (var (serverId, metricName, watermark) in rows) - { - var key = serverId.ToString(); - if (metricName == BlockingWatermarkMetric) - { - _lastAlertedBlockingCount[key] = watermark; - } - else if (metricName == DeadlockWatermarkMetric) - { - _lastAlertedDeadlockCount[key] = watermark; - } - } - - /* Failed-job watermark (time-based): seed so a restart does not re-fire tray toasts - for failures still inside the lookback window that the user already saw and dismissed - before the restart — the failed-job equivalent of the blocking/deadlock seed above. */ - var failedJobRows = await _alertHistoryStore.LoadFailedJobWatermarksAsync(); - foreach (var (serverId, watermark) in failedJobRows) - { - _lastAlertedFailedJobTime[serverId.ToString()] = watermark; - } - } - catch (Exception ex) - { - AppLogger.Error("Alerts", $"Failed to seed edge-trigger watermarks: {ex.Message}"); - } - } - } diff --git a/Lite/Mcp/McpAnalysisTools.cs b/Lite/Mcp/McpAnalysisTools.cs index d232c0930..303f9ccf4 100644 --- a/Lite/Mcp/McpAnalysisTools.cs +++ b/Lite/Mcp/McpAnalysisTools.cs @@ -11,18 +11,15 @@ namespace PerformanceMonitorLite.Mcp; [McpServerToolType] public sealed class McpAnalysisTools { - [McpServerTool(Name = "analyze_server"), Description("Runs the diagnostic inference engine against a server's collected data. Scores wait stats, blocking, memory, config, and other facts, then traverses a relationship graph to build evidence-backed stories about what's wrong and why. Anomaly detection compares the analysis window against 30-day time-bucketed baselines (hour-of-day x day-of-week) to identify deviations that are unusual for this specific time slot, not just unusual overall. Returns structured findings with severity scores, evidence chains, baseline context for anomalies, and recommended next tools to call.")] + [McpServerTool(Name = "analyze_server"), Description("Runs the diagnostic inference engine against a server's collected data. Scores wait stats, blocking, memory, config, and other facts, then traverses a relationship graph to build evidence-backed stories about what's wrong and why. Anomaly detection compares the analysis window against 30-day time-bucketed baselines (hour-of-day x day-of-week) to identify deviations that are unusual for this specific time slot, not just unusual overall. Returns structured findings with severity scores, evidence chains, baseline context for anomalies, and recommended next tools to call. A remediable finding also carries remediation_command: the full copy-paste T-SQL remediation (identical to the viewer card), including a two-sided risk-disclosure comment header on destructive changes; it is advisory only and never executed.")] public static async Task<string> AnalyzeServer( AnalysisService analysisService, ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of data to analyze. Default 4. Longer windows give more stable results but may miss recent spikes.")] int hours_back = 4) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; @@ -94,13 +91,22 @@ surfaced with the shared miss vocabulary so callers branch on it uniformly. */ remediation = advice.Remediation }, suggested_remediation_sql = advice?.RemediationTsql, + // The FULL copy-paste remediation command — the SAME text the viewer cards + // render — from the PERSISTED RemediationAction via the shared renderer (all + // seven shapes + the two-sided risk-disclosure comment header on the destructive + // ones). ADDITIVE alongside suggested_remediation_sql (the older 3-shape, + // drill-down-sourced advice-block SQL): this covers all seven shapes and also + // renders on get_analysis_findings, where the drill-down is gone. Null when the + // finding has no remediable action. PRODUCE ONLY — advisory text; the read-only + // MCP never executes it. + remediation_command = FactRemediation.RenderCopyPasteCommand(f.Remediation), // B3 Phase 3 (§6): two-sided risk DISCLOSURE for a destructive // remediation, read-only (Lite has no Apply path; its RCSI fields // are null/0 so the inaction side shows the weak-case baseline). destructive_risk_disclosure = advice?.Risks is null ? null : new { - risks_of_changing = System.Linq.Enumerable.ToArray(System.Linq.Enumerable.Select(advice.Risks.RisksOfChanging, r => r.Text)), - risks_of_not_changing = System.Linq.Enumerable.ToArray(System.Linq.Enumerable.Select(advice.Risks.RisksOfNotChanging, r => r.Text)) + risks_of_changing = advice.Risks.RisksOfChanging.Select(r => r.Text).ToArray(), + risks_of_not_changing = advice.Risks.RisksOfNotChanging.Select(r => r.Text).ToArray() } }; }) @@ -121,11 +127,8 @@ public static async Task<string> GetAnalysisFacts( [Description("Filter to a specific source category: waits, blocking, config, memory. Omit for all.")] string? source = null, [Description("Minimum severity to include. Default 0 (all facts). Use 0.5 to see only significant facts.")] double min_severity = 0) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; @@ -196,11 +199,8 @@ public static async Task<string> CompareAnalysis( [Description("Hours back for the comparison (recent) period. Default 4.")] int hours_back = 4, [Description("Hours back for the baseline period start, measured from now. Default 28 (yesterday same time). The baseline period will be the same duration as the comparison period.")] int baseline_hours_back = 28) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; @@ -287,11 +287,8 @@ public static async Task<string> AuditConfig( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -503,18 +500,15 @@ public static async Task<string> AuditConfig( } } - [McpServerTool(Name = "get_analysis_findings"), Description("Gets persisted findings from previous analysis runs without running a new analysis. Use this to review historical findings or check if anything has changed since the last analysis.")] + [McpServerTool(Name = "get_analysis_findings"), Description("Gets persisted findings from previous analysis runs without running a new analysis. Use this to review historical findings or check if anything has changed since the last analysis. A remediable finding carries remediation_command: the full copy-paste T-SQL remediation (identical to the viewer card), rendered from the finding's persisted action and including a two-sided risk-disclosure comment header on destructive changes; it is advisory only and never executed.")] public static async Task<string> GetAnalysisFindings( AnalysisService analysisService, ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null, [Description("Hours of finding history to retrieve. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; @@ -552,9 +546,12 @@ public static async Task<string> GetAnalysisFindings( // The prose IS value-stated: GetComposedForFinding reads the // value-bearing advice (current MAXDOP/CTFP/etc.) frozen into // StoryText at analysis time, falling back to the static block. - // suggested_remediation_sql is intentionally omitted: it - // would always be null here. The operator re-runs - // analyze_server when they need the copy-paste T-SQL. + // suggested_remediation_sql STAYS omitted here: it is built from + // the drill-down (gone on read-back), so it would always be null. + // The FULL copy-paste command below is rendered from the PERSISTED + // RemediationAction instead — which DOES survive read-back — so a + // triaging agent gets the same runnable command a human sees on the + // card without re-running analyze_server. var advice = FactAdvice.GetComposedForFinding(f); return new { @@ -582,7 +579,12 @@ public static async Task<string> GetAnalysisFindings( headline = advice.Headline, investigation = advice.Investigation, remediation = advice.Remediation - } + }, + // The SAME copy-paste remediation command the viewer cards render, from the + // persisted action via the shared renderer (all seven shapes + the two-sided + // risk-disclosure comment header on the destructive ones). Null when the finding + // has no remediable action. PRODUCE ONLY — the read-only MCP never executes it. + remediation_command = FactRemediation.RenderCopyPasteCommand(f.Remediation) }; }) }, McpHelpers.JsonOptions); @@ -606,11 +608,8 @@ public static async Task<string> MuteAnalysisFinding( int? serverId = null; if (server_name != null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; serverId = resolved.Value.ServerId; } diff --git a/Lite/Mcp/McpBlockingTools.cs b/Lite/Mcp/McpBlockingTools.cs index 86db7a95c..c4b7f5c5d 100644 --- a/Lite/Mcp/McpBlockingTools.cs +++ b/Lite/Mcp/McpBlockingTools.cs @@ -17,11 +17,8 @@ public static async Task<string> GetDeadlocks( [Description("Hours of history. Default 24.")] int hours_back = 24, [Description("Maximum rows. Default 20.")] int limit = 20) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -69,11 +66,8 @@ public static async Task<string> GetDeadlockDetail( [Description("Hours of history. Default 24.")] int hours_back = 24, [Description("Maximum deadlocks to return. Default 5.")] int limit = 5) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -111,7 +105,7 @@ public static async Task<string> GetDeadlockDetail( } } - [McpServerTool(Name = "get_blocked_process_reports"), Description("Gets detailed blocked process reports from extended events (parsed via sp_HumanEventsBlockViewer). Provides richer detail than get_blocking: isolation levels, transaction names, full query text for both blocker and blocked. Use for deep analysis of prolonged blocking.")] + [McpServerTool(Name = "get_blocked_process_reports"), Description("Gets detailed blocked process reports from extended events (parsed via sp_HumanEventsBlockViewer). Provides detailed blocked/blocking session info: isolation levels, transaction names, full query text for both sessions. Use for deep analysis of prolonged blocking.")] public static async Task<string> GetBlockedProcessReports( LocalDataService dataService, ServerManager serverManager, @@ -119,11 +113,8 @@ public static async Task<string> GetBlockedProcessReports( [Description("Hours of history. Default 24.")] int hours_back = 24, [Description("Maximum rows. Default 30.")] int limit = 30) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -197,11 +188,8 @@ public static async Task<string> GetBlockedProcessXml( [Description("Hours of history. Default 24.")] int hours_back = 24, [Description("Maximum reports to return. Default 5.")] int limit = 5) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -248,11 +236,8 @@ public static async Task<string> GetBlockingTrend( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -282,11 +267,8 @@ public static async Task<string> GetDeadlockTrend( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { diff --git a/Lite/Mcp/McpConfigHistoryTools.cs b/Lite/Mcp/McpConfigHistoryTools.cs new file mode 100644 index 000000000..ad8fcff15 --- /dev/null +++ b/Lite/Mcp/McpConfigHistoryTools.cs @@ -0,0 +1,151 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; +using PerformanceMonitorLite.Services; +using PerformanceMonitor.Common; + +namespace PerformanceMonitorLite.Mcp; + +/// <summary> +/// The configuration change-history MCP tools — get_server_config_changes, get_database_config_changes, +/// get_trace_flag_changes — served over Lite's DuckDB store. Each wraps the existing change reader, which +/// diffs the store's append-only config snapshots via the shared PerformanceMonitor.Common.ConfigChangeDiff. +/// STORED reads, no live monitored-server hit. Config is captured ON CONNECT (not on a fixed schedule), so +/// change granularity equals the connect/restart cadence and at least two snapshots are needed before a +/// change can be detected — the empty result explains this rather than silently returning nothing. +/// </summary> +[McpServerToolType] +public sealed class McpConfigHistoryTools +{ + [McpServerTool(Name = "get_server_config_changes"), Description("Gets server configuration change history by diffing sp_configure (sys.configurations) snapshots. Shows which settings changed, their old vs new configured/in-use values, whether the change requires a restart, and dynamic/advanced flags. NOTE: config is captured on server connect (not on a fixed schedule), so changes are detected between connect snapshots and need at least two.")] + public static async Task<string> GetServerConfigChanges( + LocalDataService dataService, + ServerManager serverManager, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history to retrieve. Default 168 (7 days).")] int hours_back = 168) + { + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; + + try + { + var hoursError = McpHelpers.ValidateHoursBack(hours_back); + if (hoursError != null) return hoursError; + + var rows = await dataService.GetServerConfigChangesAsync(resolved.Value.ServerId, hours_back); + if (rows.Count == 0) + return McpHelpers.Status("empty", + $"No server configuration changes detected in the last {hours_back}h. Config is captured on connect, so at least two snapshots are needed to detect a change."); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + change_count = rows.Count, + changes = rows.Select(r => new + { + change_time = r.ChangeTime.ToString("o"), + configuration_name = r.ConfigurationName, + old_value_configured = r.OldValueConfigured, + new_value_configured = r.NewValueConfigured, + old_value_in_use = r.OldValueInUse, + new_value_in_use = r.NewValueInUse, + requires_restart = r.RequiresRestartDisplay, + is_dynamic = r.DynamicDisplay, + is_advanced = r.AdvancedDisplay, + change_description = r.ChangeDescription + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_server_config_changes", ex); + } + } + + [McpServerTool(Name = "get_database_config_changes"), Description("Gets database configuration change history by diffing sys.databases snapshots. Shows which database settings changed (recovery model, RCSI, compatibility level, etc.) with old and new values; setting_name is the underlying column name. NOTE: config is captured on server connect (not on a fixed schedule), so changes are detected between connect snapshots and need at least two.")] + public static async Task<string> GetDatabaseConfigChanges( + LocalDataService dataService, + ServerManager serverManager, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history to retrieve. Default 168 (7 days).")] int hours_back = 168) + { + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; + + try + { + var hoursError = McpHelpers.ValidateHoursBack(hours_back); + if (hoursError != null) return hoursError; + + var rows = await dataService.GetDatabaseConfigChangesAsync(resolved.Value.ServerId, hours_back); + if (rows.Count == 0) + return McpHelpers.Status("empty", + $"No database configuration changes detected in the last {hours_back}h. Config is captured on connect, so at least two snapshots are needed to detect a change."); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + change_count = rows.Count, + changes = rows.Select(r => new + { + change_time = r.ChangeTime.ToString("o"), + database_name = r.DatabaseName, + setting_name = r.SettingName, + old_value = r.OldValue, + new_value = r.NewValue, + change_description = r.ChangeDescription + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_database_config_changes", ex); + } + } + + [McpServerTool(Name = "get_trace_flag_changes"), Description("Gets trace flag change history by diffing DBCC TRACESTATUS snapshots. Shows which trace flags were enabled or disabled, with scope (global/session) and the change time. NOTE: config is captured on server connect (not on a fixed schedule), so changes are detected between connect snapshots and need at least two.")] + public static async Task<string> GetTraceFlagChanges( + LocalDataService dataService, + ServerManager serverManager, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history to retrieve. Default 168 (7 days).")] int hours_back = 168) + { + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; + + try + { + var hoursError = McpHelpers.ValidateHoursBack(hours_back); + if (hoursError != null) return hoursError; + + var rows = await dataService.GetTraceFlagChangesAsync(resolved.Value.ServerId, hours_back); + if (rows.Count == 0) + return McpHelpers.Status("empty", + $"No trace flag changes detected in the last {hours_back}h. Config is captured on connect, so at least two snapshots are needed to detect a change."); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + change_count = rows.Count, + changes = rows.Select(r => new + { + change_time = r.ChangeTime.ToString("o"), + trace_flag = r.TraceFlag, + previous_status = r.PreviousStatusDisplay, + new_status = r.NewStatusDisplay, + scope = r.Scope, + is_global = r.GlobalDisplay, + is_session = r.SessionDisplay, + change_description = r.ChangeDescription + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_trace_flag_changes", ex); + } + } +} diff --git a/Lite/Mcp/McpConfigTools.cs b/Lite/Mcp/McpConfigTools.cs index 1becdaa4e..f25c0c73e 100644 --- a/Lite/Mcp/McpConfigTools.cs +++ b/Lite/Mcp/McpConfigTools.cs @@ -15,9 +15,8 @@ public static async Task<string> GetServerConfig( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -55,9 +54,8 @@ public static async Task<string> GetDatabaseConfig( [Description("Server name or display name.")] string? server_name = null, [Description("Filter to a specific database. Omit for all databases.")] string? database_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -115,9 +113,8 @@ public static async Task<string> GetDatabaseScopedConfig( [Description("Server name or display name.")] string? server_name = null, [Description("Filter to a specific database. Omit for all databases.")] string? database_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -163,9 +160,8 @@ public static async Task<string> GetTraceFlags( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { diff --git a/Lite/Mcp/McpCpuTools.cs b/Lite/Mcp/McpCpuTools.cs index f6462ec90..f5366b4a9 100644 --- a/Lite/Mcp/McpCpuTools.cs +++ b/Lite/Mcp/McpCpuTools.cs @@ -16,11 +16,8 @@ public static async Task<string> GetCpuUtilization( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 4.")] int hours_back = 4) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { diff --git a/Lite/Mcp/McpDefaultTraceTools.cs b/Lite/Mcp/McpDefaultTraceTools.cs new file mode 100644 index 000000000..f13e5b8bc --- /dev/null +++ b/Lite/Mcp/McpDefaultTraceTools.cs @@ -0,0 +1,70 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; +using PerformanceMonitorLite.Services; +using PerformanceMonitor.Common; + +namespace PerformanceMonitorLite.Mcp; + +/// <summary> +/// The Default Trace MCP tool — get_default_trace_events — served over Lite's DuckDB store. Wraps the +/// existing GetDefaultTraceEventsAsync reader, which returns the SIGNIFICANT set from the built-in Default +/// Trace (gated through the shared PerformanceMonitor.Common.DefaultTraceEventSignificance, the same gate the +/// viewer's System Events surface uses), each row tagged with its category. STORED read, no live hit. +/// Config-change events are intentionally NOT here — use get_server_config_changes / +/// get_database_config_changes / get_trace_flag_changes for those. +/// </summary> +[McpServerToolType] +public sealed class McpDefaultTraceTools +{ + [McpServerTool(Name = "get_default_trace_events"), Description("Gets significant server events captured by the built-in Default Trace (stored, read-only): data/log file auto-grow/shrink STALLS (over 1 second), severe ErrorLog writes (severity >= 16), schema DDL (object create/alter/delete), security audits (audit-change / DBCC / alter-trace), and Server Memory Change. Each event is tagged with a category. NOTE: configuration-change events are excluded here — use get_server_config_changes / get_database_config_changes / get_trace_flag_changes for those. Not available on Azure SQL Database (no default trace there).")] + public static async Task<string> GetDefaultTraceEvents( + LocalDataService dataService, + ServerManager serverManager, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, + [Description("Maximum number of events to return. Default 100.")] int limit = 100) + { + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; + + try + { + var validation = McpHelpers.ValidateHoursBack(hours_back) ?? McpHelpers.ValidateTop(limit); + if (validation != null) return validation; + + var rows = await dataService.GetDefaultTraceEventsAsync(resolved.Value.ServerId, hours_back); + if (rows.Count == 0) + return McpHelpers.Status("empty", "No significant default trace events found in the requested time range."); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + total_events = rows.Count, + shown = Math.Min(rows.Count, limit), + events = rows.Take(limit).Select(r => new + { + event_time = r.EventTimeUtc?.ToString("o"), + category = r.Category, + event_name = r.EventName, + database_name = r.DatabaseName, + object_name = r.ObjectName, + login_name = r.LoginName, + host_name = r.HostName, + application_name = r.ApplicationName, + spid = r.Spid, + duration_ms = r.DurationMs, + growth_mb = r.GrowthMb, + error_number = r.ErrorNumber, + severity = r.Severity, + text_data = McpHelpers.Truncate(r.TextData, 2000) + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_default_trace_events", ex); + } + } +} diff --git a/Lite/Mcp/McpHealthParserTools.cs b/Lite/Mcp/McpHealthParserTools.cs new file mode 100644 index 000000000..75eae0efa --- /dev/null +++ b/Lite/Mcp/McpHealthParserTools.cs @@ -0,0 +1,415 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; +using PerformanceMonitorLite.Services; +using PerformanceMonitor.Common; + +namespace PerformanceMonitorLite.Mcp; + +/// <summary> +/// The system_health parse-on-read MCP tools — get_health_parser_system_health / _severe_errors / +/// _io_issues / _scheduler_issues / _memory_conditions / _cpu_tasks / _memory_broker / _memory_node_oom — +/// served over Lite's DuckDB store. Each wraps the existing System Events reader, which shreds the raw +/// system_health event_xml with the shared PerformanceMonitor.Common.SystemHealthParser and gates it through +/// SystemHealthSignificance (the SAME significant set the viewer's System Events tab shows). System Health +/// is the one UNGATED category (its corruption/contention counter series returns every snapshot). STORED +/// reads, no live monitored-server hit; windowed on the XE event_time. Each tool caps output at limit. +/// </summary> +[McpServerToolType] +public sealed class McpHealthParserTools +{ + [McpServerTool(Name = "get_health_parser_system_health"), Description("Gets parsed system_health extended event data: overall health indicators (spinlock backoffs, sick spinlocks, latch warnings, dump requests, non-yielding tasks, SQL vs system CPU, bad pages) captured by sp_server_diagnostics.")] + public static async Task<string> GetSystemHealth( + LocalDataService dataService, + ServerManager serverManager, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, + [Description("Maximum number of entries. Default 50.")] int limit = 50) + { + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; + + try + { + var validation = McpHelpers.ValidateHoursBack(hours_back) ?? McpHelpers.ValidateTop(limit); + if (validation != null) return validation; + + var rows = await dataService.GetSystemHealthAsync(resolved.Value.ServerId, hours_back); + if (rows.Count == 0) + return McpHelpers.Status("empty", "No system health data found in the requested time range."); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + total_entries = rows.Count, + shown = Math.Min(rows.Count, limit), + entries = rows.Take(limit).Select(r => new + { + event_time = r.EventTime?.ToString("o"), + state = r.State, + spinlock_backoffs = r.SpinlockBackoffs, + sick_spinlock_type = r.SickSpinlockType, + sick_spinlock_type_after_av = r.SickSpinlockTypeAfterAv, + latch_warnings = r.LatchWarnings, + is_access_violation_occurred = r.IsAccessViolationOccurred, + write_access_violation_count = r.WriteAccessViolationCount, + total_dump_requests = r.TotalDumpRequests, + interval_dump_requests = r.IntervalDumpRequests, + non_yielding_tasks_reported = r.NonYieldingTasksReported, + page_faults = r.PageFaults, + system_cpu_utilization = r.SystemCpuUtilization, + sql_cpu_utilization = r.SqlCpuUtilization, + bad_pages_detected = r.BadPagesDetected, + bad_pages_fixed = r.BadPagesFixed + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_system_health", ex); } + } + + [McpServerTool(Name = "get_health_parser_severe_errors"), Description("Gets severe errors from system_health (severity >= 19, benign connection-reset numbers excluded): error number, severity, state, database, and message. These are critical SQL Server events (stack dumps, fatal errors).")] + public static async Task<string> GetSevereErrors( + LocalDataService dataService, + ServerManager serverManager, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, + [Description("Maximum number of entries. Default 50.")] int limit = 50) + { + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; + + try + { + var validation = McpHelpers.ValidateHoursBack(hours_back) ?? McpHelpers.ValidateTop(limit); + if (validation != null) return validation; + + var rows = await dataService.GetSevereErrorsAsync(resolved.Value.ServerId, hours_back); + if (rows.Count == 0) + return McpHelpers.Status("empty", "No severe errors found in the requested time range."); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + error_count = rows.Count, + shown = Math.Min(rows.Count, limit), + errors = rows.Take(limit).Select(r => new + { + event_time = r.EventTime?.ToString("o"), + error_number = r.ErrorNumber, + severity = r.Severity, + state = r.State, + database_id = r.DatabaseId, + database_name = r.DatabaseName, + message = r.Message + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_severe_errors", ex); } + } + + [McpServerTool(Name = "get_health_parser_io_issues"), Description("Gets I/O-related issues from system_health (IO_SUBSYSTEM component): 15-second I/O warnings, long I/O request counts, and the longest pending request duration with its file path.")] + public static async Task<string> GetIOIssues( + LocalDataService dataService, + ServerManager serverManager, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, + [Description("Maximum number of entries. Default 50.")] int limit = 50) + { + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; + + try + { + var validation = McpHelpers.ValidateHoursBack(hours_back) ?? McpHelpers.ValidateTop(limit); + if (validation != null) return validation; + + var rows = await dataService.GetIoIssuesAsync(resolved.Value.ServerId, hours_back); + if (rows.Count == 0) + return McpHelpers.Status("empty", "No I/O issues found in the requested time range."); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + issue_count = rows.Count, + shown = Math.Min(rows.Count, limit), + issues = rows.Take(limit).Select(r => new + { + event_time = r.EventTime?.ToString("o"), + state = r.State, + io_latch_timeouts = r.IoLatchTimeouts, + interval_long_ios = r.IntervalLongIos, + total_long_ios = r.TotalLongIos, + longest_pending_requests_duration_ms = r.LongestPendingRequestsDurationMs, + longest_pending_requests_file_path = r.LongestPendingRequestsFilePath + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_io_issues", ex); } + } + + [McpServerTool(Name = "get_health_parser_scheduler_issues"), Description("Gets scheduler issues from system_health: non-yielding schedulers and scheduler-monitor warnings, with the scheduler/cpu ids, online/runnable/running state, and non-yielding time.")] + public static async Task<string> GetSchedulerIssues( + LocalDataService dataService, + ServerManager serverManager, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, + [Description("Maximum number of entries. Default 50.")] int limit = 50) + { + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; + + try + { + var validation = McpHelpers.ValidateHoursBack(hours_back) ?? McpHelpers.ValidateTop(limit); + if (validation != null) return validation; + + var rows = await dataService.GetSchedulerIssuesAsync(resolved.Value.ServerId, hours_back); + if (rows.Count == 0) + return McpHelpers.Status("empty", "No scheduler issues found in the requested time range."); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + issue_count = rows.Count, + shown = Math.Min(rows.Count, limit), + issues = rows.Take(limit).Select(r => new + { + event_time = r.EventTime?.ToString("o"), + scheduler_id = r.SchedulerId, + cpu_id = r.CpuId, + status = r.Status, + is_online = r.IsOnline, + is_runnable = r.IsRunnable, + is_running = r.IsRunning, + non_yielding_time_ms = r.NonYieldingTimeMs, + thread_quantum_ms = r.ThreadQuantumMs + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_scheduler_issues", ex); } + } + + [McpServerTool(Name = "get_health_parser_memory_conditions"), Description("Gets memory condition snapshots from system_health (RESOURCE_MEMPHYSICAL_LOW): low-memory notifications, out-of-memory exceptions, and the memory-manager report (available physical/virtual/paging memory, working set, VM reserved/committed, pages, and the physical/virtual memory-low flags).")] + public static async Task<string> GetMemoryConditions( + LocalDataService dataService, + ServerManager serverManager, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, + [Description("Maximum number of entries. Default 50.")] int limit = 50) + { + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; + + try + { + var validation = McpHelpers.ValidateHoursBack(hours_back) ?? McpHelpers.ValidateTop(limit); + if (validation != null) return validation; + + var rows = await dataService.GetMemoryConditionsAsync(resolved.Value.ServerId, hours_back); + if (rows.Count == 0) + return McpHelpers.Status("empty", "No memory condition events found in the requested time range."); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + event_count = rows.Count, + shown = Math.Min(rows.Count, limit), + events = rows.Take(limit).Select(r => new + { + event_time = r.EventTime?.ToString("o"), + last_notification = r.LastNotification, + out_of_memory_exceptions = r.OutOfMemoryExceptions, + is_any_pool_out_of_memory = r.IsAnyPoolOutOfMemory, + process_out_of_memory_period = r.ProcessOutOfMemoryPeriod, + name = r.Name, + available_physical_memory_gb = r.AvailablePhysicalMemoryGb, + available_virtual_memory_gb = r.AvailableVirtualMemoryGb, + available_paging_file_gb = r.AvailablePagingFileGb, + working_set_gb = r.WorkingSetGb, + percent_of_committed_memory_in_ws = r.PercentOfCommittedMemoryInWs, + page_faults = r.PageFaults, + system_physical_memory_high = r.SystemPhysicalMemoryHigh, + system_physical_memory_low = r.SystemPhysicalMemoryLow, + process_physical_memory_low = r.ProcessPhysicalMemoryLow, + process_virtual_memory_low = r.ProcessVirtualMemoryLow, + vm_reserved_gb = r.VmReservedGb, + vm_committed_gb = r.VmCommittedGb, + locked_pages_allocated = r.LockedPagesAllocated, + large_pages_allocated = r.LargePagesAllocated, + emergency_memory_gb = r.EmergencyMemoryGb, + emergency_memory_in_use_gb = r.EmergencyMemoryInUseGb, + target_committed_gb = r.TargetCommittedGb, + current_committed_gb = r.CurrentCommittedGb, + pages_allocated = r.PagesAllocated, + pages_reserved = r.PagesReserved, + pages_free = r.PagesFree, + pages_in_use = r.PagesInUse, + page_alloc_potential = r.PageAllocPotential, + numa_growth_phase = r.NumaGrowthPhase, + last_oom_factor = r.LastOomFactor, + last_os_error = r.LastOsError + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_memory_conditions", ex); } + } + + [McpServerTool(Name = "get_health_parser_cpu_tasks"), Description("Gets CPU task events from system_health (QUERY_PROCESSING component): worker thread counts (max/created/idle), tasks completed within the interval, pending tasks and oldest pending task wait time, plus deadlock/blocking flags.")] + public static async Task<string> GetCPUTasks( + LocalDataService dataService, + ServerManager serverManager, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, + [Description("Maximum number of entries. Default 50.")] int limit = 50) + { + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; + + try + { + var validation = McpHelpers.ValidateHoursBack(hours_back) ?? McpHelpers.ValidateTop(limit); + if (validation != null) return validation; + + var rows = await dataService.GetCpuTasksAsync(resolved.Value.ServerId, hours_back); + if (rows.Count == 0) + return McpHelpers.Status("empty", "No CPU task events found in the requested time range."); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + event_count = rows.Count, + shown = Math.Min(rows.Count, limit), + events = rows.Take(limit).Select(r => new + { + event_time = r.EventTime?.ToString("o"), + state = r.State, + max_workers = r.MaxWorkers, + workers_created = r.WorkersCreated, + workers_idle = r.WorkersIdle, + tasks_completed_within_interval = r.TasksCompletedWithinInterval, + pending_tasks = r.PendingTasks, + oldest_pending_task_waiting_time = r.OldestPendingTaskWaitingTime, + has_unresolvable_deadlock_occurred = r.HasUnresolvableDeadlockOccurred, + has_deadlocked_schedulers_occurred = r.HasDeadlockedSchedulersOccurred, + did_blocking_occur = r.DidBlockingOccur + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_cpu_tasks", ex); } + } + + [McpServerTool(Name = "get_health_parser_memory_broker"), Description("Gets memory broker events from system_health: broker ratio changes and target adjustments (currently predicated / allocated / previously allocated), the broker name, and the notification (RESOURCE_MEMPHYSICAL_HIGH/LOW).")] + public static async Task<string> GetMemoryBroker( + LocalDataService dataService, + ServerManager serverManager, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, + [Description("Maximum number of entries. Default 50.")] int limit = 50) + { + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; + + try + { + var validation = McpHelpers.ValidateHoursBack(hours_back) ?? McpHelpers.ValidateTop(limit); + if (validation != null) return validation; + + var rows = await dataService.GetMemoryBrokerAsync(resolved.Value.ServerId, hours_back); + if (rows.Count == 0) + return McpHelpers.Status("empty", "No memory broker events found in the requested time range."); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + event_count = rows.Count, + shown = Math.Min(rows.Count, limit), + events = rows.Take(limit).Select(r => new + { + event_time = r.EventTime?.ToString("o"), + broker_id = r.BrokerId, + pool_metadata_id = r.PoolMetadataId, + delta_time = r.DeltaTime, + memory_ratio = r.MemoryRatio, + new_target = r.NewTarget, + overall = r.Overall, + rate = r.Rate, + currently_predicated = r.CurrentlyPredicated, + currently_allocated = r.CurrentlyAllocated, + previously_allocated = r.PreviouslyAllocated, + broker = r.Broker, + notification = r.Notification + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_memory_broker", ex); } + } + + [McpServerTool(Name = "get_health_parser_memory_node_oom"), Description("Gets memory node OOM events from system_health: out-of-memory conditions on specific NUMA nodes, with the node's physical/virtual/page-file memory, target/reserved/committed KB, the failure type, and the memory-low flags. Never gated — every recorded OOM is returned.")] + public static async Task<string> GetMemoryNodeOOM( + LocalDataService dataService, + ServerManager serverManager, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, + [Description("Maximum number of entries. Default 50.")] int limit = 50) + { + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; + + try + { + var validation = McpHelpers.ValidateHoursBack(hours_back) ?? McpHelpers.ValidateTop(limit); + if (validation != null) return validation; + + var rows = await dataService.GetMemoryNodeOomAsync(resolved.Value.ServerId, hours_back); + if (rows.Count == 0) + return McpHelpers.Status("empty", "No memory node OOM events found in the requested time range."); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + event_count = rows.Count, + shown = Math.Min(rows.Count, limit), + events = rows.Take(limit).Select(r => new + { + event_time = r.EventTime?.ToString("o"), + node_id = r.NodeId, + memory_node_id = r.MemoryNodeId, + memory_utilization_pct = r.MemoryUtilizationPct, + total_physical_memory_kb = r.TotalPhysicalMemoryKb, + available_physical_memory_kb = r.AvailablePhysicalMemoryKb, + total_page_file_kb = r.TotalPageFileKb, + available_page_file_kb = r.AvailablePageFileKb, + total_virtual_address_space_kb = r.TotalVirtualAddressSpaceKb, + available_virtual_address_space_kb = r.AvailableVirtualAddressSpaceKb, + target_kb = r.TargetKb, + reserved_kb = r.ReservedKb, + committed_kb = r.CommittedKb, + shared_committed_kb = r.SharedCommittedKb, + awe_kb = r.AweKb, + pages_kb = r.PagesKb, + failure_type = r.FailureType, + failure_value = r.FailureValue, + resources = r.Resources, + factor_text = r.FactorText, + factor_value = r.FactorValue, + last_error = r.LastError, + pool_metadata_id = r.PoolMetadataId, + is_process_in_job = r.IsProcessInJob, + is_system_physical_memory_high = r.IsSystemPhysicalMemoryHigh, + is_system_physical_memory_low = r.IsSystemPhysicalMemoryLow, + is_process_physical_memory_low = r.IsProcessPhysicalMemoryLow, + is_process_virtual_memory_low = r.IsProcessVirtualMemoryLow + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_memory_node_oom", ex); } + } +} diff --git a/Lite/Mcp/McpHealthTools.cs b/Lite/Mcp/McpHealthTools.cs index 0283a2392..0eb2d0e5c 100644 --- a/Lite/Mcp/McpHealthTools.cs +++ b/Lite/Mcp/McpHealthTools.cs @@ -15,11 +15,8 @@ public static async Task<string> GetServerSummary( ServerManager serverManager, [Description("Server name or display name. Optional if only one server is configured.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -47,17 +44,69 @@ public static async Task<string> GetServerSummary( } } + [McpServerTool(Name = "get_daily_summary"), Description("Gets a daily health summary: overall composite health band (Healthy/Warning/Critical), total wait time, top wait type, unique query count, deadlocks, blocking events, memory pressure (and severe memory pressure), high-CPU samples, collection errors, and actionable alert count for one day. Use this for a quick overview to decide which areas need investigation.")] + public static async Task<string> GetDailySummary( + LocalDataService dataService, + ServerManager serverManager, + [Description("Server name or display name.")] string? server_name = null, + [Description("Summary date (yyyy-MM-dd), interpreted as a UTC day. Default is today.")] string? summary_date = null) + { + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; + + DateTime? date = null; + if (!string.IsNullOrEmpty(summary_date)) + { + if (!DateTime.TryParse(summary_date, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var parsed)) + return $"Invalid date format '{summary_date}'. Use yyyy-MM-dd format (e.g., 2026-07-09)."; + date = parsed; + } + + try + { + var row = await dataService.GetDailySummaryAsync(resolved.Value.ServerId, date); + if (row == null || !row.HasData) + { + var missDate = row?.SummaryDate ?? date ?? DateTime.UtcNow.Date; + return McpHelpers.Status( + "empty", + $"No data collected for {resolved.Value.ServerName} on {missDate:yyyy-MM-dd}.", + new { summary_date = missDate.ToString("yyyy-MM-dd"), overall_health = row?.OverallHealth }); + } + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + summary_date = row.SummaryDate.ToString("yyyy-MM-dd"), + overall_health = row.OverallHealth, + health_band = row.HealthBand.ToString(), + total_wait_time_sec = row.TotalWaitTimeSec, + top_wait_type = row.TopWaitType, + unique_queries = row.UniqueQueries, + deadlock_count = row.DeadlockCount, + blocking_events = row.BlockingEvents, + high_cpu_events = row.HighCpuEvents, + memory_pressure_events = row.MemoryPressureEvents, + memory_critical_events = row.MemoryCriticalEvents, + collection_errors = row.CollectionErrors, + alert_count = row.AlertCount, + max_block_duration_ms = row.MaxBlockDurationMs + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_daily_summary", ex); + } + } + [McpServerTool(Name = "get_collection_health"), Description("Shows the health status of all data collectors for a server — whether they're running successfully, failing, or stale. Check this before investigating data to ensure collectors are working properly.")] public static async Task<string> GetCollectionHealth( LocalDataService dataService, ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { diff --git a/Lite/Mcp/McpHostService.cs b/Lite/Mcp/McpHostService.cs index ecce36c30..e0ef59753 100644 --- a/Lite/Mcp/McpHostService.cs +++ b/Lite/Mcp/McpHostService.cs @@ -78,6 +78,7 @@ the subset Gemini/Antigravity accepts — collapsing nullable type unions and .WithGeminiCompatibleTools<McpHealthTools>() .WithGeminiCompatibleTools<McpWaitTools>() .WithGeminiCompatibleTools<McpBlockingTools>() + .WithGeminiCompatibleTools<McpLongQueryTools>() .WithGeminiCompatibleTools<McpQueryTools>() .WithGeminiCompatibleTools<McpCpuTools>() .WithGeminiCompatibleTools<McpMemoryTools>() @@ -91,6 +92,11 @@ the subset Gemini/Antigravity accepts — collapsing nullable type unions and .WithGeminiCompatibleTools<McpServerInfoTools>() .WithGeminiCompatibleTools<McpSessionTools>() .WithGeminiCompatibleTools<McpObjectStatsTools>() + .WithGeminiCompatibleTools<McpLatchSpinlockTools>() + .WithGeminiCompatibleTools<McpPlanCacheSchedulerTools>() + .WithGeminiCompatibleTools<McpConfigHistoryTools>() + .WithGeminiCompatibleTools<McpDefaultTraceTools>() + .WithGeminiCompatibleTools<McpHealthParserTools>() .WithGeminiCompatibleTools<McpAnalysisTools>(); _app = builder.Build(); diff --git a/Lite/Mcp/McpInstructions.cs b/Lite/Mcp/McpInstructions.cs index e67a5845b..1c346b84d 100644 --- a/Lite/Mcp/McpInstructions.cs +++ b/Lite/Mcp/McpInstructions.cs @@ -38,6 +38,7 @@ You are connected to a SQL Server performance monitoring tool via Performance Mo | `list_servers` | Lists all monitored SQL Server instances with status and last collection time | none | | `get_collection_health` | Shows collector health: running, failing, or stale | `server_name` | | `get_server_summary` | Quick health overview: CPU %, memory, blocking/deadlock counts | `server_name` | + | `get_daily_summary` | Daily composite health band + wait/query/deadlock/blocking/CPU/memory/alert rollup for one day | `server_name`, `summary_date` (yyyy-MM-dd, default today) | ### Wait Statistics Tools | Tool | Purpose | Key Parameters | @@ -51,6 +52,18 @@ You are connected to a SQL Server performance monitoring tool via Performance Mo | Tool | Purpose | Key Parameters | |------|---------|----------------| | `get_cpu_utilization` | SQL Server CPU vs other process CPU over time | `server_name`, `hours_back` (default 4) | + | `get_cpu_scheduler_pressure` | Latest scheduler snapshot: runnable queue depth, worker-thread utilization, queued/blocked requests, pressure warnings | `server_name`, `hours_back` (default 24) | + + ### Contention Tools + | Tool | Purpose | Key Parameters | + |------|---------|----------------| + | `get_latch_stats` | Latest latch-contention snapshot by class (waits + last-interval delta waits) | `server_name`, `hours_back` (default 24) | + | `get_spinlock_stats` | Latest spinlock-contention snapshot (collisions, spins, backoffs) | `server_name`, `hours_back` (default 24) | + + ### Plan Cache Tools + | Tool | Purpose | Key Parameters | + |------|---------|----------------| + | `get_plan_cache_bloat` | Single-use vs multi-use plan composition per cache/object type, with bloat-level classification | `server_name`, `hours_back` (default 24) | ### Query Performance Tools | Tool | Purpose | Key Parameters | @@ -68,6 +81,7 @@ You are connected to a SQL Server performance monitoring tool via Performance Mo | `get_deadlock_detail` | Full deadlock graph XML for deep analysis | `server_name`, `hours_back`, `limit` | | `get_blocked_process_reports` | Parsed blocking from sp_HumanEventsBlockViewer (extended events) | `server_name`, `hours_back`, `limit` | | `get_blocked_process_xml` | Raw blocked process report XML | `server_name`, `hours_back`, `limit` | + | `get_long_query_completions` | Longest completed queries (rpc/batch over the trace threshold) + attentions/cancels from the opt-in long-query trace, duration DESC (empty until the collector is enabled) | `server_name`, `hours_back`, `limit` | | `get_blocking_trend` | Time-series of blocking event counts | `server_name`, `hours_back` | | `get_deadlock_trend` | Time-series of deadlock event counts | `server_name`, `hours_back` | @@ -78,6 +92,7 @@ You are connected to a SQL Server performance monitoring tool via Performance Mo | `get_memory_trend` | Memory usage over time | `server_name`, `hours_back` | | `get_memory_clerks` | Top memory consumers by clerk type | `server_name` | | `get_memory_grants` | Active/recent memory grants (detect grant pressure) | `server_name`, `hours_back` (default 1), `limit` | + | `get_resource_semaphore` | Latest resource-semaphore snapshot: workspace memory vs target/max ceiling, waiter/timeout/forced-grant pressure | `server_name`, `hours_back` (default 24) | | `get_memory_pressure_events` | Ring buffer memory pressure notifications (sp_pressuredetector source) | `server_name`, `hours_back` | ### I/O Tools @@ -109,6 +124,7 @@ You are connected to a SQL Server performance monitoring tool via Performance Mo |------|---------|----------------| | `get_alert_history` | Recent alert history: what fired, when, email status | `hours_back` (default 24), `limit` (default 50) | | `get_alert_settings` | Current alert thresholds and SMTP configuration | none | + | `get_mute_rules` | Configured mute rules that suppress specific recurring alerts (still logged) | `enabled_only` (default true) | ### Job Tools | Tool | Purpose | Key Parameters | @@ -122,6 +138,22 @@ You are connected to a SQL Server performance monitoring tool via Performance Mo | `get_database_config` | Database-level settings: RCSI, recovery model, auto-shrink, Query Store, etc. | `server_name`, `database_name` | | `get_database_scoped_config` | Database-scoped configuration (MAXDOP, legacy CE, parameter sniffing) | `server_name`, `database_name` | | `get_trace_flags` | Active trace flags with global/session scope | `server_name` | + | `get_server_config_changes` | sp_configure change history (diff of on-connect snapshots) | `server_name`, `hours_back` (default 168) | + | `get_database_config_changes` | sys.databases change history (recovery model, RCSI, compat level, etc.) | `server_name`, `hours_back` (default 168) | + | `get_trace_flag_changes` | Trace flag enable/disable history (diff of on-connect snapshots) | `server_name`, `hours_back` (default 168) | + + ### System Health & Default Trace Tools + | Tool | Purpose | Key Parameters | + |------|---------|----------------| + | `get_default_trace_events` | Significant Default Trace events: file auto-grow/shrink stalls, severe ErrorLog writes, schema DDL, security audits | `server_name`, `hours_back` (default 24), `limit` (default 100) | + | `get_health_parser_system_health` | Parsed sp_server_diagnostics health counters (spinlocks, latch warnings, dumps, CPU, bad pages) | `server_name`, `hours_back`, `limit` | + | `get_health_parser_severe_errors` | Severe errors (severity >= 19) from system_health | `server_name`, `hours_back`, `limit` | + | `get_health_parser_io_issues` | I/O warnings from system_health (15-second I/O, long/pending I/O) | `server_name`, `hours_back`, `limit` | + | `get_health_parser_scheduler_issues` | Non-yielding schedulers and scheduler-monitor warnings | `server_name`, `hours_back`, `limit` | + | `get_health_parser_memory_conditions` | Low-memory snapshots (RESOURCE_MEMPHYSICAL_LOW) with the memory-manager report | `server_name`, `hours_back`, `limit` | + | `get_health_parser_cpu_tasks` | CPU task/worker-thread snapshots (QUERY_PROCESSING) with deadlock/blocking flags | `server_name`, `hours_back`, `limit` | + | `get_health_parser_memory_broker` | Memory broker ratio changes and target adjustments | `server_name`, `hours_back`, `limit` | + | `get_health_parser_memory_node_oom` | Per-NUMA-node out-of-memory events | `server_name`, `hours_back`, `limit` | ### Server Information Tools | Tool | Purpose | Key Parameters | @@ -157,11 +189,11 @@ You are connected to a SQL Server performance monitoring tool via Performance Mo ### Diagnostic Analysis Tools | Tool | Purpose | Key Parameters | |------|---------|----------------| - | `analyze_server` | Runs the inference engine: scores facts, traverses relationship graph, returns evidence-backed findings with severity and recommended next tools | `server_name`, `hours_back` (default 4) | + | `analyze_server` | Runs the inference engine: scores facts, traverses relationship graph, returns evidence-backed findings with severity and recommended next tools. A remediable finding also carries `remediation_command` — the full copy-paste T-SQL remediation (identical to the viewer card), with a two-sided risk-disclosure header on destructive changes; advisory only, never executed | `server_name`, `hours_back` (default 4) | | `get_analysis_facts` | Exposes raw scored facts from the collect+score pipeline — every observation the engine sees with base severity, amplifiers, and metadata | `server_name`, `hours_back` (default 4), `source` (filter), `min_severity` | | `compare_analysis` | Compares two time periods (e.g., peak vs off-peak, before vs after a change) showing severity deltas for each fact | `server_name`, `hours_back` (default 4), `baseline_hours_back` (default 28) | | `audit_config` | Edition-aware configuration audit: evaluates CTFP, MAXDOP, max memory, and max worker threads against best practices | `server_name` | - | `get_analysis_findings` | Retrieves persisted findings from previous analysis runs | `server_name`, `hours_back` (default 24) | + | `get_analysis_findings` | Retrieves persisted findings from previous analysis runs; each remediable finding carries `remediation_command` — the full copy-paste T-SQL remediation (identical to the viewer card), rendered from the persisted action, advisory only and never executed | `server_name`, `hours_back` (default 24) | | `mute_analysis_finding` | Mutes a finding pattern by story_path_hash so it won't appear in future runs | `story_path_hash` (required), `server_name`, `reason` | ## Recommended Workflow @@ -193,16 +225,15 @@ You are connected to a SQL Server performance monitoring tool via Performance Mo | `CXPACKET` / `CXCONSUMER` | Parallelism | `get_top_queries_by_cpu` with `parallel_only=true` | | `PAGEIOLATCH_*` | Disk I/O | `get_file_io_stats`, `get_file_io_trend` | | `WRITELOG` | Transaction log I/O | `get_file_io_stats` (check log file latency) | - | `LCK_M_*` | Lock contention | `get_blocking`, `get_blocked_process_reports` | + | `LCK_M_*` | Lock contention | `get_blocked_process_reports` | | `RESOURCE_SEMAPHORE` | Memory grant pressure | `get_memory_grants` | | `LATCH_*` | Internal contention | `get_tempdb_trend` | - ## Blocking vs Blocked Process Reports + ## Blocked Process Reports - - **`get_blocking`**: Captures blocking chains from `sys.dm_exec_requests` at each collection snapshot. Shows who is blocking whom. - - **`get_blocked_process_reports`**: Captures events from SQL Server's Blocked Process Report extended event (via sp_HumanEventsBlockViewer). Fires when a session has been blocked longer than the configured threshold. Includes richer detail: isolation levels, transaction names, full query text for both blocker and blocked. + - **`get_blocked_process_reports`**: Captures events from SQL Server's Blocked Process Report extended event (via sp_HumanEventsBlockViewer). Fires when a session has been blocked longer than the configured threshold. Includes rich detail: isolation levels, transaction names, and full query text for both the blocker and the blocked session. - **Use `get_blocking` first** for a quick overview. **Use `get_blocked_process_reports`** when you need detailed analysis of prolonged blocking events. + Use it for detailed analysis of prolonged blocking events; pair it with `get_blocking_trend` to see whether blocking frequency is new, worsening, or resolved. ## Interpreting Memory Pressure Events diff --git a/Lite/Mcp/McpIoTools.cs b/Lite/Mcp/McpIoTools.cs index 95606f2cd..94fca1b32 100644 --- a/Lite/Mcp/McpIoTools.cs +++ b/Lite/Mcp/McpIoTools.cs @@ -15,11 +15,8 @@ public static async Task<string> GetFileIoStats( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -65,11 +62,8 @@ public static async Task<string> GetFileIoTrend( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { diff --git a/Lite/Mcp/McpJobTools.cs b/Lite/Mcp/McpJobTools.cs index 208f59cac..e96f98eb1 100644 --- a/Lite/Mcp/McpJobTools.cs +++ b/Lite/Mcp/McpJobTools.cs @@ -15,11 +15,8 @@ public static async Task<string> GetRunningJobs( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { diff --git a/Lite/Mcp/McpLatchSpinlockTools.cs b/Lite/Mcp/McpLatchSpinlockTools.cs new file mode 100644 index 000000000..1c74da74c --- /dev/null +++ b/Lite/Mcp/McpLatchSpinlockTools.cs @@ -0,0 +1,104 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; +using PerformanceMonitorLite.Services; +using PerformanceMonitor.Common; + +namespace PerformanceMonitorLite.Mcp; + +/// <summary> +/// The latch / spinlock contention MCP tools — get_latch_stats, get_spinlock_stats — served over Lite's +/// DuckDB store. Each wraps the existing latest-snapshot reader (GetLatchStatsSnapshotAsync / +/// GetSpinlockStatsSnapshotAsync): the per-class / per-spinlock cumulative counters plus the last +/// collection interval's delta at the most recent collection in the window. STORED reads, no live hit. +/// </summary> +[McpServerToolType] +public sealed class McpLatchSpinlockTools +{ + [McpServerTool(Name = "get_latch_stats"), Description("Gets the latest latch-contention snapshot by latch class: cumulative waiting requests and wait time (with the max single wait) plus the last collection interval's delta waits. High LATCH_EX on ACCESS_METHODS_DATASET_PARENT or a page-latch class indicates allocation/page contention (often TempDB).")] + public static async Task<string> GetLatchStats( + LocalDataService dataService, + ServerManager serverManager, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history to search for the latest snapshot. Default 24.")] int hours_back = 24) + { + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; + + try + { + var hoursError = McpHelpers.ValidateHoursBack(hours_back); + if (hoursError != null) return hoursError; + + var rows = await dataService.GetLatchStatsSnapshotAsync(resolved.Value.ServerId, hours_back); + if (rows.Count == 0) + return McpHelpers.Status("unavailable", "No latch statistics available in the requested time range."); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + latch_count = rows.Count, + latches = rows.Select(r => new + { + latch_class = r.LatchClass, + waiting_requests_count = r.WaitingRequestsCount, + wait_time_ms = r.WaitTimeMs, + max_wait_time_ms = r.MaxWaitTimeMs, + delta_waiting_requests_count = r.DeltaWaitingRequestsCount, + delta_wait_time_ms = r.DeltaWaitTimeMs, + avg_wait_ms_per_request = r.DeltaWaitingRequestsCount > 0 + ? Math.Round((double)r.DeltaWaitTimeMs / r.DeltaWaitingRequestsCount, 2) + : (double?)null + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_latch_stats", ex); + } + } + + [McpServerTool(Name = "get_spinlock_stats"), Description("Gets the latest spinlock-contention snapshot: cumulative collisions, spins, backoffs and spins-per-collision plus the last collection interval's delta collisions/spins. High spinlock contention is CPU-bound internal contention that does not appear in wait stats.")] + public static async Task<string> GetSpinlockStats( + LocalDataService dataService, + ServerManager serverManager, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history to search for the latest snapshot. Default 24.")] int hours_back = 24) + { + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; + + try + { + var hoursError = McpHelpers.ValidateHoursBack(hours_back); + if (hoursError != null) return hoursError; + + var rows = await dataService.GetSpinlockStatsSnapshotAsync(resolved.Value.ServerId, hours_back); + if (rows.Count == 0) + return McpHelpers.Status("unavailable", "No spinlock statistics available in the requested time range."); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + spinlock_count = rows.Count, + spinlocks = rows.Select(r => new + { + spinlock_name = r.SpinlockName, + collisions = r.Collisions, + spins = r.Spins, + spins_per_collision = Math.Round(r.SpinsPerCollision, 1), + sleep_time = r.SleepTime, + backoffs = r.Backoffs, + delta_collisions = r.DeltaCollisions, + delta_spins = r.DeltaSpins + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_spinlock_stats", ex); + } + } +} diff --git a/Lite/Mcp/McpLongQueryTools.cs b/Lite/Mcp/McpLongQueryTools.cs new file mode 100644 index 000000000..7d5cf08df --- /dev/null +++ b/Lite/Mcp/McpLongQueryTools.cs @@ -0,0 +1,82 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; +using PerformanceMonitorLite.Services; +using PerformanceMonitor.Common; + +namespace PerformanceMonitorLite.Mcp; + +/// <summary> +/// The long-query completion MCP tool (#1496) — get_long_query_completions — over Lite's DuckDB store. The +/// Lite twin of Darling's <see cref="PerformanceMonitor.Darling.Service.Mcp.DarlingMcpLongQueryTools"/>, +/// field-for-field, so the cross-app MCP inventory stays in parity. Returns the longest completed queries +/// (rpc/batch over the trace's duration threshold) plus attentions (cancels/timeouts) from the opt-in +/// PerformanceMonitor_LongQueryCompletions XE session, ordered by duration DESC. +/// </summary> +[McpServerToolType] +public sealed class McpLongQueryTools +{ + [McpServerTool(Name = "get_long_query_completions"), Description("Gets long-running query completions captured by the opt-in long-query trace: rpc/batch completions whose duration exceeded the trace threshold, plus attentions (client cancels / query timeouts). Shows duration, CPU, reads/writes, row count, result (OK/Error/Abort — Abort means the long query was cancelled), the statement text, and the calling session/app/login. The collector is OFF by default; if it returns empty, enable the 'long_query_completions' collector in the collector schedule.")] + public static async Task<string> GetLongQueryCompletions( + LocalDataService dataService, + ServerManager serverManager, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history. Default 24.")] int hours_back = 24, + [Description("Maximum rows. Default 30.")] int limit = 30) + { + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; + + try + { + var hoursError = McpHelpers.ValidateHoursBack(hours_back); + if (hoursError != null) return hoursError; + + var limitError = McpHelpers.ValidateTop(limit); + if (limitError != null) return limitError; + + var rows = await dataService.GetRecentLongQueryCompletionsAsync(resolved.Value.ServerId, hours_back); + if (rows.Count == 0) + { + return McpHelpers.Status("empty", "No long-running query completions found in the specified time range. The long_query_completions collector is opt-in (default OFF) — enable it in the collector schedule to capture data."); + } + + var result = rows + .OrderByDescending(r => r.DurationMicroseconds ?? long.MinValue) + .Take(limit) + .Select(r => new + { + event_time = r.EventTime?.ToString("o"), + event_type = r.EventType, + duration_ms = r.DurationMicroseconds.HasValue ? r.DurationMicroseconds.Value / 1000.0 : (double?)null, + cpu_ms = r.CpuTimeMicroseconds.HasValue ? r.CpuTimeMicroseconds.Value / 1000.0 : (double?)null, + logical_reads = r.LogicalReads, + physical_reads = r.PhysicalReads, + writes = r.Writes, + row_count = r.RowCountValue, + result = r.Result, + database_name = r.DatabaseName, + object_name = r.ObjectName, + statement = McpHelpers.Truncate(r.StatementText, 2000), + session_id = r.SessionId, + client_app_name = r.ClientAppName, + client_pid = r.ClientPid, + nt_username = r.NtUserName, + server_principal_name = r.ServerPrincipalName, + query_hash = r.QueryHash + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + total_completions = rows.Count, + completions = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_long_query_completions", ex); + } + } +} diff --git a/Lite/Mcp/McpMemoryTools.cs b/Lite/Mcp/McpMemoryTools.cs index b2032a13b..5e5e46ef8 100644 --- a/Lite/Mcp/McpMemoryTools.cs +++ b/Lite/Mcp/McpMemoryTools.cs @@ -15,11 +15,8 @@ public static async Task<string> GetMemoryStats( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -57,11 +54,8 @@ public static async Task<string> GetMemoryTrend( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -98,11 +92,8 @@ public static async Task<string> GetMemoryClerks( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -142,11 +133,8 @@ public static async Task<string> GetMemoryPressureEvents( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -178,6 +166,58 @@ public static async Task<string> GetMemoryPressureEvents( } } + [McpServerTool(Name = "get_resource_semaphore"), Description("Gets resource semaphore statistics from the latest snapshot: granted vs available workspace memory against the target/max-target ceiling, per resource semaphore, with waiter counts and cumulative + per-interval timeout/forced-grant pressure indicators. High waiter counts or rising timeout/forced deltas indicate memory grant pressure affecting query performance.")] + public static async Task<string> GetResourceSemaphore( + LocalDataService dataService, + ServerManager serverManager, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history to search for the latest snapshot. Default 24.")] int hours_back = 24) + { + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; + + try + { + var hoursError = McpHelpers.ValidateHoursBack(hours_back); + if (hoursError != null) return hoursError; + + var rows = await dataService.GetResourceSemaphoreSnapshotAsync(resolved.Value.ServerId, hours_back); + if (rows.Count == 0) + { + return McpHelpers.Status("unavailable", "No memory grant data available."); + } + + var result = rows.Select(r => new + { + collection_time = r.CollectionTime.ToString("o"), + resource_semaphore_id = r.ResourceSemaphoreId, + pool_id = r.PoolId, + target_memory_mb = Math.Round(r.TargetMemoryMb, 2), + max_target_memory_mb = Math.Round(r.MaxTargetMemoryMb, 2), + total_memory_mb = Math.Round(r.TotalMemoryMb, 2), + available_memory_mb = Math.Round(r.AvailableMemoryMb, 2), + granted_memory_mb = Math.Round(r.GrantedMemoryMb, 2), + used_memory_mb = Math.Round(r.UsedMemoryMb, 2), + grantee_count = r.GranteeCount, + waiter_count = r.WaiterCount, + timeout_error_count = r.TimeoutErrorCount, + forced_grant_count = r.ForcedGrantCount, + timeout_error_count_delta = r.TimeoutErrorCountDelta, + forced_grant_count_delta = r.ForcedGrantCountDelta + }); + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + grants = result + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_resource_semaphore", ex); + } + } + [McpServerTool(Name = "get_memory_grants"), Description("Gets resource semaphore statistics showing granted vs available workspace memory per resource pool, waiter counts, and timeout/forced grant deltas. High waiter counts or rising timeout deltas indicate memory grant pressure affecting query performance.")] public static async Task<string> GetMemoryGrants( LocalDataService dataService, @@ -185,11 +225,8 @@ public static async Task<string> GetMemoryGrants( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 1.")] int hours_back = 1) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { diff --git a/Lite/Mcp/McpObjectStatsTools.cs b/Lite/Mcp/McpObjectStatsTools.cs index 66f33e26b..4a64293f5 100644 --- a/Lite/Mcp/McpObjectStatsTools.cs +++ b/Lite/Mcp/McpObjectStatsTools.cs @@ -15,11 +15,8 @@ public static async Task<string> GetTableIndexSizes( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -62,11 +59,8 @@ public static async Task<string> GetIndexUsage( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -112,11 +106,8 @@ public static async Task<string> GetObjectLocking( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { diff --git a/Lite/Mcp/McpPerfmonTools.cs b/Lite/Mcp/McpPerfmonTools.cs index cbda33694..61e0319da 100644 --- a/Lite/Mcp/McpPerfmonTools.cs +++ b/Lite/Mcp/McpPerfmonTools.cs @@ -17,11 +17,8 @@ public static async Task<string> GetPerfmonStats( [Description("Filter to a specific counter name, e.g. 'Batch Requests/sec'.")] string? counter_name = null, [Description("Filter to a specific instance name, e.g. a database name.")] string? instance_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -65,11 +62,8 @@ public static async Task<string> GetPerfmonTrend( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { diff --git a/Lite/Mcp/McpPlanCacheSchedulerTools.cs b/Lite/Mcp/McpPlanCacheSchedulerTools.cs new file mode 100644 index 000000000..b0bbf64f4 --- /dev/null +++ b/Lite/Mcp/McpPlanCacheSchedulerTools.cs @@ -0,0 +1,133 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; +using PerformanceMonitorLite.Services; +using PerformanceMonitor.Common; + +namespace PerformanceMonitorLite.Mcp; + +/// <summary> +/// The plan-cache + CPU-scheduler snapshot MCP tools — get_plan_cache_bloat, get_cpu_scheduler_pressure — +/// served over Lite's DuckDB store. get_plan_cache_bloat folds the true (uncapped) plan-count summary +/// (GetPlanCacheSummaryAsync) with the per-(cacheobjtype,objtype) composition breakdown (GetPlanCacheSnapshotAsync) +/// and the shared ClassifyPlanCacheBloat banding; get_cpu_scheduler_pressure returns the latest +/// cpu_scheduler_stats snapshot (GetCpuSchedulerSnapshotAsync) with the collector's warning flags. STORED +/// reads, no live monitored-server hit. +/// </summary> +[McpServerToolType] +public sealed class McpPlanCacheSchedulerTools +{ + [McpServerTool(Name = "get_plan_cache_bloat"), Description("Gets plan cache composition showing single-use vs multi-use plans per cache/object type, with a bloat-level classification. High single-use plan counts indicate ad-hoc query bloat consuming buffer pool memory. Consider enabling 'optimize for ad hoc workloads' or Forced Parameterization.")] + public static async Task<string> GetPlanCacheBloat( + LocalDataService dataService, + ServerManager serverManager, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history to search for the latest snapshot. Default 24.")] int hours_back = 24) + { + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; + + try + { + var hoursError = McpHelpers.ValidateHoursBack(hours_back); + if (hoursError != null) return hoursError; + + var summary = await dataService.GetPlanCacheSummaryAsync(resolved.Value.ServerId, hours_back); + var cacheTypes = await dataService.GetPlanCacheSnapshotAsync(resolved.Value.ServerId, hours_back); + if (summary.TotalPlans == 0 && cacheTypes.Count == 0) + return McpHelpers.Status("unavailable", "No plan cache statistics available in the requested time range."); + + var bloat = LocalDataService.ClassifyPlanCacheBloat(summary.TotalPlans, summary.SingleUsePlans); + var singleUsePercent = summary.TotalPlans > 0 + ? Math.Round(100.0 * summary.SingleUsePlans / summary.TotalPlans, 1) + : 0; + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + summary = new + { + total_plans = summary.TotalPlans, + single_use_plans = summary.SingleUsePlans, + single_use_percent = singleUsePercent, + oldest_plan_create_time = summary.OldestPlanCreateTime?.ToString("o"), + bloat_level = bloat.Level, + bloat_recommendation = bloat.Recommendation + }, + cache_types = cacheTypes.Select(r => new + { + cache_type = r.Cacheobjtype, + object_type = r.Objtype, + total_plans = r.TotalPlans, + total_size_mb = r.TotalSizeMb, + single_use_plans = r.SingleUsePlans, + single_use_size_mb = r.SingleUseSizeMb, + multi_use_plans = r.MultiUsePlans, + multi_use_size_mb = r.MultiUseSizeMb, + avg_use_count = Math.Round(r.AvgUseCount, 2), + avg_size_kb = r.AvgSizeKb + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_plan_cache_bloat", ex); + } + } + + [McpServerTool(Name = "get_cpu_scheduler_pressure"), Description("Gets CPU scheduler pressure from the latest snapshot: runnable task queue depth, worker thread utilization, queued/blocked requests, and the collector's pressure warning flags. Shows whether the server has enough worker threads and if tasks are queuing for CPU time.")] + public static async Task<string> GetCpuSchedulerPressure( + LocalDataService dataService, + ServerManager serverManager, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history to search for the latest snapshot. Default 24.")] int hours_back = 24) + { + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; + + try + { + var hoursError = McpHelpers.ValidateHoursBack(hours_back); + if (hoursError != null) return hoursError; + + var item = await dataService.GetCpuSchedulerSnapshotAsync(resolved.Value.ServerId, hours_back); + if (item == null) + return McpHelpers.Status("unavailable", "No CPU scheduler data available. The scheduler collector may not have run yet."); + + var workerUtilizationPercent = item.MaxWorkersCount > 0 + ? Math.Round(item.TotalCurrentWorkersCount * 100.0 / item.MaxWorkersCount, 2) + : 0; + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + collection_time = item.CollectionTime.ToString("o"), + schedulers = item.SchedulerCount, + cpu_count = item.CpuCount, + runnable_tasks = item.TotalRunnableTasksCount, + avg_runnable_per_scheduler = Math.Round(item.AvgRunnableTasksCount, 2), + workers = item.TotalCurrentWorkersCount, + max_workers = item.MaxWorkersCount, + worker_utilization_percent = workerUtilizationPercent, + runnable_percent = item.RunnablePercent.HasValue ? Math.Round(item.RunnablePercent.Value, 2) : (double?)null, + active_requests = item.TotalActiveRequestCount, + queued_requests = item.TotalQueuedRequestCount, + blocked_tasks = item.TotalBlockedTaskCount, + system_memory_state = item.SystemMemoryStateDesc, + warnings = new + { + worker_thread_exhaustion = item.WorkerThreadExhaustionWarning, + runnable_tasks = item.RunnableTasksWarning, + blocked_tasks = item.BlockedTasksWarning, + queued_requests = item.QueuedRequestsWarning, + physical_memory_pressure = item.PhysicalMemoryPressureWarning, + offline_cpu = item.OfflineCpuWarning + } + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_cpu_scheduler_pressure", ex); + } + } +} diff --git a/Lite/Mcp/McpPlanTools.cs b/Lite/Mcp/McpPlanTools.cs index 992f6abe4..5505bbb8a 100644 --- a/Lite/Mcp/McpPlanTools.cs +++ b/Lite/Mcp/McpPlanTools.cs @@ -22,9 +22,8 @@ public static async Task<string> AnalyzeQueryPlan( [Description("The query_hash value from get_top_queries_by_cpu.")] string query_hash, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -52,9 +51,8 @@ public static async Task<string> AnalyzeProcedurePlan( [Description("The plan_handle value from get_top_procedures_by_cpu.")] string plan_handle, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -82,9 +80,8 @@ public static async Task<string> AnalyzeQueryStorePlan( [Description("The plan_id from get_query_store_top.")] long plan_id, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -144,9 +141,8 @@ public static async Task<string> GetPlanXml( [Description("The query_hash value from get_top_queries_by_cpu.")] string query_hash, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { diff --git a/Lite/Mcp/McpQueryTools.cs b/Lite/Mcp/McpQueryTools.cs index 4a210277d..42dcbafab 100644 --- a/Lite/Mcp/McpQueryTools.cs +++ b/Lite/Mcp/McpQueryTools.cs @@ -20,11 +20,8 @@ public static async Task<string> GetTopQueriesByCpu( [Description("If true, only return queries that used parallelism (max_dop > 1).")] bool parallel_only = false, [Description("Minimum DOP to filter on. Implies parallel filtering.")] int min_dop = 0) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -34,7 +31,7 @@ public static async Task<string> GetTopQueriesByCpu( var topError = McpHelpers.ValidateTop(top, "top"); if (topError != null) return topError; - var rows = await dataService.GetTopQueriesByCpuAsync(resolved.Value.ServerId, hours_back, top, databaseName: database_name); + var rows = await dataService.GetTopQueriesByCpuAsync(resolved.Value.ServerId, hours_back, top, databaseNames: string.IsNullOrEmpty(database_name) ? null : new[] { database_name }); if (rows.Count == 0) { return McpHelpers.Status("unavailable", "No query stats available for the specified time range."); @@ -94,11 +91,8 @@ public static async Task<string> GetTopProceduresByCpu( [Description("Number of top procedures. Default 20.")] int top = 20, [Description("Filter to a specific database.")] string? database_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -108,7 +102,7 @@ public static async Task<string> GetTopProceduresByCpu( var topError = McpHelpers.ValidateTop(top, "top"); if (topError != null) return topError; - var rows = await dataService.GetTopProceduresByCpuAsync(resolved.Value.ServerId, hours_back, top, databaseName: database_name); + var rows = await dataService.GetTopProceduresByCpuAsync(resolved.Value.ServerId, hours_back, top, databaseNames: string.IsNullOrEmpty(database_name) ? null : new[] { database_name }); if (rows.Count == 0) { return McpHelpers.Status( @@ -161,11 +155,8 @@ public static async Task<string> GetQueryStoreTop( [Description("Number of top queries. Default 20.")] int top = 20, [Description("Filter to a specific database.")] string? database_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -175,7 +166,7 @@ public static async Task<string> GetQueryStoreTop( var topError = McpHelpers.ValidateTop(top, "top"); if (topError != null) return topError; - var rows = await dataService.GetQueryStoreTopQueriesAsync(resolved.Value.ServerId, hours_back, top, databaseName: database_name); + var rows = await dataService.GetQueryStoreTopQueriesAsync(resolved.Value.ServerId, hours_back, top, databaseNames: string.IsNullOrEmpty(database_name) ? null : new[] { database_name }); if (rows.Count == 0) { return McpHelpers.Status("unavailable", "No Query Store data available. Query Store may not be enabled on target databases."); @@ -219,11 +210,8 @@ public static async Task<string> GetQueryDurationTrend( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -260,11 +248,8 @@ public static async Task<string> GetQueryTrend( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { diff --git a/Lite/Mcp/McpServerInfoTools.cs b/Lite/Mcp/McpServerInfoTools.cs index 7ca38b264..5bb299b99 100644 --- a/Lite/Mcp/McpServerInfoTools.cs +++ b/Lite/Mcp/McpServerInfoTools.cs @@ -15,9 +15,8 @@ public static async Task<string> GetServerProperties( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -57,9 +56,8 @@ public static async Task<string> GetDatabaseSizes( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { diff --git a/Lite/Mcp/McpSessionTools.cs b/Lite/Mcp/McpSessionTools.cs index 8beda61cb..f565303a6 100644 --- a/Lite/Mcp/McpSessionTools.cs +++ b/Lite/Mcp/McpSessionTools.cs @@ -19,9 +19,8 @@ public static async Task<string> GetActiveQueries( [Description("Show only queries involved in blocking (blocking_session_id > 0 or is a head blocker).")] bool blocking_only = false, [Description("Maximum number of rows to return. Default 50.")] int limit = 50) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; @@ -88,9 +87,8 @@ public static async Task<string> GetSessionStats( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { diff --git a/Lite/Mcp/McpTempDbTools.cs b/Lite/Mcp/McpTempDbTools.cs index d75fa50f0..da3d1309b 100644 --- a/Lite/Mcp/McpTempDbTools.cs +++ b/Lite/Mcp/McpTempDbTools.cs @@ -16,11 +16,8 @@ public static async Task<string> GetTempDbTrend( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { diff --git a/Lite/Mcp/McpWaitTools.cs b/Lite/Mcp/McpWaitTools.cs index 9bf0d6e8a..c30d7074b 100644 --- a/Lite/Mcp/McpWaitTools.cs +++ b/Lite/Mcp/McpWaitTools.cs @@ -17,11 +17,8 @@ public static async Task<string> GetWaitStats( [Description("Hours of history to analyze. Default 24.")] int hours_back = 24, [Description("Maximum rows to return. Default 20.")] int limit = 20) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -67,11 +64,8 @@ public static async Task<string> GetWaitTypes( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -100,11 +94,8 @@ public static async Task<string> GetWaitTrend( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -158,11 +149,8 @@ public static async Task<string> GetWaitingTasks( [Description("Hours of history. Default 1.")] int hours_back = 1, [Description("Maximum rows. Default 30.")] int limit = 30) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { diff --git a/Lite/Mcp/ServerResolver.cs b/Lite/Mcp/ServerResolver.cs index 8cdf9ec8c..3698682b4 100644 --- a/Lite/Mcp/ServerResolver.cs +++ b/Lite/Mcp/ServerResolver.cs @@ -8,7 +8,7 @@ namespace PerformanceMonitorLite.Mcp; /// </summary> internal static class ServerResolver { - public static (int ServerId, string ServerName)? Resolve( + private static (int ServerId, string ServerName)? Resolve( ServerManager serverManager, string? serverName) { @@ -56,7 +56,22 @@ public static (int ServerId, string ServerName)? Resolve( return null; } - public static string ListAvailableServers(ServerManager serverManager) + /// <summary> + /// Resolves a server name, returning either the resolved (server_id, name) or a ready-to-return + /// error string listing the available servers. Lets MCP tools collapse the repeated resolve-and-bail + /// block to: var (resolved, error) = ResolveOrError(...); if (error != null) return error; + /// </summary> + public static ((int ServerId, string ServerName)? resolved, string? error) ResolveOrError( + ServerManager serverManager, + string? serverName) + { + var resolved = Resolve(serverManager, serverName); + return resolved is null + ? (null, $"Could not resolve server. Available servers:\n{ListAvailableServers(serverManager)}") + : (resolved, null); + } + + private static string ListAvailableServers(ServerManager serverManager) { var servers = serverManager.GetEnabledServers(); if (servers.Count == 0) diff --git a/Lite/Models/ServerConnection.cs b/Lite/Models/ServerConnection.cs index 59417d9ee..3aebe501f 100644 --- a/Lite/Models/ServerConnection.cs +++ b/Lite/Models/ServerConnection.cs @@ -141,6 +141,15 @@ public bool UseWindowsAuth /// </summary> public System.Collections.Generic.List<string> ExcludedDatabases { get; set; } = new(); + /// <summary> + /// #1319: the per-server global database FILTER (display-only). When non-empty, database-scoped + /// views (query grids, blocking, per-DB config, trends/slicers/heatmap) show only these databases; + /// empty = "All" (today's behavior). Distinct from <see cref="ExcludedDatabases"/>, which is + /// collector-side ("don't COLLECT these") — this changes nothing about what is collected, only what + /// the viewer displays. Persisted in servers.json so a chosen subset stays sticky per server. + /// </summary> + public System.Collections.Generic.List<string> ViewFilterDatabases { get; set; } = new(); + /// <summary> /// Server name with "(Read-Only)" suffix when ReadOnlyIntent is enabled. /// Used for sidebar subtitle and status text. diff --git a/Lite/PerformanceMonitorLite.csproj b/Lite/PerformanceMonitorLite.csproj index 78fbf3873..b45aeed33 100644 --- a/Lite/PerformanceMonitorLite.csproj +++ b/Lite/PerformanceMonitorLite.csproj @@ -8,10 +8,10 @@ <AssemblyName>PerformanceMonitorLite</AssemblyName> <RootNamespace>PerformanceMonitorLite</RootNamespace> <Product>SQL Server Performance Monitor Lite</Product> - <Version>3.1.0</Version> - <AssemblyVersion>3.1.0.0</AssemblyVersion> - <FileVersion>3.1.0.0</FileVersion> - <InformationalVersion>3.1.0</InformationalVersion> + <Version>3.2.0</Version> + <AssemblyVersion>3.2.0.0</AssemblyVersion> + <FileVersion>3.2.0.0</FileVersion> + <InformationalVersion>3.2.0</InformationalVersion> <Company>Darling Data, LLC</Company> <Copyright>Copyright © 2026 Darling Data, LLC</Copyright> <Description>Lightweight SQL Server performance monitoring - no installation required on target servers</Description> @@ -39,7 +39,9 @@ </ItemGroup> <ItemGroup> + <ProjectReference Include="..\PerformanceMonitor.Alerting\PerformanceMonitor.Alerting.csproj" /> <ProjectReference Include="..\PerformanceMonitor.Analysis\PerformanceMonitor.Analysis.csproj" /> + <ProjectReference Include="..\PerformanceMonitor.Collectors\PerformanceMonitor.Collectors.csproj" /> <ProjectReference Include="..\PerformanceMonitor.Common\PerformanceMonitor.Common.csproj" /> <ProjectReference Include="..\PerformanceMonitor.PlanAnalysis\PerformanceMonitor.PlanAnalysis.csproj" /> <ProjectReference Include="..\PerformanceMonitor.Notifications\PerformanceMonitor.Notifications.csproj" /> diff --git a/Lite/Services/AppAlertEngineSettings.cs b/Lite/Services/AppAlertEngineSettings.cs new file mode 100644 index 000000000..7d0817447 --- /dev/null +++ b/Lite/Services/AppAlertEngineSettings.cs @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Collections.Generic; +using PerformanceMonitor.Alerting; +/* Alias to keep the app's own persisted CpuAlertMode enum (Total/SqlOnly) visually distinct from + the engine's (TotalServer/SqlProcess) — the two are mapped, never mixed. */ +using EngineCpuAlertMode = PerformanceMonitor.Alerting.CpuAlertMode; + +namespace PerformanceMonitorLite.Services; + +/// <summary> +/// Adapts Lite's App.* alert statics to the shared engine's <see cref="IAlertEngineSettings"/> +/// (Phase-5 forwarding) — the engine-threshold sibling of <see cref="AppAlertSettings"/> +/// (the delivery-only surface). Pure, live pass-through: every member reads the current App value +/// on each access with no caching, so a runtime settings reload is reflected on the next sweep, +/// exactly as the pre-forwarding loop's direct App.* reads behaved. Stateless; safe to construct +/// once and share. +/// <para> +/// The only member that isn't a bare forward is <see cref="CpuAlertMode"/>: Lite's persisted +/// <c>CpuAlertMode.Total</c>/<c>SqlOnly</c> maps to the engine's +/// <c>TotalServer</c>/<c>SqlProcess</c> (the engine enum is deliberately separate so app settings +/// stores never serialize engine names). +/// </para> +/// </summary> +public sealed class AppAlertEngineSettings : IAlertEngineSettings +{ + public bool AlertsEnabled => App.AlertsEnabled; + + public bool CpuEnabled => App.AlertCpuEnabled; + public bool BlockingEnabled => App.AlertBlockingEnabled; + public bool DeadlockEnabled => App.AlertDeadlockEnabled; + public bool PoisonWaitEnabled => App.AlertPoisonWaitEnabled; + public bool LongRunningQueryEnabled => App.AlertLongRunningQueryEnabled; + public bool TempDbSpaceEnabled => App.AlertTempDbSpaceEnabled; + public bool LowDiskEnabled => App.AlertLowDiskEnabled; + public bool LongRunningJobEnabled => App.AlertLongRunningJobEnabled; + public bool FailedJobEnabled => App.AlertFailedJobEnabled; + + public int CpuThresholdPercent => App.AlertCpuThreshold; + public int BlockingCountThreshold => App.AlertBlockingThreshold; + public int DeadlockCountThreshold => App.AlertDeadlockThreshold; + public int PoisonWaitThresholdMs => App.AlertPoisonWaitThresholdMs; + public int LongRunningQueryThresholdMinutes => App.AlertLongRunningQueryThresholdMinutes; + + /* The six long-running-query read knobs — Lite exposes all six as real settings. */ + public int LongRunningQueryMaxResults => App.AlertLongRunningQueryMaxResults; + public bool LongRunningQueryExcludeSpServerDiagnostics => App.AlertLongRunningQueryExcludeSpServerDiagnostics; + public bool LongRunningQueryExcludeWaitFor => App.AlertLongRunningQueryExcludeWaitFor; + public bool LongRunningQueryExcludeBackups => App.AlertLongRunningQueryExcludeBackups; + public bool LongRunningQueryExcludeMiscWaits => App.AlertLongRunningQueryExcludeMiscWaits; + public bool LongRunningQueryExcludeCdc => App.AlertLongRunningQueryExcludeCdc; + + public int TempDbSpaceThresholdPercent => App.AlertTempDbSpaceThresholdPercent; + public int LowDiskThresholdPercent => App.AlertLowDiskThresholdPercent; + public int LowDiskThresholdGb => App.AlertLowDiskThresholdGb; + public int LongRunningJobMultiplier => App.AlertLongRunningJobMultiplier; + public int FailedJobLookbackMinutes => App.AlertFailedJobLookbackMinutes; + public int CooldownMinutes => App.AlertCooldownMinutes; + + public IReadOnlyList<string> ExcludedDatabases => App.AlertExcludedDatabases; + + public EngineCpuAlertMode CpuAlertMode => + App.AlertCpuMode == PerformanceMonitorLite.CpuAlertMode.Total + ? EngineCpuAlertMode.TotalServer + : EngineCpuAlertMode.SqlProcess; +} diff --git a/Lite/Services/AppAlertSettings.cs b/Lite/Services/AppAlertSettings.cs index b75ccaba7..f95c76ad8 100644 --- a/Lite/Services/AppAlertSettings.cs +++ b/Lite/Services/AppAlertSettings.cs @@ -38,6 +38,12 @@ public sealed class AppAlertSettings : IAlertSettings public string SlackWebhookUrl => App.SlackWebhookUrl; public string SlackProxyAddress => App.SlackProxyAddress; + public bool GenericWebhookEnabled => App.GenericWebhookEnabled; + public string GenericWebhookUrl => App.GenericWebhookUrl; + public string GenericWebhookHeadersJson => App.GenericWebhookHeadersJson; + public string GenericWebhookBodyTemplate => App.GenericWebhookBodyTemplate; + public string GenericWebhookProxyAddress => App.GenericWebhookProxyAddress; + public double AnalysisNotifySeverity => App.AnalysisNotifySeverity; public int AnalysisNotifyCooldownMinutes => App.AnalysisNotifyCooldownMinutes; } diff --git a/Lite/Services/AppLogger.cs b/Lite/Services/AppLogger.cs index 0b385991c..18c7a84f8 100644 --- a/Lite/Services/AppLogger.cs +++ b/Lite/Services/AppLogger.cs @@ -97,11 +97,6 @@ public static void Debug(string source, string message) #endif } - public static void DataDiag(string source, string message) - { - Log("DATA", source, message); - } - private static void Log(string level, string source, string message) { var line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{level,-5}] [{source}] {message}"; diff --git a/Lite/Services/AppenderCollectorRowWriter.cs b/Lite/Services/AppenderCollectorRowWriter.cs new file mode 100644 index 000000000..1e1e70c7c --- /dev/null +++ b/Lite/Services/AppenderCollectorRowWriter.cs @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using DuckDB.NET.Data; +using PerformanceMonitor.Collectors; + +namespace PerformanceMonitorLite.Services; + +/// <summary> +/// Adapts a DuckDB appender row to the shared <see cref="ICollectorRowWriter"/> contract so +/// extracted collector definitions write Lite's store without knowing DuckDB exists. Reused +/// across rows via <see cref="CurrentRow"/>; the host runner owns row begin/end. +/// </summary> +internal sealed class AppenderCollectorRowWriter : ICollectorRowWriter +{ + public IDuckDBAppenderRow? CurrentRow { get; set; } + + private IDuckDBAppenderRow Row => CurrentRow ?? throw new InvalidOperationException("CurrentRow not set"); + + public ICollectorRowWriter Value(string? value) { Row.AppendValue(value); return this; } + + public ICollectorRowWriter Value(long value) { Row.AppendValue(value); return this; } + + public ICollectorRowWriter Value(long? value) { if (value.HasValue) { Row.AppendValue(value.Value); } else { Row.AppendNullValue(); } return this; } + + public ICollectorRowWriter Value(int value) { Row.AppendValue(value); return this; } + + public ICollectorRowWriter Value(int? value) { if (value.HasValue) { Row.AppendValue(value.Value); } else { Row.AppendNullValue(); } return this; } + + public ICollectorRowWriter Value(short value) { Row.AppendValue(value); return this; } + + public ICollectorRowWriter Value(short? value) { if (value.HasValue) { Row.AppendValue(value.Value); } else { Row.AppendNullValue(); } return this; } + + public ICollectorRowWriter Value(double value) { Row.AppendValue(value); return this; } + + public ICollectorRowWriter Value(double? value) { if (value.HasValue) { Row.AppendValue(value.Value); } else { Row.AppendNullValue(); } return this; } + + public ICollectorRowWriter Value(decimal value) { Row.AppendValue(value); return this; } + + public ICollectorRowWriter Value(decimal? value) { if (value.HasValue) { Row.AppendValue(value.Value); } else { Row.AppendNullValue(); } return this; } + + public ICollectorRowWriter Value(bool value) { Row.AppendValue(value); return this; } + + public ICollectorRowWriter Value(bool? value) { if (value.HasValue) { Row.AppendValue(value.Value); } else { Row.AppendNullValue(); } return this; } + + public ICollectorRowWriter Value(DateTime value) { Row.AppendValue(value); return this; } + + public ICollectorRowWriter Value(DateTime? value) { if (value.HasValue) { Row.AppendValue(value.Value); } else { Row.AppendNullValue(); } return this; } + + public ICollectorRowWriter NullValue() { Row.AppendNullValue(); return this; } +} diff --git a/Lite/Services/ArchiveService.cs b/Lite/Services/ArchiveService.cs index 2aaa13177..e9be9a007 100644 --- a/Lite/Services/ArchiveService.cs +++ b/Lite/Services/ArchiveService.cs @@ -16,6 +16,7 @@ using System.Threading.Tasks; using DuckDB.NET.Data; using Microsoft.Extensions.Logging; +using PerformanceMonitor.Collectors; using PerformanceMonitorLite.Database; namespace PerformanceMonitorLite.Services; @@ -52,40 +53,16 @@ were silently lost because ResetDatabaseAsync deletes monitor.duckdb. */ "dismissed_archive_alerts" ]; - /* Tables eligible for archival with their time column. - IMPORTANT: Every table with time-series data must be listed here, - or it will grow unbounded and push the DB past the 512 MB reset threshold. */ + /* Tables eligible for archival with their time column. Catalog-driven: every collector table + (from CollectorCatalog, with its prefix time column — collection_time everywhere except the + four config snapshots' capture_time) plus the two non-collector time-series tables. Adding a + collector gives it archival for free; the former hand-maintained list could silently omit a + new table and let it grow unbounded past the 512 MB reset threshold. Mirrors + DuckDbInitializer.ArchivableTables (same table set); a test pins the two together. */ internal static readonly (string Table, string TimeColumn)[] ArchivableTables = - [ - ("wait_stats", "collection_time"), - ("query_stats", "collection_time"), - ("procedure_stats", "collection_time"), - ("query_store_stats", "collection_time"), - ("query_snapshots", "collection_time"), - ("cpu_utilization_stats", "collection_time"), - ("file_io_stats", "collection_time"), - ("memory_stats", "collection_time"), - ("memory_clerks", "collection_time"), - ("memory_pressure_events", "collection_time"), - ("tempdb_stats", "collection_time"), - ("perfmon_stats", "collection_time"), - ("deadlocks", "collection_time"), - ("blocked_process_reports", "collection_time"), - ("memory_grant_stats", "collection_time"), - ("waiting_tasks", "collection_time"), - ("dmv_blocking_snapshots", "collection_time"), - ("running_jobs", "collection_time"), - ("database_size_stats", "collection_time"), - ("index_object_stats", "collection_time"), - ("server_properties", "collection_time"), - ("session_stats", "collection_time"), - ("server_config", "capture_time"), - ("database_config", "capture_time"), - ("database_scoped_config", "capture_time"), - ("trace_flags", "capture_time"), - ("config_alert_log", "alert_time"), - ("collection_log", "collection_time") - ]; + CollectorCatalog.All.Select(c => (c.TargetTable, c.PrefixTimeColumnName)) + .Concat([("config_alert_log", "alert_time"), ("collection_log", "collection_time")]) + .ToArray(); public ArchiveService(DuckDbInitializer duckDb, string archivePath, ILogger<ArchiveService>? logger = null) { @@ -675,7 +652,17 @@ await WithRaisedCopyMemoryLimit(connection, async () => This runs outside the write lock using an in-memory DuckDB connection and only touches filesystem files — no contention with collectors. */ _logger?.LogInformation("Compacting parquet files into monthly archives"); - CompactParquetFiles(); + try + { + CompactParquetFiles(); + } + catch (Exception compactEx) + { + /* Compaction is best-effort (merging per-cycle parquet into monthly files); a failure + must not abort the archive/reset. Previously unlogged — surface it so a stuck or + oversized backlog is visible instead of silently degrading. */ + _logger?.LogError(compactEx, "Parquet compaction failed; continuing with archive and reset"); + } /* Nuke and reinitialize outside the using-connection scope so all handles are closed */ _logger?.LogInformation("Deleting and reinitializing database"); diff --git a/Lite/Services/CollectionBackgroundService.cs b/Lite/Services/CollectionBackgroundService.cs index 2c36231aa..ab58c84d1 100644 --- a/Lite/Services/CollectionBackgroundService.cs +++ b/Lite/Services/CollectionBackgroundService.cs @@ -39,6 +39,7 @@ public class CollectionBackgroundService : BackgroundService private DateTime _lastArchiveTime = DateTime.UtcNow; private DateTime _lastRetentionTime = DateTime.UtcNow; private DateTime _lastAnalysisTime = DateTime.UtcNow; + private DateTime _lastFindingsCleanupTime = DateTime.UtcNow; /* Server IDs whose scheduled analysis is currently running — prevents relaunching analysis for a server whose previous (possibly hung) pass has not finished. */ @@ -47,6 +48,9 @@ public class CollectionBackgroundService : BackgroundService /* Archive every hour, retention once per day */ private static readonly TimeSpan ArchiveInterval = TimeSpan.FromHours(1); private static readonly TimeSpan RetentionInterval = TimeSpan.FromHours(24); + /* Analysis-findings retention purge — daily, matching the parquet-retention cadence + above and Darling's daily findings-cleanup horizon. */ + private static readonly TimeSpan FindingsCleanupInterval = TimeSpan.FromHours(24); /* Size-based trigger — when the database exceeds this size, archive ALL data to parquet and reset the database. INSERT performance degrades badly with @@ -128,6 +132,9 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) /* Periodic retention cleanup */ RunRetentionIfDue(); + /* Periodic analysis-findings retention (rolling 30-day purge) */ + await RunFindingsCleanupIfDueAsync(); + /* Periodic scheduled analysis + high-severity finding notifications */ await RunAnalysisIfDueAsync(stoppingToken); @@ -205,6 +212,35 @@ private void RunRetentionIfDue() } } + /// <summary> + /// Purges analysis findings past the 30-day retention window on a daily cadence. + /// FindingStore has always declared CleanupOldFindingsAsync, but nothing scheduled it, + /// so analysis_findings grew until a size-triggered ArchiveAllAndResetAsync incidentally + /// wiped the WHOLE DuckDB (losing ALL findings, not just aged ones — analysis_findings is + /// not in ArchiveService.ArchivableTables, so routine archival never touched it). This + /// applies the same rolling 30-day retention the other editions intend (Darling's daily + /// purge rides the same horizon). Gated on _duckDb — the dependency the cleanup needs — + /// so it is independent of the parquet RetentionService. Never throws: logs and degrades + /// like the archival/retention ticks above. + /// </summary> + private async Task RunFindingsCleanupIfDueAsync() + { + if (_duckDb == null || DateTime.UtcNow - _lastFindingsCleanupTime < FindingsCleanupInterval) + { + return; + } + + try + { + await new AnalysisService(_duckDb).CleanupAsync(retentionDays: 30); + _lastFindingsCleanupTime = DateTime.UtcNow; + } + catch (Exception ex) + { + _logger?.LogError(ex, "Findings cleanup failed"); + } + } + /// <summary> /// Runs the triage engine for each enabled server on the independent /// AnalysisIntervalMinutes cadence and persists findings to DuckDB. Production is diff --git a/Lite/Services/ConnectionEdgeDetector.cs b/Lite/Services/ConnectionEdgeDetector.cs new file mode 100644 index 000000000..6f026924f --- /dev/null +++ b/Lite/Services/ConnectionEdgeDetector.cs @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +namespace PerformanceMonitorLite.Services; + +/// <summary>The connection transition one poll observed for one server.</summary> +public enum ConnectionAlertEdge +{ + /// <summary>No transition to announce: the steady states (online→online, offline→offline) and the + /// first-ever observation, which is a silent baseline.</summary> + None, + + /// <summary>online → offline.</summary> + Lost, + + /// <summary>offline → online.</summary> + Restored +} + +/// <summary> +/// The connection-alert edge trigger (#1506), lifted out of <c>MainWindow.CheckConnectionsAndNotify</c>'s +/// inline condition so the property that actually matters is testable: an alert fires ONCE per transition, +/// never once per poll. A server that is down for eight hours is observed offline hundreds of times, but +/// every observation after the first is offline→offline — <see cref="ConnectionAlertEdge.None"/> — so it +/// produces one "Server Unreachable", not one per cycle. +/// +/// <para>Semantics are the pre-existing ones (previous-state dictionary, skip the first observation) and +/// match the Dashboard's skip-first-check and Darling's <c>Unknown/Online/Offline</c> machine: a first +/// sighting only establishes the baseline, so an app that starts up with a server already down does not +/// immediately alert about a state it never saw change.</para> +/// </summary> +public static class ConnectionEdgeDetector +{ + /// <param name="previous">The last observed state, or null when this server has never been observed.</param> + /// <param name="current">The state this poll observed.</param> + public static ConnectionAlertEdge Detect(bool? previous, bool current) => previous switch + { + null => ConnectionAlertEdge.None, + true when !current => ConnectionAlertEdge.Lost, + false when current => ConnectionAlertEdge.Restored, + _ => ConnectionAlertEdge.None + }; +} diff --git a/Lite/Services/CredentialService.cs b/Lite/Services/CredentialService.cs index a3f1052cb..d19303d58 100644 --- a/Lite/Services/CredentialService.cs +++ b/Lite/Services/CredentialService.cs @@ -6,9 +6,8 @@ * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ -using System; -using CredentialManagement; using Microsoft.Extensions.Logging; +using PerformanceMonitor.Common; namespace PerformanceMonitorLite.Services; @@ -16,15 +15,24 @@ namespace PerformanceMonitorLite.Services; /// Secure credential storage service using Windows Credential Manager. /// Credentials are encrypted by Windows using DPAPI and stored per-user. /// NEVER stores passwords in plain text. +/// +/// Thin adapter over the shared <see cref="CredentialStore"/>. The +/// <c>"PerformanceMonitorLite_"</c> prefix keeps Lite credentials isolated from +/// Dashboard's (<c>"PerformanceMonitor_"</c>) in Windows Credential Manager — it MUST +/// stay distinct. /// </summary> public class CredentialService { private const string CredentialPrefix = "PerformanceMonitorLite_"; - private readonly ILogger<CredentialService>? _logger; + + private readonly CredentialStore _store; public CredentialService(ILogger<CredentialService>? logger = null) { - _logger = logger; + _store = new CredentialStore( + CredentialPrefix, + "SQL Server credentials for Performance Monitor Lite", + logger); } /// <summary> @@ -32,143 +40,30 @@ public CredentialService(ILogger<CredentialService>? logger = null) /// Credentials are encrypted automatically by Windows. /// </summary> public bool SaveCredential(string serverId, string username, string password) - { - if (string.IsNullOrWhiteSpace(serverId)) - { - throw new ArgumentException("Server ID cannot be null or empty", nameof(serverId)); - } - - if (string.IsNullOrWhiteSpace(username)) - { - throw new ArgumentException("Username cannot be null or empty", nameof(username)); - } - - if (password == null) - { - throw new ArgumentNullException(nameof(password)); - } - - try - { - using var credential = new Credential - { - Target = GetCredentialTarget(serverId), - Username = username, - Password = password, - Type = CredentialType.Generic, - PersistanceType = PersistanceType.LocalComputer, - Description = "SQL Server credentials for Performance Monitor Lite" - }; - - return credential.Save(); - } - catch (Exception ex) - { - _logger?.LogWarning(ex, "Failed to save credential for server {ServerId}", serverId); - return false; - } - } + => _store.SaveCredential(serverId, username, password); /// <summary> /// Retrieves SQL Server credentials from Windows Credential Manager. /// </summary> public (string Username, string Password)? GetCredential(string serverId) - { - if (string.IsNullOrWhiteSpace(serverId)) - { - throw new ArgumentException("Server ID cannot be null or empty", nameof(serverId)); - } - - try - { - using var credential = new Credential - { - Target = GetCredentialTarget(serverId), - Type = CredentialType.Generic - }; - - if (credential.Load()) - { - return (credential.Username, credential.Password); - } - } - catch (Exception ex) - { - _logger?.LogWarning(ex, "Failed to load credential for server {ServerId}", serverId); - } - - return null; - } + => _store.GetCredential(serverId); /// <summary> /// Deletes SQL Server credentials from Windows Credential Manager. /// Should be called when a server is removed. /// </summary> public bool DeleteCredential(string serverId) - { - if (string.IsNullOrWhiteSpace(serverId)) - { - throw new ArgumentException("Server ID cannot be null or empty", nameof(serverId)); - } - - try - { - using var credential = new Credential - { - Target = GetCredentialTarget(serverId), - Type = CredentialType.Generic - }; - - return credential.Delete(); - } - catch (Exception ex) - { - _logger?.LogWarning(ex, "Failed to delete credential for server {ServerId}", serverId); - return false; - } - } + => _store.DeleteCredential(serverId); /// <summary> /// Checks if credentials exist for a server. /// </summary> public bool CredentialExists(string serverId) - { - if (string.IsNullOrWhiteSpace(serverId)) - { - return false; - } - - try - { - using var credential = new Credential - { - Target = GetCredentialTarget(serverId), - Type = CredentialType.Generic - }; - - return credential.Exists(); - } - catch (Exception ex) - { - _logger?.LogWarning(ex, "Failed to check credential existence for server {ServerId}", serverId); - return false; - } - } + => _store.CredentialExists(serverId); /// <summary> /// Updates existing credentials. If credentials don't exist, creates them. /// </summary> public bool UpdateCredential(string serverId, string username, string password) - { - DeleteCredential(serverId); - return SaveCredential(serverId, username, password); - } - - /// <summary> - /// Generates the credential target name for Windows Credential Manager. - /// </summary> - private static string GetCredentialTarget(string serverId) - { - return $"{CredentialPrefix}{serverId}"; - } + => _store.UpdateCredential(serverId, username, password); } diff --git a/Lite/Services/DataGridFilterService.cs b/Lite/Services/DataGridFilterService.cs deleted file mode 100644 index 4bd9f5140..000000000 --- a/Lite/Services/DataGridFilterService.cs +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (c) 2026 Erik Darling, Darling Data LLC - * - * This file is part of the SQL Server Performance Monitor Lite. - * - * Licensed under the MIT License. See LICENSE file in the project root for full license information. - */ - -using PerformanceMonitor.Common; - -namespace PerformanceMonitorLite.Services; - -/// <summary> -/// Provides operator-based filtering for DataGrid column filters. Forwards to the shared -/// <see cref="ColumnFilterMatcher"/> so Lite and Dashboard share one matching implementation. -/// </summary> -public static class DataGridFilterService -{ - /// <summary> - /// Checks if an item matches a ColumnFilterState using operator-based filtering. - /// </summary> - public static bool MatchesFilter(object item, ColumnFilterState filter) => - ColumnFilterMatcher.MatchesFilter(item, filter); -} diff --git a/Lite/Services/DeltaCalculator.cs b/Lite/Services/DeltaCalculator.cs index 990a507cd..0ad2bad18 100644 --- a/Lite/Services/DeltaCalculator.cs +++ b/Lite/Services/DeltaCalculator.cs @@ -7,27 +7,22 @@ */ using System; -using System.Collections.Concurrent; -using System.Collections.Generic; using System.Threading.Tasks; using DuckDB.NET.Data; using Microsoft.Extensions.Logging; +using PerformanceMonitor.Collectors; using PerformanceMonitorLite.Database; namespace PerformanceMonitorLite.Services; /// <summary> -/// Calculates delta values for cumulative metrics between collection intervals. -/// Caches previous values in memory for efficient delta calculation. -/// Seeds from DuckDB on startup to survive application restarts. +/// Lite's delta calculator: the shared <see cref="CollectorDeltaCalculator"/> core (baseline / +/// counter-reset / gap-policy semantics live there, identical across SKUs) plus the DuckDB +/// seeding that lets the first collection after an app restart produce accurate deltas instead +/// of returning 0 for everything. /// </summary> -public class DeltaCalculator +public class DeltaCalculator : CollectorDeltaCalculator { - /// <summary> - /// Cache structure: serverId -> collectorName -> key -> (previousValue, timestamp) - /// </summary> - private readonly ConcurrentDictionary<int, ConcurrentDictionary<string, ConcurrentDictionary<string, (long Value, DateTime? Timestamp)>>> _cache = new(); - private readonly ILogger? _logger; public DeltaCalculator(ILogger? logger = null) @@ -59,72 +54,6 @@ public async Task SeedFromDatabaseAsync(DuckDbInitializer duckDb) } } - /// <summary> - /// Removes all cached entries for a server (e.g., when the server tab is closed). - /// Next collection will re-seed from database if needed. - /// </summary> - public void ClearServer(int serverId) - { - _cache.TryRemove(serverId, out _); - } - - /// <summary> - /// Calculates the delta between the current value and the previous cached value. - /// First-ever sighting (no baseline): returns currentValue so single-execution queries appear. - /// Counter reset (value decreased): returns 0 to avoid inflated deltas from plan cache churn. - /// Gap detection: if collectionTime and maxGapSeconds are provided and the gap since the - /// last cached value exceeds maxGapSeconds, returns 0 to avoid inflated deltas after restarts. - /// Thread-safe via atomic AddOrUpdate. - /// </summary> - public long CalculateDelta(int serverId, string collectorName, string key, long currentValue, - bool baselineOnly = false, DateTime? collectionTime = null, int maxGapSeconds = 0) - { - var serverCache = _cache.GetOrAdd(serverId, _ => new ConcurrentDictionary<string, ConcurrentDictionary<string, (long Value, DateTime? Timestamp)>>()); - var collectorCache = serverCache.GetOrAdd(collectorName, _ => new ConcurrentDictionary<string, (long Value, DateTime? Timestamp)>()); - - long delta = 0; - - collectorCache.AddOrUpdate( - key, - /* Add: first time seeing this key. - baselineOnly = true: store baseline only, return 0 (for cumulative counters like perfmon). - baselineOnly = false: use current value as delta so single-execution queries surface. */ - _ => - { - delta = baselineOnly ? 0 : currentValue; - return (currentValue, collectionTime); - }, - /* Update: compute delta atomically */ - (_, previous) => - { - /* Gap detection: if too much time has passed since the last cached value, - treat this as a new baseline to avoid inflated deltas after app restarts */ - if (maxGapSeconds > 0 && collectionTime.HasValue && previous.Timestamp.HasValue - && (collectionTime.Value - previous.Timestamp.Value).TotalSeconds > maxGapSeconds) - { - delta = 0; - return (currentValue, collectionTime); - } - - delta = currentValue < previous.Value - ? 0 /* counter reset (plan cache eviction/re-entry) — not real new work */ - : currentValue - previous.Value; - return (currentValue, collectionTime); - }); - - return delta; - } - - /// <summary> - /// Seeds a single value into the cache without computing a delta. - /// </summary> - private void Seed(int serverId, string collectorName, string key, long value, DateTime? timestamp = null) - { - var serverCache = _cache.GetOrAdd(serverId, _ => new ConcurrentDictionary<string, ConcurrentDictionary<string, (long Value, DateTime? Timestamp)>>()); - var collectorCache = serverCache.GetOrAdd(collectorName, _ => new ConcurrentDictionary<string, (long Value, DateTime? Timestamp)>()); - collectorCache[key] = (value, timestamp); - } - private async Task SeedWaitStatsAsync(DuckDBConnection connection) { using var cmd = connection.CreateCommand(); @@ -238,5 +167,4 @@ FROM memory_grant_stats /* Table may not exist on first run after schema migration */ } } - } diff --git a/Lite/Services/LiteAlertDeliverer.cs b/Lite/Services/LiteAlertDeliverer.cs new file mode 100644 index 000000000..2f2847c51 --- /dev/null +++ b/Lite/Services/LiteAlertDeliverer.cs @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Threading; +using Hardcodet.Wpf.TaskbarNotification; +using PerformanceMonitor.Alerting; +using PerformanceMonitor.Notifications; +using AlertContextBuilders = PerformanceMonitor.Alerting.AlertContextBuilders; + +namespace PerformanceMonitorLite.Services; + +/// <summary> +/// Lite's <see cref="IAlertDeliverer"/> (Phase-5 forwarding): the pre-forwarding loop's per-fire +/// delivery block moved behind the engine seam, verbatim — +/// <list type="number"> +/// <item>the TRAY TOAST first (skipped when muted, exactly the old <c>if (!isMuted)</c> gates): +/// title = the metric name, message = <c>"{server}: {ShortMessage}"</c> (the engine carries each +/// metric's exact old toast body), icon = Error for "Deadlocks Detected"/"Poison Wait" and Warning +/// for everything else, always the snoozable balloon — marshaled to the WPF dispatcher because the +/// tray icon is a UI element;</item> +/// <item>then the email/webhook/history send: "Blocking Detected"/"Deadlocks Detected" go through +/// the old <c>SendDetectedAlertAsync</c> semantics — the #1236 per-server delivery-mode override +/// (from <c>servers.json</c> via <see cref="ServerManager"/>) resolved against the global +/// <c>App.AlertDeliveryMode</c>, #1141 Per-event mode splitting the context into one send per +/// distinct incident (capped at <c>App.AlertPerEventMaxPerCycle</c> with a "+N more" trailer), +/// Summary mode (or no incidents) falling back to ONE combined send, and — like the old loop — +/// no numeric values on these two metrics; every other metric is the old direct +/// <see cref="EmailAlertService.TrySendAlertEmailAsync"/> call WITH the outcome's numerics.</item> +/// </list> +/// <see cref="EmailAlertService.TrySendAlertEmailAsync"/> keeps Lite's history-row semantics +/// unchanged: one combined <c>config_alert_log</c> row per send, written even when muted +/// (flagged muted, channels skipped). Never throws — a broken toast or send must not abort the +/// engine's sweep. Resolution ("Resolved/Cleared") toasts deliberately do NOT flow through here; +/// they ride the engine's resolution callback because Lite records no history row for them. +/// </summary> +public sealed class LiteAlertDeliverer : IAlertDeliverer +{ + /* Test seams — the production ctor wires the real tray/email/servers.json paths. */ + internal delegate void ShowToast(string title, string message, BalloonIcon icon, string serverName, string metricName); + internal delegate Task SendAlert( + string metricName, string serverName, string currentValue, string thresholdValue, + int serverId, AlertContext? context, double? numericCurrentValue, double? numericThresholdValue, + bool muted, string? detailText); + + private readonly ShowToast _showToast; + private readonly SendAlert _sendAlert; + private readonly Func<int, AlertNotificationMode?> _resolveServerDeliveryOverride; + + public LiteAlertDeliverer( + EmailAlertService emailAlertService, + MuteRuleService muteRuleService, + ServerManager serverManager, + Func<SystemTrayService?> trayService, + Dispatcher dispatcher) + { + ArgumentNullException.ThrowIfNull(emailAlertService); + ArgumentNullException.ThrowIfNull(muteRuleService); + ArgumentNullException.ThrowIfNull(serverManager); + ArgumentNullException.ThrowIfNull(trayService); + ArgumentNullException.ThrowIfNull(dispatcher); + + _showToast = (title, message, icon, serverName, metricName) => + { + var tray = trayService(); + if (tray == null) + { + return; + } + + /* The tray icon is a WPF element; the engine's continuations normally resume on the + dispatcher already (no ConfigureAwait(false) anywhere in the chain), but marshal + defensively so a pool-thread caller can't crash the balloon. */ + if (dispatcher.CheckAccess()) + { + tray.ShowSnoozableNotification(title, message, icon, serverName, metricName, muteRuleService); + } + else + { + dispatcher.Invoke(() => + tray.ShowSnoozableNotification(title, message, icon, serverName, metricName, muteRuleService)); + } + }; + + _sendAlert = (metricName, serverName, currentValue, thresholdValue, serverId, context, + numericCurrentValue, numericThresholdValue, muted, detailText) => + emailAlertService.TrySendAlertEmailAsync( + metricName, serverName, currentValue, thresholdValue, serverId, context, + numericCurrentValue: numericCurrentValue, numericThresholdValue: numericThresholdValue, + muted: muted, detailText: detailText); + + /* #1236: the per-server delivery-mode override for serverId, or null to inherit the global + App.AlertDeliveryMode. serverId is the deterministic hash of the storage name (the same + mapping the alert engine keys on), so map it back to the ServerConnection to read its + override — moved verbatim from the pre-forwarding MainWindow.ResolveServerDeliveryOverride. */ + _resolveServerDeliveryOverride = serverId => + { + foreach (var s in serverManager.GetAllServers()) + { + if (RemoteCollectorService.GetDeterministicHashCode(RemoteCollectorService.GetServerNameForStorage(s)) == serverId) + { + return s.AlertDeliveryModeOverride; + } + } + return null; + }; + } + + /// <summary>Test ctor: injected toast/send/override seams, no WPF or SMTP dependencies.</summary> + internal LiteAlertDeliverer( + ShowToast showToast, + SendAlert sendAlert, + Func<int, AlertNotificationMode?> resolveServerDeliveryOverride) + { + _showToast = showToast; + _sendAlert = sendAlert; + _resolveServerDeliveryOverride = resolveServerDeliveryOverride; + } + + public async Task DeliverAsync(AlertOutcome outcome, CancellationToken cancellationToken = default) + { + if (outcome is null) + { + throw new ArgumentNullException(nameof(outcome)); + } + + try + { + var serverId = int.Parse(outcome.ServerKey, CultureInfo.InvariantCulture); + + /* Toast BEFORE the send, matching the old loop's order in every branch. */ + if (!outcome.Muted) + { + _showToast( + outcome.MetricName, + $"{outcome.ServerName}: {outcome.ShortMessage}", + ToastIconFor(outcome.MetricName), + outcome.ServerName, + outcome.MetricName); + } + + /* Only blocking/deadlocks ever routed through SendDetectedAlertAsync's #1141 split in + the old loop; every other metric was a direct single send with its numerics. */ + if (outcome.MetricName is "Blocking Detected" or "Deadlocks Detected") + { + await SendDetectedAlertAsync( + outcome.MetricName, outcome.ServerName, outcome.CurrentValue, outcome.ThresholdValue, + serverId, outcome.Context, outcome.Muted, outcome.DetailText); + } + else + { + await _sendAlert( + outcome.MetricName, outcome.ServerName, outcome.CurrentValue, outcome.ThresholdValue, + serverId, outcome.Context, outcome.NumericCurrentValue, outcome.NumericThresholdValue, + outcome.Muted, outcome.DetailText); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + AppLogger.Error("Alerts", $"Alert delivery failed for {outcome.MetricName} on {outcome.ServerName}: {ex.Message}"); + } + } + + /// <summary> + /// The old loop's toast icon table: Error for the two data-corruption-adjacent conditions + /// (deadlocks, poison waits), Warning for everything else. + /// </summary> + internal static BalloonIcon ToastIconFor(string metricName) => + metricName is "Deadlocks Detected" or "Poison Wait" ? BalloonIcon.Error : BalloonIcon.Warning; + + /* #1141: in Per-event mode, deliver one notification per distinct incident (capped at + AlertPerEventMaxPerCycle, with a trailing "+N more" that still carries the remaining + fingerprints) instead of one batched summary card. Falls back to the single summary send in + Summary mode or when there are no incidents. The engine's edge-triggered gating already + decided whether to fire; this only shapes delivery. Moved verbatim from the pre-forwarding + MainWindow.SendDetectedAlertAsync. */ + private async Task SendDetectedAlertAsync( + string metricName, string serverName, string summaryCurrentValue, string thresholdValue, + int serverId, AlertContext? context, bool isMuted, string? summaryDetailText) + { + /* #1236: a per-server override (Manage Servers -> Edit) wins over the global delivery mode; + null inherits App.AlertDeliveryMode. */ + var deliveryMode = AlertDeliveryModeResolver.Resolve(_resolveServerDeliveryOverride(serverId), App.AlertDeliveryMode); + if (deliveryMode == AlertNotificationMode.PerEvent && context?.Incidents is { Count: > 0 }) + { + foreach (var msg in PerEventNotification.Split(context, App.AlertPerEventMaxPerCycle)) + { + await _sendAlert( + metricName, serverName, msg.CurrentValue, thresholdValue, serverId, + msg.Context, null, null, isMuted, AlertContextBuilders.ContextToDetailText(msg.Context)); + } + return; + } + + await _sendAlert( + metricName, serverName, summaryCurrentValue, thresholdValue, serverId, + context, null, null, isMuted, summaryDetailText); + } +} diff --git a/Lite/Services/LiteAlertReadAdapter.cs b/Lite/Services/LiteAlertReadAdapter.cs new file mode 100644 index 000000000..418f2dcba --- /dev/null +++ b/Lite/Services/LiteAlertReadAdapter.cs @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using PerformanceMonitor.Alerting; + +namespace PerformanceMonitorLite.Services; + +/// <summary> +/// Lite's <see cref="IAlertReadAdapter"/> (Phase-5 slice B): the alert loop's collected-store reads +/// behind the shared seam, wrapping the existing <see cref="LocalDataService"/> queries verbatim — +/// the SQL, windows, and LIMITs are unchanged; this class only adds the seam's client-side filters +/// that previously lived inline in <c>MainWindow.AlertEngine.cs</c> (the poison-wait threshold +/// FindAll and the long-running-query excluded-database Where). Every method keeps the loop's +/// <c>Task.Run</c> wrap so DuckDB.NET's synchronous I/O stays off the WPF dispatcher (#1202). +/// <para> +/// <c>serverKey</c> is Lite's deterministic storage-name hash rendered as a string (the same value +/// the alert loop already keys its state dictionaries on) — parsed back to the DuckDB +/// <c>server_id</c> int here. +/// </para> +/// </summary> +public sealed class LiteAlertReadAdapter : IAlertReadAdapter +{ + private readonly LocalDataService _dataService; + + public LiteAlertReadAdapter(LocalDataService dataService) + { + _dataService = dataService ?? throw new ArgumentNullException(nameof(dataService)); + } + + /// <summary> + /// XE blocked-process reports with the always-on DMV snapshot fallback merged in — + /// <see cref="LocalDataService.GetRecentBlockedProcessReportsAsync"/> already performs the + /// BPR-preferred merge via the shared <see cref="BlockedProcessReportMerge"/>. + /// </summary> + public async Task<List<BlockedProcessAlertRow>> GetRecentBlockedProcessReportsAsync( + string serverKey, int hoursBack, CancellationToken cancellationToken = default) + { + var serverId = ParseServerKey(serverKey); + var rows = await Task.Run(() => _dataService.GetRecentBlockedProcessReportsAsync(serverId, hoursBack), cancellationToken); + return new List<BlockedProcessAlertRow>(rows); + } + + public async Task<List<DeadlockAlertRow>> GetRecentDeadlocksAsync( + string serverKey, int hoursBack, CancellationToken cancellationToken = default) + { + var serverId = ParseServerKey(serverKey); + var rows = await Task.Run(() => _dataService.GetRecentDeadlocksAsync(serverId, hoursBack), cancellationToken); + return new List<DeadlockAlertRow>(rows); + } + + public async Task<List<PoisonWaitDelta>> GetPoisonWaitDeltasAsync( + string serverKey, double thresholdMs, CancellationToken cancellationToken = default) + { + var serverId = ParseServerKey(serverKey); + var poisonWaits = await Task.Run(() => _dataService.GetLatestPoisonWaitAvgsAsync(serverId), cancellationToken); + /* Fetch-then-filter, exactly like the pre-slice-B loop: the 3-row window is selected + before thresholding (see the IAlertReadAdapter contract). */ + return poisonWaits.FindAll(w => w.AvgMsPerWait >= thresholdMs); + } + + public async Task<List<LongRunningQueryInfo>> GetLongRunningQueriesAsync( + string serverKey, + int thresholdMinutes, + int maxResults, + bool excludeSpServerDiagnostics, + bool excludeWaitFor, + bool excludeBackups, + bool excludeMiscWaits, + bool excludeCdc, + IReadOnlyList<string> excludedDatabases, + CancellationToken cancellationToken = default) + { + var serverId = ParseServerKey(serverKey); + var longRunning = await Task.Run(() => _dataService.GetLongRunningQueriesAsync( + serverId, thresholdMinutes, maxResults, excludeSpServerDiagnostics, excludeWaitFor, + excludeBackups, excludeMiscWaits, excludeCdc), cancellationToken); + + if (excludedDatabases is { Count: > 0 }) + { + longRunning = longRunning + .Where(q => string.IsNullOrEmpty(q.DatabaseName) || + !excludedDatabases.Any(e => + string.Equals(e, q.DatabaseName, StringComparison.OrdinalIgnoreCase))) + .ToList(); + } + + return longRunning; + } + + public async Task<List<VolumeFreeSpaceInfo>> GetVolumeFreeSpaceAsync( + string serverKey, CancellationToken cancellationToken = default) + { + var serverId = ParseServerKey(serverKey); + return await Task.Run(() => _dataService.GetVolumeFreeSpaceAsync(serverId), cancellationToken); + } + + public async Task<TempDbSpaceInfo?> GetTempDbSpaceAsync( + string serverKey, CancellationToken cancellationToken = default) + { + var serverId = ParseServerKey(serverKey); + return await Task.Run(() => _dataService.GetLatestTempDbSpaceAsync(serverId), cancellationToken); + } + + public async Task<List<AnomalousJobInfo>> GetAnomalousJobsAsync( + string serverKey, int multiplier, CancellationToken cancellationToken = default) + { + var serverId = ParseServerKey(serverKey); + return await Task.Run(() => _dataService.GetAnomalousJobsAsync(serverId, multiplier), cancellationToken); + } + + private static int ParseServerKey(string serverKey) => + int.Parse(serverKey, CultureInfo.InvariantCulture); +} diff --git a/Lite/Services/LiteAlertStateStore.cs b/Lite/Services/LiteAlertStateStore.cs new file mode 100644 index 000000000..b8e65fd56 --- /dev/null +++ b/Lite/Services/LiteAlertStateStore.cs @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Globalization; +using System.Threading.Tasks; +using PerformanceMonitor.Alerting; + +namespace PerformanceMonitorLite.Services; + +/// <summary> +/// Lite's <see cref="IAlertStateStore"/> (Phase-5 forwarding): the shared engine's restart-surviving +/// watermarks (#1145) over the existing <see cref="DuckDbAlertHistoryStore"/> watermark methods — +/// same <c>config_edge_trigger_watermarks</c> rows, same INSERT OR REPLACE upserts, so watermarks +/// persisted by the pre-forwarding loop seed the engine unchanged across the upgrade. +/// <para> +/// THREADING: the engine is invoked on the WPF dispatcher (the 30-second overview sweep), and +/// DuckDB.NET's I/O is synchronous under its async facade — so every store call is wrapped in +/// <c>Task.Run</c>, exactly like <see cref="LiteAlertReadAdapter"/>, keeping the #1202 hitch class +/// off the UI thread. +/// </para> +/// <para> +/// LOADS are per-key (the engine seeds each server once, before its first sweep — the per-key twin +/// of the deleted bulk <c>SeedEdgeTriggerWatermarksAsync</c>), served by filtering the store's +/// existing bulk reads: the watermark table holds at most a few rows per server, each server is +/// seeded once per process, and reusing the existing store methods means no new SQL surface. +/// Restored-watermark behavior is identical — the engine loads before first use per key, so a +/// restart cannot re-alert events still lingering in the rolling window. +/// </para> +/// <para> +/// <c>serverKey</c> is Lite's deterministic storage-name hash rendered as a string (the same value +/// the read adapter parses) — mapped back to the DuckDB <c>server_id</c> int here. +/// </para> +/// </summary> +public sealed class LiteAlertStateStore : IAlertStateStore +{ + private readonly DuckDbAlertHistoryStore _store; + + public LiteAlertStateStore(DuckDbAlertHistoryStore store) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + } + + public Task<int?> LoadEdgeTriggerWatermarkAsync(string serverKey, string metricName) + { + var serverId = ParseServerKey(serverKey); + return Task.Run(async () => + { + var rows = await _store.LoadEdgeTriggerWatermarksAsync(); + foreach (var (rowServerId, rowMetric, watermark) in rows) + { + if (rowServerId == serverId && rowMetric == metricName) + { + return (int?)watermark; + } + } + return null; + }); + } + + public Task SaveEdgeTriggerWatermarkAsync(string serverKey, string metricName, int watermark) + { + var serverId = ParseServerKey(serverKey); + return Task.Run(() => _store.SaveEdgeTriggerWatermarkAsync(serverId, metricName, watermark)); + } + + public Task<DateTime?> LoadFailedJobWatermarkAsync(string serverKey) + { + var serverId = ParseServerKey(serverKey); + return Task.Run(async () => + { + var rows = await _store.LoadFailedJobWatermarksAsync(); + foreach (var (rowServerId, watermark) in rows) + { + if (rowServerId == serverId) + { + /* Server-local basis preserved end-to-end — see the store's doc comment. */ + return (DateTime?)watermark; + } + } + return null; + }); + } + + public Task SaveFailedJobWatermarkAsync(string serverKey, DateTime watermark) + { + var serverId = ParseServerKey(serverKey); + return Task.Run(() => _store.SaveFailedJobWatermarkAsync(serverId, watermark)); + } + + private static int ParseServerKey(string serverKey) => + int.Parse(serverKey, CultureInfo.InvariantCulture); +} diff --git a/Lite/Services/LocalDataService.AlertHistory.cs b/Lite/Services/LocalDataService.AlertHistory.cs index 09fe3ee64..aef0b635b 100644 --- a/Lite/Services/LocalDataService.AlertHistory.cs +++ b/Lite/Services/LocalDataService.AlertHistory.cs @@ -439,7 +439,7 @@ Lite alert engine emits (MainWindow.AlertEngine.cs), keyed on the exact metric_n private static string FormatValue(string metricName, double value) => metricName switch { /* Percent metrics — CPU/TempDB usage, free space, and the job's "% of average". */ - "High CPU" or "TempDB Space" or "Volume Free Space" or "Long-Running Job" => $"{value:F1}%", + "High CPU" or "tempdb Space" or "Volume Free Space" or "Long-Running Job" => $"{value:F1}%", /* Poison wait carries an average ms/wait; long-running query carries elapsed minutes. */ "Poison Wait" => $"{value:F0} ms", diff --git a/Lite/Services/LocalDataService.Baselines.cs b/Lite/Services/LocalDataService.Baselines.cs index dca6c1738..ac010ce7f 100644 --- a/Lite/Services/LocalDataService.Baselines.cs +++ b/Lite/Services/LocalDataService.Baselines.cs @@ -7,6 +7,7 @@ */ using PerformanceMonitor.Analysis; +using PerformanceMonitor.Analysis.Baselines; using PerformanceMonitorLite.Analysis; namespace PerformanceMonitorLite.Services; diff --git a/Lite/Services/LocalDataService.Blocking.cs b/Lite/Services/LocalDataService.Blocking.cs index 6e9dd8430..0db83f31e 100644 --- a/Lite/Services/LocalDataService.Blocking.cs +++ b/Lite/Services/LocalDataService.Blocking.cs @@ -11,9 +11,11 @@ using System.Linq; using System.Threading.Tasks; using DuckDB.NET.Data; +using PerformanceMonitor.Alerting; using PerformanceMonitor.Analysis; using PerformanceMonitor.Ui; using PerformanceMonitor.Common; +using static PerformanceMonitor.Common.DeadlockGraphProcessParser; namespace PerformanceMonitorLite.Services; @@ -69,12 +71,13 @@ ORDER BY deadlock_time DESC /// The metric column is determined by the caller's sort preference. /// </summary> public async Task<List<TimeSliceBucket>> GetActiveQuerySlicerDataAsync( - int serverId, int hoursBack, DateTime? fromDate = null, DateTime? toDate = null) + int serverId, int hoursBack, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList<string>? databaseNames = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); command.CommandText = @" SELECT @@ -88,13 +91,15 @@ public async Task<List<TimeSliceBucket>> GetActiveQuerySlicerDataAsync( FROM v_query_snapshots WHERE server_id = $1 AND collection_time >= $2 -AND collection_time <= $3 +AND collection_time <= $3" + dbClause + @" GROUP BY date_trunc('hour', collection_time) ORDER BY bucket"; command.Parameters.Add(new DuckDBParameter { Value = serverId }); command.Parameters.Add(new DuckDBParameter { Value = startTime }); command.Parameters.Add(new DuckDBParameter { Value = endTime }); + foreach (var db in dbValues) + command.Parameters.Add(new DuckDBParameter { Value = db }); var items = new List<TimeSliceBucket>(); using var reader = await command.ExecuteReaderAsync(); @@ -119,13 +124,14 @@ GROUP BY date_trunc('hour', collection_time) /// <summary> /// Gets query snapshots (currently running queries) for a server. /// </summary> - public async Task<List<QuerySnapshotRow>> GetLatestQuerySnapshotsAsync(int serverId, int hoursBack = 4, DateTime? fromDate = null, DateTime? toDate = null) + public async Task<List<QuerySnapshotRow>> GetLatestQuerySnapshotsAsync(int serverId, int hoursBack = 4, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList<string>? databaseNames = null) { using var _q = TimeQuery("GetLatestQuerySnapshotsAsync", "v_query_snapshots latest"); using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); command.CommandText = @" SELECT @@ -154,17 +160,20 @@ public async Task<List<QuerySnapshotRow>> GetLatestQuerySnapshotsAsync(int serve host_name, program_name, open_transaction_count, - percent_complete + percent_complete, + query_hash FROM v_query_snapshots WHERE server_id = $1 AND collection_time >= $2 -AND collection_time <= $3 +AND collection_time <= $3" + dbClause + @" AND query_text NOT LIKE 'WAITFOR%' ORDER BY collection_time DESC, cpu_time_ms DESC"; command.Parameters.Add(new DuckDBParameter { Value = serverId }); command.Parameters.Add(new DuckDBParameter { Value = startTime }); command.Parameters.Add(new DuckDBParameter { Value = endTime }); + foreach (var db in dbValues) + command.Parameters.Add(new DuckDBParameter { Value = db }); var items = new List<QuerySnapshotRow>(); using var reader = await command.ExecuteReaderAsync(); @@ -197,7 +206,8 @@ AND query_text NOT LIKE 'WAITFOR%' HostName = reader.IsDBNull(22) ? "" : reader.GetString(22), ProgramName = reader.IsDBNull(23) ? "" : reader.GetString(23), OpenTransactionCount = reader.IsDBNull(24) ? 0 : reader.GetInt32(24), - PercentComplete = reader.IsDBNull(25) ? 0m : Convert.ToDecimal(reader.GetValue(25)) + PercentComplete = reader.IsDBNull(25) ? 0m : Convert.ToDecimal(reader.GetValue(25)), + QueryHash = reader.IsDBNull(26) ? "" : reader.GetString(26) }); } @@ -254,12 +264,13 @@ SELECT MAX(deadlock_time) AS t FROM v_deadlocks /// <summary> /// Gets recent blocked process reports from the XE-based collector. /// </summary> - public async Task<List<BlockedProcessReportRow>> GetRecentBlockedProcessReportsAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task<List<BlockedProcessReportRow>> GetRecentBlockedProcessReportsAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList<string>? databaseNames = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); command.CommandText = @" SELECT @@ -303,13 +314,15 @@ public async Task<List<BlockedProcessReportRow>> GetRecentBlockedProcessReportsA FROM v_blocked_process_reports WHERE server_id = $1 AND collection_time >= $2 -AND collection_time <= $3 +AND collection_time <= $3" + dbClause + @" ORDER BY event_time DESC LIMIT 200"; command.Parameters.Add(new DuckDBParameter { Value = serverId }); command.Parameters.Add(new DuckDBParameter { Value = startTime }); command.Parameters.Add(new DuckDBParameter { Value = endTime }); + foreach (var db in dbValues) + command.Parameters.Add(new DuckDBParameter { Value = db }); var items = new List<BlockedProcessReportRow>(); using (var reader = await command.ExecuteReaderAsync()) @@ -361,7 +374,7 @@ ORDER BY event_time DESC // Always-on DMV blocking snapshot: surface its rows in the grid too, so the block-chain viewer is // reachable when the blocked-process-report XE captured nothing (AWS RDS). Same connection/lock. - await AppendDmvBlockedProcessGridRowsAsync(connection.CreateCommand, items, serverId, startTime, endTime); + await AppendDmvBlockedProcessGridRowsAsync(connection.CreateCommand, items, serverId, startTime, endTime, databaseNames); return items; } @@ -373,10 +386,11 @@ ORDER BY event_time DESC /// can't be opened). v_dmv_blocking_snapshots is created by DuckDbInitializer, so it always exists. /// </summary> private static async Task AppendDmvBlockedProcessGridRowsAsync( - Func<DuckDBCommand> createCommand, List<BlockedProcessReportRow> items, int serverId, DateTime startTime, DateTime endTime) + Func<DuckDBCommand> createCommand, List<BlockedProcessReportRow> items, int serverId, DateTime startTime, DateTime endTime, IReadOnlyList<string>? databaseNames = null) { const int gridCap = 200; var dmvItems = new List<BlockedProcessReportRow>(); + var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); using (var command = createCommand()) { command.CommandText = @" @@ -386,14 +400,17 @@ private static async Task AppendDmvBlockedProcessGridRowsAsync( wait_time_ms, lock_mode, blocking_status, contentious_object, blocked_sql_text, blocking_sql_text, blocked_login_name, blocked_host_name, blocked_client_app, - blocked_last_tran_started, blocking_last_tran_started, monitor_loop + blocked_last_tran_started, blocking_last_tran_started, monitor_loop, + blocking_login_name, blocking_host_name, blocking_client_app FROM v_dmv_blocking_snapshots -WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 +WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3" + dbClause + @" ORDER BY event_time DESC LIMIT 200"; command.Parameters.Add(new DuckDBParameter { Value = serverId }); command.Parameters.Add(new DuckDBParameter { Value = startTime }); command.Parameters.Add(new DuckDBParameter { Value = endTime }); + foreach (var db in dbValues) + command.Parameters.Add(new DuckDBParameter { Value = db }); using var reader = await command.ExecuteReaderAsync(); while (await reader.ReadAsync()) @@ -419,32 +436,19 @@ ORDER BY event_time DESC BlockedLastTranStarted = reader.IsDBNull(16) ? null : reader.GetDateTime(16), BlockingLastTranStarted = reader.IsDBNull(17) ? null : reader.GetDateTime(17), MonitorLoop = reader.IsDBNull(18) ? (int?)null : reader.GetInt32(18), - Source = "DMV snapshot" + /* Blocking-side identity — the DMV snapshot is the only blocking source on AWS RDS / when the + BPR threshold is unset, so surface it here too (the XE path already sets these). */ + BlockingLoginName = reader.IsDBNull(19) ? "" : reader.GetString(19), + BlockingHostName = reader.IsDBNull(20) ? "" : reader.GetString(20), + BlockingClientApp = reader.IsDBNull(21) ? "" : reader.GetString(21), + Source = BlockedProcessAlertRow.DmvSnapshotSource }); } } - if (dmvItems.Count == 0) return; - - // Dedup: keep all BPR rows; add a DMV row only if no BPR row covers the same (blocked, blocker) - // SPID within the same minute. BPR is preferred (richer report XML). - var seen = new HashSet<(int, int, long)>(); - foreach (var b in items) - if (b.EventTime.HasValue) - seen.Add((b.BlockedSpid, b.BlockingSpid, b.EventTime.Value.Ticks / TimeSpan.TicksPerMinute)); - - foreach (var d in dmvItems) - { - var key = (d.BlockedSpid, d.BlockingSpid, (d.EventTime?.Ticks ?? 0) / TimeSpan.TicksPerMinute); - if (seen.Add(key)) - items.Add(d); - } - - // Re-cap to the grid's LIMIT, newest first. OrderByDescending is stable, so BPR rows (added - // first) win ties over DMV rows at the same event time — matching the Dashboard grid. - var merged = items.OrderByDescending(i => i.EventTime ?? DateTime.MinValue).Take(gridCap).ToList(); - items.Clear(); - items.AddRange(merged); + /* Dedup + re-cap moved verbatim to the shared BlockedProcessReportMerge (Phase-5 slice B) + so the Darling Postgres adapter reproduces EXACTLY these XE-preferred fallback semantics. */ + BlockedProcessReportMerge.AppendDmvFallbackRows(items, dmvItems, gridCap); } /// <summary> @@ -496,11 +500,12 @@ await PerformanceMonitorLite.Analysis.BlockingPairRowQuery.AppendDmvSnapshotRows /// Gets hourly-bucketed metrics from blocked process reports for the time-range slicer. /// </summary> public async Task<List<TimeSliceBucket>> GetBlockingSlicerDataAsync( - int serverId, int hoursBack, DateTime? fromDate = null, DateTime? toDate = null) + int serverId, int hoursBack, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList<string>? databaseNames = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); /* BPR buckets, falling back to the always-on DMV snapshot only when BPR has no buckets in the window (AWS RDS) — so a server with both sources never double-counts. */ @@ -514,7 +519,7 @@ WITH bpr AS ( COUNT(DISTINCT blocked_spid) AS distinct_blocked, COUNT(DISTINCT database_name) AS distinct_databases FROM v_blocked_process_reports - WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 + WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3" + dbClause + @" GROUP BY date_trunc('hour', collection_time) ), dmv AS ( @@ -526,7 +531,7 @@ dmv AS ( COUNT(DISTINCT blocked_spid) AS distinct_blocked, COUNT(DISTINCT database_name) AS distinct_databases FROM v_dmv_blocking_snapshots - WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 + WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3" + dbClause + @" GROUP BY date_trunc('hour', collection_time) ) SELECT bucket, event_count, total_wait_sec, distinct_blockers, distinct_blocked, distinct_databases FROM bpr @@ -538,6 +543,8 @@ WHERE NOT EXISTS (SELECT 1 FROM bpr) command.Parameters.Add(new DuckDBParameter { Value = serverId }); command.Parameters.Add(new DuckDBParameter { Value = startTime }); command.Parameters.Add(new DuckDBParameter { Value = endTime }); + foreach (var db in dbValues) + command.Parameters.Add(new DuckDBParameter { Value = db }); var items = new List<TimeSliceBucket>(); using var reader = await command.ExecuteReaderAsync(); @@ -605,12 +612,13 @@ GROUP BY date_trunc('hour', collection_time) /// Uses blocked_process_reports from Extended Events for more reliable detection. /// Falls back to blocking_snapshots if no XE data available. /// </summary> - public async Task<List<TrendPoint>> GetBlockingTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task<List<TrendPoint>> GetBlockingTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList<string>? databaseNames = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); /* Use blocked_process_reports from XE session - more reliable than point-in-time snapshots Group by event_time (when blocking actually occurred) rather than collection_time */ @@ -620,13 +628,13 @@ Group by event_time (when blocking actually occurred) rather than collection_tim WITH bpr AS ( SELECT DATE_TRUNC('minute', event_time) AS bucket, COUNT(*) AS incident_count FROM v_blocked_process_reports - WHERE server_id = $1 AND event_time >= $2 AND event_time <= $3 + WHERE server_id = $1 AND event_time >= $2 AND event_time <= $3" + dbClause + @" GROUP BY DATE_TRUNC('minute', event_time) ), dmv AS ( SELECT DATE_TRUNC('minute', event_time) AS bucket, COUNT(*) AS incident_count FROM v_dmv_blocking_snapshots - WHERE server_id = $1 AND event_time >= $2 AND event_time <= $3 + WHERE server_id = $1 AND event_time >= $2 AND event_time <= $3" + dbClause + @" GROUP BY DATE_TRUNC('minute', event_time) ) SELECT bucket, incident_count FROM bpr @@ -637,6 +645,8 @@ UNION ALL command.Parameters.Add(new DuckDBParameter { Value = serverId }); command.Parameters.Add(new DuckDBParameter { Value = startTime }); command.Parameters.Add(new DuckDBParameter { Value = endTime }); + foreach (var db in dbValues) + command.Parameters.Add(new DuckDBParameter { Value = db }); var items = new List<TrendPoint>(); using var reader = await command.ExecuteReaderAsync(); @@ -711,7 +721,7 @@ WITH raw AS collection_time, wait_type, delta_wait_time_ms, - date_diff('second', LAG(collection_time) OVER (PARTITION BY wait_type ORDER BY collection_time), collection_time) AS interval_seconds + extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (PARTITION BY wait_type ORDER BY collection_time)))) AS interval_seconds FROM v_wait_stats WHERE server_id = $1 AND wait_type LIKE 'LCK%' @@ -721,7 +731,7 @@ AND wait_type LIKE 'LCK%' SELECT collection_time, wait_type, - CASE WHEN interval_seconds > 0 THEN CAST(delta_wait_time_ms AS DOUBLE) / interval_seconds ELSE 0 END AS wait_time_ms_per_second + CASE WHEN interval_seconds > 0 THEN CAST(delta_wait_time_ms AS DOUBLE PRECISION) / interval_seconds ELSE 0 END AS wait_time_ms_per_second FROM raw WHERE delta_wait_time_ms >= 0 ORDER BY collection_time, wait_type"; @@ -758,260 +768,56 @@ public class TrendPoint public int Count { get; set; } } -public class DeadlockRow +/// <summary> +/// Lite's deadlock grid row. The alert-consumed members (victim process/SQL, graph XML, the parsed +/// <c>ProcessSummary</c>) live on the shared <see cref="DeadlockAlertRow"/> base (Phase-5 slice B) +/// so store reads flow into the shared alert builders without a mapping copy; only the +/// grid-display extras stay here. +/// </summary> +public class DeadlockRow : DeadlockAlertRow { public DateTime CollectionTime { get; set; } public DateTime? DeadlockTime { get; set; } - public string VictimProcessId { get; set; } = ""; - public string VictimSqlText { get; set; } = ""; - public string DeadlockGraphXml { get; set; } = ""; - public bool HasDeadlockXml => !string.IsNullOrEmpty(DeadlockGraphXml); - - /// <summary> - /// Parses the deadlock graph XML and returns a summary of all processes involved. - /// </summary> - public string ProcessSummary - { - get - { - if (string.IsNullOrEmpty(DeadlockGraphXml)) - { - return ""; - } - - try - { - var doc = System.Xml.Linq.XElement.Parse(DeadlockGraphXml); - var processes = doc.Descendants("process"); - var summaries = new System.Collections.Generic.List<string>(); - - foreach (var proc in processes) - { - var id = proc.Attribute("id")?.Value ?? "?"; - var spid = proc.Attribute("spid")?.Value ?? "?"; - var db = proc.Attribute("currentdb")?.Value ?? ""; - var isVictim = string.Equals(id, VictimProcessId, StringComparison.OrdinalIgnoreCase); - summaries.Add($"SPID {spid}{(isVictim ? " (victim)" : "")}"); - } - - return string.Join(" vs ", summaries); - } - catch - { - return ""; - } - } - } } -public class DeadlockProcessDetail +/// <summary> +/// Lite's Deadlocks-grid row — one parsed process per deadlock graph. The raw parsed fields and the +/// sp_BlitzLock-style graph walk now live once on the shared <see cref="DeadlockProcessInfo"/> / +/// <see cref="DeadlockGraphProcessParser"/> (Common); only Lite's per-server display getters +/// (<see cref="ServerTimeHelper"/>) stay here. +/// </summary> +public class DeadlockProcessDetail : DeadlockProcessInfo { - public DateTime? DeadlockTime { get; set; } - public bool IsVictim { get; set; } - public string ProcessId { get; set; } = ""; - public int Spid { get; set; } - public string DatabaseName { get; set; } = ""; - public string SqlText { get; set; } = ""; - public string WaitResource { get; set; } = ""; - public long WaitTime { get; set; } - public string LockMode { get; set; } = ""; - public string IsolationLevel { get; set; } = ""; - public long LogUsed { get; set; } - public int TransactionCount { get; set; } - public string ClientApp { get; set; } = ""; - public string HostName { get; set; } = ""; - public string LoginName { get; set; } = ""; - public string Status { get; set; } = ""; - public string DeadlockGraphXml { get; set; } = ""; - public bool HasDeadlockXml => !string.IsNullOrEmpty(DeadlockGraphXml); - - /* New fields from sp_BlitzLock analysis */ - public string DeadlockType { get; set; } = ""; - public string ObjectNames { get; set; } = ""; - public string ProcName { get; set; } = ""; - public string OwnerMode { get; set; } = ""; - public string WaiterMode { get; set; } = ""; - public string TransactionName { get; set; } = ""; - public int Priority { get; set; } - public DateTime? LastTranStarted { get; set; } - public DateTime? LastBatchStarted { get; set; } - public DateTime? LastBatchCompleted { get; set; } - public string DeadlockTimeLocal => ServerTimeHelper.FormatServerTime(DeadlockTime); public string VictimDisplay => IsVictim ? "Victim" : ""; public string WaitTimeFormatted => WaitTime > 0 ? $"{WaitTime:N0} ms" : ""; public string LastTranStartedLocal => ServerTimeHelper.FormatServerTime(LastTranStarted); /// <summary> - /// Parses a list of DeadlockRow into per-process detail rows. + /// Parses a list of <see cref="DeadlockRow"/> into per-process detail rows via the shared + /// <see cref="DeadlockGraphProcessParser"/> (the sp_BlitzLock-style graph walk, de-duplicated with the + /// Darling viewer). Lite never captures a victim plan, so the plan input is always null. /// </summary> public static List<DeadlockProcessDetail> ParseFromRows(List<DeadlockRow> rows) - { - var details = new List<DeadlockProcessDetail>(); - foreach (var row in rows) - { - if (string.IsNullOrEmpty(row.DeadlockGraphXml)) - continue; - - try - { - var doc = System.Xml.Linq.XElement.Parse(row.DeadlockGraphXml); - - /* Detect parallel deadlock */ - var resourceList = doc.Descendants("resource-list").FirstOrDefault(); - var isParallel = resourceList != null && - (resourceList.Elements("exchangeEvent").Any() || resourceList.Elements("SyncPoint").Any()); - var deadlockType = isParallel ? "Parallel" : "Regular"; - - /* Get victim IDs from victim-list */ - var victimIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase); - foreach (var vp in doc.Descendants("victimProcess")) - { - var id = vp.Attribute("id")?.Value; - if (id != null) victimIds.Add(id); - } - - /* Parse lock resources to build per-process owner/waiter modes and object names */ - var processOwnerModes = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase); - var processWaiterModes = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase); - var processObjectNames = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase); - - if (resourceList != null) - { - var lockTypes = new[] { "objectlock", "pagelock", "keylock", "ridlock", "rowgrouplock" }; - foreach (var lockType in lockTypes) - { - foreach (var lockNode in resourceList.Elements(lockType)) - { - var objectName = lockNode.Attribute("objectname")?.Value ?? ""; - - /* Parse owners */ - foreach (var owner in lockNode.Descendants("owner")) - { - var ownerId = owner.Attribute("id")?.Value ?? ""; - var ownerMode = owner.Attribute("mode")?.Value ?? ""; - if (!string.IsNullOrEmpty(ownerId) && !string.IsNullOrEmpty(ownerMode)) - { - if (!processOwnerModes.ContainsKey(ownerId)) - processOwnerModes[ownerId] = new HashSet<string>(); - processOwnerModes[ownerId].Add(ownerMode); - } - if (!string.IsNullOrEmpty(ownerId) && !string.IsNullOrEmpty(objectName)) - { - if (!processObjectNames.ContainsKey(ownerId)) - processObjectNames[ownerId] = new HashSet<string>(); - processObjectNames[ownerId].Add(objectName); - } - } - - /* Parse waiters */ - foreach (var waiter in lockNode.Descendants("waiter")) - { - var waiterId = waiter.Attribute("id")?.Value ?? ""; - var waiterMode = waiter.Attribute("mode")?.Value ?? ""; - if (!string.IsNullOrEmpty(waiterId) && !string.IsNullOrEmpty(waiterMode)) - { - if (!processWaiterModes.ContainsKey(waiterId)) - processWaiterModes[waiterId] = new HashSet<string>(); - processWaiterModes[waiterId].Add(waiterMode); - } - if (!string.IsNullOrEmpty(waiterId) && !string.IsNullOrEmpty(objectName)) - { - if (!processObjectNames.ContainsKey(waiterId)) - processObjectNames[waiterId] = new HashSet<string>(); - processObjectNames[waiterId].Add(objectName); - } - } - } - } - } - - /* Parse each process */ - foreach (var proc in doc.Descendants("process")) - { - var id = proc.Attribute("id")?.Value ?? ""; - - /* Get proc name from execution stack */ - var procName = ""; - foreach (var frame in proc.Descendants("frame")) - { - var frameProcName = frame.Attribute("procname")?.Value ?? ""; - if (!string.IsNullOrEmpty(frameProcName) && frameProcName != "adhoc" && frameProcName != "unknown") - { - procName = frameProcName; - break; - } - } - - details.Add(new DeadlockProcessDetail - { - DeadlockTime = row.DeadlockTime, - ProcessId = id, - IsVictim = victimIds.Contains(id), - Spid = int.TryParse(proc.Attribute("spid")?.Value, out var spid) ? spid : 0, - DatabaseName = proc.Attribute("currentdbname")?.Value ?? "", - SqlText = proc.Element("inputbuf")?.Value?.Trim() ?? "", - WaitResource = proc.Attribute("waitresource")?.Value ?? "", - WaitTime = long.TryParse(proc.Attribute("waittime")?.Value, out var wt) ? wt : 0, - LockMode = proc.Attribute("lockMode")?.Value ?? "", - IsolationLevel = proc.Attribute("isolationlevel")?.Value ?? "", - LogUsed = long.TryParse(proc.Attribute("logused")?.Value, out var lu) ? lu : 0, - TransactionCount = int.TryParse(proc.Attribute("trancount")?.Value, out var tc) ? tc : 0, - ClientApp = proc.Attribute("clientapp")?.Value ?? "", - HostName = proc.Attribute("hostname")?.Value ?? "", - LoginName = proc.Attribute("loginname")?.Value ?? "", - Status = proc.Attribute("status")?.Value ?? "", - DeadlockGraphXml = row.DeadlockGraphXml, - DeadlockType = deadlockType, - ProcName = procName, - TransactionName = proc.Attribute("transactionname")?.Value ?? "", - Priority = int.TryParse(proc.Attribute("priority")?.Value, out var pri) ? pri : 0, - LastTranStarted = DateTime.TryParse(proc.Attribute("lasttranstarted")?.Value, out var lts) ? lts : null, - LastBatchStarted = DateTime.TryParse(proc.Attribute("lastbatchstarted")?.Value, out var lbs) ? lbs : null, - LastBatchCompleted = DateTime.TryParse(proc.Attribute("lastbatchcompleted")?.Value, out var lbc) ? lbc : null, - OwnerMode = processOwnerModes.TryGetValue(id, out var om) ? string.Join(", ", om) : "", - WaiterMode = processWaiterModes.TryGetValue(id, out var wm) ? string.Join(", ", wm) : "", - ObjectNames = processObjectNames.TryGetValue(id, out var on) ? string.Join(", ", on) : "" - }); - } - } - catch - { - /* If XML parsing fails, add a single fallback row */ - details.Add(new DeadlockProcessDetail - { - DeadlockTime = row.DeadlockTime, - SqlText = row.VictimSqlText, - IsVictim = true, - DeadlockGraphXml = row.DeadlockGraphXml - }); - } - } - return details; - } + => DeadlockGraphProcessParser.Parse<DeadlockProcessDetail>( + rows.Select(r => new DeadlockGraphInput(r.DeadlockGraphXml, r.DeadlockTime, r.VictimSqlText, null))).ToList(); } -public class BlockedProcessReportRow +/// <summary> +/// Lite's blocked-process grid row. The alert-consumed members (event time, database, SPID pair, +/// wait/lock, the query pair, report XML, contentious object, Source) live on the shared +/// <see cref="BlockedProcessAlertRow"/> base (Phase-5 slice B) so store reads flow into the shared +/// alert builders and the XE→DMV fallback merge without a mapping copy; only the grid-display +/// extras stay here. +/// </summary> +public class BlockedProcessReportRow : BlockedProcessAlertRow { public DateTime CollectionTime { get; set; } - public DateTime? EventTime { get; set; } - public string DatabaseName { get; set; } = ""; - public int BlockedSpid { get; set; } public int BlockedEcid { get; set; } - public int BlockingSpid { get; set; } public int BlockingEcid { get; set; } public int? MonitorLoop { get; set; } - /// <summary> - /// Where this row came from: the blocked-process-report XE ("blocked-process-report") or the always-on - /// DMV snapshot fallback ("DMV snapshot"). Surfaced as a grid badge so a row captured when the - /// blocked-process threshold was unset (e.g. AWS RDS) is distinguishable. - /// </summary> - public string Source { get; set; } = "blocked-process-report"; - - public long WaitTimeMs { get; set; } public string WaitResource { get; set; } = ""; - public string LockMode { get; set; } = ""; public string BlockedStatus { get; set; } = ""; public string BlockedIsolationLevel { get; set; } = ""; public long BlockedLogUsed { get; set; } @@ -1019,15 +825,11 @@ public class BlockedProcessReportRow public string BlockedClientApp { get; set; } = ""; public string BlockedHostName { get; set; } = ""; public string BlockedLoginName { get; set; } = ""; - public string BlockedSqlText { get; set; } = ""; public string BlockingStatus { get; set; } = ""; public string BlockingIsolationLevel { get; set; } = ""; public string BlockingClientApp { get; set; } = ""; public string BlockingHostName { get; set; } = ""; public string BlockingLoginName { get; set; } = ""; - public string BlockingSqlText { get; set; } = ""; - public string BlockedProcessReportXml { get; set; } = ""; - public string ContentiousObject { get; set; } = ""; public string BlockedTransactionName { get; set; } = ""; public string BlockingTransactionName { get; set; } = ""; public DateTime? BlockedLastTranStarted { get; set; } @@ -1041,7 +843,6 @@ public class BlockedProcessReportRow public string EventTimeLocal => ServerTimeHelper.FormatServerTime(EventTime); public string WaitTimeFormatted => WaitTimeMs < 1000 ? $"{WaitTimeMs} ms" : $"{WaitTimeMs / 1000.0:F1} sec"; - public bool HasReportXml => !string.IsNullOrEmpty(BlockedProcessReportXml); public bool IsLongBlock => WaitTimeMs > 30000; public string BlockedLastTranStartedLocal => ServerTimeHelper.FormatServerTime(BlockedLastTranStarted); public string BlockedLastBatchStartedLocal => ServerTimeHelper.FormatServerTime(BlockedLastBatchStarted); @@ -1050,6 +851,11 @@ public class BlockedProcessReportRow public class QuerySnapshotRow { + /// <summary>Gates "Get Actual Plan (re-run)" in the query grids' plan menu — the re-run executes this row's + /// query text (ServerTab.Plans.cs). Row types the re-run handler has no case for simply omit this property, + /// so the menu item's binding falls back to Collapsed and the verb is hidden rather than silently no-op.</summary> + public bool CanGetActualPlan => !string.IsNullOrEmpty(QueryText); + public int SessionId { get; set; } public string DatabaseName { get; set; } = ""; public string ElapsedTimeFormatted { get; set; } = ""; @@ -1076,11 +882,27 @@ public class QuerySnapshotRow public string ProgramName { get; set; } = ""; public int OpenTransactionCount { get; set; } public decimal PercentComplete { get; set; } + public string QueryHash { get; set; } = ""; public bool HasQueryPlan => !string.IsNullOrEmpty(QueryPlan); public bool HasLiveQueryPlan => !string.IsNullOrEmpty(LiveQueryPlan); public string CollectionTimeLocal => CollectionTime == DateTime.MinValue ? "" : ServerTimeHelper.FormatServerTime(CollectionTime); + // Sessions this session is blocking at the same collection_time (SQL-derived in the + // snapshot queries; Chain mode overwrites it with the chain walker's transitive count) + public int BlockedSessionCount { get; set; } + + // Wait-drilldown triage columns collected from dm_exec_query_memory_grants, + // session/task tempdb space usage, and dm_tran_* (schema v34) + public double RequestedMemoryMb { get; set; } + public double UsedMemoryMb { get; set; } + public double MaxUsedMemoryMb { get; set; } + public double TempdbCurrentMb { get; set; } + public double TempdbAllocationsMb { get; set; } + public double TranLogUsedMb { get; set; } + public DateTime? TranStartTime { get; set; } + public int RequestId { get; set; } + public string TranStartTimeLocal => ServerTimeHelper.FormatServerTime(TranStartTime); + // Chain mode — set by WaitDrillDownWindow when showing head blockers - public int ChainBlockedCount { get; set; } public string ChainBlockingPath { get; set; } = ""; } diff --git a/Lite/Services/LocalDataService.BlockingStats.cs b/Lite/Services/LocalDataService.BlockingStats.cs new file mode 100644 index 000000000..902c43b82 --- /dev/null +++ b/Lite/Services/LocalDataService.BlockingStats.cs @@ -0,0 +1,231 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using DuckDB.NET.Data; + +namespace PerformanceMonitorLite.Services; + +/// <summary> +/// One per-minute bucket of blocking SEVERITY (the Blocking Stats sub-tab): the count of blocked-process +/// events in the bucket plus the total / max / avg blocking DURATION (ms) over them. The Lite equivalent of +/// the Dashboard's pre-aggregated <c>collect.blocking_deadlock_stats</c> (blocking_event_count / +/// total_blocking_duration_ms / max_blocking_duration_ms / avg_blocking_duration_ms), computed on-the-fly from +/// the raw <c>blocked_process_reports</c> rows Lite already keeps, so no collector or schema change is needed. +/// The duration source is each blocked process's <c>wait_time_ms</c> — the same column the Blocked Process +/// Reports grid and the blocking-incident count trend read. +/// </summary> +public sealed record BlockingDurationStatsPoint( + DateTime Time, int EventCount, long TotalDurationMs, long MaxDurationMs, double AvgDurationMs); + +/// <summary> +/// One per-minute bucket of deadlock SEVERITY (the Blocking Stats sub-tab's deadlock companion to +/// <see cref="BlockingDurationStatsPoint"/>): the count of deadlock VICTIM processes in the bucket plus the +/// total / max / avg deadlock <c>wait_time</c> over EVERY process in the bucket's graphs. The Lite equivalent +/// of the Dashboard's pre-aggregated <c>collect.blocking_deadlock_stats</c> (<c>victim_count</c> / +/// <c>total_deadlock_wait_time_ms</c>) — computed by parsing the <c>deadlock_graph_xml</c> Lite already keeps +/// with Lite's own <see cref="DeadlockProcessDetail.ParseFromRows"/> (the SAME parse the Deadlocks grid uses, +/// so the two agree in-app), so like the blocking-duration aggregate it needs no collector and no schema +/// change. Matches the Dashboard analyzer's semantics (<c>26_blocking_deadlock_analyzer.sql</c>: +/// <c>victim_count = SUM(CASE WHEN … VICTIM)</c>, <c>total_deadlock_wait_time_ms = SUM(every process's +/// wait_time)</c>). +/// </summary> +public sealed record DeadlockSeverityStatsPoint( + DateTime Time, int VictimCount, long TotalWaitMs, long MaxWaitMs, double AvgWaitMs); + +public partial class LocalDataService +{ + /// <summary> + /// Blocking-duration aggregate per minute — the SEVERITY companion to + /// <see cref="GetBlockingTrendAsync"/>'s count-only trend, built on the SAME XE-preferred / DMV-fallback + /// source selection so the two reconcile exactly. XE blocked-process reports + /// (<c>v_blocked_process_reports</c>) are the primary source, bucketed on <c>event_time</c>; the always-on + /// DMV snapshot (<c>v_dmv_blocking_snapshots</c>) contributes only when the XE source has no rows in the + /// window (<c>WHERE NOT EXISTS</c>), so a server with both sources never double-counts (identical exclusion + /// to the count trend). Each bucket carries COUNT(*) events and the SUM / MAX / AVG of <c>wait_time_ms</c> + /// (the per-blocked-process block duration). Honors the #1319 database filter on both arms, exactly like the + /// count trend. + /// </summary> + public async Task<List<BlockingDurationStatsPoint>> GetBlockingDurationStatsAsync( + int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList<string>? databaseNames = null) + { + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); + + /* BPR per-minute severity buckets, falling back to the always-on DMV snapshot only when BPR has none + in the window (AWS RDS) — so a server with both sources never double-counts. Mirrors the blocking + count trend's XE→DMV union so the severity chart and the count chart draw from the same rows. */ + command.CommandText = @" +WITH bpr AS ( + SELECT + DATE_TRUNC('minute', event_time) AS bucket, + COUNT(*) AS event_count, + CAST(COALESCE(SUM(wait_time_ms), 0) AS BIGINT) AS total_duration_ms, + CAST(COALESCE(MAX(wait_time_ms), 0) AS BIGINT) AS max_duration_ms, + CAST(COALESCE(AVG(wait_time_ms), 0) AS DOUBLE) AS avg_duration_ms + FROM v_blocked_process_reports + WHERE server_id = $1 AND event_time >= $2 AND event_time <= $3" + dbClause + @" + GROUP BY DATE_TRUNC('minute', event_time) +), +dmv AS ( + SELECT + DATE_TRUNC('minute', event_time) AS bucket, + COUNT(*) AS event_count, + CAST(COALESCE(SUM(wait_time_ms), 0) AS BIGINT) AS total_duration_ms, + CAST(COALESCE(MAX(wait_time_ms), 0) AS BIGINT) AS max_duration_ms, + CAST(COALESCE(AVG(wait_time_ms), 0) AS DOUBLE) AS avg_duration_ms + FROM v_dmv_blocking_snapshots + WHERE server_id = $1 AND event_time >= $2 AND event_time <= $3" + dbClause + @" + GROUP BY DATE_TRUNC('minute', event_time) +) +SELECT bucket, event_count, total_duration_ms, max_duration_ms, avg_duration_ms FROM bpr +UNION ALL +SELECT bucket, event_count, total_duration_ms, max_duration_ms, avg_duration_ms FROM dmv WHERE NOT EXISTS (SELECT 1 FROM bpr) +ORDER BY bucket"; + + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = startTime }); + command.Parameters.Add(new DuckDBParameter { Value = endTime }); + foreach (var db in dbValues) + command.Parameters.Add(new DuckDBParameter { Value = db }); + + var items = new List<BlockingDurationStatsPoint>(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new BlockingDurationStatsPoint( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : Convert.ToInt32(reader.GetValue(1)), + reader.IsDBNull(2) ? 0 : Convert.ToInt64(reader.GetValue(2)), + reader.IsDBNull(3) ? 0 : Convert.ToInt64(reader.GetValue(3)), + reader.IsDBNull(4) ? 0 : ToDouble(reader.GetValue(4)))); + } + + return items; + } + + /// <summary> + /// Deadlock-severity buckets for one server over the window (Blocking Stats sub-tab): victim count and + /// total / max / avg deadlock wait per minute. Reads the raw graphs, then parses + aggregates them OFF the + /// UI thread (<see cref="Task.Run"/>) — the graph walk is CPU-bound XML work, the same reason the Deadlocks + /// grid parses off-thread (#1193). Windows on <c>collection_time</c> and reads <c>v_deadlocks</c> — the + /// IDENTICAL row-selection predicate <see cref="GetDeadlockTrendAsync"/> uses — so the deadlock COUNT shown + /// on this tab's summary strip and the victim/wait aggregate are drawn from the exact same set of deadlock + /// rows and reconcile in period. No LIMIT: deadlocks are rare, and a cap would drop rows the un-capped count + /// trend keeps (breaking reconciliation) — deliberately NOT reusing the capped + /// <see cref="GetRecentDeadlocksAsync"/>. + /// </summary> + public async Task<List<DeadlockSeverityStatsPoint>> GetDeadlockSeverityStatsAsync( + int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + { + var rows = new List<DeadlockRow>(); + + using (var connection = await OpenConnectionAsync()) + using (var command = connection.CreateCommand()) + { + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + + command.CommandText = @" +SELECT + deadlock_time, + deadlock_graph_xml +FROM v_deadlocks +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3 +ORDER BY deadlock_time"; + + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = startTime }); + command.Parameters.Add(new DuckDBParameter { Value = endTime }); + + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + rows.Add(new DeadlockRow + { + DeadlockTime = reader.IsDBNull(0) ? null : reader.GetDateTime(0), + DeadlockGraphXml = reader.IsDBNull(1) ? "" : reader.GetString(1) + }); + } + } + + /* Parse each graph + aggregate on the thread pool (Lite's #1193 discipline): deadlocks are low-volume, + but the sp_BlitzLock-style walk is still CPU-bound, so it never runs on the dispatcher. */ + return await Task.Run(() => AggregateDeadlockSeverity(rows)); + } + + /// <summary> + /// Parses each deadlock graph via Lite's own <see cref="DeadlockProcessDetail.ParseFromRows"/> (the SAME + /// parse the Deadlocks grid uses, so the two agree) and rolls the processes up into per-minute severity + /// buckets (bucketed on <c>deadlock_time</c> truncated to the minute, matching the count trend's + /// <c>DATE_TRUNC('minute', deadlock_time)</c>). Per bucket: <c>victim_count</c> = SUM of + /// <see cref="DeadlockProcessDetail.IsVictim"/>, and total / max / avg deadlock wait over EVERY process's + /// <see cref="DeadlockProcessDetail.WaitTime"/> (avg is process-weighted = total ÷ process count) — the + /// Dashboard <c>collect.blocking_deadlock_stats</c> semantics. A per-process row with a null + /// <c>deadlock_time</c> (unplaceable on the time axis) contributes nothing. Pure + off-WPF so a round-trip + /// test pins it; the ORDER BY is re-applied here because the dictionary rollup does not preserve read order. + /// </summary> + internal static List<DeadlockSeverityStatsPoint> AggregateDeadlockSeverity(List<DeadlockRow> rows) + { + var details = DeadlockProcessDetail.ParseFromRows(rows); + var buckets = new Dictionary<DateTime, DeadlockSeverityAccumulator>(); + + foreach (var detail in details) + { + if (detail.DeadlockTime is not { } dt) + continue; + + var bucket = TruncateToMinute(dt); + if (!buckets.TryGetValue(bucket, out var acc)) + { + acc = new DeadlockSeverityAccumulator(); + buckets[bucket] = acc; + } + + if (detail.IsVictim) + acc.VictimCount++; + acc.TotalWaitMs += detail.WaitTime; + if (detail.WaitTime > acc.MaxWaitMs) + acc.MaxWaitMs = detail.WaitTime; + acc.ProcessCount++; + } + + return buckets + .OrderBy(kvp => kvp.Key) + .Select(kvp => new DeadlockSeverityStatsPoint( + kvp.Key, + kvp.Value.VictimCount, + kvp.Value.TotalWaitMs, + kvp.Value.MaxWaitMs, + kvp.Value.ProcessCount > 0 ? (double)kvp.Value.TotalWaitMs / kvp.Value.ProcessCount : 0.0)) + .ToList(); + } + + /// <summary>Mutable per-bucket accumulator for <see cref="AggregateDeadlockSeverity"/> (a reference type so + /// the <c>TryGetValue</c> handle mutates the stored instance in place — one alloc per populated minute, + /// which is negligible at deadlock volumes).</summary> + private sealed class DeadlockSeverityAccumulator + { + public int VictimCount; + public long TotalWaitMs; + public long MaxWaitMs; + public int ProcessCount; + } + + /// <summary>Truncates to the minute to match DuckDB <c>DATE_TRUNC('minute', …)</c> (the count trend's + /// bucketing), preserving the value's <see cref="DateTimeKind"/> (naive UTC from the store).</summary> + private static DateTime TruncateToMinute(DateTime value) => + new(value.Ticks - (value.Ticks % TimeSpan.TicksPerMinute), value.Kind); +} diff --git a/Lite/Services/LocalDataService.CollectionHealth.cs b/Lite/Services/LocalDataService.CollectionHealth.cs index e5e3e7861..d936331cb 100644 --- a/Lite/Services/LocalDataService.CollectionHealth.cs +++ b/Lite/Services/LocalDataService.CollectionHealth.cs @@ -10,6 +10,8 @@ using System.Collections.Generic; using System.Threading.Tasks; using DuckDB.NET.Data; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; namespace PerformanceMonitorLite.Services; @@ -67,12 +69,20 @@ GROUP BY collector_name } /// <summary> - /// Gets recent collection log entries for a server, most recent first. + /// Gets recent collection log entries for a server, most recent first, bounded to the tab's + /// settable window. A preset ends "now" (<paramref name="hoursBack"/> from now); a custom range + /// (<paramref name="fromDate"/>/<paramref name="toDate"/>, both already server-time) bounds + /// <c>collection_time</c> on BOTH sides EXACTLY via <see cref="GetTimeRange"/> — mirroring how + /// <see cref="GetWaitStatsAsync"/> windows its read. The old single now-relative lower bound ignored + /// the custom To, rounding a custom range to a hours-back-from-now span. /// </summary> - public async Task<List<CollectionLogRow>> GetRecentCollectionLogAsync(int serverId, int hoursBack = 4, int maxRows = 500) + public async Task<List<CollectionLogRow>> GetRecentCollectionLogAsync(int serverId, int hoursBack = 4, DateTime? fromDate = null, DateTime? toDate = null, int maxRows = 500) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); + + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + command.CommandText = @" SELECT collector_name, @@ -87,11 +97,13 @@ public async Task<List<CollectionLogRow>> GetRecentCollectionLogAsync(int server FROM v_collection_log WHERE server_id = $1 AND collection_time >= $2 +AND collection_time <= $3 ORDER BY collection_time DESC -LIMIT $3"; +LIMIT $4"; command.Parameters.Add(new DuckDBParameter { Value = serverId }); - command.Parameters.Add(new DuckDBParameter { Value = DateTime.UtcNow.AddHours(-hoursBack) }); + command.Parameters.Add(new DuckDBParameter { Value = startTime }); + command.Parameters.Add(new DuckDBParameter { Value = endTime }); command.Parameters.Add(new DuckDBParameter { Value = maxRows }); var items = new List<CollectionLogRow>(); @@ -188,21 +200,15 @@ public class CollectionLogRow public string DuckDbDurationFormatted => DuckDbDurationMs.HasValue ? $"{DuckDbDurationMs.Value} ms" : ""; } +/// <summary> +/// One Collection Health grid row — a collector's 7-day roll-up with its health band. +/// <see cref="HealthStatus"/> delegates to the shared <see cref="CollectorHealthClassifier"/> in +/// PerformanceMonitor.Common (#1573), so Lite, the Darling viewer, and the service band identically and +/// cannot drift; it resolves the collector's cadence from the shared <see cref="CollectorScheduleDefaults"/> +/// so a healthy DAILY collector is no longer flagged stale/failing on the frequent-collector thresholds. +/// </summary> public class CollectorHealthRow { - /// <summary> - /// On-load collectors run once per tab open, not on the scheduled loop. - /// Staleness thresholds don't apply to them. - /// </summary> - private static readonly HashSet<string> OnLoadCollectors = new(StringComparer.OrdinalIgnoreCase) - { - "server_config", - "database_config", - "database_scoped_config", - "trace_flags", - "server_properties" - }; - public string CollectorName { get; set; } = ""; public long TotalRuns { get; set; } public long SuccessCount { get; set; } @@ -219,23 +225,15 @@ public class CollectorHealthRow ? (DateTime.UtcNow - LastSuccessTime.Value).TotalHours : 999; - public string HealthStatus - { - get - { - if (TotalRuns == 0) return "NEVER_RUN"; - if (PermissionDeniedCount > 0 && ErrorCount == 0 && SuccessCount == 0) return "NO_PERMISSIONS"; - if (OnLoadCollectors.Contains(CollectorName)) - { - if (FailureRatePercent > 20) return "WARNING"; - return "HEALTHY"; - } - if (HoursSinceLastSuccess > 24) return "FAILING"; - if (HoursSinceLastSuccess > 4) return "STALE"; - if (FailureRatePercent > 20) return "WARNING"; - return "HEALTHY"; - } - } + /// <summary>The collector's default cadence from the shared <see cref="CollectorScheduleDefaults"/> + /// (0 for an on-load or unknown collector — both fall to the floor thresholds). The banding uses the + /// shipped default, not the per-install ScheduleManager override, so all three surfaces stay in parity.</summary> + private int FrequencyMinutes => + CollectorScheduleDefaults.All.TryGetValue(CollectorName, out var schedule) ? schedule.FrequencyMinutes : 0; + + public string HealthStatus => CollectorHealthClassifier.Classify( + TotalRuns, SuccessCount, ErrorCount, PermissionDeniedCount, + HoursSinceLastSuccess, FrequencyMinutes, CollectorHealthClassifier.IsOnLoadCollector(CollectorName)); public string AvgDurationFormatted => AvgDurationMs < 1000 ? $"{AvgDurationMs:F0} ms" diff --git a/Lite/Services/LocalDataService.Config.cs b/Lite/Services/LocalDataService.Config.cs index 2c5cfdd2a..872494283 100644 --- a/Lite/Services/LocalDataService.Config.cs +++ b/Lite/Services/LocalDataService.Config.cs @@ -51,10 +51,11 @@ FROM v_server_config /// <summary> /// Gets the latest database configuration snapshot (sys.databases). /// </summary> - public async Task<List<DatabaseConfigRow>> GetLatestDatabaseConfigAsync(int serverId) + public async Task<List<DatabaseConfigRow>> GetLatestDatabaseConfigAsync(int serverId, IReadOnlyList<string>? databaseNames = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); + var dbClause = BuildDbInClause(databaseNames, "database_name", 2, out var dbValues); command.CommandText = @" SELECT database_name, state_desc, compatibility_level, collation_name, recovery_model, is_read_only, is_auto_close_on, is_auto_shrink_on, @@ -66,10 +67,12 @@ public async Task<List<DatabaseConfigRow>> GetLatestDatabaseConfigAsync(int serv is_accelerated_database_recovery_on, is_memory_optimized_enabled, is_optimized_locking_on FROM v_database_config WHERE server_id = $1 -AND capture_time = (SELECT MAX(capture_time) FROM v_database_config WHERE server_id = $1) +AND capture_time = (SELECT MAX(capture_time) FROM v_database_config WHERE server_id = $1)" + dbClause + @" ORDER BY database_name"; command.Parameters.Add(new DuckDBParameter { Value = serverId }); + foreach (var db in dbValues) + command.Parameters.Add(new DuckDBParameter { Value = db }); var items = new List<DatabaseConfigRow>(); using var reader = await command.ExecuteReaderAsync(); @@ -115,18 +118,21 @@ FROM v_database_config /// <summary> /// Gets the latest database-scoped configuration snapshot. /// </summary> - public async Task<List<DatabaseScopedConfigRow>> GetLatestDatabaseScopedConfigAsync(int serverId) + public async Task<List<DatabaseScopedConfigRow>> GetLatestDatabaseScopedConfigAsync(int serverId, IReadOnlyList<string>? databaseNames = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); + var dbClause = BuildDbInClause(databaseNames, "database_name", 2, out var dbValues); command.CommandText = @" SELECT database_name, configuration_name, value, value_for_secondary FROM v_database_scoped_config WHERE server_id = $1 -AND capture_time = (SELECT MAX(capture_time) FROM v_database_scoped_config WHERE server_id = $1) +AND capture_time = (SELECT MAX(capture_time) FROM v_database_scoped_config WHERE server_id = $1)" + dbClause + @" ORDER BY database_name, configuration_name"; command.Parameters.Add(new DuckDBParameter { Value = serverId }); + foreach (var db in dbValues) + command.Parameters.Add(new DuckDBParameter { Value = db }); var items = new List<DatabaseScopedConfigRow>(); using var reader = await command.ExecuteReaderAsync(); diff --git a/Lite/Services/LocalDataService.ConfigChanges.cs b/Lite/Services/LocalDataService.ConfigChanges.cs new file mode 100644 index 000000000..5d15e273d --- /dev/null +++ b/Lite/Services/LocalDataService.ConfigChanges.cs @@ -0,0 +1,255 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using PerformanceMonitor.Common; +using PerformanceMonitorLite.Helpers; + +namespace PerformanceMonitorLite.Services; + +/* + * The Configuration Changes tab reads (Tier-1 parity) — the Lite (DuckDB) port of the Darling viewer's + * ViewerDataService.ConfigChanges reads. There is NO change table: server-config / database-config / + * trace-flag DRIFT is COMPUTED by diffing the store's APPEND-ONLY config SNAPSHOT views + * (v_server_config / v_database_config / v_trace_flags) in pure C#. The diff + the change/snapshot records + * live ONCE in PerformanceMonitor.Common.ConfigChangeDiff (shared by the viewer and the headless MCP host) — + * this reader only fetches the ordered snapshots from DuckDB, calls the shared diff, and wraps each change in + * a Lite display row that adds ServerTimeHelper time rendering. Lite already surfaces the same snapshots on + * its Configuration tab (LocalDataService.Config.cs), so the data exists; only this diff-derived view is new. + * + * WINDOWING (mirrors Darling): the snapshot read is upper-bounded at the window END (capture_time <= endUtc) + * but NOT lower-bounded, so the snapshot immediately BEFORE the window is kept as the diff baseline and a + * change landing on the first in-window snapshot is still detected; the shared diff then applies BOTH edges + * to the computed change_time. capture_time is stamped DateTime.UtcNow by the collector (like collection_time), + * so the read windows on the UTC bounds from GetTimeRange and each row's change_time renders through + * ServerTimeHelper.FormatServerTime (naive-UTC -> display) exactly like the other UTC-stamped grids. + * + * SHAPE CAVEATS (surfaced honestly): the config collectors run ON CONNECT only, so change granularity equals + * the connect/restart cadence and a fresh / stably-connected server may hold a single snapshot (then there is + * no change yet — the diff needs >= 2 snapshots). requires_restart is DERIVED (is_dynamic = 0 AND + * value_configured != value_in_use), matching the Dashboard view; the Dashboard's uncollected enrichment + * (server-config description, database-config setting_type) is OMITTED, not fabricated. + */ + +/// <summary>Shared render of a config change_time (naive-UTC, capture_time) for the Configuration Changes +/// grids — the same ServerTimeHelper.FormatServerTime the System Events / blocking grids use.</summary> +internal static class ConfigChangeRowFormat +{ + public static string Local(DateTime utc) => ServerTimeHelper.FormatServerTime(utc, "yyyy-MM-dd HH:mm:ss"); +} + +/// <summary>One server-configuration change (Server Config Changes sub-tab). Thin display wrapper over the +/// shared <see cref="ConfigChangeDiff.ServerConfigChange"/>; only <see cref="ChangeTimeLocal"/> is Lite-specific +/// (the derived requires_restart / dynamic / advanced / change-description columns come straight from the +/// shared record).</summary> +public sealed class ServerConfigChangeRow(ConfigChangeDiff.ServerConfigChange change) +{ + public string ChangeTimeLocal => ConfigChangeRowFormat.Local(change.ChangeTime); + /// <summary>Raw naive-UTC change time (capture_time) for the MCP layer's ISO output.</summary> + public DateTime ChangeTime => change.ChangeTime; + public string ConfigurationName => change.ConfigurationName; + public long? OldValueConfigured => change.OldValueConfigured; + public long? NewValueConfigured => change.NewValueConfigured; + public long? OldValueInUse => change.OldValueInUse; + public long? NewValueInUse => change.NewValueInUse; + public string RequiresRestartDisplay => change.RequiresRestartDisplay; + public string DynamicDisplay => change.DynamicDisplay; + public string AdvancedDisplay => change.AdvancedDisplay; + public string ChangeDescription => change.ChangeDescription; +} + +/// <summary>One database-configuration change (Database Config Changes sub-tab). Thin display wrapper over the +/// shared <see cref="ConfigChangeDiff.DatabaseConfigChange"/>; setting_name is the literal column name (the +/// WIDE store has no setting_type).</summary> +public sealed class DatabaseConfigChangeRow(ConfigChangeDiff.DatabaseConfigChange change) +{ + public string ChangeTimeLocal => ConfigChangeRowFormat.Local(change.ChangeTime); + /// <summary>Raw naive-UTC change time (capture_time) for the MCP layer's ISO output.</summary> + public DateTime ChangeTime => change.ChangeTime; + public string DatabaseName => change.DatabaseName; + public string SettingName => change.SettingName; + public string? OldValue => change.OldValue; + public string? NewValue => change.NewValue; + public string ChangeDescription => change.ChangeDescription; +} + +/// <summary>One trace-flag change (Trace Flag Changes sub-tab). Thin display wrapper over the shared +/// <see cref="ConfigChangeDiff.TraceFlagChange"/> (enabled / disabled / modified set-diff outcome).</summary> +public sealed class TraceFlagChangeRow(ConfigChangeDiff.TraceFlagChange change) +{ + public string ChangeTimeLocal => ConfigChangeRowFormat.Local(change.ChangeTime); + /// <summary>Raw naive-UTC change time (capture_time) for the MCP layer's ISO output.</summary> + public DateTime ChangeTime => change.ChangeTime; + public int TraceFlag => change.TraceFlag; + public string PreviousStatusDisplay => change.PreviousStatusDisplay; + public string NewStatusDisplay => change.NewStatusDisplay; + public string Scope => change.Scope; + public string GlobalDisplay => change.GlobalDisplay; + public string SessionDisplay => change.SessionDisplay; + public string ChangeDescription => change.ChangeDescription; +} + +public partial class LocalDataService +{ + /// <summary>Server-configuration changes over the tab window, newest first. Reads every sys.configurations + /// snapshot up to the window end (keeping the pre-window baseline), then diffs per configuration_name via + /// the shared <see cref="ConfigChangeDiff"/>.</summary> + public async Task<List<ServerConfigChangeRow>> GetServerConfigChangesAsync( + int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + { + using var _q = TimeQuery("GetServerConfigChangesAsync", "v_server_config snapshot diff"); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var snapshots = await ReadServerConfigSnapshotsAsync(serverId, endTime); + return ConfigChangeDiff.DiffServerConfigChanges(snapshots, startTime, endTime) + .Select(c => new ServerConfigChangeRow(c)) + .ToList(); + } + + /// <summary>Database-configuration changes over the tab window, newest first. Reads every sys.databases + /// snapshot up to the window end then UNPIVOTs the wide row per (database, setting) whose value moved via + /// the shared <see cref="ConfigChangeDiff"/>. #1319: the global database filter is pushed into SQL.</summary> + public async Task<List<DatabaseConfigChangeRow>> GetDatabaseConfigChangesAsync( + int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList<string>? databaseNames = null) + { + using var _q = TimeQuery("GetDatabaseConfigChangesAsync", "v_database_config snapshot diff"); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var snapshots = await ReadDatabaseConfigSnapshotsAsync(serverId, endTime, databaseNames); + return ConfigChangeDiff.DiffDatabaseConfigChanges(snapshots, startTime, endTime) + .Select(c => new DatabaseConfigChangeRow(c)) + .ToList(); + } + + /// <summary>Trace-flag changes over the tab window, newest first. Reads every trace-flag snapshot up to the + /// window end then set-diffs consecutive captures via the shared <see cref="ConfigChangeDiff"/> (flag + /// appears = enabled, disappears = disabled, status/scope moves = modified).</summary> + public async Task<List<TraceFlagChangeRow>> GetTraceFlagChangesAsync( + int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + { + using var _q = TimeQuery("GetTraceFlagChangesAsync", "v_trace_flags snapshot diff"); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var snapshots = await ReadTraceFlagSnapshotsAsync(serverId, endTime); + return ConfigChangeDiff.DiffTraceFlagChanges(snapshots, startTime, endTime) + .Select(c => new TraceFlagChangeRow(c)) + .ToList(); + } + + private async Task<List<ConfigChangeDiff.ServerConfigSnapshot>> ReadServerConfigSnapshotsAsync(int serverId, DateTime endUtc) + { + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + command.CommandText = @" +SELECT capture_time, configuration_name, value_configured, value_in_use, is_dynamic, is_advanced +FROM v_server_config +WHERE server_id = $1 +AND capture_time <= $2 +ORDER BY configuration_name, capture_time"; + + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = endUtc }); + + var rows = new List<ConfigChangeDiff.ServerConfigSnapshot>(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + rows.Add(new ConfigChangeDiff.ServerConfigSnapshot( + reader.GetDateTime(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + reader.IsDBNull(2) ? (long?)null : ToInt64(reader.GetValue(2)), + reader.IsDBNull(3) ? (long?)null : ToInt64(reader.GetValue(3)), + reader.IsDBNull(4) ? (bool?)null : reader.GetBoolean(4), + reader.IsDBNull(5) ? (bool?)null : reader.GetBoolean(5))); + } + + return rows; + } + + private async Task<List<ConfigChangeDiff.DatabaseConfigSnapshot>> ReadDatabaseConfigSnapshotsAsync( + int serverId, DateTime endUtc, IReadOnlyList<string>? databaseNames) + { + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + var dbClause = BuildDbInClause(databaseNames, "database_name", 3, out var dbValues); + /* CAST every setting to VARCHAR so the wide row diffs uniformly by text (matching the Postgres ::text + casts); the SELECT order is ConfigChangeDiff.DatabaseConfigChangeSettingNames, walked positionally. */ + command.CommandText = @" +SELECT capture_time, database_name, + CAST(state_desc AS VARCHAR), CAST(compatibility_level AS VARCHAR), CAST(collation_name AS VARCHAR), + CAST(recovery_model AS VARCHAR), CAST(is_read_only AS VARCHAR), CAST(is_auto_close_on AS VARCHAR), + CAST(is_auto_shrink_on AS VARCHAR), CAST(is_auto_create_stats_on AS VARCHAR), + CAST(is_auto_update_stats_on AS VARCHAR), CAST(is_auto_update_stats_async_on AS VARCHAR), + CAST(is_read_committed_snapshot_on AS VARCHAR), CAST(snapshot_isolation_state AS VARCHAR), + CAST(is_parameterization_forced AS VARCHAR), CAST(is_query_store_on AS VARCHAR), + CAST(is_encrypted AS VARCHAR), CAST(is_trustworthy_on AS VARCHAR), CAST(is_db_chaining_on AS VARCHAR), + CAST(is_broker_enabled AS VARCHAR), CAST(is_cdc_enabled AS VARCHAR), + CAST(is_mixed_page_allocation_on AS VARCHAR), CAST(log_reuse_wait_desc AS VARCHAR), + CAST(page_verify_option AS VARCHAR), CAST(target_recovery_time_seconds AS VARCHAR), + CAST(delayed_durability AS VARCHAR), CAST(is_accelerated_database_recovery_on AS VARCHAR), + CAST(is_memory_optimized_enabled AS VARCHAR), CAST(is_optimized_locking_on AS VARCHAR) +FROM v_database_config +WHERE server_id = $1 +AND capture_time <= $2" + dbClause + @" +ORDER BY database_name, capture_time"; + + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = endUtc }); + foreach (var db in dbValues) + command.Parameters.Add(new DuckDBParameter { Value = db }); + + var rows = new List<ConfigChangeDiff.DatabaseConfigSnapshot>(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var values = new string?[ConfigChangeDiff.DatabaseConfigChangeSettingNames.Count]; + for (var i = 0; i < values.Length; i++) + { + var ordinal = i + 2; /* capture_time, database_name precede the settings */ + values[i] = reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal); + } + + rows.Add(new ConfigChangeDiff.DatabaseConfigSnapshot( + reader.GetDateTime(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + values)); + } + + return rows; + } + + private async Task<List<ConfigChangeDiff.TraceFlagSnapshot>> ReadTraceFlagSnapshotsAsync(int serverId, DateTime endUtc) + { + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + command.CommandText = @" +SELECT capture_time, trace_flag, status, is_global, is_session +FROM v_trace_flags +WHERE server_id = $1 +AND capture_time <= $2 +ORDER BY capture_time, trace_flag"; + + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = endUtc }); + + var rows = new List<ConfigChangeDiff.TraceFlagSnapshot>(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + rows.Add(new ConfigChangeDiff.TraceFlagSnapshot( + reader.GetDateTime(0), + reader.IsDBNull(1) ? 0 : reader.GetInt32(1), + reader.IsDBNull(2) ? (bool?)null : reader.GetBoolean(2), + reader.IsDBNull(3) ? (bool?)null : reader.GetBoolean(3), + reader.IsDBNull(4) ? (bool?)null : reader.GetBoolean(4))); + } + + return rows; + } +} diff --git a/Lite/Services/LocalDataService.CpuScheduler.cs b/Lite/Services/LocalDataService.CpuScheduler.cs new file mode 100644 index 000000000..6657a1f89 --- /dev/null +++ b/Lite/Services/LocalDataService.CpuScheduler.cs @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using PerformanceMonitor.Common; + +namespace PerformanceMonitorLite.Services; + +public partial class LocalDataService +{ + /* The CPU Scheduler readers — the Lite (DuckDB) port of the Darling viewer's + ViewerDataService.CpuScheduler reads. cpu_scheduler_stats is a POINT-IN-TIME snapshot collector + (one aggregate row per collection), NOT a cumulative-counter delta table, so the trend's three + task counts plot directly with no delta/LAG normalization. Reads run against the + v_cpu_scheduler_stats archive view (base table UNION the archived parquet), matching every other + Lite trend reader. collection_time is stored in UTC, so the window uses GetTimeRange (UTC). */ + + /// <summary> + /// The CPU Scheduler pressure trend: the runnable / blocked / queued task counts per collection over + /// the window, plotted directly (point-in-time collector, no delta math). + /// </summary> + public async Task<List<CpuSchedulerTrendPoint>> GetCpuSchedulerTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + { + using var _q = TimeQuery("GetCpuSchedulerTrendAsync", "v_cpu_scheduler_stats pressure trend"); + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + + command.CommandText = @" +SELECT + collection_time, + total_runnable_tasks_count, + total_blocked_task_count, + total_queued_request_count +FROM v_cpu_scheduler_stats +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3 +ORDER BY collection_time"; + + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = startTime }); + command.Parameters.Add(new DuckDBParameter { Value = endTime }); + + var items = new List<CpuSchedulerTrendPoint>(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new CpuSchedulerTrendPoint + { + CollectionTime = reader.GetDateTime(0), + RunnableTasks = reader.IsDBNull(1) ? 0 : reader.GetInt32(1), + BlockedTasks = reader.IsDBNull(2) ? 0 : reader.GetInt32(2), + QueuedRequests = reader.IsDBNull(3) ? 0 : reader.GetInt32(3) + }); + } + + return items; + } + + /// <summary> + /// The CPU Scheduler latest-snapshot: the single most recent cpu_scheduler_stats row in the window + /// (every scheduler / worker / NUMA / OS-memory pressure column + the collector's CASE-computed + /// warning flags), feeding the metric grid. Returns null when the window holds no snapshot. + /// </summary> + public async Task<CpuSchedulerSnapshot?> GetCpuSchedulerSnapshotAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + { + using var _q = TimeQuery("GetCpuSchedulerSnapshotAsync", "v_cpu_scheduler_stats latest snapshot"); + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + + command.CommandText = @" +SELECT + collection_time, + max_workers_count, + scheduler_count, + cpu_count, + total_runnable_tasks_count, + total_work_queue_count, + total_current_workers_count, + avg_runnable_tasks_count, + total_active_request_count, + total_queued_request_count, + total_blocked_task_count, + total_active_parallel_thread_count, + runnable_request_count, + total_request_count, + runnable_percent, + worker_thread_exhaustion_warning, + runnable_tasks_warning, + blocked_tasks_warning, + queued_requests_warning, + total_physical_memory_kb, + available_physical_memory_kb, + system_memory_state_desc, + physical_memory_pressure_warning, + total_node_count, + nodes_online_count, + offline_cpu_count, + offline_cpu_warning +FROM v_cpu_scheduler_stats +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3 +ORDER BY collection_time DESC +LIMIT 1"; + + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = startTime }); + command.Parameters.Add(new DuckDBParameter { Value = endTime }); + + using var reader = await command.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) + { + return null; + } + + return new CpuSchedulerSnapshot + { + CollectionTime = reader.GetDateTime(0), + MaxWorkersCount = reader.IsDBNull(1) ? 0 : reader.GetInt32(1), + SchedulerCount = reader.IsDBNull(2) ? 0 : reader.GetInt32(2), + CpuCount = reader.IsDBNull(3) ? 0 : reader.GetInt32(3), + TotalRunnableTasksCount = reader.IsDBNull(4) ? 0 : reader.GetInt32(4), + TotalWorkQueueCount = reader.IsDBNull(5) ? 0 : reader.GetInt64(5), + TotalCurrentWorkersCount = reader.IsDBNull(6) ? 0 : reader.GetInt32(6), + AvgRunnableTasksCount = reader.IsDBNull(7) ? 0 : ToDouble(reader.GetValue(7)), + TotalActiveRequestCount = reader.IsDBNull(8) ? 0 : reader.GetInt32(8), + TotalQueuedRequestCount = reader.IsDBNull(9) ? 0 : reader.GetInt32(9), + TotalBlockedTaskCount = reader.IsDBNull(10) ? 0 : reader.GetInt32(10), + TotalActiveParallelThreadCount = reader.IsDBNull(11) ? 0 : reader.GetInt64(11), + RunnableRequestCount = reader.IsDBNull(12) ? null : reader.GetInt32(12), + TotalRequestCount = reader.IsDBNull(13) ? null : reader.GetInt32(13), + RunnablePercent = reader.IsDBNull(14) ? null : ToDouble(reader.GetValue(14)), + WorkerThreadExhaustionWarning = !reader.IsDBNull(15) && reader.GetBoolean(15), + RunnableTasksWarning = !reader.IsDBNull(16) && reader.GetBoolean(16), + BlockedTasksWarning = !reader.IsDBNull(17) && reader.GetBoolean(17), + QueuedRequestsWarning = !reader.IsDBNull(18) && reader.GetBoolean(18), + TotalPhysicalMemoryKb = reader.IsDBNull(19) ? 0 : reader.GetInt64(19), + AvailablePhysicalMemoryKb = reader.IsDBNull(20) ? 0 : reader.GetInt64(20), + SystemMemoryStateDesc = reader.IsDBNull(21) ? null : reader.GetString(21), + PhysicalMemoryPressureWarning = !reader.IsDBNull(22) && reader.GetBoolean(22), + TotalNodeCount = reader.IsDBNull(23) ? 0 : reader.GetInt32(23), + NodesOnlineCount = reader.IsDBNull(24) ? 0 : reader.GetInt32(24), + OfflineCpuCount = reader.IsDBNull(25) ? 0 : reader.GetInt32(25), + OfflineCpuWarning = !reader.IsDBNull(26) && reader.GetBoolean(26) + }; + } +} + +/// <summary>One point on the CPU-scheduler pressure trend: the runnable / blocked / queued task counts +/// at a collection instant. A point-in-time snapshot collector (one row per collection), so these plot +/// directly with no delta math.</summary> +public class CpuSchedulerTrendPoint : ICpuSchedulerTrendPoint +{ + public DateTime CollectionTime { get; set; } + public int RunnableTasks { get; set; } + public int BlockedTasks { get; set; } + public int QueuedRequests { get; set; } +} + +/// <summary>The most recent CPU-scheduler snapshot in the window — every column the cpu_scheduler_stats +/// collector captures (scheduler / worker / NUMA / OS-memory pressure), feeding the CPU Scheduler tab's +/// latest-snapshot metric grid and its warning highlights. Implements <see cref="ICpuSchedulerSnapshot"/> so +/// the shared <see cref="CpuSchedulerMetrics"/> projection reads it directly (its double / double? averaged +/// columns satisfy the interface as-is).</summary> +public class CpuSchedulerSnapshot : ICpuSchedulerSnapshot +{ + public DateTime CollectionTime { get; set; } + public int MaxWorkersCount { get; set; } + public int SchedulerCount { get; set; } + public int CpuCount { get; set; } + public int TotalRunnableTasksCount { get; set; } + public long TotalWorkQueueCount { get; set; } + public int TotalCurrentWorkersCount { get; set; } + public double AvgRunnableTasksCount { get; set; } + public int TotalActiveRequestCount { get; set; } + public int TotalQueuedRequestCount { get; set; } + public int TotalBlockedTaskCount { get; set; } + public long TotalActiveParallelThreadCount { get; set; } + public int? RunnableRequestCount { get; set; } + public int? TotalRequestCount { get; set; } + public double? RunnablePercent { get; set; } + public bool WorkerThreadExhaustionWarning { get; set; } + public bool RunnableTasksWarning { get; set; } + public bool BlockedTasksWarning { get; set; } + public bool QueuedRequestsWarning { get; set; } + public long TotalPhysicalMemoryKb { get; set; } + public long AvailablePhysicalMemoryKb { get; set; } + public string? SystemMemoryStateDesc { get; set; } + public bool PhysicalMemoryPressureWarning { get; set; } + public int TotalNodeCount { get; set; } + public int NodesOnlineCount { get; set; } + public int OfflineCpuCount { get; set; } + public bool OfflineCpuWarning { get; set; } +} diff --git a/Lite/Services/LocalDataService.DailySummary.cs b/Lite/Services/LocalDataService.DailySummary.cs index 233a71a43..d18a9ccd5 100644 --- a/Lite/Services/LocalDataService.DailySummary.cs +++ b/Lite/Services/LocalDataService.DailySummary.cs @@ -10,107 +10,195 @@ using System.Collections.Generic; using System.Threading.Tasks; using DuckDB.NET.Data; +using PerformanceMonitor.Common; namespace PerformanceMonitorLite.Services; public partial class LocalDataService { /// <summary> - /// Gets daily summary for a specific date (or today if null). + /// The Performance Calendar / Daily Summary aggregate, grouped one row per day over a half-open + /// [fromDate, toDate) window. One row is returned for every day that had ANY collection (present in + /// the collection log or any source view); days with no collection are simply absent, which the + /// calendar renders as <see cref="DailyHealthBand.NoData"/>. Each row's composite band is computed by + /// the shared <see cref="DailyHealthBandCalculator"/> so it bands identically to the Darling viewer. + /// + /// This is the single source of truth for the daily aggregate; <see cref="GetDailySummaryAsync"/> + /// (single day) delegates here so the calendar cell and the drilled-in day can never disagree. /// </summary> - public async Task<DailySummaryRow?> GetDailySummaryAsync(int serverId, DateTime? summaryDate = null) + private const string DailySummaryRangeSql = @" +WITH wait_per_type AS ( + SELECT date_trunc('day', collection_time) AS d, wait_type, SUM(delta_wait_time_ms) AS ms + FROM v_wait_stats + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 AND delta_wait_time_ms > 0 + GROUP BY 1, 2 +), +waits AS ( + SELECT d, SUM(ms) / 1000.0 AS total_wait_sec, arg_max(wait_type, ms) AS top_wait_type + FROM wait_per_type + GROUP BY d +), +queries AS ( + SELECT date_trunc('day', collection_time) AS d, COUNT(DISTINCT query_hash) AS c + FROM v_query_stats + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + GROUP BY 1 +), +deadlocks AS ( + SELECT date_trunc('day', collection_time) AS d, COUNT(*) AS c + FROM v_deadlocks + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + GROUP BY 1 +), +bpr AS ( + SELECT date_trunc('day', collection_time) AS d, COUNT(*) AS c, MAX(wait_time_ms) AS max_wait_ms + FROM v_blocked_process_reports + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + GROUP BY 1 +), +dmv AS ( + SELECT date_trunc('day', collection_time) AS d, COUNT(*) AS c, MAX(wait_time_ms) AS max_wait_ms + FROM v_dmv_blocking_snapshots + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + GROUP BY 1 +), +cpu AS ( + /* Total host CPU = SQL + other-process (NULL on Linux -> 0), matching the alert engine and the + Overview headline; sustained >= 80 samples drive the day's band. */ + SELECT date_trunc('day', collection_time) AS d, + COUNT(*) FILTER (WHERE (sqlserver_cpu_utilization + COALESCE(other_process_cpu_utilization, 0)) >= 80) AS c + FROM v_cpu_utilization_stats + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + GROUP BY 1 +), +coll AS ( + /* Any run (all statuses) marks the day as collected -> it appears even if every metric is quiet + (a quiet monitored day is Healthy/green, not No-Data/grey). errs feeds the Critical band. */ + SELECT date_trunc('day', collection_time) AS d, + COUNT(*) AS runs, + COUNT(*) FILTER (WHERE status = 'ERROR') AS errs + FROM v_collection_log + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + GROUP BY 1 +), +mem AS ( + SELECT date_trunc('day', collection_time) AS d, + COUNT(*) FILTER (WHERE memory_indicators_process >= 2 OR memory_indicators_system >= 2) AS pressure, + COUNT(*) FILTER (WHERE memory_indicators_process >= 3) AS critical + FROM v_memory_pressure_events + WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 + GROUP BY 1 +), +alerts AS ( + /* Actionable alerts only: exclude dismissed rows and resolution/good-news notices (Cleared / + Resolved / Restored), mirroring AlertMetricClassifier.IsResolution. */ + SELECT date_trunc('day', alert_time) AS d, COUNT(*) AS c + FROM v_config_alert_log + WHERE server_id = $1 AND alert_time >= $2 AND alert_time < $3 + AND dismissed = false + AND metric_name NOT LIKE '%Cleared%' + AND metric_name NOT LIKE '%Resolved%' + AND metric_name NOT LIKE '%Restored%' + GROUP BY 1 +), +day_spine AS ( + SELECT d FROM waits + UNION SELECT d FROM queries + UNION SELECT d FROM deadlocks + UNION SELECT d FROM bpr + UNION SELECT d FROM dmv + UNION SELECT d FROM cpu + UNION SELECT d FROM coll + UNION SELECT d FROM mem + UNION SELECT d FROM alerts +) +SELECT + s.d AS day, + COALESCE(w.total_wait_sec, 0) AS total_wait_sec, + w.top_wait_type, + COALESCE(q.c, 0) AS unique_queries, + COALESCE(dl.c, 0) AS deadlock_count, + COALESCE(NULLIF(b.c, 0), dm.c, 0) AS blocking_events, + COALESCE(cp.c, 0) AS high_cpu_events, + COALESCE(cl.errs, 0) AS collection_errors, + COALESCE(m.pressure, 0) AS memory_pressure_events, + COALESCE(m.critical, 0) AS memory_critical_events, + COALESCE(al.c, 0) AS alert_count, + /* Peak block wait (ms) from the SAME source the blocking count came from (BPR preferred, DMV-snapshot + fallback), so the day-detail blocking reason ('N blocking events (peak block X)') reconciles with the + count. 0 when the blocking came from a source without a wait time. */ + COALESCE(CASE WHEN COALESCE(b.c, 0) > 0 THEN b.max_wait_ms ELSE dm.max_wait_ms END, 0) AS peak_block_wait_ms +FROM day_spine s +LEFT JOIN waits w ON w.d = s.d +LEFT JOIN queries q ON q.d = s.d +LEFT JOIN deadlocks dl ON dl.d = s.d +LEFT JOIN bpr b ON b.d = s.d +LEFT JOIN dmv dm ON dm.d = s.d +LEFT JOIN cpu cp ON cp.d = s.d +LEFT JOIN coll cl ON cl.d = s.d +LEFT JOIN mem m ON m.d = s.d +LEFT JOIN alerts al ON al.d = s.d +ORDER BY s.d"; + + /// <summary> + /// Returns one <see cref="DailySummaryRow"/> per collected day in the half-open [fromDate, toDate) + /// window (dates normalized to their date component). Powers the Performance Calendar month grid. + /// </summary> + public async Task<List<DailySummaryRow>> GetDailySummaryRangeAsync(int serverId, DateTime fromDate, DateTime toDate) { - using var _q = TimeQuery("GetDailySummaryAsync", "daily summary aggregation"); + using var _q = TimeQuery("GetDailySummaryRangeAsync", "daily summary range aggregation"); using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - var targetDate = summaryDate?.Date ?? DateTime.UtcNow.Date; - var dayStart = targetDate; - var dayEnd = targetDate.AddDays(1); - - command.CommandText = @" -SELECT - COALESCE( - (SELECT SUM(delta_wait_time_ms) / 1000.0 - FROM v_wait_stats - WHERE server_id = $1 - AND collection_time >= $2 AND collection_time < $3 - AND delta_wait_time_ms > 0), 0 - ) AS total_wait_sec, - (SELECT wait_type - FROM v_wait_stats - WHERE server_id = $1 - AND collection_time >= $2 AND collection_time < $3 - AND delta_wait_time_ms > 0 - GROUP BY wait_type - ORDER BY SUM(delta_wait_time_ms) DESC - LIMIT 1 - ) AS top_wait_type, - COALESCE( - (SELECT COUNT(DISTINCT query_hash) - FROM v_query_stats - WHERE server_id = $1 - AND collection_time >= $2 AND collection_time < $3), 0 - ) AS unique_queries, - COALESCE( - (SELECT COUNT(*) - FROM v_deadlocks - WHERE server_id = $1 - AND collection_time >= $2 AND collection_time < $3), 0 - ) AS deadlock_count, - COALESCE( - NULLIF((SELECT COUNT(*) - FROM v_blocked_process_reports - WHERE server_id = $1 - AND collection_time >= $2 AND collection_time < $3), 0), - (SELECT COUNT(*) - FROM v_dmv_blocking_snapshots - WHERE server_id = $1 - AND collection_time >= $2 AND collection_time < $3), 0 - ) AS blocking_events, - COALESCE( - (SELECT COUNT(*) - FROM v_cpu_utilization_stats - WHERE server_id = $1 - AND sqlserver_cpu_utilization >= 80 - AND collection_time >= $2 AND collection_time < $3), 0 - ) AS high_cpu_events, - COALESCE( - (SELECT COUNT(*) - FROM v_collection_log - WHERE server_id = $1 - AND status = 'ERROR' - AND collection_time >= $2 AND collection_time < $3), 0 - ) AS collection_errors"; - + command.CommandText = DailySummaryRangeSql; command.Parameters.Add(new DuckDBParameter { Value = serverId }); - command.Parameters.Add(new DuckDBParameter { Value = dayStart }); - command.Parameters.Add(new DuckDBParameter { Value = dayEnd }); + command.Parameters.Add(new DuckDBParameter { Value = fromDate.Date }); + command.Parameters.Add(new DuckDBParameter { Value = toDate.Date }); + var results = new List<DailySummaryRow>(); using var reader = await command.ExecuteReaderAsync(); - if (!await reader.ReadAsync()) return null; + while (await reader.ReadAsync()) + { + results.Add(ReadDailySummaryRow(reader)); + } - var deadlocks = reader.IsDBNull(3) ? 0L : Convert.ToInt64(reader.GetValue(3)); - var blocking = reader.IsDBNull(4) ? 0L : Convert.ToInt64(reader.GetValue(4)); - var highCpu = reader.IsDBNull(5) ? 0L : Convert.ToInt64(reader.GetValue(5)); + return results; + } - var health = "NORMAL"; - if (deadlocks > 0) health = "DEADLOCKS"; - else if (highCpu > 5) health = "CPU_CRITICAL"; - else if (blocking > 10) health = "BLOCKING"; + /// <summary> + /// Gets the daily summary for a specific date (or today if null). Delegates to the range query for a + /// single day so the single-day contract shares the one aggregate; returns a No-Data row when the day + /// had no collection. + /// </summary> + public async Task<DailySummaryRow?> GetDailySummaryAsync(int serverId, DateTime? summaryDate = null) + { + var targetDate = summaryDate?.Date ?? DateTime.UtcNow.Date; + var rows = await GetDailySummaryRangeAsync(serverId, targetDate, targetDate.AddDays(1)); + return rows.Count > 0 + ? rows[0] + : new DailySummaryRow { SummaryDate = targetDate, HasData = false, HealthBand = DailyHealthBand.NoData }; + } - return new DailySummaryRow + private static DailySummaryRow ReadDailySummaryRow(System.Data.Common.DbDataReader reader) + { + var row = new DailySummaryRow { - SummaryDate = targetDate, - TotalWaitTimeSec = reader.IsDBNull(0) ? 0m : Convert.ToDecimal(reader.GetValue(0)), - TopWaitType = reader.IsDBNull(1) ? "" : reader.GetString(1), - UniqueQueries = reader.IsDBNull(2) ? 0L : Convert.ToInt64(reader.GetValue(2)), - DeadlockCount = deadlocks, - BlockingEvents = blocking, - HighCpuEvents = highCpu, - CollectionErrors = reader.IsDBNull(6) ? 0L : Convert.ToInt64(reader.GetValue(6)), - OverallHealth = health + SummaryDate = reader.IsDBNull(0) ? DateTime.MinValue : Convert.ToDateTime(reader.GetValue(0)), + TotalWaitTimeSec = reader.IsDBNull(1) ? 0m : Convert.ToDecimal(reader.GetValue(1)), + TopWaitType = reader.IsDBNull(2) ? "" : reader.GetString(2), + UniqueQueries = reader.IsDBNull(3) ? 0L : Convert.ToInt64(reader.GetValue(3)), + DeadlockCount = reader.IsDBNull(4) ? 0L : Convert.ToInt64(reader.GetValue(4)), + BlockingEvents = reader.IsDBNull(5) ? 0L : Convert.ToInt64(reader.GetValue(5)), + HighCpuEvents = reader.IsDBNull(6) ? 0L : Convert.ToInt64(reader.GetValue(6)), + CollectionErrors = reader.IsDBNull(7) ? 0L : Convert.ToInt64(reader.GetValue(7)), + MemoryPressureEvents = reader.IsDBNull(8) ? 0L : Convert.ToInt64(reader.GetValue(8)), + MemoryCriticalEvents = reader.IsDBNull(9) ? 0L : Convert.ToInt64(reader.GetValue(9)), + AlertCount = reader.IsDBNull(10) ? 0L : Convert.ToInt64(reader.GetValue(10)), + MaxBlockDurationMs = reader.IsDBNull(11) ? 0L : Convert.ToInt64(reader.GetValue(11)), + HasData = true, }; + row.HealthBand = DailyHealthBandCalculator.Classify(row.ToSignals()); + return row; } } @@ -123,11 +211,42 @@ public class DailySummaryRow public long DeadlockCount { get; set; } public long BlockingEvents { get; set; } public long HighCpuEvents { get; set; } + public long MemoryPressureEvents { get; set; } + public long MemoryCriticalEvents { get; set; } public long CollectionErrors { get; set; } - public string OverallHealth { get; set; } = ""; + public long AlertCount { get; set; } + + /// <summary>The day's peak/max block wait in ms (0 when no blocking, or blocking from a source without a + /// wait time). Surfaced on the day-detail panel's blocking reason.</summary> + public long MaxBlockDurationMs { get; set; } + + /// <summary>True when the day had any collection. False renders the calendar cell as No-Data (grey).</summary> + public bool HasData { get; set; } + + /// <summary>The composite health band that colors this day's calendar cell.</summary> + public DailyHealthBand HealthBand { get; set; } = DailyHealthBand.NoData; + + /// <summary>Human label for the band ("Healthy" / "Warning" / "Critical" / "No Data"), shown in the day detail.</summary> + public string OverallHealth => DailyHealthBandCalculator.Label(HealthBand); public string SummaryDateFormatted => SummaryDate.ToString("yyyy-MM-dd"); public string TotalWaitFormatted => TotalWaitTimeSec < 1000 ? $"{TotalWaitTimeSec:N1} s" : $"{TotalWaitTimeSec / 60:N1} min"; + + /// <summary>Multi-line hover text summarizing the day's signals, for the calendar cell tooltip.</summary> + public string SignalsTooltip => DailyHealthBandCalculator.Describe(ToSignals()); + + /// <summary>Projects this row's counts into the shared banding input.</summary> + public DailyHealthSignals ToSignals() => new() + { + HasData = HasData, + Deadlocks = DeadlockCount, + CollectionErrors = CollectionErrors, + HighCpuEvents = HighCpuEvents, + BlockingEvents = BlockingEvents, + MemoryPressureEvents = MemoryPressureEvents, + MemoryCriticalEvents = MemoryCriticalEvents, + AlertCount = AlertCount, + }; } diff --git a/Lite/Services/LocalDataService.DatabaseFilter.cs b/Lite/Services/LocalDataService.DatabaseFilter.cs new file mode 100644 index 000000000..cf249af4c --- /dev/null +++ b/Lite/Services/LocalDataService.DatabaseFilter.cs @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Collections.Generic; +using System.Text; + +namespace PerformanceMonitorLite.Services; + +public partial class LocalDataService +{ + /// <summary> + /// Builds the positional DuckDB predicate for the global database filter (issue #1319): + /// <c>" AND {column} IN ($k, $k+1, ...)"</c>, mirroring the multi-value <c>IN</c> push-down the + /// wait-type trends already use. Returns <c>""</c> (no clause, <paramref name="values"/> empty) when + /// the selection is null or empty so callers short-circuit to today's unfiltered behavior — the + /// caller MUST then add no database parameters. When non-empty, one object per database is appended + /// to <paramref name="values"/> in <c>$k</c> order; the caller adds them as DuckDBParameters + /// IMMEDIATELY AFTER its fixed parameters (so positional index <paramref name="firstParamIndex"/> + /// lines up). The returned clause may be spliced into more than one WHERE (e.g. a comparison query's + /// current + baseline CTEs) because a positional parameter can be referenced repeatedly — in that case + /// still add <paramref name="values"/> only once. + /// </summary> + /// <param name="databases">Selected databases, or null/empty for "All" (no filter).</param> + /// <param name="column">The unqualified/qualified database-name column to filter (e.g. "database_name").</param> + /// <param name="firstParamIndex">The 1-based positional index of the FIRST database parameter (one past the caller's fixed params).</param> + /// <param name="values">Receives the database values to bind, in order; empty when no clause is emitted.</param> + internal static string BuildDbInClause( + IReadOnlyList<string>? databases, + string column, + int firstParamIndex, + out List<object> values) + { + values = new List<object>(); + if (databases == null || databases.Count == 0) + { + return ""; + } + + var sb = new StringBuilder(); + sb.Append(" AND ").Append(column).Append(" IN ("); + for (int i = 0; i < databases.Count; i++) + { + if (i > 0) + { + sb.Append(", "); + } + sb.Append('$').Append(firstParamIndex + i); + values.Add(databases[i]); + } + sb.Append(')'); + return sb.ToString(); + } +} diff --git a/Lite/Services/LocalDataService.Databases.cs b/Lite/Services/LocalDataService.Databases.cs new file mode 100644 index 000000000..3b4c869c1 --- /dev/null +++ b/Lite/Services/LocalDataService.Databases.cs @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Collections.Generic; +using System.Threading.Tasks; +using DuckDB.NET.Data; + +namespace PerformanceMonitorLite.Services; + +public partial class LocalDataService +{ + /// <summary> + /// DISTINCT user-database names the store has collected for one server, from either the database + /// config snapshot or the per-file size stats (UNION dedupes across both), system databases removed, + /// ordered by name. Ported verbatim from the Darling viewer's collected-database query (issue #1319) + /// so the global database filter's picker offers the same list on both apps. $1 = server_id. + /// </summary> + public const string CollectedDatabaseNamesSql = @" +SELECT database_name +FROM ( + SELECT database_name FROM v_database_config WHERE server_id = $1 + UNION + SELECT database_name FROM v_database_size_stats WHERE server_id = $1 +) AS d +WHERE database_name IS NOT NULL +AND database_name NOT IN ('master', 'model', 'msdb', 'tempdb') +ORDER BY database_name"; + + /// <summary> + /// The distinct user databases the store has collected for one server (empty when nothing has been + /// collected yet). Feeds the global database-filter picker's checkbox list (issue #1319). + /// </summary> + public async Task<List<string>> GetCollectedDatabaseNamesAsync(int serverId) + { + var names = new List<string>(); + + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + command.CommandText = CollectedDatabaseNamesSql; + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + if (!reader.IsDBNull(0)) + { + names.Add(reader.GetString(0)); + } + } + + return names; + } +} diff --git a/Lite/Services/LocalDataService.FileIo.cs b/Lite/Services/LocalDataService.FileIo.cs index 15cd4109c..b0455aa0c 100644 --- a/Lite/Services/LocalDataService.FileIo.cs +++ b/Lite/Services/LocalDataService.FileIo.cs @@ -92,16 +92,16 @@ LIMIT 10 f.database_name, f.file_name, CASE WHEN SUM(f.delta_reads) > 0 - THEN SUM(CAST(f.delta_stall_read_ms AS DOUBLE)) / SUM(f.delta_reads) + THEN SUM(CAST(f.delta_stall_read_ms AS DOUBLE PRECISION)) / SUM(f.delta_reads) ELSE 0 END AS avg_read_latency_ms, CASE WHEN SUM(f.delta_writes) > 0 - THEN SUM(CAST(f.delta_stall_write_ms AS DOUBLE)) / SUM(f.delta_writes) + THEN SUM(CAST(f.delta_stall_write_ms AS DOUBLE PRECISION)) / SUM(f.delta_writes) ELSE 0 END AS avg_write_latency_ms, CASE WHEN SUM(f.delta_reads) > 0 - THEN SUM(CAST(COALESCE(f.delta_stall_queued_read_ms, 0) AS DOUBLE)) / SUM(f.delta_reads) + THEN SUM(CAST(COALESCE(f.delta_stall_queued_read_ms, 0) AS DOUBLE PRECISION)) / SUM(f.delta_reads) ELSE 0 END AS avg_queued_read_latency_ms, CASE WHEN SUM(f.delta_writes) > 0 - THEN SUM(CAST(COALESCE(f.delta_stall_queued_write_ms, 0) AS DOUBLE)) / SUM(f.delta_writes) + THEN SUM(CAST(COALESCE(f.delta_stall_queued_write_ms, 0) AS DOUBLE PRECISION)) / SUM(f.delta_writes) ELSE 0 END AS avg_queued_write_latency_ms FROM v_file_io_stats f JOIN top_files tf ON tf.database_name = f.database_name AND tf.file_name = f.file_name @@ -177,10 +177,10 @@ FROM v_file_io_stats f collection_time, file_label, CASE WHEN interval_seconds > 0 - THEN CAST(delta_read_bytes AS DOUBLE) / interval_seconds / 1048576.0 + THEN CAST(delta_read_bytes AS DOUBLE PRECISION) / interval_seconds / 1048576.0 ELSE 0 END AS read_mb_per_sec, CASE WHEN interval_seconds > 0 - THEN CAST(delta_write_bytes AS DOUBLE) / interval_seconds / 1048576.0 + THEN CAST(delta_write_bytes AS DOUBLE PRECISION) / interval_seconds / 1048576.0 ELSE 0 END AS write_mb_per_sec FROM with_interval WHERE interval_seconds IS NOT NULL AND interval_seconds > 0 @@ -220,8 +220,8 @@ public async Task<List<FileIoTrendPoint>> GetTempDbFileIoTrendAsync(int serverId SELECT collection_time, file_name, - CASE WHEN SUM(delta_reads) > 0 THEN SUM(CAST(delta_stall_read_ms AS DOUBLE)) / SUM(delta_reads) ELSE 0 END AS avg_read_latency_ms, - CASE WHEN SUM(delta_writes) > 0 THEN SUM(CAST(delta_stall_write_ms AS DOUBLE)) / SUM(delta_writes) ELSE 0 END AS avg_write_latency_ms + CASE WHEN SUM(delta_reads) > 0 THEN SUM(CAST(delta_stall_read_ms AS DOUBLE PRECISION)) / SUM(delta_reads) ELSE 0 END AS avg_read_latency_ms, + CASE WHEN SUM(delta_writes) > 0 THEN SUM(CAST(delta_stall_write_ms AS DOUBLE PRECISION)) / SUM(delta_writes) ELSE 0 END AS avg_write_latency_ms FROM v_file_io_stats WHERE server_id = $1 AND collection_time >= $2 diff --git a/Lite/Services/LocalDataService.FinOps.IndexObjects.cs b/Lite/Services/LocalDataService.FinOps.IndexObjects.cs index 9549c65ac..72a0144f6 100644 --- a/Lite/Services/LocalDataService.FinOps.IndexObjects.cs +++ b/Lite/Services/LocalDataService.FinOps.IndexObjects.cs @@ -39,7 +39,7 @@ WITH boundaries AS ( SELECT MAX(collection_time) AS latest_time, MIN(collection_time) AS earliest_time, - date_diff('day', MIN(collection_time), MAX(collection_time)) AS days_of_data + CAST(MAX(collection_time) AS DATE) - CAST(MIN(collection_time) AS DATE) AS days_of_data FROM v_index_object_stats WHERE server_id = $1 ), @@ -84,7 +84,7 @@ FROM v_index_object_stats l.current_reserved_mb - COALESCE(p7.reserved_mb, o.reserved_mb, l.current_reserved_mb) AS growth_7d_mb, l.current_reserved_mb - COALESCE(p30.reserved_mb, p7.reserved_mb, o.reserved_mb, l.current_reserved_mb) AS growth_30d_mb, CASE WHEN b.days_of_data >= 1 - THEN (l.current_reserved_mb - COALESCE(o.reserved_mb, l.current_reserved_mb)) / CAST(b.days_of_data AS DOUBLE) + THEN (l.current_reserved_mb - COALESCE(o.reserved_mb, l.current_reserved_mb)) / CAST(b.days_of_data AS DOUBLE PRECISION) ELSE 0 END AS daily_growth_rate_mb, CASE WHEN COALESCE(p30.reserved_mb, p7.reserved_mb, o.reserved_mb) > 0 THEN (l.current_reserved_mb - COALESCE(p30.reserved_mb, p7.reserved_mb, o.reserved_mb)) * 100.0 diff --git a/Lite/Services/LocalDataService.FinOps.ServerProperties.cs b/Lite/Services/LocalDataService.FinOps.ServerProperties.cs index e4279b306..c0a03e813 100644 --- a/Lite/Services/LocalDataService.FinOps.ServerProperties.cs +++ b/Lite/Services/LocalDataService.FinOps.ServerProperties.cs @@ -17,16 +17,18 @@ namespace PerformanceMonitorLite.Services; public partial class LocalDataService { /// <summary> - /// Queries a SQL Server directly for its properties via SERVERPROPERTY + sys.dm_os_sys_info. - /// Works from any database context — no PerformanceMonitor DB required. + /// Inventory facts: edition, product version/level, storage, HADR/cluster flags, host OS, and AG + /// replica role. Every value comes from SERVERPROPERTY scalars or OBJECT_ID-guarded dynamic SQL + /// that needs NO special permission, so this succeeds for any connected login — even an Azure SQL + /// DB login lacking VIEW DATABASE STATE. The five hardware columns (reader indices 4, 5, 6, 8, 9) + /// are typed-NULL placeholders here, filled by a separate <see cref="HardwareQueryText"/> read; + /// keeping them preserves the reader column order. sys.dm_os_sys_info is deliberately NOT + /// referenced so a permission gap can never sink the whole inventory row (#1535). + /// Columns: 0 edition, 1 product_version, 2 product_level, 3 product_update_level, 4 cpu_count, + /// 5 physical_memory_mb, 6 sqlserver_start_time, 7 storage_gb, 8 socket_count, 9 cores_per_socket, + /// 10 engine_edition, 11 is_hadr_enabled, 12 is_clustered, 13 host_os, 14 ag_replica_role. /// </summary> - public static async Task<ServerPropertyRow> GetServerPropertiesLiveAsync(string connectionString) - { - using var connection = new SqlConnection(connectionString); - await connection.OpenAsync(); - - // sys.master_files doesn't exist on Azure SQL DB — dynamic SQL picks the right catalog view - const string query = @" + internal const string InventoryQueryText = @" DECLARE @storage_sql nvarchar(MAX) = CASE @@ -88,23 +90,50 @@ ELSE CONVERT(nvarchar(256), SERVERPROPERTY('Edition')) CONVERT(nvarchar(128), SERVERPROPERTY('ProductVersion')), CONVERT(nvarchar(128), SERVERPROPERTY('ProductLevel')), CONVERT(nvarchar(128), SERVERPROPERTY('ProductUpdateLevel')), - si.cpu_count, - si.physical_memory_kb / 1024, - si.sqlserver_start_time, + CONVERT(int, NULL), + CONVERT(bigint, NULL), + CONVERT(datetime, NULL), @storage_gb, - si.socket_count, - si.cores_per_socket, + CONVERT(int, NULL), + CONVERT(int, NULL), CONVERT(int, SERVERPROPERTY('EngineEdition')), CONVERT(int, SERVERPROPERTY('IsHadrEnabled')), CONVERT(int, SERVERPROPERTY('IsClustered')), @host_os, - @ag_role -FROM sys.dm_os_sys_info AS si;"; + @ag_role;"; - using var command = new SqlCommand(query, connection) { CommandTimeout = 30 }; - using var reader = await command.ExecuteReaderAsync(); - if (await reader.ReadAsync()) + /// <summary> + /// Hardware facts from sys.dm_os_sys_info: CPU count, physical memory (MB), start time, and + /// socket/core topology. That DMV needs VIEW SERVER STATE (VIEW DATABASE STATE on Azure SQL DB), + /// so it runs in its OWN best-effort read — a permission gap loses only these fields, never the + /// inventory row above (#1535). Columns: 0 cpu_count, 1 physical_memory_mb, 2 sqlserver_start_time, + /// 3 socket_count, 4 cores_per_socket. + /// </summary> + internal const string HardwareQueryText = + "SELECT cpu_count, physical_memory_kb / 1024, sqlserver_start_time, socket_count, cores_per_socket FROM sys.dm_os_sys_info;"; + + /// <summary> + /// Queries a SQL Server directly for its properties. Inventory facts (edition, version, storage) + /// come from a permission-free query; hardware facts (CPU/memory/sockets) come from a separate + /// best-effort read of sys.dm_os_sys_info, so a login without VIEW DATABASE STATE still gets the + /// inventory row with <see cref="ServerPropertyRow.HardwareUnavailableReason"/> set. + /// Works from any database context — no PerformanceMonitor DB required. + /// </summary> + public static async Task<ServerPropertyRow> GetServerPropertiesLiveAsync(string connectionString) + { + using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(); + + ServerPropertyRow result; + + // Inventory read. Scoped closed in a using(...) block BEFORE the hardware read opens: the + // connection has no MARS, so an overlapping second reader would throw (#1535/#1589). + using (var command = new SqlCommand(InventoryQueryText, connection) { CommandTimeout = 30 }) + using (var reader = await command.ExecuteReaderAsync()) { + if (!await reader.ReadAsync()) + return new ServerPropertyRow(); + var version = reader.IsDBNull(1) ? "" : reader.GetString(1); var level = reader.IsDBNull(2) ? "" : reader.GetString(2); var updateLevel = reader.IsDBNull(3) ? null : reader.GetString(3); @@ -112,7 +141,7 @@ ELSE CONVERT(nvarchar(256), SERVERPROPERTY('Edition')) ? $"{version} - {updateLevel}" : $"{version} - {level}"; - return new ServerPropertyRow + result = new ServerPropertyRow { Edition = reader.IsDBNull(0) ? "" : reader.GetString(0), ProductVersion = versionDisplay, @@ -131,7 +160,30 @@ ELSE CONVERT(nvarchar(256), SERVERPROPERTY('Edition')) }; } - return new ServerPropertyRow(); + // Hardware read: best-effort in its OWN try/catch. sys.dm_os_sys_info needs VIEW SERVER STATE + // (VIEW DATABASE STATE on Azure SQL DB); a login without it loses only these fields, and the + // inventory row above still renders edition/version/storage (#1535). + try + { + using var hardwareCommand = new SqlCommand(HardwareQueryText, connection) { CommandTimeout = 30 }; + using var hardwareReader = await hardwareCommand.ExecuteReaderAsync(); + if (await hardwareReader.ReadAsync()) + { + result.CpuCount = hardwareReader.IsDBNull(0) ? 0 : Convert.ToInt32(hardwareReader.GetValue(0)); + result.PhysicalMemoryMb = hardwareReader.IsDBNull(1) ? 0L : Convert.ToInt64(hardwareReader.GetValue(1)); + result.SqlServerStartTime = hardwareReader.IsDBNull(2) ? null : hardwareReader.GetDateTime(2); + result.SocketCount = hardwareReader.IsDBNull(3) ? null : Convert.ToInt32(hardwareReader.GetValue(3)); + result.CoresPerSocket = hardwareReader.IsDBNull(4) ? null : Convert.ToInt32(hardwareReader.GetValue(4)); + } + } + catch (SqlException ex) + { + result.HardwareUnavailableReason = + "Hardware inventory (CPU, memory, sockets) unavailable - the monitoring login likely lacks VIEW DATABASE STATE on this database."; + AppLogger.Warn("FinOps", $"Hardware inventory unavailable for '{connection.DataSource}': {ex.Message}"); + } + + return result; } /// <summary> @@ -235,159 +287,4 @@ LEFT JOIN storage_totals st ON true return (null, null, null, null); } - - /// <summary> - /// Gets the latest server properties snapshot per server (cross-server) from DuckDB. - /// Fallback for when live query is not available. - /// </summary> - public async Task<List<ServerPropertyRow>> GetServerPropertiesLatestAsync(IEnumerable<int>? activeServerIds = null) - { - using var connection = await OpenConnectionAsync(); - using var command = connection.CreateCommand(); - - var cpuCutoff = DateTime.UtcNow.AddHours(-24); - var idleCutoff = DateTime.UtcNow.AddDays(-7); - var recentCutoff = DateTime.UtcNow.AddHours(-24); - - // Build server ID filter — integers only, safe to inline - var serverFilter = ""; - if (activeServerIds != null) - { - var idList = string.Join(",", activeServerIds); - if (!string.IsNullOrEmpty(idList)) - serverFilter = $"AND server_id IN ({idList})"; - } - - command.CommandText = $@" -WITH active_servers AS ( - SELECT DISTINCT server_id, server_name - FROM v_cpu_utilization_stats - WHERE collection_time >= $3 - {serverFilter} -), -latest_props AS ( - SELECT * - FROM v_server_properties - WHERE (server_id, collection_time) IN ( - SELECT server_id, MAX(collection_time) - FROM v_server_properties - GROUP BY server_id - ) -), -cpu_24h AS ( - SELECT - server_id, - AVG(CAST(sqlserver_cpu_utilization AS DECIMAL(5,2))) AS avg_cpu_pct, - MAX(sqlserver_cpu_utilization) AS max_cpu_pct, - PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY sqlserver_cpu_utilization) AS p95_cpu_pct - FROM v_cpu_utilization_stats - WHERE collection_time >= $1 - GROUP BY server_id -), -mem_latest AS ( - SELECT - server_id, - CAST(total_server_memory_mb AS DECIMAL(10,2)) / NULLIF(target_server_memory_mb, 0) AS memory_ratio - FROM v_memory_stats - WHERE (server_id, collection_time) IN ( - SELECT server_id, MAX(collection_time) - FROM v_memory_stats - GROUP BY server_id - ) -), -storage_totals AS ( - SELECT - server_id, - SUM(total_size_mb) / 1024.0 AS total_storage_gb - FROM v_database_size_stats - WHERE (server_id, collection_time) IN ( - SELECT server_id, MAX(collection_time) - FROM v_database_size_stats - GROUP BY server_id - ) - GROUP BY server_id -), -idle_dbs AS ( - SELECT - server_id, - COUNT(DISTINCT database_name) AS idle_db_count - FROM ( - SELECT server_id, database_name - FROM v_database_size_stats - WHERE (server_id, collection_time) IN ( - SELECT server_id, MAX(collection_time) - FROM v_database_size_stats - GROUP BY server_id - ) - AND database_name NOT IN ('master', 'model', 'msdb', 'tempdb', 'PerformanceMonitor') - EXCEPT - SELECT DISTINCT server_id, database_name - FROM v_query_stats - WHERE collection_time >= $2 - AND delta_execution_count > 0 - ) AS idle - GROUP BY server_id -) -SELECT - a.server_name, - sp.edition, - sp.product_version, - sp.product_level, - sp.product_update_level, - sp.engine_edition, - COALESCE(sp.vcore_count, sp.cpu_count) AS cpu_count, - sp.physical_memory_mb, - sp.socket_count, - sp.cores_per_socket, - sp.is_hadr_enabled, - sp.is_clustered, - c.avg_cpu_pct, - st.total_storage_gb, - id.idle_db_count, - CASE - WHEN c.avg_cpu_pct < 15 AND c.max_cpu_pct < 40 AND COALESCE(m.memory_ratio, 0) < 0.5 - THEN 'OVER_PROVISIONED' - WHEN c.p95_cpu_pct > 85 OR COALESCE(m.memory_ratio, 0) > 0.95 - THEN 'UNDER_PROVISIONED' - ELSE 'RIGHT_SIZED' - END AS provisioning_status -FROM active_servers a -LEFT JOIN latest_props sp ON sp.server_id = a.server_id -LEFT JOIN cpu_24h c ON c.server_id = a.server_id -LEFT JOIN mem_latest m ON m.server_id = a.server_id -LEFT JOIN storage_totals st ON st.server_id = a.server_id -LEFT JOIN idle_dbs id ON id.server_id = a.server_id -ORDER BY a.server_name"; - - command.Parameters.Add(new DuckDBParameter { Value = cpuCutoff }); - command.Parameters.Add(new DuckDBParameter { Value = idleCutoff }); - command.Parameters.Add(new DuckDBParameter { Value = recentCutoff }); - - var items = new List<ServerPropertyRow>(); - using var reader = await command.ExecuteReaderAsync(); - while (await reader.ReadAsync()) - { - items.Add(new ServerPropertyRow - { - ServerName = reader.IsDBNull(0) ? "" : reader.GetString(0), - Edition = reader.IsDBNull(1) ? "" : reader.GetString(1), - ProductVersion = reader.IsDBNull(2) ? "" : reader.GetString(2), - ProductLevel = reader.IsDBNull(3) ? null : reader.GetString(3), - ProductUpdateLevel = reader.IsDBNull(4) ? null : reader.GetString(4), - EngineEdition = reader.IsDBNull(5) ? 0 : Convert.ToInt32(reader.GetValue(5)), - CpuCount = reader.IsDBNull(6) ? 0 : Convert.ToInt32(reader.GetValue(6)), - PhysicalMemoryMb = reader.IsDBNull(7) ? 0L : ToInt64(reader.GetValue(7)), - SocketCount = reader.IsDBNull(8) ? null : Convert.ToInt32(reader.GetValue(8)), - CoresPerSocket = reader.IsDBNull(9) ? null : Convert.ToInt32(reader.GetValue(9)), - IsHadrEnabled = reader.IsDBNull(10) ? null : reader.GetBoolean(10), - IsClustered = reader.IsDBNull(11) ? null : reader.GetBoolean(11), - AvgCpuPct = reader.IsDBNull(12) ? null : Convert.ToDecimal(reader.GetValue(12)), - StorageTotalGb = reader.IsDBNull(13) ? null : Convert.ToDecimal(reader.GetValue(13)), - IdleDbCount = reader.IsDBNull(14) ? null : Convert.ToInt32(reader.GetValue(14)), - ProvisioningStatus = reader.IsDBNull(15) ? null : reader.GetString(15) - }); - } - - return items; - } } diff --git a/Lite/Services/LocalDataService.FinOps.StorageGrowth.cs b/Lite/Services/LocalDataService.FinOps.StorageGrowth.cs index 88bad70c6..a9266c9e2 100644 --- a/Lite/Services/LocalDataService.FinOps.StorageGrowth.cs +++ b/Lite/Services/LocalDataService.FinOps.StorageGrowth.cs @@ -15,45 +15,6 @@ namespace PerformanceMonitorLite.Services; public partial class LocalDataService { - /// <summary> - /// Gets database size trend (total_size_mb per database per collection) for a specific server. - /// </summary> - public async Task<List<DatabaseSizeTrendPoint>> GetDatabaseSizeTrendAsync(int serverId, int daysBack = 30) - { - using var connection = await OpenConnectionAsync(); - using var command = connection.CreateCommand(); - - var cutoff = DateTime.UtcNow.AddDays(-daysBack); - - command.CommandText = @" -SELECT - collection_time, - database_name, - SUM(total_size_mb) AS total_size_mb -FROM v_database_size_stats -WHERE server_id = $1 -AND collection_time >= $2 -GROUP BY collection_time, database_name -ORDER BY collection_time, database_name"; - - command.Parameters.Add(new DuckDBParameter { Value = serverId }); - command.Parameters.Add(new DuckDBParameter { Value = cutoff }); - - var items = new List<DatabaseSizeTrendPoint>(); - using var reader = await command.ExecuteReaderAsync(); - while (await reader.ReadAsync()) - { - items.Add(new DatabaseSizeTrendPoint - { - CollectionTime = reader.GetDateTime(0), - DatabaseName = reader.IsDBNull(1) ? "" : reader.GetString(1), - TotalSizeMb = reader.IsDBNull(2) ? 0m : Convert.ToDecimal(reader.GetValue(2)) - }); - } - - return items; - } - /// <summary> /// Gets per-database storage growth trends comparing current size to 7d and 30d ago. /// </summary> diff --git a/Lite/Services/LocalDataService.FinOps.Workload.cs b/Lite/Services/LocalDataService.FinOps.Workload.cs index d0613d2fe..09904171d 100644 --- a/Lite/Services/LocalDataService.FinOps.Workload.cs +++ b/Lite/Services/LocalDataService.FinOps.Workload.cs @@ -29,7 +29,7 @@ public async Task<List<DatabaseResourceUsageRow>> GetDatabaseResourceUsageAsync( WITH workload AS ( SELECT database_name, - SUM(delta_worker_time) / 1000 AS cpu_time_ms, + SUM(delta_worker_time) / 1000.0 AS cpu_time_ms, SUM(delta_logical_reads) AS logical_reads, SUM(delta_physical_reads) AS physical_reads, SUM(delta_logical_writes) AS logical_writes, @@ -116,8 +116,10 @@ WHERE c.database_name IS NOT NULL } /// <summary> - /// Gets per-application connection counts from session_stats (last 24 hours). - /// Aggregates snapshots of sys.dm_exec_sessions grouped by program_name. + /// Gets per-application connection counts plus the collected per-app resource + session-status metrics + /// from session_stats (last 24 hours). Aggregates snapshots of sys.dm_exec_sessions grouped by + /// program_name: AVG/MAX for connection/running/sleeping/dormant counts and CPU/reads/writes/logical-reads + /// (the resource columns are nullable, so AVG/MAX yield NULL until populated). /// </summary> public async Task<List<ApplicationConnectionRow>> GetApplicationConnectionsAsync(int serverId) { @@ -131,6 +133,20 @@ public async Task<List<ApplicationConnectionRow>> GetApplicationConnectionsAsync program_name, CAST(AVG(connection_count) AS INTEGER) AS avg_connections, MAX(connection_count) AS max_connections, + CAST(AVG(running_count) AS INTEGER) AS avg_running, + MAX(running_count) AS max_running, + CAST(AVG(sleeping_count) AS INTEGER) AS avg_sleeping, + MAX(sleeping_count) AS max_sleeping, + CAST(AVG(dormant_count) AS INTEGER) AS avg_dormant, + MAX(dormant_count) AS max_dormant, + CAST(AVG(total_cpu_time_ms) AS BIGINT) AS avg_cpu_time_ms, + MAX(total_cpu_time_ms) AS max_cpu_time_ms, + CAST(AVG(total_reads) AS BIGINT) AS avg_reads, + MAX(total_reads) AS max_reads, + CAST(AVG(total_writes) AS BIGINT) AS avg_writes, + MAX(total_writes) AS max_writes, + CAST(AVG(total_logical_reads) AS BIGINT) AS avg_logical_reads, + MAX(total_logical_reads) AS max_logical_reads, COUNT(*) AS sample_count, MIN(collection_time) AS first_seen, MAX(collection_time) AS last_seen @@ -152,9 +168,23 @@ GROUP BY program_name ApplicationName = reader.GetString(0), AvgConnections = reader.IsDBNull(1) ? 0 : Convert.ToInt32(reader.GetValue(1)), MaxConnections = reader.IsDBNull(2) ? 0 : Convert.ToInt32(reader.GetValue(2)), - SampleCount = reader.IsDBNull(3) ? 0 : ToInt64(reader.GetValue(3)), - FirstSeen = reader.GetDateTime(4), - LastSeen = reader.GetDateTime(5) + AvgRunning = reader.IsDBNull(3) ? 0 : Convert.ToInt32(reader.GetValue(3)), + MaxRunning = reader.IsDBNull(4) ? 0 : Convert.ToInt32(reader.GetValue(4)), + AvgSleeping = reader.IsDBNull(5) ? 0 : Convert.ToInt32(reader.GetValue(5)), + MaxSleeping = reader.IsDBNull(6) ? 0 : Convert.ToInt32(reader.GetValue(6)), + AvgDormant = reader.IsDBNull(7) ? 0 : Convert.ToInt32(reader.GetValue(7)), + MaxDormant = reader.IsDBNull(8) ? 0 : Convert.ToInt32(reader.GetValue(8)), + AvgCpuTimeMs = reader.IsDBNull(9) ? 0L : ToInt64(reader.GetValue(9)), + MaxCpuTimeMs = reader.IsDBNull(10) ? 0L : ToInt64(reader.GetValue(10)), + AvgReads = reader.IsDBNull(11) ? 0L : ToInt64(reader.GetValue(11)), + MaxReads = reader.IsDBNull(12) ? 0L : ToInt64(reader.GetValue(12)), + AvgWrites = reader.IsDBNull(13) ? 0L : ToInt64(reader.GetValue(13)), + MaxWrites = reader.IsDBNull(14) ? 0L : ToInt64(reader.GetValue(14)), + AvgLogicalReads = reader.IsDBNull(15) ? 0L : ToInt64(reader.GetValue(15)), + MaxLogicalReads = reader.IsDBNull(16) ? 0L : ToInt64(reader.GetValue(16)), + SampleCount = reader.IsDBNull(17) ? 0 : ToInt64(reader.GetValue(17)), + FirstSeen = reader.GetDateTime(18), + LastSeen = reader.GetDateTime(19) }); } @@ -175,7 +205,7 @@ public async Task<List<TopResourceConsumerRow>> GetTopResourceConsumersByTotalAs WITH workload AS ( SELECT database_name, - SUM(delta_worker_time) / 1000 AS cpu_time_ms, + SUM(delta_worker_time) / 1000.0 AS cpu_time_ms, SUM(delta_execution_count) AS execution_count FROM v_query_stats WHERE server_id = $1 @@ -256,7 +286,7 @@ public async Task<List<TopResourceConsumerRow>> GetTopResourceConsumersByAvgAsyn WITH workload AS ( SELECT database_name, - SUM(delta_worker_time) / 1000 AS cpu_time_ms, + SUM(delta_worker_time) / 1000.0 AS cpu_time_ms, SUM(delta_execution_count) AS execution_count FROM v_query_stats WHERE server_id = $1 @@ -414,7 +444,7 @@ public async Task<List<ExpensiveQueryRow>> GetExpensiveQueriesAsync(int serverId command.CommandText = @" SELECT database_name, - SUM(delta_worker_time) / 1000 AS total_cpu_ms, + SUM(delta_worker_time) / 1000.0 AS total_cpu_ms, CAST(SUM(delta_worker_time) / 1000.0 / NULLIF(SUM(delta_execution_count), 0) AS DECIMAL(19,2)) AS avg_cpu_ms, SUM(delta_logical_reads) AS total_reads, CAST(SUM(delta_logical_reads) * 1.0 / NULLIF(SUM(delta_execution_count), 0) AS DECIMAL(19,0)) AS avg_reads, diff --git a/Lite/Services/LocalDataService.FinOps.cs b/Lite/Services/LocalDataService.FinOps.cs index b1031f2e2..4b47a1a72 100644 --- a/Lite/Services/LocalDataService.FinOps.cs +++ b/Lite/Services/LocalDataService.FinOps.cs @@ -112,6 +112,20 @@ public class ApplicationConnectionRow public string ApplicationName { get; set; } = ""; public int AvgConnections { get; set; } public int MaxConnections { get; set; } + public int AvgRunning { get; set; } + public int MaxRunning { get; set; } + public int AvgSleeping { get; set; } + public int MaxSleeping { get; set; } + public int AvgDormant { get; set; } + public int MaxDormant { get; set; } + public long AvgCpuTimeMs { get; set; } + public long MaxCpuTimeMs { get; set; } + public long AvgReads { get; set; } + public long MaxReads { get; set; } + public long AvgWrites { get; set; } + public long MaxWrites { get; set; } + public long AvgLogicalReads { get; set; } + public long MaxLogicalReads { get; set; } public long SampleCount { get; set; } public DateTime FirstSeen { get; set; } public DateTime LastSeen { get; set; } @@ -180,6 +194,13 @@ public class ServerPropertyRow public bool? IsClustered { get; set; } public string AgReplicaRole { get; set; } = "Standalone"; + /// <summary> + /// Set when the sys.dm_os_sys_info hardware read failed (e.g. an Azure SQL DB monitoring login + /// without VIEW DATABASE STATE). Null when hardware inventory is available. Surfaced as a + /// non-alarming note in the FinOps Server Inventory grid (#1535). + /// </summary> + public string? HardwareUnavailableReason { get; set; } + public decimal? AvgCpuPct { get; set; } public decimal? StorageTotalGb { get; set; } public int? IdleDbCount { get; set; } @@ -221,13 +242,6 @@ public string? LicenseWarning public string HealthScoreColor => FinOpsHealthCalculator.ScoreColor(HealthScore); } -public class DatabaseSizeTrendPoint -{ - public DateTime CollectionTime { get; set; } - public string DatabaseName { get; set; } = ""; - public decimal TotalSizeMb { get; set; } -} - public class StorageGrowthRow { public string DatabaseName { get; set; } = ""; diff --git a/Lite/Services/LocalDataService.JobHistory.cs b/Lite/Services/LocalDataService.JobHistory.cs new file mode 100644 index 000000000..672fc8cdb --- /dev/null +++ b/Lite/Services/LocalDataService.JobHistory.cs @@ -0,0 +1,268 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using DuckDB.NET.Data; + +namespace PerformanceMonitorLite.Services; + +public partial class LocalDataService +{ + /// <summary> + /// Retained SQL Agent job-run history for the fleet Job History tab (issue #1433). Reads the archive + /// view <c>v_job_history</c> (hot DuckDB + parquet, deduped by the collector's instance_id watermark), + /// windowing on <c>run_datetime</c> — the time the job actually RAN — so "last N hours/days" means jobs + /// that ran in that window (a first-run backfill of a year of history does NOT flood a short window the + /// way a collection_time filter would). Long-runtime is computed reader-side via a per-job window + /// function (a step_id 0 outcome row exceeding 2x its job's average successful-outcome duration, floored + /// at 60s so tiny jobs never flag), and each row carries its job's last successful outcome run. + /// <para> + /// run_datetime is the monitored server's LOCAL wall clock (decoded from run_date/run_time), so it is + /// windowed against and displayed in local wall-clock time — the time SSMS shows for the run — rather + /// than re-converted. With no <paramref name="serverId"/> the read aggregates ALL servers (the tab + /// default); with one it scopes to that server (the Server filter combo). + /// </para> + /// </summary> + public async Task<List<JobHistoryRow>> GetJobHistoryAsync(int hoursBack = 24, int limit = 1000, int? serverId = null) + { + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + + /* run_datetime is server-local wall clock; the cutoff is a local wall-clock instant so a same-tz + Lite host (the common case) windows "last N hours" against the run time the way a DBA reads it. */ + var cutoff = DateTime.Now.AddHours(-hoursBack); + + var serverFilter = serverId.HasValue ? "AND server_id = $2" : string.Empty; + var limitParam = serverId.HasValue ? "$3" : "$2"; + + command.CommandText = $@" +WITH base AS ( + SELECT + collection_time, + server_id, + server_name, + instance_id, + job_id, + job_name, + job_enabled, + category_name, + step_id, + step_name, + run_status, + run_status_desc, + run_datetime, + run_duration_seconds, + retries_attempted, + message, + AVG(CASE WHEN step_id = 0 AND run_status = 1 THEN run_duration_seconds END) + OVER (PARTITION BY server_id, job_id) AS avg_success_duration, + MAX(CASE WHEN step_id = 0 AND run_status = 1 THEN run_datetime END) + OVER (PARTITION BY server_id, job_id) AS last_success_run + FROM v_job_history + WHERE run_datetime >= $1 + {serverFilter} +) +SELECT + collection_time, + server_id, + server_name, + instance_id, + job_id, + job_name, + job_enabled, + category_name, + step_id, + step_name, + run_status, + run_status_desc, + run_datetime, + run_duration_seconds, + retries_attempted, + message, + last_success_run, + CASE + WHEN step_id = 0 + AND avg_success_duration IS NOT NULL + AND avg_success_duration > 0 + AND run_duration_seconds > avg_success_duration * 2 + AND run_duration_seconds > 60 + THEN TRUE + ELSE FALSE + END AS is_long_running +FROM base +ORDER BY run_datetime DESC, instance_id DESC +LIMIT {limitParam}"; + + command.Parameters.Add(new DuckDBParameter { Value = cutoff }); + if (serverId.HasValue) + command.Parameters.Add(new DuckDBParameter { Value = serverId.Value }); + command.Parameters.Add(new DuckDBParameter { Value = limit }); + + var items = new List<JobHistoryRow>(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new JobHistoryRow + { + CollectionTime = reader.GetDateTime(0), + ServerId = (int)ToInt64(reader.GetValue(1)), + ServerName = reader.GetString(2), + InstanceId = ToInt64(reader.GetValue(3)), + JobId = reader.GetString(4), + JobName = reader.GetString(5), + JobEnabled = reader.GetBoolean(6), + CategoryName = reader.IsDBNull(7) ? null : reader.GetString(7), + StepId = (int)ToInt64(reader.GetValue(8)), + StepName = reader.IsDBNull(9) ? null : reader.GetString(9), + RunStatus = (int)ToInt64(reader.GetValue(10)), + RunStatusDesc = reader.IsDBNull(11) ? null : reader.GetString(11), + RunDateTime = reader.IsDBNull(12) ? null : reader.GetDateTime(12), + RunDurationSeconds = ToInt64(reader.GetValue(13)), + RetriesAttempted = (int)ToInt64(reader.GetValue(14)), + Message = reader.IsDBNull(15) ? null : reader.GetString(15), + LastSuccessfulRun = reader.IsDBNull(16) ? null : reader.GetDateTime(16), + IsLongRunning = !reader.IsDBNull(17) && reader.GetBoolean(17), + }); + } + + return items; + } + + /// <summary> + /// The latest SQL Agent status snapshot per server (issue #1433 Phase 2) — Running/Stopped, startup + /// type, and next scheduled run — read from the hot <c>agent_status</c> table (the newest row per + /// server via a window function). With no <paramref name="serverId"/> it returns one row per server (the + /// fleet header summary); with one it returns just that server's row (the Server-filtered header). The + /// Job History tab shows this in its header; the "Agent Not Running" alert (Darling) reads the same data. + /// </summary> + public async Task<List<AgentStatusRow>> GetAgentStatusAsync(int? serverId = null) + { + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + + var serverFilter = serverId.HasValue ? "WHERE server_id = $1" : string.Empty; + + command.CommandText = $@" +SELECT + server_id, + server_name, + agent_running, + agent_status_desc, + agent_startup_desc, + next_scheduled_run +FROM ( + SELECT + server_id, + server_name, + agent_running, + agent_status_desc, + agent_startup_desc, + next_scheduled_run, + ROW_NUMBER() OVER (PARTITION BY server_id ORDER BY collection_time DESC) AS rn + FROM agent_status + {serverFilter} +) t +WHERE rn = 1 +ORDER BY server_name"; + + if (serverId.HasValue) + command.Parameters.Add(new DuckDBParameter { Value = serverId.Value }); + + var items = new List<AgentStatusRow>(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new AgentStatusRow + { + ServerId = (int)ToInt64(reader.GetValue(0)), + ServerName = reader.GetString(1), + AgentRunning = reader.GetBoolean(2), + AgentStatusDesc = reader.IsDBNull(3) ? null : reader.GetString(3), + AgentStartupDesc = reader.IsDBNull(4) ? null : reader.GetString(4), + NextScheduledRun = reader.IsDBNull(5) ? null : reader.GetDateTime(5), + }); + } + + return items; + } +} + +/// <summary> +/// One retained job-history row for the Job History tab — a single <c>sysjobhistory</c> row (a step or the +/// step_id 0 job-outcome). Display helpers mirror <see cref="RunningJobRow"/>'s duration/local-time +/// formatting; the boolean flags drive the grid's failure / long-runtime / retry color-coding. +/// </summary> +public class JobHistoryRow +{ + public DateTime CollectionTime { get; set; } + public int ServerId { get; set; } + public string ServerName { get; set; } = ""; + public long InstanceId { get; set; } + public string JobId { get; set; } = ""; + public string JobName { get; set; } = ""; + public bool JobEnabled { get; set; } + public string? CategoryName { get; set; } + public int StepId { get; set; } + public string? StepName { get; set; } + public int RunStatus { get; set; } + public string? RunStatusDesc { get; set; } + public DateTime? RunDateTime { get; set; } + public long RunDurationSeconds { get; set; } + public int RetriesAttempted { get; set; } + public string? Message { get; set; } + public DateTime? LastSuccessfulRun { get; set; } + public bool IsLongRunning { get; set; } + + /// <summary>run_datetime is the server's local wall clock; shown as-is (the time SSMS shows).</summary> + public string RunTimeLocal => RunDateTime?.ToString("yyyy-MM-dd HH:mm:ss") ?? ""; + + public string LastSuccessfulRunLocal => LastSuccessfulRun?.ToString("yyyy-MM-dd HH:mm:ss") ?? "Never"; + + public string DurationFormatted => FormatDuration(RunDurationSeconds); + + public string StepDisplay => StepId == 0 + ? "(Job outcome)" + : $"{StepId}: {StepName}"; + + public string RetriesDisplay => RetriesAttempted > 0 ? RetriesAttempted.ToString() : ""; + + public string JobEnabledDisplay => JobEnabled ? "Yes" : "No"; + + public bool IsFailed => RunStatus == 0; + public bool IsSucceeded => RunStatus == 1; + public bool IsRetry => RunStatus == 2; + public bool IsCanceled => RunStatus == 3; + + private static string FormatDuration(long seconds) + { + if (seconds < 60) return $"{seconds}s"; + if (seconds < 3600) return $"{seconds / 60}m {seconds % 60}s"; + return $"{seconds / 3600}h {(seconds % 3600) / 60}m"; + } +} + +/// <summary> +/// The latest SQL Agent status snapshot for one server (issue #1433 Phase 2) — drives the Job History tab +/// header and the "Agent Not Running" alert. next_scheduled_run is the server's local wall clock (from +/// msdb), shown as-is like the job run times. +/// </summary> +public class AgentStatusRow +{ + public int ServerId { get; set; } + public string ServerName { get; set; } = ""; + public bool AgentRunning { get; set; } + public string? AgentStatusDesc { get; set; } + public string? AgentStartupDesc { get; set; } + public DateTime? NextScheduledRun { get; set; } + + public string StatusDisplay => AgentRunning ? "Running" : (AgentStatusDesc ?? "Stopped"); + + public string NextScheduledRunLocal => NextScheduledRun?.ToString("yyyy-MM-dd HH:mm:ss") ?? "None scheduled"; +} diff --git a/Lite/Services/LocalDataService.LatchSpinlock.cs b/Lite/Services/LocalDataService.LatchSpinlock.cs new file mode 100644 index 000000000..e5d4cfbb1 --- /dev/null +++ b/Lite/Services/LocalDataService.LatchSpinlock.cs @@ -0,0 +1,311 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using DuckDB.NET.Data; + +namespace PerformanceMonitorLite.Services; + +public partial class LocalDataService +{ + /* The Latches & Spinlocks readers — the Lite (DuckDB) port of the Darling viewer's + ViewerDataService.LatchSpinlock reads. Both are cumulative-counter DMV tables (like wait_stats), + so the per-second rates are computed in SQL from the per-contender LAG interval — the exact + date_trunc/extract(epoch)/LAG idiom GetWaitStatsTrendAsync uses — because the delta tables carry + no stored sample_interval_seconds. Reads run against the v_latch_stats / v_spinlock_stats archive + views (base table UNION the archived parquet), matching every other Lite trend reader. */ + + /// <summary> + /// The Latch Stats trend: the per-second wait rate for the TOP 5 latch classes (by total delta wait + /// time over the window), normalized to ms/sec via the per-class LAG interval. + /// </summary> + public async Task<List<LatchStatsTrendPoint>> GetLatchStatsTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + { + using var _q = TimeQuery("GetLatchStatsTrendAsync", "v_latch_stats top-5 per-second trend"); + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + + command.CommandText = @" +WITH top_latches AS +( + SELECT latch_class + FROM v_latch_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + GROUP BY latch_class + ORDER BY SUM(delta_wait_time_ms) DESC + LIMIT 5 +), +raw AS +( + SELECT + latch_class, + collection_time, + delta_wait_time_ms, + extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (PARTITION BY latch_class ORDER BY collection_time)))) AS interval_seconds + FROM v_latch_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND latch_class IN (SELECT latch_class FROM top_latches) +) +SELECT + latch_class, + collection_time, + CASE WHEN interval_seconds > 0 THEN CAST(delta_wait_time_ms AS DOUBLE PRECISION) / interval_seconds ELSE 0 END AS wait_time_ms_per_second +FROM raw +ORDER BY latch_class, collection_time"; + + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = startTime }); + command.Parameters.Add(new DuckDBParameter { Value = endTime }); + + var items = new List<LatchStatsTrendPoint>(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new LatchStatsTrendPoint + { + LatchClass = reader.GetString(0), + CollectionTime = reader.GetDateTime(1), + WaitTimeMsPerSecond = reader.IsDBNull(2) ? 0 : reader.GetDouble(2) + }); + } + + return items; + } + + /// <summary> + /// The Latch Stats latest-snapshot grid: every latch class captured at the most recent collection in + /// the window, ordered by the last interval's delta wait time then cumulative wait time, capped at 20. + /// </summary> + public async Task<List<LatchStatsSnapshotRow>> GetLatchStatsSnapshotAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + { + using var _q = TimeQuery("GetLatchStatsSnapshotAsync", "v_latch_stats latest snapshot"); + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + + command.CommandText = @" +WITH latest AS +( + SELECT MAX(collection_time) AS mx + FROM v_latch_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 +) +SELECT + latch_class, + waiting_requests_count, + wait_time_ms, + max_wait_time_ms, + delta_waiting_requests_count, + delta_wait_time_ms +FROM v_latch_stats +WHERE server_id = $1 +AND collection_time = (SELECT mx FROM latest) +ORDER BY delta_wait_time_ms DESC, wait_time_ms DESC +LIMIT 20"; + + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = startTime }); + command.Parameters.Add(new DuckDBParameter { Value = endTime }); + + var items = new List<LatchStatsSnapshotRow>(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new LatchStatsSnapshotRow + { + LatchClass = reader.GetString(0), + WaitingRequestsCount = reader.IsDBNull(1) ? 0 : reader.GetInt64(1), + WaitTimeMs = reader.IsDBNull(2) ? 0 : reader.GetInt64(2), + MaxWaitTimeMs = reader.IsDBNull(3) ? 0 : reader.GetInt64(3), + DeltaWaitingRequestsCount = reader.IsDBNull(4) ? 0 : reader.GetInt64(4), + DeltaWaitTimeMs = reader.IsDBNull(5) ? 0 : reader.GetInt64(5) + }); + } + + return items; + } + + /// <summary> + /// The Spinlock Stats trend: the per-second collision rate for the TOP 5 spinlocks (by total delta + /// collisions over the window), the collision analog of the latch trend — collisions/sec via the + /// per-name LAG interval. + /// </summary> + public async Task<List<SpinlockStatsTrendPoint>> GetSpinlockStatsTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + { + using var _q = TimeQuery("GetSpinlockStatsTrendAsync", "v_spinlock_stats top-5 per-second trend"); + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + + command.CommandText = @" +WITH top_spinlocks AS +( + SELECT spinlock_name + FROM v_spinlock_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + GROUP BY spinlock_name + ORDER BY SUM(delta_collisions) DESC + LIMIT 5 +), +raw AS +( + SELECT + spinlock_name, + collection_time, + delta_collisions, + extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (PARTITION BY spinlock_name ORDER BY collection_time)))) AS interval_seconds + FROM v_spinlock_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND spinlock_name IN (SELECT spinlock_name FROM top_spinlocks) +) +SELECT + spinlock_name, + collection_time, + CASE WHEN interval_seconds > 0 THEN CAST(delta_collisions AS DOUBLE PRECISION) / interval_seconds ELSE 0 END AS collisions_per_second +FROM raw +ORDER BY spinlock_name, collection_time"; + + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = startTime }); + command.Parameters.Add(new DuckDBParameter { Value = endTime }); + + var items = new List<SpinlockStatsTrendPoint>(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new SpinlockStatsTrendPoint + { + SpinlockName = reader.GetString(0), + CollectionTime = reader.GetDateTime(1), + CollisionsPerSecond = reader.IsDBNull(2) ? 0 : reader.GetDouble(2) + }); + } + + return items; + } + + /// <summary> + /// The Spinlock Stats latest-snapshot grid: every spinlock captured at the most recent collection in + /// the window, ordered by the last interval's delta collisions then cumulative collisions, capped at 20. + /// </summary> + public async Task<List<SpinlockStatsSnapshotRow>> GetSpinlockStatsSnapshotAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + { + using var _q = TimeQuery("GetSpinlockStatsSnapshotAsync", "v_spinlock_stats latest snapshot"); + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + + command.CommandText = @" +WITH latest AS +( + SELECT MAX(collection_time) AS mx + FROM v_spinlock_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 +) +SELECT + spinlock_name, + collisions, + spins, + spins_per_collision, + sleep_time, + backoffs, + delta_collisions, + delta_spins +FROM v_spinlock_stats +WHERE server_id = $1 +AND collection_time = (SELECT mx FROM latest) +ORDER BY delta_collisions DESC, collisions DESC +LIMIT 20"; + + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = startTime }); + command.Parameters.Add(new DuckDBParameter { Value = endTime }); + + var items = new List<SpinlockStatsSnapshotRow>(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new SpinlockStatsSnapshotRow + { + SpinlockName = reader.GetString(0), + Collisions = reader.IsDBNull(1) ? 0 : reader.GetInt64(1), + Spins = reader.IsDBNull(2) ? 0 : reader.GetInt64(2), + SpinsPerCollision = reader.IsDBNull(3) ? 0 : ToDouble(reader.GetValue(3)), + SleepTime = reader.IsDBNull(4) ? 0 : reader.GetInt64(4), + Backoffs = reader.IsDBNull(5) ? 0 : reader.GetInt64(5), + DeltaCollisions = reader.IsDBNull(6) ? 0 : reader.GetInt64(6), + DeltaSpins = reader.IsDBNull(7) ? 0 : reader.GetInt64(7) + }); + } + + return items; + } +} + +/// <summary>One point on a latch class's per-second wait trend (this interval's delta_wait_time_ms over +/// the seconds since the previous collection of the SAME class, via a per-class LAG window).</summary> +public class LatchStatsTrendPoint +{ + public string LatchClass { get; set; } = ""; + public DateTime CollectionTime { get; set; } + public double WaitTimeMsPerSecond { get; set; } +} + +/// <summary>One row of the Latch Stats latest-snapshot grid: cumulative counters plus the last interval's +/// deltas for one latch class at the most recent collection in the window.</summary> +public class LatchStatsSnapshotRow +{ + public string LatchClass { get; set; } = ""; + public long WaitingRequestsCount { get; set; } + public long WaitTimeMs { get; set; } + public long MaxWaitTimeMs { get; set; } + public long DeltaWaitingRequestsCount { get; set; } + public long DeltaWaitTimeMs { get; set; } +} + +/// <summary>One point on a spinlock's per-second collision trend (this interval's delta_collisions over +/// the seconds since the previous collection of the SAME spinlock, via a per-name LAG window).</summary> +public class SpinlockStatsTrendPoint +{ + public string SpinlockName { get; set; } = ""; + public DateTime CollectionTime { get; set; } + public double CollisionsPerSecond { get; set; } +} + +/// <summary>One row of the Spinlock Stats latest-snapshot grid: cumulative counters plus the last +/// interval's deltas for one spinlock at the most recent collection in the window.</summary> +public class SpinlockStatsSnapshotRow +{ + public string SpinlockName { get; set; } = ""; + public long Collisions { get; set; } + public long Spins { get; set; } + public double SpinsPerCollision { get; set; } + public long SleepTime { get; set; } + public long Backoffs { get; set; } + public long DeltaCollisions { get; set; } + public long DeltaSpins { get; set; } +} diff --git a/Lite/Services/LocalDataService.LongQueries.cs b/Lite/Services/LocalDataService.LongQueries.cs new file mode 100644 index 000000000..03acb28ea --- /dev/null +++ b/Lite/Services/LocalDataService.LongQueries.cs @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using DuckDB.NET.Data; + +namespace PerformanceMonitorLite.Services; + +public partial class LocalDataService +{ + /// <summary> + /// Recent long-running query completions (#1496) from <c>v_long_query_completions</c> (the archive + /// union view), for the Long Queries grid. Reads the most recent completions in the window; the grid + /// applies a view-only DESCENDING-by-duration sort, so the SQL keeps the chronological ORDER BY + /// (mirrors the blocked-process reader). + /// </summary> + public async Task<List<LongQueryCompletionRow>> GetRecentLongQueryCompletionsAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList<string>? databaseNames = null) + { + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); + + command.CommandText = @" +SELECT + collection_time, + event_time, + event_type, + database_name, + session_id, + client_app_name, + client_pid, + nt_username, + server_principal_name, + query_hash, + event_sequence, + duration_microseconds, + cpu_time_microseconds, + physical_reads, + logical_reads, + writes, + row_count, + result, + statement_text, + object_name +FROM v_long_query_completions +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3" + dbClause + @" +ORDER BY event_time DESC +LIMIT 200"; + + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = startTime }); + command.Parameters.Add(new DuckDBParameter { Value = endTime }); + foreach (var db in dbValues) + command.Parameters.Add(new DuckDBParameter { Value = db }); + + var items = new List<LongQueryCompletionRow>(); + using (var reader = await command.ExecuteReaderAsync()) + { + while (await reader.ReadAsync()) + { + items.Add(new LongQueryCompletionRow + { + CollectionTime = reader.GetDateTime(0), + EventTime = reader.IsDBNull(1) ? null : reader.GetDateTime(1), + EventType = reader.IsDBNull(2) ? "" : reader.GetString(2), + DatabaseName = reader.IsDBNull(3) ? "" : reader.GetString(3), + SessionId = reader.IsDBNull(4) ? null : reader.GetInt32(4), + ClientAppName = reader.IsDBNull(5) ? "" : reader.GetString(5), + ClientPid = reader.IsDBNull(6) ? null : reader.GetInt32(6), + NtUserName = reader.IsDBNull(7) ? "" : reader.GetString(7), + ServerPrincipalName = reader.IsDBNull(8) ? "" : reader.GetString(8), + QueryHash = reader.IsDBNull(9) ? "" : reader.GetString(9), + EventSequence = reader.IsDBNull(10) ? null : reader.GetInt64(10), + DurationMicroseconds = reader.IsDBNull(11) ? null : reader.GetInt64(11), + CpuTimeMicroseconds = reader.IsDBNull(12) ? null : reader.GetInt64(12), + PhysicalReads = reader.IsDBNull(13) ? null : reader.GetInt64(13), + LogicalReads = reader.IsDBNull(14) ? null : reader.GetInt64(14), + Writes = reader.IsDBNull(15) ? null : reader.GetInt64(15), + RowCountValue = reader.IsDBNull(16) ? null : reader.GetInt64(16), + Result = reader.IsDBNull(17) ? "" : reader.GetString(17), + StatementText = reader.IsDBNull(18) ? "" : reader.GetString(18), + ObjectName = reader.IsDBNull(19) ? "" : reader.GetString(19), + }); + } + } + + return items; + } +} + +/// <summary> +/// One long-query completion grid row (#1496): the completed rpc/batch (with runtime, CPU, I/O, +/// row count, result) or an <c>attention</c> (a cancelled/timed-out query — duration NULL because the +/// attention event's own duration is cancellation-handling time, not query runtime). +/// </summary> +public class LongQueryCompletionRow +{ + public DateTime CollectionTime { get; set; } + public DateTime? EventTime { get; set; } + public string EventType { get; set; } = ""; + public string DatabaseName { get; set; } = ""; + public int? SessionId { get; set; } + public string ClientAppName { get; set; } = ""; + public int? ClientPid { get; set; } + public string NtUserName { get; set; } = ""; + public string ServerPrincipalName { get; set; } = ""; + public string QueryHash { get; set; } = ""; + public long? EventSequence { get; set; } + public long? DurationMicroseconds { get; set; } + public long? CpuTimeMicroseconds { get; set; } + public long? PhysicalReads { get; set; } + public long? LogicalReads { get; set; } + public long? Writes { get; set; } + public long? RowCountValue { get; set; } + public string Result { get; set; } = ""; + public string StatementText { get; set; } = ""; + public string ObjectName { get; set; } = ""; + + public string EventTimeLocal => ServerTimeHelper.FormatServerTime(EventTime); + public string DurationFormatted => FormatMicroseconds(DurationMicroseconds); + public string CpuFormatted => FormatMicroseconds(CpuTimeMicroseconds); + + /// <summary>An attention row is a cancelled/timed-out query — no runtime, secondary evidence.</summary> + public bool IsAttention => string.Equals(EventType, "attention", StringComparison.OrdinalIgnoreCase); + + /// <summary>A completed event aborted mid-flight (client cancel / query timeout) — the row tints.</summary> + public bool IsAborted => string.Equals(Result, "Abort", StringComparison.OrdinalIgnoreCase); + + private static string FormatMicroseconds(long? microseconds) + { + if (microseconds is not long us) + { + return ""; + } + + return us < 1_000_000 + ? $"{us / 1000.0:F0} ms" + : $"{us / 1_000_000.0:F1} sec"; + } +} diff --git a/Lite/Services/LocalDataService.Memory.cs b/Lite/Services/LocalDataService.Memory.cs index fb9275cd1..17362fcfc 100644 --- a/Lite/Services/LocalDataService.Memory.cs +++ b/Lite/Services/LocalDataService.Memory.cs @@ -142,48 +142,8 @@ GROUP BY clerk_type } /// <summary> - /// Gets memory clerk trend data for a single clerk type for charting. - /// </summary> - public async Task<List<MemoryClerkTrendPoint>> GetMemoryClerkTrendAsync(int serverId, string clerkType, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) - { - using var connection = await OpenConnectionAsync(); - using var command = connection.CreateCommand(); - - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); - - command.CommandText = @" -SELECT - collection_time, - memory_mb -FROM v_memory_clerks -WHERE server_id = $1 -AND clerk_type = $2 -AND collection_time >= $3 -AND collection_time <= $4 -ORDER BY collection_time"; - - command.Parameters.Add(new DuckDBParameter { Value = serverId }); - command.Parameters.Add(new DuckDBParameter { Value = clerkType }); - command.Parameters.Add(new DuckDBParameter { Value = startTime }); - command.Parameters.Add(new DuckDBParameter { Value = endTime }); - - var items = new List<MemoryClerkTrendPoint>(); - using var reader = await command.ExecuteReaderAsync(); - while (await reader.ReadAsync()) - { - items.Add(new MemoryClerkTrendPoint - { - CollectionTime = reader.GetDateTime(0), - MemoryMb = reader.IsDBNull(1) ? 0 : ToDouble(reader.GetValue(1)) - }); - } - - return items; - } - - /// <summary> - /// Batched sibling of <see cref="GetMemoryClerkTrendAsync"/>: fetches the trend for ALL selected - /// clerk types in ONE query (replacing an N+1 query-per-clerk loop), grouped by clerk type. + /// Fetches the memory clerk trend for ALL selected clerk types in ONE query + /// (replacing an N+1 query-per-clerk loop), grouped by clerk type. /// </summary> public async Task<Dictionary<string, List<MemoryClerkTrendPoint>>> GetMemoryClerkTrendsByTypesAsync(int serverId, List<string> clerkTypes, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) { diff --git a/Lite/Services/LocalDataService.MemoryGrants.cs b/Lite/Services/LocalDataService.MemoryGrants.cs index 993b49275..c4ad19423 100644 --- a/Lite/Services/LocalDataService.MemoryGrants.cs +++ b/Lite/Services/LocalDataService.MemoryGrants.cs @@ -108,6 +108,81 @@ FROM v_memory_grant_stats } return items; } + + /// <summary> + /// The resource-semaphore latest-snapshot read (the get_resource_semaphore MCP lens): every resource + /// semaphore captured at the most recent collection in the window, with the full ceiling column set + /// (target / max-target / total workspace memory, cumulative timeout/forced grant counts, and the + /// per-interval deltas) — the same shape the Dashboard's get_resource_semaphore serves. Distinct from + /// <see cref="GetMemoryGrantChartDataAsync"/>, which aggregates a per-pool grant subset for the charts; + /// this returns one row per (pool_id, resource_semaphore_id) with the ceiling metrics intact. + /// </summary> + public async Task<List<ResourceSemaphoreRow>> GetResourceSemaphoreSnapshotAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + { + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + + command.CommandText = @" +WITH latest AS +( + SELECT MAX(collection_time) AS mx + FROM v_memory_grant_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 +) +SELECT + collection_time, + resource_semaphore_id, + pool_id, + target_memory_mb, + max_target_memory_mb, + total_memory_mb, + available_memory_mb, + granted_memory_mb, + used_memory_mb, + grantee_count, + waiter_count, + timeout_error_count, + forced_grant_count, + timeout_error_count_delta, + forced_grant_count_delta +FROM v_memory_grant_stats +WHERE server_id = $1 +AND collection_time = (SELECT mx FROM latest) +ORDER BY pool_id, resource_semaphore_id"; + + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = startTime }); + command.Parameters.Add(new DuckDBParameter { Value = endTime }); + + var items = new List<ResourceSemaphoreRow>(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new ResourceSemaphoreRow + { + CollectionTime = reader.GetDateTime(0), + ResourceSemaphoreId = reader.IsDBNull(1) ? 0 : (int)ToInt64(reader.GetValue(1)), + PoolId = reader.IsDBNull(2) ? 0 : (int)ToInt64(reader.GetValue(2)), + TargetMemoryMb = reader.IsDBNull(3) ? 0 : ToDouble(reader.GetValue(3)), + MaxTargetMemoryMb = reader.IsDBNull(4) ? 0 : ToDouble(reader.GetValue(4)), + TotalMemoryMb = reader.IsDBNull(5) ? 0 : ToDouble(reader.GetValue(5)), + AvailableMemoryMb = reader.IsDBNull(6) ? 0 : ToDouble(reader.GetValue(6)), + GrantedMemoryMb = reader.IsDBNull(7) ? 0 : ToDouble(reader.GetValue(7)), + UsedMemoryMb = reader.IsDBNull(8) ? 0 : ToDouble(reader.GetValue(8)), + GranteeCount = reader.IsDBNull(9) ? 0 : (int)ToInt64(reader.GetValue(9)), + WaiterCount = reader.IsDBNull(10) ? 0 : (int)ToInt64(reader.GetValue(10)), + TimeoutErrorCount = reader.IsDBNull(11) ? 0 : ToInt64(reader.GetValue(11)), + ForcedGrantCount = reader.IsDBNull(12) ? 0 : ToInt64(reader.GetValue(12)), + TimeoutErrorCountDelta = reader.IsDBNull(13) ? 0 : ToInt64(reader.GetValue(13)), + ForcedGrantCountDelta = reader.IsDBNull(14) ? 0 : ToInt64(reader.GetValue(14)) + }); + } + return items; + } } public class MemoryGrantChartPoint @@ -122,3 +197,26 @@ public class MemoryGrantChartPoint public long TimeoutErrorCountDelta { get; set; } public long ForcedGrantCountDelta { get; set; } } + +/// <summary>One resource-semaphore latest-snapshot row (the get_resource_semaphore MCP lens): one +/// (pool_id, resource_semaphore_id) semaphore's full ceiling metrics at the most recent collection in the +/// window — target / max-target / total workspace memory, granted vs available/used, grantee/waiter counts, +/// and the cumulative + per-interval-delta timeout/forced-grant pressure counters.</summary> +public class ResourceSemaphoreRow +{ + public DateTime CollectionTime { get; set; } + public int ResourceSemaphoreId { get; set; } + public int PoolId { get; set; } + public double TargetMemoryMb { get; set; } + public double MaxTargetMemoryMb { get; set; } + public double TotalMemoryMb { get; set; } + public double AvailableMemoryMb { get; set; } + public double GrantedMemoryMb { get; set; } + public double UsedMemoryMb { get; set; } + public int GranteeCount { get; set; } + public int WaiterCount { get; set; } + public long TimeoutErrorCount { get; set; } + public long ForcedGrantCount { get; set; } + public long TimeoutErrorCountDelta { get; set; } + public long ForcedGrantCountDelta { get; set; } +} diff --git a/Lite/Services/LocalDataService.PlanCache.cs b/Lite/Services/LocalDataService.PlanCache.cs new file mode 100644 index 000000000..8235b4a70 --- /dev/null +++ b/Lite/Services/LocalDataService.PlanCache.cs @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using DuckDB.NET.Data; + +namespace PerformanceMonitorLite.Services; + +public partial class LocalDataService +{ + /* The Plan Cache readers — the Lite (DuckDB) port of the Darling viewer's ViewerDataService.PlanCache + reads. plan_cache_stats is a point-in-time snapshot collector: one row per (cacheobjtype, objtype) + group per collection, each stamped with the store-wide oldest_plan_create_time. The trend sums + single-use vs multi-use size (MB) across every group per collection (the single-use bloat signal), + the composition grid breaks the latest snapshot down per group, and the summary reads the TRUE + total/single-use plan counts (uncapped SUM, independent of the top-30 grid). All read the + v_plan_cache_stats archive view; collection_time is UTC (GetTimeRange). */ + + /// <summary> + /// The Plan Cache size trend: single-use vs multi-use cache size (MB) per collection, summed across + /// every (cacheobjtype, objtype) group — the Dashboard's Plan Cache chart shape (single-use bloat). + /// </summary> + public async Task<List<PlanCacheTrendPoint>> GetPlanCacheTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + { + using var _q = TimeQuery("GetPlanCacheTrendAsync", "v_plan_cache_stats single/multi-use size trend"); + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + + command.CommandText = @" +SELECT + collection_time, + CAST(SUM(single_use_size_mb) AS DOUBLE PRECISION) AS single_use_mb, + CAST(SUM(multi_use_size_mb) AS DOUBLE PRECISION) AS multi_use_mb +FROM v_plan_cache_stats +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3 +GROUP BY collection_time +ORDER BY collection_time"; + + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = startTime }); + command.Parameters.Add(new DuckDBParameter { Value = endTime }); + + var items = new List<PlanCacheTrendPoint>(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new PlanCacheTrendPoint + { + CollectionTime = reader.GetDateTime(0), + SingleUseSizeMb = reader.IsDBNull(1) ? 0 : reader.GetDouble(1), + MultiUseSizeMb = reader.IsDBNull(2) ? 0 : reader.GetDouble(2) + }); + } + + return items; + } + + /// <summary> + /// The Plan Cache latest-snapshot composition grid: every (cacheobjtype, objtype) group captured at + /// the most recent collection in the window, ordered by total cache size then plan count, capped at 30. + /// </summary> + public async Task<List<PlanCacheSnapshotRow>> GetPlanCacheSnapshotAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + { + using var _q = TimeQuery("GetPlanCacheSnapshotAsync", "v_plan_cache_stats latest composition"); + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + + command.CommandText = @" +WITH latest AS +( + SELECT MAX(collection_time) AS mx + FROM v_plan_cache_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 +) +SELECT + cacheobjtype, + objtype, + total_plans, + total_size_mb, + single_use_plans, + single_use_size_mb, + multi_use_plans, + multi_use_size_mb, + avg_use_count, + avg_size_kb, + oldest_plan_create_time +FROM v_plan_cache_stats +WHERE server_id = $1 +AND collection_time = (SELECT mx FROM latest) +ORDER BY total_size_mb DESC, total_plans DESC +LIMIT 30"; + + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = startTime }); + command.Parameters.Add(new DuckDBParameter { Value = endTime }); + + var items = new List<PlanCacheSnapshotRow>(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new PlanCacheSnapshotRow + { + Cacheobjtype = reader.IsDBNull(0) ? "" : reader.GetString(0), + Objtype = reader.IsDBNull(1) ? "" : reader.GetString(1), + TotalPlans = reader.IsDBNull(2) ? 0 : reader.GetInt32(2), + TotalSizeMb = reader.IsDBNull(3) ? 0 : reader.GetInt32(3), + SingleUsePlans = reader.IsDBNull(4) ? 0 : reader.GetInt32(4), + SingleUseSizeMb = reader.IsDBNull(5) ? 0 : reader.GetInt32(5), + MultiUsePlans = reader.IsDBNull(6) ? 0 : reader.GetInt32(6), + MultiUseSizeMb = reader.IsDBNull(7) ? 0 : reader.GetInt32(7), + AvgUseCount = reader.IsDBNull(8) ? 0 : ToDouble(reader.GetValue(8)), + AvgSizeKb = reader.IsDBNull(9) ? 0 : reader.GetInt32(9), + OldestPlanCreateTime = reader.IsDBNull(10) ? null : reader.GetDateTime(10) + }); + } + + return items; + } + + /// <summary> + /// The Plan Cache summary read: the TRUE total plan count and single-use plan count (SUM over EVERY + /// group, uncapped) plus the oldest cached plan's create time (MIN) at the most recent collection in + /// the window. Computed independently of the top-30 grid so "Total Plans" is exact even when the grid + /// is capped. An aggregate with no GROUP BY always returns one row, so an empty window yields (0,0,null). + /// </summary> + public async Task<PlanCacheSummary> GetPlanCacheSummaryAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + { + using var _q = TimeQuery("GetPlanCacheSummaryAsync", "v_plan_cache_stats summary totals"); + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + + command.CommandText = @" +WITH latest AS +( + SELECT MAX(collection_time) AS mx + FROM v_plan_cache_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 +) +SELECT + COALESCE(SUM(total_plans), 0) AS total_plans, + COALESCE(SUM(single_use_plans), 0) AS single_use_plans, + MIN(oldest_plan_create_time) AS oldest_plan_create_time +FROM v_plan_cache_stats +WHERE server_id = $1 +AND collection_time = (SELECT mx FROM latest)"; + + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = startTime }); + command.Parameters.Add(new DuckDBParameter { Value = endTime }); + + using var reader = await command.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) + { + return new PlanCacheSummary(); + } + + return new PlanCacheSummary + { + TotalPlans = reader.IsDBNull(0) ? 0 : ToInt64(reader.GetValue(0)), + SingleUsePlans = reader.IsDBNull(1) ? 0 : ToInt64(reader.GetValue(1)), + OldestPlanCreateTime = reader.IsDBNull(2) ? null : reader.GetDateTime(2) + }; + } + + /// <summary> + /// The plan-cache bloat classification (single-use plan bloat), a pure client-side derivation mirroring + /// install/47's <c>report.plan_cache_bloat</c> and the Darling viewer's + /// <c>ViewerDataService.ClassifyPlanCacheBloat</c>. The ratio is single-use plans / total plans: + /// > 50% CRITICAL, > 30% HIGH, > 20% MEDIUM, else NORMAL; the recommendation flips to the + /// "unparameterized queries / Forced Parameterization" hint at the same > 20% threshold. An empty + /// cache (totalPlans == 0) is NORMAL. + /// </summary> + public static PlanCacheBloat ClassifyPlanCacheBloat(long totalPlans, long singleUsePlans) + { + double singleUsePercent = totalPlans > 0 ? singleUsePlans * 100.0 / totalPlans : 0.0; + + var level = + singleUsePercent > 50 ? "CRITICAL" : + singleUsePercent > 30 ? "HIGH" : + singleUsePercent > 20 ? "MEDIUM" : + "NORMAL"; + + var recommendation = singleUsePercent > 20 + ? "Check for unparameterized queries / Consider Forced Parameterization" + : "Plan cache composition is healthy"; + + return new PlanCacheBloat(level, recommendation); + } +} + +/// <summary>One point on the plan-cache size trend: single-use vs multi-use plan cache size (MB) at a +/// collection instant, summed across every (cacheobjtype, objtype) group.</summary> +public class PlanCacheTrendPoint +{ + public DateTime CollectionTime { get; set; } + public double SingleUseSizeMb { get; set; } + public double MultiUseSizeMb { get; set; } +} + +/// <summary>One row of the Plan Cache latest-snapshot composition grid: one (cacheobjtype, objtype) +/// group's plan/size breakdown at the most recent collection in the window.</summary> +public class PlanCacheSnapshotRow +{ + public string Cacheobjtype { get; set; } = ""; + public string Objtype { get; set; } = ""; + public int TotalPlans { get; set; } + public int TotalSizeMb { get; set; } + public int SingleUsePlans { get; set; } + public int SingleUseSizeMb { get; set; } + public int MultiUsePlans { get; set; } + public int MultiUseSizeMb { get; set; } + public double AvgUseCount { get; set; } + public int AvgSizeKb { get; set; } + public DateTime? OldestPlanCreateTime { get; set; } +} + +/// <summary>The Plan Cache summary strip's figures for the latest snapshot in the window: the TRUE total +/// plan count and single-use plan count (both summed across every group, not just the displayed top-N) +/// and the oldest cached plan's create time.</summary> +public class PlanCacheSummary +{ + public long TotalPlans { get; set; } + public long SingleUsePlans { get; set; } + public DateTime? OldestPlanCreateTime { get; set; } +} + +/// <summary>The plan-cache bloat classification for the summary badge: the banded severity level plus the +/// paired recommendation, mirroring install/47's report.plan_cache_bloat.</summary> +public sealed record PlanCacheBloat(string Level, string Recommendation); diff --git a/Lite/Services/LocalDataService.QueryStats.cs b/Lite/Services/LocalDataService.QueryStats.cs index b9d52979d..b6b47a459 100644 --- a/Lite/Services/LocalDataService.QueryStats.cs +++ b/Lite/Services/LocalDataService.QueryStats.cs @@ -40,11 +40,12 @@ FROM sys.databases AS d /// Gets top queries by CPU for a server over a time period. /// </summary> public async Task<List<TimeSliceBucket>> GetQueryStatsSlicerDataAsync( - int serverId, int hoursBack, DateTime? fromDate = null, DateTime? toDate = null) + int serverId, int hoursBack, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList<string>? databaseNames = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); command.CommandText = @" SELECT @@ -58,13 +59,15 @@ public async Task<List<TimeSliceBucket>> GetQueryStatsSlicerDataAsync( FROM v_query_stats WHERE server_id = $1 AND collection_time >= $2 -AND collection_time <= $3 +AND collection_time <= $3" + dbClause + @" GROUP BY date_trunc('hour', collection_time) ORDER BY bucket"; command.Parameters.Add(new DuckDBParameter { Value = serverId }); command.Parameters.Add(new DuckDBParameter { Value = startTime }); command.Parameters.Add(new DuckDBParameter { Value = endTime }); + foreach (var db in dbValues) + command.Parameters.Add(new DuckDBParameter { Value = db }); var items = new List<TimeSliceBucket>(); using var reader = await command.ExecuteReaderAsync(); @@ -86,13 +89,14 @@ GROUP BY date_trunc('hour', collection_time) return items; } - public async Task<List<QueryStatsRow>> GetTopQueriesByCpuAsync(int serverId, int hoursBack = 24, int top = 50, DateTime? fromDate = null, DateTime? toDate = null, int utcOffsetMinutes = 0, string? databaseName = null) + public async Task<List<QueryStatsRow>> GetTopQueriesByCpuAsync(int serverId, int hoursBack = 24, int top = 50, DateTime? fromDate = null, DateTime? toDate = null, int utcOffsetMinutes = 0, IReadOnlyList<string>? databaseNames = null) { using var _q = TimeQuery("GetTopQueriesByCpuAsync", "v_query_stats top N by CPU"); using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var dbClause = BuildDbInClause(databaseNames, "database_name", 6, out var dbValues); command.CommandText = @" WITH ranked AS ( @@ -125,22 +129,60 @@ WITH ranked AS ( MAX(max_spills) AS max_spills, MAX(query_plan_hash) AS query_plan_hash, MAX(sql_handle) AS sql_handle, - MAX(plan_handle) AS plan_handle + MAX(plan_handle) AS plan_handle, + MIN(min_used_grant_kb) AS min_used_grant_kb, + MAX(max_used_grant_kb) AS max_used_grant_kb, + MIN(min_ideal_grant_kb) AS min_ideal_grant_kb, + MAX(max_ideal_grant_kb) AS max_ideal_grant_kb, + MIN(min_reserved_threads) AS min_reserved_threads, + MAX(max_reserved_threads) AS max_reserved_threads, + MIN(min_used_threads) AS min_used_threads, + MAX(max_used_threads) AS max_used_threads, + MAX(total_clr_time) AS total_clr_time, + MAX(plan_generation_num) AS plan_generation_num, + MAX(CAST(delta_worker_time AS DOUBLE PRECISION) / NULLIF(sample_interval_seconds, 0) / 1000.0) AS worker_time_per_second FROM v_query_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 - AND last_execution_time >= $2 + $5 * INTERVAL '1' MINUTE - AND ($6 IS NULL OR database_name = $6) + AND last_execution_time >= $2 + $5 * INTERVAL '1' MINUTE" + dbClause + @" GROUP BY database_name, query_hash HAVING SUM(delta_execution_count) > 0 OR SUM(delta_elapsed_time) > 0 ORDER BY SUM(delta_elapsed_time) DESC LIMIT $4 + 5 +), +module AS ( + /* #1568 module attribution: one procedure_stats identity per sql_handle (latest collection_time + wins) so a statement whose sql_handle matches a cached procedure/function/trigger inherits its + db.schema.object and the query_stats row never fans out. Both stores persist the SAME normalized + CONVERT(varchar(130), ..., 1) handle text, so the join key lines up; unmatched -> ad hoc. */ + SELECT + sql_handle, + object_name, + schema_name, + database_name + FROM + ( + SELECT + sql_handle, + object_name, + schema_name, + database_name, + ROW_NUMBER() OVER (PARTITION BY sql_handle ORDER BY collection_time DESC) AS rn + FROM v_procedure_stats + WHERE server_id = $1 + AND sql_handle IS NOT NULL + AND sql_handle <> '' + ) ranked_modules + WHERE rn = 1 ) SELECT r.*, t.query_text, - t.query_plan_xml AS query_plan + t.query_plan_xml AS query_plan, + m.object_name AS module_object_name, + m.schema_name AS module_schema_name, + m.database_name AS module_database_name FROM ranked r LEFT JOIN LATERAL ( SELECT query_text, query_plan_xml @@ -152,6 +194,7 @@ AND query_text IS NOT NULL ORDER BY collection_time DESC LIMIT 1 ) t ON TRUE +LEFT JOIN module m ON m.sql_handle = r.sql_handle WHERE t.query_text IS NULL OR t.query_text NOT LIKE 'WAITFOR%' ORDER BY r.total_elapsed_us DESC LIMIT $4"; @@ -161,7 +204,8 @@ ORDER BY r.total_elapsed_us DESC command.Parameters.Add(new DuckDBParameter { Value = endTime }); command.Parameters.Add(new DuckDBParameter { Value = top }); command.Parameters.Add(new DuckDBParameter { Value = utcOffsetMinutes }); - command.Parameters.Add(new DuckDBParameter { Value = (object?)databaseName ?? DBNull.Value }); + foreach (var db in dbValues) + command.Parameters.Add(new DuckDBParameter { Value = db }); var items = new List<QueryStatsRow>(); using var reader = await command.ExecuteReaderAsync(); @@ -198,8 +242,22 @@ ORDER BY r.total_elapsed_us DESC QueryPlanHash = reader.IsDBNull(26) ? "" : reader.GetString(26), SqlHandle = reader.IsDBNull(27) ? "" : reader.GetString(27), PlanHandle = reader.IsDBNull(28) ? "" : reader.GetString(28), - QueryText = reader.IsDBNull(29) ? "" : reader.GetString(29), - QueryPlan = reader.IsDBNull(30) ? null : reader.GetString(30) + MinUsedGrantKb = reader.IsDBNull(29) ? 0 : reader.GetInt64(29), + MaxUsedGrantKb = reader.IsDBNull(30) ? 0 : reader.GetInt64(30), + MinIdealGrantKb = reader.IsDBNull(31) ? 0 : reader.GetInt64(31), + MaxIdealGrantKb = reader.IsDBNull(32) ? 0 : reader.GetInt64(32), + MinReservedThreads = reader.IsDBNull(33) ? 0 : reader.GetInt64(33), + MaxReservedThreads = reader.IsDBNull(34) ? 0 : reader.GetInt64(34), + MinUsedThreads = reader.IsDBNull(35) ? 0 : reader.GetInt64(35), + MaxUsedThreads = reader.IsDBNull(36) ? 0 : reader.GetInt64(36), + TotalClrUs = reader.IsDBNull(37) ? 0 : reader.GetInt64(37), + PlanGenerationNum = reader.IsDBNull(38) ? 0 : reader.GetInt64(38), + WorkerTimePerSecond = reader.IsDBNull(39) ? 0 : ToDouble(reader.GetValue(39)), + QueryText = reader.IsDBNull(40) ? "" : reader.GetString(40), + QueryPlan = reader.IsDBNull(41) ? null : reader.GetString(41), + ModuleObjectName = reader.IsDBNull(42) ? "" : reader.GetString(42), + ModuleSchemaName = reader.IsDBNull(43) ? "" : reader.GetString(43), + ModuleDatabaseName = reader.IsDBNull(44) ? "" : reader.GetString(44) }); } @@ -213,18 +271,20 @@ ORDER BY r.total_elapsed_us DESC public async Task<List<QueryStatsComparisonItem>> GetQueryStatsComparisonAsync( int serverId, DateTime currentStart, DateTime currentEnd, - DateTime baselineStart, DateTime baselineEnd) + DateTime baselineStart, DateTime baselineEnd, + IReadOnlyList<string>? databaseNames = null) { using var _q = TimeQuery("GetQueryStatsComparisonAsync", "v_query_stats comparison"); using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); + var dbClause = BuildDbInClause(databaseNames, "database_name", 6, out var dbValues); command.CommandText = @" WITH top_current AS ( SELECT query_hash, database_name FROM v_query_stats WHERE server_id = $1 - AND collection_time >= $2 AND collection_time <= $3 + AND collection_time >= $2 AND collection_time <= $3" + dbClause + @" AND delta_execution_count > 0 GROUP BY query_hash, database_name ORDER BY SUM(delta_execution_count) DESC @@ -234,7 +294,7 @@ top_baseline AS ( SELECT query_hash, database_name FROM v_query_stats WHERE server_id = $1 - AND collection_time >= $4 AND collection_time <= $5 + AND collection_time >= $4 AND collection_time <= $5" + dbClause + @" AND delta_execution_count > 0 GROUP BY query_hash, database_name ORDER BY SUM(delta_execution_count) DESC @@ -251,9 +311,9 @@ UNION ALL current_period AS ( SELECT th.database_name, th.query_hash, SUM(qs.delta_execution_count) AS exec_count, - SUM(qs.delta_elapsed_time)::DOUBLE / NULLIF(SUM(qs.delta_execution_count), 0) / 1000.0 AS avg_duration_ms, - SUM(qs.delta_worker_time)::DOUBLE / NULLIF(SUM(qs.delta_execution_count), 0) / 1000.0 AS avg_cpu_ms, - SUM(qs.delta_physical_reads)::DOUBLE / NULLIF(SUM(qs.delta_execution_count), 0) AS avg_reads, + SUM(qs.delta_elapsed_time)::DOUBLE PRECISION / NULLIF(SUM(qs.delta_execution_count), 0) / 1000.0 AS avg_duration_ms, + SUM(qs.delta_worker_time)::DOUBLE PRECISION / NULLIF(SUM(qs.delta_execution_count), 0) / 1000.0 AS avg_cpu_ms, + SUM(qs.delta_physical_reads)::DOUBLE PRECISION / NULLIF(SUM(qs.delta_execution_count), 0) AS avg_reads, MAX(qs.query_text) AS query_text FROM top_hashes th INNER JOIN v_query_stats qs @@ -267,9 +327,9 @@ AND qs.delta_execution_count > 0 baseline_period AS ( SELECT th.database_name, th.query_hash, SUM(qs.delta_execution_count) AS exec_count, - SUM(qs.delta_elapsed_time)::DOUBLE / NULLIF(SUM(qs.delta_execution_count), 0) / 1000.0 AS avg_duration_ms, - SUM(qs.delta_worker_time)::DOUBLE / NULLIF(SUM(qs.delta_execution_count), 0) / 1000.0 AS avg_cpu_ms, - SUM(qs.delta_physical_reads)::DOUBLE / NULLIF(SUM(qs.delta_execution_count), 0) AS avg_reads, + SUM(qs.delta_elapsed_time)::DOUBLE PRECISION / NULLIF(SUM(qs.delta_execution_count), 0) / 1000.0 AS avg_duration_ms, + SUM(qs.delta_worker_time)::DOUBLE PRECISION / NULLIF(SUM(qs.delta_execution_count), 0) / 1000.0 AS avg_cpu_ms, + SUM(qs.delta_physical_reads)::DOUBLE PRECISION / NULLIF(SUM(qs.delta_execution_count), 0) AS avg_reads, MAX(qs.query_text) AS query_text FROM top_hashes th INNER JOIN v_query_stats qs @@ -298,6 +358,8 @@ ON c.database_name IS NOT DISTINCT FROM b.database_name command.Parameters.Add(new DuckDBParameter { Value = currentEnd }); command.Parameters.Add(new DuckDBParameter { Value = baselineStart }); command.Parameters.Add(new DuckDBParameter { Value = baselineEnd }); + foreach (var db in dbValues) + command.Parameters.Add(new DuckDBParameter { Value = db }); var items = new List<QueryStatsComparisonItem>(); using var reader = await command.ExecuteReaderAsync(); @@ -347,8 +409,38 @@ public async Task<List<QueryStatsHistoryRow>> GetQueryStatsHistoryAsync(int serv max_worker_time, min_elapsed_time, max_elapsed_time, - query_plan_xml, - query_plan_hash + query_plan_hash, + min_grant_kb, + max_grant_kb, + min_used_grant_kb, + max_used_grant_kb, + min_ideal_grant_kb, + max_ideal_grant_kb, + min_reserved_threads, + max_reserved_threads, + min_used_threads, + max_used_threads, + min_physical_reads, + max_physical_reads, + min_rows, + max_rows, + min_spills, + max_spills, + total_clr_time, + creation_time, + last_execution_time, + execution_count, + total_worker_time, + total_elapsed_time, + total_logical_reads, + total_logical_writes, + total_physical_reads, + total_rows, + total_spills, + sql_handle, + plan_handle, + query_hash, + sample_interval_seconds FROM v_query_stats WHERE server_id = $1 AND database_name = $2 @@ -384,8 +476,38 @@ FROM v_query_stats MaxCpuUs = reader.IsDBNull(12) ? 0 : reader.GetInt64(12), MinElapsedUs = reader.IsDBNull(13) ? 0 : reader.GetInt64(13), MaxElapsedUs = reader.IsDBNull(14) ? 0 : reader.GetInt64(14), - QueryPlan = reader.IsDBNull(15) ? null : reader.GetString(15), - QueryPlanHash = reader.IsDBNull(16) ? "" : reader.GetString(16) + QueryPlanHash = reader.IsDBNull(15) ? "" : reader.GetString(15), + MinGrantKb = reader.IsDBNull(16) ? 0 : reader.GetInt64(16), + MaxGrantKb = reader.IsDBNull(17) ? 0 : reader.GetInt64(17), + MinUsedGrantKb = reader.IsDBNull(18) ? 0 : reader.GetInt64(18), + MaxUsedGrantKb = reader.IsDBNull(19) ? 0 : reader.GetInt64(19), + MinIdealGrantKb = reader.IsDBNull(20) ? 0 : reader.GetInt64(20), + MaxIdealGrantKb = reader.IsDBNull(21) ? 0 : reader.GetInt64(21), + MinReservedThreads = reader.IsDBNull(22) ? 0 : reader.GetInt64(22), + MaxReservedThreads = reader.IsDBNull(23) ? 0 : reader.GetInt64(23), + MinUsedThreads = reader.IsDBNull(24) ? 0 : reader.GetInt64(24), + MaxUsedThreads = reader.IsDBNull(25) ? 0 : reader.GetInt64(25), + MinPhysicalReads = reader.IsDBNull(26) ? 0 : reader.GetInt64(26), + MaxPhysicalReads = reader.IsDBNull(27) ? 0 : reader.GetInt64(27), + MinRows = reader.IsDBNull(28) ? 0 : reader.GetInt64(28), + MaxRows = reader.IsDBNull(29) ? 0 : reader.GetInt64(29), + MinSpills = reader.IsDBNull(30) ? 0 : reader.GetInt64(30), + MaxSpills = reader.IsDBNull(31) ? 0 : reader.GetInt64(31), + TotalClrTimeUs = reader.IsDBNull(32) ? 0 : reader.GetInt64(32), + CreationTime = reader.IsDBNull(33) ? (DateTime?)null : reader.GetDateTime(33), + LastExecutionTime = reader.IsDBNull(34) ? (DateTime?)null : reader.GetDateTime(34), + TotalExecutions = reader.IsDBNull(35) ? 0 : reader.GetInt64(35), + TotalCpuUs = reader.IsDBNull(36) ? 0 : reader.GetInt64(36), + TotalElapsedUs = reader.IsDBNull(37) ? 0 : reader.GetInt64(37), + TotalLogicalReads = reader.IsDBNull(38) ? 0 : reader.GetInt64(38), + TotalLogicalWrites = reader.IsDBNull(39) ? 0 : reader.GetInt64(39), + TotalPhysicalReads = reader.IsDBNull(40) ? 0 : reader.GetInt64(40), + TotalRows = reader.IsDBNull(41) ? 0 : reader.GetInt64(41), + TotalSpills = reader.IsDBNull(42) ? 0 : reader.GetInt64(42), + SqlHandle = reader.IsDBNull(43) ? "" : reader.GetString(43), + PlanHandle = reader.IsDBNull(44) ? "" : reader.GetString(44), + QueryHash = reader.IsDBNull(45) ? "" : reader.GetString(45), + SampleIntervalSeconds = reader.IsDBNull(46) ? (int?)null : reader.GetInt32(46) }); } @@ -413,7 +535,28 @@ public async Task<List<ProcedureStatsHistoryRow>> GetProcedureStatsHistoryAsync( max_worker_time, min_elapsed_time, max_elapsed_time, - total_spills + total_spills, + min_logical_reads, + max_logical_reads, + min_physical_reads, + max_physical_reads, + min_logical_writes, + max_logical_writes, + min_spills, + max_spills, + sql_handle, + plan_handle, + cached_time, + last_execution_time, + object_type, + execution_count, + total_worker_time, + total_elapsed_time, + total_logical_reads, + total_physical_reads, + total_logical_writes, + delta_spills, + CAST(extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (ORDER BY collection_time)))) AS BIGINT) AS sample_interval_seconds FROM v_procedure_stats WHERE server_id = $1 AND database_name = $2 @@ -447,7 +590,28 @@ FROM v_procedure_stats MaxWorkerTimeUs = reader.IsDBNull(8) ? 0 : reader.GetInt64(8), MinElapsedTimeUs = reader.IsDBNull(9) ? 0 : reader.GetInt64(9), MaxElapsedTimeUs = reader.IsDBNull(10) ? 0 : reader.GetInt64(10), - TotalSpills = reader.IsDBNull(11) ? 0 : reader.GetInt64(11) + TotalSpills = reader.IsDBNull(11) ? 0 : reader.GetInt64(11), + MinLogicalReads = reader.IsDBNull(12) ? 0 : reader.GetInt64(12), + MaxLogicalReads = reader.IsDBNull(13) ? 0 : reader.GetInt64(13), + MinPhysicalReads = reader.IsDBNull(14) ? 0 : reader.GetInt64(14), + MaxPhysicalReads = reader.IsDBNull(15) ? 0 : reader.GetInt64(15), + MinLogicalWrites = reader.IsDBNull(16) ? 0 : reader.GetInt64(16), + MaxLogicalWrites = reader.IsDBNull(17) ? 0 : reader.GetInt64(17), + MinSpills = reader.IsDBNull(18) ? 0 : reader.GetInt64(18), + MaxSpills = reader.IsDBNull(19) ? 0 : reader.GetInt64(19), + SqlHandle = reader.IsDBNull(20) ? "" : reader.GetString(20), + PlanHandle = reader.IsDBNull(21) ? "" : reader.GetString(21), + CachedTime = reader.IsDBNull(22) ? (DateTime?)null : reader.GetDateTime(22), + LastExecutionTime = reader.IsDBNull(23) ? (DateTime?)null : reader.GetDateTime(23), + ObjectType = reader.IsDBNull(24) ? "" : reader.GetString(24), + TotalExecutions = reader.IsDBNull(25) ? 0 : reader.GetInt64(25), + TotalCpuUs = reader.IsDBNull(26) ? 0 : reader.GetInt64(26), + TotalElapsedUs = reader.IsDBNull(27) ? 0 : reader.GetInt64(27), + TotalLogicalReads = reader.IsDBNull(28) ? 0 : reader.GetInt64(28), + TotalPhysicalReads = reader.IsDBNull(29) ? 0 : reader.GetInt64(29), + TotalLogicalWrites = reader.IsDBNull(30) ? 0 : reader.GetInt64(30), + DeltaSpills = reader.IsDBNull(31) ? 0 : reader.GetInt64(31), + SampleIntervalSeconds = reader.IsDBNull(32) ? (int?)null : Convert.ToInt32(reader.GetValue(32)) }); } @@ -642,11 +806,12 @@ qs.last_execution_time DESC /// Gets top procedures by CPU for a server. /// </summary> public async Task<List<TimeSliceBucket>> GetProcStatsSlicerDataAsync( - int serverId, int hoursBack, DateTime? fromDate = null, DateTime? toDate = null) + int serverId, int hoursBack, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList<string>? databaseNames = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); command.CommandText = @" SELECT @@ -660,13 +825,15 @@ public async Task<List<TimeSliceBucket>> GetProcStatsSlicerDataAsync( FROM v_procedure_stats WHERE server_id = $1 AND collection_time >= $2 -AND collection_time <= $3 +AND collection_time <= $3" + dbClause + @" GROUP BY date_trunc('hour', collection_time) ORDER BY bucket"; command.Parameters.Add(new DuckDBParameter { Value = serverId }); command.Parameters.Add(new DuckDBParameter { Value = startTime }); command.Parameters.Add(new DuckDBParameter { Value = endTime }); + foreach (var db in dbValues) + command.Parameters.Add(new DuckDBParameter { Value = db }); var items = new List<TimeSliceBucket>(); using var reader = await command.ExecuteReaderAsync(); @@ -687,13 +854,14 @@ GROUP BY date_trunc('hour', collection_time) return items; } - public async Task<List<ProcedureStatsRow>> GetTopProceduresByCpuAsync(int serverId, int hoursBack = 24, int top = 50, DateTime? fromDate = null, DateTime? toDate = null, int utcOffsetMinutes = 0, string? databaseName = null) + public async Task<List<ProcedureStatsRow>> GetTopProceduresByCpuAsync(int serverId, int hoursBack = 24, int top = 50, DateTime? fromDate = null, DateTime? toDate = null, int utcOffsetMinutes = 0, IReadOnlyList<string>? databaseNames = null) { using var _q = TimeQuery("GetTopProceduresByCpuAsync", "v_procedure_stats top N by CPU"); using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var dbClause = BuildDbInClause(databaseNames, "database_name", 6, out var dbValues); command.CommandText = @" SELECT @@ -717,19 +885,19 @@ public async Task<List<ProcedureStatsRow>> GetTopProceduresByCpuAsync(int server MAX(max_physical_reads) AS max_physical_reads, MIN(min_logical_writes) AS min_logical_writes, MAX(max_logical_writes) AS max_logical_writes, - SUM(total_spills) AS total_spills, + SUM(delta_spills) AS total_spills, MIN(min_spills) AS min_spills, MAX(max_spills) AS max_spills, MAX(cached_time) AS cached_time, MAX(last_execution_time) AS last_execution_time, MAX(sql_handle) AS sql_handle, - MAX(plan_handle) AS plan_handle + MAX(plan_handle) AS plan_handle, + CAST(SUM(delta_spills) AS DOUBLE PRECISION) / NULLIF(SUM(delta_execution_count), 0) AS avg_spills FROM v_procedure_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 -AND last_execution_time >= $2 + $5 * INTERVAL '1' MINUTE -AND ($6 IS NULL OR database_name = $6) +AND last_execution_time >= $2 + $5 * INTERVAL '1' MINUTE" + dbClause + @" GROUP BY database_name, schema_name, object_name, object_type HAVING SUM(delta_execution_count) > 0 OR SUM(delta_elapsed_time) > 0 ORDER BY SUM(delta_elapsed_time) DESC @@ -740,7 +908,8 @@ ORDER BY SUM(delta_elapsed_time) DESC command.Parameters.Add(new DuckDBParameter { Value = endTime }); command.Parameters.Add(new DuckDBParameter { Value = top }); command.Parameters.Add(new DuckDBParameter { Value = utcOffsetMinutes }); - command.Parameters.Add(new DuckDBParameter { Value = (object?)databaseName ?? DBNull.Value }); + foreach (var db in dbValues) + command.Parameters.Add(new DuckDBParameter { Value = db }); var items = new List<ProcedureStatsRow>(); using var reader = await command.ExecuteReaderAsync(); @@ -774,7 +943,8 @@ ORDER BY SUM(delta_elapsed_time) DESC CachedTime = reader.IsDBNull(23) ? (DateTime?)null : reader.GetDateTime(23), LastExecutionTime = reader.IsDBNull(24) ? (DateTime?)null : reader.GetDateTime(24), SqlHandle = reader.IsDBNull(25) ? "" : reader.GetString(25), - PlanHandle = reader.IsDBNull(26) ? "" : reader.GetString(26) + PlanHandle = reader.IsDBNull(26) ? "" : reader.GetString(26), + AvgSpills = reader.IsDBNull(27) ? 0 : ToDouble(reader.GetValue(27)) }); } @@ -787,18 +957,20 @@ ORDER BY SUM(delta_elapsed_time) DESC public async Task<List<ProcedureStatsComparisonItem>> GetProcedureStatsComparisonAsync( int serverId, DateTime currentStart, DateTime currentEnd, - DateTime baselineStart, DateTime baselineEnd) + DateTime baselineStart, DateTime baselineEnd, + IReadOnlyList<string>? databaseNames = null) { using var _q = TimeQuery("GetProcedureStatsComparisonAsync", "v_procedure_stats comparison"); using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); + var dbClause = BuildDbInClause(databaseNames, "database_name", 6, out var dbValues); command.CommandText = @" WITH top_current AS ( SELECT database_name, schema_name, object_name FROM v_procedure_stats WHERE server_id = $1 - AND collection_time >= $2 AND collection_time <= $3 + AND collection_time >= $2 AND collection_time <= $3" + dbClause + @" AND delta_execution_count > 0 GROUP BY database_name, schema_name, object_name ORDER BY SUM(delta_execution_count) DESC @@ -808,7 +980,7 @@ top_baseline AS ( SELECT database_name, schema_name, object_name FROM v_procedure_stats WHERE server_id = $1 - AND collection_time >= $4 AND collection_time <= $5 + AND collection_time >= $4 AND collection_time <= $5" + dbClause + @" AND delta_execution_count > 0 GROUP BY database_name, schema_name, object_name ORDER BY SUM(delta_execution_count) DESC @@ -825,9 +997,9 @@ UNION ALL current_period AS ( SELECT tp.database_name, tp.schema_name, tp.object_name, SUM(ps.delta_execution_count) AS exec_count, - SUM(ps.delta_elapsed_time)::DOUBLE / NULLIF(SUM(ps.delta_execution_count), 0) / 1000.0 AS avg_duration_ms, - SUM(ps.delta_worker_time)::DOUBLE / NULLIF(SUM(ps.delta_execution_count), 0) / 1000.0 AS avg_cpu_ms, - SUM(ps.delta_physical_reads)::DOUBLE / NULLIF(SUM(ps.delta_execution_count), 0) AS avg_reads + SUM(ps.delta_elapsed_time)::DOUBLE PRECISION / NULLIF(SUM(ps.delta_execution_count), 0) / 1000.0 AS avg_duration_ms, + SUM(ps.delta_worker_time)::DOUBLE PRECISION / NULLIF(SUM(ps.delta_execution_count), 0) / 1000.0 AS avg_cpu_ms, + SUM(ps.delta_physical_reads)::DOUBLE PRECISION / NULLIF(SUM(ps.delta_execution_count), 0) AS avg_reads FROM top_procs tp INNER JOIN v_procedure_stats ps ON ps.database_name IS NOT DISTINCT FROM tp.database_name @@ -841,9 +1013,9 @@ AND ps.delta_execution_count > 0 baseline_period AS ( SELECT tp.database_name, tp.schema_name, tp.object_name, SUM(ps.delta_execution_count) AS exec_count, - SUM(ps.delta_elapsed_time)::DOUBLE / NULLIF(SUM(ps.delta_execution_count), 0) / 1000.0 AS avg_duration_ms, - SUM(ps.delta_worker_time)::DOUBLE / NULLIF(SUM(ps.delta_execution_count), 0) / 1000.0 AS avg_cpu_ms, - SUM(ps.delta_physical_reads)::DOUBLE / NULLIF(SUM(ps.delta_execution_count), 0) AS avg_reads + SUM(ps.delta_elapsed_time)::DOUBLE PRECISION / NULLIF(SUM(ps.delta_execution_count), 0) / 1000.0 AS avg_duration_ms, + SUM(ps.delta_worker_time)::DOUBLE PRECISION / NULLIF(SUM(ps.delta_execution_count), 0) / 1000.0 AS avg_cpu_ms, + SUM(ps.delta_physical_reads)::DOUBLE PRECISION / NULLIF(SUM(ps.delta_execution_count), 0) AS avg_reads FROM top_procs tp INNER JOIN v_procedure_stats ps ON ps.database_name IS NOT DISTINCT FROM tp.database_name @@ -873,6 +1045,8 @@ AND c.schema_name IS NOT DISTINCT FROM b.schema_name command.Parameters.Add(new DuckDBParameter { Value = currentEnd }); command.Parameters.Add(new DuckDBParameter { Value = baselineStart }); command.Parameters.Add(new DuckDBParameter { Value = baselineEnd }); + foreach (var db in dbValues) + command.Parameters.Add(new DuckDBParameter { Value = db }); var items = new List<ProcedureStatsComparisonItem>(); using var reader = await command.ExecuteReaderAsync(); @@ -900,12 +1074,13 @@ AND c.schema_name IS NOT DISTINCT FROM b.schema_name /// <summary> /// Gets query duration trend — total elapsed time per collection snapshot. /// </summary> - public async Task<List<QueryTrendPoint>> GetQueryDurationTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task<List<QueryTrendPoint>> GetQueryDurationTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList<string>? databaseNames = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); command.CommandText = @" WITH raw AS @@ -914,23 +1089,25 @@ WITH raw AS collection_time, SUM(delta_elapsed_time) / 1000.0 AS total_elapsed_ms, SUM(delta_execution_count) AS total_executions, - date_diff('second', LAG(collection_time) OVER (ORDER BY collection_time), collection_time) AS interval_seconds + extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (ORDER BY collection_time)))) AS interval_seconds FROM v_query_stats WHERE server_id = $1 AND collection_time >= $2 - AND collection_time <= $3 + AND collection_time <= $3" + dbClause + @" GROUP BY collection_time ) SELECT collection_time, CASE WHEN interval_seconds > 0 THEN total_elapsed_ms / interval_seconds ELSE 0 END AS elapsed_ms_per_second, - CASE WHEN interval_seconds > 0 THEN CAST(total_executions AS DOUBLE) / interval_seconds ELSE 0 END AS executions_per_second + CASE WHEN interval_seconds > 0 THEN CAST(total_executions AS DOUBLE PRECISION) / interval_seconds ELSE 0 END AS executions_per_second FROM raw ORDER BY collection_time"; command.Parameters.Add(new DuckDBParameter { Value = serverId }); command.Parameters.Add(new DuckDBParameter { Value = startTime }); command.Parameters.Add(new DuckDBParameter { Value = endTime }); + foreach (var db in dbValues) + command.Parameters.Add(new DuckDBParameter { Value = db }); var items = new List<QueryTrendPoint>(); using var reader = await command.ExecuteReaderAsync(); @@ -949,12 +1126,13 @@ FROM raw /// <summary> /// Gets procedure duration trend — elapsed time per second per collection snapshot. /// </summary> - public async Task<List<QueryTrendPoint>> GetProcedureDurationTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task<List<QueryTrendPoint>> GetProcedureDurationTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList<string>? databaseNames = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); command.CommandText = @" WITH raw AS @@ -963,23 +1141,25 @@ WITH raw AS collection_time, SUM(delta_elapsed_time) / 1000.0 AS total_elapsed_ms, SUM(delta_execution_count) AS total_executions, - date_diff('second', LAG(collection_time) OVER (ORDER BY collection_time), collection_time) AS interval_seconds + extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (ORDER BY collection_time)))) AS interval_seconds FROM v_procedure_stats WHERE server_id = $1 AND collection_time >= $2 - AND collection_time <= $3 + AND collection_time <= $3" + dbClause + @" GROUP BY collection_time ) SELECT collection_time, CASE WHEN interval_seconds > 0 THEN total_elapsed_ms / interval_seconds ELSE 0 END AS elapsed_ms_per_second, - CASE WHEN interval_seconds > 0 THEN CAST(total_executions AS DOUBLE) / interval_seconds ELSE 0 END AS executions_per_second + CASE WHEN interval_seconds > 0 THEN CAST(total_executions AS DOUBLE PRECISION) / interval_seconds ELSE 0 END AS executions_per_second FROM raw ORDER BY collection_time"; command.Parameters.Add(new DuckDBParameter { Value = serverId }); command.Parameters.Add(new DuckDBParameter { Value = startTime }); command.Parameters.Add(new DuckDBParameter { Value = endTime }); + foreach (var db in dbValues) + command.Parameters.Add(new DuckDBParameter { Value = db }); var items = new List<QueryTrendPoint>(); using var reader = await command.ExecuteReaderAsync(); @@ -998,12 +1178,13 @@ FROM raw /// <summary> /// Gets execution count trend — executions per second per collection snapshot from query_stats. /// </summary> - public async Task<List<QueryTrendPoint>> GetExecutionCountTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task<List<QueryTrendPoint>> GetExecutionCountTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList<string>? databaseNames = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); command.CommandText = @" WITH raw AS @@ -1011,22 +1192,24 @@ WITH raw AS SELECT collection_time, SUM(delta_execution_count) AS total_executions, - date_diff('second', LAG(collection_time) OVER (ORDER BY collection_time), collection_time) AS interval_seconds + extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (ORDER BY collection_time)))) AS interval_seconds FROM v_query_stats WHERE server_id = $1 AND collection_time >= $2 - AND collection_time <= $3 + AND collection_time <= $3" + dbClause + @" GROUP BY collection_time ) SELECT collection_time, - CASE WHEN interval_seconds > 0 THEN CAST(total_executions AS DOUBLE) / interval_seconds ELSE 0 END AS executions_per_second + CASE WHEN interval_seconds > 0 THEN CAST(total_executions AS DOUBLE PRECISION) / interval_seconds ELSE 0 END AS executions_per_second FROM raw ORDER BY collection_time"; command.Parameters.Add(new DuckDBParameter { Value = serverId }); command.Parameters.Add(new DuckDBParameter { Value = startTime }); command.Parameters.Add(new DuckDBParameter { Value = endTime }); + foreach (var db in dbValues) + command.Parameters.Add(new DuckDBParameter { Value = db }); var items = new List<QueryTrendPoint>(); using var reader = await command.ExecuteReaderAsync(); @@ -1056,19 +1239,20 @@ FROM raw { HeatmapMetric.Duration => "(delta_elapsed_time / 1000.0) / NULLIF(delta_execution_count, 0)", HeatmapMetric.Cpu => "(delta_worker_time / 1000.0) / NULLIF(delta_execution_count, 0)", - HeatmapMetric.LogicalReads => "CAST(delta_logical_reads AS DOUBLE) / NULLIF(delta_execution_count, 0)", - HeatmapMetric.LogicalWrites => "CAST(delta_logical_writes AS DOUBLE) / NULLIF(delta_execution_count, 0)", - HeatmapMetric.ExecutionCount => "CAST(delta_execution_count AS DOUBLE)", + HeatmapMetric.LogicalReads => "CAST(delta_logical_reads AS DOUBLE PRECISION) / NULLIF(delta_execution_count, 0)", + HeatmapMetric.LogicalWrites => "CAST(delta_logical_writes AS DOUBLE PRECISION) / NULLIF(delta_execution_count, 0)", + HeatmapMetric.ExecutionCount => "CAST(delta_execution_count AS DOUBLE PRECISION)", _ => "(delta_elapsed_time / 1000.0) / NULLIF(delta_execution_count, 0)" }; - public async Task<HeatmapResult> GetQueryHeatmapAsync(int serverId, HeatmapMetric metric, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task<HeatmapResult> GetQueryHeatmapAsync(int serverId, HeatmapMetric metric, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList<string>? databaseNames = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); var metricExpr = GetMetricColumn(metric); + var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); AppLogger.Info("Heatmap", $"GetQueryHeatmapAsync: serverId={serverId}, metric={metric}, hoursBack={hoursBack}, start={startTime:O}, end={endTime:O}"); @@ -1085,7 +1269,7 @@ FROM v_query_stats AND collection_time >= $2 AND collection_time <= $3 AND delta_execution_count > 0 - AND {metricExpr} IS NOT NULL + AND {metricExpr} IS NOT NULL{dbClause} ) SELECT time_bin, @@ -1108,6 +1292,8 @@ FROM per_query command.Parameters.Add(new DuckDBParameter { Value = serverId }); command.Parameters.Add(new DuckDBParameter { Value = startTime }); command.Parameters.Add(new DuckDBParameter { Value = endTime }); + foreach (var db in dbValues) + command.Parameters.Add(new DuckDBParameter { Value = db }); var rawCells = new List<HeatmapCell>(); using var reader = await command.ExecuteReaderAsync(); @@ -1187,6 +1373,9 @@ public class QueryTrendPoint public class QueryStatsRow { + /// <summary>Gates "Get Actual Plan (re-run)" — see <see cref="QuerySnapshotRow.CanGetActualPlan"/>.</summary> + public bool CanGetActualPlan => !string.IsNullOrEmpty(QueryText); + public string DatabaseName { get; set; } = ""; public string QueryHash { get; set; } = ""; public DateTime? LastExecutionTime { get; set; } @@ -1213,14 +1402,39 @@ public class QueryStatsRow public long MaxRows { get; set; } public long MinGrantKb { get; set; } public long MaxGrantKb { get; set; } + public long MinUsedGrantKb { get; set; } + public long MaxUsedGrantKb { get; set; } + public long MinIdealGrantKb { get; set; } + public long MaxIdealGrantKb { get; set; } + public long MinReservedThreads { get; set; } + public long MaxReservedThreads { get; set; } + public long MinUsedThreads { get; set; } + public long MaxUsedThreads { get; set; } + public long TotalClrUs { get; set; } public long MinSpills { get; set; } public long MaxSpills { get; set; } + public long PlanGenerationNum { get; set; } + public double WorkerTimePerSecond { get; set; } public string QueryPlanHash { get; set; } = ""; public string SqlHandle { get; set; } = ""; public string PlanHandle { get; set; } = ""; public string QueryText { get; set; } = ""; public string? QueryPlan { get; set; } public bool HasQueryPlan => !string.IsNullOrEmpty(QueryPlan); + + /* #1568 module attribution (read-time stitch of query_stats.sql_handle -> procedure_stats): the + matched procedure/function/trigger's identity, or empty when this statement is ad hoc. */ + public string ModuleObjectName { get; set; } = ""; + public string ModuleSchemaName { get; set; } = ""; + public string ModuleDatabaseName { get; set; } = ""; + + /// <summary>Grid "Module" column: <c>database.schema.object</c> when this statement's sql_handle matched a + /// cached module (procedure/function/trigger), else the literal <c>ad hoc</c> (#1568).</summary> + public string ModuleName => + string.IsNullOrEmpty(ModuleObjectName) + ? "ad hoc" + : $"{ModuleDatabaseName}.{ModuleSchemaName}.{ModuleObjectName}"; + public double TotalCpuMs => TotalCpuUs / 1000.0; public double TotalElapsedMs => TotalElapsedUs / 1000.0; public double AvgCpuMs => TotalExecutions > 0 ? TotalCpuMs / TotalExecutions : 0; @@ -1230,6 +1444,8 @@ public class QueryStatsRow public double MaxCpuMs => MaxCpuUs / 1000.0; public double MinElapsedMs => MinElapsedUs / 1000.0; public double MaxElapsedMs => MaxElapsedUs / 1000.0; + // total_clr_time is stored in microseconds (like worker/elapsed time) + public double TotalClrMs => TotalClrUs / 1000.0; } public class ProcedureStatsRow @@ -1255,6 +1471,7 @@ public class ProcedureStatsRow public long MinLogicalWrites { get; set; } public long MaxLogicalWrites { get; set; } public long TotalSpills { get; set; } + public double AvgSpills { get; set; } public long MinSpills { get; set; } public long MaxSpills { get; set; } public DateTime? CachedTime { get; set; } @@ -1292,19 +1509,56 @@ public class QueryStatsHistoryRow public long MaxCpuUs { get; set; } public long MinElapsedUs { get; set; } public long MaxElapsedUs { get; set; } - public string? QueryPlan { get; set; } + public long MinGrantKb { get; set; } + public long MaxGrantKb { get; set; } + public long MinUsedGrantKb { get; set; } + public long MaxUsedGrantKb { get; set; } + public long MinIdealGrantKb { get; set; } + public long MaxIdealGrantKb { get; set; } + public long MinReservedThreads { get; set; } + public long MaxReservedThreads { get; set; } + public long MinUsedThreads { get; set; } + public long MaxUsedThreads { get; set; } + public long MinPhysicalReads { get; set; } + public long MaxPhysicalReads { get; set; } + public long MinRows { get; set; } + public long MaxRows { get; set; } + public long MinSpills { get; set; } + public long MaxSpills { get; set; } + public long TotalClrTimeUs { get; set; } public string QueryPlanHash { get; set; } = ""; - public bool HasQueryPlan => !string.IsNullOrEmpty(QueryPlan); + public DateTime? CreationTime { get; set; } + public DateTime? LastExecutionTime { get; set; } + public long TotalExecutions { get; set; } + public long TotalCpuUs { get; set; } + public long TotalElapsedUs { get; set; } + public long TotalLogicalReads { get; set; } + public long TotalLogicalWrites { get; set; } + public long TotalPhysicalReads { get; set; } + public long TotalRows { get; set; } + public long TotalSpills { get; set; } + public string SqlHandle { get; set; } = ""; + public string PlanHandle { get; set; } = ""; + public string QueryHash { get; set; } = ""; + public int? SampleIntervalSeconds { get; set; } public double DeltaCpuMs => DeltaCpuUs / 1000.0; public double DeltaElapsedMs => DeltaElapsedUs / 1000.0; public double AvgCpuMs => DeltaExecutions > 0 ? DeltaCpuMs / DeltaExecutions : 0; public double AvgElapsedMs => DeltaExecutions > 0 ? DeltaElapsedMs / DeltaExecutions : 0; public double AvgReads => DeltaExecutions > 0 ? (double)DeltaLogicalReads / DeltaExecutions : 0; + public double AvgPhysicalReads => DeltaExecutions > 0 ? (double)DeltaPhysicalReads / DeltaExecutions : 0; + public double AvgWrites => DeltaExecutions > 0 ? (double)DeltaLogicalWrites / DeltaExecutions : 0; + public double AvgRows => DeltaExecutions > 0 ? (double)DeltaRows / DeltaExecutions : 0; public double MinCpuMs => MinCpuUs / 1000.0; public double MaxCpuMs => MaxCpuUs / 1000.0; public double MinElapsedMs => MinElapsedUs / 1000.0; public double MaxElapsedMs => MaxElapsedUs / 1000.0; + public double TotalClrMs => TotalClrTimeUs / 1000.0; + public double TotalCpuMs => TotalCpuUs / 1000.0; + public double TotalElapsedMs => TotalElapsedUs / 1000.0; public string CollectionTimeLocal => ServerTimeHelper.FormatServerTime(CollectionTime); + public string CreationTimeLocal => ServerTimeHelper.FormatServerTime(CreationTime); + public string LastExecutionTimeLocal => ServerTimeHelper.FormatServerTime(LastExecutionTime); } public class ProcedureStatsHistoryRow @@ -1321,14 +1575,42 @@ public class ProcedureStatsHistoryRow public long MinElapsedTimeUs { get; set; } public long MaxElapsedTimeUs { get; set; } public long TotalSpills { get; set; } + public long MinLogicalReads { get; set; } + public long MaxLogicalReads { get; set; } + public long MinPhysicalReads { get; set; } + public long MaxPhysicalReads { get; set; } + public long MinLogicalWrites { get; set; } + public long MaxLogicalWrites { get; set; } + public long MinSpills { get; set; } + public long MaxSpills { get; set; } + public string SqlHandle { get; set; } = ""; + public string PlanHandle { get; set; } = ""; + public DateTime? CachedTime { get; set; } + public DateTime? LastExecutionTime { get; set; } + public string ObjectType { get; set; } = ""; + public long TotalExecutions { get; set; } + public long TotalCpuUs { get; set; } + public long TotalElapsedUs { get; set; } + public long TotalLogicalReads { get; set; } + public long TotalPhysicalReads { get; set; } + public long TotalLogicalWrites { get; set; } + public long DeltaSpills { get; set; } + public int? SampleIntervalSeconds { get; set; } public double DeltaCpuMs => DeltaCpuUs / 1000.0; public double DeltaElapsedMs => DeltaElapsedUs / 1000.0; public double AvgCpuMs => DeltaExecutions > 0 ? DeltaCpuMs / DeltaExecutions : 0; public double AvgElapsedMs => DeltaExecutions > 0 ? DeltaElapsedMs / DeltaExecutions : 0; public double AvgReads => DeltaExecutions > 0 ? (double)DeltaLogicalReads / DeltaExecutions : 0; + public double AvgPhysicalReads => DeltaExecutions > 0 ? (double)DeltaPhysicalReads / DeltaExecutions : 0; + public double AvgWrites => DeltaExecutions > 0 ? (double)DeltaLogicalWrites / DeltaExecutions : 0; + public double AvgSpills => DeltaExecutions > 0 ? (double)DeltaSpills / DeltaExecutions : 0; public double MinCpuMs => MinWorkerTimeUs / 1000.0; public double MaxCpuMs => MaxWorkerTimeUs / 1000.0; public double MinElapsedMs => MinElapsedTimeUs / 1000.0; public double MaxElapsedMs => MaxElapsedTimeUs / 1000.0; + public double TotalCpuMs => TotalCpuUs / 1000.0; + public double TotalElapsedMs => TotalElapsedUs / 1000.0; public string CollectionTimeLocal => ServerTimeHelper.FormatServerTime(CollectionTime); + public string CachedTimeLocal => ServerTimeHelper.FormatServerTime(CachedTime); + public string LastExecutionTimeLocal => ServerTimeHelper.FormatServerTime(LastExecutionTime); } diff --git a/Lite/Services/LocalDataService.QueryStore.cs b/Lite/Services/LocalDataService.QueryStore.cs index ab360f4bc..ad2615aca 100644 --- a/Lite/Services/LocalDataService.QueryStore.cs +++ b/Lite/Services/LocalDataService.QueryStore.cs @@ -24,31 +24,34 @@ public partial class LocalDataService /// Shows top queries by total duration (execution_count * avg_duration). /// </summary> public async Task<List<TimeSliceBucket>> GetQueryStoreSlicerDataAsync( - int serverId, int hoursBack, DateTime? fromDate = null, DateTime? toDate = null) + int serverId, int hoursBack, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList<string>? databaseNames = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); command.CommandText = @" SELECT date_trunc('hour', collection_time) AS bucket, COUNT(DISTINCT query_id) AS query_count, - COALESCE(SUM(CAST(avg_cpu_time_us AS DOUBLE) * execution_count), 0) / 1000.0 AS total_cpu_ms, - COALESCE(SUM(CAST(avg_duration_us AS DOUBLE) * execution_count), 0) / 1000.0 AS total_duration_ms, - COALESCE(SUM(CAST(avg_logical_io_reads AS DOUBLE) * execution_count), 0) AS total_reads, - COALESCE(SUM(CAST(avg_logical_io_writes AS DOUBLE) * execution_count), 0) AS total_writes, - COALESCE(SUM(CAST(avg_physical_io_reads AS DOUBLE) * execution_count), 0) AS total_physical_reads + COALESCE(SUM(CAST(avg_cpu_time_us AS DOUBLE PRECISION) * execution_count), 0) / 1000.0 AS total_cpu_ms, + COALESCE(SUM(CAST(avg_duration_us AS DOUBLE PRECISION) * execution_count), 0) / 1000.0 AS total_duration_ms, + COALESCE(SUM(CAST(avg_logical_io_reads AS DOUBLE PRECISION) * execution_count), 0) AS total_reads, + COALESCE(SUM(CAST(avg_logical_io_writes AS DOUBLE PRECISION) * execution_count), 0) AS total_writes, + COALESCE(SUM(CAST(avg_physical_io_reads AS DOUBLE PRECISION) * execution_count), 0) AS total_physical_reads FROM v_query_store_stats WHERE server_id = $1 AND collection_time >= $2 -AND collection_time <= $3 +AND collection_time <= $3" + dbClause + @" GROUP BY date_trunc('hour', collection_time) ORDER BY bucket"; command.Parameters.Add(new DuckDBParameter { Value = serverId }); command.Parameters.Add(new DuckDBParameter { Value = startTime }); command.Parameters.Add(new DuckDBParameter { Value = endTime }); + foreach (var db in dbValues) + command.Parameters.Add(new DuckDBParameter { Value = db }); var items = new List<TimeSliceBucket>(); using var reader = await command.ExecuteReaderAsync(); @@ -69,13 +72,14 @@ GROUP BY date_trunc('hour', collection_time) return items; } - public async Task<List<QueryStoreRow>> GetQueryStoreTopQueriesAsync(int serverId, int hoursBack = 24, int top = 50, DateTime? fromDate = null, DateTime? toDate = null, string? databaseName = null) + public async Task<List<QueryStoreRow>> GetQueryStoreTopQueriesAsync(int serverId, int hoursBack = 24, int top = 50, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList<string>? databaseNames = null) { using var _q = TimeQuery("GetQueryStoreTopQueriesAsync", "v_query_store_stats top N"); using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var dbClause = BuildDbInClause(databaseNames, "database_name", 5, out var dbValues); command.CommandText = @" WITH ranked AS ( @@ -84,14 +88,21 @@ WITH ranked AS ( query_id, plan_id, query_hash, + /* A GROUP BY key, not MAX(): with Query Store for secondary replicas (2022+) the primary holds + ONE shared Query Store carrying every replica's rows, so grouping without it would average + primary and secondary workload into a single blended row — the conflation this column exists + to expose. Grouping splits them into one row per replica role instead. On a standalone/non-AG + server every row shares one value (NULL, or 'Primary' on 2025), so the grouping is a no-op + and the grid is unchanged. */ + replica_role, MAX(module_name) AS module_name, SUM(execution_count) AS total_executions, - AVG(CAST(avg_duration_us AS DOUBLE)) / 1000.0 AS avg_duration_ms, - AVG(CAST(avg_cpu_time_us AS DOUBLE)) / 1000.0 AS avg_cpu_time_ms, - AVG(CAST(avg_logical_io_reads AS DOUBLE)) AS avg_logical_reads, - AVG(CAST(avg_logical_io_writes AS DOUBLE)) AS avg_logical_writes, - AVG(CAST(avg_physical_io_reads AS DOUBLE)) AS avg_physical_reads, - AVG(CAST(avg_rowcount AS DOUBLE)) AS avg_rowcount, + AVG(CAST(avg_duration_us AS DOUBLE PRECISION)) / 1000.0 AS avg_duration_ms, + AVG(CAST(avg_cpu_time_us AS DOUBLE PRECISION)) / 1000.0 AS avg_cpu_time_ms, + AVG(CAST(avg_logical_io_reads AS DOUBLE PRECISION)) AS avg_logical_reads, + AVG(CAST(avg_logical_io_writes AS DOUBLE PRECISION)) AS avg_logical_writes, + AVG(CAST(avg_physical_io_reads AS DOUBLE PRECISION)) AS avg_physical_reads, + AVG(CAST(avg_rowcount AS DOUBLE PRECISION)) AS avg_rowcount, MIN(min_dop) AS min_dop, MAX(max_dop) AS max_dop, MAX(last_execution_time) AS last_execution_time, @@ -100,20 +111,43 @@ WITH ranked AS ( MAX(plan_forcing_type) AS plan_forcing_type, MAX(execution_type_desc) AS execution_type_desc, MIN(first_execution_time) AS first_execution_time, - AVG(CAST(avg_clr_time_us AS DOUBLE)) / 1000.0 AS avg_clr_time_ms, - AVG(CAST(avg_tempdb_space_used AS DOUBLE)) AS avg_tempdb_space_used, - AVG(CAST(avg_log_bytes_used AS DOUBLE)) AS avg_log_bytes_used, + AVG(CAST(avg_clr_time_us AS DOUBLE PRECISION)) / 1000.0 AS avg_clr_time_ms, + AVG(CAST(avg_tempdb_space_used AS DOUBLE PRECISION)) AS avg_tempdb_space_used, + AVG(CAST(avg_log_bytes_used AS DOUBLE PRECISION)) AS avg_log_bytes_used, MAX(plan_type) AS plan_type, MAX(force_failure_count) AS force_failure_count, MAX(last_force_failure_reason) AS last_force_failure_reason, - MAX(compatibility_level) AS compatibility_level + MAX(compatibility_level) AS compatibility_level, + MIN(CAST(min_duration_us AS DOUBLE PRECISION)) / 1000.0 AS min_duration_ms, + MAX(CAST(max_duration_us AS DOUBLE PRECISION)) / 1000.0 AS max_duration_ms, + MIN(CAST(min_cpu_time_us AS DOUBLE PRECISION)) / 1000.0 AS min_cpu_time_ms, + MAX(CAST(max_cpu_time_us AS DOUBLE PRECISION)) / 1000.0 AS max_cpu_time_ms, + MIN(CAST(min_logical_io_reads AS DOUBLE PRECISION)) AS min_logical_reads, + MAX(CAST(max_logical_io_reads AS DOUBLE PRECISION)) AS max_logical_reads, + MIN(CAST(min_logical_io_writes AS DOUBLE PRECISION)) AS min_logical_writes, + MAX(CAST(max_logical_io_writes AS DOUBLE PRECISION)) AS max_logical_writes, + MIN(CAST(min_physical_io_reads AS DOUBLE PRECISION)) AS min_physical_reads, + MAX(CAST(max_physical_io_reads AS DOUBLE PRECISION)) AS max_physical_reads, + MIN(CAST(min_clr_time_us AS DOUBLE PRECISION)) / 1000.0 AS min_clr_time_ms, + MAX(CAST(max_clr_time_us AS DOUBLE PRECISION)) / 1000.0 AS max_clr_time_ms, + MIN(CAST(min_rowcount AS DOUBLE PRECISION)) AS min_rowcount, + MAX(CAST(max_rowcount AS DOUBLE PRECISION)) AS max_rowcount, + MIN(CAST(min_log_bytes_used AS DOUBLE PRECISION)) AS min_log_bytes_used, + MAX(CAST(max_log_bytes_used AS DOUBLE PRECISION)) AS max_log_bytes_used, + MIN(CAST(min_tempdb_space_used AS DOUBLE PRECISION)) AS min_tempdb_space_used, + MAX(CAST(max_tempdb_space_used AS DOUBLE PRECISION)) AS max_tempdb_space_used, + AVG(CAST(avg_query_max_used_memory AS DOUBLE PRECISION)) * 8.0 / 1024.0 AS avg_memory_mb, + MIN(CAST(min_query_max_used_memory AS DOUBLE PRECISION)) * 8.0 / 1024.0 AS min_memory_mb, + MAX(CAST(max_query_max_used_memory AS DOUBLE PRECISION)) * 8.0 / 1024.0 AS max_memory_mb, + AVG(CAST(avg_num_physical_io_reads AS DOUBLE PRECISION)) AS avg_num_physical_io_reads, + MIN(CAST(min_num_physical_io_reads AS DOUBLE PRECISION)) AS min_num_physical_io_reads, + MAX(CAST(max_num_physical_io_reads AS DOUBLE PRECISION)) AS max_num_physical_io_reads FROM v_query_store_stats WHERE server_id = $1 AND collection_time >= $2 - AND collection_time <= $3 - AND ($5 IS NULL OR database_name = $5) - GROUP BY database_name, query_id, plan_id, query_hash - ORDER BY SUM(execution_count) * AVG(CAST(avg_duration_us AS DOUBLE)) DESC + AND collection_time <= $3" + dbClause + @" + GROUP BY database_name, query_id, plan_id, query_hash, replica_role + ORDER BY SUM(execution_count) * AVG(CAST(avg_duration_us AS DOUBLE PRECISION)) DESC LIMIT $4 + 5 ) SELECT @@ -145,7 +179,32 @@ ORDER BY SUM(execution_count) * AVG(CAST(avg_duration_us AS DOUBLE)) DESC r.plan_type, r.force_failure_count, r.last_force_failure_reason, - r.compatibility_level + r.compatibility_level, + r.min_duration_ms, + r.max_duration_ms, + r.min_cpu_time_ms, + r.max_cpu_time_ms, + r.min_logical_reads, + r.max_logical_reads, + r.min_logical_writes, + r.max_logical_writes, + r.min_physical_reads, + r.max_physical_reads, + r.min_clr_time_ms, + r.max_clr_time_ms, + r.min_rowcount, + r.max_rowcount, + r.min_log_bytes_used, + r.max_log_bytes_used, + r.min_tempdb_space_used, + r.max_tempdb_space_used, + r.avg_memory_mb, + r.min_memory_mb, + r.max_memory_mb, + r.avg_num_physical_io_reads, + r.min_num_physical_io_reads, + r.max_num_physical_io_reads, + r.replica_role FROM ranked r LEFT JOIN LATERAL ( SELECT query_text @@ -165,7 +224,8 @@ ORDER BY r.total_executions * r.avg_duration_ms DESC command.Parameters.Add(new DuckDBParameter { Value = startTime }); command.Parameters.Add(new DuckDBParameter { Value = endTime }); command.Parameters.Add(new DuckDBParameter { Value = top }); - command.Parameters.Add(new DuckDBParameter { Value = (object?)databaseName ?? DBNull.Value }); + foreach (var db in dbValues) + command.Parameters.Add(new DuckDBParameter { Value = db }); var items = new List<QueryStoreRow>(); using var reader = await command.ExecuteReaderAsync(); @@ -201,7 +261,32 @@ ORDER BY r.total_executions * r.avg_duration_ms DESC PlanType = reader.IsDBNull(25) ? "" : reader.GetString(25), ForceFailureCount = reader.IsDBNull(26) ? 0 : Convert.ToInt64(reader.GetValue(26)), LastForceFailureReason = reader.IsDBNull(27) ? "" : reader.GetString(27), - CompatibilityLevel = reader.IsDBNull(28) ? 0 : Convert.ToInt32(reader.GetValue(28)) + CompatibilityLevel = reader.IsDBNull(28) ? 0 : Convert.ToInt32(reader.GetValue(28)), + MinDurationMs = reader.IsDBNull(29) ? 0 : ToDouble(reader.GetValue(29)), + MaxDurationMs = reader.IsDBNull(30) ? 0 : ToDouble(reader.GetValue(30)), + MinCpuTimeMs = reader.IsDBNull(31) ? 0 : ToDouble(reader.GetValue(31)), + MaxCpuTimeMs = reader.IsDBNull(32) ? 0 : ToDouble(reader.GetValue(32)), + MinLogicalReads = reader.IsDBNull(33) ? 0 : ToDouble(reader.GetValue(33)), + MaxLogicalReads = reader.IsDBNull(34) ? 0 : ToDouble(reader.GetValue(34)), + MinLogicalWrites = reader.IsDBNull(35) ? 0 : ToDouble(reader.GetValue(35)), + MaxLogicalWrites = reader.IsDBNull(36) ? 0 : ToDouble(reader.GetValue(36)), + MinPhysicalReads = reader.IsDBNull(37) ? 0 : ToDouble(reader.GetValue(37)), + MaxPhysicalReads = reader.IsDBNull(38) ? 0 : ToDouble(reader.GetValue(38)), + MinClrTimeMs = reader.IsDBNull(39) ? 0 : ToDouble(reader.GetValue(39)), + MaxClrTimeMs = reader.IsDBNull(40) ? 0 : ToDouble(reader.GetValue(40)), + MinRowcount = reader.IsDBNull(41) ? 0 : ToDouble(reader.GetValue(41)), + MaxRowcount = reader.IsDBNull(42) ? 0 : ToDouble(reader.GetValue(42)), + MinLogBytesUsed = reader.IsDBNull(43) ? 0 : ToDouble(reader.GetValue(43)), + MaxLogBytesUsed = reader.IsDBNull(44) ? 0 : ToDouble(reader.GetValue(44)), + MinTempdbSpaceUsed = reader.IsDBNull(45) ? 0 : ToDouble(reader.GetValue(45)), + MaxTempdbSpaceUsed = reader.IsDBNull(46) ? 0 : ToDouble(reader.GetValue(46)), + AvgMemoryMb = reader.IsDBNull(47) ? 0 : ToDouble(reader.GetValue(47)), + MinMemoryMb = reader.IsDBNull(48) ? 0 : ToDouble(reader.GetValue(48)), + MaxMemoryMb = reader.IsDBNull(49) ? 0 : ToDouble(reader.GetValue(49)), + AvgNumPhysicalIoReads = reader.IsDBNull(50) ? 0 : ToDouble(reader.GetValue(50)), + MinNumPhysicalIoReads = reader.IsDBNull(51) ? 0 : ToDouble(reader.GetValue(51)), + MaxNumPhysicalIoReads = reader.IsDBNull(52) ? 0 : ToDouble(reader.GetValue(52)), + ReplicaRole = reader.IsDBNull(53) ? null : reader.GetString(53) }); } @@ -215,18 +300,20 @@ ORDER BY r.total_executions * r.avg_duration_ms DESC public async Task<List<QueryStatsComparisonItem>> GetQueryStoreComparisonAsync( int serverId, DateTime currentStart, DateTime currentEnd, - DateTime baselineStart, DateTime baselineEnd) + DateTime baselineStart, DateTime baselineEnd, + IReadOnlyList<string>? databaseNames = null) { using var _q = TimeQuery("GetQueryStoreComparisonAsync", "v_query_store_stats comparison"); using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); + var dbClause = BuildDbInClause(databaseNames, "database_name", 6, out var dbValues); command.CommandText = @" WITH top_current AS ( SELECT database_name, query_hash FROM v_query_store_stats WHERE server_id = $1 - AND collection_time >= $2 AND collection_time <= $3 + AND collection_time >= $2 AND collection_time <= $3" + dbClause + @" AND execution_count > 0 GROUP BY database_name, query_hash ORDER BY SUM(execution_count) DESC @@ -236,7 +323,7 @@ top_baseline AS ( SELECT database_name, query_hash FROM v_query_store_stats WHERE server_id = $1 - AND collection_time >= $4 AND collection_time <= $5 + AND collection_time >= $4 AND collection_time <= $5" + dbClause + @" AND execution_count > 0 GROUP BY database_name, query_hash ORDER BY SUM(execution_count) DESC @@ -253,9 +340,9 @@ UNION ALL current_period AS ( SELECT th.database_name, th.query_hash, SUM(qs.execution_count) AS exec_count, - SUM(qs.execution_count * qs.avg_duration_us::DOUBLE) / NULLIF(SUM(qs.execution_count), 0) / 1000.0 AS avg_duration_ms, - SUM(qs.execution_count * qs.avg_cpu_time_us::DOUBLE) / NULLIF(SUM(qs.execution_count), 0) / 1000.0 AS avg_cpu_ms, - SUM(qs.execution_count * qs.avg_logical_io_reads::DOUBLE) / NULLIF(SUM(qs.execution_count), 0) AS avg_reads, + SUM(qs.execution_count * qs.avg_duration_us::DOUBLE PRECISION) / NULLIF(SUM(qs.execution_count), 0) / 1000.0 AS avg_duration_ms, + SUM(qs.execution_count * qs.avg_cpu_time_us::DOUBLE PRECISION) / NULLIF(SUM(qs.execution_count), 0) / 1000.0 AS avg_cpu_ms, + SUM(qs.execution_count * qs.avg_logical_io_reads::DOUBLE PRECISION) / NULLIF(SUM(qs.execution_count), 0) AS avg_reads, MAX(qs.query_text) AS query_text FROM top_hashes th INNER JOIN v_query_store_stats qs @@ -269,9 +356,9 @@ AND qs.execution_count > 0 baseline_period AS ( SELECT th.database_name, th.query_hash, SUM(qs.execution_count) AS exec_count, - SUM(qs.execution_count * qs.avg_duration_us::DOUBLE) / NULLIF(SUM(qs.execution_count), 0) / 1000.0 AS avg_duration_ms, - SUM(qs.execution_count * qs.avg_cpu_time_us::DOUBLE) / NULLIF(SUM(qs.execution_count), 0) / 1000.0 AS avg_cpu_ms, - SUM(qs.execution_count * qs.avg_logical_io_reads::DOUBLE) / NULLIF(SUM(qs.execution_count), 0) AS avg_reads, + SUM(qs.execution_count * qs.avg_duration_us::DOUBLE PRECISION) / NULLIF(SUM(qs.execution_count), 0) / 1000.0 AS avg_duration_ms, + SUM(qs.execution_count * qs.avg_cpu_time_us::DOUBLE PRECISION) / NULLIF(SUM(qs.execution_count), 0) / 1000.0 AS avg_cpu_ms, + SUM(qs.execution_count * qs.avg_logical_io_reads::DOUBLE PRECISION) / NULLIF(SUM(qs.execution_count), 0) AS avg_reads, MAX(qs.query_text) AS query_text FROM top_hashes th INNER JOIN v_query_store_stats qs @@ -300,6 +387,8 @@ ON c.database_name IS NOT DISTINCT FROM b.database_name command.Parameters.Add(new DuckDBParameter { Value = currentEnd }); command.Parameters.Add(new DuckDBParameter { Value = baselineStart }); command.Parameters.Add(new DuckDBParameter { Value = baselineEnd }); + foreach (var db in dbValues) + command.Parameters.Add(new DuckDBParameter { Value = db }); var items = new List<QueryStatsComparisonItem>(); using var reader = await command.ExecuteReaderAsync(); @@ -327,7 +416,11 @@ ON c.database_name IS NOT DISTINCT FROM b.database_name /// <summary> /// Gets collection-level history for a specific Query Store query (for drilldown). /// </summary> - public async Task<List<QueryStoreHistoryRow>> GetQueryStoreHistoryAsync(int serverId, string databaseName, long queryId, long planId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + /// <summary> + /// Gets collection-level history for ALL plans of a Query Store query (query-scoped, matching + /// the Dashboard drilldown) so plan switches and regressions are visible over time. + /// </summary> + public async Task<List<QueryStoreHistoryRow>> GetQueryStoreHistoryAsync(int serverId, string databaseName, long queryId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); @@ -338,26 +431,63 @@ public async Task<List<QueryStoreHistoryRow>> GetQueryStoreHistoryAsync(int serv execution_count, avg_duration_us / 1000.0 AS avg_duration_ms, avg_cpu_time_us / 1000.0 AS avg_cpu_time_ms, - CAST(avg_logical_io_reads AS DOUBLE) AS avg_logical_reads, - CAST(avg_logical_io_writes AS DOUBLE) AS avg_logical_writes, - CAST(avg_physical_io_reads AS DOUBLE) AS avg_physical_reads, - CAST(avg_rowcount AS DOUBLE) AS avg_rowcount, + CAST(avg_logical_io_reads AS DOUBLE PRECISION) AS avg_logical_reads, + CAST(avg_logical_io_writes AS DOUBLE PRECISION) AS avg_logical_writes, + CAST(avg_physical_io_reads AS DOUBLE PRECISION) AS avg_physical_reads, + CAST(avg_rowcount AS DOUBLE PRECISION) AS avg_rowcount, last_execution_time, min_dop, - max_dop + max_dop, + execution_type_desc, + first_execution_time, + module_name, + CAST(avg_num_physical_io_reads AS DOUBLE PRECISION) AS avg_num_physical_reads, + CAST(min_num_physical_io_reads AS DOUBLE PRECISION) AS min_num_physical_reads, + CAST(max_num_physical_io_reads AS DOUBLE PRECISION) AS max_num_physical_reads, + avg_clr_time_us / 1000.0 AS avg_clr_time_ms, + min_clr_time_us / 1000.0 AS min_clr_time_ms, + max_clr_time_us / 1000.0 AS max_clr_time_ms, + CAST(avg_log_bytes_used AS DOUBLE PRECISION) / 1048576.0 AS avg_log_mb, + CAST(min_log_bytes_used AS DOUBLE PRECISION) / 1048576.0 AS min_log_mb, + CAST(max_log_bytes_used AS DOUBLE PRECISION) / 1048576.0 AS max_log_mb, + plan_id, + min_duration_us / 1000.0 AS min_duration_ms, + max_duration_us / 1000.0 AS max_duration_ms, + min_cpu_time_us / 1000.0 AS min_cpu_time_ms, + max_cpu_time_us / 1000.0 AS max_cpu_time_ms, + CAST(min_logical_io_reads AS DOUBLE PRECISION) AS min_logical_reads, + CAST(max_logical_io_reads AS DOUBLE PRECISION) AS max_logical_reads, + CAST(min_logical_io_writes AS DOUBLE PRECISION) AS min_logical_writes, + CAST(max_logical_io_writes AS DOUBLE PRECISION) AS max_logical_writes, + CAST(min_physical_io_reads AS DOUBLE PRECISION) AS min_physical_reads, + CAST(max_physical_io_reads AS DOUBLE PRECISION) AS max_physical_reads, + CAST(min_rowcount AS DOUBLE PRECISION) AS min_rowcount, + CAST(max_rowcount AS DOUBLE PRECISION) AS max_rowcount, + CAST(avg_query_max_used_memory AS DOUBLE PRECISION) * 8.0 / 1024.0 AS avg_memory_mb, + CAST(min_query_max_used_memory AS DOUBLE PRECISION) * 8.0 / 1024.0 AS min_memory_mb, + CAST(max_query_max_used_memory AS DOUBLE PRECISION) * 8.0 / 1024.0 AS max_memory_mb, + CAST(avg_tempdb_space_used AS DOUBLE PRECISION) * 8.0 / 1024.0 AS avg_tempdb_mb, + CAST(min_tempdb_space_used AS DOUBLE PRECISION) * 8.0 / 1024.0 AS min_tempdb_mb, + CAST(max_tempdb_space_used AS DOUBLE PRECISION) * 8.0 / 1024.0 AS max_tempdb_mb, + plan_type, + is_forced_plan, + force_failure_count, + last_force_failure_reason, + plan_forcing_type, + compatibility_level, + query_hash, + query_plan_hash FROM v_query_store_stats WHERE server_id = $1 AND database_name = $2 AND query_id = $3 -AND plan_id = $4 -AND collection_time >= $5 -AND collection_time <= $6 +AND collection_time >= $4 +AND collection_time <= $5 ORDER BY collection_time"; command.Parameters.Add(new DuckDBParameter { Value = serverId }); command.Parameters.Add(new DuckDBParameter { Value = databaseName }); command.Parameters.Add(new DuckDBParameter { Value = queryId }); - command.Parameters.Add(new DuckDBParameter { Value = planId }); command.Parameters.Add(new DuckDBParameter { Value = startTime }); command.Parameters.Add(new DuckDBParameter { Value = endTime }); @@ -377,39 +507,52 @@ FROM v_query_store_stats AvgRowcount = reader.IsDBNull(7) ? 0 : ToDouble(reader.GetValue(7)), LastExecutionTime = reader.IsDBNull(8) ? (DateTime?)null : reader.GetDateTime(8), MinDop = reader.IsDBNull(9) ? 0 : Convert.ToInt64(reader.GetValue(9)), - MaxDop = reader.IsDBNull(10) ? 0 : Convert.ToInt64(reader.GetValue(10)) + MaxDop = reader.IsDBNull(10) ? 0 : Convert.ToInt64(reader.GetValue(10)), + ExecutionTypeDesc = reader.IsDBNull(11) ? "" : reader.GetString(11), + FirstExecutionTime = reader.IsDBNull(12) ? (DateTime?)null : reader.GetDateTime(12), + ModuleName = reader.IsDBNull(13) ? "" : reader.GetString(13), + AvgNumPhysicalReads = reader.IsDBNull(14) ? 0 : ToDouble(reader.GetValue(14)), + MinNumPhysicalReads = reader.IsDBNull(15) ? 0 : ToDouble(reader.GetValue(15)), + MaxNumPhysicalReads = reader.IsDBNull(16) ? 0 : ToDouble(reader.GetValue(16)), + AvgClrTimeMs = reader.IsDBNull(17) ? 0 : ToDouble(reader.GetValue(17)), + MinClrTimeMs = reader.IsDBNull(18) ? 0 : ToDouble(reader.GetValue(18)), + MaxClrTimeMs = reader.IsDBNull(19) ? 0 : ToDouble(reader.GetValue(19)), + AvgLogMb = reader.IsDBNull(20) ? 0 : ToDouble(reader.GetValue(20)), + MinLogMb = reader.IsDBNull(21) ? 0 : ToDouble(reader.GetValue(21)), + MaxLogMb = reader.IsDBNull(22) ? 0 : ToDouble(reader.GetValue(22)), + PlanId = reader.IsDBNull(23) ? 0 : reader.GetInt64(23), + MinDurationMs = reader.IsDBNull(24) ? 0 : ToDouble(reader.GetValue(24)), + MaxDurationMs = reader.IsDBNull(25) ? 0 : ToDouble(reader.GetValue(25)), + MinCpuTimeMs = reader.IsDBNull(26) ? 0 : ToDouble(reader.GetValue(26)), + MaxCpuTimeMs = reader.IsDBNull(27) ? 0 : ToDouble(reader.GetValue(27)), + MinLogicalReads = reader.IsDBNull(28) ? 0 : ToDouble(reader.GetValue(28)), + MaxLogicalReads = reader.IsDBNull(29) ? 0 : ToDouble(reader.GetValue(29)), + MinLogicalWrites = reader.IsDBNull(30) ? 0 : ToDouble(reader.GetValue(30)), + MaxLogicalWrites = reader.IsDBNull(31) ? 0 : ToDouble(reader.GetValue(31)), + MinPhysicalReads = reader.IsDBNull(32) ? 0 : ToDouble(reader.GetValue(32)), + MaxPhysicalReads = reader.IsDBNull(33) ? 0 : ToDouble(reader.GetValue(33)), + MinRowcount = reader.IsDBNull(34) ? 0 : ToDouble(reader.GetValue(34)), + MaxRowcount = reader.IsDBNull(35) ? 0 : ToDouble(reader.GetValue(35)), + AvgMemoryMb = reader.IsDBNull(36) ? 0 : ToDouble(reader.GetValue(36)), + MinMemoryMb = reader.IsDBNull(37) ? 0 : ToDouble(reader.GetValue(37)), + MaxMemoryMb = reader.IsDBNull(38) ? 0 : ToDouble(reader.GetValue(38)), + AvgTempdbMb = reader.IsDBNull(39) ? 0 : ToDouble(reader.GetValue(39)), + MinTempdbMb = reader.IsDBNull(40) ? 0 : ToDouble(reader.GetValue(40)), + MaxTempdbMb = reader.IsDBNull(41) ? 0 : ToDouble(reader.GetValue(41)), + PlanType = reader.IsDBNull(42) ? "" : reader.GetString(42), + IsForcedPlan = !reader.IsDBNull(43) && reader.GetBoolean(43), + ForceFailureCount = reader.IsDBNull(44) ? 0 : reader.GetInt64(44), + LastForceFailureReason = reader.IsDBNull(45) ? "" : reader.GetString(45), + PlanForcingType = reader.IsDBNull(46) ? "" : reader.GetString(46), + CompatibilityLevel = reader.IsDBNull(47) ? 0 : reader.GetInt32(47), + QueryHash = reader.IsDBNull(48) ? "" : reader.GetString(48), + QueryPlanHash = reader.IsDBNull(49) ? "" : reader.GetString(49) }); } return items; } - /// <summary> - /// Gets distinct databases that have Query Store data collected. - /// </summary> - public async Task<List<string>> GetQueryStoreDatabasesAsync(int serverId, int hoursBack = 24) - { - using var connection = await OpenConnectionAsync(); - using var command = connection.CreateCommand(); - command.CommandText = @" -SELECT DISTINCT database_name -FROM v_query_store_stats -WHERE server_id = $1 -AND collection_time >= $2 -ORDER BY database_name"; - - command.Parameters.Add(new DuckDBParameter { Value = serverId }); - command.Parameters.Add(new DuckDBParameter { Value = DateTime.UtcNow.AddHours(-hoursBack) }); - - var items = new List<string>(); - using var reader = await command.ExecuteReaderAsync(); - while (await reader.ReadAsync()) - { - items.Add(reader.GetString(0)); - } - return items; - } - /// <summary> /// Fetches a query plan on-demand from Query Store by plan_id. /// Uses three-part naming with sp_executesql for Azure SQL DB compatibility. @@ -453,12 +596,13 @@ AND qsp.query_plan IS NOT NULL /// <summary> /// Gets Query Store duration trend — SUM(execution_count * avg_duration) per collection snapshot. /// </summary> - public async Task<List<QueryTrendPoint>> GetQueryStoreDurationTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task<List<QueryTrendPoint>> GetQueryStoreDurationTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList<string>? databaseNames = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); command.CommandText = @" WITH raw AS @@ -467,23 +611,25 @@ WITH raw AS collection_time, SUM(execution_count * avg_duration_us / 1000.0) AS total_duration_ms, SUM(execution_count) AS total_executions, - date_diff('second', LAG(collection_time) OVER (ORDER BY collection_time), collection_time) AS interval_seconds + extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (ORDER BY collection_time)))) AS interval_seconds FROM v_query_store_stats WHERE server_id = $1 AND collection_time >= $2 - AND collection_time <= $3 + AND collection_time <= $3" + dbClause + @" GROUP BY collection_time ) SELECT collection_time, CASE WHEN interval_seconds > 0 THEN total_duration_ms / interval_seconds ELSE 0 END AS duration_ms_per_second, - CASE WHEN interval_seconds > 0 THEN CAST(total_executions AS DOUBLE) / interval_seconds ELSE 0 END AS executions_per_second + CASE WHEN interval_seconds > 0 THEN CAST(total_executions AS DOUBLE PRECISION) / interval_seconds ELSE 0 END AS executions_per_second FROM raw ORDER BY collection_time"; command.Parameters.Add(new DuckDBParameter { Value = serverId }); command.Parameters.Add(new DuckDBParameter { Value = startTime }); command.Parameters.Add(new DuckDBParameter { Value = endTime }); + foreach (var db in dbValues) + command.Parameters.Add(new DuckDBParameter { Value = db }); var items = new List<QueryTrendPoint>(); using var reader = await command.ExecuteReaderAsync(); @@ -502,9 +648,20 @@ FROM raw public class QueryStoreRow { + /// <summary>Gates "Get Actual Plan (re-run)" — see <see cref="QuerySnapshotRow.CanGetActualPlan"/>.</summary> + public bool CanGetActualPlan => !string.IsNullOrEmpty(QueryText); + public string DatabaseName { get; set; } = ""; public long QueryId { get; set; } public long PlanId { get; set; } + + /// <summary> + /// The replica role SQL Server 2022+ attributed these runtime stats to ('Primary', 'Secondary', + /// 'Geo Secondary', 'Geo HA Secondary'), a GROUP BY key so an AG's shared Query Store does not + /// blend replicas into one row. NULL/blank = the server did not attribute it (pre-2022, or a 2022 + /// standalone) — expected, and deliberately not coalesced. + /// </summary> + public string? ReplicaRole { get; set; } public string QueryHash { get; set; } = ""; public string QueryText { get; set; } = ""; public string ModuleName { get; set; } = ""; @@ -531,6 +688,33 @@ public class QueryStoreRow public long ForceFailureCount { get; set; } public string LastForceFailureReason { get; set; } = ""; public int CompatibilityLevel { get; set; } + // Min/max variants + memory-grant + num-physical-io-reads families, for parity with the Dashboard QS grid. + // Duration/CPU/CLR are in ms (converted from us in SQL); reads/writes/rows/tempdb pages are raw; + // log bytes are raw bytes; memory grant is converted pages->MB in SQL. + public double MinDurationMs { get; set; } + public double MaxDurationMs { get; set; } + public double MinCpuTimeMs { get; set; } + public double MaxCpuTimeMs { get; set; } + public double MinLogicalReads { get; set; } + public double MaxLogicalReads { get; set; } + public double MinLogicalWrites { get; set; } + public double MaxLogicalWrites { get; set; } + public double MinPhysicalReads { get; set; } + public double MaxPhysicalReads { get; set; } + public double MinClrTimeMs { get; set; } + public double MaxClrTimeMs { get; set; } + public double MinRowcount { get; set; } + public double MaxRowcount { get; set; } + public double MinLogBytesUsed { get; set; } + public double MaxLogBytesUsed { get; set; } + public double MinTempdbSpaceUsed { get; set; } + public double MaxTempdbSpaceUsed { get; set; } + public double AvgMemoryMb { get; set; } + public double MinMemoryMb { get; set; } + public double MaxMemoryMb { get; set; } + public double AvgNumPhysicalIoReads { get; set; } + public double MinNumPhysicalIoReads { get; set; } + public double MaxNumPhysicalIoReads { get; set; } public bool HasQueryPlan => !string.IsNullOrEmpty(QueryPlanText); public string FirstExecutionTimeLocal => ServerTimeHelper.FormatServerTime(FirstExecutionTime); public string LastExecutionTimeLocal => ServerTimeHelper.FormatServerTime(LastExecutionTime); @@ -551,8 +735,64 @@ public class QueryStoreHistoryRow public DateTime? LastExecutionTime { get; set; } public long MinDop { get; set; } public long MaxDop { get; set; } + + // Execution type (Regular / Aborted / Exception) + public string ExecutionTypeDesc { get; set; } = ""; + + // First execution time in the interval + public DateTime? FirstExecutionTime { get; set; } + + // Module (proc/function) the query came from + public string ModuleName { get; set; } = ""; + + // Number of physical IO reads (avg/min/max) + public double AvgNumPhysicalReads { get; set; } + public double MinNumPhysicalReads { get; set; } + public double MaxNumPhysicalReads { get; set; } + + // CLR time, already converted to ms in SQL (avg/min/max) + public double AvgClrTimeMs { get; set; } + public double MinClrTimeMs { get; set; } + public double MaxClrTimeMs { get; set; } + + // Log bytes used, already converted to MB in SQL (avg/min/max) + public double AvgLogMb { get; set; } + public double MinLogMb { get; set; } + public double MaxLogMb { get; set; } + + // Per-plan identity + min/max spreads + memory/tempdb + plan-forcing metadata, + // for parity with the Dashboard Query Store drilldown (QueryExecutionHistoryWindow). + public long PlanId { get; set; } + public double MinDurationMs { get; set; } + public double MaxDurationMs { get; set; } + public double MinCpuTimeMs { get; set; } + public double MaxCpuTimeMs { get; set; } + public double MinLogicalReads { get; set; } + public double MaxLogicalReads { get; set; } + public double MinLogicalWrites { get; set; } + public double MaxLogicalWrites { get; set; } + public double MinPhysicalReads { get; set; } + public double MaxPhysicalReads { get; set; } + public double MinRowcount { get; set; } + public double MaxRowcount { get; set; } + public double AvgMemoryMb { get; set; } + public double MinMemoryMb { get; set; } + public double MaxMemoryMb { get; set; } + public double AvgTempdbMb { get; set; } + public double MinTempdbMb { get; set; } + public double MaxTempdbMb { get; set; } + public string PlanType { get; set; } = ""; + public bool IsForcedPlan { get; set; } + public long ForceFailureCount { get; set; } + public string LastForceFailureReason { get; set; } = ""; + public string PlanForcingType { get; set; } = ""; + public int CompatibilityLevel { get; set; } + public string QueryHash { get; set; } = ""; + public string QueryPlanHash { get; set; } = ""; + public double TotalDurationMs => ExecutionCount * AvgDurationMs; public double TotalCpuMs => ExecutionCount * AvgCpuTimeMs; public string CollectionTimeLocal => ServerTimeHelper.FormatServerTime(CollectionTime); + public string FirstExecutionTimeLocal => ServerTimeHelper.FormatServerTime(FirstExecutionTime); public string LastExecutionTimeLocal => ServerTimeHelper.FormatServerTime(LastExecutionTime); } diff --git a/Lite/Services/LocalDataService.RunningJobs.cs b/Lite/Services/LocalDataService.RunningJobs.cs index db5fdd72b..2bedd6a93 100644 --- a/Lite/Services/LocalDataService.RunningJobs.cs +++ b/Lite/Services/LocalDataService.RunningJobs.cs @@ -121,30 +121,8 @@ ORDER BY percent_of_average DESC } } -public class FailedJobInfo -{ - public string JobName { get; set; } = ""; - public string JobId { get; set; } = ""; - - /// <summary>Server-local time the failed run started (from run_date/run_time).</summary> - public DateTime RunDateTime { get; set; } - public int StepId { get; set; } - public string StepName { get; set; } = ""; - public string Message { get; set; } = ""; - - public string RunDateTimeFormatted => RunDateTime.ToString("yyyy-MM-dd HH:mm:ss"); -} - -public class AnomalousJobInfo -{ - public string JobName { get; set; } = ""; - public string JobId { get; set; } = ""; - public long CurrentDurationSeconds { get; set; } - public long AvgDurationSeconds { get; set; } - public long P95DurationSeconds { get; set; } - public decimal? PercentOfAverage { get; set; } - public DateTime StartTime { get; set; } -} +/* FailedJobInfo and AnomalousJobInfo moved to PerformanceMonitor.Alerting (Phase-5 A0); + the bare names resolve through the global using aliases in GlobalUsings.cs. */ public class RunningJobRow { diff --git a/Lite/Services/LocalDataService.SessionStats.cs b/Lite/Services/LocalDataService.SessionStats.cs new file mode 100644 index 000000000..c14e2ff00 --- /dev/null +++ b/Lite/Services/LocalDataService.SessionStats.cs @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using PerformanceMonitor.Common; + +namespace PerformanceMonitorLite.Services; + +public partial class LocalDataService +{ + /* The Session Stats reader — the Lite (DuckDB) port of the Darling viewer's + ViewerDataService.SessionStats read. Reads the server-wide session_summary_stats table (view + v_session_summary_stats): one aggregate row per collection carrying the status-count breakdown + that drives the chart's seven series plus the attribution columns (top application / top host / + distinct databases) the summary strip shows. This is the SUMMARY collector (1:1 with the + Dashboard's session_stats table) — deliberately NOT the per-application session_stats table + (v_session_stats) FinOps' Application Connections read uses; the names are distinct so the two + never collide. collection_time is UTC (GetTimeRange). */ + + /// <summary> + /// The Session Stats trend: every server-wide session-summary snapshot in the window, oldest-first, + /// so the chart plots the seven status counts over time and the summary strip reads the latest + /// point's attribution columns. + /// </summary> + public async Task<List<SessionStatsPoint>> GetSessionStatsAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + { + using var _q = TimeQuery("GetSessionStatsAsync", "v_session_summary_stats status-count trend"); + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + + command.CommandText = @" +SELECT + collection_time, + total_sessions, + running_sessions, + sleeping_sessions, + background_sessions, + dormant_sessions, + idle_sessions_over_30min, + sessions_waiting_for_memory, + databases_with_connections, + top_application_name, + top_application_connections, + top_host_name, + top_host_connections +FROM v_session_summary_stats +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3 +ORDER BY collection_time"; + + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = startTime }); + command.Parameters.Add(new DuckDBParameter { Value = endTime }); + + var items = new List<SessionStatsPoint>(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new SessionStatsPoint + { + CollectionTime = reader.GetDateTime(0), + TotalSessions = reader.IsDBNull(1) ? 0 : reader.GetInt32(1), + RunningSessions = reader.IsDBNull(2) ? 0 : reader.GetInt32(2), + SleepingSessions = reader.IsDBNull(3) ? 0 : reader.GetInt32(3), + BackgroundSessions = reader.IsDBNull(4) ? 0 : reader.GetInt32(4), + DormantSessions = reader.IsDBNull(5) ? 0 : reader.GetInt32(5), + IdleSessionsOver30Min = reader.IsDBNull(6) ? 0 : reader.GetInt32(6), + SessionsWaitingForMemory = reader.IsDBNull(7) ? 0 : reader.GetInt32(7), + DatabasesWithConnections = reader.IsDBNull(8) ? 0 : reader.GetInt32(8), + TopApplicationName = reader.IsDBNull(9) ? null : reader.GetString(9), + TopApplicationConnections = reader.IsDBNull(10) ? null : reader.GetInt32(10), + TopHostName = reader.IsDBNull(11) ? null : reader.GetString(11), + TopHostConnections = reader.IsDBNull(12) ? null : reader.GetInt32(12) + }); + } + + return items; + } +} + +/// <summary>One point on the Session Stats trend: a single server-wide session-summary snapshot — the +/// status-count breakdown that drives the chart's seven series plus the attribution columns (top +/// application / top host / distinct databases) the summary panel shows.</summary> +public class SessionStatsPoint : ISessionStatsPoint +{ + public DateTime CollectionTime { get; set; } + public int TotalSessions { get; set; } + public int RunningSessions { get; set; } + public int SleepingSessions { get; set; } + public int BackgroundSessions { get; set; } + public int DormantSessions { get; set; } + public int IdleSessionsOver30Min { get; set; } + public int SessionsWaitingForMemory { get; set; } + public int DatabasesWithConnections { get; set; } + public string? TopApplicationName { get; set; } + public int? TopApplicationConnections { get; set; } + public string? TopHostName { get; set; } + public int? TopHostConnections { get; set; } +} diff --git a/Lite/Services/LocalDataService.SystemEvents.cs b/Lite/Services/LocalDataService.SystemEvents.cs new file mode 100644 index 000000000..915aff743 --- /dev/null +++ b/Lite/Services/LocalDataService.SystemEvents.cs @@ -0,0 +1,668 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using PerformanceMonitor.Common; + +namespace PerformanceMonitorLite.Services; + +/* + * The System Events tab reads (system_health parity) — the Lite (DuckDB) port of the Darling viewer's + * ViewerDataService.SystemEvents reads. Each warning category is a parse-on-read shred: the reader fetches + * the raw event_xml for one XE event type over the tab window from v_system_health_events (the archive view + * UNIONing hot + parquet, so the window includes archived rows), hands each blob to the Common + * SystemHealthParser to shred into the category record, then keeps only the SIGNIFICANT rows via the shared + * SystemHealthSignificance (sp_HealthParser's per-category WHERE predicates). Three categories (Memory + * Conditions, CPU Tasks, I/O Issues) share the single sp_server_diagnostics feed and split on the event's + * component. No persisted parsed tables — the raw table + the shared parser are the whole pipeline. + * + * The XML shred is CPU-bound; the code-behind calls these methods via Task.Run (mirroring the deadlock-graph + * parse ParseDeadlocksOffUiThreadAsync), so the shred runs off the UI thread. The predicates and Default + * Trace classifier live ONCE in PerformanceMonitor.Common (SystemHealthSignificance / + * DefaultTraceEventSignificance) and are called directly here — Lite and Darling share the one source, so + * the significant set can never drift between them. + * + * Time frames (load-bearing): the system_health event_time is the XE @timestamp, which is naive-UTC — those + * reads window on the UTC bounds from GetTimeRange, and the row VMs render event_time through + * ServerTimeHelper.FormatServerTime (naive-UTC -> display) exactly like the deadlock / blocked-process grids. + * The Default Trace event_time is the monitored server's LOCAL StartTime (ft.StartTime, stored raw) — that + * read windows on the server-LOCAL bounds from GetTimeRangeServerLocal and de-skews each row's server-local + * event_time to naive-UTC (subtract the connected server's UtcOffsetMinutes) BEFORE the row VM, so a Default + * Trace row and a system_health row from the same instant render the same wall-clock time and a merged + * System Events timeline sorts consistently. + */ + +/// <summary>Shared render of a naive-UTC event timestamp for the System Events grids — the same +/// ServerTimeHelper.FormatServerTime the deadlock / blocked-process grids use (empty for a null time).</summary> +internal static class SystemEventRowFormat +{ + public static string Local(DateTime? utc) => ServerTimeHelper.FormatServerTime(utc, "yyyy-MM-dd HH:mm:ss"); +} + +/// <summary>One scheduler-monitor WARNING row (Scheduler Issues sub-tab). Mirrors sp_HealthParser's <c>*_SchedulerIssues</c>.</summary> +public sealed class SchedulerIssueRow(SchedulerIssueRecord record) +{ + public string EventTimeLocal => SystemEventRowFormat.Local(record.EventTime); + /// <summary>Raw naive-UTC event time (the XE @timestamp) for the MCP layer's ISO output.</summary> + public DateTime? EventTime => record.EventTime; + public int? SchedulerId => record.SchedulerId; + public int? CpuId => record.CpuId; + public string? Status => record.Status; + public bool? IsOnline => record.IsOnline; + public bool? IsRunnable => record.IsRunnable; + public bool? IsRunning => record.IsRunning; + public long? NonYieldingTimeMs => record.NonYieldingTimeMs; + public long? ThreadQuantumMs => record.ThreadQuantumMs; +} + +/// <summary> +/// One severe-error row (Severe Errors sub-tab). Mirrors sp_HealthParser's <c>*_SevereErrors</c>. +/// <see cref="DatabaseName"/> is resolved from the store's collected database_id -> database_name mapping +/// (see <see cref="LocalDataService.ResolveDatabaseName"/>) — the pure shred left it null. +/// </summary> +public sealed class SevereErrorRow(SevereErrorRecord record, string databaseName) +{ + public string EventTimeLocal => SystemEventRowFormat.Local(record.EventTime); + /// <summary>Raw naive-UTC event time (the XE @timestamp) for the MCP layer's ISO output.</summary> + public DateTime? EventTime => record.EventTime; + public int? ErrorNumber => record.ErrorNumber; + public int? Severity => record.Severity; + public int? State => record.State; + public int? DatabaseId => record.DatabaseId; + public string DatabaseName => databaseName; + public string? Message => record.Message; +} + +/// <summary>One RESOURCE_MEMPHYSICAL_LOW memory-conditions snapshot (Memory Conditions sub-tab). Mirrors <c>*_MemoryConditions</c>.</summary> +public sealed class MemoryConditionsRow(MemoryConditionsRecord record) +{ + public string EventTimeLocal => SystemEventRowFormat.Local(record.EventTime); + /// <summary>Raw naive-UTC event time (the XE @timestamp) for the MCP layer's ISO output.</summary> + public DateTime? EventTime => record.EventTime; + public string? LastNotification => record.LastNotification; + public long? OutOfMemoryExceptions => record.OutOfMemoryExceptions; + public bool? IsAnyPoolOutOfMemory => record.IsAnyPoolOutOfMemory; + public long? ProcessOutOfMemoryPeriod => record.ProcessOutOfMemoryPeriod; + public string? Name => record.Name; + public long? AvailablePhysicalMemoryGb => record.AvailablePhysicalMemoryGb; + public long? AvailableVirtualMemoryGb => record.AvailableVirtualMemoryGb; + public long? AvailablePagingFileGb => record.AvailablePagingFileGb; + public long? WorkingSetGb => record.WorkingSetGb; + public long? PercentOfCommittedMemoryInWs => record.PercentOfCommittedMemoryInWs; + public long? PageFaults => record.PageFaults; + public long? SystemPhysicalMemoryHigh => record.SystemPhysicalMemoryHigh; + public long? SystemPhysicalMemoryLow => record.SystemPhysicalMemoryLow; + public long? ProcessPhysicalMemoryLow => record.ProcessPhysicalMemoryLow; + public long? ProcessVirtualMemoryLow => record.ProcessVirtualMemoryLow; + public long? VmReservedGb => record.VmReservedGb; + public long? VmCommittedGb => record.VmCommittedGb; + public long? LockedPagesAllocated => record.LockedPagesAllocated; + public long? LargePagesAllocated => record.LargePagesAllocated; + public long? EmergencyMemoryGb => record.EmergencyMemoryGb; + public long? EmergencyMemoryInUseGb => record.EmergencyMemoryInUseGb; + public long? TargetCommittedGb => record.TargetCommittedGb; + public long? CurrentCommittedGb => record.CurrentCommittedGb; + public long? PagesAllocated => record.PagesAllocated; + public long? PagesReserved => record.PagesReserved; + public long? PagesFree => record.PagesFree; + public long? PagesInUse => record.PagesInUse; + public long? PageAllocPotential => record.PageAllocPotential; + public long? NumaGrowthPhase => record.NumaGrowthPhase; + public long? LastOomFactor => record.LastOomFactor; + public long? LastOsError => record.LastOsError; +} + +/// <summary>One RESOURCE_MEMPHYSICAL_LOW memory-broker ratio change (Memory Broker sub-tab). Mirrors <c>*_MemoryBroker</c>.</summary> +public sealed class MemoryBrokerRow(MemoryBrokerRecord record) +{ + public string EventTimeLocal => SystemEventRowFormat.Local(record.EventTime); + /// <summary>Raw naive-UTC event time (the XE @timestamp) for the MCP layer's ISO output.</summary> + public DateTime? EventTime => record.EventTime; + public long? BrokerId => record.BrokerId; + public long? PoolMetadataId => record.PoolMetadataId; + public long? DeltaTime => record.DeltaTime; + public long? MemoryRatio => record.MemoryRatio; + public long? NewTarget => record.NewTarget; + public long? Overall => record.Overall; + public long? Rate => record.Rate; + public long? CurrentlyPredicated => record.CurrentlyPredicated; + public long? CurrentlyAllocated => record.CurrentlyAllocated; + public long? PreviouslyAllocated => record.PreviouslyAllocated; + public string? Broker => record.Broker; + public string? Notification => record.Notification; +} + +/// <summary>One memory-node OOM row (Memory Node OOM sub-tab). Mirrors <c>*_MemoryNodeOOM</c> (never gated — every OOM shows).</summary> +public sealed class MemoryNodeOomRow(MemoryNodeOomRecord record) +{ + public string EventTimeLocal => SystemEventRowFormat.Local(record.EventTime); + /// <summary>Raw naive-UTC event time (the XE @timestamp) for the MCP layer's ISO output.</summary> + public DateTime? EventTime => record.EventTime; + public long? NodeId => record.NodeId; + public long? MemoryNodeId => record.MemoryNodeId; + public long? MemoryUtilizationPct => record.MemoryUtilizationPct; + public long? TotalPhysicalMemoryKb => record.TotalPhysicalMemoryKb; + public long? AvailablePhysicalMemoryKb => record.AvailablePhysicalMemoryKb; + public long? TotalPageFileKb => record.TotalPageFileKb; + public long? AvailablePageFileKb => record.AvailablePageFileKb; + public long? TotalVirtualAddressSpaceKb => record.TotalVirtualAddressSpaceKb; + public long? AvailableVirtualAddressSpaceKb => record.AvailableVirtualAddressSpaceKb; + public long? TargetKb => record.TargetKb; + public long? ReservedKb => record.ReservedKb; + public long? CommittedKb => record.CommittedKb; + public decimal? SharedCommittedKb => record.SharedCommittedKb; + public long? AweKb => record.AweKb; + public long? PagesKb => record.PagesKb; + public string? FailureType => record.FailureType; + public long? FailureValue => record.FailureValue; + public long? Resources => record.Resources; + public string? FactorText => record.FactorText; + public long? FactorValue => record.FactorValue; + public long? LastError => record.LastError; + public long? PoolMetadataId => record.PoolMetadataId; + public string? IsProcessInJob => record.IsProcessInJob; + public string? IsSystemPhysicalMemoryHigh => record.IsSystemPhysicalMemoryHigh; + public string? IsSystemPhysicalMemoryLow => record.IsSystemPhysicalMemoryLow; + public string? IsProcessPhysicalMemoryLow => record.IsProcessPhysicalMemoryLow; + public string? IsProcessVirtualMemoryLow => record.IsProcessVirtualMemoryLow; +} + +/// <summary>One significant-wait row (Significant Waits sub-tab). Mirrors <c>*_SignificantWaits</c>.</summary> +public sealed class SignificantWaitRow(SignificantWaitRecord record) +{ + public string EventTimeLocal => SystemEventRowFormat.Local(record.EventTime); + public string? WaitType => record.WaitType; + public long? DurationMs => record.DurationMs; + public long? SignalDurationMs => record.SignalDurationMs; + public string? WaitResource => record.WaitResource; + public int? SessionId => record.SessionId; + public string? QueryText => record.QueryText; +} + +/// <summary>One CPU-task-details row (CPU Tasks sub-tab). Mirrors <c>*_CPUTasks</c> (QUERY_PROCESSING component).</summary> +public sealed class CpuTasksRow(CpuTasksRecord record) +{ + public string EventTimeLocal => SystemEventRowFormat.Local(record.EventTime); + /// <summary>Raw naive-UTC event time (the XE @timestamp) for the MCP layer's ISO output.</summary> + public DateTime? EventTime => record.EventTime; + public string? State => record.State; + public long? MaxWorkers => record.MaxWorkers; + public long? WorkersCreated => record.WorkersCreated; + public long? WorkersIdle => record.WorkersIdle; + public long? TasksCompletedWithinInterval => record.TasksCompletedWithinInterval; + public long? PendingTasks => record.PendingTasks; + public long? OldestPendingTaskWaitingTime => record.OldestPendingTaskWaitingTime; + public bool? HasUnresolvableDeadlockOccurred => record.HasUnresolvableDeadlockOccurred; + public bool? HasDeadlockedSchedulersOccurred => record.HasDeadlockedSchedulersOccurred; + public bool? DidBlockingOccur => record.DidBlockingOccur; +} + +/// <summary>One potential-IO-issue row (I/O Issues sub-tab). Mirrors <c>*_IOIssues</c> (IO_SUBSYSTEM component).</summary> +public sealed class IoIssuesRow(IoIssuesRecord record) +{ + public string EventTimeLocal => SystemEventRowFormat.Local(record.EventTime); + /// <summary>Raw naive-UTC event time (the XE @timestamp) for the MCP layer's ISO output.</summary> + public DateTime? EventTime => record.EventTime; + public string? State => record.State; + public long? IoLatchTimeouts => record.IoLatchTimeouts; + public long? IntervalLongIos => record.IntervalLongIos; + public long? TotalLongIos => record.TotalLongIos; + public long? LongestPendingRequestsDurationMs => record.LongestPendingRequestsDurationMs; + public string? LongestPendingRequestsFilePath => record.LongestPendingRequestsFilePath; +} + +/// <summary> +/// One significant Default Trace event (the Default Trace sub-tab) — the always-on server events no DMV +/// captures (file auto-grow/shrink stalls, severe ErrorLog writes, schema DDL, security audits, Server +/// Memory Change), classified via the shared <see cref="DefaultTraceEventSignificance.Classify"/>. Unlike +/// the system_health rows (whose event_time is the UTC XE @timestamp), the Default Trace StartTime is the +/// monitored server's LOCAL wall clock, so <see cref="LocalDataService.GetDefaultTraceEventsAsync"/> de-skews +/// it to naive-UTC BEFORE this row, so <see cref="EventTimeLocal"/> renders through the same +/// <see cref="SystemEventRowFormat.Local"/> as every other System Events grid and sorts consistently. +/// </summary> +public sealed class DefaultTraceEventRow +{ + public DefaultTraceEventRow( + DateTime? eventTimeUtc, + DefaultTraceEventCategory category, + string? eventName, + string? databaseName, + string? objectName, + string? loginName, + string? hostName, + string? applicationName, + int? spid, + long? durationUs, + long? integerData, + int? severity, + int? errorNumber, + string? textData) + { + EventTimeUtc = eventTimeUtc; + EventTimeLocal = SystemEventRowFormat.Local(eventTimeUtc); + Category = category.ToString(); + EventName = eventName; + DatabaseName = databaseName; + ObjectName = objectName; + LoginName = loginName; + HostName = hostName; + ApplicationName = applicationName; + Spid = spid; + /* Duration (ms) is meaningful for the auto-grow/shrink stalls; growth (MB) is the 8-KB page count on + those (IntegerData) converted, and only meaningful for that category. */ + DurationMs = durationUs.HasValue ? durationUs.Value / 1000.0m : (decimal?)null; + GrowthMb = category == DefaultTraceEventCategory.AutoGrowShrink && integerData.HasValue + ? integerData.Value * 8.0m / 1024.0m + : (decimal?)null; + Severity = severity; + ErrorNumber = errorNumber; + TextData = textData; + } + + /// <summary>Raw naive-UTC event time (de-skewed server-local StartTime) for the MCP layer's ISO output.</summary> + public DateTime? EventTimeUtc { get; } + public string EventTimeLocal { get; } + public string Category { get; } + public string? EventName { get; } + public string? DatabaseName { get; } + public string? ObjectName { get; } + public string? LoginName { get; } + public string? HostName { get; } + public string? ApplicationName { get; } + public int? Spid { get; } + public decimal? DurationMs { get; } + public decimal? GrowthMb { get; } + public int? Severity { get; } + public int? ErrorNumber { get; } + public string? TextData { get; } +} + +public partial class LocalDataService +{ + /// <summary> + /// Raw event_xml for one XE event type over the tab window, newest first, from the v_system_health_events + /// archive view (hot UNION parquet). The System Events reads window on <c>event_time</c> (the XE + /// <c>@timestamp</c> — the event's real time, which for the ring-buffer categories can lag when it was + /// collected), not <c>collection_time</c>, so "last 24 hours" means events that happened in the last 24 + /// hours. The XE @timestamp is naive-UTC, so the UTC bounds from GetTimeRange line up. + /// </summary> + private async Task<List<string>> ReadSystemHealthEventXmlAsync( + int serverId, DateTime startUtc, DateTime endUtc, string eventType) + { + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + + command.CommandText = @" +SELECT + event_xml +FROM v_system_health_events +WHERE server_id = $1 +AND event_time >= $2 +AND event_time <= $3 +AND event_type = $4 +AND event_xml IS NOT NULL +ORDER BY event_time DESC"; + + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = startUtc }); + command.Parameters.Add(new DuckDBParameter { Value = endUtc }); + command.Parameters.Add(new DuckDBParameter { Value = eventType }); + + var xmls = new List<string>(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + xmls.Add(reader.GetString(0)); + + return xmls; + } + + // ── Scheduler Issues ── + + /// <summary>Significant scheduler-monitor warnings (WARNING status) for the window, newest first.</summary> + public async Task<List<SchedulerIssueRow>> GetSchedulerIssuesAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + { + using var _q = TimeQuery("GetSchedulerIssuesAsync", "v_system_health_events scheduler_monitor shred"); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var xmls = await ReadSystemHealthEventXmlAsync(serverId, startTime, endTime, SystemHealthParser.SchedulerMonitorEvent); + + var rows = new List<SchedulerIssueRow>(); + foreach (var xml in xmls) + { + var record = SystemHealthParser.ParseSchedulerIssue(xml); + if (record != null && SystemHealthSignificance.IsSignificant(record)) + rows.Add(new SchedulerIssueRow(record)); + } + return rows; + } + + // ── Severe Errors ── + + /// <summary> + /// Significant severe errors (severity >= 19, excluding the benign connection-reset numbers) for the + /// window, newest first, with database_id resolved to a name from the collected mapping. #1319: severe + /// errors have no database_name column (the DB is resolved in C# from the event's database_id via the + /// collected id -> name map), so the global database filter is applied client-side on the resolved name. + /// </summary> + public async Task<List<SevereErrorRow>> GetSevereErrorsAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList<string>? databaseNames = null) + { + using var _q = TimeQuery("GetSevereErrorsAsync", "v_system_health_events error_reported shred"); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var map = await GetDatabaseNameMapAsync(serverId); + var xmls = await ReadSystemHealthEventXmlAsync(serverId, startTime, endTime, SystemHealthParser.ErrorReportedEvent); + + var filter = databaseNames is { Count: > 0 } ? new HashSet<string>(databaseNames, StringComparer.OrdinalIgnoreCase) : null; + + var rows = new List<SevereErrorRow>(); + foreach (var xml in xmls) + { + var record = SystemHealthParser.ParseSevereError(xml); + if (record != null && SystemHealthSignificance.IsSignificant(record)) + { + var databaseName = ResolveDatabaseName(record.DatabaseId, map); + if (filter == null || filter.Contains(databaseName)) + rows.Add(new SevereErrorRow(record, databaseName)); + } + } + return rows; + } + + // ── Memory Conditions ── + + /// <summary>Significant memory-conditions snapshots (RESOURCE_MEMPHYSICAL_LOW) for the window, newest first.</summary> + public async Task<List<MemoryConditionsRow>> GetMemoryConditionsAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + { + using var _q = TimeQuery("GetMemoryConditionsAsync", "v_system_health_events sp_server_diagnostics RESOURCE shred"); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var xmls = await ReadSystemHealthEventXmlAsync(serverId, startTime, endTime, SystemHealthParser.SpServerDiagnosticsEvent); + + var rows = new List<MemoryConditionsRow>(); + foreach (var xml in xmls) + { + // Only the RESOURCE component yields a record; other sp_server_diagnostics components parse to null. + var record = SystemHealthParser.ParseMemoryConditions(xml); + if (record != null && SystemHealthSignificance.IsSignificant(record)) + rows.Add(new MemoryConditionsRow(record)); + } + return rows; + } + + // ── Memory Broker ── + + /// <summary>Significant memory-broker ratio changes (RESOURCE_MEMPHYSICAL_LOW) for the window, newest first.</summary> + public async Task<List<MemoryBrokerRow>> GetMemoryBrokerAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + { + using var _q = TimeQuery("GetMemoryBrokerAsync", "v_system_health_events memory_broker shred"); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var xmls = await ReadSystemHealthEventXmlAsync(serverId, startTime, endTime, SystemHealthParser.MemoryBrokerEvent); + + var rows = new List<MemoryBrokerRow>(); + foreach (var xml in xmls) + { + var record = SystemHealthParser.ParseMemoryBroker(xml); + if (record != null && SystemHealthSignificance.IsSignificant(record)) + rows.Add(new MemoryBrokerRow(record)); + } + return rows; + } + + // ── Memory Node OOM ── + + /// <summary>Every memory-node OOM for the window, newest first (this category is never filtered).</summary> + public async Task<List<MemoryNodeOomRow>> GetMemoryNodeOomAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + { + using var _q = TimeQuery("GetMemoryNodeOomAsync", "v_system_health_events memory_node_oom shred"); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var xmls = await ReadSystemHealthEventXmlAsync(serverId, startTime, endTime, SystemHealthParser.MemoryNodeOomEvent); + + var rows = new List<MemoryNodeOomRow>(); + foreach (var xml in xmls) + { + var record = SystemHealthParser.ParseMemoryNodeOom(xml); + if (record != null && SystemHealthSignificance.IsSignificant(record)) + rows.Add(new MemoryNodeOomRow(record)); + } + return rows; + } + + // ── Significant Waits ── + + /// <summary> + /// Significant individual waits (real session, non-BACKUP statement, duration >= 500 ms, wait type not + /// on the idle-wait ignore list) for the window, newest first. + /// </summary> + public async Task<List<SignificantWaitRow>> GetSignificantWaitsAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + { + using var _q = TimeQuery("GetSignificantWaitsAsync", "v_system_health_events wait_info shred"); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var xmls = await ReadSystemHealthEventXmlAsync(serverId, startTime, endTime, SystemHealthParser.WaitInfoEvent); + + var rows = new List<SignificantWaitRow>(); + foreach (var xml in xmls) + { + var record = SystemHealthParser.ParseSignificantWait(xml); + if (record != null && SystemHealthSignificance.IsSignificant(record)) + rows.Add(new SignificantWaitRow(record)); + } + return rows; + } + + // ── CPU Tasks ── + + /// <summary> + /// Significant CPU-task warnings (QUERY_PROCESSING state WARNING with pendingTasks >= 10) for the + /// window, newest first. Reads the same sp_server_diagnostics feed as Memory Conditions / I/O Issues; + /// only the QUERY_PROCESSING component yields a record. + /// </summary> + public async Task<List<CpuTasksRow>> GetCpuTasksAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + { + using var _q = TimeQuery("GetCpuTasksAsync", "v_system_health_events sp_server_diagnostics QUERY_PROCESSING shred"); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var xmls = await ReadSystemHealthEventXmlAsync(serverId, startTime, endTime, SystemHealthParser.SpServerDiagnosticsEvent); + + var rows = new List<CpuTasksRow>(); + foreach (var xml in xmls) + { + // Only the QUERY_PROCESSING component yields a record; other sp_server_diagnostics components parse to null. + var record = SystemHealthParser.ParseCpuTasks(xml); + if (record != null && SystemHealthSignificance.IsSignificant(record)) + rows.Add(new CpuTasksRow(record)); + } + return rows; + } + + // ── I/O Issues ── + + /// <summary> + /// Significant potential-IO issues (IO_SUBSYSTEM state WARNING) for the window, newest first. Each event + /// can carry several pending-request files, so the shred fans out to one row per file (durations summed); + /// only the IO_SUBSYSTEM component yields records. + /// </summary> + public async Task<List<IoIssuesRow>> GetIoIssuesAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + { + using var _q = TimeQuery("GetIoIssuesAsync", "v_system_health_events sp_server_diagnostics IO_SUBSYSTEM shred"); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var xmls = await ReadSystemHealthEventXmlAsync(serverId, startTime, endTime, SystemHealthParser.SpServerDiagnosticsEvent); + + var rows = new List<IoIssuesRow>(); + foreach (var xml in xmls) + { + // A non-IO_SUBSYSTEM component (or an event with no pending request) shreds to no records. + foreach (var record in SystemHealthParser.ParseIoIssues(xml)) + { + if (SystemHealthSignificance.IsSignificant(record)) + rows.Add(new IoIssuesRow(record)); + } + } + return rows; + } + + // ── System Health (Corruption + Contention counter charts) ── + + /// <summary> + /// Every SYSTEM-component health snapshot for the window, oldest first (time-series order for the charts). + /// Reads the same sp_server_diagnostics feed as Memory Conditions / CPU Tasks / I/O Issues; only the + /// SYSTEM component yields a record. Unlike the grid categories this applies NO significance filter — the + /// Corruption Events + Contention Events chart sub-tabs plot every collected snapshot's counters over + /// time, so this returns them all (records with no event time can't sit on a time axis and are dropped). + /// Both chart sub-tabs read this one list and select their own columns. + /// </summary> + public async Task<List<SystemHealthRecord>> GetSystemHealthAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + { + using var _q = TimeQuery("GetSystemHealthAsync", "v_system_health_events sp_server_diagnostics SYSTEM shred"); + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var xmls = await ReadSystemHealthEventXmlAsync(serverId, startTime, endTime, SystemHealthParser.SpServerDiagnosticsEvent); + + var records = new List<SystemHealthRecord>(); + foreach (var xml in xmls) + { + // Only the SYSTEM component yields a record; other sp_server_diagnostics components parse to null. + var record = SystemHealthParser.ParseSystemHealth(xml); + if (record != null && record.EventTime.HasValue) + records.Add(record); + } + records.Sort((a, b) => Nullable.Compare(a.EventTime, b.EventTime)); + return records; + } + + // ── Severe-Errors database name resolution ── + + /// <summary> + /// The server's latest database_id -> database_name mapping from the collected size-stats (the newest + /// name per id — handles a dropped-and-recreated id). DuckDB has no <c>DISTINCT ON</c>, so this uses + /// <c>QUALIFY ROW_NUMBER() OVER (PARTITION BY database_id ORDER BY collection_time DESC) = 1</c> — the + /// same idiom the archive views' dedup uses. database_size_stats is the mapping source because it is the + /// only collected table carrying BOTH database_id and database_name for every online DB. + /// </summary> + public async Task<Dictionary<int, string>> GetDatabaseNameMapAsync(int serverId) + { + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + + command.CommandText = @" +SELECT + database_id, + database_name +FROM v_database_size_stats +WHERE server_id = $1 +QUALIFY ROW_NUMBER() OVER (PARTITION BY database_id ORDER BY collection_time DESC) = 1"; + + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + + var map = new Dictionary<int, string>(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + if (reader.IsDBNull(0) || reader.IsDBNull(1)) + continue; + map[reader.GetInt32(0)] = reader.GetString(1); + } + return map; + } + + /// <summary> + /// Resolves a severe-error <c>database_id</c> to a display name using the collected mapping. A null or 0 + /// id means "no database context" (error_reported often carries database_id 0; <c>DB_NAME(0)</c> is NULL + /// server-side too) -> empty. A real id absent from the map (a database dropped before the latest + /// size-stats snapshot, or one never captured) is surfaced as its raw id rather than silently blanked. + /// </summary> + public static string ResolveDatabaseName(int? databaseId, IReadOnlyDictionary<int, string> databaseNameMap) + { + if (databaseId is not { } id || id == 0) + return string.Empty; + if (databaseNameMap.TryGetValue(id, out var name)) + return name; + return $"database_id {id}"; + } + + // ── Default Trace (always-on server events; the Default Trace sub-tab) ── + + /// <summary> + /// Significant Default Trace events for the window, newest first, from the v_default_trace_events archive + /// view. The Default Trace StartTime is the monitored server's LOCAL wall clock (ft.StartTime, stored + /// raw), so this windows on the server-LOCAL bounds from <see cref="GetTimeRangeServerLocal"/> and + /// de-skews each row's server-local event_time to naive-UTC (subtracting the connected server's + /// <see cref="ServerTimeHelper.UtcOffsetMinutes"/>) BEFORE the row VM, so the returned timestamps share + /// the same UTC frame as the system_health rows and render/sort consistently on the tab. #1319: the + /// global database filter is pushed into SQL on <c>database_name</c>. The ErrorLog severity gate is + /// applied on read via the shared <see cref="DefaultTraceEventSignificance"/>. + /// </summary> + public async Task<List<DefaultTraceEventRow>> GetDefaultTraceEventsAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList<string>? databaseNames = null) + { + using var _q = TimeQuery("GetDefaultTraceEventsAsync", "v_default_trace_events significant-set read"); + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + + /* Default Trace event_time is server-LOCAL, so window on server-local bounds (the same helper the + CPU/sample_time reads use). Bind db filter params immediately after the 3 fixed params ($4+). */ + var (startTime, endTime) = GetTimeRangeServerLocal(hoursBack, fromDate, toDate); + var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); + + command.CommandText = @" +SELECT + event_time, + event_name, + database_name, + object_name, + login_name, + host_name, + application_name, + spid, + duration_us, + integer_data, + severity, + error_number, + text_data +FROM v_default_trace_events +WHERE server_id = $1 +AND event_time >= $2 +AND event_time <= $3" + dbClause + @" +ORDER BY event_time DESC"; + + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = startTime }); + command.Parameters.Add(new DuckDBParameter { Value = endTime }); + foreach (var db in dbValues) + command.Parameters.Add(new DuckDBParameter { Value = db }); + + var offset = ServerTimeHelper.UtcOffsetMinutes; + + var rows = new List<DefaultTraceEventRow>(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + /* De-skew server-local StartTime -> naive-UTC so the row shares the system_health rows' UTC frame. */ + var eventTimeUtc = reader.IsDBNull(0) ? (DateTime?)null : reader.GetDateTime(0).AddMinutes(-offset); + var eventName = reader.IsDBNull(1) ? null : reader.GetString(1); + var severity = reader.IsDBNull(10) ? (int?)null : reader.GetInt32(10); + + if (!DefaultTraceEventSignificance.IsSignificant(eventName, severity)) + continue; + + rows.Add(new DefaultTraceEventRow( + eventTimeUtc, + DefaultTraceEventSignificance.Classify(eventName), + eventName, + reader.IsDBNull(2) ? null : reader.GetString(2), /* database_name */ + reader.IsDBNull(3) ? null : reader.GetString(3), /* object_name */ + reader.IsDBNull(4) ? null : reader.GetString(4), /* login_name */ + reader.IsDBNull(5) ? null : reader.GetString(5), /* host_name */ + reader.IsDBNull(6) ? null : reader.GetString(6), /* application_name */ + reader.IsDBNull(7) ? (int?)null : reader.GetInt32(7), /* spid */ + reader.IsDBNull(8) ? (long?)null : reader.GetInt64(8), /* duration_us */ + reader.IsDBNull(9) ? (long?)null : reader.GetInt64(9), /* integer_data */ + severity, + reader.IsDBNull(11) ? (int?)null : reader.GetInt32(11), /* error_number */ + reader.IsDBNull(12) ? null : reader.GetString(12))); /* text_data */ + } + + return rows; + } +} diff --git a/Lite/Services/LocalDataService.TempDb.cs b/Lite/Services/LocalDataService.TempDb.cs index 9b9ea7fef..4e785220b 100644 --- a/Lite/Services/LocalDataService.TempDb.cs +++ b/Lite/Services/LocalDataService.TempDb.cs @@ -110,20 +110,8 @@ ORDER BY collection_time DESC } } -public class TempDbSpaceInfo -{ - public double TotalReservedMb { get; set; } - public double UnallocatedMb { get; set; } - public double UserObjectReservedMb { get; set; } - public double InternalObjectReservedMb { get; set; } - public double VersionStoreReservedMb { get; set; } - public int TopConsumerSessionId { get; set; } - public double TopConsumerMb { get; set; } - - public double UsedPercent => TotalReservedMb + UnallocatedMb > 0 - ? TotalReservedMb / (TotalReservedMb + UnallocatedMb) * 100 - : 0; -} +/* TempDbSpaceInfo moved to PerformanceMonitor.Alerting (Phase-5 A0); + the bare name resolves through the global using alias in GlobalUsings.cs. */ public class TempDbRow { diff --git a/Lite/Services/LocalDataService.VolumeSpace.cs b/Lite/Services/LocalDataService.VolumeSpace.cs index 305eef5e2..56bcfaffb 100644 --- a/Lite/Services/LocalDataService.VolumeSpace.cs +++ b/Lite/Services/LocalDataService.VolumeSpace.cs @@ -59,12 +59,5 @@ GROUP BY volume_mount_point } } -public class VolumeFreeSpaceInfo -{ - public string MountPoint { get; set; } = ""; - public double TotalMb { get; set; } - public double FreeMb { get; set; } - - public double FreePercent => TotalMb > 0 ? FreeMb / TotalMb * 100 : 0; - public double FreeGb => FreeMb / 1024.0; -} +/* VolumeFreeSpaceInfo moved to PerformanceMonitor.Alerting (Phase-5 A0); + the bare name resolves through the global using alias in GlobalUsings.cs. */ diff --git a/Lite/Services/LocalDataService.WaitStats.cs b/Lite/Services/LocalDataService.WaitStats.cs index 7556a9f47..36fdf0715 100644 --- a/Lite/Services/LocalDataService.WaitStats.cs +++ b/Lite/Services/LocalDataService.WaitStats.cs @@ -11,6 +11,8 @@ using System.Threading.Tasks; using DuckDB.NET.Data; +using PerformanceMonitor.Ui; + namespace PerformanceMonitorLite.Services; public partial class LocalDataService @@ -123,7 +125,7 @@ WITH raw AS delta_wait_time_ms, delta_signal_wait_time_ms, delta_waiting_tasks, - date_diff('second', LAG(collection_time) OVER (ORDER BY collection_time), collection_time) AS interval_seconds + extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (ORDER BY collection_time)))) AS interval_seconds FROM v_wait_stats WHERE server_id = $1 AND wait_type = $2 @@ -132,9 +134,9 @@ FROM v_wait_stats ) SELECT collection_time, - CASE WHEN interval_seconds > 0 THEN CAST(delta_wait_time_ms AS DOUBLE) / interval_seconds ELSE 0 END AS wait_time_ms_per_second, - CASE WHEN interval_seconds > 0 THEN CAST(delta_signal_wait_time_ms AS DOUBLE) / interval_seconds ELSE 0 END AS signal_wait_time_ms_per_second, - CASE WHEN delta_waiting_tasks > 0 THEN CAST(delta_wait_time_ms AS DOUBLE) / delta_waiting_tasks ELSE 0 END AS avg_ms_per_wait + CASE WHEN interval_seconds > 0 THEN CAST(delta_wait_time_ms AS DOUBLE PRECISION) / interval_seconds ELSE 0 END AS wait_time_ms_per_second, + CASE WHEN interval_seconds > 0 THEN CAST(delta_signal_wait_time_ms AS DOUBLE PRECISION) / interval_seconds ELSE 0 END AS signal_wait_time_ms_per_second, + CASE WHEN delta_waiting_tasks > 0 THEN CAST(delta_wait_time_ms AS DOUBLE PRECISION) / delta_waiting_tasks ELSE 0 END AS avg_ms_per_wait FROM raw ORDER BY collection_time"; @@ -185,7 +187,7 @@ WITH raw AS delta_wait_time_ms, delta_signal_wait_time_ms, delta_waiting_tasks, - date_diff('second', LAG(collection_time) OVER (PARTITION BY wait_type ORDER BY collection_time), collection_time) AS interval_seconds + extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (PARTITION BY wait_type ORDER BY collection_time)))) AS interval_seconds FROM v_wait_stats WHERE server_id = $1 AND collection_time >= $2 @@ -195,9 +197,9 @@ AND wait_type IN ({typeParams}) SELECT wait_type, collection_time, - CASE WHEN interval_seconds > 0 THEN CAST(delta_wait_time_ms AS DOUBLE) / interval_seconds ELSE 0 END AS wait_time_ms_per_second, - CASE WHEN interval_seconds > 0 THEN CAST(delta_signal_wait_time_ms AS DOUBLE) / interval_seconds ELSE 0 END AS signal_wait_time_ms_per_second, - CASE WHEN delta_waiting_tasks > 0 THEN CAST(delta_wait_time_ms AS DOUBLE) / delta_waiting_tasks ELSE 0 END AS avg_ms_per_wait + CASE WHEN interval_seconds > 0 THEN CAST(delta_wait_time_ms AS DOUBLE PRECISION) / interval_seconds ELSE 0 END AS wait_time_ms_per_second, + CASE WHEN interval_seconds > 0 THEN CAST(delta_signal_wait_time_ms AS DOUBLE PRECISION) / interval_seconds ELSE 0 END AS signal_wait_time_ms_per_second, + CASE WHEN delta_waiting_tasks > 0 THEN CAST(delta_wait_time_ms AS DOUBLE PRECISION) / delta_waiting_tasks ELSE 0 END AS avg_ms_per_wait FROM raw ORDER BY wait_type, collection_time"; @@ -247,7 +249,7 @@ WITH per_collection AS SELECT collection_time, SUM(delta_wait_time_ms) AS total_delta_ms, - date_diff('second', LAG(collection_time) OVER (ORDER BY collection_time), collection_time) AS interval_seconds + extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (ORDER BY collection_time)))) AS interval_seconds FROM v_wait_stats WHERE server_id = $1 AND collection_time >= $2 @@ -257,7 +259,7 @@ GROUP BY collection_time ) SELECT collection_time, - CASE WHEN interval_seconds > 0 THEN CAST(total_delta_ms AS DOUBLE) / interval_seconds ELSE 0 END AS wait_time_ms_per_second + CASE WHEN interval_seconds > 0 THEN CAST(total_delta_ms AS DOUBLE PRECISION) / interval_seconds ELSE 0 END AS wait_time_ms_per_second FROM per_collection ORDER BY collection_time"; @@ -294,7 +296,7 @@ public async Task<List<PoisonWaitDelta>> GetLatestPoisonWaitAvgsAsync(int server delta_wait_time_ms AS delta_ms, delta_waiting_tasks AS delta_tasks, CASE WHEN delta_waiting_tasks > 0 - THEN CAST(delta_wait_time_ms AS DOUBLE) / delta_waiting_tasks + THEN CAST(delta_wait_time_ms AS DOUBLE PRECISION) / delta_waiting_tasks ELSE 0 END AS avg_ms_per_wait FROM v_wait_stats WHERE server_id = $1 @@ -337,39 +339,61 @@ public async Task<List<QuerySnapshotRow>> GetQuerySnapshotsByWaitTypeAsync( var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); command.CommandText = @" +WITH blocked_counts AS ( + SELECT collection_time, blocking_session_id AS blocker_session_id, COUNT(*) AS blocked_session_count + FROM v_query_snapshots + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND blocking_session_id > 0 + GROUP BY collection_time, blocking_session_id +) SELECT - session_id, - database_name, - elapsed_time_formatted, - query_text, - status, - blocking_session_id, - wait_type, - wait_time_ms, - wait_resource, - cpu_time_ms, - total_elapsed_time_ms, - reads, - writes, - logical_reads, - granted_query_memory_gb, - transaction_isolation_level, - dop, - parallel_worker_count, - query_plan, - live_query_plan, - collection_time, - login_name, - host_name, - program_name, - open_transaction_count, - percent_complete -FROM v_query_snapshots -WHERE server_id = $1 -AND collection_time >= $2 -AND collection_time <= $3 -AND wait_type = $4 -ORDER BY wait_time_ms DESC + q.session_id, + q.database_name, + q.elapsed_time_formatted, + q.query_text, + q.status, + q.blocking_session_id, + q.wait_type, + q.wait_time_ms, + q.wait_resource, + q.cpu_time_ms, + q.total_elapsed_time_ms, + q.reads, + q.writes, + q.logical_reads, + q.granted_query_memory_gb, + q.transaction_isolation_level, + q.dop, + q.parallel_worker_count, + q.query_plan, + q.live_query_plan, + q.collection_time, + q.login_name, + q.host_name, + q.program_name, + q.open_transaction_count, + q.percent_complete, + q.query_hash, + COALESCE(bc.blocked_session_count, 0) AS blocked_session_count, + q.requested_memory_mb, + q.used_memory_mb, + q.max_used_memory_mb, + q.tempdb_current_mb, + q.tempdb_allocations_mb, + q.tran_log_used_mb, + q.tran_start_time, + q.request_id +FROM v_query_snapshots q +LEFT JOIN blocked_counts bc + ON bc.collection_time = q.collection_time + AND bc.blocker_session_id = q.session_id +WHERE q.server_id = $1 +AND q.collection_time >= $2 +AND q.collection_time <= $3 +AND q.wait_type = $4 +ORDER BY q.wait_time_ms DESC LIMIT 500"; command.Parameters.Add(new DuckDBParameter { Value = serverId }); @@ -408,7 +432,17 @@ ORDER BY wait_time_ms DESC HostName = reader.IsDBNull(22) ? "" : reader.GetString(22), ProgramName = reader.IsDBNull(23) ? "" : reader.GetString(23), OpenTransactionCount = reader.IsDBNull(24) ? 0 : reader.GetInt32(24), - PercentComplete = reader.IsDBNull(25) ? 0m : Convert.ToDecimal(reader.GetValue(25)) + PercentComplete = reader.IsDBNull(25) ? 0m : Convert.ToDecimal(reader.GetValue(25)), + QueryHash = reader.IsDBNull(26) ? "" : reader.GetString(26), + BlockedSessionCount = reader.IsDBNull(27) ? 0 : Convert.ToInt32(reader.GetValue(27)), + RequestedMemoryMb = reader.IsDBNull(28) ? 0 : ToDouble(reader.GetValue(28)), + UsedMemoryMb = reader.IsDBNull(29) ? 0 : ToDouble(reader.GetValue(29)), + MaxUsedMemoryMb = reader.IsDBNull(30) ? 0 : ToDouble(reader.GetValue(30)), + TempdbCurrentMb = reader.IsDBNull(31) ? 0 : ToDouble(reader.GetValue(31)), + TempdbAllocationsMb = reader.IsDBNull(32) ? 0 : ToDouble(reader.GetValue(32)), + TranLogUsedMb = reader.IsDBNull(33) ? 0 : ToDouble(reader.GetValue(33)), + TranStartTime = reader.IsDBNull(34) ? (DateTime?)null : reader.GetDateTime(34), + RequestId = reader.IsDBNull(35) ? 0 : reader.GetInt32(35) }); } @@ -429,38 +463,60 @@ public async Task<List<QuerySnapshotRow>> GetAllQuerySnapshotsInRangeAsync( var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); command.CommandText = @" +WITH blocked_counts AS ( + SELECT collection_time, blocking_session_id AS blocker_session_id, COUNT(*) AS blocked_session_count + FROM v_query_snapshots + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + AND blocking_session_id > 0 + GROUP BY collection_time, blocking_session_id +) SELECT - session_id, - database_name, - elapsed_time_formatted, - query_text, - status, - blocking_session_id, - wait_type, - wait_time_ms, - wait_resource, - cpu_time_ms, - total_elapsed_time_ms, - reads, - writes, - logical_reads, - granted_query_memory_gb, - transaction_isolation_level, - dop, - parallel_worker_count, - query_plan, - live_query_plan, - collection_time, - login_name, - host_name, - program_name, - open_transaction_count, - percent_complete -FROM v_query_snapshots -WHERE server_id = $1 -AND collection_time >= $2 -AND collection_time <= $3 -ORDER BY collection_time DESC + q.session_id, + q.database_name, + q.elapsed_time_formatted, + q.query_text, + q.status, + q.blocking_session_id, + q.wait_type, + q.wait_time_ms, + q.wait_resource, + q.cpu_time_ms, + q.total_elapsed_time_ms, + q.reads, + q.writes, + q.logical_reads, + q.granted_query_memory_gb, + q.transaction_isolation_level, + q.dop, + q.parallel_worker_count, + q.query_plan, + q.live_query_plan, + q.collection_time, + q.login_name, + q.host_name, + q.program_name, + q.open_transaction_count, + q.percent_complete, + q.query_hash, + COALESCE(bc.blocked_session_count, 0) AS blocked_session_count, + q.requested_memory_mb, + q.used_memory_mb, + q.max_used_memory_mb, + q.tempdb_current_mb, + q.tempdb_allocations_mb, + q.tran_log_used_mb, + q.tran_start_time, + q.request_id +FROM v_query_snapshots q +LEFT JOIN blocked_counts bc + ON bc.collection_time = q.collection_time + AND bc.blocker_session_id = q.session_id +WHERE q.server_id = $1 +AND q.collection_time >= $2 +AND q.collection_time <= $3 +ORDER BY q.collection_time DESC LIMIT 2000"; command.Parameters.Add(new DuckDBParameter { Value = serverId }); @@ -498,7 +554,17 @@ ORDER BY collection_time DESC HostName = reader.IsDBNull(22) ? "" : reader.GetString(22), ProgramName = reader.IsDBNull(23) ? "" : reader.GetString(23), OpenTransactionCount = reader.IsDBNull(24) ? 0 : reader.GetInt32(24), - PercentComplete = reader.IsDBNull(25) ? 0m : Convert.ToDecimal(reader.GetValue(25)) + PercentComplete = reader.IsDBNull(25) ? 0m : Convert.ToDecimal(reader.GetValue(25)), + QueryHash = reader.IsDBNull(26) ? "" : reader.GetString(26), + BlockedSessionCount = reader.IsDBNull(27) ? 0 : Convert.ToInt32(reader.GetValue(27)), + RequestedMemoryMb = reader.IsDBNull(28) ? 0 : ToDouble(reader.GetValue(28)), + UsedMemoryMb = reader.IsDBNull(29) ? 0 : ToDouble(reader.GetValue(29)), + MaxUsedMemoryMb = reader.IsDBNull(30) ? 0 : ToDouble(reader.GetValue(30)), + TempdbCurrentMb = reader.IsDBNull(31) ? 0 : ToDouble(reader.GetValue(31)), + TempdbAllocationsMb = reader.IsDBNull(32) ? 0 : ToDouble(reader.GetValue(32)), + TranLogUsedMb = reader.IsDBNull(33) ? 0 : ToDouble(reader.GetValue(33)), + TranStartTime = reader.IsDBNull(34) ? (DateTime?)null : reader.GetDateTime(34), + RequestId = reader.IsDBNull(35) ? 0 : reader.GetInt32(35) }); } @@ -549,7 +615,8 @@ public async Task<List<LongRunningQueryInfo>> GetLongRunningQueriesAsync( r.writes, r.wait_type, r.blocking_session_id, - r.query_hash + r.query_hash, + r.program_name FROM v_query_snapshots AS r WHERE r.server_id = $1 AND r.collection_time = (SELECT MAX(vqs.collection_time) FROM v_query_snapshots AS vqs WHERE vqs.server_id = $1) @@ -583,7 +650,11 @@ ORDER BY r.total_elapsed_time_ms DESC Writes = reader.IsDBNull(6) ? 0 : reader.GetInt64(6), WaitType = reader.IsDBNull(7) ? null : reader.GetString(7), BlockingSessionId = reader.IsDBNull(8) ? null : (int?)reader.GetInt32(8), - QueryHash = reader.IsDBNull(9) ? null : reader.GetString(9) + QueryHash = reader.IsDBNull(9) ? null : reader.GetString(9), + /* Phase-5 A reconciliation: populate ProgramName so the shared + BuildLongRunningQueryContext renders the ("Program", ...) detail item the + Dashboard's alert already had. */ + ProgramName = reader.IsDBNull(10) ? "" : reader.GetString(10) }); } @@ -591,28 +662,8 @@ ORDER BY r.total_elapsed_time_ms DESC } } -public class LongRunningQueryInfo -{ - public int SessionId { get; set; } - public string DatabaseName { get; set; } = ""; - public string QueryText { get; set; } = ""; - public string ProgramName { get; set; } = ""; - public long ElapsedSeconds { get; set; } - public long CpuTimeMs { get; set; } - public long Reads { get; set; } - public long Writes { get; set; } - public string? WaitType { get; set; } - public int? BlockingSessionId { get; set; } - public string? QueryHash { get; set; } -} - -public class PoisonWaitDelta -{ - public string WaitType { get; set; } = ""; - public long DeltaMs { get; set; } - public long DeltaTasks { get; set; } - public double AvgMsPerWait { get; set; } -} +/* LongRunningQueryInfo and PoisonWaitDelta moved to PerformanceMonitor.Alerting (Phase-5 A0); + the bare names resolve through the global using aliases in GlobalUsings.cs. */ public class WaitStatsRow { @@ -628,7 +679,6 @@ public class WaitStatsRow public string SignalWaitTimeFormatted => FormatMs(TotalSignalWaitTimeMs); public string ResourceWaitTimeFormatted => FormatMs(ResourceWaitTimeMs); public double SignalWaitPercent => TotalWaitTimeMs > 0 ? (double)TotalSignalWaitTimeMs / TotalWaitTimeMs * 100 : 0; - public bool IsHighWait => TotalWaitTimeMs > 60000; private static string FormatMs(long ms) { @@ -639,12 +689,6 @@ private static string FormatMs(long ms) } } -public class SelectableItem -{ - public string DisplayName { get; set; } = ""; - public bool IsSelected { get; set; } -} - public class WaitStatsTrendPoint { public DateTime CollectionTime { get; set; } diff --git a/Lite/Services/LocalDataService.WaitingTasks.cs b/Lite/Services/LocalDataService.WaitingTasks.cs index 8d102c9f3..afb101024 100644 --- a/Lite/Services/LocalDataService.WaitingTasks.cs +++ b/Lite/Services/LocalDataService.WaitingTasks.cs @@ -18,11 +18,17 @@ public partial class LocalDataService /// <summary> /// Gets recent waiting task snapshots for a server. /// </summary> - public async Task<List<WaitingTaskRow>> GetWaitingTasksAsync(int serverId, int hoursBack = 1) + public async Task<List<WaitingTaskRow>> GetWaitingTasksAsync(int serverId, int hoursBack = 1, IReadOnlyList<string>? databaseNames = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); - command.CommandText = @" + + /* #1240 parity: exclude the user's ignored (benign) wait types at DISPLAY time too, so waiting + tasks already in the DuckDB (collected before a type was ignored) don't surface here — the same + BuildExclusionClause the wait-stats reads use. */ + var exclude = IgnoredWaitTypes.BuildExclusionClause(_ignoredWaitTypes.Value); + var dbClause = BuildDbInClause(databaseNames, "database_name", 3, out var dbValues); + command.CommandText = $@" SELECT collection_time, session_id, @@ -33,11 +39,14 @@ public async Task<List<WaitingTaskRow>> GetWaitingTasksAsync(int serverId, int h database_name FROM v_waiting_tasks WHERE server_id = $1 -AND collection_time >= $2 +AND collection_time >= $2{dbClause} +{exclude} ORDER BY collection_time DESC, wait_duration_ms DESC"; command.Parameters.Add(new DuckDBParameter { Value = serverId }); command.Parameters.Add(new DuckDBParameter { Value = DateTime.UtcNow.AddHours(-hoursBack) }); + foreach (var db in dbValues) + command.Parameters.Add(new DuckDBParameter { Value = db }); var items = new List<WaitingTaskRow>(); using var reader = await command.ExecuteReaderAsync(); @@ -68,7 +77,10 @@ public async Task<List<WaitingTaskTrendPoint>> GetWaitingTaskTrendAsync(int serv var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); - command.CommandText = @" + /* #1240 parity: exclude the user's ignored (benign) wait types at DISPLAY time (mirrors the + wait-stats reads) so the Current Waits duration chart matches the Wait Stats tab. */ + var exclude = IgnoredWaitTypes.BuildExclusionClause(_ignoredWaitTypes.Value); + command.CommandText = $@" SELECT collection_time, wait_type, @@ -78,6 +90,7 @@ FROM v_waiting_tasks AND collection_time >= $2 AND collection_time <= $3 AND wait_type IS NOT NULL +{exclude} GROUP BY collection_time, wait_type @@ -106,12 +119,13 @@ ORDER BY /// <summary> /// Gets blocked session count trend grouped by database for charting. /// </summary> - public async Task<List<BlockedSessionTrendPoint>> GetBlockedSessionTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + public async Task<List<BlockedSessionTrendPoint>> GetBlockedSessionTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null, IReadOnlyList<string>? databaseNames = null) { using var connection = await OpenConnectionAsync(); using var command = connection.CreateCommand(); var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + var dbClause = BuildDbInClause(databaseNames, "database_name", 4, out var dbValues); command.CommandText = @" SELECT @@ -122,7 +136,7 @@ FROM v_waiting_tasks WHERE server_id = $1 AND blocking_session_id > 0 AND collection_time >= $2 -AND collection_time <= $3 +AND collection_time <= $3" + dbClause + @" AND database_name IS NOT NULL GROUP BY collection_time, @@ -134,6 +148,8 @@ ORDER BY command.Parameters.Add(new DuckDBParameter { Value = serverId }); command.Parameters.Add(new DuckDBParameter { Value = startTime }); command.Parameters.Add(new DuckDBParameter { Value = endTime }); + foreach (var db in dbValues) + command.Parameters.Add(new DuckDBParameter { Value = db }); var items = new List<BlockedSessionTrendPoint>(); using var reader = await command.ExecuteReaderAsync(); diff --git a/Lite/Services/RemoteCollectorService.AgentStatus.cs b/Lite/Services/RemoteCollectorService.AgentStatus.cs new file mode 100644 index 000000000..c76ca8c19 --- /dev/null +++ b/Lite/Services/RemoteCollectorService.AgentStatus.cs @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Threading; +using System.Threading.Tasks; +using PerformanceMonitor.Collectors; +using PerformanceMonitorLite.Models; + +namespace PerformanceMonitorLite.Services; + +public partial class RemoteCollectorService +{ + /// <summary> + /// Collects the SQL Agent service status snapshot (Running/Stopped from sys.dm_server_services + next + /// scheduled run from msdb.dbo.sysjobschedules) via the shared <see cref="AgentStatusCollector"/> — + /// issue #1433 Phase 2, the current-state facts behind the Job History tab header and the "Agent Not + /// Running" alert. Read-only. Not collected on Azure SQL DB, AWS RDS, nor when the login lacks msdb + /// access — the shared AppliesTo (<c>!IsAzureSqlDb && !IsAwsRds && HasMsdbAccess</c>) is the + /// single gate, which RunCollectorAsync consults pre-dispatch for the clean SKIPPED log. + /// </summary> + private Task<int> CollectAgentStatusAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(AgentStatusCollector.Instance, server, cancellationToken); +} diff --git a/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs b/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs index 7d5e24b9f..201ec3346 100644 --- a/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs +++ b/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs @@ -6,28 +6,27 @@ * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ -using System; using System.Data; -using System.Diagnostics; using System.Threading; using System.Threading.Tasks; -using System.Xml.Linq; -using DuckDB.NET.Data; using Microsoft.Data.SqlClient; -using Microsoft.Extensions.Logging; +using PerformanceMonitor.Collectors; using PerformanceMonitorLite.Models; namespace PerformanceMonitorLite.Services; public partial class RemoteCollectorService { - /* We create and manage our own XE session to avoid conflicts with user's existing sessions */ - private const string BlockedProcessXeSessionName = "PerformanceMonitor_BlockedProcess"; + /* The session name lives in the shared definition so the ring-buffer reader and this + lifecycle code can never disagree on it. */ + private const string BlockedProcessXeSessionName = BlockedProcessReportCollector.XeSessionName; /// <summary> /// Ensures the blocked process XE session exists and is running. /// Creates a ring_buffer session for ALL platforms (on-prem, MI, Azure SQL DB). - /// Uses server-scoped session for on-prem/MI, database-scoped for Azure SQL DB. + /// Server-scoped session for on-prem/MI; on Azure SQL DB a database-scoped session in EVERY + /// monitored database (#1535 — a single session only ever captured the connection's own + /// database, so blocking in the logical server's other databases never appeared). /// </summary> public async Task EnsureBlockedProcessXeSessionAsync(ServerConnection server, int engineEdition = 0, CancellationToken cancellationToken = default) { @@ -38,27 +37,33 @@ public async Task EnsureBlockedProcessXeSessionAsync(ServerConnection server, in return; } - bool isAzureSqlDb = engineEdition == 5; + if (engineEdition == 5) + { + /* Azure SQL DB: one database-scoped session per monitored database, matching the + per-database ring-buffer read (BlockedProcessReportCollector.RunsPerDatabase). The + shared driver skips master, honors ExcludedDatabases via the shared database list, + self-heals sessions the reader can't see, and only surfaces unhealthy when NO + database could be ensured. */ + await EnsureDatabaseScopedXeSessionsAsync( + server, "blocked process", BlockedProcessXeSessionName, + EnsureBlockedProcessXeSessionAzureSqlDbAsync, cancellationToken); + return; + } try { using var connection = await CreateConnectionAsync(server, cancellationToken); - if (isAzureSqlDb) - { - /* Azure SQL DB: create database-scoped session with ring_buffer */ - await EnsureBlockedProcessXeSessionAzureSqlDbAsync(connection, cancellationToken); - } - else - { - /* On-prem and Azure MI: create server-scoped session with ring_buffer */ - await EnsureBlockedProcessXeSessionOnPremAsync(connection, server, cancellationToken); - } + /* On-prem and Azure MI: create server-scoped session with ring_buffer */ + await EnsureBlockedProcessXeSessionOnPremAsync(connection, server, cancellationToken); + } + catch (SqlException ex) when (IsBenignXeSessionAlreadyPresent(ex)) + { + /* The session is already present + running -- see IsBenignXeSessionAlreadyPresent (#1251). */ + AppLogger.Info("XeSession", $"[{server.DisplayName}] Blocked process XE session already present (benign, #1251)"); } catch (SqlException ex) { - _logger?.LogWarning("Failed to ensure blocked process XE session on '{Server}': {Message}", - server.DisplayName, ex.Message); AppLogger.Error("XeSession", $"[{server.DisplayName}] Failed to ensure blocked process XE session: {ex.Message}"); /* Propagate so RunCollectorAsync marks the collector unhealthy instead @@ -109,7 +114,6 @@ EXECUTE sys.sp_configure if (threshold == 0) { - _logger?.LogInformation("Configured blocked process threshold to 5 seconds on '{Server}'", server.DisplayName); AppLogger.Info("XeSession", $"[{server.DisplayName}] Configured blocked process threshold to 5 seconds"); } } @@ -145,20 +149,17 @@ LEFT JOIN sys.dm_xe_sessions AS dxs $"ALTER EVENT SESSION [{BlockedProcessXeSessionName}] ON SERVER STATE = START;", connection); startCmd.CommandTimeout = CommandTimeoutSeconds; await startCmd.ExecuteNonQueryAsync(cancellationToken); - _logger?.LogInformation("Started blocked process XE session on '{Server}'", server.DisplayName); AppLogger.Info("XeSession", $"[{server.DisplayName}] Started blocked process XE session"); } catch (SqlException ex) { - _logger?.LogWarning("Failed to start blocked process XE session on '{Server}': {Message}", - server.DisplayName, ex.Message); AppLogger.Error("XeSession", $"[{server.DisplayName}] Failed to start blocked process XE session: {ex.Message}"); throw; } } else { - _logger?.LogDebug("Blocked process XE session is running on '{Server}'", server.DisplayName); + AppLogger.Debug("XeSession", $"Blocked process XE session is running on '{server.DisplayName}'"); } return; } @@ -184,13 +185,10 @@ ADD TARGET package0.ring_buffer ALTER EVENT SESSION [{BlockedProcessXeSessionName}] ON SERVER STATE = START;", connection); createCmd.CommandTimeout = CommandTimeoutSeconds; await createCmd.ExecuteNonQueryAsync(cancellationToken); - _logger?.LogInformation("Created and started blocked process XE session on '{Server}'", server.DisplayName); AppLogger.Info("XeSession", $"[{server.DisplayName}] Created and started blocked process XE session"); } catch (SqlException ex) { - _logger?.LogWarning("Failed to create blocked process XE session on '{Server}': {Message}", - server.DisplayName, ex.Message); AppLogger.Error("XeSession", $"[{server.DisplayName}] Failed to create blocked process XE session: {ex.Message}"); throw; } @@ -232,8 +230,8 @@ FROM sys.dm_xe_database_sessions AS xes startCmd.CommandTimeout = CommandTimeoutSeconds; await startCmd.ExecuteNonQueryAsync(cancellationToken); - _logger?.LogDebug("Blocked process XE session already exists (database-scoped, Azure SQL DB)"); - AppLogger.Info("XeSession", $"[Azure SQL DB] Blocked process XE session verified (database-scoped)"); + /* Debug, not Info: this fires once per monitored database per cycle (#1535). */ + AppLogger.Debug("XeSession", $"[Azure SQL DB:{connection.Database}] Blocked process XE session verified (database-scoped)"); return; } } @@ -259,569 +257,241 @@ ADD TARGET package0.ring_buffer await cmd.ExecuteNonQueryAsync(cancellationToken); } - _logger?.LogInformation("Created and started blocked process XE session (database-scoped, Azure SQL DB)"); - AppLogger.Info("XeSession", $"[Azure SQL DB] Created and started blocked process XE session (database-scoped)"); + AppLogger.Info("XeSession", $"[Azure SQL DB:{connection.Database}] Created and started blocked process XE session (database-scoped)"); } /// <summary> - /// Collects blocked process reports from the XE session ring_buffer. - /// For on-prem/MI: reads from server-scoped session. - /// For Azure SQL DB: reads from database-scoped session. + /// True when every error is a benign "the session is already there" extended-events error: + /// 25631 (event session already exists) or 25705 (already started). On Azure SQL DB the XE + /// existence catalogs (sys.database_event_sessions / sys.dm_xe_database_sessions) are visibility- + /// scoped per principal and can come back empty even when the session is present + running, so the + /// CREATE/START path reports these -- they confirm the session is up, not a failure to surface (#1251). + /// Shared by the blocked-process and deadlock ensure paths (same partial class). /// </summary> - private async Task<int> CollectBlockedProcessReportsAsync(ServerConnection server, CancellationToken cancellationToken) + private static bool IsBenignXeSessionAlreadyPresent(SqlException ex) { - var serverStatus = _serverManager.GetConnectionStatus(server.Id); - bool isAzureSqlDb = serverStatus.SqlEngineEdition == 5; - - /* The ring-buffer source DMV is the only engine difference (database-scoped on Azure SQL DB, - server-scoped elsewhere); event extraction and the wait_resource -> object decode are shared. */ - string ringBufferSource = isAzureSqlDb - ? @"sys.dm_xe_database_session_targets AS xet -JOIN sys.dm_xe_database_sessions AS xes - ON xes.address = xet.event_session_address" - : @"sys.dm_xe_session_targets AS xet -JOIN sys.dm_xe_sessions AS xes - ON xes.address = xet.event_session_address"; - - /* Resolve contentious_object from wait_resource (KEY hobt -> sys.partitions, OBJECT objid, - PAGE/RID -> sys.dm_db_page_info), mirroring sp_HumanEventsBlockViewer (DarlingData #812) so - Dashboard and Lite -- and the #1140 dedup fingerprint -- agree. The event object_id/database_id - is unreliable for lock waits, kept only as a COALESCE fallback. Cross-db lookups are per-database - dynamic SQL guarded by DB-online + TRY/CATCH; the 2019+ DMV is version-gated (or @azure, which - reports ProductVersion 12 on Azure SQL DB). #bpr is a #temp (not a table var) so the dynamic SQL - can see it. The final SELECT keeps the original 5 columns, so the reader/appender is unchanged. */ - string query = $@" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -DECLARE - @product_version integer = - CONVERT - ( - integer, - PARSENAME - ( - CONVERT(sysname, SERVERPROPERTY('ProductVersion')), - 4 - ) - ), - @azure bit = - CASE - WHEN CONVERT(integer, SERVERPROPERTY('EngineEdition')) = 5 - THEN 1 - ELSE 0 - END, - @resolve_sql nvarchar(max), - @resolve_database_id integer, - @key_dbs CURSOR, - @page_dbs CURSOR; - -DECLARE - @PerformanceMonitor_BlockedProcess TABLE -( - ring_buffer xml NOT NULL -); - -INSERT - @PerformanceMonitor_BlockedProcess -( - ring_buffer -) -SELECT /* PerformanceMonitorLite */ - ring_xml = TRY_CAST(xet.target_data AS xml) -FROM {ringBufferSource} -WHERE xes.name = N'{BlockedProcessXeSessionName}' -AND xet.target_name = N'ring_buffer' -OPTION(RECOMPILE); - -SELECT - x.event_time, - x.blocked_process_report_xml, - x.object_id, - x.database_id, - x.wait_resource, - lock_type = CONVERT(varchar(32), NULL), - frag = CONVERT(nvarchar(1024), NULL), - resource_database_id = CONVERT(integer, NULL), - resource_hobt_id = CONVERT(bigint, NULL), - resource_file_id = CONVERT(integer, NULL), - resource_page_id = CONVERT(bigint, NULL), - resource_object_id = CONVERT(integer, NULL), - contentious_object = CONVERT(nvarchar(4000), NULL) -INTO #bpr -FROM -( - SELECT - event_time = evt.value('(@timestamp)[1]', 'datetime2'), - blocked_process_report_xml = CONVERT(nvarchar(max), evt.query('data[@name=""blocked_process""]/value/blocked-process-report')), - object_id = evt.value('(data[@name=""object_id""]/value/text())[1]', 'integer'), - database_id = evt.value('(data[@name=""database_id""]/value/text())[1]', 'integer'), - wait_resource = evt.value('(data[@name=""blocked_process""]/value/blocked-process-report/blocked-process/process/@waitresource)[1]', 'nvarchar(1024)') - FROM @PerformanceMonitor_BlockedProcess AS rb - CROSS APPLY rb.ring_buffer.nodes('RingBufferTarget/event[@name=""blocked_process_report""]') AS q(evt) - WHERE evt.value('(@timestamp)[1]', 'datetime2') > @cutoff_time -) AS x -OPTION(RECOMPILE); - -/* Classify the lock resource (prefers an embedded KEY/PAGE in a compound resource). */ -UPDATE - b -SET - b.lock_type = - CASE - WHEN b.wait_resource LIKE N'%KEY: %' THEN 'KEY' - WHEN b.wait_resource LIKE N'%OBJECT: %' THEN 'OBJECT' - WHEN b.wait_resource LIKE N'%RID: %' THEN 'RID' - WHEN b.wait_resource LIKE N'%PAGE: %' THEN 'PAGE' - ELSE LEFT(UPPER(LEFT(b.wait_resource, CHARINDEX(N':', b.wait_resource + N':') - 1)), 32) - END -FROM #bpr AS b -WHERE b.wait_resource IS NOT NULL -AND b.wait_resource <> N'' -OPTION(RECOMPILE); - -UPDATE - b -SET - b.frag = - SUBSTRING - ( - b.wait_resource, - CHARINDEX(b.lock_type + N': ', b.wait_resource) + LEN(b.lock_type) + 2, - 1024 - ) -FROM #bpr AS b -WHERE b.lock_type IN ('KEY', 'OBJECT', 'RID', 'PAGE') -OPTION(RECOMPILE); - -UPDATE - b -SET - b.resource_database_id = - TRY_CONVERT(integer, LEFT(b.frag, CHARINDEX(N':', b.frag + N':') - 1)) -FROM #bpr AS b -WHERE b.frag IS NOT NULL -OPTION(RECOMPILE); - -UPDATE - b -SET - b.resource_hobt_id = - TRY_CONVERT(bigint, LTRIM(LEFT(r.rest, CHARINDEX(N' ', r.rest + N' ') - 1))) -FROM #bpr AS b -CROSS APPLY -( - SELECT - rest = SUBSTRING(b.frag, CHARINDEX(N':', b.frag) + 1, 1024) -) AS r -WHERE b.lock_type = 'KEY' -OPTION(RECOMPILE); - -UPDATE - b -SET - b.resource_object_id = - TRY_CONVERT(integer, LEFT(r.rest, CHARINDEX(N':', r.rest + N':') - 1)) -FROM #bpr AS b -CROSS APPLY -( - SELECT - rest = SUBSTRING(b.frag, CHARINDEX(N':', b.frag) + 1, 1024) -) AS r -WHERE b.lock_type = 'OBJECT' -OPTION(RECOMPILE); - -UPDATE - b -SET - b.resource_file_id = - TRY_CONVERT(integer, LEFT(r.rest, CHARINDEX(N':', r.rest + N':') - 1)), - b.resource_page_id = - TRY_CONVERT(bigint, LEFT(p.rest2, CHARINDEX(N':', p.rest2 + N':') - 1)) -FROM #bpr AS b -CROSS APPLY -( - SELECT - rest = SUBSTRING(b.frag, CHARINDEX(N':', b.frag) + 1, 1024) -) AS r -CROSS APPLY -( - SELECT - rest2 = SUBSTRING(r.rest, CHARINDEX(N':', r.rest) + 1, 1024) -) AS p -WHERE b.lock_type IN ('PAGE', 'RID') -OPTION(RECOMPILE); - -/* KEY -> object_id via sys.partitions, per contended database (no cross-db form of the view). */ -SET @key_dbs = - CURSOR - LOCAL FAST_FORWARD - FOR - SELECT DISTINCT - b.resource_database_id - FROM #bpr AS b - WHERE b.lock_type = 'KEY' - AND b.resource_database_id IS NOT NULL - AND b.resource_hobt_id IS NOT NULL; - -OPEN @key_dbs; -FETCH NEXT FROM @key_dbs INTO @resolve_database_id; -WHILE @@FETCH_STATUS = 0 -BEGIN - IF DB_NAME(@resolve_database_id) IS NOT NULL - AND DATABASEPROPERTYEX(DB_NAME(@resolve_database_id), 'Status') = N'ONLINE' - BEGIN - SET @resolve_sql = N' - BEGIN TRY - UPDATE b - SET b.resource_object_id = p.object_id - FROM #bpr AS b - JOIN ' + QUOTENAME(DB_NAME(@resolve_database_id)) + N'.sys.partitions AS p - ON p.hobt_id = b.resource_hobt_id - WHERE b.lock_type = ''KEY'' - AND b.resource_database_id = @resolve_database_id - OPTION(RECOMPILE); - END TRY - BEGIN CATCH - /* no metadata access to the database -> leave for labeling */ - END CATCH;'; - EXECUTE sys.sp_executesql - @resolve_sql, - N'@resolve_database_id integer', - @resolve_database_id; - END; - FETCH NEXT FROM @key_dbs INTO @resolve_database_id; -END; + if (ex.Errors.Count == 0) + { + return false; + } -/* PAGE / RID -> object_id via sys.dm_db_page_info (2019+ or Azure SQL DB; VIEW DATABASE STATE -> TRY/CATCH). */ -IF @product_version >= 15 -OR @azure = 1 -BEGIN - SET @page_dbs = - CURSOR - LOCAL FAST_FORWARD - FOR - SELECT DISTINCT - b.resource_database_id - FROM #bpr AS b - WHERE b.lock_type IN ('PAGE', 'RID') - AND b.resource_database_id IS NOT NULL - AND b.resource_page_id IS NOT NULL; - - OPEN @page_dbs; - FETCH NEXT FROM @page_dbs INTO @resolve_database_id; - WHILE @@FETCH_STATUS = 0 - BEGIN - IF DB_NAME(@resolve_database_id) IS NOT NULL - AND DATABASEPROPERTYEX(DB_NAME(@resolve_database_id), 'Status') = N'ONLINE' - BEGIN - SET @resolve_sql = N' - BEGIN TRY - UPDATE b - SET b.resource_object_id = pi.object_id - FROM #bpr AS b - CROSS APPLY sys.dm_db_page_info(b.resource_database_id, b.resource_file_id, b.resource_page_id, ''LIMITED'') AS pi - WHERE b.lock_type IN (''PAGE'', ''RID'') - AND b.resource_database_id = @resolve_database_id - OPTION(RECOMPILE); - END TRY - BEGIN CATCH - /* no VIEW DATABASE STATE / page reallocated -> leave for labeling */ - END CATCH;'; - EXECUTE sys.sp_executesql - @resolve_sql, - N'@resolve_database_id integer', - @resolve_database_id; - END; - FETCH NEXT FROM @page_dbs INTO @resolve_database_id; - END; -END; + foreach (Microsoft.Data.SqlClient.SqlError error in ex.Errors) + { + /* 25631 = event session already exists; 25705 = already started. */ + if (error.Number != 25631 && error.Number != 25705) + { + return false; + } + } -/* Format (plain schema.object) + label; the event object_id is the fallback, then the Unresolved sentinel. */ -UPDATE - b -SET - b.contentious_object = - COALESCE - ( - CASE - WHEN b.resource_object_id > 0 - AND OBJECT_NAME(b.resource_object_id, b.resource_database_id) IS NOT NULL - THEN CONCAT - ( - OBJECT_SCHEMA_NAME(b.resource_object_id, b.resource_database_id), - N'.', - OBJECT_NAME(b.resource_object_id, b.resource_database_id) - ) - END, - CASE - WHEN b.object_id > 0 - AND OBJECT_NAME(b.object_id, b.database_id) IS NOT NULL - THEN CONCAT - ( - OBJECT_SCHEMA_NAME(b.object_id, b.database_id), - N'.', - OBJECT_NAME(b.object_id, b.database_id) - ) - END, - N'Unresolved: ' + - CASE - WHEN b.lock_type IS NULL - THEN N'' - ELSE LOWER(b.lock_type) + N' lock, ' - END + - N'database: ' + ISNULL(DB_NAME(b.database_id), N'unknown') - ) -FROM #bpr AS b -OPTION(RECOMPILE); + return true; + } -SELECT - b.event_time, - b.blocked_process_report_xml, - b.object_id, - b.database_id, - b.contentious_object -FROM #bpr AS b -OPTION(RECOMPILE);"; - - var serverId = GetServerId(server); - var collectionTime = DateTime.UtcNow; - var rowsCollected = 0; - _lastSqlMs = 0; - _lastDuckDbMs = 0; - - /* Query the most recent event_time we already have for this server. - Pass it to SQL Server so we only fetch events newer than what we've collected. - This prevents the same blocked process report from being inserted multiple times - as it lingers in the ring buffer across collection cycles. */ - DateTime? lastCollectedTime = null; + /// <summary> + /// Azure SQL DB ensure driver, shared by the deadlock and blocked-process paths (#1535): one + /// database-scoped XE session per monitored database, matching the collectors' per-database + /// ring-buffer reads. Master is skipped (database-scoped sessions can't be created in logical + /// master); ExcludedDatabases is honored by <see cref="GetAzureDatabaseListAsync"/>. + /// + /// <para><b>The #1251 benign path grew a read-back check.</b> A benign "already exists"/"already + /// started" from the engine proves the session is there, but NOT that this principal can see it: + /// the ring-buffer reader joins <c>sys.dm_xe_database_sessions</c>, and a session that is + /// invisible there (created by another principal, or present-but-stopped) reads zero rows forever + /// while the collector records SUCCESS — the exact silent-empty shape of #1346/#1535. So after a + /// benign error the session is probed in the reader's own DMV, and an invisible one is dropped + /// and recreated under this principal. On-prem keeps the plain benign log: its server-scoped + /// catalogs need the same VIEW SERVER STATE every collector already requires.</para> + /// + /// <para><b>Failure isolation:</b> one database failing to ensure must not kill capture for the + /// other databases, so per-database failures are warn-logged and the cycle proceeds — but if NO + /// database could be ensured, capture is fully dead and this throws (SqlException failures as + /// <see cref="XeSessionEnsureException"/> for the #1086 health surface) instead of letting a + /// zero-row read record SUCCESS.</para> + /// </summary> + private async Task EnsureDatabaseScopedXeSessionsAsync( + ServerConnection server, + string captureName, + string sessionName, + Func<SqlConnection, CancellationToken, Task> ensureAsync, + CancellationToken cancellationToken) + { + List<string> databases; try { - using var duckConn = _duckDb.CreateConnection(); - await duckConn.OpenAsync(cancellationToken); - using var cmd = duckConn.CreateCommand(); - cmd.CommandText = "SELECT MAX(event_time) FROM blocked_process_reports WHERE server_id = $1"; - cmd.Parameters.Add(new DuckDBParameter { Value = serverId }); - var result = await cmd.ExecuteScalarAsync(cancellationToken); - if (result is DateTime dt) - lastCollectedTime = dt; + databases = await GetAzureDatabaseListAsync(server, cancellationToken); } - catch + catch (SqlException ex) { - /* If DuckDB query fails, fall back to default 10-minute window */ + AppLogger.Error("XeSession", $"[{server.DisplayName}] Failed to enumerate databases for {captureName} XE sessions: {ex.Message}"); + throw new XeSessionEnsureException(captureName, ex); } - var sqlSw = Stopwatch.StartNew(); - using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); - using var command = new SqlCommand(query, sqlConnection); - command.CommandTimeout = CommandTimeoutSeconds; + int attempted = 0; + int healthy = 0; + Exception? firstFailure = null; - /* Use the most recent timestamp from DuckDB as the cutoff, or fall back to 10-minute window */ - command.Parameters.Add(new SqlParameter("@cutoff_time", SqlDbType.DateTime2) { Value = lastCollectedTime ?? DateTime.UtcNow.AddMinutes(-10) }); - - try + foreach (var databaseName in databases) { - using var reader = await command.ExecuteReaderAsync(cancellationToken); - sqlSw.Stop(); - _lastSqlMs = sqlSw.ElapsedMilliseconds; + cancellationToken.ThrowIfCancellationRequested(); - var duckSw = Stopwatch.StartNew(); + /* Logical master can't host database-scoped event sessions. */ + if (string.Equals(databaseName, "master", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + attempted++; - using (var duckConnection = _duckDb.CreateConnection()) + try { - await duckConnection.OpenAsync(cancellationToken); + using var connection = await OpenAzureDatabaseConnectionAsync(server, databaseName, cancellationToken); + var readable = true; - using (var appender = duckConnection.CreateAppender("blocked_process_reports")) + try + { + await ensureAsync(connection, cancellationToken); + } + catch (SqlException ex) when (IsBenignXeSessionAlreadyPresent(ex)) { - while (await reader.ReadAsync(cancellationToken)) + var recreateKey = $"{server.Id}:{databaseName}:{sessionName}"; + + if (await IsDatabaseScopedXeSessionVisibleAsync(connection, sessionName, cancellationToken)) + { + AppLogger.Debug("XeSession", $"[{server.DisplayName}] [{databaseName}] {captureName} XE session already present (benign, #1251)"); + } + else if (_xeSessionRecreateGaveUp.ContainsKey(recreateKey)) { - var eventTime = reader.IsDBNull(0) ? (DateTime?)null : reader.GetDateTime(0); - var reportXml = reader.IsDBNull(1) ? null : reader.GetString(1); - /* #1140: object_id/database_id are the event's own data fields and contentious_object - is resolved server-side in the collection query (cols 2-4), not parsed from the XML. */ - var objectId = reader.IsDBNull(2) ? (int?)null : reader.GetInt32(2); - var databaseId = reader.IsDBNull(3) ? (int?)null : reader.GetInt32(3); - var contentiousObject = reader.IsDBNull(4) ? null : reader.GetString(4); - - if (string.IsNullOrEmpty(reportXml)) + /* Recreate already proven not to fix visibility here — don't churn the + session every cycle (each DROP wipes captured-but-unread events). + Counted unhealthy, quietly (the give-up itself was logged at Error). */ + AppLogger.Debug("XeSession", $"[{server.DisplayName}] [{databaseName}] {captureName} XE session still not readable; recreate previously didn't help — skipping (#1535)"); + readable = false; + firstFailure ??= ex; + } + else + { + /* Exists per the engine, invisible to the reader's DMV — reclaim it. + Failures here fall to the per-database catch below. */ + await RecreateDatabaseScopedXeSessionAsync(connection, sessionName, ensureAsync, cancellationToken); + + if (await IsDatabaseScopedXeSessionVisibleAsync(connection, sessionName, cancellationToken)) { - continue; + AppLogger.Info("XeSession", $"[{server.DisplayName}] [{databaseName}] {captureName} XE session existed but was not visible to the ring-buffer reader — dropped and recreated (#1535)"); } - - /* Parse the blocked process report XML in C# */ - var parsed = ParseBlockedProcessReportXml(reportXml, eventTime); - if (parsed == null) + else { - continue; + /* Recreated under THIS principal and still invisible — recreating + again next cycle can't help and would only churn events away. */ + _xeSessionRecreateGaveUp[recreateKey] = 1; + AppLogger.Error("XeSession", $"[{server.DisplayName}] [{databaseName}] {captureName} XE session is still not visible in sys.dm_xe_database_sessions after recreating it — the ring-buffer reader cannot see this database's capture; giving up on recreates until the app restarts (#1535)"); + readable = false; + firstFailure ??= ex; } - - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(GetServerNameForStorage(server)) - .AppendValue(parsed.EventTime) - .AppendValue(parsed.DatabaseName) - .AppendValue(parsed.BlockedSpid) - .AppendValue(parsed.BlockedEcid) - .AppendValue(parsed.BlockingSpid) - .AppendValue(parsed.BlockingEcid) - .AppendValue(parsed.WaitTimeMs) - .AppendValue(parsed.WaitResource) - .AppendValue(parsed.LockMode) - .AppendValue(parsed.BlockedStatus) - .AppendValue(parsed.BlockedIsolationLevel) - .AppendValue(parsed.BlockedLogUsed) - .AppendValue(parsed.BlockedTransactionCount) - .AppendValue(parsed.BlockedClientApp) - .AppendValue(parsed.BlockedHostName) - .AppendValue(parsed.BlockedLoginName) - .AppendValue(parsed.BlockedSqlText) - .AppendValue(parsed.BlockingStatus) - .AppendValue(parsed.BlockingIsolationLevel) - .AppendValue(parsed.BlockingClientApp) - .AppendValue(parsed.BlockingHostName) - .AppendValue(parsed.BlockingLoginName) - .AppendValue(parsed.BlockingSqlText) - .AppendValue(parsed.BlockedTransactionName) - .AppendValue(parsed.BlockingTransactionName) - .AppendValue(parsed.BlockedLastTranStarted) - .AppendValue(parsed.BlockingLastTranStarted) - .AppendValue(parsed.BlockedLastBatchStarted) - .AppendValue(parsed.BlockingLastBatchStarted) - .AppendValue(parsed.BlockedLastBatchCompleted) - .AppendValue(parsed.BlockingLastBatchCompleted) - .AppendValue(parsed.BlockedPriority) - .AppendValue(parsed.BlockingPriority) - .AppendValue(reportXml) - .AppendValue(objectId) - .AppendValue(databaseId) - .AppendValue(contentiousObject) - .AppendValue(parsed.MonitorLoop) - .EndRow(); - - rowsCollected++; } } - } - duckSw.Stop(); - _lastDuckDbMs = duckSw.ElapsedMilliseconds; + if (readable) + { + healthy++; + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + firstFailure ??= ex; + AppLogger.Warn("XeSession", $"[{server.DisplayName}] [{databaseName}] Failed to ensure {captureName} XE session: {ex.Message}"); + } } - catch (SqlException ex) when (ex.Number == 297 || ex.Number == 15151 || ex.Message.Contains("XE session")) + + if (attempted > 0 && healthy == 0 && firstFailure is not null) { - /* XE session not found or not accessible */ - _logger?.LogDebug("Blocked process XE session not available on '{Server}': {Message}", - server.DisplayName, ex.Message); - AppLogger.Info("XeSession", $"[{server.DisplayName}] Blocked process XE session not available: {ex.Message}"); - return 0; - } + AppLogger.Error("XeSession", $"[{server.DisplayName}] Failed to ensure the {captureName} XE session in all {attempted} database(s)"); - _logger?.LogDebug("Collected {RowCount} blocked process reports for server '{Server}'", rowsCollected, server.DisplayName); - return rowsCollected; + if (firstFailure is SqlException sqlFailure) + { + throw new XeSessionEnsureException(captureName, sqlFailure); + } + + /* Non-SQL failure (e.g. a connection-open fault) — surface it raw with its original + stack; RunCollectorAsync's general handler classifies it ERROR, which is still not a + silent SUCCESS. */ + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(firstFailure).Throw(); + } } + /* Databases where a drop+recreate demonstrably did NOT make the session visible to the reader + (see the give-up branch above): keyed server:database:session, in-memory so an app restart + retries once. Prevents a per-cycle DROP/CREATE ping-pong that would wipe captured-but-unread + ring-buffer events every cycle in a pathological-permissions database. */ + private readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte> _xeSessionRecreateGaveUp = new(); + /// <summary> - /// Parses a blocked-process-report XML fragment into a structured object. - /// XML structure: <blocked-process-report><blocked-process><process ...><inputbuf>... - /// <blocking-process><process ...><inputbuf>... + /// Whether the database-scoped session is visible to THIS principal in + /// <c>sys.dm_xe_database_sessions</c> — the very DMV the ring-buffer reader joins, so this is + /// the reader's-eye view: false means the reader would see zero rows regardless of captured + /// events (session stopped, or running but created by a principal whose sessions we can't see). /// </summary> - private static ParsedBlockedProcessReport? ParseBlockedProcessReportXml(string xml, DateTime? eventTime) + private async Task<bool> IsDatabaseScopedXeSessionVisibleAsync(SqlConnection connection, string sessionName, CancellationToken cancellationToken) { - try - { - var doc = XElement.Parse(xml); - - var blockedProcess = doc.Element("blocked-process")?.Element("process"); - var blockingProcess = doc.Element("blocking-process")?.Element("process"); + using var cmd = new SqlCommand(@" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - if (blockedProcess == null) - { - return null; - } +SELECT /* PerformanceMonitorLite */ + is_visible = + CASE + WHEN EXISTS + ( + SELECT + 1/0 + FROM sys.dm_xe_database_sessions AS xes + WHERE xes.name = @session_name + ) + THEN 1 + ELSE 0 + END;", connection); + cmd.CommandTimeout = CommandTimeoutSeconds; + cmd.Parameters.Add(new SqlParameter("@session_name", SqlDbType.NVarChar, 128) { Value = sessionName }); + var result = await cmd.ExecuteScalarAsync(cancellationToken); + return result is int isVisible && isVisible == 1; + } - return new ParsedBlockedProcessReport - { - EventTime = eventTime, - // Lite's stored XML is rooted at <blocked-process-report>, so monitorLoop is a root attribute. - MonitorLoop = int.TryParse(doc.Attribute("monitorLoop")?.Value, out var ml) ? ml : (int?)null, - DatabaseName = blockedProcess.Attribute("currentdbname")?.Value, - BlockedSpid = int.TryParse(blockedProcess.Attribute("spid")?.Value, out var bs) ? bs : 0, - BlockedEcid = int.TryParse(blockedProcess.Attribute("ecid")?.Value, out var be) ? be : 0, - BlockingSpid = int.TryParse(blockingProcess?.Attribute("spid")?.Value, out var bks) ? bks : 0, - BlockingEcid = int.TryParse(blockingProcess?.Attribute("ecid")?.Value, out var bke) ? bke : 0, - WaitTimeMs = long.TryParse(blockedProcess.Attribute("waittime")?.Value, out var wt) ? wt : 0, - WaitResource = blockedProcess.Attribute("waitresource")?.Value, - LockMode = blockedProcess.Attribute("lockMode")?.Value, - BlockedStatus = blockedProcess.Attribute("status")?.Value, - BlockedIsolationLevel = blockedProcess.Attribute("isolationlevel")?.Value, - BlockedLogUsed = long.TryParse(blockedProcess.Attribute("logused")?.Value, out var lu) ? lu : 0, - BlockedTransactionCount = int.TryParse(blockedProcess.Attribute("trancount")?.Value, out var tc) ? tc : 0, - BlockedClientApp = blockedProcess.Attribute("clientapp")?.Value, - BlockedHostName = blockedProcess.Attribute("hostname")?.Value, - BlockedLoginName = blockedProcess.Attribute("loginname")?.Value, - BlockedSqlText = blockedProcess.Element("inputbuf")?.Value?.Trim(), - BlockedTransactionName = blockedProcess.Attribute("transactionname")?.Value, - BlockedLastTranStarted = DateTime.TryParse(blockedProcess.Attribute("lasttranstarted")?.Value, out var blts) ? blts : null, - BlockedLastBatchStarted = DateTime.TryParse(blockedProcess.Attribute("lastbatchstarted")?.Value, out var blbs) ? blbs : null, - BlockedLastBatchCompleted = DateTime.TryParse(blockedProcess.Attribute("lastbatchcompleted")?.Value, out var blbc) ? blbc : null, - BlockedPriority = int.TryParse(blockedProcess.Attribute("priority")?.Value, out var bp) ? bp : 0, - BlockingStatus = blockingProcess?.Attribute("status")?.Value, - BlockingIsolationLevel = blockingProcess?.Attribute("isolationlevel")?.Value, - BlockingClientApp = blockingProcess?.Attribute("clientapp")?.Value, - BlockingHostName = blockingProcess?.Attribute("hostname")?.Value, - BlockingLoginName = blockingProcess?.Attribute("loginname")?.Value, - BlockingSqlText = blockingProcess?.Element("inputbuf")?.Value?.Trim(), - BlockingTransactionName = blockingProcess?.Attribute("transactionname")?.Value, - BlockingLastTranStarted = DateTime.TryParse(blockingProcess?.Attribute("lasttranstarted")?.Value, out var bklts) ? bklts : null, - BlockingLastBatchStarted = DateTime.TryParse(blockingProcess?.Attribute("lastbatchstarted")?.Value, out var bklbs) ? bklbs : null, - BlockingLastBatchCompleted = DateTime.TryParse(blockingProcess?.Attribute("lastbatchcompleted")?.Value, out var bklbc) ? bklbc : null, - BlockingPriority = int.TryParse(blockingProcess?.Attribute("priority")?.Value, out var bkp) ? bkp : 0 - }; - } - catch + /// <summary> + /// Drops the (engine-confirmed-present) database-scoped session and re-runs the ensure so it is + /// recreated under this principal, visible to the reader. The DROP works by name even when the + /// catalogs hide the session — the same store the CREATE collided with resolves it. + /// </summary> + private static async Task RecreateDatabaseScopedXeSessionAsync( + SqlConnection connection, + string sessionName, + Func<SqlConnection, CancellationToken, Task> ensureAsync, + CancellationToken cancellationToken) + { + using (var dropCmd = new SqlCommand($"DROP EVENT SESSION [{sessionName}] ON DATABASE;", connection)) { - return null; + dropCmd.CommandTimeout = CommandTimeoutSeconds; + await dropCmd.ExecuteNonQueryAsync(cancellationToken); } + + await ensureAsync(connection, cancellationToken); } /// <summary> - /// Internal model for parsed blocked process report XML data. + /// Collects blocked process reports via the shared <see cref="BlockedProcessReportCollector"/> + /// definition (the server- vs database-scoped ring-buffer reads, the wait_resource → + /// contentious-object resolution, the event_time watermark, and the report-XML parse live + /// there — the cross-SKU parity contract). The XE session lifecycle stays here; a + /// missing/inaccessible session is tolerated as zero rows, exactly as before. /// </summary> - private class ParsedBlockedProcessReport + private async Task<int> CollectBlockedProcessReportsAsync(ServerConnection server, CancellationToken cancellationToken) { - public DateTime? EventTime { get; set; } - public int? MonitorLoop { get; set; } - public string? DatabaseName { get; set; } - public int BlockedSpid { get; set; } - public int BlockedEcid { get; set; } - public int BlockingSpid { get; set; } - public int BlockingEcid { get; set; } - public long WaitTimeMs { get; set; } - public string? WaitResource { get; set; } - public string? LockMode { get; set; } - public string? BlockedStatus { get; set; } - public string? BlockedIsolationLevel { get; set; } - public long BlockedLogUsed { get; set; } - public int BlockedTransactionCount { get; set; } - public string? BlockedClientApp { get; set; } - public string? BlockedHostName { get; set; } - public string? BlockedLoginName { get; set; } - public string? BlockedSqlText { get; set; } - public string? BlockingStatus { get; set; } - public string? BlockingIsolationLevel { get; set; } - public string? BlockingClientApp { get; set; } - public string? BlockingHostName { get; set; } - public string? BlockingLoginName { get; set; } - public string? BlockingSqlText { get; set; } - public string? BlockedTransactionName { get; set; } - public string? BlockingTransactionName { get; set; } - public DateTime? BlockedLastTranStarted { get; set; } - public DateTime? BlockingLastTranStarted { get; set; } - public DateTime? BlockedLastBatchStarted { get; set; } - public DateTime? BlockingLastBatchStarted { get; set; } - public DateTime? BlockedLastBatchCompleted { get; set; } - public DateTime? BlockingLastBatchCompleted { get; set; } - public int BlockedPriority { get; set; } - public int BlockingPriority { get; set; } + try + { + return await RunCollectorDefinitionAsync(BlockedProcessReportCollector.Instance, server, cancellationToken); + } + catch (SqlException ex) when (ex.Number == 297 || ex.Number == 15151 || ex.Message.Contains("XE session")) + { + /* XE session not found or not accessible */ + AppLogger.Info("XeSession", $"[{server.DisplayName}] Blocked process XE session not available: {ex.Message}"); + return 0; + } } } diff --git a/Lite/Services/RemoteCollectorService.Cpu.cs b/Lite/Services/RemoteCollectorService.Cpu.cs index 81e3d56cd..da35b23b6 100644 --- a/Lite/Services/RemoteCollectorService.Cpu.cs +++ b/Lite/Services/RemoteCollectorService.Cpu.cs @@ -6,13 +6,9 @@ * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ -using System; -using System.Diagnostics; using System.Threading; using System.Threading.Tasks; -using DuckDB.NET.Data; -using Microsoft.Data.SqlClient; -using Microsoft.Extensions.Logging; +using PerformanceMonitor.Collectors; using PerformanceMonitorLite.Models; namespace PerformanceMonitorLite.Services; @@ -20,170 +16,11 @@ namespace PerformanceMonitorLite.Services; public partial class RemoteCollectorService { /// <summary> - /// Collects CPU utilization from the ring buffer (on-prem, MI, RDS) - /// or sys.dm_db_resource_stats (Azure SQL DB). + /// Collects CPU utilization via the shared <see cref="CpuUtilizationCollector"/> definition + /// (ring buffer on-prem/MI/RDS, sys.dm_db_resource_stats on Azure SQL DB; the Linux + /// NULL-other-CPU rule (#1048), the incomplete-SystemHealth skip (#989), and the + /// watermark/dedup split live there — the cross-SKU parity contract). /// </summary> - private async Task<int> CollectCpuUtilizationAsync(ServerConnection server, CancellationToken cancellationToken) - { - var serverStatus = _serverManager.GetConnectionStatus(server.Id); - bool isAzureSqlDb = serverStatus.SqlEngineEdition == 5; - - /* Azure SQL DB: ring buffer is empty, use dm_db_resource_stats instead. - Returns avg_cpu_percent sampled every 15 seconds, retained for 1 hour. - No "other process" concept in Azure SQL DB — isolated environment. */ - const string azureSqlDbQuery = @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -SELECT TOP (60) - sample_time = drs.end_time, - sqlserver_cpu_utilization = CONVERT(integer, drs.avg_cpu_percent), - other_process_cpu_utilization = 0 -FROM sys.dm_db_resource_stats AS drs -ORDER BY - drs.end_time DESC -OPTION(RECOMPILE);"; - - /* On-prem, MI, RDS: use ring buffer scheduler monitor */ - const string ringBufferQuery = @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -DECLARE - @ms_ticks bigint, - @start_time datetime2(7), - @is_linux bit = 0; - -SELECT - @ms_ticks = dosi.ms_ticks, - @start_time = dosi.sqlserver_start_time -FROM sys.dm_os_sys_info AS dosi; - -/* Detect SQL Server on Linux. SystemIdle is always 0 in the SCHEDULER_MONITOR - ring buffer on Linux, so 100 - SystemIdle - ProcessUtilization fabricates a - host figure that pins total CPU at 100% (Issue #1048). No DMV exposes true host - CPU on Linux, so other_process is stored as NULL there. sys.dm_os_host_info is - 2017+; referenced via sp_executesql so SQL 2016 never binds it (@is_linux = 0). */ -IF OBJECT_ID(N'sys.dm_os_host_info', N'V') IS NOT NULL - EXEC sys.sp_executesql - N'SELECT @linux = CASE WHEN hi.host_platform = N''Linux'' THEN 1 ELSE 0 END FROM sys.dm_os_host_info AS hi;', - N'@linux bit OUTPUT', @linux = @is_linux OUTPUT; - -SELECT TOP (60) - sample_time = DATEADD(SECOND, -((@ms_ticks - t.timestamp) / 1000), SYSDATETIME()), - sqlserver_cpu_utilization = x.process_utilization, - other_process_cpu_utilization = - CASE - WHEN @is_linux = 1 - THEN NULL - WHEN (100 - x.system_idle - x.process_utilization) < 0 - THEN 0 - ELSE 100 - x.system_idle - x.process_utilization - END -FROM -( - SELECT - dorb.timestamp, - record = CONVERT(xml, dorb.record) - FROM sys.dm_os_ring_buffers AS dorb - WHERE dorb.ring_buffer_type = N'RING_BUFFER_SCHEDULER_MONITOR' -) AS t -CROSS APPLY -( - SELECT - process_utilization = t.record.value('(Record/SchedulerMonitorEvent/SystemHealth/ProcessUtilization)[1]', 'integer'), - system_idle = t.record.value('(Record/SchedulerMonitorEvent/SystemHealth/SystemIdle)[1]', 'integer') -) AS x -/* Skip ring-buffer records lacking a complete SystemHealth block — their - XML values extract as NULL and would store NULL samples (Issue #989). */ -WHERE x.process_utilization IS NOT NULL -AND x.system_idle IS NOT NULL -ORDER BY t.timestamp DESC -OPTION(RECOMPILE);"; - - var serverId = GetServerId(server); - var collectionTime = DateTime.UtcNow; - var rowsCollected = 0; - _lastSqlMs = 0; - _lastDuckDbMs = 0; - - /* Get the most recent sample_time we already have, to skip duplicates. - Ring buffer always returns TOP 60 (computed sample_time can't be filtered server-side). - For Azure SQL DB, we push the filter into the SQL query since end_time is a real column. */ - var lastSampleTime = await GetLastCollectedTimeAsync( - serverId, "cpu_utilization_stats", "sample_time", cancellationToken); - - string query; - if (isAzureSqlDb && lastSampleTime.HasValue) - { - /* Azure SQL DB: filter server-side since end_time is a real column */ - query = @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -SELECT - sample_time = drs.end_time, - sqlserver_cpu_utilization = CONVERT(integer, drs.avg_cpu_percent), - other_process_cpu_utilization = 0 -FROM sys.dm_db_resource_stats AS drs -WHERE drs.end_time > @last_sample_time -ORDER BY - drs.end_time DESC -OPTION(RECOMPILE);"; - } - else - { - query = isAzureSqlDb ? azureSqlDbQuery : ringBufferQuery; - } - - var sqlSw = Stopwatch.StartNew(); - using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); - using var command = new SqlCommand(query, sqlConnection); - command.CommandTimeout = CommandTimeoutSeconds; - - if (isAzureSqlDb && lastSampleTime.HasValue) - { - command.Parameters.Add(new SqlParameter("@last_sample_time", System.Data.SqlDbType.DateTime2) { Value = lastSampleTime.Value }); - } - - using var reader = await command.ExecuteReaderAsync(cancellationToken); - sqlSw.Stop(); - _lastSqlMs = sqlSw.ElapsedMilliseconds; - - /* Insert into DuckDB using Appender for bulk performance */ - var duckSw = Stopwatch.StartNew(); - - using (var duckConnection = _duckDb.CreateConnection()) - { - await duckConnection.OpenAsync(cancellationToken); - - using (var appender = duckConnection.CreateAppender("cpu_utilization_stats")) - { - while (await reader.ReadAsync(cancellationToken)) - { - var sampleTime = reader.GetDateTime(0); - - /* Client-side dedup for ring buffer (computed sample_time can't be filtered in SQL) */ - if (!isAzureSqlDb && lastSampleTime.HasValue && sampleTime <= lastSampleTime.Value) - continue; - - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(GetServerNameForStorage(server)) - .AppendValue(sampleTime) - .AppendValue(reader.IsDBNull(1) ? 0 : reader.GetInt32(1)) - /* NULL = host/other CPU not derivable (SQL on Linux, Issue #1048) */ - .AppendValue(reader.IsDBNull(2) ? (int?)null : reader.GetInt32(2)) - .EndRow(); - - rowsCollected++; - } - } - } - - duckSw.Stop(); - _lastDuckDbMs = duckSw.ElapsedMilliseconds; - - _logger?.LogDebug("Collected {RowCount} CPU utilization samples for server '{Server}'", rowsCollected, server.DisplayName); - return rowsCollected; - } + private Task<int> CollectCpuUtilizationAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(CpuUtilizationCollector.Instance, server, cancellationToken); } diff --git a/Lite/Services/RemoteCollectorService.CpuSchedulerPlanCacheStats.cs b/Lite/Services/RemoteCollectorService.CpuSchedulerPlanCacheStats.cs new file mode 100644 index 000000000..d319cd805 --- /dev/null +++ b/Lite/Services/RemoteCollectorService.CpuSchedulerPlanCacheStats.cs @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Threading; +using System.Threading.Tasks; +using PerformanceMonitor.Collectors; +using PerformanceMonitorLite.Models; + +namespace PerformanceMonitorLite.Services; + +public partial class RemoteCollectorService +{ + /// <summary> + /// Collects the CPU scheduler / workload-group / NUMA / OS-memory snapshot via the shared + /// <see cref="CpuSchedulerStatsCollector"/> definition (query text and payload order live there — + /// the cross-SKU parity contract). Skips Azure SQL DB (definition's AppliesTo) because + /// sys.dm_os_sys_memory is not exposed and sys.dm_exec_requests is connection-scoped there. + /// </summary> + private Task<int> CollectCpuSchedulerStatsAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(CpuSchedulerStatsCollector.Instance, server, cancellationToken); + + /// <summary> + /// Collects the plan-cache composition snapshot from sys.dm_exec_cached_plans via the shared + /// <see cref="PlanCacheStatsCollector"/> definition (query text and payload order live there — + /// the cross-SKU parity contract). + /// </summary> + private Task<int> CollectPlanCacheStatsAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(PlanCacheStatsCollector.Instance, server, cancellationToken); +} diff --git a/Lite/Services/RemoteCollectorService.DatabaseSize.cs b/Lite/Services/RemoteCollectorService.DatabaseSize.cs index 738908cd3..0f2e0cc78 100644 --- a/Lite/Services/RemoteCollectorService.DatabaseSize.cs +++ b/Lite/Services/RemoteCollectorService.DatabaseSize.cs @@ -6,14 +6,9 @@ * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ -using System; -using System.Collections.Generic; -using System.Diagnostics; using System.Threading; using System.Threading.Tasks; -using DuckDB.NET.Data; -using Microsoft.Data.SqlClient; -using Microsoft.Extensions.Logging; +using PerformanceMonitor.Collectors; using PerformanceMonitorLite.Models; namespace PerformanceMonitorLite.Services; @@ -21,310 +16,11 @@ namespace PerformanceMonitorLite.Services; public partial class RemoteCollectorService { /// <summary> - /// Collects per-file database sizes for growth trending and capacity planning. - /// On-prem: queries sys.master_files + sys.databases + dm_os_volume_stats for file and drive context. - /// Azure SQL DB: queries sys.database_files for the single database (no volume stats available). + /// Collects per-file database sizes via the shared <see cref="DatabaseSizeStatsCollector"/> + /// definition (the server-side cursor + FILEPROPERTY staging batch, the two-site exclusion + /// splice, volume stats, and the Azure per-database variant live there — the cross-SKU + /// parity contract). /// </summary> - private async Task<int> CollectDatabaseSizeStatsAsync(ServerConnection server, CancellationToken cancellationToken) - { - var serverStatus = _serverManager.GetConnectionStatus(server.Id); - bool isAzureSqlDb = serverStatus?.SqlEngineEdition == 5; - - string onPremQuery = @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; -SET NOCOUNT ON; - -CREATE TABLE #file_space -( - database_id int NOT NULL, - file_id int NOT NULL, - used_size_mb decimal(19,2) NULL -); - -DECLARE - @db_name sysname, - @sql nvarchar(MAX); - -DECLARE db_cursor CURSOR LOCAL FAST_FORWARD FOR - SELECT - d.name - FROM sys.databases AS d - WHERE d.state_desc = N'ONLINE' - AND d.database_id > 0 - AND HAS_DBACCESS(d.name) = 1 - /*EXCLUSION_FILTER_CURSOR*/ - ORDER BY - d.name; - -OPEN db_cursor; -FETCH NEXT FROM db_cursor INTO @db_name; - -WHILE @@FETCH_STATUS = 0 -BEGIN - BEGIN TRY - SET @sql = N'EXECUTE ' + QUOTENAME(@db_name) + N'.sys.sp_executesql N'' -INSERT #file_space (database_id, file_id, used_size_mb) -SELECT - DB_ID(), - df.file_id, - CONVERT(decimal(19,2), FILEPROPERTY(df.name, N''''SpaceUsed'''') * 8.0 / 1024.0) -FROM sys.database_files AS df;'';'; - - EXECUTE sys.sp_executesql @sql; - END TRY - BEGIN CATCH - END CATCH; - - FETCH NEXT FROM db_cursor INTO @db_name; -END; - -CLOSE db_cursor; -DEALLOCATE db_cursor; - -SELECT - database_name = d.name, - database_id = d.database_id, - file_id = mf.file_id, - file_type_desc = mf.type_desc, - file_name = mf.name, - physical_name = mf.physical_name, - total_size_mb = - CONVERT(decimal(19,2), mf.size * 8.0 / 1024.0), - used_size_mb = - fs.used_size_mb, - auto_growth_mb = - CASE - WHEN mf.is_percent_growth = 1 - THEN CONVERT(decimal(19,2), NULL) - ELSE CONVERT(decimal(19,2), mf.growth * 8.0 / 1024.0) - END, - max_size_mb = - CASE - WHEN mf.max_size = -1 - THEN CONVERT(decimal(19,2), -1) - WHEN mf.max_size = 268435456 - THEN CONVERT(decimal(19,2), 2097152) - ELSE CONVERT(decimal(19,2), mf.max_size * 8.0 / 1024.0) - END, - recovery_model_desc = - d.recovery_model_desc, - compatibility_level = - CONVERT(int, d.compatibility_level), - state_desc = - d.state_desc, - volume_mount_point = - RTRIM(vs.volume_mount_point), - volume_total_mb = - CONVERT(decimal(19,2), vs.total_bytes / 1048576.0), - volume_free_mb = - CONVERT(decimal(19,2), vs.available_bytes / 1048576.0), - is_percent_growth = - mf.is_percent_growth, - growth_pct = - CASE WHEN mf.is_percent_growth = 1 THEN mf.growth ELSE NULL END, - vlf_count = - CASE WHEN mf.type = 1 /*LOG*/ THEN (SELECT COUNT(*) FROM sys.dm_db_log_info(mf.database_id) AS li WHERE li.file_id = mf.file_id) ELSE NULL END -FROM sys.master_files AS mf -JOIN sys.databases AS d - ON d.database_id = mf.database_id -CROSS APPLY sys.dm_os_volume_stats(mf.database_id, mf.file_id) AS vs -LEFT JOIN #file_space AS fs - ON fs.database_id = mf.database_id - AND fs.file_id = mf.file_id -WHERE d.state_desc = N'ONLINE' -/*EXCLUSION_FILTER_OUTER*/ -ORDER BY - d.name, - mf.file_id -OPTION(RECOMPILE);"; - - /* Both filter sites (cursor SELECT and final SELECT) are in outer T-SQL, not nested dynamic SQL, - so parameter bindings work fine and the same @excl_db_N can be referenced twice. */ - var (dbSizeExclusionClause, _) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); - onPremQuery = onPremQuery - .Replace("/*EXCLUSION_FILTER_CURSOR*/", dbSizeExclusionClause) - .Replace("/*EXCLUSION_FILTER_OUTER*/", dbSizeExclusionClause); - - const string azureSqlDbQuery = @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -SELECT - database_name = DB_NAME(), - database_id = DB_ID(), - file_id = df.file_id, - file_type_desc = df.type_desc, - file_name = df.name, - physical_name = df.physical_name, - total_size_mb = - CONVERT(decimal(19,2), df.size * 8.0 / 1024.0), - used_size_mb = - CONVERT(decimal(19,2), FILEPROPERTY(df.name, N'SpaceUsed') * 8.0 / 1024.0), - auto_growth_mb = - CASE - WHEN df.is_percent_growth = 1 - THEN CONVERT(decimal(19,2), NULL) - ELSE CONVERT(decimal(19,2), df.growth * 8.0 / 1024.0) - END, - max_size_mb = - CASE - WHEN df.max_size = -1 - THEN CONVERT(decimal(19,2), -1) - WHEN df.max_size = 268435456 - THEN CONVERT(decimal(19,2), 2097152) - ELSE CONVERT(decimal(19,2), df.max_size * 8.0 / 1024.0) - END, - recovery_model_desc = - CONVERT(nvarchar(12), DATABASEPROPERTYEX(DB_NAME(), N'Recovery')), - compatibility_level = - CONVERT(int, NULL), - state_desc = - N'ONLINE', - volume_mount_point = - CONVERT(nvarchar(256), NULL), - volume_total_mb = - CONVERT(decimal(19,2), NULL), - volume_free_mb = - CONVERT(decimal(19,2), NULL), - is_percent_growth = - df.is_percent_growth, - growth_pct = - CASE WHEN df.is_percent_growth = 1 THEN df.growth ELSE NULL END, - vlf_count = - CASE WHEN df.type = 1 /*LOG*/ THEN (SELECT COUNT(*) FROM sys.dm_db_log_info(DB_ID()) AS li WHERE li.file_id = df.file_id) ELSE NULL END -FROM sys.database_files AS df -ORDER BY - df.file_id -OPTION(RECOMPILE);"; - - var serverId = GetServerId(server); - var collectionTime = DateTime.UtcNow; - var rowsCollected = 0; - _lastSqlMs = 0; - _lastDuckDbMs = 0; - - var rows = new List<(string DatabaseName, int DatabaseId, int FileId, string FileTypeDesc, - string FileName, string PhysicalName, decimal TotalSizeMb, decimal? UsedSizeMb, - decimal? AutoGrowthMb, decimal? MaxSizeMb, string? RecoveryModel, - int? CompatibilityLevel, string? StateDesc, string? VolumeMountPoint, - decimal? VolumeTotalMb, decimal? VolumeFreeMb, bool? IsPercentGrowth, - int? GrowthPct, int? VlfCount)>(); - - var sqlSw = Stopwatch.StartNew(); - - if (isAzureSqlDb) - { - // Azure SQL DB: each database is isolated, so we must connect to each one individually. - var databases = await GetAzureDatabaseListAsync(server, cancellationToken); - - foreach (var dbName in databases) - { - try - { - using var dbConn = await OpenAzureDatabaseConnectionAsync(server, dbName, cancellationToken); - using var cmd = new SqlCommand(azureSqlDbQuery, dbConn); - cmd.CommandTimeout = CommandTimeoutSeconds; - using var reader = await cmd.ExecuteReaderAsync(cancellationToken); - while (await reader.ReadAsync(cancellationToken)) - { - rows.Add(ReadSizeRow(reader)); - } - } - catch (Exception ex) - { - _logger?.LogDebug("Skipping database '{Database}' for size collection: {Error}", dbName, ex.Message); - } - } - } - else - { - using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); - using var command = new SqlCommand(onPremQuery, sqlConnection); - command.CommandTimeout = CommandTimeoutSeconds; - var (_, dbSizeExclusionParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); - foreach (var p in dbSizeExclusionParams) command.Parameters.Add(p); - - using var reader = await command.ExecuteReaderAsync(cancellationToken); - while (await reader.ReadAsync(cancellationToken)) - { - rows.Add(ReadSizeRow(reader)); - } - } - sqlSw.Stop(); - - var duckSw = Stopwatch.StartNew(); - - using (var duckConnection = _duckDb.CreateConnection()) - { - await duckConnection.OpenAsync(cancellationToken); - - using (var appender = duckConnection.CreateAppender("database_size_stats")) - { - foreach (var r in rows) - { - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(GetServerNameForStorage(server)) - .AppendValue(r.DatabaseName) - .AppendValue(r.DatabaseId) - .AppendValue(r.FileId) - .AppendValue(r.FileTypeDesc) - .AppendValue(r.FileName) - .AppendValue(r.PhysicalName) - .AppendValue(r.TotalSizeMb) - .AppendValue(r.UsedSizeMb) - .AppendValue(r.AutoGrowthMb) - .AppendValue(r.MaxSizeMb) - .AppendValue(r.RecoveryModel) - .AppendValue(r.CompatibilityLevel) - .AppendValue(r.StateDesc) - .AppendValue(r.VolumeMountPoint) - .AppendValue(r.VolumeTotalMb) - .AppendValue(r.VolumeFreeMb) - .AppendValue(r.IsPercentGrowth) - .AppendValue(r.GrowthPct) - .AppendValue(r.VlfCount) - .EndRow(); - rowsCollected++; - } - } - } - - duckSw.Stop(); - _lastSqlMs = sqlSw.ElapsedMilliseconds; - _lastDuckDbMs = duckSw.ElapsedMilliseconds; - - _logger?.LogDebug("Collected {RowCount} database size rows for server '{Server}'", rowsCollected, server.DisplayName); - return rowsCollected; - } - - private static (string DatabaseName, int DatabaseId, int FileId, string FileTypeDesc, - string FileName, string PhysicalName, decimal TotalSizeMb, decimal? UsedSizeMb, - decimal? AutoGrowthMb, decimal? MaxSizeMb, string? RecoveryModel, - int? CompatibilityLevel, string? StateDesc, string? VolumeMountPoint, - decimal? VolumeTotalMb, decimal? VolumeFreeMb, bool? IsPercentGrowth, - int? GrowthPct, int? VlfCount) ReadSizeRow(SqlDataReader reader) - { - return ( - reader.GetString(0), - Convert.ToInt32(reader.GetValue(1)), - Convert.ToInt32(reader.GetValue(2)), - reader.GetString(3), - reader.GetString(4), - reader.GetString(5), - reader.GetDecimal(6), - reader.IsDBNull(7) ? null : reader.GetDecimal(7), - reader.IsDBNull(8) ? null : reader.GetDecimal(8), - reader.IsDBNull(9) ? null : reader.GetDecimal(9), - reader.IsDBNull(10) ? null : reader.GetString(10), - reader.IsDBNull(11) ? null : Convert.ToInt32(reader.GetValue(11)), - reader.IsDBNull(12) ? null : reader.GetString(12), - reader.IsDBNull(13) ? null : reader.GetString(13), - reader.IsDBNull(14) ? null : reader.GetDecimal(14), - reader.IsDBNull(15) ? null : reader.GetDecimal(15), - reader.IsDBNull(16) ? null : (bool?)(Convert.ToInt32(reader.GetValue(16)) == 1), - reader.IsDBNull(17) ? null : Convert.ToInt32(reader.GetValue(17)), - reader.IsDBNull(18) ? null : Convert.ToInt32(reader.GetValue(18))); - } + private Task<int> CollectDatabaseSizeStatsAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(DatabaseSizeStatsCollector.Instance, server, cancellationToken); } diff --git a/Lite/Services/RemoteCollectorService.Deadlocks.cs b/Lite/Services/RemoteCollectorService.Deadlocks.cs index 11fcefbe9..c740881f6 100644 --- a/Lite/Services/RemoteCollectorService.Deadlocks.cs +++ b/Lite/Services/RemoteCollectorService.Deadlocks.cs @@ -6,28 +6,27 @@ * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ -using System; using System.Data; -using System.Diagnostics; -using System.Linq; using System.Threading; using System.Threading.Tasks; -using System.Xml.Linq; using Microsoft.Data.SqlClient; -using Microsoft.Extensions.Logging; +using PerformanceMonitor.Collectors; using PerformanceMonitorLite.Models; namespace PerformanceMonitorLite.Services; public partial class RemoteCollectorService { - /* We create and manage our own XE session to avoid conflicts with user's existing sessions */ - private const string DeadlockXeSessionName = "PerformanceMonitor_Deadlock"; + /* The session name lives in the shared definition so the ring-buffer reader and this + lifecycle code can never disagree on it. */ + private const string DeadlockXeSessionName = DeadlocksCollector.XeSessionName; /// <summary> /// Ensures the deadlock XE session exists and is running. /// Creates a ring_buffer session for ALL platforms (on-prem, MI, Azure SQL DB, AWS RDS). - /// Uses server-scoped session for on-prem/MI/RDS, database-scoped for Azure SQL DB. + /// Server-scoped session for on-prem/MI/RDS; on Azure SQL DB a database-scoped session in + /// EVERY monitored database (#1535 — a single session only ever captured the connection's own + /// database, so deadlocks in the logical server's other databases never appeared). /// </summary> public async Task EnsureDeadlockXeSessionAsync(ServerConnection server, int engineEdition = 0, CancellationToken cancellationToken = default) { @@ -38,27 +37,33 @@ public async Task EnsureDeadlockXeSessionAsync(ServerConnection server, int engi return; } - bool isAzureSqlDb = engineEdition == 5; + if (engineEdition == 5) + { + /* Azure SQL DB: one database-scoped session per monitored database, matching the + per-database ring-buffer read (DeadlocksCollector.RunsPerDatabase). The shared driver + skips master, honors ExcludedDatabases via the shared database list, self-heals + sessions the reader can't see, and only surfaces unhealthy when NO database could be + ensured. */ + await EnsureDatabaseScopedXeSessionsAsync( + server, "deadlock", DeadlockXeSessionName, + EnsureDeadlockXeSessionAzureSqlDbAsync, cancellationToken); + return; + } try { using var connection = await CreateConnectionAsync(server, cancellationToken); - if (isAzureSqlDb) - { - /* Azure SQL DB: create database-scoped session with ring_buffer */ - await EnsureDeadlockXeSessionAzureSqlDbAsync(connection, cancellationToken); - } - else - { - /* On-prem, Azure MI, and AWS RDS: create server-scoped session with ring_buffer */ - await EnsureDeadlockXeSessionOnPremAsync(connection, server, cancellationToken); - } + /* On-prem, Azure MI, and AWS RDS: create server-scoped session with ring_buffer */ + await EnsureDeadlockXeSessionOnPremAsync(connection, server, cancellationToken); + } + catch (SqlException ex) when (IsBenignXeSessionAlreadyPresent(ex)) + { + /* Session already present + running -- see IsBenignXeSessionAlreadyPresent (#1251). */ + AppLogger.Info("XeSession", $"[{server.DisplayName}] Deadlock XE session already present (benign, #1251)"); } catch (SqlException ex) { - _logger?.LogWarning("Failed to ensure deadlock XE session on '{Server}': {Message}", - server.DisplayName, ex.Message); AppLogger.Error("XeSession", $"[{server.DisplayName}] Failed to ensure deadlock XE session: {ex.Message}"); /* Propagate so RunCollectorAsync marks the collector unhealthy instead @@ -98,20 +103,17 @@ LEFT JOIN sys.dm_xe_sessions AS dxs $"ALTER EVENT SESSION [{DeadlockXeSessionName}] ON SERVER STATE = START;", connection); startCmd.CommandTimeout = CommandTimeoutSeconds; await startCmd.ExecuteNonQueryAsync(cancellationToken); - _logger?.LogInformation("Started deadlock XE session on '{Server}'", server.DisplayName); AppLogger.Info("XeSession", $"[{server.DisplayName}] Started deadlock XE session"); } catch (SqlException ex) { - _logger?.LogWarning("Failed to start deadlock XE session on '{Server}': {Message}", - server.DisplayName, ex.Message); AppLogger.Error("XeSession", $"[{server.DisplayName}] Failed to start deadlock XE session: {ex.Message}"); throw; } } else { - _logger?.LogDebug("Deadlock XE session is running on '{Server}'", server.DisplayName); + AppLogger.Debug("XeSession", $"Deadlock XE session is running on '{server.DisplayName}'"); } return; } @@ -140,13 +142,10 @@ ADD TARGET package0.ring_buffer ALTER EVENT SESSION [{DeadlockXeSessionName}] ON SERVER STATE = START;", connection); createCmd.CommandTimeout = CommandTimeoutSeconds; await createCmd.ExecuteNonQueryAsync(cancellationToken); - _logger?.LogInformation("Created and started deadlock XE session on '{Server}'", server.DisplayName); AppLogger.Info("XeSession", $"[{server.DisplayName}] Created and started deadlock XE session"); } catch (SqlException ex) { - _logger?.LogWarning("Failed to create deadlock XE session on '{Server}': {Message}", - server.DisplayName, ex.Message); AppLogger.Error("XeSession", $"[{server.DisplayName}] Failed to create deadlock XE session: {ex.Message}"); throw; } @@ -200,11 +199,11 @@ Drop it so we can recreate with the correct event. */ $"DROP EVENT SESSION [{DeadlockXeSessionName}] ON DATABASE;", connection); dropCmd.CommandTimeout = CommandTimeoutSeconds; await dropCmd.ExecuteNonQueryAsync(cancellationToken); - AppLogger.Info("XeSession", $"[Azure SQL DB] Dropped deadlock XE session with incorrect event, will recreate"); + AppLogger.Info("XeSession", $"[Azure SQL DB:{connection.Database}] Dropped deadlock XE session with incorrect event, will recreate"); } catch (SqlException ex) { - AppLogger.Error("XeSession", $"[Azure SQL DB] Failed to drop old deadlock XE session: {ex.Message}"); + AppLogger.Error("XeSession", $"[Azure SQL DB:{connection.Database}] Failed to drop old deadlock XE session: {ex.Message}"); } /* Fall through to create with correct event */ } @@ -225,8 +224,8 @@ FROM sys.dm_xe_database_sessions AS xes startCmd.CommandTimeout = CommandTimeoutSeconds; await startCmd.ExecuteNonQueryAsync(cancellationToken); - _logger?.LogDebug("Deadlock XE session already exists (database-scoped, Azure SQL DB)"); - AppLogger.Info("XeSession", $"[Azure SQL DB] Deadlock XE session verified (database-scoped)"); + /* Debug, not Info: this fires once per monitored database per cycle (#1535). */ + AppLogger.Debug("XeSession", $"[Azure SQL DB:{connection.Database}] Deadlock XE session verified (database-scoped)"); return; } } @@ -255,222 +254,27 @@ ADD TARGET package0.ring_buffer await cmd.ExecuteNonQueryAsync(cancellationToken); } - _logger?.LogInformation("Created and started deadlock XE session (database-scoped, Azure SQL DB)"); - AppLogger.Info("XeSession", $"[Azure SQL DB] Created and started deadlock XE session (database-scoped)"); + AppLogger.Info("XeSession", $"[Azure SQL DB:{connection.Database}] Created and started deadlock XE session (database-scoped)"); } /// <summary> - /// Collects deadlock information from the PerformanceMonitor_Deadlock extended event session. - /// For on-prem/MI/RDS: reads from server-scoped session. - /// For Azure SQL DB: reads from database-scoped session. + /// Collects deadlocks via the shared <see cref="DeadlocksCollector"/> definition (the + /// server- vs database-scoped ring-buffer reads, the deadlock_time watermark, and the + /// victim-inputbuf extraction live there — the cross-SKU parity contract). The XE session + /// lifecycle stays here; a missing/inaccessible session is tolerated as zero rows, exactly + /// as before. /// </summary> private async Task<int> CollectDeadlocksAsync(ServerConnection server, CancellationToken cancellationToken) { - var serverStatus = _serverManager.GetConnectionStatus(server.Id); - bool isAzureSqlDb = serverStatus.SqlEngineEdition == 5; - - string query; - if (isAzureSqlDb) - { - /* Azure SQL DB: read from ring_buffer (database-scoped session) - Azure SQL DB uses database_xml_deadlock_report event instead of xml_deadlock_report. - Use .query() to get XML with structure intact, then CONVERT to nvarchar(max) */ - query = $@" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -DECLARE - @PerformanceMonitor_Deadlock TABLE -( - ring_buffer xml NOT NULL -); - -INSERT - @PerformanceMonitor_Deadlock -( - ring_buffer -) -SELECT /* PerformanceMonitorLite */ - ring_xml = TRY_CAST(xet.target_data AS xml) -FROM sys.dm_xe_database_session_targets AS xet -JOIN sys.dm_xe_database_sessions AS xes - ON xes.address = xet.event_session_address -WHERE xes.name = N'{DeadlockXeSessionName}' -AND xet.target_name = N'ring_buffer' -OPTION(RECOMPILE); - -SELECT - deadlock_time = evt.value('(@timestamp)[1]', 'datetime2'), - victim_process_id = evt.value('(data[@name=""xml_report""]/value/deadlock/victim-list/victimProcess/@id)[1]', 'varchar(50)'), - deadlock_graph_xml = CONVERT(nvarchar(max), evt.query('data[@name=""xml_report""]/value/deadlock')) -FROM -( - SELECT - pmd.ring_buffer - FROM @PerformanceMonitor_Deadlock AS pmd -) AS rb -CROSS APPLY rb.ring_buffer.nodes('RingBufferTarget/event[@name=""database_xml_deadlock_report""]') AS q(evt) -WHERE evt.value('(@timestamp)[1]', 'datetime2') > @cutoff_time -OPTION(RECOMPILE);"; - } - else - { - /* On-prem / Azure MI / AWS RDS: read from ring_buffer (server-scoped session) - Use .query() to get XML with structure intact, then CONVERT to nvarchar(max) */ - query = $@" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -DECLARE - @PerformanceMonitor_Deadlock TABLE -( - ring_buffer xml NOT NULL -); - -INSERT - @PerformanceMonitor_Deadlock -( - ring_buffer -) -SELECT /* PerformanceMonitorLite */ - ring_xml = TRY_CAST(xet.target_data AS xml) -FROM sys.dm_xe_session_targets AS xet -JOIN sys.dm_xe_sessions AS xes - ON xes.address = xet.event_session_address -WHERE xes.name = N'{DeadlockXeSessionName}' -AND xet.target_name = N'ring_buffer' -OPTION(RECOMPILE); - -SELECT - deadlock_time = evt.value('(@timestamp)[1]', 'datetime2'), - victim_process_id = evt.value('(data[@name=""xml_report""]/value/deadlock/victim-list/victimProcess/@id)[1]', 'varchar(50)'), - deadlock_graph_xml = CONVERT(nvarchar(max), evt.query('data[@name=""xml_report""]/value/deadlock')) -FROM -( - SELECT - pmd.ring_buffer - FROM @PerformanceMonitor_Deadlock AS pmd -) AS rb -CROSS APPLY rb.ring_buffer.nodes('RingBufferTarget/event[@name=""xml_deadlock_report""]') AS q(evt) -WHERE evt.value('(@timestamp)[1]', 'datetime2') > @cutoff_time -OPTION(RECOMPILE);"; - } - - var serverId = GetServerId(server); - var collectionTime = DateTime.UtcNow; - var rowsCollected = 0; - _lastSqlMs = 0; - _lastDuckDbMs = 0; - - /* Query the most recent deadlock_time we already have for this server. - Pass it to SQL Server so we only fetch events newer than what we've collected. - This prevents the same deadlock from being inserted multiple times as it - lingers in the ring buffer across collection cycles. */ - var lastCollectedTime = await GetLastCollectedTimeAsync( - serverId, "deadlocks", "deadlock_time", cancellationToken); - - var sqlSw = Stopwatch.StartNew(); - using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); - using var command = new SqlCommand(query, sqlConnection); - command.CommandTimeout = CommandTimeoutSeconds; - - /* Use the most recent timestamp from DuckDB as the cutoff, or fall back to 10-minute window */ - command.Parameters.Add(new SqlParameter("@cutoff_time", SqlDbType.DateTime2) { Value = lastCollectedTime ?? DateTime.UtcNow.AddMinutes(-10) }); - try { - using var reader = await command.ExecuteReaderAsync(cancellationToken); - sqlSw.Stop(); - _lastSqlMs = sqlSw.ElapsedMilliseconds; - - /* Read all rows and parse XML before starting DuckDB timing. - ExtractVictimSqlText does XElement.Parse which is expensive - and was previously misattributed as DuckDB time. */ - var deadlockRows = new System.Collections.Generic.List<(DateTime? deadlockTime, string? victimProcessId, string? victimSqlText, string? graphXml)>(); - while (await reader.ReadAsync(cancellationToken)) - { - var victimProcessId = reader.IsDBNull(1) ? null : reader.GetString(1); - var graphXml = reader.IsDBNull(2) ? null : reader.GetString(2); - var victimSqlText = ExtractVictimSqlText(graphXml, victimProcessId); - var deadlockTime = reader.IsDBNull(0) ? (DateTime?)null : reader.GetDateTime(0); - deadlockRows.Add((deadlockTime, victimProcessId, victimSqlText, graphXml)); - } - - var duckSw = Stopwatch.StartNew(); - - using (var duckConnection = _duckDb.CreateConnection()) - { - await duckConnection.OpenAsync(cancellationToken); - - using (var appender = duckConnection.CreateAppender("deadlocks")) - { - foreach (var (deadlockTime, victimProcessId, victimSqlText, graphXml) in deadlockRows) - { - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(GetServerNameForStorage(server)) - .AppendValue(deadlockTime) - .AppendValue(victimProcessId) - .AppendValue(victimSqlText) - .AppendValue(graphXml) - .EndRow(); - - rowsCollected++; - } - } - } - - duckSw.Stop(); - _lastDuckDbMs = duckSw.ElapsedMilliseconds; + return await RunCollectorDefinitionAsync(DeadlocksCollector.Instance, server, cancellationToken); } catch (SqlException ex) when (ex.Number == 297 || ex.Number == 15151 || ex.Message.Contains("XE session")) { /* XE session not found or not accessible */ - _logger?.LogDebug("Deadlock XE session not available on '{Server}': {Message}", - server.DisplayName, ex.Message); AppLogger.Info("XeSession", $"[{server.DisplayName}] Deadlock XE session not available: {ex.Message}"); return 0; } - - _logger?.LogDebug("Collected {RowCount} deadlocks for server '{Server}'", rowsCollected, server.DisplayName); - return rowsCollected; - } - - /// <summary> - /// Extracts victim SQL text from a deadlock graph XML fragment. - /// </summary> - private static string? ExtractVictimSqlText(string? graphXml, string? victimProcessId) - { - if (string.IsNullOrEmpty(graphXml)) - { - return null; - } - - try - { - var doc = XElement.Parse(graphXml); - - /* Find all process nodes in the deadlock graph */ - var processes = doc.Descendants("process").ToList(); - - /* If we have a victim ID, find that specific process */ - if (!string.IsNullOrEmpty(victimProcessId)) - { - var victim = processes.FirstOrDefault(p => - string.Equals(p.Attribute("id")?.Value, victimProcessId, StringComparison.OrdinalIgnoreCase)); - - if (victim != null) - { - return victim.Element("inputbuf")?.Value?.Trim(); - } - } - - /* Fallback: return the first process inputbuf */ - return processes.FirstOrDefault()?.Element("inputbuf")?.Value?.Trim(); - } - catch - { - return null; - } } } diff --git a/Lite/Services/RemoteCollectorService.DefaultTrace.cs b/Lite/Services/RemoteCollectorService.DefaultTrace.cs new file mode 100644 index 000000000..0f8af5195 --- /dev/null +++ b/Lite/Services/RemoteCollectorService.DefaultTrace.cs @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Threading; +using System.Threading.Tasks; +using PerformanceMonitor.Collectors; +using PerformanceMonitorLite.Models; + +namespace PerformanceMonitorLite.Services; + +public partial class RemoteCollectorService +{ + /// <summary> + /// Collects the built-in Default Trace events (file auto-grow/shrink stalls, severe ErrorLog writes, + /// schema DDL, security audits, Server Memory Change) via the shared + /// <see cref="DefaultTraceEventsCollector"/> definition — the <c>sys.traces</c> + + /// <c>sys.fn_trace_gettable</c> server-side read, the curated (config-slice-free) event set, and the + /// <c>event_time</c> dedup watermark all live there (the cross-SKU parity contract). Read-only: nothing + /// is created or reconfigured on the target, so a disabled default trace simply yields zero rows. Not + /// collected on Azure SQL DB (the definition's AppliesTo skips the cycle). + /// </summary> + private Task<int> CollectDefaultTraceEventsAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(DefaultTraceEventsCollector.Instance, server, cancellationToken); +} diff --git a/Lite/Services/RemoteCollectorService.DefinitionRunner.cs b/Lite/Services/RemoteCollectorService.DefinitionRunner.cs new file mode 100644 index 000000000..47d015d97 --- /dev/null +++ b/Lite/Services/RemoteCollectorService.DefinitionRunner.cs @@ -0,0 +1,437 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using DuckDB.NET.Data; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; +using PerformanceMonitor.Collectors; +using PerformanceMonitorLite.Models; + +namespace PerformanceMonitorLite.Services; + +public partial class RemoteCollectorService +{ + /// <summary> + /// Runs a shared collector definition (PerformanceMonitor.Collectors) against one server: + /// SQL phase (definition reads/filters rows) and storage phase (appender write with the + /// standard prefix columns) are timed separately, preserving the #1180 fetch-side metrics. + /// Collectors migrate onto this runner one PR at a time (headless plan v5.1); it reproduces + /// the hand-rolled per-collector loop byte-for-byte at the storage layer. + /// </summary> + private async Task<int> RunCollectorDefinitionAsync<TRow>( + ICollectorDefinition<TRow> definition, + ServerConnection server, + CancellationToken cancellationToken) + { + var serverId = GetServerId(server); + var collectionTime = DateTime.UtcNow; + _lastSqlMs = 0; + _lastDuckDbMs = 0; + + var status = _serverManager.GetConnectionStatus(server.Id); + var target = new CollectorTargetInfo + { + IsAzureSqlDb = status.SqlEngineEdition == 5, + IsAzureManagedInstance = status.SqlEngineEdition == 8, + IsAwsRds = status.IsAwsRds, + SqlMajorVersion = status.SqlMajorVersion, + HasMsdbAccess = status.HasMsdbAccess, + }; + + /* Some collectors don't exist on some targets (e.g. ring buffers on Azure SQL DB) — + skip the cycle entirely, matching the original hand-rolled collectors. */ + if (!definition.AppliesTo(target)) + { + return 0; + } + + /* Watermark = the host store's latest already-collected value of the definition's time + column (Darling reads Postgres here instead) — feeds server-side filters + client dedup. */ + DateTime? watermark = definition.WatermarkColumn is null + ? null + : await GetLastCollectedTimeAsync(serverId, definition.TargetTable, definition.WatermarkColumn, cancellationToken); + + /* Numeric (bigint) watermark = the host store's latest already-collected value of the definition's + monotonic identity column (job_history's instance_id) — the bigint twin of the timestamp watermark + above, for exact-and-complete dedup that survives server-side purges. Null for every collector that + declares no numeric watermark (the common case), so no extra query runs for them. */ + long? numericWatermark = definition.NumericWatermarkColumn is null + ? null + : await GetLastCollectedInstanceIdAsync(serverId, definition.TargetTable, definition.NumericWatermarkColumn, cancellationToken); + + /* Only when the watermark came back null (hot store empty): tell a TRUE first run from a store merely + emptied by archival, so a definition like default_trace_events doesn't re-scan source data already + in the parquet archive (CollectorContext.HasCollectedBefore). Skipped in the common (non-null + watermark) path — no extra query. */ + bool hasCollectedBefore = definition.WatermarkColumn is not null + && watermark is null + && await HasPriorCollectorSuccessAsync(serverId, definition.Name, cancellationToken); + + var context = new CollectorContext + { + ServerId = serverId, + ServerName = GetServerNameForStorage(server), + CollectionTime = collectionTime, + Deltas = _deltaCalculator, + Target = target, + Watermark = watermark, + NumericWatermark = numericWatermark, + HasCollectedBefore = hasCollectedBefore, + IgnoredWaitTypes = _ignoredWaitTypes.Value, + ExcludedDatabases = server.ExcludedDatabases?.ToArray() ?? Array.Empty<string>(), + PerfmonCounterOverride = GetPerfmonCounterOverride(), + }; + + /* Two accumulators, not one contiguous read-then-write pair: the enumeration and Azure paths now + FLUSH each database's rows before reading the next (#1556), so SQL and storage slices interleave. + _lastSqlMs / _lastDuckDbMs stay the #1180 fetch/store split — now sums of interleaved slices. */ + long sqlMs = 0; + long storageMs = 0; + var rowsWritten = 0; + + if (definition.RunsPerDatabase(context.Target)) + { + /* Azure SQL DB scopes some DMVs to the connected database — run the query once per + database, skipping (and debug-logging) databases that error, matching the original + hand-rolled collectors. + + Definitions with a database-scoped watermark (the XE ring-buffer collectors, whose + per-database sessions dispatch independently) get the query rebuilt per database + against that database's own newest already-collected value — the single server-wide + watermark would let one busy database's newer event silence another database's older + event still sitting in its ring buffer. Everything else keeps the build-once plan. */ + var plan = definition.PerDatabaseWatermarkColumn is null || definition.WatermarkColumn is null + ? definition.BuildQuery(context) + : null; + var commandTimeout = definition.CommandTimeoutSecondsOverride ?? CommandTimeoutSeconds; + var databases = await GetAzureDatabaseListAsync(server, cancellationToken); + + var attempted = 0; + var failed = 0; + Exception? firstFailure = null; + + /* One DuckDB connection for the whole body; one appender per database on it (disposing an + appender flushes that database — commit-1..N-1 semantics on abort). */ + using var duckConnection = _duckDb.CreateConnection(); + await duckConnection.OpenAsync(cancellationToken); + + foreach (var databaseName in databases) + { + cancellationToken.ThrowIfCancellationRequested(); + attempted++; + try + { + /* The authoritative database_name for XE rows read on this path — see + CollectorContext.CurrentDatabaseName. */ + context.CurrentDatabaseName = databaseName; + + var dbPlan = plan; + if (dbPlan is null) + { + /* Null (no rows for this database yet) falls back to the definition's + documented first-run window, per database. This is the XE ring-buffer path + (deadlocks / BPR), NOT query_store — no 24h clamp here. */ + context.Watermark = await GetLastCollectedTimeForDatabaseAsync( + serverId, definition.TargetTable, definition.WatermarkColumn!, + definition.PerDatabaseWatermarkColumn!, databaseName, cancellationToken); + dbPlan = definition.BuildQuery(context); + } + + var sqlSlice = Stopwatch.StartNew(); + List<TRow> batch; + using (var dbConnection = await OpenAzureDatabaseConnectionAsync(server, databaseName, cancellationToken)) + using (var dbCommand = CreateCollectorCommand(dbPlan, dbConnection, commandTimeout)) + using (var dbReader = await dbCommand.ExecuteReaderAsync(cancellationToken)) + { + batch = await definition.ReadAsync(dbReader, context, cancellationToken); + } + sqlMs += sqlSlice.ElapsedMilliseconds; + + /* Flush this database before reading the next — peak memory is one database's rows. */ + if (batch.Count > 0) + { + var storageSlice = Stopwatch.StartNew(); + rowsWritten += WriteBatch(duckConnection, definition, batch, serverId, context.ServerName, collectionTime, context); + storageMs += storageSlice.ElapsedMilliseconds; + } + } + catch (Exception ex) when (ex is not OperationCanceledException and not OutOfMemoryException) + { + /* OOM is filtered OUT of this per-database skip and propagates: it is fatal, not a + routine one-database miss. */ + failed++; + firstFailure ??= ex; + _logger?.LogDebug("Skipping database '{Database}' for {Collector}: {Error}", databaseName, definition.Name, ex.Message); + } + } + + context.CurrentDatabaseName = null; + + /* One database failing is routine (offline, mid-restore, a permissions oddity) and stays a + debug-logged skip. EVERY database failing is a systemic fault — before this check the + cycle recorded SUCCESS with zero rows, the silent-empty shape this codebase keeps paying + for (#1506's empty-list finding, #1535's invisible sessions). Rethrow the first failure so + RunCollectorAsync classifies it (PERMISSIONS / transient / ERROR) instead. */ + if (attempted > 0 && failed == attempted && firstFailure is not null) + { + _logger?.LogWarning("{Collector} failed in all {Count} database(s) on '{Server}'; surfacing the first failure", + definition.Name, attempted, server.DisplayName); + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(firstFailure).Throw(); + } + } + else + { + using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); + + var enumerationPlan = definition.BuildEnumerationQuery(context); + if (enumerationPlan is not null) + { + /* Enumeration shape (the [db].sys.sp_executesql idiom): list items first, then + run one query per item ON THE SAME CONNECTION; an item that fails with a + SqlException is skipped with a warning, matching the original collectors. */ + var listSlice = Stopwatch.StartNew(); + var items = new List<string>(); + /* Enumeration always uses the host default timeout, matching the originals — + the per-collector override applies only to the heavy per-item commands. */ + using (var enumerationCommand = CreateCollectorCommand(enumerationPlan, sqlConnection, CommandTimeoutSeconds)) + using (var enumerationReader = await enumerationCommand.ExecuteReaderAsync(cancellationToken)) + { + while (await enumerationReader.ReadAsync(cancellationToken)) + { + items.Add(enumerationReader.GetString(0)); + } + } + sqlMs += listSlice.ElapsedMilliseconds; + + if (items.Count == 0) + { + /* No items → no storage phase, matching the original's early return. */ + return 0; + } + + /* Optional quick scalar probe (e.g. query_store's live PRODUCTVERSION check, + deliberately probed per cycle rather than trusting cached status). Best-effort + on a 10-second budget, matching the original; failure leaves the definition on + its documented default via a null EnumerationProbeResult. */ + var probeSlice = Stopwatch.StartNew(); + var probePlan = definition.BuildEnumerationProbe(context); + if (probePlan is not null) + { + try + { + using var probeCommand = CreateCollectorCommand(probePlan, sqlConnection, 10); + var probeResult = await probeCommand.ExecuteScalarAsync(cancellationToken); + if (probeResult is not null && probeResult != DBNull.Value) + { + context.EnumerationProbeResult = probeResult; + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger?.LogDebug("Enumeration probe for {Collector} failed; using defaults: {Error}", + definition.Name, ex.Message); + } + } + sqlMs += probeSlice.ElapsedMilliseconds; + + var itemTimeout = definition.CommandTimeoutSecondsOverride ?? CommandTimeoutSeconds; + + /* One DuckDB connection for the whole body; the driver writes one appender per database + on it, flushing each before reading the next. */ + using var duckConnection = _duckDb.CreateConnection(); + await duckConnection.OpenAsync(cancellationToken); + + var driverResult = await EnumeratedCollectorDriver.RunAsync<TRow>( + items, + /* Per-database watermark refresh + the 24h catch-up clamp, computed INSIDE the loop — + this is the per-item cutoff site the plan's LOUD FLAG requires the clamp to live at. + Only query_store (the sole enumeration collector with a per-database timestamp + watermark) reaches this; the two snapshot collectors are watermark-less. */ + perItemWatermark: definition.PerDatabaseWatermarkColumn is null || definition.WatermarkColumn is null + ? null + : async (item, ct) => + { + var raw = await GetLastCollectedTimeForDatabaseAsync( + serverId, definition.TargetTable, definition.WatermarkColumn!, + definition.PerDatabaseWatermarkColumn!, item, ct); + var clamped = WatermarkPolicy.ClampCatchup(raw, collectionTime); + if (raw.HasValue && clamped != raw) + { + _logger?.LogWarning( + "{Collector} on '{Server}' database [{Database}] catch-up clamped to {Hours}h (stored watermark {Raw:o} is older) — a bounded, logged history hole.", + definition.Name, server.DisplayName, item, WatermarkPolicy.MaxCatchup.TotalHours, raw.Value); + } + context.Watermark = clamped; + }, + readItem: async (item, ct) => + { + var batch = new List<TRow>(); + using var itemCommand = CreateCollectorCommand(definition.BuildPerItemQuery(item, context), sqlConnection, itemTimeout); + using var itemReader = await itemCommand.ExecuteReaderAsync(ct); + await definition.ReadItemAsync(item, itemReader, batch, context, ct); + return batch; + }, + writeBatch: (batch, ct) => Task.FromResult(WriteBatch(duckConnection, definition, batch, serverId, context.ServerName, collectionTime, context)), + onItemComplete: (item, batchCount, itemSqlMs, itemStorageMs) => + { + /* Per-DATABASE line for non-empty batches (#1565): the per-server summary blends + every database into one number, hiding a single busy database's burst behind + quiet siblings. Quiet databases (0 rows) stay silent. */ + if (batchCount > 0) + { + _logger?.LogInformation(" [{Server}] {Collector} [{Database}] => {Rows} rows (sql:{SqlMs}ms, duckdb:{DuckMs}ms)", + server.DisplayName, definition.Name, item, batchCount, itemSqlMs, itemStorageMs); + } + + var capHit = definition.PerItemRowCountWarnThreshold is int cap && batchCount >= cap; + if (capHit || context.PerItemTextBudgetExceeded) + { + _logger?.LogWarning( + "{Collector} on '{Server}' database [{Database}] hit its per-database collection bound ({Reason}) — oldest rows dropped this cycle.", + definition.Name, server.DisplayName, item, + capHit ? $"row cap {definition.PerItemRowCountWarnThreshold}" : "256MB text budget"); + } + }, + onItemError: (item, ex) => + _logger?.LogWarning("Failed to collect {Collector} from [{Database}] on '{Server}': {Message}", + definition.Name, item, server.DisplayName, ex.Message), + cancellationToken); + + rowsWritten = driverResult.Rows; + sqlMs += driverResult.SqlMs; + storageMs += driverResult.StorageMs; + } + else + { + /* Plain single-query path — unchanged: read all rows, then write them in one batch + (supplemental never runs for per-database collectors). Routed through WriteBatch so + all three paths share one writer. */ + var sqlSlice = Stopwatch.StartNew(); + var plan = definition.BuildQuery(context); + List<TRow> rows; + using (var command = CreateCollectorCommand(plan, sqlConnection, definition.CommandTimeoutSecondsOverride ?? CommandTimeoutSeconds)) + using (var reader = await command.ExecuteReaderAsync(cancellationToken)) + { + rows = await definition.ReadAsync(reader, context, cancellationToken); + } + + /* Optional best-effort second query on the same connection (e.g. server_properties' + WS5 health probe). Failure-isolated: it can never fail the primary rows. Skipped + when the primary produced no rows, matching the originals (which only ran their + second query after a successful primary read). */ + var supplementalPlan = definition.BuildSupplementalQuery(context); + if (supplementalPlan is not null && rows.Count > 0) + { + try + { + using var supplementalCommand = CreateCollectorCommand(supplementalPlan, sqlConnection, CommandTimeoutSeconds); + using var supplementalReader = await supplementalCommand.ExecuteReaderAsync(cancellationToken); + await definition.ApplySupplementalAsync(rows, supplementalReader, context, cancellationToken); + } + catch (Exception ex) + { + _logger?.LogDebug(ex, "Supplemental query for {Collector} failed; continuing without it", definition.Name); + } + } + sqlMs += sqlSlice.ElapsedMilliseconds; + + var storageSlice = Stopwatch.StartNew(); + using var duckConnection = _duckDb.CreateConnection(); + await duckConnection.OpenAsync(cancellationToken); + rowsWritten = WriteBatch(duckConnection, definition, rows, serverId, context.ServerName, collectionTime, context); + storageMs += storageSlice.ElapsedMilliseconds; + } + } + + _lastSqlMs = sqlMs; + _lastDuckDbMs = storageMs; + + _logger?.LogDebug("Collected {RowCount} {Collector} rows for server '{Server}'", rowsWritten, definition.Name, server.DisplayName); + return rowsWritten; + } + + /// <summary> + /// Writes ONE batch (one enumerated item / one database, or the whole result set for a plain + /// collector) to DuckDB via a single appender on the caller's already-open connection (#1556). The + /// three collection paths route through here so the storage logic — the prefix columns, the positional + /// payload — lives once. Disposing the appender FLUSHES the batch, so on a mid-run abort the batches + /// already written stay committed (commit-1..N-1). An empty batch opens no appender and returns 0 + /// (rows_collected = Σ non-empty batch counts). Synchronous (the DuckDB appender API is), returning the + /// count so the driver can await it as a completed task. + /// </summary> + private static int WriteBatch<TRow>( + DuckDBConnection duckConnection, + ICollectorDefinition<TRow> definition, + List<TRow> rows, + int serverId, + string serverName, + DateTime collectionTime, + CollectorContext context) + { + if (rows.Count == 0) + { + return 0; + } + + var rowsWritten = 0; + using (var appender = duckConnection.CreateAppender(definition.TargetTable)) + { + var writer = new AppenderCollectorRowWriter(); + + foreach (var item in rows) + { + var row = appender.CreateRow(); + + if (definition.IncludesCollectionId) + { + row.AppendValue(GenerateCollectionId()); /* collection_id BIGINT */ + } + + row.AppendValue(collectionTime) /* collection_time TIMESTAMP */ + .AppendValue(serverId) /* server_id INTEGER */ + .AppendValue(serverName); /* server_name VARCHAR */ + + writer.CurrentRow = row; + definition.WritePayload(item, writer, context); + row.EndRow(); + + rowsWritten++; + } + } + + return rowsWritten; + } + + private static SqlCommand CreateCollectorCommand(CollectorQuery plan, SqlConnection connection, int commandTimeoutSeconds) + { + var command = new SqlCommand(plan.Text, connection) { CommandTimeout = commandTimeoutSeconds }; + + foreach (var parameter in plan.Parameters) + { + command.Parameters.Add(ToSqlParameter(parameter)); + } + + return command; + } + + private static SqlParameter ToSqlParameter(CollectorParameter parameter) => parameter.Type switch + { + CollectorParameterType.DateTime2 => new SqlParameter(parameter.Name, SqlDbType.DateTime2) { Value = parameter.Value ?? DBNull.Value }, + CollectorParameterType.NVarChar128 => new SqlParameter(parameter.Name, SqlDbType.NVarChar, 128) { Value = parameter.Value ?? DBNull.Value }, + CollectorParameterType.Int32 => new SqlParameter(parameter.Name, SqlDbType.Int) { Value = parameter.Value ?? DBNull.Value }, + CollectorParameterType.BigInt => new SqlParameter(parameter.Name, SqlDbType.BigInt) { Value = parameter.Value ?? DBNull.Value }, + _ => throw new ArgumentOutOfRangeException(nameof(parameter), parameter.Type, "Unmapped collector parameter type"), + }; +} diff --git a/Lite/Services/RemoteCollectorService.DmvBlockingSnapshot.cs b/Lite/Services/RemoteCollectorService.DmvBlockingSnapshot.cs index f4ca2c2b7..02d0415d9 100644 --- a/Lite/Services/RemoteCollectorService.DmvBlockingSnapshot.cs +++ b/Lite/Services/RemoteCollectorService.DmvBlockingSnapshot.cs @@ -6,13 +6,9 @@ * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ -using System; -using System.Diagnostics; using System.Threading; using System.Threading.Tasks; -using DuckDB.NET.Data; -using Microsoft.Data.SqlClient; -using Microsoft.Extensions.Logging; +using PerformanceMonitor.Collectors; using PerformanceMonitorLite.Models; namespace PerformanceMonitorLite.Services; @@ -20,188 +16,10 @@ namespace PerformanceMonitorLite.Services; public partial class RemoteCollectorService { /// <summary> - /// Always-on DMV blocking snapshot. Captures current blocking (blocker -> blocked edges) from - /// sys.dm_os_waiting_tasks + sys.dm_exec_*, the BPR-independent fallback for when the - /// blocked_process_report XE captured nothing (blocked process threshold unset, e.g. AWS RDS). - /// Same blocker/blocked pair-row shape the blocking-chain reconstruction consumes; monitor_loop is - /// synthesized NEGATIVE so a DMV episode can never collide with a real (non-negative) BPR monitorLoop. + /// Collects the always-on DMV blocking snapshot via the shared + /// <see cref="DmvBlockingSnapshotCollector"/> definition (the layered minimum-wait floors and + /// the synthetic negative monitor_loop live there — the cross-SKU parity contract). /// </summary> - private async Task<int> CollectDmvBlockingSnapshotAsync(ServerConnection server, CancellationToken cancellationToken) - { - string query = @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -SELECT /* PerformanceMonitorLite */ - database_name = DB_NAME(der_b.database_id), - blocked_spid = CONVERT(integer, wt.session_id), - blocked_ecid = CONVERT(integer, wt.exec_context_id), - blocked_last_tran_started = tat_b.transaction_begin_time, - blocking_spid = CONVERT(integer, wt.blocking_session_id), - blocking_ecid = CONVERT(integer, ISNULL(wt.blocking_exec_context_id, 0)), - blocking_last_tran_started = tat_k.transaction_begin_time, - wait_time_ms = wt.wait_duration_ms, - lock_mode = REPLACE(REPLACE(REPLACE(wt.wait_type, N'LCK_M_', N''), N'PAGEIOLATCH_', N''), N'PAGELATCH_', N''), - blocking_status = ISNULL(der_k.status, ses_k.status), - contentious_object = - CASE - WHEN objparse.object_id IS NOT NULL - THEN ISNULL(QUOTENAME(OBJECT_SCHEMA_NAME(objparse.object_id, objparse.database_id)) + N'.' + QUOTENAME(OBJECT_NAME(objparse.object_id, objparse.database_id)), der_b.wait_resource) - ELSE der_b.wait_resource - END, - blocked_sql_text = dest_b.text, - blocking_sql_text = dest_k.text, - blocked_login_name = ses_b.login_name, - blocked_host_name = ses_b.host_name, - blocked_client_app = ses_b.program_name, - blocking_login_name = ses_k.login_name, - blocking_host_name = ses_k.host_name, - blocking_client_app = ses_k.program_name -FROM sys.dm_os_waiting_tasks AS wt -JOIN sys.dm_exec_sessions AS ses_b - ON ses_b.session_id = wt.session_id -LEFT JOIN sys.dm_exec_requests AS der_b - ON der_b.session_id = wt.session_id -LEFT JOIN sys.dm_exec_sessions AS ses_k - ON ses_k.session_id = wt.blocking_session_id -LEFT JOIN sys.dm_exec_requests AS der_k - ON der_k.session_id = wt.blocking_session_id -LEFT JOIN sys.dm_exec_connections AS con_k - ON con_k.session_id = wt.blocking_session_id -OUTER APPLY sys.dm_exec_sql_text(der_b.sql_handle) AS dest_b -OUTER APPLY sys.dm_exec_sql_text(COALESCE(der_k.sql_handle, con_k.most_recent_sql_handle)) AS dest_k -OUTER APPLY -( - SELECT TOP (1) tat.transaction_begin_time - FROM sys.dm_tran_session_transactions AS stx - JOIN sys.dm_tran_active_transactions AS tat - ON tat.transaction_id = stx.transaction_id - WHERE stx.session_id = wt.session_id - ORDER BY tat.transaction_begin_time ASC -) AS tat_b -OUTER APPLY -( - SELECT TOP (1) tat.transaction_begin_time - FROM sys.dm_tran_session_transactions AS stx - JOIN sys.dm_tran_active_transactions AS tat - ON tat.transaction_id = stx.transaction_id - WHERE stx.session_id = wt.blocking_session_id - ORDER BY tat.transaction_begin_time ASC -) AS tat_k -OUTER APPLY -( - SELECT - database_id = TRY_CONVERT(integer, PARSENAME(REPLACE(SUBSTRING(der_b.wait_resource, 9, 200), N':', N'.'), 3)), - object_id = TRY_CONVERT(integer, PARSENAME(REPLACE(SUBSTRING(der_b.wait_resource, 9, 200), N':', N'.'), 2)) - WHERE der_b.wait_resource LIKE N'OBJECT: %' -) AS objparse -WHERE wt.blocking_session_id IS NOT NULL -AND wt.blocking_session_id <> 0 -AND wt.blocking_session_id <> wt.session_id -AND wt.session_id > 50 -AND wt.session_id <> @@SPID -AND ( - wt.wait_type LIKE N'LCK[_]%' - OR wt.wait_type LIKE N'PAGELATCH[_]%' - OR wt.wait_type LIKE N'PAGEIOLATCH[_]%' - OR wt.wait_type LIKE N'RESOURCE_SEMAPHORE%' - ) -/* Layered minimum-wait floor (kept in lockstep with install/56): locks must persist to matter (2s); page - latches churn faster (PAGELATCH 0.5s, PAGEIOLATCH 1s); memory-grant / compile-gate waits run long, so 5s. */ -AND wt.wait_duration_ms >= - CASE - WHEN wt.wait_type LIKE N'LCK[_]%' THEN 2000 - WHEN wt.wait_type LIKE N'PAGELATCH[_]%' THEN 500 - WHEN wt.wait_type LIKE N'PAGEIOLATCH[_]%' THEN 1000 - WHEN wt.wait_type LIKE N'RESOURCE_SEMAPHORE%' THEN 5000 - ELSE 1000 - END -OPTION(RECOMPILE);"; - - var serverId = GetServerId(server); - var collectionTime = DateTime.UtcNow; - /* Synthetic monitor_loop, NEGATIVE so it can never collide with a real (non-negative) BPR - monitorLoop. One value per cycle, distinct per second (collection cadence is >= 1 minute). */ - var monitorLoop = -(int)(collectionTime - new DateTime(2020, 1, 1)).TotalSeconds; - var rowsCollected = 0; - _lastSqlMs = 0; - _lastDuckDbMs = 0; - - var sqlSw = Stopwatch.StartNew(); - using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); - using var command = new SqlCommand(query, sqlConnection); - command.CommandTimeout = CommandTimeoutSeconds; - - using var reader = await command.ExecuteReaderAsync(cancellationToken); - sqlSw.Stop(); - _lastSqlMs = sqlSw.ElapsedMilliseconds; - - var duckSw = Stopwatch.StartNew(); - - using (var duckConnection = _duckDb.CreateConnection()) - { - await duckConnection.OpenAsync(cancellationToken); - - using (var appender = duckConnection.CreateAppender("dmv_blocking_snapshots")) - { - while (await reader.ReadAsync(cancellationToken)) - { - var databaseName = reader.IsDBNull(0) ? null : reader.GetString(0); - var blockedSpid = reader.IsDBNull(1) ? 0 : reader.GetInt32(1); - var blockedEcid = reader.IsDBNull(2) ? 0 : reader.GetInt32(2); - var blockedLastTran = reader.IsDBNull(3) ? (DateTime?)null : reader.GetDateTime(3); - var blockingSpid = reader.IsDBNull(4) ? 0 : reader.GetInt32(4); - var blockingEcid = reader.IsDBNull(5) ? 0 : reader.GetInt32(5); - var blockingLastTran = reader.IsDBNull(6) ? (DateTime?)null : reader.GetDateTime(6); - var waitTimeMs = reader.IsDBNull(7) ? 0L : reader.GetInt64(7); - var lockMode = reader.IsDBNull(8) ? null : reader.GetString(8); - var blockingStatus = reader.IsDBNull(9) ? null : reader.GetString(9); - var contentiousObject = reader.IsDBNull(10) ? null : reader.GetString(10); - var blockedSql = reader.IsDBNull(11) ? null : reader.GetString(11); - var blockingSql = reader.IsDBNull(12) ? null : reader.GetString(12); - var blockedLogin = reader.IsDBNull(13) ? null : reader.GetString(13); - var blockedHost = reader.IsDBNull(14) ? null : reader.GetString(14); - var blockedApp = reader.IsDBNull(15) ? null : reader.GetString(15); - var blockingLogin = reader.IsDBNull(16) ? null : reader.GetString(16); - var blockingHost = reader.IsDBNull(17) ? null : reader.GetString(17); - var blockingApp = reader.IsDBNull(18) ? null : reader.GetString(18); - - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(GetServerNameForStorage(server)) - .AppendValue(monitorLoop) - .AppendValue(collectionTime) /* event_time */ - .AppendValue(databaseName) - .AppendValue(blockedSpid) - .AppendValue(blockedEcid) - .AppendValue(blockedLastTran) - .AppendValue(blockingSpid) - .AppendValue(blockingEcid) - .AppendValue(blockingLastTran) - .AppendValue(waitTimeMs) - .AppendValue(lockMode) - .AppendValue(blockingStatus) - .AppendValue(contentiousObject) - .AppendValue(blockedSql) - .AppendValue(blockingSql) - .AppendValue(blockedLogin) - .AppendValue(blockedHost) - .AppendValue(blockedApp) - .AppendValue(blockingLogin) - .AppendValue(blockingHost) - .AppendValue(blockingApp) - .EndRow(); - - rowsCollected++; - } - } - } - - duckSw.Stop(); - _lastDuckDbMs = duckSw.ElapsedMilliseconds; - - _logger?.LogDebug("Collected {RowCount} DMV blocking snapshot rows for server '{Server}'", rowsCollected, server.DisplayName); - return rowsCollected; - } + private Task<int> CollectDmvBlockingSnapshotAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(DmvBlockingSnapshotCollector.Instance, server, cancellationToken); } diff --git a/Lite/Services/RemoteCollectorService.FileIo.cs b/Lite/Services/RemoteCollectorService.FileIo.cs index a1ac8a8e6..3823070e2 100644 --- a/Lite/Services/RemoteCollectorService.FileIo.cs +++ b/Lite/Services/RemoteCollectorService.FileIo.cs @@ -6,14 +6,9 @@ * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ -using System; -using System.Collections.Generic; -using System.Diagnostics; using System.Threading; using System.Threading.Tasks; -using DuckDB.NET.Data; -using Microsoft.Data.SqlClient; -using Microsoft.Extensions.Logging; +using PerformanceMonitor.Collectors; using PerformanceMonitorLite.Models; namespace PerformanceMonitorLite.Services; @@ -21,205 +16,10 @@ namespace PerformanceMonitorLite.Services; public partial class RemoteCollectorService { /// <summary> - /// Collects file I/O statistics from sys.dm_io_virtual_file_stats. + /// Collects file I/O statistics via the shared <see cref="FileIoStatsCollector"/> definition + /// (on-prem server-wide vs. Azure per-database execution, the exclusion-filter splice, and + /// the eight "{database}|{file}" delta groups live there — the cross-SKU parity contract). /// </summary> - private async Task<int> CollectFileIoStatsAsync(ServerConnection server, CancellationToken cancellationToken) - { - var serverStatus = _serverManager.GetConnectionStatus(server.Id); - /* Azure SQL DB (edition 5) doesn't have sys.master_files; use sys.database_files instead. - dm_io_virtual_file_stats on Azure SQL DB is scoped to the current database only. - Azure MI (edition 8) HAS sys.master_files and behaves like on-prem. */ - bool isAzureSqlDb = serverStatus.SqlEngineEdition == 5; - - string query = isAzureSqlDb - ? @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -SELECT - database_name = DB_NAME(), - file_name = df.name, - file_type = df.type_desc, - physical_name = df.physical_name, - size_mb = CONVERT(decimal(18,2), vfs.size_on_disk_bytes / 1048576.0), - num_of_reads = vfs.num_of_reads, - num_of_writes = vfs.num_of_writes, - read_bytes = vfs.num_of_bytes_read, - write_bytes = vfs.num_of_bytes_written, - io_stall_read_ms = vfs.io_stall_read_ms, - io_stall_write_ms = vfs.io_stall_write_ms, - io_stall_queued_read_ms = vfs.io_stall_queued_read_ms, - io_stall_queued_write_ms = vfs.io_stall_queued_write_ms, - database_id = vfs.database_id, - file_id = vfs.file_id -FROM sys.dm_io_virtual_file_stats(DB_ID(), NULL) AS vfs -LEFT JOIN sys.database_files AS df - ON df.file_id = vfs.file_id -OPTION(RECOMPILE);" - : @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -SELECT - database_name = ISNULL(d.name, DB_NAME(vfs.database_id)), - file_name = ISNULL(mf.name, N'File_' + CONVERT(nvarchar(10), vfs.file_id)), - file_type = ISNULL(mf.type_desc, N'UNKNOWN'), - physical_name = ISNULL(mf.physical_name, N''), - size_mb = CONVERT(decimal(18,2), vfs.size_on_disk_bytes / 1048576.0), - num_of_reads = vfs.num_of_reads, - num_of_writes = vfs.num_of_writes, - read_bytes = vfs.num_of_bytes_read, - write_bytes = vfs.num_of_bytes_written, - io_stall_read_ms = vfs.io_stall_read_ms, - io_stall_write_ms = vfs.io_stall_write_ms, - io_stall_queued_read_ms = vfs.io_stall_queued_read_ms, - io_stall_queued_write_ms = vfs.io_stall_queued_write_ms, - database_id = vfs.database_id, - file_id = vfs.file_id -FROM sys.dm_io_virtual_file_stats(NULL, NULL) AS vfs -LEFT JOIN sys.master_files AS mf - ON mf.database_id = vfs.database_id - AND mf.file_id = vfs.file_id -LEFT JOIN sys.databases AS d - ON d.database_id = vfs.database_id -WHERE (vfs.database_id > 4 OR vfs.database_id = 2) -AND vfs.database_id < 32761 -AND vfs.database_id <> ISNULL(DB_ID(N'PerformanceMonitor'), 0) -/*EXCLUSION_FILTER*/ -OPTION(RECOMPILE);"; - - /* Azure path filters via GetAzureDatabaseListAsync; on-prem path injects here */ - var (fileIoExclusionClause, _) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); - query = query.Replace("/*EXCLUSION_FILTER*/", isAzureSqlDb ? string.Empty : fileIoExclusionClause); - - var serverId = GetServerId(server); - var collectionTime = DateTime.UtcNow; - var rowsCollected = 0; - _lastSqlMs = 0; - _lastDuckDbMs = 0; - - var sqlSw = Stopwatch.StartNew(); - - /* Collect all rows first */ - var fileStats = new List<( - string DatabaseName, string FileName, string FileType, string PhysicalName, - decimal SizeMb, long NumOfReads, long NumOfWrites, long ReadBytes, long WriteBytes, - long IoStallReadMs, long IoStallWriteMs, long IoStallQueuedReadMs, long IoStallQueuedWriteMs, - int DatabaseId, int FileId)>(); - - if (isAzureSqlDb) - { - // Azure SQL DB: dm_io_virtual_file_stats is scoped to the connected database, - // so we must connect to each database individually. - var databases = await GetAzureDatabaseListAsync(server, cancellationToken); - foreach (var dbName in databases) - { - try - { - using var dbConn = await OpenAzureDatabaseConnectionAsync(server, dbName, cancellationToken); - using var cmd = new SqlCommand(query, dbConn) { CommandTimeout = CommandTimeoutSeconds }; - using var reader = await cmd.ExecuteReaderAsync(cancellationToken); - while (await reader.ReadAsync(cancellationToken)) - fileStats.Add(ReadFileIoRow(reader)); - } - catch (Exception ex) - { - _logger?.LogDebug("Skipping database '{Database}' for file I/O: {Error}", dbName, ex.Message); - } - } - } - else - { - using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); - using var command = new SqlCommand(query, sqlConnection) { CommandTimeout = CommandTimeoutSeconds }; - var (_, fileIoExclusionParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); - foreach (var p in fileIoExclusionParams) command.Parameters.Add(p); - using var reader = await command.ExecuteReaderAsync(cancellationToken); - while (await reader.ReadAsync(cancellationToken)) - fileStats.Add(ReadFileIoRow(reader)); - } - sqlSw.Stop(); - - /* Insert into DuckDB with delta calculations */ - var duckSw = Stopwatch.StartNew(); - - using (var duckConnection = _duckDb.CreateConnection()) - { - await duckConnection.OpenAsync(cancellationToken); - - using (var appender = duckConnection.CreateAppender("file_io_stats")) - { - foreach (var stat in fileStats) - { - var deltaKey = $"{stat.DatabaseName}|{stat.FileName}"; - var deltaReads = _deltaCalculator.CalculateDelta(serverId, "file_io_reads", deltaKey, stat.NumOfReads, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaWrites = _deltaCalculator.CalculateDelta(serverId, "file_io_writes", deltaKey, stat.NumOfWrites, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaReadBytes = _deltaCalculator.CalculateDelta(serverId, "file_io_read_bytes", deltaKey, stat.ReadBytes, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaWriteBytes = _deltaCalculator.CalculateDelta(serverId, "file_io_write_bytes", deltaKey, stat.WriteBytes, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaStallReadMs = _deltaCalculator.CalculateDelta(serverId, "file_io_stall_read", deltaKey, stat.IoStallReadMs, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaStallWriteMs = _deltaCalculator.CalculateDelta(serverId, "file_io_stall_write", deltaKey, stat.IoStallWriteMs, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaStallQueuedReadMs = _deltaCalculator.CalculateDelta(serverId, "file_io_stall_queued_read", deltaKey, stat.IoStallQueuedReadMs, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaStallQueuedWriteMs = _deltaCalculator.CalculateDelta(serverId, "file_io_stall_queued_write", deltaKey, stat.IoStallQueuedWriteMs, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(GetServerNameForStorage(server)) - .AppendValue(stat.DatabaseName) - .AppendValue(stat.FileName) - .AppendValue(stat.FileType) - .AppendValue(stat.PhysicalName) - .AppendValue(stat.SizeMb) - .AppendValue(stat.NumOfReads) - .AppendValue(stat.NumOfWrites) - .AppendValue(stat.ReadBytes) - .AppendValue(stat.WriteBytes) - .AppendValue(stat.IoStallReadMs) - .AppendValue(stat.IoStallWriteMs) - .AppendValue(stat.IoStallQueuedReadMs) - .AppendValue(stat.IoStallQueuedWriteMs) - .AppendValue(deltaReads) - .AppendValue(deltaWrites) - .AppendValue(deltaReadBytes) - .AppendValue(deltaWriteBytes) - .AppendValue(deltaStallReadMs) - .AppendValue(deltaStallWriteMs) - .AppendValue(deltaStallQueuedReadMs) - .AppendValue(deltaStallQueuedWriteMs) - .EndRow(); - - rowsCollected++; - } - } - } - - duckSw.Stop(); - _lastSqlMs = sqlSw.ElapsedMilliseconds; - _lastDuckDbMs = duckSw.ElapsedMilliseconds; - - _logger?.LogDebug("Collected {RowCount} file I/O stats for server '{Server}'", rowsCollected, server.DisplayName); - return rowsCollected; - } - - private static (string DatabaseName, string FileName, string FileType, string PhysicalName, - decimal SizeMb, long NumOfReads, long NumOfWrites, long ReadBytes, long WriteBytes, - long IoStallReadMs, long IoStallWriteMs, long IoStallQueuedReadMs, long IoStallQueuedWriteMs, - int DatabaseId, int FileId) ReadFileIoRow(SqlDataReader reader) - { - return ( - DatabaseName: reader.IsDBNull(0) ? "Unknown" : reader.GetString(0), - FileName: reader.IsDBNull(1) ? "Unknown" : reader.GetString(1), - FileType: reader.IsDBNull(2) ? "Unknown" : reader.GetString(2), - PhysicalName: reader.IsDBNull(3) ? "" : reader.GetString(3), - SizeMb: reader.IsDBNull(4) ? 0m : reader.GetDecimal(4), - NumOfReads: reader.IsDBNull(5) ? 0L : reader.GetInt64(5), - NumOfWrites: reader.IsDBNull(6) ? 0L : reader.GetInt64(6), - ReadBytes: reader.IsDBNull(7) ? 0L : reader.GetInt64(7), - WriteBytes: reader.IsDBNull(8) ? 0L : reader.GetInt64(8), - IoStallReadMs: reader.IsDBNull(9) ? 0L : reader.GetInt64(9), - IoStallWriteMs: reader.IsDBNull(10) ? 0L : reader.GetInt64(10), - IoStallQueuedReadMs: reader.IsDBNull(11) ? 0L : reader.GetInt64(11), - IoStallQueuedWriteMs: reader.IsDBNull(12) ? 0L : reader.GetInt64(12), - DatabaseId: reader.IsDBNull(13) ? 0 : Convert.ToInt32(reader.GetValue(13)), - FileId: reader.IsDBNull(14) ? 0 : Convert.ToInt32(reader.GetValue(14))); - } + private Task<int> CollectFileIoStatsAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(FileIoStatsCollector.Instance, server, cancellationToken); } diff --git a/Lite/Services/RemoteCollectorService.IndexObjectStats.cs b/Lite/Services/RemoteCollectorService.IndexObjectStats.cs index fa4db587f..93475731c 100644 --- a/Lite/Services/RemoteCollectorService.IndexObjectStats.cs +++ b/Lite/Services/RemoteCollectorService.IndexObjectStats.cs @@ -6,14 +6,9 @@ * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ -using System; -using System.Collections.Generic; -using System.Diagnostics; using System.Threading; using System.Threading.Tasks; -using DuckDB.NET.Data; -using Microsoft.Data.SqlClient; -using Microsoft.Extensions.Logging; +using PerformanceMonitor.Collectors; using PerformanceMonitorLite.Models; namespace PerformanceMonitorLite.Services; @@ -21,451 +16,11 @@ namespace PerformanceMonitorLite.Services; public partial class RemoteCollectorService { /// <summary> - /// Command timeout for the index/object-stats collector. This sweep reads - /// sys.dm_db_index_operational_stats over every index in a database, which is - /// far heavier than the other DMV collectors. It now runs one command PER - /// DATABASE (see <see cref="CollectIndexObjectStatsAsync"/>), so this larger, - /// dedicated budget applies to a single database rather than a whole-instance - /// sweep. Matches the 300s the FinOps sp_IndexCleanup path already uses - /// (LocalDataService.FinOps.IndexAnalysis). The global 30s CommandTimeoutSeconds - /// was the root cause of #1135 (one cumulative all-or-nothing command timed out). + /// Collects per-table/per-index size, usage, and locking statistics via the shared + /// <see cref="IndexObjectStatsCollector"/> definition (the #temp-staged sp_IndexCleanup + /// technique, the per-database execution on both platforms, and the dedicated 300 s + /// per-database budget from the #1135 fix live there — the cross-SKU parity contract). /// </summary> - private const int IndexObjectStatsCommandTimeoutSeconds = 300; - - /// <summary> - /// Collects per-table and per-index size, usage, and locking statistics for growth - /// trending, unused-index detection, and contention analysis. - /// Size columns are absolute point-in-time values (growth = size(t2) - size(t1) in the - /// read layer); usage and locking counters are raw cumulative totals shown as-is - the - /// read layer does NOT compute counter deltas (sqlserver_start_time flags only - /// restart / DB detach / AUTO_CLOSE resets, not the metadata-cache-eviction reset; see #1138). - /// All three DMVs are database-scoped, so collection runs ONE COMMAND PER DATABASE: - /// on-prem enumerates databases then sends each through [db].sys.sp_executesql; Azure - /// SQL DB connects to each database individually. Each database is collected with its - /// own command, timeout, and try/catch, so a slow or inaccessible database only fails - /// itself instead of discarding the whole instance's results (#1135). Within each - /// database, the three DMVs are staged into #temp tables with single scans and then - /// joined - this gives the optimizer real cardinality and avoids the bad plans the old - /// single monolithic multi-DMV join produced on large databases (the sp_IndexCleanup - /// technique). In-Memory OLTP (Hekaton) objects are not represented by these DMVs. - /// </summary> - private async Task<int> CollectIndexObjectStatsAsync(ServerConnection server, CancellationToken cancellationToken) - { - var serverStatus = _serverManager.GetConnectionStatus(server.Id); - bool isAzureSqlDb = serverStatus?.SqlEngineEdition == 5; - - /* - Per-database collection body. Runs inside a single database's context (the on-prem - path wraps it in [db].sys.sp_executesql; the Azure path connects to the database). - Each DMV is staged into its own #temp with one scan, then joined - sized/usage/ - locking counters get accurate cardinality so the final join gets a sane plan even - on very large databases. The final SELECT's column order MUST match the ordinals in - ReadIndexObjectStatRow (0..43). - */ - const string perDbStatsBody = @" -SET NOCOUNT ON; -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -/* Size + row counts (one scan of dm_db_partition_stats) */ -SELECT - dps.object_id, - dps.index_id, - partition_count = COUNT_BIG(*), - reserved_pages = SUM(dps.reserved_page_count), - used_pages = SUM(dps.used_page_count), - in_row_pages = SUM(dps.in_row_data_page_count), - lob_pages = SUM(dps.lob_used_page_count), - row_overflow_pages = SUM(dps.row_overflow_used_page_count), - total_rows = SUM(dps.row_count) -INTO #sizes -FROM sys.dm_db_partition_stats AS dps -GROUP BY - dps.object_id, - dps.index_id -OPTION(RECOMPILE); - -/* Usage counters (one scan of dm_db_index_usage_stats for this database) */ -SELECT - us.object_id, - us.index_id, - us.user_seeks, - us.user_scans, - us.user_lookups, - us.user_updates, - us.last_user_seek, - us.last_user_scan, - us.last_user_lookup, - us.last_user_update -INTO #usage -FROM sys.dm_db_index_usage_stats AS us -WHERE us.database_id = DB_ID() -OPTION(RECOMPILE); - -/* Locking/latch counters (one scan of dm_db_index_operational_stats - the heavy DMV) */ -SELECT - ios.object_id, - ios.index_id, - leaf_insert_count = SUM(ios.leaf_insert_count), - leaf_update_count = SUM(ios.leaf_update_count), - leaf_delete_count = SUM(ios.leaf_delete_count), - range_scan_count = SUM(ios.range_scan_count), - singleton_lookup_count = SUM(ios.singleton_lookup_count), - row_lock_count = SUM(ios.row_lock_count), - row_lock_wait_count = SUM(ios.row_lock_wait_count), - row_lock_wait_in_ms = SUM(ios.row_lock_wait_in_ms), - page_lock_count = SUM(ios.page_lock_count), - page_lock_wait_count = SUM(ios.page_lock_wait_count), - page_lock_wait_in_ms = SUM(ios.page_lock_wait_in_ms), - index_lock_promotion_attempt_count = SUM(ios.index_lock_promotion_attempt_count), - index_lock_promotion_count = SUM(ios.index_lock_promotion_count), - page_latch_wait_count = SUM(ios.page_latch_wait_count), - page_latch_wait_in_ms = SUM(ios.page_latch_wait_in_ms), - page_io_latch_wait_count = SUM(ios.page_io_latch_wait_count), - page_io_latch_wait_in_ms = SUM(ios.page_io_latch_wait_in_ms) -INTO #ops -FROM sys.dm_db_index_operational_stats(DB_ID(), NULL, NULL, NULL) AS ios -GROUP BY - ios.object_id, - ios.index_id -OPTION(RECOMPILE); - -SELECT - sqlserver_start_time = (SELECT osi.sqlserver_start_time FROM sys.dm_os_sys_info AS osi), - database_name = DB_NAME(), - database_id = DB_ID(), - schema_name = s.name, - object_id = o.object_id, - table_name = o.name, - index_id = i.index_id, - index_name = i.name, - index_type_desc = i.type_desc, - is_unique = i.is_unique, - is_primary_key = i.is_primary_key, - is_filtered = i.has_filter, - partition_count = ps.partition_count, - reserved_mb = CONVERT(decimal(19,2), ps.reserved_pages * 8.0 / 1024.0), - used_mb = CONVERT(decimal(19,2), ps.used_pages * 8.0 / 1024.0), - in_row_data_mb = CONVERT(decimal(19,2), ps.in_row_pages * 8.0 / 1024.0), - lob_data_mb = CONVERT(decimal(19,2), ps.lob_pages * 8.0 / 1024.0), - row_overflow_mb = CONVERT(decimal(19,2), ps.row_overflow_pages * 8.0 / 1024.0), - total_rows = ps.total_rows, - user_seeks = us.user_seeks, - user_scans = us.user_scans, - user_lookups = us.user_lookups, - user_updates = us.user_updates, - last_user_seek = us.last_user_seek, - last_user_scan = us.last_user_scan, - last_user_lookup = us.last_user_lookup, - last_user_update = us.last_user_update, - leaf_insert_count = os.leaf_insert_count, - leaf_update_count = os.leaf_update_count, - leaf_delete_count = os.leaf_delete_count, - range_scan_count = os.range_scan_count, - singleton_lookup_count = os.singleton_lookup_count, - row_lock_count = os.row_lock_count, - row_lock_wait_count = os.row_lock_wait_count, - row_lock_wait_in_ms = os.row_lock_wait_in_ms, - page_lock_count = os.page_lock_count, - page_lock_wait_count = os.page_lock_wait_count, - page_lock_wait_in_ms = os.page_lock_wait_in_ms, - index_lock_promotion_attempt_count = os.index_lock_promotion_attempt_count, - index_lock_promotion_count = os.index_lock_promotion_count, - page_latch_wait_count = os.page_latch_wait_count, - page_latch_wait_in_ms = os.page_latch_wait_in_ms, - page_io_latch_wait_count = os.page_io_latch_wait_count, - page_io_latch_wait_in_ms = os.page_io_latch_wait_in_ms -FROM sys.indexes AS i -JOIN sys.objects AS o - ON o.object_id = i.object_id -JOIN sys.schemas AS s - ON s.schema_id = o.schema_id -LEFT JOIN #sizes AS ps - ON ps.object_id = i.object_id - AND ps.index_id = i.index_id -LEFT JOIN #usage AS us - ON us.object_id = i.object_id - AND us.index_id = i.index_id -LEFT JOIN #ops AS os - ON os.object_id = i.object_id - AND os.index_id = i.index_id -WHERE o.is_ms_shipped = 0 -AND o.type IN (N'U', N'V') -OPTION(RECOMPILE);"; - - var serverId = GetServerId(server); - var serverName = GetServerNameForStorage(server); - var collectionTime = DateTime.UtcNow; - var rowsCollected = 0; - _lastSqlMs = 0; - _lastDuckDbMs = 0; - - var rows = new List<IndexObjectStatRow>(); - var sqlSw = Stopwatch.StartNew(); - - if (isAzureSqlDb) - { - /* Azure SQL DB: one connection per database (cannot cross databases). Already - resilient - each database has its own command + try/catch. */ - var databases = await GetAzureDatabaseListAsync(server, cancellationToken); - foreach (var dbName in databases) - { - cancellationToken.ThrowIfCancellationRequested(); - try - { - using var dbConn = await OpenAzureDatabaseConnectionAsync(server, dbName, cancellationToken); - using var cmd = new SqlCommand(perDbStatsBody, dbConn); - cmd.CommandTimeout = IndexObjectStatsCommandTimeoutSeconds; - using var reader = await cmd.ExecuteReaderAsync(cancellationToken); - while (await reader.ReadAsync(cancellationToken)) - { - rows.Add(ReadIndexObjectStatRow(reader)); - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - _logger?.LogDebug("Skipping database '{Database}' for index/object stats: {Error}", dbName, ex.Message); - } - } - } - else - { - /* On-prem / Azure MI / AWS RDS: one connection, enumerate databases, then collect - each one with its own command via [db].sys.sp_executesql. A slow or inaccessible - database fails only itself; the rest still persist (#1135). */ - using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); - - var (exclusionClause, exclusionParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); - var enumQuery = $@" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; -SELECT - d.name -FROM sys.databases AS d -WHERE d.state_desc = N'ONLINE' -AND d.database_id > 0 -AND HAS_DBACCESS(d.name) = 1 -{exclusionClause} -ORDER BY - d.name;"; - - var databases = new List<string>(); - using (var enumCommand = new SqlCommand(enumQuery, sqlConnection)) - { - enumCommand.CommandTimeout = CommandTimeoutSeconds; - foreach (var p in exclusionParams) enumCommand.Parameters.Add(p); - using var enumReader = await enumCommand.ExecuteReaderAsync(cancellationToken); - while (await enumReader.ReadAsync(cancellationToken)) - { - databases.Add(enumReader.GetString(0)); - } - } - - /* Double single quotes so the body survives nesting inside [db].sys.sp_executesql N'...' */ - var escapedBody = perDbStatsBody.Replace("'", "''"); - foreach (var dbName in databases) - { - cancellationToken.ThrowIfCancellationRequested(); - try - { - var escapedDbName = dbName.Replace("]", "]]"); - var perDbQuery = $"EXECUTE [{escapedDbName}].sys.sp_executesql N'{escapedBody}';"; - using var command = new SqlCommand(perDbQuery, sqlConnection); - command.CommandTimeout = IndexObjectStatsCommandTimeoutSeconds; - using var reader = await command.ExecuteReaderAsync(cancellationToken); - while (await reader.ReadAsync(cancellationToken)) - { - rows.Add(ReadIndexObjectStatRow(reader)); - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - _logger?.LogWarning("Failed to collect index/object stats from [{Database}] on '{Server}': {Error}", - dbName, server.DisplayName, ex.Message); - } - } - } - sqlSw.Stop(); - - var duckSw = Stopwatch.StartNew(); - using (var duckConnection = _duckDb.CreateConnection()) - { - await duckConnection.OpenAsync(cancellationToken); - using (var appender = duckConnection.CreateAppender("index_object_stats")) - { - foreach (var r in rows) - { - appender.CreateRow() - .AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(serverName) - .AppendValue(r.SqlServerStartTime) - .AppendValue(r.DatabaseName) - .AppendValue(r.DatabaseId) - .AppendValue(r.SchemaName) - .AppendValue(r.ObjectId) - .AppendValue(r.TableName) - .AppendValue(r.IndexId) - .AppendValue(r.IndexName) - .AppendValue(r.IndexTypeDesc) - .AppendValue(r.IsUnique) - .AppendValue(r.IsPrimaryKey) - .AppendValue(r.IsFiltered) - .AppendValue(r.PartitionCount) - .AppendValue(r.ReservedMb) - .AppendValue(r.UsedMb) - .AppendValue(r.InRowDataMb) - .AppendValue(r.LobDataMb) - .AppendValue(r.RowOverflowMb) - .AppendValue(r.TotalRows) - .AppendValue(r.UserSeeks) - .AppendValue(r.UserScans) - .AppendValue(r.UserLookups) - .AppendValue(r.UserUpdates) - .AppendValue(r.LastUserSeek) - .AppendValue(r.LastUserScan) - .AppendValue(r.LastUserLookup) - .AppendValue(r.LastUserUpdate) - .AppendValue(r.LeafInsertCount) - .AppendValue(r.LeafUpdateCount) - .AppendValue(r.LeafDeleteCount) - .AppendValue(r.RangeScanCount) - .AppendValue(r.SingletonLookupCount) - .AppendValue(r.RowLockCount) - .AppendValue(r.RowLockWaitCount) - .AppendValue(r.RowLockWaitInMs) - .AppendValue(r.PageLockCount) - .AppendValue(r.PageLockWaitCount) - .AppendValue(r.PageLockWaitInMs) - .AppendValue(r.IndexLockPromotionAttemptCount) - .AppendValue(r.IndexLockPromotionCount) - .AppendValue(r.PageLatchWaitCount) - .AppendValue(r.PageLatchWaitInMs) - .AppendValue(r.PageIoLatchWaitCount) - .AppendValue(r.PageIoLatchWaitInMs) - .EndRow(); - rowsCollected++; - } - } - } - duckSw.Stop(); - - _lastSqlMs = sqlSw.ElapsedMilliseconds; - _lastDuckDbMs = duckSw.ElapsedMilliseconds; - - _logger?.LogDebug("Collected {RowCount} index/object stat rows for server '{Server}'", rowsCollected, server.DisplayName); - return rowsCollected; - } - - private static IndexObjectStatRow ReadIndexObjectStatRow(SqlDataReader reader) - { - long? L(int i) => reader.IsDBNull(i) ? null : Convert.ToInt64(reader.GetValue(i)); - int? I(int i) => reader.IsDBNull(i) ? null : Convert.ToInt32(reader.GetValue(i)); - decimal? D(int i) => reader.IsDBNull(i) ? null : reader.GetDecimal(i); - DateTime? T(int i) => reader.IsDBNull(i) ? null : reader.GetDateTime(i); - bool? B(int i) => reader.IsDBNull(i) ? null : (bool?)(Convert.ToInt32(reader.GetValue(i)) == 1); - - return new IndexObjectStatRow - { - SqlServerStartTime = T(0), - DatabaseName = reader.GetString(1), - DatabaseId = Convert.ToInt32(reader.GetValue(2)), - SchemaName = reader.GetString(3), - ObjectId = Convert.ToInt32(reader.GetValue(4)), - TableName = reader.GetString(5), - IndexId = Convert.ToInt32(reader.GetValue(6)), - IndexName = reader.IsDBNull(7) ? null : reader.GetString(7), - IndexTypeDesc = reader.IsDBNull(8) ? null : reader.GetString(8), - IsUnique = B(9), - IsPrimaryKey = B(10), - IsFiltered = B(11), - PartitionCount = I(12), - ReservedMb = D(13), - UsedMb = D(14), - InRowDataMb = D(15), - LobDataMb = D(16), - RowOverflowMb = D(17), - TotalRows = L(18), - UserSeeks = L(19), - UserScans = L(20), - UserLookups = L(21), - UserUpdates = L(22), - LastUserSeek = T(23), - LastUserScan = T(24), - LastUserLookup = T(25), - LastUserUpdate = T(26), - LeafInsertCount = L(27), - LeafUpdateCount = L(28), - LeafDeleteCount = L(29), - RangeScanCount = L(30), - SingletonLookupCount = L(31), - RowLockCount = L(32), - RowLockWaitCount = L(33), - RowLockWaitInMs = L(34), - PageLockCount = L(35), - PageLockWaitCount = L(36), - PageLockWaitInMs = L(37), - IndexLockPromotionAttemptCount = L(38), - IndexLockPromotionCount = L(39), - PageLatchWaitCount = L(40), - PageLatchWaitInMs = L(41), - PageIoLatchWaitCount = L(42), - PageIoLatchWaitInMs = L(43) - }; - } - - private sealed class IndexObjectStatRow - { - public DateTime? SqlServerStartTime { get; set; } - public string DatabaseName { get; set; } = ""; - public int DatabaseId { get; set; } - public string SchemaName { get; set; } = ""; - public int ObjectId { get; set; } - public string TableName { get; set; } = ""; - public int IndexId { get; set; } - public string? IndexName { get; set; } - public string? IndexTypeDesc { get; set; } - public bool? IsUnique { get; set; } - public bool? IsPrimaryKey { get; set; } - public bool? IsFiltered { get; set; } - public int? PartitionCount { get; set; } - public decimal? ReservedMb { get; set; } - public decimal? UsedMb { get; set; } - public decimal? InRowDataMb { get; set; } - public decimal? LobDataMb { get; set; } - public decimal? RowOverflowMb { get; set; } - public long? TotalRows { get; set; } - public long? UserSeeks { get; set; } - public long? UserScans { get; set; } - public long? UserLookups { get; set; } - public long? UserUpdates { get; set; } - public DateTime? LastUserSeek { get; set; } - public DateTime? LastUserScan { get; set; } - public DateTime? LastUserLookup { get; set; } - public DateTime? LastUserUpdate { get; set; } - public long? LeafInsertCount { get; set; } - public long? LeafUpdateCount { get; set; } - public long? LeafDeleteCount { get; set; } - public long? RangeScanCount { get; set; } - public long? SingletonLookupCount { get; set; } - public long? RowLockCount { get; set; } - public long? RowLockWaitCount { get; set; } - public long? RowLockWaitInMs { get; set; } - public long? PageLockCount { get; set; } - public long? PageLockWaitCount { get; set; } - public long? PageLockWaitInMs { get; set; } - public long? IndexLockPromotionAttemptCount { get; set; } - public long? IndexLockPromotionCount { get; set; } - public long? PageLatchWaitCount { get; set; } - public long? PageLatchWaitInMs { get; set; } - public long? PageIoLatchWaitCount { get; set; } - public long? PageIoLatchWaitInMs { get; set; } - } + private Task<int> CollectIndexObjectStatsAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(IndexObjectStatsCollector.Instance, server, cancellationToken); } diff --git a/Lite/Services/RemoteCollectorService.JobHistory.cs b/Lite/Services/RemoteCollectorService.JobHistory.cs new file mode 100644 index 000000000..13eb3d2ff --- /dev/null +++ b/Lite/Services/RemoteCollectorService.JobHistory.cs @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Threading; +using System.Threading.Tasks; +using PerformanceMonitor.Collectors; +using PerformanceMonitorLite.Models; + +namespace PerformanceMonitorLite.Services; + +public partial class RemoteCollectorService +{ + /// <summary> + /// Collects retained SQL Agent job-run history (every step row + the job-outcome row from + /// msdb.dbo.sysjobhistory) via the shared <see cref="JobHistoryCollector"/> definition — the + /// incremental instance_id high-water-mark dedup, the run_datetime / run_duration HHMMSS decode, and + /// the sysjobs/syscategories joins all live there (the cross-SKU parity contract). Read-only. Not + /// collected on Azure SQL DB nor when the login lacks msdb access — the definition's AppliesTo + /// (<c>!IsAzureSqlDb && HasMsdbAccess</c>) is the single gate, which RunCollectorAsync consults + /// pre-dispatch for the clean SKIPPED log. + /// </summary> + private Task<int> CollectJobHistoryAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(JobHistoryCollector.Instance, server, cancellationToken); +} diff --git a/Lite/Services/RemoteCollectorService.LatchSpinlockStats.cs b/Lite/Services/RemoteCollectorService.LatchSpinlockStats.cs new file mode 100644 index 000000000..50ef09fe5 --- /dev/null +++ b/Lite/Services/RemoteCollectorService.LatchSpinlockStats.cs @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Threading; +using System.Threading.Tasks; +using PerformanceMonitor.Collectors; +using PerformanceMonitorLite.Models; + +namespace PerformanceMonitorLite.Services; + +public partial class RemoteCollectorService +{ + /// <summary> + /// Collects latch statistics from sys.dm_os_latch_stats via the shared + /// <see cref="LatchStatsCollector"/> definition (query text, delta rules, and payload order + /// live there — the cross-SKU parity contract). + /// </summary> + private Task<int> CollectLatchStatsAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(LatchStatsCollector.Instance, server, cancellationToken); + + /// <summary> + /// Collects spinlock statistics from sys.dm_os_spinlock_stats via the shared + /// <see cref="SpinlockStatsCollector"/> definition (query text, delta rules, and payload order + /// live there — the cross-SKU parity contract). + /// </summary> + private Task<int> CollectSpinlockStatsAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(SpinlockStatsCollector.Instance, server, cancellationToken); +} diff --git a/Lite/Services/RemoteCollectorService.LongQueryCompletions.cs b/Lite/Services/RemoteCollectorService.LongQueryCompletions.cs new file mode 100644 index 000000000..7b234ef8f --- /dev/null +++ b/Lite/Services/RemoteCollectorService.LongQueryCompletions.cs @@ -0,0 +1,251 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Data; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using PerformanceMonitor.Collectors; +using PerformanceMonitorLite.Models; + +namespace PerformanceMonitorLite.Services; + +public partial class RemoteCollectorService +{ + /* The session name + DDL live in the shared definition so the ring-buffer reader and this + lifecycle can never disagree on them (#1496). */ + private const string LongQueryXeSessionName = LongQueryCompletionsCollector.XeSessionName; + + /* Per-server last-applied enabled state for the long-query trace's XE session, so the reconcile + does not open a connection every cycle for a server whose state has not changed. Keyed by + server id; unset = not yet reconciled. In-memory (cleared on app restart, which then re- + reconciles once). true = the session is being ensured (enabled); false = confirmed dropped + (disabled). */ + private readonly ConcurrentDictionary<string, bool> _longQueryTraceApplied = new(); + + /// <summary> + /// Reconciles the long-query completion XE session to the collector's enabled flag — Erik's + /// dedicated switch (#1496), default OFF. ENABLED: ensure the session exists + running (re-ensured + /// every cycle, so a session dropped out from under us self-heals, and an Azure database-scoped + /// session that stopped on reconnect is restarted — mirrors the blocked-process ensure). DISABLED: + /// DROP the session on the monitored server(s) — the session itself is the busy-server cost, so + /// merely skipping collection is not enough. The drop runs once per disabled server (tracked in + /// <see cref="_longQueryTraceApplied"/>) rather than every cycle, since a disabled collector is the + /// default for every server and re-checking a confirmed-absent session each cycle would open a + /// connection to every server forever. Called unconditionally from the per-server collection loop + /// (NOT gated by the enabled flag), because a disabled collector is never dispatched and so the drop + /// has nowhere else to run. + /// </summary> + public async Task ReconcileLongQueryCompletionsXeSessionAsync(ServerConnection server, CancellationToken cancellationToken = default) + { + var schedule = _scheduleManager.GetScheduleForServer(server.Id, "long_query_completions"); + var enabled = schedule?.Enabled ?? false; + + try + { + if (enabled) + { + await EnsureLongQueryCompletionsXeSessionAsync(server, cancellationToken); + _longQueryTraceApplied[server.Id] = true; + } + else if (_longQueryTraceApplied.GetValueOrDefault(server.Id, true)) + { + /* Disabled and either never reconciled or previously enabled: drop once, then remember + it is gone so the next cycles skip the connection entirely. */ + await DropLongQueryCompletionsXeSessionAsync(server, cancellationToken); + _longQueryTraceApplied[server.Id] = false; + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + /* Leave the applied state unchanged so the next cycle retries; a failed reconcile must + never break the collection loop. */ + AppLogger.Warn("XeSession", $"[{server.DisplayName}] Failed to reconcile long-query completion XE session: {ex.Message}"); + } + } + + /// <summary> + /// Ensures the long-query completion XE session exists and is running. Server-scoped on + /// on-prem/MI/RDS; on Azure SQL DB a database-scoped session in EVERY monitored database (#1535), + /// matching the per-database ring-buffer read (<see cref="LongQueryCompletionsCollector.RunsPerDatabase"/>). + /// </summary> + public async Task EnsureLongQueryCompletionsXeSessionAsync(ServerConnection server, CancellationToken cancellationToken = default) + { + var engineEdition = _serverManager.GetConnectionStatus(server.Id).SqlEngineEdition; + + if (engineEdition == 5) + { + await EnsureDatabaseScopedXeSessionsAsync( + server, "long query completions", LongQueryXeSessionName, + EnsureLongQueryCompletionsXeSessionAzureSqlDbAsync, cancellationToken); + return; + } + + using var connection = await CreateConnectionAsync(server, cancellationToken); + await EnsureLongQueryCompletionsXeSessionOnPremAsync(connection, server, cancellationToken); + } + + private async Task EnsureLongQueryCompletionsXeSessionOnPremAsync(SqlConnection connection, ServerConnection server, CancellationToken cancellationToken) + { + using (var cmd = new SqlCommand(@" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT /* PerformanceMonitorLite */ + is_running = CASE WHEN dxs.name IS NOT NULL THEN 1 ELSE 0 END +FROM sys.server_event_sessions AS ses +LEFT JOIN sys.dm_xe_sessions AS dxs + ON dxs.name = ses.name +WHERE ses.name = @session_name;", connection)) + { + cmd.CommandTimeout = CommandTimeoutSeconds; + cmd.Parameters.Add(new SqlParameter("@session_name", SqlDbType.NVarChar, 128) { Value = LongQueryXeSessionName }); + var result = await cmd.ExecuteScalarAsync(cancellationToken); + + if (result != null) + { + if (result is int isRunning && isRunning == 0) + { + using var startCmd = new SqlCommand(LongQueryCompletionsCollector.BuildStartSessionSql(databaseScoped: false), connection); + startCmd.CommandTimeout = CommandTimeoutSeconds; + await startCmd.ExecuteNonQueryAsync(cancellationToken); + AppLogger.Info("XeSession", $"[{server.DisplayName}] Started long-query completion XE session"); + } + return; + } + } + + using var createCmd = new SqlCommand( + LongQueryCompletionsCollector.BuildCreateSessionSql(databaseScoped: false, LongQueryCompletionsCollector.DefaultDurationThresholdMicroseconds) + + "\n\n" + LongQueryCompletionsCollector.BuildStartSessionSql(databaseScoped: false), connection); + createCmd.CommandTimeout = CommandTimeoutSeconds; + await createCmd.ExecuteNonQueryAsync(cancellationToken); + AppLogger.Info("XeSession", $"[{server.DisplayName}] Created and started long-query completion XE session (duration >= {LongQueryCompletionsCollector.DefaultDurationThresholdMicroseconds} us)"); + } + + private async Task EnsureLongQueryCompletionsXeSessionAzureSqlDbAsync(SqlConnection connection, CancellationToken cancellationToken) + { + using (var cmd = new SqlCommand(@" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT /* PerformanceMonitorLite */ + session_state = des.name +FROM sys.database_event_sessions AS des +WHERE des.name = @session_name;", connection)) + { + cmd.CommandTimeout = CommandTimeoutSeconds; + cmd.Parameters.Add(new SqlParameter("@session_name", SqlDbType.NVarChar, 128) { Value = LongQueryXeSessionName }); + var result = await cmd.ExecuteScalarAsync(cancellationToken); + + if (result != null) + { + using var startCmd = new SqlCommand($@" +IF NOT EXISTS +( + SELECT + 1/0 + FROM sys.dm_xe_database_sessions AS xes + WHERE xes.name = N'{LongQueryXeSessionName}' +) +BEGIN + ALTER EVENT SESSION [{LongQueryXeSessionName}] ON DATABASE STATE = START; +END;", connection); + startCmd.CommandTimeout = CommandTimeoutSeconds; + await startCmd.ExecuteNonQueryAsync(cancellationToken); + AppLogger.Debug("XeSession", $"[Azure SQL DB:{connection.Database}] Long-query completion XE session verified (database-scoped)"); + return; + } + } + + using var createCmd = new SqlCommand( + LongQueryCompletionsCollector.BuildCreateSessionSql(databaseScoped: true, LongQueryCompletionsCollector.DefaultDurationThresholdMicroseconds) + + "\n\n" + LongQueryCompletionsCollector.BuildStartSessionSql(databaseScoped: true), connection); + createCmd.CommandTimeout = CommandTimeoutSeconds; + await createCmd.ExecuteNonQueryAsync(cancellationToken); + AppLogger.Info("XeSession", $"[Azure SQL DB:{connection.Database}] Created and started long-query completion XE session (database-scoped)"); + } + + /// <summary> + /// Drops the long-query completion XE session (the opt-out path — disabling the collector removes + /// the server-side session, the actual busy-server cost). Idempotent: the shared DROP DDL is + /// guarded by an existence check, so a server that never had the session is a clean no-op. On Azure + /// SQL DB the drop runs per monitored database (master skipped), matching where the sessions live. + /// </summary> + public async Task DropLongQueryCompletionsXeSessionAsync(ServerConnection server, CancellationToken cancellationToken = default) + { + var engineEdition = _serverManager.GetConnectionStatus(server.Id).SqlEngineEdition; + + if (engineEdition == 5) + { + List<string> databases; + try + { + databases = await GetAzureDatabaseListAsync(server, cancellationToken); + } + catch (SqlException ex) + { + AppLogger.Warn("XeSession", $"[{server.DisplayName}] Could not enumerate databases to drop the long-query completion XE session: {ex.Message}"); + return; + } + + foreach (var databaseName in databases) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (string.Equals(databaseName, "master", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + try + { + using var connection = await OpenAzureDatabaseConnectionAsync(server, databaseName, cancellationToken); + using var dropCmd = new SqlCommand(LongQueryCompletionsCollector.BuildDropSessionSql(databaseScoped: true), connection); + dropCmd.CommandTimeout = CommandTimeoutSeconds; + await dropCmd.ExecuteNonQueryAsync(cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + AppLogger.Debug("XeSession", $"[{server.DisplayName}] [{databaseName}] Could not drop the long-query completion XE session: {ex.Message}"); + } + } + + return; + } + + using var conn = await CreateConnectionAsync(server, cancellationToken); + using var cmd = new SqlCommand(LongQueryCompletionsCollector.BuildDropSessionSql(databaseScoped: false), conn); + cmd.CommandTimeout = CommandTimeoutSeconds; + await cmd.ExecuteNonQueryAsync(cancellationToken); + AppLogger.Info("XeSession", $"[{server.DisplayName}] Long-query completion XE session reconciled OFF (collector disabled)"); + } + + /// <summary> + /// Collects long-query completions via the shared <see cref="LongQueryCompletionsCollector"/> + /// definition. The session lifecycle stays in the reconcile above; a missing/inaccessible session + /// is tolerated here as zero rows, exactly like the blocked-process reader. + /// </summary> + private async Task<int> CollectLongQueryCompletionsAsync(ServerConnection server, CancellationToken cancellationToken) + { + try + { + return await RunCollectorDefinitionAsync(LongQueryCompletionsCollector.Instance, server, cancellationToken); + } + catch (SqlException ex) when (ex.Number == 297 || ex.Number == 15151 || ex.Message.Contains("XE session")) + { + AppLogger.Info("XeSession", $"[{server.DisplayName}] Long-query completion XE session not available: {ex.Message}"); + return 0; + } + } +} diff --git a/Lite/Services/RemoteCollectorService.Memory.cs b/Lite/Services/RemoteCollectorService.Memory.cs index 2dc6bfcb0..9f70262af 100644 --- a/Lite/Services/RemoteCollectorService.Memory.cs +++ b/Lite/Services/RemoteCollectorService.Memory.cs @@ -6,13 +6,9 @@ * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ -using System; -using System.Diagnostics; using System.Threading; using System.Threading.Tasks; -using DuckDB.NET.Data; -using Microsoft.Data.SqlClient; -using Microsoft.Extensions.Logging; +using PerformanceMonitor.Collectors; using PerformanceMonitorLite.Models; namespace PerformanceMonitorLite.Services; @@ -20,356 +16,24 @@ namespace PerformanceMonitorLite.Services; public partial class RemoteCollectorService { /// <summary> - /// Collects memory statistics from sys.dm_os_sys_memory and performance counters. + /// Collects server memory statistics via the shared <see cref="MemoryStatsCollector"/> + /// definition (on-prem vs. Azure SQL DB variants, incl. the #857 elastic-pool schedulers + /// skip, live there — the cross-SKU parity contract). /// </summary> - private async Task<int> CollectMemoryStatsAsync(ServerConnection server, CancellationToken cancellationToken) - { - var serverStatus = _serverManager.GetConnectionStatus(server.Id); - /* Azure SQL DB (edition 5) doesn't have sys.dm_os_sys_memory or sql_memory_model_desc. - Use sys.dm_os_sys_info committed_target_kb/committed_kb as approximations. - Azure MI (edition 8) HAS dm_os_sys_memory, sql_memory_model_desc, and behaves like on-prem. */ - bool isAzureSqlDb = serverStatus.SqlEngineEdition == 5; - - /* - * Azure SQL Database in an elastic pool (e.g. D365FO tenants) enforces - * VIEW SERVER PERFORMANCE STATE on sys.dm_os_schedulers regardless of - * the login's DB-scoped grants, so we skip the schedulers subquery on - * Azure SQL DB and report current_workers_count as NULL. The rest of - * the row — memory sizes from sys.dm_os_sys_info and the perf counters - * — succeeds for contained DB users. See #857. - */ - string query = isAzureSqlDb - ? @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -SELECT - total_physical_memory_mb = CONVERT(decimal(18,2), osi.committed_target_kb / 1024.0), - available_physical_memory_mb = CONVERT(decimal(18,2), (osi.committed_target_kb - osi.committed_kb) / 1024.0), - total_page_file_mb = CONVERT(decimal(18,2), 0), - available_page_file_mb = CONVERT(decimal(18,2), 0), - system_memory_state = N'Available', - sql_memory_model = N'N/A', - target_server_memory_mb = CONVERT(decimal(18,2), pc_target.cntr_value / 1024.0), - total_server_memory_mb = CONVERT(decimal(18,2), pc_total.cntr_value / 1024.0), - buffer_pool_mb = CONVERT(decimal(18,2), pc_buffer.cntr_value / 1024.0), - plan_cache_mb = CONVERT(decimal(18,2), pc_plan.cntr_value * 8.0 / 1024.0), - max_workers_count = osi.max_workers_count, - current_workers_count = CONVERT(int, NULL) -FROM sys.dm_os_sys_info AS osi -CROSS JOIN -( - SELECT cntr_value - FROM sys.dm_os_performance_counters - WHERE counter_name = N'Target Server Memory (KB)' -) AS pc_target -CROSS JOIN -( - SELECT cntr_value - FROM sys.dm_os_performance_counters - WHERE counter_name = N'Total Server Memory (KB)' -) AS pc_total -CROSS JOIN -( - SELECT cntr_value - FROM sys.dm_os_performance_counters - WHERE counter_name = N'Database Cache Memory (KB)' -) AS pc_buffer -CROSS JOIN -( - SELECT cntr_value = SUM(cntr_value) - FROM sys.dm_os_performance_counters - WHERE counter_name = N'Cache Pages' - AND object_name LIKE N'%:Plan Cache%' -) AS pc_plan -OPTION(RECOMPILE);" - : @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -SELECT - total_physical_memory_mb = CONVERT(decimal(18,2), osm.total_physical_memory_kb / 1024.0), - available_physical_memory_mb = CONVERT(decimal(18,2), osm.available_physical_memory_kb / 1024.0), - total_page_file_mb = CONVERT(decimal(18,2), osm.total_page_file_kb / 1024.0), - available_page_file_mb = CONVERT(decimal(18,2), osm.available_page_file_kb / 1024.0), - system_memory_state = osm.system_memory_state_desc, - sql_memory_model = osi.sql_memory_model_desc, - target_server_memory_mb = CONVERT(decimal(18,2), pc_target.cntr_value / 1024.0), - total_server_memory_mb = CONVERT(decimal(18,2), pc_total.cntr_value / 1024.0), - buffer_pool_mb = CONVERT(decimal(18,2), pc_buffer.cntr_value / 1024.0), - plan_cache_mb = CONVERT(decimal(18,2), pc_plan.cntr_value * 8.0 / 1024.0), - max_workers_count = osi.max_workers_count, - current_workers_count = w.current_workers -FROM sys.dm_os_sys_memory AS osm -CROSS JOIN sys.dm_os_sys_info AS osi -CROSS JOIN -( - SELECT cntr_value - FROM sys.dm_os_performance_counters - WHERE counter_name = N'Target Server Memory (KB)' -) AS pc_target -CROSS JOIN -( - SELECT cntr_value - FROM sys.dm_os_performance_counters - WHERE counter_name = N'Total Server Memory (KB)' -) AS pc_total -CROSS JOIN -( - SELECT cntr_value - FROM sys.dm_os_performance_counters - WHERE counter_name = N'Database Cache Memory (KB)' -) AS pc_buffer -CROSS JOIN -( - SELECT cntr_value = SUM(cntr_value) - FROM sys.dm_os_performance_counters - WHERE counter_name = N'Cache Pages' - AND object_name LIKE N'%:Plan Cache%' -) AS pc_plan -CROSS JOIN -( - SELECT current_workers = SUM(active_workers_count) - FROM sys.dm_os_schedulers - WHERE status = N'VISIBLE ONLINE' -) AS w -OPTION(RECOMPILE);"; - - var serverId = GetServerId(server); - var collectionTime = DateTime.UtcNow; - _lastSqlMs = 0; - _lastDuckDbMs = 0; - - var sqlSw = Stopwatch.StartNew(); - using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); - using var command = new SqlCommand(query, sqlConnection); - command.CommandTimeout = CommandTimeoutSeconds; - - using var reader = await command.ExecuteReaderAsync(cancellationToken); - - if (!await reader.ReadAsync(cancellationToken)) - { - return 0; - } - - var totalPhysicalMb = reader.IsDBNull(0) ? 0m : reader.GetDecimal(0); - var availablePhysicalMb = reader.IsDBNull(1) ? 0m : reader.GetDecimal(1); - var totalPageFileMb = reader.IsDBNull(2) ? 0m : reader.GetDecimal(2); - var availablePageFileMb = reader.IsDBNull(3) ? 0m : reader.GetDecimal(3); - var systemMemoryState = reader.IsDBNull(4) ? "Unknown" : reader.GetString(4); - var sqlMemoryModel = reader.IsDBNull(5) ? "Unknown" : reader.GetString(5); - var targetServerMemoryMb = reader.IsDBNull(6) ? 0m : reader.GetDecimal(6); - var totalServerMemoryMb = reader.IsDBNull(7) ? 0m : reader.GetDecimal(7); - var bufferPoolMb = reader.IsDBNull(8) ? 0m : reader.GetDecimal(8); - var planCacheMb = reader.IsDBNull(9) ? 0m : reader.GetDecimal(9); - var maxWorkersCount = reader.IsDBNull(10) ? 0 : reader.GetInt32(10); - var currentWorkersCount = reader.IsDBNull(11) ? 0 : reader.GetInt32(11); - sqlSw.Stop(); - - /* Insert into DuckDB using Appender */ - var duckSw = Stopwatch.StartNew(); - - using (var duckConnection = _duckDb.CreateConnection()) - { - await duckConnection.OpenAsync(cancellationToken); - - using (var appender = duckConnection.CreateAppender("memory_stats")) - { - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(GetServerNameForStorage(server)) - .AppendValue(totalPhysicalMb) - .AppendValue(availablePhysicalMb) - .AppendValue(totalPageFileMb) - .AppendValue(availablePageFileMb) - .AppendValue(systemMemoryState) - .AppendValue(sqlMemoryModel) - .AppendValue(targetServerMemoryMb) - .AppendValue(totalServerMemoryMb) - .AppendValue(bufferPoolMb) - .AppendValue(planCacheMb) - .AppendValue(maxWorkersCount) - .AppendValue(currentWorkersCount) - .EndRow(); - } - } - - duckSw.Stop(); - _lastSqlMs = sqlSw.ElapsedMilliseconds; - _lastDuckDbMs = duckSw.ElapsedMilliseconds; - - _logger?.LogDebug("Collected memory stats for server '{Server}'", server.DisplayName); - return 1; - } + private Task<int> CollectMemoryStatsAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(MemoryStatsCollector.Instance, server, cancellationToken); /// <summary> - /// Collects top memory clerks from sys.dm_os_memory_clerks. + /// Collects top memory clerks via the shared <see cref="MemoryClerksCollector"/> definition. /// </summary> - private async Task<int> CollectMemoryClerksAsync(ServerConnection server, CancellationToken cancellationToken) - { - const string query = @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -SELECT TOP (25) - clerk_type = mc.type, - memory_mb = CONVERT(decimal(18,2), SUM(mc.pages_kb) / 1024.0) -FROM sys.dm_os_memory_clerks AS mc -GROUP BY - mc.type -HAVING - SUM(mc.pages_kb) > 1024 -ORDER BY - SUM(mc.pages_kb) DESC -OPTION(RECOMPILE);"; - - var serverId = GetServerId(server); - var collectionTime = DateTime.UtcNow; - var rowsCollected = 0; - _lastSqlMs = 0; - _lastDuckDbMs = 0; - - var sqlSw = Stopwatch.StartNew(); - using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); - using var command = new SqlCommand(query, sqlConnection); - command.CommandTimeout = CommandTimeoutSeconds; - - using var reader = await command.ExecuteReaderAsync(cancellationToken); - sqlSw.Stop(); - - /* Insert into DuckDB */ - var duckSw = Stopwatch.StartNew(); - - using (var duckConnection = _duckDb.CreateConnection()) - { - await duckConnection.OpenAsync(cancellationToken); - - using (var appender = duckConnection.CreateAppender("memory_clerks")) - { - while (await reader.ReadAsync(cancellationToken)) - { - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(GetServerNameForStorage(server)) - .AppendValue(reader.GetString(0)) - .AppendValue(reader.GetDecimal(1)) - .EndRow(); - - rowsCollected++; - } - } - } - - duckSw.Stop(); - _lastSqlMs = sqlSw.ElapsedMilliseconds; - _lastDuckDbMs = duckSw.ElapsedMilliseconds; - - _logger?.LogDebug("Collected {RowCount} memory clerks for server '{Server}'", rowsCollected, server.DisplayName); - return rowsCollected; - } + private Task<int> CollectMemoryClerksAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(MemoryClerksCollector.Instance, server, cancellationToken); /// <summary> - /// Collects memory pressure notifications from RING_BUFFER_RESOURCE_MONITOR. - /// Same source as sp_pressuredetector — reports IndicatorsProcess/IndicatorsSystem - /// (0-1 normal, 2 medium pressure, 3+ severe) alongside the notification type. - /// Azure SQL DB does not expose sys.dm_os_ring_buffers, so this collector returns 0 there. + /// Collects memory pressure notifications via the shared + /// <see cref="MemoryPressureEventsCollector"/> definition (not applicable on Azure SQL DB — + /// the definition's AppliesTo skips the cycle; watermark dedup lives there too). /// </summary> - private async Task<int> CollectMemoryPressureEventsAsync(ServerConnection server, CancellationToken cancellationToken) - { - var serverStatus = _serverManager.GetConnectionStatus(server.Id); - bool isAzureSqlDb = serverStatus.SqlEngineEdition == 5; - - _lastSqlMs = 0; - _lastDuckDbMs = 0; - - if (isAzureSqlDb) - { - /* Ring buffer is not exposed on Azure SQL DB */ - return 0; - } - - const string query = @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -DECLARE - @ms_ticks bigint, - @now datetime2(7) = SYSDATETIME(); - -SELECT @ms_ticks = dosi.ms_ticks FROM sys.dm_os_sys_info AS dosi; - -SELECT - sample_time = DATEADD(SECOND, -((@ms_ticks - t.timestamp) / 1000), @now), - memory_notification = t.record.value('(/Record/ResourceMonitor/Notification)[1]', 'nvarchar(100)'), - memory_indicators_process = t.record.value('(/Record/ResourceMonitor/IndicatorsProcess)[1]', 'integer'), - memory_indicators_system = t.record.value('(/Record/ResourceMonitor/IndicatorsSystem)[1]', 'integer') -FROM -( - SELECT - dorb.timestamp, - record = CONVERT(xml, dorb.record) - FROM sys.dm_os_ring_buffers AS dorb - WHERE dorb.ring_buffer_type = N'RING_BUFFER_RESOURCE_MONITOR' -) AS t -ORDER BY t.timestamp -OPTION(RECOMPILE);"; - - var serverId = GetServerId(server); - var collectionTime = DateTime.UtcNow; - var rowsCollected = 0; - - /* Client-side dedup: computed sample_time cannot be filtered server-side - (it's derived from ms_ticks on each read). Fetch all and skip rows we already have. */ - var lastSampleTime = await GetLastCollectedTimeAsync( - serverId, "memory_pressure_events", "sample_time", cancellationToken); - - var sqlSw = Stopwatch.StartNew(); - using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); - using var command = new SqlCommand(query, sqlConnection); - command.CommandTimeout = CommandTimeoutSeconds; - - using var reader = await command.ExecuteReaderAsync(cancellationToken); - sqlSw.Stop(); - _lastSqlMs = sqlSw.ElapsedMilliseconds; - - var duckSw = Stopwatch.StartNew(); - - using (var duckConnection = _duckDb.CreateConnection()) - { - await duckConnection.OpenAsync(cancellationToken); - - using (var appender = duckConnection.CreateAppender("memory_pressure_events")) - { - while (await reader.ReadAsync(cancellationToken)) - { - var sampleTime = reader.IsDBNull(0) ? DateTime.MinValue : reader.GetDateTime(0); - if (lastSampleTime.HasValue && sampleTime <= lastSampleTime.Value) - continue; - - var notification = reader.IsDBNull(1) ? "" : reader.GetString(1); - var indicatorsProcess = reader.IsDBNull(2) ? 0 : reader.GetInt32(2); - var indicatorsSystem = reader.IsDBNull(3) ? 0 : reader.GetInt32(3); - - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(GetServerNameForStorage(server)) - .AppendValue(sampleTime) - .AppendValue(notification) - .AppendValue(indicatorsProcess) - .AppendValue(indicatorsSystem) - .EndRow(); - - rowsCollected++; - } - } - } - - duckSw.Stop(); - _lastDuckDbMs = duckSw.ElapsedMilliseconds; - - _logger?.LogDebug("Collected {RowCount} memory pressure events for server '{Server}'", rowsCollected, server.DisplayName); - return rowsCollected; - } + private Task<int> CollectMemoryPressureEventsAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(MemoryPressureEventsCollector.Instance, server, cancellationToken); } diff --git a/Lite/Services/RemoteCollectorService.MemoryGrants.cs b/Lite/Services/RemoteCollectorService.MemoryGrants.cs index e2bf31d49..328f8ab0a 100644 --- a/Lite/Services/RemoteCollectorService.MemoryGrants.cs +++ b/Lite/Services/RemoteCollectorService.MemoryGrants.cs @@ -6,14 +6,9 @@ * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ -using System; -using System.Collections.Generic; -using System.Diagnostics; using System.Threading; using System.Threading.Tasks; -using DuckDB.NET.Data; -using Microsoft.Data.SqlClient; -using Microsoft.Extensions.Logging; +using PerformanceMonitor.Collectors; using PerformanceMonitorLite.Models; namespace PerformanceMonitorLite.Services; @@ -21,110 +16,10 @@ namespace PerformanceMonitorLite.Services; public partial class RemoteCollectorService { /// <summary> - /// Collects memory grant statistics from sys.dm_exec_query_resource_semaphores. - /// Uses the same DMV as Dashboard for parity. + /// Collects memory grant statistics from sys.dm_exec_query_resource_semaphores via the + /// shared <see cref="MemoryGrantsCollector"/> definition (query text, composite delta key, + /// and payload order live there — the cross-SKU parity contract). /// </summary> - private async Task<int> CollectMemoryGrantStatsAsync(ServerConnection server, CancellationToken cancellationToken) - { - const string query = @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -SELECT - resource_semaphore_id = deqrs.resource_semaphore_id, - pool_id = deqrs.pool_id, - target_memory_mb = CONVERT(decimal(18,2), deqrs.target_memory_kb / 1024.0), - max_target_memory_mb = CONVERT(decimal(18,2), deqrs.max_target_memory_kb / 1024.0), - total_memory_mb = CONVERT(decimal(18,2), deqrs.total_memory_kb / 1024.0), - available_memory_mb = CONVERT(decimal(18,2), deqrs.available_memory_kb / 1024.0), - granted_memory_mb = CONVERT(decimal(18,2), ISNULL(deqrs.granted_memory_kb, 0) / 1024.0), - used_memory_mb = CONVERT(decimal(18,2), ISNULL(deqrs.used_memory_kb, 0) / 1024.0), - grantee_count = deqrs.grantee_count, - waiter_count = deqrs.waiter_count, - timeout_error_count = ISNULL(deqrs.timeout_error_count, 0), - forced_grant_count = ISNULL(deqrs.forced_grant_count, 0) -FROM sys.dm_exec_query_resource_semaphores AS deqrs -WHERE deqrs.max_target_memory_kb IS NOT NULL -OPTION(RECOMPILE);"; - - var serverId = GetServerId(server); - var collectionTime = DateTime.UtcNow; - var rowsCollected = 0; - _lastSqlMs = 0; - _lastDuckDbMs = 0; - - var rows = new List<(short ResourceSemaphoreId, int PoolId, - decimal TargetMb, decimal MaxTargetMb, decimal TotalMb, decimal AvailableMb, - decimal GrantedMb, decimal UsedMb, - int GranteeCount, int WaiterCount, long TimeoutErrorCount, long ForcedGrantCount)>(); - - var sqlSw = Stopwatch.StartNew(); - using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); - using var command = new SqlCommand(query, sqlConnection); - command.CommandTimeout = CommandTimeoutSeconds; - - using var reader = await command.ExecuteReaderAsync(cancellationToken); - while (await reader.ReadAsync(cancellationToken)) - { - rows.Add(( - Convert.ToInt16(reader.GetValue(0)), - Convert.ToInt32(reader.GetValue(1)), - reader.IsDBNull(2) ? 0m : reader.GetDecimal(2), - reader.IsDBNull(3) ? 0m : reader.GetDecimal(3), - reader.IsDBNull(4) ? 0m : reader.GetDecimal(4), - reader.IsDBNull(5) ? 0m : reader.GetDecimal(5), - reader.IsDBNull(6) ? 0m : reader.GetDecimal(6), - reader.IsDBNull(7) ? 0m : reader.GetDecimal(7), - reader.IsDBNull(8) ? 0 : Convert.ToInt32(reader.GetValue(8)), - reader.IsDBNull(9) ? 0 : Convert.ToInt32(reader.GetValue(9)), - reader.IsDBNull(10) ? 0L : Convert.ToInt64(reader.GetValue(10)), - reader.IsDBNull(11) ? 0L : Convert.ToInt64(reader.GetValue(11)))); - } - sqlSw.Stop(); - - var duckSw = Stopwatch.StartNew(); - - using (var duckConnection = _duckDb.CreateConnection()) - { - await duckConnection.OpenAsync(cancellationToken); - - using (var appender = duckConnection.CreateAppender("memory_grant_stats")) - { - foreach (var r in rows) - { - var deltaKey = $"{r.PoolId}_{r.ResourceSemaphoreId}"; - var deltaTimeouts = _deltaCalculator.CalculateDelta(serverId, "memory_grants_timeouts", deltaKey, r.TimeoutErrorCount, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaForced = _deltaCalculator.CalculateDelta(serverId, "memory_grants_forced", deltaKey, r.ForcedGrantCount, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(GetServerNameForStorage(server)) - .AppendValue(r.ResourceSemaphoreId) - .AppendValue(r.PoolId) - .AppendValue(r.TargetMb) - .AppendValue(r.MaxTargetMb) - .AppendValue(r.TotalMb) - .AppendValue(r.AvailableMb) - .AppendValue(r.GrantedMb) - .AppendValue(r.UsedMb) - .AppendValue(r.GranteeCount) - .AppendValue(r.WaiterCount) - .AppendValue(r.TimeoutErrorCount) - .AppendValue(r.ForcedGrantCount) - .AppendValue(deltaTimeouts) - .AppendValue(deltaForced) - .EndRow(); - rowsCollected++; - } - } - } - - duckSw.Stop(); - _lastSqlMs = sqlSw.ElapsedMilliseconds; - _lastDuckDbMs = duckSw.ElapsedMilliseconds; - - _logger?.LogDebug("Collected {RowCount} memory grant records for server '{Server}'", rowsCollected, server.DisplayName); - return rowsCollected; - } + private Task<int> CollectMemoryGrantStatsAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(MemoryGrantsCollector.Instance, server, cancellationToken); } diff --git a/Lite/Services/RemoteCollectorService.Perfmon.cs b/Lite/Services/RemoteCollectorService.Perfmon.cs index 145b77bd2..8ea25f43f 100644 --- a/Lite/Services/RemoteCollectorService.Perfmon.cs +++ b/Lite/Services/RemoteCollectorService.Perfmon.cs @@ -6,100 +6,31 @@ * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ -using System; -using System.Collections.Generic; -using System.Diagnostics; using System.IO; -using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using DuckDB.NET.Data; -using Microsoft.Data.SqlClient; -using Microsoft.Extensions.Logging; +using PerformanceMonitor.Collectors; using PerformanceMonitorLite.Models; namespace PerformanceMonitorLite.Services; public partial class RemoteCollectorService { - private static readonly string[] DefaultPerfmonCounters = - [ - /* I/O counters */ - "Forwarded Records/sec", - "Page reads/sec", - "Page writes/sec", - "Checkpoint pages/sec", - "Page lookups/sec", - "Readahead pages/sec", - "Background writer pages/sec", - "Lazy writes/sec", - "Full Scans/sec", - "Index Searches/sec", - "Page Splits/sec", - "Free list stalls/sec", - "Non-Page latch waits", - "Page IO latch waits", - "Page latch waits", - /* Transaction counters */ - "Transactions/sec", - "Longest Transaction Running Time", - /* Locking counters */ - "Table Lock Escalations/sec", - "Lock Requests/sec", - "Lock Wait Time (ms)", - "Lock Waits/sec", - "Number of Deadlocks/sec", - "Lock waits", - "Processes blocked", - "Lock Timeouts/sec", - /* Memory counters */ - "Granted Workspace Memory (KB)", - "Lock Memory (KB)", - "Memory Grants Pending", - "SQL Cache Memory (KB)", - "Stolen Server Memory (KB)", - "Target Server Memory (KB)", - "Total Server Memory (KB)", - "Memory grant queue waits", - "Thread-safe memory objects waits", - /* Compilation counters */ - "SQL Compilations/sec", - "SQL Re-Compilations/sec", - "Query optimizations/sec", - "Reduced memory grants/sec", - /* Batch and request counters */ - "Batch Requests/sec", - "Requests completed/sec", - "Active requests", - "Queued requests", - "Blocked tasks", - "Active parallel threads", - /* Log counters */ - "Log Flushes/sec", - "Log Bytes Flushed/sec", - "Log Flush Write Time (ms)", - "Log buffer waits", - "Log write waits", - /* TempDB counters */ - "Version Store Size (KB)", - "Free Space in tempdb (KB)", - "Active Temp Tables", - "Version Generation rate (KB/s)", - "Version Cleanup rate (KB/s)", - "Temp Tables Creation Rate", - "Workfiles Created/sec", - "Worktables Created/sec", - /* Wait counters */ - "Network IO waits", - "Wait for the worker" - ]; + private static string[]? s_perfmonCounterOverride; + private static bool s_perfmonCounterOverrideLoaded; - private static string[]? s_perfmonCounters; - - private static string[] GetPerfmonCounters() + /// <summary> + /// Host-side perfmon counter override from perfmon_counters.json (per-user config). Returns + /// null when absent/invalid so the shared definition's curated default list applies. Cached + /// after the first load, matching the original collector's one-time read. + /// </summary> + private static string[]? GetPerfmonCounterOverride() { - if (s_perfmonCounters != null) return s_perfmonCounters; + if (s_perfmonCounterOverrideLoaded) + { + return s_perfmonCounterOverride; + } var configPath = Path.Combine(App.ConfigDirectory, "perfmon_counters.json"); if (File.Exists(configPath)) @@ -110,15 +41,14 @@ private static string[] GetPerfmonCounters() var config = JsonSerializer.Deserialize<PerfmonCounterConfig>(json); if (config?.Counters?.Length > 0) { - s_perfmonCounters = config.Counters; - return s_perfmonCounters; + s_perfmonCounterOverride = config.Counters; } } - catch { /* fall through to defaults */ } + catch { /* fall through to the definition's defaults */ } } - s_perfmonCounters = DefaultPerfmonCounters; - return s_perfmonCounters; + s_perfmonCounterOverrideLoaded = true; + return s_perfmonCounterOverride; } private class PerfmonCounterConfig @@ -127,85 +57,10 @@ private class PerfmonCounterConfig } /// <summary> - /// Collects key performance counters from sys.dm_os_performance_counters. + /// Collects key performance counters via the shared <see cref="PerfmonStatsCollector"/> + /// definition (the curated default counter list, the literal-escape interpolation, and the + /// "{object}|{counter}|{instance}" delta contract live there — the cross-SKU parity contract). /// </summary> - private async Task<int> CollectPerfmonStatsAsync(ServerConnection server, CancellationToken cancellationToken) - { - var counters = GetPerfmonCounters(); - var counterList = string.Join(",\n ", counters.Select(c => $"N'{c.Replace("'", "''")}'")); - - var query = $@" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -SELECT - object_name = RTRIM(pc.object_name), - counter_name = RTRIM(pc.counter_name), - instance_name = RTRIM(pc.instance_name), - cntr_value = pc.cntr_value -FROM sys.dm_os_performance_counters AS pc -WHERE pc.counter_name IN ( - {counterList} -) -OPTION(RECOMPILE);"; - - var serverId = GetServerId(server); - var collectionTime = DateTime.UtcNow; - var rowsCollected = 0; - _lastSqlMs = 0; - _lastDuckDbMs = 0; - - var sqlSw = Stopwatch.StartNew(); - using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); - using var command = new SqlCommand(query, sqlConnection); - command.CommandTimeout = CommandTimeoutSeconds; - - using var reader = await command.ExecuteReaderAsync(cancellationToken); - sqlSw.Stop(); - _lastSqlMs = sqlSw.ElapsedMilliseconds; - - var duckSw = Stopwatch.StartNew(); - - using (var duckConnection = _duckDb.CreateConnection()) - { - await duckConnection.OpenAsync(cancellationToken); - - using (var appender = duckConnection.CreateAppender("perfmon_stats")) - { - while (await reader.ReadAsync(cancellationToken)) - { - var objectName = reader.IsDBNull(0) ? "" : reader.GetString(0); - var counterName = reader.IsDBNull(1) ? "" : reader.GetString(1); - var instanceName = reader.IsDBNull(2) ? "" : reader.GetString(2); - var cntrValue = reader.GetInt64(3); - - /* Delta for per-second counters — gap detection at 5min (5x the 1-min collection interval) - prevents inflated deltas after app restarts */ - var deltaKey = $"{objectName}|{counterName}|{instanceName}"; - var deltaCntrValue = _deltaCalculator.CalculateDelta(serverId, "perfmon", deltaKey, cntrValue, - baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(GetServerNameForStorage(server)) - .AppendValue(objectName) - .AppendValue(counterName) - .AppendValue(instanceName) - .AppendValue(cntrValue) - .AppendValue(deltaCntrValue) - .AppendValue(60) /* 1-minute collection interval */ - .EndRow(); - - rowsCollected++; - } - } - } - - duckSw.Stop(); - _lastDuckDbMs = duckSw.ElapsedMilliseconds; - - _logger?.LogDebug("Collected {RowCount} perfmon counters for server '{Server}'", rowsCollected, server.DisplayName); - return rowsCollected; - } + private Task<int> CollectPerfmonStatsAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(PerfmonStatsCollector.Instance, server, cancellationToken); } diff --git a/Lite/Services/RemoteCollectorService.ProcedureStats.cs b/Lite/Services/RemoteCollectorService.ProcedureStats.cs index 3bd37c31a..9be668610 100644 --- a/Lite/Services/RemoteCollectorService.ProcedureStats.cs +++ b/Lite/Services/RemoteCollectorService.ProcedureStats.cs @@ -6,13 +6,9 @@ * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ -using System; -using System.Diagnostics; using System.Threading; using System.Threading.Tasks; -using DuckDB.NET.Data; -using Microsoft.Data.SqlClient; -using Microsoft.Extensions.Logging; +using PerformanceMonitor.Collectors; using PerformanceMonitorLite.Models; namespace PerformanceMonitorLite.Services; @@ -20,335 +16,11 @@ namespace PerformanceMonitorLite.Services; public partial class RemoteCollectorService { /// <summary> - /// Collects procedure statistics from sys.dm_exec_procedure_stats. + /// Collects procedure/trigger/function statistics via the shared + /// <see cref="ProcedureStatsCollector"/> definition (the dynamic-SQL spills splice, the + /// double-escaped literal exclusion filter, the Azure single-database variant, and the + /// plan_handle-keyed delta groups live there — the cross-SKU parity contract). /// </summary> - private async Task<int> CollectProcedureStatsAsync(ServerConnection server, CancellationToken cancellationToken) - { - /* On Azure SQL DB, dm_exec_plan_attributes reports dbid=1 (master) for ALL plans, - so the standard NOT IN filter excludes everything. Use a simplified query. */ - var serverStatus = _serverManager.GetConnectionStatus(server.Id); - bool isAzureSqlDb = serverStatus.SqlEngineEdition == 5; - - /* total_spills/min_spills/max_spills exist in dm_exec_procedure_stats and dm_exec_trigger_stats - on all supported versions, but do NOT exist in dm_exec_function_stats on any version. - Use dynamic SQL to handle this. */ - string standardQuery = @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -DECLARE - @spills_cols nvarchar(400) = N'total_spills = ISNULL(s.total_spills, 0), min_spills = ISNULL(s.min_spills, 0), max_spills = ISNULL(s.max_spills, 0),', - @fn_spills_cols nvarchar(400) = N'total_spills = CONVERT(bigint, 0), min_spills = CONVERT(bigint, 0), max_spills = CONVERT(bigint, 0),', - @sql nvarchar(max); - -SET @sql = CAST(N' -SELECT /* PerformanceMonitorLite */ TOP (150) * FROM ( -SELECT - database_name = d.name, - schema_name = OBJECT_SCHEMA_NAME(s.object_id, s.database_id), - object_name = OBJECT_NAME(s.object_id, s.database_id), - object_type = N''PROCEDURE'', - cached_time = s.cached_time, - last_execution_time = s.last_execution_time, - execution_count = s.execution_count, - total_worker_time = s.total_worker_time, - total_elapsed_time = s.total_elapsed_time, - total_logical_reads = s.total_logical_reads, - total_physical_reads = s.total_physical_reads, - total_logical_writes = s.total_logical_writes, - min_worker_time = s.min_worker_time, - max_worker_time = s.max_worker_time, - min_elapsed_time = s.min_elapsed_time, - max_elapsed_time = s.max_elapsed_time, - min_logical_reads = s.min_logical_reads, - max_logical_reads = s.max_logical_reads, - min_physical_reads = s.min_physical_reads, - max_physical_reads = s.max_physical_reads, - min_logical_writes = s.min_logical_writes, - max_logical_writes = s.max_logical_writes, - ' AS nvarchar(max)) + @spills_cols + N' - sql_handle = CONVERT(varchar(64), s.sql_handle, 1), - plan_handle = CONVERT(varchar(64), s.plan_handle, 1) -FROM sys.dm_exec_procedure_stats AS s -CROSS APPLY -( - SELECT - dbid = CONVERT(integer, pa.value) - FROM sys.dm_exec_plan_attributes(s.plan_handle) AS pa - WHERE pa.attribute = N''dbid'' -) AS pa -INNER JOIN sys.databases AS d - ON pa.dbid = d.database_id -WHERE d.state = 0 -AND pa.dbid NOT IN (1, 3, 4, 32761, 32767, ISNULL(DB_ID(N''PerformanceMonitor''), 0)) -AND s.last_execution_time >= DATEADD(MINUTE, -10, GETDATE()) -/*EXCLUSION_FILTER*/ - -UNION ALL - -SELECT - database_name = d.name, - schema_name = ISNULL(OBJECT_SCHEMA_NAME(s.object_id, s.database_id), N''dbo''), - object_name = COALESCE( - OBJECT_NAME(s.object_id, s.database_id), - CASE - WHEN CHARINDEX(N''CREATE TRIGGER'', st.text) > 0 - THEN LTRIM(RTRIM(REPLACE(REPLACE( - SUBSTRING( - st.text, - CHARINDEX(N''CREATE TRIGGER'', st.text) + 15, - CHARINDEX(N'' ON '', st.text + N'' ON '', CHARINDEX(N''CREATE TRIGGER'', st.text) + 15) - CHARINDEX(N''CREATE TRIGGER'', st.text) - 15 - ), N''['', N''''), N'']'', N''''))) - ELSE N''trigger_'' + CONVERT(nvarchar(20), s.object_id) - END - ), - object_type = N''TRIGGER'', - cached_time = s.cached_time, - last_execution_time = s.last_execution_time, - execution_count = s.execution_count, - total_worker_time = s.total_worker_time, - total_elapsed_time = s.total_elapsed_time, - total_logical_reads = s.total_logical_reads, - total_physical_reads = s.total_physical_reads, - total_logical_writes = s.total_logical_writes, - min_worker_time = s.min_worker_time, - max_worker_time = s.max_worker_time, - min_elapsed_time = s.min_elapsed_time, - max_elapsed_time = s.max_elapsed_time, - min_logical_reads = s.min_logical_reads, - max_logical_reads = s.max_logical_reads, - min_physical_reads = s.min_physical_reads, - max_physical_reads = s.max_physical_reads, - min_logical_writes = s.min_logical_writes, - max_logical_writes = s.max_logical_writes, - ' + @spills_cols + CAST(N' - sql_handle = CONVERT(varchar(64), s.sql_handle, 1), - plan_handle = CONVERT(varchar(64), s.plan_handle, 1) -FROM sys.dm_exec_trigger_stats AS s -CROSS APPLY sys.dm_exec_sql_text(s.sql_handle) AS st -CROSS APPLY -( - SELECT - dbid = CONVERT(integer, pa.value) - FROM sys.dm_exec_plan_attributes(s.plan_handle) AS pa - WHERE pa.attribute = N''dbid'' -) AS pa -INNER JOIN sys.databases AS d - ON pa.dbid = d.database_id -WHERE d.state = 0 -AND pa.dbid NOT IN (1, 3, 4, 32761, 32767, ISNULL(DB_ID(N''PerformanceMonitor''), 0)) -AND s.last_execution_time >= DATEADD(MINUTE, -10, GETDATE()) -/*EXCLUSION_FILTER*/ - -UNION ALL - -SELECT - database_name = d.name, - schema_name = OBJECT_SCHEMA_NAME(s.object_id, s.database_id), - object_name = OBJECT_NAME(s.object_id, s.database_id), - object_type = N''FUNCTION'', - cached_time = s.cached_time, - last_execution_time = s.last_execution_time, - execution_count = s.execution_count, - total_worker_time = s.total_worker_time, - total_elapsed_time = s.total_elapsed_time, - total_logical_reads = s.total_logical_reads, - total_physical_reads = s.total_physical_reads, - total_logical_writes = s.total_logical_writes, - min_worker_time = s.min_worker_time, - max_worker_time = s.max_worker_time, - min_elapsed_time = s.min_elapsed_time, - max_elapsed_time = s.max_elapsed_time, - min_logical_reads = s.min_logical_reads, - max_logical_reads = s.max_logical_reads, - min_physical_reads = s.min_physical_reads, - max_physical_reads = s.max_physical_reads, - min_logical_writes = s.min_logical_writes, - max_logical_writes = s.max_logical_writes, - ' AS nvarchar(max)) + @fn_spills_cols + CAST(N' - sql_handle = CONVERT(varchar(64), s.sql_handle, 1), - plan_handle = CONVERT(varchar(64), s.plan_handle, 1) -FROM sys.dm_exec_function_stats AS s -CROSS APPLY -( - SELECT - dbid = CONVERT(integer, pa.value) - FROM sys.dm_exec_plan_attributes(s.plan_handle) AS pa - WHERE pa.attribute = N''dbid'' -) AS pa -INNER JOIN sys.databases AS d - ON pa.dbid = d.database_id -WHERE d.state = 0 -AND pa.dbid NOT IN (1, 3, 4, 32761, 32767, ISNULL(DB_ID(N''PerformanceMonitor''), 0)) -AND s.last_execution_time >= DATEADD(MINUTE, -10, GETDATE()) -/*EXCLUSION_FILTER*/ -) AS combined -ORDER BY total_elapsed_time DESC -OPTION(RECOMPILE);' AS nvarchar(max)); - -EXECUTE sys.sp_executesql @sql;"; - - /* Azure SQL DB: skip plan_attributes (reports dbid=1 for all plans), use DB_NAME() directly. - No trigger stats or function stats — Azure SQL DB scope is single-database. */ - const string azureSqlDbQuery = @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -SELECT /* PerformanceMonitorLite */ TOP (150) - database_name = DB_NAME(), - schema_name = OBJECT_SCHEMA_NAME(s.object_id, s.database_id), - object_name = OBJECT_NAME(s.object_id, s.database_id), - object_type = N'PROCEDURE', - cached_time = s.cached_time, - last_execution_time = s.last_execution_time, - execution_count = s.execution_count, - total_worker_time = s.total_worker_time, - total_elapsed_time = s.total_elapsed_time, - total_logical_reads = s.total_logical_reads, - total_physical_reads = s.total_physical_reads, - total_logical_writes = s.total_logical_writes, - min_worker_time = s.min_worker_time, - max_worker_time = s.max_worker_time, - min_elapsed_time = s.min_elapsed_time, - max_elapsed_time = s.max_elapsed_time, - min_logical_reads = s.min_logical_reads, - max_logical_reads = s.max_logical_reads, - min_physical_reads = s.min_physical_reads, - max_physical_reads = s.max_physical_reads, - min_logical_writes = s.min_logical_writes, - max_logical_writes = s.max_logical_writes, - total_spills = ISNULL(s.total_spills, 0), - min_spills = ISNULL(s.min_spills, 0), - max_spills = ISNULL(s.max_spills, 0), - sql_handle = CONVERT(varchar(64), s.sql_handle, 1), - plan_handle = CONVERT(varchar(64), s.plan_handle, 1) -FROM sys.dm_exec_procedure_stats AS s -WHERE s.database_id = DB_ID() -AND s.last_execution_time >= DATEADD(MINUTE, -10, GETDATE()) -/*EXCLUSION_FILTER*/ -ORDER BY s.total_elapsed_time DESC -OPTION(RECOMPILE);"; - - /* Standard query is dynamic SQL (built into @sql then passed to sp_executesql), so the - exclusion filter is interpolated as literal N'...' values rather than parameter bindings. - Names come from a user-picked checklist of existing DBs, escaped against single-quote - injection. forNestedDynamicSql=true doubles the escape because @sql is itself a - single-quoted T-SQL string at the outer layer. */ - string standardExclusionClause = BuildDatabaseExclusionLiteralClause( - server.ExcludedDatabases, "d.name", forNestedDynamicSql: true); - standardQuery = standardQuery.Replace("/*EXCLUSION_FILTER*/", standardExclusionClause); - - string query = isAzureSqlDb ? azureSqlDbQuery : standardQuery; - - var serverId = GetServerId(server); - var collectionTime = DateTime.UtcNow; - var rowsCollected = 0; - _lastSqlMs = 0; - _lastDuckDbMs = 0; - - var sqlSw = Stopwatch.StartNew(); - using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); - using var command = new SqlCommand(query, sqlConnection); - command.CommandTimeout = CommandTimeoutSeconds; - - using var reader = await command.ExecuteReaderAsync(cancellationToken); - - sqlSw.Stop(); - - var duckSw = Stopwatch.StartNew(); - - using (var duckConnection = _duckDb.CreateConnection()) - { - await duckConnection.OpenAsync(cancellationToken); - - using (var appender = duckConnection.CreateAppender("procedure_stats")) - { - while (await reader.ReadAsync(cancellationToken)) - { - /* Reader ordinals match SELECT column order: - 0=database_name, 1=schema_name, 2=object_name, 3=object_type, - 4=cached_time, 5=last_execution_time, - 6=execution_count, 7=total_worker_time, 8=total_elapsed_time, - 9=total_logical_reads, 10=total_physical_reads, 11=total_logical_writes, - 12=min_worker_time, 13=max_worker_time, 14=min_elapsed_time, 15=max_elapsed_time, - 16=min_logical_reads, 17=max_logical_reads, 18=min_physical_reads, 19=max_physical_reads, - 20=min_logical_writes, 21=max_logical_writes, - 22=total_spills, 23=min_spills, 24=max_spills, - 25=sql_handle, 26=plan_handle */ - var dbName = reader.IsDBNull(0) ? "" : reader.GetString(0); - var schemaName = reader.IsDBNull(1) ? "" : reader.GetString(1); - var objectName = reader.IsDBNull(2) ? "" : reader.GetString(2); - var objectType = reader.IsDBNull(3) ? "" : reader.GetString(3); - var cachedTime = reader.IsDBNull(4) ? (DateTime?)null : reader.GetDateTime(4); - var lastExecTime = reader.IsDBNull(5) ? (DateTime?)null : reader.GetDateTime(5); - var execCount = reader.GetInt64(6); - var workerTime = reader.GetInt64(7); - var elapsedTime = reader.GetInt64(8); - var logicalReads = reader.GetInt64(9); - var physicalReads = reader.GetInt64(10); - var logicalWrites = reader.GetInt64(11); - var sqlHandle = reader.IsDBNull(25) ? (string?)null : reader.GetString(25); - var planHandle = reader.IsDBNull(26) ? (string?)null : reader.GetString(26); - - /* Delta key: plan_handle to prevent cross-contamination - when multiple plans exist for the same object */ - var deltaKey = planHandle ?? $"{dbName}.{schemaName}.{objectName}"; - var deltaExec = _deltaCalculator.CalculateDelta(serverId, "proc_stats_exec", deltaKey, execCount, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaWorker = _deltaCalculator.CalculateDelta(serverId, "proc_stats_worker", deltaKey, workerTime, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaElapsed = _deltaCalculator.CalculateDelta(serverId, "proc_stats_elapsed", deltaKey, elapsedTime, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaReads = _deltaCalculator.CalculateDelta(serverId, "proc_stats_reads", deltaKey, logicalReads, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaWrites = _deltaCalculator.CalculateDelta(serverId, "proc_stats_writes", deltaKey, logicalWrites, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaPhysReads = _deltaCalculator.CalculateDelta(serverId, "proc_stats_phys_reads", deltaKey, physicalReads, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - - /* Appender column order must match DuckDB table definition exactly */ - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) /* collection_id */ - .AppendValue(collectionTime) /* collection_time */ - .AppendValue(serverId) /* server_id */ - .AppendValue(GetServerNameForStorage(server)) /* server_name */ - .AppendValue(dbName) /* database_name */ - .AppendValue(schemaName) /* schema_name */ - .AppendValue(objectName) /* object_name */ - .AppendValue(objectType) /* object_type */ - .AppendValue(cachedTime) /* cached_time */ - .AppendValue(lastExecTime) /* last_execution_time */ - .AppendValue(execCount) /* execution_count */ - .AppendValue(workerTime) /* total_worker_time */ - .AppendValue(elapsedTime) /* total_elapsed_time */ - .AppendValue(logicalReads) /* total_logical_reads */ - .AppendValue(physicalReads) /* total_physical_reads */ - .AppendValue(logicalWrites) /* total_logical_writes */ - .AppendValue(reader.IsDBNull(12) ? 0L : reader.GetInt64(12)) /* min_worker_time */ - .AppendValue(reader.IsDBNull(13) ? 0L : reader.GetInt64(13)) /* max_worker_time */ - .AppendValue(reader.IsDBNull(14) ? 0L : reader.GetInt64(14)) /* min_elapsed_time */ - .AppendValue(reader.IsDBNull(15) ? 0L : reader.GetInt64(15)) /* max_elapsed_time */ - .AppendValue(reader.IsDBNull(16) ? 0L : reader.GetInt64(16)) /* min_logical_reads */ - .AppendValue(reader.IsDBNull(17) ? 0L : reader.GetInt64(17)) /* max_logical_reads */ - .AppendValue(reader.IsDBNull(18) ? 0L : reader.GetInt64(18)) /* min_physical_reads */ - .AppendValue(reader.IsDBNull(19) ? 0L : reader.GetInt64(19)) /* max_physical_reads */ - .AppendValue(reader.IsDBNull(20) ? 0L : reader.GetInt64(20)) /* min_logical_writes */ - .AppendValue(reader.IsDBNull(21) ? 0L : reader.GetInt64(21)) /* max_logical_writes */ - .AppendValue(reader.GetInt64(22)) /* total_spills */ - .AppendValue(reader.GetInt64(23)) /* min_spills */ - .AppendValue(reader.GetInt64(24)) /* max_spills */ - .AppendValue(sqlHandle) /* sql_handle */ - .AppendValue(planHandle) /* plan_handle */ - .AppendValue(deltaExec) /* delta_execution_count */ - .AppendValue(deltaWorker) /* delta_worker_time */ - .AppendValue(deltaElapsed) /* delta_elapsed_time */ - .AppendValue(deltaReads) /* delta_logical_reads */ - .AppendValue(deltaWrites) /* delta_logical_writes */ - .AppendValue(deltaPhysReads) /* delta_physical_reads */ - .EndRow(); - - rowsCollected++; - } - } - } - - duckSw.Stop(); - _lastSqlMs = sqlSw.ElapsedMilliseconds; - _lastDuckDbMs = duckSw.ElapsedMilliseconds; - - _logger?.LogDebug("Collected {RowCount} procedure stats for server '{Server}'", rowsCollected, server.DisplayName); - return rowsCollected; - } + private Task<int> CollectProcedureStatsAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(ProcedureStatsCollector.Instance, server, cancellationToken); } diff --git a/Lite/Services/RemoteCollectorService.QuerySnapshots.cs b/Lite/Services/RemoteCollectorService.QuerySnapshots.cs index 63448c97d..ffae8013d 100644 --- a/Lite/Services/RemoteCollectorService.QuerySnapshots.cs +++ b/Lite/Services/RemoteCollectorService.QuerySnapshots.cs @@ -6,342 +6,30 @@ * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ -using System; -using System.Diagnostics; -using System.Text; using System.Threading; using System.Threading.Tasks; -using DuckDB.NET.Data; -using Microsoft.Data.SqlClient; -using Microsoft.Extensions.Logging; +using PerformanceMonitor.Collectors; using PerformanceMonitorLite.Models; namespace PerformanceMonitorLite.Services; public partial class RemoteCollectorService { - private const string QuerySnapshotsBase = """ - -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; -SET LOCK_TIMEOUT 1000; - -DECLARE @cdc_capture_jobs TABLE (job_id uniqueidentifier PRIMARY KEY); -DECLARE @cdc_readable bit = 0; -BEGIN TRY - INSERT @cdc_capture_jobs (job_id) - EXEC sys.sp_executesql N'SELECT cj.job_id FROM msdb.dbo.cdc_jobs AS cj WHERE cj.job_type = N''capture'';'; - SET @cdc_readable = 1; -END TRY -BEGIN CATCH - SET @cdc_readable = 0; -END CATCH; - -SELECT /* PerformanceMonitorLite */ - der.session_id, - database_name = d.name, - elapsed_time_formatted = - CASE - WHEN der.total_elapsed_time < 0 - THEN '00 00:00:00.000' - ELSE RIGHT(REPLICATE('0', 2) + CONVERT(varchar(10), der.total_elapsed_time / 86400000), 2) + - ' ' + RIGHT(CONVERT(varchar(30), DATEADD(second, der.total_elapsed_time / 1000, 0), 120), 9) + - '.' + RIGHT('000' + CONVERT(varchar(3), der.total_elapsed_time % 1000), 3) - END, - query_text = SUBSTRING(dest.text, (der.statement_start_offset / 2) + 1, - ((CASE der.statement_end_offset WHEN -1 THEN DATALENGTH(dest.text) - ELSE der.statement_end_offset END - der.statement_start_offset) / 2) + 1), - query_plan = TRY_CAST(deqp.query_plan AS nvarchar(max)), - {0} - der.status, - der.blocking_session_id, - der.wait_type, - wait_time_ms = CONVERT(bigint, der.wait_time), - der.wait_resource, - cpu_time_ms = CONVERT(bigint, der.cpu_time), - total_elapsed_time_ms = CONVERT(bigint, der.total_elapsed_time), - der.reads, - der.writes, - der.logical_reads, - granted_query_memory_gb = CONVERT(decimal(38, 2), (der.granted_query_memory / 128. / 1024.)), - transaction_isolation_level = - CASE der.transaction_isolation_level - WHEN 0 THEN 'Unspecified' - WHEN 1 THEN 'Read Uncommitted' - WHEN 2 THEN 'Read Committed' - WHEN 3 THEN 'Repeatable Read' - WHEN 4 THEN 'Serializable' - WHEN 5 THEN 'Snapshot' - ELSE '???' - END, - der.dop, - der.parallel_worker_count, - des.login_name, - des.host_name, - des.program_name, - des.open_transaction_count, - der.percent_complete, - is_cdc_capture = - CONVERT(bit, - CASE - WHEN @cdc_readable = 1 - AND des.program_name LIKE N'SQLAgent - TSQL JobStep (Job 0x%' - AND TRY_CONVERT(uniqueidentifier, TRY_CONVERT(binary(16), SUBSTRING(des.program_name, 32, 32), 2)) - IN (SELECT j.job_id FROM @cdc_capture_jobs AS j) - THEN 1 - WHEN @cdc_readable = 0 - AND dest.text IS NOT NULL - AND (dest.text LIKE N'%sp_MScdc_capture_job%' OR dest.text LIKE N'%sp_cdc_scan%') - THEN 1 - ELSE 0 - END), - query_hash = CONVERT(varchar(18), der.query_hash, 1) /* #1140 long-running-query dedup key */ -FROM sys.dm_exec_requests AS der -JOIN sys.dm_exec_sessions AS des - ON des.session_id = der.session_id -OUTER APPLY sys.dm_exec_sql_text(COALESCE(der.sql_handle, der.plan_handle)) AS dest -OUTER APPLY sys.dm_exec_text_query_plan(der.plan_handle, der.statement_start_offset, der.statement_end_offset) AS deqp -LEFT JOIN sys.databases AS d - ON d.database_id = der.database_id -{1} -WHERE der.session_id <> @@SPID -AND der.session_id >= 50 -AND dest.text IS NOT NULL -AND der.database_id <> ISNULL(DB_ID(N'PerformanceMonitor'), 0) -ORDER BY der.cpu_time DESC, der.parallel_worker_count DESC -OPTION(MAXDOP 1, RECOMPILE); -"""; - private readonly static CompositeFormat QuerySnapshotsBaseFormat = CompositeFormat.Parse(QuerySnapshotsBase); - - /* - * On Azure SQL Database with a contained / DB-scoped login (e.g. D365FO), - * the OUTER APPLY to sys.dm_exec_sql_text / sys.dm_exec_text_query_plan - * will be evaluated against master-scoped rows from sys.dm_exec_requests - * before the WHERE predicate applies, tripping error 300 (VIEW SERVER - * PERFORMANCE STATE denied). Materialising the filtered request rows - * into a #temp table first guarantees the DMFs only see handles from - * the current database. See #857. - */ - private const string QuerySnapshotsAzureBase = """ - -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; -SET LOCK_TIMEOUT 1000; - -IF OBJECT_ID(N'tempdb..#req') IS NOT NULL DROP TABLE #req; - -SELECT - der.session_id, - der.database_id, - der.sql_handle, - der.plan_handle, - der.statement_start_offset, - der.statement_end_offset, - der.status, - der.blocking_session_id, - der.wait_type, - der.wait_time, - der.wait_resource, - der.cpu_time, - der.total_elapsed_time, - der.reads, - der.writes, - der.logical_reads, - der.granted_query_memory, - der.transaction_isolation_level, - der.dop, - der.parallel_worker_count, - der.percent_complete, - der.query_hash -INTO #req -FROM sys.dm_exec_requests AS der -WHERE der.session_id <> @@SPID -AND der.session_id >= 50 -AND der.database_id = DB_ID() -AND der.database_id <> ISNULL(DB_ID(N'PerformanceMonitor'), 0); - -SELECT /* PerformanceMonitorLite */ - der.session_id, - database_name = d.name, - elapsed_time_formatted = - CASE - WHEN der.total_elapsed_time < 0 - THEN '00 00:00:00.000' - ELSE RIGHT(REPLICATE('0', 2) + CONVERT(varchar(10), der.total_elapsed_time / 86400000), 2) + - ' ' + RIGHT(CONVERT(varchar(30), DATEADD(second, der.total_elapsed_time / 1000, 0), 120), 9) + - '.' + RIGHT('000' + CONVERT(varchar(3), der.total_elapsed_time % 1000), 3) - END, - query_text = SUBSTRING(dest.text, (der.statement_start_offset / 2) + 1, - ((CASE der.statement_end_offset WHEN -1 THEN DATALENGTH(dest.text) - ELSE der.statement_end_offset END - der.statement_start_offset) / 2) + 1), - query_plan = TRY_CAST(deqp.query_plan AS nvarchar(max)), - {0} - der.status, - der.blocking_session_id, - der.wait_type, - wait_time_ms = CONVERT(bigint, der.wait_time), - der.wait_resource, - cpu_time_ms = CONVERT(bigint, der.cpu_time), - total_elapsed_time_ms = CONVERT(bigint, der.total_elapsed_time), - der.reads, - der.writes, - der.logical_reads, - granted_query_memory_gb = CONVERT(decimal(38, 2), (der.granted_query_memory / 128. / 1024.)), - transaction_isolation_level = - CASE der.transaction_isolation_level - WHEN 0 THEN 'Unspecified' - WHEN 1 THEN 'Read Uncommitted' - WHEN 2 THEN 'Read Committed' - WHEN 3 THEN 'Repeatable Read' - WHEN 4 THEN 'Serializable' - WHEN 5 THEN 'Snapshot' - ELSE '???' - END, - der.dop, - der.parallel_worker_count, - des.login_name, - des.host_name, - des.program_name, - des.open_transaction_count, - der.percent_complete, - /* Azure SQL Database has no SQL Agent / msdb.dbo.cdc_jobs (CDC there is scheduler-based), so no capture job to exclude. */ - is_cdc_capture = CONVERT(bit, 0), - query_hash = CONVERT(varchar(18), der.query_hash, 1) /* #1140 long-running-query dedup key */ -FROM #req AS der -JOIN sys.dm_exec_sessions AS des - ON des.session_id = der.session_id -OUTER APPLY sys.dm_exec_sql_text(COALESCE(der.sql_handle, der.plan_handle)) AS dest -OUTER APPLY sys.dm_exec_text_query_plan(der.plan_handle, der.statement_start_offset, der.statement_end_offset) AS deqp -LEFT JOIN sys.databases AS d - ON d.database_id = der.database_id -{1} -WHERE dest.text IS NOT NULL -ORDER BY der.cpu_time DESC, der.parallel_worker_count DESC -OPTION(MAXDOP 1, RECOMPILE); - -DROP TABLE #req; -"""; - private readonly static CompositeFormat QuerySnapshotsAzureBaseFormat = CompositeFormat.Parse(QuerySnapshotsAzureBase); - /// <summary> - /// Builds the query snapshots SQL with or without live query plan support. - /// Used by both the collector and the live snapshot button. - /// On Azure SQL Database the request rows are first materialised into a - /// #temp table scoped to the current database, so the downstream OUTER APPLYs - /// to sys.dm_exec_sql_text / sys.dm_exec_text_query_plan only ever see - /// current-DB handles — avoiding the VIEW SERVER PERFORMANCE STATE error - /// that D365FO-style DB-scoped logins hit (see #857). + /// Builds the query snapshots SQL with or without live query plan support — forwards to the + /// shared <see cref="QuerySnapshotsCollector"/> templates so the live snapshot button + /// (ServerTab) and the scheduled collector keep building identical SQL (#857 Azure #req + /// staging included). /// </summary> internal static string BuildQuerySnapshotsQuery(bool supportsLiveQueryPlan, bool isAzureSqlDatabase) - { - var liveQueryPlanColumn = supportsLiveQueryPlan - ? "live_query_plan = deqs.query_plan," - : "live_query_plan = CONVERT(xml, NULL),"; - var liveQueryPlanApply = supportsLiveQueryPlan - ? "OUTER APPLY sys.dm_exec_query_statistics_xml(der.session_id) AS deqs" - : ""; - var template = isAzureSqlDatabase ? QuerySnapshotsAzureBaseFormat : QuerySnapshotsBaseFormat; - return string.Format(null, template, liveQueryPlanColumn, liveQueryPlanApply); - } + => QuerySnapshotsCollector.BuildSnapshotQuery(supportsLiveQueryPlan, isAzureSqlDatabase); /// <summary> - /// Collects currently running queries (point-in-time snapshot). + /// Collects currently running queries via the shared <see cref="QuerySnapshotsCollector"/> + /// definition (the CDC-capture detection, the live-plan version gate, the Azure #req staging + /// from #857, and the #1286 memory/tempdb/transaction triage columns live there — the + /// cross-SKU parity contract). /// </summary> - private async Task<int> CollectQuerySnapshotsAsync(ServerConnection server, CancellationToken cancellationToken) - { - /* - * sys.dm_exec_query_statistics_xml requires SQL Server 2016 SP1+ (version 13) - * on boxed, and VIEW SERVER PERFORMANCE STATE on Azure SQL Database regardless - * of tier. DB-scoped logins (e.g. D365FO) don't have the server-level grant - * and the DMF raises error 300 even when only called for current-DB sessions, - * so we disable live query plans on Azure SQL DB entirely. See #857. - */ - var serverStatus = _serverManager.GetConnectionStatus(server.Id); - var isAzureSqlDatabase = serverStatus.SqlEngineEdition == 5; - var supportsLiveQueryPlan = !isAzureSqlDatabase - && (serverStatus.SqlMajorVersion >= 13 || serverStatus.SqlMajorVersion == 0 - || serverStatus.SqlEngineEdition == 8); - - var query = BuildQuerySnapshotsQuery(supportsLiveQueryPlan, isAzureSqlDatabase); - - /* Append the per-database exclusion filter to the WHERE clause. The base query joins - sys.databases AS d, so we filter on d.name. When ExcludedDatabases is empty the - clause is "" so nothing changes. */ - var (qsExclusionClause, _) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); - if (!string.IsNullOrEmpty(qsExclusionClause)) - { - /* Inject just before the OPTION clause so it lands inside the WHERE. */ - query = query.Replace( - "ORDER BY der.cpu_time DESC", - qsExclusionClause + "\nORDER BY der.cpu_time DESC"); - } - - var serverId = GetServerId(server); - var collectionTime = DateTime.UtcNow; - var rowsCollected = 0; - _lastSqlMs = 0; - _lastDuckDbMs = 0; - - var sqlSw = Stopwatch.StartNew(); - using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); - using var command = new SqlCommand(query, sqlConnection); - command.CommandTimeout = CommandTimeoutSeconds; - var (_, qsExclusionParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); - foreach (var p in qsExclusionParams) command.Parameters.Add(p); - - using var reader = await command.ExecuteReaderAsync(cancellationToken); - sqlSw.Stop(); - _lastSqlMs = sqlSw.ElapsedMilliseconds; - - var duckSw = Stopwatch.StartNew(); - - using (var duckConnection = _duckDb.CreateConnection()) - { - await duckConnection.OpenAsync(cancellationToken); - - using var appender = duckConnection.CreateAppender("query_snapshots"); - while (await reader.ReadAsync(cancellationToken)) - { - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(GetServerNameForStorage(server)) - .AppendValue(Convert.ToInt32(reader.GetValue(0))) /* session_id */ - .AppendValue(reader.IsDBNull(1) ? (string?)null : reader.GetString(1)) /* database_name */ - .AppendValue(reader.IsDBNull(2) ? (string?)null : reader.GetString(2)) /* elapsed_time_formatted */ - .AppendValue(reader.IsDBNull(3) ? (string?)null : reader.GetString(3)) /* query_text */ - .AppendValue(reader.IsDBNull(4) ? (string?)null : reader.GetString(4)) /* query_plan */ - .AppendValue(reader.IsDBNull(5) ? (string?)null : reader.GetValue(5)?.ToString()) /* live_query_plan (xml) */ - .AppendValue(reader.IsDBNull(6) ? (string?)null : reader.GetString(6)) /* status */ - .AppendValue(reader.IsDBNull(7) ? 0 : Convert.ToInt32(reader.GetValue(7))) /* blocking_session_id */ - .AppendValue(reader.IsDBNull(8) ? (string?)null : reader.GetString(8)) /* wait_type */ - .AppendValue(reader.IsDBNull(9) ? 0L : Convert.ToInt64(reader.GetValue(9))) /* wait_time_ms */ - .AppendValue(reader.IsDBNull(10) ? (string?)null : reader.GetString(10)) /* wait_resource */ - .AppendValue(reader.IsDBNull(11) ? 0L : Convert.ToInt64(reader.GetValue(11))) /* cpu_time_ms */ - .AppendValue(reader.IsDBNull(12) ? 0L : Convert.ToInt64(reader.GetValue(12))) /* total_elapsed_time_ms */ - .AppendValue(reader.IsDBNull(13) ? 0L : Convert.ToInt64(reader.GetValue(13))) /* reads */ - .AppendValue(reader.IsDBNull(14) ? 0L : Convert.ToInt64(reader.GetValue(14))) /* writes */ - .AppendValue(reader.IsDBNull(15) ? 0L : Convert.ToInt64(reader.GetValue(15))) /* logical_reads */ - .AppendValue(reader.IsDBNull(16) ? 0m : reader.GetDecimal(16)) /* granted_query_memory_gb */ - .AppendValue(reader.IsDBNull(17) ? (string?)null : reader.GetString(17)) /* transaction_isolation_level */ - .AppendValue(reader.IsDBNull(18) ? 0 : Convert.ToInt32(reader.GetValue(18))) /* dop */ - .AppendValue(reader.IsDBNull(19) ? 0 : Convert.ToInt32(reader.GetValue(19))) /* parallel_worker_count */ - .AppendValue(reader.IsDBNull(20) ? (string?)null : reader.GetString(20)) /* login_name */ - .AppendValue(reader.IsDBNull(21) ? (string?)null : reader.GetString(21)) /* host_name */ - .AppendValue(reader.IsDBNull(22) ? (string?)null : reader.GetString(22)) /* program_name */ - .AppendValue(reader.IsDBNull(23) ? 0 : Convert.ToInt32(reader.GetValue(23))) /* open_transaction_count */ - .AppendValue(reader.IsDBNull(24) ? 0m : Convert.ToDecimal(reader.GetValue(24))) /* percent_complete */ - .AppendValue(!reader.IsDBNull(25) && Convert.ToBoolean(reader.GetValue(25))) /* is_cdc_capture */ - .AppendValue(reader.IsDBNull(26) ? (string?)null : reader.GetString(26)) /* query_hash (#1140) */ - .EndRow(); - - rowsCollected++; - } - } - - duckSw.Stop(); - _lastDuckDbMs = duckSw.ElapsedMilliseconds; - - _logger?.LogDebug("Collected {RowCount} query snapshots for server '{Server}'", rowsCollected, server.DisplayName); - return rowsCollected; - } + private Task<int> CollectQuerySnapshotsAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(QuerySnapshotsCollector.Instance, server, cancellationToken); } diff --git a/Lite/Services/RemoteCollectorService.QueryStats.cs b/Lite/Services/RemoteCollectorService.QueryStats.cs index 4a139c3d8..fd9a1c4a0 100644 --- a/Lite/Services/RemoteCollectorService.QueryStats.cs +++ b/Lite/Services/RemoteCollectorService.QueryStats.cs @@ -6,14 +6,9 @@ * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ -using System; -using System.Collections.Generic; -using System.Diagnostics; using System.Threading; using System.Threading.Tasks; -using DuckDB.NET.Data; -using Microsoft.Data.SqlClient; -using Microsoft.Extensions.Logging; +using PerformanceMonitor.Collectors; using PerformanceMonitorLite.Models; namespace PerformanceMonitorLite.Services; @@ -21,343 +16,11 @@ namespace PerformanceMonitorLite.Services; public partial class RemoteCollectorService { /// <summary> - /// Collects query statistics from sys.dm_exec_query_stats. + /// Collects query statistics via the shared <see cref="QueryStatsCollector"/> definition + /// (the plan_attributes database resolution, the Azure per-database variant, the full + /// row-identity delta key that fixed multi-statement cross-contamination, and the + /// interval-captured worker-time delta live there — the cross-SKU parity contract). /// </summary> - private async Task<int> CollectQueryStatsAsync(ServerConnection server, CancellationToken cancellationToken) - { - /* On Azure SQL DB, dm_exec_plan_attributes reports dbid=1 (master) for ALL queries, - so the standard INNER JOIN + NOT IN filter excludes everything. - Use a simplified query that skips plan_attributes entirely — there's only one user database. */ - var serverStatus = _serverManager.GetConnectionStatus(server.Id); - bool isAzureSqlDb = serverStatus.SqlEngineEdition == 5; - - string standardQuery = @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -SELECT /* PerformanceMonitorLite */ TOP (200) - database_name = d.name, - query_hash = CONVERT(varchar(64), qs.query_hash, 1), - query_plan_hash = CONVERT(varchar(64), qs.query_plan_hash, 1), - creation_time = qs.creation_time, - last_execution_time = qs.last_execution_time, - execution_count = qs.execution_count, - total_worker_time = qs.total_worker_time, - total_elapsed_time = qs.total_elapsed_time, - total_logical_reads = qs.total_logical_reads, - total_logical_writes = qs.total_logical_writes, - total_physical_reads = qs.total_physical_reads, - total_clr_time = qs.total_clr_time, - total_rows = qs.total_rows, - total_spills = qs.total_spills, - min_worker_time = qs.min_worker_time, - max_worker_time = qs.max_worker_time, - min_elapsed_time = qs.min_elapsed_time, - max_elapsed_time = qs.max_elapsed_time, - min_physical_reads = qs.min_physical_reads, - max_physical_reads = qs.max_physical_reads, - min_rows = qs.min_rows, - max_rows = qs.max_rows, - min_dop = qs.min_dop, - max_dop = qs.max_dop, - min_grant_kb = qs.min_grant_kb, - max_grant_kb = qs.max_grant_kb, - min_used_grant_kb = qs.min_used_grant_kb, - max_used_grant_kb = qs.max_used_grant_kb, - min_ideal_grant_kb = qs.min_ideal_grant_kb, - max_ideal_grant_kb = qs.max_ideal_grant_kb, - min_reserved_threads = qs.min_reserved_threads, - max_reserved_threads = qs.max_reserved_threads, - min_used_threads = qs.min_used_threads, - max_used_threads = qs.max_used_threads, - min_spills = qs.min_spills, - max_spills = qs.max_spills, - sql_handle = CONVERT(varchar(64), qs.sql_handle, 1), - plan_handle = CONVERT(varchar(64), qs.plan_handle, 1), - query_text = - CASE - WHEN qs.statement_start_offset = 0 - AND qs.statement_end_offset = -1 - THEN st.text - ELSE - SUBSTRING - ( - st.text, - (qs.statement_start_offset / 2) + 1, - ( - CASE - WHEN qs.statement_end_offset = -1 - THEN DATALENGTH(st.text) - ELSE qs.statement_end_offset - END - qs.statement_start_offset - ) / 2 + 1 - ) - END -FROM sys.dm_exec_query_stats AS qs -OUTER APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st -CROSS APPLY -( - SELECT - dbid = CONVERT(integer, pa.value) - FROM sys.dm_exec_plan_attributes(qs.plan_handle) AS pa - WHERE pa.attribute = N'dbid' -) AS pa -INNER JOIN sys.databases AS d - ON pa.dbid = d.database_id -WHERE pa.dbid NOT IN (1, 2, 3, 4, 32761, 32767, ISNULL(DB_ID(N'PerformanceMonitor'), 0)) -AND st.text NOT LIKE N'%PerformanceMonitorLite%' -AND qs.last_execution_time >= DATEADD(MINUTE, -10, GETDATE()) -/*EXCLUSION_FILTER*/ -ORDER BY - qs.total_elapsed_time DESC -OPTION(RECOMPILE);"; - - var (exclusionClause, _) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); - standardQuery = standardQuery.Replace("/*EXCLUSION_FILTER*/", exclusionClause); - - /* Azure SQL DB: skip plan_attributes, use DB_NAME() for the single database context */ - const string azureSqlDbQuery = @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -SELECT /* PerformanceMonitorLite */ TOP (200) - database_name = DB_NAME(), - query_hash = CONVERT(varchar(64), qs.query_hash, 1), - query_plan_hash = CONVERT(varchar(64), qs.query_plan_hash, 1), - creation_time = qs.creation_time, - last_execution_time = qs.last_execution_time, - execution_count = qs.execution_count, - total_worker_time = qs.total_worker_time, - total_elapsed_time = qs.total_elapsed_time, - total_logical_reads = qs.total_logical_reads, - total_logical_writes = qs.total_logical_writes, - total_physical_reads = qs.total_physical_reads, - total_clr_time = qs.total_clr_time, - total_rows = qs.total_rows, - total_spills = qs.total_spills, - min_worker_time = qs.min_worker_time, - max_worker_time = qs.max_worker_time, - min_elapsed_time = qs.min_elapsed_time, - max_elapsed_time = qs.max_elapsed_time, - min_physical_reads = qs.min_physical_reads, - max_physical_reads = qs.max_physical_reads, - min_rows = qs.min_rows, - max_rows = qs.max_rows, - min_dop = qs.min_dop, - max_dop = qs.max_dop, - min_grant_kb = qs.min_grant_kb, - max_grant_kb = qs.max_grant_kb, - min_used_grant_kb = qs.min_used_grant_kb, - max_used_grant_kb = qs.max_used_grant_kb, - min_ideal_grant_kb = qs.min_ideal_grant_kb, - max_ideal_grant_kb = qs.max_ideal_grant_kb, - min_reserved_threads = qs.min_reserved_threads, - max_reserved_threads = qs.max_reserved_threads, - min_used_threads = qs.min_used_threads, - max_used_threads = qs.max_used_threads, - min_spills = qs.min_spills, - max_spills = qs.max_spills, - sql_handle = CONVERT(varchar(64), qs.sql_handle, 1), - plan_handle = CONVERT(varchar(64), qs.plan_handle, 1), - query_text = - CASE - WHEN qs.statement_start_offset = 0 - AND qs.statement_end_offset = -1 - THEN st.text - ELSE - SUBSTRING - ( - st.text, - (qs.statement_start_offset / 2) + 1, - ( - CASE - WHEN qs.statement_end_offset = -1 - THEN DATALENGTH(st.text) - ELSE qs.statement_end_offset - END - qs.statement_start_offset - ) / 2 + 1 - ) - END -FROM sys.dm_exec_query_stats AS qs -OUTER APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st -WHERE st.text NOT LIKE N'%PerformanceMonitorLite%' -AND qs.last_execution_time >= DATEADD(MINUTE, -10, GETDATE()) -ORDER BY - qs.total_elapsed_time DESC -OPTION(RECOMPILE);"; - - var serverId = GetServerId(server); - var collectionTime = DateTime.UtcNow; - var rowsCollected = 0; - _lastSqlMs = 0; - _lastDuckDbMs = 0; - - var sqlSw = Stopwatch.StartNew(); - - // Build list of (SqlConnection, query) pairs to execute - var connections = new List<(SqlConnection Connection, string Query, bool OwnsConnection)>(); - - if (isAzureSqlDb) - { - // Azure SQL DB: dm_exec_query_stats is scoped to the connected database, - // so we must connect to each database individually. - var databases = await GetAzureDatabaseListAsync(server, cancellationToken); - foreach (var dbName in databases) - { - try - { - var conn = await OpenAzureDatabaseConnectionAsync(server, dbName, cancellationToken); - connections.Add((conn, azureSqlDbQuery, true)); - } - catch (Exception ex) - { - _logger?.LogDebug("Skipping database '{Database}' for query stats: {Error}", dbName, ex.Message); - } - } - } - else - { - var conn = await CreateConnectionAsync(server, cancellationToken); - connections.Add((conn, standardQuery, true)); - } - - try - { - var duckSw = Stopwatch.StartNew(); - - using (var duckConnection = _duckDb.CreateConnection()) - { - await duckConnection.OpenAsync(cancellationToken); - - using (var appender = duckConnection.CreateAppender("query_stats")) - { - foreach (var (sqlConnection, query, _) in connections) - { - using var command = new SqlCommand(query, sqlConnection); - command.CommandTimeout = CommandTimeoutSeconds; - if (!isAzureSqlDb) - { - var (_, freshParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); - foreach (var p in freshParams) command.Parameters.Add(p); - } - - using var reader = await command.ExecuteReaderAsync(cancellationToken); - - while (await reader.ReadAsync(cancellationToken)) - { - /* Reader ordinals match SELECT column order: - 0=database_name, 1=query_hash, 2=query_plan_hash, - 3=creation_time, 4=last_execution_time, - 5=execution_count, 6=total_worker_time, 7=total_elapsed_time, - 8=total_logical_reads, 9=total_logical_writes, 10=total_physical_reads, - 11=total_clr_time, 12=total_rows, 13=total_spills, - 14=min_worker_time, 15=max_worker_time, 16=min_elapsed_time, 17=max_elapsed_time, - 18=min_physical_reads, 19=max_physical_reads, 20=min_rows, 21=max_rows, - 22=min_dop, 23=max_dop, - 24=min_grant_kb, 25=max_grant_kb, 26=min_used_grant_kb, 27=max_used_grant_kb, - 28=min_ideal_grant_kb, 29=max_ideal_grant_kb, - 30=min_reserved_threads, 31=max_reserved_threads, 32=min_used_threads, 33=max_used_threads, - 34=min_spills, 35=max_spills, - 36=sql_handle, 37=plan_handle, 38=query_text */ - var queryHash = reader.IsDBNull(1) ? "" : reader.GetString(1); - var creationTime = reader.IsDBNull(3) ? (DateTime?)null : reader.GetDateTime(3); - var lastExecTime = reader.IsDBNull(4) ? (DateTime?)null : reader.GetDateTime(4); - var executionCount = reader.IsDBNull(5) ? 0L : reader.GetInt64(5); - var totalWorkerTime = reader.IsDBNull(6) ? 0L : reader.GetInt64(6); - var totalElapsedTime = reader.IsDBNull(7) ? 0L : reader.GetInt64(7); - var totalLogicalReads = reader.IsDBNull(8) ? 0L : reader.GetInt64(8); - var totalLogicalWrites = reader.IsDBNull(9) ? 0L : reader.GetInt64(9); - var totalPhysicalReads = reader.IsDBNull(10) ? 0L : reader.GetInt64(10); - var totalClrTime = reader.IsDBNull(11) ? 0L : reader.GetInt64(11); - var totalRows = reader.IsDBNull(12) ? 0L : reader.GetInt64(12); - var totalSpills = reader.IsDBNull(13) ? 0L : reader.GetInt64(13); - var sqlHandle = reader.IsDBNull(36) ? (string?)null : reader.GetString(36); - var planHandle = reader.IsDBNull(37) ? (string?)null : reader.GetString(37); - - /* Delta calculations keyed by plan_handle to prevent cross-contamination - when multiple plans exist for the same query_hash */ - var deltaKey = planHandle ?? queryHash; - var deltaExecCount = _deltaCalculator.CalculateDelta(serverId, "query_stats_exec", deltaKey, executionCount, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaWorkerTime = _deltaCalculator.CalculateDelta(serverId, "query_stats_worker", deltaKey, totalWorkerTime, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaElapsedTime = _deltaCalculator.CalculateDelta(serverId, "query_stats_elapsed", deltaKey, totalElapsedTime, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaLogicalReads = _deltaCalculator.CalculateDelta(serverId, "query_stats_reads", deltaKey, totalLogicalReads, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaLogicalWrites = _deltaCalculator.CalculateDelta(serverId, "query_stats_writes", deltaKey, totalLogicalWrites, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaPhysicalReads = _deltaCalculator.CalculateDelta(serverId, "query_stats_phys_reads", deltaKey, totalPhysicalReads, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaRows = _deltaCalculator.CalculateDelta(serverId, "query_stats_rows", deltaKey, totalRows, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaSpills = _deltaCalculator.CalculateDelta(serverId, "query_stats_spills", deltaKey, totalSpills, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - - /* Appender column order must match DuckDB table definition exactly */ - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) /* collection_id */ - .AppendValue(collectionTime) /* collection_time */ - .AppendValue(serverId) /* server_id */ - .AppendValue(GetServerNameForStorage(server)) /* server_name */ - .AppendValue(reader.IsDBNull(0) ? (string?)null : reader.GetString(0)) /* database_name */ - .AppendValue(queryHash) /* query_hash */ - .AppendValue(reader.IsDBNull(2) ? (string?)null : reader.GetString(2)) /* query_plan_hash */ - .AppendValue(creationTime) /* creation_time */ - .AppendValue(lastExecTime) /* last_execution_time */ - .AppendValue(executionCount) /* execution_count */ - .AppendValue(totalWorkerTime) /* total_worker_time */ - .AppendValue(totalElapsedTime) /* total_elapsed_time */ - .AppendValue(totalLogicalReads) /* total_logical_reads */ - .AppendValue(totalLogicalWrites) /* total_logical_writes */ - .AppendValue(totalPhysicalReads) /* total_physical_reads */ - .AppendValue(totalClrTime) /* total_clr_time */ - .AppendValue(totalRows) /* total_rows */ - .AppendValue(totalSpills) /* total_spills */ - .AppendValue(reader.IsDBNull(14) ? 0L : reader.GetInt64(14)) /* min_worker_time */ - .AppendValue(reader.IsDBNull(15) ? 0L : reader.GetInt64(15)) /* max_worker_time */ - .AppendValue(reader.IsDBNull(16) ? 0L : reader.GetInt64(16)) /* min_elapsed_time */ - .AppendValue(reader.IsDBNull(17) ? 0L : reader.GetInt64(17)) /* max_elapsed_time */ - .AppendValue(reader.IsDBNull(18) ? 0L : reader.GetInt64(18)) /* min_physical_reads */ - .AppendValue(reader.IsDBNull(19) ? 0L : reader.GetInt64(19)) /* max_physical_reads */ - .AppendValue(reader.IsDBNull(20) ? 0L : reader.GetInt64(20)) /* min_rows */ - .AppendValue(reader.IsDBNull(21) ? 0L : reader.GetInt64(21)) /* max_rows */ - .AppendValue(reader.IsDBNull(22) ? 0L : Convert.ToInt64(reader.GetValue(22))) /* min_dop */ - .AppendValue(reader.IsDBNull(23) ? 0L : Convert.ToInt64(reader.GetValue(23))) /* max_dop */ - .AppendValue(reader.IsDBNull(24) ? 0L : reader.GetInt64(24)) /* min_grant_kb */ - .AppendValue(reader.IsDBNull(25) ? 0L : reader.GetInt64(25)) /* max_grant_kb */ - .AppendValue(reader.IsDBNull(26) ? 0L : reader.GetInt64(26)) /* min_used_grant_kb */ - .AppendValue(reader.IsDBNull(27) ? 0L : reader.GetInt64(27)) /* max_used_grant_kb */ - .AppendValue(reader.IsDBNull(28) ? 0L : reader.GetInt64(28)) /* min_ideal_grant_kb */ - .AppendValue(reader.IsDBNull(29) ? 0L : reader.GetInt64(29)) /* max_ideal_grant_kb */ - .AppendValue(reader.IsDBNull(30) ? 0L : reader.GetInt64(30)) /* min_reserved_threads */ - .AppendValue(reader.IsDBNull(31) ? 0L : reader.GetInt64(31)) /* max_reserved_threads */ - .AppendValue(reader.IsDBNull(32) ? 0L : reader.GetInt64(32)) /* min_used_threads */ - .AppendValue(reader.IsDBNull(33) ? 0L : reader.GetInt64(33)) /* max_used_threads */ - .AppendValue(reader.IsDBNull(34) ? 0L : reader.GetInt64(34)) /* min_spills */ - .AppendValue(reader.IsDBNull(35) ? 0L : reader.GetInt64(35)) /* max_spills */ - .AppendValue(reader.IsDBNull(38) ? (string?)null : reader.GetString(38)) /* query_text */ - .AppendValue((string?)null) /* query_plan_xml — retrieved on-demand */ - .AppendValue(sqlHandle) /* sql_handle */ - .AppendValue(planHandle) /* plan_handle */ - .AppendValue(deltaExecCount) /* delta_execution_count */ - .AppendValue(deltaWorkerTime) /* delta_worker_time */ - .AppendValue(deltaElapsedTime) /* delta_elapsed_time */ - .AppendValue(deltaLogicalReads) /* delta_logical_reads */ - .AppendValue(deltaLogicalWrites) /* delta_logical_writes */ - .AppendValue(deltaPhysicalReads) /* delta_physical_reads */ - .AppendValue(deltaRows) /* delta_rows */ - .AppendValue(deltaSpills) /* delta_spills */ - .EndRow(); - - rowsCollected++; - } - } // end foreach connection - } - } - - sqlSw.Stop(); - duckSw.Stop(); - _lastSqlMs = sqlSw.ElapsedMilliseconds; - _lastDuckDbMs = duckSw.ElapsedMilliseconds; - } - finally - { - foreach (var (conn, _, _) in connections) - conn.Dispose(); - } - - _logger?.LogDebug("Collected {RowCount} query stats for server '{Server}'", rowsCollected, server.DisplayName); - return rowsCollected; - } + private Task<int> CollectQueryStatsAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(QueryStatsCollector.Instance, server, cancellationToken); } diff --git a/Lite/Services/RemoteCollectorService.QueryStore.cs b/Lite/Services/RemoteCollectorService.QueryStore.cs index 5da8420a6..ab695a39a 100644 --- a/Lite/Services/RemoteCollectorService.QueryStore.cs +++ b/Lite/Services/RemoteCollectorService.QueryStore.cs @@ -6,14 +6,9 @@ * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ -using System; -using System.Collections.Generic; -using System.Diagnostics; using System.Threading; using System.Threading.Tasks; -using DuckDB.NET.Data; -using Microsoft.Data.SqlClient; -using Microsoft.Extensions.Logging; +using PerformanceMonitor.Collectors; using PerformanceMonitorLite.Models; namespace PerformanceMonitorLite.Services; @@ -21,482 +16,11 @@ namespace PerformanceMonitorLite.Services; public partial class RemoteCollectorService { /// <summary> - /// Collects Query Store data from databases that have it enabled. - /// Matches Dashboard column parity with version-gated columns for SQL 2017+ and 2022+. + /// Collects Query Store data via the shared <see cref="QueryStoreCollector"/> definition + /// (the actual_state enumeration probe, the live PRODUCTVERSION check deciding the + /// 2017+/2022+ column gates, the last_execution_time incremental watermark, and the + /// per-database sp_executesql query live there — the cross-SKU parity contract). /// </summary> - private async Task<int> CollectQueryStoreAsync(ServerConnection server, CancellationToken cancellationToken) - { - /* First, get databases with Query Store actually enabled. - Uses sys.database_query_store_options.actual_state instead of - sys.databases.is_query_store_on, which can be out of sync on Azure SQL DB. */ - var serverStatus = _serverManager.GetConnectionStatus(server.Id); - bool isAzureSqlDb = serverStatus?.SqlEngineEdition == 5; - - string onPremDbQuery = @" -SET NOCOUNT ON; -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -DECLARE - @result TABLE (name sysname); - -DECLARE - @db sysname, - @sql NVARCHAR(500), - @exec_sp nvarchar(256); - -DECLARE db_check CURSOR LOCAL FAST_FORWARD FOR - SELECT /* PerformanceMonitorLite */ - d.name - FROM sys.databases AS d - LEFT JOIN sys.dm_hadr_database_replica_states AS drs - ON d.database_id = drs.database_id - AND drs.is_local = 1 - WHERE d.database_id > 4 - AND d.database_id < 32761 - AND d.state_desc = N'ONLINE' - AND d.name <> N'PerformanceMonitor' - AND - ( - drs.database_id IS NULL /*not in any AG*/ - OR drs.is_primary_replica = 1 /*primary replica*/ - ) - /*EXCLUSION_FILTER*/ - OPTION(RECOMPILE); - -OPEN db_check; - -FETCH NEXT -FROM db_check -INTO @db; - -WHILE @@FETCH_STATUS = 0 -BEGIN - BEGIN TRY - SET @sql = N' - SELECT ' + QUOTENAME(@db, '''') + N' - WHERE EXISTS - ( - SELECT - 1 - FROM sys.database_query_store_options - WHERE actual_state > 0 - );'; - - SET @exec_sp = QUOTENAME(@db) + N'.sys.sp_executesql'; - - INSERT @result (name) - EXECUTE @exec_sp @sql; - END TRY - BEGIN CATCH - END CATCH; - - FETCH NEXT - FROM db_check - INTO @db; -END; - -CLOSE db_check; -DEALLOCATE db_check; - -SELECT - name -FROM @result -ORDER BY - name;"; - - string azureDbQuery = @" -SET NOCOUNT ON; -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -DECLARE - @result TABLE (name sysname); - -DECLARE - @db sysname, - @sql NVARCHAR(500), - @exec_sp nvarchar(256); - -DECLARE db_check CURSOR LOCAL FAST_FORWARD FOR - SELECT /* PerformanceMonitorLite */ - d.name - FROM sys.databases AS d - WHERE d.database_id > 4 - AND d.database_id < 32761 - AND d.state_desc = N'ONLINE' - AND d.name <> N'PerformanceMonitor' - /*EXCLUSION_FILTER*/ - OPTION(RECOMPILE); - -OPEN db_check; - -FETCH NEXT -FROM db_check -INTO @db; - -WHILE @@FETCH_STATUS = 0 -BEGIN - BEGIN TRY - SET @sql = N' - SELECT ' + QUOTENAME(@db, '''') + N' - WHERE EXISTS - ( - SELECT - 1 - FROM sys.database_query_store_options - WHERE actual_state > 0 - );'; - - SET @exec_sp = QUOTENAME(@db) + N'.sys.sp_executesql'; - - INSERT @result (name) - EXECUTE @exec_sp @sql; - END TRY - BEGIN CATCH - END CATCH; - - FETCH NEXT - FROM db_check - INTO @db; -END; - -CLOSE db_check; -DEALLOCATE db_check; - -SELECT - name -FROM @result -ORDER BY - name;"; - - var (exclusionClause, _) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); - onPremDbQuery = onPremDbQuery.Replace("/*EXCLUSION_FILTER*/", exclusionClause); - azureDbQuery = azureDbQuery.Replace("/*EXCLUSION_FILTER*/", exclusionClause); - - string dbQuery = isAzureSqlDb ? azureDbQuery : onPremDbQuery; - - var serverId = GetServerId(server); - var collectionTime = DateTime.UtcNow; - var totalRows = 0; - _lastSqlMs = 0; - _lastDuckDbMs = 0; - - /* Incremental: only fetch runtime_stats intervals newer than what we already have */ - var lastCollectedTime = await GetLastCollectedTimeAsync( - serverId, "query_store_stats", "last_execution_time", cancellationToken); - var cutoffTime = lastCollectedTime ?? DateTime.UtcNow.AddMinutes(-60); - - var sqlSw = Stopwatch.StartNew(); - using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); - - /* Get list of QS-enabled databases */ - var databases = new List<string>(); - using (var dbCommand = new SqlCommand(dbQuery, sqlConnection)) - { - dbCommand.CommandTimeout = CommandTimeoutSeconds; - var (_, exclusionParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); - foreach (var p in exclusionParams) dbCommand.Parameters.Add(p); - using var dbReader = await dbCommand.ExecuteReaderAsync(cancellationToken); - while (await dbReader.ReadAsync(cancellationToken)) - { - databases.Add(dbReader.GetString(0)); - } - } - - if (databases.Count == 0) - { - sqlSw.Stop(); - _lastSqlMs = sqlSw.ElapsedMilliseconds; - return 0; - } - - /* Detect server version for version-gated columns. - @isNew = true for SQL Server 2017+ (product version > 13) or Azure SQL DB/MI (engine 5/8). - Controls: avg_num_physical_io_reads, avg_log_bytes_used, avg_tempdb_space_used, plan_forcing_type_desc. - @hasPlanType = true for SQL Server 2022+ (product version >= 16). - Controls: plan_type_desc. */ - int productVersion = 13; /* default to SQL 2016 */ - try - { - using var versionCmd = new SqlCommand( - "SELECT CONVERT(integer, PARSENAME(CONVERT(sysname, SERVERPROPERTY('PRODUCTVERSION')), 4))", - sqlConnection); - versionCmd.CommandTimeout = 10; - var versionResult = await versionCmd.ExecuteScalarAsync(cancellationToken); - if (versionResult != null && versionResult != DBNull.Value) - productVersion = Convert.ToInt32(versionResult); - } - catch - { - /* Fall back to 13 (SQL 2016) if version detection fails */ - } - - bool isNew = productVersion > 13 || serverStatus?.SqlEngineEdition == 5 || serverStatus?.SqlEngineEdition == 8; - bool hasPlanType = productVersion >= 16; - - /* Build version-conditional column fragments for the Query Store query. - These are injected into the sp_executesql parameter string — no single quotes needed. */ - string numPhysIoReadsCols = isNew - ? "qsrs.avg_num_physical_io_reads, qsrs.min_num_physical_io_reads, qsrs.max_num_physical_io_reads," - : "avg_num_physical_io_reads = NULL, min_num_physical_io_reads = NULL, max_num_physical_io_reads = NULL,"; - - string logBytesCols = isNew - ? "avg_log_bytes_used = qsrs.avg_log_bytes_used, min_log_bytes_used = qsrs.min_log_bytes_used, max_log_bytes_used = qsrs.max_log_bytes_used," - : "avg_log_bytes_used = NULL, min_log_bytes_used = NULL, max_log_bytes_used = NULL,"; - - string tempdbCols = isNew - ? "avg_tempdb_space_used = qsrs.avg_tempdb_space_used, min_tempdb_space_used = qsrs.min_tempdb_space_used, max_tempdb_space_used = qsrs.max_tempdb_space_used," - : "avg_tempdb_space_used = NULL, min_tempdb_space_used = NULL, max_tempdb_space_used = NULL,"; - - string planForcingCol = isNew - ? "plan_forcing_type = qsp.plan_forcing_type_desc," - : "plan_forcing_type = NULL,"; - - string planTypeCol = hasPlanType - ? "plan_type = qsp.plan_type_desc," - : "plan_type = NULL,"; - - var duckSw = new Stopwatch(); - - using (var duckConnection = _duckDb.CreateConnection()) - { - await duckConnection.OpenAsync(cancellationToken); - - /* For each database, collect new query store intervals since last collection */ - foreach (var dbName in databases) - { - try - { - var escapedDbName = dbName.Replace("]", "]]"); - var qsQuery = $@" -EXECUTE [{escapedDbName}].sys.sp_executesql - N'SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - - SELECT /* PerformanceMonitorLite */ - query_id = qsq.query_id, - plan_id = qsp.plan_id, - execution_type_desc = qsrs.execution_type_desc, - first_execution_time = qsrs.first_execution_time, - last_execution_time = qsrs.last_execution_time, - module_name = - CASE - WHEN qsq.object_id = 0 - THEN N''Adhoc'' - ELSE COALESCE( - OBJECT_SCHEMA_NAME(qsq.object_id) + N''.'' + OBJECT_NAME(qsq.object_id), - N''Unknown'') - END, - query_sql_text = qst.query_sql_text, - query_hash = CONVERT(varchar(64), qsq.query_hash, 1), - count_executions = qsrs.count_executions, - avg_duration = qsrs.avg_duration, - min_duration = qsrs.min_duration, - max_duration = qsrs.max_duration, - avg_cpu_time = qsrs.avg_cpu_time, - min_cpu_time = qsrs.min_cpu_time, - max_cpu_time = qsrs.max_cpu_time, - avg_logical_io_reads = qsrs.avg_logical_io_reads, - min_logical_io_reads = qsrs.min_logical_io_reads, - max_logical_io_reads = qsrs.max_logical_io_reads, - avg_logical_io_writes = qsrs.avg_logical_io_writes, - min_logical_io_writes = qsrs.min_logical_io_writes, - max_logical_io_writes = qsrs.max_logical_io_writes, - avg_physical_io_reads = qsrs.avg_physical_io_reads, - min_physical_io_reads = qsrs.min_physical_io_reads, - max_physical_io_reads = qsrs.max_physical_io_reads, - avg_clr_time = qsrs.avg_clr_time, - min_clr_time = qsrs.min_clr_time, - max_clr_time = qsrs.max_clr_time, - min_dop = qsrs.min_dop, - max_dop = qsrs.max_dop, - avg_query_max_used_memory = qsrs.avg_query_max_used_memory, - min_query_max_used_memory = qsrs.min_query_max_used_memory, - max_query_max_used_memory = qsrs.max_query_max_used_memory, - avg_rowcount = qsrs.avg_rowcount, - min_rowcount = qsrs.min_rowcount, - max_rowcount = qsrs.max_rowcount, - {numPhysIoReadsCols} - {logBytesCols} - {tempdbCols} - {planTypeCol} - {planForcingCol} - is_forced_plan = qsp.is_forced_plan, - force_failure_count = qsp.force_failure_count, - last_force_failure_reason = qsp.last_force_failure_reason_desc, - compatibility_level = qsp.compatibility_level, - query_plan_text = CONVERT(nvarchar(1), NULL), - query_plan_hash = CONVERT(varchar(64), qsp.query_plan_hash, 1) - FROM sys.query_store_runtime_stats AS qsrs - JOIN sys.query_store_plan AS qsp - ON qsp.plan_id = qsrs.plan_id - JOIN sys.query_store_query AS qsq - ON qsq.query_id = qsp.query_id - JOIN sys.query_store_query_text AS qst - ON qst.query_text_id = qsq.query_text_id - WHERE qsrs.last_execution_time > @cutoff_time - AND qst.query_sql_text NOT LIKE N''%PerformanceMonitorLite%'' - OPTION(RECOMPILE, LOOP JOIN);', - N'@cutoff_time datetime2(7)', - @cutoff_time;"; - - sqlSw.Start(); - using var qsCommand = new SqlCommand(qsQuery, sqlConnection); - qsCommand.CommandTimeout = CommandTimeoutSeconds; - qsCommand.Parameters.Add(new SqlParameter("@cutoff_time", System.Data.SqlDbType.DateTime2) { Value = cutoffTime }); - - using var reader = await qsCommand.ExecuteReaderAsync(cancellationToken); - sqlSw.Stop(); - - duckSw.Start(); - var flushSw = new Stopwatch(); - var readerSw = new Stopwatch(); - var appendSw = new Stopwatch(); - - using (var appender = duckConnection.CreateAppender("query_store_stats")) - { - while (true) - { - readerSw.Start(); - var hasRow = await reader.ReadAsync(cancellationToken); - readerSw.Stop(); - if (!hasRow) break; - /* Reader ordinals match SELECT column order: - 0=query_id, 1=plan_id, 2=execution_type_desc, - 3=first_execution_time (dto), 4=last_execution_time (dto), - 5=module_name, 6=query_sql_text, 7=query_hash, 8=count_executions, - 9=avg_duration, 10=min_duration, 11=max_duration, - 12=avg_cpu_time, 13=min_cpu_time, 14=max_cpu_time, - 15=avg_logical_io_reads, 16=min_logical_io_reads, 17=max_logical_io_reads, - 18=avg_logical_io_writes, 19=min_logical_io_writes, 20=max_logical_io_writes, - 21=avg_physical_io_reads, 22=min_physical_io_reads, 23=max_physical_io_reads, - 24=avg_clr_time, 25=min_clr_time, 26=max_clr_time, - 27=min_dop, 28=max_dop, - 29=avg_query_max_used_memory, 30=min_query_max_used_memory, 31=max_query_max_used_memory, - 32=avg_rowcount, 33=min_rowcount, 34=max_rowcount, - 35=avg_num_physical_io_reads, 36=min_num_physical_io_reads, 37=max_num_physical_io_reads, - 38=avg_log_bytes_used, 39=min_log_bytes_used, 40=max_log_bytes_used, - 41=avg_tempdb_space_used, 42=min_tempdb_space_used, 43=max_tempdb_space_used, - 44=plan_type, 45=plan_forcing_type, - 46=is_forced_plan, 47=force_failure_count, 48=last_force_failure_reason, - 49=compatibility_level, 50=query_plan_text, 51=query_plan_hash */ - - appendSw.Start(); - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) /* collection_id */ - .AppendValue(collectionTime) /* collection_time */ - .AppendValue(serverId) /* server_id */ - .AppendValue(GetServerNameForStorage(server)) /* server_name */ - .AppendValue(dbName) /* database_name */ - .AppendValue(reader.GetInt64(0)) /* query_id */ - .AppendValue(reader.GetInt64(1)) /* plan_id */ - .AppendValue(reader.IsDBNull(2) ? (string?)null : reader.GetString(2)) /* execution_type_desc */ - .AppendValue(reader.IsDBNull(3) ? (DateTime?)null : ((DateTimeOffset)reader.GetValue(3)).UtcDateTime) /* first_execution_time */ - .AppendValue(reader.IsDBNull(4) ? (DateTime?)null : ((DateTimeOffset)reader.GetValue(4)).UtcDateTime) /* last_execution_time */ - .AppendValue(reader.IsDBNull(5) ? (string?)null : reader.GetString(5)) /* module_name */ - .AppendValue(reader.IsDBNull(6) ? (string?)null : reader.GetString(6)) /* query_text */ - .AppendValue(reader.IsDBNull(7) ? (string?)null : reader.GetString(7)) /* query_hash */ - .AppendValue(reader.GetInt64(8)) /* execution_count */ - .AppendValue(ReadNullableInt64(reader, 9)) /* avg_duration_us */ - .AppendValue(ReadNullableInt64(reader, 10)) /* min_duration_us */ - .AppendValue(ReadNullableInt64(reader, 11)) /* max_duration_us */ - .AppendValue(ReadNullableInt64(reader, 12)) /* avg_cpu_time_us */ - .AppendValue(ReadNullableInt64(reader, 13)) /* min_cpu_time_us */ - .AppendValue(ReadNullableInt64(reader, 14)) /* max_cpu_time_us */ - .AppendValue(ReadNullableInt64(reader, 15)) /* avg_logical_io_reads */ - .AppendValue(ReadNullableInt64(reader, 16)) /* min_logical_io_reads */ - .AppendValue(ReadNullableInt64(reader, 17)) /* max_logical_io_reads */ - .AppendValue(ReadNullableInt64(reader, 18)) /* avg_logical_io_writes */ - .AppendValue(ReadNullableInt64(reader, 19)) /* min_logical_io_writes */ - .AppendValue(ReadNullableInt64(reader, 20)) /* max_logical_io_writes */ - .AppendValue(ReadNullableInt64(reader, 21)) /* avg_physical_io_reads */ - .AppendValue(ReadNullableInt64(reader, 22)) /* min_physical_io_reads */ - .AppendValue(ReadNullableInt64(reader, 23)) /* max_physical_io_reads */ - .AppendValue(ReadNullableInt64(reader, 24)) /* avg_clr_time_us */ - .AppendValue(ReadNullableInt64(reader, 25)) /* min_clr_time_us */ - .AppendValue(ReadNullableInt64(reader, 26)) /* max_clr_time_us */ - .AppendValue(ReadNullableInt64(reader, 27)) /* min_dop */ - .AppendValue(ReadNullableInt64(reader, 28)) /* max_dop */ - .AppendValue(ReadNullableInt64(reader, 29)) /* avg_query_max_used_memory */ - .AppendValue(ReadNullableInt64(reader, 30)) /* min_query_max_used_memory */ - .AppendValue(ReadNullableInt64(reader, 31)) /* max_query_max_used_memory */ - .AppendValue(ReadNullableInt64(reader, 32)) /* avg_rowcount */ - .AppendValue(ReadNullableInt64(reader, 33)) /* min_rowcount */ - .AppendValue(ReadNullableInt64(reader, 34)) /* max_rowcount */ - .AppendValue(ReadNullableInt64(reader, 35)) /* avg_num_physical_io_reads (2017+) */ - .AppendValue(ReadNullableInt64(reader, 36)) /* min_num_physical_io_reads (2017+) */ - .AppendValue(ReadNullableInt64(reader, 37)) /* max_num_physical_io_reads (2017+) */ - .AppendValue(ReadNullableInt64(reader, 38)) /* avg_log_bytes_used (2017+) */ - .AppendValue(ReadNullableInt64(reader, 39)) /* min_log_bytes_used (2017+) */ - .AppendValue(ReadNullableInt64(reader, 40)) /* max_log_bytes_used (2017+) */ - .AppendValue(ReadNullableInt64(reader, 41)) /* avg_tempdb_space_used (2017+) */ - .AppendValue(ReadNullableInt64(reader, 42)) /* min_tempdb_space_used (2017+) */ - .AppendValue(ReadNullableInt64(reader, 43)) /* max_tempdb_space_used (2017+) */ - .AppendValue(reader.IsDBNull(44) ? (string?)null : reader.GetString(44)) /* plan_type (2022+) */ - .AppendValue(reader.IsDBNull(45) ? (string?)null : reader.GetString(45)) /* plan_forcing_type (2017+) */ - .AppendValue(!reader.IsDBNull(46) && reader.GetBoolean(46)) /* is_forced_plan */ - .AppendValue(reader.IsDBNull(47) ? 0L : reader.GetInt64(47)) /* force_failure_count */ - .AppendValue(reader.IsDBNull(48) ? (string?)null : reader.GetString(48)) /* last_force_failure_reason */ - .AppendValue(reader.IsDBNull(49) ? 0 : Convert.ToInt32(reader.GetValue(49))) /* compatibility_level */ - .AppendValue(reader.IsDBNull(50) ? (string?)null : reader.GetString(50)) /* query_plan_text */ - .AppendValue(reader.IsDBNull(51) ? (string?)null : reader.GetString(51)) /* query_plan_hash */ - .EndRow(); - appendSw.Stop(); - - totalRows++; - } - - flushSw.Start(); - } /* appender.Dispose() flushes here */ - flushSw.Stop(); - - duckSw.Stop(); - - if (duckSw.ElapsedMilliseconds > 2000) - { - _logger?.LogWarning( - "Query Store DuckDB write spike: {TotalMs}ms total (reader: {ReaderMs}ms, append: {AppendMs}ms, flush: {FlushMs}ms, rows: {Rows}, db: {Db})", - duckSw.ElapsedMilliseconds, - readerSw.ElapsedMilliseconds, - appendSw.ElapsedMilliseconds, - flushSw.ElapsedMilliseconds, - totalRows, - dbName); - } - } - catch (SqlException ex) - { - sqlSw.Stop(); - duckSw.Stop(); - _logger?.LogWarning("Failed to collect Query Store data from [{Database}] on '{Server}': {Message}", - dbName, server.DisplayName, ex.Message); - } - } - } - - _lastSqlMs = sqlSw.ElapsedMilliseconds; - _lastDuckDbMs = duckSw.ElapsedMilliseconds; - - _logger?.LogDebug("Collected {RowCount} Query Store rows across {DbCount} databases for server '{Server}'", - totalRows, databases.Count, server.DisplayName); - return totalRows; - } - - /// <summary> - /// Helper to read a nullable int64 from a SqlDataReader, converting float/decimal Query Store values to long. - /// Query Store runtime_stats columns are stored as float in the catalog but represent integer-scale values. - /// </summary> - private static long ReadNullableInt64(SqlDataReader reader, int ordinal) - { - if (reader.IsDBNull(ordinal)) return 0L; - var value = reader.GetValue(ordinal); - return value switch - { - long l => l, - int i => i, - short s => s, - decimal d => (long)d, - double dbl => (long)dbl, - float f => (long)f, - _ => Convert.ToInt64(value) - }; - } + private Task<int> CollectQueryStoreAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(QueryStoreCollector.Instance, server, cancellationToken); } diff --git a/Lite/Services/RemoteCollectorService.RunningJobs.cs b/Lite/Services/RemoteCollectorService.RunningJobs.cs index 4f869d1d6..5db6b46ee 100644 --- a/Lite/Services/RemoteCollectorService.RunningJobs.cs +++ b/Lite/Services/RemoteCollectorService.RunningJobs.cs @@ -8,12 +8,12 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Threading; using System.Threading.Tasks; -using DuckDB.NET.Data; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; +using PerformanceMonitor.Alerting; +using PerformanceMonitor.Collectors; using PerformanceMonitorLite.Models; namespace PerformanceMonitorLite.Services; @@ -21,248 +21,41 @@ namespace PerformanceMonitorLite.Services; public partial class RemoteCollectorService { /// <summary> - /// Collects currently running SQL Agent jobs with historical duration comparison. - /// Flags jobs running longer than their p95 historical duration. + /// Collects currently running SQL Agent jobs via the shared <see cref="RunningJobsCollector"/> + /// definition (the p95 duration comparison and the no-collection_id prefix live there — the + /// cross-SKU parity contract). The failed-jobs alert query below rides the shared + /// <see cref="FailedJobsQuery"/> (Phase-5 slice E). /// </summary> - private async Task<int> CollectRunningJobsAsync(ServerConnection server, CancellationToken cancellationToken) - { - const string query = @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -WITH - running_jobs AS -( - SELECT - job_name = j.name, - job_id = CONVERT(varchar(36), j.job_id), - job_enabled = j.enabled, - start_time = ja.start_execution_date, - current_duration_seconds = - DATEDIFF(SECOND, ja.start_execution_date, GETDATE()) - FROM msdb.dbo.sysjobactivity AS ja - JOIN msdb.dbo.sysjobs AS j - ON j.job_id = ja.job_id - JOIN msdb.dbo.syssessions AS s - ON s.session_id = ja.session_id - WHERE ja.start_execution_date IS NOT NULL - AND ja.stop_execution_date IS NULL - AND s.agent_start_date = - ( - SELECT - MAX(s2.agent_start_date) - FROM msdb.dbo.syssessions AS s2 - ) -), - job_history_raw AS -( - SELECT - job_id = CONVERT(varchar(36), jh.job_id), - duration_seconds = - (jh.run_duration / 10000) * 3600 + - ((jh.run_duration / 100) % 100) * 60 + - (jh.run_duration % 100), - p95_duration_seconds = - CONVERT(bigint, - PERCENTILE_CONT(0.95) WITHIN GROUP - ( - ORDER BY - (jh.run_duration / 10000) * 3600 + - ((jh.run_duration / 100) % 100) * 60 + - (jh.run_duration % 100) - ) OVER - ( - PARTITION BY - jh.job_id - ) - ) - FROM msdb.dbo.sysjobhistory AS jh - WHERE jh.step_id = 0 - AND jh.run_status = 1 - AND jh.run_date >= CONVERT(integer, CONVERT(varchar(8), DATEADD(DAY, -30, GETDATE()), 112)) -), - job_history_stats AS -( - SELECT - job_id = jhr.job_id, - avg_duration_seconds = AVG(jhr.duration_seconds), - p95_duration_seconds = MAX(jhr.p95_duration_seconds), - successful_run_count = COUNT_BIG(*) - FROM job_history_raw AS jhr - GROUP BY - jhr.job_id -) -SELECT - rj.job_name, - rj.job_id, - rj.job_enabled, - rj.start_time, - rj.current_duration_seconds, - avg_duration_seconds = ISNULL(jhs.avg_duration_seconds, 0), - p95_duration_seconds = ISNULL(jhs.p95_duration_seconds, 0), - successful_run_count = ISNULL(jhs.successful_run_count, 0), - is_running_long = - CASE - WHEN jhs.p95_duration_seconds > 0 - AND rj.current_duration_seconds > jhs.p95_duration_seconds - THEN CONVERT(bit, 1) - ELSE CONVERT(bit, 0) - END, - percent_of_average = - CASE - WHEN jhs.avg_duration_seconds > 0 - THEN CONVERT(decimal(10,1), rj.current_duration_seconds * 100.0 / jhs.avg_duration_seconds) - ELSE NULL - END -FROM running_jobs AS rj -LEFT JOIN job_history_stats AS jhs - ON jhs.job_id = rj.job_id -ORDER BY - rj.current_duration_seconds DESC -OPTION(RECOMPILE);"; - - var serverId = GetServerId(server); - var collectionTime = DateTime.UtcNow; - var rowsCollected = 0; - _lastSqlMs = 0; - _lastDuckDbMs = 0; - - var rows = new List<(string JobName, string JobId, bool JobEnabled, DateTime StartTime, - long CurrentDuration, long AvgDuration, long P95Duration, long SuccessfulRunCount, - bool IsRunningLong, decimal? PercentOfAverage)>(); - - var sqlSw = Stopwatch.StartNew(); - using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); - using var command = new SqlCommand(query, sqlConnection); - command.CommandTimeout = CommandTimeoutSeconds; - - using var reader = await command.ExecuteReaderAsync(cancellationToken); - while (await reader.ReadAsync(cancellationToken)) - { - rows.Add(( - reader.GetString(0), - reader.GetString(1), - Convert.ToBoolean(reader.GetValue(2)), - reader.GetDateTime(3), - Convert.ToInt64(reader.GetValue(4)), - Convert.ToInt64(reader.GetValue(5)), - Convert.ToInt64(reader.GetValue(6)), - Convert.ToInt64(reader.GetValue(7)), - Convert.ToBoolean(reader.GetValue(8)), - reader.IsDBNull(9) ? null : Convert.ToDecimal(reader.GetValue(9)))); - } - sqlSw.Stop(); - - var duckSw = Stopwatch.StartNew(); - - using (var duckConnection = _duckDb.CreateConnection()) - { - await duckConnection.OpenAsync(cancellationToken); - - using (var appender = duckConnection.CreateAppender("running_jobs")) - { - foreach (var r in rows) - { - var row = appender.CreateRow(); - row.AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(GetServerNameForStorage(server)) - .AppendValue(r.JobName) - .AppendValue(r.JobId) - .AppendValue(r.JobEnabled) - .AppendValue(r.StartTime) - .AppendValue(r.CurrentDuration) - .AppendValue(r.AvgDuration) - .AppendValue(r.P95Duration) - .AppendValue(r.SuccessfulRunCount) - .AppendValue(r.IsRunningLong) - .AppendValue(r.PercentOfAverage) - .EndRow(); - rowsCollected++; - } - } - } - - duckSw.Stop(); - _lastSqlMs = sqlSw.ElapsedMilliseconds; - _lastDuckDbMs = duckSw.ElapsedMilliseconds; - - _logger?.LogDebug("Collected {RowCount} running job rows for server '{Server}'", rowsCollected, server.DisplayName); - return rowsCollected; - } + private Task<int> CollectRunningJobsAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(RunningJobsCollector.Instance, server, cancellationToken); /// <summary> - /// Live query against the monitored server's msdb for SQL Agent job runs (step_id = 0 outcome - /// row, run_status = 0 = Failed) that failed within the lookback window. - /// Runs at alert-check time — failure outcomes are not part of the collected running_jobs - /// snapshot. run_date/run_time integers are converted to a server-local datetime and filtered - /// to the last N minutes. Reuses the collector's connection path (MFA serialization, throttle, - /// retry) and degrades gracefully: any error (a login without msdb / SQLAgentReaderRole access, - /// a transient failure, etc.) returns an empty list rather than failing the alert cycle. The - /// caller skips Azure SQL DB (no Agent) and no-msdb logins before calling. + /// Live query against the monitored server's msdb for SQL Agent job runs that FAILED within the + /// lookback window — the SQL text and row mapping live in the shared + /// <see cref="FailedJobsQuery"/> (Phase-5 slice E; see its doc for the query semantics and the + /// server-local RunDateTime note). Runs at alert-check time — failure outcomes are not part of + /// the collected running_jobs snapshot. Reuses the collector's connection path (MFA + /// serialization, throttle, retry) and degrades gracefully: any error (a login without msdb / + /// SQLAgentReaderRole access, a transient failure, etc.) returns an empty list rather than + /// failing the alert cycle. The caller skips Azure SQL DB (no Agent) and no-msdb logins before + /// calling. /// </summary> public async Task<List<FailedJobInfo>> GetRecentlyFailedJobsAsync( ServerConnection server, int lookbackMinutes, CancellationToken cancellationToken = default) { - const string query = @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -SELECT TOP (50) - job_name = j.name, - job_id = CONVERT(varchar(36), j.job_id), - run_datetime = - DATEADD - ( - SECOND, - (jh.run_time / 10000) * 3600 + - ((jh.run_time / 100) % 100) * 60 + - (jh.run_time % 100), - CONVERT(datetime, CONVERT(varchar(8), jh.run_date)) - ), - step_id = jh.step_id, - step_name = jh.step_name, - message = jh.message -FROM msdb.dbo.sysjobhistory AS jh -JOIN msdb.dbo.sysjobs AS j - ON j.job_id = jh.job_id -WHERE jh.step_id = 0 -AND jh.run_status = 0 -AND jh.run_date >= CONVERT(integer, CONVERT(varchar(8), DATEADD(MINUTE, -@lookback_minutes, GETDATE()), 112)) -AND DATEADD - ( - SECOND, - (jh.run_time / 10000) * 3600 + - ((jh.run_time / 100) % 100) * 60 + - (jh.run_time % 100), - CONVERT(datetime, CONVERT(varchar(8), jh.run_date)) - ) >= DATEADD(MINUTE, -@lookback_minutes, GETDATE()) -ORDER BY - run_datetime DESC -OPTION(RECOMPILE);"; - var items = new List<FailedJobInfo>(); try { using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); - using var command = new SqlCommand(query, sqlConnection); + using var command = new SqlCommand(FailedJobsQuery.Sql, sqlConnection); command.CommandTimeout = CommandTimeoutSeconds; - command.Parameters.AddWithValue("@lookback_minutes", lookbackMinutes); + command.Parameters.AddWithValue(FailedJobsQuery.LookbackMinutesParameter, lookbackMinutes); using var reader = await command.ExecuteReaderAsync(cancellationToken); - while (await reader.ReadAsync(cancellationToken)) - { - items.Add(new FailedJobInfo - { - JobName = reader.GetString(0), - JobId = reader.IsDBNull(1) ? "" : reader.GetString(1), - RunDateTime = reader.GetDateTime(2), - StepId = reader.IsDBNull(3) ? 0 : Convert.ToInt32(reader.GetValue(3)), - StepName = reader.IsDBNull(4) ? "" : reader.GetString(4), - Message = reader.IsDBNull(5) ? "" : reader.GetString(5) - }); - } + items = await FailedJobsQuery.ReadAsync(reader, cancellationToken); } catch (OperationCanceledException) { diff --git a/Lite/Services/RemoteCollectorService.ServerConfig.cs b/Lite/Services/RemoteCollectorService.ServerConfig.cs index 6feecd763..739df3af3 100644 --- a/Lite/Services/RemoteCollectorService.ServerConfig.cs +++ b/Lite/Services/RemoteCollectorService.ServerConfig.cs @@ -6,14 +6,11 @@ * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ -using System; -using System.Collections.Generic; -using System.Diagnostics; using System.Threading; using System.Threading.Tasks; -using DuckDB.NET.Data; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; +using PerformanceMonitor.Collectors; using PerformanceMonitorLite.Models; namespace PerformanceMonitorLite.Services; @@ -21,554 +18,45 @@ namespace PerformanceMonitorLite.Services; public partial class RemoteCollectorService { /// <summary> - /// Collects server configuration from sys.configurations. On-load only, not scheduled. + /// Collects server configuration via the shared <see cref="ServerConfigCollector"/> definition. + /// On-load only, not scheduled. /// </summary> - private async Task<int> CollectServerConfigAsync(ServerConnection server, CancellationToken cancellationToken) - { - const string query = @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -SELECT - configuration_name = c.name, - value_configured = CONVERT(bigint, c.value), - value_in_use = CONVERT(bigint, c.value_in_use), - is_dynamic = c.is_dynamic, - is_advanced = c.is_advanced -FROM sys.configurations AS c -ORDER BY c.name -OPTION(RECOMPILE);"; - - var serverId = GetServerId(server); - var captureTime = DateTime.UtcNow; - var rowsCollected = 0; - _lastSqlMs = 0; - _lastDuckDbMs = 0; - - /* Read all rows from SQL Server first to avoid holding appender open during SQL reads */ - var rows = new List<(string Name, long ValueConfigured, long ValueInUse, bool IsDynamic, bool IsAdvanced)>(); - - var sqlSw = Stopwatch.StartNew(); - using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); - using var command = new SqlCommand(query, sqlConnection); - command.CommandTimeout = CommandTimeoutSeconds; - - using var reader = await command.ExecuteReaderAsync(cancellationToken); - while (await reader.ReadAsync(cancellationToken)) - { - rows.Add(( - reader.GetString(0), - reader.GetInt64(1), - reader.GetInt64(2), - reader.GetBoolean(3), - reader.GetBoolean(4))); - } - sqlSw.Stop(); - - /* Write to DuckDB using appender */ - var duckSw = Stopwatch.StartNew(); - using var duckConnection = _duckDb.CreateConnection(); - await duckConnection.OpenAsync(cancellationToken); - - using var appender = duckConnection.CreateAppender("server_config"); - foreach (var r in rows) - { - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(captureTime) - .AppendValue(serverId) - .AppendValue(GetServerNameForStorage(server)) - .AppendValue(r.Name) - .AppendValue(r.ValueConfigured) - .AppendValue(r.ValueInUse) - .AppendValue(r.IsDynamic) - .AppendValue(r.IsAdvanced) - .EndRow(); - rowsCollected++; - } - - duckSw.Stop(); - _lastSqlMs = sqlSw.ElapsedMilliseconds; - _lastDuckDbMs = duckSw.ElapsedMilliseconds; - - _logger?.LogDebug("Collected {RowCount} server config rows for server '{Server}'", rowsCollected, server.DisplayName); - return rowsCollected; - } + private Task<int> CollectServerConfigAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(ServerConfigCollector.Instance, server, cancellationToken); /// <summary> - /// Collects database configuration from sys.databases. On-load only, not scheduled. - /// Version-gated columns are conditionally included based on the server's major version. + /// Collects database configuration via the shared <see cref="DatabaseConfigCollector"/> + /// definition (the 2019/2025 version gates and the exclusion-filter splice live there — + /// the cross-SKU parity contract). On-load only, not scheduled. /// </summary> - private async Task<int> CollectDatabaseConfigAsync(ServerConnection server, CancellationToken cancellationToken) - { - var serverStatus = _serverManager.GetConnectionStatus(server.Id); - var majorVersion = serverStatus.SqlMajorVersion; - - /* Base columns available on all supported versions (2016+) */ - var selectColumns = @" - database_name = d.name, - state_desc = d.state_desc, - compatibility_level = d.compatibility_level, - collation_name = d.collation_name, - recovery_model = d.recovery_model_desc, - is_read_only = d.is_read_only, - is_auto_close_on = d.is_auto_close_on, - is_auto_shrink_on = d.is_auto_shrink_on, - is_auto_create_stats_on = d.is_auto_create_stats_on, - is_auto_update_stats_on = d.is_auto_update_stats_on, - is_auto_update_stats_async_on = d.is_auto_update_stats_async_on, - is_read_committed_snapshot_on = d.is_read_committed_snapshot_on, - snapshot_isolation_state = d.snapshot_isolation_state_desc, - is_parameterization_forced = d.is_parameterization_forced, - is_query_store_on = d.is_query_store_on, - is_encrypted = d.is_encrypted, - is_trustworthy_on = d.is_trustworthy_on, - is_db_chaining_on = d.is_db_chaining_on, - is_broker_enabled = d.is_broker_enabled, - is_cdc_enabled = d.is_cdc_enabled, - is_mixed_page_allocation_on = d.is_mixed_page_allocation_on, - log_reuse_wait_desc = d.log_reuse_wait_desc, - page_verify_option = d.page_verify_option_desc, - target_recovery_time_seconds = d.target_recovery_time_in_seconds, - delayed_durability = d.delayed_durability_desc"; - - /* SQL Server 2019+ (major version 15), or Azure SQL DB/MI which always have these */ - var isAzure = serverStatus.SqlEngineEdition == 5 || serverStatus.SqlEngineEdition == 8; - var has2019Columns = majorVersion >= 15 || majorVersion == 0 || isAzure; - if (has2019Columns) - { - selectColumns += @", - is_accelerated_database_recovery_on = d.is_accelerated_database_recovery_on, - is_memory_optimized_enabled = d.is_memory_optimized_enabled"; - } - - /* SQL Server 2025+ (major version 17) */ - var has2025Columns = majorVersion >= 17; - if (has2025Columns) - { - selectColumns += @", - is_optimized_locking_on = d.is_optimized_locking_on"; - } - - var (dbConfigExclusionClause, _) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); - - var query = $@" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -SELECT -{selectColumns} -FROM sys.databases AS d -WHERE (d.database_id > 4 OR d.database_id = 2) -AND d.database_id < 32761 -AND d.name <> N'PerformanceMonitor' -{dbConfigExclusionClause} -ORDER BY d.name -OPTION(RECOMPILE);"; - - var serverId = GetServerId(server); - var captureTime = DateTime.UtcNow; - var rowsCollected = 0; - _lastSqlMs = 0; - _lastDuckDbMs = 0; - - var rows = new List<DatabaseConfigCollected>(); - - var sqlSw = Stopwatch.StartNew(); - using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); - using var command = new SqlCommand(query, sqlConnection); - command.CommandTimeout = CommandTimeoutSeconds; - var (_, dbConfigExclusionParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); - foreach (var p in dbConfigExclusionParams) command.Parameters.Add(p); - - using var reader = await command.ExecuteReaderAsync(cancellationToken); - while (await reader.ReadAsync(cancellationToken)) - { - var ordinal = 0; - var r = new DatabaseConfigCollected - { - DbName = reader.GetString(ordinal++), - StateDesc = reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal), - CompatLevel = reader.IsDBNull(++ordinal) ? 0 : Convert.ToInt32(reader.GetValue(ordinal)), - CollationName = reader.IsDBNull(++ordinal) ? null : reader.GetString(ordinal), - RecoveryModel = reader.IsDBNull(++ordinal) ? null : reader.GetString(ordinal), - IsReadOnly = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), - AutoClose = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), - AutoShrink = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), - AutoCreateStats = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), - AutoUpdateStats = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), - AutoUpdateStatsAsync = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), - Rcsi = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), - SnapshotIsolation = reader.IsDBNull(++ordinal) ? null : reader.GetString(ordinal), - ParameterizationForced = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), - QueryStore = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), - Encrypted = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), - Trustworthy = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), - DbChaining = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), - BrokerEnabled = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), - CdcEnabled = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), - MixedPageAllocation = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), - LogReuseWait = reader.IsDBNull(++ordinal) ? null : reader.GetString(ordinal), - PageVerify = reader.IsDBNull(++ordinal) ? null : reader.GetString(ordinal), - TargetRecovery = reader.IsDBNull(++ordinal) ? 0 : Convert.ToInt32(reader.GetValue(ordinal)), - DelayedDurability = reader.IsDBNull(++ordinal) ? null : reader.GetString(ordinal), - }; - - if (has2019Columns) - { - r.AcceleratedDatabaseRecovery = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal); - r.MemoryOptimized = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal); - } - - if (has2025Columns) - { - r.OptimizedLocking = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal); - } - - rows.Add(r); - } - sqlSw.Stop(); - - var duckSw = Stopwatch.StartNew(); - using var duckConnection = _duckDb.CreateConnection(); - await duckConnection.OpenAsync(cancellationToken); - - using var appender = duckConnection.CreateAppender("database_config"); - foreach (var r in rows) - { - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(captureTime) - .AppendValue(serverId) - .AppendValue(GetServerNameForStorage(server)) - .AppendValue(r.DbName) - .AppendValue(r.StateDesc) - .AppendValue(r.CompatLevel) - .AppendValue(r.CollationName) - .AppendValue(r.RecoveryModel) - .AppendValue(r.IsReadOnly) - .AppendValue(r.AutoClose) - .AppendValue(r.AutoShrink) - .AppendValue(r.AutoCreateStats) - .AppendValue(r.AutoUpdateStats) - .AppendValue(r.AutoUpdateStatsAsync) - .AppendValue(r.Rcsi) - .AppendValue(r.SnapshotIsolation) - .AppendValue(r.ParameterizationForced) - .AppendValue(r.QueryStore) - .AppendValue(r.Encrypted) - .AppendValue(r.Trustworthy) - .AppendValue(r.DbChaining) - .AppendValue(r.BrokerEnabled) - .AppendValue(r.CdcEnabled) - .AppendValue(r.MixedPageAllocation) - .AppendValue(r.LogReuseWait) - .AppendValue(r.PageVerify) - .AppendValue(r.TargetRecovery) - .AppendValue(r.DelayedDurability); - - /* Version-gated columns: write value if collected, NULL otherwise */ - if (r.AcceleratedDatabaseRecovery.HasValue) - row.AppendValue(r.AcceleratedDatabaseRecovery.Value); - else - row.AppendNullValue(); - - if (r.MemoryOptimized.HasValue) - row.AppendValue(r.MemoryOptimized.Value); - else - row.AppendNullValue(); - - if (r.OptimizedLocking.HasValue) - row.AppendValue(r.OptimizedLocking.Value); - else - row.AppendNullValue(); - - row.EndRow(); - rowsCollected++; - } - - duckSw.Stop(); - _lastSqlMs = sqlSw.ElapsedMilliseconds; - _lastDuckDbMs = duckSw.ElapsedMilliseconds; - - _logger?.LogDebug("Collected {RowCount} database config rows for server '{Server}'", rowsCollected, server.DisplayName); - return rowsCollected; - } - - private class DatabaseConfigCollected - { - public string DbName { get; set; } = ""; - public string? StateDesc { get; set; } - public int CompatLevel { get; set; } - public string? CollationName { get; set; } - public string? RecoveryModel { get; set; } - public bool IsReadOnly { get; set; } - public bool AutoClose { get; set; } - public bool AutoShrink { get; set; } - public bool AutoCreateStats { get; set; } - public bool AutoUpdateStats { get; set; } - public bool AutoUpdateStatsAsync { get; set; } - public bool Rcsi { get; set; } - public string? SnapshotIsolation { get; set; } - public bool ParameterizationForced { get; set; } - public bool QueryStore { get; set; } - public bool Encrypted { get; set; } - public bool Trustworthy { get; set; } - public bool DbChaining { get; set; } - public bool BrokerEnabled { get; set; } - public bool CdcEnabled { get; set; } - public bool MixedPageAllocation { get; set; } - public string? LogReuseWait { get; set; } - public string? PageVerify { get; set; } - public int TargetRecovery { get; set; } - public string? DelayedDurability { get; set; } - public bool? AcceleratedDatabaseRecovery { get; set; } - public bool? MemoryOptimized { get; set; } - public bool? OptimizedLocking { get; set; } - } + private Task<int> CollectDatabaseConfigAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(DatabaseConfigCollector.Instance, server, cancellationToken); /// <summary> - /// Collects database-scoped configurations from sys.database_scoped_configurations - /// for each online user database. On-load only, not scheduled. + /// Collects database-scoped configurations via the shared + /// <see cref="DatabaseScopedConfigCollector"/> definition (the database enumeration with the + /// AG-primary filter and the [db].sys.sp_executesql per-database loop live there — the + /// cross-SKU parity contract). On-load only, not scheduled. /// </summary> - private async Task<int> CollectDatabaseScopedConfigAsync(ServerConnection server, CancellationToken cancellationToken) - { - var serverStatus = _serverManager.GetConnectionStatus(server.Id); - bool isAzureSqlDb = serverStatus?.SqlEngineEdition == 5; - - string onPremDbQuery = @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -SELECT - d.name -FROM sys.databases AS d -LEFT JOIN sys.dm_hadr_database_replica_states AS drs - ON d.database_id = drs.database_id - AND drs.is_local = 1 -WHERE (d.database_id > 4 OR d.database_id = 2) -AND d.database_id < 32761 -AND d.name <> N'PerformanceMonitor' -AND d.state_desc = N'ONLINE' -AND -( - drs.database_id IS NULL /*not in any AG*/ - OR drs.is_primary_replica = 1 /*primary replica*/ -) -/*EXCLUSION_FILTER*/ -ORDER BY d.name -OPTION(RECOMPILE);"; - - string azureDbQuery = @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -SELECT - d.name -FROM sys.databases AS d -WHERE (d.database_id > 4 OR d.database_id = 2) -AND d.database_id < 32761 -AND d.name <> N'PerformanceMonitor' -AND d.state_desc = N'ONLINE' -/*EXCLUSION_FILTER*/ -ORDER BY d.name -OPTION(RECOMPILE);"; - - var (scopedExclusionClause, _) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); - onPremDbQuery = onPremDbQuery.Replace("/*EXCLUSION_FILTER*/", scopedExclusionClause); - azureDbQuery = azureDbQuery.Replace("/*EXCLUSION_FILTER*/", scopedExclusionClause); - - string dbQuery = isAzureSqlDb ? azureDbQuery : onPremDbQuery; - - var serverId = GetServerId(server); - var captureTime = DateTime.UtcNow; - var totalRows = 0; - _lastSqlMs = 0; - _lastDuckDbMs = 0; - - var sqlSw = Stopwatch.StartNew(); - using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); - - /* Get list of databases */ - var databases = new List<string>(); - using (var dbCommand = new SqlCommand(dbQuery, sqlConnection)) - { - dbCommand.CommandTimeout = CommandTimeoutSeconds; - var (_, scopedExclusionParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); - foreach (var p in scopedExclusionParams) dbCommand.Parameters.Add(p); - using var dbReader = await dbCommand.ExecuteReaderAsync(cancellationToken); - while (await dbReader.ReadAsync(cancellationToken)) - { - databases.Add(dbReader.GetString(0)); - } - } - - if (databases.Count == 0) - { - return 0; - } - - /* Collect all scoped configs from SQL Server first */ - var scopedRows = new List<(string DbName, string ConfigName, string? Value, string? ValueForSecondary)>(); - - foreach (var dbName in databases) - { - try - { - /* Use [dbname].sys.sp_executesql to run in database context (Azure SQL DB compatible) */ - var scopedQuery = $@" -EXECUTE [{dbName.Replace("]", "]]")}].sys.sp_executesql - N'SELECT - configuration_name = dsc.name, - value = CONVERT(nvarchar(256), dsc.value), - value_for_secondary = CONVERT(nvarchar(256), dsc.value_for_secondary) - FROM sys.database_scoped_configurations AS dsc - ORDER BY dsc.name - OPTION(RECOMPILE);'"; - - using var cmd = new SqlCommand(scopedQuery, sqlConnection); - cmd.CommandTimeout = CommandTimeoutSeconds; - - using var reader = await cmd.ExecuteReaderAsync(cancellationToken); - while (await reader.ReadAsync(cancellationToken)) - { - scopedRows.Add(( - dbName, - reader.GetString(0), - reader.IsDBNull(1) ? null : reader.GetString(1), - reader.IsDBNull(2) ? null : reader.GetString(2))); - } - } - catch (SqlException ex) - { - _logger?.LogWarning("Failed to collect scoped config from [{Database}] on '{Server}': {Message}", - dbName, server.DisplayName, ex.Message); - } - } - - sqlSw.Stop(); - - /* Write to DuckDB using appender */ - var duckSw = Stopwatch.StartNew(); - using var duckConnection = _duckDb.CreateConnection(); - await duckConnection.OpenAsync(cancellationToken); - - using var appender = duckConnection.CreateAppender("database_scoped_config"); - foreach (var (dbName, configName, value, valueForSecondary) in scopedRows) - { - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(captureTime) - .AppendValue(serverId) - .AppendValue(GetServerNameForStorage(server)) - .AppendValue(dbName) - .AppendValue(configName) - .AppendValue(value) - .AppendValue(valueForSecondary) - .EndRow(); - totalRows++; - } - - duckSw.Stop(); - _lastSqlMs = sqlSw.ElapsedMilliseconds; - _lastDuckDbMs = duckSw.ElapsedMilliseconds; - - _logger?.LogDebug("Collected {RowCount} database scoped config rows across {DbCount} databases for server '{Server}'", - totalRows, databases.Count, server.DisplayName); - return totalRows; - } + private Task<int> CollectDatabaseScopedConfigAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(DatabaseScopedConfigCollector.Instance, server, cancellationToken); /// <summary> - /// Collects active trace flags via DBCC TRACESTATUS(-1). On-load only, not scheduled. - /// Wrapped in try/catch — fails gracefully if caller lacks DBCC permissions. + /// Collects active trace flags via the shared <see cref="TraceFlagsCollector"/> definition. + /// Wrapped in a permission-tolerant catch — DBCC may be denied — so a failure degrades to + /// zero rows with a warning, exactly as the original collector did. On-load only. /// </summary> private async Task<int> CollectTraceFlagsAsync(ServerConnection server, CancellationToken cancellationToken) { - const string query = @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -CREATE TABLE - #trace_flags -( - trace_flag integer NOT NULL, - status bit NOT NULL, - is_global bit NOT NULL, - is_session bit NOT NULL -); - -INSERT - #trace_flags -( - trace_flag, - status, - is_global, - is_session -) -EXECUTE(N'DBCC TRACESTATUS(-1) WITH NO_INFOMSGS;'); - -SELECT - tf.trace_flag, - tf.status, - tf.is_global, - tf.is_session -FROM #trace_flags AS tf -ORDER BY tf.trace_flag -OPTION(RECOMPILE);"; - - var serverId = GetServerId(server); - var captureTime = DateTime.UtcNow; - var rowsCollected = 0; - _lastSqlMs = 0; - _lastDuckDbMs = 0; - try { - var rows = new List<(int TraceFlag, bool Status, bool IsGlobal, bool IsSession)>(); - - var sqlSw = Stopwatch.StartNew(); - using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); - using var command = new SqlCommand(query, sqlConnection); - command.CommandTimeout = CommandTimeoutSeconds; - - using var reader = await command.ExecuteReaderAsync(cancellationToken); - while (await reader.ReadAsync(cancellationToken)) - { - rows.Add(( - reader.GetInt32(0), - reader.GetBoolean(1), - reader.GetBoolean(2), - reader.GetBoolean(3))); - } - sqlSw.Stop(); - - var duckSw = Stopwatch.StartNew(); - using var duckConnection = _duckDb.CreateConnection(); - await duckConnection.OpenAsync(cancellationToken); - - using var appender = duckConnection.CreateAppender("trace_flags"); - foreach (var r in rows) - { - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(captureTime) - .AppendValue(serverId) - .AppendValue(GetServerNameForStorage(server)) - .AppendValue(r.TraceFlag) - .AppendValue(r.Status) - .AppendValue(r.IsGlobal) - .AppendValue(r.IsSession) - .EndRow(); - rowsCollected++; - } - - duckSw.Stop(); - _lastSqlMs = sqlSw.ElapsedMilliseconds; - _lastDuckDbMs = duckSw.ElapsedMilliseconds; - - _logger?.LogDebug("Collected {RowCount} trace flag rows for server '{Server}'", rowsCollected, server.DisplayName); + return await RunCollectorDefinitionAsync(TraceFlagsCollector.Instance, server, cancellationToken); } catch (SqlException ex) { _logger?.LogWarning("Failed to collect trace flags on '{Server}' (may lack DBCC permissions): {Message}", server.DisplayName, ex.Message); + return 0; } - - return rowsCollected; } } diff --git a/Lite/Services/RemoteCollectorService.ServerProperties.cs b/Lite/Services/RemoteCollectorService.ServerProperties.cs index 78bf195e9..7527e5290 100644 --- a/Lite/Services/RemoteCollectorService.ServerProperties.cs +++ b/Lite/Services/RemoteCollectorService.ServerProperties.cs @@ -6,13 +6,9 @@ * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ -using System; -using System.Diagnostics; using System.Threading; using System.Threading.Tasks; -using DuckDB.NET.Data; -using Microsoft.Data.SqlClient; -using Microsoft.Extensions.Logging; +using PerformanceMonitor.Collectors; using PerformanceMonitorLite.Models; namespace PerformanceMonitorLite.Services; @@ -20,300 +16,10 @@ namespace PerformanceMonitorLite.Services; public partial class RemoteCollectorService { /// <summary> - /// Collects server edition, version, CPU/memory hardware metadata for - /// license audit and FinOps cost attribution. On-load only collector. + /// Collects server edition/version/hardware metadata via the shared + /// <see cref="ServerPropertiesCollector"/> definition (Azure edition/tier naming, the vCore + /// parse, and the best-effort WS5 health probe live there — the cross-SKU parity contract). /// </summary> - // RAM floor below which LPIM-off is not worth flagging (shared rule with the Dashboard - // SqlServerFactCollector). On a small buffer pool the OS paging SQL out is not the practical - // risk it is on a large dedicated host. - private const long LpimAdvisoryMinPhysicalMemoryMb = 32 * 1024; - - private async Task<int> CollectServerPropertiesAsync(ServerConnection server, CancellationToken cancellationToken) - { - var serverStatus = _serverManager.GetConnectionStatus(server.Id); - bool isAzureSqlDb = serverStatus?.SqlEngineEdition == 5; - - const string query = @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -SELECT - server_name = - CONVERT(nvarchar(128), SERVERPROPERTY(N'ServerName')), - edition = - /* Azure SQL DB reports the legacy 'SQL Azure' for SERVERPROPERTY('Edition'); - store the actual product name + service tier instead. */ - CASE - WHEN CONVERT(int, SERVERPROPERTY(N'EngineEdition')) = 5 - THEN N'Azure SQL Database' - + ISNULL(N' (' + - CASE CONVERT(nvarchar(128), DATABASEPROPERTYEX(DB_NAME(), N'Edition')) - WHEN N'GeneralPurpose' THEN N'General Purpose' - WHEN N'BusinessCritical' THEN N'Business Critical' - ELSE CONVERT(nvarchar(128), DATABASEPROPERTYEX(DB_NAME(), N'Edition')) - END + N')', N'') - ELSE CONVERT(nvarchar(128), SERVERPROPERTY(N'Edition')) - END, - product_version = - CONVERT(nvarchar(128), SERVERPROPERTY(N'ProductVersion')), - product_level = - CONVERT(nvarchar(128), SERVERPROPERTY(N'ProductLevel')), - product_update_level = - CONVERT(nvarchar(128), SERVERPROPERTY(N'ProductUpdateLevel')), - engine_edition = - CONVERT(int, SERVERPROPERTY(N'EngineEdition')), - cpu_count = - osi.cpu_count, - hyperthread_ratio = - osi.hyperthread_ratio, - physical_memory_mb = - osi.physical_memory_kb / 1024, - socket_count = - osi.socket_count, - cores_per_socket = - osi.cores_per_socket, - is_hadr_enabled = - CONVERT(bit, SERVERPROPERTY(N'IsHadrEnabled')), - is_clustered = - CONVERT(bit, SERVERPROPERTY(N'IsClustered')), - service_objective = - CASE - WHEN CONVERT(int, SERVERPROPERTY(N'EngineEdition')) = 5 - THEN CONVERT(nvarchar(128), DATABASEPROPERTYEX(DB_NAME(), N'ServiceObjective')) - ELSE NULL - END -FROM sys.dm_os_sys_info AS osi -OPTION(RECOMPILE);"; - - var serverId = GetServerId(server); - var collectionTime = DateTime.UtcNow; - var rowsCollected = 0; - _lastSqlMs = 0; - _lastDuckDbMs = 0; - - var sqlSw = Stopwatch.StartNew(); - using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); - using var command = new SqlCommand(query, sqlConnection); - command.CommandTimeout = CommandTimeoutSeconds; - - using var reader = await command.ExecuteReaderAsync(cancellationToken); - if (await reader.ReadAsync(cancellationToken)) - { - var serverName = reader.GetString(0); - var edition = reader.GetString(1); - var productVersion = reader.GetString(2); - var productLevel = reader.GetString(3); - var productUpdateLevel = reader.IsDBNull(4) ? null : reader.GetString(4); - var engineEdition = reader.GetInt32(5); - var cpuCount = reader.GetInt32(6); - var hyperthreadRatio = reader.GetInt32(7); - var physicalMemoryMb = reader.GetInt64(8); - int? socketCount = reader.IsDBNull(9) ? null : reader.GetInt32(9); - int? coresPerSocket = reader.IsDBNull(10) ? null : reader.GetInt32(10); - bool? isHadrEnabled = reader.IsDBNull(11) ? null : reader.GetBoolean(11); - bool? isClustered = reader.IsDBNull(12) ? null : reader.GetBoolean(12); - var serviceObjective = reader.IsDBNull(13) ? null : reader.GetString(13); - - /* For Azure SQL DB, sys.dm_os_sys_info.cpu_count returns the compute node's - total cores, not the per-database vCore allocation. Parse the actual vCore - count from the service_objective string (e.g. "HS_Gen5_14" → 14). */ - int? vcoreCount = null; - if (isAzureSqlDb && !string.IsNullOrEmpty(serviceObjective)) - { - vcoreCount = ParseVcoreFromServiceObjective(serviceObjective); - } - - /* WS5 server-health values (LPIM / IFI / memory dumps). Read best-effort on the same - connection — version-gated and Azure-unavailable DMVs are guarded inside the query - and any failure leaves the values NULL rather than failing the collector. */ - var (lockPagesInMemory, instantFileInit, memoryDumpCount) = - await ReadServerHealthAsync(sqlConnection, isAzureSqlDb, cancellationToken); - - sqlSw.Stop(); - - var duckSw = Stopwatch.StartNew(); - - using (var duckConnection = _duckDb.CreateConnection()) - { - await duckConnection.OpenAsync(cancellationToken); - - using (var appender = duckConnection.CreateAppender("server_properties")) - { - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(GetServerNameForStorage(server)) - .AppendValue(edition) - .AppendValue(productVersion) - .AppendValue(productLevel) - .AppendValue(productUpdateLevel) - .AppendValue(engineEdition) - .AppendValue(cpuCount) - .AppendValue(hyperthreadRatio) - .AppendValue(physicalMemoryMb) - .AppendValue(socketCount) - .AppendValue(coresPerSocket) - .AppendValue(isHadrEnabled) - .AppendValue(isClustered) - .AppendValue((string?)null) // enterprise_features — not collected in Lite (requires cross-database cursor) - .AppendValue(serviceObjective) - .AppendValue(vcoreCount) - .AppendValue(lockPagesInMemory) - .AppendValue(instantFileInit) - .AppendValue(memoryDumpCount) - .EndRow(); - rowsCollected++; - } - } - - duckSw.Stop(); - _lastDuckDbMs = duckSw.ElapsedMilliseconds; - } - else - { - sqlSw.Stop(); - } - - _lastSqlMs = sqlSw.ElapsedMilliseconds; - - _logger?.LogDebug("Collected {RowCount} server properties row(s) for server '{Server}'", rowsCollected, server.DisplayName); - return rowsCollected; - } - - /// <summary> - /// Reads the WS5 server-health values — Lock Pages in Memory, Instant File Initialization, - /// and the SQL Server memory-dump count — best-effort from a single guarded batch. Each value - /// is left NULL when its source is unavailable (Azure SQL DB, older builds, or a missing - /// column/DMV), and any failure is swallowed so this never fails the server-properties - /// collection. Mirrors the defensive logic in install/53_collect_server_properties.sql. - /// </summary> - private async Task<(bool? lpim, bool? ifi, int? dumpCount)> ReadServerHealthAsync( - SqlConnection sqlConnection, bool isAzureSqlDb, CancellationToken cancellationToken) - { - if (isAzureSqlDb) - { - /* None of these sources are meaningful/available on Azure SQL DB. */ - return (null, null, null); - } - - /* LPIM: sql_memory_model_desc (LOCK_PAGES / LARGE_PAGES => on; CONVENTIONAL => off). - IFI: sys.dm_server_services.instant_file_initialization_enabled ('Y'/'N'), guarded on - DMV + column existence and read via dynamic SQL so older builds still compile. - Dumps: COUNT from sys.dm_server_memory_dumps, guarded on DMV existence. - Each branch is wrapped in TRY/CATCH inside the batch so one missing source does not - prevent reading the others. */ - const string query = @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -DECLARE - @lpim bit = NULL, - @ifi bit = NULL, - @dumps integer = NULL; - -BEGIN TRY - SELECT - @lpim = - CASE - WHEN osi.sql_memory_model IN (2, 3) - THEN CONVERT(bit, 1) - ELSE CONVERT(bit, 0) - END - FROM sys.dm_os_sys_info AS osi; -END TRY -BEGIN CATCH - SET @lpim = NULL; -END CATCH; - -IF OBJECT_ID(N'sys.dm_server_services', N'V') IS NOT NULL -AND EXISTS -( - SELECT - 1/0 - FROM sys.system_columns AS sc - WHERE sc.object_id = OBJECT_ID(N'sys.dm_server_services') - AND sc.name = N'instant_file_initialization_enabled' -) -BEGIN - BEGIN TRY - DECLARE - @ifi_sql nvarchar(max) = - N' - SELECT TOP (1) - @ifi_out = - CASE - WHEN ss.instant_file_initialization_enabled = N''Y'' - THEN CONVERT(bit, 1) - WHEN ss.instant_file_initialization_enabled = N''N'' - THEN CONVERT(bit, 0) - ELSE NULL - END - FROM sys.dm_server_services AS ss - WHERE ss.servicename LIKE N''SQL Server (%'';'; - - EXECUTE sys.sp_executesql - @ifi_sql, - N'@ifi_out bit OUTPUT', - @ifi_out = @ifi OUTPUT; - END TRY - BEGIN CATCH - SET @ifi = NULL; - END CATCH; -END; - -IF OBJECT_ID(N'sys.dm_server_memory_dumps', N'V') IS NOT NULL -BEGIN - BEGIN TRY - SELECT - @dumps = COUNT_BIG(*) - FROM sys.dm_server_memory_dumps AS smd; - END TRY - BEGIN CATCH - SET @dumps = NULL; - END CATCH; -END; - -SELECT - lock_pages_in_memory = @lpim, - instant_file_initialization_enabled = @ifi, - memory_dump_count = @dumps;"; - - try - { - using var command = new SqlCommand(query, sqlConnection); - command.CommandTimeout = CommandTimeoutSeconds; - - using var reader = await command.ExecuteReaderAsync(cancellationToken); - if (!await reader.ReadAsync(cancellationToken)) - { - return (null, null, null); - } - - bool? lpim = reader.IsDBNull(0) ? (bool?)null : reader.GetBoolean(0); - bool? ifi = reader.IsDBNull(1) ? (bool?)null : reader.GetBoolean(1); - int? dumpCount = reader.IsDBNull(2) ? (int?)null : reader.GetInt32(2); - return (lpim, ifi, dumpCount); - } - catch (Exception ex) - { - _logger?.LogDebug(ex, "ReadServerHealthAsync failed; leaving server-health values NULL"); - return (null, null, null); - } - } - - /// <summary> - /// Parses the vCore count from an Azure SQL DB service objective string. - /// vCore tiers follow the pattern {Tier}_{Gen}_{VcoreCount} - /// (e.g. "HS_Gen5_14", "GP_Gen5_6", "BC_Gen5_8", "GP_S_Gen5_2"). - /// DTU tiers (e.g. "P1", "S0") and elastic pools return null. - /// </summary> - internal static int? ParseVcoreFromServiceObjective(string serviceObjective) - { - var parts = serviceObjective.Split('_'); - if (parts.Length >= 3 && int.TryParse(parts[^1], out var vcores) && vcores > 0) - { - return vcores; - } - - return null; - } + private Task<int> CollectServerPropertiesAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(ServerPropertiesCollector.Instance, server, cancellationToken); } diff --git a/Lite/Services/RemoteCollectorService.SessionStats.cs b/Lite/Services/RemoteCollectorService.SessionStats.cs index 7d0f013ad..eefb308cc 100644 --- a/Lite/Services/RemoteCollectorService.SessionStats.cs +++ b/Lite/Services/RemoteCollectorService.SessionStats.cs @@ -6,13 +6,9 @@ * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ -using System; -using System.Diagnostics; using System.Threading; using System.Threading.Tasks; -using DuckDB.NET.Data; -using Microsoft.Data.SqlClient; -using Microsoft.Extensions.Logging; +using PerformanceMonitor.Collectors; using PerformanceMonitorLite.Models; namespace PerformanceMonitorLite.Services; @@ -20,127 +16,17 @@ namespace PerformanceMonitorLite.Services; public partial class RemoteCollectorService { /// <summary> - /// Collects per-application session statistics from sys.dm_exec_sessions. - /// Groups by program_name to track connection counts, status breakdown, - /// and cumulative resource usage per application. + /// Collects per-application session statistics via the shared + /// <see cref="SessionStatsCollector"/> definition. /// </summary> - private async Task<int> CollectSessionStatsAsync(ServerConnection server, CancellationToken cancellationToken) - { - const string query = @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + private Task<int> CollectSessionStatsAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(SessionStatsCollector.Instance, server, cancellationToken); -SELECT - program_name = - ISNULL(des.program_name, N''), - connection_count = - COUNT_BIG(*), - running_count = - SUM - ( - CASE - WHEN des.status = N'running' - THEN 1 - ELSE 0 - END - ), - sleeping_count = - SUM - ( - CASE - WHEN des.status = N'sleeping' - THEN 1 - ELSE 0 - END - ), - dormant_count = - SUM - ( - CASE - WHEN des.status = N'dormant' - THEN 1 - ELSE 0 - END - ), - total_cpu_time_ms = - SUM(des.cpu_time), - total_reads = - SUM(des.reads), - total_writes = - SUM(des.writes), - total_logical_reads = - SUM(des.logical_reads) -FROM sys.dm_exec_sessions AS des -WHERE des.session_id > 50 -AND des.is_user_process = 1 -AND des.program_name IS NOT NULL -AND des.program_name <> N'' -AND des.program_name NOT LIKE N'PerformanceMonitor%' -GROUP BY - des.program_name -ORDER BY - COUNT_BIG(*) DESC -OPTION(RECOMPILE);"; - - var serverId = GetServerId(server); - var collectionTime = DateTime.UtcNow; - var rowsCollected = 0; - _lastSqlMs = 0; - _lastDuckDbMs = 0; - - var sqlSw = Stopwatch.StartNew(); - using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); - using var command = new SqlCommand(query, sqlConnection); - command.CommandTimeout = CommandTimeoutSeconds; - - using var reader = await command.ExecuteReaderAsync(cancellationToken); - sqlSw.Stop(); - _lastSqlMs = sqlSw.ElapsedMilliseconds; - - var duckSw = Stopwatch.StartNew(); - - using (var duckConnection = _duckDb.CreateConnection()) - { - await duckConnection.OpenAsync(cancellationToken); - - using (var appender = duckConnection.CreateAppender("session_stats")) - { - while (await reader.ReadAsync(cancellationToken)) - { - var programName = reader.GetString(0); - var connectionCount = Convert.ToInt64(reader.GetValue(1)); - var runningCount = Convert.ToInt32(reader.GetValue(2)); - var sleepingCount = Convert.ToInt32(reader.GetValue(3)); - var dormantCount = Convert.ToInt32(reader.GetValue(4)); - long? totalCpuTimeMs = reader.IsDBNull(5) ? null : Convert.ToInt64(reader.GetValue(5)); - long? totalReads = reader.IsDBNull(6) ? null : Convert.ToInt64(reader.GetValue(6)); - long? totalWrites = reader.IsDBNull(7) ? null : Convert.ToInt64(reader.GetValue(7)); - long? totalLogicalReads = reader.IsDBNull(8) ? null : Convert.ToInt64(reader.GetValue(8)); - - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(GetServerNameForStorage(server)) - .AppendValue(programName) - .AppendValue(connectionCount) - .AppendValue(runningCount) - .AppendValue(sleepingCount) - .AppendValue(dormantCount) - .AppendValue(totalCpuTimeMs) - .AppendValue(totalReads) - .AppendValue(totalWrites) - .AppendValue(totalLogicalReads) - .EndRow(); - - rowsCollected++; - } - } - } - - duckSw.Stop(); - _lastDuckDbMs = duckSw.ElapsedMilliseconds; - - _logger?.LogDebug("Collected {RowCount} session stats rows for server '{Server}'", rowsCollected, server.DisplayName); - return rowsCollected; - } + /// <summary> + /// Collects the server-wide session SUMMARY (connection-leak / idle signal) via the shared + /// <see cref="SessionSummaryStatsCollector"/> definition (query text and payload order live there — + /// the cross-SKU parity contract). Distinct from the per-application session_stats collector. + /// </summary> + private Task<int> CollectSessionSummaryStatsAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(SessionSummaryStatsCollector.Instance, server, cancellationToken); } diff --git a/Lite/Services/RemoteCollectorService.SystemHealth.cs b/Lite/Services/RemoteCollectorService.SystemHealth.cs new file mode 100644 index 000000000..0db31fa36 --- /dev/null +++ b/Lite/Services/RemoteCollectorService.SystemHealth.cs @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Threading; +using System.Threading.Tasks; +using PerformanceMonitor.Collectors; +using PerformanceMonitorLite.Models; + +namespace PerformanceMonitorLite.Services; + +public partial class RemoteCollectorService +{ + /// <summary> + /// Collects raw system_health Extended Events (Stage 1 capture) via the shared + /// <see cref="SystemHealthEventsCollector"/> definition (the built-in-session ring-buffer read, the + /// six-event filter, the event_time watermark, and the raw-XML storage all live there — the + /// cross-SKU parity contract). Unlike the deadlock collector there is NO XE session lifecycle: + /// system_health is the Database Engine's always-on session, so nothing is created or started here, + /// just read. Not collected on Azure SQL DB (the definition's AppliesTo skips the cycle). + /// </summary> + private Task<int> CollectSystemHealthEventsAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(SystemHealthEventsCollector.Instance, server, cancellationToken); +} diff --git a/Lite/Services/RemoteCollectorService.TempDb.cs b/Lite/Services/RemoteCollectorService.TempDb.cs index e4305870b..aa06260b0 100644 --- a/Lite/Services/RemoteCollectorService.TempDb.cs +++ b/Lite/Services/RemoteCollectorService.TempDb.cs @@ -6,13 +6,9 @@ * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ -using System; -using System.Diagnostics; using System.Threading; using System.Threading.Tasks; -using DuckDB.NET.Data; -using Microsoft.Data.SqlClient; -using Microsoft.Extensions.Logging; +using PerformanceMonitor.Collectors; using PerformanceMonitorLite.Models; namespace PerformanceMonitorLite.Services; @@ -20,95 +16,10 @@ namespace PerformanceMonitorLite.Services; public partial class RemoteCollectorService { /// <summary> - /// Collects TempDB space usage statistics. + /// Collects TempDB space usage statistics via the shared <see cref="TempDbStatsCollector"/> + /// definition (two result sets → one row; query text and payload order live there — the + /// cross-SKU parity contract). /// </summary> - private async Task<int> CollectTempDbStatsAsync(ServerConnection server, CancellationToken cancellationToken) - { - const string query = @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -SELECT /* PerformanceMonitorLite */ - user_object_reserved_mb = CONVERT(decimal(18,2), SUM(dsu.user_object_reserved_page_count) * 8 / 1024.0), - internal_object_reserved_mb = CONVERT(decimal(18,2), SUM(dsu.internal_object_reserved_page_count) * 8 / 1024.0), - version_store_reserved_mb = CONVERT(decimal(18,2), SUM(dsu.version_store_reserved_page_count) * 8 / 1024.0), - total_reserved_mb = CONVERT(decimal(18,2), SUM(dsu.user_object_reserved_page_count + dsu.internal_object_reserved_page_count + dsu.version_store_reserved_page_count) * 8 / 1024.0), - unallocated_mb = CONVERT(decimal(18,2), SUM(dsu.unallocated_extent_page_count) * 8 / 1024.0) -FROM tempdb.sys.dm_db_file_space_usage AS dsu -OPTION(RECOMPILE); - -SELECT /* PerformanceMonitorLite */ TOP (1) - session_id = ssu.session_id, - tempdb_mb = CONVERT(decimal(18,2), (ssu.user_objects_alloc_page_count + ssu.internal_objects_alloc_page_count) * 8 / 1024.0), - total_sessions = (SELECT COUNT_BIG(*) FROM sys.dm_db_session_space_usage WHERE user_objects_alloc_page_count + internal_objects_alloc_page_count > 0) -FROM sys.dm_db_session_space_usage AS ssu -ORDER BY (ssu.user_objects_alloc_page_count + ssu.internal_objects_alloc_page_count) DESC -OPTION(RECOMPILE);"; - - var serverId = GetServerId(server); - var collectionTime = DateTime.UtcNow; - _lastSqlMs = 0; - _lastDuckDbMs = 0; - - var sqlSw = Stopwatch.StartNew(); - using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); - using var command = new SqlCommand(query, sqlConnection); - command.CommandTimeout = CommandTimeoutSeconds; - - using var reader = await command.ExecuteReaderAsync(cancellationToken); - - decimal userObjMb = 0, internalObjMb = 0, versionStoreMb = 0, totalReservedMb = 0, unallocatedMb = 0; - int topSessionId = 0; - long totalSessions = 0; - decimal topSessionMb = 0; - - if (await reader.ReadAsync(cancellationToken)) - { - userObjMb = reader.IsDBNull(0) ? 0m : reader.GetDecimal(0); - internalObjMb = reader.IsDBNull(1) ? 0m : reader.GetDecimal(1); - versionStoreMb = reader.IsDBNull(2) ? 0m : reader.GetDecimal(2); - totalReservedMb = reader.IsDBNull(3) ? 0m : reader.GetDecimal(3); - unallocatedMb = reader.IsDBNull(4) ? 0m : reader.GetDecimal(4); - } - - if (await reader.NextResultAsync(cancellationToken) && await reader.ReadAsync(cancellationToken)) - { - topSessionId = reader.IsDBNull(0) ? 0 : Convert.ToInt32(reader.GetValue(0)); - topSessionMb = reader.IsDBNull(1) ? 0m : reader.GetDecimal(1); - totalSessions = reader.IsDBNull(2) ? 0L : reader.GetInt64(2); - } - sqlSw.Stop(); - - /* Insert into DuckDB using Appender */ - var duckSw = Stopwatch.StartNew(); - - using (var duckConnection = _duckDb.CreateConnection()) - { - await duckConnection.OpenAsync(cancellationToken); - - using (var appender = duckConnection.CreateAppender("tempdb_stats")) - { - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(GetServerNameForStorage(server)) - .AppendValue(userObjMb) - .AppendValue(internalObjMb) - .AppendValue(versionStoreMb) - .AppendValue(totalReservedMb) - .AppendValue(unallocatedMb) - .AppendValue(totalSessions) - .AppendValue(topSessionId) - .AppendValue(topSessionMb) - .EndRow(); - } - } - - duckSw.Stop(); - _lastSqlMs = sqlSw.ElapsedMilliseconds; - _lastDuckDbMs = duckSw.ElapsedMilliseconds; - - _logger?.LogDebug("Collected TempDB stats for server '{Server}'", server.DisplayName); - return 1; - } + private Task<int> CollectTempDbStatsAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(TempDbStatsCollector.Instance, server, cancellationToken); } diff --git a/Lite/Services/RemoteCollectorService.WaitStats.cs b/Lite/Services/RemoteCollectorService.WaitStats.cs index 0e03def4c..1aa5df75a 100644 --- a/Lite/Services/RemoteCollectorService.WaitStats.cs +++ b/Lite/Services/RemoteCollectorService.WaitStats.cs @@ -8,13 +8,10 @@ using System; using System.Collections.Generic; -using System.Diagnostics; -using System.IO; using System.Threading; using System.Threading.Tasks; -using DuckDB.NET.Data; -using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; +using PerformanceMonitor.Collectors; using PerformanceMonitorLite.Models; namespace PerformanceMonitorLite.Services; @@ -40,98 +37,10 @@ private HashSet<string> LoadIgnoredWaitTypes() } /// <summary> - /// Collects wait statistics from sys.dm_os_wait_stats. + /// Collects wait statistics from sys.dm_os_wait_stats via the shared + /// <see cref="WaitStatsCollector"/> definition (query text, ignored-wait filtering, delta + /// rules, and payload order live there — the cross-SKU parity contract). /// </summary> - private async Task<int> CollectWaitStatsAsync(ServerConnection server, CancellationToken cancellationToken) - { - const string query = @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -SELECT - wait_type = ws.wait_type, - waiting_tasks_count = ws.waiting_tasks_count, - wait_time_ms = ws.wait_time_ms, - signal_wait_time_ms = ws.signal_wait_time_ms -FROM sys.dm_os_wait_stats AS ws -WHERE ws.wait_time_ms > 0 -OPTION(RECOMPILE);"; - - var ignoredWaits = _ignoredWaitTypes.Value; - var serverId = GetServerId(server); - var collectionTime = DateTime.UtcNow; - var rowsCollected = 0; - _lastSqlMs = 0; - _lastDuckDbMs = 0; - - var sqlSw = Stopwatch.StartNew(); - using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); - using var command = new SqlCommand(query, sqlConnection); - command.CommandTimeout = CommandTimeoutSeconds; - - using var reader = await command.ExecuteReaderAsync(cancellationToken); - - /* Collect all rows first, then batch insert into DuckDB */ - var waitStats = new List<(string WaitType, long WaitingTasks, long WaitTimeMs, long SignalWaitTimeMs)>(); - - while (await reader.ReadAsync(cancellationToken)) - { - var waitType = reader.GetString(0); - - /* Skip ignored wait types */ - if (ignoredWaits.Contains(waitType)) - { - continue; - } - - waitStats.Add(( - WaitType: waitType, - WaitingTasks: reader.GetInt64(1), - WaitTimeMs: reader.GetInt64(2), - SignalWaitTimeMs: reader.GetInt64(3) - )); - } - sqlSw.Stop(); - _lastSqlMs = sqlSw.ElapsedMilliseconds; - - /* Insert into DuckDB with delta calculations using Appender for bulk performance */ - var duckSw = Stopwatch.StartNew(); - - using (var duckConnection = _duckDb.CreateConnection()) - { - await duckConnection.OpenAsync(cancellationToken); - - using (var appender = duckConnection.CreateAppender("wait_stats")) - { - foreach (var stat in waitStats) - { - var deltaKey = stat.WaitType; - var deltaWaitingTasks = _deltaCalculator.CalculateDelta(serverId, "wait_stats_tasks", deltaKey, stat.WaitingTasks, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaWaitTimeMs = _deltaCalculator.CalculateDelta(serverId, "wait_stats_time", deltaKey, stat.WaitTimeMs, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaSignalWaitTimeMs = _deltaCalculator.CalculateDelta(serverId, "wait_stats_signal", deltaKey, stat.SignalWaitTimeMs, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) /* collection_id BIGINT */ - .AppendValue(collectionTime) /* collection_time TIMESTAMP */ - .AppendValue(serverId) /* server_id INTEGER */ - .AppendValue(GetServerNameForStorage(server)) /* server_name VARCHAR */ - .AppendValue(stat.WaitType) /* wait_type VARCHAR */ - .AppendValue(stat.WaitingTasks) /* waiting_tasks_count BIGINT */ - .AppendValue(stat.WaitTimeMs) /* wait_time_ms BIGINT */ - .AppendValue(stat.SignalWaitTimeMs) /* signal_wait_time_ms BIGINT */ - .AppendValue(deltaWaitingTasks) /* delta_waiting_tasks BIGINT */ - .AppendValue(deltaWaitTimeMs) /* delta_wait_time_ms BIGINT */ - .AppendValue(deltaSignalWaitTimeMs) /* delta_signal_wait_time_ms BIGINT */ - .EndRow(); - - rowsCollected++; - } - } - } - - duckSw.Stop(); - _lastDuckDbMs = duckSw.ElapsedMilliseconds; - - _logger?.LogDebug("Collected {RowCount} wait stats for server '{Server}'", rowsCollected, server.DisplayName); - return rowsCollected; - } + private Task<int> CollectWaitStatsAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(WaitStatsCollector.Instance, server, cancellationToken); } diff --git a/Lite/Services/RemoteCollectorService.WaitingTasks.cs b/Lite/Services/RemoteCollectorService.WaitingTasks.cs index aaa7d2d08..7cca91e0f 100644 --- a/Lite/Services/RemoteCollectorService.WaitingTasks.cs +++ b/Lite/Services/RemoteCollectorService.WaitingTasks.cs @@ -6,13 +6,9 @@ * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ -using System; -using System.Diagnostics; using System.Threading; using System.Threading.Tasks; -using DuckDB.NET.Data; -using Microsoft.Data.SqlClient; -using Microsoft.Extensions.Logging; +using PerformanceMonitor.Collectors; using PerformanceMonitorLite.Models; namespace PerformanceMonitorLite.Services; @@ -20,90 +16,10 @@ namespace PerformanceMonitorLite.Services; public partial class RemoteCollectorService { /// <summary> - /// Collects point-in-time waiting task information from sys.dm_os_waiting_tasks. + /// Collects point-in-time waiting tasks via the shared <see cref="WaitingTasksCollector"/> + /// definition (smallint widening, the NULL resource_description placeholder, and the + /// exclusion splice live there — the cross-SKU parity contract). /// </summary> - private async Task<int> CollectWaitingTasksAsync(ServerConnection server, CancellationToken cancellationToken) - { - string query = @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; - -SELECT /* PerformanceMonitorLite */ - session_id = wt.session_id, - wait_type = wt.wait_type, - wait_duration_ms = wt.wait_duration_ms, - blocking_session_id = wt.blocking_session_id, - database_name = d.name -FROM sys.dm_os_waiting_tasks AS wt -LEFT JOIN sys.dm_exec_requests AS er - ON er.session_id = wt.session_id -LEFT JOIN sys.databases AS d - ON d.database_id = er.database_id -WHERE wt.session_id >= 50 -AND wt.session_id <> @@SPID -AND wt.wait_type IS NOT NULL -AND er.database_id <> ISNULL(DB_ID(N'PerformanceMonitor'), 0) -/*EXCLUSION_FILTER*/ -OPTION(RECOMPILE);"; - - var (exclusionClause, _) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); - query = query.Replace("/*EXCLUSION_FILTER*/", exclusionClause); - - var serverId = GetServerId(server); - var collectionTime = DateTime.UtcNow; - var rowsCollected = 0; - _lastSqlMs = 0; - _lastDuckDbMs = 0; - - var sqlSw = Stopwatch.StartNew(); - using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); - using var command = new SqlCommand(query, sqlConnection); - command.CommandTimeout = CommandTimeoutSeconds; - var (_, exclusionParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); - foreach (var p in exclusionParams) command.Parameters.Add(p); - - using var reader = await command.ExecuteReaderAsync(cancellationToken); - sqlSw.Stop(); - _lastSqlMs = sqlSw.ElapsedMilliseconds; - - var duckSw = Stopwatch.StartNew(); - - using (var duckConnection = _duckDb.CreateConnection()) - { - await duckConnection.OpenAsync(cancellationToken); - - using (var appender = duckConnection.CreateAppender("waiting_tasks")) - { - while (await reader.ReadAsync(cancellationToken)) - { - /* session_id and blocking_session_id are smallint in sys.dm_os_waiting_tasks */ - var sessionId = reader.IsDBNull(0) ? 0 : reader.GetInt16(0); - var waitType = reader.IsDBNull(1) ? null : reader.GetString(1); - var waitDurationMs = reader.IsDBNull(2) ? 0L : reader.GetInt64(2); - var blockingSessionId = reader.IsDBNull(3) ? (short?)null : reader.GetInt16(3); - var databaseName = reader.IsDBNull(4) ? null : reader.GetString(4); - - var row = appender.CreateRow(); - row.AppendValue(GenerateCollectionId()) - .AppendValue(collectionTime) - .AppendValue(serverId) - .AppendValue(GetServerNameForStorage(server)) - .AppendValue((int)sessionId) - .AppendValue(waitType) - .AppendValue(waitDurationMs) - .AppendValue(blockingSessionId.HasValue ? (int?)blockingSessionId.Value : null) - .AppendValue((string?)null) /* resource_description — no longer collected */ - .AppendValue(databaseName) - .EndRow(); - - rowsCollected++; - } - } - } - - duckSw.Stop(); - _lastDuckDbMs = duckSw.ElapsedMilliseconds; - - _logger?.LogDebug("Collected {RowCount} waiting task records for server '{Server}'", rowsCollected, server.DisplayName); - return rowsCollected; - } + private Task<int> CollectWaitingTasksAsync(ServerConnection server, CancellationToken cancellationToken) + => RunCollectorDefinitionAsync(WaitingTasksCollector.Instance, server, cancellationToken); } diff --git a/Lite/Services/RemoteCollectorService.cs b/Lite/Services/RemoteCollectorService.cs index 124a8f604..4f46fc4cf 100644 --- a/Lite/Services/RemoteCollectorService.cs +++ b/Lite/Services/RemoteCollectorService.cs @@ -18,6 +18,7 @@ using PerformanceMonitorLite.Database; using PerformanceMonitorLite.Helpers; using PerformanceMonitorLite.Models; +using PerformanceMonitor.Collectors; using PerformanceMonitor.Common; @@ -35,11 +36,8 @@ public class CollectorHealthEntry public int ServerId { get; set; } public string CollectorName { get; set; } = ""; public DateTime? LastSuccessTime { get; set; } - public DateTime? LastErrorTime { get; set; } public string? LastErrorMessage { get; set; } public int ConsecutiveErrors { get; set; } - public int TotalErrors { get; set; } - public int TotalSuccesses { get; set; } /* * Set when a collector hits a non-transient permission error @@ -106,7 +104,6 @@ public partial class RemoteCollectorService private readonly ILogger<RemoteCollectorService>? _logger; private readonly DeltaCalculator _deltaCalculator; public DeltaCalculator DeltaCalculator => _deltaCalculator; - private static long s_idCounter = DateTime.UtcNow.Ticks; /// <summary> /// Limits how many SQL connections are <em>opened</em> at once — the semaphore is released @@ -148,15 +145,34 @@ public partial class RemoteCollectorService /// Tracks consecutive failures of the collection_log INSERT itself. /// </summary> private int _logInsertFailures; - private string? _lastLogInsertError; /// <summary> - /// Per-server flag indicating that master DB enumeration has failed with an access-denied - /// error and should not be retried. Used on Azure SQL DB where per-database logins may not - /// have master access (e.g. Microsoft Dynamics 365 FO). See issue #857. + /// Per-server timestamp of the last master-enumeration failure that looked like a permission + /// problem, so database-scoped collectors fall back to the connection's own catalog instead of + /// re-probing master every cycle. Used on Azure SQL DB where per-database logins may not have + /// master access (e.g. Microsoft Dynamics 365 FO). See issue #857. + /// + /// This is a throttle, NOT a permanent verdict. It expires after <see cref="AzureMasterRecheckInterval"/>, + /// and is dropped outright when a server returns from an outage. Both escape hatches exist because + /// the original version latched until the process restarted: a login's rights can be granted after + /// the fact, and — the reason this changed — a momentary failure must never be able to wedge + /// database-scoped collection for the life of the app. See issue #1506. + /// </summary> + private readonly Dictionary<int, DateTime> _azureMasterInaccessibleSince = new(); + private readonly object _azureMasterLock = new(); + + /// <summary> + /// How long a master-inaccessible verdict stands before master is probed again. Short enough that + /// a misjudged failure costs minutes of database-scoped collection rather than the whole session; + /// long enough that a login which genuinely cannot see master (#857) retries only 4x/hour. /// </summary> - private readonly Dictionary<int, bool> _azureMasterInaccessible = new(); - private readonly object _azureMasterInaccessibleLock = new(); + private static readonly TimeSpan AzureMasterRecheckInterval = TimeSpan.FromMinutes(15); + + /// <summary> + /// Servers observed offline and not yet seen online again. A server coming back is the signal to + /// drop cached judgements about it that its outage could have poisoned (#1506). + /// </summary> + private readonly HashSet<int> _serversSeenOffline = new(); public RemoteCollectorService( DuckDbInitializer duckDb, @@ -178,11 +194,6 @@ public RemoteCollectorService( /// </summary> public Task SeedDeltaCacheAsync() => _deltaCalculator.SeedFromDatabaseAsync(_duckDb); - /// <summary> - /// Runs a manual DuckDB WAL checkpoint during idle time between collection cycles. - /// </summary> - public Task CheckpointAsync() => _duckDb.CheckpointAsync(); - /// <summary> /// Gets a summary of collector health for a specific server connection. /// </summary> @@ -275,7 +286,6 @@ internal void RecordCollectorResult(int serverId, string collectorName, string s { entry.LastSuccessTime = DateTime.UtcNow; entry.ConsecutiveErrors = 0; - entry.TotalSuccesses++; } else if (status == "PERMISSIONS") { @@ -284,16 +294,13 @@ internal void RecordCollectorResult(int serverId, string collectorName, string s Record the error message so the user can see what's wrong, and flag the collector so the scheduler stops retrying for the rest of the app session. */ - entry.LastErrorTime = DateTime.UtcNow; entry.LastErrorMessage = errorMessage; entry.IsPermissionRestricted = true; } else { - entry.LastErrorTime = DateTime.UtcNow; entry.LastErrorMessage = errorMessage; entry.ConsecutiveErrors++; - entry.TotalErrors++; } } } @@ -332,9 +339,12 @@ public async Task RunDueCollectorsAsync(CancellationToken cancellationToken = de if (serverStatus.IsOnline == false) { skippedOffline++; - _logger?.LogDebug("Skipping offline server '{Server}'", server.DisplayName); + NoteServerOffline(server); + AppLogger.Debug("Scheduler", $"Skipping offline server '{server.DisplayName}'"); continue; } + + NoteServerOnline(server); onlineServers.Add(server); } @@ -343,8 +353,7 @@ public async Task RunDueCollectorsAsync(CancellationToken cancellationToken = de return; } - _logger?.LogInformation("Checking per-server schedules for {OnlineCount}/{TotalCount} servers ({SkippedCount} offline, skipped)", - onlineServers.Count, enabledServers.Count, skippedOffline); + AppLogger.Info("Scheduler", $"Checking per-server schedules for {onlineServers.Count}/{enabledServers.Count} servers ({skippedOffline} offline, skipped)"); /* Run servers in parallel, but collectors within each server sequentially. DuckDB is single-writer; running all collectors in parallel causes spin-wait @@ -353,6 +362,12 @@ public async Task RunDueCollectorsAsync(CancellationToken cancellationToken = de Each server gets its own due-collector list from per-server schedules. */ var serverTasks = onlineServers.Select(server => Task.Run(async () => { + /* Reconcile the opt-in long-query completion XE session to its enabled flag BEFORE the + due-collector loop and regardless of whether it is due — a disabled collector is never + dispatched, so the DROP-on-disable (#1496) has nowhere else to run. Cheap when nothing + changed (state-tracked); creates on enable, drops on disable. */ + await ReconcileLongQueryCompletionsXeSessionAsync(server, cancellationToken); + var dueCollectors = _scheduleManager.GetDueCollectorsForServer(server.Id); foreach (var collector in dueCollectors) { @@ -376,7 +391,7 @@ CHECKPOINT reorganizes/truncates the database file. */ } catch (Exception ex) { - _logger?.LogDebug(ex, "Post-collection checkpoint failed (non-critical)"); + AppLogger.Debug("Collector", $"Post-collection checkpoint failed (non-critical): {ex.Message}"); } } @@ -397,9 +412,12 @@ public async Task RunAllCollectorsForServerAsync(ServerConnection server, Cancel /* Persist edition/version to DuckDB for the analysis engine */ await PersistServerMetadataAsync(server, serverStatus); - AppLogger.Info("Collector", $"Running {enabledSchedules.Count} collectors for '{server.DisplayName}' (serverId={GetServerId(server)})"); - _logger?.LogInformation("Running {Count} collectors for server '{Server}' (initial load)", - enabledSchedules.Count, server.DisplayName); + AppLogger.Info("Collector", $"Running {enabledSchedules.Count} collectors for '{server.DisplayName}' (serverId={GetServerId(server)}, initial load)"); + + /* Reconcile the opt-in long-query completion XE session (#1496) on tab-open too, so enabling it + takes effect promptly rather than waiting for the next scheduled sweep. Unconditional — the + DROP-on-disable path must run even though a disabled collector is absent from enabledSchedules. */ + await ReconcileLongQueryCompletionsXeSessionAsync(server, cancellationToken); foreach (var schedule in enabledSchedules) { @@ -409,8 +427,7 @@ public async Task RunAllCollectorsForServerAsync(ServerConnection server, Cancel } catch (Exception ex) { - _logger?.LogError(ex, "Initial collector '{Collector}' failed for server '{Server}'", - schedule.Name, server.DisplayName); + AppLogger.Error("Collector", $"Initial collector '{schedule.Name}' failed for server '{server.DisplayName}'", ex); } } } @@ -428,16 +445,26 @@ public async Task RunCollectorAsync(ServerConnection server, string collectorNam try { - // Version-gate and edition-gate collectors + /* Target-gate collectors through the shared AppliesTo — the single authoritative gate surface + both SKUs consult. Darling's collector runner calls definition.AppliesTo(target) directly; + here it drives Lite's clean pre-dispatch SKIPPED log (a genuine skip with no collection_log + row, vs. the SUCCESS/0-rows a gated collector would otherwise record). The gate CONDITION + lives ONLY in each definition's AppliesTo override — never re-encoded in the host — so Lite + and Darling can't drift on it again (the RDS/msdb/Azure gating-drift class). */ var serverStatus = _serverManager.GetConnectionStatus(server.Id); - var majorVersion = serverStatus.SqlMajorVersion; var engineEdition = serverStatus.SqlEngineEdition; - var isAwsRds = serverStatus.IsAwsRds; - var hasMsdbAccess = serverStatus.HasMsdbAccess; + var target = new CollectorTargetInfo + { + IsAzureSqlDb = engineEdition == 5, + IsAzureManagedInstance = engineEdition == 8, + IsAwsRds = serverStatus.IsAwsRds, + SqlMajorVersion = serverStatus.SqlMajorVersion, + HasMsdbAccess = serverStatus.HasMsdbAccess, + }; - if (!IsCollectorSupported(collectorName, majorVersion, engineEdition, isAwsRds, hasMsdbAccess)) + if (!CollectorCatalog.AppliesTo(collectorName, target)) { - AppLogger.Info("Collector", $" [{server.DisplayName}] {collectorName} SKIPPED (version {majorVersion}, edition {engineEdition})"); + AppLogger.Info("Collector", $" [{server.DisplayName}] {collectorName} SKIPPED (edition {engineEdition}, version {serverStatus.SqlMajorVersion})"); return; } @@ -446,8 +473,6 @@ public async Task RunCollectorAsync(ServerConnection server, string collectorNam if (server.AuthenticationType == AuthenticationTypes.EntraMFA && serverStatus.UserCancelledMfa) { AppLogger.Info("Collector", $" [{server.DisplayName}] {collectorName} SKIPPED - MFA authentication cancelled by user"); - _logger?.LogDebug("Skipping collector '{Collector}' for server '{Server}' - user cancelled MFA", - collectorName, server.DisplayName); return; } @@ -455,13 +480,11 @@ public async Task RunCollectorAsync(ServerConnection server, string collectorNam // this session. Flag is in-memory — next app start retries once (see #857). if (IsCollectorPermissionRestricted(GetServerId(server), collectorName)) { - _logger?.LogDebug("Skipping collector '{Collector}' for server '{Server}' - permission denied this session", - collectorName, server.DisplayName); + AppLogger.Debug("Collector", $"Skipping collector '{collectorName}' for server '{server.DisplayName}' - permission denied this session"); return; } - _logger?.LogDebug("Running collector '{Collector}' for server '{Server}'", - collectorName, server.DisplayName); + AppLogger.Debug("Collector", $"Running collector '{collectorName}' for server '{server.DisplayName}'"); /* Ensure the backing XE session exists before reading its ring buffer. Runs on every cycle (cheap existence check when already present) so a @@ -480,6 +503,10 @@ failed first attempt self-heals instead of staying broken until a manual rowsCollected = collectorName switch { "wait_stats" => await CollectWaitStatsAsync(server, cancellationToken), + "latch_stats" => await CollectLatchStatsAsync(server, cancellationToken), + "spinlock_stats" => await CollectSpinlockStatsAsync(server, cancellationToken), + "cpu_scheduler_stats" => await CollectCpuSchedulerStatsAsync(server, cancellationToken), + "plan_cache_stats" => await CollectPlanCacheStatsAsync(server, cancellationToken), "cpu_utilization" => await CollectCpuUtilizationAsync(server, cancellationToken), "memory_stats" => await CollectMemoryStatsAsync(server, cancellationToken), "memory_clerks" => await CollectMemoryClerksAsync(server, cancellationToken), @@ -498,6 +525,7 @@ failed first attempt self-heals instead of staying broken until a manual "waiting_tasks" => await CollectWaitingTasksAsync(server, cancellationToken), "dmv_blocking_snapshot" => await CollectDmvBlockingSnapshotAsync(server, cancellationToken), "blocked_process_report" => await CollectBlockedProcessReportsAsync(server, cancellationToken), + "long_query_completions" => await CollectLongQueryCompletionsAsync(server, cancellationToken), "database_scoped_config" => await CollectDatabaseScopedConfigAsync(server, cancellationToken), "trace_flags" => await CollectTraceFlagsAsync(server, cancellationToken), "running_jobs" => await CollectRunningJobsAsync(server, cancellationToken), @@ -505,6 +533,11 @@ failed first attempt self-heals instead of staying broken until a manual "index_object_stats" => await CollectIndexObjectStatsAsync(server, cancellationToken), "server_properties" => await CollectServerPropertiesAsync(server, cancellationToken), "session_stats" => await CollectSessionStatsAsync(server, cancellationToken), + "session_summary_stats" => await CollectSessionSummaryStatsAsync(server, cancellationToken), + "system_health_events" => await CollectSystemHealthEventsAsync(server, cancellationToken), + "default_trace_events" => await CollectDefaultTraceEventsAsync(server, cancellationToken), + "job_history" => await CollectJobHistoryAsync(server, cancellationToken), + "agent_status" => await CollectAgentStatusAsync(server, cancellationToken), _ => throw new ArgumentException($"Unknown collector: {collectorName}") }; @@ -525,8 +558,6 @@ direct SQL failure so the health indicator stops showing OK (#1086). */ : "ERROR"; xeSessionUnavailable = true; AppLogger.Error("Collector", $" [{server.DisplayName}] {collectorName} {ex.Message}"); - _logger?.LogWarning("Collector '{Collector}' XE session unavailable for server '{Server}': {Message}", - collectorName, server.DisplayName, ex.Message); } catch (SqlException ex) { @@ -536,24 +567,20 @@ direct SQL failure so the health indicator stops showing OK (#1086). */ if (RetryHelper.IsTransient(ex)) { - _logger?.LogWarning("Collector '{Collector}' transient SQL error #{ErrorNumber} for server '{Server}': {Message}", - collectorName, ex.Number, server.DisplayName, ex.Message); + AppLogger.Warn("Collector", $"Collector '{collectorName}' transient SQL error #{ex.Number} for server '{server.DisplayName}': {ex.Message}"); } else if (ex.Number == 207) /* Invalid column name - likely version incompatibility */ { - _logger?.LogWarning("Collector '{Collector}' column not found for server '{Server}' (possible version incompatibility): {Message}", - collectorName, server.DisplayName, ex.Message); + AppLogger.Warn("Collector", $"Collector '{collectorName}' column not found for server '{server.DisplayName}' (possible version incompatibility): {ex.Message}"); } else if (ex.Number == 229 || ex.Number == 297 || ex.Number == 300) { status = "PERMISSIONS"; - _logger?.LogWarning("Collector '{Collector}' permission denied for server '{Server}': {Message}", - collectorName, server.DisplayName, ex.Message); + AppLogger.Warn("Collector", $"Collector '{collectorName}' permission denied for server '{server.DisplayName}': {ex.Message}"); } else { - _logger?.LogError(ex, "Collector '{Collector}' SQL error #{ErrorNumber} for server '{Server}'", - collectorName, ex.Number, server.DisplayName); + AppLogger.Error("Collector", $"Collector '{collectorName}' SQL error #{ex.Number} for server '{server.DisplayName}'", ex); } } catch (InvalidOperationException ex) when (ex.Message.Contains("MFA authentication cancelled")) @@ -567,15 +594,14 @@ direct SQL failure so the health indicator stops showing OK (#1086). */ { status = "CANCELLED"; errorMessage = "Collection cancelled"; - _logger?.LogDebug("Collector '{Collector}' cancelled for server '{Server}'", collectorName, server.DisplayName); + AppLogger.Debug("Collector", $"Collector '{collectorName}' cancelled for server '{server.DisplayName}'"); } catch (Exception ex) { status = "ERROR"; errorMessage = ex.Message; AppLogger.Error("Collector", $" [{server.DisplayName}] {collectorName} {ex.GetType().Name}: {ex.Message}"); - _logger?.LogError(ex, "Collector '{Collector}' failed for server '{Server}'", - collectorName, server.DisplayName); + AppLogger.Error("Collector", $"Collector '{collectorName}' failed for server '{server.DisplayName}'", ex); } // Track collector health @@ -655,19 +681,17 @@ INSERT INTO collection_log (log_id, server_id, server_name, collector_name, coll { AppLogger.Info("Collector", $"Collection logging recovered after {_logInsertFailures} failure(s)"); _logInsertFailures = 0; - _lastLogInsertError = null; } } catch (Exception ex) { _logInsertFailures++; - _lastLogInsertError = ex.Message; if (_logInsertFailures <= 3) { /* First few failures: log at Error level with full detail */ AppLogger.Error("Collector", $"COLLECTION LOGGING FAILED ({_logInsertFailures}x): {ex.GetType().Name}: {ex.Message}"); - _logger?.LogError(ex, "Failed to log collection for {Collector} (failure #{Count})", collectorName, _logInsertFailures); + AppLogger.Error("Collector", $"Failed to log collection for {collectorName} (failure #{_logInsertFailures})", ex); } else if (_logInsertFailures % 100 == 0) { @@ -677,6 +701,48 @@ INSERT INTO collection_log (log_id, server_id, server_name, collector_name, coll } } + /// <summary> + /// Records that a server is currently unreachable, so that its return can be recognised. + /// </summary> + internal void NoteServerOffline(ServerConnection server) + { + lock (_azureMasterLock) + { + _serversSeenOffline.Add(GetServerId(server)); + } + } + + /// <summary> + /// Handles a server coming back after an outage by dropping the master-inaccessible verdict. + /// + /// An outage and a permission problem are not distinguishable from the error number alone — + /// Azure SQL DB reports a firewall rejection (40615) and a login failure (18456) the same way + /// whether the cause is "this login may not read master" or "you cannot reach this server right + /// now". So a verdict formed during an outage is untrustworthy by construction, and the moment + /// the server answers again is the moment to throw it away and re-probe. Without this, a login + /// that CAN read master gets permanently misfiled as one that cannot, and database-scoped + /// collection stays broken until the app restarts (issue #1506). + /// </summary> + internal void NoteServerOnline(ServerConnection server) + { + var serverId = GetServerId(server); + + bool returnedFromOutage; + lock (_azureMasterLock) + { + returnedFromOutage = _serversSeenOffline.Remove(serverId); + if (returnedFromOutage) + { + _azureMasterInaccessibleSince.Remove(serverId); + } + } + + if (returnedFromOutage) + { + AppLogger.Info("Scheduler", $"[{server.DisplayName}] reachable again — re-probing master for database-scoped collectors."); + } + } + /// <summary> /// Enumerates online databases on an Azure SQL DB logical server. /// HAS_DBACCESS() returns false for user databases from master on Azure SQL DB, @@ -685,7 +751,7 @@ INSERT INTO collection_log (log_id, server_id, server_name, collector_name, coll /// On Azure SQL DB, logins are sometimes granted access only to a specific user database and /// not to master (e.g. Microsoft Dynamics 365 FO). In that case, master enumeration fails with /// an access/login error; we fall back to returning the connection's initial catalog as a - /// single-database list, and cache that decision per server so we don't retry master each cycle. + /// single-database list, and throttle re-probes of master so we don't retry it every cycle. /// See issue #857. /// </summary> protected async Task<List<string>> GetAzureDatabaseListAsync(ServerConnection server, CancellationToken cancellationToken) @@ -694,15 +760,15 @@ protected async Task<List<string>> GetAzureDatabaseListAsync(ServerConnection se var baseConnStr = _serverManager.CredentialResolver.GetConnectionString(server); var targetDb = new SqlConnectionStringBuilder(baseConnStr).InitialCatalog; - bool knownInaccessible; - lock (_azureMasterInaccessibleLock) - { - _azureMasterInaccessible.TryGetValue(serverId, out knownInaccessible); - } + /* Skip the throttle when there is nothing to fall back TO. With no target database the fallback + can only throw, and an error lands in collection_log either way — so honouring the throttle + here would buy one saved round-trip at the cost of 15 minutes of guaranteed failure with no + attempt to recover. Probe master instead: it might work now. */ + var hasFallback = SingleDbOrEmpty(targetDb).Count > 0; - if (knownInaccessible) + if (hasFallback && IsMasterProbeThrottled(serverId)) { - return SingleDbOrEmpty(targetDb); + return FallbackDatabaseList(server, targetDb, reason: "master previously inaccessible", quiet: true); } var connStr = new SqlConnectionStringBuilder(baseConnStr) @@ -711,43 +777,134 @@ protected async Task<List<string>> GetAzureDatabaseListAsync(ServerConnection se InitialCatalog = "master" }.ConnectionString; - var (exclusionClause, exclusionParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "name"); - - var databases = new List<string>(); try { - using var conn = new SqlConnection(connStr); - await conn.OpenAsync(cancellationToken); - using var cmd = new SqlCommand( - $"SELECT name FROM sys.databases WHERE state_desc = N'ONLINE' AND database_id > 0 {exclusionClause} ORDER BY name;", - conn) - { CommandTimeout = CommandTimeoutSeconds }; - foreach (var p in exclusionParams) cmd.Parameters.Add(p); - using var reader = await cmd.ExecuteReaderAsync(cancellationToken); - while (await reader.ReadAsync(cancellationToken)) - databases.Add(reader.GetString(0)); - return databases; + /* Retry transient failures so a blip costs a slower cycle instead of an ERROR row — this + path had no retry at all before #1506. Note it cannot prevent a master-inaccessible + verdict: RetryHelper only retries errors SqlErrorClassification calls transient, and that + set is provably disjoint from the ones that form a verdict. The time-box and the + reconnect-clear are what make a wrong verdict survivable; this just makes one less likely + to be reached. + + The exclusion filter is rebuilt inside the lambda on purpose: a SqlParameter cannot be + added to a second SqlCommand, so reusing one set across retries would throw on attempt 2. */ + return await RetryHelper.ExecuteWithRetryAsync( + async () => + { + var (exclusionClause, exclusionParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "name"); + + var databases = new List<string>(); + using var conn = new SqlConnection(connStr); + await conn.OpenAsync(cancellationToken); + using var cmd = new SqlCommand( + $"SELECT name FROM sys.databases WHERE state_desc = N'ONLINE' AND database_id > 0 {exclusionClause} ORDER BY name;", + conn) + { CommandTimeout = CommandTimeoutSeconds }; + foreach (var p in exclusionParams) cmd.Parameters.Add(p); + using var reader = await cmd.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + databases.Add(reader.GetString(0)); + + ClearMasterInaccessible(serverId); + return databases; + }, + _logger, + $"enumerate databases on {server.DisplayName}", + cancellationToken: cancellationToken); } - catch (SqlException ex) when (IsMasterAccessDeniedError(ex)) + catch (SqlException ex) when (IsMasterAccessDeniedError(ex.Number)) { - lock (_azureMasterInaccessibleLock) - { - _azureMasterInaccessible[serverId] = true; - } + MarkMasterInaccessible(serverId); + + return FallbackDatabaseList(server, targetDb, reason: $"master DB inaccessible (SQL error {ex.Number})"); + } + } - var fallback = SingleDbOrEmpty(targetDb); - if (fallback.Count > 0) + /// <summary> + /// True while a recent master-inaccessible verdict still stands. The verdict expires so that a + /// server whose access was restored (or whose login was granted master rights) recovers on its + /// own, instead of collecting from a degraded database list until the app is restarted (#1506). + /// </summary> + internal bool IsMasterProbeThrottled(int serverId) + { + lock (_azureMasterLock) + { + if (!_azureMasterInaccessibleSince.TryGetValue(serverId, out var deniedAt)) { - AppLogger.Info("Collector", $" [{server.DisplayName}] master DB inaccessible (SQL error {ex.Number}) — collecting from '{targetDb}' only."); + return false; } - else + + if (DateTime.UtcNow - deniedAt < AzureMasterRecheckInterval) { - AppLogger.Warn("Collector", $" [{server.DisplayName}] master DB inaccessible (SQL error {ex.Number}) and no target database in connection string — no data will be collected for database-scoped collectors."); + return true; } - return fallback; + + _azureMasterInaccessibleSince.Remove(serverId); + return false; + } + } + + private void ClearMasterInaccessible(int serverId) + { + lock (_azureMasterLock) + { + _azureMasterInaccessibleSince.Remove(serverId); + } + } + + /// <summary> + /// Records a master-inaccessible verdict, stamped now. Used by the production catch path, and by + /// tests to reach the expiry and reconnect logic without a live Azure SQL DB connection — + /// deliberately the same method, so the tests exercise what actually ships. + /// </summary> + internal void MarkMasterInaccessible(int serverId, DateTime? deniedAtUtc = null) + { + lock (_azureMasterLock) + { + _azureMasterInaccessibleSince[serverId] = deniedAtUtc ?? DateTime.UtcNow; } } + /// <summary> + /// The database list to use when master cannot be enumerated: the connection's own catalog. + /// + /// When there isn't one, database-scoped collectors have nowhere to read from. That used to be a + /// warning and an empty list, which made every one of them report SUCCESS with zero rows — the + /// collection status bar kept saying "Running" while nothing at all was being collected. Throwing + /// puts the failure in collection_log where it is visible and actionable (#1506). + /// + /// The message deliberately avoids the phrase RunCollectorAsync's MFA filter matches, so this + /// lands in the general handler and is recorded as an ERROR rather than a silent SKIPPED. + /// </summary> + /// <param name="quiet"> + /// Set on the throttled path, which runs for every database-scoped collector on every cycle. The + /// interesting event is forming the verdict, not re-reading it — logging both would put four lines + /// a minute in the log of a #857 user whose setup is working exactly as intended. + /// </param> + internal static List<string> FallbackDatabaseList(ServerConnection server, string? targetDb, string reason, bool quiet = false) + { + var fallback = SingleDbOrEmpty(targetDb); + + if (fallback.Count == 0) + { + throw new InvalidOperationException( + $"{reason}, and this connection has no target database to fall back to " + + $"(it resolves to master). Set a Database for '{server.DisplayName}' so database-scoped " + + $"collectors have something to read."); + } + + if (quiet) + { + AppLogger.Debug("Collector", $" [{server.DisplayName}] {reason} — collecting from '{targetDb}' only."); + } + else + { + AppLogger.Info("Collector", $" [{server.DisplayName}] {reason} — collecting from '{targetDb}' only."); + } + + return fallback; + } + /// <summary> /// Builds a SQL fragment and matching SqlParameters for excluding the supplied database names. /// When the list is empty, returns ("", []) so callers can splice without effect. @@ -804,26 +961,23 @@ private static List<string> SingleDbOrEmpty(string? targetDb) } /// <summary> - /// Error numbers indicating the login cannot open or read from master on Azure SQL DB. - /// Trigger a fallback to single-database mode when we see one of these. + /// Whether this error means the login cannot read master, in which case database-scoped collectors + /// fall back to the connection's own catalog (#857). The list — and the reason a reachability error + /// must never be on it (#1506) — is owned by <see cref="SqlErrorClassification"/>, shared with + /// Darling so the two cannot drift. /// </summary> - private static bool IsMasterAccessDeniedError(SqlException ex) - { - return ex.Number switch - { - 229 => true, // Permission denied on object - 230 => true, // Permission denied on column - 916 => true, // Server principal is not able to access the database under the current security context - 4060 => true, // Cannot open database requested by the login - 18456 => true, // Login failed for user - 40613 => true, // Database 'master' on server is not currently available - 40615 => true, // Cannot open server — login denied (firewall/auth) - _ => false - }; - } + internal static bool IsMasterAccessDeniedError(int errorNumber) => + SqlErrorClassification.IsMasterAccessDenied(errorNumber); /// <summary> /// Opens a SQL connection to a specific database on an Azure SQL DB logical server. + /// + /// Deliberately NOT retried. This runs once per database per database-scoped collector, and the + /// caller already skips a database it cannot open. Backing off here would stall the whole cycle + /// behind a single unavailable database — an auto-paused serverless database answers 40613, which + /// is transient, so a retry would wait out the backoff on every paused database, every collector, + /// every minute. The next cycle is the retry, and it costs one minute of that database's data + /// rather than delaying every other server's. /// </summary> protected async Task<SqlConnection> OpenAzureDatabaseConnectionAsync(ServerConnection server, string databaseName, CancellationToken cancellationToken) { @@ -835,8 +989,16 @@ protected async Task<SqlConnection> OpenAzureDatabaseConnectionAsync(ServerConne }.ConnectionString; var conn = new SqlConnection(connStr); - await conn.OpenAsync(cancellationToken); - return conn; + try + { + await conn.OpenAsync(cancellationToken); + return conn; + } + catch + { + conn.Dispose(); + throw; + } } /// <summary> @@ -895,8 +1057,7 @@ protected async Task<SqlConnection> CreateConnectionAsync(ServerConnection serve { var serverStatus = _serverManager.GetConnectionStatus(server.Id); serverStatus.UserCancelledMfa = true; - AppLogger.Info("Collector", $" [{server.DisplayName}] MFA authentication cancelled by user"); - _logger?.LogInformation("MFA authentication cancelled by user for server '{DisplayName}' - flagging to abort other pending connections", server.DisplayName); + AppLogger.Info("Collector", $" [{server.DisplayName}] MFA authentication cancelled by user - flagging to abort other pending connections"); } throw; } @@ -918,33 +1079,29 @@ protected async Task<SqlConnection> CreateConnectionAsync(ServerConnection serve } /// <summary> - /// Generates a unique collection ID based on timestamp. + /// Generates a unique collection ID — forwards to the shared generator so both SKUs stamp + /// ids with the same idiom. /// </summary> protected static long GenerateCollectionId() { - return Interlocked.Increment(ref s_idCounter); + return PerformanceMonitor.Collectors.CollectionIdGenerator.Next(); } /// <summary> - /// Gets the server name used for DuckDB storage and hashing. - /// Appends the database name for Azure SQL Database connections so that - /// different databases on the same logical server get distinct server_ids. - /// Appends ":RO" for ReadOnlyIntent connections so they get a - /// different server_id than read-write connections to the same host. + /// Gets the server name used for DuckDB storage and hashing — forwards to the shared + /// <see cref="PerformanceMonitor.Common.ServerIdHelper.BuildStorageName"/> (database-name and + /// :RO suffixing live there) so every SKU derives the same server_id for the same server. /// </summary> internal static string GetServerNameForStorage(ServerConnection server) { - var name = string.IsNullOrWhiteSpace(server.DatabaseName) - ? server.ServerName - : server.ServerName + ":" + server.DatabaseName; - - return server.ReadOnlyIntent ? name + ":RO" : name; + return PerformanceMonitor.Common.ServerIdHelper.BuildStorageName( + server.ServerName, server.DatabaseName, server.ReadOnlyIntent); } /// <summary> /// Gets the numeric server ID from the server connection. /// </summary> - protected static int GetServerId(ServerConnection server) + protected internal static int GetServerId(ServerConnection server) { return GetDeterministicHashCode(GetServerNameForStorage(server)); } @@ -975,96 +1132,100 @@ protected static int GetServerId(ServerConnection server) } /// <summary> - /// Safely converts a SQL Server float/real value to decimal. - /// Returns 0 for Infinity, NaN, or values outside decimal range. + /// The database-scoped twin of <see cref="GetLastCollectedTimeAsync"/>, for definitions that + /// declare a <see cref="PerformanceMonitor.Collectors.ICollectorDefinition{TRow}.PerDatabaseWatermarkColumn"/> + /// (Azure SQL DB per-database XE capture): the newest already-collected value for ONE database, + /// so each database's ring buffer dedups against its own history. Null on first run for that + /// database or on failure — the caller falls back to the definition's documented window. /// </summary> - protected static decimal SafeToDecimal(object value) + protected async Task<DateTime?> GetLastCollectedTimeForDatabaseAsync( + int serverId, string tableName, string columnName, string databaseColumnName, string databaseName, CancellationToken cancellationToken) { try { - if (value is double d) - { - if (double.IsInfinity(d) || double.IsNaN(d)) - return 0m; - } - else if (value is float f) - { - if (float.IsInfinity(f) || float.IsNaN(f)) - return 0m; - } - return Convert.ToDecimal(value); + using var conn = _duckDb.CreateConnection(); + await conn.OpenAsync(cancellationToken); + using var cmd = conn.CreateCommand(); + cmd.CommandText = $"SELECT MAX({columnName}) FROM {tableName} WHERE server_id = $1 AND {databaseColumnName} = $2"; + cmd.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = serverId }); + cmd.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = databaseName }); + var result = await cmd.ExecuteScalarAsync(cancellationToken); + if (result is DateTime dt) + return dt; } - catch (OverflowException) + catch { - return 0m; + /* If DuckDB query fails, caller uses fallback window */ } + return null; } /// <summary> - /// Deterministic hash code for a string. Forwards to the shared - /// <see cref="PerformanceMonitor.Common.ServerIdHelper.GetDeterministicHashCode"/> so Lite, - /// Dashboard, and the MCP paths all derive the same server id from a server name. Kept as a - /// thin internal wrapper to avoid churning the many existing call sites. - /// </summary> - internal static int GetDeterministicHashCode(string value) => - PerformanceMonitor.Common.ServerIdHelper.GetDeterministicHashCode(value); - - /// <summary> - /// Checks if a collector is supported on the given SQL Server version and engine edition. - /// Version 13 = SQL Server 2016, 14 = 2017, 15 = 2019, 16 = 2022, 17 = 2025. - /// Engine edition 5 = Azure SQL DB, 8 = Azure MI. + /// Gets the most recent value of a monotonic bigint identity column from DuckDB for incremental + /// collection — the numeric twin of <see cref="GetLastCollectedTimeAsync"/> (job_history dedups on + /// <c>instance_id</c>, sysjobhistory's IDENTITY bigint). Returns null on first run or if the query + /// fails (caller uses its documented first-run/fallback path). /// </summary> - private static bool IsCollectorSupported(string collectorName, int majorVersion, int engineEdition, bool isAwsRds = false, bool hasMsdbAccess = true) + protected async Task<long?> GetLastCollectedInstanceIdAsync( + int serverId, string tableName, string columnName, CancellationToken cancellationToken) { - bool isAzureSqlDb = engineEdition == 5; - bool isAzureMi = engineEdition == 8; - - /* Version gates — only for on-prem/RDS. - Azure SQL DB reports ProductMajorVersion=12 and Azure MI may report similar values, - but both fully support dm_exec_query_stats, Query Store, etc. */ - if (majorVersion > 0 && !isAzureSqlDb && !isAzureMi) + try { - switch (collectorName) - { - case "query_store": - case "query_stats": - if (majorVersion < 13) return false; - break; - } + using var conn = _duckDb.CreateConnection(); + await conn.OpenAsync(cancellationToken); + using var cmd = conn.CreateCommand(); + cmd.CommandText = $"SELECT MAX({columnName}) FROM {tableName} WHERE server_id = $1"; + cmd.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = serverId }); + var result = await cmd.ExecuteScalarAsync(cancellationToken); + if (result is not null && result != DBNull.Value) + return Convert.ToInt64(result); } - - /* Azure SQL DB edition gates — skip collectors that use unsupported DMVs */ - if (isAzureSqlDb) + catch { - switch (collectorName) - { - case "server_config": /* sys.configurations not available */ - case "trace_flags": /* DBCC TRACESTATUS not available */ - case "running_jobs": /* msdb.dbo.sysjobs not available */ - return false; - } + /* If DuckDB query fails, caller uses fallback window */ } + return null; + } - /* AWS RDS gates — limited msdb permissions (syssessions not accessible) */ - if (isAwsRds) + /// <summary> + /// Whether a prior SUCCESS row exists in collection_log for this collector+server — the "has collected + /// before" signal (see <see cref="PerformanceMonitor.Collectors.CollectorContext.HasCollectedBefore"/>), + /// consulted only when the watermark is null. Returns false on any failure, which errs toward the + /// all-history first run (correct for a genuinely fresh store). + /// </summary> + protected async Task<bool> HasPriorCollectorSuccessAsync(int serverId, string collectorName, CancellationToken cancellationToken) + { + try { - switch (collectorName) - { - case "running_jobs": /* msdb.dbo.syssessions not accessible */ - return false; - } + using var conn = _duckDb.CreateConnection(); + await conn.OpenAsync(cancellationToken); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM collection_log WHERE server_id = $1 AND collector_name = $2 AND status = 'SUCCESS'"; + cmd.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = serverId }); + cmd.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = collectorName }); + var result = await cmd.ExecuteScalarAsync(cancellationToken); + return result is not null && result != DBNull.Value && Convert.ToInt64(result) > 0; } - - /* msdb access gate — login may not have access to msdb on any edition */ - if (!hasMsdbAccess) + catch { - switch (collectorName) - { - case "running_jobs": /* requires msdb.dbo.sysjobs, sysjobactivity, etc. */ - return false; - } + /* Fail toward first-run (all-history) — matches a fresh store with no log yet. */ + return false; } - - return true; } + + /// <summary> + /// Deterministic hash code for a string. Forwards to the shared + /// <see cref="PerformanceMonitor.Common.ServerIdHelper.GetDeterministicHashCode"/> so Lite, + /// Dashboard, and the MCP paths all derive the same server id from a server name. Kept as a + /// thin internal wrapper to avoid churning the many existing call sites. + /// </summary> + internal static int GetDeterministicHashCode(string value) => + PerformanceMonitor.Common.ServerIdHelper.GetDeterministicHashCode(value); + + /* IsCollectorSupported was deleted in the gate-surface collapse: every target gate it re-encoded + (query_stats/query_store version, server_config/trace_flags on Azure SQL DB, and the + running_jobs/job_history/agent_status Azure/RDS/msdb gates) now lives ONLY in each definition's + shared AppliesTo override. RunCollectorAsync consults CollectorCatalog.AppliesTo(name, target) + pre-dispatch for the clean SKIPPED log; Darling's runner calls the same AppliesTo. One gate + surface, compiler-shared — no second layer to drift. */ } diff --git a/Lite/Services/RetryHelper.cs b/Lite/Services/RetryHelper.cs index 54b9033d2..d8f9dec0e 100644 --- a/Lite/Services/RetryHelper.cs +++ b/Lite/Services/RetryHelper.cs @@ -12,6 +12,7 @@ using System.Threading.Tasks; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; +using PerformanceMonitor.Common; namespace PerformanceMonitorLite.Services; @@ -21,27 +22,12 @@ namespace PerformanceMonitorLite.Services; public static class RetryHelper { /// <summary> - /// SQL Server error numbers considered transient and retryable. + /// SQL Server error numbers considered transient and retryable. Owned by the shared + /// <see cref="SqlErrorClassification"/> so Lite and Darling cannot drift on it, and so it stays + /// provably disjoint from the errors treated as permanent verdicts — an error that means both + /// "retry this" and "give up forever" is exactly what caused issue #1506. /// </summary> - private static readonly HashSet<int> TransientErrorNumbers = new() - { - -2, // Timeout - -1, // General network error - 2, // Timeout (alternative) - 53, // Named pipe / network not found - 233, // Connection closed - 10053, // Transport-level error (connection was forcibly closed) - 10054, // Connection reset by peer - 10060, // Connection timed out - 10061, // Connection refused - 40143, // Azure SQL transient - 40197, // Azure SQL transient - service encountered an error - 40501, // Azure SQL - service is currently busy - 40613, // Azure SQL - database not currently available - 49918, // Azure SQL - not enough resources to process request - 49919, // Azure SQL - cannot process create/update request - 49920 // Azure SQL - cannot process request (too many operations) - }; + internal static IReadOnlySet<int> TransientErrorNumbers => SqlErrorClassification.TransientErrorNumbers; /// <summary> /// Default maximum number of retry attempts. diff --git a/Lite/Services/RollingCountAlertGate.cs b/Lite/Services/RollingCountAlertGate.cs deleted file mode 100644 index 2009e9ccb..000000000 --- a/Lite/Services/RollingCountAlertGate.cs +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright (c) 2026 Erik Darling, Darling Data LLC - * - * This file is part of the SQL Server Performance Monitor Lite. - * - * Licensed under the MIT License. See LICENSE file in the project root for full license information. - */ - -namespace PerformanceMonitorLite.Services -{ - /// <summary> - /// Edge-trigger gate for the overview alert engine's rolling-window count alerts — - /// blocking and deadlocks (#1091). - /// - /// Those counts are rolling 1-hour totals: a single blocked-process report or deadlock - /// keeps the count at/above the threshold for the whole hour it lingers in the window. A - /// plain level check (<c>count >= threshold</c>) therefore re-fires the SAME event every - /// cooldown until it ages out — the bug reported in #1091, where two deadlocks produced an - /// alert every five minutes for an hour. - /// - /// This gate is edge-triggered instead. It fires only when the current count has climbed - /// above the watermark recorded at the last fired alert (a genuinely new event). The - /// watermark: - /// <list type="bullet"> - /// <item>decays down to the current count as older events age out of the window, so a later - /// rise is still recognised as new;</item> - /// <item>resets to zero when the count falls below the threshold (the window emptied), so the - /// next event re-alerts; and</item> - /// <item>advances ONLY when an alert actually fires — never while the cooldown or a - /// suppression is gating the notification — so an event that arrives during the cooldown is - /// reported on the next eligible check instead of being silently swallowed.</item> - /// </list> - /// - /// Pure function: no clock, no I/O. The caller resolves <paramref name="cooldownElapsed"/> - /// and <paramref name="suppressed"/> and persists <see cref="Decision.Watermark"/>. - /// </summary> - public static class RollingCountAlertGate - { - /// <param name="Fire">True when the caller should raise the alert this cycle (tray/email).</param> - /// <param name="Active"> - /// True while the count is at/above the threshold. Drives the "Cleared" notification on the - /// active→inactive transition; independent of whether a notification fired this cycle. - /// </param> - /// <param name="Watermark">The watermark to persist for the next evaluation.</param> - public readonly record struct Decision(bool Fire, bool Active, int Watermark); - - /// <summary> - /// Evaluates the edge-trigger gate for one alert check. - /// </summary> - /// <param name="currentCount">Current rolling-window count (raw or database-filtered).</param> - /// <param name="threshold">Alert threshold; the alert is "active" at or above this count.</param> - /// <param name="watermark">Watermark persisted from the previous evaluation (0 if none).</param> - /// <param name="cooldownElapsed">True if the cooldown since the last fired alert has elapsed.</param> - /// <param name="suppressed">True if popups/emails are suppressed for this server (acknowledged/silenced).</param> - public static Decision Evaluate( - int currentCount, - int threshold, - int watermark, - bool cooldownElapsed, - bool suppressed) - { - // Below threshold: the window has emptied (or never filled). Not active; reset the - // watermark so the next event re-alerts from a clean slate. - if (currentCount < threshold) - { - return new Decision(Fire: false, Active: false, Watermark: 0); - } - - // Decay the watermark down to the current count as older events age out of the - // window, so a subsequent rise is still recognised as a new event. - if (watermark > currentCount) - { - watermark = currentCount; - } - - bool hasNew = currentCount > watermark; - bool fire = hasNew && cooldownElapsed && !suppressed; - - // Advance the watermark only when we actually fire; otherwise a new event arriving - // during the cooldown would be consumed when the cooldown later elapses. - if (fire) - { - watermark = currentCount; - } - - return new Decision(Fire: fire, Active: true, Watermark: watermark); - } - } -} diff --git a/Lite/Services/ScheduleManager.cs b/Lite/Services/ScheduleManager.cs index 67f96b29d..5889f8b98 100644 --- a/Lite/Services/ScheduleManager.cs +++ b/Lite/Services/ScheduleManager.cs @@ -29,42 +29,56 @@ public class ScheduleManager DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; - public static readonly string[] PresetNames = ["Low-Impact", "Balanced", "Aggressive"]; - - private static readonly Dictionary<string, Dictionary<string, int>> s_presets = new(StringComparer.OrdinalIgnoreCase) + // Single source of truth for the schedule-editor presets. Exposed (internal) so + // CollectorScheduleEditorWindow reads/applies these instead of holding its own copy — the two + // preset tables previously drifted (the editor's fell ~8 collectors behind). See + // SharedCollectorDefaultsPinTests for the integrity guard. + internal static readonly Dictionary<string, Dictionary<string, int>> s_presets = new(StringComparer.OrdinalIgnoreCase) { ["Aggressive"] = new(StringComparer.OrdinalIgnoreCase) { - ["wait_stats"] = 1, ["query_stats"] = 1, ["procedure_stats"] = 1, + ["wait_stats"] = 1, ["latch_stats"] = 1, ["spinlock_stats"] = 1, + ["cpu_scheduler_stats"] = 1, ["plan_cache_stats"] = 2, + ["query_stats"] = 1, ["procedure_stats"] = 1, ["query_store"] = 2, ["query_snapshots"] = 1, ["cpu_utilization"] = 1, ["file_io_stats"] = 1, ["memory_stats"] = 1, ["memory_clerks"] = 2, ["memory_pressure_events"] = 5, ["tempdb_stats"] = 1, ["perfmon_stats"] = 1, ["deadlocks"] = 1, ["memory_grant_stats"] = 1, ["waiting_tasks"] = 1, ["dmv_blocking_snapshot"] = 1, - ["blocked_process_report"] = 1, ["running_jobs"] = 2 + ["blocked_process_report"] = 1, ["running_jobs"] = 2, + ["session_summary_stats"] = 2, ["system_health_events"] = 2, + ["default_trace_events"] = 2, ["job_history"] = 2, ["agent_status"] = 2 }, ["Balanced"] = new(StringComparer.OrdinalIgnoreCase) { - ["wait_stats"] = 1, ["query_stats"] = 1, ["procedure_stats"] = 1, + ["wait_stats"] = 1, ["latch_stats"] = 1, ["spinlock_stats"] = 1, + ["cpu_scheduler_stats"] = 1, ["plan_cache_stats"] = 5, + ["query_stats"] = 1, ["procedure_stats"] = 1, ["query_store"] = 5, ["query_snapshots"] = 1, ["cpu_utilization"] = 1, ["file_io_stats"] = 1, ["memory_stats"] = 1, ["memory_clerks"] = 5, ["memory_pressure_events"] = 5, ["tempdb_stats"] = 1, ["perfmon_stats"] = 1, ["deadlocks"] = 1, ["memory_grant_stats"] = 1, ["waiting_tasks"] = 1, ["dmv_blocking_snapshot"] = 1, - ["blocked_process_report"] = 1, ["running_jobs"] = 5 + ["blocked_process_report"] = 1, ["running_jobs"] = 5, + ["session_summary_stats"] = 5, ["system_health_events"] = 5, + ["default_trace_events"] = 5, ["job_history"] = 5, ["agent_status"] = 5 }, ["Low-Impact"] = new(StringComparer.OrdinalIgnoreCase) { - ["wait_stats"] = 5, ["query_stats"] = 10, ["procedure_stats"] = 10, + ["wait_stats"] = 5, ["latch_stats"] = 5, ["spinlock_stats"] = 5, + ["cpu_scheduler_stats"] = 5, ["plan_cache_stats"] = 15, + ["query_stats"] = 10, ["procedure_stats"] = 10, ["query_store"] = 30, ["query_snapshots"] = 5, ["cpu_utilization"] = 5, ["file_io_stats"] = 10, ["memory_stats"] = 10, ["memory_clerks"] = 30, ["memory_pressure_events"] = 15, ["tempdb_stats"] = 5, ["perfmon_stats"] = 5, ["deadlocks"] = 5, ["memory_grant_stats"] = 5, ["waiting_tasks"] = 5, ["dmv_blocking_snapshot"] = 5, - ["blocked_process_report"] = 5, ["running_jobs"] = 30 + ["blocked_process_report"] = 5, ["running_jobs"] = 30, + ["session_summary_stats"] = 15, ["system_health_events"] = 15, + ["default_trace_events"] = 15, ["job_history"] = 15, ["agent_status"] = 15 } }; @@ -92,91 +106,9 @@ public ScheduleManager(string configDirectory, ILogger<ScheduleManager>? logger } // ────────────────────────────────────────────────────────────────── - // Existing public API — operates on the default schedule. - // These methods are unchanged from v1 so existing callers keep working. + // Default-schedule public API. // ────────────────────────────────────────────────────────────────── - /// <summary> - /// Gets all configured collector schedules (default schedule). - /// </summary> - public IReadOnlyList<CollectorSchedule> GetAllSchedules() - { - lock (_lock) - { - return _defaultSchedule.ToList(); - } - } - - /// <summary> - /// Gets only enabled and scheduled collectors (default schedule). - /// </summary> - public IReadOnlyList<CollectorSchedule> GetEnabledSchedules() - { - lock (_lock) - { - return _defaultSchedule.Where(s => s.Enabled && s.IsScheduled).ToList(); - } - } - - /// <summary> - /// Gets collectors that are due to run (default schedule, global run state). - /// </summary> - public IReadOnlyList<CollectorSchedule> GetDueCollectors() - { - lock (_lock) - { - return _defaultSchedule.Where(s => s.IsDue).ToList(); - } - } - - /// <summary> - /// Gets on-load only collectors (frequency = 0) from the default schedule. - /// </summary> - public IReadOnlyList<CollectorSchedule> GetOnLoadCollectors() - { - lock (_lock) - { - return _defaultSchedule.Where(s => s.Enabled && !s.IsScheduled).ToList(); - } - } - - /// <summary> - /// Gets a specific collector schedule by name (default schedule). - /// </summary> - public CollectorSchedule? GetSchedule(string collectorName) - { - lock (_lock) - { - return _defaultSchedule.FirstOrDefault(s => - s.Name.Equals(collectorName, StringComparison.OrdinalIgnoreCase)); - } - } - - /// <summary> - /// Marks a collector as having been run (default schedule, global run state). - /// </summary> - public void MarkCollectorRun(string collectorName, DateTime runTime) - { - lock (_lock) - { - var schedule = _defaultSchedule.FirstOrDefault(s => - s.Name.Equals(collectorName, StringComparison.OrdinalIgnoreCase)); - - if (schedule != null) - { - schedule.LastRunTime = runTime; - - if (schedule.IsScheduled) - { - schedule.NextRunTime = runTime.AddMinutes(schedule.FrequencyMinutes); - } - - _logger?.LogDebug("Marked collector '{Name}' as run at {Time}, next run at {NextTime}", - collectorName, runTime, schedule.NextRunTime); - } - } - } - /// <summary> /// Updates a collector's schedule settings (default schedule). /// </summary> @@ -225,26 +157,6 @@ public string GetActivePreset() } } - /// <summary> - /// Applies a named preset to the default schedule. - /// Does not modify enabled/disabled state or on-load (frequency=0) collectors. - /// </summary> - public void ApplyPreset(string presetName) - { - if (!s_presets.TryGetValue(presetName, out var intervals)) - { - throw new ArgumentException($"Unknown preset: {presetName}"); - } - - lock (_lock) - { - ApplyPresetToList(_defaultSchedule, intervals); - SaveSchedules(); - - _logger?.LogInformation("Applied collection preset '{Preset}' to default schedule", presetName); - } - } - // ────────────────────────────────────────────────────────────────── // New per-server API (v2) // ────────────────────────────────────────────────────────────────── @@ -405,40 +317,6 @@ public bool HasServerOverride(string serverId) } } - /// <summary> - /// Applies a preset to a single server's schedule. - /// Creates an override if one doesn't exist (copies default first). - /// </summary> - public void ApplyPresetForServer(string serverId, string presetName) - { - if (!s_presets.TryGetValue(presetName, out var intervals)) - { - throw new ArgumentException($"Unknown preset: {presetName}"); - } - - lock (_lock) - { - if (!_serverOverrides.TryGetValue(serverId, out var over)) - { - over = new ServerScheduleOverride { Collectors = CloneScheduleList(_defaultSchedule) }; - _serverOverrides[serverId] = over; - } - - ApplyPresetToList(over.Collectors, intervals); - SaveSchedules(); - - _logger?.LogInformation("Applied preset '{Preset}' to server {ServerId}", presetName, serverId); - } - } - - /// <summary> - /// Applies a preset to the default schedule (alias for ApplyPreset). - /// </summary> - public void ApplyPresetToDefault(string presetName) - { - ApplyPreset(presetName); - } - /// <summary> /// Detects which preset matches a server's active schedule. /// </summary> @@ -454,31 +332,6 @@ public string GetActivePresetForServer(string serverId) } } - /// <summary> - /// Removes orphaned overrides for servers that no longer exist. - /// </summary> - public void CleanupRemovedServers(IEnumerable<string> activeServerIds) - { - lock (_lock) - { - var activeSet = new HashSet<string>(activeServerIds); - var orphaned = _serverOverrides.Keys.Where(id => !activeSet.Contains(id)).ToList(); - - if (orphaned.Count == 0) - return; - - foreach (var id in orphaned) - { - _serverOverrides.Remove(id); - _serverRunState.Remove(id); - } - - SaveSchedules(); - - _logger?.LogInformation("Cleaned up {Count} orphaned server schedule override(s)", orphaned.Count); - } - } - // ────────────────────────────────────────────────────────────────── // Persistence // ────────────────────────────────────────────────────────────────── @@ -668,9 +521,11 @@ private bool MergeIntoList(List<CollectorSchedule> list, List<CollectorSchedule> // ────────────────────────────────────────────────────────────────── /// <summary> - /// Detects which preset matches a list of collector schedules. + /// Detects which preset matches a list of collector schedules, or "Custom". This is the single + /// source of preset logic; <see cref="ApplyPreset"/> and the collector-schedule editor window both + /// call these static methods rather than keeping a second preset table, so the two cannot drift. /// </summary> - private static string DetectPreset(List<CollectorSchedule> schedules) + internal static string DetectPreset(List<CollectorSchedule> schedules) { foreach (var (presetName, intervals) in s_presets) { @@ -691,10 +546,17 @@ private static string DetectPreset(List<CollectorSchedule> schedules) } /// <summary> - /// Applies preset intervals to a collector list. Does not touch enabled/disabled or on-load collectors. + /// Applies a named preset's intervals to a schedule list in place (frequencies only; enabled and + /// retention are untouched). An unknown preset name is a no-op. Shared with the editor window so the + /// preset table lives exactly once. /// </summary> - private static void ApplyPresetToList(List<CollectorSchedule> schedules, Dictionary<string, int> intervals) + internal static void ApplyPreset(List<CollectorSchedule> schedules, string presetName) { + if (!s_presets.TryGetValue(presetName, out var intervals)) + { + return; + } + foreach (var (collector, freq) in intervals) { var schedule = schedules.FirstOrDefault(s => @@ -730,13 +592,19 @@ private static CollectorSchedule CloneSchedule(CollectorSchedule s) } /// <summary> - /// Gets the default collector schedules. + /// Gets the default collector schedules. Internal so the identity-pin test can assert this + /// table matches the shared <see cref="PerformanceMonitor.Collectors.CollectorScheduleDefaults"/> + /// (which the Darling service schedules by) — the two cannot drift. /// </summary> - private static List<CollectorSchedule> GetDefaultSchedules() + internal static List<CollectorSchedule> GetDefaultSchedules() { return new List<CollectorSchedule> { new() { Name = "wait_stats", Enabled = true, FrequencyMinutes = 1, RetentionDays = 30, Description = "Wait statistics from sys.dm_os_wait_stats" }, + new() { Name = "latch_stats", Enabled = true, FrequencyMinutes = 1, RetentionDays = 30, Description = "Latch statistics from sys.dm_os_latch_stats" }, + new() { Name = "spinlock_stats", Enabled = true, FrequencyMinutes = 1, RetentionDays = 30, Description = "Spinlock statistics from sys.dm_os_spinlock_stats" }, + new() { Name = "cpu_scheduler_stats", Enabled = true, FrequencyMinutes = 1, RetentionDays = 30, Description = "CPU scheduler, workload group, NUMA, and OS memory pressure snapshot (not collected on Azure SQL DB)" }, + new() { Name = "plan_cache_stats", Enabled = true, FrequencyMinutes = 5, RetentionDays = 30, Description = "Plan cache composition (single-use vs multi-use bloat) from sys.dm_exec_cached_plans" }, new() { Name = "query_stats", Enabled = true, FrequencyMinutes = 1, RetentionDays = 30, Description = "Query statistics from sys.dm_exec_query_stats" }, new() { Name = "procedure_stats", Enabled = true, FrequencyMinutes = 1, RetentionDays = 30, Description = "Stored procedure statistics from sys.dm_exec_procedure_stats" }, new() { Name = "query_store", Enabled = true, FrequencyMinutes = 5, RetentionDays = 30, Description = "Query Store data (top 100 queries per database)" }, @@ -746,7 +614,7 @@ private static List<CollectorSchedule> GetDefaultSchedules() new() { Name = "memory_stats", Enabled = true, FrequencyMinutes = 1, RetentionDays = 30, Description = "Memory statistics from sys.dm_os_sys_memory and performance counters" }, new() { Name = "memory_clerks", Enabled = true, FrequencyMinutes = 5, RetentionDays = 30, Description = "Memory clerk allocations from sys.dm_os_memory_clerks" }, new() { Name = "memory_pressure_events", Enabled = true, FrequencyMinutes = 5, RetentionDays = 30, Description = "Memory pressure notifications from RING_BUFFER_RESOURCE_MONITOR" }, - new() { Name = "tempdb_stats", Enabled = true, FrequencyMinutes = 1, RetentionDays = 30, Description = "TempDB space usage from sys.dm_db_file_space_usage" }, + new() { Name = "tempdb_stats", Enabled = true, FrequencyMinutes = 1, RetentionDays = 30, Description = "tempdb space usage from sys.dm_db_file_space_usage" }, new() { Name = "perfmon_stats", Enabled = true, FrequencyMinutes = 1, RetentionDays = 30, Description = "Key performance counters from sys.dm_os_performance_counters" }, new() { Name = "deadlocks", Enabled = true, FrequencyMinutes = 1, RetentionDays = 30, Description = "Deadlocks from a dedicated PerformanceMonitor_Deadlock XE session (xml_deadlock_report)" }, new() { Name = "server_config", Enabled = true, FrequencyMinutes = 0, RetentionDays = 30, Description = "Server configuration (on-load only)" }, @@ -755,13 +623,19 @@ private static List<CollectorSchedule> GetDefaultSchedules() new() { Name = "waiting_tasks", Enabled = true, FrequencyMinutes = 1, RetentionDays = 7, Description = "Point-in-time waiting tasks from sys.dm_os_waiting_tasks" }, new() { Name = "dmv_blocking_snapshot", Enabled = true, FrequencyMinutes = 1, RetentionDays = 30, Description = "Always-on point-in-time blocking snapshot from DMVs (BPR-independent fallback; works when blocked process threshold is unset, e.g. AWS RDS)" }, new() { Name = "blocked_process_report", Enabled = true, FrequencyMinutes = 1, RetentionDays = 30, Description = "Blocked process reports from XE ring buffer session (opt-out)" }, + new() { Name = "long_query_completions", Enabled = false, FrequencyMinutes = 1, RetentionDays = 30, Description = "Long-running query completions (rpc_completed/sql_batch_completed >= threshold, plus attention/cancels) from a dedicated XE ring buffer session. OPT-IN, default OFF: enabling creates the session on monitored servers, disabling drops it (#1496)" }, new() { Name = "database_scoped_config", Enabled = true, FrequencyMinutes = 0, RetentionDays = 30, Description = "Database-scoped configurations (on-load only)" }, new() { Name = "trace_flags", Enabled = true, FrequencyMinutes = 0, RetentionDays = 30, Description = "Active trace flags via DBCC TRACESTATUS (on-load only)" }, new() { Name = "running_jobs", Enabled = true, FrequencyMinutes = 5, RetentionDays = 7, Description = "Currently running SQL Agent jobs with duration comparison" }, new() { Name = "database_size_stats", Enabled = true, FrequencyMinutes = 60, RetentionDays = 90, Description = "Database file sizes for growth trending and capacity planning" }, new() { Name = "index_object_stats", Enabled = true, FrequencyMinutes = 1440, RetentionDays = 90, Description = "Per-object table/index size, usage, and locking stats for growth, unused-index, and contention analysis (daily collection)" }, new() { Name = "server_properties", Enabled = true, FrequencyMinutes = 0, RetentionDays = 365, Description = "Server edition, licensing, CPU/memory hardware metadata (on-load only)" }, - new() { Name = "session_stats", Enabled = true, FrequencyMinutes = 5, RetentionDays = 30, Description = "Per-application session counts from sys.dm_exec_sessions" } + new() { Name = "session_stats", Enabled = true, FrequencyMinutes = 5, RetentionDays = 30, Description = "Per-application session counts from sys.dm_exec_sessions" }, + new() { Name = "session_summary_stats", Enabled = true, FrequencyMinutes = 5, RetentionDays = 30, Description = "Server-wide session summary (idle/leak signal): total/running/sleeping/idle-over-30min counts, memory waits, top app/host from sys.dm_exec_sessions + sys.dm_exec_requests" }, + new() { Name = "system_health_events", Enabled = true, FrequencyMinutes = 5, RetentionDays = 30, Description = "Raw system_health Extended Events (memory broker/OOM, scheduler monitor, sp_server_diagnostics, severe errors, significant waits) captured as XML for health-parser analysis (not collected on Azure SQL DB)" }, + new() { Name = "default_trace_events", Enabled = true, FrequencyMinutes = 5, RetentionDays = 30, Description = "Built-in Default Trace events via sys.fn_trace_gettable: file auto-grow/shrink stalls, severe ErrorLog writes, schema DDL, security audits, and Server Memory Change (not collected on Azure SQL DB)" }, + new() { Name = "job_history", Enabled = true, FrequencyMinutes = 5, RetentionDays = 365, Description = "Retained SQL Agent job-run history from msdb.dbo.sysjobhistory (per-step results, retries, durations, failures) deduped on the instance_id high-water mark; up to a year retained for the Job History tab (not collected on Azure SQL DB)" }, + new() { Name = "agent_status", Enabled = true, FrequencyMinutes = 5, RetentionDays = 7, Description = "SQL Agent service status (Running/Stopped from sys.dm_server_services) and next scheduled run from msdb; drives the Job History tab header and the Agent Not Running alert (not collected on Azure SQL DB)" } }; } diff --git a/Lite/Services/ServerManager.cs b/Lite/Services/ServerManager.cs index 08239b849..74781c0c6 100644 --- a/Lite/Services/ServerManager.cs +++ b/Lite/Services/ServerManager.cs @@ -36,6 +36,33 @@ public class ServerManager /// </summary> private static int ConnectionCheckTimeoutSeconds => App.ConnectionTimeoutSeconds; + /// <summary> + /// Detection query for the connectivity check - engine edition, version, UTC offset, RDS and + /// msdb access, all from scalar functions that need NO special permission. Deliberately carries + /// NO <c>FROM sys.dm_os_sys_info</c>: that DMV requires VIEW DATABASE STATE, which an Azure SQL + /// DB monitoring login often lacks, and coupling edition detection to it made the whole probe + /// throw and silently mis-detect Azure SQL DB (EngineEdition 5) as on-prem (#1535). Must + /// succeed for any connected login. Columns: 0 sql_version, 1 major_version, 2 utc_offset, + /// 3 engine_edition, 4 is_aws_rds, 5 has_msdb_access. + /// </summary> + internal const string DetectionQueryText = @" + SELECT + @@VERSION AS sql_version, + CONVERT(integer, SERVERPROPERTY('ProductMajorVersion')) AS major_version, + DATEDIFF(MINUTE, GETUTCDATE(), GETDATE()) AS utc_offset_minutes, + CONVERT(integer, SERVERPROPERTY('EngineEdition')) AS engine_edition, + CASE WHEN DB_ID('rdsadmin') IS NOT NULL THEN 1 ELSE 0 END AS is_aws_rds, + HAS_DBACCESS(N'msdb') AS has_msdb_access"; + + /// <summary> + /// Best-effort start-time read - <c>sqlserver_start_time</c> is the ONLY probe fact that + /// genuinely needs <c>sys.dm_os_sys_info</c> (VIEW SERVER/DATABASE STATE). Run in its OWN + /// try/catch so a permission failure leaves ServerStartTime unset without flipping IsOnline or + /// failing platform detection. + /// </summary> + internal const string ServerStartTimeQueryText = + "SELECT sqlserver_start_time FROM sys.dm_os_sys_info"; + public ServerManager(string configDirectory, ILogger<ServerManager>? logger = null) { _configFilePath = Path.Combine(configDirectory, "servers.json"); @@ -226,6 +253,29 @@ public void UpdateServer(ServerConnection server, string? username = null, strin _logger?.LogInformation("Updated server '{DisplayName}' ({ServerName})", server.DisplayName, server.ServerName); } + /// <summary> + /// Persists an in-place change to an existing server's NON-credential settings (e.g. the #1319 + /// per-server view database filter) by re-serializing servers.json. Unlike <see cref="UpdateServer"/> + /// this NEVER touches Windows Credential Manager, so it is safe to call for settings unrelated to + /// auth (calling UpdateServer with no username/password would delete an EntraMFA server's stored + /// username). The passed instance is normally the same reference already in the list, mutated in place. + /// </summary> + public void UpdateServerSettings(ServerConnection server) + { + lock (_serversLock) + { + var existing = _servers.FirstOrDefault(s => s.Id == server.Id); + if (existing == null) + { + return; + } + + var index = _servers.IndexOf(existing); + _servers[index] = server; + SaveServers(); + } + } + /// <summary> /// Deletes a server by its ID. /// </summary> @@ -391,51 +441,60 @@ public async Task<ServerConnectionStatus> CheckConnectionAsync(string serverId, status.ErrorMessage = null; status.UserCancelledMfa = false; // Clear cancellation flag on successful connection - // Query server metadata (version, start time, UTC offset). - // Wrapped in its own try/catch: a permissions failure on sys.dm_os_sys_info - // must NOT flip IsOnline back to false — the login itself worked. + // Detection query: platform/version/UTC-offset/RDS/msdb facts from scalar functions + // that need NO permission (no sys.dm_os_sys_info), so an Azure SQL DB login lacking + // VIEW DATABASE STATE still classifies correctly instead of mis-detecting as on-prem + // (#1535). In its own try/catch: a failure must NOT flip IsOnline back to false. try { - using var command = new SqlCommand(@" - SELECT - sqlserver_start_time, - @@VERSION AS sql_version, - CONVERT(integer, SERVERPROPERTY('ProductMajorVersion')) AS major_version, - DATEDIFF(MINUTE, GETUTCDATE(), GETDATE()) AS utc_offset_minutes, - CONVERT(integer, SERVERPROPERTY('EngineEdition')) AS engine_edition, - CASE WHEN DB_ID('rdsadmin') IS NOT NULL THEN 1 ELSE 0 END AS is_aws_rds, - HAS_DBACCESS(N'msdb') AS has_msdb_access - FROM sys.dm_os_sys_info", connection); + using var command = new SqlCommand(DetectionQueryText, connection); command.CommandTimeout = ConnectionCheckTimeoutSeconds; using var reader = await command.ExecuteReaderAsync(); if (await reader.ReadAsync()) { if (!reader.IsDBNull(0)) - status.ServerStartTime = reader.GetDateTime(0); + status.SqlServerVersion = reader.GetString(0); if (!reader.IsDBNull(1)) - status.SqlServerVersion = reader.GetString(1); + status.SqlMajorVersion = Convert.ToInt32(reader.GetValue(1)); if (!reader.IsDBNull(2)) - status.SqlMajorVersion = Convert.ToInt32(reader.GetValue(2)); + status.UtcOffsetMinutes = Convert.ToInt32(reader.GetValue(2)); if (!reader.IsDBNull(3)) - status.UtcOffsetMinutes = Convert.ToInt32(reader.GetValue(3)); + status.SqlEngineEdition = Convert.ToInt32(reader.GetValue(3)); if (!reader.IsDBNull(4)) - status.SqlEngineEdition = Convert.ToInt32(reader.GetValue(4)); + status.IsAwsRds = Convert.ToInt32(reader.GetValue(4)) == 1; if (!reader.IsDBNull(5)) - status.IsAwsRds = Convert.ToInt32(reader.GetValue(5)) == 1; - if (!reader.IsDBNull(6)) - status.HasMsdbAccess = Convert.ToInt32(reader.GetValue(6)) == 1; + status.HasMsdbAccess = Convert.ToInt32(reader.GetValue(5)) == 1; } } catch (SqlException metaEx) { - // Metadata query failed (e.g. no VIEW SERVER STATE permission) but the - // server IS reachable — keep IsOnline = true, just record the warning. + // Detection query failed (unexpected - it needs no special permission) but the + // server IS reachable - keep IsOnline = true, just record the warning. status.ErrorMessage = $"Connected, but metadata query failed: {metaEx.Message}"; _logger?.LogWarning("Metadata query failed for server '{DisplayName}' (server is still online): {Message}", server.DisplayName, metaEx.Message); } + // Best-effort start-time read in its OWN try/catch: sqlserver_start_time is the only + // probe fact that genuinely needs sys.dm_os_sys_info (VIEW SERVER/DATABASE STATE), so a + // permission gap here leaves ServerStartTime unset without flipping IsOnline or failing + // the detection above (#1535). + try + { + using var startCommand = new SqlCommand(ServerStartTimeQueryText, connection); + startCommand.CommandTimeout = ConnectionCheckTimeoutSeconds; + + using var startReader = await startCommand.ExecuteReaderAsync(); + if (await startReader.ReadAsync() && !startReader.IsDBNull(0)) + status.ServerStartTime = startReader.GetDateTime(0); + } + catch (SqlException startEx) + { + _logger?.LogDebug("Start-time query unavailable for server '{DisplayName}' (server is still online): {Message}", + server.DisplayName, startEx.Message); + } + _logger?.LogDebug("Connectivity check passed for server '{DisplayName}'", server.DisplayName); } catch (SqlException ex) @@ -525,10 +584,6 @@ private void LoadServers() try { File.Copy(_configFilePath, _configFilePath + ".bak", overwrite: true); } catch { /* best effort */ } - // MIGRATION: Backward compatibility for existing servers.json files - // Migration from old UseWindowsAuth property happens automatically during deserialization - MigrateServerAuthentication(_servers); - // Initialize status tracking for all loaded servers foreach (var server in _servers) { @@ -551,9 +606,6 @@ private void LoadServers() var bakConfig = JsonSerializer.Deserialize<ServersConfig>(bakJson); _servers = bakConfig?.Servers ?? new List<ServerConnection>(); - // MIGRATION: Backward compatibility - MigrateServerAuthentication(_servers); - foreach (var server in _servers) { _connectionStatuses[server.Id] = new ServerConnectionStatus { ServerId = server.Id }; @@ -644,22 +696,6 @@ private void SaveServers() } } - /// <summary> - /// Migrates server authentication configuration for backward compatibility. - /// Note: Migration from old UseWindowsAuth property happens automatically via - /// the UseWindowsAuth setter during JSON deserialization. - /// </summary> - private void MigrateServerAuthentication(List<ServerConnection> servers) - { - // Migration is now automatic via UseWindowsAuth property setter - // Just log the loaded servers for debugging - foreach (var server in servers) - { - _logger?.LogDebug("Server '{DisplayName}' loaded with AuthenticationType={AuthType}", - server.DisplayName, server.AuthenticationType); - } - } - /// <summary> /// JSON wrapper for servers list. /// </summary> diff --git a/Lite/Services/ServerTimeHelper.cs b/Lite/Services/ServerTimeHelper.cs index 6a4417e40..a94753851 100644 --- a/Lite/Services/ServerTimeHelper.cs +++ b/Lite/Services/ServerTimeHelper.cs @@ -32,7 +32,7 @@ public static int UtcOffsetMinutes /// Converts a local DateTime (from date picker) to server time. /// Use when the user picks dates in their local timezone but the database stores server time. /// </summary> - public static DateTime LocalToServerTime(DateTime localTime) + private static DateTime LocalToServerTime(DateTime localTime) { var utcTime = localTime.ToUniversalTime(); return utcTime.AddMinutes(_utcOffsetMinutes); @@ -42,7 +42,7 @@ public static DateTime LocalToServerTime(DateTime localTime) /// Converts a server DateTime to local time. /// Use this when displaying server timestamps to the user in the UI. /// </summary> - public static DateTime ToLocalTime(DateTime serverTime) + private static DateTime ToLocalTime(DateTime serverTime) { /* Convert server time to UTC, then to local */ var utcTime = serverTime.AddMinutes(-_utcOffsetMinutes); diff --git a/Lite/Themes/CoolBreezeTheme.xaml b/Lite/Themes/CoolBreezeTheme.xaml index 1d91854f6..f14cdf8c2 100644 --- a/Lite/Themes/CoolBreezeTheme.xaml +++ b/Lite/Themes/CoolBreezeTheme.xaml @@ -23,7 +23,10 @@ <!-- Text Colors (dark on light backgrounds) --> <Color x:Key="ForegroundColor">#1A2A3A</Color> <Color x:Key="ForegroundDimColor">#1A2A3A</Color> - <Color x:Key="ForegroundMutedColor">#5B7A90</Color> + <!-- Muted (tertiary) text. #1441: the old #5B7A90 was only 2.87:1 on the light-blue wells and + failed WCAG AA. #4A5D72 is ~6.5:1 on #EEF4FA — a readable secondary tone that keeps the + cool blue character while reading quieter than primary, mirroring the Dark muted tier. --> + <Color x:Key="ForegroundMutedColor">#4A5D72</Color> <!-- Border Colors (subtle, low-contrast) --> <Color x:Key="BorderColor">#A8BDD0</Color> @@ -60,7 +63,9 @@ <SolidColorBrush x:Key="ForegroundBrush" Color="{StaticResource ForegroundColor}"/> <SolidColorBrush x:Key="ForegroundDimBrush" Color="{StaticResource ForegroundDimColor}"/> - <SolidColorBrush x:Key="ForegroundMutedBrush" Color="{StaticResource ForegroundColor}"/> + <!-- #1441: use the (now readable) muted color so CoolBreeze has a real 3-tier text hierarchy + like Dark, instead of collapsing muted onto the primary foreground. --> + <SolidColorBrush x:Key="ForegroundMutedBrush" Color="{StaticResource ForegroundMutedColor}"/> <SolidColorBrush x:Key="BorderBrush" Color="{StaticResource BorderColor}"/> <SolidColorBrush x:Key="BorderLightBrush" Color="{StaticResource BorderLightColor}"/> @@ -645,6 +650,7 @@ <!-- ============================================ --> <Style TargetType="DataGrid"> <Setter Property="helpers:DataGridClipboardBehavior.FixHeaderCopy" Value="True"/> + <Setter Property="helpers:DataGridSortBehavior.SortDescendingFirst" Value="True"/> <Setter Property="helpers:ScrollPanBehavior.EnableMiddleClickPanning" Value="True"/> <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> @@ -1276,6 +1282,18 @@ </Style> + <!-- Time Range Slicer (cool-breeze values; #1441 completeness — these 10 keys existed only in Dark) --> + <SolidColorBrush x:Key="SlicerBackgroundBrush" Color="{StaticResource BackgroundDarkColor}"/> + <SolidColorBrush x:Key="SlicerChartLineBrush" Color="{StaticResource AccentColor}"/> + <SolidColorBrush x:Key="SlicerChartFillBrush" Color="#331E6FA8"/> + <SolidColorBrush x:Key="SlicerOverlayBrush" Color="#40000000"/> + <SolidColorBrush x:Key="SlicerSelectedBrush" Color="#221E6FA8"/> + <SolidColorBrush x:Key="SlicerHandleBrush" Color="{StaticResource ForegroundColor}"/> + <SolidColorBrush x:Key="SlicerHandleHoverBrush" Color="{StaticResource AccentColor}"/> + <SolidColorBrush x:Key="SlicerBorderBrush" Color="{StaticResource BorderColor}"/> + <SolidColorBrush x:Key="SlicerLabelBrush" Color="{StaticResource ForegroundColor}"/> + <SolidColorBrush x:Key="SlicerToggleBrush" Color="{StaticResource ForegroundColor}"/> + <!-- ============================================ --> <!-- Plan Viewer Resources --> <!-- ============================================ --> diff --git a/Lite/Themes/DarkTheme.xaml b/Lite/Themes/DarkTheme.xaml index 6c3833bdf..782327db4 100644 --- a/Lite/Themes/DarkTheme.xaml +++ b/Lite/Themes/DarkTheme.xaml @@ -20,10 +20,14 @@ <Color x:Key="BackgroundLightColor">#22252b</Color> <Color x:Key="BackgroundLighterColor">#2a2d35</Color> - <!-- Text Colors (off-white to reduce eye strain) --> + <!-- Text Colors: 3-tier readable hierarchy, all >= 4.5:1 on the dark backgrounds + (Foreground ~11-14:1, Dim ~8.5:1, Muted ~6:1). Foreground = primary values/headers; + Dim = secondary captions/labels (dimmed a step off primary); Muted = the quietest tier + for empty-state/placeholder text. Dim stays brighter than Muted. NOTE: no truly-dim tone + here on purpose; every tier must remain comfortably readable. --> <Color x:Key="ForegroundColor">#E4E6EB</Color> - <Color x:Key="ForegroundDimColor">#E4E6EB</Color> - <Color x:Key="ForegroundMutedColor">#6B7280</Color> + <Color x:Key="ForegroundDimColor">#C7CBD4</Color> + <Color x:Key="ForegroundMutedColor">#A8AEBA</Color> <!-- Border Colors (subtle, low-contrast) --> <Color x:Key="BorderColor">#2a2d35</Color> @@ -60,7 +64,7 @@ <SolidColorBrush x:Key="ForegroundBrush" Color="{StaticResource ForegroundColor}"/> <SolidColorBrush x:Key="ForegroundDimBrush" Color="{StaticResource ForegroundDimColor}"/> - <SolidColorBrush x:Key="ForegroundMutedBrush" Color="{StaticResource ForegroundColor}"/> + <SolidColorBrush x:Key="ForegroundMutedBrush" Color="{StaticResource ForegroundMutedColor}"/> <SolidColorBrush x:Key="BorderBrush" Color="{StaticResource BorderColor}"/> <SolidColorBrush x:Key="BorderLightBrush" Color="{StaticResource BorderLightColor}"/> @@ -645,6 +649,7 @@ <!-- ============================================ --> <Style TargetType="DataGrid"> <Setter Property="helpers:DataGridClipboardBehavior.FixHeaderCopy" Value="True"/> + <Setter Property="helpers:DataGridSortBehavior.SortDescendingFirst" Value="True"/> <Setter Property="helpers:ScrollPanBehavior.EnableMiddleClickPanning" Value="True"/> <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> diff --git a/Lite/Themes/LightTheme.xaml b/Lite/Themes/LightTheme.xaml index 63f9dfe6f..d2f058a86 100644 --- a/Lite/Themes/LightTheme.xaml +++ b/Lite/Themes/LightTheme.xaml @@ -23,7 +23,10 @@ <!-- Text Colors (dark on light backgrounds) --> <Color x:Key="ForegroundColor">#1A1D23</Color> <Color x:Key="ForegroundDimColor">#1A1D23</Color> - <Color x:Key="ForegroundMutedColor">#718096</Color> + <!-- Muted (tertiary) text. #1441: the old #718096 was only 3.38:1 on the light wells and + failed WCAG AA. #5A6472 is ~6:1 on #FFFFFF / ~5.6:1 on #F5F7FA — a readable secondary + tone that still reads quieter than primary, mirroring the Dark muted tier. --> + <Color x:Key="ForegroundMutedColor">#5A6472</Color> <!-- Border Colors (subtle, low-contrast) --> <Color x:Key="BorderColor">#DEE2E6</Color> @@ -60,7 +63,9 @@ <SolidColorBrush x:Key="ForegroundBrush" Color="{StaticResource ForegroundColor}"/> <SolidColorBrush x:Key="ForegroundDimBrush" Color="{StaticResource ForegroundDimColor}"/> - <SolidColorBrush x:Key="ForegroundMutedBrush" Color="{StaticResource ForegroundColor}"/> + <!-- #1441: use the (now readable) muted color so Light has a real 3-tier text hierarchy + like Dark, instead of collapsing muted onto the primary foreground. --> + <SolidColorBrush x:Key="ForegroundMutedBrush" Color="{StaticResource ForegroundMutedColor}"/> <SolidColorBrush x:Key="BorderBrush" Color="{StaticResource BorderColor}"/> <SolidColorBrush x:Key="BorderLightBrush" Color="{StaticResource BorderLightColor}"/> @@ -645,6 +650,7 @@ <!-- ============================================ --> <Style TargetType="DataGrid"> <Setter Property="helpers:DataGridClipboardBehavior.FixHeaderCopy" Value="True"/> + <Setter Property="helpers:DataGridSortBehavior.SortDescendingFirst" Value="True"/> <Setter Property="helpers:ScrollPanBehavior.EnableMiddleClickPanning" Value="True"/> <Setter Property="Background" Value="{StaticResource BackgroundDarkBrush}"/> <Setter Property="Foreground" Value="{StaticResource ForegroundBrush}"/> @@ -1276,6 +1282,18 @@ </Style> + <!-- Time Range Slicer (light values; #1441 completeness — these 10 keys existed only in Dark) --> + <SolidColorBrush x:Key="SlicerBackgroundBrush" Color="{StaticResource BackgroundDarkColor}"/> + <SolidColorBrush x:Key="SlicerChartLineBrush" Color="{StaticResource AccentColor}"/> + <SolidColorBrush x:Key="SlicerChartFillBrush" Color="#332EAEF1"/> + <SolidColorBrush x:Key="SlicerOverlayBrush" Color="#40000000"/> + <SolidColorBrush x:Key="SlicerSelectedBrush" Color="#222EAEF1"/> + <SolidColorBrush x:Key="SlicerHandleBrush" Color="{StaticResource ForegroundColor}"/> + <SolidColorBrush x:Key="SlicerHandleHoverBrush" Color="{StaticResource AccentColor}"/> + <SolidColorBrush x:Key="SlicerBorderBrush" Color="{StaticResource BorderColor}"/> + <SolidColorBrush x:Key="SlicerLabelBrush" Color="{StaticResource ForegroundColor}"/> + <SolidColorBrush x:Key="SlicerToggleBrush" Color="{StaticResource ForegroundColor}"/> + <!-- ============================================ --> <!-- Plan Viewer Resources --> <!-- ============================================ --> diff --git a/Lite/Windows/AboutWindow.xaml b/Lite/Windows/AboutWindow.xaml index d8b77ebf0..c50bb3fe5 100644 --- a/Lite/Windows/AboutWindow.xaml +++ b/Lite/Windows/AboutWindow.xaml @@ -37,12 +37,12 @@ <!-- Links --> <StackPanel Grid.Row="4"> <TextBlock FontSize="12" Margin="0,0,0,5"> - <Hyperlink x:Name="GitHubLink" Click="GitHubLink_Click"> + <Hyperlink Click="GitHubLink_Click"> GitHub Repository </Hyperlink> </TextBlock> <TextBlock FontSize="12" Margin="0,0,0,5"> - <Hyperlink x:Name="ReportIssueLink" Click="ReportIssueLink_Click"> + <Hyperlink Click="ReportIssueLink_Click"> Report an Issue </Hyperlink> </TextBlock> @@ -52,7 +52,7 @@ Foreground="{DynamicResource ForegroundMutedBrush}" Visibility="Collapsed"/> </StackPanel> <TextBlock FontSize="12"> - <Hyperlink x:Name="DarlingDataLink" Click="DarlingDataLink_Click"> + <Hyperlink Click="DarlingDataLink_Click"> www.erikdarling.com </Hyperlink> </TextBlock> diff --git a/Lite/Windows/AboutWindow.xaml.cs b/Lite/Windows/AboutWindow.xaml.cs index 793354df2..d8e0b27fb 100644 --- a/Lite/Windows/AboutWindow.xaml.cs +++ b/Lite/Windows/AboutWindow.xaml.cs @@ -20,7 +20,6 @@ public partial class AboutWindow : Window { private const string GitHubUrl = "https://github.com/erikdarlingdata/PerformanceMonitor"; private const string IssuesUrl = "https://github.com/erikdarlingdata/PerformanceMonitor/issues"; - private const string ReleasesUrl = "https://github.com/erikdarlingdata/PerformanceMonitor/releases"; private const string DarlingDataUrl = "https://www.erikdarling.com"; private string? _updateReleaseUrl; diff --git a/Lite/Windows/AddMultipleServersDialog.xaml b/Lite/Windows/AddMultipleServersDialog.xaml new file mode 100644 index 000000000..af7355ca1 --- /dev/null +++ b/Lite/Windows/AddMultipleServersDialog.xaml @@ -0,0 +1,150 @@ +<Window x:Class="PerformanceMonitorLite.Windows.AddMultipleServersDialog" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + Title="Add Multiple Servers" + Height="760" Width="640" MinHeight="560" MinWidth="520" + WindowStartupLocation="CenterOwner" + ResizeMode="CanResizeWithGrip" + Icon="/EDD.ico" + Background="{DynamicResource BackgroundBrush}"> + <Grid Margin="16"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + + <!-- Header --> + <TextBlock Grid.Row="0" Text="Add Multiple Servers" FontSize="18" FontWeight="Bold" Margin="0,0,0,6" + Foreground="{DynamicResource ForegroundBrush}"/> + + <!-- Format hint --> + <TextBlock Grid.Row="1" TextWrapping="Wrap" FontSize="11" Margin="0,0,0,10" + Foreground="{DynamicResource ForegroundMutedBrush}" + Text="Paste one server per line as: server[, display name][, database]. Fields separate on a comma, or on a tab for spreadsheet pastes (a display name with a comma needs the tab form). Blank lines and lines starting with # are ignored. All servers share the authentication and encryption settings below — SQL / service-principal credentials are entered once as a single shared credential profile. Duplicates of servers you already monitor are skipped."/> + + <!-- Paste box --> + <TextBlock Grid.Row="2" Text="Servers" FontWeight="Bold" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <TextBox Grid.Row="3" x:Name="PasteBox" Height="130" Margin="0,0,0,10" + AcceptsReturn="True" AcceptsTab="False" TextWrapping="NoWrap" + FontFamily="Consolas" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto" + TextChanged="PasteBox_TextChanged"/> + + <!-- Shared settings block --> + <StackPanel Grid.Row="4" Margin="0,0,0,10"> + <TextBlock Text="Shared authentication (applied to every server)" FontWeight="Bold" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + + <!-- Credential source: inline (shared) vs a shared credential profile --> + <RadioButton x:Name="ConfigureCredsRadio" Content="Configure credentials for all servers" + Foreground="{DynamicResource ForegroundBrush}" IsChecked="True" + Checked="CredentialSource_Changed" Margin="0,0,0,4"/> + <RadioButton x:Name="UseProfileRadio" Content="Use a credential profile" + Foreground="{DynamicResource ForegroundBrush}" + Checked="CredentialSource_Changed" Margin="0,0,0,4" + ToolTip="Use a named, shared credential (set up under Manage Servers → Credential Profiles). The profile supplies the auth type and credentials for every server."/> + + <!-- Profile picker (shown when 'Use a credential profile' is selected) --> + <StackPanel x:Name="ProfilePickerPanel" Visibility="Collapsed" Margin="0,4,0,0"> + <TextBlock Text="Credential Profile" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <ComboBox x:Name="ProfileComboBox" DisplayMemberPath="PickerDisplay"/> + <TextBlock x:Name="NoProfilesNote" TextWrapping="Wrap" FontSize="11" Visibility="Collapsed" + Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,4,0,0" + Text="No credential profiles exist yet. Create one under Manage Servers → Credential Profiles."/> + </StackPanel> + + <!-- Inline shared auth radios (hidden when a profile is chosen). NO Entra MFA — an interactive + popup per server is a popup storm; single-add remains the MFA path. --> + <StackPanel x:Name="InlineAuthRadios" Margin="0,4,0,0"> + <RadioButton x:Name="WindowsAuthRadio" Content="Windows Authentication" Foreground="{DynamicResource ForegroundBrush}" + IsChecked="True" Checked="AuthMode_Changed" Margin="0,0,0,4"/> + <RadioButton x:Name="SqlAuthRadio" Content="SQL Server Authentication" Foreground="{DynamicResource ForegroundBrush}" + Checked="AuthMode_Changed" Margin="0,0,0,4"/> + <RadioButton x:Name="ServicePrincipalAuthRadio" Content="Azure — Service Principal" Foreground="{DynamicResource ForegroundBrush}" + Checked="AuthMode_Changed" Margin="0,0,0,4" + ToolTip="Non-interactive Azure SQL auth using an Entra app registration (client id + secret)."/> + <RadioButton x:Name="ManagedIdentityAuthRadio" Content="Azure — Managed Identity" Foreground="{DynamicResource ForegroundBrush}" + Checked="AuthMode_Changed" + ToolTip="Non-interactive Azure SQL auth using the managed identity of the Azure resource running Lite."/> + </StackPanel> + + <!-- SQL Auth Credentials (entered ONCE) --> + <StackPanel x:Name="SqlCredentialsPanel" Visibility="Collapsed" Margin="0,8,0,0"> + <TextBlock Text="Username" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <TextBox x:Name="UsernameBox" Margin="0,0,0,8" TextChanged="Credential_TextChanged"/> + <TextBlock Text="Password" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <PasswordBox x:Name="PasswordBox"/> + </StackPanel> + + <!-- Service Principal Credentials (entered ONCE) --> + <StackPanel x:Name="ServicePrincipalPanel" Visibility="Collapsed" Margin="0,8,0,0"> + <TextBlock Text="Client (Application) ID" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <TextBox x:Name="AzureClientIdBox" Margin="0,0,0,8" TextChanged="Credential_TextChanged" + ToolTip="The Application (client) ID of the Entra app registration / service principal."/> + <TextBlock Text="Client Secret" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <PasswordBox x:Name="AzureClientSecretBox" Margin="0,0,0,8"/> + <TextBlock Text="Tenant ID (optional)" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <TextBox x:Name="AzureTenantIdBox" + ToolTip="Optional. Stored for reference; the tenant is resolved automatically from the target server."/> + </StackPanel> + + <!-- Managed Identity Credentials --> + <StackPanel x:Name="ManagedIdentityPanel" Visibility="Collapsed" Margin="0,8,0,0"> + <TextBlock Text="User-Assigned Identity Client ID (optional)" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <TextBox x:Name="ManagedIdentityClientIdBox" + ToolTip="Leave blank to use the system-assigned managed identity. Set to a client id to use a specific user-assigned identity."/> + </StackPanel> + + <!-- Auto-created profile name (shown for SQL / Service Principal — the one profile all rows share) --> + <StackPanel x:Name="ProfileNamePanel" Visibility="Collapsed" Margin="0,8,0,0"> + <TextBlock Text="Credential profile name" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> + <TextBox x:Name="ProfileNameBox" TextChanged="ProfileNameBox_TextChanged" + ToolTip="A single credential profile is created with this name; every server added here references it, so the secret is stored once."/> + </StackPanel> + + <!-- Encryption --> + <StackPanel Orientation="Horizontal" Margin="0,10,0,0"> + <TextBlock Text="Encryption:" Foreground="{DynamicResource ForegroundBrush}" VerticalAlignment="Center" Width="80"/> + <ComboBox x:Name="EncryptModeComboBox" Width="150" SelectedIndex="1"> + <ComboBoxItem Content="Optional"/> + <ComboBoxItem Content="Mandatory"/> + <ComboBoxItem Content="Strict"/> + </ComboBox> + </StackPanel> + <CheckBox x:Name="TrustCertCheckBox" Content="Trust server certificate (skip certificate validation)" + Foreground="{DynamicResource ForegroundBrush}" IsChecked="False" Margin="0,6,0,0"/> + </StackPanel> + + <!-- Preview / results grid. NOTE: a plain read-only DataGrid — the ServerTab capability pin forbids + DataGridFilterManager in this dialog. --> + <DataGrid Grid.Row="5" x:Name="PreviewGrid" AutoGenerateColumns="False" IsReadOnly="True" + CanUserAddRows="False" HeadersVisibility="Column" SelectionMode="Single" Margin="0,0,0,8"> + <DataGrid.Columns> + <DataGridTextColumn Header="Line" Binding="{Binding LineNumber}" Width="50"/> + <DataGridTextColumn Header="Server" Binding="{Binding Server}" Width="*"/> + <DataGridTextColumn Header="Display" Binding="{Binding Display}" Width="*"/> + <DataGridTextColumn Header="Database" Binding="{Binding Database}" Width="*"/> + <DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="2*"/> + </DataGrid.Columns> + </DataGrid> + + <!-- Status --> + <TextBlock Grid.Row="6" x:Name="StatusText" Foreground="{DynamicResource ForegroundMutedBrush}" + TextWrapping="Wrap" Margin="0,0,0,8"/> + + <!-- Buttons --> + <StackPanel Grid.Row="7" Orientation="Horizontal" HorizontalAlignment="Right"> + <Button x:Name="TestAllButton" Content="Test All" Height="30" MinWidth="90" Click="TestAllButton_Click" Margin="0,0,8,0" + ToolTip="Ask each server to answer a connection probe with the shared credentials. Informational only — no server is added."/> + <Button x:Name="CancelTestButton" Content="Cancel Test" Height="30" MinWidth="90" Click="CancelTestButton_Click" + Visibility="Collapsed" Margin="0,0,8,0"/> + <Button x:Name="AddButton" Content="Add" Width="90" Height="30" Click="AddButton_Click" Style="{StaticResource AccentButton}" Margin="0,0,8,0"/> + <Button Content="Cancel" Width="80" Height="30" Click="CancelButton_Click" IsCancel="True"/> + </StackPanel> + </Grid> +</Window> diff --git a/Lite/Windows/AddMultipleServersDialog.xaml.cs b/Lite/Windows/AddMultipleServersDialog.xaml.cs new file mode 100644 index 000000000..955c7169b --- /dev/null +++ b/Lite/Windows/AddMultipleServersDialog.xaml.cs @@ -0,0 +1,683 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using Microsoft.Data.SqlClient; +using PerformanceMonitor.Common; +using PerformanceMonitorLite.Models; +using PerformanceMonitorLite.Services; + +namespace PerformanceMonitorLite.Windows; + +/// <summary> +/// "Add Multiple Servers" — paste a server list, set one shared authentication + encryption block, optionally +/// probe every server, and add all non-duplicate rows in one shot. SQL / service-principal credentials are +/// entered ONCE and minted as a single shared <see cref="CredentialProfile"/> that every added row references +/// (never N per-server secrets). Duplicates of already-monitored servers are skipped via the shared +/// <see cref="ServerIdHelper"/> identity, case-folded. Deliberately excludes Entra MFA (an interactive popup +/// per row is a popup storm — single-add remains the MFA path) and the single-dialog's per-server niceties +/// (favorite, description, utility DB, read-only intent, monthly cost). The single AddServerDialog is not edited. +/// </summary> +public partial class AddMultipleServersDialog : Window +{ + private readonly ServerManager _serverManager; + private readonly ProfileManager _profileManager; + private readonly ObservableCollection<BulkRowVm> _rows = new(); + + private CancellationTokenSource? _testCts; + private bool _testRunning; + private bool _profileNameTouched; + private bool _suppressProfileNameSync; + + /// <summary>Number of servers actually added (the caller refreshes when > 0).</summary> + public int AddedCount { get; private set; } + + /// <summary>Number of rows skipped as duplicates of already-monitored servers (or earlier rows in the paste).</summary> + public int SkippedCount { get; private set; } + + /// <summary>Number of rows that failed to build/add (per-row errors; the batch continues past them).</summary> + public int FailedCount { get; private set; } + + public AddMultipleServersDialog(ServerManager serverManager, ProfileManager profileManager) + { + InitializeComponent(); + _serverManager = serverManager; + _profileManager = profileManager; + PreviewGrid.ItemsSource = _rows; + PopulateProfilePicker(); + } + + /// <summary>Populates the profile dropdown; disables the "use profile" option when none exist.</summary> + private void PopulateProfilePicker() + { + var profiles = _profileManager.GetAll(); + ProfileComboBox.ItemsSource = profiles; + if (profiles.Count == 0) + { + UseProfileRadio.IsEnabled = false; + NoProfilesNote.Visibility = Visibility.Visible; + } + } + + // ── Credential source + auth mode (copied idiom from AddServerDialog, adapted to the trimmed radio set) ── + + private void CredentialSource_Changed(object sender, RoutedEventArgs e) + { + if (ProfilePickerPanel == null || InlineAuthRadios == null) return; + + bool useProfile = UseProfileRadio.IsChecked == true; + ProfilePickerPanel.Visibility = useProfile ? Visibility.Visible : Visibility.Collapsed; + InlineAuthRadios.Visibility = useProfile ? Visibility.Collapsed : Visibility.Visible; + + if (useProfile) + { + if (SqlCredentialsPanel != null) SqlCredentialsPanel.Visibility = Visibility.Collapsed; + if (ServicePrincipalPanel != null) ServicePrincipalPanel.Visibility = Visibility.Collapsed; + if (ManagedIdentityPanel != null) ManagedIdentityPanel.Visibility = Visibility.Collapsed; + } + else + { + AuthMode_Changed(sender, e); + } + + UpdateProfileNamePanelAndName(); + } + + private void AuthMode_Changed(object sender, RoutedEventArgs e) + { + if (SqlCredentialsPanel == null || ServicePrincipalPanel == null || ManagedIdentityPanel == null) return; + + SqlCredentialsPanel.Visibility = SqlAuthRadio.IsChecked == true ? Visibility.Visible : Visibility.Collapsed; + ServicePrincipalPanel.Visibility = ServicePrincipalAuthRadio.IsChecked == true ? Visibility.Visible : Visibility.Collapsed; + ManagedIdentityPanel.Visibility = ManagedIdentityAuthRadio.IsChecked == true ? Visibility.Visible : Visibility.Collapsed; + + UpdateProfileNamePanelAndName(); + } + + private void Credential_TextChanged(object sender, TextChangedEventArgs e) => UpdateProfileNamePanelAndName(); + + private void ProfileNameBox_TextChanged(object sender, TextChangedEventArgs e) + { + // A programmatic default set below is suppressed; anything else is a user edit that freezes the name. + if (!_suppressProfileNameSync) _profileNameTouched = true; + } + + /// <summary>Shows the auto-profile-name box for the SQL / SP inline modes and keeps its live default + /// (<c>"SQL — {user}"</c> / <c>"Azure SP — {clientId}"</c>) in sync until the user edits it.</summary> + private void UpdateProfileNamePanelAndName() + { + if (ProfileNamePanel == null) return; + + bool inline = ConfigureCredsRadio.IsChecked == true; + bool sqlOrSp = inline && (SqlAuthRadio.IsChecked == true || ServicePrincipalAuthRadio.IsChecked == true); + ProfileNamePanel.Visibility = sqlOrSp ? Visibility.Visible : Visibility.Collapsed; + + if (sqlOrSp && !_profileNameTouched) + { + string auto = SqlAuthRadio.IsChecked == true + ? $"SQL — {UsernameBox.Text.Trim()}" + : $"Azure SP — {AzureClientIdBox.Text.Trim()}"; + _suppressProfileNameSync = true; + ProfileNameBox.Text = auto; + _suppressProfileNameSync = false; + } + } + + // ── Preview ───────────────────────────────────────────────────────────────────────────────────────── + + private void PasteBox_TextChanged(object sender, TextChangedEventArgs e) => RefreshPreview(); + + /// <summary>Re-parses the paste box, marks duplicates (vs existing servers and earlier rows), and rebuilds + /// the line-ordered preview (valid rows + parse errors merged on LineNumber).</summary> + private void RefreshPreview() + { + if (_serverManager == null) return; // guards a TextChanged during InitializeComponent + + var result = BulkServerListParser.Parse(PasteBox.Text); + + // Seed the dedupe gate from existing servers' REAL (name, db, ro); candidates are always read-write. + var seen = SeedGate(_serverManager.GetAllServers()); + + var rows = new List<BulkRowVm>(); + foreach (var line in result.Servers) + { + // Display key = BuildStorageName(name, db, ro=false), identical by construction to the built row's + // GateKey (built.ServerName == line.ServerName, built.DatabaseName == line.DatabaseName, ro=false). + var key = ServerIdHelper.BuildStorageName(line.ServerName, line.DatabaseName, false); + bool duplicate = !seen.Add(key); + rows.Add(new BulkRowVm + { + LineNumber = line.LineNumber, + Server = line.ServerName, + Display = line.DisplayName, + Database = line.DatabaseName, + IsValid = true, + IsDuplicate = duplicate, + ParsedLine = line, + Status = duplicate ? "Duplicate" : "", + }); + } + + foreach (var error in result.Errors) + { + rows.Add(new BulkRowVm + { + LineNumber = error.LineNumber, + Server = error.RawText.Trim(), + IsValid = false, + Status = "Invalid: " + error.Message, + }); + } + + rows.Sort((a, b) => a.LineNumber.CompareTo(b.LineNumber)); + _rows.Clear(); + foreach (var r in rows) _rows.Add(r); + + int newCount = rows.Count(r => r.IsValid && !r.IsDuplicate); + int dupCount = rows.Count(r => r.IsValid && r.IsDuplicate); + int badCount = rows.Count(r => !r.IsValid); + StatusText.Text = rows.Count == 0 + ? "" + : $"{newCount} new server(s), {dupCount} duplicate(s), {badCount} invalid line(s)."; + } + + // ── Test All (bounded-parallel, cancellable, never-faulting probes) ────────────────────────────────── + + private async void TestAllButton_Click(object sender, RoutedEventArgs e) + { + if (_testRunning) return; + + var candidates = _rows.Where(r => r.IsValid && !r.IsDuplicate).ToList(); + if (candidates.Count == 0) + { + StatusText.Text = "No valid, non-duplicate servers to test."; + return; + } + + if (!TryReadSharedSettings(out var shared, out var settingsError)) + { + StatusText.Text = settingsError; + return; + } + + _testRunning = true; + _testCts = new CancellationTokenSource(); + var cts = _testCts; + + // Freeze the paste box + Add during a run so a re-parse can't rebuild the VM collection while in-flight + // probes are writing Status onto rows (correctness m3). + PasteBox.IsEnabled = false; + AddButton.IsEnabled = false; + TestAllButton.IsEnabled = false; + CancelTestButton.Visibility = Visibility.Visible; + StatusText.Text = "Testing connections…"; + + foreach (var row in candidates) row.Status = ""; + + var gate = new SemaphoreSlim(8); + try + { + var tasks = candidates.Select(row => ProbeGatedAsync(row, shared, gate, cts.Token)).ToList(); + + // Belt-and-suspenders: a gate.WaitAsync cancelled before its probe started faults that task, so + // Task.WhenAll can throw an OperationCanceledException — swallow it here so this async-void handler + // never sees an unhandled exception (an escaping OCE would crash the app on Cancel). + try + { + await Task.WhenAll(tasks); + } + catch (OperationCanceledException) + { + } + + // Rows cancelled at the gate never entered the probe — end them "Canceled". + if (cts.IsCancellationRequested) + { + foreach (var row in candidates) + { + if (row.Status is "" or "Testing…") row.Status = "Canceled"; + } + } + } + finally + { + gate.Dispose(); + _testRunning = false; + _testCts = null; + PasteBox.IsEnabled = true; + AddButton.IsEnabled = true; + TestAllButton.IsEnabled = true; + CancelTestButton.Visibility = Visibility.Collapsed; + StatusText.Text = cts.IsCancellationRequested ? "Connection test canceled." : "Connection test complete."; + } + } + + private void CancelTestButton_Click(object sender, RoutedEventArgs e) => _testCts?.Cancel(); + + /// <summary>Acquires the concurrency gate OUTSIDE the try (a throwing/cancelled wait must not over-release), + /// runs the never-faulting probe, and releases in an inner finally.</summary> + private async Task ProbeGatedAsync(BulkRowVm row, BulkSharedSettings shared, SemaphoreSlim gate, CancellationToken ct) + { + await gate.WaitAsync(ct); + try + { + await ProbeRowAsync(row, shared, ct); + } + finally + { + gate.Release(); + } + } + + /// <summary>Probes one server with the shared credentials and writes the outcome to <see cref="BulkRowVm.Status"/>. + /// Catches EVERYTHING internally (SqlException, Exception, AND OperationCanceledException) so this Task never + /// faults — mirroring CheckConnectionAsync's dual internal catch, so <c>Task.WhenAll</c> can never throw a + /// probe failure into the async-void handler.</summary> + private async Task ProbeRowAsync(BulkRowVm row, BulkSharedSettings shared, CancellationToken ct) + { + try + { + if (row.ParsedLine == null) return; + + var (server, buildError) = BuildServerConnection(row.ParsedLine, shared); + if (server == null) + { + row.Status = "Failed: " + buildError; + return; + } + + row.Status = "Testing…"; + + // Build the probe connection string from the SAME transient ServerConnection the Add path uses (no + // drift), then override ConnectTimeout to the configurable App value (no literal 5). + var builder = new SqlConnectionStringBuilder(server.BuildConnectionString(shared.Username, shared.Secret)) + { + ConnectTimeout = App.ConnectionTimeoutSeconds + }; + + using var connection = new SqlConnection(builder.ConnectionString); + await connection.OpenAsync(ct); + using var command = new SqlCommand("SELECT @@VERSION", connection); + var version = await command.ExecuteScalarAsync(ct) as string; + var firstLine = version?.Split('\n')[0]?.Trim(); + row.Status = string.IsNullOrEmpty(firstLine) ? "OK" : "OK — " + firstLine; + } + catch (OperationCanceledException) + { + row.Status = "Canceled"; + } + catch (SqlException ex) + { + row.Status = "Failed: " + ex.Message; + } + catch (Exception ex) + { + row.Status = "Failed: " + ex.Message; + } + } + + // ── Add ───────────────────────────────────────────────────────────────────────────────────────────── + + private void AddButton_Click(object sender, RoutedEventArgs e) + { + if (_testRunning) return; + + // (1) Re-parse the paste box (source of truth). + var result = BulkServerListParser.Parse(PasteBox.Text); + if (result.Servers.Count == 0) + { + StatusText.Text = "No servers to add. Enter at least one server line."; + return; + } + + if (!TryReadSharedSettings(out var shared, out var settingsError)) + { + StatusText.Text = settingsError; + return; + } + + // (2) Compute the new-row set: valid, buildable, non-duplicate. Seed from existing servers' REAL + // (name, db, ro); each candidate's key derives FROM THE BUILT row (one composition feeds the gate AND + // the stored identity); candidates are always read-write. First occurrence in the paste wins. + var seen = SeedGate(_serverManager.GetAllServers()); + var toAdd = new List<(BulkServerParseLine Line, ServerConnection Server)>(); + int skipped = 0; + int failed = 0; + string? firstBuildError = null; + + foreach (var line in result.Servers) + { + var (server, buildError) = BuildServerConnection(line, shared); + if (server == null) + { + failed++; + firstBuildError ??= buildError; + MarkRow(line.LineNumber, "Failed: " + buildError); + continue; + } + if (!seen.Add(GateKey(server))) + { + skipped++; + continue; + } + toAdd.Add((line, server)); + } + + // (3) Zero survivors → no profile created (no orphan secret). + if (toAdd.Count == 0) + { + AddedCount = 0; + SkippedCount = skipped; + FailedCount = failed; + StatusText.Text = firstBuildError != null + ? $"No servers added — {firstBuildError}." + : $"No new servers to add ({skipped} duplicate(s) skipped)."; + return; + } + + // (4) Create the ONE shared credential profile (SQL / SP only), now that ≥1 new row survives. + if (shared.CreateProfile) + { + try + { + var baseName = string.IsNullOrWhiteSpace(shared.NewProfileName) ? DefaultProfileName(shared) : shared.NewProfileName!.Trim(); + var uniqueName = MakeUniqueProfileName(baseName, _profileManager.GetAll().Select(p => p.Name)); + var profile = new CredentialProfile + { + Name = uniqueName, + AuthType = shared.AuthType, + Username = shared.Username, + AzureClientId = shared.AuthType == AuthenticationTypes.ServicePrincipal ? shared.AzureClientId : null, + AzureTenantId = shared.AuthType == AuthenticationTypes.ServicePrincipal ? shared.AzureTenantId : null, + }; + _profileManager.AddProfile(profile, shared.Username, shared.Secret); + foreach (var (_, server) in toAdd) server.CredentialProfileId = profile.Id; + } + catch (Exception ex) + { + StatusText.Text = $"Could not create the shared credential profile: {ex.Message}"; + return; + } + } + + // (5) Add each survivor; a per-row failure marks the row and continues the batch. + int added = 0; + foreach (var (line, server) in toAdd) + { + try + { + _serverManager.AddServer(server, null, null); + added++; + } + catch (Exception ex) + { + failed++; + MarkRow(line.LineNumber, "Failed: " + ex.Message); + } + } + + AddedCount = added; + SkippedCount = skipped; + FailedCount = failed; + + if (added > 0) + { + DialogResult = true; + Close(); + return; + } + + StatusText.Text = $"No servers added ({skipped} duplicate(s), {failed} failed)."; + } + + private void MarkRow(int lineNumber, string status) + { + var vm = _rows.FirstOrDefault(r => r.LineNumber == lineNumber && r.IsValid); + if (vm != null) vm.Status = status; + } + + private void CancelButton_Click(object sender, RoutedEventArgs e) + { + DialogResult = false; + Close(); + } + + // ── Shared settings + mapping (static helpers — unit-tested in Lite.Tests) ─────────────────────────── + + private string GetSelectedEncryptMode() => EncryptModeComboBox.SelectedIndex switch + { + 1 => "Mandatory", + 2 => "Strict", + _ => "Optional" + }; + + /// <summary>Reads the shared auth/encryption block into a <see cref="BulkSharedSettings"/>, validating the + /// required per-mode fields. For an existing profile the stored secret is resolved ONCE (for the probe); + /// for inline SQL/SP the block flags that a single profile must be minted at Add time.</summary> + private bool TryReadSharedSettings(out BulkSharedSettings shared, out string? error) + { + shared = new BulkSharedSettings(); + error = null; + + string encryptMode = GetSelectedEncryptMode(); + bool trustCert = TrustCertCheckBox.IsChecked == true; + + if (UseProfileRadio.IsChecked == true) + { + if (ProfileComboBox.SelectedItem is not CredentialProfile profile) + { + error = "Select a credential profile, or choose \"Configure credentials for all servers\"."; + return false; + } + + // Resolve the profile's stored secret ONCE for the probe; the Add path references it by id. + string? user = null; + string? secret = null; + if (profile.AuthType is AuthenticationTypes.SqlServer or AuthenticationTypes.ServicePrincipal) + { + var cred = _profileManager.CredentialService.GetCredential(ProfileManager.ProfileCredentialId(profile.Id)); + if (cred.HasValue) + { + user = cred.Value.Username; + secret = cred.Value.Password; + } + } + + shared = new BulkSharedSettings + { + AuthType = profile.AuthType, + ProfileId = profile.Id, + Username = user, + Secret = secret, + ManagedIdentityClientId = profile.ManagedIdentityClientId, + EncryptMode = encryptMode, + TrustServerCertificate = trustCert, + }; + return true; + } + + if (WindowsAuthRadio.IsChecked == true) + { + shared = new BulkSharedSettings { AuthType = AuthenticationTypes.Windows, EncryptMode = encryptMode, TrustServerCertificate = trustCert }; + return true; + } + + if (SqlAuthRadio.IsChecked == true) + { + var user = UsernameBox.Text.Trim(); + var secret = PasswordBox.Password; + if (string.IsNullOrEmpty(user)) { error = "Username is required for SQL Server authentication."; return false; } + if (string.IsNullOrEmpty(secret)) { error = "Password is required for SQL Server authentication."; return false; } + + shared = new BulkSharedSettings + { + AuthType = AuthenticationTypes.SqlServer, + Username = user, + Secret = secret, + CreateProfile = true, + NewProfileName = ProfileNameBox.Text.Trim(), + EncryptMode = encryptMode, + TrustServerCertificate = trustCert, + }; + return true; + } + + if (ServicePrincipalAuthRadio.IsChecked == true) + { + var clientId = AzureClientIdBox.Text.Trim(); + var secret = AzureClientSecretBox.Password; + var tenant = AzureTenantIdBox.Text.Trim(); + if (string.IsNullOrEmpty(clientId)) { error = "Client (Application) ID is required for service principal authentication."; return false; } + if (string.IsNullOrEmpty(secret)) { error = "Client secret is required for service principal authentication."; return false; } + + shared = new BulkSharedSettings + { + AuthType = AuthenticationTypes.ServicePrincipal, + Username = clientId, + Secret = secret, + AzureClientId = clientId, + AzureTenantId = string.IsNullOrEmpty(tenant) ? null : tenant, + CreateProfile = true, + NewProfileName = ProfileNameBox.Text.Trim(), + EncryptMode = encryptMode, + TrustServerCertificate = trustCert, + }; + return true; + } + + // Managed Identity (or the unreachable fallback). + var miClientId = ManagedIdentityClientIdBox.Text.Trim(); + shared = new BulkSharedSettings + { + AuthType = AuthenticationTypes.ManagedIdentity, + ManagedIdentityClientId = string.IsNullOrEmpty(miClientId) ? null : miClientId, + EncryptMode = encryptMode, + TrustServerCertificate = trustCert, + }; + return true; + } + + private static string DefaultProfileName(BulkSharedSettings shared) => shared.AuthType switch + { + AuthenticationTypes.SqlServer => $"SQL — {shared.Username}", + AuthenticationTypes.ServicePrincipal => $"Azure SP — {shared.AzureClientId}", + _ => "Bulk credential" + }; + + /// <summary>Maps one parsed row + the shared settings into a <see cref="ServerConnection"/> (the single + /// source of truth feeding BOTH the Test-All probes and the Add path). REJECTS a resolved + /// <see cref="AuthenticationTypes.EntraMFA"/> at this one choke point (the MFA belt) — no supported bulk + /// path mints MFA, but a hand-edited profiles.json could, and bulk would amplify it into a popup storm.</summary> + internal static (ServerConnection? Server, string? Error) BuildServerConnection(BulkServerParseLine row, BulkSharedSettings shared) + { + if (shared.AuthType == AuthenticationTypes.EntraMFA) + { + return (null, "Entra MFA is not supported in bulk add — use Add Server"); + } + + var databaseName = string.IsNullOrWhiteSpace(row.DatabaseName) ? null : row.DatabaseName.Trim(); + bool profileBacked = !string.IsNullOrEmpty(shared.ProfileId); + + var server = new ServerConnection + { + ServerName = row.ServerName, + DisplayName = string.IsNullOrEmpty(row.DisplayName) ? row.ServerName : row.DisplayName, + DatabaseName = databaseName, + AuthenticationType = shared.AuthType, + // SQL/SP are always profile-backed in bulk (the profile carries the client id), so the server keeps + // no inline Azure identity (single-add's M-3 hygiene). Only inline Managed Identity rides on the row. + AzureClientId = null, + AzureTenantId = null, + ManagedIdentityClientId = (!profileBacked && shared.AuthType == AuthenticationTypes.ManagedIdentity) + ? shared.ManagedIdentityClientId + : null, + EncryptMode = shared.EncryptMode, + TrustServerCertificate = shared.TrustServerCertificate, + IsEnabled = true, + CredentialProfileId = shared.ProfileId, + }; + + return (server, null); + } + + /// <summary>The dedupe gate key for a built server — the shared storage-name identity (case-folded by the + /// gate's comparer). One composition feeds both the gate and the stored server_id.</summary> + internal static string GateKey(ServerConnection server) => RemoteCollectorService.GetServerNameForStorage(server); + + /// <summary>Seeds the OrdinalIgnoreCase dedupe gate from the existing servers' REAL (name, db, read-only) keys.</summary> + internal static HashSet<string> SeedGate(IEnumerable<ServerConnection> existing) + => new(existing.Select(GateKey), StringComparer.OrdinalIgnoreCase); + + /// <summary>Returns <paramref name="baseName"/> if unused, else the first free <c>"{baseName} (N)"</c> + /// (case-insensitive), starting at (2) — mirrors the StandalonePlanViewerController unique-label idiom.</summary> + internal static string MakeUniqueProfileName(string baseName, IEnumerable<string> existingNames) + { + var existing = new HashSet<string>(existingNames, StringComparer.OrdinalIgnoreCase); + if (!existing.Contains(baseName)) return baseName; + + var counter = 2; + string candidate; + do { candidate = $"{baseName} ({counter++})"; } + while (existing.Contains(candidate)); + return candidate; + } + + /// <summary>One preview row (a parsed server or a parse error). Status is mutable + observable so probes + /// can update it live.</summary> + private sealed class BulkRowVm : INotifyPropertyChanged + { + public int LineNumber { get; init; } + public string Server { get; init; } = ""; + public string? Display { get; init; } + public string? Database { get; init; } + public bool IsValid { get; init; } + public bool IsDuplicate { get; init; } + public BulkServerParseLine? ParsedLine { get; init; } + + private string _status = ""; + public string Status + { + get => _status; + set + { + if (_status == value) return; + _status = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Status))); + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + } +} + +/// <summary>The one shared auth/encryption block applied to every bulk row. For SQL/SP inline modes +/// <see cref="CreateProfile"/> is set (one profile minted at Add time); for an existing profile +/// <see cref="ProfileId"/> is set. <see cref="Username"/>/<see cref="Secret"/> feed the Test-All probe and the +/// profile creation, and are NOT stored per-server (the built server is profile-backed for SQL/SP).</summary> +internal sealed class BulkSharedSettings +{ + public string AuthType { get; init; } = AuthenticationTypes.Windows; + public string? ProfileId { get; set; } + public string? Username { get; init; } + public string? Secret { get; init; } + public string? AzureClientId { get; init; } + public string? AzureTenantId { get; init; } + public string? ManagedIdentityClientId { get; init; } + public string EncryptMode { get; init; } = "Mandatory"; + public bool TrustServerCertificate { get; init; } + public bool CreateProfile { get; init; } + public string? NewProfileName { get; init; } +} diff --git a/Lite/Windows/AddServerDialog.xaml b/Lite/Windows/AddServerDialog.xaml index ecaea7ec2..a94cf5f88 100644 --- a/Lite/Windows/AddServerDialog.xaml +++ b/Lite/Windows/AddServerDialog.xaml @@ -39,7 +39,7 @@ <TextBlock Grid.Row="5" Text="Authentication" FontWeight="Bold" Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/> <StackPanel Grid.Row="6" Margin="0,0,0,12"> <!-- Credential source: inline (per-server) vs a shared credential profile --> - <RadioButton x:Name="ConfigureOnServerRadio" Content="Configure credentials on this server" + <RadioButton Content="Configure credentials on this server" Foreground="{DynamicResource ForegroundBrush}" IsChecked="True" Checked="CredentialSource_Changed" Margin="0,0,0,4"/> <RadioButton x:Name="UseProfileRadio" Content="Use a credential profile" diff --git a/Lite/Windows/CollectionLogWindow.xaml.cs b/Lite/Windows/CollectionLogWindow.xaml.cs index 631302833..56dc819f9 100644 --- a/Lite/Windows/CollectionLogWindow.xaml.cs +++ b/Lite/Windows/CollectionLogWindow.xaml.cs @@ -11,6 +11,7 @@ using System.Threading.Tasks; using System.Windows; using PerformanceMonitorLite.Services; +using PerformanceMonitor.Ui; namespace PerformanceMonitorLite.Windows { @@ -72,10 +73,10 @@ private async Task LoadCollectionLogAsync() } } - private void CopyCell_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyAllRows(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.ExportToCsv(sender, "collection_log"); + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => DataGridExport.ExportToCsv(sender, "collection_log", App.CsvSeparator); private void Close_Click(object sender, RoutedEventArgs e) { diff --git a/Lite/Windows/CollectorScheduleEditorWindow.xaml.cs b/Lite/Windows/CollectorScheduleEditorWindow.xaml.cs index 3243d056b..edd7b34d5 100644 --- a/Lite/Windows/CollectorScheduleEditorWindow.xaml.cs +++ b/Lite/Windows/CollectorScheduleEditorWindow.xaml.cs @@ -129,18 +129,9 @@ private void UseDefaultCheckBox_Changed(object sender, RoutedEventArgs e) bool usesDefault = UseDefaultCheckBox.IsChecked == true; - if (!usesDefault) - { - /* Switching from default to custom — copy current defaults as starting point */ - _editingSchedules = CloneScheduleList(_scheduleManager.GetDefaultSchedule()); - ScheduleGrid.ItemsSource = _editingSchedules; - } - else - { - /* Switching to default — show the default schedule (read-only) */ - _editingSchedules = CloneScheduleList(_scheduleManager.GetDefaultSchedule()); - ScheduleGrid.ItemsSource = _editingSchedules; - } + /* Reset to a fresh copy of the default schedule; UpdateEditableState makes it read-only when 'use default' is checked. */ + _editingSchedules = CloneScheduleList(_scheduleManager.GetDefaultSchedule()); + ScheduleGrid.ItemsSource = _editingSchedules; UpdateEditableState(usesDefault); DetectActivePreset(); @@ -151,7 +142,7 @@ private void DetectActivePreset() _suppressPresetChange = true; try { - string active = DetectPresetForList(_editingSchedules); + string active = ScheduleManager.DetectPreset(_editingSchedules); for (int i = 0; i < PresetComboBox.Items.Count; i++) { if (PresetComboBox.Items[i] is ComboBoxItem item && @@ -189,7 +180,7 @@ private void PresetComboBox_SelectionChanged(object sender, SelectionChangedEven return; } - ApplyPresetToList(_editingSchedules, presetName); + ScheduleManager.ApplyPreset(_editingSchedules, presetName); ScheduleGrid.ItemsSource = null; ScheduleGrid.ItemsSource = _editingSchedules; DetectActivePreset(); @@ -265,72 +256,6 @@ private void CancelButton_Click(object sender, RoutedEventArgs e) // Helpers // ────────────────────────────────────────────────────────────────── - private static readonly Dictionary<string, Dictionary<string, int>> s_presets = new(StringComparer.OrdinalIgnoreCase) - { - ["Aggressive"] = new(StringComparer.OrdinalIgnoreCase) - { - ["wait_stats"] = 1, ["query_stats"] = 1, ["procedure_stats"] = 1, - ["query_store"] = 2, ["query_snapshots"] = 1, ["cpu_utilization"] = 1, - ["file_io_stats"] = 1, ["memory_stats"] = 1, ["memory_clerks"] = 2, - ["tempdb_stats"] = 1, ["perfmon_stats"] = 1, ["deadlocks"] = 1, - ["memory_grant_stats"] = 1, ["waiting_tasks"] = 1, - ["blocked_process_report"] = 1, ["running_jobs"] = 2 - }, - ["Balanced"] = new(StringComparer.OrdinalIgnoreCase) - { - ["wait_stats"] = 1, ["query_stats"] = 1, ["procedure_stats"] = 1, - ["query_store"] = 5, ["query_snapshots"] = 1, ["cpu_utilization"] = 1, - ["file_io_stats"] = 1, ["memory_stats"] = 1, ["memory_clerks"] = 5, - ["tempdb_stats"] = 1, ["perfmon_stats"] = 1, ["deadlocks"] = 1, - ["memory_grant_stats"] = 1, ["waiting_tasks"] = 1, - ["blocked_process_report"] = 1, ["running_jobs"] = 5 - }, - ["Low-Impact"] = new(StringComparer.OrdinalIgnoreCase) - { - ["wait_stats"] = 5, ["query_stats"] = 10, ["procedure_stats"] = 10, - ["query_store"] = 30, ["query_snapshots"] = 5, ["cpu_utilization"] = 5, - ["file_io_stats"] = 10, ["memory_stats"] = 10, ["memory_clerks"] = 30, - ["tempdb_stats"] = 5, ["perfmon_stats"] = 5, ["deadlocks"] = 5, - ["memory_grant_stats"] = 5, ["waiting_tasks"] = 5, - ["blocked_process_report"] = 5, ["running_jobs"] = 30 - } - }; - - private static string DetectPresetForList(List<CollectorSchedule> schedules) - { - foreach (var (presetName, intervals) in s_presets) - { - bool matches = true; - foreach (var (collector, freq) in intervals) - { - var schedule = schedules.FirstOrDefault(s => - s.Name.Equals(collector, StringComparison.OrdinalIgnoreCase)); - if (schedule != null && schedule.FrequencyMinutes != freq) - { - matches = false; - break; - } - } - if (matches) return presetName; - } - return "Custom"; - } - - private static void ApplyPresetToList(List<CollectorSchedule> schedules, string presetName) - { - if (!s_presets.TryGetValue(presetName, out var intervals)) return; - - foreach (var (collector, freq) in intervals) - { - var schedule = schedules.FirstOrDefault(s => - s.Name.Equals(collector, StringComparison.OrdinalIgnoreCase)); - if (schedule != null) - { - schedule.FrequencyMinutes = freq; - } - } - } - private static List<CollectorSchedule> CloneScheduleList(IReadOnlyList<CollectorSchedule> source) { return source.Select(s => new CollectorSchedule diff --git a/Lite/Windows/EditCredentialProfileDialog.xaml b/Lite/Windows/EditCredentialProfileDialog.xaml index 7182189c2..edc18ad97 100644 --- a/Lite/Windows/EditCredentialProfileDialog.xaml +++ b/Lite/Windows/EditCredentialProfileDialog.xaml @@ -70,7 +70,7 @@ TextWrapping="Wrap" Margin="0,0,0,8"/> <StackPanel Grid.Row="7" Orientation="Horizontal" HorizontalAlignment="Right"> - <Button x:Name="SaveButton" Content="Save" Width="80" Height="30" Click="SaveButton_Click" Style="{StaticResource AccentButton}" Margin="0,0,8,0"/> + <Button Content="Save" Width="80" Height="30" Click="SaveButton_Click" Style="{StaticResource AccentButton}" Margin="0,0,8,0"/> <Button Content="Cancel" Width="80" Height="30" Click="CancelButton_Click" IsCancel="True"/> </StackPanel> </Grid> diff --git a/Lite/Windows/ManageMuteRulesWindow.xaml b/Lite/Windows/ManageMuteRulesWindow.xaml index b424f1807..1109f4dbc 100644 --- a/Lite/Windows/ManageMuteRulesWindow.xaml +++ b/Lite/Windows/ManageMuteRulesWindow.xaml @@ -77,11 +77,11 @@ <Button Content="Add Rule..." Width="100" Height="28" Margin="0,0,8,0" Click="AddRule_Click" Style="{StaticResource AccentButton}"/> <Button Content="Edit" Width="80" Height="28" Margin="0,0,8,0" - Click="EditRule_Click" x:Name="EditButton"/> + Click="EditRule_Click"/> <Button Content="Toggle" Width="80" Height="28" Margin="0,0,8,0" - Click="ToggleRule_Click" x:Name="ToggleButton"/> + Click="ToggleRule_Click"/> <Button Content="Delete" Width="80" Height="28" Margin="0,0,8,0" - Click="DeleteRule_Click" x:Name="DeleteButton"/> + Click="DeleteRule_Click"/> <Button Content="Purge Expired" Width="110" Height="28" Click="PurgeExpired_Click"/> </StackPanel> diff --git a/Lite/Windows/ManageServersWindow.xaml.cs b/Lite/Windows/ManageServersWindow.xaml.cs index 15ca19a7b..913d2ab94 100644 --- a/Lite/Windows/ManageServersWindow.xaml.cs +++ b/Lite/Windows/ManageServersWindow.xaml.cs @@ -11,6 +11,7 @@ using System.Windows.Input; using PerformanceMonitorLite.Models; using PerformanceMonitorLite.Services; +using PerformanceMonitor.Ui; namespace PerformanceMonitorLite.Windows; @@ -51,11 +52,7 @@ private static string GetAppVersion() ?? Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0"; - int plusIndex = raw.IndexOf('+'); - string trimmed = plusIndex >= 0 ? raw[..plusIndex] : raw; - return System.Version.TryParse(trimmed, out var v) - ? new System.Version(v.Major, v.Minor, v.Build).ToString() - : trimmed; + return VersionText.Normalize(raw); } private void AddButton_Click(object sender, RoutedEventArgs e) @@ -144,10 +141,10 @@ private void DeleteButton_Click(object sender, RoutedEventArgs e) } } - private void CopyCell_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyAllRows(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.ExportToCsv(sender, "servers"); + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => DataGridExport.ExportToCsv(sender, "servers", App.CsvSeparator); private void CloseButton_Click(object sender, RoutedEventArgs e) { diff --git a/Lite/Windows/MuteRuleDialog.xaml b/Lite/Windows/MuteRuleDialog.xaml index 81829d312..99614d6cc 100644 --- a/Lite/Windows/MuteRuleDialog.xaml +++ b/Lite/Windows/MuteRuleDialog.xaml @@ -72,7 +72,7 @@ <ComboBoxItem Content="High CPU"/> <ComboBoxItem Content="Poison Wait"/> <ComboBoxItem Content="Long-Running Query"/> - <ComboBoxItem Content="TempDB Space"/> + <ComboBoxItem Content="tempdb Space"/> <ComboBoxItem Content="Volume Free Space"/> <ComboBoxItem Content="Long-Running Job"/> </ComboBox> diff --git a/Lite/Windows/ProcedureHistoryWindow.xaml b/Lite/Windows/ProcedureHistoryWindow.xaml index e8de809fc..a3f9da9eb 100644 --- a/Lite/Windows/ProcedureHistoryWindow.xaml +++ b/Lite/Windows/ProcedureHistoryWindow.xaml @@ -25,7 +25,7 @@ <MenuItem.Icon><TextBlock Text="📊"/></MenuItem.Icon> </MenuItem> <Separator/> - <MenuItem Header="View Plan" Click="ViewPlan_Click"> + <MenuItem Header="View Stored Plan" Click="ViewPlan_Click"> <MenuItem.Icon><TextBlock Text="🔍"/></MenuItem.Icon> </MenuItem> </ContextMenu> @@ -59,9 +59,9 @@ <ComboBoxItem Content="Avg Duration (ms)" Tag="AvgElapsedMs"/> <ComboBoxItem Content="Avg Logical Reads" Tag="AvgReads"/> <ComboBoxItem Content="Execution Count Delta" Tag="DeltaExecutions"/> - <ComboBoxItem Content="Total CPU (ms)" Tag="DeltaCpuMs"/> - <ComboBoxItem Content="Total Logical Reads" Tag="DeltaLogicalReads"/> - <ComboBoxItem Content="Total Spills" Tag="TotalSpills"/> + <ComboBoxItem Content="CPU Delta (ms)" Tag="DeltaCpuMs"/> + <ComboBoxItem Content="Logical Reads Delta" Tag="DeltaLogicalReads"/> + <ComboBoxItem Content="Spills Delta" Tag="DeltaSpills"/> </ComboBox> <scottplot:WpfPlot x:Name="HistoryChart" Margin="0,0,0,0"/> </Grid> @@ -75,20 +75,44 @@ ContextMenu="{StaticResource DataGridContextMenu}"> <DataGrid.Columns> <DataGridTextColumn Header="Collection Time" Binding="{Binding CollectionTimeLocal}" Width="130"/> + <DataGridTextColumn Header="Last Execution" Binding="{Binding LastExecutionTimeLocal}" Width="130"/> + <DataGridTextColumn Header="Cached Time" Binding="{Binding CachedTimeLocal}" Width="130"/> + <DataGridTextColumn Header="Object Type" Binding="{Binding ObjectType}" Width="95"/> <DataGridTextColumn Header="Exec Delta" Binding="{Binding DeltaExecutions, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Total Executions" Binding="{Binding TotalExecutions, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"/> <DataGridTextColumn Header="CPU Delta (ms)" Binding="{Binding DeltaCpuMs, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="110"/> <DataGridTextColumn Header="Duration Delta (ms)" Binding="{Binding DeltaElapsedMs, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="130"/> <DataGridTextColumn Header="Avg CPU (ms)" Binding="{Binding AvgCpuMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> <DataGridTextColumn Header="Avg Duration (ms)" Binding="{Binding AvgElapsedMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Total CPU (ms)" Binding="{Binding TotalCpuMs, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="Total Duration (ms)" Binding="{Binding TotalElapsedMs, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="130"/> <DataGridTextColumn Header="Logical Reads" Binding="{Binding DeltaLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"/> <DataGridTextColumn Header="Avg Reads" Binding="{Binding AvgReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Total Logical Reads" Binding="{Binding TotalLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="125"/> <DataGridTextColumn Header="Writes" Binding="{Binding DeltaLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="80"/> + <DataGridTextColumn Header="Avg Writes" Binding="{Binding AvgWrites, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="95"/> + <DataGridTextColumn Header="Total Writes" Binding="{Binding TotalLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"/> <DataGridTextColumn Header="Physical Reads" Binding="{Binding DeltaPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"/> - <DataGridTextColumn Header="Spills" Binding="{Binding TotalSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="70"/> + <DataGridTextColumn Header="Avg Phys Reads" Binding="{Binding AvgPhysicalReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="Total Phys Reads" Binding="{Binding TotalPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Spills" Binding="{Binding DeltaSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="70"/> + <DataGridTextColumn Header="Avg Spills" Binding="{Binding AvgSpills, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Total Spills" Binding="{Binding TotalSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="95"/> <DataGridTextColumn Header="Min CPU (ms)" Binding="{Binding MinCpuMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> <DataGridTextColumn Header="Max CPU (ms)" Binding="{Binding MaxCpuMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> <DataGridTextColumn Header="Min Duration (ms)" Binding="{Binding MinElapsedMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"/> <DataGridTextColumn Header="Max Duration (ms)" Binding="{Binding MaxElapsedMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Min Reads" Binding="{Binding MinLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Max Reads" Binding="{Binding MaxLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Min Phys Reads" Binding="{Binding MinPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="Max Phys Reads" Binding="{Binding MaxPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="Min Writes" Binding="{Binding MinLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Max Writes" Binding="{Binding MaxLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Min Spills" Binding="{Binding MinSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Max Spills" Binding="{Binding MaxSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Interval (sec)" Binding="{Binding SampleIntervalSeconds}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="SQL Handle" Binding="{Binding SqlHandle}" Width="140"/> + <DataGridTextColumn Header="Plan Handle" Binding="{Binding PlanHandle}" Width="140"/> <DataGridTemplateColumn Header="Plan" Width="Auto" MinWidth="80"> <DataGridTemplateColumn.CellTemplate> <DataTemplate> diff --git a/Lite/Windows/ProcedureHistoryWindow.xaml.cs b/Lite/Windows/ProcedureHistoryWindow.xaml.cs index 04e09a425..853adc61c 100644 --- a/Lite/Windows/ProcedureHistoryWindow.xaml.cs +++ b/Lite/Windows/ProcedureHistoryWindow.xaml.cs @@ -13,11 +13,14 @@ using System.Text; using System.Windows; using System.Windows.Controls; +using System.Windows.Controls.Primitives; using Microsoft.Win32; +using PerformanceMonitorLite.Controls; using PerformanceMonitorLite.Services; using ScottPlot; using PerformanceMonitor.Common; using PerformanceMonitor.Ui; +using PerformanceMonitor.PlanAnalysis; namespace PerformanceMonitorLite.Windows; @@ -32,6 +35,10 @@ public partial class ProcedureHistoryWindow : Window private readonly string? _connectionString; private readonly PlanNavigationController _planActions; private List<ProcedureStatsHistoryRow> _historyData = new(); + private ChartHoverHelper? _chartHover; + private DataGridFilterManager<ProcedureStatsHistoryRow>? _filterManager; + private Popup? _filterPopup; + private ColumnFilterPopup? _filterPopupContent; public ProcedureHistoryWindow(LocalDataService dataService, int serverId, string databaseName, string schemaName, string objectName, int hoursBack, string? connectionString = null) { @@ -48,9 +55,14 @@ public ProcedureHistoryWindow(LocalDataService dataService, int serverId, string this, (xml, label, qt) => PlanViewerWindow.ShowPlanAsync(this, xml, label, qt), (db, qt, est, iso, ct) => ActualPlanExecutor.ExecuteForActualPlanAsync( - _connectionString ?? "", db, qt, est, iso, isAzureSqlDb: false, timeoutSeconds: 0, ct), + _connectionString ?? "", db, qt, est, iso, isAzureSqlDb: false, timeoutSeconds: 0, ct, + productName: "SQL Server Performance Monitor Lite"), "the monitored server"); + _filterManager = new DataGridFilterManager<ProcedureStatsHistoryRow>(HistoryDataGrid); + DataGridFilterColumns.AddFilterButtons(HistoryDataGrid, Filter_Click); + _filterManager.UpdateFilterButtonStyles(); + var fullName = string.IsNullOrEmpty(schemaName) ? objectName : $"{schemaName}.{objectName}"; ProcIdentifierText.Text = $"Procedure History: {fullName} in [{databaseName}]"; Loaded += async (_, _) => await LoadHistoryAsync(); @@ -63,7 +75,7 @@ private async System.Threading.Tasks.Task LoadHistoryAsync() try { _historyData = await _dataService.GetProcedureStatsHistoryAsync(_serverId, _databaseName, _schemaName, _objectName, _hoursBack); - HistoryDataGrid.ItemsSource = _historyData; + _filterManager!.UpdateData(_historyData); if (_historyData.Count > 0) { @@ -110,6 +122,14 @@ private void UpdateChart() ChartStyle.StyleScatter(scatter); scatter.LegendText = label; + var unit = tag.Contains("Ms") ? "ms" : ""; + if (_chartHover == null) + _chartHover = new ChartHoverHelper(HistoryChart, unit); + else + _chartHover.Unit = unit; + _chartHover.Clear(); + _chartHover.Add(scatter, label); + HistoryChart.Plot.Axes.DateTimeTicksBottom(); ApplyTheme(HistoryChart); @@ -124,6 +144,7 @@ private void UpdateChart() "DeltaExecutions" => row.DeltaExecutions, "DeltaCpuMs" => row.DeltaCpuMs, "DeltaLogicalReads" => row.DeltaLogicalReads, + "DeltaSpills" => row.DeltaSpills, _ => row.AvgCpuMs }; @@ -135,8 +156,60 @@ private void OnThemeChanged(string _) { ApplyTheme(HistoryChart); HistoryChart.Refresh(); + _filterManager?.UpdateFilterButtonStyles(); + } + + #region Column Filter Popup + + private void EnsureFilterPopup() + { + if (_filterPopup == null) + { + _filterPopupContent = new ColumnFilterPopup(); + _filterPopup = new Popup + { + Child = _filterPopupContent, + StaysOpen = false, + Placement = PlacementMode.Bottom, + AllowsTransparency = true + }; + } + } + + private void Filter_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button button || button.Tag is not string columnName) return; + if (_filterManager == null) return; + + EnsureFilterPopup(); + + _filterPopupContent!.FilterApplied -= FilterPopup_FilterApplied; + _filterPopupContent.FilterCleared -= FilterPopup_FilterCleared; + _filterPopupContent.FilterApplied += FilterPopup_FilterApplied; + _filterPopupContent.FilterCleared += FilterPopup_FilterCleared; + + _filterManager.Filters.TryGetValue(columnName, out var existingFilter); + _filterPopupContent.Initialize(columnName, existingFilter); + + _filterPopup!.PlacementTarget = button; + _filterPopup.IsOpen = true; + } + + private void FilterPopup_FilterApplied(object? sender, FilterAppliedEventArgs e) + { + if (_filterPopup != null) + _filterPopup.IsOpen = false; + _filterManager?.SetFilter(e.FilterState); + } + + private void FilterPopup_FilterCleared(object? sender, EventArgs e) + { + if (_filterPopup != null) + _filterPopup.IsOpen = false; } + #endregion + private async void DownloadPlan_Click(object sender, RoutedEventArgs e) { if (sender is not Button btn) return; @@ -174,10 +247,10 @@ private async void DownloadPlan_Click(object sender, RoutedEventArgs e) } } - private void CopyCell_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyAllRows(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.ExportToCsv(sender, "procedure_history"); + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => DataGridExport.ExportToCsv(sender, "procedure_history", App.CsvSeparator); // Procedures are View Plan only — re-executing a proc without parameter values is unsafe, // matching the main Procedure grid (no Get Actual case in its switch). diff --git a/Lite/Windows/QueryStatsHistoryWindow.xaml b/Lite/Windows/QueryStatsHistoryWindow.xaml index 848834575..8f0a30a60 100644 --- a/Lite/Windows/QueryStatsHistoryWindow.xaml +++ b/Lite/Windows/QueryStatsHistoryWindow.xaml @@ -25,10 +25,10 @@ <MenuItem.Icon><TextBlock Text="📊"/></MenuItem.Icon> </MenuItem> <Separator/> - <MenuItem Header="View Plan" Click="ViewPlan_Click"> + <MenuItem Header="View Stored Plan" Click="ViewPlan_Click"> <MenuItem.Icon><TextBlock Text="🔍"/></MenuItem.Icon> </MenuItem> - <MenuItem Header="Get Actual Plan" Click="GetActualPlan_Click"> + <MenuItem Header="Get Actual Plan (re-run)" Click="GetActualPlan_Click"> <MenuItem.Icon><TextBlock Text="▶"/></MenuItem.Icon> </MenuItem> </ContextMenu> @@ -62,9 +62,9 @@ <ComboBoxItem Content="Avg Duration (ms)" Tag="AvgElapsedMs"/> <ComboBoxItem Content="Avg Logical Reads" Tag="AvgReads"/> <ComboBoxItem Content="Execution Count Delta" Tag="DeltaExecutions"/> - <ComboBoxItem Content="Total CPU (ms)" Tag="DeltaCpuMs"/> - <ComboBoxItem Content="Total Logical Reads" Tag="DeltaLogicalReads"/> - <ComboBoxItem Content="Total Spills" Tag="TotalSpills"/> + <ComboBoxItem Content="CPU Delta (ms)" Tag="DeltaCpuMs"/> + <ComboBoxItem Content="Logical Reads Delta" Tag="DeltaLogicalReads"/> + <ComboBoxItem Content="Spills Delta" Tag="DeltaSpills"/> </ComboBox> <scottplot:WpfPlot x:Name="HistoryChart" Margin="0,0,0,0"/> </Grid> @@ -78,23 +78,57 @@ ContextMenu="{StaticResource DataGridContextMenu}"> <DataGrid.Columns> <DataGridTextColumn Header="Collection Time" Binding="{Binding CollectionTimeLocal}" Width="130"/> + <DataGridTextColumn Header="Last Execution" Binding="{Binding LastExecutionTimeLocal}" Width="130"/> + <DataGridTextColumn Header="Creation Time" Binding="{Binding CreationTimeLocal}" Width="130"/> <DataGridTextColumn Header="Exec Delta" Binding="{Binding DeltaExecutions, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Total Executions" Binding="{Binding TotalExecutions, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"/> <DataGridTextColumn Header="CPU Delta (ms)" Binding="{Binding DeltaCpuMs, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="110"/> <DataGridTextColumn Header="Duration Delta (ms)" Binding="{Binding DeltaElapsedMs, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="130"/> <DataGridTextColumn Header="Avg CPU (ms)" Binding="{Binding AvgCpuMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> <DataGridTextColumn Header="Avg Duration (ms)" Binding="{Binding AvgElapsedMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Total CPU (ms)" Binding="{Binding TotalCpuMs, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="Total Duration (ms)" Binding="{Binding TotalElapsedMs, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="130"/> <DataGridTextColumn Header="Logical Reads" Binding="{Binding DeltaLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"/> <DataGridTextColumn Header="Avg Reads" Binding="{Binding AvgReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Total Logical Reads" Binding="{Binding TotalLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="125"/> <DataGridTextColumn Header="Rows" Binding="{Binding DeltaRows, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="80"/> + <DataGridTextColumn Header="Avg Rows" Binding="{Binding AvgRows, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Total Rows" Binding="{Binding TotalRows, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="95"/> <DataGridTextColumn Header="Writes" Binding="{Binding DeltaLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="80"/> + <DataGridTextColumn Header="Avg Writes" Binding="{Binding AvgWrites, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="95"/> + <DataGridTextColumn Header="Total Writes" Binding="{Binding TotalLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"/> <DataGridTextColumn Header="Physical Reads" Binding="{Binding DeltaPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Avg Phys Reads" Binding="{Binding AvgPhysicalReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="Total Phys Reads" Binding="{Binding TotalPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"/> <DataGridTextColumn Header="Spills" Binding="{Binding DeltaSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="70"/> + <DataGridTextColumn Header="Total Spills" Binding="{Binding TotalSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="95"/> <DataGridTextColumn Header="Min CPU (ms)" Binding="{Binding MinCpuMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> <DataGridTextColumn Header="Max CPU (ms)" Binding="{Binding MaxCpuMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> <DataGridTextColumn Header="Min Duration (ms)" Binding="{Binding MinElapsedMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"/> <DataGridTextColumn Header="Max Duration (ms)" Binding="{Binding MaxElapsedMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"/> <DataGridTextColumn Header="Min DOP" Binding="{Binding MinDop}" Width="70"/> <DataGridTextColumn Header="Max DOP" Binding="{Binding MaxDop}" Width="70"/> + <DataGridTextColumn Header="Min Phys Reads" Binding="{Binding MinPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="Max Phys Reads" Binding="{Binding MaxPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="Min Rows" Binding="{Binding MinRows, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="80"/> + <DataGridTextColumn Header="Max Rows" Binding="{Binding MaxRows, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="80"/> + <DataGridTextColumn Header="Min Spills" Binding="{Binding MinSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="80"/> + <DataGridTextColumn Header="Max Spills" Binding="{Binding MaxSpills, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="80"/> + <DataGridTextColumn Header="Min Grant KB" Binding="{Binding MinGrantKb, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Max Grant KB" Binding="{Binding MaxGrantKb, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Min Used Grant KB" Binding="{Binding MinUsedGrantKb, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Max Used Grant KB" Binding="{Binding MaxUsedGrantKb, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Min Ideal Grant KB" Binding="{Binding MinIdealGrantKb, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Max Ideal Grant KB" Binding="{Binding MaxIdealGrantKb, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Min Reserved Threads" Binding="{Binding MinReservedThreads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="130"/> + <DataGridTextColumn Header="Max Reserved Threads" Binding="{Binding MaxReservedThreads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="130"/> + <DataGridTextColumn Header="Min Used Threads" Binding="{Binding MinUsedThreads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Max Used Threads" Binding="{Binding MaxUsedThreads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Total CLR (ms)" Binding="{Binding TotalClrMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="110"/> + <DataGridTextColumn Header="Interval (sec)" Binding="{Binding SampleIntervalSeconds}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="SQL Handle" Binding="{Binding SqlHandle}" Width="140"/> + <DataGridTextColumn Header="Plan Handle" Binding="{Binding PlanHandle}" Width="140"/> + <DataGridTextColumn Header="Query Hash" Binding="{Binding QueryHash}" Width="140"/> <DataGridTextColumn Header="Plan Hash" Binding="{Binding QueryPlanHash}" Width="140"/> <DataGridTemplateColumn Header="Plan" Width="Auto" MinWidth="80"> <DataGridTemplateColumn.CellTemplate> diff --git a/Lite/Windows/QueryStatsHistoryWindow.xaml.cs b/Lite/Windows/QueryStatsHistoryWindow.xaml.cs index e7d5d1c58..e0f93bb50 100644 --- a/Lite/Windows/QueryStatsHistoryWindow.xaml.cs +++ b/Lite/Windows/QueryStatsHistoryWindow.xaml.cs @@ -13,11 +13,14 @@ using System.Text; using System.Windows; using System.Windows.Controls; +using System.Windows.Controls.Primitives; using Microsoft.Win32; +using PerformanceMonitorLite.Controls; using PerformanceMonitorLite.Services; using ScottPlot; using PerformanceMonitor.Common; using PerformanceMonitor.Ui; +using PerformanceMonitor.PlanAnalysis; namespace PerformanceMonitorLite.Windows; @@ -32,6 +35,10 @@ public partial class QueryStatsHistoryWindow : Window private readonly string? _queryText; private readonly PlanNavigationController _planActions; private List<QueryStatsHistoryRow> _historyData = new(); + private ChartHoverHelper? _chartHover; + private DataGridFilterManager<QueryStatsHistoryRow>? _filterManager; + private Popup? _filterPopup; + private ColumnFilterPopup? _filterPopupContent; public QueryStatsHistoryWindow(LocalDataService dataService, int serverId, string databaseName, string queryHash, int hoursBack, string? queryText = null, string? connectionString = null) { @@ -48,9 +55,14 @@ public QueryStatsHistoryWindow(LocalDataService dataService, int serverId, strin this, (xml, label, qt) => PlanViewerWindow.ShowPlanAsync(this, xml, label, qt), (db, qt, est, iso, ct) => ActualPlanExecutor.ExecuteForActualPlanAsync( - _connectionString ?? "", db, qt, est, iso, isAzureSqlDb: false, timeoutSeconds: 0, ct), + _connectionString ?? "", db, qt, est, iso, isAzureSqlDb: false, timeoutSeconds: 0, ct, + productName: "SQL Server Performance Monitor Lite"), "the monitored server"); + _filterManager = new DataGridFilterManager<QueryStatsHistoryRow>(HistoryDataGrid); + DataGridFilterColumns.AddFilterButtons(HistoryDataGrid, Filter_Click); + _filterManager.UpdateFilterButtonStyles(); + QueryIdentifierText.Text = $"Query Stats History: {queryHash} in [{databaseName}]"; Loaded += async (_, _) => await LoadHistoryAsync(); ThemeManager.ThemeChanged += OnThemeChanged; @@ -62,7 +74,7 @@ private async System.Threading.Tasks.Task LoadHistoryAsync() try { _historyData = await _dataService.GetQueryStatsHistoryAsync(_serverId, _databaseName, _queryHash, _hoursBack); - HistoryDataGrid.ItemsSource = _historyData; + _filterManager!.UpdateData(_historyData); if (_historyData.Count > 0) { @@ -109,6 +121,14 @@ private void UpdateChart() ChartStyle.StyleScatter(scatter); scatter.LegendText = label; + var unit = tag.Contains("Ms") ? "ms" : ""; + if (_chartHover == null) + _chartHover = new ChartHoverHelper(HistoryChart, unit); + else + _chartHover.Unit = unit; + _chartHover.Clear(); + _chartHover.Add(scatter, label); + HistoryChart.Plot.Axes.DateTimeTicksBottom(); ApplyTheme(HistoryChart); @@ -123,7 +143,7 @@ private void UpdateChart() "DeltaExecutions" => row.DeltaExecutions, "DeltaCpuMs" => row.DeltaCpuMs, "DeltaLogicalReads" => row.DeltaLogicalReads, - "TotalSpills" => row.DeltaSpills, + "DeltaSpills" => row.DeltaSpills, _ => row.AvgCpuMs }; @@ -194,12 +214,64 @@ private void OnThemeChanged(string _) { ApplyTheme(HistoryChart); HistoryChart.Refresh(); + _filterManager?.UpdateFilterButtonStyles(); + } + + #region Column Filter Popup + + private void EnsureFilterPopup() + { + if (_filterPopup == null) + { + _filterPopupContent = new ColumnFilterPopup(); + _filterPopup = new Popup + { + Child = _filterPopupContent, + StaysOpen = false, + Placement = PlacementMode.Bottom, + AllowsTransparency = true + }; + } + } + + private void Filter_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button button || button.Tag is not string columnName) return; + if (_filterManager == null) return; + + EnsureFilterPopup(); + + _filterPopupContent!.FilterApplied -= FilterPopup_FilterApplied; + _filterPopupContent.FilterCleared -= FilterPopup_FilterCleared; + _filterPopupContent.FilterApplied += FilterPopup_FilterApplied; + _filterPopupContent.FilterCleared += FilterPopup_FilterCleared; + + _filterManager.Filters.TryGetValue(columnName, out var existingFilter); + _filterPopupContent.Initialize(columnName, existingFilter); + + _filterPopup!.PlacementTarget = button; + _filterPopup.IsOpen = true; + } + + private void FilterPopup_FilterApplied(object? sender, FilterAppliedEventArgs e) + { + if (_filterPopup != null) + _filterPopup.IsOpen = false; + _filterManager?.SetFilter(e.FilterState); + } + + private void FilterPopup_FilterCleared(object? sender, EventArgs e) + { + if (_filterPopup != null) + _filterPopup.IsOpen = false; } - private void CopyCell_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyAllRows(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.ExportToCsv(sender, "query_stats_history"); + #endregion + + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => DataGridExport.ExportToCsv(sender, "query_stats_history", App.CsvSeparator); private async System.Threading.Tasks.Task<string?> FetchPlanAsync() { diff --git a/Lite/Windows/QueryStoreHistoryWindow.xaml b/Lite/Windows/QueryStoreHistoryWindow.xaml index 138235c56..d70df52e5 100644 --- a/Lite/Windows/QueryStoreHistoryWindow.xaml +++ b/Lite/Windows/QueryStoreHistoryWindow.xaml @@ -25,10 +25,10 @@ <MenuItem.Icon><TextBlock Text="📊"/></MenuItem.Icon> </MenuItem> <Separator/> - <MenuItem Header="View Plan" Click="ViewPlan_Click"> + <MenuItem Header="View Stored Plan" Click="ViewPlan_Click"> <MenuItem.Icon><TextBlock Text="🔍"/></MenuItem.Icon> </MenuItem> - <MenuItem Header="Get Actual Plan" Click="GetActualPlan_Click"> + <MenuItem Header="Get Actual Plan (re-run)" Click="GetActualPlan_Click"> <MenuItem.Icon><TextBlock Text="▶"/></MenuItem.Icon> </MenuItem> </ContextMenu> @@ -78,18 +78,57 @@ ContextMenu="{StaticResource DataGridContextMenu}"> <DataGrid.Columns> <DataGridTextColumn Header="Collection Time" Binding="{Binding CollectionTimeLocal}" Width="130"/> + <DataGridTextColumn Header="Plan ID" Binding="{Binding PlanId}" ElementStyle="{StaticResource NumericCell}" Width="70"/> + <DataGridTextColumn Header="Exec Type" Binding="{Binding ExecutionTypeDesc}" Width="90"/> + <DataGridTextColumn Header="First Execution" Binding="{Binding FirstExecutionTimeLocal}" Width="130"/> + <DataGridTextColumn Header="Last Execution" Binding="{Binding LastExecutionTimeLocal}" Width="130"/> + <DataGridTextColumn Header="Module" Binding="{Binding ModuleName}" Width="160"/> <DataGridTextColumn Header="Executions" Binding="{Binding ExecutionCount, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"/> <DataGridTextColumn Header="Total Duration (ms)" Binding="{Binding TotalDurationMs, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="130"/> <DataGridTextColumn Header="Avg Duration (ms)" Binding="{Binding AvgDurationMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Min Duration (ms)" Binding="{Binding MinDurationMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Max Duration (ms)" Binding="{Binding MaxDurationMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="120"/> <DataGridTextColumn Header="Total CPU (ms)" Binding="{Binding TotalCpuMs, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="110"/> <DataGridTextColumn Header="Avg CPU (ms)" Binding="{Binding AvgCpuTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Min CPU (ms)" Binding="{Binding MinCpuTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Max CPU (ms)" Binding="{Binding MaxCpuTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Avg CLR (ms)" Binding="{Binding AvgClrTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Min CLR (ms)" Binding="{Binding MinClrTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Max CLR (ms)" Binding="{Binding MaxClrTimeMs, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> <DataGridTextColumn Header="Avg Reads" Binding="{Binding AvgLogicalReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Min Reads" Binding="{Binding MinLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Max Reads" Binding="{Binding MaxLogicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"/> <DataGridTextColumn Header="Avg Writes" Binding="{Binding AvgLogicalWrites, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Min Writes" Binding="{Binding MinLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Max Writes" Binding="{Binding MaxLogicalWrites, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Avg Log (MB)" Binding="{Binding AvgLogMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Min Log (MB)" Binding="{Binding MinLogMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Max Log (MB)" Binding="{Binding MaxLogMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"/> <DataGridTextColumn Header="Avg Physical Reads" Binding="{Binding AvgPhysicalReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Min Physical Reads" Binding="{Binding MinPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Max Physical Reads" Binding="{Binding MaxPhysicalReads, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="120"/> + <DataGridTextColumn Header="Avg Num Physical Reads" Binding="{Binding AvgNumPhysicalReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="150"/> + <DataGridTextColumn Header="Min Num Physical Reads" Binding="{Binding MinNumPhysicalReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="150"/> + <DataGridTextColumn Header="Max Num Physical Reads" Binding="{Binding MaxNumPhysicalReads, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="150"/> <DataGridTextColumn Header="Avg Rows" Binding="{Binding AvgRowcount, StringFormat=N1}" ElementStyle="{StaticResource NumericCell}" Width="90"/> - <DataGridTextColumn Header="Last Execution" Binding="{Binding LastExecutionTimeLocal}" Width="130"/> + <DataGridTextColumn Header="Min Rows" Binding="{Binding MinRowcount, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="85"/> + <DataGridTextColumn Header="Max Rows" Binding="{Binding MaxRowcount, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="85"/> + <DataGridTextColumn Header="Avg Memory (MB)" Binding="{Binding AvgMemoryMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Min Memory (MB)" Binding="{Binding MinMemoryMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Max Memory (MB)" Binding="{Binding MaxMemoryMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Avg tempdb (MB)" Binding="{Binding AvgTempdbMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Min tempdb (MB)" Binding="{Binding MinTempdbMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"/> + <DataGridTextColumn Header="Max tempdb (MB)" Binding="{Binding MaxTempdbMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"/> <DataGridTextColumn Header="Min DOP" Binding="{Binding MinDop}" Width="70"/> <DataGridTextColumn Header="Max DOP" Binding="{Binding MaxDop}" Width="70"/> + <DataGridTextColumn Header="Plan Type" Binding="{Binding PlanType}" Width="100"/> + <DataGridCheckBoxColumn Header="Forced" Binding="{Binding IsForcedPlan}" Width="60"/> + <DataGridTextColumn Header="Force Failures" Binding="{Binding ForceFailureCount, StringFormat=N0}" ElementStyle="{StaticResource NumericCell}" Width="100"/> + <DataGridTextColumn Header="Last Failure Reason" Binding="{Binding LastForceFailureReason}" Width="150"/> + <DataGridTextColumn Header="Forcing Type" Binding="{Binding PlanForcingType}" Width="100"/> + <DataGridTextColumn Header="Compat Level" Binding="{Binding CompatibilityLevel}" ElementStyle="{StaticResource NumericCell}" Width="90"/> + <DataGridTextColumn Header="Query Hash" Binding="{Binding QueryHash}" Width="140"/> + <DataGridTextColumn Header="Query Plan Hash" Binding="{Binding QueryPlanHash}" Width="140"/> <DataGridTemplateColumn Header="Plan" Width="Auto" MinWidth="80"> <DataGridTemplateColumn.CellTemplate> <DataTemplate> diff --git a/Lite/Windows/QueryStoreHistoryWindow.xaml.cs b/Lite/Windows/QueryStoreHistoryWindow.xaml.cs index 567cfc263..3975fde78 100644 --- a/Lite/Windows/QueryStoreHistoryWindow.xaml.cs +++ b/Lite/Windows/QueryStoreHistoryWindow.xaml.cs @@ -13,11 +13,14 @@ using System.Text; using System.Windows; using System.Windows.Controls; +using System.Windows.Controls.Primitives; using Microsoft.Win32; +using PerformanceMonitorLite.Controls; using PerformanceMonitorLite.Services; using ScottPlot; using PerformanceMonitor.Common; using PerformanceMonitor.Ui; +using PerformanceMonitor.PlanAnalysis; namespace PerformanceMonitorLite.Windows; @@ -33,6 +36,11 @@ public partial class QueryStoreHistoryWindow : Window private readonly string _queryText; private readonly PlanNavigationController _planActions; private List<QueryStoreHistoryRow> _historyData = new(); + private ChartHoverHelper? _chartHover; + private ScottPlot.IPanel? _legendPanel; + private DataGridFilterManager<QueryStoreHistoryRow>? _filterManager; + private Popup? _filterPopup; + private ColumnFilterPopup? _filterPopupContent; public QueryStoreHistoryWindow(LocalDataService dataService, int serverId, string databaseName, long queryId, long planId, string queryText, int hoursBack, string? connectionString = null) { @@ -50,11 +58,16 @@ public QueryStoreHistoryWindow(LocalDataService dataService, int serverId, strin this, (xml, label, qt) => PlanViewerWindow.ShowPlanAsync(this, xml, label, qt), (db, qt, est, iso, ct) => ActualPlanExecutor.ExecuteForActualPlanAsync( - _connectionString ?? "", db, qt, est, iso, isAzureSqlDb: false, timeoutSeconds: 0, ct), + _connectionString ?? "", db, qt, est, iso, isAzureSqlDb: false, timeoutSeconds: 0, ct, + productName: "SQL Server Performance Monitor Lite"), "the monitored server"); + _filterManager = new DataGridFilterManager<QueryStoreHistoryRow>(HistoryDataGrid); + DataGridFilterColumns.AddFilterButtons(HistoryDataGrid, Filter_Click); + _filterManager.UpdateFilterButtonStyles(); + var displayText = queryText.Length > 120 ? queryText[..120] + "..." : queryText; - QueryIdentifierText.Text = $"Query Store History: Query {queryId}, Plan {planId} in [{databaseName}]"; + QueryIdentifierText.Text = $"Query Store History: Query {queryId} in [{databaseName}]"; SummaryText.Text = displayText; Loaded += async (_, _) => await LoadHistoryAsync(); ThemeManager.ThemeChanged += OnThemeChanged; @@ -65,16 +78,18 @@ private async System.Threading.Tasks.Task LoadHistoryAsync() { try { - _historyData = await _dataService.GetQueryStoreHistoryAsync(_serverId, _databaseName, _queryId, _planId, _hoursBack); - HistoryDataGrid.ItemsSource = _historyData; + _historyData = await _dataService.GetQueryStoreHistoryAsync(_serverId, _databaseName, _queryId, _hoursBack); + _filterManager!.UpdateData(_historyData); if (_historyData.Count > 0) { var totalExec = _historyData.Sum(r => r.ExecutionCount); + var planCount = _historyData.Select(r => r.PlanId).Distinct().Count(); var first = _historyData.First().CollectionTime.AddMinutes(Services.ServerTimeHelper.UtcOffsetMinutes); var last = _historyData.Last().CollectionTime.AddMinutes(Services.ServerTimeHelper.UtcOffsetMinutes); SummaryText.Text = $"{_historyData.Count} samples from {first:MM/dd HH:mm} to {last:MM/dd HH:mm} | " + - $"Total Executions: {totalExec:N0}"; + $"Total Executions: {totalExec:N0} | " + + (planCount > 1 ? $"{planCount} different plans" : "Single plan"); } else { @@ -98,21 +113,50 @@ private void UpdateChart() return; } + if (_legendPanel != null) + { + HistoryChart.Plot.Axes.Remove(_legendPanel); + _legendPanel = null; + } HistoryChart.Plot.Clear(); var selected = MetricSelector.SelectedItem as ComboBoxItem; var tag = selected?.Tag?.ToString() ?? "AvgCpuTimeMs"; var label = selected?.Content?.ToString() ?? "Avg CPU (ms)"; - var xs = _historyData.Select(r => r.CollectionTime.AddMinutes(Services.ServerTimeHelper.UtcOffsetMinutes).ToOADate()).ToArray(); - var ys = _historyData.Select(r => GetMetricValue(r, tag)).ToArray(); + var unit = tag.Contains("Ms") ? "ms" : ""; + if (_chartHover == null) + _chartHover = new ChartHoverHelper(HistoryChart, unit); + else + _chartHover.Unit = unit; + _chartHover.Clear(); - var scatter = HistoryChart.Plot.Add.Scatter(xs, ys); - scatter.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("MetricTrend")); - ChartStyle.StyleScatter(scatter); - scatter.LegendText = label; + // One series per plan so plan switches/regressions are visible — same as the Dashboard drilldown. + var planGroups = _historyData.GroupBy(r => r.PlanId).OrderBy(g => g.Key).ToList(); + var colors = ChartPalette.CyclingPalette.Select(ScottPlot.Color.FromHex).ToArray(); + + int colorIndex = 0; + foreach (var planGroup in planGroups) + { + var ordered = planGroup.OrderBy(r => r.CollectionTime).ToList(); + var xs = ordered.Select(r => r.CollectionTime.AddMinutes(Services.ServerTimeHelper.UtcOffsetMinutes).ToOADate()).ToArray(); + var ys = ordered.Select(r => GetMetricValue(r, tag)).ToArray(); + + var scatter = HistoryChart.Plot.Add.Scatter(xs, ys); + scatter.Color = colors[colorIndex % colors.Length]; + ChartStyle.StyleScatter(scatter); + var seriesLabel = planGroups.Count > 1 ? $"Plan {planGroup.Key}" : label; + scatter.LegendText = seriesLabel; + _chartHover.Add(scatter, seriesLabel); + colorIndex++; + } HistoryChart.Plot.Axes.DateTimeTicksBottom(); + if (planGroups.Count > 1) + { + _legendPanel = HistoryChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); + HistoryChart.Plot.Legend.FontSize = 12; + } ApplyTheme(HistoryChart); HistoryChart.Refresh(); @@ -135,13 +179,15 @@ private void UpdateChart() private async void DownloadPlan_Click(object sender, RoutedEventArgs e) { if (sender is not Button btn) return; - if (string.IsNullOrEmpty(_connectionString) || string.IsNullOrEmpty(_databaseName) || _planId == 0) return; + // Rows can now span multiple plans — download the plan for THIS row, not the launching one. + var rowPlanId = (btn.DataContext as QueryStoreHistoryRow)?.PlanId ?? _planId; + if (string.IsNullOrEmpty(_connectionString) || string.IsNullOrEmpty(_databaseName) || rowPlanId == 0) return; btn.IsEnabled = false; btn.Content = "..."; try { - var plan = await LocalDataService.FetchQueryStorePlanAsync(_connectionString, _databaseName, _planId); + var plan = await LocalDataService.FetchQueryStorePlanAsync(_connectionString, _databaseName, rowPlanId); if (string.IsNullOrEmpty(plan)) { MessageBox.Show("No query plan found in Query Store for this plan ID.", "Plan Not Found", MessageBoxButton.OK, MessageBoxImage.Information); @@ -152,7 +198,7 @@ private async void DownloadPlan_Click(object sender, RoutedEventArgs e) { Filter = "SQL Plan files (*.sqlplan)|*.sqlplan|All files (*.*)|*.*", DefaultExt = ".sqlplan", - FileName = $"qs_plan_{_queryId}_{_planId}_{DateTime.Now:yyyyMMdd_HHmmss}.sqlplan" + FileName = $"qs_plan_{_queryId}_{rowPlanId}_{DateTime.Now:yyyyMMdd_HHmmss}.sqlplan" }; if (dialog.ShowDialog() != true) return; @@ -175,21 +221,79 @@ private void OnThemeChanged(string _) { ApplyTheme(HistoryChart); HistoryChart.Refresh(); + _filterManager?.UpdateFilterButtonStyles(); + } + + #region Column Filter Popup + + private void EnsureFilterPopup() + { + if (_filterPopup == null) + { + _filterPopupContent = new ColumnFilterPopup(); + _filterPopup = new Popup + { + Child = _filterPopupContent, + StaysOpen = false, + Placement = PlacementMode.Bottom, + AllowsTransparency = true + }; + } } - private void CopyCell_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyAllRows(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.ExportToCsv(sender, "query_store_history"); + private void Filter_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button button || button.Tag is not string columnName) return; + if (_filterManager == null) return; + + EnsureFilterPopup(); + + _filterPopupContent!.FilterApplied -= FilterPopup_FilterApplied; + _filterPopupContent.FilterCleared -= FilterPopup_FilterCleared; + _filterPopupContent.FilterApplied += FilterPopup_FilterApplied; + _filterPopupContent.FilterCleared += FilterPopup_FilterCleared; + + _filterManager.Filters.TryGetValue(columnName, out var existingFilter); + _filterPopupContent.Initialize(columnName, existingFilter); + + _filterPopup!.PlacementTarget = button; + _filterPopup.IsOpen = true; + } + + private void FilterPopup_FilterApplied(object? sender, FilterAppliedEventArgs e) + { + if (_filterPopup != null) + _filterPopup.IsOpen = false; + _filterManager?.SetFilter(e.FilterState); + } - private async System.Threading.Tasks.Task<string?> FetchPlanAsync() + private void FilterPopup_FilterCleared(object? sender, EventArgs e) { - if (string.IsNullOrEmpty(_connectionString) || _planId == 0) return null; - return await LocalDataService.FetchQueryStorePlanAsync(_connectionString, _databaseName, _planId); + if (_filterPopup != null) + _filterPopup.IsOpen = false; + } + + #endregion + + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => DataGridExport.ExportToCsv(sender, "query_store_history", App.CsvSeparator); + + private long SelectedPlanId => + ((HistoryDataGrid.CurrentItem ?? HistoryDataGrid.SelectedItem) as QueryStoreHistoryRow)?.PlanId ?? _planId; + + private async System.Threading.Tasks.Task<string?> FetchPlanAsync(long planId) + { + if (string.IsNullOrEmpty(_connectionString) || planId == 0) return null; + return await LocalDataService.FetchQueryStorePlanAsync(_connectionString, _databaseName, planId); } private async void ViewPlan_Click(object sender, RoutedEventArgs e) - => await _planActions.ViewPlanAsync(FetchPlanAsync, $"Est Plan - QS {_queryId}/{_planId}", _queryText); + { + var planId = SelectedPlanId; + await _planActions.ViewPlanAsync(() => FetchPlanAsync(planId), $"Est Plan - QS {_queryId}/{planId}", _queryText); + } private async void GetActualPlan_Click(object sender, RoutedEventArgs e) => await _planActions.GetActualPlanAsync(_queryText, _databaseName, $"Actual Plan - QS {_queryId}"); diff --git a/Lite/Windows/SettingsWindow.xaml b/Lite/Windows/SettingsWindow.xaml index b076ff541..b94d06b5d 100644 --- a/Lite/Windows/SettingsWindow.xaml +++ b/Lite/Windows/SettingsWindow.xaml @@ -216,7 +216,7 @@ Content="Exclude CDC capture jobs (sp_MScdc_capture_job, sp_cdc_scan)" Foreground="{DynamicResource ForegroundBrush}" Margin="20,0,0,0"/> <StackPanel Orientation="Horizontal" Margin="20,6,0,0"> - <CheckBox x:Name="AlertTempDbSpaceCheckBox" Content="TempDB space usage over" VerticalAlignment="Center" + <CheckBox x:Name="AlertTempDbSpaceCheckBox" Content="tempdb space usage over" VerticalAlignment="Center" Foreground="{DynamicResource ForegroundBrush}"/> <TextBox x:Name="AlertTempDbSpaceThresholdBox" Width="45" Text="80" Margin="6,0,0,0" VerticalAlignment="Center"/> <TextBlock Text="%" VerticalAlignment="Center" Margin="2,0,0,0" @@ -484,6 +484,72 @@ </Hyperlink> </TextBlock> + <!-- Generic webhook --> + <TextBlock Text="Generic Webhook (HTTP POST)" FontSize="14" FontWeight="SemiBold" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,20,0,0"/> + <TextBlock Text="POST alerts as JSON to any endpoint — PagerDuty, Opsgenie, n8n, or a GitHub repository_dispatch that re-runs a workflow. Use this to trigger automation when an alert fires (for example, re-adding your IP to an Azure SQL firewall rule after a connection is lost)." + FontSize="12" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,4,0,0" TextWrapping="Wrap"/> + <CheckBox x:Name="GenericWebhookEnabledCheckBox" Content="Enable generic webhook" Margin="0,8,0,0" + Foreground="{DynamicResource ForegroundBrush}" Checked="GenericWebhookEnabledCheckBox_Changed" Unchecked="GenericWebhookEnabledCheckBox_Changed"/> + <Grid Margin="20,6,0,0"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="110"/> + <ColumnDefinition Width="*"/> + </Grid.ColumnDefinitions> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + <TextBlock Grid.Row="0" Grid.Column="0" Text="URL:" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,8,4"/> + <TextBox Grid.Row="0" Grid.Column="1" x:Name="GenericWebhookUrlBox" Margin="0,0,0,4" + ToolTip="The endpoint to POST to, e.g. https://api.github.com/repos/OWNER/REPO/dispatches"/> + + <TextBlock Grid.Row="1" Grid.Column="0" Text="Headers (JSON):" VerticalAlignment="Top" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,4,8,4"/> + <TextBox Grid.Row="1" Grid.Column="1" x:Name="GenericWebhookHeadersBox" Margin="0,0,0,4" + AcceptsReturn="True" TextWrapping="Wrap" MinHeight="44" VerticalScrollBarVisibility="Auto" + ToolTip="A JSON object of request headers. Stored in Windows Credential Manager — it carries your token."/> + + <TextBlock Grid.Row="2" Grid.Column="0" Text="Body (JSON):" VerticalAlignment="Top" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,4,8,4"/> + <TextBox Grid.Row="2" Grid.Column="1" x:Name="GenericWebhookBodyBox" Margin="0,0,0,4" + AcceptsReturn="True" TextWrapping="Wrap" MinHeight="80" VerticalScrollBarVisibility="Auto" + FontFamily="Consolas" + ToolTip="The JSON body to POST. Leave blank to use the built-in default. Placeholders are substituted and JSON-escaped."/> + + <TextBlock Grid.Row="3" Grid.Column="0" Text="Proxy Address:" VerticalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,8,4"/> + <TextBox Grid.Row="3" Grid.Column="1" x:Name="GenericWebhookProxyAddressBox" Margin="0,0,0,4" + ToolTip="Optional HTTP proxy (e.g. http://proxy.corp:8080). Leave blank for direct connection."/> + + <StackPanel Grid.Row="4" Grid.Column="1" Orientation="Horizontal" Margin="0,4,0,0"> + <Button x:Name="TestGenericButton" Content="Send Test Notification" Click="TestGenericButton_Click"/> + <TextBlock x:Name="GenericStatusText" FontSize="11" FontStyle="Italic" + Foreground="{DynamicResource ForegroundMutedBrush}" VerticalAlignment="Center" Margin="8,0,0,0"/> + </StackPanel> + </Grid> + <TextBlock Margin="20,8,0,0" TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}"> + <Run Text="Placeholders (each is JSON-escaped when substituted): "/> + <Run FontFamily="Consolas" Text="{}{{metric}} {{server}} {{value}} {{threshold}} {{severity}} {{context}} {{timestamp}}"/> + </TextBlock> + <TextBlock Margin="20,6,0,0" TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}"> + <Run FontWeight="SemiBold" Text="Example — re-run a GitHub Actions workflow when an alert fires:"/> + <LineBreak/> + <Run FontFamily="Consolas" Text="URL: https://api.github.com/repos/OWNER/REPO/dispatches"/> + <LineBreak/> + <Run FontFamily="Consolas" Text="{}Headers: {"Authorization": "Bearer ghp_YOUR_TOKEN", "Accept": "application/vnd.github+json"}"/> + <LineBreak/> + <Run FontFamily="Consolas" Text="{}Body: {"event_type": "sql-monitor-alert", "client_payload": {"metric": "{{metric}}", "server": "{{server}}"}}"/> + <LineBreak/> + <Run Text="The repository's workflow triggers on "/> + <Run FontFamily="Consolas" Text="repository_dispatch"/> + <Run Text=". The token needs Contents: write on that repository."/> + </TextBlock> + <!-- Severity Color Reference --> <TextBlock Text="Alert Severity Colors" FontWeight="SemiBold" FontSize="12" Foreground="{DynamicResource ForegroundBrush}" Margin="0,16,0,4"/> @@ -491,7 +557,7 @@ <Run Foreground="#DC2626" FontWeight="Bold" Text="■ Red" /> — Critical (Deadlocks, Poison Waits, Server Unreachable) </TextBlock> <TextBlock TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,0,0,2"> - <Run Foreground="#D97706" FontWeight="Bold" Text="■ Orange" /> — Alert / Warning (Blocking, Long-Running Queries, TempDB, Jobs) + <Run Foreground="#D97706" FontWeight="Bold" Text="■ Orange" /> — Alert / Warning (Blocking, Long-Running Queries, tempdb, Jobs) </TextBlock> <TextBlock TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}" Margin="0,0,0,2"> <Run Foreground="#F59E0B" FontWeight="Bold" Text="■ Yellow" /> — Warning (High CPU) @@ -500,7 +566,7 @@ <Run Foreground="#16A34A" FontWeight="Bold" Text="■ Green" /> — Resolved (Server Restored) </TextBlock> <TextBlock TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource ForegroundMutedBrush}"> - <Run Foreground="#2eaef1" FontWeight="Bold" Text="■ Blue" /> — Info + <Run Foreground="{DynamicResource InfoBrush}" FontWeight="Bold" Text="■ Blue" /> — Info </TextBlock> </StackPanel> </Border> @@ -527,6 +593,7 @@ Background="Transparent" BorderThickness="0" HeadersVisibility="Column" GridLinesVisibility="Horizontal" MinHeight="80" MaxHeight="200" + ContextMenu="{StaticResource DataGridContextMenu}" MouseDoubleClick="ServerScheduleGrid_DoubleClick"> <DataGrid.Columns> <DataGridTextColumn Header="Server" Binding="{Binding ServerName}" Width="*"/> diff --git a/Lite/Windows/SettingsWindow.xaml.cs b/Lite/Windows/SettingsWindow.xaml.cs index 734efd911..30af13c6b 100644 --- a/Lite/Windows/SettingsWindow.xaml.cs +++ b/Lite/Windows/SettingsWindow.xaml.cs @@ -217,12 +217,12 @@ private async void SaveButton_Click(object sender, RoutedEventArgs e) SaveTimeDisplayMode(); bool alertsValid = SaveAlertSettings(); SaveSmtpSettings(); - SaveWebhookSettings(); + bool webhooksValid = SaveWebhookSettings(); _saved = true; if (mcpChanged) McpSettingsChanged = true; - if (!alertsValid || !mcpValid) return; + if (!alertsValid || !mcpValid || !webhooksValid) return; var message = mcpChanged ? "Settings saved. MCP changes take effect after restarting the application." @@ -318,6 +318,13 @@ private void LoadDefaultTimeRange() }; } + /// <summary> + /// Delegates to <see cref="App.WriteSetting"/> — the single read/merge/write/catch home for + /// settings.json single-value updates (now shared with MainWindow's Overview sort selector). Kept as a + /// thin alias so the existing Save* call sites and their JsonNode mutate lambdas are untouched. + /// </summary> + private static void WriteSetting(string what, Action<JsonNode> mutate) => App.WriteSetting(what, mutate); + private void SaveDefaultTimeRange() { var hours = DefaultTimeRangeCombo.SelectedIndex switch @@ -332,29 +339,7 @@ private void SaveDefaultTimeRange() App.DefaultTimeRangeHours = hours; - var settingsPath = Path.Combine(App.ConfigDirectory, "settings.json"); - try - { - JsonNode? root; - if (File.Exists(settingsPath)) - { - var json = File.ReadAllText(settingsPath); - root = JsonNode.Parse(json) ?? new JsonObject(); - } - else - { - root = new JsonObject(); - } - - root["default_time_range_hours"] = hours; - - var options = new JsonSerializerOptions { WriteIndented = true }; - File.WriteAllText(settingsPath, root.ToJsonString(options)); - } - catch (Exception ex) - { - AppLogger.Error("Settings", $"Failed to save default time range: {ex.Message}"); - } + WriteSetting("default time range", root => root["default_time_range_hours"] = hours); } private void CopyMcpCommandButton_Click(object sender, RoutedEventArgs e) @@ -392,29 +377,7 @@ private void SaveConnectionTimeout() App.ConnectionTimeoutSeconds = timeout; } - var settingsPath = Path.Combine(App.ConfigDirectory, "settings.json"); - try - { - JsonNode? root; - if (File.Exists(settingsPath)) - { - var json = File.ReadAllText(settingsPath); - root = JsonNode.Parse(json) ?? new JsonObject(); - } - else - { - root = new JsonObject(); - } - - root["connection_timeout_seconds"] = App.ConnectionTimeoutSeconds; - - var options = new JsonSerializerOptions { WriteIndented = true }; - File.WriteAllText(settingsPath, root.ToJsonString(options)); - } - catch (Exception ex) - { - AppLogger.Error("Settings", $"Failed to save connection timeout: {ex.Message}"); - } + WriteSetting("connection timeout", root => root["connection_timeout_seconds"] = App.ConnectionTimeoutSeconds); } private void LoadCsvSeparator() @@ -438,29 +401,7 @@ private void SaveCsvSeparator() App.CsvSeparator = sep; } - var settingsPath = Path.Combine(App.ConfigDirectory, "settings.json"); - try - { - JsonNode? root; - if (File.Exists(settingsPath)) - { - var json = File.ReadAllText(settingsPath); - root = JsonNode.Parse(json) ?? new JsonObject(); - } - else - { - root = new JsonObject(); - } - - root["csv_separator"] = App.CsvSeparator; - - var options = new JsonSerializerOptions { WriteIndented = true }; - File.WriteAllText(settingsPath, root.ToJsonString(options)); - } - catch (Exception ex) - { - AppLogger.Error("Settings", $"Failed to save CSV separator: {ex.Message}"); - } + WriteSetting("CSV separator", root => root["csv_separator"] = App.CsvSeparator); } private bool _isLoadingTheme; @@ -499,29 +440,7 @@ private void SaveColorTheme() ThemeManager.Apply(theme); } - var settingsPath = Path.Combine(App.ConfigDirectory, "settings.json"); - try - { - JsonNode? root; - if (File.Exists(settingsPath)) - { - var json = File.ReadAllText(settingsPath); - root = JsonNode.Parse(json) ?? new JsonObject(); - } - else - { - root = new JsonObject(); - } - - root["color_theme"] = App.ColorTheme; - - var options = new JsonSerializerOptions { WriteIndented = true }; - File.WriteAllText(settingsPath, root.ToJsonString(options)); - } - catch (Exception ex) - { - AppLogger.Error("Settings", $"Failed to save color theme: {ex.Message}"); - } + WriteSetting("color theme", root => root["color_theme"] = App.ColorTheme); } private void LoadTimeDisplayMode() @@ -547,29 +466,7 @@ private void SaveTimeDisplayMode() ServerTimeHelper.CurrentDisplayMode = tdm; } - var settingsPath = Path.Combine(App.ConfigDirectory, "settings.json"); - try - { - JsonNode? root; - if (File.Exists(settingsPath)) - { - var json = File.ReadAllText(settingsPath); - root = JsonNode.Parse(json) ?? new JsonObject(); - } - else - { - root = new JsonObject(); - } - - root["time_display_mode"] = App.TimeDisplayMode; - - var options = new JsonSerializerOptions { WriteIndented = true }; - File.WriteAllText(settingsPath, root.ToJsonString(options)); - } - catch (Exception ex) - { - AppLogger.Error("Settings", $"Failed to save time display mode: {ex.Message}"); - } + WriteSetting("time display mode", root => root["time_display_mode"] = App.TimeDisplayMode); } private void LoadAlertSettings() @@ -829,7 +726,7 @@ private void UpdateAlertPreviewText() if (AlertLongRunningQueryCheckBox.IsChecked == true) parts.Add($"queries > {AlertLongRunningQueryThresholdBox.Text}min"); if (AlertTempDbSpaceCheckBox.IsChecked == true) - parts.Add($"TempDB > {AlertTempDbSpaceThresholdBox.Text}%"); + parts.Add($"tempdb > {AlertTempDbSpaceThresholdBox.Text}%"); if (AlertLowDiskCheckBox.IsChecked == true) parts.Add($"disk free < {AlertLowDiskThresholdPercentBox.Text}% or {AlertLowDiskThresholdGbBox.Text}GB"); if (AlertLongRunningJobCheckBox.IsChecked == true) @@ -1037,7 +934,7 @@ private async void TestEmailButton_Click(object sender, RoutedEventArgs e) } // ============================================ - // Webhooks (Teams / Slack) + // Webhooks (Teams / Slack / Generic) // ============================================ private void LoadWebhookSettings() @@ -1048,22 +945,61 @@ private void LoadWebhookSettings() SlackWebhookEnabledCheckBox.IsChecked = App.SlackWebhookEnabled; SlackWebhookUrlBox.Text = App.SlackWebhookUrl; SlackProxyAddressBox.Text = App.SlackProxyAddress; + GenericWebhookEnabledCheckBox.IsChecked = App.GenericWebhookEnabled; + GenericWebhookUrlBox.Text = App.GenericWebhookUrl; + GenericWebhookHeadersBox.Text = App.GenericWebhookHeadersJson; + /* Blank means "use the built-in default" — show it, so the operator has something to edit + rather than a blank box they have to guess the shape of. */ + GenericWebhookBodyBox.Text = string.IsNullOrWhiteSpace(App.GenericWebhookBodyTemplate) + ? WebhookAlertService.DefaultGenericBodyTemplate + : App.GenericWebhookBodyTemplate; + GenericWebhookProxyAddressBox.Text = App.GenericWebhookProxyAddress; UpdateTeamsControlStates(); UpdateSlackControlStates(); + UpdateGenericControlStates(); } - private void SaveWebhookSettings() + /// <summary> + /// Persists the webhook settings. Returns false when the generic channel is enabled with a headers JSON + /// or body template that cannot produce a valid request — the values are still saved (so the operator + /// doesn't lose their typing), but they're told, because a broken template silently drops every alert. + /// Follows <see cref="SaveAlertSettings"/>'s bool contract: the caller suppresses the "Settings saved" toast. + /// </summary> + private bool SaveWebhookSettings() { + /* Cleartext warning (#1506): an http:// generic-webhook URL carrying headers sends the Authorization + token in the clear. Confirm before persisting a config that will transmit credentials unencrypted; + Yes proceeds, No cancels the webhook save (the typed values stay in the still-open dialog to fix). + NOT blocked — a plaintext POST to a trusted LAN listener is legitimate. */ + if (GenericWebhookEnabledCheckBox.IsChecked == true + && WebhookAlertService.IsCleartextHttpWithHeaders(GenericWebhookUrlBox.Text?.Trim(), GenericWebhookHeadersBox.Text?.Trim()) + && !ConfirmCleartextWebhook("Save")) + { + return false; + } + App.TeamsWebhookEnabled = TeamsWebhookEnabledCheckBox.IsChecked == true; App.TeamsWebhookUrl = TeamsWebhookUrlBox.Text?.Trim() ?? ""; App.TeamsProxyAddress = TeamsProxyAddressBox.Text?.Trim() ?? ""; App.SlackWebhookEnabled = SlackWebhookEnabledCheckBox.IsChecked == true; App.SlackWebhookUrl = SlackWebhookUrlBox.Text?.Trim() ?? ""; App.SlackProxyAddress = SlackProxyAddressBox.Text?.Trim() ?? ""; - - /* Save webhook URLs to Credential Manager instead of settings.json */ + App.GenericWebhookEnabled = GenericWebhookEnabledCheckBox.IsChecked == true; + App.GenericWebhookUrl = GenericWebhookUrlBox.Text?.Trim() ?? ""; + App.GenericWebhookHeadersJson = GenericWebhookHeadersBox.Text?.Trim() ?? ""; + /* Persist the empty "use built-in default" sentinel unless the operator actually edited the body box + (LoadWebhookSettings pre-fills it with the default), so a future release can still improve it. */ + App.GenericWebhookBodyTemplate = WebhookAlertService.IsDefaultBodyTemplate(GenericWebhookBodyBox.Text) + ? "" + : GenericWebhookBodyBox.Text?.Trim() ?? ""; + App.GenericWebhookProxyAddress = GenericWebhookProxyAddressBox.Text?.Trim() ?? ""; + + /* Save webhook URLs to Credential Manager instead of settings.json. The generic channel's headers + JSON goes there too — it carries the Authorization bearer token (#1506). */ App.SaveWebhookUrl("TeamsWebhook", App.TeamsWebhookUrl); App.SaveWebhookUrl("SlackWebhook", App.SlackWebhookUrl); + App.SaveWebhookUrl("GenericWebhook", App.GenericWebhookUrl); + App.SaveWebhookUrl("GenericWebhookHeaders", App.GenericWebhookHeadersJson); var settingsPath = Path.Combine(App.ConfigDirectory, "settings.json"); try @@ -1084,6 +1020,12 @@ private void SaveWebhookSettings() root["slack_webhook_enabled"] = App.SlackWebhookEnabled; root["slack_proxy_address"] = App.SlackProxyAddress; + /* The generic channel's URL + headers are secrets and live in Credential Manager; only these + three are safe to persist in settings.json (#1506). */ + root["generic_webhook_enabled"] = App.GenericWebhookEnabled; + root["generic_proxy_address"] = App.GenericWebhookProxyAddress; + root["generic_body_template"] = App.GenericWebhookBodyTemplate; + /* Remove legacy plaintext webhook URLs from settings.json */ if (root is JsonObject obj) { @@ -1098,6 +1040,22 @@ private void SaveWebhookSettings() { AppLogger.Error("Settings", $"Failed to save webhook settings: {ex.Message}"); } + + if (App.GenericWebhookEnabled) + { + var configError = WebhookAlertService.ValidateGenericConfig( + App.GenericWebhookHeadersJson, App.GenericWebhookBodyTemplate); + + if (configError != null) + { + MessageBox.Show( + $"The generic webhook is enabled but its configuration is not valid, so it will not deliver alerts:\n\n{configError}", + "Generic Webhook", MessageBoxButton.OK, MessageBoxImage.Warning); + return false; + } + } + + return true; } private void TeamsWebhookEnabledCheckBox_Changed(object sender, RoutedEventArgs e) @@ -1118,6 +1076,21 @@ private void UpdateTeamsControlStates() TestTeamsButton.IsEnabled = enabled; } + private void GenericWebhookEnabledCheckBox_Changed(object sender, RoutedEventArgs e) + { + UpdateGenericControlStates(); + } + + private void UpdateGenericControlStates() + { + bool enabled = GenericWebhookEnabledCheckBox.IsChecked == true; + GenericWebhookUrlBox.IsEnabled = enabled; + GenericWebhookHeadersBox.IsEnabled = enabled; + GenericWebhookBodyBox.IsEnabled = enabled; + GenericWebhookProxyAddressBox.IsEnabled = enabled; + TestGenericButton.IsEnabled = enabled; + } + private void UpdateSlackControlStates() { bool enabled = SlackWebhookEnabledCheckBox.IsChecked == true; @@ -1188,6 +1161,67 @@ private async void TestSlackButton_Click(object sender, RoutedEventArgs e) } } + /// <summary> + /// Non-blocking confirm shown when an http:// generic-webhook URL carries headers — the Authorization + /// token would go on the wire in cleartext (#1506). Yes proceeds; No cancels the action. <paramref + /// name="action"/> is the verb ("Save" / "Send"). Not blocked outright: a plaintext POST to a trusted LAN + /// listener is a legitimate setup. + /// </summary> + private bool ConfirmCleartextWebhook(string action) + { + var result = MessageBox.Show( + "The webhook URL uses http://, so the Authorization header and any credentials are sent in cleartext " + + $"and can be intercepted on the network.\n\n{action} anyway?", + "Insecure Webhook URL", MessageBoxButton.YesNo, MessageBoxImage.Warning); + return result == MessageBoxResult.Yes; + } + + /// <summary> + /// Tests the generic webhook with the values currently in the boxes (not the saved ones), like the + /// Teams/Slack test buttons. A malformed headers JSON or body template comes back as the error message + /// rather than an exception, so the operator fixes it here instead of discovering it when a real alert + /// silently fails to deliver. + /// </summary> + private async void TestGenericButton_Click(object sender, RoutedEventArgs e) + { + var url = GenericWebhookUrlBox.Text?.Trim() ?? ""; + var headers = GenericWebhookHeadersBox.Text?.Trim(); + + /* Warn before a cleartext http:// POST would send the Authorization header unencrypted (#1506). */ + if (WebhookAlertService.IsCleartextHttpWithHeaders(url, headers) && !ConfirmCleartextWebhook("Send")) + { + return; + } + + TestGenericButton.IsEnabled = false; + TestGenericButton.Content = "Sending..."; + + try + { + var body = GenericWebhookBodyBox.Text?.Trim(); + var proxy = GenericWebhookProxyAddressBox.Text?.Trim(); + var error = await WebhookAlertService.SendTestGenericAsync(url, headers, body, proxy, EmailAlertService.Branding); + + if (error == null) + { + MessageBox.Show("Generic webhook test notification sent successfully!", "Test Webhook", MessageBoxButton.OK, MessageBoxImage.Information); + } + else + { + MessageBox.Show($"Failed to send generic webhook test notification:\n\n{error}", "Test Webhook Failed", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + catch (Exception ex) + { + MessageBox.Show($"Failed to send generic webhook test notification:\n\n{ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + TestGenericButton.Content = "Send Test Notification"; + TestGenericButton.IsEnabled = true; + } + } + private void Hyperlink_RequestNavigate(object sender, RequestNavigateEventArgs e) { try @@ -1198,10 +1232,10 @@ private void Hyperlink_RequestNavigate(object sender, RequestNavigateEventArgs e e.Handled = true; } - private void CopyCell_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyAllRows(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.ExportToCsv(sender, "schedules"); + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => DataGridExport.ExportToCsv(sender, "schedules", App.CsvSeparator); private void CloseButton_Click(object sender, RoutedEventArgs e) { diff --git a/Lite/Windows/WaitDrillDownWindow.xaml b/Lite/Windows/WaitDrillDownWindow.xaml index 9df11860b..d5999a12b 100644 --- a/Lite/Windows/WaitDrillDownWindow.xaml +++ b/Lite/Windows/WaitDrillDownWindow.xaml @@ -22,10 +22,10 @@ <MenuItem.Icon><TextBlock Text="📊"/></MenuItem.Icon> </MenuItem> <Separator/> - <MenuItem Header="View Plan" Click="ViewPlan_Click"> + <MenuItem Header="View Stored Plan" Click="ViewPlan_Click"> <MenuItem.Icon><TextBlock Text="🔍"/></MenuItem.Icon> </MenuItem> - <MenuItem Header="Get Actual Plan" Click="GetActualPlan_Click"> + <MenuItem Header="Get Actual Plan (re-run)" Click="GetActualPlan_Click"> <MenuItem.Icon><TextBlock Text="▶"/></MenuItem.Icon> </MenuItem> </ContextMenu> @@ -114,6 +114,9 @@ <DataGridTextColumn Binding="{Binding BlockingSessionId}" ElementStyle="{StaticResource NumericCell}" Width="70"> <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BlockingSessionId" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Blocking" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding BlockedSessionCount}" ElementStyle="{StaticResource NumericCell}" Width="70"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="BlockedSessionCount" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Blocked" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> <DataGridTextColumn Binding="{Binding Dop}" ElementStyle="{StaticResource NumericCell}" Width="45"> <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="Dop" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="DOP" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> </DataGridTextColumn> @@ -123,6 +126,30 @@ <DataGridTextColumn Binding="{Binding GrantedQueryMemoryGb, StringFormat=F2}" ElementStyle="{StaticResource NumericCell}" Width="90"> <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="GrantedQueryMemoryGb" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Memory (GB)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding RequestedMemoryMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="RequestedMemoryMb" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Req Mem (MB)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding UsedMemoryMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="100"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="UsedMemoryMb" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Used Mem (MB)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding MaxUsedMemoryMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="130"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="MaxUsedMemoryMb" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Max Used Mem (MB)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TempdbCurrentMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="110"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TempdbCurrentMb" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="tempdb Cur (MB)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TempdbAllocationsMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="115"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TempdbAllocationsMb" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="tempdb Alloc (MB)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TranLogUsedMb, StringFormat=N2}" ElementStyle="{StaticResource NumericCell}" Width="105"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TranLogUsedMb" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Tran Log (MB)" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding TranStartTimeLocal}" Width="140"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TranStartTimeLocal" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Tran Start" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding RequestId}" ElementStyle="{StaticResource NumericCell}" Width="70"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="RequestId" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Req ID" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> <DataGridTextColumn Binding="{Binding TransactionIsolationLevel}" Width="140"> <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="TransactionIsolationLevel" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Isolation" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> </DataGridTextColumn> @@ -132,6 +159,9 @@ <DataGridTextColumn Binding="{Binding PercentComplete, StringFormat=F1}" ElementStyle="{StaticResource NumericCell}" Width="70"> <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="PercentComplete" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="% Done" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> </DataGridTextColumn> + <DataGridTextColumn Binding="{Binding QueryHash}" Width="150"> + <DataGridTextColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="QueryHash" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Query Hash" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTextColumn.Header> + </DataGridTextColumn> <DataGridTemplateColumn Width="500"> <DataGridTemplateColumn.Header><StackPanel Orientation="Horizontal"><Button Style="{DynamicResource ColumnFilterButtonStyle}" Tag="QueryText" Click="Filter_Click" Margin="0,0,4,0"/><TextBlock Text="Query Text" FontWeight="Bold" VerticalAlignment="Center"/></StackPanel></DataGridTemplateColumn.Header> <DataGridTemplateColumn.CellTemplate> diff --git a/Lite/Windows/WaitDrillDownWindow.xaml.cs b/Lite/Windows/WaitDrillDownWindow.xaml.cs index 6eb38ee12..3c0516b00 100644 --- a/Lite/Windows/WaitDrillDownWindow.xaml.cs +++ b/Lite/Windows/WaitDrillDownWindow.xaml.cs @@ -19,6 +19,7 @@ using PerformanceMonitorLite.Services; using static PerformanceMonitor.Ui.WaitDrillDownHelper; using PerformanceMonitor.Ui; +using PerformanceMonitor.PlanAnalysis; namespace PerformanceMonitorLite.Windows; @@ -60,7 +61,8 @@ public WaitDrillDownWindow( this, (xml, label, qt) => PlanViewerWindow.ShowPlanAsync(this, xml, label, qt), (db, qt, est, iso, ct) => ActualPlanExecutor.ExecuteForActualPlanAsync( - _connectionString ?? "", db, qt, est, iso, isAzureSqlDb: false, timeoutSeconds: 0, ct), + _connectionString ?? "", db, qt, est, iso, isAzureSqlDb: false, timeoutSeconds: 0, ct, + productName: "SQL Server Performance Monitor Lite"), "the monitored server"); _filterManager = new DataGridFilterManager<QuerySnapshotRow>(ResultsDataGrid); @@ -192,7 +194,8 @@ private async System.Threading.Tasks.Task LoadChainDataAsync(WaitClassification { if (snapshotLookup.TryGetValue((hb.CollectionTime, hb.SessionId), out var row)) { - row.ChainBlockedCount = hb.BlockedSessionCount; + // Overwrite the SQL-derived same-snapshot count with the chain walker's transitive count + row.BlockedSessionCount = hb.BlockedSessionCount; row.ChainBlockingPath = hb.BlockingPath; headBlockerRows.Add(row); } @@ -219,12 +222,9 @@ private async System.Threading.Tasks.Task LoadChainDataAsync(WaitClassification private void InsertChainColumns() { - // Insert "Blocked Sessions" and "Blocking Path" columns at the beginning of the grid - var blockedCountCol = CreateFilterColumn("Blocked Sessions", "ChainBlockedCount", 105, isNumeric: true); + // Insert "Blocking Path" at the beginning — "Blocked Sessions" is a static XAML column now var blockingPathCol = CreateFilterColumn("Blocking Path", "ChainBlockingPath", 250); - - ResultsDataGrid.Columns.Insert(0, blockedCountCol); - ResultsDataGrid.Columns.Insert(1, blockingPathCol); + ResultsDataGrid.Columns.Insert(0, blockingPathCol); } private DataGridTextColumn CreateFilterColumn(string headerText, string bindingPath, int width, @@ -419,10 +419,10 @@ private void FilterPopup_FilterCleared(object? sender, EventArgs e) #endregion - private void CopyCell_Click(object sender, RoutedEventArgs e) => ContextMenuHelper.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) => ContextMenuHelper.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) => ContextMenuHelper.CopyAllRows(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) => ContextMenuHelper.ExportToCsv(sender, "wait_drill_down"); + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => DataGridExport.ExportToCsv(sender, "wait_drill_down", App.CsvSeparator); private async void ViewPlan_Click(object sender, RoutedEventArgs e) { diff --git a/Lite/config/collection_schedule.json b/Lite/config/collection_schedule.json index 1e801821c..493159c19 100644 --- a/Lite/config/collection_schedule.json +++ b/Lite/config/collection_schedule.json @@ -17,6 +17,7 @@ { "name": "memory_grant_stats", "enabled": true, "frequency_minutes": 1, "retention_days": 30, "description": "Memory grant statistics from sys.dm_exec_query_memory_grants" }, { "name": "waiting_tasks", "enabled": true, "frequency_minutes": 1, "retention_days": 7, "description": "Point-in-time waiting tasks from sys.dm_os_waiting_tasks" }, { "name": "blocked_process_report", "enabled": true, "frequency_minutes": 1, "retention_days": 30, "description": "Blocked process reports from XE ring buffer session (opt-out)" }, + { "name": "long_query_completions", "enabled": false, "frequency_minutes": 1, "retention_days": 30, "description": "Long-running query completions from a dedicated XE ring buffer session. OPT-IN, default OFF (#1496)" }, { "name": "database_scoped_config", "enabled": true, "frequency_minutes": 0, "retention_days": 30, "description": "Database-scoped configurations (on-load only)" }, { "name": "trace_flags", "enabled": true, "frequency_minutes": 0, "retention_days": 30, "description": "Active trace flags via DBCC TRACESTATUS (on-load only)" }, { "name": "running_jobs", "enabled": true, "frequency_minutes": 5, "retention_days": 7, "description": "Currently running SQL Agent jobs with duration comparison" } diff --git a/Lite/packages.lock.json b/Lite/packages.lock.json index 56fbfe344..50643a62e 100644 --- a/Lite/packages.lock.json +++ b/Lite/packages.lock.json @@ -742,12 +742,23 @@ "resolved": "10.0.1", "contentHash": "BZC4mhdL569AXV56ep9YO6ShjhxFXGP7SwVX0Bc/e0dJPWnS6aBEXZJXqh64RVx8HquqWHkJUINBydLRQ1yq0g==" }, + "performancemonitor.alerting": { + "type": "Project", + "dependencies": { + "PerformanceMonitor.Notifications": "[1.0.0, )" + } + }, "performancemonitor.analysis": { "type": "Project" }, + "performancemonitor.collectors": { + "type": "Project" + }, "performancemonitor.common": { "type": "Project", "dependencies": { + "CredentialManagement": "[1.0.2, )", + "Microsoft.Extensions.Logging.Abstractions": "[10.0.9, )", "ModelContextProtocol": "[1.4.0, )" } }, @@ -761,6 +772,7 @@ "performancemonitor.plananalysis": { "type": "Project", "dependencies": { + "Microsoft.Data.SqlClient": "[7.0.1, )", "PerformanceMonitor.Common": "[1.0.0, )" } }, diff --git a/PerformanceMonitor.Alerting/AlertContextBuilders.cs b/PerformanceMonitor.Alerting/AlertContextBuilders.cs new file mode 100644 index 000000000..0f757712c --- /dev/null +++ b/PerformanceMonitor.Alerting/AlertContextBuilders.cs @@ -0,0 +1,428 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using PerformanceMonitor.Notifications; + +namespace PerformanceMonitor.Alerting; + +/// <summary> +/// The pure alert-context builders (Phase-5 slices A + B) both apps' alert engines call, plus the +/// small pure helpers they share (<see cref="ContextToDetailText"/>, <see cref="TruncateText"/>, +/// <see cref="GetBreachedVolumes"/>, <see cref="FormatLowDiskThreshold"/>). Moved verbatim from the +/// line-identical private copies in Lite's and the Dashboard's <c>MainWindow.AlertEngine.cs</c> so +/// the rendered alert detail (and the #1140 dedup fingerprints) can no longer drift between the +/// apps — and so the headless Darling alert engine renders the same alerts from the same rows. +/// <para> +/// The ONE reconciled difference at extraction time: <see cref="BuildLongRunningQueryContext"/> +/// adopts the Dashboard's version, which renders a ("Program", ProgramName) detail item Lite's +/// copy lacked — so Lite's long-running-query alerts gain the Program field. +/// </para> +/// <para> +/// Slice B lifted the last two builders — <see cref="BuildBlockingContext"/> and +/// <see cref="BuildDeadlockContext"/> — out of Lite's async wrappers (Lite's grouped rendering is +/// canonical per the Phase-5 review): the fetch moved behind <see cref="IAlertReadAdapter"/> and +/// the bodies are otherwise verbatim, with Lite's fields/settings (server name, excluded +/// databases) as parameters. The Dashboard's async blocking/deadlock builders deliberately remain +/// app-side — its rendering diverged and convergence is a separately-planned migration. +/// </para> +/// </summary> +public static class AlertContextBuilders +{ + /// <summary> + /// The blocking-alert context from the store's blocked-process rows (XE + DMV-fallback merged — + /// see <see cref="IAlertReadAdapter.GetRecentBlockedProcessReportsAsync"/>). Body verbatim from + /// Lite's pre-slice-B <c>BuildBlockingContextAsync</c> minus the fetch: excluded databases drop + /// their rows (no-database rows always pass); samples of the same chain collapse into one group + /// (#1140/#1141) with true occurrence count + wait range; capped at 10 groups with a "+N more" + /// trailer; the first row with report XML becomes the attachment. Null when nothing renders. + /// </summary> + public static AlertContext? BuildBlockingContext( + string serverName, IReadOnlyList<BlockedProcessAlertRow>? events, IReadOnlyList<string> excludedDatabases) + { + if (events == null || events.Count == 0) return null; + + IReadOnlyList<BlockedProcessAlertRow> filtered = events; + if (excludedDatabases is { Count: > 0 }) + { + filtered = events + .Where(e => string.IsNullOrEmpty(e.DatabaseName) || + !excludedDatabases.Any(ex => + string.Equals(ex, e.DatabaseName, StringComparison.OrdinalIgnoreCase))) + .ToList(); + if (filtered.Count == 0) return null; + } + + /* #1140/#1141: collapse samples of the same chain into one group (true occurrence count + + wait range) instead of listing it once per sample, and attach the dedup fingerprint. + Identity is the resolved contentious object (collected server-side, §5.3), falling back + to database + literal-stripped query pair only when the object did not resolve. */ + var groups = BlockingIncidentGrouper.Group( + serverName, + filtered.Select(e => new BlockingIncidentGrouper.BlockedEvent( + e.DatabaseName, e.ContentiousObject, e.BlockedSqlText, e.BlockingSqlText, e.WaitTimeMs, e.LockMode))); + + const int maxGroups = 10; + var shown = groups.Take(maxGroups).ToList(); + + var context = new AlertContext(); + foreach (var g in shown) + { + var item = new AlertDetailItem + { + Heading = g.OccurrenceCount > 1 ? $"Blocking chain (x{g.OccurrenceCount})" : "Blocking chain", + Fields = new() + }; + if (!string.IsNullOrEmpty(g.Database)) + item.Fields.Add(("Database", g.Database)); + if (!string.IsNullOrEmpty(g.BlockedQuery)) + item.Fields.Add(("Blocked Query", TruncateText(g.BlockedQuery))); + if (!string.IsNullOrEmpty(g.BlockingQuery)) + item.Fields.Add(("Blocking Query", TruncateText(g.BlockingQuery))); + item.Fields.Add(("Wait Range", g.Incident.WaitRange ?? g.MaxWaitMs.ToString())); + context.Details.Add(item); + } + + /* Surface the true total instead of silently dropping (gotqn's report). */ + if (groups.Count > maxGroups) + { + context.Details.Add(new AlertDetailItem + { + Heading = $"+{groups.Count - maxGroups} more distinct blocking incident(s)", + Fields = new() + }); + } + + var firstXml = filtered.FirstOrDefault(e => e.HasReportXml)?.BlockedProcessReportXml; + if (!string.IsNullOrEmpty(firstXml)) + { + context.AttachmentXml = firstXml; + context.AttachmentFileName = "blocked_process_report.xml"; + } + + AlertIncidentRenderer.Apply(context, shown.Select(g => g.Incident).ToList()); + + return context.Details.Count == 0 ? null : context; + } + + /// <summary> + /// The deadlock-alert context from the store's deadlock rows. Body verbatim from Lite's + /// pre-slice-B <c>BuildDeadlockContextAsync</c> minus the fetch: deadlocks whose processes ALL + /// ran in excluded databases are dropped (<see cref="IsDeadlockExcluded"/>); the first 3 render + /// as "Deadlock Victim" items; the first graph XML becomes the attachment; ALL deadlocks in the + /// window feed the #1140 involved-object fingerprint grouping. Null when nothing survives. + /// </summary> + public static AlertContext? BuildDeadlockContext( + string serverName, IReadOnlyList<DeadlockAlertRow>? deadlocks, IReadOnlyList<string> excludedDatabases) + { + if (deadlocks == null || deadlocks.Count == 0) return null; + + IReadOnlyList<DeadlockAlertRow> filtered = deadlocks; + if (excludedDatabases is { Count: > 0 }) + { + filtered = deadlocks + .Where(d => !IsDeadlockExcluded(d, excludedDatabases)) + .ToList(); + if (filtered.Count == 0) return null; + } + + var context = new AlertContext(); + var firstGraph = (string?)null; + + foreach (var d in filtered.Take(3)) + { + var item = new AlertDetailItem + { + Heading = "Deadlock Victim", + Fields = new() + }; + + if (!string.IsNullOrEmpty(d.VictimSqlText)) + item.Fields.Add(("Victim SQL", TruncateText(d.VictimSqlText))); + if (!string.IsNullOrEmpty(d.ProcessSummary)) + item.Fields.Add(("Processes", d.ProcessSummary)); + + context.Details.Add(item); + if (firstGraph == null && d.HasDeadlockXml) + firstGraph = d.DeadlockGraphXml; + } + + if (!string.IsNullOrEmpty(firstGraph)) + { + context.AttachmentXml = firstGraph; + context.AttachmentFileName = "deadlock_graph.xml"; + } + + /* #1140: fingerprint each deadlock by its sorted involved-object set (parsed from the + graph), across ALL deadlocks in the window — not just the 3 displayed — grouped so + recurrences over the same objects collapse to one incident with a count. */ + var groups = DeadlockIncidentGrouper.Group( + serverName, + filtered.Select(d => new DeadlockIncidentGrouper.DeadlockEvent( + DeadlockObjectExtractor.FromGraphXml(d.DeadlockGraphXml), + DeadlockDetailFields(d.VictimSqlText, d.ProcessSummary)))); + AlertIncidentRenderer.Apply(context, groups.Select(g => g.Incident).ToList()); + + return context; + } + + /* #1141: forensic detail carried on a deadlock incident so per-event cards keep the victim SQL + + process summary (Summary mode shows them via the builder's own items). */ + private static List<AlertIncidentField>? DeadlockDetailFields(string? victimSql, string? processes) + { + var f = new List<AlertIncidentField>(); + if (!string.IsNullOrWhiteSpace(victimSql)) f.Add(new AlertIncidentField("Victim SQL", TruncateText(victimSql))); + if (!string.IsNullOrWhiteSpace(processes)) f.Add(new AlertIncidentField("Processes", processes!)); + return f.Count > 0 ? f : null; + } + + /// <summary> + /// True when EVERY process in the deadlock graph ran in an excluded database (case-insensitive + /// on the graph's <c>currentdbname</c>) — a deadlock touching any non-excluded database still + /// alerts. Unparseable or database-less graphs are never excluded. Public because the alert + /// loop's count filter uses it too, not just <see cref="BuildDeadlockContext"/>. + /// </summary> + public static bool IsDeadlockExcluded(DeadlockAlertRow row, IReadOnlyList<string> excludedDatabases) + { + if (string.IsNullOrEmpty(row.DeadlockGraphXml)) return false; + try + { + var doc = System.Xml.Linq.XElement.Parse(row.DeadlockGraphXml); + var dbNames = doc.Descendants("process") + .Select(p => p.Attribute("currentdbname")?.Value) + .Where(n => !string.IsNullOrEmpty(n)) + .Cast<string>() + .ToList(); + if (dbNames.Count == 0) return false; + return dbNames.All(db => excludedDatabases.Any(e => + string.Equals(e, db, StringComparison.OrdinalIgnoreCase))); + } + catch { return false; } + } + public static AlertContext? BuildPoisonWaitContext(List<PoisonWaitDelta> triggeredWaits) + { + if (triggeredWaits.Count == 0) return null; + + var context = new AlertContext(); + foreach (var w in triggeredWaits) + { + context.Details.Add(new AlertDetailItem + { + Heading = w.WaitType, + Fields = new() + { + ("Avg ms/wait", $"{w.AvgMsPerWait:F1}"), + ("Delta wait ms", $"{w.DeltaMs:N0}"), + ("Delta tasks", $"{w.DeltaTasks:N0}") + } + }); + } + return context; + } + + public static AlertContext? BuildLongRunningQueryContext(string serverName, List<LongRunningQueryInfo> queries) + { + if (queries.Count == 0) return null; + + var context = new AlertContext(); + var shown = queries.GetRange(0, Math.Min(3, queries.Count)); + foreach (var q in shown) + { + var item = new AlertDetailItem + { + Heading = $"Session #{q.SessionId} — {q.ElapsedSeconds / 60}m {q.ElapsedSeconds % 60}s", + Fields = new() + }; + + if (!string.IsNullOrEmpty(q.DatabaseName)) + item.Fields.Add(("Database", q.DatabaseName)); + if (!string.IsNullOrEmpty(q.ProgramName)) + item.Fields.Add(("Program", q.ProgramName)); + if (!string.IsNullOrEmpty(q.QueryText)) + item.Fields.Add(("Query", TruncateText(q.QueryText))); + item.Fields.Add(("CPU Time", $"{q.CpuTimeMs:N0} ms")); + item.Fields.Add(("Reads", $"{q.Reads:N0}")); + item.Fields.Add(("Writes", $"{q.Writes:N0}")); + if (!string.IsNullOrEmpty(q.WaitType)) + item.Fields.Add(("Wait Type", q.WaitType)); + if (q.BlockingSessionId.HasValue && q.BlockingSessionId.Value > 0) + item.Fields.Add(("Blocked By", $"Session #{q.BlockingSessionId.Value}")); + + context.Details.Add(item); + } + + /* #1140: dedup key = query_hash (stable across literals/plans). Null hash -> no incident. */ + AlertIncidentRenderer.Apply(context, shown + .Select(q => AlertFingerprint.ForKey(serverName, AlertFingerprint.Query, q.QueryHash ?? "", + string.IsNullOrEmpty(q.DatabaseName) ? System.Array.Empty<string>() : new[] { q.DatabaseName })) + .Where(i => i is not null).Select(i => i!).ToList()); + return context; + } + + /* Returns the volumes whose free space is under the configured % or GB threshold (a 0 threshold + disables that dimension), worst (lowest free %) first, so the alert names the tightest volume. */ + public static List<VolumeFreeSpaceInfo> GetBreachedVolumes(List<VolumeFreeSpaceInfo> volumes, double thresholdPercent, double thresholdGb) + { + double pct = thresholdPercent; + double gb = thresholdGb; + return volumes + .Where(v => (pct > 0 && v.FreePercent < pct) || (gb > 0 && v.FreeGb < gb)) + .OrderBy(v => v.FreePercent) + .ToList(); + } + + public static string FormatLowDiskThreshold(double thresholdPercent, double thresholdGb) + { + var parts = new List<string>(); + if (thresholdPercent > 0) parts.Add($"{thresholdPercent}%"); + if (thresholdGb > 0) parts.Add($"{thresholdGb} GB"); + return parts.Count > 0 ? string.Join(" / ", parts) : "—"; + } + + public static AlertContext? BuildVolumeFreeSpaceContext(string serverName, List<VolumeFreeSpaceInfo> volumes) + { + if (volumes.Count == 0) return null; + + var context = new AlertContext(); + var shown = volumes.GetRange(0, Math.Min(5, volumes.Count)); + foreach (var v in shown) + { + context.Details.Add(new AlertDetailItem + { + Heading = $"{v.MountPoint} — {v.FreePercent:F0}% Free", + Fields = new() + { + ("Free Space", $"{v.FreeGb:F1} GB"), + ("Total Size", $"{v.TotalMb / 1024.0:F1} GB"), + ("Used", $"{(v.TotalMb - v.FreeMb) / 1024.0:F1} GB") + } + }); + } + + /* #1140: dedup key per volume (the drive/mount point). */ + AlertIncidentRenderer.Apply(context, shown + .Select(v => AlertFingerprint.ForKey(serverName, AlertFingerprint.Disk, v.MountPoint, new[] { v.MountPoint })) + .Where(i => i is not null).Select(i => i!).ToList()); + return context; + } + + public static AlertContext? BuildTempDbSpaceContext(TempDbSpaceInfo tempDb) + { + var context = new AlertContext(); + context.Details.Add(new AlertDetailItem + { + Heading = $"tempdb — {tempDb.UsedPercent:F0}% Used", + Fields = new() + { + ("Total Reserved", $"{tempDb.TotalReservedMb:F0} MB"), + ("Unallocated", $"{tempDb.UnallocatedMb:F0} MB"), + ("User Objects", $"{tempDb.UserObjectReservedMb:F0} MB"), + ("Internal Objects", $"{tempDb.InternalObjectReservedMb:F0} MB"), + ("Version Store", $"{tempDb.VersionStoreReservedMb:F0} MB"), + ("Top Consumer", tempDb.TopConsumerSessionId > 0 + ? $"Session #{tempDb.TopConsumerSessionId} ({tempDb.TopConsumerMb:F0} MB)" + : "None") + } + }); + return context; + } + + public static AlertContext? BuildAnomalousJobContext(string serverName, List<AnomalousJobInfo> jobs) + { + if (jobs.Count == 0) return null; + + var context = new AlertContext(); + var shown = jobs.GetRange(0, Math.Min(3, jobs.Count)); + foreach (var j in shown) + { + context.Details.Add(new AlertDetailItem + { + Heading = j.JobName, + Fields = new() + { + ("Current Duration", FormatDuration(j.CurrentDurationSeconds)), + ("Avg Duration", FormatDuration(j.AvgDurationSeconds)), + ("P95 Duration", FormatDuration(j.P95DurationSeconds)), + ("% of Average", j.PercentOfAverage.HasValue ? $"{j.PercentOfAverage:F0}%" : "N/A"), + ("Started", j.StartTime.ToString("yyyy-MM-dd HH:mm:ss")) + } + }); + } + + /* #1140: dedup key per job (job name, scoped to the instance via serverName). */ + AlertIncidentRenderer.Apply(context, shown + .Select(j => AlertFingerprint.ForKey(serverName, AlertFingerprint.Job, j.JobName, new[] { j.JobName })) + .Where(i => i is not null).Select(i => i!).ToList()); + return context; + } + + public static AlertContext? BuildFailedJobContext(string serverName, List<FailedJobInfo> jobs) + { + if (jobs.Count == 0) return null; + + var context = new AlertContext(); + var shown = jobs.GetRange(0, Math.Min(5, jobs.Count)); + foreach (var j in shown) + { + var item = new AlertDetailItem { Heading = j.JobName, Fields = new() }; + item.Fields.Add(("Job", j.JobName)); + item.Fields.Add(("Failed At", j.RunDateTimeFormatted)); + if (j.StepId > 0 && !string.IsNullOrEmpty(j.StepName)) + item.Fields.Add(("Step", $"{j.StepId} — {j.StepName}")); + if (!string.IsNullOrEmpty(j.Message)) + item.Fields.Add(("Message", TruncateText(j.Message, 300))); + context.Details.Add(item); + } + + /* #1140: dedup key per job (job name, scoped to the instance via serverName) — mirrors + BuildAnomalousJobContext so two distinct failed jobs are distinct incidents under the + #1154 per-fingerprint cooldown instead of coalescing on the metric key. */ + AlertIncidentRenderer.Apply(context, shown + .Select(j => AlertFingerprint.ForKey(serverName, AlertFingerprint.Job, j.JobName, new[] { j.JobName })) + .Where(i => i is not null).Select(i => i!).ToList()); + return context; + } + + /// <summary> + /// Flattens an <see cref="AlertContext"/> into the plain-text detail block persisted in alert + /// history and rendered in plain-text notification bodies. Null when there is nothing to render. + /// </summary> + public static string? ContextToDetailText(AlertContext? context) + { + if (context == null || context.Details.Count == 0) return null; + var sb = new System.Text.StringBuilder(); + foreach (var detail in context.Details) + { + if (sb.Length > 0) sb.AppendLine(); + sb.AppendLine(detail.Heading); + foreach (var (label, value) in detail.Fields) + sb.AppendLine($" {label}: {value}"); + } + return sb.ToString().TrimEnd(); + } + + /// <summary> + /// Collapses newlines to spaces, trims, and truncates to <paramref name="maxLength"/> with a + /// trailing ellipsis — the single-line preview treatment for query text / job messages. + /// </summary> + public static string TruncateText(string text, int maxLength = 300) + { + if (string.IsNullOrEmpty(text)) return ""; + text = text.Replace('\r', ' ').Replace('\n', ' ').Trim(); + return text.Length <= maxLength ? text : text.Substring(0, maxLength) + "..."; + } + + private static string FormatDuration(long seconds) + { + if (seconds < 60) return $"{seconds}s"; + if (seconds < 3600) return $"{seconds / 60}m {seconds % 60}s"; + return $"{seconds / 3600}h {(seconds % 3600) / 60}m"; + } +} diff --git a/PerformanceMonitor.Alerting/AlertEngine.cs b/PerformanceMonitor.Alerting/AlertEngine.cs new file mode 100644 index 000000000..e6770d743 --- /dev/null +++ b/PerformanceMonitor.Alerting/AlertEngine.cs @@ -0,0 +1,988 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using PerformanceMonitor.Notifications; + +namespace PerformanceMonitor.Alerting; + +/// <summary> +/// The Phase-5 shared alert engine (slice D): Lite's <c>MainWindow.CheckPerformanceAlerts</c> +/// TRANSPLANTED behind the three seams — <see cref="IAlertEngineSettings"/> (thresholds), +/// <see cref="IAlertReadAdapter"/> (collected feeds), <see cref="IAlertStateStore"/> +/// (restart-surviving watermarks) — with every fired alert emitted through +/// <see cref="IAlertDeliverer"/>. Same alert order, same gating order per alert +/// (enabled flag → data fetch → threshold compare → edge-trigger state → mute check → cooldown → +/// deliver → record state), same edge-trigger/cooldown/mute semantics, line-cited per check. +/// The headless Darling service is the first consumer; Lite forwards in a later slice. +/// <para> +/// Deliberate NON-transplants (UI-coupled Lite behavior that stays app-side): +/// tray toast RENDERING and the <c>_trayService</c> null gate (the per-metric toast BODY ships as +/// <see cref="AlertOutcome.ShortMessage"/> because it needs per-row data the other display fields +/// don't carry); the server-tab badge flags (#754/#749 <c>_badgeLowDisk</c>/<c>_badgeFailedJob</c> +/// + acknowledgement clearing — the two standing conditions they derive from are surfaced on the +/// returned <see cref="AlertSweepResult"/>); the #1141 Summary-vs-Per-event delivery split (an +/// <see cref="IAlertDeliverer"/> concern per its contract); and the #1236 per-server delivery-mode +/// override (same seam). Lite's tray-only "Resolved"/"Cleared" toasts surface through the optional +/// resolution callback (<see cref="AlertResolution"/>) with Lite's exact strings — they never +/// touch the deliverer because Lite records no history row for them. Line citations per check +/// refer to the pre-forwarding Lite loop (the transplant source, retrievable from git history). +/// </para> +/// <para> +/// Two documented adaptations of the store reads: (1) Lite's loop received precomputed rolling +/// blocking/deadlock counts from its overview summary query; the engine derives them from ONE +/// adapter fetch instead (blocking keeps Lite's XE-preferred count semantics — the XE row count, +/// falling back to the merged count when zero XE rows — and the fetched rows then serve the +/// excluded-database recount and the fired alert's context, so the numbers can't disagree within +/// a sweep). Counts therefore inherit the adapter caps (200/50) and the deadlock read's +/// collection-time window. (2) When the blocking/deadlock fetch itself fails, the engine skips +/// that check for the sweep (state untouched) — mirroring the try/catch-and-move-on shape of +/// Lite's other checks — rather than running the gate against a fabricated zero count, which +/// would reset the watermark and later re-fire. +/// </para> +/// <para> +/// THREAD-SAFETY: the engine is a long-lived singleton per host. Evaluations for the SAME server +/// are serialized internally (per-key gate), so a host that overlaps sweeps cannot interleave one +/// server's state updates; DIFFERENT servers may evaluate concurrently (all state lives in +/// concurrent dictionaries). Hosts should still call sequentially per server — the gate is a +/// guarantee, not an invitation. +/// </para> +/// </summary> +public sealed class AlertEngine +{ + /* The persisted-watermark row keys (#1145) — Lite's MainWindow.xaml.cs:111-112 constants, + shared so Lite's existing config_edge_trigger_watermarks rows seed this engine unchanged. */ + public const string BlockingWatermarkMetric = "Blocking Detected"; + public const string DeadlockWatermarkMetric = "Deadlocks Detected"; + + private readonly IAlertEngineSettings _settings; + private readonly IAlertReadAdapter _readAdapter; + private readonly IAlertStateStore _stateStore; + private readonly IAlertDeliverer _deliverer; + private readonly Func<AlertMuteContext, bool> _isAlertMuted; + private readonly Func<string, int, CancellationToken, Task<List<FailedJobInfo>>>? _failedJobsFetcher; + private readonly Func<AlertResolution, CancellationToken, Task>? _resolutionCallback; + private readonly ILogger? _logger; + private readonly Func<DateTime> _utcNow; + + /* Per-serverKey evaluation gate: serializes EvaluateServerAsync for the SAME server. */ + private readonly ConcurrentDictionary<string, SemaphoreSlim> _serverGates = new(); + + /* One-time per-serverKey watermark seeding from the state store (#1145) — the per-key twin of + Lite's bulk SeedEdgeTriggerWatermarksAsync (MainWindow.xaml.cs:1563). */ + private readonly ConcurrentDictionary<string, bool> _seededServerKeys = new(); + + /* Cooldown timestamps — Lite's MainWindow.xaml.cs:56-63,90 dictionaries, keyed serverKey. + In-memory only, exactly like Lite (the restart protection is the persisted watermarks plus + the deliverer's own email/webhook cooldown seeds, not these). */ + private readonly ConcurrentDictionary<string, DateTime> _lastCpuAlert = new(); + private readonly ConcurrentDictionary<string, DateTime> _lastBlockingAlert = new(); + private readonly ConcurrentDictionary<string, DateTime> _lastDeadlockAlert = new(); + private readonly ConcurrentDictionary<string, DateTime> _lastPoisonWaitAlert = new(); + private readonly ConcurrentDictionary<string, DateTime> _lastLongRunningQueryAlert = new(); + private readonly ConcurrentDictionary<string, DateTime> _lastTempDbSpaceAlert = new(); + private readonly ConcurrentDictionary<string, DateTime> _lastLowDiskAlert = new(); + private readonly ConcurrentDictionary<string, DateTime> _lastFailedJobAlert = new(); + + /* Keyed per job *run* ({serverKey}:{jobId}:{startTime:O}) so it grows without bound; stale + entries are pruned each pass — Lite's MainWindow.xaml.cs:63 + AlertEngine.cs:564-573. */ + private readonly ConcurrentDictionary<string, DateTime> _lastLongRunningJobAlert = new(); + + /* Active-condition flags driving the resolved/cleared transitions — + Lite's MainWindow.xaml.cs:78-89. */ + private readonly ConcurrentDictionary<string, bool> _activeCpuAlert = new(); + private readonly ConcurrentDictionary<string, bool> _activeBlockingAlert = new(); + private readonly ConcurrentDictionary<string, bool> _activeDeadlockAlert = new(); + private readonly ConcurrentDictionary<string, bool> _activePoisonWaitAlert = new(); + private readonly ConcurrentDictionary<string, bool> _activeLongRunningQueryAlert = new(); + private readonly ConcurrentDictionary<string, bool> _activeTempDbSpaceAlert = new(); + private readonly ConcurrentDictionary<string, bool> _activeLowDiskAlert = new(); + private readonly ConcurrentDictionary<string, bool> _activeLongRunningJobAlert = new(); + + /* Worst free-% captured at the last low-disk alert (#754 follow-up) — Lite's + MainWindow.xaml.cs:88; gated by LowDiskAlertGate; removed on resolve. */ + private readonly ConcurrentDictionary<string, double> _lastAlertedLowDiskPercent = new(); + + /* Rolling-count edge-trigger watermarks (#1091) — Lite's MainWindow.xaml.cs:103-104; + persisted through IAlertStateStore on change (#1145). */ + private readonly ConcurrentDictionary<string, int> _lastAlertedBlockingCount = new(); + private readonly ConcurrentDictionary<string, int> _lastAlertedDeadlockCount = new(); + + /* Newest already-alerted failed-job run time (SERVER-LOCAL) — Lite's MainWindow.xaml.cs:96; + persisted through IAlertStateStore on change (#1145 parity). */ + private readonly ConcurrentDictionary<string, DateTime> _lastAlertedFailedJobTime = new(); + + /// <param name="settings">Live threshold surface — read every sweep, never cached.</param> + /// <param name="readAdapter">The collected alert feeds (slice B seam).</param> + /// <param name="stateStore">Restart-surviving watermark persistence (#1145).</param> + /// <param name="deliverer">Record-and-send seam — the engine never touches SMTP/history itself.</param> + /// <param name="isAlertMuted"> + /// Mute check — Lite/Darling pass <c>MuteRuleService.IsAlertMuted</c>. A muted alert is still + /// delivered to the deliverer (flagged <see cref="AlertOutcome.Muted"/>) so the host records + /// it without sending, exactly Lite's flow. + /// </param> + /// <param name="failedJobsFetcher"> + /// The live msdb failed-jobs feed (serverKey, lookbackMinutes, ct) — NOT a collected read, so + /// it stays host-supplied: hosts run <see cref="FailedJobsQuery"/> on their own connections + /// and degrade failures to an empty list. Null disables the failed-jobs check entirely. + /// </param> + /// <param name="resolutionCallback"> + /// Optional condition-recovered hook (see <see cref="AlertResolution"/>). Null = resolutions + /// are tracked but not reported (state transitions still occur). + /// </param> + /// <param name="logger">Optional diagnostics logger.</param> + /// <param name="utcNow">Test seam for the cooldown clock; production leaves it null (UtcNow).</param> + public AlertEngine( + IAlertEngineSettings settings, + IAlertReadAdapter readAdapter, + IAlertStateStore stateStore, + IAlertDeliverer deliverer, + Func<AlertMuteContext, bool> isAlertMuted, + Func<string, int, CancellationToken, Task<List<FailedJobInfo>>>? failedJobsFetcher = null, + Func<AlertResolution, CancellationToken, Task>? resolutionCallback = null, + ILogger? logger = null, + Func<DateTime>? utcNow = null) + { + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _readAdapter = readAdapter ?? throw new ArgumentNullException(nameof(readAdapter)); + _stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore)); + _deliverer = deliverer ?? throw new ArgumentNullException(nameof(deliverer)); + _isAlertMuted = isAlertMuted ?? throw new ArgumentNullException(nameof(isAlertMuted)); + _failedJobsFetcher = failedJobsFetcher; + _resolutionCallback = resolutionCallback; + _logger = logger; + _utcNow = utcNow ?? (() => DateTime.UtcNow); + } + + /// <summary> + /// Runs one full alert sweep for one server — Lite's <c>CheckPerformanceAlerts(summary)</c>. + /// Per-server serialized (see class remarks). Channel/store failures never escape (the + /// deliverer and state store contracts absorb them; per-check fetch failures are logged and + /// skip that check for the sweep); only cancellation propagates. Returns what the sweep + /// OBSERVED (see <see cref="AlertSweepResult"/>) so interactive hosts can drive their + /// standing-condition badges; headless hosts ignore the result. + /// </summary> + public async Task<AlertSweepResult> EvaluateServerAsync(AlertServerSnapshot snapshot, CancellationToken cancellationToken = default) + { + if (snapshot is null) + { + throw new ArgumentNullException(nameof(snapshot)); + } + + /* Master switch — Lite's AlertEngine.cs:38 (the _trayService null gate is UI-only). + NotEvaluated mirrors Lite's early return: the host leaves badge state untouched. */ + if (!_settings.AlertsEnabled) + { + return AlertSweepResult.NotEvaluated; + } + + var gate = _serverGates.GetOrAdd(snapshot.ServerKey, _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(cancellationToken); + try + { + return await EvaluateCoreAsync(snapshot, cancellationToken); + } + finally + { + gate.Release(); + } + } + + private async Task<AlertSweepResult> EvaluateCoreAsync(AlertServerSnapshot snapshot, CancellationToken ct) + { + var key = snapshot.ServerKey; + var serverName = snapshot.ServerName; + var now = _utcNow(); /* Lite AlertEngine.cs:41 */ + var alertCooldown = TimeSpan.FromMinutes(_settings.CooldownMinutes); /* :57 */ + bool suppressed = snapshot.Suppressed; /* :60 (suppressPopups) */ + + await EnsureWatermarksSeededAsync(key, ct); + + await CheckCpuAsync(snapshot, key, serverName, now, alertCooldown, suppressed, ct); + await CheckBlockingAsync(key, serverName, now, alertCooldown, suppressed, ct); + await CheckDeadlocksAsync(key, serverName, now, alertCooldown, suppressed, ct); + await CheckPoisonWaitsAsync(key, serverName, now, alertCooldown, suppressed, ct); + await CheckLongRunningQueriesAsync(key, serverName, now, alertCooldown, suppressed, ct); + await CheckTempDbSpaceAsync(key, serverName, now, alertCooldown, suppressed, ct); + bool lowDiskConditionPresent = await CheckLowDiskAsync(key, serverName, now, alertCooldown, suppressed, ct); + await CheckAnomalousJobsAsync(key, serverName, now, alertCooldown, suppressed, ct); + bool failedJobConditionPresent = await CheckFailedJobsAsync(snapshot, key, serverName, now, alertCooldown, suppressed, ct); + + return new AlertSweepResult(true, lowDiskConditionPresent, failedJobConditionPresent); + } + + /* ---------------- watermark seeding (#1145) ---------------- */ + + /// <summary> + /// Per-key twin of Lite's startup <c>SeedEdgeTriggerWatermarksAsync</c> + /// (MainWindow.xaml.cs:1563-1594): loads the persisted blocking/deadlock count watermarks and + /// the failed-job time watermark before this server's first sweep, so a host restart doesn't + /// re-fire (and re-post webhooks for) events still lingering in the rolling window. Seeded + /// once per key; a seed failure logs and proceeds unseeded, exactly like Lite. + /// </summary> + private async Task EnsureWatermarksSeededAsync(string key, CancellationToken ct) + { + if (_seededServerKeys.ContainsKey(key)) + { + return; + } + + try + { + var blocking = await _stateStore.LoadEdgeTriggerWatermarkAsync(key, BlockingWatermarkMetric); + if (blocking.HasValue) + { + _lastAlertedBlockingCount[key] = blocking.Value; + } + + var deadlock = await _stateStore.LoadEdgeTriggerWatermarkAsync(key, DeadlockWatermarkMetric); + if (deadlock.HasValue) + { + _lastAlertedDeadlockCount[key] = deadlock.Value; + } + + var failedJob = await _stateStore.LoadFailedJobWatermarkAsync(key); + if (failedJob.HasValue) + { + _lastAlertedFailedJobTime[key] = failedJob.Value; + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger?.LogError("Failed to seed edge-trigger watermarks for {ServerKey}: {Message}", key, ex.Message); + } + + _seededServerKeys[key] = true; + } + + /* ---------------- CPU (Lite AlertEngine.cs:62-114) ---------------- */ + + private async Task CheckCpuAsync( + AlertServerSnapshot snapshot, string key, string serverName, + DateTime now, TimeSpan alertCooldown, bool suppressed, CancellationToken ct) + { + /* Mode selection INSIDE the engine — ServerSummaryItem.CpuPercentForAlert semantics + (Lite LocalDataService.Overview.cs:143-144): Total → TotalCpuPercent ?? CpuPercent; + SqlOnly → CpuPercent. */ + var alertCpuValue = _settings.CpuAlertMode == CpuAlertMode.TotalServer + ? (snapshot.TotalCpuPercent ?? snapshot.SqlCpuPercent) + : snapshot.SqlCpuPercent; + string cpuMetricLabel = _settings.CpuAlertMode == CpuAlertMode.TotalServer ? "Total CPU" : "SQL CPU"; /* :64 */ + bool cpuExceeded = _settings.CpuEnabled + && alertCpuValue.HasValue + && alertCpuValue.Value >= _settings.CpuThresholdPercent; /* :65-67 */ + + if (cpuExceeded) + { + _activeCpuAlert[key] = true; /* :71 */ + if (!suppressed && CooldownElapsed(_lastCpuAlert, key, now, alertCooldown)) /* :72 */ + { + var muteCtx = new AlertMuteContext { ServerName = serverName, MetricName = "High CPU" }; /* :74 */ + bool isMuted = _isAlertMuted(muteCtx); /* :75 */ + _lastCpuAlert[key] = now; /* :76 — stamped even when muted */ + + var cpuDetailText = $" {cpuMetricLabel}: {alertCpuValue:F0}%\n Threshold: {_settings.CpuThresholdPercent}%"; /* :89 */ + + /* :91-98 — CPU passes no context and no numerics, exactly Lite. + ShortMessage = the toast body of :84 minus the server-name prefix. */ + await _deliverer.DeliverAsync(new AlertOutcome( + key, serverName, "High CPU", + $"{alertCpuValue:F0}% ({cpuMetricLabel})", + $"{_settings.CpuThresholdPercent}%", + Context: null, DetailText: cpuDetailText, + NumericCurrentValue: null, NumericThresholdValue: null, + Muted: isMuted, Severity: null, + ShortMessage: $"{cpuMetricLabel} at {alertCpuValue:F0}% (threshold: {_settings.CpuThresholdPercent}%)"), ct); + } + } + else if (_activeCpuAlert.TryGetValue(key, out var wasCpu) && wasCpu) /* :101 */ + { + _activeCpuAlert[key] = false; /* :103 */ + /* :107 — resolve announced only while the alert is still enabled and unsuppressed + (disabling flips cpuExceeded false; neither means CPU actually recovered). */ + if (!suppressed && _settings.CpuEnabled) + { + await NotifyResolutionAsync(new AlertResolution( + key, serverName, "High CPU", + "CPU Resolved", /* :110 */ + $"{serverName}: {cpuMetricLabel} back to {alertCpuValue:F0}%"), ct); /* :111 */ + } + } + } + + /* ---------------- blocking (Lite AlertEngine.cs:116-194) ---------------- */ + + private async Task CheckBlockingAsync( + string key, string serverName, DateTime now, TimeSpan alertCooldown, bool suppressed, CancellationToken ct) + { + List<BlockedProcessAlertRow>? blockingRows = null; + int effectiveBlockingCount = 0; + + if (_settings.BlockingEnabled) + { + try + { + /* ONE fetch serves the rolling count, the excluded-database recount (:118-133), + and the fired alert's context (:172) — see class remarks adaptation (1). */ + blockingRows = await _readAdapter.GetRecentBlockedProcessReportsAsync(key, hoursBack: 1, ct); + + /* Lite's overview count semantics (LocalDataService.Overview.cs:74-77): prefer the + XE blocked-process-report count; fall back to the DMV snapshot count when the XE + count is zero (AWS RDS / unset blocked-process threshold). The merged adapter + list contains all XE rows plus only uncovered DMV rows, so when no XE row exists + the merged count IS the DMV count. */ + int xeCount = blockingRows.Count(r => r.Source == BlockedProcessAlertRow.XeReportSource); + effectiveBlockingCount = xeCount > 0 ? xeCount : blockingRows.Count; + + /* :118-127 — with excluded databases configured and the raw count at/over the + threshold, recount only rows outside the excluded set (no-database rows pass). */ + if (_settings.ExcludedDatabases.Count > 0 + && effectiveBlockingCount >= _settings.BlockingCountThreshold) + { + effectiveBlockingCount = blockingRows + .Count(r => string.IsNullOrEmpty(r.DatabaseName) || + !_settings.ExcludedDatabases.Any(e => + string.Equals(e, r.DatabaseName, StringComparison.OrdinalIgnoreCase))); + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + /* :129-132 shape — log and skip this check for the sweep (class remarks + adaptation (2)): never run the gate on a fabricated zero count. */ + _logger?.LogError("Failed to check blocking for {Server}: {Message}", serverName, ex.Message); + return; + } + } + + /* Edge-trigger the rolling 1-hour count (#1091) — :135-150. */ + int blockingWatermark = _lastAlertedBlockingCount.TryGetValue(key, out var labc) ? labc : 0; /* :138 */ + bool blockingCooldownElapsed = CooldownElapsed(_lastBlockingAlert, key, now, alertCooldown); /* :139 */ + var blockingDecision = _settings.BlockingEnabled + ? RollingCountAlertGate.Evaluate(effectiveBlockingCount, _settings.BlockingCountThreshold, blockingWatermark, blockingCooldownElapsed, suppressed) + : new RollingCountAlertGate.Decision(false, false, 0); /* :140-142 */ + _lastAlertedBlockingCount[key] = blockingDecision.Watermark; /* :143 */ + if (blockingDecision.Watermark != blockingWatermark) /* :147 — persist on change (#1145) */ + { + await _stateStore.SaveEdgeTriggerWatermarkAsync(key, BlockingWatermarkMetric, blockingDecision.Watermark); /* :149 */ + } + + bool wasBlockingActive = _activeBlockingAlert.TryGetValue(key, out var wasBlocking) && wasBlocking; /* :152 */ + _activeBlockingAlert[key] = blockingDecision.Active; /* :153 */ + + if (blockingDecision.Fire) /* :155 */ + { + var muteCtx = new AlertMuteContext { ServerName = serverName, MetricName = "Blocking Detected" }; /* :157 */ + bool isMuted = _isAlertMuted(muteCtx); /* :158 */ + _lastBlockingAlert[key] = now; /* :159 */ + + /* :172-173 — Lite's BuildBlockingContextAsync refetches the same rows; the engine + reuses this sweep's fetch (identical query/window). */ + var blockingContext = AlertContextBuilders.BuildBlockingContext(serverName, blockingRows, _settings.ExcludedDatabases); + var detailText = AlertContextBuilders.ContextToDetailText(blockingContext); + + /* :175-183 — SendDetectedAlertAsync's #1141/#1236 delivery-mode fan-out is an + IAlertDeliverer concern; the engine emits one outcome. No numerics, exactly Lite. + ShortMessage = the toast body of :167. */ + await _deliverer.DeliverAsync(new AlertOutcome( + key, serverName, "Blocking Detected", + effectiveBlockingCount.ToString(), + _settings.BlockingCountThreshold.ToString(), + blockingContext, detailText, + NumericCurrentValue: null, NumericThresholdValue: null, + Muted: isMuted, Severity: blockingContext?.SeverityOverride, + ShortMessage: $"{effectiveBlockingCount} blocking session(s)"), ct); + } + else if (!blockingDecision.Active && wasBlockingActive) /* :185 */ + { + if (!suppressed && _settings.BlockingEnabled) /* :187 */ + { + await NotifyResolutionAsync(new AlertResolution( + key, serverName, "Blocking Detected", + "Blocking Cleared", /* :190 */ + $"{serverName}: No active blocking"), ct); /* :191 */ + } + } + } + + /* ---------------- deadlocks (Lite AlertEngine.cs:196-271) ---------------- */ + + private async Task CheckDeadlocksAsync( + string key, string serverName, DateTime now, TimeSpan alertCooldown, bool suppressed, CancellationToken ct) + { + List<DeadlockAlertRow>? deadlockRows = null; + int effectiveDeadlockCount = 0; + + if (_settings.DeadlockEnabled) + { + try + { + /* ONE fetch serves the rolling count, the excluded-database recount (:198-211), + and the fired alert's context (:249) — class remarks adaptation (1). */ + deadlockRows = await _readAdapter.GetRecentDeadlocksAsync(key, hoursBack: 1, ct); + effectiveDeadlockCount = deadlockRows.Count; + + /* :198-205 — recount excluding deadlocks whose processes ALL ran in excluded + databases (graph-XML parse via the shared IsDeadlockExcluded). */ + if (_settings.ExcludedDatabases.Count > 0 + && effectiveDeadlockCount >= _settings.DeadlockCountThreshold) + { + effectiveDeadlockCount = deadlockRows + .Count(r => !AlertContextBuilders.IsDeadlockExcluded(r, _settings.ExcludedDatabases)); + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + /* :207-210 shape — log and skip (class remarks adaptation (2)). */ + _logger?.LogError("Failed to check deadlocks for {Server}: {Message}", serverName, ex.Message); + return; + } + } + + /* Edge-trigger the rolling 1-hour count (#1091) — :213-227. */ + int deadlockWatermark = _lastAlertedDeadlockCount.TryGetValue(key, out var ladc) ? ladc : 0; /* :216 */ + bool deadlockCooldownElapsed = CooldownElapsed(_lastDeadlockAlert, key, now, alertCooldown); /* :217 */ + var deadlockDecision = _settings.DeadlockEnabled + ? RollingCountAlertGate.Evaluate(effectiveDeadlockCount, _settings.DeadlockCountThreshold, deadlockWatermark, deadlockCooldownElapsed, suppressed) + : new RollingCountAlertGate.Decision(false, false, 0); /* :218-220 */ + _lastAlertedDeadlockCount[key] = deadlockDecision.Watermark; /* :221 */ + if (deadlockDecision.Watermark != deadlockWatermark) /* :224 — persist on change (#1145) */ + { + await _stateStore.SaveEdgeTriggerWatermarkAsync(key, DeadlockWatermarkMetric, deadlockDecision.Watermark); /* :226 */ + } + + bool wasDeadlockActive = _activeDeadlockAlert.TryGetValue(key, out var wasDeadlock) && wasDeadlock; /* :229 */ + _activeDeadlockAlert[key] = deadlockDecision.Active; /* :230 */ + + if (deadlockDecision.Fire) /* :232 */ + { + var muteCtx = new AlertMuteContext { ServerName = serverName, MetricName = "Deadlocks Detected" }; /* :234 */ + bool isMuted = _isAlertMuted(muteCtx); /* :235 */ + _lastDeadlockAlert[key] = now; /* :236 */ + + /* :249-250 — context from this sweep's fetch. */ + var deadlockContext = AlertContextBuilders.BuildDeadlockContext(serverName, deadlockRows, _settings.ExcludedDatabases); + var detailText = AlertContextBuilders.ContextToDetailText(deadlockContext); + + /* :252-260 — no numerics, exactly Lite. ShortMessage = the toast body of :244. */ + await _deliverer.DeliverAsync(new AlertOutcome( + key, serverName, "Deadlocks Detected", + effectiveDeadlockCount.ToString(), + _settings.DeadlockCountThreshold.ToString(), + deadlockContext, detailText, + NumericCurrentValue: null, NumericThresholdValue: null, + Muted: isMuted, Severity: deadlockContext?.SeverityOverride, + ShortMessage: $"{effectiveDeadlockCount} deadlock(s) in the last hour"), ct); + } + else if (!deadlockDecision.Active && wasDeadlockActive) /* :262 */ + { + if (!suppressed && _settings.DeadlockEnabled) /* :264 */ + { + await NotifyResolutionAsync(new AlertResolution( + key, serverName, "Deadlocks Detected", + "Deadlocks Cleared", /* :267 */ + $"{serverName}: No deadlocks in the last hour"), ct); /* :268 */ + } + } + } + + /* ---------------- poison waits (Lite AlertEngine.cs:273-339) ---------------- */ + + private async Task CheckPoisonWaitsAsync( + string key, string serverName, DateTime now, TimeSpan alertCooldown, bool suppressed, CancellationToken ct) + { + if (!_settings.PoisonWaitEnabled) /* :274 */ + { + return; + } + + try + { + var triggered = await _readAdapter.GetPoisonWaitDeltasAsync(key, _settings.PoisonWaitThresholdMs, ct); /* :278 */ + + if (triggered.Count > 0) + { + _activePoisonWaitAlert[key] = true; /* :282 */ + if (!suppressed && CooldownElapsed(_lastPoisonWaitAlert, key, now, alertCooldown)) /* :283 */ + { + var worst = triggered[0]; /* :285 */ + var allWaitNames = string.Join(", ", triggered.ConvertAll(w => $"{w.WaitType} ({w.AvgMsPerWait:F0}ms)")); /* :286 */ + + /* :288-293 — mute keys on the worst (highest avg ms/wait) triggered wait type; + same documented limitation as Lite. */ + var muteCtx = new AlertMuteContext { ServerName = serverName, MetricName = "Poison Wait", WaitType = worst.WaitType }; + bool isMuted = _isAlertMuted(muteCtx); + _lastPoisonWaitAlert[key] = now; /* :294 */ + + var poisonContext = AlertContextBuilders.BuildPoisonWaitContext(triggered); /* :307 */ + var detailText = AlertContextBuilders.ContextToDetailText(poisonContext); /* :308 */ + + /* :310-320. ShortMessage = the toast body of :302. */ + await _deliverer.DeliverAsync(new AlertOutcome( + key, serverName, "Poison Wait", + allWaitNames, + $"{_settings.PoisonWaitThresholdMs}ms avg", + poisonContext, detailText, + NumericCurrentValue: worst.AvgMsPerWait, + NumericThresholdValue: _settings.PoisonWaitThresholdMs, + Muted: isMuted, Severity: poisonContext?.SeverityOverride, + ShortMessage: $"{worst.WaitType} avg {worst.AvgMsPerWait:F0}ms/wait"), ct); + } + } + else if (_activePoisonWaitAlert.TryGetValue(key, out var wasPoisonWait) && wasPoisonWait) /* :323 */ + { + _activePoisonWaitAlert[key] = false; /* :325 */ + if (!suppressed) /* :326 */ + { + await NotifyResolutionAsync(new AlertResolution( + key, serverName, "Poison Wait", + "Poison Waits Cleared", /* :329 */ + $"{serverName}: Poison wait avg below threshold"), ct); /* :330 */ + } + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger?.LogError("Failed to check poison waits for {Server}: {Message}", serverName, ex.Message); /* :337 */ + } + } + + /* ---------------- long-running queries (Lite AlertEngine.cs:341-411) ---------------- */ + + private async Task CheckLongRunningQueriesAsync( + string key, string serverName, DateTime now, TimeSpan alertCooldown, bool suppressed, CancellationToken ct) + { + if (!_settings.LongRunningQueryEnabled) /* :342 */ + { + return; + } + + try + { + var longRunning = await _readAdapter.GetLongRunningQueriesAsync( /* :346 */ + key, + _settings.LongRunningQueryThresholdMinutes, + _settings.LongRunningQueryMaxResults, + _settings.LongRunningQueryExcludeSpServerDiagnostics, + _settings.LongRunningQueryExcludeWaitFor, + _settings.LongRunningQueryExcludeBackups, + _settings.LongRunningQueryExcludeMiscWaits, + _settings.LongRunningQueryExcludeCdc, + _settings.ExcludedDatabases, + ct); + + if (longRunning.Count > 0) + { + _activeLongRunningQueryAlert[key] = true; /* :350 */ + if (!suppressed && CooldownElapsed(_lastLongRunningQueryAlert, key, now, alertCooldown)) /* :351 */ + { + var worst = longRunning[0]; /* :353 */ + var elapsedMinutes = worst.ElapsedSeconds / 60; /* :354 — integer division, exactly Lite */ + /* :355-356 — the query-text preview feeds ShortMessage (the toast body). */ + var preview = AlertContextBuilders.TruncateText(worst.QueryText, 80); + var previewSuffix = string.IsNullOrEmpty(preview) ? "" : $" — {preview}"; + + var muteCtx = new AlertMuteContext /* :358-364 */ + { + ServerName = serverName, + MetricName = "Long-Running Query", + DatabaseName = worst.DatabaseName, + QueryText = worst.QueryText + }; + bool isMuted = _isAlertMuted(muteCtx); /* :365 */ + _lastLongRunningQueryAlert[key] = now; /* :366 */ + + var lrqContext = AlertContextBuilders.BuildLongRunningQueryContext(serverName, longRunning); /* :379 */ + var detailText = AlertContextBuilders.ContextToDetailText(lrqContext); /* :380 */ + + /* :382-392. ShortMessage = the toast body of :374. */ + await _deliverer.DeliverAsync(new AlertOutcome( + key, serverName, "Long-Running Query", + $"{longRunning.Count} query(s), longest {elapsedMinutes}m", + $"{_settings.LongRunningQueryThresholdMinutes}m", + lrqContext, detailText, + NumericCurrentValue: elapsedMinutes, + NumericThresholdValue: _settings.LongRunningQueryThresholdMinutes, + Muted: isMuted, Severity: lrqContext?.SeverityOverride, + ShortMessage: $"Session #{worst.SessionId} running {elapsedMinutes}m{previewSuffix}"), ct); + } + } + else if (_activeLongRunningQueryAlert.TryGetValue(key, out var wasLongRunning) && wasLongRunning) /* :395 */ + { + _activeLongRunningQueryAlert[key] = false; /* :397 */ + if (!suppressed) /* :398 */ + { + await NotifyResolutionAsync(new AlertResolution( + key, serverName, "Long-Running Query", + "Long-Running Queries Cleared", /* :401 */ + $"{serverName}: No queries over threshold"), ct); /* :402 */ + } + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger?.LogError("Failed to check long-running queries for {Server}: {Message}", serverName, ex.Message); /* :409 */ + } + } + + /* ---------------- tempdb space (Lite AlertEngine.cs:413-473) ---------------- */ + + private async Task CheckTempDbSpaceAsync( + string key, string serverName, DateTime now, TimeSpan alertCooldown, bool suppressed, CancellationToken ct) + { + if (!_settings.TempDbSpaceEnabled) /* :414 */ + { + return; + } + + try + { + var tempDb = await _readAdapter.GetTempDbSpaceAsync(key, ct); /* :418 */ + + if (tempDb != null && tempDb.UsedPercent >= _settings.TempDbSpaceThresholdPercent) /* :420 */ + { + _activeTempDbSpaceAlert[key] = true; /* :422 */ + if (!suppressed && CooldownElapsed(_lastTempDbSpaceAlert, key, now, alertCooldown)) /* :423 */ + { + var muteCtx = new AlertMuteContext { ServerName = serverName, MetricName = "tempdb Space" }; /* :425 */ + bool isMuted = _isAlertMuted(muteCtx); /* :426 */ + _lastTempDbSpaceAlert[key] = now; /* :427 */ + + var tempDbContext = AlertContextBuilders.BuildTempDbSpaceContext(tempDb); /* :440 */ + var detailText = AlertContextBuilders.ContextToDetailText(tempDbContext); /* :441 */ + + /* :443-453. ShortMessage = the toast body of :435. */ + await _deliverer.DeliverAsync(new AlertOutcome( + key, serverName, "tempdb Space", + $"{tempDb.UsedPercent:F0}% used ({tempDb.TotalReservedMb:F0} MB)", + $"{_settings.TempDbSpaceThresholdPercent}%", + tempDbContext, detailText, + NumericCurrentValue: tempDb.UsedPercent, + NumericThresholdValue: _settings.TempDbSpaceThresholdPercent, + Muted: isMuted, Severity: tempDbContext?.SeverityOverride, + ShortMessage: $"tempdb {tempDb.UsedPercent:F0}% used"), ct); + } + } + else if (_activeTempDbSpaceAlert.TryGetValue(key, out var wasTempDb) && wasTempDb) /* :456 */ + { + _activeTempDbSpaceAlert[key] = false; /* :458 */ + if (!suppressed) /* :459 */ + { + var pct = tempDb != null ? $"{tempDb.UsedPercent:F0}%" : "N/A"; /* :461 */ + await NotifyResolutionAsync(new AlertResolution( + key, serverName, "tempdb Space", + "tempdb Space Resolved", /* :463 */ + $"{serverName}: tempdb usage back to {pct}"), ct); /* :464 */ + } + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger?.LogError("Failed to check TempDB space for {Server}: {Message}", serverName, ex.Message); /* :471 */ + } + } + + /* ---------------- volume free space (Lite AlertEngine.cs:475-555) ---------------- */ + + /// <returns> + /// True when at least one volume is breached this sweep — the standing condition Lite's #754 + /// tab badge derives from (:487 <c>curBadgeLowDisk</c>), computed BEFORE the worsening/cooldown/ + /// suppression gates. False when the check is disabled or the read failed. + /// </returns> + private async Task<bool> CheckLowDiskAsync( + string key, string serverName, DateTime now, TimeSpan alertCooldown, bool suppressed, CancellationToken ct) + { + if (!_settings.LowDiskEnabled) /* :476 */ + { + return false; + } + + bool conditionPresent = false; + try + { + var volumes = await _readAdapter.GetVolumeFreeSpaceAsync(key, ct); /* :480 */ + var breached = AlertContextBuilders.GetBreachedVolumes(volumes, _settings.LowDiskThresholdPercent, _settings.LowDiskThresholdGb); /* :481 */ + conditionPresent = breached.Count > 0; /* :487 — feeds the sweep result */ + + if (breached.Count > 0) + { + var worst = breached[0]; /* :489 */ + _activeLowDiskAlert[key] = true; /* :490 */ + double? lastLowDiskPercent = + _lastAlertedLowDiskPercent.TryGetValue(key, out var lowDiskPct) ? lowDiskPct : (double?)null; /* :491-492 */ + /* :493-497 — #754 follow-up: notify only on a fresh or worsening breach. */ + if (!suppressed + && LowDiskAlertGate.ShouldAlert(worst.FreePercent, lastLowDiskPercent) + && CooldownElapsed(_lastLowDiskAlert, key, now, alertCooldown)) + { + var muteCtx = new AlertMuteContext { ServerName = serverName, MetricName = "Volume Free Space" }; /* :499 */ + bool isMuted = _isAlertMuted(muteCtx); /* :500 */ + _lastLowDiskAlert[key] = now; /* :501 */ + _lastAlertedLowDiskPercent[key] = worst.FreePercent; /* :502 */ + + var lowDiskContext = AlertContextBuilders.BuildVolumeFreeSpaceContext(serverName, breached); /* :515 */ + /* :516-522 — #1136: grade WARNING normally, CRITICAL when critically low. */ + if (lowDiskContext is not null && LowDiskAlertGate.IsCriticallyLow(worst.FreePercent, worst.FreeGb)) + { + lowDiskContext.SeverityOverride = AlertSeverityLevel.Critical; + } + var detailText = AlertContextBuilders.ContextToDetailText(lowDiskContext); /* :523 */ + + /* :525-535. ShortMessage = the toast body of :510. */ + await _deliverer.DeliverAsync(new AlertOutcome( + key, serverName, "Volume Free Space", + $"{worst.MountPoint} {worst.FreePercent:F0}% free ({worst.FreeGb:F1} GB)", + AlertContextBuilders.FormatLowDiskThreshold(_settings.LowDiskThresholdPercent, _settings.LowDiskThresholdGb), + lowDiskContext, detailText, + NumericCurrentValue: worst.FreePercent, + NumericThresholdValue: _settings.LowDiskThresholdPercent, + Muted: isMuted, Severity: lowDiskContext?.SeverityOverride, + ShortMessage: $"{worst.MountPoint} {worst.FreePercent:F0}% free ({worst.FreeGb:F1} GB)"), ct); + } + } + else if (_activeLowDiskAlert.TryGetValue(key, out var wasLowDisk) && wasLowDisk) /* :538 */ + { + _activeLowDiskAlert[key] = false; /* :540 */ + _lastAlertedLowDiskPercent.TryRemove(key, out _); /* :541 */ + if (!suppressed) /* :542 */ + { + await NotifyResolutionAsync(new AlertResolution( + key, serverName, "Volume Free Space", + "Volume Free Space Resolved", /* :545 */ + $"{serverName}: All volumes back above threshold"), ct); /* :546 */ + } + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger?.LogError("Failed to check volume free space for {Server}: {Message}", serverName, ex.Message); /* :553 */ + } + + return conditionPresent; + } + + /* ---------------- anomalous Agent jobs (Lite AlertEngine.cs:557-632) ---------------- */ + + private async Task CheckAnomalousJobsAsync( + string key, string serverName, DateTime now, TimeSpan alertCooldown, bool suppressed, CancellationToken ct) + { + if (!_settings.LongRunningJobEnabled) /* :558 */ + { + return; + } + + try + { + var anomalousJobs = await _readAdapter.GetAnomalousJobsAsync(key, _settings.LongRunningJobMultiplier, ct); /* :562 */ + + /* :564-573 — the per-run cooldown dict grows without bound; drop entries aged past + the cooldown each pass (scans ALL servers' entries, exactly like Lite). */ + foreach (var staleJobKey in _lastLongRunningJobAlert + .Where(kv => now - kv.Value >= alertCooldown) + .Select(kv => kv.Key) + .ToList()) + { + _lastLongRunningJobAlert.TryRemove(staleJobKey, out _); + } + + if (anomalousJobs.Count > 0) + { + _activeLongRunningJobAlert[key] = true; /* :577 */ + var worst = anomalousJobs[0]; /* :578 */ + var jobKey = $"{key}:{worst.JobId}:{worst.StartTime:O}"; /* :579 */ + + if (!suppressed && (!_lastLongRunningJobAlert.TryGetValue(jobKey, out var lastJob) || now - lastJob >= alertCooldown)) /* :581 */ + { + var currentMinutes = worst.CurrentDurationSeconds / 60; /* :583 — feeds ShortMessage (the toast body) */ + var muteCtx = new AlertMuteContext { ServerName = serverName, MetricName = "Long-Running Job", JobName = worst.JobName }; /* :585 */ + bool isMuted = _isAlertMuted(muteCtx); /* :586 */ + _lastLongRunningJobAlert[jobKey] = now; /* :587 */ + + var jobContext = AlertContextBuilders.BuildAnomalousJobContext(serverName, anomalousJobs); /* :600 */ + var detailText = AlertContextBuilders.ContextToDetailText(jobContext); /* :601 */ + + /* :603-613. ShortMessage = the toast body of :595. */ + await _deliverer.DeliverAsync(new AlertOutcome( + key, serverName, "Long-Running Job", + $"{anomalousJobs.Count} job(s) exceeding {_settings.LongRunningJobMultiplier}x average", + $"{_settings.LongRunningJobMultiplier}x historical avg", + jobContext, detailText, + NumericCurrentValue: (double)(worst.PercentOfAverage ?? 0), + NumericThresholdValue: _settings.LongRunningJobMultiplier * 100, + Muted: isMuted, Severity: jobContext?.SeverityOverride, + ShortMessage: $"{worst.JobName} at {worst.PercentOfAverage:F0}% of avg ({currentMinutes}m)"), ct); + } + } + else if (_activeLongRunningJobAlert.TryGetValue(key, out var wasJob) && wasJob) /* :616 */ + { + _activeLongRunningJobAlert[key] = false; /* :618 */ + if (!suppressed) /* :619 */ + { + await NotifyResolutionAsync(new AlertResolution( + key, serverName, "Long-Running Job", + "Long-Running Jobs Cleared", /* :622 */ + $"{serverName}: No jobs exceeding threshold"), ct); /* :623 */ + } + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger?.LogError("Failed to check anomalous jobs for {Server}: {Message}", serverName, ex.Message); /* :630 */ + } + } + + /* ---------------- failed Agent jobs (Lite AlertEngine.cs:634-717) ---------------- */ + + /// <returns> + /// True when the fetcher returned at least one failure in the lookback window — the standing + /// condition Lite's #749 tab badge derives from (:663 <c>curBadgeFailedJob</c>), computed + /// BEFORE the watermark/cooldown/suppression gates. False when the check is disabled, the + /// server is offline/Azure SQL DB, or the fetch failed. + /// </returns> + private async Task<bool> CheckFailedJobsAsync( + AlertServerSnapshot snapshot, string key, string serverName, + DateTime now, TimeSpan alertCooldown, bool suppressed, CancellationToken ct) + { + if (!_settings.FailedJobEnabled || _failedJobsFetcher is null) /* :639 */ + { + return false; + } + + bool conditionPresent = false; + try + { + /* :649-653 — Lite gates on online + non-Azure-SQL-DB + HasMsdbAccess. The engine + gates on the snapshot's online + IsAzureSqlDb flags; the msdb-access probe is + deliberately NOT part of the seam (Phase-5 review F11) — hosts degrade a denied + msdb read to an empty list inside the fetcher instead. Failures are point-in-time + events: no "cleared" notification, watermark-dedup only (:634-638). */ + if (!snapshot.IsOnline || snapshot.IsAzureSqlDb) + { + return false; + } + + var failedJobs = await _failedJobsFetcher(key, _settings.FailedJobLookbackMinutes, ct); /* :657 */ + conditionPresent = failedJobs.Count > 0; /* :663 — feeds the sweep result */ + + if (failedJobs.Count > 0) + { + var newestFailure = failedJobs.Max(j => j.RunDateTime); /* :665 */ + bool hasWatermark = _lastAlertedFailedJobTime.TryGetValue(key, out var lastFailure); /* :666 */ + bool hasNewFailure = !hasWatermark || newestFailure > lastFailure; /* :667 */ + + if (hasNewFailure && !suppressed && + CooldownElapsed(_lastFailedJobAlert, key, now, alertCooldown)) /* :669-670 */ + { + var mostRecent = failedJobs[0]; /* ORDER BY run_datetime DESC — :672 */ + var jobNames = string.Join(", ", failedJobs.Select(j => j.JobName).Distinct().Take(3)); /* :673 */ + + var muteCtx = new AlertMuteContext { ServerName = serverName, MetricName = "Failed Agent Job", JobName = mostRecent.JobName }; /* :675 */ + bool isMuted = _isAlertMuted(muteCtx); /* :676 */ + _lastFailedJobAlert[key] = now; /* :677 */ + _lastAlertedFailedJobTime[key] = newestFailure; /* :678 */ + /* :679-682 — persist the SERVER-LOCAL watermark on-change only (#1145 parity). */ + await _stateStore.SaveFailedJobWatermarkAsync(key, newestFailure); + + var failedJobContext = AlertContextBuilders.BuildFailedJobContext(serverName, failedJobs); /* :695 */ + var detailText = AlertContextBuilders.ContextToDetailText(failedJobContext); /* :696 */ + + /* :698-708. ShortMessage = the toast body of :690. */ + await _deliverer.DeliverAsync(new AlertOutcome( + key, serverName, "Failed Agent Job", + $"{failedJobs.Count} job failure(s) in last {_settings.FailedJobLookbackMinutes}m — {jobNames}", + $"last {_settings.FailedJobLookbackMinutes}m", + failedJobContext, detailText, + NumericCurrentValue: failedJobs.Count, + NumericThresholdValue: 0, + Muted: isMuted, Severity: failedJobContext?.SeverityOverride, + ShortMessage: $"{failedJobs.Count} job failure(s) — {jobNames}"), ct); + } + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger?.LogError("Failed to check failed jobs for {Server}: {Message}", serverName, ex.Message); /* :715 */ + } + + return conditionPresent; + } + + /* ---------------- helpers ---------------- */ + + /// <summary>Lite's per-check cooldown test: no prior fire, or the cooldown has elapsed.</summary> + private static bool CooldownElapsed( + ConcurrentDictionary<string, DateTime> lastFired, string key, DateTime now, TimeSpan cooldown) => + !lastFired.TryGetValue(key, out var last) || now - last >= cooldown; + + /// <summary> + /// Reports a condition-recovered transition to the optional host callback. Callback failures + /// are logged and swallowed — a broken toast/log hook must not abort the sweep. + /// </summary> + private async Task NotifyResolutionAsync(AlertResolution resolution, CancellationToken ct) + { + if (_resolutionCallback is null) + { + return; + } + + try + { + await _resolutionCallback(resolution, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger?.LogError("Alert resolution callback failed for {Server} / {Metric}: {Message}", + resolution.ServerName, resolution.MetricName, ex.Message); + } + } +} diff --git a/PerformanceMonitor.Alerting/AlertResolution.cs b/PerformanceMonitor.Alerting/AlertResolution.cs new file mode 100644 index 000000000..68c908788 --- /dev/null +++ b/PerformanceMonitor.Alerting/AlertResolution.cs @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +namespace PerformanceMonitor.Alerting; + +/// <summary> +/// A condition-recovered notification the engine emits on an active→inactive transition — the +/// engine-side twin of Lite's tray-only "Resolved"/"Cleared" toasts +/// (<c>ShowStyledNotification(..., ToastSeverity.Success)</c> in <c>MainWindow.AlertEngine.cs</c>). +/// Lite records NO history row and sends NO email for these, so they deliberately do NOT flow +/// through <see cref="IAlertDeliverer"/>: the engine hands them to an optional host callback +/// instead. <see cref="Title"/>/<see cref="Message"/> carry Lite's exact strings so a forwarding +/// Lite renders identical toasts (always Success severity — every Lite resolution toast is); +/// Darling logs them. +/// </summary> +/// <param name="ServerKey">The engine's stable identity for the server.</param> +/// <param name="ServerName">Display name (already embedded in <see cref="Message"/>).</param> +/// <param name="MetricName">The alert metric that recovered (e.g. "High CPU") — for host filtering.</param> +/// <param name="Title">Lite's toast title, verbatim (e.g. "CPU Resolved", "Blocking Cleared").</param> +/// <param name="Message">Lite's toast message, verbatim (e.g. "SERVER: Total CPU back to 12%").</param> +public sealed record AlertResolution( + string ServerKey, + string ServerName, + string MetricName, + string Title, + string Message); diff --git a/PerformanceMonitor.Alerting/AlertServerSnapshot.cs b/PerformanceMonitor.Alerting/AlertServerSnapshot.cs new file mode 100644 index 000000000..bba8295a5 --- /dev/null +++ b/PerformanceMonitor.Alerting/AlertServerSnapshot.cs @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +namespace PerformanceMonitor.Alerting; + +/// <summary> +/// The per-server input to one <see cref="AlertEngine.EvaluateServerAsync"/> sweep — the fields +/// Lite's <c>ServerSummaryItem</c> fed <c>CheckPerformanceAlerts</c>, minus everything the engine +/// now derives itself through <see cref="IAlertReadAdapter"/> (blocking/deadlock counts) and minus +/// the UI-only members (display brushes, badges). The host builds one per server per sweep. +/// </summary> +/// <param name="ServerKey"> +/// The engine's stable identity for the server — the same key convention as +/// <see cref="IAlertStateStore"/>/<see cref="IAlertReadAdapter"/> (Lite/Darling: the deterministic +/// storage-name hash rendered as a string). All engine state dictionaries key on it. +/// </param> +/// <param name="ServerName">Display name rendered in notifications and matched by mute rules.</param> +/// <param name="IsOnline"> +/// Whether the monitored server is currently reachable. Gates ONLY the live failed-jobs check +/// (Lite gates it on <c>connStatus.IsOnline == true</c>); every collected-store check evaluates +/// regardless, exactly like Lite's loop. +/// </param> +/// <param name="SqlCpuPercent"> +/// Latest collected SQL Server scheduler ProcessUtilization %, or null when no sample exists +/// (mirrors <c>ServerSummaryItem.CpuPercent</c>). +/// </param> +/// <param name="TotalCpuPercent"> +/// Latest total non-idle CPU % (SQL process + other processes), or null when the SQL sample is +/// null (mirrors <c>ServerSummaryItem.TotalCpuPercent</c> = CpuPercent + (OtherProcessCpuPercent ?? 0)). +/// </param> +/// <param name="IsAzureSqlDb"> +/// True for Azure SQL DB — skips the failed-jobs check (no SQL Agent), mirroring Lite's +/// <c>SqlEngineEdition != 5</c> call-site gate. +/// </param> +/// <param name="Suppressed"> +/// Suppression is an INPUT (Phase-5 review): true = evaluate-but-don't-deliver, exactly Lite's +/// <c>suppressPopups</c> — edge-trigger watermarks don't advance where Lite's don't. Lite forwards +/// its per-server acknowledge/silence state here; Darling always passes false. +/// </param> +public sealed record AlertServerSnapshot( + string ServerKey, + string ServerName, + bool IsOnline, + double? SqlCpuPercent, + double? TotalCpuPercent, + bool IsAzureSqlDb, + bool Suppressed); diff --git a/PerformanceMonitor.Alerting/AlertSweepResult.cs b/PerformanceMonitor.Alerting/AlertSweepResult.cs new file mode 100644 index 000000000..29e2c5198 --- /dev/null +++ b/PerformanceMonitor.Alerting/AlertSweepResult.cs @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +namespace PerformanceMonitor.Alerting; + +/// <summary> +/// What one <see cref="AlertEngine.EvaluateServerAsync"/> sweep OBSERVED, as opposed to what it +/// delivered. Fired alerts flow through <see cref="IAlertDeliverer"/> and recoveries through the +/// resolution callback; this result carries the two STANDING conditions an interactive host's +/// server-tab badge is computed from (Lite #754/#749) — a badge lights whenever the condition is +/// present this sweep, independent of the edge-trigger/cooldown/suppression/mute gates that decide +/// whether anything is delivered, so it cannot be reconstructed from outcomes alone (a suppressed +/// or cooldown-gated breach still lights it; a recovered/disabled check clears it). Headless hosts +/// ignore the result. +/// </summary> +/// <param name="Evaluated"> +/// False when the sweep did not run at all (the <see cref="IAlertEngineSettings.AlertsEnabled"/> +/// master switch is off) — mirroring Lite's early return, after which the host leaves its badge +/// flags untouched rather than clearing them. +/// </param> +/// <param name="LowDiskConditionPresent"> +/// True when the low-disk check ran and saw at least one breached volume +/// (<c>GetBreachedVolumes(...).Count > 0</c> — Lite's <c>curBadgeLowDisk</c>). False when the +/// check is disabled or its read failed, so a stale badge clears exactly like Lite's #1128 +/// sweep-end write. +/// </param> +/// <param name="FailedJobConditionPresent"> +/// True when the failed-jobs check ran and the fetcher returned at least one failure in the +/// lookback window (Lite's <c>curBadgeFailedJob</c>) — before the watermark/cooldown gates, so a +/// failure the user was already notified about keeps the badge lit for the whole window. False +/// when the check is disabled, the server is offline/Azure SQL DB, or the fetch failed. +/// </param> +public sealed record AlertSweepResult( + bool Evaluated, + bool LowDiskConditionPresent, + bool FailedJobConditionPresent) +{ + /// <summary>The master-switch-off result: nothing ran, hosts leave badge state untouched.</summary> + public static readonly AlertSweepResult NotEvaluated = new(false, false, false); +} diff --git a/PerformanceMonitor.Alerting/AnomalousJobInfo.cs b/PerformanceMonitor.Alerting/AnomalousJobInfo.cs new file mode 100644 index 000000000..b20a417ab --- /dev/null +++ b/PerformanceMonitor.Alerting/AnomalousJobInfo.cs @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; + +namespace PerformanceMonitor.Alerting; + +/// <summary> +/// A currently-running SQL Agent job whose duration exceeds the anomaly threshold (Nx its +/// historical average), used by the long-running-job alert. +/// Canonical shared copy (Phase-5 A0) — Lite and the Dashboard previously carried member-identical +/// local twins; both apps now alias this type via a global using so call sites are unchanged. +/// </summary> +public class AnomalousJobInfo +{ + public string JobName { get; set; } = ""; + public string JobId { get; set; } = ""; + public long CurrentDurationSeconds { get; set; } + public long AvgDurationSeconds { get; set; } + public long P95DurationSeconds { get; set; } + public decimal? PercentOfAverage { get; set; } + public DateTime StartTime { get; set; } +} diff --git a/PerformanceMonitor.Alerting/BlockedProcessAlertRow.cs b/PerformanceMonitor.Alerting/BlockedProcessAlertRow.cs new file mode 100644 index 000000000..80f5b38dc --- /dev/null +++ b/PerformanceMonitor.Alerting/BlockedProcessAlertRow.cs @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace PerformanceMonitor.Alerting; + +/// <summary> +/// One blocked-process event as the alert engine consumes it (Phase-5 slice B) — the superset of +/// fields the shared <see cref="AlertContextBuilders.BuildBlockingContext"/> builder, the +/// <c>BlockingIncidentGrouper</c> projection, the alert loop's excluded-database count filter, and +/// the XE→DMV fallback merge (<see cref="BlockedProcessReportMerge"/>) actually touch. Lite's +/// grid row (<c>BlockedProcessReportRow</c>) derives from this so its store reads flow into the +/// shared builders without any mapping copy; the Darling adapter materializes this type directly. +/// Store-specific display members (formatted local times, grid badges) stay on the derived types. +/// </summary> +public class BlockedProcessAlertRow +{ + /// <summary>Source tag for rows captured by the blocked-process-report XE session.</summary> + public const string XeReportSource = "blocked-process-report"; + + /// <summary>Source tag for rows from the always-on DMV blocking-snapshot fallback.</summary> + public const string DmvSnapshotSource = "DMV snapshot"; + + public DateTime? EventTime { get; set; } + public string DatabaseName { get; set; } = ""; + public int BlockedSpid { get; set; } + public int BlockingSpid { get; set; } + public long WaitTimeMs { get; set; } + public string LockMode { get; set; } = ""; + public string BlockedSqlText { get; set; } = ""; + public string BlockingSqlText { get; set; } = ""; + public string BlockedProcessReportXml { get; set; } = ""; + public string ContentiousObject { get; set; } = ""; + + /// <summary> + /// Where this row came from: the blocked-process-report XE ("blocked-process-report") or the + /// always-on DMV snapshot fallback ("DMV snapshot"). Surfaced as a grid badge so a row captured + /// when the blocked-process threshold was unset (e.g. AWS RDS) is distinguishable. + /// </summary> + public string Source { get; set; } = XeReportSource; + + public bool HasReportXml => !string.IsNullOrEmpty(BlockedProcessReportXml); +} + +/// <summary> +/// The XE→DMV blocking fallback merge — moved verbatim from the tail of Lite's +/// <c>LocalDataService.AppendDmvBlockedProcessGridRowsAsync</c> so the Lite and Darling +/// <see cref="IAlertReadAdapter"/> implementations reproduce EXACTLY the same semantics: +/// keep all blocked-process-report rows; append a DMV-snapshot row only when no BPR row covers +/// the same (blocked, blocker) SPID pair within the same minute (BPR preferred — richer report +/// XML); then re-cap to the newest <paramref name="cap"/> rows. OrderByDescending is stable, so +/// BPR rows (added first) win ties over DMV rows at the same event time. +/// </summary> +public static class BlockedProcessReportMerge +{ + /// <summary>Lite's grid LIMIT — both stores fetch and re-cap at 200 rows.</summary> + public const int DefaultCap = 200; + + public static void AppendDmvFallbackRows<TRow>(List<TRow> items, List<TRow> dmvItems, int cap = DefaultCap) + where TRow : BlockedProcessAlertRow + { + if (items is null) throw new ArgumentNullException(nameof(items)); + if (dmvItems is null || dmvItems.Count == 0) return; + + // Dedup: keep all BPR rows; add a DMV row only if no BPR row covers the same (blocked, blocker) + // SPID within the same minute. BPR is preferred (richer report XML). + var seen = new HashSet<(int, int, long)>(); + foreach (var b in items) + if (b.EventTime.HasValue) + seen.Add((b.BlockedSpid, b.BlockingSpid, b.EventTime.Value.Ticks / TimeSpan.TicksPerMinute)); + + foreach (var d in dmvItems) + { + var key = (d.BlockedSpid, d.BlockingSpid, (d.EventTime?.Ticks ?? 0) / TimeSpan.TicksPerMinute); + if (seen.Add(key)) + items.Add(d); + } + + // Re-cap to the grid's LIMIT, newest first. OrderByDescending is stable, so BPR rows (added + // first) win ties over DMV rows at the same event time — matching the Dashboard grid. + var merged = items.OrderByDescending(i => i.EventTime ?? DateTime.MinValue).Take(cap).ToList(); + items.Clear(); + items.AddRange(merged); + } +} diff --git a/PerformanceMonitor.Alerting/DeadlockAlertRow.cs b/PerformanceMonitor.Alerting/DeadlockAlertRow.cs new file mode 100644 index 000000000..be261baca --- /dev/null +++ b/PerformanceMonitor.Alerting/DeadlockAlertRow.cs @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Xml.Linq; + +namespace PerformanceMonitor.Alerting; + +/// <summary> +/// One deadlock event as the alert engine consumes it (Phase-5 slice B) — the superset of fields +/// the shared <see cref="AlertContextBuilders.BuildDeadlockContext"/> builder, the +/// <c>DeadlockIncidentGrouper</c>/<c>DeadlockObjectExtractor</c> projections, and the +/// excluded-database check (<see cref="AlertContextBuilders.IsDeadlockExcluded"/>) actually touch. +/// Lite's grid row (<c>DeadlockRow</c>) derives from this so its store reads flow into the shared +/// builders without any mapping copy; the Darling adapter materializes this type directly. +/// </summary> +public class DeadlockAlertRow +{ + public string VictimProcessId { get; set; } = ""; + public string VictimSqlText { get; set; } = ""; + public string DeadlockGraphXml { get; set; } = ""; + public bool HasDeadlockXml => !string.IsNullOrEmpty(DeadlockGraphXml); + + /// <summary> + /// Parses the deadlock graph XML and returns a summary of all processes involved + /// ("SPID 55 (victim) vs SPID 60"). Computed on access, exactly like the pre-extraction + /// Lite property — see <see cref="DeadlockGraphSummary.Summarize"/>. + /// </summary> + public string ProcessSummary => DeadlockGraphSummary.Summarize(DeadlockGraphXml, VictimProcessId); +} + +/// <summary> +/// The deadlock-graph process-summary parse — moved verbatim from Lite's +/// <c>DeadlockRow.ProcessSummary</c> getter (Phase-5 slice B) so the row shape is store-free and +/// the rendered "Processes" field cannot drift between hosts. Returns "" for empty or unparseable +/// XML (the builder omits the field then). +/// </summary> +public static class DeadlockGraphSummary +{ + public static string Summarize(string? deadlockGraphXml, string? victimProcessId) + { + if (string.IsNullOrEmpty(deadlockGraphXml)) + { + return ""; + } + + try + { + var doc = XElement.Parse(deadlockGraphXml); + var processes = doc.Descendants("process"); + var summaries = new List<string>(); + + foreach (var proc in processes) + { + var id = proc.Attribute("id")?.Value ?? "?"; + var spid = proc.Attribute("spid")?.Value ?? "?"; + var isVictim = string.Equals(id, victimProcessId, StringComparison.OrdinalIgnoreCase); + summaries.Add($"SPID {spid}{(isVictim ? " (victim)" : "")}"); + } + + return string.Join(" vs ", summaries); + } + catch + { + return ""; + } + } +} diff --git a/PerformanceMonitor.Alerting/FailedJobInfo.cs b/PerformanceMonitor.Alerting/FailedJobInfo.cs new file mode 100644 index 000000000..f556895b5 --- /dev/null +++ b/PerformanceMonitor.Alerting/FailedJobInfo.cs @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; + +namespace PerformanceMonitor.Alerting; + +/// <summary> +/// A SQL Agent job run that FAILED within the alert lookback window. Sourced from a live +/// msdb.dbo.sysjobhistory query at alert-check time (failure outcomes are not part of the +/// collected running_jobs snapshot). StepId/StepName/Message describe the actual failing step +/// (correlated from the run's step rows), falling back to the job-outcome row when a job-level +/// failure has no failed step. +/// Canonical shared copy (Phase-5 A0) — Lite and the Dashboard previously carried member-identical +/// local twins; both apps now alias this type via a global using so call sites are unchanged. +/// </summary> +public class FailedJobInfo +{ + public string JobName { get; set; } = ""; + public string JobId { get; set; } = ""; + + /// <summary>Server-local time the failed run started (from run_date/run_time).</summary> + public DateTime RunDateTime { get; set; } + public int StepId { get; set; } + public string StepName { get; set; } = ""; + public string Message { get; set; } = ""; + + public string RunDateTimeFormatted => RunDateTime.ToString("yyyy-MM-dd HH:mm:ss"); +} diff --git a/PerformanceMonitor.Alerting/FailedJobsQuery.cs b/PerformanceMonitor.Alerting/FailedJobsQuery.cs new file mode 100644 index 000000000..9b6fcf6d5 --- /dev/null +++ b/PerformanceMonitor.Alerting/FailedJobsQuery.cs @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Alerting; + +/// <summary> +/// The live-msdb failed-jobs alert feed (Phase-5 slice E): the query and row mapping behind +/// <see cref="FailedJobInfo"/>, previously duplicated verbatim in Lite's +/// <c>RemoteCollectorService.RunningJobs.cs</c> and the Dashboard's +/// <c>DatabaseService.NocHealth.cs</c>. This is NOT a collected table — it runs against the +/// monitored server's msdb at alert-check time, because failure outcomes are not part of the +/// collected <c>running_jobs</c> snapshot. It finds job runs that FAILED within the lookback +/// window (the step_id = 0 outcome row, run_status = 0) and correlates the actual failing step +/// (step_id > 0, run_status = 0) from that run via instance_id, so step_id/step_name/message +/// describe the failing step rather than the generic "(Job outcome)" row. +/// <para> +/// Hosts execute <see cref="Sql"/> on their own connections with their own gating: +/// Lite pre-gates the call (online + non-Azure-SQL-DB + <c>HasMsdbAccess</c>) and rides the +/// collector's connection path (MFA serialization, throttle, retry); the Dashboard skips Azure +/// SQL DB (engineEdition == 5) at the call site and degrades a login without msdb / +/// SQLAgentReaderRole access (SqlException 229/297/300/916) to an empty list; the future Darling +/// engine gates on !IsAzureSqlDb + the same exception degrade — per the Phase-5 review finding +/// (F11), Darling must NOT invent an msdb access probe (no <c>HasMsdbAccess</c> exists there). +/// Every host returns an empty list on failure so a permission gap or transient error never +/// fails the alert cycle. +/// </para> +/// </summary> +public static class FailedJobsQuery +{ + /// <summary> + /// The single parameter <see cref="Sql"/> expects: the lookback window in minutes (int). + /// Hosts bind it on their own command (SqlParameter / AddWithValue). + /// </summary> + public const string LookbackMinutesParameter = "@lookback_minutes"; + + /// <summary> + /// run_date/run_time integers are converted to a SERVER-LOCAL datetime (GETDATE()-relative + /// lookback filter) — <see cref="FailedJobInfo.RunDateTime"/> is server-local, not UTC. + /// </summary> + public const string Sql = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT TOP (50) + job_name = j.name, + job_id = CONVERT(varchar(36), j.job_id), + run_datetime = + DATEADD + ( + SECOND, + (jh.run_time / 10000) * 3600 + + ((jh.run_time / 100) % 100) * 60 + + (jh.run_time % 100), + CONVERT(datetime, CONVERT(varchar(8), jh.run_date)) + ), + step_id = ISNULL(fs.step_id, jh.step_id), + step_name = ISNULL(fs.step_name, jh.step_name), + message = ISNULL(fs.message, jh.message) +FROM msdb.dbo.sysjobhistory AS jh +JOIN msdb.dbo.sysjobs AS j + ON j.job_id = jh.job_id +OUTER APPLY +( + /* The actual failing step (step_id > 0, run_status = 0) from THIS run, correlated by + instance_id and bounded to be after this job's previous outcome row, so the alert names + the failing step instead of the generic job-outcome row. Falls back to the outcome row + for a rare job-level failure with no failed step row. */ + SELECT TOP (1) + s.step_id, + s.step_name, + s.message + FROM msdb.dbo.sysjobhistory AS s + WHERE s.job_id = jh.job_id + AND s.step_id > 0 + AND s.run_status = 0 + AND s.instance_id < jh.instance_id + AND s.instance_id > + ( + SELECT ISNULL(MAX(p.instance_id), 0) + FROM msdb.dbo.sysjobhistory AS p + WHERE p.job_id = jh.job_id + AND p.step_id = 0 + AND p.instance_id < jh.instance_id + ) + ORDER BY s.instance_id DESC +) AS fs +WHERE jh.step_id = 0 +AND jh.run_status = 0 +AND jh.run_date >= CONVERT(integer, CONVERT(varchar(8), DATEADD(MINUTE, -@lookback_minutes, GETDATE()), 112)) +AND DATEADD + ( + SECOND, + (jh.run_time / 10000) * 3600 + + ((jh.run_time / 100) % 100) * 60 + + (jh.run_time % 100), + CONVERT(datetime, CONVERT(varchar(8), jh.run_date)) + ) >= DATEADD(MINUTE, -@lookback_minutes, GETDATE()) +ORDER BY + run_datetime DESC +OPTION(RECOMPILE);"; + + /// <summary> + /// Maps <see cref="Sql"/>'s result rows (job_name, job_id, run_datetime, step_id, step_name, + /// message — in that ordinal order) into <see cref="FailedJobInfo"/>. Moved verbatim from the + /// two host copies; the ONE reconciled difference at extraction time: the step_id conversion + /// adopts the Dashboard's explicit <see cref="CultureInfo.InvariantCulture"/> (Lite omitted + /// the provider — behaviorally identical for an int column). + /// </summary> + public static async Task<List<FailedJobInfo>> ReadAsync(DbDataReader reader, CancellationToken cancellationToken) + { + var items = new List<FailedJobInfo>(); + + while (await reader.ReadAsync(cancellationToken)) + { + items.Add(new FailedJobInfo + { + JobName = reader.GetString(0), + JobId = reader.IsDBNull(1) ? "" : reader.GetString(1), + RunDateTime = reader.GetDateTime(2), + StepId = reader.IsDBNull(3) ? 0 : Convert.ToInt32(reader.GetValue(3), CultureInfo.InvariantCulture), + StepName = reader.IsDBNull(4) ? "" : reader.GetString(4), + Message = reader.IsDBNull(5) ? "" : reader.GetString(5) + }); + } + + return items; + } +} diff --git a/PerformanceMonitor.Alerting/IAlertDeliverer.cs b/PerformanceMonitor.Alerting/IAlertDeliverer.cs new file mode 100644 index 000000000..c4ab49302 --- /dev/null +++ b/PerformanceMonitor.Alerting/IAlertDeliverer.cs @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Threading; +using System.Threading.Tasks; +using PerformanceMonitor.Notifications; + +namespace PerformanceMonitor.Alerting; + +/// <summary> +/// One fired alert as the Phase-5 shared engine emits it — everything a host needs to record it to +/// alert history and send it out (tray/email/webhook). The member shape mirrors the argument list +/// both apps already pass to <c>EmailAlertService.TrySendAlertEmailAsync</c> / <c>RecordAlert</c>, +/// so the per-app deliverers are thin adapters. +/// </summary> +/// <param name="ServerKey">The engine's stable identity for the server (see <see cref="IAlertStateStore"/>).</param> +/// <param name="ServerName">Display name rendered in notifications.</param> +/// <param name="MetricName">The alert metric name (e.g. "High CPU", "Deadlocks Detected") — the key mute rules, severity, and history filtering match on.</param> +/// <param name="CurrentValue">Human-readable current value (e.g. "3 deadlock(s) in the last hour").</param> +/// <param name="ThresholdValue">Human-readable threshold it breached (e.g. "1", "80%").</param> +/// <param name="Context">Structured detail context (built by <see cref="AlertContextBuilders"/>), or null when no detail was resolvable.</param> +/// <param name="DetailText">Flat plain-text rendering of <paramref name="Context"/> (the engine emits <see cref="AlertContextBuilders.ContextToDetailText"/> output), or null.</param> +/// <param name="NumericCurrentValue">Numeric current value for history charting/thresholding, when the metric has one.</param> +/// <param name="NumericThresholdValue">Numeric threshold twin of <paramref name="NumericCurrentValue"/>.</param> +/// <param name="Muted">True when a mute rule matched — the host records the alert but must not toast/send it.</param> +/// <param name="Severity">Runtime severity override (e.g. low-disk grading WARNING vs CRITICAL, #1136), or null to use the per-metric severity map.</param> +/// <param name="ShortMessage"> +/// One-line human-readable summary of the fired condition WITHOUT the server-name prefix — +/// verbatim the per-metric tray-toast body Lite's pre-forwarding loop composed (e.g. +/// "Total CPU at 92% (threshold: 80%)", "Session #55 running 45m — SELECT ..."). Composed by the +/// engine because some bodies need per-row data (worst session id / query preview / job minutes) +/// that the other display fields don't carry; interactive hosts render it as +/// <c>$"{ServerName}: {ShortMessage}"</c>, headless hosts may log or ignore it. +/// </param> +public sealed record AlertOutcome( + string ServerKey, + string ServerName, + string MetricName, + string CurrentValue, + string ThresholdValue, + AlertContext? Context, + string? DetailText, + double? NumericCurrentValue, + double? NumericThresholdValue, + bool Muted, + AlertSeverityLevel? Severity, + string? ShortMessage = null); + +/// <summary> +/// The record-and-send seam for the Phase-5 shared alert engine: the engine evaluates conditions +/// and calls this once per fired alert; everything channel- and store-specific happens behind it. +/// One of the three engine seams (with <see cref="IAlertEngineSettings"/> and +/// <see cref="IAlertStateStore"/>), consumed by the headless Darling alert engine first; Lite +/// forwards later; Dashboard convergence is a separately-decided migration. +/// <para> +/// History-row SEMANTICS deliberately live behind this seam, per app: Lite writes ONE combined +/// history row per fired alert (including muted ones, flagged muted) as a side effect of its send +/// path; the Dashboard records explicitly (its <c>RecordAlert</c>), can write MULTIPLE rows per +/// condition, and also records "Cleared/Resolved" rows when a condition recovers. The engine does +/// not know or care — it reports outcomes; the deliverer decides what a history row is. Delivery +/// mode fan-out (#1141 Summary vs Per-event splitting) is also an implementation concern here, not +/// an engine one. +/// </para> +/// </summary> +public interface IAlertDeliverer +{ + /// <summary> + /// Records and (unless <see cref="AlertOutcome.Muted"/>) sends one fired alert. Implementations + /// must not throw for channel failures — a dead SMTP server must not abort the engine's sweep. + /// </summary> + Task DeliverAsync(AlertOutcome outcome, CancellationToken cancellationToken = default); +} diff --git a/PerformanceMonitor.Alerting/IAlertEngineSettings.cs b/PerformanceMonitor.Alerting/IAlertEngineSettings.cs new file mode 100644 index 000000000..459e3c51e --- /dev/null +++ b/PerformanceMonitor.Alerting/IAlertEngineSettings.cs @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Collections.Generic; + +namespace PerformanceMonitor.Alerting; + +/// <summary> +/// Which CPU metric the CPU alert evaluates against. The ENGINE — not the caller — selects which +/// collected CPU value to compare based on this mode: <see cref="SqlProcess"/> compares the SQL +/// Server scheduler ProcessUtilization %, <see cref="TotalServer"/> compares total non-idle CPU +/// (SQL process + other processes, matching OS user+system — "is the box in trouble"). Callers +/// only provide the mode; they never pre-select the value, so the selection rule cannot drift +/// between hosts. +/// <para> +/// Deliberately a NEW enum with engine-descriptive value names rather than reusing either app's +/// <c>CpuAlertMode</c> (<c>Total</c>/<c>SqlOnly</c>): the app enums are persisted in each app's +/// settings store and are mapped to this one at the adapter seam. +/// </para> +/// </summary> +public enum CpuAlertMode +{ + /// <summary>SQL Server scheduler ProcessUtilization only.</summary> + SqlProcess, + + /// <summary>SQL process + other-process CPU — matches OS user+system.</summary> + TotalServer +} + +/// <summary> +/// The ENGINE threshold surface for the Phase-5 shared alert engine: every enabled flag and +/// threshold the sweep evaluates alert conditions against. This is one of the three engine seams +/// (with <see cref="IAlertStateStore"/> and <see cref="IAlertDeliverer"/>) consumed by the headless +/// Darling alert engine first; Lite forwards its <c>App.*</c> alert statics through an adapter in a +/// later slice, and Dashboard convergence is a separately-decided migration (its live engine reads +/// <c>UserPreferences</c> directly today). +/// <para> +/// Deliberately SEPARATE from <c>PerformanceMonitor.Notifications.IAlertSettings</c>, which is the +/// delivery-only surface (SMTP/webhook/analysis-notify). Delivery settings answer "how do I send an +/// alert"; this interface answers "when does an alert exist". Keeping them apart lets a host wire +/// delivery without the engine (Dashboard today) or the engine with a different deliverer (Darling). +/// </para> +/// <para> +/// Blocking and deadlock thresholds here are COUNT-based — Lite's collected-store semantics (the +/// number of blocked-process reports / deadlocks in the rolling window). The Dashboard's live +/// engine instead thresholds blocking on longest-blocked SECONDS and deadlocks on a since-last-check +/// DELTA; those semantics intentionally stay app-side and are NOT modeled here — a Dashboard +/// migration would be a semantic change decided separately, not smuggled through this seam. +/// </para> +/// <para> +/// All members are pass-through reads of live values — the engine reads them every sweep, so a +/// settings reload is reflected immediately (no caching), matching the apps' direct settings reads. +/// </para> +/// </summary> +public interface IAlertEngineSettings +{ + /// <summary>Master switch — when false the engine runs no alert sweep at all.</summary> + bool AlertsEnabled { get; } + + /* Per-alert enabled flags. */ + bool CpuEnabled { get; } + bool BlockingEnabled { get; } + bool DeadlockEnabled { get; } + bool PoisonWaitEnabled { get; } + bool LongRunningQueryEnabled { get; } + bool TempDbSpaceEnabled { get; } + bool LowDiskEnabled { get; } + bool LongRunningJobEnabled { get; } + bool FailedJobEnabled { get; } + + /* Thresholds. */ + + /// <summary>Fire when the selected CPU metric (see <see cref="CpuAlertMode"/>) is at/above this %.</summary> + int CpuThresholdPercent { get; } + + /// <summary>Fire when the rolling-window blocked-process-report count reaches this value (count-based; see class remarks).</summary> + int BlockingCountThreshold { get; } + + /// <summary>Fire when the rolling-window deadlock count reaches this value (count-based; see class remarks).</summary> + int DeadlockCountThreshold { get; } + + /// <summary>Fire when a poison wait type's average ms-per-wait is at/above this value.</summary> + int PoisonWaitThresholdMs { get; } + + /// <summary>Fire when a query's elapsed time is at/above this many minutes.</summary> + int LongRunningQueryThresholdMinutes { get; } + + /* The long-running-query read shape the engine forwards to + IAlertReadAdapter.GetLongRunningQueriesAsync — Lite exposes all six as real settings + (App.AlertLongRunningQueryMaxResults + the five opt-out noise filters), so they belong on + the engine surface for Lite to forward later. Lite/Darling defaults: 5 / all true. */ + + /// <summary>Row cap for the long-running-query read (Lite clamps 1–1000; default 5).</summary> + int LongRunningQueryMaxResults { get; } + + /// <summary>Exclude sessions waiting on SP_SERVER_DIAGNOSTICS (default true).</summary> + bool LongRunningQueryExcludeSpServerDiagnostics { get; } + + /// <summary>Exclude WAITFOR / BROKER_RECEIVE_WAITFOR sessions (default true).</summary> + bool LongRunningQueryExcludeWaitFor { get; } + + /// <summary>Exclude BACKUPTHREAD / BACKUPIO sessions (default true).</summary> + bool LongRunningQueryExcludeBackups { get; } + + /// <summary>Exclude XE_LIVE_TARGET_TVF sessions (default true).</summary> + bool LongRunningQueryExcludeMiscWaits { get; } + + /// <summary>Exclude CDC capture sessions (default true).</summary> + bool LongRunningQueryExcludeCdc { get; } + + /// <summary>Fire when tempdb reserved space is at/above this % of total.</summary> + int TempDbSpaceThresholdPercent { get; } + + /// <summary>Fire when a volume's free space is below this % (0 disables the percent dimension).</summary> + int LowDiskThresholdPercent { get; } + + /// <summary>Fire when a volume's free space is below this many GB (0 disables the GB dimension).</summary> + int LowDiskThresholdGb { get; } + + /// <summary>Fire when a running job exceeds this multiple of its historical average duration.</summary> + int LongRunningJobMultiplier { get; } + + /// <summary>How many minutes back to look for failed Agent job runs.</summary> + int FailedJobLookbackMinutes { get; } + + /// <summary>Minimum minutes between repeated notifications for the same alert condition.</summary> + int CooldownMinutes { get; } + + /// <summary> + /// Databases excluded from database-scoped alert evaluation (blocking/deadlock/long-running-query + /// rows in these databases don't count toward thresholds). Compared case-insensitively. + /// </summary> + IReadOnlyList<string> ExcludedDatabases { get; } + + /// <summary>Which CPU value the engine compares against <see cref="CpuThresholdPercent"/>.</summary> + CpuAlertMode CpuAlertMode { get; } +} diff --git a/PerformanceMonitor.Alerting/IAlertReadAdapter.cs b/PerformanceMonitor.Alerting/IAlertReadAdapter.cs new file mode 100644 index 000000000..549241e1a --- /dev/null +++ b/PerformanceMonitor.Alerting/IAlertReadAdapter.cs @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Alerting; + +/// <summary> +/// The per-store read surface behind the Phase-5 shared alert engine (slice B) — every collected +/// feed the engine sweep evaluates, so the engine itself (slice D) is store-free. Each method +/// mirrors the semantics and filters of Lite's alert-loop reads EXACTLY (windows, LIMITs, wait-type +/// lists, latest-snapshot-only scoping); implementations over other stores (Darling's Postgres) +/// must reproduce them query-for-query so the same server state produces the same alerts. +/// <para> +/// <c>serverKey</c> is the engine's stable per-server identity, matching +/// <see cref="IAlertStateStore"/>'s convention (Lite/Darling: the deterministic storage-name hash +/// rendered as a string; Dashboard, if it ever converges: its own id) — the engine carries ONE +/// identity type across all three seams. +/// </para> +/// <para> +/// The failed-jobs feed is deliberately NOT part of this adapter: failure outcomes are not in any +/// collected table — hosts run <see cref="FailedJobsQuery"/> live against the monitored server's +/// msdb at alert-check time, on their own connections with their own permission gating (see the +/// <see cref="FailedJobsQuery"/> remarks). A collected-store read surface cannot serve it. +/// </para> +/// </summary> +public interface IAlertReadAdapter +{ + /// <summary> + /// Recent blocked-process events for the blocking alert, newest first, capped at 200. + /// CONTRACT: the result INCLUDES the always-on DMV blocking-snapshot fallback rows, merged with + /// XE blocked-process-report rows preferred — a DMV row appears only where no BPR row covers + /// the same (blocked, blocker) SPID pair within the same minute (AWS RDS / unset + /// blocked-process threshold capture nothing via XE). Implementations reproduce Lite's merge + /// via <see cref="BlockedProcessReportMerge.AppendDmvFallbackRows"/>; DMV rows carry + /// <see cref="BlockedProcessAlertRow.DmvSnapshotSource"/> and have no report XML. + /// </summary> + Task<List<BlockedProcessAlertRow>> GetRecentBlockedProcessReportsAsync( + string serverKey, int hoursBack, CancellationToken cancellationToken = default); + + /// <summary> + /// Recent deadlock events for the deadlock alert, newest (by deadlock time) first, capped + /// at 50. Excluded-database filtering happens in the builder + /// (<see cref="AlertContextBuilders.IsDeadlockExcluded"/> parses the graph XML), not here. + /// </summary> + Task<List<DeadlockAlertRow>> GetRecentDeadlocksAsync( + string serverKey, int hoursBack, CancellationToken cancellationToken = default); + + /// <summary> + /// The poison-wait deltas at or above <paramref name="thresholdMs"/> average ms/wait. Mirrors + /// Lite's read exactly: the newest wait_stats rows (max 3, collected within the last 10 + /// minutes) for THREADPOOL / RESOURCE_SEMAPHORE / RESOURCE_SEMAPHORE_QUERY_COMPILE with + /// delta_waiting_tasks > 0, THEN the threshold filter applied client-side — the row window + /// is selected BEFORE thresholding, so a sub-threshold poison wait still occupies its slot, + /// exactly like the pre-extraction loop's fetch-then-FindAll. + /// </summary> + Task<List<PoisonWaitDelta>> GetPoisonWaitDeltasAsync( + string serverKey, double thresholdMs, CancellationToken cancellationToken = default); + + /// <summary> + /// Currently-running queries over <paramref name="thresholdMinutes"/> elapsed, longest first, + /// from the LATEST collection snapshot only (and only if that snapshot is under 10 minutes + /// old — a stale store must not alert). Mirrors Lite's read exactly: user sessions only + /// (session_id > 50), the five opt-out noise filters, capped at + /// <paramref name="maxResults"/> (clamped 1–1000), then rows in + /// <paramref name="excludedDatabases"/> dropped client-side (case-insensitive; rows with no + /// database name always pass) — the loop's post-fetch exclusion, moved behind the seam. + /// </summary> + Task<List<LongRunningQueryInfo>> GetLongRunningQueriesAsync( + string serverKey, + int thresholdMinutes, + int maxResults, + bool excludeSpServerDiagnostics, + bool excludeWaitFor, + bool excludeBackups, + bool excludeMiscWaits, + bool excludeCdc, + IReadOnlyList<string> excludedDatabases, + CancellationToken cancellationToken = default); + + /// <summary> + /// The latest free space per distinct volume (mount point), worst (lowest free %) first, for + /// the low-disk alert. Files on the same volume share one row (MAX total / MIN free); volumes + /// with no mount point are excluded, so Azure SQL DB yields an empty list. Threshold + /// evaluation stays engine-side (<see cref="AlertContextBuilders.GetBreachedVolumes"/>). + /// </summary> + Task<List<VolumeFreeSpaceInfo>> GetVolumeFreeSpaceAsync( + string serverKey, CancellationToken cancellationToken = default); + + /// <summary> + /// The latest tempdb space snapshot, or null when the store has none for this server. + /// Threshold evaluation (UsedPercent) stays engine-side. + /// </summary> + Task<TempDbSpaceInfo?> GetTempDbSpaceAsync( + string serverKey, CancellationToken cancellationToken = default); + + /// <summary> + /// Currently-running Agent jobs whose duration is at least <paramref name="multiplier"/>x + /// their historical average, worst (highest % of average) first, capped at 5, from the LATEST + /// running_jobs snapshot only. Jobs averaging under 60 seconds are excluded (noise floor) — + /// mirroring Lite's read exactly. + /// </summary> + Task<List<AnomalousJobInfo>> GetAnomalousJobsAsync( + string serverKey, int multiplier, CancellationToken cancellationToken = default); +} diff --git a/PerformanceMonitor.Alerting/IAlertStateStore.cs b/PerformanceMonitor.Alerting/IAlertStateStore.cs new file mode 100644 index 000000000..3357f8fa0 --- /dev/null +++ b/PerformanceMonitor.Alerting/IAlertStateStore.cs @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Alerting; + +/// <summary> +/// Watermark / edge-trigger state persistence for the Phase-5 shared alert engine — the state that +/// must survive a host restart so the first post-restart sweep doesn't re-alert (and re-post to +/// Teams/Slack) conditions the user already saw (#1145). One of the three engine seams (with +/// <see cref="IAlertEngineSettings"/> and <see cref="IAlertDeliverer"/>), consumed by the headless +/// Darling alert engine first; Lite forwards later; Dashboard convergence is a separately-decided +/// migration. +/// <para> +/// The method names and semantics are modeled on Lite's <c>DuckDbAlertHistoryStore</c> watermark +/// methods, genericized from Lite's <c>int serverId</c> to a string <paramref name="serverKey"/> so +/// any host identity scheme fits (Lite: the deterministic storage-name hash rendered as a string; +/// Darling: its server key; Dashboard: its GUID server id). Loads are per-key here where Lite's are +/// bulk-at-startup — an implementation over a bulk-loading store can cache, as long as saves are +/// read-your-writes within the engine's sweep loop. +/// </para> +/// <para> +/// Expected implementations: Lite = the DuckDB <c>config_edge_trigger_watermarks</c> table +/// (INSERT OR REPLACE upsert on the server/metric primary key); Darling = Postgres +/// (INSERT ... ON CONFLICT upsert); Dashboard = the <c>UserPreferences</c> ticks dictionary +/// (<c>FailedJobAlertWatermarkTicks</c>) if it ever migrates. Saves are on-change only and +/// low-frequency — the gate returns the same watermark on the vast majority of sweeps. +/// </para> +/// </summary> +public interface IAlertStateStore +{ + /// <summary> + /// Loads the persisted rolling-count edge-trigger watermark (#1091/#1145: the highest + /// already-alerted rolling-window count) for one server/metric, or <c>null</c> when none has + /// been persisted. The engine seeds its in-memory watermark from this at startup so lingering + /// blocked-process reports / deadlocks don't re-alert on the first sweep. + /// </summary> + Task<int?> LoadEdgeTriggerWatermarkAsync(string serverKey, string metricName); + + /// <summary> + /// Upserts one rolling-count edge-trigger watermark. Called on-change only — a low-frequency + /// write (Lite piggybacks it on the alert-store write lock; Darling upserts a Postgres row). + /// </summary> + Task SaveEdgeTriggerWatermarkAsync(string serverKey, string metricName, int watermark); + + /// <summary> + /// Loads the persisted failed-job watermark — the newest already-alerted failure's + /// SERVER-LOCAL run time — or <c>null</c> when none has been persisted. The value is stored + /// and returned in its native server-local basis (NEVER coerced to UTC) so it compares + /// directly against <see cref="FailedJobInfo.RunDateTime"/>; mixing bases here re-fires or + /// eats failed-job alerts by the timezone offset. + /// </summary> + Task<DateTime?> LoadFailedJobWatermarkAsync(string serverKey); + + /// <summary> + /// Upserts the failed-job watermark for one server (see + /// <see cref="LoadFailedJobWatermarkAsync"/> for the time-basis contract). Called on-change + /// only — when a failed-job alert actually fires. + /// </summary> + Task SaveFailedJobWatermarkAsync(string serverKey, DateTime watermark); +} diff --git a/PerformanceMonitor.Alerting/LongRunningQueryInfo.cs b/PerformanceMonitor.Alerting/LongRunningQueryInfo.cs new file mode 100644 index 000000000..1eec860f2 --- /dev/null +++ b/PerformanceMonitor.Alerting/LongRunningQueryInfo.cs @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +namespace PerformanceMonitor.Alerting; + +/// <summary> +/// A currently-running query whose elapsed time exceeds the long-running-query alert threshold. +/// Canonical shared copy (Phase-5 A0) — Lite and the Dashboard previously carried member-identical +/// local twins; both apps now alias this type via a global using so call sites are unchanged. +/// </summary> +public class LongRunningQueryInfo +{ + public int SessionId { get; set; } + public string DatabaseName { get; set; } = ""; + public string QueryText { get; set; } = ""; + public string ProgramName { get; set; } = ""; + public long ElapsedSeconds { get; set; } + public long CpuTimeMs { get; set; } + public long Reads { get; set; } + public long Writes { get; set; } + public string? WaitType { get; set; } + public int? BlockingSessionId { get; set; } + public string? QueryHash { get; set; } +} diff --git a/PerformanceMonitor.Alerting/PerformanceMonitor.Alerting.csproj b/PerformanceMonitor.Alerting/PerformanceMonitor.Alerting.csproj new file mode 100644 index 000000000..aa9ece362 --- /dev/null +++ b/PerformanceMonitor.Alerting/PerformanceMonitor.Alerting.csproj @@ -0,0 +1,18 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <Nullable>enable</Nullable> + <ImplicitUsings>disable</ImplicitUsings> + <RootNamespace>PerformanceMonitor.Alerting</RootNamespace> + <AssemblyName>PerformanceMonitor.Alerting</AssemblyName> + <Company>Darling Data, LLC</Company> + <Copyright>Copyright © 2026 Darling Data, LLC</Copyright> + <EnableNETAnalyzers>true</EnableNETAnalyzers> + <AnalysisLevel>latest-recommended</AnalysisLevel> + <NoWarn>CA1849;CA2007;CA1508;CA1822;CA1805;CA1510;CA1816;CA1861;CA1845;CA2201;CA1848;CA1852;CA1305;CA1860;CA1707;CA1507;CA1806;CA2254</NoWarn> + </PropertyGroup> + + <ItemGroup> + <ProjectReference Include="..\PerformanceMonitor.Notifications\PerformanceMonitor.Notifications.csproj" /> + </ItemGroup> +</Project> diff --git a/PerformanceMonitor.Alerting/PoisonWaitDelta.cs b/PerformanceMonitor.Alerting/PoisonWaitDelta.cs new file mode 100644 index 000000000..c025644d2 --- /dev/null +++ b/PerformanceMonitor.Alerting/PoisonWaitDelta.cs @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +namespace PerformanceMonitor.Alerting; + +/// <summary> +/// One poison wait type's delta since the previous collection, used by the poison-wait alert. +/// Canonical shared copy (Phase-5 A0) — Lite and the Dashboard previously carried member-identical +/// local twins; both apps now alias this type via a global using so call sites are unchanged. +/// </summary> +public class PoisonWaitDelta +{ + public string WaitType { get; set; } = ""; + public long DeltaMs { get; set; } + public long DeltaTasks { get; set; } + public double AvgMsPerWait { get; set; } +} diff --git a/PerformanceMonitor.Alerting/RollingCountAlertGate.cs b/PerformanceMonitor.Alerting/RollingCountAlertGate.cs new file mode 100644 index 000000000..a037916f3 --- /dev/null +++ b/PerformanceMonitor.Alerting/RollingCountAlertGate.cs @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +namespace PerformanceMonitor.Alerting; + +/// <summary> +/// Edge-trigger gate for the alert engine's rolling-window count alerts — +/// blocking and deadlocks (#1091). +/// +/// Those counts are rolling 1-hour totals: a single blocked-process report or deadlock +/// keeps the count at/above the threshold for the whole hour it lingers in the window. A +/// plain level check (<c>count >= threshold</c>) therefore re-fires the SAME event every +/// cooldown until it ages out — the bug reported in #1091, where two deadlocks produced an +/// alert every five minutes for an hour. +/// +/// This gate is edge-triggered instead. It fires only when the current count has climbed +/// above the watermark recorded at the last fired alert (a genuinely new event). The +/// watermark: +/// <list type="bullet"> +/// <item>decays down to the current count as older events age out of the window, so a later +/// rise is still recognised as new;</item> +/// <item>resets to zero when the count falls below the threshold (the window emptied), so the +/// next event re-alerts; and</item> +/// <item>advances ONLY when an alert actually fires — never while the cooldown or a +/// suppression is gating the notification — so an event that arrives during the cooldown is +/// reported on the next eligible check instead of being silently swallowed.</item> +/// </list> +/// +/// Pure function: no clock, no I/O. The caller resolves <paramref name="cooldownElapsed"/> +/// and <paramref name="suppressed"/> and persists <see cref="Decision.Watermark"/>. +/// +/// Moved verbatim from Lite's <c>PerformanceMonitorLite.Services</c> in Phase-5 slice D so the +/// shared <see cref="AlertEngine"/> and Lite's loop run the SAME gate; Lite call sites alias +/// this type. +/// </summary> +public static class RollingCountAlertGate +{ + /// <param name="Fire">True when the caller should raise the alert this cycle (tray/email).</param> + /// <param name="Active"> + /// True while the count is at/above the threshold. Drives the "Cleared" notification on the + /// active→inactive transition; independent of whether a notification fired this cycle. + /// </param> + /// <param name="Watermark">The watermark to persist for the next evaluation.</param> + public readonly record struct Decision(bool Fire, bool Active, int Watermark); + + /// <summary> + /// Evaluates the edge-trigger gate for one alert check. + /// </summary> + /// <param name="currentCount">Current rolling-window count (raw or database-filtered).</param> + /// <param name="threshold">Alert threshold; the alert is "active" at or above this count.</param> + /// <param name="watermark">Watermark persisted from the previous evaluation (0 if none).</param> + /// <param name="cooldownElapsed">True if the cooldown since the last fired alert has elapsed.</param> + /// <param name="suppressed">True if popups/emails are suppressed for this server (acknowledged/silenced).</param> + public static Decision Evaluate( + int currentCount, + int threshold, + int watermark, + bool cooldownElapsed, + bool suppressed) + { + // Below threshold: the window has emptied (or never filled). Not active; reset the + // watermark so the next event re-alerts from a clean slate. + if (currentCount < threshold) + { + return new Decision(Fire: false, Active: false, Watermark: 0); + } + + // Decay the watermark down to the current count as older events age out of the + // window, so a subsequent rise is still recognised as a new event. + if (watermark > currentCount) + { + watermark = currentCount; + } + + bool hasNew = currentCount > watermark; + bool fire = hasNew && cooldownElapsed && !suppressed; + + // Advance the watermark only when we actually fire; otherwise a new event arriving + // during the cooldown would be consumed when the cooldown later elapses. + if (fire) + { + watermark = currentCount; + } + + return new Decision(Fire: fire, Active: true, Watermark: watermark); + } +} diff --git a/PerformanceMonitor.Alerting/TempDbSpaceInfo.cs b/PerformanceMonitor.Alerting/TempDbSpaceInfo.cs new file mode 100644 index 000000000..ab76303a6 --- /dev/null +++ b/PerformanceMonitor.Alerting/TempDbSpaceInfo.cs @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +namespace PerformanceMonitor.Alerting; + +/// <summary> +/// The latest tempdb space snapshot for a server, used by the tempdb-space alert. +/// Canonical shared copy (Phase-5 A0) — Lite and the Dashboard previously carried member-identical +/// local twins; both apps now alias this type via a global using so call sites are unchanged. +/// </summary> +public class TempDbSpaceInfo +{ + public double TotalReservedMb { get; set; } + public double UnallocatedMb { get; set; } + public double UserObjectReservedMb { get; set; } + public double InternalObjectReservedMb { get; set; } + public double VersionStoreReservedMb { get; set; } + public int TopConsumerSessionId { get; set; } + public double TopConsumerMb { get; set; } + + public double UsedPercent => TotalReservedMb + UnallocatedMb > 0 + ? TotalReservedMb / (TotalReservedMb + UnallocatedMb) * 100 + : 0; +} diff --git a/PerformanceMonitor.Alerting/VolumeFreeSpaceInfo.cs b/PerformanceMonitor.Alerting/VolumeFreeSpaceInfo.cs new file mode 100644 index 000000000..5aeaa9a46 --- /dev/null +++ b/PerformanceMonitor.Alerting/VolumeFreeSpaceInfo.cs @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +namespace PerformanceMonitor.Alerting; + +/// <summary> +/// Free space for a single distinct volume (mount point) on a monitored server, +/// used by the low-disk alert. Azure SQL DB has no volume stats, so no rows are +/// produced there and the alert never fires. +/// Canonical shared copy (Phase-5 A0) — Lite and the Dashboard previously carried member-identical +/// local twins; both apps now alias this type via a global using so call sites are unchanged. +/// </summary> +public class VolumeFreeSpaceInfo +{ + public string MountPoint { get; set; } = ""; + public double TotalMb { get; set; } + public double FreeMb { get; set; } + + public double FreePercent => TotalMb > 0 ? FreeMb / TotalMb * 100 : 0; + public double FreeGb => FreeMb / 1024.0; +} diff --git a/PerformanceMonitor.Analysis/AnalysisContext.cs b/PerformanceMonitor.Analysis/AnalysisContext.cs index ea0f0da2b..405bed480 100644 --- a/PerformanceMonitor.Analysis/AnalysisContext.cs +++ b/PerformanceMonitor.Analysis/AnalysisContext.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; namespace PerformanceMonitor.Analysis; @@ -23,8 +22,6 @@ public class AnalysisContext /// </summary> public TimeSpan ServerUtcOffset { get; set; } - public List<AnalysisExclusion> Exclusions { get; set; } = []; - /// <summary> /// Duration of the examined period in milliseconds. /// </summary> diff --git a/PerformanceMonitor.Analysis/AnalysisModels.cs b/PerformanceMonitor.Analysis/AnalysisModels.cs index bfdc80364..bc9f2e049 100644 --- a/PerformanceMonitor.Analysis/AnalysisModels.cs +++ b/PerformanceMonitor.Analysis/AnalysisModels.cs @@ -178,34 +178,3 @@ public class AnalysisMuted public DateTime MutedDate { get; set; } public string? Reason { get; set; } } - -/// <summary> -/// A user-configured exclusion filter. Maps to the analysis_exclusions table. -/// </summary> -public class AnalysisExclusion -{ - public long ExclusionId { get; set; } - public string ExclusionType { get; set; } = string.Empty; - public string ExclusionValue { get; set; } = string.Empty; - public int? ServerId { get; set; } - public string? DatabaseName { get; set; } - public bool IsEnabled { get; set; } = true; - public DateTime CreatedDate { get; set; } - public string? Description { get; set; } -} - -/// <summary> -/// A severity threshold value. Maps to the analysis_thresholds table. -/// </summary> -public class AnalysisThreshold -{ - public long ThresholdId { get; set; } - public string Category { get; set; } = string.Empty; - public string FactKey { get; set; } = string.Empty; - public string ThresholdType { get; set; } = string.Empty; - public double ThresholdValue { get; set; } - public int? ServerId { get; set; } - public string? DatabaseName { get; set; } - public bool IsEnabled { get; set; } = true; - public DateTime ModifiedDate { get; set; } -} diff --git a/PerformanceMonitor.Analysis/AnomalyGate.cs b/PerformanceMonitor.Analysis/AnomalyGate.cs new file mode 100644 index 000000000..e268d7a2e --- /dev/null +++ b/PerformanceMonitor.Analysis/AnomalyGate.cs @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; + +namespace PerformanceMonitor.Analysis; + +/// <summary> +/// The shared z-score anomaly gate — the one place the "interaction trap" decision lives so the +/// three triplicated detectors (Lite <c>AnomalyDetector</c>, Darling <c>PgAnomalyDetector</c>, +/// Dashboard <c>SqlServerAnomalyDetector</c>) cannot drift. +/// +/// <para> +/// The #1486 absolute-magnitude floors and the baseline-quality gate (BaselineBucket.IsTrustworthy) +/// are COMPLEMENTARY, not both-mandatory — if they were AND-ed, a young store with a thin baseline +/// would go blind (the z-path is untrustworthy AND a trivial value clears no floor). Instead: +/// </para> +/// <list type="bullet"> +/// <item>Trustworthy baseline → trust the z-score, with the magnitude floor as a sanity ceiling +/// (a huge z on a trivial value still doesn't fire).</item> +/// <item>Untrustworthy baseline → do NOT trust z (a z-score against a non-existent baseline is +/// meaningless); fire only on the HIGHER absolute-fallback bar. Not silence — absolute rules +/// preserve new-deployment coverage.</item> +/// </list> +/// The displayed sigma is always computed from whatever dispersion the baseline has (capped), so a +/// fallback-fired anomaly still shows how far above baseline it landed. +/// </summary> +public static class AnomalyGate +{ + /// <summary>Outcome of a z-vs-absolute-fallback decision.</summary> + /// <param name="Fire">Whether the anomaly should be emitted.</param> + /// <param name="Sigma">Capped deviation-in-sigmas for display (0 when the baseline has no dispersion).</param> + /// <param name="LowQualityBaseline">True when the decision took the absolute-fallback path (thin baseline).</param> + /// <param name="FallbackExceedance">Peak ÷ the absolute-fallback bar — set only on the low-quality + /// path (0 otherwise). On that path the stored <c>Sigma</c> is the real (small) z, which the scorer's + /// 2σ gate would zero out; the scorer grades the fire off THIS exceedance instead so the finding + /// still surfaces. See <c>FactScorer.ScoreAnomalyFact</c>.</param> + public readonly record struct ZDecision(bool Fire, double Sigma, bool LowQualityBaseline, double FallbackExceedance); + + /// <summary> + /// Decides whether a z-score anomaly fires. Callers pass the baseline's <paramref name="mean"/>, + /// its <paramref name="effectiveStdDev"/> (already floored), and its <paramref name="isTrustworthy"/> + /// flag (BaselineBucket carries all three; it is triplicated, so this helper takes primitives). + /// </summary> + /// <param name="magnitudeFloor">#1486 sanity ceiling — in the z-path the observed peak must also + /// clear this, so a giant z on a trivial value can't surface.</param> + /// <param name="absoluteFallbackBar">The higher bar the observed peak must clear when the baseline + /// is untrustworthy. Should be strictly above <paramref name="magnitudeFloor"/>.</param> + /// <param name="sigmaCap">Display cap for the reported sigma (#1486's 25σ cap).</param> + public static ZDecision EvaluateZScore( + double mean, + double effectiveStdDev, + bool isTrustworthy, + double peak, + double deviationThreshold, + double magnitudeFloor, + double absoluteFallbackBar, + double sigmaCap) + { + var sigma = effectiveStdDev > 0 + ? Math.Min((peak - mean) / effectiveStdDev, sigmaCap) + : 0.0; + + if (isTrustworthy) + { + // effectiveStdDev > 0 is guaranteed when trustworthy (IsTrustworthy requires it). + var deviation = (peak - mean) / effectiveStdDev; + var fire = deviation >= deviationThreshold && peak >= magnitudeFloor; + return new ZDecision(fire, Math.Min(deviation, sigmaCap), LowQualityBaseline: false, FallbackExceedance: 0.0); + } + + // Untrustworthy baseline → absolute-threshold fallback (NOT silence). The exceedance (>= 1.0 on a + // fire) is carried so the scorer can grade it off the absolute bar instead of the untrustworthy z. + return new ZDecision( + peak >= absoluteFallbackBar, + sigma, + LowQualityBaseline: true, + FallbackExceedance: absoluteFallbackBar > 0 ? peak / absoluteFallbackBar : 0.0); + } +} diff --git a/PerformanceMonitor.Analysis/AnomalyIncidentReconciler.cs b/PerformanceMonitor.Analysis/AnomalyIncidentReconciler.cs new file mode 100644 index 000000000..570e74394 --- /dev/null +++ b/PerformanceMonitor.Analysis/AnomalyIncidentReconciler.cs @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace PerformanceMonitor.Analysis; + +/// <summary> +/// Reconciles each surviving <c>ANOMALY_*</c> story's <see cref="AnalysisStory.IncidentId"/> onto the +/// REGULAR (non-anomaly) finding that describes the SAME symptom in the SAME run, so the anomaly folds +/// into that incident instead of rendering as its own card (viewer) and its own e-mail +/// (<c>AnalysisNotificationService</c>). This runs AFTER +/// <see cref="InferenceEngine.ClusterIntoIncidents"/> + <see cref="IncidentId.StampClusters"/> and +/// BEFORE mute-filtering, at the identical wiring site in all three analysis services. +/// +/// <para><b>Fold, don't suppress, don't add graph edges</b> (Round-2 vetted design). Suppression is +/// rejected — the anomaly carries the vs-baseline delta the regular fact does not, catches +/// sub-threshold regressions, and covers no-regular-path waits (PAGELATCH_*/ASYNC_NETWORK_IO/…). +/// Broad <see cref="RelationshipGraph"/> edges are rejected — union-find over the existing THREADPOOL +/// bridge over-merges unrelated incidents. Reconciling the id keeps every finding while collapsing the +/// duplicate presentation: nothing is dropped, only the incident tag is rewritten.</para> +/// +/// <para><b>Symptom-family map</b> mirrors the collector's grouping +/// (<see cref="FactCollectorHelpers.WaitFamilyKey"/> / <see cref="FactCollectorHelpers.IsGeneralLockWait"/>) +/// exactly. <c>ANOMALY_WAIT_PROFILE</c> resolves to the family of its DOMINANT <c>contrib_<TYPE></c> +/// driver (Stage A made the whole wait profile one fact, so this is a clean single lookup). +/// <c>ANOMALY_CPU_SPIKE</c> accepts EITHER CPU family target — the sustained <c>CPU_SQL_PERCENT</c> or +/// the burst <c>CPU_SPIKE</c> — since a run may key its CPU story on either. Anomalies with no regular +/// counterpart (batch / session / query-duration, and the db-scoped <c>ANOMALY_OBJECT_*</c> pair) are +/// unmapped and always stay solo.</para> +/// +/// <para><b>Fold targets are indexed by EVERY fact key a regular story consumed</b> — its whole +/// <see cref="AnalysisStory.Path"/>, not just its <see cref="AnalysisStory.RootFactKey"/>. A mapped +/// family key (<c>CPU_SQL_PERCENT</c>, <c>RESOURCE_SEMAPHORE</c>, <c>BLOCKING_EVENTS</c>, …) is +/// frequently a MID/LEAF member of a larger story when a higher-severity neighbour roots it +/// (<c>SOS_SCHEDULER_YIELD → CPU_SQL_PERCENT</c>, <c>PAGEIOLATCH_* → RESOURCE_SEMAPHORE</c>, +/// <c>THREADPOOL/LCK → BLOCKING_EVENTS</c>); in those common correlated runs NO story is ROOTED on the +/// family key, so root-only indexing would miss the fold exactly where dedup was most wanted. +/// <see cref="InferenceEngine.BuildStories"/> consumes each fact into exactly ONE story (its +/// <c>consumed</c> set), so a given family key belongs to at most one story's path — the mapping is +/// unambiguous and cannot over-fold onto an unrelated story.</para> +/// +/// <para><b>Database-aware.</b> The fold only happens when a regular parent with the matching family +/// key exists in the SAME database (server-scoped findings match on the empty-string database). A +/// db-scoped <c>ANOMALY_OBJECT_*</c> can therefore never fold into a finding in another database, and +/// two different databases never share a folded incident.</para> +/// +/// <para>Pure and stateless — mutates only <see cref="AnalysisStory.IncidentId"/> on the passed +/// stories, so it is directly unit-testable without any collector or store.</para> +/// </summary> +public static class AnomalyIncidentReconciler +{ + /// <summary> + /// Maps a server-scoped <c>ANOMALY_*</c> root key to the REGULAR fact key(s) — symptom family — + /// that describe the same event, in priority order (the anomaly folds into the FIRST family that + /// has a same-database parent this run). Most anomalies have a single family; <c>ANOMALY_CPU_SPIKE</c> + /// accepts either the sustained <c>CPU_SQL_PERCENT</c> or the burst <c>CPU_SPIKE</c>, since a run may + /// key its CPU story on either detector. <c>ANOMALY_WAIT_PROFILE</c> is resolved separately from its + /// dominant contributor (<see cref="ResolveFamilies"/>). Anomaly keys absent from this map — + /// batch-request, session, query-duration spikes, and the db-scoped <c>ANOMALY_OBJECT_GROWTH</c> / + /// <c>ANOMALY_OBJECT_CONTENTION</c> — have no regular counterpart and always stay solo. + /// </summary> + private static readonly Dictionary<string, string[]> AnomalyToFamilies = new(StringComparer.Ordinal) + { + ["ANOMALY_CPU_SPIKE"] = ["CPU_SQL_PERCENT", "CPU_SPIKE"], + ["ANOMALY_READ_LATENCY"] = ["IO_READ_LATENCY_MS"], + ["ANOMALY_WRITE_LATENCY"] = ["IO_WRITE_LATENCY_MS"], + ["ANOMALY_MEMORY_PRESSURE"] = ["RESOURCE_SEMAPHORE"], + ["ANOMALY_BLOCKING_SPIKE"] = ["BLOCKING_EVENTS"], + ["ANOMALY_DEADLOCK_SPIKE"] = ["DEADLOCKS"], + }; + + /// <summary> + /// Rewrites the incident id of each <c>ANOMALY_*</c> story that has a same-run, same-database + /// regular parent onto that parent's incident id. No-op when there are fewer than two + /// non-absolution stories or no regular stories to fold into. + /// </summary> + public static void Reconcile(IReadOnlyList<AnalysisStory> stories) + { + if (stories is null) + return; + + var incidentStories = stories.Where(s => s is not null && !s.IsAbsolution).ToList(); + if (incidentStories.Count < 2) + return; + + // Index the REGULAR (non-anomaly) stories by (fact key, database) over EVERY key the story + // consumed — its whole Path (root + mid/leaf members), not just its root. A mapped family key + // is frequently a non-root member of a larger story (SOS_SCHEDULER_YIELD -> CPU_SQL_PERCENT, + // THREADPOOL/LCK -> BLOCKING_EVENTS); indexing the whole path is what lets the anomaly fold + // there. BuildStories consumes each fact into exactly one story, so a given key belongs to at + // most one story's path — this cannot over-fold. The database is normalized (null/empty -> "") + // so a server-scoped anomaly matches its server-scoped counterpart on "", while a db-scoped + // anomaly only ever matches a regular finding in its OWN database. On the (shouldn't-happen, + // consumed-set-violating) chance two stories share a (key, db), keep the highest-severity one. + var regularByKey = new Dictionary<(string Key, string Db), AnalysisStory>(); + foreach (var s in incidentStories) + { + if (IsAnomaly(s.RootFactKey)) + continue; // an anomaly-rooted story is never a fold target — anomalies fold into REGULAR findings + + var db = s.DatabaseName ?? string.Empty; + foreach (var key in FoldKeys(s)) + { + var mapKey = (key, db); + if (!regularByKey.TryGetValue(mapKey, out var existing) || s.Severity > existing.Severity) + regularByKey[mapKey] = s; + } + } + + if (regularByKey.Count == 0) + return; + + foreach (var anomaly in incidentStories) + { + if (!IsAnomaly(anomaly.RootFactKey)) + continue; + + var db = anomaly.DatabaseName ?? string.Empty; + foreach (var family in ResolveFamilies(anomaly)) + { + // Fold into the FIRST candidate family that has a real parent in the SAME database + // carrying an id. Never overwrite a solo anomaly's id with an empty one (StampClusters + // always assigns a non-empty id, so that guard is belt-and-suspenders). + if (regularByKey.TryGetValue((family, db), out var parent) && !string.IsNullOrEmpty(parent.IncidentId)) + { + anomaly.IncidentId = parent.IncidentId; + break; + } + } + } + } + + /// <summary> + /// Every fact key a regular story consumed — its full <see cref="AnalysisStory.Path"/> (root plus + /// each mid/leaf member) so a family key that is a NON-ROOT member is still indexable as a fold + /// target. Falls back to the root key alone when a story carries no path (defensive; real stories + /// from <see cref="InferenceEngine.BuildStory"/> always have a populated path). + /// </summary> + private static IEnumerable<string> FoldKeys(AnalysisStory story) + { + if (story.Path is { Count: > 0 }) + return story.Path; + return string.IsNullOrEmpty(story.RootFactKey) + ? Array.Empty<string>() + : new[] { story.RootFactKey }; + } + + private static bool IsAnomaly(string? key) => + key is not null && key.StartsWith("ANOMALY_", StringComparison.Ordinal); + + /// <summary> + /// The regular-finding family key(s) an anomaly story may fold into, in priority order, or an empty + /// list when the anomaly has no regular counterpart (it then stays solo). <c>ANOMALY_WAIT_PROFILE</c> + /// resolves to the family of its dominant <c>contrib_<TYPE></c> driver; <c>ANOMALY_CPU_SPIKE</c> + /// resolves to both CPU families (sustained then burst); every other mapped anomaly resolves to one. + /// </summary> + private static string[] ResolveFamilies(AnalysisStory anomaly) + { + if (string.Equals(anomaly.RootFactKey, "ANOMALY_WAIT_PROFILE", StringComparison.Ordinal)) + { + var family = DominantWaitFamily(anomaly.RootFactMetadata); + return family is null ? Array.Empty<string>() : new[] { family }; + } + + return AnomalyToFamilies.TryGetValue(anomaly.RootFactKey, out var families) + ? families + : Array.Empty<string>(); + } + + /// <summary> + /// The wait FAMILY of the <c>ANOMALY_WAIT_PROFILE</c>'s dominant (largest-ms) <c>contrib_<TYPE></c> + /// metadata entry, mapped through <see cref="FactCollectorHelpers.WaitFamilyKey"/> so it matches the + /// grouped regular wait fact (CX* → CXPACKET, general lock modes → LCK, everything else → itself). + /// Ties are broken by ordinal type name so the result is deterministic across dictionary orderings. + /// Null when there is no <c>contrib_</c> metadata to resolve. + /// </summary> + private static string? DominantWaitFamily(Dictionary<string, double>? metadata) + { + if (metadata is null || metadata.Count == 0) + return null; + + const string prefix = "contrib_"; + string? dominantType = null; + var dominantValue = double.NegativeInfinity; + + foreach (var kv in metadata) + { + if (!kv.Key.StartsWith(prefix, StringComparison.Ordinal)) + continue; + + var type = kv.Key.Substring(prefix.Length); + if (dominantType is null + || kv.Value > dominantValue + || (kv.Value == dominantValue && string.CompareOrdinal(type, dominantType) < 0)) + { + dominantValue = kv.Value; + dominantType = type; + } + } + + return dominantType is null ? null : FactCollectorHelpers.WaitFamilyKey(dominantType); + } +} diff --git a/PerformanceMonitor.Analysis/Baselines/AnomalyThresholds.cs b/PerformanceMonitor.Analysis/Baselines/AnomalyThresholds.cs new file mode 100644 index 000000000..d127c5181 --- /dev/null +++ b/PerformanceMonitor.Analysis/Baselines/AnomalyThresholds.cs @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +namespace PerformanceMonitor.Analysis.Baselines; + +/// <summary> +/// The anomaly-detector tuning constants shared by the two active detectors (Lite +/// <c>AnomalyDetector</c>, Darling <c>PgAnomalyDetector</c>) so they cannot drift. The detector +/// METHOD BODIES stay per-store (DuckDB / Npgsql SQL + binding); every numeric threshold, floor, +/// fallback bar, and sentinel is single-sourced here. The deprecated Dashboard keeps its own copy. +/// </summary> +public static class AnomalyThresholds +{ + /// <summary> + /// Default number of standard deviations above baseline mean to flag as anomalous. + /// </summary> + public const double DefaultDeviationThreshold = 2.0; + + /// <summary> + /// Default ratio threshold for the wait-profile detector (peak window all-types ms/sec ÷ baseline + /// mean). On the HONEST per-second scale now, so far below the old 5.0 that assumed a ~240x-inflated + /// input; matches the FactScorer WaitProfileRatioFloor. CALIBRATE ON THE SQL2025/HAMMERDB BOX. + /// </summary> + public const double DefaultRatioThreshold = 4.0; + + /// <summary> + /// Default ratio threshold for event-based anomaly detection (blocking/deadlocks). + /// </summary> + public const double DefaultEventRatioThreshold = 3.0; + + // #1486 absolute-magnitude floors (the z-path sanity ceiling) so a z-score against a thin + // baseline can't surface a trivial value; sigma display cap so a variance-collapsed baseline + // can't render millions-of-sigma. + public const double CpuFloorPct = 50.0; // % + public const double ReadLatencyFloorMs = 10.0; // ms + public const double BatchRequestFloor = 500.0; // requests/sec + public const double SessionCountFloor = 50.0; // connections + public const double QueryDurationFloorUs = 1_000_000; // total elapsed us = 1 second + public const double MemoryPressureFloorPct = 90.0; // total/target % + public const double WriteLatencyFloorMs = 20.0; // ms, was 5 + public const double SigmaDisplayCap = 25.0; + + // Low-quality-baseline ABSOLUTE-FALLBACK bars: when the baseline is too thin to trust a z-score + // (BaselineBucket.IsTrustworthy false), the detector fires on these instead of going silent. + // Each is deliberately HIGHER than the matching #1486 magnitude floor above (the interaction + // trap: a young store fires only on the higher bar, never on both-AND-ed into blindness). + public const double CpuFallbackPct = 90.0; // % + public const double MemoryPressureFallbackPct = 95.0; // total/target % + public const double BatchRequestFallback = 5000.0; // requests/sec + public const double SessionCountFallback = 500.0; // connections + public const double QueryDurationFallbackUs = 5_000_000; // total elapsed us = 5 seconds + public const double IoLatencyFallbackMs = 50.0; // ms (read and write) + + // Wait-profile detector (DetectWaitAnomalies → one ANOMALY_WAIT_PROFILE): the current window's + // all-types wait ms/sec (PEAK across collections, matching the z-detectors) is compared to the + // WaitMsPerSec baseline. DefaultRatioThreshold and the FactScorer wait slope are on the HONEST + // per-second scale now (the old 5×/20× was calibrated to a ~240×-inflated per-hour-vs-per-interval + // input) — a sensible starting point; CALIBRATE ON THE SQL2025/HAMMERDB BOX. + public const double WaitProfileFallbackMsPerSec = 250.0; // untrustworthy-baseline absolute bar + public const double NoBaselineRatio = 100.0; // scoring sentinel for a first-occurrence (is_new) + + // Day-over-day object/index detection (delta-based, not stddev-baseline) since the + // index_object_stats collector runs daily and its counters are cumulative. Emits + // ANOMALY_OBJECT_GROWTH for the biggest table grower over threshold and ANOMALY_OBJECT_CONTENTION + // for the index with the largest new lock-wait time. + public const decimal ObjectGrowthMbThreshold = 100m; // ignore tables that grew less than 100 MB + public const double ObjectGrowthPctThreshold = 20.0; // ...and less than 20% day-over-day + public const long ObjectLockWaitMsDeltaThreshold = 60000; // 1 minute of new lock waits +} diff --git a/PerformanceMonitor.Analysis/Baselines/BaselineBucket.cs b/PerformanceMonitor.Analysis/Baselines/BaselineBucket.cs new file mode 100644 index 000000000..bb9a5d1ef --- /dev/null +++ b/PerformanceMonitor.Analysis/Baselines/BaselineBucket.cs @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; + +// Sub-namespace ON PURPOSE: the deprecated Full Dashboard keeps its OWN BaselineBucket/BaselineTier in +// PerformanceMonitorDashboard.Analysis, and several Dashboard files import PerformanceMonitor.Analysis +// alongside it. `using` is not recursive, so a plain `using PerformanceMonitor.Analysis;` does NOT pull +// in this .Baselines sub-namespace — which is what keeps the (untouched) Dashboard free of a CS0104 +// ambiguous-reference break. Do NOT hoist these types up to PerformanceMonitor.Analysis. The two ACTIVE +// apps (Lite + Darling) consume this single shared copy; the Dashboard's frozen copy stays as-is. +namespace PerformanceMonitor.Analysis.Baselines; + +/// <summary> +/// Represents the computed baseline statistics for a single time bucket. Shared by the two active +/// baseline providers (Lite <c>BaselineProvider</c>, Darling <c>PgBaselineProvider</c>) so the model +/// and its z-score floor semantics cannot drift; the store-specific baseline SQL stays per-provider. +/// </summary> +public class BaselineBucket +{ + public int HourOfDay { get; init; } + public int DayOfWeek { get; init; } + public double Mean { get; init; } + public double StdDev { get; init; } + public long SampleCount { get; init; } + public BaselineTier Tier { get; init; } + + /// <summary> + /// Distinct calendar days observed in this bucket — the baseline-QUALITY signal the old + /// quantity-only warmup gate lacked (a bucket with many samples but few distinct days is one + /// busy day, not a trend). Carried through collapse by CollapseToHourOnly (SUM — each calendar + /// day lands in exactly one day-of-week bucket, so the sum is exact) and CollapseToFlat (MAX — + /// a calendar day recurs across the 24 hour buckets; MAX is a ~5 ceiling, not the true pooled + /// distinct-day count, but a cheap proxy that avoids a second global query). + /// </summary> + public long DistinctDays { get; init; } + + /// <summary> + /// Absolute dispersion floor for BOUNDED metrics (CPU %, memory %, I/O ms) so a + /// variance-collapsed baseline can't manufacture a giant z-score. 0 for server-relative + /// metrics (batch/query-duration/sessions/waits/blocking), which have no universal floor and + /// rely on the detector magnitude floors + the quality gate instead. Set per metric by the provider. + /// </summary> + public double AbsStdDevFloor { get; init; } + + // Baseline-quality tier gates (see IsTrustworthy). Day-mins are tier-aware: a Full (hour × dow) + // bucket only sees ~5 same-weekday dates in a 30-day window, so its day-min is a modest 3. The Flat + // tier's DistinctDays is a MAX-over-hour-buckets proxy (CollapseToFlat) capped at that SAME ~5 + // ceiling, so it can't demand more than a Full bucket — a >=15 floor was structurally unreachable and + // left the Flat trust branch permanently dead, so match Full at 3. Sample-mins mirror the provider's + // per-tier selection floors. + private const long FullSampleMin = 10; + private const long FullDayMin = 3; + private const long HourOnlySampleMin = 10; + private const long HourOnlyDayMin = 10; + private const long FlatSampleMin = 3; + private const long FlatDayMin = 3; + + public static BaselineBucket Empty => new() + { + HourOfDay = -1, DayOfWeek = -1, Mean = 0, StdDev = 0, + SampleCount = 0, DistinctDays = 0, Tier = BaselineTier.Flat + }; + + /// <summary> + /// Returns the effective stddev with a proportional minimum floor plus, for bounded metrics, + /// an absolute floor — both prevent division-by-zero AND a variance-collapsed baseline from + /// producing a giant z-score. When both mean and stddev are 0 (zero activity), returns 0 — + /// callers should skip scoring (or fall back to the absolute-threshold path). + /// </summary> + public double EffectiveStdDev + { + get + { + if (Mean == 0 && StdDev <= 0) return 0; // Zero activity — skip scoring + return Math.Max(Math.Max(StdDev, Mean * 0.01), AbsStdDevFloor); + } + } + + /// <summary> + /// Whether this baseline is dense enough to trust a z-score / ratio against. Requires real + /// dispersion, the tier's sample floor, AND enough DISTINCT days. A low-quality baseline is NOT + /// silenced — the detector falls back to an absolute-threshold bar instead. This gate and the + /// #1486 magnitude floors are COMPLEMENTARY, not both-mandatory: a trustworthy baseline trusts z + /// with the magnitude floor as a sanity ceiling; an untrustworthy one fires only on the higher + /// absolute bar — they must never AND into blindness on a young store. + /// </summary> + public bool IsTrustworthy + { + get + { + if (EffectiveStdDev <= 0) return false; + var (sampleMin, dayMin) = Tier switch + { + BaselineTier.Full => (FullSampleMin, FullDayMin), + BaselineTier.HourOnly => (HourOnlySampleMin, HourOnlyDayMin), + _ => (FlatSampleMin, FlatDayMin), + }; + return SampleCount >= sampleMin && DistinctDays >= dayMin; + } + } +} + +public enum BaselineTier +{ + Full, // hour + day-of-week (168 buckets) + HourOnly, // hour only (24 buckets) + Flat // global mean/stddev +} diff --git a/PerformanceMonitor.Analysis/Baselines/BaselineMath.cs b/PerformanceMonitor.Analysis/Baselines/BaselineMath.cs new file mode 100644 index 000000000..b77ace274 --- /dev/null +++ b/PerformanceMonitor.Analysis/Baselines/BaselineMath.cs @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace PerformanceMonitor.Analysis.Baselines; + +/// <summary> +/// The store-agnostic baseline math shared by the two active providers (Lite <c>BaselineProvider</c>, +/// Darling <c>PgBaselineProvider</c>): the rolling-window / tier-selection thresholds, the tier +/// selection with hysteresis (<see cref="SelectBucket"/>), the pooled-statistics collapse +/// (hour-only / flat), and the bounded-metric dispersion floor. Each provider keeps only the +/// genuinely dialect-specific part — the per-metric baseline SQL and its parameter binding. Extracted +/// verbatim so the two apps' baseline behavior cannot drift. +/// </summary> +public static class BaselineMath +{ + /// <summary>Rolling window for baseline computation.</summary> + public const int BaselineWindowDays = 30; + + /// <summary>Collapse to hour-only when full bucket has fewer than this many samples.</summary> + public const int CollapseThreshold = 10; + + /// <summary>Restore to full bucket when sample count reaches this level (hysteresis).</summary> + public const int RestoreThreshold = 15; + + /// <summary> + /// Selects the most specific baseline bucket available for the (hour, day-of-week) coordinate, + /// collapsing to less-specific tiers as sample density falls: Full (hour+dow) -> Hour-only -> + /// Flat. Hysteresis keeps a Full bucket in the 10-14 sample band (RestoreThreshold vs + /// CollapseThreshold) instead of flapping to hour-only. Pure over the precomputed bucket map, + /// so it lives here rather than in either store provider. + /// </summary> + public static BaselineBucket SelectBucket( + IReadOnlyDictionary<(int HourOfDay, int DayOfWeek), BaselineBucket> baselines, + int hourOfDay, + int dayOfWeek) + { + // Try full bucket (hour + day-of-week) + var fullKey = (hourOfDay, dayOfWeek); + if (baselines.TryGetValue(fullKey, out var fullBucket) && fullBucket.SampleCount >= RestoreThreshold) + return fullBucket; + + // If full bucket exists but below restore threshold, check if it's above collapse threshold + // (hysteresis: don't collapse if we're between 10-14 samples and were previously using full) + if (fullBucket != null && fullBucket.SampleCount >= CollapseThreshold) + return fullBucket; + + // Collapse to hour-only: aggregate all days for this hour + var hourBuckets = baselines + .Where(kvp => kvp.Key.HourOfDay == hourOfDay) + .Select(kvp => kvp.Value) + .ToList(); + + if (hourBuckets.Count > 0) + { + var collapsed = CollapseToHourOnly(hourBuckets); + if (collapsed.SampleCount >= CollapseThreshold) + return collapsed; + } + + // Collapse to flat: aggregate everything + var allBuckets = baselines.Values.ToList(); + if (allBuckets.Count > 0) + { + var flat = CollapseToFlat(allBuckets); + if (flat.SampleCount >= 3) // Minimum viable baseline + return flat; + } + + return BaselineBucket.Empty; + } + + /// <summary> + /// Bounded-metric absolute dispersion floor (see BaselineBucket.AbsStdDevFloor). Server-relative + /// metrics have no universal floor and return 0. Tunable — calibrate on the SQL2025/HammerDB box. + /// </summary> + public static double AbsStdDevFloorFor(string metricName) => metricName switch + { + MetricNames.Cpu => 5.0, // CPU utilization % + MetricNames.Memory => 4.0, // memory pressure % + MetricNames.IoLatency => 2.5, // I/O latency ms + _ => 0.0, // batch/query-duration/sessions/waits/blocking/deadlock — server-relative + }; + + /// <summary> + /// Collapses multiple day-of-week buckets for the same hour into a single + /// hour-only bucket using pooled statistics. + /// </summary> + private static BaselineBucket CollapseToHourOnly(List<BaselineBucket> hourBuckets) + { + var totalSamples = hourBuckets.Sum(b => b.SampleCount); + if (totalSamples == 0) + return BaselineBucket.Empty; + + // Weighted mean across all day-of-week buckets for this hour + var weightedMean = hourBuckets.Sum(b => b.Mean * b.SampleCount) / totalSamples; + + // Pooled standard deviation + var pooledVariance = PoolVariance(hourBuckets, weightedMean); + + return new BaselineBucket + { + HourOfDay = hourBuckets[0].HourOfDay, + DayOfWeek = -1, // Indicates hour-only + Mean = weightedMean, + StdDev = Math.Sqrt(pooledVariance), + SampleCount = totalSamples, + // Each calendar day lands in exactly one day-of-week bucket, so summing distinct-days + // across the dow buckets for this hour is exact (no double-count). + DistinctDays = hourBuckets.Sum(b => b.DistinctDays), + AbsStdDevFloor = hourBuckets[0].AbsStdDevFloor, + Tier = BaselineTier.HourOnly + }; + } + + /// <summary> + /// Collapses all buckets into a single flat baseline (equivalent to old 24h behavior). + /// </summary> + private static BaselineBucket CollapseToFlat(List<BaselineBucket> allBuckets) + { + var totalSamples = allBuckets.Sum(b => b.SampleCount); + if (totalSamples == 0) + return BaselineBucket.Empty; + + var weightedMean = allBuckets.Sum(b => b.Mean * b.SampleCount) / totalSamples; + var pooledVariance = PoolVariance(allBuckets, weightedMean); + + return new BaselineBucket + { + HourOfDay = -1, + DayOfWeek = -1, + Mean = weightedMean, + StdDev = Math.Sqrt(pooledVariance), + SampleCount = totalSamples, + // A calendar day recurs across the 24 hour buckets, so summing would double-count; + // MAX is a ~5 ceiling (each (hour, dow) bucket holds at most ~5 same-weekday dates in a + // 30-day window) — a cheap proxy that avoids an extra global DISTINCT-days query. + DistinctDays = allBuckets.Max(b => b.DistinctDays), + AbsStdDevFloor = allBuckets[0].AbsStdDevFloor, + Tier = BaselineTier.Flat + }; + } + + /// <summary> + /// Computes pooled variance from multiple buckets, accounting for both + /// within-bucket variance and between-bucket mean differences. + /// </summary> + private static double PoolVariance(List<BaselineBucket> buckets, double grandMean) + { + var totalSamples = buckets.Sum(b => b.SampleCount); + if (totalSamples <= 1) return 0; + + double totalSumSq = 0; + foreach (var b in buckets) + { + if (b.SampleCount <= 0) continue; + // Within-bucket variance contribution + totalSumSq += (b.StdDev * b.StdDev) * (b.SampleCount - 1); + // Between-bucket mean difference contribution + totalSumSq += b.SampleCount * (b.Mean - grandMean) * (b.Mean - grandMean); + } + + return totalSumSq / (totalSamples - 1); + } +} diff --git a/PerformanceMonitor.Analysis/Baselines/MetricNames.cs b/PerformanceMonitor.Analysis/Baselines/MetricNames.cs new file mode 100644 index 000000000..1f285d60a --- /dev/null +++ b/PerformanceMonitor.Analysis/Baselines/MetricNames.cs @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +namespace PerformanceMonitor.Analysis.Baselines; + +/// <summary>Metric name constants used as baseline cache keys. Shared by Lite + Darling so the two +/// active apps key their baselines identically (the deprecated Dashboard keeps its own +/// <c>SqlServerMetricNames</c>).</summary> +public static class MetricNames +{ + public const string Cpu = "cpu"; + public const string BatchRequests = "batch_requests"; + public const string WaitStats = "wait_stats"; + public const string SessionCount = "session_count"; + public const string QueryDuration = "query_duration"; + public const string IoLatency = "io_latency"; + public const string Blocking = "blocking"; + public const string Deadlock = "deadlock"; + public const string Memory = "memory"; + + // Chart-unit metrics (for UI bands — units match what the chart displays) + public const string WaitMsPerSec = "wait_ms_per_sec"; + public const string BlockingPerMinute = "blocking_per_minute"; +} diff --git a/PerformanceMonitor.Analysis/CoFiredSummary.cs b/PerformanceMonitor.Analysis/CoFiredSummary.cs index a720a2e26..7fd538948 100644 --- a/PerformanceMonitor.Analysis/CoFiredSummary.cs +++ b/PerformanceMonitor.Analysis/CoFiredSummary.cs @@ -17,6 +17,19 @@ public static class CoFiredSummary /// <summary>How many sibling titles to name before collapsing the rest into "(+N more)".</summary> public const int DefaultCap = 3; + /// <summary> + /// Default lead-in for the analysis-WINDOW surfaces (viewer / Dashboard reader / Lite reader), which + /// cross-reference every card in the run. The incident-scoped alert path passes + /// <see cref="IncidentLeadIn"/> instead so its wording matches its "Co-fired in this incident" heading. + /// </summary> + public const string WindowLeadIn = "Also surfaced in this analysis window:"; + + /// <summary> + /// Incident-scoped lead-in for <c>AnalysisNotificationService</c>'s per-incident alert, where the + /// named siblings are the OTHER findings in the same incident (not the whole window). + /// </summary> + public const string IncidentLeadIn = "Also fired in this incident:"; + /// <summary> /// The distinct OTHER finding titles in the same analysis window, highest-severity first, /// excluding the caller's own title. <paramref name="windowTitles"/> is every finding/card title in @@ -38,10 +51,13 @@ public static IReadOnlyList<string> OtherTitles( } /// <summary> - /// A demarcated one-line cross-reference ("Also surfaced in this analysis window: A; B; C (+N more).") - /// for appending to a card's advice text, or null when nothing else fired in the window. + /// A demarcated one-line cross-reference ("{leadIn} A; B; C (+N more).") for appending to a card's + /// advice text or an alert detail, or null when there is nothing else to name. <paramref name="leadIn"/> + /// defaults to the window-scoped wording (<see cref="WindowLeadIn"/>); the incident-scoped alert path + /// passes <see cref="IncidentLeadIn"/>. A trailing space and the joined list follow the lead-in, so + /// callers pass the label WITHOUT a trailing space. /// </summary> - public static string? Line(IReadOnlyList<string> others, int cap = DefaultCap) + public static string? Line(IReadOnlyList<string> others, int cap = DefaultCap, string leadIn = WindowLeadIn) { if (others is null || others.Count == 0) return null; @@ -51,6 +67,6 @@ public static IReadOnlyList<string> OtherTitles( var list = string.Join("; ", shown); if (extra > 0) list += $" (+{extra} more)"; - return "Also surfaced in this analysis window: " + list + "."; + return leadIn + " " + list + "."; } } diff --git a/PerformanceMonitor.Analysis/FactAdvice.cs b/PerformanceMonitor.Analysis/FactAdvice.cs index c3ce4c25a..6cbb7c751 100644 --- a/PerformanceMonitor.Analysis/FactAdvice.cs +++ b/PerformanceMonitor.Analysis/FactAdvice.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text; using System.Text.Json; @@ -51,6 +52,12 @@ public static class FactAdvice if (factKey.StartsWith("BAD_ACTOR_", StringComparison.OrdinalIgnoreCase)) return _byKey.GetValueOrDefault("BAD_ACTOR"); + // ANOMALY_WAIT_PROFILE is the one all-types wait-profile fact (change 1). It prefix-matches + // ANOMALY_WAIT_ below, so special-case it FIRST — the generic per-type composer would render + // "Anomalous spike in PROFILE" otherwise. + if (factKey.Equals("ANOMALY_WAIT_PROFILE", StringComparison.OrdinalIgnoreCase)) + return _byKey.GetValueOrDefault("ANOMALY_WAIT_PROFILE"); + if (factKey.StartsWith("ANOMALY_WAIT_", StringComparison.OrdinalIgnoreCase)) { var inner = factKey.Substring("ANOMALY_WAIT_".Length); @@ -166,7 +173,7 @@ public static class FactAdvice "QUERY_SPILLS" => ComposeQuerySpills(factsByKey), // Simple wait-type blocks: each states its own wait totals, with a clean concept + fix. "RESOURCE_SEMAPHORE" or "RESOURCE_SEMAPHORE_QUERY_COMPILE" or "WRITELOG" - or "LATCH_EX" or "LATCH_SH" or "LCK_M_S" or "LCK_M_IS" + or "LATCH_EX" or "LATCH_SH" or "PAGELATCH_UP" or "LCK_M_S" or "LCK_M_IS" => ComposeWaitByKey(rootFactKey, factsByKey), // Query-level: state the cost-swing ratio / regression factor / tempdb driver the engine // measured, and permute on the discriminating flag (forced-plan-failing, dominant consumer). @@ -195,6 +202,7 @@ public static class FactAdvice "ANOMALY_SESSION_SPIKE" => ComposeAnomaly(factsByKey, "ANOMALY_SESSION_SPIKE", "peak_connections", "Connection count", Num, "Far more sessions connected than this hour-of-week normally sees — often a connection-pool leak or a retry storm."), "ANOMALY_QUERY_DURATION" => ComposeAnomaly(factsByKey, "ANOMALY_QUERY_DURATION", "peak_total_elapsed_us", "Total query duration", Micros, "Queries ran far longer in aggregate than this hour-of-week normally does."), "ANOMALY_MEMORY_PRESSURE" => ComposeAnomalyMemoryPressure(factsByKey), + "ANOMALY_WAIT_PROFILE" => ComposeAnomalyWaitProfile(factsByKey), "ANOMALY_BLOCKING_SPIKE" => ComposeAnomalyRatio(factsByKey, "ANOMALY_BLOCKING_SPIKE", "blocking event"), "ANOMALY_DEADLOCK_SPIKE" => ComposeAnomalyRatio(factsByKey, "ANOMALY_DEADLOCK_SPIKE", "deadlock"), "ANOMALY_OBJECT_GROWTH" => ComposeAnomalyObjectGrowth(factsByKey), @@ -913,6 +921,10 @@ private static AdviceBlock ComposeSimpleWait(IReadOnlyDictionary<string, Fact> f "Shared latch contention — LATCH_SH", "LATCH_SH is shared-latch contention on in-memory structures, and it frequently accompanies heavy parallelism or specific internal hotspots rather than user row contention.", "Narrow it by the latch sub-type — common drivers are heavy concurrent reads of the same pages or parallelism overhead. Reducing unnecessary parallelism (cost threshold for parallelism, MAXDOP) often relieves the parallelism-driven cases."), + "PAGELATCH_UP" => ComposeSimpleWait(facts, key, "Tempdb allocation page-latch waits", + "Tempdb allocation contention — PFS/GAM/SGAM page latches (PAGELATCH_UP)", + "PAGELATCH_UP is contention on tempdb's allocation bitmap pages (PFS/GAM/SGAM): sessions that rapidly create and drop temp tables, or spill sorts and hashes to tempdb, all latch the same small set of allocation pages in each data file. It is an in-memory latch — not disk I/O (that is PAGEIOLATCH) and not row locks — and it is the classic 'too few tempdb data files for the core count' signal.", + "Add tempdb data files so allocations spread across more bitmap pages — one per logical core up to 8, all the same size, with equal autogrowth (the source recommendation is add tempdb files / TF 1118). Confirm MIXED_PAGE_ALLOCATION is OFF (the default since 2016; TF 1118 is the pre-2016 equivalent forcing uniform extents). Then cut the churn that drives it: reduce how many temp objects the hot procedures create, and right-size the queries spilling to tempdb (accurate statistics give adequate grants) so fewer sessions hit the allocation pages at once."), "LCK_M_S" => ComposeSimpleWait(facts, key, "Shared-lock waits", "Readers are blocked waiting for shared locks — LCK_M_S", "LCK_M_S is time spent waiting to acquire a shared (read) lock, which under the default READ COMMITTED means a reader is blocked behind a writer's exclusive lock.", @@ -1263,31 +1275,46 @@ private static AdviceBlock ComposeTempdbUsage(IReadOnlyDictionary<string, Fact> var internalObj = FactMeta(facts, "TEMPDB_USAGE", "max_internal_object_mb") ?? 0; var version = FactMeta(facts, "TEMPDB_USAGE", "max_version_store_mb") ?? 0; + // report.tempdb_pressure keys pressure_level PURELY on the absolute version-store size and checks + // it FIRST in the recommendation CASE (install/47:1429-1443): a version store over the > 1000 MB + // MEDIUM bar is a specific, actionable problem (a long-running RCSI/snapshot transaction pinning + // row versions) regardless of what else is bigger — and it is exactly what the + // ScoreTempDbVersionStore arm fires on. So when the version store clears that bar make it the + // driver even if another consumer is nominally larger; otherwise chase the largest consumer. + var versionStorePressure = version > 1000; string driverName, fix; double driverMb; - if (version >= user && version >= internalObj && version > 0) + bool driverIsLargest; + if (versionStorePressure || (version >= user && version >= internalObj && version > 0)) { driverName = "the version store"; driverMb = version; + driverIsLargest = version >= user && version >= internalObj; fix = "The version store grows with long-running transactions under RCSI or snapshot isolation (and with heavy triggers) — find and shorten the oldest open transaction; tempdb cannot reclaim its versions until that transaction ends."; } else if (internalObj >= user && internalObj > 0) { driverName = "internal objects (sort and hash spills)"; driverMb = internalObj; + driverIsLargest = true; fix = "Internal objects are sorts and hash spills — fix the queries spilling to tempdb by getting them adequate memory grants and accurate statistics so the work stays in memory, which usually means correcting bad cardinality estimates."; } else { driverName = "user objects (#temp tables and table variables)"; driverMb = user; + driverIsLargest = true; fix = "User objects are #temp tables and table variables — find the sessions materializing large temp objects and reduce what they stage, or index them so they hold fewer rows."; } return fallback with { - Headline = $"tempdb reserved up to {reserved.Value:N0} MB — {driverName} was the largest consumer", - Investigation = $"tempdb reserved up to {reserved.Value:N0} MB this window, with {driverName} the largest consumer at {driverMb:N0} MB. That identifies which of the three tempdb consumers to chase.", + Headline = driverIsLargest + ? $"tempdb reserved up to {reserved.Value:N0} MB — {driverName} was the largest consumer" + : $"tempdb reserved up to {reserved.Value:N0} MB — the version store reached {driverMb:N0} MB", + Investigation = driverIsLargest + ? $"tempdb reserved up to {reserved.Value:N0} MB this window, with {driverName} the largest consumer at {driverMb:N0} MB. That identifies which of the three tempdb consumers to chase." + : $"tempdb reserved up to {reserved.Value:N0} MB this window; the version store reached {driverMb:N0} MB — over the 1 GB pressure bar — so it is the signal to chase even though it is not the largest of the three consumers.", Remediation = fix }; } @@ -1340,6 +1367,27 @@ private static AdviceBlock ComposeAnomalyRatio(IReadOnlyDictionary<string, Fact> if (current is null || ratio is null) return fallback; + // is_new = the baseline was too thin to trust a ratio (the detector fell back to the absolute + // count). Render it as a first occurrence — never the dishonest "spiked to 100×" the sentinel + // ratio would otherwise print. + var isNew = (FactMeta(facts, key, "is_new") ?? 0) >= 1; + if (isNew) + { + var invNew = + $"{Plural(current.Value, noun)} this window — a first occurrence, with no established baseline for this " + + "hour-of-week yet to compare against. Treat it as a new event, not a proven regression: check whether it " + + "coincides with a workload change, a deploy, or a one-off before treating it as chronic."; + var remNew = + $"If it recurs or sustains, it will cross the standard {noun} threshold and surface as a first-class finding " + + "with its chain or graph detail on a later window; treat it then. A one-time event that matches a known cause needs only awareness."; + return fallback with + { + Headline = $"{Plural(current.Value, noun)} this window — first occurrence, no baseline yet", + Investigation = invNew, + Remediation = remNew + }; + } + var baseRate = FactMeta(facts, key, "baseline_rate"); var inv = new StringBuilder($"{Plural(current.Value, noun)} this window — about {ratio.Value:0.#}× the normal rate for this hour-of-week"); if (baseRate is not null) @@ -1358,6 +1406,68 @@ private static AdviceBlock ComposeAnomalyRatio(IReadOnlyDictionary<string, Fact> }; } + /// <summary> + /// ANOMALY_WAIT_PROFILE composed (change 1): the whole-server wait rate shifted above its baseline. + /// Names the top contributing wait types (from the contrib_<TYPE> metadata keys — the type + /// name lives in the KEY since the metadata dictionary values are doubles), and states the current + /// vs baseline per-second rate. When is_new (thin baseline), renders it as a first occurrence. + /// </summary> + private static AdviceBlock ComposeAnomalyWaitProfile(IReadOnlyDictionary<string, Fact> facts) + { + var fallback = _byKey["ANOMALY_WAIT_PROFILE"]; + if (!facts.TryGetValue("ANOMALY_WAIT_PROFILE", out var f)) + return fallback; + + // Top contributors: metadata keys "contrib_<TYPE>" → value = the type's total wait ms; name + // them in descending order. + var contributors = f.Metadata + .Where(kvp => kvp.Key.StartsWith("contrib_", StringComparison.Ordinal)) + .OrderByDescending(kvp => kvp.Value) + .Select(kvp => kvp.Key.Substring("contrib_".Length)) + .ToList(); + var topList = contributors.Count == 0 + ? "the collected wait types" + : string.Join(", ", contributors.Take(3)); + + var isNew = (f.Metadata.GetValueOrDefault("is_new")) >= 1; + var current = f.Metadata.GetValueOrDefault("current_ms_per_sec"); + var mean = f.Metadata.GetValueOrDefault("baseline_mean"); + var ratio = f.Metadata.GetValueOrDefault("ratio"); + + string inv; + string headline; + if (isNew) + { + headline = "The server's wait profile is heavy, with no baseline yet to compare against"; + inv = + $"The all-types wait rate peaked at about {current:0.#} ms/sec this window, led by {topList}. There is no " + + "established wait-rate baseline for this hour-of-week yet, so this is flagged on its absolute level, not a " + + "proven deviation — treat it as a first look at where the server spends its wait time, and check whether it " + + "lines up with a workload change or a one-off job."; + } + else + { + headline = $"The server's wait profile shifted to about {ratio:0.#}× its baseline for this time of week"; + inv = + $"The all-types wait rate peaked at about {current:0.#} ms/sec this window — roughly {ratio:0.#}× the " + + $"{mean:0.#} ms/sec normal for this hour-of-week — led by {topList}. This is a shift in the overall wait " + + "profile, not necessarily a sustained problem: check whether it coincides with a workload change, a deploy, " + + "or a one-off event before treating it as chronic."; + } + + var rem = + "Open the Wait Stats tab and zoom to this window to see which of the named types drove the shift, then chase " + + "the dominant one with its normal playbook. If the elevated profile persists across windows, the threshold-based " + + "finding for the leading wait type will fire and the standard advice applies."; + + return fallback with + { + Headline = headline, + Investigation = inv, + Remediation = rem + }; + } + /// <summary> /// ANOMALY_MEMORY_PRESSURE composed: the z-score anomaly framing plus the current max-server-memory /// sentence (this anomaly's fix often depends on the configured cap). @@ -1389,6 +1499,13 @@ void Add(string metaKey, string desc) Add("page_verify_not_checksum_count", "page verify not set to CHECKSUM"); Add("auto_create_stats_off_count", "auto-create statistics off"); Add("auto_update_stats_off_count", "auto-update statistics off"); + // Query Store off = user DBs (database_count) minus those with it on (query_store_on_count); + // the QS-off scorer arm (INFO, install/50:83) roots off the same two counts. + var qsOff = f.Metadata.TryGetValue("database_count", out var dbc) && dbc > 0 + && f.Metadata.TryGetValue("query_store_on_count", out var qOn) + ? (long)(dbc - qOn) : 0L; + if (qsOff > 0) + items.Add($"{qsOff:N0} with Query Store off"); if (items.Count == 0) return fallback; @@ -1398,6 +1515,11 @@ void Add(string metaKey, string desc) "indexes and churns I/O re-growing what it shrank), AUTO_CLOSE off, page verify set to CHECKSUM so " + "corruption is detected, auto-create and auto-update statistics on, and RCSI on where the blocking " + "is readers-versus-writers. The Database Configuration view lists exactly which databases each applies to."; + if (qsOff > 0) + rem += + " Where Query Store is off, enable it (ALTER DATABASE <db> SET QUERY_STORE = ON; then " + + "OPERATION_MODE = READ_WRITE) so query performance history is captured for troubleshooting " + + "and plan-regression detection."; return fallback with { @@ -1865,6 +1987,14 @@ private static Dictionary<string, AdviceBlock> BuildAdviceTable() Remediation: "If a plan regression co-fired, force the historically faster plan as the fast fix. If parameter sensitivity co-fired, do NOT force a plan — that locks in the wrong plan for the other parameter values; use OPTION (RECOMPILE) on the affected statement, or branch the procedure by parameter value. For an ad-hoc reporting spike matching neither, Resource Governor or moving the report off-peak is the durable fix."); + t["RUNNABLE_TASKS"] = new AdviceBlock( + Headline: + "Runnable-task queue is backed up — tasks are ready to run but waiting for a CPU scheduler", + Investigation: + "Every runnable task is a query that has everything it needs EXCEPT a free scheduler — the CPUs are the bottleneck, not I/O or locks. The count is the latest cpu_scheduler_stats snapshot's total across all schedulers (the runnable_tasks_warning flag trips when the queue reaches roughly one task per core). Open the CPU tab and the Wait Stats tab: SOS_SCHEDULER_YIELD co-elevation confirms scheduler starvation, and a THREADPOOL wait means it has escalated to worker-thread exhaustion. Cross-check the top CPU-consuming queries for the window.", + Remediation: + "CPU pressure — tune the CPU-heavy queries first (almost always cheaper than adding cores); the top consumers for the window are attached. Excessive parallelism is a frequent driver: if CXPACKET or high-DOP queries co-fired, lower MAXDOP / raise the Cost Threshold for Parallelism so small queries stop going parallel and monopolizing schedulers. If the queries are already tuned and the queue stays deep, the box is genuinely under-provisioned for the load — add CPU."); + // ───────────────────────────────────────────────────────────────── // Memory pressure // ───────────────────────────────────────────────────────────────── @@ -1885,6 +2015,22 @@ private static Dictionary<string, AdviceBlock> BuildAdviceTable() Remediation: "For tempdb-driven PAGEIOLATCH_EX, fix the spilling queries — update statistics with FULLSCAN to correct the cardinality estimates that produced too-small grants, or rewrite the operator that spills. For modification-workload pressure, batch large operations into smaller chunks so the buffer pool can flush between batches. If the storage itself is slow (write latency consistently above 10 ms on data, 2 ms on log), no amount of query tuning will recover it — the storage is the bottleneck and needs hardware-side investigation."); + t["CONFIG_PRIORITY_BOOST"] = new AdviceBlock( + Headline: + "Priority boost is enabled — SQL Server threads run at above-normal Windows scheduling priority, which can starve OS-critical threads", + Investigation: + "`sp_configure 'priority boost'` raises the Windows scheduling priority of SQL Server's threads above normal. Microsoft recommends against it for essentially all servers: it can starve OS threads (network, disk, cluster heartbeat), destabilize the instance, and cause connectivity or failover problems — with no reliable throughput gain. It is almost always a leftover from bad tuning advice rather than a deliberate, measured choice.", + Remediation: + "Disable it: `EXECUTE sp_configure 'priority boost', 0; RECONFIGURE;` — the change takes effect after a SQL Server service restart. There is effectively no workload for which priority boost is the right fix."); + + t["CONFIG_LIGHTWEIGHT_POOLING"] = new AdviceBlock( + Headline: + "Lightweight pooling (fiber mode) is enabled — it breaks OLEDB and other in-process components and is not recommended", + Investigation: + "`sp_configure 'lightweight pooling'` (fiber mode) switches SQL Server to fiber-based scheduling. It disables features that assume thread-based scheduling — most OLEDB providers (linked servers, some CLR, extended stored procedures) fail or misbehave — and its narrow high-end NUMA / context-switch benefit almost never applies. Microsoft recommends leaving it off for essentially all workloads.", + Remediation: + "Disable it: `EXECUTE sp_configure 'lightweight pooling', 0; RECONFIGURE;` — takes effect after a service restart. Only consider re-enabling it under a specific, measured high-end OLTP/NUMA scenario with vendor guidance, and never with linked servers / OLEDB in use."); + t["RESOURCE_SEMAPHORE"] = new AdviceBlock( Headline: "RESOURCE_SEMAPHORE waits — queries are queueing for memory grants because the workspace pool is exhausted", @@ -1893,6 +2039,14 @@ private static Dictionary<string, AdviceBlock> BuildAdviceTable() Remediation: "The usual cause is over-granting: a query gets a grant much larger than it actually uses because cardinality estimation was wrong. Pull the offending queries' plans and compare the operator-level row estimates against actuals; update statistics with FULLSCAN on the affected tables, or add filtered indexes if a subset of the data drives the bad estimate. Per-query stopgap: `OPTION (MAX_GRANT_PERCENT = X)` caps a single offender's grant without affecting others. Resource Governor workload-group caps are the durable answer if one workload chronically starves the others."); + t["MEMORY_CLERKS"] = new AdviceBlock( + Headline: + "The TokenAndPermUserStore security cache has grown large — it can cause memory pressure and CPU spent on cache lookups", + Investigation: + "TokenAndPermUserStore caches security tokens for permission checks. Under lots of ad-hoc queries, dynamic SQL, or frequent security-context switching (EXECUTE AS, cross-database ownership chaining) it can balloon into gigabytes, crowding out useful plans and making every permission check walk a huge cache. The USERSTORE_TOKENPERM clerk size for this server is attached; the Memory → Memory Clerks view shows it alongside the other top clerks.", + Remediation: + "Clear it during a maintenance window with DBCC FREESYSTEMCACHE('TokenAndPermUserStore') for immediate relief. For a durable fix, cut the churn that grows it: parameterize ad-hoc queries so plans and security tokens are reused, and reduce security-context switching (EXECUTE AS, cross-database ownership chaining). If the growth recurs, investigate what is generating the volume of distinct security contexts."); + t["RESOURCE_SEMAPHORE_QUERY_COMPILE"] = new AdviceBlock( Headline: "Query compile gateway waits — too many concurrent compilations of expensive queries", @@ -1909,6 +2063,22 @@ private static Dictionary<string, AdviceBlock> BuildAdviceTable() Remediation: "Same playbook as RESOURCE_SEMAPHORE: the offenders are over-granting because of bad cardinality estimates. Pull the heavy queries' plans and find where the estimate diverged from actuals; update statistics with FULLSCAN, add the right indexes, and the grants shrink. `OPTION (MAX_GRANT_PERCENT = X)` is a per-query stopgap that caps the worst offender without affecting others."); + t["PLAN_CACHE_BLOAT"] = new AdviceBlock( + Headline: + "Single-use plans are bloating the plan cache — a large share of cached plans have been used exactly once, wasting the memory that holds them", + Investigation: + "The finding carries the single-use plan count and size against the totals for the latest snapshot; a single-use ratio above 20% is flagged, above 50% is severe. Each single-use plan is a query that compiled, ran once, and will almost certainly never be reused — most often unparameterized ad-hoc SQL or dynamic SQL that embeds literal values, so every distinct value produces its own one-shot plan. That memory is stolen from the buffer pool and from plans that WOULD be reused. Open Memory → Plan Cache to see the single-use vs. multi-use composition and the trend; RESOURCE_SEMAPHORE_QUERY_COMPILE or high SQL Compilations/sec co-firing confirms the same churn is also costing CPU on constant recompilation.", + Remediation: + "The low-risk first step is `optimize for ad hoc workloads` at the server level — SQL then caches only a small stub for a plan's first execution and the full plan only if it is reused, which collapses single-use bloat without changing plan choice. The durable fix is to stop generating the churn: parameterize the offending ad-hoc/dynamic SQL in the application so one plan serves every parameter value. Where the app cannot be changed, `ALTER DATABASE ... SET PARAMETERIZATION FORCED` makes the optimizer parameterize more aggressively — test it on a workload copy first, because forced parameterization can change plan selection for other queries. (Sourced from the Dashboard's report.plan_cache_bloat recommendation, install/47:1493.)"); + + t["MEMORY_PRESSURE_EVENTS"] = new AdviceBlock( + Headline: + "SQL Server signalled physical-memory pressure — the resource monitor logged RESOURCE_MEMPHYSICAL_LOW notifications in the window", + Investigation: + "These come from the RING_BUFFER_RESOURCE_MONITOR ring buffer: when the OS or SQL Server itself flags low available physical memory, the resource monitor raises an indicator (0-1 normal, 2 medium, 3+ severe) and SQL responds by trimming caches — evicting buffer-pool pages and plan cache to free memory. The finding carries the count of pressure events and the peak process/system indicators for the window. That cache trimming shows up downstream as more physical reads (PAGEIOLATCH_SH) and more recompiles as evicted plans are rebuilt, so check the Memory tab and the Wait Stats tab around the event times for that fallout.", + Remediation: + "Confirm whether the pressure is SQL's or the box's: check `max server memory` against physical RAM and what else runs on the host. If `max server memory` is unset or too high, SQL can starve the OS and other processes into signalling system-wide low memory — cap it leaving headroom for the OS (and for other instances/services on the box). If `max server memory` is already conservative and the system indicator is what fired, an external consumer (another instance, an app, a runaway agent) is taking the memory — find and constrain it. If the server is genuinely short of memory for its workload after those checks, add RAM."); + // ───────────────────────────────────────────────────────────────── // Blocking / lock contention // ───────────────────────────────────────────────────────────────── @@ -2038,6 +2208,14 @@ private static Dictionary<string, AdviceBlock> BuildAdviceTable() Remediation: "Architectural problem, not a configuration one. If a single hot page is being thrashed by small lookups, partition the index so the hot data spans multiple pages, denormalize the lookup into a wider structure, or cache at the application layer. There's no `sp_configure` setting that fixes this — the schema or workload has to change. If the contention is on a queue-table or status-flag pattern that everyone polls, switching that hot pattern to a service broker queue or an event-driven design is usually the durable answer."); + t["PAGELATCH_UP"] = new AdviceBlock( + Headline: + "Tempdb allocation contention — PFS/GAM/SGAM page-latch waits (PAGELATCH_UP)", + Investigation: + "PAGELATCH_UP waits are contention on tempdb's allocation bitmap pages (PFS/GAM/SGAM). Sessions that rapidly create and drop temp tables, or spill sorts and hashes to tempdb, all latch the same small set of allocation pages in each data file — the classic 'too few tempdb data files for the core count' signal. It is an in-memory latch, distinct from PAGEIOLATCH (disk reads) and from row locks. Open the TempDB tab and the Latch Stats sub-tab under Resource Metrics to confirm the allocation shape.", + Remediation: + "Add tempdb data files so allocations spread across more bitmap pages — one per logical core up to 8, all the same size, with equal autogrowth (the source recommendation is add tempdb files / TF 1118). Confirm MIXED_PAGE_ALLOCATION is OFF (the default since 2016; TF 1118 is the pre-2016 equivalent that forces uniform extents). Then cut the churn that drives it: reduce how many temp objects the hot procedures create, and right-size the queries spilling to tempdb (accurate statistics give adequate grants) so fewer sessions hit the allocation pages at once."); + // ───────────────────────────────────────────────────────────────── // TempDB // ───────────────────────────────────────────────────────────────── @@ -2299,6 +2477,14 @@ private static Dictionary<string, AdviceBlock> BuildAdviceTable() Remediation: "Match the fix to the shape. If total server memory dropped below target, the OS is reclaiming memory from SQL — check whether `max server memory` is capped too low and whether another process on the host is the aggressor; on a dedicated box, Lock Pages in Memory (the CONFIG_LPIM_DISABLED finding) prevents the paging. If the buffer pool grew fast and grants are pending, an offender is consuming a too-large grant from a bad cardinality estimate — its plan shows the estimate-vs-actual divergence, and FULLSCAN statistics or a filtered index fixes it. Anomalies that resolve on their own are typically one-time reporting queries; sustained ones become standard RESOURCE_SEMAPHORE or memory-grant findings on the next window."); + t["ANOMALY_WAIT_PROFILE"] = new AdviceBlock( + Headline: + "The server's wait profile shifted — its overall wait rate is anomalously elevated vs. baseline for this time bucket", + Investigation: + "This anomaly compares the whole-server all-types wait rate (ms of wait per second) against its hour-of-week baseline, rather than any single wait type — so a shift driven by a minority-but-real wait (RESOURCE_SEMAPHORE, a lock mode, ASYNC_NETWORK_IO) surfaces even while CXPACKET or SOS dominate the raw totals. The named contributors are the wait types that made up most of the window's wait time. Open the Wait Stats tab and zoom to the window to see the full breakdown and each type's trajectory.", + Remediation: + "Chase the leading contributor with its normal playbook — the profile shift only tells you the server spent unusually long waiting this window, not what to do about it. If the elevated profile persists across analysis windows, the threshold-based finding for the dominant wait type fires and the standard advice for that wait applies."); + t["ANOMALY_OBJECT_GROWTH"] = new AdviceBlock( Headline: "A table grew sharply day-over-day — its reserved size jumped well above its recent footprint", diff --git a/PerformanceMonitor.Analysis/FactCollectorHelpers.cs b/PerformanceMonitor.Analysis/FactCollectorHelpers.cs index 001b45758..1855ccc71 100644 --- a/PerformanceMonitor.Analysis/FactCollectorHelpers.cs +++ b/PerformanceMonitor.Analysis/FactCollectorHelpers.cs @@ -179,11 +179,31 @@ public static void GroupParallelismWaits(List<Fact> facts, AnalysisContext conte }); } + /// <summary> + /// Maps a raw wait type to the FAMILY key its regular fact is grouped under, mirroring + /// <see cref="GroupParallelismWaits"/> (all CX* → CXPACKET) and <see cref="GroupGeneralLockWaits"/> / + /// <see cref="IsGeneralLockWait"/> (general lock modes → LCK; LCK_M_S / LCK_M_IS, range locks, and + /// schema locks keep their own key). Every other wait type maps to itself. This is the single + /// source of truth <see cref="AnomalyIncidentReconciler"/> uses to fold an ANOMALY_WAIT_PROFILE + /// into the regular wait finding that shares its dominant driver's family, so the family decision + /// can never drift from how the collector actually grouped the waits. + /// </summary> + public static string WaitFamilyKey(string waitType) + { + if (string.IsNullOrEmpty(waitType)) + return waitType ?? string.Empty; + if (waitType.StartsWith("CX", StringComparison.Ordinal)) + return "CXPACKET"; + if (IsGeneralLockWait(waitType)) + return "LCK"; + return waitType; + } + /// <summary> /// Returns true for general lock waits that should be grouped into "LCK". /// Excludes reader locks (S, IS), range locks (RS_*, RIn_*, RX_*), and schema locks. /// </summary> - private static bool IsGeneralLockWait(string waitType) + public static bool IsGeneralLockWait(string waitType) { if (!waitType.StartsWith("LCK_M_", StringComparison.OrdinalIgnoreCase)) return false; diff --git a/PerformanceMonitor.Analysis/FactRemediation.cs b/PerformanceMonitor.Analysis/FactRemediation.cs index 194a03d0c..571c3df0f 100644 --- a/PerformanceMonitor.Analysis/FactRemediation.cs +++ b/PerformanceMonitor.Analysis/FactRemediation.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Text; using System.Text.Json; @@ -1017,6 +1018,257 @@ public static string BuildModifyFileStatement(string database, string logicalFil return $"ALTER DATABASE {QuoteName(database)} MODIFY FILE (NAME = {QuoteName(logicalFileName)}, FILEGROWTH = {growthMb}MB);"; } + // ── Copy-paste command rendering (shared: Darling viewer today; available to Dashboard/Lite) ── + // + // The Darling viewer is advise-only (Postgres read-through, no in-app remediation executor), so a + // runnable copy-paste command IS its remediation surface: for EVERY remediable finding it must + // hand the operator T-SQL they can run themselves. RenderCopyPasteCommand turns a PERSISTED + // RemediationAction back into that runnable T-SQL for all seven shapes — the single source of + // truth so no surface (Darling / Dashboard reader / email / MCP) can drift from another. The three + // always-safe shapes render bare (percent-autogrowth MODIFY FILE, missing-index CREATE, safe + // DB-config ALTER); the four that were previously copy-paste dead ends now render too — force-plan + // (USE + sp_query_store_force_plan, which is database-scoped so each target carries its own USE), + // server-config (sp_configure + RECONFIGURE), RCSI, and clear-plan. The two DESTRUCTIVE shapes + // (RCSI, clear-plan — both IsDestructive) prepend their two-sided FactRiskDisclosure text as a + // /* ... */ header so the copy-paste itself states the risk of changing AND of not changing. + // Reuses BuildModifyFileStatement / BuildSpConfigureStatement / StatementFor / FactRiskDisclosure. + + /// <summary> + /// Renders runnable, copy-paste T-SQL for a persisted <see cref="RemediationAction"/> — one command + /// (or block) per target across all seven remediation shapes — or null when the action carries no + /// renderable target. This is the shared source the Darling viewer's copy-paste affordance delegates + /// to; it operates purely on the persisted typed targets (the ephemeral drill-down is gone on + /// read-back), so it never parses preview prose. The always-safe shapes render bare; the two + /// destructive shapes (RCSI, clear-plan) prepend the two-sided <see cref="FactRiskDisclosure"/> text + /// as a <c>/* ... */</c> comment header. Nothing here executes — it is advisory text the operator runs. + /// </summary> + public static string? RenderCopyPasteCommand(RemediationAction? action) + { + if (action is null) + return null; + + var nl = Environment.NewLine; + + // FILE_AUTOGROWTH_PERCENT — one MODIFY FILE per file (shared builder), one per line. Safe. + if (action.FileGrowthTargets is { Count: > 0 } fileTargets) + { + var sb = new StringBuilder(); + foreach (var t in fileTargets) + { + if (sb.Length > 0) sb.Append(nl); + sb.Append(BuildModifyFileStatement(t.Database, t.LogicalFileName, t.RecommendedGrowthMb)); + } + return sb.Length == 0 ? null : sb.ToString(); + } + + // MISSING_INDEX — the SQL Server-suggested CREATE, verbatim, one per line. Copy-paste only. + if (action.MissingIndexTargets is { Count: > 0 } indexTargets) + { + var sb = new StringBuilder(); + foreach (var t in indexTargets) + { + if (string.IsNullOrWhiteSpace(t.CreateStatement)) continue; + if (sb.Length > 0) sb.Append(nl); + sb.Append(t.CreateStatement); + } + return sb.Length == 0 ? null : sb.ToString(); + } + + // SERVER_CONFIG — one sp_configure + RECONFIGURE per setting (shared builder), incl. the + // advise-only memory settings (the operator wants the sp_configure scaffold regardless). Safe. + if (action.ServerConfigTargets is { Count: > 0 } serverTargets) + { + var blocks = new List<string>(serverTargets.Count); + foreach (var t in serverTargets) + blocks.Add(BuildSpConfigureStatement(t.Setting, t.RecommendedValue)); + return blocks.Count == 0 ? null : string.Join(nl + nl, blocks); + } + + // PLAN_REGRESSION (force-plan) — sp_query_store_force_plan is DATABASE-scoped, so each target + // is a standalone USE + EXEC block (the copy-paste runs without an ambient database). Safe. + if (action.Targets is { Count: > 0 } forceTargets) + { + var blocks = new List<string>(forceTargets.Count); + foreach (var t in forceTargets) + blocks.Add( + $"USE {QuoteName(t.Database)};" + nl + + $"EXEC sys.sp_query_store_force_plan @query_id = {t.QueryId}, @plan_id = {t.PlanId};"); + return blocks.Count == 0 ? null : string.Join(nl + nl, blocks); + } + + // CLEAR_PLAN — DESTRUCTIVE. Two-sided disclosure header + a self-contained resolve-and-free + // script (live-resolve the currently-cached plan handles for the abnormal query hashes, then + // DBCC FREEPROCCACHE each). + if (action.ClearPlanTargets is { Count: > 0 } clearTargets) + return RenderClearPlanScript(action, clearTargets, nl); + + // RCSI reconstructed action (FactKey "RCSI") — DESTRUCTIVE. Carries a ReadCommittedSnapshotOn + // DbConfigTarget (the alert-path / Dashboard-reader shape); render header + ALTER per target. + if (string.Equals(action.FactKey, "RCSI", StringComparison.Ordinal) && + action.DbConfigTargets is { Count: > 0 } reconstructedRcsi) + { + var blocks = new List<string>(reconstructedRcsi.Count); + foreach (var t in reconstructedRcsi) + { + if (t.Setting != DbConfigSetting.ReadCommittedSnapshotOn) continue; + blocks.Add(RenderRcsiBlock(t.Database, action.RcsiFigures, nl)); + } + return blocks.Count == 0 ? null : string.Join(nl + nl, blocks); + } + + // DB_CONFIG — the always-safe settings (bare, no header) and/or the per-database RCSI targets + // carried on the same action (DESTRUCTIVE, each with its own two-sided disclosure header). Both + // can be present; render the safe block first, then one RCSI block per target. + var configBlocks = new List<string>(); + if (action.DbConfigTargets is { Count: > 0 } dbTargets) + { + var safe = new StringBuilder(); + foreach (var t in dbTargets) + { + // RCSI is destructive — it never renders bare here; it rides the header path below. + if (t.Setting == DbConfigSetting.ReadCommittedSnapshotOn) continue; + if (safe.Length > 0) safe.Append(nl); + safe.Append(StatementFor(t.Setting, t.Database)); + } + if (safe.Length > 0) configBlocks.Add(safe.ToString()); + } + if (action.RcsiTargets is { Count: > 0 } rcsiTargets) + { + foreach (var t in rcsiTargets) + configBlocks.Add(RenderRcsiBlock(t.Database, t.Figures, nl)); + } + return configBlocks.Count == 0 ? null : string.Join(nl + nl, configBlocks); + } + + /// <summary> + /// Renders one destructive RCSI enable block: the two-sided <see cref="FactRiskDisclosure"/> comment + /// header (reconstructed as a distinct FactKey "RCSI" action so the shared disclosure renders the REAL + /// carried figures) above the enabling <c>ALTER DATABASE … SET READ_COMMITTED_SNAPSHOT ON;</c>. + /// </summary> + private static string RenderRcsiBlock(string database, RcsiInactionFigures? figures, string nl) + { + var rcsiAction = new RemediationAction( + "RCSI", "set", Array.Empty<ForcePlanTarget>(), + new[] { new DbConfigTarget(database, DbConfigSetting.ReadCommittedSnapshotOn, "OFF") }, + RcsiFigures: figures); + + var sb = new StringBuilder(); + var disclosure = FactRiskDisclosure.GetForAction(rcsiAction, null); + if (disclosure is not null) + sb.Append(RenderRiskDisclosureComment(disclosure, nl)).Append(nl); + sb.Append($"ALTER DATABASE {QuoteName(database)} SET READ_COMMITTED_SNAPSHOT ON;"); + return sb.ToString(); + } + + /// <summary> + /// Renders the destructive clear-cached-plan script: the two-sided disclosure header, a per-hash + /// comment (database + anomaly figures), a review SELECT (the currently-cached plans the free will + /// affect — a query_hash is not unique to one query, so the operator confirms the blast radius + /// first), then a cursor that frees each live-resolved <c>plan_handle</c> via DBCC FREEPROCCACHE. + /// Only query hashes that look like a hex literal (<c>0x…</c>) are inlined (they were validated at + /// build time; re-checked here because this text is runnable SQL); null when none qualify. + /// </summary> + private static string? RenderClearPlanScript(RemediationAction action, IReadOnlyList<ClearPlanTarget> targets, string nl) + { + var hashes = new List<string>(targets.Count); + var lines = new List<string>(targets.Count); + foreach (var t in targets) + { + if (!IsHexLiteral(t.QueryHash)) continue; + hashes.Add(t.QueryHash); + var dbLabel = string.IsNullOrEmpty(t.Database) ? "(resolved live)" : QuoteName(t.Database); + lines.Add(t.AnomalyRatio > 0 + ? $"-- {dbLabel} {t.QueryHash} (~{t.AnomalyRatio.ToString("0.0", CultureInfo.InvariantCulture)}x normal per-exec CPU: " + + $"{t.CurrentCpuPerExecMs.ToString("0.0", CultureInfo.InvariantCulture)} ms vs baseline " + + $"{t.BaselineCpuPerExecMs.ToString("0.0", CultureInfo.InvariantCulture)} ms)" + : $"-- {dbLabel} {t.QueryHash}"); + } + + if (hashes.Count == 0) + return null; + + var inList = string.Join(", ", hashes); + var sb = new StringBuilder(); + + var disclosure = FactRiskDisclosure.GetForAction(action, null); + if (disclosure is not null) + sb.Append(RenderRiskDisclosureComment(disclosure, nl)).Append(nl); + + sb.Append("-- Clear the currently-cached plan(s) for these abnormal-CPU query hashes:").Append(nl); + foreach (var line in lines) + sb.Append(line).Append(nl); + sb.Append("-- STEP 1 (review): run this SELECT first — a query_hash can map to more than one query,").Append(nl); + sb.Append("-- so confirm the databases/queries below are the ones you intend to clear.").Append(nl); + sb.Append("SELECT DISTINCT").Append(nl); + sb.Append(" DB_NAME(st.dbid) AS database_name,").Append(nl); + sb.Append(" qs.plan_handle,").Append(nl); + sb.Append(" qs.execution_count,").Append(nl); + sb.Append(" st.text AS batch_text").Append(nl); + sb.Append("FROM sys.dm_exec_query_stats AS qs").Append(nl); + sb.Append("CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st").Append(nl); + sb.Append($"WHERE qs.query_hash IN ({inList});").Append(nl); + sb.Append(nl); + sb.Append("-- STEP 2 (apply): free every currently-cached plan for those hashes. There is NO un-clear —").Append(nl); + sb.Append("-- the prior plans are gone and the recompile is not guaranteed to be better.").Append(nl); + sb.Append("DECLARE @plan_handle VARBINARY(64);").Append(nl); + sb.Append("DECLARE plan_cursor CURSOR LOCAL FAST_FORWARD FOR").Append(nl); + sb.Append(" SELECT DISTINCT qs.plan_handle").Append(nl); + sb.Append(" FROM sys.dm_exec_query_stats AS qs").Append(nl); + sb.Append($" WHERE qs.query_hash IN ({inList});").Append(nl); + sb.Append("OPEN plan_cursor;").Append(nl); + sb.Append("FETCH NEXT FROM plan_cursor INTO @plan_handle;").Append(nl); + sb.Append("WHILE @@FETCH_STATUS = 0").Append(nl); + sb.Append("BEGIN").Append(nl); + sb.Append(" DBCC FREEPROCCACHE(@plan_handle);").Append(nl); + sb.Append(" FETCH NEXT FROM plan_cursor INTO @plan_handle;").Append(nl); + sb.Append("END;").Append(nl); + sb.Append("CLOSE plan_cursor;").Append(nl); + sb.Append("DEALLOCATE plan_cursor;"); + return sb.ToString(); + } + + /// <summary> + /// Renders a two-sided <see cref="RiskDisclosure"/> as a <c>/* … */</c> T-SQL comment header, so a + /// destructive copy-paste command states the risk of changing AND of not changing inline. The prose + /// is fixed/reviewed (only validated identifiers + numeric figures are substituted); any embedded + /// <c>*/</c> is defused so a pathological identifier cannot close the comment early. + /// </summary> + private static string RenderRiskDisclosureComment(RiskDisclosure disclosure, string nl) + { + var sb = new StringBuilder(); + sb.Append("/*").Append(nl); + sb.Append(" * REVIEW BEFORE RUNNING - this is a destructive change with trade-offs BOTH ways.").Append(nl); + sb.Append(" *").Append(nl); + sb.Append(" * Risks of MAKING this change:").Append(nl); + foreach (var r in disclosure.RisksOfChanging) + sb.Append(" * - ").Append(SanitizeForComment(r.Text)).Append(nl); + sb.Append(" *").Append(nl); + sb.Append(" * Risks of NOT making this change:").Append(nl); + foreach (var r in disclosure.RisksOfNotChanging) + sb.Append(" * - ").Append(SanitizeForComment(r.Text)).Append(nl); + sb.Append(" */"); + return sb.ToString(); + } + + /// <summary>Defuses an embedded C-style comment terminator so disclosure prose cannot close the header early.</summary> + private static string SanitizeForComment(string text) => + string.IsNullOrEmpty(text) ? string.Empty : text.Replace("*/", "* /"); + + /// <summary>True when the value is a non-empty hex literal (<c>0x</c> + at least one hex digit) safe to inline into SQL.</summary> + private static bool IsHexLiteral(string? value) + { + if (value is null || value.Length <= 2 || + !(value[0] == '0' && (value[1] == 'x' || value[1] == 'X'))) + return false; + for (var i = 2; i < value.Length; i++) + { + var c = value[i]; + var isHex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + if (!isHex) return false; + } + return true; + } + /// <summary> /// QUOTENAME-equivalent: wrap an identifier in square brackets and double /// any embedded close-bracket. The database name comes from diff --git a/PerformanceMonitor.Analysis/FactScorer.cs b/PerformanceMonitor.Analysis/FactScorer.cs index 59ff0dda2..8ca8779c5 100644 --- a/PerformanceMonitor.Analysis/FactScorer.cs +++ b/PerformanceMonitor.Analysis/FactScorer.cs @@ -77,6 +77,41 @@ public void ScoreAll(List<Fact> facts) fact.Severity = Math.Min(fact.BaseSeverity * (1.0 + totalBoost), 2.0); } + + // Layer 3: tuning-class severity cap. Parallelism (CXPACKET) saturates its base to 1.0 at 25% + // of period and then amplifiers multiply it past 1.5 into the CRITICAL band on stacking alone — + // but parallelism is a TUNING opportunity, not an outage. Cap the FINAL severity of the + // tuning-class keys at the WARNING ceiling so they can't reach CRITICAL by amplifier count. + // ESCAPE HATCH: release the cap entirely when an impact-bearing peer co-fired — THREADPOOL + // (thread exhaustion), SOS_SCHEDULER_YIELD (CPU starvation), or RESOURCE_SEMAPHORE (grant + // starvation) — because then the parallelism genuinely IS driving an outage and CRITICAL is + // earned. "Co-fired" must mean SIGNIFICANT, not merely present. Only THREADPOOL's base is + // self-gating: ScoreWaitFact requires >= 1hr total AND >= 1s avg before THREADPOOL scores at + // all, so BaseSeverity > 0 there already means real exhaustion — keep the presence check. + // SOS_SCHEDULER_YIELD (0.75, null) and RESOURCE_SEMAPHORE (0.01, null) have NO minimum guard, + // so their BaseSeverity > 0 fires on ANY trace of the wait; and SOS physically co-occurs with + // high CXPACKET (parallel workers yield -> SOS) and is emitted for any delta_wait_time_ms > 0, + // so a trivial SOS (e.g. 500ms over an hour) would release the cap on exactly the busy servers + // the cap targets — re-admitting the CXPACKET=CRITICAL noise the cap exists to kill. Gate + // SOS/RS on SIGNIFICANCE (fraction of period) via the same HasSignificantWait helper the + // amplifiers use, not on mere presence: SOS at 0.25 (matches the CXPACKET SOS amplifier bar); + // RS at 0.10 (RESOURCE_SEMAPHORE has no HasSignificantWait amplifier bar, so pick a bar here — + // 0.10 of period is meaningful grant starvation). Caps numeric Severity only — SeverityBand is + // derived from it downstream, so a capped fact stays in WARNING without a separate band edit + // and Lite parity is preserved. + var impactPeerCoFired = + (factsByKey.TryGetValue("THREADPOOL", out var tpPeer) && tpPeer.BaseSeverity > 0) + || HasSignificantWait(factsByKey, "SOS_SCHEDULER_YIELD", 0.25) + || HasSignificantWait(factsByKey, "RESOURCE_SEMAPHORE", 0.10); + + if (!impactPeerCoFired) + { + foreach (var fact in facts) + { + if (IsTuningClassKey(fact.Key)) + fact.Severity = Math.Min(fact.Severity, TuningClassSeverityCeiling); + } + } } /// <summary> @@ -97,6 +132,20 @@ private static double ScoreWaitFact(Fact fact) if (waitTimeMs < 3_600_000 || avgMs < 1_000) return 0.0; } + // PAGELATCH_UP (tempdb allocation contention) is scored on ABSOLUTE wait_time_ms, not + // fraction-of-period, because its source — the Dashboard's report.tempdb_contention_analysis + // contention_level CASE — trips on an absolute PAGELATCH_UP total (install/47:2515: + // pagelatch_up_ms > 10000 -> "MEDIUM - PAGELATCH_UP contention"). PAGELATCH_UP is the canonical + // PFS/GAM/SGAM allocation-page latch (the fix is add tempdb data files / TF 1118), and the source + // reads the SAME server-wide wait_stats this fact is built from, so scoring the wait total is a + // faithful port. Flat 0.5 (MEDIUM) at the source's single PAGELATCH_UP tier — there is no higher + // band for it there; the view's CRITICAL "allocation contention" comes from a tempdb-scoped + // dm_os_waiting_tasks flag (allocation_contention_warning, install/47:2503) that is NOT carried in + // this fact. Absolute-ms is consistent with the THREADPOOL gate just above (the analysis window is + // hours-scale; the source's window is 1 hour). + if (fact.Key == "PAGELATCH_UP") + return fact.Metadata.GetValueOrDefault("wait_time_ms") > 10_000 ? 0.5 : 0.0; + var thresholds = GetWaitThresholds(fact.Key); if (thresholds == null) return 0.0; @@ -150,10 +199,36 @@ private static double ScoreCpuFact(Fact fact) // CPU spike: value is max CPU %. Concerning at 80%, critical at 95%. // Only emitted when max is significantly above average (bursty). "CPU_SPIKE" => ApplyThresholdFormula(fact.Value, 80, 95), + // Runnable-task queue depth — a STANDALONE scheduler-pressure signal that roots the collected + // cpu_scheduler_stats snapshot directly. Distinct from (and additive to) the #1494 THREADPOOL + // runnable-queue amplifier, which still fires independently off the same RUNNABLE_TASKS fact. + "RUNNABLE_TASKS" => ScoreRunnableTasks(fact), _ => 0.0 }; } + /// <summary> + /// Scores the runnable-task-queue pressure signal (RUNNABLE_TASKS context fact; Value = + /// total_runnable_tasks_count from the latest cpu_scheduler_stats snapshot). Tiers mirror the + /// Dashboard's report.cpu_scheduler_pressure pressure_level CASE + /// (install/47_create_reporting_views.sql lines 1839-1844): > 50 CRITICAL, > 20 HIGH, > 10 MEDIUM, + /// else the collector's own runnable_tasks_warning flag (SUM(runnable_tasks_count) >= cpu_count) as a + /// small-box HIGH fallback the absolute > 10 bar misses. Base maxes at 1.0 (WARNING band) exactly as + /// every other base fact does — the CRITICAL band (>= 1.5) is reached only with corroboration, which + /// is precisely the runnable-queue -> THREADPOOL amplifier path (#1494). A bare runnable queue with no + /// thread/CPU corroboration is a strong WARNING, not an outage. + /// </summary> + private static double ScoreRunnableTasks(Fact fact) + { + var total = fact.Value; // total_runnable_tasks_count (latest snapshot) + if (total > 50) return 1.0; // CRITICAL - High runnable task queue (install/47:1839) + if (total > 20) return 0.75; // HIGH - Moderate runnable task queue (install/47:1840) + if (total > 10) return 0.5; // MEDIUM - Some runnable tasks queued (install/47:1841) + // Small-box per-scheduler pressure below the absolute bar (install/47:1844: runnable_tasks_warning). + if (fact.Metadata.GetValueOrDefault("runnable_tasks_warning") >= 1.0) return 0.75; + return 0.0; + } + /// <summary> /// Scores I/O latency facts. Value is average latency in ms. /// </summary> @@ -176,14 +251,42 @@ private static double ScoreTempDbFact(Fact fact) { return fact.Key switch { - // TempDB usage: concerning at 75%, critical at 90% - "TEMPDB_USAGE" => ApplyThresholdFormula(fact.Value, 0.75, 0.90), + // TempDB usage scores the WORSE of two INDEPENDENT pressures: space-fraction fill (concerning + // 75%, critical 90%) and absolute version-store size (ScoreTempDbVersionStore) — a multi-GB + // version store is a problem even when total tempdb space is nowhere near full, and the + // fraction arm is blind to it. + "TEMPDB_USAGE" => Math.Max( + ApplyThresholdFormula(fact.Value, 0.75, 0.90), + ScoreTempDbVersionStore(fact)), _ => 0.0 }; } /// <summary> - /// Scores memory grant facts. Only MEMORY_GRANT_PENDING (from resource semaphore) for now. + /// Scores tempdb VERSION-STORE pressure by ABSOLUTE reserved size (max_version_store_mb, carried in + /// the TEMPDB_USAGE fact metadata by every collector), independent of the space-fraction the main arm + /// scores. The version store grows with long-running RCSI/snapshot transactions (and heavy triggers) + /// that pin old row versions, so it can reach gigabytes while total tempdb space is barely used — + /// space-fraction alone misses it. Tiers mirror the Dashboard's report.tempdb_pressure pressure_level + /// CASE (install/47_create_reporting_views.sql lines 1431-1433): > 5000 MB CRITICAL, > 2000 MB HIGH, + /// > 1000 MB MEDIUM. Base maxes at 1.0 (WARNING) like every base fact — the > 5000 "CRITICAL" tier + /// caps at 1.0 here; the CRITICAL band is earned only via corroboration. tempdb_contention_analysis + /// corroborates the > 1 GB bar (version_store_high_warning fires at 1 GB — install/47:2504, + /// install/34:146). Absent metadata (older facts) scores 0, preserving prior behavior. + /// </summary> + private static double ScoreTempDbVersionStore(Fact fact) + { + var versionStoreMb = fact.Metadata.GetValueOrDefault("max_version_store_mb"); + if (versionStoreMb > 5000) return 1.0; // CRITICAL - Version store > 5GB (install/47:1431) + if (versionStoreMb > 2000) return 0.75; // HIGH - Version store > 2GB (install/47:1432) + if (versionStoreMb > 1000) return 0.5; // MEDIUM - Version store > 1GB (install/47:1433) + return 0.0; + } + + /// <summary> + /// Scores memory facts: grant waiters (MEMORY_GRANT_PENDING), security-cache growth (MEMORY_CLERKS), + /// plan-cache single-use bloat (PLAN_CACHE_BLOAT), and ring-buffer physical-memory-pressure + /// notifications (MEMORY_PRESSURE_EVENTS). /// </summary> private static double ScoreMemoryFact(Fact fact) { @@ -191,10 +294,79 @@ private static double ScoreMemoryFact(Fact fact) { // Grant waiters: concerning at 1, critical at 5 "MEMORY_GRANT_PENDING" => ApplyThresholdFormula(fact.Value, 1, 5), + // Security cache (TokenAndPermUserStore) growth — WARNING at >= 1 GB. See ScoreSecurityCache. + "MEMORY_CLERKS" => ScoreSecurityCache(fact), + // Plan-cache single-use bloat — % single-use plans, size-guarded. See ScorePlanCacheBloat. + "PLAN_CACHE_BLOAT" => ScorePlanCacheBloat(fact), + // Ring-buffer physical-memory-pressure notifications — max indicator band. See + // ScoreMemoryPressureEvents. + "MEMORY_PRESSURE_EVENTS" => ScoreMemoryPressureEvents(fact), _ => 0.0 }; } + /// <summary> + /// Scores plan-cache single-use bloat off the PLAN_CACHE_BLOAT fact (Value = single_use_percent = + /// single_use_plans * 100 / total_plans over the LATEST plan_cache_stats snapshot). Tiers mirror the + /// Dashboard's report.plan_cache_bloat bloat_level CASE + /// (install/47_create_reporting_views.sql lines 1485-1487): > 50 CRITICAL, > 30 HIGH, > 20 + /// MEDIUM, else NORMAL. Base maxes at 1.0 (WARNING) like every base fact — the > 50 "CRITICAL" + /// tier caps at 1.0 here; the CRITICAL band is earned only via corroboration. + /// + /// <para>NOISE-CONTROL GUARD (not in the Dashboard's raw report view, appropriate for a SCORED + /// recommendation): only score when the single-use footprint is materially large + /// (single_use_size_mb >= 100). A tiny or idle cache can show a high single-use % on a handful of + /// MB — that is not memory bloat worth a card, so it stays context-only (score 0) below the size + /// floor. The percentage still rides in Value for the AI surface either way.</para> + /// </summary> + private static double ScorePlanCacheBloat(Fact fact) + { + // Real memory bloat only — a high % on a trivially small single-use footprint is noise. + if (fact.Metadata.GetValueOrDefault("single_use_size_mb") < 100.0) return 0.0; + + var singleUsePercent = fact.Value; // single_use_plans * 100 / total_plans (latest snapshot) + if (singleUsePercent > 50) return 1.0; // CRITICAL - single-use plans > 50% (install/47:1485) + if (singleUsePercent > 30) return 0.75; // HIGH - single-use plans > 30% (install/47:1486) + if (singleUsePercent > 20) return 0.5; // MEDIUM - single-use plans > 20% (install/47:1487) + return 0.0; + } + + /// <summary> + /// Scores ring-buffer physical-memory-pressure notifications off the MEMORY_PRESSURE_EVENTS fact + /// (Value = the max of memory_indicators_process / memory_indicators_system over the analysis + /// window; the collector emits it only when a genuine MEDIUM+ indicator is present). Bands mirror the + /// Dashboard's report.memory_pressure_events severity CASE + /// (install/47_create_reporting_views.sql lines 229-236), which keys severity purely off the + /// indicators: process/system >= 3 → HIGH, >= 2 → MEDIUM, else LOW. A real + /// RESOURCE_MEMPHYSICAL_LOW is a genuine memory-pressure event, so HIGH earns the WARNING band (0.9); + /// MEDIUM is a softer 0.5. These are incident-ish facts (a real event, not a standing config), so + /// 0.5+ roots via the InferenceEngine's incident threshold — no ConfigAdvisoryRootKey. The LOW floor + /// scores 0 as a defensive backstop (the collector already gates it out). + /// </summary> + private static double ScoreMemoryPressureEvents(Fact fact) + { + var maxIndicator = fact.Value; // max(process, system) memory-pressure indicator in the window + if (maxIndicator >= 3) return 0.9; // HIGH - indicator >= 3 (install/47:230-231) + if (maxIndicator >= 2) return 0.5; // MEDIUM - indicator >= 2 (install/47:232-233) + return 0.0; // LOW - not a scored concern (install/47:234) + } + + /// <summary> + /// Scores TokenAndPermUserStore (security cache) growth off the otherwise context-only MEMORY_CLERKS + /// fact. That fact carries each top-clerk's size in MB keyed by its clerk_type (MemoryClerksCollector + /// stores clerk_type = sys.dm_os_memory_clerks.type), so the security cache is the USERSTORE_TOKENPERM + /// entry. The Dashboard fires a single WARNING at >= 1 GB with no size escalation + /// (install/50_configuration_issues_analyzer.sql line 562 severity=WARNING, line 583 threshold + /// pages_kb / 1024 / 1024 >= 1.0), so this is a flat WARNING-band base (0.9). Absent when the clerk is + /// not among the top-10 collected, which for a >= 1 GB clerk is effectively never. Non-security clerk + /// sets (buffer pool, etc.) score 0, preserving MEMORY_CLERKS as context-only for those. + /// </summary> + private static double ScoreSecurityCache(Fact fact) + { + var securityCacheMb = fact.Metadata.GetValueOrDefault("USERSTORE_TOKENPERM"); + return securityCacheMb >= 1024.0 ? 0.9 : 0.0; // >= 1 GB -> flat WARNING (install/50:562,583) + } + /// <summary> /// Scores query-level aggregate facts. /// </summary> @@ -260,6 +432,20 @@ private static double ScoreConfigFact(Fact fact) case "CONFIG_MIN_MAX_MEMORY_NARROW": return 0.4; + // Priority boost enabled (value_in_use == 1) — a Dashboard WARNING (install/50_configuration_issues_analyzer.sql + // line 368: "Priority boost is enabled ... not recommended"): it hands SQL Server threads an + // above-normal Windows scheduling priority, starving OS-critical threads. It is rare and + // clearly-wrong (not a routine tuning choice like MAXDOP/CTFP), so it scores the WARNING band + // (0.9) — surfacing prominently when present — rather than the low 0.4 config-advisory base. + case "CONFIG_PRIORITY_BOOST": + return fact.Value == 1 ? 0.9 : 0.0; + + // Lightweight pooling / fiber mode enabled (value_in_use == 1) — a Dashboard WARNING + // (install/50:401: "Lightweight pooling (fiber mode) is enabled ... issues with OLEDB and other + // components"). Same rationale: rare, clearly-wrong, WARNING band (0.9). + case "CONFIG_LIGHTWEIGHT_POOLING": + return fact.Value == 1 ? 0.9 : 0.0; + // WS5 server-health advisories (advise-only — no Apply). Each carries the bad/good // signal in Value so it scores its 0.4 advisory base only when bad and 0 otherwise; // the noise-control gating (Express / small-RAM for LPIM, dumps>0, IFI-known) lives in @@ -310,6 +496,18 @@ private static double ScoreDatabaseConfigFact(Fact fact) if (rcsiOff > 0) score = Math.Max(score, 0.3); + // Query Store disabled on a user database — INFO advisory (install/50_configuration_issues_analyzer.sql + // line 83 severity=INFO). Detected purely from the aggregate counts every collector already emits: + // query_store_on_count (user DBs with QS on; system DBs are excluded from both counts) < database_count + // (user DB total) means at least one user database has Query Store off. Low 0.3 base — DB_CONFIG is a + // ConfigAdvisoryRootKey so it roots as a standing INFO advisory at any positive severity, and 0.3 keeps + // it in the INFO band (< 0.75) matching the Dashboard. Requires BOTH counts present so a fact carrying + // partial metadata never trips it. + if (fact.Metadata.TryGetValue("database_count", out var dbCount) && dbCount > 0 + && fact.Metadata.TryGetValue("query_store_on_count", out var queryStoreOn) + && queryStoreOn < dbCount) + score = Math.Max(score, 0.3); + return score; } @@ -375,6 +573,33 @@ private static double ScoreBadActorFact(Fact fact) return tierBase * impact; } + // Wait-profile severity ramp (see the ANOMALY_WAIT_PROFILE arm). Floor matches the detectors' + // DefaultRatioThreshold; these are HONEST per-second-scale starting values — CALIBRATE ON SQL2025/HAMMERDB. + private const double WaitProfileRatioFloor = 4.0; + private const double WaitProfileRatioSpan = 8.0; + + // Bounded-metric low-quality fallback ramp (see the z-score anomaly arm). When the quality gate fires + // on a thin baseline (baseline_low_quality=1) the stored deviation_sigma is the real (small) z that the + // 2σ gate would zero out — so grade off the absolute exceedance (peak ÷ the absolute-fallback bar, which + // is >= 1.0 on a fire) instead: floor 0.5 AT the bar (clears InferenceEngine's 0.5 entry-point), ramping + // to 1.0 at 2× the bar. Sensible default — CALIBRATE ON SQL2025/HAMMERDB. + private const double LowQualityFallbackSpan = 1.0; + + // Layer-3 tuning-class severity ceiling (see ScoreAll). Parallelism/anomaly signals describe a tuning + // opportunity, not an outage — their FINAL severity is capped here (bands are >= 1.5 CRITICAL) unless an + // impact peer co-fired. 1.49 keeps a capped fact in the WARNING band without touching SeverityBand. + private const double TuningClassSeverityCeiling = 1.49; + + /// <summary> + /// Tuning-class keys whose FINAL severity is capped at the WARNING ceiling (Layer 3) unless an + /// impact peer co-fired: parallelism (CXPACKET/CXCONSUMER), excessive-DOP queries, and every + /// anomaly fact. Today only CXPACKET can exceed the ceiling on amplifiers (ANOMALY_* and + /// QUERY_HIGH_DOP already max at 1.0) — the rest is forward-safety as those ramps evolve. + /// </summary> + private static bool IsTuningClassKey(string key) => + key is "CXPACKET" or "CXCONSUMER" or "QUERY_HIGH_DOP" + || key.StartsWith("ANOMALY_", StringComparison.OrdinalIgnoreCase); + /// <summary> /// Scores anomaly facts based on deviation from baseline. /// At 2σ → 0.5, at 4σ → 1.0. Higher deviations are more severe. @@ -393,14 +618,47 @@ private static double ScoreAnomalyFact(Fact fact) // Deviation-based scoring: 2σ = 0.5, 4σ = 1.0 var deviation = fact.Metadata.GetValueOrDefault("deviation_sigma"); var confidence = fact.Metadata.GetValueOrDefault("confidence", 1.0); + + // Thin/untrustworthy baseline: the detector's quality gate fired on the absolute-fallback bar, + // NOT the z-score, so deviation_sigma is the real (small) z. Applying the 2σ gate below would + // zero it and InferenceEngine would silently drop the finding (Severity must clear 0.5 to root) + // — defeating the "fire on the absolute bar, not silence" guarantee (e.g. memory 96% on a young + // store). Grade off the absolute exceedance instead: the fire already cleared the bar so + // exceedance >= 1.0 → floor 0.5, ramping to 1.0 at 2× the bar. + if (fact.Metadata.GetValueOrDefault("baseline_low_quality") >= 1.0) + { + var over = Math.Max(0.0, fact.Metadata.GetValueOrDefault("fallback_exceedance") - 1.0); + // Floor AFTER the confidence multiply, not before it. confidence is a hardcoded 1.0 + // everywhere today so the ramp already lands >= 0.5 at the bar, but if confidence ever + // drops sub-1.0 the un-floored product would fall below InferenceEngine's 0.5 root + // entry-point and silently drop the finding — re-breaking the "fire on the absolute bar, + // not silence" guarantee. Math.Max(0.5, ...) keeps a fired low-quality anomaly rootable + // regardless of confidence. Behaviorally identical at confidence == 1.0. + return Math.Max(0.5, (0.5 + 0.5 * Math.Min(over / LowQualityFallbackSpan, 1.0)) * confidence); + } + if (deviation < 2.0) return 0.0; var base_score = 0.5 + 0.5 * Math.Min((deviation - 2.0) / 2.0, 1.0); return base_score * confidence; } + // Wait-profile (the one ANOMALY_WAIT_PROFILE fact) — must precede the generic ANOMALY_WAIT_ + // branch below, which it also prefix-matches. The ratio is now the HONEST per-second scale + // (peak window all-types ms/sec ÷ baseline mean), so the ramp is far smaller than the old + // 5×/20× that was calibrated to a ~240×-inflated per-hour-vs-per-interval input: 4× → 0.5, + // saturating to 1.0 at 12×. Starting values matching the detectors' DefaultRatioThreshold; + // CALIBRATE ON THE SQL2025/HAMMERDB BOX. + if (fact.Key.StartsWith("ANOMALY_WAIT_PROFILE", StringComparison.OrdinalIgnoreCase)) + { + var ratio = fact.Metadata.GetValueOrDefault("ratio"); + if (ratio < WaitProfileRatioFloor) return 0.0; + return 0.5 + 0.5 * Math.Min((ratio - WaitProfileRatioFloor) / WaitProfileRatioSpan, 1.0); + } + if (fact.Key.StartsWith("ANOMALY_WAIT_", StringComparison.OrdinalIgnoreCase)) { - // Ratio-based scoring: 5x = 0.5, 20x = 1.0 + // Legacy per-type wait anomaly (detectors now emit ANOMALY_WAIT_PROFILE instead; kept for + // any pre-upgrade persisted facts). Ratio-based scoring: 5x = 0.5, 20x = 1.0. var ratio = fact.Metadata.GetValueOrDefault("ratio"); if (ratio < 5) return 0.0; return 0.5 + 0.5 * Math.Min((ratio - 5.0) / 15.0, 1.0); @@ -621,18 +879,30 @@ private static List<AmplifierDefinition> CxPacketAmplifiers() => ]; /// <summary> - /// THREADPOOL: thread exhaustion confirmed by parallelism pressure. - /// Blocking and config amplifiers added later. + /// THREADPOOL: thread exhaustion — the impact-bearing escalation path for a parallelism → + /// worker-exhaustion meltdown. The CXPACKET amplifier is deliberately heavy (+0.5): CXPACKET + /// itself is capped at the WARNING ceiling (see the Layer-3 cap in ScoreAll), so a genuine + /// meltdown must reach CRITICAL through THREADPOOL (an impact key, never capped), not through + /// parallelism alone. The runnable-queue amplifier corroborates real scheduler CPU pressure from + /// the RUNNABLE_TASKS context fact's runnable_tasks_warning flag (the collector's own + /// SUM(runnable_tasks_count) >= cpu_count heuristic, read from cpu_scheduler_stats). /// </summary> private static List<AmplifierDefinition> ThreadpoolAmplifiers() => [ new() { Description = "CXPACKET significant — parallel queries consuming thread pool", - Boost = 0.2, + Boost = 0.5, Predicate = facts => HasSignificantWait(facts, "CXPACKET", 0.10) }, new() + { + Description = "Runnable-task queue backed up — schedulers under real CPU pressure", + Boost = 0.5, + Predicate = facts => facts.TryGetValue("RUNNABLE_TASKS", out var rt) + && rt.Metadata.GetValueOrDefault("runnable_tasks_warning") >= 1.0 + }, + new() { Description = "Lock contention present — blocked queries holding worker threads", Boost = 0.3, diff --git a/PerformanceMonitor.Analysis/InferenceEngine.cs b/PerformanceMonitor.Analysis/InferenceEngine.cs index 9d3aa0f4d..f25105990 100644 --- a/PerformanceMonitor.Analysis/InferenceEngine.cs +++ b/PerformanceMonitor.Analysis/InferenceEngine.cs @@ -45,6 +45,12 @@ public class InferenceEngine "CONFIG_CTFP", "CONFIG_MAX_MEMORY_MB", "CONFIG_MIN_MAX_MEMORY_NARROW", + // batch-2b: priority boost / lightweight pooling enabled — Dashboard WARNINGs. Score the + // WARNING band (0.9, FactScorer) and root a standalone card here so a rare, clearly-wrong + // scheduling setting surfaces on a quiet, healthy server (redundant with the 0.5 incident + // threshold at 0.9, but keeps every CONFIG_* fact in one rooting set). + "CONFIG_PRIORITY_BOOST", + "CONFIG_LIGHTWEIGHT_POOLING", // WS5: server-health advisories (advise-only). Each scores its 0.4 advisory base only when // bad (FactScorer) and roots its own standalone card here, bypassing the 0.5 incident // threshold — a standing server-health gap should surface on a quiet, healthy server. @@ -55,6 +61,12 @@ public class InferenceEngine // missing indexes and actionable plan warnings each root their own standalone advisory card. "MISSING_INDEX", "PLAN_WARNING", + // Tier-2: plan-cache single-use bloat is a STANDING cache-composition state (not a momentary + // incident), so it roots its own standalone advisory card at any positive severity — it should + // surface on a quiet, healthy server the same way a standing misconfig does. The FactScorer only + // scores it at >= 0.5 (MEDIUM+) behind a size guard, so this is belt-and-suspenders with the 0.5 + // incident threshold, and keeps the standing-state key in the same rooting set as the others. + "PLAN_CACHE_BLOAT", }; private readonly RelationshipGraph _graph; @@ -276,7 +288,8 @@ public List<List<AnalysisStory>> ClusterIntoIncidents(IReadOnlyList<AnalysisStor for (var i = 0; i < incidentStories.Count; i++) foreach (var key in incidentStories[i].Path) foreach (var edge in _graph.GetActiveEdges(NormalizeKey(key), factsByKey)) - if (owner.TryGetValue(NormalizeKey(edge.Destination), out var j) && j != i) + if (owner.TryGetValue(NormalizeKey(edge.Destination), out var j) && j != i + && CanUnionAcrossDatabase(incidentStories[i], incidentStories[j])) Union(i, j); var components = new Dictionary<int, List<AnalysisStory>>(); @@ -290,6 +303,22 @@ public List<List<AnalysisStory>> ClusterIntoIncidents(IReadOnlyList<AnalysisStor return components.Values.ToList(); } + /// <summary> + /// Guards the incident union against merging two DB-scoped findings from DIFFERENT databases + /// (correlate-and-focus DB-awareness — vetted option i). A server-scoped story (no DatabaseName) + /// bridges freely, so a server-wide symptom (CPU, waits) still correlates with a db-scoped cause; + /// only two stories that EACH name a database, and name DIFFERENT ones, are held apart. This is + /// the structural fix that stops a db1 finding and a db2 finding from being fingerprinted into one + /// cross-database incident (the anomaly-fold reconciler is DB-aware for the same reason). It does + /// not over-fragment same-database incidents: same-DB and server-scoped pairs still union. + /// </summary> + private static bool CanUnionAcrossDatabase(AnalysisStory a, AnalysisStory b) + { + if (string.IsNullOrEmpty(a.DatabaseName) || string.IsNullOrEmpty(b.DatabaseName)) + return true; + return string.Equals(a.DatabaseName, b.DatabaseName, StringComparison.OrdinalIgnoreCase); + } + /// <summary> /// Maps a relabeled THREADPOOL story root (THREADPOOL_PARALLEL / _BLOCKING / _MIXED) back to the /// base THREADPOOL key the relationship graph and fact set use, so clustering sees its edges. diff --git a/PerformanceMonitor.Analysis/PerformanceMonitor.Analysis.csproj b/PerformanceMonitor.Analysis/PerformanceMonitor.Analysis.csproj index 9943025c9..7fb12f1f0 100644 --- a/PerformanceMonitor.Analysis/PerformanceMonitor.Analysis.csproj +++ b/PerformanceMonitor.Analysis/PerformanceMonitor.Analysis.csproj @@ -17,5 +17,13 @@ <InternalsVisibleTo Include="PerformanceMonitorDashboard" /> <InternalsVisibleTo Include="Lite.Tests" /> <InternalsVisibleTo Include="Dashboard.Tests" /> + <!-- Darling's PgFactCollector reconstructs blocking chains through the same internal + BlockingPairRow/BlockingChainReconstructor/BlockingPairRowMerge seam both apps use. --> + <InternalsVisibleTo Include="PerformanceMonitor.Darling.Analysis" /> + <!-- The Darling viewer's Blocking tab (W1e) reconstructs the block-chain the same way Lite/Dashboard + do: it fetches internal BlockingPairRow rows and calls BlockingChainReconstructor, then bridges + the internal reconstruction to the shared Common BlockingChainModel through its own copied + BlockingChainViewerProjection (the same per-app pattern Lite and Dashboard use). --> + <InternalsVisibleTo Include="PerformanceMonitor.Darling.Viewer" /> </ItemGroup> </Project> diff --git a/PerformanceMonitor.Collectors/AgentStatusCollector.cs b/PerformanceMonitor.Collectors/AgentStatusCollector.cs new file mode 100644 index 000000000..6a232f07d --- /dev/null +++ b/PerformanceMonitor.Collectors/AgentStatusCollector.cs @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// SQL Agent service status snapshot (issue #1433 Phase 2) — the current-state facts retained job +/// history can't hold: whether the Agent service is Running/Stopped (<c>sys.dm_server_services</c>), its +/// startup type, and the next scheduled run across all enabled jobs (<c>msdb.dbo.sysjobschedules</c>). +/// One row per collection cycle per server; the Job History tab reads the latest row for its header +/// ("Agent: Running · next run …"), and Darling's self-alert path raises an "Agent Not Running" alert off +/// the same latest row (an unwatched stopped Agent on a headless 24/7 service is exactly the silent +/// failure nobody notices). +/// +/// <para><b>Snapshot, not incremental:</b> like <c>server_properties</c> / <c>running_jobs</c> there is no +/// watermark — each cycle overwrites the picture (readers take the newest row). <c>next_scheduled_run</c> +/// is decoded from <c>sysjobschedules.next_run_date/next_run_time</c> via <c>msdb.dbo.agent_datetime</c> +/// (msdb-readable, unlike the <c>syssessions</c> path RunningJobsCollector uses, so it does not add the +/// running-jobs AWS RDS gate); the MIN over enabled jobs' future runs is the fleet-relevant "next thing +/// that will happen".</para> +/// +/// <para><b>Azure SQL Database (edition 5), AWS RDS, and no-msdb-access logins: do NOT apply</b> — Azure SQL DB +/// has no SQL Agent and no <c>sys.dm_server_services</c>; AWS RDS is a managed service that does not expose the +/// OS/service-control state <c>sys.dm_server_services</c> reads; a login without msdb access can't read the +/// <c>sysjobschedules</c> next-run decode. <see cref="AppliesTo"/> = +/// <c>!IsAzureSqlDb && !IsAwsRds && HasMsdbAccess</c>, the single authoritative gate both hosts +/// consult, mirroring <see cref="RunningJobsCollector"/>. On-prem and Managed Instance collect normally.</para> +/// </summary> +public sealed class AgentStatusCollector : CollectorDefinitionBase<AgentStatusCollector.Row> +{ + public static AgentStatusCollector Instance { get; } = new(); + + private AgentStatusCollector() + { + } + + public readonly record struct Row( + bool AgentRunning, + string? AgentStatusDesc, + string? AgentStartupDesc, + DateTime? NextScheduledRun); + + /* Agent service status from sys.dm_server_services (the service row whose name starts "SQL Server + Agent"), plus the next scheduled run across all enabled jobs decoded from sysjobschedules via + msdb.dbo.agent_datetime. Scalar subqueries with no outer FROM => exactly one row every cycle, even + when the Agent service row is absent (agent_running 0, descriptions NULL). READ UNCOMMITTED like every + collector; OPTION(RECOMPILE) for a trivially cheap plan. */ + private const string QueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + agent_running = + CASE + WHEN EXISTS + ( + SELECT 1 + FROM sys.dm_server_services + WHERE servicename LIKE N'SQL Server Agent%' + AND status_desc = N'Running' + ) + THEN CONVERT(bit, 1) + ELSE CONVERT(bit, 0) + END, + agent_status_desc = + ( + SELECT TOP (1) dss.status_desc + FROM sys.dm_server_services AS dss + WHERE dss.servicename LIKE N'SQL Server Agent%' + ORDER BY dss.servicename + ), + agent_startup_desc = + ( + SELECT TOP (1) dss.startup_type_desc + FROM sys.dm_server_services AS dss + WHERE dss.servicename LIKE N'SQL Server Agent%' + ORDER BY dss.servicename + ), + next_scheduled_run = + ( + SELECT MIN(msdb.dbo.agent_datetime(sjs.next_run_date, sjs.next_run_time)) + FROM msdb.dbo.sysjobschedules AS sjs + JOIN msdb.dbo.sysjobs AS j + ON j.job_id = sjs.job_id + WHERE j.enabled = 1 + AND sjs.next_run_date > 0 + ) +OPTION(RECOMPILE);"; + + public override string Name => "agent_status"; + + public override string TargetTable => "agent_status"; + + /// <summary> + /// Collects on SQL Server and Azure SQL Managed Instance. NOT Azure SQL Database (edition 5): no Agent + /// and no sys.dm_server_services there. NOT AWS RDS either: sys.dm_server_services reads OS/service-control + /// state that RDS (a managed service with no OS access) does not expose, so the query returns nothing + /// useful and would drive a false "Agent Not Running" alert. NOT a login without msdb access either: the + /// next-run decode reads <c>msdb.dbo.sysjobschedules</c>. All three gates live here in the shared + /// AppliesTo — the single authoritative gate both SKUs consult. + /// </summary> + public override bool AppliesTo(CollectorTargetInfo target) => + !target.IsAzureSqlDb && !target.IsAwsRds && target.HasMsdbAccess; + + public override CollectorQuery BuildQuery(CollectorContext context) => new(QueryText); + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("agent_running", CollectorColumnType.Boolean), + new CollectorColumn("agent_status_desc", CollectorColumnType.Varchar), + new CollectorColumn("agent_startup_desc", CollectorColumnType.Varchar), + new CollectorColumn("next_scheduled_run", CollectorColumnType.Timestamp), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var rows = new List<Row>(); + + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new Row( + !reader.IsDBNull(0) && Convert.ToBoolean(reader.GetValue(0), CultureInfo.InvariantCulture), + reader.IsDBNull(1) ? null : reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetString(2), + reader.IsDBNull(3) ? null : reader.GetDateTime(3))); + } + + return rows; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + writer + .Value(row.AgentRunning) /* agent_running BOOLEAN */ + .Value(row.AgentStatusDesc) /* agent_status_desc VARCHAR */ + .Value(row.AgentStartupDesc) /* agent_startup_desc VARCHAR */ + .Value(row.NextScheduledRun); /* next_scheduled_run TIMESTAMP */ + } +} diff --git a/PerformanceMonitor.Collectors/BlockedProcessReportCollector.cs b/PerformanceMonitor.Collectors/BlockedProcessReportCollector.cs new file mode 100644 index 000000000..62aba0443 --- /dev/null +++ b/PerformanceMonitor.Collectors/BlockedProcessReportCollector.cs @@ -0,0 +1,704 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Blocked-process reports from the app-managed PerformanceMonitor_BlockedProcess XE ring-buffer +/// session. Extracted verbatim from Lite's RemoteCollectorService.BlockedProcessReport.cs: the +/// server- vs database-scoped ring-buffer read, the server-side wait_resource → contentious +/// object resolution (KEY via sys.partitions, PAGE/RID via sys.dm_db_page_info on 2019+/Azure, +/// mirroring sp_HumanEventsBlockViewer so the #1140 dedup fingerprint agrees across apps), the +/// event_time watermark that keeps ring-buffer lingerers from re-inserting (10-minute fallback), +/// and the C# blocked-process-report XML parse (read phase). Session lifecycle — including the +/// blocked-process-threshold sp_configure bootstrap — stays host-side; the session name lives +/// here so the reader and the lifecycle can never disagree on it. +/// </summary> +public sealed class BlockedProcessReportCollector : CollectorDefinitionBase<BlockedProcessReportCollector.Row> +{ + public static BlockedProcessReportCollector Instance { get; } = new(); + + private BlockedProcessReportCollector() + { + } + + /* We create and manage our own XE session to avoid conflicts with user's existing sessions */ + public const string XeSessionName = "PerformanceMonitor_BlockedProcess"; + + public sealed class Row + { + public DateTime? EventTime { get; set; } + public int? MonitorLoop { get; set; } + public string? DatabaseName { get; set; } + public int BlockedSpid { get; set; } + public int BlockedEcid { get; set; } + public int BlockingSpid { get; set; } + public int BlockingEcid { get; set; } + public long WaitTimeMs { get; set; } + public string? WaitResource { get; set; } + public string? LockMode { get; set; } + public string? BlockedStatus { get; set; } + public string? BlockedIsolationLevel { get; set; } + public long BlockedLogUsed { get; set; } + public int BlockedTransactionCount { get; set; } + public string? BlockedClientApp { get; set; } + public string? BlockedHostName { get; set; } + public string? BlockedLoginName { get; set; } + public string? BlockedSqlText { get; set; } + public string? BlockingStatus { get; set; } + public string? BlockingIsolationLevel { get; set; } + public string? BlockingClientApp { get; set; } + public string? BlockingHostName { get; set; } + public string? BlockingLoginName { get; set; } + public string? BlockingSqlText { get; set; } + public string? BlockedTransactionName { get; set; } + public string? BlockingTransactionName { get; set; } + public DateTime? BlockedLastTranStarted { get; set; } + public DateTime? BlockingLastTranStarted { get; set; } + public DateTime? BlockedLastBatchStarted { get; set; } + public DateTime? BlockingLastBatchStarted { get; set; } + public DateTime? BlockedLastBatchCompleted { get; set; } + public DateTime? BlockingLastBatchCompleted { get; set; } + public int BlockedPriority { get; set; } + public int BlockingPriority { get; set; } + public string? ReportXml { get; set; } + public int? ObjectId { get; set; } + public int? DatabaseId { get; set; } + public string? ContentiousObject { get; set; } + /* Best-effort execution plans for the two contending statements, resolved from the plan cache + at collection time (CapturePlanXml only). Null on Lite (flag off) and whenever the plan + can't be recovered — see the resolution comment in BuildQuery. */ + public string? BlockedQueryPlanXml { get; set; } + public string? BlockingQueryPlanXml { get; set; } + } + + public override string Name => "blocked_process_report"; + + public override string TargetTable => "blocked_process_reports"; + + /// <summary>Lite schema names this table's prefix id "blocked_report_id"; Darling mirrors it.</summary> + public override string PrefixIdColumnName => "blocked_report_id"; + + /// <summary> + /// Only events newer than the newest already-collected report are fetched, so a report + /// lingering in the ring buffer across cycles is never inserted twice. + /// </summary> + public override string? WatermarkColumn => "event_time"; + + /// <summary> + /// Azure SQL DB capture is a database-scoped session per monitored database (#1535 — a single + /// session only ever saw the connection's own database). The host ensures the session per + /// database and this read then runs per database, exactly like the other Azure per-database + /// collectors. The per-database contentious-object resolution degrades exactly as the + /// single-database form did: cross-database lookups that can't run on Azure fall to the + /// labeled-Unresolved sentinel via the existing TRY/CATCH guards. + /// </summary> + public override bool RunsPerDatabase(CollectorTargetInfo target) => target.IsAzureSqlDb; + + /// <summary> + /// Per-database watermark for the per-database sessions (database_name is the blocked + /// process's currentdbname, parsed since the column's introduction): each database's ring + /// buffer dispatches independently, so one database's newer report must not watermark past + /// another's older one. + /// </summary> + public override string? PerDatabaseWatermarkColumn => "database_name"; + + public override CollectorQuery BuildQuery(CollectorContext context) + { + /* The ring-buffer source DMV is the only engine difference (database-scoped on Azure SQL DB, + server-scoped elsewhere); event extraction and the wait_resource -> object decode are shared. */ + string ringBufferSource = context.Target.IsAzureSqlDb + ? @"sys.dm_xe_database_session_targets AS xet +JOIN sys.dm_xe_database_sessions AS xes + ON xes.address = xet.event_session_address" + : @"sys.dm_xe_session_targets AS xet +JOIN sys.dm_xe_sessions AS xes + ON xes.address = xet.event_session_address"; + + /* Resolve contentious_object from wait_resource (KEY hobt -> sys.partitions, OBJECT objid, + PAGE/RID -> sys.dm_db_page_info), mirroring sp_HumanEventsBlockViewer (DarlingData #812) so + Dashboard and Lite -- and the #1140 dedup fingerprint -- agree. The event object_id/database_id + is unreliable for lock waits, kept only as a COALESCE fallback. Cross-db lookups are per-database + dynamic SQL guarded by DB-online + TRY/CATCH; the 2019+ DMV is version-gated (or @azure, which + reports ProductVersion 12 on Azure SQL DB). #bpr is a #temp (not a table var) so the dynamic SQL + can see it. The final SELECT keeps the original 5 columns (plus, gated on CapturePlanXml, the + two best-effort plan columns) so the reader/appender is unchanged when the flag is off. */ + + /* Best-effort plan capture (CapturePlanXml only — Darling). Resolve each contending statement's + plan from the live plan cache by the executionStack frame's sql_handle + statement offsets, + reusing the proven on-demand shape from Lite/Dashboard's #880 right-click View Plan + (dm_exec_query_stats -> dm_exec_text_query_plan). This is honestly best-effort: the + blocked_process_report XE fires asynchronously, so by collection the plan may have aged out of + cache; dynamic-SQL / adhoc frames carry an all-zero sql_handle that matches no query_stats row; + either way the column lands NULL rather than fabricated. Frames are walked top-of-stack first, + first resolvable wins — the same order ExtractBlockedProcessFrames uses. Flag off: both + fragments are empty strings, so the emitted SQL is byte-identical to the no-plan form. */ + var planSelect = context.CapturePlanXml + ? @", + blocked_query_plan_xml = bqp.query_plan, + blocking_query_plan_xml = kqp.query_plan" + : ""; + var planApply = context.CapturePlanXml + ? BuildPlanResolutionApply("blocked-process", "bqp") + BuildPlanResolutionApply("blocking-process", "kqp") + : ""; + + string query = $@" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +DECLARE + @product_version integer = + CONVERT + ( + integer, + PARSENAME + ( + CONVERT(sysname, SERVERPROPERTY('ProductVersion')), + 4 + ) + ), + @azure bit = + CASE + WHEN CONVERT(integer, SERVERPROPERTY('EngineEdition')) = 5 + THEN 1 + ELSE 0 + END, + @resolve_sql nvarchar(max), + @resolve_database_id integer, + @key_dbs CURSOR, + @page_dbs CURSOR; + +DECLARE + @PerformanceMonitor_BlockedProcess TABLE +( + ring_buffer xml NOT NULL +); + +INSERT + @PerformanceMonitor_BlockedProcess +( + ring_buffer +) +SELECT /* PerformanceMonitorLite */ + ring_xml = TRY_CAST(xet.target_data AS xml) +FROM {ringBufferSource} +WHERE xes.name = N'{XeSessionName}' +AND xet.target_name = N'ring_buffer' +OPTION(RECOMPILE); + +SELECT + x.event_time, + x.blocked_process_report_xml, + x.object_id, + x.database_id, + x.wait_resource, + lock_type = CONVERT(varchar(32), NULL), + frag = CONVERT(nvarchar(1024), NULL), + resource_database_id = CONVERT(integer, NULL), + resource_hobt_id = CONVERT(bigint, NULL), + resource_file_id = CONVERT(integer, NULL), + resource_page_id = CONVERT(bigint, NULL), + resource_object_id = CONVERT(integer, NULL), + contentious_object = CONVERT(nvarchar(4000), NULL) +INTO #bpr +FROM +( + SELECT + event_time = evt.value('(@timestamp)[1]', 'datetime2'), + blocked_process_report_xml = CONVERT(nvarchar(max), evt.query('data[@name=""blocked_process""]/value/blocked-process-report')), + object_id = evt.value('(data[@name=""object_id""]/value/text())[1]', 'integer'), + database_id = evt.value('(data[@name=""database_id""]/value/text())[1]', 'integer'), + wait_resource = evt.value('(data[@name=""blocked_process""]/value/blocked-process-report/blocked-process/process/@waitresource)[1]', 'nvarchar(1024)') + FROM @PerformanceMonitor_BlockedProcess AS rb + CROSS APPLY rb.ring_buffer.nodes('RingBufferTarget/event[@name=""blocked_process_report""]') AS q(evt) + WHERE evt.value('(@timestamp)[1]', 'datetime2') > @cutoff_time +) AS x +OPTION(RECOMPILE); + +/* Classify the lock resource (prefers an embedded KEY/PAGE in a compound resource). */ +UPDATE + b +SET + b.lock_type = + CASE + WHEN b.wait_resource LIKE N'%KEY: %' THEN 'KEY' + WHEN b.wait_resource LIKE N'%OBJECT: %' THEN 'OBJECT' + WHEN b.wait_resource LIKE N'%RID: %' THEN 'RID' + WHEN b.wait_resource LIKE N'%PAGE: %' THEN 'PAGE' + ELSE LEFT(UPPER(LEFT(b.wait_resource, CHARINDEX(N':', b.wait_resource + N':') - 1)), 32) + END +FROM #bpr AS b +WHERE b.wait_resource IS NOT NULL +AND b.wait_resource <> N'' +OPTION(RECOMPILE); + +UPDATE + b +SET + b.frag = + SUBSTRING + ( + b.wait_resource, + CHARINDEX(b.lock_type + N': ', b.wait_resource) + LEN(b.lock_type) + 2, + 1024 + ) +FROM #bpr AS b +WHERE b.lock_type IN ('KEY', 'OBJECT', 'RID', 'PAGE') +OPTION(RECOMPILE); + +UPDATE + b +SET + b.resource_database_id = + TRY_CONVERT(integer, LEFT(b.frag, CHARINDEX(N':', b.frag + N':') - 1)) +FROM #bpr AS b +WHERE b.frag IS NOT NULL +OPTION(RECOMPILE); + +UPDATE + b +SET + b.resource_hobt_id = + TRY_CONVERT(bigint, LTRIM(LEFT(r.rest, CHARINDEX(N' ', r.rest + N' ') - 1))) +FROM #bpr AS b +CROSS APPLY +( + SELECT + rest = SUBSTRING(b.frag, CHARINDEX(N':', b.frag) + 1, 1024) +) AS r +WHERE b.lock_type = 'KEY' +OPTION(RECOMPILE); + +UPDATE + b +SET + b.resource_object_id = + TRY_CONVERT(integer, LEFT(r.rest, CHARINDEX(N':', r.rest + N':') - 1)) +FROM #bpr AS b +CROSS APPLY +( + SELECT + rest = SUBSTRING(b.frag, CHARINDEX(N':', b.frag) + 1, 1024) +) AS r +WHERE b.lock_type = 'OBJECT' +OPTION(RECOMPILE); + +UPDATE + b +SET + b.resource_file_id = + TRY_CONVERT(integer, LEFT(r.rest, CHARINDEX(N':', r.rest + N':') - 1)), + b.resource_page_id = + TRY_CONVERT(bigint, LEFT(p.rest2, CHARINDEX(N':', p.rest2 + N':') - 1)) +FROM #bpr AS b +CROSS APPLY +( + SELECT + rest = SUBSTRING(b.frag, CHARINDEX(N':', b.frag) + 1, 1024) +) AS r +CROSS APPLY +( + SELECT + rest2 = SUBSTRING(r.rest, CHARINDEX(N':', r.rest) + 1, 1024) +) AS p +WHERE b.lock_type IN ('PAGE', 'RID') +OPTION(RECOMPILE); + +/* KEY -> object_id via sys.partitions, per contended database (no cross-db form of the view). */ +SET @key_dbs = + CURSOR + LOCAL FAST_FORWARD + FOR + SELECT DISTINCT + b.resource_database_id + FROM #bpr AS b + WHERE b.lock_type = 'KEY' + AND b.resource_database_id IS NOT NULL + AND b.resource_hobt_id IS NOT NULL; + +OPEN @key_dbs; +FETCH NEXT FROM @key_dbs INTO @resolve_database_id; +WHILE @@FETCH_STATUS = 0 +BEGIN + IF DB_NAME(@resolve_database_id) IS NOT NULL + AND DATABASEPROPERTYEX(DB_NAME(@resolve_database_id), 'Status') = N'ONLINE' + BEGIN + SET @resolve_sql = N' + BEGIN TRY + UPDATE b + SET b.resource_object_id = p.object_id + FROM #bpr AS b + JOIN ' + QUOTENAME(DB_NAME(@resolve_database_id)) + N'.sys.partitions AS p + ON p.hobt_id = b.resource_hobt_id + WHERE b.lock_type = ''KEY'' + AND b.resource_database_id = @resolve_database_id + OPTION(RECOMPILE); + END TRY + BEGIN CATCH + /* no metadata access to the database -> leave for labeling */ + END CATCH;'; + EXECUTE sys.sp_executesql + @resolve_sql, + N'@resolve_database_id integer', + @resolve_database_id; + END; + FETCH NEXT FROM @key_dbs INTO @resolve_database_id; +END; + +/* PAGE / RID -> object_id via sys.dm_db_page_info (2019+ or Azure SQL DB; VIEW DATABASE STATE -> TRY/CATCH). */ +IF @product_version >= 15 +OR @azure = 1 +BEGIN + SET @page_dbs = + CURSOR + LOCAL FAST_FORWARD + FOR + SELECT DISTINCT + b.resource_database_id + FROM #bpr AS b + WHERE b.lock_type IN ('PAGE', 'RID') + AND b.resource_database_id IS NOT NULL + AND b.resource_page_id IS NOT NULL; + + OPEN @page_dbs; + FETCH NEXT FROM @page_dbs INTO @resolve_database_id; + WHILE @@FETCH_STATUS = 0 + BEGIN + IF DB_NAME(@resolve_database_id) IS NOT NULL + AND DATABASEPROPERTYEX(DB_NAME(@resolve_database_id), 'Status') = N'ONLINE' + BEGIN + SET @resolve_sql = N' + BEGIN TRY + UPDATE b + SET b.resource_object_id = pi.object_id + FROM #bpr AS b + CROSS APPLY sys.dm_db_page_info(b.resource_database_id, b.resource_file_id, b.resource_page_id, ''LIMITED'') AS pi + WHERE b.lock_type IN (''PAGE'', ''RID'') + AND b.resource_database_id = @resolve_database_id + OPTION(RECOMPILE); + END TRY + BEGIN CATCH + /* no VIEW DATABASE STATE / page reallocated -> leave for labeling */ + END CATCH;'; + EXECUTE sys.sp_executesql + @resolve_sql, + N'@resolve_database_id integer', + @resolve_database_id; + END; + FETCH NEXT FROM @page_dbs INTO @resolve_database_id; + END; +END; + +/* Format (plain schema.object) + label; the event object_id is the fallback, then the Unresolved sentinel. */ +UPDATE + b +SET + b.contentious_object = + COALESCE + ( + CASE + WHEN b.resource_object_id > 0 + AND OBJECT_NAME(b.resource_object_id, b.resource_database_id) IS NOT NULL + THEN CONCAT + ( + OBJECT_SCHEMA_NAME(b.resource_object_id, b.resource_database_id), + N'.', + OBJECT_NAME(b.resource_object_id, b.resource_database_id) + ) + END, + CASE + WHEN b.object_id > 0 + AND OBJECT_NAME(b.object_id, b.database_id) IS NOT NULL + THEN CONCAT + ( + OBJECT_SCHEMA_NAME(b.object_id, b.database_id), + N'.', + OBJECT_NAME(b.object_id, b.database_id) + ) + END, + N'Unresolved: ' + + CASE + WHEN b.lock_type IS NULL + THEN N'' + ELSE LOWER(b.lock_type) + N' lock, ' + END + + N'database: ' + ISNULL(DB_NAME(b.database_id), N'unknown') + ) +FROM #bpr AS b +OPTION(RECOMPILE); + +SELECT + b.event_time, + b.blocked_process_report_xml, + b.object_id, + b.database_id, + b.contentious_object{planSelect} +FROM #bpr AS b{planApply} +OPTION(RECOMPILE);"; + + /* Use the most recent timestamp from the host store as the cutoff, or fall back to a + 10-minute window on first run. */ + var cutoffTime = context.Watermark ?? context.CollectionTime.AddMinutes(-10); + + return new CollectorQuery(query, new List<CollectorParameter> + { + new("@cutoff_time", cutoffTime, CollectorParameterType.DateTime2), + }); + } + + /// <summary> + /// A best-effort execution-plan lookup for one side of a blocked-process report, spliced into the + /// final projection only when CapturePlanXml is set. Shreds that side's executionStack frames from + /// the stored report XML and, walking top-of-stack first, returns the first frame whose + /// (sql_handle, statement offsets) still resolves to a cached statement plan via + /// sys.dm_exec_query_stats → sys.dm_exec_text_query_plan — the exact pair the #880 live "View Plan" + /// uses. OUTER APPLY returning one nullable value, so it never multiplies or drops #bpr rows. + /// <paramref name="processNode"/> is "blocked-process" or "blocking-process"; <paramref name="alias"/> + /// is the APPLY alias the SELECT reads (bqp / kqp). + /// </summary> + private static string BuildPlanResolutionApply(string processNode, string alias) => $@" +OUTER APPLY +( + SELECT TOP (1) + query_plan = tqp.query_plan + FROM + ( + SELECT + frame_handle = TRY_CONVERT(varbinary(64), fr.n.value('@sqlhandle', 'varchar(130)'), 1), + frame_start = fr.n.value('(@stmtstart)[1]', 'integer'), + frame_end = fr.n.value('(@stmtend)[1]', 'integer'), + frame_ordinal = fr.n.value('let $c := . return count(../frame[. << $c])', 'integer') + FROM (SELECT report = TRY_CAST(b.blocked_process_report_xml AS xml)) AS x + CROSS APPLY x.report.nodes('/blocked-process-report/{processNode}/process/executionStack/frame') AS fr(n) + ) AS frames + JOIN sys.dm_exec_query_stats AS qs + ON qs.sql_handle = frames.frame_handle + AND qs.statement_start_offset = ISNULL(frames.frame_start, 0) + AND qs.statement_end_offset = ISNULL(frames.frame_end, -1) + OUTER APPLY + sys.dm_exec_text_query_plan + ( + qs.plan_handle, + qs.statement_start_offset, + qs.statement_end_offset + ) AS tqp + WHERE frames.frame_handle IS NOT NULL + AND tqp.query_plan IS NOT NULL + ORDER BY + frames.frame_ordinal +) AS {alias}"; + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("event_time", CollectorColumnType.Timestamp), + new CollectorColumn("database_name", CollectorColumnType.Varchar), + new CollectorColumn("blocked_spid", CollectorColumnType.Integer), + new CollectorColumn("blocked_ecid", CollectorColumnType.Integer), + new CollectorColumn("blocking_spid", CollectorColumnType.Integer), + new CollectorColumn("blocking_ecid", CollectorColumnType.Integer), + new CollectorColumn("wait_time_ms", CollectorColumnType.BigInt), + new CollectorColumn("wait_resource", CollectorColumnType.Varchar), + new CollectorColumn("lock_mode", CollectorColumnType.Varchar), + new CollectorColumn("blocked_status", CollectorColumnType.Varchar), + new CollectorColumn("blocked_isolation_level", CollectorColumnType.Varchar), + new CollectorColumn("blocked_log_used", CollectorColumnType.BigInt), + new CollectorColumn("blocked_transaction_count", CollectorColumnType.Integer), + new CollectorColumn("blocked_client_app", CollectorColumnType.Varchar), + new CollectorColumn("blocked_host_name", CollectorColumnType.Varchar), + new CollectorColumn("blocked_login_name", CollectorColumnType.Varchar), + new CollectorColumn("blocked_sql_text", CollectorColumnType.Varchar), + new CollectorColumn("blocking_status", CollectorColumnType.Varchar), + new CollectorColumn("blocking_isolation_level", CollectorColumnType.Varchar), + new CollectorColumn("blocking_client_app", CollectorColumnType.Varchar), + new CollectorColumn("blocking_host_name", CollectorColumnType.Varchar), + new CollectorColumn("blocking_login_name", CollectorColumnType.Varchar), + new CollectorColumn("blocking_sql_text", CollectorColumnType.Varchar), + new CollectorColumn("blocked_transaction_name", CollectorColumnType.Varchar), + new CollectorColumn("blocking_transaction_name", CollectorColumnType.Varchar), + new CollectorColumn("blocked_last_tran_started", CollectorColumnType.Timestamp), + new CollectorColumn("blocking_last_tran_started", CollectorColumnType.Timestamp), + new CollectorColumn("blocked_last_batch_started", CollectorColumnType.Timestamp), + new CollectorColumn("blocking_last_batch_started", CollectorColumnType.Timestamp), + new CollectorColumn("blocked_last_batch_completed", CollectorColumnType.Timestamp), + new CollectorColumn("blocking_last_batch_completed", CollectorColumnType.Timestamp), + new CollectorColumn("blocked_priority", CollectorColumnType.Integer), + new CollectorColumn("blocking_priority", CollectorColumnType.Integer), + new CollectorColumn("blocked_process_report_xml", CollectorColumnType.Varchar), + new CollectorColumn("object_id", CollectorColumnType.Integer), + new CollectorColumn("database_id", CollectorColumnType.Integer), + new CollectorColumn("contentious_object", CollectorColumnType.Varchar), + new CollectorColumn("monitor_loop", CollectorColumnType.Integer), + /* Trailing best-effort plan columns (appended so one ADD COLUMN each brings a pre-plan store + up to shape; NULL on Lite and whenever the statement's plan aged out of cache). */ + new CollectorColumn("blocked_query_plan_xml", CollectorColumnType.Varchar), + new CollectorColumn("blocking_query_plan_xml", CollectorColumnType.Varchar), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var rows = new List<Row>(); + + while (await reader.ReadAsync(cancellationToken)) + { + var eventTime = reader.IsDBNull(0) ? (DateTime?)null : reader.GetDateTime(0); + var reportXml = reader.IsDBNull(1) ? null : reader.GetString(1); + /* #1140: object_id/database_id are the event's own data fields and contentious_object + is resolved server-side in the collection query (cols 2-4), not parsed from the XML. */ + var objectId = reader.IsDBNull(2) ? (int?)null : reader.GetInt32(2); + var databaseId = reader.IsDBNull(3) ? (int?)null : reader.GetInt32(3); + var contentiousObject = reader.IsDBNull(4) ? null : reader.GetString(4); + /* The two best-effort plan columns ride at ordinals 5/6 only when CapturePlanXml spliced + them into the projection; the short-circuit skips them entirely when off. */ + var blockedQueryPlanXml = context.CapturePlanXml && !reader.IsDBNull(5) ? reader.GetString(5) : null; + var blockingQueryPlanXml = context.CapturePlanXml && !reader.IsDBNull(6) ? reader.GetString(6) : null; + + if (string.IsNullOrEmpty(reportXml)) + { + continue; + } + + /* Parse the blocked process report XML in C# (read phase, like the original) */ + var parsed = ParseReportXml(reportXml, eventTime); + if (parsed == null) + { + continue; + } + + parsed.ReportXml = reportXml; + parsed.ObjectId = objectId; + parsed.DatabaseId = databaseId; + parsed.ContentiousObject = contentiousObject; + parsed.BlockedQueryPlanXml = blockedQueryPlanXml; + parsed.BlockingQueryPlanXml = blockingQueryPlanXml; + /* Per-database path (#1535): the capture database is authoritative for the + per-database watermark key — a database-scoped session only captures its own + database, and a report whose XML carries no currentdbname would otherwise never + advance that database's watermark, re-inserting every cycle. Server-scoped + platforms keep the parsed currentdbname (CurrentDatabaseName is null there). */ + parsed.DatabaseName = context.CurrentDatabaseName ?? parsed.DatabaseName; + rows.Add(parsed); + } + + return rows; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + writer + .Value(row.EventTime) + .Value(row.DatabaseName) + .Value(row.BlockedSpid) + .Value(row.BlockedEcid) + .Value(row.BlockingSpid) + .Value(row.BlockingEcid) + .Value(row.WaitTimeMs) + .Value(row.WaitResource) + .Value(row.LockMode) + .Value(row.BlockedStatus) + .Value(row.BlockedIsolationLevel) + .Value(row.BlockedLogUsed) + .Value(row.BlockedTransactionCount) + .Value(row.BlockedClientApp) + .Value(row.BlockedHostName) + .Value(row.BlockedLoginName) + .Value(row.BlockedSqlText) + .Value(row.BlockingStatus) + .Value(row.BlockingIsolationLevel) + .Value(row.BlockingClientApp) + .Value(row.BlockingHostName) + .Value(row.BlockingLoginName) + .Value(row.BlockingSqlText) + .Value(row.BlockedTransactionName) + .Value(row.BlockingTransactionName) + .Value(row.BlockedLastTranStarted) + .Value(row.BlockingLastTranStarted) + .Value(row.BlockedLastBatchStarted) + .Value(row.BlockingLastBatchStarted) + .Value(row.BlockedLastBatchCompleted) + .Value(row.BlockingLastBatchCompleted) + .Value(row.BlockedPriority) + .Value(row.BlockingPriority) + .Value(row.ReportXml) + .Value(row.ObjectId) + .Value(row.DatabaseId) + .Value(row.ContentiousObject) + .Value(row.MonitorLoop) + .Value(row.BlockedQueryPlanXml) /* null unless CapturePlanXml resolved it (Darling) */ + .Value(row.BlockingQueryPlanXml); + } + + /// <summary> + /// Parses a blocked-process-report XML fragment into a row (reader-side fields left unset). + /// XML structure: <blocked-process-report><blocked-process><process ...><inputbuf>... + /// <blocking-process><process ...><inputbuf>... + /// </summary> + public static Row? ParseReportXml(string xml, DateTime? eventTime) + { + try + { + var doc = XElement.Parse(xml); + + var blockedProcess = doc.Element("blocked-process")?.Element("process"); + var blockingProcess = doc.Element("blocking-process")?.Element("process"); + + if (blockedProcess == null) + { + return null; + } + + return new Row + { + EventTime = eventTime, + // Lite's stored XML is rooted at <blocked-process-report>, so monitorLoop is a root attribute. + MonitorLoop = int.TryParse(doc.Attribute("monitorLoop")?.Value, out var ml) ? ml : (int?)null, + DatabaseName = blockedProcess.Attribute("currentdbname")?.Value, + BlockedSpid = int.TryParse(blockedProcess.Attribute("spid")?.Value, out var bs) ? bs : 0, + BlockedEcid = int.TryParse(blockedProcess.Attribute("ecid")?.Value, out var be) ? be : 0, + BlockingSpid = int.TryParse(blockingProcess?.Attribute("spid")?.Value, out var bks) ? bks : 0, + BlockingEcid = int.TryParse(blockingProcess?.Attribute("ecid")?.Value, out var bke) ? bke : 0, + WaitTimeMs = long.TryParse(blockedProcess.Attribute("waittime")?.Value, out var wt) ? wt : 0, + WaitResource = blockedProcess.Attribute("waitresource")?.Value, + LockMode = blockedProcess.Attribute("lockMode")?.Value, + BlockedStatus = blockedProcess.Attribute("status")?.Value, + BlockedIsolationLevel = blockedProcess.Attribute("isolationlevel")?.Value, + BlockedLogUsed = long.TryParse(blockedProcess.Attribute("logused")?.Value, out var lu) ? lu : 0, + BlockedTransactionCount = int.TryParse(blockedProcess.Attribute("trancount")?.Value, out var tc) ? tc : 0, + BlockedClientApp = blockedProcess.Attribute("clientapp")?.Value, + BlockedHostName = blockedProcess.Attribute("hostname")?.Value, + BlockedLoginName = blockedProcess.Attribute("loginname")?.Value, + BlockedSqlText = blockedProcess.Element("inputbuf")?.Value?.Trim(), + BlockedTransactionName = blockedProcess.Attribute("transactionname")?.Value, + BlockedLastTranStarted = DateTime.TryParse(blockedProcess.Attribute("lasttranstarted")?.Value, out var blts) ? blts : null, + BlockedLastBatchStarted = DateTime.TryParse(blockedProcess.Attribute("lastbatchstarted")?.Value, out var blbs) ? blbs : null, + BlockedLastBatchCompleted = DateTime.TryParse(blockedProcess.Attribute("lastbatchcompleted")?.Value, out var blbc) ? blbc : null, + BlockedPriority = int.TryParse(blockedProcess.Attribute("priority")?.Value, out var bp) ? bp : 0, + BlockingStatus = blockingProcess?.Attribute("status")?.Value, + BlockingIsolationLevel = blockingProcess?.Attribute("isolationlevel")?.Value, + BlockingClientApp = blockingProcess?.Attribute("clientapp")?.Value, + BlockingHostName = blockingProcess?.Attribute("hostname")?.Value, + BlockingLoginName = blockingProcess?.Attribute("loginname")?.Value, + BlockingSqlText = blockingProcess?.Element("inputbuf")?.Value?.Trim(), + BlockingTransactionName = blockingProcess?.Attribute("transactionname")?.Value, + BlockingLastTranStarted = DateTime.TryParse(blockingProcess?.Attribute("lasttranstarted")?.Value, out var bklts) ? bklts : null, + BlockingLastBatchStarted = DateTime.TryParse(blockingProcess?.Attribute("lastbatchstarted")?.Value, out var bklbs) ? bklbs : null, + BlockingLastBatchCompleted = DateTime.TryParse(blockingProcess?.Attribute("lastbatchcompleted")?.Value, out var bklbc) ? bklbc : null, + BlockingPriority = int.TryParse(blockingProcess?.Attribute("priority")?.Value, out var bkp) ? bkp : 0 + }; + } + catch + { + return null; + } + } +} diff --git a/PerformanceMonitor.Collectors/CollectionIdGenerator.cs b/PerformanceMonitor.Collectors/CollectionIdGenerator.cs new file mode 100644 index 000000000..4be8194cc --- /dev/null +++ b/PerformanceMonitor.Collectors/CollectionIdGenerator.cs @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Generates unique collection ids: a per-process counter seeded from the startup timestamp's +/// ticks, atomically incremented — monotonic within a process and jumping forward across +/// restarts so ids never collide in practice. The shared idiom both hosts stamp prefix ids with +/// (extracted verbatim from Lite's RemoteCollectorService.GenerateCollectionId). +/// </summary> +public static class CollectionIdGenerator +{ + private static long s_idCounter = DateTime.UtcNow.Ticks; + + public static long Next() + { + return Interlocked.Increment(ref s_idCounter); + } +} diff --git a/PerformanceMonitor.Collectors/CollectorCatalog.cs b/PerformanceMonitor.Collectors/CollectorCatalog.cs new file mode 100644 index 000000000..69cb91a84 --- /dev/null +++ b/PerformanceMonitor.Collectors/CollectorCatalog.cs @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Every collector definition in the library, as the engine-neutral schema surface. This is the +/// single enumeration storage hosts build from — Darling generates its full Postgres schema by +/// walking this list. A new definition MUST be added here (the catalog test pins the count). +/// </summary> +public static class CollectorCatalog +{ + public static IReadOnlyList<ICollectorSchemaInfo> All { get; } = new ICollectorSchemaInfo[] + { + WaitStatsCollector.Instance, + LatchStatsCollector.Instance, + SpinlockStatsCollector.Instance, + CpuSchedulerStatsCollector.Instance, + PlanCacheStatsCollector.Instance, + TempDbStatsCollector.Instance, + MemoryGrantsCollector.Instance, + CpuUtilizationCollector.Instance, + MemoryStatsCollector.Instance, + MemoryClerksCollector.Instance, + MemoryPressureEventsCollector.Instance, + FileIoStatsCollector.Instance, + ServerPropertiesCollector.Instance, + ServerConfigCollector.Instance, + DatabaseConfigCollector.Instance, + TraceFlagsCollector.Instance, + DatabaseScopedConfigCollector.Instance, + SessionStatsCollector.Instance, + SessionSummaryStatsCollector.Instance, + WaitingTasksCollector.Instance, + ProcedureStatsCollector.Instance, + RunningJobsCollector.Instance, + PerfmonStatsCollector.Instance, + DmvBlockingSnapshotCollector.Instance, + DatabaseSizeStatsCollector.Instance, + IndexObjectStatsCollector.Instance, + QueryStatsCollector.Instance, + QuerySnapshotsCollector.Instance, + QueryStoreCollector.Instance, + DeadlocksCollector.Instance, + BlockedProcessReportCollector.Instance, + LongQueryCompletionsCollector.Instance, + SystemHealthEventsCollector.Instance, + DefaultTraceEventsCollector.Instance, + JobHistoryCollector.Instance, + AgentStatusCollector.Instance, + }; + + /// <summary>Name → definition, for the by-name target-gate lookup. Built once from <see cref="All"/>.</summary> + private static readonly Dictionary<string, ICollectorSchemaInfo> s_byName = + All.ToDictionary(c => c.Name, StringComparer.Ordinal); + + /// <summary> + /// The single authoritative target-gate check both SKUs share: whether <paramref name="collectorName"/> + /// applies to <paramref name="target"/>. Delegates to the definition's + /// <see cref="ICollectorSchemaInfo.AppliesTo"/> override — the gate CONDITION lives there and nowhere + /// else. Darling's collector runner calls <c>definition.AppliesTo(target)</c> directly; Lite consults + /// this by name for its pre-dispatch SKIPPED log (a genuine skip with no collection_log row, vs. the + /// SUCCESS/0-rows a gated collector would otherwise record). An unknown name returns <c>true</c> (not + /// gated) so a typo surfaces as the dispatch switch's "Unknown collector" rather than a silent skip. + /// </summary> + public static bool AppliesTo(string collectorName, CollectorTargetInfo target) => + s_byName.TryGetValue(collectorName, out var definition) ? definition.AppliesTo(target) : true; +} diff --git a/PerformanceMonitor.Collectors/CollectorColumn.cs b/PerformanceMonitor.Collectors/CollectorColumn.cs new file mode 100644 index 000000000..fdd811da2 --- /dev/null +++ b/PerformanceMonitor.Collectors/CollectorColumn.cs @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Logical column types for collector payload columns — engine-neutral names each host maps to +/// its own storage types (Lite/DuckDB and Darling/Postgres both have direct equivalents). +/// </summary> +public enum CollectorColumnType +{ + BigInt, + Integer, + SmallInt, + Varchar, + Timestamp, + Double, + Decimal, + Boolean, +} + +/// <summary> +/// One payload column a collector definition emits, in emission order. The host writes its +/// standard prefix columns first; these describe everything after the prefix. +/// </summary> +public sealed class CollectorColumn +{ + public CollectorColumn(string name, CollectorColumnType type) + { + Name = name; + Type = type; + } + + /// <summary> + /// Decimal column with explicit precision/scale, mirroring the column's declared type in + /// Lite's DuckDB schema (e.g. DECIMAL(18,2) memory MBs, DECIMAL(5,2) percent_complete) so + /// Darling's generated Postgres DDL matches column-for-column. + /// </summary> + public CollectorColumn(string name, CollectorColumnType type, int precision, int scale) + : this(name, type) + { + Precision = precision; + Scale = scale; + } + + public string Name { get; } + + public CollectorColumnType Type { get; } + + /// <summary>Declared precision for <see cref="CollectorColumnType.Decimal"/> columns; 0 otherwise.</summary> + public int Precision { get; } + + /// <summary>Declared scale for <see cref="CollectorColumnType.Decimal"/> columns; 0 otherwise.</summary> + public int Scale { get; } +} diff --git a/PerformanceMonitor.Collectors/CollectorContext.cs b/PerformanceMonitor.Collectors/CollectorContext.cs new file mode 100644 index 000000000..e2e071e28 --- /dev/null +++ b/PerformanceMonitor.Collectors/CollectorContext.cs @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Host-supplied context a collector definition runs under: the server identity and collection +/// timestamp the host stamps on every row, the delta calculator, and host-configured filters. +/// </summary> +public sealed class CollectorContext +{ + private static readonly IReadOnlySet<string> s_emptySet = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + + public required int ServerId { get; init; } + + public required string ServerName { get; init; } + + /// <summary>UTC timestamp the host stamps as collection_time; definitions pass it to delta calls.</summary> + public required DateTime CollectionTime { get; init; } + + public required ICollectorDeltaCalculator Deltas { get; init; } + + /// <summary>Target-server facts the definition may branch on (engine edition, etc.).</summary> + public CollectorTargetInfo Target { get; init; } = new(); + + /// <summary> + /// The most recent already-collected value of the definition's <c>WatermarkColumn</c>, fetched + /// by the host from ITS store (Lite: DuckDB; Darling: Postgres) before the query is built. + /// Null when the definition declares no watermark or nothing was collected yet. + /// Settable (not init-only) for one host mutation: definitions with a + /// <c>PerDatabaseWatermarkColumn</c> get this re-fetched per database inside the Azure + /// per-database loop, right before each per-database <c>BuildQuery</c> (see + /// <see cref="CurrentDatabaseName"/> and <see cref="EnumerationProbeResult"/> for the other + /// mid-cycle mutations). + /// </summary> + public DateTime? Watermark { get; set; } + + /// <summary> + /// The database the host's Azure per-database loop is currently reading, set per iteration + /// (null outside that loop — single-connection runs and non-Azure targets). The XE collectors + /// use it as the authoritative <c>database_name</c> for rows read on this path: a + /// database-scoped session only ever captures its own database, so the connection's database IS + /// the ring buffer's database — while the value parsed from the event XML can be missing + /// (older graph shapes), and a row with a null database_name could never advance its + /// database's watermark, re-inserting every cycle until it aged out of the ring buffer. + /// </summary> + public string? CurrentDatabaseName { get; set; } + + /// <summary> + /// The most recent already-collected value of the definition's <c>NumericWatermarkColumn</c>, + /// fetched by the host from ITS store (Lite: DuckDB; Darling: Postgres) before the query is + /// built — the numeric (bigint) twin of <see cref="Watermark"/>, for collectors that dedup on a + /// monotonic identity/sequence column rather than a timestamp (job_history's + /// <c>sysjobhistory.instance_id</c>, a unique monotonic IDENTITY bigint that survives server-side + /// purges). Null when the definition declares no numeric watermark or nothing was collected yet. + /// Purely additive: the timestamp <see cref="Watermark"/> path is unchanged and every existing + /// collector leaves this null. + /// </summary> + public long? NumericWatermark { get; init; } + + /// <summary> + /// Set by the host ONLY when the definition's <c>WatermarkColumn</c> came back null (the hot store is + /// empty): true when a prior SUCCESS row for this collector+server exists in the host's collection_log. + /// Lets a definition whose all-history first-run fallback would re-scan source data already aged out of + /// the hot store (default_trace_events re-reading .trc files whose events are in Lite's parquet archive, + /// which <c>v_default_trace_events</c> UNIONs without dedup) tell a TRUE first run (no prior success → + /// collect all history) from a hot store merely emptied by retention/archival (prior success → a + /// BOUNDED recent window, never all-history). False in the common case — the host computes it only when + /// the watermark is null, and only default_trace_events and job_history consult it. + /// </summary> + public bool HasCollectedBefore { get; init; } + + /// <summary>Wait types excluded from collection (Lite: ignored_wait_types.json — #1240).</summary> + public IReadOnlySet<string> IgnoredWaitTypes { get; init; } = s_emptySet; + + /// <summary>Per-server excluded database names (spliced via <see cref="DatabaseExclusionFilter"/>).</summary> + public IReadOnlyList<string> ExcludedDatabases { get; init; } = System.Array.Empty<string>(); + + /// <summary> + /// When true, the query_stats and query_store collectors capture the execution plan text into + /// their plan column (query_stats.query_plan_xml / query_store_stats.query_plan_text); when + /// false they leave it NULL and the generated SQL is byte-identical to the no-plan form. + /// Default false: Lite deliberately never captures plans (they blew out DuckDB/parquet) and + /// never sets this flag. Darling sets it true — PostgreSQL TOAST compresses the plan text + /// transparently. This is the shared-collector equivalent of the full Dashboard's per-collector + /// <c>config.collection_schedule.collect_plan</c> flag (install/08_collect_query_stats.sql, + /// install/09_collect_query_store.sql). + /// </summary> + public bool CapturePlanXml { get; init; } + + /// <summary> + /// When true (the default — today's behavior), the default_trace_events collector records + /// Object:Created/Altered/Deleted schema-change (DDL) events; when false its + /// <c>@include_object_ddl</c> gate drops that entire slice while leaving every other curated + /// category (file auto-grow/shrink stalls, ErrorLog, security audits, Server Memory Change) + /// untouched. The shared-collector equivalent of the full Dashboard proc's + /// <c>@include_object_events</c> (install/29_collect_default_trace.sql). Default true so Lite is + /// unchanged — it never sets this flag; Darling sets it from darling.json's + /// <c>collectSchemaChangeEvents</c> to silence the flood a create/drop-happy workload produces + /// (e.g. HammerDB's TPC-H Query 15 creates and drops a <c>revenue</c> view thousands of times, + /// and the collector faithfully records every Object:Created/Object:Deleted). + /// </summary> + public bool CollectSchemaChangeEvents { get; init; } = true; + + /// <summary> + /// Host-configured perfmon counter override (Lite: perfmon_counters.json). Null means the + /// definition's curated default list applies. + /// </summary> + public IReadOnlyList<string>? PerfmonCounterOverride { get; init; } + + /// <summary> + /// Result of the definition's enumeration probe (see + /// <c>ICollectorDefinition.BuildEnumerationProbe</c>), set by the host between enumeration + /// and the per-item loop. Null when the definition declares no probe, the probe failed, or + /// it returned SQL NULL — the definition falls back to its documented default (query_store: + /// PRODUCTVERSION 13). One of the context members the host mutates mid-cycle (the others are + /// <see cref="Watermark"/> and <see cref="CurrentDatabaseName"/>, set per database inside the + /// Azure per-database loop). + /// </summary> + public object? EnumerationProbeResult { get; set; } + + /// <summary> + /// Truncation signal for the per-item text-byte budget (#1556), set by a definition's + /// <see cref="ICollectorDefinition{TRow}.ReadItemAsync"/> when it stops reading an enumerated item + /// because its <see cref="ICollectorDefinition{TRow}.PerItemTextByteBudget"/> was reached, and read + /// back by the host to surface the collection WARNING. Written per item (each definition that + /// enforces a budget resets it at the top of its ReadItemAsync), so the host reads it immediately + /// after ReadItemAsync returns. False in the common case — only budgeted collectors touch it. + /// </summary> + public bool PerItemTextBudgetExceeded { get; set; } +} diff --git a/PerformanceMonitor.Collectors/CollectorDefinitionBase.cs b/PerformanceMonitor.Collectors/CollectorDefinitionBase.cs new file mode 100644 index 000000000..3a5a1d951 --- /dev/null +++ b/PerformanceMonitor.Collectors/CollectorDefinitionBase.cs @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Convenience base for collector definitions: the optional contract members default to the +/// common case (no watermark, applies everywhere, single connection, no supplemental query), +/// so a definition overrides only what actually differs. Existing definitions state their +/// choices explicitly via overrides — that explicitness is deliberate. +/// </summary> +public abstract class CollectorDefinitionBase<TRow> : ICollectorDefinition<TRow> +{ + public abstract string Name { get; } + + public abstract string TargetTable { get; } + + public virtual bool IncludesCollectionId => true; + + public virtual string PrefixIdColumnName => "collection_id"; + + public virtual string PrefixTimeColumnName => "collection_time"; + + public virtual int? CommandTimeoutSecondsOverride => null; + + public abstract IReadOnlyList<CollectorColumn> PayloadColumns { get; } + + public virtual string? WatermarkColumn => null; + + public virtual string? NumericWatermarkColumn => null; + + public virtual string? PerDatabaseWatermarkColumn => null; + + public virtual int? PerItemRowCountWarnThreshold => null; + + public virtual int? PerItemTextByteBudget => null; + + public virtual bool AppliesTo(CollectorTargetInfo target) => true; + + public virtual bool RunsPerDatabase(CollectorTargetInfo target) => false; + + public abstract CollectorQuery BuildQuery(CollectorContext context); + + public virtual CollectorQuery? BuildSupplementalQuery(CollectorContext context) => null; + + public virtual ValueTask ApplySupplementalAsync(List<TRow> rows, DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + => ValueTask.CompletedTask; + + public virtual CollectorQuery? BuildEnumerationQuery(CollectorContext context) => null; + + public virtual CollectorQuery? BuildEnumerationProbe(CollectorContext context) => null; + + public virtual CollectorQuery BuildPerItemQuery(string item, CollectorContext context) + => throw new System.NotSupportedException($"{Name} does not enumerate items."); + + public virtual ValueTask ReadItemAsync(string item, DbDataReader reader, List<TRow> rows, CollectorContext context, CancellationToken cancellationToken) + => throw new System.NotSupportedException($"{Name} does not enumerate items."); + + public abstract ValueTask<List<TRow>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken); + + public abstract void WritePayload(TRow row, ICollectorRowWriter writer, CollectorContext context); +} diff --git a/PerformanceMonitor.Collectors/CollectorDeltaCalculator.cs b/PerformanceMonitor.Collectors/CollectorDeltaCalculator.cs new file mode 100644 index 000000000..f4eb1cc78 --- /dev/null +++ b/PerformanceMonitor.Collectors/CollectorDeltaCalculator.cs @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Concurrent; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Calculates delta values for cumulative metrics between collection intervals, caching previous +/// values in memory. The shared implementation of <see cref="ICollectorDeltaCalculator"/> both +/// SKUs run (extracted verbatim from Lite's DeltaCalculator, which now derives from this), so the +/// baseline / counter-reset / gap-policy semantics can never drift between portable Lite and the +/// Darling service. Hosts that survive restarts by re-seeding baselines from their own store call +/// the protected <see cref="Seed"/> (Lite: DuckDB; Darling: Postgres). +/// </summary> +public class CollectorDeltaCalculator : ICollectorDeltaCalculator +{ + /// <summary> + /// Cache structure: serverId -> collectorName -> key -> (previousValue, timestamp) + /// </summary> + private readonly ConcurrentDictionary<int, ConcurrentDictionary<string, ConcurrentDictionary<string, (long Value, DateTime? Timestamp)>>> _cache = new(); + + /// <summary> + /// Removes all cached entries for a server (e.g., when the server tab is closed). + /// Next collection will re-seed from database if needed. + /// </summary> + public void ClearServer(int serverId) + { + _cache.TryRemove(serverId, out _); + } + + /// <summary> + /// Calculates the delta between the current value and the previous cached value. + /// First-ever sighting (no baseline): returns 0 and stores the value as the new baseline. + /// Counter reset (value decreased): returns 0 to avoid inflated deltas from plan cache churn. + /// Gap detection: if collectionTime and maxGapSeconds are provided and the gap since the + /// last cached value exceeds maxGapSeconds, returns 0 to avoid inflated deltas after restarts. + /// Thread-safe via atomic AddOrUpdate. + /// </summary> + public long CalculateDelta(int serverId, string collectorName, string key, long currentValue, + DateTime? collectionTime = null, int maxGapSeconds = 0) + => CalculateDeltaWithInterval(serverId, collectorName, key, currentValue, out _, collectionTime, maxGapSeconds); + + /// <summary> + /// Same as <see cref="CalculateDelta"/>, but also reports the number of seconds between the + /// previous cached collection and the current one via <paramref name="intervalSeconds"/>. + /// The interval is 0 on the first sighting of a key or after a gap reset (no prior baseline to + /// measure against), mirroring the delta's own 0 in those cases. Callers can divide a delta by + /// this interval to derive a per-second rate (e.g. CPU-ms per wall-clock second). + /// Thread-safe via atomic AddOrUpdate. + /// </summary> + public long CalculateDeltaWithInterval(int serverId, string collectorName, string key, long currentValue, + out int intervalSeconds, DateTime? collectionTime = null, int maxGapSeconds = 0) + { + var serverCache = _cache.GetOrAdd(serverId, _ => new ConcurrentDictionary<string, ConcurrentDictionary<string, (long Value, DateTime? Timestamp)>>()); + var collectorCache = serverCache.GetOrAdd(collectorName, _ => new ConcurrentDictionary<string, (long Value, DateTime? Timestamp)>()); + + long delta = 0; + int interval = 0; + + collectorCache.AddOrUpdate( + key, + /* Add: first time seeing this key — store the baseline only and return 0. + All callers track cumulative counters (perfmon, wait stats, file IO, etc.). */ + _ => + { + delta = 0; + interval = 0; + return (currentValue, collectionTime); + }, + /* Update: compute delta atomically */ + (_, previous) => + { + /* Gap detection: if too much time has passed since the last cached value, + treat this as a new baseline to avoid inflated deltas after app restarts */ + if (maxGapSeconds > 0 && collectionTime.HasValue && previous.Timestamp.HasValue + && (collectionTime.Value - previous.Timestamp.Value).TotalSeconds > maxGapSeconds) + { + delta = 0; + interval = 0; + return (currentValue, collectionTime); + } + + /* Seconds between the previous and current collection, when both timestamps exist — + the wall-clock span this delta accrued over. */ + interval = (collectionTime.HasValue && previous.Timestamp.HasValue) + ? (int)(collectionTime.Value - previous.Timestamp.Value).TotalSeconds + : 0; + + delta = currentValue < previous.Value + ? 0 /* counter reset (plan cache eviction/re-entry) — not real new work */ + : currentValue - previous.Value; + return (currentValue, collectionTime); + }); + + intervalSeconds = interval; + return delta; + } + + /// <summary> + /// Seeds a single value into the cache without computing a delta — the restart-survival hook + /// hosts use to restore baselines from their own store. + /// </summary> + protected void Seed(int serverId, string collectorName, string key, long value, DateTime? timestamp = null) + { + var serverCache = _cache.GetOrAdd(serverId, _ => new ConcurrentDictionary<string, ConcurrentDictionary<string, (long Value, DateTime? Timestamp)>>()); + var collectorCache = serverCache.GetOrAdd(collectorName, _ => new ConcurrentDictionary<string, (long Value, DateTime? Timestamp)>()); + collectorCache[key] = (value, timestamp); + } +} diff --git a/PerformanceMonitor.Collectors/CollectorQuery.cs b/PerformanceMonitor.Collectors/CollectorQuery.cs new file mode 100644 index 000000000..308120bcd --- /dev/null +++ b/PerformanceMonitor.Collectors/CollectorQuery.cs @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; + +namespace PerformanceMonitor.Collectors; + +/// <summary>Parameter types a collector query can bind — grown as the sweep demands.</summary> +public enum CollectorParameterType +{ + DateTime2, + NVarChar128, + Int32, + BigInt, +} + +/// <summary>One bound parameter of a collector query.</summary> +public sealed record CollectorParameter(string Name, object? Value, CollectorParameterType Type); + +/// <summary> +/// The query a definition builds for one collection cycle: T-SQL text plus bound parameters. +/// Most definitions return a constant text with no parameters; target-aware definitions +/// (e.g. cpu_utilization's Azure-vs-ring-buffer fork) select text and bind values per cycle. +/// </summary> +public sealed class CollectorQuery +{ + public CollectorQuery(string text, IReadOnlyList<CollectorParameter>? parameters = null) + { + Text = text; + Parameters = parameters ?? Array.Empty<CollectorParameter>(); + } + + public string Text { get; } + + public IReadOnlyList<CollectorParameter> Parameters { get; } +} diff --git a/PerformanceMonitor.Collectors/CollectorScheduleDefaults.cs b/PerformanceMonitor.Collectors/CollectorScheduleDefaults.cs new file mode 100644 index 000000000..352f5c3f3 --- /dev/null +++ b/PerformanceMonitor.Collectors/CollectorScheduleDefaults.cs @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// The default per-collector cadence and retention — the shared source both SKUs schedule by, +/// so portable Lite and the Darling service collect on identical rhythms out of the box. Lite's +/// ScheduleManager carries the same table (user-editable per install) and an identity-pin test +/// asserts the two cannot drift; Darling consumes this directly (no schedule knobs until someone +/// needs them — defaults over speculative config). FrequencyMinutes 0 = collect once on server +/// load only (config snapshots). +/// </summary> +public static class CollectorScheduleDefaults +{ + /// <summary> + /// Per-collector cadence + retention, plus the collector's default enabled state. Nearly every + /// collector ships ENABLED (<see cref="DefaultEnabled"/> = true); a collector that must ship OFF + /// and be opted into (long_query_completions — a completion trace is not free on a busy server, + /// #1496) sets it false. Both SKUs consult this: Lite's ScheduleManager seeds its per-install + /// enabled flag from it, and Darling's <c>StoreConfigProvider.ResolveSchedule</c> falls back to it + /// when no <c>config_collector_schedules</c> override row exists — so "reset to defaults" (which + /// deletes override rows) returns a default-off collector to OFF, not ON. + /// </summary> + public sealed record Entry(int FrequencyMinutes, int RetentionDays, bool DefaultEnabled = true); + + public static IReadOnlyDictionary<string, Entry> All { get; } = new Dictionary<string, Entry>(StringComparer.OrdinalIgnoreCase) + { + ["wait_stats"] = new(1, 30), + ["latch_stats"] = new(1, 30), + ["spinlock_stats"] = new(1, 30), + ["cpu_scheduler_stats"] = new(1, 30), + ["plan_cache_stats"] = new(5, 30), + ["query_stats"] = new(1, 30), + ["procedure_stats"] = new(1, 30), + ["query_store"] = new(5, 30), + ["query_snapshots"] = new(1, 7), + ["cpu_utilization"] = new(1, 30), + ["file_io_stats"] = new(1, 30), + ["memory_stats"] = new(1, 30), + ["memory_clerks"] = new(5, 30), + ["memory_pressure_events"] = new(5, 30), + ["tempdb_stats"] = new(1, 30), + ["perfmon_stats"] = new(1, 30), + ["deadlocks"] = new(1, 30), + ["server_config"] = new(0, 30), + ["database_config"] = new(0, 30), + ["memory_grant_stats"] = new(1, 30), + ["waiting_tasks"] = new(1, 7), + ["dmv_blocking_snapshot"] = new(1, 30), + ["blocked_process_report"] = new(1, 30), + /* #1496 long-running query completion trace: seeded DISABLED (DefaultEnabled: false). A + completion trace (rpc_completed/sql_batch_completed) fires per statement/batch even though + the duration predicate discards most of them, so it is opt-in per fleet; enabling it creates + the XE session on the monitored servers and disabling it DROPS the session there. Cadence + + retention mirror the sibling XE collectors. */ + ["long_query_completions"] = new(1, 30, DefaultEnabled: false), + ["database_scoped_config"] = new(0, 30), + ["trace_flags"] = new(0, 30), + ["running_jobs"] = new(5, 7), + ["database_size_stats"] = new(60, 90), + ["index_object_stats"] = new(1440, 90), + ["server_properties"] = new(0, 365), + ["session_stats"] = new(5, 30), + ["session_summary_stats"] = new(5, 30), + ["system_health_events"] = new(5, 30), + ["default_trace_events"] = new(5, 30), + ["job_history"] = new(5, 365), + ["agent_status"] = new(5, 7), + }; +} diff --git a/PerformanceMonitor.Collectors/CollectorTargetInfo.cs b/PerformanceMonitor.Collectors/CollectorTargetInfo.cs new file mode 100644 index 000000000..e9283e37d --- /dev/null +++ b/PerformanceMonitor.Collectors/CollectorTargetInfo.cs @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// What a definition may need to know about the monitored server to build its query. +/// Grown deliberately as the sweep demands (engine edition today; version gates arrive with +/// the collectors that need them) — every added field is parity-critical target logic. +/// </summary> +public sealed class CollectorTargetInfo +{ + /// <summary>True when the target is Azure SQL Database (engine edition 5).</summary> + public bool IsAzureSqlDb { get; init; } + + /// <summary>True when the target is Azure SQL Managed Instance (engine edition 8).</summary> + public bool IsAzureManagedInstance { get; init; } + + /// <summary> + /// True when the target is an Amazon RDS for SQL Server instance (detected via + /// <c>DB_ID('rdsadmin') IS NOT NULL</c>). RDS does not expose the underlying OS, so DMVs that + /// read OS/service state — notably <c>sys.dm_server_services</c> (used by agent_status) — and the + /// restricted msdb surface running_jobs needs (<c>msdb.dbo.syssessions</c>) are unavailable there. + /// Definitions gate those collectors off via <see cref="AppliesTo"/> so both hosts skip them. + /// </summary> + public bool IsAwsRds { get; init; } + + /// <summary> + /// SQL Server major version (13 = 2016 … 17 = 2025); 0 when unknown. Definitions gate + /// version-specific columns on this (database_config treats 0 as "assume newest" to match + /// the original collector). + /// </summary> + public int SqlMajorVersion { get; init; } + + /// <summary> + /// True when the monitored login can read msdb (<c>HAS_DBACCESS('msdb') = 1</c>). The SQL-Agent + /// collectors — running_jobs, job_history, agent_status — read <c>msdb.dbo.sysjobs</c>, + /// <c>sysjobhistory</c>, <c>sysjobschedules</c>, etc., so each gates off via <see cref="AppliesTo"/> + /// when this is false; a login without msdb access would otherwise fail every cycle (error 229/916) + /// and pollute collection-health. Both hosts probe this (Lite's ServerManager and Darling's + /// DarlingServerConnector, verbatim <c>HAS_DBACCESS(N'msdb')</c>) and wire it in here. + /// <para>Defaults to <c>true</c> so a target the probe never classified (the SqlMajorVersion == 0 / + /// unknown path, and every bare <c>new CollectorTargetInfo()</c>) still attempts the Agent + /// collectors — matching the probe's own NULL-means-assume-access default, so "unknown" never + /// silently gates collection off.</para> + /// </summary> + public bool HasMsdbAccess { get; init; } = true; +} diff --git a/PerformanceMonitor.Collectors/CpuSchedulerStatsCollector.cs b/PerformanceMonitor.Collectors/CpuSchedulerStatsCollector.cs new file mode 100644 index 000000000..594df0019 --- /dev/null +++ b/PerformanceMonitor.Collectors/CpuSchedulerStatsCollector.cs @@ -0,0 +1,375 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// CPU scheduler + workload-group + NUMA + OS-memory pressure snapshot (the Dashboard-parity port +/// of install/17_collect_cpu_scheduler_stats.sql). A POINT-IN-TIME snapshot, not a cumulative-counter +/// delta collector: it reads the current worker/runnable/blocked/queued task counts, worker-thread +/// exhaustion, NUMA online/offline, and OS memory pressure directly — no delta groups (mirrors +/// <see cref="MemoryStatsCollector"/> / <see cref="SessionStatsCollector"/>). The query aggregates +/// sys.dm_os_schedulers (VISIBLE ONLINE) with single-row CROSS JOINs to sys.dm_os_sys_info, +/// sys.dm_os_sys_memory, a sys.dm_os_nodes NUMA rollup, and a +/// sys.dm_resource_governor_workload_groups rollup, plus an OUTER APPLY over sys.dm_exec_requests — +/// collapsing to exactly one row. The pressure warnings (worker-thread exhaustion, runnable/blocked/ +/// queued, physical-memory, offline-CPU) are the Dashboard proc's own CASE expressions, kept in SQL +/// so the stored columns are value-identical to the Dashboard table; they are CONVERT(bit, ...) so +/// the reader gets a clean boolean. The Dashboard proc's collection_log / critical_issues logging +/// and RAISERROR debug output are the collector framework's job and are intentionally dropped. +/// +/// <para>Does NOT apply to Azure SQL Database (edition 5): sys.dm_os_sys_memory is not exposed there +/// (MS Learn "Applies to" omits Azure SQL DB — only SQL Server + Managed Instance), so the query +/// would fail outright; and sys.dm_exec_requests is limited to the current connection on Azure SQL DB +/// (VIEW SERVER STATE cannot be granted), which would make the runnable/total request counts +/// meaningless even if the memory DMV existed. Both are documented, verified gaps — not a scope-down. +/// Gated exactly like <see cref="MemoryPressureEventsCollector"/>. Azure SQL Managed Instance +/// (edition 8) exposes all of these DMVs, so it collects there (AppliesTo = !IsAzureSqlDb).</para> +/// </summary> +public sealed class CpuSchedulerStatsCollector : CollectorDefinitionBase<CpuSchedulerStatsCollector.Row> +{ + public static CpuSchedulerStatsCollector Instance { get; } = new(); + + private CpuSchedulerStatsCollector() + { + } + + public readonly record struct Row( + int MaxWorkersCount, + int SchedulerCount, + int CpuCount, + int TotalRunnableTasksCount, + long TotalWorkQueueCount, + int TotalCurrentWorkersCount, + decimal AvgRunnableTasksCount, + int TotalActiveRequestCount, + int TotalQueuedRequestCount, + int TotalBlockedTaskCount, + long TotalActiveParallelThreadCount, + int? RunnableRequestCount, + int? TotalRequestCount, + decimal? RunnablePercent, + bool WorkerThreadExhaustionWarning, + bool RunnableTasksWarning, + bool BlockedTasksWarning, + bool QueuedRequestsWarning, + long TotalPhysicalMemoryKb, + long AvailablePhysicalMemoryKb, + string? SystemMemoryStateDesc, + bool PhysicalMemoryPressureWarning, + int TotalNodeCount, + int NodesOnlineCount, + int OfflineCpuCount, + bool OfflineCpuWarning); + + /* Ported verbatim from install/17_collect_cpu_scheduler_stats.sql's SELECT. The only additions + over the proc body are explicit output-type CONVERTs the proc relied on the INSERT to do + implicitly (COUNT_BIG -> integer for total_request_count/total_node_count; the 1/0 CASE + results -> bit for the warnings; AVG(...) -> decimal(38,2)) so the positional reader gets the + exact storage types. Values stored are identical to the Dashboard table. */ + private const string QueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + max_workers_count = osi.max_workers_count, + scheduler_count = osi.scheduler_count, + cpu_count = osi.cpu_count, + total_runnable_tasks_count = SUM(dos.runnable_tasks_count), + total_work_queue_count = SUM(dos.work_queue_count), + total_current_workers_count = SUM(dos.current_workers_count), + avg_runnable_tasks_count = CONVERT(decimal(38,2), AVG(CONVERT(decimal(38,2), dos.runnable_tasks_count))), + total_active_request_count = MAX(wg.active_request_count), + total_queued_request_count = MAX(wg.queued_request_count), + total_blocked_task_count = MAX(wg.blocked_task_count), + total_active_parallel_thread_count = MAX(wg.active_parallel_thread_count), + runnable_request_count = r.runnable_count, + total_request_count = CONVERT(integer, r.total_count), + runnable_percent = r.runnable_pct, + worker_thread_exhaustion_warning = + CONVERT + ( + bit, + CASE + WHEN SUM(dos.current_workers_count) >= (osi.max_workers_count * 0.90) + THEN 1 + ELSE 0 + END + ), + runnable_tasks_warning = + CONVERT + ( + bit, + CASE + WHEN SUM(dos.runnable_tasks_count) >= osi.cpu_count + THEN 1 + ELSE 0 + END + ), + blocked_tasks_warning = + CONVERT + ( + bit, + CASE + WHEN MAX(wg.blocked_task_count) >= 10 + THEN 1 + ELSE 0 + END + ), + queued_requests_warning = + CONVERT + ( + bit, + CASE + WHEN MAX(wg.queued_request_count) >= osi.cpu_count + THEN 1 + ELSE 0 + END + ), + total_physical_memory_kb = osm.total_physical_memory_kb, + available_physical_memory_kb = osm.available_physical_memory_kb, + system_memory_state_desc = osm.system_memory_state_desc, + physical_memory_pressure_warning = + CONVERT + ( + bit, + CASE + WHEN osm.available_physical_memory_kb < (osm.total_physical_memory_kb * 0.10) + THEN 1 + ELSE 0 + END + ), + total_node_count = CONVERT(integer, nodes.total_nodes), + nodes_online_count = nodes.online_nodes, + offline_cpu_count = nodes.offline_cpus, + offline_cpu_warning = + CONVERT + ( + bit, + CASE + WHEN + ( + SELECT COUNT_BIG(*) + FROM sys.dm_os_schedulers AS dos_offline + WHERE dos_offline.is_online = 0 + ) > 0 + THEN 1 + ELSE 0 + END + ) +FROM sys.dm_os_schedulers AS dos +CROSS JOIN sys.dm_os_sys_info AS osi +CROSS JOIN sys.dm_os_sys_memory AS osm +CROSS JOIN +( + SELECT + total_nodes = COUNT_BIG(*), + online_nodes = + SUM + ( + CASE + WHEN n.node_state_desc = N'ONLINE' + THEN 1 + ELSE 0 + END + ), + offline_cpus = + SUM + ( + CASE + WHEN n.node_state_desc <> N'ONLINE' + THEN 1 + ELSE 0 + END + ) + FROM sys.dm_os_nodes AS n + WHERE n.node_id <> 32767 /*Exclude DAC node*/ +) AS nodes +CROSS JOIN +( + SELECT + active_request_count = SUM(wg.active_request_count), + queued_request_count = SUM(wg.queued_request_count), + blocked_task_count = SUM(wg.blocked_task_count), + active_parallel_thread_count = SUM(wg.active_parallel_thread_count) + FROM sys.dm_resource_governor_workload_groups AS wg +) AS wg +OUTER APPLY +( + SELECT + total_count = COUNT_BIG(*), + runnable_count = + SUM + ( + CASE + WHEN der.status = N'runnable' + THEN 1 + ELSE 0 + END + ), + runnable_pct = + CONVERT + ( + decimal(38,2), + ( + SUM + ( + CASE + WHEN der.status = N'runnable' + THEN 1.0 + ELSE 0.0 + END + ) / + NULLIF(CONVERT(decimal(38,2), COUNT_BIG(*)), 0) + ) * 100.0 + ) + FROM sys.dm_exec_requests AS der + WHERE der.session_id > 50 + AND der.session_id <> @@SPID + AND der.status NOT IN (N'background', N'sleeping') +) AS r +WHERE dos.status = N'VISIBLE ONLINE' +GROUP BY + osi.max_workers_count, + osi.scheduler_count, + osi.cpu_count, + r.runnable_count, + r.total_count, + r.runnable_pct, + osm.total_physical_memory_kb, + osm.available_physical_memory_kb, + osm.system_memory_state_desc, + nodes.total_nodes, + nodes.online_nodes, + nodes.offline_cpus +OPTION(RECOMPILE);"; + + public override string Name => "cpu_scheduler_stats"; + + public override string TargetTable => "cpu_scheduler_stats"; + + public override string? WatermarkColumn => null; + + /// <summary> + /// Skips Azure SQL DB: sys.dm_os_sys_memory is not exposed there and sys.dm_exec_requests is + /// limited to the current connection (both MS Learn-verified). Collects on SQL Server and Azure + /// SQL Managed Instance, which expose the full DMV set. Mirrors MemoryPressureEventsCollector. + /// </summary> + public override bool AppliesTo(CollectorTargetInfo target) => !target.IsAzureSqlDb; + + public override bool RunsPerDatabase(CollectorTargetInfo target) => false; + + public override CollectorQuery BuildQuery(CollectorContext context) => new(QueryText); + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("max_workers_count", CollectorColumnType.Integer), + new CollectorColumn("scheduler_count", CollectorColumnType.Integer), + new CollectorColumn("cpu_count", CollectorColumnType.Integer), + new CollectorColumn("total_runnable_tasks_count", CollectorColumnType.Integer), + new CollectorColumn("total_work_queue_count", CollectorColumnType.BigInt), + new CollectorColumn("total_current_workers_count", CollectorColumnType.Integer), + new CollectorColumn("avg_runnable_tasks_count", CollectorColumnType.Decimal, 38, 2), + new CollectorColumn("total_active_request_count", CollectorColumnType.Integer), + new CollectorColumn("total_queued_request_count", CollectorColumnType.Integer), + new CollectorColumn("total_blocked_task_count", CollectorColumnType.Integer), + new CollectorColumn("total_active_parallel_thread_count", CollectorColumnType.BigInt), + new CollectorColumn("runnable_request_count", CollectorColumnType.Integer), + new CollectorColumn("total_request_count", CollectorColumnType.Integer), + new CollectorColumn("runnable_percent", CollectorColumnType.Decimal, 38, 2), + new CollectorColumn("worker_thread_exhaustion_warning", CollectorColumnType.Boolean), + new CollectorColumn("runnable_tasks_warning", CollectorColumnType.Boolean), + new CollectorColumn("blocked_tasks_warning", CollectorColumnType.Boolean), + new CollectorColumn("queued_requests_warning", CollectorColumnType.Boolean), + new CollectorColumn("total_physical_memory_kb", CollectorColumnType.BigInt), + new CollectorColumn("available_physical_memory_kb", CollectorColumnType.BigInt), + new CollectorColumn("system_memory_state_desc", CollectorColumnType.Varchar), + new CollectorColumn("physical_memory_pressure_warning", CollectorColumnType.Boolean), + new CollectorColumn("total_node_count", CollectorColumnType.Integer), + new CollectorColumn("nodes_online_count", CollectorColumnType.Integer), + new CollectorColumn("offline_cpu_count", CollectorColumnType.Integer), + new CollectorColumn("offline_cpu_warning", CollectorColumnType.Boolean), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var rows = new List<Row>(); + + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new Row( + MaxWorkersCount: reader.GetInt32(0), + SchedulerCount: reader.GetInt32(1), + CpuCount: reader.GetInt32(2), + TotalRunnableTasksCount: reader.GetInt32(3), + TotalWorkQueueCount: reader.GetInt64(4), + TotalCurrentWorkersCount: reader.GetInt32(5), + AvgRunnableTasksCount: reader.GetDecimal(6), + TotalActiveRequestCount: reader.GetInt32(7), + TotalQueuedRequestCount: reader.GetInt32(8), + TotalBlockedTaskCount: reader.GetInt32(9), + TotalActiveParallelThreadCount: reader.GetInt64(10), + /* r is an OUTER APPLY over an aggregate: total_count is COUNT_BIG (0 when no + qualifying requests) but runnable_count/runnable_pct are SUM/ratio over an empty + set = NULL — nullable, matching the Dashboard columns. */ + RunnableRequestCount: reader.IsDBNull(11) ? null : reader.GetInt32(11), + TotalRequestCount: reader.IsDBNull(12) ? null : reader.GetInt32(12), + RunnablePercent: reader.IsDBNull(13) ? null : reader.GetDecimal(13), + WorkerThreadExhaustionWarning: reader.GetBoolean(14), + RunnableTasksWarning: reader.GetBoolean(15), + BlockedTasksWarning: reader.GetBoolean(16), + QueuedRequestsWarning: reader.GetBoolean(17), + TotalPhysicalMemoryKb: reader.GetInt64(18), + AvailablePhysicalMemoryKb: reader.GetInt64(19), + SystemMemoryStateDesc: reader.IsDBNull(20) ? null : reader.GetString(20), + PhysicalMemoryPressureWarning: reader.GetBoolean(21), + TotalNodeCount: reader.GetInt32(22), + NodesOnlineCount: reader.GetInt32(23), + OfflineCpuCount: reader.GetInt32(24), + OfflineCpuWarning: reader.GetBoolean(25))); + } + + return rows; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + writer + .Value(row.MaxWorkersCount) /* max_workers_count INTEGER */ + .Value(row.SchedulerCount) /* scheduler_count INTEGER */ + .Value(row.CpuCount) /* cpu_count INTEGER */ + .Value(row.TotalRunnableTasksCount) /* total_runnable_tasks_count INTEGER */ + .Value(row.TotalWorkQueueCount) /* total_work_queue_count BIGINT */ + .Value(row.TotalCurrentWorkersCount) /* total_current_workers_count INTEGER */ + .Value(row.AvgRunnableTasksCount) /* avg_runnable_tasks_count DECIMAL(38,2) */ + .Value(row.TotalActiveRequestCount) /* total_active_request_count INTEGER */ + .Value(row.TotalQueuedRequestCount) /* total_queued_request_count INTEGER */ + .Value(row.TotalBlockedTaskCount) /* total_blocked_task_count INTEGER */ + .Value(row.TotalActiveParallelThreadCount) /* total_active_parallel_thread_count BIGINT */ + .Value(row.RunnableRequestCount) /* runnable_request_count INTEGER (nullable) */ + .Value(row.TotalRequestCount) /* total_request_count INTEGER (nullable) */ + .Value(row.RunnablePercent) /* runnable_percent DECIMAL(38,2) (nullable) */ + .Value(row.WorkerThreadExhaustionWarning) /* worker_thread_exhaustion_warning BOOLEAN */ + .Value(row.RunnableTasksWarning) /* runnable_tasks_warning BOOLEAN */ + .Value(row.BlockedTasksWarning) /* blocked_tasks_warning BOOLEAN */ + .Value(row.QueuedRequestsWarning) /* queued_requests_warning BOOLEAN */ + .Value(row.TotalPhysicalMemoryKb) /* total_physical_memory_kb BIGINT */ + .Value(row.AvailablePhysicalMemoryKb) /* available_physical_memory_kb BIGINT */ + .Value(row.SystemMemoryStateDesc) /* system_memory_state_desc VARCHAR (nullable) */ + .Value(row.PhysicalMemoryPressureWarning) /* physical_memory_pressure_warning BOOLEAN */ + .Value(row.TotalNodeCount) /* total_node_count INTEGER */ + .Value(row.NodesOnlineCount) /* nodes_online_count INTEGER */ + .Value(row.OfflineCpuCount) /* offline_cpu_count INTEGER */ + .Value(row.OfflineCpuWarning); /* offline_cpu_warning BOOLEAN */ + } +} diff --git a/PerformanceMonitor.Collectors/CpuUtilizationCollector.cs b/PerformanceMonitor.Collectors/CpuUtilizationCollector.cs new file mode 100644 index 000000000..94c25afa8 --- /dev/null +++ b/PerformanceMonitor.Collectors/CpuUtilizationCollector.cs @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// CPU utilization — ring buffer (on-prem, MI, RDS) or sys.dm_db_resource_stats (Azure SQL DB). +/// Extracted verbatim from Lite's RemoteCollectorService.Cpu.cs, including the platform rules +/// that are the parity contract: Linux stores NULL other-process CPU because SystemIdle is +/// always 0 there (#1048); incomplete SystemHealth ring-buffer records are skipped (#989); +/// Azure filters server-side on the watermark while the ring buffer dedups client-side +/// (its sample_time is computed and cannot be filtered in SQL). +/// </summary> +public sealed class CpuUtilizationCollector : CollectorDefinitionBase<CpuUtilizationCollector.Row> +{ + public static CpuUtilizationCollector Instance { get; } = new(); + + private CpuUtilizationCollector() + { + } + + public readonly record struct Row(DateTime SampleTime, int SqlServerCpuUtilization, int? OtherProcessCpuUtilization); + + private const string AzureSqlDbQueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT TOP (60) + sample_time = drs.end_time, + sqlserver_cpu_utilization = CONVERT(integer, drs.avg_cpu_percent), + other_process_cpu_utilization = 0 +FROM sys.dm_db_resource_stats AS drs +ORDER BY + drs.end_time DESC +OPTION(RECOMPILE);"; + + private const string AzureSqlDbWatermarkedQueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + sample_time = drs.end_time, + sqlserver_cpu_utilization = CONVERT(integer, drs.avg_cpu_percent), + other_process_cpu_utilization = 0 +FROM sys.dm_db_resource_stats AS drs +WHERE drs.end_time > @last_sample_time +ORDER BY + drs.end_time DESC +OPTION(RECOMPILE);"; + + private const string RingBufferQueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +DECLARE + @ms_ticks bigint, + @start_time datetime2(7), + @is_linux bit = 0; + +SELECT + @ms_ticks = dosi.ms_ticks, + @start_time = dosi.sqlserver_start_time +FROM sys.dm_os_sys_info AS dosi; + +/* Detect SQL Server on Linux. SystemIdle is always 0 in the SCHEDULER_MONITOR + ring buffer on Linux, so 100 - SystemIdle - ProcessUtilization fabricates a + host figure that pins total CPU at 100% (Issue #1048). No DMV exposes true host + CPU on Linux, so other_process is stored as NULL there. sys.dm_os_host_info is + 2017+; referenced via sp_executesql so SQL 2016 never binds it (@is_linux = 0). */ +IF OBJECT_ID(N'sys.dm_os_host_info', N'V') IS NOT NULL + EXEC sys.sp_executesql + N'SELECT @linux = CASE WHEN hi.host_platform = N''Linux'' THEN 1 ELSE 0 END FROM sys.dm_os_host_info AS hi;', + N'@linux bit OUTPUT', @linux = @is_linux OUTPUT; + +SELECT TOP (60) + sample_time = DATEADD(SECOND, -((@ms_ticks - t.timestamp) / 1000), SYSDATETIME()), + sqlserver_cpu_utilization = x.process_utilization, + other_process_cpu_utilization = + CASE + WHEN @is_linux = 1 + THEN NULL + WHEN (100 - x.system_idle - x.process_utilization) < 0 + THEN 0 + ELSE 100 - x.system_idle - x.process_utilization + END +FROM +( + SELECT + dorb.timestamp, + record = CONVERT(xml, dorb.record) + FROM sys.dm_os_ring_buffers AS dorb + WHERE dorb.ring_buffer_type = N'RING_BUFFER_SCHEDULER_MONITOR' +) AS t +CROSS APPLY +( + SELECT + process_utilization = t.record.value('(Record/SchedulerMonitorEvent/SystemHealth/ProcessUtilization)[1]', 'integer'), + system_idle = t.record.value('(Record/SchedulerMonitorEvent/SystemHealth/SystemIdle)[1]', 'integer') +) AS x +/* Skip ring-buffer records lacking a complete SystemHealth block — their + XML values extract as NULL and would store NULL samples (Issue #989). */ +WHERE x.process_utilization IS NOT NULL +AND x.system_idle IS NOT NULL +ORDER BY t.timestamp DESC +OPTION(RECOMPILE);"; + + public override string Name => "cpu_utilization"; + + public override string TargetTable => "cpu_utilization_stats"; + + public override string? WatermarkColumn => "sample_time"; + + public override bool AppliesTo(CollectorTargetInfo target) => true; + + public override bool RunsPerDatabase(CollectorTargetInfo target) => false; + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("sample_time", CollectorColumnType.Timestamp), + new CollectorColumn("sqlserver_cpu_utilization", CollectorColumnType.Integer), + new CollectorColumn("other_process_cpu_utilization", CollectorColumnType.Integer), + }; + + public override CollectorQuery BuildQuery(CollectorContext context) + { + if (context.Target.IsAzureSqlDb && context.Watermark.HasValue) + { + /* Azure SQL DB: end_time is a real column, so the watermark filters server-side. */ + return new CollectorQuery( + AzureSqlDbWatermarkedQueryText, + new[] { new CollectorParameter("@last_sample_time", context.Watermark.Value, CollectorParameterType.DateTime2) }); + } + + return context.Target.IsAzureSqlDb + ? new CollectorQuery(AzureSqlDbQueryText) + : new CollectorQuery(RingBufferQueryText); + } + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var rows = new List<Row>(); + + while (await reader.ReadAsync(cancellationToken)) + { + var sampleTime = reader.GetDateTime(0); + + /* Client-side dedup for the ring buffer (computed sample_time can't be filtered in SQL). */ + if (!context.Target.IsAzureSqlDb && context.Watermark.HasValue && sampleTime <= context.Watermark.Value) + { + continue; + } + + rows.Add(new Row( + sampleTime, + reader.IsDBNull(1) ? 0 : reader.GetInt32(1), + /* NULL = host/other CPU not derivable (SQL on Linux, Issue #1048) */ + reader.IsDBNull(2) ? (int?)null : reader.GetInt32(2))); + } + + return rows; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + writer + .Value(row.SampleTime) /* sample_time TIMESTAMP */ + .Value(row.SqlServerCpuUtilization) /* sqlserver_cpu_utilization INTEGER */ + .Value(row.OtherProcessCpuUtilization); /* other_process_cpu_utilization INTEGER (nullable) */ + } +} diff --git a/PerformanceMonitor.Collectors/DatabaseConfigCollector.cs b/PerformanceMonitor.Collectors/DatabaseConfigCollector.cs new file mode 100644 index 000000000..19f3b019e --- /dev/null +++ b/PerformanceMonitor.Collectors/DatabaseConfigCollector.cs @@ -0,0 +1,263 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Per-database configuration from sys.databases (on-load only). Extracted verbatim from Lite's +/// RemoteCollectorService.ServerConfig.cs, including the version gates: the 2019 columns +/// (accelerated database recovery, memory-optimized) are selected when major version ≥ 15, +/// version unknown (0 = assume newest), or Azure (SQL DB edition 5 / MI edition 8); the 2025 +/// column (optimized locking) when major ≥ 17. Ungated columns write NULL, matching the schema. +/// </summary> +public sealed class DatabaseConfigCollector : CollectorDefinitionBase<DatabaseConfigCollector.Row> +{ + public static DatabaseConfigCollector Instance { get; } = new(); + + private DatabaseConfigCollector() + { + } + + public sealed class Row + { + public string DbName { get; set; } = ""; + public string? StateDesc { get; set; } + public int CompatLevel { get; set; } + public string? CollationName { get; set; } + public string? RecoveryModel { get; set; } + public bool IsReadOnly { get; set; } + public bool AutoClose { get; set; } + public bool AutoShrink { get; set; } + public bool AutoCreateStats { get; set; } + public bool AutoUpdateStats { get; set; } + public bool AutoUpdateStatsAsync { get; set; } + public bool Rcsi { get; set; } + public string? SnapshotIsolation { get; set; } + public bool ParameterizationForced { get; set; } + public bool QueryStore { get; set; } + public bool Encrypted { get; set; } + public bool Trustworthy { get; set; } + public bool DbChaining { get; set; } + public bool BrokerEnabled { get; set; } + public bool CdcEnabled { get; set; } + public bool MixedPageAllocation { get; set; } + public string? LogReuseWait { get; set; } + public string? PageVerify { get; set; } + public int TargetRecovery { get; set; } + public string? DelayedDurability { get; set; } + public bool? AcceleratedDatabaseRecovery { get; set; } + public bool? MemoryOptimized { get; set; } + public bool? OptimizedLocking { get; set; } + } + + public override string Name => "database_config"; + + public override string TargetTable => "database_config"; + + /// <summary>The config snapshots' prefix is config_id/capture_time in Lite's schema; Darling mirrors it.</summary> + public override string PrefixIdColumnName => "config_id"; + + public override string PrefixTimeColumnName => "capture_time"; + + public override CollectorQuery BuildQuery(CollectorContext context) + { + var (has2019Columns, has2025Columns) = VersionGates(context.Target); + + /* Base columns available on all supported versions (2016+) */ + var selectColumns = @" + database_name = d.name, + state_desc = d.state_desc, + compatibility_level = d.compatibility_level, + collation_name = d.collation_name, + recovery_model = d.recovery_model_desc, + is_read_only = d.is_read_only, + is_auto_close_on = d.is_auto_close_on, + is_auto_shrink_on = d.is_auto_shrink_on, + is_auto_create_stats_on = d.is_auto_create_stats_on, + is_auto_update_stats_on = d.is_auto_update_stats_on, + is_auto_update_stats_async_on = d.is_auto_update_stats_async_on, + is_read_committed_snapshot_on = d.is_read_committed_snapshot_on, + snapshot_isolation_state = d.snapshot_isolation_state_desc, + is_parameterization_forced = d.is_parameterization_forced, + is_query_store_on = d.is_query_store_on, + is_encrypted = d.is_encrypted, + is_trustworthy_on = d.is_trustworthy_on, + is_db_chaining_on = d.is_db_chaining_on, + is_broker_enabled = d.is_broker_enabled, + is_cdc_enabled = d.is_cdc_enabled, + is_mixed_page_allocation_on = d.is_mixed_page_allocation_on, + log_reuse_wait_desc = d.log_reuse_wait_desc, + page_verify_option = d.page_verify_option_desc, + target_recovery_time_seconds = d.target_recovery_time_in_seconds, + delayed_durability = d.delayed_durability_desc"; + + if (has2019Columns) + { + selectColumns += @", + is_accelerated_database_recovery_on = d.is_accelerated_database_recovery_on, + is_memory_optimized_enabled = d.is_memory_optimized_enabled"; + } + + if (has2025Columns) + { + selectColumns += @", + is_optimized_locking_on = d.is_optimized_locking_on"; + } + + var (exclusionClause, exclusionParameters) = DatabaseExclusionFilter.Build(context.ExcludedDatabases, "d.name"); + + var query = $@" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT +{selectColumns} +FROM sys.databases AS d +WHERE (d.database_id > 4 OR d.database_id = 2) +AND d.database_id < 32761 +AND d.name <> N'PerformanceMonitor' +{exclusionClause} +ORDER BY d.name +OPTION(RECOMPILE);"; + + return new CollectorQuery(query, exclusionParameters); + } + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("database_name", CollectorColumnType.Varchar), + new CollectorColumn("state_desc", CollectorColumnType.Varchar), + new CollectorColumn("compatibility_level", CollectorColumnType.Integer), + new CollectorColumn("collation_name", CollectorColumnType.Varchar), + new CollectorColumn("recovery_model", CollectorColumnType.Varchar), + new CollectorColumn("is_read_only", CollectorColumnType.Boolean), + new CollectorColumn("is_auto_close_on", CollectorColumnType.Boolean), + new CollectorColumn("is_auto_shrink_on", CollectorColumnType.Boolean), + new CollectorColumn("is_auto_create_stats_on", CollectorColumnType.Boolean), + new CollectorColumn("is_auto_update_stats_on", CollectorColumnType.Boolean), + new CollectorColumn("is_auto_update_stats_async_on", CollectorColumnType.Boolean), + new CollectorColumn("is_read_committed_snapshot_on", CollectorColumnType.Boolean), + new CollectorColumn("snapshot_isolation_state", CollectorColumnType.Varchar), + new CollectorColumn("is_parameterization_forced", CollectorColumnType.Boolean), + new CollectorColumn("is_query_store_on", CollectorColumnType.Boolean), + new CollectorColumn("is_encrypted", CollectorColumnType.Boolean), + new CollectorColumn("is_trustworthy_on", CollectorColumnType.Boolean), + new CollectorColumn("is_db_chaining_on", CollectorColumnType.Boolean), + new CollectorColumn("is_broker_enabled", CollectorColumnType.Boolean), + new CollectorColumn("is_cdc_enabled", CollectorColumnType.Boolean), + new CollectorColumn("is_mixed_page_allocation_on", CollectorColumnType.Boolean), + new CollectorColumn("log_reuse_wait_desc", CollectorColumnType.Varchar), + new CollectorColumn("page_verify_option", CollectorColumnType.Varchar), + new CollectorColumn("target_recovery_time_seconds", CollectorColumnType.Integer), + new CollectorColumn("delayed_durability", CollectorColumnType.Varchar), + new CollectorColumn("is_accelerated_database_recovery_on", CollectorColumnType.Boolean), + new CollectorColumn("is_memory_optimized_enabled", CollectorColumnType.Boolean), + new CollectorColumn("is_optimized_locking_on", CollectorColumnType.Boolean), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var (has2019Columns, has2025Columns) = VersionGates(context.Target); + var rows = new List<Row>(); + + while (await reader.ReadAsync(cancellationToken)) + { + var ordinal = 0; + var r = new Row + { + DbName = reader.GetString(ordinal++), + StateDesc = reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal), + CompatLevel = reader.IsDBNull(++ordinal) ? 0 : Convert.ToInt32(reader.GetValue(ordinal), CultureInfo.InvariantCulture), + CollationName = reader.IsDBNull(++ordinal) ? null : reader.GetString(ordinal), + RecoveryModel = reader.IsDBNull(++ordinal) ? null : reader.GetString(ordinal), + IsReadOnly = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + AutoClose = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + AutoShrink = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + AutoCreateStats = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + AutoUpdateStats = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + AutoUpdateStatsAsync = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + Rcsi = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + SnapshotIsolation = reader.IsDBNull(++ordinal) ? null : reader.GetString(ordinal), + ParameterizationForced = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + QueryStore = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + Encrypted = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + Trustworthy = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + DbChaining = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + BrokerEnabled = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + CdcEnabled = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + MixedPageAllocation = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal), + LogReuseWait = reader.IsDBNull(++ordinal) ? null : reader.GetString(ordinal), + PageVerify = reader.IsDBNull(++ordinal) ? null : reader.GetString(ordinal), + TargetRecovery = reader.IsDBNull(++ordinal) ? 0 : Convert.ToInt32(reader.GetValue(ordinal), CultureInfo.InvariantCulture), + DelayedDurability = reader.IsDBNull(++ordinal) ? null : reader.GetString(ordinal), + }; + + if (has2019Columns) + { + r.AcceleratedDatabaseRecovery = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal); + r.MemoryOptimized = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal); + } + + if (has2025Columns) + { + r.OptimizedLocking = !reader.IsDBNull(++ordinal) && reader.GetBoolean(ordinal); + } + + rows.Add(r); + } + + return rows; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + writer + .Value(row.DbName) + .Value(row.StateDesc) + .Value(row.CompatLevel) + .Value(row.CollationName) + .Value(row.RecoveryModel) + .Value(row.IsReadOnly) + .Value(row.AutoClose) + .Value(row.AutoShrink) + .Value(row.AutoCreateStats) + .Value(row.AutoUpdateStats) + .Value(row.AutoUpdateStatsAsync) + .Value(row.Rcsi) + .Value(row.SnapshotIsolation) + .Value(row.ParameterizationForced) + .Value(row.QueryStore) + .Value(row.Encrypted) + .Value(row.Trustworthy) + .Value(row.DbChaining) + .Value(row.BrokerEnabled) + .Value(row.CdcEnabled) + .Value(row.MixedPageAllocation) + .Value(row.LogReuseWait) + .Value(row.PageVerify) + .Value(row.TargetRecovery) + .Value(row.DelayedDurability) + .Value(row.AcceleratedDatabaseRecovery) /* version-gated: NULL when not collected */ + .Value(row.MemoryOptimized) /* version-gated: NULL when not collected */ + .Value(row.OptimizedLocking); /* version-gated: NULL when not collected */ + } + + private static (bool Has2019Columns, bool Has2025Columns) VersionGates(CollectorTargetInfo target) + { + var isAzure = target.IsAzureSqlDb || target.IsAzureManagedInstance; + var has2019 = target.SqlMajorVersion >= 15 || target.SqlMajorVersion == 0 || isAzure; + var has2025 = target.SqlMajorVersion >= 17; + return (has2019, has2025); + } +} diff --git a/PerformanceMonitor.Collectors/DatabaseExclusionFilter.cs b/PerformanceMonitor.Collectors/DatabaseExclusionFilter.cs new file mode 100644 index 000000000..610338337 --- /dev/null +++ b/PerformanceMonitor.Collectors/DatabaseExclusionFilter.cs @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Collections.Generic; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Builds the parameterized database-exclusion fragment collectors splice into their queries. +/// Ported verbatim from Lite's RemoteCollectorService.BuildDatabaseExclusionFilter (which remains +/// in place for the collectors not yet migrated — DatabaseExclusionFilterTests pins this copy so +/// the two cannot drift silently; the Lite copy dies when the last caller migrates). +/// Each name is parameterized (@excl_db_N, nvarchar(128)) — works on every supported SQL Server +/// version (no STRING_SPLIT/OPENJSON compatibility-level dependency). +/// </summary> +public static class DatabaseExclusionFilter +{ + /// <param name="excludedDatabaseNames">Names from the server's excluded-databases config.</param> + /// <param name="columnExpression">SQL column to filter, e.g. "d.name".</param> + public static (string Clause, List<CollectorParameter> Parameters) Build( + IReadOnlyList<string>? excludedDatabaseNames, string columnExpression) + { + if (excludedDatabaseNames is null || excludedDatabaseNames.Count == 0) + { + return (string.Empty, new List<CollectorParameter>()); + } + + var paramNames = new List<string>(excludedDatabaseNames.Count); + var parameters = new List<CollectorParameter>(excludedDatabaseNames.Count); + for (int i = 0; i < excludedDatabaseNames.Count; i++) + { + string p = $"@excl_db_{i}"; + paramNames.Add(p); + parameters.Add(new CollectorParameter(p, excludedDatabaseNames[i], CollectorParameterType.NVarChar128)); + } + + return ($"AND {columnExpression} NOT IN ({string.Join(", ", paramNames)})", parameters); + } + + /// <summary> + /// Builds a SQL fragment with database names interpolated as literal N'...' values, for + /// dynamic-SQL paths where parameter binding is awkward (e.g. inside a string passed to + /// sp_executesql). Names come from user-picked checklists of existing databases, so literal + /// interpolation with single-quote escaping is safe. When <paramref name="forNestedDynamicSql"/> + /// is true, the escape is doubled for use inside an outer T-SQL string that itself becomes a + /// dynamic-SQL @sql variable. Ported verbatim from Lite's + /// RemoteCollectorService.BuildDatabaseExclusionLiteralClause (identity-pinned by test). + /// </summary> + public static string BuildLiteralClause( + IReadOnlyList<string>? excludedDatabaseNames, string columnExpression, bool forNestedDynamicSql = false) + { + if (excludedDatabaseNames is null || excludedDatabaseNames.Count == 0) + { + return string.Empty; + } + + string escapedQuote = forNestedDynamicSql ? "''" : "'"; + string Escape(string s) => forNestedDynamicSql + ? s.Replace("'", "''''") + : s.Replace("'", "''"); + + var quoted = new List<string>(excludedDatabaseNames.Count); + foreach (var name in excludedDatabaseNames) + { + quoted.Add($"N{escapedQuote}{Escape(name)}{escapedQuote}"); + } + + return $"AND {columnExpression} NOT IN ({string.Join(", ", quoted)})"; + } +} diff --git a/PerformanceMonitor.Collectors/DatabaseScopedConfigCollector.cs b/PerformanceMonitor.Collectors/DatabaseScopedConfigCollector.cs new file mode 100644 index 000000000..e022eec54 --- /dev/null +++ b/PerformanceMonitor.Collectors/DatabaseScopedConfigCollector.cs @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Database-scoped configurations from sys.database_scoped_configurations for each online user +/// database (on-load only). Extracted verbatim from Lite's RemoteCollectorService.ServerConfig.cs. +/// The enumeration shape: list databases first (on-prem filters to non-AG or primary-replica +/// databases; Azure lists all online), then EXECUTE [db].sys.sp_executesql per database on the +/// same connection — the proven per-database idiom the Query Store collector also uses. A +/// database that fails is skipped with a warning by the host. +/// </summary> +public sealed class DatabaseScopedConfigCollector : CollectorDefinitionBase<DatabaseScopedConfigCollector.Row> +{ + public static DatabaseScopedConfigCollector Instance { get; } = new(); + + private DatabaseScopedConfigCollector() + { + } + + public readonly record struct Row(string DbName, string ConfigName, string? Value, string? ValueForSecondary); + + private const string OnPremDatabaseListQueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + d.name +FROM sys.databases AS d +LEFT JOIN sys.dm_hadr_database_replica_states AS drs + ON d.database_id = drs.database_id + AND drs.is_local = 1 +WHERE (d.database_id > 4 OR d.database_id = 2) +AND d.database_id < 32761 +AND d.name <> N'PerformanceMonitor' +AND d.state_desc = N'ONLINE' +AND +( + drs.database_id IS NULL /*not in any AG*/ + OR drs.is_primary_replica = 1 /*primary replica*/ +) +/*EXCLUSION_FILTER*/ +ORDER BY d.name +OPTION(RECOMPILE);"; + + private const string AzureDatabaseListQueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + d.name +FROM sys.databases AS d +WHERE (d.database_id > 4 OR d.database_id = 2) +AND d.database_id < 32761 +AND d.name <> N'PerformanceMonitor' +AND d.state_desc = N'ONLINE' +/*EXCLUSION_FILTER*/ +ORDER BY d.name +OPTION(RECOMPILE);"; + + public override string Name => "database_scoped_config"; + + public override string TargetTable => "database_scoped_config"; + + /// <summary>The config snapshots' prefix is config_id/capture_time in Lite's schema; Darling mirrors it.</summary> + public override string PrefixIdColumnName => "config_id"; + + public override string PrefixTimeColumnName => "capture_time"; + + /// <summary>Enumerating collector — the primary query is never used.</summary> + public override CollectorQuery BuildQuery(CollectorContext context) + => throw new NotSupportedException("database_scoped_config enumerates databases; BuildEnumerationQuery drives the cycle."); + + public override CollectorQuery? BuildEnumerationQuery(CollectorContext context) + { + var (exclusionClause, exclusionParameters) = DatabaseExclusionFilter.Build(context.ExcludedDatabases, "d.name"); + var text = (context.Target.IsAzureSqlDb ? AzureDatabaseListQueryText : OnPremDatabaseListQueryText) + .Replace("/*EXCLUSION_FILTER*/", exclusionClause, StringComparison.Ordinal); + + return new CollectorQuery(text, exclusionParameters); + } + + public override CollectorQuery BuildPerItemQuery(string item, CollectorContext context) + { + /* Use [dbname].sys.sp_executesql to run in database context (Azure SQL DB compatible) */ + var text = $@" +EXECUTE [{item.Replace("]", "]]", StringComparison.Ordinal)}].sys.sp_executesql + N'SELECT + configuration_name = dsc.name, + value = CONVERT(nvarchar(256), dsc.value), + value_for_secondary = CONVERT(nvarchar(256), dsc.value_for_secondary) + FROM sys.database_scoped_configurations AS dsc + ORDER BY dsc.name + OPTION(RECOMPILE);'"; + + return new CollectorQuery(text); + } + + public override async ValueTask ReadItemAsync(string item, DbDataReader reader, List<Row> rows, CollectorContext context, CancellationToken cancellationToken) + { + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new Row( + item, + reader.GetString(0), + reader.IsDBNull(1) ? null : reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetString(2))); + } + } + + /// <summary>Never called — enumeration drives this collector.</summary> + public override ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + => throw new NotSupportedException("database_scoped_config enumerates databases; ReadItemAsync drives row reads."); + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("database_name", CollectorColumnType.Varchar), + new CollectorColumn("configuration_name", CollectorColumnType.Varchar), + new CollectorColumn("value", CollectorColumnType.Varchar), + new CollectorColumn("value_for_secondary", CollectorColumnType.Varchar), + }; + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + writer + .Value(row.DbName) /* database_name VARCHAR */ + .Value(row.ConfigName) /* configuration_name VARCHAR */ + .Value(row.Value) /* value VARCHAR */ + .Value(row.ValueForSecondary); /* value_for_secondary VARCHAR */ + } +} diff --git a/PerformanceMonitor.Collectors/DatabaseSizeStatsCollector.cs b/PerformanceMonitor.Collectors/DatabaseSizeStatsCollector.cs new file mode 100644 index 000000000..9c859805a --- /dev/null +++ b/PerformanceMonitor.Collectors/DatabaseSizeStatsCollector.cs @@ -0,0 +1,316 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Per-file database sizes for growth trending and capacity planning. Extracted verbatim from +/// Lite's RemoteCollectorService.DatabaseSize.cs. On-prem: one batch stages per-database +/// FILEPROPERTY(SpaceUsed) via a server-side cursor + nested sp_executesql into #file_space, then +/// joins sys.master_files + sys.dm_os_volume_stats (the exclusion filter splices at BOTH the +/// cursor and outer SELECT — same parameters referenced twice). Azure SQL DB: per-database +/// connections (RunsPerDatabase), no volume stats, NULL compatibility_level — as before. +/// </summary> +public sealed class DatabaseSizeStatsCollector : CollectorDefinitionBase<DatabaseSizeStatsCollector.Row> +{ + public static DatabaseSizeStatsCollector Instance { get; } = new(); + + private DatabaseSizeStatsCollector() + { + } + + public readonly record struct Row( + string DatabaseName, + int DatabaseId, + int FileId, + string FileTypeDesc, + string FileName, + string PhysicalName, + decimal TotalSizeMb, + decimal? UsedSizeMb, + decimal? AutoGrowthMb, + decimal? MaxSizeMb, + string? RecoveryModel, + int? CompatibilityLevel, + string? StateDesc, + string? VolumeMountPoint, + decimal? VolumeTotalMb, + decimal? VolumeFreeMb, + bool? IsPercentGrowth, + int? GrowthPct, + int? VlfCount); + + private const string OnPremQueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; +SET NOCOUNT ON; + +CREATE TABLE #file_space +( + database_id int NOT NULL, + file_id int NOT NULL, + used_size_mb decimal(19,2) NULL +); + +DECLARE + @db_name sysname, + @sql nvarchar(MAX); + +DECLARE db_cursor CURSOR LOCAL FAST_FORWARD FOR + SELECT + d.name + FROM sys.databases AS d + WHERE d.state_desc = N'ONLINE' + AND d.database_id > 0 + AND HAS_DBACCESS(d.name) = 1 + /*EXCLUSION_FILTER_CURSOR*/ + ORDER BY + d.name; + +OPEN db_cursor; +FETCH NEXT FROM db_cursor INTO @db_name; + +WHILE @@FETCH_STATUS = 0 +BEGIN + BEGIN TRY + SET @sql = N'EXECUTE ' + QUOTENAME(@db_name) + N'.sys.sp_executesql N'' +INSERT #file_space (database_id, file_id, used_size_mb) +SELECT + DB_ID(), + df.file_id, + CONVERT(decimal(19,2), FILEPROPERTY(df.name, N''''SpaceUsed'''') * 8.0 / 1024.0) +FROM sys.database_files AS df;'';'; + + EXECUTE sys.sp_executesql @sql; + END TRY + BEGIN CATCH + END CATCH; + + FETCH NEXT FROM db_cursor INTO @db_name; +END; + +CLOSE db_cursor; +DEALLOCATE db_cursor; + +SELECT + database_name = d.name, + database_id = d.database_id, + file_id = mf.file_id, + file_type_desc = mf.type_desc, + file_name = mf.name, + physical_name = mf.physical_name, + total_size_mb = + CONVERT(decimal(19,2), mf.size * 8.0 / 1024.0), + used_size_mb = + fs.used_size_mb, + auto_growth_mb = + CASE + WHEN mf.is_percent_growth = 1 + THEN CONVERT(decimal(19,2), NULL) + ELSE CONVERT(decimal(19,2), mf.growth * 8.0 / 1024.0) + END, + max_size_mb = + CASE + WHEN mf.max_size = -1 + THEN CONVERT(decimal(19,2), -1) + WHEN mf.max_size = 268435456 + THEN CONVERT(decimal(19,2), 2097152) + ELSE CONVERT(decimal(19,2), mf.max_size * 8.0 / 1024.0) + END, + recovery_model_desc = + d.recovery_model_desc, + compatibility_level = + CONVERT(int, d.compatibility_level), + state_desc = + d.state_desc, + volume_mount_point = + RTRIM(vs.volume_mount_point), + volume_total_mb = + CONVERT(decimal(19,2), vs.total_bytes / 1048576.0), + volume_free_mb = + CONVERT(decimal(19,2), vs.available_bytes / 1048576.0), + is_percent_growth = + mf.is_percent_growth, + growth_pct = + CASE WHEN mf.is_percent_growth = 1 THEN mf.growth ELSE NULL END, + vlf_count = + CASE WHEN mf.type = 1 /*LOG*/ THEN (SELECT COUNT(*) FROM sys.dm_db_log_info(mf.database_id) AS li WHERE li.file_id = mf.file_id) ELSE NULL END +FROM sys.master_files AS mf +JOIN sys.databases AS d + ON d.database_id = mf.database_id +CROSS APPLY sys.dm_os_volume_stats(mf.database_id, mf.file_id) AS vs +LEFT JOIN #file_space AS fs + ON fs.database_id = mf.database_id + AND fs.file_id = mf.file_id +WHERE d.state_desc = N'ONLINE' +/*EXCLUSION_FILTER_OUTER*/ +ORDER BY + d.name, + mf.file_id +OPTION(RECOMPILE);"; + + private const string AzureSqlDbQueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + database_name = DB_NAME(), + database_id = DB_ID(), + file_id = df.file_id, + file_type_desc = df.type_desc, + file_name = df.name, + physical_name = df.physical_name, + total_size_mb = + CONVERT(decimal(19,2), df.size * 8.0 / 1024.0), + used_size_mb = + CONVERT(decimal(19,2), FILEPROPERTY(df.name, N'SpaceUsed') * 8.0 / 1024.0), + auto_growth_mb = + CASE + WHEN df.is_percent_growth = 1 + THEN CONVERT(decimal(19,2), NULL) + ELSE CONVERT(decimal(19,2), df.growth * 8.0 / 1024.0) + END, + max_size_mb = + CASE + WHEN df.max_size = -1 + THEN CONVERT(decimal(19,2), -1) + WHEN df.max_size = 268435456 + THEN CONVERT(decimal(19,2), 2097152) + ELSE CONVERT(decimal(19,2), df.max_size * 8.0 / 1024.0) + END, + recovery_model_desc = + CONVERT(nvarchar(12), DATABASEPROPERTYEX(DB_NAME(), N'Recovery')), + compatibility_level = + CONVERT(int, NULL), + state_desc = + N'ONLINE', + volume_mount_point = + CONVERT(nvarchar(256), NULL), + volume_total_mb = + CONVERT(decimal(19,2), NULL), + volume_free_mb = + CONVERT(decimal(19,2), NULL), + is_percent_growth = + df.is_percent_growth, + growth_pct = + CASE WHEN df.is_percent_growth = 1 THEN df.growth ELSE NULL END, + vlf_count = + CASE WHEN df.type = 1 /*LOG*/ THEN (SELECT COUNT(*) FROM sys.dm_db_log_info(DB_ID()) AS li WHERE li.file_id = df.file_id) ELSE NULL END +FROM sys.database_files AS df +ORDER BY + df.file_id +OPTION(RECOMPILE);"; + + public override string Name => "database_size_stats"; + + public override string TargetTable => "database_size_stats"; + + /// <summary>Azure SQL DB databases are isolated — one connection per database.</summary> + public override bool RunsPerDatabase(CollectorTargetInfo target) => target.IsAzureSqlDb; + + public override CollectorQuery BuildQuery(CollectorContext context) + { + if (context.Target.IsAzureSqlDb) + { + /* Database exclusion happens in the host's database enumeration on Azure. */ + return new CollectorQuery(AzureSqlDbQueryText); + } + + /* Both filter sites (cursor SELECT and final SELECT) are in outer T-SQL, not nested dynamic + SQL, so parameter bindings work fine and the same @excl_db_N can be referenced twice. */ + var (exclusionClause, exclusionParameters) = DatabaseExclusionFilter.Build(context.ExcludedDatabases, "d.name"); + var text = OnPremQueryText + .Replace("/*EXCLUSION_FILTER_CURSOR*/", exclusionClause, StringComparison.Ordinal) + .Replace("/*EXCLUSION_FILTER_OUTER*/", exclusionClause, StringComparison.Ordinal); + + return new CollectorQuery(text, exclusionParameters); + } + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("database_name", CollectorColumnType.Varchar), + new CollectorColumn("database_id", CollectorColumnType.Integer), + new CollectorColumn("file_id", CollectorColumnType.Integer), + new CollectorColumn("file_type_desc", CollectorColumnType.Varchar), + new CollectorColumn("file_name", CollectorColumnType.Varchar), + new CollectorColumn("physical_name", CollectorColumnType.Varchar), + new CollectorColumn("total_size_mb", CollectorColumnType.Decimal, 19, 2), + new CollectorColumn("used_size_mb", CollectorColumnType.Decimal, 19, 2), + new CollectorColumn("auto_growth_mb", CollectorColumnType.Decimal, 19, 2), + new CollectorColumn("max_size_mb", CollectorColumnType.Decimal, 19, 2), + new CollectorColumn("recovery_model_desc", CollectorColumnType.Varchar), + new CollectorColumn("compatibility_level", CollectorColumnType.Integer), + new CollectorColumn("state_desc", CollectorColumnType.Varchar), + new CollectorColumn("volume_mount_point", CollectorColumnType.Varchar), + new CollectorColumn("volume_total_mb", CollectorColumnType.Decimal, 19, 2), + new CollectorColumn("volume_free_mb", CollectorColumnType.Decimal, 19, 2), + new CollectorColumn("is_percent_growth", CollectorColumnType.Boolean), + new CollectorColumn("growth_pct", CollectorColumnType.Integer), + new CollectorColumn("vlf_count", CollectorColumnType.Integer), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var rows = new List<Row>(); + + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new Row( + reader.GetString(0), + Convert.ToInt32(reader.GetValue(1), CultureInfo.InvariantCulture), + Convert.ToInt32(reader.GetValue(2), CultureInfo.InvariantCulture), + reader.GetString(3), + reader.GetString(4), + reader.GetString(5), + reader.GetDecimal(6), + reader.IsDBNull(7) ? null : reader.GetDecimal(7), + reader.IsDBNull(8) ? null : reader.GetDecimal(8), + reader.IsDBNull(9) ? null : reader.GetDecimal(9), + reader.IsDBNull(10) ? null : reader.GetString(10), + reader.IsDBNull(11) ? null : Convert.ToInt32(reader.GetValue(11), CultureInfo.InvariantCulture), + reader.IsDBNull(12) ? null : reader.GetString(12), + reader.IsDBNull(13) ? null : reader.GetString(13), + reader.IsDBNull(14) ? null : reader.GetDecimal(14), + reader.IsDBNull(15) ? null : reader.GetDecimal(15), + reader.IsDBNull(16) ? null : (bool?)(Convert.ToInt32(reader.GetValue(16), CultureInfo.InvariantCulture) == 1), + reader.IsDBNull(17) ? null : Convert.ToInt32(reader.GetValue(17), CultureInfo.InvariantCulture), + reader.IsDBNull(18) ? null : Convert.ToInt32(reader.GetValue(18), CultureInfo.InvariantCulture))); + } + + return rows; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + writer + .Value(row.DatabaseName) + .Value(row.DatabaseId) + .Value(row.FileId) + .Value(row.FileTypeDesc) + .Value(row.FileName) + .Value(row.PhysicalName) + .Value(row.TotalSizeMb) + .Value(row.UsedSizeMb) + .Value(row.AutoGrowthMb) + .Value(row.MaxSizeMb) + .Value(row.RecoveryModel) + .Value(row.CompatibilityLevel) + .Value(row.StateDesc) + .Value(row.VolumeMountPoint) + .Value(row.VolumeTotalMb) + .Value(row.VolumeFreeMb) + .Value(row.IsPercentGrowth) + .Value(row.GrowthPct) + .Value(row.VlfCount); + } +} diff --git a/PerformanceMonitor.Collectors/DeadlocksCollector.cs b/PerformanceMonitor.Collectors/DeadlocksCollector.cs new file mode 100644 index 000000000..da8a072fd --- /dev/null +++ b/PerformanceMonitor.Collectors/DeadlocksCollector.cs @@ -0,0 +1,338 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Deadlock events from the app-managed PerformanceMonitor_Deadlock XE ring-buffer session. +/// Extracted verbatim from Lite's RemoteCollectorService.Deadlocks.cs: the server-scoped read +/// (on-prem/MI/RDS, event xml_deadlock_report) vs the database-scoped read (Azure SQL DB, event +/// database_xml_deadlock_report — run once per monitored database since #1535, with a +/// per-database watermark), the deadlock_time watermark that keeps ring-buffer lingerers +/// from re-inserting (10-minute fallback window), and the victim-inputbuf extraction from the +/// graph XML (parsed in the SQL/read phase — XElement.Parse is expensive and was previously +/// misattributed as storage time). Session lifecycle (create/start/ensure) stays host-side; the +/// session name lives here so the reader and the lifecycle can never disagree on it. +/// </summary> +public sealed class DeadlocksCollector : CollectorDefinitionBase<DeadlocksCollector.Row> +{ + public static DeadlocksCollector Instance { get; } = new(); + + private DeadlocksCollector() + { + } + + /* We create and manage our own XE session to avoid conflicts with user's existing sessions */ + public const string XeSessionName = "PerformanceMonitor_Deadlock"; + + public sealed class Row + { + public DateTime? DeadlockTime { get; set; } + public string? VictimProcessId { get; set; } + public string? VictimSqlText { get; set; } + public string? GraphXml { get; set; } + /* Best-effort execution plan for the victim's deadlocked statement, resolved from the plan + cache at collection time (CapturePlanXml only). Null on Lite (flag off) and whenever the + plan can't be recovered — see the resolution comment on the query fragments. */ + public string? VictimQueryPlanXml { get; set; } + /* Keys the per-database watermark for Azure SQL DB's per-database capture sessions. On the + per-database read path this is the capture database itself (context.CurrentDatabaseName — + a database-scoped session only captures its own database, and a null here could never + advance that database's watermark, re-inserting the row every cycle); on server-scoped + platforms it falls back to the victim process's currentdbname graph attribute (same + source the grids' per-process parse and BlockedProcessReportCollector use), null when + the engine doesn't emit it. */ + public string? DatabaseName { get; set; } + } + + /* Azure SQL DB: read from ring_buffer (database-scoped session) + Azure SQL DB uses database_xml_deadlock_report event instead of xml_deadlock_report. + Use .query() to get XML with structure intact, then CONVERT to nvarchar(max) */ + private const string AzureQueryText = $@" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +DECLARE + @PerformanceMonitor_Deadlock TABLE +( + ring_buffer xml NOT NULL +); + +INSERT + @PerformanceMonitor_Deadlock +( + ring_buffer +) +SELECT /* PerformanceMonitorLite */ + ring_xml = TRY_CAST(xet.target_data AS xml) +FROM sys.dm_xe_database_session_targets AS xet +JOIN sys.dm_xe_database_sessions AS xes + ON xes.address = xet.event_session_address +WHERE xes.name = N'{XeSessionName}' +AND xet.target_name = N'ring_buffer' +OPTION(RECOMPILE); + +SELECT + deadlock_time = evt.value('(@timestamp)[1]', 'datetime2'), + victim_process_id = evt.value('(data[@name=""xml_report""]/value/deadlock/victim-list/victimProcess/@id)[1]', 'varchar(50)'), + deadlock_graph_xml = CONVERT(nvarchar(max), evt.query('data[@name=""xml_report""]/value/deadlock'))/*DL_PLAN_SELECT*/ +FROM +( + SELECT + pmd.ring_buffer + FROM @PerformanceMonitor_Deadlock AS pmd +) AS rb +CROSS APPLY rb.ring_buffer.nodes('RingBufferTarget/event[@name=""database_xml_deadlock_report""]') AS q(evt)/*DL_PLAN_APPLY*/ +WHERE evt.value('(@timestamp)[1]', 'datetime2') > @cutoff_time +OPTION(RECOMPILE);"; + + /* On-prem / Azure MI / AWS RDS: read from ring_buffer (server-scoped session) + Use .query() to get XML with structure intact, then CONVERT to nvarchar(max) */ + private const string ServerScopedQueryText = $@" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +DECLARE + @PerformanceMonitor_Deadlock TABLE +( + ring_buffer xml NOT NULL +); + +INSERT + @PerformanceMonitor_Deadlock +( + ring_buffer +) +SELECT /* PerformanceMonitorLite */ + ring_xml = TRY_CAST(xet.target_data AS xml) +FROM sys.dm_xe_session_targets AS xet +JOIN sys.dm_xe_sessions AS xes + ON xes.address = xet.event_session_address +WHERE xes.name = N'{XeSessionName}' +AND xet.target_name = N'ring_buffer' +OPTION(RECOMPILE); + +SELECT + deadlock_time = evt.value('(@timestamp)[1]', 'datetime2'), + victim_process_id = evt.value('(data[@name=""xml_report""]/value/deadlock/victim-list/victimProcess/@id)[1]', 'varchar(50)'), + deadlock_graph_xml = CONVERT(nvarchar(max), evt.query('data[@name=""xml_report""]/value/deadlock'))/*DL_PLAN_SELECT*/ +FROM +( + SELECT + pmd.ring_buffer + FROM @PerformanceMonitor_Deadlock AS pmd +) AS rb +CROSS APPLY rb.ring_buffer.nodes('RingBufferTarget/event[@name=""xml_deadlock_report""]') AS q(evt)/*DL_PLAN_APPLY*/ +WHERE evt.value('(@timestamp)[1]', 'datetime2') > @cutoff_time +OPTION(RECOMPILE);"; + + /* Best-effort victim plan capture (CapturePlanXml only — Darling). Resolve the victim's deadlocked + statement plan from the live plan cache by its executionStack frame's sql_handle + statement + offsets, reusing the proven on-demand shape from Lite/Dashboard's #880 right-click View Plan + (dm_exec_query_stats → dm_exec_text_query_plan). Only the victim's plan is captured at collection + time — the deadlocks row is victim-centric (one row per event: victim_process_id, victim_sql_text), + and the other processes' plans stay reachable via the apps' live right-click path. Honestly + best-effort: the deadlock XE fires asynchronously, so by collection the victim's plan may have aged + out; adhoc/dynamic-SQL frames carry an all-zero sql_handle that matches no query_stats row; either + way the column lands NULL rather than fabricated. Frames walked top-of-stack first (the innermost, + actually-deadlocked statement), first resolvable wins — mirrors ExtractDeadlockProcessFrames. The + victim <process> is matched by id against the graph's victim-list (forward navigation only — no + reverse XPath axis). Flag off: both fragments empty, so the SQL is byte-identical to the no-plan + form; the two query variants share these fragments (the victim shred navigates the shared + data[@name="xml_report"]/value/deadlock payload, identical on-prem and Azure SQL DB). */ + private const string PlanSelectFragment = @", + victim_query_plan_xml = vqp.query_plan"; + + private const string PlanApplyFragment = @" +OUTER APPLY +( + SELECT TOP (1) + query_plan = tqp.query_plan + FROM + ( + SELECT + frame_handle = TRY_CONVERT(varbinary(64), fr.n.value('@sqlhandle', 'varchar(130)'), 1), + frame_start = fr.n.value('(@stmtstart)[1]', 'integer'), + frame_end = fr.n.value('(@stmtend)[1]', 'integer'), + frame_ordinal = fr.n.value('let $c := . return count(../frame[. << $c])', 'integer') + FROM evt.nodes('data[@name=""xml_report""]/value/deadlock') AS d(dl) + CROSS APPLY d.dl.nodes('process-list/process') AS p(pr) + CROSS APPLY p.pr.nodes('executionStack/frame') AS fr(n) + WHERE p.pr.value('(@id)[1]', 'varchar(50)') = d.dl.value('(victim-list/victimProcess/@id)[1]', 'varchar(50)') + ) AS frames + JOIN sys.dm_exec_query_stats AS qs + ON qs.sql_handle = frames.frame_handle + AND qs.statement_start_offset = ISNULL(frames.frame_start, 0) + AND qs.statement_end_offset = ISNULL(frames.frame_end, -1) + OUTER APPLY + sys.dm_exec_text_query_plan + ( + qs.plan_handle, + qs.statement_start_offset, + qs.statement_end_offset + ) AS tqp + WHERE frames.frame_handle IS NOT NULL + AND tqp.query_plan IS NOT NULL + ORDER BY + frames.frame_ordinal +) AS vqp"; + + public override string Name => "deadlocks"; + + public override string TargetTable => "deadlocks"; + + /// <summary>Lite schema names this table's prefix id "deadlock_id"; Darling mirrors it.</summary> + public override string PrefixIdColumnName => "deadlock_id"; + + /// <summary> + /// Only events newer than the newest already-collected deadlock are fetched, so an event + /// lingering in the ring buffer across cycles is never inserted twice. + /// </summary> + public override string? WatermarkColumn => "deadlock_time"; + + /// <summary> + /// Azure SQL DB capture is a database-scoped session per monitored database (#1535 — a single + /// session only ever saw the connection's own database, so deadlocks in the other databases of + /// the logical server never appeared). The host ensures the session per database and this read + /// then runs per database, exactly like the other Azure per-database collectors. + /// </summary> + public override bool RunsPerDatabase(CollectorTargetInfo target) => target.IsAzureSqlDb; + + /// <summary> + /// Per-database watermark for the per-database sessions: each database's ring buffer dispatches + /// independently, so one database's newer deadlock must not watermark past another's older one. + /// </summary> + public override string? PerDatabaseWatermarkColumn => "database_name"; + + public override CollectorQuery BuildQuery(CollectorContext context) + { + var text = context.Target.IsAzureSqlDb ? AzureQueryText : ServerScopedQueryText; + + /* Splice (Darling) or erase (Lite, flag off — byte-identical) the victim plan capture. */ + text = text + .Replace("/*DL_PLAN_SELECT*/", context.CapturePlanXml ? PlanSelectFragment : "", StringComparison.Ordinal) + .Replace("/*DL_PLAN_APPLY*/", context.CapturePlanXml ? PlanApplyFragment : "", StringComparison.Ordinal); + + /* Use the most recent timestamp from the host store as the cutoff, or fall back to a + 10-minute window on first run. */ + var cutoffTime = context.Watermark ?? context.CollectionTime.AddMinutes(-10); + + return new CollectorQuery(text, new List<CollectorParameter> + { + new("@cutoff_time", cutoffTime, CollectorParameterType.DateTime2), + }); + } + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("deadlock_time", CollectorColumnType.Timestamp), + new CollectorColumn("victim_process_id", CollectorColumnType.Varchar), + new CollectorColumn("victim_sql_text", CollectorColumnType.Varchar), + new CollectorColumn("deadlock_graph_xml", CollectorColumnType.Varchar), + /* Trailing best-effort victim plan (appended so one ADD COLUMN brings a pre-plan store up to + shape; NULL on Lite and whenever the victim's plan aged out of cache). */ + new CollectorColumn("victim_query_plan_xml", CollectorColumnType.Varchar), + /* Trailing victim database (appended so one ADD COLUMN brings a pre-#1535 store up to shape; + NULL when the graph carries no currentdbname). Keys the Azure per-database watermark. */ + new CollectorColumn("database_name", CollectorColumnType.Varchar), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + /* Parse the graph XML here in the read (SQL) phase — ExtractVictimSqlText does + XElement.Parse, which is expensive and was previously misattributed as storage time. */ + var rows = new List<Row>(); + + while (await reader.ReadAsync(cancellationToken)) + { + var victimProcessId = reader.IsDBNull(1) ? null : reader.GetString(1); + var graphXml = reader.IsDBNull(2) ? null : reader.GetString(2); + var victim = ExtractVictimFields(graphXml, victimProcessId); + + rows.Add(new Row + { + DeadlockTime = reader.IsDBNull(0) ? null : reader.GetDateTime(0), + VictimProcessId = victimProcessId, + VictimSqlText = victim.SqlText, + GraphXml = graphXml, + /* victim_query_plan_xml rides at ordinal 3 only when CapturePlanXml spliced it into + the projection; the short-circuit skips it entirely when off. */ + VictimQueryPlanXml = context.CapturePlanXml && !reader.IsDBNull(3) ? reader.GetString(3) : null, + /* Per-database path: the capture database is authoritative (see the Row comment); + server-scoped: the victim's currentdbname, null when the graph doesn't carry it. */ + DatabaseName = context.CurrentDatabaseName ?? victim.DatabaseName, + }); + } + + return rows; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + writer + .Value(row.DeadlockTime) + .Value(row.VictimProcessId) + .Value(row.VictimSqlText) + .Value(row.GraphXml) + .Value(row.VictimQueryPlanXml) /* null unless CapturePlanXml resolved it (Darling) */ + .Value(row.DatabaseName); /* null when the graph carries no currentdbname */ + } + + /// <summary> + /// Extracts victim SQL text from a deadlock graph XML fragment. + /// </summary> + public static string? ExtractVictimSqlText(string? graphXml, string? victimProcessId) + => ExtractVictimFields(graphXml, victimProcessId).SqlText; + + /// <summary> + /// Extracts the victim process's inputbuf and currentdbname from a deadlock graph XML fragment + /// in one parse (XElement.Parse is the expensive part — see the ReadAsync comment). Victim + /// matched by id, case-insensitively; falls back to the first process when the id is missing or + /// unmatched (the pre-existing ExtractVictimSqlText contract). currentdbname is the same + /// attribute the grids' per-process parse and the blocked-process collector read; engines that + /// don't emit it yield a null DatabaseName rather than a fabricated one. + /// </summary> + public static (string? SqlText, string? DatabaseName) ExtractVictimFields(string? graphXml, string? victimProcessId) + { + if (string.IsNullOrEmpty(graphXml)) + { + return (null, null); + } + + try + { + var doc = XElement.Parse(graphXml); + + /* Find all process nodes in the deadlock graph */ + var processes = doc.Descendants("process").ToList(); + + /* If we have a victim ID, find that specific process */ + XElement? victim = null; + if (!string.IsNullOrEmpty(victimProcessId)) + { + victim = processes.FirstOrDefault(p => + string.Equals(p.Attribute("id")?.Value, victimProcessId, StringComparison.OrdinalIgnoreCase)); + } + + /* Fallback: the first process */ + victim ??= processes.FirstOrDefault(); + + return (victim?.Element("inputbuf")?.Value?.Trim(), victim?.Attribute("currentdbname")?.Value); + } + catch + { + return (null, null); + } + } +} diff --git a/PerformanceMonitor.Collectors/DefaultTraceEventsCollector.cs b/PerformanceMonitor.Collectors/DefaultTraceEventsCollector.cs new file mode 100644 index 000000000..d12a1212c --- /dev/null +++ b/PerformanceMonitor.Collectors/DefaultTraceEventsCollector.cs @@ -0,0 +1,362 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Always-on server events read from the built-in Default Trace via <c>sys.traces</c> + +/// <c>sys.fn_trace_gettable</c> — the events no DMV captures: data/log file auto-grow/shrink stalls, +/// severe ErrorLog writes, schema DDL (Object:Created/Altered/Deleted), security audits (audit-change / +/// DBCC / alter-trace), and Server Memory Change. Ported from the full Dashboard's +/// <c>install/29_collect_default_trace.sql</c> curated event set into the shared collector library, so +/// both SKUs (portable Lite → DuckDB, Darling → Postgres) collect it from one definition. +/// +/// <para><b>Agentless-safe (must-handle):</b> <c>fn_trace_gettable</c> runs entirely server-side against +/// the trace's own rollover files — no agent, no file share, no monitor-side artifact. The rollover-file +/// path is normalized back to the trace's BASE path (strip the <c>_NN</c> rollover suffix, re-append the +/// extension) and passed with <c>max_files</c> so the function reads across ALL rollover files, not just +/// the current one (the proven Dashboard idiom).</para> +/// +/// <para><b>Read-only (never writes the target):</b> unlike the Dashboard collector this definition does +/// NOT sp_configure/RECONFIGURE the trace on or attempt the disable/re-enable restart cycle — the shared +/// collectors are pure reads, and the default trace is enabled by default on every non-Azure-SQL-DB +/// engine. When it is off (or the configured-but-not-running SQL Server bug), the <c>status = 1</c> guard +/// simply yields zero rows for that cycle rather than mutating the monitored server.</para> +/// +/// <para><b>STATEFUL dedup (must-handle):</b> mirrors the deadlock / blocked-process-report / +/// system_health collectors — an <c>event_time</c> watermark (the newest already-collected StartTime from +/// the host store) filters <c>ft.StartTime > @cutoff_time</c>, so a rollover file re-read across cycles +/// never re-ingests an event. Unlike those XE ring-buffer collectors (whose 10-minute first-run fallback +/// fits their tiny recent-only buffers), the FIRST run here collects EVERYTHING still in the trace files +/// (a far-past cutoff), because the default trace persists hours-to-days of history on disk that would +/// otherwise be lost — the same first-run-collects-all choice the Dashboard collector makes.</para> +/// +/// <para><b>Config-change de-duplication (must-handle):</b> the Dashboard collector's config-event slice +/// (Server / Database Configuration Change, Alter Database) is deliberately DROPPED here — Darling already +/// tracks that exact slice by diffing its <c>server_config</c> / <c>database_config</c> / <c>trace_flags</c> +/// snapshots (get_server_config_changes / get_database_config_changes / get_trace_flag_changes), which +/// carry the real old→new values, so re-collecting the trace's config events would double-count it. The +/// remaining categories have no such snapshot twin. (DBCC commands stay — <c>Audit DBCC Event</c> is a +/// distinct "someone ran DBCC" audit, not the resulting trace-flag STATE the snapshot diff tracks.)</para> +/// +/// <para><b>Azure SQL Database (edition 5): does NOT apply</b> — there is no default trace on Azure SQL DB +/// (<c>sys.traces</c> exposes none), so the collector is gated off via <see cref="AppliesTo"/> = +/// <c>!IsAzureSqlDb</c> rather than logging a per-cycle empty/error. Azure SQL Managed Instance (edition 8) +/// and on-prem / AWS RDS all have the default trace and collect normally.</para> +/// </summary> +public sealed class DefaultTraceEventsCollector : CollectorDefinitionBase<DefaultTraceEventsCollector.Row> +{ + public static DefaultTraceEventsCollector Instance { get; } = new(); + + private DefaultTraceEventsCollector() + { + } + + /// <summary> + /// TRUE-first-run cutoff sentinel: on a genuine first run (no watermark AND no prior SUCCESS for this + /// collector+server in the host's collection_log) collect everything still on disk in the trace's + /// rollover files rather than only a recent window (the default trace persists real history; the XE + /// ring-buffer collectors' 10-minute fallback would silently drop it). Mirrors the Dashboard collector's + /// <c>CONVERT(datetime2(7), '19000101')</c> first-run branch. Bounded in practice by the trace's total + /// on-disk size (default 5 files × 20 MB) and the curated low-volume event set. NOT used when the hot + /// store was merely emptied by retention/archival — see <see cref="ArchivalEmptyFallbackHours"/>. + /// </summary> + private static readonly DateTime FirstRunCutoff = new(1900, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + /// <summary> + /// The BOUNDED fallback window (hours) used when the watermark is null but the collector HAS succeeded + /// before (<see cref="CollectorContext.HasCollectedBefore"/>) — i.e. the hot store was emptied by + /// retention/archival, not a true first run. Re-reading all on-disk .trc history here would re-insert + /// events already aged into Lite's parquet archive, and <c>v_default_trace_events</c> UNIONs hot + parquet + /// with no dedup, so those rows would DOUBLE-COUNT and recur every archival cycle. This window is far + /// smaller than any retention horizon, so on an archival-emptied (necessarily quiet) server it re-reads + /// only genuinely-recent events — which by definition are not yet archived — and never re-scans parquet. + /// Ports the Dashboard's collection_log-SUCCESS guard (install/29_collect_default_trace.sql lines 116-117). + /// </summary> + private const int ArchivalEmptyFallbackHours = 24; + + public sealed class Row + { + public DateTime? EventTime { get; set; } + public string? EventName { get; set; } + public int? EventClass { get; set; } + public int? Spid { get; set; } + public string? DatabaseName { get; set; } + public int? DatabaseId { get; set; } + public string? LoginName { get; set; } + public string? HostName { get; set; } + public string? ApplicationName { get; set; } + public string? ObjectName { get; set; } + public string? Filename { get; set; } + public long? IntegerData { get; set; } + public long? IntegerData2 { get; set; } + public string? TextData { get; set; } + public string? SessionLoginName { get; set; } + public int? ErrorNumber { get; set; } + public int? Severity { get; set; } + public int? State { get; set; } + public long? EventSequence { get; set; } + public long? DurationUs { get; set; } + public DateTime? EndTime { get; set; } + } + + /* The curated, config-slice-free event set (see the class remarks). A {0} placeholder is spliced with + the per-server excluded-database clause on ft.DatabaseName; the rollover-file base-path normalization + (strip _NN + re-append the extension) reads every retained file. READ UNCOMMITTED like every collector; + OPTION(RECOMPILE) because the cutoff selectivity varies wildly between the all-history first run and the + tiny steady-state windows. */ + private const string QueryTemplate = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + event_time = ft.StartTime, + event_name = te.name, + event_class = ft.EventClass, + spid = ft.SPID, + database_name = ft.DatabaseName, + database_id = ft.DatabaseID, + login_name = ft.LoginName, + host_name = ft.HostName, + application_name = ft.ApplicationName, + object_name = ft.ObjectName, + filename = ft.FileName, + integer_data = ft.IntegerData, + integer_data_2 = ft.IntegerData2, + text_data = ft.TextData, + session_login_name = ft.SessionLoginName, + error_number = ft.Error, + severity = ft.Severity, + state = ft.State, + event_sequence = ft.EventSequence, + duration_us = ft.Duration, + end_time = ft.EndTime +FROM sys.traces AS t +CROSS APPLY sys.fn_trace_gettable +( + LEFT(t.path, LEN(t.path) - CHARINDEX(N'_', REVERSE(t.path))) + + RIGHT(t.path, 4), + t.max_files +) AS ft +JOIN sys.trace_events AS te + ON ft.EventClass = te.trace_event_id +WHERE t.is_default = 1 +AND t.status = 1 +AND ft.StartTime > @cutoff_time +AND ISNULL(ft.DatabaseID, 0) NOT IN (1, 3, 4) /*master, model, msdb*/ +AND ISNULL(ft.DatabaseID, 0) < 32761 /*exclude contained AG system databases*/{0} +AND +( + /*Server Memory Change events*/ + (te.name LIKE N'%Server Memory Change%') + OR + /*Data/Log file auto grow/shrink STALLS (duration over one second only)*/ + ( + ISNULL(ft.Duration, 0) > 1000000 + AND + ( + te.name LIKE N'%Data File Auto Grow%' + OR te.name LIKE N'%Log File Auto Grow%' + OR te.name LIKE N'%Data File Auto Shrink%' + OR te.name LIKE N'%Log File Auto Shrink%' + ) + ) + OR + /*ErrorLog writes (severity gating happens in the significant-set layer, not here)*/ + (te.name = N'ErrorLog') + OR + /*Schema DDL (@include_object_ddl gates the whole slice; exclude tempdb and auto-created _WA_ statistics)*/ + ( + @include_object_ddl = 1 + AND ISNULL(ft.DatabaseID, 0) <> 2 + AND ISNULL(ft.ObjectName, N'') NOT LIKE N'[_]WA[_]%' + AND te.name IN (N'Object:Created', N'Object:Altered', N'Object:Deleted') + ) + OR + /*Security audit events (audit-change, DBCC, alter-trace)*/ + (te.name IN (N'Audit Change Audit Event', N'Audit DBCC Event', N'Audit Server Alter Trace Event')) +) +ORDER BY + ft.StartTime DESC +OPTION(RECOMPILE);"; + + public override string Name => "default_trace_events"; + + public override string TargetTable => "default_trace_events"; + + /// <summary>Lite schema names this table's prefix id "default_trace_event_id"; Darling mirrors it.</summary> + public override string PrefixIdColumnName => "default_trace_event_id"; + + /// <summary> + /// Only events newer than the newest already-collected StartTime are fetched, so re-reading the + /// rolling trace files across cycles never re-ingests an event (mirrors the deadlock / system_health + /// collectors' event_time watermark). First run has no watermark → collect all on-disk history. + /// </summary> + public override string? WatermarkColumn => "event_time"; + + /// <summary> + /// Collects on SQL Server, Azure SQL Managed Instance, and AWS RDS — everywhere the built-in default + /// trace exists. NOT Azure SQL Database (edition 5): there is no default trace there. A verified + /// platform gap gated here, not a scope-down (see the class remarks). + /// </summary> + public override bool AppliesTo(CollectorTargetInfo target) => !target.IsAzureSqlDb; + + public override CollectorQuery BuildQuery(CollectorContext context) + { + /* Per-server excluded databases apply to the trace's own NULLABLE DatabaseName — NULL-safe, so + server-level curated events (Server Memory Change, server-scoped audits/ErrorLog) that carry a + NULL DatabaseName are KEPT rather than silently dropped by a bare `NOT IN`. Spliced onto its own + line (empty => nothing added) so the generated T-SQL stays readable. */ + var (exclusionClause, exclusionParameters) = BuildNullSafeDatabaseExclusion(context.ExcludedDatabases); + var exclusionSplice = exclusionClause.Length == 0 ? string.Empty : "\r\n" + exclusionClause; + + var text = string.Format(CultureInfo.InvariantCulture, QueryTemplate, exclusionSplice); + + /* Cutoff selection (the Dashboard's first-run guard, ported): + - watermark present -> steady state, collect newer than it. + - watermark null, never succeeded -> TRUE first run, collect ALL on-disk .trc history. + - watermark null, HAS succeeded -> hot store emptied by retention/archival, use a BOUNDED + recent window so we never re-scan .trc events already in + the parquet archive (which the v_ view would double-count). */ + var cutoffTime = context.Watermark + ?? (context.HasCollectedBefore + ? context.CollectionTime.AddHours(-ArchivalEmptyFallbackHours) + : FirstRunCutoff); + + var parameters = new List<CollectorParameter>(exclusionParameters.Count + 2) + { + new("@cutoff_time", cutoffTime, CollectorParameterType.DateTime2), + /* Operator toggle mirroring the Dashboard proc's @include_object_events: 1 keeps the Object DDL + slice (today's behavior), 0 drops it so a create/drop-happy workload can't flood the tab. */ + new("@include_object_ddl", context.CollectSchemaChangeEvents ? 1 : 0, CollectorParameterType.Int32), + }; + parameters.AddRange(exclusionParameters); + + return new CollectorQuery(text, parameters); + } + + /// <summary> + /// The NULL-safe per-server excluded-database predicate for the trace's nullable <c>DatabaseName</c>. + /// Server-level curated events carry a NULL DatabaseName; a bare <c>NOT IN (...)</c> evaluates NULL to + /// UNKNOWN and would silently drop the WHOLE category whenever any database is excluded, so this keeps + /// NULL rows (<c>ft.DatabaseName IS NULL OR ...</c>) — matching the Dashboard's id-based, NULL-safe + /// exclusion. The shared <see cref="DatabaseExclusionFilter"/> is deliberately NOT reused here: its + /// other callers filter a non-nullable name column and must keep emitting the bare form. + /// </summary> + private static (string Clause, List<CollectorParameter> Parameters) BuildNullSafeDatabaseExclusion( + IReadOnlyList<string> excludedDatabaseNames) + { + if (excludedDatabaseNames.Count == 0) + { + return (string.Empty, new List<CollectorParameter>()); + } + + var paramNames = new List<string>(excludedDatabaseNames.Count); + var parameters = new List<CollectorParameter>(excludedDatabaseNames.Count); + for (int i = 0; i < excludedDatabaseNames.Count; i++) + { + var p = $"@excl_db_{i}"; + paramNames.Add(p); + parameters.Add(new CollectorParameter(p, excludedDatabaseNames[i], CollectorParameterType.NVarChar128)); + } + + return ($"AND (ft.DatabaseName IS NULL OR ft.DatabaseName NOT IN ({string.Join(", ", paramNames)}))", parameters); + } + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("event_time", CollectorColumnType.Timestamp), + new CollectorColumn("event_name", CollectorColumnType.Varchar), + new CollectorColumn("event_class", CollectorColumnType.Integer), + new CollectorColumn("spid", CollectorColumnType.Integer), + new CollectorColumn("database_name", CollectorColumnType.Varchar), + new CollectorColumn("database_id", CollectorColumnType.Integer), + new CollectorColumn("login_name", CollectorColumnType.Varchar), + new CollectorColumn("host_name", CollectorColumnType.Varchar), + new CollectorColumn("application_name", CollectorColumnType.Varchar), + new CollectorColumn("object_name", CollectorColumnType.Varchar), + new CollectorColumn("filename", CollectorColumnType.Varchar), + new CollectorColumn("integer_data", CollectorColumnType.BigInt), + new CollectorColumn("integer_data_2", CollectorColumnType.BigInt), + new CollectorColumn("text_data", CollectorColumnType.Varchar), + new CollectorColumn("session_login_name", CollectorColumnType.Varchar), + new CollectorColumn("error_number", CollectorColumnType.Integer), + new CollectorColumn("severity", CollectorColumnType.Integer), + new CollectorColumn("state", CollectorColumnType.Integer), + new CollectorColumn("event_sequence", CollectorColumnType.BigInt), + new CollectorColumn("duration_us", CollectorColumnType.BigInt), + new CollectorColumn("end_time", CollectorColumnType.Timestamp), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var rows = new List<Row>(); + + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new Row + { + EventTime = reader.IsDBNull(0) ? null : reader.GetDateTime(0), + EventName = reader.IsDBNull(1) ? null : reader.GetString(1), + EventClass = reader.IsDBNull(2) ? null : Convert.ToInt32(reader.GetValue(2), CultureInfo.InvariantCulture), + Spid = reader.IsDBNull(3) ? null : Convert.ToInt32(reader.GetValue(3), CultureInfo.InvariantCulture), + DatabaseName = reader.IsDBNull(4) ? null : reader.GetString(4), + DatabaseId = reader.IsDBNull(5) ? null : Convert.ToInt32(reader.GetValue(5), CultureInfo.InvariantCulture), + LoginName = reader.IsDBNull(6) ? null : reader.GetString(6), + HostName = reader.IsDBNull(7) ? null : reader.GetString(7), + ApplicationName = reader.IsDBNull(8) ? null : reader.GetString(8), + ObjectName = reader.IsDBNull(9) ? null : reader.GetString(9), + Filename = reader.IsDBNull(10) ? null : reader.GetString(10), + IntegerData = reader.IsDBNull(11) ? null : Convert.ToInt64(reader.GetValue(11), CultureInfo.InvariantCulture), + IntegerData2 = reader.IsDBNull(12) ? null : Convert.ToInt64(reader.GetValue(12), CultureInfo.InvariantCulture), + TextData = reader.IsDBNull(13) ? null : reader.GetString(13), + SessionLoginName = reader.IsDBNull(14) ? null : reader.GetString(14), + ErrorNumber = reader.IsDBNull(15) ? null : Convert.ToInt32(reader.GetValue(15), CultureInfo.InvariantCulture), + Severity = reader.IsDBNull(16) ? null : Convert.ToInt32(reader.GetValue(16), CultureInfo.InvariantCulture), + State = reader.IsDBNull(17) ? null : Convert.ToInt32(reader.GetValue(17), CultureInfo.InvariantCulture), + EventSequence = reader.IsDBNull(18) ? null : Convert.ToInt64(reader.GetValue(18), CultureInfo.InvariantCulture), + DurationUs = reader.IsDBNull(19) ? null : Convert.ToInt64(reader.GetValue(19), CultureInfo.InvariantCulture), + EndTime = reader.IsDBNull(20) ? null : reader.GetDateTime(20), + }); + } + + return rows; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + writer + .Value(row.EventTime) /* event_time TIMESTAMP (watermark) */ + .Value(row.EventName) /* event_name VARCHAR */ + .Value(row.EventClass) /* event_class INTEGER */ + .Value(row.Spid) /* spid INTEGER */ + .Value(row.DatabaseName) /* database_name VARCHAR */ + .Value(row.DatabaseId) /* database_id INTEGER */ + .Value(row.LoginName) /* login_name VARCHAR */ + .Value(row.HostName) /* host_name VARCHAR */ + .Value(row.ApplicationName) /* application_name VARCHAR */ + .Value(row.ObjectName) /* object_name VARCHAR */ + .Value(row.Filename) /* filename VARCHAR */ + .Value(row.IntegerData) /* integer_data BIGINT */ + .Value(row.IntegerData2) /* integer_data_2 BIGINT */ + .Value(row.TextData) /* text_data VARCHAR */ + .Value(row.SessionLoginName) /* session_login_name VARCHAR */ + .Value(row.ErrorNumber) /* error_number INTEGER */ + .Value(row.Severity) /* severity INTEGER */ + .Value(row.State) /* state INTEGER */ + .Value(row.EventSequence) /* event_sequence BIGINT */ + .Value(row.DurationUs) /* duration_us BIGINT */ + .Value(row.EndTime); /* end_time TIMESTAMP */ + } +} diff --git a/PerformanceMonitor.Collectors/DmvBlockingSnapshotCollector.cs b/PerformanceMonitor.Collectors/DmvBlockingSnapshotCollector.cs new file mode 100644 index 000000000..751ba90b3 --- /dev/null +++ b/PerformanceMonitor.Collectors/DmvBlockingSnapshotCollector.cs @@ -0,0 +1,240 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Always-on DMV blocking snapshot (blocker → blocked pair rows from sys.dm_os_waiting_tasks + +/// sys.dm_exec_*), the BPR-independent fallback for when the blocked_process_report XE captured +/// nothing. Extracted verbatim from Lite's RemoteCollectorService.DmvBlockingSnapshot.cs, +/// including the layered minimum-wait floors (LCK 2s / PAGELATCH 0.5s / PAGEIOLATCH 1s / +/// RESOURCE_SEMAPHORE 5s — kept in lockstep with install/56) and the SYNTHETIC NEGATIVE +/// monitor_loop (seconds since 2020-01-01, negated) so a DMV episode can never collide with a +/// real, non-negative BPR monitorLoop. +/// </summary> +public sealed class DmvBlockingSnapshotCollector : CollectorDefinitionBase<DmvBlockingSnapshotCollector.Row> +{ + public static DmvBlockingSnapshotCollector Instance { get; } = new(); + + private DmvBlockingSnapshotCollector() + { + } + + public readonly record struct Row( + string? DatabaseName, + int BlockedSpid, + int BlockedEcid, + DateTime? BlockedLastTranStarted, + int BlockingSpid, + int BlockingEcid, + DateTime? BlockingLastTranStarted, + long WaitTimeMs, + string? LockMode, + string? BlockingStatus, + string? ContentiousObject, + string? BlockedSqlText, + string? BlockingSqlText, + string? BlockedLoginName, + string? BlockedHostName, + string? BlockedClientApp, + string? BlockingLoginName, + string? BlockingHostName, + string? BlockingClientApp); + + private static readonly DateTime s_monitorLoopEpoch = new(2020, 1, 1); + + /// <summary> + /// Synthetic monitor_loop, NEGATIVE so it can never collide with a real (non-negative) BPR + /// monitorLoop. One value per cycle, distinct per second (collection cadence is >= 1 minute). + /// </summary> + public static int SyntheticMonitorLoop(DateTime collectionTime) + => -(int)(collectionTime - s_monitorLoopEpoch).TotalSeconds; + + private const string QueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT /* PerformanceMonitorLite */ + database_name = DB_NAME(der_b.database_id), + blocked_spid = CONVERT(integer, wt.session_id), + blocked_ecid = CONVERT(integer, wt.exec_context_id), + blocked_last_tran_started = tat_b.transaction_begin_time, + blocking_spid = CONVERT(integer, wt.blocking_session_id), + blocking_ecid = CONVERT(integer, ISNULL(wt.blocking_exec_context_id, 0)), + blocking_last_tran_started = tat_k.transaction_begin_time, + wait_time_ms = wt.wait_duration_ms, + lock_mode = REPLACE(REPLACE(REPLACE(wt.wait_type, N'LCK_M_', N''), N'PAGEIOLATCH_', N''), N'PAGELATCH_', N''), + blocking_status = ISNULL(der_k.status, ses_k.status), + contentious_object = + CASE + WHEN objparse.object_id IS NOT NULL + THEN ISNULL(QUOTENAME(OBJECT_SCHEMA_NAME(objparse.object_id, objparse.database_id)) + N'.' + QUOTENAME(OBJECT_NAME(objparse.object_id, objparse.database_id)), der_b.wait_resource) + ELSE der_b.wait_resource + END, + blocked_sql_text = dest_b.text, + blocking_sql_text = dest_k.text, + blocked_login_name = ses_b.login_name, + blocked_host_name = ses_b.host_name, + blocked_client_app = ses_b.program_name, + blocking_login_name = ses_k.login_name, + blocking_host_name = ses_k.host_name, + blocking_client_app = ses_k.program_name +FROM sys.dm_os_waiting_tasks AS wt +JOIN sys.dm_exec_sessions AS ses_b + ON ses_b.session_id = wt.session_id +LEFT JOIN sys.dm_exec_requests AS der_b + ON der_b.session_id = wt.session_id +LEFT JOIN sys.dm_exec_sessions AS ses_k + ON ses_k.session_id = wt.blocking_session_id +LEFT JOIN sys.dm_exec_requests AS der_k + ON der_k.session_id = wt.blocking_session_id +LEFT JOIN sys.dm_exec_connections AS con_k + ON con_k.session_id = wt.blocking_session_id +OUTER APPLY sys.dm_exec_sql_text(der_b.sql_handle) AS dest_b +OUTER APPLY sys.dm_exec_sql_text(COALESCE(der_k.sql_handle, con_k.most_recent_sql_handle)) AS dest_k +OUTER APPLY +( + SELECT TOP (1) tat.transaction_begin_time + FROM sys.dm_tran_session_transactions AS stx + JOIN sys.dm_tran_active_transactions AS tat + ON tat.transaction_id = stx.transaction_id + WHERE stx.session_id = wt.session_id + ORDER BY tat.transaction_begin_time ASC +) AS tat_b +OUTER APPLY +( + SELECT TOP (1) tat.transaction_begin_time + FROM sys.dm_tran_session_transactions AS stx + JOIN sys.dm_tran_active_transactions AS tat + ON tat.transaction_id = stx.transaction_id + WHERE stx.session_id = wt.blocking_session_id + ORDER BY tat.transaction_begin_time ASC +) AS tat_k +OUTER APPLY +( + SELECT + database_id = TRY_CONVERT(integer, PARSENAME(REPLACE(SUBSTRING(der_b.wait_resource, 9, 200), N':', N'.'), 3)), + object_id = TRY_CONVERT(integer, PARSENAME(REPLACE(SUBSTRING(der_b.wait_resource, 9, 200), N':', N'.'), 2)) + WHERE der_b.wait_resource LIKE N'OBJECT: %' +) AS objparse +WHERE wt.blocking_session_id IS NOT NULL +AND wt.blocking_session_id <> 0 +AND wt.blocking_session_id <> wt.session_id +AND wt.session_id > 50 +AND wt.session_id <> @@SPID +AND ( + wt.wait_type LIKE N'LCK[_]%' + OR wt.wait_type LIKE N'PAGELATCH[_]%' + OR wt.wait_type LIKE N'PAGEIOLATCH[_]%' + OR wt.wait_type LIKE N'RESOURCE_SEMAPHORE%' + ) +/* Layered minimum-wait floor (kept in lockstep with install/56): locks must persist to matter (2s); page + latches churn faster (PAGELATCH 0.5s, PAGEIOLATCH 1s); memory-grant / compile-gate waits run long, so 5s. */ +AND wt.wait_duration_ms >= + CASE + WHEN wt.wait_type LIKE N'LCK[_]%' THEN 2000 + WHEN wt.wait_type LIKE N'PAGELATCH[_]%' THEN 500 + WHEN wt.wait_type LIKE N'PAGEIOLATCH[_]%' THEN 1000 + WHEN wt.wait_type LIKE N'RESOURCE_SEMAPHORE%' THEN 5000 + ELSE 1000 + END +OPTION(RECOMPILE);"; + + public override string Name => "dmv_blocking_snapshot"; + + public override string TargetTable => "dmv_blocking_snapshots"; + + public override CollectorQuery BuildQuery(CollectorContext context) => new(QueryText); + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("monitor_loop", CollectorColumnType.Integer), + new CollectorColumn("event_time", CollectorColumnType.Timestamp), + new CollectorColumn("database_name", CollectorColumnType.Varchar), + new CollectorColumn("blocked_spid", CollectorColumnType.Integer), + new CollectorColumn("blocked_ecid", CollectorColumnType.Integer), + new CollectorColumn("blocked_last_tran_started", CollectorColumnType.Timestamp), + new CollectorColumn("blocking_spid", CollectorColumnType.Integer), + new CollectorColumn("blocking_ecid", CollectorColumnType.Integer), + new CollectorColumn("blocking_last_tran_started", CollectorColumnType.Timestamp), + new CollectorColumn("wait_time_ms", CollectorColumnType.BigInt), + new CollectorColumn("lock_mode", CollectorColumnType.Varchar), + new CollectorColumn("blocking_status", CollectorColumnType.Varchar), + new CollectorColumn("contentious_object", CollectorColumnType.Varchar), + new CollectorColumn("blocked_sql_text", CollectorColumnType.Varchar), + new CollectorColumn("blocking_sql_text", CollectorColumnType.Varchar), + new CollectorColumn("blocked_login_name", CollectorColumnType.Varchar), + new CollectorColumn("blocked_host_name", CollectorColumnType.Varchar), + new CollectorColumn("blocked_client_app", CollectorColumnType.Varchar), + new CollectorColumn("blocking_login_name", CollectorColumnType.Varchar), + new CollectorColumn("blocking_host_name", CollectorColumnType.Varchar), + new CollectorColumn("blocking_client_app", CollectorColumnType.Varchar), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var rows = new List<Row>(); + + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new Row( + reader.IsDBNull(0) ? null : reader.GetString(0), + reader.IsDBNull(1) ? 0 : reader.GetInt32(1), + reader.IsDBNull(2) ? 0 : reader.GetInt32(2), + reader.IsDBNull(3) ? null : reader.GetDateTime(3), + reader.IsDBNull(4) ? 0 : reader.GetInt32(4), + reader.IsDBNull(5) ? 0 : reader.GetInt32(5), + reader.IsDBNull(6) ? null : reader.GetDateTime(6), + reader.IsDBNull(7) ? 0L : reader.GetInt64(7), + reader.IsDBNull(8) ? null : reader.GetString(8), + reader.IsDBNull(9) ? null : reader.GetString(9), + reader.IsDBNull(10) ? null : reader.GetString(10), + reader.IsDBNull(11) ? null : reader.GetString(11), + reader.IsDBNull(12) ? null : reader.GetString(12), + reader.IsDBNull(13) ? null : reader.GetString(13), + reader.IsDBNull(14) ? null : reader.GetString(14), + reader.IsDBNull(15) ? null : reader.GetString(15), + reader.IsDBNull(16) ? null : reader.GetString(16), + reader.IsDBNull(17) ? null : reader.GetString(17), + reader.IsDBNull(18) ? null : reader.GetString(18))); + } + + return rows; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + writer + .Value(SyntheticMonitorLoop(context.CollectionTime)) /* monitor_loop INTEGER (negative) */ + .Value(context.CollectionTime) /* event_time TIMESTAMP */ + .Value(row.DatabaseName) + .Value(row.BlockedSpid) + .Value(row.BlockedEcid) + .Value(row.BlockedLastTranStarted) + .Value(row.BlockingSpid) + .Value(row.BlockingEcid) + .Value(row.BlockingLastTranStarted) + .Value(row.WaitTimeMs) + .Value(row.LockMode) + .Value(row.BlockingStatus) + .Value(row.ContentiousObject) + .Value(row.BlockedSqlText) + .Value(row.BlockingSqlText) + .Value(row.BlockedLoginName) + .Value(row.BlockedHostName) + .Value(row.BlockedClientApp) + .Value(row.BlockingLoginName) + .Value(row.BlockingHostName) + .Value(row.BlockingClientApp); + } +} diff --git a/PerformanceMonitor.Collectors/EnumeratedCollectorDriver.cs b/PerformanceMonitor.Collectors/EnumeratedCollectorDriver.cs new file mode 100644 index 000000000..2fbbe31d0 --- /dev/null +++ b/PerformanceMonitor.Collectors/EnumeratedCollectorDriver.cs @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary>Per-run outcome of the enumeration driver: rows written and the summed SQL/storage slice times.</summary> +public readonly record struct EnumeratedRunResult(int Rows, long SqlMs, long StorageMs); + +/// <summary> +/// The shared control-flow driver for the enumeration collectors' per-item loop (#1556). Both hosts +/// (Lite → DuckDB, Darling → Postgres) ran a byte-identical per-item loop that accumulated EVERY +/// database's rows into one list before a single write — the shape that let one 24-server query_store +/// cycle balloon to 13GB. Extracting the loop here does two things at once: it FLUSHES each item's +/// batch before reading the next (so peak memory is one database's rows, not the fleet's), and it +/// removes the duplicate that let the same defect live in two runners. +/// +/// <para> +/// The driver owns only the control flow — iteration, cancellation, the per-item catch SHAPE, the +/// per-item flush, and the interleaved SQL/storage timing. Everything app-specific stays in the +/// caller's delegates: the SQL connection and per-item query (readItem), the storage engine +/// (writeBatch), the host store's per-database watermark read and its 24h clamp (perItemWatermark), +/// and the log text / display name (onItemComplete / onItemError). This is the seam the plan required: +/// no app or collector semantics leak into the shared loop. +/// </para> +/// </summary> +public static class EnumeratedCollectorDriver +{ + /// <summary> + /// Runs the per-item loop: for each item, (optionally) refresh its per-database watermark, read its + /// rows, surface the cap/byte-budget WARNING, then flush that batch before moving on. + /// </summary> + /// <param name="items">The enumerated items (database names), already listed by the caller.</param> + /// <param name="perItemWatermark"> + /// Refreshes <see cref="CollectorContext.Watermark"/> for this item before its query is built — the + /// per-database watermark read plus its clamp. Null when the definition has no per-database watermark + /// (the single server-wide watermark already sits on the context). + /// </param> + /// <param name="readItem"> + /// The SQL phase: builds the per-item query, runs it, and materializes the batch. Returns a non-null + /// (possibly empty) list. Its wall time is summed into <see cref="EnumeratedRunResult.SqlMs"/>. + /// </param> + /// <param name="writeBatch"> + /// The storage phase: writes ONE item's batch to the host store. Skipped for an empty batch. Its wall + /// time is summed into <see cref="EnumeratedRunResult.StorageMs"/>. A flush failure PROPAGATES — + /// storage failure is systemic, and batches already flushed stay committed (commit-1..N-1 on abort). + /// </param> + /// <param name="onItemComplete">Per-item completion hook (item, batch count, SQL ms, storage ms), + /// invoked after a successful read AND its flush (#1565: the hosts log a per-database line from this, + /// so a burst on one database is visible instead of blending into the per-server total; they also + /// surface the row-cap / byte-budget warning here — the context truncation signal persists until the + /// next item's read resets it, so reading it post-flush is equivalent).</param> + /// <param name="onItemError">Per-item skip log, invoked when one item fails (offline DB, timeout, permissions).</param> + public static async Task<EnumeratedRunResult> RunAsync<TRow>( + IReadOnlyList<string> items, + Func<string, CancellationToken, Task>? perItemWatermark, + Func<string, CancellationToken, Task<List<TRow>>> readItem, + Func<List<TRow>, CancellationToken, Task> writeBatch, + Action<string, int, long, long> onItemComplete, + Action<string, Exception> onItemError, + CancellationToken cancellationToken) + { + var totalRows = 0; + long sqlMs = 0; + long storageMs = 0; + + foreach (var item in items) + { + cancellationToken.ThrowIfCancellationRequested(); + + List<TRow>? batch = null; + long itemSqlMs = 0; + var sqlSlice = Stopwatch.StartNew(); + try + { + /* Per-database watermark refresh (query_store): its cutoff — including the 24h catch-up + clamp — is computed HERE, inside the loop, so each database's commit advances only its + own watermark and an abort loses no other database's intervals. */ + if (perItemWatermark is not null) + { + await perItemWatermark(item, cancellationToken); + } + + batch = await readItem(item, cancellationToken); + } + catch (OutOfMemoryException) + { + /* OOM is filtered OUT of the per-item skip below and rethrown: it is fatal to this run, + not a routine one-database skip. There is no cross-item accumulator to clear — the + per-item batch is a local that unwinds with this frame — so filter+rethrow is the whole + handler; the host classifies the run ERROR. */ + throw; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + /* One item failing is routine (an offline/mid-restore database, a permissions oddity, a + timeout) — skip it and keep collecting the rest, matching the original per-item loop. + OCE and OOM deliberately propagate (they are not per-item faults). */ + onItemError(item, ex); + } + finally + { + itemSqlMs = sqlSlice.ElapsedMilliseconds; + sqlMs += itemSqlMs; + } + + /* A null batch means the read faulted and was skipped above; a successful read is a non-null + (possibly empty) list. */ + if (batch is null) + { + continue; + } + + /* Empty batch: no COPY/appender opened (rows_collected = Σ non-empty batch counts). */ + long itemStorageMs = 0; + if (batch.Count > 0) + { + var storageSlice = Stopwatch.StartNew(); + await writeBatch(batch, cancellationToken); + itemStorageMs = storageSlice.ElapsedMilliseconds; + storageMs += itemStorageMs; + totalRows += batch.Count; + } + + /* Completion hook AFTER the flush so it carries both per-item slices (#1565). The context + truncation signal is still this item's — the next read resets it. */ + onItemComplete(item, batch.Count, itemSqlMs, itemStorageMs); + } + + return new EnumeratedRunResult(totalRows, sqlMs, storageMs); + } +} diff --git a/PerformanceMonitor.Collectors/FileIoStatsCollector.cs b/PerformanceMonitor.Collectors/FileIoStatsCollector.cs new file mode 100644 index 000000000..f6db5cde7 --- /dev/null +++ b/PerformanceMonitor.Collectors/FileIoStatsCollector.cs @@ -0,0 +1,219 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// File I/O statistics from sys.dm_io_virtual_file_stats. Extracted verbatim from Lite's +/// RemoteCollectorService.FileIo.cs. On-prem/MI reads server-wide (joined to sys.master_files, +/// with the per-server database-exclusion filter spliced at /*EXCLUSION_FILTER*/); Azure SQL DB +/// (edition 5) has no sys.master_files and scopes the DMV to the connected database, so the +/// definition runs per database there (RunsPerDatabase) with database-level filtering handled by +/// the host's database enumeration. Eight delta groups keyed "{database}|{file}". +/// </summary> +public sealed class FileIoStatsCollector : CollectorDefinitionBase<FileIoStatsCollector.Row> +{ + public static FileIoStatsCollector Instance { get; } = new(); + + private FileIoStatsCollector() + { + } + + public readonly record struct Row( + string DatabaseName, + string FileName, + string FileType, + string PhysicalName, + decimal SizeMb, + long NumOfReads, + long NumOfWrites, + long ReadBytes, + long WriteBytes, + long IoStallReadMs, + long IoStallWriteMs, + long IoStallQueuedReadMs, + long IoStallQueuedWriteMs, + int DatabaseId, + int FileId); + + private const string AzureSqlDbQueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + database_name = DB_NAME(), + file_name = df.name, + file_type = df.type_desc, + physical_name = df.physical_name, + size_mb = CONVERT(decimal(18,2), vfs.size_on_disk_bytes / 1048576.0), + num_of_reads = vfs.num_of_reads, + num_of_writes = vfs.num_of_writes, + read_bytes = vfs.num_of_bytes_read, + write_bytes = vfs.num_of_bytes_written, + io_stall_read_ms = vfs.io_stall_read_ms, + io_stall_write_ms = vfs.io_stall_write_ms, + io_stall_queued_read_ms = vfs.io_stall_queued_read_ms, + io_stall_queued_write_ms = vfs.io_stall_queued_write_ms, + database_id = vfs.database_id, + file_id = vfs.file_id +FROM sys.dm_io_virtual_file_stats(DB_ID(), NULL) AS vfs +LEFT JOIN sys.database_files AS df + ON df.file_id = vfs.file_id +OPTION(RECOMPILE);"; + + private const string OnPremQueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + database_name = ISNULL(d.name, DB_NAME(vfs.database_id)), + file_name = ISNULL(mf.name, N'File_' + CONVERT(nvarchar(10), vfs.file_id)), + file_type = ISNULL(mf.type_desc, N'UNKNOWN'), + physical_name = ISNULL(mf.physical_name, N''), + size_mb = CONVERT(decimal(18,2), vfs.size_on_disk_bytes / 1048576.0), + num_of_reads = vfs.num_of_reads, + num_of_writes = vfs.num_of_writes, + read_bytes = vfs.num_of_bytes_read, + write_bytes = vfs.num_of_bytes_written, + io_stall_read_ms = vfs.io_stall_read_ms, + io_stall_write_ms = vfs.io_stall_write_ms, + io_stall_queued_read_ms = vfs.io_stall_queued_read_ms, + io_stall_queued_write_ms = vfs.io_stall_queued_write_ms, + database_id = vfs.database_id, + file_id = vfs.file_id +FROM sys.dm_io_virtual_file_stats(NULL, NULL) AS vfs +LEFT JOIN sys.master_files AS mf + ON mf.database_id = vfs.database_id + AND mf.file_id = vfs.file_id +LEFT JOIN sys.databases AS d + ON d.database_id = vfs.database_id +WHERE (vfs.database_id > 4 OR vfs.database_id = 2) +AND vfs.database_id < 32761 +AND vfs.database_id <> ISNULL(DB_ID(N'PerformanceMonitor'), 0) +/*EXCLUSION_FILTER*/ +OPTION(RECOMPILE);"; + + public override string Name => "file_io_stats"; + + public override string TargetTable => "file_io_stats"; + + public override string? WatermarkColumn => null; + + public override bool AppliesTo(CollectorTargetInfo target) => true; + + /// <summary>Azure SQL DB scopes dm_io_virtual_file_stats to the connected database.</summary> + public override bool RunsPerDatabase(CollectorTargetInfo target) => target.IsAzureSqlDb; + + public override CollectorQuery BuildQuery(CollectorContext context) + { + if (context.Target.IsAzureSqlDb) + { + /* Database exclusion happens in the host's database enumeration on Azure. */ + return new CollectorQuery(AzureSqlDbQueryText); + } + + var (exclusionClause, exclusionParameters) = DatabaseExclusionFilter.Build(context.ExcludedDatabases, "d.name"); + return new CollectorQuery( + OnPremQueryText.Replace("/*EXCLUSION_FILTER*/", exclusionClause, StringComparison.Ordinal), + exclusionParameters); + } + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("database_name", CollectorColumnType.Varchar), + new CollectorColumn("file_name", CollectorColumnType.Varchar), + new CollectorColumn("file_type", CollectorColumnType.Varchar), + new CollectorColumn("physical_name", CollectorColumnType.Varchar), + new CollectorColumn("size_mb", CollectorColumnType.Decimal, 18, 2), + new CollectorColumn("num_of_reads", CollectorColumnType.BigInt), + new CollectorColumn("num_of_writes", CollectorColumnType.BigInt), + new CollectorColumn("read_bytes", CollectorColumnType.BigInt), + new CollectorColumn("write_bytes", CollectorColumnType.BigInt), + new CollectorColumn("io_stall_read_ms", CollectorColumnType.BigInt), + new CollectorColumn("io_stall_write_ms", CollectorColumnType.BigInt), + new CollectorColumn("io_stall_queued_read_ms", CollectorColumnType.BigInt), + new CollectorColumn("io_stall_queued_write_ms", CollectorColumnType.BigInt), + new CollectorColumn("delta_reads", CollectorColumnType.BigInt), + new CollectorColumn("delta_writes", CollectorColumnType.BigInt), + new CollectorColumn("delta_read_bytes", CollectorColumnType.BigInt), + new CollectorColumn("delta_write_bytes", CollectorColumnType.BigInt), + new CollectorColumn("delta_stall_read_ms", CollectorColumnType.BigInt), + new CollectorColumn("delta_stall_write_ms", CollectorColumnType.BigInt), + new CollectorColumn("delta_stall_queued_read_ms", CollectorColumnType.BigInt), + new CollectorColumn("delta_stall_queued_write_ms", CollectorColumnType.BigInt), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var rows = new List<Row>(); + + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new Row( + reader.IsDBNull(0) ? "Unknown" : reader.GetString(0), + reader.IsDBNull(1) ? "Unknown" : reader.GetString(1), + reader.IsDBNull(2) ? "Unknown" : reader.GetString(2), + reader.IsDBNull(3) ? "" : reader.GetString(3), + reader.IsDBNull(4) ? 0m : reader.GetDecimal(4), + reader.IsDBNull(5) ? 0L : reader.GetInt64(5), + reader.IsDBNull(6) ? 0L : reader.GetInt64(6), + reader.IsDBNull(7) ? 0L : reader.GetInt64(7), + reader.IsDBNull(8) ? 0L : reader.GetInt64(8), + reader.IsDBNull(9) ? 0L : reader.GetInt64(9), + reader.IsDBNull(10) ? 0L : reader.GetInt64(10), + reader.IsDBNull(11) ? 0L : reader.GetInt64(11), + reader.IsDBNull(12) ? 0L : reader.GetInt64(12), + reader.IsDBNull(13) ? 0 : Convert.ToInt32(reader.GetValue(13), CultureInfo.InvariantCulture), + reader.IsDBNull(14) ? 0 : Convert.ToInt32(reader.GetValue(14), CultureInfo.InvariantCulture))); + } + + return rows; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + /* "{database}|{file}" delta key and the eight group names are the parity contract. */ + var deltaKey = $"{row.DatabaseName}|{row.FileName}"; + var deltaReads = context.Deltas.CalculateDelta(context.ServerId, "file_io_reads", deltaKey, row.NumOfReads, collectionTime: context.CollectionTime, maxGapSeconds: 300); + var deltaWrites = context.Deltas.CalculateDelta(context.ServerId, "file_io_writes", deltaKey, row.NumOfWrites, collectionTime: context.CollectionTime, maxGapSeconds: 300); + var deltaReadBytes = context.Deltas.CalculateDelta(context.ServerId, "file_io_read_bytes", deltaKey, row.ReadBytes, collectionTime: context.CollectionTime, maxGapSeconds: 300); + var deltaWriteBytes = context.Deltas.CalculateDelta(context.ServerId, "file_io_write_bytes", deltaKey, row.WriteBytes, collectionTime: context.CollectionTime, maxGapSeconds: 300); + var deltaStallReadMs = context.Deltas.CalculateDelta(context.ServerId, "file_io_stall_read", deltaKey, row.IoStallReadMs, collectionTime: context.CollectionTime, maxGapSeconds: 300); + var deltaStallWriteMs = context.Deltas.CalculateDelta(context.ServerId, "file_io_stall_write", deltaKey, row.IoStallWriteMs, collectionTime: context.CollectionTime, maxGapSeconds: 300); + var deltaStallQueuedReadMs = context.Deltas.CalculateDelta(context.ServerId, "file_io_stall_queued_read", deltaKey, row.IoStallQueuedReadMs, collectionTime: context.CollectionTime, maxGapSeconds: 300); + var deltaStallQueuedWriteMs = context.Deltas.CalculateDelta(context.ServerId, "file_io_stall_queued_write", deltaKey, row.IoStallQueuedWriteMs, collectionTime: context.CollectionTime, maxGapSeconds: 300); + + writer + .Value(row.DatabaseName) + .Value(row.FileName) + .Value(row.FileType) + .Value(row.PhysicalName) + .Value(row.SizeMb) + .Value(row.NumOfReads) + .Value(row.NumOfWrites) + .Value(row.ReadBytes) + .Value(row.WriteBytes) + .Value(row.IoStallReadMs) + .Value(row.IoStallWriteMs) + .Value(row.IoStallQueuedReadMs) + .Value(row.IoStallQueuedWriteMs) + .Value(deltaReads) + .Value(deltaWrites) + .Value(deltaReadBytes) + .Value(deltaWriteBytes) + .Value(deltaStallReadMs) + .Value(deltaStallWriteMs) + .Value(deltaStallQueuedReadMs) + .Value(deltaStallQueuedWriteMs); + } +} diff --git a/PerformanceMonitor.Collectors/ICollectorDefinition.cs b/PerformanceMonitor.Collectors/ICollectorDefinition.cs new file mode 100644 index 000000000..1e05f2e87 --- /dev/null +++ b/PerformanceMonitor.Collectors/ICollectorDefinition.cs @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// A collector definition is the shared, engine-neutral "monitoring brain" for one collector: +/// the T-SQL sent to the monitored server, the result-row mapping, the delta rules, and the +/// payload column order. Both SKUs (portable Lite writing DuckDB, Darling writing Postgres) +/// run the SAME definition, so a change lands once and the compiler forces every host to keep up +/// — this is what makes behavioral parity structural rather than manual (headless plan v5.1). +/// Definitions are stateless and thread-safe; per-cycle state rides in <see cref="CollectorContext"/>. +/// </summary> +/// <typeparam name="TRow">The definition's materialized result-row shape.</typeparam> +public interface ICollectorDefinition<TRow> : ICollectorSchemaInfo +{ + /// <summary> + /// Per-collector command timeout override in seconds, for collectors whose sweep is far + /// heavier than the default budget (index_object_stats: 300 s per database — the #1135 fix). + /// Null = the host's default timeout. Applies to the main/per-item/per-database commands; + /// enumeration queries always use the host default, matching the originals. + /// </summary> + int? CommandTimeoutSecondsOverride { get; } + + /* AppliesTo(CollectorTargetInfo) is declared on the base ICollectorSchemaInfo — the single + authoritative target gate, evaluable by name off CollectorCatalog.All without the row type. */ + + /// <summary> + /// True when the query must run once per database with a per-database connection (Azure SQL + /// DB scopes some DMVs to the connected database — e.g. dm_io_virtual_file_stats). The host + /// enumerates databases, opens each connection, and calls <see cref="ReadAsync"/> per reader, + /// aggregating rows; a database that errors is skipped and logged, matching the original + /// collectors. + /// </summary> + bool RunsPerDatabase(CollectorTargetInfo target); + + /// <summary> + /// Time column the host should read its latest already-collected value of (from the host's + /// own store) before building the query — exposed to the definition as + /// <see cref="CollectorContext.Watermark"/> for server-side filters and client-side dedup. + /// Null when the collector needs no watermark (the common case). + /// </summary> + string? WatermarkColumn { get; } + + /// <summary> + /// Numeric (bigint) column the host should read its latest already-collected value of (from the + /// host's own store) before building the query — exposed to the definition as + /// <see cref="CollectorContext.NumericWatermark"/> for server-side filters and client-side dedup + /// on a monotonic identity/sequence column (job_history's <c>instance_id</c>). The bigint twin of + /// <see cref="WatermarkColumn"/>. Null when the collector needs no numeric watermark (the common + /// case — every existing collector). + /// </summary> + string? NumericWatermarkColumn { get; } + + /// <summary> + /// Table column that scopes <see cref="WatermarkColumn"/> per database when the collector + /// <see cref="RunsPerDatabase"/> (Azure SQL DB). Non-null means each per-database run gets its + /// own watermark (<c>MAX(WatermarkColumn) WHERE PerDatabaseWatermarkColumn = db</c>) and the + /// host rebuilds the query per database — one busy database's newer event can then never + /// watermark past another database's older event still sitting in its ring buffer (the XE + /// collectors' per-database sessions dispatch independently). Null (the common case) keeps the + /// single server-wide watermark and the build-once-per-cycle query. Ignored when + /// <see cref="RunsPerDatabase"/> is false for the target — the server-wide watermark is already + /// exact there. + /// </summary> + string? PerDatabaseWatermarkColumn { get; } + + /// <summary> + /// Per-enumerated-item row count at or above which the host logs a WARNING naming the server and + /// item (#1556): a per-database read that returns this many rows in a single cycle is producing far + /// more than a healthy volume and its oldest rows were trimmed by the definition's own server-side + /// backstop (query_store's <c>TOP</c>). Null (the common case) = no cap, no warning. One const with + /// the SQL <c>TOP</c>: the runner warns exactly when the definition's own backstop engaged. + /// </summary> + int? PerItemRowCountWarnThreshold { get; } + + /// <summary> + /// Cumulative per-enumerated-item TEXT byte budget (#1556): a definition carrying large nvarchar(max) + /// payloads (query_store's query text + plan XML) enforces this budget CLIENT-SIDE inside its + /// <see cref="ReadItemAsync"/> — accumulating the materialized text bytes and stopping the read once + /// the budget is reached, signalling truncation via <see cref="CollectorContext.PerItemTextBudgetExceeded"/> + /// so the host surfaces the same WARNING. This is the PRIMARY memory bound: a row COUNT cap does not + /// bound BYTES (50k rows each carrying a 40KB plan is 2GB), so the byte budget is what keeps peak + /// allocation bounded. Null (the common case) = no budget. Enforced by the definition, not the host — + /// only the definition holds the reader. + /// </summary> + int? PerItemTextByteBudget { get; } + + /// <summary> + /// Builds the T-SQL (and any bound parameters) for this cycle. Constant for most collectors; + /// target-aware definitions branch on <see cref="CollectorContext.Target"/> and + /// <see cref="CollectorContext.Watermark"/>. + /// </summary> + CollectorQuery BuildQuery(CollectorContext context); + + /// <summary> + /// Optional second query run best-effort on the same (single-path) connection after + /// <see cref="ReadAsync"/> — e.g. server_properties' WS5 health probe. Null = none (the + /// common case). The host isolates its failure: any exception is logged at debug and the + /// cycle proceeds with the primary rows unchanged, so a supplemental can never fail the + /// collector. Not executed for per-database collectors. + /// </summary> + CollectorQuery? BuildSupplementalQuery(CollectorContext context); + + /// <summary>Merges the supplemental reader's data into the already-read rows.</summary> + ValueTask ApplySupplementalAsync(List<TRow> rows, DbDataReader reader, CollectorContext context, CancellationToken cancellationToken); + + /// <summary> + /// Optional enumeration shape (the "[db].sys.sp_executesql" idiom): when non-null, the host + /// runs this query first (single string column, e.g. database names), then executes + /// <see cref="BuildPerItemQuery"/> once per item ON THE SAME CONNECTION, feeding each reader + /// to <see cref="ReadItemAsync"/>. An item whose query fails with a SqlException is skipped + /// with a warning, matching the original collectors. Zero items short-circuits the cycle. + /// <see cref="ReadAsync"/> is not called for enumerating collectors. + /// </summary> + CollectorQuery? BuildEnumerationQuery(CollectorContext context); + + /// <summary> + /// Optional quick scalar probe on the enumeration path, run once after items are listed + /// (only when at least one item exists) and before the per-item loop — e.g. query_store's + /// live PRODUCTVERSION check that decides its version-gated columns (deliberately probed + /// per cycle rather than trusting cached connection status, which can be version-unknown). + /// The host runs it best-effort with a short timeout and exposes the scalar as + /// <see cref="CollectorContext.EnumerationProbeResult"/>; on any failure the result stays + /// null and the definition uses its documented default. Null = no probe (the common case). + /// </summary> + CollectorQuery? BuildEnumerationProbe(CollectorContext context); + + /// <summary>Builds the per-item query for one enumerated item (e.g. one database).</summary> + CollectorQuery BuildPerItemQuery(string item, CollectorContext context); + + /// <summary>Reads one enumerated item's result rows, appending to the shared accumulator.</summary> + ValueTask ReadItemAsync(string item, DbDataReader reader, List<TRow> rows, CollectorContext context, CancellationToken cancellationToken); + + /// <summary> + /// Materializes result rows from the query's reader, applying any definition-owned filtering. + /// Runs entirely in the SQL phase so hosts can time SQL and storage phases separately. + /// </summary> + ValueTask<List<TRow>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken); + + /// <summary> + /// Emits one row's payload through the writer in <see cref="PayloadColumns"/> order, + /// computing any deltas via <see cref="CollectorContext.Deltas"/>. + /// </summary> + void WritePayload(TRow row, ICollectorRowWriter writer, CollectorContext context); +} diff --git a/PerformanceMonitor.Collectors/ICollectorDeltaCalculator.cs b/PerformanceMonitor.Collectors/ICollectorDeltaCalculator.cs new file mode 100644 index 000000000..cedd6e5f5 --- /dev/null +++ b/PerformanceMonitor.Collectors/ICollectorDeltaCalculator.cs @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Delta computation contract for cumulative DMV counters. Definitions own WHICH fields are +/// delta'd, under WHICH counter-group names and keys, and with WHICH gap policy — that is +/// parity-critical monitoring brain. Hosts supply the stateful implementation (Lite: +/// <c>DeltaCalculator</c>'s in-memory per-server cache; Darling: its service-side equivalent). +/// Semantics contract (mirrors Lite's DeltaCalculator): first sighting returns 0 and baselines; +/// counter reset (decrease) returns 0; a gap larger than <paramref name="maxGapSeconds"/> returns +/// 0 to avoid inflated deltas after restarts. +/// </summary> +public interface ICollectorDeltaCalculator +{ + long CalculateDelta(int serverId, string collectorName, string key, long currentValue, + DateTime? collectionTime = null, int maxGapSeconds = 0); + + long CalculateDeltaWithInterval(int serverId, string collectorName, string key, long currentValue, + out int intervalSeconds, DateTime? collectionTime = null, int maxGapSeconds = 0); +} diff --git a/PerformanceMonitor.Collectors/ICollectorRowWriter.cs b/PerformanceMonitor.Collectors/ICollectorRowWriter.cs new file mode 100644 index 000000000..70645cd80 --- /dev/null +++ b/PerformanceMonitor.Collectors/ICollectorRowWriter.cs @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Positional row writer a collector definition emits its payload through. Hosts adapt this onto +/// their storage engine's bulk writer (Lite: DuckDB appender row; Darling: Npgsql binary COPY row). +/// Column ORDER is the contract — it must match the definition's <c>PayloadColumns</c> exactly, +/// and hosts write their standard prefix (collection id/time, server identity) before handing the +/// row to the definition. +/// </summary> +public interface ICollectorRowWriter +{ + ICollectorRowWriter Value(string? value); + ICollectorRowWriter Value(long value); + ICollectorRowWriter Value(long? value); + ICollectorRowWriter Value(int value); + ICollectorRowWriter Value(int? value); + ICollectorRowWriter Value(short value); + ICollectorRowWriter Value(short? value); + ICollectorRowWriter Value(double value); + ICollectorRowWriter Value(double? value); + ICollectorRowWriter Value(decimal value); + ICollectorRowWriter Value(decimal? value); + ICollectorRowWriter Value(bool value); + ICollectorRowWriter Value(bool? value); + ICollectorRowWriter Value(DateTime value); + ICollectorRowWriter Value(DateTime? value); + ICollectorRowWriter NullValue(); +} diff --git a/PerformanceMonitor.Collectors/ICollectorSchemaInfo.cs b/PerformanceMonitor.Collectors/ICollectorSchemaInfo.cs new file mode 100644 index 000000000..54052554a --- /dev/null +++ b/PerformanceMonitor.Collectors/ICollectorSchemaInfo.cs @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Collections.Generic; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// The engine-neutral schema surface of a collector definition — everything a storage host needs +/// to create the destination table and address the standard prefix columns, without knowing the +/// definition's row type. <see cref="ICollectorDefinition{TRow}"/> extends this, so +/// <see cref="CollectorCatalog.All"/> can enumerate every definition heterogeneously (Darling +/// generates its Postgres DDL from exactly this surface). +/// </summary> +public interface ICollectorSchemaInfo +{ + /// <summary>Collector name as used in schedules and collection logs (e.g. "wait_stats").</summary> + string Name { get; } + + /// <summary>Destination table; hosts prepend their standard prefix columns when writing.</summary> + string TargetTable { get; } + + /// <summary> + /// Whether the target table's standard prefix includes an id as its first column + /// (running_jobs is keyed by (collection_time, server) alone and has none). + /// </summary> + bool IncludesCollectionId { get; } + + /// <summary> + /// Name of the prefix id column in the destination schema. "collection_id" almost everywhere; + /// the XE tables use "deadlock_id"/"blocked_report_id" and the config snapshots "config_id". + /// Storage is positional in Lite (DuckDB appender), so these names only matter to hosts that + /// generate DDL or address columns by name (Darling's Postgres store, which mirrors Lite's + /// names exactly so analysis SQL can twin without translation). + /// </summary> + string PrefixIdColumnName { get; } + + /// <summary> + /// Name of the prefix timestamp column in the destination schema. "collection_time" almost + /// everywhere; the config snapshots use "capture_time". + /// </summary> + string PrefixTimeColumnName { get; } + + /// <summary>Payload columns in exactly the order the definition emits them.</summary> + IReadOnlyList<CollectorColumn> PayloadColumns { get; } + + /// <summary> + /// Whether this collector applies to the target at all — the single authoritative target gate + /// both SKUs share. e.g. memory_pressure_events returns false for Azure SQL DB (no + /// <c>sys.dm_os_ring_buffers</c>); the SQL-Agent collectors return false without msdb access. + /// Hosts skip the cycle entirely (no query, zero rows) when false: Darling's runner calls this + /// directly, and Lite consults it (via <see cref="CollectorCatalog.AppliesTo(string, CollectorTargetInfo)"/>) + /// for its pre-dispatch SKIPPED log. Declared here — on the non-generic surface + /// <see cref="CollectorCatalog.All"/> exposes — so the gate can be evaluated by name without the + /// row type, keeping the gate CONDITION in one place (each definition's override) rather than + /// re-encoded per host. + /// </summary> + bool AppliesTo(CollectorTargetInfo target); +} diff --git a/PerformanceMonitor.Collectors/IgnoredWaitDefaults.cs b/PerformanceMonitor.Collectors/IgnoredWaitDefaults.cs new file mode 100644 index 000000000..07b4f6538 --- /dev/null +++ b/PerformanceMonitor.Collectors/IgnoredWaitDefaults.cs @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// The default benign wait types excluded from wait_stats collection — the shared source both +/// SKUs filter by, so portable Lite and the Darling service collect identical wait sets. Lite +/// ships the same list as config/ignored_wait_types.json (user-overridable per machine) and an +/// identity-pin test asserts the two cannot drift; Darling consumes this constant directly. +/// </summary> +public static class IgnoredWaitDefaults +{ + public static IReadOnlySet<string> All { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase) + { + "AZURE_IMDS_VERSIONS", + "BMPALLOCATION", + "BMPBUILD", + "BMPREPARTITION", + "BROKER_EVENTHANDLER", + "BROKER_RECEIVE_WAITFOR", + "BROKER_TASK_STOP", + "BROKER_TO_FLUSH", + "BROKER_TRANSMITTER", + "BUFFERPOOL_SCAN", + "CHECKPOINT_QUEUE", + "CHKPT", + "CLR_AUTO_EVENT", + "CLR_MANUAL_EVENT", + "CLR_SEMAPHORE", + "COLUMNSTORE_BUILD_THROTTLE", + "DAC_INIT", + "DBMIRRORING_CMD", + "DBMIRROR_DBM_EVENT", + "DBMIRROR_DBM_MUTEX", + "DBMIRROR_EVENTS_QUEUE", + "DBMIRROR_SEND", + "DBMIRROR_WORKER_QUEUE", + "DIRTY_PAGE_POLL", + "DIRTY_PAGE_TABLE_LOCK", + "DISPATCHER_QUEUE_SEMAPHORE", + "FSAGENT", + "FT_IFTSHC_MUTEX", + "FT_IFTS_SCHEDULER_IDLE_WAIT", + "HADR_CLUSAPI_CALL", + "HADR_FABRIC_CALLBACK", + "HADR_FILESTREAM_IOMGR_IOCOMPLETION", + "HADR_LOGCAPTURE_WAIT", + "HADR_NOTIFICATION_DEQUEUE", + "HADR_TIMER_TASK", + "HADR_WORK_QUEUE", + "KSOURCE_WAKEUP", + "LAZYWRITER_SLEEP", + "LOGMGR_QUEUE", + "MEMORY_ALLOCATION_EXT", + "ONDEMAND_TASK_QUEUE", + "PARALLEL_REDO_DRAIN_WORKER", + "PARALLEL_REDO_FLOW_CONTROL", + "PARALLEL_REDO_LOG_CACHE", + "PARALLEL_REDO_TRAN_LIST", + "PARALLEL_REDO_TRAN_TURN", + "PARALLEL_REDO_WORKER_SYNC", + "PARALLEL_REDO_WORKER_WAIT_WORK", + "PERFORMANCE_COUNTERS_RWLOCK", + "PREEMPTIVE_OS_FLUSHFILEBUFFERS", + "PREEMPTIVE_XE_CALLBACKEXECUTE", + "PREEMPTIVE_XE_DISPATCHER", + "PREEMPTIVE_XE_GETTARGETSTATE", + "PREEMPTIVE_XE_SESSIONCOMMIT", + "PREEMPTIVE_XE_TARGETFINALIZE", + "PREEMPTIVE_XE_TARGETINIT", + "PRINT_ROLLBACK_PROGRESS", + "PURVIEW_POLICY_SDK_PREEMPTIVE_SCHEDULING", + "PVS_PREALLOCATE", + "PWAIT_ALL_COMPONENTS_INITIALIZED", + "PWAIT_DIRECTLOGCONSUMER_GETNEXT", + "PWAIT_EXTENSIBILITY_CLEANUP_TASK", + "PWAIT_HADRSIM", + "PWAIT_HADR_ACTION_COMPLETED", + "PWAIT_HADR_CHANGE_NOTIFIER_TERMINATION_SYNC", + "PWAIT_HADR_CLUSTER_INTEGRATION", + "PWAIT_HADR_FAILOVER_COMPLETED", + "PWAIT_HADR_JOIN", + "PWAIT_HADR_OFFLINE_COMPLETED", + "PWAIT_HADR_ONLINE_COMPLETED", + "PWAIT_HADR_POST_ONLINE_COMPLETED", + "PWAIT_HADR_SERVER_READY_CONNECTIONS", + "PWAIT_HADR_WORKITEM_COMPLETED", + "PWAIT_MASTERDBREADY", + "QDS_ASYNC_QUEUE", + "QDS_CLEANUP_STALE_QUERIES_TASK_MAIN_LOOP_SLEEP", + "QDS_PERSIST_TASK_MAIN_LOOP_SLEEP", + "QDS_SHUTDOWN_QUEUE", + "QUERY_EXECUTION_INDEX_SORT_EVENT_OPEN", + "QUERY_TASK_ENQUEUE_MUTEX", + "REDO_THREAD_PENDING_WORK", + "REQUEST_FOR_DEADLOCK_SEARCH", + "RESOURCE_QUEUE", + "RESOURCE_SEMAPHORE_MUTEX", + "SECURITY_CNG_PROVIDER_MUTEX", + "SERVER_IDLE_CHECK", + "SLEEP_BUFFERPOOL_HELPLW", + "SLEEP_DBSTARTUP", + "SLEEP_DCOMSTARTUP", + "SLEEP_MASTERDBREADY", + "SLEEP_MASTERMDREADY", + "SLEEP_MASTERUPGRADED", + "SLEEP_MSDBSTARTUP", + "SLEEP_PHYSMASTERDBREADY", + "SLEEP_SYSTEMTASK", + "SLEEP_TASK", + "SLEEP_TEMPDBSTARTUP", + "SNI_CRITICAL_SECTION", + "SNI_HTTP_ACCEPT", + "SOS_PROCESS_AFFINITY_MUTEX", + "SOS_WORK_DISPATCHER", + "SP_SERVER_DIAGNOSTICS_SLEEP", + "SQLTRACE_BUFFER_FLUSH", + "SQLTRACE_FILE_BUFFER", + "SQLTRACE_FILE_READ_IO_COMPLETION", + "SQLTRACE_FILE_WRITE_IO_COMPLETION", + "SQLTRACE_INCREMENTAL_FLUSH_SLEEP", + "SQLTRACE_WAIT_ENTRIES", + "UCS_SESSION_REGISTRATION", + "VDI_CLIENT_OTHER", + "WAITFOR", + "WAITFOR_TASKSHUTDOWN", + "WAIT_FOR_RESULTS", + "WAIT_XTP_CKPT_CLOSE", + "WAIT_XTP_HOST_WAIT", + "WAIT_XTP_OFFLINE_CKPT_NEW_LOG", + "WAIT_XTP_RECOVERY", + "WINDOW_AGGREGATES_MULTIPASS", + "XE_BUFFERMGR_ALLPROCESSED_EVENT", + "XE_DISPATCHER_JOIN", + "XE_DISPATCHER_WAIT", + "XE_FILE_TARGET_TVF", + "XE_LIVE_TARGET_TVF", + "XE_TIMER_EVENT", + }; +} diff --git a/PerformanceMonitor.Collectors/IndexObjectStatsCollector.cs b/PerformanceMonitor.Collectors/IndexObjectStatsCollector.cs new file mode 100644 index 000000000..bf26aa68b --- /dev/null +++ b/PerformanceMonitor.Collectors/IndexObjectStatsCollector.cs @@ -0,0 +1,671 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Per-table and per-index size, usage, and locking statistics (growth trending, unused-index +/// detection, contention analysis). Extracted verbatim from Lite's +/// RemoteCollectorService.IndexObjectStats.cs. All three DMVs are database-scoped, so collection +/// runs ONE COMMAND PER DATABASE: on-prem enumerates accessible online databases then sends the +/// staged body through [db].sys.sp_executesql (quote-doubled); Azure SQL DB connects per database +/// (RunsPerDatabase). Within each database the three DMVs stage into #temps with single scans +/// then join — the sp_IndexCleanup technique that fixed the #1135 monolithic-join timeouts — +/// under a dedicated 300 s per-database budget (CommandTimeoutSecondsOverride; the enumeration +/// keeps the default). sqlserver_start_time flags restart-class counter resets for the read layer. +/// +/// <para>Also captures the per-index DEFINITION metadata that monitor-side UNUSED/DUPLICATE index +/// analysis needs (FinOps Index Analysis, Stage 1): the ordered KEY column list (with sort +/// direction), the INCLUDE column set, the filter predicate, the uniqueness/constraint/PK/FK and +/// table-vs-indexed-view discriminators, disabled state, and the reconstruct-a-CREATE options +/// (compression, sequential key, fill factor, padding, lock granularity). key_columns/included_columns +/// use sp_IndexCleanup's +/// exact delimited STUFF/FOR XML representation so a Stage-2 analyzer's string-comparison dedupe +/// (Exact/Reverse Duplicate, Equal-Except-Filter, Key Subset/Superset) ports cleanly, and the FK +/// flags reproduce its "never drop an FK-supporting or FK-referenced index" protection. These come +/// from sys.indexes/sys.index_columns/sys.columns/sys.foreign_key_columns/sys.partitions — catalog +/// views present on every platform incl. Azure SQL DB — so they share the collector's existing +/// per-database execution unchanged.</para> +/// </summary> +public sealed class IndexObjectStatsCollector : CollectorDefinitionBase<IndexObjectStatsCollector.Row> +{ + public static IndexObjectStatsCollector Instance { get; } = new(); + + private IndexObjectStatsCollector() + { + } + + public sealed class Row + { + public DateTime? SqlServerStartTime { get; set; } + public string DatabaseName { get; set; } = ""; + public int DatabaseId { get; set; } + public string SchemaName { get; set; } = ""; + public int ObjectId { get; set; } + public string TableName { get; set; } = ""; + public int IndexId { get; set; } + public string? IndexName { get; set; } + public string? IndexTypeDesc { get; set; } + public bool? IsUnique { get; set; } + public bool? IsPrimaryKey { get; set; } + public bool? IsFiltered { get; set; } + public int? PartitionCount { get; set; } + public decimal? ReservedMb { get; set; } + public decimal? UsedMb { get; set; } + public decimal? InRowDataMb { get; set; } + public decimal? LobDataMb { get; set; } + public decimal? RowOverflowMb { get; set; } + public long? TotalRows { get; set; } + public long? UserSeeks { get; set; } + public long? UserScans { get; set; } + public long? UserLookups { get; set; } + public long? UserUpdates { get; set; } + public DateTime? LastUserSeek { get; set; } + public DateTime? LastUserScan { get; set; } + public DateTime? LastUserLookup { get; set; } + public DateTime? LastUserUpdate { get; set; } + public long? LeafInsertCount { get; set; } + public long? LeafUpdateCount { get; set; } + public long? LeafDeleteCount { get; set; } + public long? RangeScanCount { get; set; } + public long? SingletonLookupCount { get; set; } + public long? RowLockCount { get; set; } + public long? RowLockWaitCount { get; set; } + public long? RowLockWaitInMs { get; set; } + public long? PageLockCount { get; set; } + public long? PageLockWaitCount { get; set; } + public long? PageLockWaitInMs { get; set; } + public long? IndexLockPromotionAttemptCount { get; set; } + public long? IndexLockPromotionCount { get; set; } + public long? PageLatchWaitCount { get; set; } + public long? PageLatchWaitInMs { get; set; } + public long? PageIoLatchWaitCount { get; set; } + public long? PageIoLatchWaitInMs { get; set; } + public string? KeyColumns { get; set; } + public string? IncludedColumns { get; set; } + public string? FilterDefinition { get; set; } + public bool? IsUniqueConstraint { get; set; } + public bool? IsForeignKey { get; set; } + public bool? IsForeignKeyReference { get; set; } + public bool? IsDisabled { get; set; } + public string? DataCompressionDesc { get; set; } + public bool? OptimizeForSequentialKey { get; set; } + public short? FillFactor { get; set; } + public bool? IsPadded { get; set; } + public bool? AllowPageLocks { get; set; } + public bool? AllowRowLocks { get; set; } + public bool? IsIndexedView { get; set; } + } + + /// <summary> + /// The per-database staged body — the ordinals of its final SELECT are the read contract. + /// <paramref name="supportsOptimizeForSequentialKey"/> gates the SQL 2019+/Azure-only + /// optimize_for_sequential_key column: a typed NULL is emitted on older engines, where the + /// column does not exist and referencing it would fail the whole batch (mirrors + /// sp_IndexCleanup's @supports_optimize_for_sequential_key gate). Everything else is + /// engine-version-invariant. + /// </summary> + internal static string BuildPerDatabaseStatsBody(bool supportsOptimizeForSequentialKey) + { + string optimizeForSequentialKey = + supportsOptimizeForSequentialKey + ? "i.optimize_for_sequential_key" + : "CONVERT(bit, NULL)"; + + return @" +SET NOCOUNT ON; +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +/* Size + row counts (one scan of dm_db_partition_stats) */ +SELECT + dps.object_id, + dps.index_id, + partition_count = COUNT_BIG(*), + reserved_pages = SUM(dps.reserved_page_count), + used_pages = SUM(dps.used_page_count), + in_row_pages = SUM(dps.in_row_data_page_count), + lob_pages = SUM(dps.lob_used_page_count), + row_overflow_pages = SUM(dps.row_overflow_used_page_count), + total_rows = SUM(dps.row_count) +INTO #sizes +FROM sys.dm_db_partition_stats AS dps +GROUP BY + dps.object_id, + dps.index_id +OPTION(RECOMPILE); + +/* Usage counters (one scan of dm_db_index_usage_stats for this database) */ +SELECT + us.object_id, + us.index_id, + us.user_seeks, + us.user_scans, + us.user_lookups, + us.user_updates, + us.last_user_seek, + us.last_user_scan, + us.last_user_lookup, + us.last_user_update +INTO #usage +FROM sys.dm_db_index_usage_stats AS us +WHERE us.database_id = DB_ID() +OPTION(RECOMPILE); + +/* Locking/latch counters (one scan of dm_db_index_operational_stats - the heavy DMV) */ +SELECT + ios.object_id, + ios.index_id, + leaf_insert_count = SUM(ios.leaf_insert_count), + leaf_update_count = SUM(ios.leaf_update_count), + leaf_delete_count = SUM(ios.leaf_delete_count), + range_scan_count = SUM(ios.range_scan_count), + singleton_lookup_count = SUM(ios.singleton_lookup_count), + row_lock_count = SUM(ios.row_lock_count), + row_lock_wait_count = SUM(ios.row_lock_wait_count), + row_lock_wait_in_ms = SUM(ios.row_lock_wait_in_ms), + page_lock_count = SUM(ios.page_lock_count), + page_lock_wait_count = SUM(ios.page_lock_wait_count), + page_lock_wait_in_ms = SUM(ios.page_lock_wait_in_ms), + index_lock_promotion_attempt_count = SUM(ios.index_lock_promotion_attempt_count), + index_lock_promotion_count = SUM(ios.index_lock_promotion_count), + page_latch_wait_count = SUM(ios.page_latch_wait_count), + page_latch_wait_in_ms = SUM(ios.page_latch_wait_in_ms), + page_io_latch_wait_count = SUM(ios.page_io_latch_wait_count), + page_io_latch_wait_in_ms = SUM(ios.page_io_latch_wait_in_ms) +INTO #ops +FROM sys.dm_db_index_operational_stats(DB_ID(), NULL, NULL, NULL) AS ios +GROUP BY + ios.object_id, + ios.index_id +OPTION(RECOMPILE); + +SELECT + sqlserver_start_time = (SELECT osi.sqlserver_start_time FROM sys.dm_os_sys_info AS osi), + database_name = DB_NAME(), + database_id = DB_ID(), + schema_name = s.name, + object_id = o.object_id, + table_name = o.name, + index_id = i.index_id, + index_name = i.name, + index_type_desc = i.type_desc, + is_unique = i.is_unique, + is_primary_key = i.is_primary_key, + is_filtered = i.has_filter, + partition_count = ps.partition_count, + reserved_mb = CONVERT(decimal(19,2), ps.reserved_pages * 8.0 / 1024.0), + used_mb = CONVERT(decimal(19,2), ps.used_pages * 8.0 / 1024.0), + in_row_data_mb = CONVERT(decimal(19,2), ps.in_row_pages * 8.0 / 1024.0), + lob_data_mb = CONVERT(decimal(19,2), ps.lob_pages * 8.0 / 1024.0), + row_overflow_mb = CONVERT(decimal(19,2), ps.row_overflow_pages * 8.0 / 1024.0), + total_rows = ps.total_rows, + user_seeks = us.user_seeks, + user_scans = us.user_scans, + user_lookups = us.user_lookups, + user_updates = us.user_updates, + last_user_seek = us.last_user_seek, + last_user_scan = us.last_user_scan, + last_user_lookup = us.last_user_lookup, + last_user_update = us.last_user_update, + leaf_insert_count = os.leaf_insert_count, + leaf_update_count = os.leaf_update_count, + leaf_delete_count = os.leaf_delete_count, + range_scan_count = os.range_scan_count, + singleton_lookup_count = os.singleton_lookup_count, + row_lock_count = os.row_lock_count, + row_lock_wait_count = os.row_lock_wait_count, + row_lock_wait_in_ms = os.row_lock_wait_in_ms, + page_lock_count = os.page_lock_count, + page_lock_wait_count = os.page_lock_wait_count, + page_lock_wait_in_ms = os.page_lock_wait_in_ms, + index_lock_promotion_attempt_count = os.index_lock_promotion_attempt_count, + index_lock_promotion_count = os.index_lock_promotion_count, + page_latch_wait_count = os.page_latch_wait_count, + page_latch_wait_in_ms = os.page_latch_wait_in_ms, + page_io_latch_wait_count = os.page_io_latch_wait_count, + page_io_latch_wait_in_ms = os.page_io_latch_wait_in_ms, + /* Per-index DEFINITION metadata for monitor-side UNUSED/DUPLICATE analysis (sp_IndexCleanup + parity, Stage 1). key_columns/included_columns reproduce sp_IndexCleanup's delimited + STUFF/FOR XML representation EXACTLY (QUOTENAME + ' DESC', key order for keys, name order + for the include set) so Stage-2 string-comparison dedupe ports cleanly. */ + key_columns = + STUFF + ( + ( + SELECT + N', ' + + QUOTENAME(c.name) + + CASE + WHEN ic.is_descending_key = 1 + THEN N' DESC' + ELSE N'' + END + FROM sys.index_columns AS ic + JOIN sys.columns AS c + ON c.object_id = ic.object_id + AND c.column_id = ic.column_id + WHERE ic.object_id = i.object_id + AND ic.index_id = i.index_id + AND ic.is_included_column = 0 + /* key_ordinal > 0: on a partitioned index the partitioning column rides along in + sys.index_columns at key_ordinal = 0 when it is NOT a real key, and without this + guard it lands FIRST (ORDER BY key_ordinal) as a phantom leading key -- poisoning + every Stage-2 duplicate/subset comparison and any DDL rendered from key_columns. + Mirrors sp_IndexCleanup fix ae32a4c (five aggregations gained this same filter). */ + AND ic.key_ordinal > 0 + ORDER BY + ic.key_ordinal + FOR + XML + PATH(''), + TYPE + ).value('text()[1]', 'nvarchar(max)'), + 1, + 2, + '' + ), + included_columns = + STUFF + ( + ( + SELECT + N', ' + + QUOTENAME(c.name) + FROM sys.index_columns AS ic + JOIN sys.columns AS c + ON c.object_id = ic.object_id + AND c.column_id = ic.column_id + WHERE ic.object_id = i.object_id + AND ic.index_id = i.index_id + AND ic.is_included_column = 1 + ORDER BY + c.name + FOR + XML + PATH(''), + TYPE + ).value('text()[1]', 'nvarchar(max)'), + 1, + 2, + '' + ), + filter_definition = i.filter_definition, + is_unique_constraint = i.is_unique_constraint, + /* FK protections (sp_IndexCleanup guards on is_foreign_key_reference): aggregated to the index + grain over KEY columns (is_included_column = 0), matching its per-column derivation from + sys.foreign_key_columns. is_foreign_key = a key column backs an outgoing FK (supporting + index); is_foreign_key_reference = a key column is referenced by an incoming FK. */ + is_foreign_key = + CONVERT + ( + bit, + CASE + WHEN EXISTS + ( + SELECT + 1/0 + FROM sys.index_columns AS ic + JOIN sys.foreign_key_columns AS fkc + ON fkc.parent_object_id = ic.object_id + AND fkc.parent_column_id = ic.column_id + WHERE ic.object_id = i.object_id + AND ic.index_id = i.index_id + AND ic.is_included_column = 0 + ) + THEN 1 + ELSE 0 + END + ), + is_foreign_key_reference = + CONVERT + ( + bit, + CASE + WHEN EXISTS + ( + SELECT + 1/0 + FROM sys.index_columns AS ic + JOIN sys.foreign_key_columns AS fkc + ON fkc.referenced_object_id = ic.object_id + AND fkc.referenced_column_id = ic.column_id + WHERE ic.object_id = i.object_id + AND ic.index_id = i.index_id + AND ic.is_included_column = 0 + ) + THEN 1 + ELSE 0 + END + ), + is_disabled = i.is_disabled, + /* Compression state aggregated to the index grain: the lowest level across partitions, so an + index with ANY uncompressed partition surfaces as NONE (sp_IndexCleanup's compressibility + signal). */ + data_compression_desc = + ( + SELECT TOP (1) + p.data_compression_desc + FROM sys.partitions AS p + WHERE p.object_id = i.object_id + AND p.index_id = i.index_id + ORDER BY + p.data_compression + ), + optimize_for_sequential_key = " + optimizeForSequentialKey + @", + fill_factor = i.fill_factor, + is_padded = i.is_padded, + allow_page_locks = i.allow_page_locks, + allow_row_locks = i.allow_row_locks, + /* Table (U) vs indexed-view (V) index — the one table-vs-view discriminator index_type_desc + cannot express (a view's clustered index is also CLUSTERED); mirrors sp_IndexCleanup's + is_indexed_view so Stage 2 never dedupes across the two. */ + is_indexed_view = + CONVERT + ( + bit, + CASE + WHEN o.type = N'V' + THEN 1 + ELSE 0 + END + ) +FROM sys.indexes AS i +JOIN sys.objects AS o + ON o.object_id = i.object_id +JOIN sys.schemas AS s + ON s.schema_id = o.schema_id +LEFT JOIN #sizes AS ps + ON ps.object_id = i.object_id + AND ps.index_id = i.index_id +LEFT JOIN #usage AS us + ON us.object_id = i.object_id + AND us.index_id = i.index_id +LEFT JOIN #ops AS os + ON os.object_id = i.object_id + AND os.index_id = i.index_id +WHERE o.is_ms_shipped = 0 +AND o.type IN (N'U', N'V') +OPTION(RECOMPILE);"; + } + + /// <summary> + /// optimize_for_sequential_key exists only on SQL Server 2019+ (major >= 15), Azure SQL DB, and + /// Azure SQL Managed Instance (mirrors sp_IndexCleanup's engine-edition/version gate). Unknown + /// version (0) fails safe to unsupported — a typed NULL is emitted rather than risk a hard bind + /// error against a column that may not exist on the target. + /// </summary> + private static bool SupportsOptimizeForSequentialKey(CollectorTargetInfo target) => + target.IsAzureSqlDb + || target.IsAzureManagedInstance + || target.SqlMajorVersion >= 15; + + public override string Name => "index_object_stats"; + + public override string TargetTable => "index_object_stats"; + + /// <summary>The heavy per-database sweep gets the dedicated #1135 budget.</summary> + public override int? CommandTimeoutSecondsOverride => 300; + + /// <summary>Azure SQL DB cannot cross databases — one connection per database.</summary> + public override bool RunsPerDatabase(CollectorTargetInfo target) => target.IsAzureSqlDb; + + /// <summary>Azure per-database connections run the staged body directly.</summary> + public override CollectorQuery BuildQuery(CollectorContext context) => + new(BuildPerDatabaseStatsBody(SupportsOptimizeForSequentialKey(context.Target))); + + /// <summary>On-prem: enumerate accessible online databases (with exclusions), then per-item.</summary> + public override CollectorQuery? BuildEnumerationQuery(CollectorContext context) + { + if (context.Target.IsAzureSqlDb) + { + return null; + } + + var (exclusionClause, exclusionParameters) = DatabaseExclusionFilter.Build(context.ExcludedDatabases, "d.name"); + var text = $@" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; +SELECT + d.name +FROM sys.databases AS d +WHERE d.state_desc = N'ONLINE' +AND d.database_id > 0 +AND HAS_DBACCESS(d.name) = 1 +{exclusionClause} +ORDER BY + d.name;"; + + return new CollectorQuery(text, exclusionParameters); + } + + public override CollectorQuery BuildPerItemQuery(string item, CollectorContext context) + { + /* Double single quotes so the body survives nesting inside [db].sys.sp_executesql N'...' */ + var escapedBody = BuildPerDatabaseStatsBody(SupportsOptimizeForSequentialKey(context.Target)) + .Replace("'", "''", StringComparison.Ordinal); + var escapedDbName = item.Replace("]", "]]", StringComparison.Ordinal); + return new CollectorQuery($"EXECUTE [{escapedDbName}].sys.sp_executesql N'{escapedBody}';"); + } + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var rows = new List<Row>(); + await ParseRowsAsync(reader, rows, cancellationToken); + return rows; + } + + public override ValueTask ReadItemAsync(string item, DbDataReader reader, List<Row> rows, CollectorContext context, CancellationToken cancellationToken) + => new(ParseRowsAsync(reader, rows, cancellationToken)); + + private static async Task ParseRowsAsync(DbDataReader reader, List<Row> rows, CancellationToken cancellationToken) + { + long? L(int i) => reader.IsDBNull(i) ? null : Convert.ToInt64(reader.GetValue(i), CultureInfo.InvariantCulture); + int? I(int i) => reader.IsDBNull(i) ? null : Convert.ToInt32(reader.GetValue(i), CultureInfo.InvariantCulture); + short? Sh(int i) => reader.IsDBNull(i) ? null : Convert.ToInt16(reader.GetValue(i), CultureInfo.InvariantCulture); + decimal? D(int i) => reader.IsDBNull(i) ? null : reader.GetDecimal(i); + DateTime? T(int i) => reader.IsDBNull(i) ? null : reader.GetDateTime(i); + bool? B(int i) => reader.IsDBNull(i) ? null : (bool?)(Convert.ToInt32(reader.GetValue(i), CultureInfo.InvariantCulture) == 1); + string? S(int i) => reader.IsDBNull(i) ? null : reader.GetString(i); + + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new Row + { + SqlServerStartTime = T(0), + DatabaseName = reader.GetString(1), + DatabaseId = Convert.ToInt32(reader.GetValue(2), CultureInfo.InvariantCulture), + SchemaName = reader.GetString(3), + ObjectId = Convert.ToInt32(reader.GetValue(4), CultureInfo.InvariantCulture), + TableName = reader.GetString(5), + IndexId = Convert.ToInt32(reader.GetValue(6), CultureInfo.InvariantCulture), + IndexName = reader.IsDBNull(7) ? null : reader.GetString(7), + IndexTypeDesc = reader.IsDBNull(8) ? null : reader.GetString(8), + IsUnique = B(9), + IsPrimaryKey = B(10), + IsFiltered = B(11), + PartitionCount = I(12), + ReservedMb = D(13), + UsedMb = D(14), + InRowDataMb = D(15), + LobDataMb = D(16), + RowOverflowMb = D(17), + TotalRows = L(18), + UserSeeks = L(19), + UserScans = L(20), + UserLookups = L(21), + UserUpdates = L(22), + LastUserSeek = T(23), + LastUserScan = T(24), + LastUserLookup = T(25), + LastUserUpdate = T(26), + LeafInsertCount = L(27), + LeafUpdateCount = L(28), + LeafDeleteCount = L(29), + RangeScanCount = L(30), + SingletonLookupCount = L(31), + RowLockCount = L(32), + RowLockWaitCount = L(33), + RowLockWaitInMs = L(34), + PageLockCount = L(35), + PageLockWaitCount = L(36), + PageLockWaitInMs = L(37), + IndexLockPromotionAttemptCount = L(38), + IndexLockPromotionCount = L(39), + PageLatchWaitCount = L(40), + PageLatchWaitInMs = L(41), + PageIoLatchWaitCount = L(42), + PageIoLatchWaitInMs = L(43), + KeyColumns = S(44), + IncludedColumns = S(45), + FilterDefinition = S(46), + IsUniqueConstraint = B(47), + IsForeignKey = B(48), + IsForeignKeyReference = B(49), + IsDisabled = B(50), + DataCompressionDesc = S(51), + OptimizeForSequentialKey = B(52), + FillFactor = Sh(53), + IsPadded = B(54), + AllowPageLocks = B(55), + AllowRowLocks = B(56), + IsIndexedView = B(57), + }); + } + } + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("sqlserver_start_time", CollectorColumnType.Timestamp), + new CollectorColumn("database_name", CollectorColumnType.Varchar), + new CollectorColumn("database_id", CollectorColumnType.Integer), + new CollectorColumn("schema_name", CollectorColumnType.Varchar), + new CollectorColumn("object_id", CollectorColumnType.Integer), + new CollectorColumn("table_name", CollectorColumnType.Varchar), + new CollectorColumn("index_id", CollectorColumnType.Integer), + new CollectorColumn("index_name", CollectorColumnType.Varchar), + new CollectorColumn("index_type_desc", CollectorColumnType.Varchar), + new CollectorColumn("is_unique", CollectorColumnType.Boolean), + new CollectorColumn("is_primary_key", CollectorColumnType.Boolean), + new CollectorColumn("is_filtered", CollectorColumnType.Boolean), + new CollectorColumn("partition_count", CollectorColumnType.Integer), + new CollectorColumn("reserved_mb", CollectorColumnType.Decimal, 19, 2), + new CollectorColumn("used_mb", CollectorColumnType.Decimal, 19, 2), + new CollectorColumn("in_row_data_mb", CollectorColumnType.Decimal, 19, 2), + new CollectorColumn("lob_data_mb", CollectorColumnType.Decimal, 19, 2), + new CollectorColumn("row_overflow_mb", CollectorColumnType.Decimal, 19, 2), + new CollectorColumn("total_rows", CollectorColumnType.BigInt), + new CollectorColumn("user_seeks", CollectorColumnType.BigInt), + new CollectorColumn("user_scans", CollectorColumnType.BigInt), + new CollectorColumn("user_lookups", CollectorColumnType.BigInt), + new CollectorColumn("user_updates", CollectorColumnType.BigInt), + new CollectorColumn("last_user_seek", CollectorColumnType.Timestamp), + new CollectorColumn("last_user_scan", CollectorColumnType.Timestamp), + new CollectorColumn("last_user_lookup", CollectorColumnType.Timestamp), + new CollectorColumn("last_user_update", CollectorColumnType.Timestamp), + new CollectorColumn("leaf_insert_count", CollectorColumnType.BigInt), + new CollectorColumn("leaf_update_count", CollectorColumnType.BigInt), + new CollectorColumn("leaf_delete_count", CollectorColumnType.BigInt), + new CollectorColumn("range_scan_count", CollectorColumnType.BigInt), + new CollectorColumn("singleton_lookup_count", CollectorColumnType.BigInt), + new CollectorColumn("row_lock_count", CollectorColumnType.BigInt), + new CollectorColumn("row_lock_wait_count", CollectorColumnType.BigInt), + new CollectorColumn("row_lock_wait_in_ms", CollectorColumnType.BigInt), + new CollectorColumn("page_lock_count", CollectorColumnType.BigInt), + new CollectorColumn("page_lock_wait_count", CollectorColumnType.BigInt), + new CollectorColumn("page_lock_wait_in_ms", CollectorColumnType.BigInt), + new CollectorColumn("index_lock_promotion_attempt_count", CollectorColumnType.BigInt), + new CollectorColumn("index_lock_promotion_count", CollectorColumnType.BigInt), + new CollectorColumn("page_latch_wait_count", CollectorColumnType.BigInt), + new CollectorColumn("page_latch_wait_in_ms", CollectorColumnType.BigInt), + new CollectorColumn("page_io_latch_wait_count", CollectorColumnType.BigInt), + new CollectorColumn("page_io_latch_wait_in_ms", CollectorColumnType.BigInt), + new CollectorColumn("key_columns", CollectorColumnType.Varchar), + new CollectorColumn("included_columns", CollectorColumnType.Varchar), + new CollectorColumn("filter_definition", CollectorColumnType.Varchar), + new CollectorColumn("is_unique_constraint", CollectorColumnType.Boolean), + new CollectorColumn("is_foreign_key", CollectorColumnType.Boolean), + new CollectorColumn("is_foreign_key_reference", CollectorColumnType.Boolean), + new CollectorColumn("is_disabled", CollectorColumnType.Boolean), + new CollectorColumn("data_compression_desc", CollectorColumnType.Varchar), + new CollectorColumn("optimize_for_sequential_key", CollectorColumnType.Boolean), + new CollectorColumn("fill_factor", CollectorColumnType.SmallInt), + new CollectorColumn("is_padded", CollectorColumnType.Boolean), + new CollectorColumn("allow_page_locks", CollectorColumnType.Boolean), + new CollectorColumn("allow_row_locks", CollectorColumnType.Boolean), + new CollectorColumn("is_indexed_view", CollectorColumnType.Boolean), + }; + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + writer + .Value(row.SqlServerStartTime) + .Value(row.DatabaseName) + .Value(row.DatabaseId) + .Value(row.SchemaName) + .Value(row.ObjectId) + .Value(row.TableName) + .Value(row.IndexId) + .Value(row.IndexName) + .Value(row.IndexTypeDesc) + .Value(row.IsUnique) + .Value(row.IsPrimaryKey) + .Value(row.IsFiltered) + .Value(row.PartitionCount) + .Value(row.ReservedMb) + .Value(row.UsedMb) + .Value(row.InRowDataMb) + .Value(row.LobDataMb) + .Value(row.RowOverflowMb) + .Value(row.TotalRows) + .Value(row.UserSeeks) + .Value(row.UserScans) + .Value(row.UserLookups) + .Value(row.UserUpdates) + .Value(row.LastUserSeek) + .Value(row.LastUserScan) + .Value(row.LastUserLookup) + .Value(row.LastUserUpdate) + .Value(row.LeafInsertCount) + .Value(row.LeafUpdateCount) + .Value(row.LeafDeleteCount) + .Value(row.RangeScanCount) + .Value(row.SingletonLookupCount) + .Value(row.RowLockCount) + .Value(row.RowLockWaitCount) + .Value(row.RowLockWaitInMs) + .Value(row.PageLockCount) + .Value(row.PageLockWaitCount) + .Value(row.PageLockWaitInMs) + .Value(row.IndexLockPromotionAttemptCount) + .Value(row.IndexLockPromotionCount) + .Value(row.PageLatchWaitCount) + .Value(row.PageLatchWaitInMs) + .Value(row.PageIoLatchWaitCount) + .Value(row.PageIoLatchWaitInMs) + .Value(row.KeyColumns) + .Value(row.IncludedColumns) + .Value(row.FilterDefinition) + .Value(row.IsUniqueConstraint) + .Value(row.IsForeignKey) + .Value(row.IsForeignKeyReference) + .Value(row.IsDisabled) + .Value(row.DataCompressionDesc) + .Value(row.OptimizeForSequentialKey) + .Value(row.FillFactor) + .Value(row.IsPadded) + .Value(row.AllowPageLocks) + .Value(row.AllowRowLocks) + .Value(row.IsIndexedView); + } +} diff --git a/PerformanceMonitor.Collectors/JobHistoryCollector.cs b/PerformanceMonitor.Collectors/JobHistoryCollector.cs new file mode 100644 index 000000000..21ad414a0 --- /dev/null +++ b/PerformanceMonitor.Collectors/JobHistoryCollector.cs @@ -0,0 +1,292 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Retained SQL Agent job-run history — every step row AND the job-outcome (step_id 0) row from +/// <c>msdb.dbo.sysjobhistory</c>, snapshotted incrementally into a retained <c>job_history</c> table so +/// the fleet-wide "Job History" tab can show up to a year of runs with per-step results, retries, +/// durations, and color-coded failures/long-runtimes (issue #1433). The run_datetime / run_duration +/// HHMMSS-integer decode idioms and the sysjobs/syscategories joins are the proven ones from the +/// live-msdb <c>FailedJobsQuery</c> and <c>RunningJobsCollector</c>, ported into the shared collector +/// library so both SKUs (portable Lite → DuckDB, Darling → Postgres) collect it from one definition. +/// +/// <para><b>STATEFUL dedup on a numeric high-water mark (must-handle):</b> unlike the timestamp-watermark +/// event collectors, this dedups on <c>sysjobhistory.instance_id</c> — a unique monotonic IDENTITY that +/// survives server-side <c>sp_purge_jobhistory</c>. The host reads MAX(instance_id) already collected for +/// the server (<see cref="CollectorContext.NumericWatermark"/>) and this filters +/// <c>jh.instance_id > @last_instance_id</c>, so each run's rows are collected exactly once — no gaps, +/// no duplicates — even as msdb prunes its own history. The FIRST run backfills everything currently in +/// sysjobhistory (bounded by msdb's own history cap), the same first-run-collects-all choice +/// <see cref="DefaultTraceEventsCollector"/> makes.</para> +/// +/// <para><b>Lite archival-emptied edge (must-handle):</b> a quiet server's hot <c>job_history</c> table can +/// be emptied by Lite's parquet archival while the parquet tier still holds the rows, so MAX(instance_id) +/// comes back null even though history exists. Re-collecting all of sysjobhistory then would re-insert rows +/// already in parquet, and <c>v_job_history</c> UNIONs hot + parquet with no dedup, so those rows would +/// DOUBLE-COUNT. This declares <c>run_datetime</c> as its <see cref="WatermarkColumn"/> too, so the host +/// sets <see cref="CollectorContext.HasCollectedBefore"/> when the numeric watermark is null: on an +/// archival-emptied (necessarily quiet) server the query falls back to a BOUNDED recent +/// <see cref="ArchivalEmptyFallbackHours"/>-hour run_datetime window (computed server-side against +/// GETDATE(), since run_datetime is the server's LOCAL wall clock) — far smaller than any retention +/// horizon, so it re-reads only genuinely-recent runs not yet archived and never re-scans parquet. Darling +/// has no parquet tier, so this branch never triggers there. Ports the Dashboard-collector idiom +/// <see cref="DefaultTraceEventsCollector.ArchivalEmptyFallbackHours"/> uses.</para> +/// +/// <para><b>Azure SQL Database (edition 5): does NOT apply</b> — there is no SQL Agent on Azure SQL DB +/// (<c>msdb.dbo.sysjobhistory</c> is unreachable there), so the collector is gated off via +/// <see cref="AppliesTo"/> = <c>!IsAzureSqlDb</c>, exactly like <see cref="RunningJobsCollector"/> and the +/// failed-jobs alert path. Managed Instance (edition 8), on-prem, and AWS RDS all have Agent and collect +/// normally.</para> +/// </summary> +public sealed class JobHistoryCollector : CollectorDefinitionBase<JobHistoryCollector.Row> +{ + public static JobHistoryCollector Instance { get; } = new(); + + private JobHistoryCollector() + { + } + + /// <summary> + /// The BOUNDED fallback window (hours) used when the numeric watermark is null but the collector HAS + /// succeeded before (<see cref="CollectorContext.HasCollectedBefore"/>) — i.e. Lite's hot store was + /// emptied by parquet archival, not a true first run. Re-collecting all sysjobhistory here would + /// re-insert rows already aged into parquet, and <c>v_job_history</c> UNIONs hot + parquet with no + /// dedup, so those rows would DOUBLE-COUNT and recur every archival cycle. This window is far smaller + /// than the retention horizon, so on an archival-emptied (necessarily quiet) server it re-reads only + /// genuinely-recent runs — which by definition are not yet archived — and never re-scans parquet. + /// Mirrors <see cref="DefaultTraceEventsCollector.ArchivalEmptyFallbackHours"/>. + /// </summary> + public const int ArchivalEmptyFallbackHours = 24; + + public sealed class Row + { + public long InstanceId { get; set; } + public string JobId { get; set; } = ""; + public string JobName { get; set; } = ""; + public bool JobEnabled { get; set; } + public string? CategoryName { get; set; } + public int StepId { get; set; } + public string? StepName { get; set; } + public int RunStatus { get; set; } + public string? RunStatusDesc { get; set; } + public DateTime? RunDateTime { get; set; } + public long RunDurationSeconds { get; set; } + public int RetriesAttempted { get; set; } + public string? Message { get; set; } + } + + /* Every step row AND the job-outcome (step_id 0) row, joined to sysjobs (job name/enabled) and + syscategories (category), with the HHMMSS-integer decode idioms from FailedJobsQuery / + RunningJobsCollector. A {0} placeholder is spliced with the per-cycle incremental filter (see + BuildQuery). READ UNCOMMITTED like every collector; OPTION(RECOMPILE) because the filter selectivity + varies wildly between the all-history first run and the tiny steady-state instance_id windows. */ + private const string QueryTemplate = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + instance_id = jh.instance_id, + job_id = CONVERT(varchar(36), jh.job_id), + job_name = j.name, + job_enabled = j.enabled, + category_name = c.name, + step_id = jh.step_id, + step_name = jh.step_name, + run_status = jh.run_status, + run_status_desc = + CASE jh.run_status + WHEN 0 THEN N'Failed' + WHEN 1 THEN N'Succeeded' + WHEN 2 THEN N'Retry' + WHEN 3 THEN N'Canceled' + WHEN 4 THEN N'In Progress' + ELSE N'Unknown' + END, + run_datetime = + DATEADD + ( + SECOND, + (jh.run_time / 10000) * 3600 + + ((jh.run_time / 100) % 100) * 60 + + (jh.run_time % 100), + CONVERT(datetime, CONVERT(varchar(8), jh.run_date)) + ), + run_duration_seconds = + (jh.run_duration / 10000) * 3600 + + ((jh.run_duration / 100) % 100) * 60 + + (jh.run_duration % 100), + retries_attempted = jh.retries_attempted, + message = jh.message +FROM msdb.dbo.sysjobhistory AS jh +JOIN msdb.dbo.sysjobs AS j + ON j.job_id = jh.job_id +LEFT JOIN msdb.dbo.syscategories AS c + ON c.category_id = j.category_id +WHERE 1 = 1{0} +ORDER BY + jh.instance_id +OPTION(RECOMPILE);"; + + /// <summary> + /// The archival-emptied fallback predicate: a bounded recent run_datetime window computed SERVER-SIDE + /// against GETDATE() (run_datetime is the server's local wall clock, so a host-supplied UTC cutoff would + /// be timezone-skewed). A cheap sargable run_date pre-filter plus the exact decoded-run_datetime bound, + /// mirroring FailedJobsQuery's dual run_date / run_datetime lookback. Built once from + /// <see cref="ArchivalEmptyFallbackHours"/>. + /// </summary> + private static readonly string ArchivalEmptyFilter = string.Format( + CultureInfo.InvariantCulture, + @" +AND jh.run_date >= CONVERT(integer, CONVERT(varchar(8), DATEADD(HOUR, -{0}, GETDATE()), 112)) +AND DATEADD + ( + SECOND, + (jh.run_time / 10000) * 3600 + + ((jh.run_time / 100) % 100) * 60 + + (jh.run_time % 100), + CONVERT(datetime, CONVERT(varchar(8), jh.run_date)) + ) >= DATEADD(HOUR, -{0}, GETDATE())", + ArchivalEmptyFallbackHours); + + public override string Name => "job_history"; + + public override string TargetTable => "job_history"; + + /// <summary>Lite names this table's prefix id "job_history_id"; Darling mirrors it.</summary> + public override string PrefixIdColumnName => "job_history_id"; + + /// <summary> + /// Dedup is primarily on the numeric <c>instance_id</c> watermark (see + /// <see cref="NumericWatermarkColumn"/>); <c>run_datetime</c> is declared here too so the host sets + /// <see cref="CollectorContext.HasCollectedBefore"/> when the numeric watermark comes back null, + /// distinguishing a TRUE first run (collect all) from a Lite hot store merely emptied by archival + /// (bounded recent window — see the class remarks). + /// </summary> + public override string? WatermarkColumn => "run_datetime"; + + /// <summary> + /// Exact-and-complete dedup key: <c>sysjobhistory.instance_id</c>, a unique monotonic IDENTITY that + /// survives server-side history purges. The host reads MAX(instance_id) already collected for the + /// server and this filters newer rows only. + /// </summary> + public override string? NumericWatermarkColumn => "instance_id"; + + /// <summary> + /// Collects on SQL Server, Azure SQL Managed Instance, and AWS RDS — everywhere SQL Agent exists. NOT + /// Azure SQL Database (edition 5): there is no Agent / <c>msdb.dbo.sysjobhistory</c> there. NOT a login + /// without msdb access: every table this reads (sysjobhistory, sysjobs) lives in msdb. It is NOT gated on + /// AWS RDS — unlike <see cref="RunningJobsCollector"/> it never touches <c>syssessions</c>, so retained + /// history reads fine there. Gated here in the shared AppliesTo so Lite and Darling skip identically. + /// </summary> + public override bool AppliesTo(CollectorTargetInfo target) => !target.IsAzureSqlDb && target.HasMsdbAccess; + + public override CollectorQuery BuildQuery(CollectorContext context) + { + /* Incremental filter selection (the numeric high-water mark, with the Lite archival-emptied fallback): + - numeric watermark present -> steady state, collect instance_id newer than it. + - watermark null, never succeeded -> TRUE first run, collect ALL of sysjobhistory. + - watermark null, HAS succeeded (Lite) -> hot store emptied by archival, use a BOUNDED recent + run_datetime window so we never re-scan rows already + in the parquet archive (which v_job_history would + double-count). */ + string filter; + IReadOnlyList<CollectorParameter> parameters; + + if (context.NumericWatermark.HasValue) + { + filter = "\r\nAND jh.instance_id > @last_instance_id"; + parameters = new[] + { + new CollectorParameter("@last_instance_id", context.NumericWatermark.Value, CollectorParameterType.BigInt), + }; + } + else if (context.HasCollectedBefore) + { + filter = ArchivalEmptyFilter; + parameters = Array.Empty<CollectorParameter>(); + } + else + { + filter = string.Empty; + parameters = Array.Empty<CollectorParameter>(); + } + + var text = string.Format(CultureInfo.InvariantCulture, QueryTemplate, filter); + return new CollectorQuery(text, parameters); + } + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("instance_id", CollectorColumnType.BigInt), + new CollectorColumn("job_id", CollectorColumnType.Varchar), + new CollectorColumn("job_name", CollectorColumnType.Varchar), + new CollectorColumn("job_enabled", CollectorColumnType.Boolean), + new CollectorColumn("category_name", CollectorColumnType.Varchar), + new CollectorColumn("step_id", CollectorColumnType.Integer), + new CollectorColumn("step_name", CollectorColumnType.Varchar), + new CollectorColumn("run_status", CollectorColumnType.Integer), + new CollectorColumn("run_status_desc", CollectorColumnType.Varchar), + new CollectorColumn("run_datetime", CollectorColumnType.Timestamp), + new CollectorColumn("run_duration_seconds", CollectorColumnType.BigInt), + new CollectorColumn("retries_attempted", CollectorColumnType.Integer), + new CollectorColumn("message", CollectorColumnType.Varchar), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var rows = new List<Row>(); + + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new Row + { + InstanceId = Convert.ToInt64(reader.GetValue(0), CultureInfo.InvariantCulture), + JobId = reader.IsDBNull(1) ? "" : reader.GetString(1), + JobName = reader.IsDBNull(2) ? "" : reader.GetString(2), + JobEnabled = !reader.IsDBNull(3) && Convert.ToBoolean(reader.GetValue(3), CultureInfo.InvariantCulture), + CategoryName = reader.IsDBNull(4) ? null : reader.GetString(4), + StepId = reader.IsDBNull(5) ? 0 : Convert.ToInt32(reader.GetValue(5), CultureInfo.InvariantCulture), + StepName = reader.IsDBNull(6) ? null : reader.GetString(6), + RunStatus = reader.IsDBNull(7) ? 0 : Convert.ToInt32(reader.GetValue(7), CultureInfo.InvariantCulture), + RunStatusDesc = reader.IsDBNull(8) ? null : reader.GetString(8), + RunDateTime = reader.IsDBNull(9) ? null : reader.GetDateTime(9), + RunDurationSeconds = reader.IsDBNull(10) ? 0 : Convert.ToInt64(reader.GetValue(10), CultureInfo.InvariantCulture), + RetriesAttempted = reader.IsDBNull(11) ? 0 : Convert.ToInt32(reader.GetValue(11), CultureInfo.InvariantCulture), + Message = reader.IsDBNull(12) ? null : reader.GetString(12), + }); + } + + return rows; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + writer + .Value(row.InstanceId) /* instance_id BIGINT (numeric watermark) */ + .Value(row.JobId) /* job_id VARCHAR */ + .Value(row.JobName) /* job_name VARCHAR */ + .Value(row.JobEnabled) /* job_enabled BOOLEAN */ + .Value(row.CategoryName) /* category_name VARCHAR */ + .Value(row.StepId) /* step_id INTEGER */ + .Value(row.StepName) /* step_name VARCHAR */ + .Value(row.RunStatus) /* run_status INTEGER */ + .Value(row.RunStatusDesc) /* run_status_desc VARCHAR */ + .Value(row.RunDateTime) /* run_datetime TIMESTAMP (watermark fallback) */ + .Value(row.RunDurationSeconds) /* run_duration_seconds BIGINT */ + .Value(row.RetriesAttempted) /* retries_attempted INTEGER */ + .Value(row.Message); /* message VARCHAR */ + } +} diff --git a/PerformanceMonitor.Collectors/LatchStatsCollector.cs b/PerformanceMonitor.Collectors/LatchStatsCollector.cs new file mode 100644 index 000000000..6c0e1209f --- /dev/null +++ b/PerformanceMonitor.Collectors/LatchStatsCollector.cs @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Latch statistics from sys.dm_os_latch_stats — the cumulative per-latch-class waits that surface +/// contention on internal SQL Server structures (the Dashboard-parity port of +/// install/32_collect_latch_stats.sql). A cumulative-counter DMV like wait_stats, so it mirrors +/// <see cref="WaitStatsCollector"/> exactly: single DMV, delta-based between snapshots. The +/// Dashboard proc's server_start_time reset marker is intentionally dropped — the shared +/// <see cref="CollectorContext.Deltas"/> calculator detects a counter reset itself (a value drop +/// yields a 0 delta) and applies the 300 s gap policy, so no reset column is needed. Available on +/// SQL Server, Azure SQL Database, and Azure SQL Managed Instance (verified against MS Learn), so +/// <see cref="AppliesTo"/> is unconditionally true, matching wait_stats. +/// </summary> +public sealed class LatchStatsCollector : CollectorDefinitionBase<LatchStatsCollector.Row> +{ + public static LatchStatsCollector Instance { get; } = new(); + + private LatchStatsCollector() + { + } + + public readonly record struct Row(string LatchClass, long WaitingRequestsCount, long WaitTimeMs, long MaxWaitTimeMs); + + private const string QueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + latch_class = ls.latch_class, + waiting_requests_count = ls.waiting_requests_count, + wait_time_ms = ls.wait_time_ms, + max_wait_time_ms = ls.max_wait_time_ms +FROM sys.dm_os_latch_stats AS ls +WHERE ls.wait_time_ms > 0 +OPTION(RECOMPILE);"; + + public override string Name => "latch_stats"; + + public override string TargetTable => "latch_stats"; + + public override string? WatermarkColumn => null; + + public override bool AppliesTo(CollectorTargetInfo target) => true; + + public override bool RunsPerDatabase(CollectorTargetInfo target) => false; + + public override CollectorQuery BuildQuery(CollectorContext context) => new(QueryText); + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("latch_class", CollectorColumnType.Varchar), + new CollectorColumn("waiting_requests_count", CollectorColumnType.BigInt), + new CollectorColumn("wait_time_ms", CollectorColumnType.BigInt), + new CollectorColumn("max_wait_time_ms", CollectorColumnType.BigInt), + new CollectorColumn("delta_waiting_requests_count", CollectorColumnType.BigInt), + new CollectorColumn("delta_wait_time_ms", CollectorColumnType.BigInt), + new CollectorColumn("delta_max_wait_time_ms", CollectorColumnType.BigInt), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var rows = new List<Row>(); + + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new Row( + LatchClass: reader.GetString(0), + WaitingRequestsCount: reader.GetInt64(1), + WaitTimeMs: reader.GetInt64(2), + MaxWaitTimeMs: reader.GetInt64(3))); + } + + return rows; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + /* Delta groups, key (latch_class), and the 300 s gap policy are the parity contract. */ + var deltaWaitingRequests = context.Deltas.CalculateDelta(context.ServerId, "latch_stats_waiting_requests", row.LatchClass, row.WaitingRequestsCount, collectionTime: context.CollectionTime, maxGapSeconds: 300); + var deltaWaitTimeMs = context.Deltas.CalculateDelta(context.ServerId, "latch_stats_wait_time", row.LatchClass, row.WaitTimeMs, collectionTime: context.CollectionTime, maxGapSeconds: 300); + var deltaMaxWaitTimeMs = context.Deltas.CalculateDelta(context.ServerId, "latch_stats_max_wait", row.LatchClass, row.MaxWaitTimeMs, collectionTime: context.CollectionTime, maxGapSeconds: 300); + + writer + .Value(row.LatchClass) /* latch_class VARCHAR */ + .Value(row.WaitingRequestsCount) /* waiting_requests_count BIGINT */ + .Value(row.WaitTimeMs) /* wait_time_ms BIGINT */ + .Value(row.MaxWaitTimeMs) /* max_wait_time_ms BIGINT */ + .Value(deltaWaitingRequests) /* delta_waiting_requests_count BIGINT */ + .Value(deltaWaitTimeMs) /* delta_wait_time_ms BIGINT */ + .Value(deltaMaxWaitTimeMs); /* delta_max_wait_time_ms BIGINT */ + } +} diff --git a/PerformanceMonitor.Collectors/LongQueryCompletionsCollector.cs b/PerformanceMonitor.Collectors/LongQueryCompletionsCollector.cs new file mode 100644 index 000000000..7d10b7f96 --- /dev/null +++ b/PerformanceMonitor.Collectors/LongQueryCompletionsCollector.cs @@ -0,0 +1,433 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Long-running query completions from the app-managed PerformanceMonitor_LongQueryCompletions XE +/// ring-buffer session (#1496) — the Extended Events equivalent of the classic long-query trace +/// (modeled on SSMS's QuickSessionStandard completion trio): <c>rpc_completed</c> + +/// <c>sql_batch_completed</c> filtered by <c>duration >= @long_query_threshold</c> (microseconds), +/// plus <c>attention</c> captured UNFILTERED. Attention is the "long query that never finished — +/// cancelled/timed out" evidence and is deliberately not duration-filtered: the attention event's own +/// <c>duration</c> is the cancellation-HANDLING time (tens of milliseconds), not the query's runtime, +/// so a <c>duration >=</c> predicate would discard exactly the cancelled-long-query rows the trace +/// exists to capture (MS Learn's timeout worked example: the batch's real duration ≈ 30 s while the +/// paired attention's duration = 40 ms). The completed events still carry the true runtime AND a +/// <c>result</c> = <c>Abort</c> when a long query is killed, so an aborted long query is captured on +/// BOTH sides — a completed row (result=Abort) and, for correlation, an attention row on the same +/// session_id. +/// +/// <para><b>Opt-in, default OFF (Erik's dedicated switch).</b> A completion trace is not free on a +/// busy server — rpc_completed/sql_batch_completed fire per statement/batch even though the duration +/// predicate discards most of them — so the collector is seeded DISABLED +/// (<see cref="CollectorScheduleDefaults"/> DefaultEnabled = false) and the operator opts in per fleet. +/// The XE session lifecycle FOLLOWS the flag: enabling creates the session on the monitored servers, +/// disabling DROPS it there (the session itself is the cost, so merely skipping collection is not +/// enough). Both SKUs drive that reconcile from the shared <see cref="BuildCreateSessionSql"/> / +/// <see cref="BuildStartSessionSql"/> / <see cref="BuildDropSessionSql"/> here so the two hosts can +/// never drift on the (complex, 3-event / 9-action) DDL — the same "reader and lifecycle never +/// disagree" reason <see cref="XeSessionName"/> lives here.</para> +/// +/// <para>Reads the ring buffer exactly like <see cref="BlockedProcessReportCollector"/>: server-scoped +/// on on-prem/MI/RDS, database-scoped per monitored database on Azure SQL DB (#1535 — a single session +/// only ever captured the connection's own database; rpc_completed/sql_batch_completed/attention are +/// all available in an Azure database-scoped session, verified against MS Learn), with an +/// <c>event_time</c> watermark (10-minute first-run fallback) that keeps ring-buffer lingerers from +/// re-inserting. The event payload + the 9 QuickSessionStandard actions are shredded in the SQL/read +/// phase into typed columns.</para> +/// </summary> +public sealed class LongQueryCompletionsCollector : CollectorDefinitionBase<LongQueryCompletionsCollector.Row> +{ + public static LongQueryCompletionsCollector Instance { get; } = new(); + + private LongQueryCompletionsCollector() + { + } + + /* We create and manage our own XE session to avoid conflicts with user's existing sessions. */ + public const string XeSessionName = "PerformanceMonitor_LongQueryCompletions"; + + /// <summary> + /// The default duration threshold (MICROSECONDS) for the completed-event predicate — 2 seconds, + /// matching the value the full Dashboard's long-running-query SQL Trace shipped + /// (install/30_collect_trace_management.sql: <c>@duration_threshold_ms bigint = 2000000</c>, which + /// is microseconds despite the name, and which captured the same RPC:Completed / SQL:BatchCompleted + /// / Attention trio). No per-collector numeric-knob surface exists in the schedule model today + /// (schedules carry only frequency + retention), so this is a hardcoded default per the + /// defaults-over-speculative-config rule — mirroring the blocked-process threshold, which is + /// likewise a hardcoded 5-second sp_configure bootstrap. + /// </summary> + public const long DefaultDurationThresholdMicroseconds = 2_000_000; + + /// <summary>The XE event names this session captures (the QuickSessionStandard completion trio).</summary> + public const string RpcCompletedEvent = "rpc_completed"; + public const string SqlBatchCompletedEvent = "sql_batch_completed"; + public const string AttentionEvent = "attention"; + + public sealed class Row + { + public DateTime? EventTime { get; set; } + public string? EventType { get; set; } + public int? DatabaseId { get; set; } + public string? DatabaseName { get; set; } + public int? SessionId { get; set; } + public string? ClientAppName { get; set; } + public int? ClientPid { get; set; } + public string? NtUserName { get; set; } + public string? ServerPrincipalName { get; set; } + public string? QueryHash { get; set; } + public long? EventSequence { get; set; } + /* Microseconds, from the completed event's payload. NULL for attention: the attention event's + own duration is the cancellation-handling time, not the query runtime (see the class doc). */ + public long? DurationMicroseconds { get; set; } + public long? CpuTimeMicroseconds { get; set; } + public long? PhysicalReads { get; set; } + public long? LogicalReads { get; set; } + public long? Writes { get; set; } + public long? RowCount { get; set; } + /* OK / Error / Abort on the completed events (Abort = the long query was cancelled/timed out); + NULL for attention. */ + public string? Result { get; set; } + /* rpc_completed.statement or sql_batch_completed.batch_text; NULL for attention. */ + public string? StatementText { get; set; } + /* rpc_completed.object_name (the module that ran); NULL for sql batches and attention. */ + public string? ObjectName { get; set; } + } + + public override string Name => "long_query_completions"; + + public override string TargetTable => "long_query_completions"; + + /// <summary>Lite schema names this table's prefix id "long_query_completion_id"; Darling mirrors it.</summary> + public override string PrefixIdColumnName => "long_query_completion_id"; + + /// <summary> + /// Only events newer than the newest already-collected completion are fetched, so an event + /// lingering in the ring buffer across cycles is never inserted twice (mirrors the blocked-process + /// and deadlock collectors' timestamp watermark). + /// </summary> + public override string? WatermarkColumn => "event_time"; + + /// <summary> + /// Azure SQL DB capture is a database-scoped session per monitored database (#1535). The host + /// ensures the session per database and this read then runs per database, exactly like the + /// blocked-process and deadlock collectors. + /// </summary> + public override bool RunsPerDatabase(CollectorTargetInfo target) => target.IsAzureSqlDb; + + /// <summary> + /// Per-database watermark for the per-database sessions: each database's ring buffer dispatches + /// independently, so one database's newer completion must not watermark past another's older one. + /// The <c>database_name</c> action carries the capture database. + /// </summary> + public override string? PerDatabaseWatermarkColumn => "database_name"; + + /* Server- vs database-scoped ring-buffer source is the only engine difference; the event/action + shred is shared. The session only captures the three long-completion events, so the read shreds + everything in the ring buffer newer than the watermark — the duration predicate lives in the + session DDL (BuildCreateSessionSql), not here. Customizable text columns (statement / batch_text + / object_name) are turned on in the DDL's SET clause, so they are present in the payload here. */ + private const string ShredSelect = @" +SELECT + event_time = evt.value('(@timestamp)[1]', 'datetime2'), + event_type = evt.value('(@name)[1]', 'sysname'), + duration = evt.value('(data[@name=""duration""]/value/text())[1]', 'bigint'), + cpu_time = evt.value('(data[@name=""cpu_time""]/value/text())[1]', 'bigint'), + physical_reads = evt.value('(data[@name=""physical_reads""]/value/text())[1]', 'bigint'), + logical_reads = evt.value('(data[@name=""logical_reads""]/value/text())[1]', 'bigint'), + writes = evt.value('(data[@name=""writes""]/value/text())[1]', 'bigint'), + row_count = evt.value('(data[@name=""row_count""]/value/text())[1]', 'bigint'), + result = evt.value('(data[@name=""result""]/value/text())[1]', 'nvarchar(60)'), + statement_text = + COALESCE + ( + evt.value('(data[@name=""statement""]/value/text())[1]', 'nvarchar(max)'), + evt.value('(data[@name=""batch_text""]/value/text())[1]', 'nvarchar(max)') + ), + object_name = evt.value('(data[@name=""object_name""]/value/text())[1]', 'nvarchar(512)'), + client_app_name = evt.value('(action[@name=""client_app_name""]/value/text())[1]', 'nvarchar(256)'), + client_pid = evt.value('(action[@name=""client_pid""]/value/text())[1]', 'integer'), + database_id = evt.value('(action[@name=""database_id""]/value/text())[1]', 'integer'), + database_name = evt.value('(action[@name=""database_name""]/value/text())[1]', 'sysname'), + event_sequence = evt.value('(action[@name=""event_sequence""]/value/text())[1]', 'bigint'), + nt_username = evt.value('(action[@name=""nt_username""]/value/text())[1]', 'nvarchar(256)'), + query_hash = evt.value('(action[@name=""query_hash""]/value/text())[1]', 'nvarchar(64)'), + server_principal_name = evt.value('(action[@name=""server_principal_name""]/value/text())[1]', 'nvarchar(256)'), + session_id = evt.value('(action[@name=""session_id""]/value/text())[1]', 'integer') +FROM @PerformanceMonitor_LongQueryCompletions AS rb +CROSS APPLY rb.ring_buffer.nodes('RingBufferTarget/event[@name=""rpc_completed"" or @name=""sql_batch_completed"" or @name=""attention""]') AS q(evt) +WHERE evt.value('(@timestamp)[1]', 'datetime2') > @cutoff_time +OPTION(RECOMPILE);"; + + public override CollectorQuery BuildQuery(CollectorContext context) + { + string ringBufferSource = context.Target.IsAzureSqlDb + ? @"sys.dm_xe_database_session_targets AS xet +JOIN sys.dm_xe_database_sessions AS xes + ON xes.address = xet.event_session_address" + : @"sys.dm_xe_session_targets AS xet +JOIN sys.dm_xe_sessions AS xes + ON xes.address = xet.event_session_address"; + + string query = $@" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +DECLARE + @PerformanceMonitor_LongQueryCompletions TABLE +( + ring_buffer xml NOT NULL +); + +INSERT + @PerformanceMonitor_LongQueryCompletions +( + ring_buffer +) +SELECT /* PerformanceMonitorLite */ + ring_xml = TRY_CAST(xet.target_data AS xml) +FROM {ringBufferSource} +WHERE xes.name = N'{XeSessionName}' +AND xet.target_name = N'ring_buffer' +OPTION(RECOMPILE); +{ShredSelect}"; + + /* Use the most recent event_time from the host store as the cutoff, or fall back to a + 10-minute window on first run (mirrors the blocked-process / deadlock collectors). */ + var cutoffTime = context.Watermark ?? context.CollectionTime.AddMinutes(-10); + + return new CollectorQuery(query, new List<CollectorParameter> + { + new("@cutoff_time", cutoffTime, CollectorParameterType.DateTime2), + }); + } + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("event_time", CollectorColumnType.Timestamp), + new CollectorColumn("event_type", CollectorColumnType.Varchar), + new CollectorColumn("database_id", CollectorColumnType.Integer), + new CollectorColumn("database_name", CollectorColumnType.Varchar), + new CollectorColumn("session_id", CollectorColumnType.Integer), + new CollectorColumn("client_app_name", CollectorColumnType.Varchar), + new CollectorColumn("client_pid", CollectorColumnType.Integer), + new CollectorColumn("nt_username", CollectorColumnType.Varchar), + new CollectorColumn("server_principal_name", CollectorColumnType.Varchar), + new CollectorColumn("query_hash", CollectorColumnType.Varchar), + new CollectorColumn("event_sequence", CollectorColumnType.BigInt), + new CollectorColumn("duration_microseconds", CollectorColumnType.BigInt), + new CollectorColumn("cpu_time_microseconds", CollectorColumnType.BigInt), + new CollectorColumn("physical_reads", CollectorColumnType.BigInt), + new CollectorColumn("logical_reads", CollectorColumnType.BigInt), + new CollectorColumn("writes", CollectorColumnType.BigInt), + new CollectorColumn("row_count", CollectorColumnType.BigInt), + new CollectorColumn("result", CollectorColumnType.Varchar), + new CollectorColumn("statement_text", CollectorColumnType.Varchar), + new CollectorColumn("object_name", CollectorColumnType.Varchar), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var rows = new List<Row>(); + + while (await reader.ReadAsync(cancellationToken)) + { + var eventType = reader.IsDBNull(1) ? null : reader.GetString(1); + var isAttention = string.Equals(eventType, AttentionEvent, StringComparison.Ordinal); + + /* Reader ordinals follow the ShredSelect projection order exactly (0-19). */ + var parsedDatabaseName = reader.IsDBNull(14) ? null : reader.GetString(14); + + rows.Add(new Row + { + EventTime = reader.IsDBNull(0) ? null : reader.GetDateTime(0), + EventType = eventType, + /* duration is the runtime for completed events; for attention it is the + cancellation-handling time (not the query runtime), so it is dropped to NULL. */ + DurationMicroseconds = isAttention || reader.IsDBNull(2) ? null : reader.GetInt64(2), + CpuTimeMicroseconds = reader.IsDBNull(3) ? null : reader.GetInt64(3), + PhysicalReads = reader.IsDBNull(4) ? null : reader.GetInt64(4), + LogicalReads = reader.IsDBNull(5) ? null : reader.GetInt64(5), + Writes = reader.IsDBNull(6) ? null : reader.GetInt64(6), + RowCount = reader.IsDBNull(7) ? null : reader.GetInt64(7), + Result = reader.IsDBNull(8) ? null : reader.GetString(8), + StatementText = reader.IsDBNull(9) ? null : reader.GetString(9), + ObjectName = reader.IsDBNull(10) ? null : reader.GetString(10), + ClientAppName = reader.IsDBNull(11) ? null : reader.GetString(11), + ClientPid = reader.IsDBNull(12) ? null : reader.GetInt32(12), + DatabaseId = reader.IsDBNull(13) ? null : reader.GetInt32(13), + /* Per-database path (#1535): the capture database is authoritative for the + per-database watermark key — a database-scoped session only captures its own + database. Server-scoped platforms keep the database_name action (CurrentDatabaseName + is null there). */ + DatabaseName = context.CurrentDatabaseName ?? parsedDatabaseName, + EventSequence = reader.IsDBNull(15) ? null : reader.GetInt64(15), + NtUserName = reader.IsDBNull(16) ? null : reader.GetString(16), + QueryHash = reader.IsDBNull(17) ? null : reader.GetString(17), + ServerPrincipalName = reader.IsDBNull(18) ? null : reader.GetString(18), + SessionId = reader.IsDBNull(19) ? null : reader.GetInt32(19), + }); + } + + return rows; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + writer + .Value(row.EventTime) + .Value(row.EventType) + .Value(row.DatabaseId) + .Value(row.DatabaseName) + .Value(row.SessionId) + .Value(row.ClientAppName) + .Value(row.ClientPid) + .Value(row.NtUserName) + .Value(row.ServerPrincipalName) + .Value(row.QueryHash) + .Value(row.EventSequence) + .Value(row.DurationMicroseconds) + .Value(row.CpuTimeMicroseconds) + .Value(row.PhysicalReads) + .Value(row.LogicalReads) + .Value(row.Writes) + .Value(row.RowCount) + .Value(row.Result) + .Value(row.StatementText) + .Value(row.ObjectName); + } + + /* --------------------------------------------------------------------------------------------- + Session DDL — shared by both hosts' lifecycle code (Lite's RemoteCollectorService and Darling's + DarlingXeSessions) so the two can never drift on the 3-event / 9-action / SET-collect / duration- + predicate CREATE. The lifecycle STRUCTURE (existence check, create, start, drop) still lives + per-host, mirroring the blocked-process/deadlock pattern; only this DDL TEXT is centralized. + --------------------------------------------------------------------------------------------- */ + + /// <summary> + /// The 9 QuickSessionStandard actions, minus <c>nt_username</c> on the database-scoped (Azure SQL + /// DB) variant — Azure SQL DB uses Entra/SQL authentication, so an NT username is meaningless there + /// and the action may not exist database-scoped; the column simply lands NULL on Azure. All other + /// actions are available in an Azure database-scoped session (verified against MS Learn). + /// </summary> + private static string ActionList(bool databaseScoped) + { + var ntUsername = databaseScoped ? "" : "\n sqlserver.nt_username,"; + return $@" + sqlserver.client_app_name, + sqlserver.client_pid, + sqlserver.database_id, + sqlserver.database_name, + package0.event_sequence,{ntUsername} + sqlserver.query_hash, + sqlserver.server_principal_name, + sqlserver.session_id"; + } + + /// <summary> + /// The <c>CREATE EVENT SESSION</c> for the long-query completion trace. <paramref name="databaseScoped"/> + /// selects the Azure SQL DB (ON DATABASE) form; otherwise the server-scoped form (with + /// MEMORY_PARTITION_MODE = NONE for a single readable ring buffer / AWS RDS compatibility, mirroring + /// the deadlock session). The <paramref name="thresholdMicroseconds"/> duration predicate is applied + /// to the two COMPLETED events only; <c>attention</c> is captured unfiltered (see the class doc). The + /// customizable text/object columns are turned on via SET so they are actually collected. + /// </summary> + public static string BuildCreateSessionSql(bool databaseScoped, long thresholdMicroseconds) + { + var scope = databaseScoped ? "DATABASE" : "SERVER"; + var actions = ActionList(databaseScoped); + /* Server-scoped: single, unpartitioned ring buffer (readable target_data on multi-socket boxes, + AWS RDS compatible). Azure database-scoped sessions do not accept MEMORY_PARTITION_MODE. */ + var partitionMode = databaseScoped ? "" : "\n MEMORY_PARTITION_MODE = NONE,"; + + return $@" +CREATE EVENT SESSION [{XeSessionName}] +ON {scope} +ADD EVENT sqlserver.rpc_completed +( + SET + collect_statement = 1, + collect_object_name = 1 + ACTION + ({actions} + ) + WHERE duration >= {thresholdMicroseconds} +), +ADD EVENT sqlserver.sql_batch_completed +( + SET + collect_batch_text = 1 + ACTION + ({actions} + ) + WHERE duration >= {thresholdMicroseconds} +), +ADD EVENT sqlserver.attention +( + ACTION + ({actions} + ) +) +ADD TARGET package0.ring_buffer +( + SET max_memory = 4096 +) +WITH +( + MAX_DISPATCH_LATENCY = 5 SECONDS, + EVENT_RETENTION_MODE = ALLOW_SINGLE_EVENT_LOSS,{partitionMode} + STARTUP_STATE = ON +);"; + } + + /// <summary>Starts the session (CREATE does not auto-start it, even with STARTUP_STATE = ON).</summary> + public static string BuildStartSessionSql(bool databaseScoped) => + $"ALTER EVENT SESSION [{XeSessionName}] ON {(databaseScoped ? "DATABASE" : "SERVER")} STATE = START;"; + + /// <summary> + /// Idempotently drops the session (the opt-out path — disabling the collector removes the + /// server-side session, which is the actual busy-server cost). Guarded by an existence check so a + /// drop on a server that never had the session is a clean no-op. Server- vs database-scoped catalog + /// + scope selected by <paramref name="databaseScoped"/>. + /// </summary> + public static string BuildDropSessionSql(bool databaseScoped) + { + if (databaseScoped) + { + return $@" +IF EXISTS +( + SELECT + 1/0 + FROM sys.database_event_sessions + WHERE name = N'{XeSessionName}' +) +BEGIN + DROP EVENT SESSION [{XeSessionName}] ON DATABASE; +END;"; + } + + return $@" +IF EXISTS +( + SELECT + 1/0 + FROM sys.server_event_sessions + WHERE name = N'{XeSessionName}' +) +BEGIN + DROP EVENT SESSION [{XeSessionName}] ON SERVER; +END;"; + } +} diff --git a/PerformanceMonitor.Collectors/MemoryClerksCollector.cs b/PerformanceMonitor.Collectors/MemoryClerksCollector.cs new file mode 100644 index 000000000..24ce556ab --- /dev/null +++ b/PerformanceMonitor.Collectors/MemoryClerksCollector.cs @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Top memory clerks from sys.dm_os_memory_clerks (TOP 25, above 1 MB). Extracted verbatim +/// from Lite's RemoteCollectorService.Memory.cs. +/// </summary> +public sealed class MemoryClerksCollector : CollectorDefinitionBase<MemoryClerksCollector.Row> +{ + public static MemoryClerksCollector Instance { get; } = new(); + + private MemoryClerksCollector() + { + } + + public readonly record struct Row(string ClerkType, decimal MemoryMb); + + private const string QueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT TOP (25) + clerk_type = mc.type, + memory_mb = CONVERT(decimal(18,2), SUM(mc.pages_kb) / 1024.0) +FROM sys.dm_os_memory_clerks AS mc +GROUP BY + mc.type +HAVING + SUM(mc.pages_kb) > 1024 +ORDER BY + SUM(mc.pages_kb) DESC +OPTION(RECOMPILE);"; + + public override string Name => "memory_clerks"; + + public override string TargetTable => "memory_clerks"; + + public override string? WatermarkColumn => null; + + public override bool AppliesTo(CollectorTargetInfo target) => true; + + public override bool RunsPerDatabase(CollectorTargetInfo target) => false; + + public override CollectorQuery BuildQuery(CollectorContext context) => new(QueryText); + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("clerk_type", CollectorColumnType.Varchar), + new CollectorColumn("memory_mb", CollectorColumnType.Decimal, 18, 2), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var rows = new List<Row>(); + + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new Row(reader.GetString(0), reader.GetDecimal(1))); + } + + return rows; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + writer + .Value(row.ClerkType) /* clerk_type VARCHAR */ + .Value(row.MemoryMb); /* memory_mb DECIMAL */ + } +} diff --git a/PerformanceMonitor.Collectors/MemoryGrantsCollector.cs b/PerformanceMonitor.Collectors/MemoryGrantsCollector.cs new file mode 100644 index 000000000..4f9b2ce7f --- /dev/null +++ b/PerformanceMonitor.Collectors/MemoryGrantsCollector.cs @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Memory grant statistics from sys.dm_exec_query_resource_semaphores (same DMV as Dashboard, +/// for parity). Extracted verbatim from Lite's RemoteCollectorService.MemoryGrants.cs — the +/// composite "{pool}_{semaphore}" delta key and the two delta groups are the parity contract. +/// </summary> +public sealed class MemoryGrantsCollector : CollectorDefinitionBase<MemoryGrantsCollector.Row> +{ + public static MemoryGrantsCollector Instance { get; } = new(); + + private MemoryGrantsCollector() + { + } + + public readonly record struct Row( + short ResourceSemaphoreId, + int PoolId, + decimal TargetMb, + decimal MaxTargetMb, + decimal TotalMb, + decimal AvailableMb, + decimal GrantedMb, + decimal UsedMb, + int GranteeCount, + int WaiterCount, + long TimeoutErrorCount, + long ForcedGrantCount); + + public override string Name => "memory_grant_stats"; + + public override string TargetTable => "memory_grant_stats"; + + public override string? WatermarkColumn => null; + + public override bool AppliesTo(CollectorTargetInfo target) => true; + + public override bool RunsPerDatabase(CollectorTargetInfo target) => false; + + public override CollectorQuery BuildQuery(CollectorContext context) => new(QueryText); + + private const string QueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + resource_semaphore_id = deqrs.resource_semaphore_id, + pool_id = deqrs.pool_id, + target_memory_mb = CONVERT(decimal(18,2), deqrs.target_memory_kb / 1024.0), + max_target_memory_mb = CONVERT(decimal(18,2), deqrs.max_target_memory_kb / 1024.0), + total_memory_mb = CONVERT(decimal(18,2), deqrs.total_memory_kb / 1024.0), + available_memory_mb = CONVERT(decimal(18,2), deqrs.available_memory_kb / 1024.0), + granted_memory_mb = CONVERT(decimal(18,2), ISNULL(deqrs.granted_memory_kb, 0) / 1024.0), + used_memory_mb = CONVERT(decimal(18,2), ISNULL(deqrs.used_memory_kb, 0) / 1024.0), + grantee_count = deqrs.grantee_count, + waiter_count = deqrs.waiter_count, + timeout_error_count = ISNULL(deqrs.timeout_error_count, 0), + forced_grant_count = ISNULL(deqrs.forced_grant_count, 0) +FROM sys.dm_exec_query_resource_semaphores AS deqrs +WHERE deqrs.max_target_memory_kb IS NOT NULL +OPTION(RECOMPILE);"; + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("resource_semaphore_id", CollectorColumnType.SmallInt), + new CollectorColumn("pool_id", CollectorColumnType.Integer), + new CollectorColumn("target_memory_mb", CollectorColumnType.Decimal, 18, 2), + new CollectorColumn("max_target_memory_mb", CollectorColumnType.Decimal, 18, 2), + new CollectorColumn("total_memory_mb", CollectorColumnType.Decimal, 18, 2), + new CollectorColumn("available_memory_mb", CollectorColumnType.Decimal, 18, 2), + new CollectorColumn("granted_memory_mb", CollectorColumnType.Decimal, 18, 2), + new CollectorColumn("used_memory_mb", CollectorColumnType.Decimal, 18, 2), + new CollectorColumn("grantee_count", CollectorColumnType.Integer), + new CollectorColumn("waiter_count", CollectorColumnType.Integer), + new CollectorColumn("timeout_error_count", CollectorColumnType.BigInt), + new CollectorColumn("forced_grant_count", CollectorColumnType.BigInt), + new CollectorColumn("timeout_error_count_delta", CollectorColumnType.BigInt), + new CollectorColumn("forced_grant_count_delta", CollectorColumnType.BigInt), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var rows = new List<Row>(); + + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new Row( + Convert.ToInt16(reader.GetValue(0), CultureInfo.InvariantCulture), + Convert.ToInt32(reader.GetValue(1), CultureInfo.InvariantCulture), + reader.IsDBNull(2) ? 0m : reader.GetDecimal(2), + reader.IsDBNull(3) ? 0m : reader.GetDecimal(3), + reader.IsDBNull(4) ? 0m : reader.GetDecimal(4), + reader.IsDBNull(5) ? 0m : reader.GetDecimal(5), + reader.IsDBNull(6) ? 0m : reader.GetDecimal(6), + reader.IsDBNull(7) ? 0m : reader.GetDecimal(7), + reader.IsDBNull(8) ? 0 : Convert.ToInt32(reader.GetValue(8), CultureInfo.InvariantCulture), + reader.IsDBNull(9) ? 0 : Convert.ToInt32(reader.GetValue(9), CultureInfo.InvariantCulture), + reader.IsDBNull(10) ? 0L : Convert.ToInt64(reader.GetValue(10), CultureInfo.InvariantCulture), + reader.IsDBNull(11) ? 0L : Convert.ToInt64(reader.GetValue(11), CultureInfo.InvariantCulture))); + } + + return rows; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + /* Composite delta key and group names are the parity contract — do not change casually. */ + var deltaKey = $"{row.PoolId}_{row.ResourceSemaphoreId}"; + var deltaTimeouts = context.Deltas.CalculateDelta(context.ServerId, "memory_grants_timeouts", deltaKey, row.TimeoutErrorCount, collectionTime: context.CollectionTime, maxGapSeconds: 300); + var deltaForced = context.Deltas.CalculateDelta(context.ServerId, "memory_grants_forced", deltaKey, row.ForcedGrantCount, collectionTime: context.CollectionTime, maxGapSeconds: 300); + + writer + .Value(row.ResourceSemaphoreId) /* resource_semaphore_id (appended as SHORT, matching the original) */ + .Value(row.PoolId) /* pool_id INTEGER */ + .Value(row.TargetMb) /* target_memory_mb DECIMAL */ + .Value(row.MaxTargetMb) /* max_target_memory_mb DECIMAL */ + .Value(row.TotalMb) /* total_memory_mb DECIMAL */ + .Value(row.AvailableMb) /* available_memory_mb DECIMAL */ + .Value(row.GrantedMb) /* granted_memory_mb DECIMAL */ + .Value(row.UsedMb) /* used_memory_mb DECIMAL */ + .Value(row.GranteeCount) /* grantee_count INTEGER */ + .Value(row.WaiterCount) /* waiter_count INTEGER */ + .Value(row.TimeoutErrorCount) /* timeout_error_count BIGINT */ + .Value(row.ForcedGrantCount) /* forced_grant_count BIGINT */ + .Value(deltaTimeouts) /* timeout_error_count_delta BIGINT */ + .Value(deltaForced); /* forced_grant_count_delta BIGINT */ + } +} diff --git a/PerformanceMonitor.Collectors/MemoryPressureEventsCollector.cs b/PerformanceMonitor.Collectors/MemoryPressureEventsCollector.cs new file mode 100644 index 000000000..16383a6f2 --- /dev/null +++ b/PerformanceMonitor.Collectors/MemoryPressureEventsCollector.cs @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Memory pressure notifications from RING_BUFFER_RESOURCE_MONITOR (the sp_pressuredetector +/// source): IndicatorsProcess/IndicatorsSystem (0-1 normal, 2 medium, 3+ severe) plus the +/// notification type. Does not apply to Azure SQL DB (sys.dm_os_ring_buffers is not exposed). +/// Client-side watermark dedup — the computed sample_time cannot be filtered server-side. +/// Extracted verbatim from Lite's RemoteCollectorService.Memory.cs. +/// </summary> +public sealed class MemoryPressureEventsCollector : CollectorDefinitionBase<MemoryPressureEventsCollector.Row> +{ + public static MemoryPressureEventsCollector Instance { get; } = new(); + + private MemoryPressureEventsCollector() + { + } + + public readonly record struct Row(DateTime SampleTime, string MemoryNotification, int IndicatorsProcess, int IndicatorsSystem); + + private const string QueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +DECLARE + @ms_ticks bigint, + @now datetime2(7) = SYSDATETIME(); + +SELECT @ms_ticks = dosi.ms_ticks FROM sys.dm_os_sys_info AS dosi; + +SELECT + sample_time = DATEADD(SECOND, -((@ms_ticks - t.timestamp) / 1000), @now), + memory_notification = t.record.value('(/Record/ResourceMonitor/Notification)[1]', 'nvarchar(100)'), + memory_indicators_process = t.record.value('(/Record/ResourceMonitor/IndicatorsProcess)[1]', 'integer'), + memory_indicators_system = t.record.value('(/Record/ResourceMonitor/IndicatorsSystem)[1]', 'integer') +FROM +( + SELECT + dorb.timestamp, + record = CONVERT(xml, dorb.record) + FROM sys.dm_os_ring_buffers AS dorb + WHERE dorb.ring_buffer_type = N'RING_BUFFER_RESOURCE_MONITOR' +) AS t +ORDER BY t.timestamp +OPTION(RECOMPILE);"; + + public override string Name => "memory_pressure_events"; + + public override string TargetTable => "memory_pressure_events"; + + public override string? WatermarkColumn => "sample_time"; + + /// <summary>Ring buffers are not exposed on Azure SQL DB — skip the cycle entirely.</summary> + public override bool AppliesTo(CollectorTargetInfo target) => !target.IsAzureSqlDb; + + public override bool RunsPerDatabase(CollectorTargetInfo target) => false; + + public override CollectorQuery BuildQuery(CollectorContext context) => new(QueryText); + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("sample_time", CollectorColumnType.Timestamp), + new CollectorColumn("memory_notification", CollectorColumnType.Varchar), + new CollectorColumn("memory_indicators_process", CollectorColumnType.Integer), + new CollectorColumn("memory_indicators_system", CollectorColumnType.Integer), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var rows = new List<Row>(); + + while (await reader.ReadAsync(cancellationToken)) + { + var sampleTime = reader.IsDBNull(0) ? DateTime.MinValue : reader.GetDateTime(0); + + /* Client-side dedup: computed sample_time can't be filtered server-side. */ + if (context.Watermark.HasValue && sampleTime <= context.Watermark.Value) + { + continue; + } + + rows.Add(new Row( + sampleTime, + reader.IsDBNull(1) ? "" : reader.GetString(1), + reader.IsDBNull(2) ? 0 : reader.GetInt32(2), + reader.IsDBNull(3) ? 0 : reader.GetInt32(3))); + } + + return rows; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + writer + .Value(row.SampleTime) /* sample_time TIMESTAMP */ + .Value(row.MemoryNotification) /* memory_notification VARCHAR */ + .Value(row.IndicatorsProcess) /* memory_indicators_process INTEGER */ + .Value(row.IndicatorsSystem); /* memory_indicators_system INTEGER */ + } +} diff --git a/PerformanceMonitor.Collectors/MemoryStatsCollector.cs b/PerformanceMonitor.Collectors/MemoryStatsCollector.cs new file mode 100644 index 000000000..611d79929 --- /dev/null +++ b/PerformanceMonitor.Collectors/MemoryStatsCollector.cs @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Server memory statistics from sys.dm_os_sys_memory + performance counters, with an Azure SQL +/// DB variant (edition 5) that approximates from sys.dm_os_sys_info committed targets and reports +/// current_workers_count as NULL — elastic pools enforce VIEW SERVER PERFORMANCE STATE on +/// sys.dm_os_schedulers regardless of DB-scoped grants (#857). Azure MI (edition 8) behaves like +/// on-prem. Extracted verbatim from Lite's RemoteCollectorService.Memory.cs. Single row per cycle; +/// zero rows when the query returns none. +/// </summary> +public sealed class MemoryStatsCollector : CollectorDefinitionBase<MemoryStatsCollector.Row> +{ + public static MemoryStatsCollector Instance { get; } = new(); + + private MemoryStatsCollector() + { + } + + public readonly record struct Row( + decimal TotalPhysicalMemoryMb, + decimal AvailablePhysicalMemoryMb, + decimal TotalPageFileMb, + decimal AvailablePageFileMb, + string SystemMemoryState, + string SqlMemoryModel, + decimal TargetServerMemoryMb, + decimal TotalServerMemoryMb, + decimal BufferPoolMb, + decimal PlanCacheMb, + int MaxWorkersCount, + int CurrentWorkersCount); + + private const string AzureSqlDbQueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + total_physical_memory_mb = CONVERT(decimal(18,2), osi.committed_target_kb / 1024.0), + available_physical_memory_mb = CONVERT(decimal(18,2), (osi.committed_target_kb - osi.committed_kb) / 1024.0), + total_page_file_mb = CONVERT(decimal(18,2), 0), + available_page_file_mb = CONVERT(decimal(18,2), 0), + system_memory_state = N'Available', + sql_memory_model = N'N/A', + target_server_memory_mb = CONVERT(decimal(18,2), pc_target.cntr_value / 1024.0), + total_server_memory_mb = CONVERT(decimal(18,2), pc_total.cntr_value / 1024.0), + buffer_pool_mb = CONVERT(decimal(18,2), pc_buffer.cntr_value / 1024.0), + plan_cache_mb = CONVERT(decimal(18,2), pc_plan.cntr_value * 8.0 / 1024.0), + max_workers_count = osi.max_workers_count, + current_workers_count = CONVERT(int, NULL) +FROM sys.dm_os_sys_info AS osi +CROSS JOIN +( + SELECT cntr_value + FROM sys.dm_os_performance_counters + WHERE counter_name = N'Target Server Memory (KB)' +) AS pc_target +CROSS JOIN +( + SELECT cntr_value + FROM sys.dm_os_performance_counters + WHERE counter_name = N'Total Server Memory (KB)' +) AS pc_total +CROSS JOIN +( + SELECT cntr_value + FROM sys.dm_os_performance_counters + WHERE counter_name = N'Database Cache Memory (KB)' +) AS pc_buffer +CROSS JOIN +( + SELECT cntr_value = SUM(cntr_value) + FROM sys.dm_os_performance_counters + WHERE counter_name = N'Cache Pages' + AND object_name LIKE N'%:Plan Cache%' +) AS pc_plan +OPTION(RECOMPILE);"; + + private const string OnPremQueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + total_physical_memory_mb = CONVERT(decimal(18,2), osm.total_physical_memory_kb / 1024.0), + available_physical_memory_mb = CONVERT(decimal(18,2), osm.available_physical_memory_kb / 1024.0), + total_page_file_mb = CONVERT(decimal(18,2), osm.total_page_file_kb / 1024.0), + available_page_file_mb = CONVERT(decimal(18,2), osm.available_page_file_kb / 1024.0), + system_memory_state = osm.system_memory_state_desc, + sql_memory_model = osi.sql_memory_model_desc, + target_server_memory_mb = CONVERT(decimal(18,2), pc_target.cntr_value / 1024.0), + total_server_memory_mb = CONVERT(decimal(18,2), pc_total.cntr_value / 1024.0), + buffer_pool_mb = CONVERT(decimal(18,2), pc_buffer.cntr_value / 1024.0), + plan_cache_mb = CONVERT(decimal(18,2), pc_plan.cntr_value * 8.0 / 1024.0), + max_workers_count = osi.max_workers_count, + current_workers_count = w.current_workers +FROM sys.dm_os_sys_memory AS osm +CROSS JOIN sys.dm_os_sys_info AS osi +CROSS JOIN +( + SELECT cntr_value + FROM sys.dm_os_performance_counters + WHERE counter_name = N'Target Server Memory (KB)' +) AS pc_target +CROSS JOIN +( + SELECT cntr_value + FROM sys.dm_os_performance_counters + WHERE counter_name = N'Total Server Memory (KB)' +) AS pc_total +CROSS JOIN +( + SELECT cntr_value + FROM sys.dm_os_performance_counters + WHERE counter_name = N'Database Cache Memory (KB)' +) AS pc_buffer +CROSS JOIN +( + SELECT cntr_value = SUM(cntr_value) + FROM sys.dm_os_performance_counters + WHERE counter_name = N'Cache Pages' + AND object_name LIKE N'%:Plan Cache%' +) AS pc_plan +CROSS JOIN +( + SELECT current_workers = SUM(active_workers_count) + FROM sys.dm_os_schedulers + WHERE status = N'VISIBLE ONLINE' +) AS w +OPTION(RECOMPILE);"; + + public override string Name => "memory_stats"; + + public override string TargetTable => "memory_stats"; + + public override string? WatermarkColumn => null; + + public override bool AppliesTo(CollectorTargetInfo target) => true; + + public override bool RunsPerDatabase(CollectorTargetInfo target) => false; + + public override CollectorQuery BuildQuery(CollectorContext context) + => new(context.Target.IsAzureSqlDb ? AzureSqlDbQueryText : OnPremQueryText); + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("total_physical_memory_mb", CollectorColumnType.Decimal, 18, 2), + new CollectorColumn("available_physical_memory_mb", CollectorColumnType.Decimal, 18, 2), + new CollectorColumn("total_page_file_mb", CollectorColumnType.Decimal, 18, 2), + new CollectorColumn("available_page_file_mb", CollectorColumnType.Decimal, 18, 2), + new CollectorColumn("system_memory_state", CollectorColumnType.Varchar), + new CollectorColumn("sql_memory_model", CollectorColumnType.Varchar), + new CollectorColumn("target_server_memory_mb", CollectorColumnType.Decimal, 18, 2), + new CollectorColumn("total_server_memory_mb", CollectorColumnType.Decimal, 18, 2), + new CollectorColumn("buffer_pool_mb", CollectorColumnType.Decimal, 18, 2), + new CollectorColumn("plan_cache_mb", CollectorColumnType.Decimal, 18, 2), + new CollectorColumn("max_workers_count", CollectorColumnType.Integer), + new CollectorColumn("current_workers_count", CollectorColumnType.Integer), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var rows = new List<Row>(); + + if (!await reader.ReadAsync(cancellationToken)) + { + return rows; + } + + rows.Add(new Row( + reader.IsDBNull(0) ? 0m : reader.GetDecimal(0), + reader.IsDBNull(1) ? 0m : reader.GetDecimal(1), + reader.IsDBNull(2) ? 0m : reader.GetDecimal(2), + reader.IsDBNull(3) ? 0m : reader.GetDecimal(3), + reader.IsDBNull(4) ? "Unknown" : reader.GetString(4), + reader.IsDBNull(5) ? "Unknown" : reader.GetString(5), + reader.IsDBNull(6) ? 0m : reader.GetDecimal(6), + reader.IsDBNull(7) ? 0m : reader.GetDecimal(7), + reader.IsDBNull(8) ? 0m : reader.GetDecimal(8), + reader.IsDBNull(9) ? 0m : reader.GetDecimal(9), + reader.IsDBNull(10) ? 0 : reader.GetInt32(10), + reader.IsDBNull(11) ? 0 : reader.GetInt32(11))); + + return rows; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + writer + .Value(row.TotalPhysicalMemoryMb) + .Value(row.AvailablePhysicalMemoryMb) + .Value(row.TotalPageFileMb) + .Value(row.AvailablePageFileMb) + .Value(row.SystemMemoryState) + .Value(row.SqlMemoryModel) + .Value(row.TargetServerMemoryMb) + .Value(row.TotalServerMemoryMb) + .Value(row.BufferPoolMb) + .Value(row.PlanCacheMb) + .Value(row.MaxWorkersCount) + .Value(row.CurrentWorkersCount); + } +} diff --git a/PerformanceMonitor.Collectors/PerfmonStatsCollector.cs b/PerformanceMonitor.Collectors/PerfmonStatsCollector.cs new file mode 100644 index 000000000..3fd3a8638 --- /dev/null +++ b/PerformanceMonitor.Collectors/PerfmonStatsCollector.cs @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Key performance counters from sys.dm_os_performance_counters. Extracted verbatim from Lite's +/// RemoteCollectorService.Perfmon.cs — the curated default counter list is parity brain and lives +/// HERE; hosts may supply an override via <see cref="CollectorContext.PerfmonCounterOverride"/> +/// (Lite: perfmon_counters.json). Counter names interpolate as escaped N'...' literals; one delta +/// group ("perfmon") keyed "{object}|{counter}|{instance}" with the 300 s gap; the constant 60 s +/// sample interval is written per row. +/// </summary> +public sealed class PerfmonStatsCollector : CollectorDefinitionBase<PerfmonStatsCollector.Row> +{ + public static PerfmonStatsCollector Instance { get; } = new(); + + private PerfmonStatsCollector() + { + } + + public readonly record struct Row(string ObjectName, string CounterName, string InstanceName, long CntrValue); + + public static readonly IReadOnlyList<string> DefaultCounters = new[] + { + /* I/O counters */ + "Forwarded Records/sec", + "Page reads/sec", + "Page writes/sec", + "Checkpoint pages/sec", + "Page lookups/sec", + "Readahead pages/sec", + "Background writer pages/sec", + "Lazy writes/sec", + "Full Scans/sec", + "Index Searches/sec", + "Page Splits/sec", + "Free list stalls/sec", + "Non-Page latch waits", + "Page IO latch waits", + "Page latch waits", + /* Transaction counters */ + "Transactions/sec", + "Longest Transaction Running Time", + /* Locking counters */ + "Table Lock Escalations/sec", + "Lock Requests/sec", + "Lock Wait Time (ms)", + "Lock Waits/sec", + "Number of Deadlocks/sec", + "Lock waits", + "Processes blocked", + "Lock Timeouts/sec", + /* Memory counters */ + "Granted Workspace Memory (KB)", + "Lock Memory (KB)", + "Memory Grants Pending", + "SQL Cache Memory (KB)", + "Stolen Server Memory (KB)", + "Target Server Memory (KB)", + "Total Server Memory (KB)", + "Memory grant queue waits", + "Thread-safe memory objects waits", + /* Compilation counters */ + "SQL Compilations/sec", + "SQL Re-Compilations/sec", + "Query optimizations/sec", + "Reduced memory grants/sec", + /* Batch and request counters */ + "Batch Requests/sec", + "Requests completed/sec", + "Active requests", + "Queued requests", + "Blocked tasks", + "Active parallel threads", + /* Log counters */ + "Log Flushes/sec", + "Log Bytes Flushed/sec", + "Log Flush Write Time (ms)", + "Log buffer waits", + "Log write waits", + /* TempDB counters */ + "Version Store Size (KB)", + "Free Space in tempdb (KB)", + "Active Temp Tables", + "Version Generation rate (KB/s)", + "Version Cleanup rate (KB/s)", + "Temp Tables Creation Rate", + "Workfiles Created/sec", + "Worktables Created/sec", + /* Wait counters */ + "Network IO waits", + "Wait for the worker", + }; + + public override string Name => "perfmon_stats"; + + public override string TargetTable => "perfmon_stats"; + + public override CollectorQuery BuildQuery(CollectorContext context) + { + var counters = context.PerfmonCounterOverride is { Count: > 0 } + ? context.PerfmonCounterOverride + : DefaultCounters; + var counterList = string.Join(",\n ", counters.Select(c => $"N'{c.Replace("'", "''", StringComparison.Ordinal)}'")); + + var query = $@" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + object_name = RTRIM(pc.object_name), + counter_name = RTRIM(pc.counter_name), + instance_name = RTRIM(pc.instance_name), + cntr_value = pc.cntr_value +FROM sys.dm_os_performance_counters AS pc +WHERE pc.counter_name IN ( + {counterList} +) +OPTION(RECOMPILE);"; + + return new CollectorQuery(query); + } + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("object_name", CollectorColumnType.Varchar), + new CollectorColumn("counter_name", CollectorColumnType.Varchar), + new CollectorColumn("instance_name", CollectorColumnType.Varchar), + new CollectorColumn("cntr_value", CollectorColumnType.BigInt), + new CollectorColumn("delta_cntr_value", CollectorColumnType.BigInt), + new CollectorColumn("sample_interval_seconds", CollectorColumnType.Integer), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var rows = new List<Row>(); + + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new Row( + reader.IsDBNull(0) ? "" : reader.GetString(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + reader.IsDBNull(2) ? "" : reader.GetString(2), + reader.GetInt64(3))); + } + + return rows; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + /* Delta for per-second counters — gap detection at 5min (5x the 1-min collection interval) + prevents inflated deltas after app restarts. Group/key/gap are the parity contract. */ + var deltaKey = $"{row.ObjectName}|{row.CounterName}|{row.InstanceName}"; + var deltaCntrValue = context.Deltas.CalculateDelta(context.ServerId, "perfmon", deltaKey, row.CntrValue, + collectionTime: context.CollectionTime, maxGapSeconds: 300); + + writer + .Value(row.ObjectName) /* object_name VARCHAR */ + .Value(row.CounterName) /* counter_name VARCHAR */ + .Value(row.InstanceName) /* instance_name VARCHAR */ + .Value(row.CntrValue) /* cntr_value BIGINT */ + .Value(deltaCntrValue) /* delta_cntr_value BIGINT */ + .Value(60); /* sample_interval_seconds — 1-minute collection interval */ + } +} diff --git a/PerformanceMonitor.Collectors/PerformanceMonitor.Collectors.csproj b/PerformanceMonitor.Collectors/PerformanceMonitor.Collectors.csproj new file mode 100644 index 000000000..7b724248d --- /dev/null +++ b/PerformanceMonitor.Collectors/PerformanceMonitor.Collectors.csproj @@ -0,0 +1,15 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <Nullable>enable</Nullable> + <ImplicitUsings>disable</ImplicitUsings> + <RootNamespace>PerformanceMonitor.Collectors</RootNamespace> + <AssemblyName>PerformanceMonitor.Collectors</AssemblyName> + <Company>Darling Data, LLC</Company> + <Copyright>Copyright © 2026 Darling Data, LLC</Copyright> + <EnableNETAnalyzers>true</EnableNETAnalyzers> + <AnalysisLevel>latest-recommended</AnalysisLevel> + <NoWarn>CA1849;CA2007;CA1508;CA1822;CA1805;CA1510;CA1816;CA1861;CA1845;CA2201;CA1848;CA1852;CA1305;CA1860;CA1707;CA1507;CA1806;CA2254</NoWarn> + <WarningsAsErrors>CS4014</WarningsAsErrors> + </PropertyGroup> +</Project> diff --git a/PerformanceMonitor.Collectors/PlanCacheStatsCollector.cs b/PerformanceMonitor.Collectors/PlanCacheStatsCollector.cs new file mode 100644 index 000000000..e99f027fb --- /dev/null +++ b/PerformanceMonitor.Collectors/PlanCacheStatsCollector.cs @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Plan-cache composition snapshot from sys.dm_exec_cached_plans, aggregated by cacheobjtype/objtype +/// into single-use vs multi-use plan/size bloat (the Dashboard-parity port of +/// install/35_collect_plan_cache_stats.sql). A POINT-IN-TIME snapshot, not a cumulative-counter +/// delta collector: it reports the current cache composition directly, one row per +/// (cacheobjtype, objtype) group — no delta groups (mirrors <see cref="MemoryStatsCollector"/> / +/// <see cref="SessionStatsCollector"/>). oldest_plan_create_time is a single uncorrelated +/// MIN(creation_time) over sys.dm_exec_query_stats, so every group row carries the same value +/// (identical to the Dashboard proc). The proc's collection_log logging and RAISERROR single-use +/// debug output are the collector framework's job and are intentionally dropped. +/// +/// <para>Available on SQL Server, Azure SQL Database, and Azure SQL Managed Instance (both DMVs +/// verified against MS Learn — "Applies to" lists all three). On Azure SQL Database the cache is +/// inherently scoped to the connected database (cross-tenant rows filtered), which is a platform +/// property, not a collection gap — so <see cref="AppliesTo"/> is unconditionally true. We do not +/// read the Azure-NULLed memory_object_address / pool_id columns.</para> +/// </summary> +public sealed class PlanCacheStatsCollector : CollectorDefinitionBase<PlanCacheStatsCollector.Row> +{ + public static PlanCacheStatsCollector Instance { get; } = new(); + + private PlanCacheStatsCollector() + { + } + + public readonly record struct Row( + string Cacheobjtype, + string Objtype, + int TotalPlans, + int TotalSizeMb, + int SingleUsePlans, + int SingleUseSizeMb, + int MultiUsePlans, + int MultiUseSizeMb, + decimal AvgUseCount, + int AvgSizeKb, + DateTime? OldestPlanCreateTime); + + /* Ported verbatim from install/35_collect_plan_cache_stats.sql's SELECT. The only addition over + the proc body is CONVERT(integer, COUNT_BIG(*)) for total_plans (the proc relied on the INSERT + into an integer column to convert implicitly) so the positional reader gets a clean int. All + other expressions already emit their storage type. Values stored are identical to the + Dashboard table. */ + private const string QueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + cacheobjtype = cp.cacheobjtype, + objtype = cp.objtype, + total_plans = CONVERT(integer, COUNT_BIG(*)), + total_size_mb = CONVERT(integer, SUM(CONVERT(bigint, cp.size_in_bytes)) / 1024 / 1024), + single_use_plans = + SUM + ( + CASE + WHEN cp.usecounts = 1 + THEN 1 + ELSE 0 + END + ), + single_use_size_mb = + CONVERT + ( + integer, + SUM + ( + CASE + WHEN cp.usecounts = 1 + THEN CONVERT(bigint, cp.size_in_bytes) + ELSE 0 + END + ) / 1024 / 1024 + ), + multi_use_plans = + SUM + ( + CASE + WHEN cp.usecounts > 1 + THEN 1 + ELSE 0 + END + ), + multi_use_size_mb = + CONVERT + ( + integer, + SUM + ( + CASE + WHEN cp.usecounts > 1 + THEN CONVERT(bigint, cp.size_in_bytes) + ELSE 0 + END + ) / 1024 / 1024 + ), + avg_use_count = CONVERT(decimal(38,2), AVG(CONVERT(bigint, cp.usecounts))), + avg_size_kb = + CASE + WHEN AVG(CONVERT(bigint, cp.size_in_bytes)) / 1024 > 2147483647 + THEN 2147483647 + ELSE CONVERT(integer, AVG(CONVERT(bigint, cp.size_in_bytes)) / 1024) + END, + oldest_plan_create_time = + ( + SELECT + MIN(deqs.creation_time) + FROM sys.dm_exec_query_stats AS deqs + ) +FROM sys.dm_exec_cached_plans AS cp +GROUP BY + cp.cacheobjtype, + cp.objtype +OPTION(RECOMPILE);"; + + public override string Name => "plan_cache_stats"; + + public override string TargetTable => "plan_cache_stats"; + + public override string? WatermarkColumn => null; + + public override bool AppliesTo(CollectorTargetInfo target) => true; + + public override bool RunsPerDatabase(CollectorTargetInfo target) => false; + + public override CollectorQuery BuildQuery(CollectorContext context) => new(QueryText); + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("cacheobjtype", CollectorColumnType.Varchar), + new CollectorColumn("objtype", CollectorColumnType.Varchar), + new CollectorColumn("total_plans", CollectorColumnType.Integer), + new CollectorColumn("total_size_mb", CollectorColumnType.Integer), + new CollectorColumn("single_use_plans", CollectorColumnType.Integer), + new CollectorColumn("single_use_size_mb", CollectorColumnType.Integer), + new CollectorColumn("multi_use_plans", CollectorColumnType.Integer), + new CollectorColumn("multi_use_size_mb", CollectorColumnType.Integer), + new CollectorColumn("avg_use_count", CollectorColumnType.Decimal, 38, 2), + new CollectorColumn("avg_size_kb", CollectorColumnType.Integer), + new CollectorColumn("oldest_plan_create_time", CollectorColumnType.Timestamp), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var rows = new List<Row>(); + + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new Row( + Cacheobjtype: reader.GetString(0), + Objtype: reader.GetString(1), + TotalPlans: reader.GetInt32(2), + TotalSizeMb: reader.GetInt32(3), + SingleUsePlans: reader.GetInt32(4), + SingleUseSizeMb: reader.GetInt32(5), + MultiUsePlans: reader.GetInt32(6), + MultiUseSizeMb: reader.GetInt32(7), + AvgUseCount: reader.GetDecimal(8), + AvgSizeKb: reader.GetInt32(9), + /* Empty query-stats DMV -> MIN(creation_time) is NULL; matches the nullable column. */ + OldestPlanCreateTime: reader.IsDBNull(10) ? null : reader.GetDateTime(10))); + } + + return rows; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + writer + .Value(row.Cacheobjtype) /* cacheobjtype VARCHAR */ + .Value(row.Objtype) /* objtype VARCHAR */ + .Value(row.TotalPlans) /* total_plans INTEGER */ + .Value(row.TotalSizeMb) /* total_size_mb INTEGER */ + .Value(row.SingleUsePlans) /* single_use_plans INTEGER */ + .Value(row.SingleUseSizeMb) /* single_use_size_mb INTEGER */ + .Value(row.MultiUsePlans) /* multi_use_plans INTEGER */ + .Value(row.MultiUseSizeMb) /* multi_use_size_mb INTEGER */ + .Value(row.AvgUseCount) /* avg_use_count DECIMAL(38,2) */ + .Value(row.AvgSizeKb) /* avg_size_kb INTEGER */ + .Value(row.OldestPlanCreateTime); /* oldest_plan_create_time TIMESTAMP (nullable) */ + } +} diff --git a/PerformanceMonitor.Collectors/ProcedureStatsCollector.cs b/PerformanceMonitor.Collectors/ProcedureStatsCollector.cs new file mode 100644 index 000000000..142de78b8 --- /dev/null +++ b/PerformanceMonitor.Collectors/ProcedureStatsCollector.cs @@ -0,0 +1,441 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Procedure/trigger/function statistics from sys.dm_exec_procedure_stats and friends. +/// Extracted verbatim from Lite's RemoteCollectorService.ProcedureStats.cs: the standard path is +/// dynamic SQL (spills columns don't exist in dm_exec_function_stats, so @spills_cols splices per +/// branch, and the exclusion filter interpolates as double-escaped literals because @sql is itself +/// a quoted string); Azure SQL DB skips plan_attributes (it reports dbid=1 for all plans) and has +/// no trigger/function branches. Seven delta groups keyed on plan_handle (falling back to +/// db.schema.object) to prevent cross-plan contamination. +/// </summary> +public sealed class ProcedureStatsCollector : CollectorDefinitionBase<ProcedureStatsCollector.Row> +{ + public static ProcedureStatsCollector Instance { get; } = new(); + + private ProcedureStatsCollector() + { + } + + public readonly record struct Row( + string DatabaseName, + string SchemaName, + string ObjectName, + string ObjectType, + DateTime? CachedTime, + DateTime? LastExecutionTime, + long ExecutionCount, + long TotalWorkerTime, + long TotalElapsedTime, + long TotalLogicalReads, + long TotalPhysicalReads, + long TotalLogicalWrites, + long MinWorkerTime, + long MaxWorkerTime, + long MinElapsedTime, + long MaxElapsedTime, + long MinLogicalReads, + long MaxLogicalReads, + long MinPhysicalReads, + long MaxPhysicalReads, + long MinLogicalWrites, + long MaxLogicalWrites, + long TotalSpills, + long MinSpills, + long MaxSpills, + string? SqlHandle, + string? PlanHandle, + string? QueryPlanXml); + + private const string StandardQueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +DECLARE + @spills_cols nvarchar(400) = N'total_spills = ISNULL(s.total_spills, 0), min_spills = ISNULL(s.min_spills, 0), max_spills = ISNULL(s.max_spills, 0),', + @fn_spills_cols nvarchar(400) = N'total_spills = CONVERT(bigint, 0), min_spills = CONVERT(bigint, 0), max_spills = CONVERT(bigint, 0),', + @sql nvarchar(max); + +SET @sql = CAST(N' +SELECT /* PerformanceMonitorLite */ TOP (150) * FROM ( +SELECT + database_name = d.name, + schema_name = OBJECT_SCHEMA_NAME(s.object_id, s.database_id), + object_name = OBJECT_NAME(s.object_id, s.database_id), + object_type = N''PROCEDURE'', + cached_time = s.cached_time, + last_execution_time = s.last_execution_time, + execution_count = s.execution_count, + total_worker_time = s.total_worker_time, + total_elapsed_time = s.total_elapsed_time, + total_logical_reads = s.total_logical_reads, + total_physical_reads = s.total_physical_reads, + total_logical_writes = s.total_logical_writes, + min_worker_time = s.min_worker_time, + max_worker_time = s.max_worker_time, + min_elapsed_time = s.min_elapsed_time, + max_elapsed_time = s.max_elapsed_time, + min_logical_reads = s.min_logical_reads, + max_logical_reads = s.max_logical_reads, + min_physical_reads = s.min_physical_reads, + max_physical_reads = s.max_physical_reads, + min_logical_writes = s.min_logical_writes, + max_logical_writes = s.max_logical_writes, + ' AS nvarchar(max)) + @spills_cols + N' + sql_handle = CONVERT(varchar(130), s.sql_handle, 1), + plan_handle = CONVERT(varchar(130), s.plan_handle, 1)/*PLAN_SELECT*/ +FROM sys.dm_exec_procedure_stats AS s/*PLAN_APPLY*/ +CROSS APPLY +( + SELECT + dbid = CONVERT(integer, pa.value) + FROM sys.dm_exec_plan_attributes(s.plan_handle) AS pa + WHERE pa.attribute = N''dbid'' +) AS pa +INNER JOIN sys.databases AS d + ON pa.dbid = d.database_id +WHERE d.state = 0 +AND pa.dbid NOT IN (1, 3, 4, 32761, 32767, ISNULL(DB_ID(N''PerformanceMonitor''), 0)) +AND s.last_execution_time >= DATEADD(MINUTE, -10, GETDATE()) +/*EXCLUSION_FILTER*/ + +UNION ALL + +SELECT + database_name = d.name, + schema_name = ISNULL(OBJECT_SCHEMA_NAME(s.object_id, s.database_id), N''dbo''), + object_name = COALESCE( + OBJECT_NAME(s.object_id, s.database_id), + CASE + WHEN CHARINDEX(N''CREATE TRIGGER'', st.text) > 0 + THEN LTRIM(RTRIM(REPLACE(REPLACE( + SUBSTRING( + st.text, + CHARINDEX(N''CREATE TRIGGER'', st.text) + 15, + CHARINDEX(N'' ON '', st.text + N'' ON '', CHARINDEX(N''CREATE TRIGGER'', st.text) + 15) - CHARINDEX(N''CREATE TRIGGER'', st.text) - 15 + ), N''['', N''''), N'']'', N''''))) + ELSE N''trigger_'' + CONVERT(nvarchar(20), s.object_id) + END + ), + object_type = N''TRIGGER'', + cached_time = s.cached_time, + last_execution_time = s.last_execution_time, + execution_count = s.execution_count, + total_worker_time = s.total_worker_time, + total_elapsed_time = s.total_elapsed_time, + total_logical_reads = s.total_logical_reads, + total_physical_reads = s.total_physical_reads, + total_logical_writes = s.total_logical_writes, + min_worker_time = s.min_worker_time, + max_worker_time = s.max_worker_time, + min_elapsed_time = s.min_elapsed_time, + max_elapsed_time = s.max_elapsed_time, + min_logical_reads = s.min_logical_reads, + max_logical_reads = s.max_logical_reads, + min_physical_reads = s.min_physical_reads, + max_physical_reads = s.max_physical_reads, + min_logical_writes = s.min_logical_writes, + max_logical_writes = s.max_logical_writes, + ' + @spills_cols + CAST(N' + sql_handle = CONVERT(varchar(130), s.sql_handle, 1), + plan_handle = CONVERT(varchar(130), s.plan_handle, 1)/*PLAN_SELECT*/ +FROM sys.dm_exec_trigger_stats AS s/*PLAN_APPLY*/ +CROSS APPLY sys.dm_exec_sql_text(s.sql_handle) AS st +CROSS APPLY +( + SELECT + dbid = CONVERT(integer, pa.value) + FROM sys.dm_exec_plan_attributes(s.plan_handle) AS pa + WHERE pa.attribute = N''dbid'' +) AS pa +INNER JOIN sys.databases AS d + ON pa.dbid = d.database_id +WHERE d.state = 0 +AND pa.dbid NOT IN (1, 3, 4, 32761, 32767, ISNULL(DB_ID(N''PerformanceMonitor''), 0)) +AND s.last_execution_time >= DATEADD(MINUTE, -10, GETDATE()) +/*EXCLUSION_FILTER*/ + +UNION ALL + +SELECT + database_name = d.name, + schema_name = OBJECT_SCHEMA_NAME(s.object_id, s.database_id), + object_name = OBJECT_NAME(s.object_id, s.database_id), + object_type = N''FUNCTION'', + cached_time = s.cached_time, + last_execution_time = s.last_execution_time, + execution_count = s.execution_count, + total_worker_time = s.total_worker_time, + total_elapsed_time = s.total_elapsed_time, + total_logical_reads = s.total_logical_reads, + total_physical_reads = s.total_physical_reads, + total_logical_writes = s.total_logical_writes, + min_worker_time = s.min_worker_time, + max_worker_time = s.max_worker_time, + min_elapsed_time = s.min_elapsed_time, + max_elapsed_time = s.max_elapsed_time, + min_logical_reads = s.min_logical_reads, + max_logical_reads = s.max_logical_reads, + min_physical_reads = s.min_physical_reads, + max_physical_reads = s.max_physical_reads, + min_logical_writes = s.min_logical_writes, + max_logical_writes = s.max_logical_writes, + ' AS nvarchar(max)) + @fn_spills_cols + CAST(N' + sql_handle = CONVERT(varchar(130), s.sql_handle, 1), + plan_handle = CONVERT(varchar(130), s.plan_handle, 1)/*PLAN_SELECT*/ +FROM sys.dm_exec_function_stats AS s/*PLAN_APPLY*/ +CROSS APPLY +( + SELECT + dbid = CONVERT(integer, pa.value) + FROM sys.dm_exec_plan_attributes(s.plan_handle) AS pa + WHERE pa.attribute = N''dbid'' +) AS pa +INNER JOIN sys.databases AS d + ON pa.dbid = d.database_id +WHERE d.state = 0 +AND pa.dbid NOT IN (1, 3, 4, 32761, 32767, ISNULL(DB_ID(N''PerformanceMonitor''), 0)) +AND s.last_execution_time >= DATEADD(MINUTE, -10, GETDATE()) +/*EXCLUSION_FILTER*/ +) AS combined +ORDER BY total_elapsed_time DESC +OPTION(RECOMPILE);' AS nvarchar(max)); + +EXECUTE sys.sp_executesql @sql;"; + + private const string AzureSqlDbQueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT /* PerformanceMonitorLite */ TOP (150) + database_name = DB_NAME(), + schema_name = OBJECT_SCHEMA_NAME(s.object_id, s.database_id), + object_name = OBJECT_NAME(s.object_id, s.database_id), + object_type = N'PROCEDURE', + cached_time = s.cached_time, + last_execution_time = s.last_execution_time, + execution_count = s.execution_count, + total_worker_time = s.total_worker_time, + total_elapsed_time = s.total_elapsed_time, + total_logical_reads = s.total_logical_reads, + total_physical_reads = s.total_physical_reads, + total_logical_writes = s.total_logical_writes, + min_worker_time = s.min_worker_time, + max_worker_time = s.max_worker_time, + min_elapsed_time = s.min_elapsed_time, + max_elapsed_time = s.max_elapsed_time, + min_logical_reads = s.min_logical_reads, + max_logical_reads = s.max_logical_reads, + min_physical_reads = s.min_physical_reads, + max_physical_reads = s.max_physical_reads, + min_logical_writes = s.min_logical_writes, + max_logical_writes = s.max_logical_writes, + total_spills = ISNULL(s.total_spills, 0), + min_spills = ISNULL(s.min_spills, 0), + max_spills = ISNULL(s.max_spills, 0), + sql_handle = CONVERT(varchar(130), s.sql_handle, 1), + plan_handle = CONVERT(varchar(130), s.plan_handle, 1)/*PLAN_SELECT*/ +FROM sys.dm_exec_procedure_stats AS s/*PLAN_APPLY*/ +WHERE s.database_id = DB_ID() +AND s.last_execution_time >= DATEADD(MINUTE, -10, GETDATE()) +/*EXCLUSION_FILTER*/ +ORDER BY s.total_elapsed_time DESC +OPTION(RECOMPILE);"; + + /* Execution-plan capture — spliced into every branch (procedure/trigger/function) and the Azure + variant only when the host sets CapturePlanXml (Darling); when off the placeholders erase to + nothing, so Lite's SQL is byte-identical to the no-plan form. Mirrors the full Dashboard's + @collect_plan path in install/10_collect_procedure_stats.sql. Unlike query_stats, the three + module-level DMVs (sys.dm_exec_procedure_stats / _trigger_stats / _function_stats) expose NO + statement_start_offset/statement_end_offset — they aggregate at the whole-object grain — so we + pass literal 0, -1 (documented "beginning-of-batch through end-of-batch") to get the entire + module's plan. Still the TEXT DMV (not sys.dm_exec_query_plan) so large/deep plans that overflow + the xml type still return. dm_exec_text_query_plan(plan_handle, 0, -1) returns a single row, so + the OUTER APPLY never multiplies rows and never drops one (NULL plan for an aged-out handle). */ + private const string PlanSelectFragment = @", + query_plan_xml = tqp.query_plan"; + + private const string PlanApplyFragment = @" +OUTER APPLY sys.dm_exec_text_query_plan(s.plan_handle, 0, -1) AS tqp"; + + public override string Name => "procedure_stats"; + + public override string TargetTable => "procedure_stats"; + + public override CollectorQuery BuildQuery(CollectorContext context) + { + var planSelect = context.CapturePlanXml ? PlanSelectFragment : ""; + var planApply = context.CapturePlanXml ? PlanApplyFragment : ""; + + if (context.Target.IsAzureSqlDb) + { + /* Azure: single-database scope; the exclusion token is left in place unreplaced in the + original (no clause is spliced on Azure) — reproduce exactly. Plan placeholders still + erase (flag off) or splice (Darling) here just like the standard variant. */ + return new CollectorQuery( + AzureSqlDbQueryText + .Replace("/*PLAN_SELECT*/", planSelect, StringComparison.Ordinal) + .Replace("/*PLAN_APPLY*/", planApply, StringComparison.Ordinal)); + } + + /* Standard query is dynamic SQL (built into @sql then passed to sp_executesql), so the + exclusion filter is interpolated as literal N'...' values rather than parameter bindings — + doubled escaping because @sql is itself a single-quoted T-SQL string. The plan fragments + carry no single quotes, so they splice straight into the nested dynamic SQL body. */ + var exclusionClause = DatabaseExclusionFilter.BuildLiteralClause( + context.ExcludedDatabases, "d.name", forNestedDynamicSql: true); + + return new CollectorQuery( + StandardQueryText + .Replace("/*EXCLUSION_FILTER*/", exclusionClause, StringComparison.Ordinal) + .Replace("/*PLAN_SELECT*/", planSelect, StringComparison.Ordinal) + .Replace("/*PLAN_APPLY*/", planApply, StringComparison.Ordinal)); + } + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("database_name", CollectorColumnType.Varchar), + new CollectorColumn("schema_name", CollectorColumnType.Varchar), + new CollectorColumn("object_name", CollectorColumnType.Varchar), + new CollectorColumn("object_type", CollectorColumnType.Varchar), + new CollectorColumn("cached_time", CollectorColumnType.Timestamp), + new CollectorColumn("last_execution_time", CollectorColumnType.Timestamp), + new CollectorColumn("execution_count", CollectorColumnType.BigInt), + new CollectorColumn("total_worker_time", CollectorColumnType.BigInt), + new CollectorColumn("total_elapsed_time", CollectorColumnType.BigInt), + new CollectorColumn("total_logical_reads", CollectorColumnType.BigInt), + new CollectorColumn("total_physical_reads", CollectorColumnType.BigInt), + new CollectorColumn("total_logical_writes", CollectorColumnType.BigInt), + new CollectorColumn("min_worker_time", CollectorColumnType.BigInt), + new CollectorColumn("max_worker_time", CollectorColumnType.BigInt), + new CollectorColumn("min_elapsed_time", CollectorColumnType.BigInt), + new CollectorColumn("max_elapsed_time", CollectorColumnType.BigInt), + new CollectorColumn("min_logical_reads", CollectorColumnType.BigInt), + new CollectorColumn("max_logical_reads", CollectorColumnType.BigInt), + new CollectorColumn("min_physical_reads", CollectorColumnType.BigInt), + new CollectorColumn("max_physical_reads", CollectorColumnType.BigInt), + new CollectorColumn("min_logical_writes", CollectorColumnType.BigInt), + new CollectorColumn("max_logical_writes", CollectorColumnType.BigInt), + new CollectorColumn("total_spills", CollectorColumnType.BigInt), + new CollectorColumn("min_spills", CollectorColumnType.BigInt), + new CollectorColumn("max_spills", CollectorColumnType.BigInt), + new CollectorColumn("sql_handle", CollectorColumnType.Varchar), + new CollectorColumn("plan_handle", CollectorColumnType.Varchar), + new CollectorColumn("delta_execution_count", CollectorColumnType.BigInt), + new CollectorColumn("delta_worker_time", CollectorColumnType.BigInt), + new CollectorColumn("delta_elapsed_time", CollectorColumnType.BigInt), + new CollectorColumn("delta_logical_reads", CollectorColumnType.BigInt), + new CollectorColumn("delta_logical_writes", CollectorColumnType.BigInt), + new CollectorColumn("delta_physical_reads", CollectorColumnType.BigInt), + new CollectorColumn("delta_spills", CollectorColumnType.BigInt), + /* Trailing plan column (appended, not grouped with the raw handles) so a store already at the + pre-plan shape gets it via one ADD COLUMN — fresh (V1) and upgraded (V6) Darling stores keep + an identical physical order. NULL on Lite (flag off) and for any procedure whose plan aged out. */ + new CollectorColumn("query_plan_xml", CollectorColumnType.Varchar), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var rows = new List<Row>(); + + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new Row( + reader.IsDBNull(0) ? "" : reader.GetString(0), + reader.IsDBNull(1) ? "" : reader.GetString(1), + reader.IsDBNull(2) ? "" : reader.GetString(2), + reader.IsDBNull(3) ? "" : reader.GetString(3), + reader.IsDBNull(4) ? null : reader.GetDateTime(4), + reader.IsDBNull(5) ? null : reader.GetDateTime(5), + reader.GetInt64(6), + reader.GetInt64(7), + reader.GetInt64(8), + reader.GetInt64(9), + reader.GetInt64(10), + reader.GetInt64(11), + reader.IsDBNull(12) ? 0L : reader.GetInt64(12), + reader.IsDBNull(13) ? 0L : reader.GetInt64(13), + reader.IsDBNull(14) ? 0L : reader.GetInt64(14), + reader.IsDBNull(15) ? 0L : reader.GetInt64(15), + reader.IsDBNull(16) ? 0L : reader.GetInt64(16), + reader.IsDBNull(17) ? 0L : reader.GetInt64(17), + reader.IsDBNull(18) ? 0L : reader.GetInt64(18), + reader.IsDBNull(19) ? 0L : reader.GetInt64(19), + reader.IsDBNull(20) ? 0L : reader.GetInt64(20), + reader.IsDBNull(21) ? 0L : reader.GetInt64(21), + reader.GetInt64(22), + reader.GetInt64(23), + reader.GetInt64(24), + reader.IsDBNull(25) ? null : reader.GetString(25), + reader.IsDBNull(26) ? null : reader.GetString(26), + /* query_plan_xml is the trailing column present only when CapturePlanXml spliced it + into every branch's SELECT (ordinal 27); the short-circuit skips it entirely when off. */ + context.CapturePlanXml && !reader.IsDBNull(27) ? reader.GetString(27) : null)); + } + + return rows; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + /* Delta key: plan_handle to prevent cross-contamination when multiple plans exist for the + same object; the db.schema.object fallback and the seven group names are the parity contract. */ + var deltaKey = row.PlanHandle ?? $"{row.DatabaseName}.{row.SchemaName}.{row.ObjectName}"; + var deltaExec = context.Deltas.CalculateDelta(context.ServerId, "proc_stats_exec", deltaKey, row.ExecutionCount, collectionTime: context.CollectionTime, maxGapSeconds: 300); + var deltaWorker = context.Deltas.CalculateDelta(context.ServerId, "proc_stats_worker", deltaKey, row.TotalWorkerTime, collectionTime: context.CollectionTime, maxGapSeconds: 300); + var deltaElapsed = context.Deltas.CalculateDelta(context.ServerId, "proc_stats_elapsed", deltaKey, row.TotalElapsedTime, collectionTime: context.CollectionTime, maxGapSeconds: 300); + var deltaReads = context.Deltas.CalculateDelta(context.ServerId, "proc_stats_reads", deltaKey, row.TotalLogicalReads, collectionTime: context.CollectionTime, maxGapSeconds: 300); + var deltaWrites = context.Deltas.CalculateDelta(context.ServerId, "proc_stats_writes", deltaKey, row.TotalLogicalWrites, collectionTime: context.CollectionTime, maxGapSeconds: 300); + var deltaPhysReads = context.Deltas.CalculateDelta(context.ServerId, "proc_stats_phys_reads", deltaKey, row.TotalPhysicalReads, collectionTime: context.CollectionTime, maxGapSeconds: 300); + var deltaSpills = context.Deltas.CalculateDelta(context.ServerId, "proc_stats_spills", deltaKey, row.TotalSpills, collectionTime: context.CollectionTime, maxGapSeconds: 300); + + writer + .Value(row.DatabaseName) + .Value(row.SchemaName) + .Value(row.ObjectName) + .Value(row.ObjectType) + .Value(row.CachedTime) + .Value(row.LastExecutionTime) + .Value(row.ExecutionCount) + .Value(row.TotalWorkerTime) + .Value(row.TotalElapsedTime) + .Value(row.TotalLogicalReads) + .Value(row.TotalPhysicalReads) + .Value(row.TotalLogicalWrites) + .Value(row.MinWorkerTime) + .Value(row.MaxWorkerTime) + .Value(row.MinElapsedTime) + .Value(row.MaxElapsedTime) + .Value(row.MinLogicalReads) + .Value(row.MaxLogicalReads) + .Value(row.MinPhysicalReads) + .Value(row.MaxPhysicalReads) + .Value(row.MinLogicalWrites) + .Value(row.MaxLogicalWrites) + .Value(row.TotalSpills) + .Value(row.MinSpills) + .Value(row.MaxSpills) + .Value(row.SqlHandle) + .Value(row.PlanHandle) + .Value(deltaExec) + .Value(deltaWorker) + .Value(deltaElapsed) + .Value(deltaReads) + .Value(deltaWrites) + .Value(deltaPhysReads) + .Value(deltaSpills) + .Value(row.QueryPlanXml); /* null unless CapturePlanXml captured it (Darling) */ + } +} diff --git a/PerformanceMonitor.Collectors/QuerySnapshotsCollector.cs b/PerformanceMonitor.Collectors/QuerySnapshotsCollector.cs new file mode 100644 index 000000000..e4ccf0560 --- /dev/null +++ b/PerformanceMonitor.Collectors/QuerySnapshotsCollector.cs @@ -0,0 +1,558 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Globalization; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Point-in-time snapshot of currently running queries. Extracted verbatim from Lite's +/// RemoteCollectorService.QuerySnapshots.cs, including: the CDC-capture detection (job-id parse +/// from the Agent program_name with the text-match fallback when msdb.dbo.cdc_jobs is +/// unreadable), the live-query-plan gate (2016 SP1+/MI; disabled on Azure SQL DB where DB-scoped +/// logins trip error 300 — #857), the Azure #req-staging variant that keeps the sql_text/plan +/// DMFs away from master-scoped rows (#857), the #1286 memory-grant/tempdb/transaction triage +/// columns, and the exclusion clause injected ahead of the ORDER BY. +/// <see cref="BuildSnapshotQuery"/> is public because Lite's live-snapshot button builds the +/// same SQL — the templates are the single source for both paths. +/// </summary> +public sealed class QuerySnapshotsCollector : CollectorDefinitionBase<QuerySnapshotsCollector.Row> +{ + public static QuerySnapshotsCollector Instance { get; } = new(); + + private QuerySnapshotsCollector() + { + } + + public sealed class Row + { + public int SessionId { get; set; } + public string? DatabaseName { get; set; } + public string? ElapsedTimeFormatted { get; set; } + public string? QueryText { get; set; } + public string? QueryPlan { get; set; } + public string? LiveQueryPlan { get; set; } + public string? Status { get; set; } + public int BlockingSessionId { get; set; } + public string? WaitType { get; set; } + public long WaitTimeMs { get; set; } + public string? WaitResource { get; set; } + public long CpuTimeMs { get; set; } + public long TotalElapsedTimeMs { get; set; } + public long Reads { get; set; } + public long Writes { get; set; } + public long LogicalReads { get; set; } + public decimal GrantedQueryMemoryGb { get; set; } + public string? TransactionIsolationLevel { get; set; } + public int Dop { get; set; } + public int ParallelWorkerCount { get; set; } + public string? LoginName { get; set; } + public string? HostName { get; set; } + public string? ProgramName { get; set; } + public int OpenTransactionCount { get; set; } + public decimal PercentComplete { get; set; } + public bool IsCdcCapture { get; set; } + public string? QueryHash { get; set; } + public double? RequestedMemoryMb { get; set; } + public double? UsedMemoryMb { get; set; } + public double? MaxUsedMemoryMb { get; set; } + public double? TempdbCurrentMb { get; set; } + public double? TempdbAllocationsMb { get; set; } + public double? TranLogUsedMb { get; set; } + public DateTime? TranStartTime { get; set; } + public int RequestId { get; set; } + } + + private const string SnapshotsBase = """ + +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; +SET LOCK_TIMEOUT 1000; + +DECLARE @cdc_capture_jobs TABLE (job_id uniqueidentifier PRIMARY KEY); +DECLARE @cdc_readable bit = 0; +BEGIN TRY + INSERT @cdc_capture_jobs (job_id) + EXEC sys.sp_executesql N'SELECT cj.job_id FROM msdb.dbo.cdc_jobs AS cj WHERE cj.job_type = N''capture'';'; + SET @cdc_readable = 1; +END TRY +BEGIN CATCH + SET @cdc_readable = 0; +END CATCH; + +SELECT /* PerformanceMonitorLite */ + der.session_id, + database_name = d.name, + elapsed_time_formatted = + CASE + WHEN der.total_elapsed_time < 0 + THEN '00 00:00:00.000' + ELSE RIGHT(REPLICATE('0', 2) + CONVERT(varchar(10), der.total_elapsed_time / 86400000), 2) + + ' ' + RIGHT(CONVERT(varchar(30), DATEADD(second, der.total_elapsed_time / 1000, 0), 120), 9) + + '.' + RIGHT('000' + CONVERT(varchar(3), der.total_elapsed_time % 1000), 3) + END, + query_text = SUBSTRING(dest.text, (der.statement_start_offset / 2) + 1, + ((CASE der.statement_end_offset WHEN -1 THEN DATALENGTH(dest.text) + ELSE der.statement_end_offset END - der.statement_start_offset) / 2) + 1), + query_plan = TRY_CAST(deqp.query_plan AS nvarchar(max)), + {0} + der.status, + der.blocking_session_id, + der.wait_type, + wait_time_ms = CONVERT(bigint, der.wait_time), + der.wait_resource, + cpu_time_ms = CONVERT(bigint, der.cpu_time), + total_elapsed_time_ms = CONVERT(bigint, der.total_elapsed_time), + der.reads, + der.writes, + der.logical_reads, + granted_query_memory_gb = CONVERT(decimal(38, 2), (der.granted_query_memory / 128. / 1024.)), + transaction_isolation_level = + CASE der.transaction_isolation_level + WHEN 0 THEN 'Unspecified' + WHEN 1 THEN 'Read Uncommitted' + WHEN 2 THEN 'Read Committed' + WHEN 3 THEN 'Repeatable Read' + WHEN 4 THEN 'Serializable' + WHEN 5 THEN 'Snapshot' + ELSE '???' + END, + der.dop, + der.parallel_worker_count, + des.login_name, + des.host_name, + des.program_name, + des.open_transaction_count, + der.percent_complete, + is_cdc_capture = + CONVERT(bit, + CASE + WHEN @cdc_readable = 1 + AND des.program_name LIKE N'SQLAgent - TSQL JobStep (Job 0x%' + AND TRY_CONVERT(uniqueidentifier, TRY_CONVERT(binary(16), SUBSTRING(des.program_name, 32, 32), 2)) + IN (SELECT j.job_id FROM @cdc_capture_jobs AS j) + THEN 1 + WHEN @cdc_readable = 0 + AND dest.text IS NOT NULL + AND (dest.text LIKE N'%sp_MScdc_capture_job%' OR dest.text LIKE N'%sp_cdc_scan%') + THEN 1 + ELSE 0 + END), + query_hash = CONVERT(varchar(18), der.query_hash, 1), /* #1140 long-running-query dedup key */ + requested_memory_mb = CONVERT(decimal(38, 2), deqmg.requested_memory_kb / 1024.), + used_memory_mb = CONVERT(decimal(38, 2), deqmg.used_memory_kb / 1024.), + max_used_memory_mb = CONVERT(decimal(38, 2), deqmg.max_used_memory_kb / 1024.), + tempdb_current_mb = tsu.tempdb_current_mb, + tempdb_allocations_mb = tsu.tempdb_allocations_mb, + tran_log_used_mb = trn.tran_log_used_mb, + tran_start_time = trn.tran_start_time, + der.request_id +FROM sys.dm_exec_requests AS der +JOIN sys.dm_exec_sessions AS des + ON des.session_id = der.session_id +OUTER APPLY sys.dm_exec_sql_text(COALESCE(der.sql_handle, der.plan_handle)) AS dest +OUTER APPLY sys.dm_exec_text_query_plan(der.plan_handle, der.statement_start_offset, der.statement_end_offset) AS deqp +LEFT JOIN sys.databases AS d + ON d.database_id = der.database_id +LEFT JOIN sys.dm_exec_query_memory_grants AS deqmg + ON deqmg.session_id = der.session_id + AND deqmg.request_id = der.request_id +OUTER APPLY +( + SELECT + tempdb_current_mb = + CONVERT(decimal(38, 2), + CASE + WHEN SUM(x.alloc) - SUM(x.dealloc) < 0 + THEN 0. + ELSE (SUM(x.alloc) - SUM(x.dealloc)) * 8. / 1024. + END), + tempdb_allocations_mb = CONVERT(decimal(38, 2), SUM(x.alloc) * 8. / 1024.) + FROM + ( + SELECT + alloc = dssu.user_objects_alloc_page_count + dssu.internal_objects_alloc_page_count, + dealloc = dssu.user_objects_dealloc_page_count + dssu.internal_objects_dealloc_page_count + FROM sys.dm_db_session_space_usage AS dssu + WHERE dssu.session_id = der.session_id + + UNION ALL + + SELECT + dtsu.user_objects_alloc_page_count + dtsu.internal_objects_alloc_page_count, + dtsu.user_objects_dealloc_page_count + dtsu.internal_objects_dealloc_page_count + FROM sys.dm_db_task_space_usage AS dtsu + WHERE dtsu.session_id = der.session_id + AND dtsu.request_id = der.request_id + ) AS x +) AS tsu +OUTER APPLY +( + SELECT + tran_log_used_mb = CONVERT(decimal(38, 2), SUM(ddt.database_transaction_log_bytes_used) / 1048576.), + tran_start_time = MIN(dat.transaction_begin_time) + FROM sys.dm_tran_session_transactions AS dst + JOIN sys.dm_tran_active_transactions AS dat + ON dat.transaction_id = dst.transaction_id + LEFT JOIN sys.dm_tran_database_transactions AS ddt + ON ddt.transaction_id = dst.transaction_id + WHERE dst.session_id = der.session_id +) AS trn +{1} +WHERE der.session_id <> @@SPID +AND der.session_id >= 50 +AND dest.text IS NOT NULL +AND der.database_id <> ISNULL(DB_ID(N'PerformanceMonitor'), 0) +ORDER BY der.cpu_time DESC, der.parallel_worker_count DESC +OPTION(MAXDOP 1, RECOMPILE); +"""; + + private static readonly CompositeFormat s_snapshotsBaseFormat = CompositeFormat.Parse(SnapshotsBase); + + /* + * On Azure SQL Database with a contained / DB-scoped login (e.g. D365FO), + * the OUTER APPLY to sys.dm_exec_sql_text / sys.dm_exec_text_query_plan + * will be evaluated against master-scoped rows from sys.dm_exec_requests + * before the WHERE predicate applies, tripping error 300 (VIEW SERVER + * PERFORMANCE STATE denied). Materialising the filtered request rows + * into a #temp table first guarantees the DMFs only see handles from + * the current database. See #857. + */ + private const string SnapshotsAzureBase = """ + +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; +SET LOCK_TIMEOUT 1000; + +IF OBJECT_ID(N'tempdb..#req') IS NOT NULL DROP TABLE #req; + +SELECT + der.session_id, + der.database_id, + der.sql_handle, + der.plan_handle, + der.statement_start_offset, + der.statement_end_offset, + der.status, + der.blocking_session_id, + der.wait_type, + der.wait_time, + der.wait_resource, + der.cpu_time, + der.total_elapsed_time, + der.reads, + der.writes, + der.logical_reads, + der.granted_query_memory, + der.transaction_isolation_level, + der.dop, + der.parallel_worker_count, + der.percent_complete, + der.query_hash, + der.request_id +INTO #req +FROM sys.dm_exec_requests AS der +WHERE der.session_id <> @@SPID +AND der.session_id >= 50 +AND der.database_id = DB_ID() +AND der.database_id <> ISNULL(DB_ID(N'PerformanceMonitor'), 0); + +SELECT /* PerformanceMonitorLite */ + der.session_id, + database_name = d.name, + elapsed_time_formatted = + CASE + WHEN der.total_elapsed_time < 0 + THEN '00 00:00:00.000' + ELSE RIGHT(REPLICATE('0', 2) + CONVERT(varchar(10), der.total_elapsed_time / 86400000), 2) + + ' ' + RIGHT(CONVERT(varchar(30), DATEADD(second, der.total_elapsed_time / 1000, 0), 120), 9) + + '.' + RIGHT('000' + CONVERT(varchar(3), der.total_elapsed_time % 1000), 3) + END, + query_text = SUBSTRING(dest.text, (der.statement_start_offset / 2) + 1, + ((CASE der.statement_end_offset WHEN -1 THEN DATALENGTH(dest.text) + ELSE der.statement_end_offset END - der.statement_start_offset) / 2) + 1), + query_plan = TRY_CAST(deqp.query_plan AS nvarchar(max)), + {0} + der.status, + der.blocking_session_id, + der.wait_type, + wait_time_ms = CONVERT(bigint, der.wait_time), + der.wait_resource, + cpu_time_ms = CONVERT(bigint, der.cpu_time), + total_elapsed_time_ms = CONVERT(bigint, der.total_elapsed_time), + der.reads, + der.writes, + der.logical_reads, + granted_query_memory_gb = CONVERT(decimal(38, 2), (der.granted_query_memory / 128. / 1024.)), + transaction_isolation_level = + CASE der.transaction_isolation_level + WHEN 0 THEN 'Unspecified' + WHEN 1 THEN 'Read Uncommitted' + WHEN 2 THEN 'Read Committed' + WHEN 3 THEN 'Repeatable Read' + WHEN 4 THEN 'Serializable' + WHEN 5 THEN 'Snapshot' + ELSE '???' + END, + der.dop, + der.parallel_worker_count, + des.login_name, + des.host_name, + des.program_name, + des.open_transaction_count, + der.percent_complete, + /* Azure SQL Database has no SQL Agent / msdb.dbo.cdc_jobs (CDC there is scheduler-based), so no capture job to exclude. */ + is_cdc_capture = CONVERT(bit, 0), + query_hash = CONVERT(varchar(18), der.query_hash, 1), /* #1140 long-running-query dedup key */ + requested_memory_mb = CONVERT(decimal(38, 2), deqmg.requested_memory_kb / 1024.), + used_memory_mb = CONVERT(decimal(38, 2), deqmg.used_memory_kb / 1024.), + max_used_memory_mb = CONVERT(decimal(38, 2), deqmg.max_used_memory_kb / 1024.), + tempdb_current_mb = tsu.tempdb_current_mb, + tempdb_allocations_mb = tsu.tempdb_allocations_mb, + tran_log_used_mb = trn.tran_log_used_mb, + tran_start_time = trn.tran_start_time, + der.request_id +FROM #req AS der +JOIN sys.dm_exec_sessions AS des + ON des.session_id = der.session_id +OUTER APPLY sys.dm_exec_sql_text(COALESCE(der.sql_handle, der.plan_handle)) AS dest +OUTER APPLY sys.dm_exec_text_query_plan(der.plan_handle, der.statement_start_offset, der.statement_end_offset) AS deqp +LEFT JOIN sys.databases AS d + ON d.database_id = der.database_id +LEFT JOIN sys.dm_exec_query_memory_grants AS deqmg + ON deqmg.session_id = der.session_id + AND deqmg.request_id = der.request_id +OUTER APPLY +( + SELECT + tempdb_current_mb = + CONVERT(decimal(38, 2), + CASE + WHEN SUM(x.alloc) - SUM(x.dealloc) < 0 + THEN 0. + ELSE (SUM(x.alloc) - SUM(x.dealloc)) * 8. / 1024. + END), + tempdb_allocations_mb = CONVERT(decimal(38, 2), SUM(x.alloc) * 8. / 1024.) + FROM + ( + SELECT + alloc = dssu.user_objects_alloc_page_count + dssu.internal_objects_alloc_page_count, + dealloc = dssu.user_objects_dealloc_page_count + dssu.internal_objects_dealloc_page_count + FROM sys.dm_db_session_space_usage AS dssu + WHERE dssu.session_id = der.session_id + + UNION ALL + + SELECT + dtsu.user_objects_alloc_page_count + dtsu.internal_objects_alloc_page_count, + dtsu.user_objects_dealloc_page_count + dtsu.internal_objects_dealloc_page_count + FROM sys.dm_db_task_space_usage AS dtsu + WHERE dtsu.session_id = der.session_id + AND dtsu.request_id = der.request_id + ) AS x +) AS tsu +OUTER APPLY +( + SELECT + tran_log_used_mb = CONVERT(decimal(38, 2), SUM(ddt.database_transaction_log_bytes_used) / 1048576.), + tran_start_time = MIN(dat.transaction_begin_time) + FROM sys.dm_tran_session_transactions AS dst + JOIN sys.dm_tran_active_transactions AS dat + ON dat.transaction_id = dst.transaction_id + LEFT JOIN sys.dm_tran_database_transactions AS ddt + ON ddt.transaction_id = dst.transaction_id + WHERE dst.session_id = der.session_id +) AS trn +{1} +WHERE dest.text IS NOT NULL +ORDER BY der.cpu_time DESC, der.parallel_worker_count DESC +OPTION(MAXDOP 1, RECOMPILE); + +DROP TABLE #req; +"""; + + private static readonly CompositeFormat s_snapshotsAzureBaseFormat = CompositeFormat.Parse(SnapshotsAzureBase); + + /// <summary> + /// Builds the query snapshots SQL with or without live query plan support — the single + /// source for both the scheduled collector and Lite's live snapshot button (#857 for the + /// Azure #req staging). + /// </summary> + public static string BuildSnapshotQuery(bool supportsLiveQueryPlan, bool isAzureSqlDatabase) + { + var liveQueryPlanColumn = supportsLiveQueryPlan + ? "live_query_plan = deqs.query_plan," + : "live_query_plan = CONVERT(xml, NULL),"; + var liveQueryPlanApply = supportsLiveQueryPlan + ? "OUTER APPLY sys.dm_exec_query_statistics_xml(der.session_id) AS deqs" + : ""; + var template = isAzureSqlDatabase ? s_snapshotsAzureBaseFormat : s_snapshotsBaseFormat; + return string.Format(null, template, liveQueryPlanColumn, liveQueryPlanApply); + } + + /// <summary>The live-plan gate: 2016 SP1+/version-unknown/MI; never on Azure SQL DB (#857).</summary> + public static bool SupportsLiveQueryPlan(CollectorTargetInfo target) + => !target.IsAzureSqlDb + && (target.SqlMajorVersion >= 13 || target.SqlMajorVersion == 0 || target.IsAzureManagedInstance); + + public override string Name => "query_snapshots"; + + public override string TargetTable => "query_snapshots"; + + public override CollectorQuery BuildQuery(CollectorContext context) + { + var query = BuildSnapshotQuery(SupportsLiveQueryPlan(context.Target), context.Target.IsAzureSqlDb); + + /* Append the per-database exclusion filter to the WHERE clause. The base query joins + sys.databases AS d, so we filter on d.name. When ExcludedDatabases is empty the + clause is "" so nothing changes. */ + var (exclusionClause, exclusionParameters) = DatabaseExclusionFilter.Build(context.ExcludedDatabases, "d.name"); + if (!string.IsNullOrEmpty(exclusionClause)) + { + /* Inject just before the OPTION clause so it lands inside the WHERE. */ + query = query.Replace( + "ORDER BY der.cpu_time DESC", + exclusionClause + "\nORDER BY der.cpu_time DESC", + StringComparison.Ordinal); + } + + return new CollectorQuery(query, exclusionParameters); + } + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("session_id", CollectorColumnType.Integer), + new CollectorColumn("database_name", CollectorColumnType.Varchar), + new CollectorColumn("elapsed_time_formatted", CollectorColumnType.Varchar), + new CollectorColumn("query_text", CollectorColumnType.Varchar), + new CollectorColumn("query_plan", CollectorColumnType.Varchar), + new CollectorColumn("live_query_plan", CollectorColumnType.Varchar), + new CollectorColumn("status", CollectorColumnType.Varchar), + new CollectorColumn("blocking_session_id", CollectorColumnType.Integer), + new CollectorColumn("wait_type", CollectorColumnType.Varchar), + new CollectorColumn("wait_time_ms", CollectorColumnType.BigInt), + new CollectorColumn("wait_resource", CollectorColumnType.Varchar), + new CollectorColumn("cpu_time_ms", CollectorColumnType.BigInt), + new CollectorColumn("total_elapsed_time_ms", CollectorColumnType.BigInt), + new CollectorColumn("reads", CollectorColumnType.BigInt), + new CollectorColumn("writes", CollectorColumnType.BigInt), + new CollectorColumn("logical_reads", CollectorColumnType.BigInt), + new CollectorColumn("granted_query_memory_gb", CollectorColumnType.Decimal, 18, 2), + new CollectorColumn("transaction_isolation_level", CollectorColumnType.Varchar), + new CollectorColumn("dop", CollectorColumnType.Integer), + new CollectorColumn("parallel_worker_count", CollectorColumnType.Integer), + new CollectorColumn("login_name", CollectorColumnType.Varchar), + new CollectorColumn("host_name", CollectorColumnType.Varchar), + new CollectorColumn("program_name", CollectorColumnType.Varchar), + new CollectorColumn("open_transaction_count", CollectorColumnType.Integer), + new CollectorColumn("percent_complete", CollectorColumnType.Decimal, 5, 2), + new CollectorColumn("is_cdc_capture", CollectorColumnType.Boolean), + new CollectorColumn("query_hash", CollectorColumnType.Varchar), + new CollectorColumn("requested_memory_mb", CollectorColumnType.Double), + new CollectorColumn("used_memory_mb", CollectorColumnType.Double), + new CollectorColumn("max_used_memory_mb", CollectorColumnType.Double), + new CollectorColumn("tempdb_current_mb", CollectorColumnType.Double), + new CollectorColumn("tempdb_allocations_mb", CollectorColumnType.Double), + new CollectorColumn("tran_log_used_mb", CollectorColumnType.Double), + new CollectorColumn("tran_start_time", CollectorColumnType.Timestamp), + new CollectorColumn("request_id", CollectorColumnType.Integer), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var rows = new List<Row>(); + + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new Row + { + SessionId = Convert.ToInt32(reader.GetValue(0), CultureInfo.InvariantCulture), + DatabaseName = reader.IsDBNull(1) ? null : reader.GetString(1), + ElapsedTimeFormatted = reader.IsDBNull(2) ? null : reader.GetString(2), + QueryText = reader.IsDBNull(3) ? null : reader.GetString(3), + QueryPlan = reader.IsDBNull(4) ? null : reader.GetString(4), + LiveQueryPlan = reader.IsDBNull(5) ? null : reader.GetValue(5)?.ToString(), + Status = reader.IsDBNull(6) ? null : reader.GetString(6), + BlockingSessionId = reader.IsDBNull(7) ? 0 : Convert.ToInt32(reader.GetValue(7), CultureInfo.InvariantCulture), + WaitType = reader.IsDBNull(8) ? null : reader.GetString(8), + WaitTimeMs = reader.IsDBNull(9) ? 0L : Convert.ToInt64(reader.GetValue(9), CultureInfo.InvariantCulture), + WaitResource = reader.IsDBNull(10) ? null : reader.GetString(10), + CpuTimeMs = reader.IsDBNull(11) ? 0L : Convert.ToInt64(reader.GetValue(11), CultureInfo.InvariantCulture), + TotalElapsedTimeMs = reader.IsDBNull(12) ? 0L : Convert.ToInt64(reader.GetValue(12), CultureInfo.InvariantCulture), + Reads = reader.IsDBNull(13) ? 0L : Convert.ToInt64(reader.GetValue(13), CultureInfo.InvariantCulture), + Writes = reader.IsDBNull(14) ? 0L : Convert.ToInt64(reader.GetValue(14), CultureInfo.InvariantCulture), + LogicalReads = reader.IsDBNull(15) ? 0L : Convert.ToInt64(reader.GetValue(15), CultureInfo.InvariantCulture), + GrantedQueryMemoryGb = reader.IsDBNull(16) ? 0m : reader.GetDecimal(16), + TransactionIsolationLevel = reader.IsDBNull(17) ? null : reader.GetString(17), + Dop = reader.IsDBNull(18) ? 0 : Convert.ToInt32(reader.GetValue(18), CultureInfo.InvariantCulture), + ParallelWorkerCount = reader.IsDBNull(19) ? 0 : Convert.ToInt32(reader.GetValue(19), CultureInfo.InvariantCulture), + LoginName = reader.IsDBNull(20) ? null : reader.GetString(20), + HostName = reader.IsDBNull(21) ? null : reader.GetString(21), + ProgramName = reader.IsDBNull(22) ? null : reader.GetString(22), + OpenTransactionCount = reader.IsDBNull(23) ? 0 : Convert.ToInt32(reader.GetValue(23), CultureInfo.InvariantCulture), + PercentComplete = reader.IsDBNull(24) ? 0m : Convert.ToDecimal(reader.GetValue(24), CultureInfo.InvariantCulture), + IsCdcCapture = !reader.IsDBNull(25) && Convert.ToBoolean(reader.GetValue(25), CultureInfo.InvariantCulture), + QueryHash = reader.IsDBNull(26) ? null : reader.GetString(26), + RequestedMemoryMb = reader.IsDBNull(27) ? null : Convert.ToDouble(reader.GetValue(27), CultureInfo.InvariantCulture), + UsedMemoryMb = reader.IsDBNull(28) ? null : Convert.ToDouble(reader.GetValue(28), CultureInfo.InvariantCulture), + MaxUsedMemoryMb = reader.IsDBNull(29) ? null : Convert.ToDouble(reader.GetValue(29), CultureInfo.InvariantCulture), + TempdbCurrentMb = reader.IsDBNull(30) ? null : Convert.ToDouble(reader.GetValue(30), CultureInfo.InvariantCulture), + TempdbAllocationsMb = reader.IsDBNull(31) ? null : Convert.ToDouble(reader.GetValue(31), CultureInfo.InvariantCulture), + TranLogUsedMb = reader.IsDBNull(32) ? null : Convert.ToDouble(reader.GetValue(32), CultureInfo.InvariantCulture), + TranStartTime = reader.IsDBNull(33) ? null : reader.GetDateTime(33), + RequestId = reader.IsDBNull(34) ? 0 : Convert.ToInt32(reader.GetValue(34), CultureInfo.InvariantCulture), + }); + } + + return rows; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + writer + .Value(row.SessionId) + .Value(row.DatabaseName) + .Value(row.ElapsedTimeFormatted) + .Value(row.QueryText) + .Value(row.QueryPlan) + .Value(row.LiveQueryPlan) + .Value(row.Status) + .Value(row.BlockingSessionId) + .Value(row.WaitType) + .Value(row.WaitTimeMs) + .Value(row.WaitResource) + .Value(row.CpuTimeMs) + .Value(row.TotalElapsedTimeMs) + .Value(row.Reads) + .Value(row.Writes) + .Value(row.LogicalReads) + .Value(row.GrantedQueryMemoryGb) + .Value(row.TransactionIsolationLevel) + .Value(row.Dop) + .Value(row.ParallelWorkerCount) + .Value(row.LoginName) + .Value(row.HostName) + .Value(row.ProgramName) + .Value(row.OpenTransactionCount) + .Value(row.PercentComplete) + .Value(row.IsCdcCapture) + .Value(row.QueryHash) + .Value(row.RequestedMemoryMb) + .Value(row.UsedMemoryMb) + .Value(row.MaxUsedMemoryMb) + .Value(row.TempdbCurrentMb) + .Value(row.TempdbAllocationsMb) + .Value(row.TranLogUsedMb) + .Value(row.TranStartTime) + .Value(row.RequestId); + } +} diff --git a/PerformanceMonitor.Collectors/QueryStatsCollector.cs b/PerformanceMonitor.Collectors/QueryStatsCollector.cs new file mode 100644 index 000000000..c08bc7954 --- /dev/null +++ b/PerformanceMonitor.Collectors/QueryStatsCollector.cs @@ -0,0 +1,420 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Query statistics from sys.dm_exec_query_stats. Extracted verbatim from Lite's +/// RemoteCollectorService.QueryStats.cs — the standard path joins plan_attributes for the +/// database (with the exclusion splice); Azure SQL DB skips plan_attributes (it reports dbid=1 +/// for all plans) and runs per database (RunsPerDatabase). Deltas are keyed on the FULL +/// dm_exec_query_stats row identity (sql_handle:start:end:plan_handle) — keying on plan_handle +/// alone cross-contaminated multi-statement plans (one plan_handle, many statements at different +/// offsets), yielding garbage resource deltas with zero execution delta. The worker-time delta +/// also captures the collection interval (CalculateDeltaWithInterval) so the display can derive +/// peak CPU-ms per wall-clock second. +/// </summary> +public sealed class QueryStatsCollector : CollectorDefinitionBase<QueryStatsCollector.Row> +{ + public static QueryStatsCollector Instance { get; } = new(); + + private QueryStatsCollector() + { + } + + public sealed class Row + { + public string? DatabaseName { get; set; } + public string QueryHash { get; set; } = ""; + public string? QueryPlanHash { get; set; } + public DateTime? CreationTime { get; set; } + public DateTime? LastExecutionTime { get; set; } + public long ExecutionCount { get; set; } + public long TotalWorkerTime { get; set; } + public long TotalElapsedTime { get; set; } + public long TotalLogicalReads { get; set; } + public long TotalLogicalWrites { get; set; } + public long TotalPhysicalReads { get; set; } + public long TotalClrTime { get; set; } + public long TotalRows { get; set; } + public long TotalSpills { get; set; } + public long MinWorkerTime { get; set; } + public long MaxWorkerTime { get; set; } + public long MinElapsedTime { get; set; } + public long MaxElapsedTime { get; set; } + public long MinPhysicalReads { get; set; } + public long MaxPhysicalReads { get; set; } + public long MinRows { get; set; } + public long MaxRows { get; set; } + public long MinDop { get; set; } + public long MaxDop { get; set; } + public long MinGrantKb { get; set; } + public long MaxGrantKb { get; set; } + public long MinUsedGrantKb { get; set; } + public long MaxUsedGrantKb { get; set; } + public long MinIdealGrantKb { get; set; } + public long MaxIdealGrantKb { get; set; } + public long MinReservedThreads { get; set; } + public long MaxReservedThreads { get; set; } + public long MinUsedThreads { get; set; } + public long MaxUsedThreads { get; set; } + public long MinSpills { get; set; } + public long MaxSpills { get; set; } + public string? SqlHandle { get; set; } + public string? PlanHandle { get; set; } + public string? QueryText { get; set; } + public string? QueryPlanXml { get; set; } + public long PlanGenerationNum { get; set; } + public int StatementStartOffset { get; set; } + public int StatementEndOffset { get; set; } + } + + private const string SelectColumnsText = @" + query_hash = CONVERT(varchar(64), qs.query_hash, 1), + query_plan_hash = CONVERT(varchar(64), qs.query_plan_hash, 1), + creation_time = qs.creation_time, + last_execution_time = qs.last_execution_time, + execution_count = qs.execution_count, + total_worker_time = qs.total_worker_time, + total_elapsed_time = qs.total_elapsed_time, + total_logical_reads = qs.total_logical_reads, + total_logical_writes = qs.total_logical_writes, + total_physical_reads = qs.total_physical_reads, + total_clr_time = qs.total_clr_time, + total_rows = qs.total_rows, + total_spills = qs.total_spills, + min_worker_time = qs.min_worker_time, + max_worker_time = qs.max_worker_time, + min_elapsed_time = qs.min_elapsed_time, + max_elapsed_time = qs.max_elapsed_time, + min_physical_reads = qs.min_physical_reads, + max_physical_reads = qs.max_physical_reads, + min_rows = qs.min_rows, + max_rows = qs.max_rows, + min_dop = qs.min_dop, + max_dop = qs.max_dop, + min_grant_kb = qs.min_grant_kb, + max_grant_kb = qs.max_grant_kb, + min_used_grant_kb = qs.min_used_grant_kb, + max_used_grant_kb = qs.max_used_grant_kb, + min_ideal_grant_kb = qs.min_ideal_grant_kb, + max_ideal_grant_kb = qs.max_ideal_grant_kb, + min_reserved_threads = qs.min_reserved_threads, + max_reserved_threads = qs.max_reserved_threads, + min_used_threads = qs.min_used_threads, + max_used_threads = qs.max_used_threads, + min_spills = qs.min_spills, + max_spills = qs.max_spills, + sql_handle = CONVERT(varchar(130), qs.sql_handle, 1), + plan_handle = CONVERT(varchar(130), qs.plan_handle, 1), + query_text = + CASE + WHEN qs.statement_start_offset = 0 + AND qs.statement_end_offset = -1 + THEN st.text + ELSE + SUBSTRING + ( + st.text, + (qs.statement_start_offset / 2) + 1, + ( + CASE + WHEN qs.statement_end_offset = -1 + THEN DATALENGTH(st.text) + ELSE qs.statement_end_offset + END - qs.statement_start_offset + ) / 2 + 1 + ) + END, + plan_generation_num = qs.plan_generation_num, + statement_start_offset = qs.statement_start_offset, + statement_end_offset = qs.statement_end_offset"; + + private const string StandardQueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT /* PerformanceMonitorLite */ TOP (200) + database_name = d.name," + SelectColumnsText + @"/*PLAN_SELECT*/ +FROM sys.dm_exec_query_stats AS qs +OUTER APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st/*PLAN_APPLY*/ +CROSS APPLY +( + SELECT + dbid = CONVERT(integer, pa.value) + FROM sys.dm_exec_plan_attributes(qs.plan_handle) AS pa + WHERE pa.attribute = N'dbid' +) AS pa +INNER JOIN sys.databases AS d + ON pa.dbid = d.database_id +WHERE pa.dbid NOT IN (1, 2, 3, 4, 32761, 32767, ISNULL(DB_ID(N'PerformanceMonitor'), 0)) +AND st.text NOT LIKE N'%PerformanceMonitorLite%' +AND qs.last_execution_time >= DATEADD(MINUTE, -10, GETDATE()) +/*EXCLUSION_FILTER*/ +ORDER BY + qs.total_elapsed_time DESC +OPTION(RECOMPILE);"; + + private const string AzureSqlDbQueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT /* PerformanceMonitorLite */ TOP (200) + database_name = DB_NAME()," + SelectColumnsText + @"/*PLAN_SELECT*/ +FROM sys.dm_exec_query_stats AS qs +OUTER APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st/*PLAN_APPLY*/ +WHERE st.text NOT LIKE N'%PerformanceMonitorLite%' +AND qs.last_execution_time >= DATEADD(MINUTE, -10, GETDATE()) +ORDER BY + qs.total_elapsed_time DESC +OPTION(RECOMPILE);"; + + /* Execution-plan capture — spliced into both variants only when the host sets CapturePlanXml + (Darling); when off the placeholders erase to nothing, so Lite's SQL is byte-identical to the + no-plan form. Mirrors the full Dashboard's @collect_plan path in + install/08_collect_query_stats.sql: the STATEMENT-level plan from sys.dm_exec_text_query_plan + keyed on the same plan_handle + statement offsets (the text DMV, not dm_exec_query_plan, so + large/deep plans that overflow the xml type still return), with no size guard. */ + private const string PlanSelectFragment = @", + query_plan_xml = tqp.query_plan"; + + private const string PlanApplyFragment = @" +OUTER APPLY + sys.dm_exec_text_query_plan + ( + qs.plan_handle, + qs.statement_start_offset, + qs.statement_end_offset + ) AS tqp"; + + public override string Name => "query_stats"; + + public override string TargetTable => "query_stats"; + + /// <summary> + /// On-prem/RDS require SQL Server 2016 (v13) or newer; older boxes lack columns this query reads. + /// Azure SQL DB / Managed Instance report a low ProductMajorVersion yet fully support the DMV, so they + /// are never version-gated, and an unknown version (0) is assumed newest — matching the exact condition + /// Lite used in IsCollectorSupported. Gated here in the shared AppliesTo so Lite and Darling skip + /// identically on a pre-2016 target. + /// </summary> + public override bool AppliesTo(CollectorTargetInfo target) => + target.SqlMajorVersion == 0 || target.SqlMajorVersion >= 13 || target.IsAzureSqlDb || target.IsAzureManagedInstance; + + /// <summary>Azure SQL DB scopes dm_exec_query_stats to the connected database.</summary> + public override bool RunsPerDatabase(CollectorTargetInfo target) => target.IsAzureSqlDb; + + public override CollectorQuery BuildQuery(CollectorContext context) + { + var planSelect = context.CapturePlanXml ? PlanSelectFragment : ""; + var planApply = context.CapturePlanXml ? PlanApplyFragment : ""; + + if (context.Target.IsAzureSqlDb) + { + return new CollectorQuery( + AzureSqlDbQueryText + .Replace("/*PLAN_SELECT*/", planSelect, StringComparison.Ordinal) + .Replace("/*PLAN_APPLY*/", planApply, StringComparison.Ordinal)); + } + + var (exclusionClause, exclusionParameters) = DatabaseExclusionFilter.Build(context.ExcludedDatabases, "d.name"); + return new CollectorQuery( + StandardQueryText + .Replace("/*EXCLUSION_FILTER*/", exclusionClause, StringComparison.Ordinal) + .Replace("/*PLAN_SELECT*/", planSelect, StringComparison.Ordinal) + .Replace("/*PLAN_APPLY*/", planApply, StringComparison.Ordinal), + exclusionParameters); + } + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("database_name", CollectorColumnType.Varchar), + new CollectorColumn("query_hash", CollectorColumnType.Varchar), + new CollectorColumn("query_plan_hash", CollectorColumnType.Varchar), + new CollectorColumn("creation_time", CollectorColumnType.Timestamp), + new CollectorColumn("last_execution_time", CollectorColumnType.Timestamp), + new CollectorColumn("execution_count", CollectorColumnType.BigInt), + new CollectorColumn("total_worker_time", CollectorColumnType.BigInt), + new CollectorColumn("total_elapsed_time", CollectorColumnType.BigInt), + new CollectorColumn("total_logical_reads", CollectorColumnType.BigInt), + new CollectorColumn("total_logical_writes", CollectorColumnType.BigInt), + new CollectorColumn("total_physical_reads", CollectorColumnType.BigInt), + new CollectorColumn("total_clr_time", CollectorColumnType.BigInt), + new CollectorColumn("total_rows", CollectorColumnType.BigInt), + new CollectorColumn("total_spills", CollectorColumnType.BigInt), + new CollectorColumn("min_worker_time", CollectorColumnType.BigInt), + new CollectorColumn("max_worker_time", CollectorColumnType.BigInt), + new CollectorColumn("min_elapsed_time", CollectorColumnType.BigInt), + new CollectorColumn("max_elapsed_time", CollectorColumnType.BigInt), + new CollectorColumn("min_physical_reads", CollectorColumnType.BigInt), + new CollectorColumn("max_physical_reads", CollectorColumnType.BigInt), + new CollectorColumn("min_rows", CollectorColumnType.BigInt), + new CollectorColumn("max_rows", CollectorColumnType.BigInt), + new CollectorColumn("min_dop", CollectorColumnType.BigInt), + new CollectorColumn("max_dop", CollectorColumnType.BigInt), + new CollectorColumn("min_grant_kb", CollectorColumnType.BigInt), + new CollectorColumn("max_grant_kb", CollectorColumnType.BigInt), + new CollectorColumn("min_used_grant_kb", CollectorColumnType.BigInt), + new CollectorColumn("max_used_grant_kb", CollectorColumnType.BigInt), + new CollectorColumn("min_ideal_grant_kb", CollectorColumnType.BigInt), + new CollectorColumn("max_ideal_grant_kb", CollectorColumnType.BigInt), + new CollectorColumn("min_reserved_threads", CollectorColumnType.BigInt), + new CollectorColumn("max_reserved_threads", CollectorColumnType.BigInt), + new CollectorColumn("min_used_threads", CollectorColumnType.BigInt), + new CollectorColumn("max_used_threads", CollectorColumnType.BigInt), + new CollectorColumn("min_spills", CollectorColumnType.BigInt), + new CollectorColumn("max_spills", CollectorColumnType.BigInt), + new CollectorColumn("query_text", CollectorColumnType.Varchar), + new CollectorColumn("query_plan_xml", CollectorColumnType.Varchar), + new CollectorColumn("sql_handle", CollectorColumnType.Varchar), + new CollectorColumn("plan_handle", CollectorColumnType.Varchar), + new CollectorColumn("delta_execution_count", CollectorColumnType.BigInt), + new CollectorColumn("delta_worker_time", CollectorColumnType.BigInt), + new CollectorColumn("delta_elapsed_time", CollectorColumnType.BigInt), + new CollectorColumn("delta_logical_reads", CollectorColumnType.BigInt), + new CollectorColumn("delta_logical_writes", CollectorColumnType.BigInt), + new CollectorColumn("delta_physical_reads", CollectorColumnType.BigInt), + new CollectorColumn("delta_rows", CollectorColumnType.BigInt), + new CollectorColumn("delta_spills", CollectorColumnType.BigInt), + new CollectorColumn("plan_generation_num", CollectorColumnType.BigInt), + new CollectorColumn("sample_interval_seconds", CollectorColumnType.Integer), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var rows = new List<Row>(); + + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new Row + { + DatabaseName = reader.IsDBNull(0) ? null : reader.GetString(0), + QueryHash = reader.IsDBNull(1) ? "" : reader.GetString(1), + QueryPlanHash = reader.IsDBNull(2) ? null : reader.GetString(2), + CreationTime = reader.IsDBNull(3) ? null : reader.GetDateTime(3), + LastExecutionTime = reader.IsDBNull(4) ? null : reader.GetDateTime(4), + ExecutionCount = reader.IsDBNull(5) ? 0L : reader.GetInt64(5), + TotalWorkerTime = reader.IsDBNull(6) ? 0L : reader.GetInt64(6), + TotalElapsedTime = reader.IsDBNull(7) ? 0L : reader.GetInt64(7), + TotalLogicalReads = reader.IsDBNull(8) ? 0L : reader.GetInt64(8), + TotalLogicalWrites = reader.IsDBNull(9) ? 0L : reader.GetInt64(9), + TotalPhysicalReads = reader.IsDBNull(10) ? 0L : reader.GetInt64(10), + TotalClrTime = reader.IsDBNull(11) ? 0L : reader.GetInt64(11), + TotalRows = reader.IsDBNull(12) ? 0L : reader.GetInt64(12), + TotalSpills = reader.IsDBNull(13) ? 0L : reader.GetInt64(13), + MinWorkerTime = reader.IsDBNull(14) ? 0L : reader.GetInt64(14), + MaxWorkerTime = reader.IsDBNull(15) ? 0L : reader.GetInt64(15), + MinElapsedTime = reader.IsDBNull(16) ? 0L : reader.GetInt64(16), + MaxElapsedTime = reader.IsDBNull(17) ? 0L : reader.GetInt64(17), + MinPhysicalReads = reader.IsDBNull(18) ? 0L : reader.GetInt64(18), + MaxPhysicalReads = reader.IsDBNull(19) ? 0L : reader.GetInt64(19), + MinRows = reader.IsDBNull(20) ? 0L : reader.GetInt64(20), + MaxRows = reader.IsDBNull(21) ? 0L : reader.GetInt64(21), + MinDop = reader.IsDBNull(22) ? 0L : Convert.ToInt64(reader.GetValue(22), CultureInfo.InvariantCulture), + MaxDop = reader.IsDBNull(23) ? 0L : Convert.ToInt64(reader.GetValue(23), CultureInfo.InvariantCulture), + MinGrantKb = reader.IsDBNull(24) ? 0L : reader.GetInt64(24), + MaxGrantKb = reader.IsDBNull(25) ? 0L : reader.GetInt64(25), + MinUsedGrantKb = reader.IsDBNull(26) ? 0L : reader.GetInt64(26), + MaxUsedGrantKb = reader.IsDBNull(27) ? 0L : reader.GetInt64(27), + MinIdealGrantKb = reader.IsDBNull(28) ? 0L : reader.GetInt64(28), + MaxIdealGrantKb = reader.IsDBNull(29) ? 0L : reader.GetInt64(29), + MinReservedThreads = reader.IsDBNull(30) ? 0L : reader.GetInt64(30), + MaxReservedThreads = reader.IsDBNull(31) ? 0L : reader.GetInt64(31), + MinUsedThreads = reader.IsDBNull(32) ? 0L : reader.GetInt64(32), + MaxUsedThreads = reader.IsDBNull(33) ? 0L : reader.GetInt64(33), + MinSpills = reader.IsDBNull(34) ? 0L : reader.GetInt64(34), + MaxSpills = reader.IsDBNull(35) ? 0L : reader.GetInt64(35), + SqlHandle = reader.IsDBNull(36) ? null : reader.GetString(36), + PlanHandle = reader.IsDBNull(37) ? null : reader.GetString(37), + QueryText = reader.IsDBNull(38) ? null : reader.GetString(38), + PlanGenerationNum = reader.IsDBNull(39) ? 0L : reader.GetInt64(39), + StatementStartOffset = reader.IsDBNull(40) ? 0 : reader.GetInt32(40), + StatementEndOffset = reader.IsDBNull(41) ? 0 : reader.GetInt32(41), + /* query_plan_xml is the trailing column present only when CapturePlanXml spliced it + into the SELECT (ordinal 42); the short-circuit skips it entirely when off. */ + QueryPlanXml = context.CapturePlanXml && !reader.IsDBNull(42) ? reader.GetString(42) : null, + }); + } + + return rows; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + /* Delta key = the dm_exec_query_stats row identity (sql_handle + offsets + plan_handle). + Keying on plan_handle alone cross-contaminated multi-statement plans — parity contract. */ + var deltaKey = $"{row.SqlHandle}:{row.StatementStartOffset}:{row.StatementEndOffset}:{row.PlanHandle}"; + var deltaExecCount = context.Deltas.CalculateDelta(context.ServerId, "query_stats_exec", deltaKey, row.ExecutionCount, collectionTime: context.CollectionTime, maxGapSeconds: 300); + /* Capture the collection interval alongside the CPU delta so the display can derive + worker_time_per_second (peak CPU-ms per wall-clock second) over the window. */ + var deltaWorkerTime = context.Deltas.CalculateDeltaWithInterval(context.ServerId, "query_stats_worker", deltaKey, row.TotalWorkerTime, out var sampleIntervalSeconds, collectionTime: context.CollectionTime, maxGapSeconds: 300); + var deltaElapsedTime = context.Deltas.CalculateDelta(context.ServerId, "query_stats_elapsed", deltaKey, row.TotalElapsedTime, collectionTime: context.CollectionTime, maxGapSeconds: 300); + var deltaLogicalReads = context.Deltas.CalculateDelta(context.ServerId, "query_stats_reads", deltaKey, row.TotalLogicalReads, collectionTime: context.CollectionTime, maxGapSeconds: 300); + var deltaLogicalWrites = context.Deltas.CalculateDelta(context.ServerId, "query_stats_writes", deltaKey, row.TotalLogicalWrites, collectionTime: context.CollectionTime, maxGapSeconds: 300); + var deltaPhysicalReads = context.Deltas.CalculateDelta(context.ServerId, "query_stats_phys_reads", deltaKey, row.TotalPhysicalReads, collectionTime: context.CollectionTime, maxGapSeconds: 300); + var deltaRows = context.Deltas.CalculateDelta(context.ServerId, "query_stats_rows", deltaKey, row.TotalRows, collectionTime: context.CollectionTime, maxGapSeconds: 300); + var deltaSpills = context.Deltas.CalculateDelta(context.ServerId, "query_stats_spills", deltaKey, row.TotalSpills, collectionTime: context.CollectionTime, maxGapSeconds: 300); + + writer + .Value(row.DatabaseName) + .Value(row.QueryHash) + .Value(row.QueryPlanHash) + .Value(row.CreationTime) + .Value(row.LastExecutionTime) + .Value(row.ExecutionCount) + .Value(row.TotalWorkerTime) + .Value(row.TotalElapsedTime) + .Value(row.TotalLogicalReads) + .Value(row.TotalLogicalWrites) + .Value(row.TotalPhysicalReads) + .Value(row.TotalClrTime) + .Value(row.TotalRows) + .Value(row.TotalSpills) + .Value(row.MinWorkerTime) + .Value(row.MaxWorkerTime) + .Value(row.MinElapsedTime) + .Value(row.MaxElapsedTime) + .Value(row.MinPhysicalReads) + .Value(row.MaxPhysicalReads) + .Value(row.MinRows) + .Value(row.MaxRows) + .Value(row.MinDop) + .Value(row.MaxDop) + .Value(row.MinGrantKb) + .Value(row.MaxGrantKb) + .Value(row.MinUsedGrantKb) + .Value(row.MaxUsedGrantKb) + .Value(row.MinIdealGrantKb) + .Value(row.MaxIdealGrantKb) + .Value(row.MinReservedThreads) + .Value(row.MaxReservedThreads) + .Value(row.MinUsedThreads) + .Value(row.MaxUsedThreads) + .Value(row.MinSpills) + .Value(row.MaxSpills) + .Value(row.QueryText) + .Value(row.QueryPlanXml) /* null unless CapturePlanXml captured it (Darling) */ + .Value(row.SqlHandle) + .Value(row.PlanHandle) + .Value(deltaExecCount) + .Value(deltaWorkerTime) + .Value(deltaElapsedTime) + .Value(deltaLogicalReads) + .Value(deltaLogicalWrites) + .Value(deltaPhysicalReads) + .Value(deltaRows) + .Value(deltaSpills) + .Value(row.PlanGenerationNum) + .Value(sampleIntervalSeconds); /* sample_interval_seconds INTEGER */ + } +} diff --git a/PerformanceMonitor.Collectors/QueryStoreCollector.cs b/PerformanceMonitor.Collectors/QueryStoreCollector.cs new file mode 100644 index 000000000..aecae281b --- /dev/null +++ b/PerformanceMonitor.Collectors/QueryStoreCollector.cs @@ -0,0 +1,799 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Query Store runtime stats from every database with Query Store actually enabled. Extracted +/// verbatim from Lite's RemoteCollectorService.QueryStore.cs: the enumeration cursor probes each +/// database's sys.database_query_store_options.actual_state (NOT sys.databases.is_query_store_on, +/// which can be out of sync on Azure SQL DB; on-prem additionally filters to non-AG or +/// primary-replica databases). actual_state is matched against the explicit usable set +/// IN (1, 2, 4) — READ_ONLY, READ_WRITE, READ_CAPTURE_SECONDARY — rather than the looser +/// "> 0", which also admits 3 = ERROR: an errored Query Store is not readable and must not +/// pass the "is QS usable" gate (0 = OFF). The per-item [db].sys.sp_executesql query is incremental on the +/// last_execution_time watermark (fallback: 60 minutes back), and the 2017+/2022+ column gates +/// are decided by a live PRODUCTVERSION probe each cycle (default 13 when the probe fails) — +/// deliberately probed rather than trusting cached connection status, which can be +/// version-unknown. +/// </summary> +public sealed class QueryStoreCollector : CollectorDefinitionBase<QueryStoreCollector.Row> +{ + public static QueryStoreCollector Instance { get; } = new(); + + private QueryStoreCollector() + { + } + + public sealed class Row + { + public string DatabaseName { get; set; } = ""; + public long QueryId { get; set; } + public long PlanId { get; set; } + public string? ExecutionTypeDesc { get; set; } + public DateTime? FirstExecutionTime { get; set; } + public DateTime? LastExecutionTime { get; set; } + public string? ModuleName { get; set; } + public string? QueryText { get; set; } + public string? QueryHash { get; set; } + public long ExecutionCount { get; set; } + public long AvgDurationUs { get; set; } + public long MinDurationUs { get; set; } + public long MaxDurationUs { get; set; } + public long AvgCpuTimeUs { get; set; } + public long MinCpuTimeUs { get; set; } + public long MaxCpuTimeUs { get; set; } + public long AvgLogicalIoReads { get; set; } + public long MinLogicalIoReads { get; set; } + public long MaxLogicalIoReads { get; set; } + public long AvgLogicalIoWrites { get; set; } + public long MinLogicalIoWrites { get; set; } + public long MaxLogicalIoWrites { get; set; } + public long AvgPhysicalIoReads { get; set; } + public long MinPhysicalIoReads { get; set; } + public long MaxPhysicalIoReads { get; set; } + public long AvgClrTimeUs { get; set; } + public long MinClrTimeUs { get; set; } + public long MaxClrTimeUs { get; set; } + public long MinDop { get; set; } + public long MaxDop { get; set; } + public long AvgQueryMaxUsedMemory { get; set; } + public long MinQueryMaxUsedMemory { get; set; } + public long MaxQueryMaxUsedMemory { get; set; } + public long AvgRowcount { get; set; } + public long MinRowcount { get; set; } + public long MaxRowcount { get; set; } + public long AvgNumPhysicalIoReads { get; set; } + public long MinNumPhysicalIoReads { get; set; } + public long MaxNumPhysicalIoReads { get; set; } + public long AvgLogBytesUsed { get; set; } + public long MinLogBytesUsed { get; set; } + public long MaxLogBytesUsed { get; set; } + public long AvgTempdbSpaceUsed { get; set; } + public long MinTempdbSpaceUsed { get; set; } + public long MaxTempdbSpaceUsed { get; set; } + public string? PlanType { get; set; } + public string? PlanForcingType { get; set; } + public bool IsForcedPlan { get; set; } + public long ForceFailureCount { get; set; } + public string? LastForceFailureReason { get; set; } + public int CompatibilityLevel { get; set; } + public string? QueryPlanText { get; set; } + public string? QueryPlanHash { get; set; } + + /// <summary> + /// The replica role sys.query_store_replicas attributed this runtime-stats row to ('Primary', + /// 'Secondary', 'Geo Secondary', 'Geo HA Secondary'). NULL = the server did not attribute it + /// (pre-2022, or a 2022 standalone whose sys.query_store_replicas is empty) — deliberately not + /// coalesced. See hasReplicaAttribution in BuildPerItemQuery. + /// </summary> + public string? ReplicaRole { get; set; } + } + + private const string OnPremDatabaseListQueryText = @" +SET NOCOUNT ON; +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +DECLARE + @result TABLE (name sysname); + +DECLARE + @db sysname, + @sql NVARCHAR(500), + @exec_sp nvarchar(256); + +DECLARE db_check CURSOR LOCAL FAST_FORWARD FOR + SELECT /* PerformanceMonitorLite */ + d.name + FROM sys.databases AS d + LEFT JOIN sys.dm_hadr_database_replica_states AS drs + ON d.database_id = drs.database_id + AND drs.is_local = 1 + WHERE d.database_id > 4 + AND d.database_id < 32761 + AND d.state_desc = N'ONLINE' + AND d.name <> N'PerformanceMonitor' + /* Default screen (#1565): vendor/system-adjacent databases with no customer workload. First group = + vendor-controlled names (cannot collide with real customer data): cloud-provider management dbs, + SSRS catalogs, PolyBase/DW artifacts, plus the id>4 system four as a name-based belt for clarity. + Second group = common DBA-convention tooling names, screened by operator decision; the inverse + case (exclude a real workload db) is what excludedDatabases handles. */ + AND d.name NOT IN + ( + N'master', N'model', N'msdb', N'tempdb', + N'rdsadmin', N'gcloud_cloudsqladmin', + N'ReportServer', N'ReportServerTempDB', + N'DWConfiguration', N'DWDiagnostics', N'DWQueue', + N'DBAUtil', N'DBAUtils', N'Utility' + ) + AND + ( + drs.database_id IS NULL /*not in any AG*/ + OR drs.is_primary_replica = 1 /*primary replica*/ + ) + /*EXCLUSION_FILTER*/ + OPTION(RECOMPILE); + +OPEN db_check; + +FETCH NEXT +FROM db_check +INTO @db; + +WHILE @@FETCH_STATUS = 0 +BEGIN + BEGIN TRY + /* actual_state IN (1,2,4) = READ_ONLY / READ_WRITE / READ_CAPTURE_SECONDARY (#1546: > 0 also + admitted 3 = ERROR). readonly_reason & 8 = 0 (#1558): bit 8 is the engine saying read-only + BECAUSE this database is a readable secondary replica — its Query Store content is the + PRIMARY's persisted QS tables arriving via replication, not local activity, so collecting it + is duplicate, lagged primary data (caught live: 24 RDS read replicas each re-reading the same + primary's QS — pathological volume for zero information). The HADR join in the cursor above + only catches Always On AG secondaries; reason bit 8 is the engine's mechanism-agnostic flag + (AG, RDS read replicas, geo-secondaries alike). An operator-set read-only QS on a PRIMARY has + reason <> 8 and still collects; a 2025 READ_CAPTURE_SECONDARY (state 4, reason 0) captures + REAL local secondary workload and still collects. */ + SET @sql = N' + SELECT ' + QUOTENAME(@db, '''') + N' + WHERE EXISTS + ( + SELECT + 1 + FROM sys.database_query_store_options + WHERE actual_state IN (1, 2, 4) + AND readonly_reason & 8 = 0 + );'; + + SET @exec_sp = QUOTENAME(@db) + N'.sys.sp_executesql'; + + INSERT @result (name) + EXECUTE @exec_sp @sql; + END TRY + BEGIN CATCH + END CATCH; + + FETCH NEXT + FROM db_check + INTO @db; +END; + +CLOSE db_check; +DEALLOCATE db_check; + +SELECT + name +FROM @result +ORDER BY + name;"; + + private const string AzureDatabaseListQueryText = @" +SET NOCOUNT ON; +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +DECLARE + @result TABLE (name sysname); + +DECLARE + @db sysname, + @sql NVARCHAR(500), + @exec_sp nvarchar(256); + +DECLARE db_check CURSOR LOCAL FAST_FORWARD FOR + SELECT /* PerformanceMonitorLite */ + d.name + FROM sys.databases AS d + WHERE d.database_id > 4 + AND d.database_id < 32761 + AND d.state_desc = N'ONLINE' + AND d.name <> N'PerformanceMonitor' + /* Same default screen as the box/RDS enumeration (#1565) — most vendor names can't exist on Azure + SQL DB, but one shared list keeps the two paths identical and covers the DBA-convention names. */ + AND d.name NOT IN + ( + N'master', N'model', N'msdb', N'tempdb', + N'rdsadmin', N'gcloud_cloudsqladmin', + N'ReportServer', N'ReportServerTempDB', + N'DWConfiguration', N'DWDiagnostics', N'DWQueue', + N'DBAUtil', N'DBAUtils', N'Utility' + ) + /*EXCLUSION_FILTER*/ + OPTION(RECOMPILE); + +OPEN db_check; + +FETCH NEXT +FROM db_check +INTO @db; + +WHILE @@FETCH_STATUS = 0 +BEGIN + BEGIN TRY + /* Same eligibility gate as the on-prem cursor above — see the readonly_reason & 8 rationale there. */ + SET @sql = N' + SELECT ' + QUOTENAME(@db, '''') + N' + WHERE EXISTS + ( + SELECT + 1 + FROM sys.database_query_store_options + WHERE actual_state IN (1, 2, 4) + AND readonly_reason & 8 = 0 + );'; + + SET @exec_sp = QUOTENAME(@db) + N'.sys.sp_executesql'; + + INSERT @result (name) + EXECUTE @exec_sp @sql; + END TRY + BEGIN CATCH + END CATCH; + + FETCH NEXT + FROM db_check + INTO @db; +END; + +CLOSE db_check; +DEALLOCATE db_check; + +SELECT + name +FROM @result +ORDER BY + name;"; + + /// <summary>The live version probe deciding the 2017+/2022+ column gates (see class remarks).</summary> + public const string ProductVersionProbeText = + "SELECT CONVERT(integer, PARSENAME(CONVERT(sysname, SERVERPROPERTY('PRODUCTVERSION')), 4))"; + + /// <summary>PRODUCTVERSION assumed when the probe fails or returns NULL (SQL Server 2016).</summary> + public const int DefaultProductVersion = 13; + + /// <summary> + /// Server-side per-database row backstop (#1556): a coarse ceiling — ~10× a healthy per-cycle + /// volume — spliced as <c>TOP</c> so the SQL engine bounds its own work and the wire transfer before + /// the client byte budget ever engages. Deliberately NOT the siblings' curation caps (query_stats + /// TOP 200, procedure_stats TOP 150): those curate the "top N"; this only trims a pathological cycle. + /// It bounds COUNT, not BYTES — <see cref="MaxTextBytesPerDatabase"/> is the primary memory bound — + /// and is the same const the host warns on (<see cref="PerItemRowCountWarnThreshold"/>). + /// </summary> + public const int MaxRowsPerDatabase = 50_000; + + /// <summary> + /// The PRIMARY memory bound (#1556): the cumulative per-database TEXT byte budget the client stops + /// reading at, enforced in <see cref="ReadItemAsync"/>. 256 MB per database composes with the #1553 + /// 4-wide sweep to a bounded peak of ≈ 4 × 256 MB ≈ 1 GB transient — the field incident (0→13GB) was + /// exactly this un-bounded: 50k rows × ~40KB plan XML is ~2GB for ONE database, ×4 = 8GB, with the + /// row cap firing no defense (it caps rows, not bytes). Kept alongside the SQL <c>TOP</c> backstop. + /// </summary> + public const int MaxTextBytesPerDatabase = 256 * 1024 * 1024; + + /// <summary> + /// The self-identification marker every collector query carries in its leading comment. Self rows + /// are excluded CLIENT-SIDE in <see cref="ReadItemAsync"/> (#1565) — the old SQL-side + /// NOT LIKE predicate was 75% of the read's elapsed time (a full nvarchar(max) scan per row on a + /// column no index can serve), and the text is already materialized here anyway. + /// </summary> + public const string SelfQueryMarker = "PerformanceMonitorLite"; + + public override string Name => "query_store"; + + public override string TargetTable => "query_store_stats"; + + /// <summary> + /// Query Store first shipped in SQL Server 2016 (v13), so on-prem/RDS require v13+; a pre-2016 box has no + /// Query Store at all. Azure SQL DB / Managed Instance report a low ProductMajorVersion yet ship Query + /// Store, so they are never version-gated, and an unknown version (0) is assumed newest — the exact + /// condition Lite used in IsCollectorSupported. Gated here in the shared AppliesTo so Lite and Darling + /// skip identically on a pre-2016 target. (The per-cycle PRODUCTVERSION probe still refines which + /// version-gated columns are selected; this gate decides whether the collector runs at all.) + /// </summary> + public override bool AppliesTo(CollectorTargetInfo target) => + target.SqlMajorVersion == 0 || target.SqlMajorVersion >= 13 || target.IsAzureSqlDb || target.IsAzureManagedInstance; + + /// <summary>Incremental: only intervals with newer last_execution_time are fetched per cycle.</summary> + public override string? WatermarkColumn => "last_execution_time"; + + /// <summary> + /// Per-database watermark (#1556): query_store enumerates databases and now FLUSHES each database's + /// rows before reading the next, so its cutoff must be per-database — otherwise any mid-run abort + /// (shutdown, OOM, a store-write failure) would strand the un-flushed databases' intervals behind the + /// committed databases' advanced server-wide watermark. Keyed on the already-collected + /// <c>database_name</c> column, the same precedent the deadlocks / blocked_process_report XE + /// collectors set. With this, each database's commit advances only its own watermark and an abort + /// loses nothing. This also relocates query_store's cutoff computation into the per-item loop, which + /// is exactly where the 24h catch-up clamp (<see cref="WatermarkPolicy"/>) must be applied. + /// </summary> + public override string? PerDatabaseWatermarkColumn => "database_name"; + + /// <summary>Host warns when a per-database read hits the row backstop (see <see cref="MaxRowsPerDatabase"/>).</summary> + public override int? PerItemRowCountWarnThreshold => MaxRowsPerDatabase; + + /// <summary>The client-side per-database text byte budget enforced in <see cref="ReadItemAsync"/> (see <see cref="MaxTextBytesPerDatabase"/>).</summary> + public override int? PerItemTextByteBudget => MaxTextBytesPerDatabase; + + /// <summary>Enumerating collector — the primary query is never used.</summary> + public override CollectorQuery BuildQuery(CollectorContext context) + => throw new NotSupportedException("query_store enumerates databases; BuildEnumerationQuery drives the cycle."); + + public override CollectorQuery? BuildEnumerationQuery(CollectorContext context) + { + var (exclusionClause, exclusionParameters) = DatabaseExclusionFilter.Build(context.ExcludedDatabases, "d.name"); + var text = (context.Target.IsAzureSqlDb ? AzureDatabaseListQueryText : OnPremDatabaseListQueryText) + .Replace("/*EXCLUSION_FILTER*/", exclusionClause, StringComparison.Ordinal); + + return new CollectorQuery(text, exclusionParameters); + } + + public override CollectorQuery? BuildEnumerationProbe(CollectorContext context) + => new(ProductVersionProbeText); + + public override CollectorQuery BuildPerItemQuery(string item, CollectorContext context) + { + /* Detect server version for version-gated columns. + isNew = true for SQL Server 2017+ (product version > 13) or Azure SQL DB/MI. + Controls: avg_num_physical_io_reads, avg_log_bytes_used, avg_tempdb_space_used, plan_forcing_type_desc. + hasPlanType = true for SQL Server 2022+ (product version >= 16). + Controls: plan_type_desc. */ + var productVersion = context.EnumerationProbeResult is null + ? DefaultProductVersion + : Convert.ToInt32(context.EnumerationProbeResult, CultureInfo.InvariantCulture); + bool isNew = productVersion > 13 || context.Target.IsAzureSqlDb || context.Target.IsAzureManagedInstance; + bool hasPlanType = productVersion >= 16; + + /* Replica attribution — SQL Server 2022+ (product version >= 16). Controls: replica_role. + + "Query Store for secondary replicas" (2022+) gives an AG ONE shared Query Store that lives + on the PRIMARY: secondary-replica workload is streamed to the primary and persisted in the + primary's QS tables, distinguishable only by replica_group_id. We collect from primaries + only (is_primary_replica = 1, in the enumeration cursor) — which is correct and stays — but + without this attribution the primary's "Top Queries by CPU" silently BLENDS secondary + workload into the primary's own numbers, with no way for a reader to tell. (Microsoft's own + Query Performance Insight has this exact bug.) + + Gated on >= 16, NOT the docs' claimed 2025+: sys.query_store_replicas and + sys.query_store_runtime_stats.replica_group_id both verified present on SQL 2022 + (16.0.4255.1) and SQL 2025 (17.0.4045.5). + + LEFT JOIN, deliberately: on a 2022 standalone (non-AG) server sys.query_store_replicas has + ZERO rows, yet real runtime-stats rows still carry replica_group_id = 1. An INNER JOIN — or + a WHERE replica_name = 'Primary' filter — would match nothing and silently delete ALL Query + Store collection on every 2022 standalone server. Do not "tighten" this. + + replica_name is read DIRECTLY rather than mapped from role_type: contrary to the docs, it IS + populated on box SQL Server (observed: Primary, Secondary, Geo Secondary, Geo HA Secondary), + and the docs' sample CASEs replica_group_id as though it were role_type — it is not, it is a + replica SET number that accumulates per role across failovers. + + Resulting replica_role: NULL on a 2022 standalone, 'Primary' on a 2025 standalone, the actual + role on an AG with the feature enabled. NULL honestly means "the server did not attribute + this row" and is deliberately NOT coalesced to an invented value. */ + bool hasReplicaAttribution = productVersion >= 16; + + /* Build version-conditional column fragments for the Query Store query. + These are injected into the sp_executesql parameter string — no single quotes needed. */ + string numPhysIoReadsCols = isNew + ? "qsrs.avg_num_physical_io_reads, qsrs.min_num_physical_io_reads, qsrs.max_num_physical_io_reads," + : "avg_num_physical_io_reads = NULL, min_num_physical_io_reads = NULL, max_num_physical_io_reads = NULL,"; + + string logBytesCols = isNew + ? "avg_log_bytes_used = qsrs.avg_log_bytes_used, min_log_bytes_used = qsrs.min_log_bytes_used, max_log_bytes_used = qsrs.max_log_bytes_used," + : "avg_log_bytes_used = NULL, min_log_bytes_used = NULL, max_log_bytes_used = NULL,"; + + string tempdbCols = isNew + ? "avg_tempdb_space_used = qsrs.avg_tempdb_space_used, min_tempdb_space_used = qsrs.min_tempdb_space_used, max_tempdb_space_used = qsrs.max_tempdb_space_used," + : "avg_tempdb_space_used = NULL, min_tempdb_space_used = NULL, max_tempdb_space_used = NULL,"; + + string planForcingCol = isNew + ? "plan_forcing_type = qsp.plan_forcing_type_desc," + : "plan_forcing_type = NULL,"; + + string planTypeCol = hasPlanType + ? "plan_type = qsp.plan_type_desc," + : "plan_type = NULL,"; + + /* Execution-plan capture — mirrors the full Dashboard's @collect_plan path in + install/09_collect_query_store.sql: CONVERT(nvarchar(max), qsp.query_plan) from + sys.query_store_plan, no size guard. On only when the host sets CapturePlanXml (Darling); + off = the nvarchar(1) NULL placeholder (Lite), byte-identical to the no-plan form. No + single quotes, so it splices straight into the sp_executesql body. + + #1556 plan-text dedupe (ON branch only): a plan is landed ONCE per plan_id per cycle — on its + newest runtime-stats interval (rn = 1) — and NULL on the older intervals, instead of repeating + the full plan XML on every interval row of the same plan. Composes with the TOP backstop below + because both sort by last_execution_time DESC: a plan that survives TOP always keeps its rn = 1 + row (its newest interval is among the newest overall). The consumers tolerate the per-row NULL — + Lite selects NULL for the grid and fetches plans live, and Darling's stored-plan readers all + guard `query_plan_text IS NOT NULL`. Not mirrored into the Dashboard proc: its "Download Plan" + reads by exact collection_id, where per-row NULLs would break a real reader. */ + string planTextCol = context.CapturePlanXml + ? "query_plan_text = CASE WHEN ROW_NUMBER() OVER (PARTITION BY qsp.plan_id ORDER BY qsrs.last_execution_time DESC) = 1 THEN CONVERT(nvarchar(max), qsp.query_plan) ELSE CONVERT(nvarchar(max), NULL) END," + : "query_plan_text = CONVERT(nvarchar(1), NULL),"; + + /* The replica-attribution column + its join (see hasReplicaAttribution above). Selected LAST, + so pre-2022 targets read the nvarchar(1) NULL placeholder at the same ordinal — byte-identical + shape to the attributed form. No trailing comma: it is the final SELECT item. */ + string replicaRoleCol = hasReplicaAttribution + ? "replica_role = qsr.replica_name" + : "replica_role = CONVERT(nvarchar(1), NULL)"; + + string replicaJoin = hasReplicaAttribution + ? "LEFT JOIN sys.query_store_replicas AS qsr\n ON qsr.replica_group_id = qsrs.replica_group_id" + : ""; + + /* Incremental: only fetch runtime_stats intervals newer than what we already have. + + Two field-verified cost/behavior notes (#1565, actual-plan evidence from a 103k-row burst): + (1) There is deliberately NO self-exclusion predicate in this query. The old form + (query_sql_text NOT LIKE N'%marker%') was 75% of the query's total elapsed time — a per-row + substring scan over full nvarchar(max) text (11.2s of a 14.9s read; the field A/B measured + 4.3x faster without it) — and no predicate shape fixes that server-side: the QS internal + text table has no index on the column, so every variant is a residual scan whose cost is + the bytes it reads. The exclusion instead happens in ReadItemAsync, where the query text is + ALREADY materialized for every row — a client-side Contains at zero SQL cost, identical + semantics. Self rows cross the wire (~2% of a busy database's rows) and are dropped before + they enter the batch (never stored, never counted against the byte budget). + (2) Expect a bursty cadence: Query Store buffers in memory and flushes to its persisted + tables on DATA_FLUSH_INTERVAL_SECONDS (default 900s), so roughly every third 5-minute cycle + returns a burst and the others return ~nothing. That is the SOURCE's behavior, not a + watermark bug — do NOT "fix" it by narrowing the poll interval. */ + var cutoffTime = context.Watermark ?? context.CollectionTime.AddMinutes(-60); + + var escapedDbName = item.Replace("]", "]]", StringComparison.Ordinal); + var text = $@" +EXECUTE [{escapedDbName}].sys.sp_executesql + N'SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + + SELECT /* PerformanceMonitorLite */ TOP ({MaxRowsPerDatabase}) + query_id = qsq.query_id, + plan_id = qsp.plan_id, + execution_type_desc = qsrs.execution_type_desc, + first_execution_time = qsrs.first_execution_time, + last_execution_time = qsrs.last_execution_time, + module_name = + CASE + WHEN qsq.object_id = 0 + THEN N''Adhoc'' + ELSE COALESCE( + OBJECT_SCHEMA_NAME(qsq.object_id) + N''.'' + OBJECT_NAME(qsq.object_id), + N''Unknown'') + END, + query_sql_text = qst.query_sql_text, + query_hash = CONVERT(varchar(64), qsq.query_hash, 1), + count_executions = qsrs.count_executions, + avg_duration = qsrs.avg_duration, + min_duration = qsrs.min_duration, + max_duration = qsrs.max_duration, + avg_cpu_time = qsrs.avg_cpu_time, + min_cpu_time = qsrs.min_cpu_time, + max_cpu_time = qsrs.max_cpu_time, + avg_logical_io_reads = qsrs.avg_logical_io_reads, + min_logical_io_reads = qsrs.min_logical_io_reads, + max_logical_io_reads = qsrs.max_logical_io_reads, + avg_logical_io_writes = qsrs.avg_logical_io_writes, + min_logical_io_writes = qsrs.min_logical_io_writes, + max_logical_io_writes = qsrs.max_logical_io_writes, + avg_physical_io_reads = qsrs.avg_physical_io_reads, + min_physical_io_reads = qsrs.min_physical_io_reads, + max_physical_io_reads = qsrs.max_physical_io_reads, + avg_clr_time = qsrs.avg_clr_time, + min_clr_time = qsrs.min_clr_time, + max_clr_time = qsrs.max_clr_time, + min_dop = qsrs.min_dop, + max_dop = qsrs.max_dop, + avg_query_max_used_memory = qsrs.avg_query_max_used_memory, + min_query_max_used_memory = qsrs.min_query_max_used_memory, + max_query_max_used_memory = qsrs.max_query_max_used_memory, + avg_rowcount = qsrs.avg_rowcount, + min_rowcount = qsrs.min_rowcount, + max_rowcount = qsrs.max_rowcount, + {numPhysIoReadsCols} + {logBytesCols} + {tempdbCols} + {planTypeCol} + {planForcingCol} + is_forced_plan = qsp.is_forced_plan, + force_failure_count = qsp.force_failure_count, + last_force_failure_reason = qsp.last_force_failure_reason_desc, + compatibility_level = qsp.compatibility_level, + {planTextCol} + query_plan_hash = CONVERT(varchar(64), qsp.query_plan_hash, 1), + {replicaRoleCol} + FROM sys.query_store_runtime_stats AS qsrs + JOIN sys.query_store_plan AS qsp + ON qsp.plan_id = qsrs.plan_id + JOIN sys.query_store_query AS qsq + ON qsq.query_id = qsp.query_id + JOIN sys.query_store_query_text AS qst + ON qst.query_text_id = qsq.query_text_id + {replicaJoin} + WHERE qsrs.last_execution_time > @cutoff_time + ORDER BY qsrs.last_execution_time DESC + OPTION(RECOMPILE, LOOP JOIN);', + N'@cutoff_time datetime2(7)', + @cutoff_time;"; + + return new CollectorQuery(text, new List<CollectorParameter> + { + new("@cutoff_time", cutoffTime, CollectorParameterType.DateTime2), + }); + } + + public override async ValueTask ReadItemAsync(string item, DbDataReader reader, List<Row> rows, CollectorContext context, CancellationToken cancellationToken) + { + /* Reset the per-item truncation signal — the host reads it immediately after this returns. */ + context.PerItemTextBudgetExceeded = false; + + /* Client-side cumulative BYTE budget (#1556) — the PRIMARY memory bound. The server-side TOP caps + the ROW COUNT, but a row carries two nvarchar(max) fields (query text + plan XML), so 50k rows + can still be gigabytes. Accumulate the materialized text size and STOP reading at the budget, + disposing the reader early, so one database can never balloon the process. */ + var budget = PerItemTextByteBudget ?? int.MaxValue; + long textBytes = 0; + + while (await reader.ReadAsync(cancellationToken)) + { + var row = new Row + { + DatabaseName = item, + QueryId = reader.GetInt64(0), + PlanId = reader.GetInt64(1), + ExecutionTypeDesc = reader.IsDBNull(2) ? null : reader.GetString(2), + FirstExecutionTime = reader.IsDBNull(3) ? null : ((DateTimeOffset)reader.GetValue(3)).UtcDateTime, + LastExecutionTime = reader.IsDBNull(4) ? null : ((DateTimeOffset)reader.GetValue(4)).UtcDateTime, + ModuleName = reader.IsDBNull(5) ? null : reader.GetString(5), + QueryText = reader.IsDBNull(6) ? null : reader.GetString(6), + QueryHash = reader.IsDBNull(7) ? null : reader.GetString(7), + ExecutionCount = reader.GetInt64(8), + AvgDurationUs = ReadNullableInt64(reader, 9), + MinDurationUs = ReadNullableInt64(reader, 10), + MaxDurationUs = ReadNullableInt64(reader, 11), + AvgCpuTimeUs = ReadNullableInt64(reader, 12), + MinCpuTimeUs = ReadNullableInt64(reader, 13), + MaxCpuTimeUs = ReadNullableInt64(reader, 14), + AvgLogicalIoReads = ReadNullableInt64(reader, 15), + MinLogicalIoReads = ReadNullableInt64(reader, 16), + MaxLogicalIoReads = ReadNullableInt64(reader, 17), + AvgLogicalIoWrites = ReadNullableInt64(reader, 18), + MinLogicalIoWrites = ReadNullableInt64(reader, 19), + MaxLogicalIoWrites = ReadNullableInt64(reader, 20), + AvgPhysicalIoReads = ReadNullableInt64(reader, 21), + MinPhysicalIoReads = ReadNullableInt64(reader, 22), + MaxPhysicalIoReads = ReadNullableInt64(reader, 23), + AvgClrTimeUs = ReadNullableInt64(reader, 24), + MinClrTimeUs = ReadNullableInt64(reader, 25), + MaxClrTimeUs = ReadNullableInt64(reader, 26), + MinDop = ReadNullableInt64(reader, 27), + MaxDop = ReadNullableInt64(reader, 28), + AvgQueryMaxUsedMemory = ReadNullableInt64(reader, 29), + MinQueryMaxUsedMemory = ReadNullableInt64(reader, 30), + MaxQueryMaxUsedMemory = ReadNullableInt64(reader, 31), + AvgRowcount = ReadNullableInt64(reader, 32), + MinRowcount = ReadNullableInt64(reader, 33), + MaxRowcount = ReadNullableInt64(reader, 34), + AvgNumPhysicalIoReads = ReadNullableInt64(reader, 35), + MinNumPhysicalIoReads = ReadNullableInt64(reader, 36), + MaxNumPhysicalIoReads = ReadNullableInt64(reader, 37), + AvgLogBytesUsed = ReadNullableInt64(reader, 38), + MinLogBytesUsed = ReadNullableInt64(reader, 39), + MaxLogBytesUsed = ReadNullableInt64(reader, 40), + AvgTempdbSpaceUsed = ReadNullableInt64(reader, 41), + MinTempdbSpaceUsed = ReadNullableInt64(reader, 42), + MaxTempdbSpaceUsed = ReadNullableInt64(reader, 43), + PlanType = reader.IsDBNull(44) ? null : reader.GetString(44), + PlanForcingType = reader.IsDBNull(45) ? null : reader.GetString(45), + IsForcedPlan = !reader.IsDBNull(46) && reader.GetBoolean(46), + ForceFailureCount = reader.IsDBNull(47) ? 0L : reader.GetInt64(47), + LastForceFailureReason = reader.IsDBNull(48) ? null : reader.GetString(48), + CompatibilityLevel = reader.IsDBNull(49) ? 0 : Convert.ToInt32(reader.GetValue(49), CultureInfo.InvariantCulture), + QueryPlanText = reader.IsDBNull(50) ? null : reader.GetString(50), + QueryPlanHash = reader.IsDBNull(51) ? null : reader.GetString(51), + ReplicaRole = reader.IsDBNull(52) ? null : reader.GetString(52), + }; + + /* Client-side self-exclusion (#1565): our own collector queries carry the marker in their + leading comment. Skipped rows never enter the batch — not stored, not counted against the + byte budget. This replaced the SQL-side NOT LIKE predicate, which was 75% of the read's + elapsed time (full nvarchar(max) scan per row; the field A/B measured 4.3x without it). */ + if (row.QueryText?.Contains(SelfQueryMarker, StringComparison.Ordinal) == true) + { + continue; + } + + rows.Add(row); + + /* char count × 2 for UTF-16. The plan XML is deduped server-side (NULL on all but the newest + interval per plan_id), but the query text repeats on every interval row, so this budget is + what bounds that repetition too. At the budget, signal truncation and stop — the host + surfaces the WARNING. Rows are read newest-first and the per-database watermark advances to + the newest KEPT row, so the older unread intervals are deliberately ABANDONED behind it + (bounded per cycle, by design) — not retried next cycle. */ + textBytes += ((long)(row.QueryText?.Length ?? 0) + (row.QueryPlanText?.Length ?? 0)) * 2L; + if (textBytes >= budget) + { + context.PerItemTextBudgetExceeded = true; + break; + } + } + } + + /// <summary>Never called — enumeration drives this collector.</summary> + public override ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + => throw new NotSupportedException("query_store enumerates databases; ReadItemAsync drives row reads."); + + /// <summary> + /// Reads a nullable int64, converting float/decimal Query Store values to long. + /// Query Store runtime_stats columns are stored as float in the catalog but represent + /// integer-scale values. + /// </summary> + private static long ReadNullableInt64(DbDataReader reader, int ordinal) + { + if (reader.IsDBNull(ordinal)) return 0L; + var value = reader.GetValue(ordinal); + return value switch + { + long l => l, + int i => i, + short s => s, + decimal d => (long)d, + double dbl => (long)dbl, + float f => (long)f, + _ => Convert.ToInt64(value, CultureInfo.InvariantCulture) + }; + } + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("database_name", CollectorColumnType.Varchar), + new CollectorColumn("query_id", CollectorColumnType.BigInt), + new CollectorColumn("plan_id", CollectorColumnType.BigInt), + new CollectorColumn("execution_type_desc", CollectorColumnType.Varchar), + new CollectorColumn("first_execution_time", CollectorColumnType.Timestamp), + new CollectorColumn("last_execution_time", CollectorColumnType.Timestamp), + new CollectorColumn("module_name", CollectorColumnType.Varchar), + new CollectorColumn("query_text", CollectorColumnType.Varchar), + new CollectorColumn("query_hash", CollectorColumnType.Varchar), + new CollectorColumn("execution_count", CollectorColumnType.BigInt), + new CollectorColumn("avg_duration_us", CollectorColumnType.BigInt), + new CollectorColumn("min_duration_us", CollectorColumnType.BigInt), + new CollectorColumn("max_duration_us", CollectorColumnType.BigInt), + new CollectorColumn("avg_cpu_time_us", CollectorColumnType.BigInt), + new CollectorColumn("min_cpu_time_us", CollectorColumnType.BigInt), + new CollectorColumn("max_cpu_time_us", CollectorColumnType.BigInt), + new CollectorColumn("avg_logical_io_reads", CollectorColumnType.BigInt), + new CollectorColumn("min_logical_io_reads", CollectorColumnType.BigInt), + new CollectorColumn("max_logical_io_reads", CollectorColumnType.BigInt), + new CollectorColumn("avg_logical_io_writes", CollectorColumnType.BigInt), + new CollectorColumn("min_logical_io_writes", CollectorColumnType.BigInt), + new CollectorColumn("max_logical_io_writes", CollectorColumnType.BigInt), + new CollectorColumn("avg_physical_io_reads", CollectorColumnType.BigInt), + new CollectorColumn("min_physical_io_reads", CollectorColumnType.BigInt), + new CollectorColumn("max_physical_io_reads", CollectorColumnType.BigInt), + new CollectorColumn("avg_clr_time_us", CollectorColumnType.BigInt), + new CollectorColumn("min_clr_time_us", CollectorColumnType.BigInt), + new CollectorColumn("max_clr_time_us", CollectorColumnType.BigInt), + new CollectorColumn("min_dop", CollectorColumnType.BigInt), + new CollectorColumn("max_dop", CollectorColumnType.BigInt), + new CollectorColumn("avg_query_max_used_memory", CollectorColumnType.BigInt), + new CollectorColumn("min_query_max_used_memory", CollectorColumnType.BigInt), + new CollectorColumn("max_query_max_used_memory", CollectorColumnType.BigInt), + new CollectorColumn("avg_rowcount", CollectorColumnType.BigInt), + new CollectorColumn("min_rowcount", CollectorColumnType.BigInt), + new CollectorColumn("max_rowcount", CollectorColumnType.BigInt), + new CollectorColumn("avg_num_physical_io_reads", CollectorColumnType.BigInt), + new CollectorColumn("min_num_physical_io_reads", CollectorColumnType.BigInt), + new CollectorColumn("max_num_physical_io_reads", CollectorColumnType.BigInt), + new CollectorColumn("avg_log_bytes_used", CollectorColumnType.BigInt), + new CollectorColumn("min_log_bytes_used", CollectorColumnType.BigInt), + new CollectorColumn("max_log_bytes_used", CollectorColumnType.BigInt), + new CollectorColumn("avg_tempdb_space_used", CollectorColumnType.BigInt), + new CollectorColumn("min_tempdb_space_used", CollectorColumnType.BigInt), + new CollectorColumn("max_tempdb_space_used", CollectorColumnType.BigInt), + new CollectorColumn("plan_type", CollectorColumnType.Varchar), + new CollectorColumn("plan_forcing_type", CollectorColumnType.Varchar), + new CollectorColumn("is_forced_plan", CollectorColumnType.Boolean), + new CollectorColumn("force_failure_count", CollectorColumnType.BigInt), + new CollectorColumn("last_force_failure_reason", CollectorColumnType.Varchar), + new CollectorColumn("compatibility_level", CollectorColumnType.Integer), + new CollectorColumn("query_plan_text", CollectorColumnType.Varchar), + new CollectorColumn("query_plan_hash", CollectorColumnType.Varchar), + /* Appended at the END, not grouped with the identity columns: both hosts' bulk writers are + POSITIONAL (Lite's DuckDB appender, Darling's Npgsql binary COPY), and an upgraded store gets + this column from an ALTER TABLE ADD COLUMN, which can only append. A mid-list position would + put it mid-table on a FRESH store (whose DDL is generated from this list) and last on an + UPGRADED one — the same COPY then writing shifted values on one of them. Same reasoning as + deadlocks.database_name (Darling V27 / Lite v46). */ + new CollectorColumn("replica_role", CollectorColumnType.Varchar), + }; + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + writer + .Value(row.DatabaseName) + .Value(row.QueryId) + .Value(row.PlanId) + .Value(row.ExecutionTypeDesc) + .Value(row.FirstExecutionTime) + .Value(row.LastExecutionTime) + .Value(row.ModuleName) + .Value(row.QueryText) + .Value(row.QueryHash) + .Value(row.ExecutionCount) + .Value(row.AvgDurationUs) + .Value(row.MinDurationUs) + .Value(row.MaxDurationUs) + .Value(row.AvgCpuTimeUs) + .Value(row.MinCpuTimeUs) + .Value(row.MaxCpuTimeUs) + .Value(row.AvgLogicalIoReads) + .Value(row.MinLogicalIoReads) + .Value(row.MaxLogicalIoReads) + .Value(row.AvgLogicalIoWrites) + .Value(row.MinLogicalIoWrites) + .Value(row.MaxLogicalIoWrites) + .Value(row.AvgPhysicalIoReads) + .Value(row.MinPhysicalIoReads) + .Value(row.MaxPhysicalIoReads) + .Value(row.AvgClrTimeUs) + .Value(row.MinClrTimeUs) + .Value(row.MaxClrTimeUs) + .Value(row.MinDop) + .Value(row.MaxDop) + .Value(row.AvgQueryMaxUsedMemory) + .Value(row.MinQueryMaxUsedMemory) + .Value(row.MaxQueryMaxUsedMemory) + .Value(row.AvgRowcount) + .Value(row.MinRowcount) + .Value(row.MaxRowcount) + .Value(row.AvgNumPhysicalIoReads) + .Value(row.MinNumPhysicalIoReads) + .Value(row.MaxNumPhysicalIoReads) + .Value(row.AvgLogBytesUsed) + .Value(row.MinLogBytesUsed) + .Value(row.MaxLogBytesUsed) + .Value(row.AvgTempdbSpaceUsed) + .Value(row.MinTempdbSpaceUsed) + .Value(row.MaxTempdbSpaceUsed) + .Value(row.PlanType) + .Value(row.PlanForcingType) + .Value(row.IsForcedPlan) + .Value(row.ForceFailureCount) + .Value(row.LastForceFailureReason) + .Value(row.CompatibilityLevel) + .Value(row.QueryPlanText) + .Value(row.QueryPlanHash) + .Value(row.ReplicaRole); + } +} diff --git a/PerformanceMonitor.Collectors/RunningJobsCollector.cs b/PerformanceMonitor.Collectors/RunningJobsCollector.cs new file mode 100644 index 000000000..9c0d4a38b --- /dev/null +++ b/PerformanceMonitor.Collectors/RunningJobsCollector.cs @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Currently running SQL Agent jobs with historical duration comparison (p95 over the last 30 +/// days of successful outcome rows; flags jobs past their p95). Extracted verbatim from Lite's +/// RemoteCollectorService.RunningJobs.cs. Note: running_jobs is the one table without a +/// collection_id — <see cref="IncludesCollectionId"/> is false. The failed-jobs alert query +/// (GetRecentlyFailedJobsAsync) is alert-engine surface and lives in the alerting library +/// (<c>PerformanceMonitor.Alerting.FailedJobsQuery</c>, Phase-5 slice E), not here. +/// </summary> +public sealed class RunningJobsCollector : CollectorDefinitionBase<RunningJobsCollector.Row> +{ + public static RunningJobsCollector Instance { get; } = new(); + + private RunningJobsCollector() + { + } + + public readonly record struct Row( + string JobName, + string JobId, + bool JobEnabled, + DateTime StartTime, + long CurrentDurationSeconds, + long AvgDurationSeconds, + long P95DurationSeconds, + long SuccessfulRunCount, + bool IsRunningLong, + decimal? PercentOfAverage); + + private const string QueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +WITH + running_jobs AS +( + SELECT + job_name = j.name, + job_id = CONVERT(varchar(36), j.job_id), + job_enabled = j.enabled, + start_time = ja.start_execution_date, + current_duration_seconds = + DATEDIFF(SECOND, ja.start_execution_date, GETDATE()) + FROM msdb.dbo.sysjobactivity AS ja + JOIN msdb.dbo.sysjobs AS j + ON j.job_id = ja.job_id + JOIN msdb.dbo.syssessions AS s + ON s.session_id = ja.session_id + WHERE ja.start_execution_date IS NOT NULL + AND ja.stop_execution_date IS NULL + AND s.agent_start_date = + ( + SELECT + MAX(s2.agent_start_date) + FROM msdb.dbo.syssessions AS s2 + ) +), + job_history_raw AS +( + SELECT + job_id = CONVERT(varchar(36), jh.job_id), + duration_seconds = + (jh.run_duration / 10000) * 3600 + + ((jh.run_duration / 100) % 100) * 60 + + (jh.run_duration % 100), + p95_duration_seconds = + CONVERT(bigint, + PERCENTILE_CONT(0.95) WITHIN GROUP + ( + ORDER BY + (jh.run_duration / 10000) * 3600 + + ((jh.run_duration / 100) % 100) * 60 + + (jh.run_duration % 100) + ) OVER + ( + PARTITION BY + jh.job_id + ) + ) + FROM msdb.dbo.sysjobhistory AS jh + WHERE jh.step_id = 0 + AND jh.run_status = 1 + AND jh.run_date >= CONVERT(integer, CONVERT(varchar(8), DATEADD(DAY, -30, GETDATE()), 112)) +), + job_history_stats AS +( + SELECT + job_id = jhr.job_id, + avg_duration_seconds = AVG(jhr.duration_seconds), + p95_duration_seconds = MAX(jhr.p95_duration_seconds), + successful_run_count = COUNT_BIG(*) + FROM job_history_raw AS jhr + GROUP BY + jhr.job_id +) +SELECT + rj.job_name, + rj.job_id, + rj.job_enabled, + rj.start_time, + rj.current_duration_seconds, + avg_duration_seconds = ISNULL(jhs.avg_duration_seconds, 0), + p95_duration_seconds = ISNULL(jhs.p95_duration_seconds, 0), + successful_run_count = ISNULL(jhs.successful_run_count, 0), + is_running_long = + CASE + WHEN jhs.p95_duration_seconds > 0 + AND rj.current_duration_seconds > jhs.p95_duration_seconds + THEN CONVERT(bit, 1) + ELSE CONVERT(bit, 0) + END, + percent_of_average = + CASE + WHEN jhs.avg_duration_seconds > 0 + THEN CONVERT(decimal(10,1), rj.current_duration_seconds * 100.0 / jhs.avg_duration_seconds) + ELSE NULL + END +FROM running_jobs AS rj +LEFT JOIN job_history_stats AS jhs + ON jhs.job_id = rj.job_id +ORDER BY + rj.current_duration_seconds DESC +OPTION(RECOMPILE);"; + + public override string Name => "running_jobs"; + + public override string TargetTable => "running_jobs"; + + /// <summary>running_jobs is keyed by (collection_time, server) alone — no collection_id.</summary> + public override bool IncludesCollectionId => false; + + /// <summary> + /// Azure SQL Database has no SQL Agent — <c>msdb.dbo.sysjobactivity</c> is unreachable there + /// (the three-part msdb reference raises "not supported in this version of SQL Server"), so skip + /// the collector rather than log a per-cycle ERROR. Managed Instance (edition 8) DOES have Agent, + /// so it collects there; on-prem collects too. AWS RDS is skipped: this query joins + /// <c>msdb.dbo.syssessions</c>, which RDS blocks with "SELECT permission was denied" even for the + /// master login (the table requires sysadmin, which RDS never grants), so it would ERROR every cycle. + /// A login without msdb access is also skipped: every table this reads lives in msdb. All three gates + /// live here in the shared AppliesTo — the single authoritative gate both SKUs consult (mirrors the + /// failed-jobs alert path's Azure/msdb skip). + /// </summary> + public override bool AppliesTo(CollectorTargetInfo target) => + !target.IsAzureSqlDb && !target.IsAwsRds && target.HasMsdbAccess; + + public override CollectorQuery BuildQuery(CollectorContext context) => new(QueryText); + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("job_name", CollectorColumnType.Varchar), + new CollectorColumn("job_id", CollectorColumnType.Varchar), + new CollectorColumn("job_enabled", CollectorColumnType.Boolean), + new CollectorColumn("start_time", CollectorColumnType.Timestamp), + new CollectorColumn("current_duration_seconds", CollectorColumnType.BigInt), + new CollectorColumn("avg_duration_seconds", CollectorColumnType.BigInt), + new CollectorColumn("p95_duration_seconds", CollectorColumnType.BigInt), + new CollectorColumn("successful_run_count", CollectorColumnType.BigInt), + new CollectorColumn("is_running_long", CollectorColumnType.Boolean), + new CollectorColumn("percent_of_average", CollectorColumnType.Decimal, 10, 1), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var rows = new List<Row>(); + + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new Row( + reader.GetString(0), + reader.GetString(1), + Convert.ToBoolean(reader.GetValue(2), CultureInfo.InvariantCulture), + reader.GetDateTime(3), + Convert.ToInt64(reader.GetValue(4), CultureInfo.InvariantCulture), + Convert.ToInt64(reader.GetValue(5), CultureInfo.InvariantCulture), + Convert.ToInt64(reader.GetValue(6), CultureInfo.InvariantCulture), + Convert.ToInt64(reader.GetValue(7), CultureInfo.InvariantCulture), + Convert.ToBoolean(reader.GetValue(8), CultureInfo.InvariantCulture), + reader.IsDBNull(9) ? null : Convert.ToDecimal(reader.GetValue(9), CultureInfo.InvariantCulture))); + } + + return rows; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + writer + .Value(row.JobName) /* job_name VARCHAR */ + .Value(row.JobId) /* job_id VARCHAR */ + .Value(row.JobEnabled) /* job_enabled BOOLEAN */ + .Value(row.StartTime) /* start_time TIMESTAMP */ + .Value(row.CurrentDurationSeconds) /* current_duration_seconds BIGINT */ + .Value(row.AvgDurationSeconds) /* avg_duration_seconds BIGINT */ + .Value(row.P95DurationSeconds) /* p95_duration_seconds BIGINT */ + .Value(row.SuccessfulRunCount) /* successful_run_count BIGINT */ + .Value(row.IsRunningLong) /* is_running_long BOOLEAN */ + .Value(row.PercentOfAverage); /* percent_of_average DECIMAL(10,1) */ + } +} diff --git a/PerformanceMonitor.Collectors/ServerConfigCollector.cs b/PerformanceMonitor.Collectors/ServerConfigCollector.cs new file mode 100644 index 000000000..a9a1be460 --- /dev/null +++ b/PerformanceMonitor.Collectors/ServerConfigCollector.cs @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Server configuration from sys.configurations (on-load only, not scheduled). Extracted +/// verbatim from Lite's RemoteCollectorService.ServerConfig.cs. +/// </summary> +public sealed class ServerConfigCollector : CollectorDefinitionBase<ServerConfigCollector.Row> +{ + public static ServerConfigCollector Instance { get; } = new(); + + private ServerConfigCollector() + { + } + + public readonly record struct Row(string Name, long ValueConfigured, long ValueInUse, bool IsDynamic, bool IsAdvanced); + + private const string QueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + configuration_name = c.name, + value_configured = CONVERT(bigint, c.value), + value_in_use = CONVERT(bigint, c.value_in_use), + is_dynamic = c.is_dynamic, + is_advanced = c.is_advanced +FROM sys.configurations AS c +ORDER BY c.name +OPTION(RECOMPILE);"; + + public override string Name => "server_config"; + + public override string TargetTable => "server_config"; + + /// <summary>The config snapshots' prefix is config_id/capture_time in Lite's schema; Darling mirrors it.</summary> + public override string PrefixIdColumnName => "config_id"; + + public override string PrefixTimeColumnName => "capture_time"; + + /// <summary> + /// NOT Azure SQL Database (edition 5): <c>sys.configurations</c> is not exposed there, so the query + /// errors once per connect. Azure SQL Managed Instance (edition 8) and on-prem both have it, so they + /// collect. Gated here in the shared AppliesTo so Lite and Darling skip identically — before this the + /// gate lived only in Lite's IsCollectorSupported and Darling threw on connect. + /// </summary> + public override bool AppliesTo(CollectorTargetInfo target) => !target.IsAzureSqlDb; + + public override CollectorQuery BuildQuery(CollectorContext context) => new(QueryText); + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("configuration_name", CollectorColumnType.Varchar), + new CollectorColumn("value_configured", CollectorColumnType.BigInt), + new CollectorColumn("value_in_use", CollectorColumnType.BigInt), + new CollectorColumn("is_dynamic", CollectorColumnType.Boolean), + new CollectorColumn("is_advanced", CollectorColumnType.Boolean), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var rows = new List<Row>(); + + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new Row( + reader.GetString(0), + reader.GetInt64(1), + reader.GetInt64(2), + reader.GetBoolean(3), + reader.GetBoolean(4))); + } + + return rows; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + writer + .Value(row.Name) /* configuration_name VARCHAR */ + .Value(row.ValueConfigured) /* value_configured BIGINT */ + .Value(row.ValueInUse) /* value_in_use BIGINT */ + .Value(row.IsDynamic) /* is_dynamic BOOLEAN */ + .Value(row.IsAdvanced); /* is_advanced BOOLEAN */ + } +} diff --git a/PerformanceMonitor.Collectors/ServerPropertiesCollector.cs b/PerformanceMonitor.Collectors/ServerPropertiesCollector.cs new file mode 100644 index 000000000..0b11ac4ab --- /dev/null +++ b/PerformanceMonitor.Collectors/ServerPropertiesCollector.cs @@ -0,0 +1,385 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Server edition, version, and CPU/memory hardware metadata for license audit and FinOps cost +/// attribution (on-load-only collector). Extracted verbatim from Lite's +/// RemoteCollectorService.ServerProperties.cs, including: the Azure SQL DB edition/service-tier +/// naming, the vCore parse from the service objective (dm_os_sys_info.cpu_count reports the +/// compute node's cores on Azure, not the per-database allocation), and the WS5 server-health +/// probe (LPIM / IFI / memory-dump count) as a best-effort SUPPLEMENTAL query — its failure can +/// never fail the properties row, mirroring install/53_collect_server_properties.sql. +/// </summary> +public sealed class ServerPropertiesCollector : CollectorDefinitionBase<ServerPropertiesCollector.Row> +{ + public static ServerPropertiesCollector Instance { get; } = new(); + + private ServerPropertiesCollector() + { + } + + public readonly record struct Row( + string Edition, + string ProductVersion, + string ProductLevel, + string? ProductUpdateLevel, + int EngineEdition, + int CpuCount, + int HyperthreadRatio, + long PhysicalMemoryMb, + int? SocketCount, + int? CoresPerSocket, + bool? IsHadrEnabled, + bool? IsClustered, + string? ServiceObjective, + int? VcoreCount, + bool? LockPagesInMemory, + bool? InstantFileInitializationEnabled, + int? MemoryDumpCount, + DateTime? SqlServerStartTime, + string? HostOsVersion, + string? AgReplicaRole, + int? UtcOffsetMinutes); + + private const string QueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +/* Host OS + AG replica role are resolved into locals first, each behind an OBJECT_ID guard so the + batch still compiles + runs on Azure SQL Database and non-AG instances (the DMVs are absent there) + — the deferred-object-ref pattern, NOT a version guard (#980). Both leave a benign default when + unavailable: host OS falls back to the @@VERSION 'on ...' suffix; AG role stays 'Standalone'. */ +DECLARE + @host_os nvarchar(256), + @ag_role nvarchar(20) = N'Standalone'; + +IF OBJECT_ID(N'sys.dm_os_host_info', N'V') IS NOT NULL + EXEC sys.sp_executesql N'SELECT @os = host_distribution FROM sys.dm_os_host_info', + N'@os nvarchar(256) OUTPUT', @os = @host_os OUTPUT; + +IF @host_os IS NULL +BEGIN + DECLARE @ver nvarchar(4000) = @@VERSION; + DECLARE @on_pos int = CHARINDEX(N' on ', @ver); + IF @on_pos > 0 + SET @host_os = LTRIM(SUBSTRING(@ver, @on_pos + 4, LEN(@ver))); +END; + +IF CONVERT(int, ISNULL(SERVERPROPERTY(N'IsHadrEnabled'), 0)) = 1 + AND OBJECT_ID(N'sys.dm_hadr_availability_replica_states') IS NOT NULL +BEGIN + DECLARE @ag_detected nvarchar(20); + EXEC sys.sp_executesql N' + SELECT @r = CASE + WHEN MAX(CASE WHEN ars.role = 1 THEN 1 ELSE 0 END) = 1 THEN N''Primary'' + WHEN MAX(CASE WHEN ars.role = 2 THEN 1 ELSE 0 END) = 1 THEN N''Secondary'' + ELSE N''Standalone'' END + FROM sys.dm_hadr_availability_replica_states AS ars + WHERE ars.is_local = 1;', + N'@r nvarchar(20) OUTPUT', @r = @ag_detected OUTPUT; + SET @ag_role = ISNULL(@ag_detected, N'Standalone'); +END; + +SELECT + server_name = + CONVERT(nvarchar(128), SERVERPROPERTY(N'ServerName')), + edition = + /* Azure SQL DB reports the legacy 'SQL Azure' for SERVERPROPERTY('Edition'); + store the actual product name + service tier instead. */ + CASE + WHEN CONVERT(int, SERVERPROPERTY(N'EngineEdition')) = 5 + THEN N'Azure SQL Database' + + ISNULL(N' (' + + CASE CONVERT(nvarchar(128), DATABASEPROPERTYEX(DB_NAME(), N'Edition')) + WHEN N'GeneralPurpose' THEN N'General Purpose' + WHEN N'BusinessCritical' THEN N'Business Critical' + ELSE CONVERT(nvarchar(128), DATABASEPROPERTYEX(DB_NAME(), N'Edition')) + END + N')', N'') + ELSE CONVERT(nvarchar(128), SERVERPROPERTY(N'Edition')) + END, + product_version = + CONVERT(nvarchar(128), SERVERPROPERTY(N'ProductVersion')), + product_level = + CONVERT(nvarchar(128), SERVERPROPERTY(N'ProductLevel')), + product_update_level = + CONVERT(nvarchar(128), SERVERPROPERTY(N'ProductUpdateLevel')), + engine_edition = + CONVERT(int, SERVERPROPERTY(N'EngineEdition')), + cpu_count = + osi.cpu_count, + hyperthread_ratio = + osi.hyperthread_ratio, + physical_memory_mb = + osi.physical_memory_kb / 1024, + socket_count = + osi.socket_count, + cores_per_socket = + osi.cores_per_socket, + is_hadr_enabled = + CONVERT(bit, SERVERPROPERTY(N'IsHadrEnabled')), + is_clustered = + CONVERT(bit, SERVERPROPERTY(N'IsClustered')), + service_objective = + CASE + WHEN CONVERT(int, SERVERPROPERTY(N'EngineEdition')) = 5 + THEN CONVERT(nvarchar(128), DATABASEPROPERTYEX(DB_NAME(), N'ServiceObjective')) + ELSE NULL + END, + sqlserver_start_time = + osi.sqlserver_start_time, + host_os_version = + @host_os, + ag_replica_role = + @ag_role, + /* The monitored server's UTC offset in minutes — the same live derivation Lite computes at + connect (ServerManager). Collected so the headless viewer, which cannot query the target + live, can render timestamps in the server's own local time (Server-time display mode). */ + utc_offset_minutes = + DATEDIFF(MINUTE, GETUTCDATE(), GETDATE()) +FROM sys.dm_os_sys_info AS osi +OPTION(RECOMPILE);"; + + private const string ServerHealthQueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +DECLARE + @lpim bit = NULL, + @ifi bit = NULL, + @dumps integer = NULL; + +BEGIN TRY + SELECT + @lpim = + CASE + WHEN osi.sql_memory_model IN (2, 3) + THEN CONVERT(bit, 1) + ELSE CONVERT(bit, 0) + END + FROM sys.dm_os_sys_info AS osi; +END TRY +BEGIN CATCH + SET @lpim = NULL; +END CATCH; + +IF OBJECT_ID(N'sys.dm_server_services', N'V') IS NOT NULL +AND EXISTS +( + SELECT + 1/0 + FROM sys.system_columns AS sc + WHERE sc.object_id = OBJECT_ID(N'sys.dm_server_services') + AND sc.name = N'instant_file_initialization_enabled' +) +BEGIN + BEGIN TRY + DECLARE + @ifi_sql nvarchar(max) = + N' + SELECT TOP (1) + @ifi_out = + CASE + WHEN ss.instant_file_initialization_enabled = N''Y'' + THEN CONVERT(bit, 1) + WHEN ss.instant_file_initialization_enabled = N''N'' + THEN CONVERT(bit, 0) + ELSE NULL + END + FROM sys.dm_server_services AS ss + WHERE ss.servicename LIKE N''SQL Server (%'';'; + + EXECUTE sys.sp_executesql + @ifi_sql, + N'@ifi_out bit OUTPUT', + @ifi_out = @ifi OUTPUT; + END TRY + BEGIN CATCH + SET @ifi = NULL; + END CATCH; +END; + +IF OBJECT_ID(N'sys.dm_server_memory_dumps', N'V') IS NOT NULL +BEGIN + BEGIN TRY + SELECT + @dumps = COUNT_BIG(*) + FROM sys.dm_server_memory_dumps AS smd; + END TRY + BEGIN CATCH + SET @dumps = NULL; + END CATCH; +END; + +SELECT + lock_pages_in_memory = @lpim, + instant_file_initialization_enabled = @ifi, + memory_dump_count = @dumps;"; + + public override string Name => "server_properties"; + + public override string TargetTable => "server_properties"; + + public override CollectorQuery BuildQuery(CollectorContext context) => new(QueryText); + + /// <summary>WS5 health probe — meaningless/unavailable on Azure SQL DB, so skipped there.</summary> + public override CollectorQuery? BuildSupplementalQuery(CollectorContext context) + => context.Target.IsAzureSqlDb ? null : new CollectorQuery(ServerHealthQueryText); + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("edition", CollectorColumnType.Varchar), + new CollectorColumn("product_version", CollectorColumnType.Varchar), + new CollectorColumn("product_level", CollectorColumnType.Varchar), + new CollectorColumn("product_update_level", CollectorColumnType.Varchar), + new CollectorColumn("engine_edition", CollectorColumnType.Integer), + new CollectorColumn("cpu_count", CollectorColumnType.Integer), + new CollectorColumn("hyperthread_ratio", CollectorColumnType.Integer), + new CollectorColumn("physical_memory_mb", CollectorColumnType.BigInt), + new CollectorColumn("socket_count", CollectorColumnType.Integer), + new CollectorColumn("cores_per_socket", CollectorColumnType.Integer), + new CollectorColumn("is_hadr_enabled", CollectorColumnType.Boolean), + new CollectorColumn("is_clustered", CollectorColumnType.Boolean), + new CollectorColumn("enterprise_features", CollectorColumnType.Varchar), + new CollectorColumn("service_objective", CollectorColumnType.Varchar), + new CollectorColumn("vcore_count", CollectorColumnType.Integer), + new CollectorColumn("lock_pages_in_memory", CollectorColumnType.Boolean), + new CollectorColumn("instant_file_initialization_enabled", CollectorColumnType.Boolean), + new CollectorColumn("memory_dump_count", CollectorColumnType.Integer), + /* Appended (never inserted) so a fresh generated V1 store + an ALTER-migrated store keep an + identical physical column order for the positional writers (Lite DuckDB appender / Darling + binary COPY). Restores the three fields Lite's FinOps Server Inventory previously read from a + LIVE query — now collected so the headless viewer surfaces them too. */ + new CollectorColumn("sqlserver_start_time", CollectorColumnType.Timestamp), + new CollectorColumn("host_os_version", CollectorColumnType.Varchar), + new CollectorColumn("ag_replica_role", CollectorColumnType.Varchar), + /* Appended (never inserted) so a fresh V1 store and an ALTER-migrated store keep an identical + physical column order for the positional writers. The monitored server's UTC offset in + minutes — the viewer's Server-time display mode reads it (UTC + offset = server local). */ + new CollectorColumn("utc_offset_minutes", CollectorColumnType.Integer), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var rows = new List<Row>(); + + if (!await reader.ReadAsync(cancellationToken)) + { + return rows; + } + + var serviceObjective = reader.IsDBNull(13) ? null : reader.GetString(13); + /* sqlserver_start_time (14) is the server's LOCAL wall clock (sys.dm_os_sys_info) — stored + verbatim like every other DMV clock. host_os_version (15) / ag_replica_role (16) are the + OBJECT_ID-guarded locals (host OS + AG role), NULL/'Standalone' when the DMVs are absent. */ + var sqlServerStartTime = reader.IsDBNull(14) ? (DateTime?)null : reader.GetDateTime(14); + var hostOsVersion = reader.IsDBNull(15) ? null : reader.GetString(15); + var agReplicaRole = reader.IsDBNull(16) ? null : reader.GetString(16); + /* utc_offset_minutes (17): DATEDIFF(MINUTE, GETUTCDATE(), GETDATE()) — always non-null in + practice, guarded anyway for a defensive read. */ + var utcOffsetMinutes = reader.IsDBNull(17) ? (int?)null : reader.GetInt32(17); + + /* For Azure SQL DB, sys.dm_os_sys_info.cpu_count returns the compute node's total cores, + not the per-database vCore allocation. Parse the actual vCore count from the service + objective string (e.g. "HS_Gen5_14" → 14). */ + int? vcoreCount = null; + if (context.Target.IsAzureSqlDb && !string.IsNullOrEmpty(serviceObjective)) + { + vcoreCount = ParseVcoreFromServiceObjective(serviceObjective); + } + + rows.Add(new Row( + Edition: reader.GetString(1), + ProductVersion: reader.GetString(2), + ProductLevel: reader.GetString(3), + ProductUpdateLevel: reader.IsDBNull(4) ? null : reader.GetString(4), + EngineEdition: reader.GetInt32(5), + CpuCount: reader.GetInt32(6), + HyperthreadRatio: reader.GetInt32(7), + PhysicalMemoryMb: reader.GetInt64(8), + SocketCount: reader.IsDBNull(9) ? null : reader.GetInt32(9), + CoresPerSocket: reader.IsDBNull(10) ? null : reader.GetInt32(10), + IsHadrEnabled: reader.IsDBNull(11) ? null : reader.GetBoolean(11), + IsClustered: reader.IsDBNull(12) ? null : reader.GetBoolean(12), + ServiceObjective: serviceObjective, + VcoreCount: vcoreCount, + LockPagesInMemory: null, + InstantFileInitializationEnabled: null, + MemoryDumpCount: null, + SqlServerStartTime: sqlServerStartTime, + HostOsVersion: hostOsVersion, + AgReplicaRole: agReplicaRole, + UtcOffsetMinutes: utcOffsetMinutes)); + + return rows; + } + + public override async ValueTask ApplySupplementalAsync(List<Row> rows, DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + if (rows.Count == 0 || !await reader.ReadAsync(cancellationToken)) + { + return; + } + + rows[0] = rows[0] with + { + LockPagesInMemory = reader.IsDBNull(0) ? null : reader.GetBoolean(0), + InstantFileInitializationEnabled = reader.IsDBNull(1) ? null : reader.GetBoolean(1), + MemoryDumpCount = reader.IsDBNull(2) ? null : reader.GetInt32(2), + }; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + writer + .Value(row.Edition) + .Value(row.ProductVersion) + .Value(row.ProductLevel) + .Value(row.ProductUpdateLevel) + .Value(row.EngineEdition) + .Value(row.CpuCount) + .Value(row.HyperthreadRatio) + .Value(row.PhysicalMemoryMb) + .Value(row.SocketCount) + .Value(row.CoresPerSocket) + .Value(row.IsHadrEnabled) + .Value(row.IsClustered) + .Value((string?)null) /* enterprise_features — not collected in Lite (requires cross-database cursor) */ + .Value(row.ServiceObjective) + .Value(row.VcoreCount) + .Value(row.LockPagesInMemory) + .Value(row.InstantFileInitializationEnabled) + .Value(row.MemoryDumpCount) + .Value(row.SqlServerStartTime) + .Value(row.HostOsVersion) + .Value(row.AgReplicaRole) + .Value(row.UtcOffsetMinutes); + } + + /// <summary> + /// Parses the vCore count from an Azure SQL DB service objective string. + /// vCore tiers follow the pattern {Tier}_{Gen}_{VcoreCount} + /// (e.g. "HS_Gen5_14", "GP_Gen5_6", "BC_Gen5_8", "GP_S_Gen5_2"). + /// DTU tiers (e.g. "P1", "S0") and elastic pools return null. + /// </summary> + public static int? ParseVcoreFromServiceObjective(string serviceObjective) + { + var parts = serviceObjective.Split('_'); + if (parts.Length >= 3 && int.TryParse(parts[^1], out var vcores) && vcores > 0) + { + return vcores; + } + + return null; + } +} diff --git a/PerformanceMonitor.Collectors/SessionStatsCollector.cs b/PerformanceMonitor.Collectors/SessionStatsCollector.cs new file mode 100644 index 000000000..6035250fe --- /dev/null +++ b/PerformanceMonitor.Collectors/SessionStatsCollector.cs @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Per-application session statistics from sys.dm_exec_sessions, grouped by program_name +/// (connection counts, status breakdown, cumulative resource usage). Extracted verbatim from +/// Lite's RemoteCollectorService.SessionStats.cs. +/// </summary> +public sealed class SessionStatsCollector : CollectorDefinitionBase<SessionStatsCollector.Row> +{ + public static SessionStatsCollector Instance { get; } = new(); + + private SessionStatsCollector() + { + } + + public readonly record struct Row( + string ProgramName, + long ConnectionCount, + int RunningCount, + int SleepingCount, + int DormantCount, + long? TotalCpuTimeMs, + long? TotalReads, + long? TotalWrites, + long? TotalLogicalReads); + + private const string QueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + program_name = + ISNULL(des.program_name, N''), + connection_count = + COUNT_BIG(*), + running_count = + SUM + ( + CASE + WHEN des.status = N'running' + THEN 1 + ELSE 0 + END + ), + sleeping_count = + SUM + ( + CASE + WHEN des.status = N'sleeping' + THEN 1 + ELSE 0 + END + ), + dormant_count = + SUM + ( + CASE + WHEN des.status = N'dormant' + THEN 1 + ELSE 0 + END + ), + total_cpu_time_ms = + SUM(des.cpu_time), + total_reads = + SUM(des.reads), + total_writes = + SUM(des.writes), + total_logical_reads = + SUM(des.logical_reads) +FROM sys.dm_exec_sessions AS des +WHERE des.session_id > 50 +AND des.is_user_process = 1 +AND des.program_name IS NOT NULL +AND des.program_name <> N'' +AND des.program_name NOT LIKE N'PerformanceMonitor%' +GROUP BY + des.program_name +ORDER BY + COUNT_BIG(*) DESC +OPTION(RECOMPILE);"; + + public override string Name => "session_stats"; + + public override string TargetTable => "session_stats"; + + public override CollectorQuery BuildQuery(CollectorContext context) => new(QueryText); + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("program_name", CollectorColumnType.Varchar), + new CollectorColumn("connection_count", CollectorColumnType.BigInt), + new CollectorColumn("running_count", CollectorColumnType.Integer), + new CollectorColumn("sleeping_count", CollectorColumnType.Integer), + new CollectorColumn("dormant_count", CollectorColumnType.Integer), + new CollectorColumn("total_cpu_time_ms", CollectorColumnType.BigInt), + new CollectorColumn("total_reads", CollectorColumnType.BigInt), + new CollectorColumn("total_writes", CollectorColumnType.BigInt), + new CollectorColumn("total_logical_reads", CollectorColumnType.BigInt), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var rows = new List<Row>(); + + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new Row( + reader.GetString(0), + Convert.ToInt64(reader.GetValue(1), CultureInfo.InvariantCulture), + Convert.ToInt32(reader.GetValue(2), CultureInfo.InvariantCulture), + Convert.ToInt32(reader.GetValue(3), CultureInfo.InvariantCulture), + Convert.ToInt32(reader.GetValue(4), CultureInfo.InvariantCulture), + reader.IsDBNull(5) ? null : Convert.ToInt64(reader.GetValue(5), CultureInfo.InvariantCulture), + reader.IsDBNull(6) ? null : Convert.ToInt64(reader.GetValue(6), CultureInfo.InvariantCulture), + reader.IsDBNull(7) ? null : Convert.ToInt64(reader.GetValue(7), CultureInfo.InvariantCulture), + reader.IsDBNull(8) ? null : Convert.ToInt64(reader.GetValue(8), CultureInfo.InvariantCulture))); + } + + return rows; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + writer + .Value(row.ProgramName) /* program_name VARCHAR */ + .Value(row.ConnectionCount) /* connection_count BIGINT */ + .Value(row.RunningCount) /* running_count INTEGER */ + .Value(row.SleepingCount) /* sleeping_count INTEGER */ + .Value(row.DormantCount) /* dormant_count INTEGER */ + .Value(row.TotalCpuTimeMs) /* total_cpu_time_ms BIGINT */ + .Value(row.TotalReads) /* total_reads BIGINT */ + .Value(row.TotalWrites) /* total_writes BIGINT */ + .Value(row.TotalLogicalReads); /* total_logical_reads BIGINT */ + } +} diff --git a/PerformanceMonitor.Collectors/SessionSummaryStatsCollector.cs b/PerformanceMonitor.Collectors/SessionSummaryStatsCollector.cs new file mode 100644 index 000000000..4fe8e4792 --- /dev/null +++ b/PerformanceMonitor.Collectors/SessionSummaryStatsCollector.cs @@ -0,0 +1,251 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Server-wide session SUMMARY snapshot from sys.dm_exec_sessions (+ sys.dm_exec_requests for the +/// memory-wait count) — the connection-leak / idle-session signal that is the Dashboard-parity port +/// of install/36_collect_session_stats.sql. A POINT-IN-TIME snapshot, not a cumulative-counter delta +/// collector: it reports the current session-status breakdown directly, ONE aggregate row per +/// collection — no delta groups (mirrors <see cref="MemoryStatsCollector"/> / +/// <see cref="CpuSchedulerStatsCollector"/>). Counts total/running/sleeping/background/dormant +/// sessions, idle sessions over 30 minutes (the leak signal), sessions waiting on memory, distinct +/// databases with connections, and the top application/host by connection count. The Dashboard proc's +/// collection_log logging and RAISERROR idle-percent debug output are the collector framework's job +/// and are intentionally dropped. +/// +/// <para>Distinct from the existing <see cref="SessionStatsCollector"/> (table session_stats), which +/// emits ONE ROW PER program_name for per-application resource use — a different shape from a +/// different Dashboard source. This collector's server-wide summary is deliberately named +/// session_summary_stats so the two never collide.</para> +/// +/// <para>Available on SQL Server, Azure SQL Database, and Azure SQL Managed Instance — both DMVs are +/// verified against MS Learn ("Applies to" lists all three) and the query never fails on any of them, +/// so <see cref="AppliesTo"/> is unconditionally true (mirroring the sibling +/// <see cref="SessionStatsCollector"/>, which reads the same sys.dm_exec_sessions everywhere, and the +/// Dashboard proc, which has no platform guard). On Azure SQL Database the counts are inherently +/// SCOPED, not absent: sys.dm_exec_sessions with VIEW DATABASE STATE sees only sessions in the +/// current database, and sys.dm_exec_requests is always limited to the current connection there +/// (VIEW SERVER STATE cannot be granted on Azure SQL DB), so sessions_waiting_for_memory reflects +/// only this connection. That is a documented platform property of the DMVs — a narrower view, not a +/// collection gap — exactly the kind of Azure scoping <see cref="PlanCacheStatsCollector"/> notes and +/// keeps collecting through.</para> +/// </summary> +public sealed class SessionSummaryStatsCollector : CollectorDefinitionBase<SessionSummaryStatsCollector.Row> +{ + public static SessionSummaryStatsCollector Instance { get; } = new(); + + private SessionSummaryStatsCollector() + { + } + + public readonly record struct Row( + int TotalSessions, + int RunningSessions, + int SleepingSessions, + int BackgroundSessions, + int DormantSessions, + int IdleSessionsOver30Min, + int SessionsWaitingForMemory, + int DatabasesWithConnections, + string? TopApplicationName, + int? TopApplicationConnections, + string? TopHostName, + int? TopHostConnections); + + /* Ported verbatim from install/36_collect_session_stats.sql's SELECT (the point-in-time + aggregate — one row, no GROUP BY). The only additions over the proc body are explicit + CONVERT(integer, ...) wrappers the proc relied on the INSERT into its integer columns to do + implicitly (COUNT_BIG for total_sessions / databases_with_connections, and MAX-of-COUNT_BIG + for the two top_*_connections) so the positional reader gets clean int32s — mirroring the + CONVERT(integer, COUNT_BIG(*)) the plan_cache_stats port added. Values stored are identical to + the Dashboard table (all counts integer NOT NULL; the four top_* columns nullable). */ + private const string QueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + total_sessions = CONVERT(integer, COUNT_BIG(*)), + running_sessions = + SUM + ( + CASE + WHEN des.status = N'running' + THEN 1 + ELSE 0 + END + ), + sleeping_sessions = + SUM + ( + CASE + WHEN des.status = N'sleeping' + THEN 1 + ELSE 0 + END + ), + background_sessions = + SUM + ( + CASE + WHEN des.is_user_process = 0 + THEN 1 + ELSE 0 + END + ), + dormant_sessions = + SUM + ( + CASE + WHEN des.status = N'dormant' + THEN 1 + ELSE 0 + END + ), + idle_sessions_over_30min = + SUM + ( + CASE + WHEN des.status = N'sleeping' + AND des.last_request_end_time < DATEADD(MINUTE, -30, SYSDATETIME()) + AND des.is_user_process = 1 + THEN 1 + ELSE 0 + END + ), + sessions_waiting_for_memory = + SUM + ( + CASE + WHEN der.wait_type LIKE N'%MEMORY%' + THEN 1 + ELSE 0 + END + ), + databases_with_connections = CONVERT(integer, COUNT_BIG(DISTINCT des.database_id)), + top_application_name = MAX(top_app.program_name), + top_application_connections = CONVERT(integer, MAX(top_app.connection_count)), + top_host_name = MAX(top_host.host_name), + top_host_connections = CONVERT(integer, MAX(top_host.connection_count)) +FROM sys.dm_exec_sessions AS des +LEFT JOIN sys.dm_exec_requests AS der + ON der.session_id = des.session_id +OUTER APPLY +( + SELECT TOP (1) + program_name = des2.program_name, + connection_count = COUNT_BIG(*) + FROM sys.dm_exec_sessions AS des2 + WHERE des2.session_id > 50 + AND des2.is_user_process = 1 + AND des2.program_name IS NOT NULL + GROUP BY + des2.program_name + ORDER BY + COUNT_BIG(*) DESC +) AS top_app +OUTER APPLY +( + SELECT TOP (1) + host_name = des3.host_name, + connection_count = COUNT_BIG(*) + FROM sys.dm_exec_sessions AS des3 + WHERE des3.session_id > 50 + AND des3.is_user_process = 1 + AND des3.host_name IS NOT NULL + GROUP BY + des3.host_name + ORDER BY + COUNT_BIG(*) DESC +) AS top_host +WHERE des.session_id > 50 +OPTION(RECOMPILE);"; + + public override string Name => "session_summary_stats"; + + public override string TargetTable => "session_summary_stats"; + + public override string? WatermarkColumn => null; + + /// <summary> + /// Collects everywhere: sys.dm_exec_sessions and sys.dm_exec_requests exist on SQL Server, Azure + /// SQL Database, and Azure SQL Managed Instance (MS Learn-verified) and the query never fails. + /// On Azure SQL DB the counts are scoped (sessions to the current database, the memory-wait count + /// to the current connection) — a platform property, not a gap. Mirrors SessionStatsCollector. + /// </summary> + public override bool AppliesTo(CollectorTargetInfo target) => true; + + public override bool RunsPerDatabase(CollectorTargetInfo target) => false; + + public override CollectorQuery BuildQuery(CollectorContext context) => new(QueryText); + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("total_sessions", CollectorColumnType.Integer), + new CollectorColumn("running_sessions", CollectorColumnType.Integer), + new CollectorColumn("sleeping_sessions", CollectorColumnType.Integer), + new CollectorColumn("background_sessions", CollectorColumnType.Integer), + new CollectorColumn("dormant_sessions", CollectorColumnType.Integer), + new CollectorColumn("idle_sessions_over_30min", CollectorColumnType.Integer), + new CollectorColumn("sessions_waiting_for_memory", CollectorColumnType.Integer), + new CollectorColumn("databases_with_connections", CollectorColumnType.Integer), + new CollectorColumn("top_application_name", CollectorColumnType.Varchar), + new CollectorColumn("top_application_connections", CollectorColumnType.Integer), + new CollectorColumn("top_host_name", CollectorColumnType.Varchar), + new CollectorColumn("top_host_connections", CollectorColumnType.Integer), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var rows = new List<Row>(); + + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new Row( + TotalSessions: reader.GetInt32(0), + RunningSessions: reader.GetInt32(1), + SleepingSessions: reader.GetInt32(2), + BackgroundSessions: reader.GetInt32(3), + DormantSessions: reader.GetInt32(4), + IdleSessionsOver30Min: reader.GetInt32(5), + SessionsWaitingForMemory: reader.GetInt32(6), + DatabasesWithConnections: reader.GetInt32(7), + /* The three top_* OUTER APPLYs yield NULL when no user session qualifies (no program / + host); the name and its count are nullable, matching the Dashboard columns. */ + TopApplicationName: reader.IsDBNull(8) ? null : reader.GetString(8), + TopApplicationConnections: reader.IsDBNull(9) ? null : reader.GetInt32(9), + TopHostName: reader.IsDBNull(10) ? null : reader.GetString(10), + TopHostConnections: reader.IsDBNull(11) ? null : reader.GetInt32(11))); + } + + return rows; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + writer + .Value(row.TotalSessions) /* total_sessions INTEGER */ + .Value(row.RunningSessions) /* running_sessions INTEGER */ + .Value(row.SleepingSessions) /* sleeping_sessions INTEGER */ + .Value(row.BackgroundSessions) /* background_sessions INTEGER */ + .Value(row.DormantSessions) /* dormant_sessions INTEGER */ + .Value(row.IdleSessionsOver30Min) /* idle_sessions_over_30min INTEGER */ + .Value(row.SessionsWaitingForMemory) /* sessions_waiting_for_memory INTEGER */ + .Value(row.DatabasesWithConnections) /* databases_with_connections INTEGER */ + .Value(row.TopApplicationName) /* top_application_name VARCHAR (nullable) */ + .Value(row.TopApplicationConnections) /* top_application_connections INTEGER (nullable) */ + .Value(row.TopHostName) /* top_host_name VARCHAR (nullable) */ + .Value(row.TopHostConnections); /* top_host_connections INTEGER (nullable) */ + } +} diff --git a/PerformanceMonitor.Collectors/SpinlockStatsCollector.cs b/PerformanceMonitor.Collectors/SpinlockStatsCollector.cs new file mode 100644 index 000000000..c7b701353 --- /dev/null +++ b/PerformanceMonitor.Collectors/SpinlockStatsCollector.cs @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Spinlock statistics from sys.dm_os_spinlock_stats — the cumulative per-spinlock collision/spin +/// counters that expose low-level internal contention (the Dashboard-parity port of +/// install/33_collect_spinlock_stats.sql). A cumulative-counter DMV like wait_stats, so it mirrors +/// <see cref="WaitStatsCollector"/>: single DMV, delta-based between snapshots. collisions, spins, +/// sleep_time, and backoffs are the cumulative counters that get deltas; spins_per_collision is a +/// point-in-time ratio the DMV computes (type <c>real</c>) and is passed through without a delta, +/// matching the Dashboard table. The proc's server_start_time reset marker is intentionally +/// dropped — the shared <see cref="CollectorContext.Deltas"/> calculator detects a counter reset +/// itself (a value drop yields a 0 delta). Available on SQL Server, Azure SQL Database, and Azure +/// SQL Managed Instance (verified against MS Learn), so <see cref="AppliesTo"/> is unconditionally +/// true, matching wait_stats. +/// </summary> +public sealed class SpinlockStatsCollector : CollectorDefinitionBase<SpinlockStatsCollector.Row> +{ + public static SpinlockStatsCollector Instance { get; } = new(); + + private SpinlockStatsCollector() + { + } + + public readonly record struct Row(string SpinlockName, long Collisions, long Spins, double SpinsPerCollision, long SleepTime, long Backoffs); + + private const string QueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + spinlock_name = ss.name, + collisions = ss.collisions, + spins = ss.spins, + spins_per_collision = ss.spins_per_collision, + sleep_time = ss.sleep_time, + /* backoffs is int in sys.dm_os_spinlock_stats on SQL 2016/2017 and was widened to bigint on + 2019+. CONVERT so the wire type is always bigint and ReadAsync's GetInt64 doesn't throw an + Int32->Int64 InvalidCast on the older versions (collisions/spins/sleep_time are bigint on + every supported version, so they need no cast). */ + backoffs = CONVERT(bigint, ss.backoffs) +FROM sys.dm_os_spinlock_stats AS ss +WHERE ss.collisions > 0 +OR ss.spins > 0 +OPTION(RECOMPILE);"; + + public override string Name => "spinlock_stats"; + + public override string TargetTable => "spinlock_stats"; + + public override string? WatermarkColumn => null; + + public override bool AppliesTo(CollectorTargetInfo target) => true; + + public override bool RunsPerDatabase(CollectorTargetInfo target) => false; + + public override CollectorQuery BuildQuery(CollectorContext context) => new(QueryText); + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("spinlock_name", CollectorColumnType.Varchar), + new CollectorColumn("collisions", CollectorColumnType.BigInt), + new CollectorColumn("spins", CollectorColumnType.BigInt), + new CollectorColumn("spins_per_collision", CollectorColumnType.Double), + new CollectorColumn("sleep_time", CollectorColumnType.BigInt), + new CollectorColumn("backoffs", CollectorColumnType.BigInt), + new CollectorColumn("delta_collisions", CollectorColumnType.BigInt), + new CollectorColumn("delta_spins", CollectorColumnType.BigInt), + new CollectorColumn("delta_sleep_time", CollectorColumnType.BigInt), + new CollectorColumn("delta_backoffs", CollectorColumnType.BigInt), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var rows = new List<Row>(); + + while (await reader.ReadAsync(cancellationToken)) + { + /* spins_per_collision is a real (float4) in the DMV — read with GetFloat and widen to + double; GetDouble would throw an invalid-cast on a real column. */ + rows.Add(new Row( + SpinlockName: reader.GetString(0), + Collisions: reader.GetInt64(1), + Spins: reader.GetInt64(2), + SpinsPerCollision: reader.GetFloat(3), + SleepTime: reader.GetInt64(4), + Backoffs: reader.GetInt64(5))); + } + + return rows; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + /* Delta groups, key (spinlock_name), and the 300 s gap policy are the parity contract. + spins_per_collision is a computed ratio, not a cumulative counter — no delta (mirrors + the Dashboard's collect.spinlock_stats table). */ + var deltaCollisions = context.Deltas.CalculateDelta(context.ServerId, "spinlock_stats_collisions", row.SpinlockName, row.Collisions, collectionTime: context.CollectionTime, maxGapSeconds: 300); + var deltaSpins = context.Deltas.CalculateDelta(context.ServerId, "spinlock_stats_spins", row.SpinlockName, row.Spins, collectionTime: context.CollectionTime, maxGapSeconds: 300); + var deltaSleepTime = context.Deltas.CalculateDelta(context.ServerId, "spinlock_stats_sleep_time", row.SpinlockName, row.SleepTime, collectionTime: context.CollectionTime, maxGapSeconds: 300); + var deltaBackoffs = context.Deltas.CalculateDelta(context.ServerId, "spinlock_stats_backoffs", row.SpinlockName, row.Backoffs, collectionTime: context.CollectionTime, maxGapSeconds: 300); + + writer + .Value(row.SpinlockName) /* spinlock_name VARCHAR */ + .Value(row.Collisions) /* collisions BIGINT */ + .Value(row.Spins) /* spins BIGINT */ + .Value(row.SpinsPerCollision) /* spins_per_collision DOUBLE */ + .Value(row.SleepTime) /* sleep_time BIGINT */ + .Value(row.Backoffs) /* backoffs BIGINT */ + .Value(deltaCollisions) /* delta_collisions BIGINT */ + .Value(deltaSpins) /* delta_spins BIGINT */ + .Value(deltaSleepTime) /* delta_sleep_time BIGINT */ + .Value(deltaBackoffs); /* delta_backoffs BIGINT */ + } +} diff --git a/PerformanceMonitor.Collectors/SystemHealthEventsCollector.cs b/PerformanceMonitor.Collectors/SystemHealthEventsCollector.cs new file mode 100644 index 000000000..6b07ddf80 --- /dev/null +++ b/PerformanceMonitor.Collectors/SystemHealthEventsCollector.cs @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Raw <c>system_health</c> Extended Events, captured from the built-in always-on session's +/// ring-buffer target and landed as one row of raw event XML each — the STAGE 1 capture half of the +/// system_health parity port (the Dashboard already shreds these via Erik's +/// <c>sp_HealthParser</c>). This collector ONLY captures and stores; the monitor-side shred into the +/// health-parser warning categories (memory broker / memory conditions / memory node-OOM / scheduler +/// issues / severe errors / significant waits) is STAGE 2 and owns all parsing — nothing here is +/// shredded into typed columns. +/// +/// <para>Reads the <c>system_health</c> ring buffer the same way <see cref="DeadlocksCollector"/> +/// reads its own session: <c>TRY_CAST(target_data AS xml)</c> from sys.dm_xe_session_targets + +/// sys.dm_xe_sessions, <c>.nodes()</c> to split events, an <c>event_time</c> watermark so the rolling +/// ring buffer is never re-grabbed (10-minute fallback on first run), storing the raw +/// <c>evt.query('.')</c> event node verbatim. Unlike the deadlock collector there is NO session +/// lifecycle: <c>system_health</c> is created and started by the Database Engine and must not be +/// altered (MS Learn), so the host just reads it.</para> +/// +/// <para><b>Events captured</b> (verified live on SQL 2016–2025 and against +/// <c>sp_HealthParser</c>'s event map): the clear feeders for the unique warning categories — +/// <c>sp_server_diagnostics_component_result</c> (memory conditions, IO, and system component health), +/// <c>error_reported</c> (severe errors), <c>memory_broker_ring_buffer_recorded</c> (memory broker), +/// <c>memory_node_oom_ring_buffer_recorded</c> (memory node OOM), +/// <c>scheduler_monitor_system_health_ring_buffer_recorded</c> (scheduler / non-yielding issues), and +/// <c>wait_info</c> (significant individual long waits — discrete threshold-crossing events, NOT the +/// cumulative sys.dm_os_wait_stats the wait_stats collector aggregates). <c>xml_deadlock_report</c> is +/// deliberately NOT captured here — <see cref="DeadlocksCollector"/> already owns deadlocks from its +/// own dedicated session. <c>security_error_ring_buffer_recorded</c> and +/// <c>connectivity_ring_buffer_recorded</c> are also skipped (outside the warning categories this +/// feeds, and excluded by sp_HealthParser too).</para> +/// +/// <para><b>Azure SQL Database (edition 5): does NOT apply.</b> MS Learn is explicit — "There's no +/// built-in <c>system_health</c> Extended Event session in Azure SQL Database" (the session's "Applies +/// to" lists SQL Server and Azure SQL Managed Instance only), and the captured events +/// (sp_server_diagnostics, the memory-broker / node-OOM / scheduler ring buffers) are server-level and +/// have no database-scoped equivalent to recreate — so unlike the deadlock collector (which has the +/// database-scoped <c>database_xml_deadlock_report</c> to stand up its own session) there is nothing to +/// read or build on Azure SQL DB. Erik's own <c>sp_HealthParser</c> hard-refuses on edition 5 for the +/// same reason. A verified platform gap, gated via <see cref="AppliesTo"/> = <c>!IsAzureSqlDb</c>. +/// Azure SQL Managed Instance (edition 8) has the full server-scoped session and collects normally.</para> +/// </summary> +public sealed class SystemHealthEventsCollector : CollectorDefinitionBase<SystemHealthEventsCollector.Row> +{ + public static SystemHealthEventsCollector Instance { get; } = new(); + + private SystemHealthEventsCollector() + { + } + + /// <summary>The built-in always-on session name — fixed by the Database Engine, never created by us.</summary> + public const string XeSessionName = "system_health"; + + public sealed class Row + { + public DateTime? EventTime { get; set; } + public string? EventType { get; set; } + public string? EventXml { get; set; } + } + + /* On-prem / Azure MI / AWS RDS: read from the built-in system_health server-scoped ring buffer. + The event-name filter keeps the capture to the six feeders for the unique warning categories + (see the class doc); xml_deadlock_report / security / connectivity events are intentionally left + behind. evt.query('.') stores each matching <event> node verbatim as text — no shredding here + (Stage 2 owns that). Timestamp watermark (> @cutoff_time) stops the rolling ring buffer being + re-collected. Azure SQL DB is gated out by AppliesTo, so only server-scoped reaches here. */ + private const string QueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +DECLARE + @system_health TABLE +( + ring_buffer xml NOT NULL +); + +INSERT + @system_health +( + ring_buffer +) +SELECT /* PerformanceMonitorLite */ + ring_xml = TRY_CAST(xet.target_data AS xml) +FROM sys.dm_xe_session_targets AS xet +JOIN sys.dm_xe_sessions AS xes + ON xes.address = xet.event_session_address +WHERE xes.name = N'system_health' +AND xet.target_name = N'ring_buffer' +OPTION(RECOMPILE); + +SELECT + event_time = evt.value('(@timestamp)[1]', 'datetime2'), + event_type = evt.value('(@name)[1]', 'sysname'), + event_xml = CONVERT(nvarchar(max), evt.query('.')) +FROM +( + SELECT + sh.ring_buffer + FROM @system_health AS sh +) AS rb +CROSS APPLY rb.ring_buffer.nodes('RingBufferTarget/event') AS q(evt) +WHERE evt.value('(@timestamp)[1]', 'datetime2') > @cutoff_time +AND evt.value('(@name)[1]', 'sysname') IN + ( + N'sp_server_diagnostics_component_result', + N'error_reported', + N'memory_broker_ring_buffer_recorded', + N'memory_node_oom_ring_buffer_recorded', + N'scheduler_monitor_system_health_ring_buffer_recorded', + N'wait_info' + ) +OPTION(RECOMPILE);"; + + public override string Name => "system_health_events"; + + public override string TargetTable => "system_health_events"; + + /// <summary>Lite schema names this table's prefix id "system_health_event_id"; Darling mirrors it.</summary> + public override string PrefixIdColumnName => "system_health_event_id"; + + /// <summary> + /// Only events newer than the newest already-collected event are fetched, so an event lingering + /// in the rolling ring buffer across cycles is never inserted twice (mirrors the deadlock + /// collector's deadlock_time watermark). + /// </summary> + public override string? WatermarkColumn => "event_time"; + + /// <summary> + /// Collects on SQL Server, Azure SQL Managed Instance, and AWS RDS — everywhere the built-in + /// server-scoped system_health session exists. NOT Azure SQL Database (edition 5): there is no + /// built-in system_health session there (MS Learn-verified) and the captured events are + /// server-level with no database-scoped equivalent to stand up — a real platform gap, not a + /// scope-down. See the class remarks. + /// </summary> + public override bool AppliesTo(CollectorTargetInfo target) => !target.IsAzureSqlDb; + + public override bool RunsPerDatabase(CollectorTargetInfo target) => false; + + public override CollectorQuery BuildQuery(CollectorContext context) + { + /* Use the most recent event_time from the host store as the cutoff, or fall back to a + 10-minute window on first run (mirrors DeadlocksCollector). */ + var cutoffTime = context.Watermark ?? context.CollectionTime.AddMinutes(-10); + + return new CollectorQuery(QueryText, new List<CollectorParameter> + { + new("@cutoff_time", cutoffTime, CollectorParameterType.DateTime2), + }); + } + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("event_time", CollectorColumnType.Timestamp), + new CollectorColumn("event_type", CollectorColumnType.Varchar), + new CollectorColumn("event_xml", CollectorColumnType.Varchar), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var rows = new List<Row>(); + + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new Row + { + EventTime = reader.IsDBNull(0) ? null : reader.GetDateTime(0), + EventType = reader.IsDBNull(1) ? null : reader.GetString(1), + EventXml = reader.IsDBNull(2) ? null : reader.GetString(2), + }); + } + + return rows; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + writer + .Value(row.EventTime) /* event_time TIMESTAMP (watermark) */ + .Value(row.EventType) /* event_type VARCHAR (XE event name — Stage 2 dispatches on this) */ + .Value(row.EventXml); /* event_xml VARCHAR (raw event node, unshredded) */ + } +} diff --git a/PerformanceMonitor.Collectors/TempDbStatsCollector.cs b/PerformanceMonitor.Collectors/TempDbStatsCollector.cs new file mode 100644 index 000000000..016d1c601 --- /dev/null +++ b/PerformanceMonitor.Collectors/TempDbStatsCollector.cs @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// TempDB space usage from tempdb.sys.dm_db_file_space_usage plus the top tempdb-consuming +/// session (two result sets → one row). Extracted verbatim from Lite's +/// RemoteCollectorService.TempDb.cs. Always yields exactly one row — zeros when the result +/// sets are empty — matching the original collector's behavior. +/// </summary> +public sealed class TempDbStatsCollector : CollectorDefinitionBase<TempDbStatsCollector.Row> +{ + public static TempDbStatsCollector Instance { get; } = new(); + + private TempDbStatsCollector() + { + } + + public readonly record struct Row( + decimal UserObjectReservedMb, + decimal InternalObjectReservedMb, + decimal VersionStoreReservedMb, + decimal TotalReservedMb, + decimal UnallocatedMb, + long TotalSessions, + int TopSessionId, + decimal TopSessionMb); + + public override string Name => "tempdb_stats"; + + public override string TargetTable => "tempdb_stats"; + + public override string? WatermarkColumn => null; + + public override bool AppliesTo(CollectorTargetInfo target) => true; + + public override bool RunsPerDatabase(CollectorTargetInfo target) => false; + + public override CollectorQuery BuildQuery(CollectorContext context) => new(QueryText); + + private const string QueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT /* PerformanceMonitorLite */ + user_object_reserved_mb = CONVERT(decimal(18,2), SUM(dsu.user_object_reserved_page_count) * 8 / 1024.0), + internal_object_reserved_mb = CONVERT(decimal(18,2), SUM(dsu.internal_object_reserved_page_count) * 8 / 1024.0), + version_store_reserved_mb = CONVERT(decimal(18,2), SUM(dsu.version_store_reserved_page_count) * 8 / 1024.0), + total_reserved_mb = CONVERT(decimal(18,2), SUM(dsu.user_object_reserved_page_count + dsu.internal_object_reserved_page_count + dsu.version_store_reserved_page_count) * 8 / 1024.0), + unallocated_mb = CONVERT(decimal(18,2), SUM(dsu.unallocated_extent_page_count) * 8 / 1024.0) +FROM tempdb.sys.dm_db_file_space_usage AS dsu +OPTION(RECOMPILE); + +SELECT /* PerformanceMonitorLite */ TOP (1) + session_id = ssu.session_id, + tempdb_mb = CONVERT(decimal(18,2), (ssu.user_objects_alloc_page_count + ssu.internal_objects_alloc_page_count) * 8 / 1024.0), + total_sessions = (SELECT COUNT_BIG(*) FROM sys.dm_db_session_space_usage WHERE user_objects_alloc_page_count + internal_objects_alloc_page_count > 0) +FROM sys.dm_db_session_space_usage AS ssu +ORDER BY (ssu.user_objects_alloc_page_count + ssu.internal_objects_alloc_page_count) DESC +OPTION(RECOMPILE);"; + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("user_object_reserved_mb", CollectorColumnType.Decimal, 18, 2), + new CollectorColumn("internal_object_reserved_mb", CollectorColumnType.Decimal, 18, 2), + new CollectorColumn("version_store_reserved_mb", CollectorColumnType.Decimal, 18, 2), + new CollectorColumn("total_reserved_mb", CollectorColumnType.Decimal, 18, 2), + new CollectorColumn("unallocated_mb", CollectorColumnType.Decimal, 18, 2), + new CollectorColumn("total_sessions_using_tempdb", CollectorColumnType.BigInt), + new CollectorColumn("top_session_id", CollectorColumnType.Integer), + new CollectorColumn("top_session_tempdb_mb", CollectorColumnType.Decimal, 18, 2), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + decimal userObjMb = 0, internalObjMb = 0, versionStoreMb = 0, totalReservedMb = 0, unallocatedMb = 0; + int topSessionId = 0; + long totalSessions = 0; + decimal topSessionMb = 0; + + if (await reader.ReadAsync(cancellationToken)) + { + userObjMb = reader.IsDBNull(0) ? 0m : reader.GetDecimal(0); + internalObjMb = reader.IsDBNull(1) ? 0m : reader.GetDecimal(1); + versionStoreMb = reader.IsDBNull(2) ? 0m : reader.GetDecimal(2); + totalReservedMb = reader.IsDBNull(3) ? 0m : reader.GetDecimal(3); + unallocatedMb = reader.IsDBNull(4) ? 0m : reader.GetDecimal(4); + } + + if (await reader.NextResultAsync(cancellationToken) && await reader.ReadAsync(cancellationToken)) + { + topSessionId = reader.IsDBNull(0) ? 0 : Convert.ToInt32(reader.GetValue(0)); + topSessionMb = reader.IsDBNull(1) ? 0m : reader.GetDecimal(1); + totalSessions = reader.IsDBNull(2) ? 0L : reader.GetInt64(2); + } + + return new List<Row> + { + new(userObjMb, internalObjMb, versionStoreMb, totalReservedMb, unallocatedMb, totalSessions, topSessionId, topSessionMb), + }; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + writer + .Value(row.UserObjectReservedMb) /* user_object_reserved_mb DECIMAL */ + .Value(row.InternalObjectReservedMb) /* internal_object_reserved_mb DECIMAL */ + .Value(row.VersionStoreReservedMb) /* version_store_reserved_mb DECIMAL */ + .Value(row.TotalReservedMb) /* total_reserved_mb DECIMAL */ + .Value(row.UnallocatedMb) /* unallocated_mb DECIMAL */ + .Value(row.TotalSessions) /* total_sessions_using_tempdb BIGINT */ + .Value(row.TopSessionId) /* top_session_id INTEGER */ + .Value(row.TopSessionMb); /* top_session_tempdb_mb DECIMAL */ + } +} diff --git a/PerformanceMonitor.Collectors/TraceFlagsCollector.cs b/PerformanceMonitor.Collectors/TraceFlagsCollector.cs new file mode 100644 index 000000000..0209a1110 --- /dev/null +++ b/PerformanceMonitor.Collectors/TraceFlagsCollector.cs @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Active trace flags via DBCC TRACESTATUS(-1) staged through a #temp table (on-load only). +/// Extracted verbatim from Lite's RemoteCollectorService.ServerConfig.cs. The caller wraps the +/// run in a permission-tolerant catch (DBCC may be denied) — that policy stays host-side so a +/// permissions failure degrades to zero rows with a warning, exactly as before. +/// </summary> +public sealed class TraceFlagsCollector : CollectorDefinitionBase<TraceFlagsCollector.Row> +{ + public static TraceFlagsCollector Instance { get; } = new(); + + private TraceFlagsCollector() + { + } + + public readonly record struct Row(int TraceFlag, bool Status, bool IsGlobal, bool IsSession); + + private const string QueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +CREATE TABLE + #trace_flags +( + trace_flag integer NOT NULL, + status bit NOT NULL, + is_global bit NOT NULL, + is_session bit NOT NULL +); + +INSERT + #trace_flags +( + trace_flag, + status, + is_global, + is_session +) +EXECUTE(N'DBCC TRACESTATUS(-1) WITH NO_INFOMSGS;'); + +SELECT + tf.trace_flag, + tf.status, + tf.is_global, + tf.is_session +FROM #trace_flags AS tf +ORDER BY tf.trace_flag +OPTION(RECOMPILE);"; + + public override string Name => "trace_flags"; + + public override string TargetTable => "trace_flags"; + + /// <summary>The config snapshots' prefix is config_id/capture_time in Lite's schema; Darling mirrors it.</summary> + public override string PrefixIdColumnName => "config_id"; + + public override string PrefixTimeColumnName => "capture_time"; + + /// <summary> + /// NOT Azure SQL Database (edition 5): <c>DBCC TRACESTATUS</c> is not supported there. Azure SQL + /// Managed Instance (edition 8) and on-prem both support it, so they collect. Gated here in the shared + /// AppliesTo so Lite and Darling skip identically — before this the gate lived only in Lite's + /// IsCollectorSupported, and Darling relied on <c>RunTraceFlagsTolerantAsync</c> swallowing the error to + /// a 0-row SUCCESS (this makes the skip explicit, matching Lite's SKIPPED record). + /// </summary> + public override bool AppliesTo(CollectorTargetInfo target) => !target.IsAzureSqlDb; + + public override CollectorQuery BuildQuery(CollectorContext context) => new(QueryText); + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("trace_flag", CollectorColumnType.Integer), + new CollectorColumn("status", CollectorColumnType.Boolean), + new CollectorColumn("is_global", CollectorColumnType.Boolean), + new CollectorColumn("is_session", CollectorColumnType.Boolean), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var rows = new List<Row>(); + + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new Row( + reader.GetInt32(0), + reader.GetBoolean(1), + reader.GetBoolean(2), + reader.GetBoolean(3))); + } + + return rows; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + writer + .Value(row.TraceFlag) /* trace_flag INTEGER */ + .Value(row.Status) /* status BOOLEAN */ + .Value(row.IsGlobal) /* is_global BOOLEAN */ + .Value(row.IsSession); /* is_session BOOLEAN */ + } +} diff --git a/PerformanceMonitor.Collectors/WaitStatsCollector.cs b/PerformanceMonitor.Collectors/WaitStatsCollector.cs new file mode 100644 index 000000000..c67e8ddcc --- /dev/null +++ b/PerformanceMonitor.Collectors/WaitStatsCollector.cs @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Wait statistics from sys.dm_os_wait_stats. Extracted verbatim from Lite's +/// RemoteCollectorService.WaitStats.cs (query text, ignored-wait filtering, delta groups/keys/gap +/// policy, and payload order are the parity contract — WaitStatsCollectorDefinitionTests pins them). +/// </summary> +public sealed class WaitStatsCollector : CollectorDefinitionBase<WaitStatsCollector.Row> +{ + public static WaitStatsCollector Instance { get; } = new(); + + private WaitStatsCollector() + { + } + + public readonly record struct Row(string WaitType, long WaitingTasks, long WaitTimeMs, long SignalWaitTimeMs); + + private const string QueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT + wait_type = ws.wait_type, + waiting_tasks_count = ws.waiting_tasks_count, + wait_time_ms = ws.wait_time_ms, + signal_wait_time_ms = ws.signal_wait_time_ms +FROM sys.dm_os_wait_stats AS ws +WHERE ws.wait_time_ms > 0 +OPTION(RECOMPILE);"; + + public override string Name => "wait_stats"; + + public override string TargetTable => "wait_stats"; + + public override string? WatermarkColumn => null; + + public override bool AppliesTo(CollectorTargetInfo target) => true; + + public override bool RunsPerDatabase(CollectorTargetInfo target) => false; + + public override CollectorQuery BuildQuery(CollectorContext context) => new(QueryText); + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("wait_type", CollectorColumnType.Varchar), + new CollectorColumn("waiting_tasks_count", CollectorColumnType.BigInt), + new CollectorColumn("wait_time_ms", CollectorColumnType.BigInt), + new CollectorColumn("signal_wait_time_ms", CollectorColumnType.BigInt), + new CollectorColumn("delta_waiting_tasks", CollectorColumnType.BigInt), + new CollectorColumn("delta_wait_time_ms", CollectorColumnType.BigInt), + new CollectorColumn("delta_signal_wait_time_ms", CollectorColumnType.BigInt), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var rows = new List<Row>(); + + while (await reader.ReadAsync(cancellationToken)) + { + var waitType = reader.GetString(0); + + /* Skip ignored wait types (Lite: ignored_wait_types.json — #1240) */ + if (context.IgnoredWaitTypes.Contains(waitType)) + { + continue; + } + + rows.Add(new Row( + WaitType: waitType, + WaitingTasks: reader.GetInt64(1), + WaitTimeMs: reader.GetInt64(2), + SignalWaitTimeMs: reader.GetInt64(3))); + } + + return rows; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + /* Delta groups, keys, and the 300 s gap policy are the parity contract — do not reorder. */ + var deltaWaitingTasks = context.Deltas.CalculateDelta(context.ServerId, "wait_stats_tasks", row.WaitType, row.WaitingTasks, collectionTime: context.CollectionTime, maxGapSeconds: 300); + var deltaWaitTimeMs = context.Deltas.CalculateDelta(context.ServerId, "wait_stats_time", row.WaitType, row.WaitTimeMs, collectionTime: context.CollectionTime, maxGapSeconds: 300); + var deltaSignalWaitTimeMs = context.Deltas.CalculateDelta(context.ServerId, "wait_stats_signal", row.WaitType, row.SignalWaitTimeMs, collectionTime: context.CollectionTime, maxGapSeconds: 300); + + writer + .Value(row.WaitType) /* wait_type VARCHAR */ + .Value(row.WaitingTasks) /* waiting_tasks_count BIGINT */ + .Value(row.WaitTimeMs) /* wait_time_ms BIGINT */ + .Value(row.SignalWaitTimeMs) /* signal_wait_time_ms BIGINT */ + .Value(deltaWaitingTasks) /* delta_waiting_tasks BIGINT */ + .Value(deltaWaitTimeMs) /* delta_wait_time_ms BIGINT */ + .Value(deltaSignalWaitTimeMs); /* delta_signal_wait_time_ms BIGINT */ + } +} diff --git a/PerformanceMonitor.Collectors/WaitingTasksCollector.cs b/PerformanceMonitor.Collectors/WaitingTasksCollector.cs new file mode 100644 index 000000000..60a5667f4 --- /dev/null +++ b/PerformanceMonitor.Collectors/WaitingTasksCollector.cs @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Point-in-time waiting tasks from sys.dm_os_waiting_tasks. Extracted verbatim from Lite's +/// RemoteCollectorService.WaitingTasks.cs — session ids read as smallint and widened to int on +/// write, resource_description stays a NULL placeholder (no longer collected), and the +/// database-exclusion filter splices at /*EXCLUSION_FILTER*/. Benign "ignored" wait types are +/// skipped in <see cref="ReadAsync"/> via <c>context.IgnoredWaitTypes</c> — the same filter +/// <see cref="WaitStatsCollector"/> applies — so the "Current Waits" surface honors the user's ignore +/// list in both SKUs (Lite: ignored_wait_types.json, Darling: IgnoredWaitDefaults.All). +/// </summary> +public sealed class WaitingTasksCollector : CollectorDefinitionBase<WaitingTasksCollector.Row> +{ + public static WaitingTasksCollector Instance { get; } = new(); + + private WaitingTasksCollector() + { + } + + public readonly record struct Row(short SessionId, string? WaitType, long WaitDurationMs, short? BlockingSessionId, string? DatabaseName); + + private const string QueryText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT /* PerformanceMonitorLite */ + session_id = wt.session_id, + wait_type = wt.wait_type, + wait_duration_ms = wt.wait_duration_ms, + blocking_session_id = wt.blocking_session_id, + database_name = d.name +FROM sys.dm_os_waiting_tasks AS wt +LEFT JOIN sys.dm_exec_requests AS er + ON er.session_id = wt.session_id +LEFT JOIN sys.databases AS d + ON d.database_id = er.database_id +WHERE wt.session_id >= 50 +AND wt.session_id <> @@SPID +AND wt.wait_type IS NOT NULL +AND er.database_id <> ISNULL(DB_ID(N'PerformanceMonitor'), 0) +/*EXCLUSION_FILTER*/ +OPTION(RECOMPILE);"; + + public override string Name => "waiting_tasks"; + + public override string TargetTable => "waiting_tasks"; + + public override CollectorQuery BuildQuery(CollectorContext context) + { + var (exclusionClause, exclusionParameters) = DatabaseExclusionFilter.Build(context.ExcludedDatabases, "d.name"); + return new CollectorQuery( + QueryText.Replace("/*EXCLUSION_FILTER*/", exclusionClause, StringComparison.Ordinal), + exclusionParameters); + } + + public override IReadOnlyList<CollectorColumn> PayloadColumns { get; } = new[] + { + new CollectorColumn("session_id", CollectorColumnType.Integer), + new CollectorColumn("wait_type", CollectorColumnType.Varchar), + new CollectorColumn("wait_duration_ms", CollectorColumnType.BigInt), + new CollectorColumn("blocking_session_id", CollectorColumnType.Integer), + new CollectorColumn("resource_description", CollectorColumnType.Varchar), + new CollectorColumn("database_name", CollectorColumnType.Varchar), + }; + + public override async ValueTask<List<Row>> ReadAsync(DbDataReader reader, CollectorContext context, CancellationToken cancellationToken) + { + var rows = new List<Row>(); + + while (await reader.ReadAsync(cancellationToken)) + { + var waitType = reader.IsDBNull(1) ? null : reader.GetString(1); + + /* Skip ignored (benign) wait types so "Current Waits" honors the same filter as wait_stats + — mirrors WaitStatsCollector.ReadAsync. The query already drops NULL wait_type; the null + guard keeps the Contains call safe regardless. */ + if (waitType is not null && context.IgnoredWaitTypes.Contains(waitType)) + { + continue; + } + + /* session_id and blocking_session_id are smallint in sys.dm_os_waiting_tasks */ + rows.Add(new Row( + reader.IsDBNull(0) ? (short)0 : reader.GetInt16(0), + waitType, + reader.IsDBNull(2) ? 0L : reader.GetInt64(2), + reader.IsDBNull(3) ? null : reader.GetInt16(3), + reader.IsDBNull(4) ? null : reader.GetString(4))); + } + + return rows; + } + + public override void WritePayload(Row row, ICollectorRowWriter writer, CollectorContext context) + { + writer + .Value((int)row.SessionId) /* session_id INTEGER (widened) */ + .Value(row.WaitType) /* wait_type VARCHAR */ + .Value(row.WaitDurationMs) /* wait_duration_ms BIGINT */ + .Value(row.BlockingSessionId.HasValue ? row.BlockingSessionId.Value : (int?)null) /* blocking_session_id INTEGER (widened) */ + .Value((string?)null) /* resource_description — no longer collected */ + .Value(row.DatabaseName); /* database_name VARCHAR */ + } +} diff --git a/PerformanceMonitor.Collectors/WatermarkPolicy.cs b/PerformanceMonitor.Collectors/WatermarkPolicy.cs new file mode 100644 index 000000000..40e8ce681 --- /dev/null +++ b/PerformanceMonitor.Collectors/WatermarkPolicy.cs @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; + +namespace PerformanceMonitor.Collectors; + +/// <summary> +/// Bounds how far back an incremental collector will catch up after an outage (#1556). The field +/// incident was query_store: a service stopped for days came back, read its stored per-database +/// watermark, and issued a cutoff pointing DAYS into the past — the source retains ~30 days, so the +/// one cycle tried to pull the entire backlog at once and drove the 0→13GB commit-limit blowout. +/// +/// <para> +/// <see cref="ClampCatchup"/> floors a stale watermark to <c>now - 24h</c>: a routine restart or a +/// brief outage never clamps (its watermark is minutes old), a multi-day outage survives with a +/// deliberate, logged, BOUNDED hole (the source still retains the older data; the viewer's windows +/// are 24h anyway). This is deliberately NOT applied to every timestamp watermark — for a ring-buffer +/// or rolling-trace source the clamp is a no-op at best and, on a quiet <c>default_trace</c> whose +/// 100MB ring can span days, a WRONG truncation of legitimate catch-up. The runner therefore applies +/// it at exactly ONE site: query_store's per-database cutoff computation (the only unbounded-persisted +/// source among the collectors). It lives here as a pure function purely so that placement is unit- +/// testable in isolation. +/// </para> +/// </summary> +public static class WatermarkPolicy +{ + /// <summary> + /// The maximum catch-up horizon. 24h is chosen so routine outages never clamp while a multi-day + /// outage survives with a single logged, bounded hole. Exposed so a test pins the boundary. + /// </summary> + public static readonly TimeSpan MaxCatchup = TimeSpan.FromHours(24); + + /// <summary> + /// Floors a >24h-stale timestamp watermark to <c>now - 24h</c>; a null watermark (nothing + /// collected yet — the definition's documented first-run window applies) stays null, and a + /// watermark within the horizon is returned unchanged. Compare the result to the input to tell + /// whether a clamp fired (the runner logs a WARNING when it does). + /// </summary> + /// <param name="watermark">The stored watermark (newest already-collected timestamp), or null.</param> + /// <param name="now">The current collection time (the cutoff is measured back from here).</param> + public static DateTime? ClampCatchup(DateTime? watermark, DateTime now) + { + if (watermark is null) + { + return null; + } + + var floor = now - MaxCatchup; + return watermark.Value < floor ? floor : watermark; + } +} diff --git a/PerformanceMonitor.Common/BulkServerListParser.cs b/PerformanceMonitor.Common/BulkServerListParser.cs new file mode 100644 index 000000000..1c64d19ba --- /dev/null +++ b/PerformanceMonitor.Common/BulkServerListParser.cs @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Collections.Generic; + +namespace PerformanceMonitor.Common; + +/// <summary> +/// Parses the "Add Multiple Servers" paste box into a typed list of server rows + a parallel list of +/// per-line errors — shared verbatim by Lite and the Darling viewer so the two bulk dialogs can never +/// drift on grammar. Pure, synchronous, allocation-light (the Common-root pure-static precedent set by +/// <see cref="IndexCleanupAnalyzer"/>). +/// +/// <para>Grammar (one server per line, <c>server[, display name][, database]</c>): lines split on CRLF or +/// LF; blank lines and lines whose trimmed text starts with <c>#</c> are skipped; a line containing a TAB +/// is tab-separated, otherwise comma-separated (so a spreadsheet paste keeps commas inside a field); +/// every field is trimmed and trailing empty fields are dropped (tolerating trailing separators and Excel's +/// trailing empty cells); the first field is the server name (empty → an error), fields two and three are +/// the optional display name and database (empty-after-trim → null, INTERIOR empties are null not errors), +/// and more than three fields is an error. The parser does NO duplicate detection and NO normalization — +/// both are app-side by design (the shared <see cref="ServerIdHelper"/> identity + each app's dedupe gate).</para> +/// </summary> +public static class BulkServerListParser +{ + /// <summary>Parses <paramref name="text"/> into the (valid rows, errors) split. Never throws; + /// null/empty/whitespace-only input yields an empty result.</summary> + public static BulkServerParseResult Parse(string? text) + { + var servers = new List<BulkServerParseLine>(); + var errors = new List<BulkServerParseError>(); + + if (string.IsNullOrEmpty(text)) + { + return new BulkServerParseResult(servers, errors); + } + + // Rule 1: split on CRLF or LF; "\r\n" listed first so a CRLF is consumed as one separator (no + // stray trailing '\r' on the raw line). LineNumber is 1-based over the ORIGINAL input. + var rawLines = text.Split(new[] { "\r\n", "\n" }, System.StringSplitOptions.None); + for (var i = 0; i < rawLines.Length; i++) + { + var lineNumber = i + 1; + var rawText = rawLines[i]; + var trimmed = rawText.Trim(); + + // Rule 2: skip blank + comment (#) lines (LineNumber still advances, so later lines report correctly). + if (trimmed.Length == 0 || trimmed[0] == '#') + { + continue; + } + + // Rule 3: tab-separated when the line contains a tab (spreadsheet paste), else comma-separated + // (hand-typed). A display name containing a comma therefore requires tab-separated input. + var separator = rawText.Contains('\t') ? '\t' : ','; + var fields = rawText.Split(separator); + + // Rule 4: trim every field, then drop TRAILING empty fields. + for (var f = 0; f < fields.Length; f++) + { + fields[f] = fields[f].Trim(); + } + + var count = fields.Length; + while (count > 0 && fields[count - 1].Length == 0) + { + count--; + } + + // Rule 5: field 1 (server name) must be present; at most 3 fields. + if (count == 0 || fields[0].Length == 0) + { + errors.Add(new BulkServerParseError(lineNumber, rawText, "server name is empty")); + continue; + } + + if (count > 3) + { + errors.Add(new BulkServerParseError(lineNumber, rawText, "too many fields (max 3: server, display name, database)")); + continue; + } + + // Rules 5/6: absent or empty (incl. INTERIOR-empty) display/database fields → null. + var serverName = fields[0]; + var displayName = count >= 2 && fields[1].Length > 0 ? fields[1] : null; + var databaseName = count >= 3 && fields[2].Length > 0 ? fields[2] : null; + + servers.Add(new BulkServerParseLine(lineNumber, rawText, serverName, displayName, databaseName)); + } + + return new BulkServerParseResult(servers, errors); + } +} + +/// <summary>The parse output: the valid server rows and the per-line errors, each ordered by +/// <see cref="BulkServerParseLine.LineNumber"/> / <see cref="BulkServerParseError.LineNumber"/>. A +/// line-ordered preview is a merge of the two lists on LineNumber.</summary> +public sealed record BulkServerParseResult( + IReadOnlyList<BulkServerParseLine> Servers, + IReadOnlyList<BulkServerParseError> Errors); + +/// <summary>One successfully parsed server row. <see cref="ServerName"/> is guaranteed non-empty; +/// <see cref="DisplayName"/> / <see cref="DatabaseName"/> are null when absent or blank. +/// <see cref="RawText"/> is the original (pre-trim) line, for the preview grid.</summary> +public sealed record BulkServerParseLine( + int LineNumber, + string RawText, + string ServerName, + string? DisplayName, + string? DatabaseName); + +/// <summary>One line that could not be parsed (empty server name, or too many fields), carrying its +/// original text and a human-readable reason for the preview grid.</summary> +public sealed record BulkServerParseError( + int LineNumber, + string RawText, + string Message); diff --git a/PerformanceMonitor.Common/ChartPalette.cs b/PerformanceMonitor.Common/ChartPalette.cs index 9023d066e..e31072fb6 100644 --- a/PerformanceMonitor.Common/ChartPalette.cs +++ b/PerformanceMonitor.Common/ChartPalette.cs @@ -30,15 +30,21 @@ namespace PerformanceMonitor.Common /// </summary> public static class ChartPalette { - // ── Cycling palette (Material Design 300/200) — for arbitrary, data-driven series ── - // Ported from Dashboard's ChartColors so both apps share one ordering (Lite's old - // SeriesColors was reordered + swapped #FFD54F<->#90A4AE, which broke cross-app parity). + // ── Cycling palette: validated colorblind-safe categorical ramp ── + // Slots 1-8 are the primary ramp, chosen with the dataviz palette validator: + // CVD-safe (worst adjacent deltaE ~24) and >= 3:1 contrast on all three chart + // surfaces (dark #111217, light #F5F7FA, CoolBreeze #DAE6F0), with no gray-reading + // (low-chroma) hues. This is ONE theme-independent list by design (see class + // remarks), so the hues are mid-tone cross-theme compromises rather than the + // brighter per-theme optima. Slots 9-20 are a chromatic fallback tier: color stops + // reliably distinguishing past 8, so many-series charts lean on legends/labels. + // Keep the slot 1-8 order; it is the CVD-safety mechanism. private static readonly string[] Cycling = { - "#4FC3F7", "#81C784", "#FFB74D", "#E57373", "#BA68C8", - "#4DD0E1", "#FFF176", "#F06292", "#AED581", "#90A4AE", - "#A1887F", "#7986CB", "#FF7043", "#80DEEA", "#FFE082", - "#CE93D8", "#EF9A9A", "#C5E1A5", "#FFCC80", "#B0BEC5", + "#2A78D6", "#1BAF7A", "#EDA100", "#008300", "#6C5CE7", + "#E34948", "#E87BA4", "#EB6834", "#17A2B8", "#B5179E", + "#74B816", "#AD6B3C", "#3D5AFE", "#C2185B", "#2E7D32", + "#8E44AD", "#0097A7", "#D81B60", "#7CB342", "#5C6BC0", }; /// <summary>Stable cycling color for index <paramref name="index"/> (wraps the palette).</summary> @@ -52,60 +58,67 @@ public static string CyclingColor(int index) // Defines ONE color per named series, applied identically in both apps. This is the fix // for "Buffer Pool is three different colors today" (green in Lite, blue and purple in two // Dashboard charts). Unknown names fall back to stable cycling. + // + // Identity series draw from the validated Cycling ramp above (co-occurring series in a + // given chart are kept on distinct ramp slots). Severity series use reserved status hues + // (dataviz good/warning/serious/critical) so a "getting worse" signal never reuses an + // identity hue. + // A few series are intentional NEUTRALS (a dashed reference line, an inactive/idle state, + // an unallocated remainder) and stay low-chroma grey on purpose. private static readonly Dictionary<string, string> Series = new(StringComparer.OrdinalIgnoreCase) { - ["SqlCpu"] = "#4FC3F7", // SQL Server CPU % - ["TotalCpu"] = "#FF7043", // Total (non-idle) CPU % - ["BufferPool"] = "#81C784", // Buffer pool MB - ["MemoryGrants"] = "#FFB74D", // Granted memory MB - ["OtherCpu"] = "#E57373", // Other (non-SQL) process CPU % - ["TotalServerMemory"] = "#4FC3F7", // Total server memory GB + ["SqlCpu"] = "#2A78D6", // SQL Server CPU % + ["TotalCpu"] = "#EB6834", // Total (non-idle) CPU % + ["BufferPool"] = "#008300", // Buffer pool MB + ["MemoryGrants"] = "#EDA100", // Granted memory MB + ["OtherCpu"] = "#E34948", // Other (non-SQL) process CPU % + ["TotalServerMemory"] = "#2A78D6", // Total server memory GB ["TargetMemory"] = "#808080", // Target server memory (dashed grey reference line) - ["Blocking"] = "#FFB74D", // blocking count / incidents - ["BlockingDuration"] = "#FF7043", // total blocking duration - ["Deadlocks"] = "#E57373", // deadlock count - ["DeadlockWaitTime"] = "#BA68C8", // total deadlock wait time - ["BlockedSessions"] = "#EF9A9A", // blocked session count - ["ReadLatency"] = "#4DD0E1", // Avg read latency ms - ["WriteLatency"] = "#F06292", // Avg write latency ms - ["Reads"] = "#4DD0E1", // Logical/physical reads - ["Writes"] = "#F06292", // Logical/physical writes - ["Duration"] = "#4FC3F7", // Elapsed/duration - ["Executions"] = "#BA68C8", // Execution count + ["Blocking"] = "#2A78D6", // blocking count / incidents + ["BlockingDuration"] = "#EDA100", // total blocking duration + ["Deadlocks"] = "#E34948", // deadlock count + ["DeadlockWaitTime"] = "#6C5CE7", // total deadlock wait time + ["BlockedSessions"] = "#17A2B8", // blocked session count (spread off the warm cluster; tritan-safe) + ["ReadLatency"] = "#17A2B8", // Avg read latency ms + ["WriteLatency"] = "#E87BA4", // Avg write latency ms + ["Reads"] = "#17A2B8", // Logical/physical reads + ["Writes"] = "#E87BA4", // Logical/physical writes + ["Duration"] = "#2A78D6", // Elapsed/duration + ["Executions"] = "#6C5CE7", // Execution count // Session states (co-occur in the Sessions chart) - ["SessionTotal"] = "#4FC3F7", - ["SessionRunning"] = "#81C784", - ["SessionSleeping"] = "#FFB74D", - ["SessionBackground"] = "#BA68C8", - ["SessionDormant"] = "#4DD0E1", - ["SessionIdle"] = "#90A4AE", - ["SessionWaiting"] = "#E57373", + ["SessionTotal"] = "#2A78D6", + ["SessionRunning"] = "#008300", + ["SessionSleeping"] = "#EDA100", + ["SessionBackground"] = "#6C5CE7", + ["SessionDormant"] = "#17A2B8", + ["SessionIdle"] = "#90A4AE", // intentional neutral: inactive state + ["SessionWaiting"] = "#E34948", // Memory overview (total / plan cache / available) - ["TotalMemory"] = "#90A4AE", - ["CacheMemory"] = "#81C784", - ["AvailableMemory"] = "#FFB74D", - // Memory pressure event severity (stacked event-count bars; SQL = orange family, OS = red family) - ["SqlPressureMedium"] = "#FFB74D", - ["SqlPressureSevere"] = "#E65100", - ["OsPressureMedium"] = "#E57373", - ["OsPressureSevere"] = "#B71C1C", + ["TotalMemory"] = "#90A4AE", // intentional neutral: overall envelope + ["CacheMemory"] = "#008300", + ["AvailableMemory"] = "#EDA100", + // Memory pressure event severity (status palette: SQL = amber->orange, OS = salmon->red) + ["SqlPressureMedium"] = "#FAB219", + ["SqlPressureSevere"] = "#E8720C", + ["OsPressureMedium"] = "#EC835A", + ["OsPressureSevere"] = "#D03B3B", // Plan cache size buckets - ["SinglePagePlans"] = "#E57373", - ["MultiPagePlans"] = "#81C784", + ["SinglePagePlans"] = "#E34948", + ["MultiPagePlans"] = "#008300", // Tempdb object types - ["UserObjects"] = "#4FC3F7", - ["VersionStore"] = "#81C784", - ["InternalObjects"] = "#FFB74D", - ["UnallocatedTempdb"] = "#90A4AE", - ["TopTempdbTask"] = "#E57373", + ["UserObjects"] = "#2A78D6", + ["VersionStore"] = "#008300", + ["InternalObjects"] = "#EDA100", + ["UnallocatedTempdb"] = "#90A4AE", // intentional neutral: unallocated remainder + ["TopTempdbTask"] = "#E34948", // Duration trend charts (history windows / per-type) - ["QueryDuration"] = "#4FC3F7", - ["ProcedureDuration"] = "#81C784", - ["QueryStoreDuration"]= "#FFB74D", - ["MetricTrend"] = "#4FC3F7", // generic single-metric history trend - ["LockWaits"] = "#4FC3F7", - ["CurrentWaits"] = "#4FC3F7", + ["QueryDuration"] = "#2A78D6", + ["ProcedureDuration"] = "#008300", + ["QueryStoreDuration"]= "#EDA100", + ["MetricTrend"] = "#2A78D6", // generic single-metric history trend + ["LockWaits"] = "#2A78D6", + ["CurrentWaits"] = "#2A78D6", }; /// <summary>Color for a fixed-meaning series (e.g. "BufferPool"); falls back to cycling.</summary> @@ -116,13 +129,13 @@ public static string SeriesColor(string name) private static readonly Dictionary<string, string> Accents = new(StringComparer.OrdinalIgnoreCase) { - ["Threshold"] = "#FFD54F", // generic threshold / target line (yellow) - ["PressureMedium"] = "#FFB74D", // medium pressure zone (orange) - ["PressureSevere"] = "#E57373", // severe pressure zone (red) - ["Anomaly"] = "#FF5252", // anomaly dot - ["Average"] = "#FFD54F", // mean / average line - ["BaselineCpu"] = "#4FC3F7", // CPU-lane baseline band / mean tint - ["BaselineBlocking"] = "#E57373", // blocking-lane baseline band / mean tint + ["Threshold"] = "#E8B21E", // generic threshold / target line (gold) + ["PressureMedium"] = "#FAB219", // medium pressure zone (status warning) + ["PressureSevere"] = "#D03B3B", // severe pressure zone (status critical) + ["Anomaly"] = "#E8352E", // anomaly dot (critical red) + ["Average"] = "#E8B21E", // mean / average line (gold) + ["BaselineCpu"] = "#2A78D6", // CPU-lane baseline band / mean tint (matches SqlCpu) + ["BaselineBlocking"] = "#E34948", // blocking-lane baseline band / mean tint ["Crosshair"] = "#FFFFFF", // correlated-charts crosshair vline ["GhostLine"] = "#FFFFFF", // comparison-overlay ghost line (rendered semi-transparent) ["Placeholder"] = "#888888", // no-data placeholder line diff --git a/PerformanceMonitor.Common/ConfigChangeDiff.cs b/PerformanceMonitor.Common/ConfigChangeDiff.cs new file mode 100644 index 000000000..633ce0ff0 --- /dev/null +++ b/PerformanceMonitor.Common/ConfigChangeDiff.cs @@ -0,0 +1,269 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace PerformanceMonitor.Common; + +/// <summary> +/// The ONE shared source for the Configuration Changes histories — server-config / database-config / +/// trace-flag DRIFT over time, COMPUTED by diffing the store's APPEND-ONLY config SNAPSHOT tables in pure C# +/// (there is no persisted change table — the Dashboard's <c>report.*_changes</c> views LAG over a config +/// history the headless editions do not materialize). Authored once here in <c>PerformanceMonitor.Common</c> +/// (pure, dependency-free, unit-testable) precisely because three consumers need it and none may reference +/// another: the Darling <b>viewer</b>'s Configuration Changes tab, the headless <b>MCP host</b>'s +/// <c>get_*_config_changes</c> tools, and <b>Lite</b>'s Configuration Changes tab. This was previously a +/// hand-mirrored twin (the viewer's <c>Diff*</c> and the service's <c>Compute*</c>); a Lite port would have +/// made it a third copy, so — exactly like <see cref="SystemHealthSignificance"/> — the algorithm is hoisted +/// and every side delegates. The diff runs in C# (not SQL) so it is unit-testable without a live store and so +/// trace-flag enable/disable (a flag row appearing / disappearing between snapshots) is handled by set-diff. +/// +/// <para> +/// WINDOWING — every diff takes BOTH window edges (<paramref name="startUtc"/>, <paramref name="endUtc"/>) and +/// applies them to the computed <c>change_time</c>, NOT to the snapshot read. Callers deliberately keep the +/// snapshot immediately BEFORE the window as the diff baseline (the reads upper-bound at the window end but +/// never lower-bound), so a change landing on the first in-window snapshot is still detected — mirroring the +/// Dashboard, whose <c>report.*_changes</c> views LAG over the full history and then filter +/// <c>change_time >= @from AND change_time <= @to</c>. The headless MCP tools, which read the full +/// unbounded history and only lower-bound, pass <see cref="DateTime.MaxValue"/> as the upper edge (a no-op) +/// so this both-edges algorithm reproduces their prior lower-bound-only behavior exactly. +/// </para> +/// +/// <para> +/// SHAPE CAVEATS (surfaced by the consumers, never silently dropped): the config collectors run ON CONNECT +/// only (<c>FrequencyMinutes = 0</c>), so change granularity equals the connect/restart cadence and a fresh / +/// stably-connected server may hold a single snapshot — then there is no change yet (the diff needs >= 2 +/// snapshots). The change records carry ONLY collected values plus PURE-DERIVED columns (requires_restart, +/// change_description, scope, status text) that the Dashboard grids show; the Dashboard enrichment the headless +/// editions do not collect is OMITTED (not fabricated): server-config <c>description</c> and database-config +/// <c>setting_type</c> (the store is WIDE — <c>setting_name</c> is the literal column name). <c>requires_restart</c> +/// is NOT one of the gaps: it is derivable from collected columns (<c>is_dynamic = false AND +/// value_configured != value_in_use</c>, the Dashboard view's own definition), so it is kept. Timezone / +/// display formatting of <c>change_time</c> stays per-app (each app renders through its own time helper). +/// </para> +/// </summary> +public static class ConfigChangeDiff +{ + /* ─────────────────────────── snapshot input records (the diff's raw input) ─────────────────────────── */ + + /// <summary>One sys.configurations snapshot capture for a single configuration_name.</summary> + public sealed record ServerConfigSnapshot( + DateTime CaptureTime, string ConfigurationName, long? ValueConfigured, long? ValueInUse, bool? IsDynamic, bool? IsAdvanced); + + /// <summary>One sys.databases snapshot capture for a single database — the WIDE row's 27 settings CAST to + /// text (in <see cref="DatabaseConfigChangeSettingNames"/> order) so the diff walks them positionally.</summary> + public sealed record DatabaseConfigSnapshot( + DateTime CaptureTime, string DatabaseName, IReadOnlyList<string?> Values); + + /// <summary>One trace-flag snapshot row (a row exists only while a flag is enabled at that capture).</summary> + public sealed record TraceFlagSnapshot( + DateTime CaptureTime, int TraceFlag, bool? Status, bool? IsGlobal, bool? IsSession); + + /* ─────────────────────────── change output records (raw data + PURE-DERIVED display props) ─────────────────────────── */ + + /// <summary>One server-configuration change. <see cref="RequiresRestart"/> and <see cref="ChangeDescription"/> + /// are DERIVED from the collected values, matching the Dashboard's <c>report.server_configuration_changes</c> + /// view definitions. Timezone rendering of <see cref="ChangeTime"/> is per-app (not here).</summary> + public sealed record ServerConfigChange( + DateTime ChangeTime, string ConfigurationName, long? OldValueConfigured, long? NewValueConfigured, + long? OldValueInUse, long? NewValueInUse, bool? IsDynamic, bool? IsAdvanced) + { + public string DynamicDisplay => IsDynamic == true ? "Yes" : "No"; + public string AdvancedDisplay => IsAdvanced == true ? "Yes" : "No"; + + /* requires_restart = a non-dynamic setting whose configured value differs from the in-use value (the + Dashboard view's exact definition: is_dynamic = 0 AND value_configured != value_in_use). Derivable + from collected columns, so it is kept — NOT a collection gap. */ + public bool RequiresRestart => IsDynamic == false && NewValueConfigured != NewValueInUse; + public string RequiresRestartDisplay => RequiresRestart ? "Yes" : "No"; + + /* Derived narrative matching report.server_configuration_changes.change_description wording. */ + public string ChangeDescription => + OldValueConfigured != NewValueConfigured + ? $"Configured value changed from {OldValueConfigured} to {NewValueConfigured}" + : OldValueInUse != NewValueInUse + ? $"In-use value changed from {OldValueInUse} to {NewValueInUse}" + : "Value unchanged"; + } + + /// <summary>One database-configuration change. The Dashboard's <c>setting_type</c> column is OMITTED (the + /// WIDE store has no setting_type — <see cref="SettingName"/> is the literal column name); + /// <see cref="ChangeDescription"/> is DERIVED, matching the Dashboard view.</summary> + public sealed record DatabaseConfigChange( + DateTime ChangeTime, string DatabaseName, string SettingName, string? OldValue, string? NewValue) + { + /* Derived narrative matching report.database_configuration_changes.change_description wording. */ + public string ChangeDescription => + OldValue is null && NewValue is not null ? $"Set to: {NewValue}" + : OldValue is not null && NewValue is null ? $"Cleared (was: {OldValue})" + : $"Changed from {OldValue} to {NewValue}"; + } + + /// <summary>One trace-flag change. <see cref="Scope"/> and <see cref="ChangeDescription"/> are DERIVED, + /// matching the Dashboard's <c>report.trace_flag_changes</c> view. <see cref="ChangeType"/> is the set-diff + /// outcome (enabled / disabled / modified).</summary> + public sealed record TraceFlagChange( + DateTime ChangeTime, int TraceFlag, bool? PreviousStatus, bool? NewStatus, bool? IsGlobal, bool? IsSession, string ChangeType) + { + public string PreviousStatusDisplay => StatusText(PreviousStatus); + public string NewStatusDisplay => StatusText(NewStatus); + public string GlobalDisplay => IsGlobal == true ? "Yes" : "No"; + public string SessionDisplay => IsSession == true ? "Yes" : "No"; + + /* Matches report.trace_flag_changes.scope (GLOBAL / SESSION / UNKNOWN). */ + public string Scope => IsGlobal == true ? "GLOBAL" : IsSession == true ? "SESSION" : "UNKNOWN"; + + /* Derived narrative matching report.trace_flag_changes.change_description wording. */ + public string ChangeDescription => ChangeType switch + { + "enabled" => $"Trace flag {TraceFlag} ENABLED", + "disabled" => $"Trace flag {TraceFlag} DISABLED", + "modified" => $"Trace flag {TraceFlag} scope changed", + _ => "Status unchanged", + }; + + private static string StatusText(bool? status) => status is null ? "" : status.Value ? "ON" : "OFF"; + } + + /// <summary>The 27 database-config setting names, in the collector's exact column order — the WIDE store + /// row is UNPIVOTed to a change row per changed setting, with this literal column name as the setting_name. + /// Load-bearing: every consumer's snapshot read SELECTs the 27 setting columns in THIS order and hands them + /// as <see cref="DatabaseConfigSnapshot.Values"/>, which the diff walks positionally.</summary> + public static readonly IReadOnlyList<string> DatabaseConfigChangeSettingNames = new[] + { + "state_desc", "compatibility_level", "collation_name", "recovery_model", "is_read_only", + "is_auto_close_on", "is_auto_shrink_on", "is_auto_create_stats_on", "is_auto_update_stats_on", + "is_auto_update_stats_async_on", "is_read_committed_snapshot_on", "snapshot_isolation_state", + "is_parameterization_forced", "is_query_store_on", "is_encrypted", "is_trustworthy_on", + "is_db_chaining_on", "is_broker_enabled", "is_cdc_enabled", "is_mixed_page_allocation_on", + "log_reuse_wait_desc", "page_verify_option", "target_recovery_time_seconds", "delayed_durability", + "is_accelerated_database_recovery_on", "is_memory_optimized_enabled", "is_optimized_locking_on", + }; + + /* ─────────────────────────── C# snapshot diffs (pure, unit-testable, no live store) ─────────────────────────── */ + + /// <summary> + /// Diffs the sys.configurations snapshots into a change per (configuration_name) whose configured or in-use + /// value moved between two consecutive captures (every config row is present in every snapshot, so a + /// per-name walk is the whole story). Emits changes whose newer capture falls in [<paramref name="startUtc"/>, + /// <paramref name="endUtc"/>], newest first. + /// </summary> + public static List<ServerConfigChange> DiffServerConfigChanges( + IReadOnlyList<ServerConfigSnapshot> snapshots, DateTime startUtc, DateTime endUtc) + { + var changes = new List<ServerConfigChange>(); + foreach (var group in snapshots.GroupBy(s => s.ConfigurationName)) + { + ServerConfigSnapshot? prev = null; + foreach (var cur in group.OrderBy(s => s.CaptureTime)) + { + if (prev is not null + && (prev.ValueConfigured != cur.ValueConfigured || prev.ValueInUse != cur.ValueInUse) + && cur.CaptureTime >= startUtc && cur.CaptureTime <= endUtc) + { + changes.Add(new ServerConfigChange( + cur.CaptureTime, cur.ConfigurationName, prev.ValueConfigured, cur.ValueConfigured, + prev.ValueInUse, cur.ValueInUse, cur.IsDynamic, cur.IsAdvanced)); + } + + prev = cur; + } + } + + return changes.OrderByDescending(c => c.ChangeTime).ThenBy(c => c.ConfigurationName, StringComparer.Ordinal).ToList(); + } + + /// <summary> + /// Diffs the sys.databases snapshots into a change per (database, setting) whose value moved between two + /// consecutive captures — the WIDE row is UNPIVOTed against <see cref="DatabaseConfigChangeSettingNames"/> so + /// the setting_name is the literal column name. Emits changes in [<paramref name="startUtc"/>, + /// <paramref name="endUtc"/>], newest first. + /// </summary> + public static List<DatabaseConfigChange> DiffDatabaseConfigChanges( + IReadOnlyList<DatabaseConfigSnapshot> snapshots, DateTime startUtc, DateTime endUtc) + { + var changes = new List<DatabaseConfigChange>(); + foreach (var dbGroup in snapshots.GroupBy(s => s.DatabaseName)) + { + DatabaseConfigSnapshot? prev = null; + foreach (var cur in dbGroup.OrderBy(s => s.CaptureTime)) + { + if (prev is not null && cur.CaptureTime >= startUtc && cur.CaptureTime <= endUtc) + { + for (var i = 0; i < DatabaseConfigChangeSettingNames.Count; i++) + { + var oldVal = i < prev.Values.Count ? prev.Values[i] : null; + var newVal = i < cur.Values.Count ? cur.Values[i] : null; + if (!string.Equals(oldVal, newVal, StringComparison.Ordinal)) + { + changes.Add(new DatabaseConfigChange( + cur.CaptureTime, cur.DatabaseName, DatabaseConfigChangeSettingNames[i], oldVal, newVal)); + } + } + } + + prev = cur; + } + } + + return changes + .OrderByDescending(c => c.ChangeTime) + .ThenBy(c => c.DatabaseName, StringComparer.Ordinal) + .ThenBy(c => c.SettingName, StringComparer.Ordinal) + .ToList(); + } + + /// <summary> + /// Set-diffs consecutive full trace-flag snapshots: a flag present in the newer capture but not the older = + /// <c>enabled</c>; present in the older but not the newer = <c>disabled</c>; present in both with a changed + /// status/scope = <c>modified</c>. Emits changes in [<paramref name="startUtc"/>, <paramref name="endUtc"/>], + /// newest first. + /// </summary> + public static List<TraceFlagChange> DiffTraceFlagChanges( + IReadOnlyList<TraceFlagSnapshot> snapshots, DateTime startUtc, DateTime endUtc) + { + var changes = new List<TraceFlagChange>(); + var byCapture = snapshots.GroupBy(s => s.CaptureTime).OrderBy(g => g.Key).ToList(); + + for (var i = 1; i < byCapture.Count; i++) + { + var changeTime = byCapture[i].Key; + if (changeTime < startUtc || changeTime > endUtc) + { + continue; + } + + var prevSnap = byCapture[i - 1].GroupBy(s => s.TraceFlag).ToDictionary(g => g.Key, g => g.First()); + var curSnap = byCapture[i].GroupBy(s => s.TraceFlag).ToDictionary(g => g.Key, g => g.First()); + + foreach (var (flag, c) in curSnap) + { + if (!prevSnap.TryGetValue(flag, out var p)) + { + changes.Add(new TraceFlagChange(changeTime, flag, null, c.Status, c.IsGlobal, c.IsSession, "enabled")); + } + else if (p.Status != c.Status || p.IsGlobal != c.IsGlobal || p.IsSession != c.IsSession) + { + changes.Add(new TraceFlagChange(changeTime, flag, p.Status, c.Status, c.IsGlobal, c.IsSession, "modified")); + } + } + + foreach (var (flag, p) in prevSnap) + { + if (!curSnap.ContainsKey(flag)) + { + changes.Add(new TraceFlagChange(changeTime, flag, p.Status, null, p.IsGlobal, p.IsSession, "disabled")); + } + } + } + + return changes.OrderByDescending(c => c.ChangeTime).ThenBy(c => c.TraceFlag).ToList(); + } +} diff --git a/PerformanceMonitor.Common/CpuSchedulerMetrics.cs b/PerformanceMonitor.Common/CpuSchedulerMetrics.cs new file mode 100644 index 000000000..f0b433501 --- /dev/null +++ b/PerformanceMonitor.Common/CpuSchedulerMetrics.cs @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; + +namespace PerformanceMonitor.Common; + +/// <summary> +/// The shared CPU Scheduler latest-snapshot projection + pressure classification — previously duplicated +/// (byte-for-byte for the classification, behaviorally identical for the projection) as +/// <c>BuildCpuSchedulerMetrics</c> / <c>ClassifyCpuPressure</c> in both Lite +/// (<c>ServerTab.CpuScheduler.cs</c>) and the Darling viewer (<c>ViewerServerTab.CpuScheduler.cs</c>). Both +/// read a single <c>cpu_scheduler_stats</c> row through <see cref="ICpuSchedulerSnapshot"/>, so the warning-flag +/// row projection, the worker-utilization math, the memory formatting, and the install/47 +/// <c>report.cpu_scheduler_pressure</c> banding all live here once. Pure (no WPF, no time helpers) — each app +/// keeps only its own data read and chart render. +/// </summary> +public static class CpuSchedulerMetrics +{ + /// <summary> + /// Projects the latest snapshot into the metric grid's labelled rows (with the collector's pressure flags + /// driving the row highlight). A null snapshot (an empty window) yields an empty grid. + /// </summary> + public static List<CpuSchedulerMetricRow> BuildMetrics(ICpuSchedulerSnapshot? s) + { + if (s is null) + { + return new List<CpuSchedulerMetricRow>(); + } + + double workerUtil = s.MaxWorkersCount > 0 + ? (double)s.TotalCurrentWorkersCount / s.MaxWorkersCount * 100.0 + : 0; + + /* Rolled-up pressure badge + recommendation (install/47 report.cpu_scheduler_pressure parity): the + raw warning booleans are kept below, but these two headline rows give the one-glance severity + + next step. The level row highlights whenever it is not NORMAL. */ + var pressure = ClassifyCpuPressure(s); + + var rows = new List<CpuSchedulerMetricRow> + { + new("Pressure Level", pressure.Level, !pressure.Level.StartsWith("NORMAL", StringComparison.Ordinal)), + new("Recommendation", pressure.Recommendation, false), + new("Schedulers", FormatInt(s.SchedulerCount), false), + new("Logical CPUs", FormatInt(s.CpuCount), false), + new("NUMA Nodes (online / total)", $"{s.NodesOnlineCount} / {s.TotalNodeCount}", false), + new("Offline CPUs", FormatInt(s.OfflineCpuCount), s.OfflineCpuWarning), + new("Max Worker Threads", FormatInt(s.MaxWorkersCount), false), + new("Current Workers", FormatInt(s.TotalCurrentWorkersCount), s.WorkerThreadExhaustionWarning), + new("Worker Utilization %", workerUtil.ToString("F1"), s.WorkerThreadExhaustionWarning), + new("Runnable Tasks", FormatInt(s.TotalRunnableTasksCount), s.RunnableTasksWarning), + new("Avg Runnable / Scheduler", s.AvgRunnableTasksCount.ToString("F2"), s.RunnableTasksWarning), + new("Work Queue Length", FormatLong(s.TotalWorkQueueCount), false), + new("Active Requests", FormatInt(s.TotalActiveRequestCount), false), + new("Queued Requests", FormatInt(s.TotalQueuedRequestCount), s.QueuedRequestsWarning), + new("Blocked Tasks", FormatInt(s.TotalBlockedTaskCount), s.BlockedTasksWarning), + new("Active Parallel Threads", FormatLong(s.TotalActiveParallelThreadCount), false), + new("Runnable Requests", FormatNullableInt(s.RunnableRequestCount), false), + new("Total Requests", FormatNullableInt(s.TotalRequestCount), false), + new("Runnable %", s.RunnablePercent.HasValue ? s.RunnablePercent.Value.ToString("F2") : "N/A", false), + new("Total Physical Memory", FormatKbAsGb(s.TotalPhysicalMemoryKb), false), + new("Available Physical Memory", FormatKbAsGb(s.AvailablePhysicalMemoryKb), s.PhysicalMemoryPressureWarning), + new("System Memory State", string.IsNullOrEmpty(s.SystemMemoryStateDesc) ? "N/A" : s.SystemMemoryStateDesc, s.PhysicalMemoryPressureWarning), + new("Physical Memory Pressure", s.PhysicalMemoryPressureWarning ? "Yes" : "No", s.PhysicalMemoryPressureWarning), + }; + + return rows; + } + + /// <summary> + /// The rolled-up CPU-scheduler pressure classification, a pure client-side derivation mirroring + /// install/47's <c>report.cpu_scheduler_pressure</c> (pressure_level + recommendation). The banding is the + /// proc's own CASE, in the same order: runnable task queue > 50 CRITICAL / > 20 HIGH / > 10 MEDIUM, + /// then worker-utilization > 90% HIGH, then the collector's worker-exhaustion / runnable-tasks / + /// queued-requests warning flags. + /// </summary> + public static CpuPressure ClassifyCpuPressure(ICpuSchedulerSnapshot s) + { + double workerUtil = s.MaxWorkersCount > 0 + ? (double)s.TotalCurrentWorkersCount / s.MaxWorkersCount * 100.0 + : 0; + + var level = + s.TotalRunnableTasksCount > 50 ? "CRITICAL - High runnable task queue" : + s.TotalRunnableTasksCount > 20 ? "HIGH - Moderate runnable task queue" : + s.TotalRunnableTasksCount > 10 ? "MEDIUM - Some runnable tasks queued" : + workerUtil > 90 ? "HIGH - Worker thread exhaustion" : + s.WorkerThreadExhaustionWarning ? "CRITICAL - Worker thread exhaustion warning" : + s.RunnableTasksWarning ? "HIGH - Runnable tasks warning" : + s.QueuedRequestsWarning ? "MEDIUM - Queued requests warning" : + "NORMAL"; + + var recommendation = + s.TotalRunnableTasksCount > 20 ? "CPU pressure detected - check for CPU-intensive queries, consider adding CPU cores" : + s.WorkerThreadExhaustionWarning ? "Worker thread exhaustion - check max worker threads setting" : + s.TotalQueuedRequestCount > 0 ? "Requests queued for execution - CPU or worker thread pressure" : + "No CPU scheduler pressure detected"; + + return new CpuPressure(level, recommendation); + } + + private static string FormatInt(int value) => value.ToString("N0"); + + private static string FormatLong(long value) => value.ToString("N0"); + + private static string FormatNullableInt(int? value) => value.HasValue ? value.Value.ToString("N0") : "N/A"; + + private static string FormatKbAsGb(long kb) + { + double gb = kb / 1024.0 / 1024.0; + return gb >= 1 ? $"{gb:F1} GB" : $"{kb / 1024.0:F0} MB"; + } +} + +/// <summary>One row of the CPU Scheduler latest-snapshot metric grid: a labelled scalar from the most recent +/// cpu_scheduler_stats row, plus whether the collector flagged it as pressure (drives the row highlight). A +/// single-row point-in-time collector has no natural top-N, so its "latest snapshot" is presented as this +/// metric/value list.</summary> +public sealed record CpuSchedulerMetricRow(string Metric, string Value, bool IsWarning); + +/// <summary>The rolled-up CPU-scheduler pressure classification for the metric grid's headline rows: the +/// banded pressure level plus its paired recommendation, mirroring install/47's +/// report.cpu_scheduler_pressure.</summary> +public sealed record CpuPressure(string Level, string Recommendation); diff --git a/PerformanceMonitor.Common/DailyHealthBand.cs b/PerformanceMonitor.Common/DailyHealthBand.cs new file mode 100644 index 000000000..5bc33c1e1 --- /dev/null +++ b/PerformanceMonitor.Common/DailyHealthBand.cs @@ -0,0 +1,326 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace PerformanceMonitor.Common +{ + /// <summary> + /// The composite health tier for a single day, used to color a Performance Calendar day cell at a + /// glance. This is deliberately a <b>composite</b> band (folding deadlocks, collection failures, CPU, + /// blocking, memory pressure and alerts into one verdict) rather than any single metric, so a month of + /// cells reads as green / amber / red days. + /// </summary> + public enum DailyHealthBand + { + /// <summary>No collection happened that day — rendered neutral grey (the muted theme tier).</summary> + NoData = 0, + + /// <summary>Collected, nothing elevated — green.</summary> + Healthy = 1, + + /// <summary>Elevated but not critical (moderate CPU, some blocking, memory pressure, or alerts) — amber.</summary> + Warning = 2, + + /// <summary>A serious day (deadlocks, collection failures, sustained high CPU, heavy blocking, or + /// severe memory pressure) — red.</summary> + Critical = 3, + } + + /// <summary> + /// The drill targets a Performance Calendar day-detail panel can offer. Which targets are shown for a + /// given day is decided by <see cref="DailyHealthBandCalculator.AvailableDrills"/> (shared, testable); + /// how each one navigates — which inner tab, and scoping the toolbar to the clicked day's window — is + /// app-specific and handled by each host (Lite's <c>ServerTab</c>, the Darling <c>ViewerServerTab</c>), + /// reusing the same tab-switch + time-window mechanism as the per-chart "Show Active Queries at This + /// Time" drill. + /// </summary> + public enum DayDrillTarget + { + /// <summary>Jump to the Deadlocks grid scoped to the day — offered only when the day had deadlocks.</summary> + Deadlocks, + + /// <summary>Jump to the Blocked Process Reports grid scoped to the day — offered only when the day had blocking.</summary> + Blocking, + + /// <summary>Jump to the Top Queries grid scoped to the day — offered on any collected day.</summary> + TopQueries, + } + + /// <summary> + /// The per-day signal counts that feed <see cref="DailyHealthBandCalculator.Classify"/>. Every field is + /// a whole-day roll-up over the same source views the Daily Summary already aggregates. A day with no + /// collection at all sets <see cref="HasData"/> = false (all other fields ignored → <see cref="DailyHealthBand.NoData"/>). + /// </summary> + public readonly record struct DailyHealthSignals + { + /// <summary>True when any collection ran that day. When false the band is always <see cref="DailyHealthBand.NoData"/>.</summary> + public bool HasData { get; init; } + + /// <summary>Deadlocks captured that day. Any (> 0) is Critical.</summary> + public long Deadlocks { get; init; } + + /// <summary>Collector runs that ended in ERROR that day. Any (> 0) is Critical — a monitoring blind spot is itself serious.</summary> + public long CollectionErrors { get; init; } + + /// <summary>CPU samples where total host CPU (SQL + other-process) was ≥ 80%. Sustained (see + /// <see cref="DailyHealthThresholds.HighCpuCriticalSamples"/>) is Critical; a few is Warning.</summary> + public long HighCpuEvents { get; init; } + + /// <summary>Blocking events that day (blocked-process reports, falling back to DMV blocking snapshots). + /// Past <see cref="DailyHealthThresholds.BlockingCriticalEvents"/> is Critical; some is Warning.</summary> + public long BlockingEvents { get; init; } + + /// <summary>Memory-pressure events (a process or system indicator ≥ 2) that were not severe. Any is Warning.</summary> + public long MemoryPressureEvents { get; init; } + + /// <summary>Severe memory-pressure events (a process indicator ≥ 3) that day. Any (> 0) is Critical.</summary> + public long MemoryCriticalEvents { get; init; } + + /// <summary>Actionable (non-resolution) alerts raised that day. At/over + /// <see cref="DailyHealthThresholds.AlertWarningCount"/> is Warning.</summary> + public long AlertCount { get; init; } + } + + /// <summary> + /// The one, documented, tunable place for the Performance Calendar's day-banding thresholds. If a month + /// of cells reads too red or too green in practice, adjust these — nothing else needs to change. The + /// defaults are chosen to be continuous with the Daily Summary's long-standing single-day banding + /// (high-CPU > 5 and blocking > 10 were the original Critical triggers). + /// </summary> + public sealed record DailyHealthThresholds + { + /// <summary>High-CPU samples (≥ 80% total host) at or above which the day is Critical ("sustained high CPU"). + /// Default 6 preserves the original "high_cpu_events > 5" Critical rule.</summary> + public int HighCpuCriticalSamples { get; init; } = 6; + + /// <summary>High-CPU samples at or above which the day is at least Warning ("moderate CPU"). Default 1: + /// any 80%+ total-host-CPU moment is worth a second look, short of the sustained (Critical) count.</summary> + public int HighCpuWarningSamples { get; init; } = 1; + + /// <summary>Blocking events at or above which the day is Critical. Default 11 preserves the original + /// "blocking_events > 10" Critical rule.</summary> + public int BlockingCriticalEvents { get; init; } = 11; + + /// <summary>Blocking events at or above which the day is at least Warning ("some blocking"). Default 1.</summary> + public int BlockingWarningEvents { get; init; } = 1; + + /// <summary>Actionable alerts at or above which the day is at least Warning. Default 1 (any alert).</summary> + public int AlertWarningCount { get; init; } = 1; + + /// <summary>The shipped defaults. Use this everywhere unless a caller has an explicit reason to override.</summary> + public static DailyHealthThresholds Default { get; } = new(); + } + + /// <summary> + /// The single, app-agnostic source of truth for the Performance Calendar's per-day composite health band. + /// Both apps (Lite over DuckDB, the Darling viewer over Postgres) roll each day's signals into a + /// <see cref="DailyHealthSignals"/> and call <see cref="Classify"/>, so the month heatmap bands identically + /// no matter which store fed it — replacing the previously twinned-and-drifted single-day "OverallHealth" + /// string logic (Lite's inline copy lacked the memory-critical escalation the viewer had). + /// </summary> + public static class DailyHealthBandCalculator + { + /// <summary> + /// Folds a day's signals into a single <see cref="DailyHealthBand"/>. Severity is first-match-wins: + /// no-data → critical checks → warning checks → healthy. + /// </summary> + /// <param name="signals">The day's rolled-up signal counts.</param> + /// <param name="thresholds">Banding thresholds; <see cref="DailyHealthThresholds.Default"/> when null.</param> + public static DailyHealthBand Classify(in DailyHealthSignals signals, DailyHealthThresholds? thresholds = null) + { + if (!signals.HasData) + return DailyHealthBand.NoData; + + var t = thresholds ?? DailyHealthThresholds.Default; + + // Critical: anything that makes the day genuinely serious. Deadlocks, a monitoring gap + // (collection errors), severe memory pressure, sustained high CPU, or heavy blocking. + if (signals.Deadlocks > 0 + || signals.CollectionErrors > 0 + || signals.MemoryCriticalEvents > 0 + || signals.HighCpuEvents >= t.HighCpuCriticalSamples + || signals.BlockingEvents >= t.BlockingCriticalEvents) + { + return DailyHealthBand.Critical; + } + + // Warning: elevated but not critical — moderate CPU, some blocking, (non-severe) memory + // pressure, or any actionable alert fired that day. + if (signals.HighCpuEvents >= t.HighCpuWarningSamples + || signals.BlockingEvents >= t.BlockingWarningEvents + || signals.MemoryPressureEvents > 0 + || signals.AlertCount >= t.AlertWarningCount) + { + return DailyHealthBand.Warning; + } + + return DailyHealthBand.Healthy; + } + + /// <summary>A short human label for the band ("No Data" / "Healthy" / "Warning" / "Critical").</summary> + public static string Label(DailyHealthBand band) => band switch + { + DailyHealthBand.Healthy => "Healthy", + DailyHealthBand.Warning => "Warning", + DailyHealthBand.Critical => "Critical", + _ => "No Data", + }; + + /// <summary> + /// The theme resource key for the band's fill brush. Resolved by each app's theme via + /// <c>DynamicResource</c> — the shared calendar control has no theme of its own. The muted tier + /// (<c>ForegroundMutedBrush</c>) is the neutral grey used for days with no collection. + /// </summary> + public static string BrushKey(DailyHealthBand band) => band switch + { + DailyHealthBand.Healthy => "SuccessBrush", + DailyHealthBand.Warning => "WarningBrush", + DailyHealthBand.Critical => "ErrorBrush", + _ => "ForegroundMutedBrush", + }; + + /// <summary> + /// Builds a multi-line tooltip summarizing the day's signals — one line per non-zero signal, or a + /// terse "No data collected." / "No issues detected." when appropriate. Suitable for a cell ToolTip. + /// </summary> + public static string Describe(in DailyHealthSignals signals) + { + if (!signals.HasData) + return "No data collected."; + + var lines = BuildSignalLines(signals, peakBlockMs: 0); + return lines.Count == 0 ? "No issues detected." : string.Join(Environment.NewLine, lines); + } + + /// <summary> + /// The day-detail "Why this day is <band>" reasons: one line per non-zero signal that drove the + /// band, using the SAME per-signal wording as the calendar tooltip (<see cref="Describe"/>) so the two + /// can't drift, plus — when <paramref name="peakBlockMs"/> is supplied (> 0) — the day's peak block + /// duration appended to the blocking line. A collected-but-quiet day returns a single "No issues + /// detected."; a day with no collection returns "No collection this day." (the day-detail phrasing, + /// distinct from the tooltip's "No data collected."). + /// </summary> + /// <param name="signals">The day's rolled-up signal counts.</param> + /// <param name="peakBlockMs">The day's peak/max block wait in ms (0 = unknown / not carried), shown on the blocking line.</param> + public static IReadOnlyList<string> BuildReasons(in DailyHealthSignals signals, long peakBlockMs = 0) + { + if (!signals.HasData) + return new[] { "No collection this day." }; + + var lines = BuildSignalLines(signals, peakBlockMs); + return lines.Count == 0 ? new List<string> { "No issues detected." } : lines; + } + + /// <summary> + /// Which day-detail drill buttons to offer for a day, in panel order. A day with no collection offers + /// none (there is nothing to drill into). Otherwise Top Queries is always offered (you can always + /// inspect what ran), Deadlocks is offered only when the day had any deadlock, and Blocking only when it + /// had any blocking event. Pure + static so both hosts and the tests share one decision. + /// </summary> + public static IReadOnlyList<DayDrillTarget> AvailableDrills(in DailyHealthSignals signals) + { + var drills = new List<DayDrillTarget>(); + if (!signals.HasData) + return drills; + + if (signals.Deadlocks > 0) + drills.Add(DayDrillTarget.Deadlocks); + if (signals.BlockingEvents > 0) + drills.Add(DayDrillTarget.Blocking); + drills.Add(DayDrillTarget.TopQueries); + return drills; + } + + /// <summary> + /// The [start, next-day-start) window for a clicked calendar day as naive-UTC bounds — the calendar + /// buckets days in UTC day-space (<c>date_trunc('day', collection_time)</c> over naive-UTC collection + /// times), so a day drill scopes to exactly that UTC day. Each host converts these bounds into its own + /// time space (Lite adds the server offset for its server-time reads; the viewer reads naive UTC + /// directly). Pure + static so the window computation is unit-testable. + /// </summary> + public static (DateTime StartUtc, DateTime EndUtc) DayWindowUtc(DateTime date) + { + var start = date.Date; + return (start, start.AddDays(1)); + } + + /// <summary> + /// The day-detail key-metrics one-liner from a day's roll-up: top wait type, high-CPU sample count, + /// total wait time, and unique query count. Formatting lives here (shared by both apps, unit-testable) + /// so the two hosts render the line identically. + /// </summary> + public static string BuildKeyMetricsLine(string? topWaitType, long highCpuEvents, decimal totalWaitSeconds, long uniqueQueries) + { + var wait = string.IsNullOrWhiteSpace(topWaitType) ? "none" : topWaitType; + var totalWait = totalWaitSeconds < 1000 + ? totalWaitSeconds.ToString("N1", CultureInfo.InvariantCulture) + " s" + : (totalWaitSeconds / 60).ToString("N1", CultureInfo.InvariantCulture) + " min"; + return string.Format( + CultureInfo.InvariantCulture, + "Top wait: {0} High-CPU samples: {1:N0} Total wait: {2} Unique queries: {3:N0}", + wait, highCpuEvents, totalWait, uniqueQueries); + } + + /// <summary>The non-empty per-signal lines for a collected day (no terminal "quiet"/"no-data" handling — + /// callers add their own). The order matches the tooltip; the blocking line carries the peak block + /// duration when one is supplied.</summary> + private static List<string> BuildSignalLines(in DailyHealthSignals signals, long peakBlockMs) + { + var lines = new List<string>(); + AppendCount(lines, signals.Deadlocks, "deadlock", "deadlocks"); + AppendCount(lines, signals.CollectionErrors, "collection error", "collection errors"); + AppendCount(lines, signals.HighCpuEvents, "high-CPU sample", "high-CPU samples"); + AppendBlocking(lines, signals.BlockingEvents, peakBlockMs); + AppendCount(lines, signals.MemoryCriticalEvents, "severe memory-pressure event", "severe memory-pressure events"); + // MemoryPressureEvents is the full count (medium + severe); severe is a subset already listed + // above, so only the non-severe remainder is shown here to avoid double-counting. + var nonSevereMemory = Math.Max(0, signals.MemoryPressureEvents - signals.MemoryCriticalEvents); + AppendCount(lines, nonSevereMemory, "memory-pressure event", "memory-pressure events"); + AppendCount(lines, signals.AlertCount, "alert", "alerts"); + return lines; + } + + private static void AppendCount(List<string> lines, long count, string singular, string plural) + { + if (count <= 0) + return; + + var noun = count == 1 ? singular : plural; + lines.Add(count.ToString("N0", CultureInfo.InvariantCulture) + " " + noun); + } + + /// <summary>The blocking line — like <see cref="AppendCount"/> but appends the day's peak block + /// duration when one is known (> 0), e.g. "42 blocking events (peak block 12.5 s)".</summary> + private static void AppendBlocking(List<string> lines, long count, long peakBlockMs) + { + if (count <= 0) + return; + + var noun = count == 1 ? "blocking event" : "blocking events"; + var line = count.ToString("N0", CultureInfo.InvariantCulture) + " " + noun; + if (peakBlockMs > 0) + line += " (peak block " + FormatDurationMs(peakBlockMs) + ")"; + lines.Add(line); + } + + /// <summary>Formats a millisecond duration compactly ("800 ms" / "12.5 s" / "3.2 min").</summary> + internal static string FormatDurationMs(long ms) + { + if (ms < 1000) + return ms.ToString("N0", CultureInfo.InvariantCulture) + " ms"; + + var seconds = ms / 1000.0; + return seconds < 60 + ? seconds.ToString("N1", CultureInfo.InvariantCulture) + " s" + : (seconds / 60).ToString("N1", CultureInfo.InvariantCulture) + " min"; + } + } +} diff --git a/PerformanceMonitor.Common/DeadlockGraphProcessParser.cs b/PerformanceMonitor.Common/DeadlockGraphProcessParser.cs new file mode 100644 index 000000000..526ee451a --- /dev/null +++ b/PerformanceMonitor.Common/DeadlockGraphProcessParser.cs @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Xml.Linq; + +namespace PerformanceMonitor.Common; + +/// <summary> +/// The shared sp_BlitzLock-style per-process deadlock-graph walk — previously duplicated byte-for-byte as +/// <c>DeadlockProcessDetail.ParseFromRows</c> in both Lite (<c>LocalDataService.Blocking.cs</c>) and the +/// Darling viewer (<c>ViewerDataService.Deadlock.cs</c>). Parallel-vs-regular classification, victim-id +/// detection, per-process owner/waiter modes, contended object names, and proc-name resolution off the +/// execution stack all live here once. Each host passes its grid rows in as <see cref="DeadlockGraphInput"/> +/// and gets back its own <typeparamref name="T"/> (a <see cref="DeadlockProcessInfo"/> subclass carrying the +/// host-specific display getters). The walk is CPU-bound XML work, so callers run it off the UI thread. +/// </summary> +public static class DeadlockGraphProcessParser +{ + /// <summary> + /// The host-neutral inputs the walk needs from one deadlock grid row: the graph XML, the event time + /// stamped onto every parsed process, the victim SQL used for the malformed-XML fallback row, and the + /// best-effort victim plan (#1368 / V7) threaded onto every process (NULL under Lite, often NULL otherwise). + /// </summary> + public readonly record struct DeadlockGraphInput( + string? DeadlockGraphXml, + DateTime? DeadlockTime, + string? VictimSqlText, + string? VictimQueryPlanXml); + + /// <summary> + /// Parses a list of deadlock rows into per-process detail rows of the caller's <typeparamref name="T"/>. + /// </summary> + public static List<T> Parse<T>(IEnumerable<DeadlockGraphInput> rows) + where T : DeadlockProcessInfo, new() + { + var details = new List<T>(); + foreach (var row in rows) + { + if (string.IsNullOrEmpty(row.DeadlockGraphXml)) + continue; + + try + { + var doc = System.Xml.Linq.XElement.Parse(row.DeadlockGraphXml); + + /* Detect parallel deadlock */ + var resourceList = doc.Descendants("resource-list").FirstOrDefault(); + var isParallel = resourceList != null && + (resourceList.Elements("exchangeEvent").Any() || resourceList.Elements("SyncPoint").Any()); + var deadlockType = isParallel ? "Parallel" : "Regular"; + + /* Get victim IDs from victim-list */ + var victimIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + foreach (var vp in doc.Descendants("victimProcess")) + { + var id = vp.Attribute("id")?.Value; + if (id != null) victimIds.Add(id); + } + + /* Parse lock resources to build per-process owner/waiter modes and object names */ + var processOwnerModes = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase); + var processWaiterModes = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase); + var processObjectNames = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase); + + if (resourceList != null) + { + var lockTypes = new[] { "objectlock", "pagelock", "keylock", "ridlock", "rowgrouplock" }; + foreach (var lockType in lockTypes) + { + foreach (var lockNode in resourceList.Elements(lockType)) + { + var objectName = lockNode.Attribute("objectname")?.Value ?? ""; + + /* Parse owners */ + foreach (var owner in lockNode.Descendants("owner")) + { + var ownerId = owner.Attribute("id")?.Value ?? ""; + var ownerMode = owner.Attribute("mode")?.Value ?? ""; + if (!string.IsNullOrEmpty(ownerId) && !string.IsNullOrEmpty(ownerMode)) + { + if (!processOwnerModes.ContainsKey(ownerId)) + processOwnerModes[ownerId] = new HashSet<string>(); + processOwnerModes[ownerId].Add(ownerMode); + } + if (!string.IsNullOrEmpty(ownerId) && !string.IsNullOrEmpty(objectName)) + { + if (!processObjectNames.ContainsKey(ownerId)) + processObjectNames[ownerId] = new HashSet<string>(); + processObjectNames[ownerId].Add(objectName); + } + } + + /* Parse waiters */ + foreach (var waiter in lockNode.Descendants("waiter")) + { + var waiterId = waiter.Attribute("id")?.Value ?? ""; + var waiterMode = waiter.Attribute("mode")?.Value ?? ""; + if (!string.IsNullOrEmpty(waiterId) && !string.IsNullOrEmpty(waiterMode)) + { + if (!processWaiterModes.ContainsKey(waiterId)) + processWaiterModes[waiterId] = new HashSet<string>(); + processWaiterModes[waiterId].Add(waiterMode); + } + if (!string.IsNullOrEmpty(waiterId) && !string.IsNullOrEmpty(objectName)) + { + if (!processObjectNames.ContainsKey(waiterId)) + processObjectNames[waiterId] = new HashSet<string>(); + processObjectNames[waiterId].Add(objectName); + } + } + } + } + } + + /* Parse each process */ + foreach (var proc in doc.Descendants("process")) + { + var id = proc.Attribute("id")?.Value ?? ""; + + /* Get proc name from execution stack */ + var procName = ""; + foreach (var frame in proc.Descendants("frame")) + { + var frameProcName = frame.Attribute("procname")?.Value ?? ""; + if (!string.IsNullOrEmpty(frameProcName) && frameProcName != "adhoc" && frameProcName != "unknown") + { + procName = frameProcName; + break; + } + } + + details.Add(new T + { + DeadlockTime = row.DeadlockTime, + ProcessId = id, + IsVictim = victimIds.Contains(id), + Spid = int.TryParse(proc.Attribute("spid")?.Value, out var spid) ? spid : 0, + DatabaseName = proc.Attribute("currentdbname")?.Value ?? "", + SqlText = proc.Element("inputbuf")?.Value?.Trim() ?? "", + WaitResource = proc.Attribute("waitresource")?.Value ?? "", + WaitTime = long.TryParse(proc.Attribute("waittime")?.Value, out var wt) ? wt : 0, + LockMode = proc.Attribute("lockMode")?.Value ?? "", + IsolationLevel = proc.Attribute("isolationlevel")?.Value ?? "", + LogUsed = long.TryParse(proc.Attribute("logused")?.Value, out var lu) ? lu : 0, + TransactionCount = int.TryParse(proc.Attribute("trancount")?.Value, out var tc) ? tc : 0, + ClientApp = proc.Attribute("clientapp")?.Value ?? "", + HostName = proc.Attribute("hostname")?.Value ?? "", + LoginName = proc.Attribute("loginname")?.Value ?? "", + Status = proc.Attribute("status")?.Value ?? "", + DeadlockGraphXml = row.DeadlockGraphXml, + VictimQueryPlanXml = row.VictimQueryPlanXml, + DeadlockType = deadlockType, + ProcName = procName, + TransactionName = proc.Attribute("transactionname")?.Value ?? "", + Priority = int.TryParse(proc.Attribute("priority")?.Value, out var pri) ? pri : 0, + LastTranStarted = DateTime.TryParse(proc.Attribute("lasttranstarted")?.Value, out var lts) ? lts : null, + LastBatchStarted = DateTime.TryParse(proc.Attribute("lastbatchstarted")?.Value, out var lbs) ? lbs : null, + LastBatchCompleted = DateTime.TryParse(proc.Attribute("lastbatchcompleted")?.Value, out var lbc) ? lbc : null, + OwnerMode = processOwnerModes.TryGetValue(id, out var om) ? string.Join(", ", om) : "", + WaiterMode = processWaiterModes.TryGetValue(id, out var wm) ? string.Join(", ", wm) : "", + ObjectNames = processObjectNames.TryGetValue(id, out var on) ? string.Join(", ", on) : "" + }); + } + } + catch + { + /* If XML parsing fails, add a single fallback row */ + details.Add(new T + { + DeadlockTime = row.DeadlockTime, + SqlText = row.VictimSqlText ?? "", + IsVictim = true, + DeadlockGraphXml = row.DeadlockGraphXml, + VictimQueryPlanXml = row.VictimQueryPlanXml + }); + } + } + return details; + } +} diff --git a/PerformanceMonitor.Common/DeadlockProcessInfo.cs b/PerformanceMonitor.Common/DeadlockProcessInfo.cs new file mode 100644 index 000000000..80b7ea77c --- /dev/null +++ b/PerformanceMonitor.Common/DeadlockProcessInfo.cs @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; + +namespace PerformanceMonitor.Common; + +/// <summary> +/// One parsed process inside a deadlock graph — the raw, host-neutral fields produced by the shared +/// sp_BlitzLock-style graph walk (<see cref="DeadlockGraphProcessParser"/>). Both apps' grid rows derive +/// from this: Lite's <c>DeadlockProcessDetail</c> and the Darling viewer's <c>DeadlockProcessDetail</c> +/// each add ONLY their host-specific, display-time getters (per-server <c>ServerTimeHelper</c> vs the +/// viewer's <c>ViewerTimeHelper</c>) on top of these settable properties, so the ~145-line XML walk that +/// fills them lives once. WPF-free by design (this is the shared leaf project); no display strings here. +/// </summary> +public class DeadlockProcessInfo +{ + public DateTime? DeadlockTime { get; set; } + public bool IsVictim { get; set; } + public string ProcessId { get; set; } = ""; + public int Spid { get; set; } + public string DatabaseName { get; set; } = ""; + public string SqlText { get; set; } = ""; + public string WaitResource { get; set; } = ""; + public long WaitTime { get; set; } + public string LockMode { get; set; } = ""; + public string IsolationLevel { get; set; } = ""; + public long LogUsed { get; set; } + public int TransactionCount { get; set; } + public string ClientApp { get; set; } = ""; + public string HostName { get; set; } = ""; + public string LoginName { get; set; } = ""; + public string Status { get; set; } = ""; + public string DeadlockGraphXml { get; set; } = ""; + public bool HasDeadlockXml => !string.IsNullOrEmpty(DeadlockGraphXml); + + /// <summary> + /// The deadlock's BEST-EFFORT victim plan (deadlocks.victim_query_plan_xml, #1368 / V7 — resolved at + /// collection time from the victim's sql_handle, only when the host sets CapturePlanXml; often NULL, + /// always NULL under Lite). The Darling viewer threads it onto every parsed detail; Lite leaves it null. + /// </summary> + public string? VictimQueryPlanXml { get; set; } + + /* New fields from sp_BlitzLock analysis */ + public string DeadlockType { get; set; } = ""; + public string ObjectNames { get; set; } = ""; + public string ProcName { get; set; } = ""; + public string OwnerMode { get; set; } = ""; + public string WaiterMode { get; set; } = ""; + public string TransactionName { get; set; } = ""; + public int Priority { get; set; } + public DateTime? LastTranStarted { get; set; } + public DateTime? LastBatchStarted { get; set; } + public DateTime? LastBatchCompleted { get; set; } +} diff --git a/PerformanceMonitor.Common/DefaultTraceEventSignificance.cs b/PerformanceMonitor.Common/DefaultTraceEventSignificance.cs new file mode 100644 index 000000000..2fee3121e --- /dev/null +++ b/PerformanceMonitor.Common/DefaultTraceEventSignificance.cs @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; + +namespace PerformanceMonitor.Common; + +/// <summary>The System-Events category a collected Default Trace event maps to.</summary> +public enum DefaultTraceEventCategory +{ + /// <summary>Data/Log file auto-grow or auto-shrink (the collector already gated these to > 1 s — a stall).</summary> + AutoGrowShrink, + + /// <summary>An error-log write (the one category with a severity floor in the significant set).</summary> + ErrorLog, + + /// <summary>Schema DDL — Object:Created / Object:Altered / Object:Deleted.</summary> + SchemaChange, + + /// <summary>Security audit — audit-change / DBCC / alter-trace events.</summary> + SecurityAudit, + + /// <summary>Server Memory Change.</summary> + MemoryChange, + + /// <summary>Any collected event that matches none of the known categories (should not occur — the + /// collector's WHERE only admits the categories above).</summary> + Other, +} + +/// <summary> +/// The significant-set gate for Default Trace events, shared by BOTH consumers so they can never drift: +/// the viewer's <c>SystemEventSignificance</c> (which feeds the System Events surface) delegates here, and +/// the headless MCP <c>get_default_trace_events</c> tool calls it directly. Kept in +/// <c>PerformanceMonitor.Common</c> (pure, dependency-free, unit-testable) precisely because the MCP host +/// must not reference the WPF viewer project — so unlike the system_health significance (a viewer class +/// with a hand-mirrored service twin), this NEW gate is authored once, in one place. +/// +/// <para>The collector's own curated WHERE is the primary filter (auto-grow/shrink already gated to > 1 s, +/// tempdb / <c>_WA_</c> auto-stats DDL excluded, config-change events dropped so the config-snapshot diff +/// owns them). The ONE additional refinement the significant set adds is a severity floor on ErrorLog: the +/// default trace records every error-log write, but only a SEVERE one belongs on the System Events surface, +/// so <see cref="SevereErrorLogMinSeverity"/> drops the routine informational writes. Every other collected +/// category is significant as-collected (the collector already made that call).</para> +/// </summary> +public static class DefaultTraceEventSignificance +{ + /// <summary> + /// Minimum ErrorLog severity to count as a significant server event (16 — the always-on "severe" floor + /// SQL Server and sp_HealthParser use; 11-15 are user-correctable, 16 is the general error boundary). + /// A default-trace ErrorLog event with NO severity (a system message carrying no severity value) is + /// kept rather than silently dropped — see <see cref="IsSignificant(DefaultTraceEventCategory, int?)"/>. + /// </summary> + public const int SevereErrorLogMinSeverity = 16; + + /// <summary> + /// Maps a trace event name (<c>sys.trace_events.name</c>) to its System-Events category, using the SAME + /// patterns the collector's WHERE admits. OrdinalIgnoreCase throughout — the auto-grow/shrink names are + /// matched by substring (they read "Data File Auto Grow", "Log File Auto Shrink", …), the rest by exact + /// name. + /// </summary> + public static DefaultTraceEventCategory Classify(string? eventName) + { + if (string.IsNullOrEmpty(eventName)) + { + return DefaultTraceEventCategory.Other; + } + + if (Contains(eventName, "Server Memory Change")) + { + return DefaultTraceEventCategory.MemoryChange; + } + + if (Contains(eventName, "Auto Grow") || Contains(eventName, "Auto Shrink")) + { + return DefaultTraceEventCategory.AutoGrowShrink; + } + + if (Equals(eventName, "ErrorLog")) + { + return DefaultTraceEventCategory.ErrorLog; + } + + if (Equals(eventName, "Object:Created") + || Equals(eventName, "Object:Altered") + || Equals(eventName, "Object:Deleted")) + { + return DefaultTraceEventCategory.SchemaChange; + } + + if (Equals(eventName, "Audit Change Audit Event") + || Equals(eventName, "Audit DBCC Event") + || Equals(eventName, "Audit Server Alter Trace Event")) + { + return DefaultTraceEventCategory.SecurityAudit; + } + + return DefaultTraceEventCategory.Other; + } + + /// <summary> + /// Whether a collected event belongs on the significant System Events set. ErrorLog is gated by severity + /// (<see cref="SevereErrorLogMinSeverity"/>, or a null severity kept); every other category — which the + /// collector's WHERE already curated — is significant as collected. <see cref="DefaultTraceEventCategory.Other"/> + /// is treated as significant too (a collected row we can't categorize still passed the collector's gate, so + /// it is surfaced rather than silently dropped). + /// </summary> + public static bool IsSignificant(DefaultTraceEventCategory category, int? severity) => + category != DefaultTraceEventCategory.ErrorLog + || severity is null + || severity.Value >= SevereErrorLogMinSeverity; + + /// <summary>Convenience overload — classifies <paramref name="eventName"/> then applies the severity gate.</summary> + public static bool IsSignificant(string? eventName, int? severity) => + IsSignificant(Classify(eventName), severity); + + private static bool Contains(string value, string token) => + value.IndexOf(token, StringComparison.OrdinalIgnoreCase) >= 0; + + private static bool Equals(string value, string other) => + string.Equals(value, other, StringComparison.OrdinalIgnoreCase); +} diff --git a/PerformanceMonitor.Common/ICpuSchedulerSnapshot.cs b/PerformanceMonitor.Common/ICpuSchedulerSnapshot.cs new file mode 100644 index 000000000..d1f3b0893 --- /dev/null +++ b/PerformanceMonitor.Common/ICpuSchedulerSnapshot.cs @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +namespace PerformanceMonitor.Common; + +/// <summary> +/// The host-neutral read surface of a single <c>cpu_scheduler_stats</c> snapshot — EXACTLY the properties the +/// shared <see cref="CpuSchedulerMetrics.BuildMetrics"/> metric projection and +/// <see cref="CpuSchedulerMetrics.ClassifyCpuPressure"/> pressure banding read off the latest row. Both apps' +/// snapshot types implement this so that (previously byte-identical) projection lives once in Common: Lite's +/// <c>CpuSchedulerSnapshot</c> class (DuckDB reads) and the Darling viewer's <c>CpuSchedulerSnapshot</c> +/// positional record (Postgres reads). <c>collection_time</c> is deliberately absent — the projection never +/// reads it (the render method keeps its own per-app time handling). +/// +/// The two averaged/percentage columns are surfaced here as <see cref="double"/> (the natural computational +/// type): Lite already stores them as <c>double</c>, so it satisfies these implicitly; the Darling record +/// stores them as Postgres-<c>NUMERIC</c> <c>decimal</c> and satisfies them with a tiny explicit-interface +/// widening cast. Everything else matches by name and type across both snapshot declarations. +/// </summary> +public interface ICpuSchedulerSnapshot +{ + int MaxWorkersCount { get; } + int SchedulerCount { get; } + int CpuCount { get; } + int TotalRunnableTasksCount { get; } + long TotalWorkQueueCount { get; } + int TotalCurrentWorkersCount { get; } + double AvgRunnableTasksCount { get; } + int TotalActiveRequestCount { get; } + int TotalQueuedRequestCount { get; } + int TotalBlockedTaskCount { get; } + long TotalActiveParallelThreadCount { get; } + int? RunnableRequestCount { get; } + int? TotalRequestCount { get; } + double? RunnablePercent { get; } + bool WorkerThreadExhaustionWarning { get; } + bool RunnableTasksWarning { get; } + bool BlockedTasksWarning { get; } + bool QueuedRequestsWarning { get; } + long TotalPhysicalMemoryKb { get; } + long AvailablePhysicalMemoryKb { get; } + string? SystemMemoryStateDesc { get; } + bool PhysicalMemoryPressureWarning { get; } + int TotalNodeCount { get; } + int NodesOnlineCount { get; } + int OfflineCpuCount { get; } + bool OfflineCpuWarning { get; } +} diff --git a/PerformanceMonitor.Common/ICpuSchedulerTrendPoint.cs b/PerformanceMonitor.Common/ICpuSchedulerTrendPoint.cs new file mode 100644 index 000000000..7d5b5ef3a --- /dev/null +++ b/PerformanceMonitor.Common/ICpuSchedulerTrendPoint.cs @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; + +namespace PerformanceMonitor.Common; + +/// <summary> +/// One per-snapshot CPU-scheduler trend point the pressure chart plots (Runnable / Blocked / Queued task +/// counts over the window). Implemented by both apps' trend types — Lite's <c>class</c> and Darling's +/// positional <c>record</c> — so the shared <c>CpuSchedulerChartRenderer</c> reads them uniformly. +/// </summary> +public interface ICpuSchedulerTrendPoint +{ + DateTime CollectionTime { get; } + int RunnableTasks { get; } + int BlockedTasks { get; } + int QueuedRequests { get; } +} diff --git a/PerformanceMonitor.Common/ISessionStatsPoint.cs b/PerformanceMonitor.Common/ISessionStatsPoint.cs new file mode 100644 index 000000000..c1257b67d --- /dev/null +++ b/PerformanceMonitor.Common/ISessionStatsPoint.cs @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; + +namespace PerformanceMonitor.Common; + +/// <summary> +/// The host-neutral read surface of a single server-wide session-summary snapshot — EXACTLY the properties the +/// shared Session Stats chart renderer (the seven <c>SessionSeriesSpec</c> selectors + the +/// <c>CollectionTime</c> the trend orders and projects on) and the shared +/// <see cref="SessionStatsSummary.Format"/> summary-strip projection (top application / top host / distinct +/// databases) read. Both apps' snapshot types implement this so the (previously byte-identical) render + summary +/// live once: Lite's <c>SessionStatsPoint</c> class (DuckDB reads) and the Darling viewer's +/// <c>SessionStatsPoint</c> positional record (Postgres reads). Every member matches by name and type across +/// both snapshot declarations — no widening seam is needed (contrast <see cref="ICpuSchedulerSnapshot"/>). +/// </summary> +public interface ISessionStatsPoint +{ + /// <summary>Snapshot time (naive UTC); the trend orders oldest-first on it and projects it to display time.</summary> + DateTime CollectionTime { get; } + + /* The seven chart-series status counts (the SessionSeriesSpec selectors, Total first). */ + int TotalSessions { get; } + int RunningSessions { get; } + int SleepingSessions { get; } + int BackgroundSessions { get; } + int DormantSessions { get; } + int IdleSessionsOver30Min { get; } + int SessionsWaitingForMemory { get; } + + /* The summary-strip attribution columns (SessionStatsSummary.Format). */ + int DatabasesWithConnections { get; } + string? TopApplicationName { get; } + int? TopApplicationConnections { get; } + string? TopHostName { get; } + int? TopHostConnections { get; } +} diff --git a/PerformanceMonitor.Common/IndexCleanupAnalyzer.cs b/PerformanceMonitor.Common/IndexCleanupAnalyzer.cs new file mode 100644 index 000000000..acedda47c --- /dev/null +++ b/PerformanceMonitor.Common/IndexCleanupAnalyzer.cs @@ -0,0 +1,1542 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace PerformanceMonitor.Common +{ + /// <summary> + /// Monitor-side C# reproduction of Erik's <c>sp_IndexCleanup</c> analysis, driven entirely by the + /// captured Stage-1 <c>index_object_stats</c> snapshot (no live proc, no database). Given the per-index + /// rows for one or more databases it emits DROP/MERGE/COMPRESS recommendations, the reconstructed T-SQL, + /// and a reclaimable-space roll-up — the same analysis a live <c>sp_IndexCleanup</c> would, for servers + /// where the proc is not (or cannot be) installed. + /// + /// <para>Faithful to <c>sp_IndexCleanup</c> v2.7 (20260701), cross-checked against its adversarial test + /// suite. Because Stage 1 captured <c>key_columns</c>/<c>included_columns</c> in the proc's EXACT + /// delimited <c>QUOTENAME</c> form, the string-comparison dedupe ports directly. <b>Auto-consolidated</b> + /// (a script is generated): <b>Unused Index</b> (Rule 1, with the <14-day-uptime caveat and + /// <=7-day auto-dedupe), <b>Exact Duplicate</b> (Rule 2 → DISABLE), <b>Key Duplicate</b> (Rule 5 → + /// keeper MERGE INCLUDES + loser DISABLE), and <b>Key Subset</b>/<b>Key Superset</b> (Rules 3/4/6 → + /// subset DISABLE + superset MERGE, with subset-chain flattening and missing-include carry). + /// <b>Key Subset</b>/<b>Key Superset</b> (Rules 3/4/6 → subset DISABLE + superset MERGE, with subset-chain + /// flattening and missing-include carry), and <b>Unique Constraint Replacement</b> (Rules 7/7.5/7.5b → a + /// nonclustered index MAKE UNIQUE + the redundant constraint DROP CONSTRAINT, with the FK-reference + /// protection, the both-direction key-set-equality guard, and duplicate-constraint collapse; Rule 7.6 then + /// re-disables any ordinary Key Duplicate of a made-unique index and folds its includes in). + /// <b>Recognized but deliberately NOT auto-consolidated</b> (surfaced as a review row, matching the + /// proc's tested behavior — its adversarial tests 9a/10a assert these are left alone): <b>Reverse + /// Duplicate</b> (same key column set, different order/direction — a different leading column serves + /// different queries) and <b>Equal Except For Filter</b> (same keys+includes, different filter — the + /// filters index different rows). Priority scoring, <c>is_eligible_for_dedupe</c>, and the + /// PK/unique-constraint/FK-reference disable protections match the proc's guards. PAGE-compression + /// candidacy and the 0.20–0.60 general savings band reproduce the proc's <c>#compression_eligibility</c> + /// / reporting math.</para> + /// + /// <para><b>Deliberately out of scope</b> (see <see cref="IndexCleanupAnalysisResult.Notes"/>): the proc's + /// <c>Same Keys Different Order</c> (REVIEW) case, largely covered by the Reverse Duplicate review rows and + /// reproducible from the captured data if later required. Script details that need metadata Stage 1 did not + /// capture (the trailing <c>ON <filegroup/partition_scheme></c> placement; sparse/legacy-LOB + /// compression exclusions) are surfaced, never guessed.</para> + /// + /// <para><b>Review-parity (July 2026 sp_IndexCleanup fix waves ae32a4c + 0abb3ef)</b>: this port carries + /// the proc review's analysis fixes — constraint-backed (PK/UC) indexes are never merge targets + /// (<see cref="IsSafeMergeTarget"/>, Msg 1907, with a script-generator backstop); filtered and + /// partitioned indexes are never promoted to MAKE UNIQUE (<see cref="IsPromotableToUnique"/> — the + /// filtered case opens a uniqueness hole, the partitioned case can't be alignment-verified monitor-side, + /// Msg 1908); the Rule 7.5 DROP CONSTRAINT / MAKE UNIQUE pairing shares one promotability gate so a + /// constraint is never dropped pointing at an index the rebuild step refuses; and the + /// <c>MinReads</c>/<c>MinWrites</c> object gates apply per-term. The phantom-partitioning-key guard + /// (<c>key_ordinal > 0</c>) lives upstream in <c>IndexObjectStatsCollector</c>. ONE deliberate + /// non-port: the proc's PER-INDEX usage floors (<c>meets_usage_floor</c>, fix 0abb3ef) — both hosts + /// hardwire the floors to 0 and no UI exposes them, so that machinery would be unreachable here; port it + /// when a floor knob ships.</para> + /// </summary> + public static class IndexCleanupAnalyzer + { + private const string Clustered = "CLUSTERED"; + private const string NonClustered = "NONCLUSTERED"; + private const string CompressionNone = "NONE"; + + /// <summary> + /// Runs the full analysis over the captured per-index rows. Rows are grouped internally by + /// (database, object) scope, so a single call may span many databases. Never throws on data content; + /// malformed/heap/columnstore/disabled rows are simply excluded from analysis. + /// </summary> + public static IndexCleanupAnalysisResult Analyze( + IEnumerable<IndexCleanupIndexInput> indexes, + IndexCleanupOptions? options = null) + { + options ??= new IndexCleanupOptions(); + + var analyzable = (indexes ?? Array.Empty<IndexCleanupIndexInput>()) + .Where(IsAnalyzable) + .Select(src => new WorkIndex(src)) + .ToList(); + + // Uptime → the unused-index caveat (< 14 days) and sp_IndexCleanup's auto-dedupe (<= 7 days). + double? uptimeDays = ResolveUptimeDays(analyzable, options); + bool uptimeWarning = uptimeDays.HasValue && uptimeDays.Value < 14; + bool dedupeOnly = options.DedupeOnly || (uptimeDays.HasValue && uptimeDays.Value <= 7); + + var recommendations = new List<IndexCleanupRecommendation>(); + var databaseRollups = new List<IndexCleanupRollup>(); + + // Rules operate per (database_id, object_id) scope; rollups aggregate per database. + foreach (var dbGroup in analyzable + .GroupBy(w => w.Src.DatabaseId) + .OrderBy(g => g.First().Src.DatabaseName, StringComparer.Ordinal)) + { + var perDatabaseAnalyzed = new List<WorkIndex>(); + + foreach (var scope in dbGroup + .GroupBy(w => w.Src.ObjectId)) + { + var scopeIndexes = scope.ToList(); + + // Object-level gates (mirror sp_IndexCleanup's #filtered_objects @min_rows/@min_reads/@min_writes). + if (!PassesObjectFilters(scopeIndexes, options)) + { + continue; + } + + ApplyRules(scopeIndexes, options, dedupeOnly, uptimeWarning); + perDatabaseAnalyzed.AddRange(scopeIndexes); + } + + // Compression candidacy is a per-index, data-driven eligibility (independent of the dedupe action). + foreach (var w in perDatabaseAnalyzed) + { + w.CanCompress = + options.ServerSupportsCompression + && w.Src.IndexId > 0 + && string.Equals(w.Src.DataCompressionDesc, CompressionNone, StringComparison.OrdinalIgnoreCase) + && w.SizeGb >= options.MinSizeGb; + } + + recommendations.AddRange(BuildRecommendations(perDatabaseAnalyzed, options)); + databaseRollups.Add(BuildRollup(dbGroup.First().Src.DatabaseName, perDatabaseAnalyzed, options)); + } + + var ordered = recommendations + .OrderBy(r => r.DatabaseName, StringComparer.Ordinal) + .ThenBy(r => r.SchemaName, StringComparer.Ordinal) + .ThenBy(r => r.TableName, StringComparer.Ordinal) + .ThenBy(r => ResultKindSort(r.ResultKind)) + .ThenBy(r => r.IndexName, StringComparer.Ordinal) + .ToList(); + + return new IndexCleanupAnalysisResult + { + Recommendations = ordered, + DatabaseRollups = databaseRollups, + OverallRollup = BuildOverallRollup(databaseRollups), + UptimeWarning = uptimeWarning, + DedupeOnlyApplied = dedupeOnly, + Notes = BuildNotes(ordered), + }; + } + + // ──────────────────────────────────────────────────────────────────────────────────────────── + // Rule application (per object scope) + // ──────────────────────────────────────────────────────────────────────────────────────────── + + private static void ApplyRules(List<WorkIndex> scope, IndexCleanupOptions options, bool dedupeOnly, bool uptimeWarning) + { + if (!dedupeOnly) + { + ApplyUnused(scope, uptimeWarning); + } + + // Auto-consolidating rules (consume-and-mark, in sp_IndexCleanup's order). Reverse Duplicate and + // Equal Except For Filter are intentionally NOT here — the proc recognizes but does not + // consolidate them; they are detected non-destructively in DetectReviewRelationships. + ApplyExactDuplicate(scope); + ApplyKeySubset(scope); + ResolveSubsetChains(scope); + ApplyKeySuperset(scope); + ApplyKeyDuplicate(scope); + + // Rule 7 family: unique-constraint replacement (MAKE UNIQUE / DROP CONSTRAINT). Runs LAST so it + // sees the final consolidation state (and, exactly like the proc's Rule 7.5, may override an + // earlier Unused/Key-Duplicate marking on a nonclustered index whose keys EXACTLY match a + // unique constraint). + ApplyUniqueConstraintReplacement(scope); + } + + /// <summary> + /// Rule 1: an eligible (nonclustered, non-unique, non-PK/UC/FK-reference) index with no reads at all + /// is unused → DISABLE. Skipped entirely under dedupe-only. The consolidation label carries the + /// <14-day-uptime caveat when applicable. + /// </summary> + private static void ApplyUnused(List<WorkIndex> scope, bool uptimeWarning) + { + foreach (var w in scope) + { + if (w.Processed || !w.IsEligibleForDedupe) + { + continue; + } + + if (w.Src.UserSeeks == 0 && w.Src.UserScans == 0 && w.Src.UserLookups == 0 + && !w.Src.IsPrimaryKey && !w.Src.IsUniqueConstraint && !w.Src.IsUnique + && !w.Src.IsForeignKeyReference // protection (redundant with is_unique, kept per spec item 3) + && w.Src.IndexId != 1) + { + w.ConsolidationRule = uptimeWarning + ? IndexCleanupRules.UnusedIndexUptimeWarning + : IndexCleanupRules.UnusedIndex; + w.Action = IndexCleanupAction.Disable; + w.Processed = true; + } + } + } + + /// <summary>Rule 2: identical key string (with direction) + identical includes + identical filter.</summary> + private static void ApplyExactDuplicate(List<WorkIndex> scope) + { + foreach (var group in DedupeCandidates(scope) + .GroupBy(w => (w.KeyColumns, w.IncludedColumns, w.Filter), KeyIncludeFilterComparer.Instance) + .Where(g => g.Count() > 1)) + { + LabelDuplicateGroup(group.ToList(), IndexCleanupRules.ExactDuplicate); + } + } + + /// <summary> + /// Rule 3: a narrower non-unique index whose key string is a leading prefix (at a ", " boundary) of a + /// wider index's key, with a matching filter, is disabled in favor of the closest (shortest-key) + /// superset. Direction consistency on the shared prefix is enforced by the string prefix itself. + /// </summary> + private static void ApplyKeySubset(List<WorkIndex> scope) + { + foreach (var narrow in DedupeCandidates(scope) + .Where(w => !w.Src.IsUnique) // never disable a unique narrower via supersession + .OrderBy(w => w.Name, StringComparer.Ordinal)) + { + if (narrow.Processed) + { + continue; + } + + var prefix = narrow.KeyColumns + ", "; + var target = scope + .Where(w => !ReferenceEquals(w, narrow) + && !w.Processed + && w.IsEligibleForDedupe + && IsSafeMergeTarget(w) // never defer to a PK/UC-backed index: its DROP_EXISTING merge rebuild fails (Msg 1907) while this subset's DISABLE succeeds + && string.Equals(w.Filter, narrow.Filter, StringComparison.Ordinal) + && w.KeyColumns.StartsWith(prefix, StringComparison.Ordinal)) + .OrderBy(w => w.KeyColumns.Length) // closest superset = fewest extra key columns + .ThenBy(w => w.Name, StringComparer.Ordinal) + .FirstOrDefault(); + + if (target != null) + { + narrow.ConsolidationRule = IndexCleanupRules.KeySubset; + narrow.Action = IndexCleanupAction.Disable; + narrow.TargetIndexName = target.Name; + narrow.Processed = true; + // The superset is labelled in ApplyKeySuperset (it may absorb several subsets). + } + } + } + + /// <summary>Flattens subset chains (A → B → C becomes A → C) so include-merge reaches the final superset.</summary> + private static void ResolveSubsetChains(List<WorkIndex> scope) + { + var byName = scope.Where(w => w.Name.Length > 0) + .GroupBy(w => w.Name, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); + + bool changed = true; + while (changed) + { + changed = false; + foreach (var w in scope) + { + if (w.ConsolidationRule == IndexCleanupRules.KeySubset + && w.Action == IndexCleanupAction.Disable + && w.TargetIndexName != null + && byName.TryGetValue(w.TargetIndexName, out var mid) + && mid.ConsolidationRule == IndexCleanupRules.KeySubset + && mid.Action == IndexCleanupAction.Disable + && mid.TargetIndexName != null + && !string.Equals(mid.TargetIndexName, w.TargetIndexName, StringComparison.Ordinal)) + { + w.TargetIndexName = mid.TargetIndexName; + changed = true; + } + } + } + } + + /// <summary> + /// Rule 4/6: a wider index that one or more subsets defer to becomes a Key Superset — kept but rebuilt + /// with the subsets' include columns merged in (excluding any already in its key). Carries the + /// "Supersedes …" list and the newly-merged (missing) include columns. + /// </summary> + private static void ApplyKeySuperset(List<WorkIndex> scope) + { + foreach (var superset in scope) + { + if (superset.Processed) + { + continue; + } + + var subsets = scope + .Where(s => s.ConsolidationRule == IndexCleanupRules.KeySubset + && s.Action == IndexCleanupAction.Disable + && string.Equals(s.TargetIndexName, superset.Name, StringComparison.Ordinal)) + .OrderBy(s => s.Name, StringComparer.Ordinal) + .ToList(); + + if (subsets.Count == 0) + { + continue; + } + + var keyNames = new HashSet<string>(superset.KeyNames, StringComparer.Ordinal); + var ownIncludes = new HashSet<string>(superset.IncludeTokens, StringComparer.Ordinal); + + // Merge = superset's own includes + every subset's includes, minus anything already a key column. + var merged = new List<string>(); + var seen = new HashSet<string>(StringComparer.Ordinal); + foreach (var token in superset.IncludeTokens.Concat(subsets.SelectMany(s => s.IncludeTokens))) + { + if (keyNames.Contains(token) || !seen.Add(token)) + { + continue; + } + merged.Add(token); + } + merged.Sort(StringComparer.Ordinal); // deterministic, matches the collector's name-ordered includes + + var missing = merged.Where(t => !ownIncludes.Contains(t)).ToList(); + + superset.ConsolidationRule = IndexCleanupRules.KeySuperset; + superset.Action = IndexCleanupAction.MergeIncludes; + superset.MergedIncludes = merged; + superset.MissingIncludedColumns = missing.Count > 0 ? string.Join(", ", missing) : null; + superset.SupersededBy = "Supersedes " + string.Join(", ", subsets.Select(s => s.Name)); + superset.Processed = true; + } + } + + /// <summary> + /// Rule 5: indexes with the same keys (and filter) but different includes. The keeper (unique first, + /// then priority, then name) absorbs the losers' includes (MERGE INCLUDES); the losers are disabled. + /// </summary> + private static void ApplyKeyDuplicate(List<WorkIndex> scope) + { + foreach (var group in DedupeCandidates(scope) + .GroupBy(w => (w.KeyColumns, w.Filter), KeyFilterComparer.Instance) + .Where(g => g.Count() > 1)) + { + var members = group.ToList(); + var keeper = PickKeyDuplicateKeeper(members); + if (keeper == null) + { + continue; + } + + var mergedIncludes = new List<string>(); + var seen = new HashSet<string>(StringComparer.Ordinal); + foreach (var token in members.SelectMany(m => m.IncludeTokens)) + { + if (seen.Add(token)) + { + mergedIncludes.Add(token); + } + } + mergedIncludes.Sort(StringComparer.Ordinal); + + var losers = members.Where(m => !ReferenceEquals(m, keeper)).ToList(); + var disabled = new List<WorkIndex>(); + foreach (var loser in losers) + { + loser.ConsolidationRule = IndexCleanupRules.KeyDuplicate; + loser.Processed = true; + if (loser.Protected) + { + continue; // never disable a protected index + } + loser.Action = IndexCleanupAction.Disable; + loser.TargetIndexName = keeper.Name; + disabled.Add(loser); + } + + keeper.ConsolidationRule = IndexCleanupRules.KeyDuplicate; + keeper.Action = IndexCleanupAction.MergeIncludes; + keeper.MergedIncludes = mergedIncludes; + keeper.Processed = true; + if (disabled.Count > 0) + { + keeper.SupersededBy = "Supersedes " + string.Join(", ", disabled.Select(d => d.Name).OrderBy(n => n, StringComparer.Ordinal)); + } + } + } + + /// <summary> + /// Rule 7 family — Unique Constraint Replacement (sp_IndexCleanup Rules 7, 7.5, 7.5b), reproduced + /// faithfully against the live proc (verified with <c>@debug = 1</c>): + /// <list type="bullet"> + /// <item><b>Rule 7</b> (set-match, tentative): an eligible nonclustered index whose KEY-COLUMN SET + /// equals a unique constraint's (both-direction set equality — extra key columns disqualify) is + /// tentatively marked MAKE UNIQUE (if not already unique) or KEEP (already unique). A unique + /// constraint self-matches, so a lone/duplicate constraint is marked KEEP here — the precondition for + /// Rule 7.5b.</item> + /// <item><b>Rule 7.5 step 1</b>: a unique constraint that has an EXACT-<c>key_columns</c> (order + + /// direction) match to a TRUE nonclustered index (not another constraint) is marked DISABLE, pointing + /// at that index — UNLESS the constraint backs an inbound foreign-key reference (dropping it would + /// break referential integrity).</item> + /// <item><b>Rule 7.5 step 2</b>: every true nonclustered index with an EXACT-<c>key_columns</c> + /// matching constraint is (re)marked MAKE UNIQUE. Unconditional on prior state, so it OVERRIDES an + /// earlier Unused / Key-Duplicate marking on that index (verified against the proc).</item> + /// <item><b>Rule 7.5 cleanup</b>: any MAKE UNIQUE index with no disabled constraint pointing back at + /// it (the constraint was FK-protected, or Rule 7 set-matched a reversed-order index with no exact + /// match) is reverted to no action — matching the proc, this does NOT restore a prior marking.</item> + /// <item><b>Rule 7.5b</b>: two or more unique constraints with identical key columns + filter and no + /// nonclustered index to promote (all left KEEP by Rule 7) collapse to the single deterministic winner + /// (highest priority, then alphabetically-first name); the rest are demoted to DISABLE (DROP + /// CONSTRAINT). An FK-referenced constraint is never demoted.</item> + /// </list> + /// A MAKE UNIQUE index renders as a UNIQUE <c>DROP_EXISTING</c> rebuild (MERGE bucket); a disabled + /// constraint renders as <c>DROP CONSTRAINT</c> (<see cref="IndexCleanupResultKind.DisableConstraint"/>). + /// </summary> + private static void ApplyUniqueConstraintReplacement(List<WorkIndex> scope) + { + const string Ucr = IndexCleanupRules.UniqueConstraintReplacement; + + // Rule 7: tentative set-match marking (both-direction key-column SET equality — direction- and + // order-independent). A unique constraint self-matches (that is how a lone/duplicate constraint + // gets KEEP here, the Rule 7.5b precondition). Only still-unprocessed eligible rows are touched. + foreach (var w in scope) + { + if (w.Processed || !w.IsEligibleForDedupe || w.Keys.Count == 0) + { + continue; + } + + bool hasKeySetMatchingConstraint = scope.Any(u => + u.Src.IsUniqueConstraint && u.KeyNameSet.SetEquals(w.KeyNameSet)); + + if (hasKeySetMatchingConstraint) + { + // The MAKE UNIQUE arm only fires for a promotable index (not filtered, not + // partitioned, not constraint-backed — see IsPromotableToUnique). An already-unique + // row still gets its informational KEEP; a non-promotable non-unique row is left + // for the other rules rather than mislabelled as a constraint replacement. + if (!w.Src.IsUnique && !IsPromotableToUnique(w)) + { + continue; + } + + w.ConsolidationRule = Ucr; + w.Action = w.Src.IsUnique ? IndexCleanupAction.Keep : IndexCleanupAction.MakeUnique; + w.Processed = true; + } + } + + // Rule 7.5 step 1: mark each unique constraint an EXACT-key nonclustered index can replace for + // DISABLE (→ DROP CONSTRAINT), pointing at that index. Guards: never drop an FK-referenced + // constraint (silently breaks referential integrity); the replacement must be a TRUE nonclustered + // index, not another constraint — otherwise two identical constraints would mutually disable and + // BOTH get dropped (that case is Rule 7.5b's job). + foreach (var uc in scope) + { + if (!uc.Src.IsUniqueConstraint || uc.Src.IsForeignKeyReference) + { + continue; + } + + var replacement = scope + .Where(n => IsPromotableToUnique(n) // same gate as step 2, so a constraint is never disabled pointing at an index step 2 will refuse to promote (an orphaned DROP CONSTRAINT = uniqueness silently gone) + && !string.Equals(n.Name, uc.Name, StringComparison.Ordinal) + && string.Equals(n.KeyColumns, uc.KeyColumns, StringComparison.Ordinal)) + .OrderBy(n => n.Name, StringComparer.Ordinal) // deterministic pick if several match + .FirstOrDefault(); + + if (replacement != null) + { + uc.ConsolidationRule = Ucr; + uc.Action = IndexCleanupAction.Disable; + uc.TargetIndexName = replacement.Name; + uc.Processed = true; + } + } + + // Rule 7.5 step 2: (re)mark every true nonclustered index with an EXACT-key matching constraint as + // MAKE UNIQUE. Unconditional on prior state — deliberately overrides an earlier Unused/Key-Duplicate + // marking (the proc's UPDATE has no such guard; verified live). + foreach (var nc in scope) + { + // IsPromotableToUnique enforces what this step's comment always promised ("every TRUE + // nonclustered index"): nonclustered, not constraint-backed (a PK-backed or clustered row + // here would draw a DROP_EXISTING rebuild that fails Msg 1907), not filtered, not + // partitioned — mirroring sp_IndexCleanup fixes ae32a4c. + if (!IsPromotableToUnique(nc)) + { + continue; + } + + bool hasExactMatchingConstraint = scope.Any(u => + u.Src.IsUniqueConstraint + && string.Equals(u.KeyColumns, nc.KeyColumns, StringComparison.Ordinal)); + + if (hasExactMatchingConstraint) + { + // Only the rule/action/target change — the proc's Rule 7.5 step 2 touches nothing else. + // Crucially, a Key-Superset index's absorbed includes (MergedIncludes / SupersededBy) must + // SURVIVE into the MAKE UNIQUE rebuild (the proc physically rewrote included_columns in + // Rule 4/6, and step 2 never clears it); the Rule 7.5 superseded_by pass below appends to + // whatever "Supersedes …" text is already there. + nc.ConsolidationRule = Ucr; + nc.Action = IndexCleanupAction.MakeUnique; + nc.TargetIndexName = null; + nc.Processed = true; + } + } + + // Rule 7.5 cleanup: revert any MAKE UNIQUE index with no disabled constraint pointing back at it + // (FK-protected constraint, or Rule 7's set-match promoted a reversed-order index with no exact + // match). Mirrors the proc: reverts to NO action, WITHOUT restoring any prior marking. + foreach (var nc in scope) + { + if (nc.Action != IndexCleanupAction.MakeUnique) + { + continue; + } + + bool backedByDisabledConstraint = scope.Any(u => + u.Action == IndexCleanupAction.Disable + && string.Equals(u.KeyColumns, nc.KeyColumns, StringComparison.Ordinal) + && string.Equals(u.TargetIndexName, nc.Name, StringComparison.Ordinal)); + + if (!backedByDisabledConstraint) + { + nc.ConsolidationRule = null; + nc.Action = IndexCleanupAction.None; + nc.TargetIndexName = null; + nc.SupersededBy = null; + nc.Processed = false; + } + } + + // Rule 7.5 superseded_by: annotate each surviving MAKE UNIQUE index with the constraint(s) it replaces. + foreach (var nc in scope) + { + if (nc.Action != IndexCleanupAction.MakeUnique) + { + continue; + } + + foreach (var uc in scope + .Where(u => u.Action == IndexCleanupAction.Disable + && string.Equals(u.TargetIndexName, nc.Name, StringComparison.Ordinal)) + .OrderBy(u => u.Name, StringComparer.Ordinal)) + { + nc.SupersededBy = nc.SupersededBy == null + ? "Will replace constraint " + uc.Name + : nc.SupersededBy + ", will replace constraint " + uc.Name; + } + } + + // Rule 7.5b: collapse duplicate unique constraints with no nonclustered index to promote. + ApplyDuplicateUniqueConstraints(scope); + + // Rule 7.6: re-disable ordinary Key Duplicates of a MAKE UNIQUE winner (an index sharing the + // winner's exact keys+filter that step 2 briefly promoted then the cleanup reverted, or one no + // earlier rule had touched) and fold its includes into the winner. + ApplyKeyDuplicatesOfMakeUnique(scope); + } + + /// <summary> + /// Rule 7.6 (sp_IndexCleanup lines ~4143-4272): after a unique constraint is replaced by a MAKE UNIQUE + /// index, any OTHER nonclustered index with the winner's exact key columns + filter (and no action yet) + /// is a plain Key Duplicate of it — DISABLE it and fold its includes into the winner. Without this, an + /// index that Rule 5 had disabled but Rule 7.5 step 2 promoted-then-reverted would be silently left in + /// place (a redundant index un-disabled). Emits the <b>Key Duplicate</b> rule, not Unique Constraint + /// Replacement — it is the same consolidation the proc applies. (Rule 7.6's sibling behavior of folding + /// includes is reproduced; the winner keeps its own + every folded loser's includes.) + /// </summary> + private static void ApplyKeyDuplicatesOfMakeUnique(List<WorkIndex> scope) + { + foreach (var winner in scope) + { + if (winner.Action != IndexCleanupAction.MakeUnique) + { + continue; + } + + // Exact key columns + filter match (the proc's key_filter_hash), still-unassigned, non-constraint. + var losers = scope + .Where(d => !d.Processed + && d.Action == IndexCleanupAction.None + && d.ConsolidationRule == null + && !d.Src.IsUniqueConstraint + && !ReferenceEquals(d, winner) + && string.Equals(d.KeyColumns, winner.KeyColumns, StringComparison.Ordinal) + && string.Equals(d.Filter, winner.Filter, StringComparison.Ordinal)) + .OrderBy(d => d.Name, StringComparer.Ordinal) + .ToList(); + + if (losers.Count == 0) + { + continue; + } + + foreach (var loser in losers) + { + loser.ConsolidationRule = IndexCleanupRules.KeyDuplicate; + loser.Action = IndexCleanupAction.Disable; + loser.TargetIndexName = winner.Name; + loser.Processed = true; + } + + // Fold the losers' includes into the winner (dedup; the winner keeps its own/absorbed includes). + var merged = new List<string>(); + var seen = new HashSet<string>(StringComparer.Ordinal); + foreach (var token in (winner.MergedIncludes ?? winner.IncludeTokens).Concat(losers.SelectMany(l => l.IncludeTokens))) + { + if (seen.Add(token)) + { + merged.Add(token); + } + } + merged.Sort(StringComparer.Ordinal); // deterministic, matches the collector's name-ordered includes + winner.MergedIncludes = merged; + + var loserNames = string.Join(", ", losers.Select(l => l.Name)); + winner.SupersededBy = winner.SupersededBy == null + ? "Supersedes " + loserNames + : winner.SupersededBy + ", " + loserNames; + } + } + + /// <summary> + /// Rule 7.5b: two or more unique constraints with identical key columns + filter, all left KEEP by + /// Rule 7 (no nonclustered index promoted them). Keep the single deterministic winner (highest + /// priority, then alphabetically-first name); demote every other to DISABLE (→ DROP CONSTRAINT) + /// pointing at the winner. An FK-referenced constraint is never demoted. Demotions are computed + /// against the pre-demotion KEEP set (set-based, like the proc's single UPDATE) so the outcome is + /// order-independent and every loser targets the same survivor. + /// </summary> + private static void ApplyDuplicateUniqueConstraints(List<WorkIndex> scope) + { + var keepers = scope + .Where(w => w.Action == IndexCleanupAction.Keep + && w.ConsolidationRule == IndexCleanupRules.UniqueConstraintReplacement + && w.Src.IsUniqueConstraint) + .ToList(); + + if (keepers.Count < 2) + { + return; + } + + var demotions = new List<(WorkIndex Loser, WorkIndex Keeper)>(); + foreach (var loser in keepers) + { + if (loser.Src.IsForeignKeyReference) + { + continue; // never drop an FK-referenced constraint + } + + var keeper = keepers.FirstOrDefault(k => + !ReferenceEquals(k, loser) + && string.Equals(k.KeyColumns, loser.KeyColumns, StringComparison.Ordinal) + && string.Equals(k.Filter, loser.Filter, StringComparison.Ordinal) + && LosesTo(loser, k) + && IsUndisputedWinner(k, keepers)); + + if (keeper != null) + { + demotions.Add((loser, keeper)); + } + } + + foreach (var (loser, keeper) in demotions) + { + loser.Action = IndexCleanupAction.Disable; + loser.TargetIndexName = keeper.Name; + } + } + + /// <summary>The keep/drop tiebreak (mirrors Rule 2): lower priority loses; on a tie the alphabetically-later name loses.</summary> + private static bool LosesTo(WorkIndex loser, WorkIndex keeper) => + loser.Priority < keeper.Priority + || (loser.Priority == keeper.Priority + && string.Compare(loser.Name, keeper.Name, StringComparison.Ordinal) > 0); + + /// <summary>True when no sibling duplicate constraint beats <paramref name="keeper"/> — makes the winner (and every loser's target) deterministic when 3+ constraints tie.</summary> + private static bool IsUndisputedWinner(WorkIndex keeper, List<WorkIndex> keepers) => + !keepers.Any(other => + !ReferenceEquals(other, keeper) + && string.Equals(other.KeyColumns, keeper.KeyColumns, StringComparison.Ordinal) + && string.Equals(other.Filter, keeper.Filter, StringComparison.Ordinal) + && (other.Priority > keeper.Priority + || (other.Priority == keeper.Priority + && string.Compare(other.Name, keeper.Name, StringComparison.Ordinal) < 0))); + + // ──────────────────────────────────────────────────────────────────────────────────────────── + // Keeper / grouping helpers + // ──────────────────────────────────────────────────────────────────────────────────────────── + + /// <summary>Indexes eligible to participate in the string-compare dedupe rules (nonclustered, not a unique constraint, still unprocessed).</summary> + private static IEnumerable<WorkIndex> DedupeCandidates(List<WorkIndex> scope) => + scope.Where(w => !w.Processed && w.IsEligibleForDedupe && !w.Src.IsUniqueConstraint); + + /// <summary> + /// Whether an index may survive a consolidation as the REBUILT side (Rule 5 keeper / Rule 4-6 + /// superset). A constraint-backed index (PK/UC) cannot be rebuilt with CREATE INDEX ... + /// DROP_EXISTING (Msg 1907), so choosing one pairs a script that fails with loser DISABLEs that + /// succeed — the losers' covering columns vanish with nothing absorbing them. Mirrors + /// sp_IndexCleanup fix ae32a4c ("constraints are now excluded as merge targets"). A plain unique + /// index (no backing constraint) IS a legal target (fix 0abb3ef). + /// </summary> + private static bool IsSafeMergeTarget(WorkIndex w) => + !w.Src.IsPrimaryKey && !w.Src.IsUniqueConstraint; + + /// <summary> + /// Whether an index may be promoted to MAKE UNIQUE as a unique-constraint replacement (Rule 7 + /// family). On top of <see cref="IsSafeMergeTarget"/> (the rebuild is a DROP_EXISTING), the + /// replacement must be a TRUE nonclustered index (both step comments always said so; the checks + /// now enforce it), must NOT be filtered (a filtered unique index enforces uniqueness only over + /// rows matching its predicate — replacing a constraint with one opens a uniqueness hole; + /// sp_IndexCleanup fix ae32a4c), and must NOT be partitioned: a unique index's partitioning column + /// must be part of its key (Msg 1908), and Stage 1 does not capture the partitioning column, so + /// the alignment requirement cannot be verified monitor-side — the proc permits the verified + /// aligned case; we conservatively skip all partitioned candidates. + /// </summary> + private static bool IsPromotableToUnique(WorkIndex w) => + w.IsEligibleForDedupe + && IsSafeMergeTarget(w) + && w.Filter.Length == 0 + && (w.Src.PartitionCount ?? 1) <= 1; + + /// <summary> + /// Labels a set of equivalent duplicates: the highest-priority (then lowest-name) member is the + /// keeper (KEEP); the rest are disabled (unless protected, in which case the disable is skipped — + /// the protection is a hard guarantee). If the natural keeper is unprotected but a protected member + /// exists, the protected member is promoted to keeper so nothing protected is ever disabled. + /// </summary> + private static void LabelDuplicateGroup(List<WorkIndex> members, string rule) + { + var keeper = members + .OrderByDescending(m => m.Protected) // never disable a protected index → make it the keeper + .ThenByDescending(m => m.Priority) + .ThenBy(m => m.Name, StringComparer.Ordinal) + .First(); + + foreach (var m in members) + { + m.ConsolidationRule = rule; + m.Processed = true; + + if (ReferenceEquals(m, keeper)) + { + m.Action = IndexCleanupAction.Keep; + continue; + } + + if (m.Protected) + { + // Two protected members in one duplicate set (rare): keep both, disable neither. + m.Action = IndexCleanupAction.Keep; + continue; + } + + m.Action = IndexCleanupAction.Disable; + m.TargetIndexName = keeper.Name; + } + } + + /// <summary>Rule 5 keeper: prefer a unique member, then priority, then name.</summary> + private static WorkIndex? PickKeyDuplicateKeeper(List<WorkIndex> members) => + members + .Where(IsSafeMergeTarget) // the keeper is REBUILT (MERGE INCLUDES via DROP_EXISTING) — a PK-backed member can't be (Msg 1907); it stays protected among the losers instead (never disabled) + .OrderByDescending(m => m.Protected) + .ThenByDescending(m => m.Src.IsUnique) + .ThenByDescending(m => m.Priority) + .ThenBy(m => m.Name, StringComparer.Ordinal) + .FirstOrDefault(); + + /// <summary> + /// Reverse Duplicate: the same key column SET but a different arrangement (reversed/reordered order + /// or changed sort directions), with the same includes and filter. NOT an exact duplicate (the key + /// strings differ). A review signal only — never a disable. + /// </summary> + private static bool IsReverseDuplicate(WorkIndex a, WorkIndex b) => + a.Keys.Count > 0 + && a.Keys.Count == b.Keys.Count + && !string.Equals(a.KeyColumns, b.KeyColumns, StringComparison.Ordinal) // not an exact duplicate + && a.KeyNameSet.SetEquals(b.KeyNameSet) // same column membership + && string.Equals(a.IncludedColumns, b.IncludedColumns, StringComparison.Ordinal) + && string.Equals(a.Filter, b.Filter, StringComparison.Ordinal); + + /// <summary> + /// Equal Except For Filter: byte-identical keys (with direction) and includes, but a different filter + /// predicate. A review signal only — never a disable (the filters index different rows). + /// </summary> + private static bool IsEqualExceptForFilter(WorkIndex a, WorkIndex b) => + string.Equals(a.KeyColumns, b.KeyColumns, StringComparison.Ordinal) + && string.Equals(a.IncludedColumns, b.IncludedColumns, StringComparison.Ordinal) + && !string.Equals(a.Filter, b.Filter, StringComparison.Ordinal); + + /// <summary> + /// Non-destructive detection of Reverse Duplicate and Equal Except For Filter relationships among the + /// indexes a scope KEPT (action None/Keep). Mirrors sp_IndexCleanup's stance (adversarial tests + /// 9a/10a): recognize the near-duplicate, but do NOT consolidate it — emit a review row so a human can + /// decide. One row per participating index per relationship type. + /// </summary> + private static IEnumerable<IndexCleanupRecommendation> DetectReviewRelationships(List<WorkIndex> scope) + { + var survivors = scope + .Where(w => w.IsEligibleForDedupe + && !w.Src.IsUniqueConstraint + && (w.Action == IndexCleanupAction.None || w.Action == IndexCleanupAction.Keep)) + .OrderBy(w => w.Name, StringComparer.Ordinal) + .ToList(); + + for (int i = 0; i < survivors.Count; i++) + { + var a = survivors[i]; + var reverse = new List<string>(); + var equalExceptFilter = new List<string>(); + + foreach (var b in survivors) + { + if (ReferenceEquals(a, b)) + { + continue; + } + + if (IsReverseDuplicate(a, b)) + { + reverse.Add(b.Name); + } + else if (IsEqualExceptForFilter(a, b)) + { + equalExceptFilter.Add(b.Name); + } + } + + if (reverse.Count > 0) + { + yield return BuildReview(a, IndexCleanupRules.ReverseDuplicate, reverse, + "Same key columns in a different order/direction as: " + string.Join(", ", reverse) + + ". A different leading column serves different queries — review whether both are needed (not auto-disabled)."); + } + + if (equalExceptFilter.Count > 0) + { + yield return BuildReview(a, IndexCleanupRules.EqualExceptForFilter, equalExceptFilter, + "Identical keys and includes but a different filter than: " + string.Join(", ", equalExceptFilter) + + ". The filters index different rows — review whether both are needed (not auto-disabled)."); + } + } + } + + // ──────────────────────────────────────────────────────────────────────────────────────────── + // Recommendation + script assembly + // ──────────────────────────────────────────────────────────────────────────────────────────── + + private static IEnumerable<IndexCleanupRecommendation> BuildRecommendations(List<WorkIndex> analyzed, IndexCleanupOptions options) + { + foreach (var w in analyzed) + { + if (w.Action == IndexCleanupAction.Disable) + { + // A disabled unique constraint (Rule 7.5 / 7.5b) is a DROP CONSTRAINT, not ALTER INDEX DISABLE. + yield return w.Src.IsUniqueConstraint ? BuildDisableConstraint(w) : BuildDisable(w); + } + else if (w.Action == IndexCleanupAction.MakeUnique) + { + yield return BuildMakeUnique(w, options); + } + else if (w.Action == IndexCleanupAction.MergeIncludes) + { + yield return BuildMerge(w, options); + } + else if ((w.Action == IndexCleanupAction.None || w.Action == IndexCleanupAction.Keep) && w.CanCompress) + { + // Only untouched / kept indexes get a compression row (matches sp_IndexCleanup). + yield return BuildCompress(w, options); + } + } + + // Non-destructive Reverse Duplicate / Equal Except For Filter review rows, per object scope. An + // index may appear here AND in a compression row — the review flag is additive metadata. + foreach (var scope in analyzed.GroupBy(w => w.Src.ObjectId)) + { + foreach (var review in DetectReviewRelationships(scope.ToList())) + { + yield return review; + } + } + } + + private static IndexCleanupRecommendation BuildDisable(WorkIndex w) + { + string additionalInfo = w.ConsolidationRule switch + { + IndexCleanupRules.KeySubset => "This index is superseded by a wider index: " + (w.TargetIndexName ?? "(unknown)"), + IndexCleanupRules.ExactDuplicate => "This index is an exact duplicate of: " + (w.TargetIndexName ?? "(unknown)"), + IndexCleanupRules.KeyDuplicate => "This index has the same keys as: " + (w.TargetIndexName ?? "(unknown)"), + _ when w.ConsolidationRule != null && w.ConsolidationRule.StartsWith(IndexCleanupRules.UnusedIndex, StringComparison.Ordinal) => w.ConsolidationRule, + _ => "This index is redundant and will be disabled", + }; + + return NewRecommendation(w) with + { + Action = IndexCleanupAction.Disable, + ResultKind = IndexCleanupResultKind.Disable, + TargetIndexName = w.TargetIndexName, + ScriptType = "DISABLE SCRIPT", + Script = DisableScript(w), + AdditionalInfo = additionalInfo, + }; + } + + private static IndexCleanupRecommendation BuildMerge(WorkIndex w, IndexCleanupOptions options) + { + // Backstop (mirrors sp_IndexCleanup ae32a4c's script-generator guard): a constraint-backed + // index cannot be rebuilt via DROP_EXISTING (Msg 1907). The rules exclude these as merge + // targets; if one ever slips through, emit a no-script review row rather than a failing + // script paired with loser DISABLEs that succeed. + if (!IsSafeMergeTarget(w)) + { + return ConstraintBackedRebuildReview(w); + } + + string additionalInfo = w.ConsolidationRule == IndexCleanupRules.KeySuperset + ? "This index will absorb includes from the narrower indexes it supersedes" + : "This index will absorb includes from duplicate indexes"; + + var (script, omitsPlacement) = MergeScript(w, options); + + return NewRecommendation(w) with + { + Action = IndexCleanupAction.MergeIncludes, + ResultKind = IndexCleanupResultKind.Merge, + SupersededBy = w.SupersededBy, + MissingIncludedColumns = w.MissingIncludedColumns, + ScriptType = "MERGE SCRIPT", + Script = script, + AdditionalInfo = additionalInfo, + ScriptOmitsPartitionPlacement = omitsPlacement, + }; + } + + /// <summary> + /// Rule 7 / 7.5 MAKE UNIQUE: rebuild the nonclustered index as UNIQUE so it can take over a redundant + /// unique constraint's enforcement. Rendered like a merge (UNIQUE <c>DROP_EXISTING</c> rebuild); the + /// constraint itself is dropped by a companion <see cref="BuildDisableConstraint"/> row. + /// </summary> + private static IndexCleanupRecommendation BuildMakeUnique(WorkIndex w, IndexCleanupOptions options) + { + // Same Msg-1907 backstop as BuildMerge — a MAKE UNIQUE render is also a DROP_EXISTING rebuild. + if (!IsSafeMergeTarget(w)) + { + return ConstraintBackedRebuildReview(w); + } + + var (script, omitsPlacement) = MergeScript(w, options, forceUnique: true); + + return NewRecommendation(w) with + { + Action = IndexCleanupAction.MakeUnique, + ResultKind = IndexCleanupResultKind.Merge, + SupersededBy = w.SupersededBy, + ScriptType = "MERGE SCRIPT", + Script = script, + AdditionalInfo = "This index will replace a unique constraint", + ScriptOmitsPartitionPlacement = omitsPlacement, + }; + } + + /// <summary> + /// Rule 7.5 / 7.5b DROP CONSTRAINT: a redundant unique constraint replaced by a MAKE UNIQUE index, or a + /// duplicate constraint collapsed into an identical survivor. Emits <c>ALTER TABLE … DROP CONSTRAINT</c> + /// (sp_IndexCleanup's <c>DISABLE CONSTRAINT SCRIPT</c>) — a constraint cannot be disabled, only dropped. + /// </summary> + private static IndexCleanupRecommendation BuildDisableConstraint(WorkIndex w) => + NewRecommendation(w) with + { + Action = IndexCleanupAction.Disable, + ResultKind = IndexCleanupResultKind.DisableConstraint, + TargetIndexName = w.TargetIndexName, + ScriptType = "DISABLE CONSTRAINT SCRIPT", + Script = "ALTER TABLE " + FullName(w) + " DROP CONSTRAINT " + QuoteName(w.Name) + ";", + AdditionalInfo = "This constraint is being replaced by: " + (w.TargetIndexName ?? "(unknown)"), + }; + + private static IndexCleanupRecommendation BuildCompress(WorkIndex w, IndexCleanupOptions options) + { + return NewRecommendation(w) with + { + Action = IndexCleanupAction.Keep, + ResultKind = IndexCleanupResultKind.Compress, + ConsolidationRule = w.ConsolidationRule, + ScriptType = "COMPRESSION SCRIPT", + Script = CompressionScript(w, options), + AdditionalInfo = "Compression type: PAGE (All Partitions)", + }; + } + + /// <summary>The Msg-1907 backstop row: keep the constraint-backed index, emit no script.</summary> + private static IndexCleanupRecommendation ConstraintBackedRebuildReview(WorkIndex w) => + NewRecommendation(w) with + { + Action = IndexCleanupAction.Keep, + ResultKind = IndexCleanupResultKind.Review, + ConsolidationRule = w.ConsolidationRule, + ScriptType = "REVIEW", + Script = "", // a DROP_EXISTING rebuild of a constraint-backed index fails (Msg 1907) + AdditionalInfo = "Constraint-backed index cannot be rebuilt with DROP_EXISTING (Msg 1907) — review manually", + }; + + private static IndexCleanupRecommendation BuildReview(WorkIndex w, string rule, List<string> siblings, string additionalInfo) => + NewRecommendation(w) with + { + Action = IndexCleanupAction.Keep, + ResultKind = IndexCleanupResultKind.Review, + ConsolidationRule = rule, + TargetIndexName = siblings.Count > 0 ? siblings[0] : null, + SupersededBy = "Related to " + string.Join(", ", siblings), + ScriptType = "REVIEW", + Script = "", // informational only — no destructive script + AdditionalInfo = additionalInfo, + }; + + private static IndexCleanupRecommendation NewRecommendation(WorkIndex w) => new() + { + DatabaseName = w.Src.DatabaseName, + SchemaName = w.Src.SchemaName, + TableName = w.Src.TableName, + IndexName = w.Name, + IndexId = w.Src.IndexId, + ConsolidationRule = w.ConsolidationRule, + OriginalIndexDefinition = OriginalIndexDefinition(w), + IndexSizeGb = w.SizeGb, + IndexRows = w.Src.TotalRows ?? 0, + IndexReads = w.Reads, + IndexWrites = w.Src.UserUpdates, + CanCompress = w.CanCompress, + IsForeignKey = w.Src.IsForeignKey, + }; + + private static string DisableScript(WorkIndex w) => + "ALTER INDEX " + QuoteName(w.Name) + " ON " + FullName(w) + " DISABLE;"; + + private static (string Script, bool OmitsPlacement) MergeScript(WorkIndex w, IndexCleanupOptions options, bool forceUnique = false) + { + string includes = string.Join(", ", w.MergedIncludes ?? w.IncludeTokens); + + var script = "CREATE " + ((forceUnique || w.Src.IsUnique) ? "UNIQUE " : "") + "INDEX " + QuoteName(w.Name) + + " ON " + FullName(w) + " (" + w.KeyColumns + ")" + + (includes.Length > 0 ? " INCLUDE (" + includes + ")" : "") + + (w.Filter.Length > 0 ? " WHERE " + w.Filter : "") + + " WITH (DROP_EXISTING = ON, FILLFACTOR = 100, SORT_IN_TEMPDB = ON, ONLINE = " + + (options.ServerSupportsOnline ? "ON" : "OFF") + + (w.CanCompress ? ", DATA_COMPRESSION = PAGE" : "") + + (w.Src.OptimizeForSequentialKey ? ", OPTIMIZE_FOR_SEQUENTIAL_KEY = ON" : "") + + ");"; + + return (script, IsPartitioned(w)); + } + + private static string CompressionScript(WorkIndex w, IndexCleanupOptions options) => + "ALTER INDEX " + QuoteName(w.Name) + " ON " + FullName(w) + + (IsPartitioned(w) ? " REBUILD PARTITION = ALL" : " REBUILD") + + " WITH (FILLFACTOR = 100, SORT_IN_TEMPDB = ON, ONLINE = " + + (options.ServerSupportsOnline ? "ON" : "OFF") + + ", DATA_COMPRESSION = PAGE" + + (w.Src.OptimizeForSequentialKey ? ", OPTIMIZE_FOR_SEQUENTIAL_KEY = ON" : "") + + ");"; + + /// <summary> + /// Reconstructs the original CREATE (or ADD CONSTRAINT) from captured metadata — the rollback / + /// validation reference. Uses the captured delimited key/include strings verbatim. The trailing + /// <c>ON <filegroup/partition_scheme></c> is omitted (Stage 1 did not capture it). + /// </summary> + private static string OriginalIndexDefinition(WorkIndex w) + { + if (w.Src.IsUniqueConstraint) + { + return "ALTER TABLE " + FullName(w) + " ADD CONSTRAINT " + QuoteName(w.Name) + + " UNIQUE (" + w.KeyColumns + ");"; + } + + string clustering = w.Src.IndexId == 1 ? "CLUSTERED " : "NONCLUSTERED "; + return "CREATE " + (w.Src.IsUnique ? "UNIQUE " : "") + clustering + "INDEX " + QuoteName(w.Name) + + " ON " + FullName(w) + " (" + w.KeyColumns + ")" + + (w.IncludedColumns.Length > 0 ? " INCLUDE (" + w.IncludedColumns + ")" : "") + + (w.Filter.Length > 0 ? " WHERE " + w.Filter : "") + + ";"; + } + + private static bool IsPartitioned(WorkIndex w) => (w.Src.PartitionCount ?? 1) > 1; + + private static string FullName(WorkIndex w) => + QuoteName(w.Src.DatabaseName) + "." + QuoteName(w.Src.SchemaName) + "." + QuoteName(w.Src.TableName); + + /// <summary>QUOTENAME: wrap in brackets, doubling any embedded closing bracket.</summary> + private static string QuoteName(string name) => + "[" + (name ?? "").Replace("]", "]]", StringComparison.Ordinal) + "]"; + + // ──────────────────────────────────────────────────────────────────────────────────────────── + // Rollups + notes + // ──────────────────────────────────────────────────────────────────────────────────────────── + + private static IndexCleanupRollup BuildRollup(string databaseName, List<WorkIndex> analyzed, IndexCleanupOptions options) + { + decimal unusedSize = 0, compMin = 0, compMax = 0; + int disable = 0, merge = 0, compressable = 0, unused = 0; + decimal totalSize = 0; + + // Workload-impact aggregates: straight SUMs over EVERY analyzed index (mirrors sp_IndexCleanup's + // #index_reporting_stats DATABASE roll-up of analyzed_indexes — reads/writes from usage stats, + // lock/latch from dm_db_index_operational_stats). + long userSeeks = 0, userScans = 0, userLookups = 0, totalWrites = 0; + long lockWaitCount = 0, lockWaitInMs = 0, latchWaitCount = 0, latchWaitInMs = 0; + + foreach (var w in analyzed) + { + totalSize += w.SizeGb; + + userSeeks += w.Src.UserSeeks; + userScans += w.Src.UserScans; + userLookups += w.Src.UserLookups; + totalWrites += w.Src.UserUpdates; + lockWaitCount += w.Src.RowLockWaitCount + w.Src.PageLockWaitCount; + lockWaitInMs += w.Src.RowLockWaitInMs + w.Src.PageLockWaitInMs; + latchWaitCount += w.Src.PageLatchWaitCount + w.Src.PageIoLatchWaitCount; + latchWaitInMs += w.Src.PageLatchWaitInMs + w.Src.PageIoLatchWaitInMs; + + if (w.Action == IndexCleanupAction.Disable) + { + disable++; + unusedSize += w.SizeGb; + if (w.ConsolidationRule != null && w.ConsolidationRule.StartsWith(IndexCleanupRules.UnusedIndex, StringComparison.Ordinal)) + { + unused++; + } + } + else if (w.Action == IndexCleanupAction.MergeIncludes || w.Action == IndexCleanupAction.MakeUnique) + { + // MAKE UNIQUE is a kept-and-rebuilt index (sp_IndexCleanup's MERGE bucket), not a reclaim. + merge++; + } + + if (w.CanCompress) + { + compressable++; + if (w.Action == IndexCleanupAction.None || w.Action == IndexCleanupAction.Keep) + { + compMin += w.SizeGb * options.CompressionMinSavingsFactor; + compMax += w.SizeGb * options.CompressionMaxSavingsFactor; + } + } + } + + // Table row count = the base row (index_id 0/1) per object, summed across objects. + long totalRows = analyzed + .Where(w => w.Src.IndexId <= 1) + .GroupBy(w => w.Src.ObjectId) + .Sum(g => g.First().Src.TotalRows ?? 0); + + return new IndexCleanupRollup + { + DatabaseName = databaseName, + TablesAnalyzed = analyzed.Select(w => w.Src.ObjectId).Distinct().Count(), + IndexCount = analyzed.Count, + TotalSizeGb = totalSize, + TotalRows = totalRows, + IndexesToDisable = disable, + IndexesToMerge = merge, + CompressableIndexes = compressable, + UnusedIndexes = unused, + UnusedSizeGb = unusedSize, + CompressionMinSavingsGb = compMin, + CompressionMaxSavingsGb = compMax, + TotalMinSavingsGb = unusedSize + compMin, + TotalMaxSavingsGb = unusedSize + compMax, + UserSeeks = userSeeks, + UserScans = userScans, + UserLookups = userLookups, + TotalWrites = totalWrites, + LockWaitCount = lockWaitCount, + LockWaitInMs = lockWaitInMs, + LatchWaitCount = latchWaitCount, + LatchWaitInMs = latchWaitInMs, + }; + } + + private static IndexCleanupRollup BuildOverallRollup(List<IndexCleanupRollup> perDb) => new() + { + DatabaseName = null, + TablesAnalyzed = perDb.Sum(r => r.TablesAnalyzed), + IndexCount = perDb.Sum(r => r.IndexCount), + TotalSizeGb = perDb.Sum(r => r.TotalSizeGb), + TotalRows = perDb.Sum(r => r.TotalRows), + IndexesToDisable = perDb.Sum(r => r.IndexesToDisable), + IndexesToMerge = perDb.Sum(r => r.IndexesToMerge), + CompressableIndexes = perDb.Sum(r => r.CompressableIndexes), + UnusedIndexes = perDb.Sum(r => r.UnusedIndexes), + UnusedSizeGb = perDb.Sum(r => r.UnusedSizeGb), + CompressionMinSavingsGb = perDb.Sum(r => r.CompressionMinSavingsGb), + CompressionMaxSavingsGb = perDb.Sum(r => r.CompressionMaxSavingsGb), + TotalMinSavingsGb = perDb.Sum(r => r.TotalMinSavingsGb), + TotalMaxSavingsGb = perDb.Sum(r => r.TotalMaxSavingsGb), + // Workload aggregates roll up too (DATABASE = SUM(TABLE), SUMMARY = SUM(DATABASE)); the rollup-row + // projection renders them 'N/A' for the overall row, matching sp_IndexCleanup's SUMMARY output. + UserSeeks = perDb.Sum(r => r.UserSeeks), + UserScans = perDb.Sum(r => r.UserScans), + UserLookups = perDb.Sum(r => r.UserLookups), + TotalWrites = perDb.Sum(r => r.TotalWrites), + LockWaitCount = perDb.Sum(r => r.LockWaitCount), + LockWaitInMs = perDb.Sum(r => r.LockWaitInMs), + LatchWaitCount = perDb.Sum(r => r.LatchWaitCount), + LatchWaitInMs = perDb.Sum(r => r.LatchWaitInMs), + }; + + private static List<string> BuildNotes(List<IndexCleanupRecommendation> recommendations) + { + var notes = new List<string>(); + + if (recommendations.Any(r => r.ScriptOmitsPartitionPlacement)) + { + notes.Add("Reconstructed CREATE/MERGE scripts omit the trailing ON <filegroup/partition_scheme> clause: " + + "Stage 1 did not capture filegroup/partition placement. For a partitioned index the rebuild would " + + "default to the table's scheme mapping; verify placement before running."); + } + + if (recommendations.Any(r => r.ResultKind == IndexCleanupResultKind.Compress)) + { + notes.Add("Compression eligibility cannot exclude tables with sparse columns or legacy LOB types " + + "(text/ntext/image): Stage 1 did not capture per-column type/sparse metadata, so a small number of " + + "compression candidates may be ineligible in practice (sp_IndexCleanup excludes them via a live column scan)."); + notes.Add("Compression savings use sp_IndexCleanup's general 0.20–0.60 × size band, NOT " + + "sp_estimate_data_compression_savings (a live-sampling proc, out of scope for data-driven analysis)."); + notes.Add("COMPRESSION REBUILD uses PARTITION = ALL when partition_count > 1 (a proxy; Stage 1 did not " + + "capture the partition scheme name)."); + } + + notes.Add("Reverse Duplicate and Equal Except For Filter are recognized and surfaced as REVIEW rows but " + + "never auto-disabled — matching sp_IndexCleanup's tested behavior (its adversarial tests 9a/10a assert " + + "reversed-order and filter-differing indexes are left alone; a different leading column or filter serves " + + "different queries/rows)."); + + if (recommendations.Any(r => r.ConsolidationRule == IndexCleanupRules.UniqueConstraintReplacement)) + { + notes.Add("Unique Constraint Replacement (sp_IndexCleanup Rule 7/7.5/7.5b) reproduces the proc's LITERAL " + + "behavior, verified against a live @debug=1 run: a nonclustered index whose key_columns EXACTLY match " + + "a unique constraint is made unique (MERGE SCRIPT) and the constraint is dropped (DISABLE CONSTRAINT " + + "SCRIPT). This fires even when the index is ALREADY unique — the proc's Rule 7.5 step 2 has no is_unique " + + "guard, so it overrides Rule 7's KEEP with MAKE UNIQUE (a redundant-but-harmless UNIQUE rebuild that " + + "still drops the constraint). A constraint backing an inbound foreign-key reference is never dropped, " + + "and an index whose key columns are a SUPERSET of the constraint's is not promoted."); + } + + notes.Add("Out of scope of the Unique Constraint Replacement port: sp_IndexCleanup's 'Same Keys Different Order' " + + "REVIEW case, largely covered by the Reverse Duplicate review rows above and reproducible from the captured " + + "metadata if later required."); + + return notes; + } + + // ──────────────────────────────────────────────────────────────────────────────────────────── + // Input shaping + // ──────────────────────────────────────────────────────────────────────────────────────────── + + /// <summary>Only rowstore CLUSTERED/NONCLUSTERED, enabled, named indexes are analyzed (mirrors <c>i.type IN (1,2) AND i.is_disabled = 0</c>).</summary> + private static bool IsAnalyzable(IndexCleanupIndexInput src) + { + if (src == null || src.IsDisabled || string.IsNullOrEmpty(src.IndexName)) + { + return false; + } + + return string.Equals(src.IndexTypeDesc, Clustered, StringComparison.OrdinalIgnoreCase) + || string.Equals(src.IndexTypeDesc, NonClustered, StringComparison.OrdinalIgnoreCase); + } + + /// <summary>Object-level gates: @min_rows always; @min_reads/@min_writes only when the caller set them (> 0), matching sp_IndexCleanup.</summary> + private static bool PassesObjectFilters(List<WorkIndex> scopeIndexes, IndexCleanupOptions options) + { + long objectRows = scopeIndexes + .Where(w => w.Src.IndexId <= 1) + .Select(w => w.Src.TotalRows ?? 0) + .DefaultIfEmpty(scopeIndexes.Max(w => w.Src.TotalRows ?? 0)) + .First(); + + if (objectRows < options.MinRows) + { + return false; + } + + if (options.MinReads > 0 || options.MinWrites > 0) + { + long reads = scopeIndexes.Sum(w => w.Reads); + long writes = scopeIndexes.Sum(w => w.Src.UserUpdates); + + // Each term is gated on ITS OWN threshold being set (sp_IndexCleanup fix ae32a4c): + // without the per-term gate, a caller who set only MinReads still had `writes >= 0` as + // the other half of the keep-condition — always true — so the whole filter did nothing. + // The OR itself is intended: an object counts as used if it clears EITHER set floor. + bool used = + (options.MinReads > 0 && reads >= options.MinReads) + || (options.MinWrites > 0 && writes >= options.MinWrites); + if (!used) + { + return false; + } + } + + return true; + } + + private static double? ResolveUptimeDays(List<WorkIndex> analyzable, IndexCleanupOptions options) + { + if (options.ServerUptimeDays.HasValue) + { + return options.ServerUptimeDays; + } + + DateTime? start = analyzable + .Select(w => w.Src.SqlServerStartTime) + .Where(t => t.HasValue) + .DefaultIfEmpty(null) + .Max(); + + if (!start.HasValue) + { + return null; + } + + var reference = options.ReferenceTimeUtc ?? DateTime.UtcNow; + var days = (reference - start.Value).TotalDays; + return days < 0 ? null : days; + } + + private static int ResultKindSort(IndexCleanupResultKind kind) => kind switch + { + IndexCleanupResultKind.Merge => 0, // includes MAKE UNIQUE (proc's MERGE bucket) + IndexCleanupResultKind.Disable => 1, + IndexCleanupResultKind.DisableConstraint => 2, // proc orders CONSTRAINT after DISABLE + IndexCleanupResultKind.Compress => 3, + IndexCleanupResultKind.Review => 4, + _ => 5, + }; + + // ──────────────────────────────────────────────────────────────────────────────────────────── + // Working state + delimited-string parsing + // ──────────────────────────────────────────────────────────────────────────────────────────── + + /// <summary>Mutable per-index state carried through the sequential rule passes.</summary> + private sealed class WorkIndex + { + public WorkIndex(IndexCleanupIndexInput src) + { + Src = src; + Name = src.IndexName ?? ""; + KeyColumns = src.KeyColumns ?? ""; + IncludedColumns = src.IncludedColumns ?? ""; + Filter = src.FilterDefinition ?? ""; + Keys = ParseKeyTokens(KeyColumns); + KeyNames = Keys.Select(k => k.Name).ToList(); + KeyNameSet = new HashSet<string>(KeyNames, StringComparer.Ordinal); + IncludeTokens = SplitDelimited(IncludedColumns); + + // is_eligible_for_dedupe: nonclustered → yes; clustered → no (mirrors sp_IndexCleanup's CASE, + // which checks type = 2 before the clustered/PK branch). + IsEligibleForDedupe = string.Equals(src.IndexTypeDesc, NonClustered, StringComparison.OrdinalIgnoreCase); + + Priority = ComputePriority(src); + SizeGb = (src.ReservedMb ?? 0m) / 1024m; + } + + public IndexCleanupIndexInput Src { get; } + public string Name { get; } + public string KeyColumns { get; } + public string IncludedColumns { get; } + public string Filter { get; } + public List<KeyColumn> Keys { get; } + public List<string> KeyNames { get; } + public HashSet<string> KeyNameSet { get; } + public List<string> IncludeTokens { get; } + public bool IsEligibleForDedupe { get; } + public int Priority { get; } + public decimal SizeGb { get; } + + public long Reads => Src.UserSeeks + Src.UserScans + Src.UserLookups; + + /// <summary>Hard disable protection: PK, unique constraint, or FK-referenced (matches spec item 3).</summary> + public bool Protected => Src.IsPrimaryKey || Src.IsUniqueConstraint || Src.IsForeignKeyReference; + + // Mutable rule state + public bool Processed { get; set; } + public string? ConsolidationRule { get; set; } + public IndexCleanupAction Action { get; set; } = IndexCleanupAction.None; + public string? TargetIndexName { get; set; } + public string? SupersededBy { get; set; } + public List<string>? MergedIncludes { get; set; } + public string? MissingIncludedColumns { get; set; } + public bool CanCompress { get; set; } + } + + private readonly record struct KeyColumn(string Name, bool Descending); + + /// <summary>sp_IndexCleanup priority: clustered 1000; unique 500 (unique constraint only 50); seeks 200; scans 100; has includes 50.</summary> + private static int ComputePriority(IndexCleanupIndexInput src) + { + int priority = src.IndexId == 1 ? 1000 : 0; + + if (src.IsUnique) + { + priority += src.IsUniqueConstraint ? 50 : 500; + } + + if (src.UserSeeks > 0) + { + priority += 200; + } + + if (src.UserScans > 0) + { + priority += 100; + } + + if (!string.IsNullOrEmpty(src.IncludedColumns)) + { + priority += 50; + } + + return priority; + } + + /// <summary>Parses a delimited key list into (bracketed-name, descending) tokens, respecting QUOTENAME bracketing.</summary> + private static List<KeyColumn> ParseKeyTokens(string raw) + { + var result = new List<KeyColumn>(); + foreach (var token in SplitDelimited(raw)) + { + if (token.EndsWith(" DESC", StringComparison.Ordinal)) + { + result.Add(new KeyColumn(token[..^5].TrimEnd(), true)); + } + else + { + result.Add(new KeyColumn(token, false)); + } + } + return result; + } + + /// <summary> + /// Splits a QUOTENAME-delimited list on the ", " separator that sits between tokens, ignoring commas + /// and spaces inside <c>[ ... ]</c> (where <c>]]</c> is an escaped bracket). Robust to column names + /// that themselves contain commas, spaces, or brackets. + /// </summary> + private static List<string> SplitDelimited(string? raw) + { + var tokens = new List<string>(); + if (string.IsNullOrEmpty(raw)) + { + return tokens; + } + + int n = raw.Length; + int start = 0; + int i = 0; + bool inBracket = false; + + while (i < n) + { + char c = raw[i]; + if (!inBracket) + { + if (c == '[') + { + inBracket = true; + i++; + } + else if (c == ',' && i + 1 < n && raw[i + 1] == ' ') + { + AddToken(tokens, raw, start, i); + i += 2; + start = i; + } + else + { + i++; + } + } + else + { + if (c == ']') + { + if (i + 1 < n && raw[i + 1] == ']') + { + i += 2; // escaped ]] inside a name + } + else + { + inBracket = false; + i++; + } + } + else + { + i++; + } + } + } + + AddToken(tokens, raw, start, n); + return tokens; + } + + private static void AddToken(List<string> tokens, string raw, int start, int end) + { + var token = raw.Substring(start, end - start).Trim(); + if (token.Length > 0) + { + tokens.Add(token); + } + } + + // Composite grouping-key comparers over the raw delimited strings (byte-for-byte, ordinal). + + private sealed class KeyIncludeFilterComparer : IEqualityComparer<(string Key, string Include, string Filter)> + { + public static readonly KeyIncludeFilterComparer Instance = new(); + public bool Equals((string Key, string Include, string Filter) x, (string Key, string Include, string Filter) y) => + string.Equals(x.Key, y.Key, StringComparison.Ordinal) + && string.Equals(x.Include, y.Include, StringComparison.Ordinal) + && string.Equals(x.Filter, y.Filter, StringComparison.Ordinal); + public int GetHashCode((string Key, string Include, string Filter) obj) => + HashCode.Combine(obj.Key, obj.Include, obj.Filter); + } + + private sealed class KeyFilterComparer : IEqualityComparer<(string Key, string Filter)> + { + public static readonly KeyFilterComparer Instance = new(); + public bool Equals((string Key, string Filter) x, (string Key, string Filter) y) => + string.Equals(x.Key, y.Key, StringComparison.Ordinal) + && string.Equals(x.Filter, y.Filter, StringComparison.Ordinal); + public int GetHashCode((string Key, string Filter) obj) => + HashCode.Combine(obj.Key, obj.Filter); + } + } +} diff --git a/PerformanceMonitor.Common/IndexCleanupModels.cs b/PerformanceMonitor.Common/IndexCleanupModels.cs new file mode 100644 index 000000000..321469952 --- /dev/null +++ b/PerformanceMonitor.Common/IndexCleanupModels.cs @@ -0,0 +1,475 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; + +namespace PerformanceMonitor.Common +{ + /// <summary> + /// One captured per-index row consumed by <see cref="IndexCleanupAnalyzer"/> — the analysis-relevant + /// subset of the Stage-1 <c>index_object_stats</c> snapshot (the shared + /// <c>IndexObjectStatsCollector.Row</c>). Kept as a Common-local contract (like + /// <see cref="SystemHealthParser"/>'s tuple input) so the analyzer has NO dependency on the collector + /// assembly; the caller maps its read-layer rows into this shape. + /// + /// <para><see cref="KeyColumns"/> / <see cref="IncludedColumns"/> arrive in sp_IndexCleanup's EXACT + /// delimited <c>QUOTENAME</c> form — keys are <c>[a], [b] DESC</c> in key-ordinal order (with a + /// <c> DESC</c> suffix on descending columns), includes are <c>[c], [d]</c> in column-name order — so + /// the string-comparison dedupe (Exact/Reverse/Equal-Except-Filter, Key Duplicate, Key + /// Subset/Superset) ports byte-for-byte and the reconstructed CREATE/MERGE scripts can drop the strings + /// in verbatim.</para> + /// </summary> + public sealed record IndexCleanupIndexInput + { + /// <summary>Owning database name (used for scope grouping and script qualification).</summary> + public string DatabaseName { get; init; } = ""; + + /// <summary>Owning database id; with <see cref="ObjectId"/> forms the per-object dedupe scope.</summary> + public int DatabaseId { get; init; } + + /// <summary>Schema name.</summary> + public string SchemaName { get; init; } = ""; + + /// <summary>Object (table/view) id; the dedupe scope is (<see cref="DatabaseId"/>, <see cref="ObjectId"/>).</summary> + public int ObjectId { get; init; } + + /// <summary>Table (or indexed-view) name.</summary> + public string TableName { get; init; } = ""; + + /// <summary>Index id. 1 = clustered; >1 = nonclustered; 0 = heap (heaps are not analyzed).</summary> + public int IndexId { get; init; } + + /// <summary>Index name (null only for a heap, which is excluded from analysis).</summary> + public string? IndexName { get; init; } + + /// <summary>e.g. <c>CLUSTERED</c>, <c>NONCLUSTERED</c>. Only CLUSTERED/NONCLUSTERED rowstore indexes are analyzed.</summary> + public string? IndexTypeDesc { get; init; } + + /// <summary>Delimited key list <c>[a], [b] DESC</c> (key-ordinal order, DESC suffix on descending keys).</summary> + public string? KeyColumns { get; init; } + + /// <summary>Delimited include list <c>[c], [d]</c> (column-name order).</summary> + public string? IncludedColumns { get; init; } + + /// <summary>Raw filter predicate text (null for a non-filtered index).</summary> + public string? FilterDefinition { get; init; } + + /// <summary><c>sys.indexes.is_unique</c>.</summary> + public bool IsUnique { get; init; } + + /// <summary><c>sys.indexes.is_unique_constraint</c> — a hard drop/disable protection.</summary> + public bool IsUniqueConstraint { get; init; } + + /// <summary><c>sys.indexes.is_primary_key</c> — a hard drop/disable protection.</summary> + public bool IsPrimaryKey { get; init; } + + /// <summary>A key column backs an OUTGOING foreign key (this is a supporting index). Informational — sp_IndexCleanup does not hard-guard disable on it.</summary> + public bool IsForeignKey { get; init; } + + /// <summary>A key column is REFERENCED by an incoming foreign key — a hard drop/disable protection (matches sp_IndexCleanup's <c>is_foreign_key_reference</c> guard).</summary> + public bool IsForeignKeyReference { get; init; } + + /// <summary>Already disabled — excluded from analysis (mirrors sp_IndexCleanup's <c>i.is_disabled = 0</c> filter).</summary> + public bool IsDisabled { get; init; } + + /// <summary>Index belongs to an indexed view (object type V) rather than a table. Informational; scope is per-object so table and view indexes never compare.</summary> + public bool IsIndexedView { get; init; } + + /// <summary>Lowest <c>data_compression_desc</c> across the index's partitions (<c>NONE</c> ⇒ at least one uncompressed partition ⇒ a PAGE-compression candidate).</summary> + public string? DataCompressionDesc { get; init; } + + /// <summary><c>optimize_for_sequential_key</c> (SQL 2019+/Azure). When set, the reconstructed rebuild carries <c>OPTIMIZE_FOR_SEQUENTIAL_KEY = ON</c>.</summary> + public bool OptimizeForSequentialKey { get; init; } + + /// <summary><c>fill_factor</c> (0 = engine default). Captured for reference; sp_IndexCleanup's generated rebuilds hardcode <c>FILLFACTOR = 100</c>.</summary> + public short? FillFactor { get; init; } + + /// <summary><c>is_padded</c>. Captured for reference.</summary> + public bool IsPadded { get; init; } + + /// <summary><c>allow_page_locks</c>. Captured for reference.</summary> + public bool AllowPageLocks { get; init; } + + /// <summary><c>allow_row_locks</c>. Captured for reference.</summary> + public bool AllowRowLocks { get; init; } + + /// <summary>Cumulative <c>user_seeks</c> since the last usage-stats reset.</summary> + public long UserSeeks { get; init; } + + /// <summary>Cumulative <c>user_scans</c>.</summary> + public long UserScans { get; init; } + + /// <summary>Cumulative <c>user_lookups</c>.</summary> + public long UserLookups { get; init; } + + /// <summary>Cumulative <c>user_updates</c> (write maintenance cost).</summary> + public long UserUpdates { get; init; } + + /// <summary>Cumulative <c>row_lock_wait_count</c> (dm_db_index_operational_stats). Summed with <see cref="PageLockWaitCount"/> for the per-scope Lock Waits rollup.</summary> + public long RowLockWaitCount { get; init; } + + /// <summary>Cumulative <c>row_lock_wait_in_ms</c>. With <see cref="PageLockWaitInMs"/> drives the average lock wait (ms/wait).</summary> + public long RowLockWaitInMs { get; init; } + + /// <summary>Cumulative <c>page_lock_wait_count</c>.</summary> + public long PageLockWaitCount { get; init; } + + /// <summary>Cumulative <c>page_lock_wait_in_ms</c>.</summary> + public long PageLockWaitInMs { get; init; } + + /// <summary>Cumulative <c>page_latch_wait_count</c>. Summed with <see cref="PageIoLatchWaitCount"/> for the per-scope Latch Waits rollup.</summary> + public long PageLatchWaitCount { get; init; } + + /// <summary>Cumulative <c>page_latch_wait_in_ms</c>. With <see cref="PageIoLatchWaitInMs"/> drives the average latch wait (ms/wait).</summary> + public long PageLatchWaitInMs { get; init; } + + /// <summary>Cumulative <c>page_io_latch_wait_count</c>.</summary> + public long PageIoLatchWaitCount { get; init; } + + /// <summary>Cumulative <c>page_io_latch_wait_in_ms</c>.</summary> + public long PageIoLatchWaitInMs { get; init; } + + /// <summary>Reserved footprint in MB (summed across partitions). Divided by 1024 for the GB figures.</summary> + public decimal? ReservedMb { get; init; } + + /// <summary>Row count (per partition sum). The base row (index_id 0/1) drives the object's <c>@min_rows</c> gate.</summary> + public long? TotalRows { get; init; } + + /// <summary>Partition count; >1 drives <c>REBUILD PARTITION = ALL</c> in the compression script (a proxy — the scheme name is not captured).</summary> + public int? PartitionCount { get; init; } + + /// <summary>SQL Server start time (for the <14-day uptime caveat / <=7-day auto-dedupe, when the caller does not pass <see cref="IndexCleanupOptions.ServerUptimeDays"/> directly).</summary> + public DateTime? SqlServerStartTime { get; init; } + } + + /// <summary> + /// Analyzer inputs that mirror sp_IndexCleanup's parameters and server-derived flags. Because the + /// analyzer is database-free, the edition/uptime facts SQL Server derives at runtime are passed in. + /// </summary> + public sealed record IndexCleanupOptions + { + /// <summary>Only run the consolidation rules; do NOT flag unused indexes (sp_IndexCleanup's <c>@dedupe_only</c>).</summary> + public bool DedupeOnly { get; init; } + + /// <summary>Minimum index size (GB) for a PAGE-compression candidate (sp_IndexCleanup's <c>@min_size_gb</c>, applied per index here).</summary> + public decimal MinSizeGb { get; init; } + + /// <summary>Minimum object read count to analyze a table (applied only when > 0; mirrors <c>@min_reads</c>).</summary> + public long MinReads { get; init; } + + /// <summary>Minimum object write count to analyze a table (applied only when > 0; mirrors <c>@min_writes</c>).</summary> + public long MinWrites { get; init; } + + /// <summary>Minimum object row count to analyze a table (mirrors <c>@min_rows</c>).</summary> + public long MinRows { get; init; } + + /// <summary> + /// Server uptime in days. Drives the "usage data may be incomplete" caveat on unused indexes + /// (< 14 days) and sp_IndexCleanup's auto-enable of dedupe-only (<= 7 days). When null, the + /// analyzer derives it from the max <see cref="IndexCleanupIndexInput.SqlServerStartTime"/> and + /// <see cref="ReferenceTimeUtc"/>; if that is also indeterminable, no caveat is applied. + /// </summary> + public double? ServerUptimeDays { get; init; } + + /// <summary>Reference "now" for deriving uptime from server start time. Defaults to <see cref="DateTime.UtcNow"/>.</summary> + public DateTime? ReferenceTimeUtc { get; init; } + + /// <summary>Target engine supports data compression (Enterprise / Standard 2016+ / Azure). When false, nothing is a compression candidate (mirrors <c>@can_compress</c>).</summary> + public bool ServerSupportsCompression { get; init; } = true; + + /// <summary>Target engine supports <c>ONLINE = ON</c> rebuilds (Enterprise / Azure). Controls the ONLINE token in generated scripts; defaults to OFF (safe).</summary> + public bool ServerSupportsOnline { get; init; } + + /// <summary>Low end of the general PAGE-compression savings band (sp_IndexCleanup: 0.20 × size).</summary> + public decimal CompressionMinSavingsFactor { get; init; } = 0.20m; + + /// <summary>High end of the general PAGE-compression savings band (sp_IndexCleanup: 0.60 × size).</summary> + public decimal CompressionMaxSavingsFactor { get; init; } = 0.60m; + } + + /// <summary>What to do with an index. Mirrors sp_IndexCleanup's <c>action</c> for the in-scope dedupe rules.</summary> + public enum IndexCleanupAction + { + /// <summary>No action.</summary> + None = 0, + + /// <summary>Retain this index (the keeper of a duplicate set).</summary> + Keep = 1, + + /// <summary>Disable this index (redundant / unused). Never assigned to a protected index. When the + /// index is a unique constraint (Rule 7.5 / 7.5b), the generated script is a <c>DROP CONSTRAINT</c> + /// rather than an <c>ALTER INDEX … DISABLE</c>.</summary> + Disable = 2, + + /// <summary>Retain but rebuild with includes merged from the indexes it supersedes.</summary> + MergeIncludes = 3, + + /// <summary> + /// Rebuild this nonclustered index as UNIQUE so it can replace a redundant unique constraint + /// (sp_IndexCleanup's <c>MAKE UNIQUE</c>, Rule 7 / 7.5). The matching constraint is dropped + /// (a companion <see cref="Disable"/> row). The generated script is a + /// <c>CREATE UNIQUE INDEX … WITH (DROP_EXISTING = ON …)</c> rebuild. + /// </summary> + MakeUnique = 4, + } + + /// <summary>Which generated-script bucket a recommendation belongs to. Mirrors sp_IndexCleanup's <c>result_type</c>.</summary> + public enum IndexCleanupResultKind + { + /// <summary>An <c>ALTER INDEX … DISABLE</c> row.</summary> + Disable = 0, + + /// <summary>A <c>CREATE INDEX … WITH (DROP_EXISTING = ON …)</c> merge row (the keeper/superset).</summary> + Merge = 1, + + /// <summary>An <c>ALTER INDEX … REBUILD … DATA_COMPRESSION = PAGE</c> row.</summary> + Compress = 2, + + /// <summary> + /// An informational "review these related indexes" row that carries NO destructive script — the + /// analyzer recognized a Reverse Duplicate or Equal Except For Filter relationship but, exactly like + /// sp_IndexCleanup, deliberately does NOT auto-consolidate it (the indexes serve different access + /// patterns / row coverage). + /// </summary> + Review = 3, + + /// <summary> + /// An <c>ALTER TABLE … DROP CONSTRAINT</c> row: a redundant unique constraint being replaced by a + /// nonclustered index made unique in its place, or (Rule 7.5b) a duplicate unique constraint dropped + /// in favor of an identical surviving one. Mirrors sp_IndexCleanup's <c>CONSTRAINT</c> result bucket + /// (its <c>DISABLE CONSTRAINT SCRIPT</c>) — distinct from <see cref="Disable"/>'s + /// <c>ALTER INDEX … DISABLE</c>. + /// </summary> + DisableConstraint = 4, + } + + /// <summary>The canonical <c>consolidation_rule</c> labels this analyzer emits.</summary> + public static class IndexCleanupRules + { + /// <summary>No reads at all (Rule 1). Never-clustered, non-unique, non-PK/UC nonclustered indexes only.</summary> + public const string UnusedIndex = "Unused Index"; + + /// <summary>The <14-day-uptime variant of <see cref="UnusedIndex"/> (usage data may be incomplete).</summary> + public const string UnusedIndexUptimeWarning = "Unused Index (WARNING: Server uptime < 14 days - usage data may be incomplete)"; + + /// <summary>Identical keys (with direction), includes, and filter (Rule 2) → the redundant one is disabled.</summary> + public const string ExactDuplicate = "Exact Duplicate"; + + /// <summary> + /// Same key column SET but a different arrangement (reversed/reordered order or changed sort + /// directions), with the same includes and filter. Recognized and surfaced for REVIEW but never + /// auto-disabled — a different leading column serves different query patterns (sp_IndexCleanup's + /// adversarial test 9a asserts these are not consolidated). + /// </summary> + public const string ReverseDuplicate = "Reverse Duplicate"; + + /// <summary> + /// Identical keys (with direction) and includes but a DIFFERENT filter predicate. Recognized and + /// surfaced for REVIEW but never auto-disabled — the filters index different row sets + /// (sp_IndexCleanup's adversarial test 10a asserts these are not consolidated). + /// </summary> + public const string EqualExceptForFilter = "Equal Except For Filter"; + + /// <summary>Same keys (with direction) and filter but different includes (Rule 5); the keeper absorbs the includes.</summary> + public const string KeyDuplicate = "Key Duplicate"; + + /// <summary>The narrower index whose key is a leading prefix of a wider index's key (Rule 3); disabled in favor of the superset.</summary> + public const string KeySubset = "Key Subset"; + + /// <summary>The wider index a Key Subset defers to (Rule 4); rebuilt with the subset's missing includes merged in.</summary> + public const string KeySuperset = "Key Superset"; + + /// <summary> + /// A nonclustered index whose key columns match a unique constraint's (Rule 7 / 7.5 / 7.5b). The index + /// is made unique (<see cref="IndexCleanupAction.MakeUnique"/>) to take over the constraint's + /// uniqueness enforcement and the now-redundant constraint is dropped + /// (<see cref="IndexCleanupAction.Disable"/> → <see cref="IndexCleanupResultKind.DisableConstraint"/>); + /// a constraint that backs an inbound foreign-key reference is never dropped. Rule 7.5b additionally + /// collapses two or more identical unique constraints down to a single survivor. + /// </summary> + public const string UniqueConstraintReplacement = "Unique Constraint Replacement"; + } + + /// <summary> + /// One actionable finding for a single index — the C# analog of a row in sp_IndexCleanup's script result + /// set. Carries the matched rule, the action, the target/superseded linkage, the reconstructed T-SQL, and + /// the size/usage figures the rollup sums. + /// </summary> + public sealed record IndexCleanupRecommendation + { + /// <summary>Database name.</summary> + public string DatabaseName { get; init; } = ""; + + /// <summary>Schema name.</summary> + public string SchemaName { get; init; } = ""; + + /// <summary>Table (or indexed-view) name.</summary> + public string TableName { get; init; } = ""; + + /// <summary>The index this finding is about.</summary> + public string IndexName { get; init; } = ""; + + /// <summary>Index id (1 = clustered).</summary> + public int IndexId { get; init; } + + /// <summary>The matched <see cref="IndexCleanupRules"/> label (null for a pure compression row).</summary> + public string? ConsolidationRule { get; init; } + + /// <summary>The recommended action.</summary> + public IndexCleanupAction Action { get; init; } + + /// <summary>Which script bucket this row belongs to.</summary> + public IndexCleanupResultKind ResultKind { get; init; } + + /// <summary>The keeper/superset this index defers to (on a DISABLE row), or null.</summary> + public string? TargetIndexName { get; init; } + + /// <summary>On a keeper/superset row, the "Supersedes …" list of indexes it absorbs.</summary> + public string? SupersededBy { get; init; } + + /// <summary>On a Key Superset row, the subset include columns merged in that were not already present (sp_IndexCleanup's <c>missing_included_columns</c>).</summary> + public string? MissingIncludedColumns { get; init; } + + /// <summary>Human label for the script bucket: <c>DISABLE SCRIPT</c> / <c>MERGE SCRIPT</c> / <c>COMPRESSION SCRIPT</c>.</summary> + public string ScriptType { get; init; } = ""; + + /// <summary>The reconstructed T-SQL to run.</summary> + public string Script { get; init; } = ""; + + /// <summary>A short explanation of what the script does / why.</summary> + public string? AdditionalInfo { get; init; } + + /// <summary>The reconstructed original CREATE/ADD CONSTRAINT statement (rollback + validation reference).</summary> + public string OriginalIndexDefinition { get; init; } = ""; + + /// <summary>Reserved size in GB.</summary> + public decimal IndexSizeGb { get; init; } + + /// <summary>Row count.</summary> + public long IndexRows { get; init; } + + /// <summary>seeks + scans + lookups.</summary> + public long IndexReads { get; init; } + + /// <summary>user_updates.</summary> + public long IndexWrites { get; init; } + + /// <summary>This index is (also) a PAGE-compression candidate.</summary> + public bool CanCompress { get; init; } + + /// <summary>This index supports an outgoing foreign key (informational; not a disable guard).</summary> + public bool IsForeignKey { get; init; } + + /// <summary>The generated script omits the trailing <c>ON <filegroup/partition_scheme></c> placement clause because Stage 1 did not capture it.</summary> + public bool ScriptOmitsPartitionPlacement { get; init; } + } + + /// <summary> + /// The reclaimable-space roll-up for a scope (a database, or the whole snapshot) — the C# analog of + /// sp_IndexCleanup's <c>#index_reporting_stats</c> DATABASE row. + /// </summary> + public sealed record IndexCleanupRollup + { + /// <summary>Database this rollup covers; null for the overall (all-databases) rollup.</summary> + public string? DatabaseName { get; init; } + + /// <summary>Distinct tables analyzed.</summary> + public int TablesAnalyzed { get; init; } + + /// <summary>Indexes analyzed (rowstore CLUSTERED/NONCLUSTERED).</summary> + public int IndexCount { get; init; } + + /// <summary>Total reserved size of analyzed indexes (GB).</summary> + public decimal TotalSizeGb { get; init; } + + /// <summary>Total rows across analyzed tables' base row (index_id 0/1).</summary> + public long TotalRows { get; init; } + + /// <summary>Indexes recommended for DISABLE (redundant + unused).</summary> + public int IndexesToDisable { get; init; } + + /// <summary>Indexes recommended for MERGE INCLUDES (keepers/supersets).</summary> + public int IndexesToMerge { get; init; } + + /// <summary>Indexes eligible for PAGE compression.</summary> + public int CompressableIndexes { get; init; } + + /// <summary>Indexes flagged specifically as unused (subset of <see cref="IndexesToDisable"/>).</summary> + public int UnusedIndexes { get; init; } + + /// <summary>Reclaimable size (GB) from disabling redundant/unused indexes (their full size).</summary> + public decimal UnusedSizeGb { get; init; } + + /// <summary>Low-band compression savings (GB) over kept, compressible indexes (0.20 × size).</summary> + public decimal CompressionMinSavingsGb { get; init; } + + /// <summary>High-band compression savings (GB) over kept, compressible indexes (0.60 × size).</summary> + public decimal CompressionMaxSavingsGb { get; init; } + + /// <summary>Combined low-band reclaim (disabled full size + compression min).</summary> + public decimal TotalMinSavingsGb { get; init; } + + /// <summary>Combined high-band reclaim (disabled full size + compression max).</summary> + public decimal TotalMaxSavingsGb { get; init; } + + /* + * Workload-impact aggregates — straight SUMs over EVERY analyzed index in the scope (mirroring + * sp_IndexCleanup's #index_reporting_stats DATABASE roll-up of its analyzed_indexes rows). These feed + * the reporting summary's Reads / Writes / Lock Waits / Latch Waits columns. sp_IndexCleanup only + * surfaces them at its DATABASE (and TABLE) levels — its SUMMARY row shows them as 'N/A' — so the + * viewer's rollup-row projection renders 'N/A' for the overall (all-databases) row to match Lite. + */ + + /// <summary>Sum of <c>user_seeks</c> across analyzed indexes (the "seeks" part of the Reads breakdown).</summary> + public long UserSeeks { get; init; } + + /// <summary>Sum of <c>user_scans</c> across analyzed indexes.</summary> + public long UserScans { get; init; } + + /// <summary>Sum of <c>user_lookups</c> across analyzed indexes.</summary> + public long UserLookups { get; init; } + + /// <summary>Sum of <c>user_updates</c> across analyzed indexes (the Writes column).</summary> + public long TotalWrites { get; init; } + + /// <summary>Sum of <c>row_lock_wait_count + page_lock_wait_count</c> across analyzed indexes (the Lock Waits column).</summary> + public long LockWaitCount { get; init; } + + /// <summary>Sum of <c>row_lock_wait_in_ms + page_lock_wait_in_ms</c> across analyzed indexes (numerator of Avg Lock Wait ms).</summary> + public long LockWaitInMs { get; init; } + + /// <summary>Sum of <c>page_latch_wait_count + page_io_latch_wait_count</c> across analyzed indexes (the Latch Waits column).</summary> + public long LatchWaitCount { get; init; } + + /// <summary>Sum of <c>page_latch_wait_in_ms + page_io_latch_wait_in_ms</c> across analyzed indexes (numerator of Avg Latch Wait ms).</summary> + public long LatchWaitInMs { get; init; } + } + + /// <summary>The full result of one analysis pass.</summary> + public sealed record IndexCleanupAnalysisResult + { + /// <summary>Every actionable finding (DISABLE / MERGE / COMPRESS), ordered by database, schema, table, then keepers before losers.</summary> + public IReadOnlyList<IndexCleanupRecommendation> Recommendations { get; init; } = Array.Empty<IndexCleanupRecommendation>(); + + /// <summary>One rollup per database.</summary> + public IReadOnlyList<IndexCleanupRollup> DatabaseRollups { get; init; } = Array.Empty<IndexCleanupRollup>(); + + /// <summary>The all-databases rollup.</summary> + public IndexCleanupRollup OverallRollup { get; init; } = new(); + + /// <summary>Server uptime was under 14 days, so unused-index usage data may be incomplete.</summary> + public bool UptimeWarning { get; init; } + + /// <summary>Dedupe-only was in effect (either requested, or auto-enabled because uptime <= 7 days), so unused indexes were not flagged.</summary> + public bool DedupeOnlyApplied { get; init; } + + /// <summary>Precisely-surfaced limitations of this data-driven reproduction (e.g. omitted filegroup placement, compression exclusions not reproducible, out-of-scope rules).</summary> + public IReadOnlyList<string> Notes { get; init; } = Array.Empty<string>(); + } +} diff --git a/PerformanceMonitor.Common/PerformanceMonitor.Common.csproj b/PerformanceMonitor.Common/PerformanceMonitor.Common.csproj index 521473dd3..11450f569 100644 --- a/PerformanceMonitor.Common/PerformanceMonitor.Common.csproj +++ b/PerformanceMonitor.Common/PerformanceMonitor.Common.csproj @@ -9,7 +9,8 @@ <Copyright>Copyright © 2026 Darling Data, LLC</Copyright> <EnableNETAnalyzers>true</EnableNETAnalyzers> <AnalysisLevel>latest-recommended</AnalysisLevel> - <NoWarn>CA1849;CA2007;CA1508;CA1822;CA1805;CA1510;CA1816;CA1861;CA1845;CA2201;CA1848;CA1852;CA1305;CA1860;CA1707;CA1507;CA1806;CA2254</NoWarn> + <!-- NU1701: CredentialManagement package has no .NET 10 build (matches Dashboard/Lite suppression). --> + <NoWarn>CA1849;CA2007;CA1508;CA1822;CA1805;CA1510;CA1816;CA1861;CA1845;CA2201;CA1848;CA1852;CA1305;CA1860;CA1707;CA1507;CA1806;CA2254;NU1701</NoWarn> <WarningsAsErrors>CS4014</WarningsAsErrors> </PropertyGroup> @@ -17,12 +18,22 @@ <!-- MCP SDK: required by Mcp/McpSchemaCompat.cs (Gemini-compatible tool registration, issue #1074). Brings ModelContextProtocol.Core + Microsoft.Extensions.AI.Abstractions transitively. --> <PackageReference Include="ModelContextProtocol" Version="1.4.0" /> + <!-- Windows Credential Manager (DPAPI) for the shared CredentialStore; version matches Dashboard/Lite. --> + <PackageReference Include="CredentialManagement" Version="1.0.2" /> + <!-- ILogger abstraction used by CredentialStore; version matches the apps' logging stack. --> + <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" /> </ItemGroup> <ItemGroup> <InternalsVisibleTo Include="PerformanceMonitor.PlanAnalysis" /> <InternalsVisibleTo Include="PerformanceMonitorLite" /> <InternalsVisibleTo Include="PerformanceMonitorDashboard" /> + <!-- AN3: the ported PgDrillDownCollector renders plan warnings through McpHelpers.Truncate, + exactly like both apps' drill-down collectors. --> + <InternalsVisibleTo Include="PerformanceMonitor.Darling.Analysis" /> + <!-- AN4: the Darling service's analysis MCP tools use McpHelpers (Status envelope, + ValidateHoursBack, JsonOptions, FormatError) exactly like both apps' tools. --> + <InternalsVisibleTo Include="PerformanceMonitor.Darling.Service" /> <InternalsVisibleTo Include="Lite.Tests" /> <InternalsVisibleTo Include="Dashboard.Tests" /> </ItemGroup> diff --git a/PerformanceMonitor.Common/ServerHealthBands.cs b/PerformanceMonitor.Common/ServerHealthBands.cs new file mode 100644 index 000000000..18c881f8b --- /dev/null +++ b/PerformanceMonitor.Common/ServerHealthBands.cs @@ -0,0 +1,491 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; + +namespace PerformanceMonitor.Common +{ + /// <summary> + /// The collection-freshness bands the headless surfaces derive a server's connection status from — the + /// viewer has no live ping to a monitored server, so "is this server reporting" is answered by how old the + /// newest collection is (see <see cref="ServerHealthClassifier.ClassifyFreshness"/>). + /// </summary> + public enum ServerFreshness + { + /// <summary>The newest collection is within twice the fastest collector's cadence — Online (green).</summary> + Fresh, + + /// <summary>Collection has lagged past twice the cadence but the server isn't long-dead — Warning (amber).</summary> + Stale, + + /// <summary>The newest collection is long-dead — the Offline overlay (red).</summary> + Offline, + + /// <summary> + /// No collection has EVER landed for this server (this run or any prior) — the service has not reached it + /// yet. Distinct from <see cref="Offline"/> (which means data STOPPED): during a slow fleet bootstrap a + /// red "Offline" on a server that was merely still queued sent a 24-server field report chasing a phantom + /// scheduler bug. Rendered as an amber "Awaiting first collection", never the red overlay. + /// </summary> + NeverCollected, + } + + /// <summary> + /// Per-metric health bands for an Overview card's severity dots — a verbatim mirror of the Dashboard's + /// <c>HealthSeverity</c>. <see cref="Unknown"/> is a metric with no collected data (e.g. Threads on Azure SQL + /// DB) — it never escalates the card's overall band. + /// </summary> + public enum HealthSeverity + { + Unknown, + Healthy, + Warning, + Critical, + } + + /// <summary> + /// A server's fleet-health band — the SAME banding an Overview card computes, collapsed to one label per + /// server (offline / critical → red, warning / stale → amber, else calm). + /// </summary> + public enum FleetHealthBand + { + Healthy, + Warning, + Critical, + + /// <summary>The server's collection is long-dead / never happened (the card's red offline overlay).</summary> + Offline, + } + + /// <summary> + /// The one, documented place for the per-server health thresholds shared by the Darling web dashboard, the + /// get_fleet_overview MCP tool, and the WPF viewer's Overview cards. Freshness thresholds and the per-metric + /// severity cutoffs used to live twice (WPF <c>ServerSummaryItem</c> vs the service-side reads) at numerically + /// equal but independently-editable values — a drift risk. They now live here once; each host maps a band to + /// its own brush/color, but the numbers are read from this single source (#1562). + /// </summary> + public static class ServerHealthThresholds + { + /// <summary> + /// The fastest scheduled collector's cadence (wait_stats / cpu_utilization / memory_stats etc. all run + /// every minute), so MAX(collection_time) tracks a one-minute rhythm on a healthy server. Freshness bands + /// are multiples of this. + /// </summary> + public static readonly TimeSpan CollectorCadence = TimeSpan.FromMinutes(1); + + /// <summary>Older than twice the cadence = the collection has visibly lagged (Warning).</summary> + public static readonly TimeSpan StaleThreshold = TimeSpan.FromTicks(CollectorCadence.Ticks * 2); + + /// <summary>Older than this (or no collection at all) = the server is treated as Offline.</summary> + public static readonly TimeSpan OfflineThreshold = TimeSpan.FromMinutes(15); + } + + /// <summary> + /// The raw per-metric inputs a server card bands on — no brushes, no store, no display strings. Every field + /// is a value the collectors already produced; <see cref="ServerHealthClassifier"/> reduces them to the six + /// per-metric bands, the card's overall band, and the fleet score. Both the service-side cross-server reader + /// and the WPF viewer's <c>ServerSummaryItem</c> build this and hand it to the classifier, so the thresholds + /// live in exactly one place. + /// </summary> + public readonly record struct ServerHealthMetrics + { + /// <summary>Total non-idle CPU the CPU band evaluates (SQL + other-process), or null with no snapshot.</summary> + public double? CpuPercentForAlert { get; init; } + + /// <summary>True when the resource semaphore shows grant waiters, timeouts, or forced grants.</summary> + public bool HasMemoryPressure { get; init; } + + /// <summary>Blocking events in the window.</summary> + public int BlockingCount { get; init; } + + /// <summary>The worst blocking wait in the window, in seconds.</summary> + public double MaxBlockedSeconds { get; init; } + + /// <summary>Deadlocks in the window.</summary> + public int DeadlockCount { get; init; } + + /// <summary>Worker-thread ceiling (max_workers_count), or null with no scheduler snapshot (e.g. Azure SQL DB).</summary> + public int? TotalThreads { get; init; } + + /// <summary>Available worker threads = ceiling - in-use, or null with no scheduler snapshot.</summary> + public int? AvailableThreads { get; init; } + + /// <summary>Runnable tasks waiting for a CPU (total_runnable_tasks_count).</summary> + public int ThreadsWaitingForCpu { get; init; } + + /// <summary>Requests starved of a worker thread (total_work_queue_count).</summary> + public long RequestsWaitingForThreads { get; init; } + + /// <summary>Collectors whose 7-day band is FAILING (no success in over 24h).</summary> + public int FailedCollectorCount { get; init; } + } + + /// <summary> + /// The single, app-agnostic source of truth for a server's per-metric health bands, its overall card band, + /// the collection-freshness band, and the fleet-ranking score. Reproduces the Dashboard's <c>ServerHealthStatus</c> + /// CASE logic exactly. Pure + static so every host (web, MCP tool, WPF viewer) bands identically and the whole + /// decision table is unit-testable without a store (#1562). + /// </summary> + public static class ServerHealthClassifier + { + /// <summary> + /// Classify how fresh the newest collection is. Pure over (last-collection, now). Both instants are UTC + /// (the store is naive UTC; <paramref name="nowUtc"/> is <see cref="DateTime.UtcNow"/>), so the + /// subtraction is a true elapsed-time regardless of Kind. + /// </summary> + public static ServerFreshness ClassifyFreshness(DateTime? lastCollectionUtc, DateTime nowUtc) + { + if (!lastCollectionUtc.HasValue) + { + return ServerFreshness.NeverCollected; + } + + var age = nowUtc - lastCollectionUtc.Value; + if (age > ServerHealthThresholds.OfflineThreshold) + { + return ServerFreshness.Offline; + } + + if (age > ServerHealthThresholds.StaleThreshold) + { + return ServerFreshness.Stale; + } + + return ServerFreshness.Fresh; + } + + /// <summary>CPU band on total non-idle CPU: >= 95% Critical, >= 80% Warning; no snapshot Unknown.</summary> + public static HealthSeverity CpuSeverity(double? cpuPercentForAlert) + { + if (!cpuPercentForAlert.HasValue) + { + return HealthSeverity.Unknown; + } + + if (cpuPercentForAlert >= 95) + { + return HealthSeverity.Critical; + } + + if (cpuPercentForAlert >= 80) + { + return HealthSeverity.Warning; + } + + return HealthSeverity.Healthy; + } + + /// <summary>Memory band — Critical on any resource-semaphore pressure, else Healthy.</summary> + public static HealthSeverity MemorySeverity(bool hasMemoryPressure) => + hasMemoryPressure ? HealthSeverity.Critical : HealthSeverity.Healthy; + + /// <summary>Blocking band: >= 60s max wait or >= 5 events Critical; >= 10s max wait, >= 2 events, or any blocking Warning.</summary> + public static HealthSeverity BlockingSeverity(int blockingCount, double maxBlockedSeconds) + { + if (maxBlockedSeconds >= 60) + { + return HealthSeverity.Critical; + } + + if (blockingCount >= 5) + { + return HealthSeverity.Critical; + } + + if (maxBlockedSeconds >= 10) + { + return HealthSeverity.Warning; + } + + if (blockingCount >= 2) + { + return HealthSeverity.Warning; + } + + if (blockingCount > 0) + { + return HealthSeverity.Warning; + } + + return HealthSeverity.Healthy; + } + + /// <summary>Deadlock band — any deadlock in the window is Critical.</summary> + public static HealthSeverity DeadlockSeverity(int deadlockCount) => + deadlockCount > 0 ? HealthSeverity.Critical : HealthSeverity.Healthy; + + /// <summary> + /// Threads band: work-queue starvation Critical; >= 20 runnable-waiting or under 10% workers available + /// Warning. Unknown when there is no scheduler snapshot. + /// </summary> + public static HealthSeverity ThreadsSeverity(int? totalThreads, int? availableThreads, int threadsWaitingForCpu, long requestsWaitingForThreads) + { + if (!totalThreads.HasValue) + { + return HealthSeverity.Unknown; + } + + if (requestsWaitingForThreads > 0) + { + return HealthSeverity.Critical; + } + + if (threadsWaitingForCpu >= 20) + { + return HealthSeverity.Warning; + } + + if (totalThreads.Value > 0 && availableThreads < totalThreads.Value * 0.10) + { + return HealthSeverity.Warning; + } + + return HealthSeverity.Healthy; + } + + /// <summary>Collectors band — any FAILING collector is Warning.</summary> + public static HealthSeverity CollectorSeverity(int failedCollectorCount) => + failedCollectorCount > 0 ? HealthSeverity.Warning : HealthSeverity.Healthy; + + /// <summary>The six per-metric card severities, in card row order — the reuse surface for scoring / reasons.</summary> + public static IEnumerable<HealthSeverity> MetricSeverities(ServerHealthMetrics m) + { + yield return CpuSeverity(m.CpuPercentForAlert); + yield return ThreadsSeverity(m.TotalThreads, m.AvailableThreads, m.ThreadsWaitingForCpu, m.RequestsWaitingForThreads); + yield return MemorySeverity(m.HasMemoryPressure); + yield return BlockingSeverity(m.BlockingCount, m.MaxBlockedSeconds); + yield return DeadlockSeverity(m.DeadlockCount); + yield return CollectorSeverity(m.FailedCollectorCount); + } + + /// <summary> + /// The card's worst metric band (offline handled separately by the border / overlay). Unknown and Healthy + /// never escalate — matching <c>ServerHealthStatus.OverallSeverity</c>'s reduce. + /// </summary> + public static HealthSeverity OverallMetricSeverity(in ServerHealthMetrics m) + { + var worst = HealthSeverity.Healthy; + foreach (var s in MetricSeverities(m)) + { + if (s == HealthSeverity.Critical) + { + return HealthSeverity.Critical; + } + + if (s == HealthSeverity.Warning) + { + worst = HealthSeverity.Warning; + } + } + + return worst; + } + + /// <summary> + /// Collapses a server's health to one fleet band, mirroring the card border: offline collection -> Offline; + /// a never-collected (queued-during-bootstrap) server -> Warning (attention-worthy but not the red overlay); + /// else the card's worst metric band, with a stale collection also Warning. + /// </summary> + public static FleetHealthBand ClassifyBand(bool? isOnline, bool awaitingFirstCollection, bool hasCollectorErrors, HealthSeverity overallMetricSeverity) + { + if (isOnline == false) + { + return FleetHealthBand.Offline; + } + + if (awaitingFirstCollection) + { + return FleetHealthBand.Warning; + } + + return overallMetricSeverity switch + { + HealthSeverity.Critical => FleetHealthBand.Critical, + HealthSeverity.Warning => FleetHealthBand.Warning, + _ => hasCollectorErrors ? FleetHealthBand.Warning : FleetHealthBand.Healthy, + }; + } + + /// <summary> + /// The worst-first ordering score. Band rank dominates (Offline > Critical > Warning > Healthy) in + /// steps of 1000; within a band, servers are ranked by how many of the six card metrics are Critical (x100) + /// or Warning (x10), with the blocking + deadlock counts (capped at 99) as a final tiebreak. The within-band + /// terms are bounded well under 1000, so they never reorder bands. + /// </summary> + public static long FleetHealthScore(FleetHealthBand band, in ServerHealthMetrics m) + { + long bandRank = band switch + { + FleetHealthBand.Offline => 4000, + FleetHealthBand.Critical => 3000, + FleetHealthBand.Warning => 2000, + _ => 0, + }; + + var criticals = 0; + var warnings = 0; + foreach (var sev in MetricSeverities(m)) + { + if (sev == HealthSeverity.Critical) + { + criticals++; + } + else if (sev == HealthSeverity.Warning) + { + warnings++; + } + } + + long magnitude = (criticals * 100L) + (warnings * 10L); + long incidents = Math.Min(m.BlockingCount + m.DeadlockCount, 99); + return bandRank + magnitude + incidents; + } + + /// <summary>A short human label for a fleet band ("Healthy" / "Warning" / "Critical" / "Offline").</summary> + public static string BandLabel(FleetHealthBand band) => band switch + { + FleetHealthBand.Critical => "Critical", + FleetHealthBand.Warning => "Warning", + FleetHealthBand.Offline => "Offline", + _ => "Healthy", + }; + } + + /// <summary> + /// The one, app-agnostic source of truth for the collector-health ROW banding — the per-collector + /// NEVER_RUN / NO_PERMISSIONS / FAILING / STALE / WARNING / HEALTHY status shown on every Collection + /// Health surface (Lite's grid, the Darling WPF viewer's grid + Overview "collectors failing" count, + /// and the service's <c>get_collection_health</c> MCP tool + web fleet failing-count reader). It was + /// three byte-identical copies (Lite / viewer / service) with FLAT 4h-STALE / 24h-FAILING thresholds; + /// nothing pinned them together, so they could drift (#1573), and the flat numbers assumed a frequent + /// (~1-min) collector — a healthy DAILY collector that succeeds every run still read as STALE past 4h + /// and FAILING past 24h (index_object_stats at a 1440-min cadence, a real field false-positive). + /// <para> + /// The fix makes the staleness thresholds RELATIVE to each collector's own cadence, with floors set to + /// the original flat values so every FREQUENT collector bands byte-for-byte identically — only a slow + /// collector (cadence past ~2.7h for STALE / 12h for FAILING) relaxes. Pure + static so all three + /// surfaces band identically and the whole decision table is unit-testable without a store. Each host + /// keeps its own SQL, row model, and brush/display mapping; only the band DECISION lives here. + /// </para> + /// </summary> + public static class CollectorHealthClassifier + { + /* The band strings every surface's brush / display mapping already switches on — unchanged values. */ + public const string NeverRun = "NEVER_RUN"; + public const string NoPermissions = "NO_PERMISSIONS"; + public const string Failing = "FAILING"; + public const string Stale = "STALE"; + public const string Warning = "WARNING"; + public const string Healthy = "HEALTHY"; + + /// <summary>A collector with runs whose error rate exceeds this percent bands WARNING (when not STALE/FAILING).</summary> + public const double WarningFailureRatePercent = 20.0; + + /* Staleness cutoffs are max(floor, multiplier x the collector's own cadence in hours). The floors are + the original flat thresholds, so a collector with a cadence at/under the floor is unchanged; only a + slow collector relaxes. Chosen defaults (#1573): FAILING = max(24, 2 x freqHours) — a 1-min + collector still fails at 24h, a daily (1440-min) collector fails at 48h; STALE = max(4, 1.5 x + freqHours) — a 1-min collector still goes stale at 4h, a daily collector goes stale at 36h. So + index_object_stats (1440-min) at 27h since last success reads HEALTHY, not FAILING — the bug. */ + + /// <summary>Hours-since-last-success floor for FAILING — the original flat threshold.</summary> + public const double FailingFloorHours = 24.0; + + /// <summary>FAILING when hours-since-success exceeds this multiple of the collector's cadence (or the floor, whichever is larger).</summary> + public const double FailingCadenceMultiplier = 2.0; + + /// <summary>Hours-since-last-success floor for STALE — the original flat threshold.</summary> + public const double StaleFloorHours = 4.0; + + /// <summary>STALE when hours-since-success exceeds this multiple of the collector's cadence (or the floor, whichever is larger).</summary> + public const double StaleCadenceMultiplier = 1.5; + + /// <summary> + /// On-load collectors run once per server connect / tab open, NOT on the scheduled loop, so the + /// staleness thresholds never apply to them — they are banded by failure rate only (a 100-hour-old + /// last success is fine). Centralized here so the three surfaces cannot disagree on the set. These + /// are exactly the <c>FrequencyMinutes == 0</c> entries in <c>CollectorScheduleDefaults</c>, kept as + /// an explicit name set so this classifier stays free of a dependency on the collector catalog. + /// </summary> + private static readonly HashSet<string> OnLoadCollectorNames = new(StringComparer.OrdinalIgnoreCase) + { + "server_config", + "database_config", + "database_scoped_config", + "trace_flags", + "server_properties", + }; + + /// <summary>True for a collector that runs on connect rather than on the scheduled loop (staleness-exempt).</summary> + public static bool IsOnLoadCollector(string? collectorName) => + collectorName is not null && OnLoadCollectorNames.Contains(collectorName); + + /// <summary>The FAILING cutoff (hours since last success) for a collector of the given cadence.</summary> + public static double FailingThresholdHours(int frequencyMinutes) => + Math.Max(FailingFloorHours, FailingCadenceMultiplier * (frequencyMinutes / 60.0)); + + /// <summary>The STALE cutoff (hours since last success) for a collector of the given cadence.</summary> + public static double StaleThresholdHours(int frequencyMinutes) => + Math.Max(StaleFloorHours, StaleCadenceMultiplier * (frequencyMinutes / 60.0)); + + /// <summary> + /// Band one collector's trailing-window roll-up. Order is fixed: NEVER_RUN (no runs at all) -> + /// NO_PERMISSIONS (only permission denials) -> on-load (failure-rate only, never STALE/FAILING) -> + /// FAILING -> STALE -> WARNING (failure rate over the threshold) -> HEALTHY. + /// <paramref name="hoursSinceLastSuccess"/> is the caller's elapsed-hours value — its 999 sentinel + /// for "ran but never a success" flows straight through to FAILING, exactly as before. + /// <paramref name="frequencyMinutes"/> is the collector's cadence (callers resolve it from + /// <c>CollectorScheduleDefaults</c>; 0 for on-load or an unknown collector, which yields the floor + /// thresholds = the old flat behavior). <paramref name="isOnLoad"/> is <see cref="IsOnLoadCollector"/>. + /// </summary> + public static string Classify( + long totalRuns, + long successCount, + long errorCount, + long permissionDeniedCount, + double hoursSinceLastSuccess, + int frequencyMinutes, + bool isOnLoad) + { + if (totalRuns == 0) + { + return NeverRun; + } + + if (permissionDeniedCount > 0 && errorCount == 0 && successCount == 0) + { + return NoPermissions; + } + + var failureRatePercent = totalRuns > 0 ? (double)errorCount / totalRuns * 100 : 0; + + if (isOnLoad) + { + return failureRatePercent > WarningFailureRatePercent ? Warning : Healthy; + } + + if (hoursSinceLastSuccess > FailingThresholdHours(frequencyMinutes)) + { + return Failing; + } + + if (hoursSinceLastSuccess > StaleThresholdHours(frequencyMinutes)) + { + return Stale; + } + + if (failureRatePercent > WarningFailureRatePercent) + { + return Warning; + } + + return Healthy; + } + } +} diff --git a/PerformanceMonitor.Common/Services/CredentialStore.cs b/PerformanceMonitor.Common/Services/CredentialStore.cs new file mode 100644 index 000000000..90121c73e --- /dev/null +++ b/PerformanceMonitor.Common/Services/CredentialStore.cs @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using CredentialManagement; +using Microsoft.Extensions.Logging; + +namespace PerformanceMonitor.Common; + +/// <summary> +/// Secure credential storage backed by Windows Credential Manager. Credentials are +/// encrypted by Windows using DPAPI and stored per-user. NEVER stores passwords in plain text. +/// +/// <para> +/// Shared by Dashboard and Lite. The <c>credentialPrefix</c> namespaces each app's stored +/// secrets so they never collide in Windows Credential Manager: Dashboard uses +/// <c>"PerformanceMonitor_"</c>, Lite uses <c>"PerformanceMonitorLite_"</c>. Those prefixes +/// are LOAD-BEARING and must stay distinct — unifying them would let one app read the other's +/// credentials. Each app wraps this type in a thin adapter that supplies its own prefix. +/// </para> +/// </summary> +public class CredentialStore +{ + private const string DefaultDescription = "SQL Server credentials for Performance Monitor"; + + private readonly string _credentialPrefix; + private readonly string _description; + private readonly ILogger? _logger; + + /// <summary> + /// Creates a credential store for the given prefix using the default credential description. + /// </summary> + /// <param name="credentialPrefix"> + /// Per-app prefix prepended to every credential target (e.g. <c>"PerformanceMonitor_"</c>). + /// Must be non-empty and distinct per app. + /// </param> + /// <param name="logger">Optional logger; failures are logged as warnings.</param> + public CredentialStore(string credentialPrefix, ILogger? logger = null) + : this(credentialPrefix, DefaultDescription, logger) + { + } + + /// <summary> + /// Creates a credential store for the given prefix and credential description. + /// </summary> + /// <param name="credentialPrefix"> + /// Per-app prefix prepended to every credential target (e.g. <c>"PerformanceMonitor_"</c>). + /// Must be non-empty and distinct per app. + /// </param> + /// <param name="description">Description stored on each credential entry in Credential Manager.</param> + /// <param name="logger">Optional logger; failures are logged as warnings.</param> + public CredentialStore(string credentialPrefix, string description, ILogger? logger = null) + { + if (string.IsNullOrWhiteSpace(credentialPrefix)) + { + throw new ArgumentException("Credential prefix cannot be null or empty", nameof(credentialPrefix)); + } + + _credentialPrefix = credentialPrefix; + _description = description; + _logger = logger; + } + + /// <summary> + /// Saves SQL Server credentials to Windows Credential Manager. + /// Credentials are encrypted automatically by Windows. + /// </summary> + /// <param name="serverId">Unique server identifier</param> + /// <param name="username">SQL Server username</param> + /// <param name="password">SQL Server password (will be encrypted)</param> + /// <returns>True if successful, false otherwise</returns> + public bool SaveCredential(string serverId, string username, string password) + { + if (string.IsNullOrWhiteSpace(serverId)) + { + throw new ArgumentException("Server ID cannot be null or empty", nameof(serverId)); + } + + if (string.IsNullOrWhiteSpace(username)) + { + throw new ArgumentException("Username cannot be null or empty", nameof(username)); + } + + if (password == null) + { + throw new ArgumentNullException(nameof(password)); + } + + try + { + using var credential = new Credential + { + Target = GetCredentialTarget(serverId), + Username = username, + Password = password, + Type = CredentialType.Generic, + PersistanceType = PersistanceType.LocalComputer, + Description = _description + }; + + return credential.Save(); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Failed to save credential for server {ServerId}: {Error}", serverId, ex.Message); + return false; + } + } + + /// <summary> + /// Retrieves SQL Server credentials from Windows Credential Manager. + /// </summary> + /// <param name="serverId">Unique server identifier</param> + /// <returns>Tuple of (username, password) or null if not found</returns> + public (string Username, string Password)? GetCredential(string serverId) + { + if (string.IsNullOrWhiteSpace(serverId)) + { + throw new ArgumentException("Server ID cannot be null or empty", nameof(serverId)); + } + + try + { + using var credential = new Credential + { + Target = GetCredentialTarget(serverId), + Type = CredentialType.Generic + }; + + if (credential.Load()) + { + return (credential.Username, credential.Password); + } + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Failed to load credential for server {ServerId}: {Error}", serverId, ex.Message); + } + + return null; + } + + /// <summary> + /// Deletes SQL Server credentials from Windows Credential Manager. + /// Should be called when a server is removed. + /// </summary> + /// <param name="serverId">Unique server identifier</param> + /// <returns>True if deleted or didn't exist, false on error</returns> + public bool DeleteCredential(string serverId) + { + if (string.IsNullOrWhiteSpace(serverId)) + { + throw new ArgumentException("Server ID cannot be null or empty", nameof(serverId)); + } + + try + { + using var credential = new Credential + { + Target = GetCredentialTarget(serverId), + Type = CredentialType.Generic + }; + + return credential.Delete(); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Failed to delete credential for server {ServerId}: {Error}", serverId, ex.Message); + return false; + } + } + + /// <summary> + /// Checks if credentials exist for a server. + /// </summary> + /// <param name="serverId">Unique server identifier</param> + /// <returns>True if credentials exist in Credential Manager</returns> + public bool CredentialExists(string serverId) + { + if (string.IsNullOrWhiteSpace(serverId)) + { + return false; + } + + try + { + using var credential = new Credential + { + Target = GetCredentialTarget(serverId), + Type = CredentialType.Generic + }; + + return credential.Exists(); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Failed to check credential existence for server {ServerId}: {Error}", serverId, ex.Message); + return false; + } + } + + /// <summary> + /// Updates existing credentials. If credentials don't exist, creates them. + /// </summary> + /// <param name="serverId">Unique server identifier</param> + /// <param name="username">SQL Server username</param> + /// <param name="password">SQL Server password (will be encrypted)</param> + /// <returns>True if successful, false otherwise</returns> + public bool UpdateCredential(string serverId, string username, string password) + { + DeleteCredential(serverId); + return SaveCredential(serverId, username, password); + } + + /// <summary> + /// Generates the credential target name for Windows Credential Manager. + /// Format: {credentialPrefix}{serverId} + /// </summary> + private string GetCredentialTarget(string serverId) + { + return $"{_credentialPrefix}{serverId}"; + } +} diff --git a/PerformanceMonitor.Common/Services/ServerIdHelper.cs b/PerformanceMonitor.Common/Services/ServerIdHelper.cs index 5cce81825..9ffba5e78 100644 --- a/PerformanceMonitor.Common/Services/ServerIdHelper.cs +++ b/PerformanceMonitor.Common/Services/ServerIdHelper.cs @@ -38,4 +38,22 @@ public static int GetDeterministicHashCode(string value) return hash; } } + + /// <summary> + /// The canonical storage name a monitored server is identified by (hashed via + /// <see cref="GetDeterministicHashCode"/> to derive server_id). Appends the database name + /// for Azure SQL Database connections so different databases on one logical server get + /// distinct server_ids, and ":RO" for ReadOnlyIntent connections so they differ from + /// read-write connections to the same host. Extracted verbatim from Lite's + /// RemoteCollectorService.GetServerNameForStorage; every SKU MUST build storage names + /// through this one implementation so the same server derives the same id everywhere. + /// </summary> + public static string BuildStorageName(string serverName, string? databaseName, bool readOnlyIntent) + { + var name = string.IsNullOrWhiteSpace(databaseName) + ? serverName + : serverName + ":" + databaseName; + + return readOnlyIntent ? name + ":RO" : name; + } } diff --git a/PerformanceMonitor.Common/SessionStatsSummary.cs b/PerformanceMonitor.Common/SessionStatsSummary.cs new file mode 100644 index 000000000..3ce15c47e --- /dev/null +++ b/PerformanceMonitor.Common/SessionStatsSummary.cs @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Globalization; + +namespace PerformanceMonitor.Common; + +/// <summary> +/// The pure Session Stats summary-strip projection shared by Lite and the Darling viewer (previously the +/// byte-identical <c>FormatSessionSummary</c> in each app's Session Stats tab, itself the Dashboard's +/// <c>UpdateSessionStatsSummary</c> shaping). Given the latest server-wide session-summary snapshot it shapes +/// the three non-chartable attribution fields: Top Application / Top Host render as "<c>name (count)</c>" when +/// the snapshot names one and "N/A" otherwise; Databases is the distinct-databases count. A null point (no data +/// in the window) yields "N/A" for all three. WPF-free and static so the shaping is unit-testable without a +/// WpfPlot; each app keeps its own named-TextBlock setter that consumes this tuple. +/// </summary> +public static class SessionStatsSummary +{ + /// <summary> + /// Shapes the summary strip's Top Application / Top Host / Databases strings from the latest snapshot + /// (or all "N/A" when <paramref name="data"/> is null). + /// </summary> + public static (string TopApplication, string TopHost, string Databases) Format(ISessionStatsPoint? data) + { + if (data is null) + { + return ("N/A", "N/A", "N/A"); + } + + var topApp = !string.IsNullOrEmpty(data.TopApplicationName) + ? $"{data.TopApplicationName} ({data.TopApplicationConnections ?? 0})" + : "N/A"; + var topHost = !string.IsNullOrEmpty(data.TopHostName) + ? $"{data.TopHostName} ({data.TopHostConnections ?? 0})" + : "N/A"; + var databases = data.DatabasesWithConnections.ToString(CultureInfo.CurrentCulture); + + return (topApp, topHost, databases); + } +} diff --git a/PerformanceMonitor.Common/SqlErrorClassification.cs b/PerformanceMonitor.Common/SqlErrorClassification.cs new file mode 100644 index 000000000..57094f066 --- /dev/null +++ b/PerformanceMonitor.Common/SqlErrorClassification.cs @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Collections.Generic; + +namespace PerformanceMonitor.Common; + +/// <summary> +/// The single source of truth for how a SQL Server error number is judged. +/// +/// Two questions are asked about errors across the products, and they must never disagree: +/// +/// "Is this temporary — should we retry?" <see cref="IsTransient"/> +/// "Is this permanent — should we give up?" <see cref="IsMasterAccessDenied"/> +/// +/// The two sets MUST be disjoint. Issue #1506 is what happens when they aren't: error 40613 +/// ("database not currently available, please retry later") sat on the retryable list AND on the +/// give-up list at the same time, so the same error meant both "this will pass" and "this is +/// forever" depending on which code path saw it first. +/// +/// These lists lived separately in Lite and in Darling, which is how the bug was ported from one +/// to the other. They live here now so that cannot happen again, and so the disjointness invariant +/// can be asserted from both test suites. Plain integers, no SqlClient dependency, so every project +/// can reference this regardless of how it talks to SQL Server. +/// </summary> +public static class SqlErrorClassification +{ + /// <summary> + /// Errors that are temporary by nature: the operation may well succeed if tried again. + /// </summary> + public static readonly IReadOnlySet<int> TransientErrorNumbers = new HashSet<int> + { + -2, // Timeout + -1, // General network error + 2, // Timeout (alternative) + 53, // Named pipe / network not found + 233, // Connection closed + 10053, // Transport-level error (connection was forcibly closed) + 10054, // Connection reset by peer + 10060, // Connection timed out + 10061, // Connection refused + 40143, // Azure SQL transient + 40197, // Azure SQL transient - service encountered an error + 40501, // Azure SQL - service is currently busy + 40613, // Azure SQL - database not currently available + 49918, // Azure SQL - not enough resources to process request + 49919, // Azure SQL - cannot process create/update request + 49920 // Azure SQL - cannot process request (too many operations) + }; + + /// <summary> + /// Errors meaning a login genuinely cannot read <c>master</c>, so database-scoped collectors on + /// Azure SQL DB must fall back to the connection's own catalog. This is the #857 case: a contained + /// user that exists only inside a user database really does get these when it opens master. + /// + /// Every entry must describe the login's RIGHTS, never the server's REACHABILITY. Two reachability + /// errors used to be here and caused #1506: + /// + /// 40613 — "not currently available, please retry later". Transient on its face, and listed as + /// retryable above — the contradiction this class exists to prevent. + /// 40615 — "client with IP address ... is not allowed to access the server". A firewall rejection + /// at the logical server. It says nothing about master specifically, and falling back to + /// a user database is futile because the same rule blocks that connection too. + /// + /// Reported by a user whose public IP rotated daily: each rotation rejected the connection, the + /// rejection was misread as "this login may not read master", and the verdict was cached — so + /// collection stayed broken until the app was restarted. + /// + /// 4060 and 18456 stay, because a contained user really does get them on master. Callers must + /// still treat the verdict as revocable — both can also be thrown transiently. + /// </summary> + public static readonly IReadOnlySet<int> MasterAccessDeniedErrorNumbers = new HashSet<int> + { + 229, // Permission denied on object + 230, // Permission denied on column + 916, // Server principal cannot access the database under the current security context + 4060, // Cannot open database requested by the login + 18456 // Login failed for user + }; + + /// <summary> + /// True when the error is temporary and the operation is worth retrying. + /// </summary> + public static bool IsTransient(int errorNumber) => TransientErrorNumbers.Contains(errorNumber); + + /// <summary> + /// True when the error means this login cannot read <c>master</c>. Never true for an error that + /// merely means the server was unreachable — see the remarks on + /// <see cref="MasterAccessDeniedErrorNumbers"/>. + /// </summary> + public static bool IsMasterAccessDenied(int errorNumber) => MasterAccessDeniedErrorNumbers.Contains(errorNumber); +} diff --git a/PerformanceMonitor.Common/SystemHealthModels.cs b/PerformanceMonitor.Common/SystemHealthModels.cs new file mode 100644 index 000000000..5d52fe1d1 --- /dev/null +++ b/PerformanceMonitor.Common/SystemHealthModels.cs @@ -0,0 +1,544 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; + +namespace PerformanceMonitor.Common +{ + /* + * App-agnostic (Common) record types for the eight unique system_health warning categories, shredded + * from the raw system_health event XML captured by SystemHealthEventsCollector (Stage 1). Each record + * mirrors, column-for-column, the target-table shape Erik's sp_HealthParser logs (the *_SchedulerIssues, + * *_SevereErrors, *_MemoryConditions, *_MemoryBroker, *_MemoryNodeOOM and *_SignificantWaits tables) so + * the monitor-side C# parse (SystemHealthParser) stays a faithful port of the T-SQL xpath extraction. + * + * Types mirror the sp_HealthParser .value() extraction types (bigint -> long, integer -> int, bit -> + * bool, numeric(38,0) -> decimal), NOT the display-formatted table columns (e.g. sp_HealthParser stores + * the already-bigint durations as nvarchar(30) only to strip trailing decimals at render time; here they + * stay long so a consumer keeps full numeric fidelity). Every field is nullable: a raw event may omit any + * data node, and this is a pure shred with NO warning/severity/duration filtering applied — those WHERE + * predicates are an analysis/display concern (Stage 2b), and every column needed to re-apply them + * (severity, duration, wait_type, status, notification, lastNotification) is preserved. + */ + + /// <summary> + /// One scheduler-monitor warning row, shredded from a + /// <c>scheduler_monitor_system_health_ring_buffer_recorded</c> event. Mirrors sp_HealthParser's + /// <c>*_SchedulerIssues</c> table (non-yielding schedulers, deadlocked schedulers). + /// </summary> + public sealed record SchedulerIssueRecord + { + /// <summary>Event <c>@timestamp</c>, parsed as UTC (sp_HealthParser converts to server-local at render; kept UTC here).</summary> + public DateTime? EventTime { get; init; } + + /// <summary>xpath <c>data[@name="scheduler_id"]/value</c>.</summary> + public int? SchedulerId { get; init; } + + /// <summary>xpath <c>data[@name="cpu_id"]/value</c>.</summary> + public int? CpuId { get; init; } + + /// <summary>xpath <c>data[@name="status"]/text</c> (the friendly enum text, e.g. WARNING).</summary> + public string? Status { get; init; } + + /// <summary>xpath <c>data[@name="is_online"]/value</c> (bit).</summary> + public bool? IsOnline { get; init; } + + /// <summary>xpath <c>data[@name="is_runnable"]/value</c> (bit).</summary> + public bool? IsRunnable { get; init; } + + /// <summary>xpath <c>data[@name="is_running"]/value</c> (bit).</summary> + public bool? IsRunning { get; init; } + + /// <summary>xpath <c>data[@name="non_yielding_time"]/value</c> (bigint; the table column is named *_ms).</summary> + public long? NonYieldingTimeMs { get; init; } + + /// <summary>xpath <c>data[@name="thread_quantum"]/value</c> (bigint; the table column is named *_ms).</summary> + public long? ThreadQuantumMs { get; init; } + } + + /// <summary> + /// One severe-error row, shredded from an <c>error_reported</c> event. Mirrors sp_HealthParser's + /// <c>*_SevereErrors</c> table. sp_HealthParser surfaces only severity >= 16 (ignoring 17830/18056); + /// that filtering is NOT applied here (it is a Stage 2b concern), so <see cref="Severity"/> is preserved + /// for the consumer to re-apply. + /// </summary> + public sealed record SevereErrorRecord + { + /// <summary>Event <c>@timestamp</c>, parsed as UTC.</summary> + public DateTime? EventTime { get; init; } + + /// <summary>xpath <c>data[@name="error_number"]/value</c>.</summary> + public int? ErrorNumber { get; init; } + + /// <summary>xpath <c>data[@name="severity"]/value</c>.</summary> + public int? Severity { get; init; } + + /// <summary>xpath <c>data[@name="state"]/value</c>.</summary> + public int? State { get; init; } + + /// <summary>xpath <c>data[@name="message"]/value</c>.</summary> + public string? Message { get; init; } + + /// <summary> + /// The database name for <see cref="DatabaseId"/>. sp_HealthParser resolves this server-side via + /// <c>DB_NAME(database_id)</c>; a DB-free parse-on-read shred cannot resolve a dbid to a name (the raw + /// event carries only the id), so this is always null from the pure parser and is left for a later, + /// connection-bearing stage to fill. Preserved (not cut) so the column set matches sp_HealthParser. + /// </summary> + public string? DatabaseName { get; init; } + + /// <summary>xpath <c>data[@name="database_id"]/value</c>.</summary> + public int? DatabaseId { get; init; } + } + + /// <summary> + /// One memory-conditions snapshot, shredded from the RESOURCE component of an + /// <c>sp_server_diagnostics_component_result</c> event (the <c><resource></c> node's attributes plus + /// its <c>memoryReport</c> entries). Mirrors sp_HealthParser's <c>*_MemoryConditions</c> table. The + /// <c>*_gb</c> fields are integer-divided exactly as sp_HealthParser does: byte-valued entries by + /// 1024^3, KB-valued Memory-Manager entries by 1024^2. + /// </summary> + public sealed record MemoryConditionsRecord + { + /// <summary>Event <c>@timestamp</c>, parsed as UTC.</summary> + public DateTime? EventTime { get; init; } + + /// <summary><c><resource></c> attribute <c>@lastNotification</c> (e.g. RESOURCE_MEMPHYSICAL_HIGH/LOW).</summary> + public string? LastNotification { get; init; } + + /// <summary><c><resource></c> attribute <c>@outOfMemoryExceptions</c>.</summary> + public long? OutOfMemoryExceptions { get; init; } + + /// <summary><c><resource></c> attribute <c>@isAnyPoolOutOfMemory</c> (bit).</summary> + public bool? IsAnyPoolOutOfMemory { get; init; } + + /// <summary><c><resource></c> attribute <c>@processOutOfMemoryPeriod</c>.</summary> + public long? ProcessOutOfMemoryPeriod { get; init; } + + /// <summary>First <c>memoryReport/@name</c> (e.g. "Process/System Counts").</summary> + public string? Name { get; init; } + + /// <summary>memoryReport entry "Available Physical Memory" (bytes), / 1024^3 = GB.</summary> + public long? AvailablePhysicalMemoryGb { get; init; } + + /// <summary>memoryReport entry "Available Virtual Memory" (bytes), / 1024^3 = GB.</summary> + public long? AvailableVirtualMemoryGb { get; init; } + + /// <summary>memoryReport entry "Available Paging File" (bytes), / 1024^3 = GB.</summary> + public long? AvailablePagingFileGb { get; init; } + + /// <summary>memoryReport entry "Working Set" (bytes), / 1024^3 = GB.</summary> + public long? WorkingSetGb { get; init; } + + /// <summary>memoryReport entry "Percent of Committed Memory in WS" (raw).</summary> + public long? PercentOfCommittedMemoryInWs { get; init; } + + /// <summary>memoryReport entry "Page Faults" (raw).</summary> + public long? PageFaults { get; init; } + + /// <summary>memoryReport entry "System physical memory high" (raw).</summary> + public long? SystemPhysicalMemoryHigh { get; init; } + + /// <summary>memoryReport entry "System physical memory low" (raw).</summary> + public long? SystemPhysicalMemoryLow { get; init; } + + /// <summary>memoryReport entry "Process physical memory low" (raw).</summary> + public long? ProcessPhysicalMemoryLow { get; init; } + + /// <summary>memoryReport entry "Process virtual memory low" (raw).</summary> + public long? ProcessVirtualMemoryLow { get; init; } + + /// <summary>memoryReport entry "VM Reserved" (KB), / 1024^2 = GB.</summary> + public long? VmReservedGb { get; init; } + + /// <summary>memoryReport entry "VM Committed" (KB), / 1024^2 = GB.</summary> + public long? VmCommittedGb { get; init; } + + /// <summary>memoryReport entry "Locked Pages Allocated" (raw).</summary> + public long? LockedPagesAllocated { get; init; } + + /// <summary>memoryReport entry "Large Pages Allocated" (raw).</summary> + public long? LargePagesAllocated { get; init; } + + /// <summary>memoryReport entry "Emergency Memory" (KB), / 1024^2 = GB.</summary> + public long? EmergencyMemoryGb { get; init; } + + /// <summary>memoryReport entry "Emergency Memory In Use" (KB), / 1024^2 = GB.</summary> + public long? EmergencyMemoryInUseGb { get; init; } + + /// <summary>memoryReport entry "Target Committed" (KB), / 1024^2 = GB.</summary> + public long? TargetCommittedGb { get; init; } + + /// <summary>memoryReport entry "Current Committed" (KB), / 1024^2 = GB.</summary> + public long? CurrentCommittedGb { get; init; } + + /// <summary>memoryReport entry "Pages Allocated" (raw).</summary> + public long? PagesAllocated { get; init; } + + /// <summary>memoryReport entry "Pages Reserved" (raw).</summary> + public long? PagesReserved { get; init; } + + /// <summary>memoryReport entry "Pages Free" (raw).</summary> + public long? PagesFree { get; init; } + + /// <summary>memoryReport entry "Pages In Use" (raw).</summary> + public long? PagesInUse { get; init; } + + /// <summary>memoryReport entry "Page Alloc Potential" (raw).</summary> + public long? PageAllocPotential { get; init; } + + /// <summary>memoryReport entry "NUMA Growth Phase" (raw).</summary> + public long? NumaGrowthPhase { get; init; } + + /// <summary>memoryReport entry "Last OOM Factor" (raw).</summary> + public long? LastOomFactor { get; init; } + + /// <summary>memoryReport entry "Last OS Error" (raw).</summary> + public long? LastOsError { get; init; } + } + + /// <summary> + /// One memory-broker ratio-change row, shredded from a <c>memory_broker_ring_buffer_recorded</c> event. + /// Mirrors sp_HealthParser's <c>*_MemoryBroker</c> table. <see cref="BrokerId"/> is xpath + /// <c>data[@name="id"]</c> (the event names it just "id"). + /// </summary> + public sealed record MemoryBrokerRecord + { + /// <summary>Event <c>@timestamp</c>, parsed as UTC.</summary> + public DateTime? EventTime { get; init; } + + /// <summary>xpath <c>data[@name="id"]/value</c>.</summary> + public long? BrokerId { get; init; } + + /// <summary>xpath <c>data[@name="pool_metadata_id"]/value</c>.</summary> + public long? PoolMetadataId { get; init; } + + /// <summary>xpath <c>data[@name="delta_time"]/value</c>.</summary> + public long? DeltaTime { get; init; } + + /// <summary>xpath <c>data[@name="memory_ratio"]/value</c>.</summary> + public long? MemoryRatio { get; init; } + + /// <summary>xpath <c>data[@name="new_target"]/value</c>.</summary> + public long? NewTarget { get; init; } + + /// <summary>xpath <c>data[@name="overall"]/value</c>.</summary> + public long? Overall { get; init; } + + /// <summary>xpath <c>data[@name="rate"]/value</c>.</summary> + public long? Rate { get; init; } + + /// <summary>xpath <c>data[@name="currently_predicated"]/value</c>.</summary> + public long? CurrentlyPredicated { get; init; } + + /// <summary>xpath <c>data[@name="currently_allocated"]/value</c>.</summary> + public long? CurrentlyAllocated { get; init; } + + /// <summary>xpath <c>data[@name="previously_allocated"]/value</c>.</summary> + public long? PreviouslyAllocated { get; init; } + + /// <summary>xpath <c>data[@name="broker"]/value</c> (the broker name string, e.g. MEMORYBROKER_FOR_CACHE).</summary> + public string? Broker { get; init; } + + /// <summary>xpath <c>data[@name="notification"]/value</c> (e.g. RESOURCE_MEMPHYSICAL_HIGH/LOW).</summary> + public string? Notification { get; init; } + } + + /// <summary> + /// One memory-node out-of-memory row, shredded from a <c>memory_node_oom_ring_buffer_recorded</c> event. + /// Mirrors sp_HealthParser's <c>*_MemoryNodeOOM</c> table. <see cref="NodeId"/> is xpath + /// <c>data[@name="id"]</c>. The <c>is_*</c> flags are kept as their raw text ("true"/"false") exactly as + /// sp_HealthParser stores them (nvarchar(10)), not coerced to bool. + /// </summary> + public sealed record MemoryNodeOomRecord + { + /// <summary>Event <c>@timestamp</c>, parsed as UTC.</summary> + public DateTime? EventTime { get; init; } + + /// <summary>xpath <c>data[@name="id"]/value</c>.</summary> + public long? NodeId { get; init; } + + /// <summary>xpath <c>data[@name="memory_node_id"]/value</c>.</summary> + public long? MemoryNodeId { get; init; } + + /// <summary>xpath <c>data[@name="memory_utilization_pct"]/value</c>.</summary> + public long? MemoryUtilizationPct { get; init; } + + /// <summary>xpath <c>data[@name="total_physical_memory_kb"]/value</c>.</summary> + public long? TotalPhysicalMemoryKb { get; init; } + + /// <summary>xpath <c>data[@name="available_physical_memory_kb"]/value</c>.</summary> + public long? AvailablePhysicalMemoryKb { get; init; } + + /// <summary>xpath <c>data[@name="total_page_file_kb"]/value</c>.</summary> + public long? TotalPageFileKb { get; init; } + + /// <summary>xpath <c>data[@name="available_page_file_kb"]/value</c>.</summary> + public long? AvailablePageFileKb { get; init; } + + /// <summary>xpath <c>data[@name="total_virtual_address_space_kb"]/value</c>.</summary> + public long? TotalVirtualAddressSpaceKb { get; init; } + + /// <summary>xpath <c>data[@name="available_virtual_address_space_kb"]/value</c>.</summary> + public long? AvailableVirtualAddressSpaceKb { get; init; } + + /// <summary>xpath <c>data[@name="target_kb"]/value</c>.</summary> + public long? TargetKb { get; init; } + + /// <summary>xpath <c>data[@name="reserved_kb"]/value</c>.</summary> + public long? ReservedKb { get; init; } + + /// <summary>xpath <c>data[@name="committed_kb"]/value</c>.</summary> + public long? CommittedKb { get; init; } + + /// <summary>xpath <c>data[@name="shared_committed_kb"]/value</c> (numeric(38,0) -> decimal).</summary> + public decimal? SharedCommittedKb { get; init; } + + /// <summary>xpath <c>data[@name="awe_kb"]/value</c>.</summary> + public long? AweKb { get; init; } + + /// <summary>xpath <c>data[@name="pages_kb"]/value</c>.</summary> + public long? PagesKb { get; init; } + + /// <summary>xpath <c>data[@name="failure"]/text</c> (the failure enum's friendly text).</summary> + public string? FailureType { get; init; } + + /// <summary>xpath <c>data[@name="failure"]/value</c> (the failure enum's numeric value).</summary> + public long? FailureValue { get; init; } + + /// <summary>xpath <c>data[@name="resources"]/value</c>.</summary> + public long? Resources { get; init; } + + /// <summary>xpath <c>data[@name="factor"]/text</c> (the factor enum's friendly text).</summary> + public string? FactorText { get; init; } + + /// <summary>xpath <c>data[@name="factor"]/value</c> (the factor enum's numeric value).</summary> + public long? FactorValue { get; init; } + + /// <summary>xpath <c>data[@name="last_error"]/value</c>.</summary> + public long? LastError { get; init; } + + /// <summary>xpath <c>data[@name="pool_metadata_id"]/value</c>.</summary> + public long? PoolMetadataId { get; init; } + + /// <summary>xpath <c>data[@name="is_process_in_job"]/value</c> (raw "true"/"false" text).</summary> + public string? IsProcessInJob { get; init; } + + /// <summary>xpath <c>data[@name="is_system_physical_memory_high"]/value</c> (raw text).</summary> + public string? IsSystemPhysicalMemoryHigh { get; init; } + + /// <summary>xpath <c>data[@name="is_system_physical_memory_low"]/value</c> (raw text).</summary> + public string? IsSystemPhysicalMemoryLow { get; init; } + + /// <summary>xpath <c>data[@name="is_process_physical_memory_low"]/value</c> (raw text).</summary> + public string? IsProcessPhysicalMemoryLow { get; init; } + + /// <summary>xpath <c>data[@name="is_process_virtual_memory_low"]/value</c> (raw text).</summary> + public string? IsProcessVirtualMemoryLow { get; init; } + } + + /// <summary> + /// One significant-wait row, shredded from a <c>wait_info</c> event. Mirrors sp_HealthParser's + /// <c>*_SignificantWaits</c> table. sp_HealthParser filters these hard (session_id <> 0, sql_text + /// present and not a BACKUP, duration >= @wait_duration_ms, wait_type not in its ignore list); NONE of + /// that filtering is applied here — every field needed to re-apply it downstream is preserved. + /// </summary> + public sealed record SignificantWaitRecord + { + /// <summary>Event <c>@timestamp</c>, parsed as UTC.</summary> + public DateTime? EventTime { get; init; } + + /// <summary>xpath <c>data[@name="wait_type"]/text</c> (the friendly wait-type name).</summary> + public string? WaitType { get; init; } + + /// <summary>xpath <c>data[@name="duration"]/value</c> (bigint).</summary> + public long? DurationMs { get; init; } + + /// <summary>xpath <c>data[@name="signal_duration"]/value</c> (bigint).</summary> + public long? SignalDurationMs { get; init; } + + /// <summary>xpath <c>data[@name="wait_resource"]/value</c>.</summary> + public string? WaitResource { get; init; } + + /// <summary> + /// The waiting statement: action <c>sql_text</c>, with control characters (except tab/CR/LF) replaced + /// by '?' exactly as sp_HealthParser's query_text cleanup does before it is wrapped as XML. + /// </summary> + public string? QueryText { get; init; } + + /// <summary>xpath action <c>session_id</c>.</summary> + public int? SessionId { get; init; } + } + + /// <summary> + /// One CPU-task-details snapshot, shredded from the QUERY_PROCESSING component of an + /// <c>sp_server_diagnostics_component_result</c> event (the <c><queryProcessing></c> node's + /// attributes). Mirrors sp_HealthParser's <c>*_CPUTasks</c> table. The worker/task counters come + /// straight off the queryProcessing attributes; <see cref="DidBlockingOccur"/> is sp_HealthParser's + /// <c>.exist()</c> over the event's <c>blockingTasks/blocked-process-report</c> nodes. + /// </summary> + public sealed record CpuTasksRecord + { + /// <summary>Event <c>@timestamp</c>, parsed as UTC.</summary> + public DateTime? EventTime { get; init; } + + /// <summary>xpath <c>data[@name="state"]/text</c> — the component state text (CLEAN/WARNING/HAS_ISSUES).</summary> + public string? State { get; init; } + + /// <summary><c><queryProcessing></c> attribute <c>@maxWorkers</c> (bigint).</summary> + public long? MaxWorkers { get; init; } + + /// <summary><c><queryProcessing></c> attribute <c>@workersCreated</c> (bigint).</summary> + public long? WorkersCreated { get; init; } + + /// <summary><c><queryProcessing></c> attribute <c>@workersIdle</c> (bigint).</summary> + public long? WorkersIdle { get; init; } + + /// <summary><c><queryProcessing></c> attribute <c>@tasksCompletedWithinInterval</c> (bigint).</summary> + public long? TasksCompletedWithinInterval { get; init; } + + /// <summary><c><queryProcessing></c> attribute <c>@pendingTasks</c> (bigint).</summary> + public long? PendingTasks { get; init; } + + /// <summary><c><queryProcessing></c> attribute <c>@oldestPendingTaskWaitingTime</c> (bigint).</summary> + public long? OldestPendingTaskWaitingTime { get; init; } + + /// <summary><c><queryProcessing></c> attribute <c>@hasUnresolvableDeadlockOccurred</c> (bit).</summary> + public bool? HasUnresolvableDeadlockOccurred { get; init; } + + /// <summary><c><queryProcessing></c> attribute <c>@hasDeadlockedSchedulersOccurred</c> (bit).</summary> + public bool? HasDeadlockedSchedulersOccurred { get; init; } + + /// <summary> + /// True when the event carries a <c>queryProcessing/blockingTasks/blocked-process-report</c> node — + /// sp_HealthParser's <c>.exist()</c> bit (always a definite true/false, never null). + /// </summary> + public bool? DidBlockingOccur { get; init; } + } + + /// <summary> + /// One potential-IO-issue row, shredded from the IO_SUBSYSTEM component of an + /// <c>sp_server_diagnostics_component_result</c> event (the <c><ioSubsystem></c> node's attributes + /// plus its <c>longestPendingRequests</c> children). Mirrors sp_HealthParser's <c>*_IOIssues</c> table. + /// sp_HealthParser fans an event out to one row per <c>pendingRequest</c>, then collapses by file path + /// with the durations summed (its <c>#io</c> → <c>#i</c> shred); this record is one such per-(event, + /// file) row, so a single event with two distinct pending files yields two records. + /// </summary> + public sealed record IoIssuesRecord + { + /// <summary>Event <c>@timestamp</c>, parsed as UTC.</summary> + public DateTime? EventTime { get; init; } + + /// <summary>xpath <c>data[@name="state"]/text</c> — the component state text (CLEAN/WARNING/HAS_ISSUES).</summary> + public string? State { get; init; } + + /// <summary><c><ioSubsystem></c> attribute <c>@ioLatchTimeouts</c> (bigint).</summary> + public long? IoLatchTimeouts { get; init; } + + /// <summary><c><ioSubsystem></c> attribute <c>@intervalLongIos</c> (bigint).</summary> + public long? IntervalLongIos { get; init; } + + /// <summary><c><ioSubsystem></c> attribute <c>@totalLongIos</c> (bigint).</summary> + public long? TotalLongIos { get; init; } + + /// <summary> + /// SUM of the <c>pendingRequest/@duration</c> values for this file within the event (sp_HealthParser + /// stores the already-bigint value as nvarchar(30) only to strip trailing decimals at render; kept + /// long here for full numeric fidelity). + /// </summary> + public long? LongestPendingRequestsDurationMs { get; init; } + + /// <summary><c>pendingRequest/@filePath</c> (sp_HealthParser's <c>ISNULL(..., 'N/A')</c> grouping key).</summary> + public string? LongestPendingRequestsFilePath { get; init; } + } + + /// <summary> + /// One system-component health snapshot, shredded from the SYSTEM component of an + /// <c>sp_server_diagnostics_component_result</c> event (the <c><system></c> node's attributes). + /// Mirrors sp_HealthParser's <c>*_SystemHealth</c> table — the counter set that carries BOTH the + /// corruption signals (bad pages, dump requests, access violations) and the contention signals + /// (non-yielding tasks, latch warnings, sick spinlocks, SQL-vs-system CPU). The sibling Dashboard + /// splits these same columns across its "Corruption Events" and "Contention Events" sub-tabs; here + /// they stay one record (one SYSTEM snapshot) and the viewer picks the columns per chart. Every + /// counter is a raw <c>bigint</c> off the <c><system></c> node (no unit conversion), and the two + /// spinlock-type strings are kept verbatim, exactly as sp_HealthParser logs them. + /// </summary> + public sealed record SystemHealthRecord + { + /// <summary>Event <c>@timestamp</c>, parsed as UTC.</summary> + public DateTime? EventTime { get; init; } + + /// <summary>xpath <c>data[@name="state"]/text</c> — the component state text (CLEAN/WARNING/HAS_ISSUES).</summary> + public string? State { get; init; } + + /// <summary><c><system></c> attribute <c>@spinlockBackoffs</c> (bigint).</summary> + public long? SpinlockBackoffs { get; init; } + + /// <summary><c><system></c> attribute <c>@sickSpinlockType</c> (the sick spinlock's name, e.g. SOS_CACHESTORE).</summary> + public string? SickSpinlockType { get; init; } + + /// <summary><c><system></c> attribute <c>@sickSpinlockTypeAfterAv</c>.</summary> + public string? SickSpinlockTypeAfterAv { get; init; } + + /// <summary><c><system></c> attribute <c>@latchWarnings</c> (bigint).</summary> + public long? LatchWarnings { get; init; } + + /// <summary><c><system></c> attribute <c>@isAccessViolationOccurred</c> (bigint, as sp_HealthParser stores it).</summary> + public long? IsAccessViolationOccurred { get; init; } + + /// <summary><c><system></c> attribute <c>@writeAccessViolationCount</c> (bigint).</summary> + public long? WriteAccessViolationCount { get; init; } + + /// <summary><c><system></c> attribute <c>@totalDumpRequests</c> (bigint).</summary> + public long? TotalDumpRequests { get; init; } + + /// <summary><c><system></c> attribute <c>@intervalDumpRequests</c> (bigint).</summary> + public long? IntervalDumpRequests { get; init; } + + /// <summary><c><system></c> attribute <c>@nonYieldingTasksReported</c> (bigint).</summary> + public long? NonYieldingTasksReported { get; init; } + + /// <summary><c><system></c> attribute <c>@pageFaults</c> (bigint).</summary> + public long? PageFaults { get; init; } + + /// <summary><c><system></c> attribute <c>@systemCpuUtilization</c> (whole-percent bigint).</summary> + public long? SystemCpuUtilization { get; init; } + + /// <summary><c><system></c> attribute <c>@sqlCpuUtilization</c> (whole-percent bigint).</summary> + public long? SqlCpuUtilization { get; init; } + + /// <summary><c><system></c> attribute <c>@BadPagesDetected</c> (bigint).</summary> + public long? BadPagesDetected { get; init; } + + /// <summary><c><system></c> attribute <c>@BadPagesFixed</c> (bigint).</summary> + public long? BadPagesFixed { get; init; } + } + + /// <summary> + /// The result of shredding a batch of raw system_health events: one list per unique warning category. + /// Populated by <see cref="SystemHealthParser.ParseEvents"/>. Each event contributes to exactly one + /// category — at most one record for every category except <see cref="IoIssues"/> (an IO_SUBSYSTEM + /// event contributes one record per distinct pending-request file). An + /// <c>sp_server_diagnostics_component_result</c> event routes to the category matching its component + /// (SYSTEM → system health, RESOURCE → memory conditions, QUERY_PROCESSING → CPU tasks, IO_SUBSYSTEM → + /// IO issues); the other components contribute nothing. + /// </summary> + public sealed class SystemHealthParseResult + { + public List<SchedulerIssueRecord> SchedulerIssues { get; } = new(); + public List<SevereErrorRecord> SevereErrors { get; } = new(); + public List<MemoryConditionsRecord> MemoryConditions { get; } = new(); + public List<MemoryBrokerRecord> MemoryBroker { get; } = new(); + public List<MemoryNodeOomRecord> MemoryNodeOom { get; } = new(); + public List<SignificantWaitRecord> SignificantWaits { get; } = new(); + public List<CpuTasksRecord> CpuTasks { get; } = new(); + public List<IoIssuesRecord> IoIssues { get; } = new(); + public List<SystemHealthRecord> SystemHealth { get; } = new(); + } +} diff --git a/PerformanceMonitor.Common/SystemHealthParser.cs b/PerformanceMonitor.Common/SystemHealthParser.cs new file mode 100644 index 000000000..732ad266a --- /dev/null +++ b/PerformanceMonitor.Common/SystemHealthParser.cs @@ -0,0 +1,555 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Xml; +using System.Xml.Linq; + +namespace PerformanceMonitor.Common +{ + /// <summary> + /// Shreds the raw <c>system_health</c> Extended Event XML captured by + /// <c>SystemHealthEventsCollector</c> (Stage 1) into the eight unique warning-category record types — the + /// monitor-side C# port of Erik's <c>sp_HealthParser</c> per-category xpath extraction. Pure and + /// app-agnostic (Common), the same home and shape as <see cref="DeadlockGraphParser"/>: static, driven by + /// <see cref="System.Xml.Linq"/>, and robust to malformed input (a bad event yields null / no record + /// rather than throwing). + /// + /// <para>This is a <b>parse-on-read</b> shred: it reads stored raw event XML and produces typed records + /// with NO target proc, NO persisted category tables, and NO viewer coupling — so events can be + /// reprocessed without re-collecting. It faithfully extracts every column sp_HealthParser defines but + /// deliberately does NOT apply sp_HealthParser's warning/severity/duration/ignore-list WHERE predicates: + /// those are an analysis/display concern (Stage 2b) and every field needed to re-apply them is + /// preserved on the records.</para> + /// + /// <para>Dispatch is by XE event name (<c>event_type</c>): the ring-buffer / error / wait feeders each map + /// to exactly one category, while an <c>sp_server_diagnostics_component_result</c> event routes on its + /// component — SYSTEM → system health (corruption + contention counters), RESOURCE → memory conditions, + /// QUERY_PROCESSING → CPU tasks, IO_SUBSYSTEM → IO issues. Each component parser gates on the presence of + /// its own node (<c><system></c> / <c><resource></c> / <c><queryProcessing></c> / + /// <c><ioSubsystem></c>), so a single event yields a record for its component alone and nothing for + /// the others (sp_HealthParser's remaining EVENTS component is out of scope). IO issues is the one + /// many-per-event category: sp_HealthParser fans an IO_SUBSYSTEM event out to one row per pending-request + /// file, so its parser returns a list.</para> + /// </summary> + public static class SystemHealthParser + { + // XE event names — the Stage 1 capture filter and this dispatcher agree on exactly these six. + public const string SchedulerMonitorEvent = "scheduler_monitor_system_health_ring_buffer_recorded"; + public const string ErrorReportedEvent = "error_reported"; + public const string SpServerDiagnosticsEvent = "sp_server_diagnostics_component_result"; + public const string MemoryBrokerEvent = "memory_broker_ring_buffer_recorded"; + public const string MemoryNodeOomEvent = "memory_node_oom_ring_buffer_recorded"; + public const string WaitInfoEvent = "wait_info"; + + /// <summary> + /// Shreds a batch of captured events into the six per-category lists. <paramref name="events"/> is the + /// (<c>event_type</c>, <c>event_xml</c>) pair per stored <c>system_health_events</c> row; a null/blank + /// or unrecognized entry is skipped. Never throws. + /// </summary> + public static SystemHealthParseResult ParseEvents(IEnumerable<(string? EventType, string? EventXml)> events) + { + var result = new SystemHealthParseResult(); + if (events == null) + return result; + + foreach (var (eventType, eventXml) in events) + ParseInto(eventType, eventXml, result); + + return result; + } + + /// <summary> + /// Dispatches one event on its <paramref name="eventType"/> and appends the shredded record (if any) + /// to the matching list on <paramref name="result"/>. When <paramref name="eventType"/> is null/blank + /// it falls back to the event XML's own <c>@name</c> attribute. Never throws. + /// </summary> + public static void ParseInto(string? eventType, string? eventXml, SystemHealthParseResult result) + { + if (result == null || string.IsNullOrWhiteSpace(eventXml)) + return; + + var name = eventType; + if (string.IsNullOrWhiteSpace(name)) + name = EventRoot(eventXml)?.Attribute("name")?.Value; + if (string.IsNullOrWhiteSpace(name)) + return; + + switch (name) + { + case SchedulerMonitorEvent: + Add(result.SchedulerIssues, ParseSchedulerIssue(eventXml)); + break; + case ErrorReportedEvent: + Add(result.SevereErrors, ParseSevereError(eventXml)); + break; + case SpServerDiagnosticsEvent: + // One event carries one component; each parser is a no-op unless its component node is present. + Add(result.SystemHealth, ParseSystemHealth(eventXml)); + Add(result.MemoryConditions, ParseMemoryConditions(eventXml)); + Add(result.CpuTasks, ParseCpuTasks(eventXml)); + result.IoIssues.AddRange(ParseIoIssues(eventXml)); + break; + case MemoryBrokerEvent: + Add(result.MemoryBroker, ParseMemoryBroker(eventXml)); + break; + case MemoryNodeOomEvent: + Add(result.MemoryNodeOom, ParseMemoryNodeOom(eventXml)); + break; + case WaitInfoEvent: + Add(result.SignificantWaits, ParseSignificantWait(eventXml)); + break; + default: + break; // Not one of the six feeders — ignore (parity with the Stage 1 capture filter). + } + } + + private static void Add<T>(List<T> list, T? record) where T : class + { + if (record != null) + list.Add(record); + } + + // ── per-category shredders (each independently testable with one raw event XML) ── + + /// <summary> + /// Shreds a <c>scheduler_monitor_system_health_ring_buffer_recorded</c> event into a + /// <see cref="SchedulerIssueRecord"/>. Returns null for null/blank or unparseable XML. + /// </summary> + public static SchedulerIssueRecord? ParseSchedulerIssue(string? eventXml) + { + var ev = EventRoot(eventXml); + if (ev == null) + return null; + + return new SchedulerIssueRecord + { + EventTime = ParseTimestamp(ev), + SchedulerId = ParseInt(DataValue(ev, "scheduler_id")), + CpuId = ParseInt(DataValue(ev, "cpu_id")), + Status = DataText(ev, "status"), + IsOnline = ParseBool(DataValue(ev, "is_online")), + IsRunnable = ParseBool(DataValue(ev, "is_runnable")), + IsRunning = ParseBool(DataValue(ev, "is_running")), + NonYieldingTimeMs = ParseLong(DataValue(ev, "non_yielding_time")), + ThreadQuantumMs = ParseLong(DataValue(ev, "thread_quantum")), + }; + } + + /// <summary> + /// Shreds an <c>error_reported</c> event into a <see cref="SevereErrorRecord"/>. Returns null for + /// null/blank or unparseable XML. <see cref="SevereErrorRecord.DatabaseName"/> is intentionally left + /// null (server-side <c>DB_NAME(database_id)</c> resolution is not available to a DB-free shred). + /// </summary> + public static SevereErrorRecord? ParseSevereError(string? eventXml) + { + var ev = EventRoot(eventXml); + if (ev == null) + return null; + + return new SevereErrorRecord + { + EventTime = ParseTimestamp(ev), + ErrorNumber = ParseInt(DataValue(ev, "error_number")), + Severity = ParseInt(DataValue(ev, "severity")), + State = ParseInt(DataValue(ev, "state")), + Message = DataValue(ev, "message"), + DatabaseName = null, // sp_HealthParser: DB_NAME(database_id) — needs a live connection. + DatabaseId = ParseInt(DataValue(ev, "database_id")), + }; + } + + /// <summary> + /// Shreds the SYSTEM component of an <c>sp_server_diagnostics_component_result</c> event into a + /// <see cref="SystemHealthRecord"/> — the corruption + contention counter set carried on the + /// <c><system></c> node's attributes. Returns null for null/blank or unparseable XML, or when the + /// event carries no <c><system></c> node (i.e. it is a non-SYSTEM component). Every counter is a + /// raw bigint off the node; the two spinlock-type strings are kept verbatim (sp_HealthParser's + /// <c>*_SystemHealth</c> column set). + /// </summary> + public static SystemHealthRecord? ParseSystemHealth(string? eventXml) + { + var ev = EventRoot(eventXml); + var system = ev?.Descendants("system").FirstOrDefault(); + if (ev == null || system == null) + return null; + + return new SystemHealthRecord + { + EventTime = ParseTimestamp(ev), + State = DataText(ev, "state"), + SpinlockBackoffs = ParseLong((string?)system.Attribute("spinlockBackoffs")), + SickSpinlockType = (string?)system.Attribute("sickSpinlockType"), + SickSpinlockTypeAfterAv = (string?)system.Attribute("sickSpinlockTypeAfterAv"), + LatchWarnings = ParseLong((string?)system.Attribute("latchWarnings")), + IsAccessViolationOccurred = ParseLong((string?)system.Attribute("isAccessViolationOccurred")), + WriteAccessViolationCount = ParseLong((string?)system.Attribute("writeAccessViolationCount")), + TotalDumpRequests = ParseLong((string?)system.Attribute("totalDumpRequests")), + IntervalDumpRequests = ParseLong((string?)system.Attribute("intervalDumpRequests")), + NonYieldingTasksReported = ParseLong((string?)system.Attribute("nonYieldingTasksReported")), + PageFaults = ParseLong((string?)system.Attribute("pageFaults")), + SystemCpuUtilization = ParseLong((string?)system.Attribute("systemCpuUtilization")), + SqlCpuUtilization = ParseLong((string?)system.Attribute("sqlCpuUtilization")), + BadPagesDetected = ParseLong((string?)system.Attribute("BadPagesDetected")), + BadPagesFixed = ParseLong((string?)system.Attribute("BadPagesFixed")), + }; + } + + /// <summary> + /// Shreds the RESOURCE component of an <c>sp_server_diagnostics_component_result</c> event into a + /// <see cref="MemoryConditionsRecord"/>. Returns null for null/blank or unparseable XML, or when the + /// event carries no <c><resource></c> node (i.e. it is a non-RESOURCE component — no memory + /// conditions to report). + /// </summary> + public static MemoryConditionsRecord? ParseMemoryConditions(string? eventXml) + { + var ev = EventRoot(eventXml); + var resource = ev?.Descendants("resource").FirstOrDefault(); + if (ev == null || resource == null) + return null; + + return new MemoryConditionsRecord + { + EventTime = ParseTimestamp(ev), + LastNotification = (string?)resource.Attribute("lastNotification"), + OutOfMemoryExceptions = ParseLong((string?)resource.Attribute("outOfMemoryExceptions")), + IsAnyPoolOutOfMemory = ParseBool((string?)resource.Attribute("isAnyPoolOutOfMemory")), + ProcessOutOfMemoryPeriod = ParseLong((string?)resource.Attribute("processOutOfMemoryPeriod")), + Name = (string?)resource.Descendants("memoryReport").FirstOrDefault()?.Attribute("name"), + AvailablePhysicalMemoryGb = GbFromBytes(EntryValue(resource, "Available Physical Memory")), + AvailableVirtualMemoryGb = GbFromBytes(EntryValue(resource, "Available Virtual Memory")), + AvailablePagingFileGb = GbFromBytes(EntryValue(resource, "Available Paging File")), + WorkingSetGb = GbFromBytes(EntryValue(resource, "Working Set")), + PercentOfCommittedMemoryInWs = ParseLong(EntryValue(resource, "Percent of Committed Memory in WS")), + PageFaults = ParseLong(EntryValue(resource, "Page Faults")), + SystemPhysicalMemoryHigh = ParseLong(EntryValue(resource, "System physical memory high")), + SystemPhysicalMemoryLow = ParseLong(EntryValue(resource, "System physical memory low")), + ProcessPhysicalMemoryLow = ParseLong(EntryValue(resource, "Process physical memory low")), + ProcessVirtualMemoryLow = ParseLong(EntryValue(resource, "Process virtual memory low")), + VmReservedGb = GbFromKb(EntryValue(resource, "VM Reserved")), + VmCommittedGb = GbFromKb(EntryValue(resource, "VM Committed")), + LockedPagesAllocated = ParseLong(EntryValue(resource, "Locked Pages Allocated")), + LargePagesAllocated = ParseLong(EntryValue(resource, "Large Pages Allocated")), + EmergencyMemoryGb = GbFromKb(EntryValue(resource, "Emergency Memory")), + EmergencyMemoryInUseGb = GbFromKb(EntryValue(resource, "Emergency Memory In Use")), + TargetCommittedGb = GbFromKb(EntryValue(resource, "Target Committed")), + CurrentCommittedGb = GbFromKb(EntryValue(resource, "Current Committed")), + PagesAllocated = ParseLong(EntryValue(resource, "Pages Allocated")), + PagesReserved = ParseLong(EntryValue(resource, "Pages Reserved")), + PagesFree = ParseLong(EntryValue(resource, "Pages Free")), + PagesInUse = ParseLong(EntryValue(resource, "Pages In Use")), + PageAllocPotential = ParseLong(EntryValue(resource, "Page Alloc Potential")), + NumaGrowthPhase = ParseLong(EntryValue(resource, "NUMA Growth Phase")), + LastOomFactor = ParseLong(EntryValue(resource, "Last OOM Factor")), + LastOsError = ParseLong(EntryValue(resource, "Last OS Error")), + }; + } + + /// <summary> + /// Shreds the QUERY_PROCESSING component of an <c>sp_server_diagnostics_component_result</c> event into + /// a <see cref="CpuTasksRecord"/>. Returns null for null/blank or unparseable XML, or when the event + /// carries no <c><queryProcessing></c> node (i.e. it is a non-QUERY_PROCESSING component). + /// </summary> + public static CpuTasksRecord? ParseCpuTasks(string? eventXml) + { + var ev = EventRoot(eventXml); + var qp = ev?.Descendants("queryProcessing").FirstOrDefault(); + if (ev == null || qp == null) + return null; + + return new CpuTasksRecord + { + EventTime = ParseTimestamp(ev), + State = DataText(ev, "state"), + MaxWorkers = ParseLong((string?)qp.Attribute("maxWorkers")), + WorkersCreated = ParseLong((string?)qp.Attribute("workersCreated")), + WorkersIdle = ParseLong((string?)qp.Attribute("workersIdle")), + TasksCompletedWithinInterval = ParseLong((string?)qp.Attribute("tasksCompletedWithinInterval")), + PendingTasks = ParseLong((string?)qp.Attribute("pendingTasks")), + OldestPendingTaskWaitingTime = ParseLong((string?)qp.Attribute("oldestPendingTaskWaitingTime")), + HasUnresolvableDeadlockOccurred = ParseBool((string?)qp.Attribute("hasUnresolvableDeadlockOccurred")), + HasDeadlockedSchedulersOccurred = ParseBool((string?)qp.Attribute("hasDeadlockedSchedulersOccurred")), + // sp_HealthParser: .exist('.../blockingTasks/blocked-process-report') — a definite bit, never null. + DidBlockingOccur = qp.Descendants("blocked-process-report").Any(), + }; + } + + /// <summary> + /// Shreds the IO_SUBSYSTEM component of an <c>sp_server_diagnostics_component_result</c> event into + /// zero or more <see cref="IoIssuesRecord"/> — one per distinct pending-request file, with that file's + /// <c>@duration</c> values summed (sp_HealthParser's <c>#io</c> → <c>#i</c> per-event shred). Returns an + /// empty list for null/blank or unparseable XML, a non-IO_SUBSYSTEM component (no + /// <c><ioSubsystem></c> node), or an IO_SUBSYSTEM event with no pending request carrying a + /// duration (sp_HealthParser's <c>WHERE longestPendingRequests_duration_ms IS NOT NULL</c>). Never throws. + /// </summary> + public static List<IoIssuesRecord> ParseIoIssues(string? eventXml) + { + var records = new List<IoIssuesRecord>(); + + var ev = EventRoot(eventXml); + var io = ev?.Descendants("ioSubsystem").FirstOrDefault(); + if (ev == null || io == null) + return records; + + var eventTime = ParseTimestamp(ev); + var state = DataText(ev, "state"); + var ioLatchTimeouts = ParseLong((string?)io.Attribute("ioLatchTimeouts")); + var intervalLongIos = ParseLong((string?)io.Attribute("intervalLongIos")); + var totalLongIos = ParseLong((string?)io.Attribute("totalLongIos")); + + // One row per pendingRequest with a duration, grouped by filePath (ISNULL -> "N/A") with the + // durations summed — sp_HealthParser's WHERE duration IS NOT NULL + GROUP BY filePath, within one event. + var groups = io.Descendants("pendingRequest") + .Select(pr => (Duration: ParseLong((string?)pr.Attribute("duration")), FilePath: (string?)pr.Attribute("filePath"))) + .Where(pr => pr.Duration.HasValue) + .GroupBy(pr => pr.FilePath ?? "N/A", StringComparer.Ordinal); + + foreach (var group in groups) + { + records.Add(new IoIssuesRecord + { + EventTime = eventTime, + State = state, + IoLatchTimeouts = ioLatchTimeouts, + IntervalLongIos = intervalLongIos, + TotalLongIos = totalLongIos, + LongestPendingRequestsDurationMs = group.Sum(pr => pr.Duration!.Value), + LongestPendingRequestsFilePath = group.Key, + }); + } + + return records; + } + + /// <summary> + /// Shreds a <c>memory_broker_ring_buffer_recorded</c> event into a <see cref="MemoryBrokerRecord"/>. + /// Returns null for null/blank or unparseable XML. + /// </summary> + public static MemoryBrokerRecord? ParseMemoryBroker(string? eventXml) + { + var ev = EventRoot(eventXml); + if (ev == null) + return null; + + return new MemoryBrokerRecord + { + EventTime = ParseTimestamp(ev), + BrokerId = ParseLong(DataValue(ev, "id")), + PoolMetadataId = ParseLong(DataValue(ev, "pool_metadata_id")), + DeltaTime = ParseLong(DataValue(ev, "delta_time")), + MemoryRatio = ParseLong(DataValue(ev, "memory_ratio")), + NewTarget = ParseLong(DataValue(ev, "new_target")), + Overall = ParseLong(DataValue(ev, "overall")), + Rate = ParseLong(DataValue(ev, "rate")), + CurrentlyPredicated = ParseLong(DataValue(ev, "currently_predicated")), + CurrentlyAllocated = ParseLong(DataValue(ev, "currently_allocated")), + PreviouslyAllocated = ParseLong(DataValue(ev, "previously_allocated")), + Broker = DataValue(ev, "broker"), + Notification = DataValue(ev, "notification"), + }; + } + + /// <summary> + /// Shreds a <c>memory_node_oom_ring_buffer_recorded</c> event into a <see cref="MemoryNodeOomRecord"/>. + /// Returns null for null/blank or unparseable XML. + /// </summary> + public static MemoryNodeOomRecord? ParseMemoryNodeOom(string? eventXml) + { + var ev = EventRoot(eventXml); + if (ev == null) + return null; + + return new MemoryNodeOomRecord + { + EventTime = ParseTimestamp(ev), + NodeId = ParseLong(DataValue(ev, "id")), + MemoryNodeId = ParseLong(DataValue(ev, "memory_node_id")), + MemoryUtilizationPct = ParseLong(DataValue(ev, "memory_utilization_pct")), + TotalPhysicalMemoryKb = ParseLong(DataValue(ev, "total_physical_memory_kb")), + AvailablePhysicalMemoryKb = ParseLong(DataValue(ev, "available_physical_memory_kb")), + TotalPageFileKb = ParseLong(DataValue(ev, "total_page_file_kb")), + AvailablePageFileKb = ParseLong(DataValue(ev, "available_page_file_kb")), + TotalVirtualAddressSpaceKb = ParseLong(DataValue(ev, "total_virtual_address_space_kb")), + AvailableVirtualAddressSpaceKb = ParseLong(DataValue(ev, "available_virtual_address_space_kb")), + TargetKb = ParseLong(DataValue(ev, "target_kb")), + ReservedKb = ParseLong(DataValue(ev, "reserved_kb")), + CommittedKb = ParseLong(DataValue(ev, "committed_kb")), + SharedCommittedKb = ParseDecimal(DataValue(ev, "shared_committed_kb")), + AweKb = ParseLong(DataValue(ev, "awe_kb")), + PagesKb = ParseLong(DataValue(ev, "pages_kb")), + FailureType = DataText(ev, "failure"), + FailureValue = ParseLong(DataValue(ev, "failure")), + Resources = ParseLong(DataValue(ev, "resources")), + FactorText = DataText(ev, "factor"), + FactorValue = ParseLong(DataValue(ev, "factor")), + LastError = ParseLong(DataValue(ev, "last_error")), + PoolMetadataId = ParseLong(DataValue(ev, "pool_metadata_id")), + IsProcessInJob = DataValue(ev, "is_process_in_job"), + IsSystemPhysicalMemoryHigh = DataValue(ev, "is_system_physical_memory_high"), + IsSystemPhysicalMemoryLow = DataValue(ev, "is_system_physical_memory_low"), + IsProcessPhysicalMemoryLow = DataValue(ev, "is_process_physical_memory_low"), + IsProcessVirtualMemoryLow = DataValue(ev, "is_process_virtual_memory_low"), + }; + } + + /// <summary> + /// Shreds a <c>wait_info</c> event into a <see cref="SignificantWaitRecord"/>. Returns null for + /// null/blank or unparseable XML. + /// </summary> + public static SignificantWaitRecord? ParseSignificantWait(string? eventXml) + { + var ev = EventRoot(eventXml); + if (ev == null) + return null; + + return new SignificantWaitRecord + { + EventTime = ParseTimestamp(ev), + WaitType = DataText(ev, "wait_type"), + DurationMs = ParseLong(DataValue(ev, "duration")), + SignalDurationMs = ParseLong(DataValue(ev, "signal_duration")), + WaitResource = DataValue(ev, "wait_resource"), + QueryText = CleanSqlText(ActionValue(ev, "sql_text")), + SessionId = ParseInt(ActionValue(ev, "session_id")), + }; + } + + // ── XML navigation helpers ── + + /// <summary> + /// Parses <paramref name="xml"/> and returns its <c><event></c> element (the root itself when it + /// is the event, else the first event descendant). Null for null/blank or unparseable XML, mirroring + /// <see cref="DeadlockGraphParser"/>'s never-throw contract. + /// </summary> + private static XElement? EventRoot(string? xml) + { + if (string.IsNullOrWhiteSpace(xml)) + return null; + + XElement root; + try + { + // CheckCharacters=false: real wait_info sql_text carries control characters (the very reason + // sp_HealthParser strips them). SQL Server serializes those as -style references that a + // default (XML 1.0) reader rejects — so tolerate them here and let CleanSqlText do the strip, + // exactly as sp_HealthParser extracts-then-cleans. Ignorable whitespace stays significant so + // element text round-trips faithfully. + using var reader = XmlReader.Create( + new StringReader(xml.TrimStart()), + new XmlReaderSettings { CheckCharacters = false }); + root = XElement.Load(reader); + } + catch + { + return null; + } + + return root.Name.LocalName == "event" + ? root + : root.DescendantsAndSelf("event").FirstOrDefault(); + } + + /// <summary>The <c><value></c> text of the <c><data name="name"></c> child of the event.</summary> + private static string? DataValue(XElement ev, string name) => + DataNode(ev, name)?.Element("value")?.Value; + + /// <summary>The <c><text></c> text of the <c><data name="name"></c> child (friendly enum text).</summary> + private static string? DataText(XElement ev, string name) => + DataNode(ev, name)?.Element("text")?.Value; + + private static XElement? DataNode(XElement ev, string name) => + ev.Elements("data").FirstOrDefault(d => string.Equals((string?)d.Attribute("name"), name, StringComparison.Ordinal)); + + /// <summary>The <c><value></c> text of the <c><action name="name"></c> child of the event.</summary> + private static string? ActionValue(XElement ev, string name) => + ev.Elements("action") + .FirstOrDefault(a => string.Equals((string?)a.Attribute("name"), name, StringComparison.Ordinal)) + ?.Element("value")?.Value; + + /// <summary> + /// The <c>@value</c> of the first <c>memoryReport/entry</c> whose <c>@description</c> matches, searched + /// across all memoryReport groups under the resource — the C# form of sp_HealthParser's + /// <c>(//memoryReport/entry[@description[.="X"]]/@value)[1]</c>. + /// </summary> + private static string? EntryValue(XElement resource, string description) => + (string?)resource.Descendants("entry") + .FirstOrDefault(e => string.Equals((string?)e.Attribute("description"), description, StringComparison.Ordinal)) + ?.Attribute("value"); + + // ── value conversions (mirror the sp_HealthParser .value() cast types) ── + + private static DateTime? ParseTimestamp(XElement ev) + { + var ts = (string?)ev.Attribute("timestamp"); + if (string.IsNullOrWhiteSpace(ts)) + return null; + return DateTimeOffset.TryParse(ts, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var dto) + ? dto.UtcDateTime + : (DateTime?)null; + } + + private static int? ParseInt(string? s) => + int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : (int?)null; + + private static long? ParseLong(string? s) => + long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : (long?)null; + + private static decimal? ParseDecimal(string? s) => + decimal.TryParse(s, NumberStyles.Number, CultureInfo.InvariantCulture, out var v) ? v : (decimal?)null; + + /// <summary>SQL Server bit text: "1"/"true" -> true, "0"/"false" -> false, anything else -> null.</summary> + private static bool? ParseBool(string? s) + { + if (string.IsNullOrWhiteSpace(s)) + return null; + if (s == "1" || s.Equals("true", StringComparison.OrdinalIgnoreCase)) + return true; + if (s == "0" || s.Equals("false", StringComparison.OrdinalIgnoreCase)) + return false; + return null; + } + + /// <summary>Bytes -> GB by integer division (÷1024³), matching sp_HealthParser's bigint arithmetic (truncating).</summary> + private static long? GbFromBytes(string? s) => ParseLong(s) is { } v ? v / 1024 / 1024 / 1024 : (long?)null; + + /// <summary>KB -> GB by integer division (÷1024²), matching sp_HealthParser's bigint arithmetic (truncating).</summary> + private static long? GbFromKb(string? s) => ParseLong(s) is { } v ? v / 1024 / 1024 : (long?)null; + + /// <summary> + /// Replaces control characters — except tab (9), line feed (10) and carriage return (13) — with '?', + /// the exact set sp_HealthParser strips when building its query_text column. Null in, null out. + /// </summary> + private static string? CleanSqlText(string? sql) + { + if (sql == null) + return null; + + StringBuilder? sb = null; + for (int i = 0; i < sql.Length; i++) + { + var c = sql[i]; + if (c < 32 && c != '\t' && c != '\n' && c != '\r') + { + sb ??= new StringBuilder(sql, 0, i, sql.Length); + sb.Append('?'); + } + else + { + sb?.Append(c); + } + } + return sb?.ToString() ?? sql; + } + } +} diff --git a/PerformanceMonitor.Common/SystemHealthSignificance.cs b/PerformanceMonitor.Common/SystemHealthSignificance.cs new file mode 100644 index 000000000..90efa1298 --- /dev/null +++ b/PerformanceMonitor.Common/SystemHealthSignificance.cs @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; + +namespace PerformanceMonitor.Common; + +/// <summary> +/// The per-category "significant / warning" predicates the System Events surface applies AFTER +/// <see cref="SystemHealthParser"/> shreds the raw system_health events — sp_HealthParser's per-category WHERE +/// filtering, re-implemented ONCE in Common so every consumer shares it. Stage 2a (the parser) deliberately +/// preserved every field and applied NO WHERE filter; this class re-applies the significant-set gate so a tab +/// or tool shows the same SIGNIFICANT rows the sibling Dashboard surfaces. +/// +/// <para><b>One source, three consumers.</b> These predicates were previously hand-duplicated: the Darling +/// viewer's <c>SystemEventSignificance</c> and the headless MCP host's <c>DarlingSystemHealthSignificance</c> +/// each carried a separate copy, and they had ALREADY drifted — the service copy was missing the +/// significant-wait predicate (plus its wait-duration constant and idle-wait ignore list) entirely. They now +/// live here once; the Lite and Darling viewers and the Darling MCP host all call this class, so the +/// sp_HealthParser semantics can never diverge between them. (The Default Trace significant-set gate is the +/// sibling <see cref="DefaultTraceEventSignificance"/>, already shared the same way.)</para> +/// +/// <para><b>Parameter choice (load-bearing):</b> the predicates match <c>sp_HealthParser</c> run at the +/// product's canonical parameters — <c>@warnings_only = 1</c> and <c>@wait_duration_ms = 500</c>. Those are +/// exactly what the Dashboard's server-side collector passes +/// (<c>install/28_collect_system_health_wrapper.sql</c> defaults <c>@warnings_only = 1</c>; 500 ms is +/// sp_HealthParser's own default), so parse-on-read output matches the Dashboard's <c>report.HealthParser_*</c> +/// tables. Under <c>@warnings_only = 1</c> the gated categories tighten to the "warning" rows only (WARNING +/// scheduler status, RESOURCE_MEMPHYSICAL_LOW notifications, severity >= 19); memory-node-OOM is never +/// gated (every recorded OOM is significant), and the wait predicate is always-on regardless of warnings_only. +/// Each predicate cites the sp_HealthParser source line.</para> +/// +/// <para>Pure and dependency-free (over the Common records) so it is unit-testable without a live store.</para> +/// </summary> +public static class SystemHealthSignificance +{ + /// <summary> + /// sp_HealthParser severe-error cutoff under <c>@warnings_only = 1</c>: severity >= 19 + /// (sp_HealthParser.sql line 4759-4760 — the always-on floor is 16, and warnings_only raises it to 19). + /// </summary> + public const int SevereErrorMinSeverity = 19; + + /// <summary> + /// sp_HealthParser <c>@wait_duration_ms</c> default (500 ms) — the minimum <c>duration</c> for a + /// wait_info row to count as a significant wait (sp_HealthParser.sql line 1960). + /// </summary> + public const long SignificantWaitMinDurationMs = 500; + + /// <summary> + /// sp_HealthParser <c>@pending_task_threshold</c> default (10) — the minimum <c>pendingTasks</c> for a + /// QUERY_PROCESSING row to count as a significant CPU-task warning (sp_HealthParser.sql lines 54, 2981). + /// </summary> + public const long CpuTaskMinPendingTasks = 10; + + /// <summary>The scheduler <c>status</c> text sp_HealthParser keeps under warnings_only (line 4540).</summary> + public const string WarningStatus = "WARNING"; + + /// <summary> + /// The low-physical-memory notification sp_HealthParser keeps under warnings_only for the + /// MemoryConditions (line 3420) and MemoryBroker (line 3651) categories. + /// </summary> + public const string LowMemoryNotification = "RESOURCE_MEMPHYSICAL_LOW"; + + /// <summary> + /// error_reported numbers sp_HealthParser always excludes from severe errors — 17830 (a network + /// TDS/connection-reset error) and 18056 (the benign "connection was reset by the client" pool churn), + /// which are severity 20 but not actionable (sp_HealthParser.sql line 4733-4734). + /// </summary> + public static readonly IReadOnlySet<int> IgnoredSevereErrorNumbers = new HashSet<int> { 17830, 18056 }; + + /// <summary> + /// The "boring waits" ignore list sp_HealthParser excludes from significant waits — copied verbatim + /// from <c>#ignore_waits</c> (sp_HealthParser.sql line 1501-1520): idle/queue/background wait types + /// that a discrete long wait_info event on will never be actionable. OrdinalIgnoreCase because XE emits + /// these as the canonical upper-case wait-type enum text. + /// </summary> + public static readonly IReadOnlySet<string> IgnoredWaitTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) + { + "ASYNC_IO_COMPLETION", "AZURE_IMDS_VERSIONS", "BROKER_EVENTHANDLER", "BROKER_RECEIVE_WAITFOR", + "BROKER_TASK_STOP", "BROKER_TO_FLUSH", "BROKER_TRANSMITTER", "CHECKPOINT_QUEUE", + "CHKPT", "CLR_AUTO_EVENT", "CLR_MANUAL_EVENT", "CLR_SEMAPHORE", + "DBMIRROR_DBM_EVENT", "DBMIRROR_DBM_MUTEX", "DBMIRROR_EVENTS_QUEUE", "DBMIRROR_SEND", + "DBMIRROR_WORKER_QUEUE", "DBMIRRORING_CMD", "DIRTY_PAGE_POLL", "DISPATCHER_QUEUE_SEMAPHORE", + "FSAGENT", "FT_IFTS_SCHEDULER_IDLE_WAIT", "FT_IFTSHC_MUTEX", "HADR_CLUSAPI_CALL", + "HADR_FILESTREAM_IOMGR_IOCOMPLETION", "HADR_LOGCAPTURE_WAIT", "HADR_NOTIFICATION_DEQUEUE", "HADR_TIMER_TASK", + "HADR_WORK_QUEUE", "KSOURCE_WAKEUP", "LAZYWRITER_SLEEP", "LOGMGR_QUEUE", + "MEMORY_ALLOCATION_EXT", "ONDEMAND_TASK_QUEUE", "PARALLEL_REDO_DRAIN_WORKER", "PARALLEL_REDO_LOG_CACHE", + "PARALLEL_REDO_TRAN_LIST", "PARALLEL_REDO_WORKER_SYNC", "PARALLEL_REDO_WORKER_WAIT_WORK", "PREEMPTIVE_OS_FLUSHFILEBUFFERS", + "PREEMPTIVE_XE_GETTARGETSTATE", "PVS_PREALLOCATE", "PWAIT_ALL_COMPONENTS_INITIALIZED", "PWAIT_DIRECTLOGCONSUMER_GETNEXT", + "PWAIT_EXTENSIBILITY_CLEANUP_TASK", "QDS_ASYNC_QUEUE", "QDS_CLEANUP_STALE_QUERIES_TASK_MAIN_LOOP_SLEEP", "QDS_PERSIST_TASK_MAIN_LOOP_SLEEP", + "QDS_SHUTDOWN_QUEUE", "REDO_THREAD_PENDING_WORK", "REQUEST_FOR_DEADLOCK_SEARCH", "RESOURCE_QUEUE", + "SERVER_IDLE_CHECK", "SLEEP_DBSTARTUP", "SLEEP_DCOMSTARTUP", "SLEEP_MASTERDBREADY", + "SLEEP_MASTERMDREADY", "SLEEP_MASTERUPGRADED", "SLEEP_MSDBSTARTUP", "SLEEP_SYSTEMTASK", + "SLEEP_TEMPDBSTARTUP", "SNI_HTTP_ACCEPT", "SOS_WORK_DISPATCHER", "SP_SERVER_DIAGNOSTICS_SLEEP", + "SQLTRACE_BUFFER_FLUSH", "SQLTRACE_INCREMENTAL_FLUSH_SLEEP", "SQLTRACE_WAIT_ENTRIES", "UCS_SESSION_REGISTRATION", + "VDI_CLIENT_OTHER", "WAIT_FOR_RESULTS", "WAIT_XTP_CKPT_CLOSE", "WAIT_XTP_HOST_WAIT", + "WAIT_XTP_OFFLINE_CKPT_NEW_LOG", "WAIT_XTP_RECOVERY", "WAITFOR", "WAITFOR_TASKSHUTDOWN", + "XE_DISPATCHER_JOIN", "XE_DISPATCHER_WAIT", "XE_FILE_TARGET_TVF", "XE_LIVE_TARGET_TVF", "XE_TIMER_EVENT", + }; + + /// <summary> + /// A scheduler-monitor row is significant when its status is WARNING (a non-yielding or offline + /// scheduler), per sp_HealthParser.sql line 4540 under <c>@warnings_only = 1</c>. Routine heartbeat + /// rows (any other status) are dropped. + /// </summary> + public static bool IsSignificant(SchedulerIssueRecord record) => + string.Equals(record.Status, WarningStatus, StringComparison.Ordinal); + + /// <summary> + /// An error_reported row is significant when severity >= 19 and the error number is not one of the + /// benign connection-reset numbers, per sp_HealthParser.sql lines 4759-4767 under + /// <c>@warnings_only = 1</c>. A missing severity drops the row (sp requires the severity node); + /// a missing error number keeps it (it can't match the ignore list). + /// </summary> + public static bool IsSignificant(SevereErrorRecord record) => + record.Severity >= SevereErrorMinSeverity && + (record.ErrorNumber is not { } errorNumber || !IgnoredSevereErrorNumbers.Contains(errorNumber)); + + /// <summary> + /// A memory-conditions snapshot is significant when its <c>lastNotification</c> is + /// RESOURCE_MEMPHYSICAL_LOW (the server is under physical-memory pressure), per sp_HealthParser.sql + /// line 3420 under <c>@warnings_only = 1</c>. + /// </summary> + public static bool IsSignificant(MemoryConditionsRecord record) => + string.Equals(record.LastNotification, LowMemoryNotification, StringComparison.Ordinal); + + /// <summary> + /// A memory-broker ratio-change row is significant when its <c>notification</c> is + /// RESOURCE_MEMPHYSICAL_LOW, per sp_HealthParser.sql line 3651 under <c>@warnings_only = 1</c>. + /// </summary> + public static bool IsSignificant(MemoryBrokerRecord record) => + string.Equals(record.Notification, LowMemoryNotification, StringComparison.Ordinal); + + /// <summary> + /// Every recorded memory-node OOM is significant — sp_HealthParser applies NO WHERE filter to this + /// category (sp_HealthParser.sql line 3944-3946: the CROSS APPLY has no predicate), so the tab shows + /// every shredded row. Present for symmetry so the data-service read filters uniformly. + /// </summary> + public static bool IsSignificant(MemoryNodeOomRecord record) => true; + + /// <summary> + /// A wait_info row is a significant wait when it belongs to a real session, carries a non-BACKUP + /// statement, lasted at least <see cref="SignificantWaitMinDurationMs"/>, and its wait type is not on + /// the idle/background ignore list, per sp_HealthParser.sql lines 1957-1967. This predicate is + /// always-on (it is not gated by warnings_only). + /// </summary> + public static bool IsSignificant(SignificantWaitRecord record) => + record.SessionId != 0 && // session_id <> 0 (line 1957) + !string.IsNullOrEmpty(record.QueryText) && // sql_text present (line 1958) + record.QueryText.IndexOf("BACKUP", StringComparison.OrdinalIgnoreCase) < 0 && // not a BACKUP (line 1959) + record.DurationMs >= SignificantWaitMinDurationMs && // duration >= @wait_duration_ms (line 1960) + !IgnoredWaitTypes.Contains(record.WaitType ?? string.Empty); // wait_type NOT IN #ignore_waits (line 1961-1967) + + /// <summary> + /// A CPU-task-details row is significant when the QUERY_PROCESSING component state is WARNING and its + /// <c>pendingTasks</c> is at least <see cref="CpuTaskMinPendingTasks"/>, per sp_HealthParser.sql lines + /// 2976 and 2981 under <c>@warnings_only = 1</c>. A missing state or a pendingTasks below the threshold + /// (including null) drops the row, exactly as the sp's <c>state = "WARNING"</c> + + /// <c>@pendingTasks[.>= threshold]</c> exist-predicates do. + /// </summary> + public static bool IsSignificant(CpuTasksRecord record) => + string.Equals(record.State, WarningStatus, StringComparison.Ordinal) && + record.PendingTasks >= CpuTaskMinPendingTasks; + + /// <summary> + /// A potential-IO-issue row is significant when the IO_SUBSYSTEM component state is WARNING, per + /// sp_HealthParser.sql line 2757 under <c>@warnings_only = 1</c>. (The "has a pending request with a + /// duration" predicate — sp_HealthParser's <c>WHERE duration IS NOT NULL</c> — is applied upstream in + /// <see cref="SystemHealthParser.ParseIoIssues"/>, which emits no record for an event without one.) + /// </summary> + public static bool IsSignificant(IoIssuesRecord record) => + string.Equals(record.State, WarningStatus, StringComparison.Ordinal); +} diff --git a/PerformanceMonitor.Notifications/AlertSeverity.cs b/PerformanceMonitor.Notifications/AlertSeverity.cs index ffd028aa2..fde9cc738 100644 --- a/PerformanceMonitor.Notifications/AlertSeverity.cs +++ b/PerformanceMonitor.Notifications/AlertSeverity.cs @@ -52,7 +52,7 @@ public static (string HexColor, string BadgeText, string Emoji) ForMetric( "High CPU" => ("#F59E0B", "WARNING", "\U0001F7E1"), "Poison Wait" => ("#DC2626", "CRITICAL", "\U0001F534"), "Long-Running Query" => ("#D97706", "WARNING", "\U0001F7E0"), - "TempDB Space" => ("#D97706", "WARNING", "\U0001F7E0"), + "tempdb Space" => ("#D97706", "WARNING", "\U0001F7E0"), "Long-Running Job" => ("#D97706", "WARNING", "\U0001F7E0"), // Emailed/webhooked and fired as a Warning toast — was falling through to INFO-blue. "Failed Agent Job" => ("#D97706", "WARNING", "\U0001F7E0"), diff --git a/PerformanceMonitor.Notifications/AnalysisNotificationService.cs b/PerformanceMonitor.Notifications/AnalysisNotificationService.cs index e18c7ac63..b5b8ac39d 100644 --- a/PerformanceMonitor.Notifications/AnalysisNotificationService.cs +++ b/PerformanceMonitor.Notifications/AnalysisNotificationService.cs @@ -44,14 +44,31 @@ public sealed class AnalysisNotificationService private readonly Action<string, string>? _showTrayNotification; /// <summary> - /// Per-finding re-notification cooldown, keyed "{serverId}:{StoryPathHash}". - /// Seeded lazily from the alert log on first lookup per key so a finding - /// that just fired and entered its cooldown stays suppressed across an - /// app restart. Pruned on each notify cycle to entries within - /// 2 × AnalysisNotifyCooldownMinutes. + /// Per-symptom re-notification cooldown (escalate-on-CRITICAL keying). A below-critical finding is + /// keyed by its incident ("{serverId}:{IncidentId}", falling back to its StoryPathHash when it has + /// no incident id — legacy rows / absolution findings — so those never collapse into one shared + /// bucket), so co-fired non-critical symptoms stay one-e-mail-per-incident. A CRITICAL-band finding + /// (severity >= <see cref="CriticalSeverityCutoff"/>) is instead keyed by its OWN StoryPathHash, so + /// a new critical escalates past that dedup and is never silently held inside an already-alerted + /// incident. Seeded lazily from the alert log on first lookup per key so a symptom that just fired + /// and entered its cooldown stays suppressed across an app restart. Pruned on each notify cycle to + /// entries within 2 × AnalysisNotifyCooldownMinutes. /// </summary> private readonly ConcurrentDictionary<string, DateTime> _cooldowns = new(); + /// <summary> + /// Severity at/above which a finding is CRITICAL-band — the canonical <c>>= 1.5</c> cutoff every + /// recommendation reader bands CRITICAL by (Dashboard <c>RecommendationDeduper.FromEngineSeverity</c>, + /// Lite <c>LiteRecommendationsReader.SeverityBand</c>, and the Darling viewer's + /// <c>ViewerDataService.SeverityBand</c>). A finding at/above this escalates past the per-incident + /// e-mail dedup: it gets its own per-finding re-notify bucket, so a new critical joining an + /// already-alerted incident e-mails fresh and is never silently held. Below-critical co-fired + /// symptoms stay deduped under the incident bucket. Not a fresh literal — it keys off the same + /// value the shared severity bands use (there is no single named constant to import; the readers + /// each spell the cutoff inline, and this service references only PerformanceMonitor.Analysis). + /// </summary> + private const double CriticalSeverityCutoff = 1.5; + /// <param name="sender">The per-app alert dispatcher (its <c>EmailAlertService</c>).</param> /// <param name="settings">Alert settings (severity threshold + cooldown; clamped per app).</param> /// <param name="serverIdResolver"> @@ -90,8 +107,13 @@ public AnalysisNotificationService( } /// <summary> - /// Notifies on every finding at or above the configured severity that is not - /// inside its re-notification cooldown. Never throws. + /// Notifies at or above the configured severity, collapsing co-fired symptoms to one e-mail per + /// incident per cycle — EXCEPT a CRITICAL-band symptom (severity >= <see cref="CriticalSeverityCutoff"/>), + /// which holds its own re-notify bucket and so escalates past the dedup: a new critical joining an + /// already-alerted incident sends a fresh e-mail naming that critical symptom, while below-critical + /// co-fired symptoms stay held under the incident bucket. Each cycle sends one e-mail per incident, + /// led by the highest-severity member whose bucket is not cooling and naming the rest as co-fired. + /// Never throws. /// </summary> public async Task NotifyAsync(IReadOnlyList<AnalysisFinding> findings) { @@ -107,7 +129,7 @@ public async Task NotifyAsync(IReadOnlyList<AnalysisFinding> findings) /* Drop entries past 2× cooldown so the dict stays bounded — any entry past 1× is already re-fire-eligible, doubling gives clock-skew margin. - If a key here also matches a finding in this batch, the per-finding + If a key here also matches an incident in this batch, the per-incident seed below will re-add it from history; that's a wash, not a bug. */ var pruneBefore = now - TimeSpan.FromTicks(cooldown.Ticks * 2); foreach (var stale in _cooldowns) @@ -116,15 +138,29 @@ public async Task NotifyAsync(IReadOnlyList<AnalysisFinding> findings) _cooldowns.TryRemove(stale.Key, out _); } - foreach (var finding in findings) + /* Group the notify-worthy findings by incident so co-firing symptoms collapse into ONE e-mail + per cycle. The group token is the finding's IncidentId, falling back to its StoryPathHash + when empty — a legacy or absolution finding has no incident id, and without the fallback + every empty-id finding would collapse into a single bucket. ServerId is part of the key so + two servers never share a bucket. GroupBy preserves first-seen order, keeping alert ordering + stable. */ + var incidents = findings + .Where(f => f is not null && f.Severity >= threshold) + .GroupBy(f => $"{f.ServerId}:{IncidentToken(f)}"); + + foreach (var incident in incidents) { - if (finding.Severity < threshold) - continue; - - /* Cooldown key uses the finding's stable int id (matches both apps' prior - behaviour); the resolved serverId below is only the persistence shape. */ - var key = $"{finding.ServerId}:{finding.StoryPathHash}"; - var serverId = _resolveServerId(finding); + /* Members ordered so the highest-severity finding leads and the rest are named as co-fired. + Highest severity wins, root-key- then hash-tiebroken for a deterministic order (mirrors + IncidentId.Compute's severity-desc, ordinal-root-key tiebreak). */ + var members = incident + .OrderByDescending(f => f.Severity) + .ThenBy(f => f.RootFactKey, StringComparer.Ordinal) + .ThenBy(f => f.StoryPathHash, StringComparer.Ordinal) + .ToList(); + + /* ServerId is identical for every member (it is part of the group key); resolve once. */ + var serverId = _resolveServerId(members[0]); /* Honor per-server silencing (Dashboard "Silence All Alerts"). Checked after resolving serverId but before the cooldown seed/stamp, so a silenced server @@ -132,62 +168,123 @@ neither notifies nor consumes its cooldown — unsilencing resumes immediately. if (_isServerSilenced is not null && _isServerSilenced(serverId)) continue; - var metricName = FindingMessageFormatter.MetricName(finding); - - /* Seed the in-memory cooldown from the alert log on first lookup per key so an - analysis finding that fired shortly before an app restart is not re-fired - afterward. No channel/error filter — the cooldown is stamped unconditionally - below, so the persisted equivalent is the latest row for that metric_name. */ - if (!_cooldowns.ContainsKey(key)) + /* Per-member re-notification bucket (escalate-on-CRITICAL): a CRITICAL-band member + (severity >= the shared >= 1.5 cutoff) gets its OWN per-finding bucket keyed on its + StoryPathHash, so a NEW critical symptom escalates past the incident dedup and e-mails + even when the incident already alerted on another member. A below-critical member stays + on the shared per-incident bucket, so co-fired non-critical symptoms remain one-e-mail- + per-incident. */ + string BucketKey(AnalysisFinding f) => + f.Severity >= CriticalSeverityCutoff + ? $"{serverId}:{f.StoryPathHash}" + : $"{serverId}:{IncidentToken(f)}"; + + /* Seed each not-yet-known bucket from the alert log on first lookup so a symptom that + fired shortly before an app restart is not re-fired afterward. The persisted equivalent + is the latest row for that member's metric_name (which embeds the finding's hash). + Below-critical members share one bucket, so only the first (highest-severity) below- + critical member — the one that would lead a below-critical e-mail — is looked up. */ + foreach (var m in members) { - var lastPersisted = await _sender.GetLastAlertTimeAsync(serverId, metricName); + var seedKey = BucketKey(m); + if (_cooldowns.ContainsKey(seedKey)) + continue; + var lastPersisted = await _sender.GetLastAlertTimeAsync(serverId, FindingMessageFormatter.MetricName(m)); if (lastPersisted.HasValue) - { - _cooldowns.TryAdd(key, lastPersisted.Value); - } + _cooldowns.TryAdd(seedKey, lastPersisted.Value); } - if (_cooldowns.TryGetValue(key, out var last) && now - last < cooldown) + /* Lead = the highest-severity member whose bucket is not cooling; the whole incident is + held only when EVERY member's bucket is cooling (send-if-any-fresh, the same rule the + live IncidentCooldown path uses). So a fresh critical leads its own e-mail even when the + incident's top finding is still cooling. */ + AnalysisFinding? lead = null; + foreach (var m in members) + { + if (_cooldowns.TryGetValue(BucketKey(m), out var last) && now - last < cooldown) + continue; + lead = m; + break; + } + if (lead is null) continue; try { + var context = FindingMessageFormatter.BuildContext(lead, threshold); + + /* Name the OTHER findings that co-fired in THIS incident, in the one message, so the + single alert still accounts for everything the incident surfaced. Reuses the shared + CoFiredSummary the viewer/MCP surfaces use, but with the INCIDENT-scoped lead-in so the + body matches the "Co-fired in this incident" heading (the default window wording would + be inaccurate here — these siblings are incident-scoped, not the whole run). Null (and + so no item) for a lone finding, leaving the message byte-identical to the pre-dedup + single-finding path. */ + var coFired = CoFiredSummary.Line( + CoFiredSummary.OtherTitles(FindingTitle(lead), + members.Select(m => (FindingTitle(m), m.Severity))), + leadIn: CoFiredSummary.IncidentLeadIn); + if (coFired is not null) + context.Details.Add(new AlertDetailItem { Heading = "Co-fired in this incident", Body = coFired }); + /* SendFindingAlertAsync fans out to email + Slack + Teams and records the alert per this app's cadence. It returns no success/failure signal, so the - cooldown is stamped regardless — a finding whose delivery failed is + buckets are stamped regardless — a symptom whose delivery failed is suppressed for the full cooldown (accepted best-effort behavior). */ await _sender.SendFindingAlertAsync(new FindingAlert( - metricName, - finding.ServerName, - FindingMessageFormatter.CurrentValue(finding), + FindingMessageFormatter.MetricName(lead), + lead.ServerName, + FindingMessageFormatter.CurrentValue(lead), threshold.ToString("F1"), serverId, - FindingMessageFormatter.BuildContext(finding, threshold), - finding.Severity, + context, + lead.Severity, threshold, - FindingMessageFormatter.DetailText(finding, threshold))); + FindingMessageFormatter.DetailText(lead, threshold))); - /* Always raise the tray balloon for a notify-worthy finding (user choice), the + /* Always raise the tray balloon for a notify-worthy incident (user choice), the same visible signal threshold alerts already pop — so a local-only user with no email/webhook still sees it. No-op when the host wired no sink (Lite) or tray notifications are disabled (the sink checks the pref). */ if (_showTrayNotification is not null) { - var (title, message) = FindingMessageFormatter.BalloonText(finding); + var (title, message) = FindingMessageFormatter.BalloonText(lead); _showTrayNotification(title, message); } - _cooldowns[key] = now; + /* Stamp EVERY member's bucket (send-if-any-fresh, stamp-all): the e-mail named every + member, so none should re-fire until its cooldown elapses — the just-led critical on + its hash bucket, each co-fired critical on its own, and every below-critical member on + the shared incident bucket. */ + foreach (var m in members) + _cooldowns[BucketKey(m)] = now; } catch (Exception ex) { /* SendFindingAlertAsync is documented never to throw; this guards a - formatter defect so one bad finding cannot abort the rest. */ + formatter defect so one bad incident cannot abort the rest. */ _logger.LogError( - $"AnalysisNotificationService: failed to notify on finding {finding.StoryPathHash}: {ex.GetType().Name}: {ex.Message}"); + $"AnalysisNotificationService: failed to notify on incident {incident.Key}: {ex.GetType().Name}: {ex.Message}"); } } } + + /// <summary> + /// The batch/cooldown grouping token for a finding: its incident id, or its StoryPathHash when the + /// incident id is empty (legacy / absolution findings) so those stay one-bucket-per-finding rather + /// than collapsing together. + /// </summary> + private static string IncidentToken(AnalysisFinding f) => + string.IsNullOrEmpty(f.IncidentId) ? f.StoryPathHash : f.IncidentId; + + /// <summary> + /// A finding's human-readable title for the co-fired cross-reference — its advice headline when the + /// shared library has one, else its root fact key (finally its category), matching the title the + /// viewer/MCP co-fired surfaces use. + /// </summary> + private static string FindingTitle(AnalysisFinding f) => + FactAdvice.GetForFinding(f)?.Headline + ?? (string.IsNullOrEmpty(f.RootFactKey) ? f.Category : f.RootFactKey); } /// <summary> diff --git a/PerformanceMonitor.Notifications/IAlertSettings.cs b/PerformanceMonitor.Notifications/IAlertSettings.cs index f1931a62d..da9fdeced 100644 --- a/PerformanceMonitor.Notifications/IAlertSettings.cs +++ b/PerformanceMonitor.Notifications/IAlertSettings.cs @@ -43,6 +43,31 @@ public interface IAlertSettings string SlackWebhookUrl { get; } string SlackProxyAddress { get; } + /* Generic webhook (#1506): POSTs an operator-authored JSON body to any endpoint, so an alert can + drive a system we ship no adapter for — PagerDuty, Opsgenie, n8n, or a GitHub repository_dispatch + that re-runs a workflow. Deliberately the answer to "run a script/exe on alert": it covers the + same automation need with no process-execution surface in a signed binary. */ + bool GenericWebhookEnabled { get; } + string GenericWebhookUrl { get; } + + /// <summary> + /// A JSON object of request headers, e.g. <c>{"Authorization":"Bearer ghp_...","Accept":"application/vnd.github+json"}</c>. + /// Carries bearer tokens, so every app stores this alongside the URL as a SECRET (Lite/Dashboard: + /// Credential Manager; Darling: a column-REVOKEd control-plane column). Malformed JSON fails the + /// send with a logged error rather than throwing into the alert loop. + /// </summary> + string GenericWebhookHeadersJson { get; } + + /// <summary> + /// The JSON request body, with <c>{{metric}}</c>, <c>{{server}}</c>, <c>{{value}}</c>, + /// <c>{{threshold}}</c>, <c>{{severity}}</c>, <c>{{context}}</c> and <c>{{timestamp}}</c> + /// placeholders substituted per alert. Empty falls back to + /// <see cref="WebhookAlertService.DefaultGenericBodyTemplate"/>. + /// </summary> + string GenericWebhookBodyTemplate { get; } + + string GenericWebhookProxyAddress { get; } + /* Scheduled-analysis notifications */ double AnalysisNotifySeverity { get; } int AnalysisNotifyCooldownMinutes { get; } diff --git a/PerformanceMonitor.Notifications/WebhookAlertService.cs b/PerformanceMonitor.Notifications/WebhookAlertService.cs index 5e74123f1..f70af624f 100644 --- a/PerformanceMonitor.Notifications/WebhookAlertService.cs +++ b/PerformanceMonitor.Notifications/WebhookAlertService.cs @@ -9,6 +9,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Globalization; using System.Net; using System.Net.Http; using System.Text; @@ -19,7 +20,7 @@ namespace PerformanceMonitor.Notifications; /// <summary> -/// Sends alert notifications to Microsoft Teams and/or Slack via incoming webhooks. +/// Sends alert notifications to Microsoft Teams, Slack, and/or a generic JSON endpoint via webhooks. /// Color-coded accent bars match the existing email alert severity mapping. /// <para> /// Shared between Lite and Dashboard (Plan E E3c). Per-app branding (edition label + @@ -29,6 +30,12 @@ namespace PerformanceMonitor.Notifications; /// MCP/health surfaces stay app-side: Dashboard keeps a reference to the injected /// instance. /// </para> +/// <para> +/// The generic channel (#1506) is the deliberate answer to "run a script/exe when an alert fires": +/// it drives arbitrary automation (PagerDuty, Opsgenie, n8n, a GitHub <c>repository_dispatch</c> that +/// re-runs a workflow) through an operator-authored JSON body + headers, with no process-execution +/// surface in a signed binary. +/// </para> /// </summary> public class WebhookAlertService { @@ -37,6 +44,28 @@ at the email / in-app dialog instead. */ private const string TsqlWebhookHint = "See email or in-app Alert Details for the copy-paste T-SQL."; private static readonly JsonSerializerOptions s_jsonOptions = new() { PropertyNamingPolicy = null }; + /// <summary> + /// The generic channel's body template when the operator has not authored one — a flat JSON object + /// exercising every placeholder, which most endpoints (n8n, Zapier, a bare HTTP listener) accept as-is. + /// </summary> + public const string DefaultGenericBodyTemplate = + """ + { + "severity": "{{severity}}", + "metric": "{{metric}}", + "server": "{{server}}", + "value": "{{value}}", + "threshold": "{{threshold}}", + "context": "{{context}}", + "timestamp": "{{timestamp}}" + } + """; + + /* GitHub's REST API 403s any request without a User-Agent, and repository_dispatch is the motivating + use case (#1506), so the generic channel defaults one instead of handing the operator a 403 they + have to diagnose. An explicit User-Agent in the headers JSON overrides it. */ + private const string DefaultUserAgent = "PerformanceMonitor"; + /* #1154: per-incident-fingerprint cooldown (was a per-(serverId, metricName) ConcurrentDictionary). Keyed per #1140 dedup fingerprint so a distinct incident in the window is delivered; falls back to the metric-level key when an alert carries no @@ -50,6 +79,8 @@ fingerprintable incident. */ private string? _lastTeamsError; private int _consecutiveSlackFailures; private string? _lastSlackError; + private int _consecutiveGenericFailures; + private string? _lastGenericError; /// <param name="historyStore"> /// Optional alert-history store used to seed the per-fingerprint webhook cooldown across an app @@ -114,6 +145,11 @@ the alert log on first touch per key (#1145), unless the store is null (no seedi sent |= await TrySendSlackAlertAsync(metricName, serverName, currentValue, thresholdValue, context); } + if (_settings.GenericWebhookEnabled && !string.IsNullOrWhiteSpace(_settings.GenericWebhookUrl)) + { + sent |= await TrySendGenericAlertAsync(metricName, serverName, currentValue, thresholdValue, context); + } + if (sent) { _cooldown.Stamp(decision); @@ -166,12 +202,48 @@ the alert log on first touch per key (#1145), unless the store is null (no seedi } } + /// <summary> + /// Sends a test notification to the generic endpoint. Returns null on success, error message on failure. + /// Surfaces a bad template / bad headers JSON as that error message, so the operator sees the problem in + /// the settings window instead of only in a log after the next real alert. + /// </summary> + public static async Task<string?> SendTestGenericAsync( + string webhookUrl, + string? headersJson, + string? bodyTemplate, + string? proxyAddress, + AlertBranding branding) + { + try + { + if (string.IsNullOrWhiteSpace(webhookUrl)) + return "Webhook URL is not configured."; + + if (!TryParseHeaders(headersJson, out var headers, out var headerError)) + return headerError; + + var payload = BuildGenericPayload("Test Notification", "", "Webhook configuration verified", "", branding, isTest: true, bodyTemplate: bodyTemplate); + + if (!IsWellFormedJson(payload, out var bodyError)) + return bodyError; + + return await PostWebhookAsync(webhookUrl, payload, proxyAddress, headers); + } + catch (Exception ex) + { + return ex.Message; + } + } + public (int ConsecutiveFailures, string? LastError) GetTeamsHealth() => (_consecutiveTeamsFailures, _lastTeamsError); public (int ConsecutiveFailures, string? LastError) GetSlackHealth() => (_consecutiveSlackFailures, _lastSlackError); + public (int ConsecutiveFailures, string? LastError) GetGenericHealth() => + (_consecutiveGenericFailures, _lastGenericError); + #region Teams private async Task<bool> TrySendTeamsAlertAsync( @@ -472,6 +544,336 @@ internal static string BuildSlackPayload( #endregion + #region Generic + + private async Task<bool> TrySendGenericAlertAsync( + string metricName, + string serverName, + string currentValue, + string thresholdValue, + AlertContext? context) + { + try + { + /* A malformed headers JSON / body template is an operator config error, not a transport + failure — but it still counts as a failure so the health surface and the log throttle + report a channel that is delivering nothing, and it must never throw into the alert loop. */ + if (!TryParseHeaders(_settings.GenericWebhookHeadersJson, out var headers, out var headerError)) + { + RecordGenericFailure(headerError!); + return false; + } + + var payload = BuildGenericPayload( + metricName, serverName, currentValue, thresholdValue, _branding, + context: context, bodyTemplate: _settings.GenericWebhookBodyTemplate); + + if (!IsWellFormedJson(payload, out var bodyError)) + { + RecordGenericFailure(bodyError!); + return false; + } + + var error = await PostWebhookAsync( + _settings.GenericWebhookUrl, payload, _settings.GenericWebhookProxyAddress, headers); + + if (error != null) + { + RecordGenericFailure(error); + return false; + } + + if (_consecutiveGenericFailures > 0) + _logger.LogInformation($"Generic webhook recovered after {_consecutiveGenericFailures} failure(s)"); + + _consecutiveGenericFailures = 0; + _lastGenericError = null; + _logger.LogInformation($"Generic webhook sent for {metricName} on {serverName}"); + return true; + } + catch (Exception ex) + { + _consecutiveGenericFailures++; + _lastGenericError = ex.Message; + _logger.LogError($"Generic webhook error: {ex.Message}"); + return false; + } + } + + /* The Teams/Slack log-throttle shape (loud for the first 3, then every 50th) — factored out only + because the generic channel fails from three places (headers, body, transport). */ + private void RecordGenericFailure(string error) + { + _consecutiveGenericFailures++; + _lastGenericError = error; + + if (_consecutiveGenericFailures <= 3) + _logger.LogError($"GENERIC WEBHOOK FAILED ({_consecutiveGenericFailures}x): {error}"); + else if (_consecutiveGenericFailures % 50 == 0) + _logger.LogError($"GENERIC WEBHOOK STILL FAILING: {_consecutiveGenericFailures} failures. Last: {error}"); + } + + /// <summary> + /// Substitutes the alert's values into the operator's JSON body template. Every placeholder expands to + /// JSON-ESCAPED text: the tokens sit inside JSON string literals in the template + /// (<c>"server": "{{server}}"</c>), so a server name, wait type, or error message containing a quote or + /// backslash would otherwise terminate the literal early and corrupt — or inject into — the posted JSON. + /// The escaping goes through <see cref="JsonSerializer"/> (see <see cref="EscapeForJson"/>) — NOT + /// <c>JsonEncodedText.Encode</c>, which throws on the lone surrogates SQL Server names can carry — and the + /// two surrounding quotes are stripped to leave exactly the escaped INNER text a token inside a literal needs. + /// </summary> + internal static string BuildGenericPayload( + string metricName, + string serverName, + string currentValue, + string thresholdValue, + AlertBranding branding, + bool isTest = false, + AlertContext? context = null, + string? bodyTemplate = null) + { + var (_, badgeText, _) = AlertSeverity.ForMetric(metricName, context?.SeverityOverride); + var template = string.IsNullOrWhiteSpace(bodyTemplate) ? DefaultGenericBodyTemplate : bodyTemplate!; + + var contextText = isTest + ? $"Webhook configuration is working correctly. Sent by {branding.EditionName}." + : RenderContextForTemplate(context, branding); + + var values = new Dictionary<string, string>(StringComparer.Ordinal) + { + ["metric"] = EscapeForJson(isTest ? $"TEST — {metricName}" : metricName), + ["server"] = EscapeForJson(serverName), + ["value"] = EscapeForJson(currentValue), + ["threshold"] = EscapeForJson(thresholdValue), + ["severity"] = EscapeForJson(isTest ? "TEST" : badgeText), + ["context"] = EscapeForJson(contextText), + ["timestamp"] = EscapeForJson(DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)), + }; + + /* Single pass: a MatchEvaluator's output is NOT re-scanned, so a value that itself contains the + literal text of another token (e.g. a server name "{{timestamp}}") is left verbatim rather than + re-expanded — which chained .Replace() calls would do, letting one field pull another's contents + into itself. An unknown "{{foo}}" isn't in the alternation, so it stays literal (today's behavior). */ + return s_genericPlaceholders.Replace(template, m => values[m.Groups[1].Value]); + } + + private static readonly System.Text.RegularExpressions.Regex s_genericPlaceholders = + new(@"\{\{(metric|server|value|threshold|severity|context|timestamp)\}\}", + System.Text.RegularExpressions.RegexOptions.Compiled); + + /// <summary> + /// Flattens the structured alert context into one plain-text line for <c>{{context}}</c> — the generic + /// endpoint has no card schema to render into. Follows the Teams/Slack rule: remediation T-SQL is never + /// inlined, it points at the email / in-app dialog. + /// </summary> + private static string RenderContextForTemplate(AlertContext? context, AlertBranding branding) + { + if (context?.Details is not { Count: > 0 }) + { + return $"Sent by {branding.EditionName}"; + } + + var parts = new List<string>(); + + foreach (var detail in context.Details) + { + if (detail.IsCodeBlock) + { + parts.Add($"{detail.Heading}: {TsqlWebhookHint}"); + continue; + } + + if (!string.IsNullOrEmpty(detail.Body)) + { + parts.Add($"{detail.Heading}: {detail.Body.Replace("\n\n", " ", StringComparison.Ordinal).Replace("\n", " ", StringComparison.Ordinal)}"); + continue; + } + + foreach (var (label, value) in detail.Fields) + { + parts.Add($"{detail.Heading} — {label}: {value}"); + } + } + + return parts.Count == 0 ? $"Sent by {branding.EditionName}" : string.Join(" | ", parts); + } + + /// <summary> + /// Escapes a value for interpolation INSIDE a JSON string literal — the escaped inner text, without the + /// surrounding quotes. HTML-sensitive and non-ASCII characters escape to <c>\uXXXX</c>; over-escaping is + /// still valid JSON and keeps the payload ASCII-safe. + /// <para> + /// Uses <see cref="JsonSerializer"/> rather than <see cref="JsonEncodedText.Encode(string)"/> deliberately: + /// SQL Server nvarchar/sysname can hold a LONE SURROGATE (e.g. an object or login name containing + /// <c>NCHAR(0xD800)</c>, or a RAISERROR message), and <c>JsonEncodedText.Encode</c> THROWS + /// <see cref="ArgumentException"/> on invalid UTF-16 — which would let a low-privileged user drop the + /// generic webhook for the very incident they cause while email/Teams/Slack (which use + /// <c>JsonSerializer</c>) still deliver. The serializer relaxes invalid UTF-16 to U+FFFD and never throws, + /// matching the sibling channels. The result is a quoted JSON string; strip the two surrounding quotes to + /// get the inner text a token inside a literal needs. + /// </para> + /// </summary> + private static string EscapeForJson(string? value) + { + var quoted = JsonSerializer.Serialize(value ?? ""); + return quoted.Substring(1, quoted.Length - 2); + } + + /// <summary> + /// Validates the generic channel's headers JSON + body template without sending anything — for the + /// settings windows' Save path, so a typo is caught where the operator can fix it rather than silently + /// dropping every future alert. Returns null when the config is usable, else the error to show. + /// Both inputs are optional: empty headers mean "no custom headers", an empty template means + /// <see cref="DefaultGenericBodyTemplate"/>. + /// </summary> + public static string? ValidateGenericConfig(string? headersJson, string? bodyTemplate) + { + if (!TryParseHeaders(headersJson, out _, out var headerError)) + { + return headerError; + } + + /* Render with placeholder-shaped stand-ins: substitution is what can break the JSON, so validating + the raw template would miss a token sitting outside a string literal. */ + var rendered = BuildGenericPayload( + "Test Notification", "Test Server", "0", "0", + new AlertBranding("Performance Monitor", null), isTest: true, bodyTemplate: bodyTemplate); + + return IsWellFormedJson(rendered, out var bodyError) ? null : bodyError; + } + + /// <summary> + /// True when the text is the built-in default body template (newline-insensitive), or empty. The settings + /// windows pre-fill the body box with <see cref="DefaultGenericBodyTemplate"/> when nothing is stored, so + /// Save uses this to persist the empty "use the default" sentinel instead of freezing a copy of today's + /// default — which would otherwise lock that operator out of future default improvements. + /// </summary> + public static bool IsDefaultBodyTemplate(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return true; + } + + static string Normalize(string s) => s.Replace("\r\n", "\n", StringComparison.Ordinal).Replace("\r", "\n", StringComparison.Ordinal).Trim(); + return string.Equals(Normalize(text), Normalize(DefaultGenericBodyTemplate), StringComparison.Ordinal); + } + + /// <summary> + /// True when the generic webhook would send credentials in the clear: an <c>http://</c> (not https) URL + /// carrying at least one header — the headers hold the <c>Authorization</c> bearer token, which a plaintext + /// POST exposes to anyone on the path. The settings windows use this to prompt a non-blocking confirm at + /// Save/Test (it is NOT blocked: a plaintext POST to a trusted LAN listener is legitimate). Returns false + /// for https, for a headerless http URL, and for a URL that does not parse (the send surfaces its own error). + /// </summary> + public static bool IsCleartextHttpWithHeaders(string? url, string? headersJson) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) + || !string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + /* Only warn when a header is actually present; a malformed headers JSON is caught by its own + validation path, so treat "doesn't parse" as "no header to expose here". */ + return TryParseHeaders(headersJson, out var headers, out _) && headers.Count > 0; + } + + /// <summary> + /// Parses the operator's headers JSON object into request headers. An empty/whitespace value is valid + /// (no custom headers). Malformed JSON, a non-object root, or a non-string value returns false with a + /// clear message the caller reports — the alert loop never sees an exception. + /// </summary> + internal static bool TryParseHeaders( + string? headersJson, + out Dictionary<string, string> headers, + out string? error) + { + headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + error = null; + + if (string.IsNullOrWhiteSpace(headersJson)) + { + return true; + } + + try + { + using var document = JsonDocument.Parse(headersJson); + + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + error = "Webhook headers must be a JSON object, e.g. {\"Authorization\": \"Bearer <token>\"}."; + return false; + } + + foreach (var property in document.RootElement.EnumerateObject()) + { + if (property.Value.ValueKind != JsonValueKind.String) + { + error = $"Webhook header '{property.Name}' must have a string value."; + return false; + } + + var value = property.Value.GetString() ?? ""; + + /* Reject CR/LF in the name or value. HttpRequestHeaders.TryAddWithoutValidation lets a CR/LF + in a VALUE through onto the wire, splitting one header into two — so a bearer token pasted + with a stray newline would silently smuggle a garbage header. Caught here (which runs at + Save/Test via ValidateGenericConfig) the operator sees it immediately. */ + if (HasControlChar(property.Name) || HasControlChar(value)) + { + error = $"Webhook header '{property.Name}' may not contain control characters (e.g. CR or LF)."; + return false; + } + + headers[property.Name] = value; + } + + return true; + } + catch (JsonException ex) + { + error = $"Webhook headers are not valid JSON: {ex.Message}"; + return false; + } + } + + /// <summary>True when the string contains any C0/C1 control character (CR, LF, tab, NUL, …).</summary> + private static bool HasControlChar(string s) + { + foreach (var c in s) + { + if (char.IsControl(c)) + { + return true; + } + } + return false; + } + + /// <summary> + /// Validates the substituted body before it goes on the wire, so a typo in the operator's template is + /// reported as a config error (in the settings test, or the channel's health) rather than as an opaque + /// 400 from the endpoint. + /// </summary> + private static bool IsWellFormedJson(string payload, out string? error) + { + try + { + using var _ = JsonDocument.Parse(payload); + error = null; + return true; + } + catch (JsonException ex) + { + error = $"Webhook body template did not produce valid JSON: {ex.Message}"; + return false; + } + } + + #endregion + #region Shared /// <summary> @@ -519,19 +921,65 @@ private static HttpClient GetHttpClient(string? proxyAddress) /// <summary> /// Posts a JSON payload to a webhook URL. Returns null on success, error message on failure. /// </summary> - private static async Task<string?> PostWebhookAsync(string webhookUrl, string jsonPayload, string? proxyAddress) + /// <param name="headers"> + /// The generic channel's operator-authored request headers. <c>null</c> — what Teams/Slack pass — sends + /// exactly today's request (no custom headers, no User-Agent); those two endpoints need neither. + /// </param> + private static async Task<string?> PostWebhookAsync( + string webhookUrl, + string jsonPayload, + string? proxyAddress, + IReadOnlyDictionary<string, string>? headers = null) { var client = GetHttpClient(proxyAddress); using var content = new StringContent(jsonPayload, Encoding.UTF8, "application/json"); + using var request = new HttpRequestMessage(HttpMethod.Post, webhookUrl) { Content = content }; + + if (headers != null) + { + ApplyHeaders(request, content, headers); + } - var response = await client.PostAsync(webhookUrl, content); + using var response = await client.SendAsync(request); if (response.IsSuccessStatusCode) return null; + /* Cap the destination's error body: it goes into the log + the health getter, and an unbounded read + lets a hostile/misconfigured endpoint bloat both (and, if it echoes request headers, spill more of + them). The first 2 KB is plenty to diagnose a 4xx/5xx. */ var body = await response.Content.ReadAsStringAsync(); + if (body.Length > 2048) + body = string.Concat(body.AsSpan(0, 2048), "…(truncated)"); + return $"HTTP {(int)response.StatusCode}: {body}"; } + /// <summary> + /// Applies the operator's headers to the request, routing content headers (a Content-Type override) onto + /// the content — setting one on <see cref="HttpRequestMessage.Headers"/> is rejected, so a naive add would + /// silently drop it. Values are added without validation: they are the operator's own, and a strict parse + /// would reject perfectly good tokens (CR/LF is already rejected upstream in <see cref="TryParseHeaders"/>). + /// </summary> + internal static void ApplyHeaders(HttpRequestMessage request, HttpContent content, IReadOnlyDictionary<string, string> headers) + { + foreach (var (name, value) in headers) + { + if (request.Headers.TryAddWithoutValidation(name, value)) + { + continue; + } + + /* A content header (e.g. Content-Type) — StringContent already set one, so replace it. */ + content.Headers.Remove(name); + content.Headers.TryAddWithoutValidation(name, value); + } + + if (!request.Headers.Contains("User-Agent")) + { + request.Headers.TryAddWithoutValidation("User-Agent", DefaultUserAgent); + } + } + #endregion } diff --git a/Lite/Services/ActualPlanExecutor.cs b/PerformanceMonitor.PlanAnalysis/ActualPlanExecutor.cs similarity index 52% rename from Lite/Services/ActualPlanExecutor.cs rename to PerformanceMonitor.PlanAnalysis/ActualPlanExecutor.cs index 98f648170..e946554c0 100644 --- a/Lite/Services/ActualPlanExecutor.cs +++ b/PerformanceMonitor.PlanAnalysis/ActualPlanExecutor.cs @@ -11,17 +11,24 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Data.SqlClient; -using PerformanceMonitor.PlanAnalysis; -namespace PerformanceMonitorLite.Services; +namespace PerformanceMonitor.PlanAnalysis; /// <summary> -/// Executes a query against SQL Server with SET STATISTICS XML ON to capture -/// the actual execution plan with runtime statistics. All data result sets -/// are consumed and discarded — only the plan XML is returned. +/// Executes a query against SQL Server with SET STATISTICS XML ON to capture the actual execution plan with +/// runtime statistics. All data result sets are consumed and discarded — only the plan XML is returned. +/// +/// <para>Shared by every edition (Lite, the deprecated Dashboard, and the Darling service) — it lives next to +/// <see cref="ReproScriptBuilder"/>, which it uses to build the repro, and is parameterized by +/// <c>productName</c> for the repro header. RE-EXECUTING the query is how an ACTUAL plan (with runtime stats) +/// is obtained; this ALWAYS runs the query and applies its side effects, so callers must gate it behind +/// informed consent (see <see cref="QueryModificationDetector"/> for the data-modification flag).</para> /// </summary> public static class ActualPlanExecutor { + /// <summary>The repro-header product name used when a caller does not pass one (the Dashboard's original value).</summary> + public const string DefaultProductName = "SQL Server Performance Monitor"; + /// <summary> /// Executes the given query text and captures the actual execution plan XML. /// </summary> @@ -31,8 +38,9 @@ public static class ActualPlanExecutor /// <param name="planXml">Optional estimated plan XML (used to extract SET options and parameters).</param> /// <param name="isolationLevel">Optional transaction isolation level.</param> /// <param name="isAzureSqlDb">If true, skips USE [database] in the repro script.</param> - /// <param name="timeoutSeconds">Command timeout in seconds.</param> + /// <param name="timeoutSeconds">Command timeout in seconds (0 = no timeout).</param> /// <param name="cancellationToken">Cancellation token for user abort.</param> + /// <param name="productName">Product name stamped in the repro header (per-edition).</param> /// <returns>The actual execution plan XML, or null if no plan was captured.</returns> public static async Task<string?> ExecuteForActualPlanAsync( string connectionString, @@ -42,13 +50,14 @@ public static class ActualPlanExecutor string? isolationLevel, bool isAzureSqlDb, int timeoutSeconds, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + string productName = DefaultProductName) { /* Build the repro script (includes SET options from plan XML via #233) */ var reproScript = ReproScriptBuilder.BuildReproScript( queryText, databaseName, planXml, isolationLevel, source: "Actual Plan Capture", isAzureSqlDb: isAzureSqlDb, - productName: "SQL Server Performance Monitor Lite"); + productName: productName); /* Wrap with SET STATISTICS XML ON/OFF */ var sb = new StringBuilder(); @@ -57,7 +66,6 @@ public static class ActualPlanExecutor sb.AppendLine("SET STATISTICS XML OFF;"); var fullScript = sb.ToString(); - string? capturedPlanXml = null; /* Override database in connection string */ var builder = new SqlConnectionStringBuilder(connectionString); @@ -79,15 +87,28 @@ public static class ActualPlanExecutor }); using var reader = await command.ExecuteReaderAsync(cancellationToken); + return await CapturePlanXmlAsync(new SqlActualPlanReader(reader), cancellationToken); + } + + /// <summary> + /// Walks every result set the SET-STATISTICS-XML batch produces, discarding the data rows and capturing the + /// single-column row whose value begins with <c><ShowPlanXML</c> (the plan result set SQL Server appends + /// after each statement). Pure over an <see cref="IActualPlanResultReader"/> so Darling.Tests can pin the + /// capture-vs-discard logic with a scripted reader and no live SQL Server. The LAST plan result set wins + /// (a multi-statement batch's final actual plan), matching the original Lite/Dashboard loop verbatim. + /// </summary> + internal static async Task<string?> CapturePlanXmlAsync(IActualPlanResultReader reader, CancellationToken cancellationToken) + { + string? capturedPlanXml = null; - /* Iterate all result sets. SET STATISTICS XML ON causes SQL Server to - append a plan XML result set after each statement's data result set. - The plan result set has a single row with a single XML column. */ + /* Iterate all result sets. SET STATISTICS XML ON causes SQL Server to append a plan XML result set + after each statement's data result set. The plan result set has a single row with a single XML + column. */ do { if (reader.FieldCount == 1 && await reader.ReadAsync(cancellationToken)) { - var value = reader.GetValue(0)?.ToString(); + var value = reader.GetStringValue(0); if (value != null && value.TrimStart().StartsWith("<ShowPlanXML", StringComparison.Ordinal)) { /* This is a plan XML result set — capture it */ @@ -109,4 +130,36 @@ The plan result set has a single row with a single XML column. */ return capturedPlanXml; } + + /// <summary> + /// The minimal result-set surface <see cref="CapturePlanXmlAsync"/> needs — a seam so the capture loop is + /// unit-testable without a live SqlDataReader (which is sealed and un-fakeable). + /// </summary> + internal interface IActualPlanResultReader + { + int FieldCount { get; } + + Task<bool> ReadAsync(CancellationToken cancellationToken); + + Task<bool> NextResultAsync(CancellationToken cancellationToken); + + /// <summary>The ordinal's value as a string (matches the original <c>GetValue(0)?.ToString()</c>).</summary> + string? GetStringValue(int ordinal); + } + + /// <summary>The production adapter over a real <see cref="SqlDataReader"/>.</summary> + private sealed class SqlActualPlanReader : IActualPlanResultReader + { + private readonly SqlDataReader _reader; + + public SqlActualPlanReader(SqlDataReader reader) => _reader = reader; + + public int FieldCount => _reader.FieldCount; + + public Task<bool> ReadAsync(CancellationToken cancellationToken) => _reader.ReadAsync(cancellationToken); + + public Task<bool> NextResultAsync(CancellationToken cancellationToken) => _reader.NextResultAsync(cancellationToken); + + public string? GetStringValue(int ordinal) => _reader.GetValue(ordinal)?.ToString(); + } } diff --git a/PerformanceMonitor.PlanAnalysis/PerformanceMonitor.PlanAnalysis.csproj b/PerformanceMonitor.PlanAnalysis/PerformanceMonitor.PlanAnalysis.csproj index 0f210a10d..a3a1543ba 100644 --- a/PerformanceMonitor.PlanAnalysis/PerformanceMonitor.PlanAnalysis.csproj +++ b/PerformanceMonitor.PlanAnalysis/PerformanceMonitor.PlanAnalysis.csproj @@ -17,10 +17,20 @@ <ProjectReference Include="..\PerformanceMonitor.Common\PerformanceMonitor.Common.csproj" /> </ItemGroup> + <ItemGroup> + <!-- ActualPlanExecutor RE-EXECUTES a query against the target to capture its ACTUAL plan (SET STATISTICS + XML), so it needs the SqlClient runtime connection surface. Version matches Lite/Dashboard/the Darling + service (7.0.1) so there is no transitive version skew. --> + <PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.1" /> + </ItemGroup> + <ItemGroup> <InternalsVisibleTo Include="PerformanceMonitorLite" /> <InternalsVisibleTo Include="PerformanceMonitorDashboard" /> <InternalsVisibleTo Include="Lite.Tests" /> <InternalsVisibleTo Include="Dashboard.Tests" /> + <!-- The Darling service/viewer call the SHARED ActualPlanExecutor + QueryModificationDetector; Darling.Tests + also pins the executor's internal plan-capture seam (IActualPlanResultReader) with a scripted reader. --> + <InternalsVisibleTo Include="Darling.Tests" /> </ItemGroup> </Project> diff --git a/PerformanceMonitor.PlanAnalysis/QueryModificationDetector.cs b/PerformanceMonitor.PlanAnalysis/QueryModificationDetector.cs new file mode 100644 index 000000000..1e9de7ee4 --- /dev/null +++ b/PerformanceMonitor.PlanAnalysis/QueryModificationDetector.cs @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml; +using System.Xml.Linq; + +namespace PerformanceMonitor.PlanAnalysis; + +/// <summary> +/// Determines whether a query MODIFIES data (INSERT / UPDATE / DELETE / MERGE / …) — the safety signal that +/// gates "Get Actual Plan" (which RE-EXECUTES the query, re-applying any writes). One shared implementation for +/// every edition (Lite, the deprecated Dashboard, the Darling viewer). +/// +/// <para>The authoritative signal is the PLAN XML, not the query text: SQL Server's Showplan declares a +/// <c>StatementType</c> attribute on each statement element (<c>SELECT</c> / <c>INSERT</c> / <c>UPDATE</c> / +/// <c>DELETE</c> / <c>MERGE</c> / <c>SELECT INTO</c> / DDL). When the plan XML is missing or unparseable there +/// is a CONSERVATIVE text-based fallback that marks the result <see cref="QueryModificationInfo.IsUncertain"/> — +/// and an uncertain result ALWAYS reports <see cref="QueryModificationInfo.ModifiesData"/> = true (fail safe: +/// if we cannot prove a query is read-only, we treat it as modifying).</para> +/// </summary> +public static partial class QueryModificationDetector +{ + /// <summary> + /// The statement-type prefixes that WRITE data or schema. Matched case-insensitively against the leading + /// token so variants ride along (e.g. <c>INSERT</c> covers "INSERT EXEC", <c>UPDATE</c> covers + /// "UPDATE STATISTICS"). <c>SELECT INTO</c> is handled explicitly (it starts with SELECT but creates+writes + /// a table). Erring toward flagging is the safe direction for a re-execution gate. + /// </summary> + private static readonly string[] s_modifyingPrefixes = + { + "INSERT", "UPDATE", "DELETE", "MERGE", "TRUNCATE", "DROP", "ALTER", "CREATE", "GRANT", "REVOKE", "DENY", + }; + + /// <summary>The keywords the text fallback scans for (display only — an uncertain result is modifying regardless).</summary> + private static readonly string[] s_textKeywords = + { + "INSERT", "UPDATE", "DELETE", "MERGE", "TRUNCATE", "DROP", "ALTER", "CREATE", + }; + + /// <summary> + /// Determines whether the query modifies data. Prefers the plan XML's <c>StatementType</c> declarations; + /// falls back (marked uncertain → modifying) to a conservative text scan when the plan is missing or cannot + /// be parsed, or parses but declares no statement type. + /// </summary> + public static QueryModificationInfo Detect(string? planXml, string? queryText) + { + if (!string.IsNullOrWhiteSpace(planXml)) + { + var types = TryReadStatementTypesFromPlan(planXml); + if (types is { Count: > 0 }) + { + var modifies = types.Any(IsModifyingStatementType); + return new QueryModificationInfo(modifies, types, IsUncertain: false); + } + } + + /* No plan, unparseable plan, or a plan with no declared statement type: fall back to a best-effort + text scan for DISPLAY, but treat the result as MODIFYING because we could not prove otherwise. */ + return new QueryModificationInfo(ModifiesData: true, ScanTextForStatementTypes(queryText), IsUncertain: true); + } + + /// <summary> + /// True when a Showplan <c>StatementType</c> value denotes a data- or schema-modifying statement. Public so + /// callers/tests and <see cref="BuildConsentWarning"/> can filter a mixed statement list down to the writers. + /// </summary> + public static bool IsModifyingStatementType(string? statementType) + { + if (string.IsNullOrWhiteSpace(statementType)) + { + return false; + } + + var upper = statementType.Trim().ToUpperInvariant(); + if (upper == "SELECT INTO") + { + return true; + } + + foreach (var prefix in s_modifyingPrefixes) + { + if (upper.StartsWith(prefix, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + /// <summary> + /// The distinct <c>StatementType</c> values declared anywhere in the plan (statement elements at any nesting + /// — StmtSimple, and those inside StmtCond IF/ELSE blocks or StmtCursor). Returns null when the XML cannot be + /// parsed (the caller's fail-safe trigger); an empty list when it parses but declares no statement type. + /// </summary> + private static List<string>? TryReadStatementTypesFromPlan(string planXml) + { + try + { + var doc = XDocument.Parse(planXml); + + /* StatementType is an unqualified attribute, so match it regardless of the element's namespace — + robust to the showplan namespace and to any fragment/stripped-namespace plan XML. */ + return doc.Descendants() + .Select(e => e.Attribute("StatementType")?.Value) + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Select(v => v!.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(v => v, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + catch (XmlException) + { + return null; + } + } + + /// <summary> + /// A best-effort keyword scan of the query text — used ONLY for the uncertain fallback's DISPLAY (the + /// modification verdict is already "modifying" in that case). Returns the distinct DML/DDL keywords present. + /// </summary> + private static IReadOnlyList<string> ScanTextForStatementTypes(string? queryText) + { + if (string.IsNullOrWhiteSpace(queryText)) + { + return Array.Empty<string>(); + } + + var found = new List<string>(); + foreach (var keyword in s_textKeywords) + { + if (Regex.IsMatch(queryText, $@"\b{keyword}\b", RegexOptions.IgnoreCase)) + { + found.Add(keyword); + } + } + + return found; + } + + /// <summary> + /// Builds the PROMINENT, distinct data-modification warning block for a consent dialog (empty string when + /// the query does not modify data — the caller shows only its normal "this will execute" warning). Shared + /// so Lite and the Darling viewer word the modification flag identically. Names the statement types and + /// states plainly that re-executing RE-APPLIES those writes to the database. + /// </summary> + public static string BuildConsentWarning(QueryModificationInfo info, string? databaseName) + { + ArgumentNullException.ThrowIfNull(info); + if (!info.ModifiesData) + { + return string.Empty; + } + + var db = string.IsNullOrWhiteSpace(databaseName) ? "the target database" : $"[{databaseName}]"; + var sb = new StringBuilder(); + sb.AppendLine("⚠ DATA-MODIFICATION WARNING ⚠"); + sb.AppendLine(); + + if (info.IsUncertain) + { + sb.AppendLine( + "The execution plan for this query could not be analyzed, so it is being treated as " + + "DATA-MODIFYING to be safe."); + sb.AppendLine(); + sb.AppendLine( + $"Re-executing it may INSERT, UPDATE, DELETE, or MERGE data in {db}. Only continue if you are " + + "certain the query is safe to run again."); + } + else + { + var writers = info.StatementTypes.Where(IsModifyingStatementType).ToList(); + var typeList = writers.Count > 0 ? string.Join(", ", writers) : string.Join(", ", info.StatementTypes); + sb.AppendLine($"This query MODIFIES DATA. Statement type(s): {typeList}."); + sb.AppendLine(); + sb.AppendLine( + $"Getting the actual plan RE-EXECUTES the query, which will RE-APPLY those {typeList} changes to " + + $"{db}. This is NOT a read-only operation."); + } + + return sb.ToString(); + } +} + +/// <summary> +/// The result of a data-modification check. <see cref="ModifiesData"/> is the gate signal; when +/// <see cref="IsUncertain"/> is true it is ALWAYS true (the plan could not be analyzed, so the query is treated +/// as modifying). <see cref="StatementTypes"/> is the distinct set of statement types found (from the plan when +/// certain, from a text scan when uncertain) for display in the consent dialog. +/// </summary> +public sealed record QueryModificationInfo( + bool ModifiesData, + IReadOnlyList<string> StatementTypes, + bool IsUncertain); diff --git a/PerformanceMonitor.PlanAnalysis/ReproScriptBuilder.cs b/PerformanceMonitor.PlanAnalysis/ReproScriptBuilder.cs index 008ac542d..1f95790a7 100644 --- a/PerformanceMonitor.PlanAnalysis/ReproScriptBuilder.cs +++ b/PerformanceMonitor.PlanAnalysis/ReproScriptBuilder.cs @@ -21,6 +21,17 @@ namespace PerformanceMonitor.PlanAnalysis; /// </summary> public static partial class ReproScriptBuilder { + /// <summary> + /// The valid <c>SET TRANSACTION ISOLATION LEVEL</c> arguments (uppercased). A captured isolation level that + /// is not one of these — the snapshot collector records <c>Unspecified</c> (level 0) and <c>???</c> (out of + /// range) for sessions with no explicit level — is NOT valid T-SQL, so the repro skips the SET rather than + /// emit a statement that fails to parse (the query then runs under the connection's default isolation). + /// </summary> + private static readonly HashSet<string> s_validIsolationLevels = new(StringComparer.Ordinal) + { + "READ UNCOMMITTED", "READ COMMITTED", "REPEATABLE READ", "SERIALIZABLE", "SNAPSHOT", + }; + /// <summary> /// Builds a complete reproduction script from available query data. /// </summary> @@ -96,10 +107,13 @@ public static string BuildReproScript( sb.AppendLine("*/"); sb.AppendLine(); - /* USE database (skip for Azure SQL DB — USE is invalid there) */ + /* USE database (skip for Azure SQL DB — USE is invalid there). QUOTENAME-escape the closing bracket so a + database name is never a script-injection vector — the actual-plan command's database_name is + payload-supplied, and while the executor also pins it as InitialCatalog, escaping here is defense in + depth for every repro consumer. */ if (!string.IsNullOrEmpty(databaseName) && !isAzureSqlDb) { - sb.AppendLine($"USE [{databaseName}];"); + sb.AppendLine($"USE [{databaseName.Replace("]", "]]")}];"); sb.AppendLine(); } @@ -118,7 +132,11 @@ public static string BuildReproScript( if (!string.IsNullOrEmpty(isolationLevel)) { - sb.AppendLine($"SET TRANSACTION ISOLATION LEVEL {isolationLevel.ToUpperInvariant()};"); + var normalizedIsolation = isolationLevel.Trim().ToUpperInvariant(); + if (s_validIsolationLevels.Contains(normalizedIsolation)) + { + sb.AppendLine($"SET TRANSACTION ISOLATION LEVEL {normalizedIsolation};"); + } } sb.AppendLine("SET NOCOUNT ON;"); sb.AppendLine(); diff --git a/PerformanceMonitor.Ui/ChartRenderHelper.cs b/PerformanceMonitor.Ui/ChartRenderHelper.cs new file mode 100644 index 000000000..b80808ff7 --- /dev/null +++ b/PerformanceMonitor.Ui/ChartRenderHelper.cs @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Collections.Generic; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Ui; + +/// <summary> +/// The STATEFUL ScottPlot chart-render helpers shared by Lite's and Darling's per-server tabs: legend-panel +/// tracking (so a chart's bottom legend is removed before the next refresh instead of stacking) plus the +/// empty-state "No Data" chart. The stateless theme / axis-color / y-limit shapes stay as thin per-app +/// forwarders to the shared <see cref="ChartStyle"/>. (The deprecated Full Dashboard keeps its own copies.) +/// </summary> +public sealed class ChartRenderHelper +{ + private readonly Dictionary<ScottPlot.WPF.WpfPlot, ScottPlot.IPanel?> _legendPanels = new(); + + /// <summary>Clears a chart and removes any existing legend panel to prevent duplication.</summary> + public void ClearChart(ScottPlot.WPF.WpfPlot chart) + { + if (_legendPanels.TryGetValue(chart, out var existingPanel) && existingPanel != null) + { + chart.Plot.Axes.Remove(existingPanel); + _legendPanels[chart] = null; + } + + /* Reset fully — Plot.Clear() leaves stale DateTime axes behind, + and DateTimeTicksBottom() replaces the axis object entirely. + Resetting the plot object avoids tick generator type mismatches. */ + chart.Reset(); + chart.Plot.Clear(); + } + + /// <summary>Shows the legend on a chart and tracks it for cleanup on the next refresh.</summary> + public void ShowChartLegend(ScottPlot.WPF.WpfPlot chart) + { + _legendPanels[chart] = chart.Plot.ShowLegend(ScottPlot.Edge.Bottom); + chart.Plot.Legend.FontSize = 13; + } + + /// <summary> + /// Records a legend panel a caller created directly (e.g. the Query Heatmap color bar) so + /// <see cref="ClearChart"/> removes it on the next refresh. + /// </summary> + public void SetLegendPanel(ScottPlot.WPF.WpfPlot chart, ScottPlot.IPanel? panel) => _legendPanels[chart] = panel; + + /// <summary> + /// Sets up an empty chart with dark theme, Y-axis label, legend, and a centered "No Data" annotation. + /// </summary> + public void RefreshEmptyChart(ScottPlot.WPF.WpfPlot chart, string legendText, string yAxisLabel) + { + ChartStyle.ReapplyAxisColors(chart); + + /* Add invisible scatter to create legend entry (matches data chart layout) */ + var placeholder = chart.Plot.Add.Scatter(new double[] { 0 }, new double[] { 0 }); + placeholder.LegendText = legendText; + placeholder.Color = ScottPlot.Color.FromHex(ChartPalette.AccentColor("Placeholder")); + placeholder.MarkerSize = 0; + placeholder.LineWidth = 0; + + /* Add centered "No Data" text */ + var text = chart.Plot.Add.Text($"{legendText}\nNo Data", 0, 0); + text.LabelFontColor = ScottPlot.Color.FromHex(ChartPalette.AccentColor("Placeholder")); + text.LabelFontSize = 14; + text.LabelAlignment = ScottPlot.Alignment.MiddleCenter; + + /* Configure axes */ + chart.Plot.HideGrid(); + chart.Plot.Axes.SetLimitsX(-1, 1); + chart.Plot.Axes.SetLimitsY(-1, 1); + chart.Plot.Axes.Bottom.TickGenerator = new ScottPlot.TickGenerators.EmptyTickGenerator(); + chart.Plot.Axes.Left.TickGenerator = new ScottPlot.TickGenerators.EmptyTickGenerator(); + chart.Plot.YLabel(yAxisLabel); + + /* Show legend to match data chart layout */ + ShowChartLegend(chart); + chart.Refresh(); + } +} diff --git a/PerformanceMonitor.Ui/ChartStyle.cs b/PerformanceMonitor.Ui/ChartStyle.cs index 51e5a4b87..73e9a509b 100644 --- a/PerformanceMonitor.Ui/ChartStyle.cs +++ b/PerformanceMonitor.Ui/ChartStyle.cs @@ -167,9 +167,10 @@ public static void LockChartVerticalAxis(WpfPlot chart) } /// <summary> - /// Sets Y-axis limits with padding for a bottom legend and top breathing room. - /// Adds a small margin below a zero baseline so flat-at-zero lines stay visible above the - /// axis. Call this BEFORE LockChartVerticalAxis. + /// Sets Y-axis limits with padding for a bottom legend and top breathing room. Floors the axis + /// at zero for non-negative data (the common metric case) so there is no magnitude-scaled + /// dead-band below the lines; only genuinely-negative data gets a below-zero margin. Call this + /// BEFORE LockChartVerticalAxis. /// </summary> public static void SetChartYLimitsWithLegendPadding(WpfPlot chart, double dataYMin = 0, double dataYMax = 0) { @@ -179,16 +180,30 @@ public static void SetChartYLimitsWithLegendPadding(WpfPlot chart, double dataYM dataYMin = limits.Bottom; dataYMax = limits.Top; } + + var (yMin, yMax) = ComputeYLimitsWithLegendPadding(dataYMin, dataYMax); + chart.Plot.Axes.SetLimitsY(yMin, yMax); + } + + /// <summary> + /// Pure Y-limit math behind <see cref="SetChartYLimitsWithLegendPadding"/>, extracted so it can be + /// unit-tested without a WPF control. Floors at zero for non-negative data so high-magnitude charts + /// get no dead-band below the lines; only genuinely-negative data gets a 10%-of-range below-zero + /// margin. Adds 15%-of-range top breathing room so a series that plateaus at a hard ceiling + /// (e.g. CPU-scheduler task counts pinned at the scheduler count) does not crowd the top edge + /// and read as clipped; a momentary peak simply gets a little more air above it. + /// </summary> + public static (double YMin, double YMax) ComputeYLimitsWithLegendPadding(double dataYMin, double dataYMax) + { if (dataYMax <= dataYMin) dataYMax = dataYMin + 1; double range = dataYMax - dataYMin; - double topPadding = range * 0.05; + double topPadding = range * 0.15; - /* Add a small bottom margin when dataYMin is zero so flat lines at Y=0 are visible above the axis */ - double yMin = dataYMin > 0 ? 0 : dataYMin == 0 ? -(range * 0.05) : dataYMin - (range * 0.10); + double yMin = dataYMin >= 0 ? 0 : dataYMin - (range * 0.10); double yMax = dataYMax + topPadding; - chart.Plot.Axes.SetLimitsY(yMin, yMax); + return (yMin, yMax); } /// <summary> diff --git a/Lite/Controls/ColumnFilterPopup.xaml b/PerformanceMonitor.Ui/ColumnFilterPopup.xaml similarity index 96% rename from Lite/Controls/ColumnFilterPopup.xaml rename to PerformanceMonitor.Ui/ColumnFilterPopup.xaml index 51840c43d..33da02527 100644 --- a/Lite/Controls/ColumnFilterPopup.xaml +++ b/PerformanceMonitor.Ui/ColumnFilterPopup.xaml @@ -1,4 +1,4 @@ -<UserControl x:Class="PerformanceMonitorLite.Controls.ColumnFilterPopup" +<UserControl x:Class="PerformanceMonitor.Ui.ColumnFilterPopup" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Width="220" diff --git a/Lite/Controls/ColumnFilterPopup.xaml.cs b/PerformanceMonitor.Ui/ColumnFilterPopup.xaml.cs similarity index 96% rename from Lite/Controls/ColumnFilterPopup.xaml.cs rename to PerformanceMonitor.Ui/ColumnFilterPopup.xaml.cs index a7680d95f..87ed58faf 100644 --- a/Lite/Controls/ColumnFilterPopup.xaml.cs +++ b/PerformanceMonitor.Ui/ColumnFilterPopup.xaml.cs @@ -1,7 +1,7 @@ /* * Copyright (c) 2026 Erik Darling, Darling Data LLC * - * This file is part of the SQL Server Performance Monitor Lite. + * This file is part of the SQL Server Performance Monitor. * * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ @@ -10,10 +10,9 @@ using System.Windows; using System.Windows.Controls; using System.Windows.Input; -using PerformanceMonitorLite.Models; using PerformanceMonitor.Common; -namespace PerformanceMonitorLite.Controls; +namespace PerformanceMonitor.Ui; public partial class ColumnFilterPopup : UserControl { diff --git a/PerformanceMonitor.Ui/ColumnFilterPopupController.cs b/PerformanceMonitor.Ui/ColumnFilterPopupController.cs new file mode 100644 index 000000000..85533c137 --- /dev/null +++ b/PerformanceMonitor.Ui/ColumnFilterPopupController.cs @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; + +namespace PerformanceMonitor.Ui; + +/// <summary> +/// Shared host/apply plumbing for the per-grid column-filter <see cref="ColumnFilterPopup"/>. Extracted from the +/// byte-identical quartet (EnsureFilterPopup + FilterButton_Click + FilterPopup_FilterApplied + +/// FilterPopup_FilterCleared) that lived in Lite's and Darling's ServerTab / FinOps partials. Each host owns its +/// grid -> filter-manager dictionary and hands it here; the thin XAML-wired <c>FilterButton_Click</c> forwards to +/// <see cref="HandleFilterButtonClick"/>. Internal — reached by both apps via Ui's InternalsVisibleTo. +/// </summary> +internal sealed class ColumnFilterPopupController +{ + private readonly IReadOnlyDictionary<DataGrid, IDataGridFilterManager> _managers; + private Popup? _popup; + private ColumnFilterPopup? _content; + private DataGrid? _currentGrid; + + internal ColumnFilterPopupController(IReadOnlyDictionary<DataGrid, IDataGridFilterManager> managers) + => _managers = managers; + + /// <summary> + /// Opens the column-filter popup for the clicked filter button: walks up to the owning DataGrid, seeds the + /// popup with any existing filter state for that column, and anchors it under the button. + /// </summary> + internal void HandleFilterButtonClick(object sender) + { + if (sender is not Button button || button.Tag is not string columnName) return; + + /* Walk up visual tree to find the parent DataGrid */ + var dataGrid = DataGridHelpers.FindParentDataGridFromElement(button); + if (dataGrid == null || !_managers.TryGetValue(dataGrid, out var manager)) return; + + _currentGrid = dataGrid; + + EnsureFilterPopup(); + + /* Rewire events to the current grid */ + _content!.FilterApplied -= OnFilterApplied; + _content.FilterCleared -= OnFilterCleared; + _content.FilterApplied += OnFilterApplied; + _content.FilterCleared += OnFilterCleared; + + /* Initialize with existing filter state */ + manager.Filters.TryGetValue(columnName, out var existingFilter); + _content.Initialize(columnName, existingFilter); + + _popup!.PlacementTarget = button; + _popup.IsOpen = true; + } + + private void EnsureFilterPopup() + { + if (_popup == null) + { + _content = new ColumnFilterPopup(); + _popup = new Popup + { + Child = _content, + StaysOpen = false, + Placement = PlacementMode.Bottom, + AllowsTransparency = true + }; + } + } + + private void OnFilterApplied(object? sender, FilterAppliedEventArgs e) + { + if (_popup != null) + _popup.IsOpen = false; + + if (_currentGrid != null && _managers.TryGetValue(_currentGrid, out var manager)) + { + manager.SetFilter(e.FilterState); + } + } + + private void OnFilterCleared(object? sender, EventArgs e) + { + if (_popup != null) + _popup.IsOpen = false; + } +} diff --git a/PerformanceMonitor.Ui/CpuSchedulerChartRenderer.cs b/PerformanceMonitor.Ui/CpuSchedulerChartRenderer.cs new file mode 100644 index 000000000..3267e3483 --- /dev/null +++ b/PerformanceMonitor.Ui/CpuSchedulerChartRenderer.cs @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Ui; + +/// <summary> +/// Shared renderer for the CPU Scheduler pressure trend chart — three fixed series (Runnable / Blocked / +/// Queued task counts) over the settable window. The ScottPlot body was byte-identical between Lite's +/// <c>ServerTab.CpuScheduler.cs</c> and Darling's <c>ViewerServerTab.CpuScheduler.cs</c> apart from the +/// display-time projection, so it lives here once. Each app constructs one with its own projection (Lite +/// adds its UTC offset; Darling uses <c>ViewerTimeHelper.ForDisplay</c>) and hands the chart + hover + +/// pre-computed window bounds; the store-specific trend read stays per-app. INTERNAL because it takes the +/// internal <see cref="ChartHoverHelper"/> (both apps reach it via Ui's InternalsVisibleTo). +/// </summary> +internal sealed class CpuSchedulerChartRenderer +{ + private static readonly string[] SeriesColors = ChartPalette.CyclingPalette.ToArray(); + + private readonly ChartRenderHelper _chartHelper; + private readonly Func<DateTime, DateTime> _project; + + internal CpuSchedulerChartRenderer(ChartRenderHelper chartHelper, Func<DateTime, DateTime> project) + { + _chartHelper = chartHelper; + _project = project; + } + + internal void Render(ScottPlot.WPF.WpfPlot chart, ChartHoverHelper? hover, IReadOnlyList<ICpuSchedulerTrendPoint> data, double xMin, double xMax) + { + _chartHelper.ClearChart(chart); + hover?.Clear(); + ChartStyle.ApplyThemeToChart(chart); + + double globalMax = 0; + if (data.Count > 0) + { + var ordered = data.OrderBy(d => d.CollectionTime).ToList(); + var times = ordered.Select(d => _project(d.CollectionTime).ToOADate()).ToArray(); + + var series = new (string Name, Func<ICpuSchedulerTrendPoint, double> Selector)[] + { + ("Runnable Tasks", d => d.RunnableTasks), + ("Blocked Tasks", d => d.BlockedTasks), + ("Queued Requests", d => d.QueuedRequests), + }; + + int colorIdx = 0; + foreach (var s in series) + { + var values = ordered.Select(s.Selector).ToArray(); + var plot = chart.Plot.Add.Scatter(times, values); + plot.LegendText = s.Name; + plot.Color = ScottPlot.Color.FromHex(SeriesColors[colorIdx % SeriesColors.Length]); + ChartStyle.StyleScatter(plot); + hover?.Add(plot, s.Name); + colorIdx++; + if (values.Length > 0) globalMax = Math.Max(globalMax, values.Max()); + } + } + + chart.Plot.Axes.DateTimeTicksBottomDateChange(); + chart.Plot.Axes.SetLimitsX(xMin, xMax); + ChartStyle.ReapplyAxisColors(chart); + chart.Plot.YLabel("Task Count"); + ChartStyle.SetChartYLimitsWithLegendPadding(chart, 0, globalMax > 0 ? globalMax : 5); + _chartHelper.ShowChartLegend(chart); + chart.Refresh(); + } +} diff --git a/PerformanceMonitor.Ui/DataGridExport.cs b/PerformanceMonitor.Ui/DataGridExport.cs new file mode 100644 index 000000000..acbfefb0c --- /dev/null +++ b/PerformanceMonitor.Ui/DataGridExport.cs @@ -0,0 +1,238 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Media; +using Microsoft.Win32; + +namespace PerformanceMonitor.Ui; + +/// <summary> +/// Shared copy/export operations for DataGrid context menus, used by both Dashboard and Lite. +/// The four context-menu handlers (Copy Cell / Copy Row / Copy All Rows / Export to CSV) are +/// identical across ~29 sites apart from the CSV file-name prefix and the user's separator +/// preference, so they live here once. +/// +/// Headers and rows both iterate <see cref="DataGrid.Columns"/> in lock-step (bound AND template +/// columns), so the two never misalign -- a bug the older Dashboard handlers had, where headers +/// were filtered to bound columns but rows emitted every column. +/// +/// Each operation is split into a pure builder (returns the string -- unit-testable headlessly) +/// and a thin wrapper that performs the clipboard / SaveFileDialog / file side effect. +/// </summary> +public static class DataGridExport +{ + /* ---- side-effecting wrappers (wired to the context-menu MenuItem handlers) ---- */ + + /// <summary>Copies the current cell's value to the clipboard.</summary> + public static void CopyCell(object sender) + { + var grid = FindDataGrid(sender); + if (grid == null) return; + + var cell = grid.CurrentCell; + if (cell.Column == null || cell.Item == null) return; + + var value = GetCellValue(cell.Column, cell.Item); + if (value.Length > 0) Clipboard.SetDataObject(value, false); + } + + /// <summary> + /// Copies a row (all columns, tab-delimited) to the clipboard -- the row the context menu was + /// opened on, falling back to the current or selected row. Row-attached context menus don't + /// select the row on right-click, so CurrentItem alone is often null here. + /// </summary> + public static void CopyRow(object sender) + { + var grid = FindDataGrid(sender); + if (grid == null) return; + + var item = FindRowItem(sender) ?? grid.CurrentItem ?? grid.SelectedItem; + if (item == null) return; + + Clipboard.SetDataObject(string.Join("\t", GetRowValues(grid, item)), false); + } + + /// <summary>Copies the whole grid (header + all rows, tab-delimited) to the clipboard.</summary> + public static void CopyAllRows(object sender) + { + var grid = FindDataGrid(sender); + if (grid == null || grid.Items.Count == 0) return; + + Clipboard.SetDataObject(BuildClipboardText(grid), false); + } + + /// <summary> + /// Prompts for a file and exports the whole grid to CSV. <paramref name="fileNamePrefix"/> + /// seeds the suggested file name; <paramref name="separator"/> is the user's CSV separator + /// preference (each app passes its own). + /// </summary> + public static void ExportToCsv(object sender, string fileNamePrefix, string separator) + { + var grid = FindDataGrid(sender); + if (grid == null || grid.Items.Count == 0) return; + + var dialog = new SaveFileDialog + { + FileName = $"{fileNamePrefix}_{DateTime.Now:yyyyMMdd_HHmmss}.csv", + DefaultExt = ".csv", + Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*" + }; + + if (dialog.ShowDialog() != true) return; + + try + { + File.WriteAllText(dialog.FileName, BuildCsv(grid, separator), Encoding.UTF8); + MessageBox.Show($"Data exported successfully to:\n{dialog.FileName}", "Export Complete", + MessageBoxButton.OK, MessageBoxImage.Information); + } + catch (Exception ex) + { + MessageBox.Show($"Error exporting data:\n\n{ex.Message}", "Export Error", + MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + /* ---- pure builders (unit-testable) ---- */ + + /// <summary>Builds the tab-delimited header+rows text used by Copy All Rows.</summary> + public static string BuildClipboardText(DataGrid grid) + { + var sb = new StringBuilder(); + sb.AppendLine(string.Join("\t", grid.Columns.Select(DataGridClipboardBehavior.GetHeaderText))); + foreach (var item in grid.Items) + { + sb.AppendLine(string.Join("\t", GetRowValues(grid, item))); + } + return sb.ToString(); + } + + /// <summary>Builds the CSV text (header + rows, escaped) for the given separator.</summary> + public static string BuildCsv(DataGrid grid, string separator) + { + var sb = new StringBuilder(); + sb.AppendLine(string.Join(separator, + grid.Columns.Select(c => CsvEscape(DataGridClipboardBehavior.GetHeaderText(c), separator)))); + foreach (var item in grid.Items) + { + sb.AppendLine(string.Join(separator, + GetRowValues(grid, item).Select(v => CsvEscape(v, separator)))); + } + return sb.ToString(); + } + + /// <summary>Gets one string per column for a row, in column order (empty for columns with no extractable value).</summary> + public static List<string> GetRowValues(DataGrid grid, object item) + { + var values = new List<string>(grid.Columns.Count); + foreach (var column in grid.Columns) + { + values.Add(GetCellValue(column, item)); + } + return values; + } + + /// <summary> + /// Extracts a cell value for any column type. Bound columns read their binding path directly; + /// template columns are instantiated and inspected for a TextBlock binding. Unhandled column + /// types yield an empty string so the value count always matches the column count. + /// </summary> + public static string GetCellValue(DataGridColumn column, object item) + { + if (column is DataGridBoundColumn boundColumn + && boundColumn.Binding is Binding binding + && binding.Path != null) + { + var property = item.GetType().GetProperty(binding.Path.Path); + return FormatForExport(property?.GetValue(item)); + } + + if (column is DataGridTemplateColumn templateColumn && templateColumn.CellTemplate != null) + { + var content = templateColumn.CellTemplate.LoadContent(); + if (content is TextBlock textBlock) + { + var textBinding = BindingOperations.GetBinding(textBlock, TextBlock.TextProperty); + if (textBinding != null && textBinding.Path != null) + { + var property = item.GetType().GetProperty(textBinding.Path.Path); + return FormatForExport(property?.GetValue(item)); + } + } + } + + return string.Empty; + } + + /// <summary>Formats a value for export: invariant culture for IFormattable, else ToString.</summary> + public static string FormatForExport(object? value) + { + if (value == null) return string.Empty; + if (value is IFormattable formattable) return formattable.ToString(null, CultureInfo.InvariantCulture); + return value.ToString() ?? string.Empty; + } + + /// <summary>Escapes a value for CSV: quote when it contains the separator, a quote, or a newline.</summary> + public static string CsvEscape(string value, string separator) + { + if (string.IsNullOrEmpty(value)) return string.Empty; + if (value.Contains(separator, StringComparison.Ordinal) + || value.Contains('"') + || value.Contains('\n') + || value.Contains('\r')) + { + return "\"" + value.Replace("\"", "\"\"") + "\""; + } + return value; + } + + /* ---- internals ---- */ + + /// <summary>Resolves the DataGrid that owns the context menu the handler fired from.</summary> + private static DataGrid? FindDataGrid(object sender) + { + if (sender is not MenuItem menuItem || menuItem.Parent is not ContextMenu contextMenu) + return null; + + if (contextMenu.PlacementTarget is DataGrid grid) + return grid; + + DependencyObject? target = contextMenu.PlacementTarget; + while (target != null && target is not DataGrid) + { + target = VisualTreeHelper.GetParent(target); + } + return target as DataGrid; + } + + /// <summary> + /// The data item of the DataGridRow the context menu was opened on (for row- or cell-attached + /// menus), or null when the menu is attached directly to the DataGrid. + /// </summary> + private static object? FindRowItem(object sender) + { + if (sender is not MenuItem menuItem || menuItem.Parent is not ContextMenu contextMenu) + return null; + + DependencyObject? target = contextMenu.PlacementTarget; + while (target != null && target is not DataGridRow) + { + target = VisualTreeHelper.GetParent(target); + } + return (target as DataGridRow)?.Item; + } +} diff --git a/PerformanceMonitor.Ui/DataGridFilterColumns.cs b/PerformanceMonitor.Ui/DataGridFilterColumns.cs new file mode 100644 index 000000000..b66cfe248 --- /dev/null +++ b/PerformanceMonitor.Ui/DataGridFilterColumns.cs @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; + +namespace PerformanceMonitor.Ui; + +/// <summary> +/// Decorates a DataGrid's bound columns with per-column filter buttons at runtime, so a grid +/// authored with plain-string headers gains the same filter affordance as hand-built +/// StackPanel headers — without repeating the StackPanel+Button markup on every column. +/// Pair with <see cref="DataGridFilterManager{T}"/>: each button's Tag is the column's binding +/// path, which is the property name the manager filters on and looks up when styling the icons. +/// </summary> +public static class DataGridFilterColumns +{ + /// <summary> + /// Wraps each bound column's plain-string header in a horizontal StackPanel containing a + /// filter button (styled via the "ColumnFilterButtonStyle" resource, Tag = binding path) and + /// the original header text. Template columns, unbound columns, and columns whose header is + /// already a StackPanel are left untouched, so the call is idempotent. + /// </summary> + public static void AddFilterButtons(DataGrid grid, RoutedEventHandler onFilterClick) + { + foreach (var column in grid.Columns) + { + if (column is not DataGridBoundColumn bound) continue; + if (bound.Binding is not Binding binding) continue; + var path = binding.Path?.Path; + if (string.IsNullOrEmpty(path)) continue; + if (column.Header is not string headerText) continue; // skip already-decorated / non-text headers + + var filterButton = new Button + { + Tag = path, + Margin = new Thickness(0, 0, 4, 0) + }; + filterButton.SetResourceReference(FrameworkElement.StyleProperty, "ColumnFilterButtonStyle"); + filterButton.Click += onFilterClick; + + var header = new StackPanel { Orientation = Orientation.Horizontal }; + header.Children.Add(filterButton); + header.Children.Add(new TextBlock + { + Text = headerText, + FontWeight = FontWeights.Bold, + VerticalAlignment = VerticalAlignment.Center + }); + + column.Header = header; + } + } +} diff --git a/PerformanceMonitor.Ui/DataGridHelpers.cs b/PerformanceMonitor.Ui/DataGridHelpers.cs new file mode 100644 index 000000000..7acd01332 --- /dev/null +++ b/PerformanceMonitor.Ui/DataGridHelpers.cs @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; + +namespace PerformanceMonitor.Ui; + +/// <summary> +/// Shared visual-tree helpers for resolving the owning <see cref="DataGrid"/> of a context-menu / element, +/// used by Lite's and Darling's grid context menus and column filters. (The deprecated Full Dashboard is not +/// developed further and keeps its own inline copies.) +/// </summary> +public static class DataGridHelpers +{ + /// <summary>The DataGrid whose row context menu raised this event (sender is the clicked MenuItem).</summary> + public static DataGrid? FindParentDataGrid(object sender) + { + if (sender is not MenuItem menuItem) return null; + var contextMenu = menuItem.Parent as ContextMenu; + var target = contextMenu?.PlacementTarget as FrameworkElement; + while (target != null && target is not DataGrid) + { + target = VisualTreeHelper.GetParent(target) as FrameworkElement; + } + return target as DataGrid; + } + + /// <summary>Walks up the visual tree from any element to its owning DataGrid.</summary> + public static DataGrid? FindParentDataGridFromElement(DependencyObject element) + { + var current = element; + while (current != null) + { + if (current is DataGrid dg) + return dg; + current = VisualTreeHelper.GetParent(current); + } + return null; + } +} diff --git a/PerformanceMonitor.Ui/DataGridRowSelectionBehavior.cs b/PerformanceMonitor.Ui/DataGridRowSelectionBehavior.cs index aafff8053..5ec44aefa 100644 --- a/PerformanceMonitor.Ui/DataGridRowSelectionBehavior.cs +++ b/PerformanceMonitor.Ui/DataGridRowSelectionBehavior.cs @@ -14,20 +14,22 @@ namespace PerformanceMonitor.Ui; /// <summary> -/// Makes right-clicking a DataGrid row select it. WPF's DataGrid does not select a row on -/// right-click by default, so context-menu actions that read <c>SelectedItem</c> (e.g. "View Plan") -/// silently do nothing whenever the prior left-click selection was cleared — most commonly by a -/// background auto-refresh re-binding <c>ItemsSource</c>, which also drops the row's highlight. -/// Selecting the row under the cursor on right-click makes the menu always act on the row the user -/// actually clicked. Call <see cref="Enable"/> once at application startup to apply this app-wide. +/// Makes right-clicking a DataGrid select the row AND make the cell under the cursor current. WPF's +/// DataGrid does neither on right-click by default, so context-menu actions that read +/// <c>SelectedItem</c>/<c>CurrentItem</c> (e.g. "View Plan", "Copy Row") or <c>CurrentCell</c> +/// (e.g. "Copy Cell") silently do nothing on a bare right-click — and any prior left-click selection +/// is most commonly cleared by a background auto-refresh re-binding <c>ItemsSource</c>. Setting both +/// the selected row and the current cell under the cursor makes every context-menu item act on what +/// the user actually clicked, regardless of how the grid attaches its menu (row-level or grid-level). +/// Call <see cref="Enable"/> once at application startup to apply this app-wide. /// </summary> public static class DataGridRowSelectionBehavior { private static bool _enabled; /// <summary> - /// Registers a class handler so every <see cref="DataGrid"/> in the app selects the row under - /// the cursor on right-click. Idempotent. + /// Registers a class handler so every <see cref="DataGrid"/> in the app selects the row and makes + /// the cell under the cursor current on right-click. Idempotent. /// </summary> public static void Enable() { @@ -42,15 +44,30 @@ public static void Enable() private static void OnPreviewRightButtonDown(object sender, MouseButtonEventArgs e) { - // Walk up from the actual clicked element (a cell's content) to its DataGridRow. + if (sender is not DataGrid grid) return; + + // Walk up from the actual clicked element (a cell's content), capturing the DataGridCell on + // the way to its DataGridRow. The cell is a descendant of the row, so it is seen first. + DataGridCell? cell = null; var dep = e.OriginalSource as DependencyObject; while (dep != null && dep is not DataGridRow) + { + if (cell == null && dep is DataGridCell c) cell = c; dep = VisualTreeHelper.GetParent(dep); + } - if (dep is DataGridRow row && sender is DataGrid grid) + if (dep is DataGridRow row) { row.IsSelected = true; grid.SelectedItem = row.Item; } + + // Also make the clicked cell current so cell-scoped items (Copy Cell) act on it. This sets + // CurrentItem too, which row-scoped items (Copy Row) fall back to. We do NOT mark the event + // handled — the context menu must still open. + if (cell?.Column != null) + { + grid.CurrentCell = new DataGridCellInfo(cell); + } } } diff --git a/PerformanceMonitor.Ui/DataGridSortBehavior.cs b/PerformanceMonitor.Ui/DataGridSortBehavior.cs new file mode 100644 index 000000000..6b892f40f --- /dev/null +++ b/PerformanceMonitor.Ui/DataGridSortBehavior.cs @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.ComponentModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; + +namespace PerformanceMonitor.Ui; + +/// <summary> +/// Attached behavior that makes the FIRST click on a DataGrid column header sort +/// DESCENDING instead of the WPF default (ascending). When diagnosing performance you +/// almost always want the largest values — highest CPU / duration / reads — on top +/// first; clicking the same column again toggles to ascending as usual. Applied app-wide +/// via the implicit DataGrid style in the theme dictionaries. +/// </summary> +public static class DataGridSortBehavior +{ + public static readonly DependencyProperty SortDescendingFirstProperty = + DependencyProperty.RegisterAttached( + "SortDescendingFirst", + typeof(bool), + typeof(DataGridSortBehavior), + new PropertyMetadata(false, OnSortDescendingFirstChanged)); + + public static bool GetSortDescendingFirst(DependencyObject obj) => (bool)obj.GetValue(SortDescendingFirstProperty); + public static void SetSortDescendingFirst(DependencyObject obj, bool value) => obj.SetValue(SortDescendingFirstProperty, value); + + private static void OnSortDescendingFirstChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not DataGrid grid) + { + return; + } + + /* Idempotent — detach first so re-applying the style doesn't double-subscribe. */ + grid.Sorting -= OnSorting; + if ((bool)e.NewValue) + { + grid.Sorting += OnSorting; + } + } + + private static void OnSorting(object? sender, DataGridSortingEventArgs e) + { + if (sender is not DataGrid grid) + { + return; + } + + var sortPath = e.Column.SortMemberPath; + if (string.IsNullOrEmpty(sortPath)) + { + /* Template column with no SortMemberPath — nothing to sort on; leave the default alone. */ + return; + } + + var view = CollectionViewSource.GetDefaultView(grid.ItemsSource); + if (view == null) + { + return; + } + + /* First click on an unsorted column starts DESCENDING; subsequent clicks on the same + column toggle. (Default WPF is Ascending first, which is backwards for perf triage.) */ + var direction = e.Column.SortDirection == ListSortDirection.Descending + ? ListSortDirection.Ascending + : ListSortDirection.Descending; + + foreach (var column in grid.Columns) + { + column.SortDirection = column == e.Column ? direction : null; + } + + view.SortDescriptions.Clear(); + view.SortDescriptions.Add(new SortDescription(sortPath, direction)); + e.Handled = true; + } +} diff --git a/PerformanceMonitor.Ui/FileSaveHelper.cs b/PerformanceMonitor.Ui/FileSaveHelper.cs new file mode 100644 index 000000000..bb9a15281 --- /dev/null +++ b/PerformanceMonitor.Ui/FileSaveHelper.cs @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.IO; +using System.Text; +using System.Windows; +using Microsoft.Win32; + +namespace PerformanceMonitor.Ui; + +/// <summary> +/// Shared "save this text to a user-chosen file" dialogs used by Lite's and Darling's plan-download and +/// deadlock / blocked-process XML-download buttons. (The deprecated Full Dashboard keeps its own inline +/// copies and is not developed further.) +/// </summary> +public static class FileSaveHelper +{ + /// <summary> + /// Prompts for a <c>.sqlplan</c> path and writes <paramref name="planXml"/> there; the pre-filled name is + /// <paramref name="defaultName"/> plus a <c>yyyyMMdd_HHmmss</c> timestamp. + /// </summary> + public static void SavePlanFile(string planXml, string defaultName) + { + var dialog = new SaveFileDialog + { + Filter = "SQL Plan files (*.sqlplan)|*.sqlplan|All files (*.*)|*.*", + DefaultExt = ".sqlplan", + FileName = $"{defaultName}_{DateTime.Now:yyyyMMdd_HHmmss}.sqlplan" + }; + + if (dialog.ShowDialog() != true) return; + + try + { + File.WriteAllText(dialog.FileName, planXml, Encoding.UTF8); + } + catch (Exception ex) + { + MessageBox.Show($"Failed to save plan: {ex.Message}", "Save Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + /// <summary> + /// Prompts for a <c>.xml</c> path and writes <paramref name="xml"/> there. <paramref name="suggestedFileName"/> + /// is the full pre-filled file name; <paramref name="whatLabel"/> names the content in the error dialog. + /// </summary> + public static void SaveXmlToFile(string xml, string suggestedFileName, string whatLabel) + { + var dialog = new SaveFileDialog + { + Filter = "XML files (*.xml)|*.xml|All files (*.*)|*.*", + DefaultExt = ".xml", + FileName = suggestedFileName + }; + + if (dialog.ShowDialog() != true) return; + + try + { + File.WriteAllText(dialog.FileName, xml, Encoding.UTF8); + } + catch (Exception ex) + { + MessageBox.Show($"Failed to save {whatLabel}: {ex.Message}", "Save Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } +} diff --git a/PerformanceMonitor.Ui/GroupedTrendChartRenderer.cs b/PerformanceMonitor.Ui/GroupedTrendChartRenderer.cs new file mode 100644 index 000000000..0219af391 --- /dev/null +++ b/PerformanceMonitor.Ui/GroupedTrendChartRenderer.cs @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Ui; + +/// <summary> +/// Shared renderer for a "top-N grouped per-second trend" chart — the shape behind the Latches & +/// Spinlocks tab's two charts (latch wait ms/sec per latch class, spinlock collisions/sec per spinlock). +/// The ScottPlot body was byte-identical across Lite's <c>ServerTab.LatchSpinlock.cs</c> and Darling's +/// <c>ViewerServerTab.LatchSpinlock.cs</c> — and, within each app, between the latch and spinlock render — +/// apart from the chart control, the group-key / value properties, the empty-state legend text, the Y-axis +/// label, and the display-time projection. Those vary via <see cref="Func{T, TResult}"/> accessors rather +/// than an interface, because the two trend point types name their properties differently +/// (<c>LatchClass</c>/<c>WaitTimeMsPerSecond</c> vs <c>SpinlockName</c>/<c>CollisionsPerSecond</c>) and +/// stay per-app. Each app constructs one with its own projection (Lite adds its UTC offset; Darling uses +/// <c>ViewerTimeHelper.ForDisplay</c>) and hands the chart + hover + accessors + pre-computed window +/// bounds; the store-specific trend read stays per-app. INTERNAL because it takes the internal +/// <see cref="ChartHoverHelper"/> (both apps reach it via Ui's InternalsVisibleTo). +/// </summary> +internal sealed class GroupedTrendChartRenderer +{ + private static readonly string[] SeriesColors = ChartPalette.CyclingPalette.ToArray(); + + private readonly ChartRenderHelper _chartHelper; + private readonly Func<DateTime, DateTime> _project; + + internal GroupedTrendChartRenderer(ChartRenderHelper chartHelper, Func<DateTime, DateTime> project) + { + _chartHelper = chartHelper; + _project = project; + } + + /// <summary> + /// Renders one grouped per-second trend: when the window has no data, a flat labelled zero-line across + /// [xMin,xMax]; otherwise one scatter series per group (ordered by total value descending so the + /// heaviest contender takes the first palette color), each point's time projected to display space, + /// legend clipped via <see cref="TruncateName"/>, tracked in the hover. <paramref name="xMin"/> / + /// <paramref name="xMax"/> are the display-space window bounds as OADate doubles (the caller already + /// projected them); only the per-point times get re-projected here. + /// </summary> + internal void Render<T>( + ScottPlot.WPF.WpfPlot chart, + ChartHoverHelper? hover, + IReadOnlyList<T> data, + Func<T, DateTime> time, + Func<T, string> groupKey, + Func<T, double> value, + string emptyLegend, + string yLabel, + double xMin, + double xMax) + { + _chartHelper.ClearChart(chart); + hover?.Clear(); + ChartStyle.ApplyThemeToChart(chart); + + if (data.Count == 0) + { + var zeroLine = chart.Plot.Add.Scatter(new[] { xMin, xMax }, new[] { 0.0, 0.0 }); + zeroLine.LegendText = emptyLegend; + zeroLine.Color = ScottPlot.Color.FromHex(SeriesColors[0]); + zeroLine.MarkerSize = 0; + chart.Plot.Axes.DateTimeTicksBottomDateChange(); + chart.Plot.Axes.SetLimitsX(xMin, xMax); + ChartStyle.ReapplyAxisColors(chart); + chart.Plot.YLabel(yLabel); + ChartStyle.SetChartYLimitsWithLegendPadding(chart, 0, 1); + _chartHelper.ShowChartLegend(chart); + chart.Refresh(); + return; + } + + /* Order the series by total value desc so the heaviest contender gets the first palette color + (deterministic, mirrors the Dashboard's re-group-by-total). */ + var byGroup = data + .GroupBy(groupKey) + .OrderByDescending(g => g.Sum(value)) + .ToList(); + + double globalMax = 0; + int colorIdx = 0; + foreach (var group in byGroup) + { + var points = group.OrderBy(time).ToList(); + var times = points.Select(d => _project(time(d)).ToOADate()).ToArray(); + var values = points.Select(value).ToArray(); + + var plot = chart.Plot.Add.Scatter(times, values); + plot.LegendText = TruncateName(group.Key); + plot.Color = ScottPlot.Color.FromHex(SeriesColors[colorIdx % SeriesColors.Length]); + ChartStyle.StyleScatter(plot); + hover?.Add(plot, group.Key); + colorIdx++; + + if (values.Length > 0) globalMax = Math.Max(globalMax, values.Max()); + } + + chart.Plot.Axes.DateTimeTicksBottomDateChange(); + chart.Plot.Axes.SetLimitsX(xMin, xMax); + ChartStyle.ReapplyAxisColors(chart); + chart.Plot.YLabel(yLabel); + ChartStyle.SetChartYLimitsWithLegendPadding(chart, 0, globalMax > 0 ? globalMax : 1); + _chartHelper.ShowChartLegend(chart); + chart.Refresh(); + } + + /// <summary>Legend names for latch classes / spinlock names get long; clip to 20 chars + ellipsis + /// exactly like the Dashboard's Latch/Spinlock chart legends.</summary> + private static string TruncateName(string name) + => name.Length > 20 ? name.Substring(0, 20) + "..." : name; +} diff --git a/PerformanceMonitor.Ui/PerformanceCalendar.xaml b/PerformanceMonitor.Ui/PerformanceCalendar.xaml new file mode 100644 index 000000000..0ae62a3d0 --- /dev/null +++ b/PerformanceMonitor.Ui/PerformanceCalendar.xaml @@ -0,0 +1,207 @@ +<UserControl x:Class="PerformanceMonitor.Ui.PerformanceCalendar" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + x:Name="Root"> + <!-- + A navigable month heatmap: 6 weeks x 7 days of cells, each washed with its composite daily + health band (green / amber / red / muted-grey for no-data). Prev/next/Today navigate months + (raising MonthChanged so the host can fetch that month's data); clicking an in-month day opens + the built-in day-detail panel, whose drill buttons raise DayDrillRequested so the host can scope + and navigate. All colors are theme DynamicResources resolved from the host app — this control + ships no theme of its own. + --> + <Grid Margin="4"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + + <!-- Row 0: month navigation + legend --> + <Grid Grid.Row="0" Margin="2,2,2,8"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="*"/> + <ColumnDefinition Width="Auto"/> + </Grid.ColumnDefinitions> + + <StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center"> + <Button x:Name="PrevMonthButton" Content="◀" Width="30" Padding="0" ToolTip="Previous month" + Click="PrevMonthButton_Click"/> + <TextBlock x:Name="MonthLabel" MinWidth="150" Margin="10,0" FontSize="15" FontWeight="SemiBold" + VerticalAlignment="Center" HorizontalAlignment="Center" + Foreground="{DynamicResource ForegroundBrush}"/> + <Button x:Name="NextMonthButton" Content="▶" Width="30" Padding="0" ToolTip="Next month" + Click="NextMonthButton_Click"/> + <Button x:Name="TodayButton" Content="Today" Margin="12,0,0,0" Padding="10,2" + Click="TodayButton_Click"/> + </StackPanel> + + <!-- Legend --> + <StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center"> + <Rectangle Width="12" Height="12" RadiusX="2" RadiusY="2" Fill="{DynamicResource SuccessBrush}"/> + <TextBlock Text="Healthy" Margin="4,0,12,0" VerticalAlignment="Center" FontSize="11" + Foreground="{DynamicResource ForegroundDimBrush}"/> + <Rectangle Width="12" Height="12" RadiusX="2" RadiusY="2" Fill="{DynamicResource WarningBrush}"/> + <TextBlock Text="Warning" Margin="4,0,12,0" VerticalAlignment="Center" FontSize="11" + Foreground="{DynamicResource ForegroundDimBrush}"/> + <Rectangle Width="12" Height="12" RadiusX="2" RadiusY="2" Fill="{DynamicResource ErrorBrush}"/> + <TextBlock Text="Critical" Margin="4,0,12,0" VerticalAlignment="Center" FontSize="11" + Foreground="{DynamicResource ForegroundDimBrush}"/> + <Rectangle Width="12" Height="12" RadiusX="2" RadiusY="2" Fill="{DynamicResource ForegroundMutedBrush}" Opacity="0.4"/> + <TextBlock Text="No data" Margin="4,0,0,0" VerticalAlignment="Center" FontSize="11" + Foreground="{DynamicResource ForegroundDimBrush}"/> + </StackPanel> + </Grid> + + <!-- Row 1: weekday headers --> + <ItemsControl Grid.Row="1" ItemsSource="{Binding WeekdayHeaders, ElementName=Root}"> + <ItemsControl.ItemsPanel> + <ItemsPanelTemplate> + <UniformGrid Columns="7"/> + </ItemsPanelTemplate> + </ItemsControl.ItemsPanel> + <ItemsControl.ItemTemplate> + <DataTemplate> + <TextBlock Text="{Binding}" HorizontalAlignment="Center" Margin="0,0,0,4" + FontSize="11" FontWeight="SemiBold" + Foreground="{DynamicResource ForegroundDimBrush}"/> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + + <!-- Row 2: day cells --> + <ItemsControl Grid.Row="2" ItemsSource="{Binding Cells, ElementName=Root}"> + <ItemsControl.ItemsPanel> + <ItemsPanelTemplate> + <UniformGrid Columns="7" Rows="6"/> + </ItemsPanelTemplate> + </ItemsControl.ItemsPanel> + <ItemsControl.ItemTemplate> + <DataTemplate> + <!-- Cell chrome. Transparent background makes the whole cell hit-testable. --> + <Border Margin="2" CornerRadius="4" Background="Transparent" + MouseLeftButtonUp="Cell_MouseLeftButtonUp" + ToolTip="{Binding Tooltip}"> + <Border.Style> + <Style TargetType="Border"> + <Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/> + <Setter Property="BorderThickness" Value="1"/> + <Setter Property="Cursor" Value="Hand"/> + <Style.Triggers> + <!-- Out-of-month days are not interactive. --> + <DataTrigger Binding="{Binding IsInDisplayMonth}" Value="False"> + <Setter Property="Cursor" Value="Arrow"/> + <Setter Property="BorderThickness" Value="0"/> + <Setter Property="ToolTip" Value="{x:Null}"/> + </DataTrigger> + <!-- Selected day: accent ring. --> + <DataTrigger Binding="{Binding IsSelected}" Value="True"> + <Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}"/> + <Setter Property="BorderThickness" Value="2"/> + </DataTrigger> + <!-- Today: accent ring (overrides selected so today is always framed). --> + <DataTrigger Binding="{Binding IsToday}" Value="True"> + <Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}"/> + <Setter Property="BorderThickness" Value="2"/> + </DataTrigger> + <Trigger Property="IsMouseOver" Value="True"> + <Setter Property="BorderBrush" Value="{DynamicResource AccentHoverBrush}"/> + </Trigger> + </Style.Triggers> + </Style> + </Border.Style> + + <Grid MinHeight="44" MinWidth="44"> + <!-- Band wash. Fill is the band brush; opacity conveys presence/severity. --> + <Rectangle RadiusX="3" RadiusY="3" Opacity="{Binding WashOpacity}"> + <Rectangle.Style> + <Style TargetType="Rectangle"> + <Setter Property="Fill" Value="{DynamicResource ForegroundMutedBrush}"/> + <Style.Triggers> + <DataTrigger Binding="{Binding Band}" Value="Healthy"> + <Setter Property="Fill" Value="{DynamicResource SuccessBrush}"/> + </DataTrigger> + <DataTrigger Binding="{Binding Band}" Value="Warning"> + <Setter Property="Fill" Value="{DynamicResource WarningBrush}"/> + </DataTrigger> + <DataTrigger Binding="{Binding Band}" Value="Critical"> + <Setter Property="Fill" Value="{DynamicResource ErrorBrush}"/> + </DataTrigger> + </Style.Triggers> + </Style> + </Rectangle.Style> + </Rectangle> + + <!-- Day number, top-left. --> + <TextBlock Text="{Binding DayNumber}" Margin="6,4,0,0" + HorizontalAlignment="Left" VerticalAlignment="Top" FontSize="12" + Opacity="{Binding NumberOpacity}"> + <TextBlock.Style> + <Style TargetType="TextBlock"> + <Setter Property="Foreground" Value="{DynamicResource ForegroundBrush}"/> + <Style.Triggers> + <DataTrigger Binding="{Binding IsInDisplayMonth}" Value="False"> + <Setter Property="Foreground" Value="{DynamicResource ForegroundMutedBrush}"/> + </DataTrigger> + </Style.Triggers> + </Style> + </TextBlock.Style> + </TextBlock> + + <!-- Band label, bottom-right, only for in-month days with data. --> + <TextBlock Text="{Binding BandLabel}" Margin="0,0,6,4" + HorizontalAlignment="Right" VerticalAlignment="Bottom" FontSize="10" + Foreground="{DynamicResource ForegroundDimBrush}"/> + </Grid> + </Border> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + + <!-- + Row 3: the day-detail panel. Shown when a day is clicked (replacing the old one-row Daily Summary + popup): a band-colored header, the "why this day is <band>" reasons that drove the band, a + key-metrics line, and drill buttons that jump to that day's Deadlocks / Blocking / Top Queries. + The reasons / metrics / which buttons appear are all computed by the shared DailyHealthBandCalculator; + clicking a drill button raises DayDrillRequested for the host to scope + navigate. All colors are + theme DynamicResources so the panel themes with its host. + --> + <Border Grid.Row="3" x:Name="DayDetailPanel" Visibility="Collapsed" Margin="2,10,2,2" + CornerRadius="6" BorderThickness="1" + BorderBrush="{DynamicResource BorderBrush}" + Background="{DynamicResource BackgroundLighterBrush}"> + <StackPanel Margin="14,10"> + <!-- Header: "<Weekday, Mon D> — <BAND>" (band-colored via SetResourceReference in code-behind). --> + <TextBlock x:Name="DayDetailHeader" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,8"/> + + <!-- Why this day is <band> --> + <TextBlock x:Name="DayDetailWhyLabel" FontSize="12" FontWeight="SemiBold" Margin="0,0,0,3" + Foreground="{DynamicResource ForegroundDimBrush}"/> + <ItemsControl x:Name="DayDetailReasons" Margin="0,0,0,10"> + <ItemsControl.ItemTemplate> + <DataTemplate> + <TextBlock Text="{Binding}" Margin="8,1,0,1" FontSize="12" TextWrapping="Wrap" + Foreground="{DynamicResource ForegroundBrush}"/> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + + <!-- Key metrics line --> + <TextBlock x:Name="DayDetailMetrics" FontSize="12" Margin="0,0,0,10" TextWrapping="Wrap" + Foreground="{DynamicResource ForegroundDimBrush}"/> + + <!-- Drill buttons (each shown only when it makes sense for the day). --> + <StackPanel Orientation="Horizontal"> + <Button x:Name="DeadlocksDrillButton" Content="View Deadlocks" Margin="0,0,8,0" Padding="12,4" + Visibility="Collapsed" Click="DeadlocksDrillButton_Click"/> + <Button x:Name="BlockingDrillButton" Content="View Blocking" Margin="0,0,8,0" Padding="12,4" + Visibility="Collapsed" Click="BlockingDrillButton_Click"/> + <Button x:Name="TopQueriesDrillButton" Content="View Top Queries" Margin="0,0,8,0" Padding="12,4" + Visibility="Collapsed" Click="TopQueriesDrillButton_Click"/> + </StackPanel> + </StackPanel> + </Border> + </Grid> +</UserControl> diff --git a/PerformanceMonitor.Ui/PerformanceCalendar.xaml.cs b/PerformanceMonitor.Ui/PerformanceCalendar.xaml.cs new file mode 100644 index 000000000..82426be54 --- /dev/null +++ b/PerformanceMonitor.Ui/PerformanceCalendar.xaml.cs @@ -0,0 +1,322 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Ui +{ + /// <summary> + /// A navigable month heatmap of composite daily health bands, shared by Lite and the Darling viewer so + /// the Performance Calendar can't drift between them. The host supplies <see cref="Days"/> (one banded + /// <see cref="PerformanceCalendarDay"/> per collected day) and the displayed month; the control lays out + /// a 6x7 grid, washes each in-month cell with its band color, and raises <see cref="MonthChanged"/> when + /// the user navigates (so the host can fetch that month). Clicking a day opens the built-in day-detail + /// panel; its drill buttons raise <see cref="DayDrillRequested"/> so the host can scope + navigate. + /// </summary> + public partial class PerformanceCalendar : UserControl + { + private const int WeeksShown = 6; + private const int DaysPerWeek = 7; + + public PerformanceCalendar() + { + InitializeComponent(); + BuildWeekdayHeaders(); + RebuildCells(); + } + + /// <summary>The banded days to render (one per collected day in the displayed month).</summary> + public static readonly DependencyProperty DaysProperty = DependencyProperty.Register( + nameof(Days), typeof(IEnumerable<PerformanceCalendarDay>), typeof(PerformanceCalendar), + new PropertyMetadata(null, OnVisualInputChanged)); + public IEnumerable<PerformanceCalendarDay>? Days + { + get => (IEnumerable<PerformanceCalendarDay>?)GetValue(DaysProperty); + set => SetValue(DaysProperty, value); + } + + /// <summary>The month to display (any day within it; normalized to the first). Defaults to the current + /// UTC month — the calendar operates in UTC day-space to match the collected data (naive-UTC collection times).</summary> + public static readonly DependencyProperty DisplayMonthProperty = DependencyProperty.Register( + nameof(DisplayMonth), typeof(DateTime), typeof(PerformanceCalendar), + new PropertyMetadata(FirstOfMonth(DateTime.UtcNow), OnVisualInputChanged)); + public DateTime DisplayMonth + { + get => (DateTime)GetValue(DisplayMonthProperty); + set => SetValue(DisplayMonthProperty, value); + } + + /// <summary>The currently selected/drilled day, framed with an accent ring. Null when none.</summary> + public static readonly DependencyProperty SelectedDateProperty = DependencyProperty.Register( + nameof(SelectedDate), typeof(DateTime?), typeof(PerformanceCalendar), + new PropertyMetadata(null, OnVisualInputChanged)); + public DateTime? SelectedDate + { + get => (DateTime?)GetValue(SelectedDateProperty); + set => SetValue(SelectedDateProperty, value); + } + + /// <summary>Raised when the user navigates to a different month (prev / next / Today), carrying the new month's first day.</summary> + public event EventHandler<PerformanceCalendarMonthEventArgs>? MonthChanged; + + /// <summary>Raised when the user clicks a drill button in the day-detail panel (View Deadlocks / + /// Blocking / Top Queries), carrying the day + which grid to jump to. The host scopes its + /// toolbar to that day's window and switches to the target tab.</summary> + public event EventHandler<PerformanceCalendarDrillEventArgs>? DayDrillRequested; + + /// <summary>The weekday column headers in the current culture's week order.</summary> + public ObservableCollection<string> WeekdayHeaders { get; } = new(); + + /// <summary>The 42 rendered day cells (6 weeks x 7 days).</summary> + public ObservableCollection<PerformanceCalendarCell> Cells { get; } = new(); + + /// <summary>The supplied days indexed by date, for O(1) day-detail-panel lookup on click. Rebuilt + /// alongside the cells whenever <see cref="Days"/> changes.</summary> + private Dictionary<DateTime, PerformanceCalendarDay> _daysByDate = new(); + + /// <summary>The date the day-detail panel is currently showing (drives the drill buttons). Null when hidden.</summary> + private DateTime? _detailDate; + + private static DateTime FirstOfMonth(DateTime value) => new(value.Year, value.Month, 1); + + private static void OnVisualInputChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + ((PerformanceCalendar)d).RebuildCells(); + } + + private void BuildWeekdayHeaders() + { + WeekdayHeaders.Clear(); + var culture = CultureInfo.CurrentCulture; + var firstDay = (int)culture.DateTimeFormat.FirstDayOfWeek; + var names = culture.DateTimeFormat.AbbreviatedDayNames; // index 0 = Sunday + for (var i = 0; i < DaysPerWeek; i++) + { + WeekdayHeaders.Add(names[(firstDay + i) % DaysPerWeek]); + } + } + + private void RebuildCells() + { + // MonthLabel/Cells may be referenced before the template is applied during construction. + var month = FirstOfMonth(DisplayMonth); + if (MonthLabel != null) + { + MonthLabel.Text = month.ToString("MMMM yyyy", CultureInfo.CurrentCulture); + } + + // Index the supplied days by date for O(1) lookup (cells + day-detail panel). + var byDate = new Dictionary<DateTime, PerformanceCalendarDay>(); + if (Days != null) + { + foreach (var day in Days) + { + byDate[day.Date.Date] = day; + } + } + _daysByDate = byDate; + + var today = DateTime.UtcNow.Date; // calendar days are UTC-bucketed; highlight the UTC "today" + var selected = SelectedDate?.Date; + + // The grid starts on the first day-of-week on or before the 1st of the month. + var firstDayOfWeek = (int)CultureInfo.CurrentCulture.DateTimeFormat.FirstDayOfWeek; + var offset = ((int)month.DayOfWeek - firstDayOfWeek + DaysPerWeek) % DaysPerWeek; + var gridStart = month.AddDays(-offset); + + Cells.Clear(); + for (var i = 0; i < WeeksShown * DaysPerWeek; i++) + { + var date = gridStart.AddDays(i); + var inMonth = date.Year == month.Year && date.Month == month.Month; + + var band = DailyHealthBand.NoData; + var tooltip = string.Empty; + if (inMonth && byDate.TryGetValue(date, out var day)) + { + band = day.Band; + tooltip = day.Tooltip; + } + else if (inMonth) + { + tooltip = "No data collected."; + } + + Cells.Add(new PerformanceCalendarCell( + date, + inMonth, + isToday: date == today, + isSelected: selected.HasValue && date == selected.Value, + band, + tooltip)); + } + + UpdateDayDetailPanel(); + } + + private void PrevMonthButton_Click(object sender, RoutedEventArgs e) => + NavigateToMonth(FirstOfMonth(DisplayMonth).AddMonths(-1)); + + private void NextMonthButton_Click(object sender, RoutedEventArgs e) => + NavigateToMonth(FirstOfMonth(DisplayMonth).AddMonths(1)); + + private void TodayButton_Click(object sender, RoutedEventArgs e) => + NavigateToMonth(FirstOfMonth(DateTime.UtcNow)); + + private void NavigateToMonth(DateTime month) + { + DisplayMonth = month; // triggers RebuildCells via the DP callback + MonthChanged?.Invoke(this, new PerformanceCalendarMonthEventArgs(month)); + } + + private void Cell_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + if (sender is FrameworkElement { DataContext: PerformanceCalendarCell cell } && cell.IsInDisplayMonth) + { + // Setting SelectedDate reframes the accent ring and (via the DP callback -> RebuildCells -> + // UpdateDayDetailPanel) shows the day-detail panel for this day. + SelectedDate = cell.Date; + } + } + + /// <summary> + /// (Re)populates the day-detail panel for <see cref="SelectedDate"/>: a band-colored header, the + /// "why this day is <band>" reasons, a key-metrics line, and the drill buttons that make sense for + /// the day — all from the shared <see cref="DailyHealthBandCalculator"/>. Hidden when no day is selected + /// or the selection isn't in the displayed month (e.g. after navigating to another month). A selected + /// day that had no collection (absent from <see cref="Days"/>) still shows a No-Data panel. + /// </summary> + private void UpdateDayDetailPanel() + { + if (DayDetailPanel == null) + { + return; // template not applied yet (early construction) + } + + var selected = SelectedDate?.Date; + var month = FirstOfMonth(DisplayMonth); + var inMonth = selected.HasValue && selected.Value.Year == month.Year && selected.Value.Month == month.Month; + if (!inMonth) + { + DayDetailPanel.Visibility = Visibility.Collapsed; + _detailDate = null; + return; + } + + _detailDate = selected!.Value; + + // A day absent from the supplied set had no collection -> render it as a No-Data day. + _daysByDate.TryGetValue(selected.Value, out var day); + var band = day?.Band ?? DailyHealthBand.NoData; + var signals = day?.Signals ?? default; // default => HasData == false + var hasData = signals.HasData; + var bandLabel = DailyHealthBandCalculator.Label(band); + + DayDetailHeader.Text = $"{selected.Value:dddd, MMM d} — {bandLabel}"; + DayDetailHeader.SetResourceReference(TextBlock.ForegroundProperty, DailyHealthBandCalculator.BrushKey(band)); + + DayDetailWhyLabel.Text = $"Why this day is {bandLabel}:"; + DayDetailReasons.ItemsSource = DailyHealthBandCalculator.BuildReasons(signals, day?.PeakBlockMs ?? 0); + + // Key metrics only make sense for a collected day. + if (hasData) + { + DayDetailMetrics.Text = DailyHealthBandCalculator.BuildKeyMetricsLine( + day!.TopWaitType, signals.HighCpuEvents, day.TotalWaitSeconds, day.UniqueQueries); + DayDetailMetrics.Visibility = Visibility.Visible; + } + else + { + DayDetailMetrics.Visibility = Visibility.Collapsed; + } + + var drills = DailyHealthBandCalculator.AvailableDrills(signals); + DeadlocksDrillButton.Visibility = drills.Contains(DayDrillTarget.Deadlocks) ? Visibility.Visible : Visibility.Collapsed; + BlockingDrillButton.Visibility = drills.Contains(DayDrillTarget.Blocking) ? Visibility.Visible : Visibility.Collapsed; + TopQueriesDrillButton.Visibility = drills.Contains(DayDrillTarget.TopQueries) ? Visibility.Visible : Visibility.Collapsed; + + DayDetailPanel.Visibility = Visibility.Visible; + } + + private void DeadlocksDrillButton_Click(object sender, RoutedEventArgs e) => RaiseDrill(DayDrillTarget.Deadlocks); + + private void BlockingDrillButton_Click(object sender, RoutedEventArgs e) => RaiseDrill(DayDrillTarget.Blocking); + + private void TopQueriesDrillButton_Click(object sender, RoutedEventArgs e) => RaiseDrill(DayDrillTarget.TopQueries); + + private void RaiseDrill(DayDrillTarget target) + { + if (_detailDate is DateTime date) + { + DayDrillRequested?.Invoke(this, new PerformanceCalendarDrillEventArgs(date, target)); + } + } + } + + /// <summary> + /// The render model for one calendar cell. Immutable; the control rebuilds the whole set on any change, + /// so no change notification is needed. The presentation opacities/label are derived from the band here + /// (rather than in XAML) so the cell template stays declarative. + /// </summary> + public sealed class PerformanceCalendarCell + { + public PerformanceCalendarCell( + DateTime date, bool isInDisplayMonth, bool isToday, bool isSelected, + DailyHealthBand band, string tooltip) + { + Date = date; + IsInDisplayMonth = isInDisplayMonth; + IsToday = isToday; + IsSelected = isSelected; + Band = band; + Tooltip = tooltip; + DayNumber = date.Day.ToString(CultureInfo.CurrentCulture); + + if (!isInDisplayMonth) + { + WashOpacity = 0.0; + NumberOpacity = 0.45; + } + else if (band == DailyHealthBand.NoData) + { + WashOpacity = 0.14; + NumberOpacity = 0.55; + } + else + { + WashOpacity = 0.55; + NumberOpacity = 1.0; + } + + // A textual band label on non-healthy in-month days aids at-a-glance scanning and color-blind + // legibility; healthy / no-data cells stay uncluttered (color and emptiness carry the meaning). + BandLabel = isInDisplayMonth && (band == DailyHealthBand.Warning || band == DailyHealthBand.Critical) + ? DailyHealthBandCalculator.Label(band) + : string.Empty; + } + + public DateTime Date { get; } + public bool IsInDisplayMonth { get; } + public bool IsToday { get; } + public bool IsSelected { get; } + public DailyHealthBand Band { get; } + public string Tooltip { get; } + public string DayNumber { get; } + public string BandLabel { get; } + public double WashOpacity { get; } + public double NumberOpacity { get; } + } +} diff --git a/PerformanceMonitor.Ui/PerformanceCalendarDay.cs b/PerformanceMonitor.Ui/PerformanceCalendarDay.cs new file mode 100644 index 000000000..07e780e5d --- /dev/null +++ b/PerformanceMonitor.Ui/PerformanceCalendarDay.cs @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Ui +{ + /// <summary> + /// One day's worth of banded data supplied to <see cref="PerformanceCalendar"/>. Apps build one of + /// these per day that had any collection (from their own <c>DailySummaryRow</c> range read), setting the + /// <see cref="Band"/> they already computed via <see cref="DailyHealthBandCalculator"/>, a hover + /// <see cref="Tooltip"/>, and the <see cref="Signals"/> + key metrics the shared day-detail panel renders + /// on click. Days absent from the supplied set render as <see cref="DailyHealthBand.NoData"/> (no panel). + /// </summary> + public sealed class PerformanceCalendarDay + { + /// <summary>The calendar date (date component only; time ignored).</summary> + public DateTime Date { get; init; } + + /// <summary>The composite health band that colors the cell.</summary> + public DailyHealthBand Band { get; init; } + + /// <summary>Multi-line hover text summarizing the day's signals (see <see cref="DailyHealthBandCalculator.Describe"/>).</summary> + public string Tooltip { get; init; } = string.Empty; + + /// <summary>The day's rolled-up signal counts — drives the day-detail panel's "why this day is + /// <band>" reasons and which drill buttons it offers (<see cref="DailyHealthBandCalculator.BuildReasons"/> / + /// <see cref="DailyHealthBandCalculator.AvailableDrills"/>).</summary> + public DailyHealthSignals Signals { get; init; } + + /// <summary>The day's top wait type (for the panel's key-metrics line).</summary> + public string TopWaitType { get; init; } = string.Empty; + + /// <summary>The day's total wait, in seconds (for the panel's key-metrics line).</summary> + public decimal TotalWaitSeconds { get; init; } + + /// <summary>The day's distinct-query count (for the panel's key-metrics line).</summary> + public long UniqueQueries { get; init; } + + /// <summary>The day's peak/max block wait in ms (0 when unknown), shown on the panel's blocking reason.</summary> + public long PeakBlockMs { get; init; } + } + + /// <summary>Event args carrying a day-detail drill request: the clicked day plus which grid to jump to. + /// The shared panel raises this; each host scopes its toolbar to the day and switches to the target tab.</summary> + public sealed class PerformanceCalendarDrillEventArgs : EventArgs + { + public PerformanceCalendarDrillEventArgs(DateTime date, DayDrillTarget target) + { + Date = date; + Target = target; + } + + /// <summary>The day to scope the drill to (date component only).</summary> + public DateTime Date { get; } + + /// <summary>Which grid the user asked to jump to for that day.</summary> + public DayDrillTarget Target { get; } + } + + /// <summary>Event args carrying the month the calendar wants data for (its first day, at midnight).</summary> + public sealed class PerformanceCalendarMonthEventArgs : EventArgs + { + public PerformanceCalendarMonthEventArgs(DateTime month) + { + Month = month; + } + + /// <summary>The first day of the month now being displayed.</summary> + public DateTime Month { get; } + } +} diff --git a/PerformanceMonitor.Ui/PerformanceMonitor.Ui.csproj b/PerformanceMonitor.Ui/PerformanceMonitor.Ui.csproj index bb49a8af6..75b051795 100644 --- a/PerformanceMonitor.Ui/PerformanceMonitor.Ui.csproj +++ b/PerformanceMonitor.Ui/PerformanceMonitor.Ui.csproj @@ -31,7 +31,9 @@ <ItemGroup> <InternalsVisibleTo Include="PerformanceMonitorLite" /> <InternalsVisibleTo Include="PerformanceMonitorDashboard" /> + <InternalsVisibleTo Include="PerformanceMonitor.Darling.Viewer" /> <InternalsVisibleTo Include="Lite.Tests" /> <InternalsVisibleTo Include="Dashboard.Tests" /> + <InternalsVisibleTo Include="Darling.Tests" /> </ItemGroup> </Project> diff --git a/PerformanceMonitor.Ui/QueryStatsComparisonItem.cs b/PerformanceMonitor.Ui/QueryStatsComparisonItem.cs index ab0e94425..6d94efa7d 100644 --- a/PerformanceMonitor.Ui/QueryStatsComparisonItem.cs +++ b/PerformanceMonitor.Ui/QueryStatsComparisonItem.cs @@ -10,6 +10,11 @@ namespace PerformanceMonitor.Ui; public class QueryStatsComparisonItem : ComparisonItemBase { + /// <summary>Gates "Get Actual Plan (re-run)" in Lite's query-grid plan menu — the re-run executes this row's + /// query text. The sibling ProcedureStatsComparisonItem deliberately has no such property: the re-run handler + /// has no case for it, so its menu binding falls back to Collapsed and the verb is hidden there.</summary> + public bool CanGetActualPlan => !string.IsNullOrEmpty(QueryText); + public string QueryHash { get; set; } = ""; public string? ObjectName { get; set; } public string? SchemaName { get; set; } diff --git a/PerformanceMonitor.Ui/SelectableItem.cs b/PerformanceMonitor.Ui/SelectableItem.cs new file mode 100644 index 000000000..1a23e63cd --- /dev/null +++ b/PerformanceMonitor.Ui/SelectableItem.cs @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +namespace PerformanceMonitor.Ui; + +/// <summary> +/// One row of a picker's checkbox list: a display name and whether it is checked. Mutable -- the checkbox +/// two-way-binds <see cref="IsSelected"/>, and the checked-to-top reorder rewrites the list. Shared by Lite's +/// and Darling's wait-type / perfmon / memory-clerk / database pickers. (The deprecated Full Dashboard keeps +/// its own copy in Dashboard/Models/SelectableItem.cs and is not developed further.) +/// </summary> +public sealed class SelectableItem +{ + public string DisplayName { get; set; } = ""; + public bool IsSelected { get; set; } +} diff --git a/PerformanceMonitor.Ui/ServerOverviewSort.cs b/PerformanceMonitor.Ui/ServerOverviewSort.cs new file mode 100644 index 000000000..d9eecd0d2 --- /dev/null +++ b/PerformanceMonitor.Ui/ServerOverviewSort.cs @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace PerformanceMonitor.Ui; + +/// <summary>NOC Overview tile sort modes, shared by Lite and the Darling viewer.</summary> +public enum ServerOverviewSortMode +{ + /// <summary>CPU% descending (default): busiest first, no-sample servers last.</summary> + Cpu = 0, + /// <summary>Display name ascending (case-insensitive).</summary> + Name = 1, +} + +/// <summary> +/// Single source of truth for ordering the Overview server tiles in both apps. Pure, deterministic, +/// and a TOTAL order, so re-applying it every refresh on the same data yields the identical sequence — +/// tiles never jitter. Parameterised by selectors because each app binds its own unrelated +/// ServerSummaryItem type; there is deliberately no shared row interface. +/// </summary> +public static class ServerOverviewSort +{ + public const ServerOverviewSortMode Default = ServerOverviewSortMode.Cpu; + + /// <summary>Persisted token for a mode — the enum member name ("Cpu" / "Name").</summary> + public static string ToToken(ServerOverviewSortMode mode) => mode.ToString(); + + /// <summary>Tolerant parse: case-insensitive; null/empty/unrecognised -> Default (Cpu).</summary> + public static ServerOverviewSortMode ParseMode(string? token) => + !string.IsNullOrWhiteSpace(token) + && Enum.TryParse<ServerOverviewSortMode>(token, ignoreCase: true, out var mode) + && Enum.IsDefined(mode) + ? mode + : Default; + + public static List<T> Order<T>( + IEnumerable<T> items, + ServerOverviewSortMode mode, + Func<T, double?> cpuSelector, + Func<T, string?> nameSelector, + Func<T, int> idSelector) + { + IOrderedEnumerable<T> ordered = + mode == ServerOverviewSortMode.Name + ? items.OrderBy(x => nameSelector(x) ?? string.Empty, StringComparer.OrdinalIgnoreCase) + : items + .OrderByDescending(x => cpuSelector(x).HasValue) // no-sample sinks last + .ThenByDescending(x => cpuSelector(x) ?? 0.0) // CPU% descending + .ThenBy(x => nameSelector(x) ?? string.Empty, StringComparer.OrdinalIgnoreCase); + + return ordered.ThenBy(idSelector).ToList(); // final unique key -> total order + } +} diff --git a/PerformanceMonitor.Ui/SessionStatsChartRenderer.cs b/PerformanceMonitor.Ui/SessionStatsChartRenderer.cs new file mode 100644 index 000000000..c6b17dcfa --- /dev/null +++ b/PerformanceMonitor.Ui/SessionStatsChartRenderer.cs @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Ui; + +/// <summary> +/// Shared renderer for the Session Stats tab's server-wide session-status trend chart. The ScottPlot chart BODY +/// was byte-identical between Lite's <c>ServerTab.SessionStats.cs</c> and Darling's +/// <c>ViewerServerTab.SessionStats.cs</c> apart from the display-time projection, so it lives here once: it plots +/// the seven fixed status series (Total / Running / Sleeping / Background / Dormant / Idle >30m / Waiting for +/// Memory), SKIPPING any series that is all-zero across the window, each on its own shared +/// <see cref="ChartPalette"/> <c>Session*</c> key, Y-floored at 0 with a bottom legend. Each app constructs one +/// with its own projection (Lite adds its settable UTC offset; Darling uses <c>ViewerTimeHelper.ForDisplay</c>) +/// and passes the chart + hover + pre-computed display-time x-bounds; the store-specific data read, the chart +/// init/dispose, and the summary strip (the non-chartable Top App / Top Host / Databases values, shaped by +/// <see cref="SessionStatsSummary"/>) stay per-app. INTERNAL because it takes the internal +/// <see cref="ChartHoverHelper"/> (both apps see it via Ui's InternalsVisibleTo). (The deprecated Full Dashboard +/// keeps its own copy.) +/// </summary> +internal sealed class SessionStatsChartRenderer +{ + /// <summary>One chart series: its legend label, its shared-palette color key, and the accessor that pulls its + /// status count out of an <see cref="ISessionStatsPoint"/>. Pure data so the mapping is unit-testable without + /// a WpfPlot; the render loop and the parity pins share this single source of truth.</summary> + internal readonly record struct SessionSeriesSpec(string Legend, string PaletteKey, Func<ISessionStatsPoint, double> Value); + + /// <summary>The seven session-status series in the Dashboard's order (Total first, then the status + /// breakdown), each pinned to the shared <c>Session*</c> palette key the Dashboard chart uses.</summary> + internal static IReadOnlyList<SessionSeriesSpec> SessionSeriesSpecs { get; } = new[] + { + new SessionSeriesSpec("Total", "SessionTotal", p => p.TotalSessions), + new SessionSeriesSpec("Running", "SessionRunning", p => p.RunningSessions), + new SessionSeriesSpec("Sleeping", "SessionSleeping", p => p.SleepingSessions), + new SessionSeriesSpec("Background", "SessionBackground", p => p.BackgroundSessions), + new SessionSeriesSpec("Dormant", "SessionDormant", p => p.DormantSessions), + new SessionSeriesSpec("Idle >30m", "SessionIdle", p => p.IdleSessionsOver30Min), + new SessionSeriesSpec("Waiting for Memory", "SessionWaiting", p => p.SessionsWaitingForMemory), + }; + + private readonly ChartRenderHelper _chartHelper; + private readonly Func<DateTime, DateTime> _project; + + internal SessionStatsChartRenderer(ChartRenderHelper chartHelper, Func<DateTime, DateTime> project) + { + _chartHelper = chartHelper; + _project = project; + } + + /// <summary> + /// Renders the session-status trend: one scatter per status that is non-zero somewhere in the window (each on + /// its fixed palette key), the date axis bound to the pre-computed <paramref name="xMin"/>/<paramref + /// name="xMax"/> display-time bounds, Y floored at 0 with legend padding, and a bottom legend. Chart-only — + /// the caller owns the summary strip from the same ordered data. + /// </summary> + internal void Render( + ScottPlot.WPF.WpfPlot chart, ChartHoverHelper? hover, IReadOnlyList<ISessionStatsPoint> data, + double xMin, double xMax) + { + _chartHelper.ClearChart(chart); + hover?.Clear(); + ChartStyle.ApplyThemeToChart(chart); + + var ordered = data.OrderBy(d => d.CollectionTime).ToList(); + + double globalMax = 0; + if (ordered.Count > 0) + { + var times = ordered.Select(d => _project(d.CollectionTime).ToOADate()).ToArray(); + + foreach (var spec in SessionSeriesSpecs) + { + var values = ordered.Select(spec.Value).ToArray(); + + /* Mirror the Dashboard: only draw a series that is non-zero somewhere in the window, so + all-zero states (often Background / Dormant / Waiting for Memory) stay out of the legend. */ + if (!values.Any(v => v > 0)) + { + continue; + } + + var plot = chart.Plot.Add.Scatter(times, values); + plot.LegendText = spec.Legend; + plot.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor(spec.PaletteKey)); + ChartStyle.StyleScatter(plot); + hover?.Add(plot, spec.Legend); + + globalMax = Math.Max(globalMax, values.Max()); + } + } + + chart.Plot.Axes.DateTimeTicksBottomDateChange(); + chart.Plot.Axes.SetLimitsX(xMin, xMax); + ChartStyle.ReapplyAxisColors(chart); + chart.Plot.YLabel("Session Count"); + ChartStyle.SetChartYLimitsWithLegendPadding(chart, 0, globalMax > 0 ? globalMax : 10); + _chartHelper.ShowChartLegend(chart); + chart.Refresh(); + } +} diff --git a/PerformanceMonitor.Ui/StandalonePlanViewerController.cs b/PerformanceMonitor.Ui/StandalonePlanViewerController.cs new file mode 100644 index 000000000..568389cc0 --- /dev/null +++ b/PerformanceMonitor.Ui/StandalonePlanViewerController.cs @@ -0,0 +1,421 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; + +namespace PerformanceMonitor.Ui; + +/// <summary> +/// Standalone Plan Viewer tab management, shared by Lite's MainWindow and the Darling viewer's MainWindow +/// (front-end SHELL COLLAPSE - this was ~300 near-verbatim lines duplicated in each app's +/// <c>MainWindow.PlanViewer.cs</c>). Owns the inner plan <see cref="TabControl"/>'s lifecycle: the "+" +/// add-tab sentinel, empty "New Plan" sub-tabs and their open-file / paste / drag-drop entry points, +/// rendering plan XML through the shared <see cref="PlanViewerControl"/>, unique sub-tab labelling, active +/// sub-tab tracking, and teardown. +/// +/// The host MainWindow keeps ONLY the app-specific pieces: revealing/hiding the outer server-tabs container +/// (Lite's <c>ServerTabControl</c> vs the viewer's <c>MainTabs</c>) on open/close, and forwarding its +/// XAML-wired drag-over / drop / key-down handlers to <see cref="HandleDragOver"/> / <see cref="HandleDrop"/> +/// / <see cref="HandleKeyDown"/>. Resources (<c>ForegroundMutedBrush</c>, <c>TabCloseButton</c>) resolve +/// through the injected tab control's logical tree, so each app's own theme dictionaries still apply. +/// </summary> +public sealed class StandalonePlanViewerController +{ + /* Shared sentinel VALUE - the "+" tab's Tag. Both apps used this exact string; keep it identical so a + plan captured with either app's tab layout is indistinguishable. */ + private const string PlanAddTabId = "__PLAN_ADD_TAB__"; + + private readonly TabControl _planTabControl; + private bool _initialized; + + public StandalonePlanViewerController(TabControl planTabControl) + { + _planTabControl = planTabControl ?? throw new ArgumentNullException(nameof(planTabControl)); + } + + /// <summary>Adds the trailing "+" tab and its selection handler exactly once.</summary> + public void EnsureInitialized() + { + if (_initialized) return; + _initialized = true; + + // "+" tab at the end + var addTabHeader = new TextBlock + { + Text = "+", + FontSize = 14, + FontWeight = FontWeights.Bold, + VerticalAlignment = VerticalAlignment.Center, + ToolTip = "Open a new plan sub-tab" + }; + var addTab = new TabItem + { + Header = addTabHeader, + Tag = PlanAddTabId, + Content = new Grid() + }; + _planTabControl.Items.Add(addTab); + + _planTabControl.SelectionChanged += (_, _) => + { + if (_planTabControl.SelectedItem is TabItem { Tag: string t } && t == PlanAddTabId) + { + var newSub = AddNewEmptyPlanSubTab(); + _planTabControl.SelectedItem = newSub; + } + }; + } + + /// <summary> + /// Adds a new empty "New Plan" sub-tab to the inner plan TabControl and selects it. + /// Returns the newly created sub-tab. + /// </summary> + public TabItem AddNewEmptyPlanSubTab() + { + EnsureInitialized(); + + // --- Empty state layer --- + var emptyState = new Grid(); + var dashedRect = new System.Windows.Shapes.Rectangle + { + Margin = new Thickness(24), + Stroke = (System.Windows.Media.Brush)_planTabControl.FindResource("ForegroundMutedBrush"), + StrokeThickness = 1.5, + StrokeDashArray = new System.Windows.Media.DoubleCollection { 6, 4 }, + RadiusX = 10, RadiusY = 10, + Opacity = 0.25 + }; + var emptyStack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; + emptyStack.Children.Add(new TextBlock + { + Text = "\uE896", + FontFamily = new System.Windows.Media.FontFamily("Segoe MDL2 Assets"), + FontSize = 52, + HorizontalAlignment = HorizontalAlignment.Center, + Foreground = (System.Windows.Media.Brush)_planTabControl.FindResource("ForegroundMutedBrush"), + Opacity = 0.45, + Margin = new Thickness(0, 0, 0, 12) + }); + emptyStack.Children.Add(new TextBlock + { + Text = "New Plan", + FontSize = 20, + FontWeight = FontWeights.Light, + Foreground = (System.Windows.Media.Brush)_planTabControl.FindResource("ForegroundMutedBrush"), + HorizontalAlignment = HorizontalAlignment.Center + }); + emptyStack.Children.Add(new TextBlock + { + Text = "Open or paste execution plan XML to render it", + FontSize = 13, + Foreground = (System.Windows.Media.Brush)_planTabControl.FindResource("ForegroundMutedBrush"), + HorizontalAlignment = HorizontalAlignment.Center, + Margin = new Thickness(0, 8, 0, 0) + }); + var btnPanel = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(0, 20, 0, 0) }; + var openBtn = new Button { Content = "Open .sqlplan File", Height = 28, Padding = new Thickness(12, 0, 12, 0), ToolTip = "Open a .sqlplan or .xml file from disk" }; + var pasteBtn = new Button { Content = "Paste XML", Height = 28, Padding = new Thickness(12, 0, 12, 0), Margin = new Thickness(8, 0, 0, 0), ToolTip = "Paste execution plan XML to render it (or use Ctrl+V)" }; + btnPanel.Children.Add(openBtn); + btnPanel.Children.Add(pasteBtn); + emptyStack.Children.Add(btnPanel); + emptyStack.Children.Add(new TextBlock + { + Text = "or drag & drop a .sqlplan file anywhere in this area", + FontSize = 11, + Foreground = (System.Windows.Media.Brush)_planTabControl.FindResource("ForegroundMutedBrush"), + HorizontalAlignment = HorizontalAlignment.Center, + Margin = new Thickness(0, 12, 0, 0) + }); + emptyState.Children.Add(dashedRect); + emptyState.Children.Add(emptyStack); + + // --- Viewer layer (hidden until a plan is loaded) --- + var viewer = new PlanViewerControl + { + Visibility = Visibility.Collapsed + }; + + // Sub-tab content grid: index 0 = emptyState, index 1 = viewer + var subTabContent = new Grid(); + subTabContent.Children.Add(emptyState); + subTabContent.Children.Add(viewer); + + // --- Sub-tab header: label + close button --- + var initialLabel = GetUniqueSubTabLabel("New Plan"); + var labelBlock = new TextBlock + { + Text = initialLabel, + VerticalAlignment = VerticalAlignment.Center, + ToolTip = initialLabel + }; + var subCloseBtn = new Button { Style = (Style)_planTabControl.FindResource("TabCloseButton") }; + var subTabHeader = new StackPanel { Orientation = Orientation.Horizontal }; + subTabHeader.Children.Add(labelBlock); + subTabHeader.Children.Add(subCloseBtn); + + var subTab = new TabItem { Header = subTabHeader, Content = subTabContent }; + + subCloseBtn.Tag = subTab; + subCloseBtn.Click += (_, _) => + { + (subTabContent.Children[1] as PlanViewerControl)?.Cleanup(); + _planTabControl.Items.Remove(subTab); + // If only the "+" tab remains, open a fresh empty sub-tab + if (_planTabControl.Items.Count == 1 && + _planTabControl.Items[0] is TabItem { Tag: string t2 } && t2 == PlanAddTabId) + { + AddNewEmptyPlanSubTab(); + } + }; + + // Wire per-sub-tab buttons + openBtn.Click += (_, _) => + { + var dialog = new Microsoft.Win32.OpenFileDialog + { + Filter = "SQL Plan Files (*.sqlplan)|*.sqlplan|XML Files (*.xml)|*.xml|All Files (*.*)|*.*", + DefaultExt = ".sqlplan", + Multiselect = true + }; + if (dialog.ShowDialog() != true) return; + var isFirst = true; + foreach (var fileName in dialog.FileNames) + { + try + { + var xml = System.IO.File.ReadAllText(fileName); + var targetTab = isFirst ? subTab : AddNewEmptyPlanSubTab(); + LoadPlanIntoSubTab(targetTab, xml, System.IO.Path.GetFileName(fileName)); + } + catch (Exception ex) + { + MessageBox.Show($"Failed to open file:\n\n{ex.Message}", "Error", + MessageBoxButton.OK, MessageBoxImage.Error); + } + isFirst = false; + } + }; + + pasteBtn.Click += (_, _) => + { + var xml = Clipboard.GetText(); + if (!string.IsNullOrWhiteSpace(xml)) + { + LoadPlanIntoSubTab(subTab, xml, "Pasted Plan"); + return; + } + MessageBox.Show("The clipboard does not contain any text.", "Paste Plan XML", + MessageBoxButton.OK, MessageBoxImage.Information); + }; + + // Insert before "+" tab + var addTabIndex = -1; + for (var i = 0; i < _planTabControl.Items.Count; i++) + { + if (_planTabControl.Items[i] is TabItem { Tag: string t3 } && t3 == PlanAddTabId) + { + addTabIndex = i; + break; + } + } + if (addTabIndex >= 0) + _planTabControl.Items.Insert(addTabIndex, subTab); + else + _planTabControl.Items.Add(subTab); + + _planTabControl.SelectedItem = subTab; + return subTab; + } + + /// <summary> + /// Loads plan XML into an existing sub-tab (replacing whatever was there before). + /// </summary> + public async void LoadPlanIntoSubTab(TabItem subTab, string planXml, string label, string? queryText = null) + { + if (subTab.Content is not Grid subTabContent) return; + if (subTabContent.Children.Count < 2) return; + + var emptyState = subTabContent.Children[0] as FrameworkElement; + var viewer = subTabContent.Children[1] as PlanViewerControl; + if (viewer == null) return; + + try + { + /* LoadPlan parses+analyzes off the UI thread; XmlException replaces the redundant + up-front XDocument.Parse validation. */ + await viewer.LoadPlan(planXml, label, queryText); + } + catch (System.Xml.XmlException ex) + { + MessageBox.Show( + $"The plan XML is not valid:\n\n{ex.Message}", + "Invalid Plan XML", + MessageBoxButton.OK, + MessageBoxImage.Warning); + return; + } + catch (Exception ex) + { + MessageBox.Show( + $"Failed to load the execution plan:\n\n{ex.Message}", + "Plan Load Error", + MessageBoxButton.OK, + MessageBoxImage.Warning); + return; + } + emptyState!.Visibility = Visibility.Collapsed; + viewer.Visibility = Visibility.Visible; + + var uniqueLabel = GetUniqueSubTabLabel(label); + if (subTab.Header is StackPanel headerPanel && + headerPanel.Children[0] is TextBlock headerLabel) + { + headerLabel.Text = uniqueLabel.Length > 30 ? uniqueLabel[..30] + "\u2026" : uniqueLabel; + headerLabel.ToolTip = uniqueLabel; + } + } + + /// <summary> + /// Returns a label that is unique among current inner plan sub-tab headers. + /// If <paramref name="baseLabel"/> is already taken, appends " (1)", " (2)", \u2026 + /// </summary> + private string GetUniqueSubTabLabel(string baseLabel) + { + var existing = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + foreach (var item in _planTabControl.Items) + { + if (item is TabItem { Tag: string t } && t == PlanAddTabId) continue; + if (item is TabItem subTab && + subTab.Header is StackPanel sp && + sp.Children[0] is TextBlock tb) + existing.Add(tb.ToolTip as string ?? tb.Text); + } + if (!existing.Contains(baseLabel)) return baseLabel; + var counter = 1; + string candidate; + do { candidate = $"{baseLabel} ({counter++})"; } + while (existing.Contains(candidate)); + return candidate; + } + + /// <summary> + /// Returns the currently active real plan sub-tab (skips the "+" tab). + /// </summary> + private TabItem? GetActivePlanSubTab() + { + if (_planTabControl.SelectedItem is TabItem { Tag: string t } && t == PlanAddTabId) + return null; + return _planTabControl.SelectedItem as TabItem; + } + + /// <summary>Drag-over handler (forwarded from the host's XAML wire): accept only plan files.</summary> + public void HandleDragOver(DragEventArgs e) + { + if (e.Data.GetDataPresent(DataFormats.FileDrop)) + { + var files = e.Data.GetData(DataFormats.FileDrop) as string[]; + if (files?.Any(IsPlanFile) == true) + { + e.Effects = DragDropEffects.Copy; + e.Handled = true; + return; + } + } + e.Effects = DragDropEffects.None; + e.Handled = true; + } + + /// <summary>Drop handler (forwarded from the host's XAML wire): first file into the active tab, rest into new tabs.</summary> + public void HandleDrop(DragEventArgs e) + { + if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return; + var planFiles = (e.Data.GetData(DataFormats.FileDrop) as string[]) + ?.Where(IsPlanFile).ToArray(); + if (planFiles == null || planFiles.Length == 0) return; + LoadPlanFromFileIntoActiveTab(planFiles[0]); + for (var i = 1; i < planFiles.Length; i++) + { + var newTab = AddNewEmptyPlanSubTab(); + try + { + var xml = System.IO.File.ReadAllText(planFiles[i]); + LoadPlanIntoSubTab(newTab, xml, System.IO.Path.GetFileName(planFiles[i])); + } + catch (Exception ex) + { + MessageBox.Show($"Failed to load plan file:\n{ex.Message}", "Load Error", + MessageBoxButton.OK, MessageBoxImage.Error); + } + } + } + + /// <summary>Key-down handler (forwarded from the host's XAML wire): Ctrl+V pastes plan XML into the active tab.</summary> + public void HandleKeyDown(KeyEventArgs e) + { + if (e.Key == Key.V && + Keyboard.Modifiers == ModifierKeys.Control && + e.OriginalSource is not TextBox) + { + var xml = Clipboard.GetText(); + if (!string.IsNullOrWhiteSpace(xml)) + { + e.Handled = true; + LoadPlanIntoActivePlanSubTab(xml, "Pasted Plan"); + } + } + } + + private void LoadPlanFromFileIntoActiveTab(string path) + { + try + { + var xml = System.IO.File.ReadAllText(path); + LoadPlanIntoActivePlanSubTab(xml, System.IO.Path.GetFileName(path)); + } + catch (Exception ex) + { + MessageBox.Show($"Failed to load plan file:\n{ex.Message}", "Load Error", + MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + private void LoadPlanIntoActivePlanSubTab(string planXml, string label) + { + var activeSubTab = GetActivePlanSubTab(); + if (activeSubTab != null) + LoadPlanIntoSubTab(activeSubTab, planXml, label); + } + + /// <summary> + /// Tears the plan surface down to a fresh state: cleans up every sub-tab's viewer (each is rooted by the + /// static ThemeChanged event, so must be released before the tab control is cleared), empties the tab + /// control, and re-arms <see cref="EnsureInitialized"/> so the next open rebuilds the "+" tab. The host's + /// close handler calls this, then restores its own outer container. + /// </summary> + public void Reset() + { + foreach (var item in _planTabControl.Items) + if (item is TabItem { Content: Grid g } && g.Children.Count > 1 + && g.Children[1] is PlanViewerControl pv) + pv.Cleanup(); + _planTabControl.Items.Clear(); + _initialized = false; + } + + private static bool IsPlanFile(string path) + { + var ext = System.IO.Path.GetExtension(path); + return string.Equals(ext, ".sqlplan", StringComparison.OrdinalIgnoreCase) + || string.Equals(ext, ".xml", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/PerformanceMonitor.Ui/SystemHealthChartRenderer.cs b/PerformanceMonitor.Ui/SystemHealthChartRenderer.cs new file mode 100644 index 000000000..744b01209 --- /dev/null +++ b/PerformanceMonitor.Ui/SystemHealthChartRenderer.cs @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.Ui; + +/// <summary> +/// Shared renderer for the System Events tab's SYSTEM-component counter charts (the Corruption + Contention +/// 2x2 grids). The ScottPlot chart BODIES were byte-identical between Lite's +/// <c>ServerTab.SystemHealthCharts.cs</c> and Darling's <c>ViewerServerTab.SystemHealthCharts.cs</c> apart +/// from the display-time projection, so they live here once. Each app constructs one with its own projection +/// (Lite adds its settable UTC offset; Darling uses <c>ViewerTimeHelper.ForDisplay</c>) and passes each chart +/// + hover; the store-specific data read and the chart-field / hover wiring stay per-app. INTERNAL because it +/// takes the internal <see cref="ChartHoverHelper"/> (both apps see it via Ui's InternalsVisibleTo). (The +/// deprecated Full Dashboard keeps its own copies.) +/// </summary> +internal sealed class SystemHealthChartRenderer +{ + private static readonly string[] SeriesColors = ChartPalette.CyclingPalette.ToArray(); + + private readonly ChartRenderHelper _chartHelper; + private readonly Func<DateTime, DateTime> _project; + + internal SystemHealthChartRenderer(ChartRenderHelper chartHelper, Func<DateTime, DateTime> project) + { + _chartHelper = chartHelper; + _project = project; + } + + /// <summary> + /// One single-series counter chart (Bad Pages / Dump Requests / Access Violations / Write AV / + /// Non-Yielding Tasks / Latch Warnings): a scatter of the selected counter over event time, Y floored at + /// 0, no legend. Empty window shows the centered "No data" text. <paramref name="selector"/> + /// null-coalesces to 0 so a snapshot missing the attribute plots a zero, never a gap. + /// </summary> + internal void RenderCounterChart( + ScottPlot.WPF.WpfPlot chart, ChartHoverHelper? hover, List<SystemHealthRecord> data, + Func<SystemHealthRecord, long?> selector, string label, string colorHex, double xMin, double xMax) + { + _chartHelper.ClearChart(chart); + hover?.Clear(); + ChartStyle.ApplyThemeToChart(chart); + + double max = 0; + if (data.Count == 0) + { + AddNoDataText(chart, xMin, xMax); + } + else + { + var xs = data.Select(d => _project(d.EventTime!.Value).ToOADate()).ToArray(); + var ys = data.Select(d => (double)(selector(d) ?? 0)).ToArray(); + + var scatter = chart.Plot.Add.Scatter(xs, ys); + scatter.Color = ScottPlot.Color.FromHex(colorHex); + ChartStyle.StyleScatter(scatter); + hover?.Add(scatter, label); + + max = ys.Length > 0 ? ys.Max() : 0; + } + + chart.Plot.Axes.DateTimeTicksBottomDateChange(); + ChartStyle.ReapplyAxisColors(chart); + chart.Plot.Axes.SetLimitsX(xMin, xMax); + /* The tiles are borderless (no in-panel title), so the descriptive Y-axis label is the chart's + identifier — the same convention the File I/O charts use ("Read Latency (ms)"). */ + chart.Plot.YLabel(label); + chart.Plot.Axes.SetLimitsY(0, max > 0 ? max * 1.15 : 1); + chart.Refresh(); + } + + /// <summary> + /// Sick Spinlocks by Type: one series per distinct <c>SickSpinlockType</c> (top 5, to bound clutter) + /// plotting the spinlock backoffs over event time, with a bottom legend (backoffs, or 1 when absent). + /// </summary> + internal void RenderSickSpinlocksChart( + ScottPlot.WPF.WpfPlot chart, ChartHoverHelper? hover, List<SystemHealthRecord> data, double xMin, double xMax) + { + _chartHelper.ClearChart(chart); + hover?.Clear(); + ChartStyle.ApplyThemeToChart(chart); + + double max = 0; + if (data.Count == 0) + { + AddNoDataText(chart, xMin, xMax); + } + else + { + var spinlockTypes = data + .Where(d => !string.IsNullOrEmpty(d.SickSpinlockType)) + .Select(d => d.SickSpinlockType) + .Distinct() + .Take(5) + .ToList(); + + int colorIdx = 0; + foreach (var spinlockType in spinlockTypes) + { + var typeData = data.Where(d => d.SickSpinlockType == spinlockType).ToList(); + if (typeData.Count == 0) + continue; + + var xs = typeData.Select(d => _project(d.EventTime!.Value).ToOADate()).ToArray(); + var ys = typeData.Select(d => (double)(d.SpinlockBackoffs ?? 1)).ToArray(); + + var scatter = chart.Plot.Add.Scatter(xs, ys); + scatter.Color = ScottPlot.Color.FromHex(SeriesColors[colorIdx % SeriesColors.Length]); + ChartStyle.StyleScatter(scatter); + scatter.LegendText = spinlockType ?? "Unknown"; + hover?.Add(scatter, spinlockType ?? "Unknown"); + colorIdx++; + + if (ys.Length > 0) + max = Math.Max(max, ys.Max()); + } + + if (spinlockTypes.Count > 0) + _chartHelper.ShowChartLegend(chart); + } + + chart.Plot.Axes.DateTimeTicksBottomDateChange(); + ChartStyle.ReapplyAxisColors(chart); + chart.Plot.Axes.SetLimitsX(xMin, xMax); + chart.Plot.YLabel("Sick Spinlocks (backoffs)"); + ChartStyle.SetChartYLimitsWithLegendPadding(chart, 0, max); + chart.Refresh(); + } + + /// <summary> + /// SQL CPU vs System CPU: two percent series over event time on a fixed 0-100 axis with a bottom legend + /// (System CPU on the cycling accent, SQL CPU on the shared SqlCpu identity color). + /// </summary> + internal void RenderCpuComparisonChart( + ScottPlot.WPF.WpfPlot chart, ChartHoverHelper? hover, List<SystemHealthRecord> data, double xMin, double xMax) + { + _chartHelper.ClearChart(chart); + hover?.Clear(); + ChartStyle.ApplyThemeToChart(chart); + + if (data.Count == 0) + { + AddNoDataText(chart, xMin, xMax); + } + else + { + var xs = data.Select(d => _project(d.EventTime!.Value).ToOADate()).ToArray(); + + var sysScatter = chart.Plot.Add.Scatter(xs, data.Select(d => (double)(d.SystemCpuUtilization ?? 0)).ToArray()); + sysScatter.Color = ScottPlot.Color.FromHex(ChartPalette.CyclingColor(0)); + ChartStyle.StyleScatter(sysScatter); + sysScatter.LegendText = "System CPU %"; + hover?.Add(sysScatter, "System CPU %"); + + var sqlScatter = chart.Plot.Add.Scatter(xs, data.Select(d => (double)(d.SqlCpuUtilization ?? 0)).ToArray()); + sqlScatter.Color = ScottPlot.Color.FromHex(ChartPalette.SeriesColor("SqlCpu")); + ChartStyle.StyleScatter(sqlScatter); + sqlScatter.LegendText = "SQL CPU %"; + hover?.Add(sqlScatter, "SQL CPU %"); + + _chartHelper.ShowChartLegend(chart); + } + + chart.Plot.Axes.DateTimeTicksBottomDateChange(); + ChartStyle.ReapplyAxisColors(chart); + chart.Plot.Axes.SetLimitsX(xMin, xMax); + chart.Plot.YLabel("CPU %"); + chart.Plot.Axes.SetLimitsY(0, 100); + chart.Refresh(); + } + + /// <summary>Centered "No data for selected time range" annotation for an empty counter chart.</summary> + private static void AddNoDataText(ScottPlot.WPF.WpfPlot chart, double xMin, double xMax) + { + double xCenter = xMin + (xMax - xMin) / 2; + var text = chart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); + text.LabelFontSize = 14; + text.LabelFontColor = ScottPlot.Color.FromHex(ChartPalette.AccentColor("Placeholder")); + text.LabelAlignment = ScottPlot.Alignment.MiddleCenter; + } +} diff --git a/PerformanceMonitor.Ui/VersionText.cs b/PerformanceMonitor.Ui/VersionText.cs new file mode 100644 index 000000000..84d465b0b --- /dev/null +++ b/PerformanceMonitor.Ui/VersionText.cs @@ -0,0 +1,68 @@ +namespace PerformanceMonitor.Ui; + +/// <summary> +/// Lenient parsing and display-normalization of product version strings, shared by the Dashboard and Lite +/// UIs. Replaces the copy-pasted "<c>Version.TryParse(v, out p) ? new Version(p.Major, p.Minor, p.Build) …</c>" +/// snippets that were scattered across both apps' server-list / landing / health views. +/// +/// The snippets carried a latent bug this centralizes the fix for: <see cref="System.Version.TryParse(string, out System.Version)"/> +/// leaves <c>Build == -1</c> for a two-part version like <c>"3.1"</c>, and the <c>Version(int, int, int)</c> +/// constructor throws <see cref="System.ArgumentOutOfRangeException"/> on a negative build — so every one of +/// those sites would have thrown on a two-part version string. <see cref="Parse"/> clamps it. +/// +/// Mirrors the installer-side hardening in <c>Installer.Core.ScriptProvider.TryParseVersionCore</c> (that +/// one lives in the installer layer, which the UI does not reference; this is the UI-layer twin). Distinct +/// from <see cref="SingleInstanceDecision.ParseProductVersion"/>, which parses the running processes' +/// always-four-part assembly versions for comparison and deliberately does not clamp. +/// </summary> +public static class VersionText +{ + /// <summary> + /// Parse a version string leniently: strip a SemVer <c>+build</c> and/or <c>-prerelease</c> suffix, and + /// clamp <c>Build</c> up from the <c>-1</c> that a two-part version parses to. Returns null for a + /// null/blank/unparseable input, so callers can null-check instead of catching. + /// </summary> + public static System.Version? Parse(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return null; + } + + string core = raw.Trim(); + + int plus = core.IndexOf('+', System.StringComparison.Ordinal); + if (plus >= 0) + { + core = core[..plus]; + } + + int dash = core.IndexOf('-', System.StringComparison.Ordinal); + if (dash >= 0) + { + core = core[..dash]; + } + + if (!System.Version.TryParse(core, out var parsed)) + { + return null; + } + + return new System.Version(parsed.Major, parsed.Minor, System.Math.Max(parsed.Build, 0)); + } + + /// <summary> + /// The display form of a version string: "Major.Minor.Build" when it parses, otherwise the trimmed + /// original (and <see cref="string.Empty"/> for null/blank). This is what the server-list views show, + /// so "3.1.0.0" collapses to "3.1.0" and an unreadable value is passed through rather than lost. + /// </summary> + public static string Normalize(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return string.Empty; + } + + return Parse(raw)?.ToString() ?? raw.Trim(); + } +} diff --git a/PerformanceMonitor.sln b/PerformanceMonitor.sln index 803d5db6d..a76c9df69 100644 --- a/PerformanceMonitor.sln +++ b/PerformanceMonitor.sln @@ -27,6 +27,22 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PerformanceMonitor.Ui", "Pe EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PerformanceMonitor.Common", "PerformanceMonitor.Common\PerformanceMonitor.Common.csproj", "{B8E43A96-5D83-4DBA-B015-DF354ED136F4}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Darling", "Darling", "{918CE7F7-00ED-8D1B-7F0B-104614E5DD77}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PerformanceMonitor.Darling.Service", "Darling\PerformanceMonitor.Darling.Service\PerformanceMonitor.Darling.Service.csproj", "{8197D704-914A-4278-9D0D-4EC5B2824762}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PerformanceMonitor.Darling.Storage", "Darling\PerformanceMonitor.Darling.Storage\PerformanceMonitor.Darling.Storage.csproj", "{A305DCC0-9418-4589-82BD-9C26537A0710}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PerformanceMonitor.Darling.Viewer", "Darling\PerformanceMonitor.Darling.Viewer\PerformanceMonitor.Darling.Viewer.csproj", "{C6C603F6-D62A-43FB-AFFA-303D5917530A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Darling.Tests", "Darling\Darling.Tests\Darling.Tests.csproj", "{850948DD-C9FA-4274-B94D-1F6AACAF50F1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PerformanceMonitor.Collectors", "PerformanceMonitor.Collectors\PerformanceMonitor.Collectors.csproj", "{C41CB250-1E3B-4A57-913E-7183C585CDD2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PerformanceMonitor.Alerting", "PerformanceMonitor.Alerting\PerformanceMonitor.Alerting.csproj", "{C9B7CC0D-19C2-4B83-B204-D2D1CA39B470}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PerformanceMonitor.Darling.Analysis", "Darling\PerformanceMonitor.Darling.Analysis\PerformanceMonitor.Darling.Analysis.csproj", "{6423EB4C-3251-4B07-81FF-2ED890D46AC5}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -181,10 +197,101 @@ Global {B8E43A96-5D83-4DBA-B015-DF354ED136F4}.Release|x64.Build.0 = Release|Any CPU {B8E43A96-5D83-4DBA-B015-DF354ED136F4}.Release|x86.ActiveCfg = Release|Any CPU {B8E43A96-5D83-4DBA-B015-DF354ED136F4}.Release|x86.Build.0 = Release|Any CPU + {8197D704-914A-4278-9D0D-4EC5B2824762}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8197D704-914A-4278-9D0D-4EC5B2824762}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8197D704-914A-4278-9D0D-4EC5B2824762}.Debug|x64.ActiveCfg = Debug|Any CPU + {8197D704-914A-4278-9D0D-4EC5B2824762}.Debug|x64.Build.0 = Debug|Any CPU + {8197D704-914A-4278-9D0D-4EC5B2824762}.Debug|x86.ActiveCfg = Debug|Any CPU + {8197D704-914A-4278-9D0D-4EC5B2824762}.Debug|x86.Build.0 = Debug|Any CPU + {8197D704-914A-4278-9D0D-4EC5B2824762}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8197D704-914A-4278-9D0D-4EC5B2824762}.Release|Any CPU.Build.0 = Release|Any CPU + {8197D704-914A-4278-9D0D-4EC5B2824762}.Release|x64.ActiveCfg = Release|Any CPU + {8197D704-914A-4278-9D0D-4EC5B2824762}.Release|x64.Build.0 = Release|Any CPU + {8197D704-914A-4278-9D0D-4EC5B2824762}.Release|x86.ActiveCfg = Release|Any CPU + {8197D704-914A-4278-9D0D-4EC5B2824762}.Release|x86.Build.0 = Release|Any CPU + {A305DCC0-9418-4589-82BD-9C26537A0710}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A305DCC0-9418-4589-82BD-9C26537A0710}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A305DCC0-9418-4589-82BD-9C26537A0710}.Debug|x64.ActiveCfg = Debug|Any CPU + {A305DCC0-9418-4589-82BD-9C26537A0710}.Debug|x64.Build.0 = Debug|Any CPU + {A305DCC0-9418-4589-82BD-9C26537A0710}.Debug|x86.ActiveCfg = Debug|Any CPU + {A305DCC0-9418-4589-82BD-9C26537A0710}.Debug|x86.Build.0 = Debug|Any CPU + {A305DCC0-9418-4589-82BD-9C26537A0710}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A305DCC0-9418-4589-82BD-9C26537A0710}.Release|Any CPU.Build.0 = Release|Any CPU + {A305DCC0-9418-4589-82BD-9C26537A0710}.Release|x64.ActiveCfg = Release|Any CPU + {A305DCC0-9418-4589-82BD-9C26537A0710}.Release|x64.Build.0 = Release|Any CPU + {A305DCC0-9418-4589-82BD-9C26537A0710}.Release|x86.ActiveCfg = Release|Any CPU + {A305DCC0-9418-4589-82BD-9C26537A0710}.Release|x86.Build.0 = Release|Any CPU + {C6C603F6-D62A-43FB-AFFA-303D5917530A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C6C603F6-D62A-43FB-AFFA-303D5917530A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C6C603F6-D62A-43FB-AFFA-303D5917530A}.Debug|x64.ActiveCfg = Debug|Any CPU + {C6C603F6-D62A-43FB-AFFA-303D5917530A}.Debug|x64.Build.0 = Debug|Any CPU + {C6C603F6-D62A-43FB-AFFA-303D5917530A}.Debug|x86.ActiveCfg = Debug|Any CPU + {C6C603F6-D62A-43FB-AFFA-303D5917530A}.Debug|x86.Build.0 = Debug|Any CPU + {C6C603F6-D62A-43FB-AFFA-303D5917530A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C6C603F6-D62A-43FB-AFFA-303D5917530A}.Release|Any CPU.Build.0 = Release|Any CPU + {C6C603F6-D62A-43FB-AFFA-303D5917530A}.Release|x64.ActiveCfg = Release|Any CPU + {C6C603F6-D62A-43FB-AFFA-303D5917530A}.Release|x64.Build.0 = Release|Any CPU + {C6C603F6-D62A-43FB-AFFA-303D5917530A}.Release|x86.ActiveCfg = Release|Any CPU + {C6C603F6-D62A-43FB-AFFA-303D5917530A}.Release|x86.Build.0 = Release|Any CPU + {850948DD-C9FA-4274-B94D-1F6AACAF50F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {850948DD-C9FA-4274-B94D-1F6AACAF50F1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {850948DD-C9FA-4274-B94D-1F6AACAF50F1}.Debug|x64.ActiveCfg = Debug|Any CPU + {850948DD-C9FA-4274-B94D-1F6AACAF50F1}.Debug|x64.Build.0 = Debug|Any CPU + {850948DD-C9FA-4274-B94D-1F6AACAF50F1}.Debug|x86.ActiveCfg = Debug|Any CPU + {850948DD-C9FA-4274-B94D-1F6AACAF50F1}.Debug|x86.Build.0 = Debug|Any CPU + {850948DD-C9FA-4274-B94D-1F6AACAF50F1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {850948DD-C9FA-4274-B94D-1F6AACAF50F1}.Release|Any CPU.Build.0 = Release|Any CPU + {850948DD-C9FA-4274-B94D-1F6AACAF50F1}.Release|x64.ActiveCfg = Release|Any CPU + {850948DD-C9FA-4274-B94D-1F6AACAF50F1}.Release|x64.Build.0 = Release|Any CPU + {850948DD-C9FA-4274-B94D-1F6AACAF50F1}.Release|x86.ActiveCfg = Release|Any CPU + {850948DD-C9FA-4274-B94D-1F6AACAF50F1}.Release|x86.Build.0 = Release|Any CPU + {C41CB250-1E3B-4A57-913E-7183C585CDD2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C41CB250-1E3B-4A57-913E-7183C585CDD2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C41CB250-1E3B-4A57-913E-7183C585CDD2}.Debug|x64.ActiveCfg = Debug|Any CPU + {C41CB250-1E3B-4A57-913E-7183C585CDD2}.Debug|x64.Build.0 = Debug|Any CPU + {C41CB250-1E3B-4A57-913E-7183C585CDD2}.Debug|x86.ActiveCfg = Debug|Any CPU + {C41CB250-1E3B-4A57-913E-7183C585CDD2}.Debug|x86.Build.0 = Debug|Any CPU + {C41CB250-1E3B-4A57-913E-7183C585CDD2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C41CB250-1E3B-4A57-913E-7183C585CDD2}.Release|Any CPU.Build.0 = Release|Any CPU + {C41CB250-1E3B-4A57-913E-7183C585CDD2}.Release|x64.ActiveCfg = Release|Any CPU + {C41CB250-1E3B-4A57-913E-7183C585CDD2}.Release|x64.Build.0 = Release|Any CPU + {C41CB250-1E3B-4A57-913E-7183C585CDD2}.Release|x86.ActiveCfg = Release|Any CPU + {C41CB250-1E3B-4A57-913E-7183C585CDD2}.Release|x86.Build.0 = Release|Any CPU + {C9B7CC0D-19C2-4B83-B204-D2D1CA39B470}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C9B7CC0D-19C2-4B83-B204-D2D1CA39B470}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C9B7CC0D-19C2-4B83-B204-D2D1CA39B470}.Debug|x64.ActiveCfg = Debug|Any CPU + {C9B7CC0D-19C2-4B83-B204-D2D1CA39B470}.Debug|x64.Build.0 = Debug|Any CPU + {C9B7CC0D-19C2-4B83-B204-D2D1CA39B470}.Debug|x86.ActiveCfg = Debug|Any CPU + {C9B7CC0D-19C2-4B83-B204-D2D1CA39B470}.Debug|x86.Build.0 = Debug|Any CPU + {C9B7CC0D-19C2-4B83-B204-D2D1CA39B470}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C9B7CC0D-19C2-4B83-B204-D2D1CA39B470}.Release|Any CPU.Build.0 = Release|Any CPU + {C9B7CC0D-19C2-4B83-B204-D2D1CA39B470}.Release|x64.ActiveCfg = Release|Any CPU + {C9B7CC0D-19C2-4B83-B204-D2D1CA39B470}.Release|x64.Build.0 = Release|Any CPU + {C9B7CC0D-19C2-4B83-B204-D2D1CA39B470}.Release|x86.ActiveCfg = Release|Any CPU + {C9B7CC0D-19C2-4B83-B204-D2D1CA39B470}.Release|x86.Build.0 = Release|Any CPU + {6423EB4C-3251-4B07-81FF-2ED890D46AC5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6423EB4C-3251-4B07-81FF-2ED890D46AC5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6423EB4C-3251-4B07-81FF-2ED890D46AC5}.Debug|x64.ActiveCfg = Debug|Any CPU + {6423EB4C-3251-4B07-81FF-2ED890D46AC5}.Debug|x64.Build.0 = Debug|Any CPU + {6423EB4C-3251-4B07-81FF-2ED890D46AC5}.Debug|x86.ActiveCfg = Debug|Any CPU + {6423EB4C-3251-4B07-81FF-2ED890D46AC5}.Debug|x86.Build.0 = Debug|Any CPU + {6423EB4C-3251-4B07-81FF-2ED890D46AC5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6423EB4C-3251-4B07-81FF-2ED890D46AC5}.Release|Any CPU.Build.0 = Release|Any CPU + {6423EB4C-3251-4B07-81FF-2ED890D46AC5}.Release|x64.ActiveCfg = Release|Any CPU + {6423EB4C-3251-4B07-81FF-2ED890D46AC5}.Release|x64.Build.0 = Release|Any CPU + {6423EB4C-3251-4B07-81FF-2ED890D46AC5}.Release|x86.ActiveCfg = Release|Any CPU + {6423EB4C-3251-4B07-81FF-2ED890D46AC5}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {8197D704-914A-4278-9D0D-4EC5B2824762} = {918CE7F7-00ED-8D1B-7F0B-104614E5DD77} + {A305DCC0-9418-4589-82BD-9C26537A0710} = {918CE7F7-00ED-8D1B-7F0B-104614E5DD77} + {C6C603F6-D62A-43FB-AFFA-303D5917530A} = {918CE7F7-00ED-8D1B-7F0B-104614E5DD77} + {850948DD-C9FA-4274-B94D-1F6AACAF50F1} = {918CE7F7-00ED-8D1B-7F0B-104614E5DD77} + {6423EB4C-3251-4B07-81FF-2ED890D46AC5} = {918CE7F7-00ED-8D1B-7F0B-104614E5DD77} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {88C537F3-44C6-43A9-AFDC-65D59D9FB5E7} EndGlobalSection diff --git a/README.md b/README.md index 6df199e7e..8aae08cf5 100644 --- a/README.md +++ b/README.md @@ -16,31 +16,31 @@ <a href="https://erikdarling.com"><img src="https://img.shields.io/badge/Blog-erikdarling.com-FF6B35?style=for-the-badge&logo=wordpress&logoColor=white" alt="Blog"></a> </p> -**Free, open-source monitoring that replaces the tools charging you thousands per server per year.** 30+ collectors, real-time alerts, built-in MCP server for AI analysis. Nothing phones home. Your data stays on your server and your machine. +**Free, open-source monitoring that replaces the tools charging you thousands per server per year.** Specialized collectors, real-time alerts, and a built-in MCP server for AI analysis. Nothing phones home. Your data stays on your server and your machine. -**Supported:** SQL Server 2016–2025 | Azure SQL Managed Instance | AWS RDS for SQL Server | Azure SQL Database (Lite only) +**Supported:** SQL Server 2016–2025 | Azure SQL Managed Instance | AWS RDS for SQL Server | Azure SQL Database (Lite and Darling) -![Dashboard landing page with server health cards](Screenshots/Screenshot%20Dashboard%20landing%20page%20with%20server%20health%20cards.jpg) - - -![Full Dashboard — Resource Overview](Screenshots/Full%20Dashboard%20%E2%80%94%20Resource%20Overview.jpg) +![Landing page with server health cards](Screenshots/Screenshot%20Dashboard%20landing%20page%20with%20server%20health%20cards.jpg) --- -## Download +## Editions -**👉 Not sure which edition to pick? [Start with Lite.](https://github.com/erikdarlingdata/PerformanceMonitor/releases/latest)** One download, nothing installed on your server, data flowing in under 5 minutes. +Pick by how you want collection to run — the monitoring brain (collectors, alert engine, plan analysis, MCP tools) is shared across all three at the library level. -| | **[Full Edition](https://github.com/erikdarlingdata/PerformanceMonitor/releases/latest)** | **[Lite Edition](https://github.com/erikdarlingdata/PerformanceMonitor/releases/latest)** | -|---|---|---| -| **What it does** | Installs a `PerformanceMonitor` database with 34 T-SQL collectors running via SQL Agent. Separate dashboard app connects to view everything. | Single desktop app that monitors remotely. Stores data locally in DuckDB + Parquet. Nothing touches your server. | -| **Best for** | Production 24/7 monitoring, long-term baselining | Quick triage, Azure SQL DB, locked-down servers, consultants, firefighting | -| **Requires** | SQL Agent running ([see permissions](#permissions)) | `VIEW SERVER STATE` ([see permissions](#permissions)) | -| **Get started** | Run the installer, open the dashboard | Download, run, add a server, done | +| | **[Lite](https://github.com/erikdarlingdata/PerformanceMonitor/releases/latest)** — flagship | **[Darling](Darling/README.md)** — headless | **[Dashboard](Dashboard/README.md)** — *deprecated* | +|---|---|---|---| +| **How it runs** | Single desktop app monitors remotely, on demand | Windows service collects 24/7 into a central store; detached viewer reads it from any seat | SQL-Server-installed database + Agent collectors, separate viewer app | +| **Installs on your server?** | No | No | Yes (a `PerformanceMonitor` database) | +| **Stores data** | Local DuckDB + Parquet | Bundled PostgreSQL + TimescaleDB | In the target SQL Server | +| **Best for** | Quick triage, Azure SQL DB, locked-down servers, consultants, firefighting | Always-on monitoring of many servers from one service | *Existing installs only — new deployments should use Lite or Darling* | +| **Requires** | `VIEW SERVER STATE` ([permissions](#permissions)) | `VIEW SERVER STATE` + a place to run the service | SQL Agent ([Dashboard docs](Dashboard/README.md)) | -Both editions include real-time alerts (system tray + email + webhooks), charts and graphs, dark and light themes, CSV export, and a built-in MCP server for AI-powered analysis with tools like Claude. +> **⚠️ The "Full" Dashboard edition is deprecated.** It still ships and is supported for existing users, but it is no longer the recommended path. New deployments should use **Lite** or **Darling**. Its docs now live with the code: **[Dashboard/README.md](Dashboard/README.md)** (the app, tabs, permissions) and **[Installer/README.md](Installer/README.md)** (the CLI database installer). The Dashboard installer remains in the release assets. -All release binaries are digitally signed via [SignPath](https://signpath.io) — no more Windows SmartScreen warnings. +**👉 Not sure? [Start with Lite.](https://github.com/erikdarlingdata/PerformanceMonitor/releases/latest)** One download, nothing installed on your server, data flowing in under 5 minutes. + +All editions include real-time alerts (system tray + email + webhooks), charts and graphs, dark and light themes, CSV export, and a built-in MCP server for AI-powered analysis with tools like Claude. All release binaries are digitally signed via [SignPath](https://signpath.io) — no more Windows SmartScreen warnings. --- @@ -50,23 +50,23 @@ All release binaries are digitally signed via [SignPath](https://signpath.io) > *"replaced SentryOne and had it running in 10 minutes"* -> *"I've had enough time to gather data and converse with Claude on this. It helped a lot to zone in on CPU starvation from the hypervisor on which the VM runs. IT team currently investigating the host configuration."* +> *"I've had enough time to gather data and converse with Claude on this. It helped a lot to zone in on CPU starvation from the hypervisor on which the VM runs. IT team currently investigating the host configuration."* --- ## What You Get -🔍 **34 specialized T-SQL collectors** running on configurable schedules with named presets (Off, Aggressive, Balanced, Low-Impact) — wait stats, query performance, blocking chains, deadlock graphs, memory grants, file I/O, tempdb, perfmon counters, FinOps/capacity, and more. Query text and execution plan collection can be disabled per-collector for sensitive environments. Switch presets with a pair of SQL Agent jobs to get quiet-hours / overnight windows without writing any code. +🔍 **Specialized collectors** on configurable schedules — wait stats, query performance, blocking chains, deadlock graphs, memory grants, file I/O, tempdb, perfmon counters, FinOps/capacity, and more. Query text and execution plan collection can be disabled per-collector for sensitive environments. 🚨 **Real-time alerts** for blocking, deadlocks, and high CPU — system tray notifications, styled HTML emails with full XML attachments, and webhook notifications for external integrations -📊 **NOC-style dashboard** with green/yellow/red health cards, auto-refresh, configurable time ranges, and dark/light themes +📊 **NOC-style overview** with green/yellow/red health cards, auto-refresh, configurable time ranges, and dark/light themes 📋 **Graphical plan viewer** with native ShowPlan rendering, 30-rule PlanAnalyzer, operator-level cost breakdown, and a standalone mode for opening `.sqlplan` files without a server connection 💡 **Recommendations engine (advise-and-act)** — a dedicated Recommendations tab surfaces prioritized findings from your own monitoring data with the reasoning behind each one, and can apply selected fixes directly. Destructive changes (like enabling Read Committed Snapshot Isolation) are gated behind an informed-consent dialog that spells out both the risk of acting and the risk of doing nothing. -🤖 **Built-in MCP server** with 55-66 read-only tools for AI analysis — ask Claude Code or Cursor "what are the top wait types on my server?" and get answers from your actual monitoring data +🤖 **Built-in MCP server** with read-only tools for AI analysis — ask Claude Code or Cursor "what are the top wait types on my server?" and get answers from your actual monitoring data 🧰 **Community tools installed automatically** — sp_WhoIsActive, sp_BlitzLock, sp_HealthParser, sp_HumanEventsBlockViewer @@ -90,7 +90,7 @@ All release binaries are digitally signed via [SignPath](https://signpath.io) --- -## Quick Start — Lite Edition +## Quick Start — Lite 1. Download **[`PerformanceMonitorLite-win-Setup.exe`](https://github.com/erikdarlingdata/PerformanceMonitor/releases/latest)** (requires [.NET 10 Desktop Runtime](https://dotnet.microsoft.com/en-us/download/dotnet/10.0)) 2. Run the installer — it installs to `%LocalAppData%\PerformanceMonitorLite`, adds **Start Menu** and **Desktop** shortcuts, and registers the app under **Apps & Features** so it shows up in Windows search and can be uninstalled normally. Auto-update is wired in. @@ -106,7 +106,7 @@ Data starts flowing within 1–5 minutes. That's it. No installation on your ser ### Lite Collectors -26 collectors run on independent, configurable schedules: +36 collectors run on independent, configurable schedules (the long-running-query completion trace is opt-in and ships disabled): | Collector | Default | Source | |---|---|---| @@ -114,6 +114,10 @@ Data starts flowing within 1–5 minutes. That's it. No installation on your ser | blocked_process_report | 1 min | XE ring buffer session | | waiting_tasks | 1 min | `sys.dm_os_waiting_tasks` | | wait_stats | 1 min | `sys.dm_os_wait_stats` (deltas) | +| latch_stats | 1 min | `sys.dm_os_latch_stats` (deltas) | +| spinlock_stats | 1 min | `sys.dm_os_spinlock_stats` (deltas) | +| cpu_scheduler_stats | 1 min | `sys.dm_os_schedulers` runnable/blocked/queued task counts (not Azure SQL DB) | +| long_query_completions | 1 min (opt-in, ships off) | dedicated completion-trace XE (`rpc_completed`/`sql_batch_completed` over a duration threshold, plus `attention`) | | query_stats | 1 min | `sys.dm_exec_query_stats` (deltas) | | procedure_stats | 1 min | `sys.dm_exec_procedure_stats` (deltas) | | cpu_utilization | 1 min | `sys.dm_os_ring_buffers` scheduler monitor | @@ -124,19 +128,27 @@ Data starts flowing within 1–5 minutes. That's it. No installation on your ser | perfmon_stats | 1 min | `sys.dm_os_performance_counters` (deltas) | | deadlocks | 1 min | dedicated `PerformanceMonitor_Deadlock` XE session (`xml_deadlock_report`; `database_xml_deadlock_report` on Azure SQL DB) | | dmv_blocking_snapshot | 1 min | `sys.dm_os_waiting_tasks` + `sys.dm_exec_*` (always-on blocking fallback when the blocked-process-report XE is unavailable) | -| session_stats | 1 min | `sys.dm_exec_sessions` active session tracking | +| session_stats | 5 min | `sys.dm_exec_sessions` active session tracking | +| session_summary_stats | 5 min | `sys.dm_exec_sessions` top app/host/database summary | | memory_clerks | 5 min | `sys.dm_os_memory_clerks` | | memory_pressure_events | 5 min | `sys.dm_os_ring_buffers` RING_BUFFER_RESOURCE_MONITOR | | query_store | 5 min | Query Store DMVs (per database) | +| plan_cache_stats | 5 min | `sys.dm_exec_cached_plans` (single-use vs reused plan-cache bloat) | +| system_health_events | 5 min | `system_health` XE ring buffer (not Azure SQL DB) | +| default_trace_events | 5 min | default trace via `sys.fn_trace_gettable` | +| job_history | 5 min | `msdb.dbo.sysjobhistory` retained job-run history (not Azure SQL DB) | +| agent_status | 5 min | `sys.dm_server_services` + `msdb.dbo.sysjobschedules` (not Azure SQL DB / RDS) | | running_jobs | 5 min | `msdb` job history with duration vs avg/p95 | -| database_size_stats | 15 min | `sys.master_files` + `FILEPROPERTY` + `dm_os_volume_stats` | -| server_properties | 15 min | `SERVERPROPERTY()` hardware and licensing metadata | +| database_size_stats | 1 hour | `sys.master_files` + `FILEPROPERTY` + `dm_os_volume_stats` | +| server_properties | on connect | `SERVERPROPERTY()` hardware and licensing metadata | | index_object_stats | Daily | `sys.dm_db_partition_stats` + `sys.dm_db_index_usage_stats` + `sys.dm_db_index_operational_stats` | | server_config | On connect | `sys.configurations` | | database_config | On connect | `sys.databases` | | database_scoped_config | On connect | Database-scoped configurations | | trace_flags | On connect | `DBCC TRACESTATUS` | +Darling runs this same shared collector set across a fleet of servers (latch stats, spinlock stats, CPU scheduler, plan cache, and system_health parsing are now part of the shared catalog above, collected by Lite too) — see the [Darling collector reference](Darling/README.md). + ### Lite Data Storage All data is stored in `%LOCALAPPDATA%\PerformanceMonitorLite\` — separate from the executable, so auto-updates don't affect your data. @@ -153,212 +165,47 @@ All data is stored in `%LOCALAPPDATA%\PerformanceMonitorLite\` — separate from | `servers.json` | `%ProgramData%\PerformanceMonitorLite\config\` (machine-wide) | Server connections, shared across all Windows users on the machine. Passwords stay per-user in Windows Credential Manager. Optional **Utility Database** per server for community procs installed outside master. | | `settings.json` | `%LOCALAPPDATA%\PerformanceMonitorLite\config\` (per-user) | Retention, MCP server, startup behavior, alert thresholds, SMTP configuration | | `collection_schedule.json` | `%LOCALAPPDATA%\PerformanceMonitorLite\config\` (per-user) | Per-collector enable/disable and frequency | -| `ignored_wait_types.json` | `%LOCALAPPDATA%\PerformanceMonitorLite\config\` (per-user) | 144 benign wait types excluded by default | +| `ignored_wait_types.json` | `%LOCALAPPDATA%\PerformanceMonitorLite\config\` (per-user) | 124 benign wait types excluded by default | When a second Windows user on the same machine launches Lite, they see the shared `servers.json` immediately. SQL Auth and Entra MFA passwords are scoped to each user's own Credential Manager, so they'll be prompted once per server; Windows Auth works without any prompt. --- -## Quick Start — Full Edition - -### Install - -Windows Authentication: - -``` -PerformanceMonitorInstaller.exe YourServerName -``` - -SQL Authentication: - -``` -PerformanceMonitorInstaller.exe YourServerName sa YourPassword -``` - -Entra ID (MFA) Authentication: - -``` -PerformanceMonitorInstaller.exe YourServerName --entra user@domain.com -``` - -Clean reinstall (drops existing database and all collected data): - -``` -PerformanceMonitorInstaller.exe YourServerName --reinstall -PerformanceMonitorInstaller.exe YourServerName sa YourPassword --reinstall -``` - -Custom data/log file locations (applied only when the database is first created): - -``` -PerformanceMonitorInstaller.exe YourServerName --data-path D:\SQLData --log-path E:\SQLLogs -``` - -Uninstall (removes database, Agent jobs, and XE sessions): - -``` -PerformanceMonitorInstaller.exe YourServerName --uninstall -PerformanceMonitorInstaller.exe YourServerName sa YourPassword --uninstall -``` - -The installer automatically tests the connection, checks the SQL Server version (2016+ required), executes SQL scripts, downloads community dependencies, creates SQL Agent jobs, and runs initial data collection. You can also install directly from the Dashboard's Add Server dialog. - -**Air-gapped environments?** Place pre-downloaded community scripts (`sp_WhoIsActive.sql`, `DarlingData.sql`, `Install-All-Scripts.sql`) in a `community/` directory next to the installer. The installer uses local files when present and falls back to GitHub downloads otherwise. - -### CLI Installer Options - -| Option | Description | -|---|---| -| `SERVER` | SQL Server instance name (positional, required) | -| `USERNAME PASSWORD` | SQL Authentication credentials (positional, optional) | -| `--entra EMAIL` | Microsoft Entra ID interactive authentication (MFA) | -| `--reinstall` | Drop existing database and perform clean install | -| `--uninstall` | Remove database, Agent jobs, and XE sessions | -| `--reset-schedule` | Reset collection schedule to recommended defaults | -| `--preserve-jobs` | Keep existing SQL Agent job schedules during upgrade | -| `--encrypt=optional\|mandatory\|strict` | Connection encryption level (default: mandatory) | -| `--trust-cert` | Trust server certificate without validation (default: require valid cert) | -| `--data-path DIR` | Server-side directory for the data (`.mdf`) file (used only on first install) | -| `--log-path DIR` | Server-side directory for the log (`.ldf`) file (used only on first install) | -| `--help` | Show usage information and exit | - -> **Custom file locations:** `--data-path` / `--log-path` set where SQL Server places the PerformanceMonitor data and log files. They take effect **only when the database is first created** — if the database already exists they are ignored. Either flag may be supplied independently; an omitted one falls back to the instance default (`SERVERPROPERTY('InstanceDefaultDataPath')` / `InstanceDefaultLogPath`). The directory is a path **on the SQL Server host** and must already exist, with the SQL Server service account holding write permission. Both `--data-path D:\SQLData` and `--data-path=D:\SQLData` forms are accepted; quote paths containing spaces. Not applicable to Azure SQL Managed Instance, which always uses its managed file layout. - -**Environment variable:** Set `PM_SQL_PASSWORD` to avoid passing the password on the command line. - -### Exit Codes - -| Code | Meaning | -|---|---| -| `0` | Success | -| `1` | Invalid arguments | -| `2` | Connection failed | -| `3` | Critical file failed (scripts 01–03) | -| `4` | Partial installation (non-critical failures) | -| `5` | Version check failed (SQL Server 2014 or earlier) | -| `6` | SQL files not found | -| `7` | Uninstall failed | -| `8` | Upgrade script failed | - -### Post-Installation - -1. Ensure SQL Server Agent is running — the collection job executes every minute -2. Verify installation: - -```sql -SELECT * FROM PerformanceMonitor.config.current_version; - -SELECT TOP (20) * -FROM PerformanceMonitor.config.collection_log -ORDER BY collection_time DESC; -``` - -3. Install the Dashboard. Download **[`PerformanceMonitorDashboard-win-Setup.exe`](https://github.com/erikdarlingdata/PerformanceMonitor/releases/latest)** (requires [.NET 10 Desktop Runtime](https://dotnet.microsoft.com/en-us/download/dotnet/10.0)). Setup.exe installs to `%LocalAppData%\PerformanceMonitorDashboard`, adds **Start Menu** and **Desktop** shortcuts, registers the app under **Apps & Features**, and wires up auto-update. Launch from the Start Menu, add your server, enter credentials, and data appears immediately. - -### What Gets Installed - -- **PerformanceMonitor database** with collection tables and reporting views -- **33 collector stored procedures** for gathering metrics (including SQL Agent job monitoring) -- **Configurable collection** — query text and execution plan capture can be disabled per-collector via `config.collection_schedule` (`collect_query`, `collect_plan` columns) for sensitive or high-volume environments -- **Delta framework** for calculating per-second rates from cumulative DMVs -- **Community dependencies:** sp_WhoIsActive, sp_HealthParser, sp_HumanEventsBlockViewer, sp_BlitzLock -- **SQL Agent jobs:** Collection (every 1 minute), Data Retention (daily at 2:00 AM), and Hung Job Monitor (collection job watchdog, every 5 minutes) -- **Version tracking** in `config.installation_history` - -### Data Retention - -Default: 30 days (configurable per collector via the `retention_days` column in `config.collection_schedule`). - -Storage estimates: 5–10 GB per week, 20–40 GB per month. - -### Managed Platform Support - -The Full Edition supports Azure SQL Managed Instance and AWS RDS for SQL Server with some limitations: - -| Feature | On-Premises | Azure SQL MI | AWS RDS | -|---|---|---|---| -| All core collectors | Yes | Yes | Yes | -| Default trace collectors | Yes | Disabled automatically | Yes | -| System health XE (file target) | Yes | Disabled automatically | Yes | -| SQL Trace collectors | Yes | Disabled automatically | Yes | -| SQL Agent jobs | Yes | Yes | Yes | -| Running jobs collector | Yes | Yes | Disabled automatically | -| Blocked process threshold | Auto-configured | Auto-configured | Configure via RDS parameter group | -| sp_configure | Yes | Yes | Not available | - -**Azure SQL MI:** The installer automatically detects Engine Edition 8 and disables 4 collectors that require file system access or SQL Trace (default_trace, trace_management, trace_analysis, system_health). All other collectors work normally. - -**AWS RDS:** The installer automatically detects the `rdsadmin` database and disables the `running_jobs_collector` (requires `msdb.dbo.syssessions` which is restricted on RDS). It also gracefully handles restricted `sp_configure` and limited `msdb` permissions. SQL Agent jobs are created and owned by the installing login. The RDS master user is automatically enrolled in `SQLAgentUserRole`; for other logins, add them to `SQLAgentUserRole` in msdb before running the installer. - -### AWS RDS Parameter Group Configuration - -`sp_configure` is not available on AWS RDS for SQL Server. Features that depend on server-level configuration must be set through **AWS RDS Parameter Groups** instead. - -**Blocked process threshold** — Enables blocked-process-report collection (the richer XE-sourced blocking detail). Without it the blocked-process-report XE will not fire on RDS, but blocking is still captured by the always-on `dmv_blocking_snapshot` collector, so the blocking grid and block-chain viewer stay populated regardless. Set the threshold for the fuller report-sourced detail. - -1. Open the [AWS RDS Console](https://console.aws.amazon.com/rds/) and navigate to **Parameter groups** -2. Create a new parameter group (or modify the one attached to your instance): - - Family: `sqlserver-ee-16.0` (or your edition/version) - - Type: DB Parameter Group -3. Search for `blocked process threshold (s)` and set it to `5` (seconds) -4. Apply the parameter group to your RDS instance (may require a reboot if the parameter is static) -5. Verify it took effect: +## Quick Start — Darling (headless) - ```sql - SELECT - c.name, - c.value_in_use - FROM sys.configurations AS c - WHERE c.name = N'blocked process threshold (s)'; - ``` +**Darling** is the always-on edition for teams that want 24/7 collection without a desktop app driving it: a Windows service collects from your servers around the clock into a central PostgreSQL store (TimescaleDB is detected and adopted automatically for compression and chunk-based retention), and a detached WPF viewer reads that store from any seat. It runs the same monitoring brain as Lite — collectors, alert engine, and analysis pipeline shared at the library level — with alerts over email and Teams/Slack webhooks and the same MCP tool surface available on request. An optional built-in read-only **web dashboard** (off by default, its own port 5153) serves the fleet overview, per-server drill-down, and alert history to any browser, so operators can watch the fleet without installing the viewer. -**Deadlocks** — No parameter group configuration is required. The SQL Server deadlock monitor runs automatically on all platforms, and the `xml_deadlock_report` Extended Event fires without any threshold setting. +1. Download **`PerformanceMonitorDarling-<version>.zip`** from the [latest release](https://github.com/erikdarlingdata/PerformanceMonitor/releases/latest) — the signed service and viewer with the bundled PostgreSQL + TimescaleDB runtime beside the service exe, so a from-zero install needs no database provisioning. +2. Copy `darling.sample.json` to `darling.json` and add your servers (and optional SMTP / webhook delivery). In managed mode the service unpacks and runs its own PostgreSQL — no external database to set up. +3. Run the service (console for a trial, or install it as a Windows service). It seeds the store and begins collecting on the same default cadences and retention horizons as a fresh Lite install. +4. Open the viewer and point it at the store to browse the fleet. -**Azure SQL Database** — The blocked process threshold is fixed at 20 seconds and cannot be changed. The `blocked_process_report` event fires automatically when blocking exceeds this duration. +Configuration is a single JSON file with no schedule knobs. See the **[Darling operator guide](Darling/README.md)** for the full quick start, configuration reference, permissions, and operations. --- ## Edition Comparison -| Capability | Full | Lite | -|---|---|---| -| Target server installation | Required | None | -| SQL Server Agent | Required | Not needed | -| Azure SQL Managed Instance | Supported | Supported | -| AWS RDS for SQL Server | Supported | Supported | -| Azure SQL Database | Not supported | Supported | -| Multi-server from one seat | Per-server install | Built-in | -| Collectors | 33 | 25 | -| Agent job monitoring | Duration vs historical avg/p95 | Duration vs historical avg/p95 | -| Data storage | SQL Server (on target) | DuckDB + Parquet (local) | -| Execution plans | Collected and stored (can be disabled per-collector) | Download on demand | -| Graphical plan viewer | Built-in with 30-rule PlanAnalyzer | Built-in with 30-rule PlanAnalyzer | -| Standalone plan viewer | Open/paste/drag `.sqlplan` files | Open/paste/drag `.sqlplan` files | -| Community tools (sp_WhoIsActive, sp_BlitzLock) | Installed automatically | Not needed | -| Alerts (tray + email + webhooks) | Blocking, deadlocks, CPU | Blocking, deadlocks, CPU | -| Dashboard | Separate app | Built-in | -| Themes | Dark and light | Dark and light | -| Portability | Server-bound | Single executable | -| MCP server (LLM integration) | Built into Dashboard (66 tools) | Built-in (55 tools) | +| Capability | Lite | Darling | Dashboard *(deprecated)* | +|---|---|---|---| +| Target server installation | None | None | Required | +| Runs collection | On-demand desktop app | 24/7 Windows service | SQL Agent on the target | +| Multi-server from one seat | Built-in | Built-in (central store) | Per-server install | +| Data storage | DuckDB + Parquet (local) | PostgreSQL + TimescaleDB (bundled) | SQL Server (on target) | +| Azure SQL Database | Supported | Supported | Not supported | +| Azure SQL MI / AWS RDS | Supported | Supported | Supported | +| Graphical plan viewer | Built-in, 30-rule PlanAnalyzer | Built-in, 30-rule PlanAnalyzer | Built-in, 30-rule PlanAnalyzer | +| Standalone plan viewer | Open/paste/drag `.sqlplan` | Open/paste/drag `.sqlplan` | Open/paste/drag `.sqlplan` | +| Alerts (tray + email + webhooks) | Yes | Email + webhooks (headless) | Yes | +| Themes | Dark and light | Dark and light | Dark and light | +| Portability | Single executable | Portable service + viewer zip | Server-bound | +| MCP server (LLM integration) | Built-in (74 tools) | On request | Built into Dashboard (66 tools) | --- -## Dashboard Tabs - -### Full Edition Dashboard - -| Tab | Contents | -|---|---| -| **Overview** | Resource overview, daily summary, critical issues, recommendations, server config changes, database config changes, trace flag changes, collection health | -| **Performance** | Performance trends, expensive queries, active queries, query stats, procedure stats, Query Store, Query Store regressions, query trace patterns, query heatmap | -| **Resource Metrics** | Server trends, wait stats, TempDB, file I/O latency, perfmon counters, default trace events, trace analysis, session stats, latch stats, spinlock stats | -| **Memory** | Memory overview, grants, clerks, plan cache, memory pressure events | -| **Locking** | Blocking chains, deadlocks, blocking/deadlock trends, visual block-chain & deadlock-graph viewers | -| **System Events** | Corruption events, contention, errors, I/O issues, scheduler issues, memory conditions | - -Plus a NOC-style landing page with server health cards (green/yellow/red severity indicators). +## Tabs -### Lite Edition Dashboard +The **Lite** app and the **Darling** viewer share the same tab layout (the viewer is Lite's front end reading a Postgres store instead of local DuckDB): | Tab | Contents | |---|---| @@ -373,32 +220,32 @@ Plus a NOC-style landing page with server health cards (green/yellow/red severit | **Blocking** | Blocking/deadlock trends, blocked process reports, deadlock history, visual block-chain & deadlock-graph viewers | | **Perfmon** | Selectable SQL Server performance counters over time | | **Configuration** | Server configuration, database configuration, scoped configuration, trace flags | -| **FinOps** | Utilization & provisioning analysis, database resource breakdown, storage growth (7d/30d), idle database detection, index analysis via sp_IndexCleanup, per-object table/index size, growth, usage, and locking/contention analysis, application connections, server inventory, cost optimization recommendations (enterprise feature audit, CPU/memory right-sizing, compression savings, dormant databases, dev/test detection), column-level filtering on all grids | +| **FinOps** | Utilization & provisioning analysis, database resource breakdown, storage growth (7d/30d), idle database detection, index analysis via sp_IndexCleanup, per-object table/index size, growth, usage, and locking/contention analysis, application connections, server inventory, cost optimization recommendations, column-level filtering on all grids | | **Recommendations** | Prioritized findings drawn from collected metrics, grouped into incidents, each card showing the affected database, the recommendation, the reasoning behind it, and a copyable MCP investigation prompt | -Both editions feature auto-refresh, configurable time ranges, chart drill-down to Active Queries, right-click CSV export, system tray integration, dark and light themes, and timezone display options (server time, local time, or UTC). +Both feature auto-refresh, configurable time ranges, chart drill-down to Active Queries, right-click CSV export, system tray integration, dark and light themes, and timezone display options (server time, local time, or UTC). The Darling viewer adds a fleet sidebar and per-server tabs; see [Darling/README.md](Darling/README.md). The deprecated Dashboard's six-tab-group layout is documented in [Dashboard/README.md](Dashboard/README.md). --- ## Alerts & Notifications -Both editions include a real-time alert engine that monitors for performance issues and sends notifications via system tray balloons and email. +Every edition includes a real-time alert engine that monitors for performance issues and sends notifications via system tray balloons (Lite/Dashboard), email, and webhooks. ### Alert Types | Metric | Default Threshold | Description | |---|---|---| -| **Blocking** | 30 seconds (Full), 5 seconds (Lite) | Fires when the longest blocked session exceeds the threshold | +| **Blocking** | 5 seconds | Fires when the longest blocked session exceeds the threshold | | **Deadlocks** | 1 | Fires when new deadlocks are detected since the last check | | **Poison waits** | 100 ms avg | Fires when any poison wait type exceeds the average-ms-per-wait threshold | | **Long-running queries** | 5 minutes | Fires when any query exceeds the elapsed-time threshold | | **TempDB space** | 80% | Fires when TempDB usage exceeds the percentage threshold | | **Long-running agent jobs** | 3× average | Fires when a job's current duration exceeds a multiple of its historical average | -| **High CPU** | 90% (Full), 80% (Lite) | Fires when total CPU (SQL + other) exceeds the threshold | +| **High CPU** | 80% | Fires when total CPU (SQL + other) exceeds the threshold | | **Volume free space** | 10% or 5 GB free | Fires when a monitored volume's free space drops below the percentage or absolute threshold (either check can be disabled). Never fires on Azure SQL Database. | | **Failed agent job** | 60-minute lookback | Fires when a SQL Agent job run fails within the lookback window. Skipped on Azure SQL Database. | -| **Server unreachable** | N/A | Fires when a monitored server goes offline or comes back online (tray + email) | -| **Collection stopped** | Jobs disabled, or no run in 30 min | Fires when the PerformanceMonitor collector Agent jobs are disabled, or no collection has run for 30+ minutes (Agent service stopped or collectors erroring). App-computed, so it survives the collector being off; clears with a "Collection Resumed" notice. Full edition only (Lite runs its own scheduler); never fires on Azure SQL Database; degrades gracefully where msdb is restricted (e.g. AWS RDS). | +| **Server unreachable** | N/A | Fires when a monitored server goes offline or comes back online | +| **Collection stopped** | No run in 30 min | Fires when collection stalls (Agent/service stopped or collectors erroring). App-computed, so it survives the collector being off; clears with a "Collection Resumed" notice. Never fires on Azure SQL Database; degrades gracefully where msdb is restricted (e.g. AWS RDS). | All thresholds are configurable in Settings. @@ -423,17 +270,17 @@ Alert emails include: ### Alert Behavior - **Resolved notifications** — when a condition clears (e.g., blocking ends), a "Cleared" notification fires -- **Server silencing** — right-click a server tab to acknowledge alerts, silence all alerts, or unsilence -- **Always-on** — the Dashboard alert engine runs independently of which tab is active, including when minimized to the system tray. The Lite edition's alert engine also runs regardless of tab visibility. -- **Alert history** — Dashboard keeps an in-memory alert log (accessible via MCP). Lite logs alerts to DuckDB (`config_alert_log`). -- **Alert muting** — create rules to suppress specific recurring alerts while still logging them. Rules match on server name, metric type, database, query text, wait type, or job name (AND logic across fields). Access via Settings → Manage Mute Rules, or right-click an alert in the Alert History tab. The context menu offers two muting options: **Mute This Alert** (pre-fills server + metric for a targeted rule) and **Mute Similar Alerts** (pre-fills metric only, matching across all servers). Muted alerts appear grayed out in alert history and are still recorded for auditability. Rules support optional expiration (1h, 24h, 7 days, or permanent). -- **Alert details** — right-click any alert in the Alert History tab and choose **View Details** to open a detail window. The window shows core alert fields (time, server, metric, value, threshold, notification type, status) plus context-sensitive details that vary by metric: query text and session info for long-running queries, job name and duration stats for anomalous agent jobs, per-wait-type breakdowns for poison waits, space usage by category for TempDB, and blocking/deadlock session counts. +- **Server silencing** — right-click a server to acknowledge alerts, silence all alerts, or unsilence +- **Always-on** — the alert engine runs independently of which tab is active, including when minimized to the system tray +- **Alert history** — Lite logs alerts to DuckDB (`config_alert_log`); Darling logs to its Postgres store; both are accessible via MCP +- **Alert muting** — create rules to suppress specific recurring alerts while still logging them. Rules match on server name, metric type, database, query text, wait type, or job name (AND logic across fields). Access via Settings → Manage Mute Rules, or right-click an alert in the Alert History tab. The context menu offers **Mute This Alert** (pre-fills server + metric) and **Mute Similar Alerts** (pre-fills metric only, matching across all servers). Muted alerts appear grayed out and are still recorded for auditability. Rules support optional expiration (1h, 24h, 7 days, or permanent). +- **Alert details** — right-click any alert in the Alert History tab and choose **View Details** for core fields (time, server, metric, value, threshold, notification type, status) plus context-sensitive details that vary by metric. --- ## Agent Job Monitoring -Both editions monitor currently running SQL Agent jobs and flag jobs that are running longer than expected. +Every edition monitors currently running SQL Agent jobs and flags jobs running longer than expected. | Metric | How It Works | |---|---| @@ -442,15 +289,13 @@ Both editions monitor currently running SQL Agent jobs and flag jobs that are ru | **p95 duration** | 95th percentile from historical completions | | **Running long flag** | Set when current duration exceeds the p95 threshold | -The Full Edition collects this data via the `collect.running_jobs_collector` stored procedure (every 5 minutes). The Lite Edition queries `msdb` directly on each collection cycle. Both editions expose this data through the MCP `get_running_jobs` tool. - -Gracefully skipped on Azure SQL Database, AWS RDS for SQL Server, and environments without SQL Server Agent. +Lite and Darling query `msdb` directly on each collection cycle; all editions expose this data through the MCP `get_running_jobs` tool. Gracefully skipped on Azure SQL Database, AWS RDS for SQL Server, and environments without SQL Server Agent. --- ## MCP Server (LLM Integration) -Both editions include an embedded [Model Context Protocol](https://modelcontextprotocol.io) server that exposes monitoring data to LLM clients like Claude Code and Cursor. +Every edition includes an embedded [Model Context Protocol](https://modelcontextprotocol.io) server that exposes monitoring data to LLM clients like Claude Code and Cursor. ### Setup @@ -473,41 +318,38 @@ claude mcp add --transport http --scope user sql-monitor http://localhost:5151/ ### Available Tools -Full Edition exposes 66 tools, Lite Edition exposes 55. Core tools are shared across both editions. +**Lite** exposes 74 tools; **Darling** exposes the analysis + data-read surface on request; the deprecated **Dashboard** exposes 66 (see [Dashboard/README.md](Dashboard/README.md)). Core tools are shared. | Category | Tools | |---|---| | Discovery | `list_servers` | -| Health | `get_server_summary`\*, `get_daily_summary`\*\*, `get_collection_health` | +| Health | `get_server_summary`, `get_collection_health`, `get_daily_summary` | | Alerts | `get_alert_history`, `get_alert_settings`, `get_mute_rules` | -| Waits | `get_wait_stats`, `get_wait_types`\*, `get_wait_trend`, `get_waiting_tasks`\* | -| Queries | `get_top_queries_by_cpu`, `get_top_procedures_by_cpu`, `get_query_store_top`, `get_expensive_queries`\*\*, `get_query_duration_trend`\*, `get_query_trend` | +| Waits | `get_wait_stats`, `get_wait_types`, `get_wait_trend`, `get_waiting_tasks` | +| Queries | `get_top_queries_by_cpu`, `get_top_procedures_by_cpu`, `get_query_store_top`, `get_query_duration_trend`, `get_query_trend` | | Active Queries | `get_active_queries` | | CPU | `get_cpu_utilization` | -| Memory | `get_memory_stats`, `get_memory_trend`, `get_memory_clerks`, `get_memory_grants` | -| Blocking | `get_blocking`\*\*, `get_deadlocks`, `get_deadlock_detail`, `get_blocked_process_reports`\*, `get_blocked_process_xml`, `get_blocking_deadlock_stats`\*\*, `get_blocking_trend`\*, `get_deadlock_trend`\* | +| Memory | `get_memory_stats`, `get_memory_trend`, `get_memory_clerks`, `get_memory_grants`, `get_resource_semaphore` | +| Blocking | `get_deadlocks`, `get_deadlock_detail`, `get_blocked_process_reports`, `get_blocked_process_xml`, `get_blocking_trend`, `get_deadlock_trend` | | I/O | `get_file_io_stats`, `get_file_io_trend` | | TempDB | `get_tempdb_trend` | | Perfmon | `get_perfmon_stats`, `get_perfmon_trend` | | Jobs | `get_running_jobs` | -| Configuration | `get_server_config`\*, `get_database_config`\*, `get_database_scoped_config`\*, `get_trace_flags`\* | -| Config History | `get_server_config_changes`\*\*, `get_database_config_changes`\*\*, `get_trace_flag_changes`\*\* | +| Configuration | `get_server_config`, `get_database_config`, `get_database_scoped_config`, `get_trace_flags` | | Server Info | `get_server_properties`, `get_database_sizes` | | Object/Index Stats | `get_table_index_sizes`, `get_index_usage`, `get_object_locking` | | Sessions | `get_session_stats` | -| Scheduler | `get_cpu_scheduler_pressure`\*\* | -| Latch/Spinlock | `get_latch_stats`\*\*, `get_spinlock_stats`\*\* | -| Diagnostics | `get_plan_cache_bloat`\*\*, `get_critical_issues`\*\* | -| System Events | `get_default_trace_events`\*\*, `get_trace_analysis`\*\*, `get_memory_pressure_events` | -| Health Parser | `get_health_parser_system_health`\*\*, `get_health_parser_severe_errors`\*\*, `get_health_parser_io_issues`\*\*, `get_health_parser_scheduler_issues`\*\*, `get_health_parser_memory_conditions`\*\*, `get_health_parser_cpu_tasks`\*\*, `get_health_parser_memory_broker`\*\*, `get_health_parser_memory_node_oom`\*\* | +| System Events | `get_memory_pressure_events` | +| Latches & Spinlocks | `get_latch_stats`, `get_spinlock_stats` | +| Plan Cache & Scheduler | `get_plan_cache_bloat`, `get_cpu_scheduler_pressure` | +| Long Queries | `get_long_query_completions` | +| Default Trace | `get_default_trace_events` | +| Config Changes | `get_server_config_changes`, `get_database_config_changes`, `get_trace_flag_changes` | +| Health Parser | `get_health_parser_system_health`, `get_health_parser_severe_errors`, `get_health_parser_io_issues`, `get_health_parser_scheduler_issues`, `get_health_parser_memory_conditions`, `get_health_parser_cpu_tasks`, `get_health_parser_memory_broker`, `get_health_parser_memory_node_oom` | | Plan Analysis | `analyze_query_plan`, `analyze_procedure_plan`, `analyze_query_store_plan`, `analyze_plan_xml`, `get_plan_xml` | | Diagnostic Analysis | `analyze_server`, `get_analysis_facts`, `compare_analysis`, `audit_config`, `get_analysis_findings`, `mute_analysis_finding` | -\* Lite only | \*\* Full only - -Most tools accept optional `server_name` and `hours_back` parameters. If only one server is configured, `server_name` is auto-resolved. - -The MCP server binds to `localhost` only and does not accept remote connections. +Most tools accept optional `server_name` and `hours_back` parameters. If only one server is configured, `server_name` is auto-resolved. The MCP server binds to `localhost` only and does not accept remote connections. (Darling adds windowed-trend and fleet-overview tools plus agent-driven write tools — Custom Views authoring, alert-settings and mute-rule tuning, and bulk add/remove servers — and supports an opt-in LAN endpoint — see [Darling/README.md](Darling/README.md).) --- @@ -517,8 +359,8 @@ The MCP server binds to `localhost` only and does not accept remote connections. - All queries use `READ UNCOMMITTED` isolation - Configurable collection intervals -- Full Edition: typical overhead <1% CPU, <100 MB memory -- Lite Edition: max 7 concurrent SQL connections, 30-second command timeout +- Lite: max 7 concurrent SQL connections, 30-second command timeout +- Darling: the service collects on the shared default cadences; TimescaleDB compresses the store ### Local Resources (Lite) @@ -530,35 +372,7 @@ The MCP server binds to `localhost` only and does not accept remote connections. ## Troubleshooting -### Full Edition - -Two diagnostic scripts in the `install/` folder: - -| Script | Purpose | -|---|---| -| `99_installer_troubleshooting.sql` | Quick health checks: collection log errors, schedule status, Agent job status, table row counts | -| `99_user_troubleshooting.sql` | Comprehensive diagnostics: runs collectors with `@debug = 1`, detailed timing and row counts | - -```sql -SELECT - collection_time, - collector_name, - error_message -FROM PerformanceMonitor.config.collection_log -WHERE collection_status = 'ERROR' -ORDER BY collection_time DESC; -``` - -**Orphaned `Monitor_LongQueries_*.trc` files (issue #972)** — versions through 2.11.0 accumulated stale SQL Trace files in the SQL Server error log directory. Newer versions bound the long-query trace with a rollover file-count cap, so SQL Server prunes its own files going forward — but trace files already on disk are not removed automatically (`xp_delete_file` cannot delete `.trc` files). Sweep them once with `tools/Remove-OrphanedTraceFiles.ps1`, run **on the SQL Server host** as a local Administrator or the SQL Server service account: - -```powershell -.\Remove-OrphanedTraceFiles.ps1 -WhatIf # preview what would be deleted -.\Remove-OrphanedTraceFiles.ps1 # delete -``` - -It skips files belonging to a running trace and files that are in use. - -### Lite Edition +### Lite Application logs are written to the `logs/` folder. Collection success/failure is also logged to the `collection_log` table in DuckDB. @@ -566,46 +380,40 @@ Common issues: 1. **No data after connecting** — Wait for the first collection cycle (1–5 minutes). Check logs for connection errors. 2. **Query Store tab empty** — Query Store must be enabled on the target database (`ALTER DATABASE [YourDB] SET QUERY_STORE = ON`). -3. **Blocked process reports empty** — Both editions attempt to auto-configure the blocked process threshold to 5 seconds via `sp_configure`. On **AWS RDS**, `sp_configure` is not available — you must set `blocked process threshold (s)` through an RDS Parameter Group (see "AWS RDS Parameter Group Configuration" above). On **Azure SQL Database**, the threshold is fixed at 20 seconds and cannot be changed. If you still see no data on other platforms, verify the login has `ALTER SETTINGS` permission. +3. **Blocked process reports empty** — Lite attempts to auto-configure the blocked process threshold to 5 seconds via `sp_configure`. On **AWS RDS**, `sp_configure` is not available — set `blocked process threshold (s)` through an RDS Parameter Group (see [Platform Notes](#platform-notes) below). On **Azure SQL Database**, the threshold is fixed at 20 seconds and cannot be changed. If you still see no data on other platforms, verify the login has `ALTER SETTINGS` permission. 4. **Connection failures** — Verify network connectivity, firewall rules, and that the login has the required [permissions](#permissions). For Azure SQL Database, use a contained database user with `VIEW DATABASE STATE`. -5. **FinOps Index Analysis hangs, times out, or returns `Msg 229` on `sql_expression_dependencies`** — `sp_IndexCleanup` runs against each user database under your dashboard/Lite login. If that login has no user mapping in a target database, the procedure can hang at 100% CPU instead of erroring; if it is mapped but missing `SELECT` on `sys.sql_expression_dependencies`, it errors immediately on databases that have UDF-bound computed columns or check constraints. See [FinOps Index Analysis](#finops-index-analysis-per-database-grants) below for the full per-database grant set that fixes both. +5. **FinOps Index Analysis hangs, times out, or returns `Msg 229` on `sql_expression_dependencies`** — see [FinOps Index Analysis](#finops-index-analysis-per-database-grants) below for the full per-database grant set that fixes both failure modes. + +**Darling** troubleshooting (service logs, store connectivity, permissions) is in the [Darling operator guide](Darling/README.md). **Dashboard** (Full edition) troubleshooting is in [Dashboard/README.md](Dashboard/README.md). --- -## Permissions +## Authentication -### Full Edition (On-Premises) +Every edition supports five authentication types, defined once in `PerformanceMonitor.Common.AuthenticationTypes` and shared by Lite, Darling, the Dashboard, and the CLI installer: -The installer needs `sysadmin` to create the database, Agent jobs, and configure `sp_configure` settings. After installation, the collection jobs can run under a **least-privilege login** with these grants: +| Type | Interactive? | Credential stored? | Where | +|---|---|---|---| +| Windows | No | None | — | +| SQL Server | No | Password | Windows Credential Manager | +| Entra ID (MFA) | Yes, once per session | None | — | +| Service Principal | No | Client secret | Windows Credential Manager | +| Managed Identity | No | None | — | -```sql -USE [master]; -CREATE LOGIN [SQLServerPerfMon] WITH PASSWORD = N'YourStrongPassword'; -GRANT VIEW SERVER STATE TO [SQLServerPerfMon]; +**Managed Identity and Service Principal** are non-interactive Azure AD (Entra ID) authentication modes, added for fleet onboarding of Azure SQL Database / Managed Instance without a per-server interactive MFA prompt (see [#1038](https://github.com/erikdarlingdata/PerformanceMonitor/issues/1038)). Both map directly to `Microsoft.Data.SqlClient`'s native `SqlAuthenticationMethod` (`ActiveDirectoryServicePrincipal` / `ActiveDirectoryManagedIdentity`) — PerformanceMonitor never acquires, caches, or stores a token itself; the official Microsoft driver handles that internally. -USE [PerformanceMonitor]; -CREATE USER [SQLServerPerfMon] FOR LOGIN [SQLServerPerfMon]; -ALTER ROLE [db_owner] ADD MEMBER [SQLServerPerfMon]; +- **Managed Identity** requires the machine running the app/service to itself be an Azure resource (VM, App Service, etc.) with a system- or user-assigned managed identity. That identity is then provisioned as a user directly on each target database (see [Permissions](#permissions) below). Nothing is stored locally. +- **Service Principal** uses an Entra app registration's client id + secret. The client id is non-secret and stored in config; the secret is stored only in Windows Credential Manager, same as a SQL auth password. -USE [msdb]; -CREATE USER [SQLServerPerfMon] FOR LOGIN [SQLServerPerfMon]; -ALTER ROLE [SQLAgentReaderRole] ADD MEMBER [SQLServerPerfMon]; -``` +### Credential Profiles (Lite, fleet onboarding) -| Grant | Why | -|---|---| -| `VIEW SERVER STATE` | All DMV access (wait stats, query stats, memory, CPU, file I/O, etc.) | -| `db_owner` on PerformanceMonitor | Collectors insert data, create/alter tables, execute procedures. Scoped to just this database — not sysadmin. | -| `SQLAgentReaderRole` on msdb | Read `sysjobs`, `sysjobactivity`, `sysjobhistory` for the running jobs collector | +For a fleet of servers sharing one identity — one managed identity or one service principal used across many Azure SQL databases — Lite has **Credential Profiles**: a named, reusable credential that any number of server entries can reference instead of each one carrying its own inline auth. Create one under **Manage Servers → Credential Profiles…**, then point server entries at it. Profiles live in `profiles.json` alongside `servers.json`; a Managed Identity profile stores no secret, and Service Principal / SQL Server profile secrets live in Windows Credential Manager, never in the JSON file. -**Optional** (gracefully skipped if missing): -- `ALTER SETTINGS` — installer sets `blocked process threshold` via `sp_configure`. Skipped with a warning if unavailable. -- `ALTER TRACE` — default trace collector. Skipped if denied. -- `DBCC TRACESTATUS` — server config collector skips trace flag detection if denied. +--- -Change the SQL Agent job owner to the new login after installation if you want to run under least privilege end-to-end. +## Permissions -### Lite Edition (On-Premises) +### Lite / Darling (On-Premises) Nothing is installed on the target server. The login only needs: @@ -619,9 +427,11 @@ CREATE USER [YourLogin] FOR LOGIN [YourLogin]; ALTER ROLE [SQLAgentReaderRole] ADD MEMBER [YourLogin]; ``` +Darling uses the same target-server grants; its bundled PostgreSQL store and service account are covered in the [Darling operator guide](Darling/README.md). The deprecated Full edition's install/least-privilege grants are in [Dashboard/README.md](Dashboard/README.md). + ### FinOps Index Analysis (per-database grants) -Applies to **both editions**. The FinOps Index Analysis tab runs `sp_IndexCleanup` against each user database you ask it to inspect, executing as your dashboard/Lite login. The grants above (`VIEW SERVER STATE`, `db_owner` on `PerformanceMonitor`, `SQLAgentReaderRole` on `msdb`) are *not* sufficient on their own — the login also needs a user mapping in every user database it will analyze, plus `VIEW DATABASE STATE`, `VIEW DEFINITION`, and `SELECT` on `sys.sql_expression_dependencies` in each. +Applies to **all editions**. The FinOps Index Analysis tab runs `sp_IndexCleanup` against each user database you ask it to inspect, executing as your app login. The grants above (`VIEW SERVER STATE`, plus optional `SQLAgentReaderRole` on `msdb`) are *not* sufficient on their own — the login also needs a user mapping in every user database it will analyze, plus `VIEW DATABASE STATE`, `VIEW DEFINITION`, and `SELECT` on `sys.sql_expression_dependencies` in each. The third grant is the easy one to miss: by default only members of `db_owner` have `SELECT` on `sys.sql_expression_dependencies`, and `VIEW DEFINITION` does not include it. `sp_IndexCleanup` queries that catalog view (via three-part name to the target database) when checking for computed columns and check constraints that reference UDFs, so the failure only surfaces on databases that actually have those — which is why a smoke-test database may pass and a real workload database fails with `Msg 229`. @@ -650,14 +460,14 @@ BEGIN END'; ``` -**Symptoms if missing.** There are two distinct failure modes depending on which grant is absent: +**Symptoms if missing.** Two distinct failure modes depending on which grant is absent: -- *No user mapping in the target database* — `sp_IndexCleanup` can hang at 100% CPU with no waits and never return, instead of failing fast with `Msg 916` like every other catalog DMV. The hang isn't a deadlock or a long-running scan; it's a SQL Server engine bug where a permission check at execute time gets misclassified as "this plan needs to be recompiled," producing an infinite recompile loop. Reproduces on SQL Server 2016 SP3 through 2025 CU4. -- *User is mapped with `VIEW DATABASE STATE` + `VIEW DEFINITION` but no `SELECT` on `sys.sql_expression_dependencies`* — fails fast with `Msg 229, Level 14, State 5: The SELECT permission was denied on the object 'sql_expression_dependencies', database 'mssqlsystemresource', schema 'sys'` the moment a database with a UDF-bound computed column or check constraint is reached. +- *No user mapping in the target database* — `sp_IndexCleanup` can hang at 100% CPU with no waits and never return, instead of failing fast with `Msg 916`. It's a SQL Server engine bug where a permission check at execute time is misclassified as "this plan needs to be recompiled," producing an infinite recompile loop. Reproduces on SQL Server 2016 SP3 through 2025 CU4. +- *User is mapped with `VIEW DATABASE STATE` + `VIEW DEFINITION` but no `SELECT` on `sys.sql_expression_dependencies`* — fails fast with `Msg 229, Level 14, State 5: The SELECT permission was denied on the object 'sql_expression_dependencies'` the moment a database with a UDF-bound computed column or check constraint is reached. -Adding all three grants above eliminates both. See issue [#915](https://github.com/erikdarlingdata/PerformanceMonitor/issues/915) for the full diagnosis. +Adding all three grants eliminates both. See issue [#915](https://github.com/erikdarlingdata/PerformanceMonitor/issues/915) for the full diagnosis. -### Azure SQL Database (Lite Only) +### Azure SQL Database (Lite / Darling) Azure SQL Database doesn't support server-level logins. Create a **contained database user** directly on the target database: @@ -667,7 +477,17 @@ CREATE USER [SQLServerPerfMon] WITH PASSWORD = 'YourStrongPassword'; GRANT VIEW DATABASE STATE TO [SQLServerPerfMon]; ``` -When connecting in Lite, specify the database name in the connection. SQL Agent and msdb are not available on Azure SQL Database — those collectors are skipped automatically. +For [Managed Identity or Service Principal](#authentication) authentication, create the contained user from the identity's display name instead of a password — an Azure AD admin must already be configured on the logical server: + +```sql +-- Connect to your target database (not master) +CREATE USER [your-managed-identity-or-app-registration-name] FROM EXTERNAL PROVIDER; +GRANT VIEW DATABASE STATE TO [your-managed-identity-or-app-registration-name]; +``` + +For a large fleet, grant an Entra **group** instead of provisioning each identity individually where your architecture allows it. SQL Agent and msdb are not available on Azure SQL Database — those collectors are skipped automatically. + +> `VIEW DATABASE STATE` is what lets the `sys.dm_os_*` DMVs return server hardware inventory (CPU count, physical memory, socket and core topology) and memory metrics on each monitored database. If the contained user lacks it, edition, version, and storage still resolve from permission-free scalars, so the FinOps **Server Inventory** grid keeps the server's row and shows a non-alarming "Hardware Note" that hardware inventory is unavailable, instead of dropping the entire row (#1535). ### Azure SQL Managed Instance @@ -675,27 +495,52 @@ Works like on-premises. Use server-level logins with `VIEW SERVER STATE`. SQL Ag ### AWS RDS for SQL Server -Use the RDS master user for installation. The master user has the necessary permissions. For ongoing collection, `VIEW SERVER STATE` and msdb access work the same as on-premises, but `sp_configure` is not available (use RDS Parameter Groups instead — see above). +For ongoing collection, `VIEW SERVER STATE` and msdb access work the same as on-premises, but `sp_configure` is not available (use RDS Parameter Groups instead — see [Platform Notes](#platform-notes)). + +--- + +## Platform Notes + +### AWS RDS Parameter Group Configuration + +`sp_configure` is not available on AWS RDS for SQL Server. Features that depend on server-level configuration must be set through **AWS RDS Parameter Groups** instead. + +**Blocked process threshold** — Enables blocked-process-report collection (the richer XE-sourced blocking detail). Without it the blocked-process-report XE will not fire on RDS, but blocking is still captured by the always-on `dmv_blocking_snapshot` collector, so the blocking grid and block-chain viewer stay populated regardless. + +1. Open the [AWS RDS Console](https://console.aws.amazon.com/rds/) and navigate to **Parameter groups** +2. Create a new parameter group (or modify the one attached to your instance) — Family `sqlserver-ee-16.0` (or your edition/version), Type DB Parameter Group +3. Search for `blocked process threshold (s)` and set it to `5` (seconds) +4. Apply the parameter group to your RDS instance (may require a reboot if the parameter is static) +5. Verify: `SELECT c.name, c.value_in_use FROM sys.configurations AS c WHERE c.name = N'blocked process threshold (s)';` + +**Deadlocks** — No parameter group configuration is required. The SQL Server deadlock monitor runs automatically on all platforms, and the `xml_deadlock_report` Extended Event fires without any threshold setting. + +**Azure SQL Database** — The blocked process threshold is fixed at 20 seconds and cannot be changed. The `blocked_process_report` event fires automatically when blocking exceeds this duration. --- ## Folder Structure ``` -Monitor/ +PerformanceMonitor/ +│ +│ Lite Edition — standalone desktop app, nothing installed on server +├── Lite/ # Lite desktop application (WPF) +│ +│ Darling Edition — headless service + bundled Postgres + viewer +├── Darling/ # Service, storage, analysis, viewer (see Darling/README.md) │ -│ Full Edition (server-installed collectors + separate dashboard) -├── install/ # 59 SQL installation scripts -├── upgrades/ # Version-specific upgrade scripts -├── Installer/ # CLI installer for Full Edition database (C#) -├── Installer.Core/ # Shared installation library (CLI + Dashboard) -├── Dashboard/ # Full Edition dashboard application (WPF, includes installer) +│ Shared monitoring brain (collectors, alerting, analysis, MCP, UI) +├── PerformanceMonitor.*/ # Shared libraries used by every edition │ -│ Lite Edition (standalone desktop app, nothing installed on server) -├── Lite/ # Lite Edition desktop application (WPF) +│ Full Edition (deprecated) — server-installed collectors + separate dashboard +├── install/ # SQL installation scripts (Full edition) +├── upgrades/ # Version-specific upgrade scripts (Full edition) +├── Installer/ # CLI installer for the Full edition database (see Installer/README.md) +├── Installer.Core/ # Shared installation library (CLI + Dashboard) +├── Dashboard/ # Full edition dashboard app (see Dashboard/README.md) │ -│ Shared -└── README.md # This file +└── README.md # This file ``` --- @@ -705,13 +550,15 @@ Monitor/ All projects target .NET 10.0. ``` -# Full Edition Dashboard -dotnet build Dashboard/Dashboard.csproj - # Lite Edition dotnet build Lite/PerformanceMonitorLite.csproj -# CLI Installer (self-contained) +# Darling Edition (service + viewer) +dotnet build Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj +dotnet build Darling/PerformanceMonitor.Darling.Viewer/PerformanceMonitor.Darling.Viewer.csproj + +# Full Edition (deprecated) — Dashboard app + CLI installer +dotnet build Dashboard/Dashboard.csproj dotnet publish Installer/PerformanceMonitorInstaller.csproj -c Release ``` @@ -737,7 +584,7 @@ If you find the project valuable, you can also support continued development: | | | |---|---| | **Sponsor on GitHub** | [Become a sponsor](https://github.com/sponsors/erikdarlingdata) to fund new features, ongoing maintenance, and SQL Server version support. | -| **Consulting Services** | [Hire me](https://training.erikdarling.com/sqlconsulting) for hands-on consulting if you need help analyzing the data this tool collects? Want expert assistance fixing the issues it uncovers? | +| **Consulting Services** | [Hire me](https://training.erikdarling.com/sqlconsulting) for hands-on consulting if you need help analyzing the data this tool collects, or expert assistance fixing the issues it uncovers. | Neither sponsorship nor consulting is required — use the tool freely. diff --git a/install/09_collect_query_store.sql b/install/09_collect_query_store.sql index 7e812a913..ae41400c0 100644 --- a/install/09_collect_query_store.sql +++ b/install/09_collect_query_store.sql @@ -320,6 +320,11 @@ BEGIN Build list of databases where Query Store is actually enabled. Uses sys.database_query_store_options.actual_state instead of sys.databases.is_query_store_on, which can be out of sync on Azure SQL DB. + + actual_state is matched against the explicit usable set IN (1, 2, 4) — + READ_ONLY, READ_WRITE, READ_CAPTURE_SECONDARY — rather than the looser + "> 0", which also admits 3 = ERROR: an errored Query Store is not readable + and must not pass the "is QS usable" gate (0 = OFF). */ DECLARE @database_name sysname, @@ -345,6 +350,16 @@ BEGIN AND d.database_id > 4 AND d.is_read_only = 0 AND d.name <> N'PerformanceMonitor' + /*Default screen (#1565), mirroring the shared collectors: vendor management dbs, SSRS/DW + artifacts, system-name belt, and the DBA-convention tooling names.*/ + AND d.name NOT IN + ( + N'master', N'model', N'msdb', N'tempdb', + N'rdsadmin', N'gcloud_cloudsqladmin', + N'ReportServer', N'ReportServerTempDB', + N'DWConfiguration', N'DWDiagnostics', N'DWQueue', + N'DBAUtil', N'DBAUtils', N'Utility' + ) AND d.database_id < 32761 /*exclude contained AG system databases*/ AND ( @@ -369,6 +384,10 @@ BEGIN WHILE @@FETCH_STATUS = 0 BEGIN BEGIN TRY + /*readonly_reason bit 8 = read-only BECAUSE readable secondary replica: its QS content + is the replicated primary QS tables, not local activity - skip it (#1558). Catches RDS + read replicas and geo-secondaries the AG check above misses. Comment sits HERE, in the + static body: an apostrophe inside the N-literal below would terminate it.*/ SET @qs_check_sql = N' SELECT ' + QUOTENAME(@database_name, '''') + N' WHERE EXISTS @@ -376,7 +395,8 @@ BEGIN SELECT 1 FROM sys.database_query_store_options - WHERE actual_state > 0 + WHERE actual_state IN (1, 2, 4) + AND readonly_reason & 8 = 0 );'; DECLARE @qs_exec_sp nvarchar(256) = QUOTENAME(@database_name) + N'.sys.sp_executesql'; @@ -425,7 +445,15 @@ BEGIN END; /* - Collect Query Store data for this database + Collect Query Store data for this database. + + #1556 per-database OOM backstop: the main data SELECT below is capped at TOP (50000) with + ORDER BY rs.last_execution_time DESC, so a pathological cycle keeps only the newest 50k rows per + database instead of streaming an unbounded backlog (the shared collectors' MaxRowsPerDatabase, + mirrored here for the deprecated Dashboard proc). The plan-text dedupe is deliberately NOT + mirrored: the Dashboard "Download Plan" reads a plan by exact collection_id, so per-row NULLs + would break a real reader, and this proc COMPRESSes at rest and runs server-side — a different + failure mode than the streaming service collectors. */ SET @sql = N' @@ -441,7 +469,7 @@ BEGIN ON s.schema_id = o.schema_id WHERE o.type IN (''P'', ''FN'', ''FS'', ''FT'', ''TF''); - SELECT + SELECT TOP (50000) database_name = @database_name, query_id = p.query_id, plan_id = rs.plan_id, @@ -604,7 +632,8 @@ BEGIN ON qt.query_text_id = q.query_text_id LEFT JOIN #objects AS o ON q.object_id = o.object_id - WHERE rs.last_execution_time >= @cutoff_time;'; + WHERE rs.last_execution_time >= @cutoff_time + ORDER BY rs.last_execution_time DESC;'; IF @debug = 1 BEGIN diff --git a/install/47_create_reporting_views.sql b/install/47_create_reporting_views.sql index ffb986e3c..11fb55821 100644 --- a/install/47_create_reporting_views.sql +++ b/install/47_create_reporting_views.sql @@ -521,7 +521,9 @@ WITH COUNT_BIG(*) FROM collect.cpu_utilization_stats AS cus CROSS JOIN yesterday_boundary AS yb - WHERE cus.sqlserver_cpu_utilization >= 80 + /* Linux: total_cpu_utilization is NULL (#1048); fall back to the SQL-only figure. + Matches report.daily_summary.high_cpu_events so both surfaces agree. */ + WHERE ISNULL(cus.total_cpu_utilization, cus.sqlserver_cpu_utilization) >= 80 AND cus.collection_time >= yb.start_time AND cus.collection_time < yb.end_time ) @@ -556,7 +558,9 @@ WITH COUNT_BIG(*) FROM collect.cpu_utilization_stats AS cus CROSS JOIN today_boundary AS tb - WHERE cus.sqlserver_cpu_utilization >= 80 + /* Linux: total_cpu_utilization is NULL (#1048); fall back to the SQL-only figure. + Matches report.daily_summary.high_cpu_events so both surfaces agree. */ + WHERE ISNULL(cus.total_cpu_utilization, cus.sqlserver_cpu_utilization) >= 80 AND cus.collection_time >= tb.start_time ), memory_pressure_count = diff --git a/nuget.config b/nuget.config new file mode 100644 index 000000000..b9a0fe346 --- /dev/null +++ b/nuget.config @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Supply-chain hardening (security audit LOW-2): pin the ONLY package source to nuget.org and + map every package to it. This closes the dependency-substitution / dependency-confusion gap so + a restore can never silently pull a package (e.g. the tray dependency Hardcodet.NotifyIcon.Wpf) + from a machine-configured or injected feed. Combined with the committed packages.lock.json + (contentHash) + CI locked-mode restores, package identity is now pinned by both source and content. +--> +<configuration> + <packageSources> + <clear /> + <add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" /> + </packageSources> + <packageSourceMapping> + <packageSource key="nuget.org"> + <package pattern="*" /> + </packageSource> + </packageSourceMapping> +</configuration>